From fcf49d8f2a112f730052cac11bc2219a43208f81 Mon Sep 17 00:00:00 2001 From: madhavcodez Date: Tue, 14 Apr 2026 10:54:33 -0500 Subject: [PATCH 1/2] feat: cache viewport bounds and deduplicate polygons across pans Add useBoundsCache (check, commit, reset) and useDebouncedCallback hooks. Dashboard now skips /locations fetches when the viewport sits inside a previously fetched region expanded 1.5x, debounces viewport changes to 300ms so fast panning collapses into a single request, and merges polygons into a Map keyed by id so redraws never flicker out already-visible buildings. The bounds cache only advances after a fetch succeeds, so aborted requests do not silently leave regions marked cached with no data. The polygon map is capped at 20,000 entries, trimmed to the most recent 15,000 when exceeded. Closes #56 Closes #43 Closes #54 --- src/hooks/useBoundsCache.ts | 80 ++++++++++++++ src/hooks/useDebouncedCallback.ts | 36 +++++++ src/pages/Dashboard.tsx | 166 ++++++++++++++++++++++++------ 3 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 src/hooks/useBoundsCache.ts create mode 100644 src/hooks/useDebouncedCallback.ts diff --git a/src/hooks/useBoundsCache.ts b/src/hooks/useBoundsCache.ts new file mode 100644 index 0000000..b457b97 --- /dev/null +++ b/src/hooks/useBoundsCache.ts @@ -0,0 +1,80 @@ +import { useCallback, useRef } from "react"; + +export interface Bounds { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; +} + +interface BoundsCacheResult { + shouldFetch: boolean; + fetchBounds: Bounds | null; +} + +const EXPAND_FACTOR = 1.5; + +/** + * Tracks the union of all previously-fetched bounding boxes. + * + * `check` answers whether the viewport is already covered and returns the + * expanded fetch bounds to use if not. It does NOT mutate the cache. + * `commit` extends the cached region to include newly-fetched bounds and + * must be called only after the fetch succeeds — otherwise an aborted or + * failed fetch would permanently mark a region as cached with no data. + */ +export function useBoundsCache(): { + check: (viewport: Bounds) => BoundsCacheResult; + commit: (bounds: Bounds) => void; + reset: () => void; +} { + const cachedRef = useRef(null); + + const check = useCallback((viewport: Bounds): BoundsCacheResult => { + const cached = cachedRef.current; + + if ( + cached && + viewport.minLat >= cached.minLat && + viewport.maxLat <= cached.maxLat && + viewport.minLng >= cached.minLng && + viewport.maxLng <= cached.maxLng + ) { + return { shouldFetch: false, fetchBounds: null }; + } + + const latSpan = viewport.maxLat - viewport.minLat; + const lngSpan = viewport.maxLng - viewport.minLng; + const latPad = (latSpan * (EXPAND_FACTOR - 1)) / 2; + const lngPad = (lngSpan * (EXPAND_FACTOR - 1)) / 2; + + const expanded: Bounds = { + minLat: viewport.minLat - latPad, + maxLat: viewport.maxLat + latPad, + minLng: viewport.minLng - lngPad, + maxLng: viewport.maxLng + lngPad, + }; + + return { shouldFetch: true, fetchBounds: expanded }; + }, []); + + const commit = useCallback((bounds: Bounds) => { + const cached = cachedRef.current; + if (cached) { + cachedRef.current = { + minLat: Math.min(cached.minLat, bounds.minLat), + maxLat: Math.max(cached.maxLat, bounds.maxLat), + minLng: Math.min(cached.minLng, bounds.minLng), + maxLng: Math.max(cached.maxLng, bounds.maxLng), + }; + } else { + cachedRef.current = { ...bounds }; + } + }, []); + + const reset = useCallback(() => { + cachedRef.current = null; + }, []); + + return { check, commit, reset }; +} diff --git a/src/hooks/useDebouncedCallback.ts b/src/hooks/useDebouncedCallback.ts new file mode 100644 index 0000000..c0edd9f --- /dev/null +++ b/src/hooks/useDebouncedCallback.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useRef } from "react"; + +/** + * Returns a debounced version of the given callback. + * The returned function delays invocation until `delay` ms have elapsed + * since the last call. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useDebouncedCallback( + callback: (...args: A) => void, + delay: number, +): (...args: A) => void { + const timerRef = useRef | null>(null); + const callbackRef = useRef(callback); + callbackRef.current = callback; + + // Clear pending timer on unmount to avoid firing against an unmounted component + useEffect(() => { + return () => { + if (timerRef.current !== null) clearTimeout(timerRef.current); + }; + }, []); + + return useCallback( + (...args: A) => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(() => { + timerRef.current = null; + callbackRef.current(...args); + }, delay); + }, + [delay], + ); +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 09e6cbc..8b44fff 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,7 +1,9 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import ChatDock, { type ChatAction } from "@components/chat/ChatDock"; import { getChatRuntimeConfig } from "@components/chat/config"; +import { useBoundsCache } from "../hooks/useBoundsCache"; +import { useDebouncedCallback } from "../hooks/useDebouncedCallback"; import ControlPanel, { type ImageOverlayMode, type LocationToggleState, @@ -9,8 +11,14 @@ import ControlPanel, { import DashboardSidebar from "@components/dashboard/DashboardSidebar"; import DisasterInfoPanel from "@components/dashboard/DisasterInfoPanel"; import ErrorBoundary from "@components/ErrorBoundary"; -import MapView, { type FlyTarget, type ViewportBBox } from "@components/map/MapView"; -import { normalizeClassification, type MapPolygon } from "@components/map/types"; +import MapView, { + type FlyTarget, + type ViewportBBox, +} from "@components/map/MapView"; +import { + normalizeClassification, + type MapPolygon, +} from "@components/map/types"; const API_BASE_FALLBACK = "http://127.0.0.1:8000"; @@ -36,7 +44,8 @@ interface DisasterLocation { count: number; } -const toLatLng = ([lng, lat]: [number, number]) => [lat, lng] as [number, number]; +const toLatLng = ([lng, lat]: [number, number]) => + [lat, lng] as [number, number]; const pushPolygon = ( out: MapPolygon[], @@ -79,11 +88,18 @@ function featuresToMapPolygons(features: unknown[]): MapPolygon[] { ("classification" in f && f.classification) || (props.damage_level as string | undefined) || undefined; - const area = (props.area as string | undefined) || - ("feature_type" in f && f.feature_type ? String(f.feature_type) : undefined); + const area = + (props.area as string | undefined) || + ("feature_type" in f && f.feature_type + ? String(f.feature_type) + : undefined); const notes = (props.notes as string | undefined) || undefined; - if (geometry.type === "Point" && Array.isArray(coords) && coords.length >= 2) { + if ( + geometry.type === "Point" && + Array.isArray(coords) && + coords.length >= 2 + ) { const [lng, lat] = coords as number[]; const d = 0.0001; out.push({ @@ -140,6 +156,16 @@ const Dashboard = () => { }, ); const [polygons, setPolygons] = useState([]); + const polygonMapRef = useRef>(new Map()); + const { + check: checkBounds, + commit: commitBounds, + reset: resetBoundsCache, + } = useBoundsCache(); + const [isLoadingLocations, setIsLoadingLocations] = useState(false); + // Cap session-accumulated polygons to bound memory and render cost. + const MAX_POLYGONS = 20000; + const TRIM_TO_POLYGONS = 15000; const [disasterLocations, setDisasterLocations] = useState< DisasterLocation[] >([]); @@ -147,15 +173,33 @@ const Dashboard = () => { const [viewport, setViewport] = useState(null); const [flyTarget, setFlyTarget] = useState(null); - const VALID_OVERLAY_MODES: ReadonlySet = new Set(["pre", "post", "none"]); + const VALID_OVERLAY_MODES: ReadonlySet = new Set([ + "pre", + "post", + "none", + ]); const handleChatAction = useCallback((action: ChatAction) => { - if (action.type === "flyTo" && action.lat != null && action.lng != null) { - setFlyTarget({ lat: action.lat, lng: action.lng, zoom: action.zoom }); - } else if (action.type === "setOpacity" && typeof action.value === "number") { + if ( + action.type === "flyTo" && + action.lat != null && + action.lng != null + ) { + setFlyTarget({ + lat: action.lat, + lng: action.lng, + zoom: action.zoom, + }); + } else if ( + action.type === "setOpacity" && + typeof action.value === "number" + ) { const clamped = Math.max(0, Math.min(1, action.value)); setImageOverlayOpacity(clamped); - } else if (action.type === "setOverlayMode" && VALID_OVERLAY_MODES.has(action.mode as ImageOverlayMode)) { + } else if ( + action.type === "setOverlayMode" && + VALID_OVERLAY_MODES.has(action.mode as ImageOverlayMode) + ) { setImageOverlayMode(action.mode as ImageOverlayMode); } else if (action.type === "setFilters") { const boolOrSkip = (v: unknown): boolean | null => @@ -179,31 +223,59 @@ const Dashboard = () => { useEffect(() => { if (!viewport) return; + + // Skip fetch if viewport is within the already-fetched region + const { shouldFetch, fetchBounds } = checkBounds(viewport); + if (!shouldFetch || !fetchBounds) return; + const { apiBaseUrl } = getChatRuntimeConfig(); const base = apiBaseUrl || API_BASE_FALLBACK; const params = new URLSearchParams({ - min_lng: String(viewport.minLng), - min_lat: String(viewport.minLat), - max_lng: String(viewport.maxLng), - max_lat: String(viewport.maxLat), + min_lng: String(fetchBounds.minLng), + min_lat: String(fetchBounds.minLat), + max_lng: String(fetchBounds.maxLng), + max_lat: String(fetchBounds.maxLat), limit: "5000", }); const url = `${base.replace(/\/+$/, "")}/locations?${params.toString()}`; const controller = new AbortController(); + setIsLoadingLocations(true); fetch(url, { signal: controller.signal }) - .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))) + .then((r) => + r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)), + ) .then((data: { features?: unknown[] }) => { - setPolygons(featuresToMapPolygons(data.features ?? [])); + // Advance the bounds cache only on successful delivery. + commitBounds(fetchBounds); + // Merge into cache map keyed by id to deduplicate across fetches. + const newPolygons = featuresToMapPolygons(data.features ?? []); + for (const p of newPolygons) { + polygonMapRef.current.set(p.id, p); + } + // Trim oldest entries when over the cap (Map preserves insertion order). + if (polygonMapRef.current.size > MAX_POLYGONS) { + const entries = Array.from(polygonMapRef.current.entries()); + polygonMapRef.current = new Map( + entries.slice(-TRIM_TO_POLYGONS), + ); + } + setPolygons(Array.from(polygonMapRef.current.values())); }) .catch((error) => { if (controller.signal.aborted) return; console.error("Failed to fetch locations:", error); - setPolygons([]); + }) + .finally(() => { + if (!controller.signal.aborted) setIsLoadingLocations(false); }); return () => controller.abort(); - }, [viewport]); + }, [viewport, checkBounds, commitBounds]); + + // Expose a manual invalidation for future filter-reset UX; keeps the + // reset path in scope so the session cache can be cleared deliberately. + void resetBoundsCache; useEffect(() => { const { apiBaseUrl } = getChatRuntimeConfig(); @@ -219,15 +291,29 @@ const Dashboard = () => { const controller = new AbortController(); fetch(url, { signal: controller.signal }) - .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))) + .then((r) => + r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)), + ) .then((data: { features?: ApiLocationFeature[] }) => { - const byPair = new Map(); + const byPair = new Map< + string, + { lats: number[]; lngs: number[]; count: number } + >(); for (const f of data.features ?? []) { const pairId = f.image_pair_id; const lat = f.centroid?.lat; const lng = f.centroid?.lng; - if (!pairId || typeof lat !== "number" || typeof lng !== "number") continue; - const entry = byPair.get(pairId) ?? { lats: [], lngs: [], count: 0 }; + if ( + !pairId || + typeof lat !== "number" || + typeof lng !== "number" + ) + continue; + const entry = byPair.get(pairId) ?? { + lats: [], + lngs: [], + count: 0, + }; entry.lats.push(lat); entry.lngs.push(lng); entry.count += 1; @@ -235,9 +321,17 @@ const Dashboard = () => { } const locations: DisasterLocation[] = []; for (const [imagePairId, entry] of byPair) { - const avgLat = entry.lats.reduce((a, b) => a + b, 0) / entry.lats.length; - const avgLng = entry.lngs.reduce((a, b) => a + b, 0) / entry.lngs.length; - locations.push({ imagePairId, centroid: { lat: avgLat, lng: avgLng }, count: entry.count }); + const avgLat = + entry.lats.reduce((a, b) => a + b, 0) / + entry.lats.length; + const avgLng = + entry.lngs.reduce((a, b) => a + b, 0) / + entry.lngs.length; + locations.push({ + imagePairId, + centroid: { lat: avgLat, lng: avgLng }, + count: entry.count, + }); } setDisasterLocations(locations); }) @@ -249,11 +343,16 @@ const Dashboard = () => { return () => controller.abort(); }, []); - const handleLocationNavigate = useCallback((index: number) => { - if (index < 0 || index >= disasterLocations.length) return; - setCurrentLocationIndex(index); - setFlyTarget({ ...disasterLocations[index].centroid }); - }, [disasterLocations]); + const handleLocationNavigate = useCallback( + (index: number) => { + if (index < 0 || index >= disasterLocations.length) return; + setCurrentLocationIndex(index); + setFlyTarget({ ...disasterLocations[index].centroid }); + }, + [disasterLocations], + ); + + const debouncedSetViewport = useDebouncedCallback(setViewport, 300); const visiblePolygons = polygons.filter((polygon) => { const key = normalizeClassification(polygon.classification ?? null); @@ -274,8 +373,9 @@ const Dashboard = () => { From 388b3705ec1404b14d31cf309866499d50e7b2b6 Mon Sep 17 00:00:00 2001 From: madhavcodez Date: Wed, 15 Apr 2026 18:31:23 -0500 Subject: [PATCH 2/2] chore: retrigger Vercel preview build