fix(charts): round y ticks, calendar x ticks and labels beside the plot

The value chart's axes and labels, as drawn on a 90-day blood pressure
view on a tablet:

- Y ticks were whatever Recharts split the padded domain into, so the
  labels read 57, 83, 109. The domain is now widened to a 1/2/5 step
  and every tick is handed to the axis, evenly spaced and all drawn.
- X ticks were every n-th point, so their dates drifted with the data.
  They now fall on a calendar grid (days, Mondays, two weeks, months,
  quarters, years), each on the point nearest the grid line, and the
  step is the smallest whose labels fit the measured plot width.
- The "your average" label sat inside the plot over the readings, and a
  target zone's label was clipped to its first letter. Both now sit
  beside the plot, in a right margin sized to the longest label.

A browser test draws the axes with Recharts and pins the tick values,
the Monday grid, and both labels outside the plot clear of every dot.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 19:53:28 +02:00
1 parent d5ca594bd7
commit fd34ee13b9
13 files changed
+778 -159

No files matched your search

@@ -0,0 +1,185 @@
import { CartesianGrid, ComposedChart, Line, XAxis, YAxis } from "recharts";
import { describe, expect, it } from "vitest";
import { render } from "vitest-browser-react";
import { computeTickPositions } from "@/lib/charts/x-axis-density";
import { computePaddedYDomain, niceYAxis } from "@/lib/insights/chart-y-domain";
import {
baselineLayers,
rightLabelRoom,
targetZoneLayers,
} from "../health-chart/chart-layers";
/**
* The value chart's axes and its baseline label, drawn by Recharts with the
* props HealthChart hands it: the y ticks are round and evenly spaced and all
* of them drawn, the x ticks fall on the calendar grid, and the "your
* average" and target zone labels sit whole beside the plot, covering no
* reading.
*/
const DAY = 86_400_000;
const START = Date.parse("2026-07-23T12:00:00.000Z");
const MISSING = new Set([10, 11, 12, 40, 41, 70, 71, 72, 73, 80]);
const POINTS = Array.from({ length: 90 }, (_, day) => day)
.filter((day) => !MISSING.has(day))
.map((day, pointIndex) => ({
pointIndex,
timestamp: START + day * DAY,
BLOOD_PRESSURE_SYS: 118 + 14 * Math.sin(day / 5) + (day % 7),
}));
const t = (key: string) =>
key === "charts.personalBaseline" ? "Your average" : key;
const label = "Your average";
const zoneLabel = "Systolic target";
function Chart() {
const padded = computePaddedYDomain(
POINTS.map((p) => p.BLOOD_PRESSURE_SYS),
["BLOOD_PRESSURE_SYS"],
)!;
const y = niceYAxis(padded);
return (
<ComposedChart
width={720}
height={260}
data={POINTS}
margin={{
top: 10,
right: 8 + rightLabelRoom([label, zoneLabel]),
bottom: 8,
left: 8,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
type="number"
dataKey="pointIndex"
domain={[0, POINTS.length - 1]}
tick={{ fontSize: 11 }}
ticks={computeTickPositions(POINTS, 1440)}
tickFormatter={(value: number) =>
new Date(POINTS[Math.round(value)]!.timestamp)
.toISOString()
.slice(5, 10)
}
padding={{ left: 10, right: 10 }}
/>
<YAxis
tick={{ fontSize: 11 }}
domain={y.domain}
ticks={y.ticks}
width={44}
/>
<Line dataKey="BLOOD_PRESSURE_SYS" isAnimationActive={false} dot />
{targetZoneLayers(
[
{
key: "sys",
low: 110,
high: 130,
color: "#c44",
opacity: 0.1,
lineOpacity: 0.5,
topPct: 0,
heightPct: 0,
label: zoneLabel,
textColor: undefined,
},
],
POINTS.length - 1,
)}
{baselineLayers(
true,
["BLOOD_PRESSURE_SYS"],
["#888"],
new Map([["BLOOD_PRESSURE_SYS", 121]]),
t,
)}
</ComposedChart>
);
}
async function settle() {
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
}
function tickTexts(root: HTMLElement, axis: "xAxis" | "yAxis"): string[] {
return [
...root.querySelectorAll(
`.recharts-${axis}-tick-labels .recharts-cartesian-axis-tick-value`,
),
].map((node) => node.textContent ?? "");
}
describe("value chart axes", () => {
it("draws every round y tick, evenly spaced", async () => {
const screen = await render(<Chart />);
await settle();
const values = tickTexts(screen.container, "yAxis").map(Number);
expect(values).toEqual([100, 110, 120, 130, 140, 150]);
});
it("puts the x ticks on the two-week grid of Mondays", async () => {
const screen = await render(<Chart />);
await settle();
expect(tickTexts(screen.container, "xAxis")).toEqual([
"08-01",
"08-17",
"08-31",
"09-14",
"09-28",
"10-12",
]);
});
it("draws a target zone's label whole, beside the plot", async () => {
const screen = await render(<Chart />);
await settle();
const root = screen.container;
const plot = root
.querySelector(".recharts-cartesian-grid")!
.getBoundingClientRect();
const svg = root
.querySelector("svg.recharts-surface")!
.getBoundingClientRect();
const text = [...root.querySelectorAll(".recharts-label")].find(
(node) => node.textContent === zoneLabel,
)!;
expect(text).toBeDefined();
const box = text.getBoundingClientRect();
expect(box.left).toBeGreaterThanOrEqual(plot.right);
expect(box.right).toBeLessThanOrEqual(svg.right);
});
it("draws the average's label outside the plot, clear of every reading", async () => {
const screen = await render(<Chart />);
await settle();
const root = screen.container;
const plot = root
.querySelector(".recharts-cartesian-grid")!
.getBoundingClientRect();
const text = [...root.querySelectorAll(".recharts-label")].find(
(node) => node.textContent === label,
)!;
expect(text).toBeDefined();
const box = text.getBoundingClientRect();
const svg = root
.querySelector("svg.recharts-surface")!
.getBoundingClientRect();
expect(box.left).toBeGreaterThanOrEqual(plot.right);
expect(box.right).toBeLessThanOrEqual(svg.right);
for (const dot of root.querySelectorAll(".recharts-line-dot")) {
const d = dot.getBoundingClientRect();
const overlaps =
d.left < box.right &&
d.right > box.left &&
d.top < box.bottom &&
d.bottom > box.top;
expect(overlaps).toBe(false);
}
});
});
+55 -71
View File
@@ -16,7 +16,6 @@ import {
ChartDayFooter,
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { isWholeNumberType } from "@/components/day/use-day-value-format";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
@@ -25,7 +24,6 @@ import { useViewportWidth } from "@/hooks/use-viewport-width";
import { axisTickUnitSuffix } from "@/lib/charts/axis-unit";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { computeTickPositions } from "@/lib/charts/x-axis-density";
import {
useDateFormatPreference,
useFormatters,
@@ -50,12 +48,16 @@ import {
} from "./health-chart/chart-layers";
import { makeTooltipContent } from "./health-chart/chart-tooltip-content";
import {
axisValueFormatters,
dayFocusFor,
getTypeLabel,
makeDayKeyFormatter,
pointIndexXAxis,
resolveAnnotationPositions,
resolveVerticalMarkerPositions,
xTickLayout,
} from "./health-chart/series-math";
import type { ChartDataPoint, HealthChartProps } from "./health-chart/types";
import type { HealthChartProps } from "./health-chart/types";
import {
useHealthChartLabels,
useHealthChartOverlays,
@@ -194,6 +196,7 @@ export function HealthChart({
activeBucket,
bucketCaption,
yDomain,
yTicks,
} = useHealthChartView({
data,
rangePoints,
@@ -209,29 +212,25 @@ export function HealthChart({
onVisibleStats,
});
const overlays = useHealthChartOverlays({
showBands,
showTrend,
valueBands,
targetZones,
yDomain,
chartData,
types,
unit,
valueMode,
t,
fmt,
});
const { visibleBands, visibleTargetZones, trendInfo, personalBaselines } =
useHealthChartOverlays({
showBands,
showTrend,
valueBands,
targetZones,
yDomain,
chartData,
types,
unit,
valueMode,
t,
fmt,
});
overlays;
// One decimal on the axis while the visible domain spans under 6 units, so
// two gridlines never round to the same integer (82.8 vs 83.5 kg).
const yDomainSpan = yDomain ? yDomain[1] - yDomain[0] : null;
const useDecimalAxis = yDomainSpan !== null && yDomainSpan < 6;
const formatAxisValue = (value: number) =>
useDecimalAxis ? fmt.number(value, 1) : fmt.integer(value);
const formatTooltipValue = (value: number) => fmt.number(value, 1);
const [containerWidth, setContainerWidth] = useState<number | null>(null);
const { useDecimalAxis, formatAxisValue, formatTooltipValue } =
axisValueFormatters(yDomain, fmt);
const { primaryType, chartAriaLabel, dataTableColumns } =
useHealthChartLabels({
@@ -262,36 +261,21 @@ export function HealthChart({
// The days as doors: daily points only (a week or month point averages
// many days and has no one day to open).
const dayLinksActive = dayLinks && !mini && activeBucket === "day";
// What the day shows at its top when opened from a point: the chart's name
// and the point's value as the tooltip reads it.
const dayFocusFor = (point: ChartDataPoint | undefined) => {
if (!point) return null;
const parts = types
.map((type) => ({ type, v: point[type] }))
.filter((p): p is { type: string; v: number } => typeof p.v === "number");
if (parts.length === 0) return null;
return {
label: title,
// A pressure or a pulse reads in whole numbers on the day, as in every
// list; the rest keep the tooltip's one decimal.
value: parts
.map(({ type, v }) =>
valueMode === "raw" && isWholeNumberType(type)
? fmt.number(Math.round(v), 0)
: formatTooltipValue(v),
)
.join("/"),
unit,
types: valueMode === "raw" ? types : [],
};
};
const dayLinkSource = chartDataWithCompare ?? chartData;
const chartDays = useChartDayLinks({
enabled: dayLinksActive,
days: dayLinksActive
? (dayLinkSource ?? []).map((point) => chartPointDayKey(point.timestamp))
: [],
focusFor: (index) => dayFocusFor(dayLinkSource?.[index]),
focusFor: (index) =>
dayFocusFor(dayLinkSource?.[index], {
types,
title,
unit,
valueMode,
fmt,
formatTooltipValue,
}),
});
const openDayIndex = chartDays.openIndex;
const openDayPoint =
@@ -405,10 +389,19 @@ export function HealthChart({
role="img"
aria-label={chartAriaLabel}
>
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer
width="100%"
height="100%"
onResize={setContainerWidth}
>
<ComposedChart
data={chartDataWithCompare ?? chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
margin={{
top: 10,
right: 8 + overlays.rightRoom,
bottom: 8,
left: 8,
}}
accessibilityLayer
onClick={chartDays.onChartClick}
>
@@ -418,26 +411,16 @@ export function HealthChart({
opacity={0.5}
/>
<XAxis
type="number"
dataKey="pointIndex"
domain={[0, maxPointIndex]}
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(value) =>
tzFmt.date(
new Date(
chartData?.[Math.round(value)]?.timestamp ??
Date.now(),
),
)
}
// Recharts ignores `interval` on a numeric axis, so the
// day-aware density arrives as explicit tick positions.
ticks={computeTickPositions(chartData ?? [], viewportWidth)}
padding={{ left: 10, right: 10 }}
tickMargin={10}
{...pointIndexXAxis({
chartData,
formatDate: tzFmt.date,
viewportWidth,
layout: xTickLayout(
containerWidth,
yAxisWidth,
overlays.rightRoom,
),
})}
/>
<YAxis
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
@@ -446,6 +429,7 @@ export function HealthChart({
width={yAxisWidth}
tickMargin={10}
domain={yDomain}
ticks={yTicks}
tickFormatter={(value) =>
typeof value === "number"
? formatAxisValue(
@@ -537,7 +521,7 @@ export function HealthChart({
// The plot's margin (8) + the y axis + the x axis padding (10)
// on the left, margin + padding on the right.
insetLeft={8 + yAxisWidth + 10}
insetRight={8 + 10}
insetRight={8 + overlays.rightRoom + 10}
/>
) : null}
{/* The points the chart just drew, as a table, read from the same
@@ -66,8 +66,9 @@ export function targetZoneLayers(
? {
value: zone.label,
position: "right",
offset: BASELINE_LABEL_GAP,
fill: zone.textColor ?? zone.color,
fontSize: 10,
fontSize: BASELINE_LABEL_FONT_SIZE,
}
: undefined
}
@@ -186,10 +187,51 @@ export function openDayLayers(
];
}
/** The gap between the plot's right edge and the baseline label. */
const BASELINE_LABEL_GAP = 6;
/** The label's type size, and a width per character that over-reads it. */
const BASELINE_LABEL_FONT_SIZE = 10;
const BASELINE_LABEL_CHAR_WIDTH = 6;
/**
* The baseline label's text when the chart draws one: the trend overlay is
* on and the first type has a baseline inside the visible domain. Null
* otherwise, and then the chart keeps no room for it.
*/
export function baselineLabelText(
showTrend: boolean,
types: string[],
personalBaselines: Map<string, number>,
yDomain: [number, number] | undefined,
t: Translate,
): string | null {
if (!showTrend || types.length === 0) return null;
const baseline = personalBaselines.get(types[0]!);
if (baseline == null) return null;
if (yDomain && (baseline < yDomain[0] || baseline > yDomain[1])) return null;
return t("charts.personalBaseline");
}
/**
* The right margin the labels drawn beside the plot need: the average's label
* and the target zones' labels sit outside the plot, past the lines' right
* ends, so they never cover a point or a line. With the margin at 8 px a
* zone's label was cut to its first letter, and the average's label, inside
* the plot at the top left, sat on the first readings.
*/
export function rightLabelRoom(
labels: ReadonlyArray<string | null | undefined>,
): number {
const longest = Math.max(0, ...labels.map((label) => label?.length ?? 0));
if (longest === 0) return 0;
return BASELINE_LABEL_GAP + Math.ceil(longest * BASELINE_LABEL_CHAR_WIDTH);
}
/**
* The personal baseline per type, only while the trend overlay is shown (an
* always-on mean line was rejected). Only the first type carries the label,
* so a multi-type chart does not paint it twice.
* so a multi-type chart does not paint it twice; it is drawn to the right of
* the plot, in the room `rightLabelRoom` keeps there.
*/
export function baselineLayers(
showTrend: boolean,
@@ -216,9 +258,10 @@ export function baselineLayers(
i === 0
? {
value: t("charts.personalBaseline"),
position: "insideTopLeft",
position: "right",
offset: BASELINE_LABEL_GAP,
fill: "var(--muted-foreground)",
fontSize: 10,
fontSize: BASELINE_LABEL_FONT_SIZE,
opacity: 0.7,
}
: undefined
@@ -4,8 +4,16 @@
* land. No React and no Recharts, so each piece has direct unit coverage.
*/
import { chartPointDayKey } from "@/components/day/chart-day";
import { isWholeNumberType } from "@/components/day/use-day-value-format";
import { translateOrNull } from "@/lib/i18n/translate-code";
import {
computeTickPositions,
type TickLayout,
} from "@/lib/charts/x-axis-density";
import type { ChartDataPoint } from "./types";
const BASE_TYPE_LABEL_KEYS: Record<string, string> = {
WEIGHT: "charts.weight",
BLOOD_PRESSURE_SYS: "charts.systolic",
@@ -219,3 +227,114 @@ export function resolveAnnotationPositions(
}
return out;
}
/**
* The value axis's and the tooltip's number formats. One decimal on the axis
* while the visible domain spans under 6 units, so two gridlines never round
* to the same integer (82.8 vs 83.5 kg).
*/
export function axisValueFormatters(
yDomain: [number, number] | undefined,
fmt: {
number: (value: number, digits: number) => string;
integer: (value: number) => string;
},
) {
const span = yDomain ? yDomain[1] - yDomain[0] : null;
const useDecimalAxis = span !== null && span < 6;
return {
useDecimalAxis,
formatAxisValue: (value: number) =>
useDecimalAxis ? fmt.number(value, 1) : fmt.integer(value),
formatTooltipValue: (value: number) => fmt.number(value, 1),
};
}
/**
* What the day shows at its top when opened from a point: the chart's name
* and the point's value as the tooltip reads it. A pressure or a pulse reads
* in whole numbers on the day, as in every list; the rest keep the tooltip's
* one decimal.
*/
export function dayFocusFor(
point: ChartDataPoint | undefined,
ctx: {
types: string[];
title: string;
unit?: string;
valueMode: "raw" | "bmi";
fmt: { number: (value: number, digits: number) => string };
formatTooltipValue: (value: number) => string;
},
) {
if (!point) return null;
const parts = ctx.types
.map((type) => ({ type, v: point[type] }))
.filter((p): p is { type: string; v: number } => typeof p.v === "number");
if (parts.length === 0) return null;
return {
label: ctx.title,
value: parts
.map(({ type, v }) =>
ctx.valueMode === "raw" && isWholeNumberType(type)
? ctx.fmt.number(Math.round(v), 0)
: ctx.formatTooltipValue(v),
)
.join("/"),
unit: ctx.unit,
types: ctx.valueMode === "raw" ? ctx.types : [],
};
}
/**
* Where the x labels are drawn: the plot's width (the container less the
* chart margins, the y axis, the right-hand label room and the axis padding)
* and the width a date label takes, with room between two. Null until the
* container has been measured; the ticks then fall back to the count cap.
*/
export function xTickLayout(
containerWidth: number | null,
yAxisWidth: number,
rightRoom: number,
): { plotWidth: number; labelWidth: number } | undefined {
if (!containerWidth) return undefined;
const plotWidth = containerWidth - 16 - yAxisWidth - rightRoom - 20;
if (plotWidth <= 0) return undefined;
// A day-month-year date at the axis's 11 px, with a gap of its own size
// between two labels so the row reads as separate dates.
return { plotWidth, labelWidth: 64 + 16 };
}
/**
* The x axis's props: one point per index, labelled with its date. Recharts
* ignores `interval` on a numeric axis, so the calendar grid arrives as
* explicit tick positions (`computeTickPositions`).
*/
export function pointIndexXAxis({
chartData,
formatDate,
viewportWidth,
layout,
}: {
chartData: ChartDataPoint[] | undefined;
formatDate: (date: Date) => string;
viewportWidth: number;
layout: TickLayout | undefined;
}) {
return {
type: "number" as const,
dataKey: "pointIndex",
domain: [0, Math.max(0, (chartData?.length ?? 1) - 1)],
allowDecimals: false,
tick: { fontSize: 11, fill: "var(--muted-foreground)" },
tickLine: false,
axisLine: false,
tickFormatter: (value: number) =>
formatDate(
new Date(chartData?.[Math.round(value)]?.timestamp ?? Date.now()),
),
ticks: computeTickPositions(chartData ?? [], viewportWidth, layout),
padding: { left: 10, right: 10 },
tickMargin: 10,
};
}
@@ -19,6 +19,7 @@ import type {
VisibleTargetZone,
VisibleValueBand,
} from "./types";
import { baselineLabelText, rightLabelRoom } from "./chart-layers";
type Translate = (
key: string,
@@ -210,7 +211,26 @@ export function useHealthChartOverlays({
return map;
}, [chartData, types]);
return { visibleBands, visibleTargetZones, trendInfo, personalBaselines };
// The labels drawn beside the plot (the average's, the target zones'), and
// so the room the plot keeps for them on its right.
const baselineLabel = baselineLabelText(
showTrend,
types,
personalBaselines,
yDomain,
t,
);
return {
visibleBands,
visibleTargetZones,
trendInfo,
personalBaselines,
rightRoom: rightLabelRoom([
baselineLabel,
...visibleTargetZones.map((zone) => zone.label),
]),
};
}
/**
@@ -15,7 +15,7 @@ import {
type MetricWindowStats,
} from "@/lib/charts/window-stats";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
import { computePaddedYDomain, niceYAxis } from "@/lib/insights/chart-y-domain";
import { trendLineByTime } from "@/lib/stats/linear-fit";
import { movingAverageByPoints } from "./series-math";
@@ -308,7 +308,9 @@ export function useHealthChartView({
// in the tooltip.
const bucketCaption = bucketCaptionKey(activeBucket, types);
const yDomain = useMemo<[number, number] | undefined>(() => {
const yAxis = useMemo<
{ domain: [number, number]; ticks: number[] } | undefined
>(() => {
if (!chartDataWithCompare?.length) return undefined;
const keys = [...types];
@@ -325,7 +327,8 @@ export function useHealthChartView({
.filter((value): value is number => typeof value === "number")
.filter((value) => Number.isFinite(value));
return computePaddedYDomain(values, types);
const padded = computePaddedYDomain(values, types);
return padded ? niceYAxis(padded) : undefined;
}, [
chartDataWithCompare,
effectiveCompareBaseline,
@@ -342,6 +345,7 @@ export function useHealthChartView({
hasComparisonData,
activeBucket,
bucketCaption,
yDomain,
yDomain: yAxis?.domain,
yTicks: yAxis?.ticks,
};
}