fix: map view freezing browser with thousands of markers
Replace DivIcon thumbnail markers with lightweight CircleMarkers. Each DivIcon created a DOM element with an <img> tag, so 3K+ geotagged photos meant 3K DOM nodes and 3K thumbnail requests hitting simultaneously — freezing the browser during clustering. CircleMarkers are SVG-rendered on Leaflet's canvas layer with no DOM nodes per marker. Photos still open in preview on click. Also: bump cluster radius 50→80, enable removeOutsideVisibleBounds, disable clustering at max zoom, increase staleTime to 5 min. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +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, Marker, useMap } from 'react-leaflet'
|
import { MapContainer, TileLayer, CircleMarker, useMap } from 'react-leaflet'
|
||||||
// react-leaflet-cluster has no own .d.ts that survives strict mode in
|
|
||||||
// every project, so we let TS infer from its runtime export.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore — package ships JS with no bundled types
|
// @ts-ignore — package ships JS with no bundled types
|
||||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
@@ -18,24 +15,8 @@ interface MapPoint {
|
|||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the divIcon used for each photo marker. The HTML is a tiny
|
|
||||||
* square thumbnail with a white border + drop shadow so it reads on
|
|
||||||
* any tile background. Memoised per-photo so we don't re-create the
|
|
||||||
* L.DivIcon on every re-render. */
|
|
||||||
function buildPhotoIcon(photoId: string): L.DivIcon {
|
|
||||||
const url = photosApi.getThumbnailUrl(photoId, 'small')
|
|
||||||
return L.divIcon({
|
|
||||||
className: 'photo-map-marker',
|
|
||||||
html: `<div class="pmm-frame"><img src="${url}" loading="lazy" alt="" /></div>`,
|
|
||||||
iconSize: [56, 56],
|
|
||||||
iconAnchor: [28, 28],
|
|
||||||
popupAnchor: [0, -28],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pans/zooms the map to fit the supplied points the first time they
|
/** Pans/zooms the map to fit the supplied points the first time they
|
||||||
* arrive. Subsequent loads (e.g. cache refresh) leave the user's pan
|
* arrive. Subsequent loads leave the user's pan alone. */
|
||||||
* alone — they're probably mid-investigation. */
|
|
||||||
function FitBoundsOnce({ points }: { points: MapPoint[] }) {
|
function FitBoundsOnce({ points }: { points: MapPoint[] }) {
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
const fittedRef = useRef(false)
|
const fittedRef = useRef(false)
|
||||||
@@ -52,25 +33,10 @@ export function MapView() {
|
|||||||
const { data: points = [], isLoading, error } = useQuery({
|
const { data: points = [], isLoading, error } = useQuery({
|
||||||
queryKey: ['photos', 'map'],
|
queryKey: ['photos', 'map'],
|
||||||
queryFn: () => photosApi.mapPoints(),
|
queryFn: () => photosApi.mapPoints(),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Marker click hands off to the same PreviewView the timeline uses, so
|
|
||||||
// the user gets the full preview UI (large image, info panel with
|
|
||||||
// location, filmstrip nav between map photos) instead of a bespoke
|
|
||||||
// map-only lightbox. We pass the map's own point order as the visible
|
|
||||||
// sequence so left/right arrows step through neighboring markers.
|
|
||||||
const openPreview = usePhotoStore((s) => s.openPreview)
|
const openPreview = usePhotoStore((s) => s.openPreview)
|
||||||
|
|
||||||
// Stable per-marker icons. Re-created only when the set of point ids
|
|
||||||
// changes — the underlying L.DivIcon objects are pure HTML so reusing
|
|
||||||
// them is safe across re-renders.
|
|
||||||
const iconsById = useMemo(() => {
|
|
||||||
const map = new Map<string, L.DivIcon>()
|
|
||||||
for (const p of points) map.set(p.id, buildPhotoIcon(p.id))
|
|
||||||
return map
|
|
||||||
}, [points])
|
|
||||||
|
|
||||||
const visibleSequence = useMemo(() => points.map((p) => p.id), [points])
|
const visibleSequence = useMemo(() => points.map((p) => p.id), [points])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -116,54 +82,33 @@ export function MapView() {
|
|||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
<FitBoundsOnce points={points} />
|
<FitBoundsOnce points={points} />
|
||||||
<MarkerClusterGroup chunkedLoading maxClusterRadius={50}>
|
<MarkerClusterGroup
|
||||||
{points.map((p) => {
|
chunkedLoading
|
||||||
const icon = iconsById.get(p.id)
|
maxClusterRadius={80}
|
||||||
if (!icon) return null
|
disableClusteringAtZoom={18}
|
||||||
return (
|
spiderfyOnMaxZoom={false}
|
||||||
<PhotoMarker
|
showCoverageOnHover={false}
|
||||||
key={p.id}
|
removeOutsideVisibleBounds
|
||||||
point={p}
|
>
|
||||||
icon={icon}
|
{points.map((p) => (
|
||||||
onClick={() => openPreview(p.id, visibleSequence)}
|
<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>
|
</MarkerClusterGroup>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
<style>{`
|
|
||||||
.photo-map-marker { background: transparent; border: none; }
|
|
||||||
.photo-map-marker .pmm-frame {
|
|
||||||
width: 56px; height: 56px;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 2px solid white;
|
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
|
||||||
background: #1f2937;
|
|
||||||
}
|
|
||||||
.photo-map-marker .pmm-frame img {
|
|
||||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PhotoMarker({
|
|
||||||
point,
|
|
||||||
icon,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
point: MapPoint
|
|
||||||
icon: L.DivIcon
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Marker
|
|
||||||
position={[point.latitude, point.longitude]}
|
|
||||||
icon={icon}
|
|
||||||
eventHandlers={{ click: onClick }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user