src/render/figure.ts
234 lines and 14 functions in DrDustinEdwards/enarratio, at commit 4c5695a on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| validateFigure | 86 to 94 | 5 | 100% | OkCRAP 5 | Ok100% |
| requireAlt | 97 to 103 | 3 | 100% | OkCRAP 3 | Ok90% |
| cssColor | 108 to 113 | 3 | 100% | OkCRAP 3 | Ok88.9% |
| slotStyle | 116 to 129 | 3 | 100% | OkCRAP 3 | Ok88.9% |
| <anonymous:121> | 121 to 127 | 2 | 100% | OkCRAP 2 | No data: changed since the last mutation run |
| tableMarkup | 131 to 157 | 1 | 100% | OkCRAP 1 | Ok100% |
| <anonymous:135> | 135 to 135 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:138> | 138 to 149 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:143> | 143 to 146 | 2 | 100% | OkCRAP 2 | No data: changed since the last mutation run |
| dataTableBlock | 163 to 176 | 2 | 100% | OkCRAP 2 | Ok93.3% |
| legendMarkup | 178 to 205 | 5 | 100% | OkCRAP 5 | Ok92.6% |
| <anonymous:185> | 185 to 186 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:196> | 196 to 201 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| figure | 208 to 253 | 8 | 100% | WatchCRAP 8 | No data: changed since the last mutation run |
Imports
It imports src/html.ts, src/render/size.ts. Imported by src/index.ts, src/lite/series.ts, src/plot/heatmap.ts, src/plot/scatter.ts, src/primitives/companion.ts, src/primitives/progress-ring.ts, src/primitives/sparkline.ts, src/render/bar-model.ts, src/render/line-model.ts, src/render/plot.ts, src/render/series.ts, src/science/genome.ts, src/science/network.ts, src/science/titer.ts.
Source
/**
* The figure every chart is delivered in: an optional title, a legend, the SVG named by its text
* alternative, an optional caption and a data table. The accessible name lives on the SVG, never
* on the figure, because `role="img"` hides its descendants: naming the figure would hide the
* caption and table from the readers they exist for.
*/
import { element, escapeHtml } from "../html.js";
import { DEFAULT_WIDTH } from "./size.js";
/** A color for one series: any CSS color, or a pair for light and dark. */
export type SeriesColor = string | { readonly light: string; readonly dark: string };
/** Options every chart accepts. */
export interface FigureOptions {
/**
* Required. What the chart shows, including its finding, written for someone who cannot see
* it: "Publications rose from 2 in 2015 to 11 in 2024", not "Bar chart of publications".
*/
readonly alt: string;
/** A visible title above the chart. */
readonly title?: string;
/** A visible caption below the chart: method, source, caveats. */
readonly caption?: string;
/** An `id` for the figure, which enhancement events carry as `chartId`. */
readonly id?: string;
/** The drawing's width in CSS pixels; the SVG scales down to fit narrower containers. */
readonly width?: number;
/** The drawing's height in CSS pixels. */
readonly height?: number;
/**
* How the equivalent data table is shown: in a closed disclosure (default), or only to
* assistive technology. It is always present.
*/
readonly dataTable?: "details" | "visually-hidden";
}
/** A scale as the enhancement layer needs it to turn a pointer position back into data. */
export interface ScaleDescription {
readonly type: string;
readonly domain: readonly (number | string)[];
readonly range: readonly number[];
}
/** A table of the values a chart draws. The first column holds row headers. */
export interface DataTable {
readonly columns: readonly string[];
readonly rows: readonly (readonly string[])[];
}
/** One legend entry. */
export interface LegendItem {
readonly label: string;
/** The palette slot, 1 to 8. */
readonly slot: number;
}
/** One step of a sequential color legend. */
export interface RampItem {
readonly label: string;
/** The ramp step, 1 to 5. */
readonly step: number;
}
/** Everything a chart hands to {@link figure}. */
export interface FigureParts {
readonly kind: string;
readonly svg: string;
readonly table: DataTable;
readonly legend?: readonly LegendItem[];
/** A legend for a sequential ramp (heatmaps, maps), shown instead of a series legend. */
readonly ramp?: readonly RampItem[];
/** The data field series come from, which legend filters report. */
readonly seriesField?: string;
readonly x?: ScaleDescription;
/** Which way bars run, for the entrance animation. */
readonly orientation?: "vertical" | "horizontal";
/** Per-figure overrides of palette slots, as CSS custom property declarations. */
readonly slotColors?: ReadonlyMap<number, SeriesColor>;
}
/**
* Checks the options every chart shares before anything is drawn: a meaningful `alt`, and a
* positive finite width and height when given (A7, F15).
*/
export function validateFigure(kind: string, options: FigureOptions): void {
requireAlt(options.alt, kind);
for (const name of ["width", "height"] as const) {
const value = options[name];
if (value !== undefined && !(Number.isFinite(value) && value > 0)) {
throw new Error(`${kind}: ${name} must be a positive number, is ${value}`);
}
}
}
/** Throws unless `alt` is meaningful text. */
function requireAlt(alt: string | undefined, kind: string): string {
const trimmed = (alt ?? "").trim();
if (trimmed === "") {
throw new Error(`${kind}: alt is required and must describe what the chart shows`);
}
return trimmed;
}
/** What a caller-supplied color may contain: enough for hex, color functions and var(), not url(). */
const SAFE_COLOR = /^[#a-z0-9(),.%\s-]+$/i;
function cssColor(value: string): string {
if (!SAFE_COLOR.test(value) || /url\s*\(/i.test(value)) {
throw new Error(`"${value}" is not an allowed color; use hex, a color function or var(--name)`);
}
return value;
}
/** Per-figure palette overrides as an inline style value, or undefined when there are none. */
export function slotStyle(
colors: ReadonlyMap<number, SeriesColor> | undefined,
): string | undefined {
if (!colors || colors.size === 0) return undefined;
return [...colors]
.map(([slot, color]) => {
const value =
typeof color === "string"
? cssColor(color)
: `light-dark(${cssColor(color.light)}, ${cssColor(color.dark)})`;
return `--enarratio-series-${slot}: ${value}`;
})
.join("; ");
}
function tableMarkup(table: DataTable, caption: string): string {
const head = element(
"tr",
{},
table.columns.map((c) => element("th", { scope: "col" }, escapeHtml(c))).join(""),
);
const body = table.rows
.map((row) =>
element(
"tr",
{},
row
.map((cell, i) =>
i === 0
? element("th", { scope: "row" }, escapeHtml(cell))
: element("td", {}, escapeHtml(cell)),
)
.join(""),
),
)
.join("");
return element(
"table",
{},
`${element("caption", { class: "enarratio-visually-hidden" }, escapeHtml(caption))}${element("thead", {}, head)}${element("tbody", {}, body)}`,
);
}
/**
* A data table as the markup a figure carries: in a closed disclosure (default), or visible only
* to assistive technology. `caption` names the table for screen readers.
*/
export function dataTableBlock(
table: DataTable,
caption: string,
display: "details" | "visually-hidden" = "details",
): string {
const markup = tableMarkup(table, caption);
return display === "visually-hidden"
? element("div", { class: "enarratio-data enarratio-visually-hidden" }, markup)
: element(
"details",
{ class: "enarratio-data" },
`${element("summary", {}, "Data table")}${markup}`,
);
}
function legendMarkup(parts: FigureParts): string {
const swatch = element("span", { class: "enarratio-swatch", "aria-hidden": "true" });
if (parts.ramp && parts.ramp.length > 0) {
return element(
"ul",
{ class: "enarratio-legend enarratio-ramp" },
parts.ramp
.map((item) =>
element("li", { "data-step": item.step }, `${swatch}${escapeHtml(item.label)}`),
)
.join(""),
);
}
if (!parts.legend || parts.legend.length === 0) return "";
return element(
"ul",
{ class: "enarratio-legend" },
parts.legend
.map((item) =>
element(
"li",
{ "data-enarratio-series": item.label, "data-slot": item.slot },
`${swatch}${escapeHtml(item.label)}`,
),
)
.join(""),
);
}
/** Assembles the figure markup for a chart. */
export function figure(options: FigureOptions, parts: FigureParts): string {
const alt = requireAlt(options.alt, parts.kind);
const title = options.title?.trim();
const caption = options.caption?.trim();
const legend = legendMarkup(parts);
const wide = (options.width ?? DEFAULT_WIDTH) >= 480;
const tableBlock = dataTableBlock(parts.table, `Data for: ${title ?? alt}`, options.dataTable);
return element(
"figure",
{
class: "enarratio",
id: options.id,
"data-enarratio": parts.kind,
"data-enarratio-orientation": parts.orientation,
"data-enarratio-x-scale": parts.x ? JSON.stringify(parts.x) : undefined,
"data-enarratio-series-field": parts.seriesField,
style: slotStyle(parts.slotColors),
},
[
title ? element("p", { class: "enarratio-title" }, escapeHtml(title)) : "",
legend,
// A frame that scrolls sideways on narrow screens instead of shrinking the text (A13).
// Drawings 30rem or wider keep that width and scroll; narrower ones scale as usual. A wide
// frame is the one that can scroll, so a keyboard can reach it (tabindex), and the group
// role lets it carry a name. Markup cannot know whether it overflows, so every wide frame
// gets both; enhance() drops the tab stop once the marks take focus. The name is the title,
// not the alt, which the SVG inside already reads.
element(
"div",
wide
? {
class: "enarratio-frame enarratio-frame-wide",
tabindex: "0",
role: "group",
"aria-label": `${title || "Chart"}, scrolls sideways`,
}
: { class: "enarratio-frame" },
parts.svg,
),
caption ? element("figcaption", { class: "enarratio-caption" }, escapeHtml(caption)) : "",
tableBlock,
].join(""),
);
}