Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions frontend/src/components/LiveAircraftMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
applyGroundTruthFixes,
pruneGroundTruthFixes,
sweepStaleGroundTruthFixes,
truthClass,
truthFill,
truthBorder,
isPointInViewport,
isAircraftInViewport,
sampleTrailPositions,
Expand Down Expand Up @@ -103,7 +106,7 @@
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);
Expand All @@ -116,20 +119,17 @@

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;
Expand Down Expand Up @@ -165,12 +165,12 @@
markerMap.delete(hex);
}
}
}, [aircraft, map, selectedHex, ANOMALY, DRONE, SELECTED, TRUTH, TRUTH_DARK]);
}, [aircraft, map, selectedHex, palette]);

// Full cleanup on unmount
useEffect(() => {
return () => {
for (const m of markerMapRef.current.values()) m.remove();

Check warning on line 173 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

The ref value 'markerMapRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'markerMapRef.current' to a variable inside the effect, and use that variable in the cleanup function
markerMapRef.current.clear();
};
}, [map]);
Expand Down Expand Up @@ -323,7 +323,7 @@
}
markers.clear();
};
}, [map, radarAircraftRef, groundTruthRef, smoothRef, NODE, TRUTH]);

Check warning on line 326 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useEffect has a missing dependency: 'nodesByIdRef'. Either include it or remove the dependency array

return null;
});
Expand Down Expand Up @@ -1552,7 +1552,7 @@
return Object.entries(trailsRef.current).filter(
([hex, positions]) => hex === selectedHex && positions.some((p) => isPointInViewport(p[0], p[1], viewport)),
);
}, [selectedHex, trailTick, viewport]);

Check warning on line 1555 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useMemo has a missing dependency: 'trailsRef'. Either include it or remove the dependency array

const selectedTrailPositions = useMemo(() => {
if (!selectedHex) return [];
Expand Down Expand Up @@ -1760,7 +1760,7 @@
const csv = trailToCsv(ac.hex, ac.flight, rows);
downloadCsv(`trail-${ac.hex}-${Date.now()}.csv`, csv);
toast(`Exported ${rows.length} points`, { tone: "success" });
}, [selectedHex, radarAircraft, trailsRef]);

Check warning on line 1763 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useCallback has a missing dependency: 'groundTruthRef'. Either include it or remove the dependency array

const exportAllTrails = useCallback(() => {
const csv = trailsToBulkCsv(radarAircraft || [], trailsRef.current || {});
Expand Down Expand Up @@ -1825,7 +1825,7 @@
p: () => { if (selectedHex) { togglePinned(selectedHex); toast(pinnedSet.has(selectedHex) ? "Unpinned" : "Pinned"); } },
m: () => locateMe(),
n: () => { setSoundOn((v) => { toast(v ? "Sound off" : "Sound on"); return !v; }); },
}), [showShortcutHelp, searchQuery, exportSelectedTrail, exportAllTrails, locateMe, selectedHex, togglePinned, pinnedSet, setSoundOn, setShowLabels, setShowTrails, setShowCoverage, setShowIlluminators, setShowGroundTruth, setColorByAlt, setShowStats, setShowRangeRings, setShowArcs]);

Check warning on line 1828 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useMemo has a missing dependency: 'handleTogglePause'. Either include it or remove the dependency array
useKeyboardShortcuts(shortcutMap);

function computeError(hex, ac) {
Expand Down
36 changes: 18 additions & 18 deletions frontend/src/components/PhysicsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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. */}
<div className="ps-settings-card ps-live-card" style={{ "--accent": SIM_DARK }}>
<div className="ps-settings-card ps-live-card" style={{ "--accent": TRUTH_LIVE_DARK }}>
<div className="ps-settings-label">
Live ADS-B traffic
<span className="ps-settings-sublabel"> (real aircraft from adsb.retina.fm, echoed by the synthetic nodes)</span>
Expand All @@ -708,8 +709,10 @@ export default function PhysicsSettings() {
}}
/>
<span className="ps-toggle-text">Pull live aircraft into the simulation</span>
<span className="ps-type-badge" style={{ background: SIM_DARK + "22", color: SIM_DARK }}>
{counts.live ?? 0} live · {counts.live_dark ?? 0} dark
<span className="ps-type-badge" style={{ background: TRUTH_LIVE_DARK + "22", color: TRUTH_LIVE }}>
<span style={{ color: TRUTH_LIVE }}>● {counts.live_adsb ?? 0} ADS-B</span>
{" · "}
<span style={{ color: TRUTH_LIVE_DARK }}>● {counts.live_dark ?? 0} dark</span>
</span>
</label>
<div className="ps-settings-label ps-live-sublabel">
Expand All @@ -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)}%`,
}}
/>
<span className="ps-pct-val" style={{ color: SIM_DARK }}>{pct(draft.frac_live_dark)}%</span>
<span className="ps-pct-val" style={{ color: TRUTH_LIVE_DARK }}>{pct(draft.frac_live_dark)}%</span>
</div>
<p className="ps-type-desc ps-settings-desc">
Independent of the synthetic mix above: the dark slider and the objects target only govern
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
}}
>
<Tooltip>
Expand All @@ -945,11 +945,11 @@ export default function PhysicsSettings() {
</MapContainer>
</div>
<div className="ps-gt-legend">
<span style={{ color: SIM_COMMERCIAL }}>● Commercial</span>
<span style={{ color: SIM_DARK }}>● Dark</span>
{truthLegend(palette).map((t) => (
<span key={t.cls} style={{ color: t.color }}>● {t.label.replace("Truth: ", "")}</span>
))}
<span style={{ color: SIM_DRONE }}>● Drone</span>
<span style={{ color: SIM_ANOMALOUS }}>● Anomalous</span>
<span>◌ dashed = live feed</span>
</div>
</div>
);
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/map/AircraftDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,12 @@ export default function AircraftDetailPanel({ ac, onClose, groundTruth, trails,
{isTruthOnly && (
<div className="detail-section">
<div className="detail-section-title">Simulation (debug)</div>
{ac.source && (
<Field
label="Source"
value={ac.source === "live" ? "Live ADS-B feed (real aircraft, echoed by synthetic nodes)" : "Simulated spawn"}
/>
)}
<Field
label="ADS-B"
value={
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/components/map/AircraftListPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { PLANE_PATH, getAircraftColor } from "./icons";
import { POSITION_SOURCE_ARC_ONLY, POSITION_SOURCE_ADSB_SINGLE } from "./constants";
import { classifyHex } from "./hexInfo";
import { usePalette } from "./useMapTheme";
import { truthClass, truthFill } from "./truthColor";
import { distanceKm } from "./distance";
import { M_PER_FT } from "./units";

Expand All @@ -27,7 +28,8 @@ export default function AircraftListPanel({
onTogglePin,
userLoc,
}) {
const { INK_SUBTLE, NODE, TRUTH } = usePalette();
const palette = usePalette();
const { INK_SUBTLE, NODE } = palette;
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
const [containerHeight, setContainerHeight] = useState(600);
Expand Down Expand Up @@ -190,7 +192,8 @@ export default function AircraftListPanel({
<div style={{ position: "absolute", top: offsetY, left: 0, right: 0 }}>
{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
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/components/map/MapLegend.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { altitudeLegend } from "./icons";
import { truthLegend } from "./truthColor";
import { usePalette } from "./useMapTheme";
import { usePersistedState } from "./usePersistedState";

Expand All @@ -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 (
Expand All @@ -38,7 +40,12 @@ export default function MapLegend({ colorByAlt, showGroundTruth, showIlluminator
one. */}
<LegendItem color={LANE_MN_ADSB} label="MLAT + ADS-B" />
<LegendItem color={LANE_MN_DARK} label="MLAT dark" />
{showGroundTruth && <LegendItem color={TRUTH} label="Ground truth" />}
{/* 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) => (
<LegendItem key={t.cls} color={t.color} label={t.label} />
))}
</>
)}
<LegendItem color={NODE} label="Node" />
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/map/groundTruthFixes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/map/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/components/map/mapPalette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
41 changes: 41 additions & 0 deletions frontend/src/components/map/truthColor.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
Loading
Loading