354 lines
11 KiB
TypeScript
354 lines
11 KiB
TypeScript
/**
|
|
* <map-indoor-view> — 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<string, any> = new Map();
|
|
private loading = true;
|
|
private error = "";
|
|
private level0ClickCount = 0;
|
|
private level0ClickTimer: ReturnType<typeof setTimeout> | 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 = `
|
|
<style>
|
|
:host { display: block; width: 100%; height: 100%; position: relative; }
|
|
.indoor-map { width: 100%; height: 100%; }
|
|
.level-selector {
|
|
position: absolute; top: 50%; right: 10px; transform: translateY(-50%);
|
|
display: flex; flex-direction: column; gap: 2px; z-index: 5;
|
|
}
|
|
.level-btn {
|
|
width: 32px; height: 32px; border-radius: 6px;
|
|
border: 1px solid var(--rs-border, #334155);
|
|
background: var(--rs-bg-surface, #1a1a2e);
|
|
color: var(--rs-text-secondary, #94a3b8);
|
|
font-size: 12px; font-weight: 600; cursor: pointer;
|
|
display: flex; align-items: center; justify-content: center;
|
|
transition: all 0.15s;
|
|
}
|
|
.level-btn:hover { border-color: var(--rs-border-strong, #475569); color: var(--rs-text-primary, #e2e8f0); }
|
|
.level-btn.active {
|
|
background: #4f46e5; color: #fff; border-color: #6366f1;
|
|
}
|
|
.loading-overlay, .error-overlay {
|
|
position: absolute; inset: 0; display: flex; flex-direction: column;
|
|
align-items: center; justify-content: center; z-index: 6;
|
|
background: var(--rs-bg-surface-sunken, #0d1117);
|
|
}
|
|
.loading-overlay { color: var(--rs-text-muted, #64748b); font-size: 13px; gap: 8px; }
|
|
.error-overlay { color: #ef4444; font-size: 13px; gap: 12px; }
|
|
.outdoor-btn {
|
|
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--rs-border, #334155);
|
|
background: var(--rs-bg-surface, #1a1a2e); color: var(--rs-text-secondary, #94a3b8);
|
|
cursor: pointer; font-size: 12px;
|
|
}
|
|
.outdoor-btn:hover { border-color: var(--rs-border-strong, #475569); }
|
|
.spinner {
|
|
width: 24px; height: 24px; border: 3px solid var(--rs-border, #334155);
|
|
border-top-color: #6366f1; border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.easter-egg {
|
|
position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
|
|
background: rgba(0,0,0,0.8); color: #22c55e; font-size: 11px;
|
|
padding: 6px 12px; border-radius: 8px; z-index: 5;
|
|
animation: eggFade 3s forwards;
|
|
}
|
|
@keyframes eggFade { 0%,80% { opacity: 1; } 100% { opacity: 0; } }
|
|
</style>
|
|
<div class="indoor-map" id="indoor-map"></div>
|
|
<div class="level-selector" id="level-selector"></div>
|
|
<div class="loading-overlay" id="loading-overlay">
|
|
<div class="spinner"></div>
|
|
<span>Loading indoor map...</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private async loadMapLibre(): Promise<void> {
|
|
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<void>((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 =>
|
|
`<button class="level-btn ${level.id === this.currentLevel ? "active" : ""}" data-level="${level.id}" title="${level.name}">${level.id}</button>`
|
|
).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 = `
|
|
<span>⚠️ ${this.error}</span>
|
|
<button class="outdoor-btn" id="switch-outdoor">Switch to Outdoor Map</button>
|
|
`;
|
|
overlay.querySelector("#switch-outdoor")?.addEventListener("click", () => {
|
|
this.dispatchEvent(new CustomEvent("switch-outdoor", { bubbles: true, composed: true }));
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Update indoor participant markers */
|
|
updateParticipants(participants: Record<string, ParticipantState>) {
|
|
if (!this.map || !(window as any).maplibregl) return;
|
|
|
|
const currentIds = new Set<string>();
|
|
|
|
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 };
|