Skip to the content

Capsid menu

src/render/data.ts

174 lines and 23 functions in DrDustinEdwards/enarratio, at commit 4c5695a on 2026-10-24. This file on GitHub.

Functions

The functions of data.ts
FunctionLinesComplexityCoveredCRAPMutation
defaultFormat10 to 121100%OkCRAP 1Watch66.7%
readNumber15 to 233100%OkCRAP 3Ok90.9%
readNumberOrNull26 to 353100%OkCRAP 3Ok87.5%
readCategory38 to 50583%WatchCRAP 5.1Critical44.4%
readLabel53 to 551100%OkCRAP 1Ok100%
toDate58 to 666100%WatchCRAP 6No data: changed since the last mutation run
readDate69 to 782100%OkCRAP 2No data: changed since the last mutation run
domainOf86 to 1087100%WatchCRAP 7Ok93.8%
<anonymous:104>104 to 1041100%OkCRAP 1No data: changed since the last mutation run
<anonymous:105>105 to 1051100%OkCRAP 1No data: changed since the last mutation run
markKey114 to 1161100%OkCRAP 1Ok100%
minOf119 to 1233100%OkCRAP 3Ok80%
maxOf126 to 1303100%OkCRAP 3Ok80%
requireSize133 to 1374100%OkCRAP 4Ok93.3%
calendarDates147 to 1583100%OkCRAP 3Ok83.3%
<anonymous:149>149 to 1534100%OkCRAP 4No data: changed since the last mutation run
<anonymous:155>155 to 1551100%OkCRAP 1No data: changed since the last mutation run
<anonymous:157>157 to 1571100%OkCRAP 1No data: changed since the last mutation run
rampStep161 to 1653100%OkCRAP 3Ok83.3%
rampThresholds171 to 1855100%OkCRAP 5Ok93.8%
<anonymous:178>178 to 1781100%OkCRAP 1No data: changed since the last mutation run
formatInstant188 to 1922100%OkCRAP 2Ok80%
pad189 to 1891100%OkCRAP 1Ok100%

Imports

It imports no other module of this repo. Imported by src/lite/bar.ts, src/plot/heatmap.ts, src/plot/scatter.ts, src/primitives/heat-strip.ts, src/primitives/sparkline.ts, src/primitives/timeline.ts, src/render/bar-model.ts, src/render/line-model.ts, src/science/genome.ts, src/science/network.ts, src/science/titer.ts.

Source

/** Helpers for reading caller data by key, with errors that name the chart, row and field. */

/** The keys of `T` whose values are assignable to `V`. */
export type KeysOfType<T, V> = {
  [K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T] &
  string;

/** Formats a number for tooltips and tables when the caller gives no formatter. */
export function defaultFormat(value: number): string {
  return value.toLocaleString("en-US", { maximumFractionDigits: 6 });
}

/** Reads a finite number, or throws naming where it was expected. */
export function readNumber(kind: string, row: object, key: string, index: number): number {
  const value: unknown = (row as Record<string, unknown>)[key];
  if (typeof value !== "number" || !Number.isFinite(value)) {
    throw new Error(
      `${kind}: row ${index + 1} field "${key}" is ${String(value)}, not a finite number`,
    );
  }
  return value;
}

/** Reads a number or null (a gap), or throws. */
export function readNumberOrNull(
  kind: string,
  row: object,
  key: string,
  index: number,
): number | null {
  const value: unknown = (row as Record<string, unknown>)[key];
  if (value === null || value === undefined) return null;
  return readNumber(kind, row, key, index);
}

/** Reads a category: a non-empty string or a finite number (a year, a dose), or throws. */
export function readCategory(
  kind: string,
  row: object,
  key: string,
  index: number,
): string | number {
  const value: unknown = (row as Record<string, unknown>)[key];
  if (typeof value === "number" && Number.isFinite(value)) return value;
  if (typeof value === "string" && value !== "") return value;
  throw new Error(
    `${kind}: row ${index + 1} field "${key}" is ${JSON.stringify(value)}, not a label`,
  );
}

/** Reads a category as text. */
export function readLabel(kind: string, row: object, key: string, index: number): string {
  return String(readCategory(kind, row, key, index));
}

/** A Date, an ISO 8601 string or a timestamp as a Date; undefined for anything else or an invalid date. */
export function toDate(value: unknown): Date | undefined {
  const date =
    value instanceof Date
      ? value
      : typeof value === "string" || typeof value === "number"
        ? new Date(value)
        : undefined;
  return date && !Number.isNaN(date.getTime()) ? date : undefined;
}

/** Reads a Date, an ISO 8601 string or a timestamp as a Date, or throws. */
export function readDate(kind: string, row: object, key: string, index: number): Date {
  const value: unknown = (row as Record<string, unknown>)[key];
  const date = toDate(value);
  if (!date) {
    throw new Error(
      `${kind}: row ${index + 1} field "${key}" is ${JSON.stringify(value)}, not a date`,
    );
  }
  return date;
}

/**
 * The distinct values of a label field in first-seen order, or ascending when every value is a
 * number (years, doses), or `explicit` when the caller fixed the order. Values are compared as
 * text, so 2022 and "2022" are one category (A8). Throws if data holds a value `explicit` leaves
 * out.
 */
export function domainOf(
  kind: string,
  field: string,
  values: readonly (string | number)[],
  explicit: readonly (string | number)[] | undefined,
): string[] {
  if (explicit) {
    const allowed = new Set(explicit.map(String));
    for (const v of values) {
      if (!allowed.has(String(v))) {
        throw new Error(`${kind}: ${field} value "${v}" is missing from the ${field} domain given`);
      }
    }
    return [...new Set(explicit.map(String))];
  }
  const seen = new Map<string, string | number>();
  for (const v of values) if (!seen.has(String(v))) seen.set(String(v), v);
  const distinct = [...seen.values()];
  if (distinct.every((v) => typeof v === "number")) {
    return (distinct as number[]).sort((a, b) => a - b).map(String);
  }
  return [...seen.keys()];
}

/**
 * A mark's key from its parts. JSON, so no part can contain the separator and collide with
 * another mark's key (A9, F14). Keys are opaque: compare them, never parse them.
 */
export function markKey(...parts: readonly (string | number)[]): string {
  return JSON.stringify(parts);
}

/** The smallest of many numbers, without spreading them onto the stack (F15). */
export function minOf(values: Iterable<number>): number {
  let min = Number.POSITIVE_INFINITY;
  for (const v of values) if (v < min) min = v;
  return min;
}

/** The largest of many numbers, without spreading them onto the stack (F15). */
export function maxOf(values: Iterable<number>): number {
  let max = Number.NEGATIVE_INFINITY;
  for (const v of values) if (v > max) max = v;
  return max;
}

/** Throws unless a size option is a positive finite number (A7, F15). */
export function requireSize(kind: string, name: string, value: number | undefined): void {
  if (value !== undefined && !(Number.isFinite(value) && value > 0)) {
    throw new Error(`${kind}: ${name} must be a positive number, is ${value}`);
  }
}

/** Milliseconds in a UTC day. */
export const DAY = 86_400_000;

/**
 * Dates made at local midnight (`new Date(2025, 0, 6)`) mean a calendar date, but east of UTC
 * that instant falls on the previous UTC day. When every Date is at local midnight and not at
 * UTC midnight, each is moved to UTC midnight of its local date (A3).
 */
export function calendarDates(dates: Date[]): Date[] {
  const localMidnight = dates.every(
    (d) =>
      d.getHours() === 0 &&
      d.getMinutes() === 0 &&
      d.getSeconds() === 0 &&
      d.getMilliseconds() === 0,
  );
  const utcMidnight = dates.every((d) => d.getTime() % DAY === 0);
  if (!localMidnight || utcMidnight) return dates;
  return dates.map((d) => new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())));
}

/** The step (1 to 5) of the sequential ramp a value falls in, given four ascending thresholds. */
export function rampStep(value: number, thresholds: readonly number[]): number {
  let step = 1;
  for (const t of thresholds) if (value >= t) step += 1;
  return step;
}

/**
 * Four ascending thresholds splitting `min` to `max` into the ramp's five steps: the caller's, or
 * equal intervals. Flat data still gets ascending thresholds, so every value lands in step 1.
 */
export function rampThresholds(
  kind: string,
  min: number,
  max: number,
  given: readonly number[] | undefined,
): readonly number[] {
  const span = max - min || 1;
  const thresholds = given ?? [1, 2, 3, 4].map((k) => min + (span * k) / 5);
  for (let i = 1; i < thresholds.length; i += 1) {
    if ((thresholds[i] as number) <= (thresholds[i - 1] as number)) {
      throw new Error(`${kind}: thresholds must ascend`);
    }
  }
  return thresholds;
}

/** An instant as `2026-01-05 14:30 UTC` (seconds only when not zero). */
export function formatInstant(date: Date): string {
  const pad = (n: number): string => String(n).padStart(2, "0");
  const seconds = date.getUTCSeconds() ? `:${pad(date.getUTCSeconds())}` : "";
  return `${date.toISOString().slice(0, 10)} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}${seconds} UTC`;
}