From 9df81bf6367535170b522bbe4ad9a14524397b50 Mon Sep 17 00:00:00 2001 From: C3B2W23 <217007207+C3B2W23@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:02:31 -0700 Subject: [PATCH] fix(map): apply vessel data filters to the carrier layer Carrier icons and labels are built on the main thread from the raw ship array, separately from the worker that draws every other vessel, so the Data Filters vessel name / type selection never reached them. Filtering to yachts left the aircraft carriers on the map. Move the ship filter predicate into a shared helper used by both the worker and the carrier builder so all ship sources honour the same selection. Co-Authored-By: Claude Fable 5.1 --- .../src/__tests__/map/shipFilters.test.ts | 37 +++++++++++++++++++ frontend/src/components/MaplibreViewer.tsx | 14 +++++-- .../components/map/dynamicMapLayers.worker.ts | 12 +----- frontend/src/components/map/shipFilters.ts | 21 +++++++++++ 4 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 frontend/src/__tests__/map/shipFilters.test.ts create mode 100644 frontend/src/components/map/shipFilters.ts 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; + }); +}