• Steven Ponce
  • About
  • Data Visualizations
  • Projects
  • Resume
  • Email

On this page

  • Steps to Create this Graphic
    • 1. Load Packages & Setup
    • 2. Read in the Data
    • 3. Examine the Data
    • 4. Tidy Data
    • 5. Visualization Parameters
    • 6. Plot
    • 7. Save
    • 8. Session Info
    • 9. GitHub Repository
    • 10. References
    • 11. Custom Functions Documentation

68 of 69 Powerball Numbers Have Been Colder Than They Are Today

  • Show All Code
  • Hide All Code

  • View Source

Today’s longest drought is just 48 draws — well below the longest drought experienced by almost every Powerball number.

Standalone Visualization
Data Visualization
R Programming
2026
A small-multiples chart tracing the full drought history of all 69 Powerball numbers since the 2015 rule change, comparing today’s longest current drought against each number’s own historical worst stretch. 68 of 69 numbers have gone longer without hitting at some point in their past. Built in R with ggplot2, ggtext, and patchwork.
Author

Steven Ponce

Published

July 4, 2026

Figure 1: Small-multiples chart of all 69 Powerball numbers, each showing its history of droughts since 2015 as a gray timeline, with a burgundy dot marking its worst drought and a hollow circle marking today. 68 of the 69 numbers have gone longer without hitting at some point in their history than today’s longest current drought (48 draws). Three panels are highlighted: No. 23 (today’s longest drought), No. 35 (the longest drought on record, 112 draws), and No. 69 (currently setting a new personal record at 44 draws, past its old record of 43).

Steps to Create this Graphic

1. Load Packages & Setup

Show code
```{r}
#| label: load
#| warning: false
#| message: false      
#| results: "hide"     

## 1. LOAD PACKAGES & SETUP ----
suppressPackageStartupMessages({
if (!require("pacman")) install.packages("pacman")
pacman::p_load(
    tidyverse, ggtext, showtext, janitor, patchwork,
  scales, glue, here
    )
})

### |- figure size ----          
camcorder::gg_record(
  dir    = here::here("temp_plots"),
    device = "png",
    width  = 16,
    height = 10,
    units  = "in",
    dpi    = 320
)

# Source utility functions
suppressMessages(source(here::here("R/utils/fonts.R")))
source(here::here("R/utils/social_icons.R"))
source(here::here("R/utils/image_utils.R"))
source(here::here("R/themes/base_theme.R"))
```

2. Read in the Data

Show code
```{r}
#| label: read
#| include: true
#| eval: true
#| warning: false

raw <- read_csv(
  here::here("data/Lottery_Powerball_Winning_Numbers__Beginning_2010.csv"),
  show_col_types = FALSE
) |>
  clean_names()
```

3. Examine the Data

Show code
```{r}
#| label: examine
#| include: true
#| eval: true
#| results: 'hide'
#| warning: false

glimpse(raw)
```

4. Tidy Data

Show code
```{r}
#| label: tidy
#| warning: false

### |- era-match to current 69-ball rule set (since 2015-10-07) ----
draws <- raw |>
  mutate(draw_date = mdy(draw_date)) |>
  arrange(draw_date) |>
  filter(draw_date >= as_date("2015-10-07")) |>
  mutate(draw_id = row_number()) |>
  separate(
    winning_numbers,
    into = c(paste0("n", 1:5), "pb"),
    sep = " ",
    convert = TRUE
  ) |>
  select(draw_id, draw_date, n1:n5)

n_draws <- nrow(draws)
latest_date <- max(draws$draw_date)

long <- draws |>
  pivot_longer(n1:n5, names_to = NULL, values_to = "number")

### |- build the draws-since-last-hit time series for every number ----
hit_grid <- expand_grid(number = 1:69, draw_id = draws$draw_id) |>
  left_join(
    long |> select(number, draw_id) |> mutate(hit = TRUE),
    by = c("number", "draw_id")
  ) |>
  mutate(hit = replace_na(hit, FALSE)) |>
  arrange(number, draw_id)

gap_history <- hit_grid |>
  group_by(number) |>
  mutate(
    hit_draw_id     = if_else(hit, draw_id, 0L),
    last_hit_before = lag(cummax(hit_draw_id), default = 0L),
    gap             = draw_id - last_hit_before
  ) |>
  ungroup() |>
  left_join(draws |> select(draw_id, draw_date), by = "draw_id")

summary_df <- gap_history |>
  group_by(number) |>
  summarise(
    record_gap = if (any(hit)) max(gap[hit]) else max(gap),
    current_gap = gap[draw_id == max(draw_id)],
    .groups = "drop"
  ) |>
  mutate(colder_before = current_gap < record_gap)

n_colder <- sum(summary_df$colder_before)
n_total <- nrow(summary_df)
median_record <- median(summary_df$record_gap)
global_max <- max(summary_df$record_gap)
record_number <- summary_df$number[which.max(summary_df$record_gap)]
exception_row <- summary_df |>
  filter(!colder_before) |>
  slice(1)

### |- point layers: where each number's record happened, and where it stands now ----
record_points <- gap_history |>
  filter(hit) |>
  inner_join(summary_df |> select(number, record_gap), by = "number") |>
  filter(gap == record_gap) |>
  group_by(number) |>
  slice_max(draw_date, n = 1, with_ties = FALSE) |> # most recent time it hit that peak
  ungroup() |>
  select(number, draw_date, record_gap)

current_points <- summary_df |>
  select(number, current_gap) |>
  mutate(draw_date = latest_date)

### |- special-case highlight rows ----
record_highlight <- record_points |>
  filter(number == record_number) |>
  mutate(record_label = glue("{record_gap} draws"))
exception_highlight <- summary_df |> filter(number == exception_row$number)

### |- second and third "hero" panels, per feedback: three highlighted
record_panel_highlight <- summary_df |> filter(number == record_number)
coldest_today_number <- summary_df |>
  filter(current_gap == max(current_gap)) |>
  pull(number) |>
  min()
coldest_panel_highlight <- summary_df |> filter(number == coldest_today_number)
```

