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