File size: 20,180 Bytes
9e81c08
 
 
 
 
 
 
 
c7c89ad
9e81c08
c7c89ad
 
 
 
9e81c08
 
 
c7c89ad
 
9e81c08
 
c7c89ad
 
 
 
 
9e81c08
 
 
 
c7c89ad
 
 
 
 
 
9e81c08
c7c89ad
 
 
9e81c08
c7c89ad
9e81c08
 
c7c89ad
 
 
9e81c08
 
c7c89ad
2a932ac
 
 
 
 
 
 
 
 
 
 
 
 
9e81c08
 
 
c7c89ad
9e81c08
 
c7c89ad
9e81c08
 
c7c89ad
9e81c08
c7c89ad
 
 
9e81c08
c7c89ad
9e81c08
c7c89ad
 
9e81c08
 
c7c89ad
9e81c08
c7c89ad
9e81c08
c7c89ad
9e81c08
 
 
 
c7c89ad
 
 
 
 
9e81c08
c7c89ad
9e81c08
3ee8036
 
 
 
 
 
3ea7a27
c7c89ad
9e81c08
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ea7a27
 
 
 
9e81c08
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ea7a27
9e81c08
 
 
 
 
2a932ac
 
 
9e81c08
 
 
2a932ac
 
 
 
 
 
 
 
 
 
 
9e81c08
 
 
 
 
3ea7a27
9e81c08
35e0afd
9e81c08
 
35e0afd
9e81c08
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ee8036
 
 
 
 
16842f6
 
 
3ee8036
 
 
 
9e81c08
 
 
 
 
 
 
 
 
 
c7c89ad
9e81c08
 
 
 
3ea7a27
 
