
Week 12: Interactive Data Storytelling
2026-07-02
Prepare pen and paper! We are going to draw today…
group-reflection.qmd{geofacet} & {geom_sf}Part 1: Statistical Programming Foundations
Part 2: Working with Real World Data
Part 3: Advanced Topics & Summary
broom::tidy() to get a tidy data frame first{corrr} produces tidy correlation data frames ready to pipe into gt() or rplot()count() + pivot_wider() + gt() is the standard pipelineformattable colour tiles are one lightweight option{gt}gt workflow: wrangle first, then gt(), then stack formatting (fmt_*), column (cols_*), and annotation (tab_*) layersmm, g, %) — keep cells clean with cols_label() and html() / md() helperstab_spanner() groups related columns under a shared headertbl-cap in Quarto — write what a screen reader needs to understand the findingfig-alt chunk option) conveys the chart’s message — not its structure — for screen readers{gt} in one exampleWrangle first, then stack fmt_* / cols_* / tab_* layers:
library(dplyr)
penguins |>
group_by(species) |>
summarise(
n = n(),
bill = mean(bill_length_mm, na.rm = TRUE),
mass = mean(body_mass_g, na.rm = TRUE)
) |>
gt() |>
tab_header(title = "Penguin size by species") |>
tab_spanner(label = "Mean", columns = c(bill, mass)) |>
cols_label(
species = "Species", n = "N",
bill = html("Bill length<br>(mm)"),
mass = html("Body mass<br>(g)")
) |>
fmt_number(columns = c(bill, mass), decimals = 1) |>
cols_align(align = "right", columns = c(n, bill, mass)) |>
tab_source_note("Source: palmerpenguins")| Penguin size by species | |||
| Species | N |
Mean
|
|
|---|---|---|---|
| Bill length (mm) |
Body mass (g) |
||
| Adelie | 152 | 38.8 | 3,700.7 |
| Chinstrap | 68 | 48.8 | 3,733.1 |
| Gentoo | 124 | 47.5 | 5,076.0 |
| Source: palmerpenguins | |||
Start with the default, then adjust encodings, labels, and scale to foreground the message:
After — ordered, titled, message first
ggplot(penguins,
aes(reorder(species, body_mass_g,
FUN = \(x) median(x, na.rm = TRUE)),
body_mass_g, fill = species)) +
geom_boxplot(show.legend = FALSE) +
coord_flip() +
labs(title = "Gentoo penguins are the heaviest",
subtitle = "Body mass distribution by species",
x = NULL, y = "Body mass (g)",
caption = "Source: palmerpenguins") +
theme_minimal()
A familiar ggplot2 figure — one panel per island:
Drop the facets, map island to shape so it joins the legend — then hover, zoom, and click to filter:
Examples made entirely in R:
| Approach | Package | Output |
|---|---|---|
| htmlwidgets | plotly |
Any HTML output |
| htmlwidgets | leaflet |
Any HTML output |
| htmlwidgets | DT |
Any HTML output |
| htmlwidgets | dygraphs |
Any HTML output |
| Quarto / OJS | Observable JS | Quarto HTML |
| Shiny | shiny |
Shiny app / server |
Tip
Tip
ggplotly() converts an existing ggplot2 plot to interactive with one function call — the quickest upgrade path.
ggplotly()Source: plotly R open source graphing library
plot_ly() box plotSource: plotly R open source graphing library
Source: plotly R open source graphing library
Source: plotly R — client-side linking
{plotly} — key interactionstooltip =){crosstalk}{leaflet} interactive mapSource: Leaflet for R
{tidygeocoder}If you have place names, you don’t need to look up coordinates manually:
# A tibble: 3 × 3
address lat long
<chr> <dbl> <dbl>
1 Geschwister-Scholl-Platz 1, Munich 48.2 11.6
2 Ludwigstraße 28, Munich 48.2 11.6
3 Marchioninistraße 15, Munich 48.1 11.5
Tip
method = "osm" uses OpenStreetMap’s geocoder — free, no API key needed. Returns a data frame with lat and long columns ready to pass to leaflet().
{leaflet} — building up a mapEach add*() call adds a new layer on top:
addTiles() — the background map (OpenStreetMap by default). Without this you get a blank grey canvas.addMarkers() / addCircleMarkers() — pins on the map. Each marker needs a lat, lng, and optionally a popup label.addPolygons() — filled shapes, e.g. country or county boundaries coloured by a variable (like geom_sf() but interactive)addLegend() — colour legend, needed when you use fill colour to encode a variableaddLayersControl() — a toggle UI letting the reader switch between different data layersSource: Leaflet for R
nc <- sf::st_read(system.file("shape/nc.shp", package = "sf"), quiet = TRUE)
pal <- colorNumeric("viridis", domain = nc$BIR74)
cities <- data.frame(
name = c("Charlotte", "Raleigh", "Asheville"),
lng = c(-80.843, -78.639, -82.554),
lat = c( 35.227, 35.780, 35.595)
)
leaflet() |>
addTiles() |> # background map
addPolygons(data = nc, fillColor = ~pal(BIR74), # filled shapes
fillOpacity = 0.7, weight = 1, color = "white",
label = ~NAME, group = "Counties") |>
addCircleMarkers(data = cities, lng = ~lng, lat = ~lat, # pins
radius = 5, color = "red", popup = ~name,
group = "Cities") |>
addLegend(data = nc, pal = pal, values = ~BIR74, # colour legend
title = "Births 1974", position = "bottomright") |>
addLayersControl(overlayGroups = c("Counties", "Cities"), # toggle UI
options = layersControlOptions(collapsed = FALSE))Quarto supports Observable JS natively — reactive code that runs in the browser. Pass R data to OJS with ojs_define():
Then use it in an OJS cell:
```{ojs}
viewof bill_min = Inputs.range([32, 50], {value: 35, step: 1, label: "Min bill length (mm):"})
viewof islands = Inputs.checkbox(["Torgersen","Biscoe","Dream"],
{value: ["Torgersen","Biscoe","Dream"], label: "Island:"})
filtered = transpose(penguins_ojs)
.filter(d => d.bill_length_mm >= bill_min && islands.includes(d.island))
Plot.dot(filtered, {x: "bill_length_mm", y: "body_mass_g",
fill: "species", tip: true}).plot()
```Tip
Source: Quarto OJS — penguins example. No server needed — OJS runs entirely in the browser.
.shp) or GeoJSON{sf} package (Simple Features) is the standard way to work with these in Rsf object is just a data frame with a special geometry column{ggplot2} natively supports sf objects via geom_sf()Simple feature collection with 6 features and 14 fields
Geometry type: MULTIPOLYGON
Dimension: XY
Bounding box: xmin: -81.74107 ymin: 36.07282 xmax: -75.77316 ymax: 36.58965
Geodetic CRS: NAD27
AREA PERIMETER CNTY_ CNTY_ID NAME FIPS FIPSNO CRESS_ID BIR74 SID74
1 0.114 1.442 1825 1825 Ashe 37009 37009 5 1091 1
2 0.061 1.231 1827 1827 Alleghany 37005 37005 3 487 0
3 0.143 1.630 1828 1828 Surry 37171 37171 86 3188 5
4 0.070 2.968 1831 1831 Currituck 37053 37053 27 508 1
5 0.153 2.206 1832 1832 Northampton 37131 37131 66 1421 9
6 0.097 1.670 1833 1833 Hertford 37091 37091 46 1452 7
NWBIR74 BIR79 SID79 NWBIR79 geometry
1 10 1364 0 19 MULTIPOLYGON (((-81.47276 3...
2 10 542 3 12 MULTIPOLYGON (((-81.23989 3...
3 208 3616 6 260 MULTIPOLYGON (((-80.45634 3...
4 123 830 2 145 MULTIPOLYGON (((-76.00897 3...
5 1066 1606 3 1197 MULTIPOLYGON (((-77.21767 3...
6 954 1838 5 1237 MULTIPOLYGON (((-76.74506 3...
geom_sf() choroplethSource: ggplot2 — geom_sf reference

{geofacet} — small-multiple maps{geofacet} arranges facet_wrap() panels in a geographic layout — same syntax, geographic positions.
Source: geofacet package

geom_sf() choropleth
{geofacet} small-multiples
plotly, leaflet, DT) add interactivity to any Quarto HTML output — no server requiredggplotly() is the quickest path from a ggplot2 plot to an interactive one{sf} + geom_sf() for choropleth and boundary maps with proper projection handling{geofacet} for small-multiple comparisons arranged in a geographic layout