diagram-01-layer-stack
diagram-04-serpentine-milestones
Chronologie serpentine à ronds
Dérouler une chronologie sur une colonne aussi longue qu'on veut : un fil continu qui descend en serpentant, des ronds d'étape posés alternativement de part et d'autre de l'axe, et le texte toujours du côté opposé au rond. Le fil ne relie pas les étapes par le plus court chemin — il fait une ÉPINGLE au-delà de chaque rond avant de le traverser, et c'est ce demi-tour qui donne au temps sa longueur ressentie. Fond transparent : le module se pose sur ce qu'on veut.
diagramprintweb1080×2446px19 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
Une histoire à raconter dans l'ordre, sur un support qui défile — affiche, story, page longue, infographie imprimée. L'espacement de chaque intervalle étant réglable un par un, une période creuse peut occuper autant de place qu'une période dense, ou l'inverse : c'est le seul endroit où ce diagramme laisse entrer une notion de durée.
Éviter quand
Il faut une échelle de temps exacte (rien n'est proportionnel ici tant qu'on ne règle pas les espacements soi-même), la surface est en paysage (le serpentin a besoin de hauteur), ou les étapes portent des objets multiples et hétérogènes — c'est diagram-02-branch-timeline qui sait accrocher plusieurs objets à une même date.
Notes
Le fragment est GÉNÉRÉ par `node kit/serpentine.mjs` et ne se retouche pas à la main : le `d` du tracé n'est lisible que par le générateur, et une coordonnée corrigée au jugé casse la continuité de tangente sans que rien ne le montre à l'écran avant l'impression. C'est le premier pattern du dépôt dont la géométrie a un outil — parce que c'est le premier dont le nombre d'objets n'est pas fixe. Le geste tient dans le point d'épingle. Sans lui, relier des ronds alternés donne un S mou ; avec lui, le fil dépasse le rond, rebrousse, puis le traverse — et le benchmark le mesure sur le TRACÉ (largeur de la bbox du path rapportée à l'écart entre les ronds ≥ 1,5), parce qu'aucune mesure de boîte ne distingue un serpentin d'un zigzag. Le fond transparent n'est pas une commodité, c'est la première assertion : ce module est fait pour être posé sur une affiche, une photo, un fond de marque. Tout ce qui est opaque ici est un objet — le disque, et rien d'autre. Les espacements de la démonstration sont volontairement INÉGAUX (320 / 440 / 320 / 470 / 340) : c'est la propriété la moins visible du pattern et la plus utile, donc elle est portée par le fragment livré plutôt que par une phrase de documentation.
Mots-clés
Benchmarks — la géométrie, en assertions vérifiables
Rejouables par node bin/check.mjs diagram-04-serpentine-milestones : le pattern se prouve, il ne se croit pas.
| assertion | mesure | attendu |
|---|---|---|
| LE MODULE NE POSE AUCUN FOND — il se pose sur n'importe quoi, y compris rien | (() => { const c = cs('.vl-serp', 'backgroundColor'); return /^rgba\(.*,\s*0\)$/.test(c) || c === 'transparent' ? 0 : 1; })() | 0 ± 0 |
| le fil est un FIL, pas un ruban | num('.vl-serp__track', 'strokeWidth') / W | ≤ 0.025 |
| … et il est d'une seule pièce : un seul tracé, aucun segment rapporté | document.querySelectorAll('.vl-serp__track path').length | 1 ± 0 |
| AUCUNE CASSURE : le tracé n'est fait que de courbes, jamais d'un segment droit | /[LlHhVvZz]/.test($('.vl-serp__track path').getAttribute('d')) ? 1 : 0 | 0 ± 0 |
| L'ÉPINGLE EXISTE : le fil s'écarte de l'axe bien plus que les ronds | (() => { const b = $('.vl-serp__track path').getBBox(); const cx = [...document.querySelectorAll('.vl-serp__step')].map((e) => e.getBoundingClientRect().left + e.getBoundingClientRect().width / 2 - $(window.__VL_ROOT__).getBoundingClientRect().left); return b.width / (Math.max(...cx) - Math.min(...cx)); })() | ≥ 1.5 |
| le fil SORT du cadre en haut et en bas — la frise est coupée, donc extensible | (() => { const b = $('.vl-serp__track path').getBBox(); return b.y <= 0 && b.y + b.height >= H ? 1 : 0; })() | 1 ± 0 |
| les ronds ALTERNENT de part et d'autre de l'axe, sans exception | (() => { const r = $(window.__VL_ROOT__).getBoundingClientRect(); const s = [...document.querySelectorAll('.vl-serp__step')].map((e) => { const b = e.getBoundingClientRect(); return Math.sign(b.left + b.width / 2 - r.left - r.width / 2); }); return s.filter((v, i) => i && v === s[i - 1]).length; })() | 0 ± 0 |
| le texte est TOUJOURS du côté opposé à son rond | (() => { const r = $(window.__VL_ROOT__).getBoundingClientRect(); const side = (sel) => [...document.querySelectorAll(sel)].map((e) => { const b = e.getBoundingClientRect(); return Math.sign(b.left + b.width / 2 - r.left - r.width / 2); }); const a = side('.vl-serp__step'), b = side('.vl-serp__note'); return a.filter((v, i) => v === b[i]).length; })() | 0 ± 0 |
| un rond est un DISQUE | box('.vl-serp__disc').w / box('.vl-serp__disc').h | 1 ± 0.02 |
| … et il est OPAQUE : c'est lui qui masque le fil qui le traverse | /^rgb\(/.test(cs('.vl-serp__disc', 'backgroundColor')) ? 1 : 0 | 1 ± 0 |
| L'ESPACEMENT SE RÈGLE INTERVALLE PAR INTERVALLE — ici près de moitié d’écart entre le plus court et le plus long | (() => { const y = [...document.querySelectorAll('.vl-serp__step')].map((e) => { const b = e.getBoundingClientRect(); return b.top + b.height / 2; }); const g = y.slice(1).map((v, i) => v - y[i]); return Math.max(...g) / Math.min(...g); })() | ≥ 1.2 |
| … sans jamais laisser deux ronds se toucher | (() => { const b = [...document.querySelectorAll('.vl-serp__step')].map((e) => e.getBoundingClientRect()); return Math.min(...b.slice(1).map((v, i) => v.top - b[i].bottom)) / W; })() | ≥ 0.02 |
| le libellé curviligne est du côté EXTÉRIEUR du rond, jamais du côté du texte | (() => { const r = $(window.__VL_ROOT__).getBoundingClientRect(); const mid = r.left + r.width / 2; let bad = 0; document.querySelectorAll('.vl-serp__step').forEach((s) => { const t = s.querySelector('.vl-serp__arc text'); if (!t) return; const sb = s.getBoundingClientRect(), tb = t.getBoundingClientRect(); const sc = sb.left + sb.width / 2 - mid, tc = tb.left + tb.width / 2 - mid; if (Math.sign(tc) !== Math.sign(sc) || Math.abs(tc) <= Math.abs(sc)) bad++; }); return bad; })() | 0 ± 0 |
| l'année domine son texte — au moins deux fois le corps | num('.vl-serp__year', 'fontSize') / num('.vl-serp__text', 'fontSize') | ≥ 2 |
| le texte est collé sous son année | (box('.vl-serp__text').top - box('.vl-serp__year').bottom) / num('.vl-serp__text', 'fontSize') | ≤ 0.7 |
| rien ne sort de la colonne : les blocs de texte tiennent dans la largeur | (() => { const r = $(window.__VL_ROOT__).getBoundingClientRect(); return Math.min(...[...document.querySelectorAll('.vl-serp__note')].map((e) => { const b = e.getBoundingClientRect(); return Math.min(b.left - r.left, r.right - b.right); })); })() | ≥ 0 |
| le halo ne vient pas mordre le bloc de texte | (() => { const n = [...document.querySelectorAll('.vl-serp__note')].map((e) => e.getBoundingClientRect()); const h = [...document.querySelectorAll('.vl-serp__halo')].map((e) => e.getBoundingClientRect()); let d = Infinity; n.forEach((a) => h.forEach((b) => { if (a.bottom < b.top || b.bottom < a.top) return; d = Math.min(d, a.left > b.left ? a.left - b.right : b.left - a.right); })); return d === Infinity ? 999 : d / W; })() | ≥ 0.01 |
| contraste de l'année ≥ 4,5 | contrast('.vl-serp__year') | ≥ 4.5 |
| contraste du texte ≥ 4,5 | contrast('.vl-serp__text') | ≥ 4.5 |
Le code
patterns/diagram-04-serpentine-milestones.html
Télécharger<!-- diagram-04-serpentine-milestones — chronologie serpentine, FOND TRANSPARENT.
GÉNÉRÉ par `node kit/serpentine.mjs` : 6 étapes, colonne 1080×2446.2.
Ne pas retoucher le `d` à la main — regénérer. Couleurs via --vl-* uniquement.
Le tracé passe par DEUX points par étape : une épingle posée plus loin de l'axe que le
rond, puis le centre du rond. Les deux jambes du demi-tour encadrent le rond, le trait
le traverse, et repart. Sans le point d'épingle on obtient un S mou.
Le nombre d'étapes et l'espacement de CHAQUE intervalle sont des paramètres du
générateur : `--gaps 300,420,300` allonge le deuxième intervalle et lui seul. -->
<div class="vl-serp" style="--w:1080px; --h:2446.2px; --disc:253.8px; --track:18.6px">
<svg class="vl-serp__track" viewBox="0 0 1080 2446.2" aria-hidden="true">
<path d="M 373.7 -75.6 C 470.5 -26.8 773.2 87.5 842.4 160.6 C 911.6 233.7 833.5 210.2 708.5 278.1 C 583.5 346 307.2 423.2 237.6 489.3 C 168 555.4 246.5 515.6 371.5 598.1 C 496.5 680.6 772.8 797.6 842.4 888.5 C 912 979.4 833.5 963.5 708.5 1038.1 C 583.5 1112.7 307.2 1183.2 237.6 1249.3 C 168 1315.4 246.5 1271.5 371.5 1358.1 C 496.5 1444.7 772.8 1571.2 842.4 1668.3 C 912 1765.4 833.5 1748.7 708.5 1828.1 C 583.5 1907.5 307.2 1982.2 237.6 2052.5 C 168 2122.8 274.7 2071.1 371.5 2168.1 C 468.4 2265.1 637.1 2448.7 706.3 2521.8"/>
</svg>
<div class="vl-serp__step vl-serp__step--right" data-tone="gold"
style="--x:708.5px; --y:278.1px; --r:126.9px">
<svg class="vl-serp__halo" viewBox="-177.7 -177.7 355.3 355.3" aria-hidden="true">
<line x1="129.4" y1="0" x2="165" y2="0"/><line x1="125.7" y1="31" x2="160.2" y2="39.5"/><line x1="114.6" y1="60.2" x2="146.1" y2="76.7"/><line x1="96.9" y1="85.8" x2="123.5" y2="109.4"/><line x1="73.5" y1="106.5" x2="93.7" y2="135.8"/><line x1="45.9" y1="121" x2="58.5" y2="154.2"/><line x1="15.6" y1="128.5" x2="19.9" y2="163.8"/><line x1="-15.6" y1="128.5" x2="-19.9" y2="163.8"/><line x1="-45.9" y1="121" x2="-58.5" y2="154.2"/><line x1="-73.5" y1="106.5" x2="-93.7" y2="135.8"/><line x1="-96.9" y1="85.8" x2="-123.5" y2="109.4"/><line x1="-114.6" y1="60.2" x2="-146.1" y2="76.7"/><line x1="-125.7" y1="31" x2="-160.2" y2="39.5"/><line x1="-129.4" y1="0" x2="-165" y2="0"/><line x1="-125.7" y1="-31" x2="-160.2" y2="-39.5"/><line x1="-114.6" y1="-60.2" x2="-146.1" y2="-76.7"/><line x1="-96.9" y1="-85.8" x2="-123.5" y2="-109.4"/><line x1="-73.5" y1="-106.5" x2="-93.7" y2="-135.8"/><line x1="-45.9" y1="-121" x2="-58.5" y2="-154.2"/><line x1="-15.6" y1="-128.5" x2="-19.9" y2="-163.8"/><line x1="15.6" y1="-128.5" x2="19.9" y2="-163.8"/><line x1="45.9" y1="-121" x2="58.5" y2="-154.2"/><line x1="73.5" y1="-106.5" x2="93.7" y2="-135.8"/><line x1="96.9" y1="-85.8" x2="123.5" y2="-109.4"/><line x1="114.6" y1="-60.2" x2="146.1" y2="-76.7"/><line x1="125.7" y1="-31" x2="160.2" y2="-39.5"/>
</svg>
<span class="vl-serp__disc"></span>
<svg class="vl-serp__spark" viewBox="-12 -12 24 24" aria-hidden="true">
<path d="M0-12C1.4-4.6 4.6-1.4 12 0 4.6 1.4 1.4 4.6 0 12-1.4 4.6-4.6 1.4-12 0-4.6-1.4-1.4-4.6 0-12Z"/>
</svg>
<svg class="vl-serp__arc" viewBox="-273.9 -273.9 547.7 547.7" aria-hidden="true">
<path id="vl-serp-arc-0" fill="none" d="M 0 -210.7 A 210.7 210.7 0 0 1 0 210.7"/>
<text><textPath href="#vl-serp-arc-0" startOffset="50%" text-anchor="middle">FONDATION ·</textPath></text>
</svg>
</div>
<div class="vl-serp__note" style="--x:172.8px; --y:278.1px">
<p class="vl-serp__year">2<span class="vl-serp__zero">0</span><span class="vl-serp__zero">0</span>5</p>
<p class="vl-serp__text">Antoine Auclair fonde la société</p>
</div>
<div class="vl-serp__step vl-serp__step--left" data-tone="green"
style="--x:371.5px; --y:598.1px; --r:126.9px">
<svg class="vl-serp__halo" viewBox="-177.7 -177.7 355.3 355.3" aria-hidden="true">
<line x1="129.4" y1="0" x2="165" y2="0"/><line x1="125.7" y1="31" x2="160.2" y2="39.5"/><line x1="114.6" y1="60.2" x2="146.1" y2="76.7"/><line x1="96.9" y1="85.8" x2="123.5" y2="109.4"/><line x1="73.5" y1="106.5" x2="93.7" y2="135.8"/><line x1="45.9" y1="121" x2="58.5" y2="154.2"/><line x1="15.6" y1="128.5" x2="19.9" y2="163.8"/><line x1="-15.6" y1="128.5" x2="-19.9" y2="163.8"/><line x1="-45.9" y1="121" x2="-58.5" y2="154.2"/><line x1="-73.5" y1="106.5" x2="-93.7" y2="135.8"/><line x1="-96.9" y1="85.8" x2="-123.5" y2="109.4"/><line x1="-114.6" y1="60.2" x2="-146.1" y2="76.7"/><line x1="-125.7" y1="31" x2="-160.2" y2="39.5"/><line x1="-129.4" y1="0" x2="-165" y2="0"/><line x1="-125.7" y1="-31" x2="-160.2" y2="-39.5"/><line x1="-114.6" y1="-60.2" x2="-146.1" y2="-76.7"/><line x1="-96.9" y1="-85.8" x2="-123.5" y2="-109.4"/><line x1="-73.5" y1="-106.5" x2="-93.7" y2="-135.8"/><line x1="-45.9" y1="-121" x2="-58.5" y2="-154.2"/><line x1="-15.6" y1="-128.5" x2="-19.9" y2="-163.8"/><line x1="15.6" y1="-128.5" x2="19.9" y2="-163.8"/><line x1="45.9" y1="-121" x2="58.5" y2="-154.2"/><line x1="73.5" y1="-106.5" x2="93.7" y2="-135.8"/><line x1="96.9" y1="-85.8" x2="123.5" y2="-109.4"/><line x1="114.6" y1="-60.2" x2="146.1" y2="-76.7"/><line x1="125.7" y1="-31" x2="160.2" y2="-39.5"/>
</svg>
<span class="vl-serp__disc"></span>
<svg class="vl-serp__spark" viewBox="-12 -12 24 24" aria-hidden="true">
<path d="M0-12C1.4-4.6 4.6-1.4 12 0 4.6 1.4 1.4 4.6 0 12-1.4 4.6-4.6 1.4-12 0-4.6-1.4-1.4-4.6 0-12Z"/>
</svg>
<svg class="vl-serp__arc" viewBox="-273.9 -273.9 547.7 547.7" aria-hidden="true">
<path id="vl-serp-arc-1" fill="none" d="M 0 210.7 A 210.7 210.7 0 0 1 0 -210.7"/>
<text><textPath href="#vl-serp-arc-1" startOffset="50%" text-anchor="middle">LES LOCAUX ·</textPath></text>
</svg>
</div>
<div class="vl-serp__note" style="--x:907.2px; --y:598.1px">
<p class="vl-serp__year">2<span class="vl-serp__zero">0</span><span class="vl-serp__zero">0</span>8</p>
<p class="vl-serp__text">Installation du siège social à Any City</p>
</div>
<div class="vl-serp__step vl-serp__step--right" data-tone="ember"
style="--x:708.5px; --y:1038.1px; --r:126.9px">
<svg class="vl-serp__halo" viewBox="-177.7 -177.7 355.3 355.3" aria-hidden="true">
<line x1="129.4" y1="0" x2="165" y2="0"/><line x1="125.7" y1="31" x2="160.2" y2="39.5"/><line x1="114.6" y1="60.2" x2="146.1" y2="76.7"/><line x1="96.9" y1="85.8" x2="123.5" y2="109.4"/><line x1="73.5" y1="106.5" x2="93.7" y2="135.8"/><line x1="45.9" y1="121" x2="58.5" y2="154.2"/><line x1="15.6" y1="128.5" x2="19.9" y2="163.8"/><line x1="-15.6" y1="128.5" x2="-19.9" y2="163.8"/><line x1="-45.9" y1="121" x2="-58.5" y2="154.2"/><line x1="-73.5" y1="106.5" x2="-93.7" y2="135.8"/><line x1="-96.9" y1="85.8" x2="-123.5" y2="109.4"/><line x1="-114.6" y1="60.2" x2="-146.1" y2="76.7"/><line x1="-125.7" y1="31" x2="-160.2" y2="39.5"/><line x1="-129.4" y1="0" x2="-165" y2="0"/><line x1="-125.7" y1="-31" x2="-160.2" y2="-39.5"/><line x1="-114.6" y1="-60.2" x2="-146.1" y2="-76.7"/><line x1="-96.9" y1="-85.8" x2="-123.5" y2="-109.4"/><line x1="-73.5" y1="-106.5" x2="-93.7" y2="-135.8"/><line x1="-45.9" y1="-121" x2="-58.5" y2="-154.2"/><line x1="-15.6" y1="-128.5" x2="-19.9" y2="-163.8"/><line x1="15.6" y1="-128.5" x2="19.9" y2="-163.8"/><line x1="45.9" y1="-121" x2="58.5" y2="-154.2"/><line x1="73.5" y1="-106.5" x2="93.7" y2="-135.8"/><line x1="96.9" y1="-85.8" x2="123.5" y2="-109.4"/><line x1="114.6" y1="-60.2" x2="146.1" y2="-76.7"/><line x1="125.7" y1="-31" x2="160.2" y2="-39.5"/>
</svg>
<span class="vl-serp__disc"></span>
<svg class="vl-serp__spark" viewBox="-12 -12 24 24" aria-hidden="true">
<path d="M0-12C1.4-4.6 4.6-1.4 12 0 4.6 1.4 1.4 4.6 0 12-1.4 4.6-4.6 1.4-12 0-4.6-1.4-1.4-4.6 0-12Z"/>
</svg>
<svg class="vl-serp__arc" viewBox="-273.9 -273.9 547.7 547.7" aria-hidden="true">
<path id="vl-serp-arc-2" fill="none" d="M 0 -210.7 A 210.7 210.7 0 0 1 0 210.7"/>
<text><textPath href="#vl-serp-arc-2" startOffset="50%" text-anchor="middle">RECONNAISSANCE ·</textPath></text>
</svg>
</div>
<div class="vl-serp__note" style="--x:172.8px; --y:1038.1px">
<p class="vl-serp__year">2<span class="vl-serp__zero">0</span>1<span class="vl-serp__zero">0</span></p>
<p class="vl-serp__text">Obtention du prix de la meilleure conception</p>
</div>
<div class="vl-serp__step vl-serp__step--left" data-tone="lime"
style="--x:371.5px; --y:1358.1px; --r:126.9px">
<svg class="vl-serp__halo" viewBox="-177.7 -177.7 355.3 355.3" aria-hidden="true">
<line x1="129.4" y1="0" x2="165" y2="0"/><line x1="125.7" y1="31" x2="160.2" y2="39.5"/><line x1="114.6" y1="60.2" x2="146.1" y2="76.7"/><line x1="96.9" y1="85.8" x2="123.5" y2="109.4"/><line x1="73.5" y1="106.5" x2="93.7" y2="135.8"/><line x1="45.9" y1="121" x2="58.5" y2="154.2"/><line x1="15.6" y1="128.5" x2="19.9" y2="163.8"/><line x1="-15.6" y1="128.5" x2="-19.9" y2="163.8"/><line x1="-45.9" y1="121" x2="-58.5" y2="154.2"/><line x1="-73.5" y1="106.5" x2="-93.7" y2="135.8"/><line x1="-96.9" y1="85.8" x2="-123.5" y2="109.4"/><line x1="-114.6" y1="60.2" x2="-146.1" y2="76.7"/><line x1="-125.7" y1="31" x2="-160.2" y2="39.5"/><line x1="-129.4" y1="0" x2="-165" y2="0"/><line x1="-125.7" y1="-31" x2="-160.2" y2="-39.5"/><line x1="-114.6" y1="-60.2" x2="-146.1" y2="-76.7"/><line x1="-96.9" y1="-85.8" x2="-123.5" y2="-109.4"/><line x1="-73.5" y1="-106.5" x2="-93.7" y2="-135.8"/><line x1="-45.9" y1="-121" x2="-58.5" y2="-154.2"/><line x1="-15.6" y1="-128.5" x2="-19.9" y2="-163.8"/><line x1="15.6" y1="-128.5" x2="19.9" y2="-163.8"/><line x1="45.9" y1="-121" x2="58.5" y2="-154.2"/><line x1="73.5" y1="-106.5" x2="93.7" y2="-135.8"/><line x1="96.9" y1="-85.8" x2="123.5" y2="-109.4"/><line x1="114.6" y1="-60.2" x2="146.1" y2="-76.7"/><line x1="125.7" y1="-31" x2="160.2" y2="-39.5"/>
</svg>
<span class="vl-serp__disc"></span>
<svg class="vl-serp__spark" viewBox="-12 -12 24 24" aria-hidden="true">
<path d="M0-12C1.4-4.6 4.6-1.4 12 0 4.6 1.4 1.4 4.6 0 12-1.4 4.6-4.6 1.4-12 0-4.6-1.4-1.4-4.6 0-12Z"/>
</svg>
<svg class="vl-serp__arc" viewBox="-273.9 -273.9 547.7 547.7" aria-hidden="true">
<path id="vl-serp-arc-3" fill="none" d="M 0 210.7 A 210.7 210.7 0 0 1 0 -210.7"/>
<text><textPath href="#vl-serp-arc-3" startOffset="50%" text-anchor="middle">SUCCURSALES ·</textPath></text>
</svg>
</div>
<div class="vl-serp__note" style="--x:907.2px; --y:1358.1px">
<p class="vl-serp__year">2<span class="vl-serp__zero">0</span>15</p>
<p class="vl-serp__text">Inauguration de 3 succursales en Europe</p>
</div>
<div class="vl-serp__step vl-serp__step--right" data-tone="azure"
style="--x:708.5px; --y:1828.1px; --r:126.9px">
<svg class="vl-serp__halo" viewBox="-177.7 -177.7 355.3 355.3" aria-hidden="true">
<line x1="129.4" y1="0" x2="165" y2="0"/><line x1="125.7" y1="31" x2="160.2" y2="39.5"/><line x1="114.6" y1="60.2" x2="146.1" y2="76.7"/><line x1="96.9" y1="85.8" x2="123.5" y2="109.4"/><line x1="73.5" y1="106.5" x2="93.7" y2="135.8"/><line x1="45.9" y1="121" x2="58.5" y2="154.2"/><line x1="15.6" y1="128.5" x2="19.9" y2="163.8"/><line x1="-15.6" y1="128.5" x2="-19.9" y2="163.8"/><line x1="-45.9" y1="121" x2="-58.5" y2="154.2"/><line x1="-73.5" y1="106.5" x2="-93.7" y2="135.8"/><line x1="-96.9" y1="85.8" x2="-123.5" y2="109.4"/><line x1="-114.6" y1="60.2" x2="-146.1" y2="76.7"/><line x1="-125.7" y1="31" x2="-160.2" y2="39.5"/><line x1="-129.4" y1="0" x2="-165" y2="0"/><line x1="-125.7" y1="-31" x2="-160.2" y2="-39.5"/><line x1="-114.6" y1="-60.2" x2="-146.1" y2="-76.7"/><line x1="-96.9" y1="-85.8" x2="-123.5" y2="-109.4"/><line x1="-73.5" y1="-106.5" x2="-93.7" y2="-135.8"/><line x1="-45.9" y1="-121" x2="-58.5" y2="-154.2"/><line x1="-15.6" y1="-128.5" x2="-19.9" y2="-163.8"/><line x1="15.6" y1="-128.5" x2="19.9" y2="-163.8"/><line x1="45.9" y1="-121" x2="58.5" y2="-154.2"/><line x1="73.5" y1="-106.5" x2="93.7" y2="-135.8"/><line x1="96.9" y1="-85.8" x2="123.5" y2="-109.4"/><line x1="114.6" y1="-60.2" x2="146.1" y2="-76.7"/><line x1="125.7" y1="-31" x2="160.2" y2="-39.5"/>
</svg>
<span class="vl-serp__disc"></span>
<svg class="vl-serp__spark" viewBox="-12 -12 24 24" aria-hidden="true">
<path d="M0-12C1.4-4.6 4.6-1.4 12 0 4.6 1.4 1.4 4.6 0 12-1.4 4.6-4.6 1.4-12 0-4.6-1.4-1.4-4.6 0-12Z"/>
</svg>
<svg class="vl-serp__arc" viewBox="-273.9 -273.9 547.7 547.7" aria-hidden="true">
<path id="vl-serp-arc-4" fill="none" d="M 0 -210.7 A 210.7 210.7 0 0 1 0 210.7"/>
<text><textPath href="#vl-serp-arc-4" startOffset="50%" text-anchor="middle">FUSION ·</textPath></text>
</svg>
</div>
<div class="vl-serp__note" style="--x:172.8px; --y:1828.1px">
<p class="vl-serp__year">2<span class="vl-serp__zero">0</span>18</p>
<p class="vl-serp__text">Fusion avec la société Nomade</p>
</div>
<div class="vl-serp__step vl-serp__step--left" data-tone="amber"
style="--x:371.5px; --y:2168.1px; --r:126.9px">
<svg class="vl-serp__halo" viewBox="-177.7 -177.7 355.3 355.3" aria-hidden="true">
<line x1="129.4" y1="0" x2="165" y2="0"/><line x1="125.7" y1="31" x2="160.2" y2="39.5"/><line x1="114.6" y1="60.2" x2="146.1" y2="76.7"/><line x1="96.9" y1="85.8" x2="123.5" y2="109.4"/><line x1="73.5" y1="106.5" x2="93.7" y2="135.8"/><line x1="45.9" y1="121" x2="58.5" y2="154.2"/><line x1="15.6" y1="128.5" x2="19.9" y2="163.8"/><line x1="-15.6" y1="128.5" x2="-19.9" y2="163.8"/><line x1="-45.9" y1="121" x2="-58.5" y2="154.2"/><line x1="-73.5" y1="106.5" x2="-93.7" y2="135.8"/><line x1="-96.9" y1="85.8" x2="-123.5" y2="109.4"/><line x1="-114.6" y1="60.2" x2="-146.1" y2="76.7"/><line x1="-125.7" y1="31" x2="-160.2" y2="39.5"/><line x1="-129.4" y1="0" x2="-165" y2="0"/><line x1="-125.7" y1="-31" x2="-160.2" y2="-39.5"/><line x1="-114.6" y1="-60.2" x2="-146.1" y2="-76.7"/><line x1="-96.9" y1="-85.8" x2="-123.5" y2="-109.4"/><line x1="-73.5" y1="-106.5" x2="-93.7" y2="-135.8"/><line x1="-45.9" y1="-121" x2="-58.5" y2="-154.2"/><line x1="-15.6" y1="-128.5" x2="-19.9" y2="-163.8"/><line x1="15.6" y1="-128.5" x2="19.9" y2="-163.8"/><line x1="45.9" y1="-121" x2="58.5" y2="-154.2"/><line x1="73.5" y1="-106.5" x2="93.7" y2="-135.8"/><line x1="96.9" y1="-85.8" x2="123.5" y2="-109.4"/><line x1="114.6" y1="-60.2" x2="146.1" y2="-76.7"/><line x1="125.7" y1="-31" x2="160.2" y2="-39.5"/>
</svg>
<span class="vl-serp__disc"></span>
<svg class="vl-serp__spark" viewBox="-12 -12 24 24" aria-hidden="true">
<path d="M0-12C1.4-4.6 4.6-1.4 12 0 4.6 1.4 1.4 4.6 0 12-1.4 4.6-4.6 1.4-12 0-4.6-1.4-1.4-4.6 0-12Z"/>
</svg>
<svg class="vl-serp__arc" viewBox="-273.9 -273.9 547.7 547.7" aria-hidden="true">
<path id="vl-serp-arc-5" fill="none" d="M 0 210.7 A 210.7 210.7 0 0 1 0 -210.7"/>
<text><textPath href="#vl-serp-arc-5" startOffset="50%" text-anchor="middle">BOURSE ·</textPath></text>
</svg>
</div>
<div class="vl-serp__note" style="--x:907.2px; --y:2168.1px">
<p class="vl-serp__year">2<span class="vl-serp__zero">0</span>21</p>
<p class="vl-serp__text">Entrée en bourse</p>
</div>
</div>
<style>
/* Le module NE POSE AUCUN FOND : il se pose sur ce qu'on veut, y compris rien. Tout ce qui
est opaque ici est un objet (le disque), jamais un sol. */
.vl-serp {
position: relative;
width: var(--w); height: var(--h);
box-sizing: border-box;
color: var(--vl-ink);
font-family: Poppins, Montserrat, Inter, "Helvetica Neue", Arial, sans-serif;
}
/* Le tracé est en fond de pile : les ronds passent DESSUS et le masquent, ce qui produit le
passage « derrière » de la source sans aucun masque ni découpe. */
.vl-serp__track {
position: absolute; inset: 0; width: 100%; height: 100%;
fill: none; stroke: var(--vl-accent); stroke-width: var(--track);
stroke-linecap: round; stroke-linejoin: round;
}
.vl-serp__step, .vl-serp__note {
position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%);
}
.vl-serp__step { width: var(--disc); height: var(--disc); --tone: var(--vl-azure); }
.vl-serp__step[data-tone="green"] { --tone: var(--vl-green); }
.vl-serp__step[data-tone="lime"] { --tone: var(--vl-lime); }
.vl-serp__step[data-tone="gold"] { --tone: var(--vl-gold); }
.vl-serp__step[data-tone="amber"] { --tone: var(--vl-amber); }
.vl-serp__step[data-tone="ember"] { --tone: var(--vl-ember); }
.vl-serp__step[data-tone="azure"] { --tone: var(--vl-azure); }
.vl-serp__step > * { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
/* Le halo est le seul porteur de la couleur d'étape. Le disque reste sombre : deux surfaces
de même teinte l'une dans l'autre s'annuleraient. */
.vl-serp__halo {
width: calc(var(--r) * 2.8); height: calc(var(--r) * 2.8);
stroke: var(--tone); stroke-width: calc(var(--r) * 0.115); stroke-linecap: butt;
}
/* L'emplacement d'image : un aplat, à remplacer par une photo détourée le cas échéant. */
.vl-serp__disc {
width: 100%; height: 100%; border-radius: 50%;
background: var(--vl-disc);
}
.vl-serp__spark { width: calc(var(--disc) * 0.4); height: calc(var(--disc) * 0.4); fill: var(--vl-accent); }
.vl-serp__step--right .vl-serp__spark { left: 12%; top: 12%; }
.vl-serp__step--left .vl-serp__spark { left: 88%; top: 12%; }
/* Le libellé court sur l'arc EXTÉRIEUR du rond — côté opposé au texte, donc il ne peut pas
entrer en concurrence avec lui. */
.vl-serp__arc {
width: calc(var(--r) * 4.316); height: calc(var(--r) * 4.316);
fill: var(--vl-accent);
font-size: calc(var(--w) * 0.021); font-weight: 600; letter-spacing: 0.14em;
}
.vl-serp__note { width: calc(var(--w) * 0.29); text-align: center; }
.vl-serp__year {
margin: 0; font-size: calc(var(--w) * 0.058); line-height: 1; font-weight: 500;
letter-spacing: 0.01em; font-variant-numeric: tabular-nums;
}
/* Le « 0 » pointé de la charte source : la police vendorisée n'a pas ce glyphe alternatif,
le point est donc posé. Retirer la règle suffit à revenir à un zéro ordinaire. */
.vl-serp__zero { position: relative; }
.vl-serp__zero::after {
content: ""; position: absolute; left: 50%; top: 50%;
width: 0.13em; height: 0.13em; border-radius: 50%;
background: currentColor; transform: translate(-50%, -50%);
}
.vl-serp__text {
margin: calc(var(--w) * 0.012) 0 0; font-size: calc(var(--w) * 0.0235); line-height: 1.4;
font-style: italic; font-weight: 400;
}
</style>
systems/ref-23-serpentine-timeline.json
Télécharger:root {
--vl-ink: #172668;
--vl-accent: #0435E6;
--vl-disc: #172668;
--vl-green: #03BE63;
--vl-lime: #C1FE74;
--vl-gold: #FEDE59;
--vl-amber: #F89C02;
--vl-ember: #E86816;
--vl-azure: #0435E6;
--vl-sheet: #EBEBEB;
--vl-gap: 22px;
--vl-margin: 44px;
}
les métadonnées — patterns/diagram-04-serpentine-milestones.json (télécharger)
{
"name": "Chronologie serpentine à ronds",
"family": "diagram",
"ref": "ref-23-serpentine-timeline",
"intent": "Dérouler une chronologie sur une colonne aussi longue qu'on veut : un fil continu qui descend en serpentant, des ronds d'étape posés alternativement de part et d'autre de l'axe, et le texte toujours du côté opposé au rond. Le fil ne relie pas les étapes par le plus court chemin — il fait une ÉPINGLE au-delà de chaque rond avant de le traverser, et c'est ce demi-tour qui donne au temps sa longueur ressentie. Fond transparent : le module se pose sur ce qu'on veut.",
"when_to_use": "Une histoire à raconter dans l'ordre, sur un support qui défile — affiche, story, page longue, infographie imprimée. L'espacement de chaque intervalle étant réglable un par un, une période creuse peut occuper autant de place qu'une période dense, ou l'inverse : c'est le seul endroit où ce diagramme laisse entrer une notion de durée.",
"avoid_when": "Il faut une échelle de temps exacte (rien n'est proportionnel ici tant qu'on ne règle pas les espacements soi-même), la surface est en paysage (le serpentin a besoin de hauteur), ou les étapes portent des objets multiples et hétérogènes — c'est diagram-02-branch-timeline qui sait accrocher plusieurs objets à une même date.",
"media": [
"print",
"web"
],
"tags": [
"chronologie",
"frise",
"serpentin",
"etapes",
"jalons",
"rond",
"fil",
"epingle",
"annees",
"histoire",
"transparent",
"extensible",
"diagramme"
],
"vars": [
{
"name": "--vl-accent",
"role": "le FIL, et tout ce qui en dépend : libellés curvilignes, étoiles. Une seule couleur de structure",
"default": "#0435E6"
},
{
"name": "--vl-ink",
"role": "l'année et son texte",
"default": "#172668"
},
{
"name": "--vl-disc",
"role": "le disque d'étape — l'emplacement d'image, à remplacer par une photo le cas échéant. Opaque : c'est lui qui masque le fil",
"default": "#172668"
},
{
"name": "--vl-green",
"role": "halo d'étape — distingue une étape de sa voisine, ne qualifie rien",
"default": "#03BE63"
},
{
"name": "--vl-lime",
"role": "halo d'étape",
"default": "#C1FE74"
},
{
"name": "--vl-gold",
"role": "halo d'étape",
"default": "#FEDE59"
},
{
"name": "--vl-amber",
"role": "halo d'étape",
"default": "#F89C02"
},
{
"name": "--vl-ember",
"role": "halo d'étape",
"default": "#E86816"
},
{
"name": "--vl-azure",
"role": "halo d'étape — le ton par défaut, celui du fil",
"default": "#0435E6"
}
],
"slots": [
"track",
"steps",
"notes",
"labels"
],
"geometry": {
"root": ".vl-serp",
"frame": [
1080,
2446
],
"ratios": {
"track_over_width": 0.0172,
"disc_over_width": 0.235,
"step_amplitude_over_width": 0.156,
"hairpin_amplitude_over_width": 0.28,
"note_offset_over_width": 0.34,
"gap_over_width": 0.32
},
"type_px": {
"year": 63,
"label": 23,
"text": 25
},
"pad_ratio": {
"top": 0,
"x": 0,
"bottom": 0
}
},
"benchmarks": [
{
"name": "LE MODULE NE POSE AUCUN FOND — il se pose sur n'importe quoi, y compris rien",
"measure": "(() => { const c = cs('.vl-serp', 'backgroundColor'); return /^rgba\\(.*,\\s*0\\)$/.test(c) || c === 'transparent' ? 0 : 1; })()",
"expect": 0,
"tol": 0
},
{
"name": "le fil est un FIL, pas un ruban",
"measure": "num('.vl-serp__track', 'strokeWidth') / W",
"max": 0.025
},
{
"name": "… et il est d'une seule pièce : un seul tracé, aucun segment rapporté",
"measure": "document.querySelectorAll('.vl-serp__track path').length",
"expect": 1,
"tol": 0
},
{
"name": "AUCUNE CASSURE : le tracé n'est fait que de courbes, jamais d'un segment droit",
"measure": "/[LlHhVvZz]/.test($('.vl-serp__track path').getAttribute('d')) ? 1 : 0",
"expect": 0,
"tol": 0
},
{
"name": "L'ÉPINGLE EXISTE : le fil s'écarte de l'axe bien plus que les ronds",
"measure": "(() => { const b = $('.vl-serp__track path').getBBox(); const cx = [...document.querySelectorAll('.vl-serp__step')].map((e) => e.getBoundingClientRect().left + e.getBoundingClientRect().width / 2 - $(window.__VL_ROOT__).getBoundingClientRect().left); return b.width / (Math.max(...cx) - Math.min(...cx)); })()",
"min": 1.5
},
{
"name": "le fil SORT du cadre en haut et en bas — la frise est coupée, donc extensible",
"measure": "(() => { const b = $('.vl-serp__track path').getBBox(); return b.y <= 0 && b.y + b.height >= H ? 1 : 0; })()",
"expect": 1,
"tol": 0
},
{
"name": "les ronds ALTERNENT de part et d'autre de l'axe, sans exception",
"measure": "(() => { const r = $(window.__VL_ROOT__).getBoundingClientRect(); const s = [...document.querySelectorAll('.vl-serp__step')].map((e) => { const b = e.getBoundingClientRect(); return Math.sign(b.left + b.width / 2 - r.left - r.width / 2); }); return s.filter((v, i) => i && v === s[i - 1]).length; })()",
"expect": 0,
"tol": 0
},
{
"name": "le texte est TOUJOURS du côté opposé à son rond",
"measure": "(() => { const r = $(window.__VL_ROOT__).getBoundingClientRect(); const side = (sel) => [...document.querySelectorAll(sel)].map((e) => { const b = e.getBoundingClientRect(); return Math.sign(b.left + b.width / 2 - r.left - r.width / 2); }); const a = side('.vl-serp__step'), b = side('.vl-serp__note'); return a.filter((v, i) => v === b[i]).length; })()",
"expect": 0,
"tol": 0
},
{
"name": "un rond est un DISQUE",
"measure": "box('.vl-serp__disc').w / box('.vl-serp__disc').h",
"expect": 1,
"tol": 0.02
},
{
"name": "… et il est OPAQUE : c'est lui qui masque le fil qui le traverse",
"measure": "/^rgb\\(/.test(cs('.vl-serp__disc', 'backgroundColor')) ? 1 : 0",
"expect": 1,
"tol": 0
},
{
"name": "L'ESPACEMENT SE RÈGLE INTERVALLE PAR INTERVALLE — ici près de moitié d’écart entre le plus court et le plus long",
"measure": "(() => { const y = [...document.querySelectorAll('.vl-serp__step')].map((e) => { const b = e.getBoundingClientRect(); return b.top + b.height / 2; }); const g = y.slice(1).map((v, i) => v - y[i]); return Math.max(...g) / Math.min(...g); })()",
"min": 1.2
},
{
"name": "… sans jamais laisser deux ronds se toucher",
"measure": "(() => { const b = [...document.querySelectorAll('.vl-serp__step')].map((e) => e.getBoundingClientRect()); return Math.min(...b.slice(1).map((v, i) => v.top - b[i].bottom)) / W; })()",
"min": 0.02
},
{
"name": "le libellé curviligne est du côté EXTÉRIEUR du rond, jamais du côté du texte",
"measure": "(() => { const r = $(window.__VL_ROOT__).getBoundingClientRect(); const mid = r.left + r.width / 2; let bad = 0; document.querySelectorAll('.vl-serp__step').forEach((s) => { const t = s.querySelector('.vl-serp__arc text'); if (!t) return; const sb = s.getBoundingClientRect(), tb = t.getBoundingClientRect(); const sc = sb.left + sb.width / 2 - mid, tc = tb.left + tb.width / 2 - mid; if (Math.sign(tc) !== Math.sign(sc) || Math.abs(tc) <= Math.abs(sc)) bad++; }); return bad; })()",
"expect": 0,
"tol": 0
},
{
"name": "l'année domine son texte — au moins deux fois le corps",
"measure": "num('.vl-serp__year', 'fontSize') / num('.vl-serp__text', 'fontSize')",
"min": 2
},
{
"name": "le texte est collé sous son année",
"measure": "(box('.vl-serp__text').top - box('.vl-serp__year').bottom) / num('.vl-serp__text', 'fontSize')",
"max": 0.7
},
{
"name": "rien ne sort de la colonne : les blocs de texte tiennent dans la largeur",
"measure": "(() => { const r = $(window.__VL_ROOT__).getBoundingClientRect(); return Math.min(...[...document.querySelectorAll('.vl-serp__note')].map((e) => { const b = e.getBoundingClientRect(); return Math.min(b.left - r.left, r.right - b.right); })); })()",
"min": 0
},
{
"name": "le halo ne vient pas mordre le bloc de texte",
"measure": "(() => { const n = [...document.querySelectorAll('.vl-serp__note')].map((e) => e.getBoundingClientRect()); const h = [...document.querySelectorAll('.vl-serp__halo')].map((e) => e.getBoundingClientRect()); let d = Infinity; n.forEach((a) => h.forEach((b) => { if (a.bottom < b.top || b.bottom < a.top) return; d = Math.min(d, a.left > b.left ? a.left - b.right : b.left - a.right); })); return d === Infinity ? 999 : d / W; })()",
"min": 0.01
},
{
"name": "contraste de l'année ≥ 4,5",
"measure": "contrast('.vl-serp__year')",
"min": 4.5
},
{
"name": "contraste du texte ≥ 4,5",
"measure": "contrast('.vl-serp__text')",
"min": 4.5
}
],
"notes": "Le fragment est GÉNÉRÉ par `node kit/serpentine.mjs` et ne se retouche pas à la main : le `d` du tracé n'est lisible que par le générateur, et une coordonnée corrigée au jugé casse la continuité de tangente sans que rien ne le montre à l'écran avant l'impression. C'est le premier pattern du dépôt dont la géométrie a un outil — parce que c'est le premier dont le nombre d'objets n'est pas fixe.\n\nLe geste tient dans le point d'épingle. Sans lui, relier des ronds alternés donne un S mou ; avec lui, le fil dépasse le rond, rebrousse, puis le traverse — et le benchmark le mesure sur le TRACÉ (largeur de la bbox du path rapportée à l'écart entre les ronds ≥ 1,5), parce qu'aucune mesure de boîte ne distingue un serpentin d'un zigzag.\n\nLe fond transparent n'est pas une commodité, c'est la première assertion : ce module est fait pour être posé sur une affiche, une photo, un fond de marque. Tout ce qui est opaque ici est un objet — le disque, et rien d'autre.\n\nLes espacements de la démonstration sont volontairement INÉGAUX (320 / 440 / 320 / 470 / 340) : c'est la propriété la moins visible du pattern et la plus utile, donc elle est portée par le fragment livré plutôt que par une phrase de documentation.",
"id": "diagram-04-serpentine-milestones"
}
À côté
diagram-02-branch-timeline
Timeline à branches en équerre
diagram-03-time-scrubber