ADR 0002: Admin stadium map — sectors as SVG polygons over a stadium plan¶
- Status: Proposed (research deliverable for HNSTIK-158)
- Date: 2026-09-14
- Deciders: HNS Ticketing engineering (Tamer Zoubi, Ivan Knezevic)
- Refines: stadium-design-tool.md (never-built polygon schema, §"Data model"), stadium-design-tool-tech.md (Konva sector-polygon prototype), e3-f5-stadium-visualization.md, flow-admin-stadium-configuration.md (steps 2 and 4 already promise an "interactive stadium map")
- JIRA: HNSTIK-158
- Implementation: phases 1–2 on branch
HNSTIK-158in hns-ticketing-backend (BE-110) and hns-admin-portal (AP-086), 2026-09-14; phase 3 (coloured read-only map on the match config tab, SVG import) is a follow-up
Context¶
HNSTIK-158 asks for two things: research how sectors should be shown on a stadium map, and replace the
admin portal's sector grid (/admin/stadiums/{id}/sectors, a responsive card grid in DB order) with a
real stadium map with the sectors overlaid, on which an admin can see where a sector is, drag it,
rename it, delete it and create new ones. Consumer-facing maps are explicitly a later ticket — but the
data this ticket creates is what those maps will render, so the format must be chosen with them in mind.
What exists today¶
| Layer | State (verified 2026-09-14 on dev) |
|---|---|
| Admin, stadium level | templates/admin/stadium/sectors.html.twig — card grid (name, code, type, capacity, has seat map, seat count) with Uredi / Plan sjedala / Snake / delete per card and an Add sector modal. No geometry, no ordering. |
| Admin, match level | templates/admin/match/_stadium_config.html.twig — an HTML table of MatchSectors with bulk price/purpose/status updates, validate, lock/unlock. |
| Per-sector seat map | A 1 700-line Konva.js canvas editor (assets/seat-map-editor.js) — seats are grid cells (row/col × CELL_SIZE 20), saved as seat_map_json; a tracing "blueprint" image can be loaded per session but is never persisted. |
| Backend | Stadium (name, city, type, base_capacity, gates JSON), Sector (name, code, capacity, type, has_seat_map, seat_count, gate), SectorSeatMap, MatchSector. No positional or visual field anywhere — no x/y, polygon, svg path, display order, colour, image. No file-upload pipeline in either repo. |
| Seeds | config/stadiums/{slug}.json + {slug}/{CODE}.json seat maps for Opus Arena, Poljud, Rujevica, Varaždin. No map geometry. |
| Docs | stadium-design-tool.md §data model already proposes stadium.svg_template, stadium_sector.svg_path, display_order; stadium-design-tool-tech.md sketches sector polygons as Konva.Line. Neither was built. |
| Consumer | Mobile web picks a "zone" from a flat card list (GET /matches/{id}/zones → name, price, availability). No map. |
How the reference sites do it¶
- nk-osijek.hr/ulaznice/mapa-stadiona — a static PNG: a schematic (not a photo) with coloured sector blocks (A1–A6, B1–B4, C1–C6, D1–D4, VIP, Skybox) around the pitch, gate labels (ULAZ G1…G11), first-aid and ticket-point icons. This is the visual target admins and fans recognise.
- tickets.gnkdinamo.hr and ulaznice.nk-osijek.hr — both run the ulaznice.hr (Dekod/Entrio) Angular
shop. The event payload carries
venue.svg(Inkscape layers: pitch + misc,viewBox 0 0 1122 793) andsectors[] = {id: "TZD03", svgPath: "M 438.4 …", svgFontSize}; the client renders one inline<svg>with a<path id="TZD03">per sector, a<title>tooltip and availability via CSS classes (active-path/inactive-path). No canvas, no image map, no third-party widget. - The FIFA Drive link in the ticket requires a Google sign-in and could not be opened; FIFA/UEFA venue regulations mandate only colour-coded, legible ground plans and a segregable away sector — no sector nomenclature. Price-category colours (Cat 1 red …) are a per-event style, not geometry.
Market survey (14 Sep 2026)¶
| Option | Section editor | Self-host | Price | Geometry ownership |
|---|---|---|---|---|
| seats.io (BE) | Best-in-class polygon designer, trace over a reference image | No | from €450/yr + €0.18 per used seat | No export of charts |
| Seatmap.pro (CY) | Drag-and-drop editor, SVG import/export | Yes (Docker/Helm) | €400–10 000/yr cloud, €20 000/yr on-prem | SVG export |
| 3D Digital Venue / Ticketmaster | Vendor-built 2D/3D twins, no self-service editor | No | undisclosed | vendor |
| Tixly, SecuTix, Ticket Tailor, Eventbrite, Oveit | Editors exist but are tied to their own ticketing platform | No | n/a | n/a |
Open-source building blocks for a self-built editor (all MIT/BSD, all framework-free):
| Building block | Fit for us |
|---|---|
Inline SVG + interact.js (1.4 MB) or d3-drag (43 KB) for vertex/shape dragging |
Lightest; a few hundred lines for handles, midpoint insert, snap; renders identically on the consumer side |
Leaflet 1.9 + Leaflet-Geoman (free tier) on CRS.Simple + L.imageOverlay |
Vertex drag, midpoint insert, snapping, self-intersection guard out of the box; ~7 MB and a "map" look; speaks GeoJSON |
| Konva 10 (already a prod dependency) | Reuses our seat-map engine, but canvas: no SVG round-trip, DIY anchors, second coordinate system |
| Fabric.js / Paper.js | Capable, but polygon editing is DIY (Fabric) or an all-in canvas engine (Paper) we do not otherwise need |
| svgedit / Method Draw | Full drawing apps, too heavy to embed for one shape type |
Decision (proposed)¶
Build a small, SVG-based sector map editor into the admin portal, storing sector shapes as normalised polygons on a per-stadium plan image, and reuse that exact SVG on the consumer side later. Do not buy a seat-map platform: we already own the seat level, seats.io locks the geometry in, and the only self-hosted alternative costs €20 000/yr for an editor we need a fraction of.
1. Data model (backend)¶
stadiums
map_image_path VARCHAR(255) NULL -- uploaded plan (PNG/JPG/SVG), served by the backend
map_image_width INT NULL, map_image_height INT NULL
map_json JSONB NULL -- {"version":1, "pitch": [[x,y]…] | null, "north": "top", "gates":[{"name":"G1","x":0.12,"y":0.05}]}
sectors
map_shape JSONB NULL -- {"type":"polygon","points":[[0.12,0.34],…],"label":{"x":0.2,"y":0.4},"svg_path":"M …" (optional import source)}
display_order INT NOT NULL DEFAULT 0
- Points are normalised 0–1 against the plan image, so the same shape renders at any size on any client
(admin SVG, mobile web SVG, React Native
react-native-svg) and survives re-uploading a higher-resolution plan. The SVGpointsattribute is derived at render time (x * width, y * height). - The polygon is the editable source of truth. When a designer's SVG is imported, the original
svg_pathis kept alongside so nothing is lost, and curves are flattened into the polygon for editing. display_ordergives the side panel and the consumer zone list a stable, admin-chosen order (today: DB insertion order).MatchSectorneeds nothing new for v1: it links to its templateSector(sector_id) and inherits the shape. Ad-hoc match-only sectors (sector_id = NULL) are listed as "unplaced" on the match map; giving them their own shape is a v2 item. Colour (price category, status, purpose) is never stored in the geometry — it is a per-match style layer, exactly as ulaznice.hr and UEFA do it.- Seeds:
config/stadiums/{slug}.jsongains an optionalmapblock (image file underconfig/stadiums/{slug}/map.png+ per-sectorshape), andapp:seed-stadiumsreconciles it like it already reconciles name/capacity. Existing stadiums without a map keep working unchanged.
2. API (backend, OpenAPI in the same PR)¶
| Endpoint | Purpose |
|---|---|
GET /admin/stadiums/{id}/map |
Image meta + every sector's shape in one payload (what the editor and, later, the consumer map load) |
PUT /admin/stadiums/{id}/map |
Save all shapes + map_json at once (one undo-able document; optimistic version to refuse a stale save) |
POST /admin/stadiums/{id}/map/image |
Multipart upload, PNG/JPG/SVG ≤ 5 MB, returns dimensions; stored under the backend's uploads volume in v1 (S3 keys already exist in .env.example for the planned PDF distribution — switch when that lands) |
POST /admin/stadiums/{id}/map/import-svg |
v2 — parse <path id="CODE"> / <g id="CODE"> into shapes by sector code |
| existing sector CRUD | map_shape and display_order become optional fields of SectorRequest / serializeSector |
The seat-map endpoints are untouched: placing a sector on the map must never regenerate Seat rows.
3. Admin UI¶
Replace the grid on /admin/stadiums/{id}/sectors with a split view:
- Left: the map — the plan image as
<image>inside an<svg viewBox="0 0 W H">, one<g>per sector (polygon + label), zoom/pan (wheel + drag, fit-to-view), a legend. Modes, like the seat editor's S/D/E tools: Select/move (drag the whole polygon, arrow-key nudge), Edit vertices (drag handles, double-click an edge to insert, Alt-click a vertex to remove, snap to a 1 % grid), Draw (click vertices, Enter/double-click closes → opens the existing Dodaj sektor modal pre-filled), Delete (Del key / panel, same confirmation as today). Undo/redo history like the seat editor. Implementation: hand-written inline SVG driven by the Alpine component plusinteract.jsfor pointer handling (~1.4 MB, MIT) — no new rendering engine. Konva stays for seats; the two never share a coordinate system. - Right: the sector panel — the current card content condensed to rows (name, code, type, capacity,
seat map ✓, gate) in
display_order(drag to reorder), with the same Uredi / Plan sjedala / Snake / delete actions, inline rename, and an "Unplaced" group with Postavi na kartu (starts Draw mode). Selecting a row highlights the polygon and vice versa. - No plan uploaded yet → an upload drop zone plus the panel list, i.e. today's information without the grid; nothing regresses for stadiums without an image.
- Match config page (
_stadium_config.html.twig) — read-only instance of the same SVG, coloured by price category / status / purpose with a legend, click selects the table row; this finally delivers flow steps 2 and 4 ("interactive stadium map", "visually updates the map with category colors"). - HR/EN strings via
__tt; Playwright smoke in the admin repo as the regression guard (the portal has no JS unit harness).
4. Phasing and effort (one developer)¶
| Phase | Content | Estimate |
|---|---|---|
| 0 — spike | Inline-SVG editor prototype with interact.js on the Opus Arena plan from raw-notes/Stadium plans; decide grid snapping and handle UX |
2–3 days |
| 1 — backend | Migration (3 stadium + 2 sector columns), upload endpoint + storage, GET/PUT …/map, SectorRequest fields, seed map block, OpenAPI, tests |
3 days |
| 2 — admin editor | Split view, modes, panel, reorder, i18n, Playwright smoke | 5–7 days |
| 3 — match view + import | Coloured read-only map on the match config tab; Inkscape/Illustrator SVG import by sector code | 2–3 days |
| Total | ~2.5–3 weeks |
Consumer maps (mobile web, quota portal, React Native) then only need GET /matches/{id}/zones to
include shape and display_order — the rendering is the same SVG.
Options considered¶
- Buy (seats.io). Best editor on the market and a hosted consumer widget for free, but per-used-seat pricing for a seat layer we already own, no geometry export, and a hard dependency of the consumer map on a third party. Rejected.
- Buy self-hosted (Seatmap.pro on-prem). €20 000/yr for an editor whose 90 % is seat placement we do not need. Rejected.
- Extend the Konva seat editor with a "sector polygon" layer (the 2025 docs' plan). Reuses code and the team knows it, but canvas cannot be reused as-is on the consumer side, polygon anchors are DIY, and it mixes the grid-space seat coordinate system with a world-space stadium map. Rejected for the sector level; Konva remains the right tool for seats.
- Leaflet + Geoman. Tempting for the free vertex tooling and snapping, but 7 MB, GeoJSON plumbing and a "web map" look for a static plan. Kept as the fallback if the spike shows hand-written SVG editing is more work than two days.
- Inline SVG +
interact.js(chosen). Smallest, framework-free, identical rendering everywhere, matches what the two Croatian reference shops already do.
Consequences¶
- Positive: geometry is ours and portable (plain JSON, renders as SVG anywhere); admins get the map the flow docs promised; the consumer map becomes a rendering task, not a data task; no vendor fee.
- Negative: we take on an editor (vertex handles, snapping, undo) ourselves — bounded by the spike; the backend gets its first file-upload pipeline (storage, size limits, content-type checks) which must be designed once and reused for the planned PDF distribution.
- Housekeeping to fold into the implementation ticket:
SectorRequest.codeis validated as/^[A-Z0-9]+$/while the seed files use hyphenated codes (JUG-A-D) — creating or renaming such a sector from the new editor hits this immediately; the editor must respect aLOCKEDmatch config the way the table does. - Open questions for product: (a) schematic plans (Osijek-style) or aerial photos as the background —
recommendation: schematic, drawn once per stadium from the official plans in
raw-notes/Stadium plans(Poljud, Opus Arena, Rujevica, Varaždin; Maksimir still missing); (b) whether per-match sector overrides ever need their own shape (v2); (c) storage — backend volume now, S3 when PDF distribution lands.
References¶
- Codebase inventory of the current stadium/sector/seat-map stack (admin + backend + docs), 2026-09-14.
- ulaznice.hr shop payloads (
venue.svg,sectors[].svgPath) observed on tickets.gnkdinamo.hr and ulaznice.nk-osijek.hr. - seats.io docs and pricing; seatmap.pro pricing; geoman.io; interactjs.io; konvajs.org FAQ; FIFA Stadium Safety & Security Regulations; UEFA Stadium Infrastructure Regulations (ed. 2025), Art. 18.