cyborg-hunter 0.7.0 → 0.7.3
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/CHANGELOG.md +60 -0
- package/CITATION.cff +2 -2
- package/README.md +8 -2
- package/bin/cyborg-hunter.js +18 -4
- package/dist/cyborg-hunter-replay.js +3 -0
- package/dist/cyborg-hunter.esm.js +1619 -0
- package/dist/cyborg-hunter.min.js +6 -0
- package/dist/extension-cyborg-hunter.js +1 -0
- package/dist/extension-guard-friction.js +36 -0
- package/dist/extension-guard-honeypot.js +1 -0
- package/package.json +8 -3
- package/src/cli/extract-core.js +556 -0
- package/src/cli/ingest.js +11 -536
- package/src/cli/preview-entry.js +36 -0
- package/src/cli/renderers/html-index-core.js +1273 -0
- package/src/cli/renderers/html-index.js +6 -1179
- package/src/cli/renderers/replay-assets.js +8 -118
- package/src/cli/renderers/replay-viewer.client.js +179 -16
- package/src/cli/renderers/session-timeline-core.js +907 -0
- package/src/cli/renderers/session-timeline.js +15 -899
- package/src/cli/renderers/trajectories-core.js +717 -0
- package/src/cli/renderers/trajectories.js +31 -696
- package/src/cli/renderers/typing-profile-core.js +211 -0
- package/src/cli/renderers/typing-profile.js +16 -192
- package/src/cli/report.js +17 -0
- package/src/cli/update-check.js +110 -0
- package/src/core/monitor.js +17 -0
- package/src/jspsych/extension-cyborg-hunter-replay.js +1 -1
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/jspsych/extension-guard-friction.js +11 -2
- package/src/replay/viewer-model.js +125 -0
- package/src/shared/constants.js +1 -1
- package/src/shared/paths.js +20 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// src/cli/renderers/typing-profile-core.js
|
|
2
|
+
//
|
|
3
|
+
// Pure drawing core for per-participant typing-speed bar-chart images — no
|
|
4
|
+
// Node APIs, so a browser demo can bundle it directly (0.7.2-style
|
|
5
|
+
// extraction from cli/renderers/typing-profile.js, which is now a thin fs
|
|
6
|
+
// wrapper around this module: it acquires node-canvas, calls
|
|
7
|
+
// drawTypingProfile per participant, and writes the returned canvas to a
|
|
8
|
+
// PNG). Mirrors the session-timeline-core.js / trajectories-core.js split.
|
|
9
|
+
//
|
|
10
|
+
// drawTypingProfile(p, config, createCanvas) draws one participant's typing
|
|
11
|
+
// speed bar chart — one bar per trial's chars/sec, with a horizontal
|
|
12
|
+
// threshold line and bars above threshold colored red — and returns the
|
|
13
|
+
// drawn canvas (or null if there's nothing to draw: no trials, or no trial
|
|
14
|
+
// has either a typed speed or a paste event). createCanvas is injected so
|
|
15
|
+
// this module never imports the `canvas` package directly — a browser
|
|
16
|
+
// caller can pass a document-canvas-backed factory instead.
|
|
17
|
+
|
|
18
|
+
const BAR_WIDTH = 30;
|
|
19
|
+
const BAR_GAP = 8;
|
|
20
|
+
const CHART_HEIGHT = 200;
|
|
21
|
+
const TOP_PAD = 50;
|
|
22
|
+
const BOTTOM_PAD = 50;
|
|
23
|
+
const LEFT_PAD = 60;
|
|
24
|
+
|
|
25
|
+
// ── Per-participant renderer ─────────────────────────────────────────────
|
|
26
|
+
// Returns the drawn canvas, or null if there's no data to draw (caller
|
|
27
|
+
// decides what "no data" means — the fs wrapper skips the PNG write).
|
|
28
|
+
export function drawTypingProfile(p, config, createCanvas) {
|
|
29
|
+
const trials = p.trials;
|
|
30
|
+
if (trials.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
// Per-participant threshold line: prefer the typing-speed cutoff the library
|
|
33
|
+
// actually screened this participant with (saved in session.config.thresholds)
|
|
34
|
+
// so the red "fast typing" line matches summary.csv's count; fall back to the
|
|
35
|
+
// CLI/default. A strict participant (8 cps) otherwise shows a 10 cps line.
|
|
36
|
+
const threshold =
|
|
37
|
+
p.session?.config?.thresholds?.typingSpeedCps ?? config.typingSpeedThreshold_cps ?? 10;
|
|
38
|
+
|
|
39
|
+
// Per-trial state. We distinguish three cases:
|
|
40
|
+
// "typed" — charsPerSec is a real number, draw a normal bar
|
|
41
|
+
// "paste-only" — charsPerSec is null but there's a paste event; draw a
|
|
42
|
+
// grey hatched marker so the absence is legible. Single
|
|
43
|
+
// paste fires one input event → editTimestamps.length < 2
|
|
44
|
+
// → computeTypingSpeed returns null. Without this, a
|
|
45
|
+
// pasted response looks identical to a very slow one.
|
|
46
|
+
// "no-data" — neither, leave blank
|
|
47
|
+
const kinds = trials.map(t => {
|
|
48
|
+
if (typeof t.charsPerSec === 'number') return 'typed';
|
|
49
|
+
if ((t.pasteEvents || []).length > 0) return 'paste-only';
|
|
50
|
+
return 'no-data';
|
|
51
|
+
});
|
|
52
|
+
const speeds = trials.map(t => (typeof t.charsPerSec === 'number' ? t.charsPerSec : 0));
|
|
53
|
+
if (kinds.every(k => k === 'no-data')) return null;
|
|
54
|
+
|
|
55
|
+
const canvasW = LEFT_PAD + trials.length * (BAR_WIDTH + BAR_GAP) + 20;
|
|
56
|
+
const canvasH = TOP_PAD + CHART_HEIGHT + BOTTOM_PAD;
|
|
57
|
+
const canvas = createCanvas(canvasW, canvasH);
|
|
58
|
+
const ctx = canvas.getContext('2d');
|
|
59
|
+
|
|
60
|
+
// Background
|
|
61
|
+
ctx.fillStyle = '#ffffff';
|
|
62
|
+
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
63
|
+
|
|
64
|
+
// Title
|
|
65
|
+
ctx.fillStyle = '#333';
|
|
66
|
+
ctx.font = 'bold 14px sans-serif';
|
|
67
|
+
ctx.fillText(`Typing Speed Profile — ${p.participantId}`, LEFT_PAD, 20);
|
|
68
|
+
ctx.font = '10px sans-serif';
|
|
69
|
+
ctx.fillStyle = '#666';
|
|
70
|
+
ctx.fillText(`Threshold: ${threshold} chars/sec`, LEFT_PAD, 35);
|
|
71
|
+
|
|
72
|
+
// Legend — three states documented in the `kinds` comment above.
|
|
73
|
+
const legendY = 35;
|
|
74
|
+
const legendX0 = LEFT_PAD + 180;
|
|
75
|
+
ctx.fillStyle = '#2196F3';
|
|
76
|
+
ctx.fillRect(legendX0, legendY - 8, 12, 10);
|
|
77
|
+
ctx.fillStyle = '#666';
|
|
78
|
+
ctx.fillText('typed', legendX0 + 16, legendY);
|
|
79
|
+
ctx.fillStyle = '#f44336';
|
|
80
|
+
ctx.fillRect(legendX0 + 60, legendY - 8, 12, 10);
|
|
81
|
+
ctx.fillStyle = '#666';
|
|
82
|
+
ctx.fillText('> threshold', legendX0 + 76, legendY);
|
|
83
|
+
// Hatched swatch for paste-only
|
|
84
|
+
drawHatchedRect(ctx, legendX0 + 150, legendY - 8, 12, 10);
|
|
85
|
+
ctx.fillStyle = '#666';
|
|
86
|
+
ctx.fillText('paste (P)', legendX0 + 166, legendY);
|
|
87
|
+
|
|
88
|
+
// Y-axis scaling
|
|
89
|
+
const maxSpeed = Math.max(...speeds, threshold * 1.5);
|
|
90
|
+
const yScale = CHART_HEIGHT / maxSpeed;
|
|
91
|
+
|
|
92
|
+
// Y-axis labels
|
|
93
|
+
ctx.fillStyle = '#666';
|
|
94
|
+
ctx.font = '9px sans-serif';
|
|
95
|
+
const yTicks = 5;
|
|
96
|
+
for (let t = 0; t <= yTicks; t++) {
|
|
97
|
+
const val = (maxSpeed / yTicks) * t;
|
|
98
|
+
const y = TOP_PAD + CHART_HEIGHT - val * yScale;
|
|
99
|
+
ctx.fillText(`${val.toFixed(1)}`, 5, y + 3);
|
|
100
|
+
// Grid line
|
|
101
|
+
ctx.strokeStyle = '#eee';
|
|
102
|
+
ctx.lineWidth = 0.5;
|
|
103
|
+
ctx.beginPath();
|
|
104
|
+
ctx.moveTo(LEFT_PAD, y);
|
|
105
|
+
ctx.lineTo(canvasW - 10, y);
|
|
106
|
+
ctx.stroke();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Threshold line
|
|
110
|
+
const threshY = TOP_PAD + CHART_HEIGHT - threshold * yScale;
|
|
111
|
+
ctx.strokeStyle = '#ff0000';
|
|
112
|
+
ctx.lineWidth = 1.5;
|
|
113
|
+
ctx.setLineDash([5, 3]);
|
|
114
|
+
ctx.beginPath();
|
|
115
|
+
ctx.moveTo(LEFT_PAD, threshY);
|
|
116
|
+
ctx.lineTo(canvasW - 10, threshY);
|
|
117
|
+
ctx.stroke();
|
|
118
|
+
ctx.setLineDash([]);
|
|
119
|
+
|
|
120
|
+
// Bars
|
|
121
|
+
const PASTE_BAR_H = CHART_HEIGHT * 0.2; // Paste-only marker height: fixed 20% of chart.
|
|
122
|
+
for (let i = 0; i < trials.length; i++) {
|
|
123
|
+
const kind = kinds[i];
|
|
124
|
+
const speed = speeds[i];
|
|
125
|
+
const x = LEFT_PAD + i * (BAR_WIDTH + BAR_GAP);
|
|
126
|
+
|
|
127
|
+
if (kind === 'paste-only') {
|
|
128
|
+
// Fixed-height hatched grey bar + "P" marker. The height is arbitrary
|
|
129
|
+
// — the point is visible presence, not magnitude (we have no CPS to
|
|
130
|
+
// show). Sits at the bottom like a normal bar.
|
|
131
|
+
const y = TOP_PAD + CHART_HEIGHT - PASTE_BAR_H;
|
|
132
|
+
drawHatchedRect(ctx, x, y, BAR_WIDTH, PASTE_BAR_H);
|
|
133
|
+
ctx.strokeStyle = '#888';
|
|
134
|
+
ctx.lineWidth = 0.5;
|
|
135
|
+
ctx.strokeRect(x, y, BAR_WIDTH, PASTE_BAR_H);
|
|
136
|
+
// "P" marker above the bar
|
|
137
|
+
ctx.fillStyle = '#555';
|
|
138
|
+
ctx.font = 'bold 11px sans-serif';
|
|
139
|
+
ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
|
|
140
|
+
} else if (kind === 'typed') {
|
|
141
|
+
const barH = speed * yScale;
|
|
142
|
+
const y = TOP_PAD + CHART_HEIGHT - barH;
|
|
143
|
+
ctx.fillStyle = speed > threshold ? '#f44336' : '#2196F3';
|
|
144
|
+
ctx.fillRect(x, y, BAR_WIDTH, barH);
|
|
145
|
+
ctx.strokeStyle = '#333';
|
|
146
|
+
ctx.lineWidth = 0.5;
|
|
147
|
+
ctx.strokeRect(x, y, BAR_WIDTH, barH);
|
|
148
|
+
if (speed > 0) {
|
|
149
|
+
ctx.fillStyle = '#fff';
|
|
150
|
+
ctx.font = 'bold 9px sans-serif';
|
|
151
|
+
ctx.fillText(speed.toFixed(1), x + 3, y + 12);
|
|
152
|
+
}
|
|
153
|
+
// Mixed: typed AND pasted in the same trial. The bar shows the typing
|
|
154
|
+
// rate (legitimate signal); the "P" glyph above it flags that *some*
|
|
155
|
+
// of the response came in via paste. Distinct from pure paste-only
|
|
156
|
+
// (which is a hatched bar, no colored fill).
|
|
157
|
+
if ((trials[i].pasteEvents || []).length > 0) {
|
|
158
|
+
ctx.fillStyle = '#555';
|
|
159
|
+
ctx.font = 'bold 11px sans-serif';
|
|
160
|
+
ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// 'no-data' → render nothing (blank column)
|
|
164
|
+
|
|
165
|
+
// Trial label (always drawn, even for blank columns)
|
|
166
|
+
const trialId = trials[i].trialId || trials[i].ruleId || `T${i + 1}`;
|
|
167
|
+
ctx.fillStyle = '#333';
|
|
168
|
+
ctx.font = '8px sans-serif';
|
|
169
|
+
ctx.save();
|
|
170
|
+
ctx.translate(x + BAR_WIDTH / 2, TOP_PAD + CHART_HEIGHT + 10);
|
|
171
|
+
ctx.rotate(Math.PI / 4);
|
|
172
|
+
ctx.fillText(trialId.substring(0, 12), 0, 0);
|
|
173
|
+
ctx.restore();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Baseline
|
|
177
|
+
ctx.strokeStyle = '#333';
|
|
178
|
+
ctx.lineWidth = 1;
|
|
179
|
+
ctx.beginPath();
|
|
180
|
+
ctx.moveTo(LEFT_PAD, TOP_PAD + CHART_HEIGHT);
|
|
181
|
+
ctx.lineTo(canvasW - 10, TOP_PAD + CHART_HEIGHT);
|
|
182
|
+
ctx.stroke();
|
|
183
|
+
|
|
184
|
+
return canvas;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Cross-hatched fill for paste-only markers. Uses a light grey background
|
|
188
|
+
// with diagonal strokes so the bar reads as "distinct category, not zero".
|
|
189
|
+
function drawHatchedRect(ctx, x, y, w, h) {
|
|
190
|
+
ctx.save();
|
|
191
|
+
ctx.fillStyle = '#e8e8e8';
|
|
192
|
+
ctx.fillRect(x, y, w, h);
|
|
193
|
+
ctx.beginPath();
|
|
194
|
+
ctx.rect(x, y, w, h);
|
|
195
|
+
ctx.clip();
|
|
196
|
+
ctx.strokeStyle = '#888';
|
|
197
|
+
ctx.lineWidth = 0.8;
|
|
198
|
+
const step = 5;
|
|
199
|
+
for (let d = -h; d < w + h; d += step) {
|
|
200
|
+
ctx.beginPath();
|
|
201
|
+
ctx.moveTo(x + d, y);
|
|
202
|
+
ctx.lineTo(x + d + h, y + h);
|
|
203
|
+
ctx.stroke();
|
|
204
|
+
}
|
|
205
|
+
ctx.restore();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Used by the fs wrapper to build the per-participant PNG filename.
|
|
209
|
+
export function sanitize(name) {
|
|
210
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
|
|
211
|
+
}
|
|
@@ -1,177 +1,26 @@
|
|
|
1
1
|
// src/cli/renderers/typing-profile.js
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
2
|
+
// Thin fs wrapper around the pure drawing core (typing-profile-core.js).
|
|
3
|
+
// The core takes an injected createCanvas and returns the drawn canvas (or
|
|
4
|
+
// null when there's nothing to draw); this wrapper owns node-canvas
|
|
5
|
+
// acquisition and PNG serialization so the core can also run in a browser
|
|
6
|
+
// context (demo plot adapter) with document canvases.
|
|
6
7
|
import { writeFileSync } from 'fs';
|
|
7
8
|
import { join } from 'path';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
import { drawTypingProfile, sanitize } from './typing-profile-core.js';
|
|
10
|
+
// Load-bearing re-export: drawTypingProfile serves this wrapper's own loop
|
|
11
|
+
// below and the future browser demo plot adapter — mirrors
|
|
12
|
+
// session-timeline.js / trajectories.js. (renderTypingProfiles remains the
|
|
13
|
+
// only symbol external consumers import from this file — see
|
|
14
|
+
// tests/cli/renderers.test.js and src/cli/report.js.)
|
|
15
|
+
export { drawTypingProfile };
|
|
16
|
+
|
|
17
|
+
// ── Public API ───────────────────────────────────────────────────────────
|
|
16
18
|
export async function renderTypingProfiles(participants, config) {
|
|
17
19
|
const { createCanvas } = await import('canvas');
|
|
18
20
|
|
|
19
21
|
for (const p of participants) {
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
// Per-participant threshold line: prefer the typing-speed cutoff the library
|
|
24
|
-
// actually screened this participant with (saved in session.config.thresholds)
|
|
25
|
-
// so the red "fast typing" line matches summary.csv's count; fall back to the
|
|
26
|
-
// CLI/default. A strict participant (8 cps) otherwise shows a 10 cps line.
|
|
27
|
-
const threshold =
|
|
28
|
-
p.session?.config?.thresholds?.typingSpeedCps ?? config.typingSpeedThreshold_cps ?? 10;
|
|
29
|
-
|
|
30
|
-
// Per-trial state. We distinguish three cases:
|
|
31
|
-
// "typed" — charsPerSec is a real number, draw a normal bar
|
|
32
|
-
// "paste-only" — charsPerSec is null but there's a paste event; draw a
|
|
33
|
-
// grey hatched marker so the absence is legible. Single
|
|
34
|
-
// paste fires one input event → editTimestamps.length < 2
|
|
35
|
-
// → computeTypingSpeed returns null. Without this, a
|
|
36
|
-
// pasted response looks identical to a very slow one.
|
|
37
|
-
// "no-data" — neither, leave blank
|
|
38
|
-
const kinds = trials.map(t => {
|
|
39
|
-
if (typeof t.charsPerSec === 'number') return 'typed';
|
|
40
|
-
if ((t.pasteEvents || []).length > 0) return 'paste-only';
|
|
41
|
-
return 'no-data';
|
|
42
|
-
});
|
|
43
|
-
const speeds = trials.map(t => (typeof t.charsPerSec === 'number' ? t.charsPerSec : 0));
|
|
44
|
-
if (kinds.every(k => k === 'no-data')) continue;
|
|
45
|
-
|
|
46
|
-
const canvasW = LEFT_PAD + trials.length * (BAR_WIDTH + BAR_GAP) + 20;
|
|
47
|
-
const canvasH = TOP_PAD + CHART_HEIGHT + BOTTOM_PAD;
|
|
48
|
-
const canvas = createCanvas(canvasW, canvasH);
|
|
49
|
-
const ctx = canvas.getContext('2d');
|
|
50
|
-
|
|
51
|
-
// Background
|
|
52
|
-
ctx.fillStyle = '#ffffff';
|
|
53
|
-
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
54
|
-
|
|
55
|
-
// Title
|
|
56
|
-
ctx.fillStyle = '#333';
|
|
57
|
-
ctx.font = 'bold 14px sans-serif';
|
|
58
|
-
ctx.fillText(`Typing Speed Profile — ${p.participantId}`, LEFT_PAD, 20);
|
|
59
|
-
ctx.font = '10px sans-serif';
|
|
60
|
-
ctx.fillStyle = '#666';
|
|
61
|
-
ctx.fillText(`Threshold: ${threshold} chars/sec`, LEFT_PAD, 35);
|
|
62
|
-
|
|
63
|
-
// Legend — three states documented in the `kinds` comment above.
|
|
64
|
-
const legendY = 35;
|
|
65
|
-
const legendX0 = LEFT_PAD + 180;
|
|
66
|
-
ctx.fillStyle = '#2196F3';
|
|
67
|
-
ctx.fillRect(legendX0, legendY - 8, 12, 10);
|
|
68
|
-
ctx.fillStyle = '#666';
|
|
69
|
-
ctx.fillText('typed', legendX0 + 16, legendY);
|
|
70
|
-
ctx.fillStyle = '#f44336';
|
|
71
|
-
ctx.fillRect(legendX0 + 60, legendY - 8, 12, 10);
|
|
72
|
-
ctx.fillStyle = '#666';
|
|
73
|
-
ctx.fillText('> threshold', legendX0 + 76, legendY);
|
|
74
|
-
// Hatched swatch for paste-only
|
|
75
|
-
drawHatchedRect(ctx, legendX0 + 150, legendY - 8, 12, 10);
|
|
76
|
-
ctx.fillStyle = '#666';
|
|
77
|
-
ctx.fillText('paste (P)', legendX0 + 166, legendY);
|
|
78
|
-
|
|
79
|
-
// Y-axis scaling
|
|
80
|
-
const maxSpeed = Math.max(...speeds, threshold * 1.5);
|
|
81
|
-
const yScale = CHART_HEIGHT / maxSpeed;
|
|
82
|
-
|
|
83
|
-
// Y-axis labels
|
|
84
|
-
ctx.fillStyle = '#666';
|
|
85
|
-
ctx.font = '9px sans-serif';
|
|
86
|
-
const yTicks = 5;
|
|
87
|
-
for (let t = 0; t <= yTicks; t++) {
|
|
88
|
-
const val = (maxSpeed / yTicks) * t;
|
|
89
|
-
const y = TOP_PAD + CHART_HEIGHT - val * yScale;
|
|
90
|
-
ctx.fillText(`${val.toFixed(1)}`, 5, y + 3);
|
|
91
|
-
// Grid line
|
|
92
|
-
ctx.strokeStyle = '#eee';
|
|
93
|
-
ctx.lineWidth = 0.5;
|
|
94
|
-
ctx.beginPath();
|
|
95
|
-
ctx.moveTo(LEFT_PAD, y);
|
|
96
|
-
ctx.lineTo(canvasW - 10, y);
|
|
97
|
-
ctx.stroke();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Threshold line
|
|
101
|
-
const threshY = TOP_PAD + CHART_HEIGHT - threshold * yScale;
|
|
102
|
-
ctx.strokeStyle = '#ff0000';
|
|
103
|
-
ctx.lineWidth = 1.5;
|
|
104
|
-
ctx.setLineDash([5, 3]);
|
|
105
|
-
ctx.beginPath();
|
|
106
|
-
ctx.moveTo(LEFT_PAD, threshY);
|
|
107
|
-
ctx.lineTo(canvasW - 10, threshY);
|
|
108
|
-
ctx.stroke();
|
|
109
|
-
ctx.setLineDash([]);
|
|
110
|
-
|
|
111
|
-
// Bars
|
|
112
|
-
const PASTE_BAR_H = CHART_HEIGHT * 0.2; // Paste-only marker height: fixed 20% of chart.
|
|
113
|
-
for (let i = 0; i < trials.length; i++) {
|
|
114
|
-
const kind = kinds[i];
|
|
115
|
-
const speed = speeds[i];
|
|
116
|
-
const x = LEFT_PAD + i * (BAR_WIDTH + BAR_GAP);
|
|
117
|
-
|
|
118
|
-
if (kind === 'paste-only') {
|
|
119
|
-
// Fixed-height hatched grey bar + "P" marker. The height is arbitrary
|
|
120
|
-
// — the point is visible presence, not magnitude (we have no CPS to
|
|
121
|
-
// show). Sits at the bottom like a normal bar.
|
|
122
|
-
const y = TOP_PAD + CHART_HEIGHT - PASTE_BAR_H;
|
|
123
|
-
drawHatchedRect(ctx, x, y, BAR_WIDTH, PASTE_BAR_H);
|
|
124
|
-
ctx.strokeStyle = '#888';
|
|
125
|
-
ctx.lineWidth = 0.5;
|
|
126
|
-
ctx.strokeRect(x, y, BAR_WIDTH, PASTE_BAR_H);
|
|
127
|
-
// "P" marker above the bar
|
|
128
|
-
ctx.fillStyle = '#555';
|
|
129
|
-
ctx.font = 'bold 11px sans-serif';
|
|
130
|
-
ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
|
|
131
|
-
} else if (kind === 'typed') {
|
|
132
|
-
const barH = speed * yScale;
|
|
133
|
-
const y = TOP_PAD + CHART_HEIGHT - barH;
|
|
134
|
-
ctx.fillStyle = speed > threshold ? '#f44336' : '#2196F3';
|
|
135
|
-
ctx.fillRect(x, y, BAR_WIDTH, barH);
|
|
136
|
-
ctx.strokeStyle = '#333';
|
|
137
|
-
ctx.lineWidth = 0.5;
|
|
138
|
-
ctx.strokeRect(x, y, BAR_WIDTH, barH);
|
|
139
|
-
if (speed > 0) {
|
|
140
|
-
ctx.fillStyle = '#fff';
|
|
141
|
-
ctx.font = 'bold 9px sans-serif';
|
|
142
|
-
ctx.fillText(speed.toFixed(1), x + 3, y + 12);
|
|
143
|
-
}
|
|
144
|
-
// Mixed: typed AND pasted in the same trial. The bar shows the typing
|
|
145
|
-
// rate (legitimate signal); the "P" glyph above it flags that *some*
|
|
146
|
-
// of the response came in via paste. Distinct from pure paste-only
|
|
147
|
-
// (which is a hatched bar, no colored fill).
|
|
148
|
-
if ((trials[i].pasteEvents || []).length > 0) {
|
|
149
|
-
ctx.fillStyle = '#555';
|
|
150
|
-
ctx.font = 'bold 11px sans-serif';
|
|
151
|
-
ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// 'no-data' → render nothing (blank column)
|
|
155
|
-
|
|
156
|
-
// Trial label (always drawn, even for blank columns)
|
|
157
|
-
const trialId = trials[i].trialId || trials[i].ruleId || `T${i + 1}`;
|
|
158
|
-
ctx.fillStyle = '#333';
|
|
159
|
-
ctx.font = '8px sans-serif';
|
|
160
|
-
ctx.save();
|
|
161
|
-
ctx.translate(x + BAR_WIDTH / 2, TOP_PAD + CHART_HEIGHT + 10);
|
|
162
|
-
ctx.rotate(Math.PI / 4);
|
|
163
|
-
ctx.fillText(trialId.substring(0, 12), 0, 0);
|
|
164
|
-
ctx.restore();
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Baseline
|
|
168
|
-
ctx.strokeStyle = '#333';
|
|
169
|
-
ctx.lineWidth = 1;
|
|
170
|
-
ctx.beginPath();
|
|
171
|
-
ctx.moveTo(LEFT_PAD, TOP_PAD + CHART_HEIGHT);
|
|
172
|
-
ctx.lineTo(canvasW - 10, TOP_PAD + CHART_HEIGHT);
|
|
173
|
-
ctx.stroke();
|
|
174
|
-
|
|
22
|
+
const canvas = drawTypingProfile(p, config, createCanvas);
|
|
23
|
+
if (!canvas) continue;
|
|
175
24
|
const buf = canvas.toBuffer('image/png');
|
|
176
25
|
const filename = `typing_profile_${sanitize(p.participantId)}.png`;
|
|
177
26
|
writeFileSync(join(config.outputDir, 'images', filename), buf);
|
|
@@ -179,28 +28,3 @@ export async function renderTypingProfiles(participants, config) {
|
|
|
179
28
|
|
|
180
29
|
console.log(` typing profiles — rendered`);
|
|
181
30
|
}
|
|
182
|
-
|
|
183
|
-
function sanitize(name) {
|
|
184
|
-
return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Cross-hatched fill for paste-only markers. Uses a light grey background
|
|
188
|
-
// with diagonal strokes so the bar reads as "distinct category, not zero".
|
|
189
|
-
function drawHatchedRect(ctx, x, y, w, h) {
|
|
190
|
-
ctx.save();
|
|
191
|
-
ctx.fillStyle = '#e8e8e8';
|
|
192
|
-
ctx.fillRect(x, y, w, h);
|
|
193
|
-
ctx.beginPath();
|
|
194
|
-
ctx.rect(x, y, w, h);
|
|
195
|
-
ctx.clip();
|
|
196
|
-
ctx.strokeStyle = '#888';
|
|
197
|
-
ctx.lineWidth = 0.8;
|
|
198
|
-
const step = 5;
|
|
199
|
-
for (let d = -h; d < w + h; d += step) {
|
|
200
|
-
ctx.beginPath();
|
|
201
|
-
ctx.moveTo(x + d, y);
|
|
202
|
-
ctx.lineTo(x + d + h, y + h);
|
|
203
|
-
ctx.stroke();
|
|
204
|
-
}
|
|
205
|
-
ctx.restore();
|
|
206
|
-
}
|
package/src/cli/report.js
CHANGED
|
@@ -14,10 +14,15 @@ import { detectEdgeExits } from './analyzers/edge-exit.js';
|
|
|
14
14
|
import { rankTriage } from './analyzers/triage.js';
|
|
15
15
|
import { applyPhaseScope, describePhaseScope, findUnmatchedPhaseScopePhases } from './analyzers/phase-scope.js';
|
|
16
16
|
import { VERSION } from '../shared/constants.js';
|
|
17
|
+
import { checkForUpdate, formatUpdateNotice, formatCollectedVersionNotice } from './update-check.js';
|
|
17
18
|
|
|
18
19
|
export async function run(args) {
|
|
19
20
|
console.log(`cyborg-hunter report v${VERSION}\n`);
|
|
20
21
|
|
|
22
|
+
// Kick the update check off first so the registry request overlaps the
|
|
23
|
+
// whole pipeline; awaited (bounded by its own short timeout) at the end.
|
|
24
|
+
const updateCheck = checkForUpdate({ currentVersion: VERSION }).catch(() => null);
|
|
25
|
+
|
|
21
26
|
// 1. Load and merge config (defaults ← file ← CLI flags)
|
|
22
27
|
const config = loadConfig(args);
|
|
23
28
|
|
|
@@ -45,6 +50,13 @@ export async function run(args) {
|
|
|
45
50
|
process.exit(1);
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
// Offline staleness note: payloads stamp the library version that collected
|
|
54
|
+
// them, so an experiment still serving an old bundle is visible without any
|
|
55
|
+
// network. (The registry check above covers the CLI side.)
|
|
56
|
+
const collectedNotice = formatCollectedVersionNotice(
|
|
57
|
+
participants.map(p => p.libraryVersion), VERSION);
|
|
58
|
+
if (collectedNotice) console.log(collectedNotice);
|
|
59
|
+
|
|
48
60
|
// 3. Analyze — compute summaries, detect edge exits, rank by triage priority.
|
|
49
61
|
// config.phaseScope (0.6.1) filters which trials feed the analyzers so
|
|
50
62
|
// scores can honor pre-registered phase scoping; renderers below still get
|
|
@@ -145,4 +157,9 @@ export async function run(args) {
|
|
|
145
157
|
|
|
146
158
|
console.log(`\nReport written to ${config.outputDir}/`);
|
|
147
159
|
console.log(` Open ${config.outputDir}/index.html to review`);
|
|
160
|
+
|
|
161
|
+
const update = await updateCheck;
|
|
162
|
+
if (update && update.updateAvailable) {
|
|
163
|
+
console.log(`\n${formatUpdateNotice(VERSION, update.latest)}`);
|
|
164
|
+
}
|
|
148
165
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// src/cli/update-check.js
|
|
2
|
+
// Post-report update nudge, plus the offline collected-with version notice.
|
|
3
|
+
//
|
|
4
|
+
// The registry check is deliberately zero-dependency and fail-silent: one
|
|
5
|
+
// request to npm's registry for the `latest` dist-tag, throttled to once per
|
|
6
|
+
// day via a small cache file. A report run must never break, slow down, or
|
|
7
|
+
// complain because npm is unreachable. It runs only in the CLI on the
|
|
8
|
+
// researcher's machine — the browser library never makes network requests.
|
|
9
|
+
//
|
|
10
|
+
// Opt-outs (checked before any network or cache access):
|
|
11
|
+
// NO_UPDATE_NOTIFIER — the convention established by update-notifier
|
|
12
|
+
// CI — CI runs have no human to nudge
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
15
|
+
import { join, dirname } from 'node:path';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
|
|
18
|
+
const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/cyborg-hunter/latest';
|
|
19
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
20
|
+
const FETCH_TIMEOUT_MS = 1500;
|
|
21
|
+
|
|
22
|
+
function defaultCacheFile() {
|
|
23
|
+
return join(homedir(), '.cache', 'cyborg-hunter', 'update-check.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Numeric dot-segment comparison ("0.10.0" vs "0.7.2"): returns 1 / 0 / -1 as
|
|
27
|
+
// a is newer / equal / older than b. Non-numeric segments count as 0, so a
|
|
28
|
+
// malformed registry answer degrades to "no update" rather than a false nudge.
|
|
29
|
+
export function compareVersions(a, b) {
|
|
30
|
+
const as = String(a).split('.').map(s => parseInt(s, 10) || 0);
|
|
31
|
+
const bs = String(b).split('.').map(s => parseInt(s, 10) || 0);
|
|
32
|
+
const len = Math.max(as.length, bs.length);
|
|
33
|
+
for (let i = 0; i < len; i++) {
|
|
34
|
+
const d = (as[i] || 0) - (bs[i] || 0);
|
|
35
|
+
if (d > 0) return 1;
|
|
36
|
+
if (d < 0) return -1;
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Resolves to { latest, updateAvailable } or null (opted out, offline, old
|
|
42
|
+
// Node without fetch, or any registry hiccup — all silent by design).
|
|
43
|
+
// Everything impure is injectable so tests never touch the network or $HOME.
|
|
44
|
+
export async function checkForUpdate({
|
|
45
|
+
currentVersion,
|
|
46
|
+
env = process.env,
|
|
47
|
+
fetchImpl = globalThis.fetch,
|
|
48
|
+
cacheFile = defaultCacheFile(),
|
|
49
|
+
timeoutMs = FETCH_TIMEOUT_MS,
|
|
50
|
+
now = Date.now(),
|
|
51
|
+
} = {}) {
|
|
52
|
+
if (env.NO_UPDATE_NOTIFIER || env.CI || typeof fetchImpl !== 'function') return null;
|
|
53
|
+
|
|
54
|
+
let cached = null;
|
|
55
|
+
try {
|
|
56
|
+
cached = JSON.parse(readFileSync(cacheFile, 'utf8'));
|
|
57
|
+
} catch { /* no cache yet, or unreadable — fetch below */ }
|
|
58
|
+
|
|
59
|
+
let latest;
|
|
60
|
+
if (cached && typeof cached.latest === 'string' &&
|
|
61
|
+
typeof cached.checkedAt === 'number' && now - cached.checkedAt < CHECK_INTERVAL_MS) {
|
|
62
|
+
latest = cached.latest;
|
|
63
|
+
} else {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
// Deliberately NOT unref'd: on a hung fetch this timer can be the only
|
|
66
|
+
// live handle, and an unref'd one lets the event loop drain before it
|
|
67
|
+
// fires (Node 20 cancels pending awaits that way). It is cleared the
|
|
68
|
+
// moment fetch settles, so it never holds a normal run open.
|
|
69
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetchImpl(REGISTRY_LATEST_URL, { signal: controller.signal });
|
|
72
|
+
if (!res.ok) return null;
|
|
73
|
+
const body = await res.json();
|
|
74
|
+
if (typeof body.version !== 'string') return null;
|
|
75
|
+
latest = body.version;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
mkdirSync(dirname(cacheFile), { recursive: true });
|
|
83
|
+
writeFileSync(cacheFile, JSON.stringify({ checkedAt: now, latest }));
|
|
84
|
+
} catch { /* unwritable cache dir just means one fetch per run */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { latest, updateAvailable: compareVersions(latest, currentVersion) > 0 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function formatUpdateNotice(current, latest) {
|
|
91
|
+
return `Update available: cyborg-hunter ${current} -> ${latest}\n` +
|
|
92
|
+
` npx picks up new versions automatically; a global install updates with\n` +
|
|
93
|
+
` npm install -g cyborg-hunter@latest`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Offline staleness notice: session payloads stamp the library version that
|
|
97
|
+
// collected them (monitor.js sets report.libraryVersion), so a mismatch with
|
|
98
|
+
// the CLI's own version is detectable with no network at all. This catches
|
|
99
|
+
// the case the registry check can't: an experiment still collecting data
|
|
100
|
+
// with an old bundle. Returns the notice string, or null when every session
|
|
101
|
+
// matches the CLI version (or carries no version, e.g. CSV-derived data).
|
|
102
|
+
export function formatCollectedVersionNotice(collectedVersions, cliVersion) {
|
|
103
|
+
const distinct = [...new Set(
|
|
104
|
+
collectedVersions.filter(v => typeof v === 'string' && v.length > 0)
|
|
105
|
+
)].filter(v => v !== cliVersion).sort(compareVersions);
|
|
106
|
+
if (distinct.length === 0) return null;
|
|
107
|
+
return ` Note: sessions in this dataset were collected with cyborg-hunter ` +
|
|
108
|
+
`${distinct.join(', ')}; this CLI is ${cliVersion}.\n` +
|
|
109
|
+
` Signal definitions and scoring defaults can differ between versions.`;
|
|
110
|
+
}
|
package/src/core/monitor.js
CHANGED
|
@@ -378,6 +378,23 @@ export function init(userConfig) {
|
|
|
378
378
|
report.mouseMetrics = mouseMetrics;
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
// Privacy gate: the raw per-sample mouse coordinates are a much
|
|
382
|
+
// higher-resolution behavioral trace than the derived mouseMetrics
|
|
383
|
+
// above. Persist them ONLY when explicitly enabled
|
|
384
|
+
// (collectForPostHoc.rawMouseTrack) — under the `mouseTrack` name,
|
|
385
|
+
// which is what extract-core's field map already expects (mouseTrack →
|
|
386
|
+
// mouseEvents), so a saved report round-trips through
|
|
387
|
+
// extractIntegrityData() without new glue. Otherwise drop the raw
|
|
388
|
+
// report.mouseEvents entirely — the mouseMetrics signal above still
|
|
389
|
+
// works, but the {x,y,t,type} samples never leave the browser. Before
|
|
390
|
+
// this gate, deepCopy(trialData) put the full raw track in every
|
|
391
|
+
// report regardless of the documented off-by-default rawMouseTrack
|
|
392
|
+
// toggle.
|
|
393
|
+
if (config.collectForPostHoc.rawMouseTrack) {
|
|
394
|
+
report.mouseTrack = report.mouseEvents;
|
|
395
|
+
}
|
|
396
|
+
delete report.mouseEvents;
|
|
397
|
+
|
|
381
398
|
// Accumulate into session
|
|
382
399
|
sessionData.trialsCompleted++;
|
|
383
400
|
|
|
@@ -35,7 +35,7 @@ import { buildReplayMeta } from '../replay/persistence.js';
|
|
|
35
35
|
class CyborgHunterReplayExtension {
|
|
36
36
|
static info = {
|
|
37
37
|
name: 'cyborg-hunter-replay',
|
|
38
|
-
version: '0.7.
|
|
38
|
+
version: '0.7.3',
|
|
39
39
|
data: {} // per-trial return is {}; session meta goes via addProperties
|
|
40
40
|
};
|
|
41
41
|
|
|
@@ -10,7 +10,7 @@ class CyborgHunterExtension {
|
|
|
10
10
|
// and package.json). Hand-bumped on each release; if you forget, the
|
|
11
11
|
// jsPsych developer console shows a stale number — the actual version
|
|
12
12
|
// attached to data is read from window.CyborgHunter.VERSION at runtime.
|
|
13
|
-
version: '0.7.
|
|
13
|
+
version: '0.7.3',
|
|
14
14
|
data: { integrity: { type: 'object' } }
|
|
15
15
|
};
|
|
16
16
|
|
|
@@ -905,8 +905,11 @@ export function fullscreenElementOf(doc) {
|
|
|
905
905
|
}
|
|
906
906
|
|
|
907
907
|
// ----- Entry trial ---------------------------------------------
|
|
908
|
-
|
|
909
|
-
|
|
908
|
+
// Hoisted out of createEntryTrial (verbatim string move) so the frozen
|
|
909
|
+
// public API can expose it as `defaultEntryMessage` below — demo/demo.js
|
|
910
|
+
// renders this SAME string verbatim on its guard-entry step, so it can
|
|
911
|
+
// never drift out of sync with what a real participant actually sees.
|
|
912
|
+
const DEFAULT_ENTRY_MESSAGE = `
|
|
910
913
|
<h2>Fullscreen mode required</h2>
|
|
911
914
|
<p>To keep the experiment fair for everyone, we ask that this study be completed in fullscreen mode,
|
|
912
915
|
with no browser sidebars (Gemini, Copilot, Edge sidebar, etc.) open, and with this tab focused.</p>
|
|
@@ -914,6 +917,9 @@ export function fullscreenElementOf(doc) {
|
|
|
914
917
|
<p>We care about collecting high-quality data, and these rules help ensure a fair experience for all participants.</p>
|
|
915
918
|
<p>Please close any sidebars now, then click the button below to continue in fullscreen.</p>
|
|
916
919
|
`;
|
|
920
|
+
|
|
921
|
+
function createEntryTrial(opts = {}) {
|
|
922
|
+
const message = opts.message || DEFAULT_ENTRY_MESSAGE;
|
|
917
923
|
return {
|
|
918
924
|
type: jsPsychHtmlButtonResponse,
|
|
919
925
|
stimulus: message,
|
|
@@ -1048,6 +1054,9 @@ export function fullscreenElementOf(doc) {
|
|
|
1048
1054
|
createEntryTrial: function (opts) { return createEntryTrial(opts); },
|
|
1049
1055
|
onViolation: function (handler) { return onViolation(handler); },
|
|
1050
1056
|
getCurrentState: function () { return getCurrentState(); },
|
|
1057
|
+
// Added on the object literal BEFORE Object.freeze() below — a
|
|
1058
|
+
// post-freeze assignment would silently no-op.
|
|
1059
|
+
defaultEntryMessage: DEFAULT_ENTRY_MESSAGE,
|
|
1051
1060
|
};
|
|
1052
1061
|
|
|
1053
1062
|
Object.freeze(api);
|