Grafici animati con gganimate

Author

Domenico Vistocco

Prepariamo l’ambiente di lavoro

Il package gganimate estende ggplot2: permette di trasformare un grafico statico in un’animazione aggiungendo nuovi strati con il +, esattamente come si aggiungono geometrie, facet o temi. Per produrre l’animazione, gganimate disegna una serie di fotogrammi (frame) e li unisce in un unico file: per questo passaggio ha bisogno di un renderer, cioè di un package che sappia creare il file finale. I più usati sono gifski, per le gif, e av, per i video in formato mp4.

install.packages(c("gganimate", "gifski", "av"))
# carico le librerie di lavoro --------------------------------------------
library(tidyverse)          # per ggplot2 e per le funzioni di dplyr
library(janitor)            # per la funzione clean_names
library(gapminder)          # per la tabella dati utilizzata
library(gganimate)          # per animare i grafici di ggplot2
# NOTA: per creare le animazioni gganimate ha bisogno di un "renderer":
#       - il package gifski (consigliato) per salvare in formato gif
#       - il package av per salvare in formato video (mp4)
#       se non sono installati:
#       install.packages(c("gganimate", "gifski", "av"))
NoteLe animazioni di questo tutorial

Calcolare un’animazione richiede qualche secondo, e questo tutorial ne contiene più di venti. Per questo il codice delle animazioni è mostrato ma non eseguito durante la compilazione del documento: le animazioni che vedete sono state create in anticipo, eseguendo lo stesso codice dalla console, e salvate come file gif. Eseguendo il codice in RStudio o in Positron otterrete le stesse animazioni.

La tabella dati gapminder

Come nei tutorial su ggplot2 e sulla grafica di base, uso la tabella gapminder, che riporta per 142 paesi e per gli anni dal 1952 al 2007 (ogni 5 anni) il continente, la speranza di vita alla nascita, la popolazione e il PIL pro capite. È la tabella resa famosa dalla presentazione di Hans Rosling: “The best stats you’ve ever seen”, in cui un diagramma a bolle animato mostra come sono cambiate ricchezza e salute delle nazioni nel corso di mezzo secolo. Ricostruire quel grafico è l’obiettivo dell’ultima parte del tutorial.

Oltre alla tabella completa gap_data (con i nomi delle variabili ripuliti da clean_names) e alla tabella gap_2007, preparo la tabella gap_continenti con le medie per continente e anno, utile per gli esempi più semplici.

gap_data <- gapminder |>
  clean_names() |>
  rename(gdp_per_cap = gdp_percap)
gap_2007 <- gap_data |>
  filter(year == 2007)
# medie per continente e anno, utili per alcuni esempi
gap_continenti <- gap_data |>
  group_by(continent, year) |>
  summarise(life_exp = mean(life_exp),
            gdp_per_cap = mean(gdp_per_cap),
            .groups = "drop")
gap_continenti
# A tibble: 60 × 4
   continent  year life_exp gdp_per_cap
   <fct>     <int>    <dbl>       <dbl>
 1 Africa     1952     39.1       1253.
 2 Africa     1957     41.3       1385.
 3 Africa     1962     43.3       1598.
 4 Africa     1967     45.3       2050.
 5 Africa     1972     47.5       2340.
 6 Africa     1977     49.6       2586.
 7 Africa     1982     51.6       2482.
 8 Africa     1987     53.3       2283.
 9 Africa     1992     53.6       2282.
10 Africa     1997     53.6       2379.
# ℹ 50 more rows

La logica di gganimate

Un grafico ggplot2 rappresenta i dati in un’unica immagine. Quando nei dati c’è una dimensione temporale, o più in generale una variabile che distingue “momenti” diversi, possiamo rappresentarla in tre modi: con il colore o la forma degli elementi, con un pannello per ciascun momento (i facet), oppure con il tempo stesso, mostrando un momento dopo l’altro. È quest’ultima la strada di gganimate.

Il grafico seguente, con tutti gli anni insieme, contiene troppi punti per essere leggibile:

# gganimate estende la grammatica di ggplot2: si parte da un grafico
# statico e si AGGIUNGONO (con il +) nuovi "strati" che descrivono come
# il grafico cambia nel tempo. Le famiglie di funzioni sono:
#   transition_*()  quale variabile guida l'animazione (obbligatoria)
#   enter_*()/exit_*()  come compaiono e scompaiono gli elementi
#   ease_aes()      con quale "andatura" ci si muove da un fotogramma all'altro
#   view_*()        come cambiano gli assi durante l'animazione
#   shadow_*()      quale traccia lasciano i fotogrammi passati
# partiamo da un grafico statico: tutti gli anni insieme (troppi punti!)
ggplot(gap_data, aes(x = gdp_per_cap, y = life_exp)) +
  geom_point(alpha = 0.3) +
  scale_x_log10()

Aggiungendo una sola riga, transition_time(year), otteniamo un fotogramma per ciascun anno, più i fotogrammi intermedi che rendono fluido il passaggio da un anno all’altro:

# aggiungendo transition_time(year) otteniamo un fotogramma per ogni anno
# (e i fotogrammi intermedi, interpolati)
p_idea <- ggplot(gap_data, aes(x = gdp_per_cap, y = life_exp)) +
  geom_point(alpha = 0.5) +
  scale_x_log10() +
  transition_time(year)
class(p_idea)               # un oggetto "gganim": un ggplot che sa muoversi
p_idea                      # stampandolo, R calcola e mostra l'animazione
# NOTA: il calcolo richiede qualche secondo (per default 100 fotogrammi);
#       in RStudio/Positron l'animazione compare nel pannello Viewer

L’oggetto prodotto ha classe gganim: è un grafico ggplot2 che “sa muoversi”. Come un grafico ggplot2, viene disegnato solo quando lo si stampa; a differenza di un grafico statico, la stampa richiede qualche secondo, perché R deve disegnare tutti i fotogrammi (100 per default) e unirli. In RStudio e in Positron l’animazione compare nel pannello Viewer.

Le funzioni di gganimate si dividono in famiglie, ognuna con un compito preciso:

Famiglia Che cosa stabilisce Esempi
transition_*() quale variabile guida l’animazione (è l’unica obbligatoria) transition_states(), transition_time(), transition_reveal(), transition_layers()
enter_*(), exit_*() come compaiono e scompaiono gli elementi enter_fade(), enter_grow(), exit_shrink(), exit_fly()
ease_aes() l’andatura del movimento tra due fotogrammi ease_aes("linear"), ease_aes("cubic-in-out")
view_*() come cambiano gli assi durante l’animazione view_follow(), view_step(), view_zoom()
shadow_*() quale traccia lasciano i fotogrammi passati shadow_wake(), shadow_trail(), shadow_mark()

transition_states(): una variabile qualitativa

transition_states() passa da uno stato all’altro, dove gli stati sono le modalità di una variabile qualitativa (o di una variabile numerica con pochi valori). Nell’esempio, l’aspettativa di vita e il PIL pro capite dei paesi nel 2007, un continente alla volta:

# transition_states() passa da uno "stato" all'altro: gli stati sono le
# modalità di una variabile qualitativa (o di un numero intero con pochi
# valori). Qui: l'aspettativa di vita del 2007, un continente alla volta
p_states <- ggplot(gap_2007, aes(x = gdp_per_cap, y = life_exp)) +
  geom_point(aes(color = continent), size = 3) +
  scale_x_log10() +
  transition_states(continent)
p_states
# NOTA: guardate bene il passaggio da un continente all'altro: i punti
#       "viaggiano" e cambiano colore. gganimate abbina le righe di uno
#       stato a quelle dello stato successivo e le sposta: qui l'effetto
#       non ha senso (un paese africano non "diventa" americano).
#       Lo sistemiamo nella sezione 3

Gli argomenti principali di transition_states() regolano i tempi dell’animazione:

  • transition_length: la durata (relativa) del passaggio da uno stato al successivo
  • state_length: la durata (relativa) della pausa su ciascuno stato
  • wrap: se TRUE (il default) dopo l’ultimo stato l’animazione torna al primo

Le durate sono relative: con transition_length = 1 e state_length = 3 le pause durano il triplo dei passaggi, qualunque sia la durata complessiva dell’animazione.

# gli argomenti principali di transition_states():
#   transition_length  durata (relativa) del passaggio tra due stati
#   state_length       durata (relativa) della pausa su ciascuno stato
#   wrap               TRUE (default): dall'ultimo stato torna al primo
# nel titolo, {closest_state} viene sostituito dallo stato corrente
p_states <- ggplot(gap_2007, aes(x = gdp_per_cap, y = life_exp)) +
  geom_point(aes(color = continent), size = 3, show.legend = FALSE) +
  scale_x_log10() +
  labs(title = "Continente: {closest_state}") +
  transition_states(continent, transition_length = 1, state_length = 3)
p_states

TipLe variabili tra parentesi graffe

Nei testi del grafico (titolo, sottotitolo, didascalia, …) si possono inserire tra parentesi graffe alcune variabili calcolate da gganimate per ogni fotogramma. Quelle disponibili dipendono dalla funzione transition_*() usata:

Funzione Variabili
transition_states() {closest_state}, {previous_state}, {next_state}, {transitioning}
transition_time() {frame_time}
transition_reveal() {frame_along}
tutte {frame} (numero del fotogramma), {nframes} (numero totale), {progress} (avanzamento, da 0 a 1)

Tra le graffe si può scrivere anche un’espressione R, ad esempio {round(frame_time)}.

Un esempio classico di transition_states() sono le barre che crescono nel tempo. Qui l’anno ha solo 12 valori e lo possiamo trattare come uno stato:

# un esempio classico: barre che crescono anno dopo anno.
# Qui year ha 12 valori: lo trattiamo come stato
p_barre <- ggplot(gap_continenti, aes(x = continent, y = life_exp,
                                      fill = continent)) +
  geom_col(show.legend = FALSE) +
  labs(title = "Aspettativa di vita media nel {closest_state}",
       x = NULL, y = "Anni") +
  transition_states(year, transition_length = 2, state_length = 1)
p_barre

Entrare, uscire, muoversi

Gli oggetti dell’animazione e l’estetica group

Per costruire i fotogrammi intermedi, gganimate deve sapere quali righe di uno stato corrispondono a quali righe dello stato successivo: le righe abbinate vengono spostate gradualmente dalla vecchia alla nuova posizione. Se non diciamo nulla, le righe vengono abbinate in base ai gruppi definiti dalle variabili qualitative del grafico e all’ordine in cui compaiono nella tabella. Nell’esempio dei continenti questo produce un effetto privo di senso (i punti africani “diventano” punti americani), che si corregge con l’estetica group: con group = country ogni paese è un oggetto a sé, che non ha corrispondenti negli altri stati.

Gli oggetti senza corrispondenti entrano nell’animazione quando compaiono ed escono quando scompaiono. Le funzioni enter_*() ed exit_*() stabiliscono come:

Entrata Uscita Effetto
enter_appear() exit_disappear() compare/scompare di colpo (default)
enter_fade() exit_fade() dissolvenza
enter_grow() exit_shrink() crescita/riduzione della dimensione
enter_fly() exit_fly() arrivo/uscita da un punto indicato con x_loc e y_loc
enter_drift() exit_drift() scivolamento laterale
# per evitare che i punti "viaggino" da un continente all'altro bisogna
# dire a gganimate quali righe sono lo stesso oggetto: l'estetica group.
# Con group = country ogni paese è un oggetto a sé, presente in un solo
# stato: entra quando arriva il suo continente ed esce quando se ne va.
# enter_*() ed exit_*() definiscono COME entra ed esce:
#   enter_fade() / exit_fade()        dissolvenza
#   enter_grow() / exit_shrink()      crescita / riduzione della dimensione
#   enter_fly(x_loc, y_loc) / exit_fly()  arrivo / uscita da un punto
#   enter_drift() / exit_drift()      scivolamento laterale
p_states_paesi <- ggplot(gap_2007, aes(x = gdp_per_cap, y = life_exp,
                                       group = country)) +
  geom_point(aes(color = continent), size = 3, show.legend = FALSE) +
  scale_x_log10() +
  labs(title = "Continente: {closest_state}") +
  transition_states(continent, transition_length = 1, state_length = 3)
p_states_paesi                # senza enter/exit: compaiono e spariscono
p_states_paesi +
  enter_fade() +
  exit_shrink()

Senza enter ed exit: i paesi compaiono e scompaiono di colpo

Con enter_fade() ed exit_shrink()

L’andatura del movimento

La funzione ease_aes() stabilisce l’andatura (in inglese easing) con cui gli elementi si spostano tra due fotogrammi. Il default è "linear", cioè una velocità costante. Le altre andature combinano una curva (quadratic, cubic, quartic, quintic, sine, circular, exponential, elastic, back, bounce) con un suffisso che indica dove agisce l’accelerazione: -in (all’inizio), -out (alla fine) o -in-out (all’inizio e alla fine).

# ease_aes() stabilisce l'andatura del movimento tra due fotogrammi:
#   "linear"        velocità costante (default)
#   "cubic-in-out"  parte piano, accelera, rallenta all'arrivo
#   "elastic-out"   arriva "rimbalzando"
#   "bounce-out"    arriva "saltellando"
# provate a cambiare il valore e a osservare le barre
p_barre +
  ease_aes("cubic-in-out")
p_barre +
  ease_aes("elastic-out")

ease_aes(“cubic-in-out”)

ease_aes(“elastic-out”)

transition_time(): una variabile temporale

transition_time() usa una variabile numerica che rappresenta il tempo. A differenza di transition_states(), non ci sono pause sugli stati e la durata di ciascun passaggio è proporzionale alla distanza nel tempo: se gli anni non fossero equidistanti, il passaggio tra due anni lontani durerebbe di più. Nel titolo si usa la variabile {frame_time}:

# transition_time() usa una variabile numerica che rappresenta il tempo:
# a differenza di transition_states() non ci sono pause, e la durata dei
# passaggi è proporzionale alla distanza nel tempo
# nel titolo si usa {frame_time}: il tempo del fotogramma corrente
p_time <- ggplot(gap_continenti, aes(x = gdp_per_cap, y = life_exp,
                                     color = continent)) +
  geom_point(size = 4) +
  scale_x_log10() +
  labs(title = "Anno: {frame_time}") +
  transition_time(year)
p_time
# il titolo mostra anni con i decimali (es. 1953.27): sono i fotogrammi
# intermedi. Si arrotonda con una funzione R dentro le graffe:
p_time +
  labs(title = "Anno: {round(frame_time)}")

Con {frame_time}: l’anno ha i decimali

Con {round(frame_time)}
NotePerché l’anno ha i decimali?

I fotogrammi sono 100 per default, mentre gli anni sono 12: la maggior parte dei fotogrammi rappresenta un momento intermedio tra due anni, e {frame_time} vale ad esempio 1953.27. Per mostrare un anno intero basta arrotondare con {round(frame_time)}.

Le ombre: la memoria dei fotogrammi passati

Per default ogni fotogramma mostra solo i dati del momento corrente. Le funzioni shadow_*() lasciano sul grafico una traccia dei fotogrammi precedenti, utile per seguire la traiettoria degli elementi:

  • shadow_wake() lascia una scia che si dissolve progressivamente; wake_length ne stabilisce la lunghezza (come frazione dell’animazione)
  • shadow_trail() lascia una serie di “impronte” a intervalli regolari, stabiliti da distance
  • shadow_mark() lascia sul grafico tutti i fotogrammi già mostrati
# le funzioni shadow_*() lasciano una traccia dei fotogrammi precedenti
#   shadow_wake()   una scia che si dissolve (wake_length = lunghezza)
#   shadow_trail()  una serie di "impronte" a intervalli regolari
#   shadow_mark()   lascia sul grafico tutti i fotogrammi già mostrati
p_time_tit <- p_time +
  labs(title = "Anno: {round(frame_time)}")
p_time_tit +
  shadow_wake(wake_length = 0.2)
p_time_tit +
  shadow_trail(distance = 0.1, alpha = 0.3)
p_time_tit +
  shadow_mark(alpha = 0.3, size = 1.5)

shadow_wake()

shadow_trail()

shadow_mark()

transition_reveal(): le serie storiche

transition_reveal() “scopre” progressivamente i dati lungo una variabile, tipicamente il tempo, lasciando visibile il tratto già percorso: è l’animazione naturale per un grafico a linee. Si noti la differenza tra i due layer: geom_line() mostra il percorso fino al momento corrente, geom_point() solo il punto corrente. Nel titolo si usa la variabile {frame_along}.

# transition_reveal() "scopre" i dati progressivamente lungo una variabile
# (tipicamente il tempo), lasciando visibile il tratto già percorso:
# è l'animazione naturale per un grafico a linee.
# nel titolo si usa {frame_along}
paesi <- c("Italy", "China", "India", "Brazil", "Nigeria")
p_reveal <- gap_data |>
  filter(country %in% paesi) |>
  ggplot(aes(x = year, y = life_exp, color = country)) +
  geom_line(linewidth = 1) +
  geom_point(size = 3) +
  labs(title = "Aspettativa di vita fino al {frame_along}",
       x = NULL, y = "Anni", color = NULL) +
  transition_reveal(year)
p_reveal
# NOTA: geom_point mostra solo il punto corrente, geom_line l'intero
#       percorso fino a quel momento

Gli assi che si adattano: view_follow()

Per default gli assi restano fissi per tutta l’animazione, e coprono l’intervallo di tutti i dati. view_follow() adatta invece gli assi ai dati del fotogramma corrente, come una telecamera che insegue i punti. Con fixed_x = TRUE o fixed_y = TRUE uno dei due assi resta fisso.

# view_follow() adatta gli assi ai dati del fotogramma corrente: gli
# assi "inseguono" i punti, come una telecamera.
# fixed_x = TRUE (o fixed_y = TRUE) lascia fisso uno dei due assi
p_time_tit +
  view_follow()
p_time_tit +
  view_follow(fixed_y = TRUE)

view_follow()

view_follow(fixed_y = TRUE)
WarningAttenzione alle scale che cambiano

Con assi che cambiano a ogni fotogramma il grafico è più “dinamico”, ma il lettore perde i punti di riferimento: un punto può sembrare fermo anche se il suo valore cambia. Gli assi mobili sono utili quando i valori cambiano di ordine di grandezza nel tempo; negli altri casi conviene lasciarli fissi.

La grammatica in diretta: transition_layers()

transition_layers() mostra i layer del grafico uno alla volta, nell’ordine in cui sono stati aggiunti. Oltre a essere utile in una presentazione, è un modo per vedere all’opera la grammatica di ggplot2: il grafico finale è la sovrapposizione di strati indipendenti.

# transition_layers() mostra i layer uno dopo l'altro, nell'ordine in cui
# sono stati aggiunti: un modo per vedere la costruzione del grafico
ggplot(gap_2007, aes(x = gdp_per_cap, y = life_exp)) +
  geom_point(aes(color = continent)) +
  geom_smooth(color = "black", se = FALSE) +
  geom_text(data = filter(gap_2007, country %in% paesi),
            aes(label = country), vjust = -1) +
  scale_x_log10() +
  transition_layers(layer_length = 1, transition_length = 1) +
  enter_grow()
