"To be, or not to be — that is the question." We asked a different one: what does the data say?
This project takes Shakespeare's longest play and turns it into a data-driven poster. The script loads 01_hamlet.RData, runs four distinct analyses, and saves a single high-resolution PNG poster (hamlet_analysis_visualization.png). Everything lives in one file: Hamlet_DataViz_team1.r.
If you are a collaborator or want to run this project on your machine, clone the repository first:
git clone https://github.com/jadzoghaib/R_Hamlet_DataVizClub.gitThen navigate into the project folder:
cd R_Hamlet_DataVizClubOpen Hamlet_DataViz_team1.r in RStudio and click Source (not Run App — this is no longer a Shiny application).
Before you start working, always pull the latest changes from the repo:
git pull
- Prerequisites & Installation
- How to Run the Script
- Libraries Used
- The Data
- The Story We Are Telling
- Visualization 1 — Who Speaks With Whom? (Chord Diagram)
- Visualization 2 — Scene Presence Heatmap
- Visualization 3 — Sentiment Progression
- Visualization 4 — Distinctive Words
- The Poster
- Script Structure at a Glance
You need R 4.2 or higher. Download from https://cran.r-project.org.
Download from https://posit.co/download/rstudio-desktop.
Open R or RStudio and run the following once:
install.packages(c(
"tidyverse",
"tidytext",
"patchwork",
"grid",
"gridExtra",
"png",
"cowplot",
"showtext",
"SnowballC",
"scales",
"ggrepel",
"circlize"
))The script loads 01_hamlet.RData from the working directory. Make sure this file sits in the same folder as Hamlet_DataViz_team1.r before running.
R_Hamlet_DataVizClub/
├── Hamlet_DataViz_team1.r ← the script
├── 01_hamlet.RData ← the data (required)
├── hamlet_analysis_visualization.png ← output (generated on run)
└── README.md
Option A — RStudio button
Open Hamlet_DataViz_team1.r in RStudio. Click Source at the top of the editor. The script will run top-to-bottom, print progress messages in the console, and write hamlet_analysis_visualization.png to the working directory.
Option B — Console
source("Hamlet_DataViz_team1.r")Option C — Set working directory first
setwd("path/to/R_Hamlet_DataVizClub")
source("Hamlet_DataViz_team1.r")The output file is hamlet_analysis_visualization.png (40 × 26 inches at 150 dpi).
Note: The script uses Google Fonts loaded via
showtext. The first run may be slower if fonts need to download from Google's servers.
| Library | What it does in this project |
|---|---|
tidyverse |
The backbone of all data wrangling — dplyr for filtering/grouping/joining, ggplot2 for all static plots, stringr for text operations, purrr for pmap_dfr |
tidytext |
Splits dialogue into individual tokens (unnest_tokens), removes stop words (anti_join(stop_words)), and joins the Bing sentiment lexicon |
patchwork |
Assembles all ggplot objects into the final poster layout using / and | operators and plot_layout() |
grid |
Low-level graphics primitives — unit() for sizing in gridExtra layouts |
gridExtra |
Renders the Lion King comparison table as a styled tableGrob and stacks panels with arrangeGrob |
png |
Reads the chord diagram PNG back into R as a raster object so it can be embedded in the patchwork layout |
cowplot |
Wraps the chord PNG raster into a ggplot-compatible panel via ggdraw + draw_image |
showtext |
Loads Google Fonts — EB Garamond (body text), MedievalSharp (section headers), UnifrakturMaguntia (poster title) — and makes them available inside ggplot and base graphics |
SnowballC |
Stems words to their root form (wordStem) so that "drowned", "drowning", and "drown" all collapse to drown for the distinctive words analysis |
scales |
Provides percent formatter for the heatmap legend |
ggrepel |
Draws non-overlapping annotation labels on the sentiment chart (geom_label_repel) |
circlize |
Draws the chord diagram — handles arc proportions, ribbon colours, sector gaps, and circular text labels |
The dataset is loaded from 01_hamlet.RData. It contains the full text of Hamlet with one row per spoken line:
| Column | Description |
|---|---|
character |
Who is speaking (e.g. "Hamlet", "King Claudius", "[stage direction]") |
act |
The act as a string (e.g. "Act I", "Act III") |
scene |
The scene within the act (format varies: integers, Roman numerals, or full strings) |
line_number |
Sequential line number across the whole play (1 → ~3,800) |
dialogue |
The actual text spoken |
Stage directions are tagged as [stage direction] and are filtered out before every analysis. The play runs across 5 acts, 20 scenes, and roughly 3,800 lines of dialogue.
Hamlet is Shakespeare's longest play and one of the most studied works in the English language. Our goal was to answer four simple questions using data:
- Who is in this play — and who dominates the stage?
- When do the characters appear — and disappear?
- How does the emotional tone shift across the five acts?
- What words are uniquely tied to each character's voice?
To make the play accessible to anyone who hasn't read it, we map the characters to The Lion King — which is directly based on Hamlet. Simba is Hamlet, Scar is Claudius, Mufasa is the Ghost, and so on. Same story, different savanna.
The four visualizations build on each other: first you meet the cast and see how they connect (chord diagram), then you see precisely when they appear (heatmap), then you feel the emotional journey (sentiment), and finally you hear how each character speaks (distinctive words).
The question: Which characters share the stage, and how much of their dialogue happens in each other's presence?
slice_max(n, n = 6) picks the six characters with the most spoken lines, excluding stage directions. All data processing and drawing is restricted to these six characters.
The filtered data is grouped by act, scene, and character, and rows are counted with n(). The result is one row per character-scene combination with a lines column. A n_top6 column records how many of the six main characters appear in that scene, used to identify shared scenes.
This is the core data structure — a symmetric 6×6 matrix where matrix[i][j] = matrix[j][i] represents the interaction weight between characters i and j.
Off-diagonal entries — For every scene containing at least two main characters (n_top6 >= 2), the data is self-joined on act and scene to produce all ordered pairs (i, j). The raw counts are normalized so each character's off-diagonal row sum equals the lines they actually spoke in multi-character scenes (correcting for double-counting when three or more characters share a scene). The normalized values are then symmetrized by averaging each pair: v = (norm[i,j] + norm[j,i]) / 2, and the lower triangle is copied from the upper to guarantee exact floating-point symmetry.
Diagonal entries — Set to zero. The arc represents only lines spoken in shared scenes; there is no empty space for solo scenes.
The matrix is rounded to integers with round().
The diagram is built using low-level circlize functions rather than chordDiagram(). This guarantees exactly 5 ribbons per character (one per other character) regardless of any internal circlize behavior.
circos.initialize()sets each sector's width torowSums(chord_matrix_lines)— the total shared-scene lines for that character.- Track 1 (outermost) — a transparent track holds the character name labels, placing them visually outside the colored arc ring.
- Track 2 — the colored arc band, immediately inside the label ring and immediately above the ribbon area.
- A nested
forloop iterates over all unique pairs(i, j)withi > j— exactly 15 iterations for 6 characters. Each iteration callscircos.link()once. It is structurally impossible for any character to receive more than 5 ribbons. - Ribbon width on both endpoints is
chord_matrix_lines[i, j](symmetric). Ribbon color is characteri's palette color at 45% opacity.
Key layout parameters: start.degree = 90 (top), gap.degree = 3, canvas.xlim/ylim = c(-1.25, 1.25) (shrinks the circle to leave room for labels).
Because circlize uses base R graphics (not ggplot2), it cannot be combined directly with patchwork. The workaround:
- A temporary PNG file is opened with
png(tmp_chord, width = 1200, height = 1200, res = 150). draw_chord()renders into it anddev.off()closes the device.png::readPNG(tmp_chord)reads the file back as a raster array.cowplot::ggdraw() + cowplot::draw_image(chord_img)wraps the raster into a ggplot-compatible object that patchwork can place in the layout.
- Arc length for each sector = lines spoken in scenes shared with at least one other main character. Hamlet's arc is by far the largest.
- Ribbon width at a sector = the weight of that character's interaction with the connected character.
- Ribbon colour = the "from" character's palette color.
- 5 ribbons per character — one connecting to each of the other five main characters. No self-loops, no duplicates.
The question: When exactly does each character appear — and when are they absent?
The full hamlet data frame is loaded. Stage directions (character == "[stage direction]") are excluded. All characters are retained at this stage (not yet filtered to the top 7) because the scene_total_lines denominator needs to count every spoken line in a scene, regardless of who speaks it.
Before filtering to main characters, a scene-level total is computed using group_by(act, scene) %>% mutate(scene_total_lines = n()). This attaches to every row the total number of spoken lines in that scene across all characters. This is the denominator for the share calculation in Step 4.
The data is then filtered to character %in% main_characters. It is grouped by character, act, scene, and scene_total_lines (which is already attached), and rows are counted to get lines — how many lines that character spoke in that scene.
line_share = lines / scene_total_linesThis transforms raw line counts into a proportion: 0 means absent, 1 means the character spoke every line in the scene (never quite happens), 0.6 means they spoke 60% of the scene's dialogue. Using share rather than raw count normalizes across scenes of very different lengths.
Scene labels in the dataset are inconsistent — some are stored as integers (1), some as Roman numerals (IV), some as full strings ("Scene IV"). A three-level fallback using coalesce() handles all cases:
as.integer(s)— works for numeric strings.as.integer(as.roman(s))— works for plain Roman numeral strings like"IV".- A regex
gsub("^.*\\s([IVXivx]+)$", "\\1", trimws(s))extracts a trailing Roman numeral from a longer string (e.g."Scene IV"→"IV"), then converts.
All suppressWarnings calls prevent console noise from failed coercions. The result scene_num is always an integer.
Scene labels are constructed as paste0(act_labels[act], ".", scene_num) — producing labels like "I.1", "III.4", "V.2". A reference vector scene_order is built by pulling these labels sorted by act_num then scene_num, which gives the correct chronological left-to-right order on the X axis.
Act separator positions (act_sep) are computed by counting scenes per act, taking cumulative sums, and adding 0.5 to place vertical lines between tiles rather than on top of them.
tidyr::complete(character, scene_label = scene_order) fills in every character × scene combination that does not appear in the data (scenes where the character has zero lines). Missing lines and line_share values are filled with 0 — these render as the pale background colour on the heatmap.
scene_label is factored with levels = scene_order to enforce chronological X-axis order. character is factored with levels from character_totals$character (which is ordered descending by total lines) so Hamlet appears at the top.
geom_tile() draws one rectangle per character × scene cell. The fill scale maps line_share from pale parchment (#e6dec9 at 0%) to deep crimson (#7e1215 at 100%) using scale_fill_gradient(). Vertical gold-grey lines from geom_vline() mark act boundaries at the pre-computed act_sep$xpos positions.
- Pale cells — the character is absent from or barely present in that scene.
- Deep crimson — the character dominates that scene's dialogue.
- The most striking pattern: Hamlet goes nearly silent in Act IV (he has been sent to England by Claudius). The structural absence is immediately visible as a pale gap across his row.
- Characters who die mid-play (Polonius at III.4, Ophelia in Act IV) have their rows go permanently pale after that point.
The question: Does the emotional tone of the play get darker over time — and do Hamlet and Claudius feel the same way?
The pipeline runs identically and independently for Hamlet and King Claudius. For each, the hamlet data frame is filtered to rows where character == [target].
tidytext::unnest_tokens(word, dialogue) splits each line of dialogue into individual word tokens. The function lowercases everything and strips punctuation automatically. Each row in the result is one word token, still carrying its source line_number, act, and scene.
anti_join(stop_words, by = "word") removes tokens that appear in the tidytext stop word list — common function words like "the", "a", "of", "in", "that". These words carry no sentiment signal and would dilute the analysis. After this step, only content-bearing words remain.
inner_join(get_sentiments("bing"), by = "word") matches the remaining tokens against the Bing sentiment lexicon, a dictionary of ~6,800 English words manually labelled as either "positive" or "negative". Words not in the lexicon are dropped (only lexicon matches are retained). Each matched word then gets a numeric score:
sentiment_value = ifelse(sentiment == "positive", 1, -1)The token-level data is grouped by line_number, act, and scene, and scores are summed with sum(sentiment_value). The result line_sentiment is one row per spoken line that contained at least one sentiment word. A line containing "murder" (−1), "grief" (−1), and "noble" (+1) gets a line_sentiment of −1.
The line-level scores are sorted by line_number and then cumsum(line_sentiment) produces a running total. This is cumulative_sentiment — it rises when a character's recent language is positive-skewing, and falls when it is negative-skewing. The absolute value at any point reflects the accumulated emotional weight from line 1 to that point.
The Hamlet and Claudius data frames are combined with bind_rows(), with a character column added before binding to distinguish the two lines on the chart.
Six key dramatic events are annotated. Rather than placing annotations at fixed line numbers, each annotation is placed at the actual sentiment extremum within a defined window. A event_windows tibble defines each event's label, which character's line to look at, and a [line_min, line_max] search window. pmap_dfr() iterates over these rows and for each one:
- Filters
cumulative_sentiment_allto the specified character and line range. - Finds both the minimum and maximum cumulative sentiment within the window.
- Picks whichever has the larger absolute value — i.e., the most emotionally extreme point in that stretch.
The result is a six-row data frame of actual data points on the lines, used for annotation placement.
The two cumulative lines are drawn with geom_line(), coloured by character. Annotation points are drawn as open circles with geom_point(). ggrepel::geom_label_repel() draws the event labels with automatic collision avoidance — labels are nudged upward and connected to their points by arrows. Act boundaries are drawn as dashed vertical lines with act names placed at the top of the panel. Character name labels are placed at the end of each line using geom_label().
- Both characters trend downward overall — the play's language gets progressively darker from Act I to Act V.
- Hamlet's line is volatile — it drops sharply at key events and occasionally recovers.
- Claudius's line descends more steadily and at a shallower slope — his language is more controlled and measured, consistent with his character as a calculating, composed villain.
- The six annotated turning points correspond to the play's most pivotal dramatic moments.
The question: What words does each character use that are uniquely theirs — not just words they say often, but words they say disproportionately more than anyone else in the play?
The hamlet data frame is filtered to exclude stage directions and to keep only rows where character %in% main_characters (the top 6 characters). This ensures the analysis covers only the characters with enough dialogue to produce statistically meaningful distinctive words.
tidytext::unnest_tokens(word, dialogue) splits all dialogue into individual word tokens, lowercased and stripped of punctuation. Every token still carries its source character.
anti_join(stop_words, by = "word") removes common function words. This is the same step as in the sentiment pipeline. After this, only content words remain.
SnowballC::wordStem(word, language = "english") reduces each token to its morphological root using the Porter stemming algorithm. Examples:
"drowned","drowning","drown"→"drown""king","kings"→"king""love","loved","loving"→"love"
Stemming prevents the same concept from being split across word forms, which would artificially dilute each form's count and make distinctiveness ratios harder to detect.
count(word_stem, character) produces a table of how many times each character uses each stemmed word. This is the raw frequency matrix.
Within each character's vocabulary (grouped by character), the proportion of each stem is computed:
prop_this_character = n / sum(n)This is the fraction of that character's total word output that this particular stem represents.
Across the entire combined vocabulary (ungrouped), the overall proportion of each stem is computed:
prop_overall = n / sum(n)This is the fraction of all words in the play (across all 7 characters) that this stem represents.
relative = prop_this_character / prop_overallA ratio of 3.0 means this character uses this stem three times more frequently than the play average. A ratio of 10.0 means ten times more. This metric controls for the fact that some words are simply common in Elizabethan English — what matters is whether this character uses them more than expected.
Words with fewer than 3 total occurrences (filter(n > 2)) are excluded as noise — a word said once or twice can produce an extreme ratio by chance.
For the poster, each character's stems are sorted descending by relative (with n as a tiebreaker) and the top 5 are selected with slice(1:5). A rank column (1 through 5) is added within each character for X-axis positioning.
The words are displayed as a dot-plot grid: character on the Y axis (ordered by total lines, reversed so Hamlet is at top), rank on the X axis (1 = most distinctive). The text itself is plotted with geom_text() rather than points, so the word label is the data point. Colour encodes log(relative) on a gradient from navy (moderately distinctive) to deep crimson (extremely distinctive). The log scale prevents one extreme value from washing out all other colours.
- Position — rank 1 is the single most distinctive word for that character.
- Colour — deep crimson means the character uses this word at an extreme multiple of the play average; navy means the excess is real but more modest.
- The same word can appear for two characters in different colours because the colour encodes each character's individual ratio, not the word itself. A word that is deep crimson for one character but navy for another means the first character uses it at a far higher multiple of the play average.
Running the script generates a single PNG file (hamlet_analysis_visualization.png, 40 × 26 inches at 150 dpi) with the following layout:
┌──────────────────────────────────────────────────────────────────────┐
│ Intro text + Lion King table │ Title banner │
│ ├──────────────────┬──────────────────│
│ │ 01 Chord Diagram│ 02 Heatmap │
├────────────────────────────────┴──────────────────┴──────────────────┤
│ 03 Sentiment Progression │ 04 Distinctive Words │
├───────────────────────────────────────────────────────────────────────┤
│ Team member names (caption) │
└───────────────────────────────────────────────────────────────────────┘
| Section | Content |
|---|---|
| Title | "A Visual Analysis of Hamlet by Shakespeare" in UnifrakturMaguntia blackletter |
| Intro text | Brief summary of the play and our four analytical questions |
| Lion King table | Character mapping to orient readers unfamiliar with Hamlet |
| 01 — Chord Diagram | Symmetric chord diagram of lines spoken in shared scenes (top 6 characters, 5 ribbons each) |
| 02 — Scene Presence | Heatmap of each character's share of dialogue per scene |
| 03 — Sentiment | Cumulative Bing sentiment progression for Hamlet and Claudius |
| 04 — Distinctive Words | Top 5 most distinctive word stems per character (dot-plot grid) |
| Caption | Team member names |
The poster background colour (#F5E6C8) is a warm parchment tone carried consistently across every panel.
Hamlet_DataViz_team1.r
│
├── GLOBAL SETUP
│ ├── Libraries
│ ├── Fonts (Google Fonts via showtext)
│ ├── Data load (01_hamlet.RData)
│ ├── Color palette (6 characters, vintage hex codes)
│ ├── Top 6 characters + character_totals
│ ├── Act boundaries
│ ├── hamlet_theme() — shared ggplot2 theme
│ ├── chord_matrix_lines — symmetric 6×6 lines-in-shared-scenes matrix (diagonal = 0)
│ ├── draw_chord() — circlize chord diagram using circos.link() (15 calls, 5 ribbons/character)
│ ├── Heatmap data (line_share per character per scene, full grid)
│ ├── Sentiment data (cumulative Bing scores for Hamlet + Claudius)
│ ├── sentiment_annotations — extremum-anchored event labels
│ └── distinctive_words_base — stemmed proportions + distinctiveness ratios
│
├── make_header() — section header panel generator
│
└── POSTER ASSEMBLY BLOCK
├── title_panel
├── intro_plot + lk_table_grob → left_col
├── Chord PNG → tmp_chord → network_plot_p (via cowplot)
├── heatmap_plot_p
├── sentiment_plot_p
├── distinctive_plot_p
├── Section headers (sec1–sec4) via make_header()
├── Patchwork layout: top_section / bottom_section / caption_panel
└── ggsave → hamlet_analysis_visualization.png
Group 1 — Data Analytics with R Andrés Ramírez Arroyo · Ferdinand Rasmussen · Federica Selvini · Max Voss · Jad Zoghaib