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:
@@ -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='© <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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user