src/lite/area.ts
137 lines and 16 functions in DrDustinEdwards/enarratio, at commit 4c5695a on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| areaChart | 40 to 144 | 12 | 100% | WatchCRAP 12 | No data: no mutants in this code |
| color | 44 to 44 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:47> | 47 to 47 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:58> | 58 to 58 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| span | 66 to 66 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:81> | 81 to 81 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:81> | 81 to 81 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:92> | 92 to 100 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:95> | 95 to 95 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:96> | 96 to 96 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:97> | 97 to 97 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:106> | 106 to 113 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:109> | 109 to 109 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:110> | 110 to 110 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:118> | 118 to 118 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:120> | 120 to 125 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
Imports
It imports src/lite/series.ts, src/lite/svg.ts, src/render/line-model.ts, src/render/size.ts. Imported by src/lite/index.ts.
Source
/**
* `enarratio/lite/area`: {@link areaChart} without Observable Plot or a DOM, for browser bundles.
* The same options draw the same SVG as `areaChart` from `enarratio/plot`.
*
* @packageDocumentation
*/
import {
AREA_OPACITY,
areaModel,
type Point,
type SeriesChartOptions,
} from "../render/line-model.js";
import { DEFAULT_HEIGHT, DEFAULT_WIDTH } from "../render/size.js";
import {
annotations,
areaPath,
bySeries,
dots,
linePath,
pathGroup,
seriesFrame,
} from "./series.js";
import { el, svgRoot } from "./svg.js";
export type {
EventMarker,
ReferenceLine,
SeriesChartOptions,
} from "../render/line-model.js";
/**
* An area chart over time or any continuous x, drawn without Observable Plot. Several series
* stack, so the top edge is their total. The same options and output as `areaChart` from
* `enarratio/plot`, in a bundle small enough for a browser.
*
* @example
* areaChart({ data: monthly, x: "month", y: "hours", series: "activity", alt: "..." });
*/
export function areaChart<T extends object>(options: SeriesChartOptions<T>): string {
const model = areaModel(options);
const { prepared } = model;
const { points, seriesNames } = prepared;
const color = (p: Point): string => model.plan.range[p.row] ?? "";
// Each x's values stack in series order; a missing value adds nothing.
const order = new Map(seriesNames.map((name, i) => [name, i]));
const extents = new Map<Point, [number, number]>();
const byX = new Map<number, Point[]>();
for (const p of points) {
const at = byX.get(p.at);
if (at) at.push(p);
else byX.set(p.at, [p]);
}
for (const stack of byX.values()) {
let total = 0;
const sorted = [...stack].sort(
(a, b) => (order.get(a.series) ?? 0) - (order.get(b.series) ?? 0),
);
for (const p of sorted) {
const v = Math.max(0, p.value ?? 0);
extents.set(p, [total, total + v]);
total += v;
}
}
const span = (p: Point): [number, number] => extents.get(p) as [number, number];
const ys: number[] = [0];
for (const [a, b] of extents.values()) ys.push(a, b);
for (const r of options.references ?? []) ys.push(r.y);
const layout = seriesFrame(
model,
{
width: options.width ?? DEFAULT_WIDTH,
height: options.height ?? DEFAULT_HEIGHT,
marginTop: 20,
marginRight: 20,
marginBottom: 44,
marginLeft: prepared.yLabel === null ? 48 : 64,
},
[...points.map((p) => p.at), ...model.markers.map((m) => Number(m.x))],
ys,
{ log: false, zero: true },
);
const { x, y, frame } = layout;
const parts = [
layout.axes,
pathGroup(
"area",
{ "fill-opacity": String(AREA_OPACITY) },
bySeries(points).map((group) => ({
d: areaPath(
group,
() => true,
(p) => [x(p.at), y(span(p)[1])],
(p) => [x(p.at), y(span(p)[0])],
),
attrs: { fill: color(group[0] as Point) },
})),
),
// The top edge of each band as a line, so a light fill still reads as a shape.
pathGroup(
"line",
{ fill: "none", "stroke-width": "2", "stroke-linejoin": "round", "stroke-linecap": "round" },
bySeries(points).map((group) => ({
d: linePath(
group,
() => true,
(p) => [x(p.at), y(span(p)[1])],
),
attrs: { stroke: color(group[0] as Point) },
})),
),
// Gaps have no point: their hover text would otherwise claim a value.
dots(
model,
points.filter((p) => p.value !== null),
{ "fill-opacity": "0" },
(p) => ({
cx: x(p.at),
cy: y((span(p)[0] + span(p)[1]) / 2),
r: 4,
fill: color(p),
}),
),
el(
"g",
{ "data-enarratio-mark": "rule", stroke: "currentColor" },
el("line", {
x1: frame.marginLeft,
x2: frame.width - frame.marginRight,
y1: y(0),
y2: y(0),
}),
),
annotations(options, model, layout),
];
return model.figure(
svgRoot(frame.width, frame.height, options.alt, "img", parts.join("")),
layout.xScale,
);
}