diff --git a/frontend/src/__tests__/map/shipFilters.test.ts b/frontend/src/__tests__/map/shipFilters.test.ts new file mode 100644 index 0000000..46c7051 --- /dev/null +++ b/frontend/src/__tests__/map/shipFilters.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { filterShipsByActiveFilters } from '@/components/map/shipFilters'; + +const nimitz = { name: 'USS Nimitz (CVN-68)', type: 'carrier' as const }; +const eclipse = { name: 'ECLIPSE', type: 'yacht' as const }; +const frigate = { name: 'HMS Diamond', type: 'military_vessel' as const }; +const ships = [nimitz, eclipse, frigate]; + +describe('filterShipsByActiveFilters', () => { + it('returns the same array when no ship filter is set', () => { + expect(filterShipsByActiveFilters(ships, undefined)).toBe(ships); + expect(filterShipsByActiveFilters(ships, { ship_type: [] })).toBe(ships); + expect(filterShipsByActiveFilters(ships, { commercial_airline: ['UAL'] })).toBe(ships); + }); + + it('keeps carriers only when their own type is selected', () => { + expect(filterShipsByActiveFilters(ships, { ship_type: ['yacht'] })).toEqual([eclipse]); + expect(filterShipsByActiveFilters(ships, { ship_type: ['military_vessel'] })).toEqual([frigate]); + expect(filterShipsByActiveFilters(ships, { ship_type: ['carrier', 'yacht'] })).toEqual([ + nimitz, + eclipse, + ]); + }); + + it('combines name and type filters', () => { + expect( + filterShipsByActiveFilters(ships, { ship_name: ['ECLIPSE'], ship_type: ['carrier'] }), + ).toEqual([]); + expect(filterShipsByActiveFilters(ships, { ship_name: ['USS Nimitz (CVN-68)'] })).toEqual([ + nimitz, + ]); + }); + + it('handles a missing ship array', () => { + expect(filterShipsByActiveFilters(undefined, { ship_type: ['yacht'] })).toEqual([]); + }); +}); diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index fd9c4f9..47a8fba 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -152,6 +152,7 @@ import { useImperativeSource } from '@/components/map/hooks/useImperativeSource' import { useDynamicMapLayersWorker } from '@/components/map/hooks/useDynamicMapLayersWorker'; import { useStaticMapLayersWorker } from '@/components/map/hooks/useStaticMapLayersWorker'; import { applyDynamicLayerInterp } from '@/components/map/applyDynamicLayerInterp'; +import { filterShipsByActiveFilters } from '@/components/map/shipFilters'; import { ClusterCountLabels, TrackedFlightLabels, @@ -1448,9 +1449,14 @@ const MaplibreViewer = ({ const shipClusters = useClusterLabels(mapRef, 'ships-clusters-layer', shipsGeoJSON); const eqClusters = useClusterLabels(mapRef, 'eq-clusters-layer', earthquakesGeoJSON); + // Carriers bypass the worker, so apply the operator's vessel filters here. + const carrierShips = useMemo( + () => (activeLayers.ships_military ? filterShipsByActiveFilters(data?.ships, activeFilters) : []), + [activeLayers.ships_military, data?.ships, activeFilters], + ); const carriersGeoJSON = useMemo( - () => (activeLayers.ships_military ? buildCarriersGeoJSON(data?.ships) : null), - [activeLayers.ships_military, data?.ships], + () => (activeLayers.ships_military ? buildCarriersGeoJSON(carrierShips) : null), + [activeLayers.ships_military, carrierShips], ); // SAR anomaly pins (Mode B) + AOI watchbox circles. AOIs render whenever @@ -4482,8 +4488,8 @@ const MaplibreViewer = ({ )} {/* HTML labels for carriers (orange names, with ESTIMATED badge for OSINT positions) */} - {carriersGeoJSON && !selectedEntity && !isMapInteracting && data?.ships && ( - + {carriersGeoJSON && !selectedEntity && !isMapInteracting && carrierShips.length > 0 && ( + )} {/* HTML labels for tracked yachts (pink owner names) */} diff --git a/frontend/src/components/map/dynamicMapLayers.worker.ts b/frontend/src/components/map/dynamicMapLayers.worker.ts index 1455d30..3574a48 100644 --- a/frontend/src/components/map/dynamicMapLayers.worker.ts +++ b/frontend/src/components/map/dynamicMapLayers.worker.ts @@ -3,6 +3,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'; type BoundsTuple = [number, number, number, number]; type FC = GeoJSON.FeatureCollection | null; @@ -523,16 +524,7 @@ function applyFilters(activeFilters: Record | undefined) { } // ── Ships ── - let ships = dynamicData.ships; - if (ships && (has('ship_name') || has('ship_type'))) { - const nameSet = has('ship_name') ? set('ship_name') : null; - const typeSet = has('ship_type') ? set('ship_type') : null; - ships = ships.filter((s: any) => { - if (nameSet && !nameSet.has(s.name)) return false; - if (typeSet && !typeSet.has(s.type)) return false; - return true; - }); - } + const ships = dynamicData.ships ? filterShipsByActiveFilters(dynamicData.ships, f) : dynamicData.ships; return { commercial, private_, jets, military, tracked, ships }; } diff --git a/frontend/src/components/map/shipFilters.ts b/frontend/src/components/map/shipFilters.ts new file mode 100644 index 0000000..cbf42d4 --- /dev/null +++ b/frontend/src/components/map/shipFilters.ts @@ -0,0 +1,21 @@ +import type { Ship } from '@/types/dashboard'; + +/** + * Operator vessel filters from the Data Filters panel. Shared by the worker + * (regular ship icons) and the main thread (carrier icons and labels) so every + * ship source honours the same name / type selection. + */ +export function filterShipsByActiveFilters>( + ships: T[] | undefined, + activeFilters: Record | undefined, +): T[] { + if (!ships) return []; + const nameSet = activeFilters?.ship_name?.length ? new Set(activeFilters.ship_name) : null; + const typeSet = activeFilters?.ship_type?.length ? new Set(activeFilters.ship_type) : null; + if (!nameSet && !typeSet) return ships; + return ships.filter((s) => { + if (nameSet && !nameSet.has(s.name)) return false; + if (typeSet && !typeSet.has(s.type)) return false; + return true; + }); +}