From 1cc083a655b680577d67740088dbd6aa3b799e15 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 30 Mar 2026 23:47:02 -0700 Subject: [PATCH] feat(rmaps): replace SVG demo with real MapLibre GL festival meetup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo previously showed a static SVG world map with 6 hardcoded print providers — completely different from the real app. Now shows a real MapLibre GL map with 5 animated participants, 4 waypoint pins, a route line, and the full app UI (header, sidebar, controls, mobile bottom sheet). Visitors see exactly what the product looks like. Co-Authored-By: Claude Opus 4.6 --- modules/rmaps/components/folk-map-viewer.ts | 1014 ++++++------------- 1 file changed, 297 insertions(+), 717 deletions(-) diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index 80227e9..bb9b6d6 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -4,9 +4,9 @@ * 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. + * Demo mode: real MapLibre GL map showing a simulated festival meetup + * with animated participants, waypoints, and a route line — using the + * same UI as the real app. */ import { RoomSync, type RoomState, type ParticipantState, type LocationState, type ParticipantStatus, type PrecisionLevel, type PrivacySettings, type WaypointType } from "./map-sync"; @@ -60,6 +60,47 @@ const PARTICIPANT_COLORS = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6 const EMOJIS = ["\u{1F600}", "\u{1F60E}", "\u{1F913}", "\u{1F973}", "\u{1F98A}", "\u{1F431}", "\u{1F436}", "\u{1F984}", "\u{1F31F}", "\u{1F525}", "\u{1F49C}", "\u{1F3AE}"]; const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes +// ─── Demo constants: simulated festival meetup ────────────────── +// Ziegeleipark Mildenberg — CCC festival site near Berlin +const DEMO_CENTER: [number, number] = [13.0406, 52.9753]; + +const DEMO_PARTICIPANTS: { id: string; name: string; emoji: string; color: string; status: "online" | "away" | "ghost"; speed: number; route: [number, number][] }[] = [ + { + id: "demo-alice", name: "Alice", emoji: "\u{1F98A}", color: "#ef4444", status: "online", speed: 1, + route: [[13.0390, 52.9758], [13.0398, 52.9762], [13.0408, 52.9760], [13.0415, 52.9755], [13.0410, 52.9748], [13.0400, 52.9750]], + }, + { + id: "demo-boris", name: "Boris", emoji: "\u{1F43A}", color: "#3b82f6", status: "online", speed: 1, + route: [[13.0420, 52.9745], [13.0425, 52.9750], [13.0430, 52.9755], [13.0425, 52.9760], [13.0418, 52.9758], [13.0415, 52.9750]], + }, + { + id: "demo-chen", name: "Chen", emoji: "\u{1F31F}", color: "#f59e0b", status: "away", speed: 0.5, + route: [[13.0380, 52.9745], [13.0385, 52.9748], [13.0390, 52.9745], [13.0385, 52.9742]], + }, + { + id: "demo-dana", name: "Dana", emoji: "\u{1F3AE}", color: "#22c55e", status: "online", speed: 1, + route: [[13.0405, 52.9770], [13.0412, 52.9768], [13.0418, 52.9765], [13.0412, 52.9762], [13.0405, 52.9765]], + }, + { + id: "demo-eve", name: "Eve", emoji: "\u{1F52E}", color: "#8b5cf6", status: "ghost", speed: 0, + route: [[13.0435, 52.9740]], + }, +]; + +const DEMO_WAYPOINTS: { id: string; name: string; emoji: string; lat: number; lng: number }[] = [ + { id: "wp-stage", name: "Main Stage", emoji: "\u{1F3B5}", lat: 52.9760, lng: 13.0410 }, + { id: "wp-hacker", name: "Hacker Center", emoji: "\u{1F4BB}", lat: 52.9750, lng: 13.0425 }, + { id: "wp-cafe", name: "Chaos Cafe", emoji: "\u2615", lat: 52.9745, lng: 13.0388 }, + { id: "wp-parking", name: "Parking", emoji: "\u{1F17F}", lat: 52.9738, lng: 13.0440 }, +]; + +// Pre-computed route from Alice's start to Main Stage (~320m outdoor path) +const DEMO_ROUTE: [number, number][] = [ + [13.0390, 52.9758], [13.0393, 52.9759], [13.0396, 52.9760], + [13.0400, 52.9761], [13.0403, 52.9761], [13.0406, 52.9761], + [13.0408, 52.9760], [13.0410, 52.9760], +]; + class FolkMapViewer extends HTMLElement { private shadow: ShadowRoot; private space = ""; @@ -69,22 +110,10 @@ class FolkMapViewer extends HTMLElement { 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; + // Demo mode state + private _demoState: RoomState | null = null; + private _demoAnimParticipants: { id: string; route: [number, number][]; progress: number; speed: number }[] = []; + private _demoInterval: ReturnType | null = null; // MapLibre + sync state (room mode) private map: any = null; @@ -188,6 +217,7 @@ class FolkMapViewer extends HTMLElement { disconnectedCallback() { this._stopPresence?.(); + if (this._demoInterval) { clearInterval(this._demoInterval); this._demoInterval = null; } this.leaveRoom(); if (this._themeObserver) { this._themeObserver.disconnect(); @@ -395,733 +425,250 @@ class FolkMapViewer extends HTMLElement { private loadDemoData() { this.view = "map"; - this.room = "cosmolocal-providers"; + this.room = "festival-demo"; this.syncStatus = "connected"; - // Re-render demo on theme change - this._themeObserver = new MutationObserver(() => this.renderDemo()); - this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + this.participantId = "demo-alice"; + this.userName = "Alice"; + this.userEmoji = "\u{1F98A}"; + this.userColor = "#ef4444"; - 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(); + // Initialize animated participant states + this._demoAnimParticipants = DEMO_PARTICIPANTS.map(p => ({ + id: p.id, + route: p.route, + progress: Math.random() * p.route.length, // stagger start positions + speed: p.speed, + })); + + // Build initial state + this._demoState = this.buildDemoState(); + + // Render using the real map UI + this.render(); + + // Initialize real MapLibre GL map + this.initMapView().then(() => { + if (!this.map) return; + + // Override center/zoom for festival site + this.map.setCenter(DEMO_CENTER); + this.map.setZoom(16); + + const onReady = () => { + // Place markers via the real onRoomStateChange pipeline + if (this._demoState) this.onRoomStateChange(this._demoState); + // Draw demo route line + this.showDemoRoute(); + // Start animation interval + this._demoInterval = setInterval(() => this.tickDemoAnimation(), 4000); + }; + if (this.map.loaded()) onReady(); + else this.map.once("load", onReady); + }); } - 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 buildDemoState(): RoomState { + const now = new Date().toISOString(); + const participants: Record = {}; - private renderDemo() { - const W = 900; - const H = 460; - const dark = this.isDarkTheme(); + for (const dp of DEMO_PARTICIPANTS) { + const anim = this._demoAnimParticipants.find(a => a.id === dp.id); + const routeLen = dp.route.length; + const idx = anim ? Math.floor(anim.progress) % routeLen : 0; + const nextIdx = (idx + 1) % routeLen; + const frac = anim ? anim.progress - Math.floor(anim.progress) : 0; - // Theme-aware SVG colors (can't use CSS vars in SVG fill/stroke) - const oceanStop1 = dark ? "#0f1b33" : "#d4e5f7"; - const oceanStop2 = dark ? "#060d1a" : "#e8f0f8"; - const pinStroke = dark ? "#0f172a" : "#f5f5f0"; - const continentFill = dark ? "#162236" : "#c8d8c0"; - const continentStroke = dark ? "#1e3050" : "#a0b898"; - const graticuleLine = dark ? "#1a2744" : "#c0d0e0"; - const graticuleStrong = dark ? "#1e3050" : "#a8b8c8"; - const cityColor = dark ? "#64748b" : "#6b7280"; - const coordColor = dark ? "#4a5568" : "#6b7280"; + // Interpolate position + const lng = dp.route[idx][0] + (dp.route[nextIdx][0] - dp.route[idx][0]) * frac; + const lat = dp.route[idx][1] + (dp.route[nextIdx][1] - dp.route[idx][1]) * frac; - const px = (lng: number) => ((lng + 180) / 360) * W; - const py = (lat: number) => ((90 - lat) / 180) * H; + // Compute heading from movement direction + const dlng = dp.route[nextIdx][0] - dp.route[idx][0]; + const dlat = dp.route[nextIdx][1] - dp.route[idx][1]; + const heading = Math.atan2(dlng, dlat) * (180 / Math.PI); - 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"} -
- -
`; + participants[dp.id] = { + id: dp.id, + name: dp.name, + emoji: dp.emoji, + color: dp.color, + joinedAt: now, + lastSeen: now, + status: dp.status, + location: { + latitude: lat, + longitude: lng, + accuracy: 5, + heading: dp.speed > 0 ? heading : undefined, + timestamp: now, + source: "gps", + }, + }; } - this.shadow.innerHTML = ` - - -
- Cosmolocal Print Network -
- - ${Math.round(this.zoomLevel * 100)}% - - -
- ${this.providers.length} providers online -
- - - -
-
- - - - - - - - - - - - - ${this.graticule(W, H, graticuleLine, graticuleStrong)} - - - ${this.continents(W, H, continentFill, continentStroke)} - - - ${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
-
-
+ private showDemoToast(msg: string) { + // Remove existing toast + this.shadow.getElementById("demo-toast")?.remove(); + const toast = document.createElement("div"); + toast.id = "demo-toast"; + toast.style.cssText = ` + position:fixed;bottom:100px;left:50%;transform:translateX(-50%);z-index:100; + background:rgba(15,23,42,0.95);color:#e2e8f0;padding:10px 20px;border-radius:10px; + font-size:13px;font-family:system-ui,sans-serif;border:1px solid rgba(255,255,255,0.1); + backdrop-filter:blur(8px);box-shadow:0 8px 24px rgba(0,0,0,0.4); + opacity:0;transition:opacity 0.2s ease;white-space:nowrap; `; - - this.attachDemoListeners(); + toast.textContent = msg; + this.shadow.appendChild(toast); + requestAnimationFrame(() => { toast.style.opacity = "1"; }); + setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 200); }, 2800); } - 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, lineColor: string, strongColor: string): 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, fill: string, stroke: string): 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 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); + private attachDemoMapListeners() { + // Back button + this.shadow.querySelectorAll("[data-back]").forEach((el) => { + el.addEventListener("click", () => this.goBack()); }); - // 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 */ } - ); + // Functional: locate me (fly to Alice) + this.shadow.getElementById("locate-me-fab")?.addEventListener("click", () => { + if (!this.map || !this._demoState) return; + const alice = this._demoState.participants["demo-alice"]; + if (alice?.location) { + this.map.flyTo({ center: [alice.location.longitude, alice.location.latitude], zoom: 17 }); } + const btn = this.shadow.getElementById("locate-me-fab"); + if (btn) { btn.style.background = "#4285f4"; btn.style.color = "#fff"; setTimeout(() => { btn.style.background = "var(--rs-bg-surface)"; btn.style.color = "var(--rs-text-secondary)"; }, 1500); } }); - const tooltip = this.shadow.getElementById("tooltip"); - const mapWrap = this.shadow.getElementById("map-wrap"); - const mapSvg = this.shadow.getElementById("map-svg"); + // Functional: fit all participants + this.shadow.getElementById("fit-all-fab")?.addEventListener("click", () => { + if (!this.map || !this._demoState || !(window as any).maplibregl) return; + const bounds = new (window as any).maplibregl.LngLatBounds(); + for (const p of Object.values(this._demoState.participants)) { + if (p.location) bounds.extend([p.location.longitude, p.location.latitude]); + } + this.map.fitBounds(bounds, { padding: 60, maxZoom: 17 }); + }); - // 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]; + // Functional: sidebar toggle + this.shadow.getElementById("header-participants-toggle")?.addEventListener("click", () => { + const sidebar = this.shadow.querySelector(".map-sidebar") as HTMLElement; + if (sidebar && window.innerWidth > 768) { + sidebar.style.display = sidebar.style.display === "none" ? "" : "none"; + } + const sheet = this.shadow.getElementById("mobile-bottom-sheet"); + if (sheet) sheet.classList.toggle("expanded"); + }); - 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"); - } + // Sidebar tab switching (functional) + this.shadow.querySelectorAll("[data-sidebar-tab]").forEach((btn) => { + btn.addEventListener("click", () => { + this.sidebarTab = (btn as HTMLElement).dataset.sidebarTab as "participants" | "chat"; + const pList = this.shadow.getElementById("participant-list"); + const cPanel = this.shadow.getElementById("chat-panel-container"); + if (pList) pList.style.display = this.sidebarTab === "participants" ? "block" : "none"; + if (cPanel) cPanel.style.display = this.sidebarTab === "chat" ? "block" : "none"; + this.shadow.querySelectorAll("[data-sidebar-tab]").forEach(b => { + const isActive = (b as HTMLElement).dataset.sidebarTab === this.sidebarTab; + (b as HTMLElement).style.borderColor = isActive ? "#4f46e5" : "var(--rs-border)"; + (b as HTMLElement).style.background = isActive ? "#4f46e520" : "transparent"; + (b as HTMLElement).style.color = isActive ? "#818cf8" : "var(--rs-text-muted)"; }); - - 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); - } + if (this.sidebarTab === "chat") this.showDemoToast("Chat requires a live room. Sign up to try it!"); }); }); - // 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); - } - }); + // Mobile floating buttons + this.shadow.getElementById("mobile-friends-btn")?.addEventListener("click", () => { + const sheet = this.shadow.getElementById("mobile-bottom-sheet"); + if (sheet) sheet.classList.toggle("expanded"); }); - - // Close detail panel - this.shadow.getElementById("detail-close")?.addEventListener("click", () => { - this.resetZoom(); + this.shadow.getElementById("sheet-close-btn")?.addEventListener("click", () => { + const s = this.shadow.getElementById("mobile-bottom-sheet"); + if (s) s.classList.remove("expanded"); }); + const sheet = this.shadow.getElementById("mobile-bottom-sheet"); + const sheetHandle = this.shadow.getElementById("sheet-handle"); + if (sheet && sheetHandle) { + sheetHandle.addEventListener("click", () => sheet.classList.toggle("expanded")); + let startY = 0; + let sheetWasExpanded = false; + sheetHandle.addEventListener("touchstart", (e: Event) => { const te = e as TouchEvent; startY = te.touches[0].clientY; sheetWasExpanded = sheet.classList.contains("expanded"); }, { passive: true }); + sheetHandle.addEventListener("touchend", (e: Event) => { const te = e as TouchEvent; const dy = te.changedTouches[0].clientY - startY; if (sheetWasExpanded && dy > 40) sheet.classList.remove("expanded"); else if (!sheetWasExpanded && dy < -40) sheet.classList.add("expanded"); }, { passive: true }); + } - // 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 }); + // Restricted controls — show toast + const restricted = (msg: string) => () => this.showDemoToast(msg); + this.shadow.getElementById("share-location")?.addEventListener("click", restricted("Sign up to share your location")); + this.shadow.getElementById("header-share-toggle")?.addEventListener("click", restricted("Sign up to share your location")); + this.shadow.getElementById("map-share-float")?.addEventListener("click", restricted("Sign up to share your location")); + this.shadow.getElementById("drop-waypoint")?.addEventListener("click", restricted("Sign up to drop pins")); + this.shadow.getElementById("share-room-btn")?.addEventListener("click", restricted("Sign up to share rooms")); + this.shadow.getElementById("bell-toggle")?.addEventListener("click", restricted("Sign up to enable notifications")); + this.shadow.getElementById("privacy-toggle")?.addEventListener("click", restricted("Sign up to configure privacy")); + this.shadow.getElementById("indoor-toggle")?.addEventListener("click", restricted("Sign up to use indoor navigation")); + this.shadow.getElementById("emoji-picker-btn")?.addEventListener("click", restricted("Sign up to customize your avatar")); + this.shadow.getElementById("mobile-qr-btn")?.addEventListener("click", restricted("Sign up to share via QR")); } // ─── Room mode: API / health ───────────────────────────────── @@ -1574,6 +1121,15 @@ class FolkMapViewer extends HTMLElement { } el.addEventListener("click", () => { + if (this.space === "demo") { + if (isSelf) { + // Fly to self location + if (this.map && p.location) this.map.flyTo({ center: [p.location.longitude, p.location.latitude], zoom: 17 }); + } else { + this.showDemoToast("Sign up to use navigation"); + } + return; + } if (isSelf) { this.locateMe(); } else { @@ -1613,6 +1169,7 @@ class FolkMapViewer extends HTMLElement { el.textContent = wp.emoji || "\u{1F4CD}"; el.title = wp.name; el.addEventListener("click", () => { + if (this.space === "demo") { this.showDemoToast("Sign up to use navigation"); return; } this.selectedWaypoint = wp.id; this.selectedParticipant = null; this.renderNavigationPanel(); @@ -1694,6 +1251,19 @@ class FolkMapViewer extends HTMLElement { } private attachParticipantListeners(container: HTMLElement) { + if (this.space === "demo") { + // In demo mode, wire ping/nav buttons to toast instead of sync calls + container.querySelectorAll("[data-ping]").forEach((btn) => { + btn.addEventListener("click", () => this.showDemoToast("Sign up to ping friends")); + }); + container.querySelectorAll("[data-nav-participant]").forEach((btn) => { + btn.addEventListener("click", () => this.showDemoToast("Sign up to use navigation")); + }); + container.querySelector("#sidebar-ping-all-btn")?.addEventListener("click", () => this.showDemoToast("Sign up to ping friends")); + container.querySelector("#sidebar-meeting-btn")?.addEventListener("click", () => this.showDemoToast("Sign up to set meeting points")); + container.querySelector("#sidebar-import-btn")?.addEventListener("click", () => this.showDemoToast("Sign up to import places")); + return; + } container.querySelectorAll("[data-ping]").forEach((btn) => { btn.addEventListener("click", () => { const pid = (btn as HTMLElement).dataset.ping!; @@ -2824,7 +2394,11 @@ class FolkMapViewer extends HTMLElement { ${this.view === "lobby" ? this.renderLobby() : this.renderMap()} `; - this.attachListeners(); + if (this.space === "demo") { + this.attachDemoMapListeners(); + } else { + this.attachListeners(); + } this._tour.renderOverlay(); } @@ -2913,7 +2487,13 @@ class FolkMapViewer extends HTMLElement { } private renderMap(): string { + const demoBanner = this.space === "demo" ? ` +
+ Demo — simulated festival meetup. Sign up to create your own room. +
+ ` : ""; return ` + ${demoBanner}
${this._history.canGoBack ? '' : ''}