Il n’est pas toujours facile de donner du sens à une valeur numérique, notamment lorsque celle-ci est soit très grande, soit très petite. En dehors d’un spectre restreint, nous sommes rapidement confronté à un manque de référence. Il nous est difficile, voir impossible, d’avoir une image mentale du nombre considéré. Aussi, pour palier à cette difficulté, on recourt à la comparaison. Il s’agit dans un graphe d’associer la valeur à un marqueur visuel et d’offrir des marqueurs de même forme mais de tailles différentes pour d’autres valeurs qui vont servir de points de références. Il est alors nécessaire de choisir des éléments qui font eux sens soit au regard de la valeur illustrée, soit en eux-même pour le lecteur voir les deux.

Dans ce nouveau post, nous nous intéressons à l’utilisation des bulles pour porter ces comparaisons, que ces bulles soient juxtaposées ou intriquées. D’autres formes peuvent bien sûr être mobilisées (carrés, rectangles…). L’idée est ici de faire varier l’aire des figures en fonction des valeurs. Pour illustrer notre propos, nous travaillerons sur les morts du COVID 19 en France depuis le début de l’épidémie en comparant leur nombre à celui de la population d’une série de villes. Evidemment, cette présentation est par essence critiquable. On devrait surement se focaliser sur la surmortalité plutôt que sur le nombre brute des décès. De plus, comparer un cumul sur 2-3 ans avec un stock de population n’est pas ce qui est le plus rigoureux. Néanmoins, l’objectif ici est juste rendre plus concret un ordre de grandeur. Il reviendra alors au lecteur intrigué d’approfondir les choses.

Commençons donc par établir une data frame contenant les données. Celles-ci ont été collectées manuellement à partir des sites de Santé Publique France et de l’INSEE. A la date où j’écris ce post (le 4 mai 2022) le nombre de morts du COVID en France depuis le début de l’épidémie est de 146 376. Concernant les villes retenues, nous avons la plus grandes Paris 2 175 601 habitants, la seconde en terme de taille Marseille 868 277, une grande ville Lille 233 098 et deux villes moyennes Clermont-Ferrand 146 734 et Poitiers 88 665. Marquons l’observation correspondant à la valeur que nous voulons illustrer.

dat<-data.frame(cat=c('Décés','Paris','Marseille','Lille',
                      'Clermont-Ferrand','Poitiers'),
                nb=c(146376,2175601,868277,233098,146734,88665),
                ref=c('1','0','0','0','0','0'))
dat
##                cat      nb ref
## 1            Décés  146376   1
## 2            Paris 2175601   0
## 3        Marseille  868277   0
## 4            Lille  233098   0
## 5 Clermont-Ferrand  146734   0
## 6         Poitiers   88665   0

Ceci étant fait, chargeons les packages que nous utiliserons pour réaliser nos graphes.

library(tidyverse)
library(ggtext)
library(showtext)
library(formattable)

Le plus simple lorsque l’on envisage un bubble comparaison chart juxtaposé, c’est d’utiliser le geom_point() et de faire jouer l’esthétique size pour s’assurer de la variation des tailles des cercles représentant les valeurs. Réalisons un graphe de base.

ggplot(data=dat,aes(x=cat,y=1,size=nb,color=ref))+
  geom_point()

Le résultat est loin d’être convainquant. Voyons comment améliorer les choses. Commençons par revoir la taille du graphe. Disons 11 de longueur pour 4 de largeur. Puis, travaillons l’échelle de taille des cercles avec scale_size_area(). Enfin, supprimons la légende.

ggplot(data=dat,aes(x=cat,y=1,size=nb,color=ref))+
  geom_point()+
  scale_size_area(max_size=85)+
  theme(legend.position='none')

C’est mieux, mais on peut faire encore mieux. Intégrons un texte dans chaque cercle pour indiquer le nom de la catégorie et le nombre représenté. Créons l’étiquette correspondant.

dat<-dat %>% mutate(lab=paste0(cat,'<br>',comma(nb,digits=0,big.mark = ' ')))

Créons le graphe.

ggplot(data=dat,aes(x=cat,y=1,size=nb,color=ref))+
  geom_point()+
  geom_richtext(aes(label=lab),hjust=0.5,vjust=0.5)+
  scale_size_area(max_size=85)+
  theme(legend.position='none')

Revoyons la taille des caractères. Assurons-nous qu’elle soit proportionnelle à celle des cercles pour s’y intégrer. Profitons-en pour supprimer les éléments du thème (axes, grille, textes associés…) en utilisant le theme_void().

dat<-dat %>% mutate(tail=c(3,10,6,4,2.5,3))
ggplot(data=dat,aes(x=cat,y=1,size=nb,color=ref))+
  geom_point()+
  geom_richtext(aes(label=lab),hjust=0.5,vjust=0.5,
                size=dat$tail)+
  scale_size_area(max_size=85)+
  theme_void()+
  theme(legend.position='none')

Réordonnons les bulles dans l’ordre des valeurs (x) de manière ce qu’elles apparaissent toute clairement. Profitons-en pour nous assurer que Clermont-Ferrand soit écrit sur deux lignes et ajuster la fenêtre du graphe.

dat<-dat %>% mutate(pos=c(0.15,1,0.65,0.45,0.3,0.05))
dat$lab[which(dat$cat=='Clermont-Ferrand')]<-'Clermont-<br>Ferrand <br> 146 734' 
ggplot(data=dat,aes(x=pos,y=1,size=nb,color=ref))+
  geom_point()+
  geom_richtext(aes(label=lab),hjust=0.5,vjust=0.5,size=dat$tail)+
  scale_size_area(max_size=85)+
  coord_cartesian(xlim=c(0,1.15))+
  theme_void()+
  theme(legend.position='none')

Supprimons la mise en forme de type étiquette. Passons les caractères en blanc et modifions le geom_point() pour avoir des ronds avec un liseré noire.

ggplot(data=dat,aes(x=pos,y=1,size=nb,fill=ref))+
  geom_point(shape=21,color='black')+
  geom_richtext(aes(label=lab),hjust=0.5,vjust=0.5,size=dat$tail,
                color='white',fontface='bold',
                fill = NA, label.color = NA)+
  scale_size_area(max_size=85)+
  coord_cartesian(xlim=c(0,1.15))+
  theme_void()+
  theme(legend.position='none')

Ajoutons des guides pour faciliter les comparaisons visuelles ainsi qu’un titre, un sous-titre et un caption pour compléter l’ensemble.

ggplot(data=dat,aes(x=pos,y=1,size=nb,fill=ref))+
  geom_point(shape=21,color='black')+
  geom_richtext(aes(label=lab),
            size=dat$tail,hjust=0.5,vjust=0.5,
            color='white',fontface='bold',
            fill = NA, label.color = NA)+
  labs(title="Le nombre de morts du COVID 19 en France (au 5 mai 2022) est équivalant de la population de Clermont-Ferrand",
       subtitle = "(décés depuis le début de l'épidémie vs. recenncement 2018)",
       caption = "Source: Santé Publique France et INSEE")+
  scale_size_area(max_size=85)+
  coord_cartesian(xlim=c(0,1.15))+
  theme_void()+
  theme(plot.title = element_text(hjust=0.5,face='bold'),
        plot.subtitle = element_text(hjust=0.5,face='italic'),
        plot.caption = element_text(hjust=1,face='italic'),
        axis.title=element_blank(),
        panel.grid.major.y = element_line(color='black'),
        panel.background = element_rect(colour = 'black'),
        legend.position = 'none')

On obtient un graphe qui tient finalement la route. Voyons maintenant comment faire pour obtenir des bulles intriquées. Ici,le geom_point() est difficilement mobilisable. La solution qui m’apparaît la plus simple est d’utiliser le geom_polygon() et quelques souvenirs de trigonométrie qui datent du lycée.

Il s’agit de créer la figure en signant les coordonnées d’une série de points (x et y), qui, reliés, forment le périmètre de la figure. Illustrons son utilisation à partir d’un quadrilatère quelconque.

df <- data.frame(x = c(1:3,2,1), y = c(4, 1, 9,7.5,4))
ggplot(df, aes(x, y))+
  geom_polygon()

Notre objectif est de faire de même mais avec un rond. Autrement-dit, il va falloir déterminer les coordonnées de suffisamment de points pour s’assurer que la figure y ressemble. Pour cela, nous utiliserons les relations suivantes qui assurent que des points soit sur le périmètre d’un cercle centré sur l’origine de repaire (x=0 et y=0). Considérant un angle défini en radians, l’abscisse du point situé sur la périphérie correspondant est égale au rayon du cercle multiplié par le cosinus de l’angle et l’ordonnée est égale au rayon multiplié par le sinus de l’angle.

On a ainsi pour un cercle défini par cents points (des angles allant de 0 à 2 \(\pi\)) et un rayon de 2 unités. Pour rendre les choses plus faciles à visualiser dans cette étape de construction utilisant le geom_path() qui fonctionne de la même manière que le geom_polygon() mais sans colorer l’intérieur de la figure.

angle<-seq(0,2*pi,length.out=100)
rayon<-2
df1<-data.frame(x=c(rayon*cos(angle)),
                y=c(rayon*sin(angle)))
ggplot(df1,aes(x=x,y=y))+
  geom_path()+
  coord_equal()

Ajoutons un second cercle de rayon plus petit. Disons de rayon 1.

angle<-seq(0,2*pi,length.out=100)
rayon1<-2
rayon2<-1
df1<-data.frame(x=c(rayon1*cos(angle)),
                y=c(rayon1*sin(angle)))
df2<-data.frame(x=c(rayon2*cos(angle)),
                y=c(rayon2*sin(angle)))
ggplot(df1,aes(x=x,y=y))+
  geom_path()+
  geom_path(data=df2,aes(x=x,y=y))+
  coord_equal()

Etalonner la taille des cercles en fonction des valeurs n’est pas une bonne idée. Les aires des figures ne sont pas proportionnelles à leur rayon.

air1<-pi*rayon1^2
air2<-pi*rayon2^2
c(air1,air2)
## [1] 12.566371  3.141593

Pour obtenir une aire pour le rond 2 correspondant à la moitié de celle du rond 1, il faut appliquer au rayon du rond 2 un facteur tel que:

\[aire2= \frac{aire1}{2}=(rayon2)^2\times{\pi}\]

\[\frac{aire2}{\pi}=(rayon2)^2\]

\[rayon2=\sqrt{\frac{aire2}{\pi}}\]

air2<-air1/2
sqrt(air2/pi)
## [1] 1.414214

On a ainsi, pour un rayon de 2 pour le rond 1, un rayon de 1.414214 pour le rond 2.

angle<-seq(0,2*pi,length.out=100)
rayon1<-2
rayon2<-1.414214
df1<-data.frame(x=c(rayon1*cos(angle)),
                y=c(rayon1*sin(angle)))
df2<-data.frame(x=c(rayon2*cos(angle)),
                y=c(rayon2*sin(angle)))
ggplot(df1,aes(x=x,y=y))+
  geom_path()+
  geom_path(data=df2,aes(x=x,y=y))+
  coord_equal()

La différence est notable. Maintenant que le problèmes des aires est réglé, décalons le rond sur la droite pour avoir des bulles juxtaposées.

angle<-seq(0,2*pi,length.out=100)
rayon1<-2
rayon2<-1.414214
df1<-data.frame(x=c(rayon1*cos(angle)),
                y=c(rayon1*sin(angle)))
df2<-data.frame(x=c(rayon2*cos(angle)+(rayon1+rayon2)*1.25),
                y=c(rayon2*sin(angle)))
ggplot(df1,aes(x=x,y=y))+
  geom_path()+
  geom_path(data=df2,aes(x=x,y=y))+
  coord_equal()

On peut aussi s’assurer que le plus petit cercle touche le périmètre du plus grand en bas pour obtenir des cercles intriquées.

angle<-seq(0,2*pi,length.out=100)
rayon1<-2
rayon2<-1.414214
df1<-data.frame(x=c(rayon1*cos(angle)),
                y=c(rayon1*sin(angle)))
df2<-data.frame(x=c(rayon2*cos(angle)),
                y=c(rayon2*sin(angle)-(rayon1-rayon2)))
ggplot(df1,aes(x=x,y=y))+
  geom_path()+
  geom_path(data=df2,aes(x=x,y=y))+
  coord_equal()

Voyons ce que cela donne si on met en regard les morts du COVID et la population Parisienne. Ce dernier représente 6,7% de cette dernière. Les aires doivent être ajustées en conséquences.

val1<-2175601
val2<-146376
rap<-val2/val1
angle<-seq(0,2*pi,length.out=100)
rayon1<-2
air1<-rayon1^2*pi
air2<-air1*rap
rayon2<-sqrt(air2/pi)
df1<-data.frame(x=c(rayon1*cos(angle)),
                y=c(rayon1*sin(angle)))
