From 72d219320031da15bb8dd7d7344e8a9bfe15e2c2 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Thu, 10 Sep 2026 22:45:23 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Ground=20truth:=20four=20colours=20for=20si?= =?UTF-8?q?m/live=20=C3=97=20ADS-B/dark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The truth dots split two ways now that the fleet mirrors live-feed aircraft alongside its own spawns: provenance (simulated vs live) and transponder (ADS-B vs dark). One resolver, map/truthColor, gives each of the four plain- aircraft classes its own fill and rim, keeps anomalous and drone ahead of provenance, and reads an older payload with neither field as simulated ADS-B. The live map's canvas layer, the aircraft list, the legend and the Physics tab's preview map all draw through it, so an object is one colour everywhere. Simulated truth stays the neutral ramp (near-black / near-white, then grey). Live-feed truth is a teal ramp — the one hue family no lane or furniture uses — measured like the rest of the palette: every value clears its theme's contrast floor and sits CIEDE2000 >= 13 from every other mark, >= 22 from the neutral pair and >= 24 from its own partner. light TRUTH_LIVE teal-900 #134e4a TRUTH_LIVE_DARK teal-600 #0d9488 dark TRUTH_LIVE teal-200 #99f6e4 TRUTH_LIVE_DARK teal-600 #0d9488 The map legend lists the four truth rows; the detail panel names the source; the Physics tab's live card takes the teal accents and its badge splits the live count into ADS-B / dark. The dashed-ring hint the preview map used is replaced by the colour. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/LiveAircraftMap.tsx | 30 ++++---- frontend/src/components/PhysicsSettings.tsx | 36 ++++----- .../components/map/AircraftDetailPanel.tsx | 6 ++ .../src/components/map/AircraftListPanel.tsx | 7 +- frontend/src/components/map/MapLegend.tsx | 11 ++- .../src/components/map/groundTruthFixes.ts | 5 +- frontend/src/components/map/index.ts | 1 + frontend/src/components/map/mapPalette.ts | 14 ++++ .../src/components/map/truthColor.test.ts | 41 ++++++++++ frontend/src/components/map/truthColor.ts | 77 +++++++++++++++++++ frontend/src/types.ts | 3 + 11 files changed, 193 insertions(+), 38 deletions(-) create mode 100644 frontend/src/components/map/truthColor.test.ts create mode 100644 frontend/src/components/map/truthColor.ts 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..cb1b0e404 100644 --- a/frontend/src/components/map/mapPalette.ts +++ b/frontend/src/components/map/mapPalette.ts @@ -75,6 +75,16 @@ 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-600 vs the solver-seed lane on light, 13.5), ≥ 22 + * from the neutral truth pair, and ≥ 24 from its own partner. */ + 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 +160,8 @@ const LIGHT: MapPalette = { BEAM_GAP: "#b91c1c", // red-700 TRUTH: "#1e293b", // slate-800 TRUTH_DARK: "#64748b", // slate-500 + TRUTH_LIVE: "#134e4a", // teal-900 — 8.9:1, ΔE 17.0 from the seed lane + TRUTH_LIVE_DARK: "#0d9488", // teal-600 — 3.5:1, ΔE 13.5 from the seed lane MLAT: "#c026d3", // fuchsia-600 MLAT_VECTOR: "#a21caf", // fuchsia-700 @@ -210,6 +222,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() */ From bc34f03d4673a83b5e1e22731f8a969d133185a1 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Thu, 10 Sep 2026 22:49:28 +0000 Subject: [PATCH 2/2] Light theme: live ADS-B truth as teal-800, not teal-900 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured 21.9 from slate-800, but on the map a 9 px teal-900 dot beside a slate-800 one read as two blacks. teal-800 keeps every floor (7.1:1; ΔE 13.1 from the seed lane, 25.5 from TRUTH, 19.3 from its teal-600 partner) and is visibly teal. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/map/mapPalette.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/map/mapPalette.ts b/frontend/src/components/map/mapPalette.ts index cb1b0e404..ddf0c6eb9 100644 --- a/frontend/src/components/map/mapPalette.ts +++ b/frontend/src/components/map/mapPalette.ts @@ -81,8 +81,10 @@ export interface MapPalette { * 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-600 vs the solver-seed lane on light, 13.5), ≥ 22 - * from the neutral truth pair, and ≥ 24 from its own partner. */ + * 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 @@ -160,8 +162,8 @@ const LIGHT: MapPalette = { BEAM_GAP: "#b91c1c", // red-700 TRUTH: "#1e293b", // slate-800 TRUTH_DARK: "#64748b", // slate-500 - TRUTH_LIVE: "#134e4a", // teal-900 — 8.9:1, ΔE 17.0 from the seed lane - TRUTH_LIVE_DARK: "#0d9488", // teal-600 — 3.5:1, ΔE 13.5 from the seed lane + 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