Skip to the content

Capsid menu

src/render/figure.ts

234 lines and 14 functions in DrDustinEdwards/enarratio, at commit 4c5695a on 2026-10-24. This file on GitHub.

Functions

The functions of figure.ts
FunctionLinesComplexityCoveredCRAPMutation
validateFigure86 to 945100%OkCRAP 5Ok100%
requireAlt97 to 1033100%OkCRAP 3Ok90%
cssColor108 to 1133100%OkCRAP 3Ok88.9%
slotStyle116 to 1293100%OkCRAP 3Ok88.9%
<anonymous:121>121 to 1272100%OkCRAP 2No data: changed since the last mutation run
tableMarkup131 to 1571100%OkCRAP 1Ok100%
<anonymous:135>135 to 1351100%OkCRAP 1No data: changed since the last mutation run
<anonymous:138>138 to 1491100%OkCRAP 1No data: changed since the last mutation run
<anonymous:143>143 to 1462100%OkCRAP 2No data: changed since the last mutation run
dataTableBlock163 to 1762100%OkCRAP 2Ok93.3%
legendMarkup178 to 2055100%OkCRAP 5Ok92.6%
<anonymous:185>185 to 1861100%OkCRAP 1No data: changed since the last mutation run
<anonymous:196>196 to 2011100%OkCRAP 1No data: changed since the last mutation run
figure208 to 2538100%WatchCRAP 8No 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(""),
  );
}