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 <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 }