Skip to content

Stadium Visualization Component

The Stadium Visualization component provides an interactive SVG-based map for viewing and managing stadium seating. It is used in both the Admin Portal (for seat operations) and Quota Portal (for seat selection during claiming).

Purpose

  • Display stadium layout with sectors and individual seats
  • Color-code seats by status for quick visual identification
  • Enable seat selection (single, multi-select, bulk)
  • Support zoom and pan navigation
  • Show seat information on hover/click

Component Architecture

┌─────────────────────────────────────────────────────────────────┐
│                    Stadium Visualization                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌─────────────┐ ┌─────────────────────────────────────────┐    │
│  │   Controls  │ │           SVG Viewport                   │    │
│  │  ─────────  │ │  ┌─────────────────────────────────┐    │    │
│  │  [Zoom +]   │ │  │                                 │    │    │
│  │  [Zoom -]   │ │  │    Stadium Map (sectors/seats)  │    │    │
│  │  [Reset]    │ │  │                                 │    │    │
│  │  [Fullscreen│ │  └─────────────────────────────────┘    │    │
│  │             │ │                                          │    │
│  │  Legend     │ │  ┌─────────────────────────────────┐    │    │
│  │  ─────────  │ │  │     Tooltip / Info Panel        │    │    │
│  │  ● Available│ │  │  Sector: D1                      │    │    │
│  │  ● Sold     │ │  │  Row: 5, Seat: 12               │    │    │
│  │  ● Reserved │ │  │  Status: Available              │    │    │
│  │  ...        │ │  └─────────────────────────────────┘    │    │
│  └─────────────┘ └─────────────────────────────────────────┘    │
│                                                                  │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                  Selection Summary                       │    │
│  │  Selected: 5 seats | [Clear Selection] [Apply Action]   │    │
│  └─────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────┘

Features

View Modes

Mode Description Use Case
Full Stadium Bird's eye view of entire stadium Overview, sector selection
Sector Detail Zoomed view of single sector with seat-level detail Seat operations, claiming

Zoom Levels

Level Display Interaction
1 (Min) Sectors only, aggregate colors Click sector to zoom
2-3 Sectors with row outlines Hover shows sector summary
4-5 Individual seats visible Click seats for selection
6 (Max) Large seats, detailed labels Precision seat selection
  • Pan: Click and drag to move viewport
  • Zoom: Mouse wheel, pinch gesture, or zoom buttons
  • Reset: Return to default view
  • Fullscreen: Expand to full browser window

Selection Modes

Mode Behavior Keyboard
Single Click seat to select/deselect Default
Multi Click multiple seats individually Hold Ctrl/Cmd
Range Select rectangular range Shift + Click
Bulk Select by sector/row/filter Via filter panel

Seat Status Colors

Color-coding for different seat statuses.

Status Color Table

Status Color Name Hex Code Tailwind Class RGB
Available Green #22C55E bg-green-500 34, 197, 94
Sold Blue #2563EB bg-blue-600 37, 99, 235
Reserved Yellow #FACC15 bg-yellow-400 250, 204, 21
Technical Purple #A855F7 bg-purple-500 168, 85, 247
Official Gold/Amber #F59E0B bg-amber-500 245, 158, 11
Blocked Red #EF4444 bg-red-500 239, 68, 68
Maintenance Gray #9CA3AF bg-gray-400 156, 163, 175
Quarantined Orange #F97316 bg-orange-500 249, 115, 22
Allocated Cyan #06B6D4 bg-cyan-500 6, 182, 212
Inactive Light Gray #E5E7EB bg-gray-200 229, 231, 235
Selected Red (HNS) #B91C1C bg-red-700 185, 28, 28

Visual Legend Component

<!-- templates/components/stadium/legend.html.twig -->
<div class="bg-white rounded-lg shadow p-4">
  <h4 class="text-sm font-medium text-gray-700 mb-3">Legend</h4>
  <div class="space-y-2">
    <div class="flex items-center gap-2">
      <span class="w-4 h-4 rounded bg-green-500"></span>
      <span class="text-sm text-gray-600">Available</span>
    </div>
    <div class="flex items-center gap-2">
      <span class="w-4 h-4 rounded bg-blue-600"></span>
      <span class="text-sm text-gray-600">Sold</span>
    </div>
    <div class="flex items-center gap-2">
      <span class="w-4 h-4 rounded bg-yellow-400"></span>
      <span class="text-sm text-gray-600">Reserved</span>
    </div>
    <div class="flex items-center gap-2">
      <span class="w-4 h-4 rounded bg-purple-500"></span>
      <span class="text-sm text-gray-600">Technical</span>
    </div>
    <div class="flex items-center gap-2">
      <span class="w-4 h-4 rounded bg-amber-500"></span>
      <span class="text-sm text-gray-600">Official</span>
    </div>
    <div class="flex items-center gap-2">
      <span class="w-4 h-4 rounded bg-red-500"></span>
      <span class="text-sm text-gray-600">Blocked</span>
    </div>
    <div class="flex items-center gap-2">
      <span class="w-4 h-4 rounded bg-gray-400"></span>
      <span class="text-sm text-gray-600">Maintenance</span>
    </div>
    <div class="flex items-center gap-2">
      <span class="w-4 h-4 rounded bg-orange-500"></span>
      <span class="text-sm text-gray-600">Quarantined</span>
    </div>
    <div class="flex items-center gap-2">
      <span class="w-4 h-4 rounded bg-cyan-500"></span>
      <span class="text-sm text-gray-600">Allocated</span>
    </div>
    <div class="flex items-center gap-2">
      <span class="w-4 h-4 rounded bg-gray-200"></span>
      <span class="text-sm text-gray-600">Inactive</span>
    </div>
  </div>
</div>

SVG Structure

Stadium Template Format

Each stadium has a base SVG template with sector paths.

<!-- Stadium SVG Template Example -->
<svg viewBox="0 0 1000 800" xmlns="http://www.w3.org/2000/svg">
  <!-- Stadium outline -->
  <path id="stadium-outline" d="M..." fill="none" stroke="#333" stroke-width="2"/>

  <!-- Field/Pitch -->
  <rect id="field" x="200" y="150" width="600" height="400" fill="#4ade80" rx="20"/>

  <!-- Sectors -->
  <g id="sectors">
    <path id="sector-A" d="M100,100 L200,100 L200,250 L100,250 Z"
          class="sector" data-sector="A" data-capacity="1500"/>
    <path id="sector-B" d="M200,100 L350,100 L350,150 L200,150 Z"
          class="sector" data-sector="B" data-capacity="800"/>
    <!-- ... more sectors ... -->
  </g>

  <!-- Sector Labels -->
  <g id="sector-labels" class="text-sm font-medium">
    <text x="150" y="175" text-anchor="middle">A</text>
    <text x="275" y="125" text-anchor="middle">B</text>
    <!-- ... more labels ... -->
  </g>
</svg>

Seat Generation

Seats are dynamically rendered based on sector configuration and zoom level.

// Seat rendering logic (pseudocode)
function renderSeats(sector, zoomLevel) {
  if (zoomLevel < 4) return; // Don't render individual seats at low zoom

  const seats = getSeatsBySector(sector.id);
  const seatGroup = document.createElementNS(SVG_NS, 'g');
  seatGroup.setAttribute('class', 'seats');

  seats.forEach(seat => {
    const rect = document.createElementNS(SVG_NS, 'rect');
    rect.setAttribute('x', seat.x);
    rect.setAttribute('y', seat.y);
    rect.setAttribute('width', SEAT_WIDTH);
    rect.setAttribute('height', SEAT_HEIGHT);
    rect.setAttribute('rx', 2);
    rect.setAttribute('class', `seat seat-${seat.status}`);
    rect.setAttribute('data-seat-id', seat.id);
    rect.setAttribute('data-row', seat.row);
    rect.setAttribute('data-number', seat.number);
    seatGroup.appendChild(rect);
  });

  return seatGroup;
}

Component Implementation

Twig Template

<!-- templates/components/stadium/map.html.twig -->
<div class="stadium-visualization"
     x-data="stadiumMap({
       matchId: {{ matchId }},
       mode: '{{ mode }}',
       selectable: {{ selectable ? 'true' : 'false' }}
     })"
     x-init="init()">

  <!-- Toolbar -->
  <div class="flex items-center justify-between mb-4">
    <div class="flex items-center gap-2">
      <button @click="zoomIn()"
              class="p-2 bg-white rounded border border-gray-300 hover:bg-gray-50"
              title="Zoom In">
        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"/>
        </svg>
      </button>
      <button @click="zoomOut()"
              class="p-2 bg-white rounded border border-gray-300 hover:bg-gray-50"
              title="Zoom Out">
        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7"/>
        </svg>
      </button>
      <button @click="resetView()"
              class="p-2 bg-white rounded border border-gray-300 hover:bg-gray-50"
              title="Reset View">
        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
        </svg>
      </button>
      <span class="text-sm text-gray-500 ml-2" x-text="'Zoom: ' + zoomLevel + 'x'"></span>
    </div>

    <div class="flex items-center gap-2">
      <button @click="toggleFullscreen()"
              class="p-2 bg-white rounded border border-gray-300 hover:bg-gray-50">
        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
        </svg>
      </button>
    </div>
  </div>

  <!-- Map Container -->
  <div class="relative bg-gray-100 rounded-lg overflow-hidden"
       style="height: 500px;"
       @mousedown="startPan($event)"
       @mousemove="pan($event)"
       @mouseup="endPan()"
       @wheel="handleZoom($event)">

    <!-- SVG Container -->
    <div class="stadium-svg-container"
         :style="{ transform: `scale(${zoom}) translate(${panX}px, ${panY}px)` }">
      {{ stadiumSvg|raw }}
    </div>

    <!-- Tooltip -->
    <div x-show="tooltip.show"
         x-transition
         class="absolute bg-white rounded-lg shadow-lg p-3 text-sm pointer-events-none z-10"
         :style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }">
      <div class="font-medium" x-text="tooltip.title"></div>
      <div class="text-gray-500" x-text="tooltip.subtitle"></div>
      <div class="mt-1">
        <span class="inline-block w-3 h-3 rounded mr-1"
              :class="'bg-' + tooltip.statusColor"></span>
        <span x-text="tooltip.status"></span>
      </div>
    </div>
  </div>

  <!-- Selection Summary (if selectable) -->
  {% if selectable %}
  <div class="mt-4 p-4 bg-gray-50 rounded-lg" x-show="selectedSeats.length > 0">
    <div class="flex items-center justify-between">
      <div>
        <span class="font-medium" x-text="selectedSeats.length"></span>
        <span class="text-gray-600"> seats selected</span>
      </div>
      <div class="flex items-center gap-2">
        <button @click="clearSelection()"
                class="px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200 rounded">
          Clear Selection
        </button>
        <button @click="confirmSelection()"
                class="px-3 py-1.5 text-sm text-white bg-red-700 hover:bg-red-800 rounded">
          Confirm Selection
        </button>
      </div>
    </div>
  </div>
  {% endif %}
