@treelocator/runtime 0.3.2 → 0.4.1

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