feat(rmaps): ZIP import, chat, route requests, indoor maps

- ZIP import: Google Takeout + generic FeatureCollection ZIP via JSZip
- Route requests: "Ask to navigate" sends WebSocket route_request, toast notification with Navigate/Dismiss in other tab
- Chat: Automerge-backed persistent messages with MapChatMessage schema (v2), sidebar tab toggle, unread badge
- Indoor maps: <map-indoor-view> with c3nav raster tile proxy, level selector, Easter egg on Level 0 triple-click
- Indoor/outdoor toggle in controls bar and mobile FAB
- Cache bust v2→v3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 18:42:02 -07:00
parent aa520d41a2
commit 8aa2e9773a
8 changed files with 937 additions and 25 deletions

View File

@ -18,6 +18,7 @@ import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
import { requireAuth } from "../../../shared/auth-fetch";
import { getUsername } from "../../../shared/components/rstack-identity";
import { MapsLocalFirstClient } from "../local-first-client";
// MapLibre loaded via CDN — use window access with type assertion
@ -114,6 +115,16 @@ class FolkMapViewer extends HTMLElement {
private _themeObserver: MutationObserver | null = null;
private _history = new ViewHistory<"lobby" | "map">("lobby");
// Chat + Local-first state
private lfClient: MapsLocalFirstClient | null = null;
private sidebarTab: "participants" | "chat" = "participants";
private unreadCount = 0;
// Indoor/outdoor mode
private mapMode: "outdoor" | "indoor" = "outdoor";
private indoorEvent: string | null = null;
private indoorView: any = null;
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
@ -1062,10 +1073,24 @@ class FolkMapViewer extends HTMLElement {
this.render();
this.initMapView();
this.initRoomSync();
this.initLocalFirstClient();
// Periodically refresh staleness indicators
this.stalenessTimer = setInterval(() => this.refreshStaleness(), 15000);
}
private async initLocalFirstClient() {
this.lfClient = new MapsLocalFirstClient(this.space);
await this.lfClient.init();
await this.lfClient.subscribe();
// Track unread chat messages
this.lfClient.onChange((doc) => {
const msgs = Object.values(doc.messages || {});
const lastSeen = parseInt(localStorage.getItem(`rmaps_chat_seen_${this.room}`) || "0", 10);
this.unreadCount = msgs.filter(m => m.createdAt > lastSeen).length;
this.updateChatBadge();
});
}
private createRoom() {
if (!requireAuth("create map room")) return;
const name = prompt("Room name (slug):");
@ -1093,6 +1118,18 @@ class FolkMapViewer extends HTMLElement {
this.map.remove();
this.map = null;
}
if (this.indoorView) {
this.indoorView.remove();
this.indoorView = null;
}
this.mapMode = "outdoor";
this.indoorEvent = null;
if (this.lfClient) {
this.lfClient.disconnect();
this.lfClient = null;
}
this.sidebarTab = "participants";
this.unreadCount = 0;
if (this._themeObserver) {
this._themeObserver.disconnect();
this._themeObserver = null;
@ -1185,6 +1222,8 @@ class FolkMapViewer extends HTMLElement {
dot.className = `status-dot ${connected ? "status-connected" : "status-disconnected"}`;
}
},
undefined, // onLocationRequest (default)
(fromId, fromName, fromEmoji) => this.onRouteRequest(fromId, fromName, fromEmoji),
);
this.sync.connect(this.syncUrl || undefined);
@ -1412,6 +1451,11 @@ class FolkMapViewer extends HTMLElement {
}
}
// Forward to indoor view if in indoor mode
if (this.mapMode === "indoor" && this.indoorView) {
this.indoorView.updateParticipants(state.participants);
}
// Update participant list sidebar
this.updateParticipantList(state);
}
@ -1949,6 +1993,132 @@ class FolkMapViewer extends HTMLElement {
routePanel.querySelector("#close-route")?.addEventListener("click", () => this.clearRoute());
}
// ─── Route request handler ──────────────────────────────────
private onRouteRequest(fromId: string, fromName: string, fromEmoji: string) {
// Show toast notification
const state = this.sync?.getState();
const requester = state?.participants[fromId];
if (!requester?.location) return;
const toast = document.createElement("div");
toast.style.cssText = `
position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:100;
background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);
border-radius:12px;padding:12px 16px;box-shadow:0 8px 24px rgba(0,0,0,0.4);
display:flex;align-items:center;gap:10px;max-width:340px;
animation:toastIn 0.3s ease;
`;
toast.innerHTML = `
<span style="font-size:20px">${fromEmoji}</span>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;color:var(--rs-text-primary);">${this.esc(fromName)} wants you to navigate to them</div>
</div>
<button id="toast-nav" style="padding:6px 12px;border-radius:6px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:12px;">Navigate</button>
<button id="toast-dismiss" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:16px;"></button>
`;
const dismiss = () => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 200); };
toast.querySelector("#toast-dismiss")?.addEventListener("click", dismiss);
toast.querySelector("#toast-nav")?.addEventListener("click", () => {
dismiss();
if (requester.location) {
this.requestRoute(requester.location.latitude, requester.location.longitude, fromName);
}
});
this.shadow.appendChild(toast);
setTimeout(dismiss, 10000);
}
// ─── Chat badge ─────────────────────────────────────────────
private updateChatBadge() {
const badge = this.shadow.getElementById("chat-badge");
if (badge) {
badge.textContent = this.unreadCount > 0 ? String(this.unreadCount) : "";
badge.style.display = this.unreadCount > 0 ? "flex" : "none";
}
const mobileBadge = this.shadow.getElementById("fab-chat-badge");
if (mobileBadge) {
mobileBadge.textContent = this.unreadCount > 0 ? String(this.unreadCount) : "";
mobileBadge.style.display = this.unreadCount > 0 ? "flex" : "none";
}
}
private async mountChatPanel(container: HTMLElement) {
await import("./map-chat-panel");
const panel = document.createElement("map-chat-panel") as any;
panel.client = this.lfClient;
panel.participantId = this.participantId;
panel.participantName = this.userName;
panel.participantEmoji = this.userEmoji;
panel.setAttribute("room", this.room);
panel.style.cssText = "display:block;height:100%;";
container.innerHTML = "";
container.appendChild(panel);
}
// ─── Indoor/outdoor mode ────────────────────────────────────
private async switchToIndoor(event: string) {
this.mapMode = "indoor";
this.indoorEvent = event;
// Hide outdoor map
const mapContainer = this.shadow.getElementById("map-container");
if (mapContainer) mapContainer.style.display = "none";
// Create indoor view container
await import("./map-indoor-view");
const indoorContainer = document.createElement("div");
indoorContainer.id = "indoor-container";
indoorContainer.style.cssText = "width:100%;height:100%;position:absolute;inset:0;";
const mapMain = this.shadow.querySelector(".map-main");
if (mapMain) {
(mapMain as HTMLElement).style.position = "relative";
mapMain.appendChild(indoorContainer);
}
const indoorView = document.createElement("map-indoor-view") as any;
indoorView.cfg = { event, apiBase: this.getApiBase() };
indoorView.addEventListener("switch-outdoor", () => this.switchToOutdoor());
indoorContainer.appendChild(indoorView);
this.indoorView = indoorView;
// Forward current participants
const state = this.sync?.getState();
if (state) indoorView.updateParticipants(state.participants);
// Update toggle button
const toggleBtn = this.shadow.getElementById("indoor-toggle");
if (toggleBtn) {
toggleBtn.textContent = "🌍 Outdoor";
toggleBtn.title = "Switch to outdoor map";
}
}
private switchToOutdoor() {
this.mapMode = "outdoor";
this.indoorEvent = null;
// Remove indoor view
const indoorContainer = this.shadow.getElementById("indoor-container");
if (indoorContainer) indoorContainer.remove();
if (this.indoorView) { this.indoorView = null; }
// Show outdoor map
const mapContainer = this.shadow.getElementById("map-container");
if (mapContainer) mapContainer.style.display = "";
// Update toggle button
const toggleBtn = this.shadow.getElementById("indoor-toggle");
if (toggleBtn) {
toggleBtn.textContent = "🏢 Indoor";
toggleBtn.title = "Switch to indoor map";
}
}
// ─── Navigation panel (participant/waypoint selection) ───────
private async requestRoute(targetLat: number, targetLng: number, targetName: string) {
@ -2038,6 +2208,7 @@ class FolkMapViewer extends HTMLElement {
this.shadow.getElementById("map-container")?.appendChild(navPanel);
}
const isParticipant = !!this.selectedParticipant;
navPanel.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span style="font-size:20px;">${targetEmoji}</span>
@ -2047,7 +2218,10 @@ class FolkMapViewer extends HTMLElement {
</div>
<button id="close-nav" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:14px;">\u2715</button>
</div>
<button id="navigate-btn" style="width:100%;padding:8px;border-radius:6px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:12px;">\u{1F9ED} Navigate</button>
<div style="display:flex;gap:6px;">
<button id="navigate-btn" style="flex:1;padding:8px;border-radius:6px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:12px;">\u{1F9ED} Navigate</button>
${isParticipant ? `<button id="route-request-btn" style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);font-weight:600;cursor:pointer;font-size:12px;">📩 Ask to nav</button>` : ""}
</div>
`;
navPanel.querySelector("#close-nav")?.addEventListener("click", () => {
@ -2058,6 +2232,11 @@ class FolkMapViewer extends HTMLElement {
navPanel.querySelector("#navigate-btn")?.addEventListener("click", () => {
this.requestRoute(targetLat, targetLng, targetName);
});
navPanel.querySelector("#route-request-btn")?.addEventListener("click", () => {
this.sync?.sendRouteRequest(this.participantId, this.userName, this.userEmoji);
const btn = navPanel?.querySelector("#route-request-btn") as HTMLElement;
if (btn) { btn.textContent = "✓ Sent"; btn.style.color = "#22c55e"; btn.style.borderColor = "#22c55e"; }
});
}
// ─── Thumbnail capture ───────────────────────────────────────
@ -2225,6 +2404,8 @@ class FolkMapViewer extends HTMLElement {
.fab-mini-list.open .fab-mini:nth-child(3) .fab-mini-btn { transition-delay: 0.08s; }
.fab-mini-list.open .fab-mini:nth-child(4) .fab-mini-btn { transition-delay: 0.12s; }
.fab-mini-list.open .fab-mini:nth-child(5) .fab-mini-btn { transition-delay: 0.16s; }
.fab-mini-list.open .fab-mini:nth-child(6) .fab-mini-btn { transition-delay: 0.20s; }
.fab-mini-list.open .fab-mini:nth-child(7) .fab-mini-btn { transition-delay: 0.24s; }
.fab-mini-label {
font-size: 11px; background: var(--rs-bg-surface); color: var(--rs-text-secondary);
padding: 4px 8px; border-radius: 6px; white-space: nowrap;
@ -2402,10 +2583,14 @@ class FolkMapViewer extends HTMLElement {
<div class="map-container" id="map-container"></div>
</div>
<div class="map-sidebar">
<div class="sidebar-title">Participants</div>
<div id="participant-list">
<div style="display:flex;gap:4px;margin-bottom:8px;">
<button class="sidebar-tab ${this.sidebarTab === "participants" ? "active" : ""}" data-sidebar-tab="participants" style="flex:1;padding:4px;border-radius:6px;border:1px solid ${this.sidebarTab === "participants" ? "#4f46e5" : "var(--rs-border)"};background:${this.sidebarTab === "participants" ? "#4f46e520" : "transparent"};color:${this.sidebarTab === "participants" ? "#818cf8" : "var(--rs-text-muted)"};cursor:pointer;font-size:11px;font-weight:600;">Participants</button>
<button class="sidebar-tab ${this.sidebarTab === "chat" ? "active" : ""}" data-sidebar-tab="chat" style="flex:1;padding:4px;border-radius:6px;border:1px solid ${this.sidebarTab === "chat" ? "#4f46e5" : "var(--rs-border)"};background:${this.sidebarTab === "chat" ? "#4f46e520" : "transparent"};color:${this.sidebarTab === "chat" ? "#818cf8" : "var(--rs-text-muted)"};cursor:pointer;font-size:11px;font-weight:600;position:relative;">💬 Chat<span id="chat-badge" style="display:${this.unreadCount > 0 ? "flex" : "none"};position:absolute;top:-4px;right:-4px;min-width:16px;height:16px;border-radius:8px;background:#ef4444;color:#fff;font-size:9px;align-items:center;justify-content:center;padding:0 4px;">${this.unreadCount || ""}</span></button>
</div>
<div id="participant-list" style="display:${this.sidebarTab === "participants" ? "block" : "none"};">
<div style="color:var(--rs-text-muted);font-size:12px;padding:8px 0;">Connecting...</div>
</div>
<div id="chat-panel-container" style="display:${this.sidebarTab === "chat" ? "block" : "none"};height:calc(100% - 36px);"></div>
</div>
</div>
@ -2420,6 +2605,7 @@ class FolkMapViewer extends HTMLElement {
<button class="ctrl-btn" id="privacy-toggle">\u{1F6E1} Privacy</button>
<button class="ctrl-btn" id="drop-waypoint">\u{1F4CC} Drop Pin</button>
<button class="ctrl-btn" id="share-room-btn">\u{1F4E4} Share rMap</button>
<button class="ctrl-btn" id="indoor-toggle">${this.mapMode === "indoor" ? "\u{1F30D} Outdoor" : "\u{1F3E2} Indoor"}</button>
</div>
<div id="privacy-panel" style="display:${this.showPrivacyPanel ? "block" : "none"};background:var(--rs-bg-surface);border:1px solid var(--rs-border);border-radius:8px;padding:12px;margin-top:8px;"></div>
@ -2450,6 +2636,14 @@ class FolkMapViewer extends HTMLElement {
<button class="fab-mini-btn" id="fab-emoji" title="Emoji">${this.userEmoji}</button>
<span class="fab-mini-label">Emoji</span>
</div>
<div class="fab-mini">
<button class="fab-mini-btn" id="fab-chat" title="Chat" style="position:relative;">\u{1F4AC}<span id="fab-chat-badge" style="display:${this.unreadCount > 0 ? "flex" : "none"};position:absolute;top:-4px;right:-4px;min-width:14px;height:14px;border-radius:7px;background:#ef4444;color:#fff;font-size:8px;align-items:center;justify-content:center;padding:0 3px;">${this.unreadCount || ""}</span></button>
<span class="fab-mini-label">Chat</span>
</div>
<div class="fab-mini">
<button class="fab-mini-btn" id="fab-indoor" title="Indoor">${this.mapMode === "indoor" ? "\u{1F30D}" : "\u{1F3E2}"}</button>
<span class="fab-mini-label">${this.mapMode === "indoor" ? "Outdoor" : "Indoor"}</span>
</div>
</div>
<button class="fab-main" id="fab-main" title="Menu">\u2699</button>
</div>
@ -2523,6 +2717,46 @@ class FolkMapViewer extends HTMLElement {
});
});
// Indoor toggle
this.shadow.getElementById("indoor-toggle")?.addEventListener("click", () => {
if (this.mapMode === "outdoor") {
const event = prompt("c3nav event code (e.g. 39c3):", "39c3");
if (event?.trim()) this.switchToIndoor(event.trim());
} else {
this.switchToOutdoor();
}
});
// Sidebar tab switching
this.shadow.querySelectorAll("[data-sidebar-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
this.sidebarTab = (btn as HTMLElement).dataset.sidebarTab as "participants" | "chat";
const pList = this.shadow.getElementById("participant-list");
const cPanel = this.shadow.getElementById("chat-panel-container");
if (pList) pList.style.display = this.sidebarTab === "participants" ? "block" : "none";
if (cPanel) cPanel.style.display = this.sidebarTab === "chat" ? "block" : "none";
// Update tab button styles
this.shadow.querySelectorAll("[data-sidebar-tab]").forEach(b => {
const isActive = (b as HTMLElement).dataset.sidebarTab === this.sidebarTab;
(b as HTMLElement).style.borderColor = isActive ? "#4f46e5" : "var(--rs-border)";
(b as HTMLElement).style.background = isActive ? "#4f46e520" : "transparent";
(b as HTMLElement).style.color = isActive ? "#818cf8" : "var(--rs-text-muted)";
});
// Mount chat panel if switching to chat
if (this.sidebarTab === "chat" && cPanel && !cPanel.querySelector("map-chat-panel")) {
this.mountChatPanel(cPanel);
}
// Clear unread when viewing chat
if (this.sidebarTab === "chat") {
this.unreadCount = 0;
this.updateChatBadge();
}
});
});
this.shadow.getElementById("bell-toggle")?.addEventListener("click", () => {
this.pushManager?.toggle(this.room, this.participantId).then(subscribed => {
const bell = this.shadow.getElementById("bell-toggle");
@ -2581,6 +2815,31 @@ class FolkMapViewer extends HTMLElement {
this.updateEmojiButton();
});
this.shadow.getElementById("fab-chat")?.addEventListener("click", () => {
this.closeMobileFab();
// Toggle mobile bottom sheet to chat view
const sheet = this.shadow.getElementById("mobile-bottom-sheet");
if (sheet) {
sheet.classList.add("expanded");
const content = this.shadow.getElementById("participant-list-mobile");
if (content && !content.querySelector("map-chat-panel")) {
this.mountChatPanel(content);
}
}
this.unreadCount = 0;
this.updateChatBadge();
});
this.shadow.getElementById("fab-indoor")?.addEventListener("click", () => {
this.closeMobileFab();
if (this.mapMode === "outdoor") {
const event = prompt("c3nav event code (e.g. 39c3):", "39c3");
if (event?.trim()) this.switchToIndoor(event.trim());
} else {
this.switchToOutdoor();
}
});
// Mobile bottom sheet
const sheet = this.shadow.getElementById("mobile-bottom-sheet");
const sheetHandle = this.shadow.getElementById("sheet-handle");

View File

@ -0,0 +1,191 @@
/**
* <map-chat-panel> Automerge-backed persistent chat for rMaps rooms.
* Subscribes to MapsLocalFirstClient changes and renders a scrollable message list + input.
*/
import type { MapsLocalFirstClient } from "../local-first-client";
import type { MapChatMessage } from "../schemas";
class MapChatPanel extends HTMLElement {
client: MapsLocalFirstClient | null = null;
participantId = "";
participantName = "";
participantEmoji = "";
private _unsub: (() => void) | null = null;
private _messages: MapChatMessage[] = [];
connectedCallback() {
this.render();
if (this.client) {
this._unsub = this.client.onChange((doc) => {
this._messages = Object.values(doc.messages || {}).sort((a, b) => a.createdAt - b.createdAt);
this.renderMessages();
});
// Initial load
const doc = this.client.getDoc();
if (doc) {
this._messages = Object.values(doc.messages || {}).sort((a, b) => a.createdAt - b.createdAt);
this.renderMessages();
}
}
}
disconnectedCallback() {
this._unsub?.();
this._unsub = null;
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
private relativeTime(ts: number): string {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 60) return "now";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}d`;
}
private render() {
this.innerHTML = `
<style>
.chat-panel { display: flex; flex-direction: column; height: 100%; }
.chat-messages {
flex: 1; overflow-y: auto; padding: 8px 0;
scrollbar-width: thin; scrollbar-color: var(--rs-border) transparent;
}
.chat-msg {
display: flex; gap: 8px; padding: 4px 0;
animation: chatFadeIn 0.2s ease;
}
@keyframes chatFadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.chat-msg-emoji { font-size: 16px; flex-shrink: 0; margin-top: 2px; }
.chat-msg-body { flex: 1; min-width: 0; }
.chat-msg-header {
display: flex; gap: 6px; align-items: baseline;
font-size: 11px; margin-bottom: 1px;
}
.chat-msg-name { font-weight: 600; color: var(--rs-text-primary); }
.chat-msg-time { color: var(--rs-text-muted); font-size: 10px; }
.chat-msg-text {
font-size: 13px; color: var(--rs-text-secondary);
line-height: 1.4; word-break: break-word;
}
.chat-empty {
flex: 1; display: flex; align-items: center; justify-content: center;
color: var(--rs-text-muted); font-size: 12px; text-align: center; padding: 20px;
}
.chat-input-row {
display: flex; gap: 6px; padding-top: 8px;
border-top: 1px solid var(--rs-border);
}
.chat-input {
flex: 1; border: 1px solid var(--rs-border); border-radius: 8px;
padding: 8px 10px; background: var(--rs-input-bg, var(--rs-bg-surface));
color: var(--rs-text-primary); font-size: 13px; outline: none;
font-family: inherit;
}
.chat-input:focus { border-color: #6366f1; }
.chat-input::placeholder { color: var(--rs-text-muted); }
.chat-send-btn {
padding: 8px 12px; border-radius: 8px; border: none;
background: #4f46e5; color: #fff; font-weight: 600;
cursor: pointer; font-size: 13px; white-space: nowrap;
}
.chat-send-btn:hover { background: #6366f1; }
.chat-send-btn:disabled { opacity: 0.5; cursor: default; }
.chat-gap {
text-align: center; font-size: 10px; color: var(--rs-text-muted);
padding: 6px 0; margin: 4px 0;
}
</style>
<div class="chat-panel">
<div class="chat-messages" id="chat-msgs"></div>
<div class="chat-input-row">
<input class="chat-input" id="chat-input" type="text" placeholder="Message..." maxlength="500" autocomplete="off">
<button class="chat-send-btn" id="chat-send">Send</button>
</div>
</div>
`;
const input = this.querySelector("#chat-input") as HTMLInputElement;
const sendBtn = this.querySelector("#chat-send") as HTMLButtonElement;
const send = () => {
const text = input.value.trim();
if (!text || !this.client) return;
const msg: MapChatMessage = {
id: crypto.randomUUID(),
authorId: this.participantId,
authorName: this.participantName,
authorEmoji: this.participantEmoji,
text,
createdAt: Date.now(),
};
this.client.addMessage(msg);
input.value = "";
input.focus();
};
sendBtn.addEventListener("click", send);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
});
}
private renderMessages() {
const container = this.querySelector("#chat-msgs");
if (!container) return;
if (this._messages.length === 0) {
container.innerHTML = '<div class="chat-empty">No messages yet.<br>Start the conversation!</div>';
return;
}
let html = "";
let lastTs = 0;
for (const msg of this._messages) {
// Time gap separator (>5 min)
if (lastTs && msg.createdAt - lastTs > 5 * 60 * 1000) {
const d = new Date(msg.createdAt);
const timeStr = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
html += `<div class="chat-gap">${timeStr}</div>`;
}
lastTs = msg.createdAt;
html += `
<div class="chat-msg">
<span class="chat-msg-emoji">${this.esc(msg.authorEmoji)}</span>
<div class="chat-msg-body">
<div class="chat-msg-header">
<span class="chat-msg-name">${this.esc(msg.authorName)}</span>
<span class="chat-msg-time">${this.relativeTime(msg.createdAt)}</span>
</div>
<div class="chat-msg-text">${this.esc(msg.text)}</div>
</div>
</div>
`;
}
container.innerHTML = html;
// Auto-scroll to bottom
container.scrollTop = container.scrollHeight;
// Store last-viewed timestamp for unread counting
if (this._messages.length > 0) {
const lastMsg = this._messages[this._messages.length - 1];
localStorage.setItem(`rmaps_chat_seen_${this.getAttribute("room") || ""}`, String(lastMsg.createdAt));
}
}
}
customElements.define("map-chat-panel", MapChatPanel);
export { MapChatPanel };

View File

@ -19,6 +19,35 @@ class MapImportModal extends HTMLElement {
this.remove();
}
/** Extract GeoJSON from a Google Takeout ZIP (or any ZIP containing .json/.geojson files) */
private async handleZip(file: File): Promise<string | null> {
const JSZip = (await import("jszip")).default;
const zip = await JSZip.loadAsync(file);
// Google Takeout known paths (in order of preference)
const takeoutPaths = [
"Takeout/Maps/My labeled places/Labeled places.json",
"Takeout/Maps (My Places)/Saved Places.json",
"Takeout/Maps/Saved Places.json",
];
for (const p of takeoutPaths) {
const entry = zip.file(p);
if (entry) return entry.async("string");
}
// Fallback: find any .json or .geojson containing a FeatureCollection
const jsonFiles = zip.file(/\.(json|geojson)$/i);
for (const f of jsonFiles) {
const text = await f.async("string");
try {
const parsed = JSON.parse(text);
if (parsed?.type === "FeatureCollection") return text;
} catch { /* not valid JSON, skip */ }
}
return null;
}
private render() {
this.style.cssText = `position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);`;
@ -40,30 +69,47 @@ class MapImportModal extends HTMLElement {
</div>
<div id="i-drop" style="border:2px dashed var(--rs-border);border-radius:10px;padding:40px;text-align:center;cursor:pointer;transition:border-color 0.2s;">
<div style="font-size:28px;margin-bottom:8px;">\u{1F4C2}</div>
<div style="font-size:13px;color:var(--rs-text-secondary);margin-bottom:4px;">Drop a GeoJSON file here</div>
<div style="font-size:11px;color:var(--rs-text-muted);">or click to browse (.json, .geojson)</div>
<input type="file" id="i-file" accept=".json,.geojson" style="display:none;">
<div style="font-size:13px;color:var(--rs-text-secondary);margin-bottom:4px;">Drop a GeoJSON or Google Takeout ZIP here</div>
<div style="font-size:11px;color:var(--rs-text-muted);">or click to browse (.json, .geojson, .zip)</div>
<input type="file" id="i-file" accept=".json,.geojson,.zip" style="display:none;">
</div>
<div id="i-err" style="display:none;margin-top:12px;font-size:12px;color:#ef4444;padding:8px;border-radius:6px;background:rgba(239,68,68,0.1);"></div>
</div>
`;
const processJson = (jsonStr: string) => {
const result = parseGoogleMapsGeoJSON(jsonStr);
if (!result.success) {
const err = this.querySelector("#i-err") as HTMLElement;
err.style.display = "block"; err.textContent = result.error || "No places found"; return;
}
this._places = result.places.map(p => ({ ...p, selected: true }));
this._step = "preview";
this.render();
};
const handleFile = (file: File) => {
if (file.size > 50 * 1024 * 1024) {
const err = this.querySelector("#i-err") as HTMLElement;
err.style.display = "block"; err.textContent = "File too large (max 50 MB)"; return;
}
const reader = new FileReader();
reader.onload = () => {
const result = parseGoogleMapsGeoJSON(reader.result as string);
if (!result.success) {
if (file.name.endsWith(".zip") || file.type === "application/zip") {
this.handleZip(file).then((jsonStr) => {
if (!jsonStr) {
const err = this.querySelector("#i-err") as HTMLElement;
err.style.display = "block"; err.textContent = "No GeoJSON FeatureCollection found in ZIP"; return;
}
processJson(jsonStr);
}).catch(() => {
const err = this.querySelector("#i-err") as HTMLElement;
err.style.display = "block"; err.textContent = result.error || "No places found"; return;
}
this._places = result.places.map(p => ({ ...p, selected: true }));
this._step = "preview";
this.render();
};
err.style.display = "block"; err.textContent = "Failed to read ZIP file";
});
return;
}
const reader = new FileReader();
reader.onload = () => processJson(reader.result as string);
reader.readAsText(file);
};

View File

@ -0,0 +1,353 @@
/**
* <map-indoor-view> c3nav indoor map viewer using MapLibre GL raster tiles.
* Level selector, participant markers, and Easter egg on Level 0 triple-click.
*/
import type { ParticipantState } from "./map-sync";
interface IndoorConfig {
event: string;
apiBase: string;
}
const MAPLIBRE_CSS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css";
const MAPLIBRE_JS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js";
class MapIndoorView extends HTMLElement {
private shadow: ShadowRoot;
private map: any = null;
private config: IndoorConfig | null = null;
private levels: { id: number; name: string }[] = [];
private currentLevel = 0;
private bounds: { west: number; south: number; east: number; north: number } | null = null;
private participantMarkers: Map<string, any> = new Map();
private loading = true;
private error = "";
private level0ClickCount = 0;
private level0ClickTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
set cfg(val: IndoorConfig) {
this.config = val;
if (this.isConnected) this.init();
}
connectedCallback() {
this.renderShell();
if (this.config) this.init();
}
disconnectedCallback() {
if (this.map) { this.map.remove(); this.map = null; }
this.participantMarkers.clear();
}
private renderShell() {
this.shadow.innerHTML = `
<style>
:host { display: block; width: 100%; height: 100%; position: relative; }
.indoor-map { width: 100%; height: 100%; }
.level-selector {
position: absolute; top: 50%; right: 10px; transform: translateY(-50%);
display: flex; flex-direction: column; gap: 2px; z-index: 5;
}
.level-btn {
width: 32px; height: 32px; border-radius: 6px;
border: 1px solid var(--rs-border, #334155);
background: var(--rs-bg-surface, #1a1a2e);
color: var(--rs-text-secondary, #94a3b8);
font-size: 12px; font-weight: 600; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.level-btn:hover { border-color: var(--rs-border-strong, #475569); color: var(--rs-text-primary, #e2e8f0); }
.level-btn.active {
background: #4f46e5; color: #fff; border-color: #6366f1;
}
.loading-overlay, .error-overlay {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; z-index: 6;
background: var(--rs-bg-surface-sunken, #0d1117);
}
.loading-overlay { color: var(--rs-text-muted, #64748b); font-size: 13px; gap: 8px; }
.error-overlay { color: #ef4444; font-size: 13px; gap: 12px; }
.outdoor-btn {
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--rs-border, #334155);
background: var(--rs-bg-surface, #1a1a2e); color: var(--rs-text-secondary, #94a3b8);
cursor: pointer; font-size: 12px;
}
.outdoor-btn:hover { border-color: var(--rs-border-strong, #475569); }
.spinner {
width: 24px; height: 24px; border: 3px solid var(--rs-border, #334155);
border-top-color: #6366f1; border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.easter-egg {
position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.8); color: #22c55e; font-size: 11px;
padding: 6px 12px; border-radius: 8px; z-index: 5;
animation: eggFade 3s forwards;
}
@keyframes eggFade { 0%,80% { opacity: 1; } 100% { opacity: 0; } }
</style>
<div class="indoor-map" id="indoor-map"></div>
<div class="level-selector" id="level-selector"></div>
<div class="loading-overlay" id="loading-overlay">
<div class="spinner"></div>
<span>Loading indoor map...</span>
</div>
`;
}
private async loadMapLibre(): Promise<void> {
if ((window as any).maplibregl) return;
if (!document.querySelector(`link[href="${MAPLIBRE_CSS}"]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = MAPLIBRE_CSS;
document.head.appendChild(link);
}
return new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = MAPLIBRE_JS;
script.onload = () => resolve();
script.onerror = reject;
document.head.appendChild(script);
});
}
private async init() {
if (!this.config) return;
this.loading = true;
this.error = "";
try {
await this.loadMapLibre();
await this.fetchMapData();
this.createMap();
this.renderLevelSelector();
this.loading = false;
this.shadow.getElementById("loading-overlay")?.remove();
} catch (err: any) {
this.loading = false;
this.error = err?.message || "Failed to load indoor map";
this.renderError();
}
}
private async fetchMapData() {
const { event, apiBase } = this.config!;
// Fetch bounds
const boundsRes = await fetch(`${apiBase}/api/c3nav/${event}?endpoint=map/bounds`, {
signal: AbortSignal.timeout(8000),
});
if (boundsRes.ok) {
const data = await boundsRes.json();
if (data.bounds) {
this.bounds = data.bounds;
} else if (data.west !== undefined) {
this.bounds = { west: data.west, south: data.south, east: data.east, north: data.north };
}
}
// Fetch levels
const levelsRes = await fetch(`${apiBase}/api/c3nav/${event}?endpoint=map/locations`, {
signal: AbortSignal.timeout(8000),
});
if (levelsRes.ok) {
const data = await levelsRes.json();
const levelEntries = (Array.isArray(data) ? data : data.results || [])
.filter((loc: any) => loc.locationtype === "level" || loc.type === "level")
.map((loc: any) => ({
id: typeof loc.on_top_of === "number" ? loc.on_top_of : (loc.id ?? loc.level ?? 0),
name: loc.title || loc.name || `Level ${loc.id ?? 0}`,
}))
.sort((a: any, b: any) => b.id - a.id);
this.levels = levelEntries.length > 0 ? levelEntries : [
{ id: 4, name: "Level 4" },
{ id: 3, name: "Level 3" },
{ id: 2, name: "Level 2" },
{ id: 1, name: "Level 1" },
{ id: 0, name: "Ground" },
];
} else {
// Fallback levels
this.levels = [
{ id: 4, name: "Level 4" },
{ id: 3, name: "Level 3" },
{ id: 2, name: "Level 2" },
{ id: 1, name: "Level 1" },
{ id: 0, name: "Ground" },
];
}
}
private createMap() {
const container = this.shadow.getElementById("indoor-map");
if (!container || !(window as any).maplibregl) return;
const { event, apiBase } = this.config!;
// Default center (CCH Hamburg for CCC events)
const defaultCenter: [number, number] = [9.9905, 53.5545];
const center = this.bounds
? [(this.bounds.west + this.bounds.east) / 2, (this.bounds.south + this.bounds.north) / 2] as [number, number]
: defaultCenter;
const tileUrl = `${apiBase}/api/c3nav/tiles/${event}/${this.currentLevel}/{z}/{x}/{y}`;
this.map = new (window as any).maplibregl.Map({
container,
style: {
version: 8,
sources: {
"c3nav-tiles": {
type: "raster",
tiles: [tileUrl],
tileSize: 256,
maxzoom: 22,
},
},
layers: [{
id: "c3nav-layer",
type: "raster",
source: "c3nav-tiles",
}],
},
center,
zoom: 17,
minZoom: 14,
maxZoom: 22,
});
this.map.addControl(new (window as any).maplibregl.NavigationControl(), "top-right");
}
private renderLevelSelector() {
const selector = this.shadow.getElementById("level-selector");
if (!selector) return;
selector.innerHTML = this.levels.map(level =>
`<button class="level-btn ${level.id === this.currentLevel ? "active" : ""}" data-level="${level.id}" title="${level.name}">${level.id}</button>`
).join("");
selector.querySelectorAll(".level-btn").forEach(btn => {
btn.addEventListener("click", () => {
const levelId = parseInt((btn as HTMLElement).dataset.level!, 10);
this.switchLevel(levelId);
// Easter egg: triple-click Level 0 reveals Level -1
if (levelId === 0) {
this.level0ClickCount++;
if (this.level0ClickTimer) clearTimeout(this.level0ClickTimer);
this.level0ClickTimer = setTimeout(() => { this.level0ClickCount = 0; }, 600);
if (this.level0ClickCount >= 3) {
this.level0ClickCount = 0;
this.showEasterEgg();
}
}
});
});
}
private switchLevel(level: number) {
if (!this.map || !this.config) return;
this.currentLevel = level;
const { event, apiBase } = this.config;
const tileUrl = `${apiBase}/api/c3nav/tiles/${event}/${level}/{z}/{x}/{y}`;
const source = this.map.getSource("c3nav-tiles");
if (source) {
source.setTiles([tileUrl]);
}
this.renderLevelSelector();
}
private showEasterEgg() {
// Switch to Level -1 (underground)
this.switchLevel(-1);
const egg = document.createElement("div");
egg.className = "easter-egg";
egg.textContent = "🕳️ You found the secret underground level!";
this.shadow.appendChild(egg);
setTimeout(() => egg.remove(), 3500);
}
private renderError() {
const overlay = this.shadow.getElementById("loading-overlay");
if (overlay) {
overlay.className = "error-overlay";
overlay.innerHTML = `
<span> ${this.error}</span>
<button class="outdoor-btn" id="switch-outdoor">Switch to Outdoor Map</button>
`;
overlay.querySelector("#switch-outdoor")?.addEventListener("click", () => {
this.dispatchEvent(new CustomEvent("switch-outdoor", { bubbles: true, composed: true }));
});
}
}
/** Update indoor participant markers */
updateParticipants(participants: Record<string, ParticipantState>) {
if (!this.map || !(window as any).maplibregl) return;
const currentIds = new Set<string>();
for (const [id, p] of Object.entries(participants)) {
if (!p.location?.indoor) continue;
currentIds.add(id);
// Only show participants on the current level
if (p.location.indoor.level !== this.currentLevel) {
if (this.participantMarkers.has(id)) {
this.participantMarkers.get(id).remove();
this.participantMarkers.delete(id);
}
continue;
}
const lngLat: [number, number] = [p.location.longitude, p.location.latitude];
if (this.participantMarkers.has(id)) {
this.participantMarkers.get(id).setLngLat(lngLat);
} else {
const el = document.createElement("div");
el.style.cssText = `
width: 32px; height: 32px; border-radius: 50%;
border: 2px solid ${p.color}; background: #1a1a2e;
display: flex; align-items: center; justify-content: center;
font-size: 16px; cursor: pointer;
box-shadow: 0 0 8px ${p.color}60;
`;
el.textContent = p.emoji;
el.title = p.name;
const marker = new (window as any).maplibregl.Marker({ element: el })
.setLngLat(lngLat)
.addTo(this.map);
this.participantMarkers.set(id, marker);
}
}
// Remove markers for participants no longer indoor
for (const [id, marker] of this.participantMarkers) {
if (!currentIds.has(id)) {
marker.remove();
this.participantMarkers.delete(id);
}
}
}
}
customElements.define("map-indoor-view", MapIndoorView);
export { MapIndoorView };

View File

@ -70,11 +70,13 @@ export type SyncMessage =
| { type: "waypoint_remove"; waypointId: string }
| { type: "full_state"; state: RoomState }
| { type: "request_state" }
| { type: "request_location"; manual?: boolean; callerName?: string };
| { type: "request_location"; manual?: boolean; callerName?: string }
| { type: "route_request"; fromId: string; fromName: string; fromEmoji: string };
type SyncCallback = (state: RoomState) => void;
type ConnectionCallback = (connected: boolean) => void;
type LocationRequestCallback = (manual?: boolean, callerName?: string) => void;
type RouteRequestCallback = (fromId: string, fromName: string, fromEmoji: string) => void;
export function isValidLocation(location: LocationState | undefined): boolean {
if (!location) return false;
@ -93,6 +95,7 @@ export class RoomSync {
private onStateChange: SyncCallback;
private onConnectionChange: ConnectionCallback;
private onLocationRequest: LocationRequestCallback | null = null;
private onRouteRequest: RouteRequestCallback | null = null;
private participantId: string;
private currentParticipant: ParticipantState | null = null;
@ -102,12 +105,14 @@ export class RoomSync {
onStateChange: SyncCallback,
onConnectionChange: ConnectionCallback,
onLocationRequest?: LocationRequestCallback,
onRouteRequest?: RouteRequestCallback,
) {
this.slug = slug;
this.participantId = participantId;
this.onStateChange = onStateChange;
this.onConnectionChange = onConnectionChange;
this.onLocationRequest = onLocationRequest || null;
this.onRouteRequest = onRouteRequest || null;
this.state = this.loadState() || this.createInitialState();
}
@ -239,6 +244,11 @@ export class RoomSync {
case "request_location":
if (this.onLocationRequest) this.onLocationRequest(message.manual, message.callerName);
return;
case "route_request":
if (message.fromId !== this.participantId && this.onRouteRequest) {
this.onRouteRequest(message.fromId, message.fromName, message.fromEmoji);
}
return;
}
this.notifyStateChange();
}
@ -298,6 +308,10 @@ export class RoomSync {
this.notifyStateChange();
}
sendRouteRequest(fromId: string, fromName: string, fromEmoji: string): void {
this.send({ type: "route_request", fromId, fromName, fromEmoji });
}
getState(): RoomState {
return { ...this.state };
}

View File

@ -8,7 +8,7 @@ import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto';
import { mapsSchema, mapsDocId } from './schemas';
import type { MapsDoc, MapAnnotation, SavedRoute, SavedMeetingPoint } from './schemas';
import type { MapsDoc, MapAnnotation, SavedRoute, SavedMeetingPoint, MapChatMessage } from './schemas';
export class MapsLocalFirstClient {
#space: string; #documents: DocumentManager; #store: EncryptedDocStore; #sync: DocSyncManager; #initialized = false;
@ -62,6 +62,12 @@ export class MapsLocalFirstClient {
removeMeetingPoint(id: string): void {
this.#sync.change<MapsDoc>(mapsDocId(this.#space) as DocumentId, `Remove meeting point`, (d) => { delete d.savedMeetingPoints[id]; });
}
addMessage(msg: MapChatMessage): void {
this.#sync.change<MapsDoc>(mapsDocId(this.#space) as DocumentId, `Chat message`, (d) => { d.messages[msg.id] = msg; });
}
deleteMessage(id: string): void {
this.#sync.change<MapsDoc>(mapsDocId(this.#space) as DocumentId, `Delete message`, (d) => { delete d.messages[id]; });
}
async disconnect(): Promise<void> { await this.#sync.flush(); this.#sync.disconnect(); }
}

View File

@ -214,6 +214,36 @@ routes.post("/api/routing", async (c) => {
const VALID_C3NAV_EVENTS = ["39c3", "38c3", "37c3", "eh22", "eh2025", "camp2023"];
const ALLOWED_C3NAV_ENDPOINTS = ["map/settings", "map/bounds", "map/locations", "map/locations/full", "map/projection"];
// ── c3nav tile proxy ──
routes.get("/api/c3nav/tiles/:event/:level/:z/:x/:y", async (c) => {
const event = c.req.param("event");
const level = parseInt(c.req.param("level"), 10);
const z = c.req.param("z");
const x = c.req.param("x");
const y = c.req.param("y");
if (!VALID_C3NAV_EVENTS.includes(event)) return c.json({ error: "Invalid event" }, 400);
if (isNaN(level) || level < -1 || level > 10) return c.json({ error: "Invalid level" }, 400);
try {
const res = await fetch(`https://tiles.${event}.c3nav.de/${level}/${z}/${x}/${y}.png`, {
headers: { "User-Agent": "rMaps/1.0" },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) return c.json({ error: "Tile fetch failed" }, 502);
const data = await res.arrayBuffer();
return new Response(data, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600",
},
});
} catch {
return c.json({ error: "c3nav tile proxy error" }, 502);
}
});
routes.get("/api/c3nav/:event", async (c) => {
const event = c.req.param("event");
const endpoint = c.req.query("endpoint") || "map/bounds";
@ -244,8 +274,8 @@ routes.get("/", (c) => {
spaceSlug: space,
modules: getModuleInfoList(),
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=2">`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`,
}));
});
@ -259,9 +289,9 @@ routes.get("/:room", (c) => {
moduleId: "rmaps",
spaceSlug: space,
modules: getModuleInfoList(),
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=2">`,
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`,
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=2"></script>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=3"></script>`,
}));
});

View File

@ -36,28 +36,41 @@ export interface SavedMeetingPoint {
createdAt: number;
}
export interface MapChatMessage {
id: string;
authorId: string;
authorName: string;
authorEmoji: string;
text: string;
createdAt: number;
replyToId?: string;
}
export interface MapsDoc {
meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number };
annotations: Record<string, MapAnnotation>;
savedRoutes: Record<string, SavedRoute>;
savedMeetingPoints: Record<string, SavedMeetingPoint>;
messages: Record<string, MapChatMessage>;
}
export const mapsSchema: DocSchema<MapsDoc> = {
module: 'maps',
collection: 'annotations',
version: 1,
version: 2,
init: (): MapsDoc => ({
meta: { module: 'maps', collection: 'annotations', version: 1, spaceSlug: '', createdAt: Date.now() },
meta: { module: 'maps', collection: 'annotations', version: 2, spaceSlug: '', createdAt: Date.now() },
annotations: {},
savedRoutes: {},
savedMeetingPoints: {},
messages: {},
}),
migrate: (doc: any) => {
if (!doc.annotations) doc.annotations = {};
if (!doc.savedRoutes) doc.savedRoutes = {};
if (!doc.savedMeetingPoints) doc.savedMeetingPoints = {};
doc.meta.version = 1;
if (!doc.messages) doc.messages = {};
doc.meta.version = 2;
return doc;
},
};