Merge pull request #23 from trails-cool/planner-ui-fixes
Fix waypoint icons, add elevation chart scrubbing
This commit is contained in:
commit
afb413b026
13 changed files with 899 additions and 106 deletions
|
|
@ -1,9 +1,11 @@
|
|||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
|
||||
interface ElevationPoint {
|
||||
distance: number; // meters from start
|
||||
elevation: number; // meters
|
||||
distance: number;
|
||||
elevation: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
function extractElevation(geojsonStr: string): ElevationPoint[] {
|
||||
|
|
@ -22,7 +24,12 @@ function extractElevation(geojsonStr: string): ElevationPoint[] {
|
|||
totalDist += haversine(prev[1]!, prev[0]!, curr[1]!, curr[0]!);
|
||||
}
|
||||
if (coords[i]![2] !== undefined) {
|
||||
points.push({ distance: totalDist, elevation: coords[i]![2]! });
|
||||
points.push({
|
||||
distance: totalDist,
|
||||
elevation: coords[i]![2]!,
|
||||
lat: coords[i]![1]!,
|
||||
lon: coords[i]![0]!,
|
||||
});
|
||||
}
|
||||
}
|
||||
return points;
|
||||
|
|
@ -42,9 +49,19 @@ function haversine(lat1: number, lon1: number, lat2: number, lon2: number): numb
|
|||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
export function ElevationChart({ yjs }: { yjs: YjsState }) {
|
||||
const PADDING = { top: 10, right: 10, bottom: 25, left: 40 };
|
||||
|
||||
interface ElevationChartProps {
|
||||
yjs: YjsState;
|
||||
onHover?: (position: [number, number] | null) => void;
|
||||
}
|
||||
|
||||
export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
||||
const [points, setPoints] = useState<ElevationPoint[]>([]);
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointsRef = useRef<ElevationPoint[]>([]);
|
||||
pointsRef.current = points;
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
|
|
@ -60,76 +77,161 @@ export function ElevationChart({ yjs }: { yjs: YjsState }) {
|
|||
return () => yjs.routeData.unobserve(update);
|
||||
}, [yjs.routeData]);
|
||||
|
||||
const drawChart = useCallback(
|
||||
(highlightIdx: number | null) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || points.length < 2) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const chartW = w - PADDING.left - PADDING.right;
|
||||
const chartH = h - PADDING.top - PADDING.bottom;
|
||||
|
||||
const maxDist = points[points.length - 1]!.distance;
|
||||
const elevations = points.map((p) => p.elevation);
|
||||
const minEle = Math.min(...elevations);
|
||||
const maxEle = Math.max(...elevations);
|
||||
const eleRange = maxEle - minEle || 1;
|
||||
|
||||
const toX = (d: number) => PADDING.left + (d / maxDist) * chartW;
|
||||
const toY = (e: number) => PADDING.top + chartH - ((e - minEle) / eleRange) * chartH;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Fill area
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(PADDING.left, PADDING.top + chartH);
|
||||
for (const p of points) {
|
||||
ctx.lineTo(toX(p.distance), toY(p.elevation));
|
||||
}
|
||||
ctx.lineTo(PADDING.left + chartW, PADDING.top + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = "rgba(37, 99, 235, 0.15)";
|
||||
ctx.fill();
|
||||
|
||||
// Line
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i]!;
|
||||
if (i === 0) ctx.moveTo(toX(p.distance), toY(p.elevation));
|
||||
else ctx.lineTo(toX(p.distance), toY(p.elevation));
|
||||
}
|
||||
ctx.strokeStyle = "#2563eb";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Axis labels
|
||||
ctx.fillStyle = "#6b7280";
|
||||
ctx.font = "10px sans-serif";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`${Math.round(maxEle)}m`, PADDING.left - 4, PADDING.top + 10);
|
||||
ctx.fillText(`${Math.round(minEle)}m`, PADDING.left - 4, PADDING.top + chartH);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("0 km", PADDING.left, h - 4);
|
||||
ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, PADDING.left + chartW, h - 4);
|
||||
|
||||
// Hover crosshair + info
|
||||
if (highlightIdx !== null && highlightIdx >= 0 && highlightIdx < points.length) {
|
||||
const p = points[highlightIdx]!;
|
||||
const hx = toX(p.distance);
|
||||
const hy = toY(p.elevation);
|
||||
|
||||
// Vertical line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(hx, PADDING.top);
|
||||
ctx.lineTo(hx, PADDING.top + chartH);
|
||||
ctx.strokeStyle = "rgba(239, 68, 68, 0.5)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(hx, hy, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#ef4444";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "white";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Info label
|
||||
ctx.fillStyle = "#1f2937";
|
||||
ctx.font = "bold 10px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
const label = `${Math.round(p.elevation)}m · ${(p.distance / 1000).toFixed(1)}km`;
|
||||
const labelX = hx + 8 > w - 80 ? hx - 8 : hx + 8;
|
||||
ctx.textAlign = hx + 8 > w - 80 ? "right" : "left";
|
||||
ctx.fillText(label, labelX, PADDING.top + 10);
|
||||
}
|
||||
},
|
||||
[points],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || points.length < 2) return;
|
||||
drawChart(hoverIdx);
|
||||
}, [points, hoverIdx, drawChart]);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || points.length < 2) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const chartW = rect.width - PADDING.left - PADDING.right;
|
||||
const ratio = (mouseX - PADDING.left) / chartW;
|
||||
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const padding = { top: 10, right: 10, bottom: 25, left: 40 };
|
||||
const chartW = w - padding.left - padding.right;
|
||||
const chartH = h - padding.top - padding.bottom;
|
||||
if (ratio < 0 || ratio > 1) {
|
||||
setHoverIdx(null);
|
||||
onHover?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxDist = points[points.length - 1]!.distance;
|
||||
const elevations = points.map((p) => p.elevation);
|
||||
const minEle = Math.min(...elevations);
|
||||
const maxEle = Math.max(...elevations);
|
||||
const eleRange = maxEle - minEle || 1;
|
||||
const maxDist = points[points.length - 1]!.distance;
|
||||
const targetDist = ratio * maxDist;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
// Find closest point
|
||||
let closest = 0;
|
||||
let minDiff = Infinity;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const diff = Math.abs(points[i]!.distance - targetDist);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill area
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, padding.top + chartH);
|
||||
for (const p of points) {
|
||||
const x = padding.left + (p.distance / maxDist) * chartW;
|
||||
const y = padding.top + chartH - ((p.elevation - minEle) / eleRange) * chartH;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(padding.left + chartW, padding.top + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = "rgba(37, 99, 235, 0.15)";
|
||||
ctx.fill();
|
||||
setHoverIdx(closest);
|
||||
const p = points[closest]!;
|
||||
onHover?.([p.lat, p.lon]);
|
||||
},
|
||||
[points, onHover],
|
||||
);
|
||||
|
||||
// Line
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i]!;
|
||||
const x = padding.left + (p.distance / maxDist) * chartW;
|
||||
const y = padding.top + chartH - ((p.elevation - minEle) / eleRange) * chartH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = "#2563eb";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Axis labels
|
||||
ctx.fillStyle = "#6b7280";
|
||||
ctx.font = "10px sans-serif";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`${Math.round(maxEle)}m`, padding.left - 4, padding.top + 10);
|
||||
ctx.fillText(`${Math.round(minEle)}m`, padding.left - 4, padding.top + chartH);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("0 km", padding.left, h - 4);
|
||||
ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, padding.left + chartW, h - 4);
|
||||
}, [points]);
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoverIdx(null);
|
||||
onHover?.(null);
|
||||
}, [onHover]);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 px-2 py-2">
|
||||
<p className="mb-1 px-2 text-xs font-medium text-gray-500">Elevation Profile</p>
|
||||
<canvas ref={canvasRef} className="h-24 w-full" />
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="h-24 w-full cursor-crosshair"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, useMapEvents, useMap } from "react-leaflet";
|
||||
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, CircleMarker, useMapEvents, useMap } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import * as Y from "yjs";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { baseLayers } from "@trails-cool/map";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Fix Leaflet default icon paths in bundled environment
|
||||
import iconUrl from "leaflet/dist/images/marker-icon.png";
|
||||
import iconRetinaUrl from "leaflet/dist/images/marker-icon-2x.png";
|
||||
import shadowUrl from "leaflet/dist/images/marker-shadow.png";
|
||||
|
||||
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
|
||||
function waypointIcon(index: number): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="
|
||||
width:24px;height:24px;border-radius:50%;
|
||||
background:#2563eb;color:white;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:12px;font-weight:600;
|
||||
border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,0.3);
|
||||
transform:translate(-12px,-12px);
|
||||
">${index + 1}</div>`,
|
||||
iconSize: [0, 0],
|
||||
});
|
||||
}
|
||||
|
||||
interface WaypointData {
|
||||
lat: number;
|
||||
|
|
@ -30,6 +38,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[]
|
|||
interface PlannerMapProps {
|
||||
yjs: YjsState;
|
||||
onRouteRequest?: (waypoints: WaypointData[]) => void;
|
||||
highlightPosition?: [number, number] | null;
|
||||
}
|
||||
|
||||
function MapClickHandler({ onAdd }: { onAdd: (lat: number, lng: number) => void }) {
|
||||
|
|
@ -94,7 +103,7 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
|
|||
key={clientId}
|
||||
position={[cursor.lat, cursor.lng]}
|
||||
icon={L.divIcon({
|
||||
className: "cursor-marker",
|
||||
className: "",
|
||||
html: `<div style="background:${cursor.color};color:white;padding:2px 6px;border-radius:4px;font-size:11px;white-space:nowrap;transform:translate(10px,-50%)">${cursor.name}</div>`,
|
||||
iconSize: [0, 0],
|
||||
})}
|
||||
|
|
@ -104,7 +113,7 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) {
|
||||
export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) {
|
||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
||||
const [routeGeoJson, setRouteGeoJson] = useState<L.LatLngExpression[] | null>(null);
|
||||
|
||||
|
|
@ -118,11 +127,11 @@ export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) {
|
|||
}
|
||||
};
|
||||
|
||||
yjs.waypoints.observe(update);
|
||||
yjs.waypoints.observeDeep(update);
|
||||
update();
|
||||
|
||||
return () => {
|
||||
yjs.waypoints.unobserve(update);
|
||||
yjs.waypoints.unobserveDeep(update);
|
||||
};
|
||||
}, [yjs.waypoints, onRouteRequest]);
|
||||
|
||||
|
|
@ -201,6 +210,7 @@ export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) {
|
|||
key={i}
|
||||
position={[wp.lat, wp.lon]}
|
||||
draggable
|
||||
icon={waypointIcon(i)}
|
||||
eventHandlers={{
|
||||
dragend: (e) => {
|
||||
const { lat, lng } = e.target.getLatLng();
|
||||
|
|
@ -215,6 +225,14 @@ export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) {
|
|||
))}
|
||||
|
||||
{routeGeoJson && <Polyline positions={routeGeoJson} color="#2563eb" weight={4} opacity={0.8} />}
|
||||
|
||||
{highlightPosition && (
|
||||
<CircleMarker
|
||||
center={highlightPosition}
|
||||
radius={6}
|
||||
pathOptions={{ color: "#ef4444", fillColor: "#ef4444", fillOpacity: 1, weight: 2 }}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Suspense, lazy } from "react";
|
||||
import { Suspense, lazy, useState, useCallback } from "react";
|
||||
import { useYjs } from "~/lib/use-yjs";
|
||||
import { useRouting } from "~/lib/use-routing";
|
||||
import { ProfileSelector } from "~/components/ProfileSelector";
|
||||
import { ExportButton } from "~/components/ExportButton";
|
||||
import { YjsDebugPanel } from "~/components/YjsDebugPanel";
|
||||
|
||||
const PlannerMap = lazy(() =>
|
||||
import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })),
|
||||
|
|
@ -17,6 +18,11 @@ const ElevationChart = lazy(() =>
|
|||
export function SessionView({ sessionId }: { sessionId: string }) {
|
||||
const yjs = useYjs(sessionId);
|
||||
const { isHost, computing, routeStats, requestRoute } = useRouting(yjs);
|
||||
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
|
||||
|
||||
const handleElevationHover = useCallback((pos: [number, number] | null) => {
|
||||
setHighlightPosition(pos);
|
||||
}, []);
|
||||
|
||||
if (!yjs) {
|
||||
return (
|
||||
|
|
@ -50,7 +56,14 @@ export function SessionView({ sessionId }: { sessionId: string }) {
|
|||
</header>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<main className="flex-1 flex flex-col">
|
||||
<div className="flex-1">
|
||||
<div className="relative flex-1">
|
||||
{computing && (
|
||||
<div className="absolute inset-x-0 top-0 z-[1000]">
|
||||
<div className="h-1 w-full overflow-hidden bg-blue-100">
|
||||
<div className="h-full w-1/3 animate-[slide_1s_ease-in-out_infinite] bg-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">
|
||||
|
|
@ -58,11 +71,11 @@ export function SessionView({ sessionId }: { sessionId: string }) {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} />
|
||||
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<ElevationChart yjs={yjs} />
|
||||
<ElevationChart yjs={yjs} onHover={handleElevationHover} />
|
||||
</Suspense>
|
||||
</main>
|
||||
<aside className="w-72 border-l border-gray-200 bg-white">
|
||||
|
|
@ -71,6 +84,7 @@ export function SessionView({ sessionId }: { sessionId: string }) {
|
|||
</Suspense>
|
||||
</aside>
|
||||
</div>
|
||||
<YjsDebugPanel yjs={yjs} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
|
|||
|
||||
useEffect(() => {
|
||||
const update = () => setWaypoints(getWaypointsFromYjs(yjs.waypoints));
|
||||
yjs.waypoints.observe(update);
|
||||
yjs.waypoints.observeDeep(update);
|
||||
update();
|
||||
return () => yjs.waypoints.unobserve(update);
|
||||
return () => yjs.waypoints.unobserveDeep(update);
|
||||
}, [yjs.waypoints]);
|
||||
|
||||
const deleteWaypoint = useCallback(
|
||||
|
|
|
|||
255
apps/planner/app/components/YjsDebugPanel.tsx
Normal file
255
apps/planner/app/components/YjsDebugPanel.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import * as Y from "yjs";
|
||||
import * as awarenessProtocol from "y-protocols/awareness";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
|
||||
// --- localStorage helpers ---
|
||||
|
||||
function loadBool(key: string, fallback: boolean): boolean {
|
||||
try { return localStorage.getItem(key) === "1"; } catch { return fallback; }
|
||||
}
|
||||
|
||||
function saveBool(key: string, value: boolean) {
|
||||
try { localStorage.setItem(key, value ? "1" : "0"); } catch { /* localStorage unavailable */ }
|
||||
}
|
||||
|
||||
// --- Debug state ---
|
||||
|
||||
interface DebugState {
|
||||
waypoints: Array<Record<string, unknown>>;
|
||||
routeData: Record<string, unknown>;
|
||||
awareness: Array<{ clientId: number; state: Record<string, unknown> }>;
|
||||
docSize: number;
|
||||
}
|
||||
|
||||
function serializeYMap(yMap: Y.Map<unknown>): Record<string, unknown> {
|
||||
const obj: Record<string, unknown> = {};
|
||||
yMap.forEach((value, key) => {
|
||||
if (value instanceof Y.Map) {
|
||||
obj[key] = serializeYMap(value);
|
||||
} else if (value instanceof Y.Array) {
|
||||
obj[key] = `Y.Array(${value.length})`;
|
||||
} else if (typeof value === "string" && value.length > 200) {
|
||||
obj[key] = value.slice(0, 200) + `... (${value.length} chars)`;
|
||||
} else {
|
||||
obj[key] = value;
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
function getDebugState(yjs: YjsState): DebugState {
|
||||
const waypoints = yjs.waypoints.toArray().map((yMap) => serializeYMap(yMap));
|
||||
|
||||
const routeData: Record<string, unknown> = {};
|
||||
yjs.routeData.forEach((value, key) => {
|
||||
if (typeof value === "string" && value.length > 100) {
|
||||
routeData[key] = `string(${value.length} chars)`;
|
||||
} else {
|
||||
routeData[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const awareness: DebugState["awareness"] = [];
|
||||
yjs.awareness.getStates().forEach((state, clientId) => {
|
||||
awareness.push({ clientId, state: state as Record<string, unknown> });
|
||||
});
|
||||
|
||||
const docSize = Y.encodeStateAsUpdate(yjs.doc).byteLength;
|
||||
|
||||
return { waypoints, routeData, awareness, docSize };
|
||||
}
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
const btnClass =
|
||||
"rounded bg-gray-700 px-2 py-0.5 text-[10px] hover:bg-gray-600 active:bg-gray-500 transition-colors";
|
||||
const dangerBtnClass =
|
||||
"rounded bg-red-900 px-2 py-0.5 text-[10px] text-red-200 hover:bg-red-800 active:bg-red-700 transition-colors";
|
||||
|
||||
// --- Component ---
|
||||
|
||||
/**
|
||||
* Yjs debug panel with:
|
||||
* - Visibility: shown in dev by default, toggle with Shift+Cmd+Option+T anywhere
|
||||
* - Expanded state: persisted in localStorage
|
||||
* - Actions: reset session, refetch route, become host, kick users
|
||||
* - State inspector: awareness, waypoints, route data, doc stats
|
||||
*/
|
||||
export function YjsDebugPanel({ yjs }: { yjs: YjsState }) {
|
||||
const [visible, setVisible] = useState(() => loadBool("trails:debug", import.meta.env.DEV));
|
||||
const [expanded, setExpanded] = useState(() => loadBool("trails:debug:expanded", false));
|
||||
const [state, setState] = useState<DebugState | null>(null);
|
||||
|
||||
// Hotkey: Shift+Cmd+Option+T
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.shiftKey && e.metaKey && e.altKey && e.code === "KeyT") {
|
||||
e.preventDefault();
|
||||
setVisible((v) => {
|
||||
const next = !v;
|
||||
saveBool("trails:debug", next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handler, true);
|
||||
return () => document.removeEventListener("keydown", handler, true);
|
||||
}, []);
|
||||
|
||||
// Sync Yjs state
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const update = () => setState(getDebugState(yjs));
|
||||
|
||||
yjs.waypoints.observe(update);
|
||||
yjs.routeData.observe(update);
|
||||
yjs.awareness.on("change", update);
|
||||
update();
|
||||
|
||||
const interval = setInterval(update, 2000);
|
||||
|
||||
return () => {
|
||||
yjs.waypoints.unobserve(update);
|
||||
yjs.routeData.unobserve(update);
|
||||
yjs.awareness.off("change", update);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [yjs, visible]);
|
||||
|
||||
// Actions
|
||||
|
||||
const resetSession = useCallback(() => {
|
||||
yjs.doc.transact(() => {
|
||||
while (yjs.waypoints.length > 0) {
|
||||
yjs.waypoints.delete(0, 1);
|
||||
}
|
||||
yjs.routeData.delete("geojson");
|
||||
yjs.routeData.delete("profile");
|
||||
});
|
||||
}, [yjs]);
|
||||
|
||||
const refetchRoute = useCallback(async () => {
|
||||
const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
|
||||
lat: yMap.get("lat") as number,
|
||||
lon: yMap.get("lon") as number,
|
||||
}));
|
||||
|
||||
if (waypoints.length < 2) return;
|
||||
|
||||
const profile = (yjs.routeData.get("profile") as string) ?? "trekking";
|
||||
const response = await fetch("/api/route", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ waypoints, profile }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const geojson = await response.json();
|
||||
yjs.routeData.set("geojson", JSON.stringify(geojson));
|
||||
}
|
||||
}, [yjs]);
|
||||
|
||||
const kickUser = useCallback(
|
||||
(clientId: number) => {
|
||||
if (clientId === yjs.awareness.clientID) return;
|
||||
awarenessProtocol.removeAwarenessStates(yjs.awareness, [clientId], "kicked");
|
||||
},
|
||||
[yjs],
|
||||
);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setExpanded((v) => {
|
||||
const next = !v;
|
||||
saveBool("trails:debug:expanded", next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Render
|
||||
|
||||
if (!visible || !state) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 right-0 z-[2000] max-h-[50vh] w-96 overflow-auto rounded-tl-lg border-l border-t border-gray-300 bg-gray-900 text-xs text-gray-100 shadow-lg">
|
||||
<button
|
||||
onClick={toggleExpanded}
|
||||
className="sticky top-0 flex w-full items-center justify-between bg-gray-800 px-3 py-1.5 font-mono text-xs hover:bg-gray-700"
|
||||
>
|
||||
<span>Yjs Debug</span>
|
||||
<span className="text-gray-400">
|
||||
{state.waypoints.length} wps · {state.awareness.length} users · {(state.docSize / 1024).toFixed(1)}KB
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="space-y-3 p-3 font-mono">
|
||||
<Section title="Actions">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button onClick={refetchRoute} className={btnClass}>
|
||||
Refetch route
|
||||
</button>
|
||||
<button onClick={resetSession} className={dangerBtnClass}>
|
||||
Reset session
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Awareness">
|
||||
{state.awareness.map((a) => (
|
||||
<div key={a.clientId} className="mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-yellow-400">client:{a.clientId}</span>
|
||||
{a.clientId === yjs.awareness.clientID && (
|
||||
<span className="text-green-400">(you)</span>
|
||||
)}
|
||||
{Object.keys(a.state).length === 0 && (
|
||||
<span className="text-gray-500">(server)</span>
|
||||
)}
|
||||
{a.clientId !== yjs.awareness.clientID && Object.keys(a.state).length > 0 && (
|
||||
<button
|
||||
onClick={() => kickUser(a.clientId)}
|
||||
className={dangerBtnClass}
|
||||
>
|
||||
kick
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="ml-2 text-gray-400">{JSON.stringify(a.state, null, 1)}</pre>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title={`Waypoints (${state.waypoints.length})`}>
|
||||
{state.waypoints.map((wp, i) => (
|
||||
<div key={i} className="text-gray-300">
|
||||
[{i}] {JSON.stringify(wp)}
|
||||
</div>
|
||||
))}
|
||||
{state.waypoints.length === 0 && <span className="text-gray-500">empty</span>}
|
||||
</Section>
|
||||
|
||||
<Section title="Route Data">
|
||||
<pre className="text-gray-300">{JSON.stringify(state.routeData, null, 1)}</pre>
|
||||
</Section>
|
||||
|
||||
<Section title="Doc Stats">
|
||||
<div className="text-gray-300">
|
||||
Size: {(state.docSize / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-blue-400">{title}</div>
|
||||
<div className="ml-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
apps/planner/app/lib/brouter.test.ts
Normal file
167
apps/planner/app/lib/brouter.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Test the mergeGeoJsonSegments logic extracted from brouter.ts
|
||||
|
||||
interface GeoJsonFeature {
|
||||
type: string;
|
||||
properties: Record<string, unknown>;
|
||||
geometry: { type: string; coordinates: number[][] };
|
||||
}
|
||||
|
||||
interface GeoJsonCollection {
|
||||
type: string;
|
||||
features: GeoJsonFeature[];
|
||||
}
|
||||
|
||||
function mergeGeoJsonSegments(segments: GeoJsonCollection[]): GeoJsonCollection {
|
||||
const allCoords: number[][] = [];
|
||||
let totalLength = 0;
|
||||
let totalAscend = 0;
|
||||
let totalTime = 0;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const feature = segments[i]!.features?.[0];
|
||||
if (!feature) continue;
|
||||
|
||||
const coords = feature.geometry.coordinates;
|
||||
const startIdx = i === 0 ? 0 : 1;
|
||||
for (let j = startIdx; j < coords.length; j++) {
|
||||
allCoords.push(coords[j]!);
|
||||
}
|
||||
|
||||
const props = feature.properties;
|
||||
totalLength += parseInt(String(props["track-length"] ?? "0"));
|
||||
totalAscend += parseInt(String(props["filtered ascend"] ?? "0"));
|
||||
totalTime += parseInt(String(props["total-time"] ?? "0"));
|
||||
}
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {
|
||||
"track-length": String(totalLength),
|
||||
"filtered ascend": String(totalAscend),
|
||||
"total-time": String(totalTime),
|
||||
creator: "trails.cool (BRouter segments)",
|
||||
},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: allCoords,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeSegment(coords: number[][], length: number, ascend: number): GeoJsonCollection {
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: [{
|
||||
type: "Feature",
|
||||
properties: {
|
||||
"track-length": String(length),
|
||||
"filtered ascend": String(ascend),
|
||||
"total-time": "100",
|
||||
},
|
||||
geometry: { type: "LineString", coordinates: coords },
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
describe("mergeGeoJsonSegments", () => {
|
||||
it("merges two segments", () => {
|
||||
const seg1 = makeSegment([[13.0, 52.0], [13.1, 52.1], [13.2, 52.2]], 1000, 10);
|
||||
const seg2 = makeSegment([[13.2, 52.2], [13.3, 52.3], [13.4, 52.4]], 1500, 20);
|
||||
|
||||
const result = mergeGeoJsonSegments([seg1, seg2]);
|
||||
const coords = result.features[0]!.geometry.coordinates;
|
||||
|
||||
// Should have 5 points (first segment 3 + second segment 2, skipping duplicate)
|
||||
expect(coords).toHaveLength(5);
|
||||
expect(coords[0]).toEqual([13.0, 52.0]);
|
||||
expect(coords[2]).toEqual([13.2, 52.2]); // shared point
|
||||
expect(coords[4]).toEqual([13.4, 52.4]);
|
||||
});
|
||||
|
||||
it("skips duplicate point at segment boundaries", () => {
|
||||
const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0);
|
||||
const seg2 = makeSegment([[2, 2], [3, 3]], 100, 0);
|
||||
const seg3 = makeSegment([[3, 3], [4, 4]], 100, 0);
|
||||
|
||||
const result = mergeGeoJsonSegments([seg1, seg2, seg3]);
|
||||
const coords = result.features[0]!.geometry.coordinates;
|
||||
|
||||
expect(coords).toHaveLength(4);
|
||||
expect(coords).toEqual([[1, 1], [2, 2], [3, 3], [4, 4]]);
|
||||
});
|
||||
|
||||
it("accumulates stats across segments", () => {
|
||||
const seg1 = makeSegment([[1, 1], [2, 2]], 1000, 50);
|
||||
const seg2 = makeSegment([[2, 2], [3, 3]], 2000, 30);
|
||||
|
||||
const result = mergeGeoJsonSegments([seg1, seg2]);
|
||||
const props = result.features[0]!.properties;
|
||||
|
||||
expect(props["track-length"]).toBe("3000");
|
||||
expect(props["filtered ascend"]).toBe("80");
|
||||
expect(props["total-time"]).toBe("200");
|
||||
});
|
||||
|
||||
it("handles single segment", () => {
|
||||
const seg = makeSegment([[1, 1], [2, 2], [3, 3]], 500, 10);
|
||||
const result = mergeGeoJsonSegments([seg]);
|
||||
|
||||
expect(result.features[0]!.geometry.coordinates).toHaveLength(3);
|
||||
expect(result.features[0]!.properties["track-length"]).toBe("500");
|
||||
});
|
||||
|
||||
it("handles empty segment gracefully", () => {
|
||||
const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0);
|
||||
const empty: GeoJsonCollection = { type: "FeatureCollection", features: [] };
|
||||
|
||||
const result = mergeGeoJsonSegments([seg1, empty]);
|
||||
expect(result.features[0]!.geometry.coordinates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("preserves elevation data in coordinates", () => {
|
||||
const seg1 = makeSegment([[13.0, 52.0, 100], [13.1, 52.1, 150]], 500, 50);
|
||||
const seg2 = makeSegment([[13.1, 52.1, 150], [13.2, 52.2, 200]], 500, 50);
|
||||
|
||||
const result = mergeGeoJsonSegments([seg1, seg2]);
|
||||
const coords = result.features[0]!.geometry.coordinates;
|
||||
|
||||
expect(coords).toHaveLength(3);
|
||||
expect(coords[0]).toEqual([13.0, 52.0, 100]);
|
||||
expect(coords[1]).toEqual([13.1, 52.1, 150]);
|
||||
expect(coords[2]).toEqual([13.2, 52.2, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waypoint to BRouter segments", () => {
|
||||
it("splits N waypoints into N-1 pairs", () => {
|
||||
const waypoints = [
|
||||
{ lat: 52.0, lon: 13.0 },
|
||||
{ lat: 52.1, lon: 13.1 },
|
||||
{ lat: 52.2, lon: 13.2 },
|
||||
{ lat: 52.3, lon: 13.3 },
|
||||
];
|
||||
|
||||
const pairs: Array<[typeof waypoints[0], typeof waypoints[0]]> = [];
|
||||
for (let i = 0; i < waypoints.length - 1; i++) {
|
||||
pairs.push([waypoints[i]!, waypoints[i + 1]!]);
|
||||
}
|
||||
|
||||
expect(pairs).toHaveLength(3);
|
||||
expect(pairs[0]).toEqual([waypoints[0], waypoints[1]]);
|
||||
expect(pairs[1]).toEqual([waypoints[1], waypoints[2]]);
|
||||
expect(pairs[2]).toEqual([waypoints[2], waypoints[3]]);
|
||||
});
|
||||
|
||||
it("formats lon,lat correctly for BRouter", () => {
|
||||
const wp = { lat: 52.5277, lon: 13.4033 };
|
||||
const lonlat = `${wp.lon},${wp.lat}`;
|
||||
expect(lonlat).toBe("13.4033,52.5277");
|
||||
});
|
||||
});
|
||||
|
|
@ -7,29 +7,101 @@ export interface RouteRequest {
|
|||
format?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a route segment-by-segment between consecutive waypoints.
|
||||
* This matches bikerouter.de's behavior and guarantees the route
|
||||
* passes through every waypoint.
|
||||
*/
|
||||
export async function computeRoute(request: RouteRequest): Promise<unknown> {
|
||||
if (request.waypoints.length < 2) {
|
||||
throw new Error("At least 2 waypoints are required");
|
||||
}
|
||||
|
||||
const lonlats = request.waypoints.map((wp) => `${wp.lon},${wp.lat}`).join("|");
|
||||
const profile = request.profile ?? "trekking";
|
||||
const format = request.format ?? "geojson";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
lonlats,
|
||||
profile: request.profile ?? "trekking",
|
||||
alternativeidx: String(request.alternativeIdx ?? 0),
|
||||
format: request.format ?? "geojson",
|
||||
});
|
||||
// Route each segment independently
|
||||
const segments = await Promise.all(
|
||||
request.waypoints.slice(0, -1).map((wp, i) => {
|
||||
const next = request.waypoints[i + 1]!;
|
||||
const lonlats = `${wp.lon},${wp.lat}|${next.lon},${next.lat}`;
|
||||
const params = new URLSearchParams({
|
||||
lonlats,
|
||||
profile,
|
||||
alternativeidx: "0",
|
||||
format,
|
||||
});
|
||||
return fetchSegment(`${BROUTER_URL}/brouter?${params}`);
|
||||
}),
|
||||
);
|
||||
|
||||
const url = `${BROUTER_URL}/brouter?${params}`;
|
||||
// Merge GeoJSON segments into a single FeatureCollection
|
||||
return mergeGeoJsonSegments(segments);
|
||||
}
|
||||
|
||||
async function fetchSegment(url: string): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`BRouter error (${response.status}): ${body}`);
|
||||
}
|
||||
return response.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
interface GeoJsonFeature {
|
||||
type: string;
|
||||
properties: Record<string, unknown>;
|
||||
geometry: { type: string; coordinates: number[][] };
|
||||
}
|
||||
|
||||
interface GeoJsonCollection {
|
||||
type: string;
|
||||
features: GeoJsonFeature[];
|
||||
}
|
||||
|
||||
function mergeGeoJsonSegments(segments: Record<string, unknown>[]): GeoJsonCollection {
|
||||
const allCoords: number[][] = [];
|
||||
let totalLength = 0;
|
||||
let totalAscend = 0;
|
||||
let totalTime = 0;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i] as unknown as GeoJsonCollection;
|
||||
const feature = segment.features?.[0];
|
||||
if (!feature) continue;
|
||||
|
||||
const coords = feature.geometry.coordinates;
|
||||
// Skip first point of subsequent segments to avoid duplicates
|
||||
const startIdx = i === 0 ? 0 : 1;
|
||||
for (let j = startIdx; j < coords.length; j++) {
|
||||
allCoords.push(coords[j]!);
|
||||
}
|
||||
|
||||
// Accumulate stats
|
||||
const props = feature.properties;
|
||||
totalLength += parseInt(String(props["track-length"] ?? "0"));
|
||||
totalAscend += parseInt(String(props["filtered ascend"] ?? "0"));
|
||||
totalTime += parseInt(String(props["total-time"] ?? "0"));
|
||||
}
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {
|
||||
"track-length": String(totalLength),
|
||||
"filtered ascend": String(totalAscend),
|
||||
"total-time": String(totalTime),
|
||||
creator: "trails.cool (BRouter segments)",
|
||||
},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: allCoords,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function getBRouterUrl(): string {
|
||||
|
|
|
|||
94
apps/planner/app/lib/host-election.test.ts
Normal file
94
apps/planner/app/lib/host-election.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { electHost } from "./host-election";
|
||||
|
||||
function makeStates(
|
||||
entries: Array<{ id: number; user?: boolean; role?: string }>,
|
||||
): Map<number, Record<string, unknown>> {
|
||||
const map = new Map<number, Record<string, unknown>>();
|
||||
for (const e of entries) {
|
||||
const state: Record<string, unknown> = {};
|
||||
if (e.user) state.user = { color: "#000", name: "Test" };
|
||||
if (e.role) state.role = e.role;
|
||||
map.set(e.id, state);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
describe("electHost", () => {
|
||||
it("elects the only real client as host", () => {
|
||||
const states = makeStates([
|
||||
{ id: 100, user: true },
|
||||
]);
|
||||
expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" });
|
||||
});
|
||||
|
||||
it("elects lowest ID among two real clients", () => {
|
||||
const states = makeStates([
|
||||
{ id: 200, user: true },
|
||||
{ id: 100, user: true },
|
||||
]);
|
||||
expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" });
|
||||
expect(electHost(states, 200)).toEqual({ isHost: false, role: "participant" });
|
||||
});
|
||||
|
||||
it("ignores server docs (no user state)", () => {
|
||||
const states = makeStates([
|
||||
{ id: 50 }, // server doc — lowest ID but no user
|
||||
{ id: 200, user: true },
|
||||
{ id: 100, user: true },
|
||||
]);
|
||||
expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" });
|
||||
expect(electHost(states, 200)).toEqual({ isHost: false, role: "participant" });
|
||||
});
|
||||
|
||||
it("always assigns a role (never undefined)", () => {
|
||||
const states = makeStates([
|
||||
{ id: 300, user: true },
|
||||
{ id: 100, user: true },
|
||||
{ id: 200, user: true },
|
||||
]);
|
||||
const result = electHost(states, 200);
|
||||
expect(result.role).toBeDefined();
|
||||
expect(result.role).toBe("participant");
|
||||
});
|
||||
|
||||
it("handles single client with server doc", () => {
|
||||
const states = makeStates([
|
||||
{ id: 999 }, // server
|
||||
{ id: 500, user: true },
|
||||
]);
|
||||
expect(electHost(states, 500)).toEqual({ isHost: true, role: "host" });
|
||||
});
|
||||
|
||||
it("handles no real clients (defensive)", () => {
|
||||
const states = makeStates([
|
||||
{ id: 10 }, // server only
|
||||
]);
|
||||
// localId not in states — still returns host as fallback
|
||||
expect(electHost(states, 42)).toEqual({ isHost: true, role: "host" });
|
||||
});
|
||||
|
||||
it("ignores existing role claims — always uses lowest ID", () => {
|
||||
const states = makeStates([
|
||||
{ id: 200, user: true, role: "host" },
|
||||
{ id: 100, user: true, role: "participant" },
|
||||
]);
|
||||
// ID 100 should be host regardless of current role claims
|
||||
expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" });
|
||||
expect(electHost(states, 200)).toEqual({ isHost: false, role: "participant" });
|
||||
});
|
||||
|
||||
it("works with many clients", () => {
|
||||
const states = makeStates([
|
||||
{ id: 999, user: true },
|
||||
{ id: 500, user: true },
|
||||
{ id: 42 }, // server
|
||||
{ id: 200, user: true },
|
||||
{ id: 100, user: true },
|
||||
{ id: 800, user: true },
|
||||
]);
|
||||
expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" });
|
||||
expect(electHost(states, 500)).toEqual({ isHost: false, role: "participant" });
|
||||
expect(electHost(states, 999)).toEqual({ isHost: false, role: "participant" });
|
||||
});
|
||||
});
|
||||
26
apps/planner/app/lib/host-election.ts
Normal file
26
apps/planner/app/lib/host-election.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Determines which client should be the routing host.
|
||||
*
|
||||
* Simple rule: the real client (has user state) with the lowest
|
||||
* client ID is always the host. Deterministic, no race conditions.
|
||||
*/
|
||||
export function electHost(
|
||||
states: Map<number, Record<string, unknown>>,
|
||||
localId: number,
|
||||
): { isHost: boolean; role: "host" | "participant" } {
|
||||
let lowestRealId = Infinity;
|
||||
|
||||
states.forEach((state, clientId) => {
|
||||
if (state.user) {
|
||||
if (clientId < lowestRealId) lowestRealId = clientId;
|
||||
}
|
||||
});
|
||||
|
||||
// No real clients found (shouldn't happen, but defensive)
|
||||
if (lowestRealId === Infinity) {
|
||||
return { isHost: true, role: "host" };
|
||||
}
|
||||
|
||||
const isHost = localId === lowestRealId;
|
||||
return { isHost, role: isHost ? "host" : "participant" };
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as Y from "yjs";
|
||||
import type { YjsState } from "./use-yjs";
|
||||
import { electHost } from "./host-election";
|
||||
|
||||
interface RouteStats {
|
||||
distance?: number;
|
||||
|
|
@ -31,17 +32,11 @@ export function useRouting(yjs: YjsState | null) {
|
|||
if (!yjs) return;
|
||||
|
||||
const checkHost = () => {
|
||||
const states = yjs.awareness.getStates();
|
||||
const states = yjs.awareness.getStates() as Map<number, Record<string, unknown>>;
|
||||
const localId = yjs.awareness.clientID;
|
||||
|
||||
let lowestId = Infinity;
|
||||
states.forEach((_state, clientId) => {
|
||||
if (clientId < lowestId) lowestId = clientId;
|
||||
});
|
||||
|
||||
const amHost = localId === lowestId;
|
||||
const { isHost: amHost, role } = electHost(states, localId);
|
||||
setIsHost(amHost);
|
||||
yjs.awareness.setLocalStateField("role", amHost ? "host" : "participant");
|
||||
yjs.awareness.setLocalStateField("role", role);
|
||||
};
|
||||
|
||||
yjs.awareness.on("change", checkHost);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@ function randomItem<T>(arr: T[]): T {
|
|||
return arr[Math.floor(Math.random() * arr.length)]!;
|
||||
}
|
||||
|
||||
function getOrCreateUserIdentity(): { color: string; name: string } {
|
||||
try {
|
||||
const stored = localStorage.getItem("trails:user");
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch { /* localStorage unavailable */ }
|
||||
|
||||
const identity = { color: randomItem(COLORS), name: randomItem(NAMES) };
|
||||
try { localStorage.setItem("trails:user", JSON.stringify(identity)); } catch { /* localStorage unavailable */ }
|
||||
return identity;
|
||||
}
|
||||
|
||||
export interface YjsState {
|
||||
doc: Y.Doc;
|
||||
provider: WebsocketProvider;
|
||||
|
|
@ -41,8 +52,8 @@ export function useYjs(sessionId: string): YjsState | null {
|
|||
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
||||
const routeData = doc.getMap("routeData");
|
||||
|
||||
const color = randomItem(COLORS);
|
||||
const name = randomItem(NAMES);
|
||||
// Use persistent identity
|
||||
const { color, name } = getOrCreateUserIdentity();
|
||||
provider.awareness.setLocalStateField("user", { color, name });
|
||||
|
||||
const updateState = (connected: boolean) => {
|
||||
|
|
@ -60,7 +71,6 @@ export function useYjs(sessionId: string): YjsState | null {
|
|||
updateState(status === "connected");
|
||||
});
|
||||
|
||||
// Set initial state (even before connection)
|
||||
updateState(false);
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ const messageAwareness = 1;
|
|||
export function yjsDevPlugin(): Plugin {
|
||||
const docs = new Map<string, Y.Doc>();
|
||||
const awarenessMap = new Map<string, awarenessProtocol.Awareness>();
|
||||
const conns = new Map<WebSocket, { docName: string; awarenessIds: Set<number> }>();
|
||||
// Map each WebSocket to its doc name and the awareness client IDs it owns
|
||||
const conns = new Map<WebSocket, { docName: string; clientIds: Set<number> }>();
|
||||
|
||||
function getDoc(docName: string): { doc: Y.Doc; awareness: awarenessProtocol.Awareness } {
|
||||
let doc = docs.get(docName);
|
||||
|
|
@ -27,6 +28,21 @@ export function yjsDevPlugin(): Plugin {
|
|||
awareness = new awarenessProtocol.Awareness(doc);
|
||||
awarenessMap.set(docName, awareness);
|
||||
|
||||
// Broadcast doc updates to all connections in this room
|
||||
doc.on("update", (update: Uint8Array, origin: unknown) => {
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, messageSync);
|
||||
syncProtocol.writeUpdate(encoder, update);
|
||||
const message = encoding.toUint8Array(encoder);
|
||||
|
||||
for (const [ws, meta] of conns) {
|
||||
// Don't send back to the origin connection
|
||||
if (meta.docName === docName && ws !== origin && ws.readyState === ws.OPEN) {
|
||||
ws.send(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
awareness.on("update", ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => {
|
||||
const changedClients = added.concat(updated, removed);
|
||||
const encoder = encoding.createEncoder();
|
||||
|
|
@ -35,7 +51,7 @@ export function yjsDevPlugin(): Plugin {
|
|||
const message = encoding.toUint8Array(encoder);
|
||||
|
||||
for (const [ws, meta] of conns) {
|
||||
if (meta.docName === docName) {
|
||||
if (meta.docName === docName && ws.readyState === ws.OPEN) {
|
||||
ws.send(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +79,20 @@ export function yjsDevPlugin(): Plugin {
|
|||
case messageAwareness: {
|
||||
const update = decoding.readVarUint8Array(decoder);
|
||||
awarenessProtocol.applyAwarenessUpdate(awareness, update, ws);
|
||||
|
||||
// Track which client IDs this WebSocket is responsible for
|
||||
// by checking what was added/updated in this awareness update
|
||||
const meta = conns.get(ws);
|
||||
if (meta) {
|
||||
awareness.getStates().forEach((_state, clientId) => {
|
||||
// The awareness meta stores which "origin" (ws) last updated each client
|
||||
const awarenessClientMeta = awareness.meta.get(clientId);
|
||||
if (awarenessClientMeta) {
|
||||
// If this client ID was just updated from this connection, track it
|
||||
meta.clientIds.add(clientId);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -81,7 +111,7 @@ export function yjsDevPlugin(): Plugin {
|
|||
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
const { doc, awareness } = getDoc(docName);
|
||||
conns.set(ws, { docName, awarenessIds: new Set() });
|
||||
conns.set(ws, { docName, clientIds: new Set() });
|
||||
|
||||
// Send sync step 1
|
||||
const encoder = encoding.createEncoder();
|
||||
|
|
@ -89,7 +119,7 @@ export function yjsDevPlugin(): Plugin {
|
|||
syncProtocol.writeSyncStep1(encoder, doc);
|
||||
ws.send(encoding.toUint8Array(encoder));
|
||||
|
||||
// Send awareness states
|
||||
// Send current awareness states
|
||||
const awarenessStates = awareness.getStates();
|
||||
if (awarenessStates.size > 0) {
|
||||
const awarenessEncoder = encoding.createEncoder();
|
||||
|
|
@ -107,8 +137,13 @@ export function yjsDevPlugin(): Plugin {
|
|||
|
||||
ws.on("close", () => {
|
||||
const meta = conns.get(ws);
|
||||
if (meta) {
|
||||
awarenessProtocol.removeAwarenessStates(awareness, Array.from(meta.awarenessIds), null);
|
||||
if (meta && meta.clientIds.size > 0) {
|
||||
// Immediately remove awareness states for all clients on this connection
|
||||
awarenessProtocol.removeAwarenessStates(
|
||||
awareness,
|
||||
Array.from(meta.clientIds),
|
||||
null,
|
||||
);
|
||||
}
|
||||
conns.delete(ws);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1 +1,6 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@keyframes slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue