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

386 lines
14 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.
*/
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 mapWidth = 800;
const mapHeight = 400;
const projectX = (lng: number) => ((lng + 180) * (mapWidth / 360));
const projectY = (lat: number) => ((90 - lat) * (mapHeight / 180));
const providerDots = this.providers.map((p) => {
const x = projectX(p.lng);
const y = projectY(p.lat);
const labelX = x + 10;
const labelY = y + 4;
return `
<circle cx="${x}" cy="${y}" r="6" fill="${p.color}" stroke="#fff" stroke-width="1.5" opacity="0.9">
<animate attributeName="r" values="6;8;6" dur="2s" repeatCount="indefinite" />
</circle>
<text x="${labelX}" y="${labelY}" fill="${p.color}" font-size="10" font-weight="600" font-family="system-ui, sans-serif">${this.esc(p.name)}</text>
`;
}).join("");
const legendItems = this.providers.map((p) => `
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;">
<div style="width:10px;height:10px;border-radius:50%;background:${p.color};flex-shrink:0;"></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; }
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.status-connected { background: #22c55e; }
.map-container {
width: 100%; border-radius: 10px;
background: #1a1a2e; border: 1px solid #333;
overflow: hidden; padding: 16px;
}
.map-svg { width: 100%; height: auto; }
.legend {
background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 10px;
padding: 16px; margin-top: 16px;
}
.legend-title { font-size: 13px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
@media (max-width: 768px) {
.map-container { height: 300px; }
}
</style>
<div class="rapp-nav">
<span class="rapp-nav__title">Cosmolocal Print Network</span>
<span class="status-dot status-connected"></span>
<span style="font-size:12px;color:#888;margin-left:4px;">6 providers</span>
</div>
<div class="map-container">
<svg class="map-svg" viewBox="0 0 ${mapWidth} ${mapHeight}" xmlns="http://www.w3.org/2000/svg">
<!-- Grid lines -->
<line x1="0" y1="${mapHeight / 2}" x2="${mapWidth}" y2="${mapHeight / 2}" stroke="#2a2a4e" stroke-width="0.5" stroke-dasharray="4,4" />
<line x1="${mapWidth / 2}" y1="0" x2="${mapWidth / 2}" y2="${mapHeight}" stroke="#2a2a4e" stroke-width="0.5" stroke-dasharray="4,4" />
<line x1="0" y1="${mapHeight / 4}" x2="${mapWidth}" y2="${mapHeight / 4}" stroke="#1e1e3e" stroke-width="0.3" stroke-dasharray="2,4" />
<line x1="0" y1="${mapHeight * 3 / 4}" x2="${mapWidth}" y2="${mapHeight * 3 / 4}" stroke="#1e1e3e" stroke-width="0.3" stroke-dasharray="2,4" />
<line x1="${mapWidth / 4}" y1="0" x2="${mapWidth / 4}" y2="${mapHeight}" stroke="#1e1e3e" stroke-width="0.3" stroke-dasharray="2,4" />
<line x1="${mapWidth * 3 / 4}" y1="0" x2="${mapWidth * 3 / 4}" y2="${mapHeight}" stroke="#1e1e3e" stroke-width="0.3" stroke-dasharray="2,4" />
<!-- Simplified continent outlines -->
<!-- North America -->
<path d="M80,60 Q120,55 140,80 L160,90 Q170,110 150,130 L130,150 Q110,160 100,150 L80,120 Q60,90 80,60Z" fill="#2a2a4e" opacity="0.5" />
<!-- South America -->
<path d="M160,180 Q170,170 175,190 L180,230 Q185,260 170,280 L160,290 Q145,280 150,250 L155,220 Q150,200 160,180Z" fill="#2a2a4e" opacity="0.5" />
<!-- Europe -->
<path d="M380,65 Q400,55 420,60 L440,70 Q445,85 430,95 L410,100 Q390,95 380,80Z" fill="#2a2a4e" opacity="0.5" />
<!-- Africa -->
<path d="M390,110 Q410,105 430,115 L440,140 Q445,180 430,220 L415,240 Q400,245 390,230 L385,190 Q380,150 390,110Z" fill="#2a2a4e" opacity="0.5" />
<!-- Asia -->
<path d="M440,50 Q500,40 560,55 L600,70 Q640,80 660,100 L670,130 Q660,140 640,130 L600,110 Q550,100 500,90 L460,85 Q440,75 440,50Z" fill="#2a2a4e" opacity="0.5" />
<!-- Australia -->
<path d="M600,220 Q630,210 650,220 L660,240 Q655,260 640,265 L620,260 Q600,250 600,220Z" fill="#2a2a4e" opacity="0.5" />
<!-- Japan -->
<path d="M665,85 Q670,80 672,90 L670,100 Q665,105 663,95Z" fill="#2a2a4e" opacity="0.5" />
<!-- Provider pins -->
${providerDots}
</svg>
</div>
<div class="legend">
<div class="legend-title">Print Providers</div>
${legendItems}
</div>
`;
}
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);