Skip to the content

Capsid menu

render-chart.mjs

238 lines and 22 functions in DrDustinEdwards/prelum, at commit e389900 on 2026-10-24. This file on GitHub.

Functions

The functions of render-chart.mjs
FunctionLinesComplexityCoveredCRAPMutation
parseChartCsv17 to 77490%OkCRAP 4No data: mutation testing has not run on this repo yet
<anonymous:20>20 to 201100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:21>21 to 211100%OkCRAP 1No data: mutation testing has not run on this repo yet
parseLine26 to 55763%WatchCRAP 9.4No data: mutation testing has not run on this repo yet
<anonymous:62>62 to 642100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:68>68 to 74243%OkCRAP 2.7No data: mutation testing has not run on this repo yet
resolveSeries87 to 121962%WatchCRAP 13.6No data: mutation testing has not run on this repo yet
<anonymous:89>89 to 891100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:101>101 to 1011100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:109>109 to 115243%OkCRAP 2.7No data: mutation testing has not run on this repo yet
drawChart132 to 1641194%WatchCRAP 11No data: mutation testing has not run on this repo yet
<anonymous:145>145 to 1451100%OkCRAP 1No data: mutation testing has not run on this repo yet
buildChartModel170 to 229994%WatchCRAP 9No data: mutation testing has not run on this repo yet
<anonymous:196>196 to 202271%OkCRAP 2.1No data: mutation testing has not run on this repo yet
<anonymous:203>203 to 2032100%OkCRAP 2No data: mutation testing has not run on this repo yet
asX204 to 2042100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:208>208 to 2241100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:209>209 to 223560%WatchCRAP 6.6No data: mutation testing has not run on this repo yet
renderChartHast240 to 264759%WatchCRAP 10.4No data: mutation testing has not run on this repo yet
<anonymous:242>242 to 2422100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:250>250 to 2502100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:253>253 to 25320%WatchCRAP 6No 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;
}