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() */