Merge pull request #560 from C3B2W23/fix/labels-honor-data-filters

fix(map): keep tracked aircraft and yacht labels in step with data filters
This commit is contained in:
Shadowbroker
2026-09-15 01:51:29 -06:00
committed by GitHub
5 changed files with 143 additions and 6 deletions
@@ -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<string | number>): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: ids.map((id) => ({
type: 'Feature',
properties: { id },
geometry: { type: 'Point', coordinates: [0, 0] },
})),
};
}
function flight(overrides: Partial<TrackedFlight>): TrackedFlight {
return { type: 'tracked_flight', lat: 40, lng: -74, ...overrides } as TrackedFlight;
}
function ship(overrides: Partial<Ship>): 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([]);
});
});
+16 -4
View File
@@ -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 && (
<TrackedFlightLabels
flights={data.tracked_flights}
flights={trackedFlightsForLabels}
zoom={mapZoom}
inView={inView}
interpFlight={interpFlight}
@@ -4493,8 +4505,8 @@ const MaplibreViewer = ({
)}
{/* HTML labels for tracked yachts (pink owner names) */}
{shipsGeoJSON && activeLayers.ships_tracked_yachts && !selectedEntity && !isMapInteracting && data?.ships && (
<TrackedYachtLabels ships={data.ships} inView={inView} interpShip={interpShip} />
{shipsGeoJSON && activeLayers.ships_tracked_yachts && !selectedEntity && !isMapInteracting && shipsForYachtLabels.length > 0 && (
<TrackedYachtLabels ships={shipsForYachtLabels} inView={inView} interpShip={interpShip} />
)}
{/* HTML labels for earthquake cluster counts (hidden when any entity popup is active) */}
@@ -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,
+18
View File
@@ -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<Ship, 'mmsi' | 'name'>, fallback = ''): string {
return String(s.mmsi || s.name || fallback);
}
@@ -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<string> | null {
if (!fc) return null;
const ids = new Set<string>();
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)));
}