Files
HealthLog/src/lib/charts/x-axis-density.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

321 lines
12 KiB
TypeScript

/**
* Universal X-axis tick-density helper.
*
* Charts on /dashboard and /insights all pre-v1.4.19 used Recharts'
* default tick rendering with `interval="preserveStartEnd"`. That left
* the medication compliance chart painting one tick PER DAY on a Pixel
* 5 (393 CSS px wide) — 30 overlapping labels that became an unreadable
* smear. The weight / BMI charts didn't show the same density because
* they auto-bucketed daily → weekly above 90 days, but at the 30-day
* range they still drew one tick every couple of days.
*
* The user-visible problem: chart cards looked inconsistent. Some had
* dense clutter, others had clean breathing-room. This helper unifies
* the rule across every chart so the X-axis reads the same way no
* matter the metric.
*
* v1.4.19 A2 shipped a viewport-bucket policy ("≤360px → 4 ticks max",
* "≤480px → 6", etc.) that worked for daily-bucketed BP charts but
* still left the medication-compliance chart unreadable: at 30 daily
* points on a Pixel 5 the cap-at-6 algorithm produced every-5th-day
* labels ("01.05", "06.05", "11.05", …), which looked clean on the
* tick axis but read as random sample dates with no calendar rhythm.
*
* v1.4.25 W3b tunes the policy to day-aware buckets keyed on the data
* span + viewport width:
*
* Mobile (< 640px viewport):
* 1-7 points → render every tick (no skip)
* 8-31 points → every 7th day (interval 6)
* 32-90 points → every 14th day (interval 13)
* 90+ points → ~monthly (interval 29)
* Desktop (≥ 640px viewport):
* 1-14 points → render every tick
* 15-60 points → every 7th day
* 60-180 points → every 14th day
* 180+ points → ~monthly
*
* The 640px breakpoint matches Tailwind's `sm` so a single touch-vs-
* desktop rule covers every device. The day-aware steps (7 / 14 / 30)
* align tick labels with calendar week / fortnight / month rhythms so
* the user's eye lands on "every Monday" instead of "every 5th day
* from the leftmost data point".
*
* The Recharts `interval` prop expects a 0-based skip count: 0 = render
* every tick, 1 = render every other, 2 = every third, etc. So
* "every 7th day" → interval 6, "every 14th" → 13, "monthly" → 29.
*
* `interval="preserveStartEnd"` forces the first and last tick to be
* rendered regardless of the modulo skip. Every caller in the codebase
* pairs this helper with `preserveStartEnd` (or accepts that Recharts
* keeps both endpoints by default in non-numeric XAxis configurations)
* so the leftmost / rightmost dates are never sacrificed to the skip
* pattern.
*/
/** Viewport-width breakpoint between the mobile + desktop policies. */
const MOBILE_DESKTOP_BREAKPOINT_PX = 640;
interface TickBucket {
/** Inclusive lower bound on the data-point count. */
minPoints: number;
/** Recharts `interval` value: 0 = every tick, 6 = every 7th, etc. */
interval: number;
}
/** v1.4.25 W3b policy: mobile (`< 640px`) — calendar-week buckets. */
const MOBILE_BUCKETS: ReadonlyArray<TickBucket> = [
{ minPoints: 0, interval: 0 }, // 1-7 days → every tick
{ minPoints: 8, interval: 6 }, // 8-31 days → every 7th day
{ minPoints: 32, interval: 13 }, // 32-90 days → every 14th day
{ minPoints: 91, interval: 29 }, // 90+ days → monthly
];
/** v1.4.25 W3b policy: desktop (`≥ 640px`) — calendar-fortnight buckets. */
const DESKTOP_BUCKETS: ReadonlyArray<TickBucket> = [
{ minPoints: 0, interval: 0 }, // 1-14 days → every tick
{ minPoints: 15, interval: 6 }, // 15-60 days → every 7th day
{ minPoints: 61, interval: 13 }, // 60-180 days → every 14th day
{ minPoints: 181, interval: 29 }, // 180+ days → monthly
];
function resolveBucket(pointCount: number, viewportWidth: number): TickBucket {
const buckets =
viewportWidth < MOBILE_DESKTOP_BREAKPOINT_PX
? MOBILE_BUCKETS
: DESKTOP_BUCKETS;
let resolved = buckets[0];
for (const bucket of buckets) {
if (pointCount >= bucket.minPoints) resolved = bucket;
}
return resolved;
}
/**
* Compute the Recharts `interval` skip count for a chart with
* `pointCount` data points rendered at `viewportWidth`.
*
* Returns 0 when the chart has fewer points than the bucket's "render
* every tick" threshold, and a positive integer otherwise.
*
* Examples (mobile <640px viewport):
* - 7 daily points → 0 (every tick)
* - 30 daily points → 6 (every 7th day)
* - 90 daily points → 13 (every 14th day)
* - 365 daily points → 29 (monthly)
*
* Examples (desktop ≥640px viewport):
* - 30 daily points → 6 (every 7th day)
* - 180 daily points → 13 (every 14th day)
*/
export function chooseTickInterval(
pointCount: number,
viewportWidth: number,
): number {
if (!Number.isFinite(pointCount) || pointCount <= 0) return 0;
const safeViewport =
Number.isFinite(viewportWidth) && viewportWidth > 0 ? viewportWidth : 1280;
const bucket = resolveBucket(pointCount, safeViewport);
return Math.max(0, bucket.interval);
}
/**
* v1.4.19 A2 legacy helper — kept for the existing test suite + any
* external consumer. Maps the viewport to a sensible "max ticks" cap.
*
* Note that `chooseTickInterval` no longer routes through this; the
* day-aware policy above replaced the cap-based algorithm. The shape
* stays for backwards compatibility (a few tests assert specific cap
* values per viewport).
*/
const VIEWPORT_BUCKETS: ReadonlyArray<{
maxWidth: number;
maxTicks: number;
}> = [
{ maxWidth: 360, maxTicks: 4 },
{ maxWidth: 480, maxTicks: 6 },
{ maxWidth: 768, maxTicks: 8 },
{ maxWidth: Number.POSITIVE_INFINITY, maxTicks: 10 },
];
export function resolveTargetTickCount(viewportWidth: number): number {
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {
return VIEWPORT_BUCKETS[VIEWPORT_BUCKETS.length - 1].maxTicks;
}
for (const bucket of VIEWPORT_BUCKETS) {
if (viewportWidth <= bucket.maxWidth) return bucket.maxTicks;
}
return VIEWPORT_BUCKETS[VIEWPORT_BUCKETS.length - 1].maxTicks;
}
/**
* SSR-safe accessor for the active viewport width. Used by chart wrappers
* so they can react to the current device. Returns the desktop default
* (1280) on the server so SSR-rendered charts keep their pre-v1.4.19
* tick density and progressively enhance once mounted in the browser.
*/
export function getViewportWidth(): number {
if (typeof window === "undefined") return 1280;
// visualViewport reflects pinch-zoom on iOS; we want the layout
// viewport, not the visual one. Fall back to innerWidth.
return window.innerWidth ?? 1280;
}
/**
* Tick positions for a chart whose x axis is the point index, on a calendar
* grid rather than every n-th point.
*
* Every n-th point read as random dates wherever the series had gaps: on a
* 90-day blood-pressure chart with a few missed days the labels fell 18, 14,
* 13, 17 and 23 days apart, and the forced last label crowded its neighbour.
* So the step is a calendar one (a day, two days, a week, two weeks, a month,
* a quarter, half a year, a year), the smallest that keeps the labels under
* the width's cap, and each tick is the point on that grid's date: Mondays
* for the week steps (an even-week grid for two), the first of the month for
* the month steps. A grid date with no point of its own takes the nearest
* point within half a step, and none if there is none, so a tick never
* claims a date it is not near. The last point is not forced in.
*
* `timestamp` is the point's day anchor (noon UTC of the day it stands for,
* or the start of its week or month bucket); its UTC date is the day.
*/
export function computeTickPositions(
chartData: ReadonlyArray<{ timestamp: number } | unknown>,
viewportWidth: number,
layout?: TickLayout,
): number[] {
const length = chartData?.length ?? 0;
if (length <= 0) return [];
if (length === 1) return [0];
const days = chartData.map((point) =>
Math.floor(
((point as { timestamp?: number })?.timestamp ?? Number.NaN) / DAY_MS,
),
);
if (days.some((day) => !Number.isFinite(day))) {
return Array.from({ length }, (_, index) => index);
}
const first = days[0]!;
const last = days[length - 1]!;
const maxTicks =
Number.isFinite(viewportWidth) &&
viewportWidth < MOBILE_DESKTOP_BREAKPOINT_PX
? MAX_TICKS_MOBILE
: MAX_TICKS_DESKTOP;
const ticksFor = (step: CalendarStep): number[] => {
const ticks: number[] = [];
const reach = Math.floor(step.approxDays / 2);
for (const anchor of calendarAnchors(first, last, step)) {
const index = nearestPoint(days, anchor, reach);
if (
index !== -1 &&
(ticks.length === 0 || index > ticks[ticks.length - 1]!)
) {
ticks.push(index);
}
}
return ticks;
};
// With the plot's width known, the step is the smallest whose labels all
// fit side by side where they are drawn: on an index axis the points, not
// the days, are spaced evenly, so two grid dates can sit closer than their
// step where the series is dense. Recharts dropped such a colliding label
// itself, which left the grid with holes. Without a width, the count cap.
const fits = (ticks: number[]) => {
if (!layout) return ticks.length <= maxTicks;
const perIndex = layout.plotWidth / Math.max(1, length - 1);
return ticks.every(
(tick, i) =>
i === 0 || (tick - ticks[i - 1]!) * perIndex >= layout.labelWidth,
);
};
for (const step of CALENDAR_STEPS) {
const count = Math.floor((last - first) / step.approxDays) + 1;
if (!layout && count > maxTicks) continue;
const ticks = ticksFor(step);
if (ticks.length > 0 && fits(ticks)) return ticks;
}
const widest = ticksFor(CALENDAR_STEPS[CALENDAR_STEPS.length - 1]!);
return widest.length > 0 ? widest : [0];
}
/** Where the ticks are drawn: the plot's width and a label's width, in px. */
export interface TickLayout {
plotWidth: number;
labelWidth: number;
}
const DAY_MS = 86_400_000;
/** 1970-01-05 was a Monday: day numbers counted from it fall on weeks. */
const FIRST_MONDAY = 4;
const MAX_TICKS_MOBILE = 5;
const MAX_TICKS_DESKTOP = 8;
interface CalendarStep {
kind: "days" | "months";
/** Days per step, or months per step. */
size: number;
/** The step's length in days, for counting and the nearest-point window. */
approxDays: number;
}
const CALENDAR_STEPS: ReadonlyArray<CalendarStep> = [
{ kind: "days", size: 1, approxDays: 1 },
{ kind: "days", size: 2, approxDays: 2 },
{ kind: "days", size: 7, approxDays: 7 },
{ kind: "days", size: 14, approxDays: 14 },
{ kind: "months", size: 1, approxDays: 30 },
{ kind: "months", size: 3, approxDays: 91 },
{ kind: "months", size: 6, approxDays: 182 },
{ kind: "months", size: 12, approxDays: 365 },
];
/** The grid's dates (as day numbers) from `first` to `last`, both included. */
function calendarAnchors(
first: number,
last: number,
step: CalendarStep,
): number[] {
const anchors: number[] = [];
if (step.kind === "days") {
const offset = step.size >= 7 ? FIRST_MONDAY : 0;
const start =
first + ((((offset - first) % step.size) + step.size) % step.size);
for (let day = start; day <= last; day += step.size) anchors.push(day);
return anchors;
}
const from = new Date(first * DAY_MS);
let year = from.getUTCFullYear();
let month = from.getUTCMonth();
// Start on a month the step's grid holds (Jan / Apr / Jul / Oct for a
// quarter), at or after the first point.
month = Math.ceil(month / step.size) * step.size;
if (Date.UTC(year, month, 1) / DAY_MS < first) month += step.size;
for (;;) {
year += Math.floor(month / 12);
month %= 12;
const day = Date.UTC(year, month, 1) / DAY_MS;
if (day > last) break;
anchors.push(day);
month += step.size;
}
return anchors;
}
/** The index of the point nearest `anchor` within `reach` days, or -1. */
function nearestPoint(days: number[], anchor: number, reach: number): number {
let best = -1;
let bestDistance = Number.POSITIVE_INFINITY;
for (let index = 0; index < days.length; index += 1) {
const distance = Math.abs(days[index]! - anchor);
if (distance <= reach && distance < bestDistance) {
best = index;
bestDistance = distance;
}
}
return best;
}