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