| |
|
|
| const MODES = window.ASTROPARSE_MODES; |
| const modeById = Object.fromEntries(MODES.map((m) => [m.id, m])); |
|
|
| |
|
|
| function ScoreBar({ label, value, max }) { |
| const pct = Math.max(0, Math.min(1, value / max)); |
| return ( |
| <div className="ap-score"> |
| <span className="ap-score-label">{label}</span> |
| <span className="ap-score-track"><span className="ap-score-fill" style={{ width: (pct * 100).toFixed(0) + '%' }}></span></span> |
| <span className="ap-score-val">{value}</span> |
| </div> |
| ); |
| } |
|
|
| function CitePopover({ lit, pos, showScores, onClose }) { |
| const ref = React.useRef(null); |
| React.useLayoutEffect(() => { |
| const el = ref.current; if (!el) return; |
| const r = el.getBoundingClientRect(); |
| let x = pos.x, y = pos.y + 10; |
| if (x + r.width > window.innerWidth - 16) x = window.innerWidth - r.width - 16; |
| if (y + r.height > window.innerHeight - 16) y = pos.y - r.height - 10; |
| el.style.left = Math.max(8, x) + 'px'; |
| el.style.top = Math.max(8, y) + 'px'; |
| el.style.opacity = 1; |
| }, [lit, pos]); |
| React.useEffect(() => { |
| const onKey = (e) => { if (e.key === 'Escape') onClose(); }; |
| window.addEventListener('keydown', onKey); |
| return () => window.removeEventListener('keydown', onKey); |
| }, [onClose]); |
| return ( |
| <div> |
| <div className="ap-pop-backdrop" onClick={onClose}></div> |
| <div className="ap-pop" ref={ref} style={{ opacity: 0 }}> |
| <div className="ap-pop-rank">retrieved · rank {lit.scores.rank}</div> |
| <div className="ap-pop-title">{lit.title}</div> |
| <div className="ap-pop-authors">{lit.authors} ({lit.year}) · {lit.journal}</div> |
| <div className="ap-pop-abstract">{lit.abstract}</div> |
| {showScores && ( |
| <div className="ap-pop-scores"> |
| <ScoreBar label="specter" value={lit.scores.specter} max={1} /> |
| <ScoreBar label="bm25" value={lit.scores.bm25} max={20} /> |
| </div> |
| )} |
| <div className="ap-pop-links"> |
| <a href={'https://ui.adsabs.harvard.edu/abs/' + lit.bibcode + '/abstract'} target="_blank" rel="noopener">ADS ↗</a> |
| <a href={'https://arxiv.org/abs/' + lit.arxiv} target="_blank" rel="noopener">arXiv ↗</a> |
| </div> |
| <button className="ap-pop-close" onClick={onClose} title="close">×</button> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
|
|
| function MarginNote({ para, mode, entry, generating, hovered, onHover, onSetMode, onRegenerate, onCite, top, noteRef }) { |
| const m = modeById[mode]; |
| const lit = (window.ASTROPARSE_DATA.litByPara[para.id] || []).map((id) => window.ASTROPARSE_DATA.litPapers[id]).filter(Boolean); |
| return ( |
| <aside |
| ref={noteRef} |
| className={'ap-note' + (hovered ? ' is-hovered' : '')} |
| style={{ top: top, '--mode-c': 'var(--mc-' + mode + ')' }} |
| onMouseEnter={() => onHover(para.id)} |
| onMouseLeave={() => onHover(null)} |
| data-comment-anchor={'note-' + para.id} |
| > |
| <div className="ap-note-head"> |
| <span className="ap-note-mode">{m.label}</span> |
| {entry && entry.source === 'llm' && !generating && <span className="ap-note-gen" title="generated by the annotator">✦</span>} |
| {entry && !generating && ( |
| <button className="ap-note-regen" title="regenerate this note" onClick={() => onRegenerate(para.id, mode)}>↻</button> |
| )} |
| </div> |
| {generating ? ( |
| <div className="ap-note-loading">consulting the literature<span className="ap-ellipsis"></span></div> |
| ) : entry && entry.error ? ( |
| <div className="ap-note-error"> |
| {entry.message ? entry.message + ' \u2014 ' : 'the annotator did not answer. '} |
| <button className="ap-textbtn" onClick={() => onRegenerate(para.id, mode)}>try again</button> |
| </div> |
| ) : entry ? ( |
| <div className="ap-note-text">{entry.text}</div> |
| ) : ( |
| <div className="ap-note-loading">awaiting annotation…</div> |
| )} |
| {lit.length > 0 && ( |
| <div className="ap-note-cites"> |
| {lit.map((l, i) => ( |
| <button key={l.id} className="ap-cite" onClick={(e) => onCite(l, e)}> |
| <span className="ap-cite-n">{i + 1}</span> {l.short} |
| </button> |
| ))} |
| </div> |
| )} |
| <div className="ap-note-modes" role="group" aria-label="commentary mode"> |
| {MODES.map((mm) => ( |
| <button |
| key={mm.id} |
| className={'ap-mode-dot' + (mm.id === mode ? ' is-active' : '')} |
| style={{ '--mode-c': 'var(--mc-' + mm.id + ')' }} |
| title={mm.label} |
| onClick={() => onSetMode(para.id, mm.id)} |
| >{mm.glyph}</button> |
| ))} |
| </div> |
| </aside> |
| ); |
| } |
|
|
| |
|
|
| function Reader({ paper, commentary, noteModes, generating, onSetMode, onRegenerate, tweaks, toolbar }) { |
| const containerRef = React.useRef(null); |
| const marginRef = React.useRef(null); |
| const paraRefs = React.useRef({}); |
| const noteRefs = React.useRef({}); |
| const [tops, setTops] = React.useState({}); |
| const [spacers, setSpacers] = React.useState({}); |
| const spacersRef = React.useRef({}); |
| const [marginH, setMarginH] = React.useState(0); |
| const [hoverId, setHoverId] = React.useState(null); |
| const [pop, setPop] = React.useState(null); |
|
|
| const paras = paper.paragraphs; |
|
|
| const recompute = React.useCallback(() => { |
| const c = containerRef.current; if (!c) return; |
| const cTop = c.getBoundingClientRect().top; |
| const narrow = window.innerWidth <= 900; |
| const applied = spacersRef.current; |
| const NOTE_GAP = 26; |
|
|
| |
| const naturalTop = {}; |
| let cum = 0; |
| paras.forEach((p) => { |
| const pe = paraRefs.current[p.id]; if (!pe) return; |
| naturalTop[p.id] = pe.getBoundingClientRect().top - cTop - cum; |
| cum += applied[p.id] || 0; |
| }); |
|
|
| |
| |
| const nextTops = {}; const nextSpacers = {}; |
| let cum2 = 0; let lastBottom = 0; |
| paras.forEach((p, i) => { |
| const ne = noteRefs.current[p.id]; |
| if (naturalTop[p.id] == null || !ne) return; |
| const top = naturalTop[p.id] + cum2; |
| nextTops[p.id] = top; |
| lastBottom = top + ne.offsetHeight + NOTE_GAP; |
| const nxt = paras[i + 1]; |
| if (!narrow && nxt && naturalTop[nxt.id] != null) { |
| const delta = Math.max(0, Math.ceil(lastBottom - (naturalTop[nxt.id] + cum2))); |
| if (delta > 0) nextSpacers[p.id] = delta; |
| cum2 += delta; |
| } |
| }); |
|
|
| setTops((old) => { |
| const same = paras.every((p) => Math.abs((old[p.id] || 0) - (nextTops[p.id] || 0)) < 1); |
| return same ? old : nextTops; |
| }); |
| const sameSp = paras.every((p) => Math.abs((applied[p.id] || 0) - (nextSpacers[p.id] || 0)) < 1); |
| if (!sameSp) { spacersRef.current = nextSpacers; setSpacers(nextSpacers); } |
| setMarginH(lastBottom); |
| }, [paras]); |
|
|
| React.useLayoutEffect(() => { recompute(); }); |
| React.useEffect(() => { |
| if (document.fonts && document.fonts.ready) document.fonts.ready.then(recompute); |
| window.addEventListener('resize', recompute); |
| return () => window.removeEventListener('resize', recompute); |
| }, [recompute]); |
|
|
| const showCite = (lit, e) => { |
| const r = e.currentTarget.getBoundingClientRect(); |
| setPop({ lit, pos: { x: r.left, y: r.bottom } }); |
| }; |
|
|
| |
| const rows = []; |
| let lastSection = null; |
| paras.forEach((p) => { |
| if (p.section !== lastSection) { rows.push({ type: 'heading', section: p.section, key: 'h-' + p.id }); lastSection = p.section; } |
| rows.push({ type: 'para', para: p, key: p.id }); |
| }); |
|
|
| return ( |
| <div className="ap-screen ap-reader" data-screen-label="Reader"> |
| {toolbar} |
| <div className={'ap-folio' + (tweaks.dropCaps ? ' has-dropcaps' : '') + (tweaks.justify ? ' is-justified' : '')}> |
| <header className="ap-paper-head"> |
| <div className="ap-paper-title">{paper.title}</div> |
| <div className="ap-paper-authors">{paper.authors}</div> |
| <div className="ap-paper-meta">arXiv:{paper.arxivId} · {paper.pages} pp · {paper.venue}</div> |
| </header> |
| <div className="ap-legend"> |
| {MODES.map((m) => ( |
| <button |
| key={m.id} className="ap-legend-item" style={{ '--mode-c': 'var(--mc-' + m.id + ')' }} |
| title={'set every note to ' + m.label} |
| onClick={() => paras.forEach((p) => onSetMode(p.id, m.id))} |
| > |
| <span className="ap-legend-dot"></span>{m.label} |
| </button> |
| ))} |
| </div> |
| <div className={'ap-body' + (tweaks.notesSide === 'left' ? ' notes-left' : '')} ref={containerRef}> |
| <article className="ap-text"> |
| {rows.map((row) => |
| row.type === 'heading' ? ( |
| <h2 className="ap-section" key={row.key}>{row.section}</h2> |
| ) : ( |
| <p |
| key={row.key} |
| ref={(el) => (paraRefs.current[row.para.id] = el)} |
| className={'ap-para' + (row.para.firstOfSection ? ' is-first' : '') + (hoverId === row.para.id ? ' is-hovered' : '')} |
| style={{ |
| '--mode-c': 'var(--mc-' + (noteModes[row.para.id] || 'literature') + ')', |
| marginBottom: spacers[row.para.id] ? 'calc(1.15em + ' + spacers[row.para.id] + 'px)' : undefined, |
| }} |
| onMouseEnter={() => setHoverId(row.para.id)} |
| onMouseLeave={() => setHoverId(null)} |
| data-comment-anchor={'para-' + row.para.id} |
| > |
| <span className="ap-pilcrow">¶</span>{row.para.text} |
| </p> |
| ) |
| )} |
| </article> |
| <div className="ap-margin" ref={marginRef} style={{ minHeight: marginH }}> |
| {paras.map((p) => { |
| const mode = noteModes[p.id] || 'literature'; |
| const key = p.id + ':' + mode; |
| return ( |
| <MarginNote |
| key={p.id} |
| para={p} |
| mode={mode} |
| entry={commentary[key]} |
| generating={generating[key]} |
| hovered={hoverId === p.id} |
| onHover={setHoverId} |
| onSetMode={onSetMode} |
| onRegenerate={onRegenerate} |
| onCite={showCite} |
| top={tops[p.id] || 0} |
| noteRef={(el) => (noteRefs.current[p.id] = el)} |
| /> |
| ); |
| })} |
| </div> |
| </div> |
| <footer className="ap-folio-foot"> |
| annotations are machine-generated against retrieved literature — verify before citing |
| </footer> |
| </div> |
| {pop && <CitePopover lit={pop.lit} pos={pop.pos} showScores={tweaks.showScores} onClose={() => setPop(null)} />} |
| </div> |
| ); |
| } |
|
|
| Object.assign(window, { Reader }); |
|
|