davanstrien HF Staff
Laptop-friendly layout: controls strip above grid, capped image heights, tighter header; fix annoView grid display
f649914 verified | <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">·</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’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">·</span><span class="opt" data-n="2">2</span><span class="sep">·</span><span class="opt" data-n="3">3</span><span class="sep">·</span><span class="opt" data-n="4">4</span><span class="sep">·</span><span class="opt" data-n="5">5</span><span class="sep">·</span><span class="opt" data-n="6">6</span><span class="sep">·</span><span class="opt" data-n="8">8</span><span class="sep">·</span><span class="opt" data-n="10">10</span><span class="unit">codes</span> | |
| </p> | |
| <button id="rerun">↻ 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 — 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…</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’s shown</span><span class="sep">·</span><span class="vote no" id="voteNo">no</span><span class="sep">·</span><span class="vote skip" id="voteSkip">can’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 — 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 => ( | |
| {"&":"&","<":"<",">":">",'"':""","'":"'"}[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">└</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">▸</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 — the model takes a few seconds…'; | |
| 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">└</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> | |