import { useEffect, useRef } from "react"; import { API_BASE } from "@/lib/api"; import { mergeData, setBackendStatus as setStoreBackendStatus } from "./useDataStore"; import { appendLiveDataBoundsParams } from "@/lib/liveDataViewport"; export type BackendStatus = 'connecting' | 'connected' | 'disconnected'; // --------------------------------------------------------------------------- // Polling pause/resume — used by Time Machine snapshot playback // --------------------------------------------------------------------------- let _pollingPaused = false; let _fastEtagRef: { current: string | null } | null = null; let _slowEtagRef: { current: string | null } | null = null; /** Pause live data polling (snapshot mode). */ export function pausePolling() { _pollingPaused = true; } /** Resume live data polling and invalidate ETags for a full refresh. */ export function resumePolling() { _pollingPaused = false; // Invalidate ETags so the next poll gets fresh data (not 304) if (_fastEtagRef) _fastEtagRef.current = null; if (_slowEtagRef) _slowEtagRef.current = null; } /** Resume live mode and fetch both live tiers immediately instead of waiting for the next poll tick. */ export async function forceRefreshLiveData(): Promise { _pollingPaused = false; if (_fastEtagRef) _fastEtagRef.current = null; if (_slowEtagRef) _slowEtagRef.current = null; try { const [fastRes, slowRes] = await Promise.all([ fetch(appendLiveDataBoundsParams(`${API_BASE}/api/live-data/fast`)), fetch(appendLiveDataBoundsParams(`${API_BASE}/api/live-data/slow`)), ]); if (fastRes.ok) { if (_fastEtagRef) _fastEtagRef.current = fastRes.headers.get('etag') || null; mergeData(await fastRes.json()); } if (slowRes.ok) { if (_slowEtagRef) _slowEtagRef.current = slowRes.headers.get('etag') || null; mergeData(await slowRes.json()); } if (fastRes.ok || slowRes.ok) { setStoreBackendStatus('connected'); } } catch (e) { console.error("Failed forcing live data refresh", e); setStoreBackendStatus('disconnected'); } } type FastDataProbe = { commercial_flights?: unknown[]; military_flights?: unknown[]; tracked_flights?: unknown[]; ships?: unknown[]; sigint?: unknown[]; cctv?: unknown[]; news?: unknown[]; threat_level?: unknown; }; function hasMeaningfulFastData(json: FastDataProbe): boolean { return ( (json.commercial_flights?.length || 0) > 100 || (json.military_flights?.length || 0) > 25 || (json.tracked_flights?.length || 0) > 10 || (json.ships?.length || 0) > 100 || (json.sigint?.length || 0) > 100 || (json.cctv?.length || 0) > 100 ); } /** * Event name dispatched by page.tsx when a layer toggle changes. * useDataPolling listens for this to immediately refetch slow-tier data * so toggled layers (power plants, GDELT, etc.) appear without the usual * 120-second wait. */ export const LAYER_TOGGLE_EVENT = 'sb:layer-toggle'; /** * Polls the backend for fast and slow data tiers. * * Issue #288: heavy, density-driven layers (vessels, aircraft, gdelt * events, fires, sigint, …) are bbox-scoped to the visible map area via * `appendLiveDataBoundsParams`. Static reference layers (datacenters, * military bases, power plants, satellites, weather, news, …) are NOT * filtered backend-side, so panning never reveals an "empty world" of * infrastructure. World-zoomed views skip bbox params entirely and hit * the shared ETag cache exactly like the pre-#288 behaviour. * * The AIS stream viewport POST (/api/viewport) is still handled separately * by useViewportBounds to limit upstream AIS ingestion. */ export function useDataPolling() { const fastEtag = useRef(null); const slowEtag = useRef(null); useEffect(() => { // Expose refs so pausePolling/resumePolling can invalidate ETags _fastEtagRef = fastEtag; _slowEtagRef = slowEtag; let hasData = false; let fetchedStartupFastPayload = false; let fastTimerId: ReturnType | null = null; let slowTimerId: ReturnType | null = null; const fastAbortRef = { current: null as AbortController | null }; const slowAbortRef = { current: null as AbortController | null }; const fetchCriticalBootstrap = async () => { try { const res = await fetch(`${API_BASE}/api/bootstrap/critical`, { headers: { Accept: 'application/json' }, }); if (res.ok) { setStoreBackendStatus('connected'); const json = await res.json(); mergeData(json); if (hasMeaningfulFastData(json) || (json.news?.length || 0) > 0 || json.threat_level) { hasData = true; } } } catch (e) { const aborted = typeof e === 'object' && e !== null && 'name' in e && (e as { name?: string }).name === 'AbortError'; if (!aborted) { console.warn("Critical bootstrap fetch will retry via live polling", e); } } }; const fetchFastData = async () => { if (fastTimerId) { clearTimeout(fastTimerId); fastTimerId = null; } // Skip fetch when Time Machine snapshot mode is active if (_pollingPaused) { scheduleNext('fast'); return; } if (fastAbortRef.current) return; const controller = new AbortController(); fastAbortRef.current = controller; try { const useStartupPayload = !fetchedStartupFastPayload && !fastEtag.current; const headers: Record = {}; if (!useStartupPayload && fastEtag.current) headers['If-None-Match'] = fastEtag.current; const url = appendLiveDataBoundsParams( `${API_BASE}/api/live-data/fast${useStartupPayload ? '?initial=1' : ''}`, ); const res = await fetch(url, { headers, signal: controller.signal, }); if (res.status === 304) { setStoreBackendStatus('connected'); scheduleNext('fast'); return; } if (res.ok) { setStoreBackendStatus('connected'); // Do not keep the capped startup ETag. The next steady poll should // request the full fast dataset and replace the representative first paint. fastEtag.current = useStartupPayload ? null : res.headers.get('etag') || null; if (useStartupPayload) fetchedStartupFastPayload = true; const json = await res.json(); mergeData(json); if (hasMeaningfulFastData(json)) hasData = true; } } catch (e) { const aborted = typeof e === 'object' && e !== null && 'name' in e && (e as { name?: string }).name === 'AbortError'; if (!aborted) { console.warn("Fast live data fetch will retry after runtime is reachable", e); setStoreBackendStatus('disconnected'); } } finally { if (fastAbortRef.current === controller) { fastAbortRef.current = null; } } scheduleNext('fast'); }; const fetchSlowData = async () => { if (_pollingPaused) { scheduleNext('slow'); return; } if (slowAbortRef.current) return; const controller = new AbortController(); slowAbortRef.current = controller; try { const headers: Record = {}; if (slowEtag.current) headers['If-None-Match'] = slowEtag.current; const res = await fetch( appendLiveDataBoundsParams(`${API_BASE}/api/live-data/slow`), { headers, signal: controller.signal, }, ); if (res.status === 304) { scheduleNext('slow'); return; } if (res.ok) { slowEtag.current = res.headers.get('etag') || null; const json = await res.json(); mergeData(json); } } catch (e) { const aborted = typeof e === 'object' && e !== null && 'name' in e && (e as { name?: string }).name === 'AbortError'; if (!aborted) { console.warn("Slow live data fetch will retry after runtime is reachable", e); } } finally { if (slowAbortRef.current === controller) { slowAbortRef.current = null; } } scheduleNext('slow'); }; // Adaptive polling: retry every 3s during startup, back off to normal cadence once data arrives const scheduleNext = (tier: 'fast' | 'slow') => { if (tier === 'fast') { const delay = hasData ? 15000 : 3000; // 3s startup retry → 15s steady state const needsFullFastPayload = fetchedStartupFastPayload && !fastEtag.current; fastTimerId = setTimeout(fetchFastData, needsFullFastPayload ? 750 : delay); } else { const delay = hasData ? 120000 : 5000; // 5s startup retry → 120s steady state slowTimerId = setTimeout(fetchSlowData, delay); } }; // When a layer toggle fires, immediately refetch slow data so the user // doesn't wait up to 120s for power plants / GDELT / etc. to appear. const onLayerToggle = () => { slowEtag.current = null; // invalidate ETag → guarantees fresh payload if (slowTimerId) clearTimeout(slowTimerId); slowTimerId = null; fetchSlowData(); }; window.addEventListener(LAYER_TOGGLE_EVENT, onLayerToggle); void (async () => { await fetchCriticalBootstrap(); fetchFastData(); // Let the bootstrap/fast payload paint before competing with the slow tier. slowTimerId = setTimeout(fetchSlowData, 5000); })(); return () => { window.removeEventListener(LAYER_TOGGLE_EVENT, onLayerToggle); if (fastTimerId) clearTimeout(fastTimerId); if (slowTimerId) clearTimeout(slowTimerId); if (fastAbortRef.current) fastAbortRef.current.abort(); if (slowAbortRef.current) slowAbortRef.current.abort(); }; }, []); // Data and backend status are now accessed via useDataStore hooks // (useDataKey, useDataKeys, useDataSnapshot, useBackendStatus). // This hook is a pure side-effect — it starts polling and writes to the store. }