The Kit/Utils

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.

axis.ts
// 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;
}
← The whole kit