Add markercluster for POIs and sync base layer via Yjs

- leaflet.markercluster: Dynamic import with fallback to plain layer
  group. Clusters POI markers at low zoom, ungroups at zoom 15+.
- Base layer sync: baselayerchange event writes to Yjs, new
  participants load the selected base layer on connect.
- Both tile overlays and base layer now persist across participants
  and crash recovery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-11 02:35:32 +02:00
parent e158205f32
commit ba8a1bbaeb
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
5 changed files with 69 additions and 14 deletions

View file

@ -237,7 +237,7 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v
);
}
function OverlaySync({ yjs, onOverlayChange }: { yjs: YjsState; onOverlayChange: (ids: string[]) => void }) {
function OverlaySync({ yjs, onOverlayChange, onBaseLayerChange }: { yjs: YjsState; onOverlayChange: (ids: string[]) => void; onBaseLayerChange: (name: string) => void }) {
const map = useMap();
const suppressRef = useRef(false);
@ -269,11 +269,19 @@ function OverlaySync({ yjs, onOverlayChange }: { yjs: YjsState; onOverlayChange:
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]);
@ -281,16 +289,18 @@ function OverlaySync({ yjs, onOverlayChange }: { yjs: YjsState; onOverlayChange:
useEffect(() => {
const handleChange = () => {
const raw = yjs.routeData.get("overlays") as string | undefined;
if (!raw) return;
try {
const ids: string[] = JSON.parse(raw);
onOverlayChange(ids);
} catch { /* ignore */ }
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]);
}, [yjs, onOverlayChange, onBaseLayerChange]);
return null;
}
@ -341,6 +351,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
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);
@ -579,8 +590,8 @@ 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>
))}
@ -592,7 +603,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
</LayersControl>
<MapExposer />
<OverlaySync yjs={yjs} onOverlayChange={setEnabledOverlays} />
<OverlaySync yjs={yjs} onOverlayChange={setEnabledOverlays} onBaseLayerChange={setSelectedBaseLayer} />
<RouteFitter coordinates={routeCoordinates} />
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} suppressRef={suppressMapClickRef} />
<CursorTracker awareness={yjs.awareness} />

View file

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

View file

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

View file

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

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