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. */