From 6aa2229b397ea737fc78e1366bb72c4855ceb183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 01:17:41 +0200 Subject: [PATCH] Add POI overlay panel, markers, and map integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PoiPanel: Collapsible panel with category checkboxes, count badges, loading/error/zoom-too-low states - PoiMarkers: L.Marker with L.DivIcon per POI, click popup with name, hours, website, OSM link. z-index below route/waypoints. - PoiRefresher: Listens to map moveend, refreshes POIs via usePois hook - Wired into PlannerMap alongside existing controls Skipped markercluster (7.3) — can add later if density is an issue. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/PlannerMap.tsx | 26 +++++ apps/planner/app/components/PoiPanel.tsx | 128 +++++++++++++++++++++ apps/planner/app/lib/use-pois.ts | 2 +- openspec/changes/osm-overlays/tasks.md | 14 +-- 4 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 apps/planner/app/components/PoiPanel.tsx diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 5632fa6..316a9e6 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -9,9 +9,11 @@ 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 { 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 { @@ -231,9 +233,30 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v ); } +function PoiRefresher({ poiState }: { poiState: ReturnType }) { + const map = useMap(); + useEffect(() => { + const refresh = () => { + const bounds = map.getBounds(); + const zoom = map.getZoom(); + poiState.refresh({ + south: bounds.getSouth(), + west: bounds.getWest(), + north: bounds.getNorth(), + east: bounds.getEast(), + }, zoom); + }; + map.on("moveend", refresh); + refresh(); + return () => { map.off("moveend", refresh); }; + }, [map, poiState.refresh]); + return null; +} + export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlightedWaypoint, onImportError, days }: PlannerMapProps) { const { t } = useTranslation("planner"); const [waypoints, setWaypoints] = useState([]); + const poiState = usePois(); const [draggingOver, setDraggingOver] = useState(false); const dragCounterRef = useRef(0); const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null); @@ -468,6 +491,9 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted + + + {waypoints.map((wp, i) => ( (null); + + useEffect(() => { + if (ref.current) L.DomEvent.disableClickPropagation(ref.current); + }, []); + + const countByCategory = new Map(); + for (const poi of poiState.pois) { + countByCategory.set(poi.category, (countByCategory.get(poi.category) ?? 0) + 1); + } + + return ( +
+
+ + {open && ( +
+

{t("poi.title")}

+ + {poiState.status === "zoom_too_low" && ( +

{t("poi.zoomIn")}

+ )} + {poiState.status === "rate_limited" && ( +

{t("poi.rateLimited")}

+ )} + {poiState.status === "error" && ( +

{t("poi.error")}

+ )} + {poiState.status === "loading" && ( +

{t("poi.loading")}

+ )} + +
+ {poiCategories.map((cat) => { + const count = countByCategory.get(cat.id) ?? 0; + const enabled = poiState.enabledCategories.includes(cat.id); + return ( + + ); + })} +
+
+ )} +
+
+ ); +} + +export function PoiMarkers({ poiState }: PoiPanelProps) { + const map = useMap(); + const layerRef = useRef(L.layerGroup()); + + useEffect(() => { + layerRef.current.addTo(map); + return () => { layerRef.current.remove(); }; + }, [map]); + + 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: `
${cat.icon}
`, + iconSize: [0, 0], + }), + zIndexOffset: -1000, + }); + + const popupLines = [`${poi.name ?? cat.icon + " " + poi.category}`]; + if (poi.tags.opening_hours) popupLines.push(`🕐 ${poi.tags.opening_hours}`); + if (poi.tags.website) popupLines.push(`Website`); + popupLines.push(`OSM`); + + marker.bindPopup(popupLines.join("
"), { maxWidth: 200 }); + group.addLayer(marker); + } + }, [poiState.pois]); + + return null; +} diff --git a/apps/planner/app/lib/use-pois.ts b/apps/planner/app/lib/use-pois.ts index de421b6..a64f129 100644 --- a/apps/planner/app/lib/use-pois.ts +++ b/apps/planner/app/lib/use-pois.ts @@ -1,7 +1,7 @@ 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, type PoiCategory } from "./poi-categories.ts"; +import { poiCategories } from "./poi-categories.ts"; const MIN_ZOOM = 12; const DEBOUNCE_MS = 500; diff --git a/openspec/changes/osm-overlays/tasks.md b/openspec/changes/osm-overlays/tasks.md index 722c4fc..bdea449 100644 --- a/openspec/changes/osm-overlays/tasks.md +++ b/openspec/changes/osm-overlays/tasks.md @@ -33,17 +33,17 @@ ## 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