trails/apps/planner/app/components/WaypointSidebar.tsx
Ullrich Schäfer 705252a1c6
Highlight waypoint marker on sidebar hover instead of red dot
Replace position-based CircleMarker highlight with index-based marker
highlighting. Hovered waypoint marker grows from 24px to 30px with an
outline ring in its color. Uses CSS transition for smooth animation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:35:42 +02:00

217 lines
7.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useCallback } from "react";
import * as Y from "yjs";
import { useTranslation } from "react-i18next";
import type { YjsState } from "~/lib/use-yjs";
import type { DayStage } from "@trails-cool/gpx";
import { setOvernight, isOvernight } from "~/lib/overnight";
import { DayBreakdown } from "./DayBreakdown";
interface WaypointData {
lat: number;
lon: number;
name?: string;
overnight: boolean;
}
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,
name: yMap.get("name") as string | undefined,
overnight: isOvernight(yMap),
}));
}
interface WaypointSidebarProps {
yjs: YjsState;
routeStats?: {
distance?: number;
elevationGain?: number;
elevationLoss?: number;
};
days: DayStage[];
onWaypointHover?: (index: number | null) => void;
}
export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover }: WaypointSidebarProps) {
const { t } = useTranslation("planner");
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
useEffect(() => {
const update = () => setWaypoints(getWaypointsFromYjs(yjs.waypoints));
yjs.waypoints.observeDeep(update);
update();
return () => yjs.waypoints.unobserveDeep(update);
}, [yjs.waypoints]);
const deleteWaypoint = useCallback(
(index: number) => {
yjs.doc.transact(() => yjs.waypoints.delete(index, 1), "local");
},
[yjs.doc, yjs.waypoints],
);
const moveWaypoint = useCallback(
(from: number, to: number) => {
if (from === to || from < 0 || to < 0) return;
const item = yjs.waypoints.get(from);
if (!item) return;
const data = {
lat: item.get("lat") as number,
lon: item.get("lon") as number,
name: item.get("name") as string | undefined,
overnight: isOvernight(item),
};
yjs.doc.transact(() => {
yjs.waypoints.delete(from, 1);
const yMap = new Y.Map();
yMap.set("lat", data.lat);
yMap.set("lon", data.lon);
if (data.name) yMap.set("name", data.name);
if (data.overnight) yMap.set("overnight", true);
yjs.waypoints.insert(to, [yMap]);
}, "local");
},
[yjs.waypoints, yjs.doc],
);
const toggleOvernight = useCallback(
(index: number) => {
const wp = waypoints[index];
if (!wp) return;
setOvernight(yjs, index, !wp.overnight);
},
[yjs, waypoints],
);
const hasMultipleDays = days.length > 1;
const renderWaypointRow = (wp: WaypointData, i: number) => (
<li
key={i}
className="group flex items-center gap-2 px-4 py-2 hover:bg-gray-50"
onMouseEnter={() => onWaypointHover?.(i)}
onMouseLeave={() => onWaypointHover?.(null)}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-700">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-700">
{wp.name ?? `${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}`}
</p>
</div>
{wp.overnight && (
<span className="shrink-0 rounded px-1 py-0.5 text-[10px] font-medium text-amber-700 bg-amber-50 border border-amber-200">
{t("multiDay.overnight")}
</span>
)}
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
{/* Overnight toggle — not on first or last waypoint */}
{i > 0 && i < waypoints.length - 1 && (
<button
onClick={() => toggleOvernight(i)}
className={`rounded p-1 ${wp.overnight ? "text-amber-600 hover:bg-amber-100" : "text-gray-400 hover:bg-gray-200 hover:text-gray-600"}`}
title={wp.overnight ? t("multiDay.removeOvernight") : t("multiDay.markOvernight")}
>
</button>
)}
{i > 0 && (
<button
onClick={() => moveWaypoint(i, i - 1)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Move up"
>
</button>
)}
{i < waypoints.length - 1 && (
<button
onClick={() => moveWaypoint(i, i + 1)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Move down"
>
</button>
)}
<button
onClick={() => deleteWaypoint(i)}
className="rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600"
title="Delete"
>
×
</button>
</div>
</li>
);
return (
<div className="flex h-full flex-col">
{/* Header with route summary */}
<div className="border-b border-gray-200 px-4 py-3">
<h2 className="text-sm font-medium text-gray-900">
{t("sidebar.waypoints")} ({waypoints.length})
</h2>
{routeStats && routeStats.distance !== undefined && (
<p className="mt-0.5 text-xs text-gray-500">
{(routeStats.distance / 1000).toFixed(1)} km
{routeStats.elevationGain !== undefined && ` · ↑${routeStats.elevationGain} m`}
{hasMultipleDays && ` · ${t("multiDay.dayCount", { count: days.length })}`}
</p>
)}
</div>
{/* Waypoint list */}
<div className="flex-1 overflow-y-auto">
{waypoints.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-gray-500">
Click on the map to add waypoints
</p>
) : hasMultipleDays ? (
<DayBreakdown days={days}>
{(_day, { start, end }) => (
<ul className="divide-y divide-gray-100">
{waypoints.slice(start, end + 1).map((wp, offset) => renderWaypointRow(wp, start + offset))}
</ul>
)}
</DayBreakdown>
) : (
<ul className="divide-y divide-gray-100">
{waypoints.map((wp, i) => renderWaypointRow(wp, i))}
</ul>
)}
</div>
{/* Stats footer — only shown for single-day view (multi-day shows per-day stats inline) */}
{!hasMultipleDays && routeStats && routeStats.distance !== undefined && (
<div className="border-t border-gray-200 px-4 py-3">
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div>
<p className="font-medium text-gray-900">
{(routeStats.distance / 1000).toFixed(1)} km
</p>
<p className="text-gray-500">{t("multiDay.distance")}</p>
</div>
{routeStats.elevationGain !== undefined && (
<div>
<p className="font-medium text-gray-900">
{routeStats.elevationGain} m
</p>
<p className="text-gray-500">{t("multiDay.ascent")}</p>
</div>
)}
{routeStats.elevationLoss !== undefined && (
<div>
<p className="font-medium text-gray-900">
{routeStats.elevationLoss} m
</p>
<p className="text-gray-500">{t("multiDay.descent")}</p>
</div>
)}
</div>
</div>
)}
</div>
);
}