Spaces:
Paused
Paused
Download static/app.js from Cnass/sprite: direct link, hf CLI and curl.
- Browser
- Download file 50.3 kB
-
https://huggingface.co/spaces/Cnass/sprite/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/Cnass/sprite/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Cnass/sprite/resolve/main/static/app.js
50.3 kB
| /* ========================================================================== | |
| Sprite Forge — application logic | |
| Vanilla JS, no build step (this is a Flask-served static app). Talks to | |
| the /api/* endpoints in app.py. State lives in one `state` object; | |
| every mutation goes through a small set of render*() functions so the | |
| DOM never drifts out of sync with what the server actually has. | |
| ========================================================================== */ | |
| const API = { | |
| async uploadImages(files, padding) { | |
| const fd = new FormData(); | |
| for (const f of files) fd.append('files', f); | |
| fd.append('padding', padding); | |
| return fetchJSON('/api/upload/images', { method: 'POST', body: fd }); | |
| }, | |
| async uploadZip(file, padding) { | |
| const fd = new FormData(); | |
| fd.append('file', file); | |
| fd.append('padding', padding); | |
| return fetchJSON('/api/upload/zip', { method: 'POST', body: fd }); | |
| }, | |
| async listSheets() { | |
| return fetchJSON('/api/sheets'); | |
| }, | |
| async listSprites(params = {}) { | |
| const qs = new URLSearchParams(params).toString(); | |
| return fetchJSON(`/api/sprites${qs ? '?' + qs : ''}`); | |
| }, | |
| async getSprite(id) { | |
| return fetchJSON(`/api/sprite/${id}`); | |
| }, | |
| async acceptSprite(id) { | |
| return fetchJSON(`/api/sprite/${id}/accept`, { method: 'POST' }); | |
| }, | |
| async rejectSprite(id) { | |
| return fetchJSON(`/api/sprite/${id}/reject`, { method: 'POST' }); | |
| }, | |
| async updateMetadata(id, payload) { | |
| return fetchJSON(`/api/sprite/${id}/metadata`, { | |
| method: 'PATCH', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| }); | |
| }, | |
| async editBbox(id, bbox) { | |
| return fetchJSON(`/api/sprite/${id}/edit-bbox`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(bbox), | |
| }); | |
| }, | |
| async mergeSprites(ids) { | |
| return fetchJSON('/api/sprites/merge', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ sprite_ids: ids }), | |
| }); | |
| }, | |
| async splitSprite(id, boxes) { | |
| return fetchJSON('/api/sprites/split', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ sprite_id: id, boxes }), | |
| }); | |
| }, | |
| async batchAcceptAbove(threshold) { | |
| return fetchJSON('/api/batch/accept-above', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ threshold }), | |
| }); | |
| }, | |
| async batchRejectDuplicates() { | |
| return fetchJSON('/api/batch/reject-duplicates', { method: 'POST' }); | |
| }, | |
| async batchDedupeKeepOne() { | |
| return fetchJSON('/api/batch/dedupe-keep-one', { method: 'POST' }); | |
| }, | |
| async batchAssign(payload) { | |
| return fetchJSON('/api/batch/assign', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| }); | |
| }, | |
| async animationFrames(sheetId) { | |
| return fetchJSON(`/api/animation/frames?sheet_id=${encodeURIComponent(sheetId)}`); | |
| }, | |
| async datasetBalance() { | |
| return fetchJSON('/api/dataset/balance'); | |
| }, | |
| async datasetCalibration() { | |
| return fetchJSON('/api/dataset/calibration'); | |
| }, | |
| async datasetSplitPreview() { | |
| return fetchJSON('/api/dataset/split-preview'); | |
| }, | |
| async batchLowConfidence() { | |
| return fetchJSON('/api/batch/low-confidence'); | |
| }, | |
| async datasetSummary() { | |
| return fetchJSON('/api/dataset/summary'); | |
| }, | |
| async datasetUpload() { | |
| return fetchJSON('/api/dataset/upload', { method: 'POST' }); | |
| }, | |
| async getCategories() { | |
| return fetchJSON('/api/categories'); | |
| }, | |
| async addCategory(name, kind) { | |
| return fetchJSON('/api/categories', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ name, kind }), | |
| }); | |
| }, | |
| }; | |
| async function fetchJSON(url, opts) { | |
| const res = await fetch(url, opts); | |
| let body; | |
| try { | |
| body = await res.json(); | |
| } catch (e) { | |
| body = { error: `Server returned ${res.status}` }; | |
| } | |
| if (!res.ok) { | |
| const err = new Error(body.error || `Request failed (${res.status})`); | |
| err.status = res.status; | |
| throw err; | |
| } | |
| return body; | |
| } | |
| /* ========================================================================== | |
| State | |
| ========================================================================== */ | |
| const state = { | |
| sheets: [], | |
| activeSheetId: null, | |
| spritesBySheet: {}, // sheet_id -> [sprite, ...] (all statuses, for the canvas) | |
| selectedSpriteId: null, | |
| padding: 0, | |
| zoom: 1, | |
| categories: [], | |
| styles: [], | |
| mergeSelection: [], // sprite ids picked for a merge, in canvas click order | |
| mode: 'browse', // 'browse' | 'merge-pick' | 'split-pick' | |
| }; | |
| function currentSprites() { | |
| return state.spritesBySheet[state.activeSheetId] || []; | |
| } | |
| function findSprite(id) { | |
| return currentSprites().find(s => s.id === id) || null; | |
| } | |
| /* ========================================================================== | |
| Toasts | |
| ========================================================================== */ | |
| function toast(message, isError = false) { | |
| const stack = document.getElementById('toast-stack'); | |
| const el = document.createElement('div'); | |
| el.className = 'toast' + (isError ? ' error' : ''); | |
| el.textContent = message; | |
| stack.appendChild(el); | |
| setTimeout(() => el.remove(), 4200); | |
| } | |
| function reportError(err) { | |
| console.error(err); | |
| toast(err.message || 'Something went wrong', true); | |
| } | |
| /* ========================================================================== | |
| Upload flow | |
| ========================================================================== */ | |
| function setupUpload() { | |
| const imagesInput = document.getElementById('file-input-images'); | |
| const zipInput = document.getElementById('file-input-zip'); | |
| document.getElementById('btn-upload-images').addEventListener('click', () => imagesInput.click()); | |
| document.getElementById('btn-upload-zip').addEventListener('click', () => zipInput.click()); | |
| imagesInput.addEventListener('change', async (e) => { | |
| if (e.target.files.length) await handleImageUpload(Array.from(e.target.files)); | |
| e.target.value = ''; | |
| }); | |
| zipInput.addEventListener('change', async (e) => { | |
| if (e.target.files.length) await handleZipUpload(e.target.files[0]); | |
| e.target.value = ''; | |
| }); | |
| const dropzone = document.getElementById('dropzone'); | |
| dropzone.addEventListener('click', () => imagesInput.click()); | |
| ['dragenter', 'dragover'].forEach(evt => | |
| dropzone.addEventListener(evt, (e) => { e.preventDefault(); dropzone.classList.add('drag-over'); }) | |
| ); | |
| ['dragleave', 'drop'].forEach(evt => | |
| dropzone.addEventListener(evt, (e) => { e.preventDefault(); dropzone.classList.remove('drag-over'); }) | |
| ); | |
| dropzone.addEventListener('drop', async (e) => { | |
| const files = Array.from(e.dataTransfer.files || []); | |
| if (!files.length) return; | |
| const zip = files.find(f => f.name.toLowerCase().endsWith('.zip')); | |
| if (zip && files.length === 1) { | |
| await handleZipUpload(zip); | |
| } else { | |
| await handleImageUpload(files.filter(f => !f.name.toLowerCase().endsWith('.zip'))); | |
| } | |
| }); | |
| document.querySelectorAll('.padding-chip').forEach(chip => { | |
| chip.addEventListener('click', () => { | |
| document.querySelectorAll('.padding-chip').forEach(c => c.classList.remove('active')); | |
| chip.classList.add('active'); | |
| state.padding = parseInt(chip.dataset.padding, 10); | |
| }); | |
| }); | |
| } | |
| async function handleImageUpload(files) { | |
| const dropzone = document.getElementById('dropzone'); | |
| const original = dropzone.innerHTML; | |
| dropzone.innerHTML = `<div class="dropzone-icon">···</div>Processing ${files.length} file(s)…`; | |
| try { | |
| const result = await API.uploadImages(files, state.padding); | |
| reportUploadResult(result); | |
| await refreshSheets(); | |
| } catch (err) { | |
| reportError(err); | |
| } finally { | |
| dropzone.innerHTML = original; | |
| } | |
| } | |
| async function handleZipUpload(file) { | |
| const dropzone = document.getElementById('dropzone'); | |
| const original = dropzone.innerHTML; | |
| dropzone.innerHTML = `<div class="dropzone-icon">···</div>Extracting ${file.name}…`; | |
| try { | |
| const result = await API.uploadZip(file, state.padding); | |
| reportUploadResult(result); | |
| if (result.skipped && result.skipped.length) { | |
| toast(`Skipped ${result.skipped.length} file(s) in the archive (unsupported or unsafe)`, false); | |
| } | |
| await refreshSheets(); | |
| } catch (err) { | |
| reportError(err); | |
| } finally { | |
| dropzone.innerHTML = original; | |
| } | |
| } | |
| function reportUploadResult(result) { | |
| const errored = (result.sheets || []).filter(s => s.error); | |
| if (errored.length) { | |
| toast(`${errored.length} file(s) could not be processed`, true); | |
| } | |
| toast(`Detected ${result.total_sprites} sprite(s) across ${result.total_sheets} sheet(s)`); | |
| } | |
| /* ========================================================================== | |
| Sheets list + canvas rendering | |
| ========================================================================== */ | |
| async function refreshSheets() { | |
| const { sheets } = await API.listSheets(); | |
| state.sheets = sheets; | |
| renderSheetList(); | |
| if (!state.activeSheetId && sheets.length) { | |
| await selectSheet(sheets[0].id); | |
| } else if (state.activeSheetId) { | |
| // Keep the active sheet's sprite counts fresh (e.g. after an upload | |
| // added sprites to it, or a merge/split changed its count). | |
| await selectSheet(state.activeSheetId, /*keepSelection=*/true); | |
| } | |
| await refreshSummary(); | |
| } | |
| function renderSheetList() { | |
| const container = document.getElementById('sheet-list'); | |
| if (!state.sheets.length) { | |
| container.innerHTML = '<p class="empty-hint">Nothing uploaded yet. A sheet can be a single sprite or a full sheet with hundreds — either works.</p>'; | |
| return; | |
| } | |
| container.innerHTML = ''; | |
| for (const sheet of state.sheets) { | |
| const el = document.createElement('div'); | |
| el.className = 'sheet-item' + (sheet.id === state.activeSheetId ? ' active' : ''); | |
| el.innerHTML = `<span class="sheet-item-name">${escapeHtml(sheet.filename)}</span><span class="sheet-item-count">${sheet.sprite_count}</span>`; | |
| el.addEventListener('click', () => selectSheet(sheet.id)); | |
| container.appendChild(el); | |
| } | |
| document.getElementById('topbar-summary').innerHTML = | |
| `<b>${state.sheets.length}</b> sheet${state.sheets.length === 1 ? '' : 's'} · <b>${state.sheets.reduce((a, s) => a + s.sprite_count, 0)}</b> sprites detected`; | |
| } | |
| async function selectSheet(sheetId, keepSelection = false) { | |
| state.activeSheetId = sheetId; | |
| const { sprites } = await API.listSprites({ sheet_id: sheetId }); | |
| state.spritesBySheet[sheetId] = sprites; | |
| if (!keepSelection) { | |
| state.selectedSpriteId = null; | |
| exitPickMode(); | |
| } | |
| renderSheetList(); | |
| renderCanvas(); | |
| renderInspector(); | |
| updateBulkScopeHint(); | |
| } | |
| function renderCanvas() { | |
| const scroll = document.getElementById('canvas-scroll'); | |
| const sheet = state.sheets.find(s => s.id === state.activeSheetId); | |
| const zoomControls = document.getElementById('zoom-controls'); | |
| if (!sheet) { | |
| document.getElementById('canvas-title').textContent = 'No sheet selected'; | |
| zoomControls.style.display = 'none'; | |
| scroll.innerHTML = ` | |
| <div class="canvas-empty" id="canvas-empty"> | |
| <div class="canvas-empty-mark">░▒▓</div> | |
| Upload a sprite sheet to begin.<br> | |
| Detected sprites will appear here as numbered boxes — | |
| <span style="color:var(--accent)">green</span> for high confidence, | |
| <span style="color:var(--warn)">amber</span> for ones worth a second look. | |
| </div>`; | |
| return; | |
| } | |
| document.getElementById('canvas-title').textContent = | |
| `${sheet.filename} — ${sheet.width}×${sheet.height}px — ${sheet.sprite_count} sprite${sheet.sprite_count === 1 ? '' : 's'} detected`; | |
| zoomControls.style.display = 'flex'; | |
| const sprites = currentSprites(); | |
| const displayScale = computeDisplayScale(sheet.width, sheet.height) * state.zoom; | |
| const stage = document.createElement('div'); | |
| stage.className = 'sheet-stage'; | |
| stage.style.width = `${sheet.width * displayScale}px`; | |
| stage.style.height = `${sheet.height * displayScale}px`; | |
| const img = document.createElement('img'); | |
| img.src = `/api/sheet/${sheet.id}/image`; | |
| img.width = sheet.width * displayScale; | |
| img.height = sheet.height * displayScale; | |
| img.alt = sheet.filename; | |
| img.draggable = false; | |
| stage.appendChild(img); | |
| for (const sprite of sprites) { | |
| stage.appendChild(buildBboxEl(sprite, displayScale)); | |
| } | |
| scroll.innerHTML = ''; | |
| scroll.appendChild(stage); | |
| } | |
| function computeDisplayScale(w, h) { | |
| // Pick a scale that keeps small sheets legible and large sheets | |
| // reasonably sized, always a whole-pixel-friendly value where possible. | |
| // The target viewport width adapts to the actual canvas area instead of | |
| // a desktop-sized constant, so a phone-width screen doesn't force an | |
| // oversized sheet into permanent horizontal scroll on first load. | |
| const scrollEl = document.getElementById('canvas-scroll'); | |
| const available = scrollEl ? scrollEl.clientWidth - 32 : 900; // minus padding | |
| const target = Math.max(240, Math.min(900, available)); | |
| let scale = target / Math.max(w, h); | |
| if (scale > 8) scale = 8; | |
| if (scale < 0.15) scale = 0.15; | |
| return scale; | |
| } | |
| function confidenceTier(confidence) { | |
| if (confidence >= 85) return 'high'; | |
| if (confidence >= 70) return 'mid'; | |
| return 'low'; | |
| } | |
| function confidenceColorVar(confidence) { | |
| const tier = confidenceTier(confidence); | |
| if (tier === 'high') return 'var(--accent)'; | |
| if (tier === 'mid') return 'var(--warn)'; | |
| return 'var(--danger)'; | |
| } | |
| function buildBboxEl(sprite, displayScale) { | |
| const el = document.createElement('div'); | |
| el.className = 'bbox' + (sprite.id === state.selectedSpriteId ? ' selected' : '') + ` status-${sprite.status}`; | |
| el.style.setProperty('--bbox-color', confidenceColorVar(sprite.confidence)); | |
| el.style.left = `${sprite.bbox.x * displayScale}px`; | |
| el.style.top = `${sprite.bbox.y * displayScale}px`; | |
| el.style.width = `${Math.max(sprite.bbox.width * displayScale, 4)}px`; | |
| el.style.height = `${Math.max(sprite.bbox.height * displayScale, 4)}px`; | |
| const tag = document.createElement('span'); | |
| tag.className = 'bbox-tag'; | |
| tag.textContent = `#${sprite.sprite_index}`; | |
| el.appendChild(tag); | |
| el.title = `Sprite #${sprite.sprite_index} — ${Math.round(sprite.confidence)}% confidence — ${sprite.status}`; | |
| el.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| handleBboxClick(sprite.id); | |
| }); | |
| return el; | |
| } | |
| function handleBboxClick(spriteId) { | |
| if (state.mode === 'merge-pick') { | |
| toggleMergeSelection(spriteId); | |
| return; | |
| } | |
| selectSprite(spriteId); | |
| } | |
| function selectSprite(spriteId) { | |
| state.selectedSpriteId = spriteId; | |
| renderCanvas(); | |
| renderInspector(); | |
| scrollInspectorIntoViewOnMobile(); | |
| } | |
| function scrollInspectorIntoViewOnMobile() { | |
| // On the stacked mobile layout the inspector pane sits below the | |
| // canvas in normal page flow, so selecting a sprite can leave the | |
| // reviewer looking at a canvas with no visible feedback that anything | |
| // happened. On desktop the inspector is always in view in its own | |
| // column, so this is a no-op there. | |
| if (window.innerWidth > 880) return; | |
| const pane = document.getElementById('pane-right'); | |
| if (pane) pane.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| /* ========================================================================== | |
| Inspector panel | |
| ========================================================================== */ | |
| function renderInspector() { | |
| const root = document.getElementById('inspector'); | |
| const sprite = state.selectedSpriteId ? findSprite(state.selectedSpriteId) : null; | |
| if (state.mode === 'merge-pick') { | |
| root.innerHTML = buildMergePickPanel(); | |
| wireMergePickPanel(); | |
| return; | |
| } | |
| if (!sprite) { | |
| root.innerHTML = '<div class="inspector-empty">Select a sprite on the canvas to review it here.</div>'; | |
| return; | |
| } | |
| const q = sprite.quality || {}; | |
| const tier = confidenceTier(sprite.confidence); | |
| const tierLabel = tier === 'high' ? 'Confident detection' : tier === 'mid' ? 'Worth a second look' : 'Possible segmentation problem'; | |
| root.innerHTML = ` | |
| <div class="pane-section"> | |
| <p class="pane-label">Sprite #${sprite.sprite_index}</p> | |
| <div class="preview-frame"> | |
| <img src="${sprite.preview_url}" alt="Sprite #${sprite.sprite_index} preview"> | |
| </div> | |
| <div class="sprite-id">${sprite.id}</div> | |
| <div class="dim-row"> | |
| <span class="dims">${sprite.bbox.width} × ${sprite.bbox.height}px</span> | |
| <span class="source">${escapeHtml(sprite.source_file)}</span> | |
| </div> | |
| <div class="confidence-banner ${tier}"> | |
| <span class="conf-value">${Math.round(sprite.confidence)}%</span> | |
| <span>${tierLabel}</span> | |
| </div> | |
| ${sprite.confidence_reasons && sprite.confidence_reasons.length ? ` | |
| <ul class="confidence-reasons"> | |
| ${sprite.confidence_reasons.map(r => `<li>${escapeHtml(r)}</li>`).join('')} | |
| </ul>` : ''} | |
| ${sprite.is_duplicate_of ? ` | |
| <div class="dup-banner"> | |
| <b>Possible duplicate</b> — ${Math.round(sprite.duplicate_similarity)}% similar to another sprite already in the set. | |
| </div>` : ''} | |
| ${sprite.auto_tagged ? ` | |
| <div class="auto-tag-banner"> | |
| <b>Auto-labelled</b> — category, style and tags below are a machine suggestion | |
| (${Math.round(sprite.auto_tag_confidence || 0)}% confidence), not a reviewed decision. | |
| Changing any of them marks this sprite as human-checked. | |
| </div>` : ''} | |
| <div class="score-grid"> | |
| ${scoreRow('Overall quality', q.overall_quality_score)} | |
| ${scoreRow('Pixel art probability', q.pixel_art_probability)} | |
| ${scoreRow('Sharpness', q.sharpness_score)} | |
| ${scoreRow('Color consistency', q.color_consistency)} | |
| ${scoreRow('Compression', q.compression_artifact_score)} | |
| ${scoreRow('Transparency quality', q.transparency_quality)} | |
| ${scoreRow('Resolution suitability', q.resolution_suitability)} | |
| ${scoreRow('Noise', q.noise_score)} | |
| <div class="k">Unique colors</div><div class="v">${q.unique_colors ?? '—'}</div> | |
| <div class="k">Components merged</div><div class="v">${sprite.component_count}</div> | |
| </div> | |
| <div class="bg-toggle"> | |
| <button class="padding-chip active" data-bg="kept" id="bg-kept">Keep Background</button> | |
| <button class="padding-chip" data-bg="transparent" id="bg-transparent">Transparent</button> | |
| </div> | |
| <div class="field-group"> | |
| <label class="field-label">Category</label> | |
| <select class="field-select" id="field-category"> | |
| ${state.categories.map(c => `<option value="${c}" ${c === sprite.category ? 'selected' : ''}>${c}</option>`).join('')} | |
| </select> | |
| </div> | |
| <div class="field-group"> | |
| <label class="field-label">Style</label> | |
| <select class="field-select" id="field-style"> | |
| ${state.styles.map(s => `<option value="${s}" ${s === sprite.style ? 'selected' : ''}>${s}</option>`).join('')} | |
| </select> | |
| </div> | |
| <div class="field-group"> | |
| <label class="field-label">Tags</label> | |
| <input class="field-input" id="field-tag-input" placeholder="Type a tag and press Enter"> | |
| <div class="tag-list" id="tag-list"> | |
| ${(sprite.tags || []).map(t => `<span class="tag-chip">${escapeHtml(t)}<button data-tag="${escapeHtml(t)}">×</button></span>`).join('')} | |
| </div> | |
| </div> | |
| <div class="field-group"> | |
| <label class="field-label">Edit bounding box</label> | |
| <div class="edit-row"> | |
| <div><label>X</label><input type="number" id="edit-x" value="${sprite.bbox.x}"></div> | |
| <div><label>Y</label><input type="number" id="edit-y" value="${sprite.bbox.y}"></div> | |
| <div><label>W</label><input type="number" id="edit-w" value="${sprite.bbox.width}"></div> | |
| <div><label>H</label><input type="number" id="edit-h" value="${sprite.bbox.height}"></div> | |
| </div> | |
| <button class="btn btn-sm btn-block" id="btn-apply-edit" style="margin-top:8px;" ${sprite.status !== 'pending' ? 'disabled' : ''}>Apply Edit</button> | |
| </div> | |
| <div class="field-group"> | |
| <button class="btn btn-sm btn-block" id="btn-start-merge" ${sprite.status !== 'pending' ? 'disabled' : ''}>Merge with another sprite…</button> | |
| <button class="btn btn-sm btn-block" id="btn-start-split" style="margin-top:6px;" ${sprite.status !== 'pending' ? 'disabled' : ''}>Split this sprite…</button> | |
| </div> | |
| <div class="action-row"> | |
| <button class="btn btn-primary" id="btn-accept" ${sprite.status === 'accepted' ? 'disabled' : ''}>Accept</button> | |
| <button class="btn btn-danger" id="btn-reject" ${sprite.status === 'rejected' ? 'disabled' : ''}>Reject</button> | |
| </div> | |
| </div> | |
| `; | |
| wireInspectorEvents(sprite); | |
| } | |
| function scoreRow(label, value) { | |
| const v = value === undefined || value === null ? null : Math.round(value); | |
| return ` | |
| <div class="k">${label}</div><div class="v">${v === null ? '—' : v}</div> | |
| <div class="score-bar"><div class="score-bar-fill" style="width:${v ?? 0}%; background:${scoreColor(v)}"></div></div> | |
| `; | |
| } | |
| function scoreColor(v) { | |
| if (v === null) return 'var(--ink-faint)'; | |
| if (v >= 80) return 'var(--accent)'; | |
| if (v >= 55) return 'var(--warn)'; | |
| return 'var(--danger)'; | |
| } | |
| function wireInspectorEvents(sprite) { | |
| document.getElementById('btn-accept').addEventListener('click', () => doAccept(sprite.id)); | |
| document.getElementById('btn-reject').addEventListener('click', () => doReject(sprite.id)); | |
| document.getElementById('bg-kept').addEventListener('click', () => setPreviewVariant(sprite, 'kept')); | |
| document.getElementById('bg-transparent').addEventListener('click', () => setPreviewVariant(sprite, 'transparent')); | |
| document.getElementById('field-category').addEventListener('change', (e) => doUpdateMetadata(sprite.id, { category: e.target.value })); | |
| document.getElementById('field-style').addEventListener('change', (e) => doUpdateMetadata(sprite.id, { style: e.target.value })); | |
| const tagInput = document.getElementById('field-tag-input'); | |
| tagInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && tagInput.value.trim()) { | |
| e.preventDefault(); | |
| const newTags = [...(sprite.tags || []), tagInput.value.trim()]; | |
| doUpdateMetadata(sprite.id, { tags: newTags }); | |
| } | |
| }); | |
| document.querySelectorAll('#tag-list button').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const newTags = (sprite.tags || []).filter(t => t !== btn.dataset.tag); | |
| doUpdateMetadata(sprite.id, { tags: newTags }); | |
| }); | |
| }); | |
| document.getElementById('btn-apply-edit').addEventListener('click', () => { | |
| const bbox = { | |
| x: parseInt(document.getElementById('edit-x').value, 10), | |
| y: parseInt(document.getElementById('edit-y').value, 10), | |
| width: parseInt(document.getElementById('edit-w').value, 10), | |
| height: parseInt(document.getElementById('edit-h').value, 10), | |
| }; | |
| if ([bbox.x, bbox.y, bbox.width, bbox.height].some(Number.isNaN) || bbox.width <= 0 || bbox.height <= 0) { | |
| toast('Enter valid numbers — width and height must be positive', true); | |
| return; | |
| } | |
| doEditBbox(sprite.id, bbox); | |
| }); | |
| document.getElementById('btn-start-merge').addEventListener('click', () => enterMergeMode(sprite.id)); | |
| document.getElementById('btn-start-split').addEventListener('click', () => openSplitModal(sprite)); | |
| } | |
| function setPreviewVariant(sprite, variant) { | |
| document.getElementById('bg-kept').classList.toggle('active', variant === 'kept'); | |
| document.getElementById('bg-transparent').classList.toggle('active', variant === 'transparent'); | |
| const img = document.querySelector('.preview-frame img'); | |
| img.src = variant === 'kept' ? sprite.preview_url : sprite.png_transparent_url + '&preview=1'; | |
| // Transparent variant has no dedicated NEAREST-preview endpoint of its | |
| // own beyond the raw crop; browsers still render it crisp because the | |
| // frame and <img> both carry image-rendering:pixelated, so this is a | |
| // safe fallback that still looks correct at typical sprite sizes. | |
| } | |
| async function doAccept(id) { | |
| try { | |
| await API.acceptSprite(id); | |
| toast('Sprite accepted'); | |
| await afterSpriteMutation(); | |
| } catch (err) { reportError(err); } | |
| } | |
| async function doReject(id) { | |
| try { | |
| await API.rejectSprite(id); | |
| toast('Sprite rejected'); | |
| await afterSpriteMutation(); | |
| } catch (err) { reportError(err); } | |
| } | |
| async function doUpdateMetadata(id, payload) { | |
| try { | |
| const updated = await API.updateMetadata(id, payload); | |
| patchSpriteInState(updated); | |
| if (payload.category || payload.style) await refreshCategories(); | |
| renderInspector(); | |
| } catch (err) { reportError(err); } | |
| } | |
| async function doEditBbox(id, bbox) { | |
| try { | |
| const updated = await API.editBbox(id, bbox); | |
| patchSpriteInState(updated); | |
| renderCanvas(); | |
| renderInspector(); | |
| toast('Bounding box updated'); | |
| } catch (err) { reportError(err); } | |
| } | |
| function patchSpriteInState(updatedSprite) { | |
| const list = state.spritesBySheet[updatedSprite.sheet_id]; | |
| if (!list) return; | |
| const idx = list.findIndex(s => s.id === updatedSprite.id); | |
| if (idx >= 0) list[idx] = updatedSprite; | |
| else list.push(updatedSprite); | |
| } | |
| async function afterSpriteMutation() { | |
| await selectSheet(state.activeSheetId, true); | |
| await refreshSummary(); | |
| } | |
| /* ========================================================================== | |
| Merge flow | |
| ========================================================================== */ | |
| function enterMergeMode(firstSpriteId) { | |
| state.mode = 'merge-pick'; | |
| state.mergeSelection = [firstSpriteId]; | |
| renderCanvas(); | |
| renderInspector(); | |
| toast('Click another sprite on the canvas to merge with this one'); | |
| } | |
| function toggleMergeSelection(spriteId) { | |
| const i = state.mergeSelection.indexOf(spriteId); | |
| if (i >= 0) { | |
| state.mergeSelection.splice(i, 1); | |
| } else { | |
| state.mergeSelection.push(spriteId); | |
| } | |
| if (state.mergeSelection.length > 2) state.mergeSelection.shift(); | |
| renderCanvas(); | |
| renderInspector(); | |
| } | |
| function buildMergePickPanel() { | |
| const picked = state.mergeSelection.map(id => findSprite(id)).filter(Boolean); | |
| return ` | |
| <div class="pane-section"> | |
| <p class="pane-label">Merge sprites</p> | |
| <p class="modal-desc">Click a second sprite on the canvas. Merge combines both into one sprite using their combined bounding box — pixels are re-cropped from the original sheet, nothing is resampled.</p> | |
| <div class="tag-list"> | |
| ${picked.map(s => `<span class="tag-chip">#${s.sprite_index}</span>`).join('') || '<span class="tag-chip">none yet</span>'} | |
| </div> | |
| <div class="action-row"> | |
| <button class="btn btn-primary" id="btn-confirm-merge" ${picked.length === 2 ? '' : 'disabled'}>Merge</button> | |
| <button class="btn btn-ghost" id="btn-cancel-merge">Cancel</button> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| function wireMergePickPanel() { | |
| document.getElementById('btn-cancel-merge').addEventListener('click', exitPickMode); | |
| const confirmBtn = document.getElementById('btn-confirm-merge'); | |
| if (confirmBtn) { | |
| confirmBtn.addEventListener('click', async () => { | |
| try { | |
| const merged = await API.mergeSprites(state.mergeSelection); | |
| toast(`Merged into sprite #${merged.sprite_index}`); | |
| exitPickMode(); | |
| await selectSheet(state.activeSheetId, true); | |
| state.selectedSpriteId = merged.id; | |
| renderCanvas(); | |
| renderInspector(); | |
| await refreshSummary(); | |
| } catch (err) { reportError(err); } | |
| }); | |
| } | |
| } | |
| function exitPickMode() { | |
| state.mode = 'browse'; | |
| state.mergeSelection = []; | |
| } | |
| /* ========================================================================== | |
| Split flow (modal with two editable boxes seeded from the sprite bbox) | |
| ========================================================================== */ | |
| function openSplitModal(sprite) { | |
| const root = document.getElementById('modal-root'); | |
| const b = sprite.bbox; | |
| // Seed two side-by-side halves as a starting point the user can then | |
| // adjust — most splits are "this merged two side-by-side things", so a | |
| // horizontal half-split is the most common useful default. | |
| const half = Math.max(1, Math.floor(b.width / 2)); | |
| root.innerHTML = ` | |
| <div class="modal-backdrop" id="split-backdrop"> | |
| <div class="modal"> | |
| <p class="modal-title">Split sprite #${sprite.sprite_index}</p> | |
| <p class="modal-desc">Define two bounding boxes within the original sheet. Both are re-cropped from the source sheet — pixels are never resampled.</p> | |
| <p class="pane-label" style="margin-top:2px;">Box A</p> | |
| <div class="edit-row"> | |
| <div><label>X</label><input type="number" id="split-a-x" value="${b.x}"></div> | |
| <div><label>Y</label><input type="number" id="split-a-y" value="${b.y}"></div> | |
| <div><label>W</label><input type="number" id="split-a-w" value="${half}"></div> | |
| <div><label>H</label><input type="number" id="split-a-h" value="${b.height}"></div> | |
| </div> | |
| <p class="pane-label" style="margin-top:12px;">Box B</p> | |
| <div class="edit-row"> | |
| <div><label>X</label><input type="number" id="split-b-x" value="${b.x + half}"></div> | |
| <div><label>Y</label><input type="number" id="split-b-y" value="${b.y}"></div> | |
| <div><label>W</label><input type="number" id="split-b-w" value="${b.width - half}"></div> | |
| <div><label>H</label><input type="number" id="split-b-h" value="${b.height}"></div> | |
| </div> | |
| <div class="modal-actions"> | |
| <button class="btn btn-ghost" id="split-cancel">Cancel</button> | |
| <button class="btn btn-primary" id="split-confirm">Split</button> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| document.getElementById('split-cancel').addEventListener('click', closeModal); | |
| document.getElementById('split-backdrop').addEventListener('click', (e) => { | |
| if (e.target.id === 'split-backdrop') closeModal(); | |
| }); | |
| document.getElementById('split-confirm').addEventListener('click', async () => { | |
| const boxA = readBoxInputs('split-a'); | |
| const boxB = readBoxInputs('split-b'); | |
| if (!boxA || !boxB) { | |
| toast('Enter valid numbers for both boxes — width and height must be positive', true); | |
| return; | |
| } | |
| try { | |
| const result = await API.splitSprite(sprite.id, [boxA, boxB]); | |
| toast(`Split into #${result.sprite_a.sprite_index} and #${result.sprite_b.sprite_index}`); | |
| closeModal(); | |
| await selectSheet(state.activeSheetId, true); | |
| state.selectedSpriteId = result.sprite_a.id; | |
| renderCanvas(); | |
| renderInspector(); | |
| await refreshSummary(); | |
| } catch (err) { reportError(err); } | |
| }); | |
| } | |
| function readBoxInputs(prefix) { | |
| const x = parseInt(document.getElementById(`${prefix}-x`).value, 10); | |
| const y = parseInt(document.getElementById(`${prefix}-y`).value, 10); | |
| const width = parseInt(document.getElementById(`${prefix}-w`).value, 10); | |
| const height = parseInt(document.getElementById(`${prefix}-h`).value, 10); | |
| if ([x, y, width, height].some(Number.isNaN) || width <= 0 || height <= 0) return null; | |
| return { x, y, width, height }; | |
| } | |
| function closeModal() { | |
| document.getElementById('modal-root').innerHTML = ''; | |
| } | |
| /* ========================================================================== | |
| Batch controls + dataset summary + HF upload | |
| ========================================================================== */ | |
| async function refreshSummary() { | |
| try { | |
| const summary = await API.datasetSummary(); | |
| document.getElementById('stat-pending').textContent = summary.pending; | |
| document.getElementById('stat-accepted').textContent = summary.accepted; | |
| document.getElementById('stat-rejected').textContent = summary.rejected; | |
| document.getElementById('stat-duplicates').textContent = summary.duplicates; | |
| } catch (err) { | |
| console.error(err); | |
| } | |
| } | |
| async function refreshCategories() { | |
| const { categories, styles } = await API.getCategories(); | |
| state.categories = categories; | |
| state.styles = styles; | |
| renderCategoryOptions(); | |
| } | |
| function setupBatchControls() { | |
| document.getElementById('btn-accept-above').addEventListener('click', async () => { | |
| try { | |
| const result = await API.batchAcceptAbove(90); | |
| toast(`Accepted ${result.accepted_count} sprite(s) scoring 90+`); | |
| await selectSheet(state.activeSheetId, true); | |
| await refreshSummary(); | |
| } catch (err) { reportError(err); } | |
| }); | |
| document.getElementById('btn-dedupe-keep-one').addEventListener('click', async () => { | |
| try { | |
| const result = await API.batchDedupeKeepOne(); | |
| if (!result.cluster_count) { | |
| toast('No duplicate clusters among pending sprites'); | |
| } else { | |
| toast(`${result.cluster_count} cluster(s): kept ${result.kept_count}, rejected ${result.rejected_count} redundant copy(ies)`); | |
| } | |
| await selectSheet(state.activeSheetId, true); | |
| await refreshSummary(); | |
| } catch (err) { reportError(err); } | |
| }); | |
| document.getElementById('btn-dataset-report').addEventListener('click', openDatasetReport); | |
| document.getElementById('btn-reject-dupes').addEventListener('click', async () => { | |
| try { | |
| const result = await API.batchRejectDuplicates(); | |
| toast(`Rejected ${result.rejected_count} duplicate(s)`); | |
| await selectSheet(state.activeSheetId, true); | |
| await refreshSummary(); | |
| } catch (err) { reportError(err); } | |
| }); | |
| document.getElementById('btn-review-low-conf').addEventListener('click', async () => { | |
| try { | |
| const { sprites } = await API.batchLowConfidence(); | |
| if (!sprites.length) { | |
| toast('No low-confidence sprites pending review'); | |
| return; | |
| } | |
| // Jump to the first low-confidence sprite's sheet and select it — | |
| // the fastest path into what actually needs a human look. | |
| await selectSheet(sprites[0].sheet_id); | |
| selectSprite(sprites[0].id); | |
| toast(`${sprites.length} sprite(s) need review — showing the first`); | |
| } catch (err) { reportError(err); } | |
| }); | |
| document.getElementById('btn-upload-hf').addEventListener('click', async () => { | |
| const btn = document.getElementById('btn-upload-hf'); | |
| const original = btn.textContent; | |
| btn.disabled = true; | |
| btn.textContent = 'Uploading…'; | |
| try { | |
| const result = await API.datasetUpload(); | |
| if (result.success) { | |
| if (result.uploaded_count === 0) { | |
| toast('Nothing to upload — accept some sprites first'); | |
| } else { | |
| toast(`Uploaded ${result.uploaded_count} sprite(s) to the dataset in one batch commit`); | |
| } | |
| await refreshSummary(); | |
| } else { | |
| toast(result.error || 'Upload failed', true); | |
| } | |
| } catch (err) { | |
| reportError(err); | |
| } finally { | |
| btn.disabled = false; | |
| btn.textContent = original; | |
| } | |
| }); | |
| } | |
| /* ========================================================================== | |
| Bulk metadata assignment | |
| The realistic labelling workflow for a sheet of 300 tiles is not "click | |
| 300 times", it is "this whole sheet is trees". Tags are ADDITIVE by | |
| default so a bulk pass adds "forest" on top of whatever the auto-tagger | |
| worked out per sprite, instead of flattening every sprite to the same | |
| tag list. | |
| ========================================================================== */ | |
| function setupBulkAssign() { | |
| const scopeSelect = document.getElementById('bulk-scope'); | |
| scopeSelect.addEventListener('change', updateBulkScopeHint); | |
| document.getElementById('btn-bulk-apply').addEventListener('click', async () => { | |
| const scope = scopeSelect.value; | |
| if (scope === 'sheet' && !state.activeSheetId) { | |
| toast('Select a sheet first', true); | |
| return; | |
| } | |
| const category = document.getElementById('bulk-category').value.trim(); | |
| const style = document.getElementById('bulk-style').value.trim(); | |
| const rawTags = document.getElementById('bulk-tags').value.trim(); | |
| const replace = document.getElementById('bulk-replace-tags').checked; | |
| const tags = rawTags ? rawTags.split(',').map(t => t.trim()).filter(Boolean) : []; | |
| if (!category && !style && !tags.length) { | |
| toast('Enter a category, a style or some tags first', true); | |
| return; | |
| } | |
| const payload = { scope, category, style }; | |
| if (scope === 'sheet') payload.sheet_id = state.activeSheetId; | |
| if (scope === 'status') { payload.scope = 'status'; payload.status = 'pending'; } | |
| if (replace) payload.replace_tags = tags; | |
| else payload.add_tags = tags; | |
| try { | |
| const result = await API.batchAssign(payload); | |
| toast(`Updated ${result.updated_count} sprite(s)`); | |
| document.getElementById('bulk-tags').value = ''; | |
| await refreshCategories(); | |
| renderCategoryOptions(); | |
| if (state.activeSheetId) await selectSheet(state.activeSheetId, true); | |
| } catch (err) { reportError(err); } | |
| }); | |
| updateBulkScopeHint(); | |
| } | |
| function updateBulkScopeHint() { | |
| const scope = document.getElementById('bulk-scope').value; | |
| const hint = document.getElementById('bulk-scope-hint'); | |
| const sheet = state.sheets.find(s => s.id === state.activeSheetId); | |
| if (scope === 'sheet') { | |
| hint.textContent = sheet | |
| ? `${sheet.sprite_count} sprite(s) on ${sheet.filename}` | |
| : 'No sheet selected yet.'; | |
| } else if (scope === 'status') { | |
| hint.textContent = 'Every sprite still awaiting review, across all sheets.'; | |
| } else { | |
| hint.textContent = 'Every sprite in the database, whatever its status.'; | |
| } | |
| } | |
| function renderCategoryOptions() { | |
| const catList = document.getElementById('bulk-category-options'); | |
| const styleList = document.getElementById('bulk-style-options'); | |
| if (catList) catList.innerHTML = state.categories.map(c => `<option value="${escapeHtml(c)}">`).join(''); | |
| if (styleList) styleList.innerHTML = state.styles.map(s => `<option value="${escapeHtml(s)}">`).join(''); | |
| } | |
| /* ========================================================================== | |
| Animation preview | |
| Frames are drawn onto ONE shared canvas at their original sheet | |
| positions. Playing the tight crops back-to-back instead would make the | |
| animation wobble: each sprite is cropped to its own content, so a frame | |
| whose flames reach one pixel further left would start one pixel further | |
| left. The server returns per-frame offsets into a common canvas exactly | |
| so this can be avoided. | |
| ========================================================================== */ | |
| const animation = { timer: null, frames: [], index: 0, fps: 12, playing: false, canvas: null }; | |
| async function openAnimationModal() { | |
| if (!state.activeSheetId) { | |
| toast('Select a sheet first', true); | |
| return; | |
| } | |
| let data; | |
| try { | |
| data = await API.animationFrames(state.activeSheetId); | |
| } catch (err) { reportError(err); return; } | |
| if (!data.frames.length) { | |
| toast('No sprites on this sheet to animate', true); | |
| return; | |
| } | |
| animation.frames = data.frames; | |
| animation.canvas = data.canvas; | |
| animation.index = 0; | |
| animation.playing = true; | |
| const sheet = state.sheets.find(s => s.id === state.activeSheetId); | |
| const scale = Math.max(1, Math.min(8, Math.floor(340 / Math.max(1, Math.max(data.canvas.width, data.canvas.height))))); | |
| document.getElementById('modal-root').innerHTML = ` | |
| <div class="modal-backdrop" id="anim-backdrop"> | |
| <div class="modal modal-wide"> | |
| <p class="modal-title">Animation preview — ${escapeHtml(sheet ? sheet.filename : '')}</p> | |
| <p class="modal-desc"> | |
| ${data.frames.length} frames, played in sheet order. Each frame is drawn at its original | |
| position on the sheet, so this shows the motion as authored rather than a sequence of | |
| re-centred crops. | |
| </p> | |
| <div class="anim-stage" id="anim-stage" | |
| style="width:${data.canvas.width * scale}px; height:${data.canvas.height * scale}px;"> | |
| ${data.frames.map((f, i) => ` | |
| <img class="anim-frame${i === 0 ? ' visible' : ''}" data-i="${i}" src="${f.url}" | |
| style="left:${f.offset_x * scale}px; top:${f.offset_y * scale}px; | |
| width:${f.width * scale}px; height:${f.height * scale}px;">`).join('')} | |
| </div> | |
| <div class="anim-controls"> | |
| <button class="btn btn-sm" id="anim-play">Pause</button> | |
| <button class="btn btn-sm btn-ghost" id="anim-prev">◀</button> | |
| <span class="anim-counter" id="anim-counter">1 / ${data.frames.length}</span> | |
| <button class="btn btn-sm btn-ghost" id="anim-next">▶</button> | |
| <label class="anim-fps"> | |
| <span id="anim-fps-label">${animation.fps} fps</span> | |
| <input type="range" id="anim-fps" min="1" max="30" value="${animation.fps}"> | |
| </label> | |
| </div> | |
| <div class="modal-actions"> | |
| <button class="btn btn-ghost" id="anim-close">Close</button> | |
| </div> | |
| </div> | |
| </div>`; | |
| document.getElementById('anim-close').addEventListener('click', closeAnimationModal); | |
| document.getElementById('anim-backdrop').addEventListener('click', (e) => { | |
| if (e.target.id === 'anim-backdrop') closeAnimationModal(); | |
| }); | |
| document.getElementById('anim-play').addEventListener('click', toggleAnimationPlayback); | |
| document.getElementById('anim-prev').addEventListener('click', () => stepAnimation(-1)); | |
| document.getElementById('anim-next').addEventListener('click', () => stepAnimation(1)); | |
| document.getElementById('anim-fps').addEventListener('input', (e) => { | |
| animation.fps = parseInt(e.target.value, 10); | |
| document.getElementById('anim-fps-label').textContent = `${animation.fps} fps`; | |
| if (animation.playing) startAnimationTimer(); | |
| }); | |
| startAnimationTimer(); | |
| } | |
| function startAnimationTimer() { | |
| clearInterval(animation.timer); | |
| animation.timer = setInterval(() => { | |
| animation.index = (animation.index + 1) % animation.frames.length; | |
| showAnimationFrame(); | |
| }, 1000 / animation.fps); | |
| } | |
| function showAnimationFrame() { | |
| const stage = document.getElementById('anim-stage'); | |
| if (!stage) { clearInterval(animation.timer); return; } | |
| stage.querySelectorAll('.anim-frame').forEach(img => { | |
| img.classList.toggle('visible', parseInt(img.dataset.i, 10) === animation.index); | |
| }); | |
| const counter = document.getElementById('anim-counter'); | |
| if (counter) counter.textContent = `${animation.index + 1} / ${animation.frames.length}`; | |
| } | |
| function stepAnimation(delta) { | |
| pauseAnimation(); | |
| const n = animation.frames.length; | |
| animation.index = (animation.index + delta + n) % n; | |
| showAnimationFrame(); | |
| } | |
| function pauseAnimation() { | |
| animation.playing = false; | |
| clearInterval(animation.timer); | |
| const btn = document.getElementById('anim-play'); | |
| if (btn) btn.textContent = 'Play'; | |
| } | |
| function toggleAnimationPlayback() { | |
| if (animation.playing) { | |
| pauseAnimation(); | |
| } else { | |
| animation.playing = true; | |
| document.getElementById('anim-play').textContent = 'Pause'; | |
| startAnimationTimer(); | |
| } | |
| } | |
| function closeAnimationModal() { | |
| clearInterval(animation.timer); | |
| animation.playing = false; | |
| closeModal(); | |
| } | |
| /* ========================================================================== | |
| Dataset report — composition, quality calibration, split | |
| ========================================================================== */ | |
| async function openDatasetReport() { | |
| let balance, calibration, split; | |
| try { | |
| [balance, calibration, split] = await Promise.all([ | |
| API.datasetBalance(), API.datasetCalibration(), API.datasetSplitPreview(), | |
| ]); | |
| } catch (err) { reportError(err); return; } | |
| const warnings = [...(balance.warnings || []), ...(split.warnings || [])]; | |
| document.getElementById('modal-root').innerHTML = ` | |
| <div class="modal-backdrop" id="report-backdrop"> | |
| <div class="modal modal-wide modal-scroll"> | |
| <p class="modal-title">Dataset report</p> | |
| <p class="modal-desc"> | |
| What the ${balance.total} accepted sprite(s) look like as training data — not how many | |
| there are, but whether they are worth training on. | |
| </p> | |
| ${warnings.length ? ` | |
| <div class="report-warnings"> | |
| ${warnings.map(w => `<div class="report-warning">${escapeHtml(w)}</div>`).join('')} | |
| </div>` : `<div class="report-ok">No composition problems detected.</div>`} | |
| ${distributionBlock('Category', balance.by_category, balance.total)} | |
| ${distributionBlock('Size', balance.by_size, balance.total)} | |
| ${distributionBlock('Style', balance.by_style, balance.total)} | |
| ${distributionBlock('Segmentation source', balance.by_source, balance.total)} | |
| <p class="pane-label" style="margin-top:16px;">Train / validation / test</p> | |
| <p class="modal-desc" style="margin-top:2px;"> | |
| ${split.grouped_by_sheet | |
| ? 'Grouped by source sheet, so an animation\'s near-identical frames never straddle train and test — otherwise the evaluation measures memorisation.' | |
| : 'Split per sprite. Frames of one animation can land on both sides, which inflates evaluation scores.'} | |
| </p> | |
| ${distributionBlock('', split.counts, split.total)} | |
| <p class="pane-label" style="margin-top:16px;">Quality score calibration</p> | |
| <div class="report-note">${escapeHtml(calibration.note || '')}</div> | |
| <div class="score-grid" style="margin-top:8px;"> | |
| <div class="k">Accepted (mean score)</div><div class="v">${calibration.accepted_mean ?? '—'}</div><div></div> | |
| <div class="k">Rejected (mean score)</div><div class="v">${calibration.rejected_mean ?? '—'}</div><div></div> | |
| <div class="k">Current threshold</div><div class="v">${calibration.current_threshold}</div><div></div> | |
| <div class="k">Suggested threshold</div><div class="v">${calibration.suggested_threshold ?? '—'}</div><div></div> | |
| <div class="k">Agreement with you</div><div class="v">${calibration.agreement_at_suggested != null ? calibration.agreement_at_suggested + '%' : '—'}</div><div></div> | |
| </div> | |
| <p class="pane-label" style="margin-top:16px;">Metadata provenance</p> | |
| <div class="score-grid"> | |
| <div class="k">Unreviewed machine labels</div><div class="v">${balance.auto_tagged_count} / ${balance.total}</div><div></div> | |
| <div class="k">No category</div><div class="v">${balance.unknown_category_count}</div><div></div> | |
| <div class="k">Still flagged duplicate</div><div class="v">${balance.duplicate_count}</div><div></div> | |
| <div class="k">Source sheets</div><div class="v">${balance.sheet_count}</div><div></div> | |
| </div> | |
| <div class="modal-actions"> | |
| <button class="btn btn-ghost" id="report-close">Close</button> | |
| </div> | |
| </div> | |
| </div>`; | |
| document.getElementById('report-close').addEventListener('click', closeModal); | |
| document.getElementById('report-backdrop').addEventListener('click', (e) => { | |
| if (e.target.id === 'report-backdrop') closeModal(); | |
| }); | |
| } | |
| function distributionBlock(title, counts, total) { | |
| const entries = Object.entries(counts || {}); | |
| if (!entries.length) return ''; | |
| const max = Math.max(...entries.map(([, n]) => n)); | |
| return ` | |
| ${title ? `<p class="pane-label" style="margin-top:16px;">${title}</p>` : ''} | |
| <div class="dist-list"> | |
| ${entries.map(([name, n]) => ` | |
| <div class="dist-row"> | |
| <span class="dist-name">${escapeHtml(name)}</span> | |
| <span class="dist-bar"><span class="dist-bar-fill" style="width:${(n / max) * 100}%"></span></span> | |
| <span class="dist-count">${n}${total ? ` · ${Math.round((n / total) * 100)}%` : ''}</span> | |
| </div>`).join('')} | |
| </div>`; | |
| } | |
| /* ========================================================================== | |
| Zoom controls | |
| ========================================================================== */ | |
| function setupZoomControls() { | |
| document.getElementById('btn-animate').addEventListener('click', openAnimationModal); | |
| document.getElementById('zoom-in').addEventListener('click', () => { | |
| state.zoom = Math.min(4, state.zoom * 1.25); | |
| updateZoomLabel(); | |
| renderCanvas(); | |
| }); | |
| document.getElementById('zoom-out').addEventListener('click', () => { | |
| state.zoom = Math.max(0.25, state.zoom / 1.25); | |
| updateZoomLabel(); | |
| renderCanvas(); | |
| }); | |
| document.getElementById('zoom-reset').addEventListener('click', () => { | |
| state.zoom = 1; | |
| updateZoomLabel(); | |
| renderCanvas(); | |
| }); | |
| } | |
| function updateZoomLabel() { | |
| document.getElementById('zoom-label').textContent = `${Math.round(state.zoom * 100)}%`; | |
| } | |
| /* ========================================================================== | |
| Utilities + init | |
| ========================================================================== */ | |
| function escapeHtml(str) { | |
| const div = document.createElement('div'); | |
| div.textContent = str; | |
| return div.innerHTML; | |
| } | |
| async function init() { | |
| setupUpload(); | |
| setupBatchControls(); | |
| setupZoomControls(); | |
| setupBulkAssign(); | |
| setupResizeHandling(); | |
| try { | |
| await refreshCategories(); | |
| renderCategoryOptions(); | |
| await refreshSheets(); | |
| } catch (err) { | |
| reportError(err); | |
| } | |
| } | |
| function setupResizeHandling() { | |
| // Re-render the canvas on viewport size changes (phone rotation, browser | |
| // chrome show/hide) so the sheet's initial-fit scale stays correct | |
| // instead of freezing at whatever size it first loaded at. Debounced | |
| // since resize/orientationchange can fire in rapid bursts. | |
| let resizeTimer = null; | |
| const onResize = () => { | |
| clearTimeout(resizeTimer); | |
| resizeTimer = setTimeout(() => { | |
| if (state.activeSheetId) renderCanvas(); | |
| }, 150); | |
| }; | |
| window.addEventListener('resize', onResize); | |
| window.addEventListener('orientationchange', onResize); | |
| } | |
| document.addEventListener('DOMContentLoaded', init); | |