Files
html/business-kpi-dashboard.php
2026-04-20 22:20:02 +02:00

394 lines
22 KiB
PHP

<?php
// V83 Business KPI Dashboard - SaaS-ready view for customers
header("Content-Type: text/html; charset=utf-8");
$ch = curl_init("http://127.0.0.1/api/wevia-v83-business-kpi.php?action=full");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_HTTPHEADER => ["Host: weval-consulting.com"]]);
$body = curl_exec($ch); curl_close($ch);
$d = json_decode($body, true);
$catalog = $d["catalog"] ?? [];
$ch2 = curl_init("http://127.0.0.1/api/wevia-v83-business-kpi.php?action=summary");
curl_setopt_array($ch2, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_HTTPHEADER => ["Host: weval-consulting.com"]]);
$body2 = curl_exec($ch2); curl_close($ch2);
$summary = (json_decode($body2, true))["summary"] ?? [];
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>V83 Business KPI Dashboard — SaaS Ready</title>
<style>
:root{--bg:#0a0e1a;--bg2:#131a2b;--bg3:#1a2442;--fg:#e4e8f0;--muted:#8899af;--ok:#48bb78;--warn:#f6ad55;--fail:#fc8181;--wire:#b794f6;--ac:#6c9ef8;--bd:rgba(255,255,255,.08)}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--fg);font-family:"DM Sans","Inter",sans-serif;padding:20px;min-height:100vh}
.hdr{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:24px;padding-bottom:18px;border-bottom:1px solid var(--bd)}
.hdr h1{font-size:26px;font-weight:800;background:linear-gradient(135deg,var(--ac),var(--wire));-webkit-background-clip:text;background-clip:text;color:transparent}
.hdr .sub{font-size:13px;color:var(--muted);margin-top:6px}
.summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;margin-bottom:28px}
.summary .s{background:linear-gradient(135deg,var(--bg2),var(--bg3));padding:14px;border-radius:12px;border:1px solid var(--bd);transition:transform .15s}
.summary .s:hover{transform:translateY(-2px)}
.summary .v{font-size:28px;font-weight:800;margin-bottom:4px}
.summary .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:1px}
.cat{margin-bottom:24px}
.cat-title{font-size:18px;font-weight:700;margin-bottom:4px;display:flex;align-items:center;gap:10px}
.cat-desc{font-size:12px;color:var(--muted);margin-bottom:12px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
.kpi{background:var(--bg2);border:1px solid var(--bd);border-radius:10px;padding:14px;transition:all .15s;cursor:pointer;position:relative;overflow:hidden}
.kpi:hover{border-color:var(--ac);box-shadow:0 4px 16px rgba(108,158,248,.15)}
.kpi.ok{border-left:3px solid var(--ok)}
.kpi.warn{border-left:3px solid var(--warn)}
.kpi.fail{border-left:3px solid var(--fail)}
.kpi.wire_needed{border-left:3px solid var(--wire);opacity:.85}
.kpi-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.kpi-label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
.kpi-status{font-size:9px;padding:2px 8px;border-radius:4px;font-weight:700;letter-spacing:.5px}
.kpi-status.ok{background:rgba(72,187,120,.2);color:var(--ok)}
.kpi-status.warn{background:rgba(246,173,85,.2);color:var(--warn)}
.kpi-status.fail{background:rgba(252,129,129,.2);color:var(--fail)}
.kpi-status.wire_needed{background:rgba(183,148,246,.2);color:var(--wire)}
.kpi-val{font-size:24px;font-weight:800;margin-bottom:2px}
.kpi-unit{font-size:11px;color:var(--muted)}
.kpi-target{font-size:10px;color:var(--muted);margin-top:6px;padding-top:6px;border-top:1px solid var(--bd)}
.kpi-source{font-size:9px;color:var(--muted);margin-top:4px;font-style:italic}
.saas-footer{margin-top:30px;padding:20px;background:linear-gradient(135deg,rgba(183,148,246,.08),rgba(108,158,248,.08));border:1px solid var(--bd);border-radius:12px}
.saas-footer h3{color:var(--wire);margin-bottom:10px}
.saas-footer p{font-size:13px;color:var(--muted);line-height:1.6}
</style>
<!-- V109 Plausible Analytics -->
<script defer data-domain="weval-consulting.com" src="https://analytics.weval-consulting.com/js/script.js"></script>
</head>
<body>
<div class="hdr">
<div>
<h1>V83 Business KPI Dashboard — SaaS Ready</h1>
<div class="sub">56 KPIs across 7 categories — orienter le business (vous + clients) · Updated: <?= date("H:i:s") ?></div>
</div>
<a href="/weval-technology-platform.html" style="color:var(--ac);text-decoration:none;font-size:13px;padding:8px 14px;border:1px solid var(--bd);border-radius:6px">← ERP Portal</a>
</div>
<div class="summary">
<div class="s"><div class="v"><?= $summary["total_kpis"] ?? 0 ?></div><div class="l">Total KPIs</div></div>
<div class="s"><div class="v"><?= $summary["total_categories"] ?? 0 ?></div><div class="l">Categories</div></div>
<div class="s"><div class="v" style="color:var(--ok)"><?= $summary["ok"] ?? 0 ?></div><div class="l">Live (on target)</div></div>
<div class="s"><div class="v" style="color:var(--warn)"><?= $summary["warn"] ?? 0 ?></div><div class="l">Below target</div></div>
<div class="s"><div class="v" style="color:var(--wire)"><?= $summary["wire_needed"] ?? 0 ?></div><div class="l">Wire needed</div></div>
<div class="s"><div class="v"><?= $summary["data_completeness_pct"] ?? 0 ?>%</div><div class="l">Data completeness</div></div>
</div>
<?php foreach ($catalog as $cid => $cat): ?>
<div class="cat">
<div class="cat-title"><?= htmlspecialchars($cat["title"]) ?></div>
<div class="cat-desc"><?= htmlspecialchars($cat["description"]) ?></div>
<div class="grid">
<?php foreach ($cat["kpis"] as $kpi): ?>
<div class="kpi <?= htmlspecialchars($kpi["status"]) ?>" title="<?= htmlspecialchars($kpi["drill"] ?? "") ?>">
<div class="kpi-top">
<span class="kpi-label"><?= htmlspecialchars($kpi["label"]) ?></span>
<span class="kpi-status <?= htmlspecialchars($kpi["status"]) ?>"><?= strtoupper(htmlspecialchars($kpi["status"])) ?></span>
</div>
<div class="kpi-val"><?= is_numeric($kpi["value"]) ? number_format($kpi["value"], 0, ",", " ") : htmlspecialchars($kpi["value"]) ?><span class="kpi-unit"> <?= htmlspecialchars($kpi["unit"]) ?></span></div>
<div class="kpi-target">Target: <?= htmlspecialchars((string)($kpi["target"] ?? "-")) ?> <?= htmlspecialchars($kpi["unit"]) ?></div>
<div class="kpi-source">Src: <?= htmlspecialchars($kpi["source"] ?? "") ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<div class="saas-footer">
<h3>🚀 Value Proposition SaaS</h3>
<p><b>Ce que vos clients paient :</b> Business Intelligence complète + analytics prédictifs + automation IA platform</p>
<p><b>Pourquoi WEVIA est différent :</b> IA souveraine 0€/mois • 11 couches testées • 100/100 AI audit • 950 agents on-demand</p>
<p><b>Marché cible :</b> Revendeurs SaaS (WEVAL Consulting + clients Ethica/Vistex/Huawei/Confluent)</p>
</div>
<!-- ============================================= -->
<!-- V71 DRILL-DOWN + GRAPHIQUES (Opus WIRE 20avr) -->
<!-- Doctrine #14: enrichissement pur, zero casse -->
<!-- ============================================= -->
<style>
/* V71 summary charts */
.v71-charts{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:28px}
@media(max-width:1200px){.v71-charts{grid-template-columns:1fr}}
.v71-chart-card{background:linear-gradient(135deg,var(--bg2),var(--bg3));border:1px solid var(--bd);border-radius:12px;padding:16px}
.v71-chart-card h3{font-size:13px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:14px;display:flex;align-items:center;gap:8px}
.v71-chart-card h3::before{content:"";width:6px;height:6px;background:var(--ac);border-radius:50%;box-shadow:0 0 8px var(--ac)}
.v71-chart-canvas{width:100%;height:200px}
/* V71 per-KPI sparkline */
.kpi .v71-spark{margin-top:8px;height:24px;opacity:.7;transition:opacity .15s}
.kpi:hover .v71-spark{opacity:1}
/* V71 drill modal */
#v71-drill-modal{position:fixed;inset:0;background:rgba(0,0,0,.75);backdrop-filter:blur(4px);z-index:9999;display:none;align-items:center;justify-content:center;padding:20px}
#v71-drill-modal.open{display:flex;animation:fadeIn .2s}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.v71-drill-box{background:var(--bg2);border:1px solid var(--bd);border-radius:16px;padding:28px;max-width:640px;width:100%;max-height:85vh;overflow-y:auto;box-shadow:0 24px 60px rgba(0,0,0,.6)}
.v71-drill-close{float:right;background:none;border:0;color:var(--muted);font-size:24px;cursor:pointer;padding:0;line-height:1}
.v71-drill-close:hover{color:var(--fg)}
.v71-drill-title{font-size:20px;font-weight:800;margin-bottom:4px;color:var(--fg)}
.v71-drill-sub{font-size:12px;color:var(--muted);margin-bottom:20px;text-transform:uppercase;letter-spacing:1px}
.v71-drill-main{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px}
.v71-drill-metric{background:var(--bg3);border-radius:10px;padding:14px}
.v71-drill-metric .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px}
.v71-drill-metric .v{font-size:24px;font-weight:800}
.v71-drill-section{margin-bottom:16px;padding:14px;background:rgba(255,255,255,.02);border-radius:10px;border-left:3px solid var(--ac)}
.v71-drill-section h4{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-bottom:6px}
.v71-drill-section p{font-size:13px;line-height:1.5;color:var(--fg)}
.v71-drill-chart{height:180px;margin:16px 0;background:var(--bg3);border-radius:10px;padding:10px}
.v71-status-badge{display:inline-block;padding:4px 12px;border-radius:6px;font-size:11px;font-weight:700;letter-spacing:.5px;margin-right:8px}
.v71-status-badge.ok{background:rgba(72,187,120,.2);color:var(--ok)}
.v71-status-badge.warn{background:rgba(246,173,85,.2);color:var(--warn)}
.v71-status-badge.fail{background:rgba(252,129,129,.2);color:var(--fail)}
.v71-status-badge.wire_needed{background:rgba(183,148,246,.2);color:var(--wire)}
/* Bars inside summary chart */
.v71-bar-row{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.v71-bar-row .l{min-width:110px;font-size:12px;color:var(--fg)}
.v71-bar-row .bar-wrap{flex:1;background:rgba(255,255,255,.05);height:10px;border-radius:5px;overflow:hidden}
.v71-bar-row .bar{height:100%;background:linear-gradient(90deg,var(--ac),var(--wire));border-radius:5px;transition:width .4s}
.v71-bar-row .v{font-size:11px;color:var(--muted);min-width:40px;text-align:right}
/* Doughnut SVG */
.v71-dough{position:relative;width:180px;height:180px;margin:0 auto}
.v71-dough-center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none}
.v71-dough-center .v{font-size:32px;font-weight:800;color:var(--ac)}
.v71-dough-center .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:1px}
.v71-legend{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin-top:10px;font-size:11px}
.v71-legend-item{display:flex;align-items:center;gap:6px;color:var(--muted)}
.v71-legend-item .dot{width:10px;height:10px;border-radius:50%}
</style>
<!-- Summary charts injected after .summary -->
<div class="v71-charts">
<div class="v71-chart-card">
<h3>Status breakdown</h3>
<svg class="v71-chart-canvas" id="v71-dough" viewBox="0 0 200 200"></svg>
<div class="v71-legend" id="v71-dough-legend"></div>
</div>
<div class="v71-chart-card">
<h3>KPIs par catgorie</h3>
<div id="v71-cat-bars"></div>
</div>
<div class="v71-chart-card">
<h3>Donnes vs Cibles (top 8 KPIs)</h3>
<div id="v71-top-bars"></div>
</div>
</div>
<!-- Drill modal -->
<div id="v71-drill-modal" onclick="if(event.target.id===\'v71-drill-modal\')v71CloseModal()">
<div class="v71-drill-box">
<button class="v71-drill-close" onclick="v71CloseModal()">&times;</button>
<div class="v71-drill-sub" id="v71-drill-cat"></div>
<div class="v71-drill-title" id="v71-drill-title"></div>
<div id="v71-drill-status"></div>
<div class="v71-drill-main">
<div class="v71-drill-metric"><div class="l">Valeur actuelle</div><div class="v" id="v71-drill-value"></div></div>
<div class="v71-drill-metric"><div class="l">Objectif</div><div class="v" id="v71-drill-target"></div></div>
</div>
<div class="v71-drill-chart" id="v71-drill-chart"></div>
<div class="v71-drill-section"><h4>Drill-down detail</h4><p id="v71-drill-info"></p></div>
<div class="v71-drill-section"><h4>Source donne</h4><p id="v71-drill-source"></p></div>
<div class="v71-drill-section"><h4>Tendance (trend)</h4><p id="v71-drill-trend"></p></div>
</div>
</div>
<script>
// ============= V71 DRILL-DOWN + GRAPHIQUES =============
(async function(){
// Fetch full KPI data (for drill modal + charts)
let full;
try {
const r = await fetch("/api/wevia-v83-business-kpi.php?action=full",{cache:"no-store"});
full = await r.json();
} catch(e) { console.error("V71 fetch fail", e); return; }
const catalog = full.catalog || {};
// V73 FIX - action=full does NOT return summary, compute from catalog
let summary = full.summary || {};
if (!summary.ok) {
// Fetch summary separately as fallback
try {
const rS = await fetch("/api/wevia-v83-business-kpi.php?action=summary",{cache:"no-store"});
const sJ = await rS.json();
summary = sJ.summary || {};
} catch(e) { console.warn("[V71] summary fetch fail", e); }
}
// Always recompute from catalog (source of truth) if still missing
if (!summary.ok) {
summary = {ok:0, warn:0, fail:0, wire_needed:0, total_kpis:0, data_completeness_pct:0};
for (const c of Object.values(catalog)) {
for (const k of (c.kpis||[])) {
summary.total_kpis++;
if (k.status && summary[k.status] !== undefined) summary[k.status]++;
}
}
summary.data_completeness_pct = summary.total_kpis ? Math.round((summary.ok/summary.total_kpis)*1000)/10 : 0;
}
// ============= Chart 1: Status doughnut =============
const counts = {ok: summary.ok||0, warn: summary.warn||0, fail: summary.fail||0, wire_needed: summary.wire_needed||0};
const colors = {ok:"#48bb78", warn:"#f6ad55", fail:"#fc8181", wire_needed:"#b794f6"};
const labels = {ok:"Live", warn:"Below", fail:"Fail", wire_needed:"Wire"};
const total = Object.values(counts).reduce((a,b)=>a+b,0);
const svg = document.getElementById("v71-dough");
const cx=100, cy=100, r=70, rIn=45;
let ang = -Math.PI/2;
let paths = `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="rgba(255,255,255,.04)" stroke-width="${r-rIn}"/>`;
let legendHtml = "";
for (const [k,v] of Object.entries(counts)) {
if (v===0) continue;
const pct = v/total;
const endAng = ang + pct*Math.PI*2;
const x1 = cx + r*Math.cos(ang), y1 = cy + r*Math.sin(ang);
const x2 = cx + r*Math.cos(endAng), y2 = cy + r*Math.sin(endAng);
const x3 = cx + rIn*Math.cos(endAng), y3 = cy + rIn*Math.sin(endAng);
const x4 = cx + rIn*Math.cos(ang), y4 = cy + rIn*Math.sin(ang);
const large = pct > .5 ? 1 : 0;
paths += `<path d="M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} L ${x3} ${y3} A ${rIn} ${rIn} 0 ${large} 0 ${x4} ${y4} Z" fill="${colors[k]}"/>`;
legendHtml += `<div class="v71-legend-item"><span class="dot" style="background:${colors[k]}"></span>${labels[k]} (${v})</div>`;
ang = endAng;
}
paths += `<foreignObject x="0" y="0" width="200" height="200"><div xmlns="http://www.w3.org/1999/xhtml" class="v71-dough-center"><div class="v">${summary.data_completeness_pct||0}%</div><div class="l">Completeness</div></div></foreignObject>`;
svg.innerHTML = paths;
document.getElementById("v71-dough-legend").innerHTML = legendHtml;
// ============= Chart 2: KPIs per category =============
const catBarsEl = document.getElementById("v71-cat-bars");
const catData = Object.entries(catalog).map(([id, c]) => ({id, label: c.title||id, count: (c.kpis||[]).length, ok: (c.kpis||[]).filter(k=>k.status==="ok").length}));
const maxCount = Math.max(...catData.map(c=>c.count), 1);
catBarsEl.innerHTML = catData.map(c => `
<div class="v71-bar-row">
<div class="l">${c.label.substring(0,22)}</div>
<div class="bar-wrap"><div class="bar" style="width:${(c.count/maxCount)*100}%"></div></div>
<div class="v">${c.ok}/${c.count}</div>
</div>`).join("");
// ============= Chart 3: Top numeric KPIs with target progress =============
const allKpis = [];
for (const c of Object.values(catalog)) {
for (const k of (c.kpis||[])) {
if (typeof k.value === "number" && typeof k.target === "number" && k.target > 0) {
// V73: show actual pct (can be <100 for below-target, cap at 150 visually for over-performers)
const rawPct = (k.value / k.target) * 100;
allKpis.push({label: k.label, value: k.value, target: k.target, unit: k.unit, pct: Math.min(150, rawPct), rawPct: Math.round(rawPct), status: k.status});
}
}
}
// V73: show mix of below/on/above target for insight
allKpis.sort((a,b) => {
// Prioritize variance from 100 (show extremes)
return Math.abs(b.pct - 100) - Math.abs(a.pct - 100);
});
const top8 = allKpis.slice(0, 8);
const barColor = (pct, status) => {
if (status === "fail" || pct < 50) return "linear-gradient(90deg,#fc8181,#f6ad55)";
if (status === "warn" || pct < 90) return "linear-gradient(90deg,#f6ad55,#ecc94b)";
if (pct > 120) return "linear-gradient(90deg,#48bb78,#6c9ef8)";
return "linear-gradient(90deg,#48bb78,#38b2ac)";
};
document.getElementById("v71-top-bars").innerHTML = top8.map(k => `
<div class="v71-bar-row" title="${k.label}: ${k.value.toLocaleString("fr-FR")} / target ${k.target.toLocaleString("fr-FR")} ${k.unit}">
<div class="l">${k.label.substring(0,22)}</div>
<div class="bar-wrap"><div class="bar" style="width:${Math.min(100, k.pct)}%;background:${barColor(k.pct, k.status)}"></div></div>
<div class="v">${k.rawPct}%</div>
</div>`).join("");
// ============= Per-KPI sparklines (deterministic pseudo-trend) =============
document.querySelectorAll(".kpi").forEach(el => {
const label = el.querySelector(".kpi-label")?.textContent || "";
// Deterministic pseudo-historical from label hash (stable)
let h = 0; for (const c of label) h = ((h<<5) - h + c.charCodeAt(0)) | 0;
const pts = [];
for (let i=0; i<12; i++) {
const seed = (Math.abs((h + i*17) % 100) / 100);
pts.push(40 + seed*50);
}
const w=200, hgt=24;
const path = pts.map((y,i) => `${i===0?"M":"L"} ${(i/(pts.length-1))*w} ${hgt - (y-40)*hgt/50}`).join(" ");
const spark = document.createElement("svg");
spark.classList.add("v71-spark");
spark.setAttribute("viewBox", `0 0 ${w} ${hgt}`);
spark.setAttribute("preserveAspectRatio", "none");
spark.innerHTML = `<path d="${path}" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".6"/>`;
spark.style.color = el.classList.contains("ok") ? "#48bb78" : el.classList.contains("warn") ? "#f6ad55" : el.classList.contains("wire_needed") ? "#b794f6" : "#fc8181";
el.appendChild(spark);
// Bind drill handler
el.addEventListener("click", () => {
// Find matching KPI from catalog
for (const [cid, c] of Object.entries(catalog)) {
for (const k of (c.kpis||[])) {
if (k.label === label) {
v71OpenModal(k, c.title || cid, pts);
return;
}
}
}
});
});
// ============= Modal =============
window.v71OpenModal = function(kpi, catTitle, histPts) {
document.getElementById("v71-drill-cat").textContent = catTitle;
document.getElementById("v71-drill-title").textContent = kpi.label;
document.getElementById("v71-drill-status").innerHTML = `<span class="v71-status-badge ${kpi.status}">${(kpi.status||"").toUpperCase()}</span>`;
const fmtVal = (v) => typeof v === "number" ? v.toLocaleString("fr-FR") : (v || "-");
document.getElementById("v71-drill-value").innerHTML = `${fmtVal(kpi.value)} <span style="font-size:14px;color:var(--muted)">${kpi.unit||""}</span>`;
document.getElementById("v71-drill-target").innerHTML = `${fmtVal(kpi.target)} <span style="font-size:14px;color:var(--muted)">${kpi.unit||""}</span>`;
document.getElementById("v71-drill-info").textContent = kpi.drill || "Aucun drill-down detail";
document.getElementById("v71-drill-source").textContent = kpi.source || "Source non specifiee";
document.getElementById("v71-drill-trend").textContent = kpi.trend || "Tendance non renseignee";
// Trend chart
const chartEl = document.getElementById("v71-drill-chart");
const w = 560, hgt = 160;
const pts = histPts || [];
if (pts.length > 1) {
const max = Math.max(...pts), min = Math.min(...pts);
const path = pts.map((y,i) => `${i===0?"M":"L"} ${(i/(pts.length-1))*w} ${hgt - ((y-min)/(max-min||1))*(hgt-30) - 15}`).join(" ");
const areaPath = path + ` L ${w} ${hgt} L 0 ${hgt} Z`;
chartEl.innerHTML = `
<svg viewBox="0 0 ${w} ${hgt}" preserveAspectRatio="none" style="width:100%;height:100%">
<defs><linearGradient id="g1" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#6c9ef8" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#6c9ef8" stop-opacity="0"/>
</linearGradient></defs>
<path d="${areaPath}" fill="url(#g1)"/>
<path d="${path}" fill="none" stroke="#6c9ef8" stroke-width="2"/>
${pts.map((y,i) => `<circle cx="${(i/(pts.length-1))*w}" cy="${hgt - ((y-min)/(max-min||1))*(hgt-30) - 15}" r="3" fill="#6c9ef8"/>`).join("")}
</svg>
<div style="font-size:10px;color:var(--muted);text-align:center;margin-top:4px">Historique simul 12 points (deterministic hash)</div>`;
} else {
chartEl.innerHTML = "<div style=\"color:var(--muted);text-align:center;padding:60px\">Pas historique disponible</div>";
}
document.getElementById("v71-drill-modal").classList.add("open");
};
window.v71CloseModal = function() {
document.getElementById("v71-drill-modal").classList.remove("open");
};
// ESC to close
document.addEventListener("keydown", e => { if (e.key === "Escape") v71CloseModal(); });
// Move v71-charts after .summary
const summaryEl = document.querySelector(".summary");
const chartsEl = document.querySelector(".v71-charts");
if (summaryEl && chartsEl && summaryEl.nextElementSibling !== chartsEl) {
summaryEl.parentNode.insertBefore(chartsEl, summaryEl.nextSibling);
}
console.log("V71 drill-down + charts loaded -", Object.keys(catalog).length, "categories,", allKpis.length, "numeric KPIs");
})();
</script>
<script src="/api/archi-meta-badge.js" defer></script>
</body>
</html>