diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index 427734cdc..ff96385ad 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -41,6 +41,7 @@ import { import { firstPlottablePairForPost, layoutLeftoverMapPlot, + leftoverMapPlotCriterionBadge, LEFTOVER_MAP_COMPARE_PLOT_CAPTION, LEFTOVER_MAP_COMPARE_PLOT_LABEL, LEFTOVER_MAP_COMPARE_PLOT_SVG, @@ -128,11 +129,27 @@ function leftoverMapPlotAxisText( }); } +function leftoverMapPlotCriterionText( + marker: { label: string; axis1: number; axis2: number }, + variant: LeftoverMapPlotVariant, +): string { + if (variant === "comparison") { + return `${t("Criterion ζ")} ${marker.label}`; + } + const badge = leftoverMapPlotCriterionBadge(marker.label, marker.axis1, marker.axis2); + if (badge === null) { + return `${t("Criterion ζ")} ${marker.label}`; + } + return `${t("Criterion ζ")} ${badge.values.label} · ζ ${badge.values.item}`; +} + /** * Gabriel leftover-map graphic display of persisted ``ξ_{1:2}`` / ``ζ_{1:2}``. * - * Person markers are posts; item markers are leftover criteria. Click a - * post marker to open that post. Caption leftover-map axes with persisted + * Person markers are posts; item markers are leftover criteria. Report criterion + * markers name persisted item coordinates by composing the already-localized + * criterion label with persisted ζ; comparison criterion naming remains generic. + * Click a post marker to open that post. Caption leftover-map axes with persisted * Gabriel inertia share when finite, including rank-0 zero-share axes. * Report and comparison graphic axes additionally name finite, non-negative * persisted Gabriel singular values independently of axis share; never derive @@ -576,7 +593,7 @@ export function LeftoverMapPlot({ ))} {layout.items.map((marker) => ( - + {marker.label} diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index 02f5ba9ba..da6ae9b32 100644 --- a/frontend/src/leftoverMapPlotLayout.ts +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -30,6 +30,27 @@ export const LEFTOVER_MAP_COMPARE_PLOT_SVG = "Leftover map comparison"; export const LEFTOVER_MAP_PLOT_POST_ACTION = "Open leftover-map post {title} at ξ {person}"; +export const LEFTOVER_MAP_PLOT_CRITERION = + "leftover-map criterion {label} at ζ {item}"; + +export type LeftoverMapPlotCriterionBadge = { + key: string; + values: { label: string; item: string }; +}; + +/** Builds report-graphic criterion copy only from the persisted item-axis pair. */ +export function leftoverMapPlotCriterionBadge( + label: string, + axis1: number | null | undefined, + axis2: number | null | undefined, +): LeftoverMapPlotCriterionBadge | null { + const item = formatLeftoverMapCoordinatePair(axis1, axis2); + if (item === null) { + return null; + } + return { key: LEFTOVER_MAP_PLOT_CRITERION, values: { label, item } }; +} + export const LEFTOVER_MAP_PLOT_TICK = "leftover-map axis {axis} tick {value}"; diff --git a/tests/test_leftover_map_report_criterion_item_coordinate_contract.py b/tests/test_leftover_map_report_criterion_item_coordinate_contract.py new file mode 100644 index 000000000..68ee4bf41 --- /dev/null +++ b/tests/test_leftover_map_report_criterion_item_coordinate_contract.py @@ -0,0 +1,43 @@ +"""Executable contract for report-graphic criterion item-coordinate captions.""" + +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[1] +LAYOUT_SOURCE = ROOT / "frontend" / "src" / "leftoverMapPlotLayout.ts" +PLOT_SOURCE = ROOT / "frontend" / "src" / "components" / "LeftoverMapPlot.tsx" + + +def test_report_graphic_criterion_names_persisted_item_coordinates() -> None: + """Report criterion markers use distinct ζ copy while comparison markers retain generic copy.""" + layout_source = LAYOUT_SOURCE.read_text(encoding="utf-8") + plot_source = PLOT_SOURCE.read_text(encoding="utf-8") + + assert 'LEFTOVER_MAP_PLOT_CRITERION =\n "leftover-map criterion {label} at ζ {item}";' in layout_source + assert "leftoverMapPlotCriterionBadge" in layout_source + assert "leftoverMapPlotCriterionBadge" in plot_source + assert re.search( + r'variant\s*===\s*"comparison".*?Criterion ζ.*?leftoverMapPlotCriterionBadge', + plot_source, + re.DOTALL, + ) + + +def test_criterion_badge_consumes_item_axes_fail_closed_without_person_inference() -> None: + """ζ comes only from a finite persisted item coordinate pair; zero remains representable.""" + layout_source = LAYOUT_SOURCE.read_text(encoding="utf-8") + helper = re.search( + r"export function leftoverMapPlotCriterionBadge\(.*?\n}\n", + layout_source, + re.DOTALL, + ) + assert helper is not None + helper_source = helper.group(0) + + assert "formatLeftoverMapCoordinatePair(axis1, axis2)" in helper_source + assert "item === null" in helper_source + assert "return null" in helper_source + assert "leftover_map_person_axis" not in helper_source + assert "Math.sqrt" not in helper_source + assert "Math.abs" not in helper_source