mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-07 23:03:54 +02:00
v0.9.5: The Voltron Update — modular architecture, stable IDs, parallelized boot
- Parallelized startup (60s → 15s) via ThreadPoolExecutor - Adaptive polling engine with ETag caching (no more bbox interrupts) - useCallback optimization for interpolation functions - Sliding LAYERS/INTEL edge panels replace bulky Record Panel - Modular fetcher architecture (flights, geo, infrastructure, financial, earth_observation) - Stable entity IDs for GDELT & News popups (PR #63, credit @csysp) - Admin auth (X-Admin-Key), rate limiting (slowapi), auto-updater - Docker Swarm secrets support, env_check.py validation - 85+ vitest tests, CI pipeline, geoJSON builder extraction - Server-side viewport bbox filtering reduces payloads 80%+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Former-commit-id: f2883150b5bc78ebc139d89cc966a76f7d7c0408
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildEarthquakesGeoJSON, buildJammingGeoJSON, buildCctvGeoJSON, buildKiwisdrGeoJSON,
|
||||
buildFirmsGeoJSON, buildInternetOutagesGeoJSON, buildDataCentersGeoJSON,
|
||||
buildGdeltGeoJSON, buildLiveuaGeoJSON, buildFrontlineGeoJSON
|
||||
} from '@/components/map/geoJSONBuilders';
|
||||
import type { Earthquake, GPSJammingZone, FireHotspot, InternetOutage, DataCenter, GDELTIncident, LiveUAmapIncident, CCTVCamera, KiwiSDR } from '@/types/dashboard';
|
||||
|
||||
// ─── Earthquakes ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildEarthquakesGeoJSON', () => {
|
||||
it('returns null for empty/undefined input', () => {
|
||||
expect(buildEarthquakesGeoJSON(undefined)).toBeNull();
|
||||
expect(buildEarthquakesGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('builds valid FeatureCollection from earthquake data', () => {
|
||||
const earthquakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 5.2, lat: 35.0, lng: 139.0, place: 'Japan' },
|
||||
{ id: 'eq2', mag: 3.1, lat: 40.0, lng: -120.0, place: 'California', title: 'Test Title' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe('FeatureCollection');
|
||||
expect(result!.features).toHaveLength(2);
|
||||
|
||||
const f0 = result!.features[0];
|
||||
expect(f0.geometry).toEqual({ type: 'Point', coordinates: [139.0, 35.0] });
|
||||
expect(f0.properties?.type).toBe('earthquake');
|
||||
expect(f0.properties?.name).toContain('M5.2');
|
||||
expect(f0.properties?.name).toContain('Japan');
|
||||
});
|
||||
|
||||
it('filters out entries with null lat/lng', () => {
|
||||
const earthquakes = [
|
||||
{ id: 'eq1', mag: 5.0, lat: null as any, lng: 10.0, place: 'X' },
|
||||
{ id: 'eq2', mag: 3.0, lat: 20.0, lng: 30.0, place: 'Y' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes title when present', () => {
|
||||
const earthquakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 4.0, lat: 10.0, lng: 20.0, place: 'Test', title: 'Big One' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result!.features[0].properties?.title).toBe('Big One');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GPS Jamming ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildJammingGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildJammingGeoJSON(undefined)).toBeNull();
|
||||
expect(buildJammingGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('builds polygon features with correct opacity mapping', () => {
|
||||
const zones: GPSJammingZone[] = [
|
||||
{ lat: 50, lng: 30, severity: 'high', ratio: 0.8, degraded: 100, total: 125 },
|
||||
{ lat: 45, lng: 35, severity: 'medium', ratio: 0.5, degraded: 50, total: 100 },
|
||||
{ lat: 40, lng: 25, severity: 'low', ratio: 0.2, degraded: 20, total: 100 },
|
||||
];
|
||||
const result = buildJammingGeoJSON(zones);
|
||||
expect(result!.features).toHaveLength(3);
|
||||
expect(result!.features[0].properties?.opacity).toBe(0.45);
|
||||
expect(result!.features[1].properties?.opacity).toBe(0.3);
|
||||
expect(result!.features[2].properties?.opacity).toBe(0.18);
|
||||
});
|
||||
|
||||
it('builds correct 1°×1° polygon geometry', () => {
|
||||
const zones: GPSJammingZone[] = [
|
||||
{ lat: 50, lng: 30, severity: 'high', ratio: 0.8, degraded: 100, total: 125 },
|
||||
];
|
||||
const result = buildJammingGeoJSON(zones);
|
||||
const geom = result!.features[0].geometry;
|
||||
expect(geom.type).toBe('Polygon');
|
||||
if (geom.type === 'Polygon') {
|
||||
const ring = geom.coordinates[0];
|
||||
expect(ring).toHaveLength(5); // Closed ring
|
||||
expect(ring[0]).toEqual([29.5, 49.5]);
|
||||
expect(ring[2]).toEqual([30.5, 50.5]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CCTV ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildCctvGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildCctvGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features from camera data', () => {
|
||||
const cameras: CCTVCamera[] = [
|
||||
{ id: 'cam1', lat: 40.7, lon: -74.0, direction_facing: 'North', source_agency: 'DOT' },
|
||||
];
|
||||
const result = buildCctvGeoJSON(cameras);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('cctv');
|
||||
expect(result!.features[0].properties?.name).toBe('North');
|
||||
});
|
||||
|
||||
it('respects inView filter', () => {
|
||||
const cameras: CCTVCamera[] = [
|
||||
{ id: 'cam1', lat: 40.7, lon: -74.0 },
|
||||
{ id: 'cam2', lat: 10.0, lon: 20.0 },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildCctvGeoJSON(cameras, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── KiwiSDR ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildKiwisdrGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildKiwisdrGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with SDR properties', () => {
|
||||
const receivers: KiwiSDR[] = [
|
||||
{ lat: 52.0, lon: 13.0, name: 'Berlin SDR', url: 'http://test.com', users: 3, users_max: 8, bands: 'HF', antenna: 'Long Wire', location: 'Berlin' },
|
||||
];
|
||||
const result = buildKiwisdrGeoJSON(receivers);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.name).toBe('Berlin SDR');
|
||||
expect(result!.features[0].properties?.users).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIRMS Fires ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildFirmsGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildFirmsGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies fires by FRP thresholds', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{ lat: 10, lng: 20, frp: 150, brightness: 400, confidence: 'high', daynight: 'D', acq_date: '2024-01-01', acq_time: '1200' },
|
||||
{ lat: 11, lng: 21, frp: 50, brightness: 350, confidence: 'medium', daynight: 'N', acq_date: '2024-01-01', acq_time: '0100' },
|
||||
{ lat: 12, lng: 22, frp: 10, brightness: 300, confidence: 'low', daynight: 'D', acq_date: '2024-01-01', acq_time: '1400' },
|
||||
{ lat: 13, lng: 23, frp: 2, brightness: 250, confidence: 'low', daynight: 'D', acq_date: '2024-01-01', acq_time: '1500' },
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires);
|
||||
expect(result!.features).toHaveLength(4);
|
||||
expect(result!.features[0].properties?.iconId).toBe('fire-darkred');
|
||||
expect(result!.features[1].properties?.iconId).toBe('fire-red');
|
||||
expect(result!.features[2].properties?.iconId).toBe('fire-orange');
|
||||
expect(result!.features[3].properties?.iconId).toBe('fire-yellow');
|
||||
});
|
||||
|
||||
it('formats daynight correctly', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{ lat: 10, lng: 20, frp: 5, brightness: 300, confidence: 'low', daynight: 'D', acq_date: '2024-01-01', acq_time: '1200' },
|
||||
{ lat: 11, lng: 21, frp: 5, brightness: 300, confidence: 'low', daynight: 'N', acq_date: '2024-01-01', acq_time: '0100' },
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires);
|
||||
expect(result!.features[0].properties?.daynight).toBe('Day');
|
||||
expect(result!.features[1].properties?.daynight).toBe('Night');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Internet Outages ───────────────────────────────────────────────────────
|
||||
|
||||
describe('buildInternetOutagesGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildInternetOutagesGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with detail string', () => {
|
||||
const outages: InternetOutage[] = [
|
||||
{ region_code: 'TX', region_name: 'Texas', country_code: 'US', country_name: 'United States', lat: 31.0, lng: -100.0, severity: 45, level: 'region', datasource: 'bgp' },
|
||||
];
|
||||
const result = buildInternetOutagesGeoJSON(outages);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.detail).toContain('Texas');
|
||||
expect(result!.features[0].properties?.detail).toContain('45% drop');
|
||||
});
|
||||
|
||||
it('filters out entries with null coordinates', () => {
|
||||
const outages: InternetOutage[] = [
|
||||
{ region_code: 'TX', region_name: 'Texas', country_code: 'US', country_name: 'United States', lat: null as any, lng: null as any, severity: 20, level: 'region', datasource: 'bgp' },
|
||||
{ region_code: 'CA', region_name: 'California', country_code: 'US', country_name: 'United States', lat: 37.0, lng: -122.0, severity: 30, level: 'region', datasource: 'bgp' },
|
||||
];
|
||||
const result = buildInternetOutagesGeoJSON(outages);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Data Centers ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildDataCentersGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildDataCentersGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with datacenter properties', () => {
|
||||
const dcs: DataCenter[] = [
|
||||
{ lat: 40.0, lng: -74.0, name: 'NYC-DC1', company: 'Equinix', street: '123 Main', city: 'New York', country: 'US', zip: '10001' },
|
||||
];
|
||||
const result = buildDataCentersGeoJSON(dcs);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.id).toBe('dc-0');
|
||||
expect(result!.features[0].properties?.company).toBe('Equinix');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GDELT ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildGdeltGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildGdeltGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features from GDELT incidents', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [30, 50] }, properties: { name: 'Protest', count: 5, _urls_list: [], _headlines_list: [] } },
|
||||
];
|
||||
const result = buildGdeltGeoJSON(gdelt);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('gdelt');
|
||||
expect(result!.features[0].properties?.title).toBe('Protest');
|
||||
});
|
||||
|
||||
it('filters by inView when provided', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [30, 50] }, properties: { name: 'A', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [100, 10] }, properties: { name: 'B', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildGdeltGeoJSON(gdelt, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters out entries without geometry', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [30, 50] }, properties: { name: 'Good', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
{ type: 'Feature', geometry: null as any, properties: { name: 'Bad', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
];
|
||||
const result = buildGdeltGeoJSON(gdelt);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LiveUAMap ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildLiveuaGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildLiveuaGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies violent incidents with red icon', () => {
|
||||
const incidents: LiveUAmapIncident[] = [
|
||||
{ id: '1', lat: 48.0, lng: 35.0, title: 'Missile strike in Kharkiv', date: '2024-01-01' },
|
||||
{ id: '2', lat: 49.0, lng: 36.0, title: 'Humanitarian aid delivery', date: '2024-01-01' },
|
||||
];
|
||||
const result = buildLiveuaGeoJSON(incidents);
|
||||
expect(result!.features).toHaveLength(2);
|
||||
expect(result!.features[0].properties?.iconId).toBe('icon-liveua-red');
|
||||
expect(result!.features[1].properties?.iconId).toBe('icon-liveua-yellow');
|
||||
});
|
||||
|
||||
it('filters by inView when provided', () => {
|
||||
const incidents: LiveUAmapIncident[] = [
|
||||
{ id: '1', lat: 48.0, lng: 35.0, title: 'Test', date: '2024-01-01' },
|
||||
{ id: '2', lat: 10.0, lng: 20.0, title: 'Far away', date: '2024-01-01' },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildLiveuaGeoJSON(incidents, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Frontline ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildFrontlineGeoJSON', () => {
|
||||
it('returns null for null/undefined input', () => {
|
||||
expect(buildFrontlineGeoJSON(null)).toBeNull();
|
||||
expect(buildFrontlineGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the input unchanged when valid', () => {
|
||||
const fc = { type: 'FeatureCollection' as const, features: [{ type: 'Feature' as const, properties: { name: 'zone', zone_id: 1 }, geometry: { type: 'Polygon' as const, coordinates: [[[30, 48], [31, 49], [30, 49], [30, 48]]] as [number, number][][] } }] };
|
||||
const result = buildFrontlineGeoJSON(fc);
|
||||
expect(result).toBe(fc); // Same reference — passthrough
|
||||
});
|
||||
|
||||
it('returns null for empty features array', () => {
|
||||
const fc = { type: 'FeatureCollection' as const, features: [] };
|
||||
expect(buildFrontlineGeoJSON(fc)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyAircraft, HELI_TYPES, TURBOPROP_TYPES, BIZJET_TYPES } from '@/utils/aircraftClassification';
|
||||
|
||||
describe('classifyAircraft', () => {
|
||||
// ─── Helicopter classification ────────────────────────────────────────────
|
||||
|
||||
it('classifies known helicopter types', () => {
|
||||
const heliModels = ['R22', 'R44', 'B407', 'S76', 'EC35', 'H145', 'UH60', 'AH64', 'CH47'];
|
||||
for (const model of heliModels) {
|
||||
expect(classifyAircraft(model)).toBe('heli');
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies as heli when category hint is "heli"', () => {
|
||||
expect(classifyAircraft('UNKNOWN', 'heli')).toBe('heli');
|
||||
});
|
||||
|
||||
it('category hint "heli" overrides model-based classification', () => {
|
||||
// B738 would normally be airliner, but category says heli
|
||||
expect(classifyAircraft('B738', 'heli')).toBe('heli');
|
||||
});
|
||||
|
||||
// ─── Business jet classification ──────────────────────────────────────────
|
||||
|
||||
it('classifies known bizjet types', () => {
|
||||
const bizjetModels = ['C25A', 'C680', 'CL60', 'GLEX', 'GLF5', 'LJ45', 'FA7X'];
|
||||
for (const model of bizjetModels) {
|
||||
expect(classifyAircraft(model)).toBe('bizjet');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Turboprop classification ─────────────────────────────────────────────
|
||||
|
||||
it('classifies known turboprop types', () => {
|
||||
const turbopropModels = ['AT72', 'C208', 'DHC6', 'DH8D', 'PC12', 'TBM9', 'C130'];
|
||||
for (const model of turbopropModels) {
|
||||
expect(classifyAircraft(model)).toBe('turboprop');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Airliner default ────────────────────────────────────────────────────
|
||||
|
||||
it('defaults to airliner for unknown types', () => {
|
||||
expect(classifyAircraft('B738')).toBe('airliner');
|
||||
expect(classifyAircraft('A320')).toBe('airliner');
|
||||
expect(classifyAircraft('B77W')).toBe('airliner');
|
||||
});
|
||||
|
||||
it('defaults to airliner for empty model string', () => {
|
||||
expect(classifyAircraft('')).toBe('airliner');
|
||||
});
|
||||
|
||||
// ─── Case insensitivity ──────────────────────────────────────────────────
|
||||
|
||||
it('handles lowercase model codes', () => {
|
||||
expect(classifyAircraft('r22')).toBe('heli');
|
||||
expect(classifyAircraft('c25a')).toBe('bizjet');
|
||||
expect(classifyAircraft('at72')).toBe('turboprop');
|
||||
});
|
||||
|
||||
it('handles mixed case model codes', () => {
|
||||
expect(classifyAircraft('Dh8D')).toBe('turboprop');
|
||||
expect(classifyAircraft('Glf5')).toBe('bizjet');
|
||||
});
|
||||
|
||||
// ─── Priority order ──────────────────────────────────────────────────────
|
||||
|
||||
it('prioritizes heli over bizjet (if type appears in both sets)', () => {
|
||||
// heli check comes first in the function
|
||||
for (const model of ['B06', 'S92', 'H225']) {
|
||||
expect(classifyAircraft(model)).toBe('heli');
|
||||
}
|
||||
});
|
||||
|
||||
it('prioritizes bizjet over turboprop', () => {
|
||||
// PC24 appears in both BIZJET_TYPES and TURBOPROP_TYPES
|
||||
// bizjet check comes before turboprop in the function
|
||||
if (BIZJET_TYPES.has('PC24') && TURBOPROP_TYPES.has('PC24')) {
|
||||
expect(classifyAircraft('PC24')).toBe('bizjet');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Set integrity ───────────────────────────────────────────────────────
|
||||
|
||||
it('HELI_TYPES set has expected minimum entries', () => {
|
||||
expect(HELI_TYPES.size).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('TURBOPROP_TYPES set has expected minimum entries', () => {
|
||||
expect(TURBOPROP_TYPES.size).toBeGreaterThan(80);
|
||||
});
|
||||
|
||||
it('BIZJET_TYPES set has expected minimum entries', () => {
|
||||
expect(BIZJET_TYPES.size).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { interpolatePosition } from '@/utils/positioning';
|
||||
|
||||
describe('interpolatePosition', () => {
|
||||
// ─── No-op cases ──────────────────────────────────────────────────────────
|
||||
|
||||
it('returns same position when speed is zero', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 90, 0, 10);
|
||||
expect(lat).toBe(40);
|
||||
expect(lng).toBe(-74);
|
||||
});
|
||||
|
||||
it('returns same position when speed is negative', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 90, -50, 10);
|
||||
expect(lat).toBe(40);
|
||||
expect(lng).toBe(-74);
|
||||
});
|
||||
|
||||
it('returns same position when dt is zero', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 90, 100, 0);
|
||||
expect(lat).toBe(40);
|
||||
expect(lng).toBe(-74);
|
||||
});
|
||||
|
||||
it('returns same position when dt is negative', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 90, 100, -5);
|
||||
expect(lat).toBe(40);
|
||||
expect(lng).toBe(-74);
|
||||
});
|
||||
|
||||
// ─── Cardinal directions ─────────────────────────────────────────────────
|
||||
|
||||
it('moves north when heading is 0°', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 0, 100, 10);
|
||||
expect(lat).toBeGreaterThan(40);
|
||||
expect(lng).toBeCloseTo(-74, 4); // longitude should barely change
|
||||
});
|
||||
|
||||
it('moves south when heading is 180°', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 180, 100, 10);
|
||||
expect(lat).toBeLessThan(40);
|
||||
expect(lng).toBeCloseTo(-74, 4);
|
||||
});
|
||||
|
||||
it('moves east when heading is 90°', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 90, 100, 10);
|
||||
expect(lat).toBeCloseTo(40, 4);
|
||||
expect(lng).toBeGreaterThan(-74);
|
||||
});
|
||||
|
||||
it('moves west when heading is 270°', () => {
|
||||
const [lat, lng] = interpolatePosition(40, -74, 270, 100, 10);
|
||||
expect(lat).toBeCloseTo(40, 4);
|
||||
expect(lng).toBeLessThan(-74);
|
||||
});
|
||||
|
||||
// ─── Distance proportionality ────────────────────────────────────────────
|
||||
|
||||
it('doubles distance when speed doubles', () => {
|
||||
const [lat1] = interpolatePosition(0, 0, 0, 100, 10);
|
||||
const [lat2] = interpolatePosition(0, 0, 0, 200, 10);
|
||||
const dist1 = lat1; // distance from origin going north
|
||||
const dist2 = lat2;
|
||||
expect(dist2).toBeCloseTo(dist1 * 2, 4);
|
||||
});
|
||||
|
||||
it('doubles distance when time doubles', () => {
|
||||
const [lat1] = interpolatePosition(0, 0, 0, 100, 10);
|
||||
const [lat2] = interpolatePosition(0, 0, 0, 100, 20);
|
||||
const dist1 = lat1;
|
||||
const dist2 = lat2;
|
||||
expect(dist2).toBeCloseTo(dist1 * 2, 4);
|
||||
});
|
||||
|
||||
// ─── Clamping ────────────────────────────────────────────────────────────
|
||||
|
||||
it('clamps time to maxDt (prevents drift on stale data)', () => {
|
||||
// maxDt=65 by default, so dt=1000 should give same result as dt=65
|
||||
const [lat1] = interpolatePosition(0, 0, 0, 100, 65);
|
||||
const [lat2] = interpolatePosition(0, 0, 0, 100, 1000);
|
||||
expect(lat1).toBeCloseTo(lat2, 6);
|
||||
});
|
||||
|
||||
it('clamps distance to maxDist when specified', () => {
|
||||
// At 100 knots for 60 seconds = ~3086m, maxDist=1000 should cap it
|
||||
const [lat1] = interpolatePosition(0, 0, 0, 100, 60, 1000);
|
||||
const [lat2] = interpolatePosition(0, 0, 0, 100, 60, 0); // no cap
|
||||
expect(lat1).toBeLessThan(lat2);
|
||||
});
|
||||
|
||||
// ─── Known calculation ───────────────────────────────────────────────────
|
||||
|
||||
it('produces correct magnitude for known speed/time', () => {
|
||||
// 1 knot = 1 NM/hr = 1852 m/hr ≈ 0.5144 m/s
|
||||
// 100 knots for 10 seconds = 514.4 meters
|
||||
// At equator, 1° lat ≈ 111,320m, so 514.4m ≈ 0.00462°
|
||||
const [lat] = interpolatePosition(0, 0, 0, 100, 10);
|
||||
const expectedDegrees = (100 * 0.5144 * 10) / 111320;
|
||||
expect(lat).toBeCloseTo(expectedDegrees, 4);
|
||||
});
|
||||
|
||||
// ─── Edge cases ──────────────────────────────────────────────────────────
|
||||
|
||||
it('handles positions near the poles', () => {
|
||||
const [lat, lng] = interpolatePosition(89.9, 0, 0, 10, 5);
|
||||
expect(lat).toBeGreaterThan(89.9);
|
||||
expect(Number.isFinite(lat)).toBe(true);
|
||||
expect(Number.isFinite(lng)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles positions near the dateline', () => {
|
||||
const [lat, lng] = interpolatePosition(0, 179.99, 90, 100, 10);
|
||||
expect(Number.isFinite(lat)).toBe(true);
|
||||
expect(Number.isFinite(lng)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeNightPolygon } from '@/utils/solarTerminator';
|
||||
|
||||
/** Extract polygon ring from result (type-narrowing helper) */
|
||||
function getRing(result: GeoJSON.FeatureCollection): number[][] {
|
||||
const geom = result.features[0].geometry;
|
||||
if (geom.type !== 'Polygon') throw new Error('Expected Polygon geometry');
|
||||
return geom.coordinates[0];
|
||||
}
|
||||
|
||||
describe('computeNightPolygon', () => {
|
||||
// ─── Structure validation ────────────────────────────────────────────────
|
||||
|
||||
it('returns a valid GeoJSON FeatureCollection', () => {
|
||||
const result = computeNightPolygon();
|
||||
expect(result.type).toBe('FeatureCollection');
|
||||
expect(result.features).toHaveLength(1);
|
||||
expect(result.features[0].type).toBe('Feature');
|
||||
expect(result.features[0].geometry.type).toBe('Polygon');
|
||||
});
|
||||
|
||||
it('polygon has at least 360 vertices (one per degree of longitude)', () => {
|
||||
const ring = getRing(computeNightPolygon());
|
||||
// 361 terminator points + 2 closing corners + 1 ring-close = ≥364
|
||||
expect(ring.length).toBeGreaterThanOrEqual(364);
|
||||
});
|
||||
|
||||
it('polygon ring is closed (first and last points match)', () => {
|
||||
const ring = getRing(computeNightPolygon());
|
||||
expect(ring[ring.length - 1]).toEqual(ring[0]);
|
||||
});
|
||||
|
||||
// ─── Coordinate bounds ───────────────────────────────────────────────────
|
||||
|
||||
it('all coordinates are within valid lat/lng bounds', () => {
|
||||
const ring = getRing(computeNightPolygon());
|
||||
for (const [lng, lat] of ring) {
|
||||
expect(lng).toBeGreaterThanOrEqual(-180);
|
||||
expect(lng).toBeLessThanOrEqual(180);
|
||||
expect(lat).toBeGreaterThanOrEqual(-85);
|
||||
expect(lat).toBeLessThanOrEqual(85);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Deterministic for same input ────────────────────────────────────────
|
||||
|
||||
it('returns identical result for the same date', () => {
|
||||
const date = new Date('2024-06-21T12:00:00Z');
|
||||
const result1 = computeNightPolygon(date);
|
||||
const result2 = computeNightPolygon(date);
|
||||
expect(result1).toEqual(result2);
|
||||
});
|
||||
|
||||
// ─── Seasonal behavior ──────────────────────────────────────────────────
|
||||
|
||||
it('equinox produces roughly symmetric polygon', () => {
|
||||
const equinox = new Date('2024-03-20T12:00:00Z');
|
||||
const ring = getRing(computeNightPolygon(equinox));
|
||||
const lats = ring.map(([, lat]: number[]) => lat);
|
||||
const maxLat = Math.max(...lats);
|
||||
const minLat = Math.min(...lats);
|
||||
expect(maxLat).toBeGreaterThan(50);
|
||||
expect(minLat).toBeLessThan(-50);
|
||||
});
|
||||
|
||||
it('summer solstice shifts night polygon southward', () => {
|
||||
const summer = new Date('2024-06-21T00:00:00Z');
|
||||
const ring = getRing(computeNightPolygon(summer));
|
||||
const terminatorLats = ring
|
||||
.filter(([lng]: number[]) => lng >= -180 && lng <= 180)
|
||||
.slice(0, 361)
|
||||
.map(([, lat]: number[]) => lat);
|
||||
const avgLat = terminatorLats.reduce((a: number, b: number) => a + b, 0) / terminatorLats.length;
|
||||
expect(avgLat).toBeLessThan(15);
|
||||
});
|
||||
|
||||
// ─── Different times produce different results ──────────────────────────
|
||||
|
||||
it('produces different polygons for different times of day', () => {
|
||||
const morning = new Date('2024-06-21T06:00:00Z');
|
||||
const evening = new Date('2024-06-21T18:00:00Z');
|
||||
const ringM = getRing(computeNightPolygon(morning));
|
||||
const ringE = getRing(computeNightPolygon(evening));
|
||||
expect(ringM[0]).not.toEqual(ringE[0]);
|
||||
});
|
||||
});
|
||||
@@ -4,18 +4,18 @@
|
||||
--background: #000000;
|
||||
--foreground: #ededed;
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: rgb(17, 24, 39);
|
||||
--bg-tertiary: rgb(31, 41, 55);
|
||||
--bg-panel: rgba(17, 24, 39, 0.8);
|
||||
--border-primary: rgb(55, 65, 81);
|
||||
--border-secondary: rgb(75, 85, 99);
|
||||
--bg-secondary: rgb(5, 5, 8);
|
||||
--bg-tertiary: rgb(12, 12, 16);
|
||||
--bg-panel: rgba(0, 0, 0, 0.85);
|
||||
--border-primary: rgb(10, 12, 15);
|
||||
--border-secondary: rgb(20, 24, 28);
|
||||
--text-primary: rgb(243, 244, 246);
|
||||
--text-secondary: rgb(156, 163, 175);
|
||||
--text-muted: rgb(107, 114, 128);
|
||||
--text-secondary: rgb(34, 211, 238);
|
||||
--text-muted: rgb(8, 145, 178);
|
||||
--text-heading: rgb(236, 254, 255);
|
||||
--hover-accent: rgba(8, 51, 68, 0.2);
|
||||
--scrollbar-thumb: rgba(100, 116, 139, 0.3);
|
||||
--scrollbar-thumb-hover: rgba(100, 116, 139, 0.5);
|
||||
--scrollbar-thumb: rgba(8, 145, 178, 0.3);
|
||||
--scrollbar-thumb-hover: rgba(8, 145, 178, 0.5);
|
||||
}
|
||||
|
||||
/* Light theme: only the map basemap changes — UI stays dark */
|
||||
@@ -23,18 +23,18 @@
|
||||
--background: #000000;
|
||||
--foreground: #ededed;
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: rgb(17, 24, 39);
|
||||
--bg-tertiary: rgb(31, 41, 55);
|
||||
--bg-panel: rgba(17, 24, 39, 0.8);
|
||||
--border-primary: rgb(55, 65, 81);
|
||||
--border-secondary: rgb(75, 85, 99);
|
||||
--bg-secondary: rgb(5, 5, 8);
|
||||
--bg-tertiary: rgb(12, 12, 16);
|
||||
--bg-panel: rgba(0, 0, 0, 0.85);
|
||||
--border-primary: rgb(10, 12, 15);
|
||||
--border-secondary: rgb(20, 24, 28);
|
||||
--text-primary: rgb(243, 244, 246);
|
||||
--text-secondary: rgb(156, 163, 175);
|
||||
--text-muted: rgb(107, 114, 128);
|
||||
--text-secondary: rgb(34, 211, 238);
|
||||
--text-muted: rgb(8, 145, 178);
|
||||
--text-heading: rgb(236, 254, 255);
|
||||
--hover-accent: rgba(8, 51, 68, 0.2);
|
||||
--scrollbar-thumb: rgba(100, 116, 139, 0.3);
|
||||
--scrollbar-thumb-hover: rgba(100, 116, 139, 0.5);
|
||||
--scrollbar-thumb: rgba(8, 145, 178, 0.3);
|
||||
--scrollbar-thumb-hover: rgba(8, 145, 178, 0.5);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -114,6 +114,80 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── MATRIX HUD COLOR THEME ── */
|
||||
/* Remaps cyan accents → green within .hud-zone containers only */
|
||||
[data-hud="matrix"] .hud-zone {
|
||||
--text-secondary: #4ade80;
|
||||
--text-muted: #16a34a;
|
||||
--text-heading: #bbf7d0;
|
||||
--hover-accent: rgba(5, 46, 22, 0.2);
|
||||
--scrollbar-thumb: rgba(22, 163, 74, 0.3);
|
||||
--scrollbar-thumb-hover: rgba(22, 163, 74, 0.5);
|
||||
}
|
||||
|
||||
/* --- Text color overrides --- */
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-300 { color: #86efac !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-400 { color: #4ade80 !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-500 { color: #22c55e !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-600 { color: #16a34a !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-700 { color: #15803d !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-500\/50 { color: rgba(34, 197, 94, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-500\/70 { color: rgba(34, 197, 94, 0.7) !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-500\/80 { color: rgba(34, 197, 94, 0.8) !important; }
|
||||
|
||||
/* --- Background color overrides --- */
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-400 { background-color: #4ade80 !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-300 { background-color: #86efac !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-500 { background-color: #22c55e !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-500\/10 { background-color: rgba(34, 197, 94, 0.1) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-500\/20 { background-color: rgba(34, 197, 94, 0.2) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-500\/30 { background-color: rgba(34, 197, 94, 0.3) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-900\/30 { background-color: rgba(20, 83, 45, 0.3) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-900\/50 { background-color: rgba(20, 83, 45, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-900\/60 { background-color: rgba(20, 83, 45, 0.6) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-950\/10 { background-color: rgba(5, 46, 22, 0.1) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-950\/30 { background-color: rgba(5, 46, 22, 0.3) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-950\/40 { background-color: rgba(5, 46, 22, 0.4) !important; }
|
||||
|
||||
/* --- Border color overrides --- */
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-400 { border-color: #4ade80 !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-500 { border-color: #22c55e !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-700 { border-color: #15803d !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-800 { border-color: #166534 !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-900 { border-color: #14532d !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-500\/10 { border-color: rgba(34, 197, 94, 0.1) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-500\/20 { border-color: rgba(34, 197, 94, 0.2) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-500\/30 { border-color: rgba(34, 197, 94, 0.3) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-500\/40 { border-color: rgba(34, 197, 94, 0.4) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-500\/50 { border-color: rgba(34, 197, 94, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-800\/40 { border-color: rgba(22, 101, 52, 0.4) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-800\/50 { border-color: rgba(22, 101, 52, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-800\/60 { border-color: rgba(22, 101, 52, 0.6) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-900\/50 { border-color: rgba(20, 83, 45, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-b-cyan-900 { border-bottom-color: #14532d !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-l-cyan-500 { border-left-color: #22c55e !important; }
|
||||
|
||||
/* --- Hover text --- */
|
||||
[data-hud="matrix"] .hud-zone .hover\:text-cyan-300:hover { color: #86efac !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:text-cyan-400:hover { color: #4ade80 !important; }
|
||||
|
||||
/* --- Hover background --- */
|
||||
[data-hud="matrix"] .hud-zone .hover\:bg-cyan-300:hover { background-color: #86efac !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:bg-cyan-500\/20:hover { background-color: rgba(34, 197, 94, 0.2) !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:bg-cyan-900\/50:hover { background-color: rgba(20, 83, 45, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:bg-cyan-950\/30:hover { background-color: rgba(5, 46, 22, 0.3) !important; }
|
||||
|
||||
/* --- Hover border --- */
|
||||
[data-hud="matrix"] .hud-zone .hover\:border-cyan-300:hover { border-color: #86efac !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:border-cyan-500:hover { border-color: #22c55e !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:border-cyan-500\/40:hover { border-color: rgba(34, 197, 94, 0.4) !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:border-cyan-500\/50:hover { border-color: rgba(34, 197, 94, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:border-cyan-600:hover { border-color: #16a34a !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:border-cyan-800:hover { border-color: #166534 !important; }
|
||||
|
||||
/* --- Accent (range inputs) --- */
|
||||
[data-hud="matrix"] .hud-zone .accent-cyan-500 { accent-color: #22c55e !important; }
|
||||
|
||||
/* Focus mode: dim the map canvas (tiles + drawn layers) when a popup is active.
|
||||
Inside MapLibre's DOM, .maplibregl-canvas-container is a SIBLING of .maplibregl-popup,
|
||||
so this filter dims the map without affecting the popup at all. */
|
||||
|
||||
+31
-198
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import dynamic from 'next/dynamic';
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
@@ -20,6 +19,11 @@ import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import { DashboardDataProvider } from "@/lib/DashboardDataContext";
|
||||
import OnboardingModal, { useOnboarding } from "@/components/OnboardingModal";
|
||||
import ChangelogModal, { useChangelog } from "@/components/ChangelogModal";
|
||||
import type { SelectedEntity } from "@/types/dashboard";
|
||||
import { NOMINATIM_DEBOUNCE_MS } from "@/lib/constants";
|
||||
import { useDataPolling } from "@/hooks/useDataPolling";
|
||||
import { useReverseGeocode } from "@/hooks/useReverseGeocode";
|
||||
import { useRegionDossier } from "@/hooks/useRegionDossier";
|
||||
|
||||
// Use dynamic loads for Maplibre to avoid SSR window is not defined errors
|
||||
const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ssr: false });
|
||||
@@ -62,10 +66,10 @@ function LocateBar({ onLocate }: { onLocate: (lat: number, lng: number) => void
|
||||
headers: { 'Accept-Language': 'en' },
|
||||
});
|
||||
const data = await res.json();
|
||||
setResults(data.map((r: any) => ({ label: r.display_name, lat: parseFloat(r.lat), lng: parseFloat(r.lon) })));
|
||||
setResults(data.map((r: { display_name: string; lat: string; lon: string }) => ({ label: r.display_name, lat: parseFloat(r.lat), lng: parseFloat(r.lon) })));
|
||||
} catch { setResults([]); }
|
||||
setLoading(false);
|
||||
}, 350);
|
||||
}, NOMINATIM_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const handleSelect = (r: { lat: number; lng: number }) => {
|
||||
@@ -119,10 +123,12 @@ function LocateBar({ onLocate }: { onLocate: (lat: number, lng: number) => void
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const dataRef = useRef<any>({});
|
||||
const [dataVersion, setDataVersion] = useState(0);
|
||||
// Stable reference for child components — only changes when dataVersion increments
|
||||
const data = dataRef.current;
|
||||
const { data, dataVersion, backendStatus } = useDataPolling();
|
||||
const { mouseCoords, locationLabel, handleMouseCoords } = useReverseGeocode();
|
||||
const [selectedEntity, setSelectedEntity] = useState<SelectedEntity | null>(null);
|
||||
const [trackedSdr, setTrackedSdr] = useState<any>(null);
|
||||
const { regionDossier, regionDossierLoading, handleMapRightClick } = useRegionDossier(selectedEntity, setSelectedEntity);
|
||||
|
||||
const [uiVisible, setUiVisible] = useState(true);
|
||||
const [leftOpen, setLeftOpen] = useState(true);
|
||||
const [rightOpen, setRightOpen] = useState(true);
|
||||
@@ -143,6 +149,7 @@ export default function Dashboard() {
|
||||
ships_cargo: true,
|
||||
ships_civilian: false,
|
||||
ships_passenger: true,
|
||||
ships_tracked_yachts: true,
|
||||
earthquakes: true,
|
||||
cctv: false,
|
||||
ukraine_frontline: true,
|
||||
@@ -177,12 +184,11 @@ export default function Dashboard() {
|
||||
const idx = stylesList.indexOf(prev);
|
||||
const next = stylesList[(idx + 1) % stylesList.length];
|
||||
// Auto-toggle High-Res Satellite layer with SATELLITE style
|
||||
setActiveLayers((l: any) => ({ ...l, highres_satellite: next === 'SATELLITE' }));
|
||||
setActiveLayers((l) => ({ ...l, highres_satellite: next === 'SATELLITE' }));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const [selectedEntity, setSelectedEntity] = useState<{ type: string, id: string | number, extra?: any } | null>(null);
|
||||
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
|
||||
const [flyToLocation, setFlyToLocation] = useState<{ lat: number, lng: number, ts: number } | null>(null);
|
||||
|
||||
@@ -191,184 +197,9 @@ export default function Dashboard() {
|
||||
const [eavesdropLocation, setEavesdropLocation] = useState<{ lat: number, lng: number } | null>(null);
|
||||
const [cameraCenter, setCameraCenter] = useState<{ lat: number, lng: number } | null>(null);
|
||||
|
||||
// Mouse coordinate + reverse geocoding state
|
||||
const [mouseCoords, setMouseCoords] = useState<{ lat: number, lng: number } | null>(null);
|
||||
const [locationLabel, setLocationLabel] = useState('');
|
||||
|
||||
// Onboarding & connection status
|
||||
const { showOnboarding, setShowOnboarding } = useOnboarding();
|
||||
const { showChangelog, setShowChangelog } = useChangelog();
|
||||
const [backendStatus, setBackendStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
|
||||
const geocodeCache = useRef<Map<string, string>>(new Map());
|
||||
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const lastGeocodedPos = useRef<{ lat: number; lng: number } | null>(null);
|
||||
const geocodeAbort = useRef<AbortController | null>(null);
|
||||
|
||||
const handleMouseCoords = useCallback((coords: { lat: number, lng: number }) => {
|
||||
setMouseCoords(coords);
|
||||
|
||||
// Throttle reverse geocoding to every 1500ms + distance check
|
||||
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
|
||||
geocodeTimer.current = setTimeout(async () => {
|
||||
// Skip if cursor hasn't moved far enough (0.05 degrees ~= 5km)
|
||||
if (lastGeocodedPos.current) {
|
||||
const dLat = Math.abs(coords.lat - lastGeocodedPos.current.lat);
|
||||
const dLng = Math.abs(coords.lng - lastGeocodedPos.current.lng);
|
||||
if (dLat < 0.05 && dLng < 0.05) return;
|
||||
}
|
||||
|
||||
const gridKey = `${(coords.lat).toFixed(2)},${(coords.lng).toFixed(2)}`;
|
||||
const cached = geocodeCache.current.get(gridKey);
|
||||
if (cached) {
|
||||
setLocationLabel(cached);
|
||||
lastGeocodedPos.current = coords;
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any in-flight geocode request
|
||||
if (geocodeAbort.current) geocodeAbort.current.abort();
|
||||
geocodeAbort.current = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${coords.lat}&lon=${coords.lng}&format=json&zoom=10&addressdetails=1`,
|
||||
{ headers: { 'Accept-Language': 'en' }, signal: geocodeAbort.current.signal }
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const addr = data.address || {};
|
||||
const city = addr.city || addr.town || addr.village || addr.county || '';
|
||||
const state = addr.state || addr.region || '';
|
||||
const country = addr.country || '';
|
||||
const parts = [city, state, country].filter(Boolean);
|
||||
const label = parts.join(', ') || data.display_name?.split(',').slice(0, 3).join(',') || 'Unknown';
|
||||
|
||||
// LRU-style cache pruning: keep max 500 entries (Map preserves insertion order)
|
||||
if (geocodeCache.current.size > 500) {
|
||||
const iter = geocodeCache.current.keys();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const key = iter.next().value;
|
||||
if (key !== undefined) geocodeCache.current.delete(key);
|
||||
}
|
||||
}
|
||||
geocodeCache.current.set(gridKey, label);
|
||||
setLocationLabel(label);
|
||||
lastGeocodedPos.current = coords;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') { /* Silently fail - keep last label */ }
|
||||
}
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
// Region dossier state (right-click intelligence)
|
||||
const [regionDossier, setRegionDossier] = useState<any>(null);
|
||||
const [regionDossierLoading, setRegionDossierLoading] = useState(false);
|
||||
|
||||
const handleMapRightClick = useCallback(async (coords: { lat: number, lng: number }) => {
|
||||
setSelectedEntity({ type: 'region_dossier', id: `${coords.lat.toFixed(4)}_${coords.lng.toFixed(4)}`, extra: coords });
|
||||
setRegionDossierLoading(true);
|
||||
setRegionDossier(null);
|
||||
try {
|
||||
const [dossierRes, sentinelRes] = await Promise.allSettled([
|
||||
fetch(`${API_BASE}/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`),
|
||||
fetch(`${API_BASE}/api/sentinel2/search?lat=${coords.lat}&lng=${coords.lng}`),
|
||||
]);
|
||||
let dossierData: any = {};
|
||||
if (dossierRes.status === 'fulfilled' && dossierRes.value.ok) {
|
||||
dossierData = await dossierRes.value.json();
|
||||
}
|
||||
let sentinelData = null;
|
||||
if (sentinelRes.status === 'fulfilled' && sentinelRes.value.ok) {
|
||||
sentinelData = await sentinelRes.value.json();
|
||||
}
|
||||
setRegionDossier({ ...dossierData, sentinel2: sentinelData });
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch region dossier", e);
|
||||
} finally {
|
||||
setRegionDossierLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Clear dossier when selecting a different entity type
|
||||
useEffect(() => {
|
||||
if (selectedEntity?.type !== 'region_dossier') {
|
||||
setRegionDossier(null);
|
||||
setRegionDossierLoading(false);
|
||||
}
|
||||
}, [selectedEntity]);
|
||||
|
||||
// ETag tracking for conditional requests
|
||||
const fastEtag = useRef<string | null>(null);
|
||||
const slowEtag = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Track whether we've received substantial data yet (backend may still be starting up)
|
||||
let hasData = false;
|
||||
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const fetchFastData = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers });
|
||||
if (res.status === 304) { setBackendStatus('connected'); scheduleNext('fast'); return; }
|
||||
if (res.ok) {
|
||||
setBackendStatus('connected');
|
||||
fastEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
setDataVersion(v => v + 1);
|
||||
// Check if we got real data (backend finished loading)
|
||||
const flights = json.commercial_flights?.length || 0;
|
||||
if (flights > 100) hasData = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching fast live data", e);
|
||||
setBackendStatus('disconnected');
|
||||
}
|
||||
scheduleNext('fast');
|
||||
};
|
||||
|
||||
const fetchSlowData = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (slowEtag.current) headers['If-None-Match'] = slowEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/slow`, { headers });
|
||||
if (res.status === 304) { scheduleNext('slow'); return; }
|
||||
if (res.ok) {
|
||||
slowEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
setDataVersion(v => v + 1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching slow live data", e);
|
||||
}
|
||||
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
|
||||
fastTimerId = setTimeout(fetchFastData, delay);
|
||||
} else {
|
||||
const delay = hasData ? 120000 : 5000; // 5s startup retry → 120s steady state
|
||||
slowTimerId = setTimeout(fetchSlowData, delay);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFastData();
|
||||
fetchSlowData();
|
||||
|
||||
return () => {
|
||||
if (fastTimerId) clearTimeout(fastTimerId);
|
||||
if (slowTimerId) clearTimeout(slowTimerId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DashboardDataProvider data={data} selectedEntity={selectedEntity} setSelectedEntity={setSelectedEntity}>
|
||||
@@ -399,6 +230,8 @@ export default function Dashboard() {
|
||||
setMeasurePoints(prev => prev.length >= 3 ? prev : [...prev, pt]);
|
||||
}}
|
||||
measurePoints={measurePoints}
|
||||
trackedSdr={trackedSdr}
|
||||
setTrackedSdr={setTrackedSdr}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -409,7 +242,7 @@ export default function Dashboard() {
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="absolute top-6 left-6 z-[200] pointer-events-none flex items-center gap-4"
|
||||
className="absolute top-6 left-6 z-[200] pointer-events-none flex items-center gap-4 hud-zone"
|
||||
>
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
{/* Target Reticle Icon */}
|
||||
@@ -428,61 +261,61 @@ export default function Dashboard() {
|
||||
</motion.div>
|
||||
|
||||
{/* SYSTEM METRICS TOP LEFT */}
|
||||
<div className="absolute top-2 left-6 text-[8px] font-mono tracking-widest text-cyan-500/50 z-[200] pointer-events-none">
|
||||
<div className="absolute top-2 left-6 text-[8px] font-mono tracking-widest text-cyan-500/50 z-[200] pointer-events-none hud-zone">
|
||||
OPTIC VIS:113 SRC:180 DENS:1.42 0.8ms
|
||||
</div>
|
||||
|
||||
{/* SYSTEM METRICS TOP RIGHT */}
|
||||
<div className="absolute top-2 right-6 text-[9px] flex flex-col items-end font-mono tracking-widest text-[var(--text-muted)] z-[200] pointer-events-none">
|
||||
<div className="absolute top-2 right-6 text-[9px] flex flex-col items-end font-mono tracking-widest text-[var(--text-muted)] z-[200] pointer-events-none hud-zone">
|
||||
<div>RTX</div>
|
||||
<div>VSR</div>
|
||||
</div>
|
||||
|
||||
{/* LEFT HUD CONTAINER — slides off left edge when hidden */}
|
||||
<motion.div
|
||||
className="absolute left-6 top-24 bottom-6 w-80 flex flex-col gap-6 z-[200] pointer-events-none"
|
||||
className="absolute left-6 top-24 bottom-6 w-80 flex flex-col gap-6 z-[200] pointer-events-none hud-zone"
|
||||
animate={{ x: leftOpen ? 0 : -360 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
|
||||
>
|
||||
{/* LEFT PANEL - DATA LAYERS */}
|
||||
<ErrorBoundary name="WorldviewLeftPanel">
|
||||
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} onEntityClick={setSelectedEntity} onFlyTo={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} />
|
||||
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} onEntityClick={setSelectedEntity} onFlyTo={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} trackedSdr={trackedSdr} setTrackedSdr={setTrackedSdr} />
|
||||
</ErrorBoundary>
|
||||
</motion.div>
|
||||
|
||||
{/* LEFT SIDEBAR TOGGLE TAB */}
|
||||
<motion.div
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-[201] pointer-events-auto"
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-[201] pointer-events-auto hud-zone"
|
||||
animate={{ x: leftOpen ? 344 : 0 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setLeftOpen(!leftOpen)}
|
||||
className="flex flex-col items-center gap-1.5 py-5 px-1.5 bg-[var(--bg-primary)]/80 backdrop-blur-md border border-[var(--border-primary)] border-l-0 rounded-r-md text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-900/50 transition-colors shadow-[2px_0_12px_rgba(0,0,0,0.4)]"
|
||||
className="flex flex-col items-center gap-1.5 py-5 px-1.5 bg-cyan-400 border border-cyan-400 border-l-0 rounded-r-md text-black hover:bg-cyan-300 hover:border-cyan-300 transition-colors shadow-[2px_0_12px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
{leftOpen ? <ChevronLeft size={10} /> : <ChevronRight size={10} />}
|
||||
<span className="text-[7px] font-mono tracking-[0.2em] text-[var(--text-muted)]" style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>LAYERS</span>
|
||||
<span className="text-[7px] font-mono tracking-[0.2em] font-bold text-black" style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>LAYERS</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* RIGHT SIDEBAR TOGGLE TAB */}
|
||||
<motion.div
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-[201] pointer-events-auto"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-[201] pointer-events-auto hud-zone"
|
||||
animate={{ x: rightOpen ? -344 : 0 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setRightOpen(!rightOpen)}
|
||||
className="flex flex-col items-center gap-1.5 py-5 px-1.5 bg-[var(--bg-primary)]/80 backdrop-blur-md border border-[var(--border-primary)] border-r-0 rounded-l-md text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-900/50 transition-colors shadow-[-2px_0_12px_rgba(0,0,0,0.4)]"
|
||||
className="flex flex-col items-center gap-1.5 py-5 px-1.5 bg-cyan-400 border border-cyan-400 border-r-0 rounded-l-md text-black hover:bg-cyan-300 hover:border-cyan-300 transition-colors shadow-[-2px_0_12px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
{rightOpen ? <ChevronRight size={10} /> : <ChevronLeft size={10} />}
|
||||
<span className="text-[7px] font-mono tracking-[0.2em] text-[var(--text-muted)]" style={{ writingMode: 'vertical-rl' }}>INTEL</span>
|
||||
<span className="text-[7px] font-mono tracking-[0.2em] font-bold text-black" style={{ writingMode: 'vertical-rl' }}>INTEL</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* RIGHT HUD CONTAINER — slides off right edge when hidden */}
|
||||
<motion.div
|
||||
className="absolute right-6 top-24 bottom-6 w-80 flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2"
|
||||
className="absolute right-6 top-24 bottom-6 w-80 flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2 hud-zone"
|
||||
animate={{ x: rightOpen ? 0 : 360 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
|
||||
>
|
||||
@@ -548,7 +381,7 @@ export default function Dashboard() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1, duration: 1 }}
|
||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-auto flex flex-col items-center gap-2"
|
||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-auto flex flex-col items-center gap-2 hud-zone"
|
||||
>
|
||||
{/* LOCATE BAR — search by coordinates or place name */}
|
||||
<LocateBar onLocate={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} />
|
||||
|
||||
@@ -4,55 +4,55 @@ import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Zap, Ship, Download, Shield, Bug, Heart } from "lucide-react";
|
||||
|
||||
const CURRENT_VERSION = "0.9";
|
||||
const CURRENT_VERSION = "0.9.5";
|
||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||
|
||||
const NEW_FEATURES = [
|
||||
{
|
||||
icon: <Download size={14} className="text-cyan-400" />,
|
||||
title: "In-App Auto-Updater",
|
||||
desc: "One-click updates directly from the dashboard. Downloads the latest release, backs up your files, extracts over the project, and auto-restarts. Manual download fallback included if anything goes wrong.",
|
||||
icon: <Zap size={14} className="text-cyan-400" />,
|
||||
title: "Parallelized Boot (15s Cold Start)",
|
||||
desc: "Backend startup now runs fast-tier, slow-tier, and airport data concurrently via ThreadPoolExecutor. Boot time cut from 60s+ to ~15s.",
|
||||
color: "cyan",
|
||||
},
|
||||
{
|
||||
icon: <Ship size={14} className="text-blue-400" />,
|
||||
title: "Granular Ship Layer Controls",
|
||||
desc: "Ships split into 4 independent toggles: Military/Carriers, Cargo/Tankers, Civilian Vessels, and Cruise/Passenger. Each shows its own live count in the sidebar.",
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
icon: <Shield size={14} className="text-green-400" />,
|
||||
title: "Stable Entity Selection",
|
||||
desc: "Ship and flight markers now use MMSI/callsign IDs instead of volatile array indices. Selecting a ship or plane stays locked on even when data refreshes every 60 seconds.",
|
||||
title: "Adaptive Polling + ETag Caching",
|
||||
desc: "Data polling engine rebuilt with adaptive retry (3s startup, 15s steady state) and ETag conditional caching. Map panning no longer interrupts data flow.",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
icon: <X size={14} className="text-red-400" />,
|
||||
title: "Dismissible Threat Alerts",
|
||||
desc: "Click the X on any threat alert bubble to dismiss it for the session. Uses stable content hashing so dismissed alerts stay hidden across 60-second data refreshes.",
|
||||
color: "red",
|
||||
icon: <Ship size={14} className="text-blue-400" />,
|
||||
title: "Sliding Edge Panels (LAYERS / INTEL)",
|
||||
desc: "Replaced bulky Record Panel with spring-animated side tabs. LAYERS on the left, INTEL (News, Markets, Radio, Find) on the right. Premium tactical HUD feel.",
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
icon: <Zap size={14} className="text-yellow-400" />,
|
||||
title: "Faster Data Loading",
|
||||
desc: "GDELT military incidents now load instantly with background title enrichment instead of blocking for 2+ minutes. Eliminated duplicate startup fetch jobs for faster boot.",
|
||||
icon: <Download size={14} className="text-yellow-400" />,
|
||||
title: "Admin Auth + Rate Limiting + Auto-Updater",
|
||||
desc: "Settings and system endpoints protected by X-Admin-Key. All endpoints rate-limited via slowapi. One-click auto-update from GitHub releases with safe backup/restart.",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
icon: <Shield size={14} className="text-purple-400" />,
|
||||
title: "Docker Swarm Secrets Support",
|
||||
desc: "Production deployments can now load API keys from /run/secrets/ instead of environment variables. env_check.py enforces warning tiers for missing keys.",
|
||||
color: "purple",
|
||||
},
|
||||
];
|
||||
|
||||
const BUG_FIXES = [
|
||||
"Removed viewport bbox filtering that caused 20-second delays when panning between regions",
|
||||
"Fixed carrier tracker crash on GDELT 429/TypeError responses",
|
||||
"Removed fake intelligence assessment generator — all data is now real OSINT only",
|
||||
"Docker healthcheck start_period increased to 90s to prevent false-negative restarts during data preload",
|
||||
"ETag collision fix — full payload hash instead of first 256 chars",
|
||||
"Concurrent /api/refresh guard prevents duplicate data fetches",
|
||||
"Stable entity IDs for GDELT & News popups — no more wrong popup after data refresh (PR #63)",
|
||||
"useCallback optimization for interpolation functions — eliminates redundant React re-renders on every 1s tick",
|
||||
"Restored missing GDELT and datacenter background refreshes in slow-tier loop",
|
||||
"Server-side viewport bounding box filtering reduces JSON payload size by 80%+",
|
||||
"Modular fetcher architecture sustained over monolithic data_fetcher.py",
|
||||
"CCTV ingestors instantiated once at startup — no more fresh DB connections every 10min tick",
|
||||
];
|
||||
|
||||
const CONTRIBUTORS = [
|
||||
{ name: "@imqdcr", desc: "Ship toggle split into 4 categories + stable MMSI/callsign entity IDs for map markers" },
|
||||
{ name: "@csysp", desc: "Dismissible threat alert bubbles with stable content hashing + stopPropagation crash fix", pr: "#48" },
|
||||
{ name: "@suranyami", desc: "Parallel multi-arch Docker builds (11min → 3min) + runtime BACKEND_URL fix", pr: "#35, #44" },
|
||||
{ name: "@csysp", desc: "Dismissible threat alerts + stable entity IDs for GDELT & News popups", pr: "#48, #63" },
|
||||
{ name: "@suranyami", desc: "Parallel multi-arch Docker builds (11min \u2192 3min) + runtime BACKEND_URL fix", pr: "#35, #44" },
|
||||
];
|
||||
|
||||
export function useChangelog() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, Droplet, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, Droplet, ChevronDown, ChevronUp, Globe } from 'lucide-react';
|
||||
import type { DashboardData } from "@/types/dashboard";
|
||||
|
||||
const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: DashboardData }) {
|
||||
@@ -23,7 +23,10 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: Dashboar
|
||||
className="flex justify-between items-center p-3 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">GLOBAL MARKETS</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={12} className="text-cyan-500" />
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">GLOBAL MARKETS</span>
|
||||
</div>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
|
||||
@@ -456,9 +456,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-cyan-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,128,255,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-cyan-500/30 bg-cyan-950/40 flex justify-between items-center">
|
||||
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
||||
<h2 className={`text-xs tracking-widest font-bold ${selectedEntity.type === 'military_flight' ? 'text-red-400' : selectedEntity.type === 'private_flight' ? 'text-orange-400' : selectedEntity.type === 'private_jet' ? 'text-purple-400' : 'text-cyan-400'} flex items-center gap-2`}>
|
||||
{selectedEntity.type === 'military_flight' ? "MILITARY BOGEY INTERCEPT" : selectedEntity.type === 'private_flight' ? "PRIVATE TRANSPONDER" : selectedEntity.type === 'private_jet' ? "PRIVATE JET TRANSPONDER" : "COMMERCIAL TRANSPONDER"}
|
||||
</h2>
|
||||
@@ -576,9 +576,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-cyan-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,128,255,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-cyan-500/30 bg-cyan-950/40 flex justify-between items-center">
|
||||
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
||||
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
|
||||
{headerTitle}
|
||||
</h2>
|
||||
@@ -648,7 +648,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
}
|
||||
|
||||
if (selectedEntity?.type === 'gdelt') {
|
||||
const gdeltItem = data?.gdelt?.[selectedEntity.id as number];
|
||||
const gdeltItem = data?.gdelt?.find((g: any) => (g.properties?.name || String(g.geometry?.coordinates)) === selectedEntity.id);
|
||||
if (gdeltItem && gdeltItem.properties) {
|
||||
const props = gdeltItem.properties;
|
||||
return (
|
||||
@@ -810,9 +810,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-cyan-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,128,255,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-cyan-500/30 bg-cyan-950/40 flex justify-between items-center">
|
||||
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
||||
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
||||
AERONAUTICAL HUB
|
||||
</h2>
|
||||
@@ -844,9 +844,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-cyan-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,128,255,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-cyan-500/30 bg-cyan-950/40 flex justify-between items-center">
|
||||
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
||||
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-red-400" /> {selectedEntity.extra?.last_updated
|
||||
? new Date(selectedEntity.extra.last_updated + 'Z').toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'short' }).toUpperCase() + ' — OPTIC INTERCEPT'
|
||||
@@ -936,10 +936,10 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className={`w-full bg-[var(--bg-panel)] backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
|
||||
className={`w-full bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
|
||||
>
|
||||
<div
|
||||
className="p-3 border-b border-cyan-500/20 bg-cyan-950/20 relative overflow-hidden cursor-pointer hover:bg-cyan-900/30 transition-colors"
|
||||
className="p-3 border-b border-[var(--border-primary)]/50 relative overflow-hidden cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex justify-between items-center relative z-10">
|
||||
@@ -1029,7 +1029,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.cluster_count > 1 && (
|
||||
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold text-cyan-500 bg-cyan-950/50 hover:text-[var(--text-primary)] hover:bg-cyan-900 border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
|
||||
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold text-cyan-500 bg-[var(--bg-secondary)]/50 hover:text-[var(--text-primary)] hover:bg-[var(--hover-accent)] border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
|
||||
{isExpanded ? '[- COLLAPSE]' : `[+${item.cluster_count - 1} SOURCES]`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -250,18 +250,18 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="w-full flex flex-col bg-[var(--bg-primary)]/40 backdrop-blur-md border border-cyan-900/50 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.2)] relative overflow-hidden max-h-full"
|
||||
className="w-full flex flex-col bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.2)] relative overflow-hidden max-h-full"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 border-b border-cyan-900/50 cursor-pointer bg-cyan-950/20 hover:bg-cyan-900/30 transition-colors"
|
||||
className="flex items-center justify-between p-3 border-b border-[var(--border-primary)]/50 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-cyan-400">
|
||||
<div className="flex items-center gap-2 text-[var(--text-muted)]">
|
||||
<RadioReceiver size={14} className={isPlaying ? "animate-pulse" : ""} />
|
||||
<span className="text-[10px] font-mono tracking-widest font-semibold">SIGINT INTERCEPT</span>
|
||||
<span className="text-[10px] font-mono tracking-widest">SIGINT INTERCEPT</span>
|
||||
{isPlaying && <Activity size={12} className="text-red-500 animate-pulse ml-2" />}
|
||||
</div>
|
||||
<button className="text-cyan-500 hover:text-cyan-300 transition-colors">
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -275,7 +275,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
className="flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Audio Player Controls */}
|
||||
<div className="p-4 border-b border-cyan-900/40 bg-[var(--bg-primary)]/60">
|
||||
<div className="p-4 border-b border-[var(--border-primary)]/40 bg-[var(--bg-primary)]/60">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-cyan-300 font-mono tracking-wide">
|
||||
@@ -348,36 +348,6 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KiwiSDR Tuner — appears when a KiwiSDR node is clicked on the map */}
|
||||
{selectedEntity?.type === 'kiwisdr' && selectedEntity.extra?.url && (
|
||||
<div className="p-3 border-b border-amber-900/40 bg-amber-950/10">
|
||||
<div className="text-[9px] text-amber-400 font-mono tracking-widest mb-2 flex items-center gap-2">
|
||||
<RadioReceiver size={10} />
|
||||
SDR TUNER: {(selectedEntity.extra.name || 'REMOTE RECEIVER').toUpperCase().slice(0, 60)}
|
||||
</div>
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono mb-2">
|
||||
{selectedEntity.extra.location && <span>{selectedEntity.extra.location} · </span>}
|
||||
{selectedEntity.extra.antenna && <span>{selectedEntity.extra.antenna.slice(0, 80)} · </span>}
|
||||
{selectedEntity.extra.users !== undefined && <span>{selectedEntity.extra.users}/{selectedEntity.extra.users_max} users</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<a
|
||||
href={selectedEntity.extra.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center px-4 py-2.5 rounded border border-amber-500/50 bg-amber-950/30 text-amber-400 hover:bg-amber-900/40 hover:border-amber-400 text-[10px] font-mono tracking-widest transition-colors"
|
||||
>
|
||||
OPEN SDR RECEIVER →
|
||||
</a>
|
||||
</div>
|
||||
{selectedEntity.extra.bands && (
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono mt-2">
|
||||
BANDS: {(Number(selectedEntity.extra.bands.split('-')[0]) / 1e6).toFixed(0)}-{(Number(selectedEntity.extra.bands.split('-')[1]) / 1e6).toFixed(0)} MHz
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed List */}
|
||||
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
|
||||
{feeds.length === 0 ? (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield, ToggleLeft, ToggleRight } from "lucide-react";
|
||||
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield, ToggleLeft, ToggleRight, Palette } from "lucide-react";
|
||||
import packageJson from "../../package.json";
|
||||
import { useTheme } from "@/lib/ThemeContext";
|
||||
|
||||
@@ -60,11 +60,11 @@ const POTUS_ICAOS: Record<string, { label: string; type: string }> = {
|
||||
'AE5E77': { label: 'Marine One (VH-92A)', type: 'M1' },
|
||||
'AE5E79': { label: 'Marine One (VH-92A)', type: 'M1' },
|
||||
};
|
||||
import type { DashboardData, ActiveLayers, SelectedEntity } from "@/types/dashboard";
|
||||
import type { DashboardData, ActiveLayers, SelectedEntity, KiwiSDR } from "@/types/dashboard";
|
||||
|
||||
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity, onEntityClick, onFlyTo }: { data: DashboardData; activeLayers: ActiveLayers; setActiveLayers: React.Dispatch<React.SetStateAction<ActiveLayers>>; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void; onEntityClick?: (entity: SelectedEntity) => void; onFlyTo?: (lat: number, lng: number) => void }) {
|
||||
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity, onEntityClick, onFlyTo, trackedSdr, setTrackedSdr }: { data: DashboardData; activeLayers: ActiveLayers; setActiveLayers: React.Dispatch<React.SetStateAction<ActiveLayers>>; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void; onEntityClick?: (entity: SelectedEntity) => void; onFlyTo?: (lat: number, lng: number) => void; trackedSdr?: KiwiSDR | null; setTrackedSdr?: (sdr: KiwiSDR | null) => void }) {
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { theme, toggleTheme, hudColor, cycleHudColor } = useTheme();
|
||||
const [gibsPlaying, setGibsPlaying] = useState(false);
|
||||
const [potusEnabled, setPotusEnabled] = useState(true);
|
||||
const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -172,6 +172,13 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={cycleHudColor}
|
||||
className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-cyan-400 hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
|
||||
title={hudColor === 'cyan' ? 'Switch to Matrix HUD' : 'Switch to Cyan HUD'}
|
||||
>
|
||||
<Palette size={14} />
|
||||
</button>
|
||||
{onSettingsClick && (
|
||||
<button
|
||||
onClick={onSettingsClick}
|
||||
@@ -238,6 +245,58 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
className="overflow-y-auto styled-scrollbar"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-4 pt-2 pb-6">
|
||||
{/* SDR TRACKER — pinned to TOP when active */}
|
||||
{trackedSdr && (
|
||||
<div className="bg-amber-950/20 border border-amber-500/40 rounded-lg p-3 -mt-1 shadow-[0_0_15px_rgba(245,158,11,0.1)]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio size={14} className="text-amber-400" />
|
||||
<span className="text-[10px] text-amber-400 font-mono tracking-widest font-bold">SDR TRACKER</span>
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full bg-amber-500/20 border border-amber-500/40 text-amber-400 animate-pulse">
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setTrackedSdr?.(null); }}
|
||||
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-red-400 border border-[var(--border-primary)] hover:border-red-400/40 rounded px-1.5 py-0.5 transition-colors"
|
||||
title="Release SDR and clear tracking"
|
||||
>
|
||||
RELEASE
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col p-2 rounded-lg border border-amber-500/20 bg-amber-950/10">
|
||||
<span className="text-[10px] font-bold font-mono text-amber-300 truncate mb-1">
|
||||
{(trackedSdr.name || 'REMOTE RECEIVER').toUpperCase()}
|
||||
</span>
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono mb-2">
|
||||
{trackedSdr.location && <span>{trackedSdr.location} · </span>}
|
||||
{trackedSdr.antenna && <span>{trackedSdr.antenna.slice(0, 40)}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
onClick={() => onFlyTo?.(trackedSdr.lat, trackedSdr.lon)}
|
||||
className="flex-1 text-center px-2 py-1.5 rounded border border-[var(--border-primary)] hover:border-amber-400/50 hover:text-amber-400 text-[var(--text-muted)] text-[9px] font-mono tracking-widest transition-colors flex items-center justify-center gap-1.5"
|
||||
title="Pan camera to SDR location"
|
||||
>
|
||||
<Globe size={10} /> RE-LOCK
|
||||
</button>
|
||||
{trackedSdr.url && (
|
||||
<a
|
||||
href={trackedSdr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center px-2 py-1.5 rounded border border-amber-500/50 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 hover:border-amber-400 text-[9px] font-mono tracking-widest transition-colors flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Activity size={10} /> TUNER
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* POTUS Fleet — pinned to TOP when aircraft are active */}
|
||||
{potusEnabled && potusFlights.length > 0 && (
|
||||
<div className="bg-[#ff1493]/5 border border-[#ff1493]/30 rounded-lg p-3 -mt-1">
|
||||
|
||||
@@ -42,7 +42,7 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
||||
</div>
|
||||
|
||||
{/* Right side controls box */}
|
||||
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto border-r-2 border-r-cyan-900 flex flex-col relative overflow-hidden h-full">
|
||||
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto border-r-2 border-r-[var(--border-primary)] flex flex-col relative overflow-hidden h-full">
|
||||
|
||||
{/* Header / Toggle */}
|
||||
<div
|
||||
@@ -71,14 +71,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
||||
onClick={() => setEffects({ ...effects, bloom: !effects.bloom })}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-[14px] ${effects.bloom ? 'text-yellow-500' : 'text-gray-600'}`}>✧</span>
|
||||
<span className={`text-[14px] ${effects.bloom ? 'text-yellow-500' : 'text-[var(--text-muted)]'}`}>✧</span>
|
||||
<span className={`text-xs font-mono tracking-widest ${effects.bloom ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'}`}>BLOOM</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono tracking-wider text-[var(--text-muted)]">{effects.bloom ? 'ON' : 'OFF'}</span>
|
||||
</div>
|
||||
|
||||
{/* Sharpen Slider */}
|
||||
<div className="flex flex-col gap-3 group border border-cyan-900/50 bg-cyan-950/10 rounded px-4 py-3 pb-4 relative overflow-hidden">
|
||||
<div className="flex flex-col gap-3 group border border-[var(--border-primary)]/50 bg-[var(--bg-secondary)]/10 rounded px-4 py-3 pb-4 relative overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-cyan-500"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full border border-cyan-400 flex items-center justify-center relative">
|
||||
@@ -98,7 +98,7 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
||||
{/* HUD Dropdown */}
|
||||
<div className="flex flex-col gap-2 relative">
|
||||
<div className="flex items-center gap-3 border border-[var(--border-primary)] rounded px-4 py-3 text-[var(--text-muted)] cursor-default">
|
||||
<span className="w-3 h-3 border border-gray-500 rounded-full flex items-center justify-center"></span>
|
||||
<span className="w-3 h-3 border border-[var(--border-secondary)] rounded-full flex items-center justify-center"></span>
|
||||
<span className="text-xs font-mono tracking-widest">HUD</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -106,6 +106,32 @@ export function CarrierLabels({ ships, inView, interpShip }: CarrierLabelsProps)
|
||||
);
|
||||
}
|
||||
|
||||
// -- Tracked yacht labels --
|
||||
interface TrackedYachtLabelsProps {
|
||||
ships: any[];
|
||||
inView: (lat: number, lng: number) => boolean;
|
||||
interpShip: (s: any) => [number, number];
|
||||
}
|
||||
|
||||
export function TrackedYachtLabels({ ships, inView, interpShip }: TrackedYachtLabelsProps) {
|
||||
return (
|
||||
<>
|
||||
{ships.map((s: any, i: number) => {
|
||||
if (!s.yacht_alert || s.lat == null || s.lng == null) return null;
|
||||
if (!inView(s.lat, s.lng)) return null;
|
||||
const [iLng, iLat] = interpShip(s);
|
||||
return (
|
||||
<Marker key={`yacht-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 12]} style={{ zIndex: 2 }}>
|
||||
<div style={{ ...LABEL_BASE, color: s.yacht_color || '#FF69B4', fontSize: '10px', textShadow: LABEL_SHADOW_EXTRA, whiteSpace: 'nowrap' }}>
|
||||
{s.yacht_owner || s.name || 'TRACKED YACHT'}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// -- UAV labels --
|
||||
interface UavLabelsProps {
|
||||
uavs: any[];
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildEarthquakesGeoJSON,
|
||||
buildFirmsGeoJSON,
|
||||
buildInternetOutagesGeoJSON,
|
||||
buildDataCentersGeoJSON,
|
||||
buildShipsGeoJSON,
|
||||
buildCarriersGeoJSON,
|
||||
} from '@/components/map/geoJSONBuilders';
|
||||
import type { Earthquake, FireHotspot, InternetOutage, DataCenter, Ship, ActiveLayers } from '@/types/dashboard';
|
||||
|
||||
// Default active layers for ship tests
|
||||
const allShipLayers: ActiveLayers = {
|
||||
flights: true, private: true, jets: true, military: true, tracked: true,
|
||||
satellites: true, earthquakes: true, cctv: false, ukraine_frontline: true,
|
||||
global_incidents: true, firms_fires: true, jamming: true, internet_outages: true,
|
||||
datacenters: true, gdelt: false, liveuamap: true, weather: true, uav: true,
|
||||
kiwisdr: false,
|
||||
ships_military: true, ships_cargo: true, ships_civilian: true,
|
||||
ships_passenger: true, ships_tracked_yachts: true,
|
||||
};
|
||||
|
||||
describe('buildEarthquakesGeoJSON', () => {
|
||||
it('returns null for empty array', () => {
|
||||
expect(buildEarthquakesGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(buildEarthquakesGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds valid FeatureCollection', () => {
|
||||
const quakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 5.2, lat: 35.0, lng: 139.0, place: 'Japan' },
|
||||
{ id: 'eq2', mag: 3.1, lat: 40.0, lng: -74.0, place: 'New York' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(quakes);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe('FeatureCollection');
|
||||
expect(result!.features).toHaveLength(2);
|
||||
expect(result!.features[0].properties?.type).toBe('earthquake');
|
||||
expect(result!.features[0].geometry).toEqual({ type: 'Point', coordinates: [139.0, 35.0] });
|
||||
});
|
||||
|
||||
it('skips entries with null coordinates', () => {
|
||||
const quakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 5.2, lat: null as any, lng: 139.0, place: 'Bad' },
|
||||
{ id: 'eq2', mag: 3.1, lat: 40.0, lng: -74.0, place: 'Good' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(quakes);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFirmsGeoJSON', () => {
|
||||
it('returns null for empty array', () => {
|
||||
expect(buildFirmsGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('assigns correct icon by FRP intensity', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{ lat: 10, lng: 20, frp: 2, brightness: 300, confidence: 'high', daynight: 'D', acq_date: '2025-01-01', acq_time: '1200' }, // yellow
|
||||
{ lat: 10, lng: 21, frp: 10, brightness: 350, confidence: 'high', daynight: 'D', acq_date: '2025-01-01', acq_time: '1200' }, // orange
|
||||
{ lat: 10, lng: 22, frp: 50, brightness: 400, confidence: 'high', daynight: 'N', acq_date: '2025-01-01', acq_time: '0000' }, // red
|
||||
{ lat: 10, lng: 23, frp: 200, brightness: 500, confidence: 'high', daynight: 'N', acq_date: '2025-01-01', acq_time: '0000' }, // darkred
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires)!;
|
||||
expect(result.features[0].properties?.iconId).toBe('fire-yellow');
|
||||
expect(result.features[1].properties?.iconId).toBe('fire-orange');
|
||||
expect(result.features[2].properties?.iconId).toBe('fire-red');
|
||||
expect(result.features[3].properties?.iconId).toBe('fire-darkred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildShipsGeoJSON', () => {
|
||||
const alwaysInView = () => true;
|
||||
const interpIdentity = (s: Ship): [number, number] => [s.lng!, s.lat!];
|
||||
|
||||
it('returns null when all ship layers are off', () => {
|
||||
const layers = { ...allShipLayers, ships_military: false, ships_cargo: false, ships_civilian: false, ships_passenger: false, ships_tracked_yachts: false };
|
||||
const ships: Ship[] = [{ name: 'Test', lat: 10, lng: 20, type: 'cargo' } as Ship];
|
||||
expect(buildShipsGeoJSON(ships, layers, alwaysInView, interpIdentity)).toBeNull();
|
||||
});
|
||||
|
||||
it('filters out carriers (handled by buildCarriersGeoJSON)', () => {
|
||||
const ships: Ship[] = [
|
||||
{ name: 'Cargo Ship', lat: 10, lng: 20, type: 'cargo', mmsi: '123' } as Ship,
|
||||
{ name: 'USS Nimitz', lat: 30, lng: 40, type: 'carrier', mmsi: '456' } as Ship,
|
||||
];
|
||||
const result = buildShipsGeoJSON(ships, allShipLayers, alwaysInView, interpIdentity);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.name).toBe('Cargo Ship');
|
||||
});
|
||||
|
||||
it('assigns correct icon by ship type', () => {
|
||||
const ships: Ship[] = [
|
||||
{ name: 'Tanker', lat: 10, lng: 20, type: 'tanker', mmsi: '1' } as Ship,
|
||||
{ name: 'Yacht', lat: 10, lng: 21, type: 'yacht', mmsi: '2' } as Ship,
|
||||
{ name: 'Warship', lat: 10, lng: 22, type: 'military_vessel', mmsi: '3' } as Ship,
|
||||
];
|
||||
const result = buildShipsGeoJSON(ships, allShipLayers, alwaysInView, interpIdentity)!;
|
||||
expect(result.features[0].properties?.iconId).toBe('svgShipRed');
|
||||
expect(result.features[1].properties?.iconId).toBe('svgShipWhite');
|
||||
expect(result.features[2].properties?.iconId).toBe('svgShipYellow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCarriersGeoJSON', () => {
|
||||
it('returns null for empty ships', () => {
|
||||
expect(buildCarriersGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('only includes carriers', () => {
|
||||
const ships: Ship[] = [
|
||||
{ name: 'USS Nimitz', lat: 30, lng: 40, type: 'carrier', mmsi: '456', heading: 90 } as Ship,
|
||||
{ name: 'Cargo Ship', lat: 10, lng: 20, type: 'cargo', mmsi: '123' } as Ship,
|
||||
];
|
||||
const result = buildCarriersGeoJSON(ships)!;
|
||||
expect(result.features).toHaveLength(1);
|
||||
expect(result.features[0].properties?.name).toBe('USS Nimitz');
|
||||
expect(result.features[0].properties?.iconId).toBe('svgCarrier');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
// ─── Pure GeoJSON builder functions ─────────────────────────────────────────
|
||||
// Extracted from MaplibreViewer to reduce component size and enable unit testing.
|
||||
// Each function takes data arrays + optional helpers and returns a GeoJSON FeatureCollection or null.
|
||||
|
||||
import type { Earthquake, GPSJammingZone, FireHotspot, InternetOutage, DataCenter, GDELTIncident, LiveUAmapIncident, CCTVCamera, KiwiSDR, FrontlineGeoJSON, UAV, Satellite, Ship, ActiveLayers } from "@/types/dashboard";
|
||||
import { classifyAircraft } from "@/utils/aircraftClassification";
|
||||
import { MISSION_COLORS, MISSION_ICON_MAP } from "@/components/map/icons/SatelliteIcons";
|
||||
|
||||
type FC = GeoJSON.FeatureCollection | null;
|
||||
type InViewFilter = (lat: number, lng: number) => boolean;
|
||||
|
||||
// ─── Earthquakes ────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildEarthquakesGeoJSON(earthquakes?: Earthquake[]): FC {
|
||||
if (!earthquakes?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: earthquakes.map((eq, i) => {
|
||||
if (eq.lat == null || eq.lng == null) return null;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: i,
|
||||
type: 'earthquake',
|
||||
name: `[M${eq.mag}]\n${eq.place || 'Unknown Location'}`,
|
||||
title: eq.title,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [eq.lng, eq.lat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
|
||||
// ─── GPS Jamming Zones ──────────────────────────────────────────────────────
|
||||
|
||||
export function buildJammingGeoJSON(zones?: GPSJammingZone[]): FC {
|
||||
if (!zones?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: zones.map((zone, i) => {
|
||||
const halfDeg = 0.5;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: i,
|
||||
severity: zone.severity,
|
||||
ratio: zone.ratio,
|
||||
degraded: zone.degraded,
|
||||
total: zone.total,
|
||||
opacity: zone.severity === 'high' ? 0.45 : zone.severity === 'medium' ? 0.3 : 0.18
|
||||
},
|
||||
geometry: {
|
||||
type: 'Polygon' as const,
|
||||
coordinates: [[
|
||||
[zone.lng - halfDeg, zone.lat - halfDeg],
|
||||
[zone.lng + halfDeg, zone.lat - halfDeg],
|
||||
[zone.lng + halfDeg, zone.lat + halfDeg],
|
||||
[zone.lng - halfDeg, zone.lat + halfDeg],
|
||||
[zone.lng - halfDeg, zone.lat - halfDeg]
|
||||
]]
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// ─── CCTV Cameras ──────────────────────────────────────────────────────────
|
||||
|
||||
export function buildCctvGeoJSON(cameras?: CCTVCamera[], inView?: InViewFilter): FC {
|
||||
if (!cameras?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: cameras.filter(c => c.lat != null && c.lon != null && (!inView || inView(c.lat, c.lon))).map((c, i) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: c.id || i,
|
||||
type: 'cctv',
|
||||
name: c.direction_facing || 'Camera',
|
||||
source_agency: c.source_agency || 'Unknown',
|
||||
media_url: c.media_url || '',
|
||||
media_type: c.media_type || 'image'
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [c.lon, c.lat] }
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// ─── KiwiSDR Receivers ─────────────────────────────────────────────────────
|
||||
|
||||
export function buildKiwisdrGeoJSON(receivers?: KiwiSDR[], inView?: InViewFilter): FC {
|
||||
if (!receivers?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: receivers.filter(k => k.lat != null && k.lon != null && (!inView || inView(k.lat, k.lon))).map((k, i) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: i,
|
||||
type: 'kiwisdr',
|
||||
name: k.name || 'Unknown SDR',
|
||||
url: k.url || '',
|
||||
users: k.users || 0,
|
||||
users_max: k.users_max || 0,
|
||||
bands: k.bands || '',
|
||||
antenna: k.antenna || '',
|
||||
location: k.location || '',
|
||||
lat: k.lat,
|
||||
lon: k.lon,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [k.lon, k.lat] }
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// ─── NASA FIRMS Fires ───────────────────────────────────────────────────────
|
||||
|
||||
export function buildFirmsGeoJSON(fires?: FireHotspot[]): FC {
|
||||
if (!fires?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: fires.map((f, i) => {
|
||||
const frp = f.frp || 0;
|
||||
const iconId = frp >= 100 ? 'fire-darkred' : frp >= 20 ? 'fire-red' : frp >= 5 ? 'fire-orange' : 'fire-yellow';
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: i,
|
||||
type: 'firms_fire',
|
||||
name: `Fire ${frp.toFixed(1)} MW`,
|
||||
frp,
|
||||
iconId,
|
||||
brightness: f.brightness || 0,
|
||||
confidence: f.confidence || '',
|
||||
daynight: f.daynight === 'D' ? 'Day' : 'Night',
|
||||
acq_date: f.acq_date || '',
|
||||
acq_time: f.acq_time || '',
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [f.lng, f.lat] }
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internet Outages ───────────────────────────────────────────────────────
|
||||
|
||||
export function buildInternetOutagesGeoJSON(outages?: InternetOutage[]): FC {
|
||||
if (!outages?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: outages.map((o) => {
|
||||
if (o.lat == null || o.lng == null) return null;
|
||||
const severity = o.severity || 0;
|
||||
const region = o.region_name || o.region_code || '?';
|
||||
const country = o.country_name || o.country_code || '';
|
||||
const label = `${region}, ${country}`;
|
||||
const detail = `${label}\n${severity}% drop · ${o.datasource || 'IODA'}`;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: o.region_code || region,
|
||||
type: 'internet_outage',
|
||||
name: label,
|
||||
country,
|
||||
region,
|
||||
level: o.level,
|
||||
severity,
|
||||
datasource: o.datasource || '',
|
||||
detail,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [o.lng, o.lat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Data Centers ───────────────────────────────────────────────────────────
|
||||
|
||||
export function buildDataCentersGeoJSON(datacenters?: DataCenter[]): FC {
|
||||
if (!datacenters?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: datacenters.map((dc, i) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: `dc-${i}`,
|
||||
type: 'datacenter',
|
||||
name: dc.name || 'Unknown',
|
||||
company: dc.company || '',
|
||||
street: dc.street || '',
|
||||
city: dc.city || '',
|
||||
country: dc.country || '',
|
||||
zip: dc.zip || '',
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [dc.lng, dc.lat] }
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// ─── GDELT Incidents ────────────────────────────────────────────────────────
|
||||
|
||||
export function buildGdeltGeoJSON(gdelt?: GDELTIncident[], inView?: InViewFilter): FC {
|
||||
if (!gdelt?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: gdelt.map((g) => {
|
||||
if (!g.geometry || !g.geometry.coordinates) return null;
|
||||
const [gLng, gLat] = g.geometry.coordinates;
|
||||
if (inView && !inView(gLat, gLng)) return null;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: { id: g.properties?.name || String(g.geometry.coordinates), type: 'gdelt', title: g.properties?.name || '' },
|
||||
geometry: g.geometry
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
|
||||
// ─── LiveUAMap Incidents ────────────────────────────────────────────────────
|
||||
|
||||
export function buildLiveuaGeoJSON(incidents?: LiveUAmapIncident[], inView?: InViewFilter): FC {
|
||||
if (!incidents?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: incidents.map((incident) => {
|
||||
if (incident.lat == null || incident.lng == null) return null;
|
||||
if (inView && !inView(incident.lat, incident.lng)) return null;
|
||||
const isViolent = /bomb|missil|strike|attack|kill|destroy|fire|shoot|expl|raid/i.test(incident.title || "");
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: incident.id,
|
||||
type: 'liveuamap',
|
||||
title: incident.title || '',
|
||||
iconId: isViolent ? 'icon-liveua-red' : 'icon-liveua-yellow',
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [incident.lng, incident.lat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Ukraine Frontline ──────────────────────────────────────────────────────
|
||||
|
||||
export function buildFrontlineGeoJSON(frontlines?: FrontlineGeoJSON | null): FC {
|
||||
if (!frontlines?.features?.length) return null;
|
||||
return frontlines;
|
||||
}
|
||||
|
||||
// ─── Parameterized Flight Layer ─────────────────────────────────────────────
|
||||
// Deduplicates commercial / private / jets / military flight GeoJSON builders.
|
||||
|
||||
export interface FlightLayerConfig {
|
||||
colorMap: Record<string, string>;
|
||||
groundedMap: Record<string, string>;
|
||||
typeLabel: string;
|
||||
idPrefix: string;
|
||||
/** For military flights: special icon overrides by military_type */
|
||||
milSpecialMap?: Record<string, string>;
|
||||
/** If true, prefer true_track over heading for rotation (commercial flights) */
|
||||
useTrackHeading?: boolean;
|
||||
}
|
||||
|
||||
export function buildFlightLayerGeoJSON(
|
||||
flights: any[] | undefined,
|
||||
config: FlightLayerConfig,
|
||||
helpers: {
|
||||
interpFlight: (f: any) => [number, number];
|
||||
inView: InViewFilter;
|
||||
trackedIcaoSet: Set<string>;
|
||||
}
|
||||
): FC {
|
||||
if (!flights?.length) return null;
|
||||
const { colorMap, groundedMap, typeLabel, idPrefix, milSpecialMap, useTrackHeading } = config;
|
||||
const { interpFlight, inView, trackedIcaoSet } = helpers;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: flights.map((f: any, i: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (!inView(f.lat, f.lng)) return null;
|
||||
if (f.icao24 && trackedIcaoSet.has(f.icao24.toLowerCase())) return null;
|
||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
||||
const grounded = f.alt != null && f.alt <= 100;
|
||||
|
||||
let iconId: string;
|
||||
if (milSpecialMap) {
|
||||
const milType = f.military_type || 'default';
|
||||
iconId = milSpecialMap[milType] || '';
|
||||
if (!iconId) {
|
||||
iconId = grounded ? groundedMap[acType] : colorMap[acType];
|
||||
} else if (grounded) {
|
||||
iconId = groundedMap[acType];
|
||||
}
|
||||
} else {
|
||||
iconId = grounded ? groundedMap[acType] : colorMap[acType];
|
||||
}
|
||||
|
||||
const rotation = useTrackHeading ? (f.true_track || f.heading || 0) : (f.heading || 0);
|
||||
const [iLng, iLat] = interpFlight(f);
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: { id: f.icao24 || f.callsign || `${idPrefix}${i}`, type: typeLabel, callsign: f.callsign || f.icao24, rotation, iconId },
|
||||
geometry: { type: 'Point' as const, coordinates: [iLng, iLat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
|
||||
// ─── UAVs / Drones ──────────────────────────────────────────────────────────
|
||||
|
||||
export function buildUavGeoJSON(uavs?: UAV[], inView?: InViewFilter): FC {
|
||||
if (!uavs?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: uavs.map((uav, i) => {
|
||||
if (uav.lat == null || uav.lng == null) return null;
|
||||
if (inView && !inView(uav.lat, uav.lng)) return null;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: (uav as any).id || `uav-${i}`,
|
||||
type: 'uav',
|
||||
callsign: uav.callsign,
|
||||
rotation: uav.heading || 0,
|
||||
iconId: 'svgDrone',
|
||||
name: uav.aircraft_model || uav.callsign,
|
||||
country: uav.country || '',
|
||||
uav_type: uav.uav_type || '',
|
||||
alt: uav.alt || 0,
|
||||
wiki: uav.wiki || '',
|
||||
speed_knots: uav.speed_knots || 0,
|
||||
icao24: uav.icao24 || '',
|
||||
registration: uav.registration || '',
|
||||
squawk: uav.squawk || '',
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [uav.lng, uav.lat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
// ─── Satellites ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildSatellitesGeoJSON(
|
||||
satellites: Satellite[] | undefined,
|
||||
inView: InViewFilter,
|
||||
interpSat: (s: Satellite) => [number, number]
|
||||
): FC {
|
||||
if (!satellites?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: satellites
|
||||
.filter((s) => s.lat != null && s.lng != null && inView(s.lat, s.lng))
|
||||
.map((s, i) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: s.id || i, type: 'satellite', name: s.name, mission: s.mission || 'general',
|
||||
sat_type: s.sat_type || 'Satellite', country: s.country || '', alt_km: s.alt_km || 0,
|
||||
wiki: s.wiki || '', color: MISSION_COLORS[s.mission] || '#aaaaaa',
|
||||
iconId: MISSION_ICON_MAP[s.mission] || 'sat-gen'
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: interpSat(s) }
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Ships (non-carrier) ────────────────────────────────────────────────────
|
||||
|
||||
export function buildShipsGeoJSON(
|
||||
ships: Ship[] | undefined,
|
||||
activeLayers: ActiveLayers,
|
||||
inView: InViewFilter,
|
||||
interpShip: (s: Ship) => [number, number]
|
||||
): FC {
|
||||
if (!(activeLayers.ships_military || activeLayers.ships_cargo || activeLayers.ships_civilian || activeLayers.ships_passenger || activeLayers.ships_tracked_yachts) || !ships) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: ships.map((s, i) => {
|
||||
if (s.lat == null || s.lng == null) return null;
|
||||
if (!inView(s.lat, s.lng)) return null;
|
||||
const isTrackedYacht = !!s.yacht_alert;
|
||||
const isMilitary = s.type === 'carrier' || s.type === 'military_vessel';
|
||||
const isCargo = s.type === 'tanker' || s.type === 'cargo';
|
||||
const isPassenger = s.type === 'passenger';
|
||||
|
||||
if (s.type === 'carrier') return null; // Handled by buildCarriersGeoJSON
|
||||
|
||||
if (isTrackedYacht) {
|
||||
if (activeLayers?.ships_tracked_yachts === false) return null;
|
||||
} else if (isMilitary && activeLayers?.ships_military === false) return null;
|
||||
else if (isCargo && activeLayers?.ships_cargo === false) return null;
|
||||
else if (isPassenger && activeLayers?.ships_passenger === false) return null;
|
||||
else if (!isMilitary && !isCargo && !isPassenger && activeLayers?.ships_civilian === false) return null;
|
||||
|
||||
let iconId = 'svgShipBlue';
|
||||
if (isTrackedYacht) iconId = 'svgShipPink';
|
||||
else if (isCargo) iconId = 'svgShipRed';
|
||||
else if (s.type === 'yacht' || isPassenger) iconId = 'svgShipWhite';
|
||||
else if (isMilitary) iconId = 'svgShipYellow';
|
||||
|
||||
const [iLng, iLat] = interpShip(s);
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: { id: s.mmsi || s.name || `ship-${i}`, type: 'ship', name: s.name, rotation: s.heading || 0, iconId },
|
||||
geometry: { type: 'Point', coordinates: [iLng, iLat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Carriers ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildCarriersGeoJSON(ships: Ship[] | undefined): FC {
|
||||
if (!ships?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: ships.map((s, i) => {
|
||||
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: { id: s.mmsi || s.name || `carrier-${i}`, type: 'ship', name: s.name, rotation: s.heading || 0, iconId: 'svgCarrier' },
|
||||
geometry: { type: 'Point', coordinates: [s.lng, s.lat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { MapRef } from "react-map-gl/maplibre";
|
||||
|
||||
export interface ClusterItem {
|
||||
lng: number;
|
||||
lat: number;
|
||||
count: string | number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts cluster label positions from a MapLibre clustered source.
|
||||
* Listens for moveend/sourcedata events to keep labels in sync.
|
||||
*
|
||||
* @param mapRef - React ref to the MapLibre map instance
|
||||
* @param sourceId - The source ID to query clusters from (e.g. "ships", "earthquakes")
|
||||
* @param geoJSON - The GeoJSON data driving the source (null = no clusters)
|
||||
*/
|
||||
export function useClusterLabels(
|
||||
mapRef: React.RefObject<MapRef | null>,
|
||||
sourceId: string,
|
||||
geoJSON: unknown | null
|
||||
): ClusterItem[] {
|
||||
const [clusters, setClusters] = useState<ClusterItem[]>([]);
|
||||
const handlerRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current?.getMap();
|
||||
if (!map || !geoJSON) {
|
||||
setClusters([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove previous handler if it exists
|
||||
if (handlerRef.current) {
|
||||
map.off("moveend", handlerRef.current);
|
||||
map.off("sourcedata", handlerRef.current);
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
try {
|
||||
const features = map.querySourceFeatures(sourceId);
|
||||
const raw = features
|
||||
.filter((f: any) => f.properties?.cluster)
|
||||
.map((f: any) => ({
|
||||
lng: (f.geometry as any).coordinates[0],
|
||||
lat: (f.geometry as any).coordinates[1],
|
||||
count: f.properties.point_count_abbreviated || f.properties.point_count,
|
||||
id: f.properties.cluster_id,
|
||||
}));
|
||||
const seen = new Set<number>();
|
||||
const unique = raw.filter((c) => {
|
||||
if (seen.has(c.id)) return false;
|
||||
seen.add(c.id);
|
||||
return true;
|
||||
});
|
||||
setClusters(unique);
|
||||
} catch {
|
||||
setClusters([]);
|
||||
}
|
||||
};
|
||||
handlerRef.current = update;
|
||||
|
||||
map.on("moveend", update);
|
||||
map.on("sourcedata", update);
|
||||
setTimeout(update, 500);
|
||||
|
||||
return () => {
|
||||
map.off("moveend", update);
|
||||
map.off("sourcedata", update);
|
||||
};
|
||||
}, [geoJSON, sourceId]);
|
||||
|
||||
return clusters;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||
import { interpolatePosition } from "@/utils/positioning";
|
||||
import { INTERP_TICK_MS } from "@/lib/constants";
|
||||
|
||||
/**
|
||||
* Custom hook that provides position interpolation for flights, ships, and satellites.
|
||||
* Tracks elapsed time since last data refresh and provides helper functions
|
||||
* to smoothly animate entity positions between API updates.
|
||||
*/
|
||||
export function useInterpolation() {
|
||||
// Interpolation tick — bumps every INTERP_TICK_MS to animate entity positions
|
||||
const [interpTick, setInterpTick] = useState(0);
|
||||
const dataTimestamp = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => setInterpTick((t) => t + 1), INTERP_TICK_MS);
|
||||
return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
/** Call this when new data arrives to reset the interpolation baseline */
|
||||
const resetTimestamp = useCallback(() => {
|
||||
dataTimestamp.current = Date.now();
|
||||
}, []);
|
||||
|
||||
// Elapsed seconds since last data refresh (used for position interpolation)
|
||||
const dtSeconds = useMemo(() => {
|
||||
void interpTick; // use the tick to trigger recalc
|
||||
return (Date.now() - dataTimestamp.current) / 1000;
|
||||
}, [interpTick]);
|
||||
|
||||
/** Interpolate a flight's position if airborne and has speed + heading */
|
||||
const interpFlight = useCallback(
|
||||
(f: { lat: number; lng: number; speed_knots?: number | null; alt?: number | null; true_track?: number; heading?: number }): [number, number] => {
|
||||
if (!f.speed_knots || f.speed_knots <= 0 || dtSeconds <= 0) return [f.lng, f.lat];
|
||||
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
|
||||
if (dtSeconds < 1) return [f.lng, f.lat];
|
||||
const heading = f.true_track || f.heading || 0;
|
||||
const [newLat, newLng] = interpolatePosition(f.lat, f.lng, heading, f.speed_knots, dtSeconds);
|
||||
return [newLng, newLat];
|
||||
},
|
||||
[dtSeconds]
|
||||
);
|
||||
|
||||
/** Interpolate a ship's position using SOG + COG */
|
||||
const interpShip = useCallback(
|
||||
(s: { lat: number; lng: number; sog?: number; cog?: number; heading?: number }): [number, number] => {
|
||||
if (typeof s.sog !== "number" || !s.sog || s.sog <= 0 || dtSeconds <= 0) return [s.lng, s.lat];
|
||||
const heading = (typeof s.cog === "number" ? s.cog : 0) || s.heading || 0;
|
||||
const [newLat, newLng] = interpolatePosition(s.lat, s.lng, heading, s.sog, dtSeconds);
|
||||
return [newLng, newLat];
|
||||
},
|
||||
[dtSeconds]
|
||||
);
|
||||
|
||||
/** Interpolate a satellite's position between API updates */
|
||||
const interpSat = useCallback(
|
||||
(s: { lat: number; lng: number; speed_knots?: number; heading?: number }): [number, number] => {
|
||||
if (!s.speed_knots || s.speed_knots <= 0 || dtSeconds < 1) return [s.lng, s.lat];
|
||||
const [newLat, newLng] = interpolatePosition(s.lat, s.lng, s.heading || 0, s.speed_knots, dtSeconds, 0, 65);
|
||||
return [newLng, newLat];
|
||||
},
|
||||
[dtSeconds]
|
||||
);
|
||||
|
||||
return { interpTick, interpFlight, interpShip, interpSat, dtSeconds, resetTimestamp, dataTimestamp };
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export const svgShipRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xm
|
||||
export const svgShipYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="34" viewBox="0 0 24 24" fill="none"><path d="M7 22 L7 6 L12 1 L17 6 L17 22 Z" fill="yellow" stroke="#000" stroke-width="1"/><rect x="9" y="8" width="6" height="8" fill="#555" stroke="#000" stroke-width="1"/><circle cx="12" cy="18" r="1.5" fill="#000"/><line x1="12" y1="18" x2="12" y2="24" stroke="#000" stroke-width="1.5"/></svg>`)}`;
|
||||
export const svgShipBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="32" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="#3b82f6" stroke="#000" stroke-width="1"/></svg>`)}`;
|
||||
export const svgShipWhite = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="36" viewBox="0 0 24 24" fill="none"><path d="M5 21 L5 8 L12 2 L19 8 L19 21 C19 23 5 23 5 21 Z" fill="white" stroke="#000" stroke-width="1"/><rect x="7" y="10" width="10" height="8" fill="#90cdf4" stroke="#000" stroke-width="1"/><circle cx="12" cy="14" r="2" fill="yellow" stroke="#000"/></svg>`)}`;
|
||||
export const svgShipPink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="36" viewBox="0 0 24 24" fill="none"><path d="M5 21 L5 8 L12 2 L19 8 L19 21 C19 23 5 23 5 21 Z" fill="#FF69B4" stroke="#000" stroke-width="1"/><rect x="7" y="10" width="10" height="8" fill="#ff8dc7" stroke="#000" stroke-width="1"/><circle cx="12" cy="14" r="2" fill="white" stroke="#000"/></svg>`)}`;
|
||||
export const svgCarrier = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="orange" stroke="black"><polygon points="3,21 21,21 20,4 16,4 16,3 12,3 12,4 4,4" /><rect x="15" y="6" width="3" height="10" /></svg>`)}`;
|
||||
export const svgCctv = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="cyan" stroke-width="2"><path d="M16.75 12h3.632a1 1 0 0 1 .894 1.447l-2.034 4.069a1 1 0 0 1-.894.553H5.652a1 1 0 0 1-.894-.553L2.724 13.447A1 1 0 0 1 3.618 12h3.632M14 12V8a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v4a4 4 0 1 0 8 0Z" /></svg>`)}`;
|
||||
export const svgRadioTower = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="1.5"><line x1="12" y1="10" x2="12" y2="23" stroke="#f59e0b" stroke-width="2"/><line x1="8" y1="23" x2="16" y2="23" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/><line x1="9" y1="16" x2="15" y2="16" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round"/><circle cx="12" cy="9" r="2" fill="#f59e0b" stroke="none"/><path d="M8 6a5.5 5.5 0 0 1 8 0" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round"/><path d="M5.5 3.5a9 9 0 0 1 13 0" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/></svg>`)}`;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
|
||||
export type BackendStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
/**
|
||||
* Polls the backend for fast and slow data tiers.
|
||||
*
|
||||
* Matches the proven GitHub polling pattern:
|
||||
* - Empty useEffect dependency array (no restarts on viewport change)
|
||||
* - No viewport bbox filtering (full data every poll)
|
||||
* - Adaptive startup polling (3s retry → 15s/120s steady state)
|
||||
* - ETag conditional requests for bandwidth savings
|
||||
* - AbortController for clean unmount
|
||||
*/
|
||||
export function useDataPolling() {
|
||||
const dataRef = useRef<any>({});
|
||||
const [dataVersion, setDataVersion] = useState(0);
|
||||
const data = dataRef.current;
|
||||
|
||||
const [backendStatus, setBackendStatus] = useState<BackendStatus>('connecting');
|
||||
|
||||
const fastEtag = useRef<string | null>(null);
|
||||
const slowEtag = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let hasData = false;
|
||||
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const fetchFastData = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers });
|
||||
if (res.status === 304) { setBackendStatus('connected'); scheduleNext('fast'); return; }
|
||||
if (res.ok) {
|
||||
setBackendStatus('connected');
|
||||
fastEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
setDataVersion(v => v + 1);
|
||||
const flights = json.commercial_flights?.length || 0;
|
||||
if (flights > 100) hasData = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching fast live data", e);
|
||||
setBackendStatus('disconnected');
|
||||
}
|
||||
scheduleNext('fast');
|
||||
};
|
||||
|
||||
const fetchSlowData = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (slowEtag.current) headers['If-None-Match'] = slowEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/slow`, { headers });
|
||||
if (res.status === 304) { scheduleNext('slow'); return; }
|
||||
if (res.ok) {
|
||||
slowEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
setDataVersion(v => v + 1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching slow live data", e);
|
||||
}
|
||||
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
|
||||
fastTimerId = setTimeout(fetchFastData, delay);
|
||||
} else {
|
||||
const delay = hasData ? 120000 : 5000; // 5s startup retry → 120s steady state
|
||||
slowTimerId = setTimeout(fetchSlowData, delay);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFastData();
|
||||
fetchSlowData();
|
||||
|
||||
return () => {
|
||||
if (fastTimerId) clearTimeout(fastTimerId);
|
||||
if (slowTimerId) clearTimeout(slowTimerId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, dataVersion, backendStatus };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useState, useEffect } from "react";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import type { RegionDossier, SelectedEntity } from "@/types/dashboard";
|
||||
|
||||
export function useRegionDossier(
|
||||
selectedEntity: SelectedEntity | null,
|
||||
setSelectedEntity: (entity: SelectedEntity | null) => void
|
||||
) {
|
||||
const [regionDossier, setRegionDossier] = useState<RegionDossier | null>(null);
|
||||
const [regionDossierLoading, setRegionDossierLoading] = useState(false);
|
||||
|
||||
const handleMapRightClick = useCallback(async (coords: { lat: number; lng: number }) => {
|
||||
setSelectedEntity({ type: 'region_dossier', id: `${coords.lat.toFixed(4)}_${coords.lng.toFixed(4)}`, extra: coords });
|
||||
setRegionDossierLoading(true);
|
||||
setRegionDossier(null);
|
||||
try {
|
||||
const [dossierRes, sentinelRes] = await Promise.allSettled([
|
||||
fetch(`${API_BASE}/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`),
|
||||
fetch(`${API_BASE}/api/sentinel2/search?lat=${coords.lat}&lng=${coords.lng}`),
|
||||
]);
|
||||
let dossierData: Record<string, unknown> = {};
|
||||
if (dossierRes.status === 'fulfilled' && dossierRes.value.ok) {
|
||||
dossierData = await dossierRes.value.json();
|
||||
}
|
||||
let sentinelData = null;
|
||||
if (sentinelRes.status === 'fulfilled' && sentinelRes.value.ok) {
|
||||
sentinelData = await sentinelRes.value.json();
|
||||
}
|
||||
setRegionDossier({ lat: coords.lat, lng: coords.lng, ...dossierData, sentinel2: sentinelData });
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch region dossier", e);
|
||||
} finally {
|
||||
setRegionDossierLoading(false);
|
||||
}
|
||||
}, [setSelectedEntity]);
|
||||
|
||||
// Clear dossier when selecting a different entity type
|
||||
useEffect(() => {
|
||||
if (selectedEntity?.type !== 'region_dossier') {
|
||||
setRegionDossier(null);
|
||||
setRegionDossierLoading(false);
|
||||
}
|
||||
}, [selectedEntity]);
|
||||
|
||||
return { regionDossier, regionDossierLoading, handleMapRightClick };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useState, useRef } from "react";
|
||||
import { GEOCODE_THROTTLE_MS, GEOCODE_DISTANCE_THRESHOLD, GEOCODE_CACHE_SIZE } from "@/lib/constants";
|
||||
|
||||
export function useReverseGeocode() {
|
||||
const [mouseCoords, setMouseCoords] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [locationLabel, setLocationLabel] = useState('');
|
||||
const geocodeCache = useRef<Map<string, string>>(new Map());
|
||||
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastGeocodedPos = useRef<{ lat: number; lng: number } | null>(null);
|
||||
const geocodeAbort = useRef<AbortController | null>(null);
|
||||
|
||||
const handleMouseCoords = useCallback((coords: { lat: number; lng: number }) => {
|
||||
setMouseCoords(coords);
|
||||
|
||||
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
|
||||
geocodeTimer.current = setTimeout(async () => {
|
||||
if (lastGeocodedPos.current) {
|
||||
const dLat = Math.abs(coords.lat - lastGeocodedPos.current.lat);
|
||||
const dLng = Math.abs(coords.lng - lastGeocodedPos.current.lng);
|
||||
if (dLat < GEOCODE_DISTANCE_THRESHOLD && dLng < GEOCODE_DISTANCE_THRESHOLD) return;
|
||||
}
|
||||
|
||||
const gridKey = `${(coords.lat).toFixed(2)},${(coords.lng).toFixed(2)}`;
|
||||
const cached = geocodeCache.current.get(gridKey);
|
||||
if (cached) {
|
||||
setLocationLabel(cached);
|
||||
lastGeocodedPos.current = coords;
|
||||
return;
|
||||
}
|
||||
|
||||
if (geocodeAbort.current) geocodeAbort.current.abort();
|
||||
geocodeAbort.current = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${coords.lat}&lon=${coords.lng}&format=json&zoom=10&addressdetails=1`,
|
||||
{ headers: { 'Accept-Language': 'en' }, signal: geocodeAbort.current.signal }
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const addr = data.address || {};
|
||||
const city = addr.city || addr.town || addr.village || addr.county || '';
|
||||
const state = addr.state || addr.region || '';
|
||||
const country = addr.country || '';
|
||||
const parts = [city, state, country].filter(Boolean);
|
||||
const label = parts.join(', ') || data.display_name?.split(',').slice(0, 3).join(',') || 'Unknown';
|
||||
|
||||
if (geocodeCache.current.size > GEOCODE_CACHE_SIZE) {
|
||||
const iter = geocodeCache.current.keys();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const key = iter.next().value;
|
||||
if (key !== undefined) geocodeCache.current.delete(key);
|
||||
}
|
||||
}
|
||||
geocodeCache.current.set(gridKey, label);
|
||||
setLocationLabel(label);
|
||||
lastGeocodedPos.current = coords;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') { /* Silently fail - keep last label */ }
|
||||
}
|
||||
}, GEOCODE_THROTTLE_MS);
|
||||
}, []);
|
||||
|
||||
return { mouseCoords, locationLabel, handleMouseCoords };
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext } from "react";
|
||||
import type { DashboardData } from "@/types/dashboard";
|
||||
|
||||
interface DashboardDataContextValue {
|
||||
data: any;
|
||||
data: DashboardData;
|
||||
selectedEntity: { id: string | number; type: string; extra?: any } | null;
|
||||
setSelectedEntity: (entity: { id: string | number; type: string; extra?: any } | null) => void;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,23 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
type HudColor = "cyan" | "matrix";
|
||||
|
||||
const ThemeContext = createContext<{ theme: Theme; toggleTheme: () => void }>({
|
||||
const ThemeContext = createContext<{
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
hudColor: HudColor;
|
||||
cycleHudColor: () => void;
|
||||
}>({
|
||||
theme: "dark",
|
||||
toggleTheme: () => {},
|
||||
hudColor: "cyan",
|
||||
cycleHudColor: () => {},
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("dark");
|
||||
const [hudColor, setHudColor] = useState<HudColor>("cyan");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("sb-theme") as Theme | null;
|
||||
@@ -18,6 +27,11 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
setTheme(saved);
|
||||
document.documentElement.setAttribute("data-theme", saved);
|
||||
}
|
||||
const savedHud = localStorage.getItem("sb-hud-color") as HudColor | null;
|
||||
if (savedHud === "cyan" || savedHud === "matrix") {
|
||||
setHudColor(savedHud);
|
||||
document.documentElement.setAttribute("data-hud", savedHud);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
@@ -27,8 +41,15 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
};
|
||||
|
||||
const cycleHudColor = () => {
|
||||
const next = hudColor === "cyan" ? "matrix" : "cyan";
|
||||
setHudColor(next);
|
||||
localStorage.setItem("sb-hud-color", next);
|
||||
document.documentElement.setAttribute("data-hud", next);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, hudColor, cycleHudColor }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// ─── ShadowBroker Frontend Constants ────────────────────────────────────────
|
||||
// Centralized magic numbers. Import from here instead of hardcoding.
|
||||
|
||||
// ─── Data Polling ───────────────────────────────────────────────────────────
|
||||
export const POLL_FAST_STARTUP_MS = 3000;
|
||||
export const POLL_FAST_STEADY_MS = 15000;
|
||||
export const POLL_SLOW_STARTUP_MS = 5000;
|
||||
export const POLL_SLOW_STEADY_MS = 120000;
|
||||
|
||||
// ─── Reverse Geocoding ──────────────────────────────────────────────────────
|
||||
export const GEOCODE_THROTTLE_MS = 1500;
|
||||
export const GEOCODE_DISTANCE_THRESHOLD = 0.05; // ~5km in degrees
|
||||
export const GEOCODE_CACHE_SIZE = 500;
|
||||
export const NOMINATIM_DEBOUNCE_MS = 350;
|
||||
|
||||
// ─── Map Interpolation ─────────────────────────────────────────────────────
|
||||
export const INTERP_TICK_MS = 1000;
|
||||
|
||||
// ─── News/Alert Layout ──────────────────────────────────────────────────────
|
||||
export const ALERT_BOX_WIDTH_PX = 180;
|
||||
export const ALERT_MAX_OFFSET_PX = 350;
|
||||
@@ -475,4 +475,7 @@ export interface MaplibreViewerProps {
|
||||
isEavesdropping?: boolean;
|
||||
onEavesdropClick?: (coords: { lat: number; lng: number }) => void;
|
||||
onCameraMove?: (coords: { lat: number; lng: number }) => void;
|
||||
viewBoundsRef?: React.RefObject<{ south: number; west: number; north: number; east: number } | null>;
|
||||
trackedSdr?: KiwiSDR | null;
|
||||
setTrackedSdr?: (sdr: KiwiSDR | null) => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { spreadAlertItems } from '@/utils/alertSpread';
|
||||
|
||||
describe('spreadAlertItems', () => {
|
||||
const makeAlert = (title: string, lat: number, lng: number, cluster_count = 1) => ({
|
||||
title,
|
||||
coords: [lat, lng],
|
||||
cluster_count,
|
||||
alert_level: 3,
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(spreadAlertItems([], 4, new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws on null input (caller must null-check)', () => {
|
||||
expect(() => spreadAlertItems(null as any, 4, new Set())).toThrow();
|
||||
});
|
||||
|
||||
it('filters out items without coords', () => {
|
||||
const items = [
|
||||
{ title: 'No coords', alert_level: 1 },
|
||||
makeAlert('Has coords', 40, -74),
|
||||
];
|
||||
const result = spreadAlertItems(items, 4, new Set());
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].title).toBe('Has coords');
|
||||
});
|
||||
|
||||
it('filters dismissed alerts by alertKey', () => {
|
||||
const items = [
|
||||
makeAlert('Fire in NYC', 40.7, -74.0),
|
||||
makeAlert('Floods in LA', 34.0, -118.2),
|
||||
];
|
||||
const dismissed = new Set(['Fire in NYC|40.7,-74']);
|
||||
const result = spreadAlertItems(items, 4, dismissed);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].title).toBe('Floods in LA');
|
||||
});
|
||||
|
||||
it('preserves originalIdx for popup selection', () => {
|
||||
const items = [
|
||||
{ title: 'Skip me', alert_level: 1 }, // no coords
|
||||
makeAlert('Alert A', 10, 20),
|
||||
makeAlert('Alert B', 30, 40),
|
||||
];
|
||||
const result = spreadAlertItems(items, 4, new Set());
|
||||
expect(result[0].originalIdx).toBe(1);
|
||||
expect(result[1].originalIdx).toBe(2);
|
||||
});
|
||||
|
||||
it('adds alertKey and showLine properties', () => {
|
||||
const items = [makeAlert('Test Alert', 51.5, -0.1)];
|
||||
const result = spreadAlertItems(items, 4, new Set());
|
||||
expect(result[0]).toHaveProperty('alertKey');
|
||||
expect(result[0]).toHaveProperty('showLine');
|
||||
expect(result[0].alertKey).toContain('Test Alert');
|
||||
});
|
||||
|
||||
it('spreads overlapping alerts apart (offsets are non-zero for stacked items)', () => {
|
||||
// Place 5 alerts at the exact same location — they should be spread apart
|
||||
const items = Array.from({ length: 5 }, (_, i) =>
|
||||
makeAlert(`Alert ${i}`, 40.0, -74.0)
|
||||
);
|
||||
const result = spreadAlertItems(items, 8, new Set()); // zoom 8 = close enough to overlap
|
||||
const hasNonZeroOffset = result.some(
|
||||
(r: any) => Math.abs(r.offsetX) > 1 || Math.abs(r.offsetY) > 1
|
||||
);
|
||||
expect(hasNonZeroOffset).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Alert spread collision resolution algorithm.
|
||||
* Takes news items with coordinates and resolves visual overlaps
|
||||
* so alert boxes don't stack on top of each other on the map.
|
||||
*/
|
||||
|
||||
import type { NewsArticle } from "@/types/dashboard";
|
||||
import { ALERT_BOX_WIDTH_PX, ALERT_MAX_OFFSET_PX } from "@/lib/constants";
|
||||
|
||||
export interface SpreadAlertItem extends NewsArticle {
|
||||
originalIdx: number;
|
||||
x: number;
|
||||
y: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
boxH: number;
|
||||
alertKey: string;
|
||||
showLine: boolean;
|
||||
}
|
||||
|
||||
/** Estimate rendered box height based on title length */
|
||||
function estimateBoxH(n: { title?: string; cluster_count?: number }): number {
|
||||
const titleLen = (n.title || "").length;
|
||||
const titleLines = Math.max(1, Math.ceil(titleLen / 20)); // ~20 chars per line at 9px in 160px
|
||||
const hasFooter = (n.cluster_count || 1) > 1;
|
||||
return 10 + 14 + titleLines * 13 + (hasFooter ? 14 : 0) + 10; // padding + header + title + footer + padding
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves alert box collisions using a grid-based spatial algorithm (O(n) per iteration).
|
||||
* Returns positioned items with offsets and alert keys.
|
||||
*/
|
||||
export function spreadAlertItems(
|
||||
news: NewsArticle[],
|
||||
zoom: number,
|
||||
dismissedAlerts: Set<string>
|
||||
): SpreadAlertItem[] {
|
||||
const pixelsPerDeg = (256 * Math.pow(2, zoom)) / 360;
|
||||
|
||||
let items = news
|
||||
.map((n, idx) => ({ ...n, originalIdx: idx }))
|
||||
.filter((n) => n.coords)
|
||||
.map((n) => ({
|
||||
...n,
|
||||
x: n.coords![1] * pixelsPerDeg,
|
||||
y: -n.coords![0] * pixelsPerDeg,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
boxH: estimateBoxH(n as { title?: string; cluster_count?: number }),
|
||||
}));
|
||||
|
||||
const BOX_W = ALERT_BOX_WIDTH_PX;
|
||||
const GAP = 6;
|
||||
const MAX_OFFSET = ALERT_MAX_OFFSET_PX;
|
||||
|
||||
// Grid-based Collision Resolution (O(n) per iteration instead of O(n²))
|
||||
const CELL_W = BOX_W + GAP;
|
||||
const CELL_H = 100;
|
||||
const maxIter = 30;
|
||||
|
||||
for (let iter = 0; iter < maxIter; iter++) {
|
||||
let moved = false;
|
||||
const grid: Record<string, number[]> = {};
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const cx = Math.floor((items[i].x + items[i].offsetX) / CELL_W);
|
||||
const cy = Math.floor((items[i].y + items[i].offsetY) / CELL_H);
|
||||
const key = `${cx},${cy}`;
|
||||
(grid[key] ??= []).push(i);
|
||||
}
|
||||
const checked = new Set<string>();
|
||||
for (const key in grid) {
|
||||
const [cx, cy] = key.split(",").map(Number);
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const nk = `${cx + dx},${cy + dy}`;
|
||||
if (!grid[nk]) continue;
|
||||
const pairKey = cx + dx < cx || (cx + dx === cx && cy + dy < cy) ? `${nk}|${key}` : `${key}|${nk}`;
|
||||
if (key !== nk && checked.has(pairKey)) continue;
|
||||
checked.add(pairKey);
|
||||
const cellA = grid[key];
|
||||
const cellB = key === nk ? cellA : grid[nk];
|
||||
for (const i of cellA) {
|
||||
const startJ = key === nk ? cellA.indexOf(i) + 1 : 0;
|
||||
for (let jIdx = startJ; jIdx < cellB.length; jIdx++) {
|
||||
const j = cellB[jIdx];
|
||||
if (i === j) continue;
|
||||
const a = items[i],
|
||||
b = items[j];
|
||||
const adx = Math.abs(a.x + a.offsetX - (b.x + b.offsetX));
|
||||
const ady = Math.abs(a.y + a.offsetY - (b.y + b.offsetY));
|
||||
const minDistX = BOX_W + GAP;
|
||||
const minDistY = (a.boxH + b.boxH) / 2 + GAP;
|
||||
if (adx < minDistX && ady < minDistY) {
|
||||
moved = true;
|
||||
const overlapX = minDistX - adx;
|
||||
const overlapY = minDistY - ady;
|
||||
if (overlapY < overlapX) {
|
||||
const push = overlapY / 2 + 1;
|
||||
if (a.y + a.offsetY <= b.y + b.offsetY) {
|
||||
a.offsetY -= push;
|
||||
b.offsetY += push;
|
||||
} else {
|
||||
a.offsetY += push;
|
||||
b.offsetY -= push;
|
||||
}
|
||||
} else {
|
||||
const push = overlapX / 2 + 1;
|
||||
if (a.x + a.offsetX <= b.x + b.offsetX) {
|
||||
a.offsetX -= push;
|
||||
b.offsetX += push;
|
||||
} else {
|
||||
a.offsetX += push;
|
||||
b.offsetX -= push;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
|
||||
// Clamp offsets so boxes stay near their origin
|
||||
for (const item of items) {
|
||||
item.offsetX = Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, item.offsetX));
|
||||
item.offsetY = Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, item.offsetY));
|
||||
}
|
||||
|
||||
return items
|
||||
.filter((item) => {
|
||||
const alertKey = `${item.title}|${item.coords?.[0]},${item.coords?.[1]}`;
|
||||
return !dismissedAlerts.has(alertKey);
|
||||
})
|
||||
.map((item) => ({
|
||||
...item,
|
||||
alertKey: `${item.title}|${item.coords?.[0]},${item.coords?.[1]}`,
|
||||
showLine: Math.abs(item.offsetX) > 5 || Math.abs(item.offsetY) > 5,
|
||||
})) as SpreadAlertItem[];
|
||||
}
|
||||
Reference in New Issue
Block a user