/** * -- real-time location sharing map. * * Creates/joins map rooms, shows participant locations on a map, * and provides location sharing controls. * * 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"; import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history"; import { MapPushManager } from "./map-push"; import { fuzzLocation, haversineDistance, formatDistance, formatTime } from "./map-privacy"; import "./map-privacy-panel"; import { TourEngine } from "../../../shared/tour-engine"; import { ViewHistory } from "../../../shared/view-history.js"; import { requireAuth } from "../../../shared/auth-fetch"; import { getUsername } from "../../../shared/components/rstack-identity"; import { MapsLocalFirstClient } from "../local-first-client"; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; // MapLibre loaded via CDN — use window access with type assertion const MAPLIBRE_CSS = "https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.css"; const MAPLIBRE_JS = "https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.js"; const OSM_ATTRIBUTION = '© OpenStreetMap contributors'; const DARK_STYLE = { version: 8, sources: { osm: { type: "raster", tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], tileSize: 256, attribution: OSM_ATTRIBUTION, maxzoom: 19, }, }, layers: [{ id: "osm", type: "raster", source: "osm" }], }; const LIGHT_STYLE = { version: 8, sources: { osm: { type: "raster", tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], tileSize: 256, attribution: OSM_ATTRIBUTION, maxzoom: 19, }, }, layers: [{ id: "osm", type: "raster", source: "osm" }], }; const PARTICIPANT_COLORS = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316"]; 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 = ""; private room = ""; private view: "lobby" | "map" = "lobby"; private rooms: string[] = []; private loading = false; private error = ""; private syncStatus: "disconnected" | "connected" = "disconnected"; // 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; 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 privacySettings: PrivacySettings = { precision: "exact", ghostMode: false }; private showPrivacyPanel = false; private geoPermissionState: PermissionState | "" = ""; private geoTimeoutCount = 0; // Modals/panels state private showShareModal = false; private showMeetingModal = false; private showImportModal = false; private showEmojiPicker = false; private stalenessTimer: ReturnType | null = null; private selectedParticipant: string | null = null; private selectedWaypoint: string | null = null; private activeRoute: { segments: any[]; totalDistance: number; estimatedTime: number; destination: string } | null = null; private thumbnailTimer: ReturnType | null = null; private _themeObserver: MutationObserver | null = null; private _history = new ViewHistory<"lobby" | "map">("lobby"); private _stopPresence: (() => void) | null = null; // Chat + Local-first state private lfClient: MapsLocalFirstClient | null = null; private sidebarTab: "participants" | "chat" = "participants"; private unreadCount = 0; // Indoor/outdoor mode private mapMode: "outdoor" | "indoor" = "outdoor"; private indoorEvent: string | null = null; private indoorView: any = null; // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ { target: '#create-room', title: "Create a Room", message: "Start a new map room to share locations with others in real time.", advanceOnClick: true }, { target: '.room-card, .history-card', title: "Join a Room", message: "Click any room card to enter and see participants on the map.", advanceOnClick: false }, { target: '#share-location', title: "Share Location", message: "Toggle location sharing so others in the room can see where you are.", advanceOnClick: false }, { target: '#drop-waypoint', title: "Drop a Pin", message: "Drop a waypoint pin on the map to mark a point of interest for everyone.", advanceOnClick: false }, ]; private isDarkTheme(): boolean { const theme = document.documentElement.getAttribute("data-theme"); if (theme) return theme === "dark"; return window.matchMedia("(prefers-color-scheme: dark)").matches; } constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); this._tour = new TourEngine( this.shadow, FolkMapViewer.TOUR_STEPS, "rmaps_tour_done", () => this.shadow.host as HTMLElement, ); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.room = this.getAttribute("room") || ""; if (this.space === "demo") { this.loadDemoData(); } else { this.loadUserProfile(); this.pushManager = new MapPushManager(this.getApiBase()); if (this.room) { this.joinRoom(this.room); } else { this.checkSyncHealth(); this.render(); } } if (!localStorage.getItem("rmaps_tour_done")) { setTimeout(() => this._tour.start(), 1200); } // When inside a folk-shape, block map interactions until editing mode const parentShape = this.closest("folk-shape"); if (parentShape) { this._parentShape = parentShape; this._onEditEnter = () => this.setMapInteractive(true); this._onEditExit = () => this.setMapInteractive(false); parentShape.addEventListener("edit-enter", this._onEditEnter); parentShape.addEventListener("edit-exit", this._onEditExit); // Start with interactions disabled this.setMapInteractive(false); } this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rmaps', context: this.room || 'Maps' })); } disconnectedCallback() { this._stopPresence?.(); if (this._demoInterval) { clearInterval(this._demoInterval); this._demoInterval = null; } this.leaveRoom(); if (this._themeObserver) { this._themeObserver.disconnect(); this._themeObserver = null; } if (this.stalenessTimer) { clearInterval(this.stalenessTimer); this.stalenessTimer = null; } if (this._parentShape) { this._parentShape.removeEventListener("edit-enter", this._onEditEnter!); this._parentShape.removeEventListener("edit-exit", this._onEditExit!); this._parentShape = null; } } private _parentShape: Element | null = null; private _onEditEnter: (() => void) | null = null; private _onEditExit: (() => void) | null = null; private _mapInteractive = true; private setMapInteractive(interactive: boolean) { this._mapInteractive = interactive; if (this.map) { if (interactive) { this.map.scrollZoom?.enable(); this.map.boxZoom?.enable(); this.map.dragRotate?.enable(); this.map.dragPan?.enable(); this.map.keyboard?.enable(); this.map.doubleClickZoom?.enable(); this.map.touchZoomRotate?.enable(); } else { this.map.scrollZoom?.disable(); this.map.boxZoom?.disable(); this.map.dragRotate?.disable(); this.map.dragPan?.disable(); this.map.keyboard?.disable(); this.map.doubleClickZoom?.disable(); this.map.touchZoomRotate?.disable(); } } // Also toggle pointer-events on the map container const container = this.shadow.getElementById("map-container"); if (container) { container.style.pointerEvents = interactive ? "" : "none"; } } // ─── 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 changeEmoji(emoji: string) { this.userEmoji = emoji; localStorage.setItem("rmaps_user", JSON.stringify({ id: this.participantId, name: this.userName, emoji: this.userEmoji, color: this.userColor, })); // Update sync state so other participants see the change if (this.sync) { const state = this.sync.getState(); const me = state.participants[this.participantId]; if (me) { me.emoji = emoji; // Re-join with updated emoji this.sync.join({ ...me, emoji }); } } // Update own marker if present const myMarker = this.participantMarkers.get(this.participantId); if (myMarker) { const el = myMarker.getElement?.(); if (el) { const emojiSpan = el.querySelector(".marker-emoji"); if (emojiSpan) emojiSpan.textContent = emoji; } } this.showEmojiPicker = false; this.updateEmojiButton(); } private ensureUserProfile(): boolean { if (this.userName) return true; // Use EncryptID username if authenticated const identityName = getUsername(); if (identityName) { this.userName = identityName; localStorage.setItem("rmaps_user", JSON.stringify({ id: this.participantId, name: this.userName, emoji: this.userEmoji, color: this.userColor, })); return true; } return false; } private pendingRoomSlug = ""; private showJoinForm(slug: string) { this.pendingRoomSlug = slug; const saved = JSON.parse(localStorage.getItem("rmaps_user") || "null"); const savedName = saved?.name || ""; const savedEmoji = saved?.emoji || this.userEmoji; const overlay = document.createElement("div"); overlay.id = "join-form-overlay"; overlay.style.cssText = ` position:fixed;inset:0;z-index:50;background:rgba(0,0,0,0.6); display:flex;align-items:center;justify-content:center; backdrop-filter:blur(4px); `; const card = document.createElement("div"); card.style.cssText = ` background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong); border-radius:16px;padding:28px;width:340px;max-width:90vw; box-shadow:0 16px 48px rgba(0,0,0,0.4);text-align:center; font-family:system-ui,-apple-system,sans-serif; `; card.innerHTML = `
${savedEmoji}
Join ${this.esc(slug)}
Choose your avatar and display name
${EMOJIS.map(e => ``).join("")}
${savedName ? `` : ""} `; overlay.appendChild(card); this.shadow.appendChild(overlay); let selectedEmoji = savedEmoji; const preview = card.querySelector("#join-avatar-preview") as HTMLElement; const nameInput = card.querySelector("#join-name-input") as HTMLInputElement; card.querySelectorAll(".join-emoji-opt").forEach(btn => { btn.addEventListener("click", () => { selectedEmoji = (btn as HTMLElement).dataset.e!; preview.textContent = selectedEmoji; card.querySelectorAll(".join-emoji-opt").forEach(b => { const isSelected = (b as HTMLElement).dataset.e === selectedEmoji; (b as HTMLElement).style.borderColor = isSelected ? "#4f46e5" : "transparent"; (b as HTMLElement).style.background = isSelected ? "#4f46e520" : "var(--rs-bg-surface-sunken)"; }); }); }); const doJoin = (name: string, emoji: string) => { if (!name.trim()) { nameInput.style.borderColor = "#ef4444"; return; } this.userName = name.trim(); this.userEmoji = emoji; localStorage.setItem("rmaps_user", JSON.stringify({ id: this.participantId, name: this.userName, emoji: this.userEmoji, color: this.userColor, })); overlay.remove(); this.joinRoom(this.pendingRoomSlug); }; card.querySelector("#join-quick-btn")?.addEventListener("click", () => doJoin(savedName, savedEmoji)); card.querySelector("#join-submit-btn")?.addEventListener("click", () => doJoin(nameInput.value, selectedEmoji)); nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") doJoin(nameInput.value, selectedEmoji); }); card.querySelector("#join-cancel-btn")?.addEventListener("click", () => overlay.remove()); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); nameInput.focus(); } // ─── Demo mode ─────────────────────────────────────────────── private loadDemoData() { this.view = "map"; this.room = "festival-demo"; this.syncStatus = "connected"; this.participantId = "demo-alice"; this.userName = "Alice"; this.userEmoji = "\u{1F98A}"; this.userColor = "#ef4444"; // 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 buildDemoState(): RoomState { const now = new Date().toISOString(); const participants: Record = {}; 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; // 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; // 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); 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", }, }; } return { id: "festival-demo", slug: "festival-demo", name: "Festival Demo", createdAt: now, participants, waypoints: DEMO_WAYPOINTS.map(w => ({ id: w.id, name: w.name, emoji: w.emoji, latitude: w.lat, longitude: w.lng, createdBy: "demo-system", createdAt: now, type: "meeting" as WaypointType, })), }; } private tickDemoAnimation() { for (const anim of this._demoAnimParticipants) { const dp = DEMO_PARTICIPANTS.find(p => p.id === anim.id); if (!dp || dp.speed === 0) continue; // Advance 25% toward next waypoint (full segment in 4 ticks = 16s) anim.progress += 0.25 * anim.speed; if (anim.progress >= dp.route.length) anim.progress -= dp.route.length; } this._demoState = this.buildDemoState(); if (this._demoState) this.onRoomStateChange(this._demoState); } private showDemoRoute() { if (!this.map) return; // Draw a route line from Alice's start to Main Stage this.map.addSource("demo-route", { type: "geojson", data: { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: DEMO_ROUTE, }, }, }); this.map.addLayer({ id: "demo-route-line", type: "line", source: "demo-route", layout: { "line-join": "round", "line-cap": "round" }, paint: { "line-color": "#4f46e5", "line-width": 4, "line-opacity": 0.7, "line-dasharray": [2, 1], }, }); } 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; `; toast.textContent = msg; this.shadow.appendChild(toast); requestAnimationFrame(() => { toast.style.opacity = "1"; }); setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 200); }, 2800); } private attachDemoMapListeners() { // Back button this.shadow.querySelectorAll("[data-back]").forEach((el) => { el.addEventListener("click", () => this.goBack()); }); // 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); } }); // 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 }); }); // 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"); }); // 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)"; }); if (this.sidebarTab === "chat") this.showDemoToast("Chat requires a live room. Sign up to try it!"); }); }); // 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"); }); 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 }); } // 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 ───────────────────────────────── 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()) { this.showJoinForm(slug); return; } this.room = slug; this.view = "map"; this.render(); this.initMapView().then(() => this.restoreLastLocation()); this.initRoomSync(); this.initLocalFirstClient(); // Periodically refresh staleness indicators this.stalenessTimer = setInterval(() => this.refreshStaleness(), 15000); // Auto-start sharing if user had it enabled previously if (localStorage.getItem(`rmaps_sharing_${slug}`) === "true") { setTimeout(() => this.toggleLocationSharing(), 500); } } private restoreLastLocation() { try { const saved = JSON.parse(localStorage.getItem(`rmaps_loc_${this.room}`) || "null"); if (saved && this.map && Date.now() - saved.ts < 30 * 60 * 1000) { this.map.flyTo({ center: [saved.lng, saved.lat], zoom: 13 }); } } catch {} } private async initLocalFirstClient() { this.lfClient = new MapsLocalFirstClient(this.space); await this.lfClient.init(); await this.lfClient.subscribe(); // Track unread chat messages this.lfClient.onChange((doc) => { const msgs = Object.values(doc.messages || {}); const lastSeen = parseInt(localStorage.getItem(`rmaps_chat_seen_${this.room}`) || "0", 10); this.unreadCount = msgs.filter(m => m.createdAt > lastSeen).length; this.updateChatBadge(); }); } private createRoom() { if (!requireAuth("create map room")) return; this.showCreateRoomForm(); } private showCreateRoomForm() { const overlay = document.createElement("div"); overlay.id = "create-room-overlay"; overlay.style.cssText = ` position:fixed;inset:0;z-index:50;background:rgba(0,0,0,0.6); display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px); `; const card = document.createElement("div"); card.style.cssText = ` background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong); border-radius:16px;padding:28px;width:340px;max-width:90vw; box-shadow:0 16px 48px rgba(0,0,0,0.4);text-align:center; font-family:system-ui,-apple-system,sans-serif; `; card.innerHTML = `
🌐
Create Room
Enter a name for your new map room
`; overlay.appendChild(card); this.shadow.appendChild(overlay); const input = card.querySelector("#room-name-input") as HTMLInputElement; const doCreate = () => { const name = input.value.trim(); if (!name) { input.style.borderColor = "#ef4444"; return; } const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, "-"); overlay.remove(); this.joinRoom(slug); }; card.querySelector("#room-create-btn")?.addEventListener("click", doCreate); input.addEventListener("keydown", (e) => { if (e.key === "Enter") doCreate(); }); card.querySelector("#room-cancel-btn")?.addEventListener("click", () => overlay.remove()); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); input.focus(); } 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.indoorView) { this.indoorView.remove(); this.indoorView = null; } this.mapMode = "outdoor"; this.indoorEvent = null; if (this.lfClient) { this.lfClient.disconnect(); this.lfClient = null; } this.sidebarTab = "participants"; this.unreadCount = 0; if (this._themeObserver) { this._themeObserver.disconnect(); this._themeObserver = null; } if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer); if (this.stalenessTimer) { clearInterval(this.stalenessTimer); this.stalenessTimer = null; } } // ─── 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: this.isDarkTheme() ? DARK_STYLE : LIGHT_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.ScaleControl(), "bottom-left"); // If inside a folk-shape and not in editing mode, disable map interactions if (this._parentShape && !this._mapInteractive) { this.setMapInteractive(false); } // Apply dark mode inversion filter to OSM tiles this.applyDarkFilter(); // Theme observer — swap map tiles on toggle this._themeObserver = new MutationObserver(() => { this.map?.setStyle(this.isDarkTheme() ? DARK_STYLE : LIGHT_STYLE); this.applyDarkFilter(); this.updateMarkerTheme(); }); this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); // 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"}`; } }, undefined, // onLocationRequest (default) (fromId, fromName, fromEmoji) => this.onRouteRequest(fromId, fromName, fromEmoji), ); 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); try { localStorage.setItem("rmaps_last_room", this.room); } catch {} // Listen for SW-forwarded location request pushes if ("serviceWorker" in navigator) { navigator.serviceWorker.addEventListener("message", (event) => { if (event.data?.type === "LOCATION_REQUEST") { const reqRoom = event.data.data?.roomSlug; if (reqRoom === this.room) { this.showPingToast(event.data.data?.fromName || "Someone"); if (this.sharingLocation) { // Already sharing — sync will propagate automatically } else if (this.privacySettings.precision !== "hidden") { // Not sharing yet — start sharing in response to ping this.toggleLocationSharing(); } } } }); } } // ─── Persist room state to SW IndexedDB for offline access ── private persistRoomState(state: RoomState) { if (!this.room || !("serviceWorker" in navigator)) return; navigator.serviceWorker.controller?.postMessage({ type: "SAVE_ROOM_STATE", roomSlug: this.room, state, }); } // ─── State change → update markers ─────────────────────────── private onRoomStateChange(state: RoomState) { // Persist to IndexedDB for offline pinging this.persistRoomState(state); if (!this.map || !(window as any).maplibregl) { console.warn("[rMaps] onRoomStateChange: map not ready", { map: !!this.map, maplibregl: !!(window as any).maplibregl }); return; } const currentIds = new Set(); if (this.space === "demo") console.log("[rMaps demo] onRoomStateChange:", Object.keys(state.participants).length, "participants,", state.waypoints.length, "waypoints"); // 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]; const ageMs = Date.now() - new Date(p.location.timestamp).getTime(); const isStale = ageMs > STALE_THRESHOLD_MS; if (this.participantMarkers.has(id)) { const marker = this.participantMarkers.get(id); marker.setLngLat(lngLat); // Update staleness + heading on existing marker const el = marker.getElement?.(); if (el) { el.style.opacity = isStale ? "0.5" : "1"; if (isStale) { el.style.borderColor = "#6b7280"; } else { el.style.borderColor = p.color; } // Update heading arrow const arrow = el.querySelector(".heading-arrow") as HTMLElement | null; if (p.location.heading !== undefined && p.location.heading !== null) { if (arrow) { arrow.style.transform = `translateX(-50%) rotate(${p.location.heading}deg)`; arrow.style.display = "block"; arrow.style.borderBottomColor = isStale ? "#6b7280" : p.color; } } else if (arrow) { arrow.style.display = "none"; } // Update tooltip with age const ageSec = Math.floor(ageMs / 1000); const ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale"; el.title = `${p.name} - ${p.status} (${ageLabel})`; } } else { const isSelf = id === this.participantId; const dark = this.isDarkTheme(); const markerBg = dark ? '#1a1a2e' : '#fafaf7'; const textShadow = dark ? 'rgba(0,0,0,0.8)' : 'rgba(0,0,0,0.3)'; const el = document.createElement("div"); el.className = "participant-marker"; if (isSelf) { // Self-marker: pulsing blue dot el.dataset.selfMarker = "true"; el.style.cssText = ` width: 20px; height: 20px; border-radius: 50%; background: #4285f4; border: 3px solid #fff; cursor: pointer; position: relative; box-shadow: 0 0 8px rgba(66,133,244,0.6); `; // Animated pulse ring const ring = document.createElement("div"); ring.style.cssText = ` position: absolute; top: 50%; left: 50%; width: 36px; height: 36px; border-radius: 50%; border: 2px solid #4285f4; opacity: 0; transform: translate(-50%, -50%); animation: selfPulse 2s ease-out infinite; pointer-events: none; `; el.appendChild(ring); } else { el.style.cssText = ` width: 36px; height: 36px; border-radius: 50%; border: 3px solid ${isStale ? "#6b7280" : p.color}; background: ${markerBg}; display: flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer; position: relative; box-shadow: 0 0 8px ${p.color}60; opacity: ${isStale ? "0.5" : "1"}; transition: opacity 0.3s; `; // Emoji span with class for later updates const emojiSpan = document.createElement("span"); emojiSpan.className = "marker-emoji"; emojiSpan.textContent = p.emoji; el.appendChild(emojiSpan); // Status dot overlay (bottom-right corner) const statusDot = document.createElement("div"); statusDot.className = "marker-status-dot"; const dotColor = isStale ? "#6b7280" : ( p.status === "online" ? "#22c55e" : p.status === "away" ? "#f59e0b" : p.status === "ghost" ? "#64748b" : "#ef4444" ); statusDot.style.cssText = ` position:absolute;bottom:-1px;right:-1px; width:10px;height:10px;border-radius:50%; background:${dotColor};border:2px solid ${markerBg}; box-shadow:0 0 6px ${dotColor}; `; el.appendChild(statusDot); } // Staleness tooltip const ageSec = Math.floor(ageMs / 1000); const ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale"; el.title = isSelf ? "You" : `${p.name} - ${p.status} (${ageLabel})`; // Heading arrow (CSS triangle) const arrow = document.createElement("div"); arrow.className = "heading-arrow"; const arrowColor = isSelf ? "#4285f4" : p.color; arrow.style.cssText = ` position: absolute; top: ${isSelf ? "-8px" : "-6px"}; left: 50%; width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-bottom: 8px solid ${arrowColor}; transform: translateX(-50%)${p.location.heading !== undefined ? ` rotate(${p.location.heading}deg)` : ""}; display: ${p.location.heading !== undefined ? "block" : "none"}; `; el.appendChild(arrow); // Name label below (skip for self) if (!isSelf) { const label = document.createElement("div"); label.className = "marker-label"; 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 ${textShadow}; font-family: system-ui, sans-serif; `; label.textContent = p.name; el.appendChild(label); } 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 { this.selectedParticipant = id; this.selectedWaypoint = null; this.renderNavigationPanel(); } }); 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; el.addEventListener("click", () => { if (this.space === "demo") { this.showDemoToast("Sign up to use navigation"); return; } this.selectedWaypoint = wp.id; this.selectedParticipant = null; this.renderNavigationPanel(); }); 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); } } // Forward to indoor view if in indoor mode if (this.mapMode === "indoor" && this.indoorView) { this.indoorView.updateParticipants(state.participants); } // Update participant counts (header + mobile) const pCount = String(Object.keys(state.participants).length); const headerCount = this.shadow.getElementById("header-participant-count"); if (headerCount) headerCount.textContent = pCount; const mobileCount = this.shadow.getElementById("mobile-friends-count"); if (mobileCount) mobileCount.textContent = pCount; // Update participant list sidebar this.updateParticipantList(state); } private buildParticipantHTML(state: RoomState): string { // Dedup by name (keep most recent) const byName = new Map(); for (const p of Object.values(state.participants)) { const existing = byName.get(p.name); if (!existing || new Date(p.lastSeen) > new Date(existing.lastSeen)) { byName.set(p.name, p); } } const entries = Array.from(byName.values()); const myLoc = state.participants[this.participantId]?.location; const statusColors: Record = { online: "#22c55e", away: "#f59e0b", ghost: "#64748b", offline: "#ef4444" }; return entries.map((p) => { let distLabel = ""; if (myLoc && p.location && p.id !== this.participantId) { distLabel = formatDistance(haversineDistance(myLoc.latitude, myLoc.longitude, p.location.latitude, p.location.longitude)); } const statusColor = statusColors[p.status] || "#64748b"; let ageLabel = ""; let isStale = false; if (p.location) { const ageMs = Date.now() - new Date(p.location.timestamp).getTime(); isStale = ageMs > STALE_THRESHOLD_MS; const ageSec = Math.floor(ageMs / 1000); ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale"; } const glowColor = isStale ? "#6b7280" : statusColor; return `
${this.esc(p.emoji)}
${this.esc(p.name)}
${p.status === "ghost" ? "ghost mode" : p.location ? (isStale ? ageLabel : "sharing") : "no location"} ${distLabel ? ` \u2022 ${distLabel}` : ""}
${p.id !== this.participantId && p.location ? `` : ""} ${p.id !== this.participantId ? `` : ""}
`; }).join(""); } 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!; this.pushManager?.requestLocation(this.room, pid); (btn as HTMLElement).textContent = "\u2713"; setTimeout(() => { (btn as HTMLElement).textContent = "\u{1F514}"; }, 2000); }); }); container.querySelectorAll("[data-nav-participant]").forEach((btn) => { btn.addEventListener("click", () => { this.selectedParticipant = (btn as HTMLElement).dataset.navParticipant!; this.selectedWaypoint = null; this.renderNavigationPanel(); }); }); container.querySelector("#sidebar-ping-all-btn")?.addEventListener("click", () => { this.pushManager?.requestLocation(this.room, "all"); const btn = container.querySelector("#sidebar-ping-all-btn"); if (btn) { btn.textContent = "\u2713 Pinged!"; setTimeout(() => { btn.textContent = "\u{1F4E2} Ping All"; }, 2000); } }); container.querySelector("#sidebar-meeting-btn")?.addEventListener("click", () => { this.showMeetingModal = true; this.renderMeetingPointModal(); }); container.querySelector("#sidebar-import-btn")?.addEventListener("click", () => { this.showImportModal = true; this.renderImportModal(); }); } private updateParticipantList(state: RoomState) { const list = this.shadow.getElementById("participant-list"); const mobileList = this.shadow.getElementById("participant-list-mobile"); const count = Object.keys(state.participants).length; const html = this.buildParticipantHTML(state); const headerHTML = `
Friends (${count})
`; const footerHTML = `
`; // Desktop sidebar if (list) { list.innerHTML = headerHTML + html + footerHTML; this.attachParticipantListeners(list); } // Mobile bottom sheet if (mobileList) { mobileList.innerHTML = html + footerHTML; this.attachParticipantListeners(mobileList); const sheetCount = this.shadow.getElementById("sheet-participant-count"); if (sheetCount) sheetCount.textContent = String(count); } } private updateMarkerTheme() { const dark = this.isDarkTheme(); const markerBg = dark ? '#1a1a2e' : '#fafaf7'; const textShadow = dark ? 'rgba(0,0,0,0.8)' : 'rgba(0,0,0,0.3)'; for (const marker of this.participantMarkers.values()) { const el = marker.getElement?.(); if (!el) continue; // Skip self-marker (always blue) if (el.dataset?.selfMarker) continue; el.style.background = markerBg; const label = el.querySelector('.marker-label') as HTMLElement | null; if (label) label.style.textShadow = `0 1px 3px ${textShadow}`; } } /** Periodically refresh staleness visuals on all participant markers */ private refreshStaleness() { const state = this.sync?.getState(); if (!state) return; for (const [id, p] of Object.entries(state.participants)) { if (!p.location) continue; const marker = this.participantMarkers.get(id); if (!marker) continue; const el = marker.getElement?.(); if (!el) continue; const ageMs = Date.now() - new Date(p.location.timestamp).getTime(); const isStale = ageMs > STALE_THRESHOLD_MS; el.style.opacity = isStale ? "0.5" : "1"; el.style.borderColor = isStale ? "#6b7280" : p.color; const ageSec = Math.floor(ageMs / 1000); const ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale"; el.title = `${p.name} - ${p.status} (${ageLabel})`; const arrow = el.querySelector(".heading-arrow") as HTMLElement | null; if (arrow) arrow.style.borderBottomColor = isStale ? "#6b7280" : p.color; // Update status dot const statusDot = el.querySelector(".marker-status-dot") as HTMLElement | null; if (statusDot) { const dotColor = isStale ? "#6b7280" : ( p.status === "online" ? "#22c55e" : p.status === "away" ? "#f59e0b" : p.status === "ghost" ? "#64748b" : "#ef4444" ); statusDot.style.background = dotColor; statusDot.style.boxShadow = `0 0 6px ${dotColor}`; } } } private updateEmojiButton() { const btn = this.shadow.getElementById("emoji-picker-btn"); if (btn) btn.textContent = this.userEmoji; const picker = this.shadow.getElementById("emoji-picker-dropdown"); if (picker) picker.style.display = this.showEmojiPicker ? "flex" : "none"; } private applyDarkFilter() { const container = this.shadow.getElementById("map-container"); if (!container) return; const canvas = container.querySelector("canvas"); if (canvas) { canvas.style.filter = this.isDarkTheme() ? "invert(1) hue-rotate(180deg)" : "none"; } else { // Canvas may not be ready yet — retry after tiles load this.map?.once("load", () => { const c = container.querySelector("canvas"); if (c) c.style.filter = this.isDarkTheme() ? "invert(1) hue-rotate(180deg)" : "none"; }); } } // ─── Location sharing ──────────────────────────────────────── private async checkGeoPermission() { try { const result = await navigator.permissions.query({ name: "geolocation" }); this.geoPermissionState = result.state; result.addEventListener("change", () => { this.geoPermissionState = result.state; }); } catch { /* permissions API not available */ } } private toggleLocationSharing() { if (this.privacySettings.precision === "hidden") return; // Hidden/ghost mode prevents sharing if (this.sharingLocation) { if (this.watchId !== null) { navigator.geolocation.clearWatch(this.watchId); this.watchId = null; } this.sharingLocation = false; this.geoTimeoutCount = 0; this.sync?.clearLocation(); try { localStorage.removeItem(`rmaps_sharing_${this.room}`); } catch {} this.updateShareButton(); return; } if (!("geolocation" in navigator)) { this.error = "Geolocation not supported"; return; } this.checkGeoPermission(); let firstFix = true; const useHighAccuracy = this.geoTimeoutCount < 2; this.watchId = navigator.geolocation.watchPosition( (pos) => { this.sharingLocation = true; this.geoTimeoutCount = 0; this.updateShareButton(); let lat = pos.coords.latitude; let lng = pos.coords.longitude; // Apply privacy fuzzing if (this.privacySettings.precision !== "exact") { const fuzzed = fuzzLocation(lat, lng, this.privacySettings.precision); lat = fuzzed.latitude; lng = fuzzed.longitude; } const loc: LocationState = { latitude: lat, longitude: lng, 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); // Persist location + auto-share preference for session restore try { localStorage.setItem(`rmaps_loc_${this.room}`, JSON.stringify({ lat: pos.coords.latitude, lng: pos.coords.longitude, ts: Date.now() })); localStorage.setItem(`rmaps_sharing_${this.room}`, "true"); } catch {} if (firstFix && this.map) { this.map.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 14 }); firstFix = false; } }, (err) => { if (err.code === err.TIMEOUT) { this.geoTimeoutCount++; if (this.geoTimeoutCount >= 2 && this.watchId !== null) { // Restart with low accuracy navigator.geolocation.clearWatch(this.watchId); this.watchId = navigator.geolocation.watchPosition( (pos) => { this.sharingLocation = true; this.updateShareButton(); let lat = pos.coords.latitude; let lng = pos.coords.longitude; if (this.privacySettings.precision !== "exact") { const fuzzed = fuzzLocation(lat, lng, this.privacySettings.precision); lat = fuzzed.latitude; lng = fuzzed.longitude; } this.sync?.updateLocation({ latitude: lat, longitude: lng, accuracy: pos.coords.accuracy, altitude: pos.coords.altitude ?? undefined, heading: pos.coords.heading ?? undefined, speed: pos.coords.speed ?? undefined, timestamp: new Date().toISOString(), source: "network", }); }, () => { this.sharingLocation = false; this.updateShareButton(); }, { enableHighAccuracy: false, maximumAge: 10000, timeout: 30000 }, ); } } else { this.error = `Location error: ${err.message}`; this.sharingLocation = false; this.updateShareButton(); } }, { enableHighAccuracy: useHighAccuracy, maximumAge: 5000, timeout: 15000 }, ); } private setPrecision(level: PrecisionLevel) { this.privacySettings.precision = level; this.privacySettings.ghostMode = level === "hidden"; if (level === "hidden") { if (this.watchId !== null) { navigator.geolocation.clearWatch(this.watchId); this.watchId = null; } this.sharingLocation = false; this.sync?.updateStatus("ghost"); this.sync?.clearLocation(); } else { if (this.privacySettings.ghostMode) { // Was ghost, now switching to a visible level this.sync?.updateStatus("online"); } this.privacySettings.ghostMode = false; } this.renderPrivacyPanel(); this.updateShareButton(); } private updateShareButton() { const btn = this.shadow.getElementById("share-location"); if (btn) { if (this.privacySettings.precision === "hidden") { btn.textContent = "\u{1F47B} Hidden"; btn.classList.remove("sharing"); btn.classList.add("ghost"); } else if (this.sharingLocation) { btn.textContent = "\u{1F4CD} Stop Sharing"; btn.classList.add("sharing"); btn.classList.remove("ghost"); } else { btn.textContent = "\u{1F4CD} Share Location"; btn.classList.remove("sharing"); btn.classList.remove("ghost"); } } // Floating share button const floatBtn = this.shadow.getElementById("map-share-float"); if (floatBtn) { if (this.privacySettings.precision === "hidden") { floatBtn.innerHTML = "👻 Hidden"; floatBtn.className = "map-share-float ghost"; } else if (this.sharingLocation) { floatBtn.innerHTML = "📍 Sharing"; floatBtn.className = "map-share-float active"; } else { floatBtn.innerHTML = "📍 Share Location"; floatBtn.className = "map-share-float"; } } // Update permission indicator const permIndicator = this.shadow.getElementById("geo-perm-indicator"); if (permIndicator) { const colors: Record = { granted: "#22c55e", prompt: "#f59e0b", denied: "#ef4444" }; permIndicator.style.background = colors[this.geoPermissionState] || "#64748b"; permIndicator.title = `Geolocation: ${this.geoPermissionState || "unknown"}`; } // Update header share toggle const headerToggle = this.shadow.getElementById("header-share-toggle"); if (headerToggle) { headerToggle.className = `map-header__share-toggle ${this.sharingLocation ? "active" : ""}`; } // Also update mobile FAB this.updateMobileFab(); } private locateMe() { if (this.sharingLocation) { // Already sharing — fly to own location const state = this.sync?.getState(); const myLoc = state?.participants[this.participantId]?.location; if (myLoc && this.map) { this.map.flyTo({ center: [myLoc.longitude, myLoc.latitude], zoom: 16 }); } } else if (!this.privacySettings.ghostMode) { // Not sharing — start sharing first this.toggleLocationSharing(); } // Pulse the locate button 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); } } private fitToParticipants() { if (!this.map || !(window as any).maplibregl) return; const state = this.sync?.getState(); if (!state) return; const bounds = new (window as any).maplibregl.LngLatBounds(); let count = 0; for (const p of Object.values(state.participants)) { if (p.location) { bounds.extend([p.location.longitude, p.location.latitude]); count++; } } if (count < 1) return; if (count === 1) { const first = Object.values(state.participants).find(p => p.location)!; this.map.flyTo({ center: [first.location!.longitude, first.location!.latitude], zoom: 14 }); } else { this.map.fitBounds(bounds, { padding: 60, maxZoom: 16 }); } } private renderPrivacyPanel(container?: HTMLElement) { const panel = container || this.shadow.getElementById("privacy-panel"); if (!panel) return; panel.innerHTML = ""; const privacyEl = document.createElement("map-privacy-panel") as any; privacyEl.settings = this.privacySettings; privacyEl.addEventListener("precision-change", (e: CustomEvent) => { this.setPrecision(e.detail as PrecisionLevel); }); panel.appendChild(privacyEl); } // ─── Waypoint drop / Meeting point modal ──────────────────── private dropWaypoint() { this.showMeetingModal = true; this.renderMeetingPointModal(); } private renderMeetingPointModal() { if (!this.showMeetingModal) { this.shadow.getElementById("meeting-modal")?.remove(); return; } // Lazy-load sub-component import("./map-meeting-modal"); const modal = document.createElement("map-meeting-modal") as any; modal.id = "meeting-modal"; const center = this.map?.getCenter(); const myLoc = this.sync?.getState().participants[this.participantId]?.location; if (center) modal.center = { lat: center.lat, lng: center.lng }; if (myLoc) modal.myLocation = { lat: myLoc.latitude, lng: myLoc.longitude }; modal.addEventListener("meeting-create", (e: CustomEvent) => { const { name, lat, lng, emoji } = e.detail; this.sync?.addWaypoint({ id: crypto.randomUUID(), name, emoji, latitude: lat, longitude: lng, createdBy: this.participantId, createdAt: new Date().toISOString(), type: "meeting", }); this.showMeetingModal = false; }); modal.addEventListener("modal-close", () => { this.showMeetingModal = false; }); this.shadow.appendChild(modal); } // ─── Share modal with QR code ─────────────────────────────── private async renderShareModal() { if (!this.showShareModal) { this.shadow.getElementById("share-modal")?.remove(); return; } import("./map-share-modal"); const modal = document.createElement("map-share-modal") as any; modal.id = "share-modal"; modal.url = `${window.location.origin}/${this.space}/rmaps/${this.room}`; modal.room = this.room; modal.addEventListener("modal-close", () => { this.showShareModal = false; }); this.shadow.appendChild(modal); } // ─── Import modal ─────────────────────────────────────────── private renderImportModal() { if (!this.showImportModal) { this.shadow.getElementById("import-modal")?.remove(); return; } import("./map-import-modal"); const modal = document.createElement("map-import-modal") as any; modal.id = "import-modal"; modal.addEventListener("import-places", (e: CustomEvent) => { for (const p of e.detail.places) { this.sync?.addWaypoint({ id: crypto.randomUUID(), name: p.name, emoji: "\u{1F4CD}", latitude: p.lat, longitude: p.lng, createdBy: this.participantId, createdAt: new Date().toISOString(), type: "poi", }); } }); modal.addEventListener("modal-close", () => { this.showImportModal = false; }); this.shadow.appendChild(modal); } // ─── Route display ────────────────────────────────────────── private showRoute(route: { segments: any[]; totalDistance: number; estimatedTime: number }, destination: string) { if (!this.map) return; this.clearRoute(); this.activeRoute = { ...route, destination }; const segmentColors: Record = { outdoor: "#3b82f6", indoor: "#8b5cf6", transition: "#f97316" }; route.segments.forEach((seg, i) => { const sourceId = `route-seg-${i}`; const outlineLayerId = `route-layer-outline-${i}`; const layerId = `route-layer-${i}`; this.map.addSource(sourceId, { type: "geojson", data: { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: seg.coordinates }, }, }); // Outline layer (dark stroke behind colored line) this.map.addLayer({ id: outlineLayerId, type: "line", source: sourceId, layout: { "line-join": "round", "line-cap": "round" }, paint: { "line-color": "#1e293b", "line-width": 8, "line-opacity": 0.6 }, }); this.map.addLayer({ id: layerId, type: "line", source: sourceId, layout: { "line-join": "round", "line-cap": "round" }, paint: { "line-color": segmentColors[seg.type] || "#3b82f6", "line-width": 5, "line-opacity": 0.8, ...(seg.type === "transition" ? { "line-dasharray": [2, 2] } : {}), }, }); }); this.fitMapToRoute(route); this.renderRoutePanel(); } private clearRoute() { if (!this.map) return; // Remove all route layers/sources (including outline layers) for (let i = 0; i < 10; i++) { try { this.map.removeLayer(`route-layer-${i}`); } catch {} try { this.map.removeLayer(`route-layer-outline-${i}`); } catch {} try { this.map.removeSource(`route-seg-${i}`); } catch {} } this.activeRoute = null; const routePanel = this.shadow.getElementById("route-panel"); if (routePanel) routePanel.remove(); } private fitMapToRoute(route: { segments: any[] }) { if (!this.map || !(window as any).maplibregl) return; const bounds = new (window as any).maplibregl.LngLatBounds(); for (const seg of route.segments) { for (const coord of seg.coordinates) { bounds.extend(coord); } } if (!bounds.isEmpty()) { this.map.fitBounds(bounds, { padding: 60, maxZoom: 16 }); } } private formatRouteInstructions(segments: any[]): string { const parts: string[] = []; for (const seg of segments) { const label = seg.type === "indoor" ? "indoors" : seg.type === "transition" ? "transition" : "outdoors"; parts.push(`${formatDistance(seg.distance)} ${label}`); } if (parts.length <= 1) return parts[0] ? `Walk ${parts[0]}` : ""; return `Walk ${parts.join(", then ")}`; } private renderRoutePanel() { if (!this.activeRoute) return; let routePanel = this.shadow.getElementById("route-panel"); if (!routePanel) { routePanel = document.createElement("div"); routePanel.id = "route-panel"; routePanel.style.cssText = ` position:absolute;bottom:12px;left:12px;right:12px;z-index:5; background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong); border-radius:10px;padding:14px;box-shadow:0 4px 16px rgba(0,0,0,0.3); `; this.shadow.getElementById("map-container")?.appendChild(routePanel); } const segTypeLabels: Record = { outdoor: "Outdoor", indoor: "Indoor", transition: "Transition" }; const segTypeColors: Record = { outdoor: "#3b82f6", indoor: "#8b5cf6", transition: "#f97316" }; routePanel.innerHTML = `
Route to ${this.esc(this.activeRoute.destination)}
\u{1F4CF} ${formatDistance(this.activeRoute.totalDistance)} \u{23F1} ${formatTime(this.activeRoute.estimatedTime)}
${this.formatRouteInstructions(this.activeRoute.segments)}
${this.activeRoute.segments.map(seg => ` ${segTypeLabels[seg.type] || seg.type}: ${formatDistance(seg.distance)} `).join("")}
`; routePanel.querySelector("#close-route")?.addEventListener("click", () => this.clearRoute()); } // ─── Route request handler ────────────────────────────────── private onRouteRequest(fromId: string, fromName: string, fromEmoji: string) { // Show toast notification const state = this.sync?.getState(); const requester = state?.participants[fromId]; if (!requester?.location) return; const toast = document.createElement("div"); toast.style.cssText = ` position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:100; background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong); border-radius:12px;padding:12px 16px;box-shadow:0 8px 24px rgba(0,0,0,0.4); display:flex;align-items:center;gap:10px;max-width:340px; animation:toastIn 0.3s ease; `; toast.innerHTML = ` ${fromEmoji}
${this.esc(fromName)} wants you to navigate to them
`; const dismiss = () => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 200); }; toast.querySelector("#toast-dismiss")?.addEventListener("click", dismiss); toast.querySelector("#toast-nav")?.addEventListener("click", () => { dismiss(); if (requester.location) { this.requestRoute(requester.location.latitude, requester.location.longitude, fromName); } }); this.shadow.appendChild(toast); setTimeout(dismiss, 10000); } private showPingToast(fromName: string) { // Vibrate if available if ("vibrate" in navigator) navigator.vibrate([200, 100, 200]); const toast = document.createElement("div"); toast.style.cssText = ` position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:100; background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong); border-radius:12px;padding:10px 16px;box-shadow:0 8px 24px rgba(0,0,0,0.4); display:flex;align-items:center;gap:8px;max-width:300px; `; toast.innerHTML = `\u{1F4E2}${this.esc(fromName)} pinged for your location`; this.shadow.appendChild(toast); setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 200); }, 4000); } // ─── Chat badge ───────────────────────────────────────────── private updateChatBadge() { const badge = this.shadow.getElementById("chat-badge"); if (badge) { badge.textContent = this.unreadCount > 0 ? String(this.unreadCount) : ""; badge.style.display = this.unreadCount > 0 ? "flex" : "none"; } const mobileBadge = this.shadow.getElementById("fab-chat-badge"); if (mobileBadge) { mobileBadge.textContent = this.unreadCount > 0 ? String(this.unreadCount) : ""; mobileBadge.style.display = this.unreadCount > 0 ? "flex" : "none"; } } private async mountChatPanel(container: HTMLElement) { await import("./map-chat-panel"); const panel = document.createElement("map-chat-panel") as any; panel.client = this.lfClient; panel.participantId = this.participantId; panel.participantName = this.userName; panel.participantEmoji = this.userEmoji; panel.setAttribute("room", this.room); panel.style.cssText = "display:block;height:100%;"; container.innerHTML = ""; container.appendChild(panel); } // ─── Indoor/outdoor mode ──────────────────────────────────── private async switchToIndoor(event: string) { this.mapMode = "indoor"; this.indoorEvent = event; // Hide outdoor map const mapContainer = this.shadow.getElementById("map-container"); if (mapContainer) mapContainer.style.display = "none"; // Create indoor view container await import("./map-indoor-view"); const indoorContainer = document.createElement("div"); indoorContainer.id = "indoor-container"; indoorContainer.style.cssText = "width:100%;height:100%;position:absolute;inset:0;"; const mapMain = this.shadow.querySelector(".map-main"); if (mapMain) { (mapMain as HTMLElement).style.position = "relative"; mapMain.appendChild(indoorContainer); } const indoorView = document.createElement("map-indoor-view") as any; indoorView.cfg = { event, apiBase: this.getApiBase() }; indoorView.addEventListener("switch-outdoor", () => this.switchToOutdoor()); indoorContainer.appendChild(indoorView); this.indoorView = indoorView; // Forward current participants const state = this.sync?.getState(); if (state) indoorView.updateParticipants(state.participants); // Update toggle button const toggleBtn = this.shadow.getElementById("indoor-toggle"); if (toggleBtn) { toggleBtn.textContent = "🌍 Outdoor"; toggleBtn.title = "Switch to outdoor map"; } } private switchToOutdoor() { this.mapMode = "outdoor"; this.indoorEvent = null; // Remove indoor view const indoorContainer = this.shadow.getElementById("indoor-container"); if (indoorContainer) indoorContainer.remove(); if (this.indoorView) { this.indoorView = null; } // Show outdoor map const mapContainer = this.shadow.getElementById("map-container"); if (mapContainer) mapContainer.style.display = ""; // Update toggle button const toggleBtn = this.shadow.getElementById("indoor-toggle"); if (toggleBtn) { toggleBtn.textContent = "🏢 Indoor"; toggleBtn.title = "Switch to indoor map"; } } // ─── Navigation panel (participant/waypoint selection) ─────── private async requestRoute(targetLat: number, targetLng: number, targetName: string) { // Get user's current location const myState = this.sync?.getState().participants[this.participantId]; if (!myState?.location) { this.error = "Share your location first to get directions"; return; } const base = this.getApiBase(); try { const res = await fetch(`${base}/api/routing`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from: { lat: myState.location.latitude, lng: myState.location.longitude }, to: { lat: targetLat, lng: targetLng }, mode: "walking", }), signal: AbortSignal.timeout(12000), }); if (res.ok) { const data = await res.json(); if (data.success && data.route) { this.showRoute(data.route, targetName); } else { this.error = "No route found"; } } } catch { this.error = "Routing request failed"; } } private renderNavigationPanel() { let navPanel = this.shadow.getElementById("nav-panel"); // Get target details const state = this.sync?.getState(); let targetName = ""; let targetLat = 0; let targetLng = 0; let targetEmoji = ""; let targetDetail = ""; if (this.selectedParticipant && state) { const p = state.participants[this.selectedParticipant]; if (p?.location) { targetName = p.name; targetEmoji = p.emoji; targetLat = p.location.latitude; targetLng = p.location.longitude; const myLoc = state.participants[this.participantId]?.location; if (myLoc) { targetDetail = formatDistance(haversineDistance(myLoc.latitude, myLoc.longitude, targetLat, targetLng)) + " away"; } } } else if (this.selectedWaypoint && state) { const wp = state.waypoints.find(w => w.id === this.selectedWaypoint); if (wp) { targetName = wp.name; targetEmoji = wp.emoji || "\u{1F4CD}"; targetLat = wp.latitude; targetLng = wp.longitude; const myLoc = state.participants[this.participantId]?.location; if (myLoc) { targetDetail = formatDistance(haversineDistance(myLoc.latitude, myLoc.longitude, targetLat, targetLng)) + " away"; } } } if (!targetName) { if (navPanel) navPanel.remove(); return; } if (!navPanel) { navPanel = document.createElement("div"); navPanel.id = "nav-panel"; navPanel.style.cssText = ` position:absolute;top:12px;left:12px;z-index:5; background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong); border-radius:10px;padding:12px;box-shadow:0 4px 16px rgba(0,0,0,0.3); min-width:180px; `; this.shadow.getElementById("map-container")?.appendChild(navPanel); } const isParticipant = !!this.selectedParticipant; navPanel.innerHTML = `
${targetEmoji}
${this.esc(targetName)}
${targetDetail ? `
${targetDetail}
` : ""}
${isParticipant ? `` : ""}
`; navPanel.querySelector("#close-nav")?.addEventListener("click", () => { this.selectedParticipant = null; this.selectedWaypoint = null; navPanel?.remove(); }); navPanel.querySelector("#navigate-btn")?.addEventListener("click", () => { this.requestRoute(targetLat, targetLng, targetName); }); navPanel.querySelector("#route-request-btn")?.addEventListener("click", () => { this.sync?.sendRouteRequest(this.participantId, this.userName, this.userEmoji); const btn = navPanel?.querySelector("#route-request-btn") as HTMLElement; if (btn) { btn.textContent = "✓ Sent"; btn.style.color = "#22c55e"; btn.style.borderColor = "#22c55e"; } }); } // ─── 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()} `; if (this.space === "demo") { this.attachDemoMapListeners(); } else { this.attachListeners(); } this._tour.renderOverlay(); } startTour() { this._tour.start(); } private renderLobby(): string { const saved = JSON.parse(localStorage.getItem("rmaps_user") || "null"); const lastRoom = localStorage.getItem("rmaps_last_room") || ""; const profileSection = `
Get Started
${this.userEmoji}
${lastRoom ? ` ` : ""}
Profile is saved locally for quick rejoins
`; 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"}
${profileSection} ${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 demoBanner = this.space === "demo" ? `
Demo — simulated festival meetup. Sign up to create your own room.
` : ""; return ` ${demoBanner}
${this._history.canGoBack ? '' : ''} /${this.esc(this.room)}
Connecting...
Friends (0)
`; } private attachListeners() { this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom()); // Lobby profile section const lobbyNameInput = this.shadow.getElementById("lobby-name-input") as HTMLInputElement; if (lobbyNameInput) { lobbyNameInput.addEventListener("change", () => { const name = lobbyNameInput.value.trim(); if (name) { this.userName = name; localStorage.setItem("rmaps_user", JSON.stringify({ id: this.participantId, name: this.userName, emoji: this.userEmoji, color: this.userColor, })); } }); } const lobbyAvatar = this.shadow.getElementById("lobby-avatar-preview"); const lobbyEmojiGrid = this.shadow.getElementById("lobby-emoji-grid"); if (lobbyAvatar && lobbyEmojiGrid) { lobbyAvatar.addEventListener("click", () => { lobbyEmojiGrid.style.display = lobbyEmojiGrid.style.display === "none" ? "flex" : "none"; }); } this.shadow.querySelectorAll("[data-lobby-emoji]").forEach(btn => { btn.addEventListener("click", () => { const emoji = (btn as HTMLElement).dataset.lobbyEmoji!; this.userEmoji = emoji; if (lobbyAvatar) lobbyAvatar.textContent = emoji; this.shadow.querySelectorAll("[data-lobby-emoji]").forEach(b => { const sel = (b as HTMLElement).dataset.lobbyEmoji === emoji; (b as HTMLElement).style.borderColor = sel ? "#4f46e5" : "transparent"; (b as HTMLElement).style.background = sel ? "#4f46e520" : "var(--rs-bg-surface-sunken)"; }); localStorage.setItem("rmaps_user", JSON.stringify({ id: this.participantId, name: this.userName, emoji: this.userEmoji, color: this.userColor, })); }); }); this.shadow.getElementById("lobby-rejoin-btn")?.addEventListener("click", () => { const lastRoom = localStorage.getItem("rmaps_last_room"); if (lastRoom) { // Save name from input if provided if (lobbyNameInput?.value.trim()) { this.userName = lobbyNameInput.value.trim(); localStorage.setItem("rmaps_user", JSON.stringify({ id: this.participantId, name: this.userName, emoji: this.userEmoji, color: this.userColor, })); } this._history.push("lobby"); this._history.push("map", { room: lastRoom }); this.joinRoom(lastRoom); } }); this.shadow.querySelectorAll("[data-room]").forEach((el) => { el.addEventListener("click", () => { const room = (el as HTMLElement).dataset.room!; this._history.push("lobby"); this._history.push("map", { room }); this.joinRoom(room); }); }); this.shadow.querySelectorAll("[data-back]").forEach((el) => { el.addEventListener("click", () => { this.goBack(); }); }); this.shadow.getElementById("share-location")?.addEventListener("click", () => { this.toggleLocationSharing(); }); // Header share toggle this.shadow.getElementById("header-share-toggle")?.addEventListener("click", () => { this.toggleLocationSharing(); }); // Header participants toggle this.shadow.getElementById("header-participants-toggle")?.addEventListener("click", () => { // Desktop: toggle sidebar visibility, Mobile: toggle bottom sheet 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"); }); this.shadow.getElementById("drop-waypoint")?.addEventListener("click", () => { this.dropWaypoint(); }); this.shadow.getElementById("share-room-btn")?.addEventListener("click", () => { this.showShareModal = true; this.renderShareModal(); }); this.shadow.getElementById("privacy-toggle")?.addEventListener("click", () => { this.showPrivacyPanel = !this.showPrivacyPanel; const panel = this.shadow.getElementById("privacy-panel"); if (panel) { panel.style.display = this.showPrivacyPanel ? "block" : "none"; if (this.showPrivacyPanel) this.renderPrivacyPanel(); } }); // Emoji picker this.shadow.getElementById("emoji-picker-btn")?.addEventListener("click", () => { this.showEmojiPicker = !this.showEmojiPicker; this.updateEmojiButton(); }); this.shadow.querySelectorAll("[data-emoji-pick]").forEach((btn) => { btn.addEventListener("click", () => { this.changeEmoji((btn as HTMLElement).dataset.emojiPick!); }); }); // Indoor toggle this.shadow.getElementById("indoor-toggle")?.addEventListener("click", () => { if (this.mapMode === "outdoor") { const event = prompt("c3nav event code (e.g. 39c3):", "39c3"); if (event?.trim()) this.switchToIndoor(event.trim()); } else { this.switchToOutdoor(); } }); // Sidebar tab switching 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"; // Update tab button styles 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)"; }); // Mount chat panel if switching to chat if (this.sidebarTab === "chat" && cPanel && !cPanel.querySelector("map-chat-panel")) { this.mountChatPanel(cPanel); } // Clear unread when viewing chat if (this.sidebarTab === "chat") { this.unreadCount = 0; this.updateChatBadge(); } }); }); this.shadow.getElementById("bell-toggle")?.addEventListener("click", () => { this.pushManager?.toggle(this.room, this.participantId).then(subscribed => { const bell = this.shadow.getElementById("bell-toggle"); if (bell) bell.textContent = subscribed ? "\u{1F514}" : "\u{1F515}"; }); }); // Locate-me FAB this.shadow.getElementById("locate-me-fab")?.addEventListener("click", () => { this.locateMe(); }); // Fit-all FAB this.shadow.getElementById("fit-all-fab")?.addEventListener("click", () => { this.fitToParticipants(); }); // 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"); }); this.shadow.getElementById("mobile-qr-btn")?.addEventListener("click", () => { this.showShareModal = true; this.renderShareModal(); }); // Floating share-location button on map this.shadow.getElementById("map-share-float")?.addEventListener("click", () => { this.toggleLocationSharing(); }); // Sheet close button this.shadow.getElementById("sheet-close-btn")?.addEventListener("click", () => { const s = this.shadow.getElementById("mobile-bottom-sheet"); if (s) s.classList.remove("expanded"); }); // Mobile bottom sheet 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"); }); // Touch drag on handle 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 deltaY = te.changedTouches[0].clientY - startY; if (sheetWasExpanded && deltaY > 40) { sheet.classList.remove("expanded"); } else if (!sheetWasExpanded && deltaY < -40) { sheet.classList.add("expanded"); } }, { passive: true }); } // 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 updateMobileFab() { // No-op — legacy FAB removed; share state updated via updateShareButton() } private goBack() { const prev = this._history.back(); if (!prev) return; if (prev.view === "lobby" && this.view === "map") { this.leaveRoom(); } this.view = prev.view; if (prev.view === "lobby") this.loadStats(); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-map-viewer", FolkMapViewer);