1009 lines
37 KiB
TypeScript
1009 lines
37 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.
|
|
*/
|
|
|
|
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
|
|
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;
|
|
|
|
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", 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(): { provider: typeof this.providers[0]; index: number }[] {
|
|
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 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="#0f172a" 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="#64748b" 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:#e2e8f0;">${this.esc(p.name)}</span>
|
|
<span style="font-size:12px;color:#64748b;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:#e2e8f0;">${this.esc(sp.name)}</div>
|
|
<div style="font-size:12px;color:#94a3b8;">${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:#94a3b8;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:#4a5568;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: #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; }
|
|
|
|
/* Zoom controls */
|
|
.zoom-controls {
|
|
display: flex; gap: 4px; align-items: center;
|
|
}
|
|
.zoom-btn {
|
|
width: 28px; height: 28px; border-radius: 6px; border: 1px solid #333;
|
|
background: #16161e; color: #94a3b8; cursor: pointer; font-size: 16px;
|
|
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
|
|
}
|
|
.zoom-btn:hover { border-color: #555; color: #e2e8f0; }
|
|
.zoom-label { font-size: 10px; color: #4a5568; 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 #1e293b; border-radius: 8px; padding: 8px 12px;
|
|
background: #0c1221; color: #e0e0e0; font-size: 13px; outline: none;
|
|
}
|
|
.search-input:focus { border-color: #6366f1; }
|
|
.search-input::placeholder { color: #4a5568; }
|
|
.geo-btn {
|
|
padding: 8px 14px; border-radius: 8px; border: 1px solid #1e293b;
|
|
background: #0c1221; color: #94a3b8; cursor: pointer; font-size: 12px; white-space: nowrap;
|
|
}
|
|
.geo-btn:hover { border-color: #334155; color: #e2e8f0; }
|
|
.geo-btn.active { border-color: #22c55e; color: #22c55e; }
|
|
|
|
.map-wrap {
|
|
width: 100%; border-radius: 12px; background: #0c1221; border: 1px solid #1e293b;
|
|
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: #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 */
|
|
.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;
|
|
}
|
|
.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: #1a1a2e; border: 1px solid #334155; border-radius: 10px;
|
|
padding: 16px; margin-top: 12px;
|
|
}
|
|
.detail-header { display: flex; align-items: center; gap: 10px; }
|
|
.detail-close { background: none; border: none; color: #64748b; font-size: 16px; cursor: pointer; padding: 4px; }
|
|
.detail-close:hover { color: #e2e8f0; }
|
|
.detail-tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
.detail-tag {
|
|
font-size: 10px; padding: 3px 8px; border-radius: 10px;
|
|
border: 1px solid #333; 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: 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); }
|
|
.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="#0f1b33" />
|
|
<stop offset="100%" stop-color="#060d1a" />
|
|
</radialGradient>
|
|
</defs>
|
|
|
|
<!-- Ocean background -->
|
|
<rect x="-200" y="-200" width="${W + 400}" height="${H + 400}" fill="url(#ocean)" />
|
|
|
|
<!-- Graticule -->
|
|
${this.graticule(W, H)}
|
|
|
|
<!-- Continents -->
|
|
${this.continents(W, H)}
|
|
|
|
<!-- 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">📍</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">🏢</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">📌</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">🔗</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">📡</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">🛡</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): 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="#1a2744" 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="#1a2744" 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="#1e3050" stroke-width="0.7" stroke-dasharray="4,3" />`);
|
|
lines.push(`<line x1="${pm}" y1="-200" x2="${pm}" y2="${H + 200}" 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";
|
|
|
|
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 });
|
|
}
|
|
|
|
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">🗺</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">← Rooms</button>
|
|
<span class="rapp-nav__title">🗺 ${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">🌎</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);
|