CoolFace
Apppublic

kiyer/pathfinder

sourceHugging Facemitupdated 3mo agoView on Hugging Face
36likes
App.jsx413 linesDownload Raw Back to src
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}