diff --git a/lib/folk-map.ts b/lib/folk-map.ts index 07a23f3..958aeec 100644 --- a/lib/folk-map.ts +++ b/lib/folk-map.ts @@ -1,5 +1,15 @@ 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"; @@ -24,6 +34,9 @@ const DEFAULT_STYLE = { ], }; +const EMOJI_OPTIONS = ["๐Ÿ˜Ž", "๐Ÿง‘", "๐Ÿ‘ฉ", "๐Ÿง”", "๐Ÿ‘จ", "๐Ÿฑ", "๐Ÿถ", "๐ŸฆŠ", "๐Ÿป", "๐Ÿธ", "๐ŸŒŸ", "๐Ÿ”ฅ"]; +const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes + const styles = css` :host { background: var(--rs-bg-surface, #fff); @@ -72,6 +85,16 @@ const styles = css` 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); @@ -80,6 +103,11 @@ const styles = css` position: relative; } + :host([data-has-collab]) .map-container { + height: calc(100% - 36px - 36px); + border-radius: 0; + } + .map { width: 100%; height: 100%; @@ -159,6 +187,234 @@ const styles = css` .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 { @@ -188,6 +444,7 @@ interface MapLibreMarker { addTo(map: MapLibreMap): this; setPopup(popup: unknown): this; remove(): void; + getElement(): HTMLElement; } interface MapLibreGL { @@ -199,7 +456,7 @@ interface MapLibreGL { }) => MapLibreMap; NavigationControl: new () => unknown; Marker: new (options?: { element?: HTMLElement }) => MapLibreMarker; - Popup: new (options?: { offset?: number }) => { setText(text: string): unknown }; + Popup: new (options?: { offset?: number }) => { setText(text: string): unknown; setHTML(html: string): unknown }; } declare global { @@ -226,6 +483,7 @@ export class FolkMap extends FolkShape { this.styles = sheet; } + // โ”€โ”€ Existing solo-map state โ”€โ”€ #map: MapLibreMap | null = null; #markers: MapMarker[] = []; #mapMarkerInstances = new Map(); @@ -234,6 +492,36 @@ export class FolkMap extends FolkShape { #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; } @@ -256,6 +544,28 @@ export class FolkMap extends FolkShape { 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); @@ -292,7 +602,8 @@ export class FolkMap extends FolkShape {
๐Ÿ—บ - Map + Map +
@@ -306,6 +617,34 @@ export class FolkMap extends FolkShape {
+ +
+
+ Participants + +
+
+
+ +
+ + + +
+
+ + + +
+
+ ${EMOJI_OPTIONS.map((e) => ``).join("")} +
`; @@ -316,30 +655,46 @@ export class FolkMap extends FolkShape { 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 + // โ”€โ”€ Search handler โ”€โ”€ const handleSearch = async () => { const query = searchInput.value.trim(); if (!query) return; - try { - // Use Nominatim for geocoding 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)]; @@ -365,7 +720,7 @@ export class FolkMap extends FolkShape { // Prevent map interactions from triggering shape drag searchInput.addEventListener("pointerdown", (e) => e.stopPropagation()); - // Location button + // Location button (solo locate โ€” pan to my location) locateBtn.addEventListener("click", (e) => { e.stopPropagation(); if ("geolocation" in navigator) { @@ -387,20 +742,93 @@ export class FolkMap extends FolkShape { 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() { - // Check if already loaded if (window.maplibregl) return; - // Load CSS const link = document.createElement("link"); link.rel = "stylesheet"; link.href = MAPLIBRE_CSS; document.head.appendChild(link); - // Load JS return new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = MAPLIBRE_JS; @@ -420,20 +848,15 @@ export class FolkMap extends FolkShape { zoom: this.#zoom, }); - // Add navigation controls this.#map.addControl(new window.maplibregl.NavigationControl(), "top-right"); - // Hide loading indicator this.#map.on("load", () => { if (this.#loadingEl) { this.#loadingEl.classList.add("hidden"); } - - // Render any existing markers this.#markers.forEach((marker) => this.#renderMarker(marker)); }); - // Track map movement this.#map.on("moveend", () => { const center = this.#map!.getCenter(); this.#center = [center.lng, center.lat]; @@ -445,8 +868,9 @@ export class FolkMap extends FolkShape { ); }); - // Click to add marker + // 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, @@ -484,10 +908,475 @@ export class FolkMap extends FolkShape { 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; } @@ -498,6 +1387,7 @@ export class FolkMap extends FolkShape { center: this.center, zoom: this.zoom, markers: this.markers, + roomSlug: this.#roomSlug, }; } @@ -505,5 +1395,8 @@ export class FolkMap extends FolkShape { 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; + } } } diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index c51e142..7f911bb 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -55,7 +55,8 @@ const LIGHT_STYLE = { }; 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}"]; +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 class FolkMapViewer extends HTMLElement { private shadow: ShadowRoot; @@ -104,6 +105,8 @@ class FolkMapViewer extends HTMLElement { 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; @@ -163,6 +166,7 @@ class FolkMapViewer extends HTMLElement { this._themeObserver.disconnect(); this._themeObserver = null; } + if (this.stalenessTimer) { clearInterval(this.stalenessTimer); this.stalenessTimer = null; } } // โ”€โ”€โ”€ User profile โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -183,6 +187,37 @@ class FolkMapViewer extends HTMLElement { 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 @@ -977,6 +1012,8 @@ class FolkMapViewer extends HTMLElement { this.render(); this.initMapView(); this.initRoomSync(); + // Periodically refresh staleness indicators + this.stalenessTimer = setInterval(() => this.refreshStaleness(), 15000); } private createRoom() { @@ -1011,6 +1048,7 @@ class FolkMapViewer extends HTMLElement { this._themeObserver = null; } if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer); + if (this.stalenessTimer) { clearInterval(this.stalenessTimer); this.stalenessTimer = null; } } // โ”€โ”€โ”€ MapLibre GL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -1155,8 +1193,37 @@ class FolkMapViewer extends HTMLElement { 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)) { - this.participantMarkers.get(id).setLngLat(lngLat); + 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 dark = this.isDarkTheme(); const markerBg = dark ? '#1a1a2e' : '#fafaf7'; @@ -1165,13 +1232,38 @@ class FolkMapViewer extends HTMLElement { el.className = "participant-marker"; el.style.cssText = ` width: 36px; height: 36px; border-radius: 50%; - border: 3px solid ${p.color}; background: ${markerBg}; + 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; `; - el.textContent = p.emoji; - el.title = p.name; + + // Emoji span with class for later updates + const emojiSpan = document.createElement("span"); + emojiSpan.className = "marker-emoji"; + emojiSpan.textContent = p.emoji; + el.appendChild(emojiSpan); + + // 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 = `${p.name} - ${p.status} (${ageLabel})`; + + // Heading arrow (CSS triangle) + const arrow = document.createElement("div"); + arrow.className = "heading-arrow"; + arrow.style.cssText = ` + position: absolute; top: -6px; left: 50%; + width: 0; height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 8px solid ${p.color}; + 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 const label = document.createElement("div"); @@ -1266,16 +1358,25 @@ class FolkMapViewer extends HTMLElement { distLabel = formatDistance(haversineDistance(myLoc.latitude, myLoc.longitude, p.location.latitude, p.location.longitude)); } const statusColor = statusColors[p.status] || "#64748b"; + // Staleness info + 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"; + } return ` -
+
${this.esc(p.emoji)} - +
-
${this.esc(p.name)}
+
${this.esc(p.name)}
- ${p.status === "ghost" ? "ghost mode" : p.location ? "sharing" : "no location"} + ${p.status === "ghost" ? "ghost mode" : p.location ? (isStale ? ageLabel : "sharing") : "no location"} ${distLabel ? ` \u2022 ${distLabel}` : ""}
@@ -1331,6 +1432,35 @@ class FolkMapViewer extends HTMLElement { } } + /** 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; + } + } + + 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; @@ -2057,6 +2187,12 @@ class FolkMapViewer extends HTMLElement {
+
+ + +
@@ -2108,6 +2244,17 @@ class FolkMapViewer extends HTMLElement { } }); + // 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!); + }); + }); + this.shadow.getElementById("bell-toggle")?.addEventListener("click", () => { this.pushManager?.toggle(this.room, this.participantId).then(subscribed => { const bell = this.shadow.getElementById("bell-toggle"); diff --git a/website/canvas.html b/website/canvas.html index 3b2a950..4b268c0 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -4124,7 +4124,12 @@ document.getElementById("new-image").addEventListener("click", () => setPendingTool("folk-image")); document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark")); document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar")); - document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map")); + document.getElementById("new-map").addEventListener("click", () => { + const roomSlug = prompt("Map room name (leave blank for solo map):"); + if (roomSlug === null) return; + const slug = roomSlug.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-") || ""; + setPendingTool("folk-map", slug ? { roomSlug: slug } : {}); + }); document.getElementById("new-holon").addEventListener("click", () => setPendingTool("folk-holon")); document.getElementById("new-holon-browser").addEventListener("click", () => setPendingTool("folk-holon-browser")); document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));