rspace-online/lib/folk-map.ts

1403 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
},
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<string, MapLibreMarker>();
#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<string, MapLibreMarker>();
#waypointMarkers = new Map<string, MapLibreMarker>();
#syncConnected = false;
#stalenessTimer: ReturnType<typeof setInterval> | 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`
<div class="header">
<span class="header-title">
<span>🗺</span>
<span class="header-label">Map</span>
<span class="status-dot disconnected" style="display:none"></span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="map-container">
<div class="loading">Loading map...</div>
<div class="search-box">
<input type="text" class="search-input" placeholder="Search location..." />
<button class="search-btn">🔍</button>
</div>
<button class="locate-btn" title="My Location">📍</button>
<div class="map"></div>
<!-- Participant panel overlay -->
<div class="participant-panel">
<div class="participant-panel-header">
<span>Participants</span>
<button class="participant-panel-close">×</button>
</div>
<div class="participant-list"></div>
</div>
<!-- Waypoint name input -->
<div class="waypoint-input-wrap">
<input type="text" class="waypoint-input" placeholder="Pin name..." />
<button class="waypoint-submit">📌</button>
<button class="waypoint-cancel">×</button>
</div>
</div>
<!-- Collab toolbar (hidden until roomSlug set) -->
<div class="collab-toolbar" style="display:none">
<button class="collab-btn emoji-btn" title="Change avatar">😎</button>
<button class="collab-btn share-btn" title="Share location">📍 Share</button>
<button class="collab-btn pin-btn" title="Drop pin">📌 Pin</button>
<div class="collab-spacer"></div>
<button class="collab-btn count-btn" title="Participants">👥 <span class="count-badge">0</span></button>
</div>
<!-- Emoji picker dropdown -->
<div class="emoji-picker">
<div class="emoji-picker-grid">
${EMOJI_OPTIONS.map((e) => `<button class="emoji-opt" data-emoji="${e}">${e}</button>`).join("")}
</div>
</div>
`;
// 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<void>((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<string>();
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 = `
<div class="heading-arrow" style="
width: 0; height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 8px solid ${p.color || "#22c55e"};
margin-bottom: 2px;
display: ${p.location.heading != null ? "" : "none"};
transform: rotate(${p.location.heading || 0}deg);
"></div>
<div style="
font-size: 22px;
line-height: 1;
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
">${p.emoji || "😎"}</div>
<div class="p-label" style="
font-size: 9px;
background: rgba(0,0,0,0.6);
color: white;
padding: 1px 4px;
border-radius: 3px;
white-space: nowrap;
margin-top: 2px;
">${p.name}${ageText}</div>
`;
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<string>();
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 = `
<div style="font-size: 20px; line-height: 1;">📌</div>
<div style="
font-size: 9px;
background: rgba(0,0,0,0.6);
color: white;
padding: 1px 4px;
border-radius: 3px;
white-space: nowrap;
margin-top: 2px;
">${wp.name || "Pin"}</div>
`;
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 `
<div class="participant-entry">
<span class="participant-emoji">${p.emoji || "😎"}</span>
<div class="participant-info">
<div class="participant-name">${p.name}${p.id === this.#participantId ? " (you)" : ""}</div>
<div class="participant-meta">
${p.location ? (distText || "sharing") : "no location"}
${isStale ? ` · ${Math.floor(age / 60000)}m ago` : ""}
</div>
</div>
<div class="participant-status-dot ${statusClass}"></div>
</div>
`;
});
this.#participantList.innerHTML = entries.join("");
}
// ── Serialization ──
static override fromData(data: Record<string, any>): 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<string, any>): 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;
}
}
}