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:
parent
aa520d41a2
commit
8aa2e9773a
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue