mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +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
@@ -112,53 +112,105 @@ describe("chooseTickInterval (v1.4.25 W3b day-aware policy)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeTickPositions (v1.4.29 numeric-axis helper)", () => {
|
||||
// Recharts ignores `interval` on `type="number"` axes; the pulse +
|
||||
// mood charts switched to numeric axes and silently lost the
|
||||
// density policy. `computeTickPositions` translates the policy
|
||||
// into explicit indices the numeric axis can consume.
|
||||
describe("computeTickPositions — ticks on a calendar grid", () => {
|
||||
const DAY = 86_400_000;
|
||||
/** Daily points at noon UTC from `start`, leaving out the `missing` days. */
|
||||
function daily(start: string, count: number, missing: number[] = []) {
|
||||
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", () => {
|
||||
const data = Array.from({ length: 5 }, (_, i) => ({ timestamp: i }));
|
||||
expect(computeTickPositions(data, 393)).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("returns [0] for a single-point chart", () => {
|
||||
it("returns [] for an empty chart and [0] for one point", () => {
|
||||
expect(computeTickPositions([], 393)).toEqual([]);
|
||||
expect(computeTickPositions([{ timestamp: 0 }], 393)).toEqual([0]);
|
||||
});
|
||||
|
||||
it("returns [] for an empty chart", () => {
|
||||
expect(computeTickPositions([], 393)).toEqual([]);
|
||||
it("labels every day of a week where the width holds it, every other on a phone", () => {
|
||||
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", () => {
|
||||
const data = Array.from({ length: 365 }, (_, i) => ({ timestamp: i }));
|
||||
const ticks = computeTickPositions(data, 393);
|
||||
expect(ticks.length).toBeLessThanOrEqual(13);
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(6);
|
||||
// First + last indices always present.
|
||||
expect(ticks[0]).toBe(0);
|
||||
expect(ticks[ticks.length - 1]).toBe(364);
|
||||
it("puts a 90-day chart with missed days on a two-week grid of Mondays", () => {
|
||||
// The reported chart: readings with gaps, labelled every n-th point,
|
||||
// fell 18, 14, 13, 17 and 23 days apart.
|
||||
const data = daily(
|
||||
"2026-07-23",
|
||||
90,
|
||||
[10, 11, 12, 40, 41, 70, 71, 72, 73, 80],
|
||||
);
|
||||
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", () => {
|
||||
const data = Array.from({ length: 30 }, (_, i) => ({ timestamp: i }));
|
||||
const ticks = computeTickPositions(data, 393);
|
||||
// 30 points / 7-day skip → 5 ticks (0, 7, 14, 21, 28) + last (29).
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(4);
|
||||
expect(ticks.length).toBeLessThanOrEqual(6);
|
||||
expect(ticks[0]).toBe(0);
|
||||
expect(ticks[ticks.length - 1]).toBe(29);
|
||||
it("takes the nearest point within half a step when the grid date has none", () => {
|
||||
// 2026-09-14 (a grid Monday) is missing; the 13th or 15th stands in.
|
||||
const data = daily("2026-08-03", 60, [42]);
|
||||
const ticks = dates(data, computeTickPositions(data, 1440));
|
||||
expect(ticks).toContain("2026-08-17");
|
||||
expect(ticks.some((t) => t === "2026-09-13" || t === "2026-09-15")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("never returns duplicate or out-of-bounds indices", () => {
|
||||
for (const length of [2, 7, 14, 30, 90, 180, 365]) {
|
||||
const data = Array.from({ length }, (_, i) => ({ timestamp: i }));
|
||||
const ticks = computeTickPositions(data, 393);
|
||||
expect(new Set(ticks).size).toBe(ticks.length);
|
||||
for (const t of ticks) {
|
||||
expect(t).toBeGreaterThanOrEqual(0);
|
||||
expect(t).toBeLessThan(length);
|
||||
it("does not force the last point in, so the final label never crowds", () => {
|
||||
const data = daily("2026-07-23", 90);
|
||||
const ticks = computeTickPositions(data, 1440);
|
||||
expect(ticks[ticks.length - 1]).not.toBe(data.length - 1);
|
||||
});
|
||||
|
||||
it("steps to calendar months and quarters on longer spans", () => {
|
||||
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
|
||||
* `<XAxis type="number" />`. The pulse + mood charts switched to
|
||||
* numeric axes in v1.4.25 to support multi-band rendering; the
|
||||
* legacy `interval` value silently degraded to "every tick" on
|
||||
* those charts.
|
||||
* Tick positions for a chart whose x axis is the point index, on a calendar
|
||||
* grid rather than every n-th point.
|
||||
*
|
||||
* `computeTickPositions` returns explicit tick indices the numeric
|
||||
* axis can consume via the `ticks` prop. The skip count comes from
|
||||
* `chooseTickInterval` so the day-aware policy stays in one place.
|
||||
* The result is clamped to at least 3 ticks (when the chart has
|
||||
* enough data) and at most ~12, so the user always sees calendar
|
||||
* landmarks without overcrowding.
|
||||
* Every n-th point read as random dates wherever the series had gaps: on a
|
||||
* 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
|
||||
* 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
|
||||
* single-point chart.
|
||||
* `timestamp` is the point's day anchor (noon UTC of the day it stands for,
|
||||
* or the start of its week or month bucket); its UTC date is the day.
|
||||
*/
|
||||
export function computeTickPositions(
|
||||
chartData: ReadonlyArray<{ timestamp: number } | unknown>,
|
||||
viewportWidth: number,
|
||||
layout?: TickLayout,
|
||||
): number[] {
|
||||
const length = chartData?.length ?? 0;
|
||||
if (length <= 0) return [];
|
||||
if (length === 1) return [0];
|
||||
|
||||
const lastIndex = length - 1;
|
||||
const interval = chooseTickInterval(length, viewportWidth);
|
||||
// `interval === 0` means "render every tick". Cap the visible tick
|
||||
// count so dense series (e.g. 365-row pulse) never blow past ~12
|
||||
// labels — pick a step that hits ~6 ticks if the policy would
|
||||
// otherwise produce too many.
|
||||
const TARGET_MAX_TICKS = 12;
|
||||
let step = interval > 0 ? interval + 1 : 1;
|
||||
if (length / step > TARGET_MAX_TICKS) {
|
||||
step = Math.ceil(length / TARGET_MAX_TICKS);
|
||||
}
|
||||
// Floor on visible tick count — never fewer than 3 ticks unless
|
||||
// the data simply doesn't have that many points.
|
||||
const minTicks = Math.min(3, length);
|
||||
if (length / step < minTicks) {
|
||||
step = Math.max(1, Math.floor(length / minTicks));
|
||||
const days = chartData.map((point) =>
|
||||
Math.floor(
|
||||
((point as { timestamp?: number })?.timestamp ?? Number.NaN) / DAY_MS,
|
||||
),
|
||||
);
|
||||
if (days.some((day) => !Number.isFinite(day))) {
|
||||
return Array.from({ length }, (_, index) => index);
|
||||
}
|
||||
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[] = [];
|
||||
for (let i = 0; i <= lastIndex; i += step) {
|
||||
ticks.push(i);
|
||||
const ticksFor = (step: CalendarStep): number[] => {
|
||||
const ticks: number[] = [];
|
||||
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
|
||||
// tick label.
|
||||
if (ticks[ticks.length - 1] !== lastIndex) {
|
||||
ticks.push(lastIndex);
|
||||
const widest = ticksFor(CALENDAR_STEPS[CALENDAR_STEPS.length - 1]!);
|
||||
return widest.length > 0 ? widest : [0];
|
||||
}
|
||||
|
||||
/** 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 { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
|
||||
import { computePaddedYDomain, niceYAxis } from "@/lib/insights/chart-y-domain";
|
||||
|
||||
describe("computePaddedYDomain", () => {
|
||||
it("returns undefined for an empty series", () => {
|
||||
@@ -55,3 +55,62 @@ describe("computePaddedYDomain", () => {
|
||||
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);
|
||||
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