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.
@@ -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
+ }