cyborg-hunter 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ // src/cli/renderers/html-index.js
2
+ // Generates the self-contained HTML report page.
3
+ // References linked images in images/ (not base64-embedded).
4
+ //
5
+ // Sections:
6
+ // - Dashboard header with counts
7
+ // - Sortable triage table (vanilla JS, no dependencies)
8
+ // - Per-participant detail sections with image references
9
+ // - Inline CSS for clean layout
10
+ // - Works offline when opened from the output folder
11
+
12
+ import { writeFileSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { VERSION } from '../../shared/constants.js';
15
+
16
+ export async function renderHtmlIndex(summaries, triage, participants, config, visualsRendered) {
17
+ const flaggedHard = triage.filter(t => t.hardTriggered).length;
18
+ const flaggedSoft = triage.filter(t => !t.hardTriggered && t.softFlagged).length;
19
+ const clean = triage.length - flaggedHard - flaggedSoft;
20
+
21
+ const triageRows = triage.map((t, i) => {
22
+ const hardClass = t.hardTriggered ? 'hard' : t.softFlagged ? 'soft' : 'clean';
23
+ const s = t.summary || {};
24
+ const extCount = (s.aiExtensionsFound || []).length;
25
+ // Soft score from the configured scoring engine. Prefer session-authoritative
26
+ // (set by the monitor at collection time using the active scoring config);
27
+ // fall back to the per-trial accumulated sum when the session didn't record one.
28
+ const softScore = s.authoritativeSoftScore ?? s.totalSoftScore ?? 0;
29
+ return `<tr class="${hardClass}" onclick="scrollToParticipant('${esc(t.participantId)}')">
30
+ <td>${i + 1}</td>
31
+ <td>${esc(t.participantId)}</td>
32
+ <td>${t.score}</td>
33
+ <td>${softScore}</td>
34
+ <td>${t.hardTriggered ? '<strong>YES</strong>' : 'no'}</td>
35
+ <td class="session-col">${extCount}</td>
36
+ <td class="session-col">${s.sidebarEventCount ?? 0}</td>
37
+ <td class="session-col">${s.keyboardShortcutCount ?? 0}</td>
38
+ <td class="session-col">${(s.layoutShiftCount ?? 0) + (s.zoomChangeCount ?? 0)}</td>
39
+ <td>${esc(t.reason)}</td>
40
+ </tr>`;
41
+ }).join('\n');
42
+
43
+ const participantSections = triage.map(t => {
44
+ const s = t.summary;
45
+ const pid = sanitize(t.participantId);
46
+ const pidEsc = esc(t.participantId);
47
+
48
+ let images = '';
49
+ if (visualsRendered) {
50
+ // P4: Images are wrapped in .zoomable so the lightbox JS can hoist them
51
+ // to a full-viewport overlay on click. The bare PNG is still reachable
52
+ // via its href as a graceful fallback if JS is disabled.
53
+ images = `
54
+ <div class="images">
55
+ <a href="images/typing_profile_${pid}.png" class="zoomable" title="Click to enlarge"><img src="images/typing_profile_${pid}.png" alt="Typing profile" onerror="this.parentElement.style.display='none'"></a>
56
+ <a href="images/tab_timeline_${pid}.png" class="zoomable" title="Click to enlarge"><img src="images/tab_timeline_${pid}.png" alt="Tab timeline" onerror="this.parentElement.style.display='none'"></a>
57
+ <a href="images/trajectories_${pid}.png" class="zoomable" title="Click to enlarge"><img src="images/trajectories_${pid}.png" alt="Mouse trajectories" onerror="this.parentElement.style.display='none'"></a>
58
+ </div>`;
59
+ } else {
60
+ images = '<p class="note">Visual renderers not available (install canvas package).</p>';
61
+ }
62
+
63
+ // Session-level signal block — surfaces integrity-monitor session metadata
64
+ // (extensions, sidebars with timestamps, keyboard shortcuts). Only rendered
65
+ // when the session had at least one such event.
66
+ const aiExtensions = s.aiExtensionsFound || [];
67
+ const sidebarEvents = (participants.find(p => p.participantId === t.participantId)?.session?.sidebarEvents) || [];
68
+ const keyboardShortcuts = (participants.find(p => p.participantId === t.participantId)?.session?.keyboardShortcuts) || [];
69
+ const hasSessionSignals = aiExtensions.length > 0 || sidebarEvents.length > 0 || keyboardShortcuts.length > 0 ||
70
+ (s.layoutShiftCount || 0) > 0 || (s.zoomChangeCount || 0) > 0 || (s.devToolsEventCount || 0) > 0;
71
+
72
+ let sessionBlock = '';
73
+ if (hasSessionSignals) {
74
+ const extLines = aiExtensions.map(e => {
75
+ const name = typeof e === 'string' ? e : (e.name || 'unknown');
76
+ const detail = typeof e === 'object' && e.matchedSelector ? ` <span class="muted">(${esc(e.matchedSelector)})</span>` : '';
77
+ return `<li>${esc(name)}${detail}</li>`;
78
+ }).join('');
79
+ const sidebarLines = sidebarEvents.slice(0, 10).map(ev => {
80
+ const ts = ev.t || ev.timestamp || '';
81
+ // Both open and close events now carry a signed `deltaIW` (Task P6).
82
+ // Open emits positive shrinkage (+312px), close emits negative expansion
83
+ // (-312px). Render both with the same "gap" line; the sign tells the
84
+ // story. Duration is appended for close events so triage sees how long
85
+ // the sidebar was visible.
86
+ const gap = ev.deltaIW ?? ev.gap ?? ev.gapPx ?? ev.sidebarGapPx ?? '?';
87
+ const gapStr = typeof gap === 'number' ? (gap > 0 ? `+${gap}` : String(gap)) : String(gap);
88
+ const durSuffix = (ev.type === 'closed' && ev.duration_ms != null)
89
+ ? ` <span class="muted">(${(ev.duration_ms / 1000).toFixed(1)}s open)</span>` : '';
90
+ return `<li><code>${esc(String(ts))}</code> — gap ${esc(gapStr)}px${durSuffix}</li>`;
91
+ }).join('');
92
+ const kbLines = keyboardShortcuts.slice(0, 10).map(ev => {
93
+ const key = ev.combo || ev.key || JSON.stringify(ev);
94
+ return `<li><code>${esc(String(key))}</code></li>`;
95
+ }).join('');
96
+ sessionBlock = `
97
+ <div class="session-signals">
98
+ <h4>Session-level signals</h4>
99
+ <div class="session-grid">
100
+ ${aiExtensions.length > 0 ? `<div><strong>AI extensions (${aiExtensions.length}):</strong><ul>${extLines}</ul></div>` : ''}
101
+ ${sidebarEvents.length > 0 ? `<div><strong>Sidebar events (${sidebarEvents.length}${sidebarEvents.length > 10 ? ', first 10' : ''}):</strong><ul>${sidebarLines}</ul></div>` : ''}
102
+ ${keyboardShortcuts.length > 0 ? `<div><strong>Keyboard shortcuts (${keyboardShortcuts.length}${keyboardShortcuts.length > 10 ? ', first 10' : ''}):</strong><ul>${kbLines}</ul></div>` : ''}
103
+ ${(s.layoutShiftCount || s.zoomChangeCount || s.devToolsEventCount) ? `<div><strong>Other:</strong><ul>
104
+ ${s.layoutShiftCount ? `<li>${s.layoutShiftCount} layout shifts</li>` : ''}
105
+ ${s.zoomChangeCount ? `<li>${s.zoomChangeCount} zoom changes</li>` : ''}
106
+ ${s.devToolsEventCount ? `<li>${s.devToolsEventCount} devtools events</li>` : ''}
107
+ </ul></div>` : ''}
108
+ </div>
109
+ </div>`;
110
+ }
111
+
112
+ const authScore = s.authoritativeSoftScore != null
113
+ ? `<span>Soft score (session): <strong>${s.authoritativeSoftScore}</strong></span>`
114
+ : `<span>Soft score: ${s.totalSoftScore}</span>`;
115
+
116
+ return `
117
+ <section class="participant" id="p-${pid}">
118
+ <h3>${pidEsc}</h3>
119
+ <div class="stats">
120
+ <span>Score: <strong>${t.score}</strong></span>
121
+ <span>Hard: <strong>${t.hardTriggered ? 'YES' : 'no'}</strong></span>
122
+ <span>Trials: ${s.trialCount}</span>
123
+ <span>Paste: ${s.totalPasteEvents}</span>
124
+ <span>Tab-aways: ${formatTabAwayStat(s)}</span>
125
+ <span>Fast typing: ${s.trialsWithFastTyping}</span>
126
+ ${authScore}
127
+ <span>Edge exits: ${t.edgeExitCount}</span>
128
+ <span>Extensions: ${aiExtensions.length}</span>
129
+ <span>Sidebar events: ${s.sidebarEventCount ?? 0}</span>
130
+ <span>Kb shortcuts: ${s.keyboardShortcutCount ?? 0}</span>
131
+ </div>
132
+ <p class="reason">${esc(t.reason)}</p>
133
+ ${sessionBlock}
134
+ ${images}
135
+ </section>`;
136
+ }).join('\n');
137
+
138
+ const html = `<!DOCTYPE html>
139
+ <html lang="en">
140
+ <head>
141
+ <meta charset="utf-8">
142
+ <meta name="viewport" content="width=device-width, initial-scale=1">
143
+ <title>Cyborg Hunter Report</title>
144
+ <style>
145
+ * { box-sizing: border-box; margin: 0; padding: 0; }
146
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
147
+ max-width: 1200px; margin: 0 auto; padding: 20px; color: #333; background: #fafafa; }
148
+ h1 { font-size: 24px; margin-bottom: 8px; }
149
+ h2 { font-size: 18px; margin: 24px 0 12px; border-bottom: 1px solid #ddd; padding-bottom: 6px; }
150
+ h3 { font-size: 16px; margin-bottom: 8px; }
151
+ .dashboard { display: flex; gap: 16px; margin: 16px 0; }
152
+ .card { background: white; border: 1px solid #ddd; border-radius: 8px;
153
+ padding: 16px; flex: 1; text-align: center; }
154
+ .card .number { font-size: 32px; font-weight: bold; }
155
+ .card.hard .number { color: #d32f2f; }
156
+ .card.soft .number { color: #f57c00; }
157
+ .card.clean .number { color: #388e3c; }
158
+ table { width: 100%; border-collapse: collapse; margin: 12px 0; background: white; }
159
+ th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; }
160
+ th { background: #f5f5f5; cursor: pointer; user-select: none; }
161
+ th:hover { background: #e0e0e0; }
162
+ tr.hard { background: #ffebee; }
163
+ tr.soft { background: #fff3e0; }
164
+ tr.clean { background: #e8f5e9; }
165
+ tr:hover { background: #e3f2fd; cursor: pointer; }
166
+ .participant { background: white; border: 1px solid #ddd; border-radius: 8px;
167
+ padding: 16px; margin: 12px 0; }
168
+ .stats { display: flex; flex-wrap: wrap; gap: 12px; margin: 8px 0; }
169
+ .stats span { background: #f5f5f5; padding: 4px 8px; border-radius: 4px; font-size: 13px; }
170
+ .reason { color: #666; font-size: 13px; margin: 8px 0; }
171
+ .images img { max-width: 100%; height: auto; margin: 8px 0; border: 1px solid #eee; border-radius: 4px; }
172
+ .zoomable { display: block; }
173
+ .zoomable img { cursor: zoom-in; transition: opacity 0.1s; }
174
+ .zoomable:hover img { opacity: 0.9; }
175
+ .lightbox-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 1000;
176
+ display: none; align-items: center; justify-content: center; cursor: zoom-out; padding: 20px; }
177
+ .lightbox-overlay.open { display: flex; }
178
+ .lightbox-overlay img { max-width: 100%; max-height: 100%; box-shadow: 0 4px 24px rgba(0,0,0,0.5); border-radius: 4px; }
179
+ .lightbox-close { position: fixed; top: 16px; right: 24px; color: white; font-size: 32px; cursor: pointer;
180
+ background: rgba(0,0,0,0.4); border-radius: 50%; width: 40px; height: 40px;
181
+ display: flex; align-items: center; justify-content: center; line-height: 1; }
182
+ .note { color: #999; font-style: italic; font-size: 13px; }
183
+ .meta { color: #999; font-size: 12px; margin-top: 24px; }
184
+ .session-col { text-align: center; font-variant-numeric: tabular-nums; }
185
+ th.session-col { background: #eef3ff; }
186
+ .session-signals { background: #fff8e1; border: 1px solid #f0d080; border-radius: 6px;
187
+ padding: 12px; margin: 12px 0; font-size: 13px; }
188
+ .session-signals h4 { font-size: 14px; margin-bottom: 8px; color: #a06000; }
189
+ .session-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
190
+ .session-signals ul { margin-left: 16px; font-size: 12px; }
191
+ .session-signals code { background: #ffeaaa; padding: 1px 4px; border-radius: 3px; }
192
+ .muted { color: #888; font-size: 12px; }
193
+ .back-to-top { position: fixed; bottom: 16px; right: 16px; padding: 8px 12px; background: #333; color: #fff; border-radius: 4px; text-decoration: none; opacity: 0.85; font-size: 13px; z-index: 100; }
194
+ .back-to-top:hover { opacity: 1; }
195
+ </style>
196
+ </head>
197
+ <body>
198
+ <a id="top"></a>
199
+ <h1>Cyborg Hunter Report</h1>
200
+ <p class="note">v${VERSION} &mdash; ${triage.length} participants</p>
201
+
202
+ <div class="dashboard">
203
+ <div class="card hard">
204
+ <div class="number">${flaggedHard}</div>
205
+ <div>Hard signals</div>
206
+ </div>
207
+ <div class="card soft">
208
+ <div class="number">${flaggedSoft}</div>
209
+ <div>Soft flagged</div>
210
+ </div>
211
+ <div class="card clean">
212
+ <div class="number">${clean}</div>
213
+ <div>Clean</div>
214
+ </div>
215
+ </div>
216
+
217
+ <h2>Triage — Ranked by Suspiciousness</h2>
218
+ <table id="triage-table">
219
+ <thead>
220
+ <tr>
221
+ <th onclick="sortTable(0, 'num')">Rank</th>
222
+ <th onclick="sortTable(1, 'str')">Participant</th>
223
+ <th onclick="sortTable(2, 'num')" title="Composite triage score (soft + hard + session signals)">Score</th>
224
+ <th onclick="sortTable(3, 'num')" title="Soft score for the chosen scoring config (session-authoritative when present, else per-trial sum)">Soft</th>
225
+ <th onclick="sortTable(4, 'str')">Hard</th>
226
+ <th class="session-col" onclick="sortTable(5, 'num')" title="AI extensions found">Ext</th>
227
+ <th class="session-col" onclick="sortTable(6, 'num')" title="Sidebar events (DOM gaps)">Sidebar</th>
228
+ <th class="session-col" onclick="sortTable(7, 'num')" title="DevTools-ish keyboard shortcuts">Kb</th>
229
+ <th class="session-col" onclick="sortTable(8, 'num')" title="Layout shifts + zoom changes">Layout</th>
230
+ <th onclick="sortTable(9, 'str')">Reason</th>
231
+ </tr>
232
+ </thead>
233
+ <tbody>
234
+ ${triageRows}
235
+ </tbody>
236
+ </table>
237
+
238
+ <h2>Participant Details</h2>
239
+ ${participantSections}
240
+
241
+ <p class="meta">Generated by cyborg-hunter v${VERSION}</p>
242
+
243
+ <script>
244
+ // Vanilla JS table sorting — no dependencies
245
+ function sortTable(col, type) {
246
+ const table = document.getElementById('triage-table');
247
+ const tbody = table.querySelector('tbody');
248
+ const rows = Array.from(tbody.querySelectorAll('tr'));
249
+ const dir = table.dataset.sortCol == col && table.dataset.sortDir == 'asc' ? 'desc' : 'asc';
250
+ table.dataset.sortCol = col;
251
+ table.dataset.sortDir = dir;
252
+
253
+ rows.sort((a, b) => {
254
+ let av = a.cells[col].textContent.trim();
255
+ let bv = b.cells[col].textContent.trim();
256
+ if (type === 'num') { av = parseFloat(av) || 0; bv = parseFloat(bv) || 0; }
257
+ if (av < bv) return dir === 'asc' ? -1 : 1;
258
+ if (av > bv) return dir === 'asc' ? 1 : -1;
259
+ return 0;
260
+ });
261
+
262
+ rows.forEach(r => tbody.appendChild(r));
263
+ }
264
+
265
+ function scrollToParticipant(id) {
266
+ const el = document.getElementById('p-' + id.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40));
267
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
268
+ }
269
+ </script>
270
+ <a href="#top" class="back-to-top">↑ Top</a>
271
+
272
+ <div id="lightbox" class="lightbox-overlay" role="dialog" aria-label="Enlarged image">
273
+ <span class="lightbox-close" aria-label="Close">&times;</span>
274
+ <img id="lightbox-img" alt="">
275
+ </div>
276
+
277
+ <script>
278
+ // P4 lightbox — intercept clicks on .zoomable anchors; ESC or click-outside closes.
279
+ (function() {
280
+ const overlay = document.getElementById('lightbox');
281
+ const img = document.getElementById('lightbox-img');
282
+ document.querySelectorAll('a.zoomable').forEach(a => {
283
+ a.addEventListener('click', e => {
284
+ e.preventDefault();
285
+ img.src = a.getAttribute('href');
286
+ overlay.classList.add('open');
287
+ });
288
+ });
289
+ overlay.addEventListener('click', () => {
290
+ overlay.classList.remove('open');
291
+ img.src = '';
292
+ });
293
+ document.addEventListener('keydown', e => {
294
+ if (e.key === 'Escape' && overlay.classList.contains('open')) {
295
+ overlay.classList.remove('open');
296
+ img.src = '';
297
+ }
298
+ });
299
+ })();
300
+ </script>
301
+ </body>
302
+ </html>`;
303
+
304
+ writeFileSync(join(config.outputDir, 'index.html'), html);
305
+ console.log(' index.html — report page');
306
+ }
307
+
308
+ function esc(str) {
309
+ return String(str || '')
310
+ .replace(/&/g, '&amp;')
311
+ .replace(/</g, '&lt;')
312
+ .replace(/>/g, '&gt;')
313
+ .replace(/"/g, '&quot;');
314
+ }
315
+
316
+ function sanitize(name) {
317
+ return String(name || '').replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
318
+ }
319
+
320
+ // Renders the tab-away stats cell. When the three-way split is available,
321
+ // show ≥10s / 3-10s / <3s (matches the timeline chart legend).
322
+ function formatTabAwayStat(s) {
323
+ const total = s.totalTabAways ?? 0;
324
+ const long = s.tabAwayLongCount;
325
+ const medium = s.tabAwayMediumCount;
326
+ const flicker = s.tabAwayFlickerCount;
327
+ if (long == null || medium == null || flicker == null) return String(total);
328
+ return `${total} (${long} ≥10s, ${medium} 3–10s, ${flicker} &lt;3s)`;
329
+ }
@@ -0,0 +1,70 @@
1
+ // src/cli/renderers/summary-csv.js
2
+ // Writes summary.csv — one row per participant with columns for every
3
+ // signal aggregate and the triage score/reason.
4
+
5
+ import { writeFileSync } from 'fs';
6
+ import { join } from 'path';
7
+
8
+ // CSV columns in output order. Each entry: [header, accessor function].
9
+ const COLUMNS = [
10
+ ['participantId', (s, t) => s.participantId],
11
+ ['trialCount', (s, t) => s.trialCount],
12
+ ['triageScore', (s, t) => t.score],
13
+ ['hardTriggered', (s, t) => t.hardTriggered ? 'YES' : 'no'],
14
+ ['triageReason', (s, t) => t.reason],
15
+ ['totalPasteEvents', (s, t) => s.totalPasteEvents],
16
+ ['totalCopyEvents', (s, t) => s.totalCopyEvents],
17
+ ['totalDropEvents', (s, t) => s.totalDropEvents],
18
+ ['totalTabAways', (s, t) => s.totalTabAways],
19
+ ['tabAwayLongCount', (s, t) => s.tabAwayLongCount ?? 0], // ≥10s
20
+ ['tabAwayMediumCount', (s, t) => s.tabAwayMediumCount ?? 0], // 3–10s
21
+ ['tabAwayFlickerCount', (s, t) => s.tabAwayFlickerCount ?? 0], // <3s
22
+ ['totalTabAwayDuration_ms', (s, t) => s.totalTabAwayDuration_ms],
23
+ ['trialsWithTabAway', (s, t) => s.trialsWithTabAway],
24
+ ['meanTypingSpeed', (s, t) => s.meanTypingSpeed.toFixed(2)],
25
+ ['trialsWithFastTyping', (s, t) => s.trialsWithFastTyping],
26
+ ['meanMouseEvents', (s, t) => s.meanMouseEvents.toFixed(1)],
27
+ ['meanPathEfficiency', (s, t) => s.meanPathEfficiency.toFixed(3)],
28
+ ['totalIdleGaps', (s, t) => s.totalIdleGaps],
29
+ ['totalSyntheticInsertions', (s, t) => s.totalSyntheticInsertions],
30
+ ['totalForeignInputEvents', (s, t) => s.totalForeignInputEvents],
31
+ ['totalSoftScore', (s, t) => s.totalSoftScore],
32
+ ['edgeExitCount', (s, t) => t.edgeExitCount],
33
+ // Session-level signals (Task 4) — drop legacy sidebarDetected/extensionsDetected
34
+ // columns in favour of richer session-authoritative counts.
35
+ ['sidebar_event_count', (s, t) => s.sidebarEventCount ?? 0],
36
+ ['ai_extensions', (s, t) => {
37
+ const ext = s.aiExtensionsFound || [];
38
+ return ext.map(e => typeof e === 'string' ? e : (e.name || 'unknown')).join('; ');
39
+ }],
40
+ ['keyboard_shortcut_count', (s, t) => s.keyboardShortcutCount || 0],
41
+ ['layout_shift_count', (s, t) => s.layoutShiftCount || 0],
42
+ ['zoom_change_count', (s, t) => s.zoomChangeCount || 0],
43
+ ['dev_tools_event_count', (s, t) => s.devToolsEventCount || 0],
44
+ ['authoritative_soft_score', (s, t) => s.authoritativeSoftScore ?? ''],
45
+ ];
46
+
47
+ export async function renderSummaryCSV(summaries, triage, config) {
48
+ // Build a lookup from participantId → triage entry
49
+ const triageMap = new Map(triage.map(t => [t.participantId, t]));
50
+
51
+ const header = COLUMNS.map(c => c[0]).join(',');
52
+ const rows = summaries.map(s => {
53
+ const t = triageMap.get(s.participantId) || {};
54
+ return COLUMNS.map(c => escapeCSV(c[1](s, t))).join(',');
55
+ });
56
+
57
+ const csv = [header, ...rows].join('\n') + '\n';
58
+ const outPath = join(config.outputDir, 'summary.csv');
59
+ writeFileSync(outPath, csv);
60
+ console.log(` summary.csv — ${summaries.length} participants`);
61
+ }
62
+
63
+ // Escapes a CSV value: wraps in quotes if it contains commas, quotes, or newlines
64
+ function escapeCSV(val) {
65
+ const str = String(val ?? '');
66
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
67
+ return `"${str.replace(/"/g, '""')}"`;
68
+ }
69
+ return str;
70
+ }
@@ -0,0 +1,149 @@
1
+ // src/cli/renderers/tab-timeline.js
2
+ // Renders tab-away timeline images using node-canvas.
3
+ // Produces one PNG per participant showing when tab-aways occurred
4
+ // relative to the experiment timeline.
5
+ //
6
+ // Layout: horizontal bar chart where X-axis = experiment time,
7
+ // each row = a trial, bars = tab-away events colored by duration.
8
+ // Bins match the analyzer split: flicker (<3s), medium (3-10s), long (≥10s).
9
+ // The 3s boundary is the scoring engine's soft-score cutoff.
10
+
11
+ import { writeFileSync } from 'fs';
12
+ import { join } from 'path';
13
+
14
+ const ROW_HEIGHT = 25;
15
+ const LABEL_WIDTH = 120;
16
+ const CHART_WIDTH = 600;
17
+ const TOP_PAD = 50;
18
+ const BOTTOM_PAD = 30;
19
+ const LEFT_PAD = 10;
20
+
21
+ export async function renderTabTimelines(participants, config) {
22
+ const { createCanvas } = await import('canvas');
23
+
24
+ for (const p of participants) {
25
+ const trials = p.trials;
26
+ if (trials.length === 0) continue;
27
+
28
+ // Check if there are any tab-away events to render
29
+ const totalTabAways = trials.reduce((s, t) => s + (t.tabAwayEvents || []).length, 0);
30
+ if (totalTabAways === 0) continue;
31
+
32
+ const canvasH = TOP_PAD + trials.length * ROW_HEIGHT + BOTTOM_PAD;
33
+ const canvasW = LEFT_PAD + LABEL_WIDTH + CHART_WIDTH + 20;
34
+ const canvas = createCanvas(canvasW, canvasH);
35
+ const ctx = canvas.getContext('2d');
36
+
37
+ // Background
38
+ ctx.fillStyle = '#ffffff';
39
+ ctx.fillRect(0, 0, canvasW, canvasH);
40
+
41
+ // Title
42
+ ctx.fillStyle = '#333';
43
+ ctx.font = 'bold 14px sans-serif';
44
+ ctx.fillText(`Tab-Away Timeline — ${p.participantId}`, LEFT_PAD, 20);
45
+ ctx.font = '10px sans-serif';
46
+ ctx.fillStyle = '#666';
47
+ ctx.fillText(`${totalTabAways} tab-away events across ${trials.length} trials`, LEFT_PAD, 35);
48
+
49
+ // Legend (right-aligned under title)
50
+ const legend = [
51
+ { label: 'trial duration', color: '#f0f0f0', border: '#ccc' },
52
+ { label: '<3s flicker', color: '#bdbdbd', border: '#333' },
53
+ { label: '3–10s', color: '#ff9800', border: '#333' },
54
+ { label: '≥10s', color: '#f44336', border: '#333' },
55
+ ];
56
+ let lx = canvasW - 10;
57
+ ctx.font = '9px sans-serif';
58
+ // Draw legend right-to-left
59
+ for (let k = legend.length - 1; k >= 0; k--) {
60
+ const item = legend[k];
61
+ const textW = ctx.measureText(item.label).width;
62
+ lx -= textW;
63
+ ctx.fillStyle = '#333';
64
+ ctx.fillText(item.label, lx, 22);
65
+ lx -= 4;
66
+ ctx.fillStyle = item.color;
67
+ ctx.fillRect(lx - 10, 15, 10, 8);
68
+ ctx.strokeStyle = item.border;
69
+ ctx.lineWidth = 0.5;
70
+ ctx.strokeRect(lx - 10, 15, 10, 8);
71
+ lx -= 14;
72
+ }
73
+
74
+ // Find the maximum trial duration for X-axis scaling
75
+ const maxDuration = Math.max(...trials.map(t =>
76
+ (t.duration_ms || t.responseTime_ms || 30000)));
77
+ const xScale = CHART_WIDTH / maxDuration;
78
+
79
+ for (let i = 0; i < trials.length; i++) {
80
+ const trial = trials[i];
81
+ const y = TOP_PAD + i * ROW_HEIGHT;
82
+ const trialId = trial.trialId || trial.ruleId || `T${i + 1}`;
83
+ const tabAways = trial.tabAwayEvents || [];
84
+
85
+ // Row label
86
+ ctx.fillStyle = '#333';
87
+ ctx.font = '10px sans-serif';
88
+ ctx.fillText(trialId.substring(0, 16), LEFT_PAD, y + ROW_HEIGHT / 2 + 3);
89
+
90
+ // Trial duration background (light grey bar)
91
+ const trialDur = trial.duration_ms || trial.responseTime_ms || 30000;
92
+ const barX = LEFT_PAD + LABEL_WIDTH;
93
+ ctx.fillStyle = '#f0f0f0';
94
+ ctx.fillRect(barX, y + 3, trialDur * xScale, ROW_HEIGHT - 6);
95
+
96
+ // Tab-away bars
97
+ for (const ta of tabAways) {
98
+ // Use trial-relative timestamp (normalized in ingest). Fall back to
99
+ // raw `start` only if normalization was unavailable; this will look
100
+ // wrong (off-canvas) but at least won't crash.
101
+ const start = (ta.startRel_ms != null) ? ta.startRel_ms : (ta.start || 0);
102
+ const dur = ta.duration_ms || 0;
103
+ // Skip tab-aways that fall outside the trial window — they likely
104
+ // occurred between trials (inter-trial pause) and aren't meaningful here.
105
+ if (start < -1000 || start > trialDur + 1000) continue;
106
+ const clamped = Math.max(0, start);
107
+ const barStart = barX + clamped * xScale;
108
+ const barWidth = Math.max(2, dur * xScale);
109
+
110
+ // Color by duration: flicker (<3s) = grey, medium (3-10s) = orange, long (≥10s) = red.
111
+ // Matches summary analyzer bins and the scoring engine's 3s cutoff.
112
+ const durSec = dur / 1000;
113
+ ctx.fillStyle = durSec < 3 ? '#bdbdbd' : durSec < 10 ? '#ff9800' : '#f44336';
114
+ ctx.fillRect(barStart, y + 5, barWidth, ROW_HEIGHT - 10);
115
+ ctx.strokeStyle = '#333';
116
+ ctx.lineWidth = 0.5;
117
+ ctx.strokeRect(barStart, y + 5, barWidth, ROW_HEIGHT - 10);
118
+ }
119
+
120
+ // Row separator
121
+ ctx.strokeStyle = '#e0e0e0';
122
+ ctx.lineWidth = 0.5;
123
+ ctx.beginPath();
124
+ ctx.moveTo(LEFT_PAD, y + ROW_HEIGHT);
125
+ ctx.lineTo(canvasW - 10, y + ROW_HEIGHT);
126
+ ctx.stroke();
127
+ }
128
+
129
+ // X-axis labels
130
+ ctx.fillStyle = '#666';
131
+ ctx.font = '9px sans-serif';
132
+ const tickCount = 5;
133
+ for (let t = 0; t <= tickCount; t++) {
134
+ const time = (maxDuration / tickCount) * t;
135
+ const x = LEFT_PAD + LABEL_WIDTH + time * xScale;
136
+ ctx.fillText(`${(time / 1000).toFixed(0)}s`, x, canvasH - 10);
137
+ }
138
+
139
+ const buf = canvas.toBuffer('image/png');
140
+ const filename = `tab_timeline_${sanitize(p.participantId)}.png`;
141
+ writeFileSync(join(config.outputDir, 'images', filename), buf);
142
+ }
143
+
144
+ console.log(` tab timelines — rendered`);
145
+ }
146
+
147
+ function sanitize(name) {
148
+ return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
149
+ }