Persist dashboard layer, filter, style, and panel UI preferences in localStorage.

Hydrate saved toggles after client mount so SSR does not reset operator layer layouts on refresh, and store map style, filters, and section expand state alongside layer visibility.
This commit is contained in:
BigBodyCobain
2026-08-05 10:33:24 -06:00
parent 4c461a2ccd
commit f81cf88ecb
8 changed files with 422 additions and 87 deletions
@@ -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');
});
});
+46 -74
View File
@@ -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<ActiveLayers>({
// 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<ActiveLayers>(getDefaultActiveLayers);
const [activeStyle, setActiveStyle] = useState<MapStyle>(getDefaultMapStyle);
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
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<ReturnType<typeof setTimeout> | 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<Record<string, string[]>>({});
const firstPaintActiveLayers = useMemo<ActiveLayers>(() => {
if (secondaryBootReady) return activeLayers;
return {
@@ -618,6 +589,7 @@ export default function Dashboard() {
<WorldviewLeftPanel
activeLayers={activeLayers}
setActiveLayers={setActiveLayers}
onResetLayers={resetActiveLayers}
shodanResultCount={shodanResults.length}
onSettingsClick={() => setSettingsOpen(true)}
onLegendClick={() => setLegendOpen(true)}
+49 -13
View File
@@ -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<string>([
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<React.SetStateAction<ActiveLayers>>;
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<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
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<Record<string, boolean>>(
getDefaultLayerSectionExpanded,
);
const [sectionPrefsHydrated, setSectionPrefsHydrated] = useState(false);
useEffect(() => {
setExpandedSections(loadLayerSectionExpanded());
setSectionPrefsHydrated(true);
}, []);
useEffect(() => {
if (!sectionPrefsHydrated) return;
saveLayerSectionExpanded(expandedSections);
}, [expandedSections, sectionPrefsHydrated]);
const shipIcon = (
<svg
@@ -1639,6 +1660,20 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
</span>
</div>
<div className="flex items-center gap-2">
{onResetLayers ? (
<button
type="button"
title={t('layers.resetToDefaults')}
aria-label={t('layers.resetToDefaults')}
className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
onClick={(e) => {
e.stopPropagation();
onResetLayers();
}}
>
<RefreshCw size={16} />
</button>
) : null}
<button
title={isAllToggleableLayersOn ? 'Disable all layers' : 'Enable all layers'}
className={`${
@@ -1772,20 +1807,21 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
const anyOn = sectionLayerIds.some(
(id) => activeLayers[id as keyof typeof activeLayers],
);
const expanded = expandedSections[section.label] ?? true;
const expanded =
expandedSections[section.id] ?? getDefaultLayerSectionExpanded()[section.id] ?? false;
const totalCount = section.layers.reduce(
(sum, l) => sum + ((l.count as number) || 0),
0,
);
return (
<div key={section.label} className="flex flex-col">
<div key={section.id} className="flex flex-col">
{/* Section header */}
<div className="flex items-center justify-between mb-1">
<div
className="flex items-center gap-2 cursor-pointer flex-1"
onClick={() =>
setExpandedSections((prev) => ({ ...prev, [section.label]: !expanded }))
setExpandedSections((prev) => ({ ...prev, [section.id]: !expanded }))
}
>
<SectionIcon
+1
View File
@@ -147,6 +147,7 @@
"clearMeasure": "Clear measurement"
},
"layers": {
"resetToDefaults": "Reset layers to defaults",
"aircraft": "Aircraft",
"commercialFlights": "Commercial Flights",
"privateAircraft": "Private Aircraft",
+1
View File
@@ -147,6 +147,7 @@
"clearMeasure": "پاک کردن اندازه‌گیری"
},
"layers": {
"resetToDefaults": "بازنشانی لایه‌ها به پیش‌فرض",
"aircraft": "هواپیما",
"commercialFlights": "پروازهای تجاری",
"privateAircraft": "هواپیماهای خصوصی",
+1
View File
@@ -147,6 +147,7 @@
"clearMeasure": "Effacer la mesure"
},
"layers": {
"resetToDefaults": "Réinitialiser les couches par défaut",
"aircraft": "Aéronefs",
"commercialFlights": "Vols commerciaux",
"privateAircraft": "Aéronefs privés",
@@ -147,6 +147,7 @@
"clearMeasure": "清除测量"
},
"layers": {
"resetToDefaults": "将图层重置为默认",
"aircraft": "航空器",
"commercialFlights": "商业航班",
"privateAircraft": "私人飞机",
+244
View File
@@ -0,0 +1,244 @@
import type { ActiveLayers } from '@/types/dashboard';
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 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<ActiveLayers>;
mapStyle?: string;
filters?: Record<string, string[]>;
layerSectionsExpanded?: Record<string, boolean>;
};
/** 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)[];
function isBooleanRecord(value: unknown): value is Record<string, boolean> {
if (!value || typeof value !== 'object') return false;
return Object.values(value).every((entry) => typeof entry === 'boolean');
}
function isStringArrayRecord(value: unknown): value is Record<string, string[]> {
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<DashboardPrefsV1, 'version'>),
};
}
}
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<ActiveLayers> };
}
}
}
} catch {
/* ignore */
}
return { version: DASHBOARD_PREFS_VERSION };
}
function writeDashboardPrefs(patch: Omit<DashboardPrefsV1, 'version'>): 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<ActiveLayers> | Record<string, boolean> | 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);
}
export function saveActiveLayers(layers: ActiveLayers): void {
writeDashboardPrefs({ layers });
}
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 */
}
}
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<string, string[]> {
const saved = readDashboardPrefs().filters;
return isStringArrayRecord(saved) ? saved : {};
}
export function saveActiveFilters(filters: Record<string, string[]>): void {
writeDashboardPrefs({ filters });
}
export function getDefaultLayerSectionExpanded(): Record<string, boolean> {
return Object.fromEntries(LAYER_SECTION_IDS.map((id) => [id, id === 'overlays']));
}
export function mergeLayerSectionExpanded(
defaults: Record<string, boolean>,
saved: Record<string, boolean> | null | undefined,
): Record<string, boolean> {
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<string, boolean> {
const defaults = getDefaultLayerSectionExpanded();
const saved = readDashboardPrefs().layerSectionsExpanded;
return mergeLayerSectionExpanded(defaults, isBooleanRecord(saved) ? saved : null);
}
export function saveLayerSectionExpanded(expanded: Record<string, boolean>): void {
writeDashboardPrefs({ layerSectionsExpanded: expanded });
}