feat: render the floor plan in html instead of svg

This commit is contained in:
fromme committed 2026-09-17 16:10:50 +02:00
1 parent 5a01054b0e
commit ad5d5acb00
20 files changed
+256 -356

No files matched your search

+12 -20
View File
@@ -24,12 +24,10 @@ use Symfony\Component\DependencyInjection\Attribute\When;
* Seeds the office rooms, the desks, the parking spots and a handful of employees.
*
* Dev only, and idempotent: it matches on the natural key (group and resource label, user email)
* so it can be re-run after a migration without duplicating anything. The svgElementId values --
* on both resources and groups -- are the contract with
* templates/board/plan/_<type>.html.twig: change one and the other stops drawing it.
* so it can be re-run after a migration without duplicating anything.
*
* The room names and their desk membership are placeholders standing in for the real office
* layout, and they only make sense together with the geometry in _desk.html.twig.
* layout.
*/
#[When('dev')]
#[AsCommand(name: 'app:seed', description: 'Seeds desks, parking spots and demo employees (dev only)')]
@@ -50,15 +48,10 @@ final class SeedCommand extends Command
$created = 0;
// Four rooms of three desks, in the order they are drawn down the plan.
// Four rooms of three desks, in the order they are listed on the plan.
$rooms = [];
foreach ([
['Raum Nord', 'room-01'],
['Raum Ost', 'room-02'],
['Raum Süd', 'room-03'],
['Besprechungsraum', 'room-04'],
] as $position => [$label, $svgElementId]) {
[$room, $isNew] = $this->ensureGroup(ResourceType::DESK, $label, $position, $svgElementId);
foreach (['Raum Nord', 'Raum Ost', 'Raum Süd', 'Besprechungsraum'] as $position => $label) {
[$room, $isNew] = $this->ensureGroup(ResourceType::DESK, $label, $position);
$rooms[] = $room;
$created += $isNew;
}
@@ -67,15 +60,14 @@ final class SeedCommand extends Command
$created += $this->ensureResource(
ResourceType::DESK,
\sprintf('Schreibtisch %02d', $i),
\sprintf('desk-%02d', $i),
$rooms[intdiv($i - 1, 3)],
);
}
// Parking is deliberately left ungrouped: the car park is one area, and it is what keeps
// the "a plan without groups draws its own frame" path exercised by real data.
// the "a plan without rooms" path exercised by real data.
for ($i = 1; $i <= 6; ++$i) {
$created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i), \sprintf('parking-%02d', $i));
$created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i));
}
// One administrator on purpose, so the dev environment exercises both roles that open
@@ -97,13 +89,13 @@ final class SeedCommand extends Command
return Command::SUCCESS;
}
private function ensureResource(ResourceType $type, string $label, string $svgElementId, ?ResourceGroup $group = null): int
private function ensureResource(ResourceType $type, string $label, ?ResourceGroup $group = null): int
{
$resource = $this->resources->findOneBy(['type' => $type, 'label' => $label]);
if (null !== $resource) {
// Backfill only. A resource seeded before groups existed has no room and would never
// be drawn inside one, but a group somebody moved it into by hand is left alone.
// be listed under one, but a group somebody moved it into by hand is left alone.
if (null !== $group && null === $resource->getGroup()) {
$resource->setGroup($group);
}
@@ -111,7 +103,7 @@ final class SeedCommand extends Command
return 0;
}
$resource = new Resource($type, $label, $svgElementId);
$resource = new Resource($type, $label);
$resource->setGroup($group);
$this->em->persist($resource);
@@ -121,7 +113,7 @@ final class SeedCommand extends Command
/**
* @return array{ResourceGroup, int} the group, and 1 if it was created
*/
private function ensureGroup(ResourceType $type, string $label, int $position, string $svgElementId): array
private function ensureGroup(ResourceType $type, string $label, int $position): array
{
$group = $this->groups->findOneBy(['type' => $type, 'label' => $label]);
@@ -129,7 +121,7 @@ final class SeedCommand extends Command
return [$group, 0];
}
$group = new ResourceGroup($type, $label, $position, $svgElementId);
$group = new ResourceGroup($type, $label, $position);
$this->em->persist($group);
return [$group, 1];
+2 -20
View File
@@ -28,16 +28,9 @@ class Resource
#[Assert\NotBlank]
private string $label;
/**
* Id of the matching element in the hand-authored floor plan SVG. Null for anything that is
* bookable but not drawn on the plan.
*/
#[ORM\Column(length: 50, nullable: true)]
private ?string $svgElementId = null;
/**
* Where it physically is. Nullable because a group is optional: a kind of resource that is
* not split across rooms simply has none, and its plan draws its own single frame.
* not split across rooms simply has none, and the plan lists it without a room heading.
*/
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
@@ -50,11 +43,10 @@ class Resource
#[ORM\Column]
private bool $active = true;
public function __construct(ResourceType $type, string $label, ?string $svgElementId = null)
public function __construct(ResourceType $type, string $label)
{
$this->type = $type;
$this->label = $label;
$this->svgElementId = $svgElementId;
}
public function getId(): ?int
@@ -77,16 +69,6 @@ class Resource
$this->label = $label;
}
public function getSvgElementId(): ?string
{
return $this->svgElementId;
}
public function setSvgElementId(?string $svgElementId): void
{
$this->svgElementId = $svgElementId;
}
public function getGroup(): ?ResourceGroup
{
return $this->group;
+1 -19
View File
@@ -40,19 +40,11 @@ class ResourceGroup
#[ORM\Column]
private int $position = 0;
/**
* Id of the matching frame in the hand-authored floor plan SVG -- the same role svgElementId
* plays on Resource. Null for a group that is not drawn.
*/
#[ORM\Column(length: 50, nullable: true)]
private ?string $svgElementId = null;
public function __construct(ResourceType $type, string $label, int $position = 0, ?string $svgElementId = null)
public function __construct(ResourceType $type, string $label, int $position = 0)
{
$this->type = $type;
$this->label = $label;
$this->position = $position;
$this->svgElementId = $svgElementId;
}
public function getId(): ?int
@@ -84,14 +76,4 @@ class ResourceGroup
{
$this->position = $position;
}
public function getSvgElementId(): ?string
{
return $this->svgElementId;
}
public function setSvgElementId(?string $svgElementId): void
{
$this->svgElementId = $svgElementId;
}
}
+3 -4
View File
@@ -8,10 +8,9 @@ namespace App\Enum;
* The kinds of thing that can be booked.
*
* **This enum is the extension point.** The dashboard builds one tile per case, the board route
* accepts one backing value per case, and the floor plan is looked up by convention from that
* same value. Adding a third bookable kind is therefore: a case here, a
* `templates/board/plan/_<value>.html.twig` with its geometry, and rows in `app:seed`. Nothing
* else needs editing — in particular no template lists the kinds by hand.
* accepts one backing value per case, and the floor plan renders every kind the same way.
* Adding a third bookable kind is therefore: a case here and rows in `app:seed`.
* Nothing else needs editing — in particular no template lists the kinds by hand.
*
* The backing value is both what is persisted in `resource.type` and what appears in a URL, so
* there is no slug mapping to keep in sync. The label, plural and description are the only
+5 -5
View File
@@ -8,10 +8,10 @@ use App\Entity\ResourceGroup;
use App\Enum\SpotState;
/**
* The spots of one room, in draw order.
* The spots of one room, in display order.
*
* The floor plan needs the resources of a room together so it can frame them, and it needs the
* room's own label and geometry key alongside them -- which is all this is. The grouping is done
* The floor plan needs the resources of a room together so it can list them under a heading, and
* it needs the room's own label alongside them -- which is all this is. The grouping is done
* once in the presenter rather than in Twig, because deciding what goes where is not a
* presentation concern and a template cannot express it without a nested loop over every spot
* per room.
@@ -32,7 +32,7 @@ final readonly class SpotGroup
*
* Ungrouped spots collect into a single trailing entry whose group is null. That entry is
* not an error case: a kind of resource that is not split across rooms has no groups at all,
* and its plan draws its own frame instead.
* and the plan lists its spots without a heading instead.
*
* @param list<Spot> $spots
*
@@ -56,7 +56,7 @@ final readonly class SpotGroup
// instance per row, so this groups exactly as an id would -- and it also works for a
// group that has not been persisted yet, where every id is still null and every
// group would otherwise collapse into one. The repository orders by group, so
// insertion order is already draw order.
// insertion order is already display order.
$grouped[spl_object_id($group)][] = $spot;
}
+1 -1
View File
@@ -97,7 +97,7 @@ final readonly class BoardContextFactory
'is_past' => $selected < $today,
'resources' => $resources,
'spots' => $spots,
// The same spots again, partitioned into the rooms the plan frames them with.
// The same spots again, partitioned into the rooms the plan lists them under.
'spot_groups' => SpotGroup::over(array_values($spots)),
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
];