---
title: "One fight, two grades"
subtitle: "Cited Feats grades each citation shown if the cited passage depicts the feat, stated if a character, narrator or guidebook asserts it. A grade describes the evidence cited for a claim, not the event itself."
description: "Arthur Dayne's Tower of Joy fight is graded stated in the book and shown in the TV show, yet 15 of 16 hand-matched events have the same grade in both. Citation pairs from prose originals and their adaptations were found by word overlap and reviewed by hand. Built in R with ggplot2, patchwork and ggtext."
date: "2026-11-09"
author:
- name: "Steven Ponce"
url: "https://stevenponce.netlify.app"
citation:
url: "https://stevenponce.netlify.app/data_visualizations/TidyTuesday/2026/tt_2026_45.html"
categories: ["TidyTuesday", "2026"]
tags: [
"TidyTuesday",
"Cited Feats",
"Fictional Characters",
"Evidence Grading",
"Game of Thrones",
"Provenance Diagram",
"Paired Comparison",
"Explainer",
"Annotation",
"patchwork",
"ggtext",
"ggplot2",
"2026"
]
image: "thumbnails/tt_2026_45.png"
format:
html:
toc: true
toc-depth: 5
code-link: true
code-fold: true
code-tools: true
code-summary: "Show code"
editor_options:
chunk_output_type: inline
execute:
freeze: true
cache: true
error: false
message: false
warning: false
eval: true
---
{#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, scales, glue, ggview,
patchwork, ragg, magick
)
})
# 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
### |- figure settings ----
fig_w <- 12
fig_h <- 7.5
fig_dpi <- 320
### |- fonts ----
setup_fonts()
fonts <- get_font_families()
title_family <- fonts$title_1
## 2. READ IN THE DATA ----
## Read every field as character with no NA tokens; restore numbers column by column.
tt_url <- "https://raw.githubusercontent.com/rfordatascience/tidytuesday/main/data/2026/2026-11-10/"
read_chr <- function(url) {
read_csv(url, col_types = cols(.default = col_character()), na = character(), show_col_types = FALSE)
}
citations_raw <- read_chr(paste0(tt_url, "fiction_citations.csv"))
rankings_raw <- read_chr(paste0(tt_url, "fiction_rankings.csv"))
## Hand-reviewed pool, written by tt_2026_45_match_candidates.R: every word-overlap candidate
## pair (prose original vs adaptation) with its decision -- clean, partial or rejected.
matches_path <- here::here("data/TidyTuesday/2026/tt_2026_45_matched_events.csv")
matches_raw <- read_chr(matches_path)
```
#### [3. Examine the Data]{.smallcaps}
```{r}
#| label: examine
#| include: true
#| eval: true
#| results: 'hide'
#| warning: false
## 3. EXAMINING THE DATA ----
glimpse(citations_raw)
glimpse(matches_raw)
citations_raw |> count(type, confidence) |> print(n = Inf)
```
#### [4. Tidy Data]{.smallcaps}
```{r}
#| label: tidy
#| warning: false
### |- base rates quoted in copy ----
n_citations <- nrow(citations_raw)
n_scaled <- sum(citations_raw$confidence == "scaled")
### |- look up each side of every matched pair in the citations ----
## A side matches when franchise, character, type and locator agree exactly and the
## citation's claim starts with the recorded opening; exactly one citation must match.
lookup_side <- function(slug, entry, type_wanted, loc, claim_start) {
hit <- citations_raw |>
filter(
franchise_slug == slug, subject_id == entry, subject_kind == "entry",
type == type_wanted, locator == loc, str_starts(claim, fixed(claim_start))
)
if (nrow(hit) != 1) stop(glue("{entry} / {loc}: {nrow(hit)} citations matched, expected 1"))
hit |> select(work, confidence, claim)
}
n_reviewed <- nrow(matches_raw)
n_rejected <- sum(matches_raw$match == "rejected")
pairs <- matches_raw |>
filter(match != "rejected") |>
mutate(
prose = pmap(list(franchise_slug, entry_id, "novel", prose_locator, prose_claim_start), lookup_side),
adapt = pmap(list(franchise_slug, entry_id, adapt_type, adapt_locator, adapt_claim_start), lookup_side)
) |>
unnest_wider(prose, names_sep = "_") |>
unnest_wider(adapt, names_sep = "_") |>
left_join(
rankings_raw |> select(franchise_slug, entry_id, name, franchise),
by = c("franchise_slug", "entry_id")
) |>
mutate(changed = prose_confidence != adapt_confidence)
clean_pairs <- pairs |> filter(match == "clean")
n_clean <- nrow(clean_pairs)
n_changed <- sum(clean_pairs$changed)
n_same <- n_clean - n_changed
n_partial <- sum(pairs$match == "partial")
### |- Panel A: the one clean pair whose grade changes ----
hero <- clean_pairs |> filter(changed)
stopifnot(nrow(hero) == 1, hero$entry_id == "arthur-dayne", hero$event_label == "Tower of Joy")
### |- Panel B: row layout, franchise groups with a header row each ----
franchise_order <- c("westeros", "overlord", "solo-leveling")
franchise_head <- c(
westeros = "A Song of Ice and Fire / Game of Thrones",
overlord = "Overlord light novels / anime",
`solo-leveling` = "Solo Leveling web novel / webtoon"
)
ledger <- clean_pairs |>
mutate(franchise_slug = factor(franchise_slug, levels = franchise_order)) |>
arrange(franchise_slug, name, event_label) |>
mutate(row = row_number())
## y positions: one slot per row, plus one header slot (and a small gap) per franchise
row_step <- 1
ledger <- ledger |>
mutate(
group_idx = as.integer(franchise_slug),
y = -(row + group_idx * 1.25) * row_step
)
group_heads <- ledger |>
summarise(y = max(y) + 1.05 * row_step, .by = franchise_slug) |>
mutate(label = franchise_head[as.character(franchise_slug)])
partial_row <- pairs |>
filter(match == "partial") |>
mutate(y = min(ledger$y) - 2.1 * row_step)
```
#### [5. Visualization Parameters]{.smallcaps}
```{r}
#| label: params
#| include: true
#| warning: false
### |- plot aesthetics ----
clrs <- get_theme_colors(
palette = list(
accent = "#722F37",
ink = "#2C2825",
shown = "#5E5650",
stated = "#FFFFFF",
rule = "#D9D3C9",
muted = "#7A7068",
card = "#FFFFFF"
)
)
col_accent <- clrs$palette$accent
col_ink <- clrs$palette$ink
col_shown <- clrs$palette$shown
col_stated <- clrs$palette$stated
col_rule <- clrs$palette$rule
col_muted <- clrs$palette$muted
col_card <- clrs$palette$card
### |- titles and caption ----
title_text <- "One fight, two grades"
subtitle_text <- glue(
"Cited Feats grades each citation **shown** if the cited passage depicts the feat, ",
"**stated** if a character, narrator or guidebook asserts it.<br>",
"A grade describes the evidence cited for a claim, not the event itself."
)
cue_a <- "The event is the same; the cited passages are not. The book passage is Eddard's\naccount, told to Bran; the show's is Bran's vision of the fight itself."
head_b <- glue("B {n_same} of {n_clean} hand-matched events keep the same grade")
cue_b <- glue(
"Events cited from both a prose original and its adaptation, found by word overlap within each\n",
"character's citations and checked by hand; not every event the site cites twice."
)
caption_lines <- c(
glue(
"Claim text is Cited Feats' own summary of each cited passage. {n_reviewed} word-overlap candidates from the three franchises cited in both ",
"a prose original and its adaptation were read by hand: {n_clean} same event, {n_partial} partial, {n_rejected} different events."
),
glue(
"The site's third grade, scaled (inferred by comparison), appears in {n_scaled} of {comma(n_citations)} citations. ",
"Data snapshot 2026-10-10; the live site has changed since."
)
)
caption_text <- paste0(
caption_lines[1], "<br>", caption_lines[2], "<br>",
create_social_caption(
tt_year = 2026,
tt_week = 45,
source_text = "Cited Feats open data (CC BY 4.0) via TidyTuesday"
)
)
### |- plot theme ----
base_theme <- create_base_theme(clrs)
weekly_theme <- extend_weekly_theme(
base_theme,
theme(
plot.title = element_textbox_simple(
face = "bold", family = title_family, size = 30,
color = col_ink, lineheight = 1.0, margin = margin(b = 12)
),
plot.subtitle = element_textbox_simple(
family = fonts$subtitle, size = 11.5, color = col_ink,
lineheight = 1.3, margin = margin(b = 10)
),
plot.caption = element_textbox_simple(
family = fonts$caption, size = 6.6, color = col_muted,
lineheight = 1.35, margin = margin(t = 10)
),
axis.text = element_blank(),
axis.title = element_blank(),
axis.ticks = element_blank(),
panel.grid = element_blank(),
legend.position = "none",
plot.margin = margin(14, 20, 8, 20)
)
)
theme_set(weekly_theme)
```
#### [6. Plot]{.smallcaps}
```{r}
#| label: plot
#| warning: false
### |- grade chip helper (shared by both panels) ----
chip_layer <- function(df, x, y, grade, w, h, size, border, lw) {
list(
geom_tile(
data = df, aes(x = {{ x }}, y = {{ y }}),
width = w, height = h,
fill = if_else(pull(df, {{ grade }}) == "shown", col_shown, col_stated),
color = border, linewidth = lw
),
geom_text(
data = df, aes(x = {{ x }}, y = {{ y }}, label = str_to_upper({{ grade }})),
family = fonts$text, fontface = "bold", size = size,
color = if_else(pull(df, {{ grade }}) == "shown", "white", col_ink)
)
)
}
### |- panel A: one event, two cited passages ----
cards <- tibble(
side = c("prose", "adapt"),
x = c(0.245, 0.755),
head = c("PROSE ORIGINAL", "ADAPTATION"),
source = c(
glue("{hero$prose_work}\n{hero$prose_locator}"),
glue("{hero$adapt_work}\n{hero$adapt_locator}")
),
claim = c(str_wrap(hero$prose_claim, 30), str_wrap(hero$adapt_claim, 30)),
grade = c(hero$prose_confidence, hero$adapt_confidence)
)
card_w <- 0.47
card_bottom <- 0.02
card_top <- 0.66
p_a <- ggplot() +
# panel heading and decoding cue
annotate("text",
x = 0, y = 1.00, hjust = 0, vjust = 1, family = fonts$text, size = 3.6,
fontface = "bold", color = col_ink, label = "A One event, two cited passages"
) +
annotate("text",
x = 0, y = 0.945, hjust = 0, vjust = 1, family = fonts$text, size = 2.9,
color = col_muted, lineheight = 1.15, label = cue_a
) +
# the event
annotate("rect",
xmin = 0.22, xmax = 0.78, ymin = 0.765, ymax = 0.835,
fill = col_card, color = col_ink, linewidth = 0.4
) +
annotate("text",
x = 0.5, y = 0.80, family = fonts$text, size = 3.6, fontface = "bold",
color = col_ink, label = glue("{hero$name} at the {hero$event_label}")
) +
# the branch: the accent marks the change, not a grade
annotate("segment", x = 0.5, xend = 0.5, y = 0.765, yend = 0.715, color = col_accent, linewidth = 0.9) +
annotate("segment", x = 0.245, xend = 0.755, y = 0.715, yend = 0.715, color = col_accent, linewidth = 0.9) +
annotate("segment", x = 0.245, xend = 0.245, y = 0.715, yend = card_top, color = col_accent, linewidth = 0.9) +
annotate("segment", x = 0.755, xend = 0.755, y = 0.715, yend = card_top, color = col_accent, linewidth = 0.9) +
# the two cards
geom_rect(
data = cards,
aes(xmin = x - card_w / 2, xmax = x + card_w / 2, ymin = card_bottom, ymax = card_top),
fill = col_card, color = col_rule, linewidth = 0.5
) +
geom_text(
data = cards, aes(x = x - card_w / 2 + 0.03, y = card_top - 0.035, label = head),
hjust = 0, vjust = 1, family = fonts$text, size = 2.6, fontface = "bold", color = col_muted
) +
geom_text(
data = cards, aes(x = x - card_w / 2 + 0.03, y = card_top - 0.085, label = source),
hjust = 0, vjust = 1, family = fonts$text, size = 3.1, color = col_ink, lineheight = 1.1
) +
geom_text(
data = cards, aes(x = x - card_w / 2 + 0.03, y = card_top - 0.215, label = claim),
hjust = 0, vjust = 1, family = fonts$text, size = 2.75, fontface = "italic",
color = col_muted, lineheight = 1.2
) +
chip_layer(
cards |> mutate(cy = card_bottom + 0.075), x, cy, grade,
w = 0.30, h = 0.075, size = 4.2, border = col_ink, lw = 0.5
) +
scale_x_continuous(limits = c(0, 1), expand = expansion(0)) +
scale_y_continuous(limits = c(0, 1), expand = expansion(0)) +
coord_cartesian(clip = "off")
### |- panel B: the whole reviewed pool ----
x_name <- 0.025
x_event <- 0.27
x_prose <- 0.70
x_adapt <- 0.89
chip_w <- 0.15
chip_h <- 0.72
y_top <- max(group_heads$y) + 1.9
p_b <- ggplot() +
# panel heading and decoding cue
annotate("text",
x = 0, y = y_top + 2.5, hjust = 0, vjust = 1, family = fonts$text, size = 3.6,
fontface = "bold", color = col_ink, label = head_b
) +
annotate("text",
x = 0, y = y_top + 1.5, hjust = 0, vjust = 1, family = fonts$text, size = 2.9,
color = col_muted, lineheight = 1.15, label = cue_b
) +
# column headers
annotate("text",
x = c(x_prose, x_adapt), y = y_top - 0.55, label = c("PROSE ORIGINAL", "ADAPTATION"),
family = fonts$text, size = 2.5, fontface = "bold", color = col_muted
) +
# franchise group headers
geom_text(
data = group_heads, aes(x = 0, y = y, label = label),
hjust = 0, family = fonts$caption, size = 2.4, color = col_muted
) +
# rows: name and event
geom_text(
data = ledger, aes(x = x_name, y = y, label = name),
hjust = 0, family = fonts$text, size = 2.9,
fontface = if_else(ledger$changed, "bold", "plain"),
color = if_else(ledger$changed, col_accent, col_ink)
) +
geom_text(
data = ledger, aes(x = x_event, y = y, label = event_label),
hjust = 0, family = fonts$text, size = 2.9,
fontface = if_else(ledger$changed, "bold", "plain"),
color = if_else(ledger$changed, col_accent, col_muted)
) +
# connector between the two grades
geom_segment(
data = ledger,
aes(x = x_prose + chip_w / 2, xend = x_adapt - chip_w / 2, y = y, yend = y),
color = if_else(ledger$changed, col_accent, col_rule),
linewidth = if_else(ledger$changed, 0.9, 0.35)
) +
chip_layer(
ledger, x_prose, y, prose_confidence,
w = chip_w, h = chip_h, size = 2.3,
border = if_else(ledger$changed, col_accent, col_shown),
lw = if_else(ledger$changed, 0.9, 0.3)
) +
chip_layer(
ledger, x_adapt, y, adapt_confidence,
w = chip_w, h = chip_h, size = 2.3,
border = if_else(ledger$changed, col_accent, col_shown),
lw = if_else(ledger$changed, 0.9, 0.3)
) +
# partial match, outside the count
annotate("segment",
x = 0, xend = 1, y = partial_row$y + 1.15, yend = partial_row$y + 1.15,
color = col_rule, linewidth = 0.4, linetype = "22"
) +
annotate("text",
x = 0, y = partial_row$y + 0.6, hjust = 0, family = fonts$caption, size = 2.4,
color = col_muted, label = "Partial match, not counted"
) +
geom_text(
data = partial_row, aes(x = x_name, y = y - 0.25, label = name),
hjust = 0, family = fonts$text, size = 2.9, fontface = "italic", color = col_muted
) +
geom_text(
data = partial_row, aes(x = x_event, y = y - 0.25, label = event_label),
hjust = 0, family = fonts$text, size = 2.9, fontface = "italic", color = col_muted
) +
chip_layer(
partial_row |> mutate(y = y - 0.25), x_prose, y, prose_confidence,
w = chip_w, h = chip_h, size = 2.3, border = col_shown, lw = 0.3
) +
chip_layer(
partial_row |> mutate(y = y - 0.25), x_adapt, y, adapt_confidence,
w = chip_w, h = chip_h, size = 2.3, border = col_shown, lw = 0.3
) +
scale_x_continuous(limits = c(0, 1), expand = expansion(0)) +
scale_y_continuous(limits = c(partial_row$y - 1, y_top + 2.6), expand = expansion(0)) +
coord_cartesian(clip = "off")
### |- combine ----
p <- p_a + p_b +
plot_layout(widths = c(0.82, 1.18)) +
plot_annotation(
title = title_text, subtitle = subtitle_text, caption = caption_text,
theme = weekly_theme
)
```
### [7. Save]{.smallcaps}
```{r}
#| label: save
#| warning: false
### |- save ----
main_path <- here::here("data_visualizations", "TidyTuesday", "2026", "tt_2026_45.png")
thumb_path <- here::here("data_visualizations", "TidyTuesday", "2026", "thumbnails", "tt_2026_45.png")
# Full-size version, for the QMD figure
ggview::save_ggplot(plot = p, file = main_path, width = fig_w,
height = fig_h, units = "in", dpi = fig_dpi)
# Reduced-size thumbnail, for the YAML `image:` field
fs::dir_create(dirname(thumb_path))
magick::image_read(main_path) |>
magick::image_resize("400") |>
magick::image_write(thumb_path)
```
#### [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 [`tt_2026_45.qmd`](https://github.com/poncest/personal-website/blob/master/data_visualizations/TidyTuesday/2026/tt_2026_45.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:**
- TidyTuesday 2026 Week 44: [evidence-graded power rankings of fictional characters](https://github.com/rfordatascience/tidytuesday/blob/main/data/2026/2026-11-10/readme.md)
:::
#### [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
- **`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)
:::