commit 8667f4fa26aaf394db5fbd7bf771dbcdeffe505e
parent fdf7bf9ec122ae8104c86d45c5563560fafefd4f
Author: Brian Graham <brian@buildingbetterteams.de>
Date: Tue, 24 Mar 2026 06:29:17 +0100
Tension butterfly: single-hue gradient + quality-weighted balance line
Bars use single-hue intensity gradients instead of color categories:
- Positive claims: light-to-dark blue (pale = weak methodology)
- Nuanced claims: light-to-dark gray
Works in grayscale and for colorblind viewers.
Added dashed balance line across years: quality-weighted center of
gravity between positive and nuanced claims. Above center line means
optimism dominates (weighted by methodology), below means skepticism.
Shows how each tension's balance shifts over time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
1 file changed, 66 insertions(+), 20 deletions(-)
diff --git a/explorer/src/views/tensions.ts b/explorer/src/views/tensions.ts
@@ -66,8 +66,24 @@ 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 {
- // Group by year
const posByYear = new Map<number, TensionClaim[]>();
const nuaByYear = new Map<number, TensionClaim[]>();
@@ -90,9 +106,8 @@ function renderButterfly(positive: TensionClaim[], nuanced: TensionClaim[], meta
maxCount = Math.max(maxCount, (posByYear.get(y) || []).length, (nuaByYear.get(y) || []).length);
}
- // Horizontal time axis, bars going up (positive) and down (nuanced) from center line
const w = 600, h = 280;
- const pad = { l: 40, r: 20, t: 30, b: 30 };
+ const pad = { l: 15, 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;
@@ -104,47 +119,78 @@ function renderButterfly(positive: TensionClaim[], nuanced: TensionClaim[], meta
// Zero line
svg += `<line x1="${pad.l}" y1="${midY}" x2="${w - pad.r}" y2="${midY}" stroke="var(--border)" stroke-width="1"/>`;
- // Side labels
- svg += `<text x="${pad.l - 4}" y="${midY - barMaxH / 2}" text-anchor="end" font-size="10" fill="var(--text-dim)" transform="rotate(-90, ${pad.l - 4}, ${midY - barMaxH / 2})">${meta.positive} \u2191</text>`;
- svg += `<text x="${pad.l - 4}" y="${midY + barMaxH / 2}" text-anchor="end" font-size="10" fill="var(--text-dim)" transform="rotate(-90, ${pad.l - 4}, ${midY + barMaxH / 2})">${meta.nuanced} \u2193</text>`;
+ // Side labels at edges
+ svg += `<text x="${pad.l + 4}" y="${pad.t + 8}" font-size="10" fill="var(--text-dim)">\u2191 ${meta.positive}</text>`;
+ svg += `<text x="${pad.l + 4}" y="${h - pad.b - 2}" 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.65;
+ const barW = colW * 0.6;
const posC = posByYear.get(y) || [];
const nuaC = nuaByYear.get(y) || [];
const posCount = posC.length;
const nuaCount = nuaC.length;
- const posMean = posC.length ? Math.round(posC.reduce((s, c) => s + c.score, 0) / posC.length) : 0;
- const nuaMean = nuaC.length ? Math.round(nuaC.reduce((s, c) => s + c.score, 0) / nuaC.length) : 0;
+ const posMean = posC.length ? posC.reduce((s, c) => s + c.score, 0) / posC.length : 0;
+ 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>`;
- // Positive bar (goes UP from center)
+ // Positive bar (UP) — blue gradient by 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="var(--accent)" opacity="0.7">
- <title>${y} ${meta.positive}: ${posCount} claims, mean score ${posMean}%</title>
+ svg += `<rect x="${cx - barW / 2}" y="${midY - barH - 1}" width="${barW}" height="${barH}" rx="2" fill="${posBarColor(posMean)}">
+ <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-dim)">${posCount}</text>`;
- svg += `<text x="${cx}" y="${midY - barH - 14}" text-anchor="middle" font-size="9" font-weight="600" fill="var(--text)">${posMean}%</text>`;
+ svg += `<text x="${cx}" y="${midY - barH - 4}" text-anchor="middle" font-size="9" fill="var(--text)">${posCount} \u00b7 ${Math.round(posMean)}%</text>`;
}
- // Nuanced bar (goes DOWN from center)
+ // Nuanced bar (DOWN) — gray gradient by 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="var(--text-dim)" opacity="0.5">
- <title>${y} ${meta.nuanced}: ${nuaCount} claims, mean score ${nuaMean}%</title>
+ svg += `<rect x="${cx - barW / 2}" y="${midY + 1}" width="${barW}" height="${barH}" rx="2" fill="${nuaBarColor(nuaMean)}">
+ <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-dim)">${nuaCount}</text>`;
- svg += `<text x="${cx}" y="${midY + barH + 22}" text-anchor="middle" font-size="9" font-weight="600" fill="var(--text)">${nuaMean}%</text>`;
+ svg += `<text x="${cx}" y="${midY + barH + 12}" text-anchor="middle" font-size="9" fill="var(--text)">${nuaCount} \u00b7 ${Math.round(nuaMean)}%</text>`;
+ }
+ }
+
+ // Quality-weighted balance line: for each year, compute
+ // (positive_count * positive_mean - nuanced_count * nuanced_mean) / total_count
+ // Positive values = optimism dominates, negative = skepticism dominates
+ // Scale: maps to y position between top and bottom of chart
+ const balancePoints: string[] = [];
+ for (let i = 0; i < sortedYears.length; i++) {
+ const y = sortedYears[i];
+ const cx = pad.l + i * colW + colW / 2;
+ const posC = posByYear.get(y) || [];
+ const nuaC = nuaByYear.get(y) || [];
+ if (posC.length + nuaC.length === 0) continue;
+
+ const posMean = posC.length ? posC.reduce((s, c) => s + c.score, 0) / posC.length : 0;
+ const nuaMean = nuaC.length ? nuaC.reduce((s, c) => s + c.score, 0) / nuaC.length : 0;
+ // Weighted balance: positive pushes up, nuanced pushes down, weighted by mean score
+ const posWeight = posC.length * posMean;
+ const nuaWeight = nuaC.length * nuaMean;
+ const totalWeight = posWeight + nuaWeight;
+ // Balance from -1 (all nuanced) to +1 (all positive)
+ const balance = totalWeight > 0 ? (posWeight - nuaWeight) / totalWeight : 0;
+ // Map to y: +1 → top, -1 → bottom, 0 → midY
+ const lineY = midY - balance * barMaxH * 0.8;
+ balancePoints.push(`${cx},${lineY}`);
+ }
+
+ if (balancePoints.length >= 2) {
+ svg += `<polyline points="${balancePoints.join(' ')}" fill="none" stroke="var(--text)" stroke-width="2" stroke-dasharray="6,3" opacity="0.6"/>`;
+ for (const pt of balancePoints) {
+ const [px, py] = pt.split(',');
+ svg += `<circle cx="${px}" cy="${py}" r="3" fill="var(--text)" opacity="0.6"/>`;
}
}
return `<div style="margin:1rem 0">
- <p style="font-size:0.78rem;color:var(--text-dim);margin-bottom:0.5rem">Bar height = claim count. Numbers show count and mean methodology score. Blue bars up = positive claims, gray bars down = nuanced claims.</p>
+ <p style="font-size:0.78rem;color:var(--text-dim);margin-bottom:0.5rem">Bar height = claim count. Darker = higher methodology score. Dashed line = quality-weighted balance (above center = optimism dominates, below = skepticism). A tall pale bar means many claims with weak methodology.</p>
<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-width:${w}px">${svg}</svg>
</div>`;
}