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:
parent
192659b49c
commit
35dd1c3d77
|
|
@ -9,6 +9,31 @@
|
|||
* 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: '© <a href="https://carto.com/">CARTO</a> © <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 {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
|
|
@ -20,7 +45,7 @@ class FolkMapViewer extends HTMLElement {
|
|||
private syncStatus: "disconnected" | "connected" = "disconnected";
|
||||
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 vbY = 0;
|
||||
private vbW = 900;
|
||||
|
|
@ -35,6 +60,21 @@ class FolkMapViewer extends HTMLElement {
|
|||
private searchQuery = "";
|
||||
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() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
|
|
@ -47,13 +87,54 @@ class FolkMapViewer extends HTMLElement {
|
|||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
this.loadUserProfile();
|
||||
this.pushManager = new MapPushManager(this.getApiBase());
|
||||
if (this.room) {
|
||||
this.view = "map";
|
||||
this.joinRoom(this.room);
|
||||
return;
|
||||
}
|
||||
this.checkSyncHealth();
|
||||
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() {
|
||||
this.view = "map";
|
||||
this.room = "cosmolocal-providers";
|
||||
|
|
@ -772,6 +853,8 @@ class FolkMapViewer extends HTMLElement {
|
|||
}, { passive: true });
|
||||
}
|
||||
|
||||
// ─── Room mode: API / health ─────────────────────────────────
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^(\/[^/]+)?\/rmaps/);
|
||||
|
|
@ -806,10 +889,15 @@ class FolkMapViewer extends HTMLElement {
|
|||
this.render();
|
||||
}
|
||||
|
||||
// ─── Room mode: join / leave / create ────────────────────────
|
||||
|
||||
private joinRoom(slug: string) {
|
||||
if (!this.ensureUserProfile()) return;
|
||||
this.room = slug;
|
||||
this.view = "map";
|
||||
this.render();
|
||||
this.initMapView();
|
||||
this.initRoomSync();
|
||||
}
|
||||
|
||||
private createRoom() {
|
||||
|
|
@ -819,6 +907,343 @@ class FolkMapViewer extends HTMLElement {
|
|||
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;">🔔</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() {
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
|
|
@ -851,13 +1276,22 @@ class FolkMapViewer extends HTMLElement {
|
|||
.map-container {
|
||||
width: 100%; height: 500px; border-radius: 10px;
|
||||
background: #1a1a2e; border: 1px solid #333;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
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 {
|
||||
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; }
|
||||
|
||||
/* 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) {
|
||||
.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>
|
||||
|
||||
|
|
@ -901,6 +1384,26 @@ class FolkMapViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
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">🌐</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">🔔</button>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
` : "";
|
||||
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
${this.rooms.length > 0 ? this.rooms.map((r) => `
|
||||
<div class="room-card" data-room="${r}">
|
||||
<span class="room-icon">🗺</span>
|
||||
<span class="room-name">${this.esc(r)}</span>
|
||||
</div>
|
||||
`).join("") : ""}
|
||||
${this.rooms.length > 0 ? `
|
||||
<div class="section-label">Active Rooms</div>
|
||||
${this.rooms.map((r) => `
|
||||
<div class="room-card" data-room="${r}">
|
||||
<span class="room-icon">🗺</span>
|
||||
<span class="room-name">${this.esc(r)}</span>
|
||||
</div>
|
||||
`).join("")}
|
||||
` : ""}
|
||||
|
||||
${historyCards}
|
||||
|
||||
<div class="empty">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<div class="map-placeholder">
|
||||
<p style="font-size:48px">🌎</p>
|
||||
<p style="font-size:16px">Map Room: <strong>${this.esc(this.room)}</strong></p>
|
||||
<p>Connect the MapLibre GL library to display the interactive map.</p>
|
||||
<p style="font-size:12px;color:#555">WebSocket sync: ${this.syncStatus}</p>
|
||||
<div class="map-layout">
|
||||
<div class="map-main">
|
||||
<div class="map-container" id="map-container"></div>
|
||||
</div>
|
||||
<div class="map-sidebar">
|
||||
<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 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">📌 Drop Pin</button>
|
||||
<button class="ctrl-btn" id="copy-link">Copy Room Link</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -965,27 +1477,18 @@ class FolkMapViewer extends HTMLElement {
|
|||
|
||||
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
this.leaveRoom();
|
||||
this.view = "lobby";
|
||||
this.loadStats();
|
||||
});
|
||||
});
|
||||
|
||||
this.shadow.getElementById("share-location")?.addEventListener("click", () => {
|
||||
if ("geolocation" in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const btn = this.shadow.getElementById("share-location");
|
||||
if (btn) {
|
||||
btn.textContent = `Location: ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`;
|
||||
btn.classList.add("sharing");
|
||||
}
|
||||
},
|
||||
() => {
|
||||
this.error = "Location access denied";
|
||||
this.render();
|
||||
}
|
||||
);
|
||||
}
|
||||
this.toggleLocationSharing();
|
||||
});
|
||||
|
||||
this.shadow.getElementById("drop-waypoint")?.addEventListener("click", () => {
|
||||
this.dropWaypoint();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue