Enarratio gallery
Every Enarratio chart in the example theme.
Every chart below was drawn on the server and reads without scripts; the enhancement layer adds hover details, keyboard navigation, filters and brushing.
Theme check: passes with 15 findings
- warning: light series 1 #1d5fa6 and 3 #1c7a52 differ by 9.4 (CIEDE2000) with tritanopia; needs 10
- warning: light series 1 #1d5fa6 and 7 #0d7385 differ by 6.14 (CIEDE2000) with tritanopia; needs 10
- warning: light series 2 #b0520b and 4 #a1264a differ by 8.5 (CIEDE2000) with tritanopia; needs 10
- warning: light series 3 #1c7a52 and 4 #a1264a differ by 3.75 (CIEDE2000) with deuteranopia; needs 10
- warning: light series 3 #1c7a52 and 7 #0d7385 differ by 3.75 (CIEDE2000) with tritanopia; needs 10
- warning: light series 3 #1c7a52 and 8 #5b5853 differ by 8.33 (CIEDE2000) with deuteranopia; needs 10
- warning: light series 4 #a1264a and 8 #5b5853 differ by 8.01 (CIEDE2000) with protanopia; needs 10
- warning: dark series 1 #6fa8e8 and 3 #62c595 differ by 8.14 (CIEDE2000) with tritanopia; needs 10
- warning: dark series 1 #6fa8e8 and 7 #52bfd2 differ by 5.98 (CIEDE2000) with tritanopia; needs 10
- warning: dark series 2 #e8975a and 4 #e87c9b differ by 5.59 (CIEDE2000) with tritanopia; needs 10
- warning: dark series 3 #62c595 and 4 #e87c9b differ by 4.49 (CIEDE2000) with deuteranopia; needs 10
- warning: dark series 3 #62c595 and 7 #52bfd2 differ by 3.98 (CIEDE2000) with tritanopia; needs 10
- warning: dark series 3 #62c595 and 8 #b8b4ac differ by 5.82 (CIEDE2000) with deuteranopia; needs 10
- warning: dark series 4 #e87c9b and 8 #b8b4ac differ by 4.82 (CIEDE2000) with deuteranopia; needs 10
- warning: dark series 5 #b4d1df and 7 #52bfd2 differ by 6.89 (CIEDE2000) with protanopia; needs 10
Entries per year by type, counted from one row per entry: the CV's overview chart.
Entries per year
- Publications
- Grants
- Talks
Data table
| Year | Publications | Grants | Talks |
|---|---|---|---|
| 2019 | 3 | 0 | 1 |
| 2020 | 4 | 1 | 0 |
| 2021 | 5 | 0 | 2 |
| 2022 | 6 | 2 | 3 |
| 2023 | 4 | 0 | 4 |
| 2024 | 7 | 1 | 2 |
Source: examples/stacked-bar.ts
// Entries per year by type, counted from one row per entry: the CV's overview chart.
import { barChart } from "enarratio/plot";
const entries = [
...Array.from({ length: 3 }, () => ({ year: 2019, type: "Publications" })),
{ year: 2019, type: "Talks" },
...Array.from({ length: 4 }, () => ({ year: 2020, type: "Publications" })),
{ year: 2020, type: "Grants" },
...Array.from({ length: 2 }, () => ({ year: 2021, type: "Talks" })),
...Array.from({ length: 5 }, () => ({ year: 2021, type: "Publications" })),
...Array.from({ length: 6 }, () => ({ year: 2022, type: "Publications" })),
...Array.from({ length: 2 }, () => ({ year: 2022, type: "Grants" })),
...Array.from({ length: 3 }, () => ({ year: 2022, type: "Talks" })),
...Array.from({ length: 4 }, () => ({ year: 2023, type: "Publications" })),
...Array.from({ length: 4 }, () => ({ year: 2023, type: "Talks" })),
...Array.from({ length: 7 }, () => ({ year: 2024, type: "Publications" })),
{ year: 2024, type: "Grants" },
...Array.from({ length: 2 }, () => ({ year: 2024, type: "Talks" })),
];
export default barChart({
id: "entries-by-year",
data: entries,
x: "year",
series: "type",
seriesDomain: ["Publications", "Grants", "Talks"],
directLabels: true,
xLabel: "Year",
yLabel: "Entries",
title: "Entries per year",
alt: "Stacked bars of CV entries per year from 2019 to 2024. Publications lead every year except 2023, when talks matched them; 2024 is the busiest year with 10 entries.",
caption: "Illustrative data.",
});
Selecting a year: bars filter by category, link to a filtered page without script, and thin their labels.
Entries per year, 2005 to 2026
- Publications
- Grants
- Talks
Data table
| 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 |
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.",
});
Runs per day on a time axis: no pre-formatted labels, and quiet days stay in the picture.
Analysis runs per day, by site
- Austin
- Boston
- Chicago
Data table
| Day | Austin | Boston | Chicago |
|---|---|---|---|
| 2026-06-09 | 2 | 2 | 2 |
| 2026-06-10 | 2 | 2 | 2 |
| 2026-06-11 | 2 | 2 | 2 |
| 2026-06-12 | 2 | 2 | 2 |
| 2026-06-13 | 0 | 0 | 0 |
| 2026-06-14 | 0 | 0 | 0 |
| 2026-06-15 | 2 | 2 | 2 |
| 2026-06-16 | 2 | 2 | 2 |
| 2026-06-17 | 2 | 2 | 2 |
| 2026-06-18 | 2 | 2 | 2 |
| 2026-06-19 | 2 | 2 | 2 |
| 2026-06-20 | 0 | 0 | 1 |
| 2026-06-21 | 1 | 0 | 0 |
| 2026-06-22 | 2 | 2 | 2 |
| 2026-06-23 | 2 | 2 | 2 |
| 2026-06-24 | 2 | 2 | 2 |
| 2026-06-25 | 2 | 2 | 2 |
| 2026-06-26 | 2 | 2 | 2 |
| 2026-06-27 | 1 | 0 | 0 |
| 2026-06-28 | 0 | 1 | 0 |
Source: examples/runs.ts
// Runs per day on a time axis: no pre-formatted labels, and quiet days stay in the picture.
import { barChart } from "enarratio/plot";
const sites = ["Austin", "Boston", "Chicago"] as const;
// A run is one row with a time. Enarratio puts each in its day and counts them; days with no
// runs (13 and 14 June) still get a slot.
const runs = Array.from({ length: 20 * 6 }, (_, i) => {
const day = Math.floor(i / 6);
const weekend = day % 7 === 4 || day % 7 === 5;
return { day, hour: 6 + (i % 6) * 3, site: sites[(i + day) % 3] as string, weekend };
})
.filter((r) => !r.weekend || r.hour === 6)
.filter((r) => !(r.day === 4 || r.day === 5))
.map((r) => ({
at: new Date(Date.UTC(2026, 5, 9 + r.day, r.hour)).toISOString(),
site: r.site,
}));
export default barChart({
id: "runs-per-day",
data: runs,
x: "at",
xType: "time",
interval: "day",
series: "site",
xLabel: "Day",
yLabel: "Runs",
title: "Analysis runs per day, by site",
alt: "Analysis runs per day at three sites, 9 to 28 June 2026. Most days have six runs, two from each site. There are none on 13 and 14 June, and one run on each of 20, 21, 27 and 28 June.",
caption: "Illustrative data. Every day between the first and last run has a bar slot.",
});
Two measurements per category, side by side.
Time to result by assay
- Manual
- Automated
Data table
| Assay | Manual | Automated |
|---|---|---|
| Plaque | 72 | 48 |
| TCID50 | 120 | 96 |
| qPCR | 6 | 4 |
Source: examples/grouped-bar.ts
// Two measurements per category, side by side.
import { barChart } from "enarratio/plot";
const results = [
{ assay: "Plaque", method: "Manual", hours: 72 },
{ assay: "Plaque", method: "Automated", hours: 48 },
{ assay: "TCID50", method: "Manual", hours: 120 },
{ assay: "TCID50", method: "Automated", hours: 96 },
{ assay: "qPCR", method: "Manual", hours: 6 },
{ assay: "qPCR", method: "Automated", hours: 4 },
];
export default barChart({
data: results,
x: "assay",
y: "hours",
series: "method",
layout: "grouped",
xLabel: "Assay",
yLabel: "Hours to result",
title: "Time to result by assay",
alt: "Grouped bars of hours to result for three assays. Automation saves a day on plaque assays (72 to 48 hours) and TCID50 (120 to 96), and two hours on qPCR (6 to 4).",
caption: "Illustrative data.",
});
A ranking with long category names reads best as horizontal bars.
Most viewed pages
Data table
| page | Views |
|---|---|
| Teaching and mentoring | 1,840 |
| Publications | 1,320 |
| Influenza surveillance notes | 960 |
| Curriculum vitae | 720 |
| Contact | 310 |
Source: examples/horizontal-bar.ts
// A ranking with long category names reads best as horizontal bars.
import { barChart } from "enarratio/plot";
const pages = [
{ page: "Teaching and mentoring", views: 1840 },
{ page: "Publications", views: 1320 },
{ page: "Influenza surveillance notes", views: 960 },
{ page: "Curriculum vitae", views: 720 },
{ page: "Contact", views: 310 },
];
export default barChart({
data: pages,
x: "page",
y: "views",
orientation: "horizontal",
xLabel: null,
yLabel: "Views",
title: "Most viewed pages",
alt: "Horizontal bars ranking five pages by views: Teaching and mentoring leads with 1,840, then Publications 1,320, surveillance notes 960, the CV 720 and Contact 310.",
});
Weekly counts over time, with a gap, a threshold and an event marker.
Weekly positive tests by county
- Travis
- Hays
- Williamson
Data table
| Week starting | Travis | Hays | Williamson |
|---|---|---|---|
| 2025-10-06 | 42 | 18 | 27 |
| 2025-10-13 | 42 | 18 | 27 |
| 2025-10-20 | 42 | 18 | 27 |
| 2025-10-27 | 43 | 18 | 27 |
| 2025-11-03 | 44 | 19 | 29 |
| 2025-11-10 | 48 | 21 | 31 |
| 2025-11-17 | 56 | 36 | |
| 2025-11-24 | 66 | 28 | 43 |
| 2025-12-01 | 76 | 32 | 49 |
| 2025-12-08 | 80 | 34 | 51 |
| 2025-12-15 | 76 | 32 | 49 |
| 2025-12-22 | 66 | 28 | 43 |
| 2025-12-29 | 56 | 24 | 36 |
| 2026-01-05 | 48 | 21 | 31 |
| 2026-01-12 | 44 | 19 | 29 |
| 2026-01-19 | 43 | 18 | 27 |
Source: examples/line.ts
// Weekly counts over time, with a gap, a threshold and an event marker.
import { lineChart } from "enarratio/plot";
const counties = ["Travis", "Hays", "Williamson"] as const;
const base = { Travis: 42, Hays: 18, Williamson: 27 };
const weekly = counties.flatMap((county) =>
Array.from({ length: 16 }, (_, week) => {
const wave = Math.round(base[county] * (1 + 0.9 * Math.exp(-(((week - 9) / 3) ** 2))));
// Hays did not report in week 6: a gap, not a zero.
const cases = county === "Hays" && week === 6 ? null : wave;
return { week: new Date(Date.UTC(2025, 9, 6 + week * 7)), county, cases };
}),
);
export default lineChart({
id: "weekly-cases",
data: weekly,
x: "week",
y: "cases",
series: "county",
xLabel: "Week starting",
yLabel: "Positive tests",
references: [{ y: 60, label: "Alert threshold" }],
markers: [{ x: "2025-11-24", label: "Holiday" }],
directLabels: true,
title: "Weekly positive tests by county",
alt: "Lines of weekly positive tests in three counties from October 2025 to January 2026. All three peak in the week of 8 December; Travis peaks near 80, above the alert threshold of 60, while Hays and Williamson stay below it. Hays did not report in the week of 17 November.",
caption:
"Illustrative data. Drag across the chart, or hold Shift with the arrow keys, to pick a range.",
});
A forecast the caller computed: dashed after 24 February, with its 95% interval as a band.
Daily visits and a one-week forecast
Data table
| Day | Visits | 95% interval, lower | 95% interval, upper | Forecast |
|---|---|---|---|---|
| 2026-02-03 | 120 | |||
| 2026-02-04 | 127 | |||
| 2026-02-05 | 133 | |||
| 2026-02-06 | 137 | |||
| 2026-02-07 | 139 | |||
| 2026-02-08 | 140 | |||
| 2026-02-09 | 139 | |||
| 2026-02-10 | 138 | |||
| 2026-02-11 | 138 | |||
| 2026-02-12 | 139 | |||
| 2026-02-13 | 142 | |||
| 2026-02-14 | 147 | |||
| 2026-02-15 | 154 | |||
| 2026-02-16 | 161 | |||
| 2026-02-17 | 167 | |||
| 2026-02-18 | 173 | |||
| 2026-02-19 | 176 | |||
| 2026-02-20 | 177 | |||
| 2026-02-21 | 177 | |||
| 2026-02-22 | 176 | |||
| 2026-02-23 | 176 | |||
| 2026-02-24 | 176 | 166 | 186 | Yes |
| 2026-02-25 | 178 | 164 | 192 | Yes |
| 2026-02-26 | 182 | 164 | 200 | Yes |
| 2026-02-27 | 188 | 166 | 210 | Yes |
| 2026-02-28 | 194 | 168 | 220 | Yes |
| 2026-03-01 | 201 | 171 | 231 | Yes |
| 2026-03-02 | 207 | 173 | 241 | Yes |
Source: examples/forecast.ts
// A forecast the caller computed: dashed after 24 February, with its 95% interval as a band.
import { lineChart } from "enarratio/plot";
// Observed daily visits, then a forecast made elsewhere (here: a straight trend with a widening
// interval). Enarratio draws the numbers it is given; it does not estimate them.
const days = Array.from({ length: 28 }, (_, i) => {
const at = new Date(Date.UTC(2026, 1, 3 + i));
const trend = 120 + i * 3 + 8 * Math.sin(i / 2);
const forecast = i > 20;
const spread = forecast ? 6 + (i - 20) * 4 : 0;
return {
day: at,
visits: Math.round(trend),
lower: forecast ? Math.round(trend - spread) : null,
upper: forecast ? Math.round(trend + spread) : null,
};
});
export default lineChart({
id: "visits-forecast",
data: days,
x: "day",
y: "visits",
band: { lower: "lower", upper: "upper", label: "95% interval" },
forecastFrom: "2026-02-23",
forecastLabel: "Forecast",
xLabel: "Day",
yLabel: "Visits",
title: "Daily visits and a one-week forecast",
alt: "Daily visits from 3 February to 2 March 2026, rising from about 120 to about 207. From 24 February the line is a forecast: it continues the rise to about 207 by 2 March, with a 95% interval that widens from plus or minus 10 to plus or minus 34.",
caption: "Illustrative data. The forecast and its interval were computed outside Enarratio.",
});
Stacked areas: parts of a whole over time.
Where the week goes
- Research
- Teaching
- Service
Data table
| Month | Research | Teaching | Service |
|---|---|---|---|
| 2025-09-01 | 22 | 12 | 6 |
| 2025-10-01 | 20 | 14 | 6 |
| 2025-11-01 | 18 | 16 | 7 |
| 2025-12-01 | 24 | 10 | 6 |
| 2026-01-01 | 26 | 9 | 5 |
| 2026-02-01 | 25 | 12 | 6 |
Source: examples/area.ts
// Stacked areas: parts of a whole over time.
import { areaChart } from "enarratio/plot";
const activities = ["Research", "Teaching", "Service"] as const;
const hours = {
Research: [22, 20, 18, 24, 26, 25],
Teaching: [12, 14, 16, 10, 9, 12],
Service: [6, 6, 7, 6, 5, 6],
};
const monthly = activities.flatMap((activity) =>
hours[activity].map((h, i) => ({
month: new Date(Date.UTC(2025, 8 + i, 1)),
activity,
hours: h,
})),
);
export default areaChart({
data: monthly,
x: "month",
y: "hours",
series: "activity",
xLabel: "Month",
yLabel: "Hours per week",
title: "Where the week goes",
alt: "Stacked areas of weekly hours by activity from September 2025 to February 2026. Research is the largest share throughout, rising to 26 hours in January; teaching peaks at 16 hours in November; service stays near 6.",
caption: "Illustrative data.",
});
Two measurements per sample, grouped, with a regression line on a log axis.
Ct value against infectious titer
- 2.3.4.4b
- 2.3.2.1c
Data table
| Point | Series | Cycle threshold (Ct) | Infectious titer (PFU/mL) |
|---|---|---|---|
| A1 | 2.3.4.4b | 18.2 | 5,200,000 |
| A2 | 2.3.4.4b | 21.5 | 610,000 |
| A3 | 2.3.4.4b | 24.9 | 48,000 |
| A4 | 2.3.4.4b | 27.3 | 9,500 |
| A5 | 2.3.4.4b | 30.8 | 720 |
| B1 | 2.3.2.1c | 19.4 | 2,100,000 |
| B2 | 2.3.2.1c | 22.8 | 190,000 |
| B3 | 2.3.2.1c | 26.1 | 24,000 |
| B4 | 2.3.2.1c | 29.6 | 1,600 |
Source: examples/scatter.ts
// Two measurements per sample, grouped, with a regression line on a log axis.
import { scatterPlot } from "enarratio/plot";
const isolates = [
{ id: "A1", clade: "2.3.4.4b", ct: 18.2, titer: 5.2e6 },
{ id: "A2", clade: "2.3.4.4b", ct: 21.5, titer: 6.1e5 },
{ id: "A3", clade: "2.3.4.4b", ct: 24.9, titer: 4.8e4 },
{ id: "A4", clade: "2.3.4.4b", ct: 27.3, titer: 9.5e3 },
{ id: "A5", clade: "2.3.4.4b", ct: 30.8, titer: 7.2e2 },
{ id: "B1", clade: "2.3.2.1c", ct: 19.4, titer: 2.1e6 },
{ id: "B2", clade: "2.3.2.1c", ct: 22.8, titer: 1.9e5 },
{ id: "B3", clade: "2.3.2.1c", ct: 26.1, titer: 2.4e4 },
{ id: "B4", clade: "2.3.2.1c", ct: 29.6, titer: 1.6e3 },
];
export default scatterPlot({
data: isolates,
x: "ct",
y: "titer",
series: "clade",
label: "id",
yType: "log",
regression: true,
xLabel: "Cycle threshold (Ct)",
yLabel: "Infectious titer (PFU/mL)",
title: "Ct value against infectious titer",
alt: "Scatter plot of nine isolates from two clades: infectious titer falls about tenfold for every 3.3 Ct cycles in both clades, from about 5 million PFU/mL at Ct 18 to about 700 at Ct 31.",
caption: "Illustrative data. Each series has its own symbol as well as its own color.",
});
A grid of values on the theme's sequential ramp.
Retention by cohort
- below 50
- 50 to 60
- 60 to 70
- 70 to 85
- 85 or more
Data table
| Cohort \ Months since joining | M0 | M1 | M2 | M3 | M4 |
|---|---|---|---|---|---|
| Jan | 100 | 82 | 67 | 55 | 45 |
| Feb | 100 | 80 | 65 | 53 | |
| Mar | 100 | 78 | 63 | ||
| Apr | 100 | 76 | |||
| May | 100 |
Source: examples/heatmap.ts
// A grid of values on the theme's sequential ramp.
import { heatmap } from "enarratio/plot";
const cohorts = ["Jan", "Feb", "Mar", "Apr", "May"];
const retention = cohorts.flatMap((cohort, c) =>
Array.from({ length: 5 - c }, (_, m) => ({
cohort,
month: `M${m}`,
retained: m === 0 ? 100 : Math.round(100 * 0.82 ** m - c * 2),
})),
);
export default heatmap({
data: retention,
x: "month",
y: "cohort",
value: "retained",
valueLabel: "% retained",
xDomain: ["M0", "M1", "M2", "M3", "M4"],
thresholds: [50, 60, 70, 85],
xLabel: "Months since joining",
yLabel: "Cohort",
title: "Retention by cohort",
alt: "Retention heatmap for five monthly cohorts: every cohort starts at 100% and falls about 18 points a month, reaching 45% at month four for the January cohort.",
caption: "Illustrative data.",
});
A force-directed network, laid out on the server.
Co-authorship network
- Virology
- Epidemiology
- Statistics
- Public health
Data table
| Node | Group | Connected to |
|---|---|---|
| Edwards | Virology | Okafor, Lindqvist, Ramos, Novak |
| Okafor | Virology | Edwards, Lindqvist |
| Lindqvist | Virology | Edwards, Okafor, Ibarra |
| Ramos | Epidemiology | Edwards, Chen, Haddad, Sato |
| Chen | Epidemiology | Ramos, Haddad, Novak |
| Haddad | Epidemiology | Chen, Ramos, Mbeki |
| Novak | Statistics | Chen, Ibarra, Edwards |
| Ibarra | Statistics | Novak, Lindqvist |
| Mbeki | Public health | Haddad, Sato |
| Sato | Public health | Mbeki, Ramos |
Source: examples/network.ts
// A force-directed network, laid out on the server.
import { networkChart } from "enarratio/science";
const people = [
["Edwards", "Virology"],
["Okafor", "Virology"],
["Lindqvist", "Virology"],
["Ramos", "Epidemiology"],
["Chen", "Epidemiology"],
["Haddad", "Epidemiology"],
["Novak", "Statistics"],
["Ibarra", "Statistics"],
["Mbeki", "Public health"],
["Sato", "Public health"],
] as const;
const pairs = [
["Edwards", "Okafor"],
["Edwards", "Lindqvist"],
["Okafor", "Lindqvist"],
["Edwards", "Ramos"],
["Ramos", "Chen"],
["Chen", "Haddad"],
["Ramos", "Haddad"],
["Chen", "Novak"],
["Novak", "Ibarra"],
["Edwards", "Novak"],
["Haddad", "Mbeki"],
["Mbeki", "Sato"],
["Ramos", "Sato"],
["Lindqvist", "Ibarra"],
] as const;
export default networkChart({
nodes: people.map(([id, group]) => ({ id, group })),
links: pairs.map(([source, target]) => ({ source, target })),
title: "Co-authorship network",
alt: "Network of ten co-authors in four fields. Edwards and Ramos are the hubs, each with four collaborators; Edwards links the virologists to statistics and epidemiology, and Ramos links epidemiology to public health.",
caption: "Illustrative data. Links are co-authored papers.",
});
Hemagglutination inhibition titers by timepoint on a two-fold dilution axis.
HAI titers after vaccination
Data table
| Timepoint | n | GMT | 95% CI | Below LOD | Titers |
|---|---|---|---|---|---|
| Day 0 | 12 | 11.9 | 8.1 to 17.4 | 2 | <10, <10, 10, 10, 10, 20, 20, 40, 10, 10, 20, 10 |
| Day 14 | 12 | 135 | 78.8 to 230 | 0 | 40, 80, 80, 160, 160, 160, 320, 80, 40, 160, 320, 640 |
| Day 28 | 12 | 269 | 158 to 460 | 0 | 80, 160, 160, 320, 320, 320, 640, 160, 80, 320, 640, 1280 |
| Day 180 | 12 | 67.3 | 39.4 to 115 | 0 | 20, 40, 40, 80, 80, 80, 160, 40, 20, 80, 160, 320 |
Source: examples/titer.ts
// Hemagglutination inhibition titers by timepoint on a two-fold dilution axis.
import { titerPlot } from "enarratio/science";
const titers: Record<string, number[]> = {
"Day 0": [5, 5, 10, 10, 10, 20, 20, 40, 10, 10, 20, 10],
"Day 14": [40, 80, 80, 160, 160, 160, 320, 80, 40, 160, 320, 640],
"Day 28": [80, 160, 160, 320, 320, 320, 640, 160, 80, 320, 640, 1280],
"Day 180": [20, 40, 40, 80, 80, 80, 160, 40, 20, 80, 160, 320],
};
const sera = Object.entries(titers).flatMap(([timepoint, values]) =>
values.map((hai) => ({ timepoint, hai })),
);
export default titerPlot({
data: sera,
group: "timepoint",
titer: "hai",
dilution: { start: 10, factor: 2 },
groupLabel: "Timepoint",
titerLabel: "HAI titer",
title: "HAI titers after vaccination",
alt: "HAI titers for 12 participants at four timepoints. The geometric mean titer rises from about 12 at day 0 to about 270 at day 28, then wanes to about 67 by day 180. Two day 0 samples were below the 1:10 limit of detection.",
caption:
"Illustrative data. Bars are geometric mean titers with 95% confidence intervals; hollow points are below the limit of detection, plotted at half the limit.",
});
A viral genome map: features to scale, arrows by strand, overlaps stacked into lanes.
HIV-1 HXB2 genome
- Gene
- Accessory
- Antisense
- LTR
Data table
| Feature | Type | Start | End | Length (nt) | Strand |
|---|---|---|---|---|---|
| 5' LTR | LTR | 1 | 634 | 634 | |
| gag | Gene | 790 | 2292 | 1503 | + |
| pol | Gene | 2085 | 5096 | 3012 | + |
| vif | Accessory | 5041 | 5619 | 579 | + |
| vpr | Accessory | 5559 | 5850 | 292 | + |
| tat | Accessory | 5831 | 6045 | 215 | + |
| rev | Accessory | 5970 | 6045 | 76 | + |
| vpu | Accessory | 6062 | 6310 | 249 | + |
| env | Gene | 6225 | 8795 | 2571 | + |
| asp | Antisense | 7270 | 8260 | 991 | - |
| nef | Accessory | 8797 | 9417 | 621 | + |
| 3' LTR | LTR | 9086 | 9719 | 634 |
Source: examples/genome.ts
// A viral genome map: features to scale, arrows by strand, overlaps stacked into lanes.
import { genomeTrack } from "enarratio/science";
export default genomeTrack({
length: 9719,
features: [
{ name: "5' LTR", start: 1, end: 634, type: "LTR" },
{ name: "gag", start: 790, end: 2292, strand: 1, type: "Gene" },
{ name: "pol", start: 2085, end: 5096, strand: 1, type: "Gene" },
{ name: "vif", start: 5041, end: 5619, strand: 1, type: "Accessory" },
{ name: "vpr", start: 5559, end: 5850, strand: 1, type: "Accessory" },
{ name: "tat", start: 5831, end: 6045, strand: 1, type: "Accessory" },
{ name: "rev", start: 5970, end: 6045, strand: 1, type: "Accessory" },
{ name: "vpu", start: 6062, end: 6310, strand: 1, type: "Accessory" },
{ name: "env", start: 6225, end: 8795, strand: 1, type: "Gene" },
{ name: "nef", start: 8797, end: 9417, strand: 1, type: "Accessory" },
{ name: "3' LTR", start: 9086, end: 9719, type: "LTR" },
{ name: "asp", start: 7270, end: 8260, strand: -1, type: "Antisense" },
],
typeDomain: ["Gene", "Accessory", "Antisense", "LTR"],
title: "HIV-1 HXB2 genome",
alt: "Map of the 9,719-nucleotide HIV-1 HXB2 genome: long terminal repeats at both ends; the gag, pol and env genes on the forward strand, overlapping at their junctions; six accessory genes between pol and env and after env; and the antisense asp gene on the reverse strand inside env.",
caption:
"Coordinates approximate the HXB2 reference (GenBank K03455) and are rounded for display.",
});
A trend beside a number, with a text alternative generated from the data.
Source: examples/sparkline.ts
// A trend beside a number, with a text alternative generated from the data.
import { sparkline } from "enarratio";
export default sparkline({
values: [4, 5, 7, 11, 8, 10],
label: "Entries per year, 2019 to 2024",
area: true,
});
Small primitives with the same styling: progress rings and an uptime strip.
Source: examples/primitives.ts
// Small primitives with the same styling: progress rings and an uptime strip.
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" })}
${progressRing({ value: 0.92, label: "Tests passing", size: 56 })}
${uptimeStrip({ slots: hours, label: "Site, last 48 hours" })}
</div>`;
A heat strip: a one-row strip on the sequential ramp, here requests per hour over two days.
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>`;
An event timeline: a lane for each agent, spans and point events, status by color and shape.
- 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
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",
},
],
});
A primitive's numbers as a table and a sentence: the same values as the drawing, so they cannot disagree.
Entries per year: 6 values, from 3 to 9; lowest 3, highest 11.
Data table
| Position | Entries per year |
|---|---|
| 1 | 3 |
| 2 | 5 |
| 3 | 4 |
| 4 | 8 |
| 5 | 11 |
| 6 | 9 |
Requests per hour, 08:00 to 15:00: 8 cells, from 12 to 9; lowest 9, highest 44; 1 with no data.
Data table
| 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: the same values as the drawing, so they cannot disagree.
// The summary is plain text, so it is escaped before it goes into HTML.
import { escapeHtml, heatStrip, heatStripTable, sparkline, sparklineTable } from "enarratio";
const entries = { values: [3, 5, 4, 8, 11, 9], label: "Entries per year" };
const load = {
values: [12, 18, 31, 44, 38, 20, null, 9],
label: "Requests per hour, 08:00 to 15:00",
cellLabels: ["08:00", "09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00"],
width: 320,
};
const first = sparklineTable(entries);
const second = heatStripTable(load);
export default `<div class="primitive-tables">
<p>${sparkline(entries)} ${escapeHtml(first.summary)}</p>
${first.markup}
<p>${heatStrip(load)}</p>
<p>${escapeHtml(second.summary)}</p>
${second.markup}
</div>`;