/** * -- 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. */ 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 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; 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", 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(): { provider: typeof this.providers[0]; index: number }[] { 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"}
Get Directions
`; } 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 }); } 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);