paper-detail.ts (8643B)
1 import { loadPaperDetail, loadNetwork } from '../data'; 2 3 function scoreColor(s: number): string { 4 if (s < 30) return 'var(--red)'; 5 if (s < 50) return 'var(--yellow)'; 6 if (s < 70) return 'var(--accent)'; 7 return 'var(--green)'; 8 } 9 10 function formatCatName(name: string): string { 11 return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); 12 } 13 14 function formatQName(name: string): string { 15 return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); 16 } 17 18 export async function renderPaperDetail(app: HTMLElement, slug: string) { 19 app.innerHTML = '<div class="spinner"></div>'; 20 21 let paper; 22 try { 23 paper = await loadPaperDetail(slug); 24 } catch { 25 app.innerHTML = `<p>Paper not found: ${slug}</p><a class="back-link" href="#/papers">Back to papers</a>`; 26 return; 27 } 28 29 if (!paper || !paper.id) { 30 app.innerHTML = `<p>Paper not found: ${slug}</p><a class="back-link" href="#/papers">Back to papers</a>`; 31 return; 32 } 33 34 // Group checklist by category 35 const categories = new Map<string, typeof paper.checklist>(); 36 for (const item of paper.checklist) { 37 if (!categories.has(item.category)) categories.set(item.category, []); 38 categories.get(item.category)!.push(item); 39 } 40 41 // External links 42 const links: string[] = []; 43 if (paper.arxiv_id) { 44 links.push(`<a href="https://arxiv.org/abs/${paper.arxiv_id}" target="_blank" rel="noopener">arXiv</a>`); 45 links.push(`<a href="https://arxiv.org/pdf/${paper.arxiv_id}" target="_blank" rel="noopener">PDF</a>`); 46 } 47 if (paper.doi) { 48 links.push(`<a href="https://doi.org/${paper.doi}" target="_blank" rel="noopener">DOI</a>`); 49 } 50 if (paper.source_url && !paper.source_url.includes('arxiv.org')) { 51 links.push(`<a href="${paper.source_url}" target="_blank" rel="noopener">Source</a>`); 52 } 53 if (paper.code_url) { 54 links.push(`<a href="${paper.code_url}" target="_blank" rel="noopener">Code</a>`); 55 } 56 57 // HN threads 58 const hnThreads = (paper as any).hn_threads as { hn_id: string; title: string; points: number; comments: number; url: string }[] | undefined; 59 if (hnThreads && hnThreads.length > 0) { 60 const top = hnThreads[0]; 61 links.push(`<a href="${top.url}" target="_blank" rel="noopener">HN (${top.points}pts)</a>`); 62 } 63 64 // Load network for citations (lazy, non-blocking) 65 let incomingHtml = ''; 66 let outgoingHtml = ''; 67 try { 68 const net = await loadNetwork(); 69 const incoming = net.edges.filter(e => e[1] === slug).map(e => e[0]); 70 const outgoing = net.edges.filter(e => e[0] === slug).map(e => e[1]); 71 const nodeMap = new Map(net.nodes.map(n => [n.id, n])); 72 73 if (incoming.length) { 74 incomingHtml = `<h3 style="font-size:0.85rem;color:var(--text-dim);margin:0.5rem 0">Cited by (${incoming.length})</h3> 75 ${incoming.map(id => { 76 const n = nodeMap.get(id); 77 return `<div style="font-size:0.82rem;padding:0.2rem 0"><a href="#/paper/${id}" style="color:var(--accent);text-decoration:none">${n?.title || id}</a>${n?.score != null ? ` <span style="color:${scoreColor(n.score)};font-family:var(--font);font-size:0.75rem">${n.score}%</span>` : ''}</div>`; 78 }).join('')}`; 79 } 80 if (outgoing.length) { 81 outgoingHtml = `<h3 style="font-size:0.85rem;color:var(--text-dim);margin:0.5rem 0">Cites (${outgoing.length})</h3> 82 ${outgoing.map(id => { 83 const n = nodeMap.get(id); 84 return `<div style="font-size:0.82rem;padding:0.2rem 0"><a href="#/paper/${id}" style="color:var(--accent);text-decoration:none">${n?.title || id}</a>${n?.score != null ? ` <span style="color:${scoreColor(n.score)};font-family:var(--font);font-size:0.75rem">${n.score}%</span>` : ''}</div>`; 85 }).join('')}`; 86 } 87 } catch { /* network data optional */ } 88 89 app.innerHTML = ` 90 <a class="back-link" href="#/papers">\u2190 Back to papers</a> 91 <div class="paper-header"> 92 <h2>${paper.title}</h2> 93 <div class="paper-meta"> 94 <span style="color:${scoreColor(paper.score!)};font-family:var(--font);font-weight:700">${paper.score}%</span> 95 <span>${paper.year || ''}</span> 96 <span>${paper.venue || ''}</span> 97 <span class="archetype ${paper.archetype}">${paper.archetype}</span> 98 ${paper.tags.map(t => `<span class="tag">${t}</span>`).join('')} 99 </div> 100 ${links.length ? `<div class="paper-links">${links.join('')}</div>` : ''} 101 </div> 102 103 ${paper.key_findings ? `<div class="section"><h2>Key Findings</h2><p style="font-size:0.9rem">${paper.key_findings}</p></div>` : ''} 104 105 ${paper.games.length ? `<div class="section"><h2>Named Games</h2>${paper.games.map(g => `<span class="tag" style="background:rgba(240,101,101,0.15);color:var(--red);margin-right:0.5rem">${g}</span>`).join('')}</div>` : ''} 106 107 ${renderEngagementFactors(paper)} 108 109 <div class="detail-grid"> 110 <div class="section"> 111 <h2>Checklist</h2> 112 ${Array.from(categories.entries()).map(([cat, items]) => ` 113 <div class="checklist-category"> 114 <h3>${formatCatName(cat)} ${paper.category_scores[cat] != null ? `<span style="color:${scoreColor(paper.category_scores[cat])};font-size:0.8rem">${paper.category_scores[cat]}%</span>` : ''}</h3> 115 ${items.map(item => { 116 const icon = !item.applies ? '\u2014' : item.answer ? '\u2713' : '\u2717'; 117 const cls = !item.applies ? 'na' : item.answer ? 'pass' : 'fail'; 118 return `<div class="checklist-item"> 119 <span class="checklist-icon ${cls}">${icon}</span> 120 <div class="checklist-q"> 121 ${formatQName(item.question)} 122 <div class="checklist-justification">${item.justification}</div> 123 </div> 124 </div>`; 125 }).join('')} 126 </div> 127 `).join('')} 128 </div> 129 130 <div> 131 ${paper.claims.length ? `<div class="section"> 132 <h2>Claims (${paper.claims.length})</h2> 133 ${paper.claims.map(c => `<div class="claim"> 134 <span class="support-badge ${c.supported}">${c.supported}</span> 135 <span class="claim-text"> ${c.claim}</span> 136 </div>`).join('')} 137 </div>` : ''} 138 139 ${paper.red_flags.length ? `<div class="section"> 140 <h2>Red Flags (${paper.red_flags.length})</h2> 141 ${paper.red_flags.map(r => `<div class="red-flag"> 142 <div class="flag-name">${r.flag}</div> 143 <div class="flag-detail">${r.detail}</div> 144 </div>`).join('')} 145 </div>` : ''} 146 147 ${incomingHtml || outgoingHtml ? `<div class="section"> 148 <h2>Internal Citations</h2> 149 ${incomingHtml}${outgoingHtml} 150 </div>` : ''} 151 152 <div class="section"> 153 <h2>Category Scores</h2> 154 ${Object.entries(paper.category_scores).sort((a, b) => b[1] - a[1]).map(([cat, val]) => ` 155 <div class="hbar"> 156 <div class="hbar-label"><span>${formatCatName(cat)}</span><span>${val}%</span></div> 157 <div class="hbar-track"><div class="hbar-fill" style="width:${val}%;background:${scoreColor(val)}"></div></div> 158 </div> 159 `).join('')} 160 </div> 161 </div> 162 </div> 163 `; 164 } 165 166 const EF_LABELS: Record<string, string> = { 167 practical_relevance: 'Practical Relevance', 168 surprise_contrarian: 'Surprise / Contrarian', 169 fear_safety: 'Fear / Safety', 170 drama_conflict: 'Drama / Conflict', 171 demo_ability: 'Demo-ability', 172 brand_recognition: 'Brand Recognition', 173 }; 174 175 const EF_ORDER = ['practical_relevance', 'surprise_contrarian', 'fear_safety', 176 'drama_conflict', 'demo_ability', 'brand_recognition']; 177 178 function renderEngagementFactors(paper: any): string { 179 const ef = paper.engagement_factors; 180 if (!ef) return ''; 181 182 return `<div class="section"> 183 <h2>Engagement Factors (v3)</h2> 184 <p style="font-size:0.8rem;color:var(--text-dim);margin-bottom:0.75rem">What drives social/media attention for this paper (0-3 scale).</p> 185 ${EF_ORDER.map(dim => { 186 const d = ef[dim]; 187 if (!d) return ''; 188 const pct = d.score / 3 * 100; 189 const color = d.score === 0 ? 'var(--gray)' : d.score === 1 ? 'var(--yellow)' : d.score === 2 ? 'var(--accent)' : 'var(--green)'; 190 return `<div class="hbar"> 191 <div class="hbar-label"><span>${EF_LABELS[dim] || dim} <span style="color:var(--text-dim);font-size:0.75rem">${d.score}/3</span></span></div> 192 <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%;background:${color}"></div></div> 193 <div style="font-size:0.78rem;color:var(--text-dim);margin-top:1px">${d.justification}</div> 194 </div>`; 195 }).join('')} 196 </div>`; 197 }