feat(rmaps): UX parity with rmaps-online — route overlay, ping cooldown, session persistence

Phase 1: Route double-layer outline, ScaleControl, route instruction summary,
Enter key in meeting search, select-all toggle in import preview, fitToParticipants FAB.
Phase 2: Ping All sidebar button, push vibrate+requireInteraction, 30s ping cooldown,
bundled QR code (qrcode package with external API fallback), ping toast with vibration.
Phase 3: Location persistence across sessions (<30min restore), auto-start sharing preference.
Also fix pre-existing TS error in community-sync.ts (bulkForget changes array typing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 18:25:46 -07:00
parent 496dff3c7f
commit 1eb4e1cb66
7 changed files with 140 additions and 14 deletions

View File

@ -770,7 +770,7 @@ export class CommunitySync extends EventTarget {
* Shapes already forgotten get hard-deleted; others get soft-forgotten. * Shapes already forgotten get hard-deleted; others get soft-forgotten.
*/ */
bulkForget(shapeIds: string[], did: string): void { bulkForget(shapeIds: string[], did: string): void {
const changes: Array<{ id: string; before: unknown; action: 'forget' | 'delete' }> = []; const changes: Array<{ id: string; before: ShapeData | null; action: 'forget' | 'delete' }> = [];
for (const id of shapeIds) { for (const id of shapeIds) {
const state = this.getShapeVisualState(id); const state = this.getShapeVisualState(id);
changes.push({ id, before: this.#cloneShapeData(id), action: state === 'forgotten' ? 'delete' : 'forget' }); changes.push({ id, before: this.#cloneShapeData(id), action: state === 'forgotten' ? 'delete' : 'forget' });

View File

@ -1075,11 +1075,24 @@ class FolkMapViewer extends HTMLElement {
this.room = slug; this.room = slug;
this.view = "map"; this.view = "map";
this.render(); this.render();
this.initMapView(); this.initMapView().then(() => this.restoreLastLocation());
this.initRoomSync(); this.initRoomSync();
this.initLocalFirstClient(); this.initLocalFirstClient();
// Periodically refresh staleness indicators // Periodically refresh staleness indicators
this.stalenessTimer = setInterval(() => this.refreshStaleness(), 15000); this.stalenessTimer = setInterval(() => this.refreshStaleness(), 15000);
// Auto-start sharing if user had it enabled previously
if (localStorage.getItem(`rmaps_sharing_${slug}`) === "true") {
setTimeout(() => this.toggleLocationSharing(), 500);
}
}
private restoreLastLocation() {
try {
const saved = JSON.parse(localStorage.getItem(`rmaps_loc_${this.room}`) || "null");
if (saved && this.map && Date.now() - saved.ts < 30 * 60 * 1000) {
this.map.flyTo({ center: [saved.lng, saved.lat], zoom: 13 });
}
} catch {}
} }
private async initLocalFirstClient() { private async initLocalFirstClient() {
@ -1173,6 +1186,7 @@ class FolkMapViewer extends HTMLElement {
}); });
this.map.addControl(new (window as any).maplibregl.NavigationControl(), "top-right"); this.map.addControl(new (window as any).maplibregl.NavigationControl(), "top-right");
this.map.addControl(new (window as any).maplibregl.ScaleControl(), "bottom-left");
// If inside a folk-shape and not in editing mode, disable map interactions // If inside a folk-shape and not in editing mode, disable map interactions
if (this._parentShape && !this._mapInteractive) { if (this._parentShape && !this._mapInteractive) {
@ -1250,11 +1264,14 @@ class FolkMapViewer extends HTMLElement {
navigator.serviceWorker.addEventListener("message", (event) => { navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data?.type === "LOCATION_REQUEST") { if (event.data?.type === "LOCATION_REQUEST") {
const reqRoom = event.data.data?.roomSlug; const reqRoom = event.data.data?.roomSlug;
if (reqRoom === this.room && this.sharingLocation) { if (reqRoom === this.room) {
// Already sharing — sync will propagate automatically this.showPingToast(event.data.data?.fromName || "Someone");
} else if (reqRoom === this.room && this.privacySettings.precision !== "hidden") { if (this.sharingLocation) {
// Not sharing yet — start sharing in response to ping // Already sharing — sync will propagate automatically
this.toggleLocationSharing(); } else if (this.privacySettings.precision !== "hidden") {
// Not sharing yet — start sharing in response to ping
this.toggleLocationSharing();
}
} }
} }
}); });
@ -1526,6 +1543,11 @@ class FolkMapViewer extends HTMLElement {
this.renderNavigationPanel(); this.renderNavigationPanel();
}); });
}); });
container.querySelector("#sidebar-ping-all-btn")?.addEventListener("click", () => {
this.pushManager?.requestLocation(this.room, "all");
const btn = container.querySelector("#sidebar-ping-all-btn");
if (btn) { btn.textContent = "\u2713 Pinged!"; setTimeout(() => { btn.textContent = "\u{1F4E2} Ping All"; }, 2000); }
});
container.querySelector("#sidebar-meeting-btn")?.addEventListener("click", () => { container.querySelector("#sidebar-meeting-btn")?.addEventListener("click", () => {
this.showMeetingModal = true; this.showMeetingModal = true;
this.renderMeetingPointModal(); this.renderMeetingPointModal();
@ -1545,6 +1567,7 @@ class FolkMapViewer extends HTMLElement {
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap;"> <div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap;">
<button id="sidebar-meeting-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4CD} Meeting Point</button> <button id="sidebar-meeting-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4CD} Meeting Point</button>
<button id="sidebar-import-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4E5} Import Places</button> <button id="sidebar-import-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4E5} Import Places</button>
<button id="sidebar-ping-all-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4E2} Ping All</button>
</div> </div>
`; `;
@ -1645,6 +1668,7 @@ class FolkMapViewer extends HTMLElement {
this.sharingLocation = false; this.sharingLocation = false;
this.geoTimeoutCount = 0; this.geoTimeoutCount = 0;
this.sync?.clearLocation(); this.sync?.clearLocation();
try { localStorage.removeItem(`rmaps_sharing_${this.room}`); } catch {}
this.updateShareButton(); this.updateShareButton();
return; return;
} }
@ -1686,6 +1710,12 @@ class FolkMapViewer extends HTMLElement {
}; };
this.sync?.updateLocation(loc); this.sync?.updateLocation(loc);
// Persist location + auto-share preference for session restore
try {
localStorage.setItem(`rmaps_loc_${this.room}`, JSON.stringify({ lat: pos.coords.latitude, lng: pos.coords.longitude, ts: Date.now() }));
localStorage.setItem(`rmaps_sharing_${this.room}`, "true");
} catch {}
if (firstFix && this.map) { if (firstFix && this.map) {
this.map.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 14 }); this.map.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 14 });
firstFix = false; firstFix = false;
@ -1802,6 +1832,27 @@ class FolkMapViewer extends HTMLElement {
} }
} }
private fitToParticipants() {
if (!this.map || !(window as any).maplibregl) return;
const state = this.sync?.getState();
if (!state) return;
const bounds = new (window as any).maplibregl.LngLatBounds();
let count = 0;
for (const p of Object.values(state.participants)) {
if (p.location) {
bounds.extend([p.location.longitude, p.location.latitude]);
count++;
}
}
if (count < 1) return;
if (count === 1) {
const first = Object.values(state.participants).find(p => p.location)!;
this.map.flyTo({ center: [first.location!.longitude, first.location!.latitude], zoom: 14 });
} else {
this.map.fitBounds(bounds, { padding: 60, maxZoom: 16 });
}
}
private renderPrivacyPanel(container?: HTMLElement) { private renderPrivacyPanel(container?: HTMLElement) {
const panel = container || this.shadow.getElementById("privacy-panel"); const panel = container || this.shadow.getElementById("privacy-panel");
if (!panel) return; if (!panel) return;
@ -1906,6 +1957,7 @@ class FolkMapViewer extends HTMLElement {
route.segments.forEach((seg, i) => { route.segments.forEach((seg, i) => {
const sourceId = `route-seg-${i}`; const sourceId = `route-seg-${i}`;
const outlineLayerId = `route-layer-outline-${i}`;
const layerId = `route-layer-${i}`; const layerId = `route-layer-${i}`;
this.map.addSource(sourceId, { this.map.addSource(sourceId, {
type: "geojson", type: "geojson",
@ -1915,6 +1967,14 @@ class FolkMapViewer extends HTMLElement {
geometry: { type: "LineString", coordinates: seg.coordinates }, geometry: { type: "LineString", coordinates: seg.coordinates },
}, },
}); });
// Outline layer (dark stroke behind colored line)
this.map.addLayer({
id: outlineLayerId,
type: "line",
source: sourceId,
layout: { "line-join": "round", "line-cap": "round" },
paint: { "line-color": "#1e293b", "line-width": 8, "line-opacity": 0.6 },
});
this.map.addLayer({ this.map.addLayer({
id: layerId, id: layerId,
type: "line", type: "line",
@ -1935,9 +1995,10 @@ class FolkMapViewer extends HTMLElement {
private clearRoute() { private clearRoute() {
if (!this.map) return; if (!this.map) return;
// Remove all route layers/sources // Remove all route layers/sources (including outline layers)
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
try { this.map.removeLayer(`route-layer-${i}`); } catch {} try { this.map.removeLayer(`route-layer-${i}`); } catch {}
try { this.map.removeLayer(`route-layer-outline-${i}`); } catch {}
try { this.map.removeSource(`route-seg-${i}`); } catch {} try { this.map.removeSource(`route-seg-${i}`); } catch {}
} }
this.activeRoute = null; this.activeRoute = null;
@ -1958,6 +2019,16 @@ class FolkMapViewer extends HTMLElement {
} }
} }
private formatRouteInstructions(segments: any[]): string {
const parts: string[] = [];
for (const seg of segments) {
const label = seg.type === "indoor" ? "indoors" : seg.type === "transition" ? "transition" : "outdoors";
parts.push(`${formatDistance(seg.distance)} ${label}`);
}
if (parts.length <= 1) return parts[0] ? `Walk ${parts[0]}` : "";
return `Walk ${parts.join(", then ")}`;
}
private renderRoutePanel() { private renderRoutePanel() {
if (!this.activeRoute) return; if (!this.activeRoute) return;
let routePanel = this.shadow.getElementById("route-panel"); let routePanel = this.shadow.getElementById("route-panel");
@ -1982,10 +2053,11 @@ class FolkMapViewer extends HTMLElement {
</div> </div>
<button id="close-route" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:16px;">\u2715</button> <button id="close-route" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:16px;">\u2715</button>
</div> </div>
<div style="display:flex;gap:16px;font-size:13px;color:var(--rs-text-secondary);margin-bottom:8px;"> <div style="display:flex;gap:16px;font-size:13px;color:var(--rs-text-secondary);margin-bottom:4px;">
<span>\u{1F4CF} ${formatDistance(this.activeRoute.totalDistance)}</span> <span>\u{1F4CF} ${formatDistance(this.activeRoute.totalDistance)}</span>
<span>\u{23F1} ${formatTime(this.activeRoute.estimatedTime)}</span> <span>\u{23F1} ${formatTime(this.activeRoute.estimatedTime)}</span>
</div> </div>
<div style="font-size:11px;color:var(--rs-text-muted);margin-bottom:8px;">${this.formatRouteInstructions(this.activeRoute.segments)}</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;"> <div style="display:flex;gap:6px;flex-wrap:wrap;">
${this.activeRoute.segments.map(seg => ` ${this.activeRoute.segments.map(seg => `
<span style="font-size:10px;padding:2px 8px;border-radius:10px;border:1px solid ${segTypeColors[seg.type] || '#666'}40;color:${segTypeColors[seg.type] || '#666'};font-weight:500;"> <span style="font-size:10px;padding:2px 8px;border-radius:10px;border:1px solid ${segTypeColors[seg.type] || '#666'}40;color:${segTypeColors[seg.type] || '#666'};font-weight:500;">
@ -2035,6 +2107,21 @@ class FolkMapViewer extends HTMLElement {
setTimeout(dismiss, 10000); setTimeout(dismiss, 10000);
} }
private showPingToast(fromName: string) {
// Vibrate if available
if ("vibrate" in navigator) navigator.vibrate([200, 100, 200]);
const toast = document.createElement("div");
toast.style.cssText = `
position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:100;
background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);
border-radius:12px;padding:10px 16px;box-shadow:0 8px 24px rgba(0,0,0,0.4);
display:flex;align-items:center;gap:8px;max-width:300px;
`;
toast.innerHTML = `<span style="font-size:16px">\u{1F4E2}</span><span style="font-size:13px;color:var(--rs-text-primary);">${this.esc(fromName)} pinged for your location</span>`;
this.shadow.appendChild(toast);
setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 200); }, 4000);
}
// ─── Chat badge ───────────────────────────────────────────── // ─── Chat badge ─────────────────────────────────────────────
private updateChatBadge() { private updateChatBadge() {
@ -2616,6 +2703,7 @@ class FolkMapViewer extends HTMLElement {
<!-- Locate-me FAB (visible on all viewports) --> <!-- Locate-me FAB (visible on all viewports) -->
<button class="map-locate-fab" id="locate-me-fab" title="Center on my location">\u{1F3AF}</button> <button class="map-locate-fab" id="locate-me-fab" title="Center on my location">\u{1F3AF}</button>
<button class="map-locate-fab" id="fit-all-fab" title="Show all participants" style="bottom:80px;">\u{1F465}</button>
<!-- Mobile FAB menu --> <!-- Mobile FAB menu -->
<div class="mobile-fab-container" id="mobile-fab-container"> <div class="mobile-fab-container" id="mobile-fab-container">
@ -2772,6 +2860,10 @@ class FolkMapViewer extends HTMLElement {
this.shadow.getElementById("locate-me-fab")?.addEventListener("click", () => { this.shadow.getElementById("locate-me-fab")?.addEventListener("click", () => {
this.locateMe(); this.locateMe();
}); });
// Fit-all FAB
this.shadow.getElementById("fit-all-fab")?.addEventListener("click", () => {
this.fitToParticipants();
});
// Mobile FAB menu // Mobile FAB menu
this.shadow.getElementById("fab-main")?.addEventListener("click", () => { this.shadow.getElementById("fab-main")?.addEventListener("click", () => {

View File

@ -132,7 +132,10 @@ class MapImportModal extends HTMLElement {
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:24px;max-width:420px;width:90%;max-height:80vh;overflow-y:auto;"> <div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:24px;max-width:420px;width:90%;max-height:80vh;overflow-y:auto;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<div style="font-size:16px;font-weight:600;color:var(--rs-text-primary);">Preview (${this._places.length} places)</div> <div style="font-size:16px;font-weight:600;color:var(--rs-text-primary);">Preview (${this._places.length} places)</div>
<button id="i-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button> <div style="display:flex;gap:6px;align-items:center;">
<button id="i-toggle-all" style="padding:4px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;">${count === this._places.length ? "Deselect all" : "Select all"}</button>
<button id="i-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
</div> </div>
<div style="margin-bottom:12px;"> <div style="margin-bottom:12px;">
${this._places.map((p, i) => ` ${this._places.map((p, i) => `
@ -157,6 +160,13 @@ class MapImportModal extends HTMLElement {
}); });
}); });
this.querySelector("#i-toggle-all")?.addEventListener("click", () => {
const allSelected = this._places.every(p => p.selected);
this._places.forEach(p => p.selected = !allSelected);
this._step = "preview";
this.render();
});
this.querySelector("#i-confirm")?.addEventListener("click", () => { this.querySelector("#i-confirm")?.addEventListener("click", () => {
const selected = this._places.filter(p => p.selected).map(({ name, lat, lng }) => ({ name, lat, lng })); const selected = this._places.filter(p => p.selected).map(({ name, lat, lng }) => ({ name, lat, lng }));
this.dispatchEvent(new CustomEvent("import-places", { detail: { places: selected }, bubbles: true, composed: true })); this.dispatchEvent(new CustomEvent("import-places", { detail: { places: selected }, bubbles: true, composed: true }));

View File

@ -103,6 +103,9 @@ class MapMeetingModal extends HTMLElement {
</div> </div>
<div id="m-sr"></div> <div id="m-sr"></div>
`; `;
this.querySelector("#m-addr")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") { (this.querySelector("#m-addr-go") as HTMLElement)?.click(); }
});
this.querySelector("#m-addr-go")?.addEventListener("click", async () => { this.querySelector("#m-addr-go")?.addEventListener("click", async () => {
const q = (this.querySelector("#m-addr") as HTMLInputElement).value.trim(); const q = (this.querySelector("#m-addr") as HTMLInputElement).value.trim();
if (!q) return; if (!q) return;

View File

@ -10,6 +10,7 @@ export class MapPushManager {
private registration: ServiceWorkerRegistration | null = null; private registration: ServiceWorkerRegistration | null = null;
private subscription: PushSubscription | null = null; private subscription: PushSubscription | null = null;
private _subscribed = false; private _subscribed = false;
private _pingCooldowns: Map<string, number> = new Map();
constructor(apiBase: string) { constructor(apiBase: string) {
this.apiBase = apiBase; this.apiBase = apiBase;
@ -133,6 +134,12 @@ export class MapPushManager {
} }
async requestLocation(roomSlug: string, participantId: string): Promise<boolean> { async requestLocation(roomSlug: string, participantId: string): Promise<boolean> {
// 30s cooldown per participant
const cooldownKey = `${roomSlug}:${participantId}`;
const lastPing = this._pingCooldowns.get(cooldownKey);
if (lastPing && Date.now() - lastPing < 30000) return false;
this._pingCooldowns.set(cooldownKey, Date.now());
try { try {
const res = await fetch(`${this.apiBase}/api/push/request-location`, { const res = await fetch(`${this.apiBase}/api/push/request-location`, {
method: "POST", method: "POST",

View File

@ -42,11 +42,23 @@ class MapShareModal extends HTMLElement {
</div> </div>
`; `;
// QR code via public API (no Node.js dependency) // QR code via bundled qrcode package (canvas), with external API fallback
const qr = this.querySelector("#s-qr"); const qr = this.querySelector("#s-qr");
if (qr) { if (qr) {
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(this._url)}`; try {
qr.innerHTML = `<img src="${qrSrc}" alt="QR Code" style="width:200px;height:200px;border-radius:8px;" onerror="this.parentElement.innerHTML='<div style=\\'font-size:12px;color:var(--rs-text-muted)\\'>QR code unavailable</div>'">`; const QRCode = (await import("qrcode")).default;
const canvas = document.createElement("canvas");
canvas.width = 200;
canvas.height = 200;
canvas.style.cssText = "width:200px;height:200px;border-radius:8px;";
await QRCode.toCanvas(canvas, this._url, { width: 200, margin: 2 });
qr.innerHTML = "";
qr.appendChild(canvas);
} catch {
// Fallback to external API
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(this._url)}`;
qr.innerHTML = `<img src="${qrSrc}" alt="QR Code" style="width:200px;height:200px;border-radius:8px;" onerror="this.parentElement.innerHTML='<div style=\\'font-size:12px;color:var(--rs-text-muted)\\'>QR code unavailable</div>'">`;
}
} }
// Events // Events

View File

@ -382,8 +382,10 @@ self.addEventListener("push", (event) => {
icon: "/icons/icon-192.png", icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png", badge: "/icons/icon-192.png",
tag: "rmaps-location-request", tag: "rmaps-location-request",
requireInteraction: true,
vibrate: [200, 100, 200],
data: { ...payload.data, type: "location_request" }, data: { ...payload.data, type: "location_request" },
}); } as NotificationOptions);
// Notify open clients to auto-share if enabled // Notify open clients to auto-share if enabled
const clients = await self.clients.matchAll({ type: "window" }); const clients = await self.clients.matchAll({ type: "window" });