Skip to the charts

Enarratio menu

Enarratio gallery

Every Enarratio chart in the example theme.

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
Year 2019, Publications: 3Year 2019, Talks: 1Year 2020, Publications: 4Year 2020, Grants: 1Year 2021, Publications: 5Year 2021, Talks: 2Year 2022, Publications: 6Year 2022, Grants: 2Year 2022, Talks: 3Year 2023, Publications: 4Year 2023, Talks: 4Year 2024, Publications: 7Year 2024, Grants: 1Year 2024, Talks: 2
Illustrative data.
Data table
Data for: Entries per year
YearPublicationsGrantsTalks
2019301
2020410
2021502
2022623
2023404
2024712

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
Illustrative data. Clicking a bar selects its year; with scripts off, each bar links to that year.
Data table
Data for: Entries per year, 2005 to 2026
YearPublicationsGrantsTalks
2005031
2006210
2007444
2008123
2009302
2010031
2011210
2012444
2013123
2014302
2015031
2016210
2017444
2018123
2019302
2020031
2021210
2022444
2023123
2024302
2025031
2026210

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
Day 2026-06-09, Austin: 2Day 2026-06-09, Boston: 2Day 2026-06-09, Chicago: 2Day 2026-06-10, Austin: 2Day 2026-06-10, Boston: 2Day 2026-06-10, Chicago: 2Day 2026-06-11, Austin: 2Day 2026-06-11, Boston: 2Day 2026-06-11, Chicago: 2Day 2026-06-12, Austin: 2Day 2026-06-12, Boston: 2Day 2026-06-12, Chicago: 2Day 2026-06-15, Austin: 2Day 2026-06-15, Boston: 2Day 2026-06-15, Chicago: 2Day 2026-06-16, Austin: 2Day 2026-06-16, Boston: 2Day 2026-06-16, Chicago: 2Day 2026-06-17, Austin: 2Day 2026-06-17, Boston: 2Day 2026-06-17, Chicago: 2Day 2026-06-18, Austin: 2Day 2026-06-18, Boston: 2Day 2026-06-18, Chicago: 2Day 2026-06-19, Austin: 2Day 2026-06-19, Boston: 2Day 2026-06-19, Chicago: 2Day 2026-06-20, Chicago: 1Day 2026-06-21, Austin: 1Day 2026-06-22, Austin: 2Day 2026-06-22, Boston: 2Day 2026-06-22, Chicago: 2Day 2026-06-23, Austin: 2Day 2026-06-23, Boston: 2Day 2026-06-23, Chicago: 2Day 2026-06-24, Austin: 2Day 2026-06-24, Boston: 2Day 2026-06-24, Chicago: 2Day 2026-06-25, Austin: 2Day 2026-06-25, Boston: 2Day 2026-06-25, Chicago: 2Day 2026-06-26, Austin: 2Day 2026-06-26, Boston: 2Day 2026-06-26, Chicago: 2Day 2026-06-27, Austin: 1Day 2026-06-28, Boston: 1
Illustrative data. Every day between the first and last run has a bar slot.
Data table
Data for: Analysis runs per day, by site
DayAustinBostonChicago
2026-06-09222
2026-06-10222
2026-06-11222
2026-06-12222
2026-06-13000
2026-06-14000
2026-06-15222
2026-06-16222
2026-06-17222
2026-06-18222
2026-06-19222
2026-06-20001
2026-06-21100
2026-06-22222
2026-06-23222
2026-06-24222
2026-06-25222
2026-06-26222
2026-06-27100
2026-06-28010

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
Assay Plaque, Manual: 72Assay Plaque, Automated: 48Assay TCID50, Manual: 120Assay TCID50, Automated: 96Assay qPCR, Manual: 6Assay qPCR, Automated: 4
Illustrative data.
Data table
Data for: Time to result by assay
AssayManualAutomated
Plaque7248
TCID5012096
qPCR64

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

