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

654 lines
24 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: shows 6 cosmolocal print providers on a world map with
* connection arcs, interactive hover tooltips, and a feature summary
* matching the standalone rMaps capabilities.
*/
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; lat: number; lng: number; color: string }[] = [];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.room = this.getAttribute("room") || "";
if (this.space === "demo") {
this.loadDemoData();
return;
}
if (this.room) {
this.view = "map";
}
this.checkSyncHealth();
this.render();
}
private loadDemoData() {
this.view = "map";
this.room = "cosmolocal-providers";
this.syncStatus = "connected";
this.providers = [
{ name: "Radiant Hall Press", city: "Pittsburgh, PA", lat: 40.44, lng: -79.99, color: "#ef4444" },
{ name: "Tiny Splendor", city: "Los Angeles, CA", lat: 34.05, lng: -118.24, color: "#f59e0b" },
{ name: "People's Print Shop", city: "Toronto, ON", lat: 43.65, lng: -79.38, color: "#22c55e" },
{ name: "Colour Code Press", city: "London, UK", lat: 51.51, lng: -0.13, color: "#3b82f6" },
{ name: "Druckwerkstatt Berlin", city: "Berlin, DE", lat: 52.52, lng: 13.40, color: "#8b5cf6" },
{ name: "Kink\u014D Printing Collective", city: "Tokyo, JP", lat: 35.68, lng: 139.69, color: "#ec4899" },
];
this.renderDemo();
}
private renderDemo() {
const W = 900;
const H = 460;
const px = (lng: number) => ((lng + 180) / 360) * W;
const py = (lat: number) => ((90 - lat) / 180) * H;
// Label offsets to avoid overlapping (Pittsburgh/Toronto are close)
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 (great-circle style curves)
const connections = [
[0, 2], // Pittsburgh -- Toronto
[0, 3], // Pittsburgh -- London
[3, 4], // London -- Berlin
[4, 5], // Berlin -- Tokyo
[1, 5], // LA -- Tokyo (Pacific)
[0, 1], // Pittsburgh -- LA
];
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);
// Curved midpoint -- arc above the straight line
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 (drop-pin style) and labels
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];
return `
<g class="pin-group" data-idx="${i}">
<!-- Pulse ring -->
<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>
<!-- Drop pin body -->
<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="#0f172a" stroke-width="0.8" opacity="0.92" />
<!-- Pin center dot -->
<circle cx="${x}" cy="${y - 14}" r="2.5" fill="#fff" opacity="0.85" />
<!-- Label -->
<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="#64748b" font-size="8.5" font-family="system-ui,sans-serif">${this.esc(p.city)}</text>
</g>
`;
}).join("");
// Legend items
const legendItems = this.providers.map((p) => `
<div style="display:flex;align-items:center;gap:8px;padding:5px 0;">
<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>
<span style="font-weight:600;font-size:13px;color:#e2e8f0;">${this.esc(p.name)}</span>
<span style="font-size:12px;color:#64748b;margin-left:8px;">${this.esc(p.city)}</span>
</div>
</div>
`).join("");
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { 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: #e2e8f0; min-width: 140px; }
.demo-nav__badge {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; color: #94a3b8; 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; }
.map-wrap {
width: 100%; border-radius: 12px; background: #0c1221; border: 1px solid #1e293b;
overflow: hidden; position: relative;
}
.map-svg { display: block; width: 100%; height: auto; }
/* Hover tooltip */
.pin-group { cursor: pointer; }
.tooltip {
position: absolute; pointer-events: none; opacity: 0; transition: opacity 0.15s;
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
padding: 8px 12px; font-size: 12px; color: #e2e8f0; 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: #94a3b8; font-size: 11px; }
.tooltip .coords { color: #64748b; font-size: 10px; font-family: monospace; }
.legend {
background: rgba(15,23,42,0.6); border: 1px solid #1e293b; border-radius: 10px;
padding: 16px; margin-top: 16px;
}
.legend-title {
font-size: 12px; font-weight: 600; color: #94a3b8;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;
}
/* Feature highlights row */
.features {
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px; margin-top: 16px;
}
.feat {
background: rgba(15,23,42,0.4); border: 1px solid #1e293b; 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: #e2e8f0; }
.feat-desc { font-size: 10.5px; color: #64748b; margin-top: 2px; line-height: 1.4; }
@media (max-width: 640px) {
.features { grid-template-columns: repeat(2, 1fr); }
}
</style>
<div class="demo-nav">
<span class="demo-nav__title">Cosmolocal Print Network</span>
<span class="demo-nav__badge"><span class="dot"></span> 6 providers online</span>
</div>
<div class="map-wrap">
<div class="tooltip" id="tooltip"></div>
<svg class="map-svg" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="ocean" cx="50%" cy="40%" r="70%">
<stop offset="0%" stop-color="#0f1b33" />
<stop offset="100%" stop-color="#060d1a" />
</radialGradient>
</defs>
<!-- Ocean background -->
<rect width="${W}" height="${H}" fill="url(#ocean)" />
<!-- Graticule (lat/lng grid) -->
${this.graticule(W, H)}
<!-- Simplified continent fills -->
${this.continents(W, H)}
<!-- Connection arcs -->
${arcs}
<!-- Provider pins -->
${pins}
</svg>
</div>
<div class="legend">
<div class="legend-title">Print Providers</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();
}
/** Generate SVG graticule lines */
private graticule(W: number, H: number): string {
const lines: string[] = [];
// Latitude lines every 30 degrees
for (let lat = -60; lat <= 60; lat += 30) {
const y = ((90 - lat) / 180) * H;
lines.push(`<line x1="0" y1="${y}" x2="${W}" y2="${y}" stroke="#1a2744" stroke-width="0.5" stroke-dasharray="3,5" />`);
}
// Longitude lines every 30 degrees
for (let lng = -150; lng <= 180; lng += 30) {
const x = ((lng + 180) / 360) * W;
lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${H}" stroke="#1a2744" stroke-width="0.5" stroke-dasharray="3,5" />`);
}
// Equator and Prime Meridian slightly brighter
const eq = ((90 - 0) / 180) * H;
const pm = ((0 + 180) / 360) * W;
lines.push(`<line x1="0" y1="${eq}" x2="${W}" y2="${eq}" stroke="#1e3050" stroke-width="0.7" stroke-dasharray="4,3" />`);
lines.push(`<line x1="${pm}" y1="0" x2="${pm}" y2="${H}" stroke="#1e3050" stroke-width="0.7" stroke-dasharray="4,3" />`);
return lines.join("\n");
}
/** Simplified continent outlines using equirectangular projection */
private continents(W: number, H: number): 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 fill = "#162236";
const stroke = "#1e3050";
// Each continent as a polygon path
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 (mainland)
`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 (simplified)
`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 (simplified)
`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() {
const tooltip = this.shadow.getElementById("tooltip");
if (!tooltip) return;
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 = this.shadow.querySelector(".map-wrap")?.getBoundingClientRect();
const me = e as MouseEvent;
if (rect) {
tooltip.innerHTML = `<strong>${this.esc(p.name)}</strong><span class="city">${this.esc(p.city)}</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 = this.shadow.querySelector(".map-wrap")?.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");
});
});
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/maps/);
return match ? `/${match[1]}/maps` : "";
}
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();
}
private joinRoom(slug: string) {
this.room = slug;
this.view = "map";
this.render();
}
private createRoom() {
const name = prompt("Room name (slug):");
if (!name?.trim()) return;
const slug = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-");
this.joinRoom(slug);
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { 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 rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; }
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; 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: #1e1e2e; border: 1px solid #333; 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: #555; }
.room-icon { font-size: 24px; }
.room-name { font-size: 15px; font-weight: 600; }
.map-container {
width: 100%; height: 500px; border-radius: 10px;
background: #1a1a2e; border: 1px solid #333;
display: flex; align-items: center; justify-content: center;
position: relative; overflow: hidden;
}
.map-placeholder {
text-align: center; color: #666; padding: 40px;
}
.map-placeholder p { margin: 8px 0; }
.controls {
display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;
}
.ctrl-btn {
padding: 8px 16px; border-radius: 8px; border: 1px solid #444;
background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 13px;
}
.ctrl-btn:hover { border-color: #666; }
.ctrl-btn.sharing {
border-color: #22c55e; color: #22c55e; animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.share-link {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 12px; margin-top: 12px; font-family: monospace; font-size: 12px;
color: #aaa; 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 #444;
background: #2a2a3e; color: #ccc; cursor: pointer; font-size: 11px;
}
.empty { text-align: center; color: #666; padding: 40px; }
@media (max-width: 768px) {
.map-container { height: 300px; }
}
</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();
}
private renderLobby(): string {
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:#888;margin-right:12px">${this.syncStatus === "connected" ? "Sync online" : "Sync offline"}</span>
<button class="rapp-nav__btn" id="create-room">+ New Room</button>
</div>
${this.rooms.length > 0 ? this.rooms.map((r) => `
<div class="room-card" data-room="${r}">
<span class="room-icon">&#128506;</span>
<span class="room-name">${this.esc(r)}</span>
</div>
`).join("") : ""}
<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 {
const shareUrl = `${window.location.origin}/${this.space}/maps/${this.room}`;
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="lobby">&#8592; Rooms</button>
<span class="rapp-nav__title">&#128506; ${this.esc(this.room)}</span>
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
</div>
<div class="map-container">
<div class="map-placeholder">
<p style="font-size:48px">&#127758;</p>
<p style="font-size:16px">Map Room: <strong>${this.esc(this.room)}</strong></p>
<p>Connect the MapLibre GL library to display the interactive map.</p>
<p style="font-size:12px;color:#555">WebSocket sync: ${this.syncStatus}</p>
</div>
</div>
<div class="controls">
<button class="ctrl-btn" id="share-location">Share My Location</button>
<button class="ctrl-btn" id="copy-link">Copy Room Link</button>
</div>
<div class="share-link">
<span>${this.esc(shareUrl)}</span>
<button class="copy-btn" id="copy-url">Copy</button>
</div>
`;
}
private attachListeners() {
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.joinRoom(room);
});
});
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", () => {
this.view = "lobby";
this.loadStats();
});
});
this.shadow.getElementById("share-location")?.addEventListener("click", () => {
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const btn = this.shadow.getElementById("share-location");
if (btn) {
btn.textContent = `Location: ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`;
btn.classList.add("sharing");
}
},
() => {
this.error = "Location access denied";
this.render();
}
);
}
});
const copyUrl = this.shadow.getElementById("copy-url") || this.shadow.getElementById("copy-link");
copyUrl?.addEventListener("click", () => {
const url = `${window.location.origin}/${this.space}/maps/${this.room}`;
navigator.clipboard.writeText(url).then(() => {
if (copyUrl) copyUrl.textContent = "Copied!";
setTimeout(() => { if (copyUrl) copyUrl.textContent = "Copy"; }, 2000);
});
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-map-viewer", FolkMapViewer);