Merge pull request #201 from trails-cool/feat/osm-overlays-final
Add markercluster, base layer sync, and tile overlay sync
This commit is contained in:
commit
508581635a
5 changed files with 173 additions and 13 deletions
|
|
@ -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<typeof usePois> }) {
|
||||
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<string[]>([]);
|
||||
const [selectedBaseLayer, setSelectedBaseLayer] = useState<string>(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
|
|||
)}
|
||||
<MapContainer center={[50.1, 10.0]} zoom={6} className="h-full w-full">
|
||||
<LayersControl position="topright">
|
||||
{baseLayers.map((layer, i) => (
|
||||
<LayersControl.BaseLayer key={layer.name} checked={i === 0} name={layer.name}>
|
||||
{baseLayers.map((layer) => (
|
||||
<LayersControl.BaseLayer key={layer.name} checked={layer.name === selectedBaseLayer} name={layer.name}>
|
||||
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} />
|
||||
</LayersControl.BaseLayer>
|
||||
))}
|
||||
{overlayLayers.map((layer) => (
|
||||
<LayersControl.Overlay key={layer.id} name={layer.name}>
|
||||
<LayersControl.Overlay key={layer.id} checked={enabledOverlays.includes(layer.id)} name={layer.name}>
|
||||
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} opacity={layer.opacity ?? 0.7} />
|
||||
</LayersControl.Overlay>
|
||||
))}
|
||||
</LayersControl>
|
||||
|
||||
<MapExposer />
|
||||
<OverlaySync yjs={yjs} onOverlayChange={setEnabledOverlays} onBaseLayerChange={setSelectedBaseLayer} />
|
||||
<RouteFitter coordinates={routeCoordinates} />
|
||||
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} suppressRef={suppressMapClickRef} />
|
||||
<CursorTracker awareness={yjs.awareness} />
|
||||
|
|
|
|||
|
|
@ -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>(L.layerGroup());
|
||||
const layerRef = useRef<L.LayerGroup | null>(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]));
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue