diff --git a/frontend/src/components/LiveAircraftMap.tsx b/frontend/src/components/LiveAircraftMap.tsx index b3d581a12..b627b9e35 100644 --- a/frontend/src/components/LiveAircraftMap.tsx +++ b/frontend/src/components/LiveAircraftMap.tsx @@ -27,6 +27,9 @@ import { applyGroundTruthFixes, pruneGroundTruthFixes, sweepStaleGroundTruthFixes, + truthClass, + truthFill, + truthBorder, isPointInViewport, isAircraftInViewport, sampleTrailPositions, @@ -103,7 +106,7 @@ L.Icon.Default.mergeOptions({ const _gtCanvas = typeof window !== "undefined" ? L.canvas({ padding: 0.5, pane: GT_CLICK_PANE }) : null; const GroundTruthCanvasLayer = memo(function GroundTruthCanvasLayer({ aircraft, onSelect, selectedHex }) { - const { ANOMALY, DRONE, SELECTED, TRUTH, TRUTH_DARK } = usePalette(); + const palette = usePalette(); const map = useMap(); const markerMapRef = useRef(new Map()); // hex → L.circleMarker — incremental diff const onSelectRef = useRef(onSelect); @@ -116,20 +119,17 @@ const GroundTruthCanvasLayer = memo(function GroundTruthCanvasLayer({ aircraft, for (const ac of aircraft) { seen.add(ac.hex); - const isAnom = ac.is_anomalous; - const isDrone = ac.object_type === "drone"; - // Dark = simulated aircraft flying without ADS-B. Grey, matching the - // Physics tab's Dark Aircraft legend, so a viewer can tell at a glance - // which truth dots the radar must find on its own. Strict === false: - // entries without the field (older payloads) keep the ADS-B blue. - const isDark = !isAnom && !isDrone && ac.has_adsb === false; + // Four plain-aircraft classes — simulated vs live-feed, with vs without + // a transponder — plus anomalous and drone, resolved in one place + // (map/truthColor) so the legend, the list and the Physics tab agree + // with the dot. A dark dot is one the radar must find on its own; a + // teal one is a real aircraft the synthetic nodes are echoing. + const cls = truthClass(ac); + const isAnom = cls === "anomalous"; + const isDrone = cls === "drone"; const isSel = ac.hex === selectedHex; - const color = isAnom ? ANOMALY : isDrone ? DRONE : isDark ? TRUTH_DARK : TRUTH; - // Each dot takes an edge a shade darker than its own fill. Selection is - // the amber the map already uses for a selected glyph, its arcs and its - // trail: truth is the theme's neutral extreme, so a neutral ring would - // sit a single shade from the fill it rings. - const border = isSel ? SELECTED : isAnom ? "#991b1b" : isDrone ? "#b45309" : isDark ? "#334155" : "#020617"; + const color = truthFill(cls, palette); + const border = truthBorder(cls, isSel, palette); const baseR = isDrone ? 6 : isAnom ? 8 : 9; const radius = isSel ? baseR + 4 : baseR; const weight = isSel ? 4 : 3; @@ -165,7 +165,7 @@ const GroundTruthCanvasLayer = memo(function GroundTruthCanvasLayer({ aircraft, markerMap.delete(hex); } } - }, [aircraft, map, selectedHex, ANOMALY, DRONE, SELECTED, TRUTH, TRUTH_DARK]); + }, [aircraft, map, selectedHex, palette]); // Full cleanup on unmount useEffect(() => { diff --git a/frontend/src/components/PhysicsSettings.tsx b/frontend/src/components/PhysicsSettings.tsx index db33e5ea3..1c68bad91 100644 --- a/frontend/src/components/PhysicsSettings.tsx +++ b/frontend/src/components/PhysicsSettings.tsx @@ -7,6 +7,7 @@ import { PLANE_PATH } from "./map/icons"; // One palette for both tabs, so this legend and the map cannot disagree. import { usePalette } from "./map/useMapTheme"; import { activePalette, activeTheme } from "./map/mapPalette"; +import { truthClass, truthFill, truthLegend } from "./map/truthColor"; import { withCartoKey } from "../utils/basemap"; import { formatKm, @@ -139,7 +140,7 @@ function serverToScene(data) { export default function PhysicsSettings() { const palette = usePalette(); const theme = activeTheme(); - const { ACCENT_STRONG, BAD, SIM_ANOMALOUS, SIM_COMMERCIAL, SIM_DARK, SIM_DRONE, SIM_SCENE } = palette; + const { ACCENT_STRONG, BAD, SIM_ANOMALOUS, SIM_COMMERCIAL, SIM_DARK, SIM_DRONE, SIM_SCENE, TRUTH_LIVE, TRUTH_LIVE_DARK } = palette; // Both depend on the palette and nothing else, so neither is rebuilt on the // slider drags that dominate this page's re-renders. const types = useMemo(() => objectTypes(palette), [palette]); @@ -692,7 +693,7 @@ export default function PhysicsSettings() { Deliberately its own card, not a segment of the composition bar: the feed sets the live headcount, so it is not a share of the synthetic mix and must not be summed with it. */} -
+
Live ADS-B traffic (real aircraft from adsb.retina.fm, echoed by the synthetic nodes) @@ -708,8 +709,10 @@ export default function PhysicsSettings() { }} /> Pull live aircraft into the simulation - - {counts.live ?? 0} live · {counts.live_dark ?? 0} dark + + ● {counts.live_adsb ?? 0} ADS-B + {" · "} + ● {counts.live_dark ?? 0} dark
@@ -728,11 +731,11 @@ export default function PhysicsSettings() { className="ps-range" aria-label="Dark share of live aircraft" style={{ - "--thumb-color": SIM_DARK, + "--thumb-color": TRUTH_LIVE_DARK, "--fill-pct": `${pct(draft.frac_live_dark)}%`, }} /> - {pct(draft.frac_live_dark)}% + {pct(draft.frac_live_dark)}%

Independent of the synthetic mix above: the dark slider and the objects target only govern @@ -886,13 +889,13 @@ export default function PhysicsSettings() { const centerLat = centerSrc.reduce((s, a) => s + a.lat, 0) / centerSrc.length; const centerLon = centerSrc.reduce((s, a) => s + a.lon, 0) / centerSrc.length; + // Same resolver as the live map's truth dots (map/truthColor): the + // four plain-aircraft classes (simulated / live-feed × ADS-B / dark) + // plus anomalous and drone, so a dot here is the colour it is there. + // The "dark" object_type literal is kept for older payloads. function acColor(a) { - if (a.is_anomalous) return SIM_ANOMALOUS; - if (a.object_type === "drone") return SIM_DRONE; - // The backend reports dark aircraft as object_type "aircraft" with - // has_adsb false (the "dark" literal is kept for older payloads). - if (a.object_type === "dark" || a.has_adsb === false) return SIM_DARK; - return SIM_COMMERCIAL; + if (a.object_type === "dark") return SIM_DARK; + return truthFill(truthClass(a), palette); } return ( @@ -930,9 +933,6 @@ export default function PhysicsSettings() { fillColor: acColor(a), fillOpacity: 0.85, weight: a.is_anomalous ? 2 : 1, - // Live-feed aircraft get a dashed ring so real and - // synthetic trajectories can be told apart at a glance. - dashArray: a.source === "live" ? "2 2" : undefined, }} > @@ -945,11 +945,11 @@ export default function PhysicsSettings() {

- ● Commercial - ● Dark + {truthLegend(palette).map((t) => ( + ● {t.label.replace("Truth: ", "")} + ))} ● Drone ● Anomalous - ◌ dashed = live feed
); diff --git a/frontend/src/components/map/AircraftDetailPanel.tsx b/frontend/src/components/map/AircraftDetailPanel.tsx index 9a9a82211..652674c08 100644 --- a/frontend/src/components/map/AircraftDetailPanel.tsx +++ b/frontend/src/components/map/AircraftDetailPanel.tsx @@ -325,6 +325,12 @@ export default function AircraftDetailPanel({ ac, onClose, groundTruth, trails, {isTruthOnly && (
Simulation (debug)
+ {ac.source && ( + + )} {visibleItems.map((ac) => { const isSolved = ac._isSolved; - const color = !isSolved ? TRUTH : getAircraftColor(ac); + // Truth-only rows take the same class colour as their dot. + const color = !isSolved ? truthFill(truthClass(ac), palette) : getAircraftColor(ac); const callsign = ac.flight?.trim() || ac.hex?.slice(-6).toUpperCase() || ac.hex; // Nullish checks: 0 ft, 0 kt and 0° (due north) are real diff --git a/frontend/src/components/map/MapLegend.tsx b/frontend/src/components/map/MapLegend.tsx index 93b52c5b2..7ea26a1d0 100644 --- a/frontend/src/components/map/MapLegend.tsx +++ b/frontend/src/components/map/MapLegend.tsx @@ -1,4 +1,5 @@ import { altitudeLegend } from "./icons"; +import { truthLegend } from "./truthColor"; import { usePalette } from "./useMapTheme"; import { usePersistedState } from "./usePersistedState"; @@ -11,7 +12,8 @@ import { usePersistedState } from "./usePersistedState"; * its header for anyone who has learnt the colours. */ export default function MapLegend({ colorByAlt, showGroundTruth, showIlluminators, hasPlayback }) { - const { ILLUMINATOR, LANE_MN_ADSB, LANE_MN_DARK, LANE_SOLVER_SEED, NODE, TRUTH } = usePalette(); + const palette = usePalette(); + const { ILLUMINATOR, LANE_MN_ADSB, LANE_MN_DARK, LANE_SOLVER_SEED, NODE } = palette; const [open, setOpen] = usePersistedState("tf.legendOpen", true); return ( @@ -38,7 +40,12 @@ export default function MapLegend({ colorByAlt, showGroundTruth, showIlluminator one. */} - {showGroundTruth && } + {/* Truth splits four ways: simulated vs live-feed (neutral vs + teal) and with vs without a transponder (bright vs dark), + the same resolver the dots are drawn with. */} + {showGroundTruth && truthLegend(palette).map((t) => ( + + ))} )} diff --git a/frontend/src/components/map/groundTruthFixes.ts b/frontend/src/components/map/groundTruthFixes.ts index 8a967157b..780bc5acb 100644 --- a/frontend/src/components/map/groundTruthFixes.ts +++ b/frontend/src/components/map/groundTruthFixes.ts @@ -22,7 +22,7 @@ import { mToFt, msToKnots } from "./units"; * * @param fixes the fixesRef store, mutated * @param snapshot hex → trail, each point [lat, lon, alt_m, ts] - * @param meta hex → { speed_ms, heading, object_type, is_anomalous } + * @param meta hex → { speed_ms, heading, object_type, is_anomalous, has_adsb, source } * @param now ms epoch used as the dead-reckoning anchor * @returns the set of store keys this snapshot vouches for */ @@ -54,6 +54,9 @@ export function applyGroundTruthFixes(fixes, snapshot, meta, now) { // Simulated parameters for the debug detail panel. "Dark" objects are // object_type "aircraft" with has_adsb false. has_adsb: m.has_adsb, + // Provenance: "live" = mirrored from the ADS-B feed, "sim" = spawned. + // Colours the dot (map/truthColor) and labels the detail panel. + source: m.source, adsb_callsign: m.adsb_callsign, anomaly_event: m.anomaly_event, points: positions.length, diff --git a/frontend/src/components/map/index.ts b/frontend/src/components/map/index.ts index d24840123..65d7465f5 100644 --- a/frontend/src/components/map/index.ts +++ b/frontend/src/components/map/index.ts @@ -1,6 +1,7 @@ // Barrel file — re-exports every module for clean imports export { API_BASE, STALE_AIRCRAFT_MS, GT_FEED_STALE_MS, GT_PRUNE_GRACE_MS, MAX_HISTORY, VIEWPORT_PAD_DEG, ARC_HOLD_MS, ARC_FADE_MS, ARC_TOTAL_LIFE_MS, POSITION_SOURCE_ARC_ONLY, POSITION_SOURCE_ADSB_SINGLE, ADSB_SINGLE_ARC_ICON_MULTIPLE, ARC_DR_MAX_S, GT_KEY_PREFIX, groundTruthKey, dopplerColor } from "./constants"; export { applyGroundTruthFixes, pruneGroundTruthFixes, sweepStaleGroundTruthFixes } from "./groundTruthFixes"; +export { truthClass, truthFill, truthBorder, truthLegend } from "./truthColor"; export { buildViewportSnapshot, isPointInViewport, diff --git a/frontend/src/components/map/mapPalette.ts b/frontend/src/components/map/mapPalette.ts index ce547cbca..ddf0c6eb9 100644 --- a/frontend/src/components/map/mapPalette.ts +++ b/frontend/src/components/map/mapPalette.ts @@ -75,6 +75,18 @@ export interface MapPalette { /** The "dark aircraft" ground-truth dot: a simulated target flying without * ADS-B. Grey, and far enough from TRUTH to read beside it. */ TRUTH_DARK: string; + /** Ground truth mirrored from the live ADS-B feed (a real aircraft the + * simulator flies and the synthetic nodes echo), with and without its + * transponder. Teal — the one hue family no lane or furniture uses, so + * the pair reads as "truth, but real" beside the neutral simulated pair + * without borrowing a lane. Measured like the rest: every value clears + * its theme's contrast floor and sits CIEDE2000 ≥ 13 from every other + * mark (worst: teal-800 vs the solver-seed lane on light, 13.1), ≥ 22 + * from the neutral truth pair, and ≥ 19 from its own partner. teal-800 + * rather than teal-900 on light: measured 21.9 from near-black, but on + * the map a 9 px teal-900 dot beside a slate-800 one read as two blacks. */ + TRUTH_LIVE: string; + TRUTH_LIVE_DARK: string; /** MLAT verification overlay: the solve dot, and the dashed vector to the * position it is being checked against. */ MLAT: string; @@ -150,6 +162,8 @@ const LIGHT: MapPalette = { BEAM_GAP: "#b91c1c", // red-700 TRUTH: "#1e293b", // slate-800 TRUTH_DARK: "#64748b", // slate-500 + TRUTH_LIVE: "#115e59", // teal-800 — 7.1:1, ΔE 13.1 from the seed lane, 25.5 from TRUTH + TRUTH_LIVE_DARK: "#0d9488", // teal-600 — 3.5:1, ΔE 13.5 from the seed lane, 19.3 from TRUTH_LIVE MLAT: "#c026d3", // fuchsia-600 MLAT_VECTOR: "#a21caf", // fuchsia-700 @@ -210,6 +224,8 @@ const DARK: MapPalette = { BEAM_GAP: "#f87171", // red-400 TRUTH: "#f8fafc", // slate-50 — the neutral extreme, mirroring light's ink TRUTH_DARK: "#94a3b8", // slate-400 + TRUTH_LIVE: "#99f6e4", // teal-200 — 13.8:1, ΔE 17.6 from the mn-adsb lane + TRUTH_LIVE_DARK: "#0d9488", // teal-600 — 4.7:1, ΔE 22.9 from the mn-adsb lane MLAT: "#e879f9", // fuchsia-400 MLAT_VECTOR: "#f0abfc", // fuchsia-300 diff --git a/frontend/src/components/map/truthColor.test.ts b/frontend/src/components/map/truthColor.test.ts new file mode 100644 index 000000000..5378eef6a --- /dev/null +++ b/frontend/src/components/map/truthColor.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { PALETTES } from "./mapPalette"; +import { truthBorder, truthClass, truthFill, truthLegend } from "./truthColor"; + +describe("truthClass", () => { + it("splits plain aircraft into the four provenance × transponder classes", () => { + expect(truthClass({ object_type: "aircraft", has_adsb: true, source: "sim" })).toBe("sim_adsb"); + expect(truthClass({ object_type: "aircraft", has_adsb: false, source: "sim" })).toBe("sim_dark"); + expect(truthClass({ object_type: "aircraft", has_adsb: true, source: "live" })).toBe("live_adsb"); + expect(truthClass({ object_type: "aircraft", has_adsb: false, source: "live" })).toBe("live_dark"); + }); + + it("keeps anomalous and drone ahead of provenance", () => { + expect(truthClass({ is_anomalous: true, source: "live", has_adsb: false })).toBe("anomalous"); + expect(truthClass({ object_type: "drone", source: "live", has_adsb: false })).toBe("drone"); + }); + + it("reads an older payload with neither field as simulated ADS-B", () => { + expect(truthClass({})).toBe("sim_adsb"); + expect(truthClass({ object_type: "aircraft" })).toBe("sim_adsb"); + }); +}); + +describe("truthFill / truthBorder", () => { + it.each(["light", "dark"] as const)("gives the four classes four distinct fills (%s)", (theme) => { + const p = PALETTES[theme]; + const fills = (["sim_adsb", "sim_dark", "live_adsb", "live_dark"] as const).map((c) => truthFill(c, p)); + expect(new Set(fills).size).toBe(4); + expect(fills).toEqual([p.TRUTH, p.TRUTH_DARK, p.TRUTH_LIVE, p.TRUTH_LIVE_DARK]); + }); + + it("uses the selection amber for a selected dot in either theme", () => { + expect(truthBorder("live_dark", true, PALETTES.dark)).toBe(PALETTES.dark.SELECTED); + expect(truthBorder("live_dark", false, PALETTES.dark)).not.toBe(PALETTES.dark.SELECTED); + }); + + it("legend rows carry the same colours the dots are drawn with", () => { + const p = PALETTES.light; + for (const row of truthLegend(p)) expect(row.color).toBe(truthFill(row.cls, p)); + }); +}); diff --git a/frontend/src/components/map/truthColor.ts b/frontend/src/components/map/truthColor.ts new file mode 100644 index 000000000..af914ea4f --- /dev/null +++ b/frontend/src/components/map/truthColor.ts @@ -0,0 +1,77 @@ +import type { MapPalette } from "./mapPalette"; + +/* ── Ground-truth object classes and their colours. + * + * A truth dot answers two questions at once: what kind of target is this + * (anomalous / drone / a plain aircraft), and — for a plain aircraft — which + * of the four populations it belongs to: + * + * ADS-B (bright) no transponder (dark) + * simulated spawn TRUTH TRUTH_DARK + * live-feed mirror TRUTH_LIVE TRUTH_LIVE_DARK + * + * Simulated truth is the neutral ramp (near-black / near-white, then grey); + * live-feed truth is the teal ramp. Bright vs dark within a ramp is the + * transponder question, neutral vs teal is the provenance question, so each + * axis is readable on its own. One resolver, used by the live map's canvas + * layer, the aircraft list, the legend and the Physics tab's preview map, so + * the same object is the same colour everywhere. + * + * `has_adsb` and `source` are compared strictly: an entry from an older + * payload that carries neither keeps the plain simulated-ADS-B colour rather + * than being read as dark or live. ── */ + +export type TruthClass = "anomalous" | "drone" | "sim_adsb" | "sim_dark" | "live_adsb" | "live_dark"; + +export interface TruthLike { + is_anomalous?: boolean; + object_type?: string; + has_adsb?: boolean; + source?: string; +} + +export function truthClass(ac: TruthLike): TruthClass { + if (ac.is_anomalous) return "anomalous"; + if (ac.object_type === "drone") return "drone"; + const dark = ac.has_adsb === false; + if (ac.source === "live") return dark ? "live_dark" : "live_adsb"; + return dark ? "sim_dark" : "sim_adsb"; +} + +export function truthFill(cls: TruthClass, p: MapPalette): string { + switch (cls) { + case "anomalous": return p.ANOMALY; + case "drone": return p.DRONE; + case "live_adsb": return p.TRUTH_LIVE; + case "live_dark": return p.TRUTH_LIVE_DARK; + case "sim_dark": return p.TRUTH_DARK; + default: return p.TRUTH; + } +} + +/* Each dot takes an edge a shade darker than its own fill — the same per-class + edges in both themes, since a near-black rim separates a fill from either + basemap. Selection is the amber the map already uses for a selected glyph, + its arcs and its trail. */ +const BORDERS: Record = { + anomalous: "#991b1b", // red-800 + drone: "#b45309", // amber-700 + sim_adsb: "#020617", // slate-950 + sim_dark: "#334155", // slate-700 + live_adsb: "#042f2e", // teal-950 + live_dark: "#115e59", // teal-800 +}; + +export function truthBorder(cls: TruthClass, selected: boolean, p: MapPalette): string { + return selected ? p.SELECTED : BORDERS[cls]; +} + +/** Legend rows for the plain-aircraft classes, in the order the legend shows them. */ +export function truthLegend(p: MapPalette): { cls: TruthClass; color: string; label: string }[] { + return [ + { cls: "sim_adsb", color: p.TRUTH, label: "Truth: sim ADS-B" }, + { cls: "sim_dark", color: p.TRUTH_DARK, label: "Truth: sim dark" }, + { cls: "live_adsb", color: p.TRUTH_LIVE, label: "Truth: live ADS-B" }, + { cls: "live_dark", color: p.TRUTH_LIVE_DARK, label: "Truth: live dark" }, + ]; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 717179fe0..cc9259c56 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -107,6 +107,9 @@ export interface GroundTruthMeta { has_adsb?: boolean; adsb_callsign?: string | null; anomaly_event?: string | null; + /** "live" for an aircraft the simulator mirrored from the ADS-B feed, + * "sim" for one it spawned itself; absent from older fleets. */ + source?: "live" | "sim"; } /** Data returned by useAircraftFeed() */