Add heatmap, radar chart, sort/group controls, and About tab

- Heatmap matrix: pillar × market colour-coded table in Compare tab
- Radar/spider chart: pure SVG chart in entity detail view
- Sort controls: sort by overall score, pillar, or group on Markets tab
- Group toggle: cluster entity cards by group (Leroy Merlin / Obramat etc.)
- About tab: home screen tab explaining scoring levels, pillars, and features
- /api/clients now returns pillars, scoring, about for About tab rendering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Phil Dore 2026-04-28 18:15:05 +01:00
parent 586169b94a
commit 5eac433d51
3 changed files with 452 additions and 44 deletions

View file

@ -295,6 +295,43 @@
.wizard-input:focus { border-color: var(--accent); }
.wizard-input::placeholder { color: var(--text-faint); }
/* Home screen tabs */
.home-tab-btn {
background:none; border:none; padding:10px 18px; font-size:14px; font-weight:600;
color:var(--text-muted); cursor:pointer; border-bottom:2px solid transparent;
margin-bottom:-2px; transition:color 0.15s, border-color 0.15s;
}
.home-tab-btn:hover { color:var(--text); }
.home-tab-btn.active { color:var(--accent); border-bottom-color:var(--accent); }
/* About page */
.about-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-top:16px; }
@media(max-width:700px){ .about-grid { grid-template-columns:1fr; } }
.about-card { background:var(--bg-card); border:1px solid var(--border); border-radius:10px; padding:20px 22px; }
.about-pillar-row { display:flex; gap:12px; align-items:flex-start; padding:10px 0; border-bottom:1px solid var(--border-sub); }
.about-pillar-row:last-child { border-bottom:none; }
.level-row { display:flex; gap:12px; align-items:flex-start; padding:8px 0; border-bottom:1px solid var(--border-sub); }
.level-row:last-child { border-bottom:none; }
/* Sort/group controls */
.ctrl-btn {
font-size:12px; font-weight:600; padding:5px 12px; border-radius:6px;
border:1px solid var(--border); background:var(--bg-card); color:var(--text-sub); cursor:pointer;
}
.ctrl-btn.active { background:var(--accent); color:#fff; border-color:var(--accent); }
.ctrl-select {
font-size:12px; padding:5px 10px; border-radius:6px; border:1px solid var(--border);
background:var(--bg-card); color:var(--text); cursor:pointer; outline:none;
}
/* Heatmap */
.heatmap-table { width:100%; border-collapse:collapse; font-size:13px; }
.heatmap-table th { padding:10px 14px; font-size:11px; font-weight:700; color:var(--text-muted);
text-transform:uppercase; letter-spacing:0.05em; border-bottom:2px solid var(--border); }
.heatmap-table td { padding:10px 14px; border-bottom:1px solid var(--border-sub); }
.heatmap-table tr:hover td { background:var(--bg-inset) !important; }
.heatmap-cell { border-radius:6px; padding:6px 10px; font-weight:700; font-size:14px; display:inline-block; min-width:44px; text-align:center; }
/* Data quality flag indicators */
.entity-flag-badge {
font-size: 10px; font-weight: 700; color: #E65100;
@ -342,6 +379,7 @@
<div style="max-width:1280px;margin:0 auto;padding:0 24px;display:flex;">
<button class="tab-btn active" id="tab-entities" onclick="showTab('entities')">Markets</button>
<button class="tab-btn" id="tab-compare" onclick="showTab('compare')">Compare</button>
<button class="tab-btn" id="tab-heatmap" onclick="showTab('heatmap')">Heatmap</button>
<button class="tab-btn" id="tab-update" onclick="showTab('update')">Update Data</button>
</div>
</div>
@ -351,7 +389,13 @@
<!-- ── Home screen: client selector ── -->
<div id="homeScreen">
<div id="clientCards" class="cards-grid"></div>
<!-- Home tab bar -->
<div style="display:flex;gap:0;border-bottom:2px solid var(--border);margin-bottom:24px;">
<button class="home-tab-btn active" id="home-tab-clients" onclick="showHomeTab('clients')">Clients</button>
<button class="home-tab-btn" id="home-tab-about" onclick="showHomeTab('about')">About this tool</button>
</div>
<div id="homeTab-clients"><div id="clientCards" class="cards-grid"></div></div>
<div id="homeTab-about" style="display:none;"></div>
</div>
<!-- ── Client view ── -->
@ -361,6 +405,8 @@
<div id="tab-entities-content">
<!-- Summary bar -->
<div class="panel fade-up" id="summaryBar" style="margin-bottom:16px;"></div>
<!-- Sort/Group controls -->
<div id="cardControls" style="display:none;margin-bottom:14px;align-items:center;gap:10px;flex-wrap:wrap;"></div>
<!-- Export row (all entities) -->
<div id="exportRow" style="display:none;margin-bottom:16px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-right:4px;">Export all</span>
@ -385,6 +431,11 @@
<div class="panel fade-up" id="compareResult" style="display:none;"></div>
</div>
<!-- Tab: Heatmap -->
<div id="tab-heatmap-content" style="display:none;">
<div class="panel fade-up" id="heatmapPanel"></div>
</div>
<!-- Tab: Update Data -->
<div id="tab-update-content" style="display:none;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start;">

432
script.js
View file

@ -6,6 +6,8 @@ let activeClient = null; // { config, data }
let activeTab = 'entities';
let detailEntity = null;
const selectedForCompare = new Set();
let sortPillar = 'overall'; // 'overall' | pillar name
let groupByGroup = false;
// ── Pillar short labels ───────────────────────────────────────────────────────
const PILLAR_SHORT = {
@ -61,6 +63,101 @@ async function init() {
}
}
// ── Home screen tabs ──────────────────────────────────────────────────────────
function showHomeTab(name) {
['clients', 'about'].forEach(t => {
document.getElementById(`homeTab-${t}`).style.display = t === name ? '' : 'none';
document.getElementById(`home-tab-${t}`).classList.toggle('active', t === name);
});
if (name === 'about') renderAbout();
}
function renderAbout() {
const el = document.getElementById('homeTab-about');
// Build content from all loaded clients' config.about (use first client that has it)
const client = allClients.find(c => c.about) || allClients[0];
const about = client && client.about;
const scoring = client && client.scoring;
const scoreColors = { 1:'#C62828', 2:'#E65100', 3:'#2E7D32', 4:'#1B5E20' };
const levelRows = scoring ? Object.entries(scoring.labels).map(([lvl, name]) => {
const desc = about && about.scoring_descriptions ? about.scoring_descriptions[lvl] : '';
return `
<div class="level-row">
<span class="badge score-${lvl}" style="flex-shrink:0;min-width:90px;text-align:center;">${escHtml(name)}</span>
<div>
<div style="font-size:13px;font-weight:600;color:var(--text);">Level ${escHtml(lvl)}</div>
${desc ? `<div style="font-size:12px;color:var(--text-sub);margin-top:2px;line-height:1.5;">${escHtml(desc)}</div>` : ''}
</div>
</div>`;
}).join('') : '';
const pillarRows = (client && client.pillars || []).map((pname, i) => {
const desc = about && about.pillar_descriptions ? about.pillar_descriptions[pname] : '';
const icons = ['🔁','👤','📊','🛠','⚙️','💡','🏛'];
return `
<div class="about-pillar-row">
<span style="font-size:20px;flex-shrink:0;width:28px;text-align:center;">${icons[i] || '▪'}</span>
<div>
<div style="font-size:12px;font-weight:700;color:var(--text);letter-spacing:0.04em;">${escHtml(pname)}</div>
${desc ? `<div style="font-size:12px;color:var(--text-sub);margin-top:2px;line-height:1.5;">${escHtml(desc)}</div>` : ''}
</div>
</div>`;
}).join('');
el.innerHTML = `
<div style="max-width:900px;">
<!-- Summary -->
<div class="panel fade-up" style="margin-bottom:16px;">
<p class="section-header">What is this tool?</p>
<p style="font-size:14px;color:var(--text-sub);line-height:1.7;margin:0;">
${about ? escHtml(about.summary) : 'A content maturity assessment dashboard for evaluating and comparing business units across key marketing capability pillars.'}
</p>
${about && about.question_count ? `
<div style="display:flex;gap:24px;margin-top:16px;flex-wrap:wrap;">
<div style="text-align:center;"><div style="font-size:28px;font-weight:900;color:var(--accent);">${about.question_count}</div><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.06em;">Questions</div></div>
<div style="text-align:center;"><div style="font-size:28px;font-weight:900;color:var(--accent);">${(client.pillars || []).length}</div><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.06em;">Pillars</div></div>
<div style="text-align:center;"><div style="font-size:28px;font-weight:900;color:var(--accent);">${allClients[0] && allClients[0].entity_count || '—'}</div><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.06em;">Markets</div></div>
</div>` : ''}
</div>
<div class="about-grid">
<!-- Scoring scale -->
<div class="about-card fade-up" style="animation-delay:40ms;">
<p class="section-header" style="margin-top:0;">Maturity Levels</p>
${levelRows}
</div>
<!-- How to use -->
<div class="about-card fade-up" style="animation-delay:80ms;">
<p class="section-header" style="margin-top:0;">How to use this tool</p>
${[
['Markets tab', 'Browse all markets sorted by overall score. Click any card to drill into pillar scores and individual question rationale.'],
['Heatmap tab', 'See all markets and pillars in one grid — instantly spot which pillars are strong or weak across the board.'],
['Compare tab', 'Select 2 or more markets to put them side by side. Green = highest score in that pillar, red = lowest.'],
['Radar chart', 'Each market detail view shows a spider chart of its 7 pillar scores — useful for understanding the shape of a market\'s maturity.'],
['⚠ Flag badge', 'An amber warning on a card means that market has incomplete data (missing scores, rationale, or gap analysis). Click the card to see detail.'],
['Update Data tab', 'Re-sync data from Box source files or upload a new CSV/XLSX directly.'],
].map(([title, desc]) => `
<div style="padding:8px 0;border-bottom:1px solid var(--border-sub);">
<div style="font-size:12px;font-weight:700;color:var(--text);">${escHtml(title)}</div>
<div style="font-size:12px;color:var(--text-sub);margin-top:2px;line-height:1.5;">${escHtml(desc)}</div>
</div>`).join('')}
</div>
<!-- Pillars -->
<div class="about-card fade-up" style="animation-delay:120ms;grid-column:1/-1;">
<p class="section-header" style="margin-top:0;">The ${(client && client.pillars || []).length} Pillars</p>
${pillarRows}
</div>
</div>
</div>`;
}
// ── Home screen ───────────────────────────────────────────────────────────────
function renderHome() {
document.getElementById('homeBtn').style.display = 'none';
@ -609,12 +706,13 @@ function enterClientView(config) {
// ── Tab navigation ────────────────────────────────────────────────────────────
function showTab(name) {
['entities', 'compare', 'update'].forEach(t => {
['entities', 'compare', 'heatmap', 'update'].forEach(t => {
document.getElementById(`tab-${t}-content`).style.display = t === name ? '' : 'none';
document.getElementById(`tab-${t}`).classList.toggle('active', t === name);
});
activeTab = name;
if (name === 'update') populateImportEntitySelect();
if (name === 'heatmap') renderHeatmap();
}
// ── Summary bar ───────────────────────────────────────────────────────────────
@ -651,6 +749,44 @@ function renderSummaryBar() {
</div>
</div>
`;
renderCardControls();
}
// ── Card controls (sort + group) ──────────────────────────────────────────────
function renderCardControls() {
const controls = document.getElementById('cardControls');
if (!controls) return;
const pillars = activeClient.config.pillars;
const pillarOpts = pillars.map(p =>
`<option value="${escHtml(p)}"${sortPillar === p ? ' selected' : ''}>${escHtml(p)}</option>`
).join('');
controls.innerHTML = `
<span style="font-size:12px;font-weight:600;color:var(--text-muted);">Sort by:</span>
<select class="ctrl-select" id="sortSel" onchange="onSortChange(this.value)">
<option value="overall"${sortPillar === 'overall' ? ' selected' : ''}>Overall Score</option>
${pillarOpts}
</select>
<button class="ctrl-btn${groupByGroup ? ' active' : ''}" id="groupBtn" onclick="onGroupToggle()">
Group by brand
</button>
`;
controls.style.display = 'flex';
}
function onSortChange(val) {
sortPillar = val;
renderEntityCards();
}
function onGroupToggle() {
groupByGroup = !groupByGroup;
renderEntityCards();
// Update button appearance
const btn = document.getElementById('groupBtn');
if (btn) btn.className = 'ctrl-btn' + (groupByGroup ? ' active' : '');
}
// ── Score helpers ─────────────────────────────────────────────────────────────
@ -673,49 +809,54 @@ function renderEntityCards() {
document.getElementById('entityGrid').style.display = '';
document.getElementById('detailPanel').style.display = 'none';
const entities = activeClient.data.entities;
const pillars = activeClient.config.pillars;
// Sort entities
let entities = [...activeClient.data.entities];
if (sortPillar === 'overall') {
entities.sort((a, b) => b.overall_score - a.overall_score);
} else {
entities.sort((a, b) => {
const pa = a.pillars.find(x => x.name === sortPillar);
const pb = b.pillars.find(x => x.name === sortPillar);
if (!pa && !pb) return 0;
if (!pa) return 1;
if (!pb) return -1;
return pb.avg - pa.avg;
});
}
document.getElementById('entityGrid').innerHTML = entities.map((entity, i) => {
const lvlCls = scoreClass(entity.overall_level);
const bgCls = scoreBgClass(entity.overall_level);
const flags = getEntityFlags(entity);
const flagTip = flags.map(f => f.label).join(' · ');
const grid = document.getElementById('entityGrid');
const pillarBars = pillars.map(pname => {
const p = entity.pillars.find(x => x.name === pname);
if (!p) return '';
const pct = pillarBarPct(p.avg).toFixed(1);
const short = pillarShort(pname);
if (!groupByGroup) {
// Normal flat grid
grid.style.display = '';
grid.className = 'cards-grid';
grid.innerHTML = entities.map((entity, i) => buildEntityCard(entity, i)).join('');
} else {
// Group by entity.group
const groups = [];
const seen = {};
entities.forEach(e => {
if (!seen[e.group]) { seen[e.group] = []; groups.push(e.group); }
seen[e.group].push(e);
});
grid.className = '';
grid.style.display = 'flex';
grid.style.flexDirection = 'column';
grid.style.gap = '20px';
grid.innerHTML = groups.map(gname => {
const groupEntities = seen[gname];
const cards = groupEntities.map((entity, i) => buildEntityCard(entity, i)).join('');
return `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px;">
<span style="font-size:10px;color:var(--text-muted);width:52px;flex-shrink:0;font-weight:600;letter-spacing:0.03em;">${escHtml(short)}</span>
<div class="pillar-bar-track" style="flex:1;"><div class="pillar-bar-fill" style="width:${pct}%;"></div></div>
<span style="font-size:10px;color:var(--text-sub);width:28px;text-align:right;font-weight:600;">${p.avg.toFixed(1)}</span>
<div>
<p class="section-header" style="margin-bottom:10px;">${escHtml(gname)}</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;">
${cards}
</div>
</div>`;
}).join('');
return `
<div class="entity-card fade-up" style="animation-delay:${Math.min(i * 30, 240)}ms;" data-id="${escHtml(entity.id)}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:12px;">
<span class="badge ${lvlCls}">${escHtml(entity.group)}</span>
<span style="font-size:10px;color:var(--text-muted);font-weight:600;letter-spacing:0.04em;">#${i + 1}</span>
</div>
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px;">
<p style="font-size:15px;font-weight:700;color:var(--text);margin:0;line-height:1.3;flex:1;">${escHtml(entity.label)}</p>
<div class="${bgCls}" style="border-radius:8px;padding:8px 12px;text-align:center;flex-shrink:0;">
<span style="font-size:22px;font-weight:900;display:block;line-height:1;color:var(--text);">${entity.overall_score.toFixed(2)}</span>
<span class="badge ${lvlCls}" style="font-size:9px;margin-top:3px;letter-spacing:0.05em;">${escHtml(entity.overall_label)}</span>
</div>
</div>
<div>${pillarBars}</div>
<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:8px;">
<span style="font-size:11px;color:var(--text-muted);">${entity.pillars.reduce((s, p) => s + p.questions.length, 0)} questions</span>
${flags.length ? `<span class="entity-flag-badge" title="${escHtml(flagTip)}">⚠ ${flags.length} issue${flags.length > 1 ? 's' : ''}</span>` : ''}
<span style="font-size:12px;color:var(--accent);font-weight:600;white-space:nowrap;">View detail </span>
</div>
</div>`;
}).join('');
}
document.querySelectorAll('.entity-card').forEach(card => {
card.addEventListener('click', () => {
@ -725,6 +866,129 @@ function renderEntityCards() {
});
}
function buildEntityCard(entity, i) {
const pillars = activeClient.config.pillars;
const lvlCls = scoreClass(entity.overall_level);
const bgCls = scoreBgClass(entity.overall_level);
const flags = getEntityFlags(entity);
const flagTip = flags.map(f => f.label).join(' · ');
const pillarBars = pillars.map(pname => {
const p = entity.pillars.find(x => x.name === pname);
if (!p) return '';
const pct = pillarBarPct(p.avg).toFixed(1);
const short = pillarShort(pname);
return `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px;">
<span style="font-size:10px;color:var(--text-muted);width:52px;flex-shrink:0;font-weight:600;letter-spacing:0.03em;">${escHtml(short)}</span>
<div class="pillar-bar-track" style="flex:1;"><div class="pillar-bar-fill" style="width:${pct}%;"></div></div>
<span style="font-size:10px;color:var(--text-sub);width:28px;text-align:right;font-weight:600;">${p.avg.toFixed(1)}</span>
</div>`;
}).join('');
return `
<div class="entity-card fade-up" style="animation-delay:${Math.min(i * 30, 240)}ms;" data-id="${escHtml(entity.id)}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:12px;">
<span class="badge ${lvlCls}">${escHtml(entity.group)}</span>
<span style="font-size:10px;color:var(--text-muted);font-weight:600;letter-spacing:0.04em;">#${i + 1}</span>
</div>
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px;">
<p style="font-size:15px;font-weight:700;color:var(--text);margin:0;line-height:1.3;flex:1;">${escHtml(entity.label)}</p>
<div class="${bgCls}" style="border-radius:8px;padding:8px 12px;text-align:center;flex-shrink:0;">
<span style="font-size:22px;font-weight:900;display:block;line-height:1;color:var(--text);">${entity.overall_score.toFixed(2)}</span>
<span class="badge ${lvlCls}" style="font-size:9px;margin-top:3px;letter-spacing:0.05em;">${escHtml(entity.overall_label)}</span>
</div>
</div>
<div>${pillarBars}</div>
<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:8px;">
<span style="font-size:11px;color:var(--text-muted);">${entity.pillars.reduce((s, p) => s + p.questions.length, 0)} questions</span>
${flags.length ? `<span class="entity-flag-badge" title="${escHtml(flagTip)}">⚠ ${flags.length} issue${flags.length > 1 ? 's' : ''}</span>` : ''}
<span style="font-size:12px;color:var(--accent);font-weight:600;white-space:nowrap;">View detail </span>
</div>
</div>`;
}
// ── Radar chart (pure SVG) ────────────────────────────────────────────────────
function radarSvg(entity, config) {
const pillars = config.pillars;
const n = pillars.length;
const cx = 110, cy = 110, r = 80;
const { min, max } = config.scoring;
const range = max - min;
// Get scores for each pillar
const scores = pillars.map(pname => {
const p = entity.pillars.find(x => x.name === pname);
return p ? p.avg : min;
});
// Polygon point helper
function pt(idx, val) {
const angle = (Math.PI * 2 * idx / n) - Math.PI / 2;
const ratio = Math.max(0, Math.min(1, (val - min) / range));
const rr = ratio * r;
return { x: cx + rr * Math.cos(angle), y: cy + rr * Math.sin(angle) };
}
function ptEdge(idx) {
const angle = (Math.PI * 2 * idx / n) - Math.PI / 2;
return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
}
// Grid rings (4 levels)
const levels = Object.keys(config.scoring.labels).map(Number).sort((a, b) => a - b);
const ringValues = levels.map(l => l);
const gridRings = ringValues.map(val => {
const pts = pillars.map((_, i) => {
const angle = (Math.PI * 2 * i / n) - Math.PI / 2;
const ratio = Math.max(0, Math.min(1, (val - min) / range));
const rr = ratio * r;
return `${(cx + rr * Math.cos(angle)).toFixed(2)},${(cy + rr * Math.sin(angle)).toFixed(2)}`;
}).join(' ');
return `<polygon points="${pts}" fill="none" stroke="#888" stroke-width="0.8" opacity="0.35"/>`;
}).join('');
// Axis lines
const axisLines = pillars.map((_, i) => {
const e = ptEdge(i);
return `<line x1="${cx}" y1="${cy}" x2="${e.x.toFixed(2)}" y2="${e.y.toFixed(2)}" stroke="#888" stroke-width="0.8" opacity="0.4"/>`;
}).join('');
// Score polygon
const scorePoints = scores.map((s, i) => {
const p = pt(i, s);
return `${p.x.toFixed(2)},${p.y.toFixed(2)}`;
}).join(' ');
// Score dots
const scoreDots = scores.map((s, i) => {
const p = pt(i, s);
return `<circle cx="${p.x.toFixed(2)}" cy="${p.y.toFixed(2)}" r="3.5" fill="var(--accent)" stroke="none"/>`;
}).join('');
// Labels
const labels = pillars.map((pname, i) => {
const angle = (Math.PI * 2 * i / n) - Math.PI / 2;
const labelR = r + 22;
const lx = cx + labelR * Math.cos(angle);
const ly = cy + labelR * Math.sin(angle);
let anchor = 'middle';
if (lx < cx - 8) anchor = 'end';
else if (lx > cx + 8) anchor = 'start';
const short = pillarShort(pname);
return `<text x="${lx.toFixed(2)}" y="${(ly + 3).toFixed(2)}" font-size="8" fill="#9ca3af" text-anchor="${anchor}" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-weight="600">${escHtml(short)}</text>`;
}).join('');
return `<svg viewBox="0 0 220 220" width="180" height="180" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0;">
${gridRings}
${axisLines}
<polygon points="${scorePoints}" fill="var(--accent)" fill-opacity="0.18" stroke="var(--accent)" stroke-width="2" stroke-linejoin="round"/>
${scoreDots}
${labels}
</svg>`;
}
// ── Entity detail ─────────────────────────────────────────────────────────────
function openEntityDetail(entity) {
detailEntity = entity;
@ -816,6 +1080,7 @@ function openEntityDetail(entity) {
<span class="badge ${lvlCls}" style="margin-bottom:8px;">${escHtml(entity.group)}</span>
<h2 style="font-size:22px;font-weight:800;color:var(--text);margin:6px 0 0;">${escHtml(entity.label)}</h2>
</div>
${radarSvg(entity, activeClient.config)}
<div class="${bgCls}" style="border-radius:10px;padding:14px 20px;text-align:center;">
<span style="font-size:36px;font-weight:900;display:block;line-height:1;color:var(--text);">${entity.overall_score.toFixed(2)}</span>
<span class="badge ${lvlCls}" style="font-size:10px;margin-top:5px;">${escHtml(entity.overall_label)}</span>
@ -1119,6 +1384,95 @@ function renderCompareTable() {
`;
}
// ── Heatmap tab ───────────────────────────────────────────────────────────────
function renderHeatmap() {
const panel = document.getElementById('heatmapPanel');
if (!panel || !activeClient) return;
const entities = activeClient.data.entities;
const pillars = activeClient.config.pillars;
const scoring = activeClient.config.scoring;
// Helper: map numeric score to color level (1-4 buckets)
function heatLevel(score) {
return scoreToLevel(Math.round(score), scoring);
}
// Header row
const headerCells = entities.map(e =>
`<th style="text-align:center;font-weight:700;font-size:11px;padding:10px 8px;min-width:70px;">${escHtml(e.short)}</th>`
).join('');
// Pillar rows
const pillarRows = pillars.map((pname, ri) => {
const cells = entities.map(e => {
const p = e.pillars.find(x => x.name === pname);
if (!p) return `<td style="text-align:center;color:var(--text-faint);">—</td>`;
const lvl = heatLevel(p.avg);
const bgCls = scoreBgClass(lvl);
return `<td style="text-align:center;"><span class="heatmap-cell ${bgCls}">${p.avg.toFixed(2)}</span></td>`;
}).join('');
// Row average
const vals = entities.map(e => { const p = e.pillars.find(x => x.name === pname); return p ? p.avg : null; }).filter(v => v !== null);
const rowAvg = vals.length ? (vals.reduce((a, b) => a + b, 0) / vals.length) : null;
const ravgLvl = rowAvg !== null ? heatLevel(rowAvg) : 1;
const ravgBg = scoreBgClass(ravgLvl);
const rowAvgCell = rowAvg !== null
? `<td style="text-align:center;"><span class="heatmap-cell ${ravgBg}" style="opacity:0.75;">${rowAvg.toFixed(2)}</span></td>`
: `<td style="text-align:center;color:var(--text-faint);">—</td>`;
const rowBg = ri % 2 === 0 ? '' : 'background:var(--bg-inset);';
return `<tr style="${rowBg}">
<td style="font-weight:600;color:var(--text);min-width:180px;font-size:12px;padding:10px 14px;">${escHtml(pname)}</td>
${cells}
${rowAvgCell}
</tr>`;
}).join('');
// Overall row
const overallCells = entities.map(e => {
const lvl = heatLevel(e.overall_score);
const bgCls = scoreBgClass(lvl);
return `<td style="text-align:center;"><span class="heatmap-cell ${bgCls}">${e.overall_score.toFixed(2)}</span></td>`;
}).join('');
const allScores = entities.map(e => e.overall_score);
const grandAvg = allScores.length ? (allScores.reduce((a, b) => a + b, 0) / allScores.length) : 0;
const grandLvl = heatLevel(grandAvg);
const grandBg = scoreBgClass(grandLvl);
const grandAvgCell = `<td style="text-align:center;"><span class="heatmap-cell ${grandBg}" style="opacity:0.75;">${grandAvg.toFixed(2)}</span></td>`;
panel.innerHTML = `
<p class="section-header" style="margin-bottom:16px;">Pillar Heatmap</p>
<div style="overflow-x:auto;">
<table class="heatmap-table">
<thead>
<tr>
<th style="text-align:left;min-width:180px;">Pillar</th>
${headerCells}
<th style="text-align:center;color:var(--text-muted);">Avg</th>
</tr>
</thead>
<tbody>
${pillarRows}
<tr style="border-top:2px solid var(--border);">
<td style="font-weight:800;color:var(--text);font-size:12px;padding:10px 14px;text-transform:uppercase;letter-spacing:0.05em;">Overall</td>
${overallCells}
${grandAvgCell}
</tr>
</tbody>
</table>
</div>
<div style="margin-top:12px;display:flex;gap:16px;flex-wrap:wrap;font-size:11px;color:var(--text-muted);">
${Object.keys(scoring.labels).sort((a,b)=>a-b).map(n => {
const bgCls = scoreBgClass(parseInt(n));
return `<span><span class="heatmap-cell ${bgCls}" style="font-size:10px;padding:2px 8px;min-width:0;">${n}</span> ${escHtml(scoring.labels[n])}</span>`;
}).join('')}
</div>
`;
}
// ── Deliverable downloads (pre-existing client PDFs / XLSXs) ─────────────────
async function downloadDeliverable(entityId, type) {

View file

@ -55,14 +55,17 @@ app.get('/api/clients', (_req, res) => {
const cfg = readJson(path.join(CLIENT_DIR, id, 'config.json'));
const dat = readJson(path.join(CLIENT_DIR, id, 'data.json'));
return {
id: cfg.id,
name: cfg.name,
description: cfg.description,
id: cfg.id,
name: cfg.name,
description: cfg.description,
accent_color: cfg.accent_color,
entity_label: cfg.entity_label,
logo: cfg.logo || null,
entity_count: dat.entities ? dat.entities.length : 0,
generated: dat.generated,
generated: dat.generated,
pillars: cfg.pillars || [],
scoring: cfg.scoring || {},
about: cfg.about || null,
};
});
res.json(clients);