page Teaching and mentoring: 1,840page Publications: 1,320page Influenza surveillance notes: 960page Curriculum vitae: 720page Contact: 310
Data table
Data for: Most viewed pages
pageViews
Teaching and mentoring1,840
Publications1,320
Influenza surveillance notes960
Curriculum vitae720
Contact310

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
Travis, Week starting 2025-10-06: 42Hays, Week starting 2025-10-06: 18Williamson, Week starting 2025-10-06: 27Travis, Week starting 2025-10-13: 42Hays, Week starting 2025-10-13: 18Williamson, Week starting 2025-10-13: 27Travis, Week starting 2025-10-20: 42Hays, Week starting 2025-10-20: 18Williamson, Week starting 2025-10-20: 27Travis, Week starting 2025-10-27: 43Hays, Week starting 2025-10-27: 18Williamson, Week starting 2025-10-27: 27Travis, Week starting 2025-11-03: 44Hays, Week starting 2025-11-03: 19Williamson, Week starting 2025-11-03: 29Travis, Week starting 2025-11-10: 48Hays, Week starting 2025-11-10: 21Williamson, Week starting 2025-11-10: 31Travis, Week starting 2025-11-17: 56Williamson, Week starting 2025-11-17: 36Travis, Week starting 2025-11-24: 66Hays, Week starting 2025-11-24: 28Williamson, Week starting 2025-11-24: 43Travis, Week starting 2025-12-01: 76Hays, Week starting 2025-12-01: 32Williamson, Week starting 2025-12-01: 49Travis, Week starting 2025-12-08: 80Hays, Week starting 2025-12-08: 34Williamson, Week starting 2025-12-08: 51Travis, Week starting 2025-12-15: 76Hays, Week starting 2025-12-15: 32Williamson, Week starting 2025-12-15: 49Travis, Week starting 2025-12-22: 66Hays, Week starting 2025-12-22: 28Williamson, Week starting 2025-12-22: 43Travis, Week starting 2025-12-29: 56Hays, Week starting 2025-12-29: 24Williamson, Week starting 2025-12-29: 36Travis, Week starting 2026-01-05: 48Hays, Week starting 2026-01-05: 21Williamson, Week starting 2026-01-05: 31Travis, Week starting 2026-01-12: 44Hays, Week starting 2026-01-12: 19Williamson, Week starting 2026-01-12: 29Travis, Week starting 2026-01-19: 43Hays, Week starting 2026-01-19: 18Williamson, Week starting 2026-01-19: 27Alert thresholdHolidayTravisHaysWilliamson
Illustrative data. Drag across the chart, or hold Shift with the arrow keys, to pick a range.
Data table
Data for: Weekly positive tests by county
Week startingTravisHaysWilliamson
2025-10-06421827
2025-10-13421827
2025-10-20421827
2025-10-27431827
2025-11-03441929
2025-11-10482131
2025-11-175636
2025-11-24662843
2025-12-01763249
2025-12-08803451
2025-12-15763249
2025-12-22662843
2025-12-29562436
2026-01-05482131
2026-01-12441929
2026-01-19431827

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

