mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user