df2<-data.frame(x=c(rayon2*cos(angle)),
                y=c(rayon2*sin(angle)-(rayon1-rayon2)))
ggplot(df1,aes(x=x,y=y))+
  geom_path()+
  geom_path(data=df2,aes(x=x,y=y))+
  coord_equal()

Maintenant que nous avons quelque chose qui se tient. Créons une fonction permettant de générer un graphe simplement en intégrant les valeurs à comparer et quelques éléments de mise en forme.

bubble_c<-function(v1,v2,col1,col2,label1='cercle1',
                   label2='cercle2',type=c("nested","dodge")){
   rap<-v2/v1
   angle<-seq(0,2*pi,length.out=100)
   rayon1<-2
   air1<-rayon1^2*pi
   air2<-air1*rap
   rayon2<-sqrt(air2/pi)
   df1<-data.frame(x=c(rayon1*cos(angle)),
                y=c(rayon1*sin(angle)))
   if(type=="nested"){
   df2<-data.frame(x=c(rayon2*cos(angle)),
                y=c(rayon2*sin(angle)-(rayon1-rayon2)))
   g_n<-ggplot(df1,aes(x=x,y=y))+
               geom_polygon(fill=col1)+
               geom_polygon(data=df2,aes(x=x,y=y),fill=col2)+
               geom_richtext(aes(x=0,y=1.75,label=label1),
                             hjust=0.5,vjust=0.5,
                             color='white',fontface='bold',
                             fill = NA, label.color = NA)+
               geom_richtext(aes(x=0,y=-(rayon1-rayon2),label=label2),
                             hjust=0.5,vjust=0.5,
                             color='white',fontface='bold',
                             fill = NA, label.color = NA)+
               coord_equal()+
               theme_void()+
               theme(plot.title=element_text(hjust=0.5,face='bold'),
                     plot.subtitle=element_text(hjust=0.5,face='italic'),
                     plot.caption = element_text(hjust=1,face='italic'))
    return(g_n)}
    if(type=="dodge"){
     df2<-data.frame(x=c(rayon2*cos(angle)+(rayon1+rayon2)*1.25),
                y=c(rayon2*sin(angle)))
     g_d<-ggplot(df1,aes(x=x,y=y))+
                 geom_polygon(fill=col1)+
                 geom_polygon(data=df2,aes(x=x,y=y),
                                   fill=col2)+
                 geom_richtext(aes(x=0,y=0,label=label1),
                               hjust=0.5,vjust=0.5,
                               color='white',fontface='bold',
                               fill = NA, label.color = NA)+
                 geom_richtext(aes(x=(rayon1+rayon2)*1.25,
                                   y=0,label=label2),
                               hjust=0.5,vjust=0.5,
                               color='white',fontface='bold',
                               fill = NA, label.color = NA)+
                 coord_equal()+
                 theme_void()+
                 theme(plot.title=element_text(hjust=0.5,face='bold'),
                       plot.subtitle=element_text(hjust=0.5,face='italic'),
                       plot.caption = element_text(hjust=1,face='italic'))
     return(g_d)}
}

Notez que comme la fonction génère un graphe de type ggplot tous les ajouts et compléments de mise en forme sont possibles en respectant la synthaxe associée.

Testons notre fonction avec la mise en regard de la population de Lille avec le nombre de décès.

bubble_c(233098,146376,'grey','blue',type='nested',label1 = 'Lille <br> 233 098', label2='Décés <br> 146 376')+
  labs(title = "Mise en regard du nombre de décés du COVID par rapport à la population de Lille",
       subtitle = "Décés en France au 4 mai 2022 depuis le début de lépidémie",
       caption = "Source: Santé Publique France et INSEE")

On peut également à partir de notre fonction réaliser un graphe accolé juste en sélectionnant l’option dodge.

bubble_c(233098,146376,'grey','blue',type='dodge',label1 = 'Lille <br> 233 098', label2='Décés <br> 146 376')+
  labs(title = "Mise en regard du nombre de décés du COVID par rapport à la population de Lille",
       subtitle = "Décés en France au 4 mai 2022 depuis le début de lépidémie",
       caption = "Source: Santé Publique France et INSEE")

Des variations peuvent être envisagé de manière à intégrer plus de bulles dans les graphes soit en en assemblant plusieurs avec le package patchwork ou simplement en modifiant la fonction que nous venons de construire.