Files
HealthLog/src/lib/insights/chart-y-domain.ts
T
Marc-André Bombeck fd34ee13b9 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.
2026-10-23 19:53:28 +02:00

101 lines
4.1 KiB
TypeScript

/**
* Shared Y-axis domain computation for every chart surface (dashboard
* charts, insights sub-page charts, mini charts). One source of truth so
* the padding rules cannot drift per chart.
*
* Padding rules:
* - Flat series (min === max): pad symmetrically by 5% of the magnitude
* (at least 1 unit), with a little extra headroom on top.
* - Otherwise: pad the bottom by 8% of the span (min 0.5) and the top by
* 16% of the span (min 1).
* - Zero clamp: the lower bound never dips below 0 when the charted metrics
* cannot be negative — padding only extends upward or down to 0. Counts,
* steps, weights etc. must not render a negative axis. Series with genuine
* negative values (deltas, differences) keep the full downward padding.
*
* "Cannot be negative" is answered by `areAllNonNegativeMetrics` off the
* metric's own declared plausibility domain — the SAME fact the personal
* baseline band floors against, so the axis and the sentence printed under
* it can no longer disagree. It also covers the derived overlays: a trend
* line extrapolating a step count below zero now clips at the axis floor
* instead of dragging the whole chart into negative territory. The observed
* minimum stays a second, weaker trigger, so a caller that names no type
* (a mini chart over an anonymous series) keeps the data-derived behaviour.
*/
import { areAllNonNegativeMetrics } from "@/lib/measurements/value-domain";
export function computePaddedYDomain(
values: readonly number[],
types: readonly (string | null | undefined)[] = [],
): [number, number] | undefined {
const finite = values.filter(
(value): value is number =>
typeof value === "number" && Number.isFinite(value),
);
if (!finite.length) return undefined;
const min = Math.min(...finite);
const max = Math.max(...finite);
// Non-negative series never get a negative lower bound.
const nonNegative = min >= 0 || areAllNonNegativeMetrics(types);
const clampLower = (lower: number) =>
nonNegative ? Math.max(0, lower) : lower;
if (min === max) {
const delta = Math.max(Math.abs(min) * 0.05, 1);
return [clampLower(min - delta), max + delta * 1.35];
}
const span = max - min;
const paddingBottom = Math.max(span * 0.08, 0.5);
const paddingTop = Math.max(span * 0.16, 1);
return [clampLower(min - paddingBottom), max + paddingTop];
}
/**
* Evenly spaced round ticks for a value axis, and the domain padded out to
* them.
*
* Handed only a domain, Recharts started its ticks at the domain's lower
* bound and forced the upper bound in as the last tick, so a padded
* blood-pressure domain read 74, 94, 114, 134, 149: round-ish numbers that
* were not round, and a last step shorter than the others. The step here is
* 1, 2 or 5 times a power of ten, the smallest that keeps the axis at
* `maxTicks` labels or fewer, and the domain becomes the multiples of it
* around the padded data, so every gap is the same and every label is
* round. A non-negative domain stays non-negative.
*/
export function niceYAxis(
domain: readonly [number, number],
maxTicks = 6,
): { domain: [number, number]; ticks: number[] } {
const [low, high] = domain;
const span = high - low;
if (!Number.isFinite(span) || span <= 0) {
return { domain: [low, high], ticks: [low, high] };
}
const intervals = Math.max(1, maxTicks - 1);
const raw = span / intervals;
const magnitude = 10 ** Math.floor(Math.log10(raw));
let step = magnitude * 10;
for (const factor of [1, 2, 5, 10]) {
const candidate = factor * magnitude;
if (
Math.ceil(high / candidate) - Math.floor(low / candidate) <=
intervals
) {
step = candidate;
break;
}
}
const first = Math.floor(low / step) * step;
const last = Math.ceil(high / step) * step;
const ticks: number[] = [];
// Rounded to the step's own precision, so 0.1 + 0.2 never reads 0.30000…
const digits = Math.max(0, -Math.floor(Math.log10(step)));
for (let k = 0; first + k * step <= last + step / 2; k += 1) {
ticks.push(Number((first + k * step).toFixed(digits)));
}
return { domain: [ticks[0]!, ticks[ticks.length - 1]!], ticks };
}