diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index ecdc8dc..6f05030 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -20,6 +20,22 @@ import { RouteInteraction } from "./RouteInteraction"; import { PoiPanel, PoiMarkers } from "./PoiPanel"; import "leaflet/dist/leaflet.css"; +/** Distance from a point to a line segment in degrees (approximate) */ +function pointToSegmentDist( + pLat: number, pLon: number, + aLat: number, aLon: number, + bLat: number, bLon: number, +): number { + const dx = bLon - aLon; + const dy = bLat - aLat; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.sqrt((pLon - aLon) ** 2 + (pLat - aLat) ** 2); + const t = Math.max(0, Math.min(1, ((pLon - aLon) * dx + (pLat - aLat) * dy) / lenSq)); + const projLon = aLon + t * dx; + const projLat = aLat + t * dy; + return Math.sqrt((pLon - projLon) ** 2 + (pLat - projLat) ** 2); +} + function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean): L.DivIcon { const bg = overnight ? "#8B6D3A" : "#2563eb"; const scale = highlighted ? "scale(1.17)" : "scale(1)"; @@ -237,6 +253,74 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v ); } +function OverlaySync({ yjs, onOverlayChange, onBaseLayerChange }: { yjs: YjsState; onOverlayChange: (ids: string[]) => void; onBaseLayerChange: (name: string) => void }) { + const map = useMap(); + const suppressRef = useRef(false); + + // Map events → Yjs + useEffect(() => { + const handleAdd = (e: L.LayersControlEvent) => { + if (suppressRef.current) return; + const name = e.name; + const layer = overlayLayers.find((l) => l.name === name); + if (!layer) return; + const raw = yjs.routeData.get("overlays") as string | undefined; + const current: string[] = raw ? JSON.parse(raw) : []; + if (!current.includes(layer.id)) { + const updated = [...current, layer.id]; + yjs.routeData.set("overlays", JSON.stringify(updated)); + onOverlayChange(updated); + } + }; + + const handleRemove = (e: L.LayersControlEvent) => { + if (suppressRef.current) return; + const name = e.name; + const layer = overlayLayers.find((l) => l.name === name); + if (!layer) return; + const raw = yjs.routeData.get("overlays") as string | undefined; + const current: string[] = raw ? JSON.parse(raw) : []; + const updated = current.filter((id) => id !== layer.id); + yjs.routeData.set("overlays", JSON.stringify(updated)); + onOverlayChange(updated); + }; + + // Base layer change → Yjs + const handleBaseChange = (e: L.LayersControlEvent) => { + if (suppressRef.current) return; + yjs.routeData.set("baseLayer", e.name); + }; + + map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn); + map.on("overlayremove", handleRemove as L.LeafletEventHandlerFn); + map.on("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn); + return () => { + map.off("overlayadd", handleAdd as L.LeafletEventHandlerFn); + map.off("overlayremove", handleRemove as L.LeafletEventHandlerFn); + map.off("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn); + }; + }, [map, yjs, onOverlayChange]); + + // Yjs → Map: load initial state from Yjs + useEffect(() => { + const handleChange = () => { + const raw = yjs.routeData.get("overlays") as string | undefined; + if (raw) { + try { + onOverlayChange(JSON.parse(raw)); + } catch { /* ignore */ } + } + const base = yjs.routeData.get("baseLayer") as string | undefined; + if (base) onBaseLayerChange(base); + }; + yjs.routeData.observe(handleChange); + handleChange(); + return () => yjs.routeData.unobserve(handleChange); + }, [yjs, onOverlayChange, onBaseLayerChange]); + + return null; +} + function PoiRefresher({ poiState }: { poiState: ReturnType }) { const map = useMap(); const refreshRef = useRef(poiState.refresh); @@ -282,6 +366,8 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted const poiState = usePois(); useProfileDefaults(yjs, poiState); useYjsPoiSync(yjs, poiState); + const [enabledOverlays, setEnabledOverlays] = useState([]); + const [selectedBaseLayer, setSelectedBaseLayer] = useState(baseLayers[0]!.name); const [draggingOver, setDraggingOver] = useState(false); const dragCounterRef = useRef(0); const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null); @@ -368,18 +454,47 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted const addWaypoint = useCallback( (lat: number, lng: number, name?: string) => { const snap = snapToPoi(lat, lng, poiState.pois); + const finalLat = snap.lat; + const finalLon = snap.snapped ? snap.lon : lng; + + // Find the best insertion index: if the point is near the route, + // insert between the closest segment's waypoints instead of appending + let insertIndex = yjs.waypoints.length; // default: append + if (routeCoordinates && routeCoordinates.length >= 2 && segmentBoundaries.length > 0) { + let bestDist = Infinity; + let bestSegment = -1; + // For each segment, find the closest point on the route + for (let seg = 0; seg < segmentBoundaries.length; seg++) { + const start = segmentBoundaries[seg]!; + const end = seg + 1 < segmentBoundaries.length ? segmentBoundaries[seg + 1]! : routeCoordinates.length; + for (let i = start; i < end - 1; i++) { + const c1 = routeCoordinates[i]!; + const c2 = routeCoordinates[i + 1]!; + const dist = pointToSegmentDist(finalLat, finalLon, c1[1]!, c1[0]!, c2[1]!, c2[0]!); + if (dist < bestDist) { + bestDist = dist; + bestSegment = seg; + } + } + } + // If within ~1km of the route, insert after the segment's waypoint + if (bestDist < 0.01 && bestSegment >= 0) { + insertIndex = bestSegment + 1; + } + } + yjs.doc.transact(() => { const yMap = new Y.Map(); - yMap.set("lat", snap.lat); - yMap.set("lon", snap.snapped ? snap.lon : lng); + yMap.set("lat", finalLat); + yMap.set("lon", finalLon); if (snap.name) yMap.set("name", snap.name); - else if (name) yMap.set("name", name); // fallback for explicit name + else if (name) yMap.set("name", name); if (snap.osmId) yMap.set("osmId", snap.osmId); if (snap.poiTags) yMap.set("poiTags", snap.poiTags); - yjs.waypoints.push([yMap]); + yjs.waypoints.insert(insertIndex, [yMap]); }, "local"); }, - [yjs.doc, yjs.waypoints, poiState.pois], + [yjs.doc, yjs.waypoints, poiState.pois, routeCoordinates, segmentBoundaries], ); const insertWaypointAtSegment = useCallback( @@ -520,19 +635,20 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted )} - {baseLayers.map((layer, i) => ( - + {baseLayers.map((layer) => ( + ))} {overlayLayers.map((layer) => ( - + ))} + {} : addWaypoint} suppressRef={suppressMapClickRef} /> diff --git a/apps/planner/app/components/PoiPanel.tsx b/apps/planner/app/components/PoiPanel.tsx index e2f04bb..93c11de 100644 --- a/apps/planner/app/components/PoiPanel.tsx +++ b/apps/planner/app/components/PoiPanel.tsx @@ -5,6 +5,8 @@ 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"; +import "leaflet.markercluster/dist/MarkerCluster.css"; +import "leaflet.markercluster/dist/MarkerCluster.Default.css"; interface PoiPanelProps { poiState: PoiState; @@ -82,11 +84,28 @@ export function PoiPanel({ poiState }: PoiPanelProps) { export function PoiMarkers({ poiState, onAddWaypoint }: PoiPanelProps) { const map = useMap(); - const layerRef = useRef(L.layerGroup()); + const layerRef = useRef(null); useEffect(() => { - layerRef.current.addTo(map); - return () => { layerRef.current.remove(); }; + let mounted = true; + // Dynamic import to avoid bundling markercluster when POIs aren't used + import("leaflet.markercluster").then(() => { + if (!mounted) return; + // After import, L.markerClusterGroup is available + const cluster = (L as unknown as { markerClusterGroup: (opts?: object) => L.LayerGroup }).markerClusterGroup({ + maxClusterRadius: 40, + disableClusteringAtZoom: 15, + showCoverageOnHover: false, + }); + layerRef.current = cluster; + cluster.addTo(map); + }).catch(() => { + // Fallback to plain layer group if markercluster fails to load + if (!mounted) return; + layerRef.current = L.layerGroup(); + layerRef.current.addTo(map); + }); + return () => { mounted = false; layerRef.current?.remove(); }; }, [map]); // Event delegation for "Add as waypoint" buttons in popups @@ -107,6 +126,7 @@ export function PoiMarkers({ poiState, onAddWaypoint }: PoiPanelProps) { useEffect(() => { const group = layerRef.current; + if (!group) return; group.clearLayers(); const catMap = new Map(poiCategories.map((c) => [c.id, c])); diff --git a/apps/planner/package.json b/apps/planner/package.json index cb08be8..ffd30d4 100644 --- a/apps/planner/package.json +++ b/apps/planner/package.json @@ -38,9 +38,11 @@ "devDependencies": { "@react-router/dev": "catalog:", "@tailwindcss/vite": "catalog:", + "@types/leaflet.markercluster": "^1.5.6", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@types/ws": "^8.18.1", + "leaflet.markercluster": "^1.5.3", "pino-pretty": "^13.1.3", "tailwindcss": "catalog:", "typescript": "catalog:", diff --git a/openspec/changes/osm-overlays/tasks.md b/openspec/changes/osm-overlays/tasks.md index f98e686..65d503a 100644 --- a/openspec/changes/osm-overlays/tasks.md +++ b/openspec/changes/osm-overlays/tasks.md @@ -6,7 +6,7 @@ ## 2. Overlay State Sync -- [ ] 2.1 Add `overlays` string array to Yjs `routeOptions` Y.Map for enabled tile overlay IDs (deferred — tile overlays are visual-only) +- [x] 2.1 Add `overlays` string array to Yjs `routeOptions` Y.Map for enabled tile overlay IDs - [x] 2.2 Add `poiCategories` string array to Yjs `routeOptions` Y.Map for enabled POI category IDs - [x] 2.3 Sync LayersControl state with Yjs — toggling overlay updates Yjs, Yjs changes toggle layers - [x] 2.4 Include overlay state in crash recovery localStorage snapshot @@ -42,7 +42,7 @@ - [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) +- [x] 7.3 Add `leaflet.markercluster` for clustering dense POI areas (dynamic import to avoid bundle bloat) - [x] 7.4 Set z-index so POI markers render below route polyline and waypoint markers ## 8. Profile-Aware Defaults diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aad64aa..ddc9997 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,6 +361,9 @@ importers: '@tailwindcss/vite': specifier: 'catalog:' version: 4.2.2(vite@8.0.8(@types/node@25.5.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + '@types/leaflet.markercluster': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: 'catalog:' version: 19.2.14 @@ -370,6 +373,9 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + leaflet.markercluster: + specifier: ^1.5.3 + version: 1.5.3(leaflet@1.9.4) pino-pretty: specifier: ^13.1.3 version: 13.1.3 @@ -2094,6 +2100,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/leaflet.markercluster@1.5.6': + resolution: {integrity: sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==} + '@types/leaflet@1.9.21': resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} @@ -2992,6 +3001,11 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + leaflet.markercluster@1.5.3: + resolution: {integrity: sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==} + peerDependencies: + leaflet: ^1.3.1 + leaflet@1.9.4: resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} @@ -5598,6 +5612,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/leaflet.markercluster@1.5.6': + dependencies: + '@types/leaflet': 1.9.21 + '@types/leaflet@1.9.21': dependencies: '@types/geojson': 7946.0.16 @@ -6542,6 +6560,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + leaflet.markercluster@1.5.3(leaflet@1.9.4): + dependencies: + leaflet: 1.9.4 + leaflet@1.9.4: {} levn@0.4.1: