‘ggplot2 extension for everyone’

Goals

  1. Gentler introduction to layer extension: Easy Geom Recipes, and Easy Geom Recipes Python adaptation…

  2. Articulate value of extension (Why am I even here? Why are you here?): conversational data viz - being able to write down (transcribe!) visual narratives

  3. Extensions targetted at new audiences:

    1. High School Stats Students
    2. Kids
    3. Dimension Reduction and ML Crowd…

What do you want this to help you prepare for?

  1. Tomorrow, Posit Data Science Lab: ‘Easy geom recipes’, July 21 👩‍🏫
  2. JSM activities:
    1. EAPOST Workshop, July 31-Aug 1 https://sites.google.com/view/eaapost/project-overview https://www.nsf.gov/awardsearch/show-award?AWD_ID=2235355
    2. Prepare to Teach, Aug 2nd https://preparingtoteach.org/
    3. JSM Boston 2026, ‘Past Present and Future of ggplot2 extension’ https://ww3.aievolution.com/JSMAnnual2026/index.cfm?do=ev.viewEv&ev=5265
  3. Posit Conf: ‘Epic graphical poems and conversations’
  4. plotnine’s Easy Geom Recipes… 🤸🚀✈️
  5. Future of the ggplot2 extenders meetup. 👀


Tomorrow - Posit’s Data Science Lab…

So tomorrow, Data Science Lab ‘easy geom recipes’ thing. We’ll talk about types of extensions, and go through a basic Stat extension.

And in preparing for that Isabella, Libby and I met up, and I asked, ‘could I present a few slides talking about why I think ggplot2 extension is important’.

But we settled on ‘No’. Because, that’s not the DS Lab vibe. “DS Lab isn’t monologues, it’s conversation.”

And I think, this is like really a very good practice. And that’s exactly what we are going to do tomorrow. 💯🙌🚀

So let’s write geom_means together

  • A little bit of background…

The anatomy of ‘geom_point’…

library(ggplot2)

geom_point
## function (mapping = NULL, data = NULL, stat = "identity", position = "identity", 
##     ..., na.rm = FALSE, show.legend = NA, inherit.aes = TRUE) 
## {
##     layer(mapping = mapping, data = data, geom = "point", stat = stat, 
##         position = position, show.legend = show.legend, inherit.aes = inherit.aes, 
##         params = list2(na.rm = na.rm, ...))
## }
## <bytecode: 0x11bc90e80>
## <environment: 0x11bc8eb38>

Actually has three main ‘characters’ that specify the layer…

  • Geom
  • Stat
  • Position

geom_point: 1) GeomPoint, 2) StatIdentity, 3) position_identity()

  • another example:
geom_jitter
## function (mapping = NULL, data = NULL, stat = "identity", position = "jitter", 
##     ..., width = NULL, height = NULL, na.rm = FALSE, show.legend = NA, 
##     inherit.aes = TRUE) 
## {
##     if (!missing(width) || !missing(height)) {
##         if (!missing(position)) {
##             cli::cli_abort(c("Both {.arg position} and {.arg width}/{.arg height} were supplied.", 
##                 i = "Choose a single approach to alter the position."))
##         }
##         position <- position_jitter(width = width, height = height)
##     }
##     layer(data = data, mapping = mapping, stat = stat, geom = GeomPoint, 
##         position = position, show.legend = show.legend, inherit.aes = inherit.aes, 
##         params = list2(na.rm = na.rm, ...))
## }
## <bytecode: 0x11bf33a88>
## <environment: namespace:ggplot2>

geom_jitter: GeomPoint, StatIdentity, position_jitter()

  • Agenda build geom_means - which will draw a point at the means x and y for of observations (and do this group- and facet- wise - automatically)

  • Step 0: Get the job done with base ggplot2!

penguins |>
  ggplot() + 
  aes(x = bill_len,
      y = bill_dep) + 
  geom_point()

library(tidyverse)

penguins_means <- penguins |> 
  summarise(mean_bill_len = mean(bill_len, na.rm = T),
            mean_bill_dep = mean(bill_dep, na.rm = T))
  
penguins |>
  ggplot() + 
  aes(x = bill_len,
      y = bill_dep) + 
  geom_point() + 
  geom_point(data = penguins_means,
             mapping = aes(x = mean_bill_len, 
                           y = mean_bill_dep),
             color = "red", 
             size = 8)

  • Step 1: Define Compute. Test
compute_group_means <- function(data, scales){
  
  data |> 
    summarise(x = mean(x, na.rm = T),
              y = mean(y, na.rm = T))
  
}

