From 35dd1c3d774e3ce87e5d6379cdf0406d856ad1b0 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 9 Mar 2026 23:44:28 -0700 Subject: [PATCH] feat(rmaps): MapLibre GL map, WebSocket room sync, room history + ping friends Replace the map room placeholder with a real MapLibre GL dark map (CartoDB dark_all tiles). Port RoomSync from rmaps-online for WebSocket-based participant/waypoint sync. Add localStorage room history with thumbnail capture, participant sidebar with ping buttons, continuous GPS sharing via watchPosition, and waypoint drop. Demo mode unchanged. Co-Authored-By: Claude Opus 4.6 --- modules/rmaps/components/folk-map-viewer.ts | 584 +++++++++++++++++-- modules/rmaps/components/map-push.ts | 28 + modules/rmaps/components/map-room-history.ts | 54 ++ modules/rmaps/components/map-sync.ts | 290 +++++++++ 4 files changed, 921 insertions(+), 35 deletions(-) create mode 100644 modules/rmaps/components/map-push.ts create mode 100644 modules/rmaps/components/map-room-history.ts create mode 100644 modules/rmaps/components/map-sync.ts diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index d6575fe..47dfc04 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -9,6 +9,31 @@ * and feature highlights matching standalone rMaps capabilities. */ +import { RoomSync, type RoomState, type ParticipantState, type LocationState } from "./map-sync"; +import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history"; +import { MapPushManager } from "./map-push"; + +// MapLibre loaded via CDN — use window access with type assertion + +const MAPLIBRE_CSS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css"; +const MAPLIBRE_JS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js"; + +const DARK_STYLE = { + version: 8, + sources: { + carto: { + type: "raster", + tiles: ["https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png"], + tileSize: 256, + attribution: '© CARTO © OSM', + }, + }, + layers: [{ id: "carto", type: "raster", source: "carto" }], +}; + +const PARTICIPANT_COLORS = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316"]; +const EMOJIS = ["\u{1F9ED}", "\u{1F30D}", "\u{1F680}", "\u{1F308}", "\u{2B50}", "\u{1F525}", "\u{1F33F}", "\u{1F30A}", "\u{26A1}", "\u{1F48E}"]; + class FolkMapViewer extends HTMLElement { private shadow: ShadowRoot; private space = ""; @@ -20,7 +45,7 @@ class FolkMapViewer extends HTMLElement { 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 + // Zoom/pan state (demo mode) private vbX = 0; private vbY = 0; private vbW = 900; @@ -35,6 +60,21 @@ class FolkMapViewer extends HTMLElement { private searchQuery = ""; private userLocation: { lat: number; lng: number } | null = null; + // MapLibre + sync state (room mode) + private map: any = null; + private participantMarkers: Map = new Map(); + private waypointMarkers: Map = new Map(); + private sync: RoomSync | null = null; + private syncUrl = ""; + private participantId = ""; + private userName = ""; + private userEmoji = ""; + private userColor = ""; + private sharingLocation = false; + private watchId: number | null = null; + private pushManager: MapPushManager | null = null; + private thumbnailTimer: ReturnType | null = null; + constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); @@ -47,13 +87,54 @@ class FolkMapViewer extends HTMLElement { this.loadDemoData(); return; } + this.loadUserProfile(); + this.pushManager = new MapPushManager(this.getApiBase()); if (this.room) { - this.view = "map"; + this.joinRoom(this.room); + return; } this.checkSyncHealth(); this.render(); } + disconnectedCallback() { + this.leaveRoom(); + } + + // ─── User profile ──────────────────────────────────────────── + + private loadUserProfile() { + try { + const saved = JSON.parse(localStorage.getItem("rmaps_user") || "null"); + if (saved) { + this.participantId = saved.id; + this.userName = saved.name; + this.userEmoji = saved.emoji; + this.userColor = saved.color; + return; + } + } catch {} + this.participantId = crypto.randomUUID(); + this.userEmoji = EMOJIS[Math.floor(Math.random() * EMOJIS.length)]; + this.userColor = PARTICIPANT_COLORS[Math.floor(Math.random() * PARTICIPANT_COLORS.length)]; + } + + private ensureUserProfile(): boolean { + if (this.userName) return true; + const name = prompt("Your display name for this room:"); + if (!name?.trim()) return false; + this.userName = name.trim(); + localStorage.setItem("rmaps_user", JSON.stringify({ + id: this.participantId, + name: this.userName, + emoji: this.userEmoji, + color: this.userColor, + })); + return true; + } + + // ─── Demo mode ─────────────────────────────────────────────── + private loadDemoData() { this.view = "map"; this.room = "cosmolocal-providers"; @@ -772,6 +853,8 @@ class FolkMapViewer extends HTMLElement { }, { passive: true }); } + // ─── Room mode: API / health ───────────────────────────────── + private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rmaps/); @@ -806,10 +889,15 @@ class FolkMapViewer extends HTMLElement { this.render(); } + // ─── Room mode: join / leave / create ──────────────────────── + private joinRoom(slug: string) { + if (!this.ensureUserProfile()) return; this.room = slug; this.view = "map"; this.render(); + this.initMapView(); + this.initRoomSync(); } private createRoom() { @@ -819,6 +907,343 @@ class FolkMapViewer extends HTMLElement { this.joinRoom(slug); } + private leaveRoom() { + this.captureThumbnail(); + if (this.watchId !== null) { + navigator.geolocation.clearWatch(this.watchId); + this.watchId = null; + } + this.sharingLocation = false; + if (this.sync) { + this.sync.leave(); + this.sync = null; + } + this.participantMarkers.forEach((m) => m.remove()); + this.participantMarkers.clear(); + this.waypointMarkers.forEach((m) => m.remove()); + this.waypointMarkers.clear(); + if (this.map) { + this.map.remove(); + this.map = null; + } + if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer); + } + + // ─── MapLibre GL ───────────────────────────────────────────── + + private async loadMapLibre(): Promise { + if ((window as any).maplibregl) return; + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = MAPLIBRE_CSS; + document.head.appendChild(link); + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = MAPLIBRE_JS; + script.onload = () => resolve(); + script.onerror = reject; + document.head.appendChild(script); + }); + } + + private async initMapView() { + await this.loadMapLibre(); + const container = this.shadow.getElementById("map-container"); + if (!container || !(window as any).maplibregl) return; + + this.map = new (window as any).maplibregl.Map({ + container, + style: DARK_STYLE, + center: [0, 20], + zoom: 2, + preserveDrawingBuffer: true, + }); + + this.map.addControl(new (window as any).maplibregl.NavigationControl(), "top-right"); + this.map.addControl(new (window as any).maplibregl.GeolocateControl({ + positionOptions: { enableHighAccuracy: true }, + trackUserLocation: false, + }), "top-right"); + + // Debounced thumbnail capture on moveend + this.map.on("moveend", () => { + if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer); + this.thumbnailTimer = setTimeout(() => this.captureThumbnail(), 3000); + }); + + // Initial thumbnail capture after tiles load + this.map.on("load", () => { + setTimeout(() => this.captureThumbnail(), 2000); + }); + } + + // ─── Room sync ─────────────────────────────────────────────── + + private async initRoomSync() { + // Fetch sync URL from server + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/sync-url`, { signal: AbortSignal.timeout(3000) }); + if (res.ok) { + const data = await res.json(); + this.syncUrl = data.syncUrl || ""; + } + } catch {} + + this.sync = new RoomSync( + this.room, + this.participantId, + (state) => this.onRoomStateChange(state), + (connected) => { + this.syncStatus = connected ? "connected" : "disconnected"; + const dot = this.shadow.querySelector(".status-dot"); + if (dot) { + dot.className = `status-dot ${connected ? "status-connected" : "status-disconnected"}`; + } + }, + ); + + this.sync.connect(this.syncUrl || undefined); + + const now = new Date().toISOString(); + this.sync.join({ + id: this.participantId, + name: this.userName, + emoji: this.userEmoji, + color: this.userColor, + joinedAt: now, + lastSeen: now, + status: "online", + }); + + saveRoomVisit(this.room, this.room); + } + + // ─── State change → update markers ─────────────────────────── + + private onRoomStateChange(state: RoomState) { + if (!this.map || !(window as any).maplibregl) return; + + const currentIds = new Set(); + + // Update participant markers + for (const [id, p] of Object.entries(state.participants)) { + currentIds.add(id); + if (p.location) { + const lngLat: [number, number] = [p.location.longitude, p.location.latitude]; + if (this.participantMarkers.has(id)) { + this.participantMarkers.get(id).setLngLat(lngLat); + } else { + const el = document.createElement("div"); + el.className = "participant-marker"; + el.style.cssText = ` + width: 36px; height: 36px; border-radius: 50%; + border: 3px solid ${p.color}; background: #1a1a2e; + display: flex; align-items: center; justify-content: center; + font-size: 18px; cursor: pointer; position: relative; + box-shadow: 0 0 8px ${p.color}60; + `; + el.textContent = p.emoji; + el.title = p.name; + + // Name label below + const label = document.createElement("div"); + label.style.cssText = ` + position: absolute; bottom: -18px; left: 50%; transform: translateX(-50%); + font-size: 10px; color: ${p.color}; font-weight: 600; + white-space: nowrap; text-shadow: 0 1px 3px rgba(0,0,0,0.8); + font-family: system-ui, sans-serif; + `; + label.textContent = p.name; + el.appendChild(label); + + const marker = new (window as any).maplibregl.Marker({ element: el }) + .setLngLat(lngLat) + .addTo(this.map); + this.participantMarkers.set(id, marker); + } + } + } + + // Remove departed participants + for (const [id, marker] of this.participantMarkers) { + if (!currentIds.has(id) || !state.participants[id]?.location) { + marker.remove(); + this.participantMarkers.delete(id); + } + } + + // Update waypoint markers + const wpIds = new Set(state.waypoints.map((w) => w.id)); + for (const wp of state.waypoints) { + if (!this.waypointMarkers.has(wp.id)) { + const el = document.createElement("div"); + el.style.cssText = ` + width: 28px; height: 28px; border-radius: 50%; + background: #4f46e5; border: 2px solid #818cf8; + display: flex; align-items: center; justify-content: center; + font-size: 14px; cursor: pointer; + `; + el.textContent = wp.emoji || "\u{1F4CD}"; + el.title = wp.name; + const marker = new (window as any).maplibregl.Marker({ element: el }) + .setLngLat([wp.longitude, wp.latitude]) + .addTo(this.map); + this.waypointMarkers.set(wp.id, marker); + } + } + for (const [id, marker] of this.waypointMarkers) { + if (!wpIds.has(id)) { + marker.remove(); + this.waypointMarkers.delete(id); + } + } + + // Update participant list sidebar + this.updateParticipantList(state); + } + + private updateParticipantList(state: RoomState) { + const list = this.shadow.getElementById("participant-list"); + if (!list) return; + const entries = Object.values(state.participants); + list.innerHTML = entries.map((p) => ` +
+ ${this.esc(p.emoji)} +
+
${this.esc(p.name)}
+
${p.location ? "sharing location" : "no location"}
+
+ ${p.id !== this.participantId ? `` : ""} +
+ `).join(""); + + // Attach ping listeners + list.querySelectorAll("[data-ping]").forEach((btn) => { + btn.addEventListener("click", () => { + const pid = (btn as HTMLElement).dataset.ping!; + this.pushManager?.requestLocation(this.room, pid); + (btn as HTMLElement).textContent = "\u2713"; + setTimeout(() => { (btn as HTMLElement).textContent = "\u{1F514}"; }, 2000); + }); + }); + } + + // ─── Location sharing ──────────────────────────────────────── + + private toggleLocationSharing() { + if (this.sharingLocation) { + // Stop sharing + if (this.watchId !== null) { + navigator.geolocation.clearWatch(this.watchId); + this.watchId = null; + } + this.sharingLocation = false; + this.sync?.clearLocation(); + this.updateShareButton(); + return; + } + + if (!("geolocation" in navigator)) { + this.error = "Geolocation not supported"; + return; + } + + let firstFix = true; + this.watchId = navigator.geolocation.watchPosition( + (pos) => { + this.sharingLocation = true; + this.updateShareButton(); + const loc: LocationState = { + latitude: pos.coords.latitude, + longitude: pos.coords.longitude, + accuracy: pos.coords.accuracy, + altitude: pos.coords.altitude ?? undefined, + heading: pos.coords.heading ?? undefined, + speed: pos.coords.speed ?? undefined, + timestamp: new Date().toISOString(), + source: "gps", + }; + this.sync?.updateLocation(loc); + + if (firstFix && this.map) { + this.map.flyTo({ center: [loc.longitude, loc.latitude], zoom: 14 }); + firstFix = false; + } + }, + (err) => { + this.error = `Location error: ${err.message}`; + this.sharingLocation = false; + this.updateShareButton(); + }, + { enableHighAccuracy: true, maximumAge: 5000, timeout: 15000 }, + ); + } + + private updateShareButton() { + const btn = this.shadow.getElementById("share-location"); + if (!btn) return; + if (this.sharingLocation) { + btn.textContent = "\u{1F4CD} Stop Sharing"; + btn.classList.add("sharing"); + } else { + btn.textContent = "\u{1F4CD} Share Location"; + btn.classList.remove("sharing"); + } + } + + // ─── Waypoint drop ─────────────────────────────────────────── + + private dropWaypoint() { + if (!this.map) return; + const center = this.map.getCenter(); + const name = prompt("Waypoint name:", "Meeting point"); + if (!name?.trim()) return; + this.sync?.addWaypoint({ + id: crypto.randomUUID(), + name: name.trim(), + emoji: "\u{1F4CD}", + latitude: center.lat, + longitude: center.lng, + createdBy: this.participantId, + createdAt: new Date().toISOString(), + type: "meeting", + }); + } + + // ─── Thumbnail capture ─────────────────────────────────────── + + private captureThumbnail() { + if (!this.map || !this.room) return; + try { + const canvas = this.map.getCanvas(); + // Downscale to 200x120 + const tmp = document.createElement("canvas"); + tmp.width = 200; + tmp.height = 120; + const ctx = tmp.getContext("2d"); + if (!ctx) return; + ctx.drawImage(canvas, 0, 0, 200, 120); + const dataUrl = tmp.toDataURL("image/jpeg", 0.6); + updateRoomThumbnail(this.room, dataUrl); + } catch {} + } + + // ─── Time ago helper ───────────────────────────────────────── + + private timeAgo(iso: string): string { + const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (s < 60) return "just now"; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + return `${d}d ago`; + } + + // ─── Render (room mode) ────────────────────────────────────── + private render() { this.shadow.innerHTML = ` @@ -901,6 +1384,26 @@ class FolkMapViewer extends HTMLElement { } private renderLobby(): string { + const history = loadRoomHistory(); + const historyCards = history.length > 0 ? ` + +
+ ${history.map((h) => ` +
+ ${h.thumbnail + ? `` + : `
🌐
` + } +
+
${this.esc(h.name)}
+
${this.timeAgo(h.lastVisited)}
+
+ +
+ `).join("")} +
+ ` : ""; + return `
Map Rooms @@ -909,12 +1412,17 @@ class FolkMapViewer extends HTMLElement {
- ${this.rooms.length > 0 ? this.rooms.map((r) => ` -
- 🗺 - ${this.esc(r)} -
- `).join("") : ""} + ${this.rooms.length > 0 ? ` + + ${this.rooms.map((r) => ` +
+ 🗺 + ${this.esc(r)} +
+ `).join("")} + ` : ""} + + ${historyCards}

Create or join a map room to share locations

@@ -932,17 +1440,21 @@ class FolkMapViewer extends HTMLElement {
-
-
-

🌎

-

Map Room: ${this.esc(this.room)}

-

Connect the MapLibre GL library to display the interactive map.

-

WebSocket sync: ${this.syncStatus}

+
+
+
+
+
+ +
+
Connecting...
+
- + +
@@ -965,27 +1477,18 @@ class FolkMapViewer extends HTMLElement { this.shadow.querySelectorAll("[data-back]").forEach((el) => { el.addEventListener("click", () => { + this.leaveRoom(); this.view = "lobby"; this.loadStats(); }); }); this.shadow.getElementById("share-location")?.addEventListener("click", () => { - if ("geolocation" in navigator) { - navigator.geolocation.getCurrentPosition( - (pos) => { - const btn = this.shadow.getElementById("share-location"); - if (btn) { - btn.textContent = `Location: ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`; - btn.classList.add("sharing"); - } - }, - () => { - this.error = "Location access denied"; - this.render(); - } - ); - } + this.toggleLocationSharing(); + }); + + this.shadow.getElementById("drop-waypoint")?.addEventListener("click", () => { + this.dropWaypoint(); }); const copyUrl = this.shadow.getElementById("copy-url") || this.shadow.getElementById("copy-link"); @@ -996,6 +1499,17 @@ class FolkMapViewer extends HTMLElement { setTimeout(() => { if (copyUrl) copyUrl.textContent = "Copy"; }, 2000); }); }); + + // Ping buttons on history cards + this.shadow.querySelectorAll("[data-ping-room]").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const slug = (btn as HTMLElement).dataset.pingRoom!; + this.pushManager?.requestLocation(slug, "all"); + (btn as HTMLElement).textContent = "\u2713"; + setTimeout(() => { (btn as HTMLElement).textContent = "\u{1F514}"; }, 2000); + }); + }); } private esc(s: string): string { diff --git a/modules/rmaps/components/map-push.ts b/modules/rmaps/components/map-push.ts new file mode 100644 index 0000000..2e1a065 --- /dev/null +++ b/modules/rmaps/components/map-push.ts @@ -0,0 +1,28 @@ +/** + * Push notification helpers for "ping friends" in map rooms. + */ + +export class MapPushManager { + private apiBase: string; + + constructor(apiBase: string) { + this.apiBase = apiBase; + } + + get isSupported(): boolean { + return "Notification" in window && "serviceWorker" in navigator; + } + + async requestLocation(roomSlug: string, participantId: string): Promise { + try { + const res = await fetch(`${this.apiBase}/api/push/request-location`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roomSlug, participantId }), + }); + return res.ok; + } catch { + return false; + } + } +} diff --git a/modules/rmaps/components/map-room-history.ts b/modules/rmaps/components/map-room-history.ts new file mode 100644 index 0000000..ac41ad9 --- /dev/null +++ b/modules/rmaps/components/map-room-history.ts @@ -0,0 +1,54 @@ +/** + * Room history — localStorage-backed list of recently visited map rooms. + */ + +export interface RoomHistoryEntry { + slug: string; + name: string; + lastVisited: string; + thumbnail?: string; + center?: [number, number]; // [lng, lat] + zoom?: number; +} + +const STORAGE_KEY = "rmaps_room_history"; +const MAX_ENTRIES = 20; + +export function loadRoomHistory(): RoomHistoryEntry[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw) as RoomHistoryEntry[]; + } catch {} + return []; +} + +function save(entries: RoomHistoryEntry[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.slice(0, MAX_ENTRIES))); + } catch {} +} + +export function saveRoomVisit(slug: string, name?: string, center?: [number, number], zoom?: number): void { + const entries = loadRoomHistory().filter((e) => e.slug !== slug); + entries.unshift({ + slug, + name: name || slug, + lastVisited: new Date().toISOString(), + center, + zoom, + }); + save(entries); +} + +export function updateRoomThumbnail(slug: string, thumbnail: string): void { + const entries = loadRoomHistory(); + const entry = entries.find((e) => e.slug === slug); + if (entry) { + entry.thumbnail = thumbnail; + save(entries); + } +} + +export function removeRoomFromHistory(slug: string): void { + save(loadRoomHistory().filter((e) => e.slug !== slug)); +} diff --git a/modules/rmaps/components/map-sync.ts b/modules/rmaps/components/map-sync.ts new file mode 100644 index 0000000..7294bf9 --- /dev/null +++ b/modules/rmaps/components/map-sync.ts @@ -0,0 +1,290 @@ +/** + * WebSocket-based room sync for rMaps. + * Ported from rmaps-online/src/lib/sync.ts (simplified — no @/types dependency). + */ + +export interface RoomState { + id: string; + slug: string; + name: string; + createdAt: string; + participants: Record; + waypoints: WaypointState[]; +} + +export interface ParticipantState { + id: string; + name: string; + emoji: string; + color: string; + joinedAt: string; + lastSeen: string; + status: string; + location?: LocationState; +} + +export interface LocationState { + latitude: number; + longitude: number; + accuracy: number; + altitude?: number; + heading?: number; + speed?: number; + timestamp: string; + source: string; + indoor?: { level: number; x: number; y: number; spaceName?: string }; +} + +export interface WaypointState { + id: string; + name: string; + emoji?: string; + latitude: number; + longitude: number; + indoor?: { level: number; x: number; y: number }; + createdBy: string; + createdAt: string; + type: string; +} + +export type SyncMessage = + | { type: "join"; participant: ParticipantState } + | { type: "leave"; participantId: string } + | { type: "location"; participantId: string; location: LocationState } + | { type: "status"; participantId: string; status: string } + | { type: "waypoint_add"; waypoint: WaypointState } + | { type: "waypoint_remove"; waypointId: string } + | { type: "full_state"; state: RoomState } + | { type: "request_state" } + | { type: "request_location"; manual?: boolean; callerName?: string }; + +type SyncCallback = (state: RoomState) => void; +type ConnectionCallback = (connected: boolean) => void; +type LocationRequestCallback = (manual?: boolean, callerName?: string) => void; + +export function isValidLocation(location: LocationState | undefined): boolean { + if (!location) return false; + const { latitude, longitude } = location; + if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return false; + if (latitude === 0 && longitude === 0) return false; + if (isNaN(latitude) || isNaN(longitude)) return false; + return true; +} + +export class RoomSync { + private slug: string; + private state: RoomState; + private ws: WebSocket | null = null; + private reconnectTimer: ReturnType | null = null; + private onStateChange: SyncCallback; + private onConnectionChange: ConnectionCallback; + private onLocationRequest: LocationRequestCallback | null = null; + private participantId: string; + private currentParticipant: ParticipantState | null = null; + + constructor( + slug: string, + participantId: string, + onStateChange: SyncCallback, + onConnectionChange: ConnectionCallback, + onLocationRequest?: LocationRequestCallback, + ) { + this.slug = slug; + this.participantId = participantId; + this.onStateChange = onStateChange; + this.onConnectionChange = onConnectionChange; + this.onLocationRequest = onLocationRequest || null; + this.state = this.loadState() || this.createInitialState(); + } + + private createInitialState(): RoomState { + return { + id: crypto.randomUUID(), + slug: this.slug, + name: this.slug, + createdAt: new Date().toISOString(), + participants: {}, + waypoints: [], + }; + } + + private loadState(): RoomState | null { + try { + const stored = localStorage.getItem(`rmaps_room_${this.slug}`); + if (stored) { + const state = JSON.parse(stored) as RoomState; + return this.cleanupStaleParticipants(state); + } + } catch (e) { + console.warn("Failed to load room state:", e); + } + return null; + } + + private cleanupStaleParticipants(state: RoomState): RoomState { + const STALE_MS = 15 * 60 * 1000; + const now = Date.now(); + const cleaned: Record = {}; + for (const [id, p] of Object.entries(state.participants)) { + const isStale = now - new Date(p.lastSeen).getTime() > STALE_MS; + if (id === this.participantId || !isStale) { + if (p.location && !isValidLocation(p.location)) delete p.location; + cleaned[id] = p; + } + } + return { ...state, participants: cleaned }; + } + + private saveState(): void { + try { + localStorage.setItem(`rmaps_room_${this.slug}`, JSON.stringify(this.state)); + } catch {} + } + + private notifyStateChange(): void { + this.saveState(); + this.onStateChange({ ...this.state }); + } + + connect(syncUrl?: string): void { + if (!syncUrl) { + this.onConnectionChange(true); + return; + } + try { + this.ws = new WebSocket(`${syncUrl}/room/${this.slug}`); + this.ws.onopen = () => { + this.onConnectionChange(true); + if (this.currentParticipant) { + this.send({ type: "join", participant: this.currentParticipant }); + } + }; + this.ws.onmessage = (event) => { + try { + this.handleMessage(JSON.parse(event.data)); + } catch {} + }; + this.ws.onclose = () => { + this.onConnectionChange(false); + this.scheduleReconnect(syncUrl); + }; + this.ws.onerror = () => {}; + } catch { + this.onConnectionChange(false); + } + } + + private scheduleReconnect(syncUrl: string): void { + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.reconnectTimer = setTimeout(() => this.connect(syncUrl), 5000); + } + + private send(message: SyncMessage): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } + + private handleMessage(message: SyncMessage): void { + switch (message.type) { + case "full_state": { + const myP = this.state.participants[this.participantId]; + this.state = message.state; + if (myP) this.state.participants[this.participantId] = myP; + break; + } + case "join": + this.state.participants[message.participant.id] = message.participant; + break; + case "leave": + delete this.state.participants[message.participantId]; + break; + case "location": + if (this.state.participants[message.participantId]) { + if (message.location && isValidLocation(message.location)) { + this.state.participants[message.participantId].location = message.location; + this.state.participants[message.participantId].lastSeen = new Date().toISOString(); + } else if (message.location === null) { + delete this.state.participants[message.participantId].location; + this.state.participants[message.participantId].lastSeen = new Date().toISOString(); + } + } + break; + case "status": + if (this.state.participants[message.participantId]) { + this.state.participants[message.participantId].status = message.status; + this.state.participants[message.participantId].lastSeen = new Date().toISOString(); + } + break; + case "waypoint_add": + this.state.waypoints.push(message.waypoint); + break; + case "waypoint_remove": + this.state.waypoints = this.state.waypoints.filter((w) => w.id !== message.waypointId); + break; + case "request_location": + if (this.onLocationRequest) this.onLocationRequest(message.manual, message.callerName); + return; + } + this.notifyStateChange(); + } + + join(participant: ParticipantState): void { + this.currentParticipant = participant; + this.state.participants[participant.id] = participant; + this.send({ type: "join", participant }); + this.notifyStateChange(); + } + + leave(): void { + delete this.state.participants[this.participantId]; + this.send({ type: "leave", participantId: this.participantId }); + this.notifyStateChange(); + if (this.ws) { this.ws.close(); this.ws = null; } + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + } + + updateLocation(location: LocationState): void { + if (!isValidLocation(location)) return; + if (this.state.participants[this.participantId]) { + this.state.participants[this.participantId].location = location; + this.state.participants[this.participantId].lastSeen = new Date().toISOString(); + this.send({ type: "location", participantId: this.participantId, location }); + this.notifyStateChange(); + } + } + + clearLocation(): void { + if (this.state.participants[this.participantId]) { + delete this.state.participants[this.participantId].location; + this.state.participants[this.participantId].lastSeen = new Date().toISOString(); + this.send({ type: "location", participantId: this.participantId, location: null as any }); + this.notifyStateChange(); + } + } + + updateStatus(status: string): void { + if (this.state.participants[this.participantId]) { + this.state.participants[this.participantId].status = status; + this.state.participants[this.participantId].lastSeen = new Date().toISOString(); + this.send({ type: "status", participantId: this.participantId, status }); + this.notifyStateChange(); + } + } + + addWaypoint(waypoint: WaypointState): void { + this.state.waypoints.push(waypoint); + this.send({ type: "waypoint_add", waypoint }); + this.notifyStateChange(); + } + + removeWaypoint(waypointId: string): void { + this.state.waypoints = this.state.waypoints.filter((w) => w.id !== waypointId); + this.send({ type: "waypoint_remove", waypointId }); + this.notifyStateChange(); + } + + getState(): RoomState { + return { ...this.state }; + } +}