diff --git a/frontend/src/__tests__/lib/layerPreferences.test.ts b/frontend/src/__tests__/lib/layerPreferences.test.ts new file mode 100644 index 0000000..b345704 --- /dev/null +++ b/frontend/src/__tests__/lib/layerPreferences.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + DASHBOARD_PREFS_STORAGE_KEY, + LAYER_PREFERENCES_STORAGE_KEY, + clearActiveLayersPreference, + getDefaultActiveLayers, + getDefaultLayerSectionExpanded, + loadActiveFilters, + loadActiveLayers, + loadLayerSectionExpanded, + loadMapStyle, + mergeLayerSectionExpanded, + saveActiveFilters, + saveActiveLayers, + saveLayerSectionExpanded, + saveMapStyle, +} from '@/lib/layerPreferences'; + +describe('layerPreferences', () => { + afterEach(() => { + localStorage.removeItem(DASHBOARD_PREFS_STORAGE_KEY); + localStorage.removeItem(LAYER_PREFERENCES_STORAGE_KEY); + }); + + it('getDefaultActiveLayers returns a full boolean map', () => { + const defaults = getDefaultActiveLayers(); + expect(defaults.flights).toBe(true); + expect(defaults.firms).toBe(false); + expect(Object.values(defaults).every((value) => typeof value === 'boolean')).toBe(true); + }); + + it('saveActiveLayers persists and loadActiveLayers restores', () => { + const customized = { ...getDefaultActiveLayers(), flights: false, cctv: true }; + saveActiveLayers(customized); + expect(loadActiveLayers()).toEqual(customized); + }); + + it('migrates legacy sb_active_layers_v1 storage', () => { + localStorage.setItem( + LAYER_PREFERENCES_STORAGE_KEY, + JSON.stringify({ version: 1, layers: { flights: false } }), + ); + expect(loadActiveLayers().flights).toBe(false); + }); + + it('persists map style without clobbering layers', () => { + saveActiveLayers({ ...getDefaultActiveLayers(), flights: false }); + saveMapStyle('SATELLITE'); + expect(loadMapStyle()).toBe('SATELLITE'); + expect(loadActiveLayers().flights).toBe(false); + }); + + it('persists active filters', () => { + saveActiveFilters({ airline: ['UAL'], ship_type: ['Cargo'] }); + expect(loadActiveFilters()).toEqual({ airline: ['UAL'], ship_type: ['Cargo'] }); + }); + + it('persists layer section expand/collapse by stable id', () => { + const defaults = getDefaultLayerSectionExpanded(); + saveLayerSectionExpanded({ ...defaults, aircraft: true, overlays: false }); + expect(loadLayerSectionExpanded().aircraft).toBe(true); + expect(loadLayerSectionExpanded().overlays).toBe(false); + }); + + it('mergeLayerSectionExpanded keeps defaults for unknown section ids', () => { + const defaults = getDefaultLayerSectionExpanded(); + const merged = mergeLayerSectionExpanded(defaults, { aircraft: true }); + expect(merged.aircraft).toBe(true); + expect(merged.maritime).toBe(defaults.maritime); + }); + + it('clearActiveLayersPreference removes only layers from dashboard prefs', () => { + saveActiveLayers({ ...getDefaultActiveLayers(), flights: false }); + saveMapStyle('SATELLITE'); + clearActiveLayersPreference(); + expect(loadActiveLayers()).toEqual(getDefaultActiveLayers()); + expect(loadMapStyle()).toBe('SATELLITE'); + }); +}); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 8931f49..cbaf31e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -28,6 +28,17 @@ import OnboardingModal, { useOnboarding } from '@/components/OnboardingModal'; import ChangelogModal, { useChangelog } from '@/components/ChangelogModal'; import StartupWarmupModal, { useStartupWarmupNotice } from '@/components/StartupWarmupModal'; import type { ActiveLayers, KiwiSDR, Scanner, SelectedEntity } from '@/types/dashboard'; +import { + getDefaultActiveLayers, + getDefaultMapStyle, + loadActiveFilters, + loadActiveLayers, + loadMapStyle, + saveActiveFilters, + saveActiveLayers, + saveMapStyle, + type MapStyle, +} from '@/lib/layerPreferences'; import type { ShodanSearchMatch } from '@/types/shodan'; import { API_BASE } from '@/lib/api'; import { useDataPolling, LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling'; @@ -179,74 +190,37 @@ export default function Dashboard() { }); }, []); - const [activeLayers, setActiveLayers] = useState({ - // Aircraft — all ON - flights: true, - private: true, - jets: true, - military: true, - tracked: true, - gps_jamming: true, - // Maritime — all ON - ships_military: true, - ships_cargo: true, - ships_civilian: true, - ships_passenger: true, - ships_tracked_yachts: true, - fishing_activity: true, - // Space — only satellites - 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, - // Hazards — no fire, rest ON - earthquakes: true, - firms: false, - ukraine_alerts: true, - weather_alerts: true, - volcanoes: true, - air_quality: true, - // Infrastructure — military bases + internet outages only - cctv: false, - datacenters: false, - internet_outages: true, - power_plants: false, - military_bases: true, - trains: false, - // SIGINT — all ON except HF digital spots - kiwisdr: true, - psk_reporter: false, - satnogs: true, - tinygs: true, - scanners: true, - sigint_meshtastic: true, - sigint_aprs: true, - // Overlays - ukraine_frontline: true, - global_incidents: true, - day_night: true, - correlations: true, - contradictions: true, - uap_sightings: true, - // Biosurveillance - wastewater: true, - // CrowdThreat is operator opt-in only. - crowdthreat: false, - gt_risk: false, - // Shodan - shodan_overlay: false, - // AI Intel - ai_intel: true, - // SAR (Synthetic Aperture Radar) - sar: true, - }); + const [activeLayers, setActiveLayers] = useState(getDefaultActiveLayers); + const [activeStyle, setActiveStyle] = useState(getDefaultMapStyle); + const [activeFilters, setActiveFilters] = useState>({}); + const [layerPrefsHydrated, setLayerPrefsHydrated] = useState(false); + + // SSR/hydration cannot read localStorage in useState — load saved UI prefs after mount. + useEffect(() => { + setActiveLayers(loadActiveLayers()); + setActiveStyle(loadMapStyle()); + setActiveFilters(loadActiveFilters()); + setLayerPrefsHydrated(true); + }, []); + + useEffect(() => { + if (!layerPrefsHydrated) return; + saveActiveLayers(activeLayers); + }, [activeLayers, layerPrefsHydrated]); + + useEffect(() => { + if (!layerPrefsHydrated) return; + saveMapStyle(activeStyle); + }, [activeStyle, layerPrefsHydrated]); + + useEffect(() => { + if (!layerPrefsHydrated) return; + saveActiveFilters(activeFilters); + }, [activeFilters, layerPrefsHydrated]); + + const resetActiveLayers = useCallback(() => { + setActiveLayers(getDefaultActiveLayers()); + }, []); const regionLat = selectedEntity?.type === 'region_dossier' ? selectedEntity.extra?.lat : undefined; const regionLng = @@ -323,7 +297,7 @@ export default function Dashboard() { const layersTimerRef = useRef | null>(null); const initialLayerSyncRef = useRef(false); useEffect(() => { - if (!secondaryBootReady) return; + if (!secondaryBootReady || !layerPrefsHydrated) return; const syncLayers = (triggerRefetch: boolean) => fetch(`${API_BASE}/api/layers`, { method: 'POST', @@ -347,7 +321,7 @@ export default function Dashboard() { return () => { if (layersTimerRef.current) clearTimeout(layersTimerRef.current); }; - }, [activeLayers, secondaryBootReady]); + }, [activeLayers, secondaryBootReady, layerPrefsHydrated]); // Left panel accordion state const [leftDataMinimized, setLeftDataMinimized] = useState(false); @@ -430,8 +404,6 @@ export default function Dashboard() { bloom: true, }); - const [activeStyle, setActiveStyle] = useState('DEFAULT'); - const memoizedEffects = useMemo( () => ({ ...effects, bloom: effects.bloom && activeStyle !== 'DEFAULT', style: activeStyle }), [effects, activeStyle], @@ -478,14 +450,13 @@ export default function Dashboard() { const cycleStyle = () => { setActiveStyle((prev) => { const idx = stylesList.indexOf(prev); - const next = stylesList[(idx + 1) % stylesList.length]; + const next = stylesList[(idx + 1) % stylesList.length] as MapStyle; // Auto-toggle High-Res Satellite layer with SATELLITE style setActiveLayers((l) => ({ ...l, highres_satellite: next === 'SATELLITE' })); return next; }); }; - const [activeFilters, setActiveFilters] = useState>({}); const firstPaintActiveLayers = useMemo(() => { if (secondaryBootReady) return activeLayers; return { @@ -618,6 +589,7 @@ export default function Dashboard() { setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} diff --git a/frontend/src/components/WorldviewLeftPanel.tsx b/frontend/src/components/WorldviewLeftPanel.tsx index 3a71dad..de97815 100644 --- a/frontend/src/components/WorldviewLeftPanel.tsx +++ b/frontend/src/components/WorldviewLeftPanel.tsx @@ -57,6 +57,11 @@ import { useTranslation } from '@/i18n'; import SarModeChooserModal from './SarModeChooserModal'; import KiwiSdrConsentDialog from './ui/KiwiSdrConsentDialog'; import { extractGtAlerts } from '@/lib/gtAlerts'; +import { + getDefaultLayerSectionExpanded, + loadLayerSectionExpanded, + saveLayerSectionExpanded, +} from '@/lib/layerPreferences'; import { gtLeanLayerWarning, useRuntimeProfile } from '@/hooks/useRuntimeProfile'; function relativeTime(iso: string | undefined): string { @@ -703,6 +708,7 @@ const TOGGLE_ALL_EXCLUDED_LAYERS = new Set([ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ activeLayers, setActiveLayers, + onResetLayers, onSettingsClick, onLegendClick, gibsDate, @@ -729,6 +735,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ }: { activeLayers: ActiveLayers; setActiveLayers: React.Dispatch>; + onResetLayers?: () => void; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; @@ -1076,6 +1083,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ const sections = [ { + id: 'aircraft', label: t('layers.aircraft').toUpperCase(), icon: Plane, layers: [ @@ -1124,6 +1132,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'maritime', label: t('layers.maritime').toUpperCase(), icon: Ship, layers: [ @@ -1172,6 +1181,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'space', label: t('layers.space').toUpperCase(), icon: Satellite, layers: [ @@ -1235,6 +1245,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'hazards', label: t('layers.hazards').toUpperCase(), icon: AlertTriangle, layers: [ @@ -1295,6 +1306,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'uap', label: t('layers.uapSightings').toUpperCase(), icon: Eye, layers: [ @@ -1308,6 +1320,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'biosurveillance', label: t('layers.biosurveillance').toUpperCase(), icon: Droplets, layers: [ @@ -1321,6 +1334,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'infrastructure', label: t('layers.infrastructure').toUpperCase(), icon: Server, layers: [ @@ -1397,6 +1411,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'shodan', label: t('layers.shodanOverlay').toUpperCase(), icon: Search, layers: [ @@ -1410,6 +1425,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'sigint', label: t('layers.sigint').toUpperCase(), icon: Radio, layers: [ @@ -1465,6 +1481,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'overlays', label: t('layers.overlays').toUpperCase(), icon: Globe, layers: [ @@ -1538,16 +1555,20 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ }, ]; - const [expandedSections, setExpandedSections] = useState>(() => { - const initial: Record = {}; - sections.forEach((s) => { - // Keep high-traffic intel overlays visible on first paint (GDELT, Telegram, etc.) - initial[s.label] = s.layers.some((l) => - ['global_incidents', 'telegram_osint', 'ukraine_frontline', 'gt_risk'].includes(l.id), - ); - }); - return initial; - }); + const [expandedSections, setExpandedSections] = useState>( + getDefaultLayerSectionExpanded, + ); + const [sectionPrefsHydrated, setSectionPrefsHydrated] = useState(false); + + useEffect(() => { + setExpandedSections(loadLayerSectionExpanded()); + setSectionPrefsHydrated(true); + }, []); + + useEffect(() => { + if (!sectionPrefsHydrated) return; + saveLayerSectionExpanded(expandedSections); + }, [expandedSections, sectionPrefsHydrated]); const shipIcon = (
+ {onResetLayers ? ( + + ) : null}