src/lite/line.ts
151 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 |
|---|---|---|---|---|---|
| lineChart | 44 to 157 | 18 | 100% | WatchCRAP 18 | No data: no mutants in this code |
| color | 48 to 48 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:50> | 50 to 50 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:54> | 54 to 54 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:55> | 55 to 55 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| at | 73 to 73 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| hasValue | 74 to 74 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| lines | 75 to 79 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:76> | 76 to 79 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:94> | 94 to 102 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:97> | 97 to 97 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:98> | 98 to 98 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:99> | 99 to 99 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:113> | 113 to 126 | 8 | 100% | WatchCRAP 8 | No data: no mutants in this code |
| <anonymous:135> | 135 to 135 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:141> | 141 to 146 | 2 | 100% | OkCRAP 2 | 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/line`: {@link lineChart} without Observable Plot or a DOM, for browser bundles.
* The same options draw the same SVG as `lineChart` from `enarratio/plot`.
*
* @packageDocumentation
*/
import { type LineChartOptions, lineModel, type Point } from "../render/line-model.js";
import { DEFAULT_HEIGHT, DEFAULT_WIDTH } from "../render/size.js";
import {
annotations,
areaPath,
bySeries,
dots,
linePath,
pathGroup,
seriesFrame,
verticalRules,
} from "./series.js";
import { el, svgRoot, text, translate } from "./svg.js";
export type {
EventMarker,
LineBand,
LineChartOptions,
ReferenceLine,
SeriesChartOptions,
} from "../render/line-model.js";
/**
* A line chart over time or any continuous x, drawn without Observable Plot: the same options and
* output as `lineChart` from `enarratio/plot`, in a bundle small enough for a browser.
*
* @example
* lineChart({
* data: weekly,
* x: "week",
* y: "cases",
* series: "county",
* references: [{ y: 50, label: "Alert threshold" }],
* alt: "Weekly cases in three counties ...",
* });
*/
export function lineChart<T extends object>(options: LineChartOptions<T>): string {
const model = lineModel(options);
const { prepared, present, showPoints, forecast } = model;
const { points } = prepared;
const color = (p: Point): string => model.plan.range[p.row] ?? "";
const ys: (number | null)[] = points.map((p) => p.value);
if (prepared.band) for (const p of points) ys.push(p.lower, p.upper);
for (const r of options.references ?? []) ys.push(r.y);
const xs = [
...points.map((p) => p.at),
...model.markers.map((m) => Number(m.x)),
...(forecast ? [Number(forecast.x)] : []),
];
const layout = seriesFrame(
model,
{
width: options.width ?? DEFAULT_WIDTH,
height: options.height ?? DEFAULT_HEIGHT,
marginTop: 20,
marginRight: model.directLabels ? 96 : 24,
marginBottom: 44,
marginLeft: prepared.yLabel === null ? 48 : 64,
},
xs,
ys,
{ log: model.log, zero: !model.log && (options.zero ?? true) },
);
const { x, y } = layout;
const at = (p: Point): [number, number] => [x(p.at), y(p.value ?? 0)];
const hasValue = (p: Point): boolean => p.value !== null;
const lines = (rows: readonly Point[]) =>
bySeries(rows).map((group) => ({
d: linePath(group, hasValue, at),
attrs: { stroke: color(group[0] as Point) },
}));
const lineStyles = {
fill: "none",
"stroke-width": "2",
"stroke-linejoin": "round",
"stroke-linecap": "round",
};
const hollow = forecast !== undefined;
const parts = [
layout.axes,
prepared.band
? pathGroup(
"area",
{ "fill-opacity": "0.18" },
bySeries(points).map((group) => ({
d: areaPath(
group,
(p) => p.lower !== null && p.upper !== null,
(p) => [x(p.at), y(p.upper ?? 0)],
(p) => [x(p.at), y(p.lower ?? 0)],
),
attrs: { fill: color(group[0] as Point) },
})),
)
: "",
pathGroup("line", lineStyles, lines(model.solid)),
forecast
? pathGroup("line", { ...lineStyles, "stroke-dasharray": "6,4" }, lines(model.dashed))
: "",
dots(
model,
present,
hollow ? { "stroke-width": "1.5" } : { "fill-opacity": showPoints ? undefined : "0" },
(p) => ({
cx: x(p.at),
cy: y(p.value ?? 0),
r: showPoints ? 3 : 4,
fill: color(p),
// After the forecast boundary points are hollow: an outline in place of the fill.
...(hollow
? {
"fill-opacity": showPoints && !p.forecast ? "1" : "0",
stroke: color(p),
"stroke-opacity": showPoints && p.forecast ? "1" : "0",
}
: {}),
}),
),
annotations(options, model, layout),
forecast
? verticalRules([{ at: x(Number(forecast.x)), label: forecast.label }], layout.frame)
: "",
];
if (model.directLabels) {
// Each series named at its last point.
const last = bySeries(present).map((group) => group[group.length - 1] as Point);
parts.push(
el(
"g",
{ "data-enarratio-mark": "text", "text-anchor": "start", transform: translate(6, 0) },
last
.map((p) =>
el(
"text",
{ fill: color(p), transform: translate(x(p.at), y(p.value ?? 0)), y: "0.32em" },
text(p.series),
),
)
.join(""),
),
);
}
return model.figure(
svgRoot(layout.frame.width, layout.frame.height, options.alt, "img", parts.join("")),
layout.xScale,
);
}