Add per-waypoint notes and nearby POI snap to Planner

- `note` field on Waypoint type, stored in Yjs Y.Map and exported as
  `<desc>` inside `<wpt>` in GPX
- Inline textarea note editing in WaypointSidebar with auto-resize,
  character counter (500 max), save-on-blur, Escape cancel
- Note indicator dot on map markers; note tooltip on hover
- `useNearbyPois` hook: fetches POIs within 500m of selected waypoint
  via Overpass proxy, 500ms debounce, AbortController, 60s rate-limit
  suppression
- NearbyPoiMarkers component: renders POI markers on map for selected
  waypoint with snap-to-POI on click
- Nearby section in WaypointSidebar: list with snap buttons, spinner,
  empty/rate-limited states, "Show more" toggle (5 → all)
- Unit tests for fetchNearbyPois bbox geometry and snap-to-POI Yjs
  transaction behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-17 23:58:21 +02:00
parent bcf551cd27
commit c2abb64ee0
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
16 changed files with 665 additions and 58 deletions

View file

@ -0,0 +1,49 @@
import L from "leaflet";
import { Marker, Tooltip } from "react-leaflet";
import type { Poi } from "~/lib/overpass";
import type { PoiCategory } from "@trails-cool/map-core";
interface NearbyPoiMarkersProps {
pois: Poi[];
categories: PoiCategory[];
onSnap: (poi: Poi) => void;
}
function nearbyPoiIcon(color: string, icon: string): L.DivIcon {
return L.divIcon({
className: "",
html: `<div style="
width:18px;height:18px;border-radius:50%;
background:${color};color:white;
display:flex;align-items:center;justify-content:center;
font-size:10px;
border:1.5px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.25);
transform:translate(-9px,-9px);
">${icon}</div>`,
iconSize: [0, 0],
});
}
export function NearbyPoiMarkers({ pois, categories, onSnap }: NearbyPoiMarkersProps) {
return (
<>
{pois.map((poi) => {
const cat = categories.find((c) => c.id === poi.category);
if (!cat) return null;
return (
<Marker
key={poi.id}
position={[poi.lat, poi.lon]}
icon={nearbyPoiIcon(cat.color, cat.icon)}
eventHandlers={{ click: () => onSnap(poi) }}
zIndexOffset={900}
>
<Tooltip direction="top" offset={[0, -10]} opacity={0.95}>
<span>{poi.name ?? cat.icon} {cat.icon !== poi.name ? cat.icon : ""}</span>
</Tooltip>
</Marker>
);
})}
</>
);
}

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useRef } from "react";
import { MapContainer, TileLayer, LayersControl, Marker, Polyline } from "react-leaflet";
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, Tooltip } from "react-leaflet";
import L from "leaflet";
import { useTranslation } from "react-i18next";
import type { DayStage } from "@trails-cool/gpx";
@ -26,22 +26,30 @@ import {
} from "./MapHelpers";
import { useWaypointManager } from "~/lib/use-waypoint-manager";
import { useGpxDrop } from "~/lib/use-gpx-drop";
import { useNearbyPois } from "~/lib/use-nearby-pois";
import { NearbyPoiMarkers } from "./NearbyPoiMarkers";
import { poiCategories } from "@trails-cool/map-core";
import type { Poi } from "~/lib/overpass";
import "leaflet/dist/leaflet.css";
function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean): L.DivIcon {
function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean, hasNote?: boolean): L.DivIcon {
const bg = overnight ? "#8B6D3A" : "#2563eb";
const scale = highlighted ? "scale(1.17)" : "scale(1)";
const noteIndicator = hasNote
? `<span style="position:absolute;top:-3px;right:-3px;width:10px;height:10px;border-radius:50%;background:#f59e0b;border:1.5px solid white;font-size:7px;display:flex;align-items:center;justify-content:center;line-height:1;">✎</span>`
: "";
return L.divIcon({
className: "",
html: `<div style="
width:24px;height:24px;border-radius:50%;
background:${bg};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) ${scale};
transition:transform 0.2s ease;
">${overnight ? "" : index + 1}</div>`,
html: `<div style="position:relative;width:24px;height:24px;transform:translate(-12px,-12px) ${scale};transition:transform 0.2s ease;">
<div style="
width:24px;height:24px;border-radius:50%;
background:${bg};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);
">${overnight ? "" : index + 1}</div>
${noteIndicator}
</div>`,
iconSize: [0, 0],
});
}
@ -68,11 +76,12 @@ interface PlannerMapProps {
onImportError?: (message: string) => void;
highlightPosition?: [number, number] | null;
highlightedWaypoint?: number | null;
selectedWaypointIndex?: number | null;
onRouteHover?: (distance: number | null) => void;
days?: DayStage[];
}
export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, highlightedWaypoint, onRouteHover, onImportError, days }: PlannerMapProps) {
export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, highlightedWaypoint, selectedWaypointIndex, onRouteHover, onImportError, days }: PlannerMapProps) {
const { t } = useTranslation("planner");
const poiState = usePois(sessionId);
useProfileDefaults(yjs, poiState);
@ -107,6 +116,25 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition,
const { draggingOver, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } = useGpxDrop(yjs, onImportError);
const selectedWp = selectedWaypointIndex != null ? waypoints[selectedWaypointIndex] : undefined;
const nearbyPoisState = useNearbyPois(selectedWp?.lat, selectedWp?.lon);
const handleSnapToPoi = useCallback((poi: Poi) => {
if (selectedWaypointIndex == null) return;
const yMap = yjs.waypoints.get(selectedWaypointIndex);
if (!yMap) return;
const cat = poiCategories.find((c) => c.id === poi.category);
const notePrefix = cat ? `${cat.icon} ${poi.name ?? cat.id}` : (poi.name ?? "");
yjs.doc.transact(() => {
yMap.set("lat", poi.lat);
yMap.set("lon", poi.lon);
if (poi.name && !yMap.get("name")) yMap.set("name", poi.name);
const existing = yMap.get("note") as string | undefined;
yMap.set("note", existing ? `${notePrefix}\n${existing}` : notePrefix);
if (poi.id) yMap.set("osmId", poi.id);
}, "local");
}, [selectedWaypointIndex, yjs, poiCategories]);
const handleRoutePolylineOut = useCallback(() => {
onRouteHover?.(null);
}, [onRouteHover]);
@ -150,6 +178,13 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition,
<PoiRefresher poiState={poiState} />
<PoiMarkers poiState={poiState} onAddWaypoint={addWaypoint} />
<PoiPanel poiState={poiState} />
{selectedWaypointIndex != null && nearbyPoisState.pois.length > 0 && (
<NearbyPoiMarkers
pois={nearbyPoisState.pois}
categories={poiCategories}
onSnap={handleSnapToPoi}
/>
)}
{waypoints.map((wp, i) => (
<Marker
@ -157,7 +192,7 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition,
position={[wp.lat, wp.lon]}
draggable
zIndexOffset={highlightedWaypoint === i ? Z_WAYPOINT_HIGHLIGHTED : Z_WAYPOINT}
icon={waypointIcon(i, wp.overnight, highlightedWaypoint === i)}
icon={waypointIcon(i, wp.overnight, highlightedWaypoint === i, !!wp.note)}
eventHandlers={{
mouseover: () => { routeInteractionSuspendedRef.current = true; },
mouseout: () => {
@ -180,7 +215,15 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition,
setContextMenu({ x: orig.clientX, y: orig.clientY, index: i });
},
}}
/>
>
{wp.note && (
<Tooltip direction="top" offset={[0, -14]} opacity={0.95}>
<span style={{ maxWidth: 220, display: "block", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{wp.note.length > 120 ? wp.note.slice(0, 120) + "…" : wp.note}
</span>
</Tooltip>
)}
</Marker>
))}
{days && days.length > 1 && days.map((day) => {

View file

@ -113,7 +113,7 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction, addToast: (messa
}
function SidebarTabs({ yjs, routeStats, days, onWaypointHover }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"]; days: ReturnType<typeof useDays>; onWaypointHover: (index: number | null) => void }) {
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");
@ -136,7 +136,7 @@ function SidebarTabs({ yjs, routeStats, days, onWaypointHover }: { yjs: YjsState
<div className="flex-1 overflow-hidden">
{tab === "waypoints" ? (
<Suspense fallback={null}>
<WaypointSidebar yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={onWaypointHover} />
<WaypointSidebar yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={onWaypointHover} onWaypointSelect={onWaypointSelect} />
</Suspense>
) : (
<NotesPanel yjs={yjs} />
@ -166,6 +166,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
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();
@ -286,7 +287,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
</div>
}
>
<PlannerMap yjs={yjs} sessionId={sessionId} onRouteRequest={requestRoute} highlightPosition={highlightPosition} highlightedWaypoint={highlightedWaypoint} onRouteHover={setHighlightChartDistance} onImportError={(msg) => addToast(msg, "error")} days={days} />
<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}>
@ -303,7 +304,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
</div>
</Suspense>
</main>
<SidebarTabs yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={setHighlightedWaypoint} />
<SidebarTabs yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={setHighlightedWaypoint} onWaypointSelect={setSelectedWaypointIndex} />
</div>
<YjsDebugPanel yjs={yjs} sessionId={sessionId} />
{toasts.length > 0 && (

View file

@ -1,15 +1,21 @@
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } 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";
import { useNearbyPois } from "~/lib/use-nearby-pois";
import { poiCategories } from "@trails-cool/map-core";
import type { Poi } from "~/lib/overpass";
const NOTE_MAX = 500;
interface WaypointData {
lat: number;
lon: number;
name?: string;
note?: string;
overnight: boolean;
}
@ -18,6 +24,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[]
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
note: yMap.get("note") as string | undefined,
overnight: isOvernight(yMap),
}));
}
@ -31,11 +38,16 @@ interface WaypointSidebarProps {
};
days: DayStage[];
onWaypointHover?: (index: number | null) => void;
onWaypointSelect?: (index: number | null) => void;
}
export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover }: WaypointSidebarProps) {
export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover, onWaypointSelect }: WaypointSidebarProps) {
const { t } = useTranslation("planner");
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [editingNoteIndex, setEditingNoteIndex] = useState<number | null>(null);
const [noteEditValue, setNoteEditValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const update = () => setWaypoints(getWaypointsFromYjs(yjs.waypoints));
@ -60,7 +72,10 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover }: Wayp
lat: item.get("lat") as number,
lon: item.get("lon") as number,
name: item.get("name") as string | undefined,
note: item.get("note") as string | undefined,
overnight: isOvernight(item),
osmId: item.get("osmId") as number | undefined,
poiTags: item.get("poiTags") as Record<string, string> | undefined,
};
yjs.doc.transact(() => {
yjs.waypoints.delete(from, 1);
@ -68,7 +83,10 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover }: Wayp
yMap.set("lat", data.lat);
yMap.set("lon", data.lon);
if (data.name) yMap.set("name", data.name);
if (data.note) yMap.set("note", data.note);
if (data.overnight) yMap.set("overnight", true);
if (data.osmId !== undefined) yMap.set("osmId", data.osmId);
if (data.poiTags) yMap.set("poiTags", data.poiTags);
yjs.waypoints.insert(to, [yMap]);
}, "local");
},
@ -84,22 +102,125 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover }: Wayp
[yjs, waypoints],
);
const setNote = useCallback(
(index: number, note: string) => {
const yMap = yjs.waypoints.get(index);
if (!yMap) return;
yjs.doc.transact(() => {
if (note.trim()) {
yMap.set("note", note.trim());
} else {
yMap.delete("note");
}
}, "local");
},
[yjs],
);
const selectWaypoint = useCallback(
(index: number | null) => {
setSelectedIndex(index);
onWaypointSelect?.(index);
},
[onWaypointSelect],
);
const openNoteEditor = useCallback(
(index: number) => {
setNoteEditValue(waypoints[index]?.note ?? "");
setEditingNoteIndex(index);
setTimeout(() => {
textareaRef.current?.focus();
// auto-resize
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = textareaRef.current.scrollHeight + "px";
}
}, 0);
},
[waypoints],
);
const snapToPoi = useCallback(
(poi: Poi) => {
if (selectedIndex === null) return;
const yMap = yjs.waypoints.get(selectedIndex);
if (!yMap) return;
const cat = poiCategories.find((c) => c.id === poi.category);
const notePrefix = cat ? `${cat.icon} ${poi.name ?? cat.id}` : (poi.name ?? "");
yjs.doc.transact(() => {
yMap.set("lat", poi.lat);
yMap.set("lon", poi.lon);
if (poi.name && !yMap.get("name")) yMap.set("name", poi.name);
const existing = yMap.get("note") as string | undefined;
yMap.set("note", existing ? `${notePrefix}\n${existing}` : notePrefix);
if (poi.id) yMap.set("osmId", poi.id);
}, "local");
},
[selectedIndex, yjs, poiCategories],
);
const selectedWp = selectedIndex !== null ? waypoints[selectedIndex] : undefined;
const nearbyPoisState = useNearbyPois(selectedWp?.lat, selectedWp?.lon);
const [showAllNearby, setShowAllNearby] = useState(false);
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"
className={`group flex items-start gap-2 px-4 py-2 hover:bg-gray-50 cursor-pointer ${selectedIndex === i ? "bg-blue-50" : ""}`}
onClick={() => selectWaypoint(selectedIndex === i ? null : i)}
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">
<span className="mt-0.5 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>
{/* Note display / editor */}
{editingNoteIndex === i ? (
<div onClick={(e) => e.stopPropagation()}>
<textarea
ref={textareaRef}
value={noteEditValue}
maxLength={NOTE_MAX}
className="mt-1 w-full resize-none rounded border border-blue-300 bg-white px-2 py-1 text-xs italic text-gray-600 focus:outline-none focus:ring-1 focus:ring-blue-400"
rows={2}
onChange={(e) => {
setNoteEditValue(e.target.value);
e.target.style.height = "auto";
e.target.style.height = e.target.scrollHeight + "px";
}}
onBlur={() => {
setNote(i, noteEditValue);
setEditingNoteIndex(null);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setEditingNoteIndex(null);
setNoteEditValue("");
}
}}
/>
<p className="mt-0.5 text-right text-[10px] text-gray-400">
{noteEditValue.length} / {NOTE_MAX}
</p>
</div>
) : (
<p
className="mt-0.5 line-clamp-2 cursor-text text-xs italic text-gray-400 hover:text-gray-600"
onClick={(e) => {
e.stopPropagation();
openNoteEditor(i);
}}
>
{wp.note ?? t("waypoint.notePlaceholder")}
</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">
@ -183,6 +304,53 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover }: Wayp
)}
</div>
{/* Nearby POIs for selected waypoint */}
{selectedIndex !== null && (
<div className="border-t border-gray-200 px-4 py-3">
<p className="mb-2 text-xs font-medium text-gray-700">{t("nearbyPoi.heading")}</p>
{nearbyPoisState.status === "loading" && (
<p className="text-xs text-gray-400">{t("nearbyPoi.loading")}</p>
)}
{nearbyPoisState.status === "rate_limited" && (
<p className="text-xs text-gray-400">{t("nearbyPoi.rateLimited")}</p>
)}
{(nearbyPoisState.status === "done" || nearbyPoisState.status === "error") && nearbyPoisState.pois.length === 0 && (
<p className="text-xs text-gray-400">{t("nearbyPoi.empty")}</p>
)}
{nearbyPoisState.pois.length > 0 && (
<>
<ul className="space-y-1">
{(showAllNearby ? nearbyPoisState.pois : nearbyPoisState.pois.slice(0, 5)).map((poi) => {
const cat = poiCategories.find((c) => c.id === poi.category);
return (
<li key={poi.id} className="flex items-center gap-2">
<span className="shrink-0 text-sm">{cat?.icon ?? "📍"}</span>
<span className="min-w-0 flex-1 truncate text-xs text-gray-700">
{poi.name ?? cat?.id ?? poi.category}
</span>
<button
onClick={() => snapToPoi(poi)}
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 hover:bg-blue-50"
>
{t("nearbyPoi.snap")}
</button>
</li>
);
})}
</ul>
{nearbyPoisState.pois.length > 5 && !showAllNearby && (
<button
onClick={() => setShowAllNearby(true)}
className="mt-1 text-[11px] text-blue-500 hover:underline"
>
{t("nearbyPoi.showMore")}
</button>
)}
</>
)}
</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">