diff --git a/src/components/insights/coach-panel/__tests__/result-chart-axis.test.tsx b/src/components/insights/coach-panel/__tests__/result-chart-axis.test.tsx new file mode 100644 index 000000000..d48998861 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/result-chart-axis.test.tsx @@ -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>); + +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) => { + 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( + + + , + ); + 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]); + }); +}); diff --git a/src/components/insights/coach-panel/result-chart.tsx b/src/components/insights/coach-panel/result-chart.tsx index c10f820f4..35cfa9d26 100644 --- a/src/components/insights/coach-panel/result-chart.tsx +++ b/src/components/insights/coach-panel/result-chart.tsx @@ -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"], + })} /> diff --git a/src/lib/charts/__tests__/nice-axis.test.ts b/src/lib/charts/__tests__/nice-axis.test.ts new file mode 100644 index 000000000..223a6b117 --- /dev/null +++ b/src/lib/charts/__tests__/nice-axis.test.ts @@ -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); + }); +}); diff --git a/src/lib/charts/nice-axis.ts b/src/lib/charts/nice-axis.ts new file mode 100644 index 000000000..1163926da --- /dev/null +++ b/src/lib/charts/nice-axis.ts @@ -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, + 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 }; +}