cyborg-hunter 0.3.0 → 0.4.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 +17 -4
- package/dist/cyborg-hunter.esm.js +28 -35
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/jspsych-cyborg-hunter.js +1 -1
- package/package.json +1 -1
- package/src/cli/analyzers/triage.js +36 -24
- package/src/cli/config.js +2 -2
- package/src/cli/ingest.js +57 -56
- package/src/cli/renderers/html-index.js +988 -263
- package/src/cli/renderers/trajectories.js +146 -104
- package/src/cli/report.js +15 -1
- package/src/core/monitor.js +47 -30
- package/src/core/signals/dom-protection.js +1 -1
- package/src/core/state-machine.js +1 -1
- package/src/jspsych/extension.js +67 -81
|
@@ -1,27 +1,54 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// - Time-colored path
|
|
7
|
-
// - Start marker
|
|
8
|
-
// - Mousedown
|
|
9
|
-
// - Tab-away
|
|
10
|
-
//
|
|
11
|
-
// -
|
|
12
|
-
// -
|
|
13
|
-
//
|
|
14
|
-
// -
|
|
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
|
|
15
16
|
|
|
16
17
|
import { writeFileSync } from 'fs';
|
|
17
18
|
import { join } from 'path';
|
|
18
19
|
|
|
19
|
-
// Panel dimensions (pixels)
|
|
20
|
+
// Panel dimensions (pixels).
|
|
20
21
|
const PANEL_W = 400;
|
|
21
22
|
const PANEL_H = 300;
|
|
22
23
|
const PANEL_PAD = 40;
|
|
23
24
|
const TITLE_HEIGHT = 40;
|
|
24
|
-
const HEADER_HEIGHT = 80;
|
|
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
|
+
};
|
|
25
52
|
|
|
26
53
|
export async function renderTrajectories(participants, triage, config) {
|
|
27
54
|
const { createCanvas } = await import('canvas');
|
|
@@ -41,20 +68,20 @@ export async function renderTrajectories(participants, triage, config) {
|
|
|
41
68
|
const ctx = canvas.getContext('2d');
|
|
42
69
|
|
|
43
70
|
// Background
|
|
44
|
-
ctx.fillStyle =
|
|
71
|
+
ctx.fillStyle = COLORS.bg;
|
|
45
72
|
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
46
73
|
|
|
47
74
|
// Header
|
|
48
75
|
const t = triageMap.get(p.participantId);
|
|
49
76
|
const headerText = `${p.participantId} — Score: ${t?.score ?? '?'} — ${t?.reason ?? ''}`;
|
|
50
|
-
ctx.fillStyle =
|
|
77
|
+
ctx.fillStyle = COLORS.headerText;
|
|
51
78
|
ctx.font = 'bold 16px sans-serif';
|
|
52
79
|
ctx.fillText(headerText, PANEL_PAD, 30);
|
|
53
80
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
81
|
+
// Two-line legend — splitting keeps the panel-frame meaning visible rather
|
|
82
|
+
// than buried at the end of a wrap.
|
|
56
83
|
ctx.font = '11px sans-serif';
|
|
57
|
-
ctx.fillStyle =
|
|
84
|
+
ctx.fillStyle = COLORS.legendText;
|
|
58
85
|
ctx.fillText(
|
|
59
86
|
'Blue●=start Red■=end Green▼=mousedown Magenta▲=mouseup Yellow◆=tab-away (left) Cyan◆=tab-away (returned) Grey○=pause',
|
|
60
87
|
PANEL_PAD, 48
|
|
@@ -64,30 +91,27 @@ export async function renderTrajectories(participants, triage, config) {
|
|
|
64
91
|
PANEL_PAD, 64
|
|
65
92
|
);
|
|
66
93
|
|
|
67
|
-
// session.windowPositions is the 2-second-poll record
|
|
68
|
-
// core/signals/browser.js. Per-trial geometry
|
|
69
|
-
// timestamp
|
|
70
|
-
// Older
|
|
71
|
-
// window-only geometry; chooseScreenFrame degrades gracefully.
|
|
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.
|
|
72
99
|
const windowPositions = p.session?.windowPositions || [];
|
|
73
100
|
|
|
74
|
-
// Render each trial panel
|
|
75
101
|
for (let i = 0; i < trials.length; i++) {
|
|
76
102
|
const col = i % cols;
|
|
77
103
|
const row = Math.floor(i / cols);
|
|
78
104
|
const x0 = PANEL_PAD + col * (PANEL_W + PANEL_PAD);
|
|
79
105
|
const y0 = HEADER_HEIGHT + PANEL_PAD + row * (PANEL_H + TITLE_HEIGHT + PANEL_PAD);
|
|
80
106
|
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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.
|
|
84
112
|
const sessionMeta = p.metadata || {};
|
|
85
113
|
const sessionGeom = sessionMeta.geometry || {};
|
|
86
114
|
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
115
|
const polledGeom = pickWindowGeometryForTrial(trials[i], windowPositions) || {};
|
|
92
116
|
const effectiveMeta = { ...sessionMeta, ...sessionGeom, ...polledGeom, ...trialGeom };
|
|
93
117
|
renderTrialPanel(ctx, trials[i], x0, y0, config, effectiveMeta);
|
|
@@ -110,48 +134,46 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
|
|
|
110
134
|
const downs = mouse.filter(e => e.type === 'down');
|
|
111
135
|
const ups = mouse.filter(e => e.type === 'up');
|
|
112
136
|
|
|
113
|
-
// Panel border — red if any hard-signal trialHits fired
|
|
114
|
-
// Mirrors the `.triggered` → `trialHits > 0` migration in summary.js.
|
|
137
|
+
// Panel border — red if any hard-signal trialHits fired on this trial.
|
|
115
138
|
const anyHardHit = trial.trialSignals?.hard &&
|
|
116
139
|
Object.values(trial.trialSignals.hard).some(s => (s.trialHits || 0) > 0);
|
|
117
|
-
ctx.strokeStyle = anyHardHit ?
|
|
140
|
+
ctx.strokeStyle = anyHardHit ? COLORS.panelBorderHardHit : COLORS.panelBorder;
|
|
118
141
|
ctx.lineWidth = anyHardHit ? 3 : 1;
|
|
119
142
|
ctx.strokeRect(x0, y0 + TITLE_HEIGHT, PANEL_W, PANEL_H);
|
|
120
143
|
|
|
121
144
|
// Panel background
|
|
122
|
-
ctx.fillStyle =
|
|
145
|
+
ctx.fillStyle = COLORS.panelBg;
|
|
123
146
|
ctx.fillRect(x0 + 1, y0 + TITLE_HEIGHT + 1, PANEL_W - 2, PANEL_H - 2);
|
|
124
147
|
|
|
125
148
|
// Title
|
|
126
149
|
const nTabs = tabAways.length;
|
|
127
150
|
const tabDur = tabAways.reduce((s, e) => s + (e.duration_ms || 0), 0) / 1000;
|
|
128
|
-
ctx.fillStyle =
|
|
151
|
+
ctx.fillStyle = COLORS.headerText;
|
|
129
152
|
ctx.font = '11px sans-serif';
|
|
130
153
|
ctx.fillText(`${trialId.substring(0, 20)} — ${rt.toFixed(0)}s, ${moves.length} mv, ${nTabs} tabs (${tabDur.toFixed(0)}s)`,
|
|
131
154
|
x0, y0 + TITLE_HEIGHT - 8);
|
|
132
155
|
|
|
133
156
|
if (moves.length === 0) {
|
|
134
|
-
ctx.fillStyle =
|
|
157
|
+
ctx.fillStyle = COLORS.noData;
|
|
135
158
|
ctx.font = '12px sans-serif';
|
|
136
159
|
ctx.fillText('No mouse data', x0 + PANEL_W / 2 - 40, y0 + TITLE_HEIGHT + PANEL_H / 2);
|
|
137
160
|
return;
|
|
138
161
|
}
|
|
139
162
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// 1. SCREEN-API
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// 2. LEGACY
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
// 3. WINDOW-ONLY
|
|
151
|
-
//
|
|
152
|
-
// 4. AUTO-FIT
|
|
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.
|
|
153
176
|
const screenFrame = chooseScreenFrame(metadata);
|
|
154
|
-
const margin = 15;
|
|
155
177
|
let minX, maxX, minY, maxY, offsetX, offsetY;
|
|
156
178
|
if (screenFrame.mode === 'screen-api' || screenFrame.mode === 'legacy') {
|
|
157
179
|
const { screenW, screenH, winX, winY, winW, winH } = screenFrame;
|
|
@@ -197,8 +219,8 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
|
|
|
197
219
|
// Skipped in auto-fit mode (no outline → no alignment needed).
|
|
198
220
|
const zoom = (screenFrame.mode === 'auto-fit') ? { x: 1, y: 1 } : computeZoomScale(metadata);
|
|
199
221
|
|
|
200
|
-
const mapX = (px) => x0 +
|
|
201
|
-
const mapY = (py) => y0 + TITLE_HEIGHT +
|
|
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);
|
|
202
224
|
|
|
203
225
|
// Screen + browser-window rectangles. The frame chooser above decides which
|
|
204
226
|
// rectangles are safe to draw. `drawOuter` is false for legacy data with a
|
|
@@ -207,19 +229,19 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
|
|
|
207
229
|
// also false for window-only mode (no screen data at all).
|
|
208
230
|
if (screenFrame.mode === 'screen-api' || screenFrame.mode === 'legacy' || screenFrame.mode === 'window-only') {
|
|
209
231
|
const { screenW, screenH, winX, winY, winW, winH, drawOuter } = screenFrame;
|
|
210
|
-
const rectMapX = (px) => x0 +
|
|
211
|
-
const rectMapY = (py) => y0 + TITLE_HEIGHT +
|
|
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);
|
|
212
234
|
ctx.save();
|
|
213
235
|
ctx.lineWidth = 1;
|
|
214
236
|
if (drawOuter) {
|
|
215
|
-
ctx.strokeStyle =
|
|
237
|
+
ctx.strokeStyle = COLORS.screenRect;
|
|
216
238
|
ctx.strokeRect(
|
|
217
239
|
rectMapX(0), rectMapY(0),
|
|
218
240
|
rectMapX(screenW) - rectMapX(0),
|
|
219
241
|
rectMapY(screenH) - rectMapY(0)
|
|
220
242
|
);
|
|
221
243
|
}
|
|
222
|
-
ctx.strokeStyle =
|
|
244
|
+
ctx.strokeStyle = COLORS.windowRect;
|
|
223
245
|
ctx.setLineDash([4, 3]);
|
|
224
246
|
ctx.strokeRect(
|
|
225
247
|
rectMapX(winX), rectMapY(winY),
|
|
@@ -236,7 +258,7 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
|
|
|
236
258
|
// from outerWidth/innerWidth deviation.
|
|
237
259
|
const zoomTag = computeZoomTag(metadata);
|
|
238
260
|
if (zoomTag) {
|
|
239
|
-
ctx.fillStyle =
|
|
261
|
+
ctx.fillStyle = COLORS.zoomTag;
|
|
240
262
|
ctx.font = 'italic 10px sans-serif';
|
|
241
263
|
ctx.fillText(zoomTag, x0 + PANEL_W - 60, y0 + TITLE_HEIGHT - 8);
|
|
242
264
|
}
|
|
@@ -258,34 +280,38 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
|
|
|
258
280
|
ctx.lineTo(mapX(moves[j].x), mapY(moves[j].y));
|
|
259
281
|
ctx.stroke();
|
|
260
282
|
|
|
261
|
-
// Pause markers (>3s gap
|
|
262
|
-
// Both moves[j].t and ta.startRel_ms are trial-relative ms (normalized in
|
|
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.
|
|
263
288
|
const gap = moves[j].t - moves[j - 1].t;
|
|
264
289
|
if (gap > 3000) {
|
|
265
290
|
const taStart = (ta) => (ta.startRel_ms != null ? ta.startRel_ms : ta.start);
|
|
266
|
-
const
|
|
267
|
-
taStart(ta) < moves[j].t + 1000 &&
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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;
|
|
271
299
|
const size = Math.min(20, Math.max(4, 4 + Math.sqrt(dur / 1000) * 3));
|
|
272
300
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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);
|
|
277
305
|
|
|
278
|
-
// Dashed connector
|
|
279
306
|
ctx.setLineDash([4, 3]);
|
|
280
|
-
ctx.strokeStyle =
|
|
307
|
+
ctx.strokeStyle = COLORS.tabAwayLeftStroke;
|
|
281
308
|
ctx.beginPath();
|
|
282
309
|
ctx.moveTo(mapX(moves[j - 1].x), mapY(moves[j - 1].y));
|
|
283
310
|
ctx.lineTo(mapX(moves[j].x), mapY(moves[j].y));
|
|
284
311
|
ctx.stroke();
|
|
285
312
|
ctx.setLineDash([]);
|
|
286
313
|
} else {
|
|
287
|
-
|
|
288
|
-
ctx.fillStyle = '#ccc';
|
|
314
|
+
ctx.fillStyle = COLORS.pauseCircle;
|
|
289
315
|
ctx.beginPath();
|
|
290
316
|
ctx.arc(mapX(moves[j - 1].x), mapY(moves[j - 1].y), 3, 0, Math.PI * 2);
|
|
291
317
|
ctx.fill();
|
|
@@ -295,8 +321,8 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
|
|
|
295
321
|
ctx.globalAlpha = 1;
|
|
296
322
|
|
|
297
323
|
// Start marker (blue circle)
|
|
298
|
-
ctx.fillStyle =
|
|
299
|
-
ctx.strokeStyle =
|
|
324
|
+
ctx.fillStyle = COLORS.pathStart;
|
|
325
|
+
ctx.strokeStyle = COLORS.pathEndStroke;
|
|
300
326
|
ctx.lineWidth = 0.5;
|
|
301
327
|
ctx.beginPath();
|
|
302
328
|
ctx.arc(mapX(moves[0].x), mapY(moves[0].y), 5, 0, Math.PI * 2);
|
|
@@ -306,18 +332,18 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
|
|
|
306
332
|
// End marker (red square)
|
|
307
333
|
const ex = mapX(moves[moves.length - 1].x);
|
|
308
334
|
const ey = mapY(moves[moves.length - 1].y);
|
|
309
|
-
ctx.fillStyle =
|
|
335
|
+
ctx.fillStyle = COLORS.pathEnd;
|
|
310
336
|
ctx.fillRect(ex - 4, ey - 4, 8, 8);
|
|
311
337
|
ctx.strokeRect(ex - 4, ey - 4, 8, 8);
|
|
312
338
|
|
|
313
|
-
// Mousedown markers (green
|
|
314
|
-
ctx.fillStyle =
|
|
339
|
+
// Mousedown markers (green ▼)
|
|
340
|
+
ctx.fillStyle = COLORS.mousedown;
|
|
315
341
|
for (const d of downs) {
|
|
316
342
|
drawTriangle(ctx, mapX(d.x), mapY(d.y), 5, 'down');
|
|
317
343
|
}
|
|
318
344
|
|
|
319
|
-
// Mouseup markers (magenta
|
|
320
|
-
ctx.fillStyle =
|
|
345
|
+
// Mouseup markers (magenta ▲)
|
|
346
|
+
ctx.fillStyle = COLORS.mouseup;
|
|
321
347
|
for (const u of ups) {
|
|
322
348
|
drawTriangle(ctx, mapX(u.x), mapY(u.y), 5, 'up');
|
|
323
349
|
}
|
|
@@ -480,23 +506,31 @@ export function computeZoomScale(metadata) {
|
|
|
480
506
|
};
|
|
481
507
|
}
|
|
482
508
|
|
|
483
|
-
//
|
|
484
|
-
// Returns one of:
|
|
485
|
-
// { mode: 'screen-api',
|
|
486
|
-
// { mode: 'legacy',
|
|
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 }
|
|
487
514
|
// { mode: 'auto-fit' }
|
|
488
515
|
//
|
|
489
|
-
// The 'screen-api' branch trusts metadata.screens (Window Management API)
|
|
516
|
+
// The 'screen-api' branch trusts metadata.screens (Window Management API) and
|
|
490
517
|
// uses the screen the window currently sits on (currentScreenLabel-matched)
|
|
491
|
-
// rather than the primary monitor
|
|
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.
|
|
492
527
|
//
|
|
493
|
-
// The '
|
|
494
|
-
//
|
|
495
|
-
// window paradoxically exceeds the (primary-only) screen.
|
|
528
|
+
// The 'auto-fit' branch is the catch-all: no usable geometry, fit canvas to
|
|
529
|
+
// the mouse path.
|
|
496
530
|
export function chooseScreenFrame(metadata) {
|
|
497
531
|
if (!metadata) return { mode: 'auto-fit' };
|
|
498
532
|
|
|
499
|
-
// Branch 1: Window Management API data present
|
|
533
|
+
// Branch 1: Window Management API data present.
|
|
500
534
|
if (Array.isArray(metadata.screens) && metadata.screens.length > 0) {
|
|
501
535
|
// Pick the screen the window lives on. If currentScreenLabel matches a
|
|
502
536
|
// labeled entry, use it; otherwise prefer the screen whose bounds contain
|
|
@@ -515,7 +549,7 @@ export function chooseScreenFrame(metadata) {
|
|
|
515
549
|
};
|
|
516
550
|
}
|
|
517
551
|
|
|
518
|
-
// Branch 2: legacy data — primaryScreenWidth
|
|
552
|
+
// Branch 2: legacy data — primaryScreenWidth or screenWidth.
|
|
519
553
|
const screenW = metadata.primaryScreenWidth ?? metadata.screenWidth;
|
|
520
554
|
const screenH = metadata.primaryScreenHeight ?? metadata.screenHeight;
|
|
521
555
|
if (screenW > 0 && screenH > 0) {
|
|
@@ -577,12 +611,20 @@ function pickCurrentScreen(metadata) {
|
|
|
577
611
|
return screens.find(s => s.isPrimary) || screens[0];
|
|
578
612
|
}
|
|
579
613
|
|
|
580
|
-
//
|
|
581
|
-
// like "zoom × 0.75"
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
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%.
|
|
586
628
|
export function computeZoomTag(metadata) {
|
|
587
629
|
if (!metadata) return null;
|
|
588
630
|
const tol = 0.05;
|
|
@@ -590,15 +632,15 @@ export function computeZoomTag(metadata) {
|
|
|
590
632
|
if (typeof vvScale === 'number' && Math.abs(vvScale - 1) > tol) {
|
|
591
633
|
return `zoom × ${vvScale.toFixed(2)}`;
|
|
592
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.
|
|
593
639
|
const ow = metadata.outerWidth;
|
|
594
|
-
const iw = metadata.
|
|
640
|
+
const iw = metadata.innerWidth;
|
|
595
641
|
if (typeof ow === 'number' && typeof iw === 'number' && iw > 0) {
|
|
596
642
|
const ratio = ow / iw;
|
|
597
|
-
// Larger outer/inner means user zoomed out (viewport > chrome).
|
|
598
|
-
// Smaller means zoomed in. Either way, flag deviation.
|
|
599
643
|
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
644
|
const zoomEstimate = 1 / ratio;
|
|
603
645
|
return `zoom × ${zoomEstimate.toFixed(2)}`;
|
|
604
646
|
}
|
package/src/cli/report.js
CHANGED
|
@@ -26,7 +26,21 @@ export async function run(args) {
|
|
|
26
26
|
(warnings.length ? ` (${warnings.length} files had warnings)` : ''));
|
|
27
27
|
|
|
28
28
|
if (participants.length === 0) {
|
|
29
|
-
console.error(
|
|
29
|
+
console.error(
|
|
30
|
+
`[cyborg-hunter] no valid participant data found in ${config.dataDir} (pattern: ${config.filePattern}).`
|
|
31
|
+
);
|
|
32
|
+
console.error(` Common causes:`);
|
|
33
|
+
console.error(` - dataDir points to the wrong directory`);
|
|
34
|
+
console.error(` - filePattern doesn't match your file extensions (try "*.csv" or "*.{json,csv}")`);
|
|
35
|
+
console.error(` - participantIdField doesn't match the actual field in your data`);
|
|
36
|
+
console.error(` (jsPsych output usually uses "subject_ID", not "participantId")`);
|
|
37
|
+
if (warnings.length > 0) {
|
|
38
|
+
console.error(` Per-file warnings (${warnings.length}):`);
|
|
39
|
+
for (const w of warnings.slice(0, 3)) {
|
|
40
|
+
console.error(` - ${w.file}: ${w.warnings[0]}`);
|
|
41
|
+
}
|
|
42
|
+
if (warnings.length > 3) console.error(` ... and ${warnings.length - 3} more`);
|
|
43
|
+
}
|
|
30
44
|
process.exit(1);
|
|
31
45
|
}
|
|
32
46
|
|
package/src/core/monitor.js
CHANGED
|
@@ -36,7 +36,7 @@ export function init(userConfig) {
|
|
|
36
36
|
var presetName = userConfig.preset || "standard";
|
|
37
37
|
var preset = PRESETS[presetName];
|
|
38
38
|
if (!preset) {
|
|
39
|
-
throw new Error("
|
|
39
|
+
throw new Error("[cyborg-hunter] unknown preset '" + presetName + "'. Use: permissive, standard, strict.");
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// Build effective config: DEFAULT_THRESHOLDS → preset → user overrides.
|
|
@@ -75,19 +75,18 @@ export function init(userConfig) {
|
|
|
75
75
|
decoyMap: userConfig.decoyMap || null,
|
|
76
76
|
decoyVisibility: userConfig.decoyVisibility || "offscreen",
|
|
77
77
|
decoyFraming: userConfig.decoyFraming || "answer-key",
|
|
78
|
-
decoyExcludeButtons: userConfig.decoyExcludeButtons || [".jspsych-btn"]
|
|
79
|
-
_debug: userConfig._debug || false
|
|
78
|
+
decoyExcludeButtons: userConfig.decoyExcludeButtons || [".jspsych-btn"]
|
|
80
79
|
};
|
|
81
80
|
|
|
82
|
-
// Validate config keys — warn on typos
|
|
81
|
+
// Validate config keys — warn on typos.
|
|
83
82
|
var KNOWN_KEYS = [
|
|
84
83
|
"participantId", "preset", "signals", "thresholds", "scoring",
|
|
85
84
|
"screenout", "domProtection", "collectForPostHoc", "decoyAnswers",
|
|
86
85
|
"decoyMap", "decoyVisibility", "decoyFraming", "decoyExcludeButtons",
|
|
87
|
-
"onSignal", "experimentContainer", "knownInputs"
|
|
86
|
+
"onSignal", "experimentContainer", "knownInputs"
|
|
88
87
|
];
|
|
89
88
|
var warnings = validateConfig(userConfig, KNOWN_KEYS);
|
|
90
|
-
warnings.forEach(function (w) { console.warn("
|
|
89
|
+
warnings.forEach(function (w) { console.warn("[cyborg-hunter] " + w); });
|
|
91
90
|
|
|
92
91
|
// Freeze config
|
|
93
92
|
Object.freeze(config);
|
|
@@ -159,8 +158,15 @@ export function init(userConfig) {
|
|
|
159
158
|
|
|
160
159
|
function transition(to) {
|
|
161
160
|
if (!sm.transition(to)) {
|
|
161
|
+
// Common causes: calling startTrial() while another trial is open
|
|
162
|
+
// (forgot endTrial?), calling endTrial() before startTrial(), or
|
|
163
|
+
// calling anything after destroy(). Show what was expected vs got
|
|
164
|
+
// so the user can match the lifecycle.
|
|
162
165
|
throw new Error(
|
|
163
|
-
"
|
|
166
|
+
"[cyborg-hunter] invalid lifecycle call: cannot transition from '" +
|
|
167
|
+
sm.current + "' to '" + to + "'. " +
|
|
168
|
+
"Expected order: init() → startSession() → (startTrial → endTrial)* → destroy(). " +
|
|
169
|
+
"Common cause: a startTrial() without a preceding endTrial(), or a call after destroy()."
|
|
164
170
|
);
|
|
165
171
|
}
|
|
166
172
|
}
|
|
@@ -240,34 +246,45 @@ export function init(userConfig) {
|
|
|
240
246
|
syntheticInsertions: []
|
|
241
247
|
};
|
|
242
248
|
|
|
243
|
-
// Decoy injection
|
|
249
|
+
// Decoy injection. Logic in one place instead of four scattered branches:
|
|
250
|
+
// - decoyAnswers off + opts.decoyAnswer set → warn, no injection
|
|
251
|
+
// - opts.decoyAnswer === false → record explicit skip
|
|
252
|
+
// - opts.decoyAnswer is a non-empty string OR
|
|
253
|
+
// config.decoyMap has an entry for this trial → inject NOW
|
|
254
|
+
// - anything else → defer via setTimeout(0)
|
|
255
|
+
// (gives DOM time to settle
|
|
256
|
+
// when startTrial runs
|
|
257
|
+
// before the trial UI is up)
|
|
258
|
+
// Invalid (non-string, non-false, non-null) values get a warning before
|
|
259
|
+
// the deferred inject.
|
|
244
260
|
removeDecoyElement();
|
|
245
|
-
if (config.decoyAnswers) {
|
|
246
|
-
|
|
247
|
-
if (opts.decoyAnswer === false) {
|
|
248
|
-
trialData.decoy = { level: 0, source: "skipped" };
|
|
249
|
-
} else if (typeof opts.decoyAnswer === "string" && opts.decoyAnswer.length > 0) {
|
|
250
|
-
injectDecoy(opts, currentTrialId, config, trialData, function () { return trialData; });
|
|
251
|
-
} else if (config.decoyMap && config.decoyMap[currentTrialId]) {
|
|
252
|
-
injectDecoy(opts, currentTrialId, config, trialData, function () { return trialData; });
|
|
253
|
-
} else if (opts.decoyAnswer != null && opts.decoyAnswer !== false) {
|
|
261
|
+
if (!config.decoyAnswers) {
|
|
262
|
+
if (opts.decoyAnswer != null) {
|
|
254
263
|
console.warn(
|
|
255
|
-
"
|
|
256
|
-
|
|
264
|
+
"[cyborg-hunter] decoyAnswer provided but decoyAnswers is not enabled. " +
|
|
265
|
+
"Add decoyAnswers: true to init() config."
|
|
257
266
|
);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
267
|
+
}
|
|
268
|
+
} else if (opts.decoyAnswer === false) {
|
|
269
|
+
trialData.decoy = { level: 0, source: "skipped" };
|
|
270
|
+
} else {
|
|
271
|
+
var currentTrialId = trialData.trialId;
|
|
272
|
+
var inject = function () {
|
|
273
|
+
injectDecoy(opts, currentTrialId, config, trialData, function () { return trialData; });
|
|
274
|
+
};
|
|
275
|
+
var hasExplicitString = typeof opts.decoyAnswer === "string" && opts.decoyAnswer.length > 0;
|
|
276
|
+
var hasMapEntry = config.decoyMap && config.decoyMap[currentTrialId];
|
|
277
|
+
if (hasExplicitString || hasMapEntry) {
|
|
278
|
+
inject();
|
|
261
279
|
} else {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
280
|
+
if (opts.decoyAnswer != null) {
|
|
281
|
+
console.warn(
|
|
282
|
+
"[cyborg-hunter] decoyAnswer must be a non-empty string, got: " +
|
|
283
|
+
typeof opts.decoyAnswer + ". Falling through to auto-detection."
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
setTimeout(inject, 0);
|
|
265
287
|
}
|
|
266
|
-
} else if (opts.decoyAnswer != null) {
|
|
267
|
-
console.warn(
|
|
268
|
-
"IntegrityMonitor: decoyAnswer provided but decoyAnswers is not enabled. " +
|
|
269
|
-
"Add decoyAnswers: true to init() config."
|
|
270
|
-
);
|
|
271
288
|
}
|
|
272
289
|
|
|
273
290
|
// Attach trial-scoped signal listeners
|
|
@@ -188,7 +188,7 @@ export function injectDecoy(opts, expectedTrialId, config, trialData, getTrialDa
|
|
|
188
188
|
} else if (config.decoyMap) {
|
|
189
189
|
// decoyMap configured but trialId not found — Level 4 calibration fallback
|
|
190
190
|
console.warn(
|
|
191
|
-
'
|
|
191
|
+
'[cyborg-hunter] no decoyMap entry for trialId "' + expectedTrialId +
|
|
192
192
|
'". Falling back to Level 3/4.'
|
|
193
193
|
);
|
|
194
194
|
var fallbackText = buildDecoyText(null, "calibration-metadata", null, _decoyTrialIndex);
|