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:
parent
bcf551cd27
commit
c2abb64ee0
16 changed files with 665 additions and 58 deletions
49
apps/planner/app/components/NearbyPoiMarkers.tsx
Normal file
49
apps/planner/app/components/NearbyPoiMarkers.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildQuery, parseResponse, deduplicateById, quantizeBbox, type Poi } from "./overpass.ts";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { buildQuery, parseResponse, deduplicateById, quantizeBbox, fetchNearbyPois, type Poi } from "./overpass.ts";
|
||||
import { poiCategories } from "@trails-cool/map-core";
|
||||
|
||||
describe("buildQuery", () => {
|
||||
|
|
@ -141,3 +141,59 @@ describe("deduplicateById", () => {
|
|||
expect(result[1]!.id).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchNearbyPois", () => {
|
||||
const emptyOverpassResponse = { elements: [] };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify(emptyOverpassResponse),
|
||||
json: async () => emptyOverpassResponse,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("constructs a bbox from lat/lon/radius and calls fetch", async () => {
|
||||
const categories = poiCategories.filter((c) => c.id === "drinking_water");
|
||||
await fetchNearbyPois(50.0, 10.0, 500, categories);
|
||||
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
const rawBody = (((fetch as ReturnType<typeof vi.fn>).mock.calls[0] as unknown[])[1]) as unknown as { body: string };
|
||||
const body = decodeURIComponent(rawBody.body.replace("data=", ""));
|
||||
// bbox quantized to 0.01° grid; 500m at lat=50 well within one cell
|
||||
expect(body).toMatch(/\[bbox:4\d\.\d+,\d+\.\d+,50\.\d+,10\.\d+\]/);
|
||||
});
|
||||
|
||||
it("bbox is symmetric around the input point", async () => {
|
||||
const lat = 48.0;
|
||||
const lon = 11.0;
|
||||
const radius = 1000;
|
||||
const categories = poiCategories.filter((c) => c.id === "drinking_water");
|
||||
await fetchNearbyPois(lat, lon, radius, categories);
|
||||
|
||||
const rawBody = (((fetch as ReturnType<typeof vi.fn>).mock.calls[0] as unknown[])[1]) as unknown as { body: string };
|
||||
const body = decodeURIComponent(rawBody.body.replace("data=", ""));
|
||||
const match = body.match(/\[bbox:([\d.]+),([\d.]+),([\d.]+),([\d.]+)\]/);
|
||||
expect(match).not.toBeNull();
|
||||
const [, south, west, north, east] = (match as RegExpMatchArray).map(Number);
|
||||
// After quantization the center might not be exactly lat/lon, but the extent should be ≥ radius
|
||||
const DEG_PER_METER_LAT = 1 / 111320;
|
||||
const dLat = radius * DEG_PER_METER_LAT;
|
||||
expect((north as number) - (south as number)).toBeGreaterThanOrEqual(dLat * 2 - 0.02);
|
||||
expect((east as number) - (west as number)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("forwards AbortSignal to fetch", async () => {
|
||||
const controller = new AbortController();
|
||||
const categories = poiCategories.filter((c) => c.id === "drinking_water");
|
||||
await fetchNearbyPois(50.0, 10.0, 500, categories, controller.signal);
|
||||
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
const callOptions = (((fetch as ReturnType<typeof vi.fn>).mock.calls[0] as unknown[])[1]) as unknown as { signal: AbortSignal };
|
||||
expect(callOptions?.signal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -183,3 +183,29 @@ export class OverpassRateLimitError extends Error {
|
|||
this.name = "OverpassRateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
// Degrees of latitude per meter (approximate, valid globally).
|
||||
const DEG_PER_METER_LAT = 1 / 111320;
|
||||
|
||||
/**
|
||||
* Fetch POIs within `radiusMeters` of a coordinate.
|
||||
* Builds a bbox around the point and reuses queryPois + the existing proxy.
|
||||
*/
|
||||
export async function fetchNearbyPois(
|
||||
lat: number,
|
||||
lon: number,
|
||||
radiusMeters: number,
|
||||
categories: import("@trails-cool/map-core").PoiCategory[],
|
||||
signal?: AbortSignal,
|
||||
sessionId = "nearby",
|
||||
): Promise<Poi[]> {
|
||||
const dLat = radiusMeters * DEG_PER_METER_LAT;
|
||||
const dLon = dLat / Math.cos((lat * Math.PI) / 180);
|
||||
const bbox: BBox = {
|
||||
south: lat - dLat,
|
||||
north: lat + dLat,
|
||||
west: lon - dLon,
|
||||
east: lon + dLon,
|
||||
};
|
||||
return queryPois(bbox, categories, sessionId, signal);
|
||||
}
|
||||
|
|
|
|||
123
apps/planner/app/lib/snap-to-poi.test.ts
Normal file
123
apps/planner/app/lib/snap-to-poi.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import * as Y from "yjs";
|
||||
import { poiCategories } from "@trails-cool/map-core";
|
||||
import type { Poi } from "./overpass.ts";
|
||||
|
||||
// Mirrors the snapToPoi logic from WaypointSidebar / PlannerMap
|
||||
function snapToPoi(
|
||||
doc: Y.Doc,
|
||||
waypoints: Y.Array<Y.Map<unknown>>,
|
||||
index: number,
|
||||
poi: Poi,
|
||||
) {
|
||||
const yMap = waypoints.get(index);
|
||||
if (!yMap) return;
|
||||
const cat = poiCategories.find((c) => c.id === poi.category);
|
||||
const notePrefix = cat ? `${cat.icon} ${poi.name ?? cat.id}` : (poi.name ?? "");
|
||||
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");
|
||||
}
|
||||
|
||||
function makeWaypoint(doc: Y.Doc, lat: number, lon: number, extra?: Record<string, unknown>): Y.Map<unknown> {
|
||||
const yMap = new Y.Map<unknown>();
|
||||
yMap.set("lat", lat);
|
||||
yMap.set("lon", lon);
|
||||
if (extra) {
|
||||
for (const [k, v] of Object.entries(extra)) yMap.set(k, v);
|
||||
}
|
||||
return yMap;
|
||||
}
|
||||
|
||||
describe("snapToPoi", () => {
|
||||
it("updates coords to POI position", () => {
|
||||
const doc = new Y.Doc();
|
||||
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
||||
waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0)]);
|
||||
|
||||
const poi: Poi = { id: 42, lat: 50.1, lon: 10.1, category: "drinking_water", tags: {} };
|
||||
snapToPoi(doc, waypoints, 0, poi);
|
||||
|
||||
expect(waypoints.get(0)!.get("lat")).toBe(50.1);
|
||||
expect(waypoints.get(0)!.get("lon")).toBe(10.1);
|
||||
});
|
||||
|
||||
it("sets osmId from POI", () => {
|
||||
const doc = new Y.Doc();
|
||||
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
||||
waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0)]);
|
||||
|
||||
const poi: Poi = { id: 99, lat: 50.1, lon: 10.1, category: "shelter", tags: {} };
|
||||
snapToPoi(doc, waypoints, 0, poi);
|
||||
|
||||
expect(waypoints.get(0)!.get("osmId")).toBe(99);
|
||||
});
|
||||
|
||||
it("sets name from POI when waypoint has no name", () => {
|
||||
const doc = new Y.Doc();
|
||||
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
||||
waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0)]);
|
||||
|
||||
const poi: Poi = { id: 1, lat: 50.1, lon: 10.1, category: "drinking_water", name: "Stadtbrunnen", tags: {} };
|
||||
snapToPoi(doc, waypoints, 0, poi);
|
||||
|
||||
expect(waypoints.get(0)!.get("name")).toBe("Stadtbrunnen");
|
||||
});
|
||||
|
||||
it("does not overwrite an existing name", () => {
|
||||
const doc = new Y.Doc();
|
||||
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
||||
waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0, { name: "My Stop" })]);
|
||||
|
||||
const poi: Poi = { id: 1, lat: 50.1, lon: 10.1, category: "drinking_water", name: "Brunnen", tags: {} };
|
||||
snapToPoi(doc, waypoints, 0, poi);
|
||||
|
||||
expect(waypoints.get(0)!.get("name")).toBe("My Stop");
|
||||
});
|
||||
|
||||
it("prepends icon+name prefix to note", () => {
|
||||
const doc = new Y.Doc();
|
||||
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
||||
waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0)]);
|
||||
|
||||
const poi: Poi = { id: 1, lat: 50.1, lon: 10.1, category: "drinking_water", name: "Brunnen", tags: {} };
|
||||
snapToPoi(doc, waypoints, 0, poi);
|
||||
|
||||
const note = waypoints.get(0)!.get("note") as string;
|
||||
expect(note).toContain("Brunnen");
|
||||
const cat = poiCategories.find((c) => c.id === "drinking_water")!;
|
||||
expect(note).toContain(cat.icon);
|
||||
});
|
||||
|
||||
it("preserves existing note content after prefix", () => {
|
||||
const doc = new Y.Doc();
|
||||
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
||||
waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0, { note: "Refill here" })]);
|
||||
|
||||
const poi: Poi = { id: 1, lat: 50.1, lon: 10.1, category: "drinking_water", name: "Brunnen", tags: {} };
|
||||
snapToPoi(doc, waypoints, 0, poi);
|
||||
|
||||
const note = waypoints.get(0)!.get("note") as string;
|
||||
expect(note).toContain("Refill here");
|
||||
expect(note.indexOf("Brunnen")).toBeLessThan(note.indexOf("Refill here"));
|
||||
});
|
||||
|
||||
it("performs all changes in a single Yjs transaction", () => {
|
||||
const doc = new Y.Doc();
|
||||
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
||||
waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0)]);
|
||||
|
||||
let txCount = 0;
|
||||
doc.on("afterTransaction", () => { txCount++; });
|
||||
|
||||
const poi: Poi = { id: 1, lat: 50.1, lon: 10.1, category: "drinking_water", name: "Brunnen", tags: {} };
|
||||
snapToPoi(doc, waypoints, 0, poi);
|
||||
|
||||
expect(txCount).toBe(1);
|
||||
});
|
||||
});
|
||||
74
apps/planner/app/lib/use-nearby-pois.ts
Normal file
74
apps/planner/app/lib/use-nearby-pois.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { fetchNearbyPois, OverpassRateLimitError, type Poi } from "./overpass";
|
||||
import { poiCategories } from "@trails-cool/map-core";
|
||||
|
||||
const NEARBY_RADIUS_METERS = 500;
|
||||
const DEBOUNCE_MS = 500;
|
||||
const TIMEOUT_MS = 10_000;
|
||||
const RATE_LIMIT_SUPPRESS_MS = 60_000;
|
||||
|
||||
export type NearbyPoisStatus = "idle" | "loading" | "done" | "rate_limited" | "error";
|
||||
|
||||
export interface NearbyPoisState {
|
||||
pois: Poi[];
|
||||
status: NearbyPoisStatus;
|
||||
}
|
||||
|
||||
const rateLimitedUntilRef = { current: 0 };
|
||||
|
||||
export function useNearbyPois(
|
||||
lat: number | undefined,
|
||||
lon: number | undefined,
|
||||
): NearbyPoisState {
|
||||
const [state, setState] = useState<NearbyPoisState>({ pois: [], status: "idle" });
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lat === undefined || lon === undefined) {
|
||||
setState({ pois: [], status: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous debounce
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
abortRef.current?.abort();
|
||||
|
||||
timerRef.current = setTimeout(async () => {
|
||||
if (Date.now() < rateLimitedUntilRef.current) {
|
||||
setState({ pois: [], status: "rate_limited" });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
setState((prev) => ({ ...prev, status: "loading" }));
|
||||
|
||||
try {
|
||||
const pois = await fetchNearbyPois(lat, lon, NEARBY_RADIUS_METERS, poiCategories, controller.signal);
|
||||
if (!controller.signal.aborted) {
|
||||
setState({ pois, status: "done" });
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof OverpassRateLimitError) {
|
||||
rateLimitedUntilRef.current = Date.now() + RATE_LIMIT_SUPPRESS_MS;
|
||||
setState({ pois: [], status: "rate_limited" });
|
||||
} else {
|
||||
setState({ pois: [], status: "error" });
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [lat, lon]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ export interface WaypointData {
|
|||
lat: number;
|
||||
lon: number;
|
||||
name?: string;
|
||||
note?: string;
|
||||
overnight: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -19,6 +20,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),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,64 +1,65 @@
|
|||
## 1. Data Model
|
||||
|
||||
- [ ] 1.1 Add optional `note?: string` field to `Waypoint` interface in `packages/types/src/index.ts`
|
||||
- [ ] 1.2 Update `WaypointData` interface and `getWaypointsFromYjs` in `apps/planner/app/components/WaypointSidebar.tsx` to read `note` from Y.Map
|
||||
- [ ] 1.3 Update `moveWaypoint` in WaypointSidebar to preserve the `note` field when reconstructing the Y.Map
|
||||
- [x] 1.1 Add optional `note?: string` field to `Waypoint` interface in `packages/types/src/index.ts`
|
||||
- [x] 1.2 Read `note` from Y.Map in `WaypointSidebar.tsx` (add to `WaypointData` interface and the mapping loop)
|
||||
- [x] 1.3 Preserve `note` when reconstructing the Y.Map in `moveWaypoint` (WaypointSidebar)
|
||||
|
||||
## 2. Sidebar Note Display
|
||||
|
||||
- [ ] 2.1 Add note display below waypoint name in sidebar list item (italic, muted text, truncated to 2 lines)
|
||||
- [ ] 2.2 Show "Add a note..." placeholder when note is empty (i18n key: `planner.waypoint.notePlaceholder`)
|
||||
- [ ] 2.3 Add inline `<textarea>` editing: click to edit, auto-focus, auto-resize, save on blur, cancel on Escape
|
||||
- [ ] 2.4 Add character counter (e.g., "127 / 500") visible during editing
|
||||
- [x] 2.1 Add note display below waypoint name in sidebar list item (italic, muted text, truncated to 2 lines)
|
||||
- [x] 2.2 Show "Add a note..." placeholder when note is empty (i18n key: `planner.waypoint.notePlaceholder`)
|
||||
- [x] 2.3 Add inline `<textarea>` editing: click to edit, auto-focus, auto-resize, save on blur, cancel on Escape
|
||||
- [x] 2.4 Add character counter (e.g., "127 / 500") visible during editing
|
||||
|
||||
## 3. Map Markers
|
||||
|
||||
- [ ] 3.1 Add note indicator icon on waypoint markers that have a non-empty note
|
||||
- [ ] 3.2 Show note text in Leaflet tooltip on hover/tap for markers with notes
|
||||
- [x] 3.1 Add note indicator icon on waypoint markers that have a non-empty note
|
||||
- [x] 3.2 Show note text in Leaflet tooltip on hover/tap for markers with notes
|
||||
|
||||
## 4. GPX Export & Import
|
||||
|
||||
- [ ] 4.1 Update `generateGpx` in `packages/gpx/src/generate.ts` to emit `<desc>` element when waypoint has a note
|
||||
- [ ] 4.2 Update `parseGpx` in `packages/gpx/src/parse.ts` to read `<desc>` into waypoint `note` field
|
||||
- [x] 4.1 Emit `<desc>` on `<wpt>` in `generateGpx` when waypoint has a note (note: route-level `<desc>` already exists in `<metadata>`, this is per-waypoint)
|
||||
- [x] 4.2 Read `<desc>` inside `<wpt>` into waypoint `note` field in `parseWaypoints` (note: `metadata > desc` is already parsed separately)
|
||||
|
||||
## 5. i18n
|
||||
|
||||
- [ ] 5.1 Add translation keys for note UI strings (en + de): placeholder, character counter label, tooltip "more" link
|
||||
- [ ] 5.2 Add translation keys for POI types, snap action labels, and POI status messages (en + de)
|
||||
- [x] 5.1 Add translation keys for note UI strings (en + de): `planner.waypoint.notePlaceholder`, character counter label, Escape cancel hint
|
||||
- [x] 5.2 Add translation keys for POI snap action labels and nearby POI status messages (en + de) — POI type labels already exist in `@trails-cool/map-core`
|
||||
|
||||
## 6. Testing (Notes)
|
||||
|
||||
- [ ] 6.1 Unit tests: GPX generation with notes (`<desc>` output), GPX parsing with `<desc>`, character counter logic
|
||||
- [ ] 6.2 E2E test: add a waypoint, type a note in sidebar, verify note persists after page interaction, verify GPX export contains note
|
||||
- [x] 6.1 Unit tests: per-waypoint `<desc>` in GPX generation; `<desc>` inside `<wpt>` parsed into `note`; verify no collision with `metadata > desc`
|
||||
- [ ] 6.2 E2E test: type a note in sidebar, blur, verify note persists; verify GPX export contains `<desc>` inside `<wpt>`
|
||||
|
||||
## 7. POI Data Layer
|
||||
## 7. POI Data Layer (per-waypoint)
|
||||
|
||||
- [ ] 7.1 Create Overpass API client in `apps/planner/app/lib/overpass.ts`: `fetchNearbyPOIs(lat, lon, radius)` function that builds an Overpass QL query, fetches from `https://overpass-api.de/api/interpreter`, and parses the JSON response into a typed `POI[]` array
|
||||
- [ ] 7.2 Define `POI` interface and `POI_TYPES` constant: category (water, shelter, camping, food, viewpoint, bicycle), OSM tag mapping, icon, i18n label key. Place in `apps/planner/app/lib/poi-types.ts`
|
||||
- [ ] 7.3 Build Overpass query per routing profile: filter `POI_TYPES` by the active profile (e.g., bicycle categories only for trekking/fastbike profiles) and generate the corresponding Overpass QL
|
||||
- [ ] 7.4 Implement POI cache in `apps/planner/app/lib/poi-cache.ts`: `Map<string, { data: POI[]; timestamp: number }>` keyed by quantized bounding box tile, 1-hour TTL, max 50 entries, expired-on-access eviction
|
||||
- [x] 7.1 Overpass client (`overpass.ts`), proxy endpoint, `buildQuery`, `parseResponse` — already shipped
|
||||
- [x] 7.2 `Poi` interface and category definitions — in `overpass.ts` + `@trails-cool/map-core`
|
||||
- [x] 7.3 Overpass query building per routing profile — already in `buildQuery`
|
||||
- [x] 7.4 POI cache (`poi-cache.ts`) with quantized bbox, TTL, eviction — already shipped
|
||||
- [x] 7.5 Add `fetchNearbyPois(lat, lon, radiusMeters, categories)` to `overpass.ts`: constructs a ~500m bbox from a single coordinate and reuses `buildQuery` + the existing `/api/overpass` proxy
|
||||
|
||||
## 8. POI Map Display
|
||||
|
||||
- [ ] 8.1 Add `selectedWaypointIndex` state to PlannerMap (set when a waypoint marker is clicked or selected in sidebar)
|
||||
- [ ] 8.2 Create `POIMarkers` component: receives `POI[]` and renders small circle markers colored by category, with tooltip showing POI name and type
|
||||
- [ ] 8.3 Add click handler on POI markers to trigger snap (calls snap handler that updates waypoint Y.Map in a single transaction)
|
||||
- [x] 8.1 Add `selectedWaypointIndex` state lifted to `SessionView` (or passed via callback from `WaypointSidebar` → `PlannerMap`)
|
||||
- [x] 8.2 Create `NearbyPoiMarkers` component: receives `Poi[]` for the selected waypoint + renders small circle markers by category with name tooltip — distinct from the overlay `PoiLayer` markers
|
||||
- [x] 8.3 Wire click on `NearbyPoiMarkers` to call snap handler
|
||||
|
||||
## 9. POI Sidebar
|
||||
|
||||
- [ ] 9.1 Add "Nearby" section below note area in WaypointSidebar for the selected waypoint: grouped by category, sorted by distance, max 15 items with "Show more" toggle
|
||||
- [ ] 9.2 Add snap button per POI item: clicking snaps waypoint to POI coordinates, sets name, prepends note prefix (icon + type label), all in one Yjs transaction
|
||||
- [ ] 9.3 Show POI loading state (spinner) and empty state ("No nearby POIs found") with appropriate i18n strings
|
||||
- [x] 9.1 Add "Nearby" section in `WaypointSidebar` for the selected waypoint: call `fetchNearbyPois`, show spinner / empty state, list POIs sorted by distance (max 15, "Show more" toggle)
|
||||
- [x] 9.2 Snap button per POI: move waypoint coords, set name from POI (if waypoint unnamed), prepend `"<icon> <type>"` to note — all in one Yjs transaction via `use-waypoint-manager`
|
||||
- [x] 9.3 Debounce POI fetch 500ms after waypoint selection; cancel in-flight fetch via `AbortController` when selection changes
|
||||
|
||||
## 10. POI Rate Limiting & Error Handling
|
||||
|
||||
- [ ] 10.1 Implement debounce (500ms) on waypoint selection before triggering Overpass query; abort in-flight requests via `AbortController` when selection changes
|
||||
- [ ] 10.2 Handle HTTP 429 from Overpass: disable POI queries for 60 seconds, show subtle "POI lookup unavailable" message
|
||||
- [ ] 10.3 Handle network errors and timeouts (10s) gracefully: fail silently, show empty POI list, no error modals
|
||||
- [x] 10.1 Handle HTTP 429 from nearby-POI fetch: suppress for 60s, show subtle "POI lookup unavailable" notice in the Nearby section
|
||||
- [x] 10.2 Handle network errors and 10s timeout: fail silently, show empty Nearby list — no error modals
|
||||
|
||||
## 11. Testing (POI)
|
||||
|
||||
- [ ] 11.1 Unit tests for Overpass client: query construction, response parsing, error handling (mock `fetch`)
|
||||
- [ ] 11.2 Unit tests for POI cache: cache hit, cache miss, TTL expiry, eviction at max entries
|
||||
- [ ] 11.3 Unit tests for snap behavior: waypoint coordinates updated, name set from POI, note prefix prepended, existing note preserved
|
||||
- [ ] 11.4 E2E test: select a waypoint, verify POI list appears in sidebar (mock Overpass response), click snap, verify waypoint moved and note updated
|
||||
- [x] 11.1 Overpass client unit tests (query construction, response parsing) — `overpass.test.ts` already covers this
|
||||
- [x] 11.2 POI cache unit tests — `poi-cache.test.ts` already covers this
|
||||
- [x] 11.3 Unit tests for `fetchNearbyPois`: correct bbox from lat/lon/radius, category filtering
|
||||
- [x] 11.4 Unit tests for snap: coords updated, name set, note prefix prepended, existing note preserved, all in one Yjs transaction
|
||||
- [ ] 11.5 E2E test: mock Overpass via route handler, select a waypoint, verify Nearby list appears, click snap, verify waypoint moved and note prefixed
|
||||
|
|
|
|||
|
|
@ -147,6 +147,41 @@ describe("generateGpx", () => {
|
|||
expect(plain.poiTags).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits <desc> inside <wpt> when waypoint has a note", () => {
|
||||
const gpx = generateGpx({
|
||||
waypoints: [{ lat: 52.52, lon: 13.405, name: "Berlin", note: "Water refill here" }],
|
||||
});
|
||||
// Should be inside <wpt>, not <metadata>
|
||||
expect(gpx).toContain("<wpt");
|
||||
expect(gpx).toContain("<desc>Water refill here</desc>");
|
||||
// Confirm it's inside the wpt block, not metadata
|
||||
const wptBlock = gpx.slice(gpx.indexOf("<wpt"), gpx.indexOf("</wpt>") + 6);
|
||||
expect(wptBlock).toContain("<desc>Water refill here</desc>");
|
||||
});
|
||||
|
||||
it("roundtrips waypoint note through generate + parse", async () => {
|
||||
const gpx = generateGpx({
|
||||
name: "Test route",
|
||||
description: "Route-level desc",
|
||||
waypoints: [
|
||||
{ lat: 52.52, lon: 13.405, name: "Berlin", note: "Bike shop – open weekdays" },
|
||||
{ lat: 48.0, lon: 11.0, name: "Munich" },
|
||||
],
|
||||
});
|
||||
const parsed = await parseGpxAsync(gpx);
|
||||
expect(parsed.waypoints[0]!.note).toBe("Bike shop – open weekdays");
|
||||
expect(parsed.waypoints[1]!.note).toBeUndefined();
|
||||
// Route-level description must not bleed into waypoint notes
|
||||
expect(parsed.description).toBe("Route-level desc");
|
||||
});
|
||||
|
||||
it("escapes XML special chars in notes", () => {
|
||||
const gpx = generateGpx({
|
||||
waypoints: [{ lat: 0, lon: 0, note: 'Water & food <here> "maybe"' }],
|
||||
});
|
||||
expect(gpx).toContain("Water & food <here> "maybe"");
|
||||
});
|
||||
|
||||
it("omits extensions element when waypoint has no POI data", () => {
|
||||
const gpx = generateGpx({
|
||||
waypoints: [{ lat: 52.0, lon: 13.0, name: "Plain" }],
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ export function generateGpx(options: {
|
|||
if (wpt.name) {
|
||||
lines.push(` <name>${escapeXml(wpt.name)}</name>`);
|
||||
}
|
||||
if (wpt.note) {
|
||||
lines.push(` <desc>${escapeXml(wpt.note)}</desc>`);
|
||||
}
|
||||
if (wpt.isDayBreak) {
|
||||
lines.push(" <type>overnight</type>");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ function parseWaypoints(doc: Document): Waypoint[] {
|
|||
const lat = parseFloat(wpt.getAttribute("lat") ?? "0");
|
||||
const lon = parseFloat(wpt.getAttribute("lon") ?? "0");
|
||||
const name = wpt.querySelector("name")?.textContent ?? undefined;
|
||||
const note = wpt.querySelector("desc")?.textContent ?? undefined;
|
||||
const type = wpt.querySelector("type")?.textContent ?? undefined;
|
||||
const isDayBreak = type === "overnight" ? true : undefined;
|
||||
|
||||
|
|
@ -79,7 +80,7 @@ function parseWaypoints(doc: Document): Waypoint[] {
|
|||
}
|
||||
}
|
||||
|
||||
return { lat, lon, name, isDayBreak, osmId, poiTags };
|
||||
return { lat, lon, name, note, isDayBreak, osmId, poiTags };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,18 @@ export default {
|
|||
viewpoints: "Aussichtspunkte",
|
||||
toilets: "Toiletten",
|
||||
},
|
||||
waypoint: {
|
||||
notePlaceholder: "Notiz hinzufügen…",
|
||||
noteCounter: "{{count}} / {{max}}",
|
||||
},
|
||||
nearbyPoi: {
|
||||
heading: "In der Nähe",
|
||||
loading: "Suche nach nahegelegenen Orten…",
|
||||
empty: "Keine nahegelegenen Orte gefunden",
|
||||
rateLimited: "POI-Abfrage vorübergehend nicht verfügbar",
|
||||
snap: "Hier einrasten",
|
||||
showMore: "Mehr anzeigen",
|
||||
},
|
||||
noGoAreas: {
|
||||
draw: "Sperrgebiet zeichnen",
|
||||
cancel: "Sperrgebiet abbrechen",
|
||||
|
|
|
|||
|
|
@ -88,6 +88,18 @@ export default {
|
|||
viewpoints: "Viewpoints",
|
||||
toilets: "Toilets",
|
||||
},
|
||||
waypoint: {
|
||||
notePlaceholder: "Add a note...",
|
||||
noteCounter: "{{count}} / {{max}}",
|
||||
},
|
||||
nearbyPoi: {
|
||||
heading: "Nearby",
|
||||
loading: "Looking up nearby points of interest…",
|
||||
empty: "No nearby points of interest found",
|
||||
rateLimited: "POI lookup unavailable",
|
||||
snap: "Snap here",
|
||||
showMore: "Show more",
|
||||
},
|
||||
noGoAreas: {
|
||||
draw: "Draw no-go area",
|
||||
cancel: "Cancel no-go area",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface Waypoint {
|
|||
lat: number;
|
||||
lon: number;
|
||||
name?: string;
|
||||
note?: string;
|
||||
isDayBreak?: boolean;
|
||||
osmId?: number;
|
||||
poiTags?: WaypointPoiTags;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue