From 5eac433d51f8907c4fa06dbba79a76ce2f2fc8e7 Mon Sep 17 00:00:00 2001 From: Phil Dore Date: Tue, 28 Apr 2026 18:15:05 +0100 Subject: [PATCH] Add heatmap, radar chart, sort/group controls, and About tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- index.html | 53 +++++- script.js | 432 +++++++++++++++++++++++++++++++++++++++++++----- server/index.js | 11 +- 3 files changed, 452 insertions(+), 44 deletions(-) diff --git a/index.html b/index.html index 566b541..c6687d5 100644 --- a/index.html +++ b/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 @@
+
@@ -351,7 +389,13 @@
-
+ +
+ + +
+
+
@@ -361,6 +405,8 @@
+ + + + + `; + 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 => + `` + ).join(''); + + controls.innerHTML = ` + Sort by: + + + `; + + 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 ` -
- ${escHtml(short)} -
- ${p.avg.toFixed(1)} +
+

${escHtml(gname)}

+
+ ${cards} +
`; }).join(''); - - return ` -
-
- ${escHtml(entity.group)} - #${i + 1} -
-
-

${escHtml(entity.label)}

-
- ${entity.overall_score.toFixed(2)} - ${escHtml(entity.overall_label)} -
-
-
${pillarBars}
-
- ${entity.pillars.reduce((s, p) => s + p.questions.length, 0)} questions - ${flags.length ? `⚠ ${flags.length} issue${flags.length > 1 ? 's' : ''}` : ''} - View detail → -
-
`; - }).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 ` +
+ ${escHtml(short)} +
+ ${p.avg.toFixed(1)} +
`; + }).join(''); + + return ` +
+
+ ${escHtml(entity.group)} + #${i + 1} +
+
+

${escHtml(entity.label)}

+
+ ${entity.overall_score.toFixed(2)} + ${escHtml(entity.overall_label)} +
+
+
${pillarBars}
+
+ ${entity.pillars.reduce((s, p) => s + p.questions.length, 0)} questions + ${flags.length ? `⚠ ${flags.length} issue${flags.length > 1 ? 's' : ''}` : ''} + View detail → +
+
`; +} + +// ── 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 ``; + }).join(''); + + // Axis lines + const axisLines = pillars.map((_, i) => { + const e = ptEdge(i); + return ``; + }).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 ``; + }).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 `${escHtml(short)}`; + }).join(''); + + return ` + ${gridRings} + ${axisLines} + + ${scoreDots} + ${labels} + `; +} + // ── Entity detail ───────────────────────────────────────────────────────────── function openEntityDetail(entity) { detailEntity = entity; @@ -816,6 +1080,7 @@ function openEntityDetail(entity) { ${escHtml(entity.group)}

${escHtml(entity.label)}

+ ${radarSvg(entity, activeClient.config)}
${entity.overall_score.toFixed(2)} ${escHtml(entity.overall_label)} @@ -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 => + `${escHtml(e.short)}` + ).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 `—`; + const lvl = heatLevel(p.avg); + const bgCls = scoreBgClass(lvl); + return `${p.avg.toFixed(2)}`; + }).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 + ? `${rowAvg.toFixed(2)}` + : `—`; + + const rowBg = ri % 2 === 0 ? '' : 'background:var(--bg-inset);'; + return ` + ${escHtml(pname)} + ${cells} + ${rowAvgCell} + `; + }).join(''); + + // Overall row + const overallCells = entities.map(e => { + const lvl = heatLevel(e.overall_score); + const bgCls = scoreBgClass(lvl); + return `${e.overall_score.toFixed(2)}`; + }).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 = `${grandAvg.toFixed(2)}`; + + panel.innerHTML = ` +

Pillar Heatmap

+
+ + + + + ${headerCells} + + + + + ${pillarRows} + + + ${overallCells} + ${grandAvgCell} + + +
PillarAvg
Overall
+
+
+ ${Object.keys(scoring.labels).sort((a,b)=>a-b).map(n => { + const bgCls = scoreBgClass(parseInt(n)); + return `${n} ${escHtml(scoring.labels[n])}`; + }).join('')} +
+ `; +} + // ── Deliverable downloads (pre-existing client PDFs / XLSXs) ───────────────── async function downloadDeliverable(entityId, type) { diff --git a/server/index.js b/server/index.js index f79439f..a8c00c7 100644 --- a/server/index.js +++ b/server/index.js @@ -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);