fix(charts): use explicit tick positions on numeric x-axes

Recharts ignores the `interval` prop on `<XAxis type="number" />`.
The pulse + mood charts switched to numeric axes in v1.4.25 to
support multi-band rendering, which silently degraded the
day-aware density policy to "every tick" on those charts. On a
365-row pulse view that meant ~365 overlapping labels.

Add `computeTickPositions(chartData, viewportWidth)` — translates
the existing `chooseTickInterval` skip count into explicit indices
the numeric axis consumes via the `ticks` prop. Tick count clamps
to a 3-12 visible-label range so dense series don't overcrowd and
sparse series don't collapse to a single label.

The category-axis charts (`compliance-line-chart`,
`medication-compliance-chart`) stay on `interval` — Recharts
honours it there.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 10:43:03 +02:00
1 parent 5662fbecb1
commit f463ca812c
4 files changed
+125 -7

No files matched your search

+8 -3
View File
@@ -41,7 +41,7 @@ import type {
import { ChartOverlayControls } from "./chart-overlay-controls";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
import { computeTickPositions } from "@/lib/charts/x-axis-density";
const TIME_RANGES_KEYS = [
{
@@ -1281,8 +1281,13 @@ export function HealthChart({
),
)
}
interval={chooseTickInterval(
chartData?.length ?? 0,
// v1.4.29 — Recharts ignores `interval` on numeric
// axes (`type="number"`). Hand explicit tick
// positions through the `ticks` prop so the legacy
// day-aware density policy stays effective on the
// pulse chart.
ticks={computeTickPositions(
chartData ?? [],
viewportWidth,
)}
padding={{ left: 10, right: 10 }}
+5 -3
View File
@@ -36,7 +36,7 @@ import type {
import { ChartOverlayControls } from "./chart-overlay-controls";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
import { computeTickPositions } from "@/lib/charts/x-axis-density";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
@@ -748,8 +748,10 @@ export function MoodChart({
),
)
}
interval={chooseTickInterval(
chartData?.length ?? 0,
// v1.4.29 — Recharts ignores `interval` on numeric
// axes; pass explicit tick positions instead.
ticks={computeTickPositions(
chartData ?? [],
viewportWidth,
)}
padding={{ left: 10, right: 10 }}
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { chooseTickInterval, resolveTargetTickCount } from "../x-axis-density";
import {
chooseTickInterval,
computeTickPositions,
resolveTargetTickCount,
} from "../x-axis-density";
describe("resolveTargetTickCount (v1.4.19 legacy cap helper)", () => {
it("caps Galaxy Fold compact (≤360px) at 4 ticks", () => {
@@ -107,3 +111,55 @@ describe("chooseTickInterval (v1.4.25 W3b day-aware policy)", () => {
});
});
});
describe("computeTickPositions (v1.4.29 numeric-axis helper)", () => {
// Recharts ignores `interval` on `type="number"` axes; the pulse +
// mood charts switched to numeric axes and silently lost the
// density policy. `computeTickPositions` translates the policy
// into explicit indices the numeric axis can consume.
it("returns every index when the chart has fewer points than the bucket threshold", () => {
const data = Array.from({ length: 5 }, (_, i) => ({ timestamp: i }));
expect(computeTickPositions(data, 393)).toEqual([0, 1, 2, 3, 4]);
});
it("returns [0] for a single-point chart", () => {
expect(computeTickPositions([{ timestamp: 0 }], 393)).toEqual([0]);
});
it("returns [] for an empty chart", () => {
expect(computeTickPositions([], 393)).toEqual([]);
});
it("caps ticks to ~12 for a 365-point series", () => {
const data = Array.from({ length: 365 }, (_, i) => ({ timestamp: i }));
const ticks = computeTickPositions(data, 393);
expect(ticks.length).toBeLessThanOrEqual(13);
expect(ticks.length).toBeGreaterThanOrEqual(6);
// First + last indices always present.
expect(ticks[0]).toBe(0);
expect(ticks[ticks.length - 1]).toBe(364);
});
it("hits a weekly-rhythm cadence on a 30-day chart at mobile width", () => {
const data = Array.from({ length: 30 }, (_, i) => ({ timestamp: i }));
const ticks = computeTickPositions(data, 393);
// 30 points / 7-day skip → 5 ticks (0, 7, 14, 21, 28) + last (29).
expect(ticks.length).toBeGreaterThanOrEqual(4);
expect(ticks.length).toBeLessThanOrEqual(6);
expect(ticks[0]).toBe(0);
expect(ticks[ticks.length - 1]).toBe(29);
});
it("never returns duplicate or out-of-bounds indices", () => {
for (const length of [2, 7, 14, 30, 90, 180, 365]) {
const data = Array.from({ length }, (_, i) => ({ timestamp: i }));
const ticks = computeTickPositions(data, 393);
expect(new Set(ticks).size).toBe(ticks.length);
for (const t of ticks) {
expect(t).toBeGreaterThanOrEqual(0);
expect(t).toBeLessThan(length);
}
}
});
});
+55
View File
@@ -160,3 +160,58 @@ export function getViewportWidth(): number {
// 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;
}