Skip to content

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-158 in 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) and sectors[] = {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 SVG points attribute 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_path is kept alongside so nothing is lost, and curves are flattened into the polygon for editing.
  • display_order gives the side panel and the consumer zone list a stable, admin-chosen order (today: DB insertion order).
  • MatchSector needs nothing new for v1: it links to its template Sector (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}.json gains an optional map block (image file under config/stadiums/{slug}/map.png + per-sector shape), and app:seed-stadiums reconciles 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 plus interact.js for 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

  1. 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.
  2. Buy self-hosted (Seatmap.pro on-prem). €20 000/yr for an editor whose 90 % is seat placement we do not need. Rejected.
  3. 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.
  4. 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.
  5. 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.code is 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 a LOCKED match 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.