mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-17 06:35:29 +02:00
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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in New Issue
Block a user