JavaScript 73.4%
CSS 25.3%
HTML 1.3%
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