Enarratio gallery
Every Enarratio chart in the capsomer theme. Each was drawn on the server and reads without scripts; the enhancement layer adds hover details, keyboard navigation, filters and brushing.
- Release
- 0.4.1
- Charts
- 40
- Theme check
- Passes
- License
- MIT
Theme check: passes with 0 findings
No findings.
1Stacked bars
Entries per year by type, counted from one row per entry: the CV's overview chart.
Entries per year
Bar chart with 3 series and 18 points. X axis, Year: 2019 to 2024. Y axis, Entries: 0 to 11.
- Publications
- Grants
- Talks
Publications rose from 3 (2019) to 7 (2024). Grants rose from 0 (2019) to 1 (2024). Talks rose from 1 (2019) to 2 (2024). The highest value is 7 (Publications, 2024). The lowest is 0 (Grants, 2019).
Illustrative data.
Source: Sample CV · By Sample Lab · Download CSV
Show dataHide data
| Year | Publications | Grants | Talks |
|---|---|---|---|
| 2019 | 3 | 0 | 1 |
| 2020 | 4 | 1 | 0 |
| 2021 | 5 | 0 | 2 |
| 2022 | 6 | 2 | 3 |
| 2023 | 4 | 0 | 4 |
| 2024 | 7 | 1 | 2 |
Source: examples/stacked-bar.ts
// Entries per year by type, counted from one row per entry: the CV's overview chart.
import { barChart } from "enarratio/plot";
const entries = [
...Array.from({ length: 3 }, () => ({ year: 2019, type: "Publications" })),
{ year: 2019, type: "Talks" },
...Array.from({ length: 4 }, () => ({ year: 2020, type: "Publications" })),
{ year: 2020, type: "Grants" },
...Array.from({ length: 2 }, () => ({ year: 2021, type: "Talks" })),
...Array.from({ length: 5 }, () => ({ year: 2021, type: "Publications" })),
...Array.from({ length: 6 }, () => ({ year: 2022, type: "Publications" })),
...Array.from({ length: 2 }, () => ({ year: 2022, type: "Grants" })),
...Array.from({ length: 3 }, () => ({ year: 2022, type: "Talks" })),
...Array.from({ length: 4 }, () => ({ year: 2023, type: "Publications" })),
...Array.from({ length: 4 }, () => ({ year: 2023, type: "Talks" })),
...Array.from({ length: 7 }, () => ({ year: 2024, type: "Publications" })),
{ year: 2024, type: "Grants" },
...Array.from({ length: 2 }, () => ({ year: 2024, type: "Talks" })),
];
export default barChart({
id: "entries-by-year",
data: entries,
x: "year",
series: "type",
seriesDomain: ["Publications", "Grants", "Talks"],
directLabels: true,
xLabel: "Year",
yLabel: "Entries",
title: "Entries per year",
alt: "Stacked bars of CV entries per year from 2019 to 2024. Publications lead every year except 2023, when talks matched them; 2024 is the busiest year with 10 entries.",
caption: "Illustrative data.",
source: "Sample CV",
byline: "Sample Lab",
});
2Bars by year
Selecting a year: bars filter by category, link to a filtered page without script, and thin their labels.
Entries per year, 2005 to 2026
Bar chart with 3 series and 66 points. X axis, Year: 2005 to 2026. Y axis, Entries: 0 to 12.
- Publications
- Grants
- Talks
Publications rose from 0 (2005) to 2 (2026). Grants fell from 3 (2005) to 1 (2026). Talks rose from 1 (2005) to a peak of 4 (2007), then fell to 0 (2026). The highest value is 4 (Publications, 2007). The lowest is 0 (Publications, 2005).
Illustrative data. Clicking a bar selects its year; with scripts off, each bar links to that year.
Source: examples/years.ts
// Selecting a year: bars filter by category, link to a filtered page without script, and thin their labels.
import { barChart } from "enarratio/plot";
const types = ["Publications", "Grants", "Talks"] as const;
const entries = Array.from({ length: 22 }, (_, i) => 2005 + i).flatMap((year, i) =>
types.flatMap((type, t) =>
Array.from({ length: (i * (t + 2) + t * 3) % 5 }, () => ({ year, type })),
),
);
export default barChart({
id: "entries-by-year-select",
data: entries,
x: "year",
series: "type",
seriesDomain: [...types],
xDomain: Array.from({ length: 22 }, (_, i) => 2005 + i),
filterBy: "x",
href: (year) => `?year=${year}`,
maxXTicks: 8,
xLabel: "Year",
yLabel: "Entries",
title: "Entries per year, 2005 to 2026",
alt: "Stacked bars of CV entries per year from 2005 to 2026, by type. Every year has between 3 and 12 entries, peaking at 12 every fifth year: 2007, 2012, 2017 and 2022.",
caption:
"Illustrative data. Clicking a bar selects its year; with scripts off, each bar links to that year.",
});
3Runs per day
Runs per day on a time axis: no pre-formatted labels, and quiet days stay in the picture.
Analysis runs per day, by site
Bar chart with 3 series and 60 points. X axis, Day: 2026-06-09 to 2026-06-28. Y axis, Runs: 0 to 6.
- Austin
- Boston
- Chicago
Austin fell from 2 (2026-06-09) to 0 (2026-06-28). Boston fell from 2 (2026-06-09) to 1 (2026-06-28). Chicago fell from 2 (2026-06-09) to 0 (2026-06-28). The highest value is 2 (Austin, 2026-06-09). The lowest is 0 (Austin, 2026-06-13).
Illustrative data. Every day between the first and last run has a bar slot.
Show dataHide data
| Day | Austin | Boston | Chicago |
|---|---|---|---|
| 2026-06-09 | 2 | 2 | 2 |
| 2026-06-10 | 2 | 2 | 2 |
| 2026-06-11 | 2 | 2 | 2 |
| 2026-06-12 | 2 | 2 | 2 |
| 2026-06-13 | 0 | 0 | 0 |
| 2026-06-14 | 0 | 0 | 0 |
| 2026-06-15 | 2 | 2 | 2 |
| 2026-06-16 | 2 | 2 | 2 |
| 2026-06-17 | 2 | 2 | 2 |
| 2026-06-18 | 2 | 2 | 2 |
| 2026-06-19 | 2 | 2 | 2 |
| 2026-06-20 | 0 | 0 | 1 |
| 2026-06-21 | 1 | 0 | 0 |
| 2026-06-22 | 2 | 2 | 2 |
| 2026-06-23 | 2 | 2 | 2 |
| 2026-06-24 | 2 | 2 | 2 |
| 2026-06-25 | 2 | 2 | 2 |
| 2026-06-26 | 2 | 2 | 2 |
| 2026-06-27 | 1 | 0 | 0 |
| 2026-06-28 | 0 | 1 | 0 |
Source: examples/runs.ts
// Runs per day on a time axis: no pre-formatted labels, and quiet days stay in the picture.
import { barChart } from "enarratio/plot";
const sites = ["Austin", "Boston", "Chicago"] as const;
// A run is one row with a time. Enarratio puts each in its day and counts them; days with no
// runs (13 and 14 June) still get a slot.
const runs = Array.from({ length: 20 * 6 }, (_, i) => {
const day = Math.floor(i / 6);
const weekend = day % 7 === 4 || day % 7 === 5;
return { day, hour: 6 + (i % 6) * 3, site: sites[(i + day) % 3] as string, weekend };
})
.filter((r) => !r.weekend || r.hour === 6)
.filter((r) => !(r.day === 4 || r.day === 5))
.map((r) => ({
at: new Date(Date.UTC(2026, 5, 9 + r.day, r.hour)).toISOString(),
site: r.site,
}));
export default barChart({
id: "runs-per-day",
data: runs,
x: "at",
xType: "time",
interval: "day",
series: "site",
xLabel: "Day",
yLabel: "Runs",
title: "Analysis runs per day, by site",
alt: "Analysis runs per day at three sites, 9 to 28 June 2026. Most days have six runs, two from each site. There are none on 13 and 14 June, and one run on each of 20, 21, 27 and 28 June.",
caption: "Illustrative data. Every day between the first and last run has a bar slot.",
});
4Grouped bars
Two measurements per category, side by side.
Time to result by assay
Bar chart with 2 series and 6 points. X axis, Assay: Plaque to qPCR. Y axis, Hours to result: 0 to 120.
- Manual
- Automated
The highest value is 120 (Manual, TCID50). The lowest is 4 (Automated, qPCR).
Illustrative data.
Show dataHide data
| Assay | Manual | Automated |
|---|---|---|
| Plaque | 72 | 48 |
| TCID50 | 120 | 96 |
| qPCR | 6 | 4 |
Source: examples/grouped-bar.ts
// Two measurements per category, side by side.
import { barChart } from "enarratio/plot";
const results = [
{ assay: "Plaque", method: "Manual", hours: 72 },
{ assay: "Plaque", method: "Automated", hours: 48 },
{ assay: "TCID50", method: "Manual", hours: 120 },
{ assay: "TCID50", method: "Automated", hours: 96 },
{ assay: "qPCR", method: "Manual", hours: 6 },
{ assay: "qPCR", method: "Automated", hours: 4 },
];
export default barChart({
data: results,
x: "assay",
y: "hours",
series: "method",
layout: "grouped",
xLabel: "Assay",
yLabel: "Hours to result",
title: "Time to result by assay",
alt: "Grouped bars of hours to result for three assays. Automation saves a day on plaque assays (72 to 48 hours) and TCID50 (120 to 96), and two hours on qPCR (6 to 4).",
caption: "Illustrative data.",
});
5Horizontal bars
A ranking with long category names reads best as horizontal bars.
Most viewed pages
Bar chart with 5 points. Y axis: Teaching and mentoring to Contact. X axis, Views: 0 to 1,840.
The highest value is 1,840 (Teaching and mentoring). The lowest is 310 (Contact).
Show dataHide data
| page | Views |
|---|---|
| Teaching and mentoring | 1,840 |
| Publications | 1,320 |
| Influenza surveillance notes | 960 |
| Curriculum vitae | 720 |
| Contact | 310 |
Source: examples/horizontal-bar.ts
// A ranking with long category names reads best as horizontal bars.
import { barChart } from "enarratio/plot";
const pages = [
{ page: "Teaching and mentoring", views: 1840 },
{ page: "Publications", views: 1320 },
{ page: "Influenza surveillance notes", views: 960 },
{ page: "Curriculum vitae", views: 720 },
{ page: "Contact", views: 310 },
];
export default barChart({
data: pages,
x: "page",
y: "views",
orientation: "horizontal",
xLabel: null,
yLabel: "Views",
title: "Most viewed pages",
alt: "Horizontal bars ranking five pages by views: Teaching and mentoring leads with 1,840, then Publications 1,320, surveillance notes 960, the CV 720 and Contact 310.",
});
6Diverging bars
Signed changes about zero, sorted, each labeled with its sign.
Vaccination rate change, 2024 to 2025
Bar chart with 8 points. Y axis: Ashe to Haywood. X axis, Change in vaccination rate (percentage points): −3.4 to +4.2.
- Rose
- Fell
The highest value is +4.2 (Ashe). The lowest is −3.4 (Haywood).
Illustrative data.
Show dataHide data
| county | Change in vaccination rate (percentage points) |
|---|---|
| Ashe | +4.2 |
| Buncombe | +2.8 |
| Iredell | +2.1 |
| Catawba | +1.1 |
| Guilford | +0.4 |
| Durham | −0.6 |
| Forsyth | −1.9 |
| Haywood | −3.4 |
Source: examples/diverging-bar.ts
// Signed changes about zero, sorted, each labeled with its sign.
import { divergingBarChart } from "enarratio/plot";
const change = [
{ county: "Ashe", points: 4.2 },
{ county: "Buncombe", points: 2.8 },
{ county: "Catawba", points: 1.1 },
{ county: "Durham", points: -0.6 },
{ county: "Forsyth", points: -1.9 },
{ county: "Guilford", points: 0.4 },
{ county: "Haywood", points: -3.4 },
{ county: "Iredell", points: 2.1 },
];
export default divergingBarChart({
data: change,
x: "county",
y: "points",
sort: "descending",
positiveLabel: "Rose",
negativeLabel: "Fell",
xLabel: null,
yLabel: "Change in vaccination rate (percentage points)",
title: "Vaccination rate change, 2024 to 2025",
alt: "Change in vaccination rate by county from 2024 to 2025: five counties rose, led by Ashe at +4.2 points, and three fell, the most Haywood at −3.4 points.",
caption: "Illustrative data.",
});
7Error bars
Grouped bars with 95% confidence intervals as error bars.
Viral titer with and without antiviral
Bar chart with 2 series and 6 points. X axis, Strain: PR8 to Cal09. Y axis, Titer (log10 PFU/mL): 0 to 7.2.
- Control
- Antiviral
The highest value is 6.8 (Control, WSN). The lowest is 3.9 (Antiviral, PR8). For Control, the 95% CI for Cal09 is 5.1 to 6.3. For Antiviral, the 95% CI for Cal09 is 4.3 to 5.7.
Illustrative data. Error bars are 95% confidence intervals of the mean, n = 6.
Show dataHide data
| Strain | Control | Antiviral | Control 95% CI | Antiviral 95% CI |
|---|---|---|---|---|
| PR8 | 6.1 | 3.9 | 5.6 to 6.6 | 3.2 to 4.6 |
| WSN | 6.8 | 4.4 | 6.4 to 7.2 | 3.9 to 4.9 |
| Cal09 | 5.7 | 5 | 5.1 to 6.3 | 4.3 to 5.7 |
Source: examples/error-bars.ts
// Grouped bars with 95% confidence intervals as error bars.
import { barChart } from "enarratio/plot";
const assays = [
{ strain: "PR8", treatment: "Control", titer: 6.1, lo: 5.6, hi: 6.6 },
{ strain: "PR8", treatment: "Antiviral", titer: 3.9, lo: 3.2, hi: 4.6 },
{ strain: "WSN", treatment: "Control", titer: 6.8, lo: 6.4, hi: 7.2 },
{ strain: "WSN", treatment: "Antiviral", titer: 4.4, lo: 3.9, hi: 4.9 },
{ strain: "Cal09", treatment: "Control", titer: 5.7, lo: 5.1, hi: 6.3 },
{ strain: "Cal09", treatment: "Antiviral", titer: 5.0, lo: 4.3, hi: 5.7 },
];
export default barChart({
data: assays,
x: "strain",
y: "titer",
series: "treatment",
layout: "grouped",
errorBars: { lower: "lo", upper: "hi", label: "95% CI" },
xLabel: "Strain",
yLabel: "Titer (log10 PFU/mL)",
title: "Viral titer with and without antiviral",
alt: "Mean viral titer for three strains, control against antiviral, with 95% confidence intervals: the antiviral lowers titer by about 2.3 log10 for PR8 and WSN, with intervals far apart, and by 0.7 for Cal09, where the intervals overlap.",
caption: "Illustrative data. Error bars are 95% confidence intervals of the mean, n = 6.",
});
8Waterfall
A bridge from one total to another through increases and decreases.
Lab budget, 2025 to 2026
Waterfall chart with 7 points. X axis: 2025 budget to 2026 budget. Y axis, Thousands of dollars: 0 to 1,625.
- Increase
- Decrease
- Total
Running total rose from 1,200 (2025 budget) to a peak of 1,625 (Indirect costs), then fell to 1,130 (2026 budget). The highest value is 1,625 (Indirect costs). The lowest is 1,130 (Salaries).
Illustrative data.
Show dataHide data
| item | Change | Running total |
|---|---|---|
| 2025 budget | 1,200 | |
| New grant | +340 | 1,540 |
| Indirect costs | +85 | 1,625 |
| Ended award | −260 | 1,365 |
| Equipment | −140 | 1,225 |
| Salaries | −95 | 1,130 |
| 2026 budget | 1,130 |
Source: examples/waterfall.ts
// A bridge from one total to another through increases and decreases.
import { waterfallChart } from "enarratio/plot";
const budget = [
{ item: "2025 budget", amount: 1200, total: true },
{ item: "New grant", amount: 340 },
{ item: "Indirect costs", amount: 85 },
{ item: "Ended award", amount: -260 },
{ item: "Equipment", amount: -140 },
{ item: "Salaries", amount: -95 },
{ item: "2026 budget", amount: null, total: true },
];
export default waterfallChart({
data: budget,
x: "item",
y: "amount",
total: "total",
xLabel: null,
yLabel: "Thousands of dollars",
title: "Lab budget, 2025 to 2026",
alt: "Waterfall from the 2025 lab budget of $1,200k to the 2026 budget of $1,130k: a new grant adds $340k and indirect costs $85k, while an ended award removes $260k, equipment $140k and salaries $95k.",
caption: "Illustrative data.",
});
9Line
Weekly counts over time, with a gap, a threshold and an event marker.
Weekly positive tests by county
One line per county. The dashed line is the alert threshold.
Line chart with 3 series and 47 points. X axis, Week starting: October 6, 2025 to January 19, 2026. Y axis, Positive tests: 18 to 80.
- Travis
- Hays
- Williamson
Travis rose from 42 (October 6, 2025) to a peak of 80 (December 8, 2025), then fell to 43 (January 19, 2026). Hays rose from 18 (October 6, 2025) to a peak of 34 (December 8, 2025), then fell to 18 (January 19, 2026). Williamson rose from 27 (October 6, 2025) to a peak of 51 (December 8, 2025), then fell to 27 (January 19, 2026). The highest value is 80 (Travis, December 8, 2025). The lowest is 18 (Hays, October 6, 2025).
Drag across the chart, or hold Shift with the arrow keys, to pick a range. Hays did not report in the week of 17 November.
Notes: A positive test is one lab-confirmed result.
Source: Sample health department (illustrative data) · Download CSV
Show dataHide data
| Week starting | Travis | Hays | Williamson |
|---|---|---|---|
| October 6, 2025 | 42 | 18 | 27 |
| October 13, 2025 | 42 | 18 | 27 |
| October 20, 2025 | 42 | 18 | 27 |
| October 27, 2025 | 43 | 18 | 27 |
| November 3, 2025 | 44 | 19 | 29 |
| November 10, 2025 | 48 | 21 | 31 |
| November 17, 2025 | 56 | 36 | |
| November 24, 2025 | 66 | 28 | 43 |
| December 1, 2025 | 76 | 32 | 49 |
| December 8, 2025 | 80 | 34 | 51 |
| December 15, 2025 | 76 | 32 | 49 |
| December 22, 2025 | 66 | 28 | 43 |
| December 29, 2025 | 56 | 24 | 36 |
| January 5, 2026 | 48 | 21 | 31 |
| January 12, 2026 | 44 | 19 | 29 |
| January 19, 2026 | 43 | 18 | 27 |
Source: examples/line.ts
// Weekly counts over time, with a gap, a threshold and an event marker.
import { lineChart } from "enarratio/plot";
const counties = ["Travis", "Hays", "Williamson"] as const;
const base = { Travis: 42, Hays: 18, Williamson: 27 };
const weekly = counties.flatMap((county) =>
Array.from({ length: 16 }, (_, week) => {
const wave = Math.round(base[county] * (1 + 0.9 * Math.exp(-(((week - 9) / 3) ** 2))));
// Hays did not report in week 6: a gap, not a zero.
const cases = county === "Hays" && week === 6 ? null : wave;
return { week: new Date(Date.UTC(2025, 9, 6 + week * 7)), county, cases };
}),
);
export default lineChart({
id: "weekly-cases",
data: weekly,
x: "week",
y: "cases",
series: "county",
xLabel: "Week starting",
yLabel: "Positive tests",
references: [{ y: 60, label: "Alert threshold" }],
markers: [{ x: "2025-11-24", label: "Holiday" }],
directLabels: true,
title: "Weekly positive tests by county",
subtitle: "One line per county. The dashed line is the alert threshold.",
alt: "All three counties peaked in the week of 8 December, and only Travis passed the alert threshold of 60.",
caption:
"Drag across the chart, or hold Shift with the arrow keys, to pick a range. Hays did not report in the week of 17 November.",
notes: "A positive test is one lab-confirmed result.",
source: {
name: "Sample health department (illustrative data)",
href: "https://example.com/data",
},
});
10Forecast
A forecast the caller computed: dashed after 24 February, with its 95% interval as a band.
Daily visits and a one-week forecast
Line chart with 28 points. X axis, Day: February 3, 2026 to March 2, 2026. Y axis, Visits: 120 to 241.
Visits rose from 120 (February 3, 2026) to 207 (March 2, 2026). The highest value is 207 (March 2, 2026). The lowest is 120 (February 3, 2026). The 95% interval for March 2, 2026 is 173 to 241.
Illustrative data. The forecast and its interval were computed outside Enarratio.
Show dataHide data
| Day | Visits | 95% interval, lower | 95% interval, upper | Forecast |
|---|---|---|---|---|
| February 3, 2026 | 120 | |||
| February 4, 2026 | 127 | |||
| February 5, 2026 | 133 | |||
| February 6, 2026 | 137 | |||
| February 7, 2026 | 139 | |||
| February 8, 2026 | 140 | |||
| February 9, 2026 | 139 | |||
| February 10, 2026 | 138 | |||
| February 11, 2026 | 138 | |||
| February 12, 2026 | 139 | |||
| February 13, 2026 | 142 | |||
| February 14, 2026 | 147 | |||
| February 15, 2026 | 154 | |||
| February 16, 2026 | 161 | |||
| February 17, 2026 | 167 | |||
| February 18, 2026 | 173 | |||
| February 19, 2026 | 176 | |||
| February 20, 2026 | 177 | |||
| February 21, 2026 | 177 | |||
| February 22, 2026 | 176 | |||
| February 23, 2026 | 176 | |||
| February 24, 2026 | 176 | 166 | 186 | Yes |
| February 25, 2026 | 178 | 164 | 192 | Yes |
| February 26, 2026 | 182 | 164 | 200 | Yes |
| February 27, 2026 | 188 | 166 | 210 | Yes |
| February 28, 2026 | 194 | 168 | 220 | Yes |
| March 1, 2026 | 201 | 171 | 231 | Yes |
| March 2, 2026 | 207 | 173 | 241 | Yes |
Source: examples/forecast.ts
// A forecast the caller computed: dashed after 24 February, with its 95% interval as a band.
import { lineChart } from "enarratio/plot";
// Observed daily visits, then a forecast made elsewhere (here: a straight trend with a widening
// interval). Enarratio draws the numbers it is given; it does not estimate them.
const days = Array.from({ length: 28 }, (_, i) => {
const at = new Date(Date.UTC(2026, 1, 3 + i));
const trend = 120 + i * 3 + 8 * Math.sin(i / 2);
const forecast = i > 20;
const spread = forecast ? 6 + (i - 20) * 4 : 0;
return {
day: at,
visits: Math.round(trend),
lower: forecast ? Math.round(trend - spread) : null,
upper: forecast ? Math.round(trend + spread) : null,
};
});
export default lineChart({
id: "visits-forecast",
data: days,
x: "day",
y: "visits",
band: { lower: "lower", upper: "upper", label: "95% interval" },
forecastFrom: "2026-02-23",
forecastLabel: "Forecast",
xLabel: "Day",
yLabel: "Visits",
title: "Daily visits and a one-week forecast",
alt: "Daily visits from 3 February to 2 March 2026, rising from about 120 to about 207. From 24 February the line is a forecast: it continues the rise to about 207 by 2 March, with a 95% interval that widens from plus or minus 10 to plus or minus 34.",
caption: "Illustrative data. The forecast and its interval were computed outside Enarratio.",
});
11Annotations
A line chart with a highlighted range, a note and an arrow.
Influenza-like illness visits, fall 2025
Line chart with 16 points. X axis: October 6, 2025 to January 19, 2026. Y axis, ILI visits: 12 to 104.
ILI visits rose from 12 (October 6, 2025) to a peak of 104 (December 1, 2025), then fell to 21 (January 19, 2026). The highest value is 104 (December 1, 2025). The lowest is 12 (October 6, 2025).
Illustrative data.
Show dataHide data
| week | ILI visits |
|---|---|
| October 6, 2025 | 12 |
| October 13, 2025 | 14 |
| October 20, 2025 | 13 |
| October 27, 2025 | 18 |
| November 3, 2025 | 26 |
| November 10, 2025 | 41 |
| November 17, 2025 | 63 |
| November 24, 2025 | 88 |
| December 1, 2025 | 104 |
| December 8, 2025 | 97 |
| December 15, 2025 | 81 |
| December 22, 2025 | 60 |
| December 29, 2025 | 44 |
| January 5, 2026 | 33 |
| January 12, 2026 | 25 |
| January 19, 2026 | 21 |
Source: examples/annotations.ts
// A line chart with a highlighted range, a note and an arrow.
import { lineChart } from "enarratio/plot";
const weekly = [12, 14, 13, 18, 26, 41, 63, 88, 104, 97, 81, 60, 44, 33, 25, 21].map(
(visits, i) => ({ week: new Date(Date.UTC(2025, 9, 6 + 7 * i)), visits }),
);
export default lineChart({
data: weekly,
x: "week",
y: "visits",
xLabel: null,
yLabel: "ILI visits",
annotations: [
{ type: "range", axis: "x", from: "2025-12-22", to: "2026-01-12", label: "Winter break" },
{ type: "note", x: "2025-12-01", y: 104, text: "Peak, 104 visits", dx: 64, dy: 28 },
],
title: "Influenza-like illness visits, fall 2025",
alt: "Weekly influenza-like illness visits from October 2025 to January 2026 rise from 12 to a peak of 104 in the week of 1 December, then fall to 21.",
caption: "Illustrative data.",
});
12Stacked areas
Stacked areas: parts of a whole over time.
Where the week goes
Area chart with 3 series and 18 points. X axis, Month: September 1, 2025 to February 1, 2026. Y axis, Hours per week: 5 to 26.
- Research
- Teaching
- Service
Research rose from 22 (September 1, 2025) to 25 (February 1, 2026). Teaching rose from 12 (September 1, 2025) to a peak of 16 (November 1, 2025), then fell to 12 (February 1, 2026). Service stayed near 6 from September 1, 2025 to February 1, 2026. The highest value is 26 (Research, January 1, 2026). The lowest is 5 (Service, January 1, 2026).
Illustrative data.
Show dataHide data
| Month | Research | Teaching | Service |
|---|---|---|---|
| September 1, 2025 | 22 | 12 | 6 |
| October 1, 2025 | 20 | 14 | 6 |
| November 1, 2025 | 18 | 16 | 7 |
| December 1, 2025 | 24 | 10 | 6 |
| January 1, 2026 | 26 | 9 | 5 |
| February 1, 2026 | 25 | 12 | 6 |
Source: examples/area.ts
// Stacked areas: parts of a whole over time.
import { areaChart } from "enarratio/plot";
const activities = ["Research", "Teaching", "Service"] as const;
const hours = {
Research: [22, 20, 18, 24, 26, 25],
Teaching: [12, 14, 16, 10, 9, 12],
Service: [6, 6, 7, 6, 5, 6],
};
const monthly = activities.flatMap((activity) =>
hours[activity].map((h, i) => ({
month: new Date(Date.UTC(2025, 8 + i, 1)),
activity,
hours: h,
})),
);
export default areaChart({
data: monthly,
x: "month",
y: "hours",
series: "activity",
xLabel: "Month",
yLabel: "Hours per week",
title: "Where the week goes",
alt: "Stacked areas of weekly hours by activity from September 2025 to February 2026. Research is the largest share throughout, rising to 26 hours in January; teaching peaks at 16 hours in November; service stays near 6.",
caption: "Illustrative data.",
});
13100% stacked areas
Each month stacked to 100%: how the whole divides as its total changes.
Influenza detections by subtype
Area chart with 3 series and 18 points. X axis: January 1, 2025 to June 1, 2025. Y axis, Share of total (%): 10% to 52%.
- H1N1pdm09
- H3N2
- B/Victoria
H1N1pdm09 fell from 52% (January 1, 2025) to 20% (June 1, 2025). H3N2 fell from 38% (January 1, 2025) to 30% (June 1, 2025). B/Victoria rose from 10% (January 1, 2025) to 50% (June 1, 2025). The highest value is 52% (H1N1pdm09, January 1, 2025). The lowest is 10% (B/Victoria, January 1, 2025).
Illustrative data. Each month adds to 100%.
Show dataHide data
| month | H1N1pdm09 | H3N2 | B/Victoria |
|---|---|---|---|
| January 1, 2025 | 52% | 38% | 10% |
| February 1, 2025 | 46% | 42% | 12% |
| March 1, 2025 | 38% | 44% | 18% |
| April 1, 2025 | 30% | 40% | 30% |
| May 1, 2025 | 24% | 36% | 40% |
| June 1, 2025 | 20% | 30% | 50% |
Source: examples/percent-area.ts
// Each month stacked to 100%: how the whole divides as its total changes.
import { areaChart } from "enarratio/plot";
const months = ["2025-01", "2025-02", "2025-03", "2025-04", "2025-05", "2025-06"];
const share: Record<string, number[]> = {
H1N1pdm09: [52, 46, 38, 30, 24, 20],
H3N2: [38, 42, 44, 40, 36, 30],
"B/Victoria": [10, 12, 18, 30, 40, 50],
};
const detections = Object.entries(share).flatMap(([subtype, counts]) =>
counts.map((count, i) => ({ month: `${months[i]}-01`, subtype, count })),
);
export default areaChart({
data: detections,
x: "month",
y: "count",
series: "subtype",
percent: true,
xLabel: null,
title: "Influenza detections by subtype",
alt: "Share of influenza detections by subtype, January to June 2025: H1N1pdm09 falls from 52% to 20%, H3N2 stays near 40%, and B/Victoria rises from 10% to half of detections.",
caption: "Illustrative data. Each month adds to 100%.",
});
14Small multiples
One line chart per region on shared scales, so panels compare by position.
Influenza positivity by region
Small multiples with 6 series and 48 points. X axis, Week of season: 1 to 8. Y axis, Positivity (%): 0 to 23.
The highest value is 23 (Midwest, 6). The lowest is 1 (Northwest, 1).
Illustrative data.
Show dataHide data
| region | Week of season | Positivity (%) |
|---|---|---|
| Northeast | 1 | 4 |
| Northeast | 2 | 6 |
| Northeast | 3 | 9 |
| Northeast | 4 | 15 |
| Northeast | 5 | 22 |
| Northeast | 6 | 18 |
| Northeast | 7 | 12 |
| Northeast | 8 | 8 |
| Southeast | 1 | 7 |
| Southeast | 2 | 11 |
| Southeast | 3 | 16 |
| Southeast | 4 | 21 |
| Southeast | 5 | 19 |
| Southeast | 6 | 14 |
| Southeast | 7 | 10 |
| Southeast | 8 | 7 |
| Midwest | 1 | 3 |
| Midwest | 2 | 4 |
| Midwest | 3 | 6 |
| Midwest | 4 | 10 |
| Midwest | 5 | 16 |
| Midwest | 6 | 23 |
| Midwest | 7 | 19 |
| Midwest | 8 | 13 |
| Southwest | 1 | 5 |
| Southwest | 2 | 7 |
| Southwest | 3 | 8 |
| Southwest | 4 | 9 |
| Southwest | 5 | 11 |
| Southwest | 6 | 10 |
| Southwest | 7 | 9 |
| Southwest | 8 | 8 |
| West | 1 | 2 |
| West | 2 | 3 |
| West | 3 | 5 |
| West | 4 | 8 |
| West | 5 | 12 |
| West | 6 | 17 |
| West | 7 | 21 |
| West | 8 | 18 |
| Northwest | 1 | 1 |
| Northwest | 2 | 2 |
| Northwest | 3 | 3 |
| Northwest | 4 | 5 |
| Northwest | 5 | 8 |
| Northwest | 6 | 12 |
| Northwest | 7 | 15 |
| Northwest | 8 | 16 |
Source: examples/small-multiples.ts
// One line chart per region on shared scales, so panels compare by position.
import { smallMultiples } from "enarratio/plot";
const regions: Record<string, number[]> = {
Northeast: [4, 6, 9, 15, 22, 18, 12, 8],
Southeast: [7, 11, 16, 21, 19, 14, 10, 7],
Midwest: [3, 4, 6, 10, 16, 23, 19, 13],
Southwest: [5, 7, 8, 9, 11, 10, 9, 8],
West: [2, 3, 5, 8, 12, 17, 21, 18],
Northwest: [1, 2, 3, 5, 8, 12, 15, 16],
};
const weekly = Object.entries(regions).flatMap(([region, values]) =>
values.map((rate, i) => ({ region, week: i + 1, rate })),
);
export default smallMultiples({
chart: "line",
data: weekly,
facet: "region",
x: "week",
y: "rate",
xLabel: "Week of season",
yLabel: "Positivity (%)",
title: "Influenza positivity by region",
alt: "Six panels of weekly influenza positivity on shared scales: the Southeast peaks first, at 21% in week 4, the Northeast at 22% in week 5, the Midwest at 23% in week 6, and the West and Northwest last, near weeks 7 and 8; the Southwest stays flat near 10%.",
caption: "Illustrative data.",
});
15Small multiples, bars
One bar chart per clinic on a shared scale, stacked by visit type.
Clinic visits by quarter
Small multiples with 6 series and 24 points. X axis: Q1 to Q4. Y axis, Visits: 0 to 268.
- In person
- Video
The highest value is 220 (Central, In person, Q4). The lowest is 35 (Central, Video, Q1).
Illustrative data.
Show dataHide data
| clinic | quarter | In person | Video |
|---|---|---|---|
| North | Q1 | 120 | 40 |
| North | Q2 | 135 | 52 |
| North | Q3 | 128 | 61 |
| North | Q4 | 150 | 70 |
| Central | Q1 | 210 | 35 |
| Central | Q2 | 198 | 41 |
| Central | Q3 | 205 | 44 |
| Central | Q4 | 220 | 48 |
| South | Q1 | 80 | 72 |
| South | Q2 | 92 | 88 |
| South | Q3 | 101 | 97 |
| South | Q4 | 110 | 115 |
Source: examples/small-multiples-bar.ts
// One bar chart per clinic on a shared scale, stacked by visit type.
import { smallMultiples } from "enarratio/plot";
const rows: [string, string, number, number][] = [
["North", "Q1", 120, 40],
["North", "Q2", 135, 52],
["North", "Q3", 128, 61],
["North", "Q4", 150, 70],
["Central", "Q1", 210, 35],
["Central", "Q2", 198, 41],
["Central", "Q3", 205, 44],
["Central", "Q4", 220, 48],
["South", "Q1", 80, 72],
["South", "Q2", 92, 88],
["South", "Q3", 101, 97],
["South", "Q4", 110, 115],
];
const visits = rows.flatMap(([clinic, quarter, inPerson, video]) => [
{ clinic, quarter, type: "In person", visits: inPerson },
{ clinic, quarter, type: "Video", visits: video },
]);
export default smallMultiples({
chart: "bar",
data: visits,
facet: "clinic",
x: "quarter",
y: "visits",
series: "type",
xLabel: null,
yLabel: "Visits",
title: "Clinic visits by quarter",
alt: "Three panels of quarterly visits on a shared scale: Central is busiest, about 250 a quarter, nearly all in person; North grows from 160 to 220 with video rising; South grows from 152 to 225, with video overtaking in-person visits in the fourth quarter.",
caption: "Illustrative data.",
});
16Scatter
Two measurements per sample, grouped, with a regression line on a log axis.
Ct value against infectious titer
Scatter plot with 2 series and 9 points. X axis, Cycle threshold (Ct): 18.2 to 30.8. Y axis, Infectious titer (PFU/mL): 720 to 5,200,000.
- 2.3.4.4b
- 2.3.2.1c
The highest value is 5,200,000 (2.3.4.4b, A1). The lowest is 720 (2.3.4.4b, A5). As one value goes up, the other tends to go down (strong link, r = -0.99).
Illustrative data. Each series has its own symbol as well as its own color.
Show dataHide data
| Point | Series | Cycle threshold (Ct) | Infectious titer (PFU/mL) |
|---|---|---|---|
| A1 | 2.3.4.4b | 18.2 | 5,200,000 |
| A2 | 2.3.4.4b | 21.5 | 610,000 |
| A3 | 2.3.4.4b | 24.9 | 48,000 |
| A4 | 2.3.4.4b | 27.3 | 9,500 |
| A5 | 2.3.4.4b | 30.8 | 720 |
| B1 | 2.3.2.1c | 19.4 | 2,100,000 |
| B2 | 2.3.2.1c | 22.8 | 190,000 |
| B3 | 2.3.2.1c | 26.1 | 24,000 |
| B4 | 2.3.2.1c | 29.6 | 1,600 |
Source: examples/scatter.ts
// Two measurements per sample, grouped, with a regression line on a log axis.
import { scatterPlot } from "enarratio/plot";
const isolates = [
{ id: "A1", clade: "2.3.4.4b", ct: 18.2, titer: 5.2e6 },
{ id: "A2", clade: "2.3.4.4b", ct: 21.5, titer: 6.1e5 },
{ id: "A3", clade: "2.3.4.4b", ct: 24.9, titer: 4.8e4 },
{ id: "A4", clade: "2.3.4.4b", ct: 27.3, titer: 9.5e3 },
{ id: "A5", clade: "2.3.4.4b", ct: 30.8, titer: 7.2e2 },
{ id: "B1", clade: "2.3.2.1c", ct: 19.4, titer: 2.1e6 },
{ id: "B2", clade: "2.3.2.1c", ct: 22.8, titer: 1.9e5 },
{ id: "B3", clade: "2.3.2.1c", ct: 26.1, titer: 2.4e4 },
{ id: "B4", clade: "2.3.2.1c", ct: 29.6, titer: 1.6e3 },
];
export default scatterPlot({
data: isolates,
x: "ct",
y: "titer",
series: "clade",
label: "id",
yType: "log",
regression: true,
xLabel: "Cycle threshold (Ct)",
yLabel: "Infectious titer (PFU/mL)",
title: "Ct value against infectious titer",
alt: "Scatter plot of nine isolates from two clades: infectious titer falls about tenfold for every 3.3 Ct cycles in both clades, from about 5 million PFU/mL at Ct 18 to about 700 at Ct 31.",
caption: "Illustrative data. Each series has its own symbol as well as its own color.",
});
17Bubbles
Points sized by a third measurement, with a legend of reference sizes.
Wastewater signal and test positivity by site
Scatter plot with 7 points. X axis, Wastewater signal (copies per person, thousands): 18 to 96. Y axis, Test positivity (%): 5.2 to 12.6. Point size, Samples: 45 to 410.
The highest value is 12.6 (Raleigh). The lowest is 5.2 (Boone). As one value goes up, the other tends to go up (strong link, r = 0.95).
Illustrative data. Bubble area shows the number of samples.
Show dataHide data
| Point | Wastewater signal (copies per person, thousands) | Test positivity (%) | Samples |
|---|---|---|---|
| Asheville | 42 | 8.1 | 120 |
| Boone | 18 | 5.2 | 45 |
| Charlotte | 96 | 11.4 | 410 |
| Durham | 61 | 9.8 | 260 |
| Greensboro | 55 | 7.4 | 300 |
| Raleigh | 88 | 12.6 | 380 |
| Wilmington | 30 | 6.3 | 150 |
Source: examples/bubble.ts
// Points sized by a third measurement, with a legend of reference sizes.
import { scatterPlot } from "enarratio/plot";
const sites = [
{ site: "Asheville", samples: 120, positivity: 8.1, wastewater: 42 },
{ site: "Boone", samples: 45, positivity: 5.2, wastewater: 18 },
{ site: "Charlotte", samples: 410, positivity: 11.4, wastewater: 96 },
{ site: "Durham", samples: 260, positivity: 9.8, wastewater: 61 },
{ site: "Greensboro", samples: 300, positivity: 7.4, wastewater: 55 },
{ site: "Raleigh", samples: 380, positivity: 12.6, wastewater: 88 },
{ site: "Wilmington", samples: 150, positivity: 6.3, wastewater: 30 },
];
export default scatterPlot({
data: sites,
x: "wastewater",
y: "positivity",
size: "samples",
sizeLabel: "Samples",
label: "site",
xLabel: "Wastewater signal (copies per person, thousands)",
yLabel: "Test positivity (%)",
title: "Wastewater signal and test positivity by site",
alt: "Bubble chart of seven sites: test positivity rises with wastewater signal, from 5.2% at Boone (signal 18, 45 samples) to 12.6% at Raleigh (signal 88, 380 samples); larger sites sit to the upper right.",
caption: "Illustrative data. Bubble area shows the number of samples.",
});
18Histogram
A histogram of one measurement, with a density curve over it.
Cell diameters
Histogram with 12 points. X axis, Cell diameter (µm): 8 to 20. Y axis, Count: 0 to 57.
Count rose from 2 (8 to 9) to a peak of 57 (12 to 13), then fell to 1 (19 to 20). The highest value is 57 (12 to 13). The lowest is 1 (17 to 18).
Illustrative data. The line is a Gaussian kernel density estimate (Silverman's bandwidth), scaled to counts.
Show dataHide data
| Cell diameter (µm) (bin) | Count |
|---|---|
| 8 to 9 | 2 |
| 9 to 10 | 10 |
| 10 to 11 | 28 |
| 11 to 12 | 52 |
| 12 to 13 | 57 |
| 13 to 14 | 51 |
| 14 to 15 | 17 |
| 15 to 16 | 14 |
| 16 to 17 | 6 |
| 17 to 18 | 1 |
| 18 to 19 | 1 |
| 19 to 20 | 1 |
Source: examples/histogram.ts
// A histogram of one measurement, with a density curve over it.
import { histogram } from "enarratio/plot";
// A fixed sample, so the gallery draws the same chart every time.
let seed = 7;
const random = (): number => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
const normal = (mean: number, sd: number): number =>
mean + sd * Math.sqrt(-2 * Math.log(random())) * Math.cos(2 * Math.PI * random());
const cells = Array.from({ length: 240 }, () => ({
diameter: Math.round(normal(12.4, 1.8) * 10) / 10,
}));
export default histogram({
data: cells,
x: "diameter",
thresholds: 14,
density: true,
xLabel: "Cell diameter (µm)",
title: "Cell diameters",
alt: "Histogram of 240 cell diameters: one peak near 12.5 µm, roughly symmetric, with nearly all cells between 8 and 17 µm.",
caption: "Illustrative data.",
});
19Density
Smoothed distributions of one measurement, compared on shared axes.
Ct values by virus
Density plot with 3 series and 45 points. X axis, Cycle threshold (Ct): 11.7 to 43.7. Y axis, Density: 0 to 0.158.
- Wild type
- ΔNS1
- PA-X null
Wild type rose from 0.00545 (11.7) to a peak of 0.153 (23.128571), then fell to 0.000000000000000000000381 (43.7). ΔNS1 rose from 0.0000000000000107 (11.7) to a peak of 0.092 (32.271429), then fell to 0.00339 (43.7). PA-X null rose from 0.00209 (11.7) to a peak of 0.0756 (27.7), then fell to 0.00455 (43.7). The highest value is 0.153 (Wild type, 23.128571). The lowest is 0.000000000000000000000381 (Wild type, 43.7).
Illustrative data. Gaussian kernel density, Wild type: n 80, bandwidth 0.916; ΔNS1: n 80, bandwidth 1.48; PA-X null: n 80, bandwidth 1.78.
Show dataHide data
| Cycle threshold (Ct) | Wild type density | ΔNS1 density | PA-X null density |
|---|---|---|---|
| 11.7 | 0.00545 | 0.0000000000000107 | 0.00209 |
| 13.985714 | 0.000314 | 0.000000000244 | 0.00539 |
| 16.271429 | 0.00834 | 0.000000529 | 0.00695 |
| 18.557143 | 0.0374 | 0.000114 | 0.0159 |
| 20.842857 | 0.0649 | 0.00283 | 0.0298 |
| 23.128571 | 0.153 | 0.0144 | 0.0523 |
| 25.414286 | 0.113 | 0.0395 | 0.0734 |
| 27.7 | 0.0355 | 0.061 | 0.0756 |
| 29.985714 | 0.0144 | 0.079 | 0.0635 |
| 32.271429 | 0.00288 | 0.092 | 0.0509 |
| 34.557143 | 0.00457 | 0.0804 | 0.0305 |
| 36.842857 | 0.00089 | 0.0498 | 0.0128 |
| 39.128571 | 0.000000341 | 0.0118 | 0.006 |
| 41.414286 | 0.000000000000257 | 0.00234 | 0.0068 |
| 43.7 | 0.000000000000000000000381 | 0.00339 | 0.00455 |
Source: examples/density.ts
// Smoothed distributions of one measurement, compared on shared axes.
import { densityPlot } from "enarratio/plot";
let seed = 11;
const random = (): number => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
const normal = (mean: number, sd: number): number =>
mean + sd * Math.sqrt(-2 * Math.log(random())) * Math.cos(2 * Math.PI * random());
const groups = { "Wild type": [24, 3], ΔNS1: [31, 4], "PA-X null": [27, 6] } as const;
const ct = Object.entries(groups).flatMap(([virus, [mean, sd]]) =>
Array.from({ length: 80 }, () => ({ virus, ct: Math.round(normal(mean, sd) * 10) / 10 })),
);
export default densityPlot({
data: ct,
x: "ct",
series: "virus",
xLabel: "Cycle threshold (Ct)",
title: "Ct values by virus",
alt: "Density of Ct values for three viruses, 80 samples each: wild type peaks near Ct 24, PA-X null near 27 and spreads widest, and ΔNS1 peaks latest, near 32.",
caption: "Illustrative data.",
});
20Box plot
Box plots by group, every value drawn beside its box.
Body weight by diet
Box plot with 30 points. X axis, Diet: Chow to High fat + drug. Y axis, Body weight at 12 weeks (g): 22.1 to 38.2.
The highest value is 38.2 (High fat). The lowest is 22.1 (High fat + drug). Medians: 24.8 (Chow), 30.9 (High fat), 26.95 (High fat + drug).
Illustrative data. Boxes span the quartiles; whiskers reach the furthest value within 1.5 IQR.
Show dataHide data
| Diet | n | Minimum | Lower quartile | Median | Upper quartile | Maximum | Whiskers | Outliers |
|---|---|---|---|---|---|---|---|---|
| Chow | 10 | 23.2 | 24.175 | 24.8 | 25.25 | 26 | 23.2 to 26 | |
| High fat | 10 | 28.7 | 29.95 | 30.9 | 31.85 | 38.2 | 28.7 to 33.5 | 38.2 |
| High fat + drug | 10 | 22.1 | 26.3 | 26.95 | 27.7 | 29 | 25.9 to 29 | 22.1 |
Source: examples/box.ts
// Box plots by group, every value drawn beside its box.
import { boxPlot } from "enarratio/plot";
const weights: Record<string, number[]> = {
Chow: [24.1, 25.3, 23.8, 26.0, 24.7, 25.1, 23.2, 24.9, 25.8, 24.4],
"High fat": [29.4, 31.2, 28.7, 33.5, 30.1, 32.0, 29.9, 30.6, 38.2, 31.4],
"High fat + drug": [26.2, 27.8, 25.9, 28.4, 27.1, 26.6, 29.0, 27.4, 26.8, 22.1],
};
const mice = Object.entries(weights).flatMap(([diet, values]) =>
values.map((weight) => ({ diet, weight })),
);
export default boxPlot({
data: mice,
x: "diet",
y: "weight",
points: true,
xLabel: "Diet",
yLabel: "Body weight at 12 weeks (g)",
title: "Body weight by diet",
alt: "Box plots of body weight for three diets, ten mice each: chow has a median of 24.8 g, high fat 30.9 g with one heavy mouse at 38.2 g, and high fat with the drug 27.0 g with one light mouse at 22.1 g.",
caption:
"Illustrative data. Boxes span the quartiles; whiskers reach the furthest value within 1.5 IQR.",
});
21Beeswarm
Every value as a point, spread in a beeswarm so none hides another.
Vaccine knowledge scores by age
Strip plot with 120 points. X axis, Age group: 18 to 49 to 65 and over. Y axis, Knowledge score: 26 to 100.
The highest value is 100 (65 and over). The lowest is 26 (18 to 49).
Illustrative data.
Show dataHide data
| Value | Age group | Knowledge score |
|---|---|---|
| Value 1 | 18 to 49 | 26 |
| Value 2 | 18 to 49 | 50 |
| Value 3 | 18 to 49 | 56 |
| Value 4 | 18 to 49 | 81 |
| Value 5 | 18 to 49 | 71 |
| Value 6 | 18 to 49 | 44 |
| Value 7 | 18 to 49 | 71 |
| Value 8 | 18 to 49 | 46 |
| Value 9 | 18 to 49 | 91 |
| Value 10 | 18 to 49 | 68 |
| Value 11 | 18 to 49 | 77 |
| Value 12 | 18 to 49 | 64 |
| Value 13 | 18 to 49 | 52 |
| Value 14 | 18 to 49 | 78 |
| Value 15 | 18 to 49 | 75 |
| Value 16 | 18 to 49 | 60 |
| Value 17 | 18 to 49 | 66 |
| Value 18 | 18 to 49 | 80 |
| Value 19 | 18 to 49 | 61 |
| Value 20 | 18 to 49 | 64 |
| Value 21 | 18 to 49 | 69 |
| Value 22 | 18 to 49 | 61 |
| Value 23 | 18 to 49 | 63 |
| Value 24 | 18 to 49 | 59 |
| Value 25 | 18 to 49 | 60 |
| Value 26 | 18 to 49 | 48 |
| Value 27 | 18 to 49 | 66 |
| Value 28 | 18 to 49 | 39 |
| Value 29 | 18 to 49 | 58 |
| Value 30 | 18 to 49 | 65 |
| Value 31 | 18 to 49 | 54 |
| Value 32 | 18 to 49 | 73 |
| Value 33 | 18 to 49 | 59 |
| Value 34 | 18 to 49 | 61 |
| Value 35 | 18 to 49 | 63 |
| Value 36 | 18 to 49 | 69 |
| Value 37 | 18 to 49 | 62 |
| Value 38 | 18 to 49 | 54 |
| Value 39 | 18 to 49 | 66 |
| Value 40 | 18 to 49 | 56 |
| Value 41 | 50 to 64 | 62 |
| Value 42 | 50 to 64 | 90 |
| Value 43 | 50 to 64 | 77 |
| Value 44 | 50 to 64 | 77 |
| Value 45 | 50 to 64 | 70 |
| Value 46 | 50 to 64 | 77 |
| Value 47 | 50 to 64 | 49 |
| Value 48 | 50 to 64 | 64 |
| Value 49 | 50 to 64 | 55 |
| Value 50 | 50 to 64 | 70 |
| Value 51 | 50 to 64 | 63 |
| Value 52 | 50 to 64 | 65 |
| Value 53 | 50 to 64 | 68 |
| Value 54 | 50 to 64 | 80 |
| Value 55 | 50 to 64 | 61 |
| Value 56 | 50 to 64 | 70 |
| Value 57 | 50 to 64 | 66 |
| Value 58 | 50 to 64 | 65 |
| Value 59 | 50 to 64 | 63 |
| Value 60 | 50 to 64 | 56 |
| Value 61 | 50 to 64 | 76 |
| Value 62 | 50 to 64 | 71 |
| Value 63 | 50 to 64 | 84 |
| Value 64 | 50 to 64 | 69 |
| Value 65 | 50 to 64 | 80 |
| Value 66 | 50 to 64 | 78 |
| Value 67 | 50 to 64 | 81 |
| Value 68 | 50 to 64 | 79 |
| Value 69 | 50 to 64 | 60 |
| Value 70 | 50 to 64 | 64 |
| Value 71 | 50 to 64 | 74 |
| Value 72 | 50 to 64 | 62 |
| Value 73 | 50 to 64 | 70 |
| Value 74 | 50 to 64 | 66 |
| Value 75 | 50 to 64 | 63 |
| Value 76 | 50 to 64 | 79 |
| Value 77 | 50 to 64 | 60 |
| Value 78 | 50 to 64 | 72 |
| Value 79 | 50 to 64 | 82 |
| Value 80 | 50 to 64 | 66 |
| Value 81 | 65 and over | 73 |
| Value 82 | 65 and over | 68 |
| Value 83 | 65 and over | 74 |
| Value 84 | 65 and over | 68 |
| Value 85 | 65 and over | 78 |
| Value 86 | 65 and over | 73 |
| Value 87 | 65 and over | 67 |
| Value 88 | 65 and over | 88 |
| Value 89 | 65 and over | 74 |
| Value 90 | 65 and over | 71 |
| Value 91 | 65 and over | 67 |
| Value 92 | 65 and over | 81 |
| Value 93 | 65 and over | 77 |
| Value 94 | 65 and over | 75 |
| Value 95 | 65 and over | 73 |
| Value 96 | 65 and over | 69 |
| Value 97 | 65 and over | 74 |
| Value 98 | 65 and over | 69 |
| Value 99 | 65 and over | 81 |
| Value 100 | 65 and over | 68 |
| Value 101 | 65 and over | 91 |
| Value 102 | 65 and over | 79 |
| Value 103 | 65 and over | 83 |
| Value 104 | 65 and over | 71 |
| Value 105 | 65 and over | 79 |
| Value 106 | 65 and over | 74 |
| Value 107 | 65 and over | 82 |
| Value 108 | 65 and over | 82 |
| Value 109 | 65 and over | 85 |
| Value 110 | 65 and over | 89 |
| Value 111 | 65 and over | 72 |
| Value 112 | 65 and over | 81 |
| Value 113 | 65 and over | 64 |
| Value 114 | 65 and over | 100 |
| Value 115 | 65 and over | 78 |
| Value 116 | 65 and over | 75 |
| Value 117 | 65 and over | 72 |
| Value 118 | 65 and over | 88 |
| Value 119 | 65 and over | 75 |
| Value 120 | 65 and over | 76 |
Source: examples/beeswarm.ts
// Every value as a point, spread in a beeswarm so none hides another.
import { stripPlot } from "enarratio/plot";
let seed = 3;
const random = (): number => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
const normal = (mean: number, sd: number): number =>
mean + sd * Math.sqrt(-2 * Math.log(random())) * Math.cos(2 * Math.PI * random());
const cohorts = { "18 to 49": [62, 10], "50 to 64": [70, 9], "65 and over": [78, 7] } as const;
const scores = Object.entries(cohorts).flatMap(([age, [mean, sd]]) =>
Array.from({ length: 40 }, () => ({ age, score: Math.min(100, Math.round(normal(mean, sd))) })),
);
export default stripPlot({
data: scores,
x: "age",
y: "score",
colorByGroup: true,
xLabel: "Age group",
yLabel: "Knowledge score",
title: "Vaccine knowledge scores by age",
alt: "Beeswarm of knowledge scores for 40 people in each of three age groups: scores rise with age, centered near 62 for 18 to 49, 70 for 50 to 64 and 78 for 65 and over, with the youngest group the most spread.",
caption: "Illustrative data.",
});
22Dot plot
A dot plot: two values per item, told apart by color and symbol.
Influenza vaccination by state
Dot plot with 2 series and 10 points. X axis, Adults vaccinated against influenza (%): 37 to 58. Y axis, state: Georgia to Vermont.
- 2015
- 2025
The highest value is 58 (2025, Vermont). The lowest is 37 (2015, Texas).
Illustrative data.
Show dataHide data
| state | 2015 | 2025 |
|---|---|---|
| Vermont | 51 | 58 |
| Oregon | 39 | 55 |
| Georgia | 41 | 52 |
| Ohio | 44 | 49 |
| Texas | 37 | 46 |
Source: examples/dot.ts
// A dot plot: two values per item, told apart by color and symbol.
import { dotPlot } from "enarratio/plot";
const rates = [
{ state: "Georgia", year: "2015", rate: 41 },
{ state: "Georgia", year: "2025", rate: 52 },
{ state: "Ohio", year: "2015", rate: 44 },
{ state: "Ohio", year: "2025", rate: 49 },
{ state: "Oregon", year: "2015", rate: 39 },
{ state: "Oregon", year: "2025", rate: 55 },
{ state: "Texas", year: "2015", rate: 37 },
{ state: "Texas", year: "2025", rate: 46 },
{ state: "Vermont", year: "2015", rate: 51 },
{ state: "Vermont", year: "2025", rate: 58 },
];
export default dotPlot({
data: rates,
y: "state",
x: "rate",
series: "year",
sort: "descending",
xLabel: "Adults vaccinated against influenza (%)",
title: "Influenza vaccination by state",
alt: "Dot plot of adult influenza vaccination in five states, 2015 and 2025: every state rose, Oregon the most, from 39% to 55%, and Vermont stays highest at 58%.",
caption: "Illustrative data.",
});
23Range plot
Ranges from low to high, with the median marked.
Central 95% of values in the cohort
Range plot with 5 points. X axis, Value in the analyte's unit: 0.6 to 39.6. Y axis, analyte: Hemoglobin (g/dL) to Creatinine (mg/dL).
The highest value is 25.3 (Platelets (10⁴/µL)). The lowest is 0.9 (Creatinine (mg/dL)). The range for Creatinine (mg/dL) is 0.6 to 1.3.
Illustrative data. The tick marks the median.
Show dataHide data
| analyte | low | high | median |
|---|---|---|---|
| Hemoglobin (g/dL) | 11.8 | 16.4 | 14.1 |
| White cells (10³/µL) | 4.1 | 10.9 | 6.8 |
| Platelets (10⁴/µL) | 15.2 | 39.6 | 25.3 |
| Glucose (mmol/L) | 3.9 | 7.2 | 5.1 |
| Creatinine (mg/dL) | 0.6 | 1.3 | 0.9 |
Source: examples/range.ts
// Ranges from low to high, with the median marked.
import { rangePlot } from "enarratio/plot";
const analytes = [
{ analyte: "Hemoglobin (g/dL)", low: 11.8, high: 16.4, median: 14.1 },
{ analyte: "White cells (10³/µL)", low: 4.1, high: 10.9, median: 6.8 },
{ analyte: "Platelets (10⁴/µL)", low: 15.2, high: 39.6, median: 25.3 },
{ analyte: "Glucose (mmol/L)", low: 3.9, high: 7.2, median: 5.1 },
{ analyte: "Creatinine (mg/dL)", low: 0.6, high: 1.3, median: 0.9 },
];
export default rangePlot({
data: analytes,
y: "analyte",
low: "low",
high: "high",
value: "median",
valueLabel: "median",
xLabel: "Value in the analyte's unit",
title: "Central 95% of values in the cohort",
alt: "Range of the central 95% of values for five blood analytes, with medians: hemoglobin 11.8 to 16.4 g/dL, white cells 4.1 to 10.9, platelets 15.2 to 39.6, glucose 3.9 to 7.2 mmol/L and creatinine 0.6 to 1.3 mg/dL.",
caption: "Illustrative data. The tick marks the median.",
});
24Dumbbell
Two values per item joined by a line, the ends told apart by fill as well as color.
Measles vaccine coverage, 2015 and 2025
Dumbbell plot with 2 series and 10 points. X axis, Measles first-dose coverage (%): 55 to 90. Y axis, country: Brazil to Viet Nam.
- 2015
- 2025
The highest value is 90 (2025, Viet Nam). The lowest is 55 (2015, Kenya).
Illustrative data. Hollow circles are 2015, filled ones 2025.
Show dataHide data
| country | 2015 | 2025 | Change |
|---|---|---|---|
| India | 61 | 89 | +28 |
| Viet Nam | 68 | 90 | +22 |
| Kenya | 55 | 71 | +16 |
| Brazil | 72 | 84 | +12 |
| Peru | 80 | 77 | −3 |
Source: examples/dumbbell.ts
// Two values per item joined by a line, the ends told apart by fill as well as color.
import { dumbbellPlot } from "enarratio/plot";
const coverage = [
{ country: "Brazil", y2015: 72, y2025: 84 },
{ country: "India", y2015: 61, y2025: 89 },
{ country: "Kenya", y2015: 55, y2025: 71 },
{ country: "Peru", y2015: 80, y2025: 77 },
{ country: "Viet Nam", y2015: 68, y2025: 90 },
];
export default dumbbellPlot({
data: coverage,
y: "country",
from: "y2015",
to: "y2025",
fromLabel: "2015",
toLabel: "2025",
sort: "change",
xLabel: "Measles first-dose coverage (%)",
title: "Measles vaccine coverage, 2015 and 2025",
alt: "Dumbbell plot of measles first-dose coverage in five countries: India rose most, from 61% to 89%, Viet Nam from 68% to 90%, Kenya from 55% to 71% and Brazil from 72% to 84%, while Peru fell from 80% to 77%.",
caption: "Illustrative data. Hollow circles are 2015, filled ones 2025.",
});
25Slope chart
A slope chart: each item's value at two times, labeled at both ends.
Enrolled majors, 2020 and 2025
Slope chart with 6 series and 12 points.
- Rising
- Falling
The highest value is 486 (Biology, 2025). The lowest is 92 (Statistics, 2020).
Illustrative data.
Show dataHide data
| program | 2020 | 2025 | Change |
|---|---|---|---|
| Biology | 412 | 486 | +74 |
| Chemistry | 238 | 251 | +13 |
| Microbiology | 118 | 176 | +58 |
| Physics | 164 | 142 | −22 |
| Public health | 196 | 263 | +67 |
| Statistics | 92 | 134 | +42 |
Source: examples/slope.ts
// A slope chart: each item's value at two times, labeled at both ends.
import { slopeChart } from "enarratio/plot";
const programs = [
{ program: "Biology", y2020: 412, y2025: 486 },
{ program: "Chemistry", y2020: 238, y2025: 251 },
{ program: "Microbiology", y2020: 118, y2025: 176 },
{ program: "Physics", y2020: 164, y2025: 142 },
{ program: "Public health", y2020: 196, y2025: 263 },
{ program: "Statistics", y2020: 92, y2025: 134 },
];
export default slopeChart({
data: programs,
label: "program",
from: "y2020",
to: "y2025",
fromLabel: "2020",
toLabel: "2025",
title: "Enrolled majors, 2020 and 2025",
alt: "Slope chart of majors in six programs between 2020 and 2025: five grew, Biology most, from 412 to 486, then Public health from 196 to 263, while Physics fell from 164 to 142.",
caption: "Illustrative data.",
});
26Donut
Parts of one whole, as a donut: at most six slices, each labeled with its share.
Confidence in vaccine safety
Pie chart with 5 points.
- Very confident
- Somewhat confident
- Not very confident
- Not at all confident
- Not sure
The highest value is 41.9% (Somewhat confident). The lowest is 3.6% (Not sure).
Illustrative data.
Show dataHide data
| answer | count | Share |
|---|---|---|
| Very confident | 412 | 32.1% |
| Somewhat confident | 538 | 41.9% |
| Not very confident | 201 | 15.7% |
| Not at all confident | 87 | 6.8% |
| Not sure | 46 | 3.6% |
| Total | 1,284 | 100% |
Source: examples/donut.ts
// Parts of one whole, as a donut: at most six slices, each labeled with its share.
import { pieChart } from "enarratio/plot";
const replies = [
{ answer: "Very confident", count: 412 },
{ answer: "Somewhat confident", count: 538 },
{ answer: "Not very confident", count: 201 },
{ answer: "Not at all confident", count: 87 },
{ answer: "Not sure", count: 46 },
];
export default pieChart({
data: replies,
label: "answer",
value: "count",
donut: true,
centerLabel: "1,284 replies",
title: "Confidence in vaccine safety",
alt: "Donut chart of 1,284 survey replies: 74% are very or somewhat confident in vaccine safety (32% and 42%), 16% not very confident, 7% not at all, and 4% not sure.",
caption: "Illustrative data.",
});
27Heatmap
A grid of values on the theme's sequential ramp.
Retention by cohort
Heatmap with 15 points. X axis, Months since joining: M0 to M4. Y axis, Cohort: Jan to May.
- below 50
- 50 to 60
- 60 to 70
- 70 to 85
- 85 or more
The highest value is 100 (Jan, M0). The lowest is 45 (Jan, M4).
Illustrative data.
Show dataHide data
| Cohort \ Months since joining | M0 | M1 | M2 | M3 | M4 |
|---|---|---|---|---|---|
| Jan | 100 | 82 | 67 | 55 | 45 |
| Feb | 100 | 80 | 65 | 53 | |
| Mar | 100 | 78 | 63 | ||
| Apr | 100 | 76 | |||
| May | 100 |
Source: examples/heatmap.ts
// A grid of values on the theme's sequential ramp.
import { heatmap } from "enarratio/plot";
const cohorts = ["Jan", "Feb", "Mar", "Apr", "May"];
const retention = cohorts.flatMap((cohort, c) =>
Array.from({ length: 5 - c }, (_, m) => ({
cohort,
month: `M${m}`,
retained: m === 0 ? 100 : Math.round(100 * 0.82 ** m - c * 2),
})),
);
export default heatmap({
data: retention,
x: "month",
y: "cohort",
value: "retained",
valueLabel: "% retained",
xDomain: ["M0", "M1", "M2", "M3", "M4"],
thresholds: [50, 60, 70, 85],
xLabel: "Months since joining",
yLabel: "Cohort",
title: "Retention by cohort",
alt: "Retention heatmap for five monthly cohorts: every cohort starts at 100% and falls about 18 points a month, reaching 45% at month four for the January cohort.",
caption: "Illustrative data.",
});
28Table with graphics
A table with an inline bar, heat cells and a sparkline in every row.
Testing by site this week
Testing at five sites this week: Charlotte runs the most tests, 5,120, and has the highest positivity, 11.6%, up 2.9 points; Wilmington has the lowest, 4.9%; positivity fell at Durham and Greensboro.
Table with 5 points.
The highest value is 5,120 (Tests, Charlotte). The lowest is 1,290 (Tests, Wilmington).
Illustrative data. Every cell prints its value; bars and colors repeat it.
| Site | Tests | Positivity (%) | Change (points) | Last six weeks |
|---|---|---|---|---|
| Asheville | 1,840 | 8.2 | 1.4 | |
| Charlotte | 5,120 | 11.6 | 2.9 | |
| Durham | 3,310 | 9.1 | -0.8 | |
| Greensboro | 2,470 | 6.4 | -1.6 | |
| Wilmington | 1,290 | 4.9 | 0.3 |
Source: examples/table-chart.ts
// A table with an inline bar, heat cells and a sparkline in every row.
import { tableChart } from "enarratio";
const sites = [
{ site: "Asheville", tests: 1840, positivity: 8.2, change: 1.4, weeks: [5, 6, 6, 7, 8, 8.2] },
{ site: "Charlotte", tests: 5120, positivity: 11.6, change: 2.9, weeks: [7, 8, 9, 10, 11, 11.6] },
{
site: "Durham",
tests: 3310,
positivity: 9.1,
change: -0.8,
weeks: [10, 10.4, 9.9, 9.6, 9.3, 9.1],
},
{
site: "Greensboro",
tests: 2470,
positivity: 6.4,
change: -1.6,
weeks: [8.2, 8, 7.4, 7, 6.8, 6.4],
},
{
site: "Wilmington",
tests: 1290,
positivity: 4.9,
change: 0.3,
weeks: [4.5, 4.6, 4.4, 4.8, 4.7, 4.9],
},
];
export default tableChart({
data: sites,
columns: [
{ field: "site", label: "Site" },
{ field: "tests", label: "Tests", kind: "bar" },
{ field: "positivity", label: "Positivity (%)", kind: "heat" },
{ field: "change", label: "Change (points)", kind: "bar" },
{ field: "weeks", label: "Last six weeks", kind: "sparkline" },
],
title: "Testing by site this week",
alt: "Testing at five sites this week: Charlotte runs the most tests, 5,120, and has the highest positivity, 11.6%, up 2.9 points; Wilmington has the lowest, 4.9%; positivity fell at Durham and Greensboro.",
caption: "Illustrative data. Every cell prints its value; bars and colors repeat it.",
});
29Network
A force-directed network, laid out on the server.
Co-authorship network
Network with 10 points.
- Virology
- Epidemiology
- Statistics
- Public health
10 nodes and 14 links. Most connections: Edwards (4), Ramos (4) and Lindqvist (3). Every node is linked to the rest.
Illustrative data. Links are co-authored papers.
Show dataHide data
| Node | Group | Connected to |
|---|---|---|
| Edwards | Virology | Okafor, Lindqvist, Ramos, Novak |
| Okafor | Virology | Edwards, Lindqvist |
| Lindqvist | Virology | Edwards, Okafor, Ibarra |
| Ramos | Epidemiology | Edwards, Chen, Haddad, Sato |
| Chen | Epidemiology | Ramos, Haddad, Novak |
| Haddad | Epidemiology | Chen, Ramos, Mbeki |
| Novak | Statistics | Chen, Ibarra, Edwards |
| Ibarra | Statistics | Novak, Lindqvist |
| Mbeki | Public health | Haddad, Sato |
| Sato | Public health | Mbeki, Ramos |
Source: examples/network.ts
// A force-directed network, laid out on the server.
import { networkChart } from "enarratio/science";
const people = [
["Edwards", "Virology"],
["Okafor", "Virology"],
["Lindqvist", "Virology"],
["Ramos", "Epidemiology"],
["Chen", "Epidemiology"],
["Haddad", "Epidemiology"],
["Novak", "Statistics"],
["Ibarra", "Statistics"],
["Mbeki", "Public health"],
["Sato", "Public health"],
] as const;
const pairs = [
["Edwards", "Okafor"],
["Edwards", "Lindqvist"],
["Okafor", "Lindqvist"],
["Edwards", "Ramos"],
["Ramos", "Chen"],
["Chen", "Haddad"],
["Ramos", "Haddad"],
["Chen", "Novak"],
["Novak", "Ibarra"],
["Edwards", "Novak"],
["Haddad", "Mbeki"],
["Mbeki", "Sato"],
["Ramos", "Sato"],
["Lindqvist", "Ibarra"],
] as const;
export default networkChart({
nodes: people.map(([id, group]) => ({ id, group })),
links: pairs.map(([source, target]) => ({ source, target })),
title: "Co-authorship network",
alt: "Network of ten co-authors in four fields. Edwards and Ramos are the hubs, with four co-authors each. Edwards links the virus team to statistics and epidemiology. Ramos links epidemiology to public health.",
caption: "Illustrative data. Links are co-authored papers.",
});
30Titers
Hemagglutination inhibition titers by timepoint on a two-fold dilution axis.
HAI titers after vaccination
Titer plot with 48 points. X axis, Timepoint: Day 0 to Day 180. Y axis, HAI titer: 1:5 to 1:1280.
The highest value is 1:1280 (Day 28). The lowest is 1:5 (Day 0). Geometric mean titers: 11.9 (Day 0, 95% CI 8.1 to 17.4), 135 (Day 14, 95% CI 78.8 to 230), 269 (Day 28, 95% CI 158 to 460), 67.3 (Day 180, 95% CI 39.4 to 115). The limit of detection is 1:10; 2 of 48 samples are below it.
Illustrative data. Bars are geometric mean titers with 95% confidence intervals; hollow points are below the limit of detection, plotted at half the limit.
Show dataHide data
| Timepoint | n | GMT | 95% CI | Below LOD | Titers |
|---|---|---|---|---|---|
| Day 0 | 12 | 11.9 | 8.1 to 17.4 | 2 | <10, <10, 10, 10, 10, 20, 20, 40, 10, 10, 20, 10 |
| Day 14 | 12 | 135 | 78.8 to 230 | 0 | 40, 80, 80, 160, 160, 160, 320, 80, 40, 160, 320, 640 |
| Day 28 | 12 | 269 | 158 to 460 | 0 | 80, 160, 160, 320, 320, 320, 640, 160, 80, 320, 640, 1280 |
| Day 180 | 12 | 67.3 | 39.4 to 115 | 0 | 20, 40, 40, 80, 80, 80, 160, 40, 20, 80, 160, 320 |
Source: examples/titer.ts
// Hemagglutination inhibition titers by timepoint on a two-fold dilution axis.
import { titerPlot } from "enarratio/science";
const titers: Record<string, number[]> = {
"Day 0": [5, 5, 10, 10, 10, 20, 20, 40, 10, 10, 20, 10],
"Day 14": [40, 80, 80, 160, 160, 160, 320, 80, 40, 160, 320, 640],
"Day 28": [80, 160, 160, 320, 320, 320, 640, 160, 80, 320, 640, 1280],
"Day 180": [20, 40, 40, 80, 80, 80, 160, 40, 20, 80, 160, 320],
};
const sera = Object.entries(titers).flatMap(([timepoint, values]) =>
values.map((hai) => ({ timepoint, hai })),
);
export default titerPlot({
data: sera,
group: "timepoint",
titer: "hai",
dilution: { start: 10, factor: 2 },
groupLabel: "Timepoint",
titerLabel: "HAI titer",
title: "HAI titers after vaccination",
alt: "HAI titers for 12 participants at four timepoints. The geometric mean titer rises from about 12 at day 0 to about 270 at day 28, then wanes to about 67 by day 180. Two day 0 samples were below the 1:10 limit of detection.",
caption:
"Illustrative data. Bars are geometric mean titers with 95% confidence intervals; hollow points are below the limit of detection, plotted at half the limit.",
});
31Genome map
A viral genome map: features to scale, arrows by strand, overlaps stacked into lanes.
HIV-1 HXB2 genome
Genome track with 12 points. X axis, Position (nt): 1 to 9,719.
- Gene
- Accessory
- Antisense
- LTR
12 features, from 76 nt (rev) to 3,012 nt (pol) long. Strands: 9 forward, 1 reverse and 2 with no strand. 8 pairs of features overlap.
Coordinates approximate the HXB2 reference (GenBank K03455) and are rounded for display.
Show dataHide data
| Feature | Type | Start | End | Length (nt) | Strand |
|---|---|---|---|---|---|
| 5' LTR | LTR | 1 | 634 | 634 | |
| gag | Gene | 790 | 2292 | 1503 | + |
| pol | Gene | 2085 | 5096 | 3012 | + |
| vif | Accessory | 5041 | 5619 | 579 | + |
| vpr | Accessory | 5559 | 5850 | 292 | + |
| tat | Accessory | 5831 | 6045 | 215 | + |
| rev | Accessory | 5970 | 6045 | 76 | + |
| vpu | Accessory | 6062 | 6310 | 249 | + |
| env | Gene | 6225 | 8795 | 2571 | + |
| asp | Antisense | 7270 | 8260 | 991 | - |
| nef | Accessory | 8797 | 9417 | 621 | + |
| 3' LTR | LTR | 9086 | 9719 | 634 |
Source: examples/genome.ts
// A viral genome map: features to scale, arrows by strand, overlaps stacked into lanes.
import { genomeTrack } from "enarratio/science";
export default genomeTrack({
length: 9719,
features: [
{ name: "5' LTR", start: 1, end: 634, type: "LTR" },
{ name: "gag", start: 790, end: 2292, strand: 1, type: "Gene" },
{ name: "pol", start: 2085, end: 5096, strand: 1, type: "Gene" },
{ name: "vif", start: 5041, end: 5619, strand: 1, type: "Accessory" },
{ name: "vpr", start: 5559, end: 5850, strand: 1, type: "Accessory" },
{ name: "tat", start: 5831, end: 6045, strand: 1, type: "Accessory" },
{ name: "rev", start: 5970, end: 6045, strand: 1, type: "Accessory" },
{ name: "vpu", start: 6062, end: 6310, strand: 1, type: "Accessory" },
{ name: "env", start: 6225, end: 8795, strand: 1, type: "Gene" },
{ name: "nef", start: 8797, end: 9417, strand: 1, type: "Accessory" },
{ name: "3' LTR", start: 9086, end: 9719, type: "LTR" },
{ name: "asp", start: 7270, end: 8260, strand: -1, type: "Antisense" },
],
typeDomain: ["Gene", "Accessory", "Antisense", "LTR"],
title: "HIV-1 HXB2 genome",
alt: "Map of the 9,719-nucleotide HIV-1 HXB2 genome: long terminal repeats at both ends; the gag, pol and env genes on the forward strand, overlapping at their junctions; six accessory genes between pol and env and after env; and the antisense asp gene on the reverse strand inside env.",
caption:
"Coordinates approximate the HXB2 reference (GenBank K03455) and are rounded for display.",
});
32Dose-response
Dose-response curves: four-parameter logistic fits with the IC50 marked.
Inhibition of viral polymerase activity
Dose-response curve with 2 series and 48 points. X axis, Concentration (µM): 0.01 to 30. Y axis, Polymerase activity (% of control): -2.4 to 101.3.
- Oseltamivir
- Baloxavir
Oseltamivir fell from 96.4 (Concentration (µM) 0.01) to 2.4 (Concentration (µM) 30). Baloxavir fell from 97.4 (Concentration (µM) 0.01) to 1.1 (Concentration (µM) 30). The highest value is 101.3 (Oseltamivir, Concentration (µM) 0.01). The lowest is -2.4 (Baloxavir, Concentration (µM) 3).
Illustrative data. Four-parameter logistic fit by least squares: Oseltamivir: IC50 1.2 (95% CI 1.09 to 1.32), Hill slope -1.11, bottom 0.205, top 99.9; Baloxavir: IC50 0.086 (95% CI 0.079 to 0.0936), Hill slope -1.4, bottom 0.335, top 101.
Show dataHide data
| compound | Concentration (µM) | Polymerase activity (% of control) | Fitted |
|---|---|---|---|
| Oseltamivir | 0.01 | 96.4 | 99.4 |
| Oseltamivir | 0.01 | 101.3 | 99.4 |
| Oseltamivir | 0.01 | 100.4 | 99.4 |
| Baloxavir | 0.01 | 97.4 | 96.6 |
| Baloxavir | 0.01 | 96.5 | 96.6 |
| Baloxavir | 0.01 | 94.4 | 96.6 |
| Oseltamivir | 0.03 | 97.1 | 98.3 |
| Oseltamivir | 0.03 | 100.9 | 98.3 |
| Oseltamivir | 0.03 | 97.9 | 98.3 |
| Baloxavir | 0.03 | 84.9 | 82.5 |
| Baloxavir | 0.03 | 81.9 | 82.5 |
| Baloxavir | 0.03 | 83.4 | 82.5 |
| Oseltamivir | 0.1 | 95 | 93.9 |
| Oseltamivir | 0.1 | 91.7 | 93.9 |
| Oseltamivir | 0.1 | 94.2 | 93.9 |
| Baloxavir | 0.1 | 44.1 | 45.5 |
| Baloxavir | 0.1 | 46.6 | 45.5 |
| Baloxavir | 0.1 | 43.2 | 45.5 |
| Oseltamivir | 0.3 | 79 | 82.2 |
| Oseltamivir | 0.3 | 83.9 | 82.2 |
| Oseltamivir | 0.3 | 83 | 82.2 |
| Baloxavir | 0.3 | 17.4 | 15.4 |
| Baloxavir | 0.3 | 16.5 | 15.4 |
| Baloxavir | 0.3 | 14.4 | 15.4 |
| Oseltamivir | 1 | 53.8 | 55 |
| Oseltamivir | 1 | 57.6 | 55 |
| Oseltamivir | 1 | 54.6 | 55 |
| Baloxavir | 1 | 5.9 | 3.52 |
| Baloxavir | 1 | 2.9 | 3.52 |
| Baloxavir | 1 | 4.4 | 3.52 |
| Oseltamivir | 3 | 27.8 | 26.6 |
| Oseltamivir | 3 | 24.5 | 26.6 |
| Oseltamivir | 3 | 27 | 26.6 |
| Baloxavir | 3 | -1.5 | 1.04 |
| Baloxavir | 3 | 1 | 1.04 |
| Baloxavir | 3 | -2.4 | 1.04 |
| Oseltamivir | 10 | 5.7 | 8.85 |
| Oseltamivir | 10 | 10.6 | 8.85 |
| Oseltamivir | 10 | 9.7 | 8.85 |
| Baloxavir | 10 | 1.9 | 0.467 |
| Baloxavir | 10 | 1 | 0.467 |
| Baloxavir | 10 | -1.1 | 0.467 |
| Oseltamivir | 30 | 1.6 | 2.93 |
| Oseltamivir | 30 | 5.4 | 2.93 |
| Oseltamivir | 30 | 2.4 | 2.93 |
| Baloxavir | 30 | 2.6 | 0.363 |
| Baloxavir | 30 | -0.4 | 0.363 |
| Baloxavir | 30 | 1.1 | 0.363 |
Source: examples/dose-response.ts
// Dose-response curves: four-parameter logistic fits with the IC50 marked.
import { doseResponse } from "enarratio/science";
const doses = [0.01, 0.03, 0.1, 0.3, 1, 3, 10, 30];
const curve = (ic50: number, hill: number, dose: number): number =>
100 / (1 + (dose / ic50) ** hill);
// Three replicates per dose, offset by a fixed pattern of noise.
const noise = [-3.1, 1.8, 0.9, -1.2, 2.6, -0.4, 1.1, -2.2, 0.3];
const wells = (["Oseltamivir", "Baloxavir"] as const).flatMap((compound, c) =>
doses.flatMap((dose, d) =>
[0, 1, 2].map((r) => ({
compound,
dose,
activity:
Math.round(
(curve(c === 0 ? 1.2 : 0.09, 1.1 + 0.3 * c, dose) + (noise[(d * 3 + r + c) % 9] ?? 0)) *
10,
) / 10,
})),
),
);
export default doseResponse({
data: wells,
dose: "dose",
response: "activity",
group: "compound",
midpointLabel: "IC50",
doseLabel: "Concentration (µM)",
responseLabel: "Polymerase activity (% of control)",
title: "Inhibition of viral polymerase activity",
alt: "Dose-response curves for two antivirals, three replicates at eight concentrations: activity falls from about 100% to near 0 for both, baloxavir at about a fourteenth the concentration of oseltamivir, with IC50s near 0.09 µM and 1.2 µM.",
caption: "Illustrative data.",
});
33Survival
Kaplan-Meier survival curves with censor marks and the number at risk.
Remission in childhood leukemia
Survival curve with 2 series and 21 points. X axis, Weeks: 0 to 35. Y axis, Remaining in remission: 0 to 1.
- 6-MP
- Placebo
6-MP fell from 1.000 (Weeks 0) to 0.448 (Weeks 23). Placebo fell from 1.000 (Weeks 0) to 0.000 (Weeks 23). The highest value is 1.000 (6-MP, Weeks 0). The lowest is 0.000 (Placebo, Weeks 23). For 6-MP, the 95% CI for Weeks 23 is 0.188 to 0.680. For Placebo, the 95% CI for Weeks 23 is 0.000 to 1.000.
Freireich et al. (1963) as tabulated by Gehan (1965). Kaplan-Meier estimates; + marks a censored subject. 6-MP: n 21, 9 events, median survival 23; Placebo: n 21, 21 events, median survival 8.
Show dataHide data
| arm | Weeks | At risk | Events | Censored | Survival | 95% CI |
|---|---|---|---|---|---|---|
| 6-MP | 6 | 21 | 3 | 1 | 0.857 | 0.620 to 0.952 |
| 6-MP | 7 | 17 | 1 | 0 | 0.807 | 0.563 to 0.923 |
| 6-MP | 10 | 15 | 1 | 1 | 0.753 | 0.503 to 0.889 |
| 6-MP | 13 | 12 | 1 | 0 | 0.690 | 0.432 to 0.849 |
| 6-MP | 16 | 11 | 1 | 0 | 0.627 | 0.368 to 0.805 |
| 6-MP | 22 | 7 | 1 | 0 | 0.538 | 0.268 to 0.747 |
| 6-MP | 23 | 6 | 1 | 0 | 0.448 | 0.188 to 0.680 |
| Placebo | 1 | 21 | 2 | 0 | 0.905 | 0.670 to 0.975 |
| Placebo | 2 | 19 | 2 | 0 | 0.810 | 0.569 to 0.924 |
| Placebo | 3 | 17 | 1 | 0 | 0.762 | 0.519 to 0.893 |
| Placebo | 4 | 16 | 2 | 0 | 0.667 | 0.425 to 0.825 |
| Placebo | 5 | 14 | 2 | 0 | 0.571 | 0.338 to 0.749 |
| Placebo | 8 | 12 | 4 | 0 | 0.381 | 0.183 to 0.578 |
| Placebo | 11 | 8 | 2 | 0 | 0.286 | 0.117 to 0.482 |
| Placebo | 12 | 6 | 2 | 0 | 0.190 | 0.059 to 0.377 |
| Placebo | 15 | 4 | 1 | 0 | 0.143 | 0.036 to 0.321 |
| Placebo | 17 | 3 | 1 | 0 | 0.095 | 0.016 to 0.261 |
| Placebo | 22 | 2 | 1 | 0 | 0.048 | 0.003 to 0.197 |
| Placebo | 23 | 1 | 1 | 0 | 0.000 | 0.000 to 1.000 |
Source: examples/survival.ts
// Kaplan-Meier survival curves with censor marks and the number at risk.
import { survivalPlot } from "enarratio/science";
// Remission times in weeks for 6-MP and placebo (Freireich et al. 1963, Gehan 1965); a + marks censoring.
const arms: Record<string, string> = {
"6-MP": "6 6 6 6+ 7 9+ 10 10+ 11+ 13 16 17+ 19+ 20+ 22 23 25+ 32+ 32+ 34+ 35+",
Placebo: "1 1 2 2 3 4 4 5 5 8 8 8 8 11 11 12 12 15 17 22 23",
};
const patients = Object.entries(arms).flatMap(([arm, times]) =>
times.split(" ").map((t) => ({ arm, weeks: Number.parseInt(t, 10), relapsed: !t.endsWith("+") })),
);
export default survivalPlot({
data: patients,
time: "weeks",
event: "relapsed",
group: "arm",
timeLabel: "Weeks",
survivalLabel: "Remaining in remission",
title: "Remission in childhood leukemia",
alt: "Kaplan-Meier curves of remission for 21 patients on 6-MP and 21 on placebo: every placebo patient relapsed by week 23, with half by week 8, while on 6-MP 12 were censored and the estimate stays at 0.45 from week 23.",
caption: "Freireich et al. (1963) as tabulated by Gehan (1965).",
});
34Forest plot
A forest plot: study estimates with intervals, and the pooled estimate as a diamond.
Vaccination and hospitalization, six trials
Forest plot with 7 points. X axis, Odds ratio of hospitalization (95% CI): 0.4 to 1.55. Y axis, trial: Alvarez 2019 to Pooled (random effects).
The highest value is 1.04 (Diallo 2022). The lowest is 0.61 (Chen 2021). The 95% CI for Pooled (random effects) is 0.692 to 0.89.
Illustrative data. Pooled by inverse-variance weighting, random effects (DerSimonian-Laird) on the log scale: Q 4.31, I² 0%, τ² 0.
Show dataHide data
| trial | Odds ratio of hospitalization (95% CI) | 95% CI | Weight |
|---|---|---|---|
| Alvarez 2019 | 0.72 | 0.51 to 1.02 | 13.1% |
| Becker 2020 | 0.85 | 0.66 to 1.09 | 25% |
| Chen 2021 | 0.61 | 0.4 to 0.93 | 8.8% |
| Diallo 2022 | 1.04 | 0.7 to 1.55 | 9.9% |
| Evans 2023 | 0.78 | 0.63 to 0.97 | 33.7% |
| Fischer 2024 | 0.69 | 0.46 to 1.04 | 9.4% |
| Pooled (random effects) | 0.785 | 0.692 to 0.89 | 100% |
Source: examples/forest.ts
// A forest plot: study estimates with intervals, and the pooled estimate as a diamond.
import { forestPlot } from "enarratio/science";
const trials = [
{ trial: "Alvarez 2019", or: 0.72, lo: 0.51, hi: 1.02 },
{ trial: "Becker 2020", or: 0.85, lo: 0.66, hi: 1.09 },
{ trial: "Chen 2021", or: 0.61, lo: 0.4, hi: 0.93 },
{ trial: "Diallo 2022", or: 1.04, lo: 0.7, hi: 1.55 },
{ trial: "Evans 2023", or: 0.78, lo: 0.63, hi: 0.97 },
{ trial: "Fischer 2024", or: 0.69, lo: 0.46, hi: 1.04 },
];
export default forestPlot({
data: trials,
label: "trial",
estimate: "or",
lower: "lo",
upper: "hi",
log: true,
pooled: "random",
estimateLabel: "Odds ratio of hospitalization (95% CI)",
favors: ["Favors vaccine", "Favors placebo"],
title: "Vaccination and hospitalization, six trials",
alt: "Forest plot of six trials: five odds ratios fall below 1, two of their intervals clear of it, and one, Diallo 2022, sits just above 1; the pooled random-effects odds ratio is about 0.79, with an interval below 1.",
caption: "Illustrative data.",
});
35Phylogenetic tree
A phylogenetic tree from Newick, tips colored by clade, support at the nodes.
H5N1 hemagglutinin phylogeny
Phylogenetic tree with 8 points. X axis, Substitutions per site: 0 to 0.06. Y axis, Tips: A/duck/Ohio/2024 to A/goose/Guangdong/1996.
- 2.3.4.4b, B3.13
- 2.3.4.4b, other
- 2.3.4.4b, Asia
- Ancestral
The highest value is 0.06 (A/goose/Guangdong/1996). The lowest is 0.04 (A/swan/Niigata/2022).
Illustrative tree and branch lengths.
Show dataHide data
| Node | Parent | Branch length | Distance from root | Label | Group |
|---|---|---|---|---|---|
| Root | 0 | ||||
| Node 1 | Root | 0.024 | 0.024 | 100 | |
| Node 2 | Node 1 | 0.011 | 0.035 | 87 | |
| Node 3 | Node 2 | 0.006 | 0.041 | 98 | |
| A/duck/Ohio/2024 | Node 3 | 0.012 | 0.053 | 2.3.4.4b, B3.13 | |
| A/chicken/Iowa/2024 | Node 3 | 0.009 | 0.05 | 2.3.4.4b, B3.13 | |
| A/cow/Texas/2024 | Node 2 | 0.015 | 0.05 | 2.3.4.4b, B3.13 | |
| Node 4 | Node 1 | 0.009 | 0.033 | 92 | |
| A/gull/Maine/2023 | Node 4 | 0.018 | 0.051 | 2.3.4.4b, other | |
| A/fox/Vermont/2023 | Node 4 | 0.021 | 0.054 | 2.3.4.4b, other | |
| Node 5 | Root | 0.008 | 0.008 | 76 | |
| Node 6 | Node 5 | 0.019 | 0.027 | 95 | |
| A/duck/Hokkaido/2022 | Node 6 | 0.016 | 0.043 | 2.3.4.4b, Asia | |
| A/swan/Niigata/2022 | Node 6 | 0.013 | 0.04 | 2.3.4.4b, Asia | |
| A/goose/Guangdong/1996 | Node 5 | 0.052 | 0.06 | Ancestral |
Source: examples/tree.ts
// A phylogenetic tree from Newick, tips colored by clade, support at the nodes.
import { phylogeneticTree } from "enarratio/science";
const newick =
"((((A/duck/Ohio/2024:0.012,A/chicken/Iowa/2024:0.009)98:0.006,A/cow/Texas/2024:0.015)87:0.011,(A/gull/Maine/2023:0.018,A/fox/Vermont/2023:0.021)92:0.009)100:0.024,((A/duck/Hokkaido/2022:0.016,A/swan/Niigata/2022:0.013)95:0.019,A/goose/Guangdong/1996:0.052)76:0.008);";
export default phylogeneticTree({
tree: newick,
groups: {
"A/duck/Ohio/2024": "2.3.4.4b, B3.13",
"A/chicken/Iowa/2024": "2.3.4.4b, B3.13",
"A/cow/Texas/2024": "2.3.4.4b, B3.13",
"A/gull/Maine/2023": "2.3.4.4b, other",
"A/fox/Vermont/2023": "2.3.4.4b, other",
"A/duck/Hokkaido/2022": "2.3.4.4b, Asia",
"A/swan/Niigata/2022": "2.3.4.4b, Asia",
"A/goose/Guangdong/1996": "Ancestral",
},
title: "H5N1 hemagglutinin phylogeny",
alt: "Rectangular phylogenetic tree of eight H5N1 hemagglutinin sequences: the three 2024 B3.13 viruses from a duck, a chicken and a cow cluster together with 98 and 87 support, beside two 2023 New England viruses; two 2022 Japanese viruses group with the 1996 Guangdong goose virus on the longest branch.",
caption: "Illustrative tree and branch lengths.",
});
36Sparklines
A primitive as a whole figure (the default): title, generated description, drawing, table and CSV.
Entries per year, 2019 to 2024
Sparkline with 6 points.
Show dataHide data
| Position | Entries per year, 2019 to 2024 |
|---|---|
| 1 | 4 |
| 2 | 5 |
| 3 | 7 |
| 4 | 11 |
| 5 | 8 |
| 6 | 10 |
Source: examples/sparkline.ts
// A primitive as a whole figure (the default): title, generated description, drawing, table and CSV.
import { sparkline } from "enarratio";
export default sparkline({
values: [4, 5, 7, 11, 8, 10],
label: "Entries per year, 2019 to 2024",
area: true,
});
37Rings and uptime
Small primitives with the same styling: progress rings inline, and an uptime strip as a figure.
Site, last 48 hours
Uptime strip with 48 points.
Show dataHide data
| Period | Status |
|---|---|
| Period 1 | no data |
| Period 2 | no data |
| Period 3 | no data |
| Period 4 | no data |
| Period 5 | up |
| Period 6 | up |
| Period 7 | up |
| Period 8 | up |
| Period 9 | up |
| Period 10 | up |
| Period 11 | up |
| Period 12 | up |
| Period 13 | up |
| Period 14 | up |
| Period 15 | up |
| Period 16 | up |
| Period 17 | up |
| Period 18 | up |
| Period 19 | up |
| Period 20 | up |
| Period 21 | up |
| Period 22 | up |
| Period 23 | up |
| Period 24 | up |
| Period 25 | up |
| Period 26 | up |
| Period 27 | up |
| Period 28 | up |
| Period 29 | up |
| Period 30 | degraded |
| Period 31 | down |
| Period 32 | degraded |
| Period 33 | up |
| Period 34 | up |
| Period 35 | up |
| Period 36 | up |
| Period 37 | up |
| Period 38 | up |
| Period 39 | up |
| Period 40 | up |
| Period 41 | up |
| Period 42 | up |
| Period 43 | up |
| Period 44 | up |
| Period 45 | up |
| Period 46 | up |
| Period 47 | up |
| Period 48 | up |
Source: examples/primitives.ts
// Small primitives with the same styling: progress rings inline, and an uptime strip as a figure.
import { progressRing, type UptimeStatus, uptimeStrip } from "enarratio";
const hours = Array.from({ length: 48 }, (_, i): UptimeStatus => {
if (i < 4) return "unknown";
if (i === 30) return "down";
return i === 29 || i === 31 ? "degraded" : "up";
});
export default `<div class="primitives">
${progressRing({ value: 7, max: 12, label: "Chapters drafted", inline: true })}
${progressRing({ value: 0.92, label: "Tests passing", size: 56, inline: true })}
${uptimeStrip({ slots: hours, label: "Site, last 48 hours" })}
</div>`;
38Heat strip
A heat strip: a one-row strip on the sequential ramp, here requests per hour over two days.
Requests per hour, 7 and 8 June
Heat strip with 48 points.
Show dataHide data
| Cell | Value | Ramp step |
|---|---|---|
| 7 June 00:00 | 40 | 1 |
| 7 June 01:00 | 40 | 1 |
| 7 June 02:00 | 40 | 1 |
| 7 June 03:00 | 40 | 1 |
| 7 June 04:00 | 40 | 1 |
| 7 June 05:00 | 41 | 1 |
| 7 June 06:00 | 43 | 1 |
| 7 June 07:00 | 47 | 1 |
| 7 June 08:00 | 57 | 1 |
| 7 June 09:00 | 74 | 1 |
| 7 June 10:00 | 99 | 2 |
| 7 June 11:00 | 131 | 3 |
| 7 June 12:00 | 165 | 4 |
| 7 June 13:00 | 190 | 4 |
| 7 June 14:00 | 200 | 5 |
| 7 June 15:00 | 190 | 4 |
| 7 June 16:00 | 165 | 4 |
| 7 June 17:00 | 131 | 3 |
| 7 June 18:00 | 99 | 2 |
| 7 June 19:00 | 74 | 1 |
| 7 June 20:00 | no data | |
| 7 June 21:00 | no data | |
| 7 June 22:00 | 43 | 1 |
| 7 June 23:00 | 41 | 1 |
| 8 June 00:00 | 70 | 1 |
| 8 June 01:00 | 70 | 1 |
| 8 June 02:00 | 70 | 1 |
| 8 June 03:00 | 70 | 1 |
| 8 June 04:00 | 70 | 1 |
| 8 June 05:00 | 71 | 1 |
| 8 June 06:00 | 73 | 1 |
| 8 June 07:00 | 77 | 1 |
| 8 June 08:00 | 87 | 2 |
| 8 June 09:00 | 104 | 2 |
| 8 June 10:00 | 129 | 3 |
| 8 June 11:00 | 161 | 4 |
| 8 June 12:00 | 195 | 5 |
| 8 June 13:00 | 220 | 5 |
| 8 June 14:00 | 230 | 5 |
| 8 June 15:00 | 220 | 5 |
| 8 June 16:00 | 195 | 5 |
| 8 June 17:00 | 161 | 4 |
| 8 June 18:00 | 129 | 3 |
| 8 June 19:00 | 104 | 2 |
| 8 June 20:00 | 87 | 2 |
| 8 June 21:00 | 77 | 1 |
| 8 June 22:00 | 73 | 1 |
| 8 June 23:00 | 71 | 1 |
Source: examples/heat-strip.ts
// A heat strip: a one-row strip on the sequential ramp, here requests per hour over two days.
import { heatStrip } from "enarratio";
const hours = Array.from({ length: 48 }, (_, i) => {
// Quiet at night, busy in the afternoon; the monitor missed hours 20 and 21.
if (i === 20 || i === 21) return null;
const hour = i % 24;
return Math.round(40 + 160 * Math.exp(-(((hour - 14) / 4) ** 2)) + (i >= 24 ? 30 : 0));
});
export default `<div class="primitives">
${heatStrip({
values: hours,
label: "Requests per hour, 7 and 8 June",
width: 480,
cellLabels: hours.map(
(_, i) => `${i < 24 ? "7" : "8"} June ${String(i % 24).padStart(2, "0")}:00`,
),
})}
</div>`;
39Timeline
An event timeline: a lane for each agent, spans and point events, status by color and shape.
Agent runs, 9 June 2026
Timeline with 8 points.
- Ingest agent: Nightly import, ok, from 2026-06-09 00:30 UTC to 2026-06-09 02:15 UTC
- Ingest agent: Source slow, warning, from 2026-06-09 01:00 UTC to 2026-06-09 01:45 UTC
- Analysis agent: Model run 41, ok, from 2026-06-09 02:30 UTC to 2026-06-09 05:00 UTC
- Analysis agent: Model run 42 failed, error, 2026-06-09 06:20 UTC
- Report agent: Weekly report sent, ok, 2026-06-09 07:00 UTC
- Ingest agent: Morning import, ok, from 2026-06-09 08:00 UTC to 2026-06-09 08:40 UTC
- Analysis agent: Heartbeat lost, unknown, from 2026-06-09 09:00 UTC to 2026-06-09 10:30 UTC
- Report agent: Chart refresh retried, warning, 2026-06-09 11:00 UTC
Show dataHide data
| Event | Lane | Status | Start | End |
|---|---|---|---|---|
| Nightly import | Ingest agent | ok | 2026-06-09 00:30 UTC | 2026-06-09 02:15 UTC |
| Source slow | Ingest agent | warning | 2026-06-09 01:00 UTC | 2026-06-09 01:45 UTC |
| Model run 41 | Analysis agent | ok | 2026-06-09 02:30 UTC | 2026-06-09 05:00 UTC |
| Model run 42 failed | Analysis agent | error | 2026-06-09 06:20 UTC | |
| Weekly report sent | Report agent | ok | 2026-06-09 07:00 UTC | |
| Morning import | Ingest agent | ok | 2026-06-09 08:00 UTC | 2026-06-09 08:40 UTC |
| Heartbeat lost | Analysis agent | unknown | 2026-06-09 09:00 UTC | 2026-06-09 10:30 UTC |
| Chart refresh retried | Report agent | warning | 2026-06-09 11:00 UTC |
Source: examples/timeline.ts
// An event timeline: a lane for each agent, spans and point events, status by color and shape.
import { timeline } from "enarratio";
export default timeline({
label: "Agent runs, 9 June 2026",
start: "2026-06-09T00:00:00Z",
end: "2026-06-09T12:00:00Z",
lanes: ["Ingest agent", "Analysis agent", "Report agent"],
events: [
{
lane: "Ingest agent",
label: "Nightly import",
status: "ok",
from: "2026-06-09T00:30:00Z",
to: "2026-06-09T02:15:00Z",
},
{
lane: "Ingest agent",
label: "Source slow",
status: "warning",
from: "2026-06-09T01:00:00Z",
to: "2026-06-09T01:45:00Z",
},
{
lane: "Ingest agent",
label: "Morning import",
status: "ok",
from: "2026-06-09T08:00:00Z",
to: "2026-06-09T08:40:00Z",
},
{
lane: "Analysis agent",
label: "Model run 41",
status: "ok",
from: "2026-06-09T02:30:00Z",
to: "2026-06-09T05:00:00Z",
},
{
lane: "Analysis agent",
label: "Model run 42 failed",
status: "error",
at: "2026-06-09T06:20:00Z",
},
{
lane: "Analysis agent",
label: "Heartbeat lost",
status: "unknown",
from: "2026-06-09T09:00:00Z",
to: "2026-06-09T10:30:00Z",
},
{ lane: "Report agent", label: "Weekly report sent", status: "ok", at: "2026-06-09T07:00:00Z" },
{
lane: "Report agent",
label: "Chart refresh retried",
status: "warning",
at: "2026-06-09T11:00:00Z",
},
],
});
40Primitives as tables
A primitive's numbers as a table and a sentence, beside its inline drawing: the same values, so they cannot disagree.
Entries per year: 6 values, from 3 to 9; lowest 3, highest 11.
Show dataHide data
| Position | Entries per year |
|---|---|
| 1 | 3 |
| 2 | 5 |
| 3 | 4 |
| 4 | 8 |
| 5 | 11 |
| 6 | 9 |
Requests per hour, 08:00 to 15:00: 8 cells, from 12 to 9; lowest 9, highest 44; 1 with no data.
Show dataHide data
| Cell | Value | Ramp step |
|---|---|---|
| 08:00 | 12 | 1 |
| 09:00 | 18 | 2 |
| 10:00 | 31 | 4 |
| 11:00 | 44 | 5 |
| 12:00 | 38 | 5 |
| 13:00 | 20 | 2 |
| 14:00 | no data | |
| 15:00 | 9 | 1 |
Source: examples/primitive-tables.ts
// A primitive's numbers as a table and a sentence, beside its inline drawing: the same values, so they cannot disagree.
// The summary is plain text, so it is escaped before it goes into HTML.
import { escapeHtml, heatStrip, heatStripTable, sparkline, sparklineTable } from "enarratio";
const entries = { values: [3, 5, 4, 8, 11, 9], label: "Entries per year" };
const load = {
values: [12, 18, 31, 44, 38, 20, null, 9],
label: "Requests per hour, 08:00 to 15:00",
cellLabels: ["08:00", "09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00"],
width: 320,
};
const inline = { ...entries, inline: true };
const first = sparklineTable(entries);
const second = heatStripTable(load);
export default `<div class="primitive-tables">
<p>${sparkline(inline)} ${escapeHtml(first.summary)}</p>
${first.markup}
<p>${heatStrip({ ...load, inline: true })}</p>
<p>${escapeHtml(second.summary)}</p>
${second.markup}
</div>`;