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 {
- -
+
+ + +
+
Connecting...
+
@@ -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.authorName)} + ${this.relativeTime(msg.createdAt)} +
+
${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; }, };