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