trails/apps/planner/app/components/SessionView.tsx
Ullrich Schäfer 97ddf36621
fix(planner): elevation chart drag survives leaving the canvas; move reset-zoom
- Drag-to-zoom on the chart now tracks via window mousemove/mouseup, so
  it keeps going when the pointer leaves the chart and still completes if
  released outside it (previously mouseleave aborted the drag and an
  outside release was lost). Listeners are detached on mouseup/unmount.
- Move the "reset zoom" button from top-right (which overlapped the
  color-mode dropdown) to the bottom-right of the chart, and restyle it
  on tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:27:45 +02:00

314 lines
12 KiB
TypeScript

import { Suspense, lazy, useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import L from "leaflet";
import type { TFunction } from "i18next";
import * as Sentry from "@sentry/react";
import { useYjs, type YjsState } from "~/lib/use-yjs";
import { useRouting, type RouteError } from "~/lib/use-routing";
import { getCoordinates } from "~/lib/route-data";
import { useDays } from "~/lib/use-days";
import { useUndo, useUndoShortcuts } from "~/lib/use-undo";
import { ProfileSelector } from "~/components/ProfileSelector";
import { ExportButton } from "~/components/ExportButton";
import { SaveToJournalButton } from "~/components/SaveToJournalButton";
import { YjsDebugPanel } from "~/components/YjsDebugPanel";
import { Topbar } from "~/components/Topbar";
import { useParticipants } from "~/lib/use-participants";
import { NotesPanel } from "~/components/NotesPanel";
const PlannerMap = lazy(() =>
import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })),
);
const WaypointSidebar = lazy(() =>
import("~/components/WaypointSidebar").then((m) => ({ default: m.WaypointSidebar })),
);
const ElevationChart = lazy(() =>
import("~/components/ElevationChart").then((m) => ({ default: m.ElevationChart })),
);
interface Toast {
id: number;
message: string;
variant?: "error" | "info";
}
let toastIdCounter = 0;
function useToasts() {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((message: string, variant: Toast["variant"] = "info") => {
const id = ++toastIdCounter;
setToasts((prev) => [...prev, { id, message, variant }]);
setTimeout(() => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, 4000);
}, []);
return { toasts, addToast };
}
function useAwarenessToasts(yjs: YjsState | null, t: TFunction, addToast: (message: string) => void) {
const namesCacheRef = useRef<Map<number, string>>(new Map());
const initializedRef = useRef(false);
useEffect(() => {
if (!yjs) return;
const handleChange = (
changes: { added: number[]; updated: number[]; removed: number[] },
) => {
const states = yjs.awareness.getStates();
const localId = yjs.awareness.clientID;
// On first change, seed the cache with current participants and skip toasts
if (!initializedRef.current) {
states.forEach((state, clientId) => {
const user = (state as Record<string, unknown>).user as { name: string } | undefined;
if (user?.name) {
namesCacheRef.current.set(clientId, user.name);
}
});
initializedRef.current = true;
return;
}
// Update cache for any updated clients
for (const clientId of changes.updated) {
const state = states.get(clientId) as Record<string, unknown> | undefined;
const user = state?.user as { name: string } | undefined;
if (user?.name) {
namesCacheRef.current.set(clientId, user.name);
}
}
for (const clientId of changes.added) {
if (clientId === localId) continue;
const state = states.get(clientId) as Record<string, unknown> | undefined;
const user = state?.user as { name: string } | undefined;
const name = user?.name;
if (name) {
namesCacheRef.current.set(clientId, name);
addToast(t("participants.joined", { name }));
}
}
for (const clientId of changes.removed) {
if (clientId === localId) continue;
const name = namesCacheRef.current.get(clientId);
namesCacheRef.current.delete(clientId);
if (name) {
addToast(t("participants.left", { name }));
}
}
};
yjs.awareness.on("change", handleChange);
return () => {
yjs.awareness.off("change", handleChange);
initializedRef.current = false;
namesCacheRef.current.clear();
};
}, [yjs, t, addToast]);
}
function SidebarTabs({ yjs, routeStats, days, onWaypointHover, onWaypointSelect }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"]; days: ReturnType<typeof useDays>; onWaypointHover: (index: number | null) => void; onWaypointSelect: (index: number | null) => void }) {
const { t } = useTranslation("planner");
const [tab, setTab] = useState<"waypoints" | "notes">("waypoints");
return (
<aside className="hidden w-72 border-l border-border bg-bg-raised md:flex md:flex-col">
<div className="flex border-b border-border">
<button
onClick={() => setTab("waypoints")}
className={`flex-1 px-3 py-2 text-xs font-medium transition-colors ${tab === "waypoints" ? "border-b-2 border-accent text-accent" : "text-text-md hover:text-text-hi"}`}
>
{t("sidebar.waypoints", "Waypoints")}
</button>
<button
onClick={() => setTab("notes")}
className={`flex-1 px-3 py-2 text-xs font-medium transition-colors ${tab === "notes" ? "border-b-2 border-accent text-accent" : "text-text-md hover:text-text-hi"}`}
>
{t("sidebar.notes", "Notes")}
</button>
</div>
<div className="flex-1 overflow-hidden">
{tab === "waypoints" ? (
<Suspense fallback={null}>
<WaypointSidebar yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={onWaypointHover} onWaypointSelect={onWaypointSelect} />
</Suspense>
) : (
<NotesPanel yjs={yjs} />
)}
</div>
</aside>
);
}
interface SessionViewProps {
sessionId: string;
/**
* True when the session was created with a journal callback URL +
* token (i.e. the user came in from /journal/.../edit-in-planner).
* The actual URL + token live server-side; the browser only needs
* to know whether to render the Save-to-Journal button.
*/
hasJournalCallback?: boolean;
returnUrl?: string;
initialWaypoints?: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }>;
initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>;
initialNotes?: string;
}
export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialWaypoints, initialNoGoAreas, initialNotes }: SessionViewProps) {
const { t } = useTranslation("planner");
useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]);
const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas, initialNotes);
const { computing, routeError, routeStats, requestRoute } = useRouting(yjs, sessionId);
const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null);
useUndoShortcuts(yjs?.undoManager ?? null);
const { participants, renameLocal } = useParticipants(yjs);
const days = useDays(yjs);
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
const [highlightedWaypoint, setHighlightedWaypoint] = useState<number | null>(null);
const [selectedWaypointIndex, setSelectedWaypointIndex] = useState<number | null>(null);
const [highlightChartDistance, setHighlightChartDistance] = useState<number | null>(null);
const [isZoomedByChart, setIsZoomedByChart] = useState(false);
const { toasts, addToast } = useToasts();
useAwarenessToasts(yjs, t, addToast);
// Show toast on route calculation error
const prevErrorRef = useRef<RouteError>(null);
useEffect(() => {
if (routeError && routeError !== prevErrorRef.current) {
const message =
routeError === "rate_limit" ? t("rateLimitExceeded") :
routeError === "no_route" ? t("noRouteFound") :
t("routeError");
addToast(message, "error");
}
prevErrorRef.current = routeError;
}, [routeError, addToast, t]);
const handleElevationHover = useCallback((pos: [number, number] | null) => {
setHighlightPosition(pos);
}, []);
const handleChartClick = useCallback((position: [number, number]) => {
const map = window.__leafletMap;
map?.panTo(position);
}, []);
const handleChartDragSelect = useCallback((bounds: [[number, number], [number, number]]) => {
const map = window.__leafletMap;
map?.fitBounds(bounds, { padding: [30, 30] });
setIsZoomedByChart(true);
}, []);
const handleResetZoom = useCallback(() => {
const map = window.__leafletMap;
if (!map || !yjs) return;
const coords = getCoordinates(yjs.routeData);
if (!coords || coords.length < 2) return;
const latLngs = coords.map((c) => [c[1], c[0]] as [number, number]);
map.fitBounds(L.latLngBounds(latLngs), { padding: [50, 50] });
setIsZoomedByChart(false);
}, [yjs]);
if (!yjs) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-gray-500">{t("connecting")}</p>
</div>
);
}
return (
<>
<Topbar
participants={participants}
onRenameLocal={renameLocal}
connected={yjs.connected}
sessionShortId={sessionId.slice(0, 8)}
canUndo={canUndo}
canRedo={canRedo}
onUndo={undo}
onRedo={redo}
profileSlot={<ProfileSelector yjs={yjs} />}
actions={
<>
{hasJournalCallback && (
<SaveToJournalButton
yjs={yjs}
sessionId={sessionId}
returnUrl={returnUrl}
/>
)}
<ExportButton yjs={yjs} />
</>
}
/>
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 flex flex-col">
<div className="relative flex-1">
{computing && (
<>
{/* Ambient indeterminate progress along the top edge */}
<div className="absolute inset-x-0 top-0 z-[1000]">
<div className="h-0.5 w-full overflow-hidden bg-accent-bg">
<div className="h-full w-1/3 animate-[slide_1s_ease-in-out_infinite] bg-accent" />
</div>
</div>
{/* Floating status pill — overlays the map without shifting layout */}
<div className="pointer-events-none absolute left-1/2 top-3 z-[1000] flex -translate-x-1/2 items-center gap-2 rounded-full border border-border bg-bg-raised px-3 py-1 text-xs font-medium text-text-md shadow-md">
<span className="h-3 w-3 shrink-0 animate-spin rounded-full border-2 border-accent border-t-transparent" aria-hidden />
{t("computingRoute")}
</div>
</>
)}
<Suspense
fallback={
<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">
{t("loadingMap")}
</div>
}
>
<PlannerMap yjs={yjs} sessionId={sessionId} onRouteRequest={requestRoute} highlightPosition={highlightPosition} highlightedWaypoint={highlightedWaypoint} selectedWaypointIndex={selectedWaypointIndex} onRouteHover={setHighlightChartDistance} onImportError={(msg) => addToast(msg, "error")} days={days} />
</Suspense>
</div>
<Suspense fallback={null}>
<div className="relative">
<ElevationChart yjs={yjs} onHover={handleElevationHover} highlightDistance={highlightChartDistance} onClickPosition={handleChartClick} onDragSelect={handleChartDragSelect} days={days} />
{isZoomedByChart && (
<button
onClick={handleResetZoom}
className="absolute bottom-7 right-3 z-10 rounded-md bg-bg-raised px-2 py-0.5 text-[11px] font-medium text-text-md shadow-sm ring-1 ring-border transition-colors hover:bg-bg-subtle"
>
{t("elevation.resetZoom", "Reset zoom")}
</button>
)}
</div>
</Suspense>
</main>
<SidebarTabs yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={setHighlightedWaypoint} onWaypointSelect={setSelectedWaypointIndex} />
</div>
<YjsDebugPanel yjs={yjs} sessionId={sessionId} />
{toasts.length > 0 && (
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`rounded-lg px-4 py-2 text-sm text-white shadow-lg animate-[fadeIn_0.2s_ease-out] ${
toast.variant === "error" ? "bg-red-600" : "bg-gray-800"
}`}
>
{toast.message}
</div>
))}
</div>
)}
</>
);
}