kiyer/pathfinder
36
1// frontend/src/App.jsx2import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';3import { LIGHT, DARK, PIPELINE, EXAMPLES } from './theme.js';4import { runSearch, fetchUmapSample, fetchStats } from './api.js';5 6function fmtAuthors(a) {7 const list = a.split(', ');8 return list.length > 3 ? `${list.slice(0, 3).join(', ')}, +${list.length - 3}` : a;9}10 11function linkifyAnswer(text, papers) {12 const byShort = {};13 papers.forEach((p) => { byShort[p.short] = p; });14 return text.replace(/\[([^\]]+)\]/g, (m, short) => {15 const p = byShort[short];16 if (!p) return m;17 return `<a href="https://arxiv.org/abs/${p.arxiv}" target="_blank" style="color:var(--goldtext); border-bottom:1px solid var(--goldborder);">(${short})</a>`;18 });19}20 21export default function App() {22 const [view, setView] = useState('home');23 const [query, setQuery] = useState('');24 const [theme, setTheme] = useState('light');25 const [topK, setTopK] = useState(10);26 const [answerStyle, setAnswerStyle] = useState('Multi-paper');27 const [sort, setSort] = useState('relevance');28 const [weights, setWeights] = useState({ keywords: true, recency: false, citations: false });29 const [years, setYears] = useState({});30 const [expanded, setExpanded] = useState({});31 const [reasoningOpen, setReasoningOpen] = useState(false);32 const [settingsOpen, setSettingsOpen] = useState(false);33 34 const [phaseKeys, setPhaseKeys] = useState([]);35 const [papers, setPapers] = useState([]);36 const [answerHtml, setAnswerHtml] = useState('');37 const [answerRaw, setAnswerRaw] = useState('');38 const [consensus, setConsensus] = useState(null);39 const [blockedMsg, setBlockedMsg] = useState('');40 const [done, setDone] = useState(false);41 const [umapBg, setUmapBg] = useState([]);42 const [corpusTotal, setCorpusTotal] = useState(null);43 44 const mapCanvasRef = useRef(null);45 const esRef = useRef(null);46 47 useEffect(() => { fetchUmapSample().then(setUmapBg).catch(() => {}); }, []);48 useEffect(() => { fetchStats().then(setCorpusTotal).catch(() => {}); }, []);49 50 const V = theme === 'dark' ? DARK : LIGHT;51 const themeVars = useMemo(52 () => Object.fromEntries(Object.entries(V).map(([k, v]) => [k, v])),53 [V]54 );55 56 const scored = useCallback(() => {57 const recencyFactor = (y) => 0.55 + 0.45 * Math.min(1, Math.max(0, (y - 2013) / 10));58 const citeFactor = (c) => 0.55 + 0.45 * Math.min(1, Math.log10(c + 1) / Math.log10(2000));59 return papers.map((p) => {60 let sc = p.scores.embed;61 if (weights.keywords) sc *= (0.45 + 0.55 * p.scores.keywords);62 if (weights.recency) sc *= recencyFactor(p.year);63 if (weights.citations) sc *= citeFactor(p.cites);64 return { ...p, score: sc };65 });66 }, [papers, weights]);67 68 const displayed = useCallback(() => {69 const anyYr = Object.values(years).some(Boolean);70 let list = scored().filter((p) => !anyYr || years[p.year]);71 list.sort((a, b) => sort === 'year' ? b.year - a.year : sort === 'cites' ? b.cites - a.cites : b.score - a.score);72 const maxSc = Math.max(...list.map((p) => p.score), 0.0001);73 list = list.slice(0, topK);74 return list.map((p, i) => ({ ...p, rank: i + 1, rel: p.score / maxSc }));75 }, [scored, years, sort, topK]);76 77 const drawMap = useCallback(() => {78 const cv = mapCanvasRef.current;79 if (!cv) return;80 const dark = theme === 'dark';81 const ctx = cv.getContext('2d');82 const W = cv.width, H = cv.height, pad = 14;83 const allX = umapBg.map((p) => p[0]).concat(papers.map((p) => p.ux));84 const allY = umapBg.map((p) => p[1]).concat(papers.map((p) => p.uy));85 const minX = Math.min(...allX, 0), maxX = Math.max(...allX, 1);86 const minY = Math.min(...allY, 0), maxY = Math.max(...allY, 1);87 const px = (x) => pad + ((x - minX) / (maxX - minX || 1)) * (W - 2 * pad);88 const py = (y) => pad + ((y - minY) / (maxY - minY || 1)) * (H - 2 * pad);89 90 ctx.clearRect(0, 0, W, H);91 ctx.fillStyle = dark ? 'rgba(233,236,247,0.055)' : 'rgba(20,22,30,0.09)';92 umapBg.forEach(([x, y]) => { ctx.beginPath(); ctx.arc(px(x), py(y), 1.15, 0, 6.29); ctx.fill(); });93 94 displayed().forEach((p) => {95 const x = px(p.ux), y = py(p.uy);96 ctx.beginPath(); ctx.arc(x, y, 8, 0, 6.29);97 ctx.fillStyle = dark ? 'rgba(255,255,255,0.9)' : 'rgba(12,13,17,0.10)';98 ctx.fill();99 ctx.shadowColor = dark ? 'rgba(244,183,64,0.9)' : 'rgba(227,154,30,0.75)';100 ctx.shadowBlur = 11;101 ctx.beginPath(); ctx.arc(x, y, 3.6, 0, 6.29);102 ctx.fillStyle = dark ? '#F4B740' : '#E39A1E';103 ctx.fill(); ctx.shadowBlur = 0;104 });105 }, [umapBg, papers, displayed, theme]);106 107 useEffect(() => { drawMap(); }, [drawMap]);108 109 const onSubmit = async () => {110 const q = query.trim() || EXAMPLES[0];111 setQuery(q); setView('run'); setPhaseKeys([]); setPapers([]);112 setAnswerHtml(''); setAnswerRaw(''); setConsensus(null); setBlockedMsg(''); setDone(false);113 114 esRef.current = await runSearch(q, topK, answerStyle, {115 onStage: (d) => { if (d.status === 'done') setPhaseKeys((prev) => [...prev, d.name]); },116 onPapers: (ps) => setPapers(ps),117 onAnswerToken: (t) => setAnswerRaw((prev) => prev + t),118 onConsensus: (c) => setConsensus(c),119 onBlocked: (d) => { setBlockedMsg(d.message); setView('home'); },120 onDone: () => setDone(true),121 onError: (d) => { setBlockedMsg(d.message || 'Something went wrong.'); setDone(true); },122 });123 };124 125 useEffect(() => {126 return () => esRef.current?.close();127 }, []);128 129 const phase = PIPELINE.findIndex((s) => !phaseKeys.includes(s.k));130 const running = view === 'run' && !done && phase !== -1;131 const showResults = view === 'run' && (done || papers.length > 0);132 const shownPapers = displayed();133 134 const yearCounts = {};135 scored().forEach((p) => { yearCounts[p.year] = (yearCounts[p.year] || 0) + 1; });136 137 return (138 <div style={{ display: 'flex', minHeight: '100vh', color: 'var(--text)', background: 'var(--bg)', fontFamily: "'IBM Plex Sans', sans-serif", ...themeVars }}>139 {/* <div style={{ width: 66, flex: '0 0 66px', borderRight: '1px solid var(--line)', display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '18px 0', gap: 20 }}>140 <button onClick={() => setView('home')} title="Pathfinder home" style={{ width: 38, height: 38, background: 'none', border: 'none', cursor: 'pointer' }}>141 <svg viewBox="0 0 24 24" width="26" height="26"><path d="M12 1 L13.7 9.6 L22.4 12 L13.7 14.4 L12 23 L10.3 14.4 L1.6 12 L10.3 9.6 Z" style={{ fill: 'var(--gold)' }} /></svg>142 </button>143 </div> */}144 <div style={{ width: 66, flex: '0 0 66px', borderRight: '1px solid var(--line)', display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '18px 0', gap: 20, position: 'sticky', top: 0, height: '100vh' }}>145 <button onClick={() => setView('home')} title="Pathfinder home" style={{ width: 38, height: 38, background: 'none', border: 'none', cursor: 'pointer' }}>146 <svg viewBox="0 0 24 24" width="26" height="26"><path d="M12 1 L13.7 9.6 L22.4 12 L13.7 14.4 L12 23 L10.3 14.4 L1.6 12 L10.3 9.6 Z" style={{ fill: 'var(--gold)' }} /></svg>147 </button>148 <button onClick={() => { setView('home'); setQuery(''); }} title="New search" style={{ width: 38, height: 38, borderRadius: 11, background: 'var(--railbtn)', border: '1px solid var(--line)', color: 'var(--muted)', cursor: 'pointer' }}>149 <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>150 </button>151 {/* <button title="History" style={{ width: 38, height: 38, borderRadius: 11, background: 'none', border: 'none', color: 'var(--faint)', cursor: 'pointer' }}>152 <svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><circle cx="12" cy="12" r="8.5" /><path d="M12 7.5V12l3 2" /></svg>153 </button>154 <button title="Saved" style={{ width: 38, height: 38, borderRadius: 11, background: 'none', border: 'none', color: 'var(--faint)', cursor: 'pointer' }}>155 <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4h12v16l-6-4-6 4z" /></svg>156 </button> */}157 {/* <div style={{ marginTop: 'auto', color: 'var(--ver)', fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, writingMode: 'vertical-rl', letterSpacing: 1 }}>v2 · astro-ph</div> */}158 </div>159 160 <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>161 {/* <div style={{ height: 60, borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'center', gap: 14, padding: '0 26px' }}>162 <span style={{ fontFamily: "'Space Grotesk', sans-serif", fontWeight: 600, fontSize: 18, color: 'var(--head)' }}>Pathfinder</span>163 <div style={{ marginLeft: 'auto' }}>164 <button onClick={() => setTheme((t) => t === 'light' ? 'dark' : 'light')} style={{ fontSize: 13 }}>165 {theme === 'light' ? 'Dark mode' : 'Light mode'}166 </button>167 </div>168 </div> */}169 <div style={{ height: 60, borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'center', gap: 14, padding: '0 26px' }}>170 <span style={{ fontFamily: "'Space Grotesk', sans-serif", fontWeight: 600, fontSize: 18, color: 'var(--head)' }}>Pathfinder</span>171 <span style={{ color: 'var(--slash)' }}>/</span>172 <span style={{ color: 'var(--muted2)', fontSize: '13.5px' }}>{view === 'home' ? 'Find papers' : query}</span>173 <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 18 }}>174 <a href="https://iopscience.iop.org/article/10.3847/1538-4365/ad7c43" target="_blank" rel="noreferrer" style={{ color: 'var(--muted2)', fontSize: '13.5px' }}>Read the paper</a>175 <a href="https://forms.gle/VET4wckKGLos1vzW9" target="_blank" rel="noreferrer" style={{ color: 'var(--muted2)', fontSize: '13.5px' }}>Leave some feedback</a>176 <div style={{ position: 'relative' }}>177 <button onClick={() => setSettingsOpen((o) => !o)} title="Display settings" style={{ width: 34, height: 34, borderRadius: 9, border: '1px solid var(--line)', background: 'var(--railbtn)', color: 'var(--muted2)', cursor: 'pointer' }}>178 <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 6h11M19 6h1M4 12h4M12 12h8M4 18h9M17 18h3" /><circle cx="16" cy="6" r="2" /><circle cx="9" cy="12" r="2" /><circle cx="14" cy="18" r="2" /></svg>179 </button>180 {settingsOpen && (181 <>182 <div onClick={() => setSettingsOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 40 }} />183 <div style={{ position: 'absolute', top: 44, right: 0, zIndex: 41, width: 240, background: 'var(--popover)', border: '1px solid var(--line)', borderRadius: 12, boxShadow: 'var(--popovershadow)', padding: '14px 16px' }}>184 <div style={{ fontSize: 11, color: 'var(--muted2)', marginBottom: 8 }}>THEME</div>185 <div style={{ display: 'flex', background: 'var(--inset)', border: '1px solid var(--line)', borderRadius: 9, padding: 3, marginBottom: 14 }}>186 {['light', 'dark'].map((t) => (187 <button key={t} onClick={() => setTheme(t)} style={{ flex: 1, border: 'none', background: theme === t ? 'var(--goldsoft)' : 'transparent', color: theme === t ? 'var(--goldtext)' : 'var(--muted)', padding: '6px 11px', borderRadius: 7, fontSize: '12.5px', cursor: 'pointer' }}>188 {t === 'light' ? 'Light' : 'Dark'}189 </button>190 ))}191 </div>192 <div style={{ fontSize: 11, color: 'var(--muted2)', marginBottom: 8 }}>ANSWER STYLE</div>193 {['Concise', 'Multi-paper', 'Deep Research'].map((s) => (194 <button key={s} onClick={() => setAnswerStyle(s)} style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', textAlign: 'left', background: 'none', border: 'none', padding: '6px 0', cursor: 'pointer' }}>195 <span style={{ width: 15, height: 15, borderRadius: '50%', border: `2px solid ${answerStyle === s ? 'var(--gold)' : 'var(--ringoff)'}`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>196 {answerStyle === s && <span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--gold)' }} />}197 </span>198 <span style={{ fontSize: '13.5px', color: answerStyle === s ? 'var(--head)' : 'var(--muted)' }}>{s}</span>199 </button>200 ))}201 </div>202 </>203 )}204 </div>205 {/* <button style={{ border: '1px solid var(--goldbtnborder)', color: 'var(--goldtext)', background: 'transparent', padding: '7px 15px', borderRadius: 9, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Sign in</button> */}206 </div>207 </div>208 209 {view === 'home' && (210 <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '0 26px' }}>211 <div style={{ width: '100%', maxWidth: 780, marginTop: '11vh' }}>212 <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '5px 12px', border: '1px solid var(--line)', background: 'var(--pillbg, transparent)', borderRadius: 999, color: 'var(--muted)', fontSize: 12, fontFamily: "'IBM Plex Mono', monospace", marginBottom: 26 }}>213 <span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--green)' }} />214 {corpusTotal ? `${Math.round(corpusTotal / 1000)}K+` : '...'} arXiv astro-ph papers · semantic + keyword retrieval215 </div>216 <h1 style={{ fontFamily: "'Space Grotesk', sans-serif", fontWeight: 700, fontSize: 48, lineHeight: 1.05, letterSpacing: '-1.6px', color: 'var(--head)' }}>217 A beacon for navigating<br />the cosmic library.218 </h1>219 <p style={{ fontSize: 17, color: 'var(--muted)', maxWidth: 600 }}>220 Ask a research question in plain language. Pathfinder searches a corpus of astronomy & cosmology literature, weighs the results, and answers with the papers that ground it. 221 </p>222 223 {blockedMsg && (224 <div style={{ border: '1px solid var(--goldborder)', background: 'var(--goldsoft)', color: 'var(--goldtext)', borderRadius: 10, padding: '10px 14px', marginBottom: 16, fontSize: 14 }}>225 {blockedMsg}226 </div>227 )}228 229 <div style={{ border: '1px solid var(--line2)', borderRadius: 16, background: 'var(--surface)', overflow: 'hidden' }}>230 <textarea231 rows={2}232 value={query}233 onChange={(e) => setQuery(e.target.value)}234 onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSubmit(); } }}235 placeholder="How does stellar feedback regulate star formation in dwarf galaxies?"236 style={{ width: '100%', border: 'none', outline: 'none', resize: 'none', background: 'transparent', color: 'var(--text)', fontSize: 17, padding: '20px 20px 8px' }}237 />238 <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px 12px', borderTop: '1px solid var(--linesoft)' }}>239 <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, border: '1px solid var(--line)', background: 'var(--inset)', color: 'var(--muted)', padding: '6px 12px', borderRadius: 8, fontSize: '12.5px', fontWeight: 500, fontFamily: "'IBM Plex Mono', monospace" }}>240 <span>papers to retrieve</span>241 <input242 type="range"243 className="pf-slider"244 min={3}245 max={42}246 value={topK}247 onChange={(e) => setTopK(Number(e.target.value))}248 />249 <span style={{ color: 'var(--head)', minWidth: 16, textAlign: 'center' }}>{topK}</span>250</div>251 {/* <span style={{ fontSize: '12.5px', color: 'var(--muted2)' }}>top-k {topK}</span>252 <button onClick={() => setTopK((k) => Math.max(3, k - 1))}>−</button>253 <button onClick={() => setTopK((k) => Math.min(20, k + 1))}>+</button> */}254 {/* <select value={answerStyle} onChange={(e) => setAnswerStyle(e.target.value)} style={{ marginLeft: 8 }}>255 <option>Concise</option>256 <option>Multi-paper</option>257 <option>Deep Research</option>258 </select> */}259 <button onClick={() => setWeights((w) => ({ ...w, recency: !w.recency }))} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: `1px solid ${weights.recency ? 'var(--goldborder)' : 'var(--line)'}`, background: weights.recency ? 'var(--goldsoft)' : 'var(--inset)', color: weights.recency ? 'var(--goldtext)' : 'var(--muted)', padding: '6px 12px', borderRadius: 8, fontSize: '12.5px', fontWeight: 500, cursor: 'pointer' }}>260 <span style={{ width: 6, height: 6, borderRadius: '50%', background: weights.recency ? 'var(--gold)' : 'var(--faint2)' }} />Time261</button>262<button onClick={() => setWeights((w) => ({ ...w, citations: !w.citations }))} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: `1px solid ${weights.citations ? 'var(--goldborder)' : 'var(--line)'}`, background: weights.citations ? 'var(--goldsoft)' : 'var(--inset)', color: weights.citations ? 'var(--goldtext)' : 'var(--muted)', padding: '6px 12px', borderRadius: 8, fontSize: '12.5px', fontWeight: 500, cursor: 'pointer' }}>263 <span style={{ width: 6, height: 6, borderRadius: '50%', background: weights.citations ? 'var(--gold)' : 'var(--faint2)' }} />Citations264</button>265 <button onClick={onSubmit} style={{ marginLeft: 'auto', background: 'var(--gold)', color: 'var(--ongold)', border: 'none', padding: '10px 18px', borderRadius: 10, fontWeight: 600, cursor: 'pointer' }}>266 Run Pathfinder →267 </button>268 </div>269 </div>270 271 <div style={{ display: 'flex', flexWrap: 'wrap', gap: 9, marginTop: 22 }}>272 {/* <div style={{ color: 'var(--faint)', fontSize: 12, fontFamily: "'IBM Plex Mono', monospace", marginBottom: 11, letterSpacing: '0.4px' }}>TRY AN EXAMPLE:</div> */}273 {EXAMPLES.map((q) => (274 <button key={q} onClick={() => { setQuery(q); }} style={{ textAlign: 'left', border: '1px solid var(--line)', background: 'var(--example)', color: 'var(--abstract)', padding: '9px 13px', borderRadius: 10, fontSize: 13, cursor: 'pointer' }}>275 {q}276 </button>277 ))}278 </div>279 </div>280 </div>281 )}282 283 {view === 'run' && running && (284 <div style={{ maxWidth: 720, margin: '0 auto', padding: '48px 26px' }}>285 <div style={{ fontSize: 18, color: 'var(--head)', marginBottom: 26 }}>{query}</div>286 <div style={{ border: '1px solid var(--line)', borderRadius: 16, background: 'var(--card)', padding: '8px 6px' }}>287 {PIPELINE.map((s, i) => (288 <div key={s.k} style={{ display: 'flex', alignItems: 'center', gap: 13, padding: '12px 16px' }}>289 <span style={{ width: 20, textAlign: 'center' }}>290 {phaseKeys.includes(s.k) ? '✓' : i === phase ? '…' : '○'}291 </span>292 <span style={{ fontSize: '14.5px', color: i > phase ? 'var(--faint)' : 'var(--text)' }}>{s.label}</span>293 </div>294 ))}295 </div>296 </div>297 )}298 299 {showResults && (300 <div style={{ display: 'flex', gap: 28, maxWidth: 1320, margin: '0 auto', padding: '26px 26px 80px' }}>301 <div style={{ flex: 1, minWidth: 0 }}>302 <div style={{ fontFamily: "'Space Grotesk', sans-serif", fontSize: 21, color: 'var(--head)', marginBottom: 18 }}>{query}</div>303 304 <button onClick={() => setReasoningOpen((o) => !o)} style={{ marginBottom: 22, fontSize: '12.5px' }}>305 Retrieved {shownPapers.length} papers · {PIPELINE.length} steps {reasoningOpen ? '▲' : '▼'}306 </button>307 308 <div style={{ border: '1px solid var(--answercardborder)', borderRadius: 16, background: 'var(--answercardbg)', padding: '24px 26px', marginBottom: 16 }}>309 <div style={{ fontFamily: "'Space Grotesk', sans-serif", fontWeight: 600, fontSize: 15, color: 'var(--head)', marginBottom: 14 }}>310 Synthesized answer <span style={{ fontSize: 11, color: 'var(--muted2)', border: '1px solid var(--line2)', borderRadius: 999, padding: '2px 8px', marginLeft: 6 }}>{answerStyle}</span>311 </div>312 <div313 style={{ fontSize: '15.5px', lineHeight: 1.72, color: 'var(--answer)' }}314 dangerouslySetInnerHTML={{ __html: linkifyAnswer(answerRaw, papers) }}315 />316 </div>317 318 {consensus && (319 <div style={{ border: '1px solid var(--line)', borderRadius: 14, background: 'var(--card)', padding: '18px 20px', marginBottom: 14 }}>320 <div style={{ display: 'flex', gap: 10, marginBottom: 8 }}>321 <span style={{ fontSize: 12, color: 'var(--muted2)' }}>LITERATURE CONSENSUS</span>322 <span style={{ fontSize: '12.5px', fontWeight: 600, color: 'var(--green)', background: 'var(--greensoft)', padding: '3px 10px', borderRadius: 999 }}>{consensus.label}</span>323 </div>324 <div style={{ fontSize: '13.5px', color: 'var(--muted)' }}>{consensus.explanation}</div>325 </div>326 )}327 328 <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, margin: '26px 0 14px' }}>329 <h2 style={{ fontFamily: "'Space Grotesk', sans-serif", fontSize: 16, color: 'var(--head)' }}>Retrieved papers</h2>330 <span style={{ fontSize: '12.5px', color: 'var(--muted2)' }}>showing {shownPapers.length}</span>331 </div>332 333 {shownPapers.map((p) => {334 const exp = !!expanded[p.bibcode];335 const highly = p.rel > 0.94;336 return (337 <div key={p.bibcode} style={{ border: '1px solid var(--line)', borderRadius: 14, background: 'var(--card)', padding: '18px 20px', marginBottom: 12 }}>338 <div style={{ display: 'flex', gap: 14 }}>339 <div style={{ fontSize: 12, color: 'var(--faint2)', minWidth: 20 }}>{String(p.rank).padStart(2, '0')}</div>340 <div style={{ flex: 1, minWidth: 0 }}>341 <div style={{ display: 'flex', gap: 10, marginBottom: 5 }}>342 <span style={{ fontSize: '11.5px', color: 'var(--goldtext)', background: 'var(--goldchip)', padding: '2px 8px', borderRadius: 6 }}>{p.short}</span>343 <span style={{ fontSize: '11.5px', fontWeight: 600, color: highly ? 'var(--green)' : 'var(--cyan)', background: highly ? 'var(--greensoft)' : 'var(--relevantbg)', padding: '2px 9px', borderRadius: 999 }}>344 {highly ? 'Highly relevant' : p.rel > 0.85 ? 'Relevant' : 'Related'}345 </span>346 </div>347 <a href={`https://arxiv.org/abs/${p.arxiv}`} target="_blank" rel="noreferrer" style={{ fontFamily: "'Space Grotesk', sans-serif", fontSize: '16.5px', color: 'var(--head)', display: 'block', marginBottom: 6 }}>348 {p.title}349 </a>350 <div style={{ fontSize: 13, color: 'var(--muted)' }}>{fmtAuthors(p.authors)}</div>351 <div style={{ fontSize: '12.5px', color: 'var(--faint)', marginBottom: 12 }}>352 {p.year} · {p.cites.toLocaleString()} citations353 </div>354 {exp && (355 <div style={{ fontSize: '13.5px', lineHeight: 1.62, color: 'var(--abstract)', borderLeft: '2px solid var(--goldborder)', paddingLeft: 14, marginBottom: 14 }}>356 {p.abstract}357 </div>358 )}359 <div style={{ display: 'flex', gap: 18 }}>360 <button onClick={() => setExpanded((e) => ({ ...e, [p.bibcode]: !exp }))} style={{ fontSize: 12 }}>361 {exp ? 'Hide abstract' : 'Show abstract'}362 </button>363 <a href={`https://ui.adsabs.harvard.edu/abs/${p.bibcode}`} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: 'var(--cyan)' }}>ADS ↗</a>364 <a href={`https://arxiv.org/abs/${p.arxiv}`} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: 'var(--cyan)' }}>arXiv ↗</a>365 </div>366 </div>367 </div>368 </div>369 );370 })}371 </div>372 373 <div style={{ width: 284, flexShrink: 0 }}>374 <div style={{ border: '1px solid var(--line)', borderRadius: 14, background: 'var(--card2)', padding: 14, marginBottom: 16 }}>375 <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--head)', marginBottom: 10 }}>Embedding map</div>376 <canvas ref={mapCanvasRef} width={512} height={440} style={{ width: '100%', height: 'auto', borderRadius: 8, background: 'var(--mapbg)' }} />377 </div>378 379 <div style={{ border: '1px solid var(--line)', borderRadius: 14, background: 'var(--card2)', padding: '15px 16px', marginBottom: 16 }}>380 <div style={{ fontSize: 11, color: 'var(--muted2)', marginBottom: 11 }}>SORT BY</div>381 {['relevance', 'year', 'cites'].map((k) => (382 <label key={k} style={{ display: 'flex', gap: 10, padding: '6px 0', cursor: 'pointer', fontSize: '13.5px', color: sort === k ? 'var(--head)' : 'var(--muted)' }}>383 <input type="radio" checked={sort === k} onChange={() => setSort(k)} /> {k}384 </label>385 ))}386 </div>387 388 <div style={{ border: '1px solid var(--line)', borderRadius: 14, background: 'var(--card2)', padding: '15px 16px', marginBottom: 16 }}>389 <div style={{ fontSize: 11, color: 'var(--muted2)', marginBottom: 6 }}>WEIGHT RANKING BY</div>390 {[['keywords', 'Keywords'], ['recency', 'Recency'], ['citations', 'Citations']].map(([k, label]) => (391 <label key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '7px 0', cursor: 'pointer', fontSize: '13.5px' }}>392 {label}393 <input type="checkbox" checked={weights[k]} onChange={() => setWeights((w) => ({ ...w, [k]: !w[k] }))} />394 </label>395 ))}396 </div>397 398 <div style={{ border: '1px solid var(--line)', borderRadius: 14, background: 'var(--card2)', padding: '15px 16px' }}>399 <div style={{ fontSize: 11, color: 'var(--muted2)', marginBottom: 11 }}>YEAR</div>400 {Object.keys(yearCounts).sort((a, b) => b - a).map((y) => (401 <label key={y} style={{ display: 'flex', gap: 10, padding: '5px 0', cursor: 'pointer', fontSize: '13.5px' }}>402 <input type="checkbox" checked={!!years[y]} onChange={() => setYears((yr) => ({ ...yr, [y]: !yr[y] }))} />403 {y} <span style={{ marginLeft: 'auto', color: 'var(--faint2)' }}>{yearCounts[y]}</span>404 </label>405 ))}406 </div>407 </div>408 </div>409 )}410 </div>411 </div>412 );413}