reelcn
Libraries

Libraries

Chart Scale

Pure math shared by the data components: nice axis ticks, linear scales, an on-theme series palette and jitter-free number formatting.

Install

Usage

const ticks = niceTicks(0, 87); // [0, 20, 40, 60, 80, 100]
const y = scaleLinear([ticks[0], ticks[ticks.length - 1]], [400, 0]);
const colors = palette(theme.colors.accent, theme.colors.highlight, theme.colors.foreground, 4);
const label = formatter([12.5, 40], "en-US")(12.5); // "12.5"

Source

registry/items/chart-scale.ts
/**
 * @title Chart Scale
 * @category lib
 * @description Pure math shared by the data components: nice axis ticks, linear scales, an on-theme series palette and jitter-free number formatting.
 * @tags chart, axis, ticks, scale, palette, format
 * @example
 * const ticks = niceTicks(0, 87); // [0, 20, 40, 60, 80, 100]
 * const y = scaleLinear([ticks[0], ticks[ticks.length - 1]], [400, 0]);
 * const colors = palette(theme.colors.accent, theme.colors.highlight, theme.colors.foreground, 4);
 * const label = formatter([12.5, 40], "en-US")(12.5); // "12.5"
 */

/** Drops float noise such as 0.6000000000000001 and turns -0 into 0. */
const clean = (value: number) => Number(value.toPrecision(12));

/**
 * Round axis ticks covering [min, max] on a 1-2-5 step. Zero is always a tick when the domain crosses it.
 * `count` is a target, not a promise.
 */
export function niceTicks(min: number, max: number, count = 5): number[] {
  let lo = Math.min(min, max);
  let hi = Math.max(min, max);
  if (!isFinite(lo) || !isFinite(hi)) return [0, 1];
  if (lo === hi) {
    if (lo === 0) hi = 1;
    else if (lo > 0) lo = 0;
    else hi = 0;
  }
  const raw = (hi - lo) / Math.max(1, count);
  const magnitude = 10 ** Math.floor(Math.log10(raw));
  const norm = raw / magnitude;
  let nice = 10;
  for (const candidate of [1, 2, 5]) {
    if (norm <= candidate + 1e-9) {
      nice = candidate;
      break;
    }
  }
  const step = nice * magnitude;
  // The epsilon stops 0.6 / 0.2 = 2.9999999999999996 from adding a stray tick.
  const first = Math.floor(lo / step + 1e-9);
  const last = Math.ceil(hi / step - 1e-9);
  const ticks: number[] = [];
  for (let i = first; i <= last; i++) ticks.push(clean(i * step));
  return ticks;
}

/** Maps `domain` onto `range` linearly. Pass an inverted range (`[bottom, top]`) for SVG y axes. */
export function scaleLinear(domain: [number, number], range: [number, number]): (value: number) => number {
  const [d0, d1] = domain;
  const [r0, r1] = range;
  if (d0 === d1) return () => (r0 + r1) / 2;
  return (value) => r0 + ((value - d0) / (d1 - d0)) * (r1 - r0);
}

/**
 * Series colors that stay on-theme: the accent, then the highlight, then `color-mix` blends of the two with the
 * foreground and with transparency.
 */
export function palette(accent: string, highlight: string, foreground: string, count: number): string[] {
  const recipes: [string, number, string][] = [
    [accent, 50, highlight],
    [accent, 55, foreground],
    [highlight, 55, foreground],
    [accent, 60, "transparent"],
    [highlight, 60, "transparent"],
    [foreground, 50, "transparent"],
  ];
  const colors: string[] = [];
  for (let i = 0; i < count; i++) {
    if (i === 0) colors.push(accent);
    else if (i === 1) colors.push(highlight);
    else {
      const [a, weight, b] = recipes[(i - 2) % recipes.length];
      const round = Math.floor((i - 2) / recipes.length);
      colors.push(`color-mix(in srgb, ${a} ${Math.max(10, weight - round * 20)}%, ${b})`);
    }
  }
  return colors;
}

/** Decimal places needed to write every value exactly, capped at 3. */
export function decimals(values: number[]): number {
  let most = 0;
  for (const value of values) {
    if (!isFinite(value)) continue;
    const text = String(Math.abs(value));
    const exponent = text.indexOf("e-");
    const dot = text.indexOf(".");
    const places = exponent >= 0 ? Number(text.slice(exponent + 2)) : dot >= 0 ? text.length - dot - 1 : 0;
    most = Math.max(most, places);
  }
  return Math.min(most, 3);
}

/**
 * Number formatter for animated labels. Without `format` it holds the precision of `values`, so a label counting up
 * to 12.5 reads 3.0, 7.4, 12.5 instead of jumping between 3, 7.41 and 12.5.
 */
export function formatter(
  values: number[],
  locale?: string,
  format?: Intl.NumberFormatOptions,
): (value: number) => string {
  const places = decimals(values);
  const numberFormat = new Intl.NumberFormat(
    locale,
    format ?? { minimumFractionDigits: places, maximumFractionDigits: places },
  );
  return (value) => numberFormat.format(value);
}

Related