render-chart.mjs
238 lines and 22 functions in DrDustinEdwards/prelum, at commit e389900 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| parseChartCsv | 17 to 77 | 4 | 90% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| <anonymous:20> | 20 to 20 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:21> | 21 to 21 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| parseLine | 26 to 55 | 7 | 63% | WatchCRAP 9.4 | No data: mutation testing has not run on this repo yet |
| <anonymous:62> | 62 to 64 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:68> | 68 to 74 | 2 | 43% | OkCRAP 2.7 | No data: mutation testing has not run on this repo yet |
| resolveSeries | 87 to 121 | 9 | 62% | WatchCRAP 13.6 | No data: mutation testing has not run on this repo yet |
| <anonymous:89> | 89 to 89 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:101> | 101 to 101 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:109> | 109 to 115 | 2 | 43% | OkCRAP 2.7 | No data: mutation testing has not run on this repo yet |
| drawChart | 132 to 164 | 11 | 94% | WatchCRAP 11 | No data: mutation testing has not run on this repo yet |
| <anonymous:145> | 145 to 145 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| buildChartModel | 170 to 229 | 9 | 94% | WatchCRAP 9 | No data: mutation testing has not run on this repo yet |
| <anonymous:196> | 196 to 202 | 2 | 71% | OkCRAP 2.1 | No data: mutation testing has not run on this repo yet |
| <anonymous:203> | 203 to 203 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| asX | 204 to 204 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:208> | 208 to 224 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:209> | 209 to 223 | 5 | 60% | WatchCRAP 6.6 | No data: mutation testing has not run on this repo yet |
| renderChartHast | 240 to 264 | 7 | 59% | WatchCRAP 10.4 | No data: mutation testing has not run on this repo yet |
| <anonymous:242> | 242 to 242 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:250> | 250 to 250 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:253> | 253 to 253 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
Imports
It imports no other module of this repo. Imported by render-packs.mjs.
Source
// Validation and CSV parsing for the :::chart directive. The drawing is Enarratio's (the site's charts
// package, as on /cv): its theme supplies the colours, its figure carries the accessible name and the
// data table. the site's stylesheet is that theme.
import { areaChart, barChart, lineChart, scatterPlot } from "enarratio/plot";
import { fromHtml } from "hast-util-from-html";
export const CHART_TYPES = ["bar", "line", "dot", "area"];
const DEFAULT_WIDTH = 700;
const DEFAULT_HEIGHT = 360;
/**
* @param {string} text
* @returns {{ columns: string[], rows: string[][] }}
*/
function parseChartCsv(text) {
const lines = text
.split("\n")
.map((line) => line.replace(/\r$/, ""))
.filter((line) => line.trim() !== "");
const header = lines[0];
if (header === undefined) throw new Error("chart data is empty");
const parseLine = (/** @type {string} */ line) => {
/** @type {string[]} */
const out = [];
let field = "";
let quoted = false;
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if (quoted) {
if (ch === '"') {
if (line[i + 1] === '"') {
field += '"';
i += 1;
} else {
quoted = false;
}
} else {
field += ch;
}
} else if (ch === '"') {
quoted = true;
} else if (ch === ",") {
out.push(field.trim());
field = "";
} else {
field += ch;
}
}
out.push(field.trim());
return out;
};
const columns = parseLine(header);
const rows = lines.slice(1).map(parseLine);
if (rows.length === 0) throw new Error("chart data has a header but no rows");
columns.forEach((name, i) => {
if (name === "") throw new Error(`chart data column ${i + 1} has an empty name`);
});
if (new Set(columns).size !== columns.length) {
throw new Error("chart data has duplicate column names");
}
rows.forEach((row, i) => {
if (row.length !== columns.length) {
throw new Error(
`chart data row ${i + 1} has ${row.length} fields, expected ${columns.length}`,
);
}
});
return { columns, rows };
}
/**
* The y columns and the label each series is drawn and keyed under, refused unless every column
* exists and every multi-series label is present and unique.
*
* @param {Record<string, string>} attrs
* @param {string[]} columns
* @returns {{ yColumns: string[], labels: string[] }}
*/
function resolveSeries(attrs, columns) {
if (!attrs.y) throw new Error(":::chart requires a y attribute naming a data column");
const yColumns = attrs.y.split(",").map((s) => s.trim()).filter(Boolean);
if (yColumns.length === 0) throw new Error(":::chart y attribute is empty");
for (const column of yColumns) {
if (!columns.includes(column)) {
throw new Error(
`:::chart y column "${column}" is not in the data (have ${columns.join(", ")})`,
);
}
}
let labels = yColumns;
if (attrs.labels) {
labels = attrs.labels.split(",").map((s) => s.trim());
if (labels.length !== yColumns.length) {
throw new Error(
`:::chart has ${yColumns.length} series but ${labels.length} labels`,
);
}
}
if (yColumns.length > 1) {
labels.forEach((label, i) => {
if (!label) {
throw new Error(
`:::chart series ${i + 1} ("${yColumns[i]}") has an empty label; multi-series charts must label every series`,
);
}
});
if (new Set(labels).size !== labels.length) {
throw new Error(":::chart series labels must be unique");
}
}
return { yColumns, labels };
}
/**
* Draws the model with Enarratio. The data is one row per x and series, keyed by "x", "series" and
* "value"; the author's column names go back on as the axis labels. Enarratio throws for anything it
* cannot draw (too many series, a log of zero), and that reaches the build as a ContentError.
*
* @param {{ type: string, alt: string, title: string, x: string, yColumns: string[], labels: string[],
* points: Array<{ x: string | number, series: string, value: number }>, xIsNumeric: boolean }} model
* @returns {string} the figure's HTML
*/
function drawChart(model) {
const { type, alt, title, x, yColumns, labels, points, xIsNumeric } = model;
const multi = labels.length > 1;
const common = {
alt,
...(title ? { title } : {}),
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
xLabel: x,
yLabel: multi ? null : yColumns[0],
};
const series = multi ? { series: "series", seriesDomain: labels } : {};
// A one-series chart has no series field, so the label is the value axis' name.
const data = multi ? points : points.map(({ x: px, value }) => ({ x: px, value }));
const keys = { x: "x", y: "value" };
// Cast: Enarratio keys its options on the row type's fields, which are only known here by name.
const options = /** @type {any} */ ({ ...common, ...keys, ...series, data });
if (type === "bar") {
return barChart({ ...options, layout: "grouped" });
}
// Line, area and scatter read x as a number or a date, never as a category.
if (!xIsNumeric && type === "dot") {
throw new Error(`:::chart type "dot" needs a numeric x column, but "${x}" is not numeric`);
}
if (type === "dot") {
return scatterPlot(options);
}
const xType = xIsNumeric ? "linear" : "time";
return type === "area"
? areaChart({ ...options, xType })
: lineChart({ ...options, xType, directLabels: multi });
}
/**
* @param {Record<string, string>} attrs
* @param {string} csv
*/
export function buildChartModel(attrs, csv) {
const type = attrs.type;
if (!type) throw new Error(":::chart requires a type attribute");
if (!CHART_TYPES.includes(type)) {
throw new Error(
`:::chart type "${type}" is not one of ${CHART_TYPES.join(", ")}`,
);
}
// Required even with the data table: the table is the long description, not the accessible name.
const alt = (attrs.alt ?? "").trim();
if (!alt) throw new Error(`:::chart "${attrs.title ?? type}" requires an alt attribute`);
const { columns, rows } = parseChartCsv(csv);
const x = attrs.x;
if (!x) throw new Error(":::chart requires an x attribute naming a data column");
if (!columns.includes(x)) {
throw new Error(`:::chart x column "${x}" is not in the data (have ${columns.join(", ")})`);
}
const { yColumns, labels } = resolveSeries(attrs, columns);
const xIndex = columns.indexOf(x);
// A numeric x column is a number, so Enarratio spaces it on a linear scale.
const prepared = rows.map((row, i) => {
const xValue = row[xIndex];
if (xValue === undefined) {
throw new Error(`:::chart row ${i + 1} has no "${x}" cell`);
}
return { row, xValue };
});
const xIsNumeric = prepared.every((p) => p.xValue !== "" && Number.isFinite(Number(p.xValue)));
const asX = (/** @type {string} */ v) => (xIsNumeric ? Number(v) : v);
/** @type {Array<{ x: string | number, series: string, value: number }>} */
const points = [];
prepared.forEach(({ row, xValue }, rowIndex) => {
yColumns.forEach((column, seriesIndex) => {
const raw = row[columns.indexOf(column)];
const value = Number(raw);
if (raw === undefined || raw === "" || !Number.isFinite(value)) {
throw new Error(
`:::chart row ${rowIndex + 1} column "${column}" is "${raw}", which is not a number`,
);
}
const series = labels[seriesIndex];
// Thrown, not defaulted: a point with an invented series name would render as a real series.
if (series === undefined) {
throw new Error(`:::chart has no label for series ${seriesIndex + 1}`);
}
points.push({ x: asX(xValue), series, value });
});
});
const title = (attrs.title ?? "").trim();
const html = drawChart({ type, alt, title, x, yColumns, labels, points, xIsNumeric });
return { type, alt, title, columns, rows, html };
}
/**
* The figure Enarratio drew, as hast. Parsed rather than allowDangerousHtml, which would open raw HTML
* in every post. The author's caption replaces Enarratio's own and sits where it does, above the
* data table; the outer figure takes Enarratio's classes and data attributes, so there is one figure.
*
* @param {ReturnType<typeof buildChartModel>} model
* @param {any[]} captionChildren hast for an author-written caption, may be empty
* @param {any} figure the directive's figure node, whose properties the chart's merge into
*/
export function renderChartHast(model, captionChildren, figure) {
const drawn = fromHtml(model.html, { fragment: true }).children.find(
(/** @type {any} */ n) => n.type === "element" && n.tagName === "figure",
);
if (!drawn || drawn.type !== "element") throw new Error("Enarratio returned no <figure>");
const classes = [...(figure.properties.className ?? []), ...(drawn.properties.className ?? [])];
figure.properties = { ...drawn.properties, className: classes };
const children = drawn.children.filter(
(/** @type {any} */ n) => !(n.type === "element" && n.tagName === "figcaption"),
);
if (captionChildren.length > 0) {
const at = children.findIndex((/** @type {any} */ n) => n.type === "element" && n.tagName === "details");
/** @type {import("hast").Element} */
const caption = {
type: "element",
tagName: "figcaption",
properties: { className: ["enarratio-caption"] },
children: captionChildren,
};
children.splice(at === -1 ? children.length : at, 0, caption);
}
return children;
}