mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The line and bar charts under a Coach reply let recharts place the y-axis ticks over an auto domain, and after thinning the ones that would collide a blood pressure line could read 75, 90, 105, 135. The chart now computes a nice domain and hands explicit ticks one step apart (1, 2, 2.5 or 5 times a power of ten); bars keep their zero baseline.
85 lines
2.7 KiB
TypeScript
85 lines
2.7 KiB
TypeScript
/**
|
|
* An evenly stepped value axis: a domain that starts and ends on a tick,
|
|
* and ticks one "nice" step apart (1, 2, 2.5 or 5 times a power of ten).
|
|
*
|
|
* Handing recharts a domain of `["auto", "auto"]` and letting it place the
|
|
* ticks can leave uneven labels once it thins the ones that would collide
|
|
* (75, 90, 105, 135 for a blood pressure line). Explicit ticks on an explicit
|
|
* domain keep every step the same.
|
|
*
|
|
* Pure: no recharts, no React.
|
|
*/
|
|
|
|
export interface NiceAxis {
|
|
domain: [number, number];
|
|
ticks: number[];
|
|
}
|
|
|
|
/** The nice step for a raw step: 1, 2, 2.5, 5 or 10 times a power of ten. */
|
|
function niceStep(raw: number): number {
|
|
const exponent = Math.floor(Math.log10(raw));
|
|
const magnitude = 10 ** exponent;
|
|
const fraction = raw / magnitude;
|
|
const nice =
|
|
fraction <= 1
|
|
? 1
|
|
: fraction <= 2
|
|
? 2
|
|
: fraction <= 2.5
|
|
? 2.5
|
|
: fraction <= 5
|
|
? 5
|
|
: 10;
|
|
return nice * magnitude;
|
|
}
|
|
|
|
/** Decimal places a step needs, so `0.1 * 3` prints as 0.3. */
|
|
function decimalsOf(step: number): number {
|
|
if (Number.isInteger(step)) return 0;
|
|
const text = step.toString();
|
|
const dot = text.indexOf(".");
|
|
return dot === -1 ? 0 : text.length - dot - 1;
|
|
}
|
|
|
|
/**
|
|
* The axis for `values`, with at most `maxTicks` ticks. `zero` pins the
|
|
* lower end at 0 for non-negative data (bars start at the baseline). Null
|
|
* when there is no finite value to scale.
|
|
*/
|
|
export function niceAxis(
|
|
values: ReadonlyArray<number | null | undefined>,
|
|
options: { maxTicks?: number; zero?: boolean } = {},
|
|
): NiceAxis | null {
|
|
const finite = values.filter(
|
|
(v): v is number => typeof v === "number" && Number.isFinite(v),
|
|
);
|
|
if (finite.length === 0) return null;
|
|
const maxTicks = Math.max(2, options.maxTicks ?? 5);
|
|
let min = Math.min(...finite);
|
|
let max = Math.max(...finite);
|
|
if (options.zero && min >= 0) min = 0;
|
|
if (min === max) {
|
|
// One level: give it room on both sides (or above the baseline).
|
|
const pad = min === 0 ? 1 : Math.abs(min) * 0.1;
|
|
max += pad;
|
|
if (!(options.zero && min === 0)) min -= pad;
|
|
}
|
|
|
|
let step = niceStep((max - min) / (maxTicks - 1));
|
|
let lo = Math.floor(min / step) * step;
|
|
let hi = Math.ceil(max / step) * step;
|
|
// Rounding both ends out can cost a tick; widen the step until it fits.
|
|
while (Math.round((hi - lo) / step) + 1 > maxTicks) {
|
|
step = niceStep(step * 1.01);
|
|
lo = Math.floor(min / step) * step;
|
|
hi = Math.ceil(max / step) * step;
|
|
}
|
|
|
|
const decimals = decimalsOf(step);
|
|
const count = Math.round((hi - lo) / step) + 1;
|
|
const ticks = Array.from({ length: count }, (_, i) =>
|
|
Number((lo + i * step).toFixed(decimals)),
|
|
);
|
|
return { domain: [ticks[0], ticks[ticks.length - 1]], ticks };
|
|
}
|