penguins |> 
  select(x = bill_len,
         y = bill_dep) |> 
  compute_group_means()
##          x        y
## 1 43.92193 17.15117
  • Step 2: Define StatMeans. Test.
# we'll inherit from `Stat` - let's look at all the stuff in there!

StatMeans <- ggproto(`_class` = "StatMeans", 
                     `_inherit` = Stat, 
                     compute_group = compute_group_means,
                     required_aes = c("x", "y"))

ggplot(penguins) + 
  aes(x = bill_len, y = bill_dep) + 
  geom_point() + 
  geom_point(stat = StatMeans, size = 8) + 
  aes(color = species)

  • Step 3: Define user-facing functions (geom_means(), stat_means()). Test (Enjoy!)
# old way - use stat_identity to help w/ scaffolding code... 
stat_means <- function (mapping = NULL, data = NULL, geom = "point", position = "identity", 
    ..., na.rm = FALSE, show.legend = NA, inherit.aes = TRUE) 
{
    layer(mapping = mapping, data = data, geom = geom, stat = StatMeans, 
        position = position, show.legend = show.legend, inherit.aes = inherit.aes, 
        params = list2(na.rm = na.rm, ...))
}

# new way
stat_means <- make_constructor(StatMeans, geom = "point")

# old way - use geom_point to model scaffolding
geom_means <- function (mapping = NULL, data = NULL, stat = "means", position = "identity", 
    ..., na.rm = FALSE, show.legend = NA, inherit.aes = TRUE) 
{
    layer(mapping = mapping, data = data, geom = "point", stat = stat, 
        position = position, show.legend = show.legend, inherit.aes = inherit.aes, 
        params = list2(na.rm = na.rm, ...))
}

# new way
geom_means <- make_constructor(GeomPoint, stat = "means")
# Test (and enjoy!!)
ggplot(penguins) + 
  aes(bill_len, bill_dep) + 
  geom_point() + 
  geom_means(size = 8)

# And enjoy group-wise behavior...
last_plot() + 
  aes(color = species)

last_plot() + 
  facet_wrap(~island)

https://evamaerey.github.io/evamaerey/report2_easy_geom_recipes.pdf https://evamaerey.github.io/easy-geom-recipes/ https://has2k1.github.io/easy-geom-recipes/recipe1means.html


But, I do love a monologue.

And monologues sometimes compress conversations, right?

Many of [my New York Times] columns were the products of long telephone conversations with my friends - Anna Quindlen

We do monologues at ggplot2 extenders (but like they are often pretty ‘hero’s journey’ stories - speaker talks about motivation and challenges… My eyes never glaze over during a hero’s journey story.)

And anyway, what I’d been thinking about for slides (though it be a monologue) was a celebration of visual conversation, and that as motivation for ggplot2 extension.

So people like Thomas Lin Pedersen have talked about ggplot2 as enabling you to ‘Speak you plots into existence.’ And this sounds right, and powerful and awesome - just like ggplot2 is.

But, what I think is actually even more powerful and motivating for me is conversing with your data and recording that conversation - verbatim. transcribing conversations you have with data and transcribing the trains-of-thought you have with data.

Is ggplot2 is kind of the transcriber of spontaneous human-data interactions.

‘spontaneous language for conversation’

And I think many of us are thinking more and more about the power of language and conversation… Like because of LLMS, you may have been thinking, “like at what point did Natural Language emerge in the history of the universe, and why does it even work? How did we go from grunts to meaning” If I utter ‘potato’ does that conjure up in the mind the ‘potato’?

“I could literally write anything and you’d read it …Think about your belly button. Boom, now you’r thinking about your belly button… That’s the power of writing. I love the power. I’m drunk on the stuff.” - Sarah Cooper, ‘Foolish’ 2023

Language is mindbogglingly powerful.

Conversation is mindbogglingly powerful.

Grammar is mindbogglingly powerful.

I’m personally not too interested in a final, gorgeous plot, or the pretty, reorganized syntax that you get after having arriving at a final viz product.

I am crazy about the conversations with data.

I love that ggplot2 syntax can be concise, but I am a fan of the fact that ‘inconcision’ is allowed in that it allows us too record ideas practically verbatim. (Like the Data Science Lab conversations transcripts!)

More thinking about inconcision as functional: ‘Ums’ and ‘Uhs’ tend to occur when we are doing really hard cognative lifting … to kind of give us a processing moment … with Valerie Fridland, author of ‘Like, Literally, Dude.’

