commit fdf7bf9ec122ae8104c86d45c5563560fafefd4f
parent f41b10dd2346179281c7a9ef5e809bb5cb87c2ec
Author: Brian Graham <brian@buildingbetterteams.de>
Date: Tue, 24 Mar 2026 06:25:59 +0100
Add butterfly timeline charts to tensions view
Each tension now has a diverging bar chart with horizontal time axis:
- Blue bars extend UP for positive claims (count + mean method score)
- Gray bars extend DOWN for nuanced claims (count + mean method score)
- No color encoding for score — numbers shown directly as labels
- Hover tooltips on each bar with full details
Year field added to tension claims in build pipeline.
Replaces the previous vertical color-encoded butterfly prototype.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 97 insertions(+), 3 deletions(-)
diff --git a/explorer/src/data.ts b/explorer/src/data.ts
@@ -120,6 +120,7 @@ export interface TensionClaim {
claim: string;
supported: string;
score: number;
+ year: number | null;
}
export interface Tensions {
diff --git a/explorer/src/views/tensions.ts b/explorer/src/views/tensions.ts
@@ -30,6 +30,13 @@ function scoreColor(s: number): string {
return 'var(--green)';
}
+function scoreHex(s: number): string {
+ if (s < 35) return '#d04040';
+ if (s < 45) return '#c08030';
+ if (s < 55) return '#a0a040';
+ return '#40a060';
+}
+
export async function renderTensions(app: HTMLElement) {
app.innerHTML = '<div class="spinner"></div>';
const tensions = await loadTensions();
@@ -42,6 +49,9 @@ export async function renderTensions(app: HTMLElement) {
return `<div class="tension-group section">
<h2>${meta.title}</h2>
<div class="tension-stat">Positive claims: ${sides.positive.length} (mean score ${posMean}%) \u00b7 Nuanced claims: ${sides.nuanced.length} (mean score ${nuaMean}%)</div>
+
+ ${renderButterfly(sides.positive, sides.nuanced, meta)}
+
<div class="tension-columns">
<div class="tension-col">
<h4>${meta.positive}</h4>
@@ -56,6 +66,89 @@ export async function renderTensions(app: HTMLElement) {
}).join('');
}
+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[]>();
+
+ for (const c of positive) {
+ if (!c.year || c.year < 2022) continue;
+ if (!posByYear.has(c.year)) posByYear.set(c.year, []);
+ posByYear.get(c.year)!.push(c);
+ }
+ for (const c of nuanced) {
+ if (!c.year || c.year < 2022) continue;
+ if (!nuaByYear.has(c.year)) nuaByYear.set(c.year, []);
+ nuaByYear.get(c.year)!.push(c);
+ }
+
+ const sortedYears = [...new Set([...posByYear.keys(), ...nuaByYear.keys()])].sort();
+ if (sortedYears.length < 2) return '';
+
+ let maxCount = 1;
+ for (const y of sortedYears) {
+ 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 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 colW = chartW / sortedYears.length;
+
+ let svg = '';
+
+ // 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>`;
+
+ 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 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;
+
+ // 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)
+ 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>
+ </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>`;
+ }
+
+ // Nuanced bar (goes DOWN from center)
+ 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>
+ </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>`;
+ }
+ }
+
+ 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>
+ <svg viewBox="0 0 ${w} ${h}" style="width:100%;max-width:${w}px">${svg}</svg>
+ </div>`;
+}
+
function renderClaims(claims: TensionClaim[]): string {
if (!claims.length) return '<p style="color:var(--text-dim);font-size:0.82rem">No claims in this category.</p>';
const sorted = [...claims].sort((a, b) => b.score - a.score).slice(0, 20);
diff --git a/scripts/build-explorer-data.py b/scripts/build-explorer-data.py
@@ -369,19 +369,19 @@ def build():
bucket = "positive" if any(k in ct for k in ["faster", "speedup", "improves", "increases", "gain"]) else "nuanced"
tensions["productivity"][bucket].append({
"paper_id": paper_id, "claim": claim["claim"],
- "supported": claim.get("supported", ""), "score": score_pct,
+ "supported": claim.get("supported", ""), "score": score_pct, "year": year,
})
if any(k in ct for k in ["benchmark", "evaluation", "leaderboard", "swe-bench"]):
bucket = "positive" if any(k in ct for k in ["state-of-the-art", "outperforms", "achieves", "best"]) else "nuanced"
tensions["benchmarks"][bucket].append({
"paper_id": paper_id, "claim": claim["claim"],
- "supported": claim.get("supported", ""), "score": score_pct,
+ "supported": claim.get("supported", ""), "score": score_pct, "year": year,
})
if any(k in ct for k in ["agent", "autonomous", "multi-agent"]):
bucket = "positive" if any(k in ct for k in ["solves", "achieves", "succeeds", "capable", "outperforms"]) else "nuanced"
tensions["agents"][bucket].append({
"paper_id": paper_id, "claim": claim["claim"],
- "supported": claim.get("supported", ""), "score": score_pct,
+ "supported": claim.get("supported", ""), "score": score_pct, "year": year,
})
cat_scores_pct = {k: round(v * 100, 1) for k, v in cat_scores.items()}