Graphiques

Les différentes versions de graphiques présentées pour le moment reposent uniquement sur un balisage sémantique — à base de <table> — et une tartine de variables CSS portées par les balises. Aucun JavaScript n’est requis pour l’affichage, et les styles sont améliorés progressivement selon les capacités de votre navigateur.

Note : en vertu du caractère expérimental de ces techniques et d’une fondation solide améliorée progressivement, je ne précise pas le support navigateur de chaque exemple — mais il va de soi que ce n’est pas magique, et que seuls les navigateurs modernes répondent à l’appel, à l’exception notable de Edge qui ne supporte pas (encore) clip-path. Les autres navigateurs affichent un tableau correctement stylé, et c’est chouette.

L’accessibilité

Un effort conséquent a été porté à l’accessibilité. Comme évoqué précédement, un balisage sémantique et structuré est un pré-requis — mais ça ne suffit pas. Les CSS sont appliqués aussi progressivement que possible, afin de garantir le meilleur affichage possible des données pour chaque internaute.

Tableau accessible

Piqure de rappel :

Pour d’autres astuces utiles, je vous recommande chaleureusement la lecture du composant inclusif Data Tables de Heydon Pickering, qui est une véritable mine d’or.

Motifs

Pour distinguer les différentes zones autrement que par la couleur, un motif svg est appliqué en css — vous en trouverez quelques-uns sur le site Hero Patterns :

  1. afin d’améliorer le mélange avec les couleurs ou dégradés d’arrière-plan, la propriété background-blend-mode est utilisée avec la valeur hard-light ;
  2. les tailles et positions du motif dépendent directement de la valeur et de l’échelle du graphique, selon le type de graphique ;
  3. pour ne pas embarquer trop de fichiers externes, une liste finie de motifs svg encodés en base64 sont utilisés dans le thème. À ce propos, seuls les chevrons et le caracère « # » ont besoin d’être encodés dans le css. Ne vous fatiguez pas avec les autres caractères, leur encodage nuit sérieusement à la lisibilité.
    
tr:nth-child(2n + 2) td {
  --size: calc( var(--scale, 100) * 100% );
  --position: calc( var(--value, 0) / var(--scale, 100) * 100% );
  background-image:
    linear-gradient( right, Window, ButtonFace, ButtonText, highlight ),
    url(data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff99' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E);
  background-blend-mode: hard-light;
  background-position: var(--position) 0%, center;
  background-size:  var(--size) 100%, contain;
}
  

Respect des préférences

Afin de respecter autant que possible les préférences des visiteurs, de nombreux éléments ont été adaptés :

  1. les dimensions sont en unités relatives (em ou rem selon les cas), afin de s’ajuster de manière cohérente au corps de texte hérité du navigateur et de pouvoir être agrandi ou réduit sans perte ;
  2. les couleurs sont adaptées lorsque le mode de contrastes élevés de Windows est détecté à l’aide de -ms-high-contrast: active, inspiré par l’article de Greg Whitworth sur le sujet. Je vous conseille d’ailleurs l’increvable page Test of System Colors Specified in CSS 2 rédigée par Ian Graham, éditée en… 2000 !
  3. les animations et transitions sont désactivées lorsque le système expose cette préférence grâce à prefers-reduced-motion: reduce.

display et sémantique

Adrian Roselli explique que jouer avec le display d’un élément <table> ou <dl> met en péril sa sémantique. Cette dernière est donc « verrouillée » à l’aide des rôles aria dédiés — comme il l’explique dans un article détaillé.

C’est pourquoi chaque tableau est précédé d’un interrupteur — basé sur le composant inclusif conçu par Heydon Pickering — dont le seul et unique rôle est de désactiver les styles supplémentaires :

    
document.addEventListener( "DOMContentLoaded", function () {
  var switches = document.querySelectorAll( '[role="switch"]' );

  Array.prototype.forEach.call( switches, function( el, i ) {
    el.addEventListener( 'click', function() {
      var checked = this.getAttribute( 'aria-checked' ) == 'true' || false;
      this.setAttribute( 'aria-checked', !checked );

      var chart = this.parentNode.nextElementSibling;
      chart.classList.toggle( 'table-charts' );
    } );
  } );
} );
  

Voilà, nous sommes prêts à entrer dans le vif du sujet.
Faites chauffer votre inspecteur !

Graphique en barre

Ce type de graphique sert à représenter des données à une dimension (dans notre exemple, une ligne de temps). Il repose sur les grilles et les variables CSS, technique inspirée d’un article de Miriam Suzanne sur CSS Tricks légèrement agrémentée pour en améliorer l’accessibilité. Pour vous en servir, c’est simple :

  1. Sur le tableau, une variable --scale permet de définir la valeur maximale du graphique, afin d’en déterminer l’échelle. Concrètement, une grille va être générée avec :
    • la première colonne pour les entêtes <th> arbitrairement fixée à 12.5em ;
    • puis la fonction CSS repeat() crée une colonne par unité de l’échelle — dans l’exemple, 3000 colonnes ;
    • enfin une dernière colonne mesurant 10ch, soit l’espace suffisant pour dix lettres.
  2. Sur chaque cellule <td>, une variable --value permet de la placer sur la grille, appliquée à grid-column-end. De plus grâce à de savants calculs reposant sur cette valeur, le dégradé en arrière-plan est dimensionné et positionné de façon à refléter la proportion représentée par cette valeur sur l’échelle donnée (du vert pour presque rien au rouge pour presque tout).
  3. Dans chaque cellule, le contenu doit reprendre la valeur et son unité dans un élément <span>, éventuellement balisée avec <abbr> (et aria-label pour suppléer title) si un intitulé peut être explicité pour l’unité. Cette valeur est poussée à droite de la grille, et son texte sert de masque pour le dégradé en arrière-plan — lui permettant d’être de la couleur correspondante à la fin du dégradé pour la position donnée.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Temps de chargement pour ffoodd.fr
Temps de chargement cumulé
Temps : backend ms
Temps : Frontend 96 ms
Délai : premier octet 102 ms
Délai : dernier octet 129 ms
Délai : première image 188 ms
Délai : premier CSS 194 ms
Délai : premier JS 326 ms
DOM Interactif 836 ms
Chargement du DOM 836 ms
DOM complet 2561 ms
Trafic HTTP terminé 2980 ms
Le HTML
        
<table class="table-charts bar" style="--scale: 3000">
  <caption id="caption-1">Temps de chargement pour ffoodd.fr</caption>
  <thead class="sr-only">
    <tr>
      <td></td>
      <th scope="col">Temps de chargement cumulé</th>
    </tr>
  </thead>
  <tbody>
  <tr>
    <th scope="row">Temps : backend</th>
    <td style="--value: 4">
      <span>4 <abbr title="Milliseconde" aria-label="Milliseconde">ms</abbr></span>
    </td>
  </tr>
  <tr>[…]</tr>
</tbody>
</table>
        
      
Le css
        
@supports (grid-template-columns: repeat( var(--scale, 100), minmax(0, 1fr) )) {
  .table-charts.bar caption {
    text-align: initial;
    text-indent: 13.5rem;
  }

  .table-charts.bar tr {
    display: grid;
    grid-auto-rows: 1fr;
    grid-row-gap: .5rem;
    grid-template-columns: minmax(min-content, em( 200 ) ) repeat( var(--scale, 100), minmax(0, 1fr) ) 10ch;
    transition: opacity .2s cubic-bezier(.5, 0, .5, 1);
  }

  .table-charts.bar th {
    grid-column: 1 / 1;
    margin: .5rem 0 0;
    padding: 0 1rem 0 0;
    text-align: right;
  }

  .table-charts.bar td {
    --size: calc( var(--scale, 100) * 100% );
    --position: calc( var(--value, 0) / var(--scale, 100) * 100% );
    background-blend-mode: hard-light;
    background-position: var(--position) 0%, center;
    background-size:  var(--size) 100%, contain;
    grid-column: 2 / var(--value, 0);
    margin: .5rem 0 0;
    position: relative;
  }

  .table-charts.bar span {
    background: inherit;
    -webkit-text-fill-color: transparent;
    -webkit-background-clip: text;
    font-weight: bold;
    left: 100%;
    line-height: 1.5;
    position: absolute;
  }

  /* @note À répéter pour chaque ligne, afin de les distinguer grâce à un motif */
  .table-charts.bar tr:nth-child(2n + 2) td {
    background-image:
      linear-gradient(to right,
        #01ac49,
        #444,
        mediumblue,
        rebeccapurple,
        crimson
      ),
      url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff99' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E");
  }

  .table-charts.bar tbody:hover tr {
    opacity: .5;
  }

  .table-charts.bar tbody:hover tr:hover {
    opacity: 1;
  }

  @media screen and (-ms-high-contrast: active) {
    .table-charts.bar tr:nth-child(2n + 2) td {
      background-image:
        linear-gradient(to right,
          Window,
          ButtonFace,
          ButtonShadow,
          ButtonText,
          highlight
        ),
        url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff99' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E");
    }
  }
}
      

Graphique en chute d’eau

Le principe est le même pour cette variante, à un détail près : on gère également le point de départ pour chaque mesure — qui est, très simplement, la valeur du point précédent… Il faut cependant passer toutes les valeurs en tant que variables sur le parent <table>.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Temps de chargement pour ffoodd.fr
Temps de chargement cumulé
Temps : backend ms
Temps : Frontend 96 ms
Délai : premier octet 102 ms
Délai : dernier octet 129 ms
Délai : première image 188 ms
Délai : premier CSS 194 ms
Délai : premier JS 326 ms
DOM Interactif 836 ms
Chargement du DOM 836 ms
DOM complet 2561 ms
Trafic HTTP terminé 2980 ms
Le HTML
        
<table class="table-charts bar waterfall"
       style="--scale: 3000; --1: 4; --2: 96; --3: 102; --4: 129; --5: 188; --6: 194; --7: 326; --8: 836; --9: 836; --10: 2561; --11: 2980;">
</table>
        
      
Le css
        
.bar.waterfall tr:nth-of-type(2) td {
  grid-column: var(--1, 0) / var(--value, 0);
}
      

Graphique linéaire

Graphique de surface

Ce graphique repose sur les variables CSS, les grilles et clip-path. Cette dernière propriété est la plus importante.

  1. Sur le tableau, les échelles sont indiquées :
    • --y définit l’échelle des ordonnées, utilisée pour indiquer l’échelle en arrière-plan mais aussi placer les points sur la courbe ;
    • --x correspond à l’échelle des abscisses, exprimée simplement comme le nombre de colonnes à afficher ;
  2. Chaque ligne <tr> dans <tbody> porte une palanquée de variables, correspondant à toutes les valeurs qu’elle contient. Dans un pseudo-élément ::before, une position est définie pour chaque valeur au sein de la fonction polygon() de clip-path.
    • Étant donné que cette fonction accepte deux valeurs en pourcentage chaque point, la méthode est relativement simple. La position en abscisse est le nombre de colonnes (le décalage depuis la gauche, donc) et la position en ordonnée est le ratio de la valeur sur l’échelle, le tout formulé ainsi : calc( ( 100% / var(--x) * 1 ) + var(--offset) ) calc( 100% - ( var(--1) / var(--y) * 100% ) ), où * 1 et var(--1) correspondent à l’index de la valeur dans l’ensemble, et var(--offset) est la valeur d’une demi-colonne, pour placer le point au milieu de sa colonne.
    • Vous l’aurez peut-être compris, le principal écueil de ce graphique est qu’il nécessite de connaître le nombre de points par avance.
  3. Chaque cellule <td> dans <tbody> porte un pseudo-élément ::after qui sert à récapituler ses entêtes et valeur dans une infobulle simulée, et un pseudo-élément ::before pour gérer un calque interactif sur la cellule :
  4. Tout le reste n’est que décoration :
    • un padding-top important sur le tableau pour réserver l’espace d’affichage des graphiques — attention : il est nécessaire d’appliquer border-collapse: separate; sur le tableau afin que le padding ait un impact ;
    • le ::before de chaque ligne est étiré afin d’occuper tout l’espace réservé ;
    • un arrière-plan dégradé pour représenter la surface pleine du même ::before ;
    • un repeating-linear-gradient() pour représenter l’échelle verticale, en arrière-plan du tableau ;
    • et des interactions au survol pour mettre en exergue la valeur survolée : sa colonne à l’aide d’un pseudo-élément — positionné à l’aide de de savants calculs — et mix-blend-mode pour un effet waouh.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Température mensuelle moyenne en 2017
Année Jan. Fév. Mars Avr. Mai Juin Juil. Août Sep. Oct. Nov. Déc.
2017 °C °C °C 12 °C 15 °C 21 °C 24 °C 25 °C 22 °C 19 °C 14 °C °C
Le HTML
        
<table class="table-charts line" style="--y: 32; --x: 13; --t-1: 'Jan.'; --t-2: 'Fév.'; […]">
  <caption id="caption-3">Température mensuelle moyenne en 2017</caption>
  <thead>
    <tr>
      <th scope="col">Année</th>
      <th scope="col">Jan.</th>
      <th scope="col">[…]</th>
    </tr>
  </thead>
  <tbody>
    <tr style="--year: '2017'; --1: 8; --2: 6; --3: 9; --4: 12; --5: 15; --6: 21; --7: 24; --8: 25; --9: 22; --10: 19; --11: 14; --12: 9;">
      <th scope="row">2017</th>
      <td>
        8 <abbr title="Degré Celsius" aria-label="Degré Celsius">°C</abbr>
      </td>
      <td>[…]</td>
    </tr>
  </tbody>
</table>
      
Le css
        
@supports (clip-path: polygon(0% calc( 100% - ( var(--1) * 100% / var(--y) )) )) {
  .table-charts.line {
    --offset: calc( ( 100% / var(--x) ) / 2);
    --height: calc( 32em - 2rem );
    --bottom: calc( 100% - var(--height) );
    padding: var(--height) 0 1rem;
    position: relative;
    transition:
      background .3s cubic-bezier(.5, 0, .5, 1),
      color .3s cubic-bezier(.5, 0, .5, 1);
  }

  .table-charts.line::after {
    --scale: calc( ( 100% - ( var(--y) * 1px) ) / var(--y) );
    background:
      repeating-linear-gradient(
        to bottom,
        white, white var(--scale),
        rgba(0, 0, 0, .25) calc( var(--scale) + 1px)
      );
    bottom: var(--bottom);
    content: "";
    position: absolute;
    top: 0;
    width: 100%;
    z-index: 1;
  }

  .table-charts.line tr::before {
    content: "";
    position: absolute;
  }

  .table-charts.line [scope="row"],
  .table-charts.line thead th:first-child {
    color: var(--color, currentColor);
    text-align: left;
  }

  .table-charts.line [style]::before {
    bottom: var(--bottom);
    background: linear-gradient(to top, deepskyblue, crimson 75%);
    clip-path: polygon(
      0% 100%,
      calc( ( 100% / var(--x) * 1) ) 100%,
      calc( ( 100% / var(--x) * 1) ) calc( 100% - ( var(--1) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 1) + var(--offset) ) calc( 100% - ( var(--1) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 2) + var(--offset) ) calc( 100% - ( var(--2) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 3) + var(--offset) ) calc( 100% - ( var(--3) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 4) + var(--offset) ) calc( 100% - ( var(--4) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 5) + var(--offset) ) calc( 100% - ( var(--5) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 6) + var(--offset) ) calc( 100% - ( var(--6) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 7) + var(--offset) ) calc( 100% - ( var(--7) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 8) + var(--offset) ) calc( 100% - ( var(--8) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 9) + var(--offset) ) calc( 100% - ( var(--9) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 10) + var(--offset) ) calc( 100% - ( var(--10) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 11) + var(--offset) ) calc( 100% - ( var(--11) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 12) + var(--offset) ) calc( 100% - ( var(--12) / var(--y) * 100% ) ),
      100% calc( 100% - ( var(--12) / var(--y) * 100% ) ),
      100% 100%,
      0% 100%
    );
    content: "";
    position: absolute;
    top: 0;
    width: 100%;
    z-index: 2;
  }

  .table-charts.line th,
  .table-charts.line td {
    background: white;
    font-weight: bold;
    text-align: center;
    width: calc( 100% / var(--x) );
    width: 7.69%;
  }

  .table-charts.line th:hover,
  .table-charts.line td:hover {
    color: mediumblue;
  }

  .table-charts.line [scope="col"]:not(:first-child)::after {
    background-color: white;
    background-image: url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff99' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E");
    background-blend-mode: exclusion;
    bottom: 4rem;
    content: "";
    height: calc( 100% - 4rem );
    mix-blend-mode: soft-light;
    opacity: 0;
    position: absolute;
    transition: opacity .3s cubic-bezier(.5, 0, .5, 1);
    width: inherit;
    z-index: 3;
  }

  .table-charts.line th[scope="col"]:nth-child(3)::after {
    left: calc( 100% / var(--x) * 2 );
  }

  .table-charts.line  [scope="col"]:hover::after {
    opacity: .75;
  }

  .table-charts.line td {
    --value: var(--1);
    --term: var(--t-1);
    --top: calc( (var(--height) - ( var(--value) / var(--y) * 100% ) ) );
    line-height: 1.5;
  }

  .table-charts.line td::before {
    content: '';
    height: 1.5rem;
    position: absolute;
    transform: translateX(-50%);
    width: inherit;
    z-index: 10;
  }

  .table-charts.line td::after {
    --arrow: calc(100% - .25rem);
    background-color: #444;
    counter-reset: value var(--value);
    content: var(--term) " " var(--year) "\A " counter(value) "\A0°C";
    color: #fff;
    clip-path: polygon(
      0% 0%,
      100% 0%,
      100% var(--arrow),
      calc(50% - .25rem) var(--arrow),
      50% 100%,
      calc(50% + .25rem) var(--arrow),
      0% var(--arrow)
    );
    opacity: 0;
    padding: .5rem;
    left: calc( var(--offset) * 3 );
    pointer-events: none;
    position: absolute;
    top: var(--top, 100);
    transform-origin: 50% calc(100% + 10px);
    transform:
      translate3d(-50%, -125%, 0)
      perspective(1000px)
      rotate3d(1, 0, 0, 45deg);
    transition:
      opacity .2s cubic-bezier(0, .5, .5, 1),
      transform .2s cubic-bezier(0, .5, .5, 1);
    will-change: opacity, transform;
    white-space: pre;
    z-index: 5;
  }

  .table-charts.line td:nth-child(3)::after {
    --value: var(--2);
    --term: var(--t-2);
    --top: calc( (var(--height) - ( var(--value) / var(--y) * var(--height) ) ) );
    left: calc( ( 100% / var(--x) * 2 ) + var(--offset) );
  }

  .table-charts.line td:hover::after {
    opacity: 1;
    transform:
      translate3d(-50%, -125%, 0)
      perspective(1000px)
      rotate3d(1, 0, 0, 0deg);
    transition:
      opacity .2s cubic-bezier(.5, 0, 1, .5),
      transform .2s cubic-bezier(.5, 0, 1, .5);
  }

  @media screen and (-ms-high-contrast: active) {
    .table-charts.line tr[style]::before {
      background: linear-gradient(to top, ButtonHighlight, Highlight 75%);
    }
  }
}
      
Le calcul tordu

Le tracé du polygon()

Pour commencer, il faut bien intégrer que clip-path est un tracé, au même titre qu’une forme vectorielle. Il doit donc être fermé. Ainsi le tracé démarre à 0% 100% — en bas à gauche, fait sa vie de tracé, bascule à 100% 100% et revient boucler à 0% 100%.

Et dans son chemin, chaque point doit être positionné en abscisses et en ordonnées.

La position en abscisse

La première position est simple : on divise 100% par l’échelle var(--x), et on multiplie par l’index de l’élément. Par exemple : calc( ( 100% / var(--x) * 1) ). Pour placer chaque point au milieu de sa colonne, on le décale d’une demi-colonne — ce que l’on fait en ajoutant au calcul précédent var(--offset), qui correspond à calc( ( 100% / var(--x) ) / 2 ).
La position finale est donc, ici pour le troisième point :
calc( ( 100% / var(--x) * 3) + var(--offset) ).

La position en ordonnée

Dans ce graphique, l’ordonnée est l’axe le plus important. Ainsi pour placer le point, on commence par calculer le ratio de sa valeur sur l’échelle — formulé ainsi : var(--1) / var(--y). Et parce que polygon() utilise des valeurs en pourcentage, on rapporte ce calcul sur 100% : ( var(--1) / var(--y) * 100% ).
Et pour finir, les référentiels du polygone partant d’en haut à gauche, la position doit être définie en fonction du haut de la boîte. La formule finale ressemble alors à ça — toujours pour le troisième élément :
calc( 100% - ( var(--3) / var(--y) * 100% ) ).

Graphique à point

Cette variante diffère finalement assez peu de la précédente mouture :

  1. le polygon() est poursuivi pour former une ligne, en dupliquant chaque point avec un décalage de 4px — l’épaisseur du trait — et dans l’ordre inverse ;
  2. le pseudo-élément ::before qui permet d’afficher l’infobulle prend ici la forme d’un point sur la courbe — positionné à l’aide des mêmes calculs qui servent dans le polygone ;
  3. et surtout, puisque clip-path est appliqué sur la ligne <tr> : vous pouvez en mettre plusieurs ! Il nous faut donc ajouter une combinaison de couleur et motif pour distinguer chaque ligne et les associer visuellement à leur légende.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Température mensuelle moyenne par année
Année Jan. Fév. Mars Avr. Mai Juin Juil. Août Sep. Oct. Nov. Déc.
2017 °C °C °C 12 °C 15 °C 21 °C 24 °C 25 °C 22 °C 19 °C 14 °C °C
2018 10 °C °C °C 13 °C 17 °C 20 °C 22 °C 23 °C 26 °C 17 °C 14 °C 10 °C
Le css
        
@supports (clip-path: polygon(0% calc(100% - (var(--1) * 100% / var(--y))))) {
  .table-charts.points [style]::before {
    background-color: var(--color, currentColor);
    clip-path: polygon(
      calc( ( 100% / var(--x) * 1 ) + var(--offset) ) calc( 100% - ( var(--1) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 2 ) + var(--offset) ) calc( 100% - ( var(--2) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 3 ) + var(--offset) ) calc( 100% - ( var(--3) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 4 ) + var(--offset) ) calc( 100% - ( var(--4) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 5 ) + var(--offset) ) calc( 100% - ( var(--5) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 6 ) + var(--offset) ) calc( 100% - ( var(--6) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 7 ) + var(--offset) ) calc( 100% - ( var(--7) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 8 ) + var(--offset) ) calc( 100% - ( var(--8) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 9 ) + var(--offset) ) calc( 100% - ( var(--9) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 10 ) + var(--offset) ) calc( 100% - ( var(--10) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 11 ) + var(--offset) ) calc( 100% - ( var(--11) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 12 ) + var(--offset) ) calc( 100% - ( var(--12) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 13 ) + var(--offset) ) calc( 100% - ( var(--12) / var(--y) * 100% ) ),
      100% calc( 100% - ( var(--12) / var(--y) * 100% ) ),
      100% calc( ( 100% + 4rem ) - ( var(--12) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 13 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--12) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 12 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--12) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 11 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--11) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 10 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--10) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 9 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--9) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 8 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--8) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 7 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--7) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 6 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--6) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 5 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--5) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 4 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--4) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 3 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--3) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 2 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--2) / var(--y) * 100% ) ),
      calc( ( 100% / var(--x) * 1 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--1) / var(--y) * 100% ) )
    );
    transition: opacity .3s cubic-bezier(.5, 0, .5, 1);
  }

  .table-charts.points [style] th::before {
     background-color: var(--color, currentColor);
     content: "";
     display: inline-block;
     height: 1rem;
     transform: translate3d(-.2rem, .1rem, 0);
     width: 1rem;
  }

  .table-charts.points [style] td {
    --top: calc( (var(--height) - ( var(--value) / var(--y) * var(--height) ) ) );
  }

  .table-charts.points [style] td::before {
    --size: 1rem;
    background-color: var(--color, currentColor);
    border: 2px solid white;
    border-radius: 50%;
    box-shadow: 0 0 4rem rgba(0, 0, 0, .5);
    content: "";
    height: var(--size);
    left: calc( var(--offset) * 3 );
    position: absolute;
    top: var(--top, 100);
    transform: translate3d(calc( var(--size) / -2), calc( var(--size) / -2), 0);
    transition:
        opacity .3s cubic-bezier(.5, 0, .5, 1),
        transform .3s cubic-bezier(.5, 0, .5, 1);
    width: var(--size);
    z-index: 4;
  }

  .table-charts.points [style] td:nth-of-type(2)::before {
    --value: var(--2);
    --top: calc( ( var(--height) - ( var(--2 ) / var(--y) * var(--height) ) ) );
    left: calc( ( 100% / var(--x) * 2 ) + var(--offset) );
  }

  /* @note À répéter pour chaque ligne, afin de les distinguer grâce à un motif */
  .table-charts.points [style]:nth-child( 2n + 2 )::before,
  .table-charts.points [style]:nth-child( 2n + 2 ) th::before,
  .table-charts.points [style]:nth-child( 2n + 2 ) td::before  {
     background-image: url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff99' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E");
   }

  .table-charts.points tbody:hover [style]::before,
  .table-charts.points tbody:hover [style] td::before {
    opacity: .25;
  }

  .table-charts.points tbody:hover [style]:hover::before,
  .table-charts.points tbody:hover [style]:hover td::before {
    opacity: 1;
  }

  .table-charts.points tbody:hover [style]:hover td::before {
    transform:
        translate3d( calc( var(--size) / -2 ), calc( var(--size ) / -2), 0 )
        scale( 1.25 );
  }

  .table-charts.points [scope="col"]::after {
    mix-blend-mode: multiply;
  }

  .table-charts.points [scope="col"]:hover::after {
   opacity: .5;
  }
}
      

