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
|
|
@ -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