5. Visualization Parameters

Show code
```{r}
#| label: params
#| include: true
#| warning: false

### |-  plot aesthetics ----
clrs <- get_theme_colors(
  palette = c(
    "record"    = "#722F37",
    "current"   = "grey45",
    "area"      = "grey75",
    "exception" = "#FFF2C9",
    "gold"      = "#C9A227",
    "cold"      = "#3C5A78"
  )
)
col_record <- unname(clrs$palette["record"])
col_current <- unname(clrs$palette["current"])
col_area <- unname(clrs$palette["area"])
col_exception <- unname(clrs$palette["exception"])
col_gold <- unname(clrs$palette["gold"])
col_cold <- unname(clrs$palette["cold"])
bg_color <- "#F7F5EF"

### |-  fonts ----
setup_fonts()
fonts <- get_font_families()

panel_label_style <- "strip"
panel_scale_style <- "shared"

scale_factors <- summary_df |>
  mutate(scale_factor = if (panel_scale_style == "normalized") 1 / record_gap else 1) |>
  select(number, scale_factor)

gap_history_plot <- gap_history |>
  left_join(scale_factors, by = "number") |>
  mutate(gap = gap * scale_factor)

record_points_plot <- record_points |>
  left_join(scale_factors, by = "number") |>
  mutate(record_gap = record_gap * scale_factor)

current_points_plot <- current_points |>
  left_join(scale_factors, by = "number") |>
  mutate(current_gap = current_gap * scale_factor)

record_highlight_plot <- record_highlight |>
  left_join(scale_factors, by = "number") |>
  mutate(record_gap = record_gap * scale_factor)

plot_y_max <- if (panel_scale_style == "normalized") 1 else global_max

### |- one-time orientation labels (panel 1 only, not repeated 69 times) ----
orientation_labels <- tibble(
  number = 1,
  draw_date = c(min(draws$draw_date), latest_date),
  y = plot_y_max * 1.05,
  label = c("2015", "today"),
  hjust = c(0, 1)
)

### |- tiny captions under the three hero panels
hero_captions <- tibble(
  number = c(coldest_today_number, record_number, exception_row$number),
  label  = c("Today's longest drought", "Historical record", "Current exception")
)
```

6. Plot

Show code
```{r}
#| label: plot
#| warning: false

### |- main plot: 69 small-multiple drought timelines ----
timeline_style <- "area"

timeline_layer <- if (timeline_style == "area") {
  geom_area(fill = col_area)
} else {
  geom_line(color = col_area, linewidth = 0.5)
}


### |- three "hero" panels instead of one ----
main_plot <- ggplot(gap_history_plot, aes(x = draw_date, y = gap)) +
  geom_rect(
    data = exception_highlight,
    aes(xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf),
    inherit.aes = FALSE, fill = col_exception, alpha = 0.7
  ) +
  geom_rect(
    data = exception_highlight,
    aes(xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf),
    inherit.aes = FALSE, fill = NA, color = col_gold, linewidth = 0.9
  ) +
  geom_rect(
    data = record_panel_highlight,
    aes(xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf),
    inherit.aes = FALSE, fill = NA, color = col_record, linewidth = 0.7
  ) +
  geom_rect(
    data = coldest_panel_highlight,
    aes(xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf),
    inherit.aes = FALSE, fill = NA, color = col_cold, linewidth = 0.7
  ) +
  timeline_layer +
  geom_hline(yintercept = 0, color = "grey55", linewidth = 0.25) +
  geom_point(
    data = record_points_plot,
    aes(x = draw_date, y = record_gap),
    color = col_record, size = 1.3
  ) +
  geom_point(
    data = current_points_plot,
    aes(x = draw_date, y = current_gap),
    shape = 21, size = 1.5, stroke = 0.5,
    fill = "white", color = col_current
  ) +
  geom_text(
    data = current_points,
    aes(x = draw_date, y = -plot_y_max * 0.10, label = current_gap),
    hjust = 1, size = 2.4, family = fonts$caption, color = "grey40"
  ) +
  geom_text(
    data = orientation_labels,
    aes(x = draw_date, y = y, label = label, hjust = hjust),
    size = 2.2, family = fonts$caption, color = "grey55", vjust = 0
  ) +
  geom_text(
    data = hero_captions,
    aes(x = latest_date, y = -plot_y_max * 0.22, label = label),
    hjust = 1, size = 2.1, family = fonts$text, fontface = "italic",
    color = "grey35", inherit.aes = FALSE
  ) +
  geom_segment(
    data = record_highlight_plot,
    aes(
      x = draw_date, xend = draw_date,
      y = record_gap + plot_y_max * 0.14, yend = record_gap + plot_y_max * 0.03
    ),
    arrow = arrow(length = unit(0.05, "inches")), color = "grey35",
    linewidth = 0.35, inherit.aes = FALSE
  ) +
  geom_richtext(
    data = record_highlight_plot,
    aes(x = draw_date, y = record_gap + plot_y_max * 0.17, label = record_label),
    hjust = 0.5, vjust = 0, size = 2.1, family = fonts$text,
    color = "grey20", fill = NA, label.color = NA,
    label.padding = unit(0, "lines"), inherit.aes = FALSE
  ) +
  scale_y_continuous(limits = c(-plot_y_max * 0.26, plot_y_max * 1.1)) +
  {
    if (panel_label_style == "inline") {
      geom_text(
        data = summary_df, aes(x = min(draws$draw_date), y = plot_y_max * 1.05, label = number),
        hjust = 0, vjust = 1, size = 2.4, family = fonts$title_2, fontface = "bold", color = "#2C2825"
      )
    }
  } +
  facet_wrap(
    ~number,
    ncol = 9,
    labeller = if (panel_label_style == "strip") as_labeller(\(x) paste0("No. ", x)) else as_labeller(\(x) "")
  ) +
  labs(x = NULL, y = NULL) +
  theme_minimal(base_family = fonts$text) +
  theme(
    strip.text        = if (panel_label_style == "inline") element_blank() else element_text(family = fonts$title_2, face = "bold", size = 7.5, color = "#2C2825", hjust = 0),
    strip.background  = element_blank(),
    axis.text         = element_blank(),
    axis.title        = element_blank(),
    panel.grid        = element_blank(),
    panel.spacing.x   = unit(0.42, "lines"),
    panel.spacing.y   = unit(0.48, "lines"),
    plot.background   = element_rect(fill = bg_color, color = NA),
    panel.background  = element_rect(fill = bg_color, color = NA)
  )

### |- right column, piece 1: THE FINDING ----
finding_md <- glue(
  "<span style='font-size:9pt; color:grey50'><b>THE FINDING</b></span><br><br>",
  "<span style='font-size:40pt; color:{col_record}'><b>{n_colder}</b></span>",
  "<span style='font-size:15pt; color:{col_record}'> of {n_total}</span><br><br>",
  "<span style='font-size:11.5pt'>Nearly every Powerball number has<br>",
  "experienced a longer drought than<br>the one it's in today.</span><br><br>",
  "<span style='font-size:8pt; color:grey50'><b>EVIDENCE</b></span><br><br>",
  "<span style='font-size:17pt; color:{col_record}'><b>{max(summary_df$current_gap)}</b></span>",
  "<span style='font-size:9.5pt'>{strrep(\"\\u00a0\", 3)}Today's longest drought</span><br>",
  "<span style='font-size:17pt; color:{col_record}'><b>{median_record}</b></span>",
  "<span style='font-size:9.5pt'>{strrep(\"\\u00a0\", 3)}Median personal drought</span><br>",
  "<span style='font-size:17pt; color:{col_record}'><b>{global_max}</b></span>",
  "<span style='font-size:9.5pt'>{strrep(\"\\u00a0\", 2)}Longest personal drought</span><br><br>",
  "<span style='font-size:7.5pt; color:grey55'>2015 &bull; current Powerball format</span>"
)

finding_panel <- ggplot() +
  geom_richtext(
    data = tibble(x = 0, y = 1, label = finding_md),
    aes(x = x, y = y, label = label),
    hjust = 0, vjust = 1, size = 3.6, family = 'sans',
    color = "#20202A", fill = NA, label.color = NA,
    label.padding = unit(0, "lines")
  ) +
  xlim(0, 1) +
  ylim(0, 1) +
  theme_void() +
  theme(plot.background = element_rect(fill = bg_color, color = NA))

### |- right column, piece 2: "how to read" as an actual mini-diagram ----
example_timeline <- tibble(x = 1:30) |>
  mutate(y = case_when(
    x <= 14 ~ x * (18 / 14),
    x <= 19 ~ 18 - (x - 14) * (18 / 5),
    TRUE ~ (x - 19) * (9 / 11)
  ))

how_to_read_diagram <- ggplot(example_timeline, aes(x, y)) +
  geom_area(fill = col_area) +
  geom_point(data = tibble(x = 14, y = 18), aes(x, y), color = col_record, size = 2.3) +
  geom_point(
    data = tibble(x = 30, y = 9), aes(x, y),
    shape = 21, fill = "white", color = col_current, size = 2.3, stroke = 0.7
  ) +
  annotate("segment", x = 14, xend = 14, y = 19.5, yend = 22.5, color = "grey55", linewidth = 0.3) +
  annotate("text",
    x = 14, y = 24.5, label = "personal\nrecord", size = 2.8,
    family = fonts$text, color = "grey30", lineheight = 0.9
  ) +
  annotate("segment", x = 30, xend = 30, y = 10.5, yend = 13.5, color = "grey55", linewidth = 0.3) +
  annotate("text",
    x = 30, y = 15.5, label = "today", size = 2.8,
    hjust = 1, family = 'sans', color = "grey30"
  ) +
  annotate("text",
    x = 1, y = -3.5, label = "2015", size = 2.5,
    hjust = 0, family = fonts$caption, color = "grey55"
  ) +
  annotate("text",
    x = 30, y = -3.5, label = "today", size = 2.5,
    hjust = 1, family = fonts$caption, color = "grey55"
  ) +
  labs(title = "HOW TO READ") +
  coord_cartesian(ylim = c(-5, 28), clip = "off") +
  theme_void(base_family = fonts$text) +
  theme(
    aspect.ratio = 0.42,
    plot.title = element_text(
      family = fonts$subtitle, face = "bold", size = 9.5,
      color = "#20202A", margin = margin(b = 10)
    ),
    plot.background = element_rect(fill = bg_color, color = NA),
    plot.margin = margin(t = 4, r = 10, b = 4, l = 4)
  )

### |- right column, piece 3: the exception ----
exception_md <- glue(
  "<b style='font-size:10pt'>THE EXCEPTION</b><br><br>",
  "<span style='font-size:28pt; color:{col_record}'><b>{exception_row$number}</b></span><br>",
  "currently setting<br>its personal record<br><br>",
  "<span style='font-size:11pt'>{exception_row$current_gap} draws \u2014 past its<br>old record of {exception_row$record_gap}</span>"
)

exception_panel <- ggplot() +
  geom_richtext(
    data = tibble(x = 0, y = 1, label = exception_md),
    aes(x = x, y = y, label = label),
    hjust = 0, vjust = 1, size = 3.3, family = fonts$subtitle,
    color = "#20202A", fill = col_exception, label.color = col_gold,
    label.padding = unit(0.35, "lines")
  ) +
  xlim(0, 1) +
  ylim(0, 1) +
  theme_void() +
  theme(plot.background = element_rect(fill = bg_color, color = NA))

### |- assemble right column ----
right_column <- finding_panel / how_to_read_diagram / exception_panel +
  plot_layout(heights = c(1.3, 0.85, 0.85))

### |- combine ----
title_text <- glue("{n_colder} of {n_total} Powerball Numbers Have Been Colder Than They Are Today")

final_plot <- (main_plot + right_column + plot_layout(widths = c(4.2, 1.6))) +
  plot_annotation(
    title = title_text,
    subtitle = glue(
      "Today's longest drought is just {max(summary_df$current_gap)} draws \u2014 well below ",
      "the longest drought experienced by almost every Powerball number."
    ),
    caption = glue(
      "As of {format(latest_date, '%B %d, %Y')} \u2022 Draws since Oct 7, 2015 (current Powerball format)<br>",
      "{create_standalone_caption(source_text = 'NY State Gaming Commission, data.ny.gov')}"
    ),
    theme = theme(
      plot.background = element_rect(fill = bg_color, color = NA),
      plot.title = element_text(
        family = fonts$title_1, face = "bold", size = 22,
        color = "#1A1A2E", margin = margin(b = 4)
      ),
      plot.subtitle = element_markdown(
        family = 'sans', size = 12, color = "#4A5568",
        margin = margin(b = 12)
      ),
      plot.caption = element_markdown(
        family = 'sans', size = 7, color = "grey45",
        hjust = 0, margin = margin(t = 10)
      ),
      plot.margin = margin(t = 20, r = 20, b = 12, l = 20)
    )
  )
```

7. Save

Show code
```{r}
#| label: save
#| warning: false

### |-  plot image ----  
save_plot_patchwork(
  plot = final_plot,
  type = "standalone",
  year = 2026,
  month = 7,
  date = 4,
  width = 16,
  height = 10
)
```

8. Session Info

TipExpand for Session Info
R version 4.5.3 (2026-03-11 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 26100)

Matrix products: default
  LAPACK version 3.12.1

locale:
[1] LC_COLLATE=English_United States.utf8 
[2] LC_CTYPE=English_United States.utf8   
[3] LC_MONETARY=English_United States.utf8
[4] LC_NUMERIC=C                          
[5] LC_TIME=English_United States.utf8    

time zone: America/New_York
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
 [1] here_1.0.2      glue_1.8.0      scales_1.4.0    patchwork_1.3.2
 [5] janitor_2.2.1   showtext_0.9-8  showtextdb_3.0  sysfonts_0.8.9 
 [9] ggtext_0.1.2    lubridate_1.9.5 forcats_1.0.1   stringr_1.6.0  
[13] dplyr_1.2.1     purrr_1.2.2     readr_2.2.0     tidyr_1.3.2    
[17] tibble_3.3.1    ggplot2_4.0.3   tidyverse_2.0.0 pacman_0.5.1   

loaded via a namespace (and not attached):
 [1] gtable_0.3.6       xfun_0.57          htmlwidgets_1.6.4  tzdb_0.5.0        
 [5] yulab.utils_0.2.4  vctrs_0.7.3        tools_4.5.3        generics_0.1.4    
 [9] curl_7.0.0         parallel_4.5.3     gifski_1.32.0-2    pkgconfig_2.0.3   
[13] ggplotify_0.1.3    RColorBrewer_1.1-3 S7_0.2.1           lifecycle_1.0.5   
[17] compiler_4.5.3     farver_2.1.2       textshaping_1.0.5  codetools_0.2-20  
[21] snakecase_0.11.1   litedown_0.9       htmltools_0.5.9    yaml_2.3.12       
[25] pillar_1.11.1      crayon_1.5.3       camcorder_0.1.0    magick_2.9.1      
[29] commonmark_2.0.0   tidyselect_1.2.1   digest_0.6.39      stringi_1.8.7     
[33] labeling_0.4.3     rsvg_2.7.0         rprojroot_2.1.1    fastmap_1.2.0     
[37] grid_4.5.3         cli_3.6.6          magrittr_2.0.5     withr_3.0.2       
[41] rappdirs_0.3.4     bit64_4.6.0-1      timechange_0.4.0   rmarkdown_2.31    
[45] bit_4.6.0          otel_0.2.0         hms_1.1.4          evaluate_1.0.5    
[49] knitr_1.51         markdown_2.0       gridGraphics_0.5-1 rlang_1.2.0       
[53] gridtext_0.1.6     Rcpp_1.1.1         xml2_1.5.2         svglite_2.2.2     
[57] rstudioapi_0.18.0  vroom_1.7.1        jsonlite_2.0.0     R6_2.6.1          
[61] fs_2.0.1           systemfonts_1.3.2 

9. GitHub Repository

TipExpand for GitHub Repo

The complete code for this analysis is available in sa_2026-07-04.qmd.

For the full repository, click here.

10. References

TipExpand for References
  1. Data Source:
    • NY State Gaming Commission (via data.ny.gov): Lottery Powerball Winning Numbers: Beginning 2010
    • Mirrored on Data.gov: State of New York - Lottery Powerball Winning Numbers: Beginning 2010

11. Custom Functions Documentation

Note📦 Custom Helper Functions

This analysis uses custom functions from my personal module library for efficiency and consistency across projects.

Functions Used:

  • fonts.R: setup_fonts(), get_font_families() - Font management with showtext
  • social_icons.R: create_social_caption() - Generates formatted social media captions
  • image_utils.R: save_plot() - Consistent plot saving with naming conventions
  • base_theme.R: create_base_theme(), extend_weekly_theme(), get_theme_colors() - Custom ggplot2 themes

Why custom functions?
These utilities standardize theming, fonts, and output across all my data visualizations. The core analysis (data tidying and visualization logic) uses only standard tidyverse packages.

Source Code:
View all custom functions → GitHub: R/utils

Back to top

Citation

BibTeX citation:
@online{ponce2026,
  author = {Ponce, Steven},
  title = {68 of 69 {Powerball} {Numbers} {Have} {Been} {Colder} {Than}
    {They} {Are} {Today}},
  date = {2026-07-04},
  url = {https://stevenponce.netlify.app/projects/standalone_visualizations/sa_2026-07-04.html},
  langid = {en}
}
For attribution, please cite this work as:
Ponce, Steven. 2026. “68 of 69 Powerball Numbers Have Been Colder Than They Are Today.” July 4. https://stevenponce.netlify.app/projects/standalone_visualizations/sa_2026-07-04.html.
Source Code
---
title: "68 of 69 Powerball Numbers Have Been Colder Than They Are Today"
subtitle: "Today's longest drought is just 48 draws — well below the longest drought experienced by almost every Powerball number."
description: "A small-multiples chart tracing the full drought history of all 69 Powerball numbers since the 2015 rule change, comparing today's longest current drought against each number's own historical worst stretch. 68 of 69 numbers have gone longer without hitting at some point in their past. Built in R with ggplot2, ggtext, and patchwork."
date: "2026-07-04"
author:
  - name: "Steven Ponce"
    url: "https://stevenponce.netlify.app"
citation:
  url: "https://stevenponce.netlify.app/projects/standalone_visualizations/sa_2026-07-04.html"
categories: ["Standalone Visualization", "Data Visualization", "R Programming", "2026"]
tags: [
  "Powerball",
  "Lottery",
  "Small Multiples",
  "Area Chart",
  "Editorial Graphic",
  "Annotation",
  "patchwork",
  "ggtext",
  "showtext",
  "Data Visualization",
  "R Programming",
  "2026"
]
image: "thumbnails/sa_2026-07-04.png"
format:
  html:
    toc: true
    toc-depth: 5
    code-link: true
    code-fold: true
    code-tools: true
    code-summary: "Show code"
    self-contained: true
    theme: 
      light: [flatly, assets/styling/custom_styles.scss]
      dark: [darkly, assets/styling/custom_styles_dark.scss]
editor_options: 
  chunk_output_type: inline
execute: 
  freeze: true
  cache: true
  error: false
  message: false
  warning: false
  eval: true
---

![Small-multiples chart of all 69 Powerball numbers, each showing its history of droughts since 2015 as a gray timeline, with a burgundy dot marking its worst drought and a hollow circle marking today. 68 of the 69 numbers have gone longer without hitting at some point in their history than today's longest current drought (48 draws). Three panels are highlighted: No. 23 (today's longest drought), No. 35 (the longest drought on record, 112 draws), and No. 69 (currently setting a new personal record at 44 draws, past its old record of 43).](thumbnails/sa_2026-07-04.png){#fig-1}

### [**Steps to Create this Graphic**]{.mark}

#### [1. Load Packages & Setup]{.smallcaps}

```{r}
#| label: load
#| warning: false
#| message: false      
#| results: "hide"     

## 1. LOAD PACKAGES & SETUP ----
suppressPackageStartupMessages({
if (!require("pacman")) install.packages("pacman")
pacman::p_load(
    tidyverse, ggtext, showtext, janitor, patchwork,
  scales, glue, here
    )
})

### |- figure size ----          
camcorder::gg_record(
  dir    = here::here("temp_plots"),
    device = "png",
    width  = 16,
    height = 10,
    units  = "in",
    dpi    = 320
)

# Source utility functions
suppressMessages(source(here::here("R/utils/fonts.R")))
source(here::here("R/utils/social_icons.R"))
source(here::here("R/utils/image_utils.R"))
source(here::here("R/themes/base_theme.R"))
```

#### [2. Read in the Data]{.smallcaps}

```{r}
#| label: read
#| include: true
#| eval: true
#| warning: false

raw <- read_csv(
  here::here("data/Lottery_Powerball_Winning_Numbers__Beginning_2010.csv"),
  show_col_types = FALSE
) |>
  clean_names()
```

#### [3. Examine the Data]{.smallcaps}

```{r}
#| label: examine
#| include: true
#| eval: true
#| results: 'hide'
#| warning: false

glimpse(raw)
```

#### [4. Tidy Data]{.smallcaps}

```{r}
#| label: tidy
#| warning: false

### |- era-match to current 69-ball rule set (since 2015-10-07) ----
draws <- raw |>
  mutate(draw_date = mdy(draw_date)) |>
  arrange(draw_date) |>
  filter(draw_date >= as_date("2015-10-07")) |>
  mutate(draw_id = row_number()) |>
  separate(
    winning_numbers,
    into = c(paste0("n", 1:5), "pb"),
    sep = " ",
    convert = TRUE
  ) |>
  select(draw_id, draw_date, n1:n5)

n_draws <- nrow(draws)
latest_date <- max(draws$draw_date)

long <- draws |>
  pivot_longer(n1:n5, names_to = NULL, values_to = "number")

### |- build the draws-since-last-hit time series for every number ----
hit_grid <- expand_grid(number = 1:69, draw_id = draws$draw_id) |>
  left_join(
    long |> select(number, draw_id) |> mutate(hit = TRUE),
    by = c("number", "draw_id")
  ) |>
  mutate(hit = replace_na(hit, FALSE)) |>
  arrange(number, draw_id)

gap_history <- hit_grid |>
  group_by(number) |>
  mutate(
    hit_draw_id     = if_else(hit, draw_id, 0L),
    last_hit_before = lag(cummax(hit_draw_id), default = 0L),
    gap             = draw_id - last_hit_before
  ) |>
  ungroup() |>
  left_join(draws |> select(draw_id, draw_date), by = "draw_id")

summary_df <- gap_history |>
  group_by(number) |>
  summarise(
    record_gap = if (any(hit)) max(gap[hit]) else max(gap),
    current_gap = gap[draw_id == max(draw_id)],
    .groups = "drop"
  ) |>
  mutate(colder_before = current_gap < record_gap)

n_colder <- sum(summary_df$colder_before)
n_total <- nrow(summary_df)
median_record <- median(summary_df$record_gap)
global_max <- max(summary_df$record_gap)
record_number <- summary_df$number[which.max(summary_df$record_gap)]
exception_row <- summary_df |>
  filter(!colder_before) |>
  slice(1)

### |- point layers: where each number's record happened, and where it stands now ----
record_points <- gap_history |>
  filter(hit) |>
  inner_join(summary_df |> select(number, record_gap), by = "number") |>
  filter(gap == record_gap) |>
  group_by(number) |>
  slice_max(draw_date, n = 1, with_ties = FALSE) |> # most recent time it hit that peak
  ungroup() |>
  select(number, draw_date, record_gap)

current_points <- summary_df |>
  select(number, current_gap) |>
  mutate(draw_date = latest_date)

### |- special-case highlight rows ----
record_highlight <- record_points |>
  filter(number == record_number) |>
  mutate(record_label = glue("{record_gap} draws"))
exception_highlight <- summary_df |> filter(number == exception_row$number)

### |- second and third "hero" panels, per feedback: three highlighted
record_panel_highlight <- summary_df |> filter(number == record_number)
coldest_today_number <- summary_df |>
  filter(current_gap == max(current_gap)) |>
  pull(number) |>
  min()
coldest_panel_highlight <- summary_df |> filter(number == coldest_today_number)

```

#### [5. Visualization Parameters]{.smallcaps}

```{r}
#| label: params
#| include: true
#| warning: false

### |-  plot aesthetics ----
clrs <- get_theme_colors(
  palette = c(
    "record"    = "#722F37",
    "current"   = "grey45",
    "area"      = "grey75",
    "exception" = "#FFF2C9",
    "gold"      = "#C9A227",
    "cold"      = "#3C5A78"
  )
)
col_record <- unname(clrs$palette["record"])
col_current <- unname(clrs$palette["current"])
col_area <- unname(clrs$palette["area"])
col_exception <- unname(clrs$palette["exception"])
col_gold <- unname(clrs$palette["gold"])
col_cold <- unname(clrs$palette["cold"])
bg_color <- "#F7F5EF"

### |-  fonts ----
setup_fonts()
fonts <- get_font_families()

panel_label_style <- "strip"
panel_scale_style <- "shared"

scale_factors <- summary_df |>
  mutate(scale_factor = if (panel_scale_style == "normalized") 1 / record_gap else 1) |>
  select(number, scale_factor)

gap_history_plot <- gap_history |>
  left_join(scale_factors, by = "number") |>
  mutate(gap = gap * scale_factor)

record_points_plot <- record_points |>
  left_join(scale_factors, by = "number") |>
  mutate(record_gap = record_gap * scale_factor)

current_points_plot <- current_points |>
  left_join(scale_factors, by = "number") |>
  mutate(current_gap = current_gap * scale_factor)

record_highlight_plot <- record_highlight |>
  left_join(scale_factors, by = "number") |>
  mutate(record_gap = record_gap * scale_factor)

plot_y_max <- if (panel_scale_style == "normalized") 1 else global_max

### |- one-time orientation labels (panel 1 only, not repeated 69 times) ----
orientation_labels <- tibble(
  number = 1,
  draw_date = c(min(draws$draw_date), latest_date),
  y = plot_y_max * 1.05,
  label = c("2015", "today"),
  hjust = c(0, 1)
)

### |- tiny captions under the three hero panels
hero_captions <- tibble(
  number = c(coldest_today_number, record_number, exception_row$number),
  label  = c("Today's longest drought", "Historical record", "Current exception")
)
```

#### [6. Plot]{.smallcaps}

```{r}
#| label: plot
#| warning: false

### |- main plot: 69 small-multiple drought timelines ----
timeline_style <- "area"

timeline_layer <- if (timeline_style == "area") {
  geom_area(fill = col_area)
} else {
  geom_line(color = col_area, linewidth = 0.5)
}


### |- three "hero" panels instead of one ----
main_plot <- ggplot(gap_history_plot, aes(x = draw_date, y = gap)) +
  geom_rect(
    data = exception_highlight,
    aes(xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf),
    inherit.aes = FALSE, fill = col_exception, alpha = 0.7
  ) +
  geom_rect(
    data = exception_highlight,
    aes(xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf),
    inherit.aes = FALSE, fill = NA, color = col_gold, linewidth = 0.9
  ) +
  geom_rect(
    data = record_panel_highlight,
    aes(xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf),
    inherit.aes = FALSE, fill = NA, color = col_record, linewidth = 0.7
  ) +
  geom_rect(
    data = coldest_panel_highlight,
    aes(xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf),
    inherit.aes = FALSE, fill = NA, color = col_cold, linewidth = 0.7
  ) +
  timeline_layer +
  geom_hline(yintercept = 0, color = "grey55", linewidth = 0.25) +
  geom_point(
    data = record_points_plot,
    aes(x = draw_date, y = record_gap),
    color = col_record, size = 1.3
  ) +
  geom_point(
    data = current_points_plot,
    aes(x = draw_date, y = current_gap),
    shape = 21, size = 1.5, stroke = 0.5,
    fill = "white", color = col_current
  ) +
  geom_text(
    data = current_points,
    aes(x = draw_date, y = -plot_y_max * 0.10, label = current_gap),
    hjust = 1, size = 2.4, family = fonts$caption, color = "grey40"
  ) +
  geom_text(
    data = orientation_labels,
    aes(x = draw_date, y = y, label = label, hjust = hjust),
    size = 2.2, family = fonts$caption, color = "grey55", vjust = 0
  ) +
  geom_text(
    data = hero_captions,
    aes(x = latest_date, y = -plot_y_max * 0.22, label = label),
    hjust = 1, size = 2.1, family = fonts$text, fontface = "italic",
    color = "grey35", inherit.aes = FALSE
  ) +
  geom_segment(
    data = record_highlight_plot,
    aes(
      x = draw_date, xend = draw_date,
      y = record_gap + plot_y_max * 0.14, yend = record_gap + plot_y_max * 0.03
    ),
    arrow = arrow(length = unit(0.05, "inches")), color = "grey35",
    linewidth = 0.35, inherit.aes = FALSE
  ) +
  geom_richtext(
    data = record_highlight_plot,
    aes(x = draw_date, y = record_gap + plot_y_max * 0.17, label = record_label),
    hjust = 0.5, vjust = 0, size = 2.1, family = fonts$text,
    color = "grey20", fill = NA, label.color = NA,
    label.padding = unit(0, "lines"), inherit.aes = FALSE
  ) +
  scale_y_continuous(limits = c(-plot_y_max * 0.26, plot_y_max * 1.1)) +
  {
    if (panel_label_style == "inline") {
      geom_text(
        data = summary_df, aes(x = min(draws$draw_date), y = plot_y_max * 1.05, label = number),
        hjust = 0, vjust = 1, size = 2.4, family = fonts$title_2, fontface = "bold", color = "#2C2825"
      )
    }
  } +
  facet_wrap(
    ~number,
    ncol = 9,
    labeller = if (panel_label_style == "strip") as_labeller(\(x) paste0("No. ", x)) else as_labeller(\(x) "")
  ) +
  labs(x = NULL, y = NULL) +
  theme_minimal(base_family = fonts$text) +
  theme(
    strip.text        = if (panel_label_style == "inline") element_blank() else element_text(family = fonts$title_2, face = "bold", size = 7.5, color = "#2C2825", hjust = 0),
    strip.background  = element_blank(),
    axis.text         = element_blank(),
    axis.title        = element_blank(),
    panel.grid        = element_blank(),
    panel.spacing.x   = unit(0.42, "lines"),
    panel.spacing.y   = unit(0.48, "lines"),
    plot.background   = element_rect(fill = bg_color, color = NA),
    panel.background  = element_rect(fill = bg_color, color = NA)
  )

### |- right column, piece 1: THE FINDING ----
finding_md <- glue(
  "<span style='font-size:9pt; color:grey50'><b>THE FINDING</b></span><br><br>",
  "<span style='font-size:40pt; color:{col_record}'><b>{n_colder}</b></span>",
  "<span style='font-size:15pt; color:{col_record}'> of {n_total}</span><br><br>",
  "<span style='font-size:11.5pt'>Nearly every Powerball number has<br>",
  "experienced a longer drought than<br>the one it's in today.</span><br><br>",
  "<span style='font-size:8pt; color:grey50'><b>EVIDENCE</b></span><br><br>",
  "<span style='font-size:17pt; color:{col_record}'><b>{max(summary_df$current_gap)}</b></span>",
  "<span style='font-size:9.5pt'>{strrep(\"\\u00a0\", 3)}Today's longest drought</span><br>",
  "<span style='font-size:17pt; color:{col_record}'><b>{median_record}</b></span>",
  "<span style='font-size:9.5pt'>{strrep(\"\\u00a0\", 3)}Median personal drought</span><br>",
  "<span style='font-size:17pt; color:{col_record}'><b>{global_max}</b></span>",
  "<span style='font-size:9.5pt'>{strrep(\"\\u00a0\", 2)}Longest personal drought</span><br><br>",
  "<span style='font-size:7.5pt; color:grey55'>2015 &bull; current Powerball format</span>"
)

finding_panel <- ggplot() +
  geom_richtext(
    data = tibble(x = 0, y = 1, label = finding_md),
    aes(x = x, y = y, label = label),
    hjust = 0, vjust = 1, size = 3.6, family = 'sans',
    color = "#20202A", fill = NA, label.color = NA,
    label.padding = unit(0, "lines")
  ) +
  xlim(0, 1) +
  ylim(0, 1) +
  theme_void() +
  theme(plot.background = element_rect(fill = bg_color, color = NA))

### |- right column, piece 2: "how to read" as an actual mini-diagram ----
example_timeline <- tibble(x = 1:30) |>
  mutate(y = case_when(
    x <= 14 ~ x * (18 / 14),
    x <= 19 ~ 18 - (x - 14) * (18 / 5),
    TRUE ~ (x - 19) * (9 / 11)
  ))

how_to_read_diagram <- ggplot(example_timeline, aes(x, y)) +
  geom_area(fill = col_area) +
  geom_point(data = tibble(x = 14, y = 18), aes(x, y), color = col_record, size = 2.3) +
  geom_point(
    data = tibble(x = 30, y = 9), aes(x, y),
    shape = 21, fill = "white", color = col_current, size = 2.3, stroke = 0.7
  ) +
  annotate("segment", x = 14, xend = 14, y = 19.5, yend = 22.5, color = "grey55", linewidth = 0.3) +
  annotate("text",
    x = 14, y = 24.5, label = "personal\nrecord", size = 2.8,
    family = fonts$text, color = "grey30", lineheight = 0.9
  ) +
  annotate("segment", x = 30, xend = 30, y = 10.5, yend = 13.5, color = "grey55", linewidth = 0.3) +
  annotate("text",
    x = 30, y = 15.5, label = "today", size = 2.8,
    hjust = 1, family = 'sans', color = "grey30"
  ) +
  annotate("text",
    x = 1, y = -3.5, label = "2015", size = 2.5,
    hjust = 0, family = fonts$caption, color = "grey55"
  ) +
  annotate("text",
    x = 30, y = -3.5, label = "today", size = 2.5,
    hjust = 1, family = fonts$caption, color = "grey55"
  ) +
  labs(title = "HOW TO READ") +
  coord_cartesian(ylim = c(-5, 28), clip = "off") +
  theme_void(base_family = fonts$text) +
  theme(
    aspect.ratio = 0.42,
    plot.title = element_text(
      family = fonts$subtitle, face = "bold", size = 9.5,
      color = "#20202A", margin = margin(b = 10)
    ),
    plot.background = element_rect(fill = bg_color, color = NA),
    plot.margin = margin(t = 4, r = 10, b = 4, l = 4)
  )

### |- right column, piece 3: the exception ----
exception_md <- glue(
  "<b style='font-size:10pt'>THE EXCEPTION</b><br><br>",
  "<span style='font-size:28pt; color:{col_record}'><b>{exception_row$number}</b></span><br>",
  "currently setting<br>its personal record<br><br>",
  "<span style='font-size:11pt'>{exception_row$current_gap} draws \u2014 past its<br>old record of {exception_row$record_gap}</span>"
)

exception_panel <- ggplot() +
  geom_richtext(
    data = tibble(x = 0, y = 1, label = exception_md),
    aes(x = x, y = y, label = label),
    hjust = 0, vjust = 1, size = 3.3, family = fonts$subtitle,
    color = "#20202A", fill = col_exception, label.color = col_gold,
    label.padding = unit(0.35, "lines")
  ) +
  xlim(0, 1) +
  ylim(0, 1) +
  theme_void() +
  theme(plot.background = element_rect(fill = bg_color, color = NA))

### |- assemble right column ----
right_column <- finding_panel / how_to_read_diagram / exception_panel +
  plot_layout(heights = c(1.3, 0.85, 0.85))

### |- combine ----
title_text <- glue("{n_colder} of {n_total} Powerball Numbers Have Been Colder Than They Are Today")

final_plot <- (main_plot + right_column + plot_layout(widths = c(4.2, 1.6))) +
  plot_annotation(
    title = title_text,
    subtitle = glue(
      "Today's longest drought is just {max(summary_df$current_gap)} draws \u2014 well below ",
      "the longest drought experienced by almost every Powerball number."
    ),
    caption = glue(
      "As of {format(latest_date, '%B %d, %Y')} \u2022 Draws since Oct 7, 2015 (current Powerball format)<br>",
      "{create_standalone_caption(source_text = 'NY State Gaming Commission, data.ny.gov')}"
    ),
    theme = theme(
      plot.background = element_rect(fill = bg_color, color = NA),
      plot.title = element_text(
        family = fonts$title_1, face = "bold", size = 22,
        color = "#1A1A2E", margin = margin(b = 4)
      ),
      plot.subtitle = element_markdown(
        family = 'sans', size = 12, color = "#4A5568",
        margin = margin(b = 12)
      ),
      plot.caption = element_markdown(
        family = 'sans', size = 7, color = "grey45",
        hjust = 0, margin = margin(t = 10)
      ),
      plot.margin = margin(t = 20, r = 20, b = 12, l = 20)
    )
  )
```

#### [7. Save]{.smallcaps}

```{r}
#| label: save
#| warning: false

### |-  plot image ----  
save_plot_patchwork(
  plot = final_plot,
  type = "standalone",
  year = 2026,
  month = 7,
  date = 4,
  width = 16,
  height = 10
)

```

#### [8. Session Info]{.smallcaps}

::: {.callout-tip collapse="true"}
##### Expand for Session Info

```{r, echo = FALSE}
#| eval: true
#| warning: false

sessionInfo()
```
:::

#### [9. GitHub Repository]{.smallcaps}

::: {.callout-tip collapse="true"}
##### Expand for GitHub Repo

The complete code for this analysis is available in [`sa_2026-07-04.qmd`](https://github.com/poncest/personal-website/blob/master/projects/standalone_visualizations/sa_2026-07-04.qmd).

For the full repository, [click here](https://github.com/poncest/personal-website/).
:::

#### [10. References]{.smallcaps}
::: {.callout-tip collapse="true"}
##### Expand for References
1.  **Data Source:**
    -   NY State Gaming Commission (via data.ny.gov): [Lottery Powerball Winning Numbers: Beginning 2010](https://data.ny.gov/Government-Finance/Lottery-Powerball-Winning-Numbers-Beginning-2010/d6yy-54nr/about_data)
    -   Mirrored on Data.gov: [State of New York - Lottery Powerball Winning Numbers: Beginning 2010](https://catalog.data.gov/dataset/lottery-powerball-winning-numbers-beginning-2010)
:::


#### [11. Custom Functions Documentation]{.smallcaps}

::: {.callout-note collapse="true"}
##### 📦 Custom Helper Functions

This analysis uses custom functions from my personal module library for efficiency and consistency across projects.

**Functions Used:**

-   **`fonts.R`**: `setup_fonts()`, `get_font_families()` - Font management with showtext
-   **`social_icons.R`**: `create_social_caption()` - Generates formatted social media captions
-   **`image_utils.R`**: `save_plot()` - Consistent plot saving with naming conventions
-   **`base_theme.R`**: `create_base_theme()`, `extend_weekly_theme()`, `get_theme_colors()` - Custom ggplot2 themes

**Why custom functions?**\
These utilities standardize theming, fonts, and output across all my data visualizations. The core analysis (data tidying and visualization logic) uses only standard tidyverse packages.

**Source Code:**\
View all custom functions → [GitHub: R/utils](https://github.com/poncest/personal-website/tree/master/R)
:::

© 2024 Steven Ponce

Source Issues