cyborg-hunter 0.7.0 → 0.7.2

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,717 @@
1
+ // src/cli/renderers/trajectories-core.js
2
+ //
3
+ // Pure drawing core for per-participant mouse-trajectory grid images — no
4
+ // Node APIs, so a browser demo can bundle it directly (0.7.2-style
5
+ // extraction from cli/renderers/trajectories.js, which is now a thin fs
6
+ // wrapper around this module: it acquires node-canvas, calls
7
+ // drawTrajectoryGrid per participant, and writes the returned canvas to a
8
+ // PNG). Mirrors the session-timeline-core.js split.
9
+ //
10
+ // drawTrajectoryGrid(p, triageEntry, config, createCanvas) draws the full
11
+ // grid of per-trial panels for one participant and returns the drawn canvas
12
+ // (or null if the participant has no trials to plot). createCanvas is
13
+ // injected so this module never imports the `canvas` package directly — a
14
+ // browser caller can pass a document-canvas-backed factory instead.
15
+ //
16
+ // Per-panel visual elements:
17
+ // - Time-colored path (blue → red gradient over the trial's duration)
18
+ // - Start marker (blue circle), end marker (red square)
19
+ // - Mousedown (green ▼), mouseup (magenta ▲)
20
+ // - Tab-away pairs (yellow ◆ at the leave point, cyan ◆ at return, size ∝
21
+ // duration), connected by a dashed line
22
+ // - Pause circles (grey ○) for >3s gaps that don't overlap a tab-away
23
+ // - Browser-window outline (purple, dashed); screen outline (teal, solid)
24
+ // when geometry data is present and internally consistent
25
+ // - Red panel border if any hard signal fired on this trial
26
+ // - Title with trial ID, RT, mouse count, tab-away count + total duration
27
+
28
+ import { ruleChronologicalCompare } from '../extract-core.js';
29
+
30
+ // Panel dimensions (pixels).
31
+ const PANEL_W = 400;
32
+ const PANEL_H = 300;
33
+ const PANEL_PAD = 40;
34
+ const TITLE_HEIGHT = 40;
35
+ const HEADER_HEIGHT = 96; // accommodates 3-line legend
36
+ const PANEL_MARGIN = 15; // inset between panel border and content area
37
+
38
+ // Color palette. Hoisted from inline string literals to give the rendering
39
+ // style one place to look. Names match the visual element they style.
40
+ const COLORS = {
41
+ bg: '#ffffff',
42
+ panelBg: '#fafafa',
43
+ panelBorder: '#ccc',
44
+ panelBorderHardHit: '#ff0000',
45
+ headerText: '#333',
46
+ legendText: '#666',
47
+ zoomTag: '#a06000',
48
+ noData: '#999',
49
+ screenRect: '#26a69a',
50
+ windowRect: '#7e57c2',
51
+ pathStart: '#0000ff',
52
+ pathEnd: '#ff0000',
53
+ pathEndStroke: '#000',
54
+ mousedown: '#00cc00',
55
+ mouseup: '#ff00ff',
56
+ tabAwayLeftFill: '#ffff00',
57
+ tabAwayLeftStroke: '#ff8c00',
58
+ tabAwayReturnFill: '#00ffff',
59
+ tabAwayReturnStroke: '#008b8b',
60
+ pauseCircle: '#ccc',
61
+ };
62
+
63
+ // Panel-background tints by trial phase (0.6.1, retro item 9) — same palette
64
+ // as the session-timeline phase strip (session-timeline.js C.phase*), so a
65
+ // panel's tint and the timeline band for the same phase read as one system.
66
+ // Trials without a recognized phase keep the neutral panelBg.
67
+ const PHASE_TINTS = {
68
+ gallery: '#ffe0b2', // peach — matches C.phaseGallery
69
+ post_gallery_query: '#e1bee7', // light purple — matches C.phaseTyping
70
+ typing: '#e1bee7',
71
+ classification: '#bbdefb', // light blue — matches C.phaseClass
72
+ end_requery: '#eceff1', // bluish grey — matches C.phasePre/Post
73
+ };
74
+
75
+ // Orders a participant's trials for the panel grid (0.6.1, retro item 3).
76
+ // config.trajectoryDisplayOrder:
77
+ // 'rule' (default) chronological by rule — (rulePosition, phase-rank,
78
+ // trialNumber), the order each rule was actually experienced.
79
+ // Same comparator the ingest uses when merging phaseTrials, so
80
+ // already-sorted data keeps its order (stable sort).
81
+ // 'time' wall-clock trial timestamps (fallback: trialNumber)
82
+ // 'insertion' raw ingest order (pre-0.6.1 behavior)
83
+ // Exported for tests.
84
+ export function orderTrials(trials, config) {
85
+ const mode = config.trajectoryDisplayOrder || 'rule';
86
+ if (mode === 'insertion') return trials;
87
+ const ordered = trials.slice();
88
+ if (mode === 'time') {
89
+ ordered.sort((a, b) => {
90
+ const at = a?.timestamp ? Date.parse(a.timestamp) : NaN;
91
+ const bt = b?.timestamp ? Date.parse(b.timestamp) : NaN;
92
+ if (Number.isFinite(at) && Number.isFinite(bt) && at !== bt) return at - bt;
93
+ return (a.trialNumber ?? 0) - (b.trialNumber ?? 0);
94
+ });
95
+ } else {
96
+ ordered.sort(ruleChronologicalCompare);
97
+ }
98
+ return ordered;
99
+ }
100
+
101
+ // Draws one participant's full trajectory grid (all per-trial panels) onto a
102
+ // canvas obtained from the injected createCanvas factory. Returns the drawn
103
+ // canvas, or null when the participant has no trials to plot — mirrors the
104
+ // `continue` in the pre-extraction renderTrajectories loop; the fs wrapper
105
+ // skips the PNG write in that case.
106
+ export function drawTrajectoryGrid(p, triageEntry, config, createCanvas) {
107
+ const trials = orderTrials(p.trials, config);
108
+ if (trials.length === 0) return null;
109
+
110
+ // Compute grid layout
111
+ const cols = Math.min(5, trials.length);
112
+ const rows = Math.ceil(trials.length / cols);
113
+
114
+ const canvasW = cols * (PANEL_W + PANEL_PAD) + PANEL_PAD;
115
+ const canvasH = HEADER_HEIGHT + rows * (PANEL_H + TITLE_HEIGHT + PANEL_PAD) + PANEL_PAD;
116
+ const canvas = createCanvas(canvasW, canvasH);
117
+ const ctx = canvas.getContext('2d');
118
+
119
+ // Background
120
+ ctx.fillStyle = COLORS.bg;
121
+ ctx.fillRect(0, 0, canvasW, canvasH);
122
+
123
+ // Header
124
+ const headerText = `${p.participantId} — Score: ${triageEntry?.score ?? '?'} — ${triageEntry?.reason ?? ''}`;
125
+ ctx.fillStyle = COLORS.headerText;
126
+ ctx.font = 'bold 16px sans-serif';
127
+ ctx.fillText(headerText, PANEL_PAD, 30);
128
+
129
+ // Two-line legend — splitting keeps the panel-frame meaning visible rather
130
+ // than buried at the end of a wrap.
131
+ ctx.font = '11px sans-serif';
132
+ ctx.fillStyle = COLORS.legendText;
133
+ ctx.fillText(
134
+ 'Blue●=start Red■=end Green▼=mousedown Magenta▲=mouseup Yellow◆=tab-away (left) Cyan◆=tab-away (returned) Grey○=pause',
135
+ PANEL_PAD, 48
136
+ );
137
+ ctx.fillText(
138
+ 'Teal outer rect=screen Purple dashed rect=browser window Red panel frame=hard signal triggered on this trial',
139
+ PANEL_PAD, 64
140
+ );
141
+ ctx.fillText(
142
+ 'Panel tint=phase: peach gallery, purple typing/query, blue classification, grey re-query (neutral = unphased) — matches the session-timeline strip',
143
+ PANEL_PAD, 80
144
+ );
145
+
146
+ // session.windowPositions is the 2-second-poll-plus-resize-event record
147
+ // from core/signals/browser.js. Per-trial geometry uses the sample whose
148
+ // timestamp best fits the trial's mouse extent (see
149
+ // pickWindowGeometryForTrial). Older recordings without sw/sh fields
150
+ // yield a window-only geometry; chooseScreenFrame degrades gracefully.
151
+ const windowPositions = p.session?.windowPositions || [];
152
+
153
+ for (let i = 0; i < trials.length; i++) {
154
+ const col = i % cols;
155
+ const row = Math.floor(i / cols);
156
+ const x0 = PANEL_PAD + col * (PANEL_W + PANEL_PAD);
157
+ const y0 = HEADER_HEIGHT + PANEL_PAD + row * (PANEL_H + TITLE_HEIGHT + PANEL_PAD);
158
+
159
+ // Geometry resolution priority: explicit per-trial > session-end > polled.
160
+ // Per-trial wins because it was captured AT trial render time and can't
161
+ // be stale; session-end can drift if the participant resized between
162
+ // last sample and end-of-experiment. Polled fills in for jsPsych
163
+ // extension data, which has no explicit per-trial geometry field.
164
+ const sessionMeta = p.metadata || {};
165
+ const sessionGeom = sessionMeta.geometry || {};
166
+ const trialGeom = trials[i].geometry || {};
167
+ const polledGeom = pickWindowGeometryForTrial(trials[i], windowPositions) || {};
168
+ const effectiveMeta = { ...sessionMeta, ...sessionGeom, ...polledGeom, ...trialGeom };
169
+ renderTrialPanel(ctx, trials[i], x0, y0, config, effectiveMeta);
170
+ }
171
+
172
+ return canvas;
173
+ }
174
+
175
+ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
176
+ const mouse = trial.mouseEvents || [];
177
+ const tabAways = trial.tabAwayEvents || [];
178
+ // String() guard: a numeric trialId/ruleId (from a hand-edited payload or a
179
+ // dynamicTyping CSV) would otherwise throw on .substring() below and, with no
180
+ // per-participant boundary, abort every remaining visual.
181
+ const trialId = String(trial.trialId || trial.ruleId || '?');
182
+ const rt = (trial.duration_ms || trial.responseTime_ms || 0) / 1000;
183
+ const moves = mouse.filter(e => e.type === 'move');
184
+ const downs = mouse.filter(e => e.type === 'down');
185
+ const ups = mouse.filter(e => e.type === 'up');
186
+
187
+ // Panel border — red if any hard-signal trialHits fired on this trial.
188
+ const anyHardHit = trial.trialSignals?.hard &&
189
+ Object.values(trial.trialSignals.hard).some(s => (s.trialHits || 0) > 0);
190
+ ctx.strokeStyle = anyHardHit ? COLORS.panelBorderHardHit : COLORS.panelBorder;
191
+ ctx.lineWidth = anyHardHit ? 3 : 1;
192
+ ctx.strokeRect(x0, y0 + TITLE_HEIGHT, PANEL_W, PANEL_H);
193
+
194
+ // Panel background — tinted by trial phase (see PHASE_TINTS), neutral for
195
+ // unphased trials.
196
+ ctx.fillStyle = PHASE_TINTS[trial.phase] || COLORS.panelBg;
197
+ ctx.fillRect(x0 + 1, y0 + TITLE_HEIGHT + 1, PANEL_W - 2, PANEL_H - 2);
198
+
199
+ // Title
200
+ const nTabs = tabAways.length;
201
+ const tabDur = tabAways.reduce((s, e) => s + (e.duration_ms || 0), 0) / 1000;
202
+ ctx.fillStyle = COLORS.headerText;
203
+ ctx.font = '11px sans-serif';
204
+ ctx.fillText(`${trialId.substring(0, 20)} — ${rt.toFixed(0)}s, ${moves.length} mv, ${nTabs} tabs (${tabDur.toFixed(0)}s)`,
205
+ x0, y0 + TITLE_HEIGHT - 8);
206
+
207
+ if (moves.length === 0) {
208
+ ctx.fillStyle = COLORS.noData;
209
+ ctx.font = '12px sans-serif';
210
+ ctx.fillText('No mouse data', x0 + PANEL_W / 2 - 40, y0 + TITLE_HEIGHT + PANEL_H / 2);
211
+ return;
212
+ }
213
+
214
+ // Choose a geometry frame and decide which rectangles can be drawn honestly.
215
+ // Four cases, in priority order:
216
+ // 1. SCREEN-API — metadata.screens (Window Management API) present.
217
+ // Multi-monitor layout known; render in true screen space
218
+ // and draw both rectangles.
219
+ // 2. LEGACY — screenWidth/screenHeight present. Draw both rects when
220
+ // the window geometry doesn't paradoxically exceed the
221
+ // screen (zoom-out, multi-monitor). When it does, drop
222
+ // the outer rect — drawing a "screen" smaller than the
223
+ // "window" it supposedly contains is dishonest.
224
+ // 3. WINDOW-ONLY — window geometry but no screen size. Fit canvas to the
225
+ // window outline and draw it alone.
226
+ // 4. AUTO-FIT — no geometry at all; auto-fit to mouse path.
227
+ const screenFrame = chooseScreenFrame(metadata);
228
+ let minX, maxX, minY, maxY, offsetX, offsetY;
229
+ if (screenFrame.mode === 'screen-api' || screenFrame.mode === 'legacy') {
230
+ const { screenW, screenH, winX, winY, winW, winH } = screenFrame;
231
+ minX = 0;
232
+ minY = 0;
233
+ maxX = Math.max(screenW, winX + winW);
234
+ maxY = Math.max(screenH, winY + winH);
235
+ offsetX = winX;
236
+ offsetY = winY;
237
+ } else if (screenFrame.mode === 'window-only') {
238
+ // Frame the canvas around the window itself. Mouse coords are
239
+ // viewport-relative (0..innerWidth), so adding offsetX=winX places the
240
+ // trajectory inside the window outline at roughly the right spot. The
241
+ // chrome offset isn't accounted for, but for a no-screen visualization
242
+ // this is honest enough — the window outline shows scale and position.
243
+ const { winX, winY, winW, winH } = screenFrame;
244
+ minX = winX;
245
+ minY = winY;
246
+ maxX = winX + winW;
247
+ maxY = winY + winH;
248
+ offsetX = winX;
249
+ offsetY = winY;
250
+ } else {
251
+ // Auto-fit fallback
252
+ const allX = moves.map(m => m.x);
253
+ const allY = moves.map(m => m.y);
254
+ minX = Math.min(...allX) - 20;
255
+ maxX = Math.max(...allX) + 20;
256
+ minY = Math.min(...allY) - 20;
257
+ maxY = Math.max(...allY) + 20;
258
+ offsetX = 0;
259
+ offsetY = 0;
260
+ }
261
+ const rangeX = maxX - minX || 1;
262
+ const rangeY = maxY - minY || 1;
263
+
264
+ // Zoom-robust mouse plotting. Mouse coords (clientX/Y) are in viewport CSS
265
+ // pixels; the window outline is in screen CSS pixels. At non-100% browser
266
+ // zoom these scales differ — clicks would plot outside the outline.
267
+ // Scaling mouse coords by outer/inner ratio aligns them with the outline.
268
+ // Critically, X and Y scales are computed separately because chrome
269
+ // geometry isn't symmetric (top tabs+address bar vs ~zero left/right).
270
+ // Skipped in auto-fit mode (no outline → no alignment needed).
271
+ const zoom = (screenFrame.mode === 'auto-fit') ? { x: 1, y: 1 } : computeZoomScale(metadata);
272
+
273
+ const mapX = (px) => x0 + PANEL_MARGIN + ((px * zoom.x + offsetX - minX) / rangeX) * (PANEL_W - 2 * PANEL_MARGIN);
274
+ const mapY = (py) => y0 + TITLE_HEIGHT + PANEL_MARGIN + ((py * zoom.y + offsetY - minY) / rangeY) * (PANEL_H - 2 * PANEL_MARGIN);
275
+
276
+ // Screen + browser-window rectangles. The frame chooser above decides which
277
+ // rectangles are safe to draw. `drawOuter` is false for legacy data with a
278
+ // window/screen paradox (zoom-out, multi-monitor) — we'd rather show nothing
279
+ // than a screen rect smaller than the window it supposedly contains. It's
280
+ // also false for window-only mode (no screen data at all).
281
+ if (screenFrame.mode === 'screen-api' || screenFrame.mode === 'legacy' || screenFrame.mode === 'window-only') {
282
+ const { screenW, screenH, winX, winY, winW, winH, drawOuter } = screenFrame;
283
+ const rectMapX = (px) => x0 + PANEL_MARGIN + ((px - minX) / rangeX) * (PANEL_W - 2 * PANEL_MARGIN);
284
+ const rectMapY = (py) => y0 + TITLE_HEIGHT + PANEL_MARGIN + ((py - minY) / rangeY) * (PANEL_H - 2 * PANEL_MARGIN);
285
+ ctx.save();
286
+ ctx.lineWidth = 1;
287
+ if (drawOuter) {
288
+ ctx.strokeStyle = COLORS.screenRect;
289
+ ctx.strokeRect(
290
+ rectMapX(0), rectMapY(0),
291
+ rectMapX(screenW) - rectMapX(0),
292
+ rectMapY(screenH) - rectMapY(0)
293
+ );
294
+ }
295
+ ctx.strokeStyle = COLORS.windowRect;
296
+ ctx.setLineDash([4, 3]);
297
+ ctx.strokeRect(
298
+ rectMapX(winX), rectMapY(winY),
299
+ rectMapX(winX + winW) - rectMapX(winX),
300
+ rectMapY(winY + winH) - rectMapY(winY)
301
+ );
302
+ ctx.setLineDash([]);
303
+ ctx.restore();
304
+ }
305
+
306
+ // Zoom indicator — appended to the title. Honest signal that the viewport
307
+ // is scaled, so the viewer doesn't try to compare px counts across panels
308
+ // with different effective scales. Detected from visualViewport.scale or
309
+ // from outerWidth/innerWidth deviation.
310
+ const zoomTag = computeZoomTag(metadata);
311
+ if (zoomTag) {
312
+ ctx.fillStyle = COLORS.zoomTag;
313
+ ctx.font = 'italic 10px sans-serif';
314
+ ctx.fillText(zoomTag, x0 + PANEL_W - 60, y0 + TITLE_HEIGHT - 8);
315
+ }
316
+
317
+ // Time normalization for color gradient
318
+ const ts = moves.map(m => m.t);
319
+ const tMin = Math.min(...ts);
320
+ const tMax = Math.max(...ts);
321
+ const tRange = tMax - tMin || 1;
322
+
323
+ // Draw path segments with time-based color (blue→red)
324
+ ctx.lineWidth = 1;
325
+ ctx.globalAlpha = 0.6;
326
+ for (let j = 1; j < moves.length; j++) {
327
+ const tNorm = (moves[j].t - tMin) / tRange;
328
+ ctx.strokeStyle = timeColor(tNorm);
329
+ ctx.beginPath();
330
+ ctx.moveTo(mapX(moves[j - 1].x), mapY(moves[j - 1].y));
331
+ ctx.lineTo(mapX(moves[j].x), mapY(moves[j].y));
332
+ ctx.stroke();
333
+
334
+ // Pause markers (>3s gap with no movement).
335
+ // Both moves[j].t and ta.startRel_ms are trial-relative ms (normalized in
336
+ // ingest). Find the tab-away (if any) whose [start, start+duration] window
337
+ // overlaps the gap. If we find one, draw the leave/return diamond pair;
338
+ // otherwise drop a grey pause circle.
339
+ const gap = moves[j].t - moves[j - 1].t;
340
+ if (gap > 3000) {
341
+ const taStart = (ta) => (ta.startRel_ms != null ? ta.startRel_ms : ta.start);
342
+ const overlappingTab = tabAways.find(ta =>
343
+ taStart(ta) < moves[j].t + 1000 &&
344
+ (taStart(ta) + (ta.duration_ms || 0)) > moves[j - 1].t - 1000
345
+ );
346
+ if (overlappingTab) {
347
+ // Diamond size scales with √duration (square root keeps very long
348
+ // tab-aways from drawing as huge blobs that obscure the trajectory).
349
+ const dur = overlappingTab.duration_ms || gap;
350
+ const size = Math.min(20, Math.max(4, 4 + Math.sqrt(dur / 1000) * 3));
351
+
352
+ drawDiamond(ctx, mapX(moves[j - 1].x), mapY(moves[j - 1].y), size,
353
+ COLORS.tabAwayLeftFill, COLORS.tabAwayLeftStroke);
354
+ drawDiamond(ctx, mapX(moves[j].x), mapY(moves[j].y), size,
355
+ COLORS.tabAwayReturnFill, COLORS.tabAwayReturnStroke);
356
+
357
+ ctx.setLineDash([4, 3]);
358
+ ctx.strokeStyle = COLORS.tabAwayLeftStroke;
359
+ ctx.beginPath();
360
+ ctx.moveTo(mapX(moves[j - 1].x), mapY(moves[j - 1].y));
361
+ ctx.lineTo(mapX(moves[j].x), mapY(moves[j].y));
362
+ ctx.stroke();
363
+ ctx.setLineDash([]);
364
+ } else {
365
+ ctx.fillStyle = COLORS.pauseCircle;
366
+ ctx.beginPath();
367
+ ctx.arc(mapX(moves[j - 1].x), mapY(moves[j - 1].y), 3, 0, Math.PI * 2);
368
+ ctx.fill();
369
+ }
370
+ }
371
+ }
372
+ ctx.globalAlpha = 1;
373
+
374
+ // Start marker (blue circle)
375
+ ctx.fillStyle = COLORS.pathStart;
376
+ ctx.strokeStyle = COLORS.pathEndStroke;
377
+ ctx.lineWidth = 0.5;
378
+ ctx.beginPath();
379
+ ctx.arc(mapX(moves[0].x), mapY(moves[0].y), 5, 0, Math.PI * 2);
380
+ ctx.fill();
381
+ ctx.stroke();
382
+
383
+ // End marker (red square)
384
+ const ex = mapX(moves[moves.length - 1].x);
385
+ const ey = mapY(moves[moves.length - 1].y);
386
+ ctx.fillStyle = COLORS.pathEnd;
387
+ ctx.fillRect(ex - 4, ey - 4, 8, 8);
388
+ ctx.strokeRect(ex - 4, ey - 4, 8, 8);
389
+
390
+ // Mousedown markers (green ▼)
391
+ ctx.fillStyle = COLORS.mousedown;
392
+ for (const d of downs) {
393
+ drawTriangle(ctx, mapX(d.x), mapY(d.y), 5, 'down');
394
+ }
395
+
396
+ // Mouseup markers (magenta ▲)
397
+ ctx.fillStyle = COLORS.mouseup;
398
+ for (const u of ups) {
399
+ drawTriangle(ctx, mapX(u.x), mapY(u.y), 5, 'up');
400
+ }
401
+ }
402
+
403
+ // Interpolates blue→red based on normalized time (0→1)
404
+ function timeColor(t) {
405
+ const r = Math.round(t * 255);
406
+ const b = Math.round((1 - t) * 255);
407
+ return `rgb(${r}, 50, ${b})`;
408
+ }
409
+
410
+ function drawDiamond(ctx, x, y, size, fill, stroke) {
411
+ ctx.fillStyle = fill;
412
+ ctx.strokeStyle = stroke;
413
+ ctx.lineWidth = 1.5;
414
+ ctx.beginPath();
415
+ ctx.moveTo(x, y - size);
416
+ ctx.lineTo(x + size, y);
417
+ ctx.lineTo(x, y + size);
418
+ ctx.lineTo(x - size, y);
419
+ ctx.closePath();
420
+ ctx.fill();
421
+ ctx.stroke();
422
+ }
423
+
424
+ function drawTriangle(ctx, x, y, size, direction) {
425
+ ctx.beginPath();
426
+ if (direction === 'down') {
427
+ ctx.moveTo(x - size, y - size);
428
+ ctx.lineTo(x + size, y - size);
429
+ ctx.lineTo(x, y + size);
430
+ } else {
431
+ ctx.moveTo(x - size, y + size);
432
+ ctx.lineTo(x + size, y + size);
433
+ ctx.lineTo(x, y - size);
434
+ }
435
+ ctx.closePath();
436
+ ctx.fill();
437
+ }
438
+
439
+ // Used by the fs wrapper to build the per-participant PNG filename. Exported
440
+ // (rather than kept private) to match session-timeline-core.js's sanitize,
441
+ // which the browser demo plot adapter is also expected to reuse.
442
+ export function sanitize(name) {
443
+ return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
444
+ }
445
+
446
+ // Picks the windowPositions sample closest in time to the trial's start
447
+ // anchor and translates it into the field shape chooseScreenFrame() expects.
448
+ //
449
+ // Returns null when:
450
+ // - windowPositions is empty (e.g. signal disabled, or pre-tracking data)
451
+ // - the trial has no trialStart_perfNow (no time anchor → can't pick)
452
+ //
453
+ // Translation: monitor stores compact field names (x/y/w/h/iw/ih/sw/sh) for
454
+ // terse JSON; the renderer's chooseScreenFrame reads expanded names. Doing
455
+ // the rename here keeps both sides clean.
456
+ //
457
+ // The screenWidth/screenHeight fields are conditionally included — the
458
+ // April 2026 monitor change added sw/sh capture, but older recordings
459
+ // won't have it. Omitting (rather than zero-filling) lets chooseScreenFrame
460
+ // fall through its compatibility/auto-fit branches honestly.
461
+ export function pickWindowGeometryForTrial(trial, windowPositions) {
462
+ if (!Array.isArray(windowPositions) || windowPositions.length === 0) return null;
463
+ // Time anchor for matching the right windowPositions sample.
464
+ //
465
+ // jsPsych extension data carries `trialStart_perfNow` (set by the wrapper's
466
+ // on_load). Raw-DOM extension users save
467
+ // `startTime` directly from `performance.now()` at trial start. Both are in
468
+ // the same reference frame as `windowPositions[].t` (performance.now()), so
469
+ // either works.
470
+ //
471
+ // Without this fallback, raw-DOM users see auto-fit or stale session-level
472
+ // window dimensions — symptom: the dashed "window" box in the trajectory
473
+ // PNG is sized for the pre-fullscreen viewport even when the trial happened
474
+ // in fullscreen, so mouse dots plot outside the box.
475
+ const anchor = (typeof trial?.trialStart_perfNow === 'number')
476
+ ? trial.trialStart_perfNow
477
+ : (typeof trial?.startTime === 'number' ? trial.startTime : null);
478
+ if (typeof anchor !== 'number') return null;
479
+
480
+ // Selection strategy (in priority order):
481
+ // 1. Among samples within the trial's time range, prefer ones whose
482
+ // viewport (after zoom-scaling) contains the trial's max mouse extent.
483
+ // This handles trials that span a window resize — the trial's mouse
484
+ // events may live in different viewport states, so we pick the one
485
+ // that best contains them visually. Closest-in-time among fitting
486
+ // candidates.
487
+ // 2. If no in-range sample fits, pick the in-range sample with the
488
+ // largest viewport (best chance of mouse showing inside outline).
489
+ // 3. If nothing in range at all, fall back to the global closest-in-time.
490
+ //
491
+ // Without this, a trial that spanned a resize would get an arbitrary
492
+ // snapshot and mouse plotted outside the outline — visually misleading.
493
+
494
+ const duration = trial.duration_ms || 0;
495
+ const trialEnd = anchor + duration;
496
+ const moves = (trial.mouseEvents || []).filter(e => e.type === 'move');
497
+ const maxX = moves.length ? Math.max(...moves.map(m => m.x)) : 0;
498
+ const maxY = moves.length ? Math.max(...moves.map(m => m.y)) : 0;
499
+
500
+ function fits(s) {
501
+ if (!s.w || !s.iw || !s.h || !s.ih) return false;
502
+ return (maxX * s.w / s.iw) <= s.w && (maxY * s.h / s.ih) <= s.h;
503
+ }
504
+ function viewportArea(s) { return (s.iw || 0) * (s.ih || 0); }
505
+ function timeDelta(s) { return Math.abs(s.t - anchor); }
506
+
507
+ // Allow a 1-second slop on each end of the trial to account for resize
508
+ // event debouncing (samples may land slightly outside the trial window).
509
+ const inRange = duration > 0
510
+ ? windowPositions.filter(s => s.t >= anchor - 1000 && s.t <= trialEnd + 1000)
511
+ : windowPositions;
512
+
513
+ let best;
514
+ const fitting = inRange.filter(fits);
515
+ if (fitting.length > 0) {
516
+ best = fitting.reduce((a, b) => timeDelta(a) <= timeDelta(b) ? a : b);
517
+ } else if (inRange.length > 0) {
518
+ best = inRange.reduce((a, b) => viewportArea(a) >= viewportArea(b) ? a : b);
519
+ } else {
520
+ best = windowPositions.reduce((a, b) => timeDelta(a) <= timeDelta(b) ? a : b);
521
+ }
522
+
523
+ const out = {
524
+ windowX: best.x,
525
+ windowY: best.y,
526
+ windowWidth: best.w,
527
+ windowHeight: best.h,
528
+ // Both outer and inner dimensions on BOTH axes are needed by
529
+ // computeZoomScale — chrome asymmetry means the X and Y zoom factors
530
+ // genuinely differ. Earlier versions of this function dropped the
531
+ // height fields, which silently zeroed out the Y scale and plotted
532
+ // mouse Y way outside the outline at non-100% zoom.
533
+ outerWidth: best.w,
534
+ outerHeight: best.h,
535
+ innerWidth: best.iw,
536
+ innerHeight: best.ih,
537
+ };
538
+ if (typeof best.sw === 'number' && typeof best.sh === 'number') {
539
+ out.screenWidth = best.sw;
540
+ out.screenHeight = best.sh;
541
+ }
542
+ // Zoom diagnostics added April 2026. Optional — older recordings won't
543
+ // have these fields. devicePixelRatio captures HIDPI/retina scaling;
544
+ // visualViewportScale captures pinch zoom (touch devices, distinct
545
+ // from CSS-level browser zoom which we infer from outerWidth/innerWidth).
546
+ if (typeof best.dpr === 'number') out.devicePixelRatio = best.dpr;
547
+ if (typeof best.vvScale === 'number') out.visualViewportScale = best.vvScale;
548
+ return out;
549
+ }
550
+
551
+ // CSS-level browser zoom (Cmd-Plus / Cmd-Minus on desktop) has no direct JS
552
+ // API. We infer it from the ratio of outer (window frame, fixed in screen
553
+ // pixels) to inner (viewport, in CSS pixels that scale with zoom):
554
+ // zoom_factor ≈ outerWidth / innerWidth
555
+ // - ≈ 1 at 100% zoom
556
+ // - < 1 zoomed out (viewport reports MORE CSS pixels than outer frame)
557
+ // - > 1 zoomed in (viewport reports FEWER CSS pixels than outer frame)
558
+ //
559
+ // Critically, X and Y zoom factors can differ because chrome geometry isn't
560
+ // symmetric: most browsers have ~0px left/right chrome but ~80px top chrome
561
+ // (tabs + address bar). On macOS retina especially, this asymmetry creates
562
+ // significantly different x and y ratios, so a single uniform scale plots
563
+ // mouse outside the outline on one axis. Returning {x, y} fixes this.
564
+ //
565
+ // Both fields default to 1 (identity, no scaling) when either dimension is
566
+ // missing or non-positive — keeps callers honest without per-call null checks.
567
+ export function computeZoomScale(metadata) {
568
+ if (!metadata) return { x: 1, y: 1 };
569
+ const ow = metadata.outerWidth, iw = metadata.innerWidth;
570
+ const oh = metadata.outerHeight, ih = metadata.innerHeight;
571
+ return {
572
+ x: (typeof ow === 'number' && typeof iw === 'number' && ow > 0 && iw > 0) ? ow / iw : 1,
573
+ y: (typeof oh === 'number' && typeof ih === 'number' && oh > 0 && ih > 0) ? oh / ih : 1,
574
+ };
575
+ }
576
+
577
+ // Picks the geometry frame and decides which rectangles are safe to draw.
578
+ // Returns one of FOUR shapes:
579
+ // { mode: 'screen-api', screenW, screenH, winX, winY, winW, winH, drawOuter: true }
580
+ // { mode: 'legacy', screenW, screenH, winX, winY, winW, winH, drawOuter }
581
+ // { mode: 'window-only', winX, winY, winW, winH, drawOuter: false }
582
+ // { mode: 'auto-fit' }
583
+ //
584
+ // The 'screen-api' branch trusts metadata.screens (Window Management API) and
585
+ // uses the screen the window currently sits on (currentScreenLabel-matched)
586
+ // rather than the primary monitor — eliminates multi-monitor paradoxes.
587
+ //
588
+ // The 'legacy' branch covers data with screenWidth/screenHeight: draw both
589
+ // rects when the geometry is internally consistent, drop the outer rect when
590
+ // the window paradoxically exceeds the (primary-only) screen.
591
+ //
592
+ // The 'window-only' branch is for jsPsych extension data captured before
593
+ // the April 2026 monitor change added window.screen.width/height to its
594
+ // polled samples.
595
+ //
596
+ // The 'auto-fit' branch is the catch-all: no usable geometry, fit canvas to
597
+ // the mouse path.
598
+ export function chooseScreenFrame(metadata) {
599
+ if (!metadata) return { mode: 'auto-fit' };
600
+
601
+ // Branch 1: Window Management API data present.
602
+ if (Array.isArray(metadata.screens) && metadata.screens.length > 0) {
603
+ // Pick the screen the window lives on. If currentScreenLabel matches a
604
+ // labeled entry, use it; otherwise prefer the screen whose bounds contain
605
+ // the window origin; otherwise fall back to the primary.
606
+ const current = pickCurrentScreen(metadata);
607
+ const winX = metadata.windowX ?? 0;
608
+ const winY = metadata.windowY ?? 0;
609
+ const winW = metadata.windowWidth || current.width;
610
+ const winH = metadata.windowHeight || current.height;
611
+ return {
612
+ mode: 'screen-api',
613
+ screenW: current.width,
614
+ screenH: current.height,
615
+ winX, winY, winW, winH,
616
+ drawOuter: true
617
+ };
618
+ }
619
+
620
+ // Branch 2: legacy data — primaryScreenWidth or screenWidth.
621
+ const screenW = metadata.primaryScreenWidth ?? metadata.screenWidth;
622
+ const screenH = metadata.primaryScreenHeight ?? metadata.screenHeight;
623
+ if (screenW > 0 && screenH > 0) {
624
+ const winX = metadata.windowX ?? 0;
625
+ const winY = metadata.windowY ?? 0;
626
+ const winW = metadata.windowWidth || screenW;
627
+ const winH = metadata.windowHeight || screenH;
628
+ // Paradox check: window must fit inside screen for the outer rect to be
629
+ // honest. A 1px tolerance accommodates rounding. If the window exceeds
630
+ // the screen on any side, drop the outer rect — the participant likely
631
+ // zoomed out, used a secondary monitor, or has DPR scaling weirdness.
632
+ const tol = 1;
633
+ const fits =
634
+ winX >= -tol && winY >= -tol &&
635
+ (winX + winW) <= (screenW + tol) &&
636
+ (winY + winH) <= (screenH + tol);
637
+ return {
638
+ mode: 'legacy',
639
+ screenW, screenH, winX, winY, winW, winH,
640
+ drawOuter: fits
641
+ };
642
+ }
643
+
644
+ // Branch 3: window-only — we have window geometry but no screen size.
645
+ // Recordings made before the April 2026 monitor change (which added
646
+ // window.screen.width/height capture) hit this path. Draw just the
647
+ // browser-window rectangle; the screen rectangle is omitted because we
648
+ // genuinely don't know the screen dimensions.
649
+ if (metadata.windowWidth > 0 && metadata.windowHeight > 0) {
650
+ return {
651
+ mode: 'window-only',
652
+ winX: metadata.windowX ?? 0,
653
+ winY: metadata.windowY ?? 0,
654
+ winW: metadata.windowWidth,
655
+ winH: metadata.windowHeight,
656
+ drawOuter: false,
657
+ };
658
+ }
659
+
660
+ return { mode: 'auto-fit' };
661
+ }
662
+
663
+ function pickCurrentScreen(metadata) {
664
+ const screens = metadata.screens;
665
+ if (metadata.currentScreenLabel) {
666
+ const match = screens.find(s => s.label === metadata.currentScreenLabel);
667
+ if (match) return match;
668
+ }
669
+ // Prefer the screen whose availLeft/availTop bound the window origin.
670
+ if (typeof metadata.windowX === 'number' && typeof metadata.windowY === 'number') {
671
+ const containing = screens.find(s =>
672
+ metadata.windowX >= (s.availLeft ?? 0) &&
673
+ metadata.windowX < (s.availLeft ?? 0) + s.width &&
674
+ metadata.windowY >= (s.availTop ?? 0) &&
675
+ metadata.windowY < (s.availTop ?? 0) + s.height
676
+ );
677
+ if (containing) return containing;
678
+ }
679
+ return screens.find(s => s.isPrimary) || screens[0];
680
+ }
681
+
682
+ // Compute a "zoom × N" tag for the panel title when the participant has
683
+ // adjusted browser zoom away from 100%. Returns a string like "zoom × 0.75"
684
+ // or null when zoom is within tolerance.
685
+ //
686
+ // Two heuristics, in order of directness:
687
+ // 1. visualViewport.scale — direct API value, exact for pinch zoom on touch
688
+ // devices. Doesn't catch CSS-level browser zoom (Cmd-Plus / Cmd-Minus on
689
+ // desktop), which has no JS API.
690
+ // 2. outerWidth / innerWidth ratio — proxy for CSS-level zoom. outer stays
691
+ // fixed in screen pixels; inner scales with zoom. Ratio ≈ 1 at 100%,
692
+ // < 1 zoomed out, > 1 zoomed in.
693
+ //
694
+ // Threshold of 10% (0.9 < ratio < 1.1) absorbs the typical chrome offset and
695
+ // scrollbar width; only flags clear deviation from 100%.
696
+ export function computeZoomTag(metadata) {
697
+ if (!metadata) return null;
698
+ const tol = 0.05;
699
+ const vvScale = metadata.visualViewportScale;
700
+ if (typeof vvScale === 'number' && Math.abs(vvScale - 1) > tol) {
701
+ return `zoom × ${vvScale.toFixed(2)}`;
702
+ }
703
+ // Read innerWidth directly. Earlier versions fell back to windowWidth here
704
+ // — but in the polled-geometry pipeline, windowWidth is an alias for
705
+ // outerWidth, so the fallback gave outerWidth/outerWidth = 1 and the tag
706
+ // never fired. Use innerWidth strictly.
707
+ const ow = metadata.outerWidth;
708
+ const iw = metadata.innerWidth;
709
+ if (typeof ow === 'number' && typeof iw === 'number' && iw > 0) {
710
+ const ratio = ow / iw;
711
+ if (Math.abs(ratio - 1) > 0.1) {
712
+ const zoomEstimate = 1 / ratio;
713
+ return `zoom × ${zoomEstimate.toFixed(2)}`;
714
+ }
715
+ }
716
+ return null;
717
+ }