Note

Pour jouer d’avantage et vous familiariser avec clip-path, Bennett Feely a créé clippy.

Histogramme

L’histogramme sert pour les distributions de valeurs. La structure du tableau est tout à fait ordinaire, cependant sa mise en forme repose sur display: grid; et surtout display: contents; pour faciliter le placement des cellules — technique inspirée par l’article de Hidde De Vries More accessible markup with display: contents, et clarifiée par l’article de Ire Aderinokun How display: contents works.

Le principe de base est le même que le graphique en barre :

  1. la fonction repeat() appliquée avec la variable --scale permet de gérer une échelle dynamique ;
  2. les conteneurs <thead>, <tbody> et <tr> sont neutralisés dans la gestion de la grille à l’aide de display: contents ;
  3. chaque cellule est placée sur la grille en fonction de --value — sa valeur, donc — sa couleur d’arrière-plan dépend également de sa valeur ;
  4. et finalement la valeur textuelle — contenue dans un élément <span> — est positionnée en haut de la colonne à l’aide de la même astuce que dans le graphique en barre.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Parts de marché navigateurs en France en janvier 2019
Navigateur Chrome Firefox Safari Edge IE Autres
Parts de marché 62 % 15 % 9 % 5 % 6 % 3 %
Le HTML
        
<table class="table-charts column" id="column" style="--y: 7;">
  <caption id="caption-7">Parts de marché navigateurs en France en janvier 2019</caption>
  <thead>
    <tr>
      <th scope="row">Ressource</th>
      <th scope="col" style="--value: 62;">Chrome</th>
      <th scope="col" style="--value: 15;">Firefox</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Parts de marché</th>
      <td style="--value: 62;"><span>62 %</span></td>
      <td style="--value: 15;"><span>15 %</span></td>
    </tr>
  </tbody>
