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,907 @@
|
|
|
1
|
+
// src/cli/renderers/session-timeline-core.js
|
|
2
|
+
//
|
|
3
|
+
// Pure drawing core for per-participant SESSION timeline images — no Node
|
|
4
|
+
// APIs, so a browser demo can bundle it directly (0.7.2-style extraction
|
|
5
|
+
// from cli/renderers/session-timeline.js, which is now a thin fs wrapper
|
|
6
|
+
// around this module: it acquires node-canvas, calls drawSessionTimeline,
|
|
7
|
+
// and writes the returned canvas to a PNG).
|
|
8
|
+
//
|
|
9
|
+
// drawSessionTimeline(p, config, createCanvas) draws every recorded
|
|
10
|
+
// integrity signal for one participant along a single shared time axis from
|
|
11
|
+
// session start to session end, and returns the drawn canvas (or null if
|
|
12
|
+
// there's nothing to draw). createCanvas is injected so this module never
|
|
13
|
+
// imports the `canvas` package directly — a browser caller can pass a
|
|
14
|
+
// document-canvas-backed factory instead.
|
|
15
|
+
//
|
|
16
|
+
// Why this replaces tab-timeline.js:
|
|
17
|
+
// The old tab-timeline.js used per-trial rows on the chart. That layout
|
|
18
|
+
// only made sense when every integrity event landed in a per-trial bucket.
|
|
19
|
+
// For studies whose sessions are punctuated by long study phases (galleries,
|
|
20
|
+
// typing prompts, comprehension gates, demographics, etc.) where most off-
|
|
21
|
+
// task behavior actually happens, per-trial-row charts hide most of what
|
|
22
|
+
// matters and produce blank charts for participants whose tab-aways landed
|
|
23
|
+
// between trials.
|
|
24
|
+
//
|
|
25
|
+
// Layout:
|
|
26
|
+
// Title strip
|
|
27
|
+
// ─────────────────────────────────────────────
|
|
28
|
+
// Phase strip [consent | tut | comp | gallery+class+typing per rule]
|
|
29
|
+
// Tab-aways lane | vertical markers, colored by duration bin
|
|
30
|
+
// Sidebars lane | horizontal spans, opened → closed
|
|
31
|
+
// Layout shifts | thin vertical ticks
|
|
32
|
+
// Guard-friction | vertical markers, dark red
|
|
33
|
+
// ─────────────────────────────────────────────
|
|
34
|
+
// X-axis ticks 0, M:SS labels
|
|
35
|
+
// Footer session-level counts + "off-trial tab-aways" note
|
|
36
|
+
//
|
|
37
|
+
// X-axis unit: seconds elapsed since session start. Events recorded in
|
|
38
|
+
// performance.now() ms ("perfNow") are converted to session-relative seconds
|
|
39
|
+
// using a participant-specific sessionOffset estimator (see deriveSessionOffset
|
|
40
|
+
// below). For participants where the offset can't be estimated, the fallback
|
|
41
|
+
// is sessionOffset = 0 (assume page load == session start), which produces a
|
|
42
|
+
// small visualization drift (typically 10-60s, equal to the consent rendering
|
|
43
|
+
// + first click time). The drift only affects the X position of perfNow-based
|
|
44
|
+
// markers (sidebars, layout shifts, guard-friction); wall-clock-derived phase
|
|
45
|
+
// bands remain accurate.
|
|
46
|
+
//
|
|
47
|
+
// Edge cases handled gracefully (no crash, no silent failure):
|
|
48
|
+
// * participant.session missing → render phase strip only, no integrity lanes
|
|
49
|
+
// * trials missing → render integrity lanes only, no phase strip
|
|
50
|
+
// * sidebar opened but never closed → render as unclosed span to session end
|
|
51
|
+
// * malformed event objects → skipped with a debug log, render continues
|
|
52
|
+
// * zero events of all kinds → render an "no integrity events" callout
|
|
53
|
+
//
|
|
54
|
+
// Backward compatibility:
|
|
55
|
+
// * Works on payloads from any cyborg-hunter library version. Older payloads
|
|
56
|
+
// without session-level event data simply lose those lanes (rendered empty).
|
|
57
|
+
|
|
58
|
+
import { countSidebarOpenings } from '../analyzers/summary.js';
|
|
59
|
+
|
|
60
|
+
// ── Layout constants ─────────────────────────────────────────────────────
|
|
61
|
+
const CANVAS_W = 1000;
|
|
62
|
+
const LEFT_PAD = 90;
|
|
63
|
+
const RIGHT_PAD = 30;
|
|
64
|
+
const TOP_PAD = 65;
|
|
65
|
+
const BOTTOM_PAD = 110;
|
|
66
|
+
|
|
67
|
+
const LANE_HEIGHT = 32;
|
|
68
|
+
const LANE_GAP = 6;
|
|
69
|
+
const LANE_NAMES = ['Tab-aways', 'Sidebars', 'Viewport shifts', 'Guard-friction'];
|
|
70
|
+
const N_LANES = LANE_NAMES.length;
|
|
71
|
+
const PHASE_STRIP_H = 24;
|
|
72
|
+
|
|
73
|
+
// ── Colors ───────────────────────────────────────────────────────────────
|
|
74
|
+
const C = {
|
|
75
|
+
bg: '#ffffff',
|
|
76
|
+
title: '#222',
|
|
77
|
+
subtitle: '#666',
|
|
78
|
+
border: '#999',
|
|
79
|
+
laneBg: '#fafafa',
|
|
80
|
+
laneBorder: '#e0e0e0',
|
|
81
|
+
laneLabel: '#444',
|
|
82
|
+
xAxis: '#666',
|
|
83
|
+
xTick: '#aaa',
|
|
84
|
+
|
|
85
|
+
// Phase bands
|
|
86
|
+
phasePre: '#eceff1', // bluish grey — framing (consent/tut/comp/transitions/post)
|
|
87
|
+
phaseGallery: '#ffe0b2', // peach — gallery viewing
|
|
88
|
+
phaseTyping: '#e1bee7', // light purple — typing prompt (explain only)
|
|
89
|
+
phaseClass: '#bbdefb', // light blue — classification trials
|
|
90
|
+
phasePost: '#eceff1', // same as phasePre — same logical class
|
|
91
|
+
|
|
92
|
+
// Tab-away bins (match analyzer)
|
|
93
|
+
tabFlicker: '#bdbdbd', // < 3s — grey
|
|
94
|
+
tabMedium: '#fb8c00', // 3–10s — orange
|
|
95
|
+
tabLong: '#e53935', // ≥ 10s — red
|
|
96
|
+
|
|
97
|
+
sidebarSpan: '#8e24aa', // purple
|
|
98
|
+
layoutShift: '#00897b', // teal
|
|
99
|
+
guardViolation: '#c62828', // dark red
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ── Per-participant renderer ─────────────────────────────────────────────
|
|
103
|
+
// Returns the drawn canvas, or null if there's no data anywhere to draw
|
|
104
|
+
// (caller decides what "no data" means — the fs wrapper skips the PNG write).
|
|
105
|
+
async function renderOne(p, config, createCanvas) {
|
|
106
|
+
const events = collectEvents(p, config);
|
|
107
|
+
const phases = derivePhases(p);
|
|
108
|
+
const xDomainSec = computeXDomain(p, events, phases);
|
|
109
|
+
|
|
110
|
+
if (xDomainSec <= 0) {
|
|
111
|
+
// No data anywhere. Skip silently.
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const canvasH = TOP_PAD + PHASE_STRIP_H + LANE_GAP + N_LANES * (LANE_HEIGHT + LANE_GAP) + BOTTOM_PAD;
|
|
116
|
+
const canvas = createCanvas(CANVAS_W, canvasH);
|
|
117
|
+
const ctx = canvas.getContext('2d');
|
|
118
|
+
|
|
119
|
+
// Background
|
|
120
|
+
ctx.fillStyle = C.bg;
|
|
121
|
+
ctx.fillRect(0, 0, CANVAS_W, canvasH);
|
|
122
|
+
|
|
123
|
+
drawHeader(ctx, p, events);
|
|
124
|
+
|
|
125
|
+
const chartW = CANVAS_W - LEFT_PAD - RIGHT_PAD;
|
|
126
|
+
const xScale = chartW / xDomainSec;
|
|
127
|
+
|
|
128
|
+
drawPhaseStrip(ctx, phases, xScale, xDomainSec, chartW);
|
|
129
|
+
drawLanes(ctx, events, xScale, xDomainSec, chartW);
|
|
130
|
+
// Dashed vertical guides at every rule boundary (= start of each gallery
|
|
131
|
+
// phase). Drawn AFTER lanes so the markers sit on top of event bars and
|
|
132
|
+
// it's easy to see "this event happened during rule N" by tracing the
|
|
133
|
+
// line down through the lanes.
|
|
134
|
+
const lanesTopY = TOP_PAD + PHASE_STRIP_H + LANE_GAP;
|
|
135
|
+
const lanesBottomY = lanesTopY + N_LANES * (LANE_HEIGHT + LANE_GAP) - LANE_GAP;
|
|
136
|
+
drawPhaseGuides(ctx, phases, xScale, xDomainSec, lanesTopY, lanesBottomY);
|
|
137
|
+
drawXAxis(ctx, xDomainSec, chartW, canvasH);
|
|
138
|
+
drawLegendAndFooter(ctx, p, events, canvasH);
|
|
139
|
+
|
|
140
|
+
return canvas;
|
|
141
|
+
}
|
|
142
|
+
export { renderOne as drawSessionTimeline };
|
|
143
|
+
|
|
144
|
+
// ── Event collection ─────────────────────────────────────────────────────
|
|
145
|
+
// All events are normalized to a `t_sec` field (session-relative seconds)
|
|
146
|
+
// using the participant's sessionOffset (see computeXDomain).
|
|
147
|
+
export function collectEvents(p, config) {
|
|
148
|
+
const session = p.session || {};
|
|
149
|
+
|
|
150
|
+
// 1. Tab-away events. Libraries ≥0.6.1 record session-level
|
|
151
|
+
// session.tabAwayEvents — full {start, duration_ms, type} for EVERY
|
|
152
|
+
// tab-away, including off-trial ones (consent, tutorial, comprehension,
|
|
153
|
+
// gallery study) — so those are preferred and everything plots. Older
|
|
154
|
+
// payloads fall back to per-trial events; their off-trial tab-aways
|
|
155
|
+
// exist only as durations in tabAwaySums and can't be placed on the
|
|
156
|
+
// axis (the footer says so).
|
|
157
|
+
const tabAways = [];
|
|
158
|
+
const sessionTabEvents = Array.isArray(session.tabAwayEvents) ? session.tabAwayEvents : null;
|
|
159
|
+
const tabAwaySource = (sessionTabEvents && sessionTabEvents.length > 0) ? 'session' : 'per-trial';
|
|
160
|
+
if (tabAwaySource === 'session') {
|
|
161
|
+
for (const e of sessionTabEvents) {
|
|
162
|
+
if (!e || typeof e !== 'object') continue;
|
|
163
|
+
if (typeof e.start !== 'number') continue;
|
|
164
|
+
if (typeof e.duration_ms !== 'number') continue;
|
|
165
|
+
tabAways.push({
|
|
166
|
+
t_perfNow_ms: e.start,
|
|
167
|
+
duration_ms: e.duration_ms,
|
|
168
|
+
type: e.type || 'tab-away',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
for (const t of (p.trials || [])) {
|
|
173
|
+
// After ingest, events live at t.tabAwayEvents (top-level on the
|
|
174
|
+
// post-ingest trial). The raw payload nests them under integrityTrial,
|
|
175
|
+
// but Shape-1 ingest spreads that sub-object out so the field surfaces
|
|
176
|
+
// at the top. Fall back to the nested path for any callers that bypass
|
|
177
|
+
// the ingest layer.
|
|
178
|
+
const ev = (Array.isArray(t?.tabAwayEvents) && t.tabAwayEvents)
|
|
179
|
+
|| (Array.isArray(t?.integrityTrial?.tabAwayEvents) && t.integrityTrial.tabAwayEvents)
|
|
180
|
+
|| [];
|
|
181
|
+
for (const e of ev) {
|
|
182
|
+
if (!e || typeof e !== 'object') continue;
|
|
183
|
+
if (typeof e.start !== 'number') continue;
|
|
184
|
+
if (typeof e.duration_ms !== 'number') continue;
|
|
185
|
+
tabAways.push({
|
|
186
|
+
t_perfNow_ms: e.start,
|
|
187
|
+
duration_ms: e.duration_ms,
|
|
188
|
+
type: e.type || 'tab-away',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. Session-level tab-away durations (no individual timestamps).
|
|
195
|
+
// Used only for the off-trial-count annotation in the footer.
|
|
196
|
+
const sessionTabDurations = Array.isArray(session.tabAwaySums) ? session.tabAwaySums : [];
|
|
197
|
+
const offTrialTabAwayCount = Math.max(0, sessionTabDurations.length - tabAways.length);
|
|
198
|
+
const offTrialTabAwayDurMs = sessionTabDurations.reduce((s, d) => s + (Number(d) || 0), 0)
|
|
199
|
+
- tabAways.reduce((s, e) => s + e.duration_ms, 0);
|
|
200
|
+
|
|
201
|
+
// 3. Sidebar spans (paired opened → closed entries; library records each as
|
|
202
|
+
// a separate array entry, both stamped with `t` perfNow ms).
|
|
203
|
+
const sidebarSpans = pairSidebarEvents(session.sidebarEvents || []);
|
|
204
|
+
|
|
205
|
+
// 4. Viewport-width shifts — each has `t` perfNow ms. Canonical key since
|
|
206
|
+
// 0.6.1 is viewportWidthShifts; layoutShifts is the deprecated alias
|
|
207
|
+
// older payloads carry.
|
|
208
|
+
const layoutShifts = (session.viewportWidthShifts || session.layoutShifts || [])
|
|
209
|
+
.filter(e => e && typeof e.t === 'number')
|
|
210
|
+
.map(e => ({ t_perfNow_ms: e.t, delta: e.delta }));
|
|
211
|
+
|
|
212
|
+
// 5. Guard-friction violations. The ingest surfaces these as p.guardFriction;
|
|
213
|
+
// fall back to metadata/session mirrors for legacy payloads.
|
|
214
|
+
const gfRoot = p.guardFriction ?? p.metadata?.guardFriction ?? p.session?.guardFriction ?? null;
|
|
215
|
+
const guardViolations = (gfRoot?.violations || [])
|
|
216
|
+
.filter(v => v && typeof v.t === 'number')
|
|
217
|
+
.map(v => ({
|
|
218
|
+
t_perfNow_ms: v.t,
|
|
219
|
+
phase: v.phase || 'unknown',
|
|
220
|
+
reason: v.reason || 'unknown',
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
tabAways,
|
|
225
|
+
tabAwaySource,
|
|
226
|
+
sessionTabDurations,
|
|
227
|
+
offTrialTabAwayCount,
|
|
228
|
+
offTrialTabAwayDurMs,
|
|
229
|
+
sidebarSpans,
|
|
230
|
+
layoutShifts,
|
|
231
|
+
guardViolations,
|
|
232
|
+
// Per-participant tab-away "flicker vs meaningful" cutoff — the threshold the
|
|
233
|
+
// library screened this participant with (e.g. 5s for strict), saved in
|
|
234
|
+
// session.config. Keeps the timeline's bins and footer consistent with
|
|
235
|
+
// summary.csv instead of a hardcoded 3s. Three-tier precedence mirrors
|
|
236
|
+
// summary.js / typing-profile.js: the participant's saved threshold first,
|
|
237
|
+
// then an analyst-side CLI override (for re-screening legacy cohorts that
|
|
238
|
+
// predate persisted thresholds), then the default.
|
|
239
|
+
tabFlickerCutoffMs: p.session?.config?.thresholds?.tabAwayDurationMs
|
|
240
|
+
?? config?.thresholds?.tabAwayDurationMs ?? 3000,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Pair {opened, closed} sidebarEvents entries into spans. Library writes both
|
|
245
|
+
// halves of a cycle as separate array entries — we reassemble them so the
|
|
246
|
+
// renderer can draw each cycle as one horizontal bar.
|
|
247
|
+
function pairSidebarEvents(arr) {
|
|
248
|
+
const spans = [];
|
|
249
|
+
let openT = null;
|
|
250
|
+
for (const e of arr) {
|
|
251
|
+
if (!e || typeof e !== 'object') continue;
|
|
252
|
+
if (e.type === 'opened' && typeof e.t === 'number') {
|
|
253
|
+
// Anchor to the FIRST open in a run of opens with no intervening close.
|
|
254
|
+
// The two runtime detectors (innerWidth_delta + layout_compression) can
|
|
255
|
+
// each emit an "opened" on different poll ticks for one physical sidebar;
|
|
256
|
+
// overwriting here would start the drawn span at the later detector,
|
|
257
|
+
// disagreeing with countSidebarOpenings (which anchors to the first
|
|
258
|
+
// transition) and with the span's own duration_ms label.
|
|
259
|
+
if (openT === null) openT = e.t;
|
|
260
|
+
} else if (e.type === 'closed' && typeof e.t === 'number' && openT !== null) {
|
|
261
|
+
spans.push({
|
|
262
|
+
start_perfNow_ms: openT,
|
|
263
|
+
end_perfNow_ms: e.t,
|
|
264
|
+
duration_ms: e.duration_ms ?? (e.t - openT),
|
|
265
|
+
closed: true,
|
|
266
|
+
});
|
|
267
|
+
openT = null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Trailing unclosed open
|
|
271
|
+
if (openT !== null) {
|
|
272
|
+
spans.push({ start_perfNow_ms: openT, end_perfNow_ms: null, duration_ms: null, closed: false });
|
|
273
|
+
}
|
|
274
|
+
return spans;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Phase derivation ─────────────────────────────────────────────────────
|
|
278
|
+
// Returns an array of phase bands, each with:
|
|
279
|
+
// { type: 'pre'|'gallery'|'typing'|'class'|'post',
|
|
280
|
+
// ruleIndex: number?, label: string,
|
|
281
|
+
// startSec: number, endSec: number }
|
|
282
|
+
// Times are session-relative seconds (anchored to metadata.startTime).
|
|
283
|
+
function derivePhases(p) {
|
|
284
|
+
const meta = p.metadata || {};
|
|
285
|
+
const startIso = meta.startTime;
|
|
286
|
+
const endIso = meta.endTime;
|
|
287
|
+
if (!startIso) return [];
|
|
288
|
+
const sessionStartMs = Date.parse(startIso);
|
|
289
|
+
const sessionEndMs = endIso ? Date.parse(endIso) : null;
|
|
290
|
+
if (!Number.isFinite(sessionStartMs)) return [];
|
|
291
|
+
|
|
292
|
+
const trials = Array.isArray(p.trials) ? p.trials.slice() : [];
|
|
293
|
+
if (trials.length === 0) {
|
|
294
|
+
// Only the session-bounding "pre" band.
|
|
295
|
+
const endSec = Number.isFinite(sessionEndMs)
|
|
296
|
+
? (sessionEndMs - sessionStartMs) / 1000
|
|
297
|
+
: ((meta.totalDurationMs || 0) / 1000);
|
|
298
|
+
return [{ type: 'pre', label: 'session', startSec: 0, endSec }];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Sort trials by timestamp (defensive — usually already sorted)
|
|
302
|
+
trials.sort((a, b) => {
|
|
303
|
+
const aT = a?.timestamp ? Date.parse(a.timestamp) : 0;
|
|
304
|
+
const bT = b?.timestamp ? Date.parse(b.timestamp) : 0;
|
|
305
|
+
return aT - bT;
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Group by rule position. Use a fresh sentinel object as the "no current
|
|
309
|
+
// group" marker so that an initial trial with rulePosition === null
|
|
310
|
+
// doesn't accidentally match curPos.
|
|
311
|
+
//
|
|
312
|
+
// Phase trials (gallery, post_gallery_query, end_requery) are merged into
|
|
313
|
+
// the trials array by the ingester but lack wall-clock `timestamp` — only
|
|
314
|
+
// their integrityTrial.startTime (perfNow). Including them here would
|
|
315
|
+
// corrupt the per-rule anchor (a phase trial sorting first within a group
|
|
316
|
+
// makes classEndMs = NaN and the whole group drops). The phase BANDS
|
|
317
|
+
// themselves are still computed below using galleryStudyMs anchoring; we
|
|
318
|
+
// just don't need the phase trials inside the grouping.
|
|
319
|
+
const NO_POS = Symbol('NO_POS');
|
|
320
|
+
const groups = [];
|
|
321
|
+
let cur = null;
|
|
322
|
+
let curPos = NO_POS;
|
|
323
|
+
for (const t of trials) {
|
|
324
|
+
if (!t || typeof t !== 'object') continue;
|
|
325
|
+
if (t.phase && t.phase !== 'classification') continue;
|
|
326
|
+
const pos = t.rulePosition ?? null;
|
|
327
|
+
if (cur === null || pos !== curPos) {
|
|
328
|
+
if (cur) groups.push(cur);
|
|
329
|
+
cur = { rulePosition: pos, ruleId: t.ruleId, trials: [] };
|
|
330
|
+
curPos = pos;
|
|
331
|
+
}
|
|
332
|
+
cur.trials.push(t);
|
|
333
|
+
}
|
|
334
|
+
if (cur) groups.push(cur);
|
|
335
|
+
|
|
336
|
+
// Gallery durations (per rule, in ms). May be a flat array.
|
|
337
|
+
const galleryMs = Array.isArray(p.galleryStudyMs) ? p.galleryStudyMs : [];
|
|
338
|
+
const typingMs = inferTypingDurations(p);
|
|
339
|
+
|
|
340
|
+
// Build phase bands.
|
|
341
|
+
//
|
|
342
|
+
// Strategy: anchor each rule's gallery / typing windows BACKWARDS from
|
|
343
|
+
// classStartMs (which we have exactly). For rule i:
|
|
344
|
+
// classification: [classStartMs, classWindowEnd]
|
|
345
|
+
// typing: [classStartMs - typDur, classStartMs] (explain only)
|
|
346
|
+
// gallery: [classStartMs - typDur - galDur, classStartMs - typDur]
|
|
347
|
+
// pre / transition: [cursorMs, galleryStartMs]
|
|
348
|
+
// The pre band for i=0 is "consent / tutorial / comprehension"; for i≥1
|
|
349
|
+
// it's an inter-rule transition (UI render time, brief pause). Pre band is
|
|
350
|
+
// skipped if it would have zero or negative width (e.g., gallery+typing
|
|
351
|
+
// overlap with the previous classification due to data noise).
|
|
352
|
+
const phases = [];
|
|
353
|
+
let cursorMs = sessionStartMs;
|
|
354
|
+
|
|
355
|
+
for (let i = 0; i < groups.length; i++) {
|
|
356
|
+
const g = groups[i];
|
|
357
|
+
if (g.trials.length === 0) continue;
|
|
358
|
+
|
|
359
|
+
// Classification window: from first trial's start to last trial's end
|
|
360
|
+
const firstT = g.trials[0];
|
|
361
|
+
const lastT = g.trials[g.trials.length - 1];
|
|
362
|
+
const classEndMs = Date.parse(firstT.timestamp);
|
|
363
|
+
if (!Number.isFinite(classEndMs)) continue;
|
|
364
|
+
// Legacy Shape-2 trials carry `responseTime_ms` instead of `rt`; without the
|
|
365
|
+
// fallback the classification window collapses to zero width and every
|
|
366
|
+
// backwards-anchored band (gallery, typing) shifts right by the missing rt.
|
|
367
|
+
const classStartMs = classEndMs - (firstT.rt ?? firstT.responseTime_ms ?? 0);
|
|
368
|
+
const classWindowEnd = Date.parse(lastT.timestamp);
|
|
369
|
+
|
|
370
|
+
const galDur = galleryMs[i] || 0;
|
|
371
|
+
const typDur = typingMs[i] || 0;
|
|
372
|
+
|
|
373
|
+
// Anchor gallery / typing backwards from classStartMs
|
|
374
|
+
const typingEndMs = classStartMs;
|
|
375
|
+
const typingStartMs = typingEndMs - typDur;
|
|
376
|
+
const galleryEndMs = typingStartMs;
|
|
377
|
+
const galleryStartMs = galleryEndMs - galDur;
|
|
378
|
+
|
|
379
|
+
// Pre band (consent/tutorial/comprehension on i=0, transition otherwise).
|
|
380
|
+
// Skip if width would be ≤ 0 (gallery/typing don't fit in the interlude —
|
|
381
|
+
// happens with noisy data; better than overlap artefacts).
|
|
382
|
+
if (galleryStartMs > cursorMs) {
|
|
383
|
+
phases.push({
|
|
384
|
+
type: 'pre',
|
|
385
|
+
label: i === 0 ? 'consent / tutorial / comprehension' : 'transition',
|
|
386
|
+
startSec: (cursorMs - sessionStartMs) / 1000,
|
|
387
|
+
endSec: (galleryStartMs - sessionStartMs) / 1000,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Gallery band
|
|
392
|
+
phases.push({
|
|
393
|
+
type: 'gallery',
|
|
394
|
+
ruleIndex: i,
|
|
395
|
+
label: `gallery (${g.ruleId || `rule ${i + 1}`})`,
|
|
396
|
+
startSec: (galleryStartMs - sessionStartMs) / 1000,
|
|
397
|
+
endSec: (galleryEndMs - sessionStartMs) / 1000,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Typing band (explain only)
|
|
401
|
+
if (typDur > 0) {
|
|
402
|
+
phases.push({
|
|
403
|
+
type: 'typing',
|
|
404
|
+
ruleIndex: i,
|
|
405
|
+
label: 'type-explanation',
|
|
406
|
+
startSec: (typingStartMs - sessionStartMs) / 1000,
|
|
407
|
+
endSec: (typingEndMs - sessionStartMs) / 1000,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Classification band
|
|
412
|
+
phases.push({
|
|
413
|
+
type: 'class',
|
|
414
|
+
ruleIndex: i,
|
|
415
|
+
label: `classification ×${g.trials.length}`,
|
|
416
|
+
startSec: (classStartMs - sessionStartMs) / 1000,
|
|
417
|
+
endSec: (classWindowEnd - sessionStartMs) / 1000,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
cursorMs = classWindowEnd;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Post-final-rule band (re-query, demographics) up to session end
|
|
424
|
+
const postEndMs = sessionEndMs || (sessionStartMs + (meta.totalDurationMs || 0));
|
|
425
|
+
if (Number.isFinite(postEndMs) && postEndMs > cursorMs) {
|
|
426
|
+
phases.push({
|
|
427
|
+
type: 'post',
|
|
428
|
+
label: 're-query / demographics',
|
|
429
|
+
startSec: (cursorMs - sessionStartMs) / 1000,
|
|
430
|
+
endSec: (postEndMs - sessionStartMs) / 1000,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return phases;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function inferTypingDurations(p) {
|
|
438
|
+
// postGalleryGuesses[i] = null for silent, or {rt, ruleId, response} for explain.
|
|
439
|
+
// Use rt (ms) when present.
|
|
440
|
+
const arr = Array.isArray(p.postGalleryGuesses) ? p.postGalleryGuesses : [];
|
|
441
|
+
return arr.map(x => {
|
|
442
|
+
if (x && typeof x.rt === 'number') return x.rt;
|
|
443
|
+
return 0;
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── X-axis domain + perfNow → session-relative conversion ────────────────
|
|
448
|
+
// Determines the chart's X-axis maximum (in seconds) and stashes a
|
|
449
|
+
// session offset on the events object so all perfNow timestamps can be
|
|
450
|
+
// converted consistently.
|
|
451
|
+
function computeXDomain(p, events, phases) {
|
|
452
|
+
const meta = p.metadata || {};
|
|
453
|
+
|
|
454
|
+
// Session duration (seconds)
|
|
455
|
+
let sessionDurSec = 0;
|
|
456
|
+
if (Number.isFinite(meta.totalDurationMs)) {
|
|
457
|
+
sessionDurSec = meta.totalDurationMs / 1000;
|
|
458
|
+
} else if (meta.startTime && meta.endTime) {
|
|
459
|
+
sessionDurSec = (Date.parse(meta.endTime) - Date.parse(meta.startTime)) / 1000;
|
|
460
|
+
} else if (phases.length > 0) {
|
|
461
|
+
sessionDurSec = phases[phases.length - 1].endSec;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Estimate sessionOffset: the perfNow value (in ms) at session start.
|
|
465
|
+
// This is the value to subtract from event.t_perfNow_ms to get session-rel ms.
|
|
466
|
+
events.sessionOffsetMs = deriveSessionOffset(p, events);
|
|
467
|
+
|
|
468
|
+
// Convert all event times to session-relative seconds in place
|
|
469
|
+
for (const e of events.tabAways) {
|
|
470
|
+
e.t_sec = (e.t_perfNow_ms - events.sessionOffsetMs) / 1000;
|
|
471
|
+
}
|
|
472
|
+
for (const s of events.sidebarSpans) {
|
|
473
|
+
s.start_sec = (s.start_perfNow_ms - events.sessionOffsetMs) / 1000;
|
|
474
|
+
s.end_sec = s.end_perfNow_ms != null
|
|
475
|
+
? (s.end_perfNow_ms - events.sessionOffsetMs) / 1000
|
|
476
|
+
: sessionDurSec; // unclosed → extend to session end
|
|
477
|
+
}
|
|
478
|
+
for (const e of events.layoutShifts) {
|
|
479
|
+
e.t_sec = (e.t_perfNow_ms - events.sessionOffsetMs) / 1000;
|
|
480
|
+
}
|
|
481
|
+
for (const v of events.guardViolations) {
|
|
482
|
+
v.t_sec = (v.t_perfNow_ms - events.sessionOffsetMs) / 1000;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Extend X-domain if any event lands beyond computed session duration
|
|
486
|
+
// (rare — handles clock skew / late-saved data).
|
|
487
|
+
let maxObserved = sessionDurSec;
|
|
488
|
+
const consider = (sec) => {
|
|
489
|
+
if (Number.isFinite(sec) && sec > maxObserved) maxObserved = sec;
|
|
490
|
+
};
|
|
491
|
+
for (const e of events.tabAways) consider(e.t_sec + e.duration_ms / 1000);
|
|
492
|
+
for (const s of events.sidebarSpans) consider(s.end_sec);
|
|
493
|
+
for (const e of events.layoutShifts) consider(e.t_sec);
|
|
494
|
+
for (const v of events.guardViolations) consider(v.t_sec);
|
|
495
|
+
|
|
496
|
+
return Math.max(maxObserved, sessionDurSec, 1);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Derive (perfNow at session start) in ms.
|
|
500
|
+
// Strategy:
|
|
501
|
+
// 1. If any trial has trialStart_perfNow set, take the first trial's
|
|
502
|
+
// perfNow anchor minus the trial's session-rel start (best — direct).
|
|
503
|
+
// 2. Else, if there's at least one per-trial tab-away with a `start`
|
|
504
|
+
// perfNow value, use the same estimator as the ingest.
|
|
505
|
+
// 3. Else, fall back to 0 (assume page load == session start). This
|
|
506
|
+
// drifts by ~20-60s for typical setups; flagged in the footer.
|
|
507
|
+
export function deriveSessionOffset(p, events) {
|
|
508
|
+
const meta = p.metadata || {};
|
|
509
|
+
const sessionStartMs = meta.startTime ? Date.parse(meta.startTime) : null;
|
|
510
|
+
if (!Number.isFinite(sessionStartMs)) return 0;
|
|
511
|
+
|
|
512
|
+
// Strategy 1: explicit per-trial perfNow anchors
|
|
513
|
+
const trials = Array.isArray(p.trials) ? p.trials : [];
|
|
514
|
+
for (const t of trials) {
|
|
515
|
+
const anchor = t?.trialStart_perfNow ?? t?.startTime;
|
|
516
|
+
if (typeof anchor !== 'number') continue;
|
|
517
|
+
// Shape-2 (legacy) trials carry `responseTime_ms`, not `rt`
|
|
518
|
+
// (ingest's LEGACY_FIELD_MAP only renames mouseTrack). Fall back so legacy
|
|
519
|
+
// participants don't skip every candidate and drop to the offset-0 fallback.
|
|
520
|
+
const rt = t?.rt ?? t?.responseTime_ms;
|
|
521
|
+
if (!t?.timestamp || rt == null) continue;
|
|
522
|
+
const trialEndWallMs = Date.parse(t.timestamp);
|
|
523
|
+
const trialStartWallMs = trialEndWallMs - rt;
|
|
524
|
+
if (!Number.isFinite(trialStartWallMs)) continue;
|
|
525
|
+
// sessionOffset + (trialStartWall - sessionStartWall) = anchor
|
|
526
|
+
// → sessionOffset = anchor - (trialStartWall - sessionStartWall)
|
|
527
|
+
return anchor - (trialStartWallMs - sessionStartMs);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Strategy 2: median across per-trial tab-aways (same approach as ingest)
|
|
531
|
+
const candidates = [];
|
|
532
|
+
for (let i = 0; i < trials.length; i++) {
|
|
533
|
+
const t = trials[i];
|
|
534
|
+
// After Shape-1 ingest, tab-aways live at the top-level t.tabAwayEvents
|
|
535
|
+
// (the integrity sub-object is spread out). Fall back to the nested
|
|
536
|
+
// integrityTrial path for callers that bypass ingest — same fallback
|
|
537
|
+
// collectEvents() uses. Reading only the nested path made this strategy a
|
|
538
|
+
// dead branch on ingested data, dropping every legacy participant to the
|
|
539
|
+
// offset-0 fallback (Strategy 3) and drifting the timeline.
|
|
540
|
+
const tabs = (Array.isArray(t?.tabAwayEvents) && t.tabAwayEvents)
|
|
541
|
+
|| (Array.isArray(t?.integrityTrial?.tabAwayEvents) && t.integrityTrial.tabAwayEvents)
|
|
542
|
+
|| [];
|
|
543
|
+
if (tabs.length === 0) continue;
|
|
544
|
+
const firstTabPerfNow = tabs[0]?.start;
|
|
545
|
+
if (typeof firstTabPerfNow !== 'number') continue;
|
|
546
|
+
// Legacy Shape-2 trials use `responseTime_ms`; see Strategy 1 above.
|
|
547
|
+
const rt = t.rt ?? t.responseTime_ms;
|
|
548
|
+
if (!t.timestamp || rt == null) continue;
|
|
549
|
+
const trialEndWallMs = Date.parse(t.timestamp);
|
|
550
|
+
const trialStartWallMs = trialEndWallMs - rt;
|
|
551
|
+
if (!Number.isFinite(trialStartWallMs)) continue;
|
|
552
|
+
const trialStartRelMs = trialStartWallMs - sessionStartMs;
|
|
553
|
+
// Assume tab-away happened near trial midpoint (most conservative single-point estimate)
|
|
554
|
+
candidates.push(firstTabPerfNow - trialStartRelMs - rt / 2);
|
|
555
|
+
}
|
|
556
|
+
if (candidates.length > 0) {
|
|
557
|
+
candidates.sort((a, b) => a - b);
|
|
558
|
+
return candidates[Math.floor(candidates.length / 2)];
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Strategy 3: fallback. Returns 0 so perfNow values plot directly.
|
|
562
|
+
return 0;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── Drawing primitives ───────────────────────────────────────────────────
|
|
566
|
+
function drawHeader(ctx, p, events) {
|
|
567
|
+
ctx.fillStyle = C.title;
|
|
568
|
+
ctx.font = 'bold 14px sans-serif';
|
|
569
|
+
ctx.fillText(`Session Timeline — ${shortId(p.participantId)}`, LEFT_PAD, 22);
|
|
570
|
+
|
|
571
|
+
ctx.fillStyle = C.subtitle;
|
|
572
|
+
ctx.font = '11px sans-serif';
|
|
573
|
+
const cond = p.metadata?.condition || '?';
|
|
574
|
+
const dur = p.metadata?.totalDurationMs ? `${(p.metadata.totalDurationMs / 60000).toFixed(1)} min` : '?';
|
|
575
|
+
ctx.fillText(`condition=${cond} duration=${dur} trials=${(p.trials || []).length}`, LEFT_PAD, 38);
|
|
576
|
+
|
|
577
|
+
// Sessionoffset note
|
|
578
|
+
ctx.font = '10px sans-serif';
|
|
579
|
+
if (!events.sessionOffsetMs) {
|
|
580
|
+
ctx.fillStyle = '#999';
|
|
581
|
+
ctx.fillText(`(perfNow→session-rel offset not derivable — perfNow events may drift ~20-60s)`, LEFT_PAD, 53);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function drawPhaseStrip(ctx, phases, xScale, xDomain, chartW) {
|
|
586
|
+
const y = TOP_PAD;
|
|
587
|
+
// Background
|
|
588
|
+
ctx.fillStyle = C.laneBg;
|
|
589
|
+
ctx.fillRect(LEFT_PAD, y, chartW, PHASE_STRIP_H);
|
|
590
|
+
|
|
591
|
+
for (const ph of phases) {
|
|
592
|
+
const color = phaseColor(ph.type);
|
|
593
|
+
const x0 = LEFT_PAD + Math.max(0, ph.startSec) * xScale;
|
|
594
|
+
const x1 = LEFT_PAD + Math.min(xDomain, ph.endSec) * xScale;
|
|
595
|
+
const w = Math.max(0, x1 - x0);
|
|
596
|
+
if (w <= 0) continue;
|
|
597
|
+
ctx.fillStyle = color;
|
|
598
|
+
ctx.fillRect(x0, y + 1, w, PHASE_STRIP_H - 2);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Border
|
|
602
|
+
ctx.strokeStyle = C.laneBorder;
|
|
603
|
+
ctx.lineWidth = 0.5;
|
|
604
|
+
ctx.strokeRect(LEFT_PAD, y, chartW, PHASE_STRIP_H);
|
|
605
|
+
|
|
606
|
+
// Label
|
|
607
|
+
ctx.fillStyle = C.laneLabel;
|
|
608
|
+
ctx.font = '10px sans-serif';
|
|
609
|
+
ctx.textAlign = 'right';
|
|
610
|
+
ctx.fillText('Phases', LEFT_PAD - 6, y + PHASE_STRIP_H / 2 + 3);
|
|
611
|
+
ctx.textAlign = 'left';
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function phaseColor(type) {
|
|
615
|
+
switch (type) {
|
|
616
|
+
case 'pre': return C.phasePre;
|
|
617
|
+
case 'gallery': return C.phaseGallery;
|
|
618
|
+
case 'typing': return C.phaseTyping;
|
|
619
|
+
case 'class': return C.phaseClass;
|
|
620
|
+
case 'post': return C.phasePost;
|
|
621
|
+
default: return C.laneBg;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function drawLanes(ctx, events, xScale, xDomain, chartW) {
|
|
626
|
+
const baseY = TOP_PAD + PHASE_STRIP_H + LANE_GAP;
|
|
627
|
+
for (let i = 0; i < N_LANES; i++) {
|
|
628
|
+
const y = baseY + i * (LANE_HEIGHT + LANE_GAP);
|
|
629
|
+
drawLaneBackground(ctx, y, chartW);
|
|
630
|
+
drawLaneLabel(ctx, y, LANE_NAMES[i]);
|
|
631
|
+
switch (LANE_NAMES[i]) {
|
|
632
|
+
case 'Tab-aways': drawTabAways(ctx, y, events.tabAways, xScale, xDomain, events.tabFlickerCutoffMs); break;
|
|
633
|
+
case 'Sidebars': drawSidebars(ctx, y, events.sidebarSpans, xScale, xDomain); break;
|
|
634
|
+
case 'Viewport shifts': drawLayoutShifts(ctx, y, events.layoutShifts, xScale, xDomain); break;
|
|
635
|
+
case 'Guard-friction': drawGuardViolations(ctx, y, events.guardViolations, xScale, xDomain); break;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function drawLaneBackground(ctx, y, chartW) {
|
|
641
|
+
ctx.fillStyle = C.laneBg;
|
|
642
|
+
ctx.fillRect(LEFT_PAD, y, chartW, LANE_HEIGHT);
|
|
643
|
+
ctx.strokeStyle = C.laneBorder;
|
|
644
|
+
ctx.lineWidth = 0.5;
|
|
645
|
+
ctx.strokeRect(LEFT_PAD, y, chartW, LANE_HEIGHT);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function drawLaneLabel(ctx, y, text) {
|
|
649
|
+
ctx.fillStyle = C.laneLabel;
|
|
650
|
+
ctx.font = '10px sans-serif';
|
|
651
|
+
ctx.textAlign = 'right';
|
|
652
|
+
ctx.fillText(text, LEFT_PAD - 6, y + LANE_HEIGHT / 2 + 3);
|
|
653
|
+
ctx.textAlign = 'left';
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function tabColor(durationMs, flickerCutoffMs = 3000) {
|
|
657
|
+
// Strict `<=` flicker boundary matches the runtime soft-scoring rule
|
|
658
|
+
// (duration > cutoff counts) and summary.js's bins: a tab-away exactly at the
|
|
659
|
+
// cutoff is a flicker, not a scored "meaningful" absence.
|
|
660
|
+
if (durationMs <= flickerCutoffMs) return C.tabFlicker;
|
|
661
|
+
if (durationMs < 10000) return C.tabMedium;
|
|
662
|
+
return C.tabLong;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function drawTabAways(ctx, y, list, xScale, xDomain, flickerCutoffMs = 3000) {
|
|
666
|
+
if (!Array.isArray(list)) return;
|
|
667
|
+
for (const e of list) {
|
|
668
|
+
if (!Number.isFinite(e.t_sec)) continue;
|
|
669
|
+
// Allow events whose start is slightly negative if their end falls in-domain
|
|
670
|
+
const endSec = e.t_sec + (e.duration_ms || 0) / 1000;
|
|
671
|
+
if (endSec < 0 || e.t_sec > xDomain) continue;
|
|
672
|
+
const clampedStart = Math.max(0, e.t_sec);
|
|
673
|
+
const clampedEnd = Math.min(xDomain, endSec);
|
|
674
|
+
const x = LEFT_PAD + clampedStart * xScale;
|
|
675
|
+
// Bar width = duration clipped to domain (min 2px for visibility)
|
|
676
|
+
const w = Math.max(2, (clampedEnd - clampedStart) * xScale);
|
|
677
|
+
ctx.fillStyle = tabColor(e.duration_ms, flickerCutoffMs);
|
|
678
|
+
ctx.fillRect(x, y + 4, w, LANE_HEIGHT - 8);
|
|
679
|
+
ctx.strokeStyle = '#333';
|
|
680
|
+
ctx.lineWidth = 0.4;
|
|
681
|
+
ctx.strokeRect(x, y + 4, w, LANE_HEIGHT - 8);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function drawSidebars(ctx, y, spans, xScale, xDomain) {
|
|
686
|
+
if (!Array.isArray(spans)) return;
|
|
687
|
+
for (const s of spans) {
|
|
688
|
+
if (!Number.isFinite(s.start_sec)) continue;
|
|
689
|
+
const startSec = clamp(s.start_sec, 0, xDomain);
|
|
690
|
+
const endSec = clamp(s.end_sec, startSec, xDomain);
|
|
691
|
+
const x = LEFT_PAD + startSec * xScale;
|
|
692
|
+
const w = Math.max(3, (endSec - startSec) * xScale);
|
|
693
|
+
ctx.fillStyle = C.sidebarSpan;
|
|
694
|
+
ctx.fillRect(x, y + 6, w, LANE_HEIGHT - 12);
|
|
695
|
+
if (!s.closed) {
|
|
696
|
+
ctx.strokeStyle = '#fff';
|
|
697
|
+
ctx.lineWidth = 1;
|
|
698
|
+
ctx.setLineDash([3, 3]);
|
|
699
|
+
ctx.strokeRect(x + 0.5, y + 6.5, w - 1, LANE_HEIGHT - 13);
|
|
700
|
+
ctx.setLineDash([]);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function drawLayoutShifts(ctx, y, list, xScale, xDomain) {
|
|
706
|
+
if (!Array.isArray(list)) return;
|
|
707
|
+
ctx.strokeStyle = C.layoutShift;
|
|
708
|
+
ctx.lineWidth = 1.2;
|
|
709
|
+
for (const e of list) {
|
|
710
|
+
if (!Number.isFinite(e.t_sec)) continue;
|
|
711
|
+
if (e.t_sec < 0 || e.t_sec > xDomain) continue;
|
|
712
|
+
const x = LEFT_PAD + e.t_sec * xScale;
|
|
713
|
+
ctx.beginPath();
|
|
714
|
+
ctx.moveTo(x + 0.5, y + 4);
|
|
715
|
+
ctx.lineTo(x + 0.5, y + LANE_HEIGHT - 4);
|
|
716
|
+
ctx.stroke();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function drawGuardViolations(ctx, y, list, xScale, xDomain) {
|
|
721
|
+
if (!Array.isArray(list)) return;
|
|
722
|
+
// Cluster nearby polls visually — tamper events fire every ~300ms during
|
|
723
|
+
// a violation period. Group consecutive events within 500ms into one tick
|
|
724
|
+
// to keep the lane readable.
|
|
725
|
+
const groups = [];
|
|
726
|
+
let cur = null;
|
|
727
|
+
for (const v of list) {
|
|
728
|
+
if (!Number.isFinite(v.t_sec)) continue;
|
|
729
|
+
if (v.t_sec < 0 || v.t_sec > xDomain) continue;
|
|
730
|
+
if (cur && (v.t_sec - cur.lastSec) < 0.6) {
|
|
731
|
+
cur.lastSec = v.t_sec;
|
|
732
|
+
cur.count++;
|
|
733
|
+
} else {
|
|
734
|
+
if (cur) groups.push(cur);
|
|
735
|
+
cur = { firstSec: v.t_sec, lastSec: v.t_sec, count: 1, reason: v.reason };
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (cur) groups.push(cur);
|
|
739
|
+
|
|
740
|
+
for (const g of groups) {
|
|
741
|
+
const x0 = LEFT_PAD + g.firstSec * xScale;
|
|
742
|
+
const x1 = LEFT_PAD + g.lastSec * xScale;
|
|
743
|
+
const w = Math.max(2, x1 - x0);
|
|
744
|
+
ctx.fillStyle = C.guardViolation;
|
|
745
|
+
ctx.fillRect(x0, y + 6, w, LANE_HEIGHT - 12);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Dashed vertical guides at each rule boundary (= start of each gallery phase).
|
|
750
|
+
// Numbered at the top so it's easy to see which rule occupies which column.
|
|
751
|
+
// Skipped if there are < 2 galleries (e.g., trial-fields-stripped fallback).
|
|
752
|
+
function drawPhaseGuides(ctx, phases, xScale, xDomain, topY, bottomY) {
|
|
753
|
+
const galleries = phases.filter(p => p.type === 'gallery'
|
|
754
|
+
&& Number.isFinite(p.startSec)
|
|
755
|
+
&& p.startSec >= 0
|
|
756
|
+
&& p.startSec <= xDomain);
|
|
757
|
+
if (galleries.length < 2) return;
|
|
758
|
+
|
|
759
|
+
ctx.save();
|
|
760
|
+
ctx.strokeStyle = '#888';
|
|
761
|
+
ctx.lineWidth = 0.7;
|
|
762
|
+
ctx.setLineDash([3, 3]);
|
|
763
|
+
ctx.fillStyle = '#555';
|
|
764
|
+
ctx.font = '9px sans-serif';
|
|
765
|
+
ctx.textAlign = 'center';
|
|
766
|
+
|
|
767
|
+
for (let i = 0; i < galleries.length; i++) {
|
|
768
|
+
const g = galleries[i];
|
|
769
|
+
const x = LEFT_PAD + g.startSec * xScale + 0.5;
|
|
770
|
+
ctx.beginPath();
|
|
771
|
+
ctx.moveTo(x, topY);
|
|
772
|
+
ctx.lineTo(x, bottomY);
|
|
773
|
+
ctx.stroke();
|
|
774
|
+
// Rule number label just above the lanes
|
|
775
|
+
ctx.fillText(`r${i + 1}`, x, topY - 2);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
ctx.restore();
|
|
779
|
+
ctx.textAlign = 'left';
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function drawXAxis(ctx, xDomain, chartW, canvasH) {
|
|
783
|
+
const tickCount = 8;
|
|
784
|
+
const yBase = canvasH - BOTTOM_PAD + 6;
|
|
785
|
+
ctx.strokeStyle = C.xAxis;
|
|
786
|
+
ctx.lineWidth = 0.5;
|
|
787
|
+
ctx.beginPath();
|
|
788
|
+
ctx.moveTo(LEFT_PAD, yBase);
|
|
789
|
+
ctx.lineTo(LEFT_PAD + chartW, yBase);
|
|
790
|
+
ctx.stroke();
|
|
791
|
+
|
|
792
|
+
ctx.fillStyle = C.xAxis;
|
|
793
|
+
ctx.font = '10px sans-serif';
|
|
794
|
+
ctx.textAlign = 'center';
|
|
795
|
+
for (let i = 0; i <= tickCount; i++) {
|
|
796
|
+
const sec = (xDomain / tickCount) * i;
|
|
797
|
+
const x = LEFT_PAD + sec * (chartW / xDomain);
|
|
798
|
+
ctx.strokeStyle = C.xTick;
|
|
799
|
+
ctx.beginPath();
|
|
800
|
+
ctx.moveTo(x, yBase);
|
|
801
|
+
ctx.lineTo(x, yBase + 4);
|
|
802
|
+
ctx.stroke();
|
|
803
|
+
ctx.fillText(formatSec(sec), x, yBase + 16);
|
|
804
|
+
}
|
|
805
|
+
ctx.textAlign = 'left';
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function formatSec(sec) {
|
|
809
|
+
const m = Math.floor(sec / 60);
|
|
810
|
+
const s = Math.round(sec - m * 60);
|
|
811
|
+
return m > 0 ? `${m}:${String(s).padStart(2, '0')}` : `${s}s`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function drawLegendAndFooter(ctx, p, events, canvasH) {
|
|
815
|
+
// Two-row legend: phases on the top row, event types on the bottom row.
|
|
816
|
+
// Two rows is more readable than a 10-item single line and never silently
|
|
817
|
+
// truncates (the old single-line version dropped the leftmost entries when
|
|
818
|
+
// they overflowed, hiding the "gallery" label exactly when we needed it).
|
|
819
|
+
const phaseItems = [
|
|
820
|
+
{ color: C.phasePre, label: 'framing (pre / transition / post)' },
|
|
821
|
+
{ color: C.phaseGallery, label: 'gallery' },
|
|
822
|
+
{ color: C.phaseTyping, label: 'typing' },
|
|
823
|
+
{ color: C.phaseClass, label: 'classification' },
|
|
824
|
+
];
|
|
825
|
+
// Tab-away legend reflects THIS participant's cutoff (e.g. 5s for strict), not a
|
|
826
|
+
// hardcoded 3s, matching the color bins and summary.csv counts.
|
|
827
|
+
const cutoffS = Math.round((events.tabFlickerCutoffMs ?? 3000) / 1000);
|
|
828
|
+
const eventItems = [
|
|
829
|
+
{ color: C.tabFlicker, label: `tab ≤${cutoffS}s` },
|
|
830
|
+
{ color: C.tabMedium, label: `tab ${cutoffS}–10s` },
|
|
831
|
+
{ color: C.tabLong, label: 'tab ≥10s' },
|
|
832
|
+
{ color: C.sidebarSpan, label: 'sidebar (open span)' },
|
|
833
|
+
{ color: C.layoutShift, label: 'viewport shift' },
|
|
834
|
+
{ color: C.guardViolation, label: 'guard-friction' },
|
|
835
|
+
];
|
|
836
|
+
ctx.font = '9px sans-serif';
|
|
837
|
+
drawLegendRow(ctx, phaseItems, 16);
|
|
838
|
+
drawLegendRow(ctx, eventItems, 28);
|
|
839
|
+
|
|
840
|
+
// Footer summary
|
|
841
|
+
const fY = canvasH - BOTTOM_PAD + 38;
|
|
842
|
+
ctx.font = '11px sans-serif';
|
|
843
|
+
ctx.fillStyle = '#333';
|
|
844
|
+
const tabSessTotal = events.sessionTabDurations.length;
|
|
845
|
+
const flickerCutoffMs = events.tabFlickerCutoffMs ?? 3000;
|
|
846
|
+
// "Meaningful" = scored = strictly longer than the cutoff (matches the runtime).
|
|
847
|
+
const tabSessLong = events.sessionTabDurations.filter(d => d > flickerCutoffMs).length;
|
|
848
|
+
const cutoffSecLabel = `${Math.round(flickerCutoffMs / 1000)}s`;
|
|
849
|
+
const sb = events.sidebarSpans.length;
|
|
850
|
+
const ls = events.layoutShifts.length;
|
|
851
|
+
const gf = events.guardViolations.length;
|
|
852
|
+
|
|
853
|
+
// Sidebar counts: the footer reports the OPENING count — the same
|
|
854
|
+
// `countSidebarOpenings()` value summary.csv's `sidebar_event_count` and triage
|
|
855
|
+
// use, so the timeline and the CSV agree. `sb` (sidebarSpans) is the number of
|
|
856
|
+
// open→close spans actually DRAWN, which can exceed openings when one physical
|
|
857
|
+
// sidebar is double-detected (innerWidth_delta + layout_compression).
|
|
858
|
+
const sidebarOpenings = countSidebarOpenings(p.session?.sidebarEvents);
|
|
859
|
+
const spanNote = sb !== sidebarOpenings ? ` (${sb} span${sb === 1 ? '' : 's'} drawn)` : '';
|
|
860
|
+
// Tab-away note depends on what the payload could offer: libraries ≥0.6.1
|
|
861
|
+
// save session-level tabAwayEvents so every tab-away (on-trial or off)
|
|
862
|
+
// plots; older payloads keep off-trial tab-aways as durations only, and
|
|
863
|
+
// the footer flags how many are unplaceable on the axis.
|
|
864
|
+
const tabNote = events.tabAwaySource === 'session'
|
|
865
|
+
? `All ${events.tabAways.length} plotted from session-level events (incl. off-trial)`
|
|
866
|
+
: `Per-trial events plotted: ${events.tabAways.length} ` +
|
|
867
|
+
`Off-trial events: ${events.offTrialTabAwayCount} (timing not preserved — pre-0.6.1 payload)`;
|
|
868
|
+
const lines = [
|
|
869
|
+
`Tab-aways total=${tabSessTotal} (> ${cutoffSecLabel}: ${tabSessLong}) ` + tabNote,
|
|
870
|
+
`Sidebars ${sidebarOpenings} opening${sidebarOpenings === 1 ? '' : 's'}${spanNote} ` +
|
|
871
|
+
`Viewport shifts ${ls} Guard-friction violations ${gf}`,
|
|
872
|
+
];
|
|
873
|
+
for (let i = 0; i < lines.length; i++) {
|
|
874
|
+
ctx.fillText(lines[i], LEFT_PAD, fY + i * 14);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Right-aligned legend row. Never truncates; if items overflow, they extend
|
|
879
|
+
// leftward past the chart midpoint rather than being silently dropped.
|
|
880
|
+
function drawLegendRow(ctx, items, yText) {
|
|
881
|
+
let lx = CANVAS_W - RIGHT_PAD;
|
|
882
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
883
|
+
const it = items[i];
|
|
884
|
+
const w = ctx.measureText(it.label).width;
|
|
885
|
+
lx -= w;
|
|
886
|
+
ctx.fillStyle = '#333';
|
|
887
|
+
ctx.fillText(it.label, lx, yText);
|
|
888
|
+
lx -= 5;
|
|
889
|
+
ctx.fillStyle = it.color;
|
|
890
|
+
ctx.fillRect(lx - 9, yText - 7, 9, 8);
|
|
891
|
+
ctx.strokeStyle = '#666';
|
|
892
|
+
ctx.lineWidth = 0.4;
|
|
893
|
+
ctx.strokeRect(lx - 9, yText - 7, 9, 8);
|
|
894
|
+
lx -= 12;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
899
|
+
export function sanitize(name) {
|
|
900
|
+
return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
|
|
901
|
+
}
|
|
902
|
+
export function shortId(name) {
|
|
903
|
+
return String(name).slice(0, 8);
|
|
904
|
+
}
|
|
905
|
+
function clamp(v, lo, hi) {
|
|
906
|
+
return Math.max(lo, Math.min(hi, v));
|
|
907
|
+
}
|