rspace-online/modules/rmaps/components/map-indoor-view.ts

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 };