pacuaviva/image-composition-human-eval
0
1"use strict";2 3const state = {4 manifest: null,5 mode: "main",6 raterId: null,7 index: 0,8 responses: new Map(),9 itemStartedAt: null,10 startedAt: null,11 tutorialVersion: 0,12 autosaveTimer: null,13 saveQueue: Promise.resolve(),14 saveRequestId: 0,15 editRevision: 0,16};17 18const participantStorageKey = "natural_composition_human_v2.participant_code";19const tutorialVersion = 1;20const autosaveDelayMs = 500;21const svgNamespace = "http://www.w3.org/2000/svg";22const hostedApiUrl = String(window.HUMAN_EVAL_API_URL || "").replace(/\/+$/, "");23 24const dimensions = [25 "edit_a_applied",26 "edit_b_applied",27 "composition_success",28 "source_preservation",29 "absence_unintended_changes",30 "visual_realism",31 "overall",32];33 34const tourState = {35 active: false,36 required: false,37 index: 0,38 previousFocus: null,39 layoutFrame: null,40 busy: false,41};42 43const tourSteps = [44 {45 title: "Edits",46 description: "The two Source-Target pairs demonstrate the transformations the model should compose.",47 targets: [48 {49 label: "A",50 selectors: ["[data-tour-target='edit-a-source']", "[data-tour-target='edit-a-target']"],51 },52 {53 label: "B",54 selectors: ["[data-tour-target='edit-b-source']", "[data-tour-target='edit-b-target']"],55 },56 ],57 callouts: [58 {label: "Edit A", text: "Compare A Source with A Target to infer the first transformation."},59 {label: "Edit B", text: "Compare B Source with B Target to infer the second transformation."},60 ],61 placement: ["right", "bottom", "left", "top"],62 },63 {64 title: "Composition",65 description: "Query is the new image to edit. Output is the model's attempt to compose both demonstrated transformations into a single result.",66 targets: [67 {label: "Query", selectors: ["[data-tour-target='query']"]},68 {label: "Output", selectors: ["[data-tour-target='output']"]},69 ],70 placement: ["right", "top", "left", "bottom"],71 },72 {73 title: "Ratings",74 description: "Rate each criterion independently from 1 to 5. The labels below every scale explain what 1, 3, and 5 mean; use 2 or 4 for an intermediate judgment.",75 targets: [76 {label: "Ratings", selectors: [".questions"]},77 ],78 placement: ["left", "bottom", "top", "right"],79 },80 {81 title: "Unclear demonstrations",82 description: "Mark A or B as unclear only when you cannot understand the intended edit from that Source-Target pair.",83 targets: [84 {label: "Unclear", selectors: [".unclear-row"]},85 ],86 placement: ["left", "bottom", "top", "right"],87 },88 {89 title: "Navigation",90 description: "Save and continue when an item is complete. You may also skip it or save and exit, then resume later with the same participant code.",91 targets: [92 {label: "Continue", selectors: [".form-footer"]},93 ],94 placement: ["left", "top", "bottom", "right"],95 },96 {97 title: "Guide",98 description: "Use Guide whenever you want to see this overview again. Replaying it will not change your ratings or move you to another item.",99 targets: [100 {label: "Guide", selectors: ["#guide-button"]},101 ],102 placement: ["left", "bottom", "top", "right"],103 },104];105 106function element(id) { return document.getElementById(id); }107 108function setGuideVisible(visible) {109 element("guide-button").hidden = !visible;110}111 112function nextAnimationFrame() {113 return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));114}115 116async function waitForStudyImages() {117 const images = ["a-source", "a-target", "b-source", "b-target", "query", "output"]118 .map(element);119 const loaded = Promise.all(images.map(image => {120 if (image.complete) return Promise.resolve();121 return new Promise(resolve => {122 image.addEventListener("load", resolve, {once: true});123 image.addEventListener("error", resolve, {once: true});124 });125 }));126 await Promise.race([loaded, new Promise(resolve => setTimeout(resolve, 2500))]);127 await nextAnimationFrame();128}129 130function tourElementsForTarget(target) {131 const found = [];132 for (const selector of target.selectors) {133 found.push(...document.querySelectorAll(selector));134 }135 return found.filter(node => !node.hidden);136}137 138function tourTargetRect(target) {139 const rects = tourElementsForTarget(target).map(node => node.getBoundingClientRect());140 if (!rects.length) throw new Error(`Guide target not found: ${target.label || target.selectors[0]}`);141 const padding = 6;142 const left = Math.max(4, Math.min(...rects.map(rect => rect.left)) - padding);143 const top = Math.max(4, Math.min(...rects.map(rect => rect.top)) - padding);144 const right = Math.min(window.innerWidth - 4, Math.max(...rects.map(rect => rect.right)) + padding);145 const bottom = Math.min(window.innerHeight - 4, Math.max(...rects.map(rect => rect.bottom)) + padding);146 return {left, top, right, bottom, width: right - left, height: bottom - top};147}148 149function unionTourRects(rects) {150 const left = Math.min(...rects.map(rect => rect.left));151 const top = Math.min(...rects.map(rect => rect.top));152 const right = Math.max(...rects.map(rect => rect.right));153 const bottom = Math.max(...rects.map(rect => rect.bottom));154 return {left, top, right, bottom, width: right - left, height: bottom - top};155}156 157function makeSvgRect(attributes) {158 const rect = document.createElementNS(svgNamespace, "rect");159 for (const [name, value] of Object.entries(attributes)) rect.setAttribute(name, value);160 return rect;161}162 163function renderTourSpotlights(targets) {164 const width = window.innerWidth;165 const height = window.innerHeight;166 const spotlight = element("tour-spotlight");167 spotlight.setAttribute("viewBox", `0 0 ${width} ${height}`);168 for (const id of ["tour-mask-base", "tour-shade"]) {169 element(id).setAttribute("width", width);170 element(id).setAttribute("height", height);171 }172 173 const holes = element("tour-mask-holes");174 const outlines = element("tour-outlines");175 holes.replaceChildren();176 outlines.replaceChildren();177 178 const rects = targets.map(tourTargetRect);179 rects.forEach((rect, index) => {180 holes.append(makeSvgRect({181 x: rect.left,182 y: rect.top,183 width: rect.width,184 height: rect.height,185 rx: 6,186 fill: "black",187 }));188 outlines.append(makeSvgRect({189 x: rect.left,190 y: rect.top,191 width: rect.width,192 height: rect.height,193 rx: 6,194 fill: "none",195 stroke: "#58b99a",196 "stroke-width": 3,197 }));198 });199 return rects;200}201 202function popoverCandidate(position, target, width, height) {203 const gap = 16;204 if (position === "right") {205 return {left: target.right + gap, top: target.top + (target.height - height) / 2};206 }207 if (position === "left") {208 return {left: target.left - width - gap, top: target.top + (target.height - height) / 2};209 }210 if (position === "top") {211 return {left: target.left + (target.width - width) / 2, top: target.top - height - gap};212 }213 return {left: target.left + (target.width - width) / 2, top: target.bottom + gap};214}215 216function positionTourPopover(rects, placement) {217 const popover = element("tour-popover");218 const margin = 14;219 const width = popover.offsetWidth;220 const height = popover.offsetHeight;221 const target = unionTourRects(rects);222 let candidate = null;223 for (const position of placement) {224 const proposed = popoverCandidate(position, target, width, height);225 const fits = (226 proposed.left >= margin227 && proposed.top >= margin228 && proposed.left + width <= window.innerWidth - margin229 && proposed.top + height <= window.innerHeight - margin230 );231 if (fits) {232 candidate = proposed;233 break;234 }235 }236 if (!candidate) candidate = popoverCandidate(placement[0], target, width, height);237 const left = Math.min(Math.max(candidate.left, margin), window.innerWidth - width - margin);238 const top = Math.min(Math.max(candidate.top, margin), window.innerHeight - height - margin);239 popover.style.left = `${Math.round(left)}px`;240 popover.style.top = `${Math.round(top)}px`;241}242 243function updateTourLayout() {244 if (!tourState.active) return;245 const step = tourSteps[tourState.index];246 try {247 const rects = renderTourSpotlights(step.targets);248 positionTourPopover(rects, step.placement);249 element("tour-error").textContent = "";250 } catch (error) {251 element("tour-error").textContent = error.message;252 }253}254 255function scheduleTourLayout() {256 if (!tourState.active || tourState.layoutFrame !== null) return;257 tourState.layoutFrame = requestAnimationFrame(() => {258 tourState.layoutFrame = null;259 updateTourLayout();260 });261}262 263function centerTourTargetsOnSmallScreen(step) {264 if (window.innerWidth > 900) return;265 const elements = step.targets.flatMap(tourElementsForTarget);266 if (!elements.length) return;267 const rects = elements.map(node => node.getBoundingClientRect());268 const documentTop = window.scrollY + Math.min(...rects.map(rect => rect.top));269 const documentBottom = window.scrollY + Math.max(...rects.map(rect => rect.bottom));270 const targetScroll = (documentTop + documentBottom) / 2 - window.innerHeight / 2;271 window.scrollTo({top: Math.max(0, targetScroll), behavior: "auto"});272}273 274function renderTourDescription(step) {275 const root = element("tour-description");276 root.replaceChildren();277 const description = document.createElement("p");278 description.textContent = step.description;279 root.append(description);280 if (step.callouts?.length) {281 const callouts = document.createElement("div");282 callouts.className = "tour-callouts";283 for (const item of step.callouts) {284 const callout = document.createElement("div");285 callout.className = "tour-callout";286 const label = document.createElement("strong");287 label.textContent = item.label;288 const text = document.createElement("span");289 text.textContent = item.text;290 callout.append(label, text);291 callouts.append(callout);292 }293 root.append(callouts);294 }295}296 297function renderTourStep() {298 const step = tourSteps[tourState.index];299 element("tour-progress").textContent = `Guide ${tourState.index + 1} of ${tourSteps.length}`;300 element("tour-title").textContent = step.title;301 renderTourDescription(step);302 element("tour-back-button").disabled = tourState.index === 0;303 const isLast = tourState.index === tourSteps.length - 1;304 element("tour-next-button").textContent = isLast305 ? (tourState.required ? "Finish tutorial" : "Done")306 : "Next";307 element("tour-close-button").hidden = tourState.required;308 element("tour-error").textContent = "";309 centerTourTargetsOnSmallScreen(step);310 updateTourLayout();311 element("tour-popover").focus({preventScroll: true});312}313 314function startTour({required = false} = {}) {315 if (tourState.active) return;316 tourState.active = true;317 tourState.required = required;318 tourState.index = 0;319 tourState.busy = false;320 tourState.previousFocus = document.activeElement;321 element("study-view").inert = true;322 element("tour-overlay").hidden = false;323 element("tour-overlay").setAttribute("aria-hidden", "false");324 document.body.classList.add("tour-open");325 renderTourStep();326}327 328function endTour() {329 if (!tourState.active) return;330 tourState.active = false;331 if (tourState.layoutFrame !== null) cancelAnimationFrame(tourState.layoutFrame);332 tourState.layoutFrame = null;333 element("study-view").inert = false;334 element("tour-overlay").hidden = true;335 element("tour-overlay").setAttribute("aria-hidden", "true");336 document.body.classList.remove("tour-open");337 if (tourState.previousFocus?.isConnected && tourState.previousFocus.getClientRects().length) {338 tourState.previousFocus.focus({preventScroll: true});339 } else {340 element("guide-button").focus({preventScroll: true});341 }342}343 344async function advanceTour() {345 if (tourState.busy) return;346 if (tourState.index < tourSteps.length - 1) {347 tourState.index += 1;348 renderTourStep();349 return;350 }351 if (!tourState.required) {352 endTour();353 return;354 }355 const nextButton = element("tour-next-button");356 tourState.busy = true;357 nextButton.disabled = true;358 element("tour-error").textContent = "";359 state.tutorialVersion = tutorialVersion;360 try {361 await save(false);362 endTour();363 state.itemStartedAt = performance.now();364 } catch (error) {365 state.tutorialVersion = 0;366 element("tour-error").textContent = `Could not save tutorial progress: ${error.message}`;367 } finally {368 tourState.busy = false;369 nextButton.disabled = false;370 }371}372 373function retreatTour() {374 if (tourState.busy || tourState.index === 0) return;375 tourState.index -= 1;376 renderTourStep();377}378 379async function replayTour() {380 await waitForStudyImages();381 startTour({required: false});382}383 384function handleTourKeydown(event) {385 if (!tourState.active) return;386 if (event.key === "Escape" && !tourState.required) {387 event.preventDefault();388 endTour();389 return;390 }391 if (event.key === "ArrowRight") {392 event.preventDefault();393 advanceTour();394 return;395 }396 if (event.key === "ArrowLeft") {397 event.preventDefault();398 retreatTour();399 return;400 }401 if (event.key !== "Tab") return;402 const focusable = [...element("tour-popover").querySelectorAll("button:not([hidden]):not(:disabled)")];403 if (!focusable.length) return;404 const first = focusable[0];405 const last = focusable[focusable.length - 1];406 if (event.shiftKey && document.activeElement === first) {407 event.preventDefault();408 last.focus();409 } else if (!event.shiftKey && document.activeElement === last) {410 event.preventDefault();411 first.focus();412 }413}414 415function generateParticipantCode() {416 const bytes = new Uint8Array(16);417 crypto.getRandomValues(bytes);418 const suffix = Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("");419 return `participant_${suffix}`;420}421 422function storedParticipantCode() {423 try {424 return localStorage.getItem(participantStorageKey);425 } catch (_) {426 return null;427 }428}429 430function rememberParticipantCode(code) {431 try {432 localStorage.setItem(participantStorageKey, code);433 } catch (_) {434 // The code remains usable for this session when browser storage is unavailable.435 }436}437 438function participantProgressKey(code) {439 return `${participantStorageKey}.progress.${code}`;440}441 442function participantDraftsKey(code) {443 return `${participantStorageKey}.drafts.${code}`;444}445 446function storedParticipantProgress(code) {447 try {448 const serialized = localStorage.getItem(participantProgressKey(code));449 return serialized ? JSON.parse(serialized) : null;450 } catch (_) {451 return null;452 }453}454 455function rememberParticipantProgress(responsePayload) {456 try {457 localStorage.setItem(458 participantProgressKey(responsePayload.rater_id),459 JSON.stringify(responsePayload),460 );461 } catch (_) {462 // Remote persistence still succeeds if browser storage is unavailable.463 }464}465 466function storedParticipantDrafts(code) {467 try {468 const serialized = localStorage.getItem(participantDraftsKey(code));469 return serialized ? JSON.parse(serialized) : {};470 } catch (_) {471 return {};472 }473}474 475function writeParticipantDrafts(code, drafts) {476 try {477 if (Object.keys(drafts).length) {478 localStorage.setItem(participantDraftsKey(code), JSON.stringify(drafts));479 } else {480 localStorage.removeItem(participantDraftsKey(code));481 }482 } catch (_) {483 // Remote autosave remains available when browser storage is unavailable.484 }485}486 487function currentFormDraft() {488 const ratings = {};489 for (const dimension of dimensions) {490 const selected = document.querySelector(`input[name="${dimension}"]:checked`);491 ratings[dimension] = selected ? Number(selected.value) : null;492 }493 return {494 blind_id: currentItem().blind_id,495 ratings,496 unclear: {497 edit_a: element("unclear-a").checked,498 edit_b: element("unclear-b").checked,499 },500 comment: element("item-comment").value.trim(),501 };502}503 504function rememberCurrentDraft() {505 if (!state.manifest || !state.raterId) return;506 const draft = currentFormDraft();507 const drafts = storedParticipantDrafts(state.raterId);508 drafts[draft.blind_id] = draft;509 writeParticipantDrafts(state.raterId, drafts);510 state.editRevision += 1;511 element("save-status").textContent = "Draft saved";512}513 514function draftMatchesResponse(draft, response) {515 return JSON.stringify({516 ratings: draft.ratings,517 unclear: draft.unclear,518 comment: draft.comment,519 }) === JSON.stringify({520 ratings: response.ratings,521 unclear: response.unclear,522 comment: response.comment,523 });524}525 526function forgetPersistedDrafts(responsePayload) {527 const drafts = storedParticipantDrafts(responsePayload.rater_id);528 let changed = false;529 for (const response of responsePayload.responses) {530 const draft = drafts[response.blind_id];531 if (draft && draftMatchesResponse(draft, response)) {532 delete drafts[response.blind_id];533 changed = true;534 }535 }536 if (changed) writeParticipantDrafts(responsePayload.rater_id, drafts);537}538 539function initializeParticipantCode() {540 const code = storedParticipantCode() || generateParticipantCode();541 element("rater-code").value = code;542 rememberParticipantCode(code);543}544 545function replaceParticipantCode() {546 const code = generateParticipantCode();547 element("rater-code").value = code;548 rememberParticipantCode(code);549 element("participant-status").textContent = "A new participant code was created.";550 element("start-error").textContent = "";551}552 553function createQuestions() {554 const root = element("questions");555 root.replaceChildren();556 for (const question of state.manifest.questions) {557 const row = document.createElement("section");558 row.className = "question";559 row.dataset.dimension = question.key;560 const description = document.createElement("div");561 description.innerHTML = `<div class="question-title"></div><div class="question-text"></div>`;562 description.querySelector(".question-title").textContent = question.title;563 description.querySelector(".question-text").textContent = question.question;564 const control = document.createElement("div");565 const scale = document.createElement("div");566 scale.className = "scale";567 for (let value = 1; value <= 5; value += 1) {568 const label = document.createElement("label");569 label.innerHTML = `<input type="radio" name="${question.key}" value="${value}"><span>${value}</span>`;570 scale.append(label);571 }572 const anchors = document.createElement("div");573 anchors.className = "anchors";574 for (const value of ["1", "3", "5"]) {575 const anchor = document.createElement("span");576 anchor.textContent = question.anchors[value];577 anchors.append(anchor);578 }579 control.append(scale, anchors);580 row.append(description, control);581 root.append(row);582 }583}584 585function currentItem() { return state.manifest.items[state.index]; }586 587function nextUnansweredIndex(afterIndex) {588 const count = state.manifest.items.length;589 for (let offset = 1; offset <= count; offset += 1) {590 const index = (afterIndex + offset) % count;591 if (!state.responses.has(state.manifest.items[index].blind_id)) return index;592 }593 return null;594}595 596function updateProgressControls() {597 const item = currentItem();598 const count = state.manifest.items.length;599 element("progress").textContent = `Item ${state.index + 1} of ${count} · ${state.responses.size} completed`;600 element("previous-button").disabled = state.index === 0;601 const completesStudy = (602 state.responses.size === count603 || (!state.responses.has(item.blind_id) && state.responses.size === count - 1)604 );605 element("next-button").textContent = completesStudy ? "Finish" : "Save and next";606}607 608function setImage(id, relativePath) {609 element(id).src = `/${relativePath}`;610}611 612function renderItem() {613 cancelPendingAutosave();614 const item = currentItem();615 const assets = item.assets;616 setImage("a-source", assets.a_source);617 setImage("a-target", assets.a_target);618 setImage("b-source", assets.b_source);619 setImage("b-target", assets.b_target);620 setImage("query", assets.query);621 setImage("output", assets.output);622 updateProgressControls();623 element("form-error").textContent = "";624 element("rating-form").reset();625 element("unclear-a").checked = false;626 element("unclear-b").checked = false;627 const saved = state.responses.get(item.blind_id);628 const draft = storedParticipantDrafts(state.raterId)[item.blind_id];629 const displayed = draft || saved;630 if (displayed) {631 element("unclear-a").checked = displayed.unclear.edit_a;632 element("unclear-b").checked = displayed.unclear.edit_b;633 for (const [dimension, value] of Object.entries(displayed.ratings)) {634 if (value !== null) {635 const input = document.querySelector(`input[name="${dimension}"][value="${value}"]`);636 if (input) input.checked = true;637 }638 }639 }640 element("item-comment").value = displayed?.comment || "";641 updateUnclearState();642 state.itemStartedAt = performance.now();643 if (draft) {644 element("save-status").textContent = "Draft restored";645 scheduleAutosave();646 }647 window.scrollTo({top: 0, behavior: "instant"});648}649 650function updateUnclearState() {651 for (const [dimension, checkboxId] of [["edit_a_applied", "unclear-a"], ["edit_b_applied", "unclear-b"]]) {652 const disabled = element(checkboxId).checked;653 const row = document.querySelector(`[data-dimension="${dimension}"]`);654 row.classList.toggle("disabled", disabled);655 for (const input of row.querySelectorAll("input")) input.disabled = disabled;656 if (disabled) {657 for (const input of row.querySelectorAll("input")) input.checked = false;658 }659 }660}661 662function collectCurrentResponse() {663 const unclear = {edit_a: element("unclear-a").checked, edit_b: element("unclear-b").checked};664 const ratings = {};665 for (const dimension of dimensions) {666 const selected = document.querySelector(`input[name="${dimension}"]:checked`);667 const allowedNull = (dimension === "edit_a_applied" && unclear.edit_a) || (dimension === "edit_b_applied" && unclear.edit_b);668 if (!selected && !allowedNull) throw new Error("Please answer every question or mark the corresponding edit demonstration as unclear.");669 ratings[dimension] = selected ? Number(selected.value) : null;670 }671 const previous = state.responses.get(currentItem().blind_id);672 const elapsed = (performance.now() - state.itemStartedAt) / 1000 + (previous?.elapsed_seconds || 0);673 return {674 blind_id: currentItem().blind_id,675 position: state.index,676 ratings,677 unclear,678 comment: element("item-comment").value.trim(),679 elapsed_seconds: Math.round(elapsed * 10) / 10,680 };681}682 683function payload(complete = false) {684 return {685 schema_version: state.manifest.schema_version,686 study_id: state.manifest.study_id,687 set: state.mode,688 rater_id: state.raterId,689 started_at: state.startedAt,690 updated_at: new Date().toISOString(),691 tutorial_version: state.tutorialVersion,692 complete,693 responses: Array.from(state.responses.values()),694 };695}696 697async function callHostedApi(apiName, data) {698 const started = await fetch(`${hostedApiUrl}/gradio_api/call/${apiName}`, {699 method: "POST",700 headers: {"Content-Type": "application/json"},701 body: JSON.stringify({data}),702 });703 if (!started.ok) throw new Error(await started.text() || "Could not contact the evaluation server");704 const {event_id: eventId} = await started.json();705 if (!eventId) throw new Error("The evaluation server returned an invalid response");706 707 const completed = await fetch(`${hostedApiUrl}/gradio_api/call/${apiName}/${eventId}`);708 if (!completed.ok) throw new Error(await completed.text() || "The evaluation server request failed");709 const eventStream = await completed.text();710 for (const block of eventStream.split(/\r?\n\r?\n/)) {711 const lines = block.split(/\r?\n/);712 const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim();713 const dataLine = lines.find(line => line.startsWith("data:"));714 if (!dataLine) continue;715 const eventData = dataLine.slice(5).trim();716 if (event === "complete") {717 const values = JSON.parse(eventData);718 return values[0];719 }720 if (event === "error") {721 let message = eventData;722 try { message = JSON.parse(eventData); } catch (_) {}723 throw new Error(typeof message === "string" ? message : "The evaluation server rejected the request");724 }725 }726 throw new Error("The evaluation server did not complete the request");727}728 729async function persistResponse(responsePayload) {730 let result;731 if (hostedApiUrl) {732 result = await callHostedApi("save_response", [responsePayload]);733 } else {734 const response = await fetch("/api/save", {735 method: "POST",736 headers: {"Content-Type": "application/json"},737 body: JSON.stringify(responsePayload),738 });739 result = await response.json();740 if (!response.ok) throw new Error(result.error || "Could not save responses");741 }742 rememberParticipantProgress(responsePayload);743 forgetPersistedDrafts(responsePayload);744 return result;745}746 747async function loadStoredResponse(mode, raterId) {748 if (hostedApiUrl) {749 const manifest = await callHostedApi("load_study", [mode, raterId]);750 const response = storedParticipantProgress(raterId);751 const responseMatches = response752 && response.schema_version === manifest.schema_version753 && response.study_id === manifest.study_id754 && response.set === mode755 && response.rater_id === raterId;756 return {manifest, response: responseMatches ? response : null};757 }758 const response = await fetch(`/api/response?mode=${encodeURIComponent(mode)}&rater=${encodeURIComponent(raterId)}`);759 const result = await response.json();760 if (!response.ok) throw new Error(result.error || "Invalid participant code");761 return result;762}763 764async function save(complete = false) {765 const responsePayload = payload(complete);766 const requestId = ++state.saveRequestId;767 const editRevision = state.editRevision;768 element("save-status").textContent = "Saving";769 const operation = state.saveQueue770 .catch(() => {})771 .then(() => persistResponse(responsePayload));772 state.saveQueue = operation;773 try {774 await operation;775 if (requestId === state.saveRequestId) {776 element("save-status").textContent = editRevision === state.editRevision777 ? (complete ? "Complete" : "Saved")778 : "Draft saved";779 }780 } catch (error) {781 if (requestId === state.saveRequestId) {782 element("save-status").textContent = "Saved on this device";783 }784 throw error;785 }786}787 788function cancelPendingAutosave() {789 if (state.autosaveTimer !== null) {790 clearTimeout(state.autosaveTimer);791 state.autosaveTimer = null;792 }793}794 795function scheduleAutosave() {796 cancelPendingAutosave();797 if (!state.manifest || !state.raterId) return;798 const blindId = currentItem().blind_id;799 state.autosaveTimer = setTimeout(async () => {800 state.autosaveTimer = null;801 if (currentItem().blind_id !== blindId) return;802 let response;803 try {804 response = collectCurrentResponse();805 } catch (_) {806 return;807 }808 state.responses.set(response.blind_id, response);809 updateProgressControls();810 try {811 await save(state.responses.size === state.manifest.items.length);812 } catch (_) {813 // The local draft remains available and a later save retries the response.814 }815 }, autosaveDelayMs);816}817 818function handleResponseInput() {819 rememberCurrentDraft();820 scheduleAutosave();821}822 823function handleUnclearChange() {824 updateUnclearState();825 handleResponseInput();826}827 828async function startStudy(event) {829 event?.preventDefault();830 const mode = "main";831 let raterId = element("rater-code").value.trim();832 if (!raterId) {833 raterId = generateParticipantCode();834 element("rater-code").value = raterId;835 }836 element("start-error").textContent = "";837 try {838 const result = await loadStoredResponse(mode, raterId);839 state.manifest = result.manifest;840 state.mode = mode;841 state.raterId = raterId;842 rememberParticipantCode(raterId);843 state.startedAt = result.response?.started_at || new Date().toISOString();844 state.tutorialVersion = Number(result.response?.tutorial_version || 0);845 state.responses = new Map((result.response?.responses || []).map(item => [item.blind_id, item]));846 if (result.response?.complete) {847 element("start-view").hidden = true;848 element("complete-view").hidden = false;849 element("progress").textContent = "Complete";850 element("save-status").textContent = "Complete";851 setGuideVisible(false);852 return;853 }854 const firstUnanswered = state.manifest.items.findIndex(item => !state.responses.has(item.blind_id));855 state.index = firstUnanswered < 0 ? state.manifest.items.length - 1 : firstUnanswered;856 createQuestions();857 element("start-view").hidden = true;858 element("study-view").hidden = false;859 setGuideVisible(true);860 renderItem();861 if (result.response === null) {862 await waitForStudyImages();863 startTour({required: true});864 }865 } catch (error) {866 element("start-error").textContent = error.message;867 }868}869 870async function submitCurrent(event) {871 event.preventDefault();872 cancelPendingAutosave();873 try {874 const response = collectCurrentResponse();875 state.responses.set(response.blind_id, response);876 const isComplete = state.responses.size === state.manifest.items.length;877 await save(isComplete);878 if (isComplete) {879 element("study-view").hidden = true;880 element("complete-view").hidden = false;881 element("progress").textContent = "Complete";882 setGuideVisible(false);883 return;884 }885 state.index = nextUnansweredIndex(state.index);886 renderItem();887 } catch (error) {888 element("form-error").textContent = error.message;889 }890}891 892async function skipItem() {893 cancelPendingAutosave();894 try {895 await save(false);896 const nextIndex = nextUnansweredIndex(state.index);897 if (nextIndex !== null && nextIndex !== state.index) {898 state.index = nextIndex;899 renderItem();900 } else {901 element("form-error").textContent = "This is the only remaining unanswered item. You may rate it or save and exit.";902 }903 } catch (error) {904 element("form-error").textContent = error.message;905 }906}907 908async function saveAndExit() {909 cancelPendingAutosave();910 try {911 try {912 const response = collectCurrentResponse();913 state.responses.set(response.blind_id, response);914 } catch (_) {915 // An incomplete current item remains unanswered and will be shown on resume.916 }917 const isComplete = state.responses.size === state.manifest.items.length;918 await save(isComplete);919 element("study-view").hidden = true;920 setGuideVisible(false);921 if (isComplete) {922 element("complete-view").hidden = false;923 element("progress").textContent = "Complete";924 return;925 }926 element("paused-view").hidden = false;927 element("progress").textContent = `${state.responses.size} of ${state.manifest.items.length} completed`;928 } catch (error) {929 element("form-error").textContent = error.message;930 }931}932 933function resumeStudy() {934 element("paused-view").hidden = true;935 element("study-view").hidden = false;936 setGuideVisible(true);937 renderItem();938}939 940function previousItem() {941 if (state.index === 0) return;942 cancelPendingAutosave();943 try {944 const response = collectCurrentResponse();945 state.responses.set(response.blind_id, response);946 save(false).catch(error => { element("save-status").textContent = error.message; });947 } catch (_) {948 // Moving back is allowed even if a newly visited item is incomplete.949 }950 state.index -= 1;951 renderItem();952}953 954element("start-form").addEventListener("submit", startStudy);955element("new-code-button").addEventListener("click", replaceParticipantCode);956element("guide-button").addEventListener("click", replayTour);957element("rating-form").addEventListener("submit", submitCurrent);958element("rating-form").addEventListener("change", handleResponseInput);959element("item-comment").addEventListener("input", handleResponseInput);960element("previous-button").addEventListener("click", previousItem);961element("skip-button").addEventListener("click", skipItem);962element("save-exit-button").addEventListener("click", saveAndExit);963element("resume-button").addEventListener("click", resumeStudy);964element("unclear-a").addEventListener("change", handleUnclearChange);965element("unclear-b").addEventListener("change", handleUnclearChange);966element("tour-back-button").addEventListener("click", retreatTour);967element("tour-next-button").addEventListener("click", advanceTour);968element("tour-close-button").addEventListener("click", endTour);969document.addEventListener("keydown", handleTourKeydown);970window.addEventListener("resize", scheduleTourLayout);971window.addEventListener("scroll", scheduleTourLayout, true);972initializeParticipantCode();973 