cyborg-hunter 0.7.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 +46 -0
- package/CITATION.cff +2 -2
- package/README.md +4 -2
- package/bin/cyborg-hunter.js +6 -2
- package/dist/cyborg-hunter-replay.js +3 -0
- package/dist/cyborg-hunter.esm.js +1619 -0
- package/dist/cyborg-hunter.min.js +6 -0
- package/dist/extension-cyborg-hunter.js +1 -0
- package/dist/extension-guard-friction.js +36 -0
- package/dist/extension-guard-honeypot.js +1 -0
- package/package.json +8 -3
- package/src/cli/extract-core.js +552 -0
- package/src/cli/ingest.js +11 -536
- package/src/cli/preview-entry.js +36 -0
- package/src/cli/renderers/html-index-core.js +1273 -0
- package/src/cli/renderers/html-index.js +6 -1179
- package/src/cli/renderers/replay-assets.js +8 -118
- package/src/cli/renderers/replay-viewer.client.js +179 -16
- package/src/cli/renderers/session-timeline-core.js +907 -0
- package/src/cli/renderers/session-timeline.js +15 -899
- package/src/cli/renderers/trajectories-core.js +717 -0
- package/src/cli/renderers/trajectories.js +31 -696
- package/src/cli/renderers/typing-profile-core.js +211 -0
- package/src/cli/renderers/typing-profile.js +16 -192
- package/src/core/monitor.js +17 -0
- package/src/jspsych/extension-cyborg-hunter-replay.js +1 -1
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/jspsych/extension-guard-friction.js +11 -2
- package/src/replay/viewer-model.js +125 -0
- package/src/shared/constants.js +1 -1
- package/src/shared/paths.js +20 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
// src/cli/renderers/trajectories-core.js
|
|
2
|
+
//
|
|
3
|
+
// Pure drawing core for per-participant mouse-trajectory grid images — no
|
|
4
|
+
// Node APIs, so a browser demo can bundle it directly (0.7.2-style
|
|
5
|
+
// extraction from cli/renderers/trajectories.js, which is now a thin fs
|
|
6
|
+
// wrapper around this module: it acquires node-canvas, calls
|
|
7
|
+
// drawTrajectoryGrid per participant, and writes the returned canvas to a
|
|
8
|
+
// PNG). Mirrors the session-timeline-core.js split.
|
|
9
|
+
//
|
|
10
|
+
// drawTrajectoryGrid(p, triageEntry, config, createCanvas) draws the full
|
|
11
|
+
// grid of per-trial panels for one participant and returns the drawn canvas
|
|
12
|
+
// (or null if the participant has no trials to plot). createCanvas is
|
|
13
|
+
// injected so this module never imports the `canvas` package directly — a
|
|
14
|
+
// browser caller can pass a document-canvas-backed factory instead.
|
|
15
|
+
//
|
|
16
|
+
// Per-panel visual elements:
|
|
17
|
+
// - Time-colored path (blue → red gradient over the trial's duration)
|
|
18
|
+
// - Start marker (blue circle), end marker (red square)
|
|
19
|
+
// - Mousedown (green ▼), mouseup (magenta ▲)
|
|
20
|
+
// - Tab-away pairs (yellow ◆ at the leave point, cyan ◆ at return, size ∝
|
|
21
|
+
// duration), connected by a dashed line
|
|
22
|
+
// - Pause circles (grey ○) for >3s gaps that don't overlap a tab-away
|
|
23
|
+
// - Browser-window outline (purple, dashed); screen outline (teal, solid)
|
|
24
|
+
// when geometry data is present and internally consistent
|
|
25
|
+
// - Red panel border if any hard signal fired on this trial
|
|
26
|
+
// - Title with trial ID, RT, mouse count, tab-away count + total duration
|
|
27
|
+
|
|
28
|
+
import { ruleChronologicalCompare } from '../extract-core.js';
|
|
29
|
+
|
|
30
|
+
// Panel dimensions (pixels).
|
|
31
|
+
const PANEL_W = 400;
|
|
32
|
+
const PANEL_H = 300;
|
|
33
|
+
const PANEL_PAD = 40;
|
|
34
|
+
const TITLE_HEIGHT = 40;
|
|
35
|
+
const HEADER_HEIGHT = 96; // accommodates 3-line legend
|
|
36
|
+
const PANEL_MARGIN = 15; // inset between panel border and content area
|
|
37
|
+
|
|
38
|
+
// Color palette. Hoisted from inline string literals to give the rendering
|
|
39
|
+
// style one place to look. Names match the visual element they style.
|
|
40
|
+
const COLORS = {
|
|
41
|
+
bg: '#ffffff',
|
|
42
|
+
panelBg: '#fafafa',
|
|
43
|
+
panelBorder: '#ccc',
|
|
44
|
+
panelBorderHardHit: '#ff0000',
|
|
45
|
+
headerText: '#333',
|
|
46
|
+
legendText: '#666',
|
|
47
|
+
zoomTag: '#a06000',
|
|
48
|
+
noData: '#999',
|
|
49
|
+
screenRect: '#26a69a',
|
|
50
|
+
windowRect: '#7e57c2',
|
|
51
|
+
pathStart: '#0000ff',
|
|
52
|
+
pathEnd: '#ff0000',
|
|
53
|
+
pathEndStroke: '#000',
|
|
54
|
+
mousedown: '#00cc00',
|
|
55
|
+
mouseup: '#ff00ff',
|
|
56
|
+
tabAwayLeftFill: '#ffff00',
|
|
57
|
+
tabAwayLeftStroke: '#ff8c00',
|
|
58
|
+
tabAwayReturnFill: '#00ffff',
|
|
59
|
+
tabAwayReturnStroke: '#008b8b',
|
|
60
|
+
pauseCircle: '#ccc',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Panel-background tints by trial phase (0.6.1, retro item 9) — same palette
|
|
64
|
+
// as the session-timeline phase strip (session-timeline.js C.phase*), so a
|
|
65
|
+
// panel's tint and the timeline band for the same phase read as one system.
|
|
66
|
+
// Trials without a recognized phase keep the neutral panelBg.
|
|
67
|
+
const PHASE_TINTS = {
|
|
68
|
+
gallery: '#ffe0b2', // peach — matches C.phaseGallery
|
|
69
|
+
post_gallery_query: '#e1bee7', // light purple — matches C.phaseTyping
|
|
70
|
+
typing: '#e1bee7',
|
|
71
|
+
classification: '#bbdefb', // light blue — matches C.phaseClass
|
|
72
|
+
end_requery: '#eceff1', // bluish grey — matches C.phasePre/Post
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Orders a participant's trials for the panel grid (0.6.1, retro item 3).
|
|
76
|
+
// config.trajectoryDisplayOrder:
|
|
77
|
+
// 'rule' (default) chronological by rule — (rulePosition, phase-rank,
|
|
78
|
+
// trialNumber), the order each rule was actually experienced.
|
|
79
|
+
// Same comparator the ingest uses when merging phaseTrials, so
|
|
80
|
+
// already-sorted data keeps its order (stable sort).
|
|
81
|
+
// 'time' wall-clock trial timestamps (fallback: trialNumber)
|
|
82
|
+
// 'insertion' raw ingest order (pre-0.6.1 behavior)
|
|
83
|
+
// Exported for tests.
|
|
84
|
+
export function orderTrials(trials, config) {
|
|
85
|
+
const mode = config.trajectoryDisplayOrder || 'rule';
|
|
86
|
+
if (mode === 'insertion') return trials;
|
|
87
|
+
const ordered = trials.slice();
|
|
88
|
+
if (mode === 'time') {
|
|
89
|
+
ordered.sort((a, b) => {
|
|
90
|
+
const at = a?.timestamp ? Date.parse(a.timestamp) : NaN;
|
|
91
|
+
const bt = b?.timestamp ? Date.parse(b.timestamp) : NaN;
|
|
92
|
+
if (Number.isFinite(at) && Number.isFinite(bt) && at !== bt) return at - bt;
|
|
93
|
+
return (a.trialNumber ?? 0) - (b.trialNumber ?? 0);
|
|
94
|
+
});
|
|
95
|
+
} else {
|
|
96
|
+
ordered.sort(ruleChronologicalCompare);
|
|
97
|
+
}
|
|
98
|
+
return ordered;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Draws one participant's full trajectory grid (all per-trial panels) onto a
|
|
102
|
+
// canvas obtained from the injected createCanvas factory. Returns the drawn
|
|
103
|
+
// canvas, or null when the participant has no trials to plot — mirrors the
|
|
104
|
+
// `continue` in the pre-extraction renderTrajectories loop; the fs wrapper
|
|
105
|
+
// skips the PNG write in that case.
|
|
106
|
+
export function drawTrajectoryGrid(p, triageEntry, config, createCanvas) {
|
|
107
|
+
const trials = orderTrials(p.trials, config);
|
|
108
|
+
if (trials.length === 0) return null;
|
|
109
|
+
|
|
110
|
+
// Compute grid layout
|
|
111
|
+
const cols = Math.min(5, trials.length);
|
|
112
|
+
const rows = Math.ceil(trials.length / cols);
|
|
113
|
+
|
|
114
|
+
const canvasW = cols * (PANEL_W + PANEL_PAD) + PANEL_PAD;
|
|
115
|
+
const canvasH = HEADER_HEIGHT + rows * (PANEL_H + TITLE_HEIGHT + PANEL_PAD) + PANEL_PAD;
|
|
116
|
+
const canvas = createCanvas(canvasW, canvasH);
|
|
117
|
+
const ctx = canvas.getContext('2d');
|
|
118
|
+
|
|
119
|
+
// Background
|
|
120
|
+
ctx.fillStyle = COLORS.bg;
|
|
121
|
+
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
122
|
+
|
|
123
|
+
// Header
|
|
124
|
+
const headerText = `${p.participantId} — Score: ${triageEntry?.score ?? '?'} — ${triageEntry?.reason ?? ''}`;
|
|
125
|
+
ctx.fillStyle = COLORS.headerText;
|
|
126
|
+
ctx.font = 'bold 16px sans-serif';
|
|
127
|
+
ctx.fillText(headerText, PANEL_PAD, 30);
|
|
128
|
+
|
|
129
|
+
// Two-line legend — splitting keeps the panel-frame meaning visible rather
|
|
130
|
+
// than buried at the end of a wrap.
|
|
131
|
+
ctx.font = '11px sans-serif';
|
|
132
|
+
ctx.fillStyle = COLORS.legendText;
|
|
133
|
+
ctx.fillText(
|
|
134
|
+
'Blue●=start Red■=end Green▼=mousedown Magenta▲=mouseup Yellow◆=tab-away (left) Cyan◆=tab-away (returned) Grey○=pause',
|
|
135
|
+
PANEL_PAD, 48
|
|
136
|
+
);
|
|
137
|
+
ctx.fillText(
|
|
138
|
+
'Teal outer rect=screen Purple dashed rect=browser window Red panel frame=hard signal triggered on this trial',
|
|
139
|
+
PANEL_PAD, 64
|
|
140
|
+
);
|
|
141
|
+
ctx.fillText(
|
|
142
|
+
'Panel tint=phase: peach gallery, purple typing/query, blue classification, grey re-query (neutral = unphased) — matches the session-timeline strip',
|
|
143
|
+
PANEL_PAD, 80
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// session.windowPositions is the 2-second-poll-plus-resize-event record
|
|
147
|
+
// from core/signals/browser.js. Per-trial geometry uses the sample whose
|
|
148
|
+
// timestamp best fits the trial's mouse extent (see
|
|
149
|
+
// pickWindowGeometryForTrial). Older recordings without sw/sh fields
|
|
150
|
+
// yield a window-only geometry; chooseScreenFrame degrades gracefully.
|
|
151
|
+
const windowPositions = p.session?.windowPositions || [];
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < trials.length; i++) {
|
|
154
|
+
const col = i % cols;
|
|
155
|
+
const row = Math.floor(i / cols);
|
|
156
|
+
const x0 = PANEL_PAD + col * (PANEL_W + PANEL_PAD);
|
|
157
|
+
const y0 = HEADER_HEIGHT + PANEL_PAD + row * (PANEL_H + TITLE_HEIGHT + PANEL_PAD);
|
|
158
|
+
|
|
159
|
+
// Geometry resolution priority: explicit per-trial > session-end > polled.
|
|
160
|
+
// Per-trial wins because it was captured AT trial render time and can't
|
|
161
|
+
// be stale; session-end can drift if the participant resized between
|
|
162
|
+
// last sample and end-of-experiment. Polled fills in for jsPsych
|
|
163
|
+
// extension data, which has no explicit per-trial geometry field.
|
|
164
|
+
const sessionMeta = p.metadata || {};
|
|
165
|
+
const sessionGeom = sessionMeta.geometry || {};
|
|
166
|
+
const trialGeom = trials[i].geometry || {};
|
|
167
|
+
const polledGeom = pickWindowGeometryForTrial(trials[i], windowPositions) || {};
|
|
168
|
+
const effectiveMeta = { ...sessionMeta, ...sessionGeom, ...polledGeom, ...trialGeom };
|
|
169
|
+
renderTrialPanel(ctx, trials[i], x0, y0, config, effectiveMeta);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return canvas;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
|
|
176
|
+
const mouse = trial.mouseEvents || [];
|
|
177
|
+
const tabAways = trial.tabAwayEvents || [];
|
|
178
|
+
// String() guard: a numeric trialId/ruleId (from a hand-edited payload or a
|
|
179
|
+
// dynamicTyping CSV) would otherwise throw on .substring() below and, with no
|
|
180
|
+
// per-participant boundary, abort every remaining visual.
|
|
181
|
+
const trialId = String(trial.trialId || trial.ruleId || '?');
|
|
182
|
+
const rt = (trial.duration_ms || trial.responseTime_ms || 0) / 1000;
|
|
183
|
+
const moves = mouse.filter(e => e.type === 'move');
|
|
184
|
+
const downs = mouse.filter(e => e.type === 'down');
|
|
185
|
+
const ups = mouse.filter(e => e.type === 'up');
|
|
186
|
+
|
|
187
|
+
// Panel border — red if any hard-signal trialHits fired on this trial.
|
|
188
|
+
const anyHardHit = trial.trialSignals?.hard &&
|
|
189
|
+
Object.values(trial.trialSignals.hard).some(s => (s.trialHits || 0) > 0);
|
|
190
|
+
ctx.strokeStyle = anyHardHit ? COLORS.panelBorderHardHit : COLORS.panelBorder;
|
|
191
|
+
ctx.lineWidth = anyHardHit ? 3 : 1;
|
|
192
|
+
ctx.strokeRect(x0, y0 + TITLE_HEIGHT, PANEL_W, PANEL_H);
|
|
193
|
+
|
|
194
|
+
// Panel background — tinted by trial phase (see PHASE_TINTS), neutral for
|
|
195
|
+
// unphased trials.
|
|
196
|
+
ctx.fillStyle = PHASE_TINTS[trial.phase] || COLORS.panelBg;
|
|
197
|
+
ctx.fillRect(x0 + 1, y0 + TITLE_HEIGHT + 1, PANEL_W - 2, PANEL_H - 2);
|
|
198
|
+
|
|
199
|
+
// Title
|
|
200
|
+
const nTabs = tabAways.length;
|
|
201
|
+
const tabDur = tabAways.reduce((s, e) => s + (e.duration_ms || 0), 0) / 1000;
|
|
202
|
+
ctx.fillStyle = COLORS.headerText;
|
|
203
|
+
ctx.font = '11px sans-serif';
|
|
204
|
+
ctx.fillText(`${trialId.substring(0, 20)} — ${rt.toFixed(0)}s, ${moves.length} mv, ${nTabs} tabs (${tabDur.toFixed(0)}s)`,
|
|
205
|
+
x0, y0 + TITLE_HEIGHT - 8);
|
|
206
|
+
|
|
207
|
+
if (moves.length === 0) {
|
|
208
|
+
ctx.fillStyle = COLORS.noData;
|
|
209
|
+
ctx.font = '12px sans-serif';
|
|
210
|
+
ctx.fillText('No mouse data', x0 + PANEL_W / 2 - 40, y0 + TITLE_HEIGHT + PANEL_H / 2);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Choose a geometry frame and decide which rectangles can be drawn honestly.
|
|
215
|
+
// Four cases, in priority order:
|
|
216
|
+
// 1. SCREEN-API — metadata.screens (Window Management API) present.
|
|
217
|
+
// Multi-monitor layout known; render in true screen space
|
|
218
|
+
// and draw both rectangles.
|
|
219
|
+
// 2. LEGACY — screenWidth/screenHeight present. Draw both rects when
|
|
220
|
+
// the window geometry doesn't paradoxically exceed the
|
|
221
|
+
// screen (zoom-out, multi-monitor). When it does, drop
|
|
222
|
+
// the outer rect — drawing a "screen" smaller than the
|
|
223
|
+
// "window" it supposedly contains is dishonest.
|
|
224
|
+
// 3. WINDOW-ONLY — window geometry but no screen size. Fit canvas to the
|
|
225
|
+
// window outline and draw it alone.
|
|
226
|
+
// 4. AUTO-FIT — no geometry at all; auto-fit to mouse path.
|
|
227
|
+
const screenFrame = chooseScreenFrame(metadata);
|
|
228
|
+
let minX, maxX, minY, maxY, offsetX, offsetY;
|
|
229
|
+
if (screenFrame.mode === 'screen-api' || screenFrame.mode === 'legacy') {
|
|
230
|
+
const { screenW, screenH, winX, winY, winW, winH } = screenFrame;
|
|
231
|
+
minX = 0;
|
|
232
|
+
minY = 0;
|
|
233
|
+
maxX = Math.max(screenW, winX + winW);
|
|
234
|
+
maxY = Math.max(screenH, winY + winH);
|
|
235
|
+
offsetX = winX;
|
|
236
|
+
offsetY = winY;
|
|
237
|
+
} else if (screenFrame.mode === 'window-only') {
|
|
238
|
+
// Frame the canvas around the window itself. Mouse coords are
|
|
239
|
+
// viewport-relative (0..innerWidth), so adding offsetX=winX places the
|
|
240
|
+
// trajectory inside the window outline at roughly the right spot. The
|
|
241
|
+
// chrome offset isn't accounted for, but for a no-screen visualization
|
|
242
|
+
// this is honest enough — the window outline shows scale and position.
|
|
243
|
+
const { winX, winY, winW, winH } = screenFrame;
|
|
244
|
+
minX = winX;
|
|
245
|
+
minY = winY;
|
|
246
|
+
maxX = winX + winW;
|
|
247
|
+
maxY = winY + winH;
|
|
248
|
+
offsetX = winX;
|
|
249
|
+
offsetY = winY;
|
|
250
|
+
} else {
|
|
251
|
+
// Auto-fit fallback
|
|
252
|
+
const allX = moves.map(m => m.x);
|
|
253
|
+
const allY = moves.map(m => m.y);
|
|
254
|
+
minX = Math.min(...allX) - 20;
|
|
255
|
+
maxX = Math.max(...allX) + 20;
|
|
256
|
+
minY = Math.min(...allY) - 20;
|
|
257
|
+
maxY = Math.max(...allY) + 20;
|
|
258
|
+
offsetX = 0;
|
|
259
|
+
offsetY = 0;
|
|
260
|
+
}
|
|
261
|
+
const rangeX = maxX - minX || 1;
|
|
262
|
+
const rangeY = maxY - minY || 1;
|
|
263
|
+
|
|
264
|
+
// Zoom-robust mouse plotting. Mouse coords (clientX/Y) are in viewport CSS
|
|
265
|
+
// pixels; the window outline is in screen CSS pixels. At non-100% browser
|
|
266
|
+
// zoom these scales differ — clicks would plot outside the outline.
|
|
267
|
+
// Scaling mouse coords by outer/inner ratio aligns them with the outline.
|
|
268
|
+
// Critically, X and Y scales are computed separately because chrome
|
|
269
|
+
// geometry isn't symmetric (top tabs+address bar vs ~zero left/right).
|
|
270
|
+
// Skipped in auto-fit mode (no outline → no alignment needed).
|
|
271
|
+
const zoom = (screenFrame.mode === 'auto-fit') ? { x: 1, y: 1 } : computeZoomScale(metadata);
|
|
272
|
+
|
|
273
|
+
const mapX = (px) => x0 + PANEL_MARGIN + ((px * zoom.x + offsetX - minX) / rangeX) * (PANEL_W - 2 * PANEL_MARGIN);
|
|
274
|
+
const mapY = (py) => y0 + TITLE_HEIGHT + PANEL_MARGIN + ((py * zoom.y + offsetY - minY) / rangeY) * (PANEL_H - 2 * PANEL_MARGIN);
|
|
275
|
+
|
|
276
|
+
// Screen + browser-window rectangles. The frame chooser above decides which
|
|
277
|
+
// rectangles are safe to draw. `drawOuter` is false for legacy data with a
|
|
278
|
+
// window/screen paradox (zoom-out, multi-monitor) — we'd rather show nothing
|
|
279
|
+
// than a screen rect smaller than the window it supposedly contains. It's
|
|
280
|
+
// also false for window-only mode (no screen data at all).
|
|
281
|
+
if (screenFrame.mode === 'screen-api' || screenFrame.mode === 'legacy' || screenFrame.mode === 'window-only') {
|
|
282
|
+
const { screenW, screenH, winX, winY, winW, winH, drawOuter } = screenFrame;
|
|
283
|
+
const rectMapX = (px) => x0 + PANEL_MARGIN + ((px - minX) / rangeX) * (PANEL_W - 2 * PANEL_MARGIN);
|
|
284
|
+
const rectMapY = (py) => y0 + TITLE_HEIGHT + PANEL_MARGIN + ((py - minY) / rangeY) * (PANEL_H - 2 * PANEL_MARGIN);
|
|
285
|
+
ctx.save();
|
|
286
|
+
ctx.lineWidth = 1;
|
|
287
|
+
if (drawOuter) {
|
|
288
|
+
ctx.strokeStyle = COLORS.screenRect;
|
|
289
|
+
ctx.strokeRect(
|
|
290
|
+
rectMapX(0), rectMapY(0),
|
|
291
|
+
rectMapX(screenW) - rectMapX(0),
|
|
292
|
+
rectMapY(screenH) - rectMapY(0)
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
ctx.strokeStyle = COLORS.windowRect;
|
|
296
|
+
ctx.setLineDash([4, 3]);
|
|
297
|
+
ctx.strokeRect(
|
|
298
|
+
rectMapX(winX), rectMapY(winY),
|
|
299
|
+
rectMapX(winX + winW) - rectMapX(winX),
|
|
300
|
+
rectMapY(winY + winH) - rectMapY(winY)
|
|
301
|
+
);
|
|
302
|
+
ctx.setLineDash([]);
|
|
303
|
+
ctx.restore();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Zoom indicator — appended to the title. Honest signal that the viewport
|
|
307
|
+
// is scaled, so the viewer doesn't try to compare px counts across panels
|
|
308
|
+
// with different effective scales. Detected from visualViewport.scale or
|
|
309
|
+
// from outerWidth/innerWidth deviation.
|
|
310
|
+
const zoomTag = computeZoomTag(metadata);
|
|
311
|
+
if (zoomTag) {
|
|
312
|
+
ctx.fillStyle = COLORS.zoomTag;
|
|
313
|
+
ctx.font = 'italic 10px sans-serif';
|
|
314
|
+
ctx.fillText(zoomTag, x0 + PANEL_W - 60, y0 + TITLE_HEIGHT - 8);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Time normalization for color gradient
|
|
318
|
+
const ts = moves.map(m => m.t);
|
|
319
|
+
const tMin = Math.min(...ts);
|
|
320
|
+
const tMax = Math.max(...ts);
|
|
321
|
+
const tRange = tMax - tMin || 1;
|
|
322
|
+
|
|
323
|
+
// Draw path segments with time-based color (blue→red)
|
|
324
|
+
ctx.lineWidth = 1;
|
|
325
|
+
ctx.globalAlpha = 0.6;
|
|
326
|
+
for (let j = 1; j < moves.length; j++) {
|
|
327
|
+
const tNorm = (moves[j].t - tMin) / tRange;
|
|
328
|
+
ctx.strokeStyle = timeColor(tNorm);
|
|
329
|
+
ctx.beginPath();
|
|
330
|
+
ctx.moveTo(mapX(moves[j - 1].x), mapY(moves[j - 1].y));
|
|
331
|
+
ctx.lineTo(mapX(moves[j].x), mapY(moves[j].y));
|
|
332
|
+
ctx.stroke();
|
|
333
|
+
|
|
334
|
+
// Pause markers (>3s gap with no movement).
|
|
335
|
+
// Both moves[j].t and ta.startRel_ms are trial-relative ms (normalized in
|
|
336
|
+
// ingest). Find the tab-away (if any) whose [start, start+duration] window
|
|
337
|
+
// overlaps the gap. If we find one, draw the leave/return diamond pair;
|
|
338
|
+
// otherwise drop a grey pause circle.
|
|
339
|
+
const gap = moves[j].t - moves[j - 1].t;
|
|
340
|
+
if (gap > 3000) {
|
|
341
|
+
const taStart = (ta) => (ta.startRel_ms != null ? ta.startRel_ms : ta.start);
|
|
342
|
+
const overlappingTab = tabAways.find(ta =>
|
|
343
|
+
taStart(ta) < moves[j].t + 1000 &&
|
|
344
|
+
(taStart(ta) + (ta.duration_ms || 0)) > moves[j - 1].t - 1000
|
|
345
|
+
);
|
|
346
|
+
if (overlappingTab) {
|
|
347
|
+
// Diamond size scales with √duration (square root keeps very long
|
|
348
|
+
// tab-aways from drawing as huge blobs that obscure the trajectory).
|
|
349
|
+
const dur = overlappingTab.duration_ms || gap;
|
|
350
|
+
const size = Math.min(20, Math.max(4, 4 + Math.sqrt(dur / 1000) * 3));
|
|
351
|
+
|
|
352
|
+
drawDiamond(ctx, mapX(moves[j - 1].x), mapY(moves[j - 1].y), size,
|
|
353
|
+
COLORS.tabAwayLeftFill, COLORS.tabAwayLeftStroke);
|
|
354
|
+
drawDiamond(ctx, mapX(moves[j].x), mapY(moves[j].y), size,
|
|
355
|
+
COLORS.tabAwayReturnFill, COLORS.tabAwayReturnStroke);
|
|
356
|
+
|
|
357
|
+
ctx.setLineDash([4, 3]);
|
|
358
|
+
ctx.strokeStyle = COLORS.tabAwayLeftStroke;
|
|
359
|
+
ctx.beginPath();
|
|
360
|
+
ctx.moveTo(mapX(moves[j - 1].x), mapY(moves[j - 1].y));
|
|
361
|
+
ctx.lineTo(mapX(moves[j].x), mapY(moves[j].y));
|
|
362
|
+
ctx.stroke();
|
|
363
|
+
ctx.setLineDash([]);
|
|
364
|
+
} else {
|
|
365
|
+
ctx.fillStyle = COLORS.pauseCircle;
|
|
366
|
+
ctx.beginPath();
|
|
367
|
+
ctx.arc(mapX(moves[j - 1].x), mapY(moves[j - 1].y), 3, 0, Math.PI * 2);
|
|
368
|
+
ctx.fill();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
ctx.globalAlpha = 1;
|
|
373
|
+
|
|
374
|
+
// Start marker (blue circle)
|
|
375
|
+
ctx.fillStyle = COLORS.pathStart;
|
|
376
|
+
ctx.strokeStyle = COLORS.pathEndStroke;
|
|
377
|
+
ctx.lineWidth = 0.5;
|
|
378
|
+
ctx.beginPath();
|
|
379
|
+
ctx.arc(mapX(moves[0].x), mapY(moves[0].y), 5, 0, Math.PI * 2);
|
|
380
|
+
ctx.fill();
|
|
381
|
+
ctx.stroke();
|
|
382
|
+
|
|
383
|
+
// End marker (red square)
|
|
384
|
+
const ex = mapX(moves[moves.length - 1].x);
|
|
385
|
+
const ey = mapY(moves[moves.length - 1].y);
|
|
386
|
+
ctx.fillStyle = COLORS.pathEnd;
|
|
387
|
+
ctx.fillRect(ex - 4, ey - 4, 8, 8);
|
|
388
|
+
ctx.strokeRect(ex - 4, ey - 4, 8, 8);
|
|
389
|
+
|
|
390
|
+
// Mousedown markers (green ▼)
|
|
391
|
+
ctx.fillStyle = COLORS.mousedown;
|
|
392
|
+
for (const d of downs) {
|
|
393
|
+
drawTriangle(ctx, mapX(d.x), mapY(d.y), 5, 'down');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Mouseup markers (magenta ▲)
|
|
397
|
+
ctx.fillStyle = COLORS.mouseup;
|
|
398
|
+
for (const u of ups) {
|
|
399
|
+
drawTriangle(ctx, mapX(u.x), mapY(u.y), 5, 'up');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Interpolates blue→red based on normalized time (0→1)
|
|
404
|
+
function timeColor(t) {
|
|
405
|
+
const r = Math.round(t * 255);
|
|
406
|
+
const b = Math.round((1 - t) * 255);
|
|
407
|
+
return `rgb(${r}, 50, ${b})`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function drawDiamond(ctx, x, y, size, fill, stroke) {
|
|
411
|
+
ctx.fillStyle = fill;
|
|
412
|
+
ctx.strokeStyle = stroke;
|
|
413
|
+
ctx.lineWidth = 1.5;
|
|
414
|
+
ctx.beginPath();
|
|
415
|
+
ctx.moveTo(x, y - size);
|
|
416
|
+
ctx.lineTo(x + size, y);
|
|
417
|
+
ctx.lineTo(x, y + size);
|
|
418
|
+
ctx.lineTo(x - size, y);
|
|
419
|
+
ctx.closePath();
|
|
420
|
+
ctx.fill();
|
|
421
|
+
ctx.stroke();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function drawTriangle(ctx, x, y, size, direction) {
|
|
425
|
+
ctx.beginPath();
|
|
426
|
+
if (direction === 'down') {
|
|
427
|
+
ctx.moveTo(x - size, y - size);
|
|
428
|
+
ctx.lineTo(x + size, y - size);
|
|
429
|
+
ctx.lineTo(x, y + size);
|
|
430
|
+
} else {
|
|
431
|
+
ctx.moveTo(x - size, y + size);
|
|
432
|
+
ctx.lineTo(x + size, y + size);
|
|
433
|
+
ctx.lineTo(x, y - size);
|
|
434
|
+
}
|
|
435
|
+
ctx.closePath();
|
|
436
|
+
ctx.fill();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Used by the fs wrapper to build the per-participant PNG filename. Exported
|
|
440
|
+
// (rather than kept private) to match session-timeline-core.js's sanitize,
|
|
441
|
+
// which the browser demo plot adapter is also expected to reuse.
|
|
442
|
+
export function sanitize(name) {
|
|
443
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Picks the windowPositions sample closest in time to the trial's start
|
|
447
|
+
// anchor and translates it into the field shape chooseScreenFrame() expects.
|
|
448
|
+
//
|
|
449
|
+
// Returns null when:
|
|
450
|
+
// - windowPositions is empty (e.g. signal disabled, or pre-tracking data)
|
|
451
|
+
// - the trial has no trialStart_perfNow (no time anchor → can't pick)
|
|
452
|
+
//
|
|
453
|
+
// Translation: monitor stores compact field names (x/y/w/h/iw/ih/sw/sh) for
|
|
454
|
+
// terse JSON; the renderer's chooseScreenFrame reads expanded names. Doing
|
|
455
|
+
// the rename here keeps both sides clean.
|
|
456
|
+
//
|
|
457
|
+
// The screenWidth/screenHeight fields are conditionally included — the
|
|
458
|
+
// April 2026 monitor change added sw/sh capture, but older recordings
|
|
459
|
+
// won't have it. Omitting (rather than zero-filling) lets chooseScreenFrame
|
|
460
|
+
// fall through its compatibility/auto-fit branches honestly.
|
|
461
|
+
export function pickWindowGeometryForTrial(trial, windowPositions) {
|
|
462
|
+
if (!Array.isArray(windowPositions) || windowPositions.length === 0) return null;
|
|
463
|
+
// Time anchor for matching the right windowPositions sample.
|
|
464
|
+
//
|
|
465
|
+
// jsPsych extension data carries `trialStart_perfNow` (set by the wrapper's
|
|
466
|
+
// on_load). Raw-DOM extension users save
|
|
467
|
+
// `startTime` directly from `performance.now()` at trial start. Both are in
|
|
468
|
+
// the same reference frame as `windowPositions[].t` (performance.now()), so
|
|
469
|
+
// either works.
|
|
470
|
+
//
|
|
471
|
+
// Without this fallback, raw-DOM users see auto-fit or stale session-level
|
|
472
|
+
// window dimensions — symptom: the dashed "window" box in the trajectory
|
|
473
|
+
// PNG is sized for the pre-fullscreen viewport even when the trial happened
|
|
474
|
+
// in fullscreen, so mouse dots plot outside the box.
|
|
475
|
+
const anchor = (typeof trial?.trialStart_perfNow === 'number')
|
|
476
|
+
? trial.trialStart_perfNow
|
|
477
|
+
: (typeof trial?.startTime === 'number' ? trial.startTime : null);
|
|
478
|
+
if (typeof anchor !== 'number') return null;
|
|
479
|
+
|
|
480
|
+
// Selection strategy (in priority order):
|
|
481
|
+
// 1. Among samples within the trial's time range, prefer ones whose
|
|
482
|
+
// viewport (after zoom-scaling) contains the trial's max mouse extent.
|
|
483
|
+
// This handles trials that span a window resize — the trial's mouse
|
|
484
|
+
// events may live in different viewport states, so we pick the one
|
|
485
|
+
// that best contains them visually. Closest-in-time among fitting
|
|
486
|
+
// candidates.
|
|
487
|
+
// 2. If no in-range sample fits, pick the in-range sample with the
|
|
488
|
+
// largest viewport (best chance of mouse showing inside outline).
|
|
489
|
+
// 3. If nothing in range at all, fall back to the global closest-in-time.
|
|
490
|
+
//
|
|
491
|
+
// Without this, a trial that spanned a resize would get an arbitrary
|
|
492
|
+
// snapshot and mouse plotted outside the outline — visually misleading.
|
|
493
|
+
|
|
494
|
+
const duration = trial.duration_ms || 0;
|
|
495
|
+
const trialEnd = anchor + duration;
|
|
496
|
+
const moves = (trial.mouseEvents || []).filter(e => e.type === 'move');
|
|
497
|
+
const maxX = moves.length ? Math.max(...moves.map(m => m.x)) : 0;
|
|
498
|
+
const maxY = moves.length ? Math.max(...moves.map(m => m.y)) : 0;
|
|
499
|
+
|
|
500
|
+
function fits(s) {
|
|
501
|
+
if (!s.w || !s.iw || !s.h || !s.ih) return false;
|
|
502
|
+
return (maxX * s.w / s.iw) <= s.w && (maxY * s.h / s.ih) <= s.h;
|
|
503
|
+
}
|
|
504
|
+
function viewportArea(s) { return (s.iw || 0) * (s.ih || 0); }
|
|
505
|
+
function timeDelta(s) { return Math.abs(s.t - anchor); }
|
|
506
|
+
|
|
507
|
+
// Allow a 1-second slop on each end of the trial to account for resize
|
|
508
|
+
// event debouncing (samples may land slightly outside the trial window).
|
|
509
|
+
const inRange = duration > 0
|
|
510
|
+
? windowPositions.filter(s => s.t >= anchor - 1000 && s.t <= trialEnd + 1000)
|
|
511
|
+
: windowPositions;
|
|
512
|
+
|
|
513
|
+
let best;
|
|
514
|
+
const fitting = inRange.filter(fits);
|
|
515
|
+
if (fitting.length > 0) {
|
|
516
|
+
best = fitting.reduce((a, b) => timeDelta(a) <= timeDelta(b) ? a : b);
|
|
517
|
+
} else if (inRange.length > 0) {
|
|
518
|
+
best = inRange.reduce((a, b) => viewportArea(a) >= viewportArea(b) ? a : b);
|
|
519
|
+
} else {
|
|
520
|
+
best = windowPositions.reduce((a, b) => timeDelta(a) <= timeDelta(b) ? a : b);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const out = {
|
|
524
|
+
windowX: best.x,
|
|
525
|
+
windowY: best.y,
|
|
526
|
+
windowWidth: best.w,
|
|
527
|
+
windowHeight: best.h,
|
|
528
|
+
// Both outer and inner dimensions on BOTH axes are needed by
|
|
529
|
+
// computeZoomScale — chrome asymmetry means the X and Y zoom factors
|
|
530
|
+
// genuinely differ. Earlier versions of this function dropped the
|
|
531
|
+
// height fields, which silently zeroed out the Y scale and plotted
|
|
532
|
+
// mouse Y way outside the outline at non-100% zoom.
|
|
533
|
+
outerWidth: best.w,
|
|
534
|
+
outerHeight: best.h,
|
|
535
|
+
innerWidth: best.iw,
|
|
536
|
+
innerHeight: best.ih,
|
|
537
|
+
};
|
|
538
|
+
if (typeof best.sw === 'number' && typeof best.sh === 'number') {
|
|
539
|
+
out.screenWidth = best.sw;
|
|
540
|
+
out.screenHeight = best.sh;
|
|
541
|
+
}
|
|
542
|
+
// Zoom diagnostics added April 2026. Optional — older recordings won't
|
|
543
|
+
// have these fields. devicePixelRatio captures HIDPI/retina scaling;
|
|
544
|
+
// visualViewportScale captures pinch zoom (touch devices, distinct
|
|
545
|
+
// from CSS-level browser zoom which we infer from outerWidth/innerWidth).
|
|
546
|
+
if (typeof best.dpr === 'number') out.devicePixelRatio = best.dpr;
|
|
547
|
+
if (typeof best.vvScale === 'number') out.visualViewportScale = best.vvScale;
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// CSS-level browser zoom (Cmd-Plus / Cmd-Minus on desktop) has no direct JS
|
|
552
|
+
// API. We infer it from the ratio of outer (window frame, fixed in screen
|
|
553
|
+
// pixels) to inner (viewport, in CSS pixels that scale with zoom):
|
|
554
|
+
// zoom_factor ≈ outerWidth / innerWidth
|
|
555
|
+
// - ≈ 1 at 100% zoom
|
|
556
|
+
// - < 1 zoomed out (viewport reports MORE CSS pixels than outer frame)
|
|
557
|
+
// - > 1 zoomed in (viewport reports FEWER CSS pixels than outer frame)
|
|
558
|
+
//
|
|
559
|
+
// Critically, X and Y zoom factors can differ because chrome geometry isn't
|
|
560
|
+
// symmetric: most browsers have ~0px left/right chrome but ~80px top chrome
|
|
561
|
+
// (tabs + address bar). On macOS retina especially, this asymmetry creates
|
|
562
|
+
// significantly different x and y ratios, so a single uniform scale plots
|
|
563
|
+
// mouse outside the outline on one axis. Returning {x, y} fixes this.
|
|
564
|
+
//
|
|
565
|
+
// Both fields default to 1 (identity, no scaling) when either dimension is
|
|
566
|
+
// missing or non-positive — keeps callers honest without per-call null checks.
|
|
567
|
+
export function computeZoomScale(metadata) {
|
|
568
|
+
if (!metadata) return { x: 1, y: 1 };
|
|
569
|
+
const ow = metadata.outerWidth, iw = metadata.innerWidth;
|
|
570
|
+
const oh = metadata.outerHeight, ih = metadata.innerHeight;
|
|
571
|
+
return {
|
|
572
|
+
x: (typeof ow === 'number' && typeof iw === 'number' && ow > 0 && iw > 0) ? ow / iw : 1,
|
|
573
|
+
y: (typeof oh === 'number' && typeof ih === 'number' && oh > 0 && ih > 0) ? oh / ih : 1,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Picks the geometry frame and decides which rectangles are safe to draw.
|
|
578
|
+
// Returns one of FOUR shapes:
|
|
579
|
+
// { mode: 'screen-api', screenW, screenH, winX, winY, winW, winH, drawOuter: true }
|
|
580
|
+
// { mode: 'legacy', screenW, screenH, winX, winY, winW, winH, drawOuter }
|
|
581
|
+
// { mode: 'window-only', winX, winY, winW, winH, drawOuter: false }
|
|
582
|
+
// { mode: 'auto-fit' }
|
|
583
|
+
//
|
|
584
|
+
// The 'screen-api' branch trusts metadata.screens (Window Management API) and
|
|
585
|
+
// uses the screen the window currently sits on (currentScreenLabel-matched)
|
|
586
|
+
// rather than the primary monitor — eliminates multi-monitor paradoxes.
|
|
587
|
+
//
|
|
588
|
+
// The 'legacy' branch covers data with screenWidth/screenHeight: draw both
|
|
589
|
+
// rects when the geometry is internally consistent, drop the outer rect when
|
|
590
|
+
// the window paradoxically exceeds the (primary-only) screen.
|
|
591
|
+
//
|
|
592
|
+
// The 'window-only' branch is for jsPsych extension data captured before
|
|
593
|
+
// the April 2026 monitor change added window.screen.width/height to its
|
|
594
|
+
// polled samples.
|
|
595
|
+
//
|
|
596
|
+
// The 'auto-fit' branch is the catch-all: no usable geometry, fit canvas to
|
|
597
|
+
// the mouse path.
|
|
598
|
+
export function chooseScreenFrame(metadata) {
|
|
599
|
+
if (!metadata) return { mode: 'auto-fit' };
|
|
600
|
+
|
|
601
|
+
// Branch 1: Window Management API data present.
|
|
602
|
+
if (Array.isArray(metadata.screens) && metadata.screens.length > 0) {
|
|
603
|
+
// Pick the screen the window lives on. If currentScreenLabel matches a
|
|
604
|
+
// labeled entry, use it; otherwise prefer the screen whose bounds contain
|
|
605
|
+
// the window origin; otherwise fall back to the primary.
|
|
606
|
+
const current = pickCurrentScreen(metadata);
|
|
607
|
+
const winX = metadata.windowX ?? 0;
|
|
608
|
+
const winY = metadata.windowY ?? 0;
|
|
609
|
+
const winW = metadata.windowWidth || current.width;
|
|
610
|
+
const winH = metadata.windowHeight || current.height;
|
|
611
|
+
return {
|
|
612
|
+
mode: 'screen-api',
|
|
613
|
+
screenW: current.width,
|
|
614
|
+
screenH: current.height,
|
|
615
|
+
winX, winY, winW, winH,
|
|
616
|
+
drawOuter: true
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Branch 2: legacy data — primaryScreenWidth or screenWidth.
|
|
621
|
+
const screenW = metadata.primaryScreenWidth ?? metadata.screenWidth;
|
|
622
|
+
const screenH = metadata.primaryScreenHeight ?? metadata.screenHeight;
|
|
623
|
+
if (screenW > 0 && screenH > 0) {
|
|
624
|
+
const winX = metadata.windowX ?? 0;
|
|
625
|
+
const winY = metadata.windowY ?? 0;
|
|
626
|
+
const winW = metadata.windowWidth || screenW;
|
|
627
|
+
const winH = metadata.windowHeight || screenH;
|
|
628
|
+
// Paradox check: window must fit inside screen for the outer rect to be
|
|
629
|
+
// honest. A 1px tolerance accommodates rounding. If the window exceeds
|
|
630
|
+
// the screen on any side, drop the outer rect — the participant likely
|
|
631
|
+
// zoomed out, used a secondary monitor, or has DPR scaling weirdness.
|
|
632
|
+
const tol = 1;
|
|
633
|
+
const fits =
|
|
634
|
+
winX >= -tol && winY >= -tol &&
|
|
635
|
+
(winX + winW) <= (screenW + tol) &&
|
|
636
|
+
(winY + winH) <= (screenH + tol);
|
|
637
|
+
return {
|
|
638
|
+
mode: 'legacy',
|
|
639
|
+
screenW, screenH, winX, winY, winW, winH,
|
|
640
|
+
drawOuter: fits
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Branch 3: window-only — we have window geometry but no screen size.
|
|
645
|
+
// Recordings made before the April 2026 monitor change (which added
|
|
646
|
+
// window.screen.width/height capture) hit this path. Draw just the
|
|
647
|
+
// browser-window rectangle; the screen rectangle is omitted because we
|
|
648
|
+
// genuinely don't know the screen dimensions.
|
|
649
|
+
if (metadata.windowWidth > 0 && metadata.windowHeight > 0) {
|
|
650
|
+
return {
|
|
651
|
+
mode: 'window-only',
|
|
652
|
+
winX: metadata.windowX ?? 0,
|
|
653
|
+
winY: metadata.windowY ?? 0,
|
|
654
|
+
winW: metadata.windowWidth,
|
|
655
|
+
winH: metadata.windowHeight,
|
|
656
|
+
drawOuter: false,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return { mode: 'auto-fit' };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function pickCurrentScreen(metadata) {
|
|
664
|
+
const screens = metadata.screens;
|
|
665
|
+
if (metadata.currentScreenLabel) {
|
|
666
|
+
const match = screens.find(s => s.label === metadata.currentScreenLabel);
|
|
667
|
+
if (match) return match;
|
|
668
|
+
}
|
|
669
|
+
// Prefer the screen whose availLeft/availTop bound the window origin.
|
|
670
|
+
if (typeof metadata.windowX === 'number' && typeof metadata.windowY === 'number') {
|
|
671
|
+
const containing = screens.find(s =>
|
|
672
|
+
metadata.windowX >= (s.availLeft ?? 0) &&
|
|
673
|
+
metadata.windowX < (s.availLeft ?? 0) + s.width &&
|
|
674
|
+
metadata.windowY >= (s.availTop ?? 0) &&
|
|
675
|
+
metadata.windowY < (s.availTop ?? 0) + s.height
|
|
676
|
+
);
|
|
677
|
+
if (containing) return containing;
|
|
678
|
+
}
|
|
679
|
+
return screens.find(s => s.isPrimary) || screens[0];
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Compute a "zoom × N" tag for the panel title when the participant has
|
|
683
|
+
// adjusted browser zoom away from 100%. Returns a string like "zoom × 0.75"
|
|
684
|
+
// or null when zoom is within tolerance.
|
|
685
|
+
//
|
|
686
|
+
// Two heuristics, in order of directness:
|
|
687
|
+
// 1. visualViewport.scale — direct API value, exact for pinch zoom on touch
|
|
688
|
+
// devices. Doesn't catch CSS-level browser zoom (Cmd-Plus / Cmd-Minus on
|
|
689
|
+
// desktop), which has no JS API.
|
|
690
|
+
// 2. outerWidth / innerWidth ratio — proxy for CSS-level zoom. outer stays
|
|
691
|
+
// fixed in screen pixels; inner scales with zoom. Ratio ≈ 1 at 100%,
|
|
692
|
+
// < 1 zoomed out, > 1 zoomed in.
|
|
693
|
+
//
|
|
694
|
+
// Threshold of 10% (0.9 < ratio < 1.1) absorbs the typical chrome offset and
|
|
695
|
+
// scrollbar width; only flags clear deviation from 100%.
|
|
696
|
+
export function computeZoomTag(metadata) {
|
|
697
|
+
if (!metadata) return null;
|
|
698
|
+
const tol = 0.05;
|
|
699
|
+
const vvScale = metadata.visualViewportScale;
|
|
700
|
+
if (typeof vvScale === 'number' && Math.abs(vvScale - 1) > tol) {
|
|
701
|
+
return `zoom × ${vvScale.toFixed(2)}`;
|
|
702
|
+
}
|
|
703
|
+
// Read innerWidth directly. Earlier versions fell back to windowWidth here
|
|
704
|
+
// — but in the polled-geometry pipeline, windowWidth is an alias for
|
|
705
|
+
// outerWidth, so the fallback gave outerWidth/outerWidth = 1 and the tag
|
|
706
|
+
// never fired. Use innerWidth strictly.
|
|
707
|
+
const ow = metadata.outerWidth;
|
|
708
|
+
const iw = metadata.innerWidth;
|
|
709
|
+
if (typeof ow === 'number' && typeof iw === 'number' && iw > 0) {
|
|
710
|
+
const ratio = ow / iw;
|
|
711
|
+
if (Math.abs(ratio - 1) > 0.1) {
|
|
712
|
+
const zoomEstimate = 1 / ratio;
|
|
713
|
+
return `zoom × ${zoomEstimate.toFixed(2)}`;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}
|