From 7c68e1400bcc86272b61b132226178960f8d1ca9 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 13 Apr 2026 15:53:58 +0200 Subject: [PATCH] fix: map view freezing browser with thousands of markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace DivIcon thumbnail markers with lightweight CircleMarkers. Each DivIcon created a DOM element with an 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) --- frontend/src/components/map/MapView.tsx | 111 ++++++------------------ 1 file changed, 28 insertions(+), 83 deletions(-) diff --git a/frontend/src/components/map/MapView.tsx b/frontend/src/components/map/MapView.tsx index bbec2dd..a72238e 100644 --- a/frontend/src/components/map/MapView.tsx +++ b/frontend/src/components/map/MapView.tsx @@ -1,9 +1,6 @@ import { useEffect, useMemo, useRef } from 'react' import { useQuery } from '@tanstack/react-query' -import { MapContainer, TileLayer, Marker, 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 +import { MapContainer, TileLayer, CircleMarker, useMap } from 'react-leaflet' // @ts-ignore — package ships JS with no bundled types import MarkerClusterGroup from 'react-leaflet-cluster' import L from 'leaflet' @@ -18,24 +15,8 @@ interface MapPoint { 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: `
`, - iconSize: [56, 56], - iconAnchor: [28, 28], - popupAnchor: [0, -28], - }) -} - /** 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 - * alone — they're probably mid-investigation. */ + * arrive. Subsequent loads leave the user's pan alone. */ function FitBoundsOnce({ points }: { points: MapPoint[] }) { const map = useMap() const fittedRef = useRef(false) @@ -52,25 +33,10 @@ export function MapView() { const { data: points = [], isLoading, error } = useQuery({ queryKey: ['photos', 'map'], 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) - - // 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() - for (const p of points) map.set(p.id, buildPhotoIcon(p.id)) - return map - }, [points]) - const visibleSequence = useMemo(() => points.map((p) => p.id), [points]) if (isLoading) { @@ -116,54 +82,33 @@ export function MapView() { url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> - - {points.map((p) => { - const icon = iconsById.get(p.id) - if (!icon) return null - return ( - openPreview(p.id, visibleSequence)} - /> - ) - })} + + {points.map((p) => ( + openPreview(p.id, visibleSequence), + }} + /> + ))} - - ) } - -function PhotoMarker({ - point, - icon, - onClick, -}: { - point: MapPoint - icon: L.DivIcon - onClick: () => void -}) { - return ( - - ) -}