diff --git a/src/map/loadedRegions.test.ts b/src/map/loadedRegions.test.ts new file mode 100644 index 0000000..51e53ee --- /dev/null +++ b/src/map/loadedRegions.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import L from 'leaflet' +import { addLoadedRegion, isRegionLoaded, MAX_LOADED_REGIONS } from './loadedRegions' + +const box = (s: number, w: number, n: number, e: number) => L.latLngBounds([s, w], [n, e]) + +describe('loadedRegions', () => { + it('reports nothing loaded for null', () => { + expect(isRegionLoaded(null, box(0, 0, 1, 1))).toBe(false) + }) + + it('skips a viewport fully inside a loaded region', () => { + const regions = addLoadedRegion(null, box(40, -75, 42, -73)) + expect(isRegionLoaded(regions, box(40.5, -74.5, 41, -74))).toBe(true) + }) + + it('does not treat the gap between two distant loads as loaded', () => { + let regions = addLoadedRegion(null, box(40, -75, 41, -73)) // NYC-ish + regions = addLoadedRegion(regions, box(33, -119, 35, -117)) // LA-ish + expect(isRegionLoaded(regions, box(41, -88, 42, -87))).toBe(false) // Chicago-ish + expect(isRegionLoaded(regions, box(40.2, -74.5, 40.8, -73.5))).toBe(true) + expect(isRegionLoaded(regions, box(33.5, -118.5, 34.5, -117.5))).toBe(true) + }) + + it('does not mutate the previous list', () => { + const first = addLoadedRegion(null, box(0, 0, 1, 1)) + addLoadedRegion(first, box(5, 5, 6, 6)) + expect(first).toHaveLength(1) + }) + + it('drops regions fully covered by a new, larger region', () => { + let regions = addLoadedRegion(null, box(0, 0, 1, 1)) + regions = addLoadedRegion(regions, box(2, 2, 3, 3)) + regions = addLoadedRegion(regions, box(-1, -1, 1.5, 1.5)) + expect(regions).toHaveLength(2) + }) + + it('caps the list, evicting the oldest region', () => { + let regions = addLoadedRegion(null, box(0, 0, 0.5, 0.5)) + for (let i = 1; i <= MAX_LOADED_REGIONS; i++) regions = addLoadedRegion(regions, box(i, i, i + 0.5, i + 0.5)) + expect(regions).toHaveLength(MAX_LOADED_REGIONS) + expect(isRegionLoaded(regions, box(0.1, 0.1, 0.2, 0.2))).toBe(false) + expect(isRegionLoaded(regions, box(1.1, 1.1, 1.2, 1.2))).toBe(true) + }) +}) diff --git a/src/map/loadedRegions.ts b/src/map/loadedRegions.ts new file mode 100644 index 0000000..8228b70 --- /dev/null +++ b/src/map/loadedRegions.ts @@ -0,0 +1,24 @@ +import type { LatLngBounds } from 'leaflet' + +// Tracks which map areas a viewport-driven layer has already fetched. +// +// A single LatLngBounds grown with `extend` is wrong here: loading two distant +// areas produces one rectangle that also covers everything between them, so +// panning into that gap is treated as "already loaded" and never fetched. +// Instead we keep the individual loaded boxes and only skip a load when one +// box fully contains the viewport. Partial overlap simply refetches; layers +// dedupe markers by id, so that's cheap and correct. +export type LoadedRegions = readonly LatLngBounds[] + +// Bounds the list on long panning sessions. Evicting the oldest region only +// costs a refetch if the user returns there. +export const MAX_LOADED_REGIONS = 32 + +export function isRegionLoaded(regions: LoadedRegions | null, bounds: LatLngBounds): boolean { + return !!regions && regions.some((r) => r.contains(bounds)) +} + +export function addLoadedRegion(regions: LoadedRegions | null, bounds: LatLngBounds): LoadedRegions { + const next = [...(regions ?? []).filter((r) => !bounds.contains(r)), bounds] + return next.length > MAX_LOADED_REGIONS ? next.slice(next.length - MAX_LOADED_REGIONS) : next +} diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index b775501..fb51642 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -201,6 +201,7 @@ import { erSeverity, computeLocationGrade, } from '../map/scoring' +import { addLoadedRegion, isRegionLoaded, type LoadedRegions } from '../map/loadedRegions' import { fetchStaticMapDataUrl } from '../map/staticMap' import { buildReconPdfDocDefinition, reconPdfFilename } from '../map/reconPdf' import { downloadReconPdf } from '../map/pdfExport' @@ -704,7 +705,7 @@ function MapPage() { const baseLayerRef = useRef(null) const noiseLayerRef = useRef(null) const airportLayerRef = useRef(null) - const airportLoadedBoundsRef = useRef(null) + const airportLoadedBoundsRef = useRef(null) const airportKnownIdsRef = useRef>(new Set()) const superfundLayerRef = useRef(null) const superfundKnownIdsRef = useRef>(new Set()) @@ -737,25 +738,25 @@ function MapPage() { const tornadoLoadedBoundsRef = useRef(null) const transitLayerRef = useRef(null) const transitLineLayersRef = useRef | null>(null) - const transitLinesLoadedBoundsRef = useRef(null) + const transitLinesLoadedBoundsRef = useRef(null) const transitLinesKnownIdsRef = useRef>(new Set()) const transitLinesLoadingRef = useRef(false) const transitLinesRequestRef = useRef<{ key: string; controller: AbortController; promise: Promise } | null>(null) const transitRequestGenerationRef = useRef(0) - const busLinesLoadedBoundsRef = useRef(null) + const busLinesLoadedBoundsRef = useRef(null) const busLinesKnownIdsRef = useRef>(new Set()) const busLinesLoadingRef = useRef(false) const busLinesRequestRef = useRef<{ key: string; controller: AbortController; promise: Promise } | null>(null) const transitSubLayersRef = useRef | null>(null) - const transitLoadedBoundsRef = useRef(null) + const transitLoadedBoundsRef = useRef(null) const costcoLayerRef = useRef(null) - const costcoLoadedBoundsRef = useRef(null) + const costcoLoadedBoundsRef = useRef(null) const costcoKnownIdsRef = useRef>(new Set()) const trafficLayerRef = useRef(null) const dataCenterLayerRef = useRef(null) const dataCenterSubLayersRef = useRef | null>(null) const dataCenterDataRef = useRef(null) - const dataCenterLoadedBoundsRef = useRef(null) + const dataCenterLoadedBoundsRef = useRef(null) const dataCenterKnownIdsRef = useRef>(new Set()) const [dcSubVisible, setDcSubVisible] = useState>( Object.fromEntries(DC_STATUSES.map((s) => [s, true])) @@ -763,7 +764,7 @@ function MapPage() { const dcSubVisibleRef = useRef(dcSubVisible) const emsLayerRef = useRef(null) const emsSubLayersRef = useRef | null>(null) - const emsLoadedBoundsRef = useRef(null) + const emsLoadedBoundsRef = useRef(null) const emsKnownIdsRef = useRef>(new Set()) const [emsSubVisible, setEmsSubVisible] = useState>({ fire_station: true, hospital: true, police: true, @@ -771,7 +772,7 @@ function MapPage() { const emsSubVisibleRef = useRef(emsSubVisible) const crowdLayerRef = useRef(null) const crowdSubLayersRef = useRef | null>(null) - const crowdLoadedBoundsRef = useRef(null) + const crowdLoadedBoundsRef = useRef(null) const crowdKnownIdsRef = useRef>(new Set()) const [crowdSubVisible, setCrowdSubVisible] = useState>({ stadium: true, concert: true, park: true, raceway: true, themepark: true, @@ -792,11 +793,11 @@ function MapPage() { const transitStopsKnownIdsRef = useRef>(new Set()) const transitStopsLoadingRef = useRef(false) const transitStopsRequestRef = useRef<{ key: string; controller: AbortController; promise: Promise } | null>(null) - const transitBusStopsLoadedBoundsRef = useRef(null) + const transitBusStopsLoadedBoundsRef = useRef(null) // ALPR camera layer (Flock + other manufacturers) — sourced from OSM via // the DeFlock crowdsourcing project. Single cluster, no sub-types. const camerasLayerRef = useRef(null) - const camerasLoadedBoundsRef = useRef(null) + const camerasLoadedBoundsRef = useRef(null) const camerasKnownIdsRef = useRef>(new Set()) const camerasLoadingRef = useRef(false) const camerasRequestRef = useRef<{ key: string; controller: AbortController } | null>(null) @@ -1488,7 +1489,7 @@ function MapPage() { const loadAirportLabels = useCallback(async (map: L.Map, layer: L.LayerGroup) => { const bounds = map.getBounds() const loaded = airportLoadedBoundsRef.current - if (loaded && loaded.contains(bounds)) { dbg('airports', 'Skipping — bounds already loaded'); return } + if (isRegionLoaded(loaded, bounds)) { dbg('airports', 'Skipping — bounds already loaded'); return } dbg('airports', 'Loading airport labels…') try { @@ -1544,7 +1545,7 @@ function MapPage() { known.add(id) } - airportLoadedBoundsRef.current= loaded ? loaded.extend(padded.getSouthWest()).extend(padded.getNorthEast()) : padded + airportLoadedBoundsRef.current= addLoadedRegion(loaded, padded) } catch (err) { console.warn('Airport label fetch failed:', err) } @@ -1554,7 +1555,7 @@ function MapPage() { const loadCostcoLabels = useCallback(async (map: L.Map, layer: L.LayerGroup) => { const bounds = map.getBounds() const loaded = costcoLoadedBoundsRef.current - if (loaded && loaded.contains(bounds)) { dbg('costco', 'Skipping — bounds already loaded'); return } + if (isRegionLoaded(loaded, bounds)) { dbg('costco', 'Skipping — bounds already loaded'); return } dbg('costco', 'Loading Costco locations…') try { @@ -1571,7 +1572,7 @@ function MapPage() { }) dbg('costco', `Got ${places.length} warehouse(s) in current bounds`) if (places.length === 0) { - costcoLoadedBoundsRef.current = loaded ? loaded.extend(padded.getSouthWest()).extend(padded.getNorthEast()) : padded + costcoLoadedBoundsRef.current = addLoadedRegion(loaded, padded) return } @@ -1602,7 +1603,7 @@ function MapPage() { known.add(p.id) } - costcoLoadedBoundsRef.current = loaded ? loaded.extend(padded.getSouthWest()).extend(padded.getNorthEast()) : padded + costcoLoadedBoundsRef.current = addLoadedRegion(loaded, padded) } catch (err) { console.warn('Costco label fetch failed:', err) notifyLayerErrorRef.current('Costco locations') @@ -1988,13 +1989,13 @@ function MapPage() { transitSubVisibleRef.current.rail || transitSubVisibleRef.current.subway || transitSubVisibleRef.current.tram - const needRail = railVisible && (!railLoaded || !railLoaded.contains(bounds)) + const needRail = railVisible && !isRegionLoaded(railLoaded, bounds) // Bus stops are very dense; only include them once the user has zoomed in // enough that the dots aren't a wall. The line layer uses the same gate. const needBus = transitSubVisibleRef.current.bus && map.getZoom() >= 13 && - (!busLoaded || !busLoaded.contains(bounds)) + !isRegionLoaded(busLoaded, bounds) if (!needRail && !needBus) { if (transitStopsRequestRef.current) { @@ -2092,14 +2093,10 @@ function MapPage() { } if (needRail) { - transitLoadedBoundsRef.current = railLoaded - ? railLoaded.extend(bounds.getSouthWest()).extend(bounds.getNorthEast()) - : bounds + transitLoadedBoundsRef.current = addLoadedRegion(railLoaded, bounds) } if (needBus) { - transitBusStopsLoadedBoundsRef.current = busLoaded - ? busLoaded.extend(bounds.getSouthWest()).extend(bounds.getNorthEast()) - : bounds + transitBusStopsLoadedBoundsRef.current = addLoadedRegion(busLoaded, bounds) } dbg('transit', `Added ${added} new stops (rail=${railSource}; total known: ${known.size})`) } catch (err) { @@ -3831,7 +3828,7 @@ function MapPage() { // the data centers we haven't drawn yet, never clearing what's on screen. const bounds = map.getBounds() const loaded = dataCenterLoadedBoundsRef.current - if (loaded && loaded.contains(bounds)) { dbg('datacenters', 'Skipping — bounds already loaded'); return } + if (isRegionLoaded(loaded, bounds)) { dbg('datacenters', 'Skipping — bounds already loaded'); return } const padded = bounds.pad(0.3) const known = dataCenterKnownIdsRef.current let added = 0 @@ -3843,9 +3840,7 @@ function MapPage() { known.add(id) added++ } - dataCenterLoadedBoundsRef.current = loaded - ? loaded.extend(padded.getSouthWest()).extend(padded.getNorthEast()) - : padded + dataCenterLoadedBoundsRef.current = addLoadedRegion(loaded, padded) dbg('datacenters', `Added ${added} new (total known: ${known.size})`) }, []) @@ -3898,7 +3893,7 @@ function MapPage() { const loadEmsData = useCallback(async (map: L.Map, layer: L.LayerGroup) => { const bounds = map.getBounds() const loaded = emsLoadedBoundsRef.current - if (loaded && loaded.contains(bounds)) { dbg('ems', 'Skipping — bounds already loaded'); return } + if (isRegionLoaded(loaded, bounds)) { dbg('ems', 'Skipping — bounds already loaded'); return } dbg('ems', 'Loading EMS data…') setEmsLoading(true) @@ -3993,9 +3988,7 @@ function MapPage() { } } - emsLoadedBoundsRef.current = loaded - ? loaded.extend(padded.getSouthWest()).extend(padded.getNorthEast()) - : padded + emsLoadedBoundsRef.current = addLoadedRegion(loaded, padded) dbg('ems', `Total known EMS places: ${known.size}`) } catch (err) { console.warn('EMS data fetch failed:', err) @@ -4058,7 +4051,7 @@ function MapPage() { } const loaded = camerasLoadedBoundsRef.current - if (loaded && loaded.contains(bounds)) { + if (isRegionLoaded(loaded, bounds)) { camerasRequestRef.current?.controller.abort() dbg('cameras', 'Skipping — bounds already loaded') setCamerasStatus(camerasKnownIdsRef.current.size === 0 @@ -4143,9 +4136,7 @@ function MapPage() { if (cam.direction && /^-?\d+(\.\d+)?$/.test(cam.direction)) withDirection++ } - camerasLoadedBoundsRef.current = loaded - ? loaded.extend(bounds.getSouthWest()).extend(bounds.getNorthEast()) - : bounds + camerasLoadedBoundsRef.current = addLoadedRegion(loaded, bounds) dbg('cameras', `[${source}] Added ${added} new (${flockAdded} Flock, ${added - flockAdded} other, ${withDirection} with direction); total known: ${known.size}`) if (known.size === 0) { setCamerasStatus({ kind: 'empty', text: 'No mapped surveillance cameras in this area' }) @@ -4219,7 +4210,7 @@ function MapPage() { const loadCrowdData = useCallback(async (map: L.Map, layer: L.LayerGroup) => { const bounds = map.getBounds() const loaded = crowdLoadedBoundsRef.current - if (loaded && loaded.contains(bounds)) { dbg('crowd', 'Skipping — bounds already loaded'); return } + if (isRegionLoaded(loaded, bounds)) { dbg('crowd', 'Skipping — bounds already loaded'); return } dbg('crowd', 'Loading crowd magnet data…') setCrowdLoading(true) @@ -4273,9 +4264,7 @@ function MapPage() { added++ } - crowdLoadedBoundsRef.current = loaded - ? loaded.extend(padded.getSouthWest()).extend(padded.getNorthEast()) - : padded + crowdLoadedBoundsRef.current = addLoadedRegion(loaded, padded) dbg('crowd', `[${source}] Added ${added} new (total known: ${known.size})`) } catch (err) { console.warn('Crowd magnet fetch failed:', err) @@ -5011,7 +5000,7 @@ function MapPage() { } const loaded = transitLinesLoadedBoundsRef.current - if (loaded && loaded.contains(bounds)) { + if (isRegionLoaded(loaded, bounds)) { if (transitLinesRequestRef.current) { transitLinesRequestRef.current.controller.abort() transitRequestGenerationRef.current++ @@ -5097,9 +5086,7 @@ function MapPage() { added++ } dbg('transit', `[${source}] Rendered ${added} new line segments (total known: ${known.size})`) - transitLinesLoadedBoundsRef.current = loaded - ? loaded.extend(bounds.getSouthWest()).extend(bounds.getNorthEast()) - : bounds + transitLinesLoadedBoundsRef.current = addLoadedRegion(loaded, bounds) } catch (err) { if (!signal.aborted) { console.warn('Transit line fetch failed:', err) @@ -5141,7 +5128,7 @@ function MapPage() { } const loaded = busLinesLoadedBoundsRef.current - if (loaded && loaded.contains(bounds)) { + if (isRegionLoaded(loaded, bounds)) { if (busLinesRequestRef.current) { busLinesRequestRef.current.controller.abort() transitRequestGenerationRef.current++ @@ -5186,9 +5173,7 @@ function MapPage() { added++ } dbg('transit', `Rendered ${added} new bus segments (total known: ${known.size})`) - busLinesLoadedBoundsRef.current = loaded - ? loaded.extend(bounds.getSouthWest()).extend(bounds.getNorthEast()) - : bounds + busLinesLoadedBoundsRef.current = addLoadedRegion(loaded, bounds) } catch (err) { if (!signal.aborted) { console.warn('Bus line fetch failed:', err)