// 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 (
{label}
{value}
);
}
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 (
retrieved · rank {lit.scores.rank}
{lit.title}
{lit.authors} ({lit.year}) · {lit.journal}
{lit.abstract}
{showScores && (
)}
);
}
/* ---------- 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 (
);
}
/* ---------- 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 (
{toolbar}
{MODES.map((m) => (
))}
{rows.map((row) =>
row.type === 'heading' ? (
{row.section}
) : (
(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}
>
¶{row.para.text}
)
)}
{paras.map((p) => {
const mode = noteModes[p.id] || 'literature';
const key = p.id + ':' + mode;
return (
(noteRefs.current[p.id] = el)}
/>
);
})}
{pop &&
setPop(null)} />}
);
}
Object.assign(window, { Reader });