So… that was a huge monologue for someone who says they are crazy about conversation.

Native inconsicion in ggplot2

library(tidyverse)
gapminder::gapminder |> 
  filter(year == 2002) |> 
  ggplot(aes(x = gdpPercap,
             y = lifeExp, 
             color = continent,
             size = pop)) + 
  geom_point()

library(ggstamp) 
ggplot() +
stamp_png(png = hans_png_path_y_axis) +
stamp_png(png = hans_png_path_x_axis) +
stamp_png(png = hans_png_path_color) +
stamp_png(png = hans_png_path_size) +
stamp_label(label = lab, size = 10)

https://www.youtube.com/watch?v=jbkSRLYSojo&t=37s

ggplot(filter(gapminder::gapminder, year == 2002)) +
ggchalkboard:::theme_rosling() +
aes(y = lifeExp) +
aes(x = gdpPercap) +
geom_point() +
aes(color = continent) +
aes(size = pop)

Data visualization is right at the heart of my work. - Hans Rosling

And aesthetic mapping is right at the heart to data visualization - is it so wrong to want those decisions to get full voice? - me riffing on Hans

Lightly wrapped inconcision: ggkids (maybe this is okay for kids)

Tim Baller’s Daugher…

library(ggkids)

repair_data <- 
   write_table(~days, ~feeling,
                1,      "😩",
                2,      "😞",
                3,      "😕",
                4,      "😐",
                5,      "😏",
                6,      "😊",
                7,      "😁")
ggkids(data = repair_data) +
use_x(days) +
use_y(feeling) +
chart_point() +
chart_line() +
scale_x_counting() +
theme_kids(ink = "midnightblue")

Beyond ‘base’ ggplot2… let’s you keep the conversation going where train of thought is just one grammar move away…

https://sherwood.news/markets/visualizing-why-yesterdays-stock-market-reversal-was-so-weird-and-unnerving/

# remotes::install_github("https://github.com/EvaMaeRey/ggplyr")
# remotes::install_github("https://github.com/EvaMaeRey/ggcirclepack")

# this block from an ellmerXgemini

library(tidyquant)
library(dplyr)
library(lubridate) # For today()

# --- 1. Define Parameters ---
symbol <- "^GSPC" # S&P 500 Yahoo Finance symbol
start_date <- "2015-01-01" # Corrected from 20215
end_date <- "2025-11-21" 

# --- 2. Load Data ---
# tq_get fetches financial data
sp500_data <- tq_get(symbol,
                     get = "stock.prices", # Specify we want stock prices
                     from = start_date,
                     to = end_date)

# this block from an ellmerXgemini

library(tidyverse)
library(ggplyr)
library(ggcirclepack)

# tq_get fetches financial data
sp500_data <- tq_get(symbol,
                     get = "stock.prices", # Specify we want stock prices
                     from = start_date,
                     to = end_date)

sp_500_data_calcs <- 
  sp500_data  |>
  mutate(
    year = year(date), 
    return = (close - lag(close))/lag(close)*100,
    open_pct = (open - lag(close))/lag(close)*100,
    intraday_pct = 100*(close-open)/open,
    ind_yesterday = date == "2025-11-20"
         )

library(RColorBrewer)
library(tidyverse)
library(ggplyr) # hey
library(ggcirclepack)
library(RColorBrewer)

library(tidyverse)
library(ggcirclepack)
(theme_gray() + 
    theme(palette.fill.discrete = brewer.pal(8, "Dark2")[c(1:8, 1:3)],
          palette.color.discrete = c("transparent", "magenta"),
          palette.fill.continuous = "viridis")
  ) |>
  theme_set()
sp_500_data_calcs |> head()
## # A tibble: 6 × 13
##   symbol date        open  high   low close     volume adjusted  year return
##   <chr>  <date>     <dbl> <dbl> <dbl> <dbl>      <dbl>    <dbl> <dbl>  <dbl>
## 1 ^GSPC  2015-01-02 2059. 2072. 2046. 2058. 2708700000    2058.  2015 NA    
## 2 ^GSPC  2015-01-05 2054. 2054. 2017. 2021. 3799120000    2021.  2015 -1.83 
## 3 ^GSPC  2015-01-06 2022. 2030. 1992. 2003. 4460110000    2003.  2015 -0.889
## 4 ^GSPC  2015-01-07 2006. 2030. 2006. 2026. 3805480000    2026.  2015  1.16 
## 5 ^GSPC  2015-01-08 2031. 2064. 2031. 2062. 3934010000    2062.  2015  1.79 
## 6 ^GSPC  2015-01-09 2063. 2064. 2038. 2045. 3364140000    2045.  2015 -0.840
## # ℹ 3 more variables: open_pct <dbl>, intraday_pct <dbl>, ind_yesterday <lgl>
library(RColorBrewer)

