Enarratio gallery
Theme dustinedwards.info. 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 9 findings
- warning: light series 2 #4f2d7f and 8 #6b4a9b differ by 9.07 (CIEDE2000) with protanopia; needs 10
- warning: light series 3 #9b3268 and 6 #8a4a1b differ by 5.94 (CIEDE2000) with tritanopia; needs 10
- warning: light series 4 #55684a and 7 #7d7263 differ by 7.18 (CIEDE2000) with protanopia; needs 10
- warning: dark series 1 #5887b5 and 3 #c9699e differ by 5.12 (CIEDE2000) with protanopia; needs 10
- warning: dark series 1 #5887b5 and 8 #a48fd0 differ by 6.21 (CIEDE2000) with protanopia; needs 10
- warning: dark series 2 #c0b0e6 and 8 #a48fd0 differ by 8.92 (CIEDE2000) with tritanopia; needs 10
- warning: dark series 3 #c9699e and 6 #ce7f44 differ by 6.66 (CIEDE2000) with tritanopia; needs 10
- warning: dark series 4 #93b29b and 7 #ab9f8f differ by 3.16 (CIEDE2000) with deuteranopia; needs 10
- warning: dark series 7 #ab9f8f and 8 #a48fd0 differ by 9.46 (CIEDE2000) with tritanopia; 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";
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";
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.",
});
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";
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";
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";
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.",
});
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";
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";
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";
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";
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";
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";
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>`;