Gentler introduction to layer extension: Easy Geom Recipes, and Easy Geom Recipes Python adaptation…
Articulate value of extension (Why am I even here? Why are you here?): conversational data viz - being able to write down (transcribe!) visual narratives
Extensions targetted at new audiences:
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
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_point: 1) GeomPoint, 2) StatIdentity, 3) position_identity()
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)
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
# 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)
# 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
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.
library(tidyverse)
gapminder::gapminder |>
filter(year == 2002) |>
ggplot(aes(x = gdpPercap,
y = lifeExp,
color = continent,
size = pop)) +
geom_point()
library(ggstamp)
| ggplot() + |
https://www.youtube.com/watch?v=jbkSRLYSojo&t=37s
| ggplot(filter(gapminder::gapminder, year == 2002)) + |
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
Tim Baller’s Daugher…
library(ggkids)
repair_data <-
write_table(~days, ~feeling,
1, "😩",
2, "😞",
3, "😕",
4, "😐",
5, "😏",
6, "😊",
7, "😁")
| ggkids(data = repair_data) + |
# 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) + |
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.

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) + |
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()