Possible duplicate — ${Math.round(sprite.duplicate_similarity)}% similar to another sprite already in the set.
` : ''}
${sprite.auto_tagged ? `
Auto-labelled — 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.
`;
wireInspectorEvents(sprite);
}
function scoreRow(label, value) {
const v = value === undefined || value === null ? null : Math.round(value);
return `
${label}
${v === null ? '—' : v}
`;
}
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 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 `
Merge sprites
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.
`;
}
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 = `
Split sprite #${sprite.sprite_index}
Define two bounding boxes within the original sheet. Both are re-cropped from the source sheet — pixels are never resampled.
Box A
Box B
`;
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 => `