Merge pull request #21 from trails-cool/finish-planner-ui
Complete Planner UI: profile selector, elevation chart, GPX export
This commit is contained in:
commit
526f721547
11 changed files with 459 additions and 39 deletions
135
apps/planner/app/components/ElevationChart.tsx
Normal file
135
apps/planner/app/components/ElevationChart.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useEffect, useState, useRef } from "react";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
|
||||
interface ElevationPoint {
|
||||
distance: number; // meters from start
|
||||
elevation: number; // meters
|
||||
}
|
||||
|
||||
function extractElevation(geojsonStr: string): ElevationPoint[] {
|
||||
try {
|
||||
const geojson = JSON.parse(geojsonStr);
|
||||
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
|
||||
if (coords.length === 0) return [];
|
||||
|
||||
const points: ElevationPoint[] = [];
|
||||
let totalDist = 0;
|
||||
|
||||
for (let i = 0; i < coords.length; i++) {
|
||||
if (i > 0) {
|
||||
const prev = coords[i - 1]!;
|
||||
const curr = coords[i]!;
|
||||
totalDist += haversine(prev[1]!, prev[0]!, curr[1]!, curr[0]!);
|
||||
}
|
||||
if (coords[i]![2] !== undefined) {
|
||||
points.push({ distance: totalDist, elevation: coords[i]![2]! });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000;
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
export function ElevationChart({ yjs }: { yjs: YjsState }) {
|
||||
const [points, setPoints] = useState<ElevationPoint[]>([]);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const geojson = yjs.routeData.get("geojson") as string | undefined;
|
||||
if (geojson) {
|
||||
setPoints(extractElevation(geojson));
|
||||
} else {
|
||||
setPoints([]);
|
||||
}
|
||||
};
|
||||
yjs.routeData.observe(update);
|
||||
update();
|
||||
return () => yjs.routeData.unobserve(update);
|
||||
}, [yjs.routeData]);
|
||||
|
||||
useEffect(() => {
|
||||
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 padding = { top: 10, right: 10, bottom: 25, left: 40 };
|
||||
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;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// 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();
|
||||
|
||||
// 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]);
|
||||
|
||||
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" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
apps/planner/app/components/ExportButton.tsx
Normal file
57
apps/planner/app/components/ExportButton.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useCallback } from "react";
|
||||
import * as Y from "yjs";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { generateGpx } from "@trails-cool/gpx";
|
||||
import type { TrackPoint } from "@trails-cool/gpx";
|
||||
|
||||
export function ExportButton({ yjs }: { yjs: YjsState }) {
|
||||
const handleExport = useCallback(() => {
|
||||
// Get waypoints from Yjs
|
||||
const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
|
||||
lat: yMap.get("lat") as number,
|
||||
lon: yMap.get("lon") as number,
|
||||
name: yMap.get("name") as string | undefined,
|
||||
}));
|
||||
|
||||
// Get route track from GeoJSON
|
||||
let tracks: TrackPoint[][] = [];
|
||||
const geojsonStr = yjs.routeData.get("geojson") as string | undefined;
|
||||
if (geojsonStr) {
|
||||
try {
|
||||
const geojson = JSON.parse(geojsonStr);
|
||||
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
|
||||
if (coords.length > 0) {
|
||||
tracks = [
|
||||
coords.map((c) => ({
|
||||
lat: c[1]!,
|
||||
lon: c[0]!,
|
||||
ele: c[2],
|
||||
})),
|
||||
];
|
||||
}
|
||||
} catch {
|
||||
// Invalid GeoJSON
|
||||
}
|
||||
}
|
||||
|
||||
const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks });
|
||||
|
||||
// Download
|
||||
const blob = new Blob([gpx], { type: "application/gpx+xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "route.gpx";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [yjs.waypoints, yjs.routeData]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Export GPX
|
||||
</button>
|
||||
);
|
||||
}
|
||||
57
apps/planner/app/components/ProfileSelector.tsx
Normal file
57
apps/planner/app/components/ProfileSelector.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
|
||||
const PROFILES = [
|
||||
{ id: "trekking", label: "Hiking" },
|
||||
{ id: "fastbike", label: "Cycling (fast)" },
|
||||
{ id: "safety", label: "Cycling (safe)" },
|
||||
{ id: "shortest", label: "Shortest" },
|
||||
{ id: "car-eco", label: "Car" },
|
||||
];
|
||||
|
||||
interface ProfileSelectorProps {
|
||||
yjs: YjsState;
|
||||
}
|
||||
|
||||
export function ProfileSelector({ yjs }: ProfileSelectorProps) {
|
||||
const [profile, setProfile] = useState("trekking");
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const p = yjs.routeData.get("profile") as string | undefined;
|
||||
if (p) setProfile(p);
|
||||
};
|
||||
yjs.routeData.observe(update);
|
||||
update();
|
||||
return () => yjs.routeData.unobserve(update);
|
||||
}, [yjs.routeData]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
setProfile(value);
|
||||
yjs.routeData.set("profile", value);
|
||||
},
|
||||
[yjs.routeData],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="profile" className="text-sm text-gray-600">
|
||||
Profile:
|
||||
</label>
|
||||
<select
|
||||
id="profile"
|
||||
value={profile}
|
||||
onChange={handleChange}
|
||||
className="rounded border border-gray-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
{PROFILES.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { Suspense, lazy } from "react";
|
||||
import { useYjs } from "~/lib/use-yjs";
|
||||
import { useRouting } from "~/lib/use-routing";
|
||||
import { ProfileSelector } from "~/components/ProfileSelector";
|
||||
import { ExportButton } from "~/components/ExportButton";
|
||||
|
||||
const PlannerMap = lazy(() =>
|
||||
import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })),
|
||||
|
|
@ -8,6 +10,9 @@ const PlannerMap = lazy(() =>
|
|||
const WaypointSidebar = lazy(() =>
|
||||
import("~/components/WaypointSidebar").then((m) => ({ default: m.WaypointSidebar })),
|
||||
);
|
||||
const ElevationChart = lazy(() =>
|
||||
import("~/components/ElevationChart").then((m) => ({ default: m.ElevationChart })),
|
||||
);
|
||||
|
||||
export function SessionView({ sessionId }: { sessionId: string }) {
|
||||
const yjs = useYjs(sessionId);
|
||||
|
|
@ -24,8 +29,12 @@ export function SessionView({ sessionId }: { sessionId: string }) {
|
|||
return (
|
||||
<>
|
||||
<header className="flex items-center justify-between border-b border-gray-200 px-4 py-2">
|
||||
<h1 className="text-lg font-semibold text-gray-900">trails.cool Planner</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900">trails.cool Planner</h1>
|
||||
<ProfileSelector yjs={yjs} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ExportButton yjs={yjs} />
|
||||
{computing && (
|
||||
<span className="text-xs text-blue-600">Computing route...</span>
|
||||
)}
|
||||
|
|
@ -40,15 +49,20 @@ export function SessionView({ sessionId }: { sessionId: string }) {
|
|||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<main className="flex-1">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">
|
||||
Loading map...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} />
|
||||
<main className="flex-1 flex flex-col">
|
||||
<div className="flex-1">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">
|
||||
Loading map...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<ElevationChart yjs={yjs} />
|
||||
</Suspense>
|
||||
</main>
|
||||
<aside className="w-72 border-l border-gray-200 bg-white">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as Y from "yjs";
|
||||
import type { YjsState } from "./use-yjs";
|
||||
|
||||
interface RouteStats {
|
||||
|
|
@ -12,6 +13,13 @@ interface WaypointData {
|
|||
lon: number;
|
||||
}
|
||||
|
||||
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
|
||||
return waypoints.toArray().map((yMap) => ({
|
||||
lat: yMap.get("lat") as number,
|
||||
lon: yMap.get("lon") as number,
|
||||
}));
|
||||
}
|
||||
|
||||
export function useRouting(yjs: YjsState | null) {
|
||||
const [isHost, setIsHost] = useState(false);
|
||||
const [computing, setComputing] = useState(false);
|
||||
|
|
@ -26,7 +34,6 @@ export function useRouting(yjs: YjsState | null) {
|
|||
const states = yjs.awareness.getStates();
|
||||
const localId = yjs.awareness.clientID;
|
||||
|
||||
// Find the client with the lowest ID (longest connected approximation)
|
||||
let lowestId = Infinity;
|
||||
states.forEach((_state, clientId) => {
|
||||
if (clientId < lowestId) lowestId = clientId;
|
||||
|
|
@ -64,7 +71,6 @@ export function useRouting(yjs: YjsState | null) {
|
|||
|
||||
const geojson = await response.json();
|
||||
|
||||
// Extract stats from BRouter response
|
||||
const props = geojson.features?.[0]?.properties;
|
||||
if (props) {
|
||||
setRouteStats({
|
||||
|
|
@ -76,10 +82,9 @@ export function useRouting(yjs: YjsState | null) {
|
|||
});
|
||||
}
|
||||
|
||||
// Store route in Yjs for all participants
|
||||
yjs.routeData.set("geojson", JSON.stringify(geojson));
|
||||
} catch {
|
||||
// Route computation failed — don't crash
|
||||
// Route computation failed
|
||||
} finally {
|
||||
setComputing(false);
|
||||
}
|
||||
|
|
@ -96,5 +101,27 @@ export function useRouting(yjs: YjsState | null) {
|
|||
[isHost, computeRoute],
|
||||
);
|
||||
|
||||
// Watch for profile changes and trigger recompute
|
||||
useEffect(() => {
|
||||
if (!yjs || !isHost) return;
|
||||
|
||||
const onProfileChange = () => {
|
||||
const wps = getWaypointsFromYjs(yjs.waypoints);
|
||||
if (wps.length >= 2) {
|
||||
requestRoute(wps);
|
||||
}
|
||||
};
|
||||
|
||||
// Observe routeData for profile changes
|
||||
const observer = (event: Y.YMapEvent<unknown>) => {
|
||||
if (event.keysChanged.has("profile")) {
|
||||
onProfileChange();
|
||||
}
|
||||
};
|
||||
|
||||
yjs.routeData.observe(observer);
|
||||
return () => yjs.routeData.unobserve(observer);
|
||||
}, [yjs, isHost, requestRoute]);
|
||||
|
||||
return { isHost, computing, routeStats, requestRoute };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,47 +27,49 @@ export interface YjsState {
|
|||
|
||||
export function useYjs(sessionId: string): YjsState | null {
|
||||
const [state, setState] = useState<YjsState | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const initialized = useRef(false);
|
||||
const providerRef = useRef<WebsocketProvider | null>(null);
|
||||
const docRef = useRef<Y.Doc | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
|
||||
const doc = new Y.Doc();
|
||||
const wsUrl = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/sync/${sessionId}`;
|
||||
docRef.current = doc;
|
||||
|
||||
const wsUrl = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/sync`;
|
||||
const provider = new WebsocketProvider(wsUrl, sessionId, doc);
|
||||
providerRef.current = provider;
|
||||
|
||||
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
||||
const routeData = doc.getMap("routeData");
|
||||
|
||||
// Set local awareness state
|
||||
const color = randomItem(COLORS);
|
||||
const name = randomItem(NAMES);
|
||||
provider.awareness.setLocalStateField("user", { color, name });
|
||||
|
||||
const updateState = (connected: boolean) => {
|
||||
setState({
|
||||
doc,
|
||||
provider,
|
||||
waypoints,
|
||||
routeData,
|
||||
awareness: provider.awareness,
|
||||
connected,
|
||||
});
|
||||
};
|
||||
|
||||
provider.on("status", ({ status }: { status: string }) => {
|
||||
setConnected(status === "connected");
|
||||
updateState(status === "connected");
|
||||
});
|
||||
|
||||
setState({
|
||||
doc,
|
||||
provider,
|
||||
waypoints,
|
||||
routeData,
|
||||
awareness: provider.awareness,
|
||||
connected: false,
|
||||
});
|
||||
// Set initial state (even before connection)
|
||||
updateState(false);
|
||||
|
||||
return () => {
|
||||
provider.destroy();
|
||||
doc.destroy();
|
||||
providerRef.current = null;
|
||||
docRef.current = null;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
if (state) {
|
||||
state.connected = connected;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
|
|||
119
apps/planner/app/lib/vite-yjs-plugin.ts
Normal file
119
apps/planner/app/lib/vite-yjs-plugin.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import type { Plugin } from "vite";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import * as Y from "yjs";
|
||||
import * as syncProtocol from "y-protocols/sync";
|
||||
import * as awarenessProtocol from "y-protocols/awareness";
|
||||
import * as encoding from "lib0/encoding";
|
||||
import * as decoding from "lib0/decoding";
|
||||
|
||||
const messageSync = 0;
|
||||
const messageAwareness = 1;
|
||||
|
||||
/**
|
||||
* Vite plugin that runs a Yjs sync server in dev mode.
|
||||
* Implements the y-websocket protocol for full compatibility.
|
||||
*/
|
||||
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> }>();
|
||||
|
||||
function getDoc(docName: string): { doc: Y.Doc; awareness: awarenessProtocol.Awareness } {
|
||||
let doc = docs.get(docName);
|
||||
let awareness = awarenessMap.get(docName);
|
||||
if (!doc) {
|
||||
doc = new Y.Doc();
|
||||
docs.set(docName, doc);
|
||||
awareness = new awarenessProtocol.Awareness(doc);
|
||||
awarenessMap.set(docName, awareness);
|
||||
|
||||
awareness.on("update", ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => {
|
||||
const changedClients = added.concat(updated, removed);
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, messageAwareness);
|
||||
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness!, changedClients));
|
||||
const message = encoding.toUint8Array(encoder);
|
||||
|
||||
for (const [ws, meta] of conns) {
|
||||
if (meta.docName === docName) {
|
||||
ws.send(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return { doc, awareness: awareness! };
|
||||
}
|
||||
|
||||
function handleMessage(ws: WebSocket, message: Uint8Array, docName: string) {
|
||||
const { doc, awareness } = getDoc(docName);
|
||||
const decoder = decoding.createDecoder(message);
|
||||
const messageType = decoding.readVarUint(decoder);
|
||||
|
||||
switch (messageType) {
|
||||
case messageSync: {
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, messageSync);
|
||||
syncProtocol.readSyncMessage(decoder, encoder, doc, ws);
|
||||
const resp = encoding.toUint8Array(encoder);
|
||||
if (encoding.length(encoder) > 1) {
|
||||
ws.send(resp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case messageAwareness: {
|
||||
const update = decoding.readVarUint8Array(decoder);
|
||||
awarenessProtocol.applyAwarenessUpdate(awareness, update, ws);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "yjs-dev-websocket",
|
||||
configureServer(server) {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.httpServer?.on("upgrade", (request, socket, head) => {
|
||||
const url = new URL(request.url ?? "", `http://${request.headers.host}`);
|
||||
if (!url.pathname.startsWith("/sync/")) return;
|
||||
|
||||
const docName = url.pathname.slice("/sync/".length);
|
||||
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
const { doc, awareness } = getDoc(docName);
|
||||
conns.set(ws, { docName, awarenessIds: new Set() });
|
||||
|
||||
// Send sync step 1
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, messageSync);
|
||||
syncProtocol.writeSyncStep1(encoder, doc);
|
||||
ws.send(encoding.toUint8Array(encoder));
|
||||
|
||||
// Send awareness states
|
||||
const awarenessStates = awareness.getStates();
|
||||
if (awarenessStates.size > 0) {
|
||||
const awarenessEncoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(awarenessEncoder, messageAwareness);
|
||||
encoding.writeVarUint8Array(
|
||||
awarenessEncoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(awareness, Array.from(awarenessStates.keys())),
|
||||
);
|
||||
ws.send(encoding.toUint8Array(awarenessEncoder));
|
||||
}
|
||||
|
||||
ws.on("message", (data: ArrayBuffer) => {
|
||||
handleMessage(ws, new Uint8Array(data), docName);
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
const meta = conns.get(ws);
|
||||
if (meta) {
|
||||
awarenessProtocol.removeAwarenessStates(awareness, Array.from(meta.awarenessIds), null);
|
||||
}
|
||||
conns.delete(ws);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -21,10 +21,12 @@
|
|||
"@trails-cool/ui": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"isbot": "^5.1.0",
|
||||
"lib0": "^0.2.117",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "catalog:",
|
||||
"ws": "^8.20.0",
|
||||
"y-protocols": "^1.0.7",
|
||||
"y-websocket": "^3.0.0",
|
||||
"yjs": "^13.6.30"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { reactRouter } from "@react-router/dev/vite";
|
|||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import path from "node:path";
|
||||
import { yjsDevPlugin } from "./app/lib/vite-yjs-plugin";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter()],
|
||||
plugins: [tailwindcss(), reactRouter(), yjsDevPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": path.resolve(__dirname, "./app"),
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
- [x] 5.4 Implement routing host failover (detect disconnect, elect new host by join timestamp)
|
||||
- [x] 5.5 Implement route computation trigger (host watches waypoint changes, debounce 500ms, call BRouter)
|
||||
- [x] 5.6 Implement route broadcast (host stores GeoJSON result in Y.Map, syncs to all)
|
||||
- [ ] 5.7 Implement profile selection (sync profile choice via Y.Map, trigger recompute)
|
||||
- [x] 5.7 Implement profile selection (sync profile choice via Y.Map, trigger recompute)
|
||||
|
||||
## 6. Planner — Map UI
|
||||
|
||||
|
|
@ -57,9 +57,9 @@
|
|||
- [x] 6.4 Implement waypoint delete (right-click → remove from Y.Array)
|
||||
- [x] 6.5 Implement waypoint list sidebar (draggable reorder, synced with Y.Array)
|
||||
- [x] 6.6 Implement route polyline display (render GeoJSON from Y.Map)
|
||||
- [ ] 6.7 Implement elevation profile chart (parse elevation from route GeoJSON, render chart)
|
||||
- [ ] 6.8 Implement profile selector UI (dropdown, synced via Y.Map)
|
||||
- [ ] 6.9 Implement GPX export button (generate GPX from current waypoints and route)
|
||||
- [x] 6.7 Implement elevation profile chart (parse elevation from route GeoJSON, render chart)
|
||||
- [x] 6.8 Implement profile selector UI (dropdown, synced via Y.Map)
|
||||
- [x] 6.9 Implement GPX export button (generate GPX from current waypoints and route)
|
||||
|
||||
## 7. Journal — Auth
|
||||
|
||||
|
|
|
|||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
|
@ -255,6 +255,9 @@ importers:
|
|||
isbot:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.36
|
||||
lib0:
|
||||
specifier: ^0.2.117
|
||||
version: 0.2.117
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.4
|
||||
|
|
@ -267,6 +270,9 @@ importers:
|
|||
ws:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
y-protocols:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(yjs@13.6.30)
|
||||
y-websocket:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0(yjs@13.6.30)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue