cyborg-hunter 0.5.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -0
- package/CITATION.cff +29 -0
- package/LICENSE +21 -0
- package/README.md +80 -21
- package/bin/cyborg-hunter.js +6 -2
- package/dist/cyborg-hunter-replay.js +3 -0
- package/dist/cyborg-hunter.esm.js +114 -22
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/dist/extension-guard-honeypot.js +1 -1
- package/package.json +15 -3
- package/src/cli/analyzers/edge-exit.js +4 -1
- package/src/cli/analyzers/phase-scope.js +83 -0
- package/src/cli/analyzers/summary.js +161 -28
- package/src/cli/analyzers/triage.js +59 -27
- package/src/cli/config.js +26 -1
- package/src/cli/extract-core.js +552 -0
- package/src/cli/ingest.js +250 -193
- package/src/cli/init.js +1 -1
- package/src/cli/preview-entry.js +36 -0
- package/src/cli/renderers/event-log.js +18 -19
- package/src/cli/renderers/extensions.js +12 -3
- package/src/cli/renderers/html-index-core.js +1273 -0
- package/src/cli/renderers/html-index.js +13 -1047
- package/src/cli/renderers/replay-assets.js +67 -0
- package/src/cli/renderers/replay-viewer.client.js +1185 -0
- package/src/cli/renderers/session-timeline-core.js +907 -0
- package/src/cli/renderers/session-timeline.js +33 -0
- package/src/cli/renderers/summary-csv.js +5 -0
- package/src/cli/renderers/trajectories-core.js +717 -0
- package/src/cli/renderers/trajectories.js +31 -635
- package/src/cli/renderers/triage-md.js +10 -4
- package/src/cli/renderers/typing-profile-core.js +211 -0
- package/src/cli/renderers/typing-profile.js +16 -186
- package/src/cli/report.js +42 -8
- package/src/core/monitor.js +77 -7
- package/src/core/scoring.js +11 -2
- package/src/core/signals/browser.js +51 -18
- package/src/core/signals/clipboard.js +10 -2
- package/src/core/signals/dom-protection.js +9 -0
- package/src/core/signals/focus.js +16 -2
- package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
- package/src/jspsych/extension-cyborg-hunter.js +9 -2
- package/src/jspsych/extension-guard-friction.js +43 -14
- package/src/jspsych/extension-guard-honeypot.js +25 -1
- package/src/replay/capture-dom.js +575 -0
- package/src/replay/capture-trace.js +468 -0
- package/src/replay/index.js +104 -0
- package/src/replay/persistence.js +141 -0
- package/src/replay/recorder.js +315 -0
- package/src/replay/serializer.js +119 -0
- package/src/replay/viewer-model.js +125 -0
- package/src/shared/constants.js +12 -6
- package/src/shared/paths.js +20 -0
- package/src/shared/schema.js +5 -0
- package/src/shared/validation.js +55 -0
- package/src/cli/renderers/tab-timeline.js +0 -149
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// src/replay/viewer-model.js
|
|
2
|
+
// Wire SessionRecording → viewer model. Pure — no Node APIs — so a browser
|
|
3
|
+
// demo can bundle it directly (0.7.2 extraction from
|
|
4
|
+
// cli/renderers/replay-assets.js, which re-exports this for existing
|
|
5
|
+
// callers).
|
|
6
|
+
//
|
|
7
|
+
// This is one of exactly two allowed wire→viewer time-conversion points (the
|
|
8
|
+
// other lives in the CLI ingest path): SessionRecording carries ms since
|
|
9
|
+
// session start; the viewer speaks trial-relative ms.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wire SessionRecording → viewer model. Times become trial-relative
|
|
13
|
+
* (t − t_load); null anchors (standalone implicit trials) degrade to the
|
|
14
|
+
* first event's time so durations are always finite.
|
|
15
|
+
*/
|
|
16
|
+
export function buildViewerModel(recording) {
|
|
17
|
+
const md = recording.metadata || {};
|
|
18
|
+
const ext = recording.ch_extensions || {};
|
|
19
|
+
|
|
20
|
+
// ── Camera seeding (central, per Sol round-1 finding 8) ──
|
|
21
|
+
// New recordings carry a per-trial view_state seed. Legacy recordings
|
|
22
|
+
// don't — but the full event stream is present, so each trial's starting
|
|
23
|
+
// camera is reconstructed by folding all PRIOR trials' window-scroll and
|
|
24
|
+
// resize events over the session-start viewport. Initial scroll is assumed
|
|
25
|
+
// 0 (a recording that starts pre-scrolled with no scroll events is
|
|
26
|
+
// unrecoverable — that's what the viewer's legacy banner covers).
|
|
27
|
+
const vp = recording.viewport || {};
|
|
28
|
+
const vv = vp.visual_viewport || {};
|
|
29
|
+
// Scrollbar delta: legacy resize events carry only innerWidth/Height; the
|
|
30
|
+
// layout (client) width is estimated as w minus the session-start delta
|
|
31
|
+
// between innerWidth and the layout width (visual_viewport.width).
|
|
32
|
+
const sbW = (vp.width && (vp.client_width || vv.width))
|
|
33
|
+
? vp.width - (vp.client_width || vv.width) : 0;
|
|
34
|
+
const sbH = (vp.height && (vp.client_height || vv.height))
|
|
35
|
+
? vp.height - (vp.client_height || vv.height) : 0;
|
|
36
|
+
let foldState = {
|
|
37
|
+
x: 0, y: 0,
|
|
38
|
+
w: vp.width || null, h: vp.height || null,
|
|
39
|
+
cw: vp.client_width || vv.width || vp.width || null,
|
|
40
|
+
ch: vp.client_height || vv.height || vp.height || null,
|
|
41
|
+
dpr: vp.dpr || 1
|
|
42
|
+
};
|
|
43
|
+
const foldEvent = (state, e) => {
|
|
44
|
+
if (e.kind === 'scroll' && e.el == null && e.redacted == null) {
|
|
45
|
+
state.x = Number(e.x) || 0;
|
|
46
|
+
state.y = Number(e.y) || 0;
|
|
47
|
+
} else if (e.kind === 'resize') {
|
|
48
|
+
state.w = e.w != null ? e.w : state.w;
|
|
49
|
+
state.h = e.h != null ? e.h : state.h;
|
|
50
|
+
state.cw = e.cw != null ? e.cw : (e.w != null ? e.w - sbW : state.cw);
|
|
51
|
+
state.ch = e.ch != null ? e.ch : (e.h != null ? e.h - sbH : state.ch);
|
|
52
|
+
if (e.dpr != null) state.dpr = e.dpr;
|
|
53
|
+
}
|
|
54
|
+
return state;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Defensive against malformed/truncated artifacts (a hand-edited or
|
|
58
|
+
// partially-written recording): non-array trials/events and null entries must
|
|
59
|
+
// degrade to empty rather than throw and abort the whole cohort report.
|
|
60
|
+
const rawTrials = Array.isArray(recording.trials) ? recording.trials : [];
|
|
61
|
+
const trials = rawTrials.map((trial) => {
|
|
62
|
+
trial = trial || {};
|
|
63
|
+
// Sort by absolute time before anchoring. RAF-coalesced input events flush
|
|
64
|
+
// with an EARLIER timestamp than events pushed after they were enqueued, so
|
|
65
|
+
// the recorded array is not strictly time-ordered; the viewer scrubs by
|
|
66
|
+
// scanning until the first future event and would otherwise mis-apply an
|
|
67
|
+
// out-of-order event on a seek. Stable sort keeps equal-time order.
|
|
68
|
+
const events = (Array.isArray(trial.events) ? trial.events : [])
|
|
69
|
+
.filter((e) => e && typeof e === 'object')
|
|
70
|
+
.slice()
|
|
71
|
+
.sort((a, b) => (Number(a.t) || 0) - (Number(b.t) || 0));
|
|
72
|
+
const anchor = trial.t_load != null ? trial.t_load
|
|
73
|
+
: (events.length > 0 ? events[0].t : 0);
|
|
74
|
+
const lastT = events.length > 0 ? events[events.length - 1].t : anchor;
|
|
75
|
+
const end = trial.t_end != null ? trial.t_end : lastT;
|
|
76
|
+
// Camera seed for THIS trial: recorded view_state, else the folded state
|
|
77
|
+
// as of the end of the previous trial. A recorded view_state also
|
|
78
|
+
// RESYNCS the fold — in a mixed recording (some trials seeded, some
|
|
79
|
+
// not: truncation, version mixes) a later unseeded trial must inherit
|
|
80
|
+
// real observed state, not a fold that ignored every observation.
|
|
81
|
+
if (trial.view_state) foldState = Object.assign({}, foldState, trial.view_state);
|
|
82
|
+
const camera = trial.view_state
|
|
83
|
+
? Object.assign({}, trial.view_state, { source: 'view_state' })
|
|
84
|
+
: Object.assign({}, foldState, { source: 'folded' });
|
|
85
|
+
// Advance the fold across this trial's events for the NEXT trial's seed.
|
|
86
|
+
events.forEach((e) => foldEvent(foldState, e));
|
|
87
|
+
return {
|
|
88
|
+
index: trial.trial_index,
|
|
89
|
+
id: trial.trial_id,
|
|
90
|
+
plugin: trial.plugin,
|
|
91
|
+
durMs: Math.max(0, Math.round((end - anchor) * 10) / 10),
|
|
92
|
+
initialDom: trial.initial_dom || '',
|
|
93
|
+
camera,
|
|
94
|
+
events: events.map((e) => {
|
|
95
|
+
const out = Object.assign({}, e);
|
|
96
|
+
out.t = Math.max(0, Math.round(((Number(e.t) || 0) - anchor) * 10) / 10);
|
|
97
|
+
return out;
|
|
98
|
+
})
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
pid: md.participant_id != null ? String(md.participant_id) : 'unknown',
|
|
104
|
+
tier: md.tier || 'trace',
|
|
105
|
+
keys: md.keys || null,
|
|
106
|
+
startTime: md.start_time || null,
|
|
107
|
+
endReason: md.end_reason || null,
|
|
108
|
+
recorder: md.recorder || null,
|
|
109
|
+
viewport: recording.viewport || null,
|
|
110
|
+
// Legacy = ANY trial lacks a view_state seed (all-legacy recordings and
|
|
111
|
+
// mixed/truncated ones alike): those trials replay on folded camera
|
|
112
|
+
// state, so the reduced-guarantees banner must show.
|
|
113
|
+
legacy: !rawTrials.every((t) => t && t.view_state),
|
|
114
|
+
markerAttr: ext.marker_attr || null,
|
|
115
|
+
// Session scrollbar delta (innerWidth − layout width): the viewer uses
|
|
116
|
+
// the same fallback chain as the folding above for legacy resize events.
|
|
117
|
+
scrollbar: { w: sbW, h: sbH },
|
|
118
|
+
stylesheets: (recording.stylesheets && recording.stylesheets.initial) || [],
|
|
119
|
+
scoring: ext.scoring || null,
|
|
120
|
+
guardViolations: ext.guard_violations || [],
|
|
121
|
+
captureStopped: !!ext.capture_stopped,
|
|
122
|
+
captureFailures: (ext.capture_failures || []).map((f) => f.channel),
|
|
123
|
+
trials
|
|
124
|
+
};
|
|
125
|
+
}
|
package/src/shared/constants.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Default thresholds and preset configurations for Cyborg Hunter.
|
|
3
3
|
// Single source of truth — used by both the browser library and CLI report tool.
|
|
4
4
|
|
|
5
|
-
export const VERSION = "0.
|
|
5
|
+
export const VERSION = "0.7.2";
|
|
6
6
|
|
|
7
7
|
// Default detection thresholds shared across presets.
|
|
8
8
|
// Researchers can override any value at init() time.
|
|
@@ -11,6 +11,7 @@ export const DEFAULT_THRESHOLDS = {
|
|
|
11
11
|
dropMinChars: 0, // record ALL drops
|
|
12
12
|
sidebarGapPx: 100, // outerWidth - innerWidth above this = sidebar
|
|
13
13
|
layoutCompressionPx: 20, // innerWidth - clientWidth above scrollbar width
|
|
14
|
+
viewportShiftDebounceMs: 250, // quiet period before a viewport-width shift is logged (one gesture = one event)
|
|
14
15
|
syntheticGapMs: 100, // keydown-to-input gap above this = synthetic insertion
|
|
15
16
|
idleGapMs: 10000, // no input for this long = idle gap
|
|
16
17
|
idleCheckIntervalMs: 5000, // how often to check for idle gaps
|
|
@@ -37,7 +38,7 @@ export const PRESETS = {
|
|
|
37
38
|
permissive: {
|
|
38
39
|
// Collect everything, screen out nobody. For calibration/pilot studies.
|
|
39
40
|
signals: {
|
|
40
|
-
paste: true, copy: true, tabAway: true, typingSpeed: true,
|
|
41
|
+
paste: true, copy: true, drop: true, tabAway: true, typingSpeed: true,
|
|
41
42
|
devTools: true, aiExtensions: true, sidebarGap: true,
|
|
42
43
|
keyboardShortcuts: true, mouseTracking: true, idleGaps: true,
|
|
43
44
|
windowPosition: true, clipboardManager: true,
|
|
@@ -66,7 +67,7 @@ export const PRESETS = {
|
|
|
66
67
|
standard: {
|
|
67
68
|
// Balanced detection. Default for most studies.
|
|
68
69
|
signals: {
|
|
69
|
-
paste: true, copy: true, tabAway: true, typingSpeed: true,
|
|
70
|
+
paste: true, copy: true, drop: true, tabAway: true, typingSpeed: true,
|
|
70
71
|
devTools: true, aiExtensions: true, sidebarGap: true,
|
|
71
72
|
keyboardShortcuts: true, mouseTracking: true, idleGaps: true,
|
|
72
73
|
windowPosition: true, clipboardManager: true,
|
|
@@ -93,7 +94,7 @@ export const PRESETS = {
|
|
|
93
94
|
strict: {
|
|
94
95
|
// Low thresholds, all signals. For high-stakes studies.
|
|
95
96
|
signals: {
|
|
96
|
-
paste: true, copy: true, tabAway: true, typingSpeed: true,
|
|
97
|
+
paste: true, copy: true, drop: true, tabAway: true, typingSpeed: true,
|
|
97
98
|
devTools: true, aiExtensions: true, sidebarGap: true,
|
|
98
99
|
keyboardShortcuts: true, mouseTracking: true, idleGaps: true,
|
|
99
100
|
windowPosition: true, clipboardManager: true,
|
|
@@ -158,5 +159,10 @@ export const BENIGN_TAGS = [
|
|
|
158
159
|
"dashlane", "roboform", "honey", "ublock", "adblock"
|
|
159
160
|
];
|
|
160
161
|
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
|
|
163
|
+
// Filesystem/CSS-safe participant-id sanitizer. Single source of truth for
|
|
164
|
+
// every artifact filename (browser persistence, CLI ingest, report assets)
|
|
165
|
+
// — divergent copies caused a live fallback-path mismatch (autoreview 0.7.0).
|
|
166
|
+
export function sanitizeId(id) {
|
|
167
|
+
return String(id).replace(/[^a-zA-Z0-9_.-]/g, '_');
|
|
168
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/shared/paths.js
|
|
2
|
+
// Pure path-walking helper. No Node APIs — shared by CLI and browser preview.
|
|
3
|
+
// Used by: CLI ingest/renderers and the browser preview bundle.
|
|
4
|
+
|
|
5
|
+
// Resolves a possibly-dotted field path against an object (0.6.1).
|
|
6
|
+
// A flat key wins over a dotted walk, so data that literally contains a
|
|
7
|
+
// "metadata.sessionId" column stays addressable; otherwise the path is
|
|
8
|
+
// walked one segment at a time. Returns undefined when any segment is
|
|
9
|
+
// missing or a non-object is hit mid-path.
|
|
10
|
+
export function getByPath(obj, path) {
|
|
11
|
+
if (obj == null || typeof path !== 'string' || path.length === 0) return undefined;
|
|
12
|
+
if (Object.prototype.hasOwnProperty.call(obj, path)) return obj[path];
|
|
13
|
+
if (!path.includes('.')) return undefined;
|
|
14
|
+
let cur = obj;
|
|
15
|
+
for (const seg of path.split('.')) {
|
|
16
|
+
if (cur == null || typeof cur !== 'object') return undefined;
|
|
17
|
+
cur = cur[seg];
|
|
18
|
+
}
|
|
19
|
+
return cur;
|
|
20
|
+
}
|
package/src/shared/schema.js
CHANGED
|
@@ -33,13 +33,16 @@ export const TRIAL_REPORT_FIELDS = {
|
|
|
33
33
|
// All fields have sensible defaults — minimal config is just dataDir + filePattern.
|
|
34
34
|
export const DEFAULT_CLI_CONFIG = {
|
|
35
35
|
dataDir: "./data",
|
|
36
|
+
replayDir: null, // replay artifacts dir; defaults to dataDir
|
|
36
37
|
filePattern: "*.json",
|
|
37
38
|
participantIdField: "participantId",
|
|
38
39
|
trialIdField: "trialId",
|
|
39
40
|
trialOrderField: "trialIndex",
|
|
40
41
|
integrityField: "integrity",
|
|
42
|
+
sessionIntegrityPath: null,
|
|
41
43
|
trialsPerParticipant: null,
|
|
42
44
|
platformIdField: null,
|
|
45
|
+
showPlatformId: false,
|
|
43
46
|
conditionField: null,
|
|
44
47
|
groupField: null,
|
|
45
48
|
typingSpeedThreshold_cps: 10,
|
|
@@ -48,9 +51,11 @@ export const DEFAULT_CLI_CONFIG = {
|
|
|
48
51
|
suspiciouslyFastRT_ms: 2000,
|
|
49
52
|
scoring: null, // uses library defaults
|
|
50
53
|
signals: null, // all enabled
|
|
54
|
+
phaseScope: null, // {include: [...], exclude: [...]} — scope scores to phases
|
|
51
55
|
trajectoryGrid: "auto",
|
|
52
56
|
trajectoryTrialLabel: "trialId",
|
|
53
57
|
trajectoryResponseField: null,
|
|
58
|
+
trajectoryDisplayOrder: "rule",
|
|
54
59
|
outputDir: "./cyborg-hunter-report",
|
|
55
60
|
outputFormat: "html"
|
|
56
61
|
};
|
package/src/shared/validation.js
CHANGED
|
@@ -8,6 +8,7 @@ import { DEFAULT_THRESHOLDS } from './constants.js';
|
|
|
8
8
|
// All recognized config keys across both browser library and CLI.
|
|
9
9
|
// Unknown keys trigger a warning with the closest match suggestion.
|
|
10
10
|
const ALL_KNOWN_KEYS = [
|
|
11
|
+
'replayDir',
|
|
11
12
|
...Object.keys(DEFAULT_CLI_CONFIG),
|
|
12
13
|
...Object.keys(DEFAULT_THRESHOLDS),
|
|
13
14
|
'preset', 'participantId', 'signals', 'thresholds', 'scoring',
|
|
@@ -36,6 +37,60 @@ export function validateConfig(config, knownKeys = ALL_KNOWN_KEYS) {
|
|
|
36
37
|
return warnings;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Validates the SHAPE of a merged scoring config, after preset+user overrides.
|
|
42
|
+
* Returns warning strings for rules whose required numeric field is missing.
|
|
43
|
+
*
|
|
44
|
+
* Why this exists: scoring overrides replace each rule object wholesale
|
|
45
|
+
* (Object.assign at the rule level), and validateConfig() only inspects
|
|
46
|
+
* top-level keys. So a nested typo like `{hard:{paste:{countThreshhold:2}}}`
|
|
47
|
+
* drops the real `countThreshold`, and `count >= undefined` is always false —
|
|
48
|
+
* silently disabling a hard screenout rule with no warning. This catches that
|
|
49
|
+
* class of misconfiguration: every hard rule needs a numeric countThreshold,
|
|
50
|
+
* every soft rule a numeric weight. Valid overrides produce no warnings.
|
|
51
|
+
*/
|
|
52
|
+
export function validateScoringShape(scoring) {
|
|
53
|
+
const warnings = [];
|
|
54
|
+
if (!scoring || typeof scoring !== 'object') return warnings;
|
|
55
|
+
// Number.isFinite (not typeof === 'number') so NaN and ±Infinity are also
|
|
56
|
+
// flagged: typeof NaN === 'number', yet `count >= NaN` is always false (hard
|
|
57
|
+
// rule silently off) and a NaN/Infinity weight breaks the soft score.
|
|
58
|
+
// safeStr() is used in the message so a non-stringifiable value (e.g. a
|
|
59
|
+
// Symbol, which template interpolation would throw on) still warns rather than
|
|
60
|
+
// crashing the very validation meant to surface the misconfiguration.
|
|
61
|
+
const hard = scoring.hard || {};
|
|
62
|
+
Object.keys(hard).forEach(key => {
|
|
63
|
+
const rule = hard[key];
|
|
64
|
+
if (!rule || !Number.isFinite(rule.countThreshold)) {
|
|
65
|
+
warnings.push(
|
|
66
|
+
`scoring.hard.${key} has no finite numeric countThreshold (got ` +
|
|
67
|
+
`${rule ? safeStr(rule.countThreshold) : 'no rule'}) — this hard ` +
|
|
68
|
+
`screenout rule can never trigger. A likely cause is a typo in the ` +
|
|
69
|
+
`override key (e.g. "countThreshhold").`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
const soft = scoring.soft || {};
|
|
74
|
+
Object.keys(soft).forEach(key => {
|
|
75
|
+
const rule = soft[key];
|
|
76
|
+
if (!rule || !Number.isFinite(rule.weight)) {
|
|
77
|
+
warnings.push(
|
|
78
|
+
`scoring.soft.${key} has no finite numeric weight (got ` +
|
|
79
|
+
`${rule ? safeStr(rule.weight) : 'no rule'}) — this soft signal ` +
|
|
80
|
+
`contributes nothing (or breaks the soft score). A likely cause is a ` +
|
|
81
|
+
`typo in the override key (e.g. "wieght").`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return warnings;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Coerce a value to a string for a warning message without ever throwing.
|
|
89
|
+
// String(Symbol()) throws; falling back to the typeof keeps the warning alive.
|
|
90
|
+
function safeStr(v) {
|
|
91
|
+
try { return String(v); } catch (e) { return typeof v; }
|
|
92
|
+
}
|
|
93
|
+
|
|
39
94
|
/**
|
|
40
95
|
* Finds the closest matching key using Levenshtein distance.
|
|
41
96
|
* Returns null if no key is within edit distance 3 (too different to suggest).
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
// src/cli/renderers/tab-timeline.js
|
|
2
|
-
// Renders tab-away timeline images using node-canvas.
|
|
3
|
-
// Produces one PNG per participant showing when tab-aways occurred
|
|
4
|
-
// relative to the experiment timeline.
|
|
5
|
-
//
|
|
6
|
-
// Layout: horizontal bar chart where X-axis = experiment time,
|
|
7
|
-
// each row = a trial, bars = tab-away events colored by duration.
|
|
8
|
-
// Bins match the analyzer split: flicker (<3s), medium (3-10s), long (≥10s).
|
|
9
|
-
// The 3s boundary is the scoring engine's soft-score cutoff.
|
|
10
|
-
|
|
11
|
-
import { writeFileSync } from 'fs';
|
|
12
|
-
import { join } from 'path';
|
|
13
|
-
|
|
14
|
-
const ROW_HEIGHT = 25;
|
|
15
|
-
const LABEL_WIDTH = 120;
|
|
16
|
-
const CHART_WIDTH = 600;
|
|
17
|
-
const TOP_PAD = 50;
|
|
18
|
-
const BOTTOM_PAD = 30;
|
|
19
|
-
const LEFT_PAD = 10;
|
|
20
|
-
|
|
21
|
-
export async function renderTabTimelines(participants, config) {
|
|
22
|
-
const { createCanvas } = await import('canvas');
|
|
23
|
-
|
|
24
|
-
for (const p of participants) {
|
|
25
|
-
const trials = p.trials;
|
|
26
|
-
if (trials.length === 0) continue;
|
|
27
|
-
|
|
28
|
-
// Check if there are any tab-away events to render
|
|
29
|
-
const totalTabAways = trials.reduce((s, t) => s + (t.tabAwayEvents || []).length, 0);
|
|
30
|
-
if (totalTabAways === 0) continue;
|
|
31
|
-
|
|
32
|
-
const canvasH = TOP_PAD + trials.length * ROW_HEIGHT + BOTTOM_PAD;
|
|
33
|
-
const canvasW = LEFT_PAD + LABEL_WIDTH + CHART_WIDTH + 20;
|
|
34
|
-
const canvas = createCanvas(canvasW, canvasH);
|
|
35
|
-
const ctx = canvas.getContext('2d');
|
|
36
|
-
|
|
37
|
-
// Background
|
|
38
|
-
ctx.fillStyle = '#ffffff';
|
|
39
|
-
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
40
|
-
|
|
41
|
-
// Title
|
|
42
|
-
ctx.fillStyle = '#333';
|
|
43
|
-
ctx.font = 'bold 14px sans-serif';
|
|
44
|
-
ctx.fillText(`Tab-Away Timeline — ${p.participantId}`, LEFT_PAD, 20);
|
|
45
|
-
ctx.font = '10px sans-serif';
|
|
46
|
-
ctx.fillStyle = '#666';
|
|
47
|
-
ctx.fillText(`${totalTabAways} tab-away events across ${trials.length} trials`, LEFT_PAD, 35);
|
|
48
|
-
|
|
49
|
-
// Legend (right-aligned under title)
|
|
50
|
-
const legend = [
|
|
51
|
-
{ label: 'trial duration', color: '#f0f0f0', border: '#ccc' },
|
|
52
|
-
{ label: '<3s flicker', color: '#bdbdbd', border: '#333' },
|
|
53
|
-
{ label: '3–10s', color: '#ff9800', border: '#333' },
|
|
54
|
-
{ label: '≥10s', color: '#f44336', border: '#333' },
|
|
55
|
-
];
|
|
56
|
-
let lx = canvasW - 10;
|
|
57
|
-
ctx.font = '9px sans-serif';
|
|
58
|
-
// Draw legend right-to-left
|
|
59
|
-
for (let k = legend.length - 1; k >= 0; k--) {
|
|
60
|
-
const item = legend[k];
|
|
61
|
-
const textW = ctx.measureText(item.label).width;
|
|
62
|
-
lx -= textW;
|
|
63
|
-
ctx.fillStyle = '#333';
|
|
64
|
-
ctx.fillText(item.label, lx, 22);
|
|
65
|
-
lx -= 4;
|
|
66
|
-
ctx.fillStyle = item.color;
|
|
67
|
-
ctx.fillRect(lx - 10, 15, 10, 8);
|
|
68
|
-
ctx.strokeStyle = item.border;
|
|
69
|
-
ctx.lineWidth = 0.5;
|
|
70
|
-
ctx.strokeRect(lx - 10, 15, 10, 8);
|
|
71
|
-
lx -= 14;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Find the maximum trial duration for X-axis scaling
|
|
75
|
-
const maxDuration = Math.max(...trials.map(t =>
|
|
76
|
-
(t.duration_ms || t.responseTime_ms || 30000)));
|
|
77
|
-
const xScale = CHART_WIDTH / maxDuration;
|
|
78
|
-
|
|
79
|
-
for (let i = 0; i < trials.length; i++) {
|
|
80
|
-
const trial = trials[i];
|
|
81
|
-
const y = TOP_PAD + i * ROW_HEIGHT;
|
|
82
|
-
const trialId = trial.trialId || trial.ruleId || `T${i + 1}`;
|
|
83
|
-
const tabAways = trial.tabAwayEvents || [];
|
|
84
|
-
|
|
85
|
-
// Row label
|
|
86
|
-
ctx.fillStyle = '#333';
|
|
87
|
-
ctx.font = '10px sans-serif';
|
|
88
|
-
ctx.fillText(trialId.substring(0, 16), LEFT_PAD, y + ROW_HEIGHT / 2 + 3);
|
|
89
|
-
|
|
90
|
-
// Trial duration background (light grey bar)
|
|
91
|
-
const trialDur = trial.duration_ms || trial.responseTime_ms || 30000;
|
|
92
|
-
const barX = LEFT_PAD + LABEL_WIDTH;
|
|
93
|
-
ctx.fillStyle = '#f0f0f0';
|
|
94
|
-
ctx.fillRect(barX, y + 3, trialDur * xScale, ROW_HEIGHT - 6);
|
|
95
|
-
|
|
96
|
-
// Tab-away bars
|
|
97
|
-
for (const ta of tabAways) {
|
|
98
|
-
// Use trial-relative timestamp (normalized in ingest). Fall back to
|
|
99
|
-
// raw `start` only if normalization was unavailable; this will look
|
|
100
|
-
// wrong (off-canvas) but at least won't crash.
|
|
101
|
-
const start = (ta.startRel_ms != null) ? ta.startRel_ms : (ta.start || 0);
|
|
102
|
-
const dur = ta.duration_ms || 0;
|
|
103
|
-
// Skip tab-aways that fall outside the trial window — they likely
|
|
104
|
-
// occurred between trials (inter-trial pause) and aren't meaningful here.
|
|
105
|
-
if (start < -1000 || start > trialDur + 1000) continue;
|
|
106
|
-
const clamped = Math.max(0, start);
|
|
107
|
-
const barStart = barX + clamped * xScale;
|
|
108
|
-
const barWidth = Math.max(2, dur * xScale);
|
|
109
|
-
|
|
110
|
-
// Color by duration: flicker (<3s) = grey, medium (3-10s) = orange, long (≥10s) = red.
|
|
111
|
-
// Matches summary analyzer bins and the scoring engine's 3s cutoff.
|
|
112
|
-
const durSec = dur / 1000;
|
|
113
|
-
ctx.fillStyle = durSec < 3 ? '#bdbdbd' : durSec < 10 ? '#ff9800' : '#f44336';
|
|
114
|
-
ctx.fillRect(barStart, y + 5, barWidth, ROW_HEIGHT - 10);
|
|
115
|
-
ctx.strokeStyle = '#333';
|
|
116
|
-
ctx.lineWidth = 0.5;
|
|
117
|
-
ctx.strokeRect(barStart, y + 5, barWidth, ROW_HEIGHT - 10);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Row separator
|
|
121
|
-
ctx.strokeStyle = '#e0e0e0';
|
|
122
|
-
ctx.lineWidth = 0.5;
|
|
123
|
-
ctx.beginPath();
|
|
124
|
-
ctx.moveTo(LEFT_PAD, y + ROW_HEIGHT);
|
|
125
|
-
ctx.lineTo(canvasW - 10, y + ROW_HEIGHT);
|
|
126
|
-
ctx.stroke();
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// X-axis labels
|
|
130
|
-
ctx.fillStyle = '#666';
|
|
131
|
-
ctx.font = '9px sans-serif';
|
|
132
|
-
const tickCount = 5;
|
|
133
|
-
for (let t = 0; t <= tickCount; t++) {
|
|
134
|
-
const time = (maxDuration / tickCount) * t;
|
|
135
|
-
const x = LEFT_PAD + LABEL_WIDTH + time * xScale;
|
|
136
|
-
ctx.fillText(`${(time / 1000).toFixed(0)}s`, x, canvasH - 10);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const buf = canvas.toBuffer('image/png');
|
|
140
|
-
const filename = `tab_timeline_${sanitize(p.participantId)}.png`;
|
|
141
|
-
writeFileSync(join(config.outputDir, 'images', filename), buf);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
console.log(` tab timelines — rendered`);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function sanitize(name) {
|
|
148
|
-
return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
|
|
149
|
-
}
|