9e81c08
 
 
 
 
 
 
 
 
 
 
7ba513a
35e0afd
9e81c08
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ea7a27
9e81c08
 
 
35e0afd
9e81c08
 
 
 
 
35e0afd
9e81c08
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b24b44
9e81c08
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Study Dashboard</title>
<style>
/* Same plain style as the study page: system font, no boxes, one accent. */
:root{
  --bg:#ffffff; --ink:#1a1a1a; --muted:#6b6b6b; --line:#e3e3e3; --hover:#f4f4f4;
  --accent:#2457a6; --accent-soft:#eef3fb; --warn:#b4530f; --bad:#b3261e;
  --font:system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --mono:ui-monospace, Menlo, Consolas, monospace;
  color-scheme:light;
}
@media (prefers-color-scheme: dark){:root{
  --bg:#161616; --ink:#ececec; --muted:#a0a0a0; --line:#333333; --hover:#212121;
  --accent:#8ab4f8; --accent-soft:#1e2a3d; --warn:#f0a868; --bad:#f2b8b5; color-scheme:dark}}
[hidden]{display:none!important}
html,body{background:var(--bg);color:var(--ink)}
body{margin:0;font-family:var(--font);font-size:14px;line-height:1.5}
main{max-width:1080px;margin:0 auto;padding:32px 16px 64px;display:flex;flex-direction:column;gap:36px}
h1{font-size:22px;font-weight:600;line-height:1.3;margin:0}
h2{font-size:16px;font-weight:600;margin:0 0 2px}
.eyebrow{font-size:13px;color:var(--muted)}
.muted{color:var(--muted)}
.mono{font-family:var(--mono);font-size:12.5px}
header{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:flex-end;justify-content:space-between}
header .meta{display:flex;flex-direction:column;gap:2px;font-size:12.5px;color:var(--muted)}
button{font:500 13px var(--font);color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer}
button:hover{background:var(--hover)}
button.primary{background:var(--accent);border-color:var(--accent);color:var(--bg)}
input[type=password]{font:14px var(--font);color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px 10px;min-width:0;flex:1}
.card{display:flex;flex-direction:column}
main > section.card{border-top:1px solid var(--line);padding-top:20px}
section > .sub{margin:0 0 14px;color:var(--muted);font-size:13px}
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px 24px}
.tile b{display:block;font-size:28px;font-weight:600;line-height:1.1;margin:4px 0 2px;font-variant-numeric:tabular-nums}
.tile .eyebrow{font-size:13px}
.tile span.note{font-size:12px;color:var(--muted)}
.warnbox{color:var(--warn);border-left:3px solid var(--warn);padding:2px 0 2px 12px;font-size:13px}
.err{color:var(--bad)}
/* list progress */
.lists{display:flex;flex-direction:column;gap:6px}
.lrow{display:grid;grid-template-columns:56px 1fr 120px;gap:12px;align-items:center}
.track{height:8px;background:var(--hover);border-radius:4px;display:flex;gap:2px;overflow:hidden}
.track i{display:block;height:100%;background:var(--accent);border-radius:0 4px 4px 0}
.track i.pend{background:var(--accent-soft);border-radius:0 4px 4px 0}
.lrow .n{font-size:12.5px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
.lists details>summary{list-style:none;cursor:pointer;border-radius:4px}
.lists details>summary::-webkit-details-marker{display:none}
.lists details>summary:hover{background:var(--hover)}
.lists details[open]>summary .mono{font-weight:600}
.lcontent{display:flex;flex-direction:column;gap:12px;padding:10px 0 16px 68px}
.lcontent h3{font-size:12px;font-weight:500;color:var(--muted);margin:0}
.lstims{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px}
.lstims figure,.lrank figure{margin:0;display:flex;flex-direction:column;gap:4px;font-size:11.5px}
.lstims img,.lrank img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:4px;background:var(--hover)}
.lstims figure.check img{outline:2px solid var(--warn);outline-offset:-2px}
.lrank{display:flex;flex-direction:column;gap:6px}
.lrank .imgs{display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,110px));gap:8px}
@media (max-width:600px){.lcontent{padding-left:0}}
/* tables */
.tw{overflow-x:auto;margin:0 -4px}
table{border-collapse:collapse;width:100%;font-size:13px}
th{font-size:12px;font-weight:500;color:var(--muted);text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);white-space:nowrap}
th.sortable{cursor:pointer}
th.sortable:hover,th.sorted{color:var(--ink)}
td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:middle}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
tr.stim{cursor:pointer}
tr.stim:hover td{background:var(--hover)}
tr.stim[aria-expanded=true] td{background:var(--accent-soft)}
.prompt{font-weight:500}
.tag{display:inline-block;font-size:11.5px;padding:1px 6px;border-radius:4px;background:var(--hover);color:var(--muted);white-space:nowrap}
.tag.ctrl{color:var(--warn)}
.meanbar{display:flex;align-items:center;gap:8px;justify-content:flex-end}
.meanbar .t{width:64px;height:6px;background:var(--hover);border-radius:3px;overflow:hidden}
.meanbar .t i{display:block;height:100%;background:var(--accent);border-radius:0 3px 3px 0}
.hist{display:inline-flex;align-items:flex-end;gap:2px;height:24px}
.hist i{display:block;width:8px;background:var(--accent);border-radius:2px 2px 0 0;min-height:1px}
.hist i.zero{background:var(--line)}
/* detail row */
.detail td{background:var(--hover);padding:16px}
.detail .wrap{display:flex;flex-wrap:wrap;gap:20px;align-items:flex-start}
.stimimg{position:relative;width:280px;max-width:100%;line-height:0;border-radius:4px;overflow:hidden}
.stimimg img{width:100%;height:auto}
.stimimg .mk{position:absolute;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;background:#ff7a1a;opacity:.6;box-shadow:0 0 0 2px #ffffff}
.detail dl{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:13px;max-width:520px}
.detail dt{color:var(--muted)}
.detail dd{margin:0}
/* pairs */
.pairs{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px 28px}
.pair{display:flex;flex-direction:column;gap:6px}
.pair .head{display:flex;justify-content:space-between;align-items:baseline;color:var(--muted);font-size:12.5px}
.pm{display:grid;grid-template-columns:1fr 64px 56px;gap:10px;align-items:center;font-size:13px}
.pm .t{height:6px;background:var(--hover);border-radius:3px;overflow:hidden}
.pm .t i{display:block;height:100%;background:var(--accent);border-radius:0 3px 3px 0}
.pm .v{text-align:right;font-size:12.5px;font-variant-numeric:tabular-nums}
.fail{color:var(--bad);font-weight:500}
.rankings{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:20px 28px}
.rset{display:flex;flex-direction:column;gap:8px}
.rimgs{display:flex;gap:8px}
.rimgs figure{margin:0;flex:1;min-width:0}
.rimgs img{width:100%;height:auto;display:block;border-radius:4px}
.rimgs figcaption{font-size:12px;margin-top:4px;line-height:1.35}
.warn{color:var(--warn)}
#login{max-width:400px;margin:14vh auto;padding:0 16px;display:flex;flex-direction:column;gap:12px}
#login form{display:flex;gap:8px}
@media (max-width:640px){
  .lrow{grid-template-columns:48px 1fr 92px}
}
</style>
</head>
<body>
<div id="login" class="card" hidden>
  <div class="eyebrow">Partial prompt study</div>
  <h1>Dashboard</h1>
  <p class="muted" style="margin:0">Enter the <span class="mono">ADMIN_KEY</span> set on the Space.</p>
  <form id="loginform"><input type="password" id="key" autocomplete="current-password" placeholder="Admin key" required><button class="primary">Open</button></form>
  <p id="loginerr" class="err" hidden></p>
</div>
<main id="app" hidden></main>
<script>
const $ = (s, el = document) => el.querySelector(s);
const esc = v => String(v ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const fmt = (v, d = 2) => v == null ? "–" : (+v).toFixed(d);
const pct = v => v == null ? "–" : Math.round(v * 100) + "%";
const FLAG = {attention_check: "Attention check", too_fast: "Many answers under 1 s", same_answers: "Same rating everywhere"};
// "Attention check, Attention check" -> "2 × Attention check"
const flagText = flags => Object.entries(flags.reduce((c, f) => (c[f] = (c[f] || 0) + 1, c), {}))
  .map(([f, n]) => `${n > 1 ? n + " × " : ""}${FLAG[f] || f}`).join(", ");
const when = iso => iso ? new Date(iso).toLocaleString(undefined, {month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"}) : "–";

const params = new URLSearchParams(location.search);
let key = params.get("key") || "";
try { key = key || sessionStorage.getItem("adminKey") || ""; } catch (e) {}
let data = null, sort = {col: "sample_id", dir: 1}, open = new Set();

$("#loginform").onsubmit = e => { e.preventDefault(); key = $("#key").value.trim(); load(); };

async function load(refresh = false) {
  if (!key) { $("#login").hidden = false; return; }
  const r = await fetch(`/api/summary?key=${encodeURIComponent(key)}${refresh ? "&refresh=true" : ""}`);
  if (!r.ok) {
    const msg = (await r.json().catch(() => ({}))).detail || `HTTP ${r.status}`;
    try { sessionStorage.removeItem("adminKey"); } catch (e) {}
    $("#app").hidden = true; $("#login").hidden = false;
    $("#loginerr").hidden = false; $("#loginerr").textContent = msg;
    return;
  }
  try { sessionStorage.setItem("adminKey", key); } catch (e) {}
  if (params.has("key")) history.replaceState(null, "", location.pathname);  // keep the key out of the address bar
  data = await r.json();
  $("#login").hidden = true; $("#app").hidden = false;
  render();
}

function render() {
  const d = data, t = d.totals;
  const maxList = Math.max(1, ...d.lists.map(l => l.completed + l.in_progress));
  const spread = Math.max(...d.lists.map(l => l.completed)) - Math.min(...d.lists.map(l => l.completed));
  $("#app").innerHTML = `
  <header>
    <div><div class="eyebrow">Partial prompt study · ${esc(d.study_version)}</div><h1>Dashboard</h1></div>
    <div class="meta">
      <span>Stimuli <span class="mono">${esc(d.dataset)}</span></span>
      <span>Saving to <span class="mono">${esc(d.saving_to)}</span></span>
      <span>Updated ${when(d.generated_at)} · <button id="refresh">Refresh</button></span>
    </div>
  </header>
  ${d.saving_to.startsWith("local") ? `<div class="warnbox">Responses are not being pushed to the Hub. They will be lost when the Space restarts. Set <span class="mono">HF_TOKEN</span> and <span class="mono">RESPONSES_REPO</span>.</div>` : ""}

  <div class="tiles">
    <div class="tile"><span class="eyebrow">Completed</span><b>${t.completed}</b><span class="note">sessions saved</span></div>
    <div class="tile"><span class="eyebrow">In progress</span><b>${t.in_progress}</b><span class="note">started in the last 30 min</span></div>
    <div class="tile"><span class="eyebrow">Excluded</span><b>${t.excluded}</b><span class="note">${t.excluded ? Object.entries(t.excluded_by).map(([f, n]) => `${FLAG[f] || f} ${n}`).join(" · ") : `failed ${t.exclude_at} checks or more`}${t.flagged ? ` · ${t.flagged} more with warnings` : ""}</span></div>
    <div class="tile"><span class="eyebrow">Median time</span><b>${t.median_minutes == null ? "–" : fmt(t.median_minutes, 1) + " min"}</b><span class="note">per completed session</span></div>
  </div>

  <section class="card">
    <h2>Progress per list</h2>
    <p class="sub">${d.lists.length} counterbalanced lists. New participants get the least-filled list. Click a list to see its images and rankings. ${t.completed ? `Gap between the fullest and emptiest list: ${spread}.` : ""}</p>
    <div class="lists">${d.lists.map(l => `<details>
      <summary class="lrow" title="List ${l.list_id}: ${l.completed} completed, ${l.in_progress} in progress, ${l.items} items. Click to see its images and rankings.">
        <span class="mono">List ${l.list_id}</span>
        <div class="track">${l.completed ? `<i style="width:${l.completed / maxList * 100}%"></i>` : ""}${l.in_progress ? `<i class="pend" style="width:${l.in_progress / maxList * 100}%"></i>` : ""}</div>
        <span class="n">${l.completed} done${l.in_progress ? ` · ${l.in_progress} live` : ""}</span>
      </summary>
      <div class="lcontent">
        <h3>${l.stimuli.length} images to rate${l.stimuli.some(s => s.check) ? ` · attention checks outlined` : ""}</h3>
        <div class="lstims">${l.stimuli.map(s => `<figure class="${s.check ? "check" : ""}" title="${esc(s.prompt)}">
          <img src="/stimuli/${s.file_name.split("/").map(encodeURIComponent).join("/")}" alt="" loading="lazy">
          <figcaption><span class="mono">${esc(s.sample_id)}</span>${s.check ? ` <span class="tag">check</span>` : ""}<br>${esc(s.prompt)}</figcaption></figure>`).join("")}</div>
        ${l.rankings.length ? `<h3>${l.rankings.length} rankings</h3>${l.rankings.map(r => `<div class="lrank">
          <span><span class="prompt">${esc(r.prompt)}</span> <span class="mono muted">${esc(r.set_id)}</span></span>
          <div class="imgs">${r.images.map(f => `<figure><img src="/stimuli/${f.split("/").map(encodeURIComponent).join("/")}" alt="" loading="lazy"></figure>`).join("")}</div>
        </div>`).join("")}` : ""}
      </div></details>`).join("")}
    </div>
  </section>

  <section class="card">
    <h2>By category</h2>
    <p class="sub">Attention check left out. Rating from 1 (does not match) to 5 (matches). Excluded sessions are left out of every statistic below.</p>
    <div class="tw"><table>
      <thead><tr><th>Category</th><th class="num">Ratings</th><th class="num">Mean rating</th><th class="num">“Problem” marked</th></tr></thead>
      <tbody>${d.categories.map(c => `<tr>
        <td><span class="tag">${esc(c.category)}</span> ${esc(c.name)}</td>
        <td class="num">${c.n}</td><td class="num">${meanbar(c.mean)}</td><td class="num">${pct(c.problem_rate)}</td></tr>`).join("")}</tbody>
    </table></div>
  </section>

  <section class="card">
    <h2>Minimal pairs</h2>
    <p class="sub">Members of a pair are seen by different participants. Compare their mean ratings.</p>
    <div class="pairs">${d.pairs.map(p => `<div class="pair">
      <div class="head"><span class="mono">${esc(p.pair_id)}</span></div>
      ${p.members.map(m => `<div class="pm" title="${esc(m.sample_id)}: n = ${m.n}">
        <span><span class="mono">${esc(m.sample_id)}</span> <span class="prompt">${esc(m.prompt)}</span>${m.pair_role ? ` <span class="tag">${esc(m.pair_role)}</span>` : ""}</span>
        <div class="t">${m.mean != null ? `<i style="width:${(m.mean - 1) / 4 * 100}%"></i>` : ""}</div>
        <span class="v">${fmt(m.mean)} <span class="muted">n${m.n}</span></span></div>`).join("")}
    </div>`).join("")}</div>
  </section>

  ${d.rankings.length ? `<section class="card">
    <h2>Rankings</h2>
    <p class="sub">Images shown together for one text, ordered by mean rank (1 = favourite). “First” is how often each image was ranked first.</p>
    <div class="rankings">${d.rankings.map(r => `<div class="rset">
      <div class="head"><span class="prompt">${esc(r.prompt)}</span> <span class="muted">${esc(r.set_id)} · n${r.n}</span></div>
      <div class="rimgs">${r.images.map(im => `<figure title="${esc(im.image_id)}${im.sample_id ? " · " + esc(im.sample_id) : ""}${im.case_id ? " · " + esc(im.case_id) : ""}">
        <img src="/stimuli/${im.file_name.split("/").map(encodeURIComponent).join("/")}" alt="" loading="lazy">
        <figcaption>${im.n ? `<b>${fmt(im.mean_rank, 1)}</b> <span class="muted">${pct(im.first)} first</span><br>` : ""}<span class="mono muted">${esc(im.sample_id || im.image_id)}</span></figcaption>
      </figure>`).join("")}</div>
    </div>`).join("")}</div>
  </section>` : ""}

  <section class="card">
    <h2>Stimuli</h2>
    <p class="sub">Click a row to see the image with every click mark. Click a column header to sort.</p>
    <div class="tw"><table id="stimtable"></table></div>
  </section>

  <section class="card">
    <h2>Recent sessions</h2>
    <p class="sub">The last ${d.sessions.length} sessions, newest first.</p>
    <div class="tw"><table>
      <thead><tr><th>Received</th><th>Session</th><th class="num">List</th><th class="num">Time</th><th>Source</th><th>English</th><th>T2I use</th><th>Quality</th></tr></thead>
      <tbody>${d.sessions.map(s => `<tr>
        <td class="num">${when(s.received_at)}</td><td class="mono">${esc(s.session)}</td><td class="num">${s.list_id}</td>
        <td class="num">${s.minutes == null ? "–" : fmt(s.minutes, 1) + " min"}</td>
        <td>${s.prolific ? "Prolific" : "Direct"}</td><td>${esc(s.english)}</td><td>${esc(s.t2i)}</td>
        <td>${s.excluded ? `<span class="fail">✕ Excluded: ${flagText(s.flags)}</span>`
             : s.flags.length ? `<span class="warn">⚠ ${flagText(s.flags)}</span>` : "✓ Passed"}</td></tr>`).join("") || `<tr><td colspan="8" class="muted">No sessions yet.</td></tr>`}</tbody>
    </table></div>
  </section>`;
  $("#refresh").onclick = () => load(true);
  renderStimuli();
}

function meanbar(m) {
  return `<span class="meanbar">${fmt(m)}<span class="t">${m != null ? `<i style="width:${(m - 1) / 4 * 100}%"></i>` : ""}</span></span>`;
}

const COLS = [
  ["sample_id", "ID"], ["prompt", "Prompt"], ["case_id", "Case"],
  ["n", "n", 1], ["mean", "Mean rating", 1], ["dist", "Ratings 1–5"], ["problem_rate", "Problem", 1],
  ["median_rt_s", "Median RT", 1],
];

function renderStimuli() {
  const rows = [...data.stimuli].sort((a, b) => {
    const x = a[sort.col], y = b[sort.col];
    if (x == null) return 1; if (y == null) return -1;
    return (x > y ? 1 : x < y ? -1 : 0) * sort.dir;
  });
  const tbl = $("#stimtable");
  tbl.innerHTML = `<thead><tr>${COLS.map(([k, label, num]) =>
    `<th class="${num ? "num " : ""}${k !== "dist" ? "sortable" : ""}${sort.col === k ? " sorted" : ""}" data-k="${k}">${label}${sort.col === k ? (sort.dir > 0 ? " ↑" : " ↓") : ""}</th>`).join("")}</tr></thead>
  <tbody>${rows.map(s => {
    const peak = Math.max(1, ...s.dist);
    return `<tr class="stim" data-id="${esc(s.sample_id)}" aria-expanded="${open.has(s.sample_id)}" tabindex="0">
      <td class="mono">${esc(s.sample_id)}</td>
      <td><span class="prompt">${esc(s.prompt)}</span></td>
      <td>${s.attention_check ? `<span class="tag ctrl">attention check</span>` : `<span class="tag">${esc(s.case_id)}</span>`}</td>
      <td class="num">${s.n}</td>
      <td class="num">${meanbar(s.mean)}</td>
      <td><span class="hist" title="${s.dist.map((c, i) => `${i + 1}: ${c}`).join(" · ")}">${s.dist.map(c => `<i class="${c ? "" : "zero"}" style="height:${c ? Math.max(3, c / peak * 26) : 1}px"></i>`).join("")}</span></td>
      <td class="num">${pct(s.problem_rate)}</td>
      <td class="num">${s.median_rt_s == null ? "–" : fmt(s.median_rt_s, 1) + " s"}</td>
    </tr>${open.has(s.sample_id) ? detail(s) : ""}`;
  }).join("")}</tbody>`;
  tbl.querySelectorAll("th.sortable").forEach(th => th.onclick = () => {
    const k = th.dataset.k;
    sort = {col: k, dir: sort.col === k ? -sort.dir : (["n", "mean", "problem_rate", "median_rt_s"].includes(k) ? -1 : 1)};
    renderStimuli();
  });
  tbl.querySelectorAll("tr.stim").forEach(tr => {
    const toggle = () => { const id = tr.dataset.id; open.has(id) ? open.delete(id) : open.add(id); renderStimuli(); };
    tr.onclick = toggle;
    tr.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } };
  });
}

function detail(s) {
  return `<tr class="detail"><td colspan="${COLS.length}"><div class="wrap">
    <div class="stimimg"><img src="/stimuli/${encodeURIComponent(s.file_name)}" alt="${esc(s.prompt)}">
      ${s.marks.map(([x, y]) => `<div class="mk" style="left:${x * 100}%;top:${y * 100}%"></div>`).join("")}</div>
    <dl>
      <dt>Category</dt><dd><span class="tag">${esc(s.category)}</span> ${esc(s.category_name)} · ${esc(s.subcategory)}</dd>
      <dt>Ratings</dt><dd>${s.dist.map((n, i) => `${i + 1}: <b>${n}</b>`).join(" · ")}</dd>
      <dt>Click marks</dt><dd>${s.marks.length}</dd>
      ${s.segments && s.segments.length ? `<dt>Parts selected</dt><dd>${s.segments.map(([c, n]) => `${esc(c)} ${n}`).join(", ")}</dd>` : ""}
    </dl></div></td></tr>`;
}

load();
</script>
</body>
</html>