Skip to the content

Capsid menu

src/primitives/heat-strip.ts

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

Functions

The functions of heat-strip.ts
FunctionLinesComplexityCoveredCRAPMutation
round65 to 651100%OkCRAP 1Ok100%
prepareHeatStrip68 to 1202097%WatchCRAP 20No data: changed since the last mutation run
<anonymous:78>78 to 823100%OkCRAP 3Ok90%
<anonymous:83>83 to 831100%OkCRAP 1Ok100%
<anonymous:108>108 to 1082100%OkCRAP 2Watch75%
cellLabel113 to 1132100%OkCRAP 2Watch60%
heatStrip131 to 1811100%OkCRAP 1Ok100%
<anonymous:135>135 to 1663100%OkCRAP 3Ok100%
heatStripTable191 to 2092100%OkCRAP 2Ok100%
<anonymous:200>200 to 2043100%OkCRAP 3Ok100%

Imports

It imports src/html.ts, src/primitives/companion.ts, src/primitives/tick-gap.ts, src/render/data.ts. Imported by src/index.ts.

Source

import { element, escapeHtml } from "../html.js";
import {
  defaultFormat,
  maxOf,
  minOf,
  rampStep,
  rampThresholds,
  requireSize,
} from "../render/data.js";
import { type PrimitiveTable, type PrimitiveTableOptions, primitiveTable } from "./companion.js";
import { tickGap } from "./tick-gap.js";

/** Options for {@link heatStrip}. */
export interface HeatStripOptions {
  /** One value per cell, in order. `null` is a cell with no data, drawn as a dashed outline. */
  readonly values: readonly (number | null)[];
  /**
   * What the strip shows and over what window, e.g. "Requests per hour, last 48 hours"; it starts
   * the generated text alternative. Required unless `alt` is given.
   */
  readonly label?: string;
  /** The text alternative, instead of the one generated from `label` and the values. */
  readonly alt?: string;
  /** The strip's width in CSS pixels (default 240). */
  readonly width?: number;
  /** The strip's height in CSS pixels (default 24). */
  readonly height?: number;
  /**
   * The values the ramp runs from and to. Default: the lowest and highest value, so the strip
   * uses all five steps; fix them to compare strips drawn from different data.
   */
  readonly min?: number;
  readonly max?: number;
  /**
   * Four ascending values splitting the range into the ramp's five steps, as in `heatmap`.
   * Default: equal intervals between `min` and `max`.
   */
  readonly thresholds?: readonly [number, number, number, number];
  /** Hover text naming each cell, e.g. "Mon 14:00"; the value is added. Default "Cell 3". */
  readonly cellLabels?: readonly string[];
  /** Formats values in hover details and the text alternative. */
  readonly formatValue?: (value: number) => string;
}

const KIND = "heatStrip";

/** Everything the strip, its text alternative and its companion table are computed from. */
interface HeatStripValues {
  readonly values: readonly (number | null)[];
  /** The ramp step, 1 to 5, of each cell; 0 for a cell with no data. */
  readonly steps: readonly number[];
  readonly min: number;
  readonly max: number;
  readonly thresholds: readonly number[];
  readonly format: (value: number) => string;
  readonly cellLabel: (index: number) => string;
  readonly subject: string;
  /** The sentence that names the values; the default text alternative. */
  readonly summary: string;
  readonly width: number;
  readonly height: number;
  readonly alt: string;
}

const round = (n: number): number => Math.round(n * 100) / 100;

