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:
anoracleofra-code
2026-03-14 14:01:54 -06:00
parent 60c90661d4
commit 90c2e90e2c
63 changed files with 6015 additions and 2756 deletions
@@ -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>`)}`;