annotator / app /static /index.html
roz322's picture
v7 (1103): schema-driven blocks, v7 display rules, ANNOT_DIR
7901436 verified
Raw History Blame Contribute Delete
73.5 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Reproducibility Annotator</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root{
--bg:#f6f7f9; --panel:#ffffff; --ink:#1d2430; --muted:#6b7686; --line:#e3e7ee;
--accent:#d97706; --accent-soft:#fff3e0; --blue:#2563eb; --green:#15803d; --red:#b91c1c;
--na:#eef0f4;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{font:14px/1.55 -apple-system,"PingFang SC","Helvetica Neue",sans-serif;color:var(--ink);background:var(--bg)}
button{font:inherit;cursor:pointer;border:1px solid var(--line);background:#fff;border-radius:6px;padding:4px 10px}
button:hover{border-color:#b9c2d0}
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
select,input[type=text],textarea{font:inherit;border:1px solid var(--line);border-radius:6px;padding:4px 8px;background:#fff;color:var(--ink);width:100%}
textarea{resize:vertical;min-height:34px}
.muted{color:var(--muted)}
/* ---------- login ---------- */
#login{position:fixed;inset:0;background:var(--bg);display:flex;align-items:center;justify-content:center;z-index:50}
#login .box{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:28px 32px;width:420px;box-shadow:0 8px 30px rgba(0,0,0,.06)}
#login h1{font-size:18px;margin:0 0 4px}
#login .sub{font-size:12px;color:var(--muted);margin-bottom:18px}
#login label{display:block;font-size:12px;color:var(--muted);margin:12px 0 4px}
#login input[type=password]{font:inherit;border:1px solid var(--line);border-radius:6px;padding:4px 8px;width:100%}
.login-err{color:var(--red);font-size:12px;min-height:16px;margin-top:8px}
/* ---------- home ---------- */
:root{--c-l1:#475569;--c-dataset:#2563eb;--c-split:#0891b2;--c-asset:#0d9488;--c-model:#7c3aed;--c-result:#d97706;--c-claim:#e11d48;--c-scope:#475569;--c-code:#0d9488;--c-summary:#e11d48;--c-notes:#64748b}
#home{display:none;min-height:100vh}
.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:12px;padding:10px 24px;background:rgba(255,255,255,.92);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
.brand{font-weight:700;font-size:15px;letter-spacing:-.01em}
.brand small{font-weight:400;color:var(--muted);margin-left:6px}
.topbar .spacer{flex:1}
.topbar .chip{font-size:12px;background:var(--na);border-radius:99px;padding:3px 10px}
.topbar select{width:auto;font-size:12px;padding:3px 8px}
.home-wrap{max-width:1320px;margin:0 auto;padding:24px}
.home-head{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;margin-bottom:6px}
.home-head h2{margin:0;font-size:22px;letter-spacing:-.02em}
.home-stats{color:var(--muted);font-size:13px}
.blind-note{font-size:12px;color:var(--muted);margin:4px 0 18px}
.filters{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:18px}
.filters input{max-width:320px}
.pill{font-size:12px;border:1px solid var(--line);background:#fff;border-radius:99px;padding:4px 12px;cursor:pointer;color:var(--muted);white-space:nowrap}
.pill.on{background:var(--ink);border-color:var(--ink);color:#fff}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}
.pcard{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px 16px 14px;cursor:pointer;display:flex;flex-direction:column;gap:8px;transition:box-shadow .15s,transform .15s,border-color .15s;min-height:230px}
.pcard:hover{box-shadow:0 10px 28px rgba(15,23,42,.08);transform:translateY(-2px);border-color:#cfd6e2}
.pcard .meta{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted)}
.venue{font-size:11px;font-weight:600;border-radius:6px;padding:2px 8px;background:var(--na);color:var(--ink)}
.pcard h3{margin:0;font-size:14.5px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.pcard .abs{font-size:12.5px;color:var(--muted);line-height:1.5;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;flex:1}
.pcard .foot{display:flex;align-items:center;gap:10px;border-top:1px dashed var(--line);padding-top:10px}
.blocks{display:flex;gap:3px;flex:1}
.blk{flex:1;height:22px;border-radius:5px;background:var(--na);font-size:10px;font-weight:600;color:#9aa3b2;display:flex;align-items:center;justify-content:center}
.blk.on{color:#fff}
.status{font-size:11px;border-radius:99px;padding:2px 9px;white-space:nowrap}
.status.todo{background:var(--na);color:var(--muted)}
.status.doing{background:var(--accent-soft);color:var(--accent)}
.empty{color:var(--muted);text-align:center;padding:60px 0}
/* ---------- 1003: admin upload + pool ---------- */
.pool-pill{font-size:11px;border-radius:99px;padding:2px 9px;border:1px solid var(--line);background:#fff;color:var(--muted);cursor:pointer;white-space:nowrap}
.pool-pill.on{background:#e7f6ec;border-color:#bfe3cb;color:var(--green)}
.src-tag{font-size:10.5px;border-radius:4px;padding:1px 6px;background:var(--na);color:var(--muted)}
#upload{display:none;position:fixed;inset:0;z-index:70;background:rgba(15,23,42,.45);align-items:center;justify-content:center;padding:24px}
#upload .box{background:var(--panel);border-radius:14px;width:min(640px,100%);max-height:100%;overflow:auto;padding:22px 24px;box-shadow:0 30px 80px rgba(0,0,0,.25)}
#upload h3{margin:0 0 4px;font-size:17px}
#upload .sub{font-size:12.5px;color:var(--muted);margin-bottom:14px;line-height:1.5}
#upload label{display:block;font-size:12px;color:var(--muted);margin:10px 0 4px}
#upload .row{display:flex;gap:10px}
#upload .row>div{flex:1}
#upload .acts{display:flex;align-items:center;gap:10px;margin-top:16px}
#up-err{color:var(--red);font-size:12px}
.job{border:1px solid var(--line);border-radius:10px;padding:8px 10px;margin-top:10px;font-size:12.5px}
.job .st{font-weight:600}
.job .st.done{color:var(--green)} .job .st.error{color:var(--red)} .job .st.running,.job .st.queued{color:var(--accent)}
.job pre{margin:6px 0 0;font-size:11.5px;white-space:pre-wrap;color:var(--muted);font-family:ui-monospace,monospace;max-height:140px;overflow:auto}
/* ---------- detail modal ---------- */
#detail{display:none;position:fixed;inset:0;z-index:60;background:rgba(15,23,42,.45);align-items:center;justify-content:center;padding:24px}
#detail .modal{background:var(--bg);border-radius:16px;width:min(1400px,100%);height:min(920px,100%);display:flex;flex-direction:column;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.25)}
.m-head{display:flex;align-items:flex-start;gap:14px;padding:16px 20px;background:var(--panel);border-bottom:1px solid var(--line)}
.m-head h2{margin:4px 0 0;font-size:17px;line-height:1.35}
.m-head .x{margin-left:auto;font-size:18px;line-height:1;padding:4px 10px}
.m-body{flex:1;display:flex;min-height:0}
.m-pdf{flex:1.1;min-width:0;border-right:1px solid var(--line);display:flex;flex-direction:column;background:#525659}
.m-pdf iframe{flex:1;border:0;width:100%}
.m-pdf .nopdf{color:#fff;margin:auto;opacity:.8}
.m-side{flex:1;min-width:0;display:flex;flex-direction:column}
.m-cards{flex:1;overflow:auto;padding:16px 18px;display:grid;grid-template-columns:1fr 1fr;gap:12px;align-content:start}
.bcard{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;border-top:3px solid var(--bc,var(--line))}
.bcard.wide{grid-column:1/-1}
.bcard h4{margin:0 0 2px;font-size:13px;display:flex;align-items:center;gap:6px}
.bcard .sub{font-size:11.5px;color:var(--muted);margin-bottom:8px}
.bcard ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
.bcard li{font-size:12.5px;line-height:1.45;background:#fafbfd;border:1px solid #eef1f6;border-radius:8px;padding:6px 8px}
.bcard li .k{color:var(--muted);font-size:11px;margin-right:4px}
.bcard li .tag{font-size:10.5px;border-radius:4px;padding:0 5px;background:var(--na);color:var(--muted);margin-left:4px;white-space:nowrap;display:inline-block}
.bcard .none{font-size:12px;color:#a0a8b6;font-style:italic}
.bar{height:4px;background:var(--na);border-radius:99px;overflow:hidden;margin:6px 0 8px}
.bar i{display:block;height:100%;background:var(--bc)}
.m-foot{display:flex;align-items:center;gap:10px;padding:12px 18px;background:var(--panel);border-top:1px solid var(--line)}
.m-foot .spacer{flex:1}
.m-foot button.primary{padding:7px 18px;font-weight:600;white-space:nowrap}
.m-foot a{white-space:nowrap}
@media (max-width:900px){.m-body{flex-direction:column}.m-pdf{flex:none;height:45%}.m-cards{grid-template-columns:1fr}}
header .chip.sync{cursor:pointer}
header .chip.sync.ok{background:#e7f6ec;color:var(--green)}
header .chip.sync.pending{background:var(--accent-soft);color:var(--accent)}
header .chip.sync.err{background:#fde8e8;color:var(--red)}
/* ---------- layout ---------- */
#app{display:none;height:100vh;flex-direction:column}
header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line)}
header .title{font-weight:600}
header .chip{font-size:12px;background:var(--na);border-radius:99px;padding:2px 10px}
header .spacer{flex:1}
#main{flex:1;display:flex;min-height:0}
#left,#right{min-width:0;display:flex;flex-direction:column}
#left{width:46%;border-right:1px solid var(--line)}
#right{flex:1}
.tabs{display:flex;gap:2px;padding:6px 10px 0;background:var(--panel);border-bottom:1px solid var(--line);flex-wrap:wrap}
.tab{padding:5px 12px;border:1px solid transparent;border-bottom:none;border-radius:8px 8px 0 0;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap}
.tab.on{background:var(--bg);border-color:var(--line);color:var(--ink);font-weight:600}
.tab .cnt{font-size:11px;color:var(--muted)}
.pane{flex:1;overflow:auto;min-height:0}
/* ---------- left ---------- */
#textpane{padding:16px 20px;white-space:pre-wrap;font-size:13.5px;line-height:1.75;user-select:text}
#textpane mark{background:#ffe9a8;border-radius:3px}
#textpane table{border-collapse:collapse;margin:10px 0;font-size:12.5px;display:block;overflow-x:auto;max-width:100%}
#textpane th,#textpane td{border:1px solid var(--line);padding:3px 8px;text-align:left;white-space:nowrap}
#textpane th{background:#fbfcfe;font-weight:600}
#textpane h2,#textpane h3,#textpane h4,#textpane h5,#textpane h6{margin:14px 0 6px;line-height:1.3}
#textpane h2{font-size:16px} #textpane h3{font-size:14.5px} #textpane h4,#textpane h5,#textpane h6{font-size:13.5px}
#textpane p{margin:6px 0}
#textpane .flash{background:#ffe9a8;transition:background .3s}
/* 1003: PDF ↔ text side by side */
#splitter{flex:none;width:6px;cursor:col-resize;background:var(--line)}
#splitter:hover,#splitter.drag{background:var(--accent)}
.lpane{flex:1;display:flex;min-height:0}
.lpane>div{flex:1;min-width:0;overflow:auto}
#pdfview{background:#525659;padding:12px;position:relative;border-right:1px solid var(--line)}
#pdfview .nopdf{color:#fff;opacity:.8;text-align:center;margin-top:40px}
#pdfview .pg{position:relative;margin:0 auto 12px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.35)}
#pdfview .pg canvas{position:absolute;left:0;top:0}
.textLayer{position:absolute;inset:0;overflow:hidden;line-height:1;text-size-adjust:none;forced-color-adjust:none;transform-origin:0 0;z-index:2}
.textLayer span,.textLayer br{color:transparent;position:absolute;white-space:pre;cursor:text;transform-origin:0% 0%}
.textLayer ::selection{background:rgba(37,99,235,.3)}
.textLayer .endOfContent{display:block;position:absolute;inset:100% 0 0;z-index:-1;cursor:default;user-select:none}
.hl-layer{position:absolute;inset:0;pointer-events:none;z-index:3}
.hl-layer i{position:absolute;border-radius:3px}
.hl-layer i.blk{background:rgba(217,119,6,.12);outline:2px solid rgba(217,119,6,.75)}
.hl-layer i.ev{background:rgba(250,204,21,.35);outline:2px solid #eab308}
#textpane .tb{border-left:3px solid transparent;padding-left:8px;margin-left:-11px;border-radius:2px}
#textpane.linked .tb:hover{border-left-color:#d6dbe4}
#textpane .tb.cur{border-left-color:var(--accent);background:#fffaf0}
#textpane .tb.k-figure,#textpane .tb.k-formula{color:var(--muted);font-style:italic}
#textpane .tb.k-caption{font-size:12.5px;font-weight:600;color:#374151}
#textpane .tb.k-footnote,#textpane .tb.k-reference{font-size:12px;color:var(--muted)}
.src-chip{margin-left:auto;align-self:center;font-size:11px;border-radius:99px;padding:2px 9px;margin-bottom:4px;white-space:nowrap}
.src-chip.good{background:#e7f6ec;color:var(--green)}
.src-chip.ok{background:#eff6ff;color:#1d4ed8}
.src-chip.weak{background:#fde8e8;color:var(--red)}
.ev .pdf-tag{font-size:10px;font-weight:600;border-radius:4px;padding:0 4px;background:#eff6ff;color:#1d4ed8;cursor:pointer}
.ev .g-pdf{color:var(--accent);font-weight:600}
#seltool{position:fixed;display:none;z-index:40;background:var(--ink);color:#fff;border-radius:8px;padding:6px 10px;font-size:12px;box-shadow:0 4px 14px rgba(0,0,0,.25);cursor:pointer}
/* ---------- right ---------- */
.rt-help{font-size:12px;color:var(--muted);padding:8px 14px 0}
.recwrap{padding:10px 14px 80px}
.record{background:var(--panel);border:1px solid var(--line);border-radius:10px;margin-bottom:14px}
.rec-head{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line);background:#fbfcfe;border-radius:10px 10px 0 0;flex-wrap:wrap}
.rec-head .rlabel{font-weight:600;font-size:13px}
.rec-head .uid{font-size:11px;color:var(--muted)}
.extras{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:12px}
.extras select{width:auto}
.group{border-top:1px solid var(--line)}
.group:first-of-type{border-top:none}
.group-title{font-size:12px;font-weight:600;color:var(--muted);padding:8px 12px 0;cursor:pointer}
.group-title .tri{display:inline-block;transition:.15s}
.group.closed .tri{transform:rotate(-90deg)}
.group.closed .field{display:none}
/* ---------- field card ---------- */
.field{padding:8px 12px;border-top:1px dashed #eef1f6}
.field.active{background:var(--accent-soft);outline:2px solid var(--accent);outline-offset:-2px;border-radius:6px}
.field.na{background:var(--na);opacity:.65}
.f-head{display:flex;align-items:baseline;gap:8px;cursor:pointer}
.f-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--blue);font-weight:600}
.f-label{font-size:13px}
.badge{font-size:10px;border-radius:4px;padding:1px 5px;font-weight:600}
.b-novel{background:#fdf2f8;color:#be185d}
.b-op{background:#eff6ff;color:#1d4ed8}
.b-gold{background:#fef9c3;color:#a16207}
.b-verb{background:#f0fdf4;color:#15803d}
.b-na{background:#e2e8f0;color:#475569}
.f-done{margin-left:auto;font-size:11px;color:var(--green)}
.f-body{margin-top:6px;display:grid;grid-template-columns:150px 170px 1fr;gap:6px;align-items:start}
.f-body .full{grid-column:1/-1}
.f-hint{grid-column:1/-1;font-size:11px;color:var(--muted)}
.f-guide{font-size:12px;color:var(--muted);margin:2px 0 0;line-height:1.45}
.f-guide .eg{color:#8a94a6;font-style:italic}
.ev-list{grid-column:1/-1;display:flex;flex-direction:column;gap:4px}
.ev{display:flex;gap:6px;align-items:center;background:#fbfcfe;border:1px solid var(--line);border-radius:6px;padding:4px 6px;font-size:12px}
.ev .q{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
.ev select{width:auto;font-size:11px;padding:2px 4px}
.ev .g-ok{color:var(--green)} .ev .g-bad{color:var(--red);font-weight:600}
.ev label{display:flex;gap:2px;align-items:center;font-size:11px;color:var(--muted);white-space:nowrap}
.axes-row{display:flex;gap:6px;margin-bottom:4px}
.axes-row input{width:140px}
.fk-box{border:1px solid var(--line);border-radius:6px;background:#fff;padding:4px 8px;max-height:110px;overflow:auto}
.fk-box label{display:flex;gap:6px;font-size:12px;align-items:center}
.addbar{padding:0 14px 20px}
.footer-tip{flex:none;background:var(--panel);border-top:1px solid var(--line);padding:5px 14px;font-size:11px;color:var(--muted)}
</style>
</head>
<body>
<div id="login">
<div class="box">
<h1>Reproducibility Annotator</h1>
<div class="sub">Blind mode · schema-driven · full audit trail. Do not consult any model output.</div>
<div id="step-login">
<div id="quick-ids" style="display:none">
<label>Who are you?</label>
<div id="quick-list" style="display:flex;gap:8px;flex-wrap:wrap"></div>
<label style="margin-top:16px">or type an ID</label>
</div>
<label id="id-label">Annotator ID (e.g. A / B, keep it consistent)</label>
<input type="text" id="in-annotator" placeholder="A" autocomplete="username">
<div id="pw-wrap" style="display:none">
<label>Password</label>
<input type="password" id="in-password" autocomplete="current-password">
</div>
<div class="login-err" id="login-err"></div>
<div style="margin-top:18px;text-align:right"><button class="primary" id="btn-login">Sign in</button></div>
</div>
</div>
</div>
<div id="home">
<div class="topbar">
<span class="brand">Reproducibility Annotator<small>independent double annotation</small></span>
<span class="chip" id="hm-schema"></span>
<span class="spacer"></span>
<button id="hm-add" class="primary" style="display:none">+ Add paper</button>
<span id="hm-view" style="display:none;font-size:12px;color:var(--muted)">Viewing annotator <select id="hm-annotator"></select></span>
<span class="chip" id="hm-who"></span>
<button id="hm-logout">Sign out</button>
</div>
<div class="home-wrap">
<div class="home-head">
<h2>Papers</h2>
<span class="home-stats" id="hm-stats"></span>
</div>
<div class="blind-note" id="hm-note">Blind mode: you only see your own annotations. Open a card to preview the PDF and your annotation cards.</div>
<div class="filters">
<input type="text" id="hm-q" placeholder="Search title or ID">
<span id="hm-venues"></span>
<span style="width:8px"></span>
<span id="hm-status"></span>
</div>
<div class="grid" id="hm-grid"></div>
</div>
</div>
<div id="upload">
<div class="box">
<h3>Add a paper</h3>
<div class="sub">Upload a PDF, or just give a PMCID / DOI / PMID. If the paper is in the PMC open-access subset the publisher's XML is fetched automatically (best text); otherwise the PDF is converted with Docling. Conversion takes about 10 s – 2 min. Papers are only ever added, never overwritten.</div>
<label>PDF</label>
<input type="file" id="up-pdf" accept="application/pdf,.pdf">
<label>PMCID / DOI / PMID <span class="muted">(optional; detected from the PDF if left empty)</span></label>
<input type="text" id="up-ident" placeholder="e.g. PMC4502302 or 10.1155/2015/605940">
<div class="row">
<div><label>Title <span class="muted">(optional)</span></label><input type="text" id="up-title"></div>
<div style="max-width:160px"><label>Venue <span class="muted">(optional)</span></label><input type="text" id="up-venue" placeholder="e.g. MICCAI"></div>
</div>
<label style="display:flex;gap:6px;align-items:center;color:var(--ink);font-size:13px;margin-top:14px"><input type="checkbox" id="up-pool" checked> Add to the annotators' pool (everyone except admin sees only pool papers)</label>
<div class="acts"><button class="primary" id="up-go">Upload &amp; convert</button><button id="up-close">Close</button><span id="up-err"></span></div>
<div id="up-jobs"></div>
</div>
</div>
<div id="detail">
<div class="modal">
<div class="m-head">
<div style="min-width:0">
<div class="meta" id="dt-meta" style="display:flex;gap:8px;align-items:center;font-size:12px;color:var(--muted)"></div>
<h2 id="dt-title"></h2>
</div>
<button class="x" id="dt-close" title="Close (Esc)">×</button>
</div>
<div class="m-body">
<div class="m-pdf" id="dt-pdf"></div>
<div class="m-side">
<div class="m-cards" id="dt-cards"></div>
<div class="m-foot">
<span class="muted" id="dt-sum" style="font-size:12px"></span>
<span class="spacer"></span>
<a id="dt-pdflink" target="_blank" style="font-size:12px">Open PDF in new tab ↗</a>
<button class="primary" id="dt-start">Start annotating</button>
</div>
</div>
</div>
</div>
</div>
<div id="app">
<header>
<span class="title" id="h-paper"></span>
<span class="chip" id="h-annotator"></span>
<span class="chip" id="h-schema"></span>
<span class="chip" id="h-progress">0 fields annotated</span>
<span class="spacer"></span>
<span class="chip sync" id="h-sync" title="Click to sync now">…</span>
<button id="btn-switch">← Papers</button>
<button id="btn-logout">Sign out</button>
</header>
<div id="main">
<div id="left">
<div class="tabs" id="lt-tabs"></div>
<div class="lpane"><div id="pdfview"></div><div id="textpane"></div></div>
</div>
<div id="splitter" title="Drag to resize"></div>
<div id="right">
<div class="tabs" id="rt-tabs"></div>
<div class="rt-help" id="rt-help"></div>
<div class="pane"><div class="recwrap" id="recwrap"></div></div>
<div class="footer-tip">Select text in the text pane <b>or on the PDF</b> → "Add as evidence" or ⌘/Ctrl+E attaches it to the active field · click a paragraph to see it on the PDF (and vice versa) · if text and PDF disagree, trust the PDF and tick dmg · ✓ grounded / ✗ mismatch · autosave</div>
</div>
</div>
</div>
<div id="seltool">Add as evidence ⌘E</div>
<script>
"use strict";
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const api=(p,opt)=>fetch(p,opt).then(r=>{
if(r.status===401&&S.annotator){alert("Session expired, please sign in again");location.reload();}
if(!r.ok)throw new Error(p+" "+r.status);return r.json()});
const lsGet=()=>{try{return JSON.parse(localStorage.getItem("repl_ann")||"{}")}catch(e){return {}}};
const lsSet=v=>{try{localStorage.setItem("repl_ann",JSON.stringify(v))}catch(e){}};
const S={annotator:null,paper:null,schema:null,manifest:null,records:[],fields:{},rtype:null,active:null,rawText:"",saveTimers:{}};
/* ---------------- boot / login ---------------- */
(async function(){
const cfg=await fetch("/api/config").then(r=>r.json());
S.authMode=cfg.auth;
$("#pw-wrap").style.display=cfg.auth?"block":"none";
if(!cfg.auth&&cfg.annotators&&cfg.annotators.length){
$("#quick-ids").style.display="block";$("#id-label").style.display="none";
$("#quick-list").innerHTML=cfg.annotators.map(a=>`<button class="qid" data-id="${esc(a)}" style="padding:6px 16px;font-weight:600">${esc(a)}</button>`).join("");
$("#quick-list").onclick=e=>{const b=e.target.closest(".qid");if(b){$("#in-annotator").value=b.dataset.id;doLogin()}};
}
const saved=lsGet();
if(saved.annotator)$("#in-annotator").value=saved.annotator;
const me=await fetch("/api/me");
if(me.ok)await enterHome(await me.json());
})();
async function doLogin(){
const a=$("#in-annotator").value.trim(), pw=$("#in-password").value;
if(!a){$("#login-err").textContent="Enter an annotator ID";return}
const r=await fetch("/api/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({annotator:a,password:pw})});
if(!r.ok){$("#login-err").textContent=r.status===401?(S.authMode?"Wrong ID or password":"Unknown annotator ID"):r.status===400?"ID: letters, digits, _ or - only":r.status===403?"This ID cannot sign in":"Login failed ("+r.status+")";return}
$("#login-err").textContent="";
await enterHome(await api("/api/me"));
}
$("#btn-login").onclick=doLogin;
$("#in-password").addEventListener("keydown",e=>{if(e.key==="Enter")doLogin()});
$("#in-annotator").addEventListener("keydown",e=>{if(e.key==="Enter"&&!S.authMode)doLogin()});
/* ---------------- home: paper cards ---------------- */
const enc=encodeURIComponent;
/* 1103: block letters come from the schema (record_types[].short) so the same page serves 0817 and v7 */
const BLOCKS_0817=[["l1","L1"],["dataset","D"],["split","S"],["asset","A"],["model","M"],["result","R"],["claim","C"]];
const blocks=()=>S.schema.record_types.some(r=>r.short)?S.schema.record_types.map(r=>[r.id,r.short||r.id]):BLOCKS_0817;
/* All UI text is English (0925). Schema labels carry Chinese in parentheses; stripped for display (schema file untouched). */
const EN_RT={l1:"L1 characterization",dataset:"Datasets",split:"Splits",asset:"Artifacts (code / weights / environment)",model:"Models",result:"Results",claim:"Claims"};
const EN_STATUS={stated:"stated",directly_reported:"directly reported",inferred:"inferred",not_reported:"not reported",absent_deferred:"deferred elsewhere",uncertain:"uncertain"};
const rtName=rt=>rt.label_en||EN_RT[rt.id]||rt.label;
const prettyEnum=t=>String(t).replace(/^C\d_/,"").replace(/_/g," ");
const stripZh=t=>typeof t==="string"?t.replace(/\s*[((](?=[^()()]*[\u4e00-\u9fff])[^()()]*[))]/g,"").trim():t;
function enSchema(x){if(Array.isArray(x))return x.map(enSchema);if(x&&typeof x==="object"){const o={};for(const [k,v] of Object.entries(x))o[k]=(k==="label"||k==="title"||k==="help"||k==="hint")?stripZh(v):enSchema(v);return o}return x}
const H={view:null,overview:{},q:"",venue:"all",status:"all",pid:null};
const rtDef=id=>S.schema.record_types.find(r=>r.id===id);
const fieldDefs=rt=>rt.fields||(rt.groups||[]).flatMap(g=>g.fields||[]);
const isFilled=p=>!!(p&&(p.status||p.value||(p.evidence&&p.evidence.length)||(p.fk&&p.fk.length)||(p.axes&&p.axes.length)||p.no_anchor));
function valOf(p,max=90){
if(!p)return "";
let v=p.value;
if(v&&typeof v==="object")v=Array.isArray(v)?v.join(", "):Object.values(v).filter(Boolean).join(" · ");
v=(v||"").toString().trim();
if(p.value_text)v=v?v+" · "+p.value_text:p.value_text; /* v7 choice + text */
if(!v&&p.status)v="("+(EN_STATUS[p.status]||p.status)+")";
return v.length>max?v.slice(0,max)+"…":v;
}
async function enterHome(me){
S.me=me;
if(!S.schema)S.schema=enSchema(await api("/api/schema"));
if(!S.manifest)S.manifest=await api("/api/papers");
H.view=H.view||me.annotator;
$("#hm-schema").textContent="schema "+S.schema.schema_version;
$("#hm-who").textContent="Signed in: "+me.annotator+(me.admin?" (admin)":"");
if(me.admin){
$("#hm-view").style.display="inline";$("#hm-add").style.display="inline-block";
$("#hm-annotator").innerHTML=me.annotators.map(a=>`<option value="${esc(a)}" ${a===H.view?"selected":""}>${esc(a)}</option>`).join("");
$("#hm-note").textContent="Admin view: switch annotator at the top right to read anyone's cards (read-only). Annotators only ever see their own.";
}
const venues=[...new Set(Object.values(S.manifest.papers).map(e=>e.venue).filter(Boolean))].sort();
$("#hm-venues").innerHTML=["all",...venues].map(v=>`<span class="pill ${v===H.venue?"on":""}" data-venue="${esc(v)}">${v==="all"?"All venues":esc(v)}</span>`).join(" ");
$("#hm-status").innerHTML=[["all","Any status"],["todo","Not started"],["doing","In progress"]].map(([k,l])=>`<span class="pill ${k===H.status?"on":""}" data-status="${k}">${l}</span>`).join(" ");
$("#login").style.display="none";$("#app").style.display="none";$("#home").style.display="block";
await loadOverview();
}
async function loadOverview(){
$("#hm-grid").innerHTML=`<div class="empty">Loading…</div>`;
H.overview=(await api(`/api/overview?annotator=${enc(H.view)}`)).papers;
renderGrid();
}
const statusOf=pid=>(H.overview[pid]&&H.overview[pid].filled>0)?"doing":"todo";
function renderGrid(){
const all=Object.entries(S.manifest.papers);
const doing=all.filter(([p])=>statusOf(p)==="doing").length;
$("#hm-stats").textContent=`${all.length} papers · ${doing} in progress · ${all.length-doing} not started`+(H.view!==S.me.annotator?` · viewing ${H.view}`:"");
const q=H.q.toLowerCase();
const rows=all.filter(([pid,e])=>(H.venue==="all"||e.venue===H.venue)&&(H.status==="all"||statusOf(pid)===H.status)
&&(!q||pid.toLowerCase().includes(q)||(e.title||"").toLowerCase().includes(q)));
if(!rows.length){$("#hm-grid").innerHTML=`<div class="empty">No papers match</div>`;return}
$("#hm-grid").innerHTML=rows.map(([pid,e])=>{
const o=H.overview[pid]||{blocks:{},filled:0};
const blks=blocks().map(([id,sh])=>{
const b=o.blocks[id]||{records:0,filled:0},rt=rtDef(id);
const on=b.filled>0;
return `<span class="blk ${on?"on":""}" style="${on?`background:var(--c-${id})`:""}" title="${esc(rt?rtName(rt):id)}: ${b.records} records · ${b.filled} fields filled">${sh}</span>`;
}).join("");
const st=statusOf(pid);
return `<div class="pcard" data-pid="${esc(pid)}">
<div class="meta"><span class="venue">${esc(e.venue||"")}</span><span>${esc(pid)}</span>${e.text_source==="jats"?'<span class="src-tag">PMC XML</span>':""}${S.me.admin&&S.manifest.pool?`<span style="flex:1"></span><span class="pool-pill ${S.manifest.pool.includes(pid)?"on":""}" data-pool="${esc(pid)}" title="Whether annotators (A/B/C…) see this paper">${S.manifest.pool.includes(pid)?"✓ in pool":"not in pool"}</span>`:""}</div>
<h3>${esc(e.title||pid)}</h3>
<div class="abs">${esc(e.abstract||"")}</div>
<div class="foot"><div class="blocks">${blks}</div>
<span class="status ${st}">${st==="doing"?o.filled+" fields":"Not started"}</span></div>
</div>`;
}).join("");
}
$("#hm-grid").onclick=async e=>{
const pp=e.target.closest(".pool-pill");
if(pp){
e.stopPropagation();const pid=pp.dataset.pool,inc=!S.manifest.pool.includes(pid);
pp.textContent="…";
try{const r=await api("/api/admin/pool",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({paper:pid,include:inc})});S.manifest.pool=r.pool}
catch(err){alert("Could not update the pool: "+err.message)}
renderGrid();return;
}
const c=e.target.closest(".pcard");if(c)openDetail(c.dataset.pid)};
/* ---------------- admin: add paper (1003) ---------------- */
const UP={timer:null};
$("#hm-add").onclick=()=>{$("#upload").style.display="flex";$("#up-err").textContent="";pollJobs()};
$("#up-close").onclick=()=>{$("#upload").style.display="none"};
$("#upload").onclick=e=>{if(e.target.id==="upload")$("#upload").style.display="none"};
$("#up-go").onclick=async()=>{
const f=$("#up-pdf").files[0],ident=$("#up-ident").value.trim();
if(!f&&!ident){$("#up-err").textContent="Choose a PDF or enter an ID";return}
const fd=new FormData();
if(f)fd.append("pdf",f);
fd.append("ident",ident);fd.append("title",$("#up-title").value.trim());fd.append("venue",$("#up-venue").value.trim());
fd.append("add_to_pool",$("#up-pool").checked?"true":"false");
$("#up-go").disabled=true;$("#up-err").textContent="Uploading…";
try{
const r=await fetch("/api/admin/upload",{method:"POST",body:fd});
if(!r.ok){const t=await r.json().catch(()=>({}));throw new Error(t.detail||r.status)}
$("#up-err").textContent="";$("#up-pdf").value="";$("#up-ident").value="";$("#up-title").value="";$("#up-venue").value="";
}catch(err){$("#up-err").textContent=String(err.message||err)}
$("#up-go").disabled=false;pollJobs();
};
async function pollJobs(){
clearTimeout(UP.timer);
let jobs=[];try{jobs=(await api("/api/admin/jobs")).jobs}catch(e){return}
const ST={queued:"waiting",running:"converting…",done:"added",error:"failed"};
$("#up-jobs").innerHTML=jobs.map(j=>`<div class="job"><span class="st ${j.status}">${ST[j.status]||j.status}</span> · ${esc(j.file||j.ident||"")}${j.pid?` → <b>${esc(j.pid)}</b>`:""}${j.error?`<div style="color:var(--red);margin-top:4px">${esc(j.error)}</div>`:""}<pre>${esc((j.log||[]).join("\n"))}</pre></div>`).join("");
const busy=jobs.some(j=>j.status==="queued"||j.status==="running");
const fresh=jobs.filter(j=>j.status==="done"&&!(j.pid in S.manifest.papers));
if(fresh.length){S.manifest=await api("/api/papers");await loadOverview()}
if(busy||$("#upload").style.display==="flex")UP.timer=setTimeout(pollJobs,busy?2000:6000);
}
$("#hm-q").oninput=e=>{H.q=e.target.value;renderGrid()};
$("#hm-venues").onclick=e=>{const p=e.target.closest(".pill");if(!p)return;H.venue=p.dataset.venue;$$("#hm-venues .pill").forEach(x=>x.classList.toggle("on",x===p));renderGrid()};
$("#hm-status").onclick=e=>{const p=e.target.closest(".pill");if(!p)return;H.status=p.dataset.status;$$("#hm-status .pill").forEach(x=>x.classList.toggle("on",x===p));renderGrid()};
$("#hm-annotator").onchange=e=>{H.view=e.target.value;loadOverview()};
/* ---------------- detail: PDF preview + annotation cards ---------------- */
async function openDetail(pid){
H.pid=pid;const e=S.manifest.papers[pid];
$("#dt-meta").innerHTML=`<span class="venue">${esc(e.venue||"")}</span><span>${esc(pid)}</span>`;
$("#dt-title").textContent=e.title||pid;
const url=`/api/paper/${enc(pid)}/pdf`;
$("#dt-pdf").innerHTML=e.pdf?`<iframe src="${url}#navpanes=0&view=FitH"></iframe>`:`<div class="nopdf">No PDF for this paper</div>`;
$("#dt-pdflink").style.display=e.pdf?"inline":"none";$("#dt-pdflink").href=url;
$("#dt-cards").innerHTML=`<div class="muted">Loading…</div>`;
$("#detail").style.display="flex";
const st=await api(`/api/state?paper=${enc(pid)}&annotator=${enc(H.view)}`);
if(H.pid!==pid)return;
renderCards(st);
const mine=!st.read_only, started=Object.values(st.fields).some(m=>Object.values(m).some(isFilled));
$("#dt-start").textContent=!mine?`Annotate as ${S.me.annotator}`:started?"Continue annotating":"Start annotating";
}
function recordLine(rt,r,flds){
const f=flds||{},x=r.extra||{};
const tags=Object.entries(x).filter(([k,v])=>v&&k!=="model_fk"&&typeof v!=="object").map(([k,v])=>{const t=v===true?k:String(v);return `<span class="tag">${esc(prettyEnum(t))}</span>`}).join("");
let main;
if(rt.id==="result"){main=[valOf(f.R1,50),valOf(f.R2,40)].filter(Boolean).join(" = ")}
else if(rt.id==="claim"||rt.id==="summary"){main=valOf(f.F1,160)}
else if(rt.id==="asset"){main=[x.asset_type&&prettyEnum(x.asset_type),valOf(f.availability_claim,90)].filter(Boolean).join(": ")}
else{main=valOf(f[rt.label_field],80)}
const ce=rt.card_extra; /* v7: [label, field id] from the schema */
const extra=ce?(f[ce[1]]?`<span class="k">${esc(ce[0])}</span>${esc(valOf(f[ce[1]],40))}`:""):rt.id==="dataset"&&f.D3?`<span class="k">source</span>${esc(valOf(f.D3,40))}`:rt.id==="split"&&f.D26?`<span class="k">split unit</span>${esc(valOf(f.D26,40))}`:"";
const n=fieldDefs(rt).filter(d=>isFilled(f[d.id])).length;
return `<li>${main?esc(main):`<span class="none">(unnamed)</span>`}${rt.id==="asset"?"":tags} ${extra?"<br>"+extra:""}<span class="tag" style="float:right">${n}/${fieldDefs(rt).length}</span></li>`;
}
function renderCards(st){
const by={};st.records.forEach(r=>(by[r.rtype]=by[r.rtype]||[]).push(r));
let totalFilled=0;
$("#dt-cards").innerHTML=S.schema.record_types.map(rt=>{
const defs=fieldDefs(rt),recs=by[rt.id]||[];
const filledN=recs.reduce((s,r)=>s+defs.filter(d=>isFilled((st.fields[r.uid]||{})[d.id])).length,0);
totalFilled+=filledN;
const denom=defs.length*Math.max(recs.length,1),pct=Math.round(100*filledN/denom);
let body;
if(rt.cardinality==="single"){
const r=recs[0],f=r?(st.fields[r.uid]||{}):{};
const items=defs.filter(d=>d.type!=="gate_list").map(d=>`<li><span class="k">${esc(d.label)}</span>${isFilled(f[d.id])?esc(valOf(f[d.id],70)):'<span class="none">—</span>'}</li>`);
body=`<ul>${items.join("")}</ul>`;
}else{
body=recs.length?`<ul>${recs.map(r=>recordLine(rt,r,st.fields[r.uid])).join("")}</ul>`:`<div class="none">Not annotated yet</div>`;
}
const sub=rt.cardinality==="single"?`${filledN}/${defs.length} fields`:`${recs.length} record${recs.length===1?"":"s"} · ${filledN} fields filled`;
return `<div class="bcard ${rt.cardinality==="single"||rt.id==="claim"||rt.id==="summary"?"wide":""}" style="--bc:var(--c-${rt.id})">
<h4>${esc(rtName(rt))}</h4><div class="sub">${sub}</div>
<div class="bar"><i style="width:${Math.min(pct,100)}%"></i></div>${body}</div>`;
}).join("");
$("#dt-sum").textContent=(st.read_only?`${st.annotator}'s annotation (read-only) · `:"")+`${totalFilled} fields filled in total`;
}
function closeDetail(){$("#detail").style.display="none";$("#dt-pdf").innerHTML="";H.pid=null}
$("#dt-close").onclick=closeDetail;
$("#detail").onclick=e=>{if(e.target.id==="detail")closeDetail()};
document.addEventListener("keydown",e=>{if(e.key==="Escape"&&$("#detail").style.display==="flex")closeDetail()});
$("#dt-start").onclick=async()=>{
const pid=H.pid;closeDetail();
S.annotator=S.me.annotator;S.paper=pid;
lsSet({annotator:S.annotator,paper:pid});
$("#home").style.display="none";
await loadPaper();
$("#app").style.display="flex";
pollSync();if(!S.syncTimer)S.syncTimer=setInterval(pollSync,5000);
};
async function logout(){
await flushPendingSaves();
await fetch("/api/sync",{method:"POST"}).catch(()=>{});
await fetch("/api/logout",{method:"POST"});location.reload();
}
$("#btn-logout").onclick=logout;
$("#hm-logout").onclick=logout;
$("#btn-switch").onclick=async()=>{
await flushPendingSaves();
$("#app").style.display="none";$("#home").style.display="block";
H.view=S.me.annotator;if(S.me.admin)$("#hm-annotator").value=H.view;
await loadOverview();
};
/* ---------------- sync indicator ---------------- */
function fmtTime(ts){return new Date(ts*1000).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"})}
function showSync(s){
const el=$("#h-sync");el.classList.remove("ok","pending","err");
const where=s.backend==="github"?"GitHub":"disk";
if(s.error){el.classList.add("err");el.textContent="Sync error, retrying";el.title=s.error;return}
if(s.pending||Object.keys(S.saveTimers).length){el.classList.add("pending");el.textContent="Saving…";el.title="Changes are saved on the server and will be committed to "+where+" within "+s.flush_seconds+"s";return}
el.classList.add("ok");el.textContent=s.last_ok?"Synced to "+where+" ✓ "+fmtTime(s.last_ok):"Synced ✓";el.title="Click to sync now";
}
async function pollSync(){try{showSync(await api("/api/sync"))}catch(e){}}
$("#h-sync").onclick=async()=>{try{showSync(await api("/api/sync",{method:"POST"}))}catch(e){}};
async function flushPendingSaves(){
for(const [k,t] of Object.entries(S.saveTimers)){clearTimeout(t);const f=S.pendingSaves&&S.pendingSaves[k];if(f)await f();}
S.saveTimers={};
}
window.addEventListener("beforeunload",e=>{if(Object.keys(S.saveTimers).length){e.preventDefault();e.returnValue=""}});
async function loadPaper(){
const pe=enc(S.paper), entry=S.manifest.papers[S.paper];
const [text,state,layout]=await Promise.all([
fetch(`/api/paper/${pe}/text`).then(r=>r.text()),
api(`/api/state?paper=${pe}`),
fetch(`/api/paper/${pe}/layout`).then(r=>r.ok?r.json():null).catch(()=>null)
]);
S.rawText=text;S.records=state.records;S.fields=state.fields;S.layout=layout;
S.isMd=(entry.fulltext||"").endsWith(".md");S.rawView=false;
$("#h-paper").textContent=S.paper;
$("#h-annotator").textContent="Annotator "+S.annotator;
$("#h-schema").textContent="schema "+S.schema.schema_version;
setupLeftTabs(entry);
openPdf(entry.pdf?`/api/paper/${pe}/pdf`:null);
S.rtype=S.schema.record_types[0].id;
ensureSingletons();
renderTabs();renderRight();updateProgress();
}
async function ensureSingletons(){
for(const rt of S.schema.record_types.filter(r=>r.cardinality==="single")){
if(!S.records.some(r=>r.rtype===rt.id)){
const res=await api("/api/record",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({annotator:S.annotator,paper:S.paper,action:"create",rtype:rt.id,extra:{}})});
S.records.push({uid:res.uid,rtype:rt.id,extra:{}});
}
}
renderTabs();renderRight();
}
/* ---------------- left pane ---------------- */
function esc(t){return t.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}
function renderText(hlFrom,hlTo){
const el=$("#textpane");
el.classList.toggle("linked",!!S.layout&&!S.rawView);
if(S.layout&&!S.rawView){
/* 1003: 按 layout 块渲染, 每块记 data-b, 用来和 PDF 互相定位 */
el.innerHTML=S.layout.blocks.map(b=>`<div class="tb k-${b.kind}" data-b="${b.id}">${mdToHtml(S.rawText.slice(b.start,b.end))}</div>`).join("");
return;
}
if(S.isMd&&!S.rawView){el.innerHTML=mdToHtml(S.rawText);return}
if(hlFrom==null){el.innerHTML=esc(S.rawText);return}
el.innerHTML=esc(S.rawText.slice(0,hlFrom))+"<mark id='hl'>"+esc(S.rawText.slice(hlFrom,hlTo))+"</mark>"+esc(S.rawText.slice(hlTo));
document.getElementById("hl")?.scrollIntoView({block:"center",behavior:"smooth"});
}
/* 极简 md 渲染: 标题 / 管道表格 / 去粗斜体标记。只做展示, 取证坐标由后端宽容匹配回源文件 */
function mdToHtml(src){
const inline=t=>esc(t).replace(/\*\*([^*]+)\*\*/g,"$1").replace(/\*([^*]+)\*/g,"$1").replace(/`([^`]+)`/g,"$1");
const lines=src.split("\n");
let html="",i=0,para=[];
const flush=()=>{if(para.length){html+="<p>"+inline(para.join(" "))+"</p>";para=[]}};
while(i<lines.length){
const L=lines[i];
if(/^\s*\|/.test(L)){
flush();
const rows=[];
while(i<lines.length&&/^\s*\|/.test(lines[i])){
if(!/^\s*\|[\s\-:|]+\|?\s*$/.test(lines[i]))rows.push(lines[i]);
i++;
}
html+="<table>"+rows.map((r,ri)=>{
const cells=r.replace(/^\s*\|/,"").replace(/\|\s*$/,"").split(/(?<!\\)\|/).map(c=>c.replace(/\\\|/g,"|"));
const tag=ri===0?"th":"td";
return "<tr>"+cells.map(c=>`<${tag}>`+inline(c.trim())+`</${tag}>`).join("")+"</tr>";
}).join("")+"</table>";
continue;
}
const h=L.match(/^(#{1,6})\s+(.*)/);
if(h){flush();const lv=Math.min(h[1].length+1,6);html+=`<h${lv}>`+inline(h[2])+`</h${lv}>`;i++;continue}
if(/^\s*<!--.*-->\s*$/.test(L)){i++;continue}
if(/^\s*$/.test(L)){flush();i++;continue}
para.push(L);i++;
}
flush();
return html;
}
/* ---------------- left: views (side by side / text / PDF / raw) ---------------- */
const SRC={jats:["good","Text: publisher XML (official)","Text comes from the PMC XML, not from PDF conversion"],
docling:["ok","Text: layout-aware PDF conversion","Converted with Docling; tables/formulas can still be wrong, check against the PDF"],
docling_0817:["ok","Text: PDF conversion (0817)","Docling conversion without PDF linking"],
docling_package:["ok","Text: v7 package (Docling)","Same text the v7 extraction read: main text + supplementary parts, marked <<<PART n>>>; figures as captions only. No click-sync with the PDF."],
pymupdf:["weak","Text: plain PDF extraction","Flat PyMuPDF text: tables are often scrambled, check against the PDF"]};
const W=(()=>{try{return Object.assign({split:64,single:46},JSON.parse(localStorage.getItem("repl_widths")||"{}"))}catch(e){return {split:64,single:46}}})();
function saveW(){try{localStorage.setItem("repl_widths",JSON.stringify(W))}catch(e){}}
function applyLeftWidth(){$("#left").style.width=(S.view==="split"?W.split:W.single)+"%"}
function setupLeftTabs(entry){
const views=S.layout?[["split","Side by side"],["text","Text"],["pdf","PDF"]]:[["text","Text"],["pdf","PDF"]];
if(S.isMd)views.push(["raw","Raw MD"]);
const src=SRC[entry.text_source||(S.isMd?"docling_0817":"pymupdf")]||SRC.pymupdf;
$("#lt-tabs").innerHTML=views.map(([k,l])=>`<div class="tab" data-lt="${k}">${l}</div>`).join("")+`<span class="src-chip ${src[0]}" title="${esc(src[2])}">${esc(src[1])}</span>`;
$$("#lt-tabs .tab").forEach(t=>t.onclick=()=>setView(t.dataset.lt));
setView(S.layout?"split":"text");
}
function setView(v){
S.view=v;S.rawView=(v==="raw");
$$("#lt-tabs .tab").forEach(x=>x.classList.toggle("on",x.dataset.lt===v));
$("#pdfview").style.display=(v==="split"||v==="pdf")?"block":"none";
$("#textpane").style.display=v==="pdf"?"none":"block";
applyLeftWidth();
if(v!=="pdf")renderText();
}
$("#splitter").onmousedown=e=>{
e.preventDefault();
const box=$("#main").getBoundingClientRect(),sp=$("#splitter");sp.classList.add("drag");
const key=S.view==="split"?"split":"single";
const mv=ev=>{W[key]=Math.min(82,Math.max(22,(ev.clientX-box.left)/box.width*100));$("#left").style.width=W[key]+"%"};
const up=()=>{document.removeEventListener("mousemove",mv);document.removeEventListener("mouseup",up);sp.classList.remove("drag");saveW()};
document.addEventListener("mousemove",mv);document.addEventListener("mouseup",up);
};
/* ---------------- PDF viewer (PDF.js, lazy per page; falls back to the browser viewer) ---------------- */
if(window.pdfjsLib)pdfjsLib.GlobalWorkerOptions.workerSrc="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
const PV={doc:null,pages:[],width:0,hl:{},obs:null,token:0};
async function openPdf(url){
const box=$("#pdfview"),my=++PV.token;
box.innerHTML="";PV.doc=null;PV.pages=[];PV.hl={};PV.width=0;
if(!url){box.innerHTML=`<div class="nopdf">No PDF for this paper</div>`;return}
if(!window.pdfjsLib){box.style.padding="0";box.innerHTML=`<iframe src="${url}" style="width:100%;height:100%;border:0"></iframe>`;return}
box.style.padding="";
try{
const doc=await pdfjsLib.getDocument({url}).promise;
if(my!==PV.token)return;
for(let n=1;n<=doc.numPages;n++){const page=await doc.getPage(n);if(my!==PV.token)return;PV.pages[n]={page}}
PV.doc=doc;layoutPages();
}catch(e){box.innerHTML=`<div class="nopdf">Could not load the PDF (${esc(String(e.message||e))})</div>`}
}
function layoutPages(){
const box=$("#pdfview");if(!PV.doc)return;
const w=box.clientWidth-24;if(w<60)return; // hidden (text-only view)
const keep=PV.width?currentPdfSpot():null;
PV.width=w;
if(PV.obs)PV.obs.disconnect();
PV.obs=new IntersectionObserver(es=>es.forEach(e=>{if(e.isIntersecting)renderPage(+e.target.dataset.page)}),{root:box,rootMargin:"800px 0px"});
box.innerHTML="";
PV.pages.forEach((P,n)=>{
const vp=P.page.getViewport({scale:1});P.w=vp.width;P.scale=w/vp.width;P.rendered=false;
const d=document.createElement("div");d.className="pg";d.dataset.page=n;
d.style.width=w+"px";d.style.height=vp.height*P.scale+"px";
P.div=d;box.appendChild(d);PV.obs.observe(d);
});
if(keep)scrollPdfTo(keep.page,keep.y,false);
}
async function renderPage(n){
const P=PV.pages[n];if(!P||P.rendered)return;P.rendered=true;
const div=P.div,vp=P.page.getViewport({scale:P.scale}),dpr=window.devicePixelRatio||1;
const c=document.createElement("canvas");c.width=Math.floor(vp.width*dpr);c.height=Math.floor(vp.height*dpr);
c.style.width=vp.width+"px";c.style.height=vp.height+"px";div.appendChild(c);
await P.page.render({canvasContext:c.getContext("2d"),viewport:vp,transform:dpr!==1?[dpr,0,0,dpr,0,0]:undefined}).promise;
if(P.div!==div)return; // re-laid out meanwhile
div.style.setProperty("--scale-factor",P.scale);
const tl=document.createElement("div");tl.className="textLayer";div.appendChild(tl);
try{await pdfjsLib.renderTextLayer({textContentSource:P.page.streamTextContent(),container:tl,viewport:vp}).promise}catch(e){}
const hl=document.createElement("div");hl.className="hl-layer";div.appendChild(hl);
drawHl(n);
}
/* layout bbox = PDF points, top-left origin; PDF.js page width in points → css px */
function pageScale(n){const P=PV.pages[n];if(!P)return 1;const lp=S.layout&&S.layout.pages&&S.layout.pages[n];return PV.width/(lp?lp[0]:P.w)}
function currentPdfSpot(){
const box=$("#pdfview"),top=box.scrollTop;
for(const [n,P] of PV.pages.entries())if(P&&P.div&&P.div.offsetTop+P.div.offsetHeight>top)return {page:n,y:Math.max(0,top-P.div.offsetTop)/pageScale(n)};
return null;
}
function scrollPdfTo(n,y,smooth){
const P=PV.pages[n];if(!P||!P.div)return;
$("#pdfview").scrollTo({top:P.div.offsetTop+y*pageScale(n)-60,behavior:smooth?"smooth":"auto"});
}
function setHl(boxes,cls,scroll=true){
PV.hl={};(boxes||[]).forEach(b=>(PV.hl[b.page]=PV.hl[b.page]||[]).push({bbox:b.bbox,cls}));
PV.pages.forEach((P,n)=>{if(P&&P.rendered)drawHl(n)});
if(scroll&&boxes&&boxes.length&&$("#pdfview").style.display!=="none")scrollPdfTo(boxes[0].page,boxes[0].bbox[1],true);
}
function drawHl(n){
const P=PV.pages[n],layer=P&&P.div&&P.div.querySelector(".hl-layer");if(!layer)return;
const k=pageScale(n);
layer.innerHTML=(PV.hl[n]||[]).map(h=>{const [x0,y0,x1,y1]=h.bbox;
return `<i class="${h.cls}" style="left:${x0*k-2}px;top:${y0*k-2}px;width:${(x1-x0)*k+4}px;height:${(y1-y0)*k+4}px"></i>`}).join("");
}
let roT;new ResizeObserver(()=>{clearTimeout(roT);roT=setTimeout(()=>{
const w=$("#pdfview").clientWidth-24;if(PV.doc&&w>=60&&Math.abs(w-PV.width)>8)layoutPages()},150)}).observe($("#pdfview"));
/* ---------------- text ↔ PDF linking ---------------- */
function focusBlock(id,from){
const b=S.layout&&S.layout.blocks[id];if(!b)return;
$$("#textpane .tb.cur").forEach(x=>x.classList.remove("cur"));
const el=$(`#textpane .tb[data-b="${id}"]`);
if(el){el.classList.add("cur");if(from==="pdf")el.scrollIntoView({block:"center",behavior:"smooth"})}
setHl(b.boxes,"blk",from!=="pdf");
}
$("#textpane").addEventListener("click",e=>{
if(!S.layout||S.rawView||!window.getSelection().isCollapsed)return;
const tb=e.target.closest(".tb");if(tb)focusBlock(+tb.dataset.b,"text");
});
$("#pdfview").addEventListener("click",e=>{
if(!S.layout||!window.getSelection().isCollapsed)return;
const pg=e.target.closest(".pg");if(!pg)return;
const n=+pg.dataset.page,r=pg.getBoundingClientRect(),k=pageScale(n),x=(e.clientX-r.left)/k,y=(e.clientY-r.top)/k;
let best=null,area=Infinity;
for(const b of S.layout.blocks)for(const bx of b.boxes){
if(bx.page!==n)continue;const [x0,y0,x1,y1]=bx.bbox;
if(x>=x0-3&&x<=x1+3&&y>=y0-3&&y<=y1+3){const a=(x1-x0)*(y1-y0);if(a<area){area=a;best=b}}
}
if(best)focusBlock(best.id,"pdf");
});
/* PDF selection → page boxes (PDF points), merged per line */
function selectionBoxes(range){
const pages=$$("#pdfview .pg").map(d=>[d,d.getBoundingClientRect()]),out=[];
for(const r of range.getClientRects()){
if(r.width<1||r.height<1)continue;
const hit=pages.find(([,b])=>r.top>=b.top-2&&r.bottom<=b.bottom+2&&r.left>=b.left-2&&r.right<=b.right+2);if(!hit)continue;
const [d,b]=hit,n=+d.dataset.page,k=pageScale(n);
const bb=[(r.left-b.left)/k,(r.top-b.top)/k,(r.right-b.left)/k,(r.bottom-b.top)/k];
const prev=out[out.length-1];
if(prev&&prev.page===n&&Math.abs(prev.bbox[1]-bb[1])<4&&bb[0]-prev.bbox[2]<30){
prev.bbox=[Math.min(prev.bbox[0],bb[0]),Math.min(prev.bbox[1],bb[1]),Math.max(prev.bbox[2],bb[2]),Math.max(prev.bbox[3],bb[3])];
}else out.push({page:n,bbox:bb});
}
return out.slice(0,60).map(o=>({page:o.page,bbox:o.bbox.map(v=>Math.round(v*10)/10)}));
}
/* selection toolbar */
const seltool=$("#seltool");
document.addEventListener("selectionchange",()=>{
const sel=window.getSelection();
if(sel.isCollapsed||!($("#textpane").contains(sel.anchorNode)||$("#pdfview").contains(sel.anchorNode))){seltool.style.display="none";return}
const r=sel.getRangeAt(0).getBoundingClientRect();
seltool.style.left=Math.min(r.left,innerWidth-160)+"px";
seltool.style.top=Math.max(4,r.top-34)+"px";
seltool.style.display="block";
});
seltool.onmousedown=e=>{e.preventDefault();addSelectionAsEvidence()};
document.addEventListener("keydown",e=>{
if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="e"){e.preventDefault();addSelectionAsEvidence()}
});
function addSelectionAsEvidence(){
const sel=window.getSelection();
if(sel.isCollapsed||!sel.rangeCount)return;
const fromPdf=$("#pdfview").contains(sel.anchorNode);
let q=sel.toString();
if(fromPdf)q=q.replace(/([A-Za-z])-\s*\n\s*([a-z])/g,"$1$2"); // PDF line-break hyphenation
q=q.replace(/\s+/g," ").trim();
if(!q)return;
if(!S.active){alert("Activate a field card first");return}
const pl=getPayload(S.active.uid,S.active.fid);
pl.evidence=pl.evidence||[];
const ev={quote:q,section:"other",flags:{},grounded:null,char_span:null};
/* 在 PDF 上划选: 记下精确位置; 转换文本里对不上(损坏)时位置也不丢 */
if(fromPdf){ev.origin="pdf";ev.pdf_anchor={blocks:[],boxes:selectionBoxes(sel.getRangeAt(0))}}
pl.evidence.push(ev);
if(!pl.status)pl.status=S.schema.attributes.status[0].v;
saveField(S.active.uid,S.active.fid,pl,true);
sel.removeAllRanges();seltool.style.display="none";
}
/* ---------------- right pane ---------------- */
function rt(){return S.schema.record_types.find(r=>r.id===S.rtype)}
function recsOf(t){return S.records.filter(r=>r.rtype===t)}
function getPayload(uid,fid){
S.fields[uid]=S.fields[uid]||{};
S.fields[uid][fid]=S.fields[uid][fid]||{value:"",status:"",granularity:"",evidence:[],defer_target:"",note:""};
return S.fields[uid][fid];
}
function naSet(){
const l1=recsOf("l1")[0];if(!l1)return new Set();
const v=(S.fields[l1.uid]?.L1_na_fields?.value)||"";
/* 分隔符: , , ; ; 、 空格都行; 范围 R9-R11 / R9–R11 展开 */
const out=new Set();
v.toUpperCase().replace(/\s*([-–—~~])\s*/g,"$1").split(/[,,;;、\s]+/).filter(Boolean).forEach(t=>{
const m=t.match(/^([A-Z]+)(\d+)[-–—~~][A-Z]*(\d+)$/);
if(m&&+m[3]>=+m[2]&&+m[3]-+m[2]<60){for(let i=+m[2];i<=+m[3];i++)out.add(m[1]+i)}else out.add(t);
});
return out;
}
function recLabel(rec,def){
const lf=def.label_field;
if(lf&&def.extras?.some(e=>e.id===lf))return rec.extra[lf]||"(unset)";
if(lf)return (S.fields[rec.uid]?.[lf]?.value||"").slice(0,60)||"("+lf+" empty)";
return def.label;
}
function renderTabs(){
const bar=$("#rt-tabs");bar.innerHTML="";
S.schema.record_types.forEach(def=>{
const t=document.createElement("div");
t.className="tab"+(def.id===S.rtype?" on":"");
const n=recsOf(def.id).length;
t.innerHTML=esc(def.label)+(def.cardinality==="multi"?` <span class="cnt">(${n})</span>`:"");
t.onclick=()=>{S.rtype=def.id;renderTabs();renderRight()};
bar.appendChild(t);
});
}
function renderRight(){
const def=rt();
$("#rt-help").textContent=def.help||"";
const wrap=$("#recwrap");wrap.innerHTML="";
recsOf(def.id).forEach(rec=>wrap.appendChild(renderRecord(rec,def)));
if(def.cardinality==="multi"){
const bar=document.createElement("div");bar.className="addbar";
const b=document.createElement("button");b.className="primary";b.textContent="+ New record: "+def.label;
b.onclick=async()=>{
const extra={};(def.extras||[]).forEach(e=>{if(e.default)extra[e.id]=e.default});
const res=await api("/api/record",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({annotator:S.annotator,paper:S.paper,action:"create",rtype:def.id,extra})});
S.records.push({uid:res.uid,rtype:def.id,extra});
renderTabs();renderRight();
};
bar.appendChild(b);wrap.appendChild(bar);
}
}
function renderRecord(rec,def){
const card=document.createElement("div");card.className="record";
/* head */
const head=document.createElement("div");head.className="rec-head";
head.innerHTML=`<span class="rlabel">${esc(recLabel(rec,def))}</span><span class="uid">#${rec.uid}</span>`;
const ex=document.createElement("span");ex.className="extras";
(def.extras||[]).filter(e=>e.type!=="fk").forEach(e=>{ // fk 类(model_fk)在下面单独画成勾选框
const lab=document.createElement("label");lab.textContent=e.label+" ";
if(e.type==="flag"){
const c=document.createElement("input");c.type="checkbox";c.checked=!!rec.extra[e.id];
c.onchange=()=>{rec.extra[e.id]=c.checked;saveExtra(rec);renderRight()};
lab.prepend(c);
}else if(e.type==="text"){ /* 1103 v7: free-text record attribute (model name, ablation variant) */
const t=document.createElement("input");t.type="text";t.value=rec.extra[e.id]||"";t.style.width="140px";
t.oninput=()=>{rec.extra[e.id]=t.value;saveExtra(rec)};
t.onchange=()=>{renderTabs();renderRight()}; /* refresh the card title (label_field may be this attribute) */
lab.appendChild(t);
}else{
const s=document.createElement("select");
s.innerHTML="<option value=''>—</option>"+e.options.map(o=>`<option ${rec.extra[e.id]===o?"selected":""}>${o}</option>`).join("");
s.onchange=()=>{rec.extra[e.id]=s.value;saveExtra(rec);renderTabs();renderRight()};
lab.appendChild(s);
}
if(e.hint)lab.title=e.hint;
ex.appendChild(lab);
});
head.appendChild(ex);
if(def.cardinality==="multi"){
const del=document.createElement("button");del.textContent="Delete";del.style.marginLeft="auto";
del.onclick=async()=>{
if(!confirm("Delete this record and all its field annotations?"))return;
await api("/api/record",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({annotator:S.annotator,paper:S.paper,action:"delete",uid:rec.uid})});
S.records=S.records.filter(r=>r.uid!==rec.uid);renderTabs();renderRight();
};
head.appendChild(del);
}
card.appendChild(head);
/* fk-type extras (model_fk) rendered as a field-like row */
(def.extras||[]).filter(e=>e.type==="fk").forEach(e=>{
const g=document.createElement("div");g.className="field";
g.innerHTML=`<div class="f-head"><span class="f-id">${e.id}</span><span class="f-label">${esc(e.label)}</span></div>`;
g.appendChild(fkBox(e.fk_target,rec.extra[e.id]||[],ids=>{rec.extra[e.id]=ids;saveExtra(rec)}));
if(e.hint){const h=document.createElement("div");h.className="f-hint";h.textContent=e.hint;g.appendChild(h)}
card.appendChild(g);
});
/* groups */
const na=naSet();
const groups=def.groups||[{title:null,fields:def.fields}];
const su=def.simple_unless;
const simple=su?!!(rec.extra[su.extra]&&rec.extra[su.extra]!==su.equals):!!(def.id==="model"&&rec.extra.role&&rec.extra.role!=="proposed"); /* datasets also have a role in v7 */
groups.forEach(gr=>{
if(!groupVisible(rec,gr))return;
const g=document.createElement("div");g.className="group";
if(gr.title){
const t=document.createElement("div");t.className="group-title";
t.innerHTML=`<span class="tri">▾</span> ${esc(gr.title)}`;
t.onclick=()=>g.classList.toggle("closed");
g.appendChild(t);
if(simple&&!gr.always_open&&!/M1–M4|framework/i.test(gr.title))g.classList.add("closed");
}
gr.fields.forEach(f=>g.appendChild(renderField(rec,f,na)));
card.appendChild(g);
});
if(simple){
const n=document.createElement("div");n.className="f-hint";n.style.padding="6px 12px";
n.textContent=su?"Baseline / comparison: header (incl. HP source) + M1 only.":"role ≠ proposed: abbreviated record suffices; other groups collapsed, expand as needed.";
card.appendChild(n);
}
return card;
}
/* 1103 v7 display rules (all optional in the schema):
trigger = flag extra must be on · only_if_extra / hide_if_extra = {extra: [values]} · show_if_field = {field id: [values]} */
function groupVisible(rec,gr){
if(gr.trigger&&!rec.extra[gr.trigger])return false;
for(const [k,vals] of Object.entries(gr.only_if_extra||{}))if(!vals.includes(rec.extra[k]))return false;
for(const [k,vals] of Object.entries(gr.hide_if_extra||{}))if(vals.includes(rec.extra[k]))return false;
for(const [fid,vals] of Object.entries(gr.show_if_field||{}))if(!vals.includes(S.fields[rec.uid]?.[fid]?.value))return false;
return true;
}
function fkBox(target,selected,onchange){
const targets=Array.isArray(target)?target:[target];
const box=document.createElement("div");box.className="fk-box";
let any=false;
targets.forEach(tg=>{
const tdef=S.schema.record_types.find(r=>r.id===tg);
recsOf(tg).forEach(r=>{
any=true;
const lab=document.createElement("label");
const c=document.createElement("input");c.type="checkbox";
c.checked=selected.includes(r.uid);
c.onchange=()=>{
const s=new Set(selected);c.checked?s.add(r.uid):s.delete(r.uid);
selected=[...s];onchange(selected);
};
lab.appendChild(c);
lab.append(`[${tdef.label.split("·").pop().trim()}] ${recLabel(r,tdef)} #${r.uid}`);
box.appendChild(lab);
});
});
if(!any){box.innerHTML=`<span class="muted" style="font-size:12px">No target records yet — create a ${targets.join("/")} record first</span>`}
return box;
}
function renderField(rec,f,na){
const uid=rec.uid;
const pl=getPayload(uid,f.id);
const el=document.createElement("div");
el.className="field";el.dataset.uid=uid;el.dataset.fid=f.id;
const isNA=na.has(f.id.toUpperCase());
if(isNA)el.classList.add("na");
if(S.active&&S.active.uid===uid&&S.active.fid===f.id)el.classList.add("active");
const badges=[
f.novel?'<span class="badge b-novel">novel</span>':"",
f.operational?'<span class="badge b-op">operational</span>':"",
f.gold?'<span class="badge b-gold">gold priority</span>':"",
f.verbatim?'<span class="badge b-verb">verbatim</span>':"",
isNA?'<span class="badge b-na">L1 N/A</span>':""
].join("");
const done=pl.status?`<span class="f-done">● ${pl.status}</span>`:"";
el.innerHTML=`<div class="f-head"><span class="f-id">${f.id}</span><span class="f-label">${esc(f.label)}</span>${badges}${done}</div>`
+(f.hint?`<div class="f-guide">${esc(f.hint)}${f.example?` <span class="eg">e.g. ${esc(f.example)}</span>`:""}${f.suggest&&f.example?` <span class="eg">vocabulary: ${esc(f.suggest.join(" · "))}</span>`:""}</div>`:"");
el.querySelector(".f-head").onclick=()=>activate(uid,f.id);
const body=document.createElement("div");body.className="f-body";
/* status */
const st=document.createElement("select");
st.innerHTML="<option value=''>status…</option>"+S.schema.attributes.status.map(s=>`<option value="${s.v}" ${pl.status===s.v?"selected":""}>${s.label}</option>`).join("");
st.onchange=()=>{pl.status=st.value;saveField(uid,f.id,pl,true)};
body.appendChild(st);
/* granularity */
if(S.schema.attributes.granularity){ /* v7 has no granularity */
const gr=document.createElement("select");
gr.innerHTML="<option value=''>granularity…</option>"+S.schema.attributes.granularity.map(s=>`<option value="${s.v}" ${pl.granularity===s.v?"selected":""}>${s.label}</option>`).join("");
gr.onchange=()=>{pl.granularity=gr.value;saveField(uid,f.id,pl)};
body.appendChild(gr);
}
/* value */
body.appendChild(valueEditor(f,pl,uid));
/* defer_target */
if(pl.status==="absent_deferred"){
const d=document.createElement("input");d.type="text";d.className="full";
d.placeholder="defer_target: where it points (e.g. Appendix B.1). First confirm it is truly absent from the documents at hand.";
d.value=pl.defer_target||"";
d.oninput=()=>{pl.defer_target=d.value;saveField(uid,f.id,pl)};
body.appendChild(d);
}
/* evidence */
if(!f.no_evidence)body.appendChild(evidenceList(uid,f.id,pl));
/* note */
const note=document.createElement("input");note.type="text";note.className="full";
note.placeholder="Note: reason for uncertain, internal contradictions, ...";note.value=pl.note||"";
note.style.fontSize="12px";
note.oninput=()=>{pl.note=note.value;saveField(uid,f.id,pl)};
body.appendChild(note);
el.appendChild(body);
el.addEventListener("mousedown",()=>activate(uid,f.id,el),true);
return el;
}
function valueEditor(f,pl,uid){
if(f.type==="enum_field"){
const s=document.createElement("select");s.className="full";
s.innerHTML="<option value=''>value…</option>"+f.options.map(o=>`<option ${pl.value===o?"selected":""}>${o}</option>`).join("");
s.onchange=()=>{pl.value=s.value;saveField(uid,f.id,pl);if(f.rerender)renderRight()};
if(!f.with_text)return s;
/* 1103 v7: choice + free text (D12 name, D15/D10 "other", R1 metric) */
const box=document.createElement("div");box.className="full";
const t=document.createElement("input");t.type="text";t.className="full";t.style.marginTop="4px";
t.placeholder=f.text_placeholder||"details";t.value=pl.value_text||"";
t.oninput=()=>{pl.value_text=t.value;saveField(uid,f.id,pl)};
box.append(s,t);return box;
}
if(f.type==="axes"){
const box=document.createElement("div");box.className="full";
pl.axes=pl.axes||[];
const draw=()=>{
box.innerHTML="";
pl.axes.forEach((ax,i)=>{
const row=document.createElement("div");row.className="axes-row";
const a=document.createElement("input");a.type="text";a.placeholder="axis (patient/video/image…)";a.value=ax.axis||"";
a.oninput=()=>{ax.axis=a.value;saveField(uid,f.id,pl)};
const s=document.createElement("select");
s.innerHTML=["disjoint","overlap","unknown"].map(o=>`<option ${ax.status===o?"selected":""}>${o}</option>`).join("");
s.onchange=()=>{ax.status=s.value;saveField(uid,f.id,pl)};
const d=document.createElement("button");d.textContent="×";
d.onclick=()=>{pl.axes.splice(i,1);saveField(uid,f.id,pl);draw()};
row.append(a,s,d);box.appendChild(row);
});
const add=document.createElement("button");add.textContent="+ Add axis";
add.onclick=()=>{pl.axes.push({axis:"",status:"unknown"});draw()};
box.appendChild(add);
};
draw();return box;
}
if(f.type==="fk"||f.type==="fk2"){
const box=document.createElement("div");box.className="full";
pl.fk=pl.fk||[];
box.appendChild(fkBox(f.type==="fk2"?f.fk_targets:f.fk_target,pl.fk,ids=>{pl.fk=ids;saveField(uid,f.id,pl)}));
if(f.allow_no_anchor){
const lab=document.createElement("label");lab.style.cssText="font-size:12px;display:flex;gap:4px;margin-top:4px";
const c=document.createElement("input");c.type="checkbox";c.checked=!!pl.no_anchor;
c.onchange=()=>{pl.no_anchor=c.checked;saveField(uid,f.id,pl)};
lab.append(c,"no_anchor (explicit: anchors to no reported result)");
box.appendChild(lab);
}
const v=document.createElement("textarea");v.placeholder=f.example?"e.g. "+f.example:"Describe in the paper's words";v.value=pl.value||"";
v.style.marginTop="4px";
v.oninput=()=>{pl.value=v.value;saveField(uid,f.id,pl)};
box.appendChild(v);
return box;
}
/* text (default) */
const t=document.createElement("textarea");t.className="full";t.value=pl.value||"";
t.placeholder=f.example?"e.g. "+f.example:f.suggest?("e.g. "+f.suggest.join(" / ")):"What the paper says, in its own words";
t.oninput=()=>{pl.value=t.value;saveField(uid,f.id,pl)};
return t;
}
function evidenceList(uid,fid,pl){
const box=document.createElement("div");box.className="ev-list";
(pl.evidence||[]).forEach((ev,i)=>{
const row=document.createElement("div");row.className="ev";
const hasBox=!!(ev.pdf_anchor&&ev.pdf_anchor.boxes&&ev.pdf_anchor.boxes.length);
const g=ev.grounded===true?'<span class="g-ok">✓</span>'
:ev.grounded===false&&ev.origin==="pdf"&&hasBox?'<span class="g-pdf" title="Selected on the PDF but not found in the converted text (likely conversion damage). The PDF location is kept; consider ticking dmg.">◐</span>'
:ev.grounded===false?'<span class="g-bad" title="Grounding mismatch: quote is not an exact substring of the fulltext; verify and fix">✗</span>':"…";
row.innerHTML=`${g}`;
const q=document.createElement("span");q.className="q";q.textContent=ev.quote;q.title="Click to locate in the left pane";
q.onclick=()=>locateQuote(ev);
row.appendChild(q);
if(hasBox&&PV.doc){const t=document.createElement("span");t.className="pdf-tag";t.textContent="PDF p."+ev.pdf_anchor.boxes[0].page;
t.title="Show on the PDF";t.onclick=()=>{if(S.view==="text")setView("split");setHl(ev.pdf_anchor.boxes,"ev")};row.appendChild(t)}
const sec=document.createElement("select");
sec.innerHTML=S.schema.attributes.evidence_sections.map(s=>`<option ${ev.section===s?"selected":""}>${s}</option>`).join("");
sec.onchange=()=>{ev.section=sec.value;saveField(uid,fid,pl)};
row.appendChild(sec);
S.schema.attributes.evidence_flags.forEach(fl=>{
const lab=document.createElement("label");lab.title=fl.label;
const c=document.createElement("input");c.type="checkbox";c.checked=!!(ev.flags&&ev.flags[fl.v]);
c.onchange=()=>{ev.flags=ev.flags||{};ev.flags[fl.v]=c.checked;saveField(uid,fid,pl)};
lab.append(c,fl.v.replace("pipeline_damage","dmg").replace("figure_only","fig"));
row.appendChild(lab);
});
const d=document.createElement("button");d.textContent="×";
d.onclick=()=>{pl.evidence.splice(i,1);saveField(uid,fid,pl,true)};
row.appendChild(d);
box.appendChild(row);
});
const add=document.createElement("button");
add.textContent="+ Figure/table evidence (no selection)";
add.style.cssText="font-size:11px;align-self:flex-start";
add.title="Use when the value only exists in a figure or damaged table: paste the caption verbatim (grounds if it matches) or a locator like Figure 3 left panel";
add.onclick=()=>{
const q=prompt("Figure/table evidence: paste the caption/table title verbatim if possible (it can then be grounded), otherwise a locator description, e.g. Figure 3 left panel");
if(!q)return;
pl.evidence=pl.evidence||[];
pl.evidence.push({quote:q.replace(/\s+/g," ").trim(),section:"figure",flags:{figure_only:true},grounded:null,char_span:null});
if(!pl.status)pl.status=S.schema.attributes.status[0].v;
saveField(uid,fid,pl,true);
};
box.appendChild(add);
return box;
}
const normQ=t=>t.replace(/\s+/g," ").trim().toLowerCase();
function locateQuote(ev){
if(S.layout){
const boxes=ev.pdf_anchor&&ev.pdf_anchor.boxes,ids=ev.pdf_anchor&&ev.pdf_anchor.blocks;
if(S.view==="pdf"&&boxes&&boxes.length){setHl(boxes,"ev");return}
if(S.view==="pdf"||S.view==="raw")setView("split");
const fq=normQ(ev.quote);
let el=ids&&ids.length?$(`#textpane .tb[data-b="${ids[0]}"]`):null;
if(!el)el=$$("#textpane .tb").find(b=>normQ(b.textContent).includes(fq))||null;
if(el){
$$("#textpane .tb.cur").forEach(x=>x.classList.remove("cur"));el.classList.add("cur");
el.scrollIntoView({block:"center",behavior:"smooth"});el.classList.add("flash");setTimeout(()=>el.classList.remove("flash"),2500);
}
const bx=boxes&&boxes.length?boxes:el?S.layout.blocks[+el.dataset.b].boxes:null;
if(bx)setHl(bx,"ev");
if(!el&&!bx)alert("Not found in the text or on the PDF. char_span (folded coords) = "+JSON.stringify(ev.char_span));
return;
}
if(S.view==="pdf")setView("text");
if(S.isMd){
const fq=ev.quote.replace(/\s+/g," ").trim().toLowerCase();
for(const b of $("#textpane").querySelectorAll("p,h2,h3,h4,h5,h6,tr")){
if(b.textContent.replace(/\s+/g," ").toLowerCase().includes(fq)){
b.scrollIntoView({block:"center",behavior:"smooth"});
b.classList.add("flash");setTimeout(()=>b.classList.remove("flash"),2500);
return;
}
}
setView("raw"); // 渲染视图找不到, 退到源文件视图按原逻辑高亮
}
const raw=S.rawText, q=ev.quote;
let i=raw.indexOf(q);
if(i<0){ /* whitespace-insensitive fallback */
const pat=q.trim().split(/\s+/).map(w=>w.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("\\s+");
const m=raw.match(new RegExp(pat));
if(m){renderText(m.index,m.index+m[0].length);return}
alert("Not found in the displayed text. char_span (folded coords) = "+JSON.stringify(ev.char_span));return;
}
renderText(i,i+q.length);
}
/* ---------------- save ---------------- */
function activate(uid,fid,el){
const prev=S.active;
S.active={uid,fid};
if(!prev||prev.uid!==uid||prev.fid!==fid){
$$(".field.active").forEach(x=>x.classList.remove("active"));
(el||document.querySelector(`.field[data-uid="${uid}"][data-fid="${fid}"]`))?.classList.add("active");
}
}
function saveExtra(rec){
api("/api/record",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({annotator:S.annotator,paper:S.paper,action:"extra",uid:rec.uid,extra:rec.extra})});
}
function saveField(uid,fid,pl,rerender){
const key=uid+"."+fid;
clearTimeout(S.saveTimers[key]);
S.pendingSaves=S.pendingSaves||{};
const run=S.pendingSaves[key]=async()=>{
delete S.saveTimers[key];delete S.pendingSaves[key];
const res=await api("/api/field",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({paper:S.paper,record_uid:uid,field_id:fid,payload:pl})});
S.fields[uid][fid]=res.payload;
updateProgress();
if(rerender)renderRight();
pollSync();
};
S.saveTimers[key]=setTimeout(run,rerender?0:600);
const chip=$("#h-sync");chip.classList.remove("ok","err");chip.classList.add("pending");chip.textContent="Saving…";
}
function updateProgress(){
let n=0;
Object.values(S.fields).forEach(m=>Object.values(m).forEach(p=>{
if(isFilled(p))n++;
}));
$("#h-progress").textContent=n+" fields annotated";
}
</script>
</body>
</html>