feat(rmaps): theme support, mobile fix, theme-aware map tiles

Replace all hardcoded dark colors with --rs-* CSS variables in both
demo and room modes. Add LIGHT_STYLE (CARTO voyager) tiles and
MutationObserver to swap MapLibre styles on theme toggle. Make SVG
demo map theme-aware (ocean, continents, graticule, pins). Fix mobile
layout with calc(100vh) sizing instead of fixed heights. Remove
hardcoded theme: "dark" from mod.ts renderShell calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 14:22:11 -07:00
parent 53af7fc057
commit 1f6b019dbf
3 changed files with 152 additions and 85 deletions

View File

@ -31,6 +31,19 @@ const DARK_STYLE = {
layers: [{ id: "carto", type: "raster", source: "carto" }], layers: [{ id: "carto", type: "raster", source: "carto" }],
}; };
const LIGHT_STYLE = {
version: 8,
sources: {
carto: {
type: "raster",
tiles: ["https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png"],
tileSize: 256,
attribution: '&copy; <a href="https://carto.com/">CARTO</a> &copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>',
},
},
layers: [{ id: "carto", type: "raster", source: "carto" }],
};
const PARTICIPANT_COLORS = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316"]; const 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}"]; const EMOJIS = ["\u{1F9ED}", "\u{1F30D}", "\u{1F680}", "\u{1F308}", "\u{2B50}", "\u{1F525}", "\u{1F33F}", "\u{1F30A}", "\u{26A1}", "\u{1F48E}"];
@ -74,6 +87,13 @@ class FolkMapViewer extends HTMLElement {
private watchId: number | null = null; private watchId: number | null = null;
private pushManager: MapPushManager | null = null; private pushManager: MapPushManager | null = null;
private thumbnailTimer: ReturnType<typeof setTimeout> | null = null; private thumbnailTimer: ReturnType<typeof setTimeout> | null = null;
private _themeObserver: MutationObserver | null = null;
private isDarkTheme(): boolean {
const theme = document.documentElement.getAttribute("data-theme");
if (theme) return theme === "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
constructor() { constructor() {
super(); super();
@ -99,6 +119,10 @@ class FolkMapViewer extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
this.leaveRoom(); this.leaveRoom();
if (this._themeObserver) {
this._themeObserver.disconnect();
this._themeObserver = null;
}
} }
// ─── User profile ──────────────────────────────────────────── // ─── User profile ────────────────────────────────────────────
@ -139,6 +163,10 @@ class FolkMapViewer extends HTMLElement {
this.view = "map"; this.view = "map";
this.room = "cosmolocal-providers"; this.room = "cosmolocal-providers";
this.syncStatus = "connected"; 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 = [ this.providers = [
{ {
name: "Radiant Hall Press", city: "Pittsburgh", country: "USA", name: "Radiant Hall Press", city: "Pittsburgh", country: "USA",
@ -195,6 +223,18 @@ class FolkMapViewer extends HTMLElement {
private renderDemo() { private renderDemo() {
const W = 900; const W = 900;
const H = 460; 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 px = (lng: number) => ((lng + 180) / 360) * W;
const py = (lat: number) => ((90 - lat) / 180) * H; const py = (lat: number) => ((90 - lat) / 180) * H;
@ -253,10 +293,10 @@ class FolkMapViewer extends HTMLElement {
<animate attributeName="opacity" values="0.5;0;0.5" dur="3s" repeatCount="indefinite" /> <animate attributeName="opacity" values="0.5;0;0.5" dur="3s" repeatCount="indefinite" />
</circle> </circle>
${isSelected ? `<circle cx="${x}" cy="${y}" r="18" fill="none" stroke="${p.color}" stroke-width="2" opacity="0.6" />` : ""} ${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" /> <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" /> <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}" 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> <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> </g>
`; `;
}).join(""); }).join("");
@ -266,8 +306,8 @@ class FolkMapViewer extends HTMLElement {
<div class="legend-item ${this.selectedProvider === i ? "selected" : ""}" data-legend="${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="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"> <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-weight:600;font-size:13px;color:var(--rs-text-primary);">${this.esc(p.name)}</span>
<span style="font-size:12px;color:#64748b;margin-left:8px;">${this.esc(p.city)}, ${this.esc(p.country)}</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>
</div> </div>
`).join(""); `).join("");
@ -281,16 +321,16 @@ class FolkMapViewer extends HTMLElement {
<div class="detail-header"> <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="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="flex:1">
<div style="font-size:15px;font-weight:600;color:#e2e8f0;">${this.esc(sp.name)}</div> <div style="font-size:15px;font-weight:600;color:var(--rs-text-primary);">${this.esc(sp.name)}</div>
<div style="font-size:12px;color:#94a3b8;">${this.esc(sp.city)}, ${this.esc(sp.country)}</div> <div style="font-size:12px;color:var(--rs-text-secondary);">${this.esc(sp.city)}, ${this.esc(sp.country)}</div>
</div> </div>
<button class="detail-close" id="detail-close">\u2715</button> <button class="detail-close" id="detail-close">\u2715</button>
</div> </div>
<p style="font-size:13px;color:#94a3b8;line-height:1.5;margin:10px 0;">${this.esc(sp.desc)}</p> <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"> <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("")} ${sp.specialties.map(s => `<span class="detail-tag" style="border-color:${sp.color}40;color:${sp.color}">${this.esc(s)}</span>`).join("")}
</div> </div>
<div style="font-size:11px;color:#4a5568;margin-top:10px;font-family:monospace;"> <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"} ${sp.lat.toFixed(4)}\u00B0N, ${Math.abs(sp.lng).toFixed(4)}\u00B0${sp.lng >= 0 ? "E" : "W"}
</div> </div>
<div style="display:flex;gap:8px;margin-top:12px;"> <div style="display:flex;gap:8px;margin-top:12px;">
@ -301,15 +341,15 @@ class FolkMapViewer extends HTMLElement {
this.shadow.innerHTML = ` this.shadow.innerHTML = `
<style> <style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; } :host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
* { box-sizing: border-box; } * { box-sizing: border-box; }
.demo-nav { .demo-nav {
display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; flex-wrap: wrap; 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__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); min-width: 140px; }
.demo-nav__badge { .demo-nav__badge {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; color: #94a3b8; background: rgba(16,185,129,0.1); 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; 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; } .demo-nav__badge .dot { width: 6px; height: 6px; border-radius: 50%; background: #22c55e; }
@ -319,29 +359,29 @@ class FolkMapViewer extends HTMLElement {
display: flex; gap: 4px; align-items: center; display: flex; gap: 4px; align-items: center;
} }
.zoom-btn { .zoom-btn {
width: 28px; height: 28px; border-radius: 6px; border: 1px solid #333; width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--rs-border);
background: #16161e; color: #94a3b8; cursor: pointer; font-size: 16px; 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; display: flex; align-items: center; justify-content: center; transition: all 0.15s;
} }
.zoom-btn:hover { border-color: #555; color: #e2e8f0; } .zoom-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
.zoom-label { font-size: 10px; color: #4a5568; font-variant-numeric: tabular-nums; min-width: 32px; text-align: center; } .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-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.search-input { .search-input {
flex: 1; border: 1px solid #1e293b; border-radius: 8px; padding: 8px 12px; flex: 1; border: 1px solid var(--rs-border); border-radius: 8px; padding: 8px 12px;
background: #0c1221; color: #e0e0e0; font-size: 13px; outline: none; background: var(--rs-input-bg); color: var(--rs-text-primary); font-size: 13px; outline: none;
} }
.search-input:focus { border-color: #6366f1; } .search-input:focus { border-color: #6366f1; }
.search-input::placeholder { color: #4a5568; } .search-input::placeholder { color: var(--rs-text-muted); }
.geo-btn { .geo-btn {
padding: 8px 14px; border-radius: 8px; border: 1px solid #1e293b; padding: 8px 14px; border-radius: 8px; border: 1px solid var(--rs-border);
background: #0c1221; color: #94a3b8; cursor: pointer; font-size: 12px; white-space: nowrap; background: var(--rs-input-bg); color: var(--rs-text-secondary); cursor: pointer; font-size: 12px; white-space: nowrap;
} }
.geo-btn:hover { border-color: #334155; color: #e2e8f0; } .geo-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
.geo-btn.active { border-color: #22c55e; color: #22c55e; } .geo-btn.active { border-color: #22c55e; color: #22c55e; }
.map-wrap { .map-wrap {
width: 100%; border-radius: 12px; background: #0c1221; border: 1px solid #1e293b; width: 100%; border-radius: 12px; background: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border);
overflow: hidden; position: relative; cursor: grab; overflow: hidden; position: relative; cursor: grab;
} }
.map-wrap.dragging { cursor: grabbing; } .map-wrap.dragging { cursor: grabbing; }
@ -351,22 +391,22 @@ class FolkMapViewer extends HTMLElement {
.pin-group { cursor: pointer; } .pin-group { cursor: pointer; }
.tooltip { .tooltip {
position: absolute; pointer-events: none; opacity: 0; transition: opacity 0.15s; position: absolute; pointer-events: none; opacity: 0; transition: opacity 0.15s;
background: #1e293b; border: 1px solid #334155; border-radius: 8px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 8px;
padding: 8px 12px; font-size: 12px; color: #e2e8f0; white-space: nowrap; 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; box-shadow: 0 4px 12px rgba(0,0,0,0.4); z-index: 10;
} }
.tooltip.visible { opacity: 1; } .tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 2px; } .tooltip strong { display: block; margin-bottom: 2px; }
.tooltip .city { color: #94a3b8; font-size: 11px; } .tooltip .city { color: var(--rs-text-secondary); font-size: 11px; }
.tooltip .coords { color: #64748b; font-size: 10px; font-family: monospace; } .tooltip .coords { color: var(--rs-text-muted); font-size: 10px; font-family: monospace; }
/* Legend */ /* Legend */
.legend { .legend {
background: rgba(15,23,42,0.6); border: 1px solid #1e293b; border-radius: 10px; background: var(--rs-glass-bg); border: 1px solid var(--rs-border); border-radius: 10px;
padding: 16px; margin-top: 16px; padding: 16px; margin-top: 16px;
} }
.legend-title { .legend-title {
font-size: 12px; font-weight: 600; color: #94a3b8; font-size: 12px; font-weight: 600; color: var(--rs-text-secondary);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;
} }
.legend-item { .legend-item {
@ -378,16 +418,16 @@ class FolkMapViewer extends HTMLElement {
/* Detail panel */ /* Detail panel */
.detail-panel { .detail-panel {
background: #1a1a2e; border: 1px solid #334155; border-radius: 10px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 10px;
padding: 16px; margin-top: 12px; padding: 16px; margin-top: 12px;
} }
.detail-header { display: flex; align-items: center; gap: 10px; } .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 { background: none; border: none; color: var(--rs-text-muted); font-size: 16px; cursor: pointer; padding: 4px; }
.detail-close:hover { color: #e2e8f0; } .detail-close:hover { color: var(--rs-text-primary); }
.detail-tags { display: flex; gap: 6px; flex-wrap: wrap; } .detail-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.detail-tag { .detail-tag {
font-size: 10px; padding: 3px 8px; border-radius: 10px; font-size: 10px; padding: 3px 8px; border-radius: 10px;
border: 1px solid #333; font-weight: 500; border: 1px solid var(--rs-border); font-weight: 500;
} }
/* Feature highlights row */ /* Feature highlights row */
@ -396,12 +436,12 @@ class FolkMapViewer extends HTMLElement {
gap: 10px; margin-top: 16px; gap: 10px; margin-top: 16px;
} }
.feat { .feat {
background: rgba(15,23,42,0.4); border: 1px solid #1e293b; border-radius: 10px; background: var(--rs-glass-bg); border: 1px solid var(--rs-border); border-radius: 10px;
padding: 12px; text-align: center; padding: 12px; text-align: center;
} }
.feat-icon { font-size: 20px; margin-bottom: 4px; } .feat-icon { font-size: 20px; margin-bottom: 4px; }
.feat-label { font-size: 12px; font-weight: 600; color: #e2e8f0; } .feat-label { font-size: 12px; font-weight: 600; color: var(--rs-text-primary); }
.feat-desc { font-size: 10.5px; color: #64748b; margin-top: 2px; line-height: 1.4; } .feat-desc { font-size: 10.5px; color: var(--rs-text-muted); margin-top: 2px; line-height: 1.4; }
@media (max-width: 640px) { @media (max-width: 640px) {
.features { grid-template-columns: repeat(2, 1fr); } .features { grid-template-columns: repeat(2, 1fr); }
@ -430,8 +470,8 @@ class FolkMapViewer extends HTMLElement {
<svg class="map-svg" id="map-svg" viewBox="${this.vbX} ${this.vbY} ${this.vbW} ${this.vbH}" xmlns="http://www.w3.org/2000/svg"> <svg class="map-svg" id="map-svg" viewBox="${this.vbX} ${this.vbY} ${this.vbW} ${this.vbH}" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
<radialGradient id="ocean" cx="50%" cy="40%" r="70%"> <radialGradient id="ocean" cx="50%" cy="40%" r="70%">
<stop offset="0%" stop-color="#0f1b33" /> <stop offset="0%" stop-color="${oceanStop1}" />
<stop offset="100%" stop-color="#060d1a" /> <stop offset="100%" stop-color="${oceanStop2}" />
</radialGradient> </radialGradient>
</defs> </defs>
@ -439,10 +479,10 @@ class FolkMapViewer extends HTMLElement {
<rect x="-200" y="-200" width="${W + 400}" height="${H + 400}" fill="url(#ocean)" /> <rect x="-200" y="-200" width="${W + 400}" height="${H + 400}" fill="url(#ocean)" />
<!-- Graticule --> <!-- Graticule -->
${this.graticule(W, H)} ${this.graticule(W, H, graticuleLine, graticuleStrong)}
<!-- Continents --> <!-- Continents -->
${this.continents(W, H)} ${this.continents(W, H, continentFill, continentStroke)}
<!-- Connection arcs --> <!-- Connection arcs -->
${arcs} ${arcs}
@ -525,34 +565,31 @@ class FolkMapViewer extends HTMLElement {
} }
/** Generate SVG graticule lines */ /** Generate SVG graticule lines */
private graticule(W: number, H: number): string { private graticule(W: number, H: number, lineColor: string, strongColor: string): string {
const lines: string[] = []; const lines: string[] = [];
for (let lat = -60; lat <= 60; lat += 30) { for (let lat = -60; lat <= 60; lat += 30) {
const y = ((90 - lat) / 180) * H; 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" />`); 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) { for (let lng = -150; lng <= 180; lng += 30) {
const x = ((lng + 180) / 360) * W; 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" />`); 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 eq = ((90 - 0) / 180) * H;
const pm = ((0 + 180) / 360) * W; 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="-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="#1e3050" 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"); return lines.join("\n");
} }
/** Simplified continent outlines using equirectangular projection */ /** Simplified continent outlines using equirectangular projection */
private continents(W: number, H: number): string { private continents(W: number, H: number, fill: string, stroke: string): string {
const p = (lat: number, lng: number) => { const p = (lat: number, lng: number) => {
const x = ((lng + 180) / 360) * W; const x = ((lng + 180) / 360) * W;
const y = ((90 - lat) / 180) * H; const y = ((90 - lat) / 180) * H;
return `${x.toFixed(1)},${y.toFixed(1)}`; return `${x.toFixed(1)},${y.toFixed(1)}`;
}; };
const fill = "#162236";
const stroke = "#1e3050";
const continents = [ const continents = [
// North America // North America
`M${p(50, -130)} L${p(60, -130)} L${p(65, -120)} L${p(70, -100)} L${p(72, -80)} `M${p(50, -130)} L${p(60, -130)} L${p(65, -120)} L${p(70, -100)} L${p(72, -80)}
@ -926,6 +963,10 @@ class FolkMapViewer extends HTMLElement {
this.map.remove(); this.map.remove();
this.map = null; this.map = null;
} }
if (this._themeObserver) {
this._themeObserver.disconnect();
this._themeObserver = null;
}
if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer); if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer);
} }
@ -953,7 +994,7 @@ class FolkMapViewer extends HTMLElement {
this.map = new (window as any).maplibregl.Map({ this.map = new (window as any).maplibregl.Map({
container, container,
style: DARK_STYLE, style: this.isDarkTheme() ? DARK_STYLE : LIGHT_STYLE,
center: [0, 20], center: [0, 20],
zoom: 2, zoom: 2,
preserveDrawingBuffer: true, preserveDrawingBuffer: true,
@ -965,6 +1006,13 @@ class FolkMapViewer extends HTMLElement {
trackUserLocation: false, trackUserLocation: false,
}), "top-right"); }), "top-right");
// Theme observer — swap map tiles on toggle
this._themeObserver = new MutationObserver(() => {
this.map?.setStyle(this.isDarkTheme() ? DARK_STYLE : LIGHT_STYLE);
this.updateMarkerTheme();
});
this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
// Debounced thumbnail capture on moveend // Debounced thumbnail capture on moveend
this.map.on("moveend", () => { this.map.on("moveend", () => {
if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer); if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer);
@ -1034,11 +1082,14 @@ class FolkMapViewer extends HTMLElement {
if (this.participantMarkers.has(id)) { if (this.participantMarkers.has(id)) {
this.participantMarkers.get(id).setLngLat(lngLat); this.participantMarkers.get(id).setLngLat(lngLat);
} else { } 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"); const el = document.createElement("div");
el.className = "participant-marker"; el.className = "participant-marker";
el.style.cssText = ` el.style.cssText = `
width: 36px; height: 36px; border-radius: 50%; width: 36px; height: 36px; border-radius: 50%;
border: 3px solid ${p.color}; background: #1a1a2e; border: 3px solid ${p.color}; background: ${markerBg};
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
font-size: 18px; cursor: pointer; position: relative; font-size: 18px; cursor: pointer; position: relative;
box-shadow: 0 0 8px ${p.color}60; box-shadow: 0 0 8px ${p.color}60;
@ -1048,10 +1099,11 @@ class FolkMapViewer extends HTMLElement {
// Name label below // Name label below
const label = document.createElement("div"); const label = document.createElement("div");
label.className = "marker-label";
label.style.cssText = ` label.style.cssText = `
position: absolute; bottom: -18px; left: 50%; transform: translateX(-50%); position: absolute; bottom: -18px; left: 50%; transform: translateX(-50%);
font-size: 10px; color: ${p.color}; font-weight: 600; font-size: 10px; color: ${p.color}; font-weight: 600;
white-space: nowrap; text-shadow: 0 1px 3px rgba(0,0,0,0.8); white-space: nowrap; text-shadow: 0 1px 3px ${textShadow};
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
`; `;
label.textContent = p.name; label.textContent = p.name;
@ -1129,6 +1181,19 @@ class FolkMapViewer extends HTMLElement {
}); });
} }
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}`;
}
}
// ─── Location sharing ──────────────────────────────────────── // ─── Location sharing ────────────────────────────────────────
private toggleLocationSharing() { private toggleLocationSharing() {
@ -1247,13 +1312,13 @@ class FolkMapViewer extends HTMLElement {
private render() { private render() {
this.shadow.innerHTML = ` this.shadow.innerHTML = `
<style> <style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; } :host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
* { box-sizing: border-box; } * { box-sizing: border-box; }
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; } .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 { 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: #e2e8f0; border-color: rgba(255,255,255,0.2); } .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: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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 { .status-dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block; width: 8px; height: 8px; border-radius: 50%; display: inline-block;
@ -1265,17 +1330,18 @@ class FolkMapViewer extends HTMLElement {
.rapp-nav__btn:hover { background: #6366f1; } .rapp-nav__btn:hover { background: #6366f1; }
.room-card { .room-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 10px; 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; padding: 16px; margin-bottom: 12px; cursor: pointer; transition: border-color 0.2s;
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
} }
.room-card:hover { border-color: #555; } .room-card:hover { border-color: var(--rs-border-strong); }
.room-icon { font-size: 24px; } .room-icon { font-size: 24px; }
.room-name { font-size: 15px; font-weight: 600; } .room-name { font-size: 15px; font-weight: 600; }
.map-container { .map-container {
width: 100%; height: 500px; border-radius: 10px; width: 100%; height: calc(100vh - 220px); min-height: 300px; max-height: 700px;
background: #1a1a2e; border: 1px solid #333; border-radius: 10px;
background: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border);
position: relative; overflow: hidden; position: relative; overflow: hidden;
} }
@ -1285,11 +1351,11 @@ class FolkMapViewer extends HTMLElement {
.map-main { flex: 1; min-width: 0; } .map-main { flex: 1; min-width: 0; }
.map-sidebar { .map-sidebar {
width: 220px; flex-shrink: 0; width: 220px; flex-shrink: 0;
background: rgba(15,23,42,0.6); border: 1px solid #1e293b; border-radius: 10px; background: var(--rs-glass-bg); border: 1px solid var(--rs-border); border-radius: 10px;
padding: 12px; max-height: 560px; overflow-y: auto; padding: 12px; max-height: 560px; overflow-y: auto;
} }
.sidebar-title { .sidebar-title {
font-size: 11px; font-weight: 600; color: #94a3b8; font-size: 11px; font-weight: 600; color: var(--rs-text-secondary);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;
} }
@ -1297,10 +1363,10 @@ class FolkMapViewer extends HTMLElement {
display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;
} }
.ctrl-btn { .ctrl-btn {
padding: 8px 16px; border-radius: 8px; border: 1px solid #444; padding: 8px 16px; border-radius: 8px; border: 1px solid var(--rs-border);
background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 13px; background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 13px;
} }
.ctrl-btn:hover { border-color: #666; } .ctrl-btn:hover { border-color: var(--rs-border-strong); }
.ctrl-btn.sharing { .ctrl-btn.sharing {
border-color: #22c55e; color: #22c55e; animation: pulse 2s infinite; border-color: #22c55e; color: #22c55e; animation: pulse 2s infinite;
} }
@ -1310,21 +1376,21 @@ class FolkMapViewer extends HTMLElement {
} }
.share-link { .share-link {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px; 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; padding: 12px; margin-top: 12px; font-family: monospace; font-size: 12px;
color: #aaa; display: flex; align-items: center; gap: 8px; color: var(--rs-text-secondary); display: flex; align-items: center; gap: 8px;
} }
.share-link span { flex: 1; overflow: hidden; text-overflow: ellipsis; } .share-link span { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.copy-btn { .copy-btn {
padding: 4px 10px; border-radius: 4px; border: 1px solid #444; padding: 4px 10px; border-radius: 4px; border: 1px solid var(--rs-border);
background: #2a2a3e; color: #ccc; cursor: pointer; font-size: 11px; background: var(--rs-btn-secondary-bg); color: var(--rs-text-secondary); cursor: pointer; font-size: 11px;
} }
.empty { text-align: center; color: #666; padding: 40px; } .empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
/* Section labels */ /* Section labels */
.section-label { .section-label {
font-size: 12px; font-weight: 600; color: #94a3b8; font-size: 12px; font-weight: 600; color: var(--rs-text-secondary);
text-transform: uppercase; letter-spacing: 0.06em; margin: 20px 0 10px; text-transform: uppercase; letter-spacing: 0.06em; margin: 20px 0 10px;
} }
@ -1334,34 +1400,34 @@ class FolkMapViewer extends HTMLElement {
gap: 10px; margin-bottom: 16px; gap: 10px; margin-bottom: 16px;
} }
.history-card { .history-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 10px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px;
overflow: hidden; cursor: pointer; transition: border-color 0.2s; overflow: hidden; cursor: pointer; transition: border-color 0.2s;
position: relative; position: relative;
} }
.history-card:hover { border-color: #555; } .history-card:hover { border-color: var(--rs-border-strong); }
.history-thumb { .history-thumb {
width: 100%; height: 90px; object-fit: cover; display: block; width: 100%; height: 90px; object-fit: cover; display: block;
background: #0c1221; background: var(--rs-bg-surface-sunken);
} }
.history-thumb-placeholder { .history-thumb-placeholder {
width: 100%; height: 90px; display: flex; align-items: center; justify-content: center; width: 100%; height: 90px; display: flex; align-items: center; justify-content: center;
background: #0c1221; font-size: 32px; background: var(--rs-bg-surface-sunken); font-size: 32px;
} }
.history-info { .history-info {
padding: 8px 10px; padding: 8px 10px;
} }
.history-name { .history-name {
font-size: 13px; font-weight: 600; color: #e2e8f0; font-size: 13px; font-weight: 600; color: var(--rs-text-primary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.history-time { .history-time {
font-size: 10px; color: #64748b; margin-top: 2px; font-size: 10px; color: var(--rs-text-muted); margin-top: 2px;
} }
.ping-btn { .ping-btn {
position: absolute; top: 6px; right: 6px; position: absolute; top: 6px; right: 6px;
width: 26px; height: 26px; border-radius: 50%; width: 26px; height: 26px; border-radius: 50%;
background: rgba(15,23,42,0.8); border: 1px solid #333; background: var(--rs-glass-bg); border: 1px solid var(--rs-border);
color: #94a3b8; cursor: pointer; font-size: 13px; color: var(--rs-text-secondary); cursor: pointer; font-size: 13px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.15s; opacity: 0; transition: opacity 0.15s;
} }
@ -1369,7 +1435,7 @@ class FolkMapViewer extends HTMLElement {
.ping-btn:hover { border-color: #6366f1; color: #818cf8; } .ping-btn:hover { border-color: #6366f1; color: #818cf8; }
@media (max-width: 768px) { @media (max-width: 768px) {
.map-container { height: 350px; } .map-container { height: calc(100vh - 160px); min-height: 250px; max-height: none; }
.map-layout { flex-direction: column; } .map-layout { flex-direction: column; }
.map-sidebar { width: 100%; max-height: 200px; } .map-sidebar { width: 100%; max-height: 200px; }
.room-history-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } .room-history-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
@ -1408,7 +1474,7 @@ class FolkMapViewer extends HTMLElement {
<div class="rapp-nav"> <div class="rapp-nav">
<span class="rapp-nav__title">Map Rooms</span> <span class="rapp-nav__title">Map Rooms</span>
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></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> <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="create-room">+ New Room</button>
</div> </div>

View File

@ -1,6 +1,9 @@
/* Maps module — dark theme */ /* Maps module — responsive layout */
folk-map-viewer { folk-map-viewer {
display: block; display: block;
min-height: 400px; min-height: 0;
padding: 20px; padding: 16px;
}
@media (max-width: 768px) {
folk-map-viewer { padding: 8px; }
} }

View File

@ -140,7 +140,6 @@ routes.get("/", (c) => {
moduleId: "rmaps", moduleId: "rmaps",
spaceSlug: space, spaceSlug: space,
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark",
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`, body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js"></script>`, scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`, styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
@ -157,7 +156,6 @@ routes.get("/:room", (c) => {
moduleId: "rmaps", moduleId: "rmaps",
spaceSlug: space, spaceSlug: space,
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`, styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`, body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js"></script>`, scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js"></script>`,