/** * -- real-time location sharing map. * * Creates/joins map rooms, shows participant locations on a map, * and provides location sharing controls. * * Demo mode: interactive SVG world map with zoom/pan, 6 cosmolocal * print providers, connection arcs, city-level detail views, tooltips, * and feature highlights matching standalone rMaps capabilities. */ import { RoomSync, type RoomState, type ParticipantState, type LocationState } from "./map-sync"; import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history"; import { MapPushManager } from "./map-push"; // MapLibre loaded via CDN — use window access with type assertion 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"; const DARK_STYLE = { version: 8, sources: { carto: { type: "raster", tiles: ["https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png"], tileSize: 256, attribution: '© CARTO © OSM', }, }, layers: [{ id: "carto", type: "raster", source: "carto" }], }; const PARTICIPANT_COLORS = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316"]; const EMOJIS = ["\u{1F9ED}", "\u{1F30D}", "\u{1F680}", "\u{1F308}", "\u{2B50}", "\u{1F525}", "\u{1F33F}", "\u{1F30A}", "\u{26A1}", "\u{1F48E}"]; 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; country: string; lat: number; lng: number; color: string; desc: string; specialties: string[] }[] = []; // Zoom/pan state (demo mode) private vbX = 0; private vbY = 0; private vbW = 900; private vbH = 460; private isDragging = false; private dragStartX = 0; private dragStartY = 0; private dragVbX = 0; private dragVbY = 0; private zoomLevel = 1; private selectedProvider = -1; private searchQuery = ""; private userLocation: { lat: number; lng: number } | null = null; // MapLibre + sync state (room mode) private map: any = null; private participantMarkers: Map = new Map(); private waypointMarkers: Map = new Map(); private sync: RoomSync | null = null; private syncUrl = ""; private participantId = ""; private userName = ""; private userEmoji = ""; private userColor = ""; private sharingLocation = false; private watchId: number | null = null; private pushManager: MapPushManager | null = null; private thumbnailTimer: ReturnType | null = null; 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; } this.loadUserProfile(); this.pushManager = new MapPushManager(this.getApiBase()); if (this.room) { this.joinRoom(this.room); return; } this.checkSyncHealth(); this.render(); } disconnectedCallback() { this.leaveRoom(); } // ─── User profile ──────────────────────────────────────────── private loadUserProfile() { try { const saved = JSON.parse(localStorage.getItem("rmaps_user") || "null"); if (saved) { this.participantId = saved.id; this.userName = saved.name; this.userEmoji = saved.emoji; this.userColor = saved.color; return; } } catch {} this.participantId = crypto.randomUUID(); this.userEmoji = EMOJIS[Math.floor(Math.random() * EMOJIS.length)]; this.userColor = PARTICIPANT_COLORS[Math.floor(Math.random() * PARTICIPANT_COLORS.length)]; } private ensureUserProfile(): boolean { if (this.userName) return true; const name = prompt("Your display name for this room:"); if (!name?.trim()) return false; this.userName = name.trim(); localStorage.setItem("rmaps_user", JSON.stringify({ id: this.participantId, name: this.userName, emoji: this.userEmoji, color: this.userColor, })); return true; } // ─── Demo mode ─────────────────────────────────────────────── private loadDemoData() { this.view = "map"; this.room = "cosmolocal-providers"; this.syncStatus = "connected"; this.providers = [ { name: "Radiant Hall Press", city: "Pittsburgh", country: "USA", lat: 40.44, lng: -79.99, color: "#ef4444", desc: "Worker-owned letterpress and risograph studio specializing in art prints and zines.", specialties: ["Letterpress", "Risograph", "Zines"], }, { name: "Tiny Splendor", city: "Los Angeles", country: "USA", lat: 34.05, lng: -118.24, color: "#f59e0b", desc: "Artist-run collective creating hand-pulled screen prints and artist books.", specialties: ["Screen Print", "Artist Books", "Posters"], }, { name: "People's Print Shop", city: "Toronto", country: "Canada", lat: 43.65, lng: -79.38, color: "#22c55e", desc: "Community print shop offering affordable risograph and offset printing for social movements.", specialties: ["Risograph", "Offset", "Community"], }, { name: "Colour Code Press", city: "London", country: "UK", lat: 51.51, lng: -0.13, color: "#3b82f6", desc: "Independent risograph studio in East London, specializing in publications and packaging.", specialties: ["Risograph", "Publications", "Packaging"], }, { name: "Druckwerkstatt Berlin", city: "Berlin", country: "Germany", lat: 52.52, lng: 13.40, color: "#8b5cf6", desc: "Open-access printmaking workshop in Kreuzberg with letterpress, screen print, and risograph.", specialties: ["Letterpress", "Screen Print", "Risograph"], }, { name: "Kink\u014D Printing Collective", city: "Tokyo", country: "Japan", lat: 35.68, lng: 139.69, color: "#ec4899", desc: "Tokyo-based collective blending traditional Japanese woodblock with modern risograph techniques.", specialties: ["Woodblock", "Risograph", "Limited Editions"], }, ]; this.renderDemo(); } private getFilteredProviders() { if (!this.searchQuery.trim()) return this.providers.map((p, i) => ({ provider: p, index: i })); const q = this.searchQuery.toLowerCase(); return this.providers.map((p, i) => ({ provider: p, index: i })) .filter(({ provider: p }) => p.name.toLowerCase().includes(q) || p.city.toLowerCase().includes(q) || p.country.toLowerCase().includes(q) || p.specialties.some(s => s.toLowerCase().includes(q)) ); } private renderDemo() { const W = 900; const H = 460; const px = (lng: number) => ((lng + 180) / 360) * W; const py = (lat: number) => ((90 - lat) / 180) * H; const filteredSet = new Set(this.getFilteredProviders().map(f => f.index)); // Label offsets to avoid overlapping 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 const connections = [ [0, 2], [0, 3], [3, 4], [4, 5], [1, 5], [0, 1], ]; 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); const mx = (x1 + x2) / 2; const my = (y1 + y2) / 2 - Math.abs(x2 - x1) * 0.12; return ``; }).join("\n"); // Provider pins // User location pin const userPin = this.userLocation ? (() => { const ux = px(this.userLocation!.lng); const uy = py(this.userLocation!.lat); return ` You `; })() : ""; 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]; const isSelected = this.selectedProvider === i; const isDimmed = this.searchQuery.trim() && !filteredSet.has(i); return ` ${isSelected ? `` : ""} ${this.esc(p.name)} ${this.esc(p.city)}, ${this.esc(p.country)} `; }).join(""); // Legend items const legendItems = this.providers.map((p, i) => `
${this.esc(p.name)} ${this.esc(p.city)}, ${this.esc(p.country)}
`).join(""); // Provider detail panel (shown when selected) let detailPanel = ""; if (this.selectedProvider >= 0 && this.selectedProvider < this.providers.length) { const sp = this.providers[this.selectedProvider]; detailPanel = `
${this.esc(sp.name)}
${this.esc(sp.city)}, ${this.esc(sp.country)}

