Spaces:
Paused
Paused
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Interior Edit Study</title> | |
| <style> | |
| :root { | |
| --bg: #eceef1; --panel: #ffffff; --ink: #1c2128; --muted: #6b7280; | |
| --line: #d7dade; --accent: #2456b3; --accent-ink: #ffffff; | |
| --good: #1b7837; --warn: #b3541e; --chip: #f1f3f6; --chip-on: #2456b3; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html, body { height: 100%; } | |
| body { | |
| background: var(--bg); color: var(--ink); | |
| font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | |
| } | |
| .wrap { max-width: 1280px; margin: 0 auto; padding: 14px 18px 28px; } | |
| header { | |
| display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; | |
| padding: 6px 2px 12px; | |
| } | |
| header h1 { font-size: 17px; font-weight: 650; letter-spacing: .2px; } | |
| header .grow { flex: 1; } | |
| header .meta { color: var(--muted); font-size: 13px; } | |
| button { | |
| font: inherit; border: 1px solid var(--line); background: var(--panel); | |
| color: var(--ink); border-radius: 8px; padding: 7px 14px; cursor: pointer; | |
| } | |
| button:hover { border-color: #b9bec6; } | |
| button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } | |
| button.ghost { background: transparent; } | |
| button:disabled { opacity: .45; cursor: default; } | |
| .card { | |
| background: var(--panel); border: 1px solid var(--line); border-radius: 12px; | |
| padding: 18px 20px; | |
| } | |
| /* ---------- setup screen ---------- */ | |
| #setup { max-width: 720px; margin: 40px auto; } | |
| #setup h2 { font-size: 16px; margin-bottom: 10px; } | |
| #setup p, #setup li { color: #374151; font-size: 14px; margin-bottom: 8px; } | |
| #setup ol { padding-left: 20px; margin-bottom: 10px; } | |
| #setup .row { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; align-items: center; } | |
| #setup input, #setup select { | |
| font: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; | |
| background: #fff; color: var(--ink); | |
| } | |
| #setup input { width: 220px; } | |
| #resumeNote { color: var(--good); font-size: 13px; margin-top: 8px; display: none; } | |
| .err { color: #b42318; font-size: 13px; margin-top: 8px; } | |
| /* ---------- judge screen ---------- */ | |
| #judge { display: none; } | |
| .progressbar { height: 5px; background: #dfe2e7; border-radius: 3px; overflow: hidden; margin-bottom: 12px; } | |
| .progressbar > div { height: 100%; background: var(--accent); width: 0%; transition: width .15s; } | |
| .spec { display: flex; gap: 14px; margin-bottom: 12px; } | |
| .spec .imgbox { flex: 0 0 auto; } | |
| .spec .instr { flex: 1; min-width: 0; } | |
| .imgbox { position: relative; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background: #fff; } | |
| .imgbox img { display: block; width: 100%; height: 100%; object-fit: contain; user-select: none; -webkit-user-drag: none; } | |
| .imgbox .tag { | |
| position: absolute; top: 8px; left: 8px; font-size: 12px; font-weight: 650; | |
| background: rgba(28,33,40,.78); color: #fff; padding: 2px 9px; border-radius: 6px; | |
| } | |
| #specOverlay { width: 240px; height: 170px; cursor: zoom-in; } | |
| #specRef { width: 130px; height: 170px; cursor: zoom-in; } | |
| .instr h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); margin-bottom: 6px; } | |
| .instr .text { font-size: 15px; } | |
| .instr .chips { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; } | |
| .instr .chips span { | |
| font-size: 12px; background: var(--chip); border: 1px solid var(--line); | |
| padding: 2px 9px; border-radius: 999px; color: #374151; | |
| } | |
| .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } | |
| .cand { cursor: zoom-in; aspect-ratio: 4 / 3; } | |
| .cand.chosen { outline: 3px solid var(--accent); outline-offset: -1px; } | |
| .controls { display: flex; gap: 10px; justify-content: center; margin-top: 14px; flex-wrap: wrap; } | |
| .controls button { min-width: 170px; padding: 10px 16px; font-weight: 600; } | |
| .key { | |
| display: inline-block; font: 600 11px/1 ui-monospace, monospace; background: #eef0f3; | |
| border: 1px solid var(--line); border-radius: 5px; padding: 3px 6px; margin-right: 7px; | |
| color: #4b5563; vertical-align: 1px; | |
| } | |
| /* tag panel */ | |
| #tagPanel { display: none; margin-top: 14px; text-align: center; } | |
| #tagPanel .q { font-size: 14px; color: #374151; margin-bottom: 10px; } | |
| #tagPanel .chipsrow { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; } | |
| .tagchip { | |
| font-size: 13px; border: 1px solid var(--line); background: var(--chip); | |
| border-radius: 999px; padding: 7px 14px; cursor: pointer; user-select: none; | |
| } | |
| .tagchip.on { background: var(--chip-on); border-color: var(--chip-on); color: #fff; } | |
| #tagPanel .next { margin-top: 12px; } | |
| footer.hints { margin-top: 14px; color: var(--muted); font-size: 12.5px; text-align: center; } | |
| /* lightbox */ | |
| #lightbox { | |
| display: none; position: fixed; inset: 0; background: rgba(12,14,17,.93); z-index: 50; | |
| align-items: center; justify-content: center; flex-direction: column; gap: 10px; | |
| } | |
| #lightbox img { max-width: 96vw; max-height: 88vh; object-fit: contain; } | |
| #lightbox .cap { color: #d1d5db; font-size: 13px; } | |
| /* done screen */ | |
| #done { display: none; max-width: 640px; margin: 60px auto; text-align: center; } | |
| #done h2 { margin-bottom: 10px; } | |
| #done p { color: #374151; margin-bottom: 16px; } | |
| @media (max-width: 860px) { | |
| .spec { flex-wrap: wrap; } | |
| .pair { grid-template-columns: 1fr; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <header> | |
| <h1>Interior Edit Study</h1> | |
| <span class="meta" id="whoami"></span> | |
| <div class="grow"></div> | |
| <span class="meta" id="counter"></span> | |
| <button class="ghost" id="undoBtn" title="Undo last judgment (U)" style="display:none">Undo</button> | |
| <button class="ghost" id="exportBtn" style="display:none">Export JSONL</button> | |
| </header> | |
| <!-- ============ setup ============ --> | |
| <div id="setup" class="card"> | |
| <h2>Instructions</h2> | |
| <p>Each screen shows one <b>editing task</b> (a room photo with the target | |
| surface highlighted, a reference image of the target material, and the text | |
| instruction) and <b>two candidate edits</b>, A and B.</p> | |
| <ol> | |
| <li>Pick the candidate that <b>better replaces the material of the highlighted | |
| surface</b> while keeping <b>everything else</b> (the rest of the scene, the | |
| object's shape, the lighting) unchanged.</li> | |
| <li>If they are genuinely equally good or equally bad, choose | |
| <b>About equal</b>.</li> | |
| <li>After choosing a winner you may optionally tag what is wrong with the | |
| <i>other</i> image, then press <b>Enter</b> to continue (Enter alone skips | |
| the tags).</li> | |
| <li>Click any image (or press <b>Z</b>) to inspect it full-screen; | |
| press <b>Space</b> there to flip between A and B in place — the fastest way | |
| to spot unwanted scene changes. <b>S</b> shows the task highlight, Esc closes.</li> | |
| </ol> | |
| <p>Keys: <b>←</b> A better · <b>→</b> B better · <b>↓</b> about equal · | |
| <b>1–4</b> toggle tags · <b>Enter</b> next · <b>U</b> undo · <b>Z</b> zoom.</p> | |
| <p>Every judgment is saved automatically (in your browser and to the study | |
| server), so you can close the tab anytime and resume later by entering the | |
| same name. The <b>Export JSONL</b> button is only a backup; no need to use | |
| it in normal runs.</p> | |
| <div class="row"> | |
| <label>Your name / ID <input id="raterInput" placeholder="e.g. alice" autocomplete="off"></label> | |
| <label id="codeLabel" style="display:none">Study code <input id="codeInput" placeholder="4 digits" autocomplete="off" inputmode="numeric" style="width:110px"></label> | |
| <label id="shardLabel">Shard <select id="shardSel"></select></label> | |
| <button class="primary" id="startBtn">Start</button> | |
| </div> | |
| <div id="resumeNote"></div> | |
| <div class="err" id="setupErr"></div> | |
| </div> | |
| <!-- ============ judge ============ --> | |
| <div id="judge"> | |
| <div class="progressbar"><div id="pbar"></div></div> | |
| <div class="card"> | |
| <div class="spec"> | |
| <div class="imgbox" id="specOverlay"><span class="tag">Task · target highlighted</span><img id="specOverlayImg" alt="scene with target region highlighted"></div> | |
| <div class="imgbox" id="specRef"><span class="tag">Material</span><img id="specRefImg" alt="material reference"></div> | |
| <div class="instr"> | |
| <h3>Instruction</h3> | |
| <div class="text" id="instrText"></div> | |
| <div class="chips" id="instrChips"></div> | |
| </div> | |
| </div> | |
| <div class="pair"> | |
| <div class="imgbox cand" id="candA"><span class="tag">A</span><img id="candAImg" alt="candidate A"></div> | |
| <div class="imgbox cand" id="candB"><span class="tag">B</span><img id="candBImg" alt="candidate B"></div> | |
| </div> | |
| <div class="controls" id="choiceRow"> | |
| <button id="pickA"><span class="key">←</span>A is better</button> | |
| <button id="pickTie"><span class="key">↓</span>About equal</button> | |
| <button id="pickB"><span class="key">→</span>B is better</button> | |
| </div> | |
| <div id="tagPanel"> | |
| <div class="q">Optional — what is wrong with <b id="loserName">the other image</b>?</div> | |
| <div class="chipsrow"> | |
| <span class="tagchip" data-tag="wrong-material"><b>1</b> wrong material</span> | |
| <span class="tagchip" data-tag="scene-changed"><b>2</b> scene changed outside</span> | |
| <span class="tagchip" data-tag="geometry-changed"><b>3</b> object shape changed</span> | |
| <span class="tagchip" data-tag="lighting-off"><b>4</b> lighting / blending off</span> | |
| </div> | |
| <div class="next"><button class="primary" id="nextBtn"><span class="key" style="background:rgba(255,255,255,.2);border-color:transparent;color:#fff">Enter</span>Next</button></div> | |
| </div> | |
| </div> | |
| <footer class="hints">← A · → B · ↓ equal · 1–4 tags · Enter next · U undo · Z / click zoom (Space flips A/B, S task) </footer> | |
| </div> | |
| <!-- ============ done ============ --> | |
| <div id="done" class="card"> | |
| <h2>All done — thank you!</h2> | |
| <p id="doneStats"></p> | |
| <button class="primary" id="exportBtn2">Export JSONL</button> | |
| </div> | |
| </div> | |
| <div id="lightbox"><img id="lbImg" alt=""><div class="cap" id="lbCap"></div></div> | |
| <script> | |
| ; | |
| /* ---------------- utilities ---------------- */ | |
| function h32(s){let h=2166136261;for(const c of s){h^=c.codePointAt(0);h=Math.imul(h,16777619);}return h>>>0;} | |
| function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};} | |
| function shuffled(arr,rng){const a=arr.slice();for(let i=a.length-1;i>0;i--){const j=Math.floor(rng()*(i+1));[a[i],a[j]]=[a[j],a[i]];}return a;} | |
| const $=id=>document.getElementById(id); | |
| /* ---------------- state ---------------- */ | |
| let MANIFEST=null, MHASH="", PAIRS=[], RECORDS=[], IDX=0; | |
| let RATER="", SHARD=1, NSHARDS=1; | |
| let MODE="local"; // "server": backend assigns a global work queue | |
| let CURRENT=null, SEEN={}, SRV={judged:0,total:0}, CODE="", GATED=false; | |
| let pending=null; // {chosenSys, loserSys, loserLabel, tie, t0} | |
| let shownAt=0, leftIsA=true; // leftIsA: sys_a shown on the left? | |
| let lbState=-1; // lightbox: 0=A 1=B 2=spec, -1 closed | |
| const TAGS=["wrong-material","scene-changed","geometry-changed","lighting-off"]; | |
| /* ---------------- boot ---------------- */ | |
| fetch("manifest.json").then(r=>{ | |
| if(!r.ok) throw new Error("HTTP "+r.status); | |
| return r.json(); | |
| }).then(m=>{ | |
| MANIFEST=m; | |
| const ps=m.pairs||[]; | |
| if(!ps.length) throw new Error("manifest has no pairs"); | |
| MHASH=String(h32(`${m.version}|${ps.length}|${ps[0].pair_id}|${ps[ps.length-1].pair_id}`)); | |
| NSHARDS=m.n_shards||1; | |
| const sel=$("shardSel"); | |
| for(let i=1;i<=NSHARDS;i++){const o=document.createElement("option");o.value=i;o.textContent=`${i} of ${NSHARDS}`;sel.appendChild(o);} | |
| $("raterInput").addEventListener("input",showResumeNote); | |
| sel.addEventListener("change",showResumeNote); | |
| // does the backend offer a shared work queue? | |
| return fetch("next?peek=1").then(r=>r.ok?r.json():null).catch(()=>null); | |
| }).then(pk=>{ | |
| if(pk&&pk.total){ | |
| MODE="server";SRV={judged:pk.judged,total:pk.total};GATED=!!pk.gated; | |
| $("shardLabel").style.display="none"; | |
| if(GATED){ | |
| $("codeLabel").style.display=""; | |
| $("codeInput").value=localStorage.getItem("he_code")||""; | |
| } | |
| const note=document.createElement("p"); | |
| note.textContent=`Judgments are assigned automatically from a shared pool; `+ | |
| `${pk.judged} of ${pk.total} are already done. Stop whenever you like — `+ | |
| `every single judgment helps.`; | |
| $("setup").insertBefore(note,$("setup").querySelector(".row")); | |
| } | |
| }).catch(e=>{ | |
| $("setupErr").textContent="Could not load manifest.json — serve this folder over HTTP, e.g. python3 -m http.server ("+e.message+")"; | |
| }); | |
| function storeKey(){return `matreplace_he_${MHASH}_${RATER}_${SHARD}`;} | |
| function peekKey(r,s){return `matreplace_he_${MHASH}_${r}_${s}`;} | |
| function showResumeNote(){ | |
| const r=$("raterInput").value.trim(), s=+$("shardSel").value||1; | |
| const note=$("resumeNote"); | |
| if(!r||!MANIFEST){note.style.display="none";return;} | |
| const saved=localStorage.getItem(peekKey(r,s)); | |
| if(saved){const n=JSON.parse(saved).records.length;note.textContent=`Found saved progress for "${r}" (shard ${s}): ${n} judgments — you will resume where you left off.`;note.style.display="block";} | |
| else note.style.display="none"; | |
| } | |
| $("startBtn").addEventListener("click",()=>{ | |
| if(!MANIFEST) return; | |
| RATER=$("raterInput").value.trim(); | |
| if(!RATER){$("setupErr").textContent="Please enter your name / ID.";return;} | |
| if(MODE==="server"&&GATED){ | |
| CODE=$("codeInput").value.trim(); | |
| if(!CODE){$("setupErr").textContent="Please enter the study code.";return;} | |
| localStorage.setItem("he_code",CODE); | |
| } | |
| SHARD=MODE==="server"?0:(+$("shardSel").value||1); | |
| if(MODE==="local"){ | |
| // shard split: stable shuffle shared by all raters, then modulo partition | |
| const base=shuffled(MANIFEST.pairs, mulberry32(h32("shard-order|"+MHASH))); | |
| const mine=base.filter((_,i)=>i%NSHARDS===(SHARD-1)); | |
| // per-rater presentation order | |
| PAIRS=shuffled(mine, mulberry32(h32("order|"+RATER))); | |
| } | |
| const saved=localStorage.getItem(storeKey()); | |
| RECORDS=saved?JSON.parse(saved).records:[]; | |
| IDX=RECORDS.length; | |
| $("setup").style.display="none"; | |
| $("whoami").textContent=MODE==="server"?RATER:`${RATER} · shard ${SHARD}/${NSHARDS}`; | |
| $("undoBtn").style.display=""; | |
| $("exportBtn").style.display=""; | |
| if(MODE==="server"){fetchNext();} | |
| else if(IDX>=PAIRS.length) showDone(); | |
| else {$("judge").style.display="block";render();} | |
| }); | |
| function fetchNext(){ | |
| fetch("next?rater="+encodeURIComponent(RATER)+"&code="+encodeURIComponent(CODE)) | |
| .then(r=>{ | |
| if(r.status===403){ // wrong code: back to setup with a message | |
| $("judge").style.display="none";$("done").style.display="none"; | |
| $("setup").style.display="block"; | |
| $("setupErr").textContent="Wrong study code — please check it and press Start again."; | |
| throw new Error("gate"); | |
| } | |
| return r.json(); | |
| }).then(res=>{ | |
| SRV={judged:res.judged,total:res.total}; | |
| if(res.done){showDone();return;} | |
| CURRENT=res.pair;SEEN[res.pair.pair_id]=res.pair; | |
| $("done").style.display="none";$("judge").style.display="block"; | |
| render(); | |
| if(res.prefetch){ // warm the browser cache for the pair we'll get next | |
| [res.prefetch.img_a,res.prefetch.img_b,res.prefetch.spec.overlay, | |
| res.prefetch.spec.reference].forEach(u=>{const im=new Image();im.src=u;}); | |
| } | |
| }).catch(e=>{if(e.message!=="gate")setTimeout(fetchNext,1500);}); | |
| } | |
| /* ---------------- rendering ---------------- */ | |
| function cur(){return MODE==="server"?CURRENT:PAIRS[IDX];} | |
| function sysOnSide(side){ // side: "A"(left) or "B"(right) -> system id | |
| const p=cur(); | |
| return (side==="A")===leftIsA ? p.sys_a : p.sys_b; | |
| } | |
| function imgOnSide(side){ | |
| const p=cur(); | |
| return (side==="A")===leftIsA ? p.img_a : p.img_b; | |
| } | |
| function render(){ | |
| const p=cur(); | |
| leftIsA=h32(RATER+"|"+p.pair_id)%2===0; | |
| $("specOverlayImg").src=p.spec.overlay; | |
| $("specRefImg").src=p.spec.reference; | |
| $("instrText").textContent=p.spec.instruction||""; | |
| const chips=$("instrChips");chips.innerHTML=""; | |
| for(const t of [p.spec.material,p.spec.surface].filter(Boolean)){ | |
| const s=document.createElement("span");s.textContent=t;chips.appendChild(s); | |
| } | |
| $("candAImg").src=imgOnSide("A"); | |
| $("candBImg").src=imgOnSide("B"); | |
| $("candA").classList.remove("chosen");$("candB").classList.remove("chosen"); | |
| $("tagPanel").style.display="none";$("choiceRow").style.display="flex"; | |
| document.querySelectorAll(".tagchip").forEach(c=>c.classList.remove("on")); | |
| pending=null; shownAt=performance.now(); | |
| if(MODE==="server"){ | |
| $("counter").textContent=`pool: ${SRV.judged} / ${SRV.total} done · you: ${RECORDS.length}`; | |
| $("pbar").style.width=(100*SRV.judged/SRV.total)+"%"; | |
| }else{ | |
| $("counter").textContent=`${IDX+1} / ${PAIRS.length}`; | |
| $("pbar").style.width=(100*IDX/PAIRS.length)+"%"; | |
| const nx=PAIRS[IDX+1]; | |
| if(nx) [nx.img_a,nx.img_b,nx.spec.overlay].forEach(u=>{const im=new Image();im.src=u;}); | |
| } | |
| } | |
| /* ---------------- choosing ---------------- */ | |
| function choose(side){ // "A" | "B" | "tie" | |
| if(pending||!cur()) return; | |
| const ms=Math.round(performance.now()-shownAt); | |
| if(side==="tie"){commit({chosen:"tie",loser:null,tags:[],ms});return;} | |
| const chosen=sysOnSide(side), loser=sysOnSide(side==="A"?"B":"A"); | |
| pending={chosen,loser,ms,loserLabel:side==="A"?"B":"A"}; | |
| $("candA").classList.toggle("chosen",side==="A"); | |
| $("candB").classList.toggle("chosen",side==="B"); | |
| $("choiceRow").style.display="none"; | |
| $("loserName").textContent="image "+pending.loserLabel; | |
| $("tagPanel").style.display="block"; | |
| } | |
| function commitPending(){ | |
| if(!pending) return; | |
| const tags=[...document.querySelectorAll(".tagchip.on")].map(c=>c.dataset.tag); | |
| commit({chosen:pending.chosen,loser:pending.loser,tags,ms:pending.ms}); | |
| } | |
| function postToServer(obj){ // resolves AFTER the server confirms (one retry); | |
| // callers must await it before asking for the next pair, otherwise /next can | |
| // race ahead of /submit and re-serve the pair that was just judged. | |
| const send=()=>fetch("submit?code="+encodeURIComponent(CODE),{method:"POST", | |
| headers:{"Content-Type":"application/json"},body:JSON.stringify(obj)}) | |
| .then(r=>{if(!r.ok)throw new Error("submit "+r.status);}); | |
| return send().catch(()=>send()).catch(()=>{}); | |
| } | |
| function commit({chosen,loser,tags,ms}){ | |
| const p=cur(); | |
| const rec={ | |
| rater:RATER,shard:SHARD,pair_id:p.pair_id,task_uid:p.task_uid, | |
| sys_a:p.sys_a,sys_b:p.sys_b,left_sys:leftIsA?p.sys_a:p.sys_b, | |
| chosen_sys:chosen,tags:tags,ms:ms,ord:IDX,ts:new Date().toISOString() | |
| }; | |
| RECORDS.push(rec); | |
| localStorage.setItem(storeKey(),JSON.stringify({records:RECORDS})); | |
| IDX++; | |
| if(MODE==="server"){ | |
| SRV.judged++; | |
| postToServer(rec).then(fetchNext); // strictly after the server has recorded it | |
| }else{ | |
| postToServer(rec); | |
| if(IDX>=PAIRS.length) showDone(); else render(); | |
| } | |
| } | |
| function undo(){ | |
| if(!RECORDS.length) return; | |
| if($("done").style.display==="block"){$("done").style.display="none";$("judge").style.display="block";} | |
| const dropped=RECORDS.pop(); | |
| localStorage.setItem(storeKey(),JSON.stringify({records:RECORDS})); | |
| postToServer({rater:RATER,shard:SHARD,pair_id:dropped.pair_id,retract:true,ts:new Date().toISOString()}); | |
| if(MODE==="server"){ | |
| SRV.judged=Math.max(0,SRV.judged-1); | |
| CURRENT=SEEN[dropped.pair_id]||CURRENT; | |
| render(); | |
| }else{ | |
| IDX=RECORDS.length; render(); | |
| } | |
| } | |
| function showDone(){ | |
| $("judge").style.display="none";$("done").style.display="block"; | |
| const times=RECORDS.map(r=>r.ms).sort((a,b)=>a-b); | |
| const med=times.length?times[Math.floor(times.length/2)]:0; | |
| const medTxt=times.length?` · median ${(med/1000).toFixed(1)}s each`:""; | |
| $("doneStats").textContent=MODE==="server" | |
| ?`The whole pool is complete (${SRV.total} comparisons) — you contributed ${RECORDS.length}${medTxt}. Everything was uploaded automatically. Thank you!` | |
| :`${RECORDS.length} judgments${medTxt}. Everything was uploaded automatically — you're done!`; | |
| $("pbar").style.width="100%"; | |
| $("counter").textContent=MODE==="server"?`pool: ${SRV.total} / ${SRV.total}`:`${PAIRS.length} / ${PAIRS.length}`; | |
| } | |
| /* ---------------- export ---------------- */ | |
| function exportJsonl(){ | |
| if(!RECORDS.length) return; | |
| const blob=new Blob([RECORDS.map(r=>JSON.stringify(r)).join("\n")+"\n"],{type:"application/x-ndjson"}); | |
| const a=document.createElement("a"); | |
| a.href=URL.createObjectURL(blob); | |
| a.download=`judgments_${RATER}_s${SHARD}_${new Date().toISOString().slice(0,10)}.jsonl`; | |
| a.click();URL.revokeObjectURL(a.href); | |
| exported=true; | |
| } | |
| let exported=false; | |
| window.addEventListener("beforeunload",e=>{ | |
| if(RECORDS.length&&!exported){e.preventDefault();e.returnValue="";} | |
| }); | |
| /* ---------------- lightbox ---------------- */ | |
| function lbOpen(which){lbState=which;lbShow();$("lightbox").style.display="flex";} | |
| function lbShow(){ | |
| const src=lbState===0?imgOnSide("A"):lbState===1?imgOnSide("B"):cur().spec.overlay; | |
| $("lbImg").src=src; | |
| $("lbCap").textContent=lbState===2?"Task (target highlighted) — Space: back to A/B, Esc: close" | |
| :`Candidate ${lbState===0?"A":"B"} — Space: flip A/B, S: task, Esc: close`; | |
| } | |
| function lbClose(){lbState=-1;$("lightbox").style.display="none";} | |
| $("lightbox").addEventListener("click",lbClose); | |
| /* ---------------- events ---------------- */ | |
| $("pickA").addEventListener("click",()=>choose("A")); | |
| $("pickB").addEventListener("click",()=>choose("B")); | |
| $("pickTie").addEventListener("click",()=>choose("tie")); | |
| $("nextBtn").addEventListener("click",commitPending); | |
| $("undoBtn").addEventListener("click",undo); | |
| $("exportBtn").addEventListener("click",exportJsonl); | |
| $("exportBtn2").addEventListener("click",exportJsonl); | |
| $("candA").addEventListener("click",()=>lbOpen(0)); | |
| $("candB").addEventListener("click",()=>lbOpen(1)); | |
| $("specOverlay").addEventListener("click",()=>lbOpen(2)); | |
| $("specRef").addEventListener("click",()=>lbOpen(2)); | |
| document.querySelectorAll(".tagchip").forEach(c=>c.addEventListener("click",()=>c.classList.toggle("on"))); | |
| document.addEventListener("keydown",e=>{ | |
| if($("setup").style.display!=="none"&&$("judge").style.display!=="block") { | |
| if(e.key==="Enter"&&document.activeElement===$("raterInput"))$("startBtn").click(); | |
| return; | |
| } | |
| if(lbState>=0){ | |
| if(e.key===" "){e.preventDefault();lbState=lbState===0?1:0;lbShow();} | |
| else if(e.key==="s"||e.key==="S"){lbState=2;lbShow();} | |
| else if(e.key==="Escape"){lbClose();} | |
| return; | |
| } | |
| if($("done").style.display==="block"){if(e.key==="u"||e.key==="U")undo();return;} | |
| if(pending){ | |
| if(e.key==="Enter"||e.key===" "){e.preventDefault();commitPending();} | |
| else if(e.key>="1"&&e.key<="4"){ | |
| const chip=document.querySelectorAll(".tagchip")[+e.key-1];chip.classList.toggle("on"); | |
| } | |
| else if(e.key==="u"||e.key==="U"){ // cancel the pending choice | |
| pending=null;$("tagPanel").style.display="none";$("choiceRow").style.display="flex"; | |
| $("candA").classList.remove("chosen");$("candB").classList.remove("chosen"); | |
| document.querySelectorAll(".tagchip").forEach(c=>c.classList.remove("on")); | |
| } | |
| return; | |
| } | |
| switch(e.key){ | |
| case "ArrowLeft": e.preventDefault();choose("A");break; | |
| case "ArrowRight": e.preventDefault();choose("B");break; | |
| case "ArrowDown": e.preventDefault();choose("tie");break; | |
| case "u": case "U": undo();break; | |
| case "z": case "Z": lbOpen(0);break; | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |