davanstrien's picture
davanstrien HF Staff
Laptop-friendly layout: controls strip above grid, capped image heights, tighter header; fix annoView grid display
f649914 verified
Raw
History Blame Contribute Delete
26.6 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Iconclass · 9B vision model</title>
<style>
:root{
--ink:#111; --paper:#fffff8; --faint:#6b6b63; --rule:#d9d8cf;
--ok:#3a7d44; --bad:#9b2226; --accent:#7a1f12; --panel:#faf9f0;
}
*{box-sizing:border-box}
html{font-size:15px}
body{
background:var(--paper); color:var(--ink);
max-width:1180px; margin:auto; padding:1.8rem 2rem 4rem;
font-family:et-book,Palatino,"Palatino Linotype",Georgia,serif; line-height:1.5;
}
a{color:var(--accent); text-decoration:none; border-bottom:1px solid rgba(122,31,18,.25)}
a:hover{border-bottom-color:var(--accent)}
h1{font-weight:400; font-size:1.6rem; margin:0 0 .15rem; letter-spacing:-.01em}
.sub{color:var(--faint); font-style:italic; margin:0 0 .9rem; font-size:.92rem}
p.lead{max-width:62rem; margin:0 0 1.2rem; font-size:.92rem; color:var(--faint)}
p.lead a{color:var(--accent)}
code.inline{font-family:ui-monospace,monospace; font-size:.78rem; color:var(--faint)}
/* layout */
.grid{display:grid; grid-template-columns:minmax(300px,420px) 1fr; gap:2.6rem; align-items:start}
@media(max-width:860px){.grid{grid-template-columns:1fr; gap:2rem}}
h3{
font-size:.74rem; font-variant:small-caps; letter-spacing:.09em; color:var(--faint);
font-weight:400; margin:0 0 .6rem; border-bottom:1px solid var(--rule); padding-bottom:.25rem;
}
/* control strip (examples · depth · catalogued) above the main grid */
.controls{
display:flex; gap:2.2rem; align-items:flex-start; flex-wrap:wrap;
border-top:1px solid var(--rule); border-bottom:1px solid var(--rule);
padding:.55rem 0 .65rem; margin:0 0 1.4rem;
}
.controls .ctl{min-width:150px}
.controls .ctl.grow{flex:1; min-width:240px}
/* drop zone */
#drop{
border:1px solid var(--rule); background:var(--panel);
height:min(52vh, 460px);
display:flex; align-items:center; justify-content:center;
text-align:center; cursor:pointer; position:relative; overflow:hidden; transition:border-color .15s;
}
#drop.drag{border-color:var(--accent)}
#drop .hint{color:var(--faint); font-style:italic; font-size:.95rem; padding:1.5rem}
#drop img{width:100%; height:100%; object-fit:contain; background:var(--panel)}
#fileInput{display:none}
.thumbs{display:flex; gap:.5rem; flex-wrap:wrap}
.thumbs img{
width:46px; height:46px; object-fit:cover; border:1px solid var(--rule);
cursor:pointer; filter:saturate(.92); transition:border-color .15s, filter .15s;
}
.thumbs img:hover{border-color:var(--accent); filter:saturate(1)}
/* options — typographic controls, no widget chrome */
.microlabel{
font-size:.7rem; font-variant:small-caps; letter-spacing:.08em; color:var(--faint);
margin:0 0 .3rem;
}
.depth{font-size:.92rem; line-height:1.9}
.depth .opt{
cursor:pointer; color:var(--faint); padding:.1rem .05rem;
border-bottom:1px solid transparent; transition:color .12s, border-color .12s;
}
.depth .opt:hover{color:var(--ink)}
.depth .opt.sel{color:var(--accent); border-bottom-color:var(--accent)}
.depth .sep{color:var(--rule); margin:0 .35rem; user-select:none}
.depth .unit{color:var(--faint); font-style:italic; font-size:.85rem; margin-left:.4rem}
input[type=text]{
width:100%; font-family:ui-monospace,monospace; font-size:.8rem; color:var(--ink);
background:transparent; border:none; border-bottom:1px solid var(--rule);
padding:.25rem 0; transition:border-color .15s;
}
input[type=text]:focus{outline:none; border-bottom-color:var(--accent)}
input[type=text]::placeholder{color:var(--rule); font-style:italic}
.opt-hint{font-size:.74rem; color:var(--faint); font-style:italic; margin:.3rem 0 0}
#rerun{
display:none; margin-top:.8rem; font-size:.78rem; font-variant:small-caps; letter-spacing:.07em;
background:none; border:none; padding:0; color:var(--accent); cursor:pointer;
border-bottom:1px solid rgba(122,31,18,.25);
}
#rerun:hover{border-bottom-color:var(--accent)}
/* results */
#status{min-height:1.4rem; color:var(--faint); font-style:italic; font-size:.92rem; margin:.1rem 0 1rem}
.spinner{display:inline-block; width:.72em; height:.72em; margin-right:.4em; border:1.5px solid var(--rule);
border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; vertical-align:-1px}
@keyframes spin{to{transform:rotate(360deg)}}
ul.codes{list-style:none; margin:0; padding:0}
ul.codes li{
margin:0; padding:.62rem 0; border-top:1px solid var(--rule);
}
ul.codes li:first-child{border-top:none}
ul.codes li.given{opacity:.72}
.codehead{display:flex; align-items:baseline; gap:.7rem; cursor:pointer}
li.given .codehead{cursor:default}
.codehead .chev{
color:var(--rule); font-size:.7rem; width:.8rem; flex:none;
transition:transform .15s; user-select:none;
}
li.open .codehead .chev{transform:rotate(90deg); color:var(--accent)}
.codehead:hover .chev{color:var(--accent)}
/* decoded hierarchy ladder */
.ladder{display:none; margin:.45rem 0 .3rem 1.05rem}
li.open .ladder{display:block}
.ladder .rung{
display:flex; align-items:baseline; gap:.7rem;
padding:.16rem 0; font-size:.92rem;
}
.ladder .rtext{flex:1; color:var(--faint)}
.ladder .rung:last-child .rtext{color:var(--ink)}
.ladder .rung .joint{color:var(--rule); font-family:ui-monospace,monospace; font-size:.8rem; user-select:none}
.ladder .rcode{font-family:ui-monospace,monospace; font-size:.68rem; color:var(--rule);
white-space:nowrap; text-align:right}
.ladder .rung:last-child .rcode{color:var(--faint)}
.ladder + .ladder{margin-top:.4rem; padding-top:.4rem; border-top:1px dotted var(--rule)}
.ladderhint{font-size:.72rem; color:var(--faint); font-style:italic; margin:.5rem 0 0}
.tag{
font-variant:small-caps; letter-spacing:.06em; font-size:.66rem; color:var(--faint);
border:1px solid var(--rule); padding:.05rem .4rem; white-space:nowrap;
}
li.added .tag{color:var(--accent); border-color:rgba(122,31,18,.3)}
.meaning{flex:1; font-size:1.02rem}
.codecol{font-family:ui-monospace,monospace; font-size:.74rem; color:var(--faint);
white-space:nowrap; text-align:right}
.codecol a{border:none}
.codecol a:hover{text-decoration:underline}
details.raw{margin-top:1.6rem}
details.raw summary{font-size:.74rem; color:var(--faint); cursor:pointer; font-variant:small-caps; letter-spacing:.06em}
details.raw pre{white-space:pre-wrap; word-break:break-word; font-size:.72rem; background:var(--panel);
border:1px solid var(--rule); padding:.6rem; margin:.5rem 0 0; max-height:260px; overflow:auto; color:#333}
.empty{color:var(--faint); font-style:italic}
footer{margin-top:4rem; padding-top:1rem; border-top:1px solid var(--rule);
font-size:.78rem; color:var(--faint); max-width:46rem}
/* mode nav */
nav.modes{position:absolute; top:2rem; right:2rem; font-size:.78rem;
font-variant:small-caps; letter-spacing:.07em}
nav.modes .m{color:var(--faint); cursor:pointer; padding:.1rem 0; border-bottom:1px solid transparent}
nav.modes .m:hover{color:var(--ink)}
nav.modes .m.on{color:var(--accent); border-bottom-color:var(--accent)}
nav.modes .sep{color:var(--rule); margin:0 .45rem}
body{position:relative}
/* annotation mode */
#annoView{display:none}
body.annotating #annoView{display:grid}
body.annotating #classifyView{display:none}
#annoImgWrap{border:1px solid var(--rule); background:var(--panel); height:min(58vh, 520px);
display:flex; align-items:center; justify-content:center; overflow:hidden}
#annoImgWrap img{width:100%; height:100%; object-fit:contain; cursor:zoom-in}
.annoq{color:var(--faint); font-style:italic; font-size:.95rem; margin:.2rem 0 1.1rem}
.annoq em{color:var(--ink)}
#annoCard .lead-meaning{font-size:1.05rem; margin:0 0 .8rem}
#annoCard .lead-meaning .codecol{margin-left:.6rem}
/* the statement under judgement */
#annoCard .lead-meaning{font-size:1.35rem; line-height:1.45; margin:0 0 .9rem}
/* passive context ladder (ancestors, non-interactive) */
.ctxladder{margin:.2rem 0 0}
.ctxladder .rung{display:flex; align-items:baseline; gap:.7rem; padding:.14rem 0; font-size:.86rem}
.ctxladder .rtext{flex:1; color:var(--faint)}
.ctxladder .rcode{font-family:ui-monospace,monospace; font-size:.66rem; color:var(--rule); white-space:nowrap}
.ctxladder .joint{color:var(--rule); font-family:ui-monospace,monospace; font-size:.78rem}
.ctxladder + .ctxladder{margin-top:.45rem; padding-top:.45rem; border-top:1px dotted var(--rule)}
.ctxlabel{font-size:.68rem; font-variant:small-caps; letter-spacing:.08em; color:var(--faint);
margin:1.2rem 0 .2rem; border-bottom:1px solid var(--rule); padding-bottom:.2rem; max-width:30rem}
/* vote row */
.voterow{margin:1.6rem 0 0; font-size:1.02rem}
.voterow .vote{cursor:pointer; padding:.15rem .1rem; border-bottom:1px solid transparent}
.voterow .vote.yes{color:var(--ok)}
.voterow .vote.yes:hover{border-bottom-color:var(--ok)}
.voterow .vote.no{color:var(--bad)}
.voterow .vote.no:hover{border-bottom-color:var(--bad)}
.voterow .vote.skip{color:var(--faint); font-size:.9rem}
.voterow .vote.skip:hover{border-bottom-color:var(--rule); color:var(--ink)}
.voterow .sep{color:var(--rule); margin:0 .6rem}
#annoSaved{display:inline-block; margin-left:.8rem; color:var(--ok); font-size:.8rem;
font-style:italic; opacity:0; transition:opacity .2s}
#annoSaved.show{opacity:1}
</style>
</head>
<body>
<nav class="modes"><span class="m on" id="navClassify">classify</span><span class="sep">&middot;</span><span class="m" id="navAnnotate">annotate</span></nav>
<h1>Reading pictures with Iconclass</h1>
<p class="sub">A 9-billion-parameter vision model that classifies artworks with <a href="https://iconclass.org/" target="_blank" rel="noopener">Iconclass</a> codes.</p>
<p class="lead">
<a href="https://iconclass.org/" target="_blank" rel="noopener">Iconclass</a> is the standard
iconographic classification used to catalogue what a picture <em>depicts</em>. Upload an artwork
(or pick an example) and the model proposes codes, decoded to plain English. Optionally set
<em>how many</em> codes you want, or paste codes your catalogue <em>already has</em> and ask what&rsquo;s missing.
</p>
<div id="classifyView">
<div class="controls">
<div class="ctl">
<p class="microlabel">Try an example</p>
<div class="thumbs" id="thumbs">
<img data-src="/static/portrait.jpg" src="/static/portrait.jpg" alt="Victorian portrait" title="A Victorian portrait" />
<img data-src="/static/venus.jpg" src="/static/venus.jpg" alt="Venus and Cupid" title="Venus and Cupid (mythology)" />
<img data-src="/static/marine.jpg" src="/static/marine.jpg" alt="Ships at sea" title="Ships at sea" />
<img data-src="/static/interior.jpg" src="/static/interior.jpg" alt="Peasant interior" title="A peasant interior" />
<img data-src="/static/saint.jpg" src="/static/saint.jpg" alt="Illuminated saint" title="A saint in a printed book" />
</div>
</div>
<div class="ctl">
<p class="microlabel">Cataloguing depth</p>
<p class="depth" id="depth">
<span class="opt sel" data-n="0">model decides</span><span class="sep">&middot;</span><span class="opt" data-n="2">2</span><span class="sep">&middot;</span><span class="opt" data-n="3">3</span><span class="sep">&middot;</span><span class="opt" data-n="4">4</span><span class="sep">&middot;</span><span class="opt" data-n="5">5</span><span class="sep">&middot;</span><span class="opt" data-n="6">6</span><span class="sep">&middot;</span><span class="opt" data-n="8">8</span><span class="sep">&middot;</span><span class="opt" data-n="10">10</span><span class="unit">codes</span>
</p>
<button id="rerun">&#8635;&nbsp;re-run on this image</button>
</div>
<div class="ctl grow">
<p class="microlabel">Already catalogued</p>
<input type="text" id="existing" placeholder="11H(JEROME), 49M32 &mdash; codes the record already has" spellcheck="false" />
<p class="opt-hint">The model will only suggest codes for aspects <em>not yet covered</em>. Press Enter to apply.</p>
</div>
</div>
<div class="grid">
<div>
<h3>The artwork</h3>
<div id="drop" tabindex="0" role="button" aria-label="Upload an artwork">
<span class="hint">Drop an image here<br />or click to choose a file</span>
<input type="file" id="fileInput" accept="image/*" />
</div>
</div>
<div>
<h3>Predicted Iconclass codes</h3>
<div id="status">Awaiting an image&hellip;</div>
<ul class="codes" id="results"></ul>
<p class="empty" id="empty" style="display:none">No codes yet. Upload or choose an artwork above.</p>
<details class="raw" id="rawWrap" style="display:none">
<summary>raw model output</summary>
<pre id="raw"></pre>
</details>
</div>
</div>
</div>
<div class="grid" id="annoView">
<div>
<h3>The artwork</h3>
<div id="annoImgWrap"><img id="annoImg" alt="artwork to annotate" /></div>
<p class="microlabel" id="annoProgress"></p>
</div>
<div>
<h3>Is this in the picture?</h3>
<p class="annoq">The model says this picture shows the following. Do you see it?</p>
<div id="annoCard"></div>
<p class="voterow">
<span class="vote yes" id="voteYes">yes, it&rsquo;s shown</span><span class="sep">&middot;</span><span class="vote no" id="voteNo">no</span><span class="sep">&middot;</span><span class="vote skip" id="voteSkip">can&rsquo;t tell</span><span id="annoSaved">saved</span>
</p>
</div>
</div>
<footer>
Model: <a href="https://huggingface.co/davanstrien/qwen35-9b-iconclass-sft-multitask-2ep" target="_blank" rel="noopener">davanstrien/qwen35-9b-iconclass-sft-multitask-2ep</a>,
a Qwen3.5-9B vision-language model fine-tuned on three tasks: classify an artwork, classify with a
requested number of codes, and extend an existing partial catalogue record.
Running on ZeroGPU. Code meanings decoded with the <code class="inline">iconclass</code> Python package.
Predictions are a research artifact and may be incomplete or wrong &mdash; verify against
<a href="https://iconclass.org/" target="_blank" rel="noopener">iconclass.org</a>.
</footer>
<script type="module">
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
const dropEl = document.getElementById("drop");
const fileInput= document.getElementById("fileInput");
const statusEl = document.getElementById("status");
const resultsEl= document.getElementById("results");
const emptyEl = document.getElementById("empty");
const rawWrap = document.getElementById("rawWrap");
const rawEl = document.getElementById("raw");
const thumbsEl = document.getElementById("thumbs");
const depthEl = document.getElementById("depth");
const existEl = document.getElementById("existing");
const rerunEl = document.getElementById("rerun");
let client = null;
let busy = false;
let lastFile = null;
let currentN = 0;
async function getClient(){
if(!client){ client = await Client.connect(window.location.origin); }
return client;
}
function setPreview(src){
dropEl.innerHTML = '<img alt="selected artwork" src="' + src + '">' +
'<input type="file" id="fileInput" accept="image/*">';
// re-bind the freshly created input
document.getElementById("fileInput").addEventListener("change", onPick);
}
function escapeHtml(s){
return (s || "").replace(/[&<>"']/g, c => (
{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]
));
}
function clip(s, n){
s = s || "";
return s.length > n ? s.slice(0, n - 1).replace(/\s+\S*$/, "") + "…" : s;
}
function ladderHtml(c){
// c.path = list of ladders; each ladder = [{code,text}, ...] root -> leaf.
// Single-ladder codes show ANCESTORS only — the header line already is the leaf.
// Composite (:) codes keep their leaves so each part stays identifiable.
let ladders = c.path || [];
if(!ladders.length) return "";
if(ladders.length === 1 && ladders[0].length > 1){
ladders = [ladders[0].slice(0, -1)];
}
return ladders.map(ladder =>
'<div class="ladder">' + ladder.map((r, i) =>
'<div class="rung" style="margin-left:' + (i * 0.95) + 'rem">' +
(i ? '<span class="joint">&#9492;</span>' : '') +
'<span class="rtext">' + escapeHtml(clip(r.text, 92)) + '</span>' +
'<span class="rcode">' + escapeHtml(r.code) + '</span>' +
'</div>'
).join("") + '</div>'
).join("");
}
function codeLi(c, cls, tag){
const li = document.createElement("li");
if(cls) li.className = cls;
const ladder = (cls === "given") ? "" : ladderHtml(c);
li.innerHTML =
'<div class="codehead">' +
(ladder ? '<span class="chev">&#9656;</span>' : '') +
(tag ? '<span class="tag">' + tag + '</span>' : '') +
'<span class="meaning">' + escapeHtml(c.meaning) + '</span>' +
'<span class="codecol"><a href="' + c.url + '" target="_blank" rel="noopener">' +
escapeHtml(c.code) + '</a></span>' +
'</div>' + ladder;
if(ladder){
li.querySelector(".codehead").addEventListener("click", (e) => {
if(e.target.closest("a")) return; // let the iconclass.org link work
li.classList.toggle("open");
});
}
return li;
}
function renderCodes(data){
resultsEl.innerHTML = "";
rawWrap.style.display = data.raw ? "block" : "none";
rawEl.textContent = data.raw || "";
const given = (data && data.existing) || [];
const codes = (data && data.codes) || [];
const completion = given.length > 0;
for(const c of given){ resultsEl.appendChild(codeLi(c, "given", "given")); }
if(!codes.length && !given.length){
emptyEl.style.display = "block";
emptyEl.textContent = data.error || "The model returned no valid Iconclass codes for this image.";
return;
}
emptyEl.style.display = "none";
for(const c of codes){
resultsEl.appendChild(codeLi(c, completion ? "added" : "", completion ? "added" : ""));
}
if(completion && !codes.length){
const li = document.createElement("li");
li.innerHTML = '<span class="meaning empty">The model proposed no additions beyond the given codes.</span>';
resultsEl.appendChild(li);
}
if(codes.some(c => (c.path || []).length)){
const hint = document.createElement("p");
hint.className = "ladderhint";
hint.textContent = "Click a code to see its reading — each line is a step down the Iconclass hierarchy, from broad to specific.";
resultsEl.insertAdjacentElement("afterend", hint);
const old = document.querySelectorAll(".ladderhint");
for(let i = 0; i < old.length - 1; i++) old[i].remove();
}
}
window.__testRender = renderCodes; // hook for local visual testing
window.__testAnno = (items) => { seedItems = items; aImg = 0; aCode = 0; setMode(true); showAnno(); };
async function classifyFile(file){
if(busy || !file) return;
busy = true;
lastFile = file;
resultsEl.innerHTML = "";
emptyEl.style.display = "none";
rawWrap.style.display = "none";
statusEl.innerHTML = '<span class="spinner"></span>Reading the image &mdash; the model takes a few seconds&hellip;';
try{
const c = await getClient();
const result = await c.predict("/classify", {
image_path: handle_file(file),
num_codes: currentN,
existing_codes: existEl.value || "",
});
const data = result.data[0];
const n = (data && data.codes) ? data.codes.length : 0;
const mode = data && data.mode;
statusEl.innerHTML = n
? (mode === "completion"
? n + ' code' + (n===1?'':'s') + ' added to the record.'
: n + ' code' + (n===1?'':'s') + ' predicted.')
: 'Done.';
renderCodes(data);
rerunEl.style.display = "inline-block";
}catch(err){
console.error(err);
statusEl.innerHTML = '';
emptyEl.style.display = "block";
emptyEl.textContent = "Something went wrong calling the model. Please try again.";
}finally{
busy = false;
}
}
async function classifyUrl(url){
// fetch the example as a Blob so we can preview + send it
setPreview(url);
const resp = await fetch(url);
const blob = await resp.blob();
const file = new File([blob], url.split("/").pop(), { type: blob.type || "image/jpeg" });
classifyFile(file);
}
function onPick(e){
const file = e.target.files && e.target.files[0];
if(!file) return;
setPreview(URL.createObjectURL(file));
classifyFile(file);
}
// wiring
fileInput.addEventListener("change", onPick);
dropEl.addEventListener("click", () => document.getElementById("fileInput").click());
dropEl.addEventListener("keydown", (e) => { if(e.key==="Enter"||e.key===" "){ document.getElementById("fileInput").click(); }});
["dragenter","dragover"].forEach(ev => dropEl.addEventListener(ev, e => { e.preventDefault(); dropEl.classList.add("drag"); }));
["dragleave","drop"].forEach(ev => dropEl.addEventListener(ev, e => { e.preventDefault(); dropEl.classList.remove("drag"); }));
dropEl.addEventListener("drop", e => {
const file = e.dataTransfer.files && e.dataTransfer.files[0];
if(file){ setPreview(URL.createObjectURL(file)); classifyFile(file); }
});
thumbsEl.querySelectorAll("img").forEach(img => {
img.addEventListener("click", () => classifyUrl(img.dataset.src));
});
rerunEl.addEventListener("click", () => { if(lastFile) classifyFile(lastFile); });
existEl.addEventListener("keydown", (e) => { if(e.key === "Enter" && lastFile) classifyFile(lastFile); });
// ---------------------------------------------------------------------------
// Annotation mode
// ---------------------------------------------------------------------------
const navC = document.getElementById("navClassify");
const navA = document.getElementById("navAnnotate");
const annoImg = document.getElementById("annoImg");
const annoCard = document.getElementById("annoCard");
const annoProg = document.getElementById("annoProgress");
const annoSaved = document.getElementById("annoSaved");
let seedItems = null;
let aImg = 0, aCode = 0, annoBusy = false;
const session = (() => {
let s = localStorage.getItem("icx_session");
if(!s){ s = crypto.randomUUID(); localStorage.setItem("icx_session", s); }
return s;
})();
function setMode(annotate){
document.body.classList.toggle("annotating", annotate);
navA.classList.toggle("on", annotate);
navC.classList.toggle("on", !annotate);
if(annotate && !seedItems) loadSeed();
}
navC.addEventListener("click", () => setMode(false));
navA.addEventListener("click", () => setMode(true));
if(location.hash === "#annotate") setMode(true);
async function loadSeed(){
annoProg.textContent = "loading…";
try{
const data = await (await fetch("/seed")).json();
seedItems = (data.items || []).filter(it => (it.codes || []).length);
for(let i = seedItems.length - 1; i > 0; i--){ // shuffle
const j = Math.floor(Math.random() * (i + 1));
[seedItems[i], seedItems[j]] = [seedItems[j], seedItems[i]];
}
aImg = 0; aCode = 0;
if(!seedItems.length){ annoProg.textContent = "no seed items available"; return; }
showAnno();
}catch(e){
console.error(e);
annoProg.textContent = "could not load the annotation set";
}
}
function showAnno(){
const item = seedItems[aImg];
const codeObj = item.codes[aCode];
if(annoImg.src !== item.image){
annoImg.src = item.image;
annoImg.onclick = () => window.open(item.image, "_blank");
}
annoProg.textContent = "image " + (aImg + 1) + " of " + seedItems.length +
" · code " + (aCode + 1) + " of " + item.codes.length;
// The claim under judgement, big and plain; ancestors shown as quiet context.
const ladders = codeObj.ladders || [];
const ctx = ladders.map(ladder => {
const anc = ladders.length === 1 && ladder.length > 1 ? ladder.slice(0, -1) : ladder;
return '<div class="ctxladder">' + anc.map((r, i) =>
'<div class="rung" style="margin-left:' + (i * 0.85) + 'rem">' +
(i ? '<span class="joint">&#9492;</span>' : '') +
'<span class="rtext">' + escapeHtml(clip(r.text, 92)) + '</span>' +
'<span class="rcode">' + escapeHtml(r.code) + '</span>' +
'</div>'
).join("") + '</div>';
}).join("");
annoCard.innerHTML =
'<p class="lead-meaning">' + escapeHtml(codeObj.meaning) +
'<span class="codecol"><a href="https://iconclass.org/' + encodeURIComponent(codeObj.code) +
'" target="_blank" rel="noopener">' + escapeHtml(codeObj.code) + '</a></span></p>' +
(ctx ? '<p class="ctxlabel">where this sits in Iconclass</p>' + ctx : '');
}
async function sendVerdict(item, codeObj, extra){
annoBusy = true;
try{
await fetch("/annotate", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify(Object.assign({
seed_id: item.id, code: codeObj.code,
gt: !!codeObj.gt, judge: codeObj.judge || null, session: session,
ui: "simple-v1",
}, extra)),
});
annoSaved.classList.add("show");
setTimeout(() => annoSaved.classList.remove("show"), 900);
}catch(e){ console.error(e); }
setTimeout(() => {
annoBusy = false;
advanceAnno();
}, 250);
}
function advanceAnno(){
const item = seedItems[aImg];
if(aCode + 1 < item.codes.length){ aCode++; }
else { aImg = (aImg + 1) % seedItems.length; aCode = 0; }
showAnno();
}
function voteHandler(verdict){
return () => {
if(annoBusy || !seedItems) return;
const item = seedItems[aImg];
sendVerdict(item, item.codes[aCode], {verdict: verdict});
};
}
document.getElementById("voteYes").addEventListener("click", voteHandler("yes"));
document.getElementById("voteNo").addEventListener("click", voteHandler("no"));
document.getElementById("voteSkip").addEventListener("click", voteHandler("skip"));
// depth: typographic segmented control — click selects and re-runs immediately
depthEl.querySelectorAll(".opt").forEach(opt => {
opt.addEventListener("click", () => {
if(busy) return; // don't change selection mid-inference
depthEl.querySelectorAll(".opt").forEach(o => o.classList.remove("sel"));
opt.classList.add("sel");
currentN = parseInt(opt.dataset.n, 10) || 0;
if(lastFile) classifyFile(lastFile);
});
});
</script>
</body>
</html>