rspace-online/modules/rmaps/mod.ts

284 lines
9.8 KiB
TypeScript

/**
* 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: 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: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
}));
});
// 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: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js"></script>`,
}));
});
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" },
],
};