cupido/scripts/barrier_picker_app/static/index.html
Giorgio Gilestro d314c02d05 Picker: lighter annotation bar + room below it
Background was nearly black (#181818) which made the bar barely visible
against the page. Switched to mid-grey (#555) so the red mark stands
out and the bar reads as an interactive element.

Added 2rem bottom margin so the tooltip below the mark doesn't crash
into the action buttons. Empty (un-annotated) state collapses to
zero height so it doesn't add space when there's no mark to show.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 14:31:21 +01:00

517 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Cupido — barrier-opening picker</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
background: #1a1a1a; color: #e8e8e8; }
header { padding: 0.6rem 1rem; background: #111; border-bottom: 1px solid #333;
display: flex; align-items: center; gap: 1.5rem; }
h1 { margin: 0; font-size: 1rem; font-weight: 500; color: #ccc; }
#status { font-family: ui-monospace, "SF Mono", monospace; font-size: 0.85rem;
color: #9aa; }
#info { font-family: ui-monospace, monospace; font-size: 0.85rem; color: #cce; }
#meta { display: flex; gap: 1.5rem; flex-wrap: wrap; margin: 0.6rem 0 0.4rem;
font-size: 0.85rem; color: #aab; max-width: 1400px; width: 100%;
justify-content: center; }
#meta .pair { font-family: ui-monospace, monospace; }
#meta .pair .k { color: #678; }
#meta .pair .v { color: #def; margin-left: 0.25rem; }
#meta .role-training { color: #cd6 !important; }
#meta .role-testing { color: #6cd !important; }
main { display: flex; flex-direction: column; align-items: center; padding: 1rem; }
video { width: 100%; max-width: 1400px; height: auto; background: #000;
border-radius: 4px; }
#controls { margin-top: 1rem; display: flex; gap: 1rem; flex-wrap: wrap;
justify-content: center; }
button { padding: 0.7rem 1.4rem; font-size: 0.95rem; border: 1px solid #444;
background: #2a2a2a; color: #eee; border-radius: 4px; cursor: pointer;
transition: all 80ms; }
button:hover { background: #383838; border-color: #666; }
button:active { transform: translateY(1px); }
button.primary { background: #2d5; color: #053; border-color: #1a4;
font-weight: 600; }
button.primary:hover { background: #3e6; }
button.warn { background: #d84; color: #311; border-color: #b62; }
button.warn:hover { background: #ea5; }
button.muted { background: #2a2a2a; color: #888; }
.mute-divider { width: 1px; background: #333; margin: 0 0.3rem; }
#help { margin-top: 1rem; font-size: 0.8rem; color: #889; text-align: center;
font-family: ui-monospace, monospace; }
kbd { background: #222; border: 1px solid #444; border-radius: 3px;
padding: 0.1rem 0.4rem; font-size: 0.8rem; color: #ddd;
font-family: ui-monospace, monospace; }
#progress { font-size: 0.85rem; color: #889; margin-left: auto; }
#flash { position: fixed; top: 0.5rem; right: 0.5rem; padding: 0.5rem 1rem;
border-radius: 4px; opacity: 0; transition: opacity 200ms;
font-family: ui-monospace, monospace; font-size: 0.85rem; }
#flash.show { opacity: 1; }
#flash.ok { background: #2d5; color: #042; }
#flash.err { background: #d44; color: white; }
/* Welcome modal */
#help-btn { background: transparent; border: 1px solid #444; color: #aab;
font-size: 0.85rem; padding: 0.3rem 0.7rem; }
#help-btn:hover { background: #2a2a2a; }
#modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85);
display: none; align-items: center; justify-content: center;
z-index: 100; padding: 1rem; }
#modal-backdrop.show { display: flex; }
#modal { background: #222; border: 1px solid #444; border-radius: 6px;
padding: 1.6rem 2rem; max-width: 640px; width: 100%;
max-height: 90vh; overflow-y: auto; color: #ddd; line-height: 1.55; }
#modal h2 { margin: 0 0 0.6rem; color: #fff; font-size: 1.2rem; }
#modal h3 { margin: 1.2rem 0 0.4rem; color: #cce; font-size: 0.95rem;
font-weight: 600; }
#modal p { margin: 0.5rem 0; }
#modal ol { padding-left: 1.4rem; margin: 0.5rem 0; }
#modal li { margin: 0.6rem 0; }
#modal .button-tag { display: inline-block; padding: 0.05rem 0.4rem;
background: #2d5; color: #053; border-radius: 3px;
font-weight: 600; font-size: 0.85rem; }
#modal-close { margin-top: 1.4rem; width: 100%; padding: 0.7rem;
background: #2d5; color: #053; font-weight: 600;
border-radius: 4px; cursor: pointer; border: 1px solid #1a4;
font-size: 0.95rem; }
#modal-close:hover { background: #3e6; }
/* User badge in header */
#user-badge { background: #2a3; color: #042; font-weight: 700;
padding: 0.2rem 0.6rem; border-radius: 12px;
font-family: ui-monospace, monospace; font-size: 0.85rem;
cursor: pointer; user-select: none; }
#user-badge:hover { background: #3b4; }
#user-badge.empty { background: #d84; color: #311; }
/* Login modal — narrower than the welcome modal */
#login-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85);
display: none; align-items: center; justify-content: center;
z-index: 200; }
#login-backdrop.show { display: flex; }
#login-box { background: #222; border: 1px solid #444; border-radius: 6px;
padding: 1.6rem 2rem; max-width: 380px; width: 90%;
color: #ddd; }
#login-box h2 { margin: 0 0 0.6rem; color: #fff; font-size: 1.1rem; }
#login-box p { margin: 0.4rem 0 1rem; color: #aab; font-size: 0.9rem; }
#login-input { width: 100%; padding: 0.6rem 0.8rem; font-size: 1.1rem;
background: #111; color: #fff; border: 1px solid #444;
border-radius: 4px; text-align: center;
font-family: ui-monospace, monospace; letter-spacing: 0.2em;
text-transform: uppercase; }
#login-input:focus { outline: none; border-color: #6c5; }
#login-submit { width: 100%; margin-top: 1rem; padding: 0.7rem;
background: #2d5; color: #053; font-weight: 600;
border-radius: 4px; cursor: pointer; border: 1px solid #1a4;
font-size: 0.95rem; }
#login-submit:hover { background: #3e6; }
#login-submit:disabled { background: #444; color: #888; cursor: not-allowed; }
/* Annotation bar (existing-mark indicator below the video) */
#annotation-bar { position: relative; width: 100%; max-width: 1400px;
height: 18px; background: #555; border: 1px solid #666;
border-radius: 3px; margin: 0.5rem 0 2rem;
cursor: pointer; }
#annotation-bar.empty { background: transparent; border-color: transparent;
cursor: default; height: 0; margin: 0; }
#annotation-bar .mark-line { position: absolute; top: -2px; bottom: -2px;
width: 3px; background: #f44;
box-shadow: 0 0 4px rgba(255,80,80,0.7); }
#annotation-bar .mark-line:hover { background: #f88; }
#annotation-bar .mark-tooltip { position: absolute; top: 100%;
transform: translateX(-50%);
margin-top: 4px; background: #f44;
color: white; padding: 2px 6px;
font-size: 0.75rem; border-radius: 3px;
font-family: ui-monospace, monospace;
white-space: nowrap; pointer-events: none; }
</style>
</head>
<body>
<header>
<h1>Cupido — barrier picker</h1>
<span id="info">loading…</span>
<span id="progress"></span>
<span id="user-badge" title="click to change initials"></span>
<button id="help-btn" title="show the help modal">?</button>
</header>
<div id="login-backdrop">
<div id="login-box">
<h2>Who are you?</h2>
<p>Enter your initials so we can record who annotated each video.
(Just letters, e.g. <code>GG</code>.)</p>
<input id="login-input" maxlength="4" autocomplete="off" autofocus />
<button id="login-submit" disabled>Continue</button>
</div>
</div>
<div id="modal-backdrop">
<div id="modal">
<h2>Welcome to the Cupido barrier picker</h2>
<p>For each tracked video you'll mark the moment the barrier between
the two halves of the arena is removed and the flies start interacting.
That timestamp is what every downstream "post-opening" analysis needs.</p>
<h3>How to annotate one video</h3>
<ol>
<li><strong>Drag the seekbar to the end of the video</strong> and check
whether <em>all</em> ROIs are open or only the upper or lower half.
This determines which button you'll click later.</li>
<li><strong>Drag back toward the beginning</strong> to roughly find
the moment the barrier(s) lift. The opening can be anywhere from a
few seconds to several minutes in.</li>
<li><strong>Use the arrow keys</strong> to pin down the exact frame:
<kbd></kbd>/<kbd></kbd> jump ±5 s, <kbd>Shift</kbd>+arrow jump ±30 s.
Pause the player on the opening frame.</li>
<li><strong>Click the button</strong> matching what opened — the
playhead time is recorded, ROI inclusion is set accordingly, and
the next video loads automatically.</li>
</ol>
<h3>The three buttons</h3>
<p>
<span class="button-tag">All barriers open</span> — every ROI usable<br>
<span class="button-tag">Upper barrier opens</span> — only ROIs 1, 3, 5 (top row); lower row will be excluded from analysis<br>
<span class="button-tag">Lower barrier opens</span> — only ROIs 2, 4, 6 (bottom row); upper row excluded
</p>
<h3>Other controls</h3>
<p>
<kbd>Space</kbd> play/pause &middot;
<kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> the three buttons &middot;
<kbd>s</kbd> skip &middot; <kbd>u</kbd> mark unusable
</p>
<p style="font-size: 0.85rem; color: #889;">Each click saves to
<code>barrier_opening.csv</code> immediately, so closing the tab
and coming back resumes from where you left off.</p>
<button id="modal-close">Got it — let's start</button>
</div>
</div>
<main>
<div id="meta"></div>
<video id="player" controls preload="auto"></video>
<div id="annotation-bar" class="empty"></div>
<div id="controls">
<button class="primary" data-mode="all">All barriers open <kbd>1</kbd></button>
<button class="primary" data-mode="upper">Upper barrier opens <kbd>2</kbd></button>
<button class="primary" data-mode="lower">Lower barrier opens <kbd>3</kbd></button>
<span class="mute-divider"></span>
<button class="muted" data-mode="skip">Skip <kbd>s</kbd></button>
<button class="warn" data-mode="unusable">Unusable <kbd>u</kbd></button>
<span class="mute-divider"></span>
<button class="muted" id="prev">◀ Previous</button>
<button class="muted" id="next">Next ▶</button>
</div>
<div id="help">
<kbd>Space</kbd> play/pause &nbsp;·&nbsp;
<kbd></kbd> / <kbd></kbd> ±5 s &nbsp;·&nbsp;
<kbd>Shift</kbd>+arrows ±30 s &nbsp;·&nbsp;
drag the seekbar for finer control
</div>
<div id="flash"></div>
</main>
<script>
const player = document.getElementById('player');
const info = document.getElementById('info');
const meta = document.getElementById('meta');
const progress = document.getElementById('progress');
const flash = document.getElementById('flash');
const userBadge = document.getElementById('user-badge');
const loginBackdrop = document.getElementById('login-backdrop');
const loginInput = document.getElementById('login-input');
const loginSubmit = document.getElementById('login-submit');
// ─── Analyst identity (persisted in localStorage) ───────────────────
function getAnalyst() {
try { return localStorage.getItem('cupido.analyst') || ''; }
catch (e) { return ''; }
}
function setAnalyst(s) {
try { localStorage.setItem('cupido.analyst', s); } catch (e) {}
renderUserBadge();
}
function renderUserBadge() {
const a = getAnalyst();
userBadge.textContent = a || 'sign in';
userBadge.classList.toggle('empty', !a);
}
function showLogin() {
loginBackdrop.classList.add('show');
loginInput.value = getAnalyst();
loginSubmit.disabled = !loginInput.value.trim();
setTimeout(() => loginInput.focus(), 50);
}
function hideLogin() { loginBackdrop.classList.remove('show'); }
function submitLogin() {
const v = loginInput.value.trim().toUpperCase();
if (!v) return;
setAnalyst(v);
hideLogin();
// After first login, show the welcome modal if not yet seen.
try {
if (localStorage.getItem('cupido.welcomed') !== '1') {
showModal(); // welcome modal — defined below
}
} catch (e) {}
}
loginInput.addEventListener('input', () => {
loginSubmit.disabled = !loginInput.value.trim();
});
loginInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && loginInput.value.trim()) submitLogin();
});
loginSubmit.addEventListener('click', submitLogin);
userBadge.addEventListener('click', showLogin);
let queue = [];
let cursor = 0;
function showFlash(msg, kind = 'ok') {
flash.textContent = msg;
flash.className = 'show ' + kind;
setTimeout(() => { flash.className = ''; }, 1800);
}
function updateProgress() {
const done = queue.filter(q => q.done).length;
progress.textContent = `${done}/${queue.length} done`;
}
function renderAnnotationBar(item) {
const bar = document.getElementById('annotation-bar');
bar.innerHTML = '';
const dur = item.duration_s;
const ex = item.existing;
// Always allow click-to-seek if we have a duration; only show as a real
// bar (with marks) when there's an existing annotation to display.
if (!dur) {
bar.classList.add('empty');
bar.onclick = null;
return;
}
if (!ex || ex.opening_s == null || isNaN(ex.opening_s)) {
bar.classList.add('empty');
bar.onclick = null;
return;
}
bar.classList.remove('empty');
// Click anywhere on the bar = seek to that fractional position.
bar.onclick = (e) => {
const rect = bar.getBoundingClientRect();
const t = (e.clientX - rect.left) / rect.width * dur;
player.currentTime = Math.max(0, Math.min(dur, t));
};
// Mark line at opening_s
const pct = (ex.opening_s / dur) * 100;
const line = document.createElement('div');
line.className = 'mark-line';
line.style.left = `calc(${pct}% - 1px)`;
// Click on the line specifically = snap to exact opening_s
line.onclick = (e) => {
e.stopPropagation();
player.currentTime = Math.max(0, Math.min(dur, ex.opening_s));
};
const tooltip = document.createElement('div');
tooltip.className = 'mark-tooltip';
const m = Math.floor(ex.opening_s / 60);
const s = (ex.opening_s % 60).toFixed(1);
const tag = ex.bad_rois ? ` · bad ROIs ${ex.bad_rois}` : '';
const who = ex.analyst ? ` · ${ex.analyst}` : '';
tooltip.textContent = `${m}:${s.padStart(4,'0')}${who}${tag}`;
// Position tooltip; if mark is near right edge, anchor right.
tooltip.style.left = pct < 90 ? `${pct}%` : `${pct}%`;
if (pct < 8) tooltip.style.transform = 'translateX(0)';
else if (pct > 92) tooltip.style.transform = 'translateX(-100%)';
bar.appendChild(line);
bar.appendChild(tooltip);
}
function renderMeta(m) {
meta.innerHTML = '';
if (!m) return;
const fields = [
['role', m.session_role,
m.session_role === 'training' ? 'role-training'
: m.session_role === 'testing' ? 'role-testing' : ''],
['species', m.species],
['memory', m.memory],
['training (hr)', m.training_length_hr],
['consol. (hr)', m.consolidation_length_hr],
['age (d)', m.age],
['flies', (m.n_trained || m.n_naive)
? `${m.n_trained || 0} trained · ${m.n_naive || 0} naive`
: null],
['training time', m.training_date_time],
['testing time', m.testing_date_time],
];
for (const [label, value, cls] of fields) {
if (value === undefined || value === null || value === '') continue;
const span = document.createElement('span');
span.className = 'pair';
span.innerHTML = `<span class="k">${label}:</span><span class="v ${cls||''}">${value}</span>`;
meta.appendChild(span);
}
}
function loadCursor() {
if (queue.length === 0) {
info.textContent = 'queue empty';
return;
}
cursor = ((cursor % queue.length) + queue.length) % queue.length;
const item = queue[cursor];
info.textContent =
`[${cursor + 1}/${queue.length}] ${item.machine_name} ${item.session_date} ${item.session_time} ` +
(item.duration_s ? `(${(item.duration_s/60).toFixed(1)} min)` : '') +
(item.done ? ' — already done' : '');
renderMeta(item.metadata);
renderAnnotationBar(item);
player.src = `/api/video/${item.idx}`;
player.load();
}
async function fetchQueue() {
const resp = await fetch('/api/queue');
queue = await resp.json();
// Jump to first not-yet-done
const firstUndone = queue.findIndex(q => !q.done);
cursor = firstUndone === -1 ? 0 : firstUndone;
updateProgress();
loadCursor();
}
async function submit(mode) {
if (queue.length === 0) return;
// Require initials before any picking action (skip is OK without).
if (mode !== 'skip' && !getAnalyst()) {
showLogin();
return;
}
const item = queue[cursor];
const payload = {
idx: item.idx,
time_s: (mode === 'skip' || mode === 'unusable') ? null : player.currentTime,
mode: mode,
analyst: getAnalyst(),
notes: '',
};
try {
const resp = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json();
showFlash('error: ' + (err.detail || resp.status), 'err');
return;
}
const result = await resp.json();
if (result.status === 'saved') {
item.done = true;
updateProgress();
const t = result.row.opening_s;
const bad = result.row.bad_rois;
showFlash(
`saved ${item.machine_name} ${item.session_date} ${item.session_time}: ` +
(Number.isNaN(t) || t === null ? 'unusable' :
`${t.toFixed(1)}s${bad ? ' (bad ROIs: ' + bad + ')' : ''}`)
);
} else if (result.status === 'skipped') {
showFlash(`skipped ${item.machine_name} ${item.session_date} ${item.session_time}`);
}
cursor = (cursor + 1) % queue.length;
loadCursor();
} catch (e) {
showFlash('network error: ' + e.message, 'err');
}
}
// Button handlers
document.querySelectorAll('button[data-mode]').forEach(btn => {
btn.addEventListener('click', () => submit(btn.dataset.mode));
});
document.getElementById('prev').addEventListener('click', () => {
cursor = (cursor - 1 + queue.length) % queue.length;
loadCursor();
});
document.getElementById('next').addEventListener('click', () => {
cursor = (cursor + 1) % queue.length;
loadCursor();
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Don't react to picker shortcuts while either modal is open.
if (modalBackdrop.classList.contains('show')) return;
if (loginBackdrop.classList.contains('show')) return;
// Prevent the browser default (e.g. video focus side effects on space).
const stop = () => { e.preventDefault(); e.stopPropagation(); };
switch (e.key) {
case ' ':
stop();
if (player.paused) player.play(); else player.pause();
break;
case 'ArrowLeft':
stop();
player.currentTime -= e.shiftKey ? 30 : 5;
break;
case 'ArrowRight':
stop();
player.currentTime += e.shiftKey ? 30 : 5;
break;
case '1': stop(); submit('all'); break;
case '2': stop(); submit('upper'); break;
case '3': stop(); submit('lower'); break;
case 's': stop(); submit('skip'); break;
case 'u': stop(); submit('unusable'); break;
}
});
// Welcome modal — show first time, dismissable; ? button reopens it.
const modalBackdrop = document.getElementById('modal-backdrop');
const modalClose = document.getElementById('modal-close');
const helpBtn = document.getElementById('help-btn');
function showModal() { modalBackdrop.classList.add('show'); }
function hideModal() {
modalBackdrop.classList.remove('show');
try { localStorage.setItem('cupido.welcomed', '1'); } catch (e) {}
}
modalClose.addEventListener('click', hideModal);
helpBtn.addEventListener('click', showModal);
modalBackdrop.addEventListener('click', (e) => {
if (e.target === modalBackdrop) hideModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modalBackdrop.classList.contains('show')) {
e.stopPropagation(); hideModal();
}
});
// On first visit, login first (mandatory) then welcome modal.
renderUserBadge();
if (!getAnalyst()) {
showLogin();
} else {
let welcomed = false;
try { welcomed = localStorage.getItem('cupido.welcomed') === '1'; } catch (e) {}
if (!welcomed) showModal();
}
fetchQueue();
</script>
</body>
</html>