src/lite/bar.ts
311 lines and 26 functions in DrDustinEdwards/enarratio, at commit 4c5695a on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| stack | 51 to 69 | 6 | 100% | WatchCRAP 6 | No data: no mutants in this code |
| barChart | 83 to 86 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| drawBars | 88 to 328 | 30 | 100% | WatchCRAP 30 | No data: no mutants in this code |
| <anonymous:119> | 119 to 119 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:120> | 120 to 126 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:122> | 122 to 125 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:145> | 145 to 145 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:146> | 146 to 146 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:158> | 158 to 166 | 4 | 100% | OkCRAP 4 | No data: no mutants in this code |
| <anonymous:168> | 168 to 168 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:170> | 170 to 176 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:182> | 182 to 182 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:189> | 189 to 193 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| categoryAxis | 200 to 215 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:202> | 202 to 202 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:203> | 203 to 203 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:208> | 208 to 208 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| valueAxis | 216 to 223 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:229> | 229 to 229 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| <anonymous:235> | 235 to 235 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| <anonymous:242> | 242 to 251 | 5 | 100% | OkCRAP 5 | No data: no mutants in this code |
| barMarkup | 253 to 269 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:255> | 255 to 268 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| <anonymous:275> | 275 to 275 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:283> | 283 to 296 | 3 | 75% | OkCRAP 3.1 | No data: no mutants in this code |
| <anonymous:323> | 323 to 323 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
Imports
It imports src/lite/axis.ts, src/lite/scale.ts, src/lite/svg.ts, src/render/bar-model.ts, src/render/data.ts, src/render/datum.ts. Imported by src/lite/index.ts.
Source
/**
* `enarratio/lite/bar`: {@link barChart} without Observable Plot or a DOM, for browser bundles.
* The same options draw the same SVG as `barChart` from `enarratio/plot`.
*
* @packageDocumentation
*/
import {
type BarChartOptions,
type BarModel,
type BarPoint,
barModel,
labelFits,
} from "../render/bar-model.js";
import { markKey } from "../render/data.js";
import { datumAttributes } from "../render/datum.js";
import {
axisLabel,
axisTickLabels,
axisTicks,
type Frame,
facetTickLabels,
grid,
type Placement,
type Tick,
} from "./axis.js";
import {
band,
type Continuous,
continuous,
linearTickFormat,
ticks as linearTicks,
niceLinear,
} from "./scale.js";
import { el, markGroup, svgRoot, text } from "./svg.js";
interface Box {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
export type { BarChartOptions } from "../render/bar-model.js";
const MARGIN_TOP = 20;
const MARGIN_RIGHT = 20;
const MARGIN_BOTTOM = 44;
/** Each bar's extent along the value axis, stacked as Plot stacks it. */
function stack(model: BarModel): Map<BarPoint, [number, number]> {
const out = new Map<BarPoint, [number, number]>();
const totals = new Map<string, { pos: number; neg: number }>();
for (const p of model.points) {
// Grouped bars each start at zero; stacked ones continue their category's stack.
const id = model.grouped ? markKey(p.x, p.series) : p.x;
const total = totals.get(id) ?? { pos: 0, neg: 0 };
totals.set(id, total);
const v = p.value;
if (v < 0) {
out.set(p, [total.neg, total.neg + v]);
total.neg += v;
} else if (v > 0) {
out.set(p, [total.pos, total.pos + v]);
total.pos += v;
} else out.set(p, [total.pos, total.pos]);
}
return out;
}
/**
* A bar chart, drawn without Observable Plot: one series, or several stacked or grouped. The same
* options and output as `barChart` from `enarratio/plot`, in a bundle small enough for a browser.
*
* @example
* barChart({
* data: [{ year: 2023, type: "Publications" }, { year: 2024, type: "Talks" }],
* x: "year",
* series: "type",
* alt: "Entries per year by type, 2023 to 2024.",
* });
*/
export function barChart<T extends object>(options: BarChartOptions<T>): string {
const model = barModel(options);
return model.figure(drawBars(model, options));
}
function drawBars<T extends object>(model: BarModel, options: BarChartOptions<T>): string {
const { width, height, horizontal, grouped, xNames, seriesNames, points } = model;
const frame: Frame = {
width,
height,
marginTop: MARGIN_TOP,
marginRight: MARGIN_RIGHT,
marginBottom: MARGIN_BOTTOM,
marginLeft: model.marginLeft,
};
const extents = stack(model);
// The value scale: the stacks' extent and zero, made round, or the count ticks as given.
let lo = 0;
let hi = 0;
for (const [a, b] of extents.values()) {
lo = Math.min(lo, a, b);
hi = Math.max(hi, a, b);
}
const counts = model.countTicks;
const domain: [number, number] = counts
? [0, counts[counts.length - 1] ?? 1]
: (niceLinear([lo, hi]) as [number, number]);
const valueRange: [number, number] = horizontal
? [frame.marginLeft, width - frame.marginRight]
: [height - frame.marginBottom, frame.marginTop];
const value: Continuous = continuous("linear", domain, valueRange);
const tickCount = horizontal
? (valueRange[1] - valueRange[0]) / 80
: (valueRange[0] - valueRange[1]) / 35;
const valueTicks: Tick[] = counts
? counts.map((t) => ({ at: value(t), label: model.format(t) }))
: (() => {
const format = linearTickFormat(domain, tickCount);
return linearTicks(domain[0], domain[1], tickCount).map((t) => ({
at: value(t),
label: format(t),
}));
})();
// The category scale, and for grouped bars the facets, one per category.
const categoryRange: [number, number] = horizontal
? [frame.marginTop, height - frame.marginBottom]
: [frame.marginLeft, width - frame.marginRight];
const facetBand = grouped ? band(xNames.length, categoryRange, 0.15, 0) : undefined;
let inner = frame;
if (facetBand) {
inner = horizontal
? { ...frame, height: facetBand.bandwidth + frame.marginTop + frame.marginBottom }
: { ...frame, width: facetBand.bandwidth + frame.marginLeft + frame.marginRight };
}
const innerRange: [number, number] = horizontal
? [inner.marginTop, inner.height - inner.marginBottom]
: [inner.marginLeft, inner.width - inner.marginRight];
const category = grouped
? band(seriesNames.length, innerRange, 0.05, 0.05)
: band(xNames.length, innerRange, 0.2, 0.2);
const columnOf = new Map(xNames.map((name, i) => [name, i]));
const rowOf = new Map(seriesNames.map((name, i) => [name, i]));
// Facets in category order; a category without bars is an empty facet, drawn without marks.
const byCategory = new Map<string, BarPoint[]>();
if (facetBand) {
for (const p of points) {
const list = byCategory.get(p.x);
if (list) list.push(p);
else byCategory.set(p.x, [p]);
}
}
const facets = facetBand
? xNames.map((name, i) => {
const at = facetBand.at(i);
return {
name,
dx: horizontal ? 0 : at - frame.marginLeft,
dy: horizontal ? at - frame.marginTop : 0,
points: byCategory.get(name) ?? [],
};
})
: undefined;
const drawn = facets?.filter((f) => f.points.length > 0);
const outer: Frame | undefined = facetBand
? (() => {
const first = facetBand.at(0);
const last = facetBand.at(xNames.length - 1) + facetBand.bandwidth;
return horizontal
? { ...frame, marginTop: first, marginBottom: height - last }
: { ...frame, marginLeft: first, marginRight: width - last };
})()
: undefined;
const parts: string[] = [];
const thinned = model.thinned ? new Set(model.thinned) : undefined;
const label = model.categoryLabel;
const valueAt = valueTicks.map((t) => t.at);
if (facets && outer) {
const k = horizontal ? "fy" : "fx";
parts.push(
facetTickLabels(
k,
facets.map((f) => ({
dx: f.dx,
dy: f.dy,
...(thinned && !thinned.has(f.name) ? {} : { label: f.name }),
})),
inner,
),
axisLabel(k, label, outer),
);
}
const categoryAxis = (k: "x" | "y"): void => {
const ticks = xNames
.map((name, i) => ({ at: category.at(i), label: name }))
.filter((t) => !thinned || thinned.has(t.label));
const offset = category.bandwidth / 2;
parts.push(
axisTicks(
k,
ticks.map((t) => t.at),
frame,
offset,
),
axisTickLabels(k, ticks, frame, { offset }),
axisLabel(k, label, frame),
);
};
const valueAxis = (k: "x" | "y", placement: Placement): void => {
parts.push(
grid(k, valueAt, inner, drawn),
axisTicks(k, valueAt, inner, 0, placement),
axisTickLabels(k, valueTicks, inner, { tabular: true, placement }),
axisLabel(k, model.valueLabel, outer ?? frame, frame),
);
};
if (horizontal) {
if (!grouped) categoryAxis("y");
// A faceted axis is drawn in the facets with nothing beyond them: below, for x.
valueAxis(
"x",
facets?.filter((f, i) => f.points.length > 0 && !(facets[i + 1]?.points.length ?? 0)),
);
} else {
// ... and to the left, for y.
valueAxis(
"y",
facets?.filter((f, i) => f.points.length > 0 && !(facets[i - 1]?.points.length ?? 0)),
);
if (!grouped) categoryAxis("x");
}
// The bars, each its stack's extent across its slot.
const boxes = new Map(
points.map((p) => {
const [a, b] = extents.get(p) as [number, number];
const start = category.at(grouped ? (rowOf.get(p.series) ?? 0) : (columnOf.get(p.x) ?? 0));
const v1 = value(a);
const v2 = value(b);
const box: Box = horizontal
? { x: Math.min(v1, v2), width: Math.abs(v1 - v2), y: start, height: category.bandwidth }
: { x: start, width: category.bandwidth, y: Math.min(v1, v2), height: Math.abs(v1 - v2) };
return [p, box];
}),
);
const barMarkup = (list: readonly BarPoint[]): string =>
list
.map((p) => {
const box = boxes.get(p) as Box;
const keys = Object.fromEntries(datumAttributes(model.datum(p)));
const shape = { ...box, fill: model.plan.range[rowOf.get(p.series) ?? 0] };
const title = el("title", {}, text(model.describe(p)));
// A link is read on its own, so it carries its own name.
return model.link
? el(
"a",
{ href: model.link(p), fill: "inherit", ...keys, "aria-label": model.describe(p) },
el("rect", shape, title),
)
: el("rect", { ...shape, ...keys }, title);
})
.join("");
parts.push(
markGroup(
{ "data-enarratio-mark": "bar" },
{},
drawn
? drawn.map((f) => ({ dx: f.dx, dy: f.dy, children: barMarkup(f.points) }))
: barMarkup(points),
),
);
if (model.directLabels) {
// Each series' name inside the stacked segments it fits in, in the color for text on it.
const labels = points
.map((p) => {
const box = boxes.get(p) as Box;
if (!labelFits(p.series, box.width, box.height)) return "";
return el(
"text",
{
x: box.x + box.width / 2,
y: box.y + box.height / 2,
dy: "0.35em",
fill: `var(--enarratio-series-text-${(rowOf.get(p.series) ?? 0) + 1})`,
},
text(p.series),
);
})
.join("");
parts.push(
el(
"g",
{
"data-enarratio-mark": "bar label",
"aria-hidden": "true",
// Labels sit on the bars they name; pointer events go through them to the bar.
"pointer-events": "none",
"text-anchor": "middle",
"font-size": "11",
},
labels,
),
);
}
// The rule at zero, across the frame (in each drawn facet).
const zero = value(0);
const rule = horizontal
? el("line", { x1: zero, x2: zero, y1: inner.marginTop, y2: inner.height - inner.marginBottom })
: el("line", { x1: inner.marginLeft, x2: inner.width - inner.marginRight, y1: zero, y2: zero });
parts.push(
markGroup(
{ "data-enarratio-mark": "rule" },
{ stroke: "currentColor" },
drawn ? drawn.map((f) => ({ dx: f.dx, dy: f.dy, children: rule })) : rule,
),
);
return svgRoot(width, height, options.alt, options.href ? "group" : "img", parts.join(""));
}