SPB Git forge

spb/ka-stats

Public
8commits 1branches 0releases
2.2 MBsize
maindefault branch
26 days agolast push
JavaScript 73.4% CSS 25.3% HTML 1.3%
47.1 KB · 996 lines javascript
Raw Blame History
1/* -----------------------------------------------------------------------------2  Auteur : Simon-Pierre Boucher — contact@spboucher.ai3  Fichier : ka-stats/public/charts.js4  Desc.  : QCharts — bibliothèque de graphiques SVG vanilla de Ka·Stats5           (contrat ka-stats v2). Règles dataviz appliquées : marques fines6           (lignes 2 px, barres ≤ 24 px à bout arrondi 4 px), écarts de surface7           2 px, grilles hairline pleines, légende dès 2 séries, texte toujours8           en encre, infobulle crosshair (lignes) ou par-marque (barres/9           cellules), vue tableau jumelle sur CHAQUE graphique, rampe10           séquentielle mono-teinte, palette catégorielle validée en ordre fixe.11----------------------------------------------------------------------------- */12"use strict";1314const QCharts = (() => {15  /* Palette catégorielle validée (validate_palette.js — ordre fixe, jamais cyclée) */16  const CAT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"];17  const DASHES = ["", "7 4", "2 3", "10 3 2 3"]; // identité multi-courbes ≤ 4 (jamais couleur seule)18  const INK = "#141814", INK2 = "#4d5551", INK3 = "#8b928c";19  const GRID = "rgba(20,24,20,0.10)", AXIS = "rgba(20,24,20,0.28)";20  const GREEN = "#1c5c41", DANGER = "#b3423a";2122  /* ---------- utilitaires ---------- */23  const NBSP = " ";24  const nfInt = new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 0 });25  const nf1 = new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 1, minimumFractionDigits: 0 });26  const nf2 = new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 2 });2728  function fmtNum(v, unit) {29    if (v === null || v === undefined || Number.isNaN(v)) return "—";30    if (typeof v !== "number") return String(v);31    let s;32    const av = Math.abs(v);33    if (Number.isInteger(v)) s = nfInt.format(v);34    else s = (av < 10 ? nf2 : nf1).format(v);35    if (unit) {36      const u = String(unit).trim();37      if (u === "%") return s + NBSP + "%";38      if (u === "$") return s + NBSP + "$";39      return s + NBSP + u;40    }41    return s;42  }43  function fmtCompact(v) {44    const av = Math.abs(v);45    if (av >= 1e6) return nf1.format(v / 1e6) + NBSP + "M";46    if (av >= 10000) return nfInt.format(Math.round(v / 1000)) + NBSP + "k";47    if (av >= 1000) return nf1.format(v / 1000) + NBSP + "k";48    return Number.isInteger(v) ? nfInt.format(v) : nf1.format(v);49  }50  const MONTHS = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."];51  const DOWS = ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"];52  function fmtDate(t) {53    const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(t));54    if (!m) return String(t);55    return `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]}`;56  }57  function fmtDateFull(t) {58    const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(t));59    if (!m) return String(t);60    return `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]} ${m[1]}`;61  }6263  function el(tag, cls, text) {64    const n = document.createElement(tag);65    if (cls) n.className = cls;66    if (text !== undefined) n.textContent = text; // jamais innerHTML avec des libellés externes67    return n;68  }69  function svgEl(tag, attrs) {70    const n = document.createElementNS("http://www.w3.org/2000/svg", tag);71    for (const k in attrs || {}) n.setAttribute(k, attrs[k]);72    return n;73  }7475  /* couleur : garde de contraste + rampe mono-teinte */76  function hexRgb(h) {77    const s = h.replace("#", "");78    return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)];79  }80  function rgbHex(r, g, b) {81    const c = (x) => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, "0");82    return "#" + c(r) + c(g) + c(b);83  }84  function mix(h1, h2, t) {85    const a = hexRgb(h1), b = hexRgb(h2);86    return rgbHex(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t);87  }88  function relLum(h) {89    const [r, g, b] = hexRgb(h).map((v) => {90      const s = v / 255;91      return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);92    });93    return 0.2126 * r + 0.7152 * g + 0.0722 * b;94  }95  function contrastWhite(h) {96    return 1.05 / (relLum(h) + 0.05);97  }98  /* trait de série : l'accent si assez contrasté sur blanc, sinon sa version deep */99  function strokeFor(accent, deep) {100    return contrastWhite(accent) >= 3 ? accent : deep || mix(accent, INK, 0.45);101  }102  /* rampe séquentielle mono-teinte (clair → foncé), monotone en luminosité */103  function seqRamp(accent) {104    const base = contrastWhite(accent) >= 2 ? accent : mix(accent, INK, 0.35);105    return (t) => (t <= 0 ? "#f1efe8" : mix(mix("#ffffff", base, 0.16 + 0.84 * t), INK, Math.max(0, t - 0.72) * 0.9));106  }107108  function niceTicks(min, max, n = 4) {109    if (!(max > min)) max = min + 1;110    const span = max - min;111    const step0 = Math.pow(10, Math.floor(Math.log10(span / n)));112    const err = span / n / step0;113    const step = step0 * (err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1);114    const lo = Math.floor(min / step) * step;115    const hi = Math.ceil(max / step) * step;116    const ticks = [];117    for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Math.round(v * 1e6) / 1e6);118    return { lo, hi, ticks };119  }120121  /* ---------- coquille de graphique : carte + infobulle + vue tableau ---------- */122  function shell(opts) {123    const card = el("figure", "viz-card");124    if (opts.title) {125      const head = el("figcaption", "viz-head");126      head.appendChild(el("div", "viz-title", opts.title));127      if (opts.unit) head.appendChild(el("span", "klabel viz-unit", opts.unit));128      card.appendChild(head);129    }130    const holder = el("div", "viz-plot");131    card.appendChild(holder);132    const tt = el("div", "viz-tt");133    tt.setAttribute("role", "status");134    holder.appendChild(tt);135    return { card, holder, tt };136  }137  function showTT(holder, tt, xPct, yPct) {138    tt.style.display = "block";139    const r = holder.getBoundingClientRect();140    const tw = tt.offsetWidth, th = tt.offsetHeight;141    let x = (xPct / 100) * r.width + 14;142    if (x + tw > r.width - 4) x = (xPct / 100) * r.width - tw - 14;143    let y = (yPct / 100) * r.height - th - 10;144    if (y < 0) y = 4;145    tt.style.left = Math.max(2, x) + "px";146    tt.style.top = y + "px";147  }148  function hideTT(tt) { tt.style.display = "none"; }149  function ttRows(tt, title, rows) {150    tt.textContent = "";151    const h = el("div", "viz-tt-title", title);152    tt.appendChild(h);153    for (const r of rows) {154      const row = el("div", "viz-tt-row");155      if (r.color) {156        const key = el("span", "viz-tt-key");157        key.style.background = r.color;158        if (r.dash) key.style.backgroundImage = `repeating-linear-gradient(90deg, ${r.color} 0 5px, #fff 5px 8px)`;159        row.appendChild(key);160      }161      row.appendChild(el("b", "viz-tt-val", r.value));162      row.appendChild(el("span", "viz-tt-lbl", r.label || ""));163      tt.appendChild(row);164    }165  }166  /* vue tableau jumelle (l'infobulle ne « garde » jamais une valeur) */167  function twinTable(card, columns, rows) {168    const det = el("details", "viz-data");169    det.appendChild(el("summary", null, "Données"));170    const wrap = el("div", "tbl-wrap");171    const tbl = el("table", "viz-table");172    const thead = el("thead");173    const trh = el("tr");174    for (const c of columns) trh.appendChild(el("th", null, c));175    thead.appendChild(trh);176    tbl.appendChild(thead);177    const tb = el("tbody");178    for (const r of rows.slice(0, 400)) {179      const tr = el("tr");180      r.forEach((c, i) => {181        const td = el("td", i > 0 ? "num" : null, typeof c === "number" ? fmtNum(c) : String(c ?? "—"));182        tr.appendChild(td);183      });184      tb.appendChild(tr);185    }186    tbl.appendChild(tb);187    wrap.appendChild(tbl);188    det.appendChild(wrap);189    card.appendChild(det);190    return det;191  }192  function legend(card, entries, kind) {193    const lg = el("div", "viz-legend");194    for (const e of entries) {195      const it = el("span", "viz-legend-item");196      const key = el("span", kind === "line" ? "viz-key-line" : "viz-key-rect");197      key.style.background = e.color;198      if (e.dash && kind === "line") key.style.backgroundImage = `repeating-linear-gradient(90deg, ${e.color} 0 6px, #fff 6px 9px)`;199      it.appendChild(key);200      it.appendChild(el("span", null, e.label));201      lg.appendChild(it);202    }203    card.insertBefore(lg, card.querySelector(".viz-plot"));204    return lg;205  }206207  /* ---------- sparkline (mini tendance de KPI) ---------- */208  function sparkline(points, color, w = 110, h = 30) {209    const vs = points.map((p) => p.v).filter((v) => typeof v === "number");210    if (!vs.length) return svgEl("svg", { width: w, height: h });211    const min = Math.min(...vs), max = Math.max(...vs);212    const span = max - min || 1;213    const svg = svgEl("svg", { viewBox: `0 0 ${w} ${h}`, class: "spark", "aria-hidden": "true" });214    const n = points.length;215    const px = (i) => 2 + (i / Math.max(1, n - 1)) * (w - 8);216    const py = (v) => h - 4 - ((v - min) / span) * (h - 9);217    let d = "";218    points.forEach((p, i) => { d += (i ? "L" : "M") + px(i).toFixed(1) + "," + py(p.v).toFixed(1); });219    svg.appendChild(svgEl("path", { d, fill: "none", stroke: color, "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round" }));220    const last = points[n - 1];221    svg.appendChild(svgEl("circle", { cx: px(n - 1), cy: py(last.v), r: 3.2, fill: color, stroke: "#fff", "stroke-width": 2 }));222    return svg;223  }224225  /* ---------- courbes / aires — INTERACTIF ----------226     · crosshair + infobulle toutes séries227     · zoom par sélection horizontale (glisser), double-clic = réinitialiser228     · légende cliquable (≥ 2 séries) pour masquer/afficher une série */229  function lineChart(opts) {230    const { card, holder, tt } = shell(opts);231    const W = 860, H = opts.height || 280;232    const padL = 56, padR = 18, padT = 14, padB = 34;233    const plotW = W - padL - padR, plotH = H - padT - padB;234    const all = opts.series.filter((s) => s.points && s.points.length);235    if (!all.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }236    const tsetAll = new Set();237    all.forEach((s) => s.points.forEach((p) => tsetAll.add(p.t)));238    const tsAll = [...tsetAll].sort();239    const maps = all.map((s) => new Map(s.points.map((p) => [p.t, p.v])));240    const hidden = new Set();241    let z0 = 0, z1 = tsAll.length - 1;242243    const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img" });244    svg.setAttribute("aria-label", opts.title || "graphique en courbes");245    svg.style.touchAction = "pan-y";246    holder.appendChild(svg);247248    // légende interactive249    let legendItems = [];250    if (all.length >= 2) {251      const lg = el("div", "viz-legend");252      all.forEach((s, si) => {253        const it = el("button", "viz-legend-item viz-legend-btn");254        it.type = "button";255        it.title = "Afficher/masquer la série";256        const key = el("span", "viz-key-line");257        key.style.background = s.color;258        if (s.dash) key.style.backgroundImage = `repeating-linear-gradient(90deg, ${s.color} 0 6px, #fff 6px 9px)`;259        it.appendChild(key);260        it.appendChild(el("span", null, s.label));261        it.addEventListener("click", () => {262          if (hidden.has(si)) hidden.delete(si);263          else if (hidden.size < all.length - 1) hidden.add(si); // toujours ≥ 1 série visible264          it.classList.toggle("dim", hidden.has(si));265          draw();266        });267        lg.appendChild(it);268        legendItems.push(it);269      });270      card.insertBefore(lg, holder);271    }272    // bandeau zoom (visible quand zoomé)273    const zoomBar = el("div", "viz-zoombar");274    const zoomLbl = el("span", "klabel");275    const zoomReset = el("button", "pill viz-zreset", "Réinitialiser le zoom");276    zoomReset.type = "button";277    zoomReset.addEventListener("click", () => { z0 = 0; z1 = tsAll.length - 1; draw(); });278    zoomBar.appendChild(zoomLbl);279    zoomBar.appendChild(zoomReset);280    zoomBar.style.display = "none";281    card.insertBefore(zoomBar, holder);282283    function draw() {284      svg.textContent = "";285      const ts = tsAll.slice(z0, z1 + 1);286      const visIdx = all.map((_, i) => i).filter((i) => !hidden.has(i));287      let vmin = Infinity, vmax = -Infinity;288      for (const si of visIdx) for (const t of ts) {289        const v = maps[si].get(t);290        if (typeof v === "number") { vmin = Math.min(vmin, v); vmax = Math.max(vmax, v); }291      }292      if (!isFinite(vmin)) { vmin = 0; vmax = 1; }293      let lo0 = vmin, hi0 = vmax;294      if (opts.baselineZero || (vmin >= 0 && vmax > 0 && vmin / vmax < 0.35)) lo0 = 0;295      const { lo, hi, ticks } = niceTicks(lo0, hi0, 4);296      const X = (i) => padL + (ts.length === 1 ? plotW / 2 : (i / (ts.length - 1)) * plotW);297      const Y = (v) => padT + plotH - ((v - lo) / (hi - lo || 1)) * plotH;298      for (const tk of ticks) {299        svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(tk), y2: Y(tk), stroke: GRID, "stroke-width": 1 }));300        const lbl = svgEl("text", { x: padL - 8, y: Y(tk) + 3.5, "text-anchor": "end", class: "viz-tick" });301        lbl.textContent = fmtCompact(tk);302        svg.appendChild(lbl);303      }304      svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(lo), y2: Y(lo), stroke: AXIS, "stroke-width": 1 }));305      [0, Math.floor((ts.length - 1) / 2), ts.length - 1].forEach((i, k) => {306        if (i < 0 || (k > 0 && i === 0)) return;307        const lbl = svgEl("text", { x: X(i), y: H - 12, "text-anchor": k === 0 ? "start" : k === 2 ? "end" : "middle", class: "viz-tick" });308        lbl.textContent = fmtDate(ts[i]);309        svg.appendChild(lbl);310      });311      for (const si of visIdx) {312        const s = all[si];313        let d = "", started = false;314        ts.forEach((t, i) => {315          const v = maps[si].get(t);316          if (typeof v !== "number") { started = false; return; }317          d += (started ? "L" : "M") + X(i).toFixed(1) + "," + Y(v).toFixed(1);318          started = true;319        });320        if ((opts.area || visIdx.length === 1) && !s.noArea) {321          let ad = "", st = false, firstI = null, lastI = null;322          ts.forEach((t, i) => {323            const v = maps[si].get(t);324            if (typeof v !== "number") return;325            if (firstI === null) firstI = i;326            lastI = i;327            ad += (st ? "L" : "M") + X(i).toFixed(1) + "," + Y(v).toFixed(1);328            st = true;329          });330          if (firstI !== null) {331            ad += `L${X(lastI).toFixed(1)},${Y(lo).toFixed(1)}L${X(firstI).toFixed(1)},${Y(lo).toFixed(1)}Z`;332            svg.appendChild(svgEl("path", { d: ad, fill: s.color, opacity: 0.1 }));333          }334        }335        const attrs = { d, fill: "none", stroke: s.color, "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round" };336        if (s.dash) attrs["stroke-dasharray"] = s.dash;337        if (s.faded) attrs.opacity = 0.55;338        svg.appendChild(svgEl("path", attrs));339        for (let i = ts.length - 1; i >= 0; i--) {340          const v = maps[si].get(ts[i]);341          if (typeof v === "number") {342            svg.appendChild(svgEl("circle", { cx: X(i), cy: Y(v), r: 4, fill: s.color, stroke: "#fff", "stroke-width": 2 }));343            if (visIdx.length <= 2 && !s.faded) {344              const lbl = svgEl("text", { x: Math.min(X(i), W - padR - 2), y: Math.max(12, Y(v) - 9), "text-anchor": "end", class: "viz-endlbl" });345              lbl.textContent = fmtNum(v, opts.unitShort || (opts.unit === "$" ? "$" : ""));346              svg.appendChild(lbl);347            }348            break;349          }350        }351      }352      // crosshair + sélection de zoom353      const cross = svgEl("line", { y1: padT, y2: padT + plotH, stroke: AXIS, "stroke-width": 1, style: "display:none" });354      svg.appendChild(cross);355      const selRect = svgEl("rect", { y: padT, height: plotH, fill: "rgba(9,87,151,0.12)", stroke: "rgba(9,87,151,0.5)", "stroke-width": 1, style: "display:none" });356      svg.appendChild(selRect);357      const hit = svgEl("rect", { x: padL, y: padT, width: plotW, height: plotH, fill: "transparent", style: "cursor: crosshair" });358      svg.appendChild(hit);359      const idxAt = (clientX) => {360        const r = svg.getBoundingClientRect();361        const mx = ((clientX - r.left) / r.width) * W;362        return Math.max(0, Math.min(ts.length - 1, Math.round(((mx - padL) / plotW) * (ts.length - 1))));363      };364      let dragStart = null;365      hit.addEventListener("pointerdown", (ev) => {366        if (ts.length < 3) return;367        dragStart = idxAt(ev.clientX);368        hit.setPointerCapture(ev.pointerId);369      });370      hit.addEventListener("pointermove", (ev) => {371        const i = idxAt(ev.clientX);372        if (dragStart !== null) {373          const a = Math.min(dragStart, i), b = Math.max(dragStart, i);374          selRect.setAttribute("x", X(a));375          selRect.setAttribute("width", Math.max(1, X(b) - X(a)));376          selRect.style.display = "";377          hideTT(tt);378          return;379        }380        cross.setAttribute("x1", X(i)); cross.setAttribute("x2", X(i));381        cross.style.display = "";382        const rows = visIdx.map((si) => {383          const s = all[si];384          const v = maps[si].get(ts[i]);385          return { color: s.color, dash: !!s.dash, value: typeof v === "number" ? fmtNum(v, opts.unit === "$" ? "$" : "") : "—", label: s.label };386        });387        ttRows(tt, fmtDateFull(ts[i]), rows);388        showTT(holder, tt, (X(i) / W) * 100, (padT / H) * 100 + 8);389      });390      hit.addEventListener("pointerup", (ev) => {391        if (dragStart === null) return;392        const i = idxAt(ev.clientX);393        const a = Math.min(dragStart, i), b = Math.max(dragStart, i);394        dragStart = null;395        selRect.style.display = "none";396        if (b - a >= 2) { z0 = z0 + a; z1 = z0 + (b - a); draw(); }397      });398      hit.addEventListener("pointerleave", () => { cross.style.display = "none"; hideTT(tt); if (dragStart !== null) { dragStart = null; selRect.style.display = "none"; } });399      hit.addEventListener("dblclick", () => { z0 = 0; z1 = tsAll.length - 1; draw(); });400      // bandeau zoom401      const zoomed = z0 > 0 || z1 < tsAll.length - 1;402      zoomBar.style.display = zoomed ? "" : "none";403      if (zoomed) zoomLbl.textContent = `Zoom : ${fmtDateFull(tsAll[z0])} → ${fmtDateFull(tsAll[z1])} (glisser pour zoomer · double-clic pour tout revoir)`;404    }405    draw();406    if (opts.summary !== false && all.length === 1) card.appendChild(statSummary(all[0].points, opts.unit));407    twinTable(card, ["Date", ...all.map((s) => s.label)], tsAll.map((t) => [fmtDateFull(t), ...all.map((_, si) => maps[si].get(t) ?? "—")]));408    return card;409  }410411  /* ---------- barres verticales (volumes quotidiens, histogrammes) ---------- */412  function vBarChart(opts) {413    // opts: {title, unit, points:[{t|label, v}], color, isDate}414    const { card, holder, tt } = shell(opts);415    const pts = (opts.points || []).filter((p) => typeof p.v === "number");416    if (!pts.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }417    const W = 860, H = opts.height || 250;418    const padL = 56, padR = 14, padT = 12, padB = opts.rotateLabels ? 66 : 34;419    const plotW = W - padL - padR, plotH = H - padT - padB;420    const vmax = Math.max(...pts.map((p) => p.v), 1);421    const { hi, ticks } = niceTicks(0, vmax, 4);422    const n = pts.length;423    const slot = plotW / n;424    const bw = Math.min(24, Math.max(2, slot - 2)); // écart de surface 2 px425    const X = (i) => padL + i * slot + (slot - bw) / 2;426    const Y = (v) => padT + plotH - (v / (hi || 1)) * plotH;427    const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img" });428    svg.setAttribute("aria-label", opts.title || "graphique en barres");429    for (const tk of ticks) {430      svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(tk), y2: Y(tk), stroke: GRID, "stroke-width": 1 }));431      const lbl = svgEl("text", { x: padL - 8, y: Y(tk) + 3.5, "text-anchor": "end", class: "viz-tick" });432      lbl.textContent = fmtCompact(tk);433      svg.appendChild(lbl);434    }435    svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(0), y2: Y(0), stroke: AXIS, "stroke-width": 1 }));436    const color = opts.color;437    pts.forEach((p, i) => {438      const x = X(i), y = Y(p.v), h = Y(0) - y;439      const r = Math.min(4, bw / 2, h); // bout arrondi côté donnée, carré à la base440      const d = h <= 0.5441        ? `M${x},${Y(0)}h${bw}v-0.5h-${bw}Z`442        : `M${x},${Y(0)} L${x},${y + r} Q${x},${y} ${x + r},${y} L${x + bw - r},${y} Q${x + bw},${y} ${x + bw},${y + r} L${x + bw},${Y(0)} Z`;443      const bar = svgEl("path", { d, fill: color, class: "viz-bar" });444      const label = opts.isDate === false ? String(p.label ?? p.t) : fmtDateFull(p.t);445      bar.addEventListener("pointerenter", () => {446        bar.style.opacity = "0.75";447        ttRows(tt, label, [{ color, value: fmtNum(p.v, opts.unit === "$" ? "$" : ""), label: opts.unit && opts.unit !== "$" ? opts.unit : "" }]);448        showTT(holder, tt, ((x + bw / 2) / W) * 100, (y / H) * 100);449      });450      bar.addEventListener("pointerleave", () => { bar.style.opacity = ""; hideTT(tt); });451      svg.appendChild(bar);452    });453    // repères x454    if (opts.isDate === false) {455      const step = Math.ceil(n / (opts.rotateLabels ? 14 : 8));456      pts.forEach((p, i) => {457        if (i % step) return;458        const lbl = svgEl("text", {459          x: X(i) + bw / 2, y: H - (opts.rotateLabels ? 8 : 12), class: "viz-tick",460          "text-anchor": opts.rotateLabels ? "end" : "middle",461          transform: opts.rotateLabels ? `rotate(-35 ${X(i) + bw / 2} ${H - 8})` : "",462        });463        lbl.textContent = String(p.label ?? p.t).slice(0, 16);464        svg.appendChild(lbl);465      });466    } else {467      [0, Math.floor((n - 1) / 2), n - 1].forEach((i, k) => {468        const lbl = svgEl("text", { x: X(i) + bw / 2, y: H - 12, "text-anchor": k === 0 ? "start" : k === 2 ? "end" : "middle", class: "viz-tick" });469        lbl.textContent = fmtDate(pts[i].t);470        svg.appendChild(lbl);471      });472    }473    holder.appendChild(svg);474    twinTable(card, [opts.isDate === false ? "Catégorie" : "Date", opts.unit || "Valeur"], pts.map((p) => [opts.isDate === false ? String(p.label ?? p.t) : fmtDateFull(p.t), p.v]));475    return card;476  }477478  /* ---------- barres horizontales (répartitions, géo) ---------- */479  function hBarChart(opts) {480    // opts: {title, unit, items:[{label, value, delta_pct}], color, max}481    const { card, holder } = shell(opts);482    const items = (opts.items || []).filter((it) => typeof it.value === "number").slice(0, opts.max || 14);483    if (!items.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }484    const vmax = Math.max(...items.map((it) => it.value), 1);485    const list = el("div", "viz-hbars");486    for (const it of items) {487      const row = el("div", "viz-hbar-row");488      const top = el("div", "viz-hbar-top");489      top.appendChild(el("span", "viz-hbar-label", it.label));490      const right = el("span", "viz-hbar-val");491      right.appendChild(el("b", null, fmtNum(it.value, opts.unit === "$" ? "$" : "")));492      if (typeof it.delta_pct === "number") {493        const d = el("span", "viz-delta " + (it.delta_pct >= 0 ? "up" : "down"), (it.delta_pct >= 0 ? "▲ " : "▼ ") + nf1.format(Math.abs(it.delta_pct)) + NBSP + "%");494        right.appendChild(d);495      }496      top.appendChild(right);497      row.appendChild(top);498      const track = el("div", "viz-hbar-track");499      const bar = el("div", "viz-hbar-fill");500      bar.style.width = Math.max(0.8, (it.value / vmax) * 100) + "%";501      bar.style.background = opts.color;502      track.appendChild(bar);503      row.appendChild(track);504      list.appendChild(row);505    }506    holder.appendChild(list);507    twinTable(card, ["Catégorie", opts.unit || "Valeur", "Δ %"], items.map((it) => [it.label, it.value, typeof it.delta_pct === "number" ? nf1.format(it.delta_pct) + " %" : "—"]));508    return card;509  }510511  /* ---------- anneau (part-du-tout, ≤ 6 segments + Autres) ---------- */512  function donut(opts) {513    // opts: {title, unit, items:[{label, value}]}514    const { card, holder, tt } = shell(opts);515    let items = (opts.items || []).filter((it) => typeof it.value === "number" && it.value > 0);516    if (!items.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }517    items = [...items].sort((a, b) => b.value - a.value);518    if (items.length > 6) {519      const rest = items.slice(5).reduce((a, it) => a + it.value, 0);520      items = [...items.slice(0, 5), { label: "Autres", value: rest }];521    }522    const total = items.reduce((a, it) => a + it.value, 0);523    const W = 420, H = 230, cx = 115, cy = 115, R = 88, TH = 30;524    const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg viz-donut", role: "img" });525    svg.setAttribute("aria-label", opts.title || "anneau");526    const PAD = 0.024; // écart de surface entre arcs527    let a0 = -Math.PI / 2;528    const arcs = [];529    items.forEach((it, i) => {530      const frac = it.value / total;531      const a1 = a0 + frac * Math.PI * 2;532      const s = a0 + PAD / 2, e = Math.max(s + 0.005, a1 - PAD / 2);533      const large = e - s > Math.PI ? 1 : 0;534      const p = (a, r) => [cx + Math.cos(a) * r, cy + Math.sin(a) * r];535      const [x1, y1] = p(s, R), [x2, y2] = p(e, R), [x3, y3] = p(e, R - TH), [x4, y4] = p(s, R - TH);536      const d = `M${x1},${y1} A${R},${R} 0 ${large} 1 ${x2},${y2} L${x3},${y3} A${R - TH},${R - TH} 0 ${large} 0 ${x4},${y4} Z`;537      const color = i === 5 && it.label === "Autres" ? "#b9b6ac" : CAT[i];538      const path = svgEl("path", { d, fill: color, class: "viz-arc" });539      const pct = nf1.format(frac * 100) + NBSP + "%";540      path.addEventListener("pointerenter", () => {541        path.style.opacity = "0.78";542        ttRows(tt, it.label, [{ color, value: fmtNum(it.value, opts.unit === "$" ? "$" : ""), label: pct }]);543        showTT(holder, tt, ((cx + Math.cos((s + e) / 2) * R) / W) * 100, ((cy + Math.sin((s + e) / 2) * R) / H) * 100);544      });545      path.addEventListener("pointerleave", () => { path.style.opacity = ""; hideTT(tt); });546      svg.appendChild(path);547      arcs.push({ ...it, color, pct });548      a0 = a1;549    });550    const ctr = svgEl("text", { x: cx, y: cy - 2, "text-anchor": "middle", class: "viz-donut-total" });551    ctr.textContent = fmtCompact(total);552    svg.appendChild(ctr);553    const ctr2 = svgEl("text", { x: cx, y: cy + 16, "text-anchor": "middle", class: "viz-tick" });554    ctr2.textContent = "total";555    svg.appendChild(ctr2);556    // légende à droite (identité par pastille + libellé, jamais couleur seule)557    arcs.forEach((a, i) => {558      const y = 28 + i * 32;559      svg.appendChild(svgEl("rect", { x: 232, y: y - 10, width: 12, height: 12, rx: 3, fill: a.color }));560      const l1 = svgEl("text", { x: 252, y, class: "viz-legend-txt" });561      l1.textContent = a.label.length > 20 ? a.label.slice(0, 19) + "…" : a.label;562      svg.appendChild(l1);563      const l2 = svgEl("text", { x: W - 6, y, "text-anchor": "end", class: "viz-legend-num" });564      l2.textContent = a.pct;565      svg.appendChild(l2);566    });567    holder.appendChild(svg);568    twinTable(card, ["Catégorie", opts.unit || "Valeur", "Part"], arcs.map((a) => [a.label, a.value, a.pct]));569    return card;570  }571572  /* ---------- barres empilées (composition dans le temps) ---------- */573  function stackedBar(opts) {574    // opts: {title, unit, keys:[...], points:[{t, values:[...]}]}575    const { card, holder, tt } = shell(opts);576    let keys = opts.keys || [];577    let pts = (opts.points || []).filter((p) => Array.isArray(p.values));578    if (!keys.length || !pts.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }579    // repli > 8 clés dans « Autres » (jamais de 9e teinte)580    if (keys.length > 8) {581      const totals = keys.map((_, ki) => pts.reduce((a, p) => a + (p.values[ki] || 0), 0));582      const order = totals.map((v, i) => [v, i]).sort((a, b) => b[0] - a[0]).map((x) => x[1]);583      const kept = order.slice(0, 7), rest = order.slice(7);584      keys = [...kept.map((i) => keys[i]), "Autres"];585      pts = pts.map((p) => ({ t: p.t, values: [...kept.map((i) => p.values[i] || 0), rest.reduce((a, i) => a + (p.values[i] || 0), 0)] }));586    }587    const W = 860, H = opts.height || 280;588    const padL = 56, padR = 14, padT = 12, padB = 34;589    const plotW = W - padL - padR, plotH = H - padT - padB;590    const totals = pts.map((p) => p.values.reduce((a, v) => a + (v || 0), 0));591    const { hi, ticks } = niceTicks(0, Math.max(...totals, 1), 4);592    const n = pts.length, slot = plotW / n;593    const bw = Math.min(24, Math.max(2, slot - 2));594    const X = (i) => padL + i * slot + (slot - bw) / 2;595    const Y = (v) => padT + plotH - (v / (hi || 1)) * plotH;596    const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img" });597    svg.setAttribute("aria-label", opts.title || "barres empilées");598    for (const tk of ticks) {599      svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(tk), y2: Y(tk), stroke: GRID, "stroke-width": 1 }));600      const lbl = svgEl("text", { x: padL - 8, y: Y(tk) + 3.5, "text-anchor": "end", class: "viz-tick" });601      lbl.textContent = fmtCompact(tk);602      svg.appendChild(lbl);603    }604    svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(0), y2: Y(0), stroke: AXIS, "stroke-width": 1 }));605    const colorOf = (ki) => (keys[ki] === "Autres" ? "#b9b6ac" : CAT[ki]);606    pts.forEach((p, i) => {607      let acc = 0;608      const segs = [];609      p.values.forEach((v, ki) => {610        if (!v || v <= 0) return;611        const y1 = Y(acc + v), y0 = Y(acc);612        // écart de surface 2 px entre segments (dans l'espace écran ≈ viewBox)613        const rect = svgEl("rect", { x: X(i), y: y1 + 1, width: bw, height: Math.max(0.6, y0 - y1 - 2), fill: colorOf(ki), class: "viz-bar" });614        svg.appendChild(rect);615        segs.push(rect);616        acc += v;617      });618      const label = fmtDateFull(p.t);619      const onEnter = () => {620        segs.forEach((s) => (s.style.opacity = "0.8"));621        ttRows(tt, label, keys.map((k, ki) => ({ color: colorOf(ki), value: fmtNum(p.values[ki] || 0), label: k })).filter((r) => r.value !== "0"));622        showTT(holder, tt, ((X(i) + bw / 2) / W) * 100, (Y(acc) / H) * 100);623      };624      const onLeave = () => { segs.forEach((s) => (s.style.opacity = "")); hideTT(tt); };625      segs.forEach((s) => { s.addEventListener("pointerenter", onEnter); s.addEventListener("pointerleave", onLeave); });626    });627    [0, Math.floor((n - 1) / 2), n - 1].forEach((i, k) => {628      const lbl = svgEl("text", { x: X(i) + bw / 2, y: H - 12, "text-anchor": k === 0 ? "start" : k === 2 ? "end" : "middle", class: "viz-tick" });629      lbl.textContent = fmtDate(pts[i].t);630      svg.appendChild(lbl);631    });632    holder.appendChild(svg);633    legend(card, keys.map((k, ki) => ({ label: k, color: colorOf(ki) })), "rect");634    twinTable(card, ["Date", ...keys], pts.map((p) => [fmtDateFull(p.t), ...keys.map((_, ki) => p.values[ki] || 0)]));635    return card;636  }637638  /* ---------- jauge demi-arc ---------- */639  function gauge(opts) {640    // opts: {label, value, max, unit, color, track}641    const wrap = el("div", "viz-gauge card");642    const W = 180, H = 108, cx = 90, cy = 96, R = 72, TH = 14;643    const frac = Math.max(0, Math.min(1, (opts.value || 0) / (opts.max || 100)));644    const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": `${opts.label} : ${fmtNum(opts.value, opts.unit)}` });645    const arc = (a0, a1, color, width) => {646      const p = (a) => [cx + Math.cos(a) * R, cy + Math.sin(a) * R];647      const [x1, y1] = p(a0), [x2, y2] = p(a1);648      return svgEl("path", { d: `M${x1},${y1} A${R},${R} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x2},${y2}`, fill: "none", stroke: color, "stroke-width": width, "stroke-linecap": "round" });649    };650    svg.appendChild(arc(Math.PI, Math.PI * 2, opts.track, TH)); // piste = pas clair de la même rampe651    if (frac > 0.005) svg.appendChild(arc(Math.PI, Math.PI + frac * Math.PI, opts.color, TH));652    const val = svgEl("text", { x: cx, y: cy - 6, "text-anchor": "middle", class: "viz-gauge-val" });653    val.textContent = fmtNum(opts.value, opts.unit);654    svg.appendChild(val);655    wrap.appendChild(svg);656    wrap.appendChild(el("div", "klabel viz-gauge-lbl", opts.label));657    return wrap;658  }659660  /* ---------- heatmap calendrier (26 semaines) ---------- */661  function calendarHeatmap(opts) {662    // opts: {title, cells:[{date, value}], accent}663    const { card, holder, tt } = shell(opts);664    const cells = (opts.cells || []).filter((c) => c && c.date);665    if (!cells.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }666    const byDate = new Map(cells.map((c) => [c.date.slice(0, 10), c.value]));667    const vmax = Math.max(...cells.map((c) => c.value || 0), 1);668    const ramp = seqRamp(opts.accent);669    const last = new Date(cells.map((c) => c.date.slice(0, 10)).sort().at(-1) + "T12:00:00");670    const CS = 13, GAP = 3, weeks = 26;671    const W = 40 + weeks * (CS + GAP) + 8, H = 26 + 7 * (CS + GAP) + 6;672    const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img", "aria-label": opts.title || "calendrier d'activité" });673    // aligne la dernière colonne sur la semaine du dernier jour (lundi = rangée 0)674    const dow = (d) => (d.getDay() + 6) % 7;675    const end = new Date(last); end.setDate(end.getDate() + (6 - dow(last)));676    DOWS.forEach((d, i) => {677      if (i % 2) return;678      const lbl = svgEl("text", { x: 32, y: 26 + i * (CS + GAP) + CS - 3, "text-anchor": "end", class: "viz-tick" });679      lbl.textContent = d;680      svg.appendChild(lbl);681    });682    let lastMonth = -1;683    for (let w = 0; w < weeks; w++) {684      for (let r = 0; r < 7; r++) {685        const d = new Date(end);686        d.setDate(end.getDate() - (weeks - 1 - w) * 7 - (6 - r));687        const iso = d.toISOString().slice(0, 10);688        const v = byDate.get(iso);689        const x = 40 + w * (CS + GAP), y = 22 + r * (CS + GAP);690        if (r === 0 && d.getMonth() !== lastMonth) {691          lastMonth = d.getMonth();692          const ml = svgEl("text", { x, y: 12, class: "viz-tick" });693          ml.textContent = MONTHS[lastMonth];694          svg.appendChild(ml);695        }696        const rect = svgEl("rect", { x, y, width: CS, height: CS, rx: 3, fill: typeof v === "number" && v > 0 ? ramp(Math.sqrt(v / vmax)) : "#efede6", class: "viz-cell" });697        if (typeof v === "number") {698          rect.addEventListener("pointerenter", () => {699            rect.setAttribute("stroke", INK); rect.setAttribute("stroke-width", "1.4");700            ttRows(tt, fmtDateFull(iso), [{ color: opts.accent, value: fmtNum(v), label: opts.unit || "" }]);701            showTT(holder, tt, (x / W) * 100, (y / H) * 100);702          });703          rect.addEventListener("pointerleave", () => { rect.removeAttribute("stroke"); hideTT(tt); });704        }705        svg.appendChild(rect);706      }707    }708    holder.appendChild(svg);709    const sorted = [...byDate.entries()].sort();710    twinTable(card, ["Date", opts.unit || "Valeur"], sorted.map(([d, v]) => [fmtDateFull(d), v]));711    return card;712  }713714  /* ---------- heatmap horaire 7 × 24 (dow 0 = lundi) ---------- */715  function hourHeatmap(opts) {716    const { card, holder, tt } = shell(opts);717    const cells = (opts.cells || []).filter((c) => c && typeof c.dow === "number" && typeof c.hour === "number");718    if (!cells.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }719    const grid = new Map(cells.map((c) => [c.dow + ":" + c.hour, c.value]));720    const vmax = Math.max(...cells.map((c) => c.value || 0), 1);721    const ramp = seqRamp(opts.accent);722    const CS = 26, CH = 20, GAP = 3;723    const W = 46 + 24 * (CS + GAP), H = 24 + 7 * (CH + GAP) + 6;724    const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img", "aria-label": opts.title || "activité horaire" });725    for (let h = 0; h < 24; h += 3) {726      const lbl = svgEl("text", { x: 46 + h * (CS + GAP) + CS / 2, y: 12, "text-anchor": "middle", class: "viz-tick" });727      lbl.textContent = h + " h";728      svg.appendChild(lbl);729    }730    for (let r = 0; r < 7; r++) {731      const lbl = svgEl("text", { x: 38, y: 22 + r * (CH + GAP) + CH - 5, "text-anchor": "end", class: "viz-tick" });732      lbl.textContent = DOWS[r];733      svg.appendChild(lbl);734      for (let h = 0; h < 24; h++) {735        const v = grid.get(r + ":" + h);736        const x = 46 + h * (CS + GAP), y = 18 + r * (CH + GAP);737        const rect = svgEl("rect", { x, y, width: CS, height: CH, rx: 3, fill: typeof v === "number" && v > 0 ? ramp(Math.sqrt(v / vmax)) : "#efede6", class: "viz-cell" });738        if (typeof v === "number") {739          rect.addEventListener("pointerenter", () => {740            rect.setAttribute("stroke", INK); rect.setAttribute("stroke-width", "1.4");741            ttRows(tt, `${DOWS[r]} ${h} h`, [{ color: opts.accent, value: fmtNum(v), label: opts.unit || "" }]);742            showTT(holder, tt, (x / W) * 100, (y / H) * 100);743          });744          rect.addEventListener("pointerleave", () => { rect.removeAttribute("stroke"); hideTT(tt); });745        }746        svg.appendChild(rect);747      }748    }749    holder.appendChild(svg);750    twinTable(card, ["Jour", "Heure", "Valeur"], cells.slice(0, 400).map((c) => [DOWS[c.dow] || c.dow, c.hour + " h", c.value]));751    return card;752  }753754  /* ---------- résumé statistique ---------- */755  function statSummary(points, unit) {756    const vs = (points || []).map((p) => p.v).filter((v) => typeof v === "number").sort((a, b) => a - b);757    const box = el("div", "viz-summary");758    if (vs.length < 2) return box;759    const mean = vs.reduce((a, v) => a + v, 0) / vs.length;760    const med = vs.length % 2 ? vs[(vs.length - 1) / 2] : (vs[vs.length / 2 - 1] + vs[vs.length / 2]) / 2;761    const sd = Math.sqrt(vs.reduce((a, v) => a + (v - mean) ** 2, 0) / vs.length);762    const u = unit === "$" ? "$" : "";763    const items = [["min", vs[0]], ["max", vs[vs.length - 1]], ["moy", mean], ["méd", med], ["σ", sd]];764    for (const [k, v] of items) {765      const chip = el("span", "viz-stat");766      chip.appendChild(el("span", "klabel", k));767      chip.appendChild(el("b", null, fmtNum(Math.round(v * 10) / 10, u)));768      box.appendChild(chip);769    }770    return box;771  }772773  /* ---------- carte KPI ---------- */774  function kpiCard(k, accent, deep) {775    const card = el("div", "kpi card");776    card.appendChild(el("div", "klabel kpi-label", k.label));777    const val = el("div", "kpi-value", fmtNum(k.value, k.unit));778    card.appendChild(val);779    const foot = el("div", "kpi-foot");780    if (typeof k.delta_pct === "number") {781      foot.appendChild(el("span", "viz-delta " + (k.delta_pct >= 0 ? "up" : "down"), (k.delta_pct >= 0 ? "▲ " : "▼ ") + nf1.format(Math.abs(k.delta_pct)) + NBSP + "% vs période préc."));782    }783    card.appendChild(foot);784    if (Array.isArray(k.spark) && k.spark.length > 2) {785      const sp = el("div", "kpi-spark");786      sp.appendChild(sparkline(k.spark, strokeFor(accent, deep)));787      card.appendChild(sp);788    }789    return card;790  }791792  /* ---------- tableau interactif (tri, recherche, pagination 25) ---------- */793  function dataTable(opts) {794    // opts: {title, columns, rows}795    const card = el("section", "viz-card viz-tablecard");796    const head = el("div", "viz-head");797    head.appendChild(el("div", "viz-title", opts.title || "Tableau"));798    const search = el("input", "input viz-search");799    search.type = "search";800    search.placeholder = "Filtrer…";801    search.setAttribute("aria-label", "Filtrer le tableau " + (opts.title || ""));802    head.appendChild(search);803    card.appendChild(head);804    const wrap = el("div", "tbl-wrap");805    const tbl = el("table", "viz-table viz-table-lg");806    wrap.appendChild(tbl);807    card.appendChild(wrap);808    const pager = el("div", "viz-pager");809    card.appendChild(pager);810    const cols = opts.columns || [];811    let rows = (opts.rows || []).map((r) => r.map((c) => c));812    let sortCol = -1, sortDir = 1, page = 0, query = "";813    const PAGE = 25;814    const numVal = (c) => {815      if (typeof c === "number") return c;816      const m = String(c).replace(/[\s  %$,]/g, "").replace(",", ".");817      const f = parseFloat(m);818      return Number.isNaN(f) ? null : f;819    };820    function render() {821      tbl.textContent = "";822      const thead = el("thead");823      const trh = el("tr");824      cols.forEach((c, i) => {825        const th = el("th");826        const btn = el("button", "viz-th", c + (sortCol === i ? (sortDir > 0 ? " ↑" : " ↓") : ""));827        btn.addEventListener("click", () => { if (sortCol === i) sortDir *= -1; else { sortCol = i; sortDir = -1; } page = 0; render(); });828        th.appendChild(btn);829        if (sortCol === i) th.setAttribute("aria-sort", sortDir > 0 ? "ascending" : "descending");830        trh.appendChild(th);831      });832      thead.appendChild(trh);833      tbl.appendChild(thead);834      let view = rows;835      if (query) {836        const q = query.toLowerCase();837        view = rows.filter((r) => r.some((c) => String(c).toLowerCase().includes(q)));838      }839      if (sortCol >= 0) {840        view = [...view].sort((a, b) => {841          const na = numVal(a[sortCol]), nb = numVal(b[sortCol]);842          if (na !== null && nb !== null) return (na - nb) * sortDir;843          return String(a[sortCol]).localeCompare(String(b[sortCol]), "fr") * sortDir;844        });845      }846      const pages = Math.max(1, Math.ceil(view.length / PAGE));847      page = Math.min(page, pages - 1);848      const tb = el("tbody");849      for (const r of view.slice(page * PAGE, page * PAGE + PAGE)) {850        const tr = el("tr");851        r.forEach((c, i) => {852          const isNum = numVal(c) !== null && i > 0;853          tr.appendChild(el("td", isNum ? "num" : null, typeof c === "number" ? fmtNum(c) : String(c ?? "—")));854        });855        tb.appendChild(tr);856      }857      tbl.appendChild(tb);858      pager.textContent = "";859      pager.appendChild(el("span", "klabel", `${nfInt.format(view.length)} lignes`));860      if (pages > 1) {861        const nav = el("span", "viz-pager-nav");862        const prev = el("button", "btn btn-ghost viz-pgbtn", "←");863        prev.disabled = page === 0;864        prev.addEventListener("click", () => { page--; render(); });865        const next = el("button", "btn btn-ghost viz-pgbtn", "→");866        next.disabled = page >= pages - 1;867        next.addEventListener("click", () => { page++; render(); });868        nav.appendChild(prev);869        nav.appendChild(el("span", "klabel", ` ${page + 1}/${pages} `));870        nav.appendChild(next);871        pager.appendChild(nav);872      }873    }874    search.addEventListener("input", () => { query = search.value.trim(); page = 0; render(); });875    render();876    return card;877  }878879  /* ---------- exports PNG / CSV ---------- */880  function downloadBlob(name, blob) {881    const a = document.createElement("a");882    a.href = URL.createObjectURL(blob);883    a.download = name;884    document.body.appendChild(a);885    a.click();886    setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 800);887  }888  function exportCSV(name, columns, rows) {889    const esc = (c) => {890      const s = String(c ?? "");891      return /[",;\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;892    };893    const lines = [columns.map(esc).join(";"), ...rows.map((r) => r.map(esc).join(";"))];894    downloadBlob(name + ".csv", new Blob(["" + lines.join("\n")], { type: "text/csv;charset=utf-8" }));895  }896  const SVG_EXPORT_CSS = `897    text{font-family:system-ui,-apple-system,sans-serif;}898    .viz-tick{font-family:ui-monospace,monospace;font-size:10.5px;fill:#8b928c;}899    .viz-endlbl{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;fill:#141814;paint-order:stroke;stroke:#fff;stroke-width:3px;}900    .viz-donut-total{font-weight:700;font-size:26px;fill:#141814;}901    .viz-legend-txt{font-size:12.5px;fill:#4d5551;}902    .viz-legend-num{font-family:ui-monospace,monospace;font-size:11.5px;font-weight:700;fill:#141814;}903    .viz-gauge-val{font-weight:700;font-size:24px;fill:#141814;}904  `;905  function exportPNG(container, name) {906    const src = container.querySelector("svg.viz-svg");907    if (!src) return;908    const clone = src.cloneNode(true);909    const style = document.createElementNS("http://www.w3.org/2000/svg", "style");910    style.textContent = SVG_EXPORT_CSS;911    clone.insertBefore(style, clone.firstChild);912    const vb = (src.getAttribute("viewBox") || "0 0 860 280").split(/\s+/).map(Number);913    const w = vb[2], h = vb[3];914    clone.setAttribute("width", w);915    clone.setAttribute("height", h);916    const xml = new XMLSerializer().serializeToString(clone);917    const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }));918    const img = new Image();919    img.onload = () => {920      const canvas = document.createElement("canvas");921      canvas.width = w * 2; canvas.height = h * 2;922      const ctx = canvas.getContext("2d");923      ctx.fillStyle = "#ffffff";924      ctx.fillRect(0, 0, canvas.width, canvas.height);925      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);926      URL.revokeObjectURL(url);927      canvas.toBlob((b) => b && downloadBlob(name + ".png", b), "image/png");928    };929    img.src = url;930  }931932  /* ---------- carte à type commutable + outils (CSV/PNG/Studio) ---------- */933  function switchableChart(spec) {934    // spec: {kinds:[{id,label}], initial, render(kindId)=>node, csv:{name,columns,rows}, pngName, studioHref}935    const wrap = el("div", "viz-switch");936    const bar = el("div", "viz-toolbar");937    let current = spec.initial || (spec.kinds && spec.kinds[0] && spec.kinds[0].id);938    const slot = el("div", "viz-slot");939    const pillEls = [];940    if (spec.kinds && spec.kinds.length > 1) {941      const kinds = el("div", "viz-kindrow");942      kinds.setAttribute("role", "group");943      kinds.setAttribute("aria-label", "Type de graphique");944      for (const k of spec.kinds) {945        const b = el("button", "pill viz-kindpill" + (k.id === current ? " active" : ""), k.label);946        b.type = "button";947        b.addEventListener("click", () => {948          current = k.id;949          pillEls.forEach((p) => p.classList.toggle("active", p === b));950          show();951        });952        pillEls.push(b);953        kinds.appendChild(b);954      }955      bar.appendChild(kinds);956    }957    const tools = el("div", "viz-tools");958    if (spec.csv) {959      const b = el("button", "viz-toolbtn", "⭳ CSV");960      b.type = "button"; b.title = "Exporter les données (CSV)";961      b.addEventListener("click", () => exportCSV(spec.csv.name, spec.csv.columns, spec.csv.rows));962      tools.appendChild(b);963    }964    if (spec.pngName) {965      const b = el("button", "viz-toolbtn", "⭳ PNG");966      b.type = "button"; b.title = "Exporter l'image (PNG)";967      b.addEventListener("click", () => exportPNG(slot, spec.pngName));968      tools.appendChild(b);969    }970    if (spec.studioHref) {971      const a = el("a", "viz-toolbtn", "✦ Studio");972      a.href = spec.studioHref;973      a.dataset.link = "1";974      a.title = "Ouvrir cette métrique dans le Studio d'indicateurs";975      tools.appendChild(a);976    }977    if (tools.childNodes.length) bar.appendChild(tools);978    if (bar.childNodes.length) wrap.appendChild(bar);979    wrap.appendChild(slot);980    function show() {981      slot.textContent = "";982      slot.appendChild(spec.render(current));983    }984    show();985    return wrap;986  }987988  return {989    CAT, DASHES, GREEN, DANGER,990    fmtNum, fmtCompact, fmtDate, fmtDateFull, el, strokeFor, seqRamp, mix, contrastWhite,991    sparkline, lineChart, vBarChart, hBarChart, donut, stackedBar, gauge,992    calendarHeatmap, hourHeatmap, statSummary, kpiCard, dataTable,993    exportCSV, exportPNG, switchableChart,994  };995})();996