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.
|
* 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 {
|
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;">🔔</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">🌐</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 `
|
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">🗺</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">🗺</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">🌎</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">📌 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 {
|
||||||
|
|
|
||||||
|
|
@ -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