@treelocator/runtime 0.3.2 → 0.4.0

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,908 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Dejitter — Animation frame recorder & jank detector (vendored)
4
+ *
5
+ * Ported from dejitter/recorder.js as an ES module factory.
6
+ * Records every rAF at full speed, then downsamples intelligently on getData().
7
+ */
8
+
9
+ export function createDejitterRecorder() {
10
+ const DEFAULT_CONFIG = {
11
+ selector: '*',
12
+ props: ['opacity', 'transform'],
13
+ sampleRate: 15,
14
+ maxDuration: 10000,
15
+ minTextLength: 0,
16
+ mutations: false,
17
+ idleTimeout: 2000,
18
+ thresholds: {
19
+ jitter: {
20
+ minDeviation: 1,
21
+ maxDuration: 1000,
22
+ highSeverity: 20,
23
+ medSeverity: 5
24
+ },
25
+ shiver: {
26
+ minReversals: 5,
27
+ minDensity: 0.3,
28
+ highDensity: 0.7,
29
+ medDensity: 0.5,
30
+ minDelta: 0.01
31
+ },
32
+ jump: {
33
+ medianMultiplier: 10,
34
+ minAbsolute: 50,
35
+ highMultiplier: 50,
36
+ medMultiplier: 20
37
+ },
38
+ stutter: {
39
+ velocityRatio: 0.3,
40
+ maxFrames: 3,
41
+ minVelocity: 0.5
42
+ },
43
+ stuck: {
44
+ minStillFrames: 3,
45
+ maxDelta: 0.5,
46
+ minSurroundingVelocity: 1,
47
+ highDuration: 500,
48
+ medDuration: 200
49
+ },
50
+ outlier: {
51
+ ratioThreshold: 3
52
+ }
53
+ }
54
+ };
55
+ let config = {
56
+ ...DEFAULT_CONFIG
57
+ };
58
+ let recording = false;
59
+ let startTime = 0;
60
+ let rafId = null;
61
+ let stopTimer = null;
62
+ let mutationObserver = null;
63
+ let onStopCallbacks = [];
64
+ let lastSeen = new Map();
65
+ let rawFrames = [];
66
+ let lastChangeTime = 0;
67
+ let hasSeenChange = false;
68
+ let mutations = [];
69
+ let nextElemId = 0;
70
+ function elemId(el) {
71
+ if (!el.__dj_id) {
72
+ el.__dj_id = `e${nextElemId++}`;
73
+ el.__dj_label = {
74
+ tag: el.tagName.toLowerCase(),
75
+ cls: (typeof el.className === 'string' ? el.className : '').slice(0, 80),
76
+ text: (el.textContent || '').trim().slice(0, 60)
77
+ };
78
+ }
79
+ return el.__dj_id;
80
+ }
81
+ function readProps(el) {
82
+ const out = {};
83
+ const computed = getComputedStyle(el);
84
+ for (const p of config.props) {
85
+ if (p === 'boundingRect') {
86
+ const r = el.getBoundingClientRect();
87
+ out['rect.x'] = Math.round(r.x);
88
+ out['rect.y'] = Math.round(r.y);
89
+ out['rect.w'] = Math.round(r.width);
90
+ out['rect.h'] = Math.round(r.height);
91
+ } else if (p === 'scroll') {
92
+ out['scrollTop'] = Math.round(el.scrollTop);
93
+ out['scrollHeight'] = Math.round(el.scrollHeight);
94
+ } else if (p === 'textContent') {
95
+ out['textLen'] = (el.textContent || '').length;
96
+ } else {
97
+ out[p] = computed.getPropertyValue(p);
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+ function computeDelta(id, current) {
103
+ const prev = lastSeen.get(id);
104
+ if (!prev) {
105
+ lastSeen.set(id, {
106
+ ...current
107
+ });
108
+ return current;
109
+ }
110
+ const delta = {};
111
+ let changed = false;
112
+ for (const [k, v] of Object.entries(current)) {
113
+ if (prev[k] !== v) {
114
+ delta[k] = v;
115
+ prev[k] = v;
116
+ changed = true;
117
+ }
118
+ }
119
+ return changed ? delta : null;
120
+ }
121
+ function sampleAll() {
122
+ const t = Math.round(performance.now() - startTime);
123
+ const elements = document.querySelectorAll(config.selector);
124
+ const frameDelta = [];
125
+ elements.forEach(el => {
126
+ if (config.minTextLength > 0) {
127
+ if ((el.textContent || '').trim().length < config.minTextLength) return;
128
+ }
129
+ const id = elemId(el);
130
+ const current = readProps(el);
131
+ const delta = computeDelta(id, current);
132
+ if (delta) {
133
+ frameDelta.push({
134
+ id,
135
+ ...delta
136
+ });
137
+ }
138
+ });
139
+ if (frameDelta.length > 0) {
140
+ rawFrames.push({
141
+ t,
142
+ changes: frameDelta
143
+ });
144
+ if (rawFrames.length > 1) hasSeenChange = true;
145
+ lastChangeTime = performance.now();
146
+ }
147
+ }
148
+ function loop() {
149
+ if (!recording) return;
150
+ if (config.idleTimeout > 0 && hasSeenChange) {
151
+ if (performance.now() - lastChangeTime >= config.idleTimeout) {
152
+ api.stop();
153
+ return;
154
+ }
155
+ }
156
+ sampleAll();
157
+ rafId = requestAnimationFrame(loop);
158
+ }
159
+ function startMutationObserver() {
160
+ if (!config.mutations) return;
161
+ mutationObserver = new MutationObserver(muts => {
162
+ if (!recording) return;
163
+ const t = Math.round(performance.now() - startTime);
164
+ lastChangeTime = performance.now();
165
+ hasSeenChange = true;
166
+ for (const m of muts) {
167
+ if (m.type === 'childList') {
168
+ for (const node of m.addedNodes) {
169
+ if (node.nodeType === 3) {
170
+ const text = node.textContent?.trim();
171
+ if (text) mutations.push({
172
+ t,
173
+ type: 'text+',
174
+ text: text.slice(0, 120),
175
+ parent: m.target.tagName?.toLowerCase()
176
+ });
177
+ } else if (node.nodeType === 1) {
178
+ const text = (node.textContent || '').trim().slice(0, 80);
179
+ mutations.push({
180
+ t,
181
+ type: 'node+',
182
+ tag: node.tagName.toLowerCase(),
183
+ text
184
+ });
185
+ }
186
+ }
187
+ for (const node of m.removedNodes) {
188
+ if (node.nodeType === 1) {
189
+ mutations.push({
190
+ t,
191
+ type: 'node-',
192
+ tag: node.tagName.toLowerCase(),
193
+ text: (node.textContent || '').trim().slice(0, 40)
194
+ });
195
+ }
196
+ }
197
+ } else if (m.type === 'characterData') {
198
+ const text = m.target.textContent?.trim();
199
+ if (text) mutations.push({
200
+ t,
201
+ type: 'text~',
202
+ text: text.slice(0, 120),
203
+ parent: m.target.parentElement?.tagName?.toLowerCase()
204
+ });
205
+ }
206
+ }
207
+ });
208
+ mutationObserver.observe(document.body, {
209
+ childList: true,
210
+ subtree: true,
211
+ characterData: true
212
+ });
213
+ }
214
+
215
+ // --- Downsampling ---
216
+
217
+ function downsample() {
218
+ if (rawFrames.length === 0) return [];
219
+ const duration = rawFrames[rawFrames.length - 1].t;
220
+ const targetFrames = Math.max(1, Math.round(duration / 1000 * config.sampleRate));
221
+ const changeIndex = new Map();
222
+ for (let fi = 0; fi < rawFrames.length; fi++) {
223
+ const frame = rawFrames[fi];
224
+ for (const change of frame.changes) {
225
+ const {
226
+ id,
227
+ ...props
228
+ } = change;
229
+ for (const [prop, value] of Object.entries(props)) {
230
+ const key = `${id}.${prop}`;
231
+ if (!changeIndex.has(key)) changeIndex.set(key, []);
232
+ changeIndex.get(key).push({
233
+ frameIdx: fi,
234
+ t: frame.t,
235
+ value
236
+ });
237
+ }
238
+ }
239
+ }
240
+ const outputEvents = [];
241
+ for (const [key, changes] of changeIndex.entries()) {
242
+ const dotIdx = key.indexOf('.');
243
+ const id = key.slice(0, dotIdx);
244
+ const prop = key.slice(dotIdx + 1);
245
+ if (changes.length === 0) continue;
246
+ if (changes.length <= targetFrames) {
247
+ for (const c of changes) {
248
+ outputEvents.push({
249
+ t: c.t,
250
+ id,
251
+ prop,
252
+ value: c.value
253
+ });
254
+ }
255
+ } else {
256
+ for (let i = 0; i < targetFrames; i++) {
257
+ const srcIdx = Math.round(i / (targetFrames - 1) * (changes.length - 1));
258
+ const c = changes[srcIdx];
259
+ outputEvents.push({
260
+ t: c.t,
261
+ id,
262
+ prop,
263
+ value: c.value
264
+ });
265
+ }
266
+ }
267
+ }
268
+ outputEvents.sort((a, b) => a.t - b.t);
269
+ const frames = [];
270
+ let currentFrame = null;
271
+ for (const evt of outputEvents) {
272
+ if (!currentFrame || currentFrame.t !== evt.t) {
273
+ currentFrame = {
274
+ t: evt.t,
275
+ changes: []
276
+ };
277
+ frames.push(currentFrame);
278
+ }
279
+ let elemChange = currentFrame.changes.find(c => c.id === evt.id);
280
+ if (!elemChange) {
281
+ elemChange = {
282
+ id: evt.id
283
+ };
284
+ currentFrame.changes.push(elemChange);
285
+ }
286
+ elemChange[evt.prop] = evt.value;
287
+ }
288
+ return frames;
289
+ }
290
+ function buildElementMap() {
291
+ const elements = {};
292
+ const seenIds = new Set();
293
+ for (const f of rawFrames) {
294
+ for (const c of f.changes) seenIds.add(c.id);
295
+ }
296
+ document.querySelectorAll('*').forEach(el => {
297
+ if (el.__dj_id && seenIds.has(el.__dj_id)) {
298
+ elements[el.__dj_id] = el.__dj_label || {
299
+ tag: el.tagName.toLowerCase(),
300
+ text: (el.textContent || '').trim().slice(0, 60)
301
+ };
302
+ }
303
+ });
304
+ return elements;
305
+ }
306
+ function buildPropStats() {
307
+ const stats = {};
308
+ const duration = rawFrames.length ? rawFrames[rawFrames.length - 1].t : 0;
309
+ const targetFrames = Math.max(1, Math.round(duration / 1000 * config.sampleRate));
310
+ for (const f of rawFrames) {
311
+ for (const change of f.changes) {
312
+ const {
313
+ id,
314
+ ...props
315
+ } = change;
316
+ for (const prop of Object.keys(props)) {
317
+ const key = `${id}.${prop}`;
318
+ if (!stats[key]) stats[key] = {
319
+ elem: id,
320
+ prop,
321
+ raw: 0
322
+ };
323
+ stats[key].raw++;
324
+ }
325
+ }
326
+ }
327
+ for (const s of Object.values(stats)) {
328
+ if (s.raw === 0) {
329
+ s.mode = 'dropped';
330
+ s.output = 0;
331
+ } else if (s.raw <= targetFrames) {
332
+ s.mode = 'anomaly';
333
+ s.output = s.raw;
334
+ } else {
335
+ s.mode = 'sampled';
336
+ s.output = targetFrames;
337
+ }
338
+ }
339
+ return {
340
+ targetFrames,
341
+ props: Object.values(stats)
342
+ };
343
+ }
344
+
345
+ // --- Analysis ---
346
+
347
+ function getTimeline(eId, prop) {
348
+ const timeline = [];
349
+ for (const frame of rawFrames) {
350
+ for (const c of frame.changes) {
351
+ if (c.id === eId && c[prop] !== undefined) {
352
+ timeline.push({
353
+ t: frame.t,
354
+ value: c[prop]
355
+ });
356
+ }
357
+ }
358
+ }
359
+ return timeline;
360
+ }
361
+ function extractNumeric(value) {
362
+ if (value === 'none' || value === '' || value === 'auto') return 0;
363
+ const matrixMatch = String(value).match(/^matrix\(([^)]+)\)$/);
364
+ if (matrixMatch) {
365
+ const parts = matrixMatch[1].split(',').map(Number);
366
+ const tx = parts[4] || 0;
367
+ const ty = parts[5] || 0;
368
+ return Math.abs(tx) > Math.abs(ty) ? tx : ty;
369
+ }
370
+ const num = parseFloat(value);
371
+ return isNaN(num) ? null : num;
372
+ }
373
+ function detectBounce(timeline) {
374
+ if (timeline.length < 3) return null;
375
+ const first = timeline[0].value;
376
+ const last = timeline[timeline.length - 1].value;
377
+ if (first !== last) return null;
378
+ const firstNum = extractNumeric(first);
379
+ if (firstNum === null) return null;
380
+ let peakDeviation = 0;
381
+ let peakT = 0;
382
+ let peakValue = first;
383
+ for (const {
384
+ t,
385
+ value
386
+ } of timeline) {
387
+ const num = extractNumeric(value);
388
+ if (num === null) continue;
389
+ const deviation = Math.abs(num - firstNum);
390
+ if (deviation > peakDeviation) {
391
+ peakDeviation = deviation;
392
+ peakT = t;
393
+ peakValue = value;
394
+ }
395
+ }
396
+ if (peakDeviation === 0) return null;
397
+ return {
398
+ restValue: first,
399
+ peak: peakValue,
400
+ peakDeviation: Math.round(peakDeviation * 10) / 10,
401
+ peakT,
402
+ startT: timeline[0].t,
403
+ endT: timeline[timeline.length - 1].t,
404
+ duration: timeline[timeline.length - 1].t - timeline[0].t
405
+ };
406
+ }
407
+ function detectOutliers(propStats) {
408
+ const byElem = {};
409
+ for (const p of propStats.props) {
410
+ if (!byElem[p.elem]) byElem[p.elem] = [];
411
+ byElem[p.elem].push(p);
412
+ }
413
+ const outliers = [];
414
+ for (const [, props] of Object.entries(byElem)) {
415
+ if (props.length < 2) continue;
416
+ const counts = props.map(p => p.raw).sort((a, b) => a - b);
417
+ const median = counts[Math.floor(counts.length / 2)];
418
+ for (const p of props) {
419
+ if (p.raw <= 1) continue;
420
+ const ratio = median > 0 ? p.raw / median : p.raw;
421
+ const rt = config.thresholds.outlier.ratioThreshold;
422
+ const isOutlier = (ratio > rt || ratio < 1 / rt) && p.raw !== counts[counts.length - 1] && p.raw !== counts[0];
423
+ if (isOutlier) {
424
+ outliers.push({
425
+ ...p,
426
+ median,
427
+ ratio: Math.round(ratio * 10) / 10
428
+ });
429
+ }
430
+ }
431
+ }
432
+ return outliers;
433
+ }
434
+ function makeFinding(type, severity, elem, elemLabel, prop, description, extra) {
435
+ return {
436
+ type,
437
+ severity,
438
+ elem,
439
+ elemLabel,
440
+ prop,
441
+ description,
442
+ ...extra
443
+ };
444
+ }
445
+ function countReversals(numeric) {
446
+ let reversals = 0;
447
+ for (let i = 2; i < numeric.length; i++) {
448
+ const d1 = numeric[i - 1].val - numeric[i - 2].val;
449
+ const d2 = numeric[i].val - numeric[i - 1].val;
450
+ if (Math.abs(d1) > config.thresholds.shiver.minDelta && Math.abs(d2) > config.thresholds.shiver.minDelta && d1 * d2 < 0) {
451
+ reversals++;
452
+ }
453
+ }
454
+ return reversals;
455
+ }
456
+ function detectOutlierFindings(propStats, elements) {
457
+ const findings = [];
458
+ const outliers = detectOutliers(propStats);
459
+ for (const outlier of outliers) {
460
+ const timeline = getTimeline(outlier.elem, outlier.prop);
461
+ const bounce = detectBounce(timeline);
462
+ const label = elements[outlier.elem];
463
+ if (bounce) {
464
+ const jt = config.thresholds.jitter;
465
+ findings.push(makeFinding('jitter', bounce.peakDeviation > jt.highSeverity ? 'high' : bounce.peakDeviation > jt.medSeverity ? 'medium' : 'low', outlier.elem, label, outlier.prop, `${outlier.prop} bounces from ${bounce.restValue} to ${bounce.peak} and back over ${bounce.duration}ms at t=${bounce.startT}ms`, {
466
+ rawChanges: outlier.raw,
467
+ medianForElement: outlier.median,
468
+ bounce,
469
+ timeline
470
+ }));
471
+ } else {
472
+ findings.push(makeFinding('outlier', 'info', outlier.elem, label, outlier.prop, `${outlier.prop} changed ${outlier.raw}x while sibling props median is ${outlier.median}x`, {
473
+ rawChanges: outlier.raw,
474
+ medianForElement: outlier.median,
475
+ timeline
476
+ }));
477
+ }
478
+ }
479
+ for (const p of propStats.props) {
480
+ if (p.raw < 3) continue;
481
+ if (findings.some(f => f.elem === p.elem && f.prop === p.prop)) continue;
482
+ const timeline = getTimeline(p.elem, p.prop);
483
+ const bounce = detectBounce(timeline);
484
+ const jt = config.thresholds.jitter;
485
+ if (bounce && bounce.peakDeviation > jt.minDeviation && bounce.duration < jt.maxDuration) {
486
+ const isFlicker = p.prop === 'opacity';
487
+ findings.push(makeFinding(isFlicker ? 'flicker' : 'jitter', bounce.peakDeviation > jt.highSeverity ? 'high' : bounce.peakDeviation > jt.medSeverity ? 'medium' : 'low', p.elem, elements[p.elem], p.prop, `${p.prop} bounces from ${bounce.restValue} to ${bounce.peak} and back over ${bounce.duration}ms at t=${bounce.startT}ms`, {
488
+ rawChanges: p.raw,
489
+ bounce,
490
+ timeline
491
+ }));
492
+ }
493
+ }
494
+ return findings;
495
+ }
496
+ function detectShiverFindings(propStats, elements, existingFindings) {
497
+ const findings = [];
498
+ for (const p of propStats.props) {
499
+ if (p.raw < 10) continue;
500
+ if (existingFindings.some(f => f.elem === p.elem && f.prop === p.prop)) continue;
501
+ const timeline = getTimeline(p.elem, p.prop);
502
+ if (timeline.length < 10) continue;
503
+ const numeric = [];
504
+ for (const {
505
+ t,
506
+ value
507
+ } of timeline) {
508
+ const n = extractNumeric(value);
509
+ if (n !== null) numeric.push({
510
+ t,
511
+ val: n
512
+ });
513
+ }
514
+ if (numeric.length < 10) continue;
515
+ const reversals = countReversals(numeric);
516
+ const reversalDensity = reversals / (numeric.length - 2);
517
+ const st = config.thresholds.shiver;
518
+ if (reversalDensity > st.minDensity && reversals >= st.minReversals) {
519
+ const uniqueVals = [...new Set(numeric.map(n => Math.round(n.val * 10) / 10))];
520
+ const isTwoValueFight = uniqueVals.length <= 4;
521
+ const vals = numeric.map(n => n.val);
522
+ const amplitude = Math.round((Math.max(...vals) - Math.min(...vals)) * 10) / 10;
523
+ const hz = Math.round(reversals / ((numeric[numeric.length - 1].t - numeric[0].t) / 1000) * 10) / 10;
524
+ findings.push(makeFinding('shiver', reversalDensity > st.highDensity ? 'high' : reversalDensity > st.medDensity ? 'medium' : 'low', p.elem, elements[p.elem], p.prop, isTwoValueFight ? `${p.prop} oscillates between ${Math.min(...vals)} and ${Math.max(...vals)} at ${hz}Hz — two forces fighting (${Math.round(reversalDensity * 100)}% frames reverse)` : `${p.prop} shivers with ${reversals} direction reversals across ${numeric.length} frames (${Math.round(reversalDensity * 100)}% reversal rate, amplitude ${amplitude}, ${hz}Hz)`, {
525
+ rawChanges: p.raw,
526
+ shiver: {
527
+ reversals,
528
+ totalFrames: numeric.length,
529
+ reversalDensity: Math.round(reversalDensity * 1000) / 1000,
530
+ amplitude,
531
+ hz,
532
+ range: [Math.min(...vals), Math.max(...vals)],
533
+ uniqueValues: uniqueVals.length,
534
+ isTwoValueFight,
535
+ durationMs: Math.round(numeric[numeric.length - 1].t - numeric[0].t)
536
+ }
537
+ }));
538
+ }
539
+ }
540
+ return findings;
541
+ }
542
+ function detectJumpFindings(propStats, elements, existingFindings) {
543
+ const findings = [];
544
+ for (const p of propStats.props) {
545
+ if (p.raw < 3) continue;
546
+ if (existingFindings.some(f => f.elem === p.elem && f.prop === p.prop)) continue;
547
+ const timeline = getTimeline(p.elem, p.prop);
548
+ if (timeline.length < 3) continue;
549
+ const deltas = [];
550
+ for (let i = 1; i < timeline.length; i++) {
551
+ const prev = extractNumeric(timeline[i - 1].value);
552
+ const curr = extractNumeric(timeline[i].value);
553
+ if (prev === null || curr === null) continue;
554
+ deltas.push({
555
+ t: timeline[i].t,
556
+ delta: Math.abs(curr - prev),
557
+ from: timeline[i - 1].value,
558
+ to: timeline[i].value
559
+ });
560
+ }
561
+ if (deltas.length < 3) continue;
562
+ const sortedDeltas = deltas.map(d => d.delta).sort((a, b) => a - b);
563
+ const medianDelta = sortedDeltas[Math.floor(sortedDeltas.length / 2)];
564
+ if (medianDelta === 0) continue;
565
+ const jmpT = config.thresholds.jump;
566
+ for (const d of deltas) {
567
+ if (d.delta > medianDelta * jmpT.medianMultiplier && d.delta > jmpT.minAbsolute) {
568
+ findings.push(makeFinding('jump', d.delta > medianDelta * jmpT.highMultiplier ? 'high' : d.delta > medianDelta * jmpT.medMultiplier ? 'medium' : 'low', p.elem, elements[p.elem], p.prop, `${p.prop} jumps from ${d.from} to ${d.to} at t=${d.t}ms (${Math.round(d.delta)}px, typical step is ${Math.round(medianDelta * 10) / 10}px)`, {
569
+ rawChanges: p.raw,
570
+ jump: {
571
+ t: d.t,
572
+ from: d.from,
573
+ to: d.to,
574
+ magnitude: Math.round(d.delta),
575
+ medianDelta: Math.round(medianDelta * 10) / 10,
576
+ ratio: Math.round(d.delta / medianDelta)
577
+ }
578
+ }));
579
+ }
580
+ }
581
+ }
582
+ return findings;
583
+ }
584
+ function detectStutterFindings(propStats, elements, existingFindings) {
585
+ const findings = [];
586
+ const st = config.thresholds.stutter;
587
+ for (const p of propStats.props) {
588
+ if (p.raw < 6) continue;
589
+ if (existingFindings.some(f => f.elem === p.elem && f.prop === p.prop)) continue;
590
+ const timeline = getTimeline(p.elem, p.prop);
591
+ if (timeline.length < 6) continue;
592
+ const numeric = [];
593
+ for (const {
594
+ t,
595
+ value
596
+ } of timeline) {
597
+ const n = extractNumeric(value);
598
+ if (n !== null) numeric.push({
599
+ t,
600
+ val: n
601
+ });
602
+ }
603
+ if (numeric.length < 6) continue;
604
+ const deltas = [];
605
+ for (let i = 1; i < numeric.length; i++) {
606
+ deltas.push(numeric[i].val - numeric[i - 1].val);
607
+ }
608
+ const windowSize = 5;
609
+ let i = 0;
610
+ while (i < deltas.length) {
611
+ const winStart = Math.max(0, i - windowSize);
612
+ if (i - winStart < 2) {
613
+ i++;
614
+ continue;
615
+ }
616
+ let sum = 0;
617
+ for (let w = winStart; w < i; w++) sum += deltas[w];
618
+ const dominantDir = Math.sign(sum);
619
+ if (dominantDir === 0) {
620
+ i++;
621
+ continue;
622
+ }
623
+ if (deltas[i] !== 0 && Math.sign(deltas[i]) !== dominantDir) {
624
+ const reversalStart = i;
625
+ let reversalEnd = i;
626
+ while (reversalEnd + 1 < deltas.length && reversalEnd - reversalStart + 1 < st.maxFrames && deltas[reversalEnd + 1] !== 0 && Math.sign(deltas[reversalEnd + 1]) !== dominantDir) {
627
+ reversalEnd++;
628
+ }
629
+ const afterIdx = reversalEnd + 1;
630
+ if (afterIdx >= deltas.length || Math.sign(deltas[afterIdx]) !== dominantDir) {
631
+ i = reversalEnd + 1;
632
+ continue;
633
+ }
634
+ let reversalMag = 0;
635
+ for (let r = reversalStart; r <= reversalEnd; r++) {
636
+ reversalMag += Math.abs(deltas[r]);
637
+ }
638
+ const localStart = Math.max(0, reversalStart - windowSize);
639
+ const localEnd = Math.min(deltas.length - 1, reversalEnd + windowSize);
640
+ let localSum = 0;
641
+ let localCount = 0;
642
+ for (let l = localStart; l <= localEnd; l++) {
643
+ if (l >= reversalStart && l <= reversalEnd) continue;
644
+ localSum += Math.abs(deltas[l]);
645
+ localCount++;
646
+ }
647
+ const localVelocity = localCount > 0 ? localSum / localCount : 0;
648
+ if (localVelocity >= st.minVelocity) {
649
+ const ratio = reversalMag / localVelocity;
650
+ if (ratio >= st.velocityRatio) {
651
+ const reversalFrameCount = reversalEnd - reversalStart + 1;
652
+ const severity = ratio >= 1.0 ? 'high' : ratio >= 0.5 ? 'medium' : 'low';
653
+ const t = numeric[reversalStart + 1].t;
654
+ findings.push(makeFinding('stutter', severity, p.elem, elements[p.elem], p.prop, `${p.prop} reverses for ${reversalFrameCount} frame${reversalFrameCount > 1 ? 's' : ''} at t=${t}ms during smooth motion (reversal ${Math.round(reversalMag * 10) / 10}px vs local velocity ${Math.round(localVelocity * 10) / 10}px/frame, ratio ${Math.round(ratio * 100) / 100})`, {
655
+ rawChanges: p.raw,
656
+ stutter: {
657
+ t,
658
+ reversalFrames: reversalFrameCount,
659
+ reversalMagnitude: Math.round(reversalMag * 10) / 10,
660
+ localVelocity: Math.round(localVelocity * 10) / 10,
661
+ ratio: Math.round(ratio * 100) / 100,
662
+ dominantDirection: dominantDir > 0 ? 'increasing' : 'decreasing'
663
+ }
664
+ }));
665
+ }
666
+ }
667
+ i = reversalEnd + 1;
668
+ } else {
669
+ i++;
670
+ }
671
+ }
672
+ }
673
+ return findings;
674
+ }
675
+ function detectStuckFindings(propStats, elements, existingFindings) {
676
+ const findings = [];
677
+ const sk = config.thresholds.stuck;
678
+ for (const p of propStats.props) {
679
+ if (p.raw < 6) continue;
680
+ if (existingFindings.some(f => f.elem === p.elem && f.prop === p.prop)) continue;
681
+ const timeline = getTimeline(p.elem, p.prop);
682
+ if (timeline.length < 6) continue;
683
+ const numeric = [];
684
+ for (const {
685
+ t,
686
+ value
687
+ } of timeline) {
688
+ const n = extractNumeric(value);
689
+ if (n !== null) numeric.push({
690
+ t,
691
+ val: n
692
+ });
693
+ }
694
+ if (numeric.length < 6) continue;
695
+ const deltas = [];
696
+ for (let i = 1; i < numeric.length; i++) {
697
+ deltas.push(Math.abs(numeric[i].val - numeric[i - 1].val));
698
+ }
699
+ let i = 0;
700
+ while (i < deltas.length) {
701
+ if (deltas[i] > sk.maxDelta) {
702
+ i++;
703
+ continue;
704
+ }
705
+ const runStart = i;
706
+ while (i < deltas.length && deltas[i] <= sk.maxDelta) i++;
707
+ const runEnd = i - 1;
708
+ const stillCount = runEnd - runStart + 1;
709
+ if (stillCount < sk.minStillFrames) continue;
710
+ const windowSize = 5;
711
+ let surroundingSum = 0;
712
+ let surroundingCount = 0;
713
+ const beforeStart = Math.max(0, runStart - windowSize);
714
+ for (let b = beforeStart; b < runStart; b++) {
715
+ surroundingSum += deltas[b];
716
+ surroundingCount++;
717
+ }
718
+ const afterEnd = Math.min(deltas.length - 1, runEnd + windowSize);
719
+ for (let a = runEnd + 1; a <= afterEnd; a++) {
720
+ surroundingSum += deltas[a];
721
+ surroundingCount++;
722
+ }
723
+ if (surroundingCount === 0) continue;
724
+ const meanSurroundingVelocity = surroundingSum / surroundingCount;
725
+ if (meanSurroundingVelocity < sk.minSurroundingVelocity) continue;
726
+ const tStart = numeric[runStart].t;
727
+ const tEnd = numeric[runEnd + 1].t;
728
+ const duration = Math.round(tEnd - tStart);
729
+ const severity = duration >= sk.highDuration ? 'high' : duration >= sk.medDuration ? 'medium' : 'low';
730
+ findings.push(makeFinding('stuck', severity, p.elem, elements[p.elem], p.prop, `${p.prop} stalls for ${stillCount} frames (${duration}ms) at t=${Math.round(tStart)}ms while surrounding motion averages ${Math.round(meanSurroundingVelocity * 10) / 10}px/frame`, {
731
+ rawChanges: p.raw,
732
+ stuck: {
733
+ t: Math.round(tStart),
734
+ duration,
735
+ stillFrames: stillCount,
736
+ meanSurroundingVelocity: Math.round(meanSurroundingVelocity * 10) / 10,
737
+ stuckValue: numeric[runStart].val
738
+ }
739
+ }));
740
+ }
741
+ }
742
+ return findings;
743
+ }
744
+ function deduplicateShivers(findings) {
745
+ const shiverFindings = findings.filter(f => f.type === 'shiver');
746
+ const otherFindings = findings.filter(f => f.type !== 'shiver');
747
+ const shiverGroups = new Map();
748
+ for (const f of shiverFindings) {
749
+ const key = `${f.prop}|${f.shiver.hz}|${f.shiver.isTwoValueFight}`;
750
+ if (!shiverGroups.has(key)) shiverGroups.set(key, []);
751
+ shiverGroups.get(key).push(f);
752
+ }
753
+ const deduped = [];
754
+ for (const group of shiverGroups.values()) {
755
+ if (group.length === 1) {
756
+ deduped.push(group[0]);
757
+ } else {
758
+ group.sort((a, b) => b.shiver.amplitude - a.shiver.amplitude);
759
+ const rep = {
760
+ ...group[0]
761
+ };
762
+ rep.affectedElements = group.length;
763
+ rep.description += ` (affects ${group.length} elements)`;
764
+ deduped.push(rep);
765
+ }
766
+ }
767
+ return [...otherFindings, ...deduped];
768
+ }
769
+ function analyzeFindings() {
770
+ const propStats = buildPropStats();
771
+ const elements = buildElementMap();
772
+ let findings = detectOutlierFindings(propStats, elements);
773
+ findings = findings.concat(detectShiverFindings(propStats, elements, findings));
774
+ findings = findings.concat(detectJumpFindings(propStats, elements, findings));
775
+ findings = findings.concat(detectStutterFindings(propStats, elements, findings));
776
+ findings = findings.concat(detectStuckFindings(propStats, elements, findings));
777
+ findings = deduplicateShivers(findings);
778
+ const sevOrder = {
779
+ high: 0,
780
+ medium: 1,
781
+ low: 2,
782
+ info: 3
783
+ };
784
+ findings.sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity]);
785
+ return findings;
786
+ }
787
+
788
+ // --- Public API ---
789
+
790
+ const api = {
791
+ configure(opts = {}) {
792
+ const {
793
+ thresholds: userThresholds,
794
+ ...rest
795
+ } = opts;
796
+ config = {
797
+ ...DEFAULT_CONFIG,
798
+ ...rest
799
+ };
800
+ if (userThresholds) {
801
+ config.thresholds = {
802
+ ...DEFAULT_CONFIG.thresholds
803
+ };
804
+ for (const key of Object.keys(userThresholds)) {
805
+ if (DEFAULT_CONFIG.thresholds[key]) {
806
+ config.thresholds[key] = {
807
+ ...DEFAULT_CONFIG.thresholds[key],
808
+ ...userThresholds[key]
809
+ };
810
+ }
811
+ }
812
+ }
813
+ return config;
814
+ },
815
+ start() {
816
+ rawFrames = [];
817
+ mutations = [];
818
+ lastSeen = new Map();
819
+ nextElemId = 0;
820
+ recording = true;
821
+ startTime = performance.now();
822
+ lastChangeTime = performance.now();
823
+ hasSeenChange = false;
824
+ onStopCallbacks = [];
825
+ startMutationObserver();
826
+ rafId = requestAnimationFrame(loop);
827
+ if (config.maxDuration > 0) {
828
+ stopTimer = setTimeout(() => api.stop(), config.maxDuration);
829
+ }
830
+ const elemCount = document.querySelectorAll(config.selector).length;
831
+ return `Recording ${elemCount} elements (outputRate=${config.sampleRate}/s, max=${config.maxDuration}ms, idle=${config.idleTimeout}ms, props=[${config.props}], mutations=${config.mutations})`;
832
+ },
833
+ stop() {
834
+ recording = false;
835
+ if (rafId) cancelAnimationFrame(rafId);
836
+ if (stopTimer) clearTimeout(stopTimer);
837
+ mutationObserver?.disconnect();
838
+ const msg = `Stopped. ${rawFrames.length} raw frames, ${mutations.length} mutation events.`;
839
+ for (const cb of onStopCallbacks) {
840
+ try {
841
+ cb();
842
+ } catch (e) {
843
+ console.error('[dejitter] onStop callback error:', e);
844
+ }
845
+ }
846
+ return msg;
847
+ },
848
+ onStop(callback) {
849
+ onStopCallbacks.push(callback);
850
+ },
851
+ getData() {
852
+ const samples = downsample();
853
+ const elements = buildElementMap();
854
+ const propStats = buildPropStats();
855
+ return {
856
+ config: {
857
+ ...config
858
+ },
859
+ duration: rawFrames.length ? rawFrames[rawFrames.length - 1].t : 0,
860
+ rawFrameCount: rawFrames.length,
861
+ outputFrameCount: samples.length,
862
+ mutationEvents: mutations.length,
863
+ propStats,
864
+ elements,
865
+ samples,
866
+ mutations
867
+ };
868
+ },
869
+ summary(json) {
870
+ const propStats = buildPropStats();
871
+ const elements = buildElementMap();
872
+ const byMode = {
873
+ anomaly: 0,
874
+ sampled: 0,
875
+ dropped: 0
876
+ };
877
+ for (const p of propStats.props) {
878
+ byMode[p.mode] = (byMode[p.mode] || 0) + 1;
879
+ }
880
+ const data = {
881
+ duration: rawFrames.length ? rawFrames[rawFrames.length - 1].t : 0,
882
+ rawFrameCount: rawFrames.length,
883
+ targetOutputFrames: propStats.targetFrames,
884
+ mutationEvents: mutations.length,
885
+ elementsTracked: Object.keys(elements).length,
886
+ propBreakdown: byMode
887
+ };
888
+ return json ? data : JSON.stringify(data, null, 2);
889
+ },
890
+ findings(json) {
891
+ const data = analyzeFindings().map(({
892
+ timeline,
893
+ ...rest
894
+ }) => rest);
895
+ return json ? data : JSON.stringify(data, null, 2);
896
+ },
897
+ getRaw() {
898
+ return {
899
+ rawFrames,
900
+ mutations
901
+ };
902
+ },
903
+ toJSON() {
904
+ return JSON.stringify(api.getData(), null, 2);
905
+ }
906
+ };
907
+ return api;
908
+ }