Enarratio gallery
Every Enarratio chart in the default theme. Each was drawn on the server and reads without scripts; the enhancement layer adds hover details, keyboard navigation, filters and brushing.
- Release
- 0.15.0
- Charts
- 51
- 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.
Download
- Publications
- Grants
- Talks
| 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 |
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
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.
Download
- Publications
- Grants
- Talks
| Year | Publications | Grants | Talks |
|---|---|---|---|
| 2005 | 0 | 3 | 1 |
| 2006 | 2 | 1 | 0 |
| 2007 | 4 | 4 | 4 |
| 2008 | 1 | 2 | 3 |
| 2009 | 3 | 0 | 2 |
| 2010 | 0 | 3 | 1 |
| 2011 | 2 | 1 | 0 |
| 2012 | 4 | 4 | 4 |
| 2013 | 1 | 2 | 3 |
| 2014 | 3 | 0 | 2 |
| 2015 | 0 | 3 | 1 |
| 2016 | 2 | 1 | 0 |
| 2017 | 4 | 4 | 4 |
| 2018 | 1 | 2 | 3 |
| 2019 | 3 | 0 | 2 |
| 2020 | 0 | 3 | 1 |
| 2021 | 2 | 1 | 0 |
| 2022 | 4 | 4 | 4 |
| 2023 | 1 | 2 | 3 |
| 2024 | 3 | 0 | 2 |
| 2025 | 0 | 3 | 1 |
| 2026 | 2 | 1 | 0 |
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.
Download
- Austin
- Boston
- Chicago
| 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 |
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.
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.
Download
- Manual
- Automated
| Assay | Manual | Automated |
|---|---|---|
| Plaque | 72 | 48 |
| TCID50 | 120 | 96 |
| qPCR | 6 | 4 |
The highest value is 120 (Manual, TCID50). The lowest is 4 (Automated, qPCR).
Illustrative data.
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.
Download
| page | Views |
|---|---|
| Teaching and mentoring | 1,840 |
| Publications | 1,320 |
| Influenza surveillance notes | 960 |
| Curriculum vitae | 720 |
| Contact | 310 |
The highest value is 1,840 (Teaching and mentoring). The lowest is 310 (Contact).
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.
Download
- Rose
- Fell
| 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 |
The highest value is +4.2 (Ashe). The lowest is โ3.4 (Haywood).
Illustrative data.
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.
Download
- Control
- Antiviral
| 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 |
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.
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.
Download
- Increase
- Decrease
- Total
| 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 |
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.
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.
Range
Download
- Travis
- Hays
- Williamson
| 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 |
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: 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.
Range
Download
| 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 |
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.
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.
Range
Download
| 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 |
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.
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.
Range
Download
- Research
- Teaching
- Service
| 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 |
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.
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%.
Range
Download
- H1N1pdm09
- H3N2
- B/Victoria
| 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% |
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%.
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.
Download
| 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 |
The highest value is 23 (Midwest, 6). The lowest is 1 (Northwest, 1).
Illustrative data.
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.
Download
- In person
- Video
| 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 |
The highest value is 220 (Central, In person, Q4). The lowest is 35 (Central, Video, Q1).
Illustrative data.
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.
Range
Download
- 2.3.4.4b
- 2.3.2.1c
| 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 |
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.
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.
Range
Download
| 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 |
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.
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.
Download
| 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 |
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.
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.
// jscpd:ignore-start: each example is a snippet the gallery shows whole, so each keeps its own seeded sample
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());
// jscpd:ignore-end
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.
Download
- Wild type
- ฮNS1
- PA-X null
| Cycle threshold (Ct) | Wild type density | ฮNS1 density | PA-X null density |
|---|---|---|---|
| 11.7 | 0.00545 | 1.07E-14 | 0.00209 |
| 13.99 | 3.14E-4 | 2.44E-10 | 0.00539 |
| 16.27 | 0.00834 | 5.29E-7 | 0.00695 |
| 18.56 | 0.0374 | 1.14E-4 | 0.0159 |
| 20.84 | 0.0649 | 0.00283 | 0.0298 |
| 23.13 | 0.153 | 0.0144 | 0.0523 |
| 25.41 | 0.113 | 0.0395 | 0.0734 |
| 27.7 | 0.0355 | 0.061 | 0.0756 |
| 29.99 | 0.0144 | 0.079 | 0.0635 |
| 32.27 | 0.00288 | 0.092 | 0.0509 |
| 34.56 | 0.00457 | 0.0804 | 0.0305 |
| 36.84 | 8.9E-4 | 0.0498 | 0.0128 |
| 39.13 | 3.41E-7 | 0.0118 | 0.006 |
| 41.41 | 2.57E-13 | 0.00234 | 0.0068 |
| 43.7 | 3.81E-22 | 0.00339 | 0.00455 |
Wild type rose from 0.00545 (11.7) to a peak of 0.153 (23.13), then fell to 3.81E-22 (43.7). ฮNS1 rose from 1.07E-14 (11.7) to a peak of 0.092 (32.27), 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.13). The lowest is 3.81E-22 (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.
Source: examples/density.ts
// Smoothed distributions of one measurement, compared on shared axes.
import { densityPlot } from "enarratio/plot";
// jscpd:ignore-start: each example is a snippet the gallery shows whole, so each keeps its own seeded sample
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());
// jscpd:ignore-end
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.
Download
| 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 |
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.
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 90 points. X axis, Age group: 18 to 49 to 65 and over. Y axis, Knowledge score: 26 to 91.
Download
| 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 | 50 to 64 | 63 |
| Value 32 | 50 to 64 | 80 |
| Value 33 | 50 to 64 | 67 |
| Value 34 | 50 to 64 | 69 |
| Value 35 | 50 to 64 | 71 |
| Value 36 | 50 to 64 | 76 |
| Value 37 | 50 to 64 | 70 |
| Value 38 | 50 to 64 | 63 |
| Value 39 | 50 to 64 | 73 |
| Value 40 | 50 to 64 | 65 |
| 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 | 65 and over | 83 |
| Value 62 | 65 and over | 79 |
| Value 63 | 65 and over | 89 |
| Value 64 | 65 and over | 77 |
| Value 65 | 65 and over | 85 |
| Value 66 | 65 and over | 84 |
| Value 67 | 65 and over | 86 |
| Value 68 | 65 and over | 85 |
| Value 69 | 65 and over | 70 |
| Value 70 | 65 and over | 73 |
| Value 71 | 65 and over | 81 |
| Value 72 | 65 and over | 72 |
| Value 73 | 65 and over | 78 |
| Value 74 | 65 and over | 75 |
| Value 75 | 65 and over | 73 |
| Value 76 | 65 and over | 85 |
| Value 77 | 65 and over | 70 |
| Value 78 | 65 and over | 80 |
| Value 79 | 65 and over | 87 |
| Value 80 | 65 and over | 75 |
| 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 |
The highest value is 91 (18 to 49). The lowest is 26 (18 to 49).
Illustrative data.
Source: examples/beeswarm.ts
// Every value as a point, spread in a beeswarm so none hides another.
import { stripPlot } from "enarratio/plot";
// jscpd:ignore-start: each example is a snippet the gallery shows whole, so each keeps its own seeded sample
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());
// jscpd:ignore-end
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: 30 }, () => ({ 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 30 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.
Download
- 2015
- 2025
| state | 2015 | 2025 |
|---|---|---|
| Vermont | 51 | 58 |
| Oregon | 39 | 55 |
| Georgia | 41 | 52 |
| Ohio | 44 | 49 |
| Texas | 37 | 46 |
The highest value is 58 (2025, Vermont). The lowest is 37 (2015, Texas).
Illustrative data.
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).
Download
| 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 |
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.
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.
Download
- 2015
- 2025
| 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 |
The highest value is 90 (2025, Viet Nam). The lowest is 55 (2015, Kenya).
Illustrative data. Hollow circles are 2015, filled ones 2025.
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.
Download
- Rising
- Falling
| 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 |
The highest value is 486 (Biology, 2025). The lowest is 92 (Statistics, 2020).
Illustrative data.
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.
Download
- Very confident
- Somewhat confident
- Not very confident
- Not at all confident
- Not sure
| 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% |
The highest value is 41.9% (Somewhat confident). The lowest is 3.6% (Not sure).
Illustrative data.
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.",
});
27Treemap
Parts of a whole in two levels: each top-level group takes a color and a header with its total.
Where a monthly budget of $4,750 goes
Treemap with 13 points.
Download
- Housing
- Transport
- Food
- Health
- Other
| group | item | Dollars a month | Share |
|---|---|---|---|
| Housing | Rent or mortgage | $1,420 | 29.9% |
| Housing | Utilities | $310 | 6.5% |
| Housing | Insurance | $140 | 2.9% |
| Housing | Total | $1,870 | 39.4% |
| Transport | Car payment | $520 | 10.9% |
| Transport | Fuel | $210 | 4.4% |
| Transport | Transit | $60 | 1.3% |
| Transport | Total | $790 | 16.6% |
| Food | Groceries | $610 | 12.8% |
| Food | Eating out | $290 | 6.1% |
| Food | Total | $900 | 18.9% |
| Health | Premiums | $380 | 8% |
| Health | Out of pocket | $120 | 2.5% |
| Health | Total | $500 | 10.5% |
| Other | Savings | $400 | 8.4% |
| Other | Leisure | $180 | 3.8% |
| Other | Clothing | $110 | 2.3% |
| Other | Total | $690 | 14.5% |
| Total | $4,750 | 100% |
The highest value is $1,420 (Housing / Rent or mortgage). The lowest is $60 (Transport / Transit). One value stands out: $1,420 (Housing / Rent or mortgage).
Illustrative data.
Source: examples/treemap.ts
// Parts of a whole in two levels: each top-level group takes a color and a header with its total.
import { treemap } from "enarratio/plot";
const spending = [
{ group: "Housing", item: "Rent or mortgage", dollars: 1420 },
{ group: "Housing", item: "Utilities", dollars: 310 },
{ group: "Housing", item: "Insurance", dollars: 140 },
{ group: "Transport", item: "Car payment", dollars: 520 },
{ group: "Transport", item: "Fuel", dollars: 210 },
{ group: "Transport", item: "Transit", dollars: 60 },
{ group: "Food", item: "Groceries", dollars: 610 },
{ group: "Food", item: "Eating out", dollars: 290 },
{ group: "Health", item: "Premiums", dollars: 380 },
{ group: "Health", item: "Out of pocket", dollars: 120 },
{ group: "Other", item: "Savings", dollars: 400 },
{ group: "Other", item: "Leisure", dollars: 180 },
{ group: "Other", item: "Clothing", dollars: 110 },
];
export default treemap({
data: spending,
path: ["group", "item"],
value: "dollars",
valueLabel: "Dollars a month",
formatValue: (v) => `$${v.toLocaleString("en-US")}`,
title: "Where a monthly budget of $4,750 goes",
alt: "Treemap of a $4,750 monthly household budget: housing takes $1,870 (39%), mostly rent; food $900, transport $790, other spending $690 (of which $400 is savings) and health $500.",
caption: "Illustrative data.",
});
28Sunburst
Parts of a whole in levels, read from the center out: each group keeps its color in every ring.
Classified reads in one stool sample
Sunburst chart with 11 points.
Download
- Bacteria
- Archaea
- Eukaryota
- Viruses
| kingdom | phylum | Reads | Share |
|---|---|---|---|
| Bacteria | Bacillota | 41,200 | 41.2% |
| Bacteria | Bacteroidota | 28,900 | 28.9% |
| Bacteria | Pseudomonadota | 9,800 | 9.8% |
| Bacteria | Actinomycetota | 6,100 | 6.1% |
| Bacteria | Verrucomicrobiota | 2,400 | 2.4% |
| Bacteria | Total | 88,400 | 88.4% |
| Archaea | Methanobacteriota | 3,100 | 3.1% |
| Archaea | Thermoproteota | 400 | 0.4% |
| Archaea | Total | 3,500 | 3.5% |
| Eukaryota | Ascomycota | 1,900 | 1.9% |
| Eukaryota | Basidiomycota | 700 | 0.7% |
| Eukaryota | Total | 2,600 | 2.6% |
| Viruses | Uroviricota | 4,300 | 4.3% |
| Viruses | Phixviricota | 1,200 | 1.2% |
| Viruses | Total | 5,500 | 5.5% |
| Total | 100,000 | 100% |
The highest value is 41,200 (Bacteria / Bacillota). The lowest is 400 (Archaea / Thermoproteota). Values that stand out: 41,200 (Bacteria / Bacillota) and 28,900 (Bacteria / Bacteroidota).
Illustrative data.
Source: examples/sunburst.ts
// Parts of a whole in levels, read from the center out: each group keeps its color in every ring.
import { sunburst } from "enarratio/plot";
const reads = [
{ kingdom: "Bacteria", phylum: "Bacillota", reads: 41200 },
{ kingdom: "Bacteria", phylum: "Bacteroidota", reads: 28900 },
{ kingdom: "Bacteria", phylum: "Pseudomonadota", reads: 9800 },
{ kingdom: "Bacteria", phylum: "Actinomycetota", reads: 6100 },
{ kingdom: "Bacteria", phylum: "Verrucomicrobiota", reads: 2400 },
{ kingdom: "Archaea", phylum: "Methanobacteriota", reads: 3100 },
{ kingdom: "Archaea", phylum: "Thermoproteota", reads: 400 },
{ kingdom: "Eukaryota", phylum: "Ascomycota", reads: 1900 },
{ kingdom: "Eukaryota", phylum: "Basidiomycota", reads: 700 },
{ kingdom: "Viruses", phylum: "Uroviricota", reads: 4300 },
{ kingdom: "Viruses", phylum: "Phixviricota", reads: 1200 },
];
export default sunburst({
data: reads,
path: ["kingdom", "phylum"],
value: "reads",
valueLabel: "Reads",
title: "Classified reads in one stool sample",
alt: "Sunburst of 100,000 classified reads: bacteria make up 88%, led by Bacillota (41%) and Bacteroidota (29%); viruses 5.5%, archaea 3.5% and eukaryotes 2.6%.",
caption: "Illustrative data.",
});
29Funnel
Stages of a process: each bar prints its count and share of the first stage, and each gap the
From invitation to enrollment in a vaccine trial
Funnel chart with 6 points. Y axis, stage: Invited to Enrolled.
Download
| stage | People | Share of first | Share of previous |
|---|---|---|---|
| Invited | 12,000 | 100% | |
| Opened the invitation | 7,450 | 62.1% | 62.1% |
| Screened | 3,120 | 26% | 41.9% |
| Eligible | 1,890 | 15.8% | 60.6% |
| Consented | 1,240 | 10.3% | 65.6% |
| Enrolled | 1,105 | 9.2% | 89.1% |
People fell from 12,000 (Invited) to 1,105 (Enrolled). The highest value is 12,000 (Invited). The lowest is 1,105 (Enrolled).
Illustrative data.
Source: examples/funnel.ts
// Stages of a process: each bar prints its count and share of the first stage, and each gap the
// share of the previous stage that carried on.
import { funnelChart } from "enarratio/plot";
const enrollment = [
{ stage: "Invited", people: 12000 },
{ stage: "Opened the invitation", people: 7450 },
{ stage: "Screened", people: 3120 },
{ stage: "Eligible", people: 1890 },
{ stage: "Consented", people: 1240 },
{ stage: "Enrolled", people: 1105 },
];
export default funnelChart({
data: enrollment,
stage: "stage",
value: "people",
valueLabel: "People",
title: "From invitation to enrollment in a vaccine trial",
alt: "Funnel of trial recruitment: of 12,000 people invited, 1,105 (9.2%) enrolled. The largest drop is from opening the invitation to screening, where 42% carried on; 89% of those who consented enrolled.",
caption: "Illustrative data.",
});
30Sankey
Flows from stage to stage: each band is as thick as what it carries.
Where clinic patients went in one quarter
Sankey diagram with 8 points.
Download
- Referred
- Walk-in
| From | To | Patients |
|---|---|---|
| Referred | Seen in clinic | 820 |
| Referred | Did not attend | 140 |
| Walk-in | Seen in clinic | 310 |
| Seen in clinic | Treated as outpatient | 760 |
| Seen in clinic | Admitted | 290 |
| Seen in clinic | Referred on | 80 |
| Admitted | Discharged home | 240 |
| Admitted | Transferred | 50 |
The highest value is 820 (Referred โ Seen in clinic). The lowest is 50 (Admitted โ Transferred).
Illustrative data.
Source: examples/sankey.ts
// Flows from stage to stage: each band is as thick as what it carries.
import { sankeyDiagram } from "enarratio/plot";
const patients = [
{ from: "Referred", to: "Seen in clinic", patients: 820 },
{ from: "Referred", to: "Did not attend", patients: 140 },
{ from: "Walk-in", to: "Seen in clinic", patients: 310 },
{ from: "Seen in clinic", to: "Treated as outpatient", patients: 760 },
{ from: "Seen in clinic", to: "Admitted", patients: 290 },
{ from: "Seen in clinic", to: "Referred on", patients: 80 },
{ from: "Admitted", to: "Discharged home", patients: 240 },
{ from: "Admitted", to: "Transferred", patients: 50 },
];
export default sankeyDiagram({
data: patients,
from: "from",
to: "to",
value: "patients",
valueLabel: "Patients",
title: "Where clinic patients went in one quarter",
alt: "Sankey diagram of 1,270 patients: 1,130 were seen in clinic (820 referred, 310 walk-ins) and 140 referrals did not attend. Of those seen, 760 were treated as outpatients, 290 admitted and 80 referred on; 240 of the admitted went home.",
caption: "Illustrative data.",
});
31Chord
Flows among a few groups: each arc is what a group sends, each ribbon what flows each way.
Moves between US regions in one year
Chord diagram with 12 points.
Download
- Northeast
- Midwest
- South
- West
| From \ To | Northeast | Midwest | South | West | Total |
|---|---|---|---|---|---|
| Northeast | 0 | 52 | 310 | 98 | 460 |
| Midwest | 41 | 0 | 265 | 140 | 446 |
| South | 128 | 190 | 0 | 230 | 548 |
| West | 62 | 118 | 290 | 0 | 470 |
The highest value is 310 (Northeast โ South). The lowest is 41 (Midwest โ Northeast).
Illustrative data.
Source: examples/chord.ts
// Flows among a few groups: each arc is what a group sends, each ribbon what flows each way.
import { chordDiagram } from "enarratio/plot";
const regions = ["Northeast", "Midwest", "South", "West"];
// Thousands of people who moved between regions in a year: rows send, columns receive.
const moves = [
[0, 52, 310, 98],
[41, 0, 265, 140],
[128, 190, 0, 230],
[62, 118, 290, 0],
];
export default chordDiagram({
data: moves.flatMap((row, i) =>
row.flatMap((thousands, j) =>
thousands > 0 ? [{ from: regions[i] as string, to: regions[j] as string, thousands }] : [],
),
),
from: "from",
to: "to",
value: "thousands",
valueLabel: "Thousands of people",
title: "Moves between US regions in one year",
alt: "Chord diagram of moves between four US regions: the South gains most, receiving 865,000 people and sending 548,000. The largest single flow is 310,000 from the Northeast to the South.",
caption: "Illustrative data.",
});
32Radar
A few profiles over several measures on one scale: each series an outline, the second dashed.
Two diagnostic tests, scored out of 100
Radar chart with 2 series and 10 points.
Download
- PCR
- Antigen test
| method | Sensitivity | Specificity | Speed | Low cost | Ease of use |
|---|---|---|---|---|---|
| PCR | 98 | 99 | 40 | 35 | 45 |
| Antigen test | 72 | 97 | 95 | 90 | 95 |
The highest value is 99 (PCR, Specificity). The lowest is 35 (PCR, Low cost).
Illustrative scores.
Source: examples/radar.ts
// A few profiles over several measures on one scale: each series an outline, the second dashed.
import { radarChart } from "enarratio/plot";
const assays = [
{ method: "PCR", sensitivity: 98, specificity: 99, speed: 40, cost: 35, ease: 45 },
{ method: "Antigen test", sensitivity: 72, specificity: 97, speed: 95, cost: 90, ease: 95 },
];
export default radarChart({
data: assays,
label: "method",
axes: ["sensitivity", "specificity", "speed", "cost", "ease"],
axisLabels: {
sensitivity: "Sensitivity",
specificity: "Specificity",
speed: "Speed",
cost: "Low cost",
ease: "Ease of use",
},
max: 100,
title: "Two diagnostic tests, scored out of 100",
alt: "Radar chart scoring PCR and an antigen test out of 100: PCR leads on sensitivity (98 to 72), both are near 100 on specificity, and the antigen test leads on speed, cost and ease of use, each by 45 to 55 points.",
caption: "Illustrative scores.",
});
33Gauge
One reading against its scale, with qualitative bands and a target.
Hospital bed occupancy today
Gauge with 1 point. X axis, Beds occupied (%): 0% to 100%.
Download
| Measure | Value | Minimum | Maximum | Target | Band |
|---|---|---|---|---|---|
| Beds occupied (%) | 87% | 0% | 100% | 85% | busy |
Illustrative data.
Source: examples/gauge.ts
// One reading against its scale, with qualitative bands and a target.
import { gaugeChart } from "enarratio/plot";
export default gaugeChart({
value: 87,
min: 0,
max: 100,
label: "Beds occupied (%)",
target: 85,
bands: [
{ to: 85, label: "normal" },
{ to: 95, label: "busy" },
{ to: 100, label: "full" },
],
lowerIsBetter: true,
formatValue: (v) => `${v}%`,
title: "Hospital bed occupancy today",
alt: "Gauge: 87% of beds are occupied, in the busy band, above the 85% target.",
caption: "Illustrative data.",
});
34Bullet chart
Several measures, each a bar against its target over qualitative bands (Few's bullet graph).
Laboratory performance this month
Bullet chart with 3 points.
Download
| measure | done | Target | Band | Maximum |
|---|---|---|---|---|
| Samples processed | 1,840 | 2,000 | fair | 2,400 |
| Same-day results (%) | 91 | 95 | good | 100 |
| Results within 24 h (%) | 78 | 90 | fair | 100 |
The highest value is 1,840 (Samples processed). The lowest is 78 (Results within 24 h (%)).
Illustrative data. Bands are fixed shares of each scale.
Source: examples/bullet.ts
// Several measures, each a bar against its target over qualitative bands (Few's bullet graph).
import { bulletChart } from "enarratio/plot";
const lab = [
{ measure: "Samples processed", done: 1840, goal: 2000, scale: 2400 },
{ measure: "Same-day results (%)", done: 91, goal: 95, scale: 100 },
{ measure: "Results within 24 h (%)", done: 78, goal: 90, scale: 100 },
];
export default bulletChart({
data: lab,
label: "measure",
value: "done",
target: "goal",
max: "scale",
bands: (row) => [
{ to: row.scale * 0.6, label: "poor" },
{ to: row.scale * 0.85, label: "fair" },
{ to: row.scale, label: "good" },
],
title: "Laboratory performance this month",
alt: "Bullet chart of three lab measures: 1,840 samples processed against a goal of 2,000, 91% same-day results against 95%, and 78% of results within 24 hours against 90%. Each misses its target; same-day results are in the good band, the other two in the fair band.",
caption: "Illustrative data. Bands are fixed shares of each scale.",
});
35Parallel coordinates
Many items over several measures: one axis per measure, one line per item, two highlighted.
Twelve yeast strains on four traits
Parallel coordinates with 12 points.
Download
| strain | Growth (per h) | Yield (g/L) | Heat limit (ยฐC) | Best pH |
|---|---|---|---|---|
| A1 | 0.42 | 3.1 | 41 | 6.8 |
| A2 | 0.38 | 2.7 | 44 | 6.6 |
| B1 | 0.55 | 4 | 37 | 7.1 |
| B2 | 0.61 | 4.4 | 35 | 7.3 |
| C1 | 0.29 | 2.2 | 47 | 6.4 |
| C2 | 0.33 | 2.4 | 46 | 6.5 |
| D1 | 0.49 | 3.6 | 39 | 6.9 |
| D2 | 0.52 | 3.5 | 40 | 7 |
| E1 | 0.45 | 3 | 42 | 6.7 |
| E2 | 0.58 | 4.1 | 36 | 7.2 |
| F1 | 0.36 | 2.9 | 45 | 6.6 |
| F2 | 0.4 | 3.2 | 43 | 6.8 |
Growth (per h) is highest for B2 (0.61) and lowest for C1 (0.29). Yield (g/L) is highest for B2 (4.4) and lowest for C1 (2.2). Heat limit (ยฐC) is highest for C1 (47) and lowest for B2 (35). Best pH is highest for B2 (7.3) and lowest for C1 (6.4).
Illustrative data.
Source: examples/parallel.ts
// Many items over several measures: one axis per measure, one line per item, two highlighted.
import { parallelCoordinates } from "enarratio/plot";
const strains = [
{ strain: "A1", growth: 0.42, yield: 3.1, tolerance: 41, ph: 6.8 },
{ strain: "A2", growth: 0.38, yield: 2.7, tolerance: 44, ph: 6.6 },
{ strain: "B1", growth: 0.55, yield: 4.0, tolerance: 37, ph: 7.1 },
{ strain: "B2", growth: 0.61, yield: 4.4, tolerance: 35, ph: 7.3 },
{ strain: "C1", growth: 0.29, yield: 2.2, tolerance: 47, ph: 6.4 },
{ strain: "C2", growth: 0.33, yield: 2.4, tolerance: 46, ph: 6.5 },
{ strain: "D1", growth: 0.49, yield: 3.6, tolerance: 39, ph: 6.9 },
{ strain: "D2", growth: 0.52, yield: 3.5, tolerance: 40, ph: 7.0 },
{ strain: "E1", growth: 0.45, yield: 3.0, tolerance: 42, ph: 6.7 },
{ strain: "E2", growth: 0.58, yield: 4.1, tolerance: 36, ph: 7.2 },
{ strain: "F1", growth: 0.36, yield: 2.9, tolerance: 45, ph: 6.6 },
{ strain: "F2", growth: 0.4, yield: 3.2, tolerance: 43, ph: 6.8 },
];
export default parallelCoordinates({
data: strains,
label: "strain",
axes: ["growth", "yield", "tolerance", "ph"],
axisLabels: {
growth: "Growth (per h)",
yield: "Yield (g/L)",
tolerance: "Heat limit (ยฐC)",
ph: "Best pH",
},
highlight: ["B2", "C1"],
title: "Twelve yeast strains on four traits",
alt: "Parallel coordinates of 12 yeast strains: growth and yield rise together, and both fall as heat tolerance rises. B2 grows fastest (0.61 per hour) and yields most but tolerates least heat; C1 is the reverse.",
caption: "Illustrative data.",
});
36Heatmap
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.
Download
- below 50
- 50 to 60
- 60 to 70
- 70 to 85
- 85 or more
| 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 |
The highest value is 100 (Jan, M0). The lowest is 45 (Jan, M4).
Illustrative data.
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.",
});
37Table 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.
Download
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.",
});
38Network
A force-directed network, laid out on the server.
Co-authorship network
Network with 10 points.
Download
- Virology
- Epidemiology
- Statistics
- Public health
| Node | Group | Connected to |
|---|---|---|
| Rivera | Virology | Okafor, Lindqvist, Ramos, Novak |
| Okafor | Virology | Rivera, Lindqvist |
| Lindqvist | Virology | Rivera, Okafor, Ibarra |
| Ramos | Epidemiology | Rivera, Chen, Haddad, Sato |
| Chen | Epidemiology | Ramos, Haddad, Novak |
| Haddad | Epidemiology | Chen, Ramos, Mbeki |
| Novak | Statistics | Chen, Ibarra, Rivera |
| Ibarra | Statistics | Novak, Lindqvist |
| Mbeki | Public health | Haddad, Sato |
| Sato | Public health | Mbeki, Ramos |
10 nodes and 14 links. Most connections: Rivera (4), Ramos (4) and Lindqvist (3). Every node is linked to the rest.
Illustrative data. Links are co-authored papers.
Source: examples/network.ts
// A force-directed network, laid out on the server.
import { networkChart } from "enarratio/science";
const people = [
["Rivera", "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 = [
["Rivera", "Okafor"],
["Rivera", "Lindqvist"],
["Okafor", "Lindqvist"],
["Rivera", "Ramos"],
["Ramos", "Chen"],
["Chen", "Haddad"],
["Ramos", "Haddad"],
["Chen", "Novak"],
["Novak", "Ibarra"],
["Rivera", "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. Rivera and Ramos are the hubs, with four co-authors each. Rivera links the virus team to statistics and epidemiology. Ramos links epidemiology to public health.",
caption: "Illustrative data. Links are co-authored papers.",
});
39Titers
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.
Download
| 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 |
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.
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.",
});
40Genome 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.
Download
- Gene
- Accessory
- Antisense
- LTR
| 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 |
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.
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.",
});
41Dose-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.
Download
- Oseltamivir
- Baloxavir
| 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 |
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.
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.",
});
42Survival
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.
Download
- 6-MP
- Placebo
| 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 |
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.
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).",
});
43Forest 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).
Download
| 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% |
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.
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.",
});
44Phylogenetic 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.
Download
- 2.3.4.4b, B3.13
- 2.3.4.4b, other
- 2.3.4.4b, Asia
- Ancestral
| 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 |
The highest value is 0.06 (A/goose/Guangdong/1996). The lowest is 0.04 (A/swan/Niigata/2022).
Illustrative tree and branch lengths.
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.",
});
45Dot map
Points sized by value over the caller's own streets, with landmarks.
Cases by address
Dot map with 24 points. Point size, cases: 1 to 9.
Download
| Address | cases | Longitude | Latitude |
|---|---|---|---|
| 10 Market sample | 9 | -0.00286 | 51.5045 |
| 12 Church sample | 6.5 | -0.00468 | 51.505175 |
| 14 Bridge sample | 4 | -0.003276 | 51.502906 |
| 16 School sample | 1.5 | -0.001359 | 51.506246 |
| 18 Market sample | 3 | -0.007911 | 51.504012 |
| 20 Church sample | 1 | 0.001091 | 51.502676 |
| 22 Bridge sample | 7 | -0.003666 | 51.504886 |
| 24 School sample | 4.5 | -0.004237 | 51.503612 |
| 26 Market sample | 6 | -0.001096 | 51.505049 |
| 28 Church sample | 3.5 | -0.006754 | 51.505339 |
| 30 Bridge sample | 1 | -0.0016 | 51.501964 |
| 32 School sample | 1 | -0.001874 | 51.507745 |
| 34 Market sample | 9 | -0.004054 | 51.504299 |
| 36 Church sample | 6.5 | -0.001937 | 51.504286 |
| 38 Bridge sample | 4 | -0.004973 | 51.505808 |
| 40 School sample | 1.5 | -0.00395 | 51.502318 |
| 42 Market sample | 3 | -0.000076 | 51.506306 |
| 44 Church sample | 1 | -0.008935 | 51.504638 |
| 46 Bridge sample | 7 | -0.003046 | 51.504218 |
| 48 School sample | 4.5 | -0.003575 | 51.505499 |
| 50 Market sample | 6 | -0.005139 | 51.503271 |
| 52 Church sample | 3.5 | -0.000012 | 51.504795 |
| 54 Bridge sample | 1 | -0.00718 | 51.506097 |
| 56 School sample | 1 | -0.002302 | 51.501184 |
| Market well (Well, highlighted) | -0.0034 | 51.5044 | |
| Mill well (Well) | 0.0035 | 51.5028 | |
| Park well (Well) | -0.0085 | 51.5082 |
The highest value is 9 (10 Market sample). The lowest is 1 (20 Church sample). 3 squares mark landmarks (Well); the filled square is Market well.
Illustrative data.
Source: examples/dot-map.ts
// Points sized by value over the caller's own streets, with landmarks.
import { dotMap } from "enarratio/map";
// A sample town: four streets each way, about 1 km across, in longitude and latitude.
const line = (name: string, coordinates: number[][]) => ({
type: "Feature",
properties: { name },
geometry: { type: "LineString", coordinates },
});
const streets = {
type: "FeatureCollection",
features: [
...["Mill Street", "Market Street", "Church Street", "Quay Street"].map((name, i) =>
line(name, [
[-0.01, 51.502 + i * 0.002],
[0.006, 51.5025 + i * 0.002],
]),
),
...["North Lane", "Bridge Road", "School Lane", "Park Road"].map((name, i) =>
line(name, [
[-0.0075 + i * 0.004, 51.501],
[-0.007 + i * 0.004, 51.5095],
]),
),
],
};
// Cases by address cluster round the well on Market Street.
const cases = Array.from({ length: 24 }, (_, i) => {
const angle = i * 2.4;
const spread = 0.0004 + (i % 6) * 0.0006;
return {
address: `${10 + i * 2} ${["Market", "Church", "Bridge", "School"][i % 4]} sample`,
lon: -0.0035 + Math.cos(angle) * spread * 1.6,
lat: 51.5045 + Math.sin(angle) * spread,
cases: Math.max(1, 9 - (i % 6) * 1.5 - (i % 4)),
};
});
export default dotMap({
data: cases,
longitude: "lon",
latitude: "lat",
value: "cases",
label: "address",
labelHeader: "Address",
basemap: [{ geometry: streets, labels: "name" }],
landmarks: [
{ name: "Market well", longitude: -0.0034, latitude: 51.5044, highlight: true },
{ name: "Mill well", longitude: 0.0035, latitude: 51.5028 },
{ name: "Park well", longitude: -0.0085, latitude: 51.5082 },
],
landmarkLabel: "Well",
title: "Cases by address",
alt: "Dot map of 24 sample addresses: the largest dots, up to 9 cases, sit round the Market well, and fewer cases lie farther from it.",
caption: "Illustrative data.",
});
46Choropleth map
Regions shaded in five labeled steps of the sequential ramp.
Cases per 10,000 by ward
Choropleth map with 8 points. Color, cases per 10,000: 9 to 44.
Download
- below 10
- 10 to 20
- 20 to 30
- 30 to 40
- 40 or more
| Ward | cases per 10,000 |
|---|---|
| Ward 1 | 12 |
| Ward 2 | 18 |
| Ward 3 | 25 |
| Ward 4 | 9 |
| Ward 5 | 31 |
| Ward 6 | 44 |
| Ward 7 | 15 |
| Ward 8 | 22 |
| Ward 9 | no data |
The highest value is 44 (Ward 6). The lowest is 9 (Ward 4).
Illustrative data.
Source: examples/choropleth.ts
// Regions shaded in five labeled steps of the sequential ramp.
import { choroplethMap } from "enarratio/map";
// A sample county of nine wards, three by three, each a slightly uneven square.
const wards = {
type: "FeatureCollection",
features: Array.from({ length: 9 }, (_, i) => {
const col = i % 3;
const row = Math.floor(i / 3);
const corner = (c: number, r: number) => [
-1 + c * 0.2 + ((c * 7 + r * 3) % 5) * 0.006,
52.6 - r * 0.12 + ((c * 3 + r * 5) % 4) * 0.005,
];
return {
type: "Feature",
properties: { ward: `Ward ${i + 1}` },
geometry: {
type: "Polygon",
coordinates: [
[
corner(col, row),
corner(col + 1, row),
corner(col + 1, row + 1),
corner(col, row + 1),
corner(col, row),
],
],
},
};
}),
};
const rates = [12, 18, 25, 9, 31, 44, 15, 22, null].map((rate, i) => ({
ward: `Ward ${i + 1}`,
rate,
}));
export default choroplethMap({
regions: wards,
regionKey: "ward",
data: rates,
region: "ward",
value: "rate",
valueLabel: "cases per 10,000",
regionHeader: "Ward",
thresholds: [10, 20, 30, 40],
title: "Cases per 10,000 by ward",
alt: "Map of nine sample wards: Ward 6, in the east, has the highest rate at 44 per 10,000 and Ward 4 the lowest at 9; Ward 9 has no data.",
caption: "Illustrative data.",
});
47Sparklines
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.
Download
| 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,
});
48Rings 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.
Download
| 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>`;
49Heat 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.
Download
| 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>`;
50Timeline
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.
Download
- 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
| 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",
},
],
});
51Primitives 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
Entries rose from 3 in the first year to 11 in year 5, then fell to 9.
Entries per year: 6 values, from 3 to 9; lowest 3, highest 11.
| Position | Entries per year |
|---|---|
| 1 | 3 |
| 2 | 5 |
| 3 | 4 |
| 4 | 8 |
| 5 | 11 |
| 6 | 9 |
Requests per hour, 08:00 to 15:00
Requests peaked at 44 at 11:00; 14:00 has no reading.
Requests per hour, 08:00 to 15:00: 8 cells, from 12 to 9; lowest 9, highest 44; 1 with no 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.
// Each sits in a figure whose caption gives a visible title and the finding, worked out from the same values.
// The summary is plain text, so it is escaped before it goes into HTML.
import { escapeHtml } from "@dustinedwards/site-runtime/html";
import { 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 first = sparklineTable(entries);
const second = heatStripTable(load);
const top = Math.max(...entries.values);
const busiest = Math.max(...load.values.filter((v) => v !== null));
const hourOf = (v: number | null) => load.cellLabels[load.values.indexOf(v)];
const caption = (title: string, finding: string) =>
`<figcaption class="enarratio-head"><p class="enarratio-title">${escapeHtml(title)}</p><p class="enarratio-finding">${escapeHtml(finding)}</p></figcaption>`;
export default `<div class="primitive-tables">
<figure>
${caption("Entries per year", `Entries rose from ${entries.values[0]} in the first year to ${top} in year ${entries.values.indexOf(top) + 1}, then fell to ${entries.values.at(-1)}.`)}
<p>${sparkline({ ...entries, inline: true })} ${escapeHtml(first.summary)}</p>
${first.markup}
</figure>
<figure>
${caption("Requests per hour, 08:00 to 15:00", `Requests peaked at ${busiest} at ${hourOf(busiest)}; ${hourOf(null)} has no reading.`)}
<p>${heatStrip({ ...load, inline: true })}</p>
<p>${escapeHtml(second.summary)}</p>
${second.markup}
</figure>
</div>`;