Viz move: Every dot here represents one day of the S&P 500 Index’s return. aes(id = date) + geom_circlepack()

ggplot(sp_500_data_calcs) +
aes(id = date) +
ggcirclepack::geom_circlepack() +
aes(color = ind_yesterday) +
aes(fill = as_factor(year)) +
coord_equal() +
aes(fill = return) +
scale_fill_gradient2(mid = "snow", low = "#410000", high = "darkblue") +
facet_wrap(~year) +
facet_null() +
ggplyr::data_filter(open_pct > 1) +
ggplyr::layers_wipe() +
aes(y = "All", x = open_pct) +
ggbeeswarm::geom_beeswarm(shape = 21) +
coord_cartesian() +
aes(size = I(5)) +
scale_color_manual(values = c("whitesmoke", "magenta")) +
aes(x = return) +
aes(y = "") +
facet_grid(rows = vars(year)) +
facet_null() +
aes(y = intraday_pct, x = open_pct)

https://evamaerey.github.io/mytidytuesday/2025-11-24-s-and-p-since-2015/s-and-p-since-2015.html

Do we even have the tools for nearly verbatim recording data analysis? Trains-of-thought.

Extension and statistical narratives …

2020: Stats instructor teaching “Introduction to Statistical Investigations”

loved,

but… missed coded workflow (there was some, but less that I’d have liked)

There’s something really grounding (and powerful?) about going back to a code narrative, being able to review the steps of what you did.

(quotes from Winston, Paul)


Some instructors may train students in using a specific software package, but mastery of advanced programming skills should not be allowed to crowd out data analysis skills or statistical thinking.


Could we have the best of both worlds?


Could we do what we were doing on chalkboards (love a chalk board!! analogue gold standard), but also do that w/ code, consistent w/ the touch of ggplot2 students were learning anyway, and code that would serve the concepts being learned instead of shoehorning functions around the statistical narrative.


Chalkboard (applet) version went like this…

ggplot(donor_data) +
ggchalkboard::theme_chalkboard() +
labs(title = "Are donor decisions consistent with 50/50 ") +
aes(x = decision) +
geom_stack() +
geom_stack_label() +
geom_support() +
geom_prop() +
geom_prop_label() +
stamp_prop(0.5) +
geom_binomial_null() +
geom_normal_prop_null() +
geom_normal_prop_null_sds() +
stamp_eq_norm_prop()

So the motivation today, is to show you some ggplot2 extension moves - but also to think about how we can create new vocabulary to write down the statistical narratives that we are telling all the time - be it in the classroom or elsewhere - with greater precision and using new bespoke vocabulary (functions) if needed.


Isn’t this like a lot of effort to be able to ‘write down a statistical poem’ something down?

Why don’t we just have students use base ggplot2, and do the wrangling/calculations by hand?

Some instructors may train students in using a specific software package, but mastery of advanced programming skills should not be allowed to crowd out data analysis skills or statistical thinking.


Mastry of the minutia of ggplot2 and data manipulation and should not be allowed to crowd out statistical thinking.


Leland McInnes’ (UMAP dimentionality reduction technique) red star…

necessarily particular details of how any given technique works so that means I’m going to introduce a little bit of extra notation which is gonna be really useful repeatedly and that’s the red star. So sometimes technical details

really really matter but they’re not worth getting into because by the time you’ve explained all the technical details you have lost the point of what you are trying to explain in the first place so if I just want the big picture idea 1:22 and there are some technical details that make that statement not technically true but morally it’s the right thing


Huge audiences:

1000 cadets every year…

EAPOST

100000 AP stats high school students per year

deserve bespoke vocab that allows us to write down statistical narrative elegantly!

library(tidyverse)
library(ggraph)

ggraph_to_threejs <- function(edgelist = package_author_edgelist, 
                              plot = last_plot()){
    
  g <-  edgelist |> select(1:2) |> as.matrix() |> 
    graph_from_edgelist()
  
  node_data <- layer_data(i = 2, plot = plot)
  
  graph_attr(g, "layout") <- NULL    
    
  node_id <- V(g) |> names()
  
  joined <- tibble(node_id) |> 
    left_join(node_data, by = "node_id")
  
  from_list <- edgelist |> pull(1)
  
  V(g)$size <- joined$size/3.66
  V(g)$shape <- joined$label
  V(g)$color <- joined$colour
   
  graphjs(g = g, bg = "gray10",  
          showLabels = T,  
          layout = list(  
            layout_with_fr(g, dim = 3)),  
                    main = "")

}

