beacon / prototype /reader.jsx
kiyer's picture
chore: move prototype to prototype/, scaffold backend with uv
ccf55cb
Raw
History Blame Contribute Delete
11.3 kB
// astroparse — annotated reading view: main text + marginalia
const MODES = window.ASTROPARSE_MODES;
const modeById = Object.fromEntries(MODES.map((m) => [m.id, m]));
/* ---------- citation popover ---------- */
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 &middot; rank {lit.scores.rank}</div>
<div className="ap-pop-title">{lit.title}</div>
<div className="ap-pop-authors">{lit.authors} ({lit.year}) &middot; {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 &#8599;</a>
<a href={'https://arxiv.org/abs/' + lit.arxiv} target="_blank" rel="noopener">arXiv &#8599;</a>
</div>
<button className="ap-pop-close" onClick={onClose} title="close">&times;</button>
</div>
</div>
);
}
/* ---------- margin note ---------- */
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">&#10022;</span>}
{entry && !generating && (
<button className="ap-note-regen" title="regenerate this note" onClick={() => onRegenerate(para.id, mode)}>&#8635;</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&hellip;</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>
);
}
/* ---------- reader ---------- */
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); // {lit, pos}
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;
// reconstruct each paragraph's natural top (as if no spacers were applied)
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;
});
// pin every note to its paragraph's top; pad the text column below any
// paragraph shorter than its note so the next pair stays level too
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 } });
};
// group paragraphs by section for headings
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} &middot; {paper.pages} pp &middot; {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">&para;</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 &mdash; verify before citing
</footer>
</div>
{pop && <CitePopover lit={pop.lit} pos={pop.pos} showScores={tweaks.showScores} onClose={() => setPop(null)} />}
</div>
);
}
Object.assign(window, { Reader });