</table>
      
Le css
        
@supports (display: contents) {
  .table-charts[class*="column"] {
    --gap: .5rem;
    --size: calc(var(--scale, 100) * 100%);
    --width: calc(64em / var(--y) - 1rem);
    display: grid;
    grid-column-gap: var(--gap);
    max-height: 64em;
    position: relative;
  }

  .table-charts[class*="column"] td,
  .table-charts[class*="column"] th {
    margin: 0;
  }

  .table-charts[class*="column"] tr > * + * {
    text-align: center;
  }

  .table-charts[class*="column"] tr,
  .table-charts[class*="column"] tbody,
  .table-charts[class*="column"] thead {
    display: contents;
  }

  .table-charts[class*="column"] caption {
    grid-column: 1 / span var(--y);
    grid-row: -1;
  }

  .table-charts[class*="column"] td {
    grid-row: calc( 100 - var(--value) ) / -3;
    pointer-events: none;
    position: relative;
    transition: opacity .2s cubic-bezier(.5, 0, .5, 1);
  }

  .table-charts[class*="column"] span {
    background: inherit;
    -webkit-text-fill-color: transparent;
    -webkit-background-clip: text;
    font-weight: bold;
    bottom: 100%;
    left: 0;
    line-height: 1.5;
    pointer-events: auto;
    position: absolute;
    right: 0;
  }

  /* @note À répéter pour chaque colonne, afin de les distinguer grâce à un motif *
  .table-charts[class*="column"] td:nth-of-type(2) {
    grid-column: 3;
  }

  .table-charts.column-single {
    grid-auto-columns: 1fr;
    grid-template-rows: repeat(var(--scale, 100), minmax(0, .25rem)) minmax(min-content, 2rem);
  }

  .table-charts.column-single tbody th {
    grid-row: -6 / -3;
    grid-column: 1;
    line-height: 1;
  }

  .table-charts.column-single thead * {
    grid-row: -2;
  }

  .table-charts.column-single td {
    --position: calc(var(--value, 0) / var(--scale, 100) * 100%);
    background-blend-mode: hard-light;
    background-position: 0% var(--position), center;
    background-size: 100% var(--size), 1rem;
  }

  .table-charts.column-single [scope="col"]::after {
    background-color: whitesmoke;
    background-image: url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff99' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E");
    background-blend-mode: exclusion;
    bottom: 4rem;
    content: "";
    mix-blend-mode: multiply;
    opacity: 0;
    position: absolute;
    transition: opacity .3s cubic-bezier(.5, 0, .5, 1);
    top: 1rem;
    width: var(--width);
    z-index: 0;
  }

  .table-charts.column-single [scope="col"]:hover::after {
    opacity: .5;
  }

  /* @note À répéter pour chaque colonne, afin d’en disposer l’arrière-plan au bon endroit */
  .table-charts.column-single [scope="col"]:nth-child(3)::after {
    left: calc(1em + (var(--width) * 2) + (var(--gap) * 2));
  }

  /* @note À répéter pour chaque colonne, afin de les distinguer grâce à un motif */
  .table-charts.column-single td:nth-of-type(2n + 2) {
    background-image:
      linear-gradient(to top,
        #01ac49,
        #444,
        mediumblue,
        rebeccapurple,
        crimson
      ),
      url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff99' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E);
  }

  @media screen and (-ms-high-contrast: active) {
    .table-charts.column-single td:nth-of-type(2n + 2) {
      background-image:
        linear-gradient(to top,
          Window,
          ButtonFace,
          ButtonShadow,
          ButtonText,
          highlight
        ),
        url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff99' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E);
    }
  }
}
      

Colonnes multiples

Pour disposer de deux valeurs pour chaque colonne principale, nous devons également disposer de deux sous-titres. Concrètement parlant :

  1. on ajoute une seconde ligne dans l’entête <thead> :
    • avec deux cellules d’entête de colonne <th scope="col"> pour chaque cellule d’entête de colonne dans la première ligne d’entête ;
    • n’oublions pas d’ajouter un colspan="2" sur les cellules d’entête de la première ligne pour faire correspondre la structure du tableau ;
    • et enfin ajouter un identifiant à chaque cellule d’entête afin de les référencer sur les cellules de données concernées — à l’aide de l’attribut headers, par exemple pour la première cellule : headers="navigateur chrome annee chrome-2018" où chaque valeur est un identifiant de cellule d’entête.
  2. Côté styles :
    • les cellules d’entête de premier niveau doivent s’étendre sur deux colonnes de la grille, comme le réclame leur attribut colspan pour la structure du tableau. Il est malheureusement impossible d’utiliser la valeur d’un attribut dans une autre propriété que content — sinon nous pourrions simplement écrire grid-column: 2 / span attr(colspan); et ça serait magnifique…
    • mais non ! Ainsi, une variable --span est ajoutée sur <table>, et doit correspondre à la valeur des attributs colspan cités plus tôt : elle sert donc à étendre les entêtes de premier niveau sur le nombre de colonnes adéquat.
    • les couleurs et motifs ne sont plus appliqués en fonction de chaque valeur, mais en fonction de chaque colonne — dans l’exemple, un élément sur deux (puisque nous avons deux entrées par colonne). Là aussi, si nous pouvions utiliser une valeur d’attribut ou une propriété personnalisée dans un sélecteur, ce serait génial. Imaginez un peu tbody td:nth-of-type(var(--span)n + var(--span)) ou encore tbody td:nth-of-type(attr(colspan)n + attr(colspan)) !

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Parts de marché navigateurs en France en janvier 2019
Navigateur Chrome Firefox Safari Edge IE
Année 2018 2019 2018 2019 2018 2019 2018 2019 2018 2019
Parts de marché 49.6 % 57 % 11.74 % 9.59 % 21.53 % 18.78 % 3.72 % 3.5 % 4.46 % 3.66 %
Le HTML
        
<table class="table-charts column-multiple" id="column-multiple" style="--y: 11; --span: 2;">
  <caption id="caption-8">Parts de marché navigateurs en France en janvier 2019</caption>
  <thead>
    <tr>
      <th scope="row" id="navigateur">Navigateur</th>
      <th scope="col" colspan="2" id="chrome">Chrome</th>
    </tr>
    <tr>
      <th scope="row" id="annee">Année</th>
      <th scope="col" id="chrome-2018">2018</th>
      <th scope="col" id="chrome-2019">2019</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row" id="parts">Parts de marché</th>
      <td style="--value: 50;" headers="navigateur chrome annee chrome-2018"><span>49.6 %</span></td>
      <td style="--value: 57;" headers="navigateur chrome annee chrome-2019"><span>57 %</span></td>
    </tr>
  </tbody>
</table>
      
Le css
        
@supports (display: contents) {
  .table-charts.column-multiple {
    grid-template-columns: minmax(min-content, 14ch) repeat(calc(var(--y) - 1), 1fr);
    grid-template-rows: repeat(var(--scale, 100), minmax(0, .25rem)) repeat(2, minmax(min-content, 2rem));
  }

  .table-charts.column-multiple tbody th {
    grid-row: -10 / span 4;
  }

  .table-charts.column-multiple thead tr * {
    grid-row: -2;
    grid-column: 1;
  }

  .table-charts.column-multiple thead tr *:nth-of-type(2) {
    grid-column: calc(4 - var(--span)) / span var(--span);
  }

  .table-charts.column-multiple thead tr + tr * {
    font-weight: normal;
    grid-row: -3;
  }

  .table-charts.column-multiple thead tr + tr *:nth-of-type(2) {
    grid-column: 2;
  }

  .table-charts.column-multiple tr:first-child [scope="col"]:nth-child(even)::after {
    background-color: whitesmoke;
    background-image: url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff99' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E");
    background-blend-mode: exclusion;
    bottom: 4rem;
    content: "";
    mix-blend-mode: multiply;
    opacity: .25;
    position: absolute;
    transition: opacity .3s cubic-bezier(.5, 0, .5, 1);
    top: 1rem;
    width: calc((var(--width) * 2) + (var(--gap) / 2) + 1px);
    z-index: 0;
  }

  .table-charts.column-multiple tr:first-child [scope="col"]:nth-child(4)::after {
    left: calc(14ch + 1em + (((var(--width) * 2) + (var(--gap) / 2) + 1px) * 2) + (var(--gap) * 3));
  }

  .table-charts.column-multiple td {
    background-color: #e11a81;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='12' viewBox='0 0 20 12'%3E%3Cg fill-rule='evenodd'%3E%3Cg fill='%23ffffff99'%3E%3Cpath d='M9.8 12L0 2.2V.8l10 10 10-10v1.4L10.2 12h-.4zm-4 0L0 6.2V4.8L7.2 12H5.8zm8.4 0L20 6.2V4.8L12.8 12h1.4zM9.8 0l.2.2.2-.2h-.4zm-4 0L10 4.2 14.2 0h-1.4L10 2.8 7.2 0H5.8z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
  }

  /* @note Oh boy, if we could use var(--span) in selector… */
  .table-charts.column-multiple td:nth-of-type(2n + 2) {
    background-color: #0172f0;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='16' viewBox='0 0 36 72'%3E%3Cg fill-rule='evenodd'%3E%3Cg fill='%23ffffff99'%3E%3Cpath d='M2 6h12L8 18 2 6zm18 36h12l-6 12-6-12z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
  }

  @media screen and (-ms-high-contrast: active) {
    .table-charts.column-multiple td {
      background-color: Window;
    }

    .table-charts.column-multiple td:nth-of-type(2n + 2) {
      background-color: Highlight;
    }
  }
}
      

Bug dans Chromium

Il y a en ce moment un bug dans Chromium — j’ai saisi un ticket sur bugs.chromium.org — lors de l’utilisation d’une valeur décimale pour le placement sur la grille. Je ne sais pas si c’est une implication de la décimale, du calcul, de la propriété personnalisée… mais pour le moment, ça ne fonctionne pas !

Graphique en tarte

Le graphique en tarte sert pour les représentations de proportions en pourcentage. Il s’appuie sur des variables CSS, un abus outrancier de calc(), display: table-*, clip-path, mask-image, transform et un tantinet de SVG pour distinguer chaque zone. Oui, je sais rigoler. Comment qu’on s’en sert ?

  1. Sur chaque entête <th>, une variable --color permet d’attribuer, et bien… une couleur.
  2. Puis chaque cellule <td> doit contenir la valeur et son unité, ainsi qu’un attribut style pour porter quelques variables :
    1. --value correspond à la valeur en pourcentage, utile pour déterminer l’angle que doit occuper l’élément sur le cercle. Tous les points du polygon() de clip-path dépendent de cette valeur — lire la note technique après l’exemple pour le détail des calculs.
    2. --start permet de définir le point de départ de l’arc sur le cercle. Il s’agit de la somme des précédentes définitions, et est appliqué à la fonction rotate() de la propriété transform.
    3. Et enfin une série de variables booléennes valant chacune 0 ou 1 — d’après une idée de Roman Komarov dans son article "Conditions for CSS variables" — dépendent de la valeur : --lt-25, --gt-25, --lt-50… Elles permettent de faire basculer les points de leur position d’origine (50% 50%) à leur position calculée, en s’additionnant ou se soustrayant à la valeur initiale ;
  3. un pseudo-élément sur chaque cellule <td> est mis en forme de savante manière en fonction de toutes nos variables, avec notamment transform, clip-path et mask-image.
  4. Et finalement un motif est appliqué sur l’arrière-plan, afin de mieux l’associer visuellement avec la légende correspondante.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Répartition du poids des ressources pour ffoodd.fr
Ressource Proportion
HTML 2 %
CSS 2 %
JS 32 %
Json 1 %
Images 44 %
Webfonts 17 %
Autres 2 %
Un peu de trigonométrie

Dans ce graphique, chaque portion représente un arc de cercle basé sur un angle (une partie de 360 degrés). Pour définir la forme de cette portion, il faut donc placer un point sur le cercle.

Pour ce faire, je divise le cercle en quatre carrés. La position du point sur le cercle peut ainsi être calculée en utilisant les propriétés du triangle rectangle formé par :

  1. le centre du cercle,
  2. le point que nous cherchons à positionner,
  3. et le point perpendiculaire au rayon et passant par notre point cible.

Nous connaissons l’hypoténuse de ce triangle — le rayon du cercle —, et l’angle formé par l’hypoténuse et partant du centre du cercle (en ramenant la valeur sur 90 degrés, puisque le cercle est divisé en quatre secteurs carrés : si la valeur est supérieure à 25 : moins 90°, etc.) — plus un angle droit, bien entendu.

La loi des sinus

Nous pouvons donc utiliser la loi des sinus pour mesurer chaque côté, et ainsi obtenir la position du point sur le cercle. Cela implique de calculer le sinus… Fort heureusement, Stereokai a implémenté pour nous la représentation polynomiale de Taylor/Maclaurin en CSS — que j’ai implémentée sour forme d’un mixin :

        
  @mixin sin($angle) {
    --sin-term-#{$angle}-1: var(--#{$angle});
    --sin-term-#{$angle}-2: calc((var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle})) / 6);
    --sin-term-#{$angle}-3: calc((var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle})) / 120);
    --sin-term-#{$angle}-4: calc((var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle})) / 5040);
    --sin-term-#{$angle}-5: calc((var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle})) / 362880);
    --sin-#{$angle}: calc(var(--sin-term-#{$angle}-1) - var(--sin-term-#{$angle}-2) + var(--sin-term-#{$angle}-3) - var(--sin-term-#{$angle}-4) + var(--sin-term-#{$angle}-5));
  }
      

Il ne reste plus qu’à utiliser ces dimensions pour placer les points du polygône. Un vrai jeu d’enfants !

Le HTML
        
<table class="table-charts pie">
  <caption id="caption-5">Répartition du poids des ressources pour ffoodd.fr</caption>
  <thead class="sr-only">
    <tr>
      <th scope="col">Ressource</th>
      <th scope="col">Proportion</th>
    </tr>
  </thead>
  <tbody>
    <tr style="--color: #734bf9">
      <th scope="row">HTML
        <td style="--value: 2; --start: 0; ">
          2 %
        </td>
    </tr>
    <tr>[…]</tr>
  </tbody>
</table>
      
Le css
        
@supports (clip-path: polygon( 50% calc( 50% + ( var(--gt-25, 0) ) ) )) {
  .table-charts.pie {
    margin: 0 auto;
    padding-top: calc(32em - 2rem);
    position: relative;
  }

  .table-charts.pie tbody {
    --side: calc( 64em / 2 );
    --hypo: calc( 1024 / 16 / 2 );
    display: table-row;
  }

  .table-charts.pie tr {
    display: table-cell;
    transition: opacity .3s cubic-bezier(.5, 0, .5, 1);
  }

  .table-charts.pie [scope="row"] {
    padding-right: .5rem;
  }

  .table-charts.pie [scope="row"]::before {
    background: var(--color, currentColor);
    content: "";
    display: inline-block;
    height: 1rem;
    transform: translate3d(-.2rem, .1rem, 0);
    width: 1rem;
  }

  .table-charts.pie td {
    --position: calc(var(--start, 0) * .01turn);
  }

  .table-charts.pie td::after,
  .table-charts.pie td::before {
    left: 50%;
    position: absolute;
    top: 40%;
    transform-origin: center center;
  }

  .table-charts.pie td::before {
    /* L’inclinaison, pour se placer au bon endroit */
    --position: calc(var(--start, 0) * .01turn);
    --zoom: .75;
    /* L’angle représenté par la valeur : 3.6 = 360deg / 100 */
    /* Puisque nous utilisons une valeur en pourcentage */
    --part: calc( var(--value) * 3.6 );
    /* L’angle « utile » pour le calcul, nécessairement inférieur à 90deg */
    /* On soustrait donc 90deg (= ¼ × 360deg) par tranche de 25% (= ¼ × 100%, oui oui) */
    --main-angle: calc( var(--part) - ( 90 * ( var(--gt-25, 0) + var(--gt-50, 0) + var(--gt-75, 0) ) ) );
    /* L’angle principal, exprimé en radian */
    --β: calc( var(--main-angle) * 0.01745329251 );
    /* Le dernier angle en radian, par déduction puisque dans un triangle rectangle */
    --α: calc( ( 90 - var(--main-angle) ) * 0.01745329251 );
    /* La magie de Stereokai, pour obtenir le sinus de ces angles */
    --sin-term-β-1: var(--β);
    --sin-term-β-2: calc((var(--β) * var(--β) * var(--β)) / 6);
    --sin-term-β-3: calc((var(--β) * var(--β) * var(--β) * var(--β) * var(--β)) / 120);
    --sin-term-β-4: calc((var(--β) * var(--β) * var(--β) * var(--β) * var(--β) * var(--β) * var(--β)) / 5040);
    --sin-term-β-5: calc((var(--β) * var(--β) * var(--β) * var(--β) * var(--β) * var(--β) * var(--β) * var(--β) * var(--β)) / 362880);
    --sin-β: calc(var(--sin-term-β-1) - var(--sin-term-β-2) + var(--sin-term-β-3) - var(--sin-term-β-4) + var(--sin-term-β-5));
    --sin-term-α-1: var(--α);
    --sin-term-α-2: calc((var(--α) * var(--α) * var(--α)) / 6);
    --sin-term-α-3: calc((var(--α) * var(--α) * var(--α) * var(--α) * var(--α)) / 120);
    --sin-term-α-4: calc((var(--α) * var(--α) * var(--α) * var(--α) * var(--α) * var(--α) * var(--α)) / 5040);
    --sin-term-α-5: calc((var(--α) * var(--α) * var(--α) * var(--α) * var(--α) * var(--α) * var(--α) * var(--α) * var(--α)) / 362880);
    --sin-α: calc(var(--sin-term-α-1) - var(--sin-term-α-2) + var(--sin-term-α-3) - var(--sin-term-α-4) + var(--sin-term-α-5));
    /* La longueur des côtés opposés à chaque angle */
    /* L’hypothénuse = le rayon, sans unité ↑ */
    --B: calc( var(--hypo) * var(--sin-β) );
    --A: calc( var(--hypo) * var(--sin-α) );
    /* Et enfin, la position, exprimée en pourcentage de l’hypothénuse, */
    /* puis divisée par deux pour s’inscrire dans un quart du cercle */
    --pos-B: calc( ( var(--B) * 100 / var(--hypo) ) / 2 );
    --pos-A: calc( ( var(--A) * 100 / var(--hypo) ) / 2 );
    background-color: var(--color, currentColor);
    clip-path: polygon(
      50% 50%,
      50% 0%,
      100% 0%,
      calc( 50% + ( var(--pos-B) * 1% * var(--lt-25, 1) ) + ( var(--gt-25, 0) * 50% ) ) calc( 50% - ( var(--pos-A) * 1% * var(--lt-25, 1) ) ),
      calc( 50% + ( var(--gt-25, 0) * 50% ) )                                           calc( 50% + ( var(--gt-25, 0) * 50% ) ),
      calc( 50% + ( var(--pos-A) * 1% * var(--lt-50, 1) ) + ( var(--gt-50, 0) * 50% ) ) calc( 50% + ( var(--pos-B) * 1% * var(--lt-50, 1) ) + ( var(--gt-50, 0) * 50% ) ),
      calc( 50% - ( var(--gt-50, 0) * 50% ) )                                           calc( 50% + ( var(--gt-50, 0) * 50% ) ),
      calc( 50% - ( var(--pos-B) * 1% * var(--lt-75, 1) ) - ( var(--gt-75, 0) * 50% ) ) calc( 50% + ( var(--pos-A) * 1% * var(--lt-75, 1) ) ),
      calc( 50% - ( var(--gt-75, 0) * 50% ) )                                           calc( 50% - ( var(--gt-75, 0) * 50% ) ),
      calc( 50% - ( var(--pos-A) * 1% * var(--gt-75, 0) ) )                             calc( 50% - ( var(--pos-B) * 1% * var(--gt-75, 0) ) ),
      50% 50%
    );
    content: '';
    height: var(--side);
    left: 50%;
    mask-image: radial-gradient(
      circle at center,
      white 0%,
      white calc(var(--side) / 2),
      transparent calc(var(--side) / 2)
    );
    position: absolute;
    top: 40%;
    transform:
      translate3d(-50%, -50%, 0)
      rotate( var(--position) )
      scale( var(--zoom) );
    transform-origin: center center;
    transition: transform .2s cubic-bezier(.5, 0, .5, 1);
    width: var(--side);
  }

  .table-charts.pie tr:hover td::before {
    --zoom: .8;
  }

  .table-charts.pie tr:nth-child(2n + 2) *::before {
    background-image: url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff99' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E");
  }

  .table-charts.pie tbody:hover tr {
    opacity: .75;
  }

  .table-charts.pie tbody:hover tr:hover {
    opacity: 1;
  }

  @media screen and (-ms-high-contrast: active) {
    .table-charts.pie tr *::before {
      background-color: Window;
    }

    .table-charts.pie tr:nth-of-type(odd) *::before {
      background-color: WindowText;
    }
  }
}
      
Le calcul tordu

Les positions du polygone

L’utilisation de variables pseudo-booléennes rend ce calcul pseudo-algorithmiques. Démarrons par un pré-requis essentiel : le polygone étant un tracé fermé et CSS n’étant pas magique, les points doivent pré-exister. Spoiler, il nous faut onze points :

  1. L’axe initial, du centre vers le milieu en haut : 50% 50% et 50% 0%.
  2. Un point pour chaque angle aux extrémités : le premier est fixe, à 100% 0% (en haut à droite) — puis chacun des autres angles a deux états, atteint ou non. Quelques détails :
    • Par exemple le point en bas à droite concerne les valeurs entre 25% et 50% : si la valeur est inférieure à 25%, il doit être au centre (pour ne pas gêner le tracé), et dans le cas contraire être dans son coin. ce qui s’exprime ainsi : calc( 50% + ( var(--gt-25, 0) * 50% ) ) calc( 50% + ( var(--gt-25, 0) * 50% ) )
      Ainsi la valeur calculée sera 50% 50% si --gt-25 vaut 0, et 100% 100% si --gt-25 vaut 1.
    • De plus, chaque angle a sa coordonnée cible : 100% 100% pour en bas à droite, 0% 100% pour en bas à gauche, 0% 0% pour en en haut à gauche. Il faut donc tantôt soustraire et tantôt ajouter 50% à la valeur intitiale 50% 50% pour basculer sur le bon point.
  3. Un point pour chaque position possible par quart de cercle, correspondant à chaque tranche de 25%. À l’instar des points aux angles, ces points doivent être au centre s’ils ne sont pas utilisés. C’est là qu’on rigole le plus :
    • on part de 50%, auxquels on ajoute ou soustrait la suite du calcul ;
    • on utilise enfin la position calculée — --pos-A ou --pos-B selon le cas — qu’on convertit en pourcentages à l’aide de * 1%, et qu’on rend inerte si la valeur est inférieure à la tranche concernée grâce à * var(--lt-25, 1), par exemple.
      Remarquez le seconde valeur dans var(), qui est la valeur par défaut si la variable n’est pas définie. Cool, non ?
    • et finalement lorsque la tranche est dépassée, le point bascule vers 0% ou 100% selon le cas.
  4. Et enfin, on referme le tracé en revenant au centre du cercle, à 50% 50%.

C’est tout !

Donut

Sur l’élément <table>, on ajoute une variable --offset qui permet de déterminer la dimension du trou du donut, généré à l’aide de mask-image et radial-gradient(). Ana Tudor a réalisé de très nombreux exemples d’utilisation des mask-* sur CodePen, jetez-y un œil !

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Répartition du poids des ressources pour ffoodd.fr
Ressource Proportion
HTML 2 %
CSS 2 %
JS 32 %
Json 1 %
Images 44 %
Webfonts 17 %
Autres 2 %
Le css
        
.table-charts.donut {
  mask-image: radial-gradient(
    circle at 50% calc(50% - 2rem),
    transparent 0%,
    transparent var(--offset),
    white calc(var(--offset) + 1px),
    white 100%
  );
}

.table-charts.donut td::after {
  --away: calc( var(--side) / 2 - 2.5rem );
}
      

Dégradé conique

L’utilisation de conic-gradient() est prometteuse pour ce cas précis. Vous en trouverez des exemples réalisés par Ana Tudor ou Léa Verou — qui a carrément rédigé la spécification, et conçu un polyfill. Cependant le support limité aux navigateurs basés sur WebKit est déprimant, et pose tout de même quelques questions en matière d’accessibilité puisqu’on ne peut pas affecter un motif à chaque couleur du dégradé conique.

Polaire

Pour cette déclinaison, on ne change presque rien : seulement la variable --zoom et son implication dans la mise à l’échelle des portions à l’aide de scale().

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Répartition du poids des ressources pour ffoodd.fr
Ressource Proportion
HTML 2 %
CSS 2 %
JS 32 %
Json 1 %
Images 44 %
Webfonts 17 %
Autres 2 %
Le css
        
.table-charts.polar td::before {
  --zoom: 50;
  transform:
    translate3d( -50%, -50%, 0 )
    rotate( var(--position) )
    scale( calc( ( var(--zoom) + ( var(--value) / ( 100 / var(--zoom) ) ) ) / 100 ) );
}

.table-charts.polar tr:hover td::before {
  --zoom: 50;
}

.table-charts.polar tbody:hover tr {
  opacity: .5;
}
      

Graphique radar

Celui-ci est plutôt amusant. On définit quelques variables CSS sur le tableau  : l’échelle (et les paliers), le nombre d’éléments, et les valeurs.

Côté CSS, ça se complique :

Et voilà, c’est tout !

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Niveau d’intérêt par domaine, sur 20
Accessibilité Référencement Performance Compatibilité Sécurité Qualité de code Test
14 11 13 16 10 12 4
Le HTML
        
<table class="table-charts radar" id="radar" style="--scale: 20; --step: 5; --items: 7; --1: 14; --2: 11; --3: 13; --4: 16; --5: 10; --6: 12; --7: 4; --8: var(--1);">
  <caption id="caption-9">Niveau d’intérêt par domaine, sur 20</caption>
  <thead>
    <tr>
      <th scope="col">Accessibilité</th>
      <th scope="col">Référencement</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><span>14</span></td>
      <td><span>11</span></td>
    </tr>
  </tbody>
</table>
      
Le css
        
@supports(clip-path: polygon(calc( 0% 0%, 100% - ( var(--1) * 100% / var(--scale) ) ) 100%, 100% 100%)) {
  .table-charts[class*="radar"] {
    --radius: 12.8rem;
    --unitless-radius: calc( 1024 / 16 / 5 );
    --size: calc( var(--radius) / var(--scale) );
    --part: calc( 360deg / var(--items) );
    background-image:
      repeating-radial-gradient(
        circle at 50%,
        rgba(0, 0, 0, .2),
        rgba(0, 0, 0, .2) 2px,
        transparent 2px,
        transparent calc(var(--size) * var(--step))
      ),
      repeating-radial-gradient(
        circle at 50%,
        rgba(0, 0, 0, .1),
        rgba(0, 0, 0, .1) 2px,
        transparent 2px,
        transparent var(--size)
      );
    border: 2px solid;
    border-radius: 50%;
    contain: layout style;
    counter-reset: scale var(--scale);
    height: calc( var(--radius) * 2 );
    margin: 6rem auto 12rem;
    overflow: visible;
    position: relative;
    width: calc( var(--radius) * 2 );
  }

  .table-charts[class*="radar"] caption {
    background: none;
    bottom: -10rem;
    position: absolute;
  }

  .table-charts[class*="radar"] [scope="col"] {
    --away: calc( (var(--radius) * -1) - 50% );
    left: 50%;
    margin: 0;
    padding: 0 1rem;
    position: absolute;
    top: 50%;
    transform:
      translate3d(-50%, -50%, 0)
      rotate( calc(var(--part) * var(--index, 1)) )
      translate( var(--away) )
      rotate( calc(var(--part) * var(--index, 1) * -1) );
  }

  .table-charts[class*="radar"] td:nth-of-type(2),
  .table-charts[class*="radar"] [scope="col"]:nth-of-type(2) {
    --index: 2;
  }

  .table-charts[class*="radar"] td {
    --skew: calc( 90deg - var(--part) );
    border-bottom: 1px solid blueviolet;
    height: 50%;
    left: 0;
    margin: 0;
    position: absolute;
    top: 0;
    transform:
      rotate( calc(var(--part) * var(--index, 1)) )
      skew( var(--skew) );
    transform-origin: 100% 100%;
    width: 50%;
  }

  .table-charts[class*="radar"] span {
    --opposite: calc( 180 - (90 + (90 - (360 / var(--items)))) );
    --angle: calc( var(--opposite) * 0.01745329251 );
    --sin-term-angle-1: var(--angle);
    --sin-term-angle-2: calc((var(--angle) * var(--angle) * var(--angle)) / 6);
    --sin-term-angle-3: calc((var(--angle) * var(--angle) * var(--angle) * var(--angle) * var(--angle)) / 120);
    --sin-term-angle-4: calc((var(--angle) * var(--angle) * var(--angle) * var(--angle) * var(--angle) * var(--angle) * var(--angle)) / 5040);
    --sin-term-angle-5: calc((var(--angle) * var(--angle) * var(--angle) * var(--angle) * var(--angle) * var(--angle) * var(--angle) * var(--angle) * var(--angle)) / 362880);
    --sin-angle: calc(var(--sin-term-angle-1) - var(--sin-term-angle-2) + var(--sin-term-angle-3) - var(--sin-term-angle-4) + var(--sin-term-angle-5));
    --hypo: calc( var(--unitless-radius) / var(--sin-angle) );
    --ratio: calc( var(--hypo) / var(--unitless-radius) );
    background: linear-gradient(
      to top left,
      blueviolet 10%,
      darkblue 75%
    );
    filter: drop-shadow( 0 0 1rem indigo );
    height: 100%;
    position: absolute;
    width: 100%;
  }

  .table-charts[class*="radar"] td:nth-of-type(2) span {
    --pos: calc( 100% - (var(--3) * 100% / (var(--scale) * var(--ratio) ) ) );
    clip-path: polygon(
      100% var(--pos),
      calc( 100% - ( var(--2) * 100% / var(--scale) ) ) 100%,
      100% 100%
    );
  }

  .table-charts[class*="radar"] td::after,
  .table-charts[class*="radar"] td::before {
    display: none;
  }

  .table-charts.radar [scope="col"]::after {
    color: rebeccapurple;
    display: block;
    font-size: small;
    font-weight: 400;
  }

  .table-charts.radar [scope="col"]:nth-child(2)::after {
    counter-reset: value var(--2);
    content: counter(value) "\A0/\A0" counter(scale);
  }
}
      

Bug dans Chromium

Il y a en ce moment un bug dans Chromium — j’ai saisi un ticket sur bugs.chromium.org — lors de l’utilisation de la propriété border-spacing sur le tableau : cela empêche Chrome de définir les dimensions du tableau… Pour les utilisateurs de Chrome, utilisez l’inspecteur pour décocher cette propriété sur la balise <table> de ces exemples !

Radars superposés

Très peu de changements par rapport à la version précédente :

  1. l’élément <table> ne porte plus les valeurs, mais dispose d’une nouvelle variable --areas pour indiquer le nombre de lignes dans le tableau ;
  2. en revanche on multiplie le nombre de lignes dans le corps du tableau :
    • chacune porte plusieurs variables : --color puis les valeurs — --1, etc. ;
    • et contient plusieurs cellules : une d’entête de ligne <th scope="row"> et des cellules de données <td> ;
  3. le reste est relativement commun désormais — si vous avez parcourus les exmples précédents :
    1. une couleur pour chaque ligne, présentée sur les cellules d’entête et servant d’arrière-plan aux cellules de données ;
    2. un effet distinctif au survol de chaque ligne : les valeurs apparaissent textuellement au survol, et la ligne survolée est mise en exergue. Afin de ne pas priver les utilisateurs n’ayant pas de pointeur doué pour le survol, cet effet est une amélioration progressive basé sur la media query @media (hover: hover) { … }.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Niveau d’intérêt par domaine, sur 20
Accessibilité Référencement Performance Compatibilité Sécurité Qualité de code Test
Gaël 14 11 13 16 14 10 4
Luc 18 10 11 16 10 12 11
Le HTML
        
<table class="table-charts radar-multiple" id="radar-multiple" style="--scale: 20; --step: 5; --items: 7; --areas: 2;">
  <caption id="caption-9">Niveau d’intérêt par domaine, sur 20</caption>
  <thead>
    <tr>
      <th scope="col">Accessibilité</th>
      <th scope="col">Référencement</th>
    </tr>
  </thead>
  <tbody>
    <tr style="--color: #734bf9; --1: 14; --2: 11; --3: 13; --4: 16; --5: 14; --6: 10; --7: 4; --8: var(--1);">
      <th scope="row">Gaël</th>
      <td><span>14</span></td>
    </tr>
    <tr style="-color: #e11a81; --1: 18; --2: 10; --3: 11; --4: 16; --5: 10; --6: 12; --7: 11; --8: var(--1);">
      <th scope="row">Luc</th>
      <td><span>18</span></td>
    </tr>
  </tbody>
</table>
      
Le css
        
.table-charts.radar-multiple {
  margin-bottom: 12rem;
}

.table-charts.radar-multiple tbody {
  columns: var(--areas);
  vertical-align: bottom;
}

.table-charts.radar-multiple [scope="row"] {
  bottom: -8rem;
  height: 2rem;
  left: 1rem;
  position: absolute;
}

.table-charts.radar-multiple [scope="row"]::before {
  background: var(--color, currentColor);
  content: "";
  display: inline-block;
  height: 1rem;
  margin-right: .25rem;
  transform: translate3d(0, .1rem, 0);
  width: 1rem;
}

/* Pour obtenir une deuxième zone : */
.table-charts.radar-multiple tr:nth-child(2) [scope="row"] {
  left: calc( 1rem + (100% / var(--areas)) * 1);
}

.table-charts.radar-multiple td {
  align-items: flex-end;
  border-color: var(--color, currentColor);
  display: flex;
  justify-content: flex-end;
  opacity: .5;
  pointer-events: none;
  transition: opacity .2s cubic-bezier(.5, 0, .5, 1);
  will-change: opacity;
  z-index: 0;
}

.table-charts.radar-multiple td::after {
  color: var(--color, currentColor);
  display: block;
  font-size: small;
  font-weight: 700;
  text-indent: -.5rem;
  transform:
    skew( calc( var(--skew) * -1 ) )
    rotate( calc( var(--part) * var(--index, 1) * -1 ) );
  transform-origin: 0 0;
  transition: inherit;
  width: 100%;
  will-change: inherit;
  white-space: nowrap;
}

.table-charts.radar-multiple td:nth-of-type(2)::after {
  counter-reset: value var(--2);
  content: counter(value);
  width: calc( var(--2) * 100% / var(--scale) );
}

.table-charts.radar-multiple span {
  background: var(--color, currentColor);
  pointer-events: auto;
}

@supports ( mask-image: url() ) {
  .table-charts.radar-multiple span {
    mask-image: radial-gradient(circle at bottom right, rgba(0,0,0,1), rgba(0,0,0,.5));
  }
}

@media (hover: hover) {
  .table-charts.radar-multiple td {
    opacity: .25;
  }

  .table-charts.radar-multiple td::after {
    opacity: 0;
  }

  .table-charts.radar-multiple tr:hover td {
    opacity: 1;
    z-index: 1;
  }

  .table-charts.radar-multiple tr:hover td::after {
    opacity: inherit;
  }
}