Chart Axis & Scales
Dependency-free chart math: linScale with .invert (hit-testing), niceTicks (1/2/5 x 10^n), lap/gap/clock formatters, linePath. The backbone under every SVG chart.
// Pure, dependency-free chart helpers (SVG- and canvas-agnostic). Shared by every
// Track-1 chart (compare panels + season standings evolution). No React, no DOM.
export interface Scale {
(v: number): number;
invert: (px: number) => number;
domain: [number, number];
range: [number, number];
}
/** linear scale domain -> range, with .invert for hit-testing */
export function linScale(domain: [number, number], range: [number, number]): Scale {
const [d0, d1] = domain;
const [r0, r1] = range;
const m = d1 === d0 ? 0 : (r1 - r0) / (d1 - d0);
const f = ((v: number) => r0 + (v - d0) * m) as Scale;
f.invert = (px: number) => (m === 0 ? d0 : d0 + (px - r0) / m);
f.domain = domain;
f.range = range;
return f;
}
/** "nice" round tick values across [min,max] (1/2/5 * 10^n steps) */
export function niceTicks(min: number, max: number, count = 5): number[] {
if (!isFinite(min) || !isFinite(max) || min === max) return [min];
const step0 = (max - min) / Math.max(1, count);
const mag = Math.pow(10, Math.floor(Math.log10(step0)));
const norm = step0 / mag;
const step = (norm >= 5 ? 5 : norm >= 2 ? 2 : 1) * mag;
const start = Math.ceil(min / step) * step;
const out: number[] = [];
for (let v = start; v <= max + step * 1e-6; v += step) out.push(Number(v.toPrecision(12)));
return out;
}
/** lap time ms -> "1:18.305" (or "18.305" under a minute) */
export function fmtLapMs(ms: number): string {
if (!isFinite(ms) || ms <= 0) return "—";
const m = Math.floor(ms / 60000);
const s = (ms % 60000) / 1000;
return m > 0 ? `${m}:${s.toFixed(3).padStart(6, "0")}` : s.toFixed(3);
}
/** signed gap seconds -> "+1.234" / "−0.500" (true minus glyph) */
export function fmtGap(s: number): string {
if (!isFinite(s)) return "—";
const sign = s > 0 ? "+" : s < 0 ? "−" : "";
return `${sign}${Math.abs(s).toFixed(3)}`;
}
/** ms -> "M:SS" short clock for axis labels */
export function fmtClockShort(ms: number): string {
const total = Math.floor(ms / 1000);
const m = Math.floor(total / 60);
return `${m}:${String(total % 60).padStart(2, "0")}`;
}
/** path string for a polyline from [x,y] screen points (skips null = gap break) */
export function linePath(points: ([number, number] | null)[]): string {
let d = "";
let pen = false;
for (const p of points) {
if (!p) {
pen = false;
continue;
}
d += `${pen ? "L" : "M"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`;
pen = true;
}
return d;
}