Stadium Design Tool - Technical Specification¶
This document provides the detailed technical specification for implementing the Stadium Design Tool using Konva.js + TypeScript as a standalone SPA embedded in the Symfony admin portal.
Executive Summary¶
| Decision | Choice | Rationale |
|---|---|---|
| Rendering | Canvas (not SVG) | 50K seat performance |
| Library | Konva.js | Best balance of features/performance |
| Language | TypeScript | Type safety for complex editor |
| State | Immer + custom | Immutable updates, undo/redo |
| Build | Vite | Fast dev, single bundle output |
| Integration | Standalone SPA | Clean separation from Symfony |
| Framework | None (vanilla) | Minimal overhead |
Technology Stack¶
Core Dependencies¶
{
"dependencies": {
"konva": "^9.3.0",
"immer": "^10.0.0"
},
"devDependencies": {
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}
Why These Choices?¶
Konva.js¶
- Scene Graph Architecture - Manages object hierarchy (Stage → Layers → Groups → Shapes)
- Built-in Interactions - Drag, resize, rotate, selection out of the box
- Event System - Click, hover, drag events on any canvas element
- JSON Serialization - Native
stage.toJSON()/stage.find()for persistence - Performance - Handles 50K+ shapes with proper layering and caching
- Official Seat Example - Seats Reservation Widget
TypeScript¶
- Type safety prevents errors in complex editor state
- Better IDE support for large codebases
- Self-documenting code through interfaces
- Easier refactoring and maintenance
Immer¶
- Immutable state updates with mutable syntax
- Perfect for undo/redo implementation
- Works naturally with TypeScript
Vite¶
- Fast HMR during development
- Outputs single bundled JS/CSS files
- No runtime dependencies in output
Architecture¶
System Architecture¶
┌─────────────────────────────────────────────────────────────────────┐
│ HNS Admin Portal (Symfony/Twig) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Stadium Design Tool Container (Twig Template) │ │
│ │ │ │
│ │ <div id="stadium-design-tool" │ │
│ │ data-stadium-id="maksimir" │ │
│ │ data-api-base="/api/admin/stadiums"> │ │
│ │ </div> │ │
│ │ <script src="/assets/stadium-design-tool.js"></script> │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Stadium Design Tool Application (TypeScript/Konva) │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Toolbar │ │ Canvas │ │ Properties │ │ │
│ │ │ Panel │ │ (Konva) │ │ Panel │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ State Manager (Immer) │ │ │
│ │ │ - EditorState (tool, zoom, selection) │ │ │
│ │ │ - StadiumData (sectors, blocks, rows, seats) │ │ │
│ │ │ - HistoryStack (undo/redo) │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Symfony Backend API │ │
│ │ │ │
│ │ GET /api/admin/stadiums/{id} → Load stadium config │ │
│ │ PUT /api/admin/stadiums/{id} → Save stadium config │ │
│ │ POST /api/admin/stadiums/{id}/validate → Validate configuration│ │
│ │ POST /api/admin/stadiums/{id}/generate → Generate seat records │ │
│ │ POST /api/admin/stadiums/{id}/export → Export (JSON/CSV/SVG) │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL Database │ │
│ └────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Konva Layer Structure¶
Stage (container)
├── BackgroundLayer
│ └── Field rectangle, stadium outline
├── SectorLayer
│ ├── Sector A (Group)
│ │ ├── Polygon (sector boundary)
│ │ └── Label (sector name)
│ ├── Sector B (Group)
│ └── ...
├── SeatLayer
│ ├── Block A1 (Group)
│ │ ├── Row 1 (Group)
│ │ │ ├── Seat 1 (Circle/Rect)
│ │ │ ├── Seat 2 (Circle/Rect)
│ │ │ └── ...
│ │ ├── Row 2 (Group)
│ │ └── ...
│ └── ...
├── AnnotationLayer
│ ├── Aisle markers
│ ├── Gate labels
│ └── Dimension lines
└── InteractionLayer
├── Selection rectangle
├── Resize handles
└── Snap guides
Data Structures¶
TypeScript Interfaces¶
// Core Types
interface Stadium {
id: string;
code: string;
name: string;
city: string;
country: string;
readonly capacity: number; // Auto-calculated from sum of sector seat counts
type: 'football' | 'multipurpose';
geometry: StadiumGeometry;
sectors: Sector[];
specialAreas: SpecialArea[];
priceZones: PriceZone[];
metadata: StadiumMetadata;
}
interface StadiumGeometry {
viewBox: ViewBox;
fieldRect: Rectangle;
orientation: 'north' | 'south' | 'east' | 'west';
coordinateOrigin: 'center' | 'top-left';
scale: number; // pixels per meter
}
interface ViewBox {
x: number;
y: number;
width: number;
height: number;
}
interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
// Sector & Seating Hierarchy
interface Sector {
id: string;
code: string;
name: string;
type: SectorType;
accessLevel: 'public' | 'vip' | 'technical' | 'away';
path: string; // SVG path or Konva Line points
priceZoneId: string;
readonly calculatedCapacity: number; // Computed from total seat count across blocks/rows
blocks: Block[];
gates: string[]; // gate IDs
displayOrder: number;
}
type SectorType = 'stand' | 'corner' | 'premium' | 'general' | 'away' | 'technical';
interface Block {
id: string;
code: string;
name: string;
sectorId: string;
rows: Row[];
displayOrder: number;
}
interface Row {
id: string;
identifier: string; // "1", "A", "AA"
blockId: string;
seatCount: number;
startSeatNumber: number;
direction: 'LTR' | 'RTL' | 'center-out';
curveRadius: number | null; // null for straight rows
yPosition: number; // relative to block
seatWidth: number;
seats: Seat[];
}
interface Seat {
id: string;
rowId: string;
number: string;
type: SeatType;
priceZoneId: string | null; // override sector default
coordinates: SeatCoordinates;
isManuallyPositioned: boolean;
}
type SeatType =
| 'standard'
| 'technical'
| 'accessibility'
| 'companion'
| 'restricted-view'
| 'premium';
interface SeatCoordinates {
x: number; // stadium coordinates (meters)
y: number;
svgX: number; // canvas coordinates (pixels)
svgY: number;
}
// Special Areas
interface SpecialArea {
id: string;
type: 'aisle' | 'stairs' | 'gate' | 'column' | 'camera' | 'emergency-exit';
name: string;
path: string;
width?: number;
properties: Record<string, unknown>;
}
// Price Zones
interface PriceZone {
id: string;
code: string;
name: string;
color: string; // hex color
defaultPrice: number;
displayOrder: number;
}
// Metadata
interface StadiumMetadata {
version: string;
status: 'draft' | 'published' | 'archived';
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
}
Editor State¶
interface EditorState {
// Tool State
activeTool: ToolType;
toolSettings: ToolSettings;
// View State
zoom: number;
panOffset: { x: number; y: number };
visibleLayers: LayerType[];
// Selection State
selectedIds: string[];
selectionType: 'sector' | 'block' | 'row' | 'seat' | 'area' | null;
selectionRect: Rectangle | null;
// Edit State
isDrawing: boolean;
drawingPoints: number[];
isDragging: boolean;
dragStart: { x: number; y: number } | null;
// UI State
showGrid: boolean;
gridSize: number;
snapToGrid: boolean;
showLabels: boolean;
// History
historyIndex: number;
historyStack: Stadium[];
maxHistorySize: number;
}
type ToolType =
| 'select'
| 'pan'
| 'zoom'
| 'sector'
| 'block'
| 'row'
| 'seat'
| 'aisle'
| 'stairs'
| 'gate';
type LayerType =
| 'background'
| 'sectors'
| 'seats'
| 'annotations'
| 'grid';
interface ToolSettings {
sector: {
type: SectorType;
defaultPriceZone: string;
};
row: {
seatCount: number;
direction: 'LTR' | 'RTL';
seatWidth: number;
rowSpacing: number;
};
seat: {
type: SeatType;
size: number;
};
}
Core Implementation¶
Application Entry Point¶
// src/main.ts
import { StadiumDesignTool } from './StadiumDesignTool';
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('stadium-design-tool');
if (!container) return;
const stadiumId = container.dataset.stadiumId;
const apiBase = container.dataset.apiBase || '/api/admin/stadiums';
const app = new StadiumDesignTool({
container,
stadiumId,
apiBase,
onSave: (stadium) => console.log('Saved:', stadium),
onError: (error) => console.error('Error:', error),
});
app.init();
});
Main Application Class¶
// src/StadiumDesignTool.ts
import Konva from 'konva';
import { produce } from 'immer';
import type { Stadium, EditorState } from './types';
import { StateManager } from './StateManager';
import { CanvasRenderer } from './CanvasRenderer';
import { ToolbarPanel } from './ui/ToolbarPanel';
import { PropertiesPanel } from './ui/PropertiesPanel';
import { ApiClient } from './ApiClient';
interface StadiumDesignToolOptions {
container: HTMLElement;
stadiumId: string;
apiBase: string;
onSave?: (stadium: Stadium) => void;
onError?: (error: Error) => void;
}
export class StadiumDesignTool {
private options: StadiumDesignToolOptions;
private stage: Konva.Stage;
private stateManager: StateManager;
private renderer: CanvasRenderer;
private api: ApiClient;
constructor(options: StadiumDesignToolOptions) {
this.options = options;
this.api = new ApiClient(options.apiBase);
}
async init(): Promise<void> {
// Create UI structure
this.createLayout();
// Initialize Konva stage
this.stage = new Konva.Stage({
container: 'canvas-container',
width: 800,
height: 600,
});
// Initialize state manager
this.stateManager = new StateManager();
// Initialize renderer
this.renderer = new CanvasRenderer(this.stage, this.stateManager);
// Load stadium data
if (this.options.stadiumId) {
const stadium = await this.api.loadStadium(this.options.stadiumId);
this.stateManager.setStadium(stadium);
this.renderer.render();
}
// Set up event handlers
this.setupEventHandlers();
// Initialize UI panels
this.initializePanels();
}
private createLayout(): void {
this.options.container.innerHTML = `
<div class="sdt-container">
<div class="sdt-toolbar" id="toolbar-panel"></div>
<div class="sdt-main">
<div class="sdt-canvas" id="canvas-container"></div>
</div>
<div class="sdt-properties" id="properties-panel"></div>
<div class="sdt-status" id="status-bar"></div>
</div>
`;
}
private setupEventHandlers(): void {
// Keyboard shortcuts
document.addEventListener('keydown', (e) => this.handleKeyDown(e));
// Window resize
window.addEventListener('resize', () => this.handleResize());
// Mouse wheel for zoom
this.stage.on('wheel', (e) => this.handleWheel(e));
// Click handling
this.stage.on('click tap', (e) => this.handleClick(e));
}
private handleKeyDown(e: KeyboardEvent): void {
// Undo: Ctrl+Z
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
this.stateManager.undo();
this.renderer.render();
}
// Redo: Ctrl+Shift+Z or Ctrl+Y
if ((e.ctrlKey && e.shiftKey && e.key === 'z') || (e.ctrlKey && e.key === 'y')) {
e.preventDefault();
this.stateManager.redo();
this.renderer.render();
}
// Tool shortcuts
const toolShortcuts: Record<string, string> = {
'v': 'select',
's': 'sector',
'b': 'block',
'r': 'row',
't': 'seat',
'a': 'aisle',
'g': 'gate',
};
if (!e.ctrlKey && !e.altKey && toolShortcuts[e.key]) {
this.stateManager.setTool(toolShortcuts[e.key] as any);
}
}
private handleWheel(e: Konva.KonvaEventObject<WheelEvent>): void {
e.evt.preventDefault();
const scaleBy = 1.1;
const stage = this.stage;
const oldScale = stage.scaleX();
const pointer = stage.getPointerPosition()!;
const mousePointTo = {
x: (pointer.x - stage.x()) / oldScale,
y: (pointer.y - stage.y()) / oldScale,
};
const newScale = e.evt.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy;
// Clamp zoom level
const clampedScale = Math.max(0.1, Math.min(5, newScale));
stage.scale({ x: clampedScale, y: clampedScale });
const newPos = {
x: pointer.x - mousePointTo.x * clampedScale,
y: pointer.y - mousePointTo.y * clampedScale,
};
stage.position(newPos);
this.stateManager.setZoom(clampedScale);
}
async save(): Promise<void> {
const stadium = this.stateManager.getStadium();
await this.api.saveStadium(stadium);
this.options.onSave?.(stadium);
}
}
State Manager with Undo/Redo¶
// src/StateManager.ts
import { produce, enablePatches, Patch, applyPatches } from 'immer';
import type { Stadium, EditorState, ToolType } from './types';
enablePatches();
type StateListener = () => void;
export class StateManager {
private stadium: Stadium;
private editorState: EditorState;
// History for undo/redo
private patches: Patch[][] = [];
private inversePatches: Patch[][] = [];
private historyIndex: number = -1;
private maxHistory: number = 50;
private listeners: Set<StateListener> = new Set();
constructor() {
this.stadium = this.createEmptyStadium();
this.editorState = this.createInitialEditorState();
}
// Stadium operations with history
updateStadium(recipe: (draft: Stadium) => void): void {
let newPatches: Patch[] = [];
let newInversePatches: Patch[] = [];
this.stadium = produce(
this.stadium,
recipe,
(patches, inversePatches) => {
newPatches = patches;
newInversePatches = inversePatches;
}
);
// Truncate future history if we're not at the end
if (this.historyIndex < this.patches.length - 1) {
this.patches = this.patches.slice(0, this.historyIndex + 1);
this.inversePatches = this.inversePatches.slice(0, this.historyIndex + 1);
}
// Add to history
this.patches.push(newPatches);
this.inversePatches.push(newInversePatches);
this.historyIndex++;
// Limit history size
if (this.patches.length > this.maxHistory) {
this.patches.shift();
this.inversePatches.shift();
this.historyIndex--;
}
this.notify();
}
undo(): boolean {
if (this.historyIndex < 0) return false;
this.stadium = applyPatches(
this.stadium,
this.inversePatches[this.historyIndex]
);
this.historyIndex--;
this.notify();
return true;
}
redo(): boolean {
if (this.historyIndex >= this.patches.length - 1) return false;
this.historyIndex++;
this.stadium = applyPatches(
this.stadium,
this.patches[this.historyIndex]
);
this.notify();
return true;
}
canUndo(): boolean {
return this.historyIndex >= 0;
}
canRedo(): boolean {
return this.historyIndex < this.patches.length - 1;
}
// Editor state (no history needed)
setTool(tool: ToolType): void {
this.editorState = produce(this.editorState, draft => {
draft.activeTool = tool;
});
this.notify();
}
setZoom(zoom: number): void {
this.editorState = produce(this.editorState, draft => {
draft.zoom = zoom;
});
this.notify();
}
setSelection(ids: string[], type: EditorState['selectionType']): void {
this.editorState = produce(this.editorState, draft => {
draft.selectedIds = ids;
draft.selectionType = type;
});
this.notify();
}
// Getters
getStadium(): Stadium {
return this.stadium;
}
getEditorState(): EditorState {
return this.editorState;
}
setStadium(stadium: Stadium): void {
this.stadium = stadium;
this.patches = [];
this.inversePatches = [];
this.historyIndex = -1;
this.notify();
}
// Subscription
subscribe(listener: StateListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach(listener => listener());
}
private createEmptyStadium(): Stadium {
return {
id: '',
code: '',
name: 'New Stadium',
city: '',
country: 'HR',
capacity: 0,
type: 'football',
geometry: {
viewBox: { x: 0, y: 0, width: 1000, height: 800 },
fieldRect: { x: 200, y: 150, width: 600, height: 400 },
orientation: 'north',
coordinateOrigin: 'center',
scale: 10,
},
sectors: [],
specialAreas: [],
priceZones: [],
metadata: {
version: '1.0.0',
status: 'draft',
createdAt: new Date().toISOString(),
createdBy: '',
updatedAt: new Date().toISOString(),
updatedBy: '',
},
};
}
private createInitialEditorState(): EditorState {
return {
activeTool: 'select',
toolSettings: {
sector: { type: 'stand', defaultPriceZone: '' },
row: { seatCount: 30, direction: 'LTR', seatWidth: 0.45, rowSpacing: 0.85 },
seat: { type: 'standard', size: 8 },
},
zoom: 1,
panOffset: { x: 0, y: 0 },
visibleLayers: ['background', 'sectors', 'seats', 'annotations'],
selectedIds: [],
selectionType: null,
selectionRect: null,
isDrawing: false,
drawingPoints: [],
isDragging: false,
dragStart: null,
showGrid: true,
gridSize: 10,
snapToGrid: true,
showLabels: true,
historyIndex: -1,
historyStack: [],
maxHistorySize: 50,
};
}
}
Canvas Renderer¶
// src/CanvasRenderer.ts
import Konva from 'konva';
import type { Stadium, Sector, Block, Row, Seat } from './types';
import type { StateManager } from './StateManager';
const SEAT_COLORS: Record<string, string> = {
standard: '#4CAF50',
technical: '#9C27B0',
accessibility: '#2196F3',
companion: '#03A9F4',
'restricted-view': '#FF9800',
premium: '#FFD700',
};
export class CanvasRenderer {
private stage: Konva.Stage;
private stateManager: StateManager;
private backgroundLayer: Konva.Layer;
private sectorLayer: Konva.Layer;
private seatLayer: Konva.Layer;
private annotationLayer: Konva.Layer;
private interactionLayer: Konva.Layer;
// Object pools for performance
private seatShapes: Map<string, Konva.Circle> = new Map();
constructor(stage: Konva.Stage, stateManager: StateManager) {
this.stage = stage;
this.stateManager = stateManager;
// Create layers in order
this.backgroundLayer = new Konva.Layer();
this.sectorLayer = new Konva.Layer();
this.seatLayer = new Konva.Layer();
this.annotationLayer = new Konva.Layer();
this.interactionLayer = new Konva.Layer();
this.stage.add(this.backgroundLayer);
this.stage.add(this.sectorLayer);
this.stage.add(this.seatLayer);
this.stage.add(this.annotationLayer);
this.stage.add(this.interactionLayer);
// Subscribe to state changes
this.stateManager.subscribe(() => this.render());
}
render(): void {
const stadium = this.stateManager.getStadium();
const editorState = this.stateManager.getEditorState();
this.renderBackground(stadium);
this.renderSectors(stadium.sectors, editorState);
this.renderSeats(stadium.sectors, editorState);
this.renderAnnotations(stadium.specialAreas);
this.stage.batchDraw();
}
private renderBackground(stadium: Stadium): void {
this.backgroundLayer.destroyChildren();
const { viewBox, fieldRect } = stadium.geometry;
// Stadium background
const background = new Konva.Rect({
x: viewBox.x,
y: viewBox.y,
width: viewBox.width,
height: viewBox.height,
fill: '#f5f5f5',
});
// Field/pitch
const field = new Konva.Rect({
x: fieldRect.x,
y: fieldRect.y,
width: fieldRect.width,
height: fieldRect.height,
fill: '#4CAF50',
stroke: '#fff',
strokeWidth: 2,
});
this.backgroundLayer.add(background);
this.backgroundLayer.add(field);
}
private renderSectors(sectors: Sector[], editorState: EditorState): void {
this.sectorLayer.destroyChildren();
for (const sector of sectors) {
const isSelected = editorState.selectedIds.includes(sector.id);
// Parse path to points (simplified - real impl would handle SVG paths)
const points = this.pathToPoints(sector.path);
const polygon = new Konva.Line({
points,
closed: true,
fill: isSelected ? '#e3f2fd' : '#fff',
stroke: isSelected ? '#1976D2' : '#666',
strokeWidth: isSelected ? 2 : 1,
id: sector.id,
});
// Click handler for selection
polygon.on('click tap', () => {
this.stateManager.setSelection([sector.id], 'sector');
});
// Label
const label = new Konva.Text({
x: this.getCentroid(points).x,
y: this.getCentroid(points).y,
text: sector.name,
fontSize: 12,
fill: '#333',
align: 'center',
});
label.offsetX(label.width() / 2);
label.offsetY(label.height() / 2);
const group = new Konva.Group();
group.add(polygon);
if (editorState.showLabels) {
group.add(label);
}
this.sectorLayer.add(group);
}
}
private renderSeats(sectors: Sector[], editorState: EditorState): void {
// Clear previous seats not in current render
const currentSeatIds = new Set<string>();
for (const sector of sectors) {
for (const block of sector.blocks) {
for (const row of block.rows) {
for (const seat of row.seats) {
currentSeatIds.add(seat.id);
let seatShape = this.seatShapes.get(seat.id);
if (!seatShape) {
// Create new seat shape
seatShape = new Konva.Circle({
x: seat.coordinates.svgX,
y: seat.coordinates.svgY,
radius: 4,
fill: SEAT_COLORS[seat.type] || SEAT_COLORS.standard,
stroke: editorState.selectedIds.includes(seat.id) ? '#000' : null,
strokeWidth: 2,
id: seat.id,
});
seatShape.on('click tap', () => {
this.stateManager.setSelection([seat.id], 'seat');
});
this.seatShapes.set(seat.id, seatShape);
this.seatLayer.add(seatShape);
} else {
// Update existing seat
seatShape.position({
x: seat.coordinates.svgX,
y: seat.coordinates.svgY,
});
seatShape.fill(SEAT_COLORS[seat.type] || SEAT_COLORS.standard);
seatShape.stroke(
editorState.selectedIds.includes(seat.id) ? '#000' : null
);
}
}
}
}
}
// Remove seats no longer in data
for (const [id, shape] of this.seatShapes) {
if (!currentSeatIds.has(id)) {
shape.destroy();
this.seatShapes.delete(id);
}
}
// Cache seat layer for better performance
this.seatLayer.cache();
}
private renderAnnotations(specialAreas: Stadium['specialAreas']): void {
this.annotationLayer.destroyChildren();
for (const area of specialAreas) {
switch (area.type) {
case 'gate':
this.renderGate(area);
break;
case 'aisle':
this.renderAisle(area);
break;
case 'stairs':
this.renderStairs(area);
break;
}
}
}
private renderGate(area: SpecialArea): void {
const points = this.pathToPoints(area.path);
const centroid = this.getCentroid(points);
const gateIcon = new Konva.Rect({
x: centroid.x - 15,
y: centroid.y - 10,
width: 30,
height: 20,
fill: '#FF5722',
cornerRadius: 3,
});
const label = new Konva.Text({
x: centroid.x,
y: centroid.y,
text: area.name,
fontSize: 10,
fill: '#fff',
align: 'center',
});
label.offsetX(label.width() / 2);
label.offsetY(label.height() / 2);
this.annotationLayer.add(gateIcon);
this.annotationLayer.add(label);
}
private renderAisle(area: SpecialArea): void {
const points = this.pathToPoints(area.path);
const aisle = new Konva.Line({
points,
stroke: '#9E9E9E',
strokeWidth: area.width ? area.width * 10 : 10,
lineCap: 'round',
dash: [5, 5],
});
this.annotationLayer.add(aisle);
}
private renderStairs(area: SpecialArea): void {
const points = this.pathToPoints(area.path);
const stairs = new Konva.Line({
points,
closed: true,
fill: '#BDBDBD',
stroke: '#757575',
strokeWidth: 1,
});
this.annotationLayer.add(stairs);
}
// Utility methods
private pathToPoints(path: string): number[] {
// Simplified - real implementation would parse SVG path commands
// For now, expect "x1,y1 x2,y2 x3,y3" format
return path
.split(' ')
.flatMap(p => p.split(',').map(Number));
}
private getCentroid(points: number[]): { x: number; y: number } {
let sumX = 0;
let sumY = 0;
const count = points.length / 2;
for (let i = 0; i < points.length; i += 2) {
sumX += points[i];
sumY += points[i + 1];
}
return { x: sumX / count, y: sumY / count };
}
}
Seat Generation Algorithm¶
Row-Based Seat Generation¶
// src/algorithms/seatGeneration.ts
interface SeatGenerationOptions {
row: Row;
blockPosition: { x: number; y: number };
scale: number;
}
export function generateSeatsForRow(options: SeatGenerationOptions): Seat[] {
const { row, blockPosition, scale } = options;
const seats: Seat[] = [];
const totalWidth = row.seatCount * row.seatWidth;
const startX = blockPosition.x - totalWidth / 2;
const y = blockPosition.y + row.yPosition;
for (let i = 0; i < row.seatCount; i++) {
// Calculate seat number based on direction
let seatNumber: number;
switch (row.direction) {
case 'RTL':
seatNumber = row.startSeatNumber + row.seatCount - 1 - i;
break;
case 'center-out':
// Odd numbers on left, even on right from center
const centerIndex = Math.floor(row.seatCount / 2);
if (i < centerIndex) {
seatNumber = row.startSeatNumber + (centerIndex - i) * 2 - 1; // odd
} else {
seatNumber = row.startSeatNumber + (i - centerIndex) * 2; // even
}
break;
default: // LTR
seatNumber = row.startSeatNumber + i;
}
// Calculate position
let x: number;
let seatY: number;
if (row.curveRadius) {
// Curved row - calculate position on arc
const totalAngle = totalWidth / row.curveRadius;
const startAngle = -totalAngle / 2;
const angle = startAngle + (i + 0.5) * (totalAngle / row.seatCount);
x = blockPosition.x + row.curveRadius * Math.sin(angle);
seatY = y + row.curveRadius * (1 - Math.cos(angle));
} else {
// Straight row
x = startX + (i + 0.5) * row.seatWidth;
seatY = y;
}
seats.push({
id: `${row.id}-${seatNumber}`,
rowId: row.id,
number: String(seatNumber),
type: 'standard',
priceZoneId: null,
coordinates: {
x: x / scale, // Stadium meters
y: seatY / scale,
svgX: x, // Canvas pixels
svgY: seatY,
},
isManuallyPositioned: false,
});
}
return seats;
}
export function generateAllSeats(stadium: Stadium): Stadium {
return produce(stadium, draft => {
for (const sector of draft.sectors) {
for (const block of sector.blocks) {
const blockPosition = calculateBlockPosition(sector, block);
for (const row of block.rows) {
row.seats = generateSeatsForRow({
row,
blockPosition,
scale: draft.geometry.scale,
});
}
}
}
});
}
function calculateBlockPosition(sector: Sector, block: Block): { x: number; y: number } {
// Calculate block center based on sector geometry
// This is simplified - real implementation would use sector path bounds
const sectorBounds = getSectorBounds(sector.path);
const blockIndex = sector.blocks.findIndex(b => b.id === block.id);
const blockCount = sector.blocks.length;
const blockWidth = sectorBounds.width / blockCount;
return {
x: sectorBounds.x + blockWidth * (blockIndex + 0.5),
y: sectorBounds.y + sectorBounds.height / 2,
};
}
API Integration¶
API Client¶
// src/ApiClient.ts
export class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async loadStadium(id: string): Promise<Stadium> {
const response = await fetch(`${this.baseUrl}/${id}`);
if (!response.ok) {
throw new Error(`Failed to load stadium: ${response.statusText}`);
}
return response.json();
}
async saveStadium(stadium: Stadium): Promise<Stadium> {
const response = await fetch(`${this.baseUrl}/${stadium.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(stadium),
});
if (!response.ok) {
throw new Error(`Failed to save stadium: ${response.statusText}`);
}
return response.json();
}
async validateStadium(stadium: Stadium): Promise<ValidationResult> {
const response = await fetch(`${this.baseUrl}/${stadium.id}/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(stadium),
});
return response.json();
}
async generateSeats(stadiumId: string): Promise<Stadium> {
const response = await fetch(`${this.baseUrl}/${stadiumId}/generate`, {
method: 'POST',
});
return response.json();
}
async exportStadium(stadiumId: string, format: 'json' | 'csv' | 'svg'): Promise<Blob> {
const response = await fetch(
`${this.baseUrl}/${stadiumId}/export?format=${format}`
);
return response.blob();
}
}
interface ValidationResult {
valid: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
summary: {
sectorCount: number;
blockCount: number;
rowCount: number;
seatCount: number;
};
}
interface ValidationError {
code: string;
message: string;
path: string;
severity: 'error';
}
interface ValidationWarning {
code: string;
message: string;
path: string;
severity: 'warning';
}
SeatMapEditor Constructor Options¶
The SeatMapEditor class (implemented in assets/seat-map-editor.js) accepts the following constructor options relevant to capacity management and save behavior:
interface SeatMapEditorOptions {
// ... other options ...
/** Maximum number of seats allowed in this sector. When set, the editor
* disables the Draw and Fill tools once the seat count reaches this limit. */
maxSeats?: number;
/** Callback invoked when the user attempts to add seats beyond the maxSeats
* limit. Use this to display a toast or warning notification. */
onCapacityExceeded?: (currentCount: number, maxSeats: number) => void;
}
Capacity enforcement behavior:
- When
maxSeatsis set and the current seat count equals or exceeds the limit, the Draw and Fill tools become disabled - The
onCapacityExceededcallback fires when a user attempts to draw beyond the limit - The Erase tool remains enabled so users can remove seats to make room
- The capacity counter in the toolbar shows
currentCount / maxSeats
Save Conflict Retry (409 Handling)¶
When saving a seat map, another user may have modified the same data concurrently. The editor handles HTTP 409 Conflict responses with an automatic retry mechanism:
- On save, the editor sends a
PUTrequest with the currentversionfield - If the backend returns 409 Conflict (version mismatch), the editor: a. Fetches the latest seat map data from the backend b. Re-applies the local changes on top of the fresh data c. Retries the save with the updated version
- If the retry also returns 409, the editor shows an error toast asking the user to reload
- All other HTTP errors (400, 500, etc.) are shown as error notifications without retry
Symfony Backend Endpoints¶
// src/Controller/Api/Admin/StadiumController.php
namespace App\Controller\Api\Admin;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/api/admin/stadiums')]
class StadiumController extends AbstractController
{
#[Route('/{id}', methods: ['GET'])]
public function show(string $id): JsonResponse
{
// Load stadium configuration from database
$stadium = $this->stadiumRepository->find($id);
return $this->json($stadium->toArray());
}
#[Route('/{id}', methods: ['PUT'])]
public function update(string $id, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
// Validate and save
$stadium = $this->stadiumRepository->find($id);
$stadium->updateFromArray($data);
// Increment version
$stadium->incrementVersion();
$this->entityManager->flush();
return $this->json($stadium->toArray());
}
#[Route('/{id}/validate', methods: ['POST'])]
public function validate(string $id, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$result = $this->stadiumValidator->validate($data);
return $this->json([
'valid' => $result->isValid(),
'errors' => $result->getErrors(),
'warnings' => $result->getWarnings(),
'summary' => $result->getSummary(),
]);
}
#[Route('/{id}/generate', methods: ['POST'])]
public function generateSeats(string $id): JsonResponse
{
$stadium = $this->stadiumRepository->find($id);
// Generate seat records from row configurations
$this->seatGenerator->generateForStadium($stadium);
$this->entityManager->flush();
return $this->json($stadium->toArray());
}
#[Route('/{id}/export', methods: ['GET'])]
public function export(string $id, Request $request): Response
{
$format = $request->query->get('format', 'json');
$stadium = $this->stadiumRepository->find($id);
return match($format) {
'csv' => $this->exportCsv($stadium),
'svg' => $this->exportSvg($stadium),
default => $this->json($stadium->toArray()),
};
}
}
Performance Optimizations¶
Large Stadium Handling (35K-50K Seats)¶
// src/optimizations/VirtualRendering.ts
/**
* Virtual rendering only draws seats visible in viewport.
* Critical for 35K-50K seat stadiums.
*/
export class VirtualSeatRenderer {
private stage: Konva.Stage;
private seatLayer: Konva.Layer;
private allSeats: Seat[] = [];
private visibleSeats: Map<string, Konva.Circle> = new Map();
private viewportPadding = 100; // pixels
setSeats(seats: Seat[]): void {
this.allSeats = seats;
this.updateVisibleSeats();
}
updateVisibleSeats(): void {
const viewport = this.getVisibleViewport();
// Find seats in viewport
const seatsInView = this.allSeats.filter(seat =>
this.isInViewport(seat.coordinates, viewport)
);
const seatsInViewIds = new Set(seatsInView.map(s => s.id));
// Remove seats no longer in view
for (const [id, shape] of this.visibleSeats) {
if (!seatsInViewIds.has(id)) {
shape.destroy();
this.visibleSeats.delete(id);
}
}
// Add newly visible seats
for (const seat of seatsInView) {
if (!this.visibleSeats.has(seat.id)) {
const shape = this.createSeatShape(seat);
this.seatLayer.add(shape);
this.visibleSeats.set(seat.id, shape);
}
}
this.seatLayer.batchDraw();
}
private getVisibleViewport(): Rectangle {
const scale = this.stage.scaleX();
const pos = this.stage.position();
const size = this.stage.size();
return {
x: (-pos.x / scale) - this.viewportPadding,
y: (-pos.y / scale) - this.viewportPadding,
width: (size.width / scale) + this.viewportPadding * 2,
height: (size.height / scale) + this.viewportPadding * 2,
};
}
private isInViewport(coords: SeatCoordinates, viewport: Rectangle): boolean {
return (
coords.svgX >= viewport.x &&
coords.svgX <= viewport.x + viewport.width &&
coords.svgY >= viewport.y &&
coords.svgY <= viewport.y + viewport.height
);
}
private createSeatShape(seat: Seat): Konva.Circle {
return new Konva.Circle({
x: seat.coordinates.svgX,
y: seat.coordinates.svgY,
radius: 4,
fill: SEAT_COLORS[seat.type],
listening: true, // Enable events
});
}
}
Layer Caching¶
// Cache entire seat layer when not being edited
// This significantly improves pan/zoom performance
function cacheSeatLayer(seatLayer: Konva.Layer): void {
// Only cache if more than 1000 seats
if (seatLayer.children.length > 1000) {
seatLayer.cache({
pixelRatio: 2, // Higher quality on retina displays
});
}
}
function uncacheSeatLayer(seatLayer: Konva.Layer): void {
seatLayer.clearCache();
}
// Usage in renderer:
// - Cache after initial render
// - Uncache when editing seats
// - Re-cache after editing complete
Level of Detail (LOD)¶
// src/optimizations/LevelOfDetail.ts
/**
* Adjust rendering detail based on zoom level.
* At far zoom, render sectors as solid colors.
* At medium zoom, render rows as lines.
* At close zoom, render individual seats.
*/
export function getLODLevel(zoom: number): 'sector' | 'row' | 'seat' {
if (zoom < 0.3) return 'sector';
if (zoom < 0.8) return 'row';
return 'seat';
}
export function renderAtLOD(
stadium: Stadium,
lod: 'sector' | 'row' | 'seat',
seatLayer: Konva.Layer
): void {
seatLayer.destroyChildren();
switch (lod) {
case 'sector':
// Just show sector fills with capacity numbers
for (const sector of stadium.sectors) {
const sectorShape = createSectorOverview(sector);
seatLayer.add(sectorShape);
}
break;
case 'row':
// Show rows as colored lines
for (const sector of stadium.sectors) {
for (const block of sector.blocks) {
for (const row of block.rows) {
const rowShape = createRowLine(row, sector, block);
seatLayer.add(rowShape);
}
}
}
break;
case 'seat':
// Full seat rendering
for (const sector of stadium.sectors) {
for (const block of sector.blocks) {
for (const row of block.rows) {
for (const seat of row.seats) {
const seatShape = createSeatCircle(seat);
seatLayer.add(seatShape);
}
}
}
}
break;
}
}
Build Configuration¶
Vite Configuration¶
// vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/main.ts'),
name: 'StadiumDesignTool',
fileName: 'stadium-design-tool',
formats: ['iife'], // Single self-executing bundle
},
outDir: '../public/assets/stadium-tool',
emptyOutDir: true,
rollupOptions: {
output: {
// Single JS file
entryFileNames: 'stadium-design-tool.js',
// Single CSS file
assetFileNames: 'stadium-design-tool.[ext]',
},
},
},
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
});
Package.json Scripts¶
{
"name": "stadium-design-tool",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
}
}
Twig Integration¶
Admin Template¶
{# templates/admin/stadium/design.html.twig #}
{% extends 'admin/base.html.twig' %}
{% block title %}Stadium Design Tool - {{ stadium.name }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('assets/stadium-tool/stadium-design-tool.css') }}">
{% endblock %}
{% block content %}
<div class="stadium-design-page">
<header class="page-header">
<h1>Stadium Design: {{ stadium.name }}</h1>
<div class="header-actions">
<button type="button" class="btn btn-secondary" id="btn-validate">Validate</button>
<button type="button" class="btn btn-primary" id="btn-save">Save</button>
</div>
</header>
<div id="stadium-design-tool"
data-stadium-id="{{ stadium.id }}"
data-api-base="{{ path('api_admin_stadiums_show', {id: '__ID__'})|replace({'__ID__': ''}) }}"
></div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="{{ asset('assets/stadium-tool/stadium-design-tool.js') }}"></script>
{% endblock %}
File Structure¶
stadium-design-tool/
├── src/
│ ├── main.ts # Entry point
│ ├── StadiumDesignTool.ts # Main application class
│ ├── StateManager.ts # State management with undo/redo
│ ├── CanvasRenderer.ts # Konva rendering
│ ├── ApiClient.ts # Backend API client
│ ├── types/
│ │ ├── index.ts # Type exports
│ │ ├── stadium.ts # Stadium data types
│ │ └── editor.ts # Editor state types
│ ├── algorithms/
│ │ ├── seatGeneration.ts # Seat placement algorithms
│ │ └── validation.ts # Stadium validation
│ ├── optimizations/
│ │ ├── VirtualRendering.ts # Viewport-based rendering
│ │ └── LevelOfDetail.ts # LOD rendering
│ ├── ui/
│ │ ├── ToolbarPanel.ts # Tool selection UI
│ │ ├── PropertiesPanel.ts # Property editor UI
│ │ └── StatusBar.ts # Status display
│ ├── tools/
│ │ ├── SelectTool.ts # Selection tool
│ │ ├── SectorTool.ts # Sector drawing
│ │ ├── RowTool.ts # Row configuration
│ │ └── ... # Other tools
│ └── styles/
│ └── main.css # Component styles
├── public/
│ └── index.html # Dev server HTML
├── package.json
├── tsconfig.json
├── vite.config.ts
└── README.md
Related Documentation¶
- Stadium Design Tool (Workflow & UX)
- Stadium Template Management Feature
- Stadium Visualization Component
- Seat Inventory Management Epic
Last Updated: January 2026