mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-10 12:40:34 +02:00
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:
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"clearMeasure": "Clear measurement"
|
||||
},
|
||||
"layers": {
|
||||
"resetToDefaults": "Reset layers to defaults",
|
||||
"aircraft": "Aircraft",
|
||||
"commercialFlights": "Commercial Flights",
|
||||
"privateAircraft": "Private Aircraft",
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"clearMeasure": "پاک کردن اندازهگیری"
|
||||
},
|
||||
"layers": {
|
||||
"resetToDefaults": "بازنشانی لایهها به پیشفرض",
|
||||
"aircraft": "هواپیما",
|
||||
"commercialFlights": "پروازهای تجاری",
|
||||
"privateAircraft": "هواپیماهای خصوصی",
|
||||
|
||||
@@ -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": "私人飞机",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user