Itzulpena: Tidytuesday 2020-03-03
Animar capas de anotaciones con gganimate
Introducción
Hace unos días publiqué un post para el tidytuesday (2020-03-03) con datos de la NHL.
El gráfico muestra el porcentaje de los goles obtenidos por un determinado jugador en toda su carrera con respecto al total de los goles que se metieron en la liga.
Una vez revisado el gráfico, observamos lo siguiente:
- Los jugadores con un mayot porcentaje de goles sobre el total se retiraron antes del cambio de milenio.
- El siguiente grupo de jugadores con mejor porcentaje se retiró antes de 2007.
- Los jugadores comenzaron su carrera a partir de los noventa tienen un porcentaje a sus predecesores.
- Ovechkin, último jugador en superar los 700 goles, y aún en activo, mejora el porcentaje, aunque sigue por debajo del segundo grupo.
No tenemos que olvidar que el indicador que estamos utilizando no tiene por qué coincidir con el número de goles obtenidos por cada jugador; si nos fijamos en el segundo grupo (jugadores con un porcentaje entre el 7% y el 8%) solo uno de los jugadores obtuvo más de 700 goles.
Para facilitar la comunicación de estas observaciones, planteamos crear una pequeña animación que muestre los distintos grupos de jugadores observados uno a uno, para lo que usaremos el paquete gganimate
.
Ésta la primera vez que uso este paquete más allá replicar ejemplos concretos, por lo que he tenido que realizar varios intentos antes de conseguir el resultado deseado. En este post recogeré mis conclusiones principales y la forma en la que he conseguido generar la animación deseada con gganimate
.
Sobre gganimate
gganimate
extends the grammar of graphics as implemented byggplot2
to include the description of animation. It does this by providing a range of new grammar classes that can be added to the plot object in order to customise how it should change with time.
Después de varias iteraciones me ha quedado claro que gganimate
es una herramienta mejor preparada para la fase EDA que para comunicar las conclusiones obtenidas a partir de ese mismo análisis inicial.
Resumiendo mucho, gganimate
saca una serie de fotos de los datos, y crea la animación entre dichas fotos. Para sacar cada una de las fotos, se basa en los datos existentes, usando funciones transition_*()
. En concreto, he trabajado con dos de estas funciones:
transition_states()
: crea las fotos a partir de una variable. En nuestro ejemplo, se trataría de una variable inexistente en el dataset original, ya que las agrupaciones que hemos mencionado en la introducción vienen dadas del análisis de los datos. Por eso, para poder usar este filtro primero tendríamos que añadir una nueva variable computada al conjunto de datos inicial (se npuede hacer fácilmente con las funcionesmutate()
ycase_when()
).transition_filter()
: crea las fotos a partir de distintos filtros. Finalmente he optado por esta opción, ya que me permite no tener que transformar los datos originales (en cualquier caso, los filtros que vamos a utilizar son los mismos que usaríamos con la funcióncase_when()
mencionada en el punto anterior).
Para este ejemplo no vamos a utilizar las funciones más vistosas de la animación (enter_*()
y exit_*()
), ya que simplemente queremos que las nuevas capas de datos vayan apareciendo y añadiéndose a las anteriores.
Preparar los datos
# Cargar librerías
library(tidyverse)
library(gganimate)
A diferencia del ejemplo anterior, en este ejemplo vamos a utizar geom_segment()
para dibujar la duración de la carrera de cada jugador. La estructura de datos que necesitaremos es más simple, ya que no hace falta pivotar los datos para poder dibujar geom_segment()
, ya que disponemos de los datos para marcar el inicio y el final de cada línea.
# Obtener los datos
datos_carrera_top_ten_anim <- datos %>%
group_by(player) %>%
summarise(inicio = as.integer(min(season)),
final = as.integer(max(season)),
goals = sum(goals_player),
career_goals_all = sum(season_goals),
player_ratio_career = goals / career_goals_all) %>%
mutate(estado = case_when(
as.integer(final) == lubridate::year(Sys.Date()) ~ "En activo",
TRUE ~ "Retirado"),
grosor = case_when(
estado == "Retirado" ~ 1,
estado == "En activo" ~ 2,
TRUE ~ 0),
texto = paste0(player," (",goals,"/",career_goals_all,")")
) %>%
top_n(10, goals)
datos_carrera_top_ten_anim
## # A tibble: 10 x 9
## player inicio final goals career_goals_all player_ratio_ca~ estado grosor
## <chr> <int> <int> <dbl> <dbl> <dbl> <chr> <dbl>
## 1 Alex ~ 2006 2020 701 10668 0.0657 Retir~ 1
## 2 Brett~ 1987 2006 741 8621 0.0860 Retir~ 1
## 3 Jarom~ 1991 2018 766 13222 0.0579 Retir~ 1
## 4 Luc R~ 1987 2006 668 8621 0.0775 Retir~ 1
## 5 Mario~ 1985 2006 690 8001 0.0862 Retir~ 1
## 6 Mark ~ 1980 2004 694 9759 0.0711 Retir~ 1
## 7 Mike ~ 1980 1998 708 7432 0.0953 Retir~ 1
## 8 Steve~ 1984 2006 692 9620 0.0719 Retir~ 1
## 9 Teemu~ 1993 2014 684 11619 0.0589 Retir~ 1
## 10 Wayne~ 1980 1999 894 7803 0.115 Retir~ 1
## # ... with 1 more variable: texto <chr>
Visualización de base
Recreamos el código del post anterior, con las modificaciones pertinentes para poder dibujar los segmentos (en lugar de las líneas). Además, y para entender mejor los problemas que tendremos luego con gganimate
, separaremos las capas básicas de las capas con anotaciones post-análisis.
Gráfico base
p <- ggplot(datos_carrera_top_ten_anim, aes(inicio, player_ratio_career, group=player, color=estado)) +
# dibujamos las líneas
geom_segment(aes(x = inicio, xend = final, y = player_ratio_career, yend = player_ratio_career, size = fct_rev(as.factor(estado)),
linetype=fct_rev(as_factor(estado)))) +
# dibujamos los corchetes
geom_point(shape=91,
size = 4) +
geom_point(data = filter(datos_carrera_top_ten_anim, final != lubridate::year(Sys.Date())), aes(x = final), shape=93,
size = 4) +
# Mostramos los nombres de los jugadores, con desplazamientos ad hoc para que no se solapen
geom_text(data = filter(datos_carrera_top_ten_anim, !player %in% c(subir, bajar)),
aes(label = texto),
x = lubridate::year(Sys.Date()) + 1, hjust = "left") +
geom_text(data = filter(datos_carrera_top_ten_anim, player %in% bajar),
aes(y = player_ratio_career - 0.0015, label = texto),
x = lubridate::year(Sys.Date()) + 1, hjust = "left") +
geom_text(data = filter(datos_carrera_top_ten_anim, player %in% subir),
aes(y = player_ratio_career + 0.0015, label = texto),
x = lubridate::year(Sys.Date()) + 1, hjust = "left") +
labs(
title = "¿Cuál es la aportación de los máximos goleadores?",
subtitle = "Estamos acostumbrados a ver la lista de los máximos goleadores, pero hace años un número parecido de goles\nen la carrera de un jugador suponía un mayor porcentaje de todos los goles de la liga.\n",
caption = "\n@neregauzak | #tidytuesday | Data: hockey-reference.com"
) +
# Modificamos la escala de color para usar colores personalizados
scale_color_manual(values = c("#4f970c","#707173")) +
# Modificamos la escala de formas para usar corchetes de apertura y cierre con geom_point
scale_shape_manual(values = c(93,91)) +
# Modificamos la escala de tamño para dar más anchura a las líneas de jugadores en activo
scale_size_discrete(range = c(0.2,1.5), breaks = c(1,2)) +
# Modificamos la escala de tamño para dar un estilo de linea discontinua a las líneas de jugadores en activo
scale_linetype_manual(values=c("solid","longdash")) +
# Modificamos los límites de X para poder acomodar los nombres de los jugadores
scale_x_continuous("", limits = c(min(datos_carrera_top_ten_anim$inicio)-1, max(datos_carrera_top_ten_anim$final)+15),
breaks = seq(from = min(datos_carrera_top_ten_anim$inicio), to = max(datos_carrera_top_ten_anim$final), by = 5)
) +
scale_y_continuous("", labels = scales::percent_format()) +
# Eliminamos las leyendas
guides(color = "none", size = "none", linetype = "none", shape ="none") +
#○ Seleccionamos el tema minimal y realizamos un par de modificaciones
theme_minimal() +
theme(panel.border = element_blank(), panel.grid.major = element_blank(),
panel.grid.minor = element_blank(), axis.line = element_line(colour = "black"))
## Warning: Using size for a discrete variable is not advised.
p
Gráfico con anotaciones
p_anotated <- p + geom_vline(xintercept = 2020,
linetype = "longdash", color = "#7c4d25", size = 1) +
geom_label(x = lubridate::year(Sys.Date()),
y = max(datos_carrera_top_ten_anim$player_ratio_career) - 0.01,
hjust = "middle", label ="Última temporada", color ="#7c4d25") +
# Añadimos anotaciones personalizadas para saber cómo interpretar las líneas
## Anotación para un jugador cuya carrera ha terminado
annotate(geom = "text", x = 2000, y = 0.105, size = 3, color = "#3e3d40",
label ="Gretzky jugó entre 1979 y 1999\n y metió 894 goles",
lineheight = 0.9) +
annotate(geom ="curve", x = 1992, y = 0.1075, xend = 1990, yend = 0.1145,
curvature = -0.25, color = "#3e3d40") +
## Anotación para el jugador que aún está en activo
annotate(geom = "text", x = 2015, y = 0.08, size = 3, color = "#4f970c",
label ="Ovechkin es el único\njugador en activo\nde la lista de los\n10 máximos goleadores",
lineheight = 0.9) +
annotate(geom ="curve", x = 2010, y = 0.08, xend = 2007, yend = 0.0657,
curvature = 0.25, color = "#4f970c")
p_anotated
Animación
Como ya se ha mencionado, usaremos la función transition_filter()
para crear los estados que nos interesan. A esta función podemos pasarle una serie de filtros, a los que podemos poner nombres (más adelante veremos la utilidad de estos nombres).
En nuestro caso, tenemos que fijarnos en los años en los que hemos detectado los cortes entre los distintos grupos de jugadores, y usarlos para crear los filtros.
Nos interesa que los nuevos datos vayan acumulándose en el gráfico; en principio, podríamos obtener este resultado añadiendo la función shadow_mark()
despúes de la función de transición , pero en este caso da error.
También podemos usar el argumento keep = TRUE
; el funcionamiento de este argumento no es tan intuitivo como parece a simple vista, recomiendo echar un vistazo en este post explican cómo funciona. Lo que hace keep = TRUE
es mantener todos los elementos en el gráfico
Para obtener el efecto deseado, tenemos que “pisar” los elementos gráficos; para el resultado final cambiaremos el color de todos los elementos a blanco, pero de momento vamos a marcar otro color para ver cómo funciona gganimate
.
anim_filter <- p_anotated + transition_filter(transition_length = 0.5,
filter_length = 5,
` ` = final < 1900,
`Jugadores retirados antes de 2000` = final < 2000,
`Jugadores retirados antes de 2007` = final < 2007,
`Jugadores retirados antes de 2020` = final < 2020,
`Todos los jugadores`= final < 2021,
wrap = TRUE, keep = TRUE) +
exit_manual(function(x) dplyr::mutate(x,colour = "blue"))
anim_filter
- El primer primer fotograma muestra todos los elementos en azul (recordemos que en la versión final se mostrarán todos en blanco, por lo que la percepción será que partimos de un lienzo en blanco).
- El segundo fotograma muestra dos líneas en gris (y sus corchetes y textos), así como la etiqueta de “última temporada”.
- Los fotogramas tercero y cuarto muestran suceesivamente otros conjuntos de líneas en gris.
- El último fotograma muestra la línea del único jugador en activo (verde discontinua).
- La línea vertical y las anotaciones manuales se muestran en todo momento en azul; dicho de otra forma, si hubieramos seleccionado el color blanco, esos elementos no se llegarían a ver en ningún momento.
¿A qué se debe este comportamiento? ¿Por qué se visualiza uno de los elementos contextuales que hemos usado (la etiqueta de “última temporada”) y no el resto?
Si nos fijamos en cómo están definidos los distintos elementos que hemos usado para crear las anotaciones observamos que:
- Dos de los elementos están creados con funciones
geom
; estás funciones están vinculadas a un conjunto de datos y sus variables. Uno de estos elementos (etiqueta “última temporada”) entra correctamente en la animación (es decir, cambia de color en el segundo fotograma, por lo que pasa a ser visible). El otro (línea vertical) no cambia de color (azul), por lo que en la versión final resultaría invisible. - Los otros cuatro elementos están creados con funciones
annotate
, que son independientes de los conjuntos de datos. Ninguno de estos elementos entra correctamente en la animación (queremos que aparezca uno en el segundo fotograma -junto a las primeras líneas- y otro con el último fotograma).
¿Qué diferencia los dos elementos geom
, de tal forma que gganimate
sólo tiene en cuenta uno de ellos a la hora de generar la animación? Para dar con la respuesta tenemos que fijarnos en los aes
obligatorios que se han aplicado a los distintos geoms
. geom_vline()
es uno de los pocos geoms
que no tienen que indicar el canal x
obligatoriamente. Tras realizar pruebas con varios geoms
, se ve que aquellos que tienen que que indicar x
obligatoriamente (o lo heredan de las asignaciones realizadas en ggplot()
) entran en las animaciones de gganimate
, mientras que resto no.
Por otra parte, ¿qué criterio ha seguido gganimate
para decidir en qué fotograma aplicar el cambio de color? No hemos de olvidar que los filtros en juego solo tienen en cuenta la variable final
, y que la capa geom_label()
carece de dicho dato. De la página de referencia de transition_filter()
:
If keep = TRUE the rows not matching the conditions of a filter is not removed from the plot after the exit animation
Como hemos usado keep = TRUE
y no se puede aplicar ninguno de los filtros a geom_label()
(ya que carecemos de un valor para la variable final
), gganimate
muestra en todo momento la etiqueta (que, en este caso, es el efecto que buscamos). Sin embargo, nos quedan un par de detalles por configurar:
- Necesitamos que
gganimate
muestre la línea de referencia junto a la etiquetaÚltima temporada
desde el principio. - También queremos que se muestren las anotaciones manuales, y además en distintos fotogramas.
Preparación de los datos para las capas de anotaciones
Como ya hemos dicho, para que gganimate
tenga en cuenta unas marcas gráficas, tienen que ser de tipo geom
y contener la(s) variable(s) utilizadas a modo de filtros de la animación.
Por lo tanto, vamos a:
- Sustituir las capas
annotate
porgeom_text()
ygeom_segment()
. - Crear un dataset que recogerá las variables del dataset original, un par de variables necesarias para los
geom_segment()
y una variable para poder controlar que filas del nuevo dataset queremos aplicar a cada capa anotación.x
.y
,xend
,yend
,label
,final
,player
,estado
: solo rellenaremos los datos necesarios, al resto les asignaremosNA
. En el caso de la variableplayer
, todas las filas que vamos a crear tendrán asignadoNA
, ya que aunque no lo necesitemos,gganimate
no funcionará correctamente si no detecta la variable en el conjunto de datos.type
: vamos a usar esta variable como un metadato que nos permitirá filtrar las filas del nuevo dataset dependiendo delgeom
con el que estemos trabajando.
El nuevo dataset quedará de la siguiente manera:
## type x y xend yend
## 1 vline 2020 NA NA NA
## 2 label 2020 0.1045713 NA NA
## 3 text 2000 0.1050000 NA NA
## 4 curve 1992 0.1075000 1990 0.1145
## 5 text 2015 0.0800000 NA NA
## 6 curve 2010 0.0800000 2007 0.0657
## label
## 1 <NA>
## 2 Última temporada
## 3 Gretzky jugó entre 1979 y 1999\n y metió 894 goles
## 4 <NA>
## 5 Ovechkin es el único\njugador en activo\nde la lista de los\n10 máximos goleadores
## 6 <NA>
## final player estado
## 1 1900 NA <NA>
## 2 1900 NA <NA>
## 3 1900 NA Retirado
## 4 1900 NA Retirado
## 5 2020 NA En activo
## 6 2020 NA En activo
altura_etiqueta <- max(datos_carrera_top_ten_anim$player_ratio_career) - 0.01
type <- c("vline","label","text","curve","text","curve")
x <- c(2020,2020,2000,1992,2015,2010)
y <- c(NA,altura_etiqueta,0.105,0.1075,0.08,0.08)
xend <- c(NA,NA,NA,1990,NA,2007)
yend <- c(NA,NA,NA,0.1145,NA,0.0657)
label<- c(NA,"Última temporada","Gretzky jugó entre 1979 y 1999\n y metió 894 goles",NA,"Ovechkin es el único\njugador en activo\nde la lista de los\n10 máximos goleadores",NA)
final <- c(1900,1900,1900,1900,2020,2020)
player <- c(NA, NA, NA, NA, NA, NA)
estado <- c(NA,NA,"Retirado","Retirado","En activo","En activo")
annotation_data <- data.frame(type = type,
x = x,
y = y,
xend = xend,
yend = yend,
label = label,
final = final,
player = player,
estado = estado)
Reconfigurar los elementos de las capas con anotaciones
Una vez creado el conjunto de datos, podemos rehacer las capas con las anotaciones. Ahora que tenemos los datos en un data.frame
no es necesario crear una capa para cada anotación de texto o línea conectora; podemos mapear los datos a los canales gráficos aes
correspondientes:
p_annotated <- p + geom_vline(data = filter(annotation_data, type == "vline"), aes(xintercept = x),
linetype = "longdash", color = "#7c4d25", size = 1) +
geom_label(data = filter(annotation_data, type == "label"),
aes(x = x, y = y, label = label),
color ="#7c4d25", hjust = "middle") +
geom_text(data = filter(annotation_data, type == "text"),
aes(x = x, y = y, label = label), hjust = "middle", size = 3, lineheight = 0.9) +
geom_segment(data = filter(annotation_data, type == "curve"),
aes(x = x, y = y, xend = xend, yend = yend), arrow = arrow(ends = "first"))
Hemos realizado otro pequeño cambio, pasando de conectores curvos a rectos; esto se debe a que el argumento curve
de geom_curve()
no puede pasarse a través de la función aes()
, por lo que solo podemos pasar un único valor a las dos curvas. En este caso tenemos una de signo positivo y otra de signo negativo, por lo que una de las dos no quedaría bien (NOTA: también podríamos haber diferenciado en el dataset las dos curvas para usar el mismo truco, por ejemplo type = [curva_pos|curva_neg]
).
Generar la animación
Ahora ya podemos obtener la animación tal y como la queríamos:
anim_filter <- p_annotated + transition_filter(transition_length = 0.5,
filter_length = 5,
` ` = final < 1900,
`Jugadores retirados antes de 2000` = final < 2000,
`Jugadores retirados antes de 2007` = final < 2007,
`Jugadores retirados antes de 2020` = final < 2020,
`Todos los jugadores`= final < 2021,
wrap = TRUE, keep = TRUE) +
exit_manual(function(x) dplyr::mutate(x,colour = "white"))
animate(anim_filter, width = 800, height = 600)
Utilizar variables de etiquetas generadas por gganimate
Finalmente, añadiremos un nuevo elemento en la capa de anotaciones que nos permitirá saber qué datos estamos viendo en cada momento. Cada transición de gganimate
genera una serie de etiquetas que podemos visualizar en el gráfico:
It can be quite hard to understand an animation without any indication as to what each time point relates to. gganimate solves this by providing a set of variables for each frame, which can be inserted into plot labels using glue syntax.
En este enlace podemos ver las variables de etiqueta que podemos usar con transition_filter()
.
Estas etiquetas pueden utilizarse en los elementos title
, subtitle
, caption
y tag
, y pueden concatenarse a textos fijos. En este ejemplo, ya hemos hecho uso de title
, subtitle
, y caption
y no tenemos intención de modificarlos, por lo que insertaremos las etiquetas en el elemento tag
.
Por otra parte, el posicionamiento por defecto de este elemento es arriba a la izquierda, antes del título. A fin de que se integre mejor con los datos, vamos a cambiar la posición de la etiqueta.
anim_filter <- p_annotated + transition_filter(transition_length = 0.5,
filter_length = 5,
` ` = final < 1900,
`Jugadores retirados antes de 2000` = final < 2000,
`Jugadores retirados antes de 2007` = final < 2007,
`Jugadores retirados antes de 2020` = final < 2020,
`Todos los jugadores`= final < 2021,
wrap = TRUE, keep = TRUE) +
labs(tag = "{closest_filter}") +
theme(plot.tag.position = c(0.075,0.1),
plot.tag = element_text(face = "bold", size = 15, color = "#7c4d25", hjust = 0)) +
exit_manual(function(x) dplyr::mutate(x,colour = "white"))
animate(anim_filter, width = 800, height = 600)