import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; import type { RoomState, ParticipantState, LocationState, PrivacySettings, PrecisionLevel, WaypointState, } from "../modules/rmaps/components/map-sync"; import { RoomSync } from "../modules/rmaps/components/map-sync"; import { fuzzLocation, haversineDistance, formatDistance } from "../modules/rmaps/components/map-privacy"; 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"; // Default tile provider (OpenStreetMap) const DEFAULT_STYLE = { version: 8, sources: { osm: { type: "raster", tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], tileSize: 256, attribution: '© OpenStreetMap', }, }, layers: [ { id: "osm", type: "raster", source: "osm", }, ], }; const EMOJI_OPTIONS = ["๐Ÿ˜Ž", "๐Ÿง‘", "๐Ÿ‘ฉ", "๐Ÿง”", "๐Ÿ‘จ", "๐Ÿฑ", "๐Ÿถ", "๐ŸฆŠ", "๐Ÿป", "๐Ÿธ", "๐ŸŒŸ", "๐Ÿ”ฅ"]; const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes const styles = css` :host { background: var(--rs-bg-surface, #fff); color: var(--rs-text-primary, #1e293b); border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); min-width: 400px; min-height: 300px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #22c55e; color: white; border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions { display: flex; gap: 4px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-left: 4px; } .status-dot.connected { background: #86efac; } .status-dot.disconnected { background: #fbbf24; } .map-container { width: 100%; height: calc(100% - 36px); border-radius: 0 0 8px 8px; overflow: hidden; position: relative; } :host([data-has-collab]) .map-container { height: calc(100% - 36px - 36px); border-radius: 0; } .map { width: 100%; height: 100%; } .loading { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #f1f5f9; color: #64748b; font-size: 14px; } .loading.hidden { display: none; } .search-box { position: absolute; top: 10px; left: 10px; z-index: 10; display: flex; gap: 4px; } .search-input { padding: 8px 12px; border: none; border-radius: 4px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); font-size: 13px; width: 200px; outline: none; } .search-btn { padding: 8px 12px; background: #22c55e; color: white; border: none; border-radius: 4px; cursor: pointer; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); } .search-btn:hover { background: #16a34a; } .locate-btn { position: absolute; bottom: 80px; right: 10px; z-index: 10; width: 32px; height: 32px; background: white; border: none; border-radius: 4px; cursor: pointer; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); font-size: 16px; } .locate-btn:hover { background: #f1f5f9; } /* Override MapLibre default styles */ .maplibregl-ctrl-attrib { font-size: 10px !important; } /* โ”€โ”€ Collab toolbar โ”€โ”€ */ .collab-toolbar { display: flex; align-items: center; gap: 4px; padding: 4px 8px; background: #f8fafc; border-top: 1px solid #e2e8f0; border-radius: 0 0 8px 8px; font-size: 12px; position: relative; } .collab-btn { display: flex; align-items: center; gap: 3px; padding: 4px 8px; background: white; border: 1px solid #e2e8f0; border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap; } .collab-btn:hover { background: #f1f5f9; } .collab-btn.sharing { background: #dcfce7; border-color: #22c55e; animation: pulse-green 2s ease-in-out infinite; } @keyframes pulse-green { 0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.3); } 50% { box-shadow: 0 0 0 4px rgba(34, 197, 94, 0); } } .collab-spacer { flex: 1; } /* โ”€โ”€ Emoji picker โ”€โ”€ */ .emoji-picker { position: absolute; bottom: 40px; left: 8px; background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 20; display: none; } .emoji-picker.open { display: block; } .emoji-picker-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; } .emoji-opt { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border: 1px solid transparent; border-radius: 4px; cursor: pointer; font-size: 18px; background: none; padding: 0; } .emoji-opt:hover { background: #f1f5f9; border-color: #e2e8f0; } .emoji-opt.selected { border-color: #22c55e; background: #dcfce7; } /* โ”€โ”€ Participant panel โ”€โ”€ */ .participant-panel { position: absolute; top: 0; right: 0; width: 200px; height: 100%; background: white; border-left: 1px solid #e2e8f0; z-index: 15; overflow-y: auto; display: none; flex-direction: column; } .participant-panel.open { display: flex; } .participant-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 600; color: #475569; } .participant-panel-close { background: none; border: none; cursor: pointer; font-size: 14px; color: #94a3b8; padding: 0 2px; } .participant-entry { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid #f1f5f9; font-size: 11px; } .participant-emoji { font-size: 18px; flex-shrink: 0; } .participant-info { flex: 1; min-width: 0; } .participant-name { font-weight: 500; color: #1e293b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .participant-meta { color: #94a3b8; font-size: 10px; } .participant-status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } .participant-status-dot.online { background: #22c55e; } .participant-status-dot.away { background: #fbbf24; } .participant-status-dot.ghost { background: #94a3b8; } .participant-status-dot.offline { background: #e2e8f0; } /* โ”€โ”€ Waypoint input โ”€โ”€ */ .waypoint-input-wrap { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 20; background: white; border-radius: 8px; padding: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); display: none; } .waypoint-input-wrap.open { display: flex; gap: 6px; } .waypoint-input { padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 12px; outline: none; width: 140px; } .waypoint-input:focus { border-color: #22c55e; } .waypoint-submit { padding: 6px 10px; background: #22c55e; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; } .waypoint-cancel { padding: 6px 10px; background: #f1f5f9; color: #64748b; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; } `; export interface MapMarker { id: string; lng: number; lat: number; color?: string; label?: string; } // MapLibre types (loaded dynamically from CDN) interface MapLibreMap { flyTo(options: { center: [number, number] }): void; setZoom(zoom: number): void; getCenter(): { lng: number; lat: number }; getZoom(): number; addControl(control: unknown, position?: string): void; on(event: string, handler: (e: MapLibreEvent) => void): void; } interface MapLibreEvent { lngLat: { lng: number; lat: number }; } interface MapLibreMarker { setLngLat(coords: [number, number]): this; addTo(map: MapLibreMap): this; setPopup(popup: unknown): this; remove(): void; getElement(): HTMLElement; } interface MapLibreGL { Map: new (options: { container: HTMLElement; style: object; center: [number, number]; zoom: number; }) => MapLibreMap; NavigationControl: new () => unknown; Marker: new (options?: { element?: HTMLElement }) => MapLibreMarker; Popup: new (options?: { offset?: number }) => { setText(text: string): unknown; setHTML(html: string): unknown }; } declare global { interface HTMLElementTagNameMap { "folk-map": FolkMap; } interface Window { maplibregl: MapLibreGL; } } export class FolkMap extends FolkShape { static override tagName = "folk-map"; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules) .map((r) => r.cssText) .join("\n"); const childRules = Array.from(styles.cssRules) .map((r) => r.cssText) .join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } // โ”€โ”€ Existing solo-map state โ”€โ”€ #map: MapLibreMap | null = null; #markers: MapMarker[] = []; #mapMarkerInstances = new Map(); #center: [number, number] = [-74.006, 40.7128]; // NYC default #zoom = 12; #mapEl: HTMLElement | null = null; #loadingEl: HTMLElement | null = null; // โ”€โ”€ Collab state โ”€โ”€ #roomSlug = ""; #sync: RoomSync | null = null; #syncUrl = ""; #participantId = ""; #userName = ""; #userEmoji = "๐Ÿ˜Ž"; #userColor = "#22c55e"; #sharingLocation = false; #watchId: number | null = null; #privacySettings: PrivacySettings = { precision: "exact" as PrecisionLevel, ghostMode: false }; #participantMarkers = new Map(); #waypointMarkers = new Map(); #syncConnected = false; #stalenessTimer: ReturnType | null = null; #showParticipants = false; #showEmojiPicker = false; // โ”€โ”€ DOM refs for collab UI โ”€โ”€ #collabToolbar: HTMLElement | null = null; #emojiPickerEl: HTMLElement | null = null; #participantPanel: HTMLElement | null = null; #participantList: HTMLElement | null = null; #shareBtn: HTMLButtonElement | null = null; #countBadge: HTMLElement | null = null; #statusDot: HTMLElement | null = null; #headerLabel: HTMLElement | null = null; #waypointInputWrap: HTMLElement | null = null; #emojiBtnEl: HTMLButtonElement | null = null; get center(): [number, number] { return this.#center; } set center(value: [number, number]) { this.#center = value; this.#map?.flyTo({ center: value }); } get zoom(): number { return this.#zoom; } set zoom(value: number) { this.#zoom = value; this.#map?.setZoom(value); } get markers(): MapMarker[] { return this.#markers; } get roomSlug(): string { return this.#roomSlug; } set roomSlug(value: string) { const slug = (value || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "-"); if (slug === this.#roomSlug) return; if (this.#roomSlug) this.#leaveRoom(); this.#roomSlug = slug; if (slug) { this.setAttribute("data-has-collab", ""); if (this.#collabToolbar) this.#collabToolbar.style.display = "flex"; this.#initRoomSync(); } else { this.removeAttribute("data-has-collab"); if (this.#collabToolbar) this.#collabToolbar.style.display = "none"; } if (this.#headerLabel) { this.#headerLabel.textContent = slug ? `Map โ€” ${slug}` : "Map"; } } addMarker(marker: MapMarker) { this.#markers.push(marker); this.#renderMarker(marker); this.dispatchEvent(new CustomEvent("marker-add", { detail: { marker } })); } removeMarker(id: string) { const instance = this.#mapMarkerInstances.get(id); if (instance) { instance.remove(); this.#mapMarkerInstances.delete(id); } this.#markers = this.#markers.filter((m) => m.id !== id); } override createRenderRoot() { const root = super.createRenderRoot(); // Parse initial attributes const centerAttr = this.getAttribute("center"); if (centerAttr) { const [lng, lat] = centerAttr.split(",").map(Number); if (!isNaN(lng) && !isNaN(lat)) { this.#center = [lng, lat]; } } const zoomAttr = this.getAttribute("zoom"); if (zoomAttr && !isNaN(Number(zoomAttr))) { this.#zoom = Number(zoomAttr); } const wrapper = document.createElement("div"); wrapper.innerHTML = html`
๐Ÿ—บ Map
Loading map...
Participants
${EMOJI_OPTIONS.map((e) => ``).join("")}
`; // Replace the container div (slot's parent) with our wrapper const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) { containerDiv.replaceWith(wrapper); } // โ”€โ”€ Grab DOM refs โ”€โ”€ this.#mapEl = wrapper.querySelector(".map"); this.#loadingEl = wrapper.querySelector(".loading"); this.#headerLabel = wrapper.querySelector(".header-label"); this.#statusDot = wrapper.querySelector(".status-dot"); this.#collabToolbar = wrapper.querySelector(".collab-toolbar"); this.#emojiPickerEl = wrapper.querySelector(".emoji-picker"); this.#participantPanel = wrapper.querySelector(".participant-panel"); this.#participantList = wrapper.querySelector(".participant-list"); this.#waypointInputWrap = wrapper.querySelector(".waypoint-input-wrap"); const searchInput = wrapper.querySelector(".search-input") as HTMLInputElement; const searchBtn = wrapper.querySelector(".search-btn") as HTMLButtonElement; const locateBtn = wrapper.querySelector(".locate-btn") as HTMLButtonElement; const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; this.#shareBtn = wrapper.querySelector(".share-btn") as HTMLButtonElement; this.#emojiBtnEl = wrapper.querySelector(".emoji-btn") as HTMLButtonElement; this.#countBadge = wrapper.querySelector(".count-badge") as HTMLElement; const pinBtn = wrapper.querySelector(".pin-btn") as HTMLButtonElement; const countBtn = wrapper.querySelector(".count-btn") as HTMLButtonElement; const panelCloseBtn = wrapper.querySelector(".participant-panel-close") as HTMLButtonElement; const waypointInput = wrapper.querySelector(".waypoint-input") as HTMLInputElement; const waypointSubmit = wrapper.querySelector(".waypoint-submit") as HTMLButtonElement; const waypointCancel = wrapper.querySelector(".waypoint-cancel") as HTMLButtonElement; // Load MapLibre and initialize map this.#loadMapLibre().then(() => { this.#initMap(); }); // โ”€โ”€ Search handler โ”€โ”€ const handleSearch = async () => { const query = searchInput.value.trim(); if (!query) return; try { const response = await fetch( `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}` ); const results = await response.json(); if (results.length > 0) { const { lon, lat } = results[0]; this.center = [parseFloat(lon), parseFloat(lat)]; this.zoom = 15; } } catch (error) { console.error("Search error:", error); } }; searchBtn.addEventListener("click", (e) => { e.stopPropagation(); handleSearch(); }); searchInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); handleSearch(); } }); // Prevent map interactions from triggering shape drag searchInput.addEventListener("pointerdown", (e) => e.stopPropagation()); // Location button (solo locate โ€” pan to my location) locateBtn.addEventListener("click", (e) => { e.stopPropagation(); if ("geolocation" in navigator) { navigator.geolocation.getCurrentPosition( (pos) => { this.center = [pos.coords.longitude, pos.coords.latitude]; this.zoom = 15; }, (err) => { console.error("Geolocation error:", err); } ); } }); // Close button closeBtn.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // โ”€โ”€ Collab button handlers โ”€โ”€ // Emoji button โ†’ toggle picker this.#emojiBtnEl.addEventListener("click", (e) => { e.stopPropagation(); this.#showEmojiPicker = !this.#showEmojiPicker; this.#emojiPickerEl?.classList.toggle("open", this.#showEmojiPicker); }); // Emoji option clicks this.#emojiPickerEl?.addEventListener("click", (e) => { e.stopPropagation(); const target = (e.target as HTMLElement).closest(".emoji-opt") as HTMLElement | null; if (!target) return; const emoji = target.dataset.emoji; if (emoji) this.#changeEmoji(emoji); }); // Share location toggle this.#shareBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#toggleLocationSharing(); }); // Drop waypoint pinBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#showWaypointInput(); }); // Waypoint input events waypointInput.addEventListener("pointerdown", (e) => e.stopPropagation()); waypointSubmit.addEventListener("click", (e) => { e.stopPropagation(); this.#submitWaypoint(waypointInput.value.trim()); waypointInput.value = ""; }); waypointCancel.addEventListener("click", (e) => { e.stopPropagation(); waypointInput.value = ""; this.#waypointInputWrap?.classList.remove("open"); }); waypointInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); this.#submitWaypoint(waypointInput.value.trim()); waypointInput.value = ""; } else if (e.key === "Escape") { waypointInput.value = ""; this.#waypointInputWrap?.classList.remove("open"); } }); // Participant count โ†’ toggle panel countBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#showParticipants = !this.#showParticipants; this.#participantPanel?.classList.toggle("open", this.#showParticipants); }); panelCloseBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#showParticipants = false; this.#participantPanel?.classList.remove("open"); }); // Stop propagation on collab toolbar this.#collabToolbar?.addEventListener("pointerdown", (e) => e.stopPropagation()); this.#emojiPickerEl?.addEventListener("pointerdown", (e) => e.stopPropagation()); return root; } disconnectedCallback() { super.disconnectedCallback?.(); this.#leaveRoom(); } // โ”€โ”€ MapLibre loading โ”€โ”€ async #loadMapLibre() { if (window.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); }); } #initMap() { if (!this.#mapEl || !window.maplibregl) return; this.#map = new window.maplibregl.Map({ container: this.#mapEl, style: DEFAULT_STYLE, center: this.#center, zoom: this.#zoom, }); this.#map.addControl(new window.maplibregl.NavigationControl(), "top-right"); this.#map.on("load", () => { if (this.#loadingEl) { this.#loadingEl.classList.add("hidden"); } this.#markers.forEach((marker) => this.#renderMarker(marker)); }); this.#map.on("moveend", () => { const center = this.#map!.getCenter(); this.#center = [center.lng, center.lat]; this.#zoom = this.#map!.getZoom(); this.dispatchEvent( new CustomEvent("map-move", { detail: { center: this.#center, zoom: this.#zoom }, }) ); }); // Click to add marker (solo mode only) this.#map.on("click", (e) => { if (this.#roomSlug) return; // In collab mode, don't add solo markers on click const marker: MapMarker = { id: crypto.randomUUID(), lng: e.lngLat.lng, lat: e.lngLat.lat, color: "#22c55e", }; this.addMarker(marker); }); } #renderMarker(marker: MapMarker) { if (!this.#map || !window.maplibregl) return; const el = document.createElement("div"); el.style.cssText = ` width: 24px; height: 24px; background: ${marker.color || "#22c55e"}; border: 2px solid white; border-radius: 50%; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.3); `; const mapMarker = new window.maplibregl.Marker({ element: el }) .setLngLat([marker.lng, marker.lat]) .addTo(this.#map); if (marker.label) { mapMarker.setPopup( new window.maplibregl.Popup({ offset: 25 }).setText(marker.label) ); } this.#mapMarkerInstances.set(marker.id, mapMarker); } // โ”€โ”€ Room sync methods โ”€โ”€ #loadUserProfile() { try { const stored = localStorage.getItem("rmaps_user"); if (stored) { const profile = JSON.parse(stored); this.#userName = profile.name || ""; this.#userEmoji = profile.emoji || "๐Ÿ˜Ž"; this.#userColor = profile.color || "#22c55e"; if (profile.privacy) { this.#privacySettings = profile.privacy; } } } catch {} // Generate participant ID if not set if (!this.#participantId) { this.#participantId = localStorage.getItem("rmaps_participant_id") || crypto.randomUUID(); localStorage.setItem("rmaps_participant_id", this.#participantId); } } #saveUserProfile() { try { localStorage.setItem("rmaps_user", JSON.stringify({ name: this.#userName, emoji: this.#userEmoji, color: this.#userColor, privacy: this.#privacySettings, })); } catch {} } #getApiBase(): string { const match = window.location.pathname.match(/^\/([^/]+)/); const spaceSlug = match ? match[1] : "default"; return `/${spaceSlug}/rmaps`; } async #initRoomSync() { if (!this.#roomSlug) return; this.#loadUserProfile(); // Prompt for name if not set if (!this.#userName) { this.#userName = prompt("Your name for this map room:") || "Anonymous"; this.#saveUserProfile(); } // Fetch sync URL from API try { const apiBase = this.#getApiBase(); const resp = await fetch(`${apiBase}/api/sync-url`); if (resp.ok) { const data = await resp.json(); this.#syncUrl = data.url || ""; } } catch { console.warn("[folk-map] Could not fetch sync URL, running local-only"); } // Create RoomSync this.#sync = new RoomSync( this.#roomSlug, this.#participantId, (state) => this.#onRoomStateChange(state), (connected) => { this.#syncConnected = connected; if (this.#statusDot) { this.#statusDot.style.display = ""; this.#statusDot.className = `status-dot ${connected ? "connected" : "disconnected"}`; } } ); // Connect (falls back to local-only if no syncUrl) this.#sync.connect(this.#syncUrl || undefined); // Join with participant info const participant: ParticipantState = { id: this.#participantId, name: this.#userName, emoji: this.#userEmoji, color: this.#userColor, joinedAt: new Date().toISOString(), lastSeen: new Date().toISOString(), status: "online", }; this.#sync.join(participant); // Update emoji button if (this.#emojiBtnEl) this.#emojiBtnEl.textContent = this.#userEmoji; // Start staleness timer this.#stalenessTimer = setInterval(() => this.#refreshStaleness(), 15000); } #leaveRoom() { // Stop GPS watching if (this.#watchId !== null) { navigator.geolocation.clearWatch(this.#watchId); this.#watchId = null; } this.#sharingLocation = false; if (this.#shareBtn) this.#shareBtn.classList.remove("sharing"); // Leave sync this.#sync?.leave(); this.#sync = null; // Clear participant markers for (const m of this.#participantMarkers.values()) m.remove(); this.#participantMarkers.clear(); // Clear waypoint markers for (const m of this.#waypointMarkers.values()) m.remove(); this.#waypointMarkers.clear(); // Clear staleness timer if (this.#stalenessTimer) { clearInterval(this.#stalenessTimer); this.#stalenessTimer = null; } // Reset status dot if (this.#statusDot) this.#statusDot.style.display = "none"; // Close panels this.#showParticipants = false; this.#showEmojiPicker = false; this.#participantPanel?.classList.remove("open"); this.#emojiPickerEl?.classList.remove("open"); } #onRoomStateChange(state: RoomState) { if (!this.#map || !window.maplibregl) return; const participants = Object.values(state.participants); const now = Date.now(); // โ”€โ”€ Update/create participant markers โ”€โ”€ const activeIds = new Set(); for (const p of participants) { if (p.id === this.#participantId && !this.#sharingLocation) continue; activeIds.add(p.id); if (!p.location) { // No location โ€” remove marker if exists const existing = this.#participantMarkers.get(p.id); if (existing) { existing.remove(); this.#participantMarkers.delete(p.id); } continue; } const age = now - new Date(p.lastSeen).getTime(); const isStale = age > STALE_THRESHOLD_MS; const opacity = isStale ? 0.4 : 1; const ageText = isStale ? ` (${formatDistance(0)}${Math.floor(age / 60000)}m ago)` : ""; const existing = this.#participantMarkers.get(p.id); if (existing) { // Update position existing.setLngLat([p.location.longitude, p.location.latitude]); const el = existing.getElement(); el.style.opacity = String(opacity); // Update tooltip const nameLabel = el.querySelector(".p-label") as HTMLElement; if (nameLabel) nameLabel.textContent = p.name + ageText; // Update heading arrow const arrow = el.querySelector(".heading-arrow") as HTMLElement; if (arrow && p.location.heading != null) { arrow.style.display = ""; arrow.style.transform = `rotate(${p.location.heading}deg)`; } else if (arrow) { arrow.style.display = "none"; } } else { // Create new participant marker const el = document.createElement("div"); el.style.cssText = ` display: flex; flex-direction: column; align-items: center; cursor: pointer; opacity: ${opacity}; pointer-events: auto; `; el.innerHTML = `
${p.emoji || "๐Ÿ˜Ž"}
${p.name}${ageText}
`; const marker = new window.maplibregl.Marker({ element: el }) .setLngLat([p.location.longitude, p.location.latitude]) .addTo(this.#map); this.#participantMarkers.set(p.id, marker); } } // Remove markers for participants who left for (const [id, marker] of this.#participantMarkers) { if (!activeIds.has(id)) { marker.remove(); this.#participantMarkers.delete(id); } } // โ”€โ”€ Update waypoint markers โ”€โ”€ const activeWpIds = new Set(); for (const wp of state.waypoints) { activeWpIds.add(wp.id); const existing = this.#waypointMarkers.get(wp.id); if (!existing) { const el = document.createElement("div"); el.style.cssText = ` display: flex; flex-direction: column; align-items: center; cursor: pointer; `; el.innerHTML = `
๐Ÿ“Œ
${wp.name || "Pin"}
`; const marker = new window.maplibregl.Marker({ element: el }) .setLngLat([wp.longitude, wp.latitude]) .addTo(this.#map); this.#waypointMarkers.set(wp.id, marker); } } // Remove deleted waypoints for (const [id, marker] of this.#waypointMarkers) { if (!activeWpIds.has(id)) { marker.remove(); this.#waypointMarkers.delete(id); } } // โ”€โ”€ Update count badge โ”€โ”€ if (this.#countBadge) { this.#countBadge.textContent = String(participants.length); } // โ”€โ”€ Refresh participant panel if open โ”€โ”€ if (this.#showParticipants) { this.#renderParticipantPanel(state); } } #toggleLocationSharing() { if (!this.#sync) return; if (this.#sharingLocation) { // Stop sharing if (this.#watchId !== null) { navigator.geolocation.clearWatch(this.#watchId); this.#watchId = null; } this.#sharingLocation = false; this.#shareBtn?.classList.remove("sharing"); if (this.#shareBtn) this.#shareBtn.innerHTML = "๐Ÿ“ Share"; this.#sync.clearLocation(); return; } if (!("geolocation" in navigator)) return; this.#sharingLocation = true; this.#shareBtn?.classList.add("sharing"); if (this.#shareBtn) this.#shareBtn.innerHTML = "๐Ÿ“ Sharing..."; this.#watchId = navigator.geolocation.watchPosition( (pos) => { const { latitude, longitude, accuracy, altitude, heading, speed } = pos.coords; const fuzzed = fuzzLocation(latitude, longitude, this.#privacySettings.precision); const location: LocationState = { latitude: fuzzed.latitude, longitude: fuzzed.longitude, accuracy, altitude: altitude ?? undefined, heading: heading ?? undefined, speed: speed ?? undefined, timestamp: new Date().toISOString(), source: "gps", }; this.#sync?.updateLocation(location); }, (err) => { console.warn("[folk-map] GPS error:", err); // Try lower accuracy if (err.code === err.TIMEOUT && this.#watchId !== null) { navigator.geolocation.clearWatch(this.#watchId); this.#watchId = navigator.geolocation.watchPosition( (pos) => { const { latitude, longitude, accuracy } = pos.coords; const fuzzed = fuzzLocation(latitude, longitude, this.#privacySettings.precision); this.#sync?.updateLocation({ latitude: fuzzed.latitude, longitude: fuzzed.longitude, accuracy, timestamp: new Date().toISOString(), source: "network", }); }, () => {}, { enableHighAccuracy: false, timeout: 30000, maximumAge: 60000 } ); } }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 5000 } ); } #showWaypointInput() { this.#waypointInputWrap?.classList.add("open"); const input = this.#waypointInputWrap?.querySelector(".waypoint-input") as HTMLInputElement; if (input) { input.value = ""; input.focus(); } } #submitWaypoint(name: string) { this.#waypointInputWrap?.classList.remove("open"); if (!this.#sync || !this.#map) return; const center = this.#map.getCenter(); const waypoint: WaypointState = { id: crypto.randomUUID(), name: name || "Pin", latitude: center.lat, longitude: center.lng, createdBy: this.#participantId, createdAt: new Date().toISOString(), type: "poi", }; this.#sync.addWaypoint(waypoint); } #changeEmoji(emoji: string) { this.#userEmoji = emoji; this.#saveUserProfile(); if (this.#emojiBtnEl) this.#emojiBtnEl.textContent = emoji; // Close picker this.#showEmojiPicker = false; this.#emojiPickerEl?.classList.remove("open"); // Update selected state in picker this.#emojiPickerEl?.querySelectorAll(".emoji-opt").forEach((el) => { el.classList.toggle("selected", (el as HTMLElement).dataset.emoji === emoji); }); // Re-join with updated emoji if (this.#sync) { this.#sync.join({ id: this.#participantId, name: this.#userName, emoji: this.#userEmoji, color: this.#userColor, joinedAt: new Date().toISOString(), lastSeen: new Date().toISOString(), status: "online", }); } } #refreshStaleness() { if (!this.#sync) return; const state = this.#sync.getState(); const now = Date.now(); for (const p of Object.values(state.participants)) { if (p.id === this.#participantId) continue; const marker = this.#participantMarkers.get(p.id); if (!marker) continue; const age = now - new Date(p.lastSeen).getTime(); const isStale = age > STALE_THRESHOLD_MS; const el = marker.getElement(); el.style.opacity = isStale ? "0.4" : "1"; const nameLabel = el.querySelector(".p-label") as HTMLElement; if (nameLabel) { const ageText = isStale ? ` (${Math.floor(age / 60000)}m ago)` : ""; nameLabel.textContent = p.name + ageText; } } } #renderParticipantPanel(state: RoomState) { if (!this.#participantList) return; const myLocation = state.participants[this.#participantId]?.location; const entries = Object.values(state.participants).map((p) => { let distText = ""; if (myLocation && p.location && p.id !== this.#participantId) { const dist = haversineDistance( myLocation.latitude, myLocation.longitude, p.location.latitude, p.location.longitude ); distText = formatDistance(dist); } const now = Date.now(); const age = now - new Date(p.lastSeen).getTime(); const isStale = age > STALE_THRESHOLD_MS; const statusClass = isStale ? "away" : p.status; return `
${p.emoji || "๐Ÿ˜Ž"}
${p.name}${p.id === this.#participantId ? " (you)" : ""}
${p.location ? (distText || "sharing") : "no location"} ${isStale ? ` ยท ${Math.floor(age / 60000)}m ago` : ""}
`; }); this.#participantList.innerHTML = entries.join(""); } // โ”€โ”€ Serialization โ”€โ”€ static override fromData(data: Record): FolkMap { const shape = FolkShape.fromData(data) as FolkMap; if (data.center) shape.center = data.center; if (data.zoom !== undefined) shape.zoom = data.zoom; if (data.roomSlug) shape.roomSlug = data.roomSlug; return shape; } override toJSON() { return { ...super.toJSON(), type: "folk-map", center: this.center, zoom: this.zoom, markers: this.markers, roomSlug: this.#roomSlug, }; } override applyData(data: Record): void { super.applyData(data); if (data.center !== undefined) this.center = data.center; if (data.zoom !== undefined && this.zoom !== data.zoom) this.zoom = data.zoom; if ("roomSlug" in data && this.#roomSlug !== data.roomSlug) { this.roomSlug = data.roomSlug; } } }