# NOTA: con geom_smooth() la combinazione con enter_fade() restituisce un
#       errore (Can't combine `..1$fill`...): usiamo enter_grow()

Controllare e salvare l’animazione

Stampare un oggetto gganim equivale a chiamare la funzione animate() con i valori di default. Usando direttamente animate() possiamo controllare il risultato:

Argomento Significato
nframes numero di fotogrammi (default 100)
fps fotogrammi al secondo (default 10): la durata è nframes / fps secondi
duration in alternativa a fps, la durata in secondi
width, height, res dimensioni in pixel e risoluzione
start_pause, end_pause numero di fotogrammi di pausa all’inizio e alla fine
rewind se TRUE, alla fine l’animazione torna indietro
renderer il formato del risultato: gifski_renderer() per le gif (default), av_renderer() per i video

Più fotogrammi rendono l’animazione più fluida, ma richiedono più tempo di calcolo e producono file più grandi.

TipLe dimensioni in un documento Quarto

Gli argomenti width e height di animate() indicano le dimensioni in pixel e vanno usati quando si lavora nella console o in uno script. In un documento Quarto, invece, le dimensioni dell’animazione si indicano con le opzioni del chunk (#| fig-width, #| fig-height e #| dpi), come per i grafici statici: indicarle anche nella chiamata ad animate() può bloccare la compilazione del documento.

# stampare un oggetto gganim equivale a chiamare animate() con i valori
# di default. animate() permette di controllare:
#   nframes    numero di fotogrammi (default 100)
#   fps        fotogrammi al secondo (default 10): durata = nframes / fps
#   duration   in alternativa: durata in secondi
#   width, height, res   dimensioni (in pixel) e risoluzione
#   start_pause, end_pause   fotogrammi di pausa all'inizio e alla fine
#   rewind     TRUE: alla fine l'animazione torna indietro
#   renderer   il formato di uscita: gifski_renderer() (gif),
#              av_renderer() (video mp4)
animate(p_time_tit, nframes = 120, fps = 20,
        width = 700, height = 450, end_pause = 20)

La funzione anim_save() salva in un file l’ultima animazione mostrata, oppure quella indicata con l’argomento animation. Il formato del file dipende dal renderer usato per produrre l’animazione:

# anim_save() salva l'ultima animazione mostrata (o quella indicata
# con l'argomento animation) nella cartella del progetto
anim_save("continenti.gif")
# per un video: si sceglie il renderer av_renderer() in animate()
video <- animate(p_time_tit, nframes = 120, fps = 20,
                 renderer = av_renderer())
anim_save("continenti.mp4", animation = video)

Il grafico di Gapminder, passo passo

Mettiamo insieme quanto visto per ricostruire il diagramma a bolle della presentazione di Hans Rosling. Come in ogni grafico ggplot2 conviene procedere per piccoli passi, controllando il risultato di ciascuno.

Passo 1: il diagramma di dispersione di un anno

Partiamo dal grafico statico più semplice: il PIL pro capite e l’aspettativa di vita dei paesi nel 2007.

# PASSO 1: il grafico di dispersione di un solo anno
ggplot(gap_2007, aes(x = gdp_per_cap, y = life_exp)) +
  geom_point()

Passo 2: la scala logaritmica

La distribuzione del PIL pro capite è molto asimmetrica: pochi paesi hanno valori molto alti e la maggior parte dei punti è schiacciata a sinistra. Con la scala logaritmica sull’asse x la nuvola di punti si “apre” e la relazione tra le due variabili diventa quasi lineare.

# PASSO 2: la scala logaritmica sull'asse x
# il reddito pro capite è molto asimmetrico: in scala logaritmica la
# nuvola di punti si "apre" e la relazione diventa quasi lineare
ggplot(gap_2007, aes(x = gdp_per_cap, y = life_exp)) +
  geom_point() +
  scale_x_log10()

Passo 3: dai punti alle bolle

Associamo la popolazione alla dimensione dei punti e il continente al colore. La funzione scale_size() regola la dimensione minima e massima delle bolle; la trasparenza (alpha) rende visibili le bolle sovrapposte.

# PASSO 3: dai punti alle bolle
# la dimensione rappresenta la popolazione, il colore il continente;
# scale_size() regola la dimensione minima e massima delle bolle,
# alpha rende visibili le bolle sovrapposte
ggplot(gap_2007, aes(x = gdp_per_cap, y = life_exp,
                     size = pop, color = continent)) +
  geom_point(alpha = 0.7) +
  scale_x_log10() +
  scale_size(range = c(2, 15))

Passo 4: tutti gli anni insieme

Se usiamo la tabella completa il grafico diventa illeggibile: ogni paese compare 12 volte. Servirebbe un grafico per ogni anno…

# PASSO 4: tutti gli anni insieme. Il grafico è illeggibile:
# servirebbe un grafico per ogni anno...
ggplot(gap_data, aes(x = gdp_per_cap, y = life_exp,
                     size = pop, color = continent)) +
  geom_point(alpha = 0.7) +
  scale_x_log10() +
  scale_size(range = c(2, 15))

Passo 5: l’animazione

… ed è proprio quello che fa transition_time(year): rispetto al passo 4 basta aggiungere una riga.

# PASSO 5: ... ed è proprio quello che fa transition_time(year)
# basta aggiungere una riga!
p_bolle <- ggplot(gap_data, aes(x = gdp_per_cap, y = life_exp,
                                size = pop, color = continent)) +
  geom_point(alpha = 0.7) +
  scale_x_log10() +
  scale_size(range = c(2, 15)) +
  transition_time(year)
p_bolle

Passo 6: l’anno nel titolo e il movimento

Aggiungiamo l’anno nel titolo e un movimento a velocità costante.

# PASSO 6: l'anno nel titolo e un movimento a velocità costante
p_bolle <- p_bolle +
  labs(title = "Anno: {round(frame_time)}") +
  ease_aes("linear")
p_bolle

Passo 7: l’anno sullo sfondo

Nella presentazione di Rosling l’anno compare in grande sullo sfondo. Lo aggiungiamo come un nuovo layer di testo, con tre accorgimenti:

  • il layer viene aggiunto prima di geom_point(), così il testo resta sotto le bolle
  • usa una tabella propria, anni, con una sola riga per anno, e non eredita le estetiche del grafico (inherit.aes = FALSE)
  • l’anno è trasformato in testo con as.character(): un numero verrebbe interpolato tra un anno e l’altro, e comparirebbero valori come 1984.5
# PASSO 7: l'anno sullo sfondo, come nella presentazione di Rosling.
# Il testo è un nuovo layer, disegnato PRIMA dei punti, quindi sotto le
# bolle; usa una tabella con una riga per anno e non eredita le
# estetiche del grafico (inherit.aes = FALSE)
# il testo deve essere di tipo character: un numero verrebbe interpolato
# tra un anno e l'altro (comparirebbe, ad esempio, 1984.5)
anni <- gap_data |>
  distinct(year) |>
  mutate(etichetta = as.character(year))
p_bolle <- ggplot(gap_data, aes(x = gdp_per_cap, y = life_exp,
                                size = pop, color = continent)) +
  geom_text(data = anni, aes(x = 3000, y = 55, label = etichetta),
            inherit.aes = FALSE, size = 40, color = "grey90") +
  geom_point(alpha = 0.7) +
  scale_x_log10() +
  scale_size(range = c(2, 15)) +
  transition_time(year) +
  ease_aes("linear")
p_bolle

Passo 8: alcuni paesi da seguire

Per seguire alcuni paesi durante l’animazione aggiungiamo il loro nome con un altro layer di testo, che usa solo le righe dei paesi scelti. Osservate in particolare il Ruanda: nel 1992 l’aspettativa di vita crolla a 23.6 anni, per effetto della guerra civile e del genocidio dei primi anni ’90.

# PASSO 8: il nome di alcuni paesi, per seguirli durante l'animazione
# (anche questo è un layer con dati propri: solo i paesi scelti)
paesi <- c("Italy", "China", "India", "Nigeria", "Rwanda")
# osservate il Ruanda: nel 1992 l'aspettativa di vita crolla a 23.6 anni
# (guerra civile e genocidio dei primi anni '90)
p_bolle <- p_bolle +
  geom_text(data = filter(gap_data, country %in% paesi),
            aes(label = country), size = 4, color = "black",
            vjust = -1.5, show.legend = FALSE)
p_bolle

Passo 9: le rifiniture

Sistemiamo le etichette dell’asse x (in dollari, con la funzione label_dollar() del package scales), eliminiamo la legenda della dimensione, aggiungiamo titoli in italiano e un tema più pulito. R avvisa che stiamo sostituendo le scale x e size già presenti: è proprio quello che vogliamo.

# PASSO 9: le rifiniture
#   - etichette leggibili sugli assi (scales::label_dollar)
#   - nessuna legenda per la dimensione (guide = "none")
#   - titoli in italiano e un tema pulito
p_bolle <- p_bolle +
  scale_x_log10(labels = scales::label_dollar()) +
  scale_size(range = c(2, 15), guide = "none") +
  labs(title = "Ricchezza e salute delle nazioni",
       x = "PIL pro capite (dollari, scala logaritmica)",
       y = "Aspettativa di vita (anni)",
       color = "Continente",
       caption = "Fonte: gapminder.org") +
  theme_minimal(base_size = 14)
p_bolle
# NOTA: R avvisa che stiamo sostituendo le scale x e size già presenti
#       ("Scale for x is already present"): è quello che vogliamo

Passo 10: un’animazione più fluida e il salvataggio

Infine aumentiamo il numero di fotogrammi, aggiungiamo una pausa finale e salviamo il risultato in un file gif.

# PASSO 10: animazione più fluida e salvataggio
animate(p_bolle, nframes = 240, fps = 20,
        width = 900, height = 600, end_pause = 30)
anim_save("gapminder_bolle.gif")

Per chi vuole fare di più

Il package gapminder contiene anche il vettore country_colors, che assegna a ogni paese un colore, con tonalità diverse per i diversi continenti. Combinandolo con un pannello per continente otteniamo l’animazione presentata nella documentazione di gganimate:

# BONUS: un pannello per continente, con i colori "ufficiali" dei paesi
# (il vettore country_colors è contenuto nel package gapminder)
ggplot(gap_data, aes(x = gdp_per_cap, y = life_exp,
                     size = pop, color = country)) +
  geom_point(alpha = 0.7, show.legend = FALSE) +
  scale_color_manual(values = country_colors) +
  scale_size(range = c(2, 12)) +
  scale_x_log10() +
  facet_wrap(~ continent) +
  labs(title = "Anno: {round(frame_time)}",
       x = "PIL pro capite", y = "Aspettativa di vita") +
  transition_time(year) +
  ease_aes("linear")

Un consiglio: quando animare un grafico

Un’animazione attira l’attenzione ed è molto efficace in una presentazione, quando qualcuno ne guida la lettura. Ha però dei limiti: chi la guarda non può confrontare facilmente due momenti lontani nel tempo, e su un documento stampato non funziona. Prima di animare un grafico conviene chiedersi se un grafico statico ben costruito, ad esempio con un pannello per ciascun momento (i facet), non comunichi lo stesso messaggio in modo più semplice.

Per approfondire