fix(coach): step a result chart's value axis evenly

The line and bar charts under a Coach reply let recharts place the y-axis
ticks over an auto domain, and after thinning the ones that would collide
a blood pressure line could read 75, 90, 105, 135. The chart now computes
a nice domain and hands explicit ticks one step apart (1, 2, 2.5 or 5
times a power of ten); bars keep their zero baseline.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 12:51:43 +02:00
1 parent e7f3385374
commit 74f4c6e767
4 files changed
+276 -1

No files matched your search

@@ -0,0 +1,111 @@
/**
* The value axis of a Coach result chart steps evenly: the chart hands
* recharts explicit ticks on an explicit domain instead of letting it place
* (and thin) them, which left labels like 75, 90, 105, 135 on a blood
* pressure line. Recharts is replaced by pass-through parts so the props the
* chart gives its y axis can be read without a browser layout.
*/
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const yAxisProps = vi.hoisted(() => [] as Array<Record<string, unknown>>);
vi.mock("recharts", () => {
const Pass = ({ children }: { children?: ReactNode }) => <>{children}</>;
const Leaf = () => null;
return {
ResponsiveContainer: Pass,
LineChart: Pass,
BarChart: Pass,
CartesianGrid: Leaf,
Line: Leaf,
Bar: Leaf,
Tooltip: Leaf,
XAxis: Leaf,
YAxis: (props: Record<string, unknown>) => {
yAxisProps.push(props);
return null;
},
};
});
import { I18nProvider } from "@/lib/i18n/context";
import type { CoachResultTable } from "@/lib/ai/coach/types";
import { CoachResultChart } from "../result-chart";
function bpTable(rows: CoachResultTable["rows"]): CoachResultTable {
return {
ref: "r1",
source: {
tool: "get_metric_table",
domain: "bp",
window: "last30days",
period: "current",
granularity: "day",
},
shape: "timeSeries",
titleKey: "coach.result.title.byDay",
title: "Blood pressure by day",
rowCount: rows.length,
chartKind: "line",
displayed: true,
columns: [
{ key: "day", kind: "period", labelKey: "k", label: "Day" },
{ key: "systolic", kind: "number", labelKey: "k", label: "Systolic" },
{ key: "diastolic", kind: "number", labelKey: "k", label: "Diastolic" },
],
rows,
truncated: false,
chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] },
};
}
function renderChart(result: CoachResultTable) {
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<CoachResultChart result={result} otherLabel="Other" label="Chart" />
</I18nProvider>,
);
return yAxisProps.at(-1)!;
}
beforeEach(() => {
yAxisProps.length = 0;
});
describe("CoachResultChart value axis", () => {
it("gives a blood pressure line evenly spaced ticks on its domain", () => {
const props = renderChart(
bpTable([
["2026-09-01", 134, 88],
["2026-09-02", null, null],
["2026-09-03", 121, 78],
]),
);
const ticks = props.ticks as number[];
expect(ticks).toEqual([60, 80, 100, 120, 140]);
expect(props.domain).toEqual([60, 140]);
// Every tick is drawn; none is thinned into an uneven step.
expect(props.interval).toBe(0);
});
it("starts vertical bars at zero", () => {
const props = renderChart({
...bpTable([
["2026-09-01", 3200, null],
["2026-09-02", 11050, null],
]),
chart: {
kind: "bar",
x: "day",
series: ["systolic"],
orientation: "vertical",
},
chartKind: "bar",
});
expect(props.domain).toEqual([0, 15000]);
expect(props.ticks).toEqual([0, 5000, 10000, 15000]);
});
});
@@ -37,6 +37,7 @@ import type {
CoachResultTable,
} from "@/lib/ai/coach/types";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { niceAxis } from "@/lib/charts/nice-axis";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -219,6 +220,13 @@ function SeriesChart({
const colorOf = (key: string) =>
SERIES_COLORS[spec.series.indexOf(key) % SERIES_COLORS.length];
const visible = spec.series.filter((key) => !hidden.has(key));
// Evenly stepped ticks over what is shown; bars start at the baseline.
const yAxis = niceAxis(
points.flatMap((point) =>
visible.map((key) => point[key] as number | null),
),
{ zero: spec.kind === "bar" },
);
const tooltip = (props: {
active?: boolean;
@@ -263,7 +271,11 @@ function SeriesChart({
axisLine={false}
tickLine={false}
width={40}
domain={spec.kind === "bar" ? [0, "auto"] : ["auto", "auto"]}
{...(yAxis
? { domain: yAxis.domain, ticks: yAxis.ticks, interval: 0 }
: {
domain: spec.kind === "bar" ? [0, "auto"] : ["auto", "auto"],
})}
/>
<Tooltip content={tooltip} cursor={{ fill: "var(--muted)" }} />
</>
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { niceAxis } from "../nice-axis";
/** Every gap between neighbouring ticks, rounded against float noise. */
function steps(ticks: number[]): number[] {
return ticks.slice(1).map((t, i) => Number((t - ticks[i]).toFixed(6)));
}
describe("niceAxis", () => {
it("steps a blood pressure pair evenly on round numbers", () => {
const axis = niceAxis([78, 84, 88, 121, 128, 134]);
expect(axis).toEqual({ domain: [60, 140], ticks: [60, 80, 100, 120, 140] });
});
it("keeps every step equal and the domain on the outer ticks", () => {
for (const values of [
[75, 90, 105, 135],
[61.2, 63.8, 70.1],
[0.4, 0.9, 1.7],
[3120, 8840, 12005],
[-3, 4],
]) {
const axis = niceAxis(values)!;
expect(new Set(steps(axis.ticks)).size).toBe(1);
expect(axis.domain).toEqual([axis.ticks[0], axis.ticks.at(-1)]);
expect(axis.ticks[0]).toBeLessThanOrEqual(Math.min(...values));
expect(axis.ticks.at(-1)).toBeGreaterThanOrEqual(Math.max(...values));
expect(axis.ticks.length).toBeLessThanOrEqual(5);
}
});
it("uses 1, 2, 2.5 or 5 times a power of ten as the step", () => {
for (const values of [
[75, 135],
[61, 64],
[0.1, 0.35],
[100, 9000],
]) {
const step = steps(niceAxis(values)!.ticks)[0];
const fraction = step / 10 ** Math.floor(Math.log10(step));
expect([1, 2, 2.5, 5]).toContain(Number(fraction.toFixed(6)));
}
});
it("starts bars at zero", () => {
expect(niceAxis([3200, 8100, 11050], { zero: true })).toEqual({
domain: [0, 15000],
ticks: [0, 5000, 10000, 15000],
});
});
it("prints decimal ticks without float noise", () => {
expect(niceAxis([0.1, 0.7])!.ticks).toEqual([0, 0.2, 0.4, 0.6, 0.8]);
});
it("gives a single level room around it", () => {
const axis = niceAxis([72, 72])!;
expect(axis.ticks[0]).toBeLessThan(72);
expect(axis.ticks.at(-1)).toBeGreaterThan(72);
expect(new Set(steps(axis.ticks)).size).toBe(1);
});
it("ignores gaps and answers null without a value", () => {
expect(niceAxis([null, undefined, Number.NaN])).toBeNull();
expect(niceAxis([null, 10, 20])!.ticks).toContain(20);
});
});
+84
View File
@@ -0,0 +1,84 @@
/**
* An evenly stepped value axis: a domain that starts and ends on a tick,
* and ticks one "nice" step apart (1, 2, 2.5 or 5 times a power of ten).
*
* Handing recharts a domain of `["auto", "auto"]` and letting it place the
* ticks can leave uneven labels once it thins the ones that would collide
* (75, 90, 105, 135 for a blood pressure line). Explicit ticks on an explicit
* domain keep every step the same.
*
* Pure: no recharts, no React.
*/
export interface NiceAxis {
domain: [number, number];
ticks: number[];
}
/** The nice step for a raw step: 1, 2, 2.5, 5 or 10 times a power of ten. */
function niceStep(raw: number): number {
const exponent = Math.floor(Math.log10(raw));
const magnitude = 10 ** exponent;
const fraction = raw / magnitude;
const nice =
fraction <= 1
? 1
: fraction <= 2
? 2
: fraction <= 2.5
? 2.5
: fraction <= 5
? 5
: 10;
return nice * magnitude;
}
/** Decimal places a step needs, so `0.1 * 3` prints as 0.3. */
function decimalsOf(step: number): number {
if (Number.isInteger(step)) return 0;
const text = step.toString();
const dot = text.indexOf(".");
return dot === -1 ? 0 : text.length - dot - 1;
}
/**
* The axis for `values`, with at most `maxTicks` ticks. `zero` pins the
* lower end at 0 for non-negative data (bars start at the baseline). Null
* when there is no finite value to scale.
*/
export function niceAxis(
values: ReadonlyArray<number | null | undefined>,
options: { maxTicks?: number; zero?: boolean } = {},
): NiceAxis | null {
const finite = values.filter(
(v): v is number => typeof v === "number" && Number.isFinite(v),
);
if (finite.length === 0) return null;
const maxTicks = Math.max(2, options.maxTicks ?? 5);
let min = Math.min(...finite);
let max = Math.max(...finite);
if (options.zero && min >= 0) min = 0;
if (min === max) {
// One level: give it room on both sides (or above the baseline).
const pad = min === 0 ? 1 : Math.abs(min) * 0.1;
max += pad;
if (!(options.zero && min === 0)) min -= pad;
}
let step = niceStep((max - min) / (maxTicks - 1));
let lo = Math.floor(min / step) * step;
let hi = Math.ceil(max / step) * step;
// Rounding both ends out can cost a tick; widen the step until it fits.
while (Math.round((hi - lo) / step) + 1 > maxTicks) {
step = niceStep(step * 1.01);
lo = Math.floor(min / step) * step;
hi = Math.ceil(max / step) * step;
}
const decimals = decimalsOf(step);
const count = Math.round((hi - lo) / step) + 1;
const ticks = Array.from({ length: count }, (_, i) =>
Number((lo + i * step).toFixed(decimals)),
);
return { domain: [ticks[0], ticks[ticks.length - 1]], ticks };
}