/** * — real-time location sharing map. * * Creates/joins map rooms, shows participant locations on a map, * and provides location sharing controls. */ class FolkMapViewer extends HTMLElement { private shadow: ShadowRoot; private space = ""; private room = ""; private view: "lobby" | "map" = "lobby"; private rooms: string[] = []; private loading = false; private error = ""; private syncStatus: "disconnected" | "connected" = "disconnected"; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.room = this.getAttribute("room") || ""; if (this.room) { this.view = "map"; } this.checkSyncHealth(); this.render(); } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/maps/); return match ? `/${match[1]}/maps` : ""; } private async checkSyncHealth() { try { const base = this.getApiBase(); const res = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(3000) }); if (res.ok) { const data = await res.json(); this.syncStatus = data.sync !== false ? "connected" : "disconnected"; } } catch { this.syncStatus = "disconnected"; } this.render(); } private async loadStats() { try { const base = this.getApiBase(); const res = await fetch(`${base}/api/stats`, { signal: AbortSignal.timeout(3000) }); if (res.ok) { const data = await res.json(); this.rooms = Object.keys(data.rooms || {}); } } catch { this.rooms = []; } this.render(); } private joinRoom(slug: string) { this.room = slug; this.view = "map"; this.render(); } private createRoom() { const name = prompt("Room name (slug):"); if (!name?.trim()) return; const slug = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-"); this.joinRoom(slug); } private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.view === "lobby" ? this.renderLobby() : this.renderMap()} `; this.attachListeners(); } private renderLobby(): string { return `
Map Rooms ${this.syncStatus === "connected" ? "Sync online" : "Sync offline"}
${this.rooms.length > 0 ? this.rooms.map((r) => `
\u{1F5FA} ${this.esc(r)}
`).join("") : ""}

Create or join a map room to share locations

Share the room link with friends to see each other on the map in real-time

`; } private renderMap(): string { const shareUrl = `${window.location.origin}/${this.space}/maps/${this.room}`; return `
\u{1F5FA} ${this.esc(this.room)}

\u{1F30D}

Map Room: ${this.esc(this.room)}

Connect the MapLibre GL library to display the interactive map.

WebSocket sync: ${this.syncStatus}

`; } private attachListeners() { this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom()); this.shadow.querySelectorAll("[data-room]").forEach((el) => { el.addEventListener("click", () => { const room = (el as HTMLElement).dataset.room!; this.joinRoom(room); }); }); this.shadow.querySelectorAll("[data-back]").forEach((el) => { el.addEventListener("click", () => { this.view = "lobby"; this.loadStats(); }); }); this.shadow.getElementById("share-location")?.addEventListener("click", () => { if ("geolocation" in navigator) { navigator.geolocation.getCurrentPosition( (pos) => { const btn = this.shadow.getElementById("share-location"); if (btn) { btn.textContent = `Location: ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`; btn.classList.add("sharing"); } }, () => { this.error = "Location access denied"; this.render(); } ); } }); const copyUrl = this.shadow.getElementById("copy-url") || this.shadow.getElementById("copy-link"); copyUrl?.addEventListener("click", () => { const url = `${window.location.origin}/${this.space}/maps/${this.room}`; navigator.clipboard.writeText(url).then(() => { if (copyUrl) copyUrl.textContent = "Copied!"; setTimeout(() => { if (copyUrl) copyUrl.textContent = "Copy"; }, 2000); }); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-map-viewer", FolkMapViewer);