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 |
Navigation
- 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
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;
}
};
}
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
- Lazy Loading: Load seat data only when sector is zoomed
- Viewport Culling: Only render seats currently visible
- Level of Detail: Show aggregates at low zoom, details at high zoom
- Debouncing: Debounce pan/zoom events
- Canvas Fallback: Consider Canvas for 50K+ seats
Recommended Limits
| 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 |
{
"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