Files
HealthLog/src/lib/charts/x-axis-density.ts
T
Marc-André Bombeck d76c0b56e7 chore(release): v1.4.29
A targeted performance + polish patch. The headline is a faster
dashboard on data-rich accounts: the pulse chart drops up to
~5 000 raw rows per range tab in favour of one row per day, the
duplicate `/api/dashboard/widgets` fetch on every mount collapses
to one, and three long-running analytics reads are bounded to
the trailing window the tile path actually needs. Two production
regressions close — the `aggregate=daily|weekly|monthly` path on
`/api/measurements` 500'd for every grain, and the same path
averaged cumulative step counts instead of summing.
2026-05-16 10:59:42 +02:00

218 lines
8.5 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;
}
/**
* v1.4.29 — Recharts ignores the `interval` prop on a numeric
* `<XAxis type="number" />`. The pulse + mood charts switched to
* numeric axes in v1.4.25 to support multi-band rendering; the
* legacy `interval` value silently degraded to "every tick" on
* those charts.
*
* `computeTickPositions` returns explicit tick indices the numeric
* axis can consume via the `ticks` prop. The skip count comes from
* `chooseTickInterval` so the day-aware policy stays in one place.
* The result is clamped to at least 3 ticks (when the chart has
* enough data) and at most ~12, so the user always sees calendar
* landmarks without overcrowding.
*
* Returns an empty array for zero-length data and `[0]` for a
* single-point chart.
*/
export function computeTickPositions(
chartData: ReadonlyArray<{ timestamp: number } | unknown>,
viewportWidth: number,
): number[] {
const length = chartData?.length ?? 0;
if (length <= 0) return [];
if (length === 1) return [0];
const lastIndex = length - 1;
const interval = chooseTickInterval(length, viewportWidth);
// `interval === 0` means "render every tick". Cap the visible tick
// count so dense series (e.g. 365-row pulse) never blow past ~12
// labels — pick a step that hits ~6 ticks if the policy would
// otherwise produce too many.
const TARGET_MAX_TICKS = 12;
let step = interval > 0 ? interval + 1 : 1;
if (length / step > TARGET_MAX_TICKS) {
step = Math.ceil(length / TARGET_MAX_TICKS);
}
// Floor on visible tick count — never fewer than 3 ticks unless
// the data simply doesn't have that many points.
const minTicks = Math.min(3, length);
if (length / step < minTicks) {
step = Math.max(1, Math.floor(length / minTicks));
}
const ticks: number[] = [];
for (let i = 0; i <= lastIndex; i += step) {
ticks.push(i);
}
// Always include the last index so the chart's right edge keeps a
// tick label.
if (ticks[ticks.length - 1] !== lastIndex) {
ticks.push(lastIndex);
}
return ticks;
}