{
  "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"
}