feat(rmaps): MapLibre GL map, WebSocket room sync, room history + ping friends

Replace the map room placeholder with a real MapLibre GL dark map (CartoDB
dark_all tiles). Port RoomSync from rmaps-online for WebSocket-based
participant/waypoint sync. Add localStorage room history with thumbnail
capture, participant sidebar with ping buttons, continuous GPS sharing
via watchPosition, and waypoint drop. Demo mode unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 23:44:28 -07:00
parent 192659b49c
commit 35dd1c3d77
4 changed files with 921 additions and 35 deletions

View File

@ -9,6 +9,31 @@
* and feature highlights matching standalone rMaps capabilities. * and feature highlights matching standalone rMaps capabilities.
*/ */
import { RoomSync, type RoomState, type ParticipantState, type LocationState } from "./map-sync";
import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history";
import { MapPushManager } from "./map-push";
// MapLibre loaded via CDN — use window access with type assertion
const MAPLIBRE_CSS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css";
const MAPLIBRE_JS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js";
const DARK_STYLE = {
version: 8,
sources: {
carto: {
type: "raster",
tiles: ["https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png"],
tileSize: 256,
attribution: '&copy; <a href="https://carto.com/">CARTO</a> &copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>',
},
},
layers: [{ id: "carto", type: "raster", source: "carto" }],
};
const PARTICIPANT_COLORS = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316"];
const EMOJIS = ["\u{1F9ED}", "\u{1F30D}", "\u{1F680}", "\u{1F308}", "\u{2B50}", "\u{1F525}", "\u{1F33F}", "\u{1F30A}", "\u{26A1}", "\u{1F48E}"];
class FolkMapViewer extends HTMLElement { class FolkMapViewer extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = ""; private space = "";
@ -20,7 +45,7 @@ class FolkMapViewer extends HTMLElement {
private syncStatus: "disconnected" | "connected" = "disconnected"; private syncStatus: "disconnected" | "connected" = "disconnected";
private providers: { name: string; city: string; country: string; lat: number; lng: number; color: string; desc: string; specialties: string[] }[] = []; private providers: { name: string; city: string; country: string; lat: number; lng: number; color: string; desc: string; specialties: string[] }[] = [];
// Zoom/pan state // Zoom/pan state (demo mode)
private vbX = 0; private vbX = 0;
private vbY = 0; private vbY = 0;
private vbW = 900; private vbW = 900;
@ -35,6 +60,21 @@ class FolkMapViewer extends HTMLElement {
private searchQuery = ""; private searchQuery = "";
private userLocation: { lat: number; lng: number } | null = null; private userLocation: { lat: number; lng: number } | null = null;
// MapLibre + sync state (room mode)
private map: any = null;
private participantMarkers: Map<string, any> = new Map();
private waypointMarkers: Map<string, any> = new Map();
private sync: RoomSync | null = null;
private syncUrl = "";
private participantId = "";
private userName = "";
private userEmoji = "";
private userColor = "";
private sharingLocation = false;
private watchId: number | null = null;
private pushManager: MapPushManager | null = null;
private thumbnailTimer: ReturnType<typeof setTimeout> | null = null;
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
@ -47,13 +87,54 @@ class FolkMapViewer extends HTMLElement {
this.loadDemoData(); this.loadDemoData();
return; return;
} }
this.loadUserProfile();
this.pushManager = new MapPushManager(this.getApiBase());
if (this.room) { if (this.room) {
this.view = "map"; this.joinRoom(this.room);
return;
} }
this.checkSyncHealth(); this.checkSyncHealth();
this.render(); this.render();
} }
disconnectedCallback() {
this.leaveRoom();
}
// ─── User profile ────────────────────────────────────────────
private loadUserProfile() {
try {
const saved = JSON.parse(localStorage.getItem("rmaps_user") || "null");
if (saved) {
this.participantId = saved.id;
this.userName = saved.name;
this.userEmoji = saved.emoji;
this.userColor = saved.color;
return;
}
} catch {}
this.participantId = crypto.randomUUID();
this.userEmoji = EMOJIS[Math.floor(Math.random() * EMOJIS.length)];
this.userColor = PARTICIPANT_COLORS[Math.floor(Math.random() * PARTICIPANT_COLORS.length)];
}
private ensureUserProfile(): boolean {
if (this.userName) return true;
const name = prompt("Your display name for this room:");
if (!name?.trim()) return false;
this.userName = name.trim();
localStorage.setItem("rmaps_user", JSON.stringify({
id: this.participantId,
name: this.userName,
emoji: this.userEmoji,
color: this.userColor,
}));
return true;
}
// ─── Demo mode ───────────────────────────────────────────────
private loadDemoData() { private loadDemoData() {
this.view = "map"; this.view = "map";
this.room = "cosmolocal-providers"; this.room = "cosmolocal-providers";
@ -772,6 +853,8 @@ class FolkMapViewer extends HTMLElement {
}, { passive: true }); }, { passive: true });
} }
// ─── Room mode: API / health ─────────────────────────────────
private getApiBase(): string { private getApiBase(): string {
const path = window.location.pathname; const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rmaps/); const match = path.match(/^(\/[^/]+)?\/rmaps/);
@ -806,10 +889,15 @@ class FolkMapViewer extends HTMLElement {
this.render(); this.render();
} }
// ─── Room mode: join / leave / create ────────────────────────
private joinRoom(slug: string) { private joinRoom(slug: string) {
if (!this.ensureUserProfile()) return;
this.room = slug; this.room = slug;
this.view = "map"; this.view = "map";
this.render(); this.render();
this.initMapView();
this.initRoomSync();
} }
private createRoom() { private createRoom() {
@ -819,6 +907,343 @@ class FolkMapViewer extends HTMLElement {
this.joinRoom(slug); this.joinRoom(slug);
} }
private leaveRoom() {
this.captureThumbnail();
if (this.watchId !== null) {
navigator.geolocation.clearWatch(this.watchId);
this.watchId = null;
}
this.sharingLocation = false;
if (this.sync) {
this.sync.leave();
this.sync = null;
}
this.participantMarkers.forEach((m) => m.remove());
this.participantMarkers.clear();
this.waypointMarkers.forEach((m) => m.remove());
this.waypointMarkers.clear();
if (this.map) {
this.map.remove();
this.map = null;
}
if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer);
}
// ─── MapLibre GL ─────────────────────────────────────────────
private async loadMapLibre(): Promise<void> {
if ((window as any).maplibregl) return;
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = MAPLIBRE_CSS;
document.head.appendChild(link);
return new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = MAPLIBRE_JS;
script.onload = () => resolve();
script.onerror = reject;
document.head.appendChild(script);
});
}
private async initMapView() {
await this.loadMapLibre();
const container = this.shadow.getElementById("map-container");
if (!container || !(window as any).maplibregl) return;
this.map = new (window as any).maplibregl.Map({
container,
style: DARK_STYLE,
center: [0, 20],
zoom: 2,
preserveDrawingBuffer: true,
});
this.map.addControl(new (window as any).maplibregl.NavigationControl(), "top-right");
this.map.addControl(new (window as any).maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: false,
}), "top-right");
// Debounced thumbnail capture on moveend
this.map.on("moveend", () => {
if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer);
this.thumbnailTimer = setTimeout(() => this.captureThumbnail(), 3000);
});
// Initial thumbnail capture after tiles load
this.map.on("load", () => {
setTimeout(() => this.captureThumbnail(), 2000);
});
}
// ─── Room sync ───────────────────────────────────────────────
private async initRoomSync() {
// Fetch sync URL from server
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/sync-url`, { signal: AbortSignal.timeout(3000) });
if (res.ok) {
const data = await res.json();
this.syncUrl = data.syncUrl || "";
}
} catch {}
this.sync = new RoomSync(
this.room,
this.participantId,
(state) => this.onRoomStateChange(state),
(connected) => {
this.syncStatus = connected ? "connected" : "disconnected";
const dot = this.shadow.querySelector(".status-dot");
if (dot) {
dot.className = `status-dot ${connected ? "status-connected" : "status-disconnected"}`;
}
},
);
this.sync.connect(this.syncUrl || undefined);
const now = new Date().toISOString();
this.sync.join({
id: this.participantId,
name: this.userName,
emoji: this.userEmoji,
color: this.userColor,
joinedAt: now,
lastSeen: now,
status: "online",
});
saveRoomVisit(this.room, this.room);
}
// ─── State change → update markers ───────────────────────────
private onRoomStateChange(state: RoomState) {
if (!this.map || !(window as any).maplibregl) return;
const currentIds = new Set<string>();
// Update participant markers
for (const [id, p] of Object.entries(state.participants)) {
currentIds.add(id);
if (p.location) {
const lngLat: [number, number] = [p.location.longitude, p.location.latitude];
if (this.participantMarkers.has(id)) {
this.participantMarkers.get(id).setLngLat(lngLat);
} else {
const el = document.createElement("div");
el.className = "participant-marker";
el.style.cssText = `
width: 36px; height: 36px; border-radius: 50%;
border: 3px solid ${p.color}; background: #1a1a2e;
display: flex; align-items: center; justify-content: center;
font-size: 18px; cursor: pointer; position: relative;
box-shadow: 0 0 8px ${p.color}60;
`;
el.textContent = p.emoji;
el.title = p.name;
// Name label below
const label = document.createElement("div");
label.style.cssText = `
position: absolute; bottom: -18px; left: 50%; transform: translateX(-50%);
font-size: 10px; color: ${p.color}; font-weight: 600;
white-space: nowrap; text-shadow: 0 1px 3px rgba(0,0,0,0.8);
font-family: system-ui, sans-serif;
`;
label.textContent = p.name;
el.appendChild(label);
const marker = new (window as any).maplibregl.Marker({ element: el })
.setLngLat(lngLat)
.addTo(this.map);
this.participantMarkers.set(id, marker);
}
}
}
// Remove departed participants
for (const [id, marker] of this.participantMarkers) {
if (!currentIds.has(id) || !state.participants[id]?.location) {
marker.remove();
this.participantMarkers.delete(id);
}
}
// Update waypoint markers
const wpIds = new Set(state.waypoints.map((w) => w.id));
for (const wp of state.waypoints) {
if (!this.waypointMarkers.has(wp.id)) {
const el = document.createElement("div");
el.style.cssText = `
width: 28px; height: 28px; border-radius: 50%;
background: #4f46e5; border: 2px solid #818cf8;
display: flex; align-items: center; justify-content: center;
font-size: 14px; cursor: pointer;
`;
el.textContent = wp.emoji || "\u{1F4CD}";
el.title = wp.name;
const marker = new (window as any).maplibregl.Marker({ element: el })
.setLngLat([wp.longitude, wp.latitude])
.addTo(this.map);
this.waypointMarkers.set(wp.id, marker);
}
}
for (const [id, marker] of this.waypointMarkers) {
if (!wpIds.has(id)) {
marker.remove();
this.waypointMarkers.delete(id);
}
}
// Update participant list sidebar
this.updateParticipantList(state);
}
private updateParticipantList(state: RoomState) {
const list = this.shadow.getElementById("participant-list");
if (!list) return;
const entries = Object.values(state.participants);
list.innerHTML = entries.map((p) => `
<div class="participant-entry" style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid #1e293b;">
<span style="font-size:18px">${this.esc(p.emoji)}</span>
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;color:${p.color};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${this.esc(p.name)}</div>
<div style="font-size:10px;color:#64748b;">${p.location ? "sharing location" : "no location"}</div>
</div>
${p.id !== this.participantId ? `<button class="ping-btn-inline" data-ping="${p.id}" title="Ping" style="background:none;border:1px solid #333;border-radius:4px;color:#94a3b8;cursor:pointer;padding:2px 6px;font-size:11px;">&#128276;</button>` : ""}
</div>
`).join("");
// Attach ping listeners
list.querySelectorAll("[data-ping]").forEach((btn) => {
btn.addEventListener("click", () => {
const pid = (btn as HTMLElement).dataset.ping!;
this.pushManager?.requestLocation(this.room, pid);
(btn as HTMLElement).textContent = "\u2713";
setTimeout(() => { (btn as HTMLElement).textContent = "\u{1F514}"; }, 2000);
});
});
}
// ─── Location sharing ────────────────────────────────────────
private toggleLocationSharing() {
if (this.sharingLocation) {
// Stop sharing
if (this.watchId !== null) {
navigator.geolocation.clearWatch(this.watchId);
this.watchId = null;
}
this.sharingLocation = false;
this.sync?.clearLocation();
this.updateShareButton();
return;
}
if (!("geolocation" in navigator)) {
this.error = "Geolocation not supported";
return;
}
let firstFix = true;
this.watchId = navigator.geolocation.watchPosition(
(pos) => {
this.sharingLocation = true;
this.updateShareButton();
const loc: LocationState = {
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
accuracy: pos.coords.accuracy,
altitude: pos.coords.altitude ?? undefined,
heading: pos.coords.heading ?? undefined,
speed: pos.coords.speed ?? undefined,
timestamp: new Date().toISOString(),
source: "gps",
};
this.sync?.updateLocation(loc);
if (firstFix && this.map) {
this.map.flyTo({ center: [loc.longitude, loc.latitude], zoom: 14 });
firstFix = false;
}
},
(err) => {
this.error = `Location error: ${err.message}`;
this.sharingLocation = false;
this.updateShareButton();
},
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 15000 },
);
}
private updateShareButton() {
const btn = this.shadow.getElementById("share-location");
if (!btn) return;
if (this.sharingLocation) {
btn.textContent = "\u{1F4CD} Stop Sharing";
btn.classList.add("sharing");
} else {
btn.textContent = "\u{1F4CD} Share Location";
btn.classList.remove("sharing");
}
}
// ─── Waypoint drop ───────────────────────────────────────────
private dropWaypoint() {
if (!this.map) return;
const center = this.map.getCenter();
const name = prompt("Waypoint name:", "Meeting point");
if (!name?.trim()) return;
this.sync?.addWaypoint({
id: crypto.randomUUID(),
name: name.trim(),
emoji: "\u{1F4CD}",
latitude: center.lat,
longitude: center.lng,
createdBy: this.participantId,
createdAt: new Date().toISOString(),
type: "meeting",
});
}
// ─── Thumbnail capture ───────────────────────────────────────
private captureThumbnail() {
if (!this.map || !this.room) return;
try {
const canvas = this.map.getCanvas();
// Downscale to 200x120
const tmp = document.createElement("canvas");
tmp.width = 200;
tmp.height = 120;
const ctx = tmp.getContext("2d");
if (!ctx) return;
ctx.drawImage(canvas, 0, 0, 200, 120);
const dataUrl = tmp.toDataURL("image/jpeg", 0.6);
updateRoomThumbnail(this.room, dataUrl);
} catch {}
}
// ─── Time ago helper ─────────────────────────────────────────
private timeAgo(iso: string): string {
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return "just now";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
// ─── Render (room mode) ──────────────────────────────────────
private render() { private render() {
this.shadow.innerHTML = ` this.shadow.innerHTML = `
<style> <style>
@ -851,13 +1276,22 @@ class FolkMapViewer extends HTMLElement {
.map-container { .map-container {
width: 100%; height: 500px; border-radius: 10px; width: 100%; height: 500px; border-radius: 10px;
background: #1a1a2e; border: 1px solid #333; background: #1a1a2e; border: 1px solid #333;
display: flex; align-items: center; justify-content: center;
position: relative; overflow: hidden; position: relative; overflow: hidden;
} }
.map-placeholder {
text-align: center; color: #666; padding: 40px; .map-layout {
display: flex; gap: 12px;
}
.map-main { flex: 1; min-width: 0; }
.map-sidebar {
width: 220px; flex-shrink: 0;
background: rgba(15,23,42,0.6); border: 1px solid #1e293b; border-radius: 10px;
padding: 12px; max-height: 560px; overflow-y: auto;
}
.sidebar-title {
font-size: 11px; font-weight: 600; color: #94a3b8;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;
} }
.map-placeholder p { margin: 8px 0; }
.controls { .controls {
display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;
@ -888,8 +1322,57 @@ class FolkMapViewer extends HTMLElement {
.empty { text-align: center; color: #666; padding: 40px; } .empty { text-align: center; color: #666; padding: 40px; }
/* Section labels */
.section-label {
font-size: 12px; font-weight: 600; color: #94a3b8;
text-transform: uppercase; letter-spacing: 0.06em; margin: 20px 0 10px;
}
/* Room history grid */
.room-history-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px; margin-bottom: 16px;
}
.history-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
overflow: hidden; cursor: pointer; transition: border-color 0.2s;
position: relative;
}
.history-card:hover { border-color: #555; }
.history-thumb {
width: 100%; height: 90px; object-fit: cover; display: block;
background: #0c1221;
}
.history-thumb-placeholder {
width: 100%; height: 90px; display: flex; align-items: center; justify-content: center;
background: #0c1221; font-size: 32px;
}
.history-info {
padding: 8px 10px;
}
.history-name {
font-size: 13px; font-weight: 600; color: #e2e8f0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.history-time {
font-size: 10px; color: #64748b; margin-top: 2px;
}
.ping-btn {
position: absolute; top: 6px; right: 6px;
width: 26px; height: 26px; border-radius: 50%;
background: rgba(15,23,42,0.8); border: 1px solid #333;
color: #94a3b8; cursor: pointer; font-size: 13px;
display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.15s;
}
.history-card:hover .ping-btn { opacity: 1; }
.ping-btn:hover { border-color: #6366f1; color: #818cf8; }
@media (max-width: 768px) { @media (max-width: 768px) {
.map-container { height: 300px; } .map-container { height: 350px; }
.map-layout { flex-direction: column; }
.map-sidebar { width: 100%; max-height: 200px; }
.room-history-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
} }
</style> </style>
@ -901,6 +1384,26 @@ class FolkMapViewer extends HTMLElement {
} }
private renderLobby(): string { private renderLobby(): string {
const history = loadRoomHistory();
const historyCards = history.length > 0 ? `
<div class="section-label">Recent Rooms</div>
<div class="room-history-grid">
${history.map((h) => `
<div class="history-card" data-room="${this.esc(h.slug)}">
${h.thumbnail
? `<img class="history-thumb" src="${h.thumbnail}" alt="">`
: `<div class="history-thumb-placeholder">&#127760;</div>`
}
<div class="history-info">
<div class="history-name">${this.esc(h.name)}</div>
<div class="history-time">${this.timeAgo(h.lastVisited)}</div>
</div>
<button class="ping-btn" data-ping-room="${this.esc(h.slug)}" title="Ping friends in this room">&#128276;</button>
</div>
`).join("")}
</div>
` : "";
return ` return `
<div class="rapp-nav"> <div class="rapp-nav">
<span class="rapp-nav__title">Map Rooms</span> <span class="rapp-nav__title">Map Rooms</span>
@ -909,12 +1412,17 @@ class FolkMapViewer extends HTMLElement {
<button class="rapp-nav__btn" id="create-room">+ New Room</button> <button class="rapp-nav__btn" id="create-room">+ New Room</button>
</div> </div>
${this.rooms.length > 0 ? this.rooms.map((r) => ` ${this.rooms.length > 0 ? `
<div class="room-card" data-room="${r}"> <div class="section-label">Active Rooms</div>
<span class="room-icon">&#128506;</span> ${this.rooms.map((r) => `
<span class="room-name">${this.esc(r)}</span> <div class="room-card" data-room="${r}">
</div> <span class="room-icon">&#128506;</span>
`).join("") : ""} <span class="room-name">${this.esc(r)}</span>
</div>
`).join("")}
` : ""}
${historyCards}
<div class="empty"> <div class="empty">
<p style="font-size:16px;margin-bottom:8px">Create or join a map room to share locations</p> <p style="font-size:16px;margin-bottom:8px">Create or join a map room to share locations</p>
@ -932,17 +1440,21 @@ class FolkMapViewer extends HTMLElement {
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span> <span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
</div> </div>
<div class="map-container"> <div class="map-layout">
<div class="map-placeholder"> <div class="map-main">
<p style="font-size:48px">&#127758;</p> <div class="map-container" id="map-container"></div>
<p style="font-size:16px">Map Room: <strong>${this.esc(this.room)}</strong></p> </div>
<p>Connect the MapLibre GL library to display the interactive map.</p> <div class="map-sidebar">
<p style="font-size:12px;color:#555">WebSocket sync: ${this.syncStatus}</p> <div class="sidebar-title">Participants</div>
<div id="participant-list">
<div style="color:#4a5568;font-size:12px;padding:8px 0;">Connecting...</div>
</div>
</div> </div>
</div> </div>
<div class="controls"> <div class="controls">
<button class="ctrl-btn" id="share-location">Share My Location</button> <button class="ctrl-btn ${this.sharingLocation ? "sharing" : ""}" id="share-location">${this.sharingLocation ? "\u{1F4CD} Stop Sharing" : "\u{1F4CD} Share Location"}</button>
<button class="ctrl-btn" id="drop-waypoint">&#128204; Drop Pin</button>
<button class="ctrl-btn" id="copy-link">Copy Room Link</button> <button class="ctrl-btn" id="copy-link">Copy Room Link</button>
</div> </div>
@ -965,27 +1477,18 @@ class FolkMapViewer extends HTMLElement {
this.shadow.querySelectorAll("[data-back]").forEach((el) => { this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
this.leaveRoom();
this.view = "lobby"; this.view = "lobby";
this.loadStats(); this.loadStats();
}); });
}); });
this.shadow.getElementById("share-location")?.addEventListener("click", () => { this.shadow.getElementById("share-location")?.addEventListener("click", () => {
if ("geolocation" in navigator) { this.toggleLocationSharing();
navigator.geolocation.getCurrentPosition( });
(pos) => {
const btn = this.shadow.getElementById("share-location"); this.shadow.getElementById("drop-waypoint")?.addEventListener("click", () => {
if (btn) { this.dropWaypoint();
btn.textContent = `Location: ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`;
btn.classList.add("sharing");
}
},
() => {
this.error = "Location access denied";
this.render();
}
);
}
}); });
const copyUrl = this.shadow.getElementById("copy-url") || this.shadow.getElementById("copy-link"); const copyUrl = this.shadow.getElementById("copy-url") || this.shadow.getElementById("copy-link");
@ -996,6 +1499,17 @@ class FolkMapViewer extends HTMLElement {
setTimeout(() => { if (copyUrl) copyUrl.textContent = "Copy"; }, 2000); setTimeout(() => { if (copyUrl) copyUrl.textContent = "Copy"; }, 2000);
}); });
}); });
// Ping buttons on history cards
this.shadow.querySelectorAll("[data-ping-room]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const slug = (btn as HTMLElement).dataset.pingRoom!;
this.pushManager?.requestLocation(slug, "all");
(btn as HTMLElement).textContent = "\u2713";
setTimeout(() => { (btn as HTMLElement).textContent = "\u{1F514}"; }, 2000);
});
});
} }
private esc(s: string): string { private esc(s: string): string {

View File

@ -0,0 +1,28 @@
/**
* Push notification helpers for "ping friends" in map rooms.
*/
export class MapPushManager {
private apiBase: string;
constructor(apiBase: string) {
this.apiBase = apiBase;
}
get isSupported(): boolean {
return "Notification" in window && "serviceWorker" in navigator;
}
async requestLocation(roomSlug: string, participantId: string): Promise<boolean> {
try {
const res = await fetch(`${this.apiBase}/api/push/request-location`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomSlug, participantId }),
});
return res.ok;
} catch {
return false;
}
}
}

View File

@ -0,0 +1,54 @@
/**
* Room history localStorage-backed list of recently visited map rooms.
*/
export interface RoomHistoryEntry {
slug: string;
name: string;
lastVisited: string;
thumbnail?: string;
center?: [number, number]; // [lng, lat]
zoom?: number;
}
const STORAGE_KEY = "rmaps_room_history";
const MAX_ENTRIES = 20;
export function loadRoomHistory(): RoomHistoryEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw) as RoomHistoryEntry[];
} catch {}
return [];
}
function save(entries: RoomHistoryEntry[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.slice(0, MAX_ENTRIES)));
} catch {}
}
export function saveRoomVisit(slug: string, name?: string, center?: [number, number], zoom?: number): void {
const entries = loadRoomHistory().filter((e) => e.slug !== slug);
entries.unshift({
slug,
name: name || slug,
lastVisited: new Date().toISOString(),
center,
zoom,
});
save(entries);
}
export function updateRoomThumbnail(slug: string, thumbnail: string): void {
const entries = loadRoomHistory();
const entry = entries.find((e) => e.slug === slug);
if (entry) {
entry.thumbnail = thumbnail;
save(entries);
}
}
export function removeRoomFromHistory(slug: string): void {
save(loadRoomHistory().filter((e) => e.slug !== slug));
}

View File

@ -0,0 +1,290 @@
/**
* WebSocket-based room sync for rMaps.
* Ported from rmaps-online/src/lib/sync.ts (simplified no @/types dependency).
*/
export interface RoomState {
id: string;
slug: string;
name: string;
createdAt: string;
participants: Record<string, ParticipantState>;
waypoints: WaypointState[];
}
export interface ParticipantState {
id: string;
name: string;
emoji: string;
color: string;
joinedAt: string;
lastSeen: string;
status: string;
location?: LocationState;
}
export interface LocationState {
latitude: number;
longitude: number;
accuracy: number;
altitude?: number;
heading?: number;
speed?: number;
timestamp: string;
source: string;
indoor?: { level: number; x: number; y: number; spaceName?: string };
}
export interface WaypointState {
id: string;
name: string;
emoji?: string;
latitude: number;
longitude: number;
indoor?: { level: number; x: number; y: number };
createdBy: string;
createdAt: string;
type: string;
}
export type SyncMessage =
| { type: "join"; participant: ParticipantState }
| { type: "leave"; participantId: string }
| { type: "location"; participantId: string; location: LocationState }
| { type: "status"; participantId: string; status: string }
| { type: "waypoint_add"; waypoint: WaypointState }
| { type: "waypoint_remove"; waypointId: string }
| { type: "full_state"; state: RoomState }
| { type: "request_state" }
| { type: "request_location"; manual?: boolean; callerName?: string };
type SyncCallback = (state: RoomState) => void;
type ConnectionCallback = (connected: boolean) => void;
type LocationRequestCallback = (manual?: boolean, callerName?: string) => void;
export function isValidLocation(location: LocationState | undefined): boolean {
if (!location) return false;
const { latitude, longitude } = location;
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return false;
if (latitude === 0 && longitude === 0) return false;
if (isNaN(latitude) || isNaN(longitude)) return false;
return true;
}
export class RoomSync {
private slug: string;
private state: RoomState;
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private onStateChange: SyncCallback;
private onConnectionChange: ConnectionCallback;
private onLocationRequest: LocationRequestCallback | null = null;
private participantId: string;
private currentParticipant: ParticipantState | null = null;
constructor(
slug: string,
participantId: string,
onStateChange: SyncCallback,
onConnectionChange: ConnectionCallback,
onLocationRequest?: LocationRequestCallback,
) {
this.slug = slug;
this.participantId = participantId;
this.onStateChange = onStateChange;
this.onConnectionChange = onConnectionChange;
this.onLocationRequest = onLocationRequest || null;
this.state = this.loadState() || this.createInitialState();
}
private createInitialState(): RoomState {
return {
id: crypto.randomUUID(),
slug: this.slug,
name: this.slug,
createdAt: new Date().toISOString(),
participants: {},
waypoints: [],
};
}
private loadState(): RoomState | null {
try {
const stored = localStorage.getItem(`rmaps_room_${this.slug}`);
if (stored) {
const state = JSON.parse(stored) as RoomState;
return this.cleanupStaleParticipants(state);
}
} catch (e) {
console.warn("Failed to load room state:", e);
}
return null;
}
private cleanupStaleParticipants(state: RoomState): RoomState {
const STALE_MS = 15 * 60 * 1000;
const now = Date.now();
const cleaned: Record<string, ParticipantState> = {};
for (const [id, p] of Object.entries(state.participants)) {
const isStale = now - new Date(p.lastSeen).getTime() > STALE_MS;
if (id === this.participantId || !isStale) {
if (p.location && !isValidLocation(p.location)) delete p.location;
cleaned[id] = p;
}
}
return { ...state, participants: cleaned };
}
private saveState(): void {
try {
localStorage.setItem(`rmaps_room_${this.slug}`, JSON.stringify(this.state));
} catch {}
}
private notifyStateChange(): void {
this.saveState();
this.onStateChange({ ...this.state });
}
connect(syncUrl?: string): void {
if (!syncUrl) {
this.onConnectionChange(true);
return;
}
try {
this.ws = new WebSocket(`${syncUrl}/room/${this.slug}`);
this.ws.onopen = () => {
this.onConnectionChange(true);
if (this.currentParticipant) {
this.send({ type: "join", participant: this.currentParticipant });
}
};
this.ws.onmessage = (event) => {
try {
this.handleMessage(JSON.parse(event.data));
} catch {}
};
this.ws.onclose = () => {
this.onConnectionChange(false);
this.scheduleReconnect(syncUrl);
};
this.ws.onerror = () => {};
} catch {
this.onConnectionChange(false);
}
}
private scheduleReconnect(syncUrl: string): void {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = setTimeout(() => this.connect(syncUrl), 5000);
}
private send(message: SyncMessage): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
private handleMessage(message: SyncMessage): void {
switch (message.type) {
case "full_state": {
const myP = this.state.participants[this.participantId];
this.state = message.state;
if (myP) this.state.participants[this.participantId] = myP;
break;
}
case "join":
this.state.participants[message.participant.id] = message.participant;
break;
case "leave":
delete this.state.participants[message.participantId];
break;
case "location":
if (this.state.participants[message.participantId]) {
if (message.location && isValidLocation(message.location)) {
this.state.participants[message.participantId].location = message.location;
this.state.participants[message.participantId].lastSeen = new Date().toISOString();
} else if (message.location === null) {
delete this.state.participants[message.participantId].location;
this.state.participants[message.participantId].lastSeen = new Date().toISOString();
}
}
break;
case "status":
if (this.state.participants[message.participantId]) {
this.state.participants[message.participantId].status = message.status;
this.state.participants[message.participantId].lastSeen = new Date().toISOString();
}
break;
case "waypoint_add":
this.state.waypoints.push(message.waypoint);
break;
case "waypoint_remove":
this.state.waypoints = this.state.waypoints.filter((w) => w.id !== message.waypointId);
break;
case "request_location":
if (this.onLocationRequest) this.onLocationRequest(message.manual, message.callerName);
return;
}
this.notifyStateChange();
}
join(participant: ParticipantState): void {
this.currentParticipant = participant;
this.state.participants[participant.id] = participant;
this.send({ type: "join", participant });
this.notifyStateChange();
}
leave(): void {
delete this.state.participants[this.participantId];
this.send({ type: "leave", participantId: this.participantId });
this.notifyStateChange();
if (this.ws) { this.ws.close(); this.ws = null; }
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
}
updateLocation(location: LocationState): void {
if (!isValidLocation(location)) return;
if (this.state.participants[this.participantId]) {
this.state.participants[this.participantId].location = location;
this.state.participants[this.participantId].lastSeen = new Date().toISOString();
this.send({ type: "location", participantId: this.participantId, location });
this.notifyStateChange();
}
}
clearLocation(): void {
if (this.state.participants[this.participantId]) {
delete this.state.participants[this.participantId].location;
this.state.participants[this.participantId].lastSeen = new Date().toISOString();
this.send({ type: "location", participantId: this.participantId, location: null as any });
this.notifyStateChange();
}
}
updateStatus(status: string): void {
if (this.state.participants[this.participantId]) {
this.state.participants[this.participantId].status = status;
this.state.participants[this.participantId].lastSeen = new Date().toISOString();
this.send({ type: "status", participantId: this.participantId, status });
this.notifyStateChange();
}
}
addWaypoint(waypoint: WaypointState): void {
this.state.waypoints.push(waypoint);
this.send({ type: "waypoint_add", waypoint });
this.notifyStateChange();
}
removeWaypoint(waypointId: string): void {
this.state.waypoints = this.state.waypoints.filter((w) => w.id !== waypointId);
this.send({ type: "waypoint_remove", waypointId });
this.notifyStateChange();
}
getState(): RoomState {
return { ...this.state };
}
}