Archive the monolithic route-features spec and replace with 9 focused OpenSpec changes: multi-day-routes, waypoint-notes (with POI snapping), undo-redo, local-dev-stack, route-sharing, route-discovery, activity-photos, osm-overlays, plus the existing changelog and komoot-import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8.3 KiB
Context
The Planner currently has three base tile layers (OSM, OpenTopoMap, CyclOSM) in
packages/map/src/layers.ts, rendered via Leaflet's LayersControl. There are
no overlay layers and no POI display. brouter-web offers hillshading, Waymarked
Trails networks, and ~60 Overpass-powered POI categories — a model worth
adopting selectively.
The Planner is stateless (Yjs CRDT), collaborative, and uses BRouter for routing. Overlay state should sync across participants.
Goals / Non-Goals
Goals:
- Add tile-based overlays (hillshading, waymarked trails) to the layer switcher
- Add viewport-scoped POI overlays from Overpass API with per-category toggling
- Auto-suggest relevant overlays based on routing profile
- Build a reusable Overpass client shared with waypoint-notes POI snap
Non-Goals:
- Custom tile server or self-hosted overlays (use public tile services)
- Full brouter-web layer catalog (50+ layers, most country-specific)
- Offline/cached tile data
- Vector tile overlays (MVT) — stick with raster for now
- POI editing or contributing back to OSM
Decisions
D1: Tile overlay definitions
Add an overlayLayers export to packages/map/src/layers.ts alongside
existing baseLayers. Each overlay is a transparent tile layer rendered on top
of the base layer.
Initial overlays:
| Id | Name | URL | Attribution |
|---|---|---|---|
hillshading |
Hillshading | https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png via Leaflet.TileLayer.Terrarium or pre-rendered from https://tiles.wmflabs.org/hillshading/{z}/{x}/{y}.png |
SRTM/Mapzen |
waymarked-cycling |
Cycling Routes | https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png |
Waymarked Trails (CC-BY-SA) |
waymarked-hiking |
Hiking Routes | https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png |
Waymarked Trails (CC-BY-SA) |
waymarked-mtb |
MTB Routes | https://tile.waymarkedtrails.org/mtb/{z}/{x}/{y}.png |
Waymarked Trails (CC-BY-SA) |
These are all free, public tile endpoints used by brouter-web and other OSM tools. No API keys needed.
Alternative considered: Thunderforest Outdoors or OpenCycleMap — requires API key, limited free tier. Not worth the complexity.
D2: Overlay toggle in LayersControl
Leaflet's LayersControl already supports overlays natively via
LayersControl.Overlay (react-leaflet). Add overlay tile layers as checkboxes
alongside the existing base layer radio buttons. No custom UI needed for Phase 1.
D3: POI category system
Define POI categories as a typed configuration mapping OSM tags to display
properties. Inspired by brouter-web's layers/overpass/ structure but
simplified to the categories most relevant for route planning:
interface PoiCategory {
id: string;
name: string; // i18n key
icon: string; // emoji or SVG icon id
color: string; // marker color
query: string; // Overpass QL fragment, e.g. "nwr[amenity=drinking_water]"
profiles?: string[]; // routing profiles where this is auto-enabled
}
Initial categories (curated from brouter-web's full list):
| Category | POI types (OSM tags) | Icon | Auto-enable for |
|---|---|---|---|
| Drinking water | amenity=drinking_water, amenity=water_point |
💧 | all |
| Shelter | amenity=shelter, tourism=wilderness_hut |
🛖 | hiking |
| Camping | tourism=camp_site, tourism=caravan_site, tourism=picnic_site |
⛺ | all |
| Food & drink | amenity=restaurant, amenity=cafe, amenity=fast_food, amenity=pub, amenity=biergarten |
🍽️ | — |
| Groceries | shop=supermarket, shop=convenience, shop=bakery |
🛒 | — |
| Bike infrastructure | amenity=bicycle_parking, amenity=bicycle_repair_station, amenity=bicycle_rental |
🔧 | cycling |
| Accommodation | tourism=hotel, tourism=hostel, tourism=guest_house |
🏨 | — |
| Viewpoints | tourism=viewpoint |
👁️ | hiking |
| Toilets | amenity=toilets |
🚻 | — |
Not included (from brouter-web but too niche): ATMs, banks, benches, telephones, kneipp water cures, car parking, railway stations, art galleries, museums, ice cream shops, BBQs. Can be added later by extending the config.
D4: Overpass client
Create apps/planner/app/lib/overpass.ts with:
queryPois(bbox, categories): Promise<Poi[]>— builds Overpass QL query combining all enabled categories into one request (union query), returns parsed GeoJSON features- Bbox query:
[bbox:south,west,north,east]in Overpass QL, scoped to current Leaflet viewport - Endpoint:
https://overpass-api.de/api/interpreter(public, no key) - Response format: Request
[out:json]for easier parsing than XML - Deduplication: Overpass may return same node via multiple tags — dedup by OSM id
This client is also used by the waypoint-notes POI snap feature (smaller radius query around a single waypoint).
D5: POI caching and rate limiting
Overpass API is public and rate-limited. Must be respectful:
- Debounce: 500ms after map
moveendbefore querying - Abort: Cancel in-flight requests when viewport changes (AbortController)
- Tile-based caching: Quantize viewport to grid tiles (e.g., 0.1° cells), cache results per tile. Reuse cached tiles that overlap new viewport.
- TTL: 10 minutes for cached tiles (POI data changes slowly)
- Max concurrent: 1 request at a time
- 429 handling: Exponential backoff, disable auto-refresh temporarily, show "POI data unavailable" message
- Zoom threshold: Only query POIs at zoom >= 12 (avoids massive result sets at country-level zoom)
D6: POI overlay panel
A collapsible panel (not the LayersControl — too many items) for toggling POI categories. Positioned below the layer switcher on the right side of the map.
- Toggle button with POI icon to open/close
- Checkbox per category with icon and name
- "Loading..." indicator while Overpass query is in flight
- Category count badge showing number of visible POIs
- Panel state (which categories are enabled) synced via Yjs so all participants see the same POIs
D7: POI marker rendering
- Use Leaflet
L.MarkerwithL.DivIconfor each POI (not CircleMarker — need icons) - Icon shows the category emoji/icon at 20×20px
- Popup on click showing: name, category, opening hours (if available), website link (if available), OSM link
- Clustering: Use
leaflet.markerclusterat low zoom levels to avoid thousands of markers. Cluster by category color. - Z-index: POI markers below route and waypoint markers
D8: Profile-aware overlay defaults
When the routing profile changes (via Yjs routeOptions.profile), suggest
relevant overlays:
- Cycling profiles (cycling-safe, cycling-fast, etc.): Auto-enable Waymarked Cycling overlay + Bike infrastructure POIs
- Hiking profiles: Auto-enable Waymarked Hiking + Shelter + Viewpoints
- MTB profiles: Auto-enable Waymarked MTB + Bike infrastructure
"Auto-enable" means toggling on when profile changes, not forcing — users can still disable. Only auto-enable on profile change, not on page load (respect user's previous choice stored in Yjs).
D9: Yjs overlay state
Store enabled overlays in Yjs routeOptions Y.Map:
routeOptions.overlays = ["hillshading", "waymarked-cycling"]
routeOptions.poiCategories = ["drinking_water", "camping", "bike_infra"]
Array of string IDs. Changes sync to all participants. Persisted in crash recovery localStorage snapshot.
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. - 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).
- Overpass query cost: Combining many categories into one query is efficient but returns large payloads. → Only query enabled categories, not all.