/** Validates the options and computes the numbers every part of a heat strip is made from. */
function prepareHeatStrip(options: HeatStripOptions): HeatStripValues {
  const { values } = options;
  if (values.length === 0) throw new Error(`${KIND}: needs at least one value`);
  const subject = options.label?.trim() ?? "";
  const given = options.alt?.trim() ?? "";
  if (subject === "" && given === "") throw new Error(`${KIND}: label or alt is required`);
  const width = options.width ?? 240;
  const height = options.height ?? 24;
  requireSize(KIND, "width", width);
  requireSize(KIND, "height", height);
  values.forEach((v, i) => {
    if (v !== null && !Number.isFinite(v)) {
      throw new Error(`${KIND}: value ${i + 1} is ${v}, not a finite number or null`);
    }
  });
  const present = values.filter((v): v is number => v !== null);
  if (present.length === 0) throw new Error(`${KIND}: every value is empty`);
  const min = options.min ?? minOf(present);
  const max = options.max ?? maxOf(present);
  for (const [name, n] of [
    ["min", min],
    ["max", max],
  ] as const) {
    if (!Number.isFinite(n)) throw new Error(`${KIND}: ${name} must be a finite number, is ${n}`);
  }
  if (min > max) throw new Error(`${KIND}: min ${min} is above max ${max}`);
  const thresholds = rampThresholds(KIND, min, max, options.thresholds);
  const labels = options.cellLabels;
  if (labels !== undefined && labels.length !== values.length) {
    throw new Error(
      `${KIND}: cellLabels has ${labels.length} entries for ${values.length} values; give one each`,
    );
  }
  const format = options.formatValue ?? defaultFormat;
  const lowest = minOf(present);
  const highest = maxOf(present);
  const missing = values.length - present.length;
  const summary = `${subject === "" ? "" : `${subject}: `}${defaultFormat(values.length)} cells, from ${format(present[0] as number)} to ${format(present[present.length - 1] as number)}; lowest ${format(lowest)}, highest ${format(highest)}${missing > 0 ? `; ${missing} with no data` : ""}.`;
  return {
    values,
    steps: values.map((v) => (v === null ? 0 : rampStep(v, thresholds))),
    min,
    max,
    thresholds,
    format,
    cellLabel: (i) => labels?.[i] ?? `Cell ${i + 1}`,
    subject,
    summary,
    width,
    height,
    alt: given || summary,
  };
}

/**
 * A one-row strip of cells with no axis, each colored on the theme's sequential ramp by its value:
 * hours of load, days of activity, a sibling of {@link uptimeStrip}. It carries a text alternative
 * summarizing the values, and each cell has hover details. Colors come in five steps, so read the
 * numbers from the hover details or from {@link heatStripTable}.
 *
 * @example
 * heatStrip({ values: [3, 5, 9, 14, 8, 2], label: "Requests per hour" });
 */
export function heatStrip(options: HeatStripOptions): string {
  const p = prepareHeatStrip(options);
  const step = p.width / p.values.length;
  const gap = tickGap(step);
  const cells = p.values.map((value, i) => {
    const title = `${p.cellLabel(i)}: ${value === null ? "no data" : p.format(value)}`;
    const common = {
      x: round(i * step),
      y: 0,
      width: round(step - gap),
      height: p.height,
    };
    return element(
      "rect",
      value === null
        ? {
            ...common,
            x: round(i * step + 0.5),
            y: 0.5,
            width: round(step - gap - 1),
            height: p.height - 1,
            fill: "none",
            stroke: "var(--enarratio-text-muted)",
            "stroke-dasharray": "2,2",
          }
        : {
            ...common,
            fill: `var(--enarratio-sequential-${p.steps[i]})`,
            // A hairline keeps the palest step visible against the page.
            stroke: "var(--enarratio-grid)",
            "stroke-width": 0.5,
            "data-enarratio-step": p.steps[i],
          },
      element("title", {}, escapeHtml(title)),
    );
  });
  return element(
    "svg",
    {
      class: "enarratio enarratio-heat-strip",
      "data-enarratio": "heat-strip",
      xmlns: "http://www.w3.org/2000/svg",
      viewBox: `0 0 ${p.width} ${p.height}`,
      width: p.width,
      height: p.height,
      role: "img",
      "aria-label": p.alt,
    },
    cells.join(""),
  );
}

/**
 * A heat strip's cells as a table (cell, value and ramp step, `no data` for a gap) and a summary
 * sentence, computed from the same values as the strip so the two cannot disagree. Takes the same
 * options as {@link heatStrip}. The summary is the generated description even when `alt` is given.
 *
 * @example
 * const { markup } = heatStripTable({ values: [3, 5, 9], label: "Requests per hour" });
 */
export function heatStripTable(
  options: HeatStripOptions,
  display?: PrimitiveTableOptions,
): PrimitiveTable {
  const p = prepareHeatStrip(options);
  return primitiveTable(
    p.summary,
    {
      columns: ["Cell", "Value", "Ramp step"],
      rows: p.values.map((v, i) => [
        p.cellLabel(i),
        v === null ? "no data" : p.format(v),
        v === null ? "" : String(p.steps[i]),
      ]),
    },
    p.subject || p.alt,
    display,
  );
}