perf: native canvas markers for map view, drop clustering library

Replace 3K+ React CircleMarker components + MarkerClusterGroup with
native Leaflet L.circleMarker on a shared L.canvas() renderer added
in a single useEffect. Zero React components per marker — canvas
draws all points in one paint (<50ms vs multi-second freeze).

Also drops react-leaflet-cluster from the bundle (-46KB gzipped).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-13 16:17:32 +02:00
parent 7c68e1400b
commit 800ee447ad

View File

@@ -1,8 +1,6 @@
import { useEffect, useMemo, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { MapContainer, TileLayer, CircleMarker, useMap } from 'react-leaflet'
// @ts-ignore — package ships JS with no bundled types
import MarkerClusterGroup from 'react-leaflet-cluster'
import { MapContainer, TileLayer, useMap } from 'react-leaflet'
import L from 'leaflet'
import { photos as photosApi } from '../../services/api'
import { usePhotoStore } from '../../store/photoStore'
@@ -15,17 +13,65 @@ interface MapPoint {
taken_at: string | null
}
/** Pans/zooms the map to fit the supplied points the first time they
* arrive. Subsequent loads leave the user's pan alone. */
function FitBoundsOnce({ points }: { points: MapPoint[] }) {
/** Adds all points as native Leaflet circleMarkers on a single canvas
* layer — no React components per marker, no clustering library.
* 3K+ markers render in <50ms on canvas. */
function CanvasMarkers({
points,
onClickId,
}: {
points: MapPoint[]
onClickId: (id: string) => void
}) {
const map = useMap()
const layerRef = useRef<L.LayerGroup | null>(null)
const fittedRef = useRef(false)
useEffect(() => {
if (fittedRef.current || points.length === 0) return
const bounds = L.latLngBounds(points.map((p) => [p.latitude, p.longitude]))
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 })
fittedRef.current = true
}, [points, map])
if (!map || points.length === 0) return
// Remove previous layer if data changed.
if (layerRef.current) {
map.removeLayer(layerRef.current)
}
const group = L.layerGroup()
const renderer = L.canvas({ padding: 0.5 })
for (const p of points) {
const marker = L.circleMarker([p.latitude, p.longitude], {
renderer,
radius: 5,
fillColor: '#3b82f6',
fillOpacity: 0.85,
color: '#ffffff',
weight: 1.5,
opacity: 0.9,
})
marker.on('click', () => onClickId(p.id))
group.addLayer(marker)
}
group.addTo(map)
layerRef.current = group
// Fit bounds once on first data load.
if (!fittedRef.current) {
const bounds = L.latLngBounds(
points.map((p) => [p.latitude, p.longitude] as [number, number])
)
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 })
fittedRef.current = true
}
return () => {
if (layerRef.current) {
map.removeLayer(layerRef.current)
layerRef.current = null
}
}
}, [map, points, onClickId])
return null
}
@@ -39,6 +85,11 @@ export function MapView() {
const openPreview = usePhotoStore((s) => s.openPreview)
const visibleSequence = useMemo(() => points.map((p) => p.id), [points])
const handleClick = useMemo(
() => (id: string) => openPreview(id, visibleSequence),
[openPreview, visibleSequence],
)
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
@@ -75,39 +126,14 @@ export function MapView() {
zoom={2}
minZoom={2}
worldCopyJump
preferCanvas
style={{ height: '100%', width: '100%' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FitBoundsOnce points={points} />
<MarkerClusterGroup
chunkedLoading
maxClusterRadius={80}
disableClusteringAtZoom={18}
spiderfyOnMaxZoom={false}
showCoverageOnHover={false}
removeOutsideVisibleBounds
>
{points.map((p) => (
<CircleMarker
key={p.id}
center={[p.latitude, p.longitude]}
radius={6}
pathOptions={{
fillColor: '#3b82f6',
fillOpacity: 0.8,
color: '#ffffff',
weight: 2,
opacity: 0.9,
}}
eventHandlers={{
click: () => openPreview(p.id, visibleSequence),
}}
/>
))}
</MarkerClusterGroup>
<CanvasMarkers points={points} onClickId={handleClick} />
</MapContainer>
</div>
)