import type { ActiveLayers } from '@/types/dashboard'; import { API_BASE } from '@/lib/api'; export const LAYER_PREFERENCES_STORAGE_KEY = 'sb_active_layers_v1'; export const DASHBOARD_PREFS_STORAGE_KEY = 'sb_dashboard_prefs_v1'; export const DASHBOARD_PREFS_VERSION = 1; export const LAYER_PREFERENCES_CHANGED_EVENT = 'sb:layer-preferences-changed'; export const MAP_STYLES = ['DEFAULT', 'SATELLITE'] as const; export type MapStyle = (typeof MAP_STYLES)[number]; export const LAYER_SECTION_IDS = [ 'aircraft', 'maritime', 'space', 'hazards', 'uap', 'biosurveillance', 'infrastructure', 'shodan', 'sigint', 'overlays', ] as const; export type LayerSectionId = (typeof LAYER_SECTION_IDS)[number]; type DashboardPrefsV1 = { version: typeof DASHBOARD_PREFS_VERSION; layers?: Partial; mapStyle?: string; filters?: Record; layerSectionsExpanded?: Record; }; /** Ship defaults for layer visibility — single source of truth for page + reset. */ export function getDefaultActiveLayers(): ActiveLayers { return { flights: true, private: true, jets: true, military: true, tracked: true, gps_jamming: true, ships_military: true, ships_cargo: true, ships_civilian: true, ships_passenger: true, ships_tracked_yachts: true, fishing_activity: true, satellites: true, gibs_imagery: false, highres_satellite: false, sentinel_hub: false, viirs_nightlights: false, road_corridor_trends: false, malware_c2: false, submarine_cables: false, scm_suppliers: false, cyber_threats: false, telegram_osint: true, earthquakes: true, firms: false, ukraine_alerts: true, weather_alerts: true, volcanoes: true, air_quality: true, cctv: false, datacenters: false, internet_outages: true, power_plants: false, military_bases: true, trains: false, kiwisdr: true, psk_reporter: false, satnogs: true, tinygs: true, scanners: true, sigint_meshtastic: true, sigint_aprs: true, ukraine_frontline: true, global_incidents: true, day_night: true, correlations: true, contradictions: true, uap_sightings: true, wastewater: true, crowdthreat: false, gt_risk: false, shodan_overlay: false, ai_intel: true, sar: true, }; } const ACTIVE_LAYER_KEYS = Object.keys(getDefaultActiveLayers()) as (keyof ActiveLayers)[]; const LAYER_BACKEND_SYNC_INTERVAL_MS = 5000; let layerBackendSyncTimer: ReturnType | null = null; let layerBackendSyncInFlight = false; let layerBackendSeenSuccess = false; let layerBackendSawFailure = false; let repairedDefaultBackendState = false; function isBooleanRecord(value: unknown): value is Record { if (!value || typeof value !== 'object') return false; return Object.values(value).every((entry) => typeof entry === 'boolean'); } function isStringArrayRecord(value: unknown): value is Record { if (!value || typeof value !== 'object') return false; return Object.values(value).every( (entry) => Array.isArray(entry) && entry.every((item) => typeof item === 'string'), ); } function readDashboardPrefs(): DashboardPrefsV1 { if (typeof window === 'undefined') { return { version: DASHBOARD_PREFS_VERSION }; } try { const raw = localStorage.getItem(DASHBOARD_PREFS_STORAGE_KEY); if (raw) { const parsed: unknown = JSON.parse(raw); if (parsed && typeof parsed === 'object') { return { version: DASHBOARD_PREFS_VERSION, ...(parsed as Omit), }; } } const legacyRaw = localStorage.getItem(LAYER_PREFERENCES_STORAGE_KEY); if (legacyRaw) { const parsed: unknown = JSON.parse(legacyRaw); if (parsed && typeof parsed === 'object') { const layers = (parsed as { layers?: unknown }).layers; if (isBooleanRecord(layers)) { return { version: DASHBOARD_PREFS_VERSION, layers: layers as Partial }; } } } } catch { /* ignore */ } return { version: DASHBOARD_PREFS_VERSION }; } function writeDashboardPrefs(patch: Omit): void { if (typeof window === 'undefined') return; try { const { version: _version, ...current } = readDashboardPrefs(); localStorage.setItem( DASHBOARD_PREFS_STORAGE_KEY, JSON.stringify({ version: DASHBOARD_PREFS_VERSION, ...current, ...patch, }), ); localStorage.removeItem(LAYER_PREFERENCES_STORAGE_KEY); } catch { /* quota / private mode */ } } /** Apply saved toggles onto current defaults so new layers keep ship defaults. */ export function mergeActiveLayers( defaults: ActiveLayers, saved: Partial | Record | null | undefined, ): ActiveLayers { if (!saved) return { ...defaults }; const merged = { ...defaults }; for (const key of ACTIVE_LAYER_KEYS) { const value = saved[key]; if (typeof value === 'boolean') { merged[key] = value; } } return merged; } export function loadActiveLayers(): ActiveLayers { const defaults = getDefaultActiveLayers(); return mergeActiveLayers(defaults, readDashboardPrefs().layers); } function layerMapsEqual(left: ActiveLayers, right: ActiveLayers): boolean { return ACTIVE_LAYER_KEYS.every((key) => left[key] === right[key]); } function emitLayerPreferencesChanged(layers: ActiveLayers): void { if (typeof window === 'undefined') return; window.dispatchEvent( new CustomEvent(LAYER_PREFERENCES_CHANGED_EVENT, { detail: layers }), ); } /** * Quietly repair the backend's process-local layer gate after a runtime restart. * * The dashboard already saves the operator's layer choices locally. The backend, * however, resets its fetcher gates on process restart. This reconciliation loop * observes the existing GET /api/layers endpoint and replays the saved operator * state only when a connection is first established, after an observed outage, * or when the backend has unmistakably fallen back to its shipped defaults. * It does not continuously overwrite arbitrary non-default server state, so two * independent clients do not get into a five-second preference tug-of-war. */ export async function reconcileActiveLayersWithBackend(): Promise<'synced' | 'noop' | 'offline'> { if (typeof window === 'undefined' || typeof fetch !== 'function') return 'offline'; if (layerBackendSyncInFlight) return 'noop'; layerBackendSyncInFlight = true; try { const response = await fetch(`${API_BASE}/api/layers`, { headers: { Accept: 'application/json' }, cache: 'no-store', }); if (!response.ok) throw new Error(`layer state probe failed (${response.status})`); const payload: unknown = await response.json(); const rawLayers = payload && typeof payload === 'object' ? (payload as { layers?: unknown }).layers : null; if (!isBooleanRecord(rawLayers)) { throw new Error('layer state probe returned an invalid payload'); } const defaults = getDefaultActiveLayers(); const desired = loadActiveLayers(); const backend = mergeActiveLayers(defaults, rawLayers); const mismatch = !layerMapsEqual(desired, backend); const backendAtDefaults = layerMapsEqual(backend, defaults); const shouldRepair = mismatch && (!layerBackendSeenSuccess || layerBackendSawFailure || (backendAtDefaults && !repairedDefaultBackendState)); if (shouldRepair) { const syncResponse = await fetch(`${API_BASE}/api/layers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ layers: desired }), }); if (!syncResponse.ok) { throw new Error(`layer state sync failed (${syncResponse.status})`); } // useDataPolling already listens for this event and refreshes gated data immediately. window.dispatchEvent(new Event('sb:layer-toggle')); repairedDefaultBackendState = backendAtDefaults; layerBackendSeenSuccess = true; layerBackendSawFailure = false; return 'synced'; } if (!backendAtDefaults) repairedDefaultBackendState = false; layerBackendSeenSuccess = true; layerBackendSawFailure = false; return 'noop'; } catch { layerBackendSawFailure = true; return 'offline'; } finally { layerBackendSyncInFlight = false; } } function ensureLayerBackendSync(): void { if ( typeof window === 'undefined' || typeof fetch !== 'function' || process.env.NODE_ENV === 'test' ) { return; } if (layerBackendSyncTimer !== null) return; void reconcileActiveLayersWithBackend(); layerBackendSyncTimer = window.setInterval(() => { void reconcileActiveLayersWithBackend(); }, LAYER_BACKEND_SYNC_INTERVAL_MS); } export function saveActiveLayers(layers: ActiveLayers): void { writeDashboardPrefs({ layers }); emitLayerPreferencesChanged(layers); ensureLayerBackendSync(); } export function clearActiveLayersPreference(): void { if (typeof window === 'undefined') return; try { const { version: _version, layers: _layers, ...rest } = readDashboardPrefs(); localStorage.setItem( DASHBOARD_PREFS_STORAGE_KEY, JSON.stringify({ version: DASHBOARD_PREFS_VERSION, ...rest }), ); localStorage.removeItem(LAYER_PREFERENCES_STORAGE_KEY); } catch { /* ignore */ } const defaults = getDefaultActiveLayers(); emitLayerPreferencesChanged(defaults); ensureLayerBackendSync(); } export function getDefaultMapStyle(): MapStyle { return 'DEFAULT'; } export function loadMapStyle(): MapStyle { const saved = readDashboardPrefs().mapStyle; return MAP_STYLES.includes(saved as MapStyle) ? (saved as MapStyle) : getDefaultMapStyle(); } export function saveMapStyle(style: string): void { if (!MAP_STYLES.includes(style as MapStyle)) return; writeDashboardPrefs({ mapStyle: style }); } export function loadActiveFilters(): Record { const saved = readDashboardPrefs().filters; return isStringArrayRecord(saved) ? saved : {}; } export function saveActiveFilters(filters: Record): void { writeDashboardPrefs({ filters }); } export function getDefaultLayerSectionExpanded(): Record { return Object.fromEntries(LAYER_SECTION_IDS.map((id) => [id, id === 'overlays'])); } export function mergeLayerSectionExpanded( defaults: Record, saved: Record | null | undefined, ): Record { if (!saved) return { ...defaults }; const merged = { ...defaults }; for (const key of Object.keys(defaults)) { if (typeof saved[key] === 'boolean') { merged[key] = saved[key]; } } return merged; } export function loadLayerSectionExpanded(): Record { const defaults = getDefaultLayerSectionExpanded(); const saved = readDashboardPrefs().layerSectionsExpanded; return mergeLayerSectionExpanded(defaults, isBooleanRecord(saved) ? saved : null); } export function saveLayerSectionExpanded(expanded: Record): void { writeDashboardPrefs({ layerSectionsExpanded: expanded }); }