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
|
|
@ -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),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue