mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
5662fbecb1
commit
f463ca812c
4 files changed
+125
-7
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user