rspace-online/modules/rmaps/components/folk-map-viewer.ts

2505 lines
100 KiB
TypeScript

/**
* <folk-map-viewer> -- real-time location sharing map.
*
* Creates/joins map rooms, shows participant locations on a map,
* and provides location sharing controls.
*
* Demo mode: interactive SVG world map with zoom/pan, 6 cosmolocal
* print providers, connection arcs, city-level detail views, tooltips,
* and feature highlights matching standalone rMaps capabilities.
*/
import { RoomSync, type RoomState, type ParticipantState, type LocationState, type ParticipantStatus, type PrecisionLevel, type PrivacySettings, type WaypointType } from "./map-sync";
import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history";
import { MapPushManager } from "./map-push";
import { fuzzLocation, haversineDistance, formatDistance, formatTime } from "./map-privacy";
import { parseGoogleMapsGeoJSON, type ParsedPlace } from "./map-import";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
import { requireAuth } from "../../../shared/auth-fetch";
import { getUsername } from "../../../shared/components/rstack-identity";
// 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 OSM_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
const DARK_STYLE = {
version: 8,
sources: {
osm: {
type: "raster",
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
tileSize: 256,
attribution: OSM_ATTRIBUTION,
maxzoom: 19,
},
},
layers: [{ id: "osm", type: "raster", source: "osm" }],
};
const LIGHT_STYLE = {
version: 8,
sources: {
osm: {
type: "raster",
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
tileSize: 256,
attribution: OSM_ATTRIBUTION,
maxzoom: 19,
},
},
layers: [{ id: "osm", type: "raster", source: "osm" }],
};
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 = "";
private room = "";
private view: "lobby" | "map" = "lobby";
private rooms: string[] = [];
private loading = false;
private error = "";
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 (demo mode)
private vbX = 0;
private vbY = 0;
private vbW = 900;
private vbH = 460;
private isDragging = false;
private dragStartX = 0;
private dragStartY = 0;
private dragVbX = 0;
private dragVbY = 0;
private zoomLevel = 1;
private selectedProvider = -1;
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 privacySettings: PrivacySettings = { precision: "exact", ghostMode: false };
private showPrivacyPanel = false;
private geoPermissionState: PermissionState | "" = "";
private geoTimeoutCount = 0;
// Modals/panels state
private showShareModal = false;
private showMeetingModal = false;
private showImportModal = false;
private selectedParticipant: string | null = null;
private selectedWaypoint: string | null = null;
private activeRoute: { segments: any[]; totalDistance: number; estimatedTime: number; destination: string } | null = null;
private meetingSearchResults: { display_name: string; lat: string; lon: string }[] = [];
private meetingSearchQuery = "";
private importParsedPlaces: { name: string; lat: number; lng: number; selected: boolean }[] = [];
private importStep: "upload" | "preview" | "done" = "upload";
private thumbnailTimer: ReturnType<typeof setTimeout> | null = null;
private _themeObserver: MutationObserver | null = null;
private _history = new ViewHistory<"lobby" | "map">("lobby");
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '#create-room', title: "Create a Room", message: "Start a new map room to share locations with others in real time.", advanceOnClick: true },
{ target: '.room-card, .history-card', title: "Join a Room", message: "Click any room card to enter and see participants on the map.", advanceOnClick: false },
{ target: '#share-location', title: "Share Location", message: "Toggle location sharing so others in the room can see where you are.", advanceOnClick: false },
{ target: '#drop-waypoint', title: "Drop a Pin", message: "Drop a waypoint pin on the map to mark a point of interest for everyone.", advanceOnClick: false },
];
private isDarkTheme(): boolean {
const theme = document.documentElement.getAttribute("data-theme");
if (theme) return theme === "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkMapViewer.TOUR_STEPS,
"rmaps_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.room = this.getAttribute("room") || "";
if (this.space === "demo") {
this.loadDemoData();
} else {
this.loadUserProfile();
this.pushManager = new MapPushManager(this.getApiBase());
if (this.room) {
this.joinRoom(this.room);
} else {
this.checkSyncHealth();
this.render();
}
}
if (!localStorage.getItem("rmaps_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
this.leaveRoom();
if (this._themeObserver) {
this._themeObserver.disconnect();
this._themeObserver = null;
}
}
// ─── 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;
// Use EncryptID username if authenticated
const identityName = getUsername();
const name = identityName || 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";
this.syncStatus = "connected";
// Re-render demo on theme change
this._themeObserver = new MutationObserver(() => this.renderDemo());
this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
this.providers = [
{
name: "Radiant Hall Press", city: "Pittsburgh", country: "USA",
lat: 40.44, lng: -79.99, color: "#ef4444",
desc: "Worker-owned letterpress and risograph studio specializing in art prints and zines.",
specialties: ["Letterpress", "Risograph", "Zines"],
},
{
name: "Tiny Splendor", city: "Los Angeles", country: "USA",
lat: 34.05, lng: -118.24, color: "#f59e0b",
desc: "Artist-run collective creating hand-pulled screen prints and artist books.",
specialties: ["Screen Print", "Artist Books", "Posters"],
},
{
name: "People's Print Shop", city: "Toronto", country: "Canada",
lat: 43.65, lng: -79.38, color: "#22c55e",
desc: "Community print shop offering affordable risograph and offset printing for social movements.",
specialties: ["Risograph", "Offset", "Community"],
},
{
name: "Colour Code Press", city: "London", country: "UK",
lat: 51.51, lng: -0.13, color: "#3b82f6",
desc: "Independent risograph studio in East London, specializing in publications and packaging.",
specialties: ["Risograph", "Publications", "Packaging"],
},
{
name: "Druckwerkstatt Berlin", city: "Berlin", country: "Germany",
lat: 52.52, lng: 13.40, color: "#8b5cf6",
desc: "Open-access printmaking workshop in Kreuzberg with letterpress, screen print, and risograph.",
specialties: ["Letterpress", "Screen Print", "Risograph"],
},
{
name: "Kink\u014D Printing Collective", city: "Tokyo", country: "Japan",
lat: 35.68, lng: 139.69, color: "#ec4899",
desc: "Tokyo-based collective blending traditional Japanese woodblock with modern risograph techniques.",
specialties: ["Woodblock", "Risograph", "Limited Editions"],
},
];
this.renderDemo();
}
private getFilteredProviders() {
if (!this.searchQuery.trim()) return this.providers.map((p, i) => ({ provider: p, index: i }));
const q = this.searchQuery.toLowerCase();
return this.providers.map((p, i) => ({ provider: p, index: i }))
.filter(({ provider: p }) =>
p.name.toLowerCase().includes(q) ||
p.city.toLowerCase().includes(q) ||
p.country.toLowerCase().includes(q) ||
p.specialties.some(s => s.toLowerCase().includes(q))
);
}
private renderDemo() {
const W = 900;
const H = 460;
const dark = this.isDarkTheme();
// Theme-aware SVG colors (can't use CSS vars in SVG fill/stroke)
const oceanStop1 = dark ? "#0f1b33" : "#d4e5f7";
const oceanStop2 = dark ? "#060d1a" : "#e8f0f8";
const pinStroke = dark ? "#0f172a" : "#f5f5f0";
const continentFill = dark ? "#162236" : "#c8d8c0";
const continentStroke = dark ? "#1e3050" : "#a0b898";
const graticuleLine = dark ? "#1a2744" : "#c0d0e0";
const graticuleStrong = dark ? "#1e3050" : "#a8b8c8";
const cityColor = dark ? "#64748b" : "#6b7280";
const coordColor = dark ? "#4a5568" : "#6b7280";
const px = (lng: number) => ((lng + 180) / 360) * W;
const py = (lat: number) => ((90 - lat) / 180) * H;
const filteredSet = new Set(this.getFilteredProviders().map(f => f.index));
// Label offsets to avoid overlapping
const labelOffsets: Record<string, [number, number]> = {
"Radiant Hall Press": [10, -8],
"Tiny Splendor": [-110, 14],
"People's Print Shop": [10, 14],
"Colour Code Press": [10, -8],
"Druckwerkstatt Berlin": [10, 14],
"Kink\u014D Printing Collective": [-150, -8],
};
// Connection arcs between providers
const connections = [
[0, 2], [0, 3], [3, 4], [4, 5], [1, 5], [0, 1],
];
const arcs = connections.map(([i, j]) => {
const a = this.providers[i];
const b = this.providers[j];
const x1 = px(a.lng), y1 = py(a.lat);
const x2 = px(b.lng), y2 = py(b.lat);
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2 - Math.abs(x2 - x1) * 0.12;
return `<path d="M${x1},${y1} Q${mx},${my} ${x2},${y2}" fill="none" stroke="rgba(148,163,184,0.15)" stroke-width="1" stroke-dasharray="4,3" />`;
}).join("\n");
// Provider pins
// User location pin
const userPin = this.userLocation ? (() => {
const ux = px(this.userLocation!.lng);
const uy = py(this.userLocation!.lat);
return `
<g>
<circle cx="${ux}" cy="${uy}" r="6" fill="#22c55e" stroke="#fff" stroke-width="2" opacity="0.9">
<animate attributeName="r" values="6;10;6" dur="2s" repeatCount="indefinite" />
</circle>
<text x="${ux + 10}" y="${uy + 4}" fill="#22c55e" font-size="9" font-weight="600" font-family="system-ui,sans-serif">You</text>
</g>`;
})() : "";
const pins = this.providers.map((p, i) => {
const x = px(p.lng);
const y = py(p.lat);
const [lx, ly] = labelOffsets[p.name] || [10, 4];
const isSelected = this.selectedProvider === i;
const isDimmed = this.searchQuery.trim() && !filteredSet.has(i);
return `
<g class="pin-group" data-idx="${i}" style="cursor:pointer;${isDimmed ? "opacity:0.2" : ""}">
<circle cx="${x}" cy="${y}" r="4" fill="none" stroke="${p.color}" stroke-width="1" opacity="0.5">
<animate attributeName="r" values="4;14;4" dur="3s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0;0.5" dur="3s" repeatCount="indefinite" />
</circle>
${isSelected ? `<circle cx="${x}" cy="${y}" r="18" fill="none" stroke="${p.color}" stroke-width="2" opacity="0.6" />` : ""}
<path d="M${x},${y - 2} c0,-7 -6,-12 -6,-16 a6,6 0 1,1 12,0 c0,4 -6,9 -6,16z" fill="${p.color}" stroke="${pinStroke}" stroke-width="0.8" opacity="0.92" />
<circle cx="${x}" cy="${y - 14}" r="2.5" fill="#fff" opacity="0.85" />
<text x="${x + lx}" y="${y + ly}" fill="${p.color}" font-size="10" font-weight="600" font-family="system-ui,sans-serif" opacity="0.9">${this.esc(p.name)}</text>
<text x="${x + lx}" y="${y + ly + 12}" fill="${cityColor}" font-size="8.5" font-family="system-ui,sans-serif">${this.esc(p.city)}, ${this.esc(p.country)}</text>
</g>
`;
}).join("");
// Legend items
const legendItems = this.providers.map((p, i) => `
<div class="legend-item ${this.selectedProvider === i ? "selected" : ""}" data-legend="${i}">
<div style="width:10px;height:10px;border-radius:50%;background:${p.color};flex-shrink:0;box-shadow:0 0 6px ${p.color}40;"></div>
<div style="flex:1;min-width:0">
<span style="font-weight:600;font-size:13px;color:var(--rs-text-primary);">${this.esc(p.name)}</span>
<span style="font-size:12px;color:var(--rs-text-muted);margin-left:8px;">${this.esc(p.city)}, ${this.esc(p.country)}</span>
</div>
</div>
`).join("");
// Provider detail panel (shown when selected)
let detailPanel = "";
if (this.selectedProvider >= 0 && this.selectedProvider < this.providers.length) {
const sp = this.providers[this.selectedProvider];
detailPanel = `
<div class="detail-panel">
<div class="detail-header">
<div style="width:14px;height:14px;border-radius:50%;background:${sp.color};flex-shrink:0;box-shadow:0 0 8px ${sp.color}60;"></div>
<div style="flex:1">
<div style="font-size:15px;font-weight:600;color:var(--rs-text-primary);">${this.esc(sp.name)}</div>
<div style="font-size:12px;color:var(--rs-text-secondary);">${this.esc(sp.city)}, ${this.esc(sp.country)}</div>
</div>
<button class="detail-close" id="detail-close">\u2715</button>
</div>
<p style="font-size:13px;color:var(--rs-text-secondary);line-height:1.5;margin:10px 0;">${this.esc(sp.desc)}</p>
<div class="detail-tags">
${sp.specialties.map(s => `<span class="detail-tag" style="border-color:${sp.color}40;color:${sp.color}">${this.esc(s)}</span>`).join("")}
</div>
<div style="font-size:11px;color:var(--rs-text-muted);margin-top:10px;font-family:monospace;">
${sp.lat.toFixed(4)}\u00B0N, ${Math.abs(sp.lng).toFixed(4)}\u00B0${sp.lng >= 0 ? "E" : "W"}
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<a href="https://www.openstreetmap.org/?mlat=${sp.lat}&mlon=${sp.lng}#map=15/${sp.lat}/${sp.lng}" target="_blank" rel="noopener" style="flex:1;text-align:center;padding:8px;border-radius:6px;background:#4f46e520;border:1px solid #4f46e540;color:#818cf8;font-size:12px;font-weight:600;text-decoration:none;">Get Directions</a>
</div>
</div>`;
}
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
* { box-sizing: border-box; }
.demo-nav {
display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; flex-wrap: wrap;
}
.demo-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); min-width: 140px; }
.demo-nav__badge {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; color: var(--rs-text-secondary); background: rgba(16,185,129,0.1);
border: 1px solid rgba(16,185,129,0.2); border-radius: 20px; padding: 3px 10px;
}
.demo-nav__badge .dot { width: 6px; height: 6px; border-radius: 50%; background: #22c55e; }
/* Zoom controls */
.zoom-controls {
display: flex; gap: 4px; align-items: center;
}
.zoom-btn {
width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--rs-border);
background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 16px;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.zoom-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
.zoom-label { font-size: 10px; color: var(--rs-text-muted); font-variant-numeric: tabular-nums; min-width: 32px; text-align: center; }
.search-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.search-input {
flex: 1; border: 1px solid var(--rs-border); border-radius: 8px; padding: 8px 12px;
background: var(--rs-input-bg); color: var(--rs-text-primary); font-size: 13px; outline: none;
}
.search-input:focus { border-color: #6366f1; }
.search-input::placeholder { color: var(--rs-text-muted); }
.geo-btn {
padding: 8px 14px; border-radius: 8px; border: 1px solid var(--rs-border);
background: var(--rs-input-bg); color: var(--rs-text-secondary); cursor: pointer; font-size: 12px; white-space: nowrap;
}
.geo-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
.geo-btn.active { border-color: #22c55e; color: #22c55e; }
.map-wrap {
width: 100%; border-radius: 12px; background: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border);
overflow: hidden; position: relative; cursor: grab;
}
.map-wrap.dragging { cursor: grabbing; }
.map-svg { display: block; width: 100%; height: auto; user-select: none; }
/* Hover tooltip */
.pin-group { cursor: pointer; }
.tooltip {
position: absolute; pointer-events: none; opacity: 0; transition: opacity 0.15s;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 8px;
padding: 8px 12px; font-size: 12px; color: var(--rs-text-primary); white-space: nowrap;
box-shadow: 0 4px 12px rgba(0,0,0,0.4); z-index: 10;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 2px; }
.tooltip .city { color: var(--rs-text-secondary); font-size: 11px; }
.tooltip .coords { color: var(--rs-text-muted); font-size: 10px; font-family: monospace; }
/* Legend */
.legend {
background: var(--rs-glass-bg); border: 1px solid var(--rs-border); border-radius: 10px;
padding: 16px; margin-top: 16px;
}
.legend-title {
font-size: 12px; font-weight: 600; color: var(--rs-text-secondary);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;
}
.legend-item {
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
border-radius: 6px; cursor: pointer; transition: background 0.15s;
}
.legend-item:hover { background: rgba(255,255,255,0.04); }
.legend-item.selected { background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); }
/* Detail panel */
.detail-panel {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 10px;
padding: 16px; margin-top: 12px;
}
.detail-header { display: flex; align-items: center; gap: 10px; }
.detail-close { background: none; border: none; color: var(--rs-text-muted); font-size: 16px; cursor: pointer; padding: 4px; }
.detail-close:hover { color: var(--rs-text-primary); }
.detail-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.detail-tag {
font-size: 10px; padding: 3px 8px; border-radius: 10px;
border: 1px solid var(--rs-border); font-weight: 500;
}
/* Feature highlights row */
.features {
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px; margin-top: 16px;
}
.feat {
background: var(--rs-glass-bg); border: 1px solid var(--rs-border); border-radius: 10px;
padding: 12px; text-align: center;
}
.feat-icon { font-size: 20px; margin-bottom: 4px; }
.feat-label { font-size: 12px; font-weight: 600; color: var(--rs-text-primary); }
.feat-desc { font-size: 10.5px; color: var(--rs-text-muted); margin-top: 2px; line-height: 1.4; }
@media (max-width: 640px) {
.features { grid-template-columns: repeat(2, 1fr); }
.zoom-controls { gap: 2px; }
}
</style>
<div class="demo-nav">
<span class="demo-nav__title">Cosmolocal Print Network</span>
<div class="zoom-controls">
<button class="zoom-btn" id="zoom-out" title="Zoom out">\u2212</button>
<span class="zoom-label">${Math.round(this.zoomLevel * 100)}%</span>
<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
<button class="zoom-btn" id="zoom-reset" title="Reset view" style="font-size:12px">\u21BA</button>
</div>
<span class="demo-nav__badge"><span class="dot"></span> ${this.providers.length} providers online</span>
</div>
<div class="search-bar">
<input class="search-input" type="text" id="map-search" placeholder="Search providers by name, city, or specialty..." value="${this.esc(this.searchQuery)}">
<button class="geo-btn ${this.userLocation ? "active" : ""}" id="share-geo">${this.userLocation ? "\u{1F4CD} Sharing" : "\u{1F4CD} Share Location"}</button>
</div>
<div class="map-wrap" id="map-wrap">
<div class="tooltip" id="tooltip"></div>
<svg class="map-svg" id="map-svg" viewBox="${this.vbX} ${this.vbY} ${this.vbW} ${this.vbH}" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="ocean" cx="50%" cy="40%" r="70%">
<stop offset="0%" stop-color="${oceanStop1}" />
<stop offset="100%" stop-color="${oceanStop2}" />
</radialGradient>
</defs>
<!-- Ocean background -->
<rect x="-200" y="-200" width="${W + 400}" height="${H + 400}" fill="url(#ocean)" />
<!-- Graticule -->
${this.graticule(W, H, graticuleLine, graticuleStrong)}
<!-- Continents -->
${this.continents(W, H, continentFill, continentStroke)}
<!-- Connection arcs -->
${arcs}
<!-- Provider pins -->
${pins}
<!-- User location -->
${userPin}
</svg>
</div>
${detailPanel}
<div class="legend">
<div class="legend-title">Print Providers \u2014 click to explore</div>
${legendItems}
</div>
<div class="features">
<div class="feat">
<div class="feat-icon">&#128205;</div>
<div class="feat-label">Live GPS</div>
<div class="feat-desc">Real-time location sharing via WebSocket</div>
</div>
<div class="feat">
<div class="feat-icon">&#127970;</div>
<div class="feat-label">Indoor Nav</div>
<div class="feat-desc">c3nav integration for CCC events</div>
</div>
<div class="feat">
<div class="feat-icon">&#128204;</div>
<div class="feat-label">Waypoints</div>
<div class="feat-desc">Drop meeting points and pins</div>
</div>
<div class="feat">
<div class="feat-icon">&#128279;</div>
<div class="feat-label">QR Sharing</div>
<div class="feat-desc">Scan to join any room instantly</div>
</div>
<div class="feat">
<div class="feat-icon">&#128225;</div>
<div class="feat-label">Ping Friends</div>
<div class="feat-desc">Request location with one tap</div>
</div>
<div class="feat">
<div class="feat-icon">&#128737;</div>
<div class="feat-label">Privacy First</div>
<div class="feat-desc">Ghost mode, precision levels, zero tracking</div>
</div>
</div>
`;
this.attachDemoListeners();
}
private zoomTo(lat: number, lng: number, level: number) {
const W = 900, H = 460;
const cx = ((lng + 180) / 360) * W;
const cy = ((90 - lat) / 180) * H;
this.zoomLevel = level;
this.vbW = W / level;
this.vbH = H / level;
this.vbX = cx - this.vbW / 2;
this.vbY = cy - this.vbH / 2;
// Clamp
this.vbX = Math.max(-100, Math.min(W - this.vbW + 100, this.vbX));
this.vbY = Math.max(-100, Math.min(H - this.vbH + 100, this.vbY));
this.renderDemo();
}
private resetZoom() {
this.vbX = 0;
this.vbY = 0;
this.vbW = 900;
this.vbH = 460;
this.zoomLevel = 1;
this.selectedProvider = -1;
this.renderDemo();
}
/** Generate SVG graticule lines */
private graticule(W: number, H: number, lineColor: string, strongColor: string): string {
const lines: string[] = [];
for (let lat = -60; lat <= 60; lat += 30) {
const y = ((90 - lat) / 180) * H;
lines.push(`<line x1="-200" y1="${y}" x2="${W + 200}" y2="${y}" stroke="${lineColor}" stroke-width="0.5" stroke-dasharray="3,5" />`);
}
for (let lng = -150; lng <= 180; lng += 30) {
const x = ((lng + 180) / 360) * W;
lines.push(`<line x1="${x}" y1="-200" x2="${x}" y2="${H + 200}" stroke="${lineColor}" stroke-width="0.5" stroke-dasharray="3,5" />`);
}
const eq = ((90 - 0) / 180) * H;
const pm = ((0 + 180) / 360) * W;
lines.push(`<line x1="-200" y1="${eq}" x2="${W + 200}" y2="${eq}" stroke="${strongColor}" stroke-width="0.7" stroke-dasharray="4,3" />`);
lines.push(`<line x1="${pm}" y1="-200" x2="${pm}" y2="${H + 200}" stroke="${strongColor}" stroke-width="0.7" stroke-dasharray="4,3" />`);
return lines.join("\n");
}
/** Simplified continent outlines using equirectangular projection */
private continents(W: number, H: number, fill: string, stroke: string): string {
const p = (lat: number, lng: number) => {
const x = ((lng + 180) / 360) * W;
const y = ((90 - lat) / 180) * H;
return `${x.toFixed(1)},${y.toFixed(1)}`;
};
const continents = [
// North America
`M${p(50, -130)} L${p(60, -130)} L${p(65, -120)} L${p(70, -100)} L${p(72, -80)}
L${p(65, -65)} L${p(50, -55)} L${p(45, -65)} L${p(40, -75)}
L${p(30, -82)} L${p(28, -90)} L${p(25, -100)} L${p(20, -105)}
L${p(18, -100)} L${p(15, -88)} L${p(10, -84)} L${p(10, -78)}
L${p(20, -88)} L${p(25, -80)} L${p(30, -82)} L${p(30, -75)}
L${p(40, -75)} L${p(48, -90)} L${p(48, -95)}
L${p(50, -120)} Z`,
// South America
`M${p(12, -75)} L${p(10, -68)} L${p(5, -60)} L${p(0, -50)}
L${p(-5, -45)} L${p(-10, -38)} L${p(-15, -40)} L${p(-20, -42)}
L${p(-25, -48)} L${p(-30, -50)} L${p(-35, -56)} L${p(-40, -62)}
L${p(-45, -66)} L${p(-50, -70)} L${p(-55, -68)}
L${p(-50, -74)} L${p(-42, -72)} L${p(-35, -70)}
L${p(-25, -70)} L${p(-20, -70)} L${p(-15, -76)}
L${p(-5, -78)} L${p(0, -80)} L${p(5, -78)} L${p(10, -76)} Z`,
// Europe
`M${p(40, -8)} L${p(42, 0)} L${p(44, 5)} L${p(46, 8)}
L${p(48, 10)} L${p(50, 5)} L${p(52, 8)} L${p(55, 10)}
L${p(58, 12)} L${p(60, 10)} L${p(62, 18)} L${p(65, 20)}
L${p(70, 25)} L${p(68, 30)} L${p(62, 32)} L${p(58, 30)}
L${p(55, 28)} L${p(50, 30)} L${p(48, 28)} L${p(45, 25)}
L${p(42, 28)} L${p(38, 25)} L${p(36, 22)} L${p(38, 10)}
L${p(38, 0)} Z`,
// Africa
`M${p(35, -5)} L${p(37, 10)} L${p(35, 20)} L${p(32, 32)}
L${p(28, 33)} L${p(15, 42)} L${p(10, 42)} L${p(5, 38)}
L${p(0, 40)} L${p(-5, 38)} L${p(-10, 34)} L${p(-15, 30)}
L${p(-20, 28)} L${p(-25, 28)} L${p(-30, 28)} L${p(-34, 25)}
L${p(-34, 20)} L${p(-30, 15)} L${p(-20, 12)} L${p(-15, 12)}
L${p(-10, 15)} L${p(-5, 10)} L${p(0, 8)} L${p(5, 2)}
L${p(10, 0)} L${p(15, -5)} L${p(20, -10)} L${p(25, -15)}
L${p(30, -10)} L${p(35, -5)} Z`,
// Asia
`M${p(70, 30)} L${p(72, 50)} L${p(72, 80)} L${p(70, 110)}
L${p(68, 140)} L${p(65, 165)} L${p(60, 165)} L${p(55, 140)}
L${p(50, 130)} L${p(45, 135)} L${p(40, 130)} L${p(35, 120)}
L${p(30, 120)} L${p(25, 105)} L${p(22, 100)} L${p(20, 98)}
L${p(15, 100)} L${p(10, 105)} L${p(5, 100)} L${p(0, 104)}
L${p(-5, 105)} L${p(-8, 112)} L${p(-6, 118)}
L${p(2, 110)} L${p(8, 108)} L${p(12, 110)}
L${p(20, 108)} L${p(22, 114)} L${p(30, 110)}
L${p(28, 88)} L${p(25, 68)} L${p(30, 50)}
L${p(35, 45)} L${p(40, 40)} L${p(42, 32)}
L${p(48, 30)} L${p(55, 30)} L${p(62, 32)}
L${p(68, 30)} Z`,
// Australia
`M${p(-12, 132)} L${p(-15, 140)} L${p(-20, 148)} L${p(-25, 150)}
L${p(-30, 148)} L${p(-35, 140)} L${p(-38, 146)} L${p(-35, 150)}
L${p(-32, 152)} L${p(-28, 153)} L${p(-25, 152)}
L${p(-20, 150)} L${p(-15, 145)} L${p(-12, 138)}
L${p(-14, 130)} L${p(-18, 122)} L${p(-22, 115)}
L${p(-28, 114)} L${p(-32, 116)} L${p(-35, 118)}
L${p(-34, 125)} L${p(-30, 130)} L${p(-25, 132)}
L${p(-20, 130)} L${p(-16, 128)} L${p(-12, 132)} Z`,
// Japan
`M${p(35, 133)} L${p(38, 136)} L${p(40, 140)} L${p(42, 142)}
L${p(44, 144)} L${p(45, 142)} L${p(43, 140)} L${p(40, 137)}
L${p(37, 135)} L${p(35, 133)} Z`,
// UK/Ireland
`M${p(51, -5)} L${p(53, 0)} L${p(55, -2)} L${p(57, -5)}
L${p(58, -3)} L${p(56, 0)} L${p(54, 1)} L${p(52, 0)}
L${p(50, -3)} L${p(51, -5)} Z`,
// Greenland
`M${p(62, -50)} L${p(68, -52)} L${p(75, -45)} L${p(78, -35)}
L${p(76, -20)} L${p(70, -22)} L${p(65, -35)} L${p(62, -45)} Z`,
// Indonesia
`M${p(-2, 100)} L${p(-4, 108)} L${p(-6, 112)} L${p(-8, 115)}
L${p(-7, 118)} L${p(-5, 116)} L${p(-3, 112)} L${p(-1, 106)} L${p(-2, 100)} Z`,
// New Zealand
`M${p(-36, 174)} L${p(-38, 176)} L${p(-42, 174)} L${p(-46, 168)}
L${p(-44, 168)} L${p(-42, 172)} L${p(-38, 174)} L${p(-36, 174)} Z`,
];
return continents.map((d) =>
`<path d="${d.replace(/\s+/g, " ").trim()}" fill="${fill}" stroke="${stroke}" stroke-width="0.5" opacity="0.75" />`
).join("\n");
}
private attachDemoListeners() {
// Search input
let searchTimeout: any;
this.shadow.getElementById("map-search")?.addEventListener("input", (e) => {
this.searchQuery = (e.target as HTMLInputElement).value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => this.renderDemo(), 200);
});
// Geolocation button
this.shadow.getElementById("share-geo")?.addEventListener("click", () => {
if (this.userLocation) {
this.userLocation = null;
this.renderDemo();
return;
}
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(
(pos) => {
this.userLocation = { lat: pos.coords.latitude, lng: pos.coords.longitude };
this.renderDemo();
},
() => { /* denied — do nothing */ }
);
}
});
const tooltip = this.shadow.getElementById("tooltip");
const mapWrap = this.shadow.getElementById("map-wrap");
const mapSvg = this.shadow.getElementById("map-svg");
// Tooltip on hover
if (tooltip) {
this.shadow.querySelectorAll(".pin-group").forEach((el) => {
const idx = parseInt((el as HTMLElement).dataset.idx || "0", 10);
const p = this.providers[idx];
el.addEventListener("mouseenter", (e) => {
const rect = mapWrap?.getBoundingClientRect();
const me = e as MouseEvent;
if (rect) {
tooltip.innerHTML = `<strong>${this.esc(p.name)}</strong><span class="city">${this.esc(p.city)}, ${this.esc(p.country)}</span><span class="coords">${p.lat.toFixed(2)}, ${p.lng.toFixed(2)}</span>`;
tooltip.style.left = `${me.clientX - rect.left + 12}px`;
tooltip.style.top = `${me.clientY - rect.top - 10}px`;
tooltip.classList.add("visible");
}
});
el.addEventListener("mousemove", (e) => {
const rect = mapWrap?.getBoundingClientRect();
const me = e as MouseEvent;
if (rect) {
tooltip.style.left = `${me.clientX - rect.left + 12}px`;
tooltip.style.top = `${me.clientY - rect.top - 10}px`;
}
});
el.addEventListener("mouseleave", () => {
tooltip.classList.remove("visible");
});
});
}
// Click pin to select provider and zoom
this.shadow.querySelectorAll(".pin-group").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt((el as HTMLElement).dataset.idx || "0", 10);
if (this.selectedProvider === idx) {
this.resetZoom();
} else {
this.selectedProvider = idx;
const p = this.providers[idx];
this.zoomTo(p.lat, p.lng, 3);
}
});
});
// Click legend item to select/zoom
this.shadow.querySelectorAll("[data-legend]").forEach((el) => {
el.addEventListener("click", () => {
const idx = parseInt((el as HTMLElement).dataset.legend || "0", 10);
if (this.selectedProvider === idx) {
this.resetZoom();
} else {
this.selectedProvider = idx;
const p = this.providers[idx];
this.zoomTo(p.lat, p.lng, 3);
}
});
});
// Close detail panel
this.shadow.getElementById("detail-close")?.addEventListener("click", () => {
this.resetZoom();
});
// Zoom controls
this.shadow.getElementById("zoom-in")?.addEventListener("click", () => {
const newZoom = Math.min(this.zoomLevel * 1.5, 6);
const cx = this.vbX + this.vbW / 2;
const cy = this.vbY + this.vbH / 2;
const lat = 90 - (cy / 460) * 180;
const lng = (cx / 900) * 360 - 180;
this.zoomTo(lat, lng, newZoom);
});
this.shadow.getElementById("zoom-out")?.addEventListener("click", () => {
if (this.zoomLevel <= 1) {
this.resetZoom();
return;
}
const newZoom = Math.max(this.zoomLevel / 1.5, 1);
const cx = this.vbX + this.vbW / 2;
const cy = this.vbY + this.vbH / 2;
const lat = 90 - (cy / 460) * 180;
const lng = (cx / 900) * 360 - 180;
this.zoomTo(lat, lng, newZoom);
});
this.shadow.getElementById("zoom-reset")?.addEventListener("click", () => {
this.resetZoom();
});
// Mouse wheel zoom
mapWrap?.addEventListener("wheel", (e) => {
e.preventDefault();
const we = e as WheelEvent;
const delta = we.deltaY > 0 ? 0.8 : 1.25;
const newZoom = Math.max(1, Math.min(6, this.zoomLevel * delta));
// Zoom toward mouse position
const rect = mapWrap.getBoundingClientRect();
const mouseX = we.clientX - rect.left;
const mouseY = we.clientY - rect.top;
const svgX = this.vbX + (mouseX / rect.width) * this.vbW;
const svgY = this.vbY + (mouseY / rect.height) * this.vbH;
const lat = 90 - (svgY / 460) * 180;
const lng = (svgX / 900) * 360 - 180;
this.zoomTo(lat, lng, newZoom);
}, { passive: false });
// Drag to pan
mapWrap?.addEventListener("mousedown", (e) => {
const me = e as MouseEvent;
// Don't start drag on pins
if ((me.target as Element)?.closest?.(".pin-group")) return;
this.isDragging = true;
this.dragStartX = me.clientX;
this.dragStartY = me.clientY;
this.dragVbX = this.vbX;
this.dragVbY = this.vbY;
mapWrap.classList.add("dragging");
});
const onMouseMove = (e: Event) => {
if (!this.isDragging) return;
const me = e as MouseEvent;
const rect = mapWrap?.getBoundingClientRect();
if (!rect) return;
const dx = (me.clientX - this.dragStartX) / rect.width * this.vbW;
const dy = (me.clientY - this.dragStartY) / rect.height * this.vbH;
this.vbX = this.dragVbX - dx;
this.vbY = this.dragVbY - dy;
// Clamp
this.vbX = Math.max(-200, Math.min(900 - this.vbW + 200, this.vbX));
this.vbY = Math.max(-200, Math.min(460 - this.vbH + 200, this.vbY));
if (mapSvg) {
mapSvg.setAttribute("viewBox", `${this.vbX} ${this.vbY} ${this.vbW} ${this.vbH}`);
}
};
const onMouseUp = () => {
this.isDragging = false;
mapWrap?.classList.remove("dragging");
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
// Touch support for pan
mapWrap?.addEventListener("touchstart", (e) => {
const te = e as TouchEvent;
if (te.touches.length === 1) {
this.isDragging = true;
this.dragStartX = te.touches[0].clientX;
this.dragStartY = te.touches[0].clientY;
this.dragVbX = this.vbX;
this.dragVbY = this.vbY;
}
}, { passive: true });
mapWrap?.addEventListener("touchmove", (e) => {
if (!this.isDragging) return;
const te = e as TouchEvent;
if (te.touches.length !== 1) return;
const rect = mapWrap.getBoundingClientRect();
const dx = (te.touches[0].clientX - this.dragStartX) / rect.width * this.vbW;
const dy = (te.touches[0].clientY - this.dragStartY) / rect.height * this.vbH;
this.vbX = this.dragVbX - dx;
this.vbY = this.dragVbY - dy;
this.vbX = Math.max(-200, Math.min(900 - this.vbW + 200, this.vbX));
this.vbY = Math.max(-200, Math.min(460 - this.vbH + 200, this.vbY));
if (mapSvg) {
mapSvg.setAttribute("viewBox", `${this.vbX} ${this.vbY} ${this.vbW} ${this.vbH}`);
}
}, { passive: true });
mapWrap?.addEventListener("touchend", () => {
this.isDragging = false;
}, { passive: true });
}
// ─── Room mode: API / health ─────────────────────────────────
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rmaps/);
return match ? match[0] : "";
}
private async checkSyncHealth() {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(3000) });
if (res.ok) {
const data = await res.json();
this.syncStatus = data.sync !== false ? "connected" : "disconnected";
}
} catch {
this.syncStatus = "disconnected";
}
this.render();
}
private async loadStats() {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/stats`, { signal: AbortSignal.timeout(3000) });
if (res.ok) {
const data = await res.json();
this.rooms = Object.keys(data.rooms || {});
}
} catch {
this.rooms = [];
}
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() {
if (!requireAuth("create map room")) return;
const name = prompt("Room name (slug):");
if (!name?.trim()) return;
const slug = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-");
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._themeObserver) {
this._themeObserver.disconnect();
this._themeObserver = 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: this.isDarkTheme() ? DARK_STYLE : LIGHT_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");
// Apply dark mode inversion filter to OSM tiles
this.applyDarkFilter();
// Theme observer — swap map tiles on toggle
this._themeObserver = new MutationObserver(() => {
this.map?.setStyle(this.isDarkTheme() ? DARK_STYLE : LIGHT_STYLE);
this.applyDarkFilter();
this.updateMarkerTheme();
});
this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
// 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);
// Listen for SW-forwarded location request pushes
if ("serviceWorker" in navigator) {
navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data?.type === "LOCATION_REQUEST") {
const reqRoom = event.data.data?.roomSlug;
if (reqRoom === this.room && this.sharingLocation) {
// Already sharing — sync will propagate automatically
} else if (reqRoom === this.room && !this.privacySettings.ghostMode) {
// Not sharing yet — start sharing in response to ping
this.toggleLocationSharing();
}
}
});
}
}
// ─── Persist room state to SW IndexedDB for offline access ──
private persistRoomState(state: RoomState) {
if (!this.room || !("serviceWorker" in navigator)) return;
navigator.serviceWorker.controller?.postMessage({
type: "SAVE_ROOM_STATE",
roomSlug: this.room,
state,
});
}
// ─── State change → update markers ───────────────────────────
private onRoomStateChange(state: RoomState) {
// Persist to IndexedDB for offline pinging
this.persistRoomState(state);
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 dark = this.isDarkTheme();
const markerBg = dark ? '#1a1a2e' : '#fafaf7';
const textShadow = dark ? 'rgba(0,0,0,0.8)' : 'rgba(0,0,0,0.3)';
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: ${markerBg};
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.className = "marker-label";
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 ${textShadow};
font-family: system-ui, sans-serif;
`;
label.textContent = p.name;
el.appendChild(label);
el.addEventListener("click", () => {
this.selectedParticipant = id;
this.selectedWaypoint = null;
this.renderNavigationPanel();
});
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;
el.addEventListener("click", () => {
this.selectedWaypoint = wp.id;
this.selectedParticipant = null;
this.renderNavigationPanel();
});
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;
// Dedup by name (keep most recent)
const byName = new Map<string, ParticipantState>();
for (const p of Object.values(state.participants)) {
const existing = byName.get(p.name);
if (!existing || new Date(p.lastSeen) > new Date(existing.lastSeen)) {
byName.set(p.name, p);
}
}
const entries = Array.from(byName.values());
const myLoc = state.participants[this.participantId]?.location;
const statusColors: Record<string, string> = { online: "#22c55e", away: "#f59e0b", ghost: "#64748b", offline: "#ef4444" };
list.innerHTML = entries.map((p) => {
let distLabel = "";
if (myLoc && p.location && p.id !== this.participantId) {
distLabel = formatDistance(haversineDistance(myLoc.latitude, myLoc.longitude, p.location.latitude, p.location.longitude));
}
const statusColor = statusColors[p.status] || "#64748b";
return `
<div class="participant-entry" style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rs-border);">
<div style="position:relative;">
<span style="font-size:18px">${this.esc(p.emoji)}</span>
<span style="position:absolute;bottom:-2px;right:-2px;width:8px;height:8px;border-radius:50%;background:${statusColor};border:2px solid var(--rs-bg-surface);"></span>
</div>
<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:var(--rs-text-muted);">
${p.status === "ghost" ? "ghost mode" : p.location ? "sharing" : "no location"}
${distLabel ? ` \u2022 ${distLabel}` : ""}
</div>
</div>
${p.id !== this.participantId && p.location ? `<button class="nav-to-btn" data-nav-participant="${p.id}" title="Navigate" style="background:none;border:1px solid var(--rs-border);border-radius:4px;color:var(--rs-text-muted);cursor:pointer;padding:2px 6px;font-size:11px;">\u{1F9ED}</button>` : ""}
${p.id !== this.participantId ? `<button class="ping-btn-inline" data-ping="${p.id}" title="Ping" style="background:none;border:1px solid var(--rs-border);border-radius:4px;color:var(--rs-text-muted);cursor:pointer;padding:2px 6px;font-size:11px;">\u{1F514}</button>` : ""}
</div>`;
}).join("");
// Footer actions
list.insertAdjacentHTML("beforeend", `
<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-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>
</div>
`);
// Attach 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);
});
});
list.querySelectorAll("[data-nav-participant]").forEach((btn) => {
btn.addEventListener("click", () => {
this.selectedParticipant = (btn as HTMLElement).dataset.navParticipant!;
this.selectedWaypoint = null;
this.renderNavigationPanel();
});
});
list.querySelector("#sidebar-meeting-btn")?.addEventListener("click", () => {
this.showMeetingModal = true;
this.renderMeetingPointModal();
});
list.querySelector("#sidebar-import-btn")?.addEventListener("click", () => {
this.showImportModal = true;
this.importStep = "upload";
this.renderImportModal();
});
}
private updateMarkerTheme() {
const dark = this.isDarkTheme();
const markerBg = dark ? '#1a1a2e' : '#fafaf7';
const textShadow = dark ? 'rgba(0,0,0,0.8)' : 'rgba(0,0,0,0.3)';
for (const marker of this.participantMarkers.values()) {
const el = marker.getElement?.();
if (!el) continue;
el.style.background = markerBg;
const label = el.querySelector('.marker-label') as HTMLElement | null;
if (label) label.style.textShadow = `0 1px 3px ${textShadow}`;
}
}
private applyDarkFilter() {
const container = this.shadow.getElementById("map-container");
if (!container) return;
const canvas = container.querySelector("canvas");
if (canvas) {
canvas.style.filter = this.isDarkTheme() ? "invert(1) hue-rotate(180deg)" : "none";
} else {
// Canvas may not be ready yet — retry after tiles load
this.map?.once("load", () => {
const c = container.querySelector("canvas");
if (c) c.style.filter = this.isDarkTheme() ? "invert(1) hue-rotate(180deg)" : "none";
});
}
}
// ─── Location sharing ────────────────────────────────────────
private async checkGeoPermission() {
try {
const result = await navigator.permissions.query({ name: "geolocation" });
this.geoPermissionState = result.state;
result.addEventListener("change", () => { this.geoPermissionState = result.state; });
} catch { /* permissions API not available */ }
}
private toggleLocationSharing() {
if (this.privacySettings.ghostMode) return; // Ghost mode prevents sharing
if (this.sharingLocation) {
if (this.watchId !== null) {
navigator.geolocation.clearWatch(this.watchId);
this.watchId = null;
}
this.sharingLocation = false;
this.geoTimeoutCount = 0;
this.sync?.clearLocation();
this.updateShareButton();
return;
}
if (!("geolocation" in navigator)) {
this.error = "Geolocation not supported";
return;
}
this.checkGeoPermission();
let firstFix = true;
const useHighAccuracy = this.geoTimeoutCount < 2;
this.watchId = navigator.geolocation.watchPosition(
(pos) => {
this.sharingLocation = true;
this.geoTimeoutCount = 0;
this.updateShareButton();
let lat = pos.coords.latitude;
let lng = pos.coords.longitude;
// Apply privacy fuzzing
if (this.privacySettings.precision !== "exact") {
const fuzzed = fuzzLocation(lat, lng, this.privacySettings.precision);
lat = fuzzed.latitude;
lng = fuzzed.longitude;
}
const loc: LocationState = {
latitude: lat,
longitude: lng,
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: [pos.coords.longitude, pos.coords.latitude], zoom: 14 });
firstFix = false;
}
},
(err) => {
if (err.code === err.TIMEOUT) {
this.geoTimeoutCount++;
if (this.geoTimeoutCount >= 2 && this.watchId !== null) {
// Restart with low accuracy
navigator.geolocation.clearWatch(this.watchId);
this.watchId = navigator.geolocation.watchPosition(
(pos) => {
this.sharingLocation = true;
this.updateShareButton();
let lat = pos.coords.latitude;
let lng = pos.coords.longitude;
if (this.privacySettings.precision !== "exact") {
const fuzzed = fuzzLocation(lat, lng, this.privacySettings.precision);
lat = fuzzed.latitude;
lng = fuzzed.longitude;
}
this.sync?.updateLocation({
latitude: lat, longitude: lng,
accuracy: pos.coords.accuracy,
altitude: pos.coords.altitude ?? undefined,
heading: pos.coords.heading ?? undefined,
speed: pos.coords.speed ?? undefined,
timestamp: new Date().toISOString(),
source: "network",
});
},
() => { this.sharingLocation = false; this.updateShareButton(); },
{ enableHighAccuracy: false, maximumAge: 10000, timeout: 30000 },
);
}
} else {
this.error = `Location error: ${err.message}`;
this.sharingLocation = false;
this.updateShareButton();
}
},
{ enableHighAccuracy: useHighAccuracy, maximumAge: 5000, timeout: 15000 },
);
}
private toggleGhostMode() {
this.privacySettings.ghostMode = !this.privacySettings.ghostMode;
if (this.privacySettings.ghostMode) {
if (this.watchId !== null) {
navigator.geolocation.clearWatch(this.watchId);
this.watchId = null;
}
this.sharingLocation = false;
this.sync?.updateStatus("ghost");
this.sync?.clearLocation();
} else {
this.sync?.updateStatus("online");
}
this.renderPrivacyPanel();
this.updateShareButton();
}
private updateShareButton() {
const btn = this.shadow.getElementById("share-location");
if (!btn) return;
if (this.privacySettings.ghostMode) {
btn.textContent = "\u{1F47B} Ghost Mode";
btn.classList.remove("sharing");
btn.classList.add("ghost");
} else if (this.sharingLocation) {
btn.textContent = "\u{1F4CD} Stop Sharing";
btn.classList.add("sharing");
btn.classList.remove("ghost");
} else {
btn.textContent = "\u{1F4CD} Share Location";
btn.classList.remove("sharing");
btn.classList.remove("ghost");
}
// Update permission indicator
const permIndicator = this.shadow.getElementById("geo-perm-indicator");
if (permIndicator) {
const colors: Record<string, string> = { granted: "#22c55e", prompt: "#f59e0b", denied: "#ef4444" };
permIndicator.style.background = colors[this.geoPermissionState] || "#64748b";
permIndicator.title = `Geolocation: ${this.geoPermissionState || "unknown"}`;
}
}
private renderPrivacyPanel() {
const panel = this.shadow.getElementById("privacy-panel");
if (!panel) return;
const precisionLabels: Record<PrecisionLevel, string> = {
exact: "Exact", building: "~50m (Building)", area: "~500m (Area)", approximate: "~5km (Approximate)",
};
panel.innerHTML = `
<div style="font-size:12px;font-weight:600;color:var(--rs-text-secondary);margin-bottom:8px;">Privacy Settings</div>
<label style="font-size:12px;color:var(--rs-text-secondary);display:block;margin-bottom:6px;">Location Precision</label>
<select id="precision-select" style="width:100%;padding:6px 8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:12px;margin-bottom:10px;">
${(["exact", "building", "area", "approximate"] as PrecisionLevel[]).map(p =>
`<option value="${p}" ${this.privacySettings.precision === p ? "selected" : ""}>${precisionLabels[p]}</option>`
).join("")}
</select>
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--rs-text-secondary);cursor:pointer;">
<input type="checkbox" id="ghost-toggle" ${this.privacySettings.ghostMode ? "checked" : ""} style="accent-color:#8b5cf6;">
<span>\u{1F47B} Ghost Mode</span>
</label>
<div style="font-size:10px;color:var(--rs-text-muted);margin-top:4px;line-height:1.4;">
Ghost mode hides your location from all participants and stops GPS tracking.
</div>
`;
panel.querySelector("#precision-select")?.addEventListener("change", (e) => {
this.privacySettings.precision = (e.target as HTMLSelectElement).value as PrecisionLevel;
});
panel.querySelector("#ghost-toggle")?.addEventListener("change", () => {
this.toggleGhostMode();
});
}
// ─── Waypoint drop / Meeting point modal ────────────────────
private dropWaypoint() {
this.showMeetingModal = true;
this.meetingSearchQuery = "";
this.meetingSearchResults = [];
this.renderMeetingPointModal();
}
private renderMeetingPointModal() {
let modal = this.shadow.getElementById("meeting-modal");
if (!this.showMeetingModal) {
modal?.remove();
return;
}
if (!modal) {
modal = document.createElement("div");
modal.id = "meeting-modal";
modal.style.cssText = `
position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;
background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);
`;
this.shadow.appendChild(modal);
}
const center = this.map?.getCenter();
const myLoc = this.sync?.getState().participants[this.participantId]?.location;
const meetingEmojis = ["\u{1F4CD}", "\u{2B50}", "\u{1F3E0}", "\u{1F37D}", "\u{26FA}", "\u{1F3AF}", "\u{1F680}", "\u{1F33F}", "\u{26A1}", "\u{1F48E}"];
modal.innerHTML = `
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:20px;max-width:400px;width:90%;max-height:80vh;overflow-y:auto;">
<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);">\u{1F4CD} Set Meeting Point</div>
<button id="meeting-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
<label style="font-size:12px;color:var(--rs-text-secondary);display:block;margin-bottom:4px;">Name</label>
<input type="text" id="meeting-name" placeholder="Meeting point" value="Meeting point"
style="width:100%;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:13px;margin-bottom:12px;">
<label style="font-size:12px;color:var(--rs-text-secondary);display:block;margin-bottom:6px;">Emoji</label>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;" id="emoji-picker">
${meetingEmojis.map((e, i) => `<button class="emoji-opt" data-emoji="${e}" style="width:32px;height:32px;border-radius:6px;border:1px solid ${i === 0 ? "#4f46e5" : "var(--rs-border)"};background:var(--rs-input-bg);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;">${e}</button>`).join("")}
</div>
<label style="font-size:12px;color:var(--rs-text-secondary);display:block;margin-bottom:4px;">Location</label>
<div style="display:flex;gap:6px;margin-bottom:10px;">
<button id="loc-gps" class="loc-mode-btn" style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;">\u{1F4CD} Current GPS</button>
<button id="loc-search" class="loc-mode-btn" style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;">\u{1F50E} Search Address</button>
<button id="loc-manual" class="loc-mode-btn" style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;">\u{1F4DD} Manual</button>
</div>
<div id="loc-mode-content">
<div style="font-size:12px;color:var(--rs-text-muted);text-align:center;padding:12px;">
${center ? `Map center: ${center.lat.toFixed(5)}, ${center.lng.toFixed(5)}` : "Select a location mode"}
</div>
</div>
<input type="hidden" id="meeting-lat" value="${center?.lat || myLoc?.latitude || 0}">
<input type="hidden" id="meeting-lng" value="${center?.lng || myLoc?.longitude || 0}">
<input type="hidden" id="meeting-emoji" value="\u{1F4CD}">
<button id="meeting-create" style="width:100%;margin-top:14px;padding:10px;border-radius:8px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:13px;">Drop Meeting Point</button>
</div>
`;
// Listeners
modal.querySelector("#meeting-close")?.addEventListener("click", () => {
this.showMeetingModal = false;
modal?.remove();
});
modal.addEventListener("click", (e) => {
if (e.target === modal) { this.showMeetingModal = false; modal?.remove(); }
});
// Emoji picker
let selectedEmoji = "\u{1F4CD}";
modal.querySelectorAll(".emoji-opt").forEach(btn => {
btn.addEventListener("click", () => {
selectedEmoji = (btn as HTMLElement).dataset.emoji!;
(modal!.querySelector("#meeting-emoji") as HTMLInputElement).value = selectedEmoji;
modal!.querySelectorAll(".emoji-opt").forEach(b => (b as HTMLElement).style.borderColor = "var(--rs-border)");
(btn as HTMLElement).style.borderColor = "#4f46e5";
});
});
// GPS mode
modal.querySelector("#loc-gps")?.addEventListener("click", () => {
if (myLoc) {
(modal!.querySelector("#meeting-lat") as HTMLInputElement).value = String(myLoc.latitude);
(modal!.querySelector("#meeting-lng") as HTMLInputElement).value = String(myLoc.longitude);
modal!.querySelector("#loc-mode-content")!.innerHTML = `<div style="font-size:12px;color:#22c55e;text-align:center;padding:12px;">\u2713 Using your current GPS: ${myLoc.latitude.toFixed(5)}, ${myLoc.longitude.toFixed(5)}</div>`;
} else {
modal!.querySelector("#loc-mode-content")!.innerHTML = `<div style="font-size:12px;color:#f59e0b;text-align:center;padding:12px;">Share your location first</div>`;
}
});
// Search mode
modal.querySelector("#loc-search")?.addEventListener("click", () => {
modal!.querySelector("#loc-mode-content")!.innerHTML = `
<div style="display:flex;gap:6px;margin-bottom:8px;">
<input type="text" id="address-search" placeholder="Search address..." style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:12px;">
<button id="address-search-btn" style="padding:8px 12px;border-radius:6px;border:none;background:#4f46e5;color:#fff;cursor:pointer;font-size:12px;">Search</button>
</div>
<div id="search-results"></div>
`;
modal!.querySelector("#address-search-btn")?.addEventListener("click", async () => {
const q = (modal!.querySelector("#address-search") as HTMLInputElement).value.trim();
if (!q) return;
const resultsDiv = modal!.querySelector("#search-results")!;
resultsDiv.innerHTML = '<div style="font-size:11px;color:var(--rs-text-muted);padding:6px;">Searching...</div>';
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q)}&limit=5`, {
headers: { "User-Agent": "rMaps/1.0" },
signal: AbortSignal.timeout(5000),
});
const data = await res.json();
this.meetingSearchResults = data;
resultsDiv.innerHTML = data.length ? data.map((r: any, i: number) => `
<div class="search-result" data-sr="${i}" style="padding:6px 8px;border-radius:6px;cursor:pointer;font-size:12px;color:var(--rs-text-secondary);border:1px solid transparent;margin-bottom:4px;">
${this.esc(r.display_name?.substring(0, 80))}
</div>
`).join("") : '<div style="font-size:11px;color:var(--rs-text-muted);padding:6px;">No results found</div>';
resultsDiv.querySelectorAll("[data-sr]").forEach(el => {
el.addEventListener("click", () => {
const idx = parseInt((el as HTMLElement).dataset.sr!, 10);
const r = this.meetingSearchResults[idx];
(modal!.querySelector("#meeting-lat") as HTMLInputElement).value = r.lat;
(modal!.querySelector("#meeting-lng") as HTMLInputElement).value = r.lon;
resultsDiv.querySelectorAll("[data-sr]").forEach(e => (e as HTMLElement).style.borderColor = "transparent");
(el as HTMLElement).style.borderColor = "#4f46e5";
});
});
} catch {
resultsDiv.innerHTML = '<div style="font-size:11px;color:#ef4444;padding:6px;">Search failed</div>';
}
});
});
// Manual mode
modal.querySelector("#loc-manual")?.addEventListener("click", () => {
modal!.querySelector("#loc-mode-content")!.innerHTML = `
<div style="display:flex;gap:8px;">
<div style="flex:1;">
<label style="font-size:11px;color:var(--rs-text-muted);">Latitude</label>
<input type="number" step="any" id="manual-lat" value="${center?.lat || 0}" style="width:100%;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:12px;">
</div>
<div style="flex:1;">
<label style="font-size:11px;color:var(--rs-text-muted);">Longitude</label>
<input type="number" step="any" id="manual-lng" value="${center?.lng || 0}" style="width:100%;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:12px;">
</div>
</div>
`;
modal!.querySelector("#manual-lat")?.addEventListener("input", (e) => {
(modal!.querySelector("#meeting-lat") as HTMLInputElement).value = (e.target as HTMLInputElement).value;
});
modal!.querySelector("#manual-lng")?.addEventListener("input", (e) => {
(modal!.querySelector("#meeting-lng") as HTMLInputElement).value = (e.target as HTMLInputElement).value;
});
});
// Create
modal.querySelector("#meeting-create")?.addEventListener("click", () => {
const name = (modal!.querySelector("#meeting-name") as HTMLInputElement).value.trim() || "Meeting point";
const lat = parseFloat((modal!.querySelector("#meeting-lat") as HTMLInputElement).value);
const lng = parseFloat((modal!.querySelector("#meeting-lng") as HTMLInputElement).value);
const emoji = (modal!.querySelector("#meeting-emoji") as HTMLInputElement).value || "\u{1F4CD}";
if (isNaN(lat) || isNaN(lng)) return;
this.sync?.addWaypoint({
id: crypto.randomUUID(),
name,
emoji,
latitude: lat,
longitude: lng,
createdBy: this.participantId,
createdAt: new Date().toISOString(),
type: "meeting",
});
this.showMeetingModal = false;
modal?.remove();
});
}
// ─── Share modal with QR code ───────────────────────────────
private async renderShareModal() {
let modal = this.shadow.getElementById("share-modal");
if (!this.showShareModal) {
modal?.remove();
return;
}
if (!modal) {
modal = document.createElement("div");
modal.id = "share-modal";
modal.style.cssText = `
position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;
background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);
`;
this.shadow.appendChild(modal);
}
const shareUrl = `${window.location.origin}/${this.space}/rmaps/${this.room}`;
modal.innerHTML = `
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:24px;max-width:360px;width:90%;text-align:center;">
<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);">Share Room</div>
<button id="share-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
<div id="qr-container" style="margin:16px auto;display:flex;align-items:center;justify-content:center;">
<div style="font-size:12px;color:var(--rs-text-muted);">Generating QR code...</div>
</div>
<div style="background:var(--rs-bg-surface-sunken);border:1px solid var(--rs-border);border-radius:8px;padding:10px;margin:12px 0;font-family:monospace;font-size:11px;color:var(--rs-text-secondary);word-break:break-all;text-align:left;">
${this.esc(shareUrl)}
</div>
<div style="display:flex;gap:8px;">
<button id="share-copy" style="flex:1;padding:10px;border-radius:8px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:13px;font-weight:500;">\u{1F4CB} Copy Link</button>
<button id="share-native" style="flex:1;padding:10px;border-radius:8px;border:none;background:#4f46e5;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">\u{1F4E4} Share</button>
</div>
</div>
`;
// Generate QR code
try {
const QRCode = await import("qrcode");
const dataUrl = await QRCode.toDataURL(shareUrl, { width: 200, margin: 2, color: { dark: "#000000", light: "#ffffff" } });
const qrContainer = modal.querySelector("#qr-container");
if (qrContainer) {
qrContainer.innerHTML = `<img src="${dataUrl}" alt="QR Code" style="width:200px;height:200px;border-radius:8px;">`;
}
} catch {
const qrContainer = modal.querySelector("#qr-container");
if (qrContainer) qrContainer.innerHTML = `<div style="font-size:12px;color:var(--rs-text-muted);">QR code unavailable</div>`;
}
// Listeners
modal.querySelector("#share-close")?.addEventListener("click", () => {
this.showShareModal = false;
modal?.remove();
});
modal.addEventListener("click", (e) => {
if (e.target === modal) { this.showShareModal = false; modal?.remove(); }
});
modal.querySelector("#share-copy")?.addEventListener("click", () => {
navigator.clipboard.writeText(shareUrl).then(() => {
const btn = modal!.querySelector("#share-copy");
if (btn) { btn.textContent = "\u2713 Copied!"; setTimeout(() => { btn.textContent = "\u{1F4CB} Copy Link"; }, 2000); }
});
});
modal.querySelector("#share-native")?.addEventListener("click", () => {
if (navigator.share) {
navigator.share({ title: `rMaps: ${this.room}`, url: shareUrl }).catch(() => {});
} else {
navigator.clipboard.writeText(shareUrl).then(() => {
const btn = modal!.querySelector("#share-native");
if (btn) { btn.textContent = "\u2713 Copied!"; setTimeout(() => { btn.textContent = "\u{1F4E4} Share"; }, 2000); }
});
}
});
}
// ─── Import modal ───────────────────────────────────────────
private renderImportModal() {
let modal = this.shadow.getElementById("import-modal");
if (!this.showImportModal) {
modal?.remove();
return;
}
if (!modal) {
modal = document.createElement("div");
modal.id = "import-modal";
modal.style.cssText = `
position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;
background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);
`;
this.shadow.appendChild(modal);
}
if (this.importStep === "upload") {
modal.innerHTML = `
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:24px;max-width:420px;width:90%;">
<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);">\u{1F4E5} Import Places</div>
<button id="import-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
<div id="drop-zone" style="border:2px dashed var(--rs-border);border-radius:10px;padding:40px;text-align:center;cursor:pointer;transition:border-color 0.2s;">
<div style="font-size:28px;margin-bottom:8px;">\u{1F4C2}</div>
<div style="font-size:13px;color:var(--rs-text-secondary);margin-bottom:4px;">Drop a GeoJSON file here</div>
<div style="font-size:11px;color:var(--rs-text-muted);">or click to browse (.json, .geojson)</div>
<input type="file" id="file-input" accept=".json,.geojson" style="display:none;">
</div>
<div id="import-error" style="display:none;margin-top:12px;font-size:12px;color:#ef4444;padding:8px;border-radius:6px;background:rgba(239,68,68,0.1);"></div>
</div>
`;
const handleFile = (file: File) => {
if (file.size > 50 * 1024 * 1024) {
const errDiv = modal!.querySelector("#import-error") as HTMLElement;
errDiv.style.display = "block";
errDiv.textContent = "File too large (max 50 MB)";
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = parseGoogleMapsGeoJSON(reader.result as string);
if (!result.success) {
const errDiv = modal!.querySelector("#import-error") as HTMLElement;
errDiv.style.display = "block";
errDiv.textContent = result.error || "No places found";
return;
}
this.importParsedPlaces = result.places.map(p => ({ ...p, selected: true }));
this.importStep = "preview";
this.renderImportModal();
};
reader.readAsText(file);
};
const dropZone = modal.querySelector("#drop-zone")!;
const fileInput = modal.querySelector("#file-input") as HTMLInputElement;
dropZone.addEventListener("click", () => fileInput.click());
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); (dropZone as HTMLElement).style.borderColor = "#4f46e5"; });
dropZone.addEventListener("dragleave", () => { (dropZone as HTMLElement).style.borderColor = "var(--rs-border)"; });
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
(dropZone as HTMLElement).style.borderColor = "var(--rs-border)";
const file = (e as DragEvent).dataTransfer?.files[0];
if (file) handleFile(file);
});
fileInput.addEventListener("change", () => {
if (fileInput.files?.[0]) handleFile(fileInput.files[0]);
});
} else if (this.importStep === "preview") {
const selectedCount = this.importParsedPlaces.filter(p => p.selected).length;
modal.innerHTML = `
<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="font-size:16px;font-weight:600;color:var(--rs-text-primary);">Preview (${this.importParsedPlaces.length} places)</div>
<button id="import-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
<div style="margin-bottom:12px;">
${this.importParsedPlaces.map((p, i) => `
<label style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rs-border);cursor:pointer;font-size:12px;color:var(--rs-text-secondary);">
<input type="checkbox" data-place-idx="${i}" ${p.selected ? "checked" : ""} style="accent-color:#4f46e5;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;color:var(--rs-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${this.esc(p.name)}</div>
<div style="font-size:10px;color:var(--rs-text-muted);">${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}</div>
</div>
</label>
`).join("")}
</div>
<button id="import-confirm" style="width:100%;padding:10px;border-radius:8px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:13px;">Import ${selectedCount} Places as Waypoints</button>
</div>
`;
modal.querySelectorAll("[data-place-idx]").forEach(cb => {
cb.addEventListener("change", (e) => {
const idx = parseInt((cb as HTMLElement).dataset.placeIdx!, 10);
this.importParsedPlaces[idx].selected = (e.target as HTMLInputElement).checked;
const btn = modal!.querySelector("#import-confirm");
const count = this.importParsedPlaces.filter(p => p.selected).length;
if (btn) btn.textContent = `Import ${count} Places as Waypoints`;
});
});
modal.querySelector("#import-confirm")?.addEventListener("click", () => {
for (const p of this.importParsedPlaces) {
if (!p.selected) continue;
this.sync?.addWaypoint({
id: crypto.randomUUID(),
name: p.name,
emoji: "\u{1F4CD}",
latitude: p.lat,
longitude: p.lng,
createdBy: this.participantId,
createdAt: new Date().toISOString(),
type: "poi",
});
}
this.importStep = "done";
this.renderImportModal();
});
} else if (this.importStep === "done") {
const count = this.importParsedPlaces.filter(p => p.selected).length;
modal.innerHTML = `
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:24px;max-width:360px;width:90%;text-align:center;">
<div style="font-size:32px;margin-bottom:8px;">\u2705</div>
<div style="font-size:16px;font-weight:600;color:var(--rs-text-primary);margin-bottom:8px;">Imported ${count} places!</div>
<div style="font-size:12px;color:var(--rs-text-muted);margin-bottom:16px;">They've been added as waypoints to this room.</div>
<button id="import-done-btn" style="padding:10px 24px;border-radius:8px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:13px;">Done</button>
</div>
`;
modal.querySelector("#import-done-btn")?.addEventListener("click", () => {
this.showImportModal = false;
modal?.remove();
});
}
// Close handlers (shared)
modal.querySelector("#import-close")?.addEventListener("click", () => {
this.showImportModal = false;
modal?.remove();
});
modal.addEventListener("click", (e) => {
if (e.target === modal) { this.showImportModal = false; modal?.remove(); }
});
}
// ─── Route display ──────────────────────────────────────────
private showRoute(route: { segments: any[]; totalDistance: number; estimatedTime: number }, destination: string) {
if (!this.map) return;
this.clearRoute();
this.activeRoute = { ...route, destination };
const segmentColors: Record<string, string> = { outdoor: "#3b82f6", indoor: "#8b5cf6", transition: "#f97316" };
route.segments.forEach((seg, i) => {
const sourceId = `route-seg-${i}`;
const layerId = `route-layer-${i}`;
this.map.addSource(sourceId, {
type: "geojson",
data: {
type: "Feature",
properties: {},
geometry: { type: "LineString", coordinates: seg.coordinates },
},
});
this.map.addLayer({
id: layerId,
type: "line",
source: sourceId,
layout: { "line-join": "round", "line-cap": "round" },
paint: {
"line-color": segmentColors[seg.type] || "#3b82f6",
"line-width": 5,
"line-opacity": 0.8,
...(seg.type === "transition" ? { "line-dasharray": [2, 2] } : {}),
},
});
});
this.fitMapToRoute(route);
this.renderRoutePanel();
}
private clearRoute() {
if (!this.map) return;
// Remove all route layers/sources
for (let i = 0; i < 10; i++) {
try { this.map.removeLayer(`route-layer-${i}`); } catch {}
try { this.map.removeSource(`route-seg-${i}`); } catch {}
}
this.activeRoute = null;
const routePanel = this.shadow.getElementById("route-panel");
if (routePanel) routePanel.remove();
}
private fitMapToRoute(route: { segments: any[] }) {
if (!this.map || !(window as any).maplibregl) return;
const bounds = new (window as any).maplibregl.LngLatBounds();
for (const seg of route.segments) {
for (const coord of seg.coordinates) {
bounds.extend(coord);
}
}
if (!bounds.isEmpty()) {
this.map.fitBounds(bounds, { padding: 60, maxZoom: 16 });
}
}
private renderRoutePanel() {
if (!this.activeRoute) return;
let routePanel = this.shadow.getElementById("route-panel");
if (!routePanel) {
routePanel = document.createElement("div");
routePanel.id = "route-panel";
routePanel.style.cssText = `
position:absolute;bottom:12px;left:12px;right:12px;z-index:5;
background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);
border-radius:10px;padding:14px;box-shadow:0 4px 16px rgba(0,0,0,0.3);
`;
this.shadow.getElementById("map-container")?.appendChild(routePanel);
}
const segTypeLabels: Record<string, string> = { outdoor: "Outdoor", indoor: "Indoor", transition: "Transition" };
const segTypeColors: Record<string, string> = { outdoor: "#3b82f6", indoor: "#8b5cf6", transition: "#f97316" };
routePanel.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="font-size:14px;font-weight:600;color:var(--rs-text-primary);">
Route to ${this.esc(this.activeRoute.destination)}
</div>
<button id="close-route" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:16px;">\u2715</button>
</div>
<div style="display:flex;gap:16px;font-size:13px;color:var(--rs-text-secondary);margin-bottom:8px;">
<span>\u{1F4CF} ${formatDistance(this.activeRoute.totalDistance)}</span>
<span>\u{23F1} ${formatTime(this.activeRoute.estimatedTime)}</span>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
${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;">
${segTypeLabels[seg.type] || seg.type}: ${formatDistance(seg.distance)}
</span>
`).join("")}
</div>
`;
routePanel.querySelector("#close-route")?.addEventListener("click", () => this.clearRoute());
}
// ─── Navigation panel (participant/waypoint selection) ───────
private async requestRoute(targetLat: number, targetLng: number, targetName: string) {
// Get user's current location
const myState = this.sync?.getState().participants[this.participantId];
if (!myState?.location) {
this.error = "Share your location first to get directions";
return;
}
const base = this.getApiBase();
try {
const res = await fetch(`${base}/api/routing`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: { lat: myState.location.latitude, lng: myState.location.longitude },
to: { lat: targetLat, lng: targetLng },
mode: "walking",
}),
signal: AbortSignal.timeout(12000),
});
if (res.ok) {
const data = await res.json();
if (data.success && data.route) {
this.showRoute(data.route, targetName);
} else {
this.error = "No route found";
}
}
} catch {
this.error = "Routing request failed";
}
}
private renderNavigationPanel() {
let navPanel = this.shadow.getElementById("nav-panel");
// Get target details
const state = this.sync?.getState();
let targetName = "";
let targetLat = 0;
let targetLng = 0;
let targetEmoji = "";
let targetDetail = "";
if (this.selectedParticipant && state) {
const p = state.participants[this.selectedParticipant];
if (p?.location) {
targetName = p.name;
targetEmoji = p.emoji;
targetLat = p.location.latitude;
targetLng = p.location.longitude;
const myLoc = state.participants[this.participantId]?.location;
if (myLoc) {
targetDetail = formatDistance(haversineDistance(myLoc.latitude, myLoc.longitude, targetLat, targetLng)) + " away";
}
}
} else if (this.selectedWaypoint && state) {
const wp = state.waypoints.find(w => w.id === this.selectedWaypoint);
if (wp) {
targetName = wp.name;
targetEmoji = wp.emoji || "\u{1F4CD}";
targetLat = wp.latitude;
targetLng = wp.longitude;
const myLoc = state.participants[this.participantId]?.location;
if (myLoc) {
targetDetail = formatDistance(haversineDistance(myLoc.latitude, myLoc.longitude, targetLat, targetLng)) + " away";
}
}
}
if (!targetName) {
if (navPanel) navPanel.remove();
return;
}
if (!navPanel) {
navPanel = document.createElement("div");
navPanel.id = "nav-panel";
navPanel.style.cssText = `
position:absolute;top:12px;left:12px;z-index:5;
background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);
border-radius:10px;padding:12px;box-shadow:0 4px 16px rgba(0,0,0,0.3);
min-width:180px;
`;
this.shadow.getElementById("map-container")?.appendChild(navPanel);
}
navPanel.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span style="font-size:20px;">${targetEmoji}</span>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;color:var(--rs-text-primary);">${this.esc(targetName)}</div>
${targetDetail ? `<div style="font-size:11px;color:var(--rs-text-muted);">${targetDetail}</div>` : ""}
</div>
<button id="close-nav" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:14px;">\u2715</button>
</div>
<button id="navigate-btn" style="width:100%;padding:8px;border-radius:6px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:12px;">\u{1F9ED} Navigate</button>
`;
navPanel.querySelector("#close-nav")?.addEventListener("click", () => {
this.selectedParticipant = null;
this.selectedWaypoint = null;
navPanel?.remove();
});
navPanel.querySelector("#navigate-btn")?.addEventListener("click", () => {
this.requestRoute(targetLat, targetLng, targetName);
});
}
// ─── 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>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
* { box-sizing: border-box; }
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 13px; }
.rapp-nav__back:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status-dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
}
.status-connected { background: #22c55e; }
.status-disconnected { background: #ef4444; }
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
.rapp-nav__btn:hover { background: #6366f1; }
.room-card {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px;
padding: 16px; margin-bottom: 12px; cursor: pointer; transition: border-color 0.2s;
display: flex; align-items: center; gap: 12px;
}
.room-card:hover { border-color: var(--rs-border-strong); }
.room-icon { font-size: 24px; }
.room-name { font-size: 15px; font-weight: 600; }
.map-container {
width: 100%; height: calc(100vh - 220px); min-height: 300px; max-height: 700px;
border-radius: 10px;
background: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border);
position: relative; overflow: hidden;
}
.map-layout {
display: flex; gap: 12px;
}
.map-main { flex: 1; min-width: 0; }
.map-sidebar {
width: 220px; flex-shrink: 0;
background: var(--rs-glass-bg); border: 1px solid var(--rs-border); border-radius: 10px;
padding: 12px; max-height: 560px; overflow-y: auto;
}
.sidebar-title {
font-size: 11px; font-weight: 600; color: var(--rs-text-secondary);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;
}
.controls {
display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;
}
.ctrl-btn {
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--rs-border);
background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 13px;
}
.ctrl-btn:hover { border-color: var(--rs-border-strong); }
.ctrl-btn.sharing {
border-color: #22c55e; color: #22c55e; animation: pulse 2s infinite;
}
.ctrl-btn.ghost {
border-color: #8b5cf6; color: #8b5cf6;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.share-link {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px;
padding: 12px; margin-top: 12px; font-family: monospace; font-size: 12px;
color: var(--rs-text-secondary); display: flex; align-items: center; gap: 8px;
}
.share-link span { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.copy-btn {
padding: 4px 10px; border-radius: 4px; border: 1px solid var(--rs-border);
background: var(--rs-btn-secondary-bg); color: var(--rs-text-secondary); cursor: pointer; font-size: 11px;
}
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
/* Section labels */
.section-label {
font-size: 12px; font-weight: 600; color: var(--rs-text-secondary);
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: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px;
overflow: hidden; cursor: pointer; transition: border-color 0.2s;
position: relative;
}
.history-card:hover { border-color: var(--rs-border-strong); }
.history-thumb {
width: 100%; height: 90px; object-fit: cover; display: block;
background: var(--rs-bg-surface-sunken);
}
.history-thumb-placeholder {
width: 100%; height: 90px; display: flex; align-items: center; justify-content: center;
background: var(--rs-bg-surface-sunken); font-size: 32px;
}
.history-info {
padding: 8px 10px;
}
.history-name {
font-size: 13px; font-weight: 600; color: var(--rs-text-primary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.history-time {
font-size: 10px; color: var(--rs-text-muted); margin-top: 2px;
}
.ping-btn {
position: absolute; top: 6px; right: 6px;
width: 26px; height: 26px; border-radius: 50%;
background: var(--rs-glass-bg); border: 1px solid var(--rs-border);
color: var(--rs-text-secondary); 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: calc(100vh - 160px); min-height: 250px; max-height: none; }
.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>
${this.error ? `<div style="color:#ef5350;text-align:center;padding:12px">${this.esc(this.error)}</div>` : ""}
${this.view === "lobby" ? this.renderLobby() : this.renderMap()}
`;
this.attachListeners();
this._tour.renderOverlay();
}
startTour() { this._tour.start(); }
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)}" data-collab-id="room:${this.esc(h.slug)}">
${h.thumbnail
? `<img class="history-thumb" src="${h.thumbnail}" alt="">`
: `<div class="history-thumb-placeholder">&#127760;</div>`
}
<div class="history-info">
<div class="history-name">${this.esc(h.name)}</div>
<div class="history-time">${this.timeAgo(h.lastVisited)}</div>
</div>
<button class="ping-btn" data-ping-room="${this.esc(h.slug)}" title="Ping friends in this room">&#128276;</button>
</div>
`).join("")}
</div>
` : "";
return `
<div class="rapp-nav">
<span class="rapp-nav__title">Map Rooms</span>
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
<span style="font-size:12px;color:var(--rs-text-muted);margin-right:12px">${this.syncStatus === "connected" ? "Sync online" : "Sync offline"}</span>
<button class="rapp-nav__btn" id="create-room">+ New Room</button>
<button class="rapp-nav__btn" id="btn-tour" style="background:transparent;border:1px solid var(--rs-border,#334155);color:var(--rs-text-secondary,#94a3b8);font-weight:500">Tour</button>
</div>
${this.rooms.length > 0 ? `
<div class="section-label">Active Rooms</div>
${this.rooms.map((r) => `
<div class="room-card" data-room="${r}" data-collab-id="room:${r}">
<span class="room-icon">&#128506;</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>
<p style="font-size:13px">Share the room link with friends to see each other on the map in real-time</p>
</div>
`;
}
private renderMap(): string {
return `
<div class="rapp-nav">
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="lobby">&#8592; Rooms</button>' : ''}
<span class="rapp-nav__title">\u{1F5FA} ${this.esc(this.room)}</span>
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
<span id="geo-perm-indicator" style="width:6px;height:6px;border-radius:50%;background:#64748b;" title="Geolocation: unknown"></span>
<button id="bell-toggle" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:16px;padding:4px;" title="Notifications">\u{1F514}</button>
</div>
<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:var(--rs-text-muted);font-size:12px;padding:8px 0;">Connecting...</div>
</div>
</div>
</div>
<div class="controls">
<button class="ctrl-btn ${this.sharingLocation ? "sharing" : ""} ${this.privacySettings.ghostMode ? "ghost" : ""}" id="share-location">${this.privacySettings.ghostMode ? "\u{1F47B} Ghost Mode" : this.sharingLocation ? "\u{1F4CD} Stop Sharing" : "\u{1F4CD} Share Location"}</button>
<button class="ctrl-btn" id="privacy-toggle">\u{1F6E1} Privacy</button>
<button class="ctrl-btn" id="drop-waypoint">\u{1F4CC} Drop Pin</button>
<button class="ctrl-btn" id="share-room-btn">\u{1F4E4} Share Room</button>
</div>
<div id="privacy-panel" style="display:${this.showPrivacyPanel ? "block" : "none"};background:var(--rs-bg-surface);border:1px solid var(--rs-border);border-radius:8px;padding:12px;margin-top:8px;"></div>
`;
}
private attachListeners() {
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom());
this.shadow.querySelectorAll("[data-room]").forEach((el) => {
el.addEventListener("click", () => {
const room = (el as HTMLElement).dataset.room!;
this._history.push("lobby");
this._history.push("map", { room });
this.joinRoom(room);
});
});
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", () => {
this.goBack();
});
});
this.shadow.getElementById("share-location")?.addEventListener("click", () => {
this.toggleLocationSharing();
});
this.shadow.getElementById("drop-waypoint")?.addEventListener("click", () => {
this.dropWaypoint();
});
this.shadow.getElementById("share-room-btn")?.addEventListener("click", () => {
this.showShareModal = true;
this.renderShareModal();
});
this.shadow.getElementById("privacy-toggle")?.addEventListener("click", () => {
this.showPrivacyPanel = !this.showPrivacyPanel;
const panel = this.shadow.getElementById("privacy-panel");
if (panel) {
panel.style.display = this.showPrivacyPanel ? "block" : "none";
if (this.showPrivacyPanel) this.renderPrivacyPanel();
}
});
this.shadow.getElementById("bell-toggle")?.addEventListener("click", () => {
this.pushManager?.toggle(this.room, this.participantId).then(subscribed => {
const bell = this.shadow.getElementById("bell-toggle");
if (bell) bell.textContent = subscribed ? "\u{1F514}" : "\u{1F515}";
});
});
// 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 goBack() {
const prev = this._history.back();
if (!prev) return;
if (prev.view === "lobby" && this.view === "map") {
this.leaveRoom();
}
this.view = prev.view;
if (prev.view === "lobby") this.loadStats();
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-map-viewer", FolkMapViewer);