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;
+ });
+}