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:
parent
586169b94a
commit
5eac433d51
3 changed files with 452 additions and 44 deletions
53
index.html
53
index.html
|
|
@ -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
432
script.js
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue