commit 48c57f7f304597d6144168e70d4abea8de51fb65
parent 3776fd8528b73d85622c4a27e63d7a6e653b67c9
Author: Brian Graham <brian@buildingbetterteams.de>
Date: Tue, 24 Mar 2026 06:35:47 +0100
Replace shade gradient with bar+dot chart on tensions
Bars show claim count (flat blue up, flat gray down).
Dots show mean methodology score as position on a mini x-axis
per year column, with score number inside each dot.
Two independent visual channels — no color interpretation needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
1 file changed, 44 insertions(+), 33 deletions(-)
diff --git a/explorer/src/views/tensions.ts b/explorer/src/views/tensions.ts
@@ -66,23 +66,6 @@ export async function renderTensions(app: HTMLElement) {
}).join('');
}
-// Single-hue gradient: lighter = weaker methodology, darker = stronger
-function posBarColor(score: number): string {
- // Blue family: light (#a0c0f0) at 20% → dark (#1a4080) at 80%
- const t = Math.max(0, Math.min(1, (score - 20) / 60));
- const r = Math.round(160 - t * 134);
- const g = Math.round(192 - t * 128);
- const b = Math.round(240 - t * 112);
- return `rgb(${r},${g},${b})`;
-}
-
-function nuaBarColor(score: number): string {
- // Gray family: light (#b0b0b0) at 20% → dark (#404040) at 80%
- const t = Math.max(0, Math.min(1, (score - 20) / 60));
- const v = Math.round(176 - t * 112);
- return `rgb(${v},${v},${v})`;
-}
-
function renderButterfly(positive: TensionClaim[], nuanced: TensionClaim[], meta: { positive: string; nuanced: string }): string {
const posByYear = new Map<number, TensionClaim[]>();
const nuaByYear = new Map<number, TensionClaim[]>();
@@ -106,14 +89,21 @@ function renderButterfly(positive: TensionClaim[], nuanced: TensionClaim[], meta
maxCount = Math.max(maxCount, (posByYear.get(y) || []).length, (nuaByYear.get(y) || []).length);
}
- const w = 600, h = 280;
- const pad = { l: 15, r: 15, t: 30, b: 30 };
+ // Layout: bars for count + dots for score on a separate scale
+ const w = 650, h = 300;
+ const pad = { l: 30, r: 50, t: 30, b: 30 };
const chartW = w - pad.l - pad.r;
const chartH = h - pad.t - pad.b;
const midY = pad.t + chartH / 2;
- const barMaxH = chartH / 2 - 15;
+ const barMaxH = chartH / 2 - 20;
const colW = chartW / sortedYears.length;
+ // Score dot scale: right side mini-axis, 0-100% mapped within bar zone
+ const dotMinX = (i: number) => pad.l + i * colW + colW * 0.45;
+ const dotMaxX = (i: number) => pad.l + i * colW + colW * 0.85;
+ // Score maps to x position within that range
+ const dotX = (i: number, score: number) => dotMinX(i) + (score / 100) * (dotMaxX(i) - dotMinX(i));
+
let svg = '';
// Zero line
@@ -122,18 +112,21 @@ function renderButterfly(positive: TensionClaim[], nuanced: TensionClaim[], meta
// Count scale ticks on left edge
for (const count of [Math.round(maxCount / 2), maxCount]) {
const tickH = (count / maxCount) * barMaxH;
- svg += `<text x="${pad.l}" y="${midY - tickH - 1}" text-anchor="start" font-size="9" fill="var(--text-dim)">${count}</text>`;
- svg += `<text x="${pad.l}" y="${midY + tickH + 8}" text-anchor="start" font-size="9" fill="var(--text-dim)">${count}</text>`;
+ svg += `<text x="${pad.l - 4}" y="${midY - tickH + 3}" text-anchor="end" font-size="9" fill="var(--text-dim)">${count}</text>`;
+ svg += `<text x="${pad.l - 4}" y="${midY + tickH + 3}" text-anchor="end" font-size="9" fill="var(--text-dim)">${count}</text>`;
}
- // Side labels at edges
+ // Score scale label on right
+ svg += `<text x="${w - 4}" y="${midY - barMaxH + 3}" text-anchor="end" font-size="9" fill="var(--text-dim)">score%</text>`;
+
+ // Side labels
svg += `<text x="${w - pad.r}" y="${pad.t + 8}" text-anchor="end" font-size="10" fill="var(--text-dim)">\u2191 ${meta.positive}</text>`;
svg += `<text x="${w - pad.r}" y="${h - pad.b - 2}" text-anchor="end" font-size="10" fill="var(--text-dim)">\u2193 ${meta.nuanced}</text>`;
for (let i = 0; i < sortedYears.length; i++) {
const y = sortedYears[i];
- const cx = pad.l + i * colW + colW / 2;
- const barW = colW * 0.6;
+ const cx = pad.l + i * colW + colW * 0.2;
+ const barW = colW * 0.3;
const posC = posByYear.get(y) || [];
const nuaC = nuaByYear.get(y) || [];
const posCount = posC.length;
@@ -142,24 +135,42 @@ function renderButterfly(positive: TensionClaim[], nuanced: TensionClaim[], meta
const nuaMean = nuaC.length ? nuaC.reduce((s, c) => s + c.score, 0) / nuaC.length : 0;
// Year label
- svg += `<text x="${cx}" y="${h - 8}" text-anchor="middle" font-size="11" fill="var(--text)">${y}</text>`;
+ svg += `<text x="${pad.l + i * colW + colW / 2}" y="${h - 8}" text-anchor="middle" font-size="11" fill="var(--text)">${y}</text>`;
- // Positive bar (UP) — blue gradient by score
+ // Positive: bar (count) + dot (score)
if (posCount > 0) {
const barH = (posCount / maxCount) * barMaxH;
- svg += `<rect x="${cx - barW / 2}" y="${midY - barH - 1}" width="${barW}" height="${barH}" rx="2" fill="${posBarColor(posMean)}">
+ // Count bar — flat blue
+ svg += `<rect x="${cx - barW / 2}" y="${midY - barH - 1}" width="${barW}" height="${barH}" rx="2" fill="var(--accent)" opacity="0.6">
<title>${y} ${meta.positive}: ${posCount} claims, mean score ${Math.round(posMean)}%</title>
</rect>`;
- svg += `<text x="${cx}" y="${midY - barH - 4}" text-anchor="middle" font-size="9" fill="var(--text)">${posCount} \u00b7 ${Math.round(posMean)}%</text>`;
+ // Count label
+ svg += `<text x="${cx}" y="${midY - barH - 4}" text-anchor="middle" font-size="9" fill="var(--text-dim)">${posCount}</text>`;
+ // Score dot — positioned at score on a 0-100 mini axis, vertically centered in the bar zone
+ const dy = midY - barH / 2 - 1;
+ const dx = dotX(i, posMean);
+ svg += `<circle cx="${dx}" cy="${dy}" r="5" fill="var(--accent)" stroke="var(--surface)" stroke-width="1.5">
+ <title>${Math.round(posMean)}% mean methodology</title>
+ </circle>`;
+ svg += `<text x="${dx}" y="${dy + 3.5}" text-anchor="middle" font-size="7" fill="#fff" font-weight="700" style="pointer-events:none">${Math.round(posMean)}</text>`;
}
- // Nuanced bar (DOWN) — gray gradient by score
+ // Nuanced: bar (count) + dot (score)
if (nuaCount > 0) {
const barH = (nuaCount / maxCount) * barMaxH;
- svg += `<rect x="${cx - barW / 2}" y="${midY + 1}" width="${barW}" height="${barH}" rx="2" fill="${nuaBarColor(nuaMean)}">
+ // Count bar — flat gray
+ svg += `<rect x="${cx - barW / 2}" y="${midY + 1}" width="${barW}" height="${barH}" rx="2" fill="var(--text-dim)" opacity="0.4">
<title>${y} ${meta.nuanced}: ${nuaCount} claims, mean score ${Math.round(nuaMean)}%</title>
</rect>`;
- svg += `<text x="${cx}" y="${midY + barH + 12}" text-anchor="middle" font-size="9" fill="var(--text)">${nuaCount} \u00b7 ${Math.round(nuaMean)}%</text>`;
+ // Count label
+ svg += `<text x="${cx}" y="${midY + barH + 12}" text-anchor="middle" font-size="9" fill="var(--text-dim)">${nuaCount}</text>`;
+ // Score dot
+ const dy = midY + barH / 2 + 1;
+ const dx = dotX(i, nuaMean);
+ svg += `<circle cx="${dx}" cy="${dy}" r="5" fill="var(--text-dim)" stroke="var(--surface)" stroke-width="1.5">
+ <title>${Math.round(nuaMean)}% mean methodology</title>
+ </circle>`;
+ svg += `<text x="${dx}" y="${dy + 3.5}" text-anchor="middle" font-size="7" fill="var(--surface)" font-weight="700" style="pointer-events:none">${Math.round(nuaMean)}</text>`;
}
}
@@ -197,7 +208,7 @@ function renderButterfly(positive: TensionClaim[], nuanced: TensionClaim[], meta
}
return `<div style="margin:1rem 0">
- <p style="font-size:0.78rem;color:var(--text-dim);margin-bottom:0.5rem"><strong>Height</strong> = number of claims. <strong>Darkness</strong> = mean methodology score (darker = more rigorous). Dashed line = quality-weighted balance. A tall pale bar = many claims from weak papers.</p>
+ <p style="font-size:0.78rem;color:var(--text-dim);margin-bottom:0.5rem"><strong>Bars</strong> = claim count. <strong>Dots</strong> = mean methodology score (further right = higher). Dashed line = quality-weighted balance.</p>
<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-width:${w}px">${svg}</svg>
</div>`;
}