Day 2026-02-03: 120Day 2026-02-04: 127Day 2026-02-05: 133Day 2026-02-06: 137Day 2026-02-07: 139Day 2026-02-08: 140Day 2026-02-09: 139Day 2026-02-10: 138Day 2026-02-11: 138Day 2026-02-12: 139Day 2026-02-13: 142Day 2026-02-14: 147Day 2026-02-15: 154Day 2026-02-16: 161Day 2026-02-17: 167Day 2026-02-18: 173Day 2026-02-19: 176Day 2026-02-20: 177Day 2026-02-21: 177Day 2026-02-22: 176Day 2026-02-23: 176Day 2026-02-24: 176 (forecast; 95% interval 166 to 186)Day 2026-02-25: 178 (forecast; 95% interval 164 to 192)Day 2026-02-26: 182 (forecast; 95% interval 164 to 200)Day 2026-02-27: 188 (forecast; 95% interval 166 to 210)Day 2026-02-28: 194 (forecast; 95% interval 168 to 220)Day 2026-03-01: 201 (forecast; 95% interval 171 to 231)Day 2026-03-02: 207 (forecast; 95% interval 173 to 241)Forecast
Illustrative data. The forecast and its interval were computed outside Enarratio.
Data table
Data for: Daily visits and a one-week forecast
DayVisits95% interval, lower95% interval, upperForecast
2026-02-03120
2026-02-04127
2026-02-05133
2026-02-06137
2026-02-07139
2026-02-08140
2026-02-09139
2026-02-10138
2026-02-11138
2026-02-12139
2026-02-13142
2026-02-14147
2026-02-15154
2026-02-16161
2026-02-17167
2026-02-18173
2026-02-19176
2026-02-20177
2026-02-21177
2026-02-22176
2026-02-23176
2026-02-24176166186Yes
2026-02-25178164192Yes
2026-02-26182164200Yes
2026-02-27188166210Yes
2026-02-28194168220Yes
2026-03-01201171231Yes
2026-03-02207173241Yes

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
Research, Month 2025-09-01: 22Teaching, Month 2025-09-01: 12Service, Month 2025-09-01: 6Research, Month 2025-10-01: 20Teaching, Month 2025-10-01: 14Service, Month 2025-10-01: 6Research, Month 2025-11-01: 18Teaching, Month 2025-11-01: 16Service, Month 2025-11-01: 7Research, Month 2025-12-01: 24Teaching, Month 2025-12-01: 10Service, Month 2025-12-01: 6Research, Month 2026-01-01: 26Teaching, Month 2026-01-01: 9Service, Month 2026-01-01: 5Research, Month 2026-02-01: 25Teaching, Month 2026-02-01: 12Service, Month 2026-02-01: 6
Illustrative data.
Data table
Data for: Where the week goes
MonthResearchTeachingService
2025-09-0122126
2025-10-0120146
2025-11-0118167
2025-12-0124106
2026-01-012695
2026-02-0125126

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
A1: 2.3.4.4b, Cycle threshold (Ct) 18.2, Infectious titer (PFU/mL) 5,200,000A2: 2.3.4.4b, Cycle threshold (Ct) 21.5, Infectious titer (PFU/mL) 610,000A3: 2.3.4.4b, Cycle threshold (Ct) 24.9, Infectious titer (PFU/mL) 48,000A4: 2.3.4.4b, Cycle threshold (Ct) 27.3, Infectious titer (PFU/mL) 9,500A5: 2.3.4.4b, Cycle threshold (Ct) 30.8, Infectious titer (PFU/mL) 720B1: 2.3.2.1c, Cycle threshold (Ct) 19.4, Infectious titer (PFU/mL) 2,100,000B2: 2.3.2.1c, Cycle threshold (Ct) 22.8, Infectious titer (PFU/mL) 190,000B3: 2.3.2.1c, Cycle threshold (Ct) 26.1, Infectious titer (PFU/mL) 24,000B4: 2.3.2.1c, Cycle threshold (Ct) 29.6, Infectious titer (PFU/mL) 1,600
Illustrative data. Each series has its own symbol as well as its own color.
Data table
Data for: Ct value against infectious titer
PointSeriesCycle threshold (Ct)Infectious titer (PFU/mL)
A12.3.4.4b18.25,200,000
A22.3.4.4b21.5610,000
A32.3.4.4b24.948,000
A42.3.4.4b27.39,500
A52.3.4.4b30.8720
B12.3.2.1c19.42,100,000
B22.3.2.1c22.8190,000
B32.3.2.1c26.124,000
B42.3.2.1c29.61,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
Cohort Jan, Months since joining M0: 100 % retainedCohort Jan, Months since joining M1: 82 % retainedCohort Jan, Months since joining M2: 67 % retainedCohort Jan, Months since joining M3: 55 % retainedCohort Jan, Months since joining M4: 45 % retainedCohort Feb, Months since joining M0: 100 % retainedCohort Feb, Months since joining M1: 80 % retainedCohort Feb, Months since joining M2: 65 % retainedCohort Feb, Months since joining M3: 53 % retainedCohort Mar, Months since joining M0: 100 % retainedCohort Mar, Months since joining M1: 78 % retainedCohort Mar, Months since joining M2: 63 % retainedCohort Apr, Months since joining M0: 100 % retainedCohort Apr, Months since joining M1: 76 % retainedCohort May, Months since joining M0: 100 % retained10082675545100806553100786310076100
Illustrative data.
Data table
Data for: Retention by cohort
Cohort \ Months since joiningM0M1M2M3M4
Jan10082675545
Feb100806553
Mar1007863
Apr10076
May100

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
Edwards (Virology): 4 connectionsOkafor (Virology): 2 connectionsLindqvist (Virology): 3 connectionsRamos (Epidemiology): 4 connectionsChen (Epidemiology): 3 connectionsHaddad (Epidemiology): 3 connectionsNovak (Statistics): 3 connectionsIbarra (Statistics): 2 connectionsMbeki (Public health): 2 connectionsSato (Public health): 2 connectionsEdwardsOkaforLindqvistRamosChenHaddadNovakIbarraMbekiSato
Illustrative data. Links are co-authored papers.
Data table
Data for: Co-authorship network
NodeGroupConnected to
EdwardsVirologyOkafor, Lindqvist, Ramos, Novak
OkaforVirologyEdwards, Lindqvist
LindqvistVirologyEdwards, Okafor, Ibarra
RamosEpidemiologyEdwards, Chen, Haddad, Sato
ChenEpidemiologyRamos, Haddad, Novak
HaddadEpidemiologyChen, Ramos, Mbeki
NovakStatisticsChen, Ibarra, Edwards
IbarraStatisticsNovak, Lindqvist
MbekiPublic healthHaddad, Sato
SatoPublic healthMbeki, 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

