/** * Maps module — real-time collaborative location sharing. * * Port of rmaps-online. Rooms are ephemeral (no DB). Proxies to * the sync server for WebSocket-based location sharing. Embeds * MapLibre GL for outdoor maps and c3nav for indoor maps. */ import { Hono } from "hono"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; const routes = new Hono(); const SYNC_SERVER = process.env.MAPS_SYNC_URL || "http://localhost:3001"; // ── Sync URL for client-side WebSocket connection ── routes.get("/api/sync-url", (c) => { const wsUrl = process.env.MAPS_SYNC_URL || "wss://maps-sync.rspace.online"; return c.json({ syncUrl: wsUrl }); }); // ── Proxy: sync server health ── routes.get("/api/health", async (c) => { try { const res = await fetch(`${SYNC_SERVER}/health`, { signal: AbortSignal.timeout(3000) }); if (res.ok) return c.json(await res.json()); return c.json({ status: "degraded", sync: false }); } catch { return c.json({ status: "degraded", sync: false }); } }); // ── Proxy: room stats ── routes.get("/api/stats", async (c) => { try { const res = await fetch(`${SYNC_SERVER}/stats`, { signal: AbortSignal.timeout(3000) }); if (res.ok) return c.json(await res.json()); } catch {} return c.json({ rooms: {} }); }); // ── Proxy: push notification VAPID key ── routes.get("/api/push/vapid-public-key", async (c) => { try { const res = await fetch(`${SYNC_SERVER}/push/vapid-public-key`, { signal: AbortSignal.timeout(3000) }); if (res.ok) return c.json(await res.json()); } catch {} return c.json({ error: "Push not available" }, 503); }); // ── Proxy: push subscribe/unsubscribe ── routes.post("/api/push/subscribe", async (c) => { const body = await c.req.json(); const res = await fetch(`${SYNC_SERVER}/push/subscribe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return c.json(await res.json(), res.status as any); }); routes.post("/api/push/unsubscribe", async (c) => { const body = await c.req.json(); const res = await fetch(`${SYNC_SERVER}/push/unsubscribe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return c.json(await res.json(), res.status as any); }); // ── Proxy: request location (ping) ── routes.post("/api/push/request-location", async (c) => { const body = await c.req.json(); const res = await fetch(`${SYNC_SERVER}/push/request-location`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return c.json(await res.json(), res.status as any); }); // ── Proxy: last-known-location (offline ping response) ── routes.post("/api/push/last-known-location", async (c) => { const body = await c.req.json(); try { const res = await fetch(`${SYNC_SERVER}/push/last-known-location`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (res.ok) return c.json(await res.json()); } catch {} return c.json({ received: true }); }); // ── Proxy: routing (OSRM + c3nav) ── routes.post("/api/routing", async (c) => { const body = await c.req.json(); const { from, to, mode = "walking", accessibility, indoor } = body; if (!from?.lat || !from?.lng || !to?.lat || !to?.lng) { return c.json({ error: "from and to with lat/lng required" }, 400); } const segments: { type: string; coordinates: [number, number][]; distance: number; duration: number; steps?: any[] }[] = []; let totalDistance = 0; let estimatedTime = 0; // ── Indoor routing via c3nav ── if (indoor?.event && (from.indoor || to.indoor)) { try { // If both are indoor, route fully indoors if (from.indoor && to.indoor) { const c3navRes = await fetch(`https://${indoor.event}.c3nav.de/api/v2/routing/route/`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": "anonymous", Accept: "application/json", "User-Agent": "rMaps/1.0" }, body: JSON.stringify({ origin: { level: from.indoor.level, x: from.indoor.x, y: from.indoor.y }, destination: { level: to.indoor.level, x: to.indoor.x, y: to.indoor.y }, ...(accessibility?.avoidStairs ? { options: { avoid_stairs: true } } : {}), ...(accessibility?.wheelchair ? { options: { wheelchair: true } } : {}), }), signal: AbortSignal.timeout(8000), }); if (c3navRes.ok) { const data = await c3navRes.json(); const coords: [number, number][] = data.path?.map((p: any) => [p.lng || p.x, p.lat || p.y]) || []; const dist = data.distance || 0; const dur = data.duration || Math.round(dist / 1.2); segments.push({ type: "indoor", coordinates: coords, distance: dist, duration: dur }); totalDistance += dist; estimatedTime += dur; } } else { // Mixed indoor/outdoor: transition segment + outdoor OSRM const outdoorPoint = from.indoor ? to : from; const indoorPoint = from.indoor ? from : to; const transitionCoords: [number, number][] = [ [indoorPoint.lng, indoorPoint.lat], [outdoorPoint.lng, outdoorPoint.lat], ]; segments.push({ type: "transition", coordinates: transitionCoords, distance: 50, duration: 30 }); totalDistance += 50; estimatedTime += 30; // OSRM for the outdoor portion const profile = mode === "driving" ? "car" : "foot"; const osrmRes = await fetch( `https://router.project-osrm.org/route/v1/${profile}/${outdoorPoint.lng},${outdoorPoint.lat};${(from.indoor ? to : from).lng},${(from.indoor ? to : from).lat}?overview=full&geometries=geojson&steps=true`, { signal: AbortSignal.timeout(10000) }, ); if (osrmRes.ok) { const osrmData = await osrmRes.json(); const route = osrmData.routes?.[0]; if (route) { segments.push({ type: "outdoor", coordinates: route.geometry?.coordinates || [], distance: route.distance || 0, duration: route.duration || 0, steps: route.legs?.[0]?.steps, }); totalDistance += route.distance || 0; estimatedTime += route.duration || 0; } } } if (segments.length > 0) { return c.json({ success: true, route: { segments, totalDistance, estimatedTime: Math.round(estimatedTime) }, }); } } catch { /* fall through to outdoor-only routing */ } } // ── Outdoor-only routing via OSRM ── const profile = mode === "driving" ? "car" : "foot"; try { const res = await fetch( `https://router.project-osrm.org/route/v1/${profile}/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson&steps=true`, { signal: AbortSignal.timeout(10000) }, ); if (res.ok) { const data = await res.json(); const route = data.routes?.[0]; if (route) { return c.json({ success: true, route: { segments: [{ type: "outdoor", coordinates: route.geometry?.coordinates || [], distance: route.distance || 0, duration: route.duration || 0, steps: route.legs?.[0]?.steps, }], totalDistance: route.distance || 0, estimatedTime: Math.round(route.duration || 0), }, }); } } } catch {} return c.json({ error: "Routing failed" }, 502); }); // ── Proxy: c3nav API ── 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"]; routes.get("/api/c3nav/:event", async (c) => { const event = c.req.param("event"); const endpoint = c.req.query("endpoint") || "map/bounds"; if (!VALID_C3NAV_EVENTS.includes(event)) return c.json({ error: "Invalid event" }, 400); const isAllowed = ALLOWED_C3NAV_ENDPOINTS.some((a) => endpoint === a || endpoint.startsWith(a + "/")); if (!isAllowed && !endpoint.startsWith("map/locations/")) return c.json({ error: "Endpoint not allowed" }, 403); try { const res = await fetch(`https://${event}.c3nav.de/api/v2/${endpoint}/`, { headers: { "X-API-Key": "anonymous", Accept: "application/json", "User-Agent": "rMaps/1.0" }, signal: AbortSignal.timeout(5000), }); if (res.ok) return c.json(await res.json()); return c.json({ error: "c3nav API error" }, res.status as any); } catch { return c.json({ error: "c3nav unreachable" }, 502); } }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; return c.html(renderShell({ title: `${space} — Maps | rSpace`, moduleId: "rmaps", spaceSlug: space, modules: getModuleInfoList(), body: ``, scripts: ``, styles: ``, })); }); // Room-specific page routes.get("/:room", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const room = c.req.param("room"); return c.html(renderShell({ title: `${room} — Maps | rSpace`, moduleId: "rmaps", spaceSlug: space, modules: getModuleInfoList(), styles: ``, body: ``, scripts: ``, })); }); export const mapsModule: RSpaceModule = { id: "rmaps", name: "rMaps", icon: "🗺", description: "Real-time collaborative location sharing and indoor/outdoor maps", scoping: { defaultScope: 'global', userConfigurable: false }, routes, landingPage: renderLanding, standaloneDomain: "rmaps.online", feeds: [ { id: "locations", name: "Locations", kind: "data", description: "Shared location pins, points of interest, and room positions", filterable: true, }, { id: "routes", name: "Routes", kind: "data", description: "Calculated routes and navigation paths between locations", }, ], acceptsFeeds: ["data"], outputPaths: [ { path: "routes", name: "Routes", icon: "🛤️", description: "Saved navigation routes" }, { path: "places", name: "Places", icon: "📍", description: "Saved locations and points of interest" }, ], };