mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
This commit is contained in:
1 parent
d5ca594bd7
commit
fd34ee13b9
13 files changed
+778
-159
No files matched your search
@@ -239,6 +239,9 @@ const eslintConfig = defineConfig([
|
|||||||
"src/components/charts/workout-elevation-chart.tsx",
|
"src/components/charts/workout-elevation-chart.tsx",
|
||||||
"src/components/insights/coach-panel/result-chart.tsx",
|
"src/components/insights/coach-panel/result-chart.tsx",
|
||||||
"src/components/insights/derived/score-trend-chart.tsx",
|
"src/components/insights/derived/score-trend-chart.tsx",
|
||||||
|
// Browser tests draw a chart with Recharts itself to pin what it lays
|
||||||
|
// out; a test file is never bundled, so it mints no chunk.
|
||||||
|
"src/**/*.browser.test.tsx",
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
"no-restricted-imports": [
|
"no-restricted-imports": [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||||
"count": 355,
|
"count": 355,
|
||||||
"total": 114773,
|
"total": 114755,
|
||||||
"entries": {
|
"entries": {
|
||||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
|
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
|
||||||
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
|
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
|
||||||
"components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325,
|
"components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325,
|
||||||
"components/charts/health-chart.tsx :: HealthChart": 488,
|
"components/charts/health-chart.tsx :: HealthChart": 470,
|
||||||
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
|
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
|
||||||
"components/charts/mood-chart.tsx :: MoodChart": 896,
|
"components/charts/mood-chart.tsx :: MoodChart": 896,
|
||||||
"components/charts/mood-heatmap.tsx :: MoodHeatmap": 296,
|
"components/charts/mood-heatmap.tsx :: MoodHeatmap": 296,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||||
"count": 2898,
|
"count": 2898,
|
||||||
"total": 9297,
|
"total": 9296,
|
||||||
"entries": {
|
"entries": {
|
||||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||||
@@ -1904,7 +1904,7 @@
|
|||||||
"lib/charts/comparison-shift.ts": 1,
|
"lib/charts/comparison-shift.ts": 1,
|
||||||
"lib/charts/data-ready-latch.ts": 1,
|
"lib/charts/data-ready-latch.ts": 1,
|
||||||
"lib/charts/window-stats.ts": 1,
|
"lib/charts/window-stats.ts": 1,
|
||||||
"lib/charts/x-axis-density.ts": 6,
|
"lib/charts/x-axis-density.ts": 5,
|
||||||
"lib/clinician-share/__tests__/passphrase.test.ts": 1,
|
"lib/clinician-share/__tests__/passphrase.test.ts": 1,
|
||||||
"lib/clinician-share/__tests__/resolve-share-gate-state.test.ts": 1,
|
"lib/clinician-share/__tests__/resolve-share-gate-state.test.ts": 1,
|
||||||
"lib/clinician-share/__tests__/resolve-share-token.test.ts": 1,
|
"lib/clinician-share/__tests__/resolve-share-token.test.ts": 1,
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { CartesianGrid, ComposedChart, Line, XAxis, YAxis } from "recharts";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { render } from "vitest-browser-react";
|
||||||
|
|
||||||
|
import { computeTickPositions } from "@/lib/charts/x-axis-density";
|
||||||
|
import { computePaddedYDomain, niceYAxis } from "@/lib/insights/chart-y-domain";
|
||||||
|
|
||||||
|
import {
|
||||||
|
baselineLayers,
|
||||||
|
rightLabelRoom,
|
||||||
|
targetZoneLayers,
|
||||||
|
} from "../health-chart/chart-layers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value chart's axes and its baseline label, drawn by Recharts with the
|
||||||
|
* props HealthChart hands it: the y ticks are round and evenly spaced and all
|
||||||
|
* of them drawn, the x ticks fall on the calendar grid, and the "your
|
||||||
|
* average" and target zone labels sit whole beside the plot, covering no
|
||||||
|
* reading.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
const START = Date.parse("2026-07-23T12:00:00.000Z");
|
||||||
|
const MISSING = new Set([10, 11, 12, 40, 41, 70, 71, 72, 73, 80]);
|
||||||
|
const POINTS = Array.from({ length: 90 }, (_, day) => day)
|
||||||
|
.filter((day) => !MISSING.has(day))
|
||||||
|
.map((day, pointIndex) => ({
|
||||||
|
pointIndex,
|
||||||
|
timestamp: START + day * DAY,
|
||||||
|
BLOOD_PRESSURE_SYS: 118 + 14 * Math.sin(day / 5) + (day % 7),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const t = (key: string) =>
|
||||||
|
key === "charts.personalBaseline" ? "Your average" : key;
|
||||||
|
const label = "Your average";
|
||||||
|
const zoneLabel = "Systolic target";
|
||||||
|
|
||||||
|
function Chart() {
|
||||||
|
const padded = computePaddedYDomain(
|
||||||
|
POINTS.map((p) => p.BLOOD_PRESSURE_SYS),
|
||||||
|
["BLOOD_PRESSURE_SYS"],
|
||||||
|
)!;
|
||||||
|
const y = niceYAxis(padded);
|
||||||
|
return (
|
||||||
|
<ComposedChart
|
||||||
|
width={720}
|
||||||
|
height={260}
|
||||||
|
data={POINTS}
|
||||||
|
margin={{
|
||||||
|
top: 10,
|
||||||
|
right: 8 + rightLabelRoom([label, zoneLabel]),
|
||||||
|
bottom: 8,
|
||||||
|
left: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
dataKey="pointIndex"
|
||||||
|
domain={[0, POINTS.length - 1]}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
ticks={computeTickPositions(POINTS, 1440)}
|
||||||
|
tickFormatter={(value: number) =>
|
||||||
|
new Date(POINTS[Math.round(value)]!.timestamp)
|
||||||
|
.toISOString()
|
||||||
|
.slice(5, 10)
|
||||||
|
}
|
||||||
|
padding={{ left: 10, right: 10 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
domain={y.domain}
|
||||||
|
ticks={y.ticks}
|
||||||
|
width={44}
|
||||||
|
/>
|
||||||
|
<Line dataKey="BLOOD_PRESSURE_SYS" isAnimationActive={false} dot />
|
||||||
|
{targetZoneLayers(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: "sys",
|
||||||
|
low: 110,
|
||||||
|
high: 130,
|
||||||
|
color: "#c44",
|
||||||
|
opacity: 0.1,
|
||||||
|
lineOpacity: 0.5,
|
||||||
|
topPct: 0,
|
||||||
|
heightPct: 0,
|
||||||
|
label: zoneLabel,
|
||||||
|
textColor: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
POINTS.length - 1,
|
||||||
|
)}
|
||||||
|
{baselineLayers(
|
||||||
|
true,
|
||||||
|
["BLOOD_PRESSURE_SYS"],
|
||||||
|
["#888"],
|
||||||
|
new Map([["BLOOD_PRESSURE_SYS", 121]]),
|
||||||
|
t,
|
||||||
|
)}
|
||||||
|
</ComposedChart>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickTexts(root: HTMLElement, axis: "xAxis" | "yAxis"): string[] {
|
||||||
|
return [
|
||||||
|
...root.querySelectorAll(
|
||||||
|
`.recharts-${axis}-tick-labels .recharts-cartesian-axis-tick-value`,
|
||||||
|
),
|
||||||
|
].map((node) => node.textContent ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("value chart axes", () => {
|
||||||
|
it("draws every round y tick, evenly spaced", async () => {
|
||||||
|
const screen = await render(<Chart />);
|
||||||
|
await settle();
|
||||||
|
const values = tickTexts(screen.container, "yAxis").map(Number);
|
||||||
|
expect(values).toEqual([100, 110, 120, 130, 140, 150]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the x ticks on the two-week grid of Mondays", async () => {
|
||||||
|
const screen = await render(<Chart />);
|
||||||
|
await settle();
|
||||||
|
expect(tickTexts(screen.container, "xAxis")).toEqual([
|
||||||
|
"08-01",
|
||||||
|
"08-17",
|
||||||
|
"08-31",
|
||||||
|
"09-14",
|
||||||
|
"09-28",
|
||||||
|
"10-12",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws a target zone's label whole, beside the plot", async () => {
|
||||||
|
const screen = await render(<Chart />);
|
||||||
|
await settle();
|
||||||
|
const root = screen.container;
|
||||||
|
const plot = root
|
||||||
|
.querySelector(".recharts-cartesian-grid")!
|
||||||
|
.getBoundingClientRect();
|
||||||
|
const svg = root
|
||||||
|
.querySelector("svg.recharts-surface")!
|
||||||
|
.getBoundingClientRect();
|
||||||
|
const text = [...root.querySelectorAll(".recharts-label")].find(
|
||||||
|
(node) => node.textContent === zoneLabel,
|
||||||
|
)!;
|
||||||
|
expect(text).toBeDefined();
|
||||||
|
const box = text.getBoundingClientRect();
|
||||||
|
expect(box.left).toBeGreaterThanOrEqual(plot.right);
|
||||||
|
expect(box.right).toBeLessThanOrEqual(svg.right);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws the average's label outside the plot, clear of every reading", async () => {
|
||||||
|
const screen = await render(<Chart />);
|
||||||
|
await settle();
|
||||||
|
const root = screen.container;
|
||||||
|
const plot = root
|
||||||
|
.querySelector(".recharts-cartesian-grid")!
|
||||||
|
.getBoundingClientRect();
|
||||||
|
const text = [...root.querySelectorAll(".recharts-label")].find(
|
||||||
|
(node) => node.textContent === label,
|
||||||
|
)!;
|
||||||
|
expect(text).toBeDefined();
|
||||||
|
const box = text.getBoundingClientRect();
|
||||||
|
const svg = root
|
||||||
|
.querySelector("svg.recharts-surface")!
|
||||||
|
.getBoundingClientRect();
|
||||||
|
expect(box.left).toBeGreaterThanOrEqual(plot.right);
|
||||||
|
expect(box.right).toBeLessThanOrEqual(svg.right);
|
||||||
|
for (const dot of root.querySelectorAll(".recharts-line-dot")) {
|
||||||
|
const d = dot.getBoundingClientRect();
|
||||||
|
const overlaps =
|
||||||
|
d.left < box.right &&
|
||||||
|
d.right > box.left &&
|
||||||
|
d.top < box.bottom &&
|
||||||
|
d.bottom > box.top;
|
||||||
|
expect(overlaps).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
ChartDayFooter,
|
ChartDayFooter,
|
||||||
useChartDayLinks,
|
useChartDayLinks,
|
||||||
} from "@/components/day/chart-day-links";
|
} from "@/components/day/chart-day-links";
|
||||||
import { isWholeNumberType } from "@/components/day/use-day-value-format";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
|
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
|
||||||
@@ -25,7 +24,6 @@ import { useViewportWidth } from "@/hooks/use-viewport-width";
|
|||||||
import { axisTickUnitSuffix } from "@/lib/charts/axis-unit";
|
import { axisTickUnitSuffix } from "@/lib/charts/axis-unit";
|
||||||
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
|
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
|
||||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||||
import { computeTickPositions } from "@/lib/charts/x-axis-density";
|
|
||||||
import {
|
import {
|
||||||
useDateFormatPreference,
|
useDateFormatPreference,
|
||||||
useFormatters,
|
useFormatters,
|
||||||
@@ -50,12 +48,16 @@ import {
|
|||||||
} from "./health-chart/chart-layers";
|
} from "./health-chart/chart-layers";
|
||||||
import { makeTooltipContent } from "./health-chart/chart-tooltip-content";
|
import { makeTooltipContent } from "./health-chart/chart-tooltip-content";
|
||||||
import {
|
import {
|
||||||
|
axisValueFormatters,
|
||||||
|
dayFocusFor,
|
||||||
getTypeLabel,
|
getTypeLabel,
|
||||||
makeDayKeyFormatter,
|
makeDayKeyFormatter,
|
||||||
|
pointIndexXAxis,
|
||||||
resolveAnnotationPositions,
|
resolveAnnotationPositions,
|
||||||
resolveVerticalMarkerPositions,
|
resolveVerticalMarkerPositions,
|
||||||
|
xTickLayout,
|
||||||
} from "./health-chart/series-math";
|
} from "./health-chart/series-math";
|
||||||
import type { ChartDataPoint, HealthChartProps } from "./health-chart/types";
|
import type { HealthChartProps } from "./health-chart/types";
|
||||||
import {
|
import {
|
||||||
useHealthChartLabels,
|
useHealthChartLabels,
|
||||||
useHealthChartOverlays,
|
useHealthChartOverlays,
|
||||||
@@ -194,6 +196,7 @@ export function HealthChart({
|
|||||||
activeBucket,
|
activeBucket,
|
||||||
bucketCaption,
|
bucketCaption,
|
||||||
yDomain,
|
yDomain,
|
||||||
|
yTicks,
|
||||||
} = useHealthChartView({
|
} = useHealthChartView({
|
||||||
data,
|
data,
|
||||||
rangePoints,
|
rangePoints,
|
||||||
@@ -209,29 +212,25 @@ export function HealthChart({
|
|||||||
onVisibleStats,
|
onVisibleStats,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const overlays = useHealthChartOverlays({
|
||||||
|
showBands,
|
||||||
|
showTrend,
|
||||||
|
valueBands,
|
||||||
|
targetZones,
|
||||||
|
yDomain,
|
||||||
|
chartData,
|
||||||
|
types,
|
||||||
|
unit,
|
||||||
|
valueMode,
|
||||||
|
t,
|
||||||
|
fmt,
|
||||||
|
});
|
||||||
const { visibleBands, visibleTargetZones, trendInfo, personalBaselines } =
|
const { visibleBands, visibleTargetZones, trendInfo, personalBaselines } =
|
||||||
useHealthChartOverlays({
|
overlays;
|
||||||
showBands,
|
|
||||||
showTrend,
|
|
||||||
valueBands,
|
|
||||||
targetZones,
|
|
||||||
yDomain,
|
|
||||||
chartData,
|
|
||||||
types,
|
|
||||||
unit,
|
|
||||||
valueMode,
|
|
||||||
t,
|
|
||||||
fmt,
|
|
||||||
});
|
|
||||||
|
|
||||||
// One decimal on the axis while the visible domain spans under 6 units, so
|
const [containerWidth, setContainerWidth] = useState<number | null>(null);
|
||||||
// two gridlines never round to the same integer (82.8 vs 83.5 kg).
|
const { useDecimalAxis, formatAxisValue, formatTooltipValue } =
|
||||||
const yDomainSpan = yDomain ? yDomain[1] - yDomain[0] : null;
|
axisValueFormatters(yDomain, fmt);
|
||||||
const useDecimalAxis = yDomainSpan !== null && yDomainSpan < 6;
|
|
||||||
const formatAxisValue = (value: number) =>
|
|
||||||
useDecimalAxis ? fmt.number(value, 1) : fmt.integer(value);
|
|
||||||
|
|
||||||
const formatTooltipValue = (value: number) => fmt.number(value, 1);
|
|
||||||
|
|
||||||
const { primaryType, chartAriaLabel, dataTableColumns } =
|
const { primaryType, chartAriaLabel, dataTableColumns } =
|
||||||
useHealthChartLabels({
|
useHealthChartLabels({
|
||||||
@@ -262,36 +261,21 @@ export function HealthChart({
|
|||||||
// The days as doors: daily points only (a week or month point averages
|
// The days as doors: daily points only (a week or month point averages
|
||||||
// many days and has no one day to open).
|
// many days and has no one day to open).
|
||||||
const dayLinksActive = dayLinks && !mini && activeBucket === "day";
|
const dayLinksActive = dayLinks && !mini && activeBucket === "day";
|
||||||
// What the day shows at its top when opened from a point: the chart's name
|
|
||||||
// and the point's value as the tooltip reads it.
|
|
||||||
const dayFocusFor = (point: ChartDataPoint | undefined) => {
|
|
||||||
if (!point) return null;
|
|
||||||
const parts = types
|
|
||||||
.map((type) => ({ type, v: point[type] }))
|
|
||||||
.filter((p): p is { type: string; v: number } => typeof p.v === "number");
|
|
||||||
if (parts.length === 0) return null;
|
|
||||||
return {
|
|
||||||
label: title,
|
|
||||||
// A pressure or a pulse reads in whole numbers on the day, as in every
|
|
||||||
// list; the rest keep the tooltip's one decimal.
|
|
||||||
value: parts
|
|
||||||
.map(({ type, v }) =>
|
|
||||||
valueMode === "raw" && isWholeNumberType(type)
|
|
||||||
? fmt.number(Math.round(v), 0)
|
|
||||||
: formatTooltipValue(v),
|
|
||||||
)
|
|
||||||
.join("/"),
|
|
||||||
unit,
|
|
||||||
types: valueMode === "raw" ? types : [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const dayLinkSource = chartDataWithCompare ?? chartData;
|
const dayLinkSource = chartDataWithCompare ?? chartData;
|
||||||
const chartDays = useChartDayLinks({
|
const chartDays = useChartDayLinks({
|
||||||
enabled: dayLinksActive,
|
enabled: dayLinksActive,
|
||||||
days: dayLinksActive
|
days: dayLinksActive
|
||||||
? (dayLinkSource ?? []).map((point) => chartPointDayKey(point.timestamp))
|
? (dayLinkSource ?? []).map((point) => chartPointDayKey(point.timestamp))
|
||||||
: [],
|
: [],
|
||||||
focusFor: (index) => dayFocusFor(dayLinkSource?.[index]),
|
focusFor: (index) =>
|
||||||
|
dayFocusFor(dayLinkSource?.[index], {
|
||||||
|
types,
|
||||||
|
title,
|
||||||
|
unit,
|
||||||
|
valueMode,
|
||||||
|
fmt,
|
||||||
|
formatTooltipValue,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const openDayIndex = chartDays.openIndex;
|
const openDayIndex = chartDays.openIndex;
|
||||||
const openDayPoint =
|
const openDayPoint =
|
||||||
@@ -405,10 +389,19 @@ export function HealthChart({
|
|||||||
role="img"
|
role="img"
|
||||||
aria-label={chartAriaLabel}
|
aria-label={chartAriaLabel}
|
||||||
>
|
>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
onResize={setContainerWidth}
|
||||||
|
>
|
||||||
<ComposedChart
|
<ComposedChart
|
||||||
data={chartDataWithCompare ?? chartData}
|
data={chartDataWithCompare ?? chartData}
|
||||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
margin={{
|
||||||
|
top: 10,
|
||||||
|
right: 8 + overlays.rightRoom,
|
||||||
|
bottom: 8,
|
||||||
|
left: 8,
|
||||||
|
}}
|
||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
onClick={chartDays.onChartClick}
|
onClick={chartDays.onChartClick}
|
||||||
>
|
>
|
||||||
@@ -418,26 +411,16 @@ export function HealthChart({
|
|||||||
opacity={0.5}
|
opacity={0.5}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
{...pointIndexXAxis({
|
||||||
dataKey="pointIndex"
|
chartData,
|
||||||
domain={[0, maxPointIndex]}
|
formatDate: tzFmt.date,
|
||||||
allowDecimals={false}
|
viewportWidth,
|
||||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
layout: xTickLayout(
|
||||||
tickLine={false}
|
containerWidth,
|
||||||
axisLine={false}
|
yAxisWidth,
|
||||||
tickFormatter={(value) =>
|
overlays.rightRoom,
|
||||||
tzFmt.date(
|
),
|
||||||
new Date(
|
})}
|
||||||
chartData?.[Math.round(value)]?.timestamp ??
|
|
||||||
Date.now(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Recharts ignores `interval` on a numeric axis, so the
|
|
||||||
// day-aware density arrives as explicit tick positions.
|
|
||||||
ticks={computeTickPositions(chartData ?? [], viewportWidth)}
|
|
||||||
padding={{ left: 10, right: 10 }}
|
|
||||||
tickMargin={10}
|
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||||
@@ -446,6 +429,7 @@ export function HealthChart({
|
|||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickMargin={10}
|
tickMargin={10}
|
||||||
domain={yDomain}
|
domain={yDomain}
|
||||||
|
ticks={yTicks}
|
||||||
tickFormatter={(value) =>
|
tickFormatter={(value) =>
|
||||||
typeof value === "number"
|
typeof value === "number"
|
||||||
? formatAxisValue(
|
? formatAxisValue(
|
||||||
@@ -537,7 +521,7 @@ export function HealthChart({
|
|||||||
// The plot's margin (8) + the y axis + the x axis padding (10)
|
// The plot's margin (8) + the y axis + the x axis padding (10)
|
||||||
// on the left, margin + padding on the right.
|
// on the left, margin + padding on the right.
|
||||||
insetLeft={8 + yAxisWidth + 10}
|
insetLeft={8 + yAxisWidth + 10}
|
||||||
insetRight={8 + 10}
|
insetRight={8 + overlays.rightRoom + 10}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/* The points the chart just drew, as a table, read from the same
|
{/* The points the chart just drew, as a table, read from the same
|
||||||
|
|||||||
@@ -66,8 +66,9 @@ export function targetZoneLayers(
|
|||||||
? {
|
? {
|
||||||
value: zone.label,
|
value: zone.label,
|
||||||
position: "right",
|
position: "right",
|
||||||
|
offset: BASELINE_LABEL_GAP,
|
||||||
fill: zone.textColor ?? zone.color,
|
fill: zone.textColor ?? zone.color,
|
||||||
fontSize: 10,
|
fontSize: BASELINE_LABEL_FONT_SIZE,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -186,10 +187,51 @@ export function openDayLayers(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The gap between the plot's right edge and the baseline label. */
|
||||||
|
const BASELINE_LABEL_GAP = 6;
|
||||||
|
/** The label's type size, and a width per character that over-reads it. */
|
||||||
|
const BASELINE_LABEL_FONT_SIZE = 10;
|
||||||
|
const BASELINE_LABEL_CHAR_WIDTH = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The baseline label's text when the chart draws one: the trend overlay is
|
||||||
|
* on and the first type has a baseline inside the visible domain. Null
|
||||||
|
* otherwise, and then the chart keeps no room for it.
|
||||||
|
*/
|
||||||
|
export function baselineLabelText(
|
||||||
|
showTrend: boolean,
|
||||||
|
types: string[],
|
||||||
|
personalBaselines: Map<string, number>,
|
||||||
|
yDomain: [number, number] | undefined,
|
||||||
|
t: Translate,
|
||||||
|
): string | null {
|
||||||
|
if (!showTrend || types.length === 0) return null;
|
||||||
|
const baseline = personalBaselines.get(types[0]!);
|
||||||
|
if (baseline == null) return null;
|
||||||
|
if (yDomain && (baseline < yDomain[0] || baseline > yDomain[1])) return null;
|
||||||
|
return t("charts.personalBaseline");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right margin the labels drawn beside the plot need: the average's label
|
||||||
|
* and the target zones' labels sit outside the plot, past the lines' right
|
||||||
|
* ends, so they never cover a point or a line. With the margin at 8 px a
|
||||||
|
* zone's label was cut to its first letter, and the average's label, inside
|
||||||
|
* the plot at the top left, sat on the first readings.
|
||||||
|
*/
|
||||||
|
export function rightLabelRoom(
|
||||||
|
labels: ReadonlyArray<string | null | undefined>,
|
||||||
|
): number {
|
||||||
|
const longest = Math.max(0, ...labels.map((label) => label?.length ?? 0));
|
||||||
|
if (longest === 0) return 0;
|
||||||
|
return BASELINE_LABEL_GAP + Math.ceil(longest * BASELINE_LABEL_CHAR_WIDTH);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The personal baseline per type, only while the trend overlay is shown (an
|
* The personal baseline per type, only while the trend overlay is shown (an
|
||||||
* always-on mean line was rejected). Only the first type carries the label,
|
* always-on mean line was rejected). Only the first type carries the label,
|
||||||
* so a multi-type chart does not paint it twice.
|
* so a multi-type chart does not paint it twice; it is drawn to the right of
|
||||||
|
* the plot, in the room `rightLabelRoom` keeps there.
|
||||||
*/
|
*/
|
||||||
export function baselineLayers(
|
export function baselineLayers(
|
||||||
showTrend: boolean,
|
showTrend: boolean,
|
||||||
@@ -216,9 +258,10 @@ export function baselineLayers(
|
|||||||
i === 0
|
i === 0
|
||||||
? {
|
? {
|
||||||
value: t("charts.personalBaseline"),
|
value: t("charts.personalBaseline"),
|
||||||
position: "insideTopLeft",
|
position: "right",
|
||||||
|
offset: BASELINE_LABEL_GAP,
|
||||||
fill: "var(--muted-foreground)",
|
fill: "var(--muted-foreground)",
|
||||||
fontSize: 10,
|
fontSize: BASELINE_LABEL_FONT_SIZE,
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
@@ -4,8 +4,16 @@
|
|||||||
* land. No React and no Recharts, so each piece has direct unit coverage.
|
* land. No React and no Recharts, so each piece has direct unit coverage.
|
||||||
*/
|
*/
|
||||||
import { chartPointDayKey } from "@/components/day/chart-day";
|
import { chartPointDayKey } from "@/components/day/chart-day";
|
||||||
|
import { isWholeNumberType } from "@/components/day/use-day-value-format";
|
||||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||||
|
|
||||||
|
import {
|
||||||
|
computeTickPositions,
|
||||||
|
type TickLayout,
|
||||||
|
} from "@/lib/charts/x-axis-density";
|
||||||
|
|
||||||
|
import type { ChartDataPoint } from "./types";
|
||||||
|
|
||||||
const BASE_TYPE_LABEL_KEYS: Record<string, string> = {
|
const BASE_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||||
WEIGHT: "charts.weight",
|
WEIGHT: "charts.weight",
|
||||||
BLOOD_PRESSURE_SYS: "charts.systolic",
|
BLOOD_PRESSURE_SYS: "charts.systolic",
|
||||||
@@ -219,3 +227,114 @@ export function resolveAnnotationPositions(
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value axis's and the tooltip's number formats. One decimal on the axis
|
||||||
|
* while the visible domain spans under 6 units, so two gridlines never round
|
||||||
|
* to the same integer (82.8 vs 83.5 kg).
|
||||||
|
*/
|
||||||
|
export function axisValueFormatters(
|
||||||
|
yDomain: [number, number] | undefined,
|
||||||
|
fmt: {
|
||||||
|
number: (value: number, digits: number) => string;
|
||||||
|
integer: (value: number) => string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const span = yDomain ? yDomain[1] - yDomain[0] : null;
|
||||||
|
const useDecimalAxis = span !== null && span < 6;
|
||||||
|
return {
|
||||||
|
useDecimalAxis,
|
||||||
|
formatAxisValue: (value: number) =>
|
||||||
|
useDecimalAxis ? fmt.number(value, 1) : fmt.integer(value),
|
||||||
|
formatTooltipValue: (value: number) => fmt.number(value, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the day shows at its top when opened from a point: the chart's name
|
||||||
|
* and the point's value as the tooltip reads it. A pressure or a pulse reads
|
||||||
|
* in whole numbers on the day, as in every list; the rest keep the tooltip's
|
||||||
|
* one decimal.
|
||||||
|
*/
|
||||||
|
export function dayFocusFor(
|
||||||
|
point: ChartDataPoint | undefined,
|
||||||
|
ctx: {
|
||||||
|
types: string[];
|
||||||
|
title: string;
|
||||||
|
unit?: string;
|
||||||
|
valueMode: "raw" | "bmi";
|
||||||
|
fmt: { number: (value: number, digits: number) => string };
|
||||||
|
formatTooltipValue: (value: number) => string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!point) return null;
|
||||||
|
const parts = ctx.types
|
||||||
|
.map((type) => ({ type, v: point[type] }))
|
||||||
|
.filter((p): p is { type: string; v: number } => typeof p.v === "number");
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
return {
|
||||||
|
label: ctx.title,
|
||||||
|
value: parts
|
||||||
|
.map(({ type, v }) =>
|
||||||
|
ctx.valueMode === "raw" && isWholeNumberType(type)
|
||||||
|
? ctx.fmt.number(Math.round(v), 0)
|
||||||
|
: ctx.formatTooltipValue(v),
|
||||||
|
)
|
||||||
|
.join("/"),
|
||||||
|
unit: ctx.unit,
|
||||||
|
types: ctx.valueMode === "raw" ? ctx.types : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the x labels are drawn: the plot's width (the container less the
|
||||||
|
* chart margins, the y axis, the right-hand label room and the axis padding)
|
||||||
|
* and the width a date label takes, with room between two. Null until the
|
||||||
|
* container has been measured; the ticks then fall back to the count cap.
|
||||||
|
*/
|
||||||
|
export function xTickLayout(
|
||||||
|
containerWidth: number | null,
|
||||||
|
yAxisWidth: number,
|
||||||
|
rightRoom: number,
|
||||||
|
): { plotWidth: number; labelWidth: number } | undefined {
|
||||||
|
if (!containerWidth) return undefined;
|
||||||
|
const plotWidth = containerWidth - 16 - yAxisWidth - rightRoom - 20;
|
||||||
|
if (plotWidth <= 0) return undefined;
|
||||||
|
// A day-month-year date at the axis's 11 px, with a gap of its own size
|
||||||
|
// between two labels so the row reads as separate dates.
|
||||||
|
return { plotWidth, labelWidth: 64 + 16 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The x axis's props: one point per index, labelled with its date. Recharts
|
||||||
|
* ignores `interval` on a numeric axis, so the calendar grid arrives as
|
||||||
|
* explicit tick positions (`computeTickPositions`).
|
||||||
|
*/
|
||||||
|
export function pointIndexXAxis({
|
||||||
|
chartData,
|
||||||
|
formatDate,
|
||||||
|
viewportWidth,
|
||||||
|
layout,
|
||||||
|
}: {
|
||||||
|
chartData: ChartDataPoint[] | undefined;
|
||||||
|
formatDate: (date: Date) => string;
|
||||||
|
viewportWidth: number;
|
||||||
|
layout: TickLayout | undefined;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
type: "number" as const,
|
||||||
|
dataKey: "pointIndex",
|
||||||
|
domain: [0, Math.max(0, (chartData?.length ?? 1) - 1)],
|
||||||
|
allowDecimals: false,
|
||||||
|
tick: { fontSize: 11, fill: "var(--muted-foreground)" },
|
||||||
|
tickLine: false,
|
||||||
|
axisLine: false,
|
||||||
|
tickFormatter: (value: number) =>
|
||||||
|
formatDate(
|
||||||
|
new Date(chartData?.[Math.round(value)]?.timestamp ?? Date.now()),
|
||||||
|
),
|
||||||
|
ticks: computeTickPositions(chartData ?? [], viewportWidth, layout),
|
||||||
|
padding: { left: 10, right: 10 },
|
||||||
|
tickMargin: 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
VisibleTargetZone,
|
VisibleTargetZone,
|
||||||
VisibleValueBand,
|
VisibleValueBand,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { baselineLabelText, rightLabelRoom } from "./chart-layers";
|
||||||
|
|
||||||
type Translate = (
|
type Translate = (
|
||||||
key: string,
|
key: string,
|
||||||
@@ -210,7 +211,26 @@ export function useHealthChartOverlays({
|
|||||||
return map;
|
return map;
|
||||||
}, [chartData, types]);
|
}, [chartData, types]);
|
||||||
|
|
||||||
return { visibleBands, visibleTargetZones, trendInfo, personalBaselines };
|
// The labels drawn beside the plot (the average's, the target zones'), and
|
||||||
|
// so the room the plot keeps for them on its right.
|
||||||
|
const baselineLabel = baselineLabelText(
|
||||||
|
showTrend,
|
||||||
|
types,
|
||||||
|
personalBaselines,
|
||||||
|
yDomain,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleBands,
|
||||||
|
visibleTargetZones,
|
||||||
|
trendInfo,
|
||||||
|
personalBaselines,
|
||||||
|
rightRoom: rightLabelRoom([
|
||||||
|
baselineLabel,
|
||||||
|
...visibleTargetZones.map((zone) => zone.label),
|
||||||
|
]),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type MetricWindowStats,
|
type MetricWindowStats,
|
||||||
} from "@/lib/charts/window-stats";
|
} from "@/lib/charts/window-stats";
|
||||||
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
|
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
|
||||||
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
|
import { computePaddedYDomain, niceYAxis } from "@/lib/insights/chart-y-domain";
|
||||||
import { trendLineByTime } from "@/lib/stats/linear-fit";
|
import { trendLineByTime } from "@/lib/stats/linear-fit";
|
||||||
|
|
||||||
import { movingAverageByPoints } from "./series-math";
|
import { movingAverageByPoints } from "./series-math";
|
||||||
@@ -308,7 +308,9 @@ export function useHealthChartView({
|
|||||||
// in the tooltip.
|
// in the tooltip.
|
||||||
const bucketCaption = bucketCaptionKey(activeBucket, types);
|
const bucketCaption = bucketCaptionKey(activeBucket, types);
|
||||||
|
|
||||||
const yDomain = useMemo<[number, number] | undefined>(() => {
|
const yAxis = useMemo<
|
||||||
|
{ domain: [number, number]; ticks: number[] } | undefined
|
||||||
|
>(() => {
|
||||||
if (!chartDataWithCompare?.length) return undefined;
|
if (!chartDataWithCompare?.length) return undefined;
|
||||||
|
|
||||||
const keys = [...types];
|
const keys = [...types];
|
||||||
@@ -325,7 +327,8 @@ export function useHealthChartView({
|
|||||||
.filter((value): value is number => typeof value === "number")
|
.filter((value): value is number => typeof value === "number")
|
||||||
.filter((value) => Number.isFinite(value));
|
.filter((value) => Number.isFinite(value));
|
||||||
|
|
||||||
return computePaddedYDomain(values, types);
|
const padded = computePaddedYDomain(values, types);
|
||||||
|
return padded ? niceYAxis(padded) : undefined;
|
||||||
}, [
|
}, [
|
||||||
chartDataWithCompare,
|
chartDataWithCompare,
|
||||||
effectiveCompareBaseline,
|
effectiveCompareBaseline,
|
||||||
@@ -342,6 +345,7 @@ export function useHealthChartView({
|
|||||||
hasComparisonData,
|
hasComparisonData,
|
||||||
activeBucket,
|
activeBucket,
|
||||||
bucketCaption,
|
bucketCaption,
|
||||||
yDomain,
|
yDomain: yAxis?.domain,
|
||||||
|
yTicks: yAxis?.ticks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -112,53 +112,105 @@ describe("chooseTickInterval (v1.4.25 W3b day-aware policy)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("computeTickPositions (v1.4.29 numeric-axis helper)", () => {
|
describe("computeTickPositions — ticks on a calendar grid", () => {
|
||||||
// Recharts ignores `interval` on `type="number"` axes; the pulse +
|
const DAY = 86_400_000;
|
||||||
// mood charts switched to numeric axes and silently lost the
|
/** Daily points at noon UTC from `start`, leaving out the `missing` days. */
|
||||||
// density policy. `computeTickPositions` translates the policy
|
function daily(start: string, count: number, missing: number[] = []) {
|
||||||
// into explicit indices the numeric axis can consume.
|
const t0 = Date.parse(`${start}T12:00:00.000Z`);
|
||||||
|
return Array.from({ length: count }, (_, i) => i)
|
||||||
|
.filter((i) => !missing.includes(i))
|
||||||
|
.map((i) => ({ timestamp: t0 + i * DAY }));
|
||||||
|
}
|
||||||
|
const dates = (data: { timestamp: number }[], ticks: number[]) =>
|
||||||
|
ticks.map((i) => new Date(data[i]!.timestamp).toISOString().slice(0, 10));
|
||||||
|
|
||||||
it("returns every index when the chart has fewer points than the bucket threshold", () => {
|
it("returns [] for an empty chart and [0] for one point", () => {
|
||||||
const data = Array.from({ length: 5 }, (_, i) => ({ timestamp: i }));
|
expect(computeTickPositions([], 393)).toEqual([]);
|
||||||
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]);
|
expect(computeTickPositions([{ timestamp: 0 }], 393)).toEqual([0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns [] for an empty chart", () => {
|
it("labels every day of a week where the width holds it, every other on a phone", () => {
|
||||||
expect(computeTickPositions([], 393)).toEqual([]);
|
const data = daily("2026-10-17", 7);
|
||||||
|
expect(computeTickPositions(data, 1440)).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||||
|
const phone = dates(data, computeTickPositions(data, 393));
|
||||||
|
expect(phone).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caps ticks to ~12 for a 365-point series", () => {
|
it("puts a 90-day chart with missed days on a two-week grid of Mondays", () => {
|
||||||
const data = Array.from({ length: 365 }, (_, i) => ({ timestamp: i }));
|
// The reported chart: readings with gaps, labelled every n-th point,
|
||||||
const ticks = computeTickPositions(data, 393);
|
// fell 18, 14, 13, 17 and 23 days apart.
|
||||||
expect(ticks.length).toBeLessThanOrEqual(13);
|
const data = daily(
|
||||||
expect(ticks.length).toBeGreaterThanOrEqual(6);
|
"2026-07-23",
|
||||||
// First + last indices always present.
|
90,
|
||||||
expect(ticks[0]).toBe(0);
|
[10, 11, 12, 40, 41, 70, 71, 72, 73, 80],
|
||||||
expect(ticks[ticks.length - 1]).toBe(364);
|
);
|
||||||
|
const ticks = dates(data, computeTickPositions(data, 1440));
|
||||||
|
// 3 August is a grid Monday with no reading; the nearest day stands in.
|
||||||
|
expect(ticks).toEqual([
|
||||||
|
"2026-08-01",
|
||||||
|
"2026-08-17",
|
||||||
|
"2026-08-31",
|
||||||
|
"2026-09-14",
|
||||||
|
"2026-09-28",
|
||||||
|
"2026-10-12",
|
||||||
|
]);
|
||||||
|
for (const tick of ticks.slice(1)) {
|
||||||
|
expect(new Date(`${tick}T12:00:00Z`).getUTCDay()).toBe(1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hits a weekly-rhythm cadence on a 30-day chart at mobile width", () => {
|
it("takes the nearest point within half a step when the grid date has none", () => {
|
||||||
const data = Array.from({ length: 30 }, (_, i) => ({ timestamp: i }));
|
// 2026-09-14 (a grid Monday) is missing; the 13th or 15th stands in.
|
||||||
const ticks = computeTickPositions(data, 393);
|
const data = daily("2026-08-03", 60, [42]);
|
||||||
// 30 points / 7-day skip → 5 ticks (0, 7, 14, 21, 28) + last (29).
|
const ticks = dates(data, computeTickPositions(data, 1440));
|
||||||
expect(ticks.length).toBeGreaterThanOrEqual(4);
|
expect(ticks).toContain("2026-08-17");
|
||||||
expect(ticks.length).toBeLessThanOrEqual(6);
|
expect(ticks.some((t) => t === "2026-09-13" || t === "2026-09-15")).toBe(
|
||||||
expect(ticks[0]).toBe(0);
|
true,
|
||||||
expect(ticks[ticks.length - 1]).toBe(29);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never returns duplicate or out-of-bounds indices", () => {
|
it("does not force the last point in, so the final label never crowds", () => {
|
||||||
for (const length of [2, 7, 14, 30, 90, 180, 365]) {
|
const data = daily("2026-07-23", 90);
|
||||||
const data = Array.from({ length }, (_, i) => ({ timestamp: i }));
|
const ticks = computeTickPositions(data, 1440);
|
||||||
const ticks = computeTickPositions(data, 393);
|
expect(ticks[ticks.length - 1]).not.toBe(data.length - 1);
|
||||||
expect(new Set(ticks).size).toBe(ticks.length);
|
});
|
||||||
for (const t of ticks) {
|
|
||||||
expect(t).toBeGreaterThanOrEqual(0);
|
it("steps to calendar months and quarters on longer spans", () => {
|
||||||
expect(t).toBeLessThan(length);
|
const year = daily("2025-10-23", 365);
|
||||||
|
const months = dates(year, computeTickPositions(year, 1440));
|
||||||
|
expect(months.length).toBeGreaterThanOrEqual(4);
|
||||||
|
for (const tick of months) expect(tick.endsWith("-01")).toBe(true);
|
||||||
|
|
||||||
|
// Weekly buckets over a year and a bit, on a phone: quarter starts, each
|
||||||
|
// taken by the nearest weekly point.
|
||||||
|
const weeks = Array.from({ length: 58 }, (_, i) => ({
|
||||||
|
timestamp: Date.parse("2025-10-01T12:00:00Z") + i * 7 * DAY,
|
||||||
|
}));
|
||||||
|
for (const tick of dates(weeks, computeTickPositions(weeks, 393))) {
|
||||||
|
const at = Date.parse(`${tick}T12:00:00Z`);
|
||||||
|
const quarter = [0, 3, 6, 9].flatMap((m) =>
|
||||||
|
[2025, 2026, 2027].map((y) => Date.UTC(y, m, 1, 12)),
|
||||||
|
);
|
||||||
|
const off = Math.min(...quarter.map((q) => Math.abs(q - at))) / DAY;
|
||||||
|
expect(off).toBeLessThanOrEqual(3.5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps under the width's cap, with no duplicate or out-of-range index", () => {
|
||||||
|
for (const [width, cap] of [
|
||||||
|
[393, 5],
|
||||||
|
[1440, 8],
|
||||||
|
] as const) {
|
||||||
|
for (const length of [2, 7, 14, 30, 90, 180, 365, 730]) {
|
||||||
|
const data = daily("2024-01-01", length);
|
||||||
|
const ticks = computeTickPositions(data, width);
|
||||||
|
expect(ticks.length).toBeLessThanOrEqual(cap);
|
||||||
|
expect(new Set(ticks).size).toBe(ticks.length);
|
||||||
|
expect([...ticks].sort((a, b) => a - b)).toEqual(ticks);
|
||||||
|
for (const t of ticks) {
|
||||||
|
expect(t).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(t).toBeLessThan(data.length);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -162,56 +162,159 @@ export function getViewportWidth(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v1.4.29 — Recharts ignores the `interval` prop on a numeric
|
* Tick positions for a chart whose x axis is the point index, on a calendar
|
||||||
* `<XAxis type="number" />`. The pulse + mood charts switched to
|
* grid rather than every n-th point.
|
||||||
* 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
|
* Every n-th point read as random dates wherever the series had gaps: on a
|
||||||
* axis can consume via the `ticks` prop. The skip count comes from
|
* 90-day blood-pressure chart with a few missed days the labels fell 18, 14,
|
||||||
* `chooseTickInterval` so the day-aware policy stays in one place.
|
* 13, 17 and 23 days apart, and the forced last label crowded its neighbour.
|
||||||
* The result is clamped to at least 3 ticks (when the chart has
|
* So the step is a calendar one (a day, two days, a week, two weeks, a month,
|
||||||
* enough data) and at most ~12, so the user always sees calendar
|
* a quarter, half a year, a year), the smallest that keeps the labels under
|
||||||
* landmarks without overcrowding.
|
* 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.
|
||||||
*
|
*
|
||||||
* Returns an empty array for zero-length data and `[0]` for a
|
* `timestamp` is the point's day anchor (noon UTC of the day it stands for,
|
||||||
* single-point chart.
|
* or the start of its week or month bucket); its UTC date is the day.
|
||||||
*/
|
*/
|
||||||
export function computeTickPositions(
|
export function computeTickPositions(
|
||||||
chartData: ReadonlyArray<{ timestamp: number } | unknown>,
|
chartData: ReadonlyArray<{ timestamp: number } | unknown>,
|
||||||
viewportWidth: number,
|
viewportWidth: number,
|
||||||
|
layout?: TickLayout,
|
||||||
): number[] {
|
): number[] {
|
||||||
const length = chartData?.length ?? 0;
|
const length = chartData?.length ?? 0;
|
||||||
if (length <= 0) return [];
|
if (length <= 0) return [];
|
||||||
if (length === 1) return [0];
|
if (length === 1) return [0];
|
||||||
|
|
||||||
const lastIndex = length - 1;
|
const days = chartData.map((point) =>
|
||||||
const interval = chooseTickInterval(length, viewportWidth);
|
Math.floor(
|
||||||
// `interval === 0` means "render every tick". Cap the visible tick
|
((point as { timestamp?: number })?.timestamp ?? Number.NaN) / DAY_MS,
|
||||||
// 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.
|
if (days.some((day) => !Number.isFinite(day))) {
|
||||||
const TARGET_MAX_TICKS = 12;
|
return Array.from({ length }, (_, index) => index);
|
||||||
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 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 ticks: number[] = [];
|
const ticksFor = (step: CalendarStep): number[] => {
|
||||||
for (let i = 0; i <= lastIndex; i += step) {
|
const ticks: number[] = [];
|
||||||
ticks.push(i);
|
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;
|
||||||
}
|
}
|
||||||
// Always include the last index so the chart's right edge keeps a
|
const widest = ticksFor(CALENDAR_STEPS[CALENDAR_STEPS.length - 1]!);
|
||||||
// tick label.
|
return widest.length > 0 ? widest : [0];
|
||||||
if (ticks[ticks.length - 1] !== lastIndex) {
|
}
|
||||||
ticks.push(lastIndex);
|
|
||||||
|
/** 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;
|
||||||
}
|
}
|
||||||
return ticks;
|
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;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
|
import { computePaddedYDomain, niceYAxis } from "@/lib/insights/chart-y-domain";
|
||||||
|
|
||||||
describe("computePaddedYDomain", () => {
|
describe("computePaddedYDomain", () => {
|
||||||
it("returns undefined for an empty series", () => {
|
it("returns undefined for an empty series", () => {
|
||||||
@@ -55,3 +55,62 @@ describe("computePaddedYDomain", () => {
|
|||||||
expect(domain![0]).toBeLessThan(-10);
|
expect(domain![0]).toBeLessThan(-10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("niceYAxis", () => {
|
||||||
|
/** Every gap the same, every tick a multiple of the step. */
|
||||||
|
function expectEven(ticks: number[]) {
|
||||||
|
const gaps = ticks
|
||||||
|
.slice(1)
|
||||||
|
.map((t, i) => Number((t - ticks[i]!).toFixed(9)));
|
||||||
|
expect(new Set(gaps).size).toBe(1);
|
||||||
|
const step = gaps[0]!;
|
||||||
|
for (const tick of ticks) {
|
||||||
|
expect(Math.abs(tick / step - Math.round(tick / step))).toBeLessThan(
|
||||||
|
1e-9,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("turns the reported blood-pressure axis into round, even ticks", () => {
|
||||||
|
// Recharts drew 74, 94, 114, 134, 149 from this padded domain: the last
|
||||||
|
// step 15 against 20, the upper bound forced in as a tick.
|
||||||
|
const { domain, ticks } = niceYAxis([73.4, 149.2]);
|
||||||
|
expect(ticks).toEqual([60, 80, 100, 120, 140, 160]);
|
||||||
|
expect(domain).toEqual([60, 160]);
|
||||||
|
expect(expectEven(ticks)).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the padded domain and stays within six labels", () => {
|
||||||
|
for (const [low, high] of [
|
||||||
|
[0, 1],
|
||||||
|
[61.2, 63.9],
|
||||||
|
[0, 10234],
|
||||||
|
[35.9, 37.4],
|
||||||
|
[-3.2, 4.8],
|
||||||
|
[92, 99],
|
||||||
|
[4.1, 8.7],
|
||||||
|
] as const) {
|
||||||
|
const { domain, ticks } = niceYAxis([low, high]);
|
||||||
|
expect(domain[0]).toBeLessThanOrEqual(low);
|
||||||
|
expect(domain[1]).toBeGreaterThanOrEqual(high);
|
||||||
|
expect(ticks.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(ticks.length).toBeLessThanOrEqual(6);
|
||||||
|
expect(ticks[0]).toBe(domain[0]);
|
||||||
|
expect(ticks[ticks.length - 1]).toBe(domain[1]);
|
||||||
|
const step = expectEven(ticks);
|
||||||
|
const mantissa = step / 10 ** Math.floor(Math.log10(step));
|
||||||
|
expect([1, 2, 5]).toContain(Number(mantissa.toFixed(6)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a non-negative domain non-negative and decimals clean", () => {
|
||||||
|
expect(niceYAxis([0.2, 9.6]).domain[0]).toBe(0);
|
||||||
|
const { ticks } = niceYAxis([35.9, 37.4]);
|
||||||
|
for (const tick of ticks) expect(String(tick)).not.toMatch(/\d{4,}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a degenerate domain alone", () => {
|
||||||
|
expect(niceYAxis([5, 5])).toEqual({ domain: [5, 5], ticks: [5, 5] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,3 +51,50 @@ export function computePaddedYDomain(
|
|||||||
const paddingTop = Math.max(span * 0.16, 1);
|
const paddingTop = Math.max(span * 0.16, 1);
|
||||||
return [clampLower(min - paddingBottom), max + paddingTop];
|
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 };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user