card-12-inverted-kpi-row
chart-05-tile-heatmap
Heatmap en tuiles arrondies, rampe achromatique + marque de catégorie
Lire une matrice canal × période d'un seul coup d'œil, en faisant cohabiter DEUX échelles dans la même grille sans qu'elles se mélangent : une rampe de gris qui encode l'intensité (le noir est le maximum), et une couleur d'accent qui n'est PAS un cran de cette rampe mais une marque de catégorie posée par-dessus. Les cellules sont des tuiles arrondies largement espacées — jamais un damier jointif.
chartwebslide605×431px16 benchmarks
Le fragment RÉEL, dans son propre document, sur le sol de sa charte — pas une image. Ce que vous voyez est exactement ce que vous collerez.
Employer quand
Matrice de quatre à six lignes sur quatre à huit colonnes dont on veut la SILHOUETTE et non les valeurs : performance par canal et par semaine, densité d'activité, couverture. Les libellés de lignes vivent hors de la grille, à gauche, sans filet de séparation.
Éviter quand
Le lecteur doit relire une valeur précise (une heatmap ne se lit pas au chiffre près — il faut un tableau), la matrice dépasse huit colonnes (les tuiles deviennent des traits et le rayon n'a plus de sens), ou l'intensité est déjà encodée par une couleur : deux échelles chromatiques dans une même grille sont indémêlables, et c'est exactement ce que l'achromatisme de la rampe évite ici.
Notes
Le pattern ne tient qu'à une condition, et elle est mesurée : la rampe d'intensité est ACHROMATIQUE (écart max-min des canaux RVB ≤ 6 sur chacun de ses cinq crans) et l'accent ne l'est pas (écart ≥ 60). C'est ce qui permet à deux échelles de cohabiter dans une même grille sans devenir indémêlables — l'œil range les gris sur un axe et le vert dans une autre catégorie, sans qu'on ait rien à lui expliquer. Poser un second accent coloré, ou teinter la rampe, casse le pattern quel que soit le soin apporté au reste. La monotonie de la rampe est vérifiée à part : cinq gris achromatiques mais désordonnés passeraient le premier contrôle et ne coderaient plus rien. Deux assertions, deux propriétés distinctes. Le rayon est encadré des DEUX côtés : au moins un tiers de la hauteur de cellule (sinon c'est un damier, et la matrice se met à ressembler à un tableau) et au plus la moitié (sinon ce sont des pilules, et la lecture en colonnes disparaît). C'est le même raisonnement que sur la barre de menu de ref-17 : une borne seule laisse toujours passer l'autre faute. L'attribut `data-ramp` n'est là que pour la MESURE : il nomme le cran de rampe d'une cellule témoin de chaque niveau, ce qui permet d'écrire les assertions sur des couleurs calculées plutôt que sur les tokens déclarés. Un système thémé qui casserait sa rampe sortirait rouge ici et nulle part ailleurs.
Mots-clés
Benchmarks — la géométrie, en assertions vérifiables
Rejouables par node bin/check.mjs chart-05-tile-heatmap : le pattern se prouve, il ne se croit pas.
| assertion | mesure | attendu |
|---|---|---|
| les cellules sont des TUILES, pas un damier : le rayon vaut au moins un tiers de leur hauteur | num('.vl-heat__cell','borderTopLeftRadius') / box('.vl-heat__cell').h | ≥ 0.33 |
| … et jamais des pilules : le rayon reste sous la demi-hauteur | num('.vl-heat__cell','borderTopLeftRadius') / box('.vl-heat__cell').h | ≤ 0.5 |
| cellule nettement paysage | box('.vl-heat__cell').w / box('.vl-heat__cell').h | ≥ 1.2 |
| les tuiles respirent : la gouttière vaut au moins 12 % de la largeur d'une cellule | num('.vl-heat__grid','columnGap') / box('.vl-heat__cell').w | ≥ 0.12 |
| les libellés de lignes sont HORS de la grille de tuiles | box('.vl-heat__cell').left - box('.vl-heat__row').right | ≥ 8 |
| aucun filet de séparation entre les lignes | num('.vl-heat__row','borderBottomWidth') | 0 ± 0 |
| LA RAMPE EST ACHROMATIQUE : aucun de ses cinq crans n'a de saturation | ['1','2','3','4','5'].reduce((m, n) => { const c = parseRgb(cs('.vl-heat__cell[data-ramp="' + n + '"]','backgroundColor')); return Math.max(m, Math.max(...c) - Math.min(...c)); }, 0) | ≤ 6 |
| L'ACCENT N'EST PAS UN CRAN DE LA RAMPE : il est franchement coloré | (() => { const c = parseRgb(cs('.vl-heat__cell[data-v="on"]','backgroundColor')); return Math.max(...c) - Math.min(...c); })() | ≥ 60 |
| la rampe est MONOTONE : chaque cran est plus sombre que le précédent | (() => { const l = ['1','2','3','4','5'].map((n) => lum(parseRgb(cs('.vl-heat__cell[data-ramp="' + n + '"]','backgroundColor')))); return l.every((v, i) => i === 0 || v < l[i - 1]) ? 1 : 0; })() | 1 |
| l'accent reste minoritaire : au plus un quart des cellules le portent | document.querySelectorAll('.vl-heat__cell[data-v="on"]').length / document.querySelectorAll('.vl-heat__cell').length | ≤ 0.28 |
| le titre et son sous-titre sont collés : moins d'un demi-corps de sous-titre les sépare | (box('.vl-heat__sub').top - box('.vl-heat__title').bottom) / num('.vl-heat__sub','fontSize') | ≤ 0.5 |
| les outils sont poussés au bord droit du module | W - num('.vl-heat','paddingRight') - box('.vl-heat__tools').right | 0 ± 1.5 |
| saut typographique titre / libellé de ligne ≥ 1,4 | num('.vl-heat__title','fontSize') / num('.vl-heat__row','fontSize') | ≥ 1.4 |
| contraste des libellés de lignes ≥ 4,5 | contrast('.vl-heat__row') | ≥ 4.5 |
| contraste du sous-titre ≥ 4,5 | contrast('.vl-heat__sub') | ≥ 4.5 |
| aucun débordement du module | overflow('.vl-heat') | ≤ 0 |
Le code
patterns/chart-05-tile-heatmap.html
Télécharger<!-- chart-05-tile-heatmap — une matrice canal × période lue à la silhouette.
Couleurs via --vl-* uniquement.
DEUX ÉCHELLES DANS UNE MÊME GRILLE, et elles ne se mélangent pas :
· `data-ramp` = l'INTENSITÉ, sur une rampe strictement achromatique (le noir est le max) ;
· `data-v="on"` = une MARQUE DE CATÉGORIE, franchement colorée, HORS rampe.
C'est l'achromatisme de la première qui rend la seconde lisible sans légende. Teinter la
rampe, ou poser un second accent, casse le pattern quel que soit le soin du reste.
`data-ramp` sert aussi à la MESURE : il nomme le cran de chaque cellule, ce qui permet
d'écrire les benchmarks sur des couleurs calculées et non sur les tokens déclarés. -->
<section class="vl-heat">
<div class="vl-heat__head">
<div>
<h2 class="vl-heat__title">Orders</h2>
<p class="vl-heat__sub">Based on social media</p>
</div>
<div class="vl-heat__tools">
<span class="vl-heat__chip">Monthly</span>
<span class="vl-heat__btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 8h16M4 16h16"/><circle cx="9" cy="8" r="2.2"/><circle cx="15" cy="16" r="2.2"/></svg>
</span>
</div>
</div>
<div class="vl-heat__grid">
<span class="vl-heat__row">Facebook</span>
<span class="vl-heat__cell" data-ramp="1"></span>
<span class="vl-heat__cell" data-ramp="1"></span>
<span class="vl-heat__cell" data-v="on"></span>
<span class="vl-heat__cell" data-ramp="1"></span>
<span class="vl-heat__row">Instagram</span>
<span class="vl-heat__cell" data-ramp="1"></span>
<span class="vl-heat__cell" data-v="on"></span>
<span class="vl-heat__cell" data-ramp="5"></span>
<span class="vl-heat__cell" data-ramp="5"></span>
<span class="vl-heat__row">Tiktok</span>
<span class="vl-heat__cell" data-v="on"></span>
<span class="vl-heat__cell" data-v="on"></span>
<span class="vl-heat__cell" data-ramp="3"></span>
<span class="vl-heat__cell" data-ramp="4"></span>
<span class="vl-heat__row">Whatsapp</span>
<span class="vl-heat__cell" data-ramp="2"></span>
<span class="vl-heat__cell" data-ramp="2"></span>
<span class="vl-heat__cell" data-ramp="3"></span>
<span class="vl-heat__cell" data-ramp="5"></span>
</div>
</section>
<style>
.vl-heat {
width: 605px; height: 431px;
box-sizing: border-box;
padding: 28px 30px;
background: var(--vl-module);
color: var(--vl-ink);
border-radius: var(--vl-radius-module, 29px);
display: flex; flex-direction: column;
overflow: hidden;
font-family: Montserrat, Poppins, Inter, "Helvetica Neue", Arial, sans-serif;
}
.vl-heat__head { display: flex; align-items: flex-start; gap: 14px; }
.vl-heat__title { margin: 0; font-size: 27px; font-weight: 500; letter-spacing: -0.01em; }
.vl-heat__sub { margin: 4px 0 0; font-size: 16px; color: var(--vl-ink-soft); }
.vl-heat__tools { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.vl-heat__chip {
height: 46px; padding: 0 22px; border-radius: var(--vl-radius-pill, 999px);
background: var(--vl-app); display: flex; align-items: center;
font-size: 16px; font-weight: 500;
}
.vl-heat__btn {
width: 46px; height: 46px; border-radius: 50%;
background: var(--vl-app); display: flex; align-items: center; justify-content: center;
}
.vl-heat__btn svg { width: 20px; height: 20px; }
/* La colonne de libellés est une colonne de la MÊME grille, mais sans tuile : les noms de
lignes vivent hors du damier, sans filet ni fond. Un filet de séparation ramènerait la
matrice à un tableau, ce qu'elle n'est justement pas. */
.vl-heat__grid {
flex: 1 1 auto; min-height: 0;
display: grid;
grid-template-columns: 150px repeat(4, minmax(0, 1fr));
gap: 18px 16px;
padding-top: 34px;
}
.vl-heat__row { display: flex; align-items: center; font-size: 17px; }
/* Rayon encadré des DEUX côtés : au moins un tiers de la hauteur de cellule (en dessous,
c'est un damier), au plus la moitié (au-delà, ce sont des pilules et la lecture en
colonnes disparaît). Une borne seule laisse toujours passer l'autre faute. */
.vl-heat__cell { border-radius: var(--vl-radius-tile, 20px); background: var(--vl-ramp-1); }
.vl-heat__cell[data-ramp="2"] { background: var(--vl-ramp-2); }
.vl-heat__cell[data-ramp="3"] { background: var(--vl-ramp-3); }
.vl-heat__cell[data-ramp="4"] { background: var(--vl-ramp-4); }
.vl-heat__cell[data-ramp="5"] { background: var(--vl-ramp-5); }
/* Hors rampe. Ce n'est pas « un gris de plus », c'est une autre information. */
.vl-heat__cell[data-v="on"] { background: var(--vl-accent); }
</style>
systems/ref-18-lime-sales-dashboard.json
Télécharger:root {
--vl-app: #FEFEFE;
--vl-module: #F2F2F2;
--vl-module-warm: #FBF9EC;
--vl-invert: #000000;
--vl-accent: #5FE85C;
--vl-ink: #0B0B0B;
--vl-ink-soft: #6B6B6B;
--vl-on-invert: #FFFFFF;
--vl-on-invert-soft: rgba(255,255,255,0.66);
--vl-hairline: #E6E6E6;
--vl-ramp-1: #E3E3E3;
--vl-ramp-2: #CCCCCC;
--vl-ramp-3: #999999;
--vl-ramp-4: #666666;
--vl-ramp-5: #000000;
--vl-radius-module: 29px;
--vl-radius-tile: 20px;
--vl-radius-pill: 999px;
--vl-gutter: 16px;
--vl-gutter-row: 11px;
--vl-rail-w: 95px;
--vl-margin: 30px;
--vl-header-h: 69px;
}
les métadonnées — patterns/chart-05-tile-heatmap.json (télécharger)
{
"name": "Heatmap en tuiles arrondies, rampe achromatique + marque de catégorie",
"family": "chart",
"ref": "ref-18-lime-sales-dashboard",
"intent": "Lire une matrice canal × période d'un seul coup d'œil, en faisant cohabiter DEUX échelles dans la même grille sans qu'elles se mélangent : une rampe de gris qui encode l'intensité (le noir est le maximum), et une couleur d'accent qui n'est PAS un cran de cette rampe mais une marque de catégorie posée par-dessus. Les cellules sont des tuiles arrondies largement espacées — jamais un damier jointif.",
"when_to_use": "Matrice de quatre à six lignes sur quatre à huit colonnes dont on veut la SILHOUETTE et non les valeurs : performance par canal et par semaine, densité d'activité, couverture. Les libellés de lignes vivent hors de la grille, à gauche, sans filet de séparation.",
"avoid_when": "Le lecteur doit relire une valeur précise (une heatmap ne se lit pas au chiffre près — il faut un tableau), la matrice dépasse huit colonnes (les tuiles deviennent des traits et le rayon n'a plus de sens), ou l'intensité est déjà encodée par une couleur : deux échelles chromatiques dans une même grille sont indémêlables, et c'est exactement ce que l'achromatisme de la rampe évite ici.",
"media": [
"web",
"slide"
],
"tags": [
"heatmap",
"matrice",
"grille",
"intensite",
"rampe",
"canal",
"dashboard",
"tuile",
"categorie",
"accent",
"achromatique",
"densite"
],
"vars": [
{
"name": "--vl-module",
"role": "aplat du module qui porte la matrice",
"default": "#F2F2F2"
},
{
"name": "--vl-app",
"role": "aplat des pilules d'outil du module",
"default": "#FEFEFE"
},
{
"name": "--vl-ramp-1",
"role": "cran 1 de la rampe — le plus faible",
"default": "#E3E3E3"
},
{
"name": "--vl-ramp-2",
"role": "cran 2",
"default": "#CCCCCC"
},
{
"name": "--vl-ramp-3",
"role": "cran 3",
"default": "#999999"
},
{
"name": "--vl-ramp-4",
"role": "cran 4",
"default": "#666666"
},
{
"name": "--vl-ramp-5",
"role": "cran 5 — le maximum de l'échelle d'intensité",
"default": "#000000"
},
{
"name": "--vl-accent",
"role": "marque de CATÉGORIE — hors rampe, jamais un cran de plus",
"default": "#5FE85C"
},
{
"name": "--vl-ink",
"role": "titre et libellés de lignes",
"default": "#0B0B0B"
},
{
"name": "--vl-ink-soft",
"role": "sous-titre du module",
"default": "#6B6B6B"
},
{
"name": "--vl-radius-tile",
"role": "rayon d'une cellule",
"default": "20px"
}
],
"slots": [
"title",
"sub",
"tools",
"rows",
"cells"
],
"geometry": {
"root": ".vl-heat",
"frame": [
605,
431
],
"ratios": {
"label_col_over_width": 0.248,
"cell_gap_x_over_width": 0.026,
"cell_gap_y_over_width": 0.023,
"cell_ratio_w_over_h": 1.67,
"radius_over_cell_height": 0.38
},
"type_px": {
"title": 27,
"sub": 16,
"row": 17
},
"pad_ratio": {
"top": 0.065,
"x": 0.05,
"bottom": 0.065
}
},
"benchmarks": [
{
"name": "les cellules sont des TUILES, pas un damier : le rayon vaut au moins un tiers de leur hauteur",
"measure": "num('.vl-heat__cell','borderTopLeftRadius') / box('.vl-heat__cell').h",
"min": 0.33
},
{
"name": "… et jamais des pilules : le rayon reste sous la demi-hauteur",
"measure": "num('.vl-heat__cell','borderTopLeftRadius') / box('.vl-heat__cell').h",
"max": 0.5
},
{
"name": "cellule nettement paysage",
"measure": "box('.vl-heat__cell').w / box('.vl-heat__cell').h",
"min": 1.2
},
{
"name": "les tuiles respirent : la gouttière vaut au moins 12 % de la largeur d'une cellule",
"measure": "num('.vl-heat__grid','columnGap') / box('.vl-heat__cell').w",
"min": 0.12
},
{
"name": "les libellés de lignes sont HORS de la grille de tuiles",
"measure": "box('.vl-heat__cell').left - box('.vl-heat__row').right",
"min": 8
},
{
"name": "aucun filet de séparation entre les lignes",
"measure": "num('.vl-heat__row','borderBottomWidth')",
"expect": 0,
"tol": 0
},
{
"name": "LA RAMPE EST ACHROMATIQUE : aucun de ses cinq crans n'a de saturation",
"measure": "['1','2','3','4','5'].reduce((m, n) => { const c = parseRgb(cs('.vl-heat__cell[data-ramp=\"' + n + '\"]','backgroundColor')); return Math.max(m, Math.max(...c) - Math.min(...c)); }, 0)",
"max": 6
},
{
"name": "L'ACCENT N'EST PAS UN CRAN DE LA RAMPE : il est franchement coloré",
"measure": "(() => { const c = parseRgb(cs('.vl-heat__cell[data-v=\"on\"]','backgroundColor')); return Math.max(...c) - Math.min(...c); })()",
"min": 60
},
{
"name": "la rampe est MONOTONE : chaque cran est plus sombre que le précédent",
"measure": "(() => { const l = ['1','2','3','4','5'].map((n) => lum(parseRgb(cs('.vl-heat__cell[data-ramp=\"' + n + '\"]','backgroundColor')))); return l.every((v, i) => i === 0 || v < l[i - 1]) ? 1 : 0; })()",
"expect": 1
},
{
"name": "l'accent reste minoritaire : au plus un quart des cellules le portent",
"measure": "document.querySelectorAll('.vl-heat__cell[data-v=\"on\"]').length / document.querySelectorAll('.vl-heat__cell').length",
"max": 0.28
},
{
"name": "le titre et son sous-titre sont collés : moins d'un demi-corps de sous-titre les sépare",
"measure": "(box('.vl-heat__sub').top - box('.vl-heat__title').bottom) / num('.vl-heat__sub','fontSize')",
"max": 0.5
},
{
"name": "les outils sont poussés au bord droit du module",
"measure": "W - num('.vl-heat','paddingRight') - box('.vl-heat__tools').right",
"expect": 0,
"tol": 1.5
},
{
"name": "saut typographique titre / libellé de ligne ≥ 1,4",
"measure": "num('.vl-heat__title','fontSize') / num('.vl-heat__row','fontSize')",
"min": 1.4
},
{
"name": "contraste des libellés de lignes ≥ 4,5",
"measure": "contrast('.vl-heat__row')",
"min": 4.5
},
{
"name": "contraste du sous-titre ≥ 4,5",
"measure": "contrast('.vl-heat__sub')",
"min": 4.5
},
{
"name": "aucun débordement du module",
"measure": "overflow('.vl-heat')",
"max": 0
}
],
"notes": "Le pattern ne tient qu'à une condition, et elle est mesurée : la rampe d'intensité est ACHROMATIQUE (écart max-min des canaux RVB ≤ 6 sur chacun de ses cinq crans) et l'accent ne l'est pas (écart ≥ 60). C'est ce qui permet à deux échelles de cohabiter dans une même grille sans devenir indémêlables — l'œil range les gris sur un axe et le vert dans une autre catégorie, sans qu'on ait rien à lui expliquer. Poser un second accent coloré, ou teinter la rampe, casse le pattern quel que soit le soin apporté au reste.\n\nLa monotonie de la rampe est vérifiée à part : cinq gris achromatiques mais désordonnés passeraient le premier contrôle et ne coderaient plus rien. Deux assertions, deux propriétés distinctes.\n\nLe rayon est encadré des DEUX côtés : au moins un tiers de la hauteur de cellule (sinon c'est un damier, et la matrice se met à ressembler à un tableau) et au plus la moitié (sinon ce sont des pilules, et la lecture en colonnes disparaît). C'est le même raisonnement que sur la barre de menu de ref-17 : une borne seule laisse toujours passer l'autre faute.\n\nL'attribut `data-ramp` n'est là que pour la MESURE : il nomme le cran de rampe d'une cellule témoin de chaque niveau, ce qui permet d'écrire les assertions sur des couleurs calculées plutôt que sur les tokens déclarés. Un système thémé qui casserait sa rampe sortirait rouge ici et nulle part ailleurs.",
"id": "chart-05-tile-heatmap"
}
À côté
chart-01-stadium-bars
Barres à sommet arrondi
chart-02-isotype
Isotype à taux de remplissage
chart-03-accent-column-callout
Histogramme à colonne accentuée et info-bulle
chart-04-unit-textured-bar
Barre à unité déclarée
layout-07-icon-rail-shell