LODTimepoint Day 0: below the limit of detection (<1:10)Timepoint Day 0: below the limit of detection (<1:10)Timepoint Day 0: 1:10Timepoint Day 0: 1:10Timepoint Day 0: 1:10Timepoint Day 0: 1:20Timepoint Day 0: 1:20Timepoint Day 0: 1:40Timepoint Day 0: 1:10Timepoint Day 0: 1:10Timepoint Day 0: 1:20Timepoint Day 0: 1:10Timepoint Day 14: 1:40Timepoint Day 14: 1:80Timepoint Day 14: 1:80Timepoint Day 14: 1:160Timepoint Day 14: 1:160Timepoint Day 14: 1:160Timepoint Day 14: 1:320Timepoint Day 14: 1:80Timepoint Day 14: 1:40Timepoint Day 14: 1:160Timepoint Day 14: 1:320Timepoint Day 14: 1:640Timepoint Day 28: 1:80Timepoint Day 28: 1:160Timepoint Day 28: 1:160Timepoint Day 28: 1:320Timepoint Day 28: 1:320Timepoint Day 28: 1:320Timepoint Day 28: 1:640Timepoint Day 28: 1:160Timepoint Day 28: 1:80Timepoint Day 28: 1:320Timepoint Day 28: 1:640Timepoint Day 28: 1:1280Timepoint Day 180: 1:20Timepoint Day 180: 1:40Timepoint Day 180: 1:40Timepoint Day 180: 1:80Timepoint Day 180: 1:80Timepoint Day 180: 1:80Timepoint Day 180: 1:160Timepoint Day 180: 1:40Timepoint Day 180: 1:20Timepoint Day 180: 1:80Timepoint Day 180: 1:160Timepoint Day 180: 1:320
Illustrative data. Bars are geometric mean titers with 95% confidence intervals; hollow points are below the limit of detection, plotted at half the limit.
Data table
Data for: HAI titers after vaccination
TimepointnGMT95% CIBelow LODTiters
Day 01211.98.1 to 17.42<10, <10, 10, 10, 10, 20, 20, 40, 10, 10, 20, 10
Day 141213578.8 to 230040, 80, 80, 160, 160, 160, 320, 80, 40, 160, 320, 640
Day 2812269158 to 460080, 160, 160, 320, 320, 320, 640, 160, 80, 320, 640, 1280
Day 1801267.339.4 to 115020, 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
5' LTR (LTR): 1 to 634, 634 nt, no strandgag (Gene): 790 to 2,292, 1,503 nt, forward strandpol (Gene): 2,085 to 5,096, 3,012 nt, forward strandvif (Accessory): 5,041 to 5,619, 579 nt, forward strandvpr (Accessory): 5,559 to 5,850, 292 nt, forward strandtat (Accessory): 5,831 to 6,045, 215 nt, forward strandrev (Accessory): 5,970 to 6,045, 76 nt, forward strandvpu (Accessory): 6,062 to 6,310, 249 nt, forward strandenv (Gene): 6,225 to 8,795, 2,571 nt, forward strandnef (Accessory): 8,797 to 9,417, 621 nt, forward strand3' LTR (LTR): 9,086 to 9,719, 634 nt, no strandasp (Antisense): 7,270 to 8,260, 991 nt, reverse strandgagpolvifenvnefasp
Coordinates approximate the HXB2 reference (GenBank K03455) and are rounded for display.
Data table
Data for: HIV-1 HXB2 genome
FeatureTypeStartEndLength (nt)Strand
5' LTRLTR1634634
gagGene79022921503+
polGene208550963012+
vifAccessory50415619579+
vprAccessory55595850292+
tatAccessory58316045215+
revAccessory5970604576+
vpuAccessory60626310249+
envGene622587952571+
aspAntisense72708260991-
nefAccessory87979417621+
3' LTRLTR90869719634

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.

Entries per year, 2019 to 2024: 6 values, from 4 to 10; lowest 4, highest 11.

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.

Chapters drafted: 7 of 12 (58%)58% Tests passing: 92%92% Period 1: no dataPeriod 2: no dataPeriod 3: no dataPeriod 4: no dataPeriod 5: upPeriod 6: upPeriod 7: upPeriod 8: upPeriod 9: upPeriod 10: upPeriod 11: upPeriod 12: upPeriod 13: upPeriod 14: upPeriod 15: upPeriod 16: upPeriod 17: upPeriod 18: upPeriod 19: upPeriod 20: upPeriod 21: upPeriod 22: upPeriod 23: upPeriod 24: upPeriod 25: upPeriod 26: upPeriod 27: upPeriod 28: upPeriod 29: upPeriod 30: degradedPeriod 31: downPeriod 32: degradedPeriod 33: upPeriod 34: upPeriod 35: upPeriod 36: upPeriod 37: upPeriod 38: upPeriod 39: upPeriod 40: upPeriod 41: upPeriod 42: upPeriod 43: upPeriod 44: upPeriod 45: upPeriod 46: upPeriod 47: upPeriod 48: up

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.

7 June 00:00: 407 June 01:00: 407 June 02:00: 407 June 03:00: 407 June 04:00: 407 June 05:00: 417 June 06:00: 437 June 07:00: 477 June 08:00: 577 June 09:00: 747 June 10:00: 997 June 11:00: 1317 June 12:00: 1657 June 13:00: 1907 June 14:00: 2007 June 15:00: 1907 June 16:00: 1657 June 17:00: 1317 June 18:00: 997 June 19:00: 747 June 20:00: no data7 June 21:00: no data7 June 22:00: 437 June 23:00: 418 June 00:00: 708 June 01:00: 708 June 02:00: 708 June 03:00: 708 June 04:00: 708 June 05:00: 718 June 06:00: 738 June 07:00: 778 June 08:00: 878 June 09:00: 1048 June 10:00: 1298 June 11:00: 1618 June 12:00: 1958 June 13:00: 2208 June 14:00: 2308 June 15:00: 2208 June 16:00: 1958 June 17:00: 1618 June 18:00: 1298 June 19:00: 1048 June 20:00: 878 June 21:00: 778 June 22:00: 738 June 23:00: 71

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.

Agent runs, 9 June 2026: 8 events in 3 lanes, 2026-06-09 00:00 UTC to 2026-06-09 12:00 UTC; 4 ok, 2 warning, 1 error, 1 unknown.06-0903:0006:0009:0012:00Ingest agentIngest agent: Nightly import, ok, from 2026-06-09 00:30 UTC to 2026-06-09 02:15 UTCIngest agent: Source slow, warning, from 2026-06-09 01:00 UTC to 2026-06-09 01:45 UTCIngest agent: Morning import, ok, from 2026-06-09 08:00 UTC to 2026-06-09 08:40 UTCAnalysis agentAnalysis agent: Model run 41, ok, from 2026-06-09 02:30 UTC to 2026-06-09 05:00 UTCAnalysis agent: Model run 42 failed, error, 2026-06-09 06:20 UTCAnalysis agent: Heartbeat lost, unknown, from 2026-06-09 09:00 UTC to 2026-06-09 10:30 UTCReport agentReport agent: Weekly report sent, ok, 2026-06-09 07:00 UTCReport agent: Chart refresh retried, warning, 2026-06-09 11:00 UTC
  1. Ingest agent: Nightly import, ok, from 2026-06-09 00:30 UTC to 2026-06-09 02:15 UTC
  2. Ingest agent: Source slow, warning, from 2026-06-09 01:00 UTC to 2026-06-09 01:45 UTC
  3. Analysis agent: Model run 41, ok, from 2026-06-09 02:30 UTC to 2026-06-09 05:00 UTC
  4. Analysis agent: Model run 42 failed, error, 2026-06-09 06:20 UTC
  5. Report agent: Weekly report sent, ok, 2026-06-09 07:00 UTC
  6. Ingest agent: Morning import, ok, from 2026-06-09 08:00 UTC to 2026-06-09 08:40 UTC
  7. Analysis agent: Heartbeat lost, unknown, from 2026-06-09 09:00 UTC to 2026-06-09 10:30 UTC
  8. 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. Entries per year: 6 values, from 3 to 9; lowest 3, highest 11.

Data table
Data for: Entries per year
PositionEntries per year
13
25
34
48
511
69

08:00: 1209:00: 1810:00: 3111:00: 4412:00: 3813:00: 2014:00: no data15:00: 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
Data for: Requests per hour, 08:00 to 15:00
CellValueRamp step
08:00121
09:00182
10:00314
11:00445
12:00385
13:00202
14:00no data
15:0091

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>`;