${this.esc(sp.desc)}

${sp.specialties.map(s => `${this.esc(s)}`).join("")}
${sp.lat.toFixed(4)}\u00B0N, ${Math.abs(sp.lng).toFixed(4)}\u00B0${sp.lng >= 0 ? "E" : "W"}
`; } this.shadow.innerHTML = `
Cosmolocal Print Network
${Math.round(this.zoomLevel * 100)}%
${this.providers.length} providers online
${this.graticule(W, H)} ${this.continents(W, H)} ${arcs} ${pins} ${userPin}
${detailPanel}
Print Providers \u2014 click to explore
${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(); } private zoomTo(lat: number, lng: number, level: number) { const W = 900, H = 460; const cx = ((lng + 180) / 360) * W; const cy = ((90 - lat) / 180) * H; this.zoomLevel = level; this.vbW = W / level; this.vbH = H / level; this.vbX = cx - this.vbW / 2; this.vbY = cy - this.vbH / 2; // Clamp this.vbX = Math.max(-100, Math.min(W - this.vbW + 100, this.vbX)); this.vbY = Math.max(-100, Math.min(H - this.vbH + 100, this.vbY)); this.renderDemo(); } private resetZoom() { this.vbX = 0; this.vbY = 0; this.vbW = 900; this.vbH = 460; this.zoomLevel = 1; this.selectedProvider = -1; this.renderDemo(); } /** Generate SVG graticule lines */ private graticule(W: number, H: number): string { const lines: string[] = []; for (let lat = -60; lat <= 60; lat += 30) { const y = ((90 - lat) / 180) * H; lines.push(``); } for (let lng = -150; lng <= 180; lng += 30) { const x = ((lng + 180) / 360) * W; lines.push(``); } 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"; 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 `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 `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 `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() { // Search input let searchTimeout: any; this.shadow.getElementById("map-search")?.addEventListener("input", (e) => { this.searchQuery = (e.target as HTMLInputElement).value; clearTimeout(searchTimeout); searchTimeout = setTimeout(() => this.renderDemo(), 200); }); // Geolocation button this.shadow.getElementById("share-geo")?.addEventListener("click", () => { if (this.userLocation) { this.userLocation = null; this.renderDemo(); return; } if ("geolocation" in navigator) { navigator.geolocation.getCurrentPosition( (pos) => { this.userLocation = { lat: pos.coords.latitude, lng: pos.coords.longitude }; this.renderDemo(); }, () => { /* denied — do nothing */ } ); } }); const tooltip = this.shadow.getElementById("tooltip"); const mapWrap = this.shadow.getElementById("map-wrap"); const mapSvg = this.shadow.getElementById("map-svg"); // Tooltip on hover if (tooltip) { 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 = mapWrap?.getBoundingClientRect(); const me = e as MouseEvent; if (rect) { tooltip.innerHTML = `${this.esc(p.name)}${this.esc(p.city)}, ${this.esc(p.country)}${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 = mapWrap?.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"); }); }); } // Click pin to select provider and zoom this.shadow.querySelectorAll(".pin-group").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const idx = parseInt((el as HTMLElement).dataset.idx || "0", 10); if (this.selectedProvider === idx) { this.resetZoom(); } else { this.selectedProvider = idx; const p = this.providers[idx]; this.zoomTo(p.lat, p.lng, 3); } }); }); // Click legend item to select/zoom this.shadow.querySelectorAll("[data-legend]").forEach((el) => { el.addEventListener("click", () => { const idx = parseInt((el as HTMLElement).dataset.legend || "0", 10); if (this.selectedProvider === idx) { this.resetZoom(); } else { this.selectedProvider = idx; const p = this.providers[idx]; this.zoomTo(p.lat, p.lng, 3); } }); }); // Close detail panel this.shadow.getElementById("detail-close")?.addEventListener("click", () => { this.resetZoom(); }); // Zoom controls this.shadow.getElementById("zoom-in")?.addEventListener("click", () => { const newZoom = Math.min(this.zoomLevel * 1.5, 6); const cx = this.vbX + this.vbW / 2; const cy = this.vbY + this.vbH / 2; const lat = 90 - (cy / 460) * 180; const lng = (cx / 900) * 360 - 180; this.zoomTo(lat, lng, newZoom); }); this.shadow.getElementById("zoom-out")?.addEventListener("click", () => { if (this.zoomLevel <= 1) { this.resetZoom(); return; } const newZoom = Math.max(this.zoomLevel / 1.5, 1); const cx = this.vbX + this.vbW / 2; const cy = this.vbY + this.vbH / 2; const lat = 90 - (cy / 460) * 180; const lng = (cx / 900) * 360 - 180; this.zoomTo(lat, lng, newZoom); }); this.shadow.getElementById("zoom-reset")?.addEventListener("click", () => { this.resetZoom(); }); // Mouse wheel zoom mapWrap?.addEventListener("wheel", (e) => { e.preventDefault(); const we = e as WheelEvent; const delta = we.deltaY > 0 ? 0.8 : 1.25; const newZoom = Math.max(1, Math.min(6, this.zoomLevel * delta)); // Zoom toward mouse position const rect = mapWrap.getBoundingClientRect(); const mouseX = we.clientX - rect.left; const mouseY = we.clientY - rect.top; const svgX = this.vbX + (mouseX / rect.width) * this.vbW; const svgY = this.vbY + (mouseY / rect.height) * this.vbH; const lat = 90 - (svgY / 460) * 180; const lng = (svgX / 900) * 360 - 180; this.zoomTo(lat, lng, newZoom); }, { passive: false }); // Drag to pan mapWrap?.addEventListener("mousedown", (e) => { const me = e as MouseEvent; // Don't start drag on pins if ((me.target as Element)?.closest?.(".pin-group")) return; this.isDragging = true; this.dragStartX = me.clientX; this.dragStartY = me.clientY; this.dragVbX = this.vbX; this.dragVbY = this.vbY; mapWrap.classList.add("dragging"); }); const onMouseMove = (e: Event) => { if (!this.isDragging) return; const me = e as MouseEvent; const rect = mapWrap?.getBoundingClientRect(); if (!rect) return; const dx = (me.clientX - this.dragStartX) / rect.width * this.vbW; const dy = (me.clientY - this.dragStartY) / rect.height * this.vbH; this.vbX = this.dragVbX - dx; this.vbY = this.dragVbY - dy; // Clamp this.vbX = Math.max(-200, Math.min(900 - this.vbW + 200, this.vbX)); this.vbY = Math.max(-200, Math.min(460 - this.vbH + 200, this.vbY)); if (mapSvg) { mapSvg.setAttribute("viewBox", `${this.vbX} ${this.vbY} ${this.vbW} ${this.vbH}`); } }; const onMouseUp = () => { this.isDragging = false; mapWrap?.classList.remove("dragging"); }; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); // Touch support for pan mapWrap?.addEventListener("touchstart", (e) => { const te = e as TouchEvent; if (te.touches.length === 1) { this.isDragging = true; this.dragStartX = te.touches[0].clientX; this.dragStartY = te.touches[0].clientY; this.dragVbX = this.vbX; this.dragVbY = this.vbY; } }, { passive: true }); mapWrap?.addEventListener("touchmove", (e) => { if (!this.isDragging) return; const te = e as TouchEvent; if (te.touches.length !== 1) return; const rect = mapWrap.getBoundingClientRect(); const dx = (te.touches[0].clientX - this.dragStartX) / rect.width * this.vbW; const dy = (te.touches[0].clientY - this.dragStartY) / rect.height * this.vbH; this.vbX = this.dragVbX - dx; this.vbY = this.dragVbY - dy; this.vbX = Math.max(-200, Math.min(900 - this.vbW + 200, this.vbX)); this.vbY = Math.max(-200, Math.min(460 - this.vbH + 200, this.vbY)); if (mapSvg) { mapSvg.setAttribute("viewBox", `${this.vbX} ${this.vbY} ${this.vbW} ${this.vbH}`); } }, { passive: true }); mapWrap?.addEventListener("touchend", () => { this.isDragging = false; }, { passive: true }); } // ─── Room mode: API / health ───────────────────────────────── private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rmaps/); return match ? match[0] : ""; } 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(); } // ─── Room mode: join / leave / create ──────────────────────── private joinRoom(slug: string) { if (!this.ensureUserProfile()) return; this.room = slug; this.view = "map"; this.render(); this.initMapView(); this.initRoomSync(); } 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 leaveRoom() { this.captureThumbnail(); if (this.watchId !== null) { navigator.geolocation.clearWatch(this.watchId); this.watchId = null; } this.sharingLocation = false; if (this.sync) { this.sync.leave(); this.sync = null; } this.participantMarkers.forEach((m) => m.remove()); this.participantMarkers.clear(); this.waypointMarkers.forEach((m) => m.remove()); this.waypointMarkers.clear(); if (this.map) { this.map.remove(); this.map = null; } if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer); } // ─── MapLibre GL ───────────────────────────────────────────── private async loadMapLibre(): Promise { if ((window as any).maplibregl) return; 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 initMapView() { await this.loadMapLibre(); const container = this.shadow.getElementById("map-container"); if (!container || !(window as any).maplibregl) return; this.map = new (window as any).maplibregl.Map({ container, style: DARK_STYLE, center: [0, 20], zoom: 2, preserveDrawingBuffer: true, }); this.map.addControl(new (window as any).maplibregl.NavigationControl(), "top-right"); this.map.addControl(new (window as any).maplibregl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: false, }), "top-right"); // Debounced thumbnail capture on moveend this.map.on("moveend", () => { if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer); this.thumbnailTimer = setTimeout(() => this.captureThumbnail(), 3000); }); // Initial thumbnail capture after tiles load this.map.on("load", () => { setTimeout(() => this.captureThumbnail(), 2000); }); } // ─── Room sync ─────────────────────────────────────────────── private async initRoomSync() { // Fetch sync URL from server try { const base = this.getApiBase(); const res = await fetch(`${base}/api/sync-url`, { signal: AbortSignal.timeout(3000) }); if (res.ok) { const data = await res.json(); this.syncUrl = data.syncUrl || ""; } } catch {} this.sync = new RoomSync( this.room, this.participantId, (state) => this.onRoomStateChange(state), (connected) => { this.syncStatus = connected ? "connected" : "disconnected"; const dot = this.shadow.querySelector(".status-dot"); if (dot) { dot.className = `status-dot ${connected ? "status-connected" : "status-disconnected"}`; } }, ); this.sync.connect(this.syncUrl || undefined); const now = new Date().toISOString(); this.sync.join({ id: this.participantId, name: this.userName, emoji: this.userEmoji, color: this.userColor, joinedAt: now, lastSeen: now, status: "online", }); saveRoomVisit(this.room, this.room); } // ─── State change → update markers ─────────────────────────── private onRoomStateChange(state: RoomState) { if (!this.map || !(window as any).maplibregl) return; const currentIds = new Set(); // Update participant markers for (const [id, p] of Object.entries(state.participants)) { currentIds.add(id); if (p.location) { 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.className = "participant-marker"; el.style.cssText = ` width: 36px; height: 36px; border-radius: 50%; border: 3px solid ${p.color}; background: #1a1a2e; display: flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer; position: relative; box-shadow: 0 0 8px ${p.color}60; `; el.textContent = p.emoji; el.title = p.name; // Name label below const label = document.createElement("div"); label.style.cssText = ` position: absolute; bottom: -18px; left: 50%; transform: translateX(-50%); font-size: 10px; color: ${p.color}; font-weight: 600; white-space: nowrap; text-shadow: 0 1px 3px rgba(0,0,0,0.8); font-family: system-ui, sans-serif; `; label.textContent = p.name; el.appendChild(label); const marker = new (window as any).maplibregl.Marker({ element: el }) .setLngLat(lngLat) .addTo(this.map); this.participantMarkers.set(id, marker); } } } // Remove departed participants for (const [id, marker] of this.participantMarkers) { if (!currentIds.has(id) || !state.participants[id]?.location) { marker.remove(); this.participantMarkers.delete(id); } } // Update waypoint markers const wpIds = new Set(state.waypoints.map((w) => w.id)); for (const wp of state.waypoints) { if (!this.waypointMarkers.has(wp.id)) { const el = document.createElement("div"); el.style.cssText = ` width: 28px; height: 28px; border-radius: 50%; background: #4f46e5; border: 2px solid #818cf8; display: flex; align-items: center; justify-content: center; font-size: 14px; cursor: pointer; `; el.textContent = wp.emoji || "\u{1F4CD}"; el.title = wp.name; const marker = new (window as any).maplibregl.Marker({ element: el }) .setLngLat([wp.longitude, wp.latitude]) .addTo(this.map); this.waypointMarkers.set(wp.id, marker); } } for (const [id, marker] of this.waypointMarkers) { if (!wpIds.has(id)) { marker.remove(); this.waypointMarkers.delete(id); } } // Update participant list sidebar this.updateParticipantList(state); } private updateParticipantList(state: RoomState) { const list = this.shadow.getElementById("participant-list"); if (!list) return; const entries = Object.values(state.participants); list.innerHTML = entries.map((p) => `
${this.esc(p.emoji)}
${this.esc(p.name)}
${p.location ? "sharing location" : "no location"}
${p.id !== this.participantId ? `` : ""}
`).join(""); // Attach ping listeners list.querySelectorAll("[data-ping]").forEach((btn) => { btn.addEventListener("click", () => { const pid = (btn as HTMLElement).dataset.ping!; this.pushManager?.requestLocation(this.room, pid); (btn as HTMLElement).textContent = "\u2713"; setTimeout(() => { (btn as HTMLElement).textContent = "\u{1F514}"; }, 2000); }); }); } // ─── Location sharing ──────────────────────────────────────── private toggleLocationSharing() { if (this.sharingLocation) { // Stop sharing if (this.watchId !== null) { navigator.geolocation.clearWatch(this.watchId); this.watchId = null; } this.sharingLocation = false; this.sync?.clearLocation(); this.updateShareButton(); return; } if (!("geolocation" in navigator)) { this.error = "Geolocation not supported"; return; } let firstFix = true; this.watchId = navigator.geolocation.watchPosition( (pos) => { this.sharingLocation = true; this.updateShareButton(); const loc: LocationState = { latitude: pos.coords.latitude, longitude: pos.coords.longitude, accuracy: pos.coords.accuracy, altitude: pos.coords.altitude ?? undefined, heading: pos.coords.heading ?? undefined, speed: pos.coords.speed ?? undefined, timestamp: new Date().toISOString(), source: "gps", }; this.sync?.updateLocation(loc); if (firstFix && this.map) { this.map.flyTo({ center: [loc.longitude, loc.latitude], zoom: 14 }); firstFix = false; } }, (err) => { this.error = `Location error: ${err.message}`; this.sharingLocation = false; this.updateShareButton(); }, { enableHighAccuracy: true, maximumAge: 5000, timeout: 15000 }, ); } private updateShareButton() { const btn = this.shadow.getElementById("share-location"); if (!btn) return; if (this.sharingLocation) { btn.textContent = "\u{1F4CD} Stop Sharing"; btn.classList.add("sharing"); } else { btn.textContent = "\u{1F4CD} Share Location"; btn.classList.remove("sharing"); } } // ─── Waypoint drop ─────────────────────────────────────────── private dropWaypoint() { if (!this.map) return; const center = this.map.getCenter(); const name = prompt("Waypoint name:", "Meeting point"); if (!name?.trim()) return; this.sync?.addWaypoint({ id: crypto.randomUUID(), name: name.trim(), emoji: "\u{1F4CD}", latitude: center.lat, longitude: center.lng, createdBy: this.participantId, createdAt: new Date().toISOString(), type: "meeting", }); } // ─── Thumbnail capture ─────────────────────────────────────── private captureThumbnail() { if (!this.map || !this.room) return; try { const canvas = this.map.getCanvas(); // Downscale to 200x120 const tmp = document.createElement("canvas"); tmp.width = 200; tmp.height = 120; const ctx = tmp.getContext("2d"); if (!ctx) return; ctx.drawImage(canvas, 0, 0, 200, 120); const dataUrl = tmp.toDataURL("image/jpeg", 0.6); updateRoomThumbnail(this.room, dataUrl); } catch {} } // ─── Time ago helper ───────────────────────────────────────── private timeAgo(iso: string): string { const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); if (s < 60) return "just now"; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); return `${d}d ago`; } // ─── Render (room mode) ────────────────────────────────────── private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.view === "lobby" ? this.renderLobby() : this.renderMap()} `; this.attachListeners(); } private renderLobby(): string { const history = loadRoomHistory(); const historyCards = history.length > 0 ? `
${history.map((h) => `
${h.thumbnail ? `` : `
🌐
` }
${this.esc(h.name)}
${this.timeAgo(h.lastVisited)}
`).join("")}
` : ""; return `
Map Rooms ${this.syncStatus === "connected" ? "Sync online" : "Sync offline"}
${this.rooms.length > 0 ? ` ${this.rooms.map((r) => `
🗺 ${this.esc(r)}
`).join("")} ` : ""} ${historyCards}

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)}
Connecting...
`; } 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.leaveRoom(); this.view = "lobby"; this.loadStats(); }); }); this.shadow.getElementById("share-location")?.addEventListener("click", () => { this.toggleLocationSharing(); }); this.shadow.getElementById("drop-waypoint")?.addEventListener("click", () => { this.dropWaypoint(); }); 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); }); }); // Ping buttons on history cards this.shadow.querySelectorAll("[data-ping-room]").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); const slug = (btn as HTMLElement).dataset.pingRoom!; this.pushManager?.requestLocation(slug, "all"); (btn as HTMLElement).textContent = "\u2713"; setTimeout(() => { (btn as HTMLElement).textContent = "\u{1F514}"; }, 2000); }); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-map-viewer", FolkMapViewer);