ai-research-survey

Systematic scan of agentic development research. What's signal, what's noise.
git clone https://git.shiptheloop.com/ai-research-survey.git
Log | Files | Refs

findings.ts (42797B)


      1 import { loadFindings, type Findings, type QuestionRate } from '../data';
      2 import { navigate } from '../router';
      3 import { renderBarChart } from '../components/bar-chart';
      4 import { renderMultiLineChart } from '../components/multi-line-chart';
      5 
      6 function formatName(name: string): string {
      7   return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
      8 }
      9 
     10 const CAT_COLORS: Record<string, string> = {
     11   contamination: '#f06565',
     12   data_leakage: '#e08050',
     13   statistical_methodology: '#6c8cff',
     14   experimental_rigor: '#f0c050',
     15   artifacts: '#3dd68c',
     16   evaluation_design: '#a080f0',
     17   claims_and_evidence: '#50c0c0',
     18   survey_methodology: '#ff80b0',
     19   setup_transparency: '#90b060',
     20   limitations_and_scope: '#c0a060',
     21   cost_and_practicality: '#8090a0',
     22   human_studies: '#b070b0',
     23   data_integrity: '#70a0d0',
     24   conflicts_of_interest: '#d07070',
     25 };
     26 
     27 const TENSION_NAMES: Record<string, string> = {
     28   productivity: 'Productivity',
     29   benchmarks: 'Benchmarks',
     30   agents: 'Agents',
     31 };
     32 
     33 export async function renderFindings(app: HTMLElement) {
     34   app.innerHTML = '<div class="spinner"></div>';
     35   const f = await loadFindings();
     36 
     37   app.innerHTML = `
     38     ${renderQuestionRates(f)}
     39     ${renderCorrelationHeatmap(f)}
     40     ${renderPcaScatter(f)}
     41     ${renderYearCategoryTrends(f)}
     42     ${renderVenueCitation(f)}
     43     ${renderOptimismRigor(f)}
     44     ${renderHomophily(f)}
     45     ${renderSamplingEffect(f)}
     46     ${renderBenchmarkMonoculture(f)}
     47     ${renderFundingGap(f)}
     48     ${renderReproDetail(f)}
     49     ${renderReproFunnel(f)}
     50     ${renderTagTreemap(f)}
     51     ${renderTwoCultures(f)}
     52     ${renderNetworkInsights(f)}
     53     ${renderHnAnalysis(f)}
     54     ${renderGames(f)}
     55   `;
     56 
     57   // Attach toggle listeners for year-category chart
     58   attachCategoryToggles(f);
     59   // Attach PCA scatter interactivity
     60   attachPcaScatter(f);
     61 }
     62 
     63 function renderQuestionRates(f: Findings): string {
     64   const sorted = Object.entries(f.question_rates)
     65     .sort((a, b) => a[1].rate - b[1].rate);
     66   const worst20 = sorted.slice(0, 20);
     67   const best10 = sorted.slice(-10).reverse();
     68 
     69   return `<div class="section">
     70     <h2>Per-Question Pass Rates</h2>
     71     <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:1rem">${sorted.length} questions across 14 categories. Sorted by pass rate, worst first.</p>
     72     <h3 style="font-size:0.85rem;color:var(--red);margin-bottom:0.5rem">Worst 20</h3>
     73     ${worst20.map(([key, d]) => {
     74       const [cat] = key.split('.');
     75       const desc = (d as any).desc || formatName(key.split('.')[1]);
     76       const color = d.rate < 10 ? 'var(--red)' : d.rate < 30 ? 'var(--yellow)' : 'var(--accent)';
     77       return `<div class="hbar">
     78         <div class="hbar-label"><span>${desc} <span style="color:var(--text-dim);font-size:0.7rem">${formatName(cat)}</span></span><span>${d.rate}% <span style="color:var(--text-dim)">(n=${d.n})</span></span></div>
     79         <div class="hbar-track"><div class="hbar-fill" style="width:${d.rate}%;background:${color}"></div></div>
     80       </div>`;
     81     }).join('')}
     82     <h3 style="font-size:0.85rem;color:var(--green);margin:1rem 0 0.5rem">Best 10</h3>
     83     ${best10.map(([key, d]) => {
     84       const [cat] = key.split('.');
     85       const desc = (d as any).desc || formatName(key.split('.')[1]);
     86       return `<div class="hbar">
     87         <div class="hbar-label"><span>${desc} <span style="color:var(--text-dim);font-size:0.7rem">${formatName(cat)}</span></span><span>${d.rate}% <span style="color:var(--text-dim)">(n=${d.n})</span></span></div>
     88         <div class="hbar-track"><div class="hbar-fill" style="width:${d.rate}%;background:var(--green)"></div></div>
     89       </div>`;
     90     }).join('')}
     91   </div>`;
     92 }
     93 
     94 function renderCorrelationHeatmap(f: Findings): string {
     95   const { categories, matrix } = f.correlation;
     96   const n = categories.length;
     97   const cell = 38;
     98   const labelW = 140;
     99   const w = labelW + n * cell + 10;
    100   const h = labelW + n * cell + 10;
    101 
    102   function corrColor(r: number | null): string {
    103     if (r === null) return 'var(--border)';
    104     if (r >= 0) {
    105       // Green intensity
    106       const a = Math.min(r / 0.7, 1);
    107       return `rgba(61, 214, 140, ${(a * 0.8 + 0.05).toFixed(2)})`;
    108     } else {
    109       // Red intensity
    110       const a = Math.min(Math.abs(r) / 0.3, 1);
    111       return `rgba(240, 101, 101, ${(a * 0.8 + 0.05).toFixed(2)})`;
    112     }
    113   }
    114 
    115   let cells = '';
    116   for (let i = 0; i < n; i++) {
    117     for (let j = 0; j < n; j++) {
    118       const d = matrix[i][j];
    119       const x = labelW + j * cell;
    120       const y = labelW + i * cell;
    121       const fill = i === j ? 'var(--border)' : corrColor(d.r);
    122       const rText = d.r !== null ? d.r.toFixed(2) : '';
    123       const textColor = d.r !== null && Math.abs(d.r) > 0.35 ? '#fff' : 'var(--text-dim)';
    124       cells += `<rect x="${x}" y="${y}" width="${cell - 1}" height="${cell - 1}" fill="${fill}" rx="2">
    125         <title>${formatName(categories[i])} \u2194 ${formatName(categories[j])}\nr=${d.r !== null ? d.r.toFixed(3) : 'N/A'} (n=${d.n})</title>
    126       </rect>`;
    127       if (i !== j && d.r !== null) {
    128         cells += `<text x="${x + cell / 2}" y="${y + cell / 2 + 4}" text-anchor="middle" fill="${textColor}" font-size="9">${rText}</text>`;
    129       }
    130     }
    131   }
    132 
    133   // Row labels (left)
    134   let labels = '';
    135   for (let i = 0; i < n; i++) {
    136     labels += `<text x="${labelW - 4}" y="${labelW + i * cell + cell / 2 + 4}" text-anchor="end" font-size="10" fill="var(--text)">${formatName(categories[i])}</text>`;
    137   }
    138   // Column labels (top, rotated)
    139   for (let j = 0; j < n; j++) {
    140     labels += `<text x="0" y="0" text-anchor="end" font-size="10" fill="var(--text)" transform="translate(${labelW + j * cell + cell / 2 + 4}, ${labelW - 4}) rotate(-55)">${formatName(categories[j])}</text>`;
    141   }
    142 
    143   return `<div class="section">
    144     <h2>Category Correlation Matrix</h2>
    145     <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:1rem">Pearson correlation between category-level pass rates across ${matrix[0]?.[0]?.n || 0}+ papers. <span style="color:var(--green)">Green = positive</span>, <span style="color:var(--red)">red = negative</span>. Hover cells for details.</p>
    146     <div style="overflow-x:auto">
    147       <svg viewBox="0 0 ${w} ${h}" style="width:100%;max-width:${w}px;min-width:500px">
    148         ${labels}${cells}
    149       </svg>
    150     </div>
    151     <div style="font-size:0.82rem;color:var(--text-dim);margin-top:0.75rem">
    152       <strong>Key patterns:</strong>
    153       Contamination \u2194 data leakage (r=0.86) are effectively the same decision.
    154       Artifacts \u2194 statistical methodology (r=0.06) are completely independent — releasing code says nothing about statistical rigor.
    155       Human studies \u2194 artifacts (r=\u22120.20) is the strongest negative — two research traditions that don't speak to each other.
    156       Three independent rigor clusters: transparency/artifacts, statistics/experimental, contamination awareness.
    157     </div>
    158   </div>`;
    159 }
    160 
    161 const ARCH_COLORS: Record<string, string> = {
    162   Complete: '#3dd68c',
    163   Builder: '#6c8cff',
    164   Theater: '#f0c050',
    165   Mixed: '#8b8fa3',
    166   Minimal: '#f06565',
    167 };
    168 
    169 function renderPcaScatter(f: Findings): string {
    170   const { pca } = f;
    171   // Build loading descriptions
    172   const pc1Top = pca.categories
    173     .map((c, i) => ({ cat: c, v: pca.pc1_loadings[i] }))
    174     .sort((a, b) => Math.abs(b.v) - Math.abs(a.v))
    175     .slice(0, 3)
    176     .map(d => `${formatName(d.cat)} (${d.v > 0 ? '+' : ''}${d.v.toFixed(2)})`)
    177     .join(', ');
    178   const pc2Top = pca.categories
    179     .map((c, i) => ({ cat: c, v: pca.pc2_loadings[i] }))
    180     .sort((a, b) => Math.abs(b.v) - Math.abs(a.v))
    181     .slice(0, 3)
    182     .map(d => `${formatName(d.cat)} (${d.v > 0 ? '+' : ''}${d.v.toFixed(2)})`)
    183     .join(', ');
    184 
    185   const legend = Object.entries(ARCH_COLORS)
    186     .map(([name, color]) => `<span class="chart-legend-item"><span class="chart-legend-swatch" style="background:${color};height:8px;width:8px;border-radius:50%"></span>${name}</span>`)
    187     .join('');
    188 
    189   return `<div class="section">
    190     <h2>Paper Methodology Map (PCA)</h2>
    191     <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">${pca.points.length} papers projected from ${pca.categories.length} category scores to 2D. Colors = archetype. Hover for title, click to view.</p>
    192     <p style="font-size:0.8rem;color:var(--text-dim);margin-bottom:0.25rem"><strong>X-axis</strong> (${pca.pc1_variance_pct}% variance): ${pc1Top}</p>
    193     <p style="font-size:0.8rem;color:var(--text-dim);margin-bottom:0.75rem"><strong>Y-axis</strong> (${pca.pc2_variance_pct}% variance): ${pc2Top}</p>
    194     <canvas id="pca-canvas" width="800" height="500"></canvas>
    195     <div class="chart-legend" style="margin-top:0.5rem">${legend}</div>
    196     <div class="network-tooltip" id="pca-tooltip" style="display:none"></div>
    197   </div>`;
    198 }
    199 
    200 function attachPcaScatter(f: Findings) {
    201   const canvas = document.getElementById('pca-canvas') as HTMLCanvasElement | null;
    202   const tooltip = document.getElementById('pca-tooltip') as HTMLElement | null;
    203   if (!canvas || !tooltip) return;
    204 
    205   const ctx = canvas.getContext('2d')!;
    206   const { points } = f.pca;
    207   const w = canvas.width, h = canvas.height;
    208   const pad = { l: 50, r: 20, t: 20, b: 40 };
    209 
    210   // Compute bounds
    211   const xs = points.map(p => p.x);
    212   const ys = points.map(p => p.y);
    213   const xMin = Math.min(...xs), xMax = Math.max(...xs);
    214   const yMin = Math.min(...ys), yMax = Math.max(...ys);
    215   const xRange = xMax - xMin || 1;
    216   const yRange = yMax - yMin || 1;
    217   // Add 5% padding
    218   const xPad = xRange * 0.05, yPad = yRange * 0.05;
    219 
    220   function toCanvas(px: number, py: number): [number, number] {
    221     const cx = pad.l + ((px - xMin + xPad) / (xRange + 2 * xPad)) * (w - pad.l - pad.r);
    222     const cy = pad.t + (1 - (py - yMin + yPad) / (yRange + 2 * yPad)) * (h - pad.t - pad.b);
    223     return [cx, cy];
    224   }
    225 
    226   function getStyle(prop: string): string {
    227     return getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
    228   }
    229 
    230   function draw() {
    231     const bgColor = getStyle('--surface');
    232     const borderColor = getStyle('--border');
    233     const textColor = getStyle('--text-dim');
    234 
    235     ctx.fillStyle = bgColor;
    236     ctx.fillRect(0, 0, w, h);
    237 
    238     // Grid lines
    239     ctx.strokeStyle = borderColor;
    240     ctx.lineWidth = 0.5;
    241     const [zeroX, zeroY] = toCanvas(0, 0);
    242     ctx.setLineDash([4, 4]);
    243     ctx.beginPath(); ctx.moveTo(zeroX, pad.t); ctx.lineTo(zeroX, h - pad.b); ctx.stroke();
    244     ctx.beginPath(); ctx.moveTo(pad.l, zeroY); ctx.lineTo(w - pad.r, zeroY); ctx.stroke();
    245     ctx.setLineDash([]);
    246 
    247     // Axis labels
    248     ctx.fillStyle = textColor;
    249     ctx.font = '11px sans-serif';
    250     ctx.textAlign = 'center';
    251     ctx.fillText('\u2190 Higher rigor', pad.l + 60, h - 8);
    252     ctx.fillText('Lower rigor \u2192', w - pad.r - 60, h - 8);
    253     ctx.save();
    254     ctx.translate(14, h / 2);
    255     ctx.rotate(-Math.PI / 2);
    256     ctx.fillText('Practical detail \u2191', 0, 0);
    257     ctx.restore();
    258 
    259     // Points
    260     for (const p of points) {
    261       const [cx, cy] = toCanvas(p.x, p.y);
    262       const color = ARCH_COLORS[p.archetype] || '#888';
    263       ctx.beginPath();
    264       ctx.arc(cx, cy, 4, 0, Math.PI * 2);
    265       ctx.fillStyle = color;
    266       ctx.globalAlpha = 0.7;
    267       ctx.fill();
    268       ctx.globalAlpha = 1;
    269       ctx.strokeStyle = 'rgba(0,0,0,0.2)';
    270       ctx.lineWidth = 0.5;
    271       ctx.stroke();
    272     }
    273   }
    274 
    275   draw();
    276 
    277   // Mouse interaction
    278   function canvasCoords(e: MouseEvent): [number, number] {
    279     const rect = canvas!.getBoundingClientRect();
    280     return [
    281       (e.clientX - rect.left) * (w / rect.width),
    282       (e.clientY - rect.top) * (h / rect.height),
    283     ];
    284   }
    285 
    286   canvas.addEventListener('mousemove', e => {
    287     const [mx, my] = canvasCoords(e);
    288     let closest: typeof points[0] | null = null;
    289     let closestDist = 20;
    290     for (const p of points) {
    291       const [cx, cy] = toCanvas(p.x, p.y);
    292       const d = Math.sqrt((cx - mx) ** 2 + (cy - my) ** 2);
    293       if (d < closestDist) { closest = p; closestDist = d; }
    294     }
    295     if (closest) {
    296       canvas!.style.cursor = 'pointer';
    297       tooltip!.style.display = 'block';
    298       tooltip!.style.left = e.clientX + 14 + 'px';
    299       tooltip!.style.top = e.clientY + 14 + 'px';
    300       tooltip!.innerHTML = `<strong>${closest.id}</strong><br>Score: ${closest.score}%<br>Type: ${closest.archetype}`;
    301     } else {
    302       canvas!.style.cursor = 'default';
    303       tooltip!.style.display = 'none';
    304     }
    305   });
    306 
    307   canvas.addEventListener('mouseleave', () => { tooltip!.style.display = 'none'; });
    308 
    309   canvas.addEventListener('click', e => {
    310     const [mx, my] = canvasCoords(e);
    311     for (const p of points) {
    312       const [cx, cy] = toCanvas(p.x, p.y);
    313       if (Math.sqrt((cx - mx) ** 2 + (cy - my) ** 2) < 20) {
    314         navigate(`/paper/${p.id}`);
    315         return;
    316       }
    317     }
    318   });
    319 }
    320 
    321 function renderYearCategoryTrends(f: Findings): string {
    322   const years = Object.keys(f.year_category_trends).sort();
    323   const defaultCats = ['contamination', 'data_leakage', 'statistical_methodology', 'experimental_rigor'];
    324   const allCats = Object.keys(CAT_COLORS);
    325 
    326   const toggles = allCats.map(cat => {
    327     const active = defaultCats.includes(cat) ? ' active' : '';
    328     return `<button class="toggle-btn${active}" data-cat="${cat}" style="border-color:${active ? CAT_COLORS[cat] : ''};color:${active ? CAT_COLORS[cat] : ''}">${formatName(cat)}</button>`;
    329   }).join('');
    330 
    331   const lines = defaultCats.map(cat => ({
    332     label: formatName(cat),
    333     color: CAT_COLORS[cat],
    334     points: years.map((y, i) => ({ x: i, y: f.year_category_trends[y]?.[cat] ?? 0 }))
    335       .filter(p => p.y > 0),
    336   }));
    337 
    338   return `<div class="section">
    339     <h2>Year Trends by Category</h2>
    340     <div class="toggle-group" id="cat-toggles">${toggles}</div>
    341     <div id="cat-chart">${renderMultiLineChart(lines, years, { width: 700 })}</div>
    342   </div>`;
    343 }
    344 
    345 function attachCategoryToggles(f: Findings) {
    346   const container = document.getElementById('cat-toggles');
    347   const chartEl = document.getElementById('cat-chart');
    348   if (!container || !chartEl) return;
    349 
    350   container.addEventListener('click', e => {
    351     const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLElement;
    352     if (!btn) return;
    353     btn.classList.toggle('active');
    354     const cat = btn.dataset.cat!;
    355     const color = CAT_COLORS[cat];
    356     if (btn.classList.contains('active')) {
    357       btn.style.borderColor = color;
    358       btn.style.color = color;
    359     } else {
    360       btn.style.borderColor = '';
    361       btn.style.color = '';
    362     }
    363 
    364     // Re-render chart with active categories
    365     const activeCats = Array.from(container.querySelectorAll('.toggle-btn.active'))
    366       .map(b => (b as HTMLElement).dataset.cat!);
    367     const years = Object.keys(f.year_category_trends).sort();
    368     const lines = activeCats.map(c => ({
    369       label: formatName(c),
    370       color: CAT_COLORS[c],
    371       points: years.map((y, i) => ({ x: i, y: f.year_category_trends[y]?.[c] ?? 0 }))
    372         .filter(p => p.y > 0),
    373     }));
    374     chartEl.innerHTML = renderMultiLineChart(lines, years, { width: 700 });
    375   });
    376 }
    377 
    378 function renderVenueCitation(f: Findings): string {
    379   const venues = Object.entries(f.venue_stats).sort((a, b) => b[1].mean - a[1].mean);
    380   const bands = ['0', '1-50', '51-500', '500+'];
    381 
    382   return `<div class="section">
    383     <h2>Venue & Citation Scoring</h2>
    384     <div class="detail-grid">
    385       <div>
    386         <h3 style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">Score by Venue (3+ papers)</h3>
    387         ${venues.map(([v, d]) => {
    388           const color = d.mean < 40 ? 'var(--red)' : d.mean < 55 ? 'var(--yellow)' : 'var(--green)';
    389           return `<div class="hbar">
    390             <div class="hbar-label"><span>${v}</span><span>${d.mean}% <span style="color:var(--text-dim)">(n=${d.n})</span></span></div>
    391             <div class="hbar-track"><div class="hbar-fill" style="width:${d.mean}%;background:${color}"></div></div>
    392           </div>`;
    393         }).join('')}
    394       </div>
    395       <div>
    396         <h3 style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">Score by Citation Count</h3>
    397         ${bands.map(band => {
    398           const d = f.citation_band_stats[band];
    399           if (!d) return '';
    400           const color = d.mean < 40 ? 'var(--red)' : d.mean < 55 ? 'var(--yellow)' : 'var(--green)';
    401           return `<div class="hbar">
    402             <div class="hbar-label"><span>${band} citations</span><span>${d.mean}% <span style="color:var(--text-dim)">(n=${d.n})</span></span></div>
    403             <div class="hbar-track"><div class="hbar-fill" style="width:${d.mean}%;background:${color}"></div></div>
    404           </div>`;
    405         }).join('')}
    406         <p style="font-size:0.8rem;color:var(--text-dim);margin-top:0.75rem">Most-cited papers (500+) score <strong style="color:var(--red)">below average</strong>. Citations measure influence, not rigor.</p>
    407       </div>
    408     </div>
    409   </div>`;
    410 }
    411 
    412 function renderOptimismRigor(f: Findings): string {
    413   return `<div class="section">
    414     <h2>Optimism-Rigor Inversion</h2>
    415     <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:1rem">Across all three claim tensions, papers making positive/optimistic claims have <strong>lower</strong> methodology scores than papers with nuanced findings.</p>
    416     ${Object.entries(f.optimism_rigor).map(([key, d]) => `
    417       <div class="game-row">
    418         <span class="game-name">${TENSION_NAMES[key] || key}</span>
    419         <span style="font-family:var(--font);font-size:0.85rem">
    420           <span style="color:var(--yellow)">Positive ${d.positive_mean}%</span>
    421           <span style="color:var(--text-dim)"> vs </span>
    422           <span style="color:var(--green)">Nuanced ${d.nuanced_mean}%</span>
    423           <span style="color:var(--accent)"> (+${d.gap}pp)</span>
    424         </span>
    425       </div>
    426     `).join('')}
    427   </div>`;
    428 }
    429 
    430 function renderHomophily(f: Findings): string {
    431   const h = f.homophily;
    432   const ratio = h.high_cite_total > 0 ? (h.high_cite_high_pct / h.baseline_pct).toFixed(1) : '?';
    433   return `<div class="section">
    434     <h2>Quality Homophily</h2>
    435     <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:1rem">Do high-quality papers cite other high-quality papers more than expected? (threshold: ${h.threshold}%+ score)</p>
    436     <div class="hbar">
    437       <div class="hbar-label"><span>Expected (baseline)</span><span>${h.baseline_pct}%</span></div>
    438       <div class="hbar-track"><div class="hbar-fill" style="width:${h.baseline_pct}%;background:var(--text-dim)"></div></div>
    439     </div>
    440     <div class="hbar">
    441       <div class="hbar-label"><span>Observed (high cites high)</span><span>${h.high_cite_high_pct}%</span></div>
    442       <div class="hbar-track"><div class="hbar-fill" style="width:${Math.min(h.high_cite_high_pct, 100)}%;background:var(--green)"></div></div>
    443     </div>
    444     <p style="font-size:0.85rem;margin-top:0.5rem"><strong>${ratio}x</strong> more likely to cite high-quality work <span style="color:var(--text-dim)">(n=${h.high_cite_total} citations)</span></p>
    445   </div>`;
    446 }
    447 
    448 function renderSamplingEffect(f: Findings): string {
    449   const pts = f.sampling_effect.checkpoints;
    450   const w = 400, h = 150;
    451   const pad = { l: 50, r: 20, t: 15, b: 30 };
    452   const chartW = w - pad.l - pad.r;
    453   const chartH = h - pad.t - pad.b;
    454 
    455   const xScale = (i: number) => pad.l + (i / (pts.length - 1)) * chartW;
    456   const yMin = 40, yMax = 60;
    457   const yScale = (v: number) => pad.t + chartH - ((v - yMin) / (yMax - yMin)) * chartH;
    458 
    459   let svg = '';
    460   for (let v = yMin; v <= yMax; v += 5) {
    461     svg += `<text x="${pad.l - 8}" y="${yScale(v) + 4}" text-anchor="end">${v}%</text>`;
    462     svg += `<line class="grid-line" x1="${pad.l}" x2="${w - pad.r}" y1="${yScale(v)}" y2="${yScale(v)}" stroke-dasharray="3"/>`;
    463   }
    464 
    465   const polyline = pts.map((p, i) => `${xScale(i)},${yScale(p.median)}`).join(' ');
    466   svg += `<polyline points="${polyline}" fill="none" stroke="var(--yellow)" stroke-width="2"/>`;
    467   pts.forEach((p, i) => {
    468     svg += `<circle cx="${xScale(i)}" cy="${yScale(p.median)}" r="4" fill="var(--yellow)"/>`;
    469     svg += `<text x="${xScale(i)}" y="${yScale(p.median) - 10}" text-anchor="middle" fill="var(--yellow)" font-size="11">${p.median}%</text>`;
    470     svg += `<text x="${xScale(i)}" y="${h - 5}" text-anchor="middle">n=${p.n}</text>`;
    471   });
    472 
    473   return `<div class="section">
    474     <h2>Sampling Effect</h2>
    475     <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">Median score drops as the long tail is scanned. Visibility correlates with quality.</p>
    476     <svg viewBox="0 0 ${w} ${h}" style="width:100%;max-width:${w}px">${svg}</svg>
    477   </div>`;
    478 }
    479 
    480 function renderBenchmarkMonoculture(f: Findings): string {
    481   const years = Object.keys(f.benchmark_monoculture).sort();
    482   const pts = years.map((y, i) => ({ x: i, y: f.benchmark_monoculture[y].pct }));
    483   const lines = [{
    484     label: 'Benchmark-only papers',
    485     color: '#f0c050',
    486     points: pts,
    487   }];
    488 
    489   return `<div class="section">
    490     <h2>Benchmark Monoculture</h2>
    491     <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">Share of papers using only benchmark evaluation, no other methodology.</p>
    492     ${renderMultiLineChart(lines, years)}
    493     ${years.length > 0 ? `<p style="font-size:0.85rem;margin-top:0.5rem">${f.benchmark_monoculture[years[years.length - 1]]?.pct ?? 0}% of ${years[years.length - 1]} papers are pure benchmark-eval.</p>` : ''}
    494   </div>`;
    495 }
    496 
    497 function renderFundingGap(f: Findings): string {
    498   const disc = f.funding_gap.disclosed;
    499   const nodisc = f.funding_gap.not_disclosed;
    500   if (!disc || !nodisc) return '';
    501   const gap = (disc.mean - nodisc.mean).toFixed(1);
    502 
    503   return `<div class="section">
    504     <h2>Funding Disclosure Gap</h2>
    505     <div class="hbar">
    506       <div class="hbar-label"><span>Funding disclosed</span><span>${disc.mean}% <span style="color:var(--text-dim)">(n=${disc.n})</span></span></div>
    507       <div class="hbar-track"><div class="hbar-fill" style="width:${disc.mean}%;background:var(--green)"></div></div>
    508     </div>
    509     <div class="hbar">
    510       <div class="hbar-label"><span>Not disclosed</span><span>${nodisc.mean}% <span style="color:var(--text-dim)">(n=${nodisc.n})</span></span></div>
    511       <div class="hbar-track"><div class="hbar-fill" style="width:${nodisc.mean}%;background:var(--red)"></div></div>
    512     </div>
    513     <p style="font-size:0.85rem;margin-top:0.5rem"><strong>${gap}pp gap</strong> — papers that disclose funding score substantially higher.</p>
    514   </div>`;
    515 }
    516 
    517 function renderReproDetail(f: Findings): string {
    518   const qs = ['code_released', 'data_released', 'environment_specified', 'reproduction_instructions'];
    519   return `<div class="section">
    520     <h2>Reproducibility Drill-Down</h2>
    521     ${qs.map(q => {
    522       const d = f.repro_detail[q] as QuestionRate | undefined;
    523       if (!d || typeof d === 'number') return '';
    524       const color = d.rate < 15 ? 'var(--red)' : d.rate < 50 ? 'var(--yellow)' : 'var(--green)';
    525       return `<div class="hbar">
    526         <div class="hbar-label"><span>${formatName(q)}</span><span>${d.rate}% <span style="color:var(--text-dim)">(n=${d.n})</span></span></div>
    527         <div class="hbar-track"><div class="hbar-fill" style="width:${d.rate}%;background:${color}"></div></div>
    528       </div>`;
    529     }).join('')}
    530     <p style="font-size:1rem;margin-top:1rem;font-weight:600"><span style="color:var(--red);font-family:var(--font);font-size:1.3rem">${f.repro_detail.full_pass_pct}%</span> of papers are fully reproducible <span style="color:var(--text-dim)">(${f.repro_detail.full_pass_count} papers pass all 4 criteria)</span></p>
    531   </div>`;
    532 }
    533 
    534 function renderReproFunnel(f: Findings): string {
    535   const funnel = (f.repro_detail as any).funnel as { step: string; n: number }[];
    536   if (!funnel || !funnel.length) return '';
    537   const max = funnel[0].n;
    538 
    539   return `<div class="section">
    540     <h2>Reproducibility Funnel</h2>
    541     <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:1rem">Each step filters papers that pass ALL previous criteria. The cliff at "Environment specified" is where reproducibility collapses.</p>
    542     ${funnel.map((step, i) => {
    543       const pct = (step.n / max * 100).toFixed(1);
    544       const lost = i > 0 ? funnel[i - 1].n - step.n : 0;
    545       const color = step.n / max > 0.5 ? 'var(--green)' : step.n / max > 0.1 ? 'var(--yellow)' : 'var(--red)';
    546       return `<div class="funnel-step">
    547         <div class="funnel-label">
    548           <span>${step.step}</span>
    549           <span style="font-family:var(--font)">${step.n} <span style="color:var(--text-dim)">(${pct}%)</span>${i > 0 ? ` <span style="color:var(--red);font-size:0.75rem">\u2212${lost}</span>` : ''}</span>
    550         </div>
    551         <div class="funnel-track"><div class="funnel-fill" style="width:${pct}%;background:${color}"></div></div>
    552       </div>`;
    553     }).join('')}
    554   </div>`;
    555 }
    556 
    557 function renderTagTreemap(f: Findings): string {
    558   const tags = (f as any).tag_treemap as { tag: string; n: number; mean: number }[];
    559   if (!tags || !tags.length) return '';
    560   const totalPapers = tags.reduce((s, t) => s + t.n, 0);
    561 
    562   // Render as proportional blocks
    563   return `<div class="section">
    564     <h2>Methodology Landscape</h2>
    565     <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:1rem">Paper corpus by methodology type. Size = paper count. Color = mean score.</p>
    566     <div class="treemap">
    567       ${tags.map(t => {
    568         const pct = (t.n / totalPapers * 100);
    569         const color = t.mean < 42 ? 'var(--red)' : t.mean < 52 ? 'var(--yellow)' : t.mean < 58 ? 'var(--accent)' : 'var(--green)';
    570         // Min width for small tags
    571         const width = Math.max(pct, 6);
    572         return `<div class="treemap-cell" style="flex-basis:${width}%;background:${color}" title="${formatName(t.tag)}: ${t.n} papers, mean ${t.mean}%">
    573           <div class="treemap-label">${formatName(t.tag)}</div>
    574           <div class="treemap-value">${t.n} (${t.mean}%)</div>
    575         </div>`;
    576       }).join('')}
    577     </div>
    578   </div>`;
    579 }
    580 
    581 function renderTwoCultures(f: Findings): string {
    582   const papers = (f as any).two_cultures as { human_studies: number; artifacts: number; id: string; score: number }[];
    583   if (!papers || papers.length < 10) return '';
    584 
    585   // Bucket into grid cells (scores are quantized to ~25% steps)
    586   const steps = [0, 25, 50, 75, 100];
    587   function snap(v: number): number {
    588     let best = 0;
    589     for (const s of steps) {
    590       if (Math.abs(v - s) < Math.abs(v - best)) best = s;
    591     }
    592     return best;
    593   }
    594 
    595   const grid = new Map<string, { count: number; totalScore: number }>();
    596   for (const p of papers) {
    597     const ax = snap(p.artifacts);
    598     const hy = snap(p.human_studies);
    599     const key = `${ax},${hy}`;
    600     const cell = grid.get(key) || { count: 0, totalScore: 0 };
    601     cell.count++;
    602     cell.totalScore += p.score;
    603     grid.set(key, cell);
    604   }
    605 
    606   const maxCount = Math.max(...Array.from(grid.values()).map(c => c.count));
    607 
    608   // SVG bubble grid
    609   const w = 420, h = 420;
    610   const pad = { l: 70, r: 30, t: 30, b: 60 };
    611   const cw = w - pad.l - pad.r, ch = h - pad.t - pad.b;
    612   const xScale = (v: number) => pad.l + (v / 100) * cw;
    613   const yScale = (v: number) => pad.t + ch - (v / 100) * ch;
    614 
    615   // Grid lines
    616   let svgGrid = '';
    617   for (const v of steps) {
    618     svgGrid += `<line x1="${xScale(v)}" y1="${pad.t}" x2="${xScale(v)}" y2="${h - pad.b}" stroke="var(--border)" stroke-width="0.5"/>`;
    619     svgGrid += `<line x1="${pad.l}" y1="${yScale(v)}" x2="${w - pad.r}" y2="${yScale(v)}" stroke="var(--border)" stroke-width="0.5"/>`;
    620     svgGrid += `<text x="${pad.l - 8}" y="${yScale(v) + 4}" text-anchor="end" font-size="10">${v}%</text>`;
    621     svgGrid += `<text x="${xScale(v)}" y="${h - pad.b + 16}" text-anchor="middle" font-size="10">${v}%</text>`;
    622   }
    623 
    624   // Bubbles
    625   let bubbles = '';
    626   for (const [key, cell] of grid.entries()) {
    627     const [ax, hy] = key.split(',').map(Number);
    628     const r = Math.max(4, Math.sqrt(cell.count / maxCount) * 22);
    629     const meanScore = cell.totalScore / cell.count;
    630     const color = meanScore < 40 ? '#f06565' : meanScore < 55 ? '#f0c050' : '#3dd68c';
    631     bubbles += `<circle cx="${xScale(ax)}" cy="${yScale(hy)}" r="${r}" fill="${color}" opacity="0.7">
    632       <title>Artifacts ${ax}%, Human Studies ${hy}%\n${cell.count} papers, mean score ${meanScore.toFixed(1)}%</title>
    633     </circle>`;
    634     if (cell.count > 1) {
    635       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>`;
    636     }
    637   }
    638 
    639   // Quadrant shading
    640   const midX = xScale(50), midY = yScale(50);
    641   const quadLabels = `
    642     <line x1="${midX}" y1="${pad.t}" x2="${midX}" y2="${h - pad.b}" stroke="var(--text-dim)" stroke-dasharray="4" opacity="0.4"/>
    643     <line x1="${pad.l}" y1="${midY}" x2="${w - pad.r}" y2="${midY}" stroke="var(--text-dim)" stroke-dasharray="4" opacity="0.4"/>
    644   `;
    645 
    646   // Axes
    647   const axes = `
    648     <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>
    649     <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>
    650   `;
    651 
    652   // Quadrant summary
    653   let qHigh = 0, qLow = 0;
    654   for (const p of papers) {
    655     if (p.human_studies >= 50 && p.artifacts >= 50) qHigh++;
    656     if (p.human_studies < 50 && p.artifacts < 50) qLow++;
    657   }
    658 
    659   return `<div class="section">
    660     <h2>Two Cultures</h2>
    661     <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>
    662     <svg viewBox="0 0 ${w} ${h}" style="width:100%;max-width:${w}px">
    663       ${svgGrid}${quadLabels}${bubbles}${axes}
    664     </svg>
    665     <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>
    666   </div>`;
    667 }
    668 
    669 function renderNetworkInsights(f: Findings): string {
    670   const ni = (f as any).network_insights;
    671   if (!ni) return '';
    672 
    673   const foundational = ni.foundational as { id: string; title: string; in_degree: number; score: number | null }[];
    674   const contagion = ni.quality_contagion as Record<string, { n: number; mean: number }>;
    675   const diffusion = ni.rigor_diffusion as { id: string; title: string; score: number | null; in_degree: number; citer_mean: number | null; citer_n: number }[];
    676 
    677   return `<div class="section">
    678     <h2>Citation Network Insights</h2>
    679 
    680     <h3 style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">Foundational Papers (most internally cited)</h3>
    681     <div class="table-wrap"><table style="font-size:0.82rem">
    682       <thead><tr><th>Paper</th><th>Citations</th><th>Score</th><th>Citer Mean</th></tr></thead>
    683       <tbody>${diffusion.slice(0, 15).map(p => {
    684         const sc = p.score != null ? `<span style="color:${p.score < 40 ? 'var(--red)' : p.score < 55 ? 'var(--yellow)' : 'var(--green)'}">${p.score}%</span>` : '--';
    685         const cm = p.citer_mean != null ? `${p.citer_mean}%` : '--';
    686         return `<tr>
    687           <td>${p.score != null ? `<a href="#/paper/${p.id}" style="color:var(--accent);text-decoration:none">${p.title.length > 55 ? p.title.slice(0, 52) + '...' : p.title}</a>` : (p.title.length > 55 ? p.title.slice(0, 52) + '...' : p.title)}</td>
    688           <td style="font-family:var(--font)">${p.in_degree}</td>
    689           <td class="score">${sc}</td>
    690           <td style="font-family:var(--font)">${cm}</td>
    691         </tr>`;
    692       }).join('')}</tbody>
    693     </table></div>
    694 
    695     <h3 style="font-size:0.85rem;color:var(--text-dim);margin:1.5rem 0 0.5rem">Quality Contagion — You Are Who You Cite</h3>
    696     <p style="font-size:0.82rem;color:var(--text-dim);margin-bottom:0.5rem">Mean methodology score by the proportion of high-quality (\u226550%) papers in a paper's reference list.</p>
    697     ${['0%', '1-33%', '34-66%', '67-100%'].map(band => {
    698       const d = contagion[band];
    699       if (!d) return '';
    700       const color = d.mean < 45 ? 'var(--red)' : d.mean < 50 ? 'var(--yellow)' : 'var(--green)';
    701       return `<div class="hbar">
    702         <div class="hbar-label"><span>${band} high-quality refs</span><span>${d.mean}% <span style="color:var(--text-dim)">(n=${d.n})</span></span></div>
    703         <div class="hbar-track"><div class="hbar-fill" style="width:${d.mean}%;background:${color}"></div></div>
    704       </div>`;
    705     }).join('')}
    706   </div>`;
    707 }
    708 
    709 function renderHnAnalysis(f: Findings): string {
    710   const hn = (f as any).hn_analysis;
    711   if (!hn || !hn.total_with_hn) return '';
    712 
    713   const topHn = hn.top_hn as { id: string; title: string; score: number; hn_points: number }[];
    714   const gems = hn.hidden_gems as { id: string; title: string; score: number; hn_points: number }[];
    715   const overhyped = hn.overhyped as { id: string; title: string; score: number; hn_points: number }[];
    716   const scatter = hn.scatter as { id: string; hn: number; score: number; log_hn: number }[];
    717   const tagComp = hn.tag_comparison as Record<string, { n: number; mean_hn: number; mean_score: number }>;
    718   const repost = hn.repost_signal as Record<string, { n: number; mean_score: number; mean_hn: number }>;
    719   const controversy = hn.controversy as { high_discussion_mean: number; low_discussion_mean: number; high_n: number; low_n: number } | undefined;
    720 
    721   // Scatter: HN points (log) vs methodology score
    722   const scatterSvg = renderHnScatter(scatter);
    723 
    724   // Tag comparison bars
    725   const tagBars = Object.entries(tagComp)
    726     .sort((a, b) => b[1].mean_hn - a[1].mean_hn)
    727     .map(([tag, d]) => {
    728       const hnColor = 'var(--accent)';
    729       const scColor = d.mean_score < 42 ? 'var(--red)' : d.mean_score < 52 ? 'var(--yellow)' : 'var(--green)';
    730       return `<div style="margin-bottom:0.6rem">
    731         <div class="hbar-label"><span>${formatName(tag)} <span style="color:var(--text-dim)">(n=${d.n})</span></span></div>
    732         <div style="display:flex;gap:4px">
    733           <div class="hbar-track" style="flex:1"><div class="hbar-fill" style="width:${Math.min(d.mean_hn / 120 * 100, 100)}%;background:${hnColor}"></div></div>
    734           <div class="hbar-track" style="flex:1"><div class="hbar-fill" style="width:${d.mean_score}%;background:${scColor}"></div></div>
    735         </div>
    736         <div style="display:flex;justify-content:space-between;font-size:0.72rem;color:var(--text-dim)">
    737           <span>HN: ${d.mean_hn.toFixed(0)} pts</span>
    738           <span>Method: ${d.mean_score}%</span>
    739         </div>
    740       </div>`;
    741     }).join('');
    742 
    743   return `<div class="section">
    744     <h2>Social Attention vs Rigor</h2>
    745     <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:1rem">Hacker News discussion data for ${hn.total_with_hn} papers (${hn.total_without_hn} had no HN presence). Correlation between HN points and methodology score: <strong>r=${hn.correlation}</strong> — social attention is uncorrelated with rigor.</p>
    746 
    747     ${scatterSvg}
    748 
    749     <div class="detail-grid" style="margin-top:1.5rem">
    750       <div>
    751         <h3 style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">The Case Study Paradox</h3>
    752         <p style="font-size:0.8rem;color:var(--text-dim);margin-bottom:0.75rem">HN attention (blue, left bar) vs methodology score (right bar) by paper type. Case studies get the most love with the worst rigor.</p>
    753         ${tagBars}
    754       </div>
    755       <div>
    756         <h3 style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">Signals in the Noise</h3>
    757         ${repost ? `<div style="margin-bottom:1rem">
    758           <p style="font-size:0.8rem;color:var(--text-dim);margin-bottom:0.5rem"><strong>Repost signal:</strong> papers shared repeatedly tend to have more substance.</p>
    759           ${Object.entries(repost).map(([label, d]) => `<div class="hbar">
    760             <div class="hbar-label"><span>${label}</span><span>${d.mean_score}% <span style="color:var(--text-dim)">(n=${d.n})</span></span></div>
    761             <div class="hbar-track"><div class="hbar-fill" style="width:${d.mean_score}%;background:${d.mean_score >= 50 ? 'var(--green)' : 'var(--yellow)'}"></div></div>
    762           </div>`).join('')}
    763         </div>` : ''}
    764         ${controversy ? `<div style="margin-bottom:1rem">
    765           <p style="font-size:0.8rem;color:var(--text-dim);margin-bottom:0.5rem"><strong>Controversy signal:</strong> papers people argue about tend to have something real to argue about.</p>
    766           <div class="hbar">
    767             <div class="hbar-label"><span>High discussion</span><span>${controversy.high_discussion_mean}% <span style="color:var(--text-dim)">(n=${controversy.high_n})</span></span></div>
    768             <div class="hbar-track"><div class="hbar-fill" style="width:${controversy.high_discussion_mean}%;background:var(--green)"></div></div>
    769           </div>
    770           <div class="hbar">
    771             <div class="hbar-label"><span>Upvote-and-move-on</span><span>${controversy.low_discussion_mean}% <span style="color:var(--text-dim)">(n=${controversy.low_n})</span></span></div>
    772             <div class="hbar-track"><div class="hbar-fill" style="width:${controversy.low_discussion_mean}%;background:var(--yellow)"></div></div>
    773           </div>
    774         </div>` : ''}
    775       </div>
    776     </div>
    777 
    778     ${hn.engagement_n >= 10 ? `<div style="margin-top:1.5rem">
    779       <h3 style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">Engagement Factor Correlations (n=${hn.engagement_n})</h3>
    780       ${['brand_recognition', 'fear_safety', 'drama_conflict', 'surprise_contrarian', 'practical_relevance', 'demo_ability'].map(dim => {
    781         const r = hn.engagement_correlations?.[dim];
    782         if (r === undefined) return '';
    783         const color = r > 0.1 ? 'var(--green)' : r < -0.1 ? 'var(--red)' : 'var(--text-dim)';
    784         const label = dim.replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase());
    785         return `<div class="hbar">
    786           <div class="hbar-label"><span>${label}</span><span style="color:${color}">r=${r > 0 ? '+' : ''}${r.toFixed(3)}</span></div>
    787           <div class="hbar-track"><div class="hbar-fill" style="width:${Math.abs(r) * 300}%;max-width:100%;background:${color}"></div></div>
    788         </div>`;
    789       }).join('')}
    790     </div>` : ''}
    791 
    792     <div class="detail-grid" style="margin-top:1.5rem">
    793       <div>
    794         <h3 style="font-size:0.85rem;color:var(--green);margin-bottom:0.5rem">Hidden Gems (score \u226565%, \u22645 HN pts)</h3>
    795         ${gems.map(p => `<div class="game-row" style="padding:0.3rem 0">
    796           <span style="font-size:0.82rem"><a href="#/paper/${p.id}" style="color:var(--accent);text-decoration:none">${p.title.length > 50 ? p.title.slice(0, 47) + '...' : p.title}</a></span>
    797           <span style="font-family:var(--font);font-size:0.8rem"><span style="color:var(--green)">${p.score}%</span></span>
    798         </div>`).join('')}
    799       </div>
    800       <div>
    801         <h3 style="font-size:0.85rem;color:var(--red);margin-bottom:0.5rem">Overhyped (score &lt;40%, \u226530 HN pts)</h3>
    802         ${overhyped.map(p => `<div class="game-row" style="padding:0.3rem 0">
    803           <span style="font-size:0.82rem"><a href="#/paper/${p.id}" style="color:var(--accent);text-decoration:none">${p.title.length > 50 ? p.title.slice(0, 47) + '...' : p.title}</a></span>
    804           <span style="font-family:var(--font);font-size:0.8rem"><span style="color:var(--red)">${p.score}%</span> <span style="color:var(--text-dim)">${p.hn_points}pts</span></span>
    805         </div>`).join('')}
    806       </div>
    807     </div>
    808 
    809     <h3 style="font-size:0.85rem;color:var(--text-dim);margin:1.5rem 0 0.5rem">Most Discussed on HN</h3>
    810     <div class="table-wrap"><table style="font-size:0.82rem">
    811       <thead><tr><th>Paper</th><th>HN pts</th><th>Method</th></tr></thead>
    812       <tbody>${topHn.slice(0, 15).map(p => {
    813         const sc = p.score < 40 ? 'var(--red)' : p.score < 55 ? 'var(--yellow)' : 'var(--green)';
    814         return `<tr>
    815           <td><a href="#/paper/${p.id}" style="color:var(--accent);text-decoration:none">${p.title.length > 55 ? p.title.slice(0, 52) + '...' : p.title}</a></td>
    816           <td style="font-family:var(--font)">${p.hn_points}</td>
    817           <td class="score" style="color:${sc}">${p.score}%</td>
    818         </tr>`;
    819       }).join('')}</tbody>
    820     </table></div>
    821   </div>`;
    822 }
    823 
    824 function renderHnScatter(scatter: { id: string; hn: number; score: number; log_hn: number }[]): string {
    825   if (!scatter || scatter.length < 10) return '';
    826 
    827   const w = 600, h = 350;
    828   const pad = { l: 50, r: 20, t: 15, b: 45 };
    829   const cw = w - pad.l - pad.r, ch = h - pad.t - pad.b;
    830 
    831   const maxLog = Math.max(...scatter.map(p => p.log_hn));
    832   const xScale = (v: number) => pad.l + (v / maxLog) * cw;
    833   const yScale = (v: number) => pad.t + ch - (v / 100) * ch;
    834 
    835   // Grid
    836   let grid = '';
    837   for (let v = 0; v <= 100; v += 25) {
    838     grid += `<text x="${pad.l - 8}" y="${yScale(v) + 4}" text-anchor="end" font-size="10">${v}%</text>`;
    839     grid += `<line class="grid-line" x1="${pad.l}" x2="${w - pad.r}" y1="${yScale(v)}" y2="${yScale(v)}" stroke-dasharray="3"/>`;
    840   }
    841   // X-axis: log scale labels
    842   for (const pts of [1, 10, 50, 100, 500, 1000]) {
    843     const lv = Math.log(pts + 1);
    844     if (lv <= maxLog) {
    845       grid += `<text x="${xScale(lv)}" y="${h - 8}" text-anchor="middle" font-size="10">${pts}</text>`;
    846     }
    847   }
    848 
    849   // Dots
    850   let dots = '';
    851   for (const p of scatter) {
    852     const cx = xScale(p.log_hn);
    853     const cy = yScale(p.score);
    854     const color = p.score < 40 ? '#f06565' : p.score < 55 ? '#f0c050' : '#3dd68c';
    855     dots += `<circle cx="${cx}" cy="${cy}" r="3" fill="${color}" opacity="0.5">
    856       <title>${p.id}: ${p.score}% method, ${p.hn} HN pts</title>
    857     </circle>`;
    858   }
    859 
    860   // Axes
    861   const axes = `
    862     <text x="${(pad.l + w - pad.r) / 2}" y="${h - 25}" text-anchor="middle" fill="var(--text-dim)" font-size="11">HN Points (log scale) \u2192</text>
    863     <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})">Methodology Score \u2192</text>
    864   `;
    865 
    866   return `<div>
    867     <h3 style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.5rem">${scatter.length} Papers: HN Points vs Methodology</h3>
    868     <svg viewBox="0 0 ${w} ${h}" style="width:100%;max-width:${w}px">${grid}${dots}${axes}</svg>
    869     <p style="font-size:0.78rem;color:var(--text-dim)">The blob has no slope. Social attention is random with respect to rigor.</p>
    870   </div>`;
    871 }
    872 
    873 function renderGames(f: Findings): string {
    874   const sorted = Object.entries(f.game_pcts).sort((a, b) => b[1] - a[1]);
    875   return `<div class="section">
    876     <h2>Named Games</h2>
    877     <p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.75rem">Recurring methodological patterns detected across the corpus.</p>
    878     ${sorted.map(([name, pct]) =>
    879       `<div class="game-row"><span class="game-name">${name}</span><span class="game-pct">${pct}%</span></div>`
    880     ).join('')}
    881   </div>`;
    882 }

Impressum · Datenschutz