</div>

JavaScript Component

// assets/js/components/stadium-map.js
function stadiumMap(config) {
  return {
    matchId: config.matchId,
    mode: config.mode,
    selectable: config.selectable,

    // View state
    zoom: 1,
    minZoom: 0.5,
    maxZoom: 6,
    panX: 0,
    panY: 0,
    isPanning: false,

    // Data
    sectors: [],
    seats: [],
    selectedSeats: [],

    // Tooltip
    tooltip: {
      show: false,
      x: 0,
      y: 0,
      title: '',
      subtitle: '',
      status: '',
      statusColor: ''
    },

    async init() {
      await this.loadStadiumData();
      this.attachEventListeners();
    },

    async loadStadiumData() {
      const response = await fetch(`/api/matches/${this.matchId}/stadium`);
      const data = await response.json();
      this.sectors = data.sectors;
      this.updateSectorColors();
    },

    updateSectorColors() {
      this.sectors.forEach(sector => {
        const element = document.getElementById(`sector-${sector.id}`);
        if (element) {
          element.style.fill = this.getAggregateColor(sector.statusSummary);
        }
      });
    },

    getAggregateColor(summary) {
      // Return dominant status color for sector overview
      const total = Object.values(summary).reduce((a, b) => a + b, 0);
      if (summary.available / total > 0.5) return '#22C55E';
      if (summary.sold / total > 0.5) return '#2563EB';
      return '#9CA3AF';
    },

    zoomIn() {
      this.zoom = Math.min(this.zoom * 1.5, this.maxZoom);
      this.onZoomChange();
    },

    zoomOut() {
      this.zoom = Math.max(this.zoom / 1.5, this.minZoom);
      this.onZoomChange();
    },

    handleZoom(event) {
      event.preventDefault();
      const delta = event.deltaY > 0 ? 0.9 : 1.1;
      this.zoom = Math.max(this.minZoom, Math.min(this.zoom * delta, this.maxZoom));
      this.onZoomChange();
    },

    onZoomChange() {
      if (this.zoom >= 4) {
        this.loadSeatsForVisibleSectors();
      }
    },

    async loadSeatsForVisibleSectors() {
      // Load individual seat data when zoomed in
      // Implementation depends on viewport calculation
    },

    startPan(event) {
      if (event.button === 0) {
        this.isPanning = true;
        this.panStartX = event.clientX - this.panX;
        this.panStartY = event.clientY - this.panY;
      }
    },

    pan(event) {
      if (this.isPanning) {
        this.panX = event.clientX - this.panStartX;
        this.panY = event.clientY - this.panStartY;
      }
    },

    endPan() {
      this.isPanning = false;
    },

    resetView() {
      this.zoom = 1;
      this.panX = 0;
      this.panY = 0;
    },

    selectSeat(seatId) {
      if (!this.selectable) return;

      const index = this.selectedSeats.indexOf(seatId);
      if (index > -1) {
        this.selectedSeats.splice(index, 1);
      } else {
        this.selectedSeats.push(seatId);
      }

      this.updateSeatVisual(seatId);
    },

    clearSelection() {
      this.selectedSeats.forEach(id => this.updateSeatVisual(id));
      this.selectedSeats = [];
    },

    showTooltip(event, data) {
      this.tooltip = {
        show: true,
        x: event.clientX + 10,
        y: event.clientY + 10,
        ...data
      };
    },

    hideTooltip() {
      this.tooltip.show = false;
    }
  };
}

Performance Considerations

Large Stadium Handling (50K+ Seats)

Challenge Solution
Initial load time Load sector outlines first, seats on demand
Memory usage Only render seats in viewport
Selection performance Use Set for O(1) lookup
SVG rendering Virtualize or use Canvas for dense areas

Optimization Strategies

  1. Lazy Loading: Load seat data only when sector is zoomed
  2. Viewport Culling: Only render seats currently visible
  3. Level of Detail: Show aggregates at low zoom, details at high zoom
  4. Debouncing: Debounce pan/zoom events
  5. Canvas Fallback: Consider Canvas for 50K+ seats
Stadium Size Recommended Approach
< 10,000 Full SVG rendering
10,000 - 30,000 Virtualized SVG with lazy loading
30,000 - 50,000 Hybrid SVG/Canvas
> 50,000 Canvas with WebGL acceleration

API Integration

Endpoints

Endpoint Method Description
/api/matches/{id}/stadium GET Get stadium layout and sector data
/api/matches/{id}/seats GET Get all seats with status
/api/matches/{id}/seats?sector={id} GET Get seats for specific sector
/api/seats/{id}/status PATCH Update seat status
/api/seats/bulk PATCH Bulk update seat statuses

Response Format

{
  "stadium": {
    "id": "maksimir",
    "name": "Stadion Maksimir",
    "capacity": 35000,
    "svgTemplate": "/stadiums/maksimir.svg"
  },
  "sectors": [
    {
      "id": "A",
      "name": "Sector A",
      "capacity": 1500,
      "statusSummary": {
        "available": 800,
        "sold": 500,
        "reserved": 100,
        "blocked": 100
      }
    }
  ],
  "seats": [
    {
      "id": "A-5-12",
      "sector": "A",
      "row": 5,
      "number": 12,
      "status": "available",
      "x": 150.5,
      "y": 200.3
    }
  ]
}

Usage Contexts

Admin Portal - Seat Operations

{% include 'components/stadium/map.html.twig' with {
  matchId: match.id,
  mode: 'admin',
  selectable: true,
  showLegend: true,
  showFilters: true,
  onSelect: 'openStatusChangeModal'
} %}

Quota Portal - Seat Selection

{% include 'components/stadium/map.html.twig' with {
  matchId: match.id,
  mode: 'quota',
  selectable: true,
  allowedSeats: quota.allocatedSeatIds,
  showLegend: true,
  onSelect: 'updateSelection'
} %}

Admin Portal - Read-Only Overview

{% include 'components/stadium/map.html.twig' with {
  matchId: match.id,
  mode: 'readonly',
  selectable: false,
  showLegend: true
} %}

Accessibility

  • Keyboard navigation for seat selection (arrow keys, Enter)
  • Screen reader announcements for status changes
  • High contrast mode support
  • Focus indicators on interactive elements


Last Updated: January 2026