commit 4b1edc22cbf261dbc4f797132d00a2067c22d276
parent 8378a8226cb32ffa456ceaab94abc9b42ccb619b
Author: Brian Graham <brian@buildingbetterteams.de>
Date: Mon, 23 Mar 2026 11:19:23 +0100
Replace jittered scatter with bubble grid for two cultures
Scatter with jitter was dishonest — data is discrete (4-7 questions
per category = quantized scores). Replaced with bubble grid: each
grid intersection shows a circle sized by paper count, colored by
mean score, with count label. Honestly represents the discrete data
while clearly showing the negative correlation pattern.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
1 file changed, 56 insertions(+), 42 deletions(-)
diff --git a/explorer/src/views/findings.ts b/explorer/src/views/findings.ts
@@ -580,73 +580,87 @@ function renderTwoCultures(f: Findings): string {
const papers = (f as any).two_cultures as { human_studies: number; artifacts: number; id: string; score: number }[];
if (!papers || papers.length < 10) return '';
- // Compute quadrant counts
- const q = { hh: 0, hl: 0, lh: 0, ll: 0 };
+ // Bucket into grid cells (scores are quantized to ~25% steps)
+ const steps = [0, 25, 50, 75, 100];
+ function snap(v: number): number {
+ let best = 0;
+ for (const s of steps) {
+ if (Math.abs(v - s) < Math.abs(v - best)) best = s;
+ }
+ return best;
+ }
+
+ const grid = new Map<string, { count: number; totalScore: number }>();
for (const p of papers) {
- const hs = p.human_studies >= 50;
- const ar = p.artifacts >= 50;
- if (hs && ar) q.hh++;
- else if (hs && !ar) q.hl++;
- else if (!hs && ar) q.lh++;
- else q.ll++;
+ const ax = snap(p.artifacts);
+ const hy = snap(p.human_studies);
+ const key = `${ax},${hy}`;
+ const cell = grid.get(key) || { count: 0, totalScore: 0 };
+ cell.count++;
+ cell.totalScore += p.score;
+ grid.set(key, cell);
}
- // SVG scatter
- const w = 500, h = 400;
- const pad = { l: 60, r: 20, t: 20, b: 50 };
+ const maxCount = Math.max(...Array.from(grid.values()).map(c => c.count));
+
+ // SVG bubble grid
+ const w = 420, h = 420;
+ const pad = { l: 70, r: 30, t: 30, b: 60 };
const cw = w - pad.l - pad.r, ch = h - pad.t - pad.b;
const xScale = (v: number) => pad.l + (v / 100) * cw;
const yScale = (v: number) => pad.t + ch - (v / 100) * ch;
- // Deterministic jitter to spread overlapping dots on the quantized grid
- function jitter(idx: number, axis: number): number {
- // Simple hash-like spread: use index to distribute dots in a circle around the grid point
- const angle = ((idx * 137.5 + axis * 73) % 360) * Math.PI / 180;
- const radius = 6 + (idx * 3.7 % 5);
- return Math.cos(angle) * radius;
+ // Grid lines
+ let svgGrid = '';
+ for (const v of steps) {
+ svgGrid += `<line x1="${xScale(v)}" y1="${pad.t}" x2="${xScale(v)}" y2="${h - pad.b}" stroke="var(--border)" stroke-width="0.5"/>`;
+ svgGrid += `<line x1="${pad.l}" y1="${yScale(v)}" x2="${w - pad.r}" y2="${yScale(v)}" stroke="var(--border)" stroke-width="0.5"/>`;
+ svgGrid += `<text x="${pad.l - 8}" y="${yScale(v) + 4}" text-anchor="end" font-size="10">${v}%</text>`;
+ svgGrid += `<text x="${xScale(v)}" y="${h - pad.b + 16}" text-anchor="middle" font-size="10">${v}%</text>`;
}
- let dots = '';
- for (let i = 0; i < papers.length; i++) {
- const p = papers[i];
- const cx = xScale(p.artifacts) + jitter(i, 0);
- const cy = yScale(p.human_studies) + jitter(i, 1);
- const color = p.score < 40 ? '#f06565' : p.score < 55 ? '#f0c050' : '#3dd68c';
- dots += `<circle cx="${cx}" cy="${cy}" r="5" fill="${color}" opacity="0.6">
- <title>${p.id}: artifacts ${p.artifacts}%, human_studies ${p.human_studies}%, score ${p.score}%</title>
+ // Bubbles
+ let bubbles = '';
+ for (const [key, cell] of grid.entries()) {
+ const [ax, hy] = key.split(',').map(Number);
+ const r = Math.max(4, Math.sqrt(cell.count / maxCount) * 22);
+ const meanScore = cell.totalScore / cell.count;
+ const color = meanScore < 40 ? '#f06565' : meanScore < 55 ? '#f0c050' : '#3dd68c';
+ bubbles += `<circle cx="${xScale(ax)}" cy="${yScale(hy)}" r="${r}" fill="${color}" opacity="0.7">
+ <title>Artifacts ${ax}%, Human Studies ${hy}%\n${cell.count} papers, mean score ${meanScore.toFixed(1)}%</title>
</circle>`;
+ if (cell.count > 1) {
+ bubbles += `<text x="${xScale(ax)}" y="${yScale(hy) + 4}" text-anchor="middle" font-size="9" fill="#fff" font-weight="600" style="pointer-events:none">${cell.count}</text>`;
+ }
}
- // Quadrant labels
+ // Quadrant shading
const midX = xScale(50), midY = yScale(50);
- const quadrants = `
- <line x1="${midX}" y1="${pad.t}" x2="${midX}" y2="${h - pad.b}" stroke="var(--border)" stroke-dasharray="4"/>
- <line x1="${pad.l}" y1="${midY}" x2="${w - pad.r}" y2="${midY}" stroke="var(--border)" stroke-dasharray="4"/>
- <text x="${xScale(25)}" y="${yScale(80)}" text-anchor="middle" font-size="10" fill="var(--text-dim)">CS tradition only (${q.hl})</text>
- <text x="${xScale(75)}" y="${yScale(80)}" text-anchor="middle" font-size="10" fill="var(--green)">Both traditions (${q.hh})</text>
- <text x="${xScale(25)}" y="${yScale(20)}" text-anchor="middle" font-size="10" fill="var(--red)">Neither (${q.ll})</text>
- <text x="${xScale(75)}" y="${yScale(20)}" text-anchor="middle" font-size="10" fill="var(--text-dim)">Psych tradition only (${q.lh})</text>
+ const quadLabels = `
+ <line x1="${midX}" y1="${pad.t}" x2="${midX}" y2="${h - pad.b}" stroke="var(--text-dim)" stroke-dasharray="4" opacity="0.4"/>
+ <line x1="${pad.l}" y1="${midY}" x2="${w - pad.r}" y2="${midY}" stroke="var(--text-dim)" stroke-dasharray="4" opacity="0.4"/>
`;
// Axes
const axes = `
- <text x="${w / 2}" y="${h - 5}" text-anchor="middle" fill="var(--text-dim)" font-size="11">Artifacts Score \u2192</text>
- <text x="12" y="${h / 2}" text-anchor="middle" fill="var(--text-dim)" font-size="11" transform="rotate(-90, 12, ${h / 2})">Human Studies Score \u2192</text>
+ <text x="${(pad.l + w - pad.r) / 2}" y="${h - 5}" text-anchor="middle" fill="var(--text-dim)" font-size="11">Artifacts Score \u2192</text>
+ <text x="14" y="${(pad.t + h - pad.b) / 2}" text-anchor="middle" fill="var(--text-dim)" font-size="11" transform="rotate(-90, 14, ${(pad.t + h - pad.b) / 2})">Human Studies Score \u2192</text>
`;
- // Grid
- let grid = '';
- for (let v = 0; v <= 100; v += 25) {
- grid += `<text x="${pad.l - 8}" y="${yScale(v) + 4}" text-anchor="end" font-size="10">${v}%</text>`;
- grid += `<text x="${xScale(v)}" y="${h - pad.b + 15}" text-anchor="middle" font-size="10">${v}%</text>`;
+ // Quadrant summary
+ let qHigh = 0, qLow = 0;
+ for (const p of papers) {
+ if (p.human_studies >= 50 && p.artifacts >= 50) qHigh++;
+ if (p.human_studies < 50 && p.artifacts < 50) qLow++;
}
return `<div class="section">
<h2>Two Cultures</h2>
- <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">Papers with human subjects (n=${papers.length}): human_studies score vs artifacts score. These two dimensions are <strong>negatively correlated</strong> (r=\u22120.24). CS-trained researchers release code but skip IRB; psychology-trained researchers do ethics review but don't release data.</p>
+ <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">Papers with human subjects (n=${papers.length}). Bubble size = paper count at that grid position. Color = mean score. These two dimensions are <strong>negatively correlated</strong> (r=\u22120.24).</p>
<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-width:${w}px">
- ${grid}${quadrants}${dots}${axes}
+ ${svgGrid}${quadLabels}${bubbles}${axes}
</svg>
+ <p style="font-size:0.82rem;color:var(--text-dim);margin-top:0.5rem">Only <strong>${qHigh}</strong> papers score well on both dimensions. <strong>${qLow}</strong> score poorly on both. CS researchers release code but skip IRB; psychology-trained researchers do ethics review but don't release data.</p>
</div>`;
}