vct_packages <- c("dplyr", "ggplot2", "ellmer",
                        "tidyr", "tidymodels", "recipes",
                        "readr", "tibble", "stringr", "forcats",
                        "purrr", "lubridate")

# data cleaning
as_tibble(tools::CRAN_package_db()) |>
  filter(Package %in% vct_packages) |>
  mutate(Author = str_replace_all(Author, ' \\[|\\]', "*" )) |>
  select(Package, Author) |>
  mutate(Author = map(Author, ~ str_split_1(.x,"\n"))) |>
  unnest() |> 
  mutate(Author = Author |> str_remove('\\*.+' )) |>
  mutate(Author = Author |> str_trim()) |>
  filter_out(Author |> str_detect("orcid.org")) |>
  filter_out(Author == "Posit Software, PBC") |>
  filter_out(Author == "Posit, PBC") ->
package_author_edgelist



theme_set(theme_gray())

# network visualization
package_author_edgelist |> 
  tidygraph::as_tbl_graph() |>
  mutate(is_package = name %in% vct_packages) |> 
  ggraph(layout = "kk") +
  geom_edge_link() +
  geom_node_label(aes(label = name, 
                      node_id = name, # for conversion to threejs
                      color = is_package))

package_author_edgelist |> 
  distinct(Author) |> 
  mutate(package = "Claude")
## # A tibble: 37 × 2
##    Author            package
##    <chr>             <chr>  
##  1 Hadley Wickham    Claude 
##  2 Romain François   Claude 
##  3 Lionel Henry      Claude 
##  4 Kirill Müller     Claude 
##  5 Davis Vaughan     Claude 
##  6 Joe Cheng         Claude 
##  7 Aaron Jacobs      Claude 
##  8 Garrick Aden-Buie Claude 
##  9 Barret Schloerke  Claude 
## 10 Winston Chang     Claude 
## # ℹ 27 more rows
library(threejs)
library(igraph)



ggraph_to_threejs(package_author_edgelist)
ggplyr::last_plot_wipe_last() + 
  geom_node_text(aes(
    node_id = name, # just for threejs
    label = is_package |> ifelse("📦", "🧠"), 
    color = is_package |> ifelse("tan", "pink") |> I()))

ggraph_to_threejs(package_author_edgelist)
ggplyr::last_plot_wipe_last() + 
  geom_node_text(aes(
    size = !is_package,
    node_id = name, # just for threejs
    label = is_package |> ifelse("📦", "🏃🏽"), 
    color = is_package |> ifelse("tan", "tan") |> I())) + 
  scale_size(range = c(3,8))

ggraph_to_threejs(package_author_edgelist)
layer_data() |> head()
##   PANEL         x          y group      index edge_colour edge_width
## 1     1 -1.290417 -0.4899154     1 0.00000000       black        0.5
## 2     1 -1.275734 -0.4861394     1 0.01010101       black        0.5
## 3     1 -1.261051 -0.4823633     1 0.02020202       black        0.5
## 4     1 -1.246368 -0.4785872     1 0.03030303       black        0.5
## 5     1 -1.231685 -0.4748111     1 0.04040404       black        0.5
## 6     1 -1.217002 -0.4710351     1 0.05050505       black        0.5
##   edge_linetype edge_alpha start_cap end_cap label label_pos label_size angle
## 1         solid         NA        NA      NA    NA       0.5       3.88     0
## 2         solid         NA        NA      NA    NA       0.5       3.88     0
## 3         solid         NA        NA      NA    NA       0.5       3.88     0
## 4         solid         NA        NA      NA    NA       0.5       3.88     0
## 5         solid         NA        NA      NA    NA       0.5       3.88     0
## 6         solid         NA        NA      NA    NA       0.5       3.88     0
##   hjust vjust family fontface lineheight
## 1   0.5   0.5               1        1.2
## 2   0.5   0.5               1        1.2
## 3   0.5   0.5               1        1.2
## 4   0.5   0.5               1        1.2
## 5   0.5   0.5               1        1.2
## 6   0.5   0.5               1        1.2
last_plot() + 
  geom_point(data = layer_data(), aes(x = x, y = y), shape = "|")

knitr::knit_exit()