/** * -- real-time location sharing map. * * Creates/joins map rooms, shows participant locations on a map, * and provides location sharing controls. * * Demo mode: shows 6 cosmolocal print providers on a world map with * connection arcs, interactive hover tooltips, and a feature summary * matching the standalone rMaps capabilities. */ 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"; private providers: { name: string; city: string; lat: number; lng: number; color: string }[] = []; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.room = this.getAttribute("room") || ""; if (this.space === "demo") { this.loadDemoData(); return; } if (this.room) { this.view = "map"; } this.checkSyncHealth(); this.render(); } private loadDemoData() { this.view = "map"; this.room = "cosmolocal-providers"; this.syncStatus = "connected"; this.providers = [ { name: "Radiant Hall Press", city: "Pittsburgh, PA", lat: 40.44, lng: -79.99, color: "#ef4444" }, { name: "Tiny Splendor", city: "Los Angeles, CA", lat: 34.05, lng: -118.24, color: "#f59e0b" }, { name: "People's Print Shop", city: "Toronto, ON", lat: 43.65, lng: -79.38, color: "#22c55e" }, { name: "Colour Code Press", city: "London, UK", lat: 51.51, lng: -0.13, color: "#3b82f6" }, { name: "Druckwerkstatt Berlin", city: "Berlin, DE", lat: 52.52, lng: 13.40, color: "#8b5cf6" }, { name: "Kink\u014D Printing Collective", city: "Tokyo, JP", lat: 35.68, lng: 139.69, color: "#ec4899" }, ]; this.renderDemo(); } private renderDemo() { const W = 900; const H = 460; const px = (lng: number) => ((lng + 180) / 360) * W; const py = (lat: number) => ((90 - lat) / 180) * H; // Label offsets to avoid overlapping (Pittsburgh/Toronto are close) const labelOffsets: Record = { "Radiant Hall Press": [10, -8], "Tiny Splendor": [-110, 14], "People's Print Shop": [10, 14], "Colour Code Press": [10, -8], "Druckwerkstatt Berlin": [10, 14], "Kink\u014D Printing Collective": [-150, -8], }; // Connection arcs between providers (great-circle style curves) const connections = [ [0, 2], // Pittsburgh -- Toronto [0, 3], // Pittsburgh -- London [3, 4], // London -- Berlin [4, 5], // Berlin -- Tokyo [1, 5], // LA -- Tokyo (Pacific) [0, 1], // Pittsburgh -- LA ]; const arcs = connections.map(([i, j]) => { const a = this.providers[i]; const b = this.providers[j]; const x1 = px(a.lng), y1 = py(a.lat); const x2 = px(b.lng), y2 = py(b.lat); // Curved midpoint -- arc above the straight line const mx = (x1 + x2) / 2; const my = (y1 + y2) / 2 - Math.abs(x2 - x1) * 0.12; return ``; }).join("\n"); // Provider pins (drop-pin style) and labels const pins = this.providers.map((p, i) => { const x = px(p.lng); const y = py(p.lat); const [lx, ly] = labelOffsets[p.name] || [10, 4]; return ` ${this.esc(p.name)} ${this.esc(p.city)} `; }).join(""); // Legend items const legendItems = this.providers.map((p) => `
${this.esc(p.name)} ${this.esc(p.city)}
`).join(""); this.shadow.innerHTML = `
Cosmolocal Print Network 6 providers online
${this.graticule(W, H)} ${this.continents(W, H)} ${arcs} ${pins}
Print Providers
${legendItems}
📍
Live GPS
Real-time location sharing via WebSocket
🏢
Indoor Nav
c3nav integration for CCC events
📌
Waypoints
Drop meeting points and pins
🔗
QR Sharing
Scan to join any room instantly
📡
Ping Friends
Request location with one tap
🛡
Privacy First
Ghost mode, precision levels, zero tracking
`; this.attachDemoListeners(); } /** Generate SVG graticule lines */ private graticule(W: number, H: number): string { const lines: string[] = []; // Latitude lines every 30 degrees for (let lat = -60; lat <= 60; lat += 30) { const y = ((90 - lat) / 180) * H; lines.push(``); } // Longitude lines every 30 degrees for (let lng = -150; lng <= 180; lng += 30) { const x = ((lng + 180) / 360) * W; lines.push(``); } // Equator and Prime Meridian slightly brighter const eq = ((90 - 0) / 180) * H; const pm = ((0 + 180) / 360) * W; lines.push(``); lines.push(``); return lines.join("\n"); } /** Simplified continent outlines using equirectangular projection */ private continents(W: number, H: number): string { const p = (lat: number, lng: number) => { const x = ((lng + 180) / 360) * W; const y = ((90 - lat) / 180) * H; return `${x.toFixed(1)},${y.toFixed(1)}`; }; const fill = "#162236"; const stroke = "#1e3050"; // Each continent as a polygon path const continents = [ // North America `M${p(50, -130)} L${p(60, -130)} L${p(65, -120)} L${p(70, -100)} L${p(72, -80)} L${p(65, -65)} L${p(50, -55)} L${p(45, -65)} L${p(40, -75)} L${p(30, -82)} L${p(28, -90)} L${p(25, -100)} L${p(20, -105)} L${p(18, -100)} L${p(15, -88)} L${p(10, -84)} L${p(10, -78)} L${p(20, -88)} L${p(25, -80)} L${p(30, -82)} L${p(30, -75)} L${p(40, -75)} L${p(48, -90)} L${p(48, -95)} L${p(50, -120)} Z`, // South America `M${p(12, -75)} L${p(10, -68)} L${p(5, -60)} L${p(0, -50)} L${p(-5, -45)} L${p(-10, -38)} L${p(-15, -40)} L${p(-20, -42)} L${p(-25, -48)} L${p(-30, -50)} L${p(-35, -56)} L${p(-40, -62)} L${p(-45, -66)} L${p(-50, -70)} L${p(-55, -68)} L${p(-50, -74)} L${p(-42, -72)} L${p(-35, -70)} L${p(-25, -70)} L${p(-20, -70)} L${p(-15, -76)} L${p(-5, -78)} L${p(0, -80)} L${p(5, -78)} L${p(10, -76)} Z`, // Europe `M${p(40, -8)} L${p(42, 0)} L${p(44, 5)} L${p(46, 8)} L${p(48, 10)} L${p(50, 5)} L${p(52, 8)} L${p(55, 10)} L${p(58, 12)} L${p(60, 10)} L${p(62, 18)} L${p(65, 20)} L${p(70, 25)} L${p(68, 30)} L${p(62, 32)} L${p(58, 30)} L${p(55, 28)} L${p(50, 30)} L${p(48, 28)} L${p(45, 25)} L${p(42, 28)} L${p(38, 25)} L${p(36, 22)} L${p(38, 10)} L${p(38, 0)} Z`, // Africa `M${p(35, -5)} L${p(37, 10)} L${p(35, 20)} L${p(32, 32)} L${p(28, 33)} L${p(15, 42)} L${p(10, 42)} L${p(5, 38)} L${p(0, 40)} L${p(-5, 38)} L${p(-10, 34)} L${p(-15, 30)} L${p(-20, 28)} L${p(-25, 28)} L${p(-30, 28)} L${p(-34, 25)} L${p(-34, 20)} L${p(-30, 15)} L${p(-20, 12)} L${p(-15, 12)} L${p(-10, 15)} L${p(-5, 10)} L${p(0, 8)} L${p(5, 2)} L${p(10, 0)} L${p(15, -5)} L${p(20, -10)} L${p(25, -15)} L${p(30, -10)} L${p(35, -5)} Z`, // Asia (mainland) `M${p(70, 30)} L${p(72, 50)} L${p(72, 80)} L${p(70, 110)} L${p(68, 140)} L${p(65, 165)} L${p(60, 165)} L${p(55, 140)} L${p(50, 130)} L${p(45, 135)} L${p(40, 130)} L${p(35, 120)} L${p(30, 120)} L${p(25, 105)} L${p(22, 100)} L${p(20, 98)} L${p(15, 100)} L${p(10, 105)} L${p(5, 100)} L${p(0, 104)} L${p(-5, 105)} L${p(-8, 112)} L${p(-6, 118)} L${p(2, 110)} L${p(8, 108)} L${p(12, 110)} L${p(20, 108)} L${p(22, 114)} L${p(30, 110)} L${p(28, 88)} L${p(25, 68)} L${p(30, 50)} L${p(35, 45)} L${p(40, 40)} L${p(42, 32)} L${p(48, 30)} L${p(55, 30)} L${p(62, 32)} L${p(68, 30)} Z`, // Australia `M${p(-12, 132)} L${p(-15, 140)} L${p(-20, 148)} L${p(-25, 150)} L${p(-30, 148)} L${p(-35, 140)} L${p(-38, 146)} L${p(-35, 150)} L${p(-32, 152)} L${p(-28, 153)} L${p(-25, 152)} L${p(-20, 150)} L${p(-15, 145)} L${p(-12, 138)} L${p(-14, 130)} L${p(-18, 122)} L${p(-22, 115)} L${p(-28, 114)} L${p(-32, 116)} L${p(-35, 118)} L${p(-34, 125)} L${p(-30, 130)} L${p(-25, 132)} L${p(-20, 130)} L${p(-16, 128)} L${p(-12, 132)} Z`, // Japan (simplified) `M${p(35, 133)} L${p(38, 136)} L${p(40, 140)} L${p(42, 142)} L${p(44, 144)} L${p(45, 142)} L${p(43, 140)} L${p(40, 137)} L${p(37, 135)} L${p(35, 133)} Z`, // UK/Ireland `M${p(51, -5)} L${p(53, 0)} L${p(55, -2)} L${p(57, -5)} L${p(58, -3)} L${p(56, 0)} L${p(54, 1)} L${p(52, 0)} L${p(50, -3)} L${p(51, -5)} Z`, // Greenland `M${p(62, -50)} L${p(68, -52)} L${p(75, -45)} L${p(78, -35)} L${p(76, -20)} L${p(70, -22)} L${p(65, -35)} L${p(62, -45)} Z`, // Indonesia (simplified) `M${p(-2, 100)} L${p(-4, 108)} L${p(-6, 112)} L${p(-8, 115)} L${p(-7, 118)} L${p(-5, 116)} L${p(-3, 112)} L${p(-1, 106)} L${p(-2, 100)} Z`, // New Zealand `M${p(-36, 174)} L${p(-38, 176)} L${p(-42, 174)} L${p(-46, 168)} L${p(-44, 168)} L${p(-42, 172)} L${p(-38, 174)} L${p(-36, 174)} Z`, ]; return continents.map((d) => `` ).join("\n"); } private attachDemoListeners() { const tooltip = this.shadow.getElementById("tooltip"); if (!tooltip) return; this.shadow.querySelectorAll(".pin-group").forEach((el) => { const idx = parseInt((el as HTMLElement).dataset.idx || "0", 10); const p = this.providers[idx]; el.addEventListener("mouseenter", (e) => { const rect = this.shadow.querySelector(".map-wrap")?.getBoundingClientRect(); const me = e as MouseEvent; if (rect) { tooltip.innerHTML = `${this.esc(p.name)}${this.esc(p.city)}${p.lat.toFixed(2)}, ${p.lng.toFixed(2)}`; tooltip.style.left = `${me.clientX - rect.left + 12}px`; tooltip.style.top = `${me.clientY - rect.top - 10}px`; tooltip.classList.add("visible"); } }); el.addEventListener("mousemove", (e) => { const rect = this.shadow.querySelector(".map-wrap")?.getBoundingClientRect(); const me = e as MouseEvent; if (rect) { tooltip.style.left = `${me.clientX - rect.left + 12}px`; tooltip.style.top = `${me.clientY - rect.top - 10}px`; } }); el.addEventListener("mouseleave", () => { tooltip.classList.remove("visible"); }); }); } 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) => `
🗺 ${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 `
🗺 ${this.esc(this.room)}

🌎

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