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:
Ullrich Schäfer 2026-04-11 02:25:55 +02:00 committed by GitHub
commit 90918221d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1297 additions and 67 deletions

View file

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

View 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, "&quot;")}" 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;
}

View file

@ -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 = () => {

View file

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

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

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

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

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

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

View 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"],
};

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

View file

@ -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(() => {

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: '&copy; <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: '&copy; <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: '&copy; <a href="https://waymarkedtrails.org">Waymarked Trails</a> (CC-BY-SA)',
maxZoom: 18,
},
];
export const baseLayers: TileLayerConfig[] = [
{
name: "OpenStreetMap",