/* ----------------------------------------------------------------------------- Auteur : Simon-Pierre Boucher — contact@spboucher.ai Fichier : ka-stats/public/charts.js Desc. : QCharts — bibliothèque de graphiques SVG vanilla de Ka·Stats (contrat ka-stats v2). Règles dataviz appliquées : marques fines (lignes 2 px, barres ≤ 24 px à bout arrondi 4 px), écarts de surface 2 px, grilles hairline pleines, légende dès 2 séries, texte toujours en encre, infobulle crosshair (lignes) ou par-marque (barres/ cellules), vue tableau jumelle sur CHAQUE graphique, rampe séquentielle mono-teinte, palette catégorielle validée en ordre fixe. ----------------------------------------------------------------------------- */ "use strict"; const QCharts = (() => { /* Palette catégorielle validée (validate_palette.js — ordre fixe, jamais cyclée) */ const CAT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"]; const DASHES = ["", "7 4", "2 3", "10 3 2 3"]; // identité multi-courbes ≤ 4 (jamais couleur seule) const INK = "#141814", INK2 = "#4d5551", INK3 = "#8b928c"; const GRID = "rgba(20,24,20,0.10)", AXIS = "rgba(20,24,20,0.28)"; const GREEN = "#1c5c41", DANGER = "#b3423a"; /* ---------- utilitaires ---------- */ const NBSP = " "; const nfInt = new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 0 }); const nf1 = new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 1, minimumFractionDigits: 0 }); const nf2 = new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 2 }); function fmtNum(v, unit) { if (v === null || v === undefined || Number.isNaN(v)) return "—"; if (typeof v !== "number") return String(v); let s; const av = Math.abs(v); if (Number.isInteger(v)) s = nfInt.format(v); else s = (av < 10 ? nf2 : nf1).format(v); if (unit) { const u = String(unit).trim(); if (u === "%") return s + NBSP + "%"; if (u === "$") return s + NBSP + "$"; return s + NBSP + u; } return s; } function fmtCompact(v) { const av = Math.abs(v); if (av >= 1e6) return nf1.format(v / 1e6) + NBSP + "M"; if (av >= 10000) return nfInt.format(Math.round(v / 1000)) + NBSP + "k"; if (av >= 1000) return nf1.format(v / 1000) + NBSP + "k"; return Number.isInteger(v) ? nfInt.format(v) : nf1.format(v); } const MONTHS = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."]; const DOWS = ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"]; function fmtDate(t) { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(t)); if (!m) return String(t); return `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]}`; } function fmtDateFull(t) { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(t)); if (!m) return String(t); return `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]} ${m[1]}`; } function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text !== undefined) n.textContent = text; // jamais innerHTML avec des libellés externes return n; } function svgEl(tag, attrs) { const n = document.createElementNS("http://www.w3.org/2000/svg", tag); for (const k in attrs || {}) n.setAttribute(k, attrs[k]); return n; } /* couleur : garde de contraste + rampe mono-teinte */ function hexRgb(h) { const s = h.replace("#", ""); return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)]; } function rgbHex(r, g, b) { const c = (x) => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, "0"); return "#" + c(r) + c(g) + c(b); } function mix(h1, h2, t) { const a = hexRgb(h1), b = hexRgb(h2); return rgbHex(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t); } function relLum(h) { const [r, g, b] = hexRgb(h).map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } function contrastWhite(h) { return 1.05 / (relLum(h) + 0.05); } /* trait de série : l'accent si assez contrasté sur blanc, sinon sa version deep */ function strokeFor(accent, deep) { return contrastWhite(accent) >= 3 ? accent : deep || mix(accent, INK, 0.45); } /* rampe séquentielle mono-teinte (clair → foncé), monotone en luminosité */ function seqRamp(accent) { const base = contrastWhite(accent) >= 2 ? accent : mix(accent, INK, 0.35); return (t) => (t <= 0 ? "#f1efe8" : mix(mix("#ffffff", base, 0.16 + 0.84 * t), INK, Math.max(0, t - 0.72) * 0.9)); } function niceTicks(min, max, n = 4) { if (!(max > min)) max = min + 1; const span = max - min; const step0 = Math.pow(10, Math.floor(Math.log10(span / n))); const err = span / n / step0; const step = step0 * (err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1); const lo = Math.floor(min / step) * step; const hi = Math.ceil(max / step) * step; const ticks = []; for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Math.round(v * 1e6) / 1e6); return { lo, hi, ticks }; } /* ---------- coquille de graphique : carte + infobulle + vue tableau ---------- */ function shell(opts) { const card = el("figure", "viz-card"); if (opts.title) { const head = el("figcaption", "viz-head"); head.appendChild(el("div", "viz-title", opts.title)); if (opts.unit) head.appendChild(el("span", "klabel viz-unit", opts.unit)); card.appendChild(head); } const holder = el("div", "viz-plot"); card.appendChild(holder); const tt = el("div", "viz-tt"); tt.setAttribute("role", "status"); holder.appendChild(tt); return { card, holder, tt }; } function showTT(holder, tt, xPct, yPct) { tt.style.display = "block"; const r = holder.getBoundingClientRect(); const tw = tt.offsetWidth, th = tt.offsetHeight; let x = (xPct / 100) * r.width + 14; if (x + tw > r.width - 4) x = (xPct / 100) * r.width - tw - 14; let y = (yPct / 100) * r.height - th - 10; if (y < 0) y = 4; tt.style.left = Math.max(2, x) + "px"; tt.style.top = y + "px"; } function hideTT(tt) { tt.style.display = "none"; } function ttRows(tt, title, rows) { tt.textContent = ""; const h = el("div", "viz-tt-title", title); tt.appendChild(h); for (const r of rows) { const row = el("div", "viz-tt-row"); if (r.color) { const key = el("span", "viz-tt-key"); key.style.background = r.color; if (r.dash) key.style.backgroundImage = `repeating-linear-gradient(90deg, ${r.color} 0 5px, #fff 5px 8px)`; row.appendChild(key); } row.appendChild(el("b", "viz-tt-val", r.value)); row.appendChild(el("span", "viz-tt-lbl", r.label || "")); tt.appendChild(row); } } /* vue tableau jumelle (l'infobulle ne « garde » jamais une valeur) */ function twinTable(card, columns, rows) { const det = el("details", "viz-data"); det.appendChild(el("summary", null, "Données")); const wrap = el("div", "tbl-wrap"); const tbl = el("table", "viz-table"); const thead = el("thead"); const trh = el("tr"); for (const c of columns) trh.appendChild(el("th", null, c)); thead.appendChild(trh); tbl.appendChild(thead); const tb = el("tbody"); for (const r of rows.slice(0, 400)) { const tr = el("tr"); r.forEach((c, i) => { const td = el("td", i > 0 ? "num" : null, typeof c === "number" ? fmtNum(c) : String(c ?? "—")); tr.appendChild(td); }); tb.appendChild(tr); } tbl.appendChild(tb); wrap.appendChild(tbl); det.appendChild(wrap); card.appendChild(det); return det; } function legend(card, entries, kind) { const lg = el("div", "viz-legend"); for (const e of entries) { const it = el("span", "viz-legend-item"); const key = el("span", kind === "line" ? "viz-key-line" : "viz-key-rect"); key.style.background = e.color; if (e.dash && kind === "line") key.style.backgroundImage = `repeating-linear-gradient(90deg, ${e.color} 0 6px, #fff 6px 9px)`; it.appendChild(key); it.appendChild(el("span", null, e.label)); lg.appendChild(it); } card.insertBefore(lg, card.querySelector(".viz-plot")); return lg; } /* ---------- sparkline (mini tendance de KPI) ---------- */ function sparkline(points, color, w = 110, h = 30) { const vs = points.map((p) => p.v).filter((v) => typeof v === "number"); if (!vs.length) return svgEl("svg", { width: w, height: h }); const min = Math.min(...vs), max = Math.max(...vs); const span = max - min || 1; const svg = svgEl("svg", { viewBox: `0 0 ${w} ${h}`, class: "spark", "aria-hidden": "true" }); const n = points.length; const px = (i) => 2 + (i / Math.max(1, n - 1)) * (w - 8); const py = (v) => h - 4 - ((v - min) / span) * (h - 9); let d = ""; points.forEach((p, i) => { d += (i ? "L" : "M") + px(i).toFixed(1) + "," + py(p.v).toFixed(1); }); svg.appendChild(svgEl("path", { d, fill: "none", stroke: color, "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round" })); const last = points[n - 1]; svg.appendChild(svgEl("circle", { cx: px(n - 1), cy: py(last.v), r: 3.2, fill: color, stroke: "#fff", "stroke-width": 2 })); return svg; } /* ---------- courbes / aires — INTERACTIF ---------- · crosshair + infobulle toutes séries · zoom par sélection horizontale (glisser), double-clic = réinitialiser · légende cliquable (≥ 2 séries) pour masquer/afficher une série */ function lineChart(opts) { const { card, holder, tt } = shell(opts); const W = 860, H = opts.height || 280; const padL = 56, padR = 18, padT = 14, padB = 34; const plotW = W - padL - padR, plotH = H - padT - padB; const all = opts.series.filter((s) => s.points && s.points.length); if (!all.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; } const tsetAll = new Set(); all.forEach((s) => s.points.forEach((p) => tsetAll.add(p.t))); const tsAll = [...tsetAll].sort(); const maps = all.map((s) => new Map(s.points.map((p) => [p.t, p.v]))); const hidden = new Set(); let z0 = 0, z1 = tsAll.length - 1; const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img" }); svg.setAttribute("aria-label", opts.title || "graphique en courbes"); svg.style.touchAction = "pan-y"; holder.appendChild(svg); // légende interactive let legendItems = []; if (all.length >= 2) { const lg = el("div", "viz-legend"); all.forEach((s, si) => { const it = el("button", "viz-legend-item viz-legend-btn"); it.type = "button"; it.title = "Afficher/masquer la série"; const key = el("span", "viz-key-line"); key.style.background = s.color; if (s.dash) key.style.backgroundImage = `repeating-linear-gradient(90deg, ${s.color} 0 6px, #fff 6px 9px)`; it.appendChild(key); it.appendChild(el("span", null, s.label)); it.addEventListener("click", () => { if (hidden.has(si)) hidden.delete(si); else if (hidden.size < all.length - 1) hidden.add(si); // toujours ≥ 1 série visible it.classList.toggle("dim", hidden.has(si)); draw(); }); lg.appendChild(it); legendItems.push(it); }); card.insertBefore(lg, holder); } // bandeau zoom (visible quand zoomé) const zoomBar = el("div", "viz-zoombar"); const zoomLbl = el("span", "klabel"); const zoomReset = el("button", "pill viz-zreset", "Réinitialiser le zoom"); zoomReset.type = "button"; zoomReset.addEventListener("click", () => { z0 = 0; z1 = tsAll.length - 1; draw(); }); zoomBar.appendChild(zoomLbl); zoomBar.appendChild(zoomReset); zoomBar.style.display = "none"; card.insertBefore(zoomBar, holder); function draw() { svg.textContent = ""; const ts = tsAll.slice(z0, z1 + 1); const visIdx = all.map((_, i) => i).filter((i) => !hidden.has(i)); let vmin = Infinity, vmax = -Infinity; for (const si of visIdx) for (const t of ts) { const v = maps[si].get(t); if (typeof v === "number") { vmin = Math.min(vmin, v); vmax = Math.max(vmax, v); } } if (!isFinite(vmin)) { vmin = 0; vmax = 1; } let lo0 = vmin, hi0 = vmax; if (opts.baselineZero || (vmin >= 0 && vmax > 0 && vmin / vmax < 0.35)) lo0 = 0; const { lo, hi, ticks } = niceTicks(lo0, hi0, 4); const X = (i) => padL + (ts.length === 1 ? plotW / 2 : (i / (ts.length - 1)) * plotW); const Y = (v) => padT + plotH - ((v - lo) / (hi - lo || 1)) * plotH; for (const tk of ticks) { svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(tk), y2: Y(tk), stroke: GRID, "stroke-width": 1 })); const lbl = svgEl("text", { x: padL - 8, y: Y(tk) + 3.5, "text-anchor": "end", class: "viz-tick" }); lbl.textContent = fmtCompact(tk); svg.appendChild(lbl); } svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(lo), y2: Y(lo), stroke: AXIS, "stroke-width": 1 })); [0, Math.floor((ts.length - 1) / 2), ts.length - 1].forEach((i, k) => { if (i < 0 || (k > 0 && i === 0)) return; const lbl = svgEl("text", { x: X(i), y: H - 12, "text-anchor": k === 0 ? "start" : k === 2 ? "end" : "middle", class: "viz-tick" }); lbl.textContent = fmtDate(ts[i]); svg.appendChild(lbl); }); for (const si of visIdx) { const s = all[si]; let d = "", started = false; ts.forEach((t, i) => { const v = maps[si].get(t); if (typeof v !== "number") { started = false; return; } d += (started ? "L" : "M") + X(i).toFixed(1) + "," + Y(v).toFixed(1); started = true; }); if ((opts.area || visIdx.length === 1) && !s.noArea) { let ad = "", st = false, firstI = null, lastI = null; ts.forEach((t, i) => { const v = maps[si].get(t); if (typeof v !== "number") return; if (firstI === null) firstI = i; lastI = i; ad += (st ? "L" : "M") + X(i).toFixed(1) + "," + Y(v).toFixed(1); st = true; }); if (firstI !== null) { ad += `L${X(lastI).toFixed(1)},${Y(lo).toFixed(1)}L${X(firstI).toFixed(1)},${Y(lo).toFixed(1)}Z`; svg.appendChild(svgEl("path", { d: ad, fill: s.color, opacity: 0.1 })); } } const attrs = { d, fill: "none", stroke: s.color, "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round" }; if (s.dash) attrs["stroke-dasharray"] = s.dash; if (s.faded) attrs.opacity = 0.55; svg.appendChild(svgEl("path", attrs)); for (let i = ts.length - 1; i >= 0; i--) { const v = maps[si].get(ts[i]); if (typeof v === "number") { svg.appendChild(svgEl("circle", { cx: X(i), cy: Y(v), r: 4, fill: s.color, stroke: "#fff", "stroke-width": 2 })); if (visIdx.length <= 2 && !s.faded) { 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" }); lbl.textContent = fmtNum(v, opts.unitShort || (opts.unit === "$" ? "$" : "")); svg.appendChild(lbl); } break; } } } // crosshair + sélection de zoom const cross = svgEl("line", { y1: padT, y2: padT + plotH, stroke: AXIS, "stroke-width": 1, style: "display:none" }); svg.appendChild(cross); 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" }); svg.appendChild(selRect); const hit = svgEl("rect", { x: padL, y: padT, width: plotW, height: plotH, fill: "transparent", style: "cursor: crosshair" }); svg.appendChild(hit); const idxAt = (clientX) => { const r = svg.getBoundingClientRect(); const mx = ((clientX - r.left) / r.width) * W; return Math.max(0, Math.min(ts.length - 1, Math.round(((mx - padL) / plotW) * (ts.length - 1)))); }; let dragStart = null; hit.addEventListener("pointerdown", (ev) => { if (ts.length < 3) return; dragStart = idxAt(ev.clientX); hit.setPointerCapture(ev.pointerId); }); hit.addEventListener("pointermove", (ev) => { const i = idxAt(ev.clientX); if (dragStart !== null) { const a = Math.min(dragStart, i), b = Math.max(dragStart, i); selRect.setAttribute("x", X(a)); selRect.setAttribute("width", Math.max(1, X(b) - X(a))); selRect.style.display = ""; hideTT(tt); return; } cross.setAttribute("x1", X(i)); cross.setAttribute("x2", X(i)); cross.style.display = ""; const rows = visIdx.map((si) => { const s = all[si]; const v = maps[si].get(ts[i]); return { color: s.color, dash: !!s.dash, value: typeof v === "number" ? fmtNum(v, opts.unit === "$" ? "$" : "") : "—", label: s.label }; }); ttRows(tt, fmtDateFull(ts[i]), rows); showTT(holder, tt, (X(i) / W) * 100, (padT / H) * 100 + 8); }); hit.addEventListener("pointerup", (ev) => { if (dragStart === null) return; const i = idxAt(ev.clientX); const a = Math.min(dragStart, i), b = Math.max(dragStart, i); dragStart = null; selRect.style.display = "none"; if (b - a >= 2) { z0 = z0 + a; z1 = z0 + (b - a); draw(); } }); hit.addEventListener("pointerleave", () => { cross.style.display = "none"; hideTT(tt); if (dragStart !== null) { dragStart = null; selRect.style.display = "none"; } }); hit.addEventListener("dblclick", () => { z0 = 0; z1 = tsAll.length - 1; draw(); }); // bandeau zoom const zoomed = z0 > 0 || z1 < tsAll.length - 1; zoomBar.style.display = zoomed ? "" : "none"; if (zoomed) zoomLbl.textContent = `Zoom : ${fmtDateFull(tsAll[z0])} → ${fmtDateFull(tsAll[z1])} (glisser pour zoomer · double-clic pour tout revoir)`; } draw(); if (opts.summary !== false && all.length === 1) card.appendChild(statSummary(all[0].points, opts.unit)); twinTable(card, ["Date", ...all.map((s) => s.label)], tsAll.map((t) => [fmtDateFull(t), ...all.map((_, si) => maps[si].get(t) ?? "—")])); return card; } /* ---------- barres verticales (volumes quotidiens, histogrammes) ---------- */ function vBarChart(opts) { // opts: {title, unit, points:[{t|label, v}], color, isDate} const { card, holder, tt } = shell(opts); const pts = (opts.points || []).filter((p) => typeof p.v === "number"); if (!pts.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; } const W = 860, H = opts.height || 250; const padL = 56, padR = 14, padT = 12, padB = opts.rotateLabels ? 66 : 34; const plotW = W - padL - padR, plotH = H - padT - padB; const vmax = Math.max(...pts.map((p) => p.v), 1); const { hi, ticks } = niceTicks(0, vmax, 4); const n = pts.length; const slot = plotW / n; const bw = Math.min(24, Math.max(2, slot - 2)); // écart de surface 2 px const X = (i) => padL + i * slot + (slot - bw) / 2; const Y = (v) => padT + plotH - (v / (hi || 1)) * plotH; const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img" }); svg.setAttribute("aria-label", opts.title || "graphique en barres"); for (const tk of ticks) { svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(tk), y2: Y(tk), stroke: GRID, "stroke-width": 1 })); const lbl = svgEl("text", { x: padL - 8, y: Y(tk) + 3.5, "text-anchor": "end", class: "viz-tick" }); lbl.textContent = fmtCompact(tk); svg.appendChild(lbl); } svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(0), y2: Y(0), stroke: AXIS, "stroke-width": 1 })); const color = opts.color; pts.forEach((p, i) => { const x = X(i), y = Y(p.v), h = Y(0) - y; const r = Math.min(4, bw / 2, h); // bout arrondi côté donnée, carré à la base const d = h <= 0.5 ? `M${x},${Y(0)}h${bw}v-0.5h-${bw}Z` : `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`; const bar = svgEl("path", { d, fill: color, class: "viz-bar" }); const label = opts.isDate === false ? String(p.label ?? p.t) : fmtDateFull(p.t); bar.addEventListener("pointerenter", () => { bar.style.opacity = "0.75"; ttRows(tt, label, [{ color, value: fmtNum(p.v, opts.unit === "$" ? "$" : ""), label: opts.unit && opts.unit !== "$" ? opts.unit : "" }]); showTT(holder, tt, ((x + bw / 2) / W) * 100, (y / H) * 100); }); bar.addEventListener("pointerleave", () => { bar.style.opacity = ""; hideTT(tt); }); svg.appendChild(bar); }); // repères x if (opts.isDate === false) { const step = Math.ceil(n / (opts.rotateLabels ? 14 : 8)); pts.forEach((p, i) => { if (i % step) return; const lbl = svgEl("text", { x: X(i) + bw / 2, y: H - (opts.rotateLabels ? 8 : 12), class: "viz-tick", "text-anchor": opts.rotateLabels ? "end" : "middle", transform: opts.rotateLabels ? `rotate(-35 ${X(i) + bw / 2} ${H - 8})` : "", }); lbl.textContent = String(p.label ?? p.t).slice(0, 16); svg.appendChild(lbl); }); } else { [0, Math.floor((n - 1) / 2), n - 1].forEach((i, k) => { const lbl = svgEl("text", { x: X(i) + bw / 2, y: H - 12, "text-anchor": k === 0 ? "start" : k === 2 ? "end" : "middle", class: "viz-tick" }); lbl.textContent = fmtDate(pts[i].t); svg.appendChild(lbl); }); } holder.appendChild(svg); 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])); return card; } /* ---------- barres horizontales (répartitions, géo) ---------- */ function hBarChart(opts) { // opts: {title, unit, items:[{label, value, delta_pct}], color, max} const { card, holder } = shell(opts); const items = (opts.items || []).filter((it) => typeof it.value === "number").slice(0, opts.max || 14); if (!items.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; } const vmax = Math.max(...items.map((it) => it.value), 1); const list = el("div", "viz-hbars"); for (const it of items) { const row = el("div", "viz-hbar-row"); const top = el("div", "viz-hbar-top"); top.appendChild(el("span", "viz-hbar-label", it.label)); const right = el("span", "viz-hbar-val"); right.appendChild(el("b", null, fmtNum(it.value, opts.unit === "$" ? "$" : ""))); if (typeof it.delta_pct === "number") { const d = el("span", "viz-delta " + (it.delta_pct >= 0 ? "up" : "down"), (it.delta_pct >= 0 ? "▲ " : "▼ ") + nf1.format(Math.abs(it.delta_pct)) + NBSP + "%"); right.appendChild(d); } top.appendChild(right); row.appendChild(top); const track = el("div", "viz-hbar-track"); const bar = el("div", "viz-hbar-fill"); bar.style.width = Math.max(0.8, (it.value / vmax) * 100) + "%"; bar.style.background = opts.color; track.appendChild(bar); row.appendChild(track); list.appendChild(row); } holder.appendChild(list); twinTable(card, ["Catégorie", opts.unit || "Valeur", "Δ %"], items.map((it) => [it.label, it.value, typeof it.delta_pct === "number" ? nf1.format(it.delta_pct) + " %" : "—"])); return card; } /* ---------- anneau (part-du-tout, ≤ 6 segments + Autres) ---------- */ function donut(opts) { // opts: {title, unit, items:[{label, value}]} const { card, holder, tt } = shell(opts); let items = (opts.items || []).filter((it) => typeof it.value === "number" && it.value > 0); if (!items.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; } items = [...items].sort((a, b) => b.value - a.value); if (items.length > 6) { const rest = items.slice(5).reduce((a, it) => a + it.value, 0); items = [...items.slice(0, 5), { label: "Autres", value: rest }]; } const total = items.reduce((a, it) => a + it.value, 0); const W = 420, H = 230, cx = 115, cy = 115, R = 88, TH = 30; const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg viz-donut", role: "img" }); svg.setAttribute("aria-label", opts.title || "anneau"); const PAD = 0.024; // écart de surface entre arcs let a0 = -Math.PI / 2; const arcs = []; items.forEach((it, i) => { const frac = it.value / total; const a1 = a0 + frac * Math.PI * 2; const s = a0 + PAD / 2, e = Math.max(s + 0.005, a1 - PAD / 2); const large = e - s > Math.PI ? 1 : 0; const p = (a, r) => [cx + Math.cos(a) * r, cy + Math.sin(a) * r]; const [x1, y1] = p(s, R), [x2, y2] = p(e, R), [x3, y3] = p(e, R - TH), [x4, y4] = p(s, R - TH); 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`; const color = i === 5 && it.label === "Autres" ? "#b9b6ac" : CAT[i]; const path = svgEl("path", { d, fill: color, class: "viz-arc" }); const pct = nf1.format(frac * 100) + NBSP + "%"; path.addEventListener("pointerenter", () => { path.style.opacity = "0.78"; ttRows(tt, it.label, [{ color, value: fmtNum(it.value, opts.unit === "$" ? "$" : ""), label: pct }]); showTT(holder, tt, ((cx + Math.cos((s + e) / 2) * R) / W) * 100, ((cy + Math.sin((s + e) / 2) * R) / H) * 100); }); path.addEventListener("pointerleave", () => { path.style.opacity = ""; hideTT(tt); }); svg.appendChild(path); arcs.push({ ...it, color, pct }); a0 = a1; }); const ctr = svgEl("text", { x: cx, y: cy - 2, "text-anchor": "middle", class: "viz-donut-total" }); ctr.textContent = fmtCompact(total); svg.appendChild(ctr); const ctr2 = svgEl("text", { x: cx, y: cy + 16, "text-anchor": "middle", class: "viz-tick" }); ctr2.textContent = "total"; svg.appendChild(ctr2); // légende à droite (identité par pastille + libellé, jamais couleur seule) arcs.forEach((a, i) => { const y = 28 + i * 32; svg.appendChild(svgEl("rect", { x: 232, y: y - 10, width: 12, height: 12, rx: 3, fill: a.color })); const l1 = svgEl("text", { x: 252, y, class: "viz-legend-txt" }); l1.textContent = a.label.length > 20 ? a.label.slice(0, 19) + "…" : a.label; svg.appendChild(l1); const l2 = svgEl("text", { x: W - 6, y, "text-anchor": "end", class: "viz-legend-num" }); l2.textContent = a.pct; svg.appendChild(l2); }); holder.appendChild(svg); twinTable(card, ["Catégorie", opts.unit || "Valeur", "Part"], arcs.map((a) => [a.label, a.value, a.pct])); return card; } /* ---------- barres empilées (composition dans le temps) ---------- */ function stackedBar(opts) { // opts: {title, unit, keys:[...], points:[{t, values:[...]}]} const { card, holder, tt } = shell(opts); let keys = opts.keys || []; let pts = (opts.points || []).filter((p) => Array.isArray(p.values)); if (!keys.length || !pts.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; } // repli > 8 clés dans « Autres » (jamais de 9e teinte) if (keys.length > 8) { const totals = keys.map((_, ki) => pts.reduce((a, p) => a + (p.values[ki] || 0), 0)); const order = totals.map((v, i) => [v, i]).sort((a, b) => b[0] - a[0]).map((x) => x[1]); const kept = order.slice(0, 7), rest = order.slice(7); keys = [...kept.map((i) => keys[i]), "Autres"]; 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)] })); } const W = 860, H = opts.height || 280; const padL = 56, padR = 14, padT = 12, padB = 34; const plotW = W - padL - padR, plotH = H - padT - padB; const totals = pts.map((p) => p.values.reduce((a, v) => a + (v || 0), 0)); const { hi, ticks } = niceTicks(0, Math.max(...totals, 1), 4); const n = pts.length, slot = plotW / n; const bw = Math.min(24, Math.max(2, slot - 2)); const X = (i) => padL + i * slot + (slot - bw) / 2; const Y = (v) => padT + plotH - (v / (hi || 1)) * plotH; const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img" }); svg.setAttribute("aria-label", opts.title || "barres empilées"); for (const tk of ticks) { svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(tk), y2: Y(tk), stroke: GRID, "stroke-width": 1 })); const lbl = svgEl("text", { x: padL - 8, y: Y(tk) + 3.5, "text-anchor": "end", class: "viz-tick" }); lbl.textContent = fmtCompact(tk); svg.appendChild(lbl); } svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(0), y2: Y(0), stroke: AXIS, "stroke-width": 1 })); const colorOf = (ki) => (keys[ki] === "Autres" ? "#b9b6ac" : CAT[ki]); pts.forEach((p, i) => { let acc = 0; const segs = []; p.values.forEach((v, ki) => { if (!v || v <= 0) return; const y1 = Y(acc + v), y0 = Y(acc); // écart de surface 2 px entre segments (dans l'espace écran ≈ viewBox) 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" }); svg.appendChild(rect); segs.push(rect); acc += v; }); const label = fmtDateFull(p.t); const onEnter = () => { segs.forEach((s) => (s.style.opacity = "0.8")); ttRows(tt, label, keys.map((k, ki) => ({ color: colorOf(ki), value: fmtNum(p.values[ki] || 0), label: k })).filter((r) => r.value !== "0")); showTT(holder, tt, ((X(i) + bw / 2) / W) * 100, (Y(acc) / H) * 100); }; const onLeave = () => { segs.forEach((s) => (s.style.opacity = "")); hideTT(tt); }; segs.forEach((s) => { s.addEventListener("pointerenter", onEnter); s.addEventListener("pointerleave", onLeave); }); }); [0, Math.floor((n - 1) / 2), n - 1].forEach((i, k) => { const lbl = svgEl("text", { x: X(i) + bw / 2, y: H - 12, "text-anchor": k === 0 ? "start" : k === 2 ? "end" : "middle", class: "viz-tick" }); lbl.textContent = fmtDate(pts[i].t); svg.appendChild(lbl); }); holder.appendChild(svg); legend(card, keys.map((k, ki) => ({ label: k, color: colorOf(ki) })), "rect"); twinTable(card, ["Date", ...keys], pts.map((p) => [fmtDateFull(p.t), ...keys.map((_, ki) => p.values[ki] || 0)])); return card; } /* ---------- jauge demi-arc ---------- */ function gauge(opts) { // opts: {label, value, max, unit, color, track} const wrap = el("div", "viz-gauge card"); const W = 180, H = 108, cx = 90, cy = 96, R = 72, TH = 14; const frac = Math.max(0, Math.min(1, (opts.value || 0) / (opts.max || 100))); const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": `${opts.label} : ${fmtNum(opts.value, opts.unit)}` }); const arc = (a0, a1, color, width) => { const p = (a) => [cx + Math.cos(a) * R, cy + Math.sin(a) * R]; const [x1, y1] = p(a0), [x2, y2] = p(a1); 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" }); }; svg.appendChild(arc(Math.PI, Math.PI * 2, opts.track, TH)); // piste = pas clair de la même rampe if (frac > 0.005) svg.appendChild(arc(Math.PI, Math.PI + frac * Math.PI, opts.color, TH)); const val = svgEl("text", { x: cx, y: cy - 6, "text-anchor": "middle", class: "viz-gauge-val" }); val.textContent = fmtNum(opts.value, opts.unit); svg.appendChild(val); wrap.appendChild(svg); wrap.appendChild(el("div", "klabel viz-gauge-lbl", opts.label)); return wrap; } /* ---------- heatmap calendrier (26 semaines) ---------- */ function calendarHeatmap(opts) { // opts: {title, cells:[{date, value}], accent} const { card, holder, tt } = shell(opts); const cells = (opts.cells || []).filter((c) => c && c.date); if (!cells.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; } const byDate = new Map(cells.map((c) => [c.date.slice(0, 10), c.value])); const vmax = Math.max(...cells.map((c) => c.value || 0), 1); const ramp = seqRamp(opts.accent); const last = new Date(cells.map((c) => c.date.slice(0, 10)).sort().at(-1) + "T12:00:00"); const CS = 13, GAP = 3, weeks = 26; const W = 40 + weeks * (CS + GAP) + 8, H = 26 + 7 * (CS + GAP) + 6; const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img", "aria-label": opts.title || "calendrier d'activité" }); // aligne la dernière colonne sur la semaine du dernier jour (lundi = rangée 0) const dow = (d) => (d.getDay() + 6) % 7; const end = new Date(last); end.setDate(end.getDate() + (6 - dow(last))); DOWS.forEach((d, i) => { if (i % 2) return; const lbl = svgEl("text", { x: 32, y: 26 + i * (CS + GAP) + CS - 3, "text-anchor": "end", class: "viz-tick" }); lbl.textContent = d; svg.appendChild(lbl); }); let lastMonth = -1; for (let w = 0; w < weeks; w++) { for (let r = 0; r < 7; r++) { const d = new Date(end); d.setDate(end.getDate() - (weeks - 1 - w) * 7 - (6 - r)); const iso = d.toISOString().slice(0, 10); const v = byDate.get(iso); const x = 40 + w * (CS + GAP), y = 22 + r * (CS + GAP); if (r === 0 && d.getMonth() !== lastMonth) { lastMonth = d.getMonth(); const ml = svgEl("text", { x, y: 12, class: "viz-tick" }); ml.textContent = MONTHS[lastMonth]; svg.appendChild(ml); } 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" }); if (typeof v === "number") { rect.addEventListener("pointerenter", () => { rect.setAttribute("stroke", INK); rect.setAttribute("stroke-width", "1.4"); ttRows(tt, fmtDateFull(iso), [{ color: opts.accent, value: fmtNum(v), label: opts.unit || "" }]); showTT(holder, tt, (x / W) * 100, (y / H) * 100); }); rect.addEventListener("pointerleave", () => { rect.removeAttribute("stroke"); hideTT(tt); }); } svg.appendChild(rect); } } holder.appendChild(svg); const sorted = [...byDate.entries()].sort(); twinTable(card, ["Date", opts.unit || "Valeur"], sorted.map(([d, v]) => [fmtDateFull(d), v])); return card; } /* ---------- heatmap horaire 7 × 24 (dow 0 = lundi) ---------- */ function hourHeatmap(opts) { const { card, holder, tt } = shell(opts); const cells = (opts.cells || []).filter((c) => c && typeof c.dow === "number" && typeof c.hour === "number"); if (!cells.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; } const grid = new Map(cells.map((c) => [c.dow + ":" + c.hour, c.value])); const vmax = Math.max(...cells.map((c) => c.value || 0), 1); const ramp = seqRamp(opts.accent); const CS = 26, CH = 20, GAP = 3; const W = 46 + 24 * (CS + GAP), H = 24 + 7 * (CH + GAP) + 6; const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img", "aria-label": opts.title || "activité horaire" }); for (let h = 0; h < 24; h += 3) { const lbl = svgEl("text", { x: 46 + h * (CS + GAP) + CS / 2, y: 12, "text-anchor": "middle", class: "viz-tick" }); lbl.textContent = h + " h"; svg.appendChild(lbl); } for (let r = 0; r < 7; r++) { const lbl = svgEl("text", { x: 38, y: 22 + r * (CH + GAP) + CH - 5, "text-anchor": "end", class: "viz-tick" }); lbl.textContent = DOWS[r]; svg.appendChild(lbl); for (let h = 0; h < 24; h++) { const v = grid.get(r + ":" + h); const x = 46 + h * (CS + GAP), y = 18 + r * (CH + GAP); 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" }); if (typeof v === "number") { rect.addEventListener("pointerenter", () => { rect.setAttribute("stroke", INK); rect.setAttribute("stroke-width", "1.4"); ttRows(tt, `${DOWS[r]} ${h} h`, [{ color: opts.accent, value: fmtNum(v), label: opts.unit || "" }]); showTT(holder, tt, (x / W) * 100, (y / H) * 100); }); rect.addEventListener("pointerleave", () => { rect.removeAttribute("stroke"); hideTT(tt); }); } svg.appendChild(rect); } } holder.appendChild(svg); twinTable(card, ["Jour", "Heure", "Valeur"], cells.slice(0, 400).map((c) => [DOWS[c.dow] || c.dow, c.hour + " h", c.value])); return card; } /* ---------- résumé statistique ---------- */ function statSummary(points, unit) { const vs = (points || []).map((p) => p.v).filter((v) => typeof v === "number").sort((a, b) => a - b); const box = el("div", "viz-summary"); if (vs.length < 2) return box; const mean = vs.reduce((a, v) => a + v, 0) / vs.length; const med = vs.length % 2 ? vs[(vs.length - 1) / 2] : (vs[vs.length / 2 - 1] + vs[vs.length / 2]) / 2; const sd = Math.sqrt(vs.reduce((a, v) => a + (v - mean) ** 2, 0) / vs.length); const u = unit === "$" ? "$" : ""; const items = [["min", vs[0]], ["max", vs[vs.length - 1]], ["moy", mean], ["méd", med], ["σ", sd]]; for (const [k, v] of items) { const chip = el("span", "viz-stat"); chip.appendChild(el("span", "klabel", k)); chip.appendChild(el("b", null, fmtNum(Math.round(v * 10) / 10, u))); box.appendChild(chip); } return box; } /* ---------- carte KPI ---------- */ function kpiCard(k, accent, deep) { const card = el("div", "kpi card"); card.appendChild(el("div", "klabel kpi-label", k.label)); const val = el("div", "kpi-value", fmtNum(k.value, k.unit)); card.appendChild(val); const foot = el("div", "kpi-foot"); if (typeof k.delta_pct === "number") { 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.")); } card.appendChild(foot); if (Array.isArray(k.spark) && k.spark.length > 2) { const sp = el("div", "kpi-spark"); sp.appendChild(sparkline(k.spark, strokeFor(accent, deep))); card.appendChild(sp); } return card; } /* ---------- tableau interactif (tri, recherche, pagination 25) ---------- */ function dataTable(opts) { // opts: {title, columns, rows} const card = el("section", "viz-card viz-tablecard"); const head = el("div", "viz-head"); head.appendChild(el("div", "viz-title", opts.title || "Tableau")); const search = el("input", "input viz-search"); search.type = "search"; search.placeholder = "Filtrer…"; search.setAttribute("aria-label", "Filtrer le tableau " + (opts.title || "")); head.appendChild(search); card.appendChild(head); const wrap = el("div", "tbl-wrap"); const tbl = el("table", "viz-table viz-table-lg"); wrap.appendChild(tbl); card.appendChild(wrap); const pager = el("div", "viz-pager"); card.appendChild(pager); const cols = opts.columns || []; let rows = (opts.rows || []).map((r) => r.map((c) => c)); let sortCol = -1, sortDir = 1, page = 0, query = ""; const PAGE = 25; const numVal = (c) => { if (typeof c === "number") return c; const m = String(c).replace(/[\s  %$,]/g, "").replace(",", "."); const f = parseFloat(m); return Number.isNaN(f) ? null : f; }; function render() { tbl.textContent = ""; const thead = el("thead"); const trh = el("tr"); cols.forEach((c, i) => { const th = el("th"); const btn = el("button", "viz-th", c + (sortCol === i ? (sortDir > 0 ? " ↑" : " ↓") : "")); btn.addEventListener("click", () => { if (sortCol === i) sortDir *= -1; else { sortCol = i; sortDir = -1; } page = 0; render(); }); th.appendChild(btn); if (sortCol === i) th.setAttribute("aria-sort", sortDir > 0 ? "ascending" : "descending"); trh.appendChild(th); }); thead.appendChild(trh); tbl.appendChild(thead); let view = rows; if (query) { const q = query.toLowerCase(); view = rows.filter((r) => r.some((c) => String(c).toLowerCase().includes(q))); } if (sortCol >= 0) { view = [...view].sort((a, b) => { const na = numVal(a[sortCol]), nb = numVal(b[sortCol]); if (na !== null && nb !== null) return (na - nb) * sortDir; return String(a[sortCol]).localeCompare(String(b[sortCol]), "fr") * sortDir; }); } const pages = Math.max(1, Math.ceil(view.length / PAGE)); page = Math.min(page, pages - 1); const tb = el("tbody"); for (const r of view.slice(page * PAGE, page * PAGE + PAGE)) { const tr = el("tr"); r.forEach((c, i) => { const isNum = numVal(c) !== null && i > 0; tr.appendChild(el("td", isNum ? "num" : null, typeof c === "number" ? fmtNum(c) : String(c ?? "—"))); }); tb.appendChild(tr); } tbl.appendChild(tb); pager.textContent = ""; pager.appendChild(el("span", "klabel", `${nfInt.format(view.length)} lignes`)); if (pages > 1) { const nav = el("span", "viz-pager-nav"); const prev = el("button", "btn btn-ghost viz-pgbtn", "←"); prev.disabled = page === 0; prev.addEventListener("click", () => { page--; render(); }); const next = el("button", "btn btn-ghost viz-pgbtn", "→"); next.disabled = page >= pages - 1; next.addEventListener("click", () => { page++; render(); }); nav.appendChild(prev); nav.appendChild(el("span", "klabel", ` ${page + 1}/${pages} `)); nav.appendChild(next); pager.appendChild(nav); } } search.addEventListener("input", () => { query = search.value.trim(); page = 0; render(); }); render(); return card; } /* ---------- exports PNG / CSV ---------- */ function downloadBlob(name, blob) { const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 800); } function exportCSV(name, columns, rows) { const esc = (c) => { const s = String(c ?? ""); return /[",;\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const lines = [columns.map(esc).join(";"), ...rows.map((r) => r.map(esc).join(";"))]; downloadBlob(name + ".csv", new Blob(["" + lines.join("\n")], { type: "text/csv;charset=utf-8" })); } const SVG_EXPORT_CSS = ` text{font-family:system-ui,-apple-system,sans-serif;} .viz-tick{font-family:ui-monospace,monospace;font-size:10.5px;fill:#8b928c;} .viz-endlbl{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;fill:#141814;paint-order:stroke;stroke:#fff;stroke-width:3px;} .viz-donut-total{font-weight:700;font-size:26px;fill:#141814;} .viz-legend-txt{font-size:12.5px;fill:#4d5551;} .viz-legend-num{font-family:ui-monospace,monospace;font-size:11.5px;font-weight:700;fill:#141814;} .viz-gauge-val{font-weight:700;font-size:24px;fill:#141814;} `; function exportPNG(container, name) { const src = container.querySelector("svg.viz-svg"); if (!src) return; const clone = src.cloneNode(true); const style = document.createElementNS("http://www.w3.org/2000/svg", "style"); style.textContent = SVG_EXPORT_CSS; clone.insertBefore(style, clone.firstChild); const vb = (src.getAttribute("viewBox") || "0 0 860 280").split(/\s+/).map(Number); const w = vb[2], h = vb[3]; clone.setAttribute("width", w); clone.setAttribute("height", h); const xml = new XMLSerializer().serializeToString(clone); const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" })); const img = new Image(); img.onload = () => { const canvas = document.createElement("canvas"); canvas.width = w * 2; canvas.height = h * 2; const ctx = canvas.getContext("2d"); ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(url); canvas.toBlob((b) => b && downloadBlob(name + ".png", b), "image/png"); }; img.src = url; } /* ---------- carte à type commutable + outils (CSV/PNG/Studio) ---------- */ function switchableChart(spec) { // spec: {kinds:[{id,label}], initial, render(kindId)=>node, csv:{name,columns,rows}, pngName, studioHref} const wrap = el("div", "viz-switch"); const bar = el("div", "viz-toolbar"); let current = spec.initial || (spec.kinds && spec.kinds[0] && spec.kinds[0].id); const slot = el("div", "viz-slot"); const pillEls = []; if (spec.kinds && spec.kinds.length > 1) { const kinds = el("div", "viz-kindrow"); kinds.setAttribute("role", "group"); kinds.setAttribute("aria-label", "Type de graphique"); for (const k of spec.kinds) { const b = el("button", "pill viz-kindpill" + (k.id === current ? " active" : ""), k.label); b.type = "button"; b.addEventListener("click", () => { current = k.id; pillEls.forEach((p) => p.classList.toggle("active", p === b)); show(); }); pillEls.push(b); kinds.appendChild(b); } bar.appendChild(kinds); } const tools = el("div", "viz-tools"); if (spec.csv) { const b = el("button", "viz-toolbtn", "⭳ CSV"); b.type = "button"; b.title = "Exporter les données (CSV)"; b.addEventListener("click", () => exportCSV(spec.csv.name, spec.csv.columns, spec.csv.rows)); tools.appendChild(b); } if (spec.pngName) { const b = el("button", "viz-toolbtn", "⭳ PNG"); b.type = "button"; b.title = "Exporter l'image (PNG)"; b.addEventListener("click", () => exportPNG(slot, spec.pngName)); tools.appendChild(b); } if (spec.studioHref) { const a = el("a", "viz-toolbtn", "✦ Studio"); a.href = spec.studioHref; a.dataset.link = "1"; a.title = "Ouvrir cette métrique dans le Studio d'indicateurs"; tools.appendChild(a); } if (tools.childNodes.length) bar.appendChild(tools); if (bar.childNodes.length) wrap.appendChild(bar); wrap.appendChild(slot); function show() { slot.textContent = ""; slot.appendChild(spec.render(current)); } show(); return wrap; } return { CAT, DASHES, GREEN, DANGER, fmtNum, fmtCompact, fmtDate, fmtDateFull, el, strokeFor, seqRamp, mix, contrastWhite, sparkline, lineChart, vBarChart, hBarChart, donut, stackedBar, gauge, calendarHeatmap, hourHeatmap, statSummary, kpiCard, dataTable, exportCSV, exportPNG, switchableChart, }; })();