feat(dashboard): compare mode UI, rating sort/badge, scoreboard modal
Blind two-up compare overlay (keys, reveal, undo, vote queue with debounced save), cog PAIRWISE REVIEW section, Rating sort option and label badge, scoreboard modal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
02f0766566
commit
fadcd0405f
@@ -17,10 +17,12 @@ def get_html_template(title, manifest_data, node_id):
|
||||
js_files = [
|
||||
"logic_state.js", # Global variables
|
||||
"logic_utils.js", # API & Helper functions
|
||||
"logic_ratings.js", # Pairwise review: pure logic (normalisers, Bradley-Terry, pairing, scoreboard)
|
||||
"logic_ui.js", # DOM Creation, Modals, JSON Bars
|
||||
"logic_upscale_modal.js", # Upscale Modal (preset management, async upscale)
|
||||
"logic_virtual.js", # The Virtual Scroll Engine & Viewport
|
||||
"logic_pipeline.js", # Sorting & Filtering Logic
|
||||
"logic_compare.js", # Pairwise review: compare overlay, votes, scoreboard modal
|
||||
"logic_events.js", # Event Listeners (Message, Keyboard)
|
||||
"logic_init.js" # Initialization
|
||||
]
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
/**
|
||||
* PAIRWISE REVIEW - DOM + STATE GLUE
|
||||
* Compare overlay, vote/undo/reveal flow, persistence queue, scoreboard modal,
|
||||
* settings bindings, and the rating getters used by the grid (sort + label badge).
|
||||
* Pure logic lives in logic_ratings.js (no DOM there). Everything here is prefixed
|
||||
* cmp / _cmp because all logic_*.js files share one inlined <script> scope.
|
||||
*/
|
||||
|
||||
var _cmpSettings = { mode: 'matched', axis: '', reveal: 1000, blind: true, restrict: true };
|
||||
|
||||
var _cmp = {
|
||||
open: false,
|
||||
src: null,
|
||||
srcKey: '',
|
||||
state: { votesById: new Map(), votedPairs: new Set(), skipped: new Set(), allowRepeats: false },
|
||||
current: null, // {a, b (items), repeating, axis}
|
||||
next: null, // prefetched pair
|
||||
revealing: false,
|
||||
revealTimer: null,
|
||||
unblind: false,
|
||||
sessionVotes: 0,
|
||||
undoStack: [], // votes cast in this compare session (objects living in comparisons)
|
||||
lastTs: 0,
|
||||
pairsEstimate: 0
|
||||
};
|
||||
|
||||
// Persistence queue
|
||||
var _cmpPendingAppend = [];
|
||||
var _cmpPendingRemove = [];
|
||||
var _cmpInflightKeys = new Set();
|
||||
var _cmpFlushTimer = null;
|
||||
var _cmpInFlight = false;
|
||||
var _cmpRetryMs = 0;
|
||||
var _cmpSinceFlush = 0;
|
||||
var _cmpSess = '';
|
||||
var _cmpLastServer = []; // last list known to be on disk for _cmpSess
|
||||
var _cmpVersion = 0; // bumped on any change of fullManifest.comparisons
|
||||
var _cmpRatingCache = { version: -1, n: -1, byId: new Map() };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function cmpSessionName() {
|
||||
var el = document.getElementById('session-input');
|
||||
return (el && el.value) || 'default';
|
||||
}
|
||||
|
||||
function cmpList() {
|
||||
if (!fullManifest) return [];
|
||||
if (!Array.isArray(fullManifest.comparisons)) fullManifest.comparisons = [];
|
||||
return fullManifest.comparisons;
|
||||
}
|
||||
|
||||
function cmpSetStatus(msg, isError) {
|
||||
var el = document.getElementById('cmp-status');
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
el.style.color = isError ? '#ff5555' : '#666';
|
||||
}
|
||||
|
||||
function cmpBump() {
|
||||
_cmpVersion++;
|
||||
}
|
||||
|
||||
function cmpItemsMap() {
|
||||
var m = new Map();
|
||||
var items = (fullManifest && fullManifest.items) || [];
|
||||
for (var i = 0; i < items.length; i++) m.set(items[i].id, items[i]);
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ratings (used by the Rating sort and the Rating label badge)
|
||||
// ---------------------------------------------------------------------------
|
||||
function cmpRatings() {
|
||||
var n = fullManifest && fullManifest.items ? fullManifest.items.length : 0;
|
||||
if (_cmpRatingCache.version !== _cmpVersion || _cmpRatingCache.n !== n) {
|
||||
var ids = new Set();
|
||||
var items = (fullManifest && fullManifest.items) || [];
|
||||
for (var i = 0; i < items.length; i++) ids.add(items[i].id);
|
||||
_cmpRatingCache = { version: _cmpVersion, n: n, byId: computeBradleyTerry(cmpList(), ids) };
|
||||
}
|
||||
return _cmpRatingCache.byId;
|
||||
}
|
||||
|
||||
function getItemRating(id) {
|
||||
return cmpRatings().get(id) || null;
|
||||
}
|
||||
|
||||
/** Sort comparator: rated items first (descending), unrated last, never NaN. */
|
||||
function compareRatings(a, b) {
|
||||
var ra = getItemRating(a.id), rb = getItemRating(b.id);
|
||||
if (!ra && !rb) return 0;
|
||||
if (!ra) return 1;
|
||||
if (!rb) return -1;
|
||||
var d = rb.rating - ra.rating;
|
||||
return isFinite(d) ? d : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings (cog panel)
|
||||
// ---------------------------------------------------------------------------
|
||||
function cmpLoadSettings() {
|
||||
try {
|
||||
var raw = localStorage.getItem('ultimate_grid_compare');
|
||||
if (raw) {
|
||||
var p = JSON.parse(raw);
|
||||
if (['matched', 'seed', 'one_axis', 'random'].indexOf(p.mode) >= 0) _cmpSettings.mode = p.mode;
|
||||
if (typeof p.axis === 'string' && (p.axis === '' || CMP_AXES.indexOf(p.axis) >= 0)) _cmpSettings.axis = p.axis;
|
||||
if ([0, 1000, 2000].indexOf(+p.reveal) >= 0) _cmpSettings.reveal = +p.reveal;
|
||||
if (typeof p.blind === 'boolean') _cmpSettings.blind = p.blind;
|
||||
if (typeof p.restrict === 'boolean') _cmpSettings.restrict = p.restrict;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function cmpSaveSettings() {
|
||||
try { localStorage.setItem('ultimate_grid_compare', JSON.stringify(_cmpSettings)); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function cmpSyncSettingsUI() {
|
||||
var set = function (id, fn) { var el = document.getElementById(id); if (el) fn(el); };
|
||||
set('cmp-mode', function (el) { el.value = _cmpSettings.mode; });
|
||||
set('cmp-axis', function (el) { el.value = _cmpSettings.axis; });
|
||||
set('cmp-reveal', function (el) { el.value = String(_cmpSettings.reveal); });
|
||||
set('cmp-blind', function (el) { el.checked = _cmpSettings.blind; });
|
||||
set('cmp-restrict', function (el) { el.checked = _cmpSettings.restrict; });
|
||||
}
|
||||
|
||||
function cmpSettingsChanged() {
|
||||
var v = function (id) { var el = document.getElementById(id); return el; };
|
||||
var mode = v('cmp-mode'), axis = v('cmp-axis'), reveal = v('cmp-reveal'), blind = v('cmp-blind'), restrict = v('cmp-restrict');
|
||||
if (mode) _cmpSettings.mode = mode.value;
|
||||
if (axis) _cmpSettings.axis = axis.value;
|
||||
if (reveal) _cmpSettings.reveal = +reveal.value;
|
||||
if (blind) _cmpSettings.blind = blind.checked;
|
||||
if (restrict) _cmpSettings.restrict = restrict.checked;
|
||||
cmpSaveSettings();
|
||||
if (_cmp.open) {
|
||||
cmpEnsureSource(true);
|
||||
cmpShowNext(true);
|
||||
}
|
||||
}
|
||||
|
||||
function initCompareUI() {
|
||||
cmpLoadSettings();
|
||||
cmpSyncSettingsUI();
|
||||
_cmpSess = cmpSessionName();
|
||||
window.addEventListener('pagehide', function () { cmpFlushNow(true); });
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'hidden') cmpFlushNow(true);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
function cmpScheduleFlush() {
|
||||
if (_cmpFlushTimer) clearTimeout(_cmpFlushTimer);
|
||||
_cmpFlushTimer = setTimeout(function () { _cmpFlushTimer = null; cmpFlushNow(false); }, 1500);
|
||||
}
|
||||
|
||||
function cmpApplyPending(list) {
|
||||
var out = mergeComparisons(list, _cmpPendingAppend);
|
||||
if (_cmpPendingRemove.length) {
|
||||
var rm = new Set(_cmpPendingRemove);
|
||||
out = out.filter(function (c) { return !rm.has(cmpVoteKey(c)); });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function cmpFlushNow(keepalive) {
|
||||
if (_cmpFlushTimer) { clearTimeout(_cmpFlushTimer); _cmpFlushTimer = null; }
|
||||
if (_cmpInFlight) return;
|
||||
if (!_cmpPendingAppend.length && !_cmpPendingRemove.length) return;
|
||||
var sentA = _cmpPendingAppend.slice();
|
||||
var sentR = _cmpPendingRemove.slice();
|
||||
var session = _cmpSess || cmpSessionName();
|
||||
_cmpInFlight = true;
|
||||
_cmpInflightKeys = new Set(sentA.map(cmpVoteKey));
|
||||
try {
|
||||
var res = await fetch('/config_tester/save_comparisons', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
keepalive: !!keepalive,
|
||||
body: JSON.stringify({ session_name: session, append: sentA, remove: sentR })
|
||||
});
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
var data = await res.json();
|
||||
var sentASet = new Set(sentA);
|
||||
_cmpPendingAppend = _cmpPendingAppend.filter(function (c) { return !sentASet.has(c); });
|
||||
var sentRSet = new Set(sentR);
|
||||
_cmpPendingRemove = _cmpPendingRemove.filter(function (k) { return !sentRSet.has(k); });
|
||||
_cmpSinceFlush = 0;
|
||||
_cmpRetryMs = 0;
|
||||
if (session === cmpSessionName()) {
|
||||
_cmpLastServer = Array.isArray(data.comparisons) ? data.comparisons : _cmpLastServer;
|
||||
if (fullManifest) fullManifest.comparisons = cmpApplyPending(_cmpLastServer);
|
||||
cmpBump();
|
||||
cmpRefreshAfterSync();
|
||||
}
|
||||
cmpSetStatus('Votes saved (' + (data.total !== undefined ? data.total : cmpList().length) + ' total)', false);
|
||||
} catch (e) {
|
||||
_cmpRetryMs = Math.min(_cmpRetryMs ? _cmpRetryMs * 2 : 2000, 30000);
|
||||
cmpSetStatus('Saving votes failed (' + (e && e.message || e) + '), retrying in ' + Math.round(_cmpRetryMs / 1000) + 's', true);
|
||||
if (_cmpFlushTimer) clearTimeout(_cmpFlushTimer);
|
||||
_cmpFlushTimer = setTimeout(function () { _cmpFlushTimer = null; cmpFlushNow(false); }, _cmpRetryMs);
|
||||
} finally {
|
||||
_cmpInFlight = false;
|
||||
_cmpInflightKeys = new Set();
|
||||
// votes cast while the request was running
|
||||
if ((_cmpPendingAppend.length || _cmpPendingRemove.length) && !_cmpFlushTimer) cmpScheduleFlush();
|
||||
}
|
||||
}
|
||||
|
||||
/** Called after the manifest was replaced (loadSession / full-manifest push). */
|
||||
function onComparisonsReplaced() {
|
||||
var now = cmpSessionName();
|
||||
if (_cmpSess && _cmpSess !== now) {
|
||||
// Session switched: send what is still queued to the OLD session (best effort), then reset
|
||||
cmpFlushNow(true);
|
||||
_cmpPendingAppend = [];
|
||||
_cmpPendingRemove = [];
|
||||
_cmpLastServer = [];
|
||||
if (_cmp.open) cmpCloseCompare(true);
|
||||
}
|
||||
_cmpSess = now;
|
||||
if (fullManifest) {
|
||||
if (Array.isArray(fullManifest.comparisons)) _cmpLastServer = fullManifest.comparisons;
|
||||
else fullManifest.comparisons = _cmpLastServer.slice(); // e.g. a generation push without votes
|
||||
fullManifest.comparisons = cmpApplyPending(fullManifest.comparisons);
|
||||
}
|
||||
cmpBump();
|
||||
cmpRefreshAfterSync();
|
||||
}
|
||||
|
||||
function cmpRefreshAfterSync() {
|
||||
if (_cmp.open) {
|
||||
cmpRebuildVoteState();
|
||||
cmpUpdateProgress();
|
||||
}
|
||||
}
|
||||
|
||||
function cmpRecordVote(c) {
|
||||
cmpList().push(c);
|
||||
_cmpPendingAppend.push(c);
|
||||
_cmpSess = cmpSessionName();
|
||||
cmpBump();
|
||||
_cmpSinceFlush++;
|
||||
if (_cmpSinceFlush >= 20) cmpFlushNow(false); else cmpScheduleFlush();
|
||||
}
|
||||
|
||||
function cmpRetractVote(c) {
|
||||
var key = cmpVoteKey(c);
|
||||
var list = cmpList();
|
||||
for (var i = list.length - 1; i >= 0; i--) {
|
||||
if (list[i] === c || cmpVoteKey(list[i]) === key) { list.splice(i, 1); break; }
|
||||
}
|
||||
var idx = _cmpPendingAppend.indexOf(c);
|
||||
if (idx >= 0 && !_cmpInflightKeys.has(key)) _cmpPendingAppend.splice(idx, 1);
|
||||
else {
|
||||
if (idx >= 0) _cmpPendingAppend.splice(idx, 1);
|
||||
_cmpPendingRemove.push(key); // already sent (or in flight): ask the server to drop it
|
||||
}
|
||||
cmpBump();
|
||||
cmpScheduleFlush();
|
||||
}
|
||||
|
||||
async function clearComparisons() {
|
||||
var n = cmpList().length;
|
||||
if (!n) { cmpSetStatus('No votes to clear', false); return; }
|
||||
if (!window.confirm('Delete all ' + n + ' votes saved in this session? This cannot be undone.')) return;
|
||||
try {
|
||||
var res = await fetch('/config_tester/clear_comparisons', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_name: cmpSessionName() })
|
||||
});
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
_cmpPendingAppend = [];
|
||||
_cmpPendingRemove = [];
|
||||
_cmpLastServer = [];
|
||||
if (fullManifest) fullManifest.comparisons = [];
|
||||
_cmp.undoStack = [];
|
||||
_cmp.state.skipped.clear();
|
||||
cmpBump();
|
||||
cmpRefreshAfterSync();
|
||||
cmpAfterRatingsChanged();
|
||||
cmpSetStatus('Cleared ' + n + ' votes', false);
|
||||
} catch (e) {
|
||||
cmpSetStatus('Clearing votes failed: ' + (e && e.message || e), true);
|
||||
}
|
||||
}
|
||||
|
||||
function cmpAfterRatingsChanged() {
|
||||
if (typeof refreshLabelOverlays === 'function') refreshLabelOverlays();
|
||||
if ((currentSort === 'rating' || currentSecondarySort === 'rating') && typeof updateDataPipeline === 'function') {
|
||||
updateDataPipeline();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pair source / queue
|
||||
// ---------------------------------------------------------------------------
|
||||
function cmpBuildPool() {
|
||||
var out = [];
|
||||
var src = processedData || [];
|
||||
for (var i = 0; i < src.length; i++) {
|
||||
var d = src[i];
|
||||
if (d && d.file && !d.rejected) out.push(d); // a reject is a decision already made
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function cmpRebuildVoteState() {
|
||||
var ids = new Set();
|
||||
var items = (fullManifest && fullManifest.items) || [];
|
||||
for (var i = 0; i < items.length; i++) ids.add(items[i].id);
|
||||
var votesById = new Map(), votedPairs = new Set();
|
||||
var list = cmpList();
|
||||
for (var j = 0; j < list.length; j++) {
|
||||
var c = list[j];
|
||||
if (!c || !ids.has(c.a) || !ids.has(c.b) || c.a === c.b) continue;
|
||||
votedPairs.add(cmpPairKey(c.a, c.b));
|
||||
votesById.set(c.a, (votesById.get(c.a) || 0) + 1);
|
||||
votesById.set(c.b, (votesById.get(c.b) || 0) + 1);
|
||||
}
|
||||
_cmp.state.votesById = votesById;
|
||||
_cmp.state.votedPairs = votedPairs;
|
||||
cmpComputeRemaining();
|
||||
}
|
||||
|
||||
function cmpComputeRemaining() {
|
||||
if (!_cmp.src) { _cmp.pairsEstimate = 0; return; }
|
||||
var inPool = new Set();
|
||||
_cmp.src.buckets.forEach(function (b) { b.items.forEach(function (e) { inPool.add(e.id); }); });
|
||||
var voted = 0;
|
||||
_cmp.state.votedPairs.forEach(function (k) {
|
||||
var p = k.split('|');
|
||||
if (inPool.has(+p[0]) && inPool.has(+p[1])) voted++;
|
||||
});
|
||||
_cmp.pairsEstimate = Math.max(0, _cmp.src.estimate - voted);
|
||||
}
|
||||
|
||||
function cmpEnsureSource(force) {
|
||||
var key = '';
|
||||
try { key = getFilterKey(); } catch (e) { key = ''; }
|
||||
key += '|' + activeData.length + '|' + _cmpSettings.mode + '|' + _cmpSettings.axis;
|
||||
if (!force && _cmp.src && _cmp.srcKey === key) return;
|
||||
_cmp.srcKey = key;
|
||||
_cmp.src = buildPairSource(cmpBuildPool(), _cmpSettings.mode, _cmpSettings.axis, { meta: meta });
|
||||
_cmp.next = null;
|
||||
cmpRebuildVoteState();
|
||||
}
|
||||
|
||||
function cmpPickPair() {
|
||||
var p = _cmp.next;
|
||||
_cmp.next = null;
|
||||
if (p) {
|
||||
var k = cmpPairKey(p.a.id, p.b.id);
|
||||
var stale = _cmp.state.skipped.has(k) || (!p.repeating && _cmp.state.votedPairs.has(k));
|
||||
if (!stale) return p;
|
||||
}
|
||||
return nextPair(_cmp.src, _cmp.state, Math.random);
|
||||
}
|
||||
|
||||
function cmpPrefetch() {
|
||||
_cmp.next = nextPair(_cmp.src, _cmp.state, Math.random);
|
||||
if (_cmp.next) {
|
||||
[_cmp.next.a, _cmp.next.b].forEach(function (d) {
|
||||
if (d.media_type !== 'video') { var im = new Image(); im.src = d.file; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overlay rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
function cmpModeLabel() {
|
||||
var m = _cmpSettings.mode;
|
||||
if (m === 'matched') return 'Matched pairs';
|
||||
if (m === 'seed') return 'Seed pairs';
|
||||
if (m === 'random') return 'Random pairs';
|
||||
return 'One axis' + (_cmpSettings.axis ? ': ' + _cmpSettings.axis : '');
|
||||
}
|
||||
|
||||
function cmpUpdateProgress() {
|
||||
var el = document.getElementById('cmp-progress');
|
||||
if (!el) return;
|
||||
var total = 0;
|
||||
var list = cmpList();
|
||||
for (var i = 0; i < list.length; i++) total++;
|
||||
var poolN = _cmp.src ? _cmp.src.poolSize : 0;
|
||||
var rep = document.getElementById('cmp-repeat-badge');
|
||||
if (rep) rep.style.display = (_cmp.current && _cmp.current.repeating) ? '' : 'none';
|
||||
el.textContent = 'Session ' + _cmp.sessionVotes + ' · Total ' + total + ' · Pool ' + poolN.toLocaleString() +
|
||||
' items · ~' + Math.round(_cmp.pairsEstimate).toLocaleString() + ' unvoted pairs';
|
||||
var ml = document.getElementById('cmp-mode-label');
|
||||
if (ml) ml.textContent = cmpModeLabel();
|
||||
var ub = document.getElementById('cmp-unblind-btn');
|
||||
if (ub) ub.classList.toggle('active', _cmp.unblind);
|
||||
}
|
||||
|
||||
function cmpFillMedia(el, d, asVideo) {
|
||||
el.textContent = '';
|
||||
var m;
|
||||
if (asVideo) {
|
||||
m = document.createElement('video');
|
||||
m.src = d.file;
|
||||
m.autoplay = true; m.muted = true; m.loop = true; m.playsInline = true;
|
||||
m.setAttribute('muted', ''); m.setAttribute('playsinline', '');
|
||||
var p = m.play && m.play(); if (p && p.catch) p.catch(function () { });
|
||||
} else {
|
||||
m = document.createElement('img');
|
||||
m.src = d.file;
|
||||
m.draggable = false;
|
||||
}
|
||||
el.appendChild(m);
|
||||
}
|
||||
|
||||
function cmpRenderChips(show) {
|
||||
var ca = document.getElementById('cmp-chips-a'), cb = document.getElementById('cmp-chips-b');
|
||||
if (ca) ca.textContent = '';
|
||||
if (cb) cb.textContent = '';
|
||||
if (!show || !_cmp.current) return;
|
||||
var A = _cmp.current.a, B = _cmp.current.b;
|
||||
var df = axisDiff(A, B, { meta: meta });
|
||||
var sa = itemSig(A, meta), sb = itemSig(B, meta);
|
||||
var add = function (host, axis, sig) {
|
||||
var s = document.createElement('span');
|
||||
s.className = 'cmp-chip';
|
||||
s.textContent = axis + ': ' + sig.labels[axis];
|
||||
s.title = axis + ': ' + sig.axes[axis];
|
||||
host.appendChild(s);
|
||||
};
|
||||
df.axes.forEach(function (ax) { add(ca, ax, sa); add(cb, ax, sb); });
|
||||
if (!df.axes.length || !df.ctxEqual) {
|
||||
[ca, cb].forEach(function (h) {
|
||||
var s = document.createElement('span');
|
||||
s.className = 'cmp-chip';
|
||||
s.textContent = df.axes.length ? 'other settings differ' : (df.ctxEqual ? 'identical settings' : 'other settings differ');
|
||||
h.appendChild(s);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function cmpRenderPair() {
|
||||
var pair = _cmp.current;
|
||||
var empty = document.getElementById('cmp-empty');
|
||||
var panes = document.getElementById('cmp-panes');
|
||||
var bar = document.getElementById('cmp-bar');
|
||||
if (!pair) {
|
||||
if (empty) { empty.style.display = 'flex'; }
|
||||
if (panes) panes.style.visibility = 'hidden';
|
||||
if (bar) bar.style.visibility = 'hidden';
|
||||
cmpUpdateProgress();
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = 'none';
|
||||
if (panes) panes.style.visibility = '';
|
||||
if (bar) bar.style.visibility = '';
|
||||
var video = pair.a.media_type === 'video' && pair.b.media_type === 'video';
|
||||
cmpFillMedia(document.getElementById('cmp-media-a'), pair.a, video);
|
||||
cmpFillMedia(document.getElementById('cmp-media-b'), pair.b, video);
|
||||
['a', 'b'].forEach(function (s) {
|
||||
var p = document.getElementById('cmp-pane-' + s);
|
||||
if (p) p.classList.remove('cmp-winner', 'cmp-tie');
|
||||
});
|
||||
cmpRenderChips(_cmp.unblind || !_cmpSettings.blind);
|
||||
cmpUpdateProgress();
|
||||
}
|
||||
|
||||
function cmpEmptyMessage() {
|
||||
var el = document.getElementById('cmp-empty');
|
||||
if (!el) return;
|
||||
var pool = _cmp.src ? _cmp.src.poolSize : 0;
|
||||
var msg = 'No comparable pairs for this mode with the current filters.';
|
||||
if (_cmp.src && _cmp.src.buckets.length && _cmp.state.skipped.size) {
|
||||
msg = 'Every remaining pair was skipped in this compare session.';
|
||||
} else {
|
||||
var pairs = cmpBuildPool();
|
||||
var tries = [['matched', ''], ['seed', ''], ['one_axis', ''], ['random', '']];
|
||||
var best = null;
|
||||
tries.forEach(function (t) {
|
||||
if (t[0] === _cmpSettings.mode && t[1] === _cmpSettings.axis) return;
|
||||
var s = buildPairSource(pairs, t[0], t[1], { meta: meta });
|
||||
if (s.estimate > 0 && (!best || s.estimate > best.n)) best = { mode: t[0], n: s.estimate };
|
||||
});
|
||||
var names = { matched: 'Matched', seed: 'Seed pairs', one_axis: 'One axis', random: 'Random' };
|
||||
if (best) msg += ' Try the "' + names[best.mode] + '" pairing mode (about ' + Math.round(best.n).toLocaleString() + ' pairs) in Settings > Pairwise Review.';
|
||||
else msg += ' The pool has ' + pool + ' item(s); widen the filters or generate more images.';
|
||||
}
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
function cmpShowNext(rebuilt) {
|
||||
if (!_cmp.open) return;
|
||||
_cmp.revealing = false;
|
||||
if (_cmp.revealTimer) { clearTimeout(_cmp.revealTimer); _cmp.revealTimer = null; }
|
||||
_cmp.current = _cmp.src ? cmpPickPair() : null;
|
||||
if (!_cmp.current) cmpEmptyMessage();
|
||||
cmpRenderPair();
|
||||
if (_cmp.current) cmpPrefetch();
|
||||
}
|
||||
|
||||
function cmpShowPair(pair) {
|
||||
_cmp.revealing = false;
|
||||
if (_cmp.revealTimer) { clearTimeout(_cmp.revealTimer); _cmp.revealTimer = null; }
|
||||
_cmp.current = pair;
|
||||
var empty = document.getElementById('cmp-empty');
|
||||
if (empty) empty.style.display = 'none';
|
||||
cmpRenderPair();
|
||||
_cmp.next = null;
|
||||
cmpPrefetch();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vote / skip / undo
|
||||
// ---------------------------------------------------------------------------
|
||||
function cmpAdvance() {
|
||||
cmpShowNext(false);
|
||||
}
|
||||
|
||||
function cmpVoteSide(side) {
|
||||
if (!_cmp.open) return;
|
||||
if (_cmp.revealing) { cmpAdvance(); return; } // tap during reveal: advance, no vote
|
||||
var pair = _cmp.current;
|
||||
if (!pair || (side !== 'a' && side !== 'b' && side !== 'tie')) return;
|
||||
var ts = Math.max(Date.now(), _cmp.lastTs + 1);
|
||||
_cmp.lastTs = ts;
|
||||
var c = { a: pair.a.id, b: pair.b.id, winner: side, ts: ts, mode: _cmpSettings.mode };
|
||||
if (!(_cmpSettings.blind && !_cmp.unblind)) c.blind = false;
|
||||
cmpRecordVote(c);
|
||||
_cmp.undoStack.push(c);
|
||||
_cmp.sessionVotes++;
|
||||
var pk = cmpPairKey(c.a, c.b);
|
||||
_cmp.state.votedPairs.add(pk);
|
||||
_cmp.state.votesById.set(c.a, (_cmp.state.votesById.get(c.a) || 0) + 1);
|
||||
_cmp.state.votesById.set(c.b, (_cmp.state.votesById.get(c.b) || 0) + 1);
|
||||
cmpComputeRemaining();
|
||||
cmpUpdateProgress();
|
||||
var ms = _cmpSettings.reveal;
|
||||
if (ms > 0) {
|
||||
_cmp.revealing = true;
|
||||
var pa = document.getElementById('cmp-pane-a'), pb = document.getElementById('cmp-pane-b');
|
||||
if (side === 'tie') { pa.classList.add('cmp-tie'); pb.classList.add('cmp-tie'); }
|
||||
else (side === 'a' ? pa : pb).classList.add('cmp-winner');
|
||||
cmpRenderChips(true);
|
||||
_cmp.revealTimer = setTimeout(cmpAdvance, ms);
|
||||
} else {
|
||||
cmpAdvance();
|
||||
}
|
||||
}
|
||||
|
||||
function cmpSkip() {
|
||||
if (!_cmp.open) return;
|
||||
if (_cmp.revealing) { cmpAdvance(); return; }
|
||||
var pair = _cmp.current;
|
||||
if (!pair) return;
|
||||
_cmp.state.skipped.add(cmpPairKey(pair.a.id, pair.b.id)); // memory only, never persisted
|
||||
cmpAdvance();
|
||||
}
|
||||
|
||||
function cmpUndo() {
|
||||
if (!_cmp.open || !_cmp.undoStack.length) return;
|
||||
var c = _cmp.undoStack.pop();
|
||||
cmpRetractVote(c);
|
||||
_cmp.sessionVotes = Math.max(0, _cmp.sessionVotes - 1);
|
||||
cmpRebuildVoteState();
|
||||
var items = cmpItemsMap();
|
||||
var A = items.get(c.a), B = items.get(c.b);
|
||||
if (A && B) cmpShowPair({ a: A, b: B, repeating: false, axis: null });
|
||||
else cmpShowNext(false);
|
||||
}
|
||||
|
||||
function cmpToggleUnblind() {
|
||||
_cmp.unblind = !_cmp.unblind;
|
||||
if (_cmp.current && !_cmp.revealing) cmpRenderChips(_cmp.unblind || !_cmpSettings.blind);
|
||||
cmpUpdateProgress();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Open / close + keys
|
||||
// ---------------------------------------------------------------------------
|
||||
function cmpKeyHandler(e) {
|
||||
if (!window._compareOpen) return;
|
||||
var t = e.target && e.target.tagName;
|
||||
if (t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT') return;
|
||||
var chord = e.ctrlKey || e.metaKey || e.altKey;
|
||||
var key = e.key;
|
||||
var isUndoChord = (e.ctrlKey || e.metaKey) && !e.altKey && (key === 'z' || key === 'Z');
|
||||
if (chord && !isUndoChord) return; // Ctrl+S etc. keep reaching their own handlers
|
||||
|
||||
var sbOpen = !!document.getElementById('scoreboard-modal');
|
||||
var stop = function () { e.preventDefault(); e.stopPropagation(); };
|
||||
|
||||
if (key === 'Escape') {
|
||||
stop();
|
||||
var sb = document.getElementById('scoreboard-modal');
|
||||
if (sb) sb.remove(); else closeCompareMode();
|
||||
return;
|
||||
}
|
||||
if (sbOpen) return; // scoreboard on top: only Escape is ours
|
||||
if (e.repeat) { stop(); return; }
|
||||
|
||||
var k = key.length === 1 ? key.toLowerCase() : key;
|
||||
if (k === 'z' || k === 'Backspace') { stop(); cmpUndo(); return; }
|
||||
if (_cmp.revealing) {
|
||||
// any other key during the reveal advances and does not vote
|
||||
if (key === 'Tab' || key.indexOf('F') === 0 && key.length > 1) return;
|
||||
stop(); cmpAdvance(); return;
|
||||
}
|
||||
switch (k) {
|
||||
case 'a': case 'ArrowLeft': stop(); cmpVoteSide('a'); return;
|
||||
case 'd': case 'ArrowRight': stop(); cmpVoteSide('b'); return;
|
||||
case 's': case 'ArrowDown': stop(); cmpVoteSide('tie'); return;
|
||||
case 'w': case 'ArrowUp': stop(); cmpSkip(); return;
|
||||
case 'i': stop(); cmpToggleUnblind(); return;
|
||||
case 'b': stop(); openScoreboard(); return;
|
||||
}
|
||||
// swallow everything else (digits would favourite grid cards, f/r would fullscreen/reload)
|
||||
if (key.length === 1 || key.indexOf('Arrow') === 0 || key === 'Enter') stop();
|
||||
}
|
||||
|
||||
function openCompareMode() {
|
||||
if (_cmp.open) return;
|
||||
if (!fullManifest || !(fullManifest.items || []).length) {
|
||||
cmpSetStatus('Load a session with images first', true);
|
||||
return;
|
||||
}
|
||||
cmpLoadSettings();
|
||||
cmpSyncSettingsUI();
|
||||
// close the settings panel if it is open (the overlay is above it anyway)
|
||||
var cog = document.getElementById('cog-menu-dropdown');
|
||||
if (cog && cog.style.display !== 'none' && cog.style.display !== '' && typeof toggleCogMenu === 'function') toggleCogMenu();
|
||||
_cmpSess = cmpSessionName();
|
||||
if (!Array.isArray(fullManifest.comparisons)) fullManifest.comparisons = cmpApplyPending(_cmpLastServer.slice());
|
||||
_cmp.open = true;
|
||||
_cmp.unblind = false;
|
||||
_cmp.sessionVotes = 0;
|
||||
_cmp.undoStack = [];
|
||||
_cmp.state.skipped = new Set();
|
||||
_cmp.state.allowRepeats = false;
|
||||
_cmp.lastTs = 0;
|
||||
window._compareOpen = true;
|
||||
window._modalOpen = true;
|
||||
document.getElementById('compare-overlay').style.display = 'flex';
|
||||
document.removeEventListener('keydown', window._cmpKeyHandler, true);
|
||||
window._cmpKeyHandler = cmpKeyHandler;
|
||||
document.addEventListener('keydown', window._cmpKeyHandler, true);
|
||||
cmpEnsureSource(true);
|
||||
cmpShowNext(true);
|
||||
}
|
||||
|
||||
function cmpCloseCompare(silent) {
|
||||
if (!_cmp.open) return;
|
||||
_cmp.open = false;
|
||||
window._compareOpen = false;
|
||||
var m = document.getElementById('modal');
|
||||
window._modalOpen = !!(m && m.style.display === 'flex');
|
||||
if (_cmp.revealTimer) { clearTimeout(_cmp.revealTimer); _cmp.revealTimer = null; }
|
||||
_cmp.revealing = false;
|
||||
document.removeEventListener('keydown', window._cmpKeyHandler, true);
|
||||
window._cmpKeyHandler = null;
|
||||
var ov = document.getElementById('compare-overlay');
|
||||
if (ov) ov.style.display = 'none';
|
||||
['cmp-media-a', 'cmp-media-b'].forEach(function (id) { var el = document.getElementById(id); if (el) el.textContent = ''; });
|
||||
var sb = document.getElementById('scoreboard-modal');
|
||||
if (sb && !silent) sb.remove();
|
||||
cmpFlushNow(false);
|
||||
if (!silent) cmpAfterRatingsChanged();
|
||||
}
|
||||
|
||||
function closeCompareMode() {
|
||||
cmpCloseCompare(false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoreboard modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function cmpCopyText(text, btn) {
|
||||
var done = function () {
|
||||
if (!btn) return;
|
||||
var o = btn.textContent;
|
||||
btn.textContent = 'Copied';
|
||||
setTimeout(function () { btn.textContent = o; }, 1500);
|
||||
};
|
||||
var fallback = function () {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (e) { /* ignore */ }
|
||||
document.body.removeChild(ta);
|
||||
done();
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, fallback);
|
||||
} else fallback();
|
||||
}
|
||||
|
||||
function cmpCompareMore(axis) {
|
||||
var sb = document.getElementById('scoreboard-modal');
|
||||
if (sb) sb.remove();
|
||||
if (axis === 'seed') { _cmpSettings.mode = 'seed'; _cmpSettings.axis = ''; }
|
||||
else { _cmpSettings.mode = 'one_axis'; _cmpSettings.axis = axis; }
|
||||
cmpSaveSettings();
|
||||
cmpSyncSettingsUI();
|
||||
if (_cmp.open) { cmpEnsureSource(true); cmpShowNext(true); }
|
||||
else openCompareMode();
|
||||
}
|
||||
|
||||
function cmpEl(tag, cls, text) {
|
||||
var el = document.createElement(tag);
|
||||
if (cls) el.className = cls;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function cmpPct(x) { return (x * 100).toFixed(0) + '%'; }
|
||||
|
||||
function openScoreboard() {
|
||||
var existing = document.getElementById('scoreboard-modal');
|
||||
if (existing) existing.remove();
|
||||
var items = cmpItemsMap();
|
||||
var restrict = null;
|
||||
if (_cmpSettings.restrict) {
|
||||
restrict = new Set();
|
||||
(processedData || []).forEach(function (d) { restrict.add(d.id); });
|
||||
}
|
||||
var ratings = cmpRatings();
|
||||
var sb = computeScoreboard(cmpList(), items, ratings, { restrictIds: restrict, minN: 3, meta: meta });
|
||||
|
||||
var overlay = cmpEl('div', 'analytics-modal-overlay');
|
||||
overlay.id = 'scoreboard-modal';
|
||||
var popup = cmpEl('div', 'analytics-modal-popup');
|
||||
var head = cmpEl('div', 'analytics-modal-header');
|
||||
head.style.borderBottomColor = '#e0c040';
|
||||
var title = cmpEl('span', 'analytics-modal-title', 'Pairwise scoreboard');
|
||||
title.style.color = '#e0c040';
|
||||
var closeBtn = cmpEl('button', 'close-popup-btn');
|
||||
closeBtn.innerHTML = '✕';
|
||||
closeBtn.addEventListener('click', function () { overlay.remove(); });
|
||||
head.appendChild(title); head.appendChild(closeBtn);
|
||||
popup.appendChild(head);
|
||||
|
||||
var summary = cmpEl('div', 'analytics-summary');
|
||||
summary.appendChild(cmpEl('span', '', sb.total + ' votes' + (restrict ? ' inside the current filter' : '')));
|
||||
summary.appendChild(cmpEl('span', '', '|'));
|
||||
summary.appendChild(cmpEl('span', '', 'win % counts ties as half; best = highest 95% Wilson lower bound with n >= 3'));
|
||||
summary.title = 'Pairs are chosen with a preference for low-vote items and respect your filters, so win % is not a controlled effect size.';
|
||||
popup.appendChild(summary);
|
||||
|
||||
var body = cmpEl('div', 'analytics-modal-body');
|
||||
var order = CMP_AXES.concat(['seed']);
|
||||
order.forEach(function (ax) {
|
||||
var blk = sb.axes[ax];
|
||||
var det = cmpEl('details', 'sb-axis');
|
||||
det.open = blk.rows.length > 0;
|
||||
var sm = cmpEl('summary', '', ax === 'seed' ? 'seed (noise floor)' : ax);
|
||||
sm.appendChild(cmpEl('span', 'sb-meta', blk.matchedVotes + ' matched votes'));
|
||||
det.appendChild(sm);
|
||||
var anyEnough = blk.rows.some(function (r) { return r.enough; });
|
||||
if (!anyEnough) {
|
||||
det.appendChild(cmpEl('div', 'sb-empty', 'not enough data (' + blk.matchedVotes + ' matched votes)'));
|
||||
}
|
||||
if (blk.rows.length) {
|
||||
var table = cmpEl('table', 'analytics-table');
|
||||
var thead = cmpEl('thead');
|
||||
var hr = cmpEl('tr');
|
||||
['value', 'n', 'W-L-T', 'win %', '95% CI', 'mean rating', ''].forEach(function (h) { hr.appendChild(cmpEl('th', 'analytics-th', h)); });
|
||||
thead.appendChild(hr); table.appendChild(thead);
|
||||
var tb = cmpEl('tbody');
|
||||
blk.rows.forEach(function (r) {
|
||||
var tr = cmpEl('tr', 'analytics-row' + (r.enough ? '' : ' sb-row-dim') + (r.value === blk.best ? ' sb-row-best' : ''));
|
||||
var tdv = cmpEl('td', 'sb-val', r.label); tdv.title = r.value;
|
||||
tr.appendChild(tdv);
|
||||
tr.appendChild(cmpEl('td', 'sb-num', String(r.n)));
|
||||
tr.appendChild(cmpEl('td', 'sb-num', r.wins + '-' + r.losses + '-' + r.ties));
|
||||
tr.appendChild(cmpEl('td', 'sb-num', r.enough ? cmpPct(r.winPct) : 'not enough data'));
|
||||
tr.appendChild(cmpEl('td', 'sb-num', r.enough ? cmpPct(r.wilsonLow) + '–' + cmpPct(r.wilsonHigh) : ''));
|
||||
tr.appendChild(cmpEl('td', 'sb-num', r.meanRating === null ? '' : String(Math.round(r.meanRating))));
|
||||
tr.appendChild(cmpEl('td', '', ''));
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tb);
|
||||
det.appendChild(table);
|
||||
}
|
||||
var more = cmpEl('div', 'sb-link', ax === 'seed' ? 'compare more seeds' : 'compare more');
|
||||
more.addEventListener('click', function () { cmpCompareMore(ax); });
|
||||
det.appendChild(more);
|
||||
body.appendChild(det);
|
||||
});
|
||||
popup.appendChild(body);
|
||||
|
||||
var foot = cmpEl('div', 'sb-footer');
|
||||
var seedVotes = sb.axes.seed.matchedVotes;
|
||||
var txt = sb.attributable + ' of ' + sb.total + ' votes attributable to exactly one axis';
|
||||
if (seedVotes) txt += ' (+ ' + seedVotes + ' seed-only votes in the noise-floor block)';
|
||||
foot.appendChild(cmpEl('div', '', txt));
|
||||
if (sb.topExtraKeys.length) {
|
||||
foot.appendChild(cmpEl('div', '', 'Other differences in the remaining votes: ' +
|
||||
sb.topExtraKeys.map(function (k) { return k.key + ' (' + k.count + ')'; }).join(', ')));
|
||||
}
|
||||
popup.appendChild(foot);
|
||||
|
||||
var actions = cmpEl('div', 'sb-actions');
|
||||
var bj = cmpEl('button', '', 'Copy JSON'), bc = cmpEl('button', '', 'Copy CSV'), bx = cmpEl('button', '', 'Close');
|
||||
bj.addEventListener('click', function () { cmpCopyText(JSON.stringify(sb, null, 2), bj); });
|
||||
bc.addEventListener('click', function () { cmpCopyText(scoreboardToCSV(sb), bc); });
|
||||
bx.addEventListener('click', function () { overlay.remove(); });
|
||||
actions.appendChild(bj); actions.appendChild(bc); actions.appendChild(bx);
|
||||
popup.appendChild(actions);
|
||||
|
||||
overlay.appendChild(popup);
|
||||
overlay.addEventListener('click', function (e) { if (e.target === overlay) overlay.remove(); });
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// close the settings panel so the modal is not stacked over it
|
||||
var cog = document.getElementById('cog-menu-dropdown');
|
||||
if (cog && cog.style.display !== 'none' && cog.style.display !== '' && typeof toggleCogMenu === 'function') toggleCogMenu();
|
||||
}
|
||||
@@ -85,6 +85,7 @@ window.addEventListener('message', (event) => {
|
||||
fullManifest = payload.manifest;
|
||||
activeData = fullManifest.items || [];
|
||||
meta = payload.meta || {};
|
||||
if (typeof onComparisonsReplaced === 'function') onComparisonsReplaced();
|
||||
|
||||
// Recompute label global values with new data
|
||||
if (typeof computeLabelGlobalValues === 'function' && labelMode && labelMode.enabled) {
|
||||
|
||||
@@ -50,6 +50,11 @@ function init() {
|
||||
loadLabelPreferences();
|
||||
}
|
||||
|
||||
// Pairwise review settings (cog panel) + vote sync
|
||||
if (typeof initCompareUI === 'function') {
|
||||
initCompareUI();
|
||||
}
|
||||
|
||||
// Start
|
||||
refreshIndices();
|
||||
|
||||
|
||||
@@ -310,6 +310,8 @@ function compareItems(a, b, sortKey) {
|
||||
return (a.sampler || "").toLowerCase().localeCompare((b.sampler || "").toLowerCase());
|
||||
case 'size':
|
||||
return (a.width * a.height) - (b.width * b.height);
|
||||
case 'rating':
|
||||
return (typeof compareRatings === 'function') ? compareRatings(a, b) : 0;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@ const sortOptionsList = [
|
||||
{ id: 'lora', label: 'LoRA' },
|
||||
{ id: 'sampler', label: 'Sampler' },
|
||||
{ id: 'seed', label: 'Seed' },
|
||||
{ id: 'size', label: 'Size' }
|
||||
{ id: 'size', label: 'Size' },
|
||||
{ id: 'rating', label: 'Rating' }
|
||||
];
|
||||
|
||||
// --- SEARCH FILTER STATE ---
|
||||
@@ -122,7 +123,8 @@ var labelMode = labelMode || {
|
||||
upscaleHiresSteps: false,
|
||||
upscaleTiling: false,
|
||||
hiresPromptBehavior: false,
|
||||
hiresPromptText: false
|
||||
hiresPromptText: false,
|
||||
rating: false // Pairwise-review rating badge
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+16
-2
@@ -295,6 +295,13 @@ function buildLabelOverlay(d) {
|
||||
}
|
||||
}
|
||||
|
||||
if (labelMode.fields.rating && typeof getItemRating === 'function') {
|
||||
const rt = getItemRating(d.id);
|
||||
if (rt && rt.votes > 0) {
|
||||
tags.push(`<span class="label-tag label-rating" title="Pairwise rating (Bradley-Terry) and number of votes">★${Math.round(rt.rating)} · ${rt.votes}</span>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (tags.length === 0) return '';
|
||||
return `<div class="label-overlay">${tags.join('')}</div>`;
|
||||
}
|
||||
@@ -320,7 +327,7 @@ function toggleLabelMode(enabled) {
|
||||
function updateLabelFields() {
|
||||
const fields = ['model', 'lora', 'prompt', 'sampler', 'scheduler', 'cfg', 'steps', 'seed', 'denoise', 'upscale',
|
||||
'upscaleMode', 'upscaleModel', 'upscaleRatio', 'upscaleDenoise', 'upscaleResizeMethod', 'upscaleHiresSteps', 'upscaleTiling',
|
||||
'hiresPromptBehavior', 'hiresPromptText'];
|
||||
'hiresPromptBehavior', 'hiresPromptText', 'rating'];
|
||||
fields.forEach(f => {
|
||||
const cb = document.getElementById(`label-field-${f}`);
|
||||
if (cb) labelMode.fields[f] = cb.checked;
|
||||
@@ -388,7 +395,7 @@ function loadLabelPreferences() {
|
||||
|
||||
const fields = ['model', 'lora', 'prompt', 'sampler', 'scheduler', 'cfg', 'steps', 'seed', 'denoise', 'upscale',
|
||||
'upscaleMode', 'upscaleModel', 'upscaleRatio', 'upscaleDenoise', 'upscaleResizeMethod', 'upscaleHiresSteps', 'upscaleTiling',
|
||||
'hiresPromptBehavior', 'hiresPromptText'];
|
||||
'hiresPromptBehavior', 'hiresPromptText', 'rating'];
|
||||
fields.forEach(f => {
|
||||
const cb = document.getElementById(`label-field-${f}`);
|
||||
if (cb) cb.checked = labelMode.fields[f] || false;
|
||||
@@ -524,6 +531,13 @@ document.addEventListener('keydown', (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close pairwise-review scoreboard
|
||||
const scoreboardModal = document.getElementById('scoreboard-modal');
|
||||
if (scoreboardModal) {
|
||||
scoreboardModal.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Close analytics results modal
|
||||
const analyticsModal = document.getElementById('analytics-results-modal');
|
||||
if (analyticsModal) {
|
||||
|
||||
@@ -14,6 +14,7 @@ function toggleFullscreen() {
|
||||
function setupKeyReloadFullscreen() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
if (window._compareOpen) return; // compare mode swallows f / r
|
||||
|
||||
switch (e.key) {
|
||||
case 'f':
|
||||
@@ -94,6 +95,7 @@ async function loadSession() {
|
||||
fullManifest = data;
|
||||
activeData = fullManifest.items || [];
|
||||
meta = fullManifest.meta || {};
|
||||
if (typeof onComparisonsReplaced === 'function') onComparisonsReplaced();
|
||||
|
||||
console.log(`[Load] 📦 activeData now has ${activeData.length} items`);
|
||||
|
||||
|
||||
@@ -906,6 +906,14 @@ function setupKeyboardShortcuts() {
|
||||
updateTransform();
|
||||
updateVisibleItems();
|
||||
break;
|
||||
case 'c':
|
||||
case 'C':
|
||||
// Pairwise review (compare mode); plain C only, so Ctrl+C copy is untouched
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey && typeof openCompareMode === 'function') {
|
||||
e.preventDefault();
|
||||
openCompareMode();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 1-9 (no modifiers): Quick favorite — toggle favorite on Nth visible card
|
||||
|
||||
+218
-1
@@ -1734,4 +1734,221 @@ body {
|
||||
.analytics-count {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PAIRWISE REVIEW (compare overlay + scoreboard)
|
||||
============================================================ */
|
||||
|
||||
.cmp-setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
}
|
||||
.cmp-setting-row label { flex: 0 0 auto; user-select: none; }
|
||||
.cmp-setting-row select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #1a1a1a;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#compare-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
background: #050505;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
#cmp-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #0a0a0a;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
}
|
||||
#cmp-mode-label { font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
#cmp-progress { color: #888; }
|
||||
#cmp-repeat-badge {
|
||||
background: #7a4a00;
|
||||
color: #ffd27a;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.cmp-spacer { flex: 1; }
|
||||
.cmp-head-btn {
|
||||
background: #1a1a1a;
|
||||
color: #ccc;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cmp-head-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.cmp-head-btn.active { background: #3a2f00; border-color: #e0c040; color: #e0c040; }
|
||||
|
||||
#cmp-panes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
.cmp-pane {
|
||||
flex: 1 1 50%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0d0d0d;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cmp-pane.cmp-winner { border-color: #2ecc71; }
|
||||
.cmp-pane.cmp-tie { border-color: #888; }
|
||||
.cmp-media { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
.cmp-media img, .cmp-media video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cmp-label {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 10px;
|
||||
z-index: 2;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
text-shadow: 0 1px 4px #000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cmp-chips {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
|
||||
pointer-events: none;
|
||||
}
|
||||
.cmp-chips:empty { display: none; }
|
||||
.cmp-chip {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#cmp-empty {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 48px;
|
||||
bottom: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
background: #050505;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
#cmp-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
background: #0a0a0a;
|
||||
border-top: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cmp-btn {
|
||||
min-height: 44px;
|
||||
min-width: 88px;
|
||||
padding: 8px 16px;
|
||||
background: #1c1c1c;
|
||||
color: #eee;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cmp-btn:hover { border-color: var(--accent); }
|
||||
.cmp-btn:active { background: #2a2a2a; }
|
||||
.cmp-btn-undo { background: #14202a; }
|
||||
|
||||
@media (max-aspect-ratio: 1/1) and (max-width: 700px) {
|
||||
#cmp-panes { flex-direction: column; }
|
||||
.cmp-pane { flex: 1 1 50%; }
|
||||
.cmp-btn { min-width: 0; flex: 1; padding: 8px 6px; font-size: 12px; }
|
||||
}
|
||||
|
||||
.label-tag.label-rating { background: rgba(110, 90, 0, 0.7); }
|
||||
|
||||
/* Scoreboard (reuses the analytics modal classes) */
|
||||
.sb-axis { border-bottom: 1px solid #222; }
|
||||
.sb-axis > summary {
|
||||
cursor: pointer;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #ddd;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
.sb-axis > summary .sb-meta { color: #777; font-weight: 400; text-transform: none; margin-left: 8px; font-size: 10px; }
|
||||
.sb-row-dim td { opacity: 0.45; }
|
||||
.sb-row-best { background: rgba(0, 209, 178, 0.14); }
|
||||
.sb-row-best td { color: #fff; }
|
||||
.sb-num { text-align: right; font-family: 'Consolas', monospace; white-space: nowrap; padding: 5px 10px; }
|
||||
.sb-val { padding: 5px 10px; color: #ddd; font-family: 'Consolas', monospace; font-size: 10px; word-break: break-all; }
|
||||
.sb-link { color: var(--accent-sch); cursor: pointer; text-decoration: underline; font-size: 10px; padding: 5px 10px; white-space: nowrap; }
|
||||
.sb-empty { padding: 10px 14px; color: #777; font-size: 11px; }
|
||||
.sb-footer { padding: 8px 14px; font-size: 10px; color: #888; background: #0d0d0d; border-top: 1px solid #222; flex-shrink: 0; }
|
||||
.sb-actions { display: flex; gap: 6px; padding: 8px 14px; flex-shrink: 0; border-top: 1px solid #222; background: #0a0a0a; }
|
||||
.sb-actions button {
|
||||
background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 4px; padding: 6px 12px; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.sb-actions button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
@@ -45,6 +45,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="fs-btn" id="compare-open-btn" onclick="openCompareMode()" title="Compare (C)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="4" width="8" height="16" rx="1.5"></rect>
|
||||
<rect x="14" y="4" width="8" height="16" rx="1.5"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="fs-btn" onclick="loadSession()" title="Reload Session (R)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
@@ -215,6 +222,10 @@
|
||||
<input type="checkbox" id="label-field-hiresPromptText" onchange="updateLabelFields()" style="width: 12px; height: 12px; cursor: pointer;">
|
||||
<label for="label-field-hiresPromptText" style="font-size: 10px; color: #d06080; cursor: pointer; user-select: none;">HiRes Prompt Text</label>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<input type="checkbox" id="label-field-rating" onchange="updateLabelFields()" style="width: 12px; height: 12px; cursor: pointer;">
|
||||
<label for="label-field-rating" style="font-size: 10px; color: #e0c040; cursor: pointer; user-select: none;" title="Pairwise rating and vote count (voted cards only)">Rating</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 8px; display: flex; align-items: center; gap: 6px;">
|
||||
<input type="checkbox" id="label-unique-only" checked
|
||||
@@ -324,6 +335,71 @@
|
||||
<div id="delete-status" style="margin-top: 4px; font-size: 10px; color: #666; min-height: 16px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Pairwise review (blind A/B compare + scoreboard) -->
|
||||
<div class="cog-panel-section">
|
||||
<div class="cog-panel-section-title">PAIRWISE REVIEW</div>
|
||||
<div style="font-size: 9px; color: #666; margin-bottom: 8px;">
|
||||
Blind two-up compare. Votes are saved in the session manifest and rolled up per setting.
|
||||
</div>
|
||||
<button class="session-action-btn"
|
||||
style="background: #e0a020; color: #000; width: 100%; font-size: 11px; padding: 10px; margin-bottom: 6px;"
|
||||
onclick="openCompareMode()" title="Start comparing pairs (C)">
|
||||
START COMPARE (C)
|
||||
</button>
|
||||
<button class="session-action-btn"
|
||||
style="background: #3e8ed0; width: 100%; font-size: 11px; padding: 10px; margin-bottom: 8px;"
|
||||
onclick="openScoreboard()" title="Win rates per setting from all votes">
|
||||
SCOREBOARD
|
||||
</button>
|
||||
<div class="cmp-setting-row">
|
||||
<label for="cmp-mode">Pairing</label>
|
||||
<select id="cmp-mode" onchange="cmpSettingsChanged()">
|
||||
<option value="matched">Matched (same prompt + seed)</option>
|
||||
<option value="seed">Same settings, different seed</option>
|
||||
<option value="one_axis">One axis at a time</option>
|
||||
<option value="random">Random (same media type)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cmp-setting-row">
|
||||
<label for="cmp-axis">Axis focus</label>
|
||||
<select id="cmp-axis" onchange="cmpSettingsChanged()">
|
||||
<option value="">Any axis</option>
|
||||
<option value="model">Model</option>
|
||||
<option value="lora">LoRA</option>
|
||||
<option value="sampler">Sampler</option>
|
||||
<option value="scheduler">Scheduler</option>
|
||||
<option value="cfg">CFG</option>
|
||||
<option value="steps">Steps</option>
|
||||
<option value="denoise">Denoise</option>
|
||||
<option value="resolution">Resolution</option>
|
||||
<option value="prompt">Prompt</option>
|
||||
<option value="upscale">Upscale</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cmp-setting-row">
|
||||
<label for="cmp-reveal">Reveal after vote</label>
|
||||
<select id="cmp-reveal" onchange="cmpSettingsChanged()">
|
||||
<option value="0">Off</option>
|
||||
<option value="1000" selected>1 s</option>
|
||||
<option value="2000">2 s</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cmp-setting-row">
|
||||
<input type="checkbox" id="cmp-blind" checked onchange="cmpSettingsChanged()" style="width: 12px; height: 12px; cursor: pointer;">
|
||||
<label for="cmp-blind" style="cursor: pointer;">Blind (hide settings until you vote)</label>
|
||||
</div>
|
||||
<div class="cmp-setting-row">
|
||||
<input type="checkbox" id="cmp-restrict" checked onchange="cmpSettingsChanged()" style="width: 12px; height: 12px; cursor: pointer;">
|
||||
<label for="cmp-restrict" style="cursor: pointer;">Scoreboard: only votes inside the current filter</label>
|
||||
</div>
|
||||
<button class="session-action-btn"
|
||||
style="background: #cc2222; width: 100%; font-size: 11px; padding: 8px; margin-top: 8px; border: 2px solid #ff4444;"
|
||||
onclick="clearComparisons()" title="Delete every vote saved in this session">
|
||||
CLEAR VOTES
|
||||
</button>
|
||||
<div id="cmp-status" style="margin-top: 6px; font-size: 10px; color: #666; min-height: 14px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Section 5: Manifest Analytics -->
|
||||
<div class="cog-panel-section">
|
||||
<div class="cog-panel-section-title">MANIFEST ANALYTICS</div>
|
||||
@@ -406,6 +482,18 @@
|
||||
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">R</td>
|
||||
<td style="padding: 4px 8px; color: #ccc;">Reload session</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #333;">
|
||||
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">C</td>
|
||||
<td style="padding: 4px 8px; color: #ccc;">Compare mode (blind A/B review)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #333;">
|
||||
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">A/D, S, W</td>
|
||||
<td style="padding: 4px 8px; color: #ccc;">In compare: A wins / B wins, tie, skip (arrows work too)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #333;">
|
||||
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">Z, I, B</td>
|
||||
<td style="padding: 4px 8px; color: #ccc;">In compare: undo, unblind, scoreboard</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #333;">
|
||||
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">1-9</td>
|
||||
<td style="padding: 4px 8px; color: #ccc;">Quick favorite Nth visible card</td>
|
||||
@@ -684,6 +772,40 @@
|
||||
onclick="selectJSON('json-bar-bad')">[]</div>
|
||||
</div>
|
||||
|
||||
<!-- Pairwise review overlay (see logic_compare.js) -->
|
||||
<div id="compare-overlay" style="display: none;">
|
||||
<div id="cmp-head">
|
||||
<span id="cmp-mode-label"></span>
|
||||
<span id="cmp-progress"></span>
|
||||
<span id="cmp-repeat-badge" style="display: none;">repeating voted pairs</span>
|
||||
<span class="cmp-spacer"></span>
|
||||
<button class="cmp-head-btn" id="cmp-unblind-btn" onclick="cmpToggleUnblind()" title="Show what differs before you vote (I)">Unblind (I)</button>
|
||||
<button class="cmp-head-btn" onclick="openScoreboard()" title="Scoreboard (B)">Scoreboard (B)</button>
|
||||
<button class="cmp-head-btn" onclick="cmpUndo()" title="Undo last vote (Z)">Undo (Z)</button>
|
||||
<button class="cmp-head-btn cmp-close" onclick="closeCompareMode()" title="Close (Esc)">✕</button>
|
||||
</div>
|
||||
<div id="cmp-panes">
|
||||
<div class="cmp-pane" id="cmp-pane-a" onclick="cmpVoteSide('a')">
|
||||
<div class="cmp-label">A</div>
|
||||
<div class="cmp-media" id="cmp-media-a"></div>
|
||||
<div class="cmp-chips" id="cmp-chips-a"></div>
|
||||
</div>
|
||||
<div class="cmp-pane" id="cmp-pane-b" onclick="cmpVoteSide('b')">
|
||||
<div class="cmp-label">B</div>
|
||||
<div class="cmp-media" id="cmp-media-b"></div>
|
||||
<div class="cmp-chips" id="cmp-chips-b"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cmp-empty" style="display: none;"></div>
|
||||
<div id="cmp-bar">
|
||||
<button class="cmp-btn" onclick="cmpVoteSide('a')">◀ A wins</button>
|
||||
<button class="cmp-btn" onclick="cmpVoteSide('tie')">Tie</button>
|
||||
<button class="cmp-btn" onclick="cmpSkip()">Skip</button>
|
||||
<button class="cmp-btn" onclick="cmpVoteSide('b')">B wins ▶</button>
|
||||
<button class="cmp-btn cmp-btn-undo" onclick="cmpUndo()">↶ Undo</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal">
|
||||
<button class="modal-close-x" onclick="closeM()" title="Close (Esc)">×</button>
|
||||
<!-- Lightbox navigation bar -->
|
||||
|
||||
Reference in New Issue
Block a user