File size: 23,859 Bytes
a06b5b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794fa12
0b278ff
a06b5b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b278ff
794fa12
a06b5b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b278ff
 
 
 
794fa12
0b278ff
794fa12
 
 
 
0b278ff
 
 
 
 
 
a06b5b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794fa12
 
 
 
 
0b278ff
 
 
 
 
 
 
 
a06b5b2
 
 
 
0b278ff
a06b5b2
 
0b278ff
 
 
a06b5b2
 
0b278ff
794fa12
 
 
 
 
 
 
 
 
 
0b278ff
 
 
 
 
3bebca3
 
 
 
794fa12
0b278ff
 
a06b5b2
0b278ff
a06b5b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b278ff
 
 
 
 
 
 
 
 
a06b5b2
 
 
 
0b278ff
a06b5b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45b52e4
 
 
 
 
 
 
a06b5b2
 
 
 
 
 
 
 
 
 
 
45b52e4
 
 
 
 
 
 
a06b5b2
 
 
 
 
 
 
0b278ff
 
 
 
 
 
 
a06b5b2
 
 
 
 
0b278ff
 
 
 
 
 
a06b5b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
<!doctype html>
<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>
"use strict";
/* ---------------- 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>