src/render/data.ts
174 lines and 23 functions in DrDustinEdwards/enarratio, at commit 4c5695a on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| defaultFormat | 10 to 12 | 1 | 100% | OkCRAP 1 | Watch66.7% |
| readNumber | 15 to 23 | 3 | 100% | OkCRAP 3 | Ok90.9% |
| readNumberOrNull | 26 to 35 | 3 | 100% | OkCRAP 3 | Ok87.5% |
| readCategory | 38 to 50 | 5 | 83% | WatchCRAP 5.1 | Critical44.4% |
| readLabel | 53 to 55 | 1 | 100% | OkCRAP 1 | Ok100% |
| toDate | 58 to 66 | 6 | 100% | WatchCRAP 6 | No data: changed since the last mutation run |
| readDate | 69 to 78 | 2 | 100% | OkCRAP 2 | No data: changed since the last mutation run |
| domainOf | 86 to 108 | 7 | 100% | WatchCRAP 7 | Ok93.8% |
| <anonymous:104> | 104 to 104 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:105> | 105 to 105 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| markKey | 114 to 116 | 1 | 100% | OkCRAP 1 | Ok100% |
| minOf | 119 to 123 | 3 | 100% | OkCRAP 3 | Ok80% |
| maxOf | 126 to 130 | 3 | 100% | OkCRAP 3 | Ok80% |
| requireSize | 133 to 137 | 4 | 100% | OkCRAP 4 | Ok93.3% |
| calendarDates | 147 to 158 | 3 | 100% | OkCRAP 3 | Ok83.3% |
| <anonymous:149> | 149 to 153 | 4 | 100% | OkCRAP 4 | No data: changed since the last mutation run |
| <anonymous:155> | 155 to 155 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:157> | 157 to 157 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| rampStep | 161 to 165 | 3 | 100% | OkCRAP 3 | Ok83.3% |
| rampThresholds | 171 to 185 | 5 | 100% | OkCRAP 5 | Ok93.8% |
| <anonymous:178> | 178 to 178 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| formatInstant | 188 to 192 | 2 | 100% | OkCRAP 2 | Ok80% |
| pad | 189 to 189 | 1 | 100% | OkCRAP 1 | Ok100% |
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`;
}