diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts
index 2197d5b..36a9eb9 100644
--- a/modules/rmaps/components/folk-map-viewer.ts
+++ b/modules/rmaps/components/folk-map-viewer.ts
@@ -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 = `
+ ${fromEmoji}
+
+
${this.esc(fromName)} wants you to navigate to them
+
+
+
+ `;
+
+ 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 = `
${targetEmoji}
@@ -2047,7 +2218,10 @@ class FolkMapViewer extends HTMLElement {
-
+
+
+ ${isParticipant ? `` : ""}
+
`;
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 {
@@ -2420,6 +2605,7 @@ class FolkMapViewer extends HTMLElement {
+
@@ -2450,6 +2636,14 @@ class FolkMapViewer extends HTMLElement {
Emoji
+
+
+ Chat
+
+
+
+ ${this.mapMode === "indoor" ? "Outdoor" : "Indoor"}
+
@@ -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");
diff --git a/modules/rmaps/components/map-chat-panel.ts b/modules/rmaps/components/map-chat-panel.ts
new file mode 100644
index 0000000..903aa43
--- /dev/null
+++ b/modules/rmaps/components/map-chat-panel.ts
@@ -0,0 +1,191 @@
+/**
+ * — 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 = `
+
+
+ `;
+
+ 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 = 'No messages yet.
Start the conversation!
';
+ 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 += `${timeStr}
`;
+ }
+ lastTs = msg.createdAt;
+
+ html += `
+
+
${this.esc(msg.authorEmoji)}
+
+
+
${this.esc(msg.text)}
+
+
+ `;
+ }
+
+ 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 };
diff --git a/modules/rmaps/components/map-import-modal.ts b/modules/rmaps/components/map-import-modal.ts
index cdae9da..e443500 100644
--- a/modules/rmaps/components/map-import-modal.ts
+++ b/modules/rmaps/components/map-import-modal.ts
@@ -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 {
+ 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 {
\u{1F4C2}
-
Drop a GeoJSON file here
-
or click to browse (.json, .geojson)
-
+
Drop a GeoJSON or Google Takeout ZIP here
+
or click to browse (.json, .geojson, .zip)
+
`;
+ 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);
};
diff --git a/modules/rmaps/components/map-indoor-view.ts b/modules/rmaps/components/map-indoor-view.ts
new file mode 100644
index 0000000..48c699b
--- /dev/null
+++ b/modules/rmaps/components/map-indoor-view.ts
@@ -0,0 +1,353 @@
+/**
+ * — 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 = new Map();
+ private loading = true;
+ private error = "";
+ private level0ClickCount = 0;
+ private level0ClickTimer: ReturnType | 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 = `
+
+
+
+
+
+
Loading indoor map...
+
+ `;
+ }
+
+ private async loadMapLibre(): Promise {
+ 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((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 =>
+ ``
+ ).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 = `
+ ⚠️ ${this.error}
+
+ `;
+ overlay.querySelector("#switch-outdoor")?.addEventListener("click", () => {
+ this.dispatchEvent(new CustomEvent("switch-outdoor", { bubbles: true, composed: true }));
+ });
+ }
+ }
+
+ /** Update indoor participant markers */
+ updateParticipants(participants: Record) {
+ if (!this.map || !(window as any).maplibregl) return;
+
+ const currentIds = new Set();
+
+ 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 };
diff --git a/modules/rmaps/components/map-sync.ts b/modules/rmaps/components/map-sync.ts
index 90f47c8..ffd0f57 100644
--- a/modules/rmaps/components/map-sync.ts
+++ b/modules/rmaps/components/map-sync.ts
@@ -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 };
}
diff --git a/modules/rmaps/local-first-client.ts b/modules/rmaps/local-first-client.ts
index 4fe563f..5d62dec 100644
--- a/modules/rmaps/local-first-client.ts
+++ b/modules/rmaps/local-first-client.ts
@@ -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(mapsDocId(this.#space) as DocumentId, `Remove meeting point`, (d) => { delete d.savedMeetingPoints[id]; });
}
+ addMessage(msg: MapChatMessage): void {
+ this.#sync.change(mapsDocId(this.#space) as DocumentId, `Chat message`, (d) => { d.messages[msg.id] = msg; });
+ }
+ deleteMessage(id: string): void {
+ this.#sync.change(mapsDocId(this.#space) as DocumentId, `Delete message`, (d) => { delete d.messages[id]; });
+ }
async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); }
}
diff --git a/modules/rmaps/mod.ts b/modules/rmaps/mod.ts
index 0f4cd0d..21226dd 100644
--- a/modules/rmaps/mod.ts
+++ b/modules/rmaps/mod.ts
@@ -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: ``,
- scripts: ``,
- styles: ``,
+ scripts: ``,
+ styles: ``,
}));
});
@@ -259,9 +289,9 @@ routes.get("/:room", (c) => {
moduleId: "rmaps",
spaceSlug: space,
modules: getModuleInfoList(),
- styles: ``,
+ styles: ``,
body: ``,
- scripts: ``,
+ scripts: ``,
}));
});
diff --git a/modules/rmaps/schemas.ts b/modules/rmaps/schemas.ts
index 543df50..d1c6d32 100644
--- a/modules/rmaps/schemas.ts
+++ b/modules/rmaps/schemas.ts
@@ -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;
savedRoutes: Record;
savedMeetingPoints: Record;
+ messages: Record;
}
export const mapsSchema: DocSchema = {
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;
},
};