Preskoči na sadržaj

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 maxSeats is set and the current seat count equals or exceeds the limit, the Draw and Fill tools become disabled
  • The onCapacityExceeded callback 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:

  1. On save, the editor sends a PUT request with the current version field
  2. 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
  3. If the retry also returns 409, the editor shows an error toast asking the user to reload
  4. 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


Last Updated: January 2026