diff --git a/frontend/src/__tests__/map/labelSubjects.test.ts b/frontend/src/__tests__/map/labelSubjects.test.ts new file mode 100644 index 0000000..c964ff0 --- /dev/null +++ b/frontend/src/__tests__/map/labelSubjects.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { shipsWithIcons, trackedFlightsWithIcons } from '@/components/map/labelSubjects'; +import { shipFeatureId, trackedFlightFeatureId } from '@/components/map/featureIds'; +import type { Ship, TrackedFlight } from '@/types/dashboard'; + +function collection(ids: Array): GeoJSON.FeatureCollection { + return { + type: 'FeatureCollection', + features: ids.map((id) => ({ + type: 'Feature', + properties: { id }, + geometry: { type: 'Point', coordinates: [0, 0] }, + })), + }; +} + +function flight(overrides: Partial): TrackedFlight { + return { type: 'tracked_flight', lat: 40, lng: -74, ...overrides } as TrackedFlight; +} + +function ship(overrides: Partial): Ship { + return { type: 'yacht', lat: 40, lng: -74, ...overrides } as Ship; +} + +describe('trackedFlightsWithIcons', () => { + const spielberg = flight({ icao24: 'a1b2c3', callsign: 'N700KS', alert_category: 'Celebrity' }); + const quest = flight({ icao24: 'd4e5f6', callsign: 'LBQ500', alert_category: 'Business' }); + + it('keeps only flights whose icao24 has a feature in the filtered layer', () => { + expect(trackedFlightsWithIcons([spielberg, quest], collection(['a1b2c3']))).toEqual([spielberg]); + }); + + it('matches features by the same id rule the worker uses', () => { + const noIcao = flight({ callsign: 'ZZZ1' }); + const nothing = flight({}); + // Worker-side ids, including the positional fallback for an unidentifiable row. + const ids = [spielberg, noIcao, nothing].map((f, i) => trackedFlightFeatureId(f, `tracked-${i}`)); + expect(ids).toEqual(['a1b2c3', 'ZZZ1', 'tracked-2']); + // Only rows with a real identifier can be matched back for labelling. + expect(trackedFlightsWithIcons([spielberg, noIcao, nothing], collection(ids))).toEqual([ + spielberg, + noIcao, + ]); + }); + + it('returns nothing when the layer has no GeoJSON', () => { + expect(trackedFlightsWithIcons([spielberg], null)).toEqual([]); + expect(trackedFlightsWithIcons(undefined, collection(['a1b2c3']))).toEqual([]); + }); +}); + +describe('shipsWithIcons', () => { + const eclipse = ship({ mmsi: 123456789, name: 'ECLIPSE', yacht_alert: true }); + const tanker = ship({ mmsi: 987654321, name: 'TANKER', type: 'tanker' }); + + it('matches numeric mmsi against the string feature id', () => { + expect(shipsWithIcons([eclipse, tanker], collection([123456789]))).toEqual([eclipse]); + }); + + it('matches features by the same id rule the worker uses', () => { + const unnamed = ship({ name: 'NO MMSI' }); + const ids = [eclipse, unnamed].map((s, i) => shipFeatureId(s, `ship-${i}`)); + expect(ids).toEqual(['123456789', 'NO MMSI']); + expect(shipsWithIcons([eclipse, unnamed], collection(ids))).toEqual([eclipse, unnamed]); + }); + + it('returns nothing when the layer has no GeoJSON', () => { + expect(shipsWithIcons([eclipse], null)).toEqual([]); + }); +}); diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index 47a8fba..b4ecd04 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -153,6 +153,7 @@ import { useDynamicMapLayersWorker } from '@/components/map/hooks/useDynamicMapL import { useStaticMapLayersWorker } from '@/components/map/hooks/useStaticMapLayersWorker'; import { applyDynamicLayerInterp } from '@/components/map/applyDynamicLayerInterp'; import { filterShipsByActiveFilters } from '@/components/map/shipFilters'; +import { shipsWithIcons, trackedFlightsWithIcons } from '@/components/map/labelSubjects'; import { ClusterCountLabels, TrackedFlightLabels, @@ -1311,6 +1312,17 @@ const MaplibreViewer = ({ aprsGeoJSON, } = interpolatedDynamicMapLayers; + // Label subjects follow the worker output (pre-interp, stable between + // rebuilds) so labels track the same data filters as the icons. + const trackedFlightsForLabels = useMemo( + () => trackedFlightsWithIcons(data?.tracked_flights, dynamicMapLayers.trackedFlightsGeoJSON), + [data?.tracked_flights, dynamicMapLayers.trackedFlightsGeoJSON], + ); + const shipsForYachtLabels = useMemo( + () => shipsWithIcons(data?.ships, dynamicMapLayers.shipsGeoJSON), + [data?.ships, dynamicMapLayers.shipsGeoJSON], + ); + const staticMapLayers = useStaticMapLayersWorker( { cctv: staticCctv, @@ -4478,9 +4490,9 @@ const MaplibreViewer = ({ )} {/* HTML labels for tracked flights — color-matched, zoom-gated for non-HVA */} - {trackedFlightsGeoJSON && !selectedEntity && !isMapInteracting && data?.tracked_flights && ( + {trackedFlightsGeoJSON && !selectedEntity && !isMapInteracting && trackedFlightsForLabels.length > 0 && ( + {shipsGeoJSON && activeLayers.ships_tracked_yachts && !selectedEntity && !isMapInteracting && shipsForYachtLabels.length > 0 && ( + )} {/* HTML labels for earthquake cluster counts (hidden when any entity popup is active) */} diff --git a/frontend/src/components/map/dynamicMapLayers.worker.ts b/frontend/src/components/map/dynamicMapLayers.worker.ts index 3574a48..36f4914 100644 --- a/frontend/src/components/map/dynamicMapLayers.worker.ts +++ b/frontend/src/components/map/dynamicMapLayers.worker.ts @@ -4,6 +4,7 @@ import { classifyAircraft } from '@/utils/aircraftClassification'; import type { Flight, Ship, SigintSignal } from '@/types/dashboard'; import type { FlightLayerConfig } from '@/components/map/geoJSONBuilders'; import { filterShipsByActiveFilters } from '@/components/map/shipFilters'; +import { shipFeatureId, trackedFlightFeatureId } from '@/components/map/featureIds'; type BoundsTuple = [number, number, number, number]; type FC = GeoJSON.FeatureCollection | null; @@ -306,7 +307,7 @@ function buildTrackedFlightsGeoJSONWorker( features.push({ type: 'Feature', properties: { - id: f.icao24 || i, + id: trackedFlightFeatureId(f, `tracked-${i}`), type: 'tracked_flight', callsign: String(displayName), rotation, @@ -375,7 +376,7 @@ function buildShipsGeoJSONWorker( features.push({ type: 'Feature', properties: { - id: s.mmsi || s.name || `ship-${i}`, + id: shipFeatureId(s, `ship-${i}`), type: 'ship', name: s.name, rotation, diff --git a/frontend/src/components/map/featureIds.ts b/frontend/src/components/map/featureIds.ts new file mode 100644 index 0000000..b89538a --- /dev/null +++ b/frontend/src/components/map/featureIds.ts @@ -0,0 +1,18 @@ +import type { Ship } from '@/types/dashboard'; + +/** + * Feature ids shared by the worker that builds map icons and the main-thread + * code that picks label subjects, so both sides agree on which record a + * feature represents. Records with no identifier get the worker's positional + * fallback, which cannot be matched from outside and so draw no label. + */ +export function trackedFlightFeatureId( + f: { icao24?: string; callsign?: string }, + fallback = '', +): string { + return f.icao24 || f.callsign || fallback; +} + +export function shipFeatureId(s: Pick, fallback = ''): string { + return String(s.mmsi || s.name || fallback); +} diff --git a/frontend/src/components/map/labelSubjects.ts b/frontend/src/components/map/labelSubjects.ts new file mode 100644 index 0000000..35e8365 --- /dev/null +++ b/frontend/src/components/map/labelSubjects.ts @@ -0,0 +1,36 @@ +import type { Ship, TrackedFlight } from '@/types/dashboard'; +import { shipFeatureId, trackedFlightFeatureId } from '@/components/map/featureIds'; + +/** + * HTML labels are rendered from the raw store arrays, while the icons come + * from the worker-built GeoJSON that already has the operator's data filters + * applied. Restrict the label subjects to entities that actually have an icon + * so a filtered-out aircraft or yacht does not keep its name on the map. + */ +function featureIds(fc: GeoJSON.FeatureCollection | null | undefined): Set | null { + if (!fc) return null; + const ids = new Set(); + for (const feature of fc.features) { + const id = feature.properties?.id; + if (id != null) ids.add(String(id)); + } + return ids; +} + +export function trackedFlightsWithIcons( + flights: TrackedFlight[] | undefined, + fc: GeoJSON.FeatureCollection | null | undefined, +): TrackedFlight[] { + const ids = featureIds(fc); + if (!ids || !flights?.length) return []; + return flights.filter((f) => ids.has(trackedFlightFeatureId(f))); +} + +export function shipsWithIcons( + ships: Ship[] | undefined, + fc: GeoJSON.FeatureCollection | null | undefined, +): Ship[] { + const ids = featureIds(fc); + if (!ids || !ships?.length) return []; + return ships.filter((s) => ids.has(shipFeatureId(s))); +}