From 7b7b2860ed2c8b9b06291436bdaca94e373f3505 Mon Sep 17 00:00:00 2001 From: TomaszB Date: Tue, 23 Dec 2025 22:27:32 +0100 Subject: [PATCH 1/2] Add customizable map marker editor with drag-and-drop support: - Implement `MapMarkerEditorController` to allow marker placement on maps using percentage-based coordinates. - Add `MarkerShape` enum for defining marker shapes and associated properties. - Introduce database migration to support marker position, shape, and tags in `MapLocation`. - Update forms, controllers, and templates to integrate marker editing, tagging, and styling. - Enhance UI translations with marker-related labels and messages. --- .../map-marker-editor_controller.js | 216 ++++++++++++++++++ migrations/Version20251223212023.php | 77 +++++++ .../Backoffice/GameMapController.php | 18 +- src/Domain/Map/Entity/Enum/MarkerShape.php | 85 +++++++ src/Domain/Map/Entity/MapLocation.php | 85 ++++++- src/Domain/Map/Form/MapLocationType.php | 75 ++++-- .../StoryObject/Entity/Enum/TargetType.php | 3 + .../larp/map/location_modify.html.twig | 106 +++++++-- .../backoffice/larp/map/modify.html.twig | 3 +- translations/forms.en.yaml | 27 ++- translations/messages.en.yaml | 15 ++ 11 files changed, 640 insertions(+), 70 deletions(-) create mode 100644 assets/controllers/map-marker-editor_controller.js create mode 100644 migrations/Version20251223212023.php create mode 100644 src/Domain/Map/Entity/Enum/MarkerShape.php diff --git a/assets/controllers/map-marker-editor_controller.js b/assets/controllers/map-marker-editor_controller.js new file mode 100644 index 0000000..a0b99ea --- /dev/null +++ b/assets/controllers/map-marker-editor_controller.js @@ -0,0 +1,216 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Map Marker Editor Controller + * + * Allows placing a single marker on a map image using percentage-based coordinates. + * The marker can be dragged to reposition. + */ +export default class extends Controller { + static values = { + imageUrl: String, + positionX: { type: Number, default: 50 }, + positionY: { type: Number, default: 50 }, + shape: { type: String, default: 'dot' }, + color: { type: String, default: '#3388ff' } + }; + + static targets = ['container', 'positionX', 'positionY', 'shape', 'color', 'displayX', 'displayY']; + + connect() { + this.initMap(); + } + + disconnect() { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + } + + initMap() { + const container = this.containerTarget; + + // Create image element + this.imageElement = document.createElement('img'); + this.imageElement.src = this.imageUrlValue; + this.imageElement.style.cssText = 'max-width: 100%; max-height: 100%; object-fit: contain; display: block; margin: auto;'; + + // Create wrapper for image and marker + this.wrapper = document.createElement('div'); + this.wrapper.style.cssText = 'position: relative; display: inline-block; max-width: 100%; max-height: 100%;'; + this.wrapper.appendChild(this.imageElement); + + container.innerHTML = ''; + container.style.display = 'flex'; + container.style.alignItems = 'center'; + container.style.justifyContent = 'center'; + container.appendChild(this.wrapper); + + this.imageElement.onload = () => { + this.createMarker(); + this.updateMarkerPosition(); + this.setupClickHandler(); + }; + + // Handle resize + this.resizeObserver = new ResizeObserver(() => { + this.updateMarkerPosition(); + }); + this.resizeObserver.observe(container); + } + + createMarker() { + this.marker = document.createElement('div'); + this.marker.className = 'map-marker'; + this.marker.style.cssText = ` + position: absolute; + width: 24px; + height: 24px; + transform: translate(-50%, -50%); + cursor: grab; + z-index: 100; + pointer-events: auto; + `; + + this.updateMarkerAppearance(); + this.wrapper.appendChild(this.marker); + + // Setup drag handling + this.setupDragHandler(); + } + + updateMarkerAppearance() { + if (!this.marker) return; + + const shape = this.hasShapeTarget ? this.shapeTarget.value : this.shapeValue; + const color = this.hasColorTarget ? this.colorTarget.value : this.colorValue; + + // Create SVG based on shape + const svgContent = this.getSvgForShape(shape, color); + this.marker.innerHTML = svgContent; + } + + getSvgForShape(shape, color) { + const shapes = { + dot: ``, + circle: ``, + square: ``, + diamond: ``, + triangle: ``, + house: ``, + arrow_up: ``, + arrow_down: ``, + arrow_left: ``, + arrow_right: ``, + star: ``, + flag: ``, + pin: ``, + cross: `` + }; + + const shapeSvg = shapes[shape] || shapes.dot; + + return `${shapeSvg}`; + } + + updateMarkerPosition() { + if (!this.marker || !this.imageElement) return; + + const imgWidth = this.imageElement.offsetWidth; + const imgHeight = this.imageElement.offsetHeight; + + const x = (this.positionXValue / 100) * imgWidth; + const y = (this.positionYValue / 100) * imgHeight; + + this.marker.style.left = `${x}px`; + this.marker.style.top = `${y}px`; + + this.updateDisplays(); + } + + updateDisplays() { + if (this.hasDisplayXTarget) { + this.displayXTarget.textContent = this.positionXValue.toFixed(2); + } + if (this.hasDisplayYTarget) { + this.displayYTarget.textContent = this.positionYValue.toFixed(2); + } + } + + updateFormFields() { + if (this.hasPositionXTarget) { + this.positionXTarget.value = this.positionXValue.toFixed(4); + } + if (this.hasPositionYTarget) { + this.positionYTarget.value = this.positionYValue.toFixed(4); + } + } + + setupClickHandler() { + this.imageElement.style.cursor = 'crosshair'; + this.imageElement.addEventListener('click', (e) => { + const rect = this.imageElement.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 100; + const y = ((e.clientY - rect.top) / rect.height) * 100; + + this.positionXValue = Math.max(0, Math.min(100, x)); + this.positionYValue = Math.max(0, Math.min(100, y)); + + this.updateMarkerPosition(); + this.updateFormFields(); + }); + } + + setupDragHandler() { + let isDragging = false; + + this.marker.addEventListener('mousedown', (e) => { + isDragging = true; + this.marker.style.cursor = 'grabbing'; + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (!isDragging) return; + + const rect = this.imageElement.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 100; + const y = ((e.clientY - rect.top) / rect.height) * 100; + + this.positionXValue = Math.max(0, Math.min(100, x)); + this.positionYValue = Math.max(0, Math.min(100, y)); + + this.updateMarkerPosition(); + this.updateFormFields(); + }); + + document.addEventListener('mouseup', () => { + if (isDragging) { + isDragging = false; + this.marker.style.cursor = 'grab'; + } + }); + } + + // Actions + onShapeChange() { + this.updateMarkerAppearance(); + } + + onColorChange() { + this.updateMarkerAppearance(); + } + + centerMarker() { + this.positionXValue = 50; + this.positionYValue = 50; + this.updateMarkerPosition(); + this.updateFormFields(); + } + + fitImage() { + // Reset zoom/pan if we add that feature later + // For now, just center the marker + this.updateMarkerPosition(); + } +} diff --git a/migrations/Version20251223212023.php b/migrations/Version20251223212023.php new file mode 100644 index 0000000..9ee9fd7 --- /dev/null +++ b/migrations/Version20251223212023.php @@ -0,0 +1,77 @@ + Tag ManyToMany relationship + $this->addSql('CREATE TABLE map_location_tags (map_location_id UUID NOT NULL, tag_id UUID NOT NULL, PRIMARY KEY(map_location_id, tag_id))'); + $this->addSql('CREATE INDEX IDX_A892DB94E1E073CC ON map_location_tags (map_location_id)'); + $this->addSql('CREATE INDEX IDX_A892DB94BAD26311 ON map_location_tags (tag_id)'); + $this->addSql('COMMENT ON COLUMN map_location_tags.map_location_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN map_location_tags.tag_id IS \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE map_location_tags ADD CONSTRAINT FK_A892DB94E1E073CC FOREIGN KEY (map_location_id) REFERENCES map_location (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE map_location_tags ADD CONSTRAINT FK_A892DB94BAD26311 FOREIGN KEY (tag_id) REFERENCES tag (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // Add created_by_id to comment as nullable first + $this->addSql('ALTER TABLE comment ADD created_by_id UUID DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN comment.created_by_id IS \'(DC2Type:uuid)\''); + // Set existing comments' created_by to the author + $this->addSql('UPDATE comment SET created_by_id = author_id WHERE created_by_id IS NULL'); + // Now make it NOT NULL + $this->addSql('ALTER TABLE comment ALTER created_by_id SET NOT NULL'); + $this->addSql('ALTER TABLE comment ADD CONSTRAINT FK_9474526CB03A8386 FOREIGN KEY (created_by_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_9474526CB03A8386 ON comment (created_by_id)'); + + $this->addSql('ALTER TABLE larp_application_choice ALTER character_id DROP NOT NULL'); + + // Add position and shape columns to map_location as nullable first + $this->addSql('ALTER TABLE map_location ADD position_x NUMERIC(8, 4) DEFAULT NULL'); + $this->addSql('ALTER TABLE map_location ADD position_y NUMERIC(8, 4) DEFAULT NULL'); + $this->addSql('ALTER TABLE map_location ADD shape VARCHAR(50) DEFAULT NULL'); + // Set default values for existing records + $this->addSql("UPDATE map_location SET position_x = 50.0000, position_y = 50.0000, shape = 'dot' WHERE position_x IS NULL"); + // Now make columns NOT NULL + $this->addSql('ALTER TABLE map_location ALTER position_x SET NOT NULL'); + $this->addSql('ALTER TABLE map_location ALTER position_y SET NOT NULL'); + $this->addSql('ALTER TABLE map_location ALTER shape SET NOT NULL'); + $this->addSql('ALTER TABLE map_location DROP grid_coordinates'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE map_location_tags DROP CONSTRAINT IF EXISTS FK_A892DB94E1E073CC'); + $this->addSql('ALTER TABLE map_location_tags DROP CONSTRAINT IF EXISTS FK_A892DB94BAD26311'); + $this->addSql('DROP TABLE IF EXISTS map_location_tags'); + + $this->addSql('ALTER TABLE comment DROP CONSTRAINT IF EXISTS FK_9474526CB03A8386'); + $this->addSql('DROP INDEX IF EXISTS IDX_9474526CB03A8386'); + $this->addSql('ALTER TABLE comment DROP COLUMN IF EXISTS created_by_id'); + + // Add grid_coordinates back as nullable, populate, then make NOT NULL + $this->addSql('ALTER TABLE map_location ADD grid_coordinates JSON DEFAULT NULL'); + $this->addSql("UPDATE map_location SET grid_coordinates = '[]'::json WHERE grid_coordinates IS NULL"); + $this->addSql('ALTER TABLE map_location ALTER grid_coordinates SET NOT NULL'); + + $this->addSql('ALTER TABLE map_location DROP COLUMN IF EXISTS position_x'); + $this->addSql('ALTER TABLE map_location DROP COLUMN IF EXISTS position_y'); + $this->addSql('ALTER TABLE map_location DROP COLUMN IF EXISTS shape'); + + $this->addSql('ALTER TABLE larp_application_choice ALTER character_id SET NOT NULL'); + } +} diff --git a/src/Domain/Map/Controller/Backoffice/GameMapController.php b/src/Domain/Map/Controller/Backoffice/GameMapController.php index 6fa3cb7..53be45f 100755 --- a/src/Domain/Map/Controller/Backoffice/GameMapController.php +++ b/src/Domain/Map/Controller/Backoffice/GameMapController.php @@ -1,5 +1,7 @@ findByMap($map); - // Serialize locations for JavaScript with all required fields $locationsData = array_map(function (MapLocation $location) { + $tagNames = $location->getTags()->map(fn ($tag) => $tag->getTitle())->toArray(); + return [ 'id' => $location->getId()->toString(), 'name' => $location->getName(), - 'gridCoordinates' => $location->getGridCoordinates(), - 'color' => $location->getColor(), + 'positionX' => $location->getPositionX(), + 'positionY' => $location->getPositionY(), + 'shape' => $location->getShape()->value, + 'color' => $location->getEffectiveColor(), 'type' => $location->getType()?->value, 'capacity' => $location->getCapacity(), 'description' => $location->getDescription(), + 'tags' => $tagNames, ]; }, $locations); @@ -155,12 +161,6 @@ public function locationModify( $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - // Handle gridCoordinates JSON conversion - $coordinatesData = $form->get('gridCoordinates')->getData(); - if (is_string($coordinatesData)) { - $location->setGridCoordinates(json_decode($coordinatesData, true) ?? []); - } - $locationRepository->save($location); $this->addFlash('success', $this->translator->trans('success_save')); diff --git a/src/Domain/Map/Entity/Enum/MarkerShape.php b/src/Domain/Map/Entity/Enum/MarkerShape.php new file mode 100644 index 0000000..3744034 --- /dev/null +++ b/src/Domain/Map/Entity/Enum/MarkerShape.php @@ -0,0 +1,85 @@ + 'Dot', + self::CIRCLE => 'Circle', + self::SQUARE => 'Square', + self::DIAMOND => 'Diamond', + self::TRIANGLE => 'Triangle', + self::HOUSE => 'House', + self::ARROW_UP => 'Arrow Up', + self::ARROW_DOWN => 'Arrow Down', + self::ARROW_LEFT => 'Arrow Left', + self::ARROW_RIGHT => 'Arrow Right', + self::STAR => 'Star', + self::FLAG => 'Flag', + self::PIN => 'Pin', + self::CROSS => 'Cross', + }; + } + + public function getIcon(): string + { + return match ($this) { + self::DOT => 'bi-circle-fill', + self::CIRCLE => 'bi-circle', + self::SQUARE => 'bi-square-fill', + self::DIAMOND => 'bi-diamond-fill', + self::TRIANGLE => 'bi-triangle-fill', + self::HOUSE => 'bi-house-fill', + self::ARROW_UP => 'bi-arrow-up', + self::ARROW_DOWN => 'bi-arrow-down', + self::ARROW_LEFT => 'bi-arrow-left', + self::ARROW_RIGHT => 'bi-arrow-right', + self::STAR => 'bi-star-fill', + self::FLAG => 'bi-flag-fill', + self::PIN => 'bi-pin-map-fill', + self::CROSS => 'bi-x-lg', + }; + } + + public function getSvgPath(): string + { + return match ($this) { + self::DOT => 'M12,12m-8,0a8,8 0 1,0 16,0a8,8 0 1,0 -16,0', + self::CIRCLE => 'M12,12m-10,0a10,10 0 1,0 20,0a10,10 0 1,0 -20,0', + self::SQUARE => 'M2,2h20v20H2z', + self::DIAMOND => 'M12,2L22,12L12,22L2,12z', + self::TRIANGLE => 'M12,2L22,22H2z', + self::HOUSE => 'M12,2L2,10v12h8v-6h4v6h8V10z', + self::ARROW_UP => 'M12,2L22,14H16v8H8v-8H2z', + self::ARROW_DOWN => 'M12,22L2,10H8V2h8v8h6z', + self::ARROW_LEFT => 'M2,12L14,2v6h8v8h-8v6z', + self::ARROW_RIGHT => 'M22,12L10,22v-6H2V8h8V2z', + self::STAR => 'M12,2l3,6.5l7,1l-5,5l1.2,7L12,18l-6.2,3.5l1.2-7l-5-5l7-1z', + self::FLAG => 'M4,2v20h2v-8h12l-4-6l4-6z', + self::PIN => 'M12,2C8,2 5,5 5,9c0,5 7,13 7,13s7-8 7-13c0-4-3-7-7-7zm0,10c-1.7,0-3-1.3-3-3s1.3-3,3-3s3,1.3,3,3S13.7,12,12,12z', + self::CROSS => 'M4,8h6V2h4v6h6v4h-6v6h-4v-6H4z', + }; + } +} diff --git a/src/Domain/Map/Entity/MapLocation.php b/src/Domain/Map/Entity/MapLocation.php index 3a41172..a697998 100755 --- a/src/Domain/Map/Entity/MapLocation.php +++ b/src/Domain/Map/Entity/MapLocation.php @@ -1,13 +1,19 @@ */ + #[ORM\ManyToMany(targetEntity: Tag::class)] + #[ORM\JoinTable(name: 'map_location_tags')] + private Collection $tags; + #[ORM\Column(length: 255)] private string $name; - #[ORM\Column(type: Types::JSON)] - private array $gridCoordinates = []; + #[ORM\Column(type: Types::DECIMAL, precision: 8, scale: 4)] + private string $positionX = '0'; + + #[ORM\Column(type: Types::DECIMAL, precision: 8, scale: 4)] + private string $positionY = '0'; + + #[ORM\Column(length: 50, enumType: MarkerShape::class)] + private MarkerShape $shape = MarkerShape::DOT; #[ORM\Column(length: 7, nullable: true)] private ?string $color = null; @@ -50,6 +67,7 @@ class MapLocation implements Timestampable, CreatorAwareInterface, \Stringable public function __construct() { $this->id = Uuid::v4(); + $this->tags = new ArrayCollection(); } public function getMap(): ?GameMap @@ -74,6 +92,30 @@ public function setPlace(?Place $place): self return $this; } + /** + * @return Collection + */ + public function getTags(): Collection + { + return $this->tags; + } + + public function addTag(Tag $tag): self + { + if (!$this->tags->contains($tag)) { + $this->tags->add($tag); + } + + return $this; + } + + public function removeTag(Tag $tag): self + { + $this->tags->removeElement($tag); + + return $this; + } + public function getName(): string { return $this->name; @@ -85,14 +127,36 @@ public function setName(string $name): self return $this; } - public function getGridCoordinates(): array + public function getPositionX(): float { - return $this->gridCoordinates; + return (float) $this->positionX; } - public function setGridCoordinates(array $gridCoordinates): self + public function setPositionX(float $positionX): self { - $this->gridCoordinates = $gridCoordinates; + $this->positionX = (string) $positionX; + return $this; + } + + public function getPositionY(): float + { + return (float) $this->positionY; + } + + public function setPositionY(float $positionY): self + { + $this->positionY = (string) $positionY; + return $this; + } + + public function getShape(): MarkerShape + { + return $this->shape; + } + + public function setShape(MarkerShape $shape): self + { + $this->shape = $shape; return $this; } @@ -145,8 +209,13 @@ public function __toString(): string return $this->name; } - public function getGridCoordinatesString(): string + public function getEffectiveColor(): string + { + return $this->color ?? '#3388ff'; + } + + public function getEffectiveShape(): MarkerShape { - return implode(', ', $this->gridCoordinates); + return $this->shape; } } diff --git a/src/Domain/Map/Form/MapLocationType.php b/src/Domain/Map/Form/MapLocationType.php index 81fe8f1..6aef980 100755 --- a/src/Domain/Map/Form/MapLocationType.php +++ b/src/Domain/Map/Form/MapLocationType.php @@ -1,9 +1,13 @@ 'map_location.name_placeholder', ], ]) - ->add('gridCoordinates', HiddenType::class, [ - 'label' => 'map_location.grid_coordinates', - 'help' => 'map_location.grid_coordinates_help', + ->add('positionX', HiddenType::class, [ + 'attr' => [ + 'data-map-marker-editor-target' => 'positionX', + ], + ]) + ->add('positionY', HiddenType::class, [ 'attr' => [ - 'data-map-location-target' => 'coordinates', + 'data-map-marker-editor-target' => 'positionY', + ], + ]) + ->add('shape', EnumType::class, [ + 'label' => 'map_location.shape', + 'class' => MarkerShape::class, + 'choice_label' => fn (MarkerShape $shape) => 'enum.marker_shape.' . $shape->value, + 'attr' => [ + 'data-map-marker-editor-target' => 'shape', + 'data-action' => 'change->map-marker-editor#onShapeChange', ], ]) ->add('type', EnumType::class, [ @@ -49,6 +65,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'map_location.color', 'required' => false, 'help' => 'map_location.color_help', + 'attr' => [ + 'data-map-marker-editor-target' => 'color', + 'data-action' => 'change->map-marker-editor#onColorChange', + ], ]) ->add('capacity', IntegerType::class, [ 'label' => 'map_location.capacity', @@ -73,25 +93,36 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ; if ($larp) { - $builder->add('place', EntityType::class, [ - 'label' => 'map_location.place', - 'choice_label' => 'title', - 'class' => Place::class, - 'required' => false, - 'placeholder' => 'map_location.place_placeholder', - 'help' => 'map_location.place_help', - 'query_builder' => function ($repository) use ($larp) { - return $repository->createQueryBuilder('p') - ->where('p.larp = :larp') + $builder + ->add('tags', EntityType::class, [ + 'label' => 'map_location.tags', + 'choice_label' => 'title', + 'class' => Tag::class, + 'required' => false, + 'multiple' => true, + 'autocomplete' => true, + 'help' => 'map_location.tags_help', + 'query_builder' => fn (TagRepository $repo) => $repo->createQueryBuilder('t') + ->where('t.larp = :larp') ->setParameter('larp', $larp) - ->orderBy('p.title', 'ASC'); - }, - 'autocomplete' => true, - ]); + ->orderBy('t.title', 'ASC'), + ]) + ->add('place', EntityType::class, [ + 'label' => 'map_location.place', + 'choice_label' => 'title', + 'class' => Place::class, + 'required' => false, + 'placeholder' => 'map_location.place_placeholder', + 'help' => 'map_location.place_help', + 'query_builder' => function ($repository) use ($larp) { + return $repository->createQueryBuilder('p') + ->where('p.larp = :larp') + ->setParameter('larp', $larp) + ->orderBy('p.title', 'ASC'); + }, + 'autocomplete' => true, + ]); } - - $builder->get('gridCoordinates') - ->addModelTransformer(new JsonToArrayTransformer()); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Domain/StoryObject/Entity/Enum/TargetType.php b/src/Domain/StoryObject/Entity/Enum/TargetType.php index c2dfbdf..8a07e8b 100755 --- a/src/Domain/StoryObject/Entity/Enum/TargetType.php +++ b/src/Domain/StoryObject/Entity/Enum/TargetType.php @@ -36,6 +36,7 @@ enum TargetType: string case Place = 'place'; // a location in the larp world, can be a place of interest, a quest location, etc. case Relation = 'relation'; // describes relation between players/factions, can be anything starting from friendship, family to rivalry case Tag = 'tag'; + case MapLocation = 'map_location'; // a location on a game map, used for tagging map markers //Both storyline -> threads -> events and quests can have a decision tree public function getEntityClass(): string @@ -50,6 +51,8 @@ public function getEntityClass(): string self::Item => Item::class, self::Place => Place::class, self::Tag => Tag::class, + // Use FQCN string to avoid cross-domain import + self::MapLocation => 'App\\Domain\\Map\\Entity\\MapLocation', }; } diff --git a/templates/backoffice/larp/map/location_modify.html.twig b/templates/backoffice/larp/map/location_modify.html.twig index 46ccf00..6b56f02 100755 --- a/templates/backoffice/larp/map/location_modify.html.twig +++ b/templates/backoffice/larp/map/location_modify.html.twig @@ -1,5 +1,7 @@ {% extends 'backoffice/larp/base.html.twig' %} +{% import 'macros/ui_components.html.twig' as ui %} + {% block larp_title %}{{ 'larp.map.location_modify'|trans }} - {{ larp.title }}{% endblock %} {% block larp_content %} @@ -15,33 +17,89 @@

{{ 'larp.map.on_map'|trans }}: {{ map.name }}

- {% if map.imageFile %} -
-
-
- - {{ 'larp.map.location_select_help'|trans }} -
+ {{ form_start(form) }} + +
+
+
{{ 'larp.map.location_info'|trans }}
+ + {{ form_row(form.name) }} + {{ form_row(form.shape) }} + {{ form_row(form.color) }} + {{ form_row(form.type) }} + {% if form.tags is defined %} + {{ form_row(form.tags) }} + {% endif %} + {{ form_row(form.capacity) }} + {% if form.place is defined %} + {{ form_row(form.place) }} + {% endif %} + {{ form_row(form.description) }}
- {% endif %} - {{ form_start(form) }} - {{ form_widget(form) }} -
- - - {{ 'cancel'|trans }} - +
+
+ {{ 'larp.map.marker_position'|trans }} +
+ + {% if map.imageFile %} +
+ +
+
+ +
+ + {{ 'larp.map.click_to_place_marker'|trans }} +
+ +
+ + +
+ +
+
+ X: {{ location.positionX|default(50)|number_format(2) }}% +
+
+ Y: {{ location.positionY|default(50)|number_format(2) }}% +
+
+
+ {% else %} +
+ + {{ 'larp.map.no_image_uploaded'|trans }} +
+ {% endif %} + + {{ form_widget(form.positionX) }} + {{ form_widget(form.positionY) }} +
+ + {{ ui.form_actions( + isNew ? 'create' : 'save', + 'bi-check-circle', + path('backoffice_larp_map_view', { larp: larp.id, map: map.id }) + ) }} + {{ form_end(form) }}
diff --git a/templates/backoffice/larp/map/modify.html.twig b/templates/backoffice/larp/map/modify.html.twig index d68bb54..d51f767 100755 --- a/templates/backoffice/larp/map/modify.html.twig +++ b/templates/backoffice/larp/map/modify.html.twig @@ -192,7 +192,8 @@ 'bi-check-circle', path('backoffice_larp_map_list', { larp: larp.id }) ) }} - {{ form_end(form) }} + {{ form_widget(form._token) }} + {{ form_end(form, {'render_rest': false}) }}
{% endblock %} diff --git a/translations/forms.en.yaml b/translations/forms.en.yaml index 2b8663b..2d0101e 100755 --- a/translations/forms.en.yaml +++ b/translations/forms.en.yaml @@ -210,21 +210,21 @@ game_map: map_location: name: "Location Name" name_placeholder: "Enter location name (e.g., Main Hall, Forest Clearing)" - grid_coordinates: "Grid Coordinates" - grid_coordinates_help: "Click on grid cells in the map above to select this location's area" + shape: "Marker Shape" type: "Location Type" type_placeholder: "Select location type..." - color: "Location Color" - color_help: "Color used to mark this location on the map" + color: "Marker Color" + color_help: "Color used to display this marker on the map (overrides tag color if set)" capacity: "Capacity" capacity_help: "Maximum number of people this location can hold (optional)" place: "Linked Story Place" place_placeholder: "Link to existing story place (optional)" place_help: "Associate this map location with a story place entity" - description: 'Click in grid to select location' + tags: "Tags" + tags_help: "Categorize this location with tags" + description: "Description" description_placeholder: "Describe this location and its purpose" - # Event Planner planning_resource: name: 'Resource Name' @@ -360,6 +360,21 @@ enum: outdoor: "Outdoor" special: "Special" transition: "Transition" + marker_shape: + dot: "Dot" + circle: "Circle" + square: "Square" + diamond: "Diamond" + triangle: "Triangle" + house: "House" + arrow_up: "Arrow Up" + arrow_down: "Arrow Down" + arrow_left: "Arrow Left" + arrow_right: "Arrow Right" + star: "Star" + flag: "Flag" + pin: "Pin" + cross: "Cross" gallery: title: "Gallery Title" title_placeholder: "e.g., LARP Photos - Day 1" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 115e2a9..80990a5 100755 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -476,6 +476,21 @@ larp: no_image: 'No map image uploaded' locations: 'Map Locations' location_select_help: 'Click on grid cells to select the area for this location' + location_info: 'Location Information' + marker_position: 'Marker Position' + click_to_place_marker: 'Click on the map to place the marker. Drag to reposition.' + center_marker: 'Center Marker' + fit_image: 'Fit to View' + no_image_uploaded: 'No map image has been uploaded yet. Please upload an image first.' + tag: + singular: 'Map Tag' + plural: 'Map Tags' + list: 'Map Tags' + create: 'Create Map Tag' + edit: 'Edit Map Tag' + default_shape: 'Default Shape' + locations_count: 'Locations' + preview: 'Tag Preview' google_drive_folders: title: "Google Drive Folders" From 765127c3444cfd36129522c9d883ba56c33eddca Mon Sep 17 00:00:00 2001 From: TomaszB Date: Wed, 7 Jan 2026 21:08:54 +0100 Subject: [PATCH 2/2] Add map position management system: - Introduced `GameMapFactory` for creating customizable game maps with preset configurations (small, large, custom grids, etc.). - Added `MapLocationTest` for validating grid coordinate calculations, edge cases, and position handling. - Developed Twig templates (`position_index.html.twig`, `position_update.html.twig`, `position_view.html.twig`) for user map interaction, position updates, and view display. - Implemented `staff-position-update_controller.js` with grid drawing, cell selection, and position updates. - Enhanced UI with translations and responsive layouts for map-based features. --- CLAUDE.md | 105 ++++ assets/controllers/leaflet-map_controller.js | 204 +++++-- .../map-marker-editor_controller.js | 228 ++++---- .../staff-position-update_controller.js | 340 ++++++++++++ .../staff-position-view_controller.js | 232 ++++++++ config/routes.yaml | 10 + migrations/Version20251224083715.php | 40 ++ .../API/StaffPositionController.php | 156 ++++++ .../Backoffice/GameMapController.php | 36 +- .../Participant/StaffPositionController.php | 161 ++++++ src/Domain/Map/Entity/MapLocation.php | 27 + src/Domain/Map/Entity/StaffPosition.php | 150 ++++++ .../Repository/StaffPositionRepository.php | 85 +++ .../Map/Security/Voter/StaffPositionVoter.php | 82 +++ .../Map/Service/StaffPositionService.php | 230 ++++++++ templates/backoffice/larp/_menu.html.twig | 8 + templates/backoffice/larp/map/list.html.twig | 12 +- .../larp/map/location_modify.html.twig | 71 +-- templates/backoffice/larp/map/view.html.twig | 98 +++- .../participant/map/position_index.html.twig | 44 ++ .../participant/map/position_update.html.twig | 135 +++++ .../participant/map/position_view.html.twig | 109 ++++ tests/Functional/Map/StaffPositionCest.php | 506 ++++++++++++++++++ tests/Support/Factory/Core/LarpFactory.php | 2 + tests/Support/Factory/Map/GameMapFactory.php | 118 ++++ .../Factory/Map/StaffPositionFactory.php | 154 ++++++ tests/Unit/Domain/Map/MapLocationTest.php | 80 +++ translations/messages.en.yaml | 51 ++ 28 files changed, 3277 insertions(+), 197 deletions(-) create mode 100644 assets/controllers/staff-position-update_controller.js create mode 100644 assets/controllers/staff-position-view_controller.js create mode 100644 migrations/Version20251224083715.php create mode 100644 src/Domain/Map/Controller/API/StaffPositionController.php create mode 100644 src/Domain/Map/Controller/Participant/StaffPositionController.php create mode 100644 src/Domain/Map/Entity/StaffPosition.php create mode 100644 src/Domain/Map/Repository/StaffPositionRepository.php create mode 100644 src/Domain/Map/Security/Voter/StaffPositionVoter.php create mode 100644 src/Domain/Map/Service/StaffPositionService.php create mode 100644 templates/participant/map/position_index.html.twig create mode 100644 templates/participant/map/position_update.html.twig create mode 100644 templates/participant/map/position_view.html.twig create mode 100644 tests/Functional/Map/StaffPositionCest.php create mode 100644 tests/Support/Factory/Map/GameMapFactory.php create mode 100644 tests/Support/Factory/Map/StaffPositionFactory.php create mode 100644 tests/Unit/Domain/Map/MapLocationTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 781da08..df6def9 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -668,6 +668,111 @@ Test database uses suffix `_test` (configured in `config/packages/doctrine.yaml` make prepare-test-db ``` +### Testing Patterns Quick Reference + +**User Creation (via Factory):** +```php +use Tests\Support\Factory\Account\UserFactory; + +$user = UserFactory::createPendingUser(); // PENDING status +$user = UserFactory::createApprovedUser(); // APPROVED status +$user = UserFactory::createSuperAdmin(); // SUPER_ADMIN role +``` + +**LARP Creation (via Factory):** +```php +use Tests\Support\Factory\Core\LarpFactory; + +$larp = LarpFactory::new()->create(); // Default LARP +$larp = LarpFactory::createDraftLarp($organizer); // DRAFT status +$larp = LarpFactory::createPublishedLarp($organizer); // PUBLISHED status +``` + +**Participant Roles (via Factory):** +```php +use Tests\Support\Factory\Core\LarpParticipantFactory; + +LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($user) + ->player() // PLAYER role + ->create(); + +// Available role methods: +// ->player(), ->organizer(), ->staff(), ->gameMaster(), +// ->trustPerson(), ->photographer(), ->medic() +``` + +**Access Control Testing Pattern:** +```php +public function roleCannotAccessFeature(FunctionalTester $I): void +{ + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($user) + ->player() + ->create(); + + $I->amLoggedInAs($user); + $I->amOnRoute('route_name', ['larp' => $larp->getId()]); + $I->seeResponseCodeIs(403); +} +``` + +**Voter Testing Pattern:** +```php +public function voterGrantsPermission(FunctionalTester $I): void +{ + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($user) + ->organizer() + ->create(); + + $I->amLoggedInAs($user); + + $authChecker = $I->grabService('security.authorization_checker'); + $canAccess = $authChecker->isGranted('VOTER_ATTRIBUTE', $larp); + + $I->assertTrue($canAccess, 'Organizer should have access'); +} +``` + +**Service Testing Pattern:** +```php +public function serviceMethodWorks(FunctionalTester $I): void +{ + // Setup test data with factories + $larp = LarpFactory::new()->create(); + $participant = LarpParticipantFactory::new() + ->forLarp($larp) + ->organizer() + ->create(); + + /** @var MyService $service */ + $service = $I->grabService(MyService::class); + + // Call service method (use ->_real() to get actual entity from proxy) + $result = $service->doSomething($participant->_real()); + + // Assert results + $I->assertNotNull($result); +} +``` + +**Available Assertions:** +- `$I->seeResponseCodeIsSuccessful()` - 2xx status codes +- `$I->seeResponseCodeIs(403)` - Specific status code +- `$I->seeResponseCodeIsRedirection()` - 3xx status codes +- `$I->followRedirect()` - Follow redirect +- `$I->assertTrue($condition, $message)` / `$I->assertFalse(...)` +- `$I->assertEquals($expected, $actual)` / `$I->assertNotNull(...)` +- `$I->assertCount($expected, $array)` + ## Code Quality Standards - **PHP Version**: 8.2+ diff --git a/assets/controllers/leaflet-map_controller.js b/assets/controllers/leaflet-map_controller.js index aa06e80..f243e95 100755 --- a/assets/controllers/leaflet-map_controller.js +++ b/assets/controllers/leaflet-map_controller.js @@ -9,10 +9,12 @@ export default class extends Controller { gridColumns: Number, gridOpacity: Number, gridVisible: Boolean, - locations: Array + locations: Array, + staffPositions: Array }; connect() { + this.staffPositionsLayer = null; this.initMap(); } @@ -27,6 +29,8 @@ export default class extends Controller { img.src = this.imageUrlValue; img.onload = () => { + this.imageWidth = img.width; + this.imageHeight = img.height; const bounds = [[0, 0], [img.height, img.width]]; this.map = L.map('map', { @@ -48,9 +52,23 @@ export default class extends Controller { // Add location markers this.addLocationMarkers(img.width, img.height); + + // Add staff position markers if present + if (this.hasStaffPositionsValue && this.staffPositionsValue.length > 0) { + this.addStaffPositionMarkers(); + } }; } + toggleStaffPositions(event) { + if (event.target.checked) { + this.addStaffPositionMarkers(); + } else if (this.staffPositionsLayer) { + this.staffPositionsLayer.remove(); + this.staffPositionsLayer = null; + } + } + drawGrid(width, height) { const rows = this.gridRowsValue; const cols = this.gridColumnsValue; @@ -102,66 +120,75 @@ export default class extends Controller { return; } - const cellWidth = width / this.gridColumnsValue; - const cellHeight = height / this.gridRowsValue; - this.locationsValue.forEach(location => { - if (!location.gridCoordinates || location.gridCoordinates.length === 0) { - return; - } - const markerColor = location.color || '#3388ff'; + const shape = location.shape || 'dot'; - // Highlight each grid cell for this location - let totalX = 0, totalY = 0; - location.gridCoordinates.forEach(coord => { - const { row, col } = this.parseCellCoordinate(coord); - - // Calculate cell bounds - const x1 = col * cellWidth; - const y1 = row * cellHeight; - const x2 = x1 + cellWidth; - const y2 = y1 + cellHeight; - - // Draw highlighted rectangle for this cell - L.rectangle([[y1, x1], [y2, x2]], { - color: markerColor, - fillColor: markerColor, - fillOpacity: 0.3, - weight: 2 - }).addTo(this.map); - - // Accumulate center coordinates - totalX += col * cellWidth + cellWidth / 2; - totalY += row * cellHeight + cellHeight / 2; - }); - - const centerX = totalX / location.gridCoordinates.length; - const centerY = totalY / location.gridCoordinates.length; + // Convert percentage position to pixel coordinates + const x = (location.positionX / 100) * width; + const y = (location.positionY / 100) * height; - // Create marker with custom icon at the center - const marker = L.marker([centerY, centerX], { - icon: L.divIcon({ - className: 'location-marker', - html: `
`, - iconSize: [20, 20] - }) + // Create marker with shaped icon + const marker = L.marker([y, x], { + icon: this.createShapedIcon(shape, markerColor) }).addTo(this.map); // Add popup with location info - let popupContent = `${location.name}
`; + let popupContent = `${location.name}`; if (location.type) { - popupContent += `Type: ${location.type}
`; + popupContent += `
Type: ${location.type}`; } if (location.capacity) { - popupContent += `Capacity: ${location.capacity}
`; + popupContent += `
Capacity: ${location.capacity}`; + } + if (location.description) { + popupContent += `
${location.description}`; } - popupContent += `Cells: ${location.gridCoordinates.join(', ')}`; marker.bindPopup(popupContent); }); } + createShapedIcon(shape, color) { + const svgContent = this.getSvgForShape(shape, color); + + return L.divIcon({ + className: 'location-marker-icon', + html: `
${svgContent}
`, + iconSize: [28, 28], + iconAnchor: [14, 14] + }); + } + + getSvgForShape(shape, color) { + const shapes = { + dot: ``, + circle: ``, + square: ``, + diamond: ``, + triangle: ``, + house: ``, + arrow_up: ``, + arrow_down: ``, + arrow_left: ``, + arrow_right: ``, + star: ``, + flag: ``, + pin: ``, + cross: `` + }; + + const shapeSvg = shapes[shape] || shapes.dot; + return `${shapeSvg}`; + } + getCellLabel(row, col) { const letter = String.fromCharCode(65 + col); // A, B, C, ... return `${letter}${row + 1}`; @@ -178,4 +205,91 @@ export default class extends Controller { const row = parseInt(match[2]) - 1; return { row, col }; } + + addStaffPositionMarkers() { + if (!this.staffPositionsValue || this.staffPositionsValue.length === 0) { + return; + } + + // Remove existing layer if any + if (this.staffPositionsLayer) { + this.staffPositionsLayer.remove(); + } + + this.staffPositionsLayer = L.layerGroup().addTo(this.map); + + const cellWidth = this.imageWidth / this.gridColumnsValue; + const cellHeight = this.imageHeight / this.gridRowsValue; + + this.staffPositionsValue.forEach(position => { + const { row, col } = this.parseCellCoordinate(position.gridCell); + const centerX = col * cellWidth + cellWidth / 2; + const centerY = row * cellHeight + cellHeight / 2; + + // Determine marker color based on role + const color = this.getRoleColor(position.roles); + + // Create marker + const marker = L.marker([centerY, centerX], { + icon: L.divIcon({ + className: 'staff-position-marker', + html: ` +
+ +
+ `, + iconSize: [28, 28], + iconAnchor: [14, 14] + }) + }).addTo(this.staffPositionsLayer); + + // Create popup content + let popupContent = ` +
+ ${position.participantName}
+ + ${position.roles.map(r => r.replace('ROLE_', '').toLowerCase()).join(', ')} +
+ ${position.gridCell} + ${position.statusNote ? `
${position.statusNote}` : ''} +
Updated: ${position.updatedAt} +
+ `; + + marker.bindPopup(popupContent); + }); + } + + getRoleColor(roles) { + // Color coding by role priority + if (roles.includes('ROLE_ORGANIZER')) { + return '#dc3545'; // Red - main organizer + } + if (roles.includes('ROLE_PERSON_OF_TRUST')) { + return '#28a745'; // Green - trust person + } + if (roles.includes('ROLE_PHOTOGRAPHER')) { + return '#17a2b8'; // Cyan - photographer + } + if (roles.includes('ROLE_MEDIC')) { + return '#ffc107'; // Yellow - medic + } + if (roles.includes('ROLE_GAME_MASTER')) { + return '#6f42c1'; // Purple - game master + } + if (roles.includes('ROLE_STAFF')) { + return '#fd7e14'; // Orange - staff + } + return '#6c757d'; // Gray - other + } } diff --git a/assets/controllers/map-marker-editor_controller.js b/assets/controllers/map-marker-editor_controller.js index a0b99ea..95fbe88 100644 --- a/assets/controllers/map-marker-editor_controller.js +++ b/assets/controllers/map-marker-editor_controller.js @@ -1,10 +1,12 @@ import { Controller } from '@hotwired/stimulus'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.min.css'; /** - * Map Marker Editor Controller + * Map Marker Editor Controller (Leaflet version) * * Allows placing a single marker on a map image using percentage-based coordinates. - * The marker can be dragged to reposition. + * Uses Leaflet for professional map interactions with zoom/pan. */ export default class extends Controller { static values = { @@ -22,78 +24,96 @@ export default class extends Controller { } disconnect() { - if (this.resizeObserver) { - this.resizeObserver.disconnect(); + if (this.map) { + this.map.remove(); } } initMap() { const container = this.containerTarget; - // Create image element - this.imageElement = document.createElement('img'); - this.imageElement.src = this.imageUrlValue; - this.imageElement.style.cssText = 'max-width: 100%; max-height: 100%; object-fit: contain; display: block; margin: auto;'; - - // Create wrapper for image and marker - this.wrapper = document.createElement('div'); - this.wrapper.style.cssText = 'position: relative; display: inline-block; max-width: 100%; max-height: 100%;'; - this.wrapper.appendChild(this.imageElement); - + // Create map div + this.mapDiv = document.createElement('div'); + this.mapDiv.style.cssText = 'width: 100%; height: 100%;'; container.innerHTML = ''; - container.style.display = 'flex'; - container.style.alignItems = 'center'; - container.style.justifyContent = 'center'; - container.appendChild(this.wrapper); - - this.imageElement.onload = () => { + container.appendChild(this.mapDiv); + + // Load image to get dimensions + const img = new Image(); + img.src = this.imageUrlValue; + + img.onload = () => { + this.imageWidth = img.width; + this.imageHeight = img.height; + const bounds = [[0, 0], [img.height, img.width]]; + + // Initialize Leaflet map + this.map = L.map(this.mapDiv, { + crs: L.CRS.Simple, + minZoom: -3, + maxZoom: 3, + center: [img.height / 2, img.width / 2], + zoom: 0 + }); + + // Add image overlay + L.imageOverlay(this.imageUrlValue, bounds).addTo(this.map); + this.map.fitBounds(bounds); + + // Create the marker this.createMarker(); - this.updateMarkerPosition(); - this.setupClickHandler(); - }; - // Handle resize - this.resizeObserver = new ResizeObserver(() => { - this.updateMarkerPosition(); - }); - this.resizeObserver.observe(container); + // Setup click handler on map + this.map.on('click', (e) => this.onMapClick(e)); + }; } createMarker() { - this.marker = document.createElement('div'); - this.marker.className = 'map-marker'; - this.marker.style.cssText = ` - position: absolute; - width: 24px; - height: 24px; - transform: translate(-50%, -50%); - cursor: grab; - z-index: 100; - pointer-events: auto; - `; + const shape = this.hasShapeTarget ? this.shapeTarget.value : this.shapeValue; + const color = this.hasColorTarget ? this.colorTarget.value : this.colorValue; - this.updateMarkerAppearance(); - this.wrapper.appendChild(this.marker); + // Convert percentage to pixel coordinates + const x = (this.positionXValue / 100) * this.imageWidth; + const y = (this.positionYValue / 100) * this.imageHeight; - // Setup drag handling - this.setupDragHandler(); - } + // Create custom icon + const icon = this.createMarkerIcon(shape, color); - updateMarkerAppearance() { - if (!this.marker) return; + // Create draggable marker + this.marker = L.marker([y, x], { + icon: icon, + draggable: true + }).addTo(this.map); - const shape = this.hasShapeTarget ? this.shapeTarget.value : this.shapeValue; - const color = this.hasColorTarget ? this.colorTarget.value : this.colorValue; + // Handle drag events + this.marker.on('drag', (e) => this.onMarkerDrag(e)); + this.marker.on('dragend', (e) => this.onMarkerDrag(e)); - // Create SVG based on shape + this.updateDisplays(); + } + + createMarkerIcon(shape, color) { const svgContent = this.getSvgForShape(shape, color); - this.marker.innerHTML = svgContent; + + return L.divIcon({ + className: 'map-marker-editor-icon', + html: `
${svgContent}
`, + iconSize: [32, 32], + iconAnchor: [16, 16] + }); } getSvgForShape(shape, color) { const shapes = { dot: ``, - circle: ``, + circle: ``, square: ``, diamond: ``, triangle: ``, @@ -104,7 +124,7 @@ export default class extends Controller { arrow_right: ``, star: ``, flag: ``, - pin: ``, + pin: ``, cross: `` }; @@ -113,18 +133,44 @@ export default class extends Controller { return `${shapeSvg}`; } - updateMarkerPosition() { - if (!this.marker || !this.imageElement) return; + updateMarkerAppearance() { + if (!this.marker) return; + + const shape = this.hasShapeTarget ? this.shapeTarget.value : this.shapeValue; + const color = this.hasColorTarget ? this.colorTarget.value : this.colorValue; + + const icon = this.createMarkerIcon(shape, color); + this.marker.setIcon(icon); + } + + onMapClick(e) { + // Convert pixel coordinates to percentage + const x = (e.latlng.lng / this.imageWidth) * 100; + const y = (e.latlng.lat / this.imageHeight) * 100; - const imgWidth = this.imageElement.offsetWidth; - const imgHeight = this.imageElement.offsetHeight; + this.positionXValue = Math.max(0, Math.min(100, x)); + this.positionYValue = Math.max(0, Math.min(100, y)); - const x = (this.positionXValue / 100) * imgWidth; - const y = (this.positionYValue / 100) * imgHeight; + // Move marker to new position + const newLat = (this.positionYValue / 100) * this.imageHeight; + const newLng = (this.positionXValue / 100) * this.imageWidth; + this.marker.setLatLng([newLat, newLng]); - this.marker.style.left = `${x}px`; - this.marker.style.top = `${y}px`; + this.updateFormFields(); + this.updateDisplays(); + } + + onMarkerDrag(e) { + const latlng = e.target.getLatLng(); + // Convert pixel coordinates to percentage + const x = (latlng.lng / this.imageWidth) * 100; + const y = (latlng.lat / this.imageHeight) * 100; + + this.positionXValue = Math.max(0, Math.min(100, x)); + this.positionYValue = Math.max(0, Math.min(100, y)); + + this.updateFormFields(); this.updateDisplays(); } @@ -146,53 +192,7 @@ export default class extends Controller { } } - setupClickHandler() { - this.imageElement.style.cursor = 'crosshair'; - this.imageElement.addEventListener('click', (e) => { - const rect = this.imageElement.getBoundingClientRect(); - const x = ((e.clientX - rect.left) / rect.width) * 100; - const y = ((e.clientY - rect.top) / rect.height) * 100; - - this.positionXValue = Math.max(0, Math.min(100, x)); - this.positionYValue = Math.max(0, Math.min(100, y)); - - this.updateMarkerPosition(); - this.updateFormFields(); - }); - } - - setupDragHandler() { - let isDragging = false; - - this.marker.addEventListener('mousedown', (e) => { - isDragging = true; - this.marker.style.cursor = 'grabbing'; - e.preventDefault(); - }); - - document.addEventListener('mousemove', (e) => { - if (!isDragging) return; - - const rect = this.imageElement.getBoundingClientRect(); - const x = ((e.clientX - rect.left) / rect.width) * 100; - const y = ((e.clientY - rect.top) / rect.height) * 100; - - this.positionXValue = Math.max(0, Math.min(100, x)); - this.positionYValue = Math.max(0, Math.min(100, y)); - - this.updateMarkerPosition(); - this.updateFormFields(); - }); - - document.addEventListener('mouseup', () => { - if (isDragging) { - isDragging = false; - this.marker.style.cursor = 'grab'; - } - }); - } - - // Actions + // Actions triggered by form field changes onShapeChange() { this.updateMarkerAppearance(); } @@ -204,13 +204,19 @@ export default class extends Controller { centerMarker() { this.positionXValue = 50; this.positionYValue = 50; - this.updateMarkerPosition(); + + const newLat = (this.positionYValue / 100) * this.imageHeight; + const newLng = (this.positionXValue / 100) * this.imageWidth; + this.marker.setLatLng([newLat, newLng]); + this.updateFormFields(); + this.updateDisplays(); } fitImage() { - // Reset zoom/pan if we add that feature later - // For now, just center the marker - this.updateMarkerPosition(); + if (this.map && this.imageWidth && this.imageHeight) { + const bounds = [[0, 0], [this.imageHeight, this.imageWidth]]; + this.map.fitBounds(bounds); + } } } diff --git a/assets/controllers/staff-position-update_controller.js b/assets/controllers/staff-position-update_controller.js new file mode 100644 index 0000000..c0c3f00 --- /dev/null +++ b/assets/controllers/staff-position-update_controller.js @@ -0,0 +1,340 @@ +import { Controller } from '@hotwired/stimulus'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.min.css'; + +/** + * Stimulus controller for updating staff position on a game map. + * Provides tap-to-select interface for mobile-friendly grid selection. + */ +export default class extends Controller { + static values = { + apiUrl: String, + removeUrl: String, + imageUrl: String, + gridRows: Number, + gridColumns: Number, + gridOpacity: { type: Number, default: 0.5 }, + currentCell: String + }; + + static targets = ['mapContainer', 'cellDisplay', 'statusNote', 'saveButton', 'status']; + + connect() { + this.selectedCell = this.currentCellValue || null; + this.highlightLayer = null; + this.initMap(); + } + + disconnect() { + if (this.map) { + this.map.remove(); + } + } + + initMap() { + if (!this.hasMapContainerTarget) { + console.error('Map container target not found'); + return; + } + + const img = new Image(); + img.src = this.imageUrlValue; + + img.onload = () => { + this.imageWidth = img.width; + this.imageHeight = img.height; + const bounds = [[0, 0], [img.height, img.width]]; + + this.map = L.map(this.mapContainerTarget, { + crs: L.CRS.Simple, + minZoom: -2, + maxZoom: 2, + center: [img.height / 2, img.width / 2], + zoom: 0, + zoomControl: true, + attributionControl: false + }); + + // Add the image overlay + L.imageOverlay(this.imageUrlValue, bounds).addTo(this.map); + this.map.fitBounds(bounds); + + // Draw grid + this.drawGrid(); + + // Highlight current cell if exists + if (this.selectedCell) { + this.highlightCell(this.selectedCell); + } + + // Add click handler for cell selection + this.map.on('click', (e) => this.onMapClick(e)); + }; + } + + drawGrid() { + const rows = this.gridRowsValue; + const cols = this.gridColumnsValue; + const cellWidth = this.imageWidth / cols; + const cellHeight = this.imageHeight / rows; + + this.gridLayer = L.layerGroup().addTo(this.map); + + // Draw horizontal lines + for (let i = 0; i <= rows; i++) { + const y = i * cellHeight; + L.polyline([[y, 0], [y, this.imageWidth]], { + color: 'black', + weight: 1, + opacity: this.gridOpacityValue + }).addTo(this.gridLayer); + } + + // Draw vertical lines + for (let i = 0; i <= cols; i++) { + const x = i * cellWidth; + L.polyline([[0, x], [this.imageHeight, x]], { + color: 'black', + weight: 1, + opacity: this.gridOpacityValue + }).addTo(this.gridLayer); + } + + // Add grid labels + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const x = col * cellWidth + cellWidth / 2; + const y = row * cellHeight + cellHeight / 2; + const label = this.getCellLabel(row, col); + + L.marker([y, x], { + icon: L.divIcon({ + className: 'grid-label', + html: `
${label}
`, + iconSize: [25, 15] + }), + interactive: false + }).addTo(this.gridLayer); + } + } + } + + onMapClick(e) { + const cellWidth = this.imageWidth / this.gridColumnsValue; + const cellHeight = this.imageHeight / this.gridRowsValue; + + // Convert click coordinates to grid cell + const col = Math.floor(e.latlng.lng / cellWidth); + const row = Math.floor(e.latlng.lat / cellHeight); + + // Validate bounds + if (col < 0 || col >= this.gridColumnsValue || row < 0 || row >= this.gridRowsValue) { + return; + } + + const cellLabel = this.getCellLabel(row, col); + this.selectCell(cellLabel, row, col); + } + + selectCell(cellLabel, row, col) { + this.selectedCell = cellLabel; + + // Update cell display + if (this.hasCellDisplayTarget) { + this.cellDisplayTarget.value = cellLabel; + } + + // Enable save button + if (this.hasSaveButtonTarget) { + this.saveButtonTarget.disabled = false; + } + + // Highlight the selected cell + this.highlightCell(cellLabel); + } + + highlightCell(cellLabel) { + // Remove previous highlight + if (this.highlightLayer) { + this.highlightLayer.remove(); + } + + const { row, col } = this.parseCellLabel(cellLabel); + const cellWidth = this.imageWidth / this.gridColumnsValue; + const cellHeight = this.imageHeight / this.gridRowsValue; + + const x1 = col * cellWidth; + const y1 = row * cellHeight; + const x2 = x1 + cellWidth; + const y2 = y1 + cellHeight; + + // Create highlight rectangle + this.highlightLayer = L.rectangle([[y1, x1], [y2, x2]], { + color: '#007bff', + fillColor: '#007bff', + fillOpacity: 0.4, + weight: 3 + }).addTo(this.map); + + // Add marker at center + const centerX = (x1 + x2) / 2; + const centerY = (y1 + y2) / 2; + + L.marker([centerY, centerX], { + icon: L.divIcon({ + className: 'selected-marker', + html: `
`, + iconSize: [30, 30], + iconAnchor: [15, 15] + }) + }).addTo(this.highlightLayer); + } + + getCellLabel(row, col) { + const letter = String.fromCharCode(65 + col); // A, B, C, ... + return `${letter}${row + 1}`; + } + + parseCellLabel(label) { + const match = label.match(/^([A-Z]+)(\d+)$/); + if (!match) { + return { row: 0, col: 0 }; + } + + const col = match[1].charCodeAt(0) - 65; + const row = parseInt(match[2]) - 1; + return { row, col }; + } + + async save() { + if (!this.selectedCell) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = ' Please select a cell first'; + } + return; + } + + const statusNote = this.hasStatusNoteTarget ? this.statusNoteTarget.value : ''; + + // Disable save button + if (this.hasSaveButtonTarget) { + this.saveButtonTarget.disabled = true; + this.saveButtonTarget.innerHTML = ' Saving...'; + } + + try { + const response = await fetch(this.apiUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + gridCell: this.selectedCell, + statusNote: statusNote + }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + // Update status display + if (this.hasStatusTarget) { + const now = new Date(); + const timeString = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + this.statusTarget.innerHTML = ` Updated: ${timeString}`; + } + + // Show success feedback + if (this.hasSaveButtonTarget) { + this.saveButtonTarget.innerHTML = ' Saved!'; + this.saveButtonTarget.classList.remove('btn-primary'); + this.saveButtonTarget.classList.add('btn-success'); + + setTimeout(() => { + this.saveButtonTarget.innerHTML = ' Save Position'; + this.saveButtonTarget.classList.remove('btn-success'); + this.saveButtonTarget.classList.add('btn-primary'); + this.saveButtonTarget.disabled = false; + }, 2000); + } + + } catch (error) { + console.error('Error saving position:', error); + + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = ` Error saving position`; + } + + if (this.hasSaveButtonTarget) { + this.saveButtonTarget.innerHTML = ' Error'; + this.saveButtonTarget.classList.remove('btn-primary'); + this.saveButtonTarget.classList.add('btn-danger'); + + setTimeout(() => { + this.saveButtonTarget.innerHTML = ' Save Position'; + this.saveButtonTarget.classList.remove('btn-danger'); + this.saveButtonTarget.classList.add('btn-primary'); + this.saveButtonTarget.disabled = false; + }, 2000); + } + } + } + + async remove() { + if (!this.hasRemoveUrlValue) { + console.error('Remove URL not configured'); + return; + } + + if (!confirm('Are you sure you want to remove your position?')) { + return; + } + + try { + const response = await fetch(this.removeUrlValue, { + method: 'DELETE', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Clear selection + this.selectedCell = null; + + if (this.highlightLayer) { + this.highlightLayer.remove(); + this.highlightLayer = null; + } + + if (this.hasCellDisplayTarget) { + this.cellDisplayTarget.value = ''; + } + + if (this.hasStatusNoteTarget) { + this.statusNoteTarget.value = ''; + } + + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = ' Position removed'; + } + + // Reload the page to update UI (remove the remove button) + window.location.reload(); + + } catch (error) { + console.error('Error removing position:', error); + + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = ` Error removing position`; + } + } + } +} diff --git a/assets/controllers/staff-position-view_controller.js b/assets/controllers/staff-position-view_controller.js new file mode 100644 index 0000000..99fdb97 --- /dev/null +++ b/assets/controllers/staff-position-view_controller.js @@ -0,0 +1,232 @@ +import { Controller } from '@hotwired/stimulus'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.min.css'; + +/** + * Stimulus controller for viewing staff positions on a game map. + * Displays markers for all visible staff members with their position info. + */ +export default class extends Controller { + static values = { + imageUrl: String, + gridRows: Number, + gridColumns: Number, + gridOpacity: Number, + positions: Array, + apiUrl: String + }; + + connect() { + this.positionsLayer = null; + this.initMap(); + } + + disconnect() { + if (this.map) { + this.map.remove(); + } + } + + initMap() { + const img = new Image(); + img.src = this.imageUrlValue; + + img.onload = () => { + this.imageWidth = img.width; + this.imageHeight = img.height; + const bounds = [[0, 0], [img.height, img.width]]; + + this.map = L.map('mapContainer', { + crs: L.CRS.Simple, + minZoom: -2, + maxZoom: 2, + center: [img.height / 2, img.width / 2], + zoom: 0, + zoomControl: true, + attributionControl: false + }); + + // Add the image overlay + L.imageOverlay(this.imageUrlValue, bounds).addTo(this.map); + this.map.fitBounds(bounds); + + // Draw grid + this.drawGrid(); + + // Add position markers + this.renderPositions(this.positionsValue); + }; + } + + drawGrid() { + const rows = this.gridRowsValue; + const cols = this.gridColumnsValue; + const cellWidth = this.imageWidth / cols; + const cellHeight = this.imageHeight / rows; + + this.gridLayer = L.layerGroup().addTo(this.map); + + // Draw horizontal lines + for (let i = 0; i <= rows; i++) { + const y = i * cellHeight; + L.polyline([[y, 0], [y, this.imageWidth]], { + color: 'black', + weight: 1, + opacity: this.gridOpacityValue + }).addTo(this.gridLayer); + } + + // Draw vertical lines + for (let i = 0; i <= cols; i++) { + const x = i * cellWidth; + L.polyline([[0, x], [this.imageHeight, x]], { + color: 'black', + weight: 1, + opacity: this.gridOpacityValue + }).addTo(this.gridLayer); + } + + // Add grid labels + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const x = col * cellWidth + cellWidth / 2; + const y = row * cellHeight + cellHeight / 2; + const label = this.getCellLabel(row, col); + + L.marker([y, x], { + icon: L.divIcon({ + className: 'grid-label', + html: `
${label}
`, + iconSize: [25, 15] + }), + interactive: false + }).addTo(this.gridLayer); + } + } + } + + renderPositions(positions) { + // Remove previous positions layer + if (this.positionsLayer) { + this.positionsLayer.remove(); + } + + this.positionsLayer = L.layerGroup().addTo(this.map); + + if (!positions || positions.length === 0) { + return; + } + + positions.forEach(position => { + this.addPositionMarker(position); + }); + } + + addPositionMarker(position) { + const { row, col } = this.parseCellLabel(position.gridCell); + const cellWidth = this.imageWidth / this.gridColumnsValue; + const cellHeight = this.imageHeight / this.gridRowsValue; + + const centerX = col * cellWidth + cellWidth / 2; + const centerY = row * cellHeight + cellHeight / 2; + + // Determine marker color based on role + const color = this.getRoleColor(position.roles); + + // Create marker + const marker = L.marker([centerY, centerX], { + icon: L.divIcon({ + className: 'staff-position-marker', + html: ` +
+ +
+ `, + iconSize: [28, 28], + iconAnchor: [14, 14] + }) + }).addTo(this.positionsLayer); + + // Create popup content + let popupContent = ` +
+ ${position.participantName}
+ + ${position.roles.map(r => r.replace('ROLE_', '').toLowerCase()).join(', ')} +
+ ${position.gridCell} + ${position.statusNote ? `
${position.statusNote}` : ''} +
Updated: ${position.updatedAt} +
+ `; + + marker.bindPopup(popupContent); + } + + getRoleColor(roles) { + // Color coding by role priority + if (roles.includes('ROLE_ORGANIZER')) { + return '#dc3545'; // Red - main organizer + } + if (roles.includes('ROLE_PERSON_OF_TRUST')) { + return '#28a745'; // Green - trust person + } + if (roles.includes('ROLE_PHOTOGRAPHER')) { + return '#17a2b8'; // Cyan - photographer + } + if (roles.includes('ROLE_MEDIC')) { + return '#ffc107'; // Yellow - medic + } + if (roles.includes('ROLE_GAME_MASTER')) { + return '#6f42c1'; // Purple - game master + } + if (roles.includes('ROLE_STAFF')) { + return '#fd7e14'; // Orange - staff + } + return '#6c757d'; // Gray - other + } + + getCellLabel(row, col) { + const letter = String.fromCharCode(65 + col); + return `${letter}${row + 1}`; + } + + parseCellLabel(label) { + const match = label.match(/^([A-Z]+)(\d+)$/); + if (!match) { + return { row: 0, col: 0 }; + } + + const col = match[1].charCodeAt(0) - 65; + const row = parseInt(match[2]) - 1; + return { row, col }; + } + + async refresh() { + if (!this.apiUrlValue) { + return; + } + + try { + const response = await fetch(this.apiUrlValue); + if (!response.ok) { + throw new Error('Failed to fetch positions'); + } + + const data = await response.json(); + this.renderPositions(data.positions); + } catch (error) { + console.error('Error refreshing positions:', error); + } + } +} diff --git a/config/routes.yaml b/config/routes.yaml index 283e96a..aedc30a 100755 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -93,6 +93,16 @@ map_backoffice: type: attribute prefix: /backoffice +map_participant: + resource: '../src/Domain/Map/Controller/Participant/' + type: attribute + prefix: / + +map_api: + resource: '../src/Domain/Map/Controller/API/' + type: attribute + prefix: /api + # StoryMarketplace Domain story_marketplace_backoffice: resource: '../src/Domain/StoryMarketplace/Controller/Backoffice/' diff --git a/migrations/Version20251224083715.php b/migrations/Version20251224083715.php new file mode 100644 index 0000000..50de26e --- /dev/null +++ b/migrations/Version20251224083715.php @@ -0,0 +1,40 @@ +addSql('CREATE TABLE staff_position (id UUID NOT NULL, participant_id UUID NOT NULL, map_id UUID NOT NULL, grid_cell VARCHAR(10) NOT NULL, status_note VARCHAR(255) DEFAULT NULL, position_updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_6472445B9D1C3019 ON staff_position (participant_id)'); + $this->addSql('CREATE INDEX IDX_6472445B53C55F64 ON staff_position (map_id)'); + $this->addSql('CREATE UNIQUE INDEX unique_participant_map ON staff_position (participant_id, map_id)'); + $this->addSql('COMMENT ON COLUMN staff_position.id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN staff_position.participant_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN staff_position.map_id IS \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE staff_position ADD CONSTRAINT FK_6472445B9D1C3019 FOREIGN KEY (participant_id) REFERENCES larp_participant (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE staff_position ADD CONSTRAINT FK_6472445B53C55F64 FOREIGN KEY (map_id) REFERENCES game_map (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE staff_position DROP CONSTRAINT FK_6472445B9D1C3019'); + $this->addSql('ALTER TABLE staff_position DROP CONSTRAINT FK_6472445B53C55F64'); + $this->addSql('DROP TABLE staff_position'); + } +} diff --git a/src/Domain/Map/Controller/API/StaffPositionController.php b/src/Domain/Map/Controller/API/StaffPositionController.php new file mode 100644 index 0000000..99b0afc --- /dev/null +++ b/src/Domain/Map/Controller/API/StaffPositionController.php @@ -0,0 +1,156 @@ +denyAccessUnlessGranted(StaffPositionVoter::VIEW_POSITIONS, $larp); + + $user = $this->getUser(); + $participant = $participantRepository->findOneBy(['user' => $user, 'larp' => $larp]); + + if (!$participant) { + return new JsonResponse(['error' => 'Not a participant'], Response::HTTP_FORBIDDEN); + } + + // Get positions filtered by visibility rules + $positions = $positionService->getVisiblePositions($map, $participant); + + // Convert to array for JSON + $positionsData = array_map( + fn ($pos) => $positionService->positionToArray($pos), + $positions + ); + + return new JsonResponse([ + 'positions' => $positionsData, + 'canViewAll' => $positionService->canViewAllPositions($participant), + 'canUpdate' => $positionService->canUpdatePosition($participant), + ]); + } + + /** + * Update current user's position on the map. + */ + #[Route('', name: 'update', methods: ['POST'])] + public function update( + Request $request, + Larp $larp, + GameMap $map, + LarpParticipantRepository $participantRepository, + StaffPositionService $positionService, + ): JsonResponse { + $this->denyAccessUnlessGranted(StaffPositionVoter::UPDATE_POSITION, $larp); + + $user = $this->getUser(); + $participant = $participantRepository->findOneBy(['user' => $user, 'larp' => $larp]); + + if (!$participant) { + return new JsonResponse(['error' => 'Not a participant'], Response::HTTP_FORBIDDEN); + } + + $data = json_decode($request->getContent(), true); + + $gridCell = $data['gridCell'] ?? null; + $statusNote = $data['statusNote'] ?? null; + + if (!$gridCell) { + return new JsonResponse(['error' => 'gridCell is required'], Response::HTTP_BAD_REQUEST); + } + + try { + $position = $positionService->updatePosition($participant, $map, $gridCell, $statusNote); + + return new JsonResponse([ + 'success' => true, + 'position' => $positionService->positionToArray($position), + ]); + } catch (\InvalidArgumentException $e) { + return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST); + } + } + + /** + * Remove current user's position from the map. + */ + #[Route('', name: 'remove', methods: ['DELETE'])] + public function remove( + Larp $larp, + GameMap $map, + LarpParticipantRepository $participantRepository, + StaffPositionService $positionService, + ): JsonResponse { + $this->denyAccessUnlessGranted(StaffPositionVoter::UPDATE_POSITION, $larp); + + $user = $this->getUser(); + $participant = $participantRepository->findOneBy(['user' => $user, 'larp' => $larp]); + + if (!$participant) { + return new JsonResponse(['error' => 'Not a participant'], Response::HTTP_FORBIDDEN); + } + + $positionService->removePosition($participant, $map); + + return new JsonResponse(['success' => true]); + } + + /** + * Get current user's position on the map. + */ + #[Route('/me', name: 'my_position', methods: ['GET'])] + public function myPosition( + Larp $larp, + GameMap $map, + LarpParticipantRepository $participantRepository, + StaffPositionService $positionService, + ): JsonResponse { + $this->denyAccessUnlessGranted(StaffPositionVoter::UPDATE_POSITION, $larp); + + $user = $this->getUser(); + $participant = $participantRepository->findOneBy(['user' => $user, 'larp' => $larp]); + + if (!$participant) { + return new JsonResponse(['error' => 'Not a participant'], Response::HTTP_FORBIDDEN); + } + + $position = $positionService->getPosition($participant, $map); + + if (!$position) { + return new JsonResponse(['position' => null]); + } + + return new JsonResponse([ + 'position' => $positionService->positionToArray($position), + ]); + } +} diff --git a/src/Domain/Map/Controller/Backoffice/GameMapController.php b/src/Domain/Map/Controller/Backoffice/GameMapController.php index 53be45f..23dc630 100755 --- a/src/Domain/Map/Controller/Backoffice/GameMapController.php +++ b/src/Domain/Map/Controller/Backoffice/GameMapController.php @@ -13,6 +13,8 @@ use App\Domain\Map\Form\MapLocationType; use App\Domain\Map\Repository\GameMapRepository; use App\Domain\Map\Repository\MapLocationRepository; +use App\Domain\Map\Repository\StaffPositionRepository; +use App\Domain\Map\Service\StaffPositionService; use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; @@ -104,8 +106,14 @@ public function modify( } #[Route('{map}/view', name: 'view', methods: ['GET'])] - public function view(Larp $larp, GameMap $map, MapLocationRepository $locationRepository): Response - { + public function view( + Larp $larp, + GameMap $map, + MapLocationRepository $locationRepository, + StaffPositionRepository $staffPositionRepository, + StaffPositionService $staffPositionService, + \App\Domain\Core\Repository\LarpParticipantRepository $participantRepository, + ): Response { $locations = $locationRepository->findByMap($map); $locationsData = array_map(function (MapLocation $location) { @@ -125,11 +133,35 @@ public function view(Larp $larp, GameMap $map, MapLocationRepository $locationRe ]; }, $locations); + // Get all staff positions for the map (backoffice users see all) + $staffPositions = $staffPositionRepository->findByMap($map); + $staffPositionsData = array_map( + fn ($pos) => $staffPositionService->positionToArray($pos), + $staffPositions + ); + + // Get current user's participant and position + $user = $this->getUser(); + $participant = $participantRepository->findOneBy(['user' => $user, 'larp' => $larp]); + $myPosition = null; + $canUpdatePosition = false; + + if ($participant) { + $canUpdatePosition = $staffPositionService->canUpdatePosition($participant); + if ($canUpdatePosition) { + $myPosition = $staffPositionService->getPosition($participant, $map); + } + } + return $this->render('backoffice/larp/map/view.html.twig', [ 'larp' => $larp, 'map' => $map, 'locations' => $locations, 'locationsData' => $locationsData, + 'staffPositions' => $staffPositions, + 'staffPositionsData' => $staffPositionsData, + 'myPosition' => $myPosition, + 'canUpdatePosition' => $canUpdatePosition, ]); } diff --git a/src/Domain/Map/Controller/Participant/StaffPositionController.php b/src/Domain/Map/Controller/Participant/StaffPositionController.php new file mode 100644 index 0000000..3ff39c3 --- /dev/null +++ b/src/Domain/Map/Controller/Participant/StaffPositionController.php @@ -0,0 +1,161 @@ +denyAccessUnlessGranted(StaffPositionVoter::UPDATE_POSITION, $larp); + + $maps = $mapRepository->findByLarp($larp); + + return $this->render('participant/map/position_index.html.twig', [ + 'larp' => $larp, + 'maps' => $maps, + ]); + } + + /** + * Position update page - mobile interface to tap on grid to update position. + */ + #[Route('/map/{map}', name: 'update', methods: ['GET', 'POST'])] + public function update( + Request $request, + Larp $larp, + GameMap $map, + LarpParticipantRepository $participantRepository, + StaffPositionService $positionService, + ): Response { + $this->denyAccessUnlessGranted(StaffPositionVoter::UPDATE_POSITION, $larp); + + $user = $this->getUser(); + $participant = $participantRepository->findOneBy(['user' => $user, 'larp' => $larp]); + + if (!$participant) { + throw $this->createAccessDeniedException('You are not a participant in this LARP'); + } + + // Get current position if exists + $currentPosition = $positionService->getPosition($participant, $map); + + if ($request->isMethod('POST')) { + $gridCell = $request->request->get('gridCell'); + $statusNote = $request->request->get('statusNote'); + + if ($gridCell) { + try { + $positionService->updatePosition($participant, $map, $gridCell, $statusNote); + $this->addFlash('success', $this->translator->trans('staff_position.updated')); + + return $this->redirectToRoute('participant_staff_position_update', [ + 'larp' => $larp->getId(), + 'map' => $map->getId(), + ]); + } catch (\InvalidArgumentException $e) { + $this->addFlash('error', $this->translator->trans('staff_position.invalid_cell')); + } + } else { + $this->addFlash('error', $this->translator->trans('staff_position.select_cell')); + } + } + + return $this->render('participant/map/position_update.html.twig', [ + 'larp' => $larp, + 'map' => $map, + 'currentPosition' => $currentPosition, + ]); + } + + /** + * View staff positions page - shows all visible staff positions on the map. + */ + #[Route('/map/{map}/view', name: 'view', methods: ['GET'])] + public function view( + Larp $larp, + GameMap $map, + LarpParticipantRepository $participantRepository, + StaffPositionService $positionService, + ): Response { + $this->denyAccessUnlessGranted(StaffPositionVoter::VIEW_POSITIONS, $larp); + + $user = $this->getUser(); + $participant = $participantRepository->findOneBy(['user' => $user, 'larp' => $larp]); + + if (!$participant) { + throw $this->createAccessDeniedException('You are not a participant in this LARP'); + } + + // Get positions filtered by visibility rules + $positions = $positionService->getVisiblePositions($map, $participant); + + // Convert to array for JSON + $positionsData = array_map( + fn ($pos) => $positionService->positionToArray($pos), + $positions + ); + + $canViewAll = $positionService->canViewAllPositions($participant); + + return $this->render('participant/map/position_view.html.twig', [ + 'larp' => $larp, + 'map' => $map, + 'positions' => $positions, + 'positionsData' => $positionsData, + 'canViewAll' => $canViewAll, + ]); + } + + /** + * Remove current position from map. + */ + #[Route('/map/{map}/remove', name: 'remove', methods: ['POST'])] + public function remove( + Larp $larp, + GameMap $map, + LarpParticipantRepository $participantRepository, + StaffPositionService $positionService, + ): Response { + $this->denyAccessUnlessGranted(StaffPositionVoter::UPDATE_POSITION, $larp); + + $user = $this->getUser(); + $participant = $participantRepository->findOneBy(['user' => $user, 'larp' => $larp]); + + if (!$participant) { + throw $this->createAccessDeniedException('You are not a participant in this LARP'); + } + + $positionService->removePosition($participant, $map); + $this->addFlash('success', $this->translator->trans('staff_position.removed')); + + return $this->redirectToRoute('participant_staff_position_index', [ + 'larp' => $larp->getId(), + ]); + } +} diff --git a/src/Domain/Map/Entity/MapLocation.php b/src/Domain/Map/Entity/MapLocation.php index a697998..1a54dea 100755 --- a/src/Domain/Map/Entity/MapLocation.php +++ b/src/Domain/Map/Entity/MapLocation.php @@ -218,4 +218,31 @@ public function getEffectiveShape(): MarkerShape { return $this->shape; } + + /** + * Get grid coordinates string based on position percentage and map grid settings. + * Returns format like "A1", "B2", etc. + */ + public function getGridCoordinatesString(): string + { + if (!$this->map) { + return '-'; + } + + $cols = $this->map->getGridColumns(); + $rows = $this->map->getGridRows(); + + // Convert percentage to grid cell + $col = (int) floor(($this->getPositionX() / 100) * $cols); + $row = (int) floor(($this->getPositionY() / 100) * $rows); + + // Clamp to valid range + $col = max(0, min($cols - 1, $col)); + $row = max(0, min($rows - 1, $row)); + + // Convert to letter + number format (A1, B2, etc.) + $letter = chr(65 + $col); // A, B, C, ... + + return sprintf('%s%d', $letter, $row + 1); + } } diff --git a/src/Domain/Map/Entity/StaffPosition.php b/src/Domain/Map/Entity/StaffPosition.php new file mode 100644 index 0000000..b1751d7 --- /dev/null +++ b/src/Domain/Map/Entity/StaffPosition.php @@ -0,0 +1,150 @@ +positionUpdatedAt = new \DateTime(); + } + + public function getParticipant(): LarpParticipant + { + return $this->participant; + } + + public function setParticipant(LarpParticipant $participant): self + { + $this->participant = $participant; + return $this; + } + + public function getMap(): GameMap + { + return $this->map; + } + + public function setMap(GameMap $map): self + { + $this->map = $map; + return $this; + } + + public function getGridCell(): string + { + return $this->gridCell; + } + + public function setGridCell(string $gridCell): self + { + $this->gridCell = strtoupper($gridCell); + $this->positionUpdatedAt = new \DateTime(); + return $this; + } + + public function getStatusNote(): ?string + { + return $this->statusNote; + } + + public function setStatusNote(?string $statusNote): self + { + $this->statusNote = $statusNote; + return $this; + } + + public function getPositionUpdatedAt(): \DateTimeInterface + { + return $this->positionUpdatedAt; + } + + public function setPositionUpdatedAt(\DateTimeInterface $positionUpdatedAt): self + { + $this->positionUpdatedAt = $positionUpdatedAt; + return $this; + } + + /** + * Gets the column letter from the grid cell (e.g., "A" from "A1") + */ + public function getGridColumn(): string + { + preg_match('/^([A-Z]+)/', $this->gridCell, $matches); + return $matches[1] ?? 'A'; + } + + /** + * Gets the row number from the grid cell (e.g., 1 from "A1") + */ + public function getGridRow(): int + { + preg_match('/(\d+)$/', $this->gridCell, $matches); + return (int) ($matches[1] ?? 1); + } + + /** + * Calculate the center position (as percentage) for displaying the marker on the map. + */ + public function getCenterPosition(): array + { + $map = $this->map; + $col = ord($this->getGridColumn()) - ord('A'); + $row = $this->getGridRow() - 1; + + $cellWidthPercent = 100 / $map->getGridColumns(); + $cellHeightPercent = 100 / $map->getGridRows(); + + return [ + 'x' => ($col + 0.5) * $cellWidthPercent, + 'y' => ($row + 0.5) * $cellHeightPercent, + ]; + } +} diff --git a/src/Domain/Map/Repository/StaffPositionRepository.php b/src/Domain/Map/Repository/StaffPositionRepository.php new file mode 100644 index 0000000..e2e0e5e --- /dev/null +++ b/src/Domain/Map/Repository/StaffPositionRepository.php @@ -0,0 +1,85 @@ + + */ +class StaffPositionRepository extends BaseRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, StaffPosition::class); + } + + /** + * Find all staff positions for a specific map. + * + * @return StaffPosition[] + */ + public function findByMap(GameMap $map): array + { + return $this->createQueryBuilder('sp') + ->join('sp.participant', 'p') + ->where('sp.map = :map') + ->setParameter('map', $map) + ->orderBy('sp.positionUpdatedAt', 'DESC') + ->getQuery() + ->getResult(); + } + + /** + * Find all staff positions for a specific LARP. + * + * @return StaffPosition[] + */ + public function findByLarp(Larp $larp): array + { + return $this->createQueryBuilder('sp') + ->join('sp.map', 'm') + ->where('m.larp = :larp') + ->setParameter('larp', $larp) + ->orderBy('sp.positionUpdatedAt', 'DESC') + ->getQuery() + ->getResult(); + } + + /** + * Find position for a specific participant on a specific map. + */ + public function findByParticipantAndMap(LarpParticipant $participant, GameMap $map): ?StaffPosition + { + return $this->createQueryBuilder('sp') + ->where('sp.participant = :participant') + ->andWhere('sp.map = :map') + ->setParameter('participant', $participant) + ->setParameter('map', $map) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * Find or create a position for a participant on a map. + */ + public function findOrCreate(LarpParticipant $participant, GameMap $map): StaffPosition + { + $position = $this->findByParticipantAndMap($participant, $map); + + if (!$position) { + $position = new StaffPosition(); + $position->setParticipant($participant); + $position->setMap($map); + } + + return $position; + } +} diff --git a/src/Domain/Map/Security/Voter/StaffPositionVoter.php b/src/Domain/Map/Security/Voter/StaffPositionVoter.php new file mode 100644 index 0000000..2306f1c --- /dev/null +++ b/src/Domain/Map/Security/Voter/StaffPositionVoter.php @@ -0,0 +1,82 @@ +getUser(); + if (!$user instanceof UserInterface) { + return false; + } + + // Get the LARP from the subject + $larp = $subject instanceof Larp ? $subject : $subject->getLarp(); + + // Get participant for this user in this LARP + $participant = $this->participantRepository->findOneBy([ + 'user' => $user, + 'larp' => $larp, + ]); + + // Must be a participant to access staff positions + if (!$participant) { + return false; + } + + return match ($attribute) { + self::UPDATE_POSITION => $this->staffPositionService->canUpdatePosition($participant), + self::VIEW_POSITIONS => true, // All participants can view (filtered by visibility rules in service) + self::VIEW_ALL_POSITIONS => $this->staffPositionService->canViewAllPositions($participant), + default => false, + }; + } +} diff --git a/src/Domain/Map/Service/StaffPositionService.php b/src/Domain/Map/Service/StaffPositionService.php new file mode 100644 index 0000000..940d2fe --- /dev/null +++ b/src/Domain/Map/Service/StaffPositionService.php @@ -0,0 +1,230 @@ +isOrganizer(); + } + + /** + * Check if a participant can view all staff positions. + * Organizers and staff can see all positions. + */ + public function canViewAllPositions(LarpParticipant $participant): bool + { + return $participant->isOrganizer(); + } + + /** + * Check if a position should be visible to a viewer. + * If viewer is organizer, they can see all positions. + * If viewer is player, they can only see specific roles (ORGANIZER, TRUST_PERSON, PHOTOGRAPHER). + */ + public function isPositionVisibleTo(StaffPosition $position, LarpParticipant $viewer): bool + { + // Organizers can see all positions + if ($viewer->isOrganizer()) { + return true; + } + + // Players can only see specific roles + $staffRoles = $position->getParticipant()->getRoles(); + $visibleRoles = self::getPlayerVisibleRoles(); + + foreach ($staffRoles as $role) { + if (in_array($role, $visibleRoles, true)) { + return true; + } + } + + return false; + } + + /** + * Get all positions visible to a participant on a map. + * + * @return StaffPosition[] + */ + public function getVisiblePositions(GameMap $map, LarpParticipant $viewer): array + { + $positions = $this->repository->findByMap($map); + + // Organizers see all positions + if ($viewer->isOrganizer()) { + return $positions; + } + + // Filter positions for players based on visibility rules + return array_filter( + $positions, + fn (StaffPosition $position) => $this->isPositionVisibleTo($position, $viewer) + ); + } + + /** + * Update a participant's position on a map. + */ + public function updatePosition( + LarpParticipant $participant, + GameMap $map, + string $gridCell, + ?string $statusNote = null + ): StaffPosition { + $position = $this->repository->findOrCreate($participant, $map); + + if (!$this->validateGridCell($map, $gridCell)) { + throw new \InvalidArgumentException( + sprintf( + 'Invalid grid cell "%s" for map with %d columns and %d rows', + $gridCell, + $map->getGridColumns(), + $map->getGridRows() + ) + ); + } + + $position->setGridCell($gridCell); + $position->setStatusNote($statusNote); + + $this->entityManager->persist($position); + $this->entityManager->flush(); + + return $position; + } + + /** + * Validate that a grid cell is valid for the given map. + */ + public function validateGridCell(GameMap $map, string $gridCell): bool + { + $gridCell = strtoupper(trim($gridCell)); + + // Match pattern like "A1", "B12", "AA1" + if (!preg_match('/^([A-Z]+)(\d+)$/', $gridCell, $matches)) { + return false; + } + + $col = $this->columnLetterToNumber($matches[1]); + $row = (int) $matches[2]; + + // Validate within bounds (1-indexed) + return $col >= 1 && $col <= $map->getGridColumns() + && $row >= 1 && $row <= $map->getGridRows(); + } + + /** + * Convert column letter(s) to number (A=1, B=2, ..., Z=26, AA=27) + */ + private function columnLetterToNumber(string $letters): int + { + $result = 0; + $length = strlen($letters); + + for ($i = 0; $i < $length; $i++) { + $result = $result * 26 + (ord($letters[$i]) - ord('A') + 1); + } + + return $result; + } + + /** + * Convert column number to letter(s) (1=A, 2=B, ..., 26=Z, 27=AA) + */ + public function columnNumberToLetter(int $number): string + { + $result = ''; + + while ($number > 0) { + $number--; + $result = chr(ord('A') + ($number % 26)) . $result; + $number = (int) ($number / 26); + } + + return $result; + } + + /** + * Remove a participant's position from a map. + */ + public function removePosition(LarpParticipant $participant, GameMap $map): void + { + $position = $this->repository->findByParticipantAndMap($participant, $map); + + if ($position) { + $this->entityManager->remove($position); + $this->entityManager->flush(); + } + } + + /** + * Get a participant's position on a specific map. + */ + public function getPosition(LarpParticipant $participant, GameMap $map): ?StaffPosition + { + return $this->repository->findByParticipantAndMap($participant, $map); + } + + /** + * Convert staff position to array for JSON response. + */ + public function positionToArray(StaffPosition $position): array + { + $centerPosition = $position->getCenterPosition(); + $participant = $position->getParticipant(); + $user = $participant->getUser(); + $roles = $participant->getRoles(); + + return [ + 'id' => $position->getId()->toString(), + 'participantId' => $participant->getId()->toString(), + 'participantName' => $user?->getUsername() ?? 'Unknown', + 'roles' => array_map(fn ($r) => $r->value, $roles), + 'gridCell' => $position->getGridCell(), + 'centerX' => $centerPosition['x'], + 'centerY' => $centerPosition['y'], + 'statusNote' => $position->getStatusNote(), + 'updatedAt' => $position->getPositionUpdatedAt()->format('Y-m-d H:i:s'), + ]; + } +} diff --git a/templates/backoffice/larp/_menu.html.twig b/templates/backoffice/larp/_menu.html.twig index dd36783..59041c4 100755 --- a/templates/backoffice/larp/_menu.html.twig +++ b/templates/backoffice/larp/_menu.html.twig @@ -82,6 +82,14 @@ {% endif %} +
  • +
  • + + {{ 'staff_position.title'|trans }} + +
  • {% endif %} diff --git a/templates/backoffice/larp/map/list.html.twig b/templates/backoffice/larp/map/list.html.twig index b0e0240..ce25d08 100755 --- a/templates/backoffice/larp/map/list.html.twig +++ b/templates/backoffice/larp/map/list.html.twig @@ -1,14 +1,18 @@ {% extends 'backoffice/larp/base.html.twig' %} {% import 'macros/ui_components.html.twig' as ui %} -{% block larp_title %}{{ 'larp.map.list'|trans }} - {{ larp.title }}{% endblock %} +{% set staffPositionsMode = app.request.query.get('staff_positions') == '1' %} + +{% block larp_title %}{{ staffPositionsMode ? 'staff_position.title'|trans : 'larp.map.list'|trans }} - {{ larp.title }}{% endblock %} {% block larp_content %}
    {% set actions %} - {{ ui.primary_button('create', path('backoffice_larp_map_modify', { larp: larp.id }), 'bi-plus-circle') }} + {% if not staffPositionsMode %} + {{ ui.primary_button('create', path('backoffice_larp_map_modify', { larp: larp.id }), 'bi-plus-circle') }} + {% endif %} {% endset %} - {{ ui.card_header('larp.map.plural', actions) }} + {{ ui.card_header(staffPositionsMode ? 'staff_position.select_map' : 'larp.map.plural', actions) }}
    {% include 'includes/filter_form.html.twig' with { form: filterForm } %} @@ -31,7 +35,7 @@ {% for map in maps %} - + {{ map.name }} diff --git a/templates/backoffice/larp/map/location_modify.html.twig b/templates/backoffice/larp/map/location_modify.html.twig index 6b56f02..0e0b4fc 100755 --- a/templates/backoffice/larp/map/location_modify.html.twig +++ b/templates/backoffice/larp/map/location_modify.html.twig @@ -19,6 +19,15 @@
    {{ form_start(form) }} + {% if map.imageFile %} +
    + {% endif %} +
    {{ 'larp.map.location_info'|trans }}
    @@ -43,43 +52,35 @@ {% if map.imageFile %} -
    +
    +
    -
    -
    +
    + + {{ 'larp.map.click_to_place_marker'|trans }} +
    -
    - - {{ 'larp.map.click_to_place_marker'|trans }} -
    +
    + + +
    -
    - - +
    +
    + X: {{ location.positionX|default(50)|number_format(2) }}%
    - -
    -
    - X: {{ location.positionX|default(50)|number_format(2) }}% -
    -
    - Y: {{ location.positionY|default(50)|number_format(2) }}% -
    +
    + Y: {{ location.positionY|default(50)|number_format(2) }}%
    {% else %} @@ -94,6 +95,10 @@
    + {% if map.imageFile %} +
    + {% endif %} + {{ ui.form_actions( isNew ? 'create' : 'save', 'bi-check-circle', diff --git a/templates/backoffice/larp/map/view.html.twig b/templates/backoffice/larp/map/view.html.twig index 0bd561e..91fa26a 100755 --- a/templates/backoffice/larp/map/view.html.twig +++ b/templates/backoffice/larp/map/view.html.twig @@ -22,6 +22,20 @@
    + {% if staffPositions is not empty %} +
    +
    + + +
    +
    + {% endif %}
    {% if map.description %} @@ -50,7 +64,8 @@ data-leaflet-map-grid-columns-value="{{ map.gridColumns }}" data-leaflet-map-grid-opacity-value="{{ map.gridOpacity }}" data-leaflet-map-grid-visible-value="{{ map.isGridVisible ? 'true' : 'false' }}" - data-leaflet-map-locations-value="{{ locationsData|json_encode|e('html_attr') }}"> + data-leaflet-map-locations-value="{{ locationsData|json_encode|e('html_attr') }}" + data-leaflet-map-staff-positions-value="{{ staffPositionsData|json_encode|e('html_attr') }}">
    {% else %} @@ -59,6 +74,85 @@
    {% endif %} + {# Staff Position Update Panel #} + {% if canUpdatePosition and map.imageFile %} +
    +
    + + + {{ 'staff_position.my_position'|trans }} + + +
    +
    +
    +
    +
    +

    + + {{ 'staff_position.tap_to_select'|trans }} +

    + +
    +
    +
    +
    +
    + +
    + + +
    +
    + +
    + + +
    + + + + {% if myPosition %} + + {% endif %} + +
    + {% if myPosition %} + {{ 'staff_position.updated_at'|trans }}: {{ myPosition.positionUpdatedAt|date('H:i') }} + {% endif %} +
    +
    +
    +
    +
    +
    + {% endif %} + {% if locations is not empty %}

    {{ 'larp.map.locations'|trans }}

    +{% endblock %} diff --git a/templates/participant/map/position_update.html.twig b/templates/participant/map/position_update.html.twig new file mode 100644 index 0000000..1ade996 --- /dev/null +++ b/templates/participant/map/position_update.html.twig @@ -0,0 +1,135 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'staff_position.update_title'|trans }} - {{ map.name }}{% endblock %} + +{% block body %} +
    +
    +
    +
    +
    +
    +
    +

    {{ 'staff_position.update_title'|trans }}

    + {{ map.name }} - {{ larp.title }} +
    + +
    +
    +
    + {% if currentPosition %} +
    + {{ 'staff_position.current_position'|trans }}: + {{ currentPosition.gridCell }} + {% if currentPosition.statusNote %} + - {{ currentPosition.statusNote }} + {% endif %} + + {{ 'staff_position.updated_at'|trans }}: + {{ currentPosition.positionUpdatedAt|date('H:i') }} + +
    + {% endif %} + +

    + + {{ 'staff_position.tap_to_select'|trans }} +

    + + {% if map.imageFile %} +
    + +
    + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + {% if currentPosition %} + + {% endif %} +
    +
    +
    + {% else %} +
    + {{ 'larp.map.no_image'|trans }} +
    + {% endif %} +
    +
    +
    +
    +
    + + {% if currentPosition %} + + {% endif %} +{% endblock %} diff --git a/templates/participant/map/position_view.html.twig b/templates/participant/map/position_view.html.twig new file mode 100644 index 0000000..385f73b --- /dev/null +++ b/templates/participant/map/position_view.html.twig @@ -0,0 +1,109 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'staff_position.view_title'|trans }} - {{ map.name }}{% endblock %} + +{% block body %} +
    +
    +
    +
    +
    +
    +
    +

    {{ 'staff_position.view_title'|trans }}

    + {{ map.name }} - {{ larp.title }} +
    + +
    +
    +
    + {% if not canViewAll %} +
    + + {{ 'staff_position.limited_visibility'|trans }} +
    + {% endif %} + +
    + {{ 'staff_position.active_positions'|trans }}: + {{ positions|length }} +
    + + {% if map.imageFile %} +
    + +
    +
    + {% else %} +
    + {{ 'larp.map.no_image'|trans }} +
    + {% endif %} + + {% if positions is not empty %} +
    +
    {{ 'staff_position.positions_list'|trans }}
    +
    + + + + + + + + + + + + {% for position in positions %} + + + + + + + + {% endfor %} + +
    {{ 'staff_position.staff_member'|trans }}{{ 'staff_position.role'|trans }}{{ 'staff_position.cell'|trans }}{{ 'staff_position.status'|trans }}{{ 'staff_position.updated'|trans }}
    {{ position.participant.user.username }} + {% for role in position.participant.roles %} + {{ role.value|replace({'ROLE_': ''})|lower }} + {% endfor %} + {{ position.gridCell }}{{ position.statusNote ?? '-' }}{{ position.positionUpdatedAt|date('H:i') }}
    +
    +
    + {% else %} +
    + {{ 'staff_position.no_positions'|trans }} +
    + {% endif %} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/tests/Functional/Map/StaffPositionCest.php b/tests/Functional/Map/StaffPositionCest.php new file mode 100644 index 0000000..c6c6cb2 --- /dev/null +++ b/tests/Functional/Map/StaffPositionCest.php @@ -0,0 +1,506 @@ +wantTo('verify that organizers can update their position'); + + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + $participant = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($user) + ->organizer() + ->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->create(); + + $I->amLoggedInAs($user); + + $authChecker = $I->grabService('security.authorization_checker'); + $canUpdate = $authChecker->isGranted(StaffPositionVoter::UPDATE_POSITION, $larp->_real()); + + $I->assertTrue($canUpdate, 'Organizer should be able to update position'); + } + + public function staffCanUpdatePosition(FunctionalTester $I): void + { + $I->wantTo('verify that staff can update their position'); + + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + $participant = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($user) + ->staff() + ->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->create(); + + $I->amLoggedInAs($user); + + $authChecker = $I->grabService('security.authorization_checker'); + $canUpdate = $authChecker->isGranted(StaffPositionVoter::UPDATE_POSITION, $larp->_real()); + + $I->assertTrue($canUpdate, 'Staff should be able to update position'); + } + + public function gameMasterCanUpdatePosition(FunctionalTester $I): void + { + $I->wantTo('verify that game masters can update their position'); + + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + $participant = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($user) + ->gameMaster() + ->create(); + + $I->amLoggedInAs($user); + + $authChecker = $I->grabService('security.authorization_checker'); + $canUpdate = $authChecker->isGranted(StaffPositionVoter::UPDATE_POSITION, $larp->_real()); + + $I->assertTrue($canUpdate, 'Game master should be able to update position'); + } + + public function playerCannotUpdatePosition(FunctionalTester $I): void + { + $I->wantTo('verify that players cannot update position'); + + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + $participant = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($user) + ->player() + ->create(); + + $I->amLoggedInAs($user); + + $authChecker = $I->grabService('security.authorization_checker'); + $canUpdate = $authChecker->isGranted(StaffPositionVoter::UPDATE_POSITION, $larp->_real()); + + $I->assertFalse($canUpdate, 'Player should not be able to update position'); + } + + public function nonParticipantCannotAccessPositions(FunctionalTester $I): void + { + $I->wantTo('verify that non-participants cannot access positions'); + + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + // User is not a participant in this LARP + + $I->amLoggedInAs($user); + + $authChecker = $I->grabService('security.authorization_checker'); + $canUpdate = $authChecker->isGranted(StaffPositionVoter::UPDATE_POSITION, $larp->_real()); + + $I->assertFalse($canUpdate, 'Non-participant should not be able to update position'); + } + + // ======================================================================== + // Visibility Tests + // ======================================================================== + + public function organizerSeesAllStaffPositions(FunctionalTester $I): void + { + $I->wantTo('verify that organizers see all staff positions'); + + $larp = LarpFactory::new()->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->create(); + + // Create various staff positions + $organizer = LarpParticipantFactory::new()->forLarp($larp->_real())->organizer()->create(); + $gameMaster = LarpParticipantFactory::new()->forLarp($larp->_real())->gameMaster()->create(); + $trustPerson = LarpParticipantFactory::new()->forLarp($larp->_real())->trustPerson()->create(); + + StaffPositionFactory::new()->forParticipant($organizer->_real())->forMap($map->_real())->atCell('A1')->create(); + StaffPositionFactory::new()->forParticipant($gameMaster->_real())->forMap($map->_real())->atCell('B2')->create(); + StaffPositionFactory::new()->forParticipant($trustPerson->_real())->forMap($map->_real())->atCell('C3')->create(); + + // Create a viewer who is an organizer + $viewerUser = UserFactory::createApprovedUser(); + $viewer = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($viewerUser) + ->organizer() + ->create(); + + /** @var StaffPositionService $service */ + $service = $I->grabService(StaffPositionService::class); + $visiblePositions = $service->getVisiblePositions($map->_real(), $viewer->_real()); + + $I->assertCount(3, $visiblePositions, 'Organizer should see all 3 staff positions'); + } + + public function playerSeesOnlyOrganizerTrustPersonPhotographer(FunctionalTester $I): void + { + $I->wantTo('verify that players see only organizer, trust person, and photographer positions'); + + $larp = LarpFactory::new()->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->create(); + + // Create various staff positions + $organizer = LarpParticipantFactory::new()->forLarp($larp->_real())->organizer()->create(); + $gameMaster = LarpParticipantFactory::new()->forLarp($larp->_real())->gameMaster()->create(); + $trustPerson = LarpParticipantFactory::new()->forLarp($larp->_real())->trustPerson()->create(); + $photographer = LarpParticipantFactory::new()->forLarp($larp->_real())->photographer()->create(); + + StaffPositionFactory::new()->forParticipant($organizer->_real())->forMap($map->_real())->atCell('A1')->create(); + StaffPositionFactory::new()->forParticipant($gameMaster->_real())->forMap($map->_real())->atCell('B2')->create(); + StaffPositionFactory::new()->forParticipant($trustPerson->_real())->forMap($map->_real())->atCell('C3')->create(); + StaffPositionFactory::new()->forParticipant($photographer->_real())->forMap($map->_real())->atCell('D4')->create(); + + // Create a viewer who is a player + $viewerUser = UserFactory::createApprovedUser(); + $viewer = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($viewerUser) + ->player() + ->create(); + + /** @var StaffPositionService $service */ + $service = $I->grabService(StaffPositionService::class); + $visiblePositions = $service->getVisiblePositions($map->_real(), $viewer->_real()); + + // Player should see: organizer, trust person, photographer (3), but NOT game master + $I->assertCount(3, $visiblePositions, 'Player should see only 3 positions (organizer, trust person, photographer)'); + } + + public function playerDoesNotSeeGameMasterPosition(FunctionalTester $I): void + { + $I->wantTo('verify that players do not see game master positions'); + + $larp = LarpFactory::new()->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->create(); + + // Create only a game master position + $gameMaster = LarpParticipantFactory::new()->forLarp($larp->_real())->gameMaster()->create(); + StaffPositionFactory::new()->forParticipant($gameMaster->_real())->forMap($map->_real())->atCell('A1')->create(); + + // Create a viewer who is a player + $viewerUser = UserFactory::createApprovedUser(); + $viewer = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($viewerUser) + ->player() + ->create(); + + /** @var StaffPositionService $service */ + $service = $I->grabService(StaffPositionService::class); + $visiblePositions = $service->getVisiblePositions($map->_real(), $viewer->_real()); + + $I->assertCount(0, $visiblePositions, 'Player should not see game master position'); + } + + // ======================================================================== + // Functionality Tests + // ======================================================================== + + public function canSaveNewPosition(FunctionalTester $I): void + { + $I->wantTo('verify that a new position can be saved'); + + $larp = LarpFactory::new()->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->withGridSize(10, 10)->create(); + $participant = LarpParticipantFactory::new()->forLarp($larp->_real())->organizer()->create(); + + /** @var StaffPositionService $service */ + $service = $I->grabService(StaffPositionService::class); + + $position = $service->updatePosition($participant->_real(), $map->_real(), 'B3', 'On patrol'); + + $I->assertNotNull($position->getId()); + $I->assertEquals('B3', $position->getGridCell()); + $I->assertEquals('On patrol', $position->getStatusNote()); + } + + public function canUpdateExistingPosition(FunctionalTester $I): void + { + $I->wantTo('verify that an existing position can be updated'); + + $larp = LarpFactory::new()->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->withGridSize(10, 10)->create(); + $participant = LarpParticipantFactory::new()->forLarp($larp->_real())->organizer()->create(); + + // Create initial position + $existingPosition = StaffPositionFactory::new() + ->forParticipant($participant->_real()) + ->forMap($map->_real()) + ->atCell('A1') + ->create(); + + /** @var StaffPositionService $service */ + $service = $I->grabService(StaffPositionService::class); + + // Update to new position + $updatedPosition = $service->updatePosition($participant->_real(), $map->_real(), 'C5', 'Moved to new area'); + + $I->assertEquals($existingPosition->getId(), $updatedPosition->getId()); + $I->assertEquals('C5', $updatedPosition->getGridCell()); + $I->assertEquals('Moved to new area', $updatedPosition->getStatusNote()); + } + + public function invalidGridCellIsRejected(FunctionalTester $I): void + { + $I->wantTo('verify that invalid grid cells are rejected'); + + $larp = LarpFactory::new()->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->withGridSize(10, 10)->create(); + $participant = LarpParticipantFactory::new()->forLarp($larp->_real())->organizer()->create(); + + /** @var StaffPositionService $service */ + $service = $I->grabService(StaffPositionService::class); + + // Try to set position at invalid cell (Z99 is outside 10x10 grid) + $exceptionThrown = false; + try { + $service->updatePosition($participant->_real(), $map->_real(), 'Z99'); + } catch (\InvalidArgumentException $e) { + $exceptionThrown = true; + } + + $I->assertTrue($exceptionThrown, 'Invalid grid cell should throw exception'); + } + + public function validGridCellsAccepted(FunctionalTester $I): void + { + $I->wantTo('verify that valid grid cells are accepted'); + + $larp = LarpFactory::new()->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->withGridSize(10, 10)->create(); + + /** @var StaffPositionService $service */ + $service = $I->grabService(StaffPositionService::class); + + // Valid cells for 10x10 grid (A-J columns, 1-10 rows) + $I->assertTrue($service->validateGridCell($map->_real(), 'A1')); + $I->assertTrue($service->validateGridCell($map->_real(), 'J10')); + $I->assertTrue($service->validateGridCell($map->_real(), 'E5')); + + // Invalid cells + $I->assertFalse($service->validateGridCell($map->_real(), 'K1')); // Column K doesn't exist + $I->assertFalse($service->validateGridCell($map->_real(), 'A11')); // Row 11 doesn't exist + $I->assertFalse($service->validateGridCell($map->_real(), 'K11')); // Both invalid + } + + public function statusNoteIsSaved(FunctionalTester $I): void + { + $I->wantTo('verify that status note is saved correctly'); + + $larp = LarpFactory::new()->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->create(); + $participant = LarpParticipantFactory::new()->forLarp($larp->_real())->organizer()->create(); + + /** @var StaffPositionService $service */ + $service = $I->grabService(StaffPositionService::class); + + $statusNote = 'Handling incident at tavern area'; + $position = $service->updatePosition($participant->_real(), $map->_real(), 'A1', $statusNote); + + $I->assertEquals($statusNote, $position->getStatusNote()); + } + + public function positionCanBeRemoved(FunctionalTester $I): void + { + $I->wantTo('verify that a position can be removed'); + + $larp = LarpFactory::new()->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->create(); + $participant = LarpParticipantFactory::new()->forLarp($larp->_real())->organizer()->create(); + + StaffPositionFactory::new() + ->forParticipant($participant->_real()) + ->forMap($map->_real()) + ->atCell('A1') + ->create(); + + /** @var StaffPositionService $service */ + $service = $I->grabService(StaffPositionService::class); + + // Verify position exists + $I->assertNotNull($service->getPosition($participant->_real(), $map->_real())); + + // Remove position + $service->removePosition($participant->_real(), $map->_real()); + + // Verify position is removed + $I->assertNull($service->getPosition($participant->_real(), $map->_real())); + } + + // ======================================================================== + // Route Access Tests + // ======================================================================== + + public function organizerCanAccessUpdatePositionPage(FunctionalTester $I): void + { + $I->wantTo('verify that organizers can access the position update page'); + + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + $participant = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($user) + ->organizer() + ->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->create(); + + $I->amLoggedInAs($user); + + $I->amOnRoute('participant_staff_position_update', [ + 'larp' => $larp->getId(), + 'map' => $map->getId(), + ]); + + $I->seeResponseCodeIsSuccessful(); + } + + public function playerCannotAccessUpdatePositionPage(FunctionalTester $I): void + { + $I->wantTo('verify that players cannot access the position update page'); + + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + $participant = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($user) + ->player() + ->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->create(); + + $I->amLoggedInAs($user); + + $I->amOnRoute('participant_staff_position_update', [ + 'larp' => $larp->getId(), + 'map' => $map->getId(), + ]); + + $I->seeResponseCodeIs(403); + } + + public function playerCanAccessViewPositionsPage(FunctionalTester $I): void + { + $I->wantTo('verify that players can access the view positions page'); + + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + $participant = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($user) + ->player() + ->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->create(); + + $I->amLoggedInAs($user); + + $I->amOnRoute('participant_staff_position_view', [ + 'larp' => $larp->getId(), + 'map' => $map->getId(), + ]); + + $I->seeResponseCodeIsSuccessful(); + } + + // ======================================================================== + // Backoffice Integration Tests + // ======================================================================== + + public function backofficeMapViewShowsPositionPanelForOrganizer(FunctionalTester $I): void + { + $I->wantTo('verify that backoffice map view shows position update panel for organizer'); + + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + $participant = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($user) + ->organizer() + ->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->withImage('test-map.png')->create(); + + $I->amLoggedInAs($user); + + $I->amOnRoute('backoffice_larp_map_view', [ + 'larp' => $larp->getId(), + 'map' => $map->getId(), + ]); + + $I->seeResponseCodeIsSuccessful(); + // Translated text for staff_position.my_position is "My Position" + $I->see('My Position', 'div.card-header'); + } + + public function mapListWithStaffPositionsParameterShowsCorrectHeader(FunctionalTester $I): void + { + $I->wantTo('verify that map list with staff_positions=1 shows correct header'); + + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + $participant = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($user) + ->organizer() + ->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->create(); + + $I->amLoggedInAs($user); + + // Access map list with staff_positions parameter + $I->amOnPage('/backoffice/larp/' . $larp->getId() . '/map/list?staff_positions=1'); + + $I->seeResponseCodeIsSuccessful(); + // Translated text for staff_position.select_map is "Select Map" + $I->see('Select Map', 'h2'); + } + + public function mapListLinkPreservesStaffPositionsParameter(FunctionalTester $I): void + { + $I->wantTo('verify that map links preserve staff_positions parameter'); + + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::new()->create(); + $participant = LarpParticipantFactory::new() + ->forLarp($larp->_real()) + ->forUser($user) + ->organizer() + ->create(); + $map = GameMapFactory::new()->forLarp($larp->_real())->create(); + + $I->amLoggedInAs($user); + + // Access map list with staff_positions parameter + $I->amOnPage('/backoffice/larp/' . $larp->getId() . '/map/list?staff_positions=1'); + + $I->seeResponseCodeIsSuccessful(); + // Map name link should include staff_positions parameter + $I->seeElement('a[href*="staff_positions=1"]'); + } +} diff --git a/tests/Support/Factory/Core/LarpFactory.php b/tests/Support/Factory/Core/LarpFactory.php index d3c8833..4452eb4 100644 --- a/tests/Support/Factory/Core/LarpFactory.php +++ b/tests/Support/Factory/Core/LarpFactory.php @@ -5,6 +5,7 @@ use App\Domain\Account\Entity\User; use App\Domain\Core\Entity\Enum\LarpStageStatus; use App\Domain\Core\Entity\Larp; +use Tests\Support\Factory\Account\UserFactory; use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; use Zenstruck\Foundry\Persistence\Proxy; @@ -30,6 +31,7 @@ protected function defaults(): array 'maxCharacterChoices' => 3, 'minThreadsPerCharacter' => 2, 'marking' => 'DRAFT', + 'createdBy' => UserFactory::new(), ]; } diff --git a/tests/Support/Factory/Map/GameMapFactory.php b/tests/Support/Factory/Map/GameMapFactory.php new file mode 100644 index 0000000..6a90299 --- /dev/null +++ b/tests/Support/Factory/Map/GameMapFactory.php @@ -0,0 +1,118 @@ + + */ +final class GameMapFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return GameMap::class; + } + + protected function defaults(): array + { + return [ + 'larp' => LarpFactory::new(), + 'name' => self::faker()->words(3, true), + 'description' => self::faker()->sentence(), + 'gridRows' => 10, + 'gridColumns' => 10, + 'gridOpacity' => 0.5, + 'gridVisible' => true, + 'imageFile' => null, + 'createdBy' => UserFactory::new(), + ]; + } + + protected function initialize(): static + { + return $this; + } + + // ======================================================================== + // Factory States + // ======================================================================== + + /** + * Map for a specific LARP + */ + public function forLarp(mixed $larp): self + { + return $this->with([ + 'larp' => $larp, + ]); + } + + /** + * Map with a specific name + */ + public function withName(string $name): self + { + return $this->with([ + 'name' => $name, + ]); + } + + /** + * Map with a custom grid size + */ + public function withGridSize(int $rows, int $columns): self + { + return $this->with([ + 'gridRows' => $rows, + 'gridColumns' => $columns, + ]); + } + + /** + * Map with an image file + */ + public function withImage(string $imageFile): self + { + return $this->with([ + 'imageFile' => $imageFile, + ]); + } + + /** + * Map with grid hidden + */ + public function withHiddenGrid(): self + { + return $this->with([ + 'gridVisible' => false, + ]); + } + + /** + * Small map (5x5 grid) + */ + public function small(): self + { + return $this->with([ + 'gridRows' => 5, + 'gridColumns' => 5, + ]); + } + + /** + * Large map (20x20 grid) + */ + public function large(): self + { + return $this->with([ + 'gridRows' => 20, + 'gridColumns' => 20, + ]); + } +} diff --git a/tests/Support/Factory/Map/StaffPositionFactory.php b/tests/Support/Factory/Map/StaffPositionFactory.php new file mode 100644 index 0000000..ebc3f30 --- /dev/null +++ b/tests/Support/Factory/Map/StaffPositionFactory.php @@ -0,0 +1,154 @@ + + */ +final class StaffPositionFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return StaffPosition::class; + } + + protected function defaults(): array + { + return [ + 'participant' => LarpParticipantFactory::new()->organizer(), + 'map' => GameMapFactory::new(), + 'gridCell' => $this->randomGridCell(), + 'statusNote' => null, + 'positionUpdatedAt' => new \DateTime(), + ]; + } + + protected function initialize(): static + { + return $this; + } + + // ======================================================================== + // Factory States + // ======================================================================== + + /** + * Position for a specific participant + */ + public function forParticipant(mixed $participant): self + { + return $this->with([ + 'participant' => $participant, + ]); + } + + /** + * Position on a specific map + */ + public function forMap(mixed $map): self + { + return $this->with([ + 'map' => $map, + ]); + } + + /** + * Position at a specific grid cell + */ + public function atCell(string $gridCell): self + { + return $this->with([ + 'gridCell' => $gridCell, + ]); + } + + /** + * Position with a status note + */ + public function withStatusNote(string $note): self + { + return $this->with([ + 'statusNote' => $note, + ]); + } + + /** + * Position updated at a specific time + */ + public function updatedAt(\DateTimeInterface $dateTime): self + { + return $this->with([ + 'positionUpdatedAt' => $dateTime, + ]); + } + + /** + * Position for an organizer participant + */ + public function forOrganizer(): self + { + return $this->with([ + 'participant' => LarpParticipantFactory::new()->organizer(), + ]); + } + + /** + * Position for a staff participant + */ + public function forStaff(): self + { + return $this->with([ + 'participant' => LarpParticipantFactory::new()->staff(), + ]); + } + + /** + * Position for a game master + */ + public function forGameMaster(): self + { + return $this->with([ + 'participant' => LarpParticipantFactory::new()->gameMaster(), + ]); + } + + /** + * Position for a trust person + */ + public function forTrustPerson(): self + { + return $this->with([ + 'participant' => LarpParticipantFactory::new()->trustPerson(), + ]); + } + + /** + * Position for a photographer + */ + public function forPhotographer(): self + { + return $this->with([ + 'participant' => LarpParticipantFactory::new()->photographer(), + ]); + } + + // ======================================================================== + // Helper Methods + // ======================================================================== + + /** + * Generate a random grid cell reference (A1-J10) + */ + private function randomGridCell(): string + { + $col = chr(65 + rand(0, 9)); // A-J + $row = rand(1, 10); + return $col . $row; + } +} diff --git a/tests/Unit/Domain/Map/MapLocationTest.php b/tests/Unit/Domain/Map/MapLocationTest.php new file mode 100644 index 0000000..93f24cc --- /dev/null +++ b/tests/Unit/Domain/Map/MapLocationTest.php @@ -0,0 +1,80 @@ +createMock(GameMap::class); + $map->method('getGridRows')->willReturn($gridRows); + $map->method('getGridColumns')->willReturn($gridColumns); + + $location = new MapLocation(); + $location->setMap($map); + $location->setPositionX($positionX); + $location->setPositionY($positionY); + + $this->assertSame($expected, $location->getGridCoordinatesString()); + } + + public static function gridCoordinatesProvider(): array + { + return [ + '10x10 top-left corner (0%, 0%)' => [10, 10, 0.0, 0.0, 'A1'], + '10x10 bottom-right corner (99%, 99%)' => [10, 10, 99.0, 99.0, 'J10'], + '10x10 center (50%, 50%)' => [10, 10, 50.0, 50.0, 'F6'], + '10x10 first row, second column' => [10, 10, 15.0, 5.0, 'B1'], + '10x10 last row, first column' => [10, 10, 5.0, 95.0, 'A10'], + '5x5 center (50%, 50%)' => [5, 5, 50.0, 50.0, 'C3'], + '5x5 last cell' => [5, 5, 99.0, 99.0, 'E5'], + '20x20 center (50%, 50%)' => [20, 20, 50.0, 50.0, 'K11'], + '10x10 exactly at boundary (10%, 10%)' => [10, 10, 10.0, 10.0, 'B2'], + ]; + } + + public function testGetGridCoordinatesStringWithoutMap(): void + { + $location = new MapLocation(); + // No map set + + $this->assertSame('-', $location->getGridCoordinatesString()); + } + + public function testGetGridCoordinatesStringEdgeCases(): void + { + $map = $this->createMock(GameMap::class); + $map->method('getGridRows')->willReturn(10); + $map->method('getGridColumns')->willReturn(10); + + $location = new MapLocation(); + $location->setMap($map); + + // Test 100% position (should be clamped to last cell) + $location->setPositionX(100.0); + $location->setPositionY(100.0); + $this->assertSame('J10', $location->getGridCoordinatesString()); + + // Test exactly at boundary + $location->setPositionX(0.0); + $location->setPositionY(0.0); + $this->assertSame('A1', $location->getGridCoordinatesString()); + } +} diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 80990a5..4cb7476 100755 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -788,6 +788,21 @@ enum: outdoor: "Outdoor" special: "Special" transition: "Transition" + marker_shape: + dot: "Dot" + circle: "Circle" + square: "Square" + diamond: "Diamond" + triangle: "Triangle" + house: "House" + arrow_up: "Arrow Up" + arrow_down: "Arrow Down" + arrow_left: "Arrow Left" + arrow_right: "Arrow Right" + star: "Star" + flag: "Flag" + pin: "Pin" + cross: "Cross" # Role enumeration translations user_role: @@ -1242,6 +1257,42 @@ survey: assigned: 'Assigned' confirmed: 'Confirmed' +# Staff Position Tracking +staff_position: + title: 'Staff Position' + select_map: 'Select Map' + no_maps: 'No maps available for this LARP' + update_title: 'Update My Position' + view_title: 'Staff Positions' + view_positions: 'View Staff' + update_my_position: 'Update My Position' + current_position: 'Current Position' + updated_at: 'Updated at' + tap_to_select: 'Tap on the grid to select your position' + selected_cell: 'Selected Cell' + no_cell_selected: 'No cell selected' + status_note: 'Status Note' + status_note_placeholder: 'e.g., On break, Handling incident...' + save_position: 'Save Position' + remove_position: 'Remove Position' + remove_confirm: 'Are you sure you want to remove your position from this map?' + updated: 'Position updated successfully' + removed: 'Position removed successfully' + invalid_cell: 'Invalid grid cell selected' + select_cell: 'Please select a grid cell first' + limited_visibility: 'You are viewing a limited list of staff positions. Contact organizers for full visibility.' + active_positions: 'Active Positions' + positions_list: 'Positions List' + staff_member: 'Staff Member' + role: 'Role' + cell: 'Cell' + status: 'Status' + no_positions: 'No staff positions recorded yet' + already_exists: 'You already have a position on this map' + show_on_map: 'Show Staff Positions' + update_position: 'Update Position' + my_position: 'My Position' + # Legal Pages privacy_policy: title: 'Privacy Policy'