src/plot/heatmap.ts
186 lines and 19 functions in DrDustinEdwards/enarratio, at commit 4c5695a on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| heatmap | 60 to 197 | 14 | 91% | WatchCRAP 14.1 | No data: changed since the last mutation run |
| <anonymous:66> | 66 to 66 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:67> | 67 to 67 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:70> | 70 to 70 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:71> | 71 to 71 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:75> | 75 to 79 | 2 | 100% | OkCRAP 2 | No data: changed since the last mutation run |
| <anonymous:84> | 84 to 92 | 3 | 100% | OkCRAP 3 | No data: changed since the last mutation run |
| <anonymous:94> | 94 to 94 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:95> | 95 to 95 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| describe | 98 to 99 | 2 | 100% | OkCRAP 2 | Ok100% |
| <anonymous:102> | 102 to 102 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:120> | 120 to 126 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:127> | 127 to 127 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| text | 136 to 136 | 2 | 100% | OkCRAP 2 | Watch60% |
| <anonymous:137> | 137 to 138 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:171> | 171 to 179 | 3 | 100% | OkCRAP 3 | No data: changed since the last mutation run |
| <anonymous:180> | 180 to 180 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:188> | 188 to 194 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:190> | 190 to 193 | 3 | 100% | OkCRAP 3 | No data: changed since the last mutation run |
Imports
It imports src/render/data.ts, src/render/figure.ts, src/render/plot.ts, src/render/size.ts. Imported by src/plot/index.ts.
Source
import * as Plot from "@observablehq/plot";
import {
defaultFormat,
domainOf,
type KeysOfType,
markKey,
maxOf,
minOf,
rampStep,
rampThresholds,
readCategory,
readNumberOrNull,
} from "../render/data.js";
import { type FigureOptions, figure, type RampItem, validateFigure } from "../render/figure.js";
import { decorate, keyed, renderPlot } from "../render/plot.js";
import { DEFAULT_WIDTH } from "../render/size.js";
/** Options for {@link heatmap}. */
export interface HeatmapOptions<T extends object> extends FigureOptions {
readonly data: readonly T[];
/** The column field. */
readonly x: KeysOfType<T, string | number>;
/** The row field. */
readonly y: KeysOfType<T, string | number>;
/** The field colored by the theme's sequential ramp. `null` is an empty cell, outlined dashed. */
readonly value: KeysOfType<T, number | null | undefined>;
readonly xDomain?: readonly (string | number)[];
readonly yDomain?: readonly (string | number)[];
/**
* Four ascending thresholds splitting values into the ramp's five steps. Defaults to equal
* intervals between the lowest and highest value.
*/
readonly thresholds?: readonly [number, number, number, number];
/** Print each value in its cell (default true when there are 100 cells or fewer). */
readonly cellLabels?: boolean;
readonly xLabel?: string | null;
readonly yLabel?: string | null;
/** What the value measures, for hover details and the table header. */
readonly valueLabel?: string;
readonly formatValue?: (value: number) => string;
}
interface Cell {
readonly x: string;
readonly y: string;
readonly value: number | null;
readonly step: number;
}
const KIND = "heatmap";
/**
* A grid of cells colored by value on the theme's sequential ramp: cohort retention, weekly
* activity, plate layouts. Colors come in five labelled steps, printed values keep the chart
* readable without color, and empty cells are outlined with a dashed line rather than colored as zero.
*
* @example
* heatmap({ data: cohorts, x: "month", y: "cohort", value: "retained", alt: "..." });
*/
export function heatmap<T extends object>(options: HeatmapOptions<T>): string {
const { data, x, y, value } = options;
validateFigure(KIND, options);
if (data.length === 0) throw new Error(`${KIND}: data is empty`);
const format = options.formatValue ?? defaultFormat;
const valueLabel = options.valueLabel ?? value;
const xs = data.map((row, i) => readCategory(KIND, row, x, i));
const ys = data.map((row, i) => readCategory(KIND, row, y, i));
const xNames = domainOf(KIND, x, xs, options.xDomain);
const yNames = domainOf(KIND, y, ys, options.yDomain);
const values = data.map((row, i) => readNumberOrNull(KIND, row, value, i));
const present = values.filter((v): v is number => v !== null);
if (present.length === 0) throw new Error(`${KIND}: every value is empty`);
const seen = new Set<string>();
data.forEach((_, i) => {
const id = `${xs[i]}|${ys[i]}`;
if (seen.has(id)) throw new Error(`${KIND}: two rows for cell ${xs[i]}, ${ys[i]}`);
seen.add(id);
});
const min = minOf(present);
const max = maxOf(present);
const thresholds = rampThresholds(KIND, min, max, options.thresholds);
const cells = data.map((_, i): Cell => {
const v = values[i] ?? null;
return {
x: String(xs[i]),
y: String(ys[i]),
value: v,
step: v === null ? 0 : rampStep(v, thresholds),
};
});
const filledCells = cells.filter((c) => c.value !== null);
const emptyCells = cells.filter((c) => c.value === null);
const xColumn = options.xLabel ?? x;
const yColumn = options.yLabel ?? y;
const describe = (c: Cell): string =>
`${yColumn} ${c.y}, ${xColumn} ${c.x}: ${c.value === null ? "no data" : `${format(c.value)} ${valueLabel}`}`;
const showLabels = options.cellLabels ?? cells.length <= 100;
const longest = Math.max(maxOf(yNames.map((n) => n.length)), 1);
const marks: Plot.Markish[] = [
Plot.cell(emptyCells, {
x: "x",
y: "y",
fill: "none",
stroke: "var(--enarratio-status-unknown)",
strokeDasharray: "3,2",
inset: 1.5,
title: describe,
}),
Plot.cell(filledCells, {
x: "x",
y: "y",
inset: 1,
title: describe,
render: keyed(
filledCells,
(c) => ({
key: markKey(c.x, c.y),
filter: { field: y, value: c.y },
x: c.x,
column: xNames.indexOf(c.x),
row: yNames.indexOf(c.y),
}),
(el, c) => el.setAttribute("fill", `var(--enarratio-sequential-${c.step})`),
),
}),
];
if (showLabels) {
marks.push(
Plot.text(filledCells, {
x: "x",
y: "y",
text: (c: Cell) => (c.value === null ? "" : format(c.value)),
render: decorate(filledCells, (el, c) =>
el.setAttribute("fill", `var(--enarratio-sequential-text-${c.step})`),
),
}),
);
}
const { svg } = renderPlot(
{
width: options.width ?? DEFAULT_WIDTH,
height: options.height ?? Math.max(160, yNames.length * 32 + 64),
marginTop: 20,
marginLeft: Math.min(200, longest * 7 + 24) + (options.yLabel === null ? 0 : 20),
marginBottom: 44,
x: {
type: "band",
domain: xNames,
label: options.xLabel === undefined ? x : options.xLabel,
labelAnchor: "center",
labelArrow: "none",
},
y: {
type: "band",
domain: yNames,
label: options.yLabel === undefined ? y : options.yLabel,
labelAnchor: "center",
labelArrow: "none",
},
marks,
},
options.alt,
);
const edges = [min, ...thresholds, max];
const ramp: RampItem[] = [1, 2, 3, 4, 5].map((step) => ({
step,
label:
step === 1
? `below ${format(edges[1] as number)}`
: step === 5
? `${format(edges[4] as number)} or more`
: `${format(edges[step - 1] as number)} to ${format(edges[step] as number)}`,
}));
const cellOf = new Map(cells.map((c) => [`${c.x}|${c.y}`, c]));
return figure(options, {
kind: "heatmap",
svg,
ramp,
table: {
columns: [`${yColumn} \\ ${xColumn}`, ...xNames],
rows: yNames.map((yn) => [
yn,
...xNames.map((xn) => {
const c = cellOf.get(`${xn}|${yn}`);
return c?.value === null || c === undefined ? "" : format(c.value);
}),
]),
},
});
}