diff --git a/src/components/charts/__tests__/health-chart-axes.browser.test.tsx b/src/components/charts/__tests__/health-chart-axes.browser.test.tsx index 00257dad3..289218ea8 100644 --- a/src/components/charts/__tests__/health-chart-axes.browser.test.tsx +++ b/src/components/charts/__tests__/health-chart-axes.browser.test.tsx @@ -10,13 +10,15 @@ import { rightLabelRoom, targetZoneLayers, } from "../health-chart/chart-layers"; +import { labelsBesidePlot, xTickLayout } from "../health-chart/series-math"; /** * 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. + * reading. On a phone the plot keeps no room beside it: the labels are drawn + * whole inside it, and a 90-day chart still gets its month grid. */ const DAY = 86_400_000; @@ -35,7 +37,20 @@ const t = (key: string) => const label = "Your average"; const zoneLabel = "Systolic target"; -function Chart() { +const PHONE_WIDTH = 324; +// HealthChart's default y axis width. +const Y_AXIS_WIDTH = 76; + +function Chart({ width = 720 }: { width?: number }) { + const besidePlot = labelsBesidePlot(width); + const rightRoom = besidePlot ? rightLabelRoom([label, zoneLabel]) : 0; + const ticks = besidePlot + ? computeTickPositions(POINTS, 1440) + : computeTickPositions( + POINTS, + 390, + xTickLayout(width, Y_AXIS_WIDTH, rightRoom, "12/31/2000"), + ); const padded = computePaddedYDomain( POINTS.map((p) => p.BLOOD_PRESSURE_SYS), ["BLOOD_PRESSURE_SYS"], @@ -43,12 +58,12 @@ function Chart() { const y = niceYAxis(padded); return ( new Date(POINTS[Math.round(value)]!.timestamp) .toISOString() @@ -71,7 +86,7 @@ function Chart() { tick={{ fontSize: 11 }} domain={y.domain} ticks={y.ticks} - width={44} + width={Y_AXIS_WIDTH} /> {targetZoneLayers( @@ -90,6 +105,7 @@ function Chart() { }, ], POINTS.length - 1, + besidePlot, )} {baselineLayers( true, @@ -97,6 +113,7 @@ function Chart() { ["#888"], new Map([["BLOOD_PRESSURE_SYS", 121]]), t, + besidePlot, )} ); @@ -182,4 +199,39 @@ describe("value chart axes", () => { expect(overlaps).toBe(false); } }); + + it("on a phone keeps no room beside the plot and draws both labels whole inside it", async () => { + const screen = await render(); + await settle(); + const root = screen.container; + const plot = root + .querySelector(".recharts-cartesian-grid")! + .getBoundingClientRect(); + const svg = root + .querySelector("svg.recharts-surface")! + .getBoundingClientRect(); + // The plot reaches the chart's right margin (8 px) and nothing more. + expect(svg.right - plot.right).toBeLessThanOrEqual(8 + 10 + 1); + for (const text of [label, zoneLabel]) { + const node = [...root.querySelectorAll(".recharts-label")].find( + (n) => n.textContent === text, + )!; + expect(node).toBeDefined(); + const box = node.getBoundingClientRect(); + expect(box.left).toBeGreaterThanOrEqual(plot.left); + expect(box.right).toBeLessThanOrEqual(plot.right + 1); + } + }); + + it("on a phone labels a 90-day chart on the month grid, three dates", async () => { + const screen = await render(); + await settle(); + // September 1 and October 1 have no reading, so their ticks take the + // nearest day that has one. + expect(tickTexts(screen.container, "xAxis")).toEqual([ + "08-01", + "08-31", + "09-30", + ]); + }); }); diff --git a/src/components/charts/__tests__/health-chart-axis-format.test.ts b/src/components/charts/__tests__/health-chart-axis-format.test.ts new file mode 100644 index 000000000..c13810d7b --- /dev/null +++ b/src/components/charts/__tests__/health-chart-axis-format.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + axisValueFormatters, + gridYear, + labelsBesidePlot, + xTickLayout, +} from "../health-chart/series-math"; + +/** + * The value axis shows the decimals its tick step needs and no more, a phone + * keeps the chart's labels inside the plot, and an x label is as wide as a + * date in the reader's format. + */ + +const fmt = { + number: (value: number, digits: number) => value.toFixed(digits), + integer: (value: number) => String(Math.round(value)), +}; + +describe("axis value format", () => { + it("writes a step of one as whole numbers, not 57.0, 58.0", () => { + const { formatAxisValue, useDecimalAxis } = axisValueFormatters( + [57, 62], + fmt, + [57, 58, 59, 60, 61, 62], + ); + expect(useDecimalAxis).toBe(false); + expect(formatAxisValue(58)).toBe("58"); + }); + + it("keeps one decimal for a step of 0.5 or 0.2, two for 0.05", () => { + expect( + axisValueFormatters([82, 84], fmt, [82, 82.5, 83]).formatAxisValue(82.5), + ).toBe("82.5"); + expect( + axisValueFormatters([82, 83], fmt, [82, 82.2, 82.4]).formatAxisValue( + 82.2, + ), + ).toBe("82.2"); + expect( + axisValueFormatters([1, 1.2], fmt, [1, 1.05, 1.1]).formatAxisValue(1.05), + ).toBe("1.05"); + }); + + it("without ticks, keeps one decimal while the domain spans under six units", () => { + expect(axisValueFormatters([82, 85], fmt).useDecimalAxis).toBe(true); + expect(axisValueFormatters([80, 90], fmt).useDecimalAxis).toBe(false); + }); +}); + +describe("labels beside the plot", () => { + it("are kept on a tablet or wider and dropped into the plot on a phone", () => { + expect(labelsBesidePlot(324)).toBe(false); + expect(labelsBesidePlot(519)).toBe(false); + expect(labelsBesidePlot(520)).toBe(true); + expect(labelsBesidePlot(720)).toBe(true); + }); +}); + +describe("x label width", () => { + it("follows the length of a date in the reader's format", () => { + const short = xTickLayout(324, 76, 0, "12/31/2000")!; + const long = xTickLayout(324, 76, 0, "2000. 12. 31.")!; + expect(short.labelWidth).toBeLessThan(70); + expect(long.labelWidth).toBeGreaterThan(short.labelWidth); + expect(short.plotWidth).toBe(324 - 16 - 76 - 20); + }); +}); + +describe("yearly grid labels", () => { + it("name the year the tick marks, also when it sits on a December point", () => { + expect(gridYear(Date.UTC(2025, 0, 1, 12))).toBe(2025); + expect(gridYear(Date.UTC(2024, 11, 18, 12))).toBe(2025); + expect(gridYear(Date.UTC(2025, 1, 10, 12))).toBe(2025); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index c7c953124..57b2b53ad 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -51,6 +51,7 @@ import { axisValueFormatters, dayFocusFor, getTypeLabel, + labelsBesidePlot, makeDayKeyFormatter, pointIndexXAxis, resolveAnnotationPositions, @@ -66,6 +67,9 @@ import { useHealthChartSeries } from "./health-chart/use-health-chart-series"; import { useHealthChartView } from "./health-chart/use-health-chart-view"; import { resolveMiniRangePoints } from "./mini-window"; +/** A date with a two-digit day and month: the widest an x label gets. */ +const X_LABEL_SAMPLE_DATE = new Date(Date.UTC(2000, 11, 31, 12)); + /** * A metric over time: the line, the overlays a person can switch on (the * 7-day average, the trend, the target range, the prior period), reference @@ -230,7 +234,10 @@ export function HealthChart({ const [containerWidth, setContainerWidth] = useState(null); const { useDecimalAxis, formatAxisValue, formatTooltipValue } = - axisValueFormatters(yDomain, fmt); + axisValueFormatters(yDomain, fmt, yTicks); + // Until the container is measured, the viewport stands in for its width. + const besidePlot = labelsBesidePlot(containerWidth ?? viewportWidth); + const rightRoom = besidePlot ? overlays.rightRoom : 0; const { primaryType, chartAriaLabel, dataTableColumns } = useHealthChartLabels({ @@ -398,7 +405,7 @@ export function HealthChart({ data={chartDataWithCompare ?? chartData} margin={{ top: 10, - right: 8 + overlays.rightRoom, + right: 8 + rightRoom, bottom: 8, left: 8, }} @@ -418,7 +425,8 @@ export function HealthChart({ layout: xTickLayout( containerWidth, yAxisWidth, - overlays.rightRoom, + rightRoom, + tzFmt.date(X_LABEL_SAMPLE_DATE), ), })} /> @@ -443,7 +451,11 @@ export function HealthChart({ : undefined } /> - {targetZoneLayers(visibleTargetZones, maxPointIndex)} + {targetZoneLayers( + visibleTargetZones, + maxPointIndex, + besidePlot, + )} {annotationLayers(annotationPositions, viewportWidth)} {verticalMarkerLayers(verticalMarkerPositions, yDomain)} {openDayLayers(openDayIndex, openDayPoint, types, colors)} @@ -453,6 +465,7 @@ export function HealthChart({ colors, personalBaselines, t, + besidePlot, )} ) : null} {/* The points the chart just drew, as a table, read from the same diff --git a/src/components/charts/health-chart/chart-layers.tsx b/src/components/charts/health-chart/chart-layers.tsx index ca23ddeb3..9a8a56c59 100644 --- a/src/components/charts/health-chart/chart-layers.tsx +++ b/src/components/charts/health-chart/chart-layers.tsx @@ -28,6 +28,7 @@ type Translate = (key: string) => string; export function targetZoneLayers( zones: VisibleTargetZone[], maxPointIndex: number, + besidePlot = true, ) { return [ zones.map((zone) => ( @@ -65,7 +66,9 @@ export function targetZoneLayers( zone.label ? { value: zone.label, - position: "right", + // On a narrow chart, inside the zone at its top right: the + // plot keeps no room beside it there (`labelsBesidePlot`). + position: besidePlot ? "right" : "insideTopRight", offset: BASELINE_LABEL_GAP, fill: zone.textColor ?? zone.color, fontSize: BASELINE_LABEL_FONT_SIZE, @@ -231,7 +234,8 @@ export function rightLabelRoom( * 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, * 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. + * the plot, in the room `rightLabelRoom` keeps there, or inside the plot on a + * chart too narrow to keep that room. */ export function baselineLayers( showTrend: boolean, @@ -239,6 +243,7 @@ export function baselineLayers( colors: string[], personalBaselines: Map, t: Translate, + besidePlot = true, ) { return ( showTrend && @@ -258,7 +263,9 @@ export function baselineLayers( i === 0 ? { value: t("charts.personalBaseline"), - position: "right", + // Inside the plot at the top left on a narrow chart, as + // before the room beside it existed. + position: besidePlot ? "right" : "insideTopLeft", offset: BASELINE_LABEL_GAP, fill: "var(--muted-foreground)", fontSize: BASELINE_LABEL_FONT_SIZE, diff --git a/src/components/charts/health-chart/series-math.ts b/src/components/charts/health-chart/series-math.ts index 6ce3eeba5..9f1a115e9 100644 --- a/src/components/charts/health-chart/series-math.ts +++ b/src/components/charts/health-chart/series-math.ts @@ -7,10 +7,7 @@ import { chartPointDayKey } from "@/components/day/chart-day"; import { isWholeNumberType } from "@/components/day/use-day-value-format"; import { translateOrNull } from "@/lib/i18n/translate-code"; -import { - computeTickPositions, - type TickLayout, -} from "@/lib/charts/x-axis-density"; +import { computeTickGrid, type TickLayout } from "@/lib/charts/x-axis-density"; import type { ChartDataPoint } from "./types"; @@ -229,9 +226,11 @@ export function resolveAnnotationPositions( } /** - * 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). + * The value axis's and the tooltip's number formats. The axis shows as many + * decimals as its tick step needs: none for a step of 1 or more (57, 58, 59 + * bpm rather than 57.0, 58.0, 59.0), one for 0.5 or 0.2, two for 0.05. Without + * ticks, one decimal 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, @@ -239,17 +238,43 @@ export function axisValueFormatters( number: (value: number, digits: number) => string; integer: (value: number) => string; }, + yTicks?: ReadonlyArray, ) { - const span = yDomain ? yDomain[1] - yDomain[0] : null; - const useDecimalAxis = span !== null && span < 6; + const digits = axisDecimals(yDomain, yTicks); return { - useDecimalAxis, + useDecimalAxis: digits > 0, formatAxisValue: (value: number) => - useDecimalAxis ? fmt.number(value, 1) : fmt.integer(value), + digits > 0 ? fmt.number(value, digits) : fmt.integer(value), formatTooltipValue: (value: number) => fmt.number(value, 1), }; } +function axisDecimals( + yDomain: [number, number] | undefined, + yTicks?: ReadonlyArray, +): number { + if (yTicks && yTicks.length >= 2) { + const step = Math.abs(yTicks[1]! - yTicks[0]!); + if (step >= 1 || step === 0) return 0; + return Math.min(2, Math.ceil(-Math.log10(step) - 1e-9)); + } + const span = yDomain ? yDomain[1] - yDomain[0] : null; + return span !== null && span < 6 ? 1 : 0; +} + +/** + * Below this chart width the labels beside the plot (the average's, the target + * zones') move into it, as they were drawn before 1.46. On a phone the room + * beside the plot took about a third of its width, and the legend, wrapping + * into the narrower space, took more of its height. + */ +export const LABELS_BESIDE_PLOT_MIN_WIDTH = 520; + +/** Whether a chart this wide keeps its labels beside the plot. */ +export function labelsBesidePlot(width: number): boolean { + return width >= LABELS_BESIDE_PLOT_MIN_WIDTH; +} + /** * 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 @@ -291,24 +316,45 @@ export function dayFocusFor( * 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. + * + * The label's width comes from a date in the reader's own format: at the + * axis's 11 px a digit or a separator is under 6 px wide (10/01/2026 draws at + * 58 px). A fixed 80 px kept the month grid off a 90-day chart on a phone, + * whose month starts sit about 78 px apart, and left it one date. */ export function xTickLayout( containerWidth: number | null, yAxisWidth: number, rightRoom: number, -): { plotWidth: number; labelWidth: number } | undefined { + sampleLabel?: string, +): TickLayout | 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 }; + const textWidth = sampleLabel ? Math.ceil(sampleLabel.length * 5.8) : 58; + return { + plotWidth, + labelWidth: textWidth + 8, + yearLabelWidth: Math.ceil(4 * 5.8) + 8, + }; +} + +/** + * The year a tick on the yearly grid stands for. The tick sits on the point + * nearest January 1, which may be one from the December before when the + * series has a gap there; it still marks the new year. + */ +export function gridYear(timestamp: number): number { + const date = new Date(timestamp); + const year = date.getUTCFullYear(); + return date.getUTCMonth() >= 6 ? year + 1 : year; } /** * 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`). + * explicit tick positions (`computeTickGrid`), and a yearly grid is labelled + * with the year alone. */ export function pointIndexXAxis({ chartData, @@ -321,6 +367,7 @@ export function pointIndexXAxis({ viewportWidth: number; layout: TickLayout | undefined; }) { + const grid = computeTickGrid(chartData ?? [], viewportWidth, layout); return { type: "number" as const, dataKey: "pointIndex", @@ -329,11 +376,13 @@ export function pointIndexXAxis({ 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), + tickFormatter: (value: number) => { + const timestamp = chartData?.[Math.round(value)]?.timestamp ?? Date.now(); + return grid.yearly + ? String(gridYear(timestamp)) + : formatDate(new Date(timestamp)); + }, + ticks: grid.ticks, padding: { left: 10, right: 10 }, tickMargin: 10, }; diff --git a/src/lib/charts/__tests__/x-axis-density.test.ts b/src/lib/charts/__tests__/x-axis-density.test.ts index fac5dd0d6..b905519e4 100644 --- a/src/lib/charts/__tests__/x-axis-density.test.ts +++ b/src/lib/charts/__tests__/x-axis-density.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { chooseTickInterval, + computeTickGrid, computeTickPositions, resolveTargetTickCount, } from "../x-axis-density"; @@ -196,6 +197,35 @@ describe("computeTickPositions — ticks on a calendar grid", () => { } }); + it("labels a long history on a phone by the year, one tick a year", () => { + // Four and a half years in monthly points on a 212 px plot: a full date + // fits only every other year, the year alone fits every year. + const months = Array.from({ length: 55 }, (_, i) => ({ + timestamp: Date.UTC(2022, 3 + i, 1, 12), + })); + const layout = { plotWidth: 212, labelWidth: 66, yearLabelWidth: 32 }; + const grid = computeTickGrid(months, 390, layout); + expect(grid.yearly).toBe(true); + expect(dates(months, grid.ticks)).toEqual([ + "2023-01-01", + "2024-01-01", + "2025-01-01", + "2026-01-01", + ]); + // Without the narrow year label, the same plot holds a date every two + // years only. + const full = computeTickGrid(months, 390, { + plotWidth: 212, + labelWidth: 66, + }); + expect(full.ticks.length).toBe(2); + }); + + it("does not call a month grid yearly", () => { + const year = daily("2025-10-23", 365); + expect(computeTickGrid(year, 1440).yearly).toBe(false); + }); + it("keeps under the width's cap, with no duplicate or out-of-range index", () => { for (const [width, cap] of [ [393, 5], diff --git a/src/lib/charts/x-axis-density.ts b/src/lib/charts/x-axis-density.ts index 204c2005f..63e257933 100644 --- a/src/lib/charts/x-axis-density.ts +++ b/src/lib/charts/x-axis-density.ts @@ -169,7 +169,7 @@ export function getViewportWidth(): number { * 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 + * a quarter, half a year, a year, two years), 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 @@ -184,9 +184,22 @@ export function computeTickPositions( viewportWidth: number, layout?: TickLayout, ): number[] { + return computeTickGrid(chartData, viewportWidth, layout).ticks; +} + +/** + * The tick positions and whether they fall on a grid of whole years, where a + * label needs only the year: "2024" rather than "01/01/2024". A year label is + * also narrower, so on a phone every year of a long history gets one. + */ +export function computeTickGrid( + chartData: ReadonlyArray<{ timestamp: number } | unknown>, + viewportWidth: number, + layout?: TickLayout, +): { ticks: number[]; yearly: boolean } { const length = chartData?.length ?? 0; - if (length <= 0) return []; - if (length === 1) return [0]; + if (length <= 0) return { ticks: [], yearly: false }; + if (length === 1) return { ticks: [0], yearly: false }; const days = chartData.map((point) => Math.floor( @@ -194,7 +207,10 @@ export function computeTickPositions( ), ); if (days.some((day) => !Number.isFinite(day))) { - return Array.from({ length }, (_, index) => index); + return { + ticks: Array.from({ length }, (_, index) => index), + yearly: false, + }; } const first = days[0]!; const last = days[length - 1]!; @@ -224,28 +240,42 @@ export function computeTickPositions( // 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[]) => { + const fits = (ticks: number[], step: CalendarStep) => { if (!layout) return ticks.length <= maxTicks; const perIndex = layout.plotWidth / Math.max(1, length - 1); + const labelWidth = + isYearly(step) && layout.yearLabelWidth + ? layout.yearLabelWidth + : layout.labelWidth; return ticks.every( - (tick, i) => - i === 0 || (tick - ticks[i - 1]!) * perIndex >= layout.labelWidth, + (tick, i) => i === 0 || (tick - ticks[i - 1]!) * perIndex >= 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; + if (ticks.length > 0 && fits(ticks, step)) { + return { ticks, yearly: isYearly(step) }; + } } - const widest = ticksFor(CALENDAR_STEPS[CALENDAR_STEPS.length - 1]!); - return widest.length > 0 ? widest : [0]; + const widestStep = CALENDAR_STEPS[CALENDAR_STEPS.length - 1]!; + const widest = ticksFor(widestStep); + return widest.length > 0 + ? { ticks: widest, yearly: isYearly(widestStep) } + : { ticks: [0], yearly: false }; +} + +function isYearly(step: CalendarStep): boolean { + return step.kind === "months" && step.size >= 12; } /** Where the ticks are drawn: the plot's width and a label's width, in px. */ export interface TickLayout { plotWidth: number; labelWidth: number; + /** A year-only label's width, for the yearly grid; else `labelWidth`. */ + yearLabelWidth?: number; } const DAY_MS = 86_400_000; @@ -271,6 +301,7 @@ const CALENDAR_STEPS: ReadonlyArray = [ { kind: "months", size: 3, approxDays: 91 }, { kind: "months", size: 6, approxDays: 182 }, { kind: "months", size: 12, approxDays: 365 }, + { kind: "months", size: 24, approxDays: 730 }, ]; /** The grid's dates (as day numbers) from `first` to `last`, both included. */