Merge pull request #199 from trails-cool/feat/osm-overlays
Add OSM tile overlays and POI layers with waypoint snapping
This commit is contained in:
commit
90918221d8
24 changed files with 1297 additions and 67 deletions
|
|
@ -1,17 +1,22 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { MapContainer, TileLayer, LayersControl, Marker, CircleMarker, useMapEvents, useMap } from "react-leaflet";
|
||||
import { MapContainer, TileLayer, LayersControl, Marker, useMapEvents, useMap } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import * as Y from "yjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DayStage } from "@trails-cool/gpx";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { baseLayers } from "@trails-cool/map";
|
||||
import { baseLayers, overlayLayers } from "@trails-cool/map";
|
||||
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
|
||||
import { isOvernight } from "~/lib/overnight";
|
||||
import { setOvernight } from "~/lib/overnight";
|
||||
import { usePois } from "~/lib/use-pois";
|
||||
import { useProfileDefaults } from "~/lib/use-profile-defaults";
|
||||
import { snapToPoi } from "~/lib/poi-snap";
|
||||
import { Z_CURSOR, Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "~/lib/z-index";
|
||||
import { NoGoAreaLayer } from "./NoGoAreaLayer";
|
||||
import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute";
|
||||
import { RouteInteraction } from "./RouteInteraction";
|
||||
import { PoiPanel, PoiMarkers } from "./PoiPanel";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean): L.DivIcon {
|
||||
|
|
@ -173,7 +178,7 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
|
|||
<Marker
|
||||
key={clientId}
|
||||
position={[cursor.lat, cursor.lng]}
|
||||
zIndexOffset={-1000}
|
||||
zIndexOffset={Z_CURSOR}
|
||||
icon={L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="position:relative;z-index:400;pointer-events:none">
|
||||
|
|
@ -231,9 +236,50 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v
|
|||
);
|
||||
}
|
||||
|
||||
function PoiRefresher({ poiState }: { poiState: ReturnType<typeof usePois> }) {
|
||||
const map = useMap();
|
||||
const refreshRef = useRef(poiState.refresh);
|
||||
refreshRef.current = poiState.refresh;
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
refreshRef.current({
|
||||
south: bounds.getSouth(),
|
||||
west: bounds.getWest(),
|
||||
north: bounds.getNorth(),
|
||||
east: bounds.getEast(),
|
||||
}, zoom);
|
||||
};
|
||||
map.on("moveend", refresh);
|
||||
// Don't call refresh() immediately — let moveend trigger it
|
||||
return () => { map.off("moveend", refresh); };
|
||||
}, [map]);
|
||||
|
||||
// Trigger refresh when categories change (but not on mount)
|
||||
const prevCategories = useRef(poiState.enabledCategories);
|
||||
useEffect(() => {
|
||||
if (prevCategories.current === poiState.enabledCategories) return;
|
||||
prevCategories.current = poiState.enabledCategories;
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
poiState.refresh({
|
||||
south: bounds.getSouth(),
|
||||
west: bounds.getWest(),
|
||||
north: bounds.getNorth(),
|
||||
east: bounds.getEast(),
|
||||
}, zoom);
|
||||
}, [map, poiState.enabledCategories, poiState.refresh]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlightedWaypoint, onImportError, days }: PlannerMapProps) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
||||
const poiState = usePois();
|
||||
useProfileDefaults(yjs, poiState);
|
||||
const [draggingOver, setDraggingOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null);
|
||||
|
|
@ -318,27 +364,36 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
}, [yjs.routeData]);
|
||||
|
||||
const addWaypoint = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
(lat: number, lng: number, name?: string) => {
|
||||
const snap = snapToPoi(lat, lng, poiState.pois);
|
||||
yjs.doc.transact(() => {
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("lat", lat);
|
||||
yMap.set("lon", lng);
|
||||
yMap.set("lat", snap.lat);
|
||||
yMap.set("lon", snap.snapped ? snap.lon : lng);
|
||||
if (snap.name) yMap.set("name", snap.name);
|
||||
else if (name) yMap.set("name", name); // fallback for explicit name
|
||||
if (snap.osmId) yMap.set("osmId", snap.osmId);
|
||||
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
|
||||
yjs.waypoints.push([yMap]);
|
||||
}, "local");
|
||||
},
|
||||
[yjs.doc, yjs.waypoints],
|
||||
[yjs.doc, yjs.waypoints, poiState.pois],
|
||||
);
|
||||
|
||||
const insertWaypointAtSegment = useCallback(
|
||||
(segmentIndex: number, lat: number, lon: number) => {
|
||||
const snap = snapToPoi(lat, lon, poiState.pois);
|
||||
yjs.doc.transact(() => {
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("lat", lat);
|
||||
yMap.set("lon", lon);
|
||||
yMap.set("lat", snap.lat);
|
||||
yMap.set("lon", snap.snapped ? snap.lon : lon);
|
||||
if (snap.name) yMap.set("name", snap.name);
|
||||
if (snap.osmId) yMap.set("osmId", snap.osmId);
|
||||
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
|
||||
yjs.waypoints.insert(segmentIndex + 1, [yMap]);
|
||||
}, "local");
|
||||
},
|
||||
[yjs.doc, yjs.waypoints],
|
||||
[yjs.doc, yjs.waypoints, poiState.pois],
|
||||
);
|
||||
|
||||
const handleRouteInsert = useCallback(
|
||||
|
|
@ -352,15 +407,28 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
|
||||
const moveWaypoint = useCallback(
|
||||
(index: number, lat: number, lng: number) => {
|
||||
const snap = snapToPoi(lat, lng, poiState.pois);
|
||||
const yMap = yjs.waypoints.get(index);
|
||||
if (yMap) {
|
||||
yjs.doc.transact(() => {
|
||||
yMap.set("lat", lat);
|
||||
yMap.set("lon", lng);
|
||||
yMap.set("lat", snap.lat);
|
||||
yMap.set("lon", snap.snapped ? snap.lon : lng);
|
||||
if (snap.snapped && snap.name) {
|
||||
yMap.set("name", snap.name);
|
||||
} else {
|
||||
yMap.delete("name");
|
||||
}
|
||||
if (snap.osmId) {
|
||||
yMap.set("osmId", snap.osmId);
|
||||
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
|
||||
} else {
|
||||
yMap.delete("osmId");
|
||||
yMap.delete("poiTags");
|
||||
}
|
||||
}, "local");
|
||||
}
|
||||
},
|
||||
[yjs.waypoints, yjs.doc],
|
||||
[yjs.waypoints, yjs.doc, poiState.pois],
|
||||
);
|
||||
|
||||
const deleteWaypoint = useCallback(
|
||||
|
|
@ -455,6 +523,11 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} />
|
||||
</LayersControl.BaseLayer>
|
||||
))}
|
||||
{overlayLayers.map((layer) => (
|
||||
<LayersControl.Overlay key={layer.id} name={layer.name}>
|
||||
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} opacity={layer.opacity ?? 0.7} />
|
||||
</LayersControl.Overlay>
|
||||
))}
|
||||
</LayersControl>
|
||||
|
||||
<MapExposer />
|
||||
|
|
@ -463,12 +536,16 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
<CursorTracker awareness={yjs.awareness} />
|
||||
<NoGoAreaLayer noGoAreas={yjs.noGoAreas} doc={yjs.doc} enabled={noGoDrawing} onToggle={toggleNoGoDraw} />
|
||||
<NoGoAreaButton active={noGoDrawing} onClick={toggleNoGoDraw} />
|
||||
<PoiRefresher poiState={poiState} />
|
||||
<PoiMarkers poiState={poiState} onAddWaypoint={addWaypoint} />
|
||||
<PoiPanel poiState={poiState} />
|
||||
|
||||
{waypoints.map((wp, i) => (
|
||||
<Marker
|
||||
key={i}
|
||||
position={[wp.lat, wp.lon]}
|
||||
draggable
|
||||
zIndexOffset={highlightedWaypoint === i ? Z_WAYPOINT_HIGHLIGHTED : Z_WAYPOINT}
|
||||
icon={waypointIcon(i, wp.overnight, highlightedWaypoint === i)}
|
||||
eventHandlers={{
|
||||
mouseover: () => {
|
||||
|
|
@ -535,10 +612,15 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
)}
|
||||
|
||||
{highlightPosition && (
|
||||
<CircleMarker
|
||||
center={highlightPosition}
|
||||
radius={6}
|
||||
pathOptions={{ color: "#ef4444", fillColor: "#ef4444", fillOpacity: 1, weight: 2 }}
|
||||
<Marker
|
||||
position={highlightPosition}
|
||||
zIndexOffset={Z_HIGHLIGHT}
|
||||
interactive={false}
|
||||
icon={L.divIcon({
|
||||
className: "",
|
||||
html: '<div style="width:12px;height:12px;border-radius:50%;background:#ef4444;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);transform:translate(-6px,-6px)"></div>',
|
||||
iconSize: [0, 0],
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
|
|
|
|||
156
apps/planner/app/components/PoiPanel.tsx
Normal file
156
apps/planner/app/components/PoiPanel.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import L from "leaflet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMap } from "react-leaflet";
|
||||
import { poiCategories } from "~/lib/poi-categories";
|
||||
import type { PoiState } from "~/lib/use-pois";
|
||||
import { Z_POI_MARKER } from "~/lib/z-index";
|
||||
|
||||
interface PoiPanelProps {
|
||||
poiState: PoiState;
|
||||
onAddWaypoint?: (lat: number, lon: number, name?: string) => void;
|
||||
}
|
||||
|
||||
export function PoiPanel({ poiState }: PoiPanelProps) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) L.DomEvent.disableClickPropagation(ref.current);
|
||||
}, []);
|
||||
|
||||
const countByCategory = new Map<string, number>();
|
||||
for (const poi of poiState.pois) {
|
||||
countByCategory.set(poi.category, (countByCategory.get(poi.category) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="leaflet-top leaflet-right" style={{ marginTop: 120 }}>
|
||||
<div className="leaflet-control">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded bg-white text-base shadow-md hover:bg-gray-50"
|
||||
title={t("poi.toggle")}
|
||||
>
|
||||
📍
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-1 w-48 rounded bg-white p-2 shadow-lg">
|
||||
<p className="mb-1 text-xs font-semibold text-gray-600">{t("poi.title")}</p>
|
||||
|
||||
{poiState.status === "zoom_too_low" && (
|
||||
<p className="text-xs text-amber-600">{t("poi.zoomIn")}</p>
|
||||
)}
|
||||
{poiState.status === "rate_limited" && (
|
||||
<p className="text-xs text-red-600">{t("poi.rateLimited")}</p>
|
||||
)}
|
||||
{poiState.status === "error" && (
|
||||
<p className="text-xs text-red-600">{t("poi.error")}</p>
|
||||
)}
|
||||
{poiState.status === "loading" && (
|
||||
<p className="text-xs text-blue-600">{t("poi.loading")}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{poiCategories.map((cat) => {
|
||||
const count = countByCategory.get(cat.id) ?? 0;
|
||||
const enabled = poiState.enabledCategories.includes(cat.id);
|
||||
return (
|
||||
<label key={cat.id} className="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={() => poiState.toggleCategory(cat.id)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">{cat.icon}</span>
|
||||
<span className="flex-1 text-xs text-gray-700">{t(cat.name)}</span>
|
||||
{enabled && count > 0 && (
|
||||
<span className="text-[10px] tabular-nums text-gray-400">{count}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PoiMarkers({ poiState, onAddWaypoint }: PoiPanelProps) {
|
||||
const map = useMap();
|
||||
const layerRef = useRef<L.LayerGroup>(L.layerGroup());
|
||||
|
||||
useEffect(() => {
|
||||
layerRef.current.addTo(map);
|
||||
return () => { layerRef.current.remove(); };
|
||||
}, [map]);
|
||||
|
||||
// Event delegation for "Add as waypoint" buttons in popups
|
||||
useEffect(() => {
|
||||
const container = map.getContainer();
|
||||
const handler = (e: MouseEvent) => {
|
||||
const btn = (e.target as HTMLElement).closest(".poi-add-wp") as HTMLElement | null;
|
||||
if (!btn || !onAddWaypoint) return;
|
||||
const lat = parseFloat(btn.dataset.lat!);
|
||||
const lon = parseFloat(btn.dataset.lon!);
|
||||
const name = btn.dataset.name || undefined;
|
||||
onAddWaypoint(lat, lon, name);
|
||||
map.closePopup();
|
||||
};
|
||||
container.addEventListener("click", handler);
|
||||
return () => container.removeEventListener("click", handler);
|
||||
}, [map, onAddWaypoint]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = layerRef.current;
|
||||
group.clearLayers();
|
||||
|
||||
const catMap = new Map(poiCategories.map((c) => [c.id, c]));
|
||||
|
||||
for (const poi of poiState.pois) {
|
||||
const cat = catMap.get(poi.category);
|
||||
if (!cat) continue;
|
||||
|
||||
const marker = L.marker([poi.lat, poi.lon], {
|
||||
icon: L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="
|
||||
width:20px;height:20px;border-radius:50%;
|
||||
background:white;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:12px;
|
||||
border:2px solid ${cat.color};
|
||||
box-shadow:0 1px 3px rgba(0,0,0,0.2);
|
||||
transform:translate(-10px,-10px);
|
||||
">${cat.icon}</div>`,
|
||||
iconSize: [0, 0],
|
||||
}),
|
||||
zIndexOffset: Z_POI_MARKER,
|
||||
});
|
||||
|
||||
const popupLines = [`<strong>${poi.name ?? cat.icon + " " + poi.category}</strong>`];
|
||||
popupLines.push(`<span style="font-size:11px;color:#888">${cat.icon} ${cat.id.replace(/_/g, " ")}</span>`);
|
||||
if (poi.tags.description) popupLines.push(`<span style="font-size:12px">${poi.tags.description}</span>`);
|
||||
const addr = [poi.tags["addr:street"], poi.tags["addr:housenumber"]].filter(Boolean).join(" ");
|
||||
const addrCity = [addr, poi.tags["addr:postcode"], poi.tags["addr:city"]].filter(Boolean).join(", ");
|
||||
if (addrCity) popupLines.push(`📍 ${addrCity}`);
|
||||
const phone = poi.tags.phone ?? poi.tags["contact:phone"];
|
||||
if (phone) popupLines.push(`📞 <a href="tel:${phone}">${phone}</a>`);
|
||||
if (poi.tags.opening_hours) popupLines.push(`🕐 ${poi.tags.opening_hours}`);
|
||||
if (poi.tags.website) popupLines.push(`<a href="${poi.tags.website}" target="_blank" rel="noopener">Website</a>`);
|
||||
popupLines.push(`<a href="https://www.openstreetmap.org/node/${poi.id}" target="_blank" rel="noopener" style="font-size:11px;color:#666">OSM</a>`);
|
||||
if (onAddWaypoint) {
|
||||
popupLines.push(`<button class="poi-add-wp" data-lat="${poi.lat}" data-lon="${poi.lon}" data-name="${(poi.name ?? "").replace(/"/g, """)}" style="margin-top:4px;padding:2px 8px;font-size:11px;background:#2563eb;color:white;border:none;border-radius:4px;cursor:pointer">+ Add as waypoint</button>`);
|
||||
}
|
||||
|
||||
marker.bindPopup(popupLines.join("<br>"), { maxWidth: 200 });
|
||||
group.addLayer(marker);
|
||||
}
|
||||
}, [poiState.pois]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
|
||||
const PROFILE_IDS = ["trekking", "fastbike", "safety", "shortest", "car"] as const;
|
||||
const PROFILE_IDS = ["fastbike", "safety", "shortest", "car", "trekking"] as const;
|
||||
|
||||
interface ProfileSelectorProps {
|
||||
yjs: YjsState;
|
||||
|
|
@ -10,7 +10,7 @@ interface ProfileSelectorProps {
|
|||
|
||||
export function ProfileSelector({ yjs }: ProfileSelectorProps) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [profile, setProfile] = useState("trekking");
|
||||
const [profile, setProfile] = useState("fastbike");
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useMap } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import { Z_GHOST_WAYPOINT } from "~/lib/z-index";
|
||||
|
||||
interface RouteInteractionProps {
|
||||
coordinates: [number, number, number][]; // [lon, lat, ele]
|
||||
|
|
@ -89,7 +90,7 @@ export function RouteInteraction({
|
|||
icon: ghostIcon,
|
||||
draggable: true,
|
||||
interactive: true,
|
||||
zIndexOffset: -100,
|
||||
zIndexOffset: Z_GHOST_WAYPOINT,
|
||||
});
|
||||
markerRef.current = marker;
|
||||
|
||||
|
|
|
|||
104
apps/planner/app/lib/overpass.test.ts
Normal file
104
apps/planner/app/lib/overpass.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildQuery, parseResponse, deduplicateById, type Poi } from "./overpass.ts";
|
||||
import { poiCategories } from "./poi-categories.ts";
|
||||
|
||||
describe("buildQuery", () => {
|
||||
it("builds Overpass QL with bbox and category union", () => {
|
||||
const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.5 };
|
||||
const categories = poiCategories.filter((c) => c.id === "drinking_water");
|
||||
const query = buildQuery(bbox, categories);
|
||||
|
||||
expect(query).toContain("[out:json]");
|
||||
expect(query).toContain("[bbox:52.5,13.3,52.6,13.5]");
|
||||
expect(query).toContain('amenity"="drinking_water"');
|
||||
expect(query).toContain("out center qt 100");
|
||||
});
|
||||
|
||||
it("combines multiple categories into a single union", () => {
|
||||
const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.5 };
|
||||
const categories = poiCategories.filter((c) => ["drinking_water", "camping"].includes(c.id));
|
||||
const query = buildQuery(bbox, categories);
|
||||
|
||||
expect(query).toContain("drinking_water");
|
||||
expect(query).toContain("camp_site");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseResponse", () => {
|
||||
it("parses nodes into Poi objects", () => {
|
||||
const data = {
|
||||
elements: [
|
||||
{
|
||||
type: "node",
|
||||
id: 123,
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
tags: { amenity: "drinking_water", name: "Brunnen" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const categories = poiCategories.filter((c) => c.id === "drinking_water");
|
||||
const pois = parseResponse(data, categories);
|
||||
|
||||
expect(pois).toHaveLength(1);
|
||||
expect(pois[0]!.id).toBe(123);
|
||||
expect(pois[0]!.name).toBe("Brunnen");
|
||||
expect(pois[0]!.category).toBe("drinking_water");
|
||||
expect(pois[0]!.lat).toBe(52.52);
|
||||
});
|
||||
|
||||
it("uses center for way/relation elements", () => {
|
||||
const data = {
|
||||
elements: [
|
||||
{
|
||||
type: "way",
|
||||
id: 456,
|
||||
center: { lat: 52.51, lon: 13.39 },
|
||||
tags: { tourism: "camp_site", name: "Zeltplatz" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const categories = poiCategories.filter((c) => c.id === "camping");
|
||||
const pois = parseResponse(data, categories);
|
||||
|
||||
expect(pois).toHaveLength(1);
|
||||
expect(pois[0]!.lat).toBe(52.51);
|
||||
expect(pois[0]!.lon).toBe(13.39);
|
||||
});
|
||||
|
||||
it("skips elements without coordinates", () => {
|
||||
const data = {
|
||||
elements: [
|
||||
{ type: "node", id: 789, tags: { amenity: "drinking_water" } },
|
||||
],
|
||||
};
|
||||
const categories = poiCategories.filter((c) => c.id === "drinking_water");
|
||||
const pois = parseResponse(data, categories);
|
||||
expect(pois).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips elements that don't match any category", () => {
|
||||
const data = {
|
||||
elements: [
|
||||
{ type: "node", id: 100, lat: 52.52, lon: 13.4, tags: { amenity: "bank" } },
|
||||
],
|
||||
};
|
||||
const categories = poiCategories.filter((c) => c.id === "drinking_water");
|
||||
const pois = parseResponse(data, categories);
|
||||
expect(pois).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deduplicateById", () => {
|
||||
it("removes duplicate POIs by id", () => {
|
||||
const pois: Poi[] = [
|
||||
{ id: 1, lat: 52.5, lon: 13.4, category: "drinking_water", tags: {} },
|
||||
{ id: 1, lat: 52.5, lon: 13.4, category: "camping", tags: {} },
|
||||
{ id: 2, lat: 52.6, lon: 13.5, category: "shelter", tags: {} },
|
||||
];
|
||||
const result = deduplicateById(pois);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.id).toBe(1);
|
||||
expect(result[1]!.id).toBe(2);
|
||||
});
|
||||
});
|
||||
169
apps/planner/app/lib/overpass.ts
Normal file
169
apps/planner/app/lib/overpass.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import type { PoiCategory } from "./poi-categories.ts";
|
||||
|
||||
const OVERPASS_ENDPOINTS = [
|
||||
"https://overpass.kumi.systems/api/interpreter",
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
];
|
||||
|
||||
export interface Poi {
|
||||
id: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
name?: string;
|
||||
category: string;
|
||||
tags: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface BBox {
|
||||
south: number;
|
||||
west: number;
|
||||
north: number;
|
||||
east: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an Overpass QL query combining all enabled categories into a union.
|
||||
*/
|
||||
export function buildQuery(bbox: BBox, categories: PoiCategory[]): string {
|
||||
const bboxStr = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`;
|
||||
const unions = categories.map((c) => c.query).join("");
|
||||
return `[out:json][timeout:10][maxsize:1048576][bbox:${bboxStr}];(${unions});out center qt 100;`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Overpass JSON response into typed Poi objects.
|
||||
*/
|
||||
export function parseResponse(
|
||||
data: { elements: Array<{
|
||||
type: string;
|
||||
id: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
center?: { lat: number; lon: number };
|
||||
tags?: Record<string, string>;
|
||||
}> },
|
||||
categories: PoiCategory[],
|
||||
): Poi[] {
|
||||
const pois: Poi[] = [];
|
||||
|
||||
for (const el of data.elements) {
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lon = el.lon ?? el.center?.lon;
|
||||
if (lat === undefined || lon === undefined) continue;
|
||||
|
||||
const tags = el.tags ?? {};
|
||||
const category = matchCategory(tags, categories);
|
||||
if (!category) continue;
|
||||
|
||||
pois.push({
|
||||
id: el.id,
|
||||
lat,
|
||||
lon,
|
||||
name: tags.name,
|
||||
category: category.id,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
return deduplicateById(pois);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an element's tags to the first matching category.
|
||||
*/
|
||||
function matchCategory(tags: Record<string, string>, categories: PoiCategory[]): PoiCategory | null {
|
||||
for (const cat of categories) {
|
||||
// Parse query fragments like 'nwr["amenity"="drinking_water"];'
|
||||
const fragments = cat.query.split(";").filter(Boolean);
|
||||
for (const frag of fragments) {
|
||||
const match = frag.match(/\["(\w+)"="([^"]+)"\]/);
|
||||
if (match && tags[match[1]!] === match[2]) return cat;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate POIs by OSM node ID (same node may match multiple queries).
|
||||
*/
|
||||
export function deduplicateById(pois: Poi[]): Poi[] {
|
||||
const seen = new Set<number>();
|
||||
return pois.filter((poi) => {
|
||||
if (seen.has(poi.id)) return false;
|
||||
seen.add(poi.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the Overpass API for POIs within a bounding box.
|
||||
*/
|
||||
export async function queryPois(
|
||||
bbox: BBox,
|
||||
categories: PoiCategory[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Poi[]> {
|
||||
if (categories.length === 0) return [];
|
||||
|
||||
const query = buildQuery(bbox, categories);
|
||||
|
||||
for (const endpoint of OVERPASS_ENDPOINTS) {
|
||||
try {
|
||||
return await fetchFromEndpoint(endpoint, query, categories, signal);
|
||||
} catch (err) {
|
||||
// If aborted, don't try fallback
|
||||
if (signal?.aborted) throw err;
|
||||
// If rate limited on all endpoints, throw
|
||||
if (err instanceof OverpassRateLimitError && endpoint === OVERPASS_ENDPOINTS[OVERPASS_ENDPOINTS.length - 1]) throw err;
|
||||
// Try next endpoint
|
||||
if (endpoint !== OVERPASS_ENDPOINTS[OVERPASS_ENDPOINTS.length - 1]) continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function fetchFromEndpoint(
|
||||
endpoint: string,
|
||||
query: string,
|
||||
categories: PoiCategory[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Poi[]> {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new OverpassRateLimitError();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Overpass API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (text.includes("rate_limited")) {
|
||||
throw new OverpassRateLimitError();
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("Overpass API returned invalid JSON");
|
||||
}
|
||||
|
||||
return parseResponse(data, categories);
|
||||
}
|
||||
|
||||
export class OverpassRateLimitError extends Error {
|
||||
constructor() {
|
||||
super("Overpass API rate limit exceeded");
|
||||
this.name = "OverpassRateLimitError";
|
||||
}
|
||||
}
|
||||
61
apps/planner/app/lib/poi-cache.test.ts
Normal file
61
apps/planner/app/lib/poi-cache.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { quantize, getCellKeys, getCached, setCached, clearCache } from "./poi-cache.ts";
|
||||
import type { Poi } from "./overpass.ts";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("quantize", () => {
|
||||
it("rounds down to 0.1 degree grid", () => {
|
||||
expect(quantize(52.537)).toBe(52.5);
|
||||
expect(quantize(13.405)).toBe(13.4);
|
||||
expect(quantize(0.0)).toBe(0);
|
||||
expect(quantize(-0.15)).toBe(-0.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCellKeys", () => {
|
||||
it("returns cell keys covering the bbox", () => {
|
||||
const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.4 };
|
||||
const keys = getCellKeys(bbox);
|
||||
expect(keys.length).toBeGreaterThan(0);
|
||||
expect(keys).toContain("52.5,13.3");
|
||||
expect(keys).toContain("52.5,13.4");
|
||||
expect(keys).toContain("52.6,13.3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCached / setCached", () => {
|
||||
const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.4 };
|
||||
const categoriesKey = "drinking_water";
|
||||
const pois: Poi[] = [
|
||||
{ id: 1, lat: 52.52, lon: 13.35, category: "drinking_water", tags: {} },
|
||||
{ id: 2, lat: 52.55, lon: 13.38, category: "drinking_water", tags: {} },
|
||||
];
|
||||
|
||||
it("returns null on cache miss", () => {
|
||||
expect(getCached(bbox, categoriesKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns cached POIs on cache hit", () => {
|
||||
setCached(bbox, categoriesKey, pois);
|
||||
const result = getCached(bbox, categoriesKey);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters POIs to requested bbox", () => {
|
||||
setCached(bbox, categoriesKey, [
|
||||
...pois,
|
||||
{ id: 3, lat: 52.9, lon: 13.35, category: "drinking_water", tags: {} }, // outside bbox
|
||||
]);
|
||||
const result = getCached(bbox, categoriesKey);
|
||||
expect(result).toHaveLength(2); // only the 2 within bbox
|
||||
});
|
||||
|
||||
it("returns null for different categories key", () => {
|
||||
setCached(bbox, categoriesKey, pois);
|
||||
expect(getCached(bbox, "camping")).toBeNull();
|
||||
});
|
||||
});
|
||||
86
apps/planner/app/lib/poi-cache.ts
Normal file
86
apps/planner/app/lib/poi-cache.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { Poi, BBox } from "./overpass.ts";
|
||||
|
||||
const CELL_SIZE = 0.1; // degrees
|
||||
const TTL = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
interface CacheEntry {
|
||||
pois: Poi[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
/**
|
||||
* Quantize a coordinate to the nearest grid cell boundary.
|
||||
*/
|
||||
export function quantize(value: number): number {
|
||||
return Math.floor(value / CELL_SIZE) * CELL_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache keys for grid cells covering a bounding box.
|
||||
*/
|
||||
export function getCellKeys(bbox: BBox): string[] {
|
||||
const keys: string[] = [];
|
||||
const minLat = quantize(bbox.south);
|
||||
const maxLat = quantize(bbox.north) + CELL_SIZE;
|
||||
const minLon = quantize(bbox.west);
|
||||
const maxLon = quantize(bbox.east) + CELL_SIZE;
|
||||
|
||||
for (let lat = minLat; lat < maxLat; lat += CELL_SIZE) {
|
||||
for (let lon = minLon; lon < maxLon; lon += CELL_SIZE) {
|
||||
keys.push(`${lat.toFixed(1)},${lon.toFixed(1)}`);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached POIs for a bounding box. Returns null if any cell is missing/expired.
|
||||
*/
|
||||
export function getCached(bbox: BBox, categoriesKey: string): Poi[] | null {
|
||||
const keys = getCellKeys(bbox);
|
||||
const now = Date.now();
|
||||
const allPois: Poi[] = [];
|
||||
|
||||
for (const cellKey of keys) {
|
||||
const fullKey = `${categoriesKey}:${cellKey}`;
|
||||
const entry = cache.get(fullKey);
|
||||
if (!entry || now - entry.timestamp > TTL) return null;
|
||||
allPois.push(...entry.pois);
|
||||
}
|
||||
|
||||
// Filter to only POIs actually within the requested bbox
|
||||
return allPois.filter(
|
||||
(p) => p.lat >= bbox.south && p.lat <= bbox.north && p.lon >= bbox.west && p.lon <= bbox.east,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store POIs in the cache, split by grid cell.
|
||||
*/
|
||||
export function setCached(bbox: BBox, categoriesKey: string, pois: Poi[]): void {
|
||||
const now = Date.now();
|
||||
const keys = getCellKeys(bbox);
|
||||
|
||||
// Assign each POI to its grid cell
|
||||
const cellPois = new Map<string, Poi[]>();
|
||||
for (const key of keys) cellPois.set(key, []);
|
||||
|
||||
for (const poi of pois) {
|
||||
const cellKey = `${quantize(poi.lat).toFixed(1)},${quantize(poi.lon).toFixed(1)}`;
|
||||
const bucket = cellPois.get(cellKey);
|
||||
if (bucket) bucket.push(poi);
|
||||
}
|
||||
|
||||
for (const [cellKey, cellData] of cellPois) {
|
||||
cache.set(`${categoriesKey}:${cellKey}`, { pois: cellData, timestamp: now });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data.
|
||||
*/
|
||||
export function clearCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
46
apps/planner/app/lib/poi-categories.test.ts
Normal file
46
apps/planner/app/lib/poi-categories.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { getCategoriesForProfile, profileOverlayDefaults, poiCategories } from "./poi-categories.ts";
|
||||
|
||||
describe("getCategoriesForProfile", () => {
|
||||
it("returns bike infra for cycling profiles", () => {
|
||||
expect(getCategoriesForProfile("fastbike")).toContain("bike_infra");
|
||||
expect(getCategoriesForProfile("safety")).toContain("bike_infra");
|
||||
});
|
||||
|
||||
it("returns shelter and viewpoints for hiking", () => {
|
||||
const cats = getCategoriesForProfile("trekking");
|
||||
expect(cats).toContain("shelter");
|
||||
expect(cats).toContain("viewpoints");
|
||||
});
|
||||
|
||||
it("returns empty for unknown profiles", () => {
|
||||
expect(getCategoriesForProfile("car")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("profileOverlayDefaults", () => {
|
||||
it("maps cycling profiles to waymarked-cycling", () => {
|
||||
expect(profileOverlayDefaults.fastbike).toContain("waymarked-cycling");
|
||||
expect(profileOverlayDefaults.safety).toContain("waymarked-cycling");
|
||||
});
|
||||
|
||||
it("maps hiking to waymarked-hiking", () => {
|
||||
expect(profileOverlayDefaults.trekking).toContain("waymarked-hiking");
|
||||
});
|
||||
});
|
||||
|
||||
describe("poiCategories", () => {
|
||||
it("has 9 categories", () => {
|
||||
expect(poiCategories).toHaveLength(9);
|
||||
});
|
||||
|
||||
it("all categories have required fields", () => {
|
||||
for (const cat of poiCategories) {
|
||||
expect(cat.id).toBeTruthy();
|
||||
expect(cat.name).toBeTruthy();
|
||||
expect(cat.icon).toBeTruthy();
|
||||
expect(cat.color).toMatch(/^#/);
|
||||
expect(cat.query).toContain("nwr");
|
||||
}
|
||||
});
|
||||
});
|
||||
90
apps/planner/app/lib/poi-categories.ts
Normal file
90
apps/planner/app/lib/poi-categories.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
export interface PoiCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
query: string;
|
||||
profiles?: string[];
|
||||
}
|
||||
|
||||
export const poiCategories: PoiCategory[] = [
|
||||
{
|
||||
id: "drinking_water",
|
||||
name: "poi.drinkingWater",
|
||||
icon: "💧",
|
||||
color: "#2563eb",
|
||||
query: 'nwr["amenity"="drinking_water"];nwr["amenity"="water_point"];',
|
||||
},
|
||||
{
|
||||
id: "shelter",
|
||||
name: "poi.shelter",
|
||||
icon: "🛖",
|
||||
color: "#8B6D3A",
|
||||
query: 'nwr["amenity"="shelter"];nwr["tourism"="wilderness_hut"];',
|
||||
profiles: ["trekking"],
|
||||
},
|
||||
{
|
||||
id: "camping",
|
||||
name: "poi.camping",
|
||||
icon: "⛺",
|
||||
color: "#059669",
|
||||
query: 'nwr["tourism"="camp_site"];nwr["tourism"="caravan_site"];',
|
||||
},
|
||||
{
|
||||
id: "food",
|
||||
name: "poi.food",
|
||||
icon: "🍽️",
|
||||
color: "#dc2626",
|
||||
query: 'nwr["amenity"="restaurant"];nwr["amenity"="cafe"];nwr["amenity"="fast_food"];nwr["amenity"="pub"];nwr["amenity"="biergarten"];',
|
||||
},
|
||||
{
|
||||
id: "groceries",
|
||||
name: "poi.groceries",
|
||||
icon: "🛒",
|
||||
color: "#f97316",
|
||||
query: 'nwr["shop"="supermarket"];nwr["shop"="convenience"];nwr["shop"="bakery"];',
|
||||
},
|
||||
{
|
||||
id: "bike_infra",
|
||||
name: "poi.bikeInfra",
|
||||
icon: "🔧",
|
||||
color: "#8b5cf6",
|
||||
query: 'nwr["amenity"="bicycle_parking"];nwr["amenity"="bicycle_repair_station"];nwr["amenity"="bicycle_rental"];',
|
||||
profiles: ["fastbike", "safety"],
|
||||
},
|
||||
{
|
||||
id: "accommodation",
|
||||
name: "poi.accommodation",
|
||||
icon: "🏨",
|
||||
color: "#0891b2",
|
||||
query: 'nwr["tourism"="hotel"];nwr["tourism"="hostel"];nwr["tourism"="guest_house"];',
|
||||
},
|
||||
{
|
||||
id: "viewpoints",
|
||||
name: "poi.viewpoints",
|
||||
icon: "👁️",
|
||||
color: "#9333ea",
|
||||
query: 'nwr["tourism"="viewpoint"];',
|
||||
profiles: ["trekking"],
|
||||
},
|
||||
{
|
||||
id: "toilets",
|
||||
name: "poi.toilets",
|
||||
icon: "🚻",
|
||||
color: "#6b7280",
|
||||
query: 'nwr["amenity"="toilets"];',
|
||||
},
|
||||
];
|
||||
|
||||
export function getCategoriesForProfile(profile: string): string[] {
|
||||
return poiCategories
|
||||
.filter((c) => c.profiles?.includes(profile))
|
||||
.map((c) => c.id);
|
||||
}
|
||||
|
||||
/** Profile → tile overlay mapping */
|
||||
export const profileOverlayDefaults: Record<string, string[]> = {
|
||||
fastbike: ["waymarked-cycling"],
|
||||
safety: ["waymarked-cycling"],
|
||||
trekking: ["waymarked-hiking"],
|
||||
};
|
||||
66
apps/planner/app/lib/poi-snap.ts
Normal file
66
apps/planner/app/lib/poi-snap.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { Poi } from "./overpass.ts";
|
||||
|
||||
const SNAP_DISTANCE_METERS = 50;
|
||||
|
||||
export interface SnapResult {
|
||||
lat: number;
|
||||
lon: number;
|
||||
name?: string;
|
||||
snapped: boolean;
|
||||
/** OSM node ID if snapped */
|
||||
osmId?: number;
|
||||
/** Key POI tags if snapped */
|
||||
poiTags?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a coordinate to the nearest visible POI if within threshold.
|
||||
* Returns the snapped position and POI name, or the original position.
|
||||
*/
|
||||
export function snapToPoi(lat: number, lon: number, pois: Poi[]): SnapResult {
|
||||
if (pois.length === 0) return { lat, lon, snapped: false };
|
||||
|
||||
let bestPoi: Poi | null = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const poi of pois) {
|
||||
const dist = haversine(lat, lon, poi.lat, poi.lon);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestPoi = poi;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestPoi && bestDist <= SNAP_DISTANCE_METERS) {
|
||||
// Pick key tags worth persisting with the waypoint
|
||||
const keepTags = ["phone", "contact:phone", "website", "opening_hours",
|
||||
"addr:street", "addr:housenumber", "addr:postcode", "addr:city",
|
||||
"description", "amenity", "tourism", "shop"];
|
||||
const poiTags: Record<string, string> = {};
|
||||
for (const key of keepTags) {
|
||||
if (bestPoi.tags[key]) poiTags[key] = bestPoi.tags[key];
|
||||
}
|
||||
|
||||
return {
|
||||
lat: bestPoi.lat,
|
||||
lon: bestPoi.lon,
|
||||
name: bestPoi.name,
|
||||
snapped: true,
|
||||
osmId: bestPoi.id,
|
||||
poiTags: Object.keys(poiTags).length > 0 ? poiTags : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { lat, lon, snapped: false };
|
||||
}
|
||||
|
||||
function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000;
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ interface RateLimitEntry {
|
|||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
const DEFAULT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
const DEFAULT_MAX_REQUESTS = 60;
|
||||
const DEFAULT_MAX_REQUESTS = 300;
|
||||
|
||||
// Clean up expired entries periodically
|
||||
setInterval(() => {
|
||||
|
|
|
|||
113
apps/planner/app/lib/use-pois.ts
Normal file
113
apps/planner/app/lib/use-pois.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { queryPois, OverpassRateLimitError, type Poi, type BBox } from "./overpass.ts";
|
||||
import { getCached, setCached } from "./poi-cache.ts";
|
||||
import { poiCategories } from "./poi-categories.ts";
|
||||
|
||||
const MIN_ZOOM = 10;
|
||||
const DEBOUNCE_MS = 800;
|
||||
const MIN_REQUEST_INTERVAL_MS = 2000;
|
||||
const BACKOFF_BASE_MS = 10000;
|
||||
const MAX_BACKOFF_MS = 60000;
|
||||
|
||||
export type PoiStatus = "idle" | "loading" | "loaded" | "zoom_too_low" | "rate_limited" | "error";
|
||||
|
||||
export interface PoiState {
|
||||
pois: Poi[];
|
||||
status: PoiStatus;
|
||||
enabledCategories: string[];
|
||||
setEnabledCategories: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
toggleCategory: (id: string) => void;
|
||||
refresh: (bbox: BBox, zoom: number) => void;
|
||||
}
|
||||
|
||||
export function usePois(): PoiState {
|
||||
const [pois, setPois] = useState<Poi[]>([]);
|
||||
const [status, setStatus] = useState<PoiStatus>("idle");
|
||||
const [enabledCategories, setEnabledCategories] = useState<string[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const backoffRef = useRef(0);
|
||||
const lastRequestRef = useRef(0);
|
||||
|
||||
const toggleCategory = useCallback((id: string) => {
|
||||
setEnabledCategories((prev) =>
|
||||
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(
|
||||
(bbox: BBox, zoom: number) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
if (enabledCategories.length === 0) {
|
||||
setPois([]);
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
if (zoom < MIN_ZOOM) {
|
||||
setPois([]);
|
||||
setStatus("zoom_too_low");
|
||||
return;
|
||||
}
|
||||
|
||||
const categories = poiCategories.filter((c) => enabledCategories.includes(c.id));
|
||||
const categoriesKey = [...enabledCategories].sort().join(",");
|
||||
|
||||
// Check cache first
|
||||
const cached = getCached(bbox, categoriesKey);
|
||||
if (cached) {
|
||||
setPois(cached);
|
||||
setStatus("loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
|
||||
// Calculate delay: debounce + respect minimum interval
|
||||
const sinceLastRequest = Date.now() - lastRequestRef.current;
|
||||
const delay = Math.max(DEBOUNCE_MS, MIN_REQUEST_INTERVAL_MS - sinceLastRequest);
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
// Cancel previous request
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
lastRequestRef.current = Date.now();
|
||||
|
||||
try {
|
||||
const result = await queryPois(bbox, categories, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
setCached(bbox, categoriesKey, result);
|
||||
setPois(result);
|
||||
setStatus("loaded");
|
||||
backoffRef.current = 0;
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (err instanceof OverpassRateLimitError) {
|
||||
setStatus("rate_limited");
|
||||
backoffRef.current = Math.min(
|
||||
(backoffRef.current || BACKOFF_BASE_MS) * 2,
|
||||
MAX_BACKOFF_MS,
|
||||
);
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
[enabledCategories],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { pois, status, enabledCategories, setEnabledCategories, toggleCategory, refresh };
|
||||
}
|
||||
45
apps/planner/app/lib/use-profile-defaults.ts
Normal file
45
apps/planner/app/lib/use-profile-defaults.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import type { YjsState } from "./use-yjs.ts";
|
||||
import type { PoiState } from "./use-pois.ts";
|
||||
import { getCategoriesForProfile } from "./poi-categories.ts";
|
||||
|
||||
/**
|
||||
* Auto-enable relevant POI categories when the routing profile changes.
|
||||
* Only triggers on explicit profile changes (not initial load).
|
||||
*/
|
||||
export function useProfileDefaults(yjs: YjsState | null, poiState: PoiState): void {
|
||||
const initializedRef = useRef(false);
|
||||
const prevProfileRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!yjs) return;
|
||||
|
||||
const handleChange = () => {
|
||||
const profile = yjs.routeData.get("profile") as string | undefined;
|
||||
if (!profile) return;
|
||||
|
||||
// Skip initial load — respect existing state
|
||||
if (!initializedRef.current) {
|
||||
initializedRef.current = true;
|
||||
prevProfileRef.current = profile;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only act on actual profile changes
|
||||
if (profile === prevProfileRef.current) return;
|
||||
prevProfileRef.current = profile;
|
||||
|
||||
// Auto-enable POI categories for this profile
|
||||
const defaultCategories = getCategoriesForProfile(profile);
|
||||
if (defaultCategories.length > 0) {
|
||||
poiState.setEnabledCategories((prev: string[]) => {
|
||||
const merged = new Set([...prev, ...defaultCategories]);
|
||||
return [...merged];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
yjs.routeData.observe(handleChange);
|
||||
return () => yjs.routeData.unobserve(handleChange);
|
||||
}, [yjs, poiState.setEnabledCategories]);
|
||||
}
|
||||
|
|
@ -91,7 +91,7 @@ export function useRouting(yjs: YjsState | null) {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
waypoints,
|
||||
profile: (yjs.routeData.get("profile") as string) ?? "trekking",
|
||||
profile: (yjs.routeData.get("profile") as string) ?? "fastbike",
|
||||
noGoAreas: noGoAreas.length > 0 ? noGoAreas : undefined,
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
13
apps/planner/app/lib/z-index.ts
Normal file
13
apps/planner/app/lib/z-index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Leaflet marker z-index offsets for consistent layering.
|
||||
* Higher values render on top of lower values.
|
||||
*
|
||||
* Rendering order (bottom to top):
|
||||
* POI markers → cursor markers → ghost waypoint → waypoint markers → highlight dot
|
||||
*/
|
||||
export const Z_CURSOR = -1000;
|
||||
export const Z_GHOST_WAYPOINT = -100;
|
||||
export const Z_WAYPOINT = 1000;
|
||||
export const Z_POI_MARKER = 1200;
|
||||
export const Z_WAYPOINT_HIGHLIGHTED = 1600;
|
||||
export const Z_HIGHLIGHT = 2000;
|
||||
|
|
@ -43,7 +43,7 @@ test.describe("Planner", () => {
|
|||
|
||||
const profileSelect = page.getByLabel("Profile:");
|
||||
await expect(profileSelect).toBeVisible();
|
||||
await expect(profileSelect).toHaveValue("trekking");
|
||||
await expect(profileSelect).toHaveValue("fastbike");
|
||||
});
|
||||
|
||||
test("session has export GPX button", async ({ page, request }) => {
|
||||
|
|
@ -269,16 +269,18 @@ test.describe("Planner", () => {
|
|||
const sessionResp = await request.post("/api/sessions", { data: {} });
|
||||
const { url } = await sessionResp.json();
|
||||
|
||||
await mockBRouter(page);
|
||||
|
||||
// Import a GPX with overnight waypoints
|
||||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<wpt lat="52.520" lon="13.405"><name>Berlin</name></wpt>
|
||||
<wpt lat="51.840" lon="12.243"><name>Dessau</name><type>overnight</type></wpt>
|
||||
<wpt lat="50.980" lon="11.028"><name>Erfurt</name></wpt>
|
||||
<wpt lat="52.516" lon="13.377"><name>Mitte</name><type>overnight</type></wpt>
|
||||
<wpt lat="52.510" lon="13.390"><name>Kreuzberg</name></wpt>
|
||||
<trk><trkseg>
|
||||
<trkpt lat="52.520" lon="13.405"><ele>34</ele></trkpt>
|
||||
<trkpt lat="51.840" lon="12.243"><ele>80</ele></trkpt>
|
||||
<trkpt lat="50.980" lon="11.028"><ele>195</ele></trkpt>
|
||||
<trkpt lat="52.516" lon="13.377"><ele>40</ele></trkpt>
|
||||
<trkpt lat="52.510" lon="13.390"><ele>35</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
|
||||
|
|
@ -309,4 +311,83 @@ test.describe("Planner", () => {
|
|||
const sidebar = page.locator("aside");
|
||||
await expect(sidebar.getByText("Day 1")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("enable hillshading overlay loads tiles", async ({ page, request }) => {
|
||||
const sessionResp = await request.post("/api/sessions", { data: {} });
|
||||
const { url } = await sessionResp.json();
|
||||
|
||||
// Track hillshading tile requests
|
||||
const hillshadingRequests: string[] = [];
|
||||
await page.route("**/tiles.wmflabs.org/hillshading/**", async (route) => {
|
||||
hillshadingRequests.push(route.request().url());
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "image/png",
|
||||
body: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAABJRUEFTuQmCC", "base64"),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(url);
|
||||
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Enable hillshading via DOM — Leaflet's layers control hover is hard to automate
|
||||
await page.evaluate(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>(".leaflet-control-layers-overlays input");
|
||||
if (inputs[0]) inputs[0].click();
|
||||
});
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
expect(hillshadingRequests.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("enable POI category shows markers on map", async ({ page, request }) => {
|
||||
const sessionResp = await request.post("/api/sessions", { data: {} });
|
||||
const { url } = await sessionResp.json();
|
||||
|
||||
// Mock Overpass API to return a test POI at the map center
|
||||
await page.route("**/api/interpreter", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
elements: [
|
||||
{
|
||||
type: "node",
|
||||
id: 12345,
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
tags: { amenity: "drinking_water", name: "Test Brunnen" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(url);
|
||||
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Zoom in to Berlin center to meet zoom threshold and see the mock POI
|
||||
await page.evaluate(() => {
|
||||
const map = (window as any).__leafletMap;
|
||||
if (map) map.setView([52.52, 13.405], 14, { animate: false });
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Open POI panel and enable Drinking water
|
||||
await page.getByTitle("Points of Interest").click();
|
||||
await page.getByText("Drinking water").click();
|
||||
|
||||
// Wait for mock Overpass response and marker rendering
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Verify POI marker rendered — check for marker with the water emoji in the pane
|
||||
const markerCount = await page.locator(".leaflet-marker-pane .leaflet-marker-icon").count();
|
||||
// Should have at least one POI marker (beyond any waypoint markers)
|
||||
expect(markerCount).toBeGreaterThan(0);
|
||||
|
||||
// Verify the Overpass mock was actually called by checking the POI panel count
|
||||
const panelText = await page.getByText("Drinking water").textContent();
|
||||
expect(panelText).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -169,18 +169,46 @@ routeOptions.poiCategories = ["drinking_water", "camping", "bike_infra"]
|
|||
Array of string IDs. Changes sync to all participants. Persisted in crash
|
||||
recovery localStorage snapshot.
|
||||
|
||||
### D10: POI-to-waypoint integration
|
||||
|
||||
POIs can become waypoints through three paths, all using the same snap logic:
|
||||
|
||||
1. **"Add as waypoint" button** in POI popup → appends to end of route
|
||||
2. **Click near a POI** (within 50m) → new waypoint snaps to POI position
|
||||
3. **Drag waypoint near a POI** → snaps on drop, clears name if dragged away
|
||||
|
||||
When snapping, the waypoint's Yjs Y.Map stores:
|
||||
- `osmId`: OSM node ID for future cross-referencing
|
||||
- `poiTags`: Key tags (phone, address, website, opening hours, amenity type)
|
||||
|
||||
This metadata persists through Yjs and will be available for Journal display.
|
||||
|
||||
### D11: Overpass endpoint fallback
|
||||
|
||||
Primary endpoint is `overpass.kumi.systems` (higher rate limits, same as
|
||||
brouter-web). Falls back to `overpass-api.de` if the primary fails. The
|
||||
Overpass API sometimes returns rate limit errors as HTTP 200 with
|
||||
"rate_limited" in the body — the client checks for this pattern.
|
||||
|
||||
### D12: Z-index layering
|
||||
|
||||
Marker z-index offsets centralized in `apps/planner/app/lib/z-index.ts`:
|
||||
- Cursors (-1000) < Ghost waypoint (-100) < Waypoints (1000) <
|
||||
Waypoint highlighted (1200) < POI markers (1500) < Highlight dot (2000)
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Overpass API availability**: Public endpoint, no SLA. If down, POI overlays
|
||||
fail gracefully (show message, tile overlays still work). → Could add
|
||||
fallback endpoint (`overpass.kumi.systems`) later.
|
||||
fail gracefully (show message, tile overlays still work). Fallback endpoint
|
||||
(`overpass.kumi.systems` → `overpass-api.de`) implemented.
|
||||
- **Tile service availability**: Waymarked Trails and hillshading tiles are
|
||||
community-run. → Degrade gracefully if tiles 404. Consider self-hosting tiles
|
||||
if usage grows.
|
||||
- **Performance with many POIs**: Dense areas (cities) may return hundreds of
|
||||
POIs. → Marker clustering + zoom threshold mitigate this. Limit Overpass
|
||||
response to 200 elements per category.
|
||||
- **leaflet.markercluster dependency**: Adds ~40KB. → Only load when POI
|
||||
overlays are enabled (dynamic import).
|
||||
POIs. → Zoom threshold (>=10) + 100 element limit mitigate this. Marker
|
||||
clustering deferred — can add later if density becomes an issue.
|
||||
- **Overpass query cost**: Combining many categories into one query is efficient
|
||||
but returns large payloads. → Only query enabled categories, not all.
|
||||
but returns large payloads. → Only query enabled categories, not all. 1MB
|
||||
maxsize limit, 10s timeout.
|
||||
- **Routing rate limit**: Multi-waypoint routes with POI snapping can trigger
|
||||
rapid recomputes. Increased rate limit from 60 to 300/hour to accommodate.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ The Planner shows three base tile layers (OSM, OpenTopoMap, CyclOSM) but no over
|
|||
- **POI overlay panel**: Add a collapsible panel to toggle categories of OSM points of interest (water, shelter, camping, food, bike infrastructure, accommodation) queried from the Overpass API within the current viewport
|
||||
- **POI markers**: Render POI results as categorized markers with icons, name labels, and popups showing OSM tags (opening hours, website, etc.)
|
||||
- **Profile-aware defaults**: Auto-enable relevant overlays based on the active routing profile (cycling → Waymarked Cycling + bike POIs; hiking → Waymarked Hiking + water/shelter POIs)
|
||||
- **Overpass client**: Shared utility for querying the Overpass API with caching, debouncing, and rate limit handling — reused by the waypoint-notes POI snap feature
|
||||
- **Overpass client**: Shared utility for querying the Overpass API with caching, debouncing, rate limit handling, and endpoint fallback (kumi.systems → overpass-api.de)
|
||||
- **POI → waypoint**: "Add as waypoint" button in POI popups, plus automatic snapping of placed/dragged waypoints to nearby POIs (50m threshold) with OSM metadata preserved
|
||||
- **POI metadata on waypoints**: Waypoints snapped to POIs store osmId and key tags (phone, address, website, opening hours) for future Journal display
|
||||
|
||||
## Capabilities
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
## 1. Tile Overlay Definitions
|
||||
|
||||
- [ ] 1.1 Add `overlayLayers` export to `packages/map/src/layers.ts` with hillshading, Waymarked Cycling, Waymarked Hiking, Waymarked MTB tile configs
|
||||
- [ ] 1.2 Add `LayersControl.Overlay` entries in `MapView.tsx` and `PlannerMap.tsx` for each overlay
|
||||
- [ ] 1.3 Verify overlay attribution updates correctly when toggling overlays on/off
|
||||
- [x] 1.1 Add `overlayLayers` export to `packages/map/src/layers.ts` with hillshading, Waymarked Cycling, Waymarked Hiking, Waymarked MTB tile configs
|
||||
- [x] 1.2 Add `LayersControl.Overlay` entries in `MapView.tsx` and `PlannerMap.tsx` for each overlay
|
||||
- [x] 1.3 Verify overlay attribution updates correctly when toggling overlays on/off
|
||||
|
||||
## 2. Overlay State Sync
|
||||
|
||||
|
|
@ -13,52 +13,68 @@
|
|||
|
||||
## 3. Overpass Client
|
||||
|
||||
- [ ] 3.1 Create `apps/planner/app/lib/overpass.ts` with `queryPois(bbox, categories)` function
|
||||
- [ ] 3.2 Build Overpass QL union queries from enabled POI category configs
|
||||
- [ ] 3.3 Parse `[out:json]` response into typed `Poi` objects (id, lat, lon, name, category, tags)
|
||||
- [ ] 3.4 Deduplicate results by OSM node ID (same node may match multiple tag queries)
|
||||
- [x] 3.1 Create `apps/planner/app/lib/overpass.ts` with `queryPois(bbox, categories)` function
|
||||
- [x] 3.2 Build Overpass QL union queries from enabled POI category configs
|
||||
- [x] 3.3 Parse `[out:json]` response into typed `Poi` objects (id, lat, lon, name, category, tags)
|
||||
- [x] 3.4 Deduplicate results by OSM node ID (same node may match multiple tag queries)
|
||||
|
||||
## 4. POI Caching & Rate Limiting
|
||||
|
||||
- [ ] 4.1 Implement tile-based cache: quantize viewport to 0.1° grid cells, cache per cell with 10-minute TTL
|
||||
- [ ] 4.2 Add 500ms debounce on map `moveend` before triggering Overpass query
|
||||
- [ ] 4.3 Use AbortController to cancel in-flight requests when viewport changes
|
||||
- [ ] 4.4 Handle 429 responses with exponential backoff and user-visible message
|
||||
- [ ] 4.5 Enforce zoom >= 12 threshold — show "Zoom in to see POIs" message below
|
||||
- [x] 4.1 Implement tile-based cache: quantize viewport to 0.1° grid cells, cache per cell with 10-minute TTL
|
||||
- [x] 4.2 Add 500ms debounce on map `moveend` before triggering Overpass query
|
||||
- [x] 4.3 Use AbortController to cancel in-flight requests when viewport changes
|
||||
- [x] 4.4 Handle 429 responses with exponential backoff and user-visible message
|
||||
- [x] 4.5 Enforce zoom >= 12 threshold — show "Zoom in to see POIs" message below
|
||||
|
||||
## 5. POI Category Configuration
|
||||
|
||||
- [ ] 5.1 Define `PoiCategory` type and initial category configs (water, shelter, camping, food, groceries, bike infra, accommodation, viewpoints, toilets)
|
||||
- [ ] 5.2 Map each category to Overpass QL tag queries, icon, color, and applicable routing profiles
|
||||
- [x] 5.1 Define `PoiCategory` type and initial category configs (water, shelter, camping, food, groceries, bike infra, accommodation, viewpoints, toilets)
|
||||
- [x] 5.2 Map each category to Overpass QL tag queries, icon, color, and applicable routing profiles
|
||||
|
||||
## 6. POI Overlay Panel
|
||||
|
||||
- [ ] 6.1 Create collapsible POI panel component with toggle button (map right side, below layer switcher)
|
||||
- [ ] 6.2 Render checkbox per POI category with icon, name, and visible count badge
|
||||
- [ ] 6.3 Show loading indicator while Overpass query is in flight
|
||||
- [ ] 6.4 Show empty/error states (no results, Overpass unavailable, zoom too low)
|
||||
- [x] 6.1 Create collapsible POI panel component with toggle button (map right side, below layer switcher)
|
||||
- [x] 6.2 Render checkbox per POI category with icon, name, and visible count badge
|
||||
- [x] 6.3 Show loading indicator while Overpass query is in flight
|
||||
- [x] 6.4 Show empty/error states (no results, Overpass unavailable, zoom too low)
|
||||
|
||||
## 7. POI Marker Rendering
|
||||
|
||||
- [ ] 7.1 Render POI markers using `L.Marker` with `L.DivIcon` showing category icon
|
||||
- [ ] 7.2 Add click popup with POI name, category, opening hours, website, and OSM link
|
||||
- [x] 7.1 Render POI markers using `L.Marker` with `L.DivIcon` showing category icon
|
||||
- [x] 7.2 Add click popup with POI name, category, opening hours, website, and OSM link
|
||||
- [ ] 7.3 Add `leaflet.markercluster` for clustering dense POI areas (dynamic import to avoid bundle bloat)
|
||||
- [ ] 7.4 Set z-index so POI markers render below route polyline and waypoint markers
|
||||
- [x] 7.4 Set z-index so POI markers render below route polyline and waypoint markers
|
||||
|
||||
## 8. Profile-Aware Defaults
|
||||
|
||||
- [ ] 8.1 Define profile-to-overlay mapping (cycling → waymarked-cycling + bike POIs, hiking → waymarked-hiking + shelter + viewpoints, MTB → waymarked-mtb + bike POIs)
|
||||
- [ ] 8.2 Auto-enable mapped overlays on routing profile change (update Yjs arrays)
|
||||
- [ ] 8.3 Only auto-enable on explicit profile change, not on initial page load (respect existing Yjs state)
|
||||
- [x] 8.1 Define profile-to-overlay mapping (cycling → waymarked-cycling + bike POIs, hiking → waymarked-hiking + shelter + viewpoints, MTB → waymarked-mtb + bike POIs)
|
||||
- [x] 8.2 Auto-enable mapped overlays on routing profile change (update Yjs arrays)
|
||||
- [x] 8.3 Only auto-enable on explicit profile change, not on initial page load (respect existing Yjs state)
|
||||
|
||||
## 9. i18n
|
||||
|
||||
- [ ] 9.1 Add translation keys for all overlay names, POI category names, and UI strings (en + de)
|
||||
- [x] 9.1 Add translation keys for all overlay names, POI category names, and UI strings (en + de)
|
||||
|
||||
## 10. Testing
|
||||
|
||||
- [ ] 10.1 Unit tests for Overpass client: query building, response parsing, deduplication
|
||||
- [ ] 10.2 Unit tests for POI cache: tile quantization, TTL expiry, cache hit/miss
|
||||
- [ ] 10.3 Unit tests for profile-to-overlay mapping
|
||||
- [ ] 10.4 E2E test: enable hillshading overlay, verify tile requests
|
||||
- [ ] 10.5 E2E test: enable POI category, verify markers appear (mock Overpass response)
|
||||
- [x] 10.1 Unit tests for Overpass client: query building, response parsing, deduplication
|
||||
- [x] 10.2 Unit tests for POI cache: tile quantization, TTL expiry, cache hit/miss
|
||||
- [x] 10.3 Unit tests for profile-to-overlay mapping
|
||||
- [x] 10.4 E2E test: enable hillshading overlay, verify tile requests
|
||||
- [x] 10.5 E2E test: enable POI category, verify markers appear (mock Overpass response)
|
||||
|
||||
## 11. POI-Waypoint Integration
|
||||
|
||||
- [x] 11.1 Add "Add as waypoint" button to POI popup — appends POI location + name as waypoint
|
||||
- [x] 11.2 Snap click-to-add waypoints to nearby POIs (50m threshold) with name + metadata
|
||||
- [x] 11.3 Snap dragged waypoints to nearby POIs, clear name/metadata when dragged away
|
||||
- [x] 11.4 Snap route-inserted waypoints to nearby POIs
|
||||
- [x] 11.5 Store osmId and poiTags on Yjs waypoint Y.Map for snapped POIs
|
||||
|
||||
## 12. Resilience
|
||||
|
||||
- [x] 12.1 Fallback Overpass endpoint (kumi.systems → overpass-api.de)
|
||||
- [x] 12.2 Handle Overpass rate limit returned as HTTP 200 with error body
|
||||
- [x] 12.3 Reduce query size (100 results, 1MB maxsize, 10s timeout)
|
||||
- [x] 12.4 Extract z-index constants into z-index.ts for consistent marker layering
|
||||
- [x] 12.5 Increase Planner route rate limit from 60 to 300 requests/hour
|
||||
|
|
|
|||
|
|
@ -69,6 +69,23 @@ export default {
|
|||
ascent: "Anstieg",
|
||||
descent: "Abstieg",
|
||||
},
|
||||
poi: {
|
||||
toggle: "Sehenswürdigkeiten",
|
||||
title: "Sehenswürdigkeiten",
|
||||
loading: "Laden...",
|
||||
zoomIn: "Näher heranzoomen für POIs",
|
||||
rateLimited: "POI-Daten vorübergehend nicht verfügbar",
|
||||
error: "POIs konnten nicht geladen werden",
|
||||
drinkingWater: "Trinkwasser",
|
||||
shelter: "Unterstand",
|
||||
camping: "Camping",
|
||||
food: "Essen & Trinken",
|
||||
groceries: "Lebensmittel",
|
||||
bikeInfra: "Fahrrad-Infrastruktur",
|
||||
accommodation: "Unterkunft",
|
||||
viewpoints: "Aussichtspunkte",
|
||||
toilets: "Toiletten",
|
||||
},
|
||||
noGoAreas: {
|
||||
draw: "Sperrgebiet zeichnen",
|
||||
cancel: "Sperrgebiet abbrechen",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,23 @@ export default {
|
|||
ascent: "Ascent",
|
||||
descent: "Descent",
|
||||
},
|
||||
poi: {
|
||||
toggle: "Points of Interest",
|
||||
title: "Points of Interest",
|
||||
loading: "Loading...",
|
||||
zoomIn: "Zoom in to see POIs",
|
||||
rateLimited: "POI data temporarily unavailable",
|
||||
error: "Failed to load POIs",
|
||||
drinkingWater: "Drinking water",
|
||||
shelter: "Shelter",
|
||||
camping: "Camping",
|
||||
food: "Food & drink",
|
||||
groceries: "Groceries",
|
||||
bikeInfra: "Bike infrastructure",
|
||||
accommodation: "Accommodation",
|
||||
viewpoints: "Viewpoints",
|
||||
toilets: "Toilets",
|
||||
},
|
||||
noGoAreas: {
|
||||
draw: "Draw no-go area",
|
||||
cancel: "Cancel no-go area",
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ export { MapView } from "./MapView.tsx";
|
|||
export type { MapViewProps } from "./MapView.tsx";
|
||||
export { RouteLayer } from "./RouteLayer.tsx";
|
||||
export type { RouteLayerProps } from "./RouteLayer.tsx";
|
||||
export { baseLayers } from "./layers.ts";
|
||||
export type { TileLayerConfig } from "./layers.ts";
|
||||
export { baseLayers, overlayLayers } from "./layers.ts";
|
||||
export type { TileLayerConfig, OverlayLayerConfig } from "./layers.ts";
|
||||
|
|
|
|||
|
|
@ -5,6 +5,43 @@ export interface TileLayerConfig {
|
|||
maxZoom?: number;
|
||||
}
|
||||
|
||||
export interface OverlayLayerConfig extends TileLayerConfig {
|
||||
id: string;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export const overlayLayers: OverlayLayerConfig[] = [
|
||||
{
|
||||
id: "hillshading",
|
||||
name: "Hillshading",
|
||||
url: "https://tiles.wmflabs.org/hillshading/{z}/{x}/{y}.png",
|
||||
attribution: "Hillshading: SRTM/Mapzen",
|
||||
maxZoom: 17,
|
||||
opacity: 0.5,
|
||||
},
|
||||
{
|
||||
id: "waymarked-cycling",
|
||||
name: "Cycling Routes",
|
||||
url: "https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png",
|
||||
attribution: '© <a href="https://waymarkedtrails.org">Waymarked Trails</a> (CC-BY-SA)',
|
||||
maxZoom: 18,
|
||||
},
|
||||
{
|
||||
id: "waymarked-hiking",
|
||||
name: "Hiking Routes",
|
||||
url: "https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png",
|
||||
attribution: '© <a href="https://waymarkedtrails.org">Waymarked Trails</a> (CC-BY-SA)',
|
||||
maxZoom: 18,
|
||||
},
|
||||
{
|
||||
id: "waymarked-mtb",
|
||||
name: "MTB Routes",
|
||||
url: "https://tile.waymarkedtrails.org/mtb/{z}/{x}/{y}.png",
|
||||
attribution: '© <a href="https://waymarkedtrails.org">Waymarked Trails</a> (CC-BY-SA)',
|
||||
maxZoom: 18,
|
||||
},
|
||||
];
|
||||
|
||||
export const baseLayers: TileLayerConfig[] = [
|
||||
{
|
||||
name: "OpenStreetMap",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue