/** * — c3nav indoor map viewer using MapLibre GL raster tiles. * Level selector, participant markers, and Easter egg on Level 0 triple-click. */ import type { ParticipantState } from "./map-sync"; interface IndoorConfig { event: string; apiBase: string; } const MAPLIBRE_CSS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css"; const MAPLIBRE_JS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js"; class MapIndoorView extends HTMLElement { private shadow: ShadowRoot; private map: any = null; private config: IndoorConfig | null = null; private levels: { id: number; name: string }[] = []; private currentLevel = 0; private bounds: { west: number; south: number; east: number; north: number } | null = null; private participantMarkers: Map = new Map(); private loading = true; private error = ""; private level0ClickCount = 0; private level0ClickTimer: ReturnType | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } set cfg(val: IndoorConfig) { this.config = val; if (this.isConnected) this.init(); } connectedCallback() { this.renderShell(); if (this.config) this.init(); } disconnectedCallback() { if (this.map) { this.map.remove(); this.map = null; } this.participantMarkers.clear(); } private renderShell() { this.shadow.innerHTML = `
Loading indoor map...
`; } private async loadMapLibre(): Promise { if ((window as any).maplibregl) return; if (!document.querySelector(`link[href="${MAPLIBRE_CSS}"]`)) { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = MAPLIBRE_CSS; document.head.appendChild(link); } return new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = MAPLIBRE_JS; script.onload = () => resolve(); script.onerror = reject; document.head.appendChild(script); }); } private async init() { if (!this.config) return; this.loading = true; this.error = ""; try { await this.loadMapLibre(); await this.fetchMapData(); this.createMap(); this.renderLevelSelector(); this.loading = false; this.shadow.getElementById("loading-overlay")?.remove(); } catch (err: any) { this.loading = false; this.error = err?.message || "Failed to load indoor map"; this.renderError(); } } private async fetchMapData() { const { event, apiBase } = this.config!; // Fetch bounds const boundsRes = await fetch(`${apiBase}/api/c3nav/${event}?endpoint=map/bounds`, { signal: AbortSignal.timeout(8000), }); if (boundsRes.ok) { const data = await boundsRes.json(); if (data.bounds) { this.bounds = data.bounds; } else if (data.west !== undefined) { this.bounds = { west: data.west, south: data.south, east: data.east, north: data.north }; } } // Fetch levels const levelsRes = await fetch(`${apiBase}/api/c3nav/${event}?endpoint=map/locations`, { signal: AbortSignal.timeout(8000), }); if (levelsRes.ok) { const data = await levelsRes.json(); const levelEntries = (Array.isArray(data) ? data : data.results || []) .filter((loc: any) => loc.locationtype === "level" || loc.type === "level") .map((loc: any) => ({ id: typeof loc.on_top_of === "number" ? loc.on_top_of : (loc.id ?? loc.level ?? 0), name: loc.title || loc.name || `Level ${loc.id ?? 0}`, })) .sort((a: any, b: any) => b.id - a.id); this.levels = levelEntries.length > 0 ? levelEntries : [ { id: 4, name: "Level 4" }, { id: 3, name: "Level 3" }, { id: 2, name: "Level 2" }, { id: 1, name: "Level 1" }, { id: 0, name: "Ground" }, ]; } else { // Fallback levels this.levels = [ { id: 4, name: "Level 4" }, { id: 3, name: "Level 3" }, { id: 2, name: "Level 2" }, { id: 1, name: "Level 1" }, { id: 0, name: "Ground" }, ]; } } private createMap() { const container = this.shadow.getElementById("indoor-map"); if (!container || !(window as any).maplibregl) return; const { event, apiBase } = this.config!; // Default center (CCH Hamburg for CCC events) const defaultCenter: [number, number] = [9.9905, 53.5545]; const center = this.bounds ? [(this.bounds.west + this.bounds.east) / 2, (this.bounds.south + this.bounds.north) / 2] as [number, number] : defaultCenter; const tileUrl = `${apiBase}/api/c3nav/tiles/${event}/${this.currentLevel}/{z}/{x}/{y}`; this.map = new (window as any).maplibregl.Map({ container, style: { version: 8, sources: { "c3nav-tiles": { type: "raster", tiles: [tileUrl], tileSize: 256, maxzoom: 22, }, }, layers: [{ id: "c3nav-layer", type: "raster", source: "c3nav-tiles", }], }, center, zoom: 17, minZoom: 14, maxZoom: 22, }); this.map.addControl(new (window as any).maplibregl.NavigationControl(), "top-right"); } private renderLevelSelector() { const selector = this.shadow.getElementById("level-selector"); if (!selector) return; selector.innerHTML = this.levels.map(level => `` ).join(""); selector.querySelectorAll(".level-btn").forEach(btn => { btn.addEventListener("click", () => { const levelId = parseInt((btn as HTMLElement).dataset.level!, 10); this.switchLevel(levelId); // Easter egg: triple-click Level 0 reveals Level -1 if (levelId === 0) { this.level0ClickCount++; if (this.level0ClickTimer) clearTimeout(this.level0ClickTimer); this.level0ClickTimer = setTimeout(() => { this.level0ClickCount = 0; }, 600); if (this.level0ClickCount >= 3) { this.level0ClickCount = 0; this.showEasterEgg(); } } }); }); } private switchLevel(level: number) { if (!this.map || !this.config) return; this.currentLevel = level; const { event, apiBase } = this.config; const tileUrl = `${apiBase}/api/c3nav/tiles/${event}/${level}/{z}/{x}/{y}`; const source = this.map.getSource("c3nav-tiles"); if (source) { source.setTiles([tileUrl]); } this.renderLevelSelector(); } private showEasterEgg() { // Switch to Level -1 (underground) this.switchLevel(-1); const egg = document.createElement("div"); egg.className = "easter-egg"; egg.textContent = "🕳️ You found the secret underground level!"; this.shadow.appendChild(egg); setTimeout(() => egg.remove(), 3500); } private renderError() { const overlay = this.shadow.getElementById("loading-overlay"); if (overlay) { overlay.className = "error-overlay"; overlay.innerHTML = ` ⚠️ ${this.error} `; overlay.querySelector("#switch-outdoor")?.addEventListener("click", () => { this.dispatchEvent(new CustomEvent("switch-outdoor", { bubbles: true, composed: true })); }); } } /** Update indoor participant markers */ updateParticipants(participants: Record) { if (!this.map || !(window as any).maplibregl) return; const currentIds = new Set(); for (const [id, p] of Object.entries(participants)) { if (!p.location?.indoor) continue; currentIds.add(id); // Only show participants on the current level if (p.location.indoor.level !== this.currentLevel) { if (this.participantMarkers.has(id)) { this.participantMarkers.get(id).remove(); this.participantMarkers.delete(id); } continue; } const lngLat: [number, number] = [p.location.longitude, p.location.latitude]; if (this.participantMarkers.has(id)) { this.participantMarkers.get(id).setLngLat(lngLat); } else { const el = document.createElement("div"); el.style.cssText = ` width: 32px; height: 32px; border-radius: 50%; border: 2px solid ${p.color}; background: #1a1a2e; display: flex; align-items: center; justify-content: center; font-size: 16px; cursor: pointer; box-shadow: 0 0 8px ${p.color}60; `; el.textContent = p.emoji; el.title = p.name; const marker = new (window as any).maplibregl.Marker({ element: el }) .setLngLat(lngLat) .addTo(this.map); this.participantMarkers.set(id, marker); } } // Remove markers for participants no longer indoor for (const [id, marker] of this.participantMarkers) { if (!currentIds.has(id)) { marker.remove(); this.participantMarkers.delete(id); } } } } customElements.define("map-indoor-view", MapIndoorView); export { MapIndoorView };