commit e794391f9bd6ec7b3ed6676a0738aecb985100dc
parent 48c57f7f304597d6144168e70d4abea8de51fb65
Author: Brian Graham <brian@buildingbetterteams.de>
Date: Tue, 24 Mar 2026 06:40:47 +0100
Replace dot chart with border-thickness encoding for tension quality
Bars are now lightly filled outlines where border thickness encodes
methodology score: 1px at 20% → 5px at 70%. A tall thin-bordered
bar = many claims from weak papers. A short thick-bordered bar =
few claims from rigorous papers. Score shown as text label.
Cleaner than dots (which were cramped) and shades (which were
imperceptible).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
1 file changed, 21 insertions(+), 41 deletions(-)
diff --git a/explorer/src/views/tensions.ts b/explorer/src/views/tensions.ts
@@ -89,44 +89,40 @@ function renderButterfly(positive: TensionClaim[], nuanced: TensionClaim[], meta
maxCount = Math.max(maxCount, (posByYear.get(y) || []).length, (nuaByYear.get(y) || []).length);
}
- // 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 };
+ // Layout: bars with border thickness encoding methodology score
+ const w = 600, h = 280;
+ const pad = { l: 30, r: 15, 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 - 20;
+ const barMaxH = chartH / 2 - 15;
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));
+ // Map score to border thickness: 20% → 1px, 70% → 5px
+ function borderW(score: number): number {
+ return 1 + Math.max(0, Math.min(1, (score - 20) / 50)) * 4;
+ }
let svg = '';
// Zero line
svg += `<line x1="${pad.l}" y1="${midY}" x2="${w - pad.r}" y2="${midY}" stroke="var(--border)" stroke-width="1"/>`;
- // Count scale ticks on left edge
+ // Count scale ticks
for (const count of [Math.round(maxCount / 2), maxCount]) {
const tickH = (count / maxCount) * barMaxH;
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>`;
}
- // 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 * 0.2;
- const barW = colW * 0.3;
+ const cx = pad.l + i * colW + colW / 2;
+ const barW = colW * 0.55;
const posC = posByYear.get(y) || [];
const nuaC = nuaByYear.get(y) || [];
const posCount = posC.length;
@@ -135,42 +131,26 @@ 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="${pad.l + i * colW + colW / 2}" y="${h - 8}" text-anchor="middle" font-size="11" fill="var(--text)">${y}</text>`;
+ svg += `<text x="${cx}" y="${h - 8}" text-anchor="middle" font-size="11" fill="var(--text)">${y}</text>`;
- // Positive: bar (count) + dot (score)
+ // Positive bar — thin fill, border thickness = score
if (posCount > 0) {
const barH = (posCount / maxCount) * barMaxH;
- // 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">
+ const bw = borderW(posMean);
+ svg += `<rect x="${cx - barW / 2}" y="${midY - barH - 1}" width="${barW}" height="${barH}" rx="2" fill="var(--accent)" fill-opacity="0.15" stroke="var(--accent)" stroke-width="${bw}">
<title>${y} ${meta.positive}: ${posCount} claims, mean score ${Math.round(posMean)}%</title>
</rect>`;
- // 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>`;
+ svg += `<text x="${cx}" y="${midY - barH - 5}" text-anchor="middle" font-size="9" fill="var(--text)">${posCount} \u00b7 ${Math.round(posMean)}%</text>`;
}
- // Nuanced: bar (count) + dot (score)
+ // Nuanced bar — thin fill, border thickness = score
if (nuaCount > 0) {
const barH = (nuaCount / maxCount) * barMaxH;
- // 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">
+ const bw = borderW(nuaMean);
+ svg += `<rect x="${cx - barW / 2}" y="${midY + 1}" width="${barW}" height="${barH}" rx="2" fill="var(--text-dim)" fill-opacity="0.1" stroke="var(--text-dim)" stroke-width="${bw}">
<title>${y} ${meta.nuanced}: ${nuaCount} claims, mean score ${Math.round(nuaMean)}%</title>
</rect>`;
- // 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>`;
+ svg += `<text x="${cx}" y="${midY + barH + 13}" text-anchor="middle" font-size="9" fill="var(--text)">${nuaCount} \u00b7 ${Math.round(nuaMean)}%</text>`;
}
}
@@ -208,7 +188,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>Bars</strong> = claim count. <strong>Dots</strong> = mean methodology score (further right = higher). Dashed line = quality-weighted balance.</p>
+ <p style="font-size:0.78rem;color:var(--text-dim);margin-bottom:0.5rem"><strong>Height</strong> = claim count. <strong>Border thickness</strong> = methodology score (thicker = more rigorous). A tall thin-bordered bar = many claims from weak papers. Dashed line = quality-weighted balance.</p>
<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-width:${w}px">${svg}</svg>
</div>`;
}