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 { useEffect, useMemo, useRef } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { MapContainer, TileLayer, CircleMarker, useMap } from 'react-leaflet'
|
import { MapContainer, TileLayer, useMap } from 'react-leaflet'
|
||||||
// @ts-ignore — package ships JS with no bundled types
|
|
||||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
@@ -15,17 +13,65 @@ interface MapPoint {
|
|||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pans/zooms the map to fit the supplied points the first time they
|
/** Adds all points as native Leaflet circleMarkers on a single canvas
|
||||||
* arrive. Subsequent loads leave the user's pan alone. */
|
* layer — no React components per marker, no clustering library.
|
||||||
function FitBoundsOnce({ points }: { points: MapPoint[] }) {
|
* 3K+ markers render in <50ms on canvas. */
|
||||||
|
function CanvasMarkers({
|
||||||
|
points,
|
||||||
|
onClickId,
|
||||||
|
}: {
|
||||||
|
points: MapPoint[]
|
||||||
|
onClickId: (id: string) => void
|
||||||
|
}) {
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
|
const layerRef = useRef<L.LayerGroup | null>(null)
|
||||||
const fittedRef = useRef(false)
|
const fittedRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fittedRef.current || points.length === 0) return
|
if (!map || points.length === 0) return
|
||||||
const bounds = L.latLngBounds(points.map((p) => [p.latitude, p.longitude]))
|
|
||||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 })
|
// Remove previous layer if data changed.
|
||||||
fittedRef.current = true
|
if (layerRef.current) {
|
||||||
}, [points, map])
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +85,11 @@ export function MapView() {
|
|||||||
const openPreview = usePhotoStore((s) => s.openPreview)
|
const openPreview = usePhotoStore((s) => s.openPreview)
|
||||||
const visibleSequence = useMemo(() => points.map((p) => p.id), [points])
|
const visibleSequence = useMemo(() => points.map((p) => p.id), [points])
|
||||||
|
|
||||||
|
const handleClick = useMemo(
|
||||||
|
() => (id: string) => openPreview(id, visibleSequence),
|
||||||
|
[openPreview, visibleSequence],
|
||||||
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
@@ -75,39 +126,14 @@ export function MapView() {
|
|||||||
zoom={2}
|
zoom={2}
|
||||||
minZoom={2}
|
minZoom={2}
|
||||||
worldCopyJump
|
worldCopyJump
|
||||||
|
preferCanvas
|
||||||
style={{ height: '100%', width: '100%' }}
|
style={{ height: '100%', width: '100%' }}
|
||||||
>
|
>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
<FitBoundsOnce points={points} />
|
<CanvasMarkers points={points} onClickId={handleClick} />
|
||||||
<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>
|
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user