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

@ -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);
});
});

View file

@ -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);
}

View 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);
});
});

View 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;
}

View file

@ -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),
}));
}