perf(primitives): add performance audit report and vitest bench baselines

Library-wide Vue+V8 perf/leak audit (PERF_AUDIT.md) plus bench baselines for the
hot-path modules (timeline, curve-editor, spline, pointer-drag, collection, etc.).
This commit is contained in:
2026-06-15 16:54:28 +07:00
parent 263c32002f
commit 661a55719e
19 changed files with 6507 additions and 0 deletions
+542
View File
@@ -0,0 +1,542 @@
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > screenToContent — over N points 1376ms
· 100 points 1,192,582.00 0.0000 0.8000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.78% 596291
· 1000 points 143,410.00 0.0000 0.2000 0.0070 0.0000 0.1000 0.1000 0.1000 ±2.68% 71705
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > contentToScreen — over N points 1375ms
· 100 points 1,182,360.00 0.0000 6.5000 0.0008 0.0000 0.0000 0.1000 0.1000 ±4.42% 591180
· 1000 points 146,178.76 0.0000 0.3000 0.0068 0.0000 0.1000 0.1000 0.1000 ±2.69% 73104
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > round-trip screen→content→screen — over N points 1365ms
· 100 points 1,179,437.99 0.0000 0.2000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.77% 589719
· 1000 points 141,443.71 0.0000 0.2000 0.0071 0.0000 0.1000 0.1000 0.1000 ±2.68% 70736
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > zoomAtPointer — over N anchor points 1373ms
· 100 points 1,095,020.00 0.0000 0.2000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.76% 547510
· 1000 points 128,084.38 0.0000 0.2000 0.0078 0.0000 0.1000 0.1000 0.1000 ±2.67% 64055
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > clampViewport — zoom-only (no extent) 1343ms
· 100 viewports 964,282.00 0.0000 0.3000 0.0010 0.0000 0.1000 0.1000 0.1000 ±2.77% 482141
· 1000 viewports 107,640.47 0.0000 0.2000 0.0093 0.0000 0.1000 0.1000 0.1000 ±2.65% 53831
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > clampViewport — with translate extent 1330ms
· 100 viewports 902,672.00 0.0000 0.2000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 451336
· 1000 viewports 97,734.45 0.0000 0.4000 0.0102 0.0000 0.1000 0.1000 0.1000 ±2.66% 48877
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > clampViewport — degenerate extent (centring branch) 1334ms
· 100 viewports 919,704.06 0.0000 0.3000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 459944
· 1000 viewports 105,346.00 0.0000 0.2000 0.0095 0.0000 0.1000 0.1000 0.1000 ±2.64% 52673
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > wheelToZoomFactor — over N wheel events 1232ms
· 100 events 174,063.19 0.0000 0.4000 0.0057 0.0000 0.1000 0.1000 0.1000 ±2.70% 87049
· 1000 events 16,542.00 0.0000 0.4000 0.0605 0.1000 0.2000 0.2000 0.2000 ±2.01% 8271
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > wheel-zoom pipeline (factor → clamp → zoomAtPointer) 1238ms
· 100 steps 205,246.00 0.0000 0.3000 0.0049 0.0000 0.1000 0.1000 0.1000 ±2.72% 102623
· 1000 steps 19,588.00 0.0000 0.4000 0.0511 0.1000 0.2000 0.2000 0.2000 ±2.11% 9794
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > drag-pan move (translate + clamp) 1316ms
· 100 moves 822,331.53 0.0000 0.2000 0.0012 0.0000 0.1000 0.1000 0.1000 ±2.76% 411248
· 1000 moves 85,706.86 0.0000 0.2000 0.0117 0.0000 0.1000 0.1000 0.1000 ±2.62% 42862
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > fitViewTransform 2304ms
· single fit 7,124,967.01 0.0000 4.5000 0.0001 0.0000 0.0000 0.0000 0.1000 ±3.29% 3563196
· 100 fits 1,157,900.00 0.0000 1.0000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.80% 578950
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > measureContentRect (real getBoundingClientRect) 616ms
· 100 measurements 17,414.52 0.0000 2.0000 0.0574 0.1000 0.2000 0.2000 0.2000 ±2.20% 8709
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > ViewportRoot — mount with N tiles 1236ms
· 50 tiles — mount + unmount 2,345.61 0.2000 14.2000 0.4263 0.4000 1.0000 6.5000 13.7000 ±10.02% 1204
· 500 tiles — mount + unmount 875.12 0.9000 8.1000 1.1427 1.1000 5.4000 6.8000 8.1000 ±5.54% 438
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > ruler ticks — timecode (per pan/zoom) 1933ms
· timecodeTicks — 100-clip span (~150s) 53,809.24 0.0000 0.3000 0.0186 0.0000 0.1000 0.1000 0.2000 ±2.52% 26910
· timecodeTicks — 1000-clip span (~1500s) 18,988.20 0.0000 0.3000 0.0527 0.1000 0.2000 0.2000 0.2000 ±2.11% 9496
· timecodeTicks — wide window, fixed viewport (1200px) 838,271.99 0.0000 0.3000 0.0012 0.0000 0.1000 0.1000 0.1000 ±2.77% 419136
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > ruler ticks — wall clock (per pan/zoom) 1233ms
· timeTicks — 100-clip span (~150s) 157,950.41 0.0000 0.4000 0.0063 0.0000 0.1000 0.1000 0.1000 ±2.71% 78991
· timeTicks — 1000-clip span (~1500s) 32,748.00 0.0000 0.6000 0.0305 0.1000 0.1000 0.2000 0.2000 ±2.39% 16374
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > scale projection (scaleLinear over clips) 1741ms
· scaleLinear — project 100 clip edges 3,262,253.56 0.0000 0.2000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1631453
· scaleLinear — project 1000 clip edges 466,074.79 0.0000 0.2000 0.0021 0.0000 0.1000 0.1000 0.1000 ±2.74% 233084
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > timecode formatting (per clip label) 1826ms
· timeToTimecode — 100 clip durations 113,117.38 0.0000 0.2000 0.0088 0.0000 0.1000 0.1000 0.1000 ±2.65% 56570
· timeToTimecode — 1000 clip durations 11,245.75 0.0000 0.4000 0.0889 0.1000 0.2000 0.2000 0.3000 ±1.71% 5624
· framesToTimecode — 1000 (raw, pre-converted) 14,099.18 0.0000 0.3000 0.0709 0.1000 0.2000 0.2000 0.2000 ±1.88% 7051
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > snapToFrame (nudge / grid granularity) 1537ms
· snapToFrame — 100 clip starts 2,369,298.14 0.0000 0.3000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1184886
· snapToFrame — 1000 clip starts 308,024.40 0.0000 0.1000 0.0032 0.0000 0.1000 0.1000 0.1000 ±2.73% 154043
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > marquee hit-test (clipIntersectsTime per pointer move) 1743ms
· clipIntersectsTime — 100 clips 3,878,759.99 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1939380
· clipIntersectsTime — 1000 clips 600,243.95 0.0000 0.1000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.75% 300182
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > clipsDuration (auto-duration recompute) 1897ms
· clipsDuration — 100 clips 4,189,313.97 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2094657
· clipsDuration — 1000 clips 639,822.00 0.0000 0.1000 0.0016 0.0000 0.1000 0.1000 0.1000 ±2.75% 319911
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > applyClipChanges (controlled reducer) 1828ms
· applyClipChanges — 100 clips / 100 changes 115,978.00 0.0000 0.3000 0.0086 0.0000 0.1000 0.1000 0.1000 ±2.67% 57989
· applyClipChanges — 1000 clips / 1000 changes 10,148.00 0.0000 0.5000 0.0985 0.1000 0.2000 0.2000 0.3000 ±1.65% 5074
· applyClipChanges — 1000 clips / single move 17,258.00 0.0000 0.4000 0.0579 0.1000 0.2000 0.2000 0.3000 ±2.05% 8629
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > applyTrackChanges (controlled reducer) 1236ms
· applyTrackChanges — 50 tracks / 50 patches 190,324.00 0.0000 0.3000 0.0053 0.0000 0.1000 0.1000 0.1000 ±2.71% 95162
· applyTrackChanges — 500 tracks / 500 patches 18,284.00 0.0000 0.4000 0.0547 0.1000 0.2000 0.2000 0.3000 ±2.09% 9142
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > TimelineRoot — mount (full tree) 1446ms
· mount — 4 tracks / 50 clips 196.51 3.7000 52.7000 5.0889 4.3000 52.7000 52.7000 52.7000 ±20.03% 99
· mount — 8 tracks / 500 clips 22.7790 34.9000 94.4000 43.9000 41.2000 94.4000 94.4000 94.4000 ±23.28% 12
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > TimelineRoot — update after prop change 2151ms
· zoom change (pxPerSecond) — 8 tracks / 500 clips 14.4949 59.8000 138.30 68.9900 62.1000 138.30 138.30 138.30 ±25.29% 10
· clips-array swap — 8 tracks / 500 clips 13.0225 66.7000 150.90 76.7900 70.0000 150.90 150.90 150.90 ±24.30% 10
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > buildEvaluator — build cost 5205ms
· linear — 16 anchors 1,234,628.00 0.0000 0.3000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.77% 617314
· linear — 256 anchors 125,932.00 0.0000 0.3000 0.0079 0.0000 0.1000 0.1000 0.1000 ±2.67% 62966
· monotone — 16 anchors 383,598.00 0.0000 0.2000 0.0026 0.0000 0.1000 0.1000 0.1000 ±2.74% 191799
· monotone — 256 anchors 33,197.36 0.0000 0.2000 0.0301 0.1000 0.1000 0.1000 0.2000 ±2.37% 16602
· catmull-rom — 16 anchors 79,434.11 0.0000 1.5000 0.0126 0.0000 0.1000 0.1000 0.1000 ±2.79% 39725
· catmull-rom — 256 anchors 50,563.89 0.0000 1.5000 0.0198 0.0000 0.1000 0.1000 0.2000 ±2.70% 25287
· bezier — 16 anchors 720,043.99 0.0000 0.2000 0.0014 0.0000 0.1000 0.1000 0.1000 ±2.75% 360094
· bezier — 256 anchors 68,496.00 0.0000 0.2000 0.0146 0.0000 0.1000 0.1000 0.1000 ±2.58% 34248
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > evaluator sampling — 256 samples 2576ms
· linear 343,429.32 0.0000 0.2000 0.0029 0.0000 0.1000 0.1000 0.1000 ±2.73% 171749
· monotone 592,140.00 0.0000 0.1000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.75% 296070
· catmull-rom 248,890.00 0.0000 0.2000 0.0040 0.0000 0.1000 0.1000 0.1000 ±2.72% 124445
· bezier (Newton-Raphson per call) 161,676.00 0.0000 0.2000 0.0062 0.0000 0.1000 0.1000 0.1000 ±2.69% 80838
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > evaluator sampling — 1024 samples (stress) 1848ms
· monotone — 16 anchors 156,256.00 0.0000 0.2000 0.0064 0.0000 0.1000 0.1000 0.1000 ±2.68% 78128
· monotone — 256 anchors (deep binary search) 111,976.00 0.0000 0.2000 0.0089 0.0000 0.1000 0.1000 0.1000 ±2.65% 55988
· bezier — 16 anchors 48,394.00 0.0000 0.3000 0.0207 0.0000 0.1000 0.1000 0.2000 ±2.50% 24197
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > build + sample 256 (full per-edit, 16 anchors) 1864ms
· monotone 185,120.98 0.0000 0.5000 0.0054 0.0000 0.1000 0.1000 0.1000 ±2.71% 92579
· catmull-rom 58,420.32 0.0000 1.5000 0.0171 0.0000 0.1000 0.1000 0.2000 ±2.75% 29216
· bezier 120,559.89 0.0000 0.3000 0.0083 0.0000 0.1000 0.1000 0.1000 ±2.67% 60292
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > toLUT — spline lookup table 1841ms
· monotone — 256 entries 110,670.00 0.0000 0.3000 0.0090 0.0000 0.1000 0.1000 0.1000 ±2.65% 55335
· monotone — 1024 entries 27,216.56 0.0000 0.2000 0.0367 0.1000 0.1000 0.2000 0.2000 ±2.29% 13611
· bezier — 256 entries 82,832.00 0.0000 0.3000 0.0121 0.0000 0.1000 0.1000 0.1000 ±2.62% 41416
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > curve path `d` build — sampled polyline (256 samples) 1217ms
· monotone — sample + project + buildPolylinePath 40,060.00 0.0000 2.3000 0.0250 0.0000 0.1000 0.1000 0.2000 ±2.61% 20030
· catmull-rom — sample + project + buildPolylinePath 36,482.00 0.0000 1.9000 0.0274 0.1000 0.1000 0.1000 0.2000 ±2.53% 18241
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > curve path `d` build — bezier segment chain 1242ms
· 16 anchors (15 segments) 234,902.00 0.0000 1.6000 0.0043 0.0000 0.1000 0.1000 0.1000 ±2.87% 117451
· 256 anchors (255 segments) 11,619.68 0.0000 2.1000 0.0861 0.1000 0.2000 0.2000 0.5000 ±2.14% 5811
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > spline primitives — per-call baselines 5370ms
· linearInterpolate — 256-pt table lookup 7,088,362.35 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3544890
· catmullRom — 16-pt parametric eval 7,313,673.99 0.0000 0.7000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.79% 3656837
· evalCubicBezier — single cubic eval 6,966,088.85 0.0000 0.7000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.79% 3483741
· monotoneCubic — build closure (16 pts) 510,439.91 0.0000 0.3000 0.0020 0.0000 0.1000 0.1000 0.1000 ±2.75% 255271
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > anchor housekeeping 2732ms
· sortAnchors — 16 (unsorted) 1,003,727.99 0.0000 0.3000 0.0010 0.0000 0.0000 0.1000 0.1000 ±2.77% 501864
· sortAnchors — 256 (unsorted) 46,338.73 0.0000 0.4000 0.0216 0.0000 0.1000 0.1000 0.2000 ±2.49% 23174
· anchorsToPoints — 16 1,232,089.59 0.0000 0.6000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.78% 616168
· anchorsToPoints — 256 125,538.00 0.0000 0.3000 0.0080 0.0000 0.1000 0.1000 0.1000 ±2.67% 62769
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > pointer-move clamp math 7694ms
· clampAnchorX — interior anchor (neighbour clamp), 16 7,403,125.41 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3702303
· clampAnchorX — interior anchor (neighbour clamp), 256 7,373,969.27 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3687722
· clampAnchorY — domain clamp 7,443,999.99 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3722000
· simulated updateAnchor step — clamp + slice-replace, 16 6,432,109.99 0.0000 7.2000 0.0002 0.0000 0.0000 0.0000 0.1000 ±3.96% 3216055
· simulated updateAnchor step — clamp + slice-replace, 256 5,501,865.63 0.0000 0.3000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.78% 2751483
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > simulated drag stroke (60 frames, monotone, 16 anchors) 603ms
· clamp + rebuild + sample-256 per frame 3,084.00 0.2000 0.8000 0.3243 0.4000 0.4000 0.5000 0.7000 ±0.94% 1542
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > mount — Root + Curve + N Points 2026ms
· 50 points (monotone) 231.22 3.0000 52.5000 4.3248 3.4000 25.9000 52.5000 52.5000 ±21.15% 121
· 500 points (monotone, stress) 23.6220 32.1000 101.60 42.3333 38.5000 101.60 101.60 101.60 ±28.33% 12
· 50 points (bezier path) 239.62 2.9000 78.0000 4.1733 3.2000 9.8000 78.0000 78.0000 ±30.21% 120
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > update after prop change (50 points) 1215ms
· switch interpolation monotone→bezier→monotone 165.90 5.1000 10.5000 6.0277 5.6000 10.5000 10.5000 10.5000 ±5.79% 83
· replace model array (commit an edit) 118.34 7.5000 12.3000 8.4500 8.1000 12.3000 12.3000 12.3000 ±4.39% 60
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > rotatePoint — kernel 2625ms
· rotatePoint × 100 4,186,828.67 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2093833
· rotatePoint × 1000 893,619.28 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 446899
· rotateVector (origin-free) × 1000 896,738.65 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 448459
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > pointer angle math 3950ms
· pointerAngle × 100 3,031,405.99 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1515703
· pointerAngle × 1000 889,550.00 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 444775
· shortestAngleDelta × 1000 900,573.89 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 450377
· normalizeRotation × 1000 1,020,559.89 0.0000 0.1000 0.0010 0.0000 0.0000 0.1000 0.1000 ±2.76% 510382
· snapRotation (15°) × 1000 1,030,022.00 0.0000 0.1000 0.0010 0.0000 0.0000 0.1000 0.1000 ±2.76% 515011
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > rotate drag — per-frame 1280ms
· rotationFromPointer × 100 frames 533,194.00 0.0000 0.1000 0.0019 0.0000 0.1000 0.1000 0.1000 ±2.75% 266597
· rotationFromPointer × 1000 frames 52,782.00 0.0000 0.2000 0.0189 0.0000 0.1000 0.1000 0.1000 ±2.51% 26391
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > resizeEdge — per-frame 3636ms
· resizeEdge corner (no options) × 100 108,148.00 0.0000 0.3000 0.0092 0.0000 0.1000 0.1000 0.1000 ±2.65% 54074
· resizeEdge corner (no options) × 1000 10,806.00 0.0000 0.5000 0.0925 0.1000 0.2000 0.2000 0.3000 ±1.67% 5403
· resizeEdge aspect-locked corner × 1000 8,762.25 0.0000 0.4000 0.1141 0.1000 0.2000 0.2000 0.3000 ±1.50% 4382
· resizeEdge symmetric (Alt) corner × 1000 9,848.00 0.0000 0.3000 0.1015 0.1000 0.2000 0.2000 0.3000 ±1.55% 4924
· resizeEdge edge handle × 1000 13,704.00 0.0000 0.3000 0.0730 0.1000 0.2000 0.2000 0.2000 ±1.85% 6852
· rotated scale frame (rotateVector → resizeEdge) × 1000 8,680.53 0.0000 0.4000 0.1152 0.2000 0.2000 0.2000 0.3000 ±1.55% 4342
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > aspect + axes helpers 1376ms
· applyAspectRatio × 1000 421,907.62 0.0000 0.1000 0.0024 0.0000 0.1000 0.1000 0.1000 ±2.74% 210996
· handleAxes × 8 positions × 125 (=1000) 967,260.00 0.0000 0.1000 0.0010 0.0000 0.1000 0.1000 0.1000 ±2.76% 483630
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > constrain + move 2025ms
· constrainRect × 1000 901,852.00 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 450926
· moveBox × 1000 61,595.68 0.0000 0.2000 0.0162 0.0000 0.1000 0.1000 0.2000 ±2.56% 30804
· resolvePivot (center) × 1000 898,150.37 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 449165
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > local ⇄ world 703ms
· localToWorld → worldToLocal round-trip × 1000 892,126.00 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 446063
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > decomposeTransform — corners 1230ms
· decomposeTransform × 100 175,760.00 0.0000 0.5000 0.0057 0.0000 0.1000 0.1000 0.1000 ±2.72% 87880
· decomposeTransform × 1000 15,972.00 0.0000 0.3000 0.0626 0.1000 0.2000 0.2000 0.2000 ±2.00% 7986
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > TransformBoxRoot — mount full part set 7944ms
· mount + unmount — 1 box (root + 8 handles + rotate + status) 1,188.34 0.5000 47.3000 0.8415 0.7000 6.2000 6.8000 47.3000 ±19.41% 595
· mount + unmount — 50 boxes 26.9024 28.2000 95.9000 37.1714 34.1000 95.9000 95.9000 95.9000 ±26.49% 14
· mount + unmount — 500 boxes (stress) 2.5223 338.90 636.70 396.47 373.10 636.70 636.70 636.70 ±19.60% 10
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > TransformBoxRoot — update after transform change 1010ms
· mount → setProps(transform) → update — 50 boxes 30.7515 27.7000 36.6000 32.5188 34.6000 36.6000 36.6000 36.6000 ±5.11% 16
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > edge-paths — straight 1308ms
· 100 edges 664,128.00 0.0000 0.5000 0.0015 0.0000 0.1000 0.1000 0.1000 ±2.78% 332064
· 1000 edges 72,543.49 0.0000 0.5000 0.0138 0.0000 0.1000 0.1000 0.2000 ±2.61% 36279
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > edge-paths — bezier 1221ms
· 100 edges 77,522.00 0.0000 0.8000 0.0129 0.0000 0.1000 0.1000 0.2000 ±2.64% 38761
· 1000 edges 6,873.25 0.0000 0.6000 0.1455 0.2000 0.3000 0.3000 0.4000 ±1.43% 3438
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > edge-paths — smoothstep (corner builder) 1209ms
· 100 edges 13,001.40 0.0000 0.6000 0.0769 0.1000 0.2000 0.2000 0.4000 ±1.88% 6502
· 1000 edges 1,290.19 0.6000 1.3000 0.7751 0.8000 1.1000 1.3000 1.3000 ±0.87% 646
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > edge-paths — step (zero-radius smoothstep) 1208ms
· 100 edges 12,797.44 0.0000 0.5000 0.0781 0.1000 0.2000 0.2000 0.4000 ±1.89% 6400
· 1000 edges 1,268.48 0.6000 1.6000 0.7883 0.8000 1.3000 1.5000 1.6000 ±1.08% 635
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > pointer math — screenToFlow 1772ms
· 100 moves 3,327,878.00 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1663939
· 1000 moves 533,703.26 0.0000 0.1000 0.0019 0.0000 0.1000 0.1000 0.1000 ±2.75% 266905
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > pointer math — flowToScreen 1695ms
· 100 points 3,548,374.33 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1774542
· 1000 points 596,212.76 0.0000 0.1000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.75% 298166
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > pointer math — zoomAtPointer (wheel zoom) 1708ms
· 100 wheel steps 3,119,281.99 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1559641
· 1000 wheel steps 466,404.72 0.0000 0.1000 0.0021 0.0000 0.1000 0.1000 0.1000 ±2.74% 233249
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > pointer math — snapPoint (drag with snap-to-grid) 1399ms
· 100 moves 1,187,976.00 0.0000 0.1000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.76% 593988
· 1000 moves 137,674.00 0.0000 0.2000 0.0073 0.0000 0.1000 0.1000 0.1000 ±2.67% 68837
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > getNodesBounds 1430ms
· 100 nodes 1,637,925.99 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 818963
· 1000 nodes 167,484.00 0.0000 0.2000 0.0060 0.0000 0.1000 0.1000 0.1000 ±2.69% 83742
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > fitViewTransform (bounds + fit) 1421ms
· 100 nodes 1,622,870.00 0.0000 0.4000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.77% 811435
· 1000 nodes 168,224.36 0.0000 0.1000 0.0059 0.0000 0.1000 0.1000 0.1000 ±2.69% 84129
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > getNodePositionAbsolute — parent chain (depth 64) 1295ms
· single leaf walk 725,752.85 0.0000 0.1000 0.0014 0.0000 0.1000 0.1000 0.1000 ±2.75% 362949
· 64 nodes (all walked) 25,778.00 0.0000 0.2000 0.0388 0.1000 0.1000 0.2000 0.2000 ±2.25% 12889
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > visibleFlowRect + getVisibleNodeIds (node cull) 1309ms
· 100 nodes 800,992.00 0.0000 0.1000 0.0012 0.0000 0.1000 0.1000 0.1000 ±2.75% 400496
· 1000 nodes 80,856.00 0.0000 0.2000 0.0124 0.0000 0.1000 0.1000 0.1000 ±2.60% 40428
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > getVisibleEdgeIds (edge cull by visible node set) 1310ms
· 100 edges 796,650.67 0.0000 0.2000 0.0013 0.0000 0.1000 0.1000 0.1000 ±2.76% 398405
· 1000 edges 79,270.15 0.0000 0.3000 0.0126 0.0000 0.1000 0.1000 0.1000 ±2.60% 39643
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > getNodesInsideRect (marquee selection) 1429ms
· 100 nodes 1,742,606.00 0.0000 0.4000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.78% 871303
· 1000 nodes 159,956.01 0.0000 0.3000 0.0063 0.0000 0.1000 0.1000 0.1000 ±2.69% 79994
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > findClosestHandle (connect-drag snapping) 1216ms
· 100 nodes 3,915.22 0.1000 0.6000 0.2554 0.3000 0.4000 0.4000 0.6000 ±1.04% 1958
· 1000 nodes 360.41 2.7000 3.2000 2.7746 2.8000 3.2000 3.2000 3.2000 ±0.45% 181
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > applyNodeChanges (drag → position changes) 1223ms
· 100 position changes 115,198.00 0.0000 0.4000 0.0087 0.0000 0.1000 0.1000 0.1000 ±2.68% 57599
· 1000 position changes 10,344.00 0.0000 0.5000 0.0967 0.1000 0.2000 0.2000 0.4000 ±1.67% 5172
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > applyEdgeChanges (select changes) 1223ms
· 100 select changes 113,930.00 0.0000 0.4000 0.0088 0.0000 0.1000 0.1000 0.1000 ±2.68% 56965
· 1000 select changes 10,150.00 0.0000 0.5000 0.0985 0.1000 0.2000 0.2000 0.3000 ±1.63% 5075
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > addEdge (dedupe scan on connect) 1271ms
· append into 100 edges 422,387.52 0.0000 0.3000 0.0024 0.0000 0.1000 0.1000 0.1000 ±2.75% 211236
· append into 1000 edges 41,092.00 0.0000 0.3000 0.0243 0.0000 0.1000 0.1000 0.2000 ±2.47% 20546
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > FlowRoot — mount + unmount 1879ms
· 50 nodes / 50 edges 127.81 5.7000 43.9000 7.8242 6.8000 43.9000 43.9000 43.9000 ±16.28% 66
· 500 nodes / 500 edges 13.8007 64.9000 104.80 72.4600 72.8000 104.80 104.80 104.80 ±11.67% 10
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > FlowRoot — re-render after prop change (viewport pan) 4494ms
· 50 nodes — viewport setProps 78.8022 9.3000 97.7000 12.6900 10.0000 97.7000 97.7000 97.7000 ±35.07% 40
· 500 nodes — nodes setProps (controlled replace) 3.8464 234.50 331.70 259.98 258.90 331.70 331.70 331.70 ±7.26% 10
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > tick generation — timeTicks (seconds mode) 1585ms
· realistic window (~15s @ 40px/s) 2,270,653.88 0.0000 0.5000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.80% 1135554
· stress window (1000s @ 4px/s) 628,932.00 0.0000 0.3000 0.0016 0.0000 0.1000 0.1000 0.1000 ±2.76% 314466
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > tick generation — timecodeTicks (timecode mode) 2025ms
· realistic window 659,680.07 0.0000 5.2000 0.0015 0.0000 0.1000 0.1000 0.1000 ±3.44% 329906
· stress window 212,063.59 0.0000 0.3000 0.0047 0.0000 0.1000 0.1000 0.1000 ±2.72% 106053
· realistic window — drop-frame labels 639,334.00 0.0000 0.3000 0.0016 0.0000 0.1000 0.1000 0.1000 ±2.76% 319667
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > tick generation — frameTicks (frames mode) 1226ms
· realistic window — timecode ticker w/ frame labels 13,785.24 0.0000 0.2000 0.0725 0.1000 0.2000 0.2000 0.2000 ±1.87% 6894
· stress window — integer-frame axis 3,373.33 0.2000 0.4000 0.2964 0.3000 0.4000 0.4000 0.4000 ±0.90% 1687
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > tick generation — niceTicks (generic axis) 1620ms
· realistic window 2,482,165.58 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.78% 1241331
· stress window 689,030.00 0.0000 0.3000 0.0015 0.0000 0.1000 0.1000 0.1000 ±2.76% 344515
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > projection math — scaleLinear (time → px) 2085ms
· 100 values 5,357,289.99 0.0000 4.6000 0.0002 0.0000 0.0000 0.0000 0.1000 ±3.29% 2678645
· 1000 values 954,867.03 0.0000 0.1000 0.0010 0.0000 0.1000 0.1000 0.1000 ±2.76% 477529
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > projection math — scaleLinear (px → time, invert) 2082ms
· 100 pixels 5,374,567.97 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2687284
· 1000 pixels 897,614.00 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 448807
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > projection math — roundToStep (snap, pointer path) 1228ms
· 100 values 145,666.87 0.0000 1.2000 0.0069 0.0000 0.1000 0.1000 0.1000 ±2.72% 72848
· 1000 values 13,971.21 0.0000 0.4000 0.0716 0.1000 0.2000 0.2000 0.2000 ±1.90% 6987
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > useScale — projector closures 1260ms
· scale() × 1000 24,637.07 0.0000 0.3000 0.0406 0.1000 0.2000 0.2000 0.2000 ±2.24% 12321
· invert() × 100 (pointer sweep) 362,189.56 0.0000 5.2000 0.0028 0.0000 0.1000 0.1000 0.1000 ±3.41% 181131
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > label formatting — per mode 3045ms
· formatClock × 1000 (seconds) 58,456.00 0.0000 0.4000 0.0171 0.0000 0.1000 0.1000 0.2000 ±2.55% 29228
· formatTimecode × 1000 (timecode) 14,244.00 0.0000 0.4000 0.0702 0.1000 0.2000 0.2000 0.2000 ±1.92% 7122
· framesToTimecode × 1000 — drop-frame 11,798.00 0.0000 0.3000 0.0848 0.1000 0.2000 0.2000 0.2000 ±1.75% 5899
· formatFrames × 1000 (frames) 126.51 7.8000 8.0000 7.9047 7.9000 8.0000 8.0000 8.0000 ±0.20% 64
· formatTimeForMode × 1000 — dispatch (timecode) 14,360.00 0.0000 0.5000 0.0696 0.1000 0.2000 0.2000 0.2000 ±1.90% 7180
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > mode plumbing 3848ms
· modeToTickKind × 3 modes 7,138,575.98 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3569288
· tickFormatFor × 3 modes 7,160,973.99 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3580487
· secondsToFrames × 1000 900,848.00 0.0000 0.2000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 450424
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > TimeRulerRoot — mount 1816ms
· mount — seconds mode 3,622.55 0.1000 7.5000 0.2760 0.3000 0.5000 5.6000 6.9000 ±7.69% 1812
· mount — timecode mode 3,641.08 0.1000 15.8000 0.2746 0.3000 0.5000 4.9000 10.0000 ±9.53% 1826
· mount — frames mode 3,690.00 0.1000 17.4000 0.2710 0.3000 0.4000 3.8000 5.9000 ±8.82% 1845
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > TimeRulerRoot — re-render after prop change 1807ms
· zoom change (pan/zoom gesture stream) 2,864.85 0.2000 16.5000 0.3491 0.3000 0.6000 4.5000 5.0000 ±8.18% 1433
· offset change (pan stream) 2,864.85 0.2000 19.1000 0.3491 0.3000 0.6000 4.7000 18.3000 ±11.20% 1433
· mode change (timecode → frames, regenerate ladder) 2,636.42 0.2000 19.4000 0.3793 0.4000 0.7000 4.7000 5.4000 ±9.04% 1319
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > resolveAxisLock — per-frame axis decision 4332ms
· static axis "x" — fast path (100 frames) 5,190,889.99 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2595445
· axis "both", no shift-lock (100 frames) 5,139,134.18 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2570081
· axis "both" + shift-lock dominant-axis pick (100 frames) 3,188,760.26 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1594699
· axis "both" + shift-lock dominant-axis pick (1000 frames) 536,272.00 0.0000 0.1000 0.0019 0.0000 0.1000 0.1000 0.1000 ±2.75% 268136
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > computeFrame — single frame (feature on/off matrix) 4741ms
· free move, no snap/bounds/rect 7,265,881.98 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3632941
· axis-locked + scalar snap + bounds + rect (all features) 7,268,978.27 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3635216
· tuple snap + bounds (per-axis grid) 7,100,307.97 0.0000 0.3000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3550154
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > computeFrame — full gesture stream 2307ms
· 100 frames — free move (no snap/bounds) 2,542,427.52 0.0000 0.1000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1271468
· 100 frames — snap + bounds + rect 1,352,931.41 0.0000 0.1000 0.0007 0.0000 0.0000 0.1000 0.1000 ±2.76% 676601
· 1000 frames — snap + bounds + rect (stress) 158,140.37 0.0000 0.2000 0.0063 0.0000 0.1000 0.1000 0.1000 ±2.68% 79086
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > simulated flush() pipeline — resolveAxisLock + computeFrame 2103ms
· 100 moves — shift-lock, no snap/bounds 1,252,108.00 0.0000 0.2000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.76% 626054
· 100 moves — shift-lock + snap + bounds + rect 714,692.00 0.0000 0.2000 0.0014 0.0000 0.1000 0.1000 0.1000 ±2.76% 357346
· 1000 moves — shift-lock + snap + bounds + rect (stress) 71,891.62 0.0000 0.2000 0.0139 0.0000 0.1000 0.1000 0.1000 ±2.59% 35953
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > usePointerDrag — mount N instances 1444ms
· mount 50 draggable handles 3,532.68 0.1000 16.5000 0.2831 0.3000 4.5000 5.5000 8.6000 ±10.78% 1778
· mount 500 draggable handles (stress) 375.97 2.0000 9.8000 2.6598 2.3000 8.8000 9.8000 9.8000 ±8.40% 189
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > usePointerDrag — update after prop change 806ms
· 50 handles → re-render to 60 handles 1,159.21 0.2000 379.80 0.8627 0.4000 5.2000 5.8000 379.80 ±105.99% 814
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > usePointerDrag — live event round-trip (rAF-coalesced) 2625ms
· mount + down + 20 moves + up 5.7202 173.00 176.30 174.82 176.10 176.30 176.30 176.30 ±0.48% 10
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > countBars 3183ms
· small body (300px) 7,352,141.59 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3676806
· large body (1800px) 7,120,649.88 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3561037
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > resamplePeaks — by source length (100 buckets) 2620ms
· 100 peaks 1,162,090.00 0.0000 0.4000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.82% 581045
· 1000 peaks 274,426.00 0.0000 0.3000 0.0036 0.0000 0.1000 0.1000 0.1000 ±2.74% 137213
· 10000 peaks 32,241.55 0.0000 0.3000 0.0310 0.1000 0.1000 0.2000 0.2000 ±2.39% 16124
· 10000 peaks (Float32Array) 28,240.00 0.0000 0.4000 0.0354 0.1000 0.2000 0.2000 0.2000 ±2.35% 14120
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > resamplePeaks — by bucket count (10000 peaks) 1815ms
· 100 buckets 30,416.00 0.0000 0.3000 0.0329 0.1000 0.1000 0.2000 0.2000 ±2.37% 15208
· 600 buckets 24,258.00 0.0000 0.4000 0.0412 0.1000 0.2000 0.2000 0.2000 ±2.27% 12129
· upsample → 2000 buckets 17,884.42 0.0000 0.4000 0.0559 0.1000 0.2000 0.2000 0.3000 ±2.12% 8944
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > resamplePeaks — windowed slice (zoom/scroll) 1219ms
· full window — 600 buckets over 10000 24,211.16 0.0000 0.4000 0.0413 0.1000 0.2000 0.2000 0.3000 ±2.26% 12108
· 25% zoom window — 600 buckets over slice 81,302.00 0.0000 0.5000 0.0123 0.0000 0.1000 0.1000 0.2000 ±2.65% 40651
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > buildBars — bars-mode geometry 1837ms
· 100 bars from 1000 peaks 192,124.00 0.0000 0.4000 0.0052 0.0000 0.1000 0.1000 0.1000 ±2.73% 96062
· 600 bars from 10000 peaks 20,179.96 0.0000 0.5000 0.0496 0.1000 0.2000 0.2000 0.3000 ±2.19% 10092
· 600 bars from 10000 peaks (Float32Array) 20,065.99 0.0000 0.5000 0.0498 0.1000 0.2000 0.2000 0.3000 ±2.19% 10035
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > buildBars — sliding window (simulated scrub/zoom recompute) 608ms
· 600 bars, window slides per iteration 48,556.00 0.0000 0.3000 0.0206 0.0000 0.1000 0.1000 0.2000 ±2.52% 24278
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > buildPathPoints — path-mode silhouette 1229ms
· 256 samples from 1000 peaks 143,684.00 0.0000 0.3000 0.0070 0.0000 0.1000 0.1000 0.1000 ±2.68% 71842
· 1024 samples from 10000 peaks 17,946.41 0.0000 0.4000 0.0557 0.1000 0.2000 0.2000 0.2000 ±2.08% 8975
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > buildSmoothPath — Catmull-Rom path string 1814ms
· 256 points, tension 0 20,822.00 0.0000 12.7000 0.0480 0.1000 0.2000 0.2000 0.3000 ±5.58% 10411
· 256 points, tension 0.5 21,690.00 0.0000 1.8000 0.0461 0.1000 0.2000 0.2000 0.3000 ±2.49% 10845
· 1024 points, tension 0 3,891.22 0.1000 1.9000 0.2570 0.3000 0.5000 0.6000 1.9000 ±1.71% 1946
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > WaveformRoot + WaveformBars — mount 1212ms
· mount with ~50-bar fixture 3,469.92 0.1000 25.3000 0.2882 0.3000 0.6000 4.6000 14.5000 ±12.57% 1736
· mount with ~500-bar fixture 2,211.16 0.3000 7.5000 0.4523 0.4000 2.1000 2.3000 3.4000 ±4.26% 1110
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > WaveformRoot — update after prop change 1209ms
· currentTime change → patch 3,270.69 0.1000 4.4000 0.3057 0.3000 0.6000 3.7000 4.3000 ±5.00% 1636
· peaks swap → re-resample + patch 3,008.00 0.1000 26.9000 0.3324 0.3000 0.4000 4.2000 18.1000 ±13.36% 1504
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > WaveformRoot + WaveformPath — mount 1212ms
· path mode, 256 samples 3,722.51 0.1000 26.3000 0.2686 0.3000 0.4000 4.8000 21.6000 ±14.62% 1862
· path mode, 1024 samples 3,783.24 0.1000 24.8000 0.2643 0.2000 0.5000 4.8000 23.8000 ±14.72% 1892
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > sampleKeyframes — single sample by curve size 2895ms
· 100 keyframes — sample mid-range 6,003,987.24 0.0000 0.4000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 3002594
· 1000 keyframes — sample mid-range 6,322,849.43 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 3162057
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > sampleKeyframes — full curve sweep (per-frame readout) 1241ms
· 100 keyframes × 120 samples 131,984.00 0.0000 0.3000 0.0076 0.0000 0.1000 0.1000 0.1000 ±2.68% 65992
· 1000 keyframes × 120 samples 132,100.00 0.0000 0.4000 0.0076 0.0000 0.1000 0.1000 0.1000 ±2.68% 66050
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > solveBezierX — easing solve 1954ms
· identity (linear) × 64 4,689,626.08 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2345282
· ease-in-out (Newton-Raphson) × 64 684,475.11 0.0000 0.1000 0.0015 0.0000 0.1000 0.1000 0.1000 ±2.75% 342306
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > sortKeyframes — reconcile / commit 1289ms
· 100 keyframes (reverse-sorted input) 572,597.48 0.0000 0.3000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.77% 286356
· 1000 keyframes (reverse-sorted input) 58,944.00 0.0000 0.5000 0.0170 0.0000 0.1000 0.1000 0.2000 ±2.58% 29472
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > clampKeyframeTime — neighbour clamp (pointer drag) 1495ms
· 100 keyframes × 100 moves 1,118,014.00 0.0000 0.1000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.76% 559007
· 1000 keyframes × 100 moves 1,098,890.23 0.0000 0.1000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.76% 549555
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > snapTimeToFrame — frame-grid quantize 942ms
· 100 quantize ops @30fps 2,803,763.98 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1401882
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > defaultKeyframeValueText — aria-valuetext 653ms
· 100 value-text formats (with property) 374,868.00 0.0000 2.1000 0.0027 0.0000 0.1000 0.1000 0.1000 ±2.87% 187434
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > KeyframeTrackRoot — mount + unmount 3241ms
· mount 50 keyframes 124.68 6.5000 17.3000 8.0206 7.5000 17.3000 17.3000 17.3000 ±7.59% 63
· mount 500 keyframes 5.5850 167.40 205.80 179.05 182.80 205.80 205.80 205.80 ±4.79% 10
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > KeyframeTrackRoot — re-render after prop change 3593ms
· 50 keyframes — duration change + flush 106.93 8.1000 14.6000 9.3519 8.8000 14.6000 14.6000 14.6000 ±5.96% 54
· 500 keyframes — duration change + flush 5.0792 191.20 211.80 196.88 198.90 211.80 211.80 211.80 ±2.12% 10
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > evalCubicBezier — sweep t 2273ms
· 100 params 5,712,887.99 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2856444
· 1000 params 1,745,702.86 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 873026
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > cubicBezierTangent — sweep t 2221ms
· 100 params 5,713,947.23 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2857545
· 1000 params 1,732,195.56 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 866271
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > solveBezierX — ease (x→y) 1966ms
· 100 params 430,674.00 0.0000 0.3000 0.0023 0.0000 0.1000 0.1000 0.1000 ±2.75% 215337
· 1000 params 75,640.00 0.0000 0.2000 0.0132 0.0000 0.1000 0.1000 0.1000 ±2.59% 37820
· 1000 params — identity short-circuit 746,870.00 0.0000 0.1000 0.0013 0.0000 0.1000 0.1000 0.1000 ±2.75% 373435
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > cubicBezier1D — scalar Bernstein 799ms
· 1000 params 1,727,796.00 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 863898
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > catmullRom — sweep t 2559ms
· 50 knots × 100 params 573,049.39 0.0000 0.2000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.75% 286582
· 500 knots × 100 params 566,750.00 0.0000 0.2000 0.0018 0.0000 0.1000 0.1000 0.1000 ±2.75% 283375
· 500 knots × 1000 params 61,514.00 0.0000 0.2000 0.0163 0.0000 0.1000 0.1000 0.2000 ±2.56% 30757
· 500 knots × 1000 params — closed 25,026.00 0.0000 0.5000 0.0400 0.1000 0.2000 0.2000 0.2000 ±2.28% 12513
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > monotoneCubic — build 1223ms
· 100 knots 107,972.00 0.0000 0.4000 0.0093 0.0000 0.1000 0.1000 0.1000 ±2.65% 53986
· 1000 knots 11,616.00 0.0000 0.4000 0.0861 0.1000 0.2000 0.2000 0.3000 ±1.74% 5808
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > monotoneCubic — apply (pre-built fn) 1841ms
· 100 knots → 256-LUT 112,353.53 0.0000 0.2000 0.0089 0.0000 0.1000 0.1000 0.1000 ±2.65% 56188
· 1000 knots → 256-LUT 98,026.39 0.0000 0.3000 0.0102 0.0000 0.1000 0.1000 0.1000 ±2.64% 49023
· 1000 knots → 1024-LUT 22,792.00 0.0000 0.3000 0.0439 0.1000 0.2000 0.2000 0.2000 ±2.19% 11396
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > monotoneCubic — build + apply (knots changed) 1211ms
· 100 knots → build + 256-LUT 51,035.79 0.0000 0.3000 0.0196 0.0000 0.1000 0.1000 0.2000 ±2.51% 25523
· 1000 knots → build + 256-LUT 10,241.95 0.0000 0.4000 0.0976 0.1000 0.2000 0.2000 0.3000 ±1.64% 5122
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > linearInterpolate — query sweep 1218ms
· 100 knots × 1000 queries 58,300.00 0.0000 0.2000 0.0172 0.0000 0.1000 0.1000 0.2000 ±2.54% 29150
· 1000 knots × 1000 queries 45,376.00 0.0000 0.2000 0.0220 0.0000 0.1000 0.1000 0.2000 ±2.47% 22688
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > sampleToPolyline — bezier curve 1256ms
· 100 segments 302,246.00 0.0000 0.2000 0.0033 0.0000 0.1000 0.1000 0.1000 ±2.73% 151123
· 1000 segments 29,642.07 0.0000 0.2000 0.0337 0.1000 0.1000 0.2000 0.2000 ±2.33% 14824
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > sampleFnToPolyline — monotone curve 1244ms
· 100 segments 237,716.46 0.0000 0.2000 0.0042 0.0000 0.1000 0.1000 0.1000 ±2.72% 118882
· 1000 segments 24,476.00 0.0000 0.3000 0.0409 0.1000 0.2000 0.2000 0.2000 ±2.24% 12238
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > buildPolylinePath — string concat 1248ms
· 100 points 292,709.46 0.0000 5.2000 0.0034 0.0000 0.1000 0.1000 0.1000 ±3.40% 146384
· 1000 points 20,331.93 0.0000 4.1000 0.0492 0.1000 0.2000 0.2000 0.3000 ±2.83% 10168
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > buildSmoothPath — Catmull-Rom cubics 1836ms
· 50 points 164,496.00 0.0000 1.7000 0.0061 0.0000 0.1000 0.1000 0.1000 ±2.90% 82248
· 500 points 11,876.00 0.0000 2.8000 0.0842 0.1000 0.2000 0.3000 0.5000 ±2.30% 5938
· 500 points — tension 0.5 11,907.62 0.0000 1.5000 0.0840 0.1000 0.2000 0.2000 0.8000 ±2.10% 5955
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > buildBezierPath — single segment 1584ms
· 1 segment 7,229,343.95 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3614672
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > pointer-move — smooth path rebuild 622ms
· drag mutate + buildSmoothPath (64 points) 124,047.19 0.0000 3.3000 0.0081 0.0000 0.1000 0.1000 0.1000 ±3.04% 62036
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > pointer-move — curve recompute 609ms
· mutate knot + monotoneCubic + 256-LUT (100 knots) 50,884.00 0.0000 0.4000 0.0197 0.0000 0.1000 0.1000 0.2000 ±2.52% 25442
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: scaleLinear (pointer projection) 2187ms
· scaleLinear ×100 5,601,259.99 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2800630
· scaleLinear ×1000 1,711,881.63 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 856112
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: roundToStep (snap-to-step hot path) 1223ms
· roundToStep ×100 139,038.19 0.0000 0.4000 0.0072 0.0000 0.1000 0.1000 0.1000 ±2.69% 69533
· roundToStep ×1000 14,550.00 0.0000 0.3000 0.0687 0.1000 0.2000 0.2000 0.2000 ±1.93% 7275
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: getStepDecimals (per-step cache miss) 609ms
· getStepDecimals ×1000 (varied step) 57,814.44 0.0000 0.4000 0.0173 0.0000 0.1000 0.1000 0.2000 ±2.55% 28913
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: getClosestValueIndex (nearest-thumb pick) 1233ms
· 100 thumbs ×100 picks 185,872.83 0.0000 0.2000 0.0054 0.0000 0.1000 0.1000 0.1000 ±2.70% 92955
· 1000 thumbs ×100 picks 19,448.00 0.0000 0.2000 0.0514 0.1000 0.2000 0.2000 0.2000 ±2.09% 9724
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: hasMinStepsBetweenSortedValues (drag invariant) 2044ms
· 100 values 5,019,368.13 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2510186
· 1000 values 1,238,818.25 0.0000 0.1000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.76% 619533
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: niceNum (tick rounding primitive) 866ms
· niceNum ×1000 (varied magnitude) 1,927,046.59 0.0000 0.1000 0.0005 0.0000 0.0000 0.1000 0.1000 ±2.76% 963716
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > ticks: niceTicks (realistic vs stress) 2234ms
· realistic (600s axis) 2,542,638.00 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.78% 1271319
· stress (10h axis, dense range) 186,886.00 0.0000 0.3000 0.0054 0.0000 0.1000 0.1000 0.1000 ±2.72% 93443
· stress + custom format 164,223.16 0.0000 0.4000 0.0061 0.0000 0.1000 0.1000 0.1000 ±2.73% 82128
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > ticks: timeTicks (human time ladder) 1426ms
· realistic (600s axis) 1,647,574.49 0.0000 0.4000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.77% 823952
· stress (10h axis, dense range) 53,833.23 0.0000 0.3000 0.0186 0.0000 0.1000 0.1000 0.2000 ±2.53% 26922
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > ticks: timecodeTicks (frame-aligned, fps conversion) 1297ms
· realistic (600s @ 30fps) 548,354.33 0.0000 0.4000 0.0018 0.0000 0.1000 0.1000 0.1000 ±2.77% 274232
· stress (10h @ 29.97fps drop-frame labels) 57,158.00 0.0000 0.4000 0.0175 0.0000 0.1000 0.1000 0.2000 ±2.57% 28579
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > ticks: frameTicks (integer-frame axis) 1210ms
· realistic (18000-frame axis) 15,666.00 0.0000 0.2000 0.0638 0.1000 0.2000 0.2000 0.2000 ±1.96% 7833
· stress (1.08M-frame axis, dense range) 583.65 1.6000 2.2000 1.7134 1.7000 1.8000 1.8000 2.2000 ±0.45% 292
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > timecode: framesToTimecode label formatting 1843ms
· non-drop ×100 131,950.00 0.0000 0.5000 0.0076 0.0000 0.1000 0.1000 0.1000 ±2.69% 65975
· drop-frame 29.97 ×100 98,946.00 0.0000 0.4000 0.0101 0.0000 0.1000 0.1000 0.1000 ±2.65% 49473
· drop-frame 29.97 ×1000 11,649.67 0.0000 0.5000 0.0858 0.1000 0.2000 0.2000 0.2000 ±1.76% 5826
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > timecode: scalar label formatters 2705ms
· formatClock ×1000 38,464.00 0.0000 0.5000 0.0260 0.1000 0.1000 0.1000 0.2000 ±2.44% 19232
· formatTimecode ×1000 (@30fps) 14,127.17 0.0000 0.5000 0.0708 0.1000 0.2000 0.2000 0.3000 ±1.93% 7065
· formatFrames ×1000 125.00 7.8000 8.1000 8.0000 8.0000 8.1000 8.1000 8.1000 ±0.20% 63
· secondsToFrames ×1000 1,958,522.30 0.0000 0.1000 0.0005 0.0000 0.0000 0.1000 0.1000 ±2.76% 979457
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > useScale: composable construction 2233ms
· build (plain options) 3,913,615.30 0.0000 0.2000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1957199
· build (clamp + step + ticks) 3,798,454.33 0.0000 5.6000 0.0003 0.0000 0.0000 0.0000 0.1000 ±3.54% 1899607
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > useScale: pointer-move loop (scale/invert/roundValue) 1825ms
· invert+round ×100 events 75,092.00 0.0000 0.3000 0.0133 0.0000 0.1000 0.1000 0.1000 ±2.59% 37546
· invert+round ×1000 events 7,250.55 0.0000 0.5000 0.1379 0.2000 0.3000 0.3000 0.4000 ±1.43% 3626
· scale ×1000 events 32,496.00 0.0000 0.2000 0.0308 0.1000 0.1000 0.2000 0.2000 ±2.36% 16248
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > useScale: reactive tick recompute on domain change (zoom/pan) 664ms
· zoom step → recompute ticks/major/minor 425,508.00 0.0000 11.8000 0.0024 0.0000 0.1000 0.1000 0.1000 ±6.84% 212754
✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > pointer → saturation/value math 2226ms
· pointerToSV — 100 moves (ltr) 2,405,101.98 0.0000 0.1000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1202551
· pointerToSV — 1000 moves (ltr) 333,625.28 0.0000 0.1000 0.0030 0.0000 0.1000 0.1000 0.1000 ±2.73% 166846
· pointerToSV — 1000 moves (rtl flip) 336,208.76 0.0000 0.1000 0.0030 0.0000 0.1000 0.1000 0.1000 ±2.73% 168138
✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > clampChannel — channel clamp 681ms
· clampChannel — 1000 calls 675,342.93 0.0000 0.2000 0.0015 0.0000 0.1000 0.1000 0.1000 ±2.75% 337739
✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > hsvToRgb — hue background recompute 1839ms
· hsvToRgb — 100 colors 187,550.00 0.0000 0.4000 0.0053 0.0000 0.1000 0.1000 0.1000 ±2.71% 93775
· hsvToRgb — 1000 colors 21,726.00 0.0000 0.3000 0.0460 0.1000 0.2000 0.2000 0.2000 ±2.19% 10863
· hsvaToCss — 1000 colors (full hsva) 15,998.80 0.0000 0.3000 0.0625 0.1000 0.2000 0.2000 0.2000 ±1.96% 8001
✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > preserve-hue setters — drag/key commit 1228ms
· setSaturationValue — 1000 commits (sweep incl. grey) 477.43 1.7000 14.6000 2.0946 1.9000 9.7000 9.8000 14.6000 ±8.93% 239
· setSaturation + setValue — 1000 key nudges 215.27 3.7000 20.6000 4.6454 4.0000 14.5000 20.6000 20.6000 ±11.18% 108
✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > mount — ColorAreaRoot + N thumbs 1574ms
· mount + unmount — 50 thumbs 481.33 1.6000 8.4000 2.0776 1.8000 8.1000 8.3000 8.4000 ±7.89% 241
· mount + unmount — 500 thumbs 48.6003 16.5000 31.7000 20.5760 24.1000 31.7000 31.7000 31.7000 ±8.52% 25
✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > update — re-render after HSVA change 603ms
· 1 thumb — mount then patch new HSVA 8,746.25 0.0000 14.5000 0.1143 0.1000 0.2000 0.3000 6.4000 ±9.94% 4374
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > histogramMax — peak scan 4098ms
· 100 bins 4,995,467.98 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2497734
· 256 bins 3,199,458.11 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1600049
· 1000 bins 1,277,438.00 0.0000 0.1000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.76% 638719
· 256 bins — all zero (guard path) 3,267,112.59 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1633883
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > projectBars — linear (peak scan + normalise + alloc) 1886ms
· 100 bins 340,717.86 0.0000 1.0000 0.0029 0.0000 0.1000 0.1000 0.1000 ±2.76% 170393
· 256 bins 141,573.69 0.0000 0.5000 0.0071 0.0000 0.1000 0.1000 0.1000 ±2.70% 70801
· 1000 bins 35,842.00 0.0000 0.6000 0.0279 0.1000 0.1000 0.1000 0.2000 ±2.44% 17921
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > projectBars — log (log1p per bin + alloc) 1865ms
· 100 bins 269,120.18 0.0000 0.6000 0.0037 0.0000 0.1000 0.1000 0.1000 ±2.73% 134587
· 256 bins 107,862.00 0.0000 0.6000 0.0093 0.0000 0.1000 0.1000 0.1000 ±2.68% 53931
· 1000 bins 24,446.00 0.0000 0.9000 0.0409 0.1000 0.2000 0.2000 0.2000 ±2.29% 12223
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > projectBars — all-zero guard (no NaN, no divide) 1244ms
· 256 bins — linear 144,297.14 0.0000 0.6000 0.0069 0.0000 0.1000 0.1000 0.1000 ±2.72% 72163
· 256 bins — log 142,830.00 0.0000 0.5000 0.0070 0.0000 0.1000 0.1000 0.1000 ±2.71% 71415
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > projectBarHeight — per-bin scalar (1000x loop) 1613ms
· linear x1000 1,734,069.99 0.0000 0.2000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 867035
· log x1000 1,715,284.95 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 857814
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > per-channel projection (RGB composite, record data) 1209ms
· 4 channels x 100 bins x 2 scales 33,410.00 0.0000 1.1000 0.0299 0.1000 0.1000 0.1000 0.2000 ±2.45% 16705
· 4 channels x 1000 bins x 2 scales 3,681.26 0.1000 0.9000 0.2716 0.3000 0.4000 0.4000 0.9000 ±1.11% 1841
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > HistogramRoot + HistogramBars — mount 1843ms
· 50 bars (linear) 1,078.00 0.6000 31.0000 0.9276 0.8000 5.1000 5.2000 31.0000 ±13.21% 539
· 500 bars (linear) 137.50 5.5000 40.9000 7.2725 6.3000 40.9000 40.9000 40.9000 ±14.68% 69
· 500 bars (log) 135.59 5.6000 49.4000 7.3750 6.3000 49.4000 49.4000 49.4000 ±17.87% 68
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > HistogramRoot + HistogramBars — update after prop change 1266ms
· 500 bars — scaleType linear → log 96.9721 8.8000 13.6000 10.3122 12.2000 13.6000 13.6000 13.6000 ±4.41% 49
· record data — channel l → rgb (expand to 3 primaries) 166.20 4.6000 47.2000 6.0167 5.0000 47.2000 47.2000 47.2000 ±17.36% 84
✓ |chromium| src/internal/utils/__test__/getRawChildren.bench.ts > getRawChildren 4228ms
· flat elements 6,294,584.19 0.0000 4.6000 0.0002 0.0000 0.0000 0.0000 0.1000 ±3.32% 3148551
· mixed elements and comments 1,001,372.00 0.0000 0.3000 0.0010 0.0000 0.0000 0.1000 0.1000 ±2.77% 500686
· single fragment with children 1,208,230.35 0.0000 0.3000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.77% 604236
· nested fragments (depth 5) 499,336.13 0.0000 0.3000 0.0020 0.0000 0.1000 0.1000 0.1000 ±2.75% 249718
· wide fragment (50 children) 86,290.74 0.0000 0.4000 0.0116 0.0000 0.1000 0.1000 0.1000 ±2.63% 43154
✓ |chromium| src/internal/utils/__test__/getRawChildren.bench.ts > getRawChildren — BAIL path 2383ms
· 1 keyed fragment (no BAIL) 1,520,578.00 0.0000 0.3000 0.0007 0.0000 0.0000 0.1000 0.1000 ±2.78% 760289
· 2 keyed fragments (BAIL triggered) 1,533,169.38 0.0000 0.4000 0.0007 0.0000 0.0000 0.1000 0.1000 ±2.79% 766738
· 3 keyed fragments (BAIL triggered) 1,175,990.00 0.0000 0.4000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.77% 587995
✓ |chromium| src/internal/utils/__test__/getRawChildren.bench.ts > patch — optimized vs BAIL patchFlag 2535ms
· patch with TEXT patchFlag 188,428.83 0.0000 3.6000 0.0053 0.0000 0.1000 0.1000 0.1000 ±5.49% 94384
· patch with BAIL patchFlag 186,314.00 0.0000 3.8000 0.0054 0.0000 0.1000 0.1000 0.1000 ±5.34% 93157
· patch with CLASS patchFlag 186,798.64 0.0000 5.4000 0.0054 0.0000 0.1000 0.1000 0.1000 ±5.87% 93418
· patch with CLASS→BAIL patchFlag 189,736.00 0.0000 3.9000 0.0053 0.0000 0.1000 0.1000 0.1000 ±5.56% 94868
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > baseline: raw h() 2736ms
· h() — 1 attr 2,030,339.94 0.0000 0.5000 0.0005 0.0000 0.0000 0.0000 0.1000 ±2.79% 1015373
· h() — 5 attrs 2,006,400.00 0.0000 4.1000 0.0005 0.0000 0.0000 0.0000 0.1000 ±3.20% 1003200
· h() — 15 attrs 1,971,350.00 0.0000 0.5000 0.0005 0.0000 0.0000 0.1000 0.1000 ±2.79% 985675
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > baseline: raw cloneVNode() 3321ms
· cloneVNode — 1 attr 4,866,745.99 0.0000 4.4000 0.0002 0.0000 0.0000 0.0000 0.1000 ±3.26% 2433373
· cloneVNode — 5 attrs 3,803,831.25 0.0000 0.4000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.78% 1902296
· cloneVNode — 15 attrs 2,318,270.00 0.0000 0.3000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1159135
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Primitive vs h() 2658ms
· h("div") — baseline 1,979,428.12 0.0000 5.8000 0.0005 0.0000 0.0000 0.0000 0.1000 ±3.58% 989912
· Primitive({ as: "div" }) 2,033,420.00 0.0000 0.4000 0.0005 0.0000 0.0000 0.0000 0.1000 ±2.78% 1016710
· Primitive({ as: "template" }) — Slot mode 2,260,389.99 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.78% 1130195
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Slot — scaling by attrs 2697ms
· 1 attr 2,604,471.11 0.0000 4.7000 0.0004 0.0000 0.0000 0.0000 0.1000 ±3.32% 1302496
· 5 attrs 2,245,925.99 0.0000 0.3000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1122963
· 15 attrs (mixed types) 1,642,142.00 0.0000 0.3000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.77% 821071
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Slot — edge cases 2331ms
· child with comments to skip 1,212,457.51 0.0000 0.3000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.77% 606350
· no default slot 7,219,866.03 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3610655
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Slot — fresh attrs per call 1768ms
· 5 attrs (stable ref) 2,232,887.43 0.0000 0.3000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.78% 1116667
· 5 attrs (new object) 2,244,653.07 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.79% 1122551
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Primitive — mount + update via render() 1852ms
· h("div") — mount + update 135,494.00 0.0000 6.2000 0.0074 0.0000 0.1000 0.1000 0.1000 ±6.78% 67747
· Primitive({ as: "div" }) — mount + update 39,210.00 0.0000 6.5000 0.0255 0.0000 0.1000 0.1000 0.2000 ±6.71% 19605
· Primitive({ as: "template" }) — mount + update 39,744.05 0.0000 19.9000 0.0252 0.0000 0.1000 0.1000 0.2000 ±10.03% 19876
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,465 @@
import type { VueWrapper } from '@vue/test-utils';
import type { CurveEditorAnchor, CurveEditorInterpolation } from '../index';
import { mount } from '@vue/test-utils';
import { bench, describe } from 'vitest';
import { defineComponent, h } from 'vue';
import { CurveEditorCurve, CurveEditorPoint, CurveEditorRoot } from '../index';
import {
anchorsToPoints,
buildEvaluator,
clampAnchorX,
clampAnchorY,
sortAnchors,
} from '../utils';
import {
buildBezierPath,
buildPolylinePath,
catmullRom,
evalCubicBezier,
linearInterpolate,
monotoneCubic,
sampleFnToPolyline,
toLUT,
} from '../../../internal/spline';
// ─── deterministic fixtures (NO Math.random — values seeded by index) ────────
//
// A monotone-in-x anchor set across the [0,1] domain. `x` is strictly
// increasing (i / (n - 1)); `y` is a deterministic gentle S-shape via a
// smoothstep-ish formula so every interpolation mode has real curvature to
// chew on (a straight line would shortcut the monotone/bezier solvers).
function makeAnchors(n: number): CurveEditorAnchor[] {
const out: CurveEditorAnchor[] = Array.from({ length: n });
for (let i = 0; i < n; i++) {
const x = n === 1 ? 0 : i / (n - 1);
// smoothstep S-curve, nudged per-index so segments are not all identical.
const s = x * x * (3 - 2 * x);
const y = Math.min(1, Math.max(0, s + 0.05 * Math.sin(i)));
out[i] = { id: `a${i}`, x, y };
}
return out;
}
// Same set, but carrying bezier tangents so the 'bezier' build/eval/path paths
// exercise the per-anchor handle branch (deltas relative to the anchor).
function makeAnchorsWithHandles(n: number): CurveEditorAnchor[] {
const base = makeAnchors(n);
const dx = n > 1 ? 1 / (n - 1) : 0;
for (let i = 0; i < n; i++) {
const a = base[i]!;
base[i] = {
...a,
inHandle: { x: -dx / 3, y: -0.03 },
outHandle: { x: dx / 3, y: 0.03 },
};
}
return base;
}
// Realistic editor scale (a tone/easing curve is typically 416 anchors) and a
// stress scale (a dense imported LUT / many keyframes).
const anchors16 = makeAnchors(16);
const anchors256 = makeAnchors(256);
const bezierAnchors16 = makeAnchorsWithHandles(16);
const bezierAnchors256 = makeAnchorsWithHandles(256);
const points16 = anchorsToPoints(anchors16);
const points256 = anchorsToPoints(anchors256);
// Shuffled (deterministically reversed-ish) copies to give `sortAnchors` real
// work instead of an already-sorted no-op.
function unsorted(src: readonly CurveEditorAnchor[]): CurveEditorAnchor[] {
const a = src.slice();
// deterministic interleave: pull from both ends.
const out: CurveEditorAnchor[] = [];
let lo = 0;
let hi = a.length - 1;
while (lo <= hi) {
out.push(a[hi]!);
if (lo !== hi) out.push(a[lo]!);
lo++;
hi--;
}
return out;
}
const unsorted16 = unsorted(anchors16);
const unsorted256 = unsorted(anchors256);
// Pre-built evaluators (for the sampling-only benches that should NOT pay the
// build cost on every iteration).
const evalLinear16 = buildEvaluator(anchors16, 'linear');
const evalMonotone16 = buildEvaluator(anchors16, 'monotone');
const evalCatmull16 = buildEvaluator(anchors16, 'catmull-rom');
const evalBezier16 = buildEvaluator(bezierAnchors16, 'bezier');
const evalMonotone256 = buildEvaluator(anchors256, 'monotone');
// A fixed grid of probe x's to sample the evaluator at (the render/LUT density).
function probeGrid(count: number): number[] {
const xs: number[] = Array.from({ length: count });
for (let i = 0; i < count; i++) xs[i] = i / (count - 1);
return xs;
}
const probes256 = probeGrid(256);
const probes1024 = probeGrid(1024);
const clampOpts = {
domainMin: 0,
domainMax: 1,
monotonicX: true,
fixedEndpoints: true,
minGap: 0.001,
};
// ─── 1. evaluator BUILD cost (per interpolation mode × scale) ────────────────
// `buildEvaluator` does the heavy precompute (monotone tangents, catmull-rom
// resample to a 256-pt table, bezier x-segment table). Rebuilt on every anchor
// edit (`evaluator = computed(() => buildEvaluator(...))`), so its build cost is
// on the edit hot path.
describe('buildEvaluator — build cost', () => {
bench('linear — 16 anchors', () => {
buildEvaluator(anchors16, 'linear');
});
bench('linear — 256 anchors', () => {
buildEvaluator(anchors256, 'linear');
});
bench('monotone — 16 anchors', () => {
buildEvaluator(anchors16, 'monotone');
});
bench('monotone — 256 anchors', () => {
buildEvaluator(anchors256, 'monotone');
});
bench('catmull-rom — 16 anchors', () => {
buildEvaluator(anchors16, 'catmull-rom');
});
bench('catmull-rom — 256 anchors', () => {
buildEvaluator(anchors256, 'catmull-rom');
});
bench('bezier — 16 anchors', () => {
buildEvaluator(bezierAnchors16, 'bezier');
});
bench('bezier — 256 anchors', () => {
buildEvaluator(bezierAnchors256, 'bezier');
});
});
// ─── 2. evaluator SAMPLING cost (pre-built closure × sample density) ─────────
// Calling `f(x)` densely is the render/LUT hot path. Each `f(x)` does a binary
// search + Hermite/linear eval (bezier additionally runs a Newton-Raphson
// per call). 256 = the editor's default sample density; 1024 = a fine LUT.
describe('evaluator sampling — 256 samples', () => {
bench('linear', () => {
for (let i = 0; i < probes256.length; i++) evalLinear16(probes256[i]!);
});
bench('monotone', () => {
for (let i = 0; i < probes256.length; i++) evalMonotone16(probes256[i]!);
});
bench('catmull-rom', () => {
for (let i = 0; i < probes256.length; i++) evalCatmull16(probes256[i]!);
});
bench('bezier (Newton-Raphson per call)', () => {
for (let i = 0; i < probes256.length; i++) evalBezier16(probes256[i]!);
});
});
describe('evaluator sampling — 1024 samples (stress)', () => {
bench('monotone — 16 anchors', () => {
for (let i = 0; i < probes1024.length; i++) evalMonotone16(probes1024[i]!);
});
bench('monotone — 256 anchors (deep binary search)', () => {
for (let i = 0; i < probes1024.length; i++) evalMonotone256(probes1024[i]!);
});
bench('bezier — 16 anchors', () => {
for (let i = 0; i < probes1024.length; i++) evalBezier16(probes1024[i]!);
});
});
// ─── 3. build + sample combined (the full per-edit cost, default density) ────
// Mirrors what `CurveEditorRoot` pays each anchor edit: rebuild the evaluator,
// then re-sample the whole curve for the rendered polyline.
describe('build + sample 256 (full per-edit, 16 anchors)', () => {
bench('monotone', () => {
const f = buildEvaluator(anchors16, 'monotone');
for (let i = 0; i < probes256.length; i++) f(probes256[i]!);
});
bench('catmull-rom', () => {
const f = buildEvaluator(anchors16, 'catmull-rom');
for (let i = 0; i < probes256.length; i++) f(probes256[i]!);
});
bench('bezier', () => {
const f = buildEvaluator(bezierAnchors16, 'bezier');
for (let i = 0; i < probes256.length; i++) f(probes256[i]!);
});
});
// ─── 4. toLUT — pixel-application table (the consumer's apply hot path) ───────
// `CurveEditorRoot.toLUT()` → `splineToLUT(evaluator, size, x0, x1)`. The 256
// table backs an 8-bit channel; 1024 a higher-precision apply.
describe('toLUT — spline lookup table', () => {
bench('monotone — 256 entries', () => {
toLUT(evalMonotone16, 256, 0, 1);
});
bench('monotone — 1024 entries', () => {
toLUT(evalMonotone16, 1024, 0, 1);
});
bench('bezier — 256 entries', () => {
toLUT(evalBezier16, 256, 0, 1);
});
});
// ─── 5. CurveEditorCurve path build (the SVG `d` hot path) ────────────────────
// The rendered curve recomputes its `d` whenever anchors/interpolation/scale
// change. Sampled modes: `sampleFnToPolyline` → project → `buildPolylinePath`.
// Bezier mode: chain `buildBezierPath` per segment.
const pxScale = 320; // a typical plot box in px.
function project(p: { x: number; y: number }): { x: number; y: number } {
// value→pixel like CurveEditorPoint/Curve (y value-up): cheap linear map.
return { x: p.x * pxScale, y: (1 - p.y) * pxScale };
}
describe('curve path `d` build — sampled polyline (256 samples)', () => {
bench('monotone — sample + project + buildPolylinePath', () => {
const samples = sampleFnToPolyline(x => evalMonotone16(x), 0, 1, 256);
for (let i = 0; i < samples.length; i++) samples[i] = project(samples[i]!);
buildPolylinePath(samples);
});
bench('catmull-rom — sample + project + buildPolylinePath', () => {
const samples = sampleFnToPolyline(x => evalCatmull16(x), 0, 1, 256);
for (let i = 0; i < samples.length; i++) samples[i] = project(samples[i]!);
buildPolylinePath(samples);
});
});
describe('curve path `d` build — bezier segment chain', () => {
function buildBezierD(list: readonly CurveEditorAnchor[]): string {
let d = '';
for (let i = 0; i < list.length - 1; i++) {
const a = list[i]!;
const b = list[i + 1]!;
const dx = b.x - a.x;
const c1x = a.outHandle ? a.x + a.outHandle.x : a.x + dx / 3;
const c1y = a.outHandle ? a.y + a.outHandle.y : a.y + (b.y - a.y) / 3;
const c2x = b.inHandle ? b.x + b.inHandle.x : b.x - dx / 3;
const c2y = b.inHandle ? b.y + b.inHandle.y : b.y - (b.y - a.y) / 3;
const seg = buildBezierPath(
project({ x: a.x, y: a.y }),
project({ x: c1x, y: c1y }),
project({ x: c2x, y: c2y }),
project({ x: b.x, y: b.y }),
);
d += i === 0 ? seg : seg.replace(/^M[^C]*/, '');
}
return d;
}
bench('16 anchors (15 segments)', () => {
buildBezierD(bezierAnchors16);
});
bench('256 anchors (255 segments)', () => {
buildBezierD(bezierAnchors256);
});
});
// ─── 6. raw spline primitives (sub-operation baselines) ──────────────────────
describe('spline primitives — per-call baselines', () => {
bench('linearInterpolate — 256-pt table lookup', () => {
linearInterpolate(points256, 0.4321);
});
bench('catmullRom — 16-pt parametric eval', () => {
catmullRom(points16, 0.4321);
});
bench('evalCubicBezier — single cubic eval', () => {
evalCubicBezier(
{ x: 0, y: 0 },
{ x: 0.33, y: 0.1 },
{ x: 0.66, y: 0.9 },
{ x: 1, y: 1 },
0.4321,
);
});
bench('monotoneCubic — build closure (16 pts)', () => {
monotoneCubic(points16);
});
});
// ─── 7. anchor housekeeping (sort / project) at scale ────────────────────────
describe('anchor housekeeping', () => {
bench('sortAnchors — 16 (unsorted)', () => {
sortAnchors(unsorted16);
});
bench('sortAnchors — 256 (unsorted)', () => {
sortAnchors(unsorted256);
});
bench('anchorsToPoints — 16', () => {
anchorsToPoints(anchors16);
});
bench('anchorsToPoints — 256', () => {
anchorsToPoints(anchors256);
});
});
// ─── 8. pointer-move drag math (the pointermove hot path) ─────────────────────
// `CurveEditorPoint.onMove` → `clampAnchorX` + `clampAnchorY`. Pure clamp math,
// fired once per pointermove. Bench the clamp alone, and a simulated full
// `updateAnchor` step (clamp + slice-replace, the per-frame array churn).
describe('pointer-move clamp math', () => {
bench('clampAnchorX — interior anchor (neighbour clamp), 16', () => {
// index 8 has both neighbours → the monotonic-x branch does real work.
clampAnchorX(anchors16, 8, 0.5321, clampOpts);
});
bench('clampAnchorX — interior anchor (neighbour clamp), 256', () => {
clampAnchorX(anchors256, 128, 0.5321, clampOpts);
});
bench('clampAnchorY — domain clamp', () => {
clampAnchorY(1.2345, 0, 1);
});
bench('simulated updateAnchor step — clamp + slice-replace, 16', () => {
const list = anchors16;
const index = 8;
const cur = list[index]!;
const x = clampAnchorX(list, index, 0.5321, clampOpts);
const y = clampAnchorY(0.4567, 0, 1);
const candidate = list.slice();
candidate[index] = { ...cur, x, y };
});
bench('simulated updateAnchor step — clamp + slice-replace, 256', () => {
const list = anchors256;
const index = 128;
const cur = list[index]!;
const x = clampAnchorX(list, index, 0.5321, clampOpts);
const y = clampAnchorY(0.4567, 0, 1);
const candidate = list.slice();
candidate[index] = { ...cur, x, y };
});
});
// A full simulated drag stroke: 60 pointermove frames each rebuilding the
// evaluator + re-sampling (what one second of dragging at 60fps costs).
describe('simulated drag stroke (60 frames, monotone, 16 anchors)', () => {
bench('clamp + rebuild + sample-256 per frame', () => {
let list = anchors16;
for (let frame = 0; frame < 60; frame++) {
const index = 8;
const cur = list[index]!;
// deterministic sweep across the frame index (no Math.random).
const nx = clampAnchorX(list, index, 0.3 + (frame / 60) * 0.4, clampOpts);
const ny = clampAnchorY(0.2 + (frame / 60) * 0.6, 0, 1);
const candidate = list.slice();
candidate[index] = { ...cur, x: nx, y: ny };
list = candidate;
const f = buildEvaluator(list, 'monotone');
for (let i = 0; i < probes256.length; i++) f(probes256[i]!);
}
});
});
// ─── 9. component mount — Root + N Points + Curve (realistic & stress) ────────
const wrappers: Array<VueWrapper<any>> = [];
function teardown(): void {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
}
function makeHarness(
data: CurveEditorAnchor[],
interpolation: CurveEditorInterpolation,
) {
return defineComponent({
setup: () => () => h(
CurveEditorRoot,
{ defaultValue: data, interpolation },
{
default: ({ anchors }: { anchors: CurveEditorAnchor[] }) => [
h(CurveEditorCurve),
...anchors.map(a => h(CurveEditorPoint, { key: a.id, anchor: a })),
],
},
),
});
}
const Harness50Monotone = makeHarness(makeAnchors(50), 'monotone');
const Harness500Monotone = makeHarness(makeAnchors(500), 'monotone');
const Harness50Bezier = makeHarness(makeAnchorsWithHandles(50), 'bezier');
describe('mount — Root + Curve + N Points', () => {
bench('50 points (monotone)', () => {
const w = mount(Harness50Monotone, { attachTo: document.body });
wrappers.push(w);
teardown();
});
bench('500 points (monotone, stress)', () => {
const w = mount(Harness500Monotone, { attachTo: document.body });
wrappers.push(w);
teardown();
});
bench('50 points (bezier path)', () => {
const w = mount(Harness50Bezier, { attachTo: document.body });
wrappers.push(w);
teardown();
});
});
// ─── 10. re-render / update after a prop change ──────────────────────────────
// Switching interpolation and replacing the model both invalidate the evaluator
// + every point's projection + the curve `d`. Bench an in-place update via
// setProps (the realistic "user toggled mode / committed an edit" path).
const dataA = makeAnchors(50);
const dataB = makeAnchors(50).map((a, i) => ({ ...a, y: Math.min(1, a.y + 0.1 * Math.cos(i)) }));
function makeControlledHarness() {
return defineComponent({
props: {
modelValue: { type: Array as () => CurveEditorAnchor[], required: true },
interpolation: { type: String as () => CurveEditorInterpolation, default: 'monotone' },
},
setup: props => () => h(
CurveEditorRoot,
{ modelValue: props.modelValue, interpolation: props.interpolation },
{
default: ({ anchors }: { anchors: CurveEditorAnchor[] }) => [
h(CurveEditorCurve),
...anchors.map(a => h(CurveEditorPoint, { key: a.id, anchor: a })),
],
},
),
});
}
const ControlledHarness = makeControlledHarness();
describe('update after prop change (50 points)', () => {
bench('switch interpolation monotone→bezier→monotone', async () => {
const w = mount(ControlledHarness, {
attachTo: document.body,
props: { modelValue: dataA, interpolation: 'monotone' },
});
wrappers.push(w);
await w.setProps({ interpolation: 'bezier' });
await w.setProps({ interpolation: 'monotone' });
teardown();
});
bench('replace model array (commit an edit)', async () => {
const w = mount(ControlledHarness, {
attachTo: document.body,
props: { modelValue: dataA, interpolation: 'monotone' },
});
wrappers.push(w);
await w.setProps({ modelValue: dataB });
await w.setProps({ modelValue: dataA });
teardown();
});
});
@@ -0,0 +1,458 @@
import { bench, describe } from 'vitest';
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import {
FlowRoot,
addEdge,
applyEdgeChanges,
applyNodeChanges,
findClosestHandle,
fitViewTransform,
flowToScreen,
getBezierPath,
getNodePositionAbsolute,
getNodesBounds,
getNodesInsideRect,
getSmoothStepPath,
getStepPath,
getStraightPath,
getVisibleEdgeIds,
getVisibleNodeIds,
screenToFlow,
snapPoint,
visibleFlowRect,
zoomAtPointer,
} from '../index';
import type {
EdgeChange,
FlowEdge,
FlowNode,
HandleBound,
HandleBounds,
InternalNode,
NodeChange,
Position,
Rect,
Viewport,
XYPosition,
} from '../index';
// ── Deterministic fixtures (no Math.random; seeded by index/formula) ──────────
//
// A flow graph laid out on a grid. Node `i` sits at a spread-out position so the
// bounds/cull/spatial math sees a realistic non-degenerate spread, and edges chain
// node `i → i+1` plus a few cross-links so the visible-edge fan-out is non-trivial.
const SIDES: Position[] = ['top', 'right', 'bottom', 'left'];
const NODE_W = 160;
const NODE_H = 56;
function seededNode(i: number): FlowNode<{ label: string }> {
// Deterministic 2D layout: 40 columns, rows below, with a per-index jitter.
const col = i % 40;
const row = Math.floor(i / 40);
return {
id: `n${i}`,
type: 'process',
position: { x: col * 240 + (i % 7) * 13, y: row * 180 + (i % 5) * 11 },
data: { label: `Step ${i}` },
};
}
function seededInternalNode(i: number): InternalNode<{ label: string }> {
const base = seededNode(i);
const handleBounds: HandleBounds = {
target: [{ id: null, type: 'target', position: 'left', x: 0, y: NODE_H / 2, width: 10, height: 10 }],
source: [{ id: null, type: 'source', position: 'right', x: NODE_W, y: NODE_H / 2, width: 10, height: 10 }],
};
return {
...base,
measured: { width: NODE_W, height: NODE_H },
positionAbsolute: { ...base.position },
handleBounds,
};
}
function seededEdge(i: number): FlowEdge {
// Chain plus a deterministic cross-link every 3rd edge.
const source = `n${i}`;
const target = i % 3 === 0 ? `n${(i + 7) % 1000}` : `n${i + 1}`;
return {
id: `e${i}`,
source,
target,
type: i % 2 === 0 ? 'smoothstep' : 'bezier',
animated: i % 5 === 0,
};
}
function buildNodes(count: number): Array<FlowNode<{ label: string }>> {
const out: Array<FlowNode<{ label: string }>> = [];
for (let i = 0; i < count; i++) out.push(seededNode(i));
return out;
}
function buildInternalNodes(count: number): Array<InternalNode<{ label: string }>> {
const out: Array<InternalNode<{ label: string }>> = [];
for (let i = 0; i < count; i++) out.push(seededInternalNode(i));
return out;
}
function buildLookup(internals: InternalNode[]): Map<string, InternalNode> {
const map = new Map<string, InternalNode>();
for (const n of internals) map.set(n.id, n);
return map;
}
function buildEdges(count: number): FlowEdge[] {
const out: FlowEdge[] = [];
for (let i = 0; i < count; i++) out.push(seededEdge(i));
return out;
}
// Endpoint pairs feeding the path builders — varied handle sides so the
// smooth-step branch/corner logic is exercised, not just the fast collinear case.
interface EndpointPair {
sourceX: number;
sourceY: number;
sourcePosition: Position;
targetX: number;
targetY: number;
targetPosition: Position;
}
function buildEndpoints(count: number): EndpointPair[] {
const out: EndpointPair[] = [];
for (let i = 0; i < count; i++) {
out.push({
sourceX: (i % 13) * 40,
sourceY: (i % 17) * 30,
sourcePosition: SIDES[i % 4]!,
targetX: 400 + (i % 11) * 50,
targetY: 200 + (i % 7) * 45,
targetPosition: SIDES[(i + 2) % 4]!,
});
}
return out;
}
const VP: Viewport = { x: 120, y: -80, zoom: 1.5 };
const ORIGIN = { left: 24, top: 16 };
const CONTAINER = { width: 1280, height: 720 };
// Pointer-move samples (client-space pixels) for screenToFlow/zoom hot paths.
function buildPointers(count: number): XYPosition[] {
const out: XYPosition[] = [];
for (let i = 0; i < count; i++)
out.push({ x: (i * 37) % CONTAINER.width, y: (i * 53) % CONTAINER.height });
return out;
}
// Pre-built fixture sets at realistic (100) and stress (1000) scale.
const NODES_100 = buildNodes(100);
const NODES_1000 = buildNodes(1000);
const INTERNAL_100 = buildInternalNodes(100);
const INTERNAL_1000 = buildInternalNodes(1000);
const LOOKUP_100 = buildLookup(INTERNAL_100);
const LOOKUP_1000 = buildLookup(INTERNAL_1000);
const EDGES_100 = buildEdges(100);
const EDGES_1000 = buildEdges(1000);
const ENDPOINTS_100 = buildEndpoints(100);
const ENDPOINTS_1000 = buildEndpoints(1000);
const POINTERS_100 = buildPointers(100);
const POINTERS_1000 = buildPointers(1000);
// A `parentId` chain so getNodePositionAbsolute walks ancestors (subflow cost).
const CHAIN_LOOKUP = new Map<string, InternalNode>();
(() => {
for (let i = 0; i < 64; i++) {
const n = seededInternalNode(i);
if (i > 0) n.parentId = `n${i - 1}`;
CHAIN_LOOKUP.set(n.id, n);
}
})();
const CHAIN_LEAF = CHAIN_LOOKUP.get('n63')!;
const VISIBLE_RECT: Rect = visibleFlowRect(VP, CONTAINER, 200);
const VISIBLE_NODE_SET_100 = new Set(getVisibleNodeIds(NODES_100, LOOKUP_100, VISIBLE_RECT));
const VISIBLE_NODE_SET_1000 = new Set(getVisibleNodeIds(NODES_1000, LOOKUP_1000, VISIBLE_RECT));
// Marquee rect covering the upper-left quadrant of the laid-out graph.
const MARQUEE: Rect = { x: 0, y: 0, width: 4000, height: 1500 };
// Closest-handle drag origin: drag from n0's source toward a moving pointer.
const DRAG_FROM_HANDLE: HandleBound = INTERNAL_1000[0]!.handleBounds!.source[0]!;
// ── Edge-path math (per-edge, runs for every visible edge every transform) ────
describe('edge-paths — straight', () => {
bench('100 edges', () => {
for (let i = 0; i < ENDPOINTS_100.length; i++) getStraightPath(ENDPOINTS_100[i]!);
});
bench('1000 edges', () => {
for (let i = 0; i < ENDPOINTS_1000.length; i++) getStraightPath(ENDPOINTS_1000[i]!);
});
});
describe('edge-paths — bezier', () => {
bench('100 edges', () => {
for (let i = 0; i < ENDPOINTS_100.length; i++) getBezierPath(ENDPOINTS_100[i]!);
});
bench('1000 edges', () => {
for (let i = 0; i < ENDPOINTS_1000.length; i++) getBezierPath(ENDPOINTS_1000[i]!);
});
});
describe('edge-paths — smoothstep (corner builder)', () => {
bench('100 edges', () => {
for (let i = 0; i < ENDPOINTS_100.length; i++) getSmoothStepPath(ENDPOINTS_100[i]!);
});
bench('1000 edges', () => {
for (let i = 0; i < ENDPOINTS_1000.length; i++) getSmoothStepPath(ENDPOINTS_1000[i]!);
});
});
describe('edge-paths — step (zero-radius smoothstep)', () => {
bench('100 edges', () => {
for (let i = 0; i < ENDPOINTS_100.length; i++) getStepPath(ENDPOINTS_100[i]!);
});
bench('1000 edges', () => {
for (let i = 0; i < ENDPOINTS_1000.length; i++) getStepPath(ENDPOINTS_1000[i]!);
});
});
// ── Pointer / viewport transform math (runs on every pointermove & wheel) ─────
describe('pointer math — screenToFlow', () => {
bench('100 moves', () => {
for (let i = 0; i < POINTERS_100.length; i++) screenToFlow(POINTERS_100[i]!, VP, ORIGIN);
});
bench('1000 moves', () => {
for (let i = 0; i < POINTERS_1000.length; i++) screenToFlow(POINTERS_1000[i]!, VP, ORIGIN);
});
});
describe('pointer math — flowToScreen', () => {
bench('100 points', () => {
for (let i = 0; i < POINTERS_100.length; i++) flowToScreen(POINTERS_100[i]!, VP, ORIGIN);
});
bench('1000 points', () => {
for (let i = 0; i < POINTERS_1000.length; i++) flowToScreen(POINTERS_1000[i]!, VP, ORIGIN);
});
});
describe('pointer math — zoomAtPointer (wheel zoom)', () => {
bench('100 wheel steps', () => {
for (let i = 0; i < POINTERS_100.length; i++)
zoomAtPointer(VP, POINTERS_100[i]!, 1 + (i % 20) / 10);
});
bench('1000 wheel steps', () => {
for (let i = 0; i < POINTERS_1000.length; i++)
zoomAtPointer(VP, POINTERS_1000[i]!, 1 + (i % 20) / 10);
});
});
describe('pointer math — snapPoint (drag with snap-to-grid)', () => {
const grid: [number, number] = [16, 16];
bench('100 moves', () => {
for (let i = 0; i < POINTERS_100.length; i++) snapPoint(POINTERS_100[i]!, grid);
});
bench('1000 moves', () => {
for (let i = 0; i < POINTERS_1000.length; i++) snapPoint(POINTERS_1000[i]!, grid);
});
});
// ── Bounds / fit-view (runs on fitView and minimap recompute) ─────────────────
describe('getNodesBounds', () => {
bench('100 nodes', () => {
getNodesBounds(INTERNAL_100);
});
bench('1000 nodes', () => {
getNodesBounds(INTERNAL_1000);
});
});
describe('fitViewTransform (bounds + fit)', () => {
const opts = { padding: 0.1, minZoom: 0.2, maxZoom: 2.5 };
bench('100 nodes', () => {
fitViewTransform(getNodesBounds(INTERNAL_100), CONTAINER, opts);
});
bench('1000 nodes', () => {
fitViewTransform(getNodesBounds(INTERNAL_1000), CONTAINER, opts);
});
});
// ── Subflow absolute position (parentId chain walk) ───────────────────────────
describe('getNodePositionAbsolute — parent chain (depth 64)', () => {
bench('single leaf walk', () => {
getNodePositionAbsolute(CHAIN_LEAF, CHAIN_LOOKUP);
});
bench('64 nodes (all walked)', () => {
for (const n of CHAIN_LOOKUP.values()) getNodePositionAbsolute(n, CHAIN_LOOKUP);
});
});
// ── Virtualization / spatial culling (runs every viewport pan/zoom) ──────────
describe('visibleFlowRect + getVisibleNodeIds (node cull)', () => {
bench('100 nodes', () => {
const rect = visibleFlowRect(VP, CONTAINER, 200);
getVisibleNodeIds(NODES_100, LOOKUP_100, rect);
});
bench('1000 nodes', () => {
const rect = visibleFlowRect(VP, CONTAINER, 200);
getVisibleNodeIds(NODES_1000, LOOKUP_1000, rect);
});
});
describe('getVisibleEdgeIds (edge cull by visible node set)', () => {
bench('100 edges', () => {
getVisibleEdgeIds(EDGES_100, VISIBLE_NODE_SET_100);
});
bench('1000 edges', () => {
getVisibleEdgeIds(EDGES_1000, VISIBLE_NODE_SET_1000);
});
});
describe('getNodesInsideRect (marquee selection)', () => {
bench('100 nodes', () => {
getNodesInsideRect(INTERNAL_100, MARQUEE, 'partial');
});
bench('1000 nodes', () => {
getNodesInsideRect(INTERNAL_1000, MARQUEE, 'partial');
});
});
// ── Connection snapping (runs every pointermove during a connect drag) ────────
describe('findClosestHandle (connect-drag snapping)', () => {
bench('100 nodes', () => {
for (let i = 0; i < POINTERS_100.length; i++)
findClosestHandle(POINTERS_100[i]!, LOOKUP_100, 'source', 'n0', null, 'strict', 40);
});
bench('1000 nodes', () => {
for (let i = 0; i < POINTERS_100.length; i++)
findClosestHandle(POINTERS_100[i]!, LOOKUP_1000, 'source', 'n0', null, 'strict', 40);
});
});
void DRAG_FROM_HANDLE; // referenced for fixture parity; snapping uses lookup handles
// ── Controlled-mode change application (the @nodes-change / @edges-change path) ──
describe('applyNodeChanges (drag → position changes)', () => {
// A whole-graph position update, as emitted while dragging a multi-selection.
const changes100: NodeChange[] = NODES_100.map((n, i) => ({
type: 'position',
id: n.id,
position: { x: n.position.x + i, y: n.position.y + i },
}));
const changes1000: NodeChange[] = NODES_1000.map((n, i) => ({
type: 'position',
id: n.id,
position: { x: n.position.x + i, y: n.position.y + i },
}));
bench('100 position changes', () => {
applyNodeChanges(changes100, NODES_100);
});
bench('1000 position changes', () => {
applyNodeChanges(changes1000, NODES_1000);
});
});
describe('applyEdgeChanges (select changes)', () => {
const changes100: EdgeChange[] = EDGES_100.map(e => ({ type: 'select', id: e.id, selected: true }));
const changes1000: EdgeChange[] = EDGES_1000.map(e => ({ type: 'select', id: e.id, selected: true }));
bench('100 select changes', () => {
applyEdgeChanges(changes100, EDGES_100);
});
bench('1000 select changes', () => {
applyEdgeChanges(changes1000, EDGES_1000);
});
});
describe('addEdge (dedupe scan on connect)', () => {
bench('append into 100 edges', () => {
addEdge({ source: 'n5', target: 'n90', sourceHandle: null, targetHandle: null }, EDGES_100);
});
bench('append into 1000 edges', () => {
addEdge({ source: 'n5', target: 'n900', sourceHandle: null, targetHandle: null }, EDGES_1000);
});
});
// ── Component: FlowRoot mount + re-render (the realistic end-to-end path) ──────
const nodeSlot = (p: { data?: { label?: string } }) => h('div', { class: 'node-body' }, p.data?.label ?? '');
function mountFlow(nodes: FlowNode[], edges: FlowEdge[]) {
return mount(FlowRoot, {
attachTo: document.body,
props: { defaultNodes: nodes, defaultEdges: edges },
slots: { 'node-default': nodeSlot },
});
}
const NODES_50 = buildNodes(50);
const EDGES_50 = buildEdges(50);
const NODES_500 = buildNodes(500);
const EDGES_500 = buildEdges(500);
describe('FlowRoot — mount + unmount', () => {
bench('50 nodes / 50 edges', () => {
const w = mountFlow(NODES_50, EDGES_50);
w.unmount();
});
bench('500 nodes / 500 edges', () => {
const w = mountFlow(NODES_500, EDGES_500);
w.unmount();
});
});
describe('FlowRoot — re-render after prop change (viewport pan)', () => {
bench('50 nodes — viewport setProps', async () => {
const w = mount(FlowRoot, {
attachTo: document.body,
props: { defaultNodes: NODES_50, defaultEdges: EDGES_50, viewport: { x: 0, y: 0, zoom: 1 } },
slots: { 'node-default': nodeSlot },
});
await w.setProps({ viewport: { x: 120, y: -60, zoom: 1.4 } });
w.unmount();
});
bench('500 nodes — nodes setProps (controlled replace)', async () => {
const w = mount(FlowRoot, {
attachTo: document.body,
props: { nodes: NODES_500, edges: EDGES_500 },
slots: { 'node-default': nodeSlot },
});
// Move the first node — a single fresh object, rest keep identity.
const next = [{ ...NODES_500[0]!, position: { x: 999, y: 999 } }, ...NODES_500.slice(1)];
await w.setProps({ nodes: next });
w.unmount();
});
});
@@ -0,0 +1,238 @@
import { mount } from '@vue/test-utils';
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { HistogramBars, HistogramRoot } from '../index';
import type { HistogramBarChannel, HistogramData, HistogramScaleType } from '../utils';
import {
getChannelBins,
histogramMax,
projectBarHeight,
projectBars,
} from '../utils';
// ---------------------------------------------------------------------------
// Deterministic fixtures (NO Math.random). Bins are seeded by index/formula so
// every run measures identical work. Each builder produces a bell-ish tonal
// curve plus a highlight bump, the shape a real image histogram carries — tall
// central spike, long zero tails — which exercises both the linear and log
// projection branches and the all-zero guards.
// ---------------------------------------------------------------------------
/** A bell bump centred on `center` bins, scaled to `peak`, sampled over `n` bins. */
function bell(n: number, center: number, spread: number, peak: number): number[] {
const out: number[] = Array.from({ length: n });
for (let i = 0; i < n; i++) {
const d = (i - center) / spread;
out[i] = Math.round(peak * Math.exp(-0.5 * d * d));
}
return out;
}
/** Realistic single-channel bins of length `n` (midtone hump + highlight bump). */
function makeBins(n: number): number[] {
const a = bell(n, n * 0.44, n * 0.14, 1000);
const b = bell(n, n * 0.78, n * 0.08, 280);
const out: number[] = Array.from({ length: n });
for (let i = 0; i < n; i++) out[i] = a[i]! + b[i]!;
return out;
}
/** A per-channel record (r/g/b/l) of length `n`, each primary peaking elsewhere. */
function makeChannelData(n: number): HistogramData {
return {
l: makeBins(n),
r: bell(n, n * 0.53, n * 0.16, 760),
g: bell(n, n * 0.41, n * 0.12, 940),
b: bell(n, n * 0.31, n * 0.18, 620),
};
}
// 256 is the canonical bin count (8-bit channel); 100 / 1000 bracket realistic
// and stress scales. 50 / 500 bracket the rendered bar count for components.
const bins100 = makeBins(100);
const bins256 = makeBins(256);
const bins1000 = makeBins(1000);
const binsZero256 = Array.from<number>({ length: 256 }).fill(0);
const record100 = makeChannelData(100);
const record1000 = makeChannelData(1000);
const SCALES: HistogramScaleType[] = ['linear', 'log'];
const PRIMARIES: HistogramBarChannel[] = ['l', 'r', 'g', 'b'];
const bars50 = makeBins(50);
const bars500 = makeBins(500);
// Stable harnesses so the component benches measure render cost, not the cost
// of redefining a component each iteration.
const RootBarsHarness = defineComponent({
props: {
data: { type: [Array, Object] as unknown as () => HistogramData, required: true },
channel: { type: String, default: 'l' },
scaleType: { type: String, default: 'linear' },
},
setup(props) {
return () =>
h(
HistogramRoot,
{ data: props.data, channel: props.channel, scaleType: props.scaleType },
{ default: () => h(HistogramBars) },
);
},
});
// ---------------------------------------------------------------------------
// Pure projection math — the per-channel hot path the root runs on every data
// or prop change (peak scan → normalise every bin → fresh packed array).
// ---------------------------------------------------------------------------
describe('histogramMax — peak scan', () => {
bench('100 bins', () => {
histogramMax(bins100);
});
bench('256 bins', () => {
histogramMax(bins256);
});
bench('1000 bins', () => {
histogramMax(bins1000);
});
bench('256 bins — all zero (guard path)', () => {
histogramMax(binsZero256);
});
});
describe('projectBars — linear (peak scan + normalise + alloc)', () => {
bench('100 bins', () => {
projectBars(bins100, 'linear');
});
bench('256 bins', () => {
projectBars(bins256, 'linear');
});
bench('1000 bins', () => {
projectBars(bins1000, 'linear');
});
});
describe('projectBars — log (log1p per bin + alloc)', () => {
bench('100 bins', () => {
projectBars(bins100, 'log');
});
bench('256 bins', () => {
projectBars(bins256, 'log');
});
bench('1000 bins', () => {
projectBars(bins1000, 'log');
});
});
describe('projectBars — all-zero guard (no NaN, no divide)', () => {
bench('256 bins — linear', () => {
projectBars(binsZero256, 'linear');
});
bench('256 bins — log', () => {
projectBars(binsZero256, 'log');
});
});
describe('projectBarHeight — per-bin scalar (1000x loop)', () => {
const max = histogramMax(bins1000);
bench('linear x1000', () => {
for (let i = 0; i < bins1000.length; i++) projectBarHeight(bins1000[i]!, max, 'linear');
});
bench('log x1000', () => {
for (let i = 0; i < bins1000.length; i++) projectBarHeight(bins1000[i]!, max, 'log');
});
});
// ---------------------------------------------------------------------------
// Full root projection across every primary + both scales — the work the root's
// `bars()`/`hasData` perform when re-deriving an RGB composite. getChannelBins
// resolves the record per channel; projectBars then scans + packs each.
// ---------------------------------------------------------------------------
describe('per-channel projection (RGB composite, record data)', () => {
bench('4 channels x 100 bins x 2 scales', () => {
for (const scale of SCALES) {
for (const ch of PRIMARIES) {
projectBars(getChannelBins(record100, ch, 'l'), scale);
}
}
});
bench('4 channels x 1000 bins x 2 scales', () => {
for (const scale of SCALES) {
for (const ch of PRIMARIES) {
projectBars(getChannelBins(record1000, ch, 'l'), scale);
}
}
});
});
// ---------------------------------------------------------------------------
// Component mount — full Root → Bars → per-bar Primitive tree. Each bar is its
// own DOM node, so cost scales with the rendered bar count.
// ---------------------------------------------------------------------------
describe('HistogramRoot + HistogramBars — mount', () => {
bench('50 bars (linear)', () => {
const w = mount(RootBarsHarness, {
props: { data: bars50, channel: 'l', scaleType: 'linear' },
attachTo: document.body,
});
w.unmount();
});
bench('500 bars (linear)', () => {
const w = mount(RootBarsHarness, {
props: { data: bars500, channel: 'l', scaleType: 'linear' },
attachTo: document.body,
});
w.unmount();
});
bench('500 bars (log)', () => {
const w = mount(RootBarsHarness, {
props: { data: bars500, channel: 'l', scaleType: 'log' },
attachTo: document.body,
});
w.unmount();
});
});
// ---------------------------------------------------------------------------
// Re-render after a prop change — the realistic interaction (the demo toggles
// channel and scaleType). Re-projects + patches the existing bar tree in place.
// ---------------------------------------------------------------------------
describe('HistogramRoot + HistogramBars — update after prop change', () => {
bench('500 bars — scaleType linear → log', async () => {
const w = mount(RootBarsHarness, {
props: { data: bars500, channel: 'l', scaleType: 'linear' },
attachTo: document.body,
});
await w.setProps({ scaleType: 'log' });
await nextTick();
w.unmount();
});
bench('record data — channel l → rgb (expand to 3 primaries)', async () => {
const w = mount(RootBarsHarness, {
props: { data: record100, channel: 'l', scaleType: 'linear' },
attachTo: document.body,
});
await w.setProps({ channel: 'rgb' });
await nextTick();
w.unmount();
});
});
@@ -0,0 +1,279 @@
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick, render } from 'vue';
import { solveBezierX } from '../../../internal/spline';
import {
KeyframeTrackKeyframe,
KeyframeTrackRoot,
KeyframeTrackSegment,
clampKeyframeTime,
defaultKeyframeValueText,
sampleKeyframes,
snapTimeToFrame,
sortKeyframes,
} from '../index';
import type { KeyframeTrackKeyframeData } from '../index';
// ─────────────────────────────────────────────────────────────────────────────
// Fixtures — deterministic, built once at module scope (NO Math.random).
//
// `time` ramps monotonically (already sorted ascending) so the realistic scales
// hit the binary-search / neighbour-clamp paths the way the live sampler does;
// `value` and `easing` are seeded by index so the spline solve takes a non-trivial
// (non-identity) curve roughly half the time.
// ─────────────────────────────────────────────────────────────────────────────
const EASINGS: Array<[number, number, number, number]> = [
[0, 0, 1, 1], // linear (identity fast-path in solveBezierX)
[0, 0, 0.2, 1], // ease-out
[0.5, 0, 1, 1], // ease-in
[0.65, 0, 0.35, 1], // ease-in-out
];
/** Build `n` sorted keyframes spanning [0, n/10] seconds with cycling easings. */
function makeKeyframes(n: number): KeyframeTrackKeyframeData[] {
const out: KeyframeTrackKeyframeData[] = new Array(n);
for (let i = 0; i < n; i++) {
out[i] = {
id: `k${i}`,
time: i / 10, // 0.1s apart → strictly ascending, already sorted
value: (i % 11) / 10, // 0.0 .. 1.0, deterministic sawtooth
// Every 4th keyframe is un-eased (undefined → DEFAULT_KEYFRAME_EASING);
// the rest cycle through real curves to exercise the Newton-Raphson solve.
easing: i % 4 === 0 ? undefined : EASINGS[i % EASINGS.length],
};
}
return out;
}
/** Build `n` keyframes in REVERSE time order to force a real sort (worst case for sortKeyframes). */
function makeUnsortedKeyframes(n: number): KeyframeTrackKeyframeData[] {
const out: KeyframeTrackKeyframeData[] = new Array(n);
for (let i = 0; i < n; i++) {
const j = n - 1 - i;
out[i] = { id: `k${j}`, time: j / 10, value: (j % 11) / 10 };
}
return out;
}
const KF_100 = makeKeyframes(100);
const KF_1000 = makeKeyframes(1000);
const KF_100_UNSORTED = makeUnsortedKeyframes(100);
const KF_1000_UNSORTED = makeUnsortedKeyframes(1000);
const VALUE_RANGE: readonly [number, number] = [0, 1];
const FPS = 30;
// Pre-computed sample times spanning the full keyframe range, so each pass over
// a curve exercises the bracketing binary search end-to-end (the readout in the
// demo samples ~120 points per animation frame). Seeded by index, not random.
function makeSampleTimes(count: number, span: number): number[] {
const out: number[] = new Array(count);
for (let i = 0; i < count; i++) out[i] = (i / (count - 1)) * span;
return out;
}
const SAMPLE_TIMES_120 = makeSampleTimes(120, 10); // one demo curve frame over the 100-kf range
const SAMPLE_TIMES_120_WIDE = makeSampleTimes(120, 100); // same frame over the 1000-kf range
// Easing-solver probes in [0,1] (the curve-preview polyline samples ~33 points).
const BEZIER_X_64 = makeSampleTimes(64, 1);
// Simulated pointermove deltas (px-equivalent seconds) for the drag hot path —
// a deterministic back-and-forth sweep, no random jitter.
const MOVE_TIMES_100 = (() => {
const out: number[] = Array.from({ length: 100 });
for (let i = 0; i < 100; i++) out[i] = 5 + Math.sin(i / 8) * 4; // ∈ ~[1, 9]
return out;
})();
// ─────────────────────────────────────────────────────────────────────────────
// Pure hot-path maths — these run on the live render / pointer paths.
// ─────────────────────────────────────────────────────────────────────────────
describe('sampleKeyframes — single sample by curve size', () => {
bench('100 keyframes — sample mid-range', () => {
sampleKeyframes(KF_100, 5, VALUE_RANGE);
});
bench('1000 keyframes — sample mid-range', () => {
sampleKeyframes(KF_1000, 50, VALUE_RANGE);
});
});
describe('sampleKeyframes — full curve sweep (per-frame readout)', () => {
bench('100 keyframes × 120 samples', () => {
let acc = 0;
for (let i = 0; i < SAMPLE_TIMES_120.length; i++)
acc += sampleKeyframes(KF_100, SAMPLE_TIMES_120[i]!, VALUE_RANGE);
return acc;
});
bench('1000 keyframes × 120 samples', () => {
let acc = 0;
for (let i = 0; i < SAMPLE_TIMES_120_WIDE.length; i++)
acc += sampleKeyframes(KF_1000, SAMPLE_TIMES_120_WIDE[i]!, VALUE_RANGE);
return acc;
});
});
describe('solveBezierX — easing solve', () => {
bench('identity (linear) × 64', () => {
let acc = 0;
for (let i = 0; i < BEZIER_X_64.length; i++)
acc += solveBezierX(0, 0, 1, 1, BEZIER_X_64[i]!);
return acc;
});
bench('ease-in-out (Newton-Raphson) × 64', () => {
let acc = 0;
for (let i = 0; i < BEZIER_X_64.length; i++)
acc += solveBezierX(0.65, 0, 0.35, 1, BEZIER_X_64[i]!);
return acc;
});
});
describe('sortKeyframes — reconcile / commit', () => {
bench('100 keyframes (reverse-sorted input)', () => {
sortKeyframes(KF_100_UNSORTED);
});
bench('1000 keyframes (reverse-sorted input)', () => {
sortKeyframes(KF_1000_UNSORTED);
});
});
describe('clampKeyframeTime — neighbour clamp (pointer drag)', () => {
const opts = { allowOverlap: false, minTimeBetween: 1 / FPS, duration: 100 };
bench('100 keyframes × 100 moves', () => {
let acc = 0;
for (let i = 0; i < MOVE_TIMES_100.length; i++) {
const index = (i * 7) % KF_100.length; // deterministic spread of touched indices
acc += clampKeyframeTime(KF_100, index, MOVE_TIMES_100[i]!, opts);
}
return acc;
});
bench('1000 keyframes × 100 moves', () => {
let acc = 0;
for (let i = 0; i < MOVE_TIMES_100.length; i++) {
const index = (i * 71) % KF_1000.length;
acc += clampKeyframeTime(KF_1000, index, MOVE_TIMES_100[i]!, opts);
}
return acc;
});
});
describe('snapTimeToFrame — frame-grid quantize', () => {
bench('100 quantize ops @30fps', () => {
let acc = 0;
for (let i = 0; i < MOVE_TIMES_100.length; i++)
acc += snapTimeToFrame(MOVE_TIMES_100[i]!, FPS);
return acc;
});
});
describe('defaultKeyframeValueText — aria-valuetext', () => {
bench('100 value-text formats (with property)', () => {
for (let i = 0; i < MOVE_TIMES_100.length; i++)
defaultKeyframeValueText(MOVE_TIMES_100[i]! / 10, 'opacity');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Component mount + update via Vue render() — realistic (50) and stress (500).
//
// Each keyframe renders a `role="slider"` Primitive that reads the projection /
// sampler from context, plus a segment band between neighbours. We mount the full
// tree, flush a tick, then update a prop (duration) to time the reconcile +
// re-projection cost, and finally unmount.
// ─────────────────────────────────────────────────────────────────────────────
function makeHarness(initial: KeyframeTrackKeyframeData[]) {
return defineComponent({
props: {
keyframes: { type: Array as () => KeyframeTrackKeyframeData[], default: () => initial },
duration: { type: Number, default: 50 },
},
setup(props) {
return () =>
h(
KeyframeTrackRoot as any,
{
modelValue: props.keyframes,
property: 'opacity',
duration: props.duration,
fps: FPS,
valueRange: [0, 1],
// The Root measures its own box; give it a concrete lane so the
// projection / snap targets are non-degenerate during the bench.
style: 'width: 600px; height: 160px; position: relative; display: block;',
},
{
default: ({ keyframes }: { keyframes: KeyframeTrackKeyframeData[] }) => [
...keyframes
.slice(0, -1)
.map(k =>
h(KeyframeTrackSegment, { key: `seg-${k.id}`, keyframeId: k.id }),
),
...keyframes.map(k =>
h(KeyframeTrackKeyframe, { key: k.id, keyframeId: k.id, id: `kf-${k.id}` }),
),
],
},
);
},
});
}
const KF_50 = makeKeyframes(50);
const KF_500 = makeKeyframes(500);
const Harness50 = makeHarness(KF_50);
const Harness500 = makeHarness(KF_500);
describe('KeyframeTrackRoot — mount + unmount', () => {
bench('mount 50 keyframes', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
render(h(Harness50), container);
await nextTick();
render(null, container);
container.remove();
});
bench('mount 500 keyframes', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
render(h(Harness500), container);
await nextTick();
render(null, container);
container.remove();
});
});
describe('KeyframeTrackRoot — re-render after prop change', () => {
bench('50 keyframes — duration change + flush', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
render(h(Harness50, { duration: 50 }), container);
await nextTick();
// Prop change re-runs the time scale + every keyframe/segment projection.
render(h(Harness50, { duration: 80 }), container);
await nextTick();
render(null, container);
container.remove();
});
bench('500 keyframes — duration change + flush', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
render(h(Harness500, { duration: 50 }), container);
await nextTick();
render(h(Harness500, { duration: 80 }), container);
await nextTick();
render(null, container);
container.remove();
});
});
@@ -0,0 +1,381 @@
import { mount } from '@vue/test-utils';
import { bench, describe } from 'vitest';
import { h, nextTick } from 'vue';
// Pure hot-path math + tick generators live in the shared `internal/scale`
// module, re-exported from the package barrel. Import them relatively so the
// bench tracks the same source the ruler consumes.
import {
formatClock,
formatFrames,
formatTimecode,
frameTicks,
framesToTimecode,
getStepDecimals,
niceTicks,
roundToStep,
scaleLinear,
secondsToFrames,
timeTicks,
timecodeTicks,
useScale,
} from '../../../internal/scale';
import { formatTimeForMode, modeToTickKind, tickFormatFor } from '../utils';
import { TimeRulerCursor, TimeRulerRoot } from '../index';
// ---------------------------------------------------------------------------
// Fixtures (deterministic — values seeded by index/formula, no Math.random).
// ---------------------------------------------------------------------------
const FPS = 30;
// A "visible window" mirrors the demo: offset (left-edge seconds) + a width in
// pixels projected through a zoom (px/s). Two scales: realistic and stress.
// realistic: 600px wide @ 40 px/s over a 10-minute clip → window ~15s
// stress: a long zoomed-out window so the generators emit many ticks.
function windowFor(widthPx: number, zoomPxPerSec: number, offsetSec: number): {
domain: readonly [number, number];
range: readonly [number, number];
} {
const span = widthPx / zoomPxPerSec;
return {
domain: [offsetSec, offsetSec + span] as const,
range: [0, widthPx] as const,
};
}
// Realistic single-screen window (demo defaults: 600px, 40 px/s, offset 12s).
const REALISTIC = windowFor(600, 40, 12);
// Stress window: a wide, zoomed-out viewport that walks many tick candidates.
const STRESS = windowFor(4000, 4, 0);
// A bank of seconds values for scalar projection / formatter benches. Seeded by
// a deterministic formula so every run hits identical inputs.
function secondsBank(n: number): Float64Array {
const out = new Float64Array(n);
for (let i = 0; i < n; i++) {
// Spread across a 10-minute clip with frame-fractional offsets so the
// timecode/frame paths exercise rounding, not just integers.
out[i] = (i * 600) / n + (i % 30) / FPS;
}
return out;
}
// A bank of pixel offsets for the invert (pointer→time) hot path.
function pixelBank(n: number, widthPx: number): Float64Array {
const out = new Float64Array(n);
for (let i = 0; i < n; i++) {
out[i] = (i / (n - 1 || 1)) * widthPx;
}
return out;
}
const SECONDS_100 = secondsBank(100);
const SECONDS_1000 = secondsBank(1000);
const PIXELS_100 = pixelBank(100, 600);
const PIXELS_1000 = pixelBank(1000, 4000);
// A frame bank for the integer-frame generator / formatters.
const FRAMES_1000 = (() => {
const out = new Float64Array(1000);
for (let i = 0; i < out.length; i++) out[i] = i * 37; // arbitrary stable stride
return out;
})();
// ---------------------------------------------------------------------------
// 1. Tick generation — the ruler's heaviest per-frame compute. Each generator
// is benched at a realistic single-screen window and a stress window.
// ---------------------------------------------------------------------------
describe('tick generation — timeTicks (seconds mode)', () => {
bench('realistic window (~15s @ 40px/s)', () => {
timeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, targetDensity: 80 });
});
bench('stress window (1000s @ 4px/s)', () => {
timeTicks({ domain: STRESS.domain, range: STRESS.range, targetDensity: 80 });
});
});
describe('tick generation — timecodeTicks (timecode mode)', () => {
bench('realistic window', () => {
timecodeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, fps: FPS, targetDensity: 80 });
});
bench('stress window', () => {
timecodeTicks({ domain: STRESS.domain, range: STRESS.range, fps: FPS, targetDensity: 80 });
});
bench('realistic window — drop-frame labels', () => {
timecodeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, fps: 29.97, dropFrame: true, targetDensity: 80 });
});
});
describe('tick generation — frameTicks (frames mode)', () => {
// frames mode routes the seconds domain through the timecode ticker with a
// frame-number `format` override; bench that exact path too.
const frameFormat = tickFormatFor('frames', FPS);
bench('realistic window — timecode ticker w/ frame labels', () => {
timecodeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, fps: FPS, format: frameFormat, targetDensity: 80 });
});
bench('stress window — integer-frame axis', () => {
// A wide integer-frame domain (0..18000 frames ≈ 600s @ 30fps).
frameTicks({ domain: [0, 18000] as const, range: [0, 4000] as const, fps: FPS, targetDensity: 80 });
});
});
describe('tick generation — niceTicks (generic axis)', () => {
bench('realistic window', () => {
niceTicks({ domain: REALISTIC.domain, range: REALISTIC.range, targetDensity: 80 });
});
bench('stress window', () => {
niceTicks({ domain: STRESS.domain, range: STRESS.range, targetDensity: 80 });
});
});
// ---------------------------------------------------------------------------
// 2. Pure projection math — `scale` / `invert` run on the pointer hot path.
// Bench the underlying scaleLinear over 100 / 1000 inputs.
// ---------------------------------------------------------------------------
describe('projection math — scaleLinear (time → px)', () => {
const [d0, d1] = REALISTIC.domain;
const [r0, r1] = REALISTIC.range;
bench('100 values', () => {
let acc = 0;
for (let i = 0; i < SECONDS_100.length; i++) {
acc += scaleLinear(SECONDS_100[i]!, d0, d1, r0, r1);
}
return acc;
});
bench('1000 values', () => {
let acc = 0;
for (let i = 0; i < SECONDS_1000.length; i++) {
acc += scaleLinear(SECONDS_1000[i]!, d0, d1, r0, r1);
}
return acc;
});
});
describe('projection math — scaleLinear (px → time, invert)', () => {
const [d0, d1] = STRESS.domain;
const [r0, r1] = STRESS.range;
bench('100 pixels', () => {
let acc = 0;
for (let i = 0; i < PIXELS_100.length; i++) {
acc += scaleLinear(PIXELS_100[i]!, r0, r1, d0, d1);
}
return acc;
});
bench('1000 pixels', () => {
let acc = 0;
for (let i = 0; i < PIXELS_1000.length; i++) {
acc += scaleLinear(PIXELS_1000[i]!, r0, r1, d0, d1);
}
return acc;
});
});
describe('projection math — roundToStep (snap, pointer path)', () => {
const step = 0.5;
const decimals = getStepDecimals(step);
bench('100 values', () => {
let acc = 0;
for (let i = 0; i < SECONDS_100.length; i++) {
acc += roundToStep(SECONDS_100[i]!, step, 0, decimals);
}
return acc;
});
bench('1000 values', () => {
let acc = 0;
for (let i = 0; i < SECONDS_1000.length; i++) {
acc += roundToStep(SECONDS_1000[i]!, step, 0, decimals);
}
return acc;
});
});
// ---------------------------------------------------------------------------
// 3. Live scale via useScale — the projector closures the ruler actually calls
// (read domain/range/reverse at call time). Bench the realistic per-frame
// burst: project every tick value to a pixel + invert a pointer sweep.
// ---------------------------------------------------------------------------
describe('useScale — projector closures', () => {
const { scale, invert } = useScale({
domain: () => REALISTIC.domain,
range: () => REALISTIC.range,
tickKind: () => 'time',
tickOptions: () => ({ targetDensity: 80 }),
});
bench('scale() × 1000', () => {
let acc = 0;
for (let i = 0; i < SECONDS_1000.length; i++) acc += scale(SECONDS_1000[i]!);
return acc;
});
bench('invert() × 100 (pointer sweep)', () => {
let acc = 0;
for (let i = 0; i < PIXELS_100.length; i++) acc += invert(PIXELS_100[i]!);
return acc;
});
});
// ---------------------------------------------------------------------------
// 4. Per-mode label formatters — run once per major tick on every regenerate.
// ---------------------------------------------------------------------------
describe('label formatting — per mode', () => {
bench('formatClock × 1000 (seconds)', () => {
let len = 0;
for (let i = 0; i < SECONDS_1000.length; i++) len += formatClock(SECONDS_1000[i]!).length;
return len;
});
bench('formatTimecode × 1000 (timecode)', () => {
let len = 0;
for (let i = 0; i < SECONDS_1000.length; i++) len += formatTimecode(SECONDS_1000[i]!, FPS).length;
return len;
});
bench('framesToTimecode × 1000 — drop-frame', () => {
let len = 0;
for (let i = 0; i < FRAMES_1000.length; i++) len += framesToTimecode(FRAMES_1000[i]!, 29.97, true).length;
return len;
});
bench('formatFrames × 1000 (frames)', () => {
let len = 0;
for (let i = 0; i < FRAMES_1000.length; i++) len += formatFrames(FRAMES_1000[i]!).length;
return len;
});
bench('formatTimeForMode × 1000 — dispatch (timecode)', () => {
let len = 0;
for (let i = 0; i < SECONDS_1000.length; i++) len += formatTimeForMode(SECONDS_1000[i]!, 'timecode', FPS).length;
return len;
});
});
// ---------------------------------------------------------------------------
// 5. Mode → tick-kind / format selection (cheap, but runs on every prop change).
// ---------------------------------------------------------------------------
describe('mode plumbing', () => {
const modes = ['seconds', 'timecode', 'frames'] as const;
bench('modeToTickKind × 3 modes', () => {
for (const m of modes) modeToTickKind(m);
});
bench('tickFormatFor × 3 modes', () => {
for (const m of modes) tickFormatFor(m, FPS);
});
bench('secondsToFrames × 1000', () => {
let acc = 0;
for (let i = 0; i < SECONDS_1000.length; i++) acc += secondsToFrames(SECONDS_1000[i]!, FPS);
return acc;
});
});
// ---------------------------------------------------------------------------
// 6. Component lifecycle — mount cost (builds useScale + tick computeds + the
// accessible group), and a re-render after a prop change. The default slot
// renders one DOM node per tick, so this captures the real render-N cost.
// ---------------------------------------------------------------------------
// Render the ruler's tick layer + a cursor, exactly like the demo, so the slot
// work (v-for over `ticks`, per-tick class/style, cursor projection) is benched.
function rulerSlot() {
return {
default: ({ ticks, formatTime }: { ticks: Array<{ value: number; px: number; major: boolean; label: string }>; formatTime: (s: number) => string }) =>
ticks
.map(tick =>
h(
'div',
{ key: tick.value, class: tick.major ? 'major' : 'minor', style: { left: `${tick.px}px` } },
tick.major ? [h('span', tick.label)] : [],
),
)
.concat([
h(TimeRulerCursor as never, { time: 72 }, {
default: ({ time }: { time: number }) => h('span', formatTime(time)),
}),
]),
};
}
describe('TimeRulerRoot — mount', () => {
bench('mount — seconds mode', () => {
const w = mount(TimeRulerRoot, {
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'seconds', focusable: true, wheel: true, draggable: true },
slots: rulerSlot(),
attachTo: document.body,
});
w.unmount();
});
bench('mount — timecode mode', () => {
const w = mount(TimeRulerRoot, {
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'timecode', focusable: true },
slots: rulerSlot(),
attachTo: document.body,
});
w.unmount();
});
bench('mount — frames mode', () => {
const w = mount(TimeRulerRoot, {
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'frames', focusable: true },
slots: rulerSlot(),
attachTo: document.body,
});
w.unmount();
});
});
describe('TimeRulerRoot — re-render after prop change', () => {
bench('zoom change (pan/zoom gesture stream)', async () => {
const w = mount(TimeRulerRoot, {
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'timecode' },
slots: rulerSlot(),
attachTo: document.body,
});
// A zoom write re-derives the visible window → ticks computed → slot re-render.
await w.setProps({ zoom: 120 });
await nextTick();
w.unmount();
});
bench('offset change (pan stream)', async () => {
const w = mount(TimeRulerRoot, {
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'timecode' },
slots: rulerSlot(),
attachTo: document.body,
});
await w.setProps({ offset: 120 });
await nextTick();
w.unmount();
});
bench('mode change (timecode → frames, regenerate ladder)', async () => {
const w = mount(TimeRulerRoot, {
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'timecode' },
slots: rulerSlot(),
attachTo: document.body,
});
await w.setProps({ mode: 'frames' });
await nextTick();
w.unmount();
});
});
@@ -0,0 +1,397 @@
import { bench, describe } from 'vitest';
import { defineComponent, h, ref } from 'vue';
import { mount } from '@vue/test-utils';
import {
framesToTimecode,
scaleLinear,
secondsToFrames,
timeTicks,
timecodeTicks,
} from '../../../internal/scale';
import {
TimelineClip,
TimelinePlayhead,
TimelineRoot,
TimelineTrack,
TimelineTrackHeader,
TimelineTracks,
applyClipChanges,
applyTrackChanges,
clipIntersectsTime,
clipsDuration,
snapToFrame,
timeToTimecode,
} from '../index';
import type {
TimelineClipChange,
TimelineClipData,
TimelineTrackChange,
TimelineTrackData,
} from '../index';
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic fixtures (NO Math.random — every value is seeded by index).
//
// The timeline domain unit is SECONDS; pxPerSecond is the zoom. The hot paths
// are (a) the pure ruler/timecode/scale math recomputed on every pan/zoom, (b)
// the controlled-mode reducers folding @clips-change / @tracks-change batches,
// and (c) mounting + updating the headless component tree with N clips.
// ─────────────────────────────────────────────────────────────────────────────
const FPS = 30;
const PX_PER_SECOND = 90; // matches the demo's default zoom.
/** Build a deterministic clip array spread across `trackCount` lanes. */
function makeClips(count: number, trackCount: number): TimelineClipData[] {
const out: TimelineClipData[] = [];
for (let i = 0; i < count; i++) {
// Stagger starts so clips tile along time; vary duration by a fixed cycle.
const start = i * 1.5;
const duration = 0.5 + (i % 5) * 0.4;
out.push({
id: `c${i}`,
trackId: `t${i % trackCount}`,
start,
duration,
label: `Clip ${i}`,
color: i % 2 === 0 ? 'var(--color-accent)' : '#0ea5e9',
locked: i % 11 === 0,
});
}
return out;
}
/** Build a deterministic track array. */
function makeTracks(count: number): TimelineTrackData[] {
const out: TimelineTrackData[] = [];
for (let i = 0; i < count; i++) {
out.push({
id: `t${i}`,
label: `Track ${i}`,
height: 52 + (i % 3) * 6,
kind: i % 3 === 0 ? 'audio' : 'video',
});
}
return out;
}
// Pre-built fixture sets (module scope, simple loops — no randomness).
const clips100 = makeClips(100, 8);
const clips1000 = makeClips(1000, 16);
const tracks50 = makeTracks(50);
const tracks500 = makeTracks(500);
const clips50 = makeClips(50, 4);
const clips500 = makeClips(500, 8);
// A long visible time span so the tick generators emit a realistic tick count.
const SPAN_100 = clipsDuration(clips100); // ~150s
const SPAN_1000 = clipsDuration(clips1000); // ~1500s
const VIEWPORT_PX = 1200;
// Pre-built change batches for the reducers (mixed move/trim, deterministic).
function makeClipChanges(clips: TimelineClipData[], n: number): TimelineClipChange[] {
const out: TimelineClipChange[] = [];
for (let i = 0; i < n; i++) {
const clip = clips[i % clips.length]!;
if (i % 2 === 0) out.push({ type: 'move', id: clip.id, trackId: clip.trackId, start: clip.start + 0.25 });
else out.push({ type: 'trim', id: clip.id, start: clip.start, duration: clip.duration + 0.1 });
}
return out;
}
const clipChanges100 = makeClipChanges(clips100, 100);
const clipChanges1000 = makeClipChanges(clips1000, 1000);
function makeTrackChanges(tracks: TimelineTrackData[], n: number): TimelineTrackChange[] {
const out: TimelineTrackChange[] = [];
for (let i = 0; i < n; i++) {
const t = tracks[i % tracks.length]!;
out.push({ type: 'patch', id: t.id, patch: { height: 60 + (i % 4) * 4, muted: i % 2 === 0 } });
}
return out;
}
const trackChanges50 = makeTrackChanges(tracks50, 50);
const trackChanges500 = makeTrackChanges(tracks500, 500);
// ─────────────────────────────────────────────────────────────────────────────
// 1. Ruler tick generation — runs on EVERY pan / zoom / offset change.
// domain = [offset, offset + width / pxPerSecond] → range = [0, width].
// ─────────────────────────────────────────────────────────────────────────────
describe('ruler ticks — timecode (per pan/zoom)', () => {
bench('timecodeTicks — 100-clip span (~150s)', () => {
timecodeTicks({ domain: [0, SPAN_100], range: [0, SPAN_100 * PX_PER_SECOND], fps: FPS });
});
bench('timecodeTicks — 1000-clip span (~1500s)', () => {
timecodeTicks({ domain: [0, SPAN_1000], range: [0, SPAN_1000 * PX_PER_SECOND], fps: FPS });
});
bench('timecodeTicks — wide window, fixed viewport (1200px)', () => {
timecodeTicks({ domain: [0, SPAN_1000], range: [0, VIEWPORT_PX], fps: FPS });
});
});
describe('ruler ticks — wall clock (per pan/zoom)', () => {
bench('timeTicks — 100-clip span (~150s)', () => {
timeTicks({ domain: [0, SPAN_100], range: [0, SPAN_100 * PX_PER_SECOND] });
});
bench('timeTicks — 1000-clip span (~1500s)', () => {
timeTicks({ domain: [0, SPAN_1000], range: [0, SPAN_1000 * PX_PER_SECOND] });
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. Scale projection + timecode formatting — per clip / per tick, every render.
// ─────────────────────────────────────────────────────────────────────────────
describe('scale projection (scaleLinear over clips)', () => {
bench('scaleLinear — project 100 clip edges', () => {
let acc = 0;
for (let i = 0; i < clips100.length; i++) {
const c = clips100[i]!;
acc += scaleLinear(c.start, 0, SPAN_100, 0, VIEWPORT_PX);
acc += scaleLinear(c.start + c.duration, 0, SPAN_100, 0, VIEWPORT_PX);
}
return acc;
});
bench('scaleLinear — project 1000 clip edges', () => {
let acc = 0;
for (let i = 0; i < clips1000.length; i++) {
const c = clips1000[i]!;
acc += scaleLinear(c.start, 0, SPAN_1000, 0, VIEWPORT_PX);
acc += scaleLinear(c.start + c.duration, 0, SPAN_1000, 0, VIEWPORT_PX);
}
return acc;
});
});
describe('timecode formatting (per clip label)', () => {
bench('timeToTimecode — 100 clip durations', () => {
let len = 0;
for (let i = 0; i < clips100.length; i++) len += timeToTimecode(clips100[i]!.duration, FPS).length;
return len;
});
bench('timeToTimecode — 1000 clip durations', () => {
let len = 0;
for (let i = 0; i < clips1000.length; i++) len += timeToTimecode(clips1000[i]!.duration, FPS).length;
return len;
});
bench('framesToTimecode — 1000 (raw, pre-converted)', () => {
let len = 0;
for (let i = 0; i < clips1000.length; i++) {
const frames = secondsToFrames(clips1000[i]!.start, FPS);
len += framesToTimecode(frames, FPS).length;
}
return len;
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Snap-to-frame — keyboard nudge granularity + default snap grid.
// ─────────────────────────────────────────────────────────────────────────────
describe('snapToFrame (nudge / grid granularity)', () => {
bench('snapToFrame — 100 clip starts', () => {
let acc = 0;
for (let i = 0; i < clips100.length; i++) acc += snapToFrame(clips100[i]!.start + 0.017, FPS);
return acc;
});
bench('snapToFrame — 1000 clip starts', () => {
let acc = 0;
for (let i = 0; i < clips1000.length; i++) acc += snapToFrame(clips1000[i]!.start + 0.017, FPS);
return acc;
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. Marquee hit-testing — clipIntersectsTime per clip on every marquee move.
// Plus clipsDuration, the auto-duration recompute over the whole clip set.
// ─────────────────────────────────────────────────────────────────────────────
describe('marquee hit-test (clipIntersectsTime per pointer move)', () => {
// A simulated marquee window sweeping a fixed sub-range of the timeline.
const from = SPAN_100 * 0.3;
const to = SPAN_100 * 0.6;
bench('clipIntersectsTime — 100 clips', () => {
let hits = 0;
for (let i = 0; i < clips100.length; i++) if (clipIntersectsTime(clips100[i]!, from, to)) hits++;
return hits;
});
bench('clipIntersectsTime — 1000 clips', () => {
const f = SPAN_1000 * 0.3;
const t = SPAN_1000 * 0.6;
let hits = 0;
for (let i = 0; i < clips1000.length; i++) if (clipIntersectsTime(clips1000[i]!, f, t)) hits++;
return hits;
});
});
describe('clipsDuration (auto-duration recompute)', () => {
bench('clipsDuration — 100 clips', () => clipsDuration(clips100));
bench('clipsDuration — 1000 clips', () => clipsDuration(clips1000));
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. Controlled-mode reducers — fold a @clips-change / @tracks-change batch.
// This is the React-Flow-style applyNodeChanges hot path.
// ─────────────────────────────────────────────────────────────────────────────
describe('applyClipChanges (controlled reducer)', () => {
bench('applyClipChanges — 100 clips / 100 changes', () => {
applyClipChanges(clips100, clipChanges100);
});
bench('applyClipChanges — 1000 clips / 1000 changes', () => {
applyClipChanges(clips1000, clipChanges1000);
});
bench('applyClipChanges — 1000 clips / single move', () => {
applyClipChanges(clips1000, [
{ type: 'move', id: 'c500', trackId: 't0', start: 999 },
]);
});
});
describe('applyTrackChanges (controlled reducer)', () => {
bench('applyTrackChanges — 50 tracks / 50 patches', () => {
applyTrackChanges(tracks50, trackChanges50);
});
bench('applyTrackChanges — 500 tracks / 500 patches', () => {
applyTrackChanges(tracks500, trackChanges500);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. Component mount — the full headless tree with N clips/tracks.
// Builds a Root → Tracks → Track → Clip composition mirroring the demo, with
// a fixed-width viewport so the scale projects to real pixels.
// ─────────────────────────────────────────────────────────────────────────────
/** A full timeline composition over a live clips model (add/remove reflects). */
function makeTimeline(tracks: TimelineTrackData[], initialClips: TimelineClipData[]) {
return defineComponent({
props: { pxPerSecond: { type: Number, default: PX_PER_SECOND } },
setup(props) {
const clipsRef = ref<TimelineClipData[]>(initialClips.map(c => ({ ...c })));
return () => h(
TimelineRoot,
{
tracks,
clips: clipsRef.value,
'onUpdate:clips': (v: TimelineClipData[]) => { clipsRef.value = v; },
pxPerSecond: props.pxPerSecond,
'onUpdate:pxPerSecond': () => {},
fps: FPS,
trackHeight: 56,
style: 'width:1200px;display:block;',
},
{
default: () => [
h(TimelinePlayhead, {}, { default: () => 'PH' }),
h(
TimelineTracks,
{ style: 'position:relative;display:block;width:1200px;height:600px;' },
{
default: () => tracks.map(t => h(
TimelineTrack,
{ trackId: t.id, key: t.id, style: 'position:relative;display:block;' },
{
default: () => [
h(TimelineTrackHeader, {}, {}),
...clipsRef.value
.filter(c => c.trackId === t.id)
.map(c => h(
TimelineClip,
{ clipId: c.id, key: c.id },
{ default: () => c.label },
)),
],
},
)),
},
),
],
},
);
},
});
}
describe('TimelineRoot — mount (full tree)', () => {
bench('mount — 4 tracks / 50 clips', () => {
const w = mount(makeTimeline(tracks50.slice(0, 4), clips50), { attachTo: document.body });
w.unmount();
});
bench('mount — 8 tracks / 500 clips', () => {
const w = mount(makeTimeline(tracks500.slice(0, 8), clips500), { attachTo: document.body });
w.unmount();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 7. Re-render after a prop change — zoom (pxPerSecond) and clip-array updates.
// A zoom change re-projects every clip + rebuilds the ruler ticks; a clips
// swap re-reconciles the internal shallowRef Map.
// ─────────────────────────────────────────────────────────────────────────────
describe('TimelineRoot — update after prop change', () => {
bench('zoom change (pxPerSecond) — 8 tracks / 500 clips', async () => {
const w = mount(makeTimeline(tracks500.slice(0, 8), clips500), { attachTo: document.body });
await w.setProps({ pxPerSecond: PX_PER_SECOND * 2 });
w.unmount();
});
bench('clips-array swap — 8 tracks / 500 clips', async () => {
const Comp = defineComponent({
setup() {
const clipsRef = ref<TimelineClipData[]>(clips500.map(c => ({ ...c })));
const tracks = tracks500.slice(0, 8);
const swap = () => {
// Shift every start by a frame (new objects → reconcile path).
clipsRef.value = clipsRef.value.map(c => ({ ...c, start: c.start + 1 / FPS }));
};
return { clipsRef, tracks, swap };
},
render() {
return h(
TimelineRoot,
{ tracks: this.tracks, clips: this.clipsRef, pxPerSecond: PX_PER_SECOND, fps: FPS, style: 'width:1200px;display:block;' },
{
default: () => h(
TimelineTracks,
{ style: 'position:relative;display:block;width:1200px;height:600px;' },
{
default: () => this.tracks.map(t => h(
TimelineTrack,
{ trackId: t.id, key: t.id, style: 'position:relative;display:block;' },
{
default: () => this.clipsRef
.filter(c => c.trackId === t.id)
.map(c => h(TimelineClip, { clipId: c.id, key: c.id }, { default: () => c.label })),
},
)),
},
),
},
);
},
});
const w = mount(Comp, { attachTo: document.body });
(w.vm as unknown as { swap: () => void }).swap();
await w.vm.$nextTick();
w.unmount();
});
});
@@ -0,0 +1,386 @@
import { mount } from '@vue/test-utils';
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
TransformBoxHandle,
TransformBoxRoot,
TransformBoxRotateHandle,
TransformBoxStatus,
} from '../index';
import type { Point, TransformBoxHandlePosition, TransformBoxValue } from '../utils';
import {
applyAspectRatio,
boxCenter,
constrainRect,
decomposeTransform,
handleAxes,
localToWorld,
moveBox,
normalizeRotation,
pointerAngle,
resizeEdge,
resolvePivot,
rotatePoint,
rotateVector,
rotationFromPointer,
shortestAngleDelta,
snapRotation,
worldToLocal,
} from '../utils';
// ──────────────────────────────────────────────────────────────────────────────
// Deterministic fixtures (NO Math.random — every value is seeded by its index so
// runs are reproducible). Built once at module scope with simple loops.
// ──────────────────────────────────────────────────────────────────────────────
/** The 8 handle positions in the package's stable order. */
const POSITIONS: TransformBoxHandlePosition[] = [
'top-left',
'top',
'top-right',
'right',
'bottom-right',
'bottom',
'bottom-left',
'left',
];
/** A representative rotated box; the math hot path mostly trades on `rotation`. */
const BOX: TransformBoxValue = { x: 96, y: 64, width: 200, height: 130, rotation: -8 };
/** Build N deterministic boxes spread across position / size / rotation space. */
function makeBoxes(n: number): TransformBoxValue[] {
const out: TransformBoxValue[] = new Array(n);
for (let i = 0; i < n; i++) {
out[i] = {
x: (i % 400) - 200,
y: ((i * 3) % 400) - 200,
width: 20 + (i % 380),
height: 20 + ((i * 7) % 380),
// Sweep rotation across the full circle and across the ±180 seam.
rotation: ((i * 13) % 720) - 360,
};
}
return out;
}
/** Build N deterministic 2D points (used as pointer samples / deltas). */
function makePoints(n: number): Point[] {
const out: Point[] = new Array(n);
for (let i = 0; i < n; i++) {
// Lissajous-ish spread so x/y are decorrelated but fully deterministic.
out[i] = {
x: ((i * 31) % 800) - 400,
y: ((i * 17) % 600) - 300,
};
}
return out;
}
/** Pre-resolve handle axes for every position once. */
const AXES = POSITIONS.map(handleAxes);
const BOXES_100 = makeBoxes(100);
const BOXES_1000 = makeBoxes(1000);
const POINTS_100 = makePoints(100);
const POINTS_1000 = makePoints(1000);
/** A simulated pointer-move drag track about a pivot (rotation gesture frames). */
const PIVOT: Point = boxCenter(BOX);
const DRAG_100 = makePoints(100);
const DRAG_1000 = makePoints(1000);
const START_POINTER_ANGLE = pointerAngle(DRAG_100[0]!, PIVOT);
// ──────────────────────────────────────────────────────────────────────────────
// Pure rotation primitives — the innermost kernel every gesture frame calls.
// ──────────────────────────────────────────────────────────────────────────────
describe('rotatePoint — kernel', () => {
bench('rotatePoint × 100', () => {
for (let i = 0; i < POINTS_100.length; i++) {
rotatePoint(POINTS_100[i]!, 37, PIVOT);
}
});
bench('rotatePoint × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
rotatePoint(POINTS_1000[i]!, 37, PIVOT);
}
});
bench('rotateVector (origin-free) × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
rotateVector(POINTS_1000[i]!, -BOX.rotation);
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Pointer→angle math — the rotate-handle hot path (atan2 + seam-safe delta).
// ──────────────────────────────────────────────────────────────────────────────
describe('pointer angle math', () => {
bench('pointerAngle × 100', () => {
for (let i = 0; i < POINTS_100.length; i++) {
pointerAngle(POINTS_100[i]!, PIVOT);
}
});
bench('pointerAngle × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
pointerAngle(POINTS_1000[i]!, PIVOT);
}
});
bench('shortestAngleDelta × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
shortestAngleDelta(BOXES_1000[i]!.rotation, BOX.rotation);
}
});
bench('normalizeRotation × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
normalizeRotation(BOXES_1000[i]!.rotation);
}
});
bench('snapRotation (15°) × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
snapRotation(BOXES_1000[i]!.rotation, 15);
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Simulated rotate drag — one rotationFromPointer per pointer-move frame, the
// full per-frame computation the rotate handle does (angle + accumulate delta).
// ──────────────────────────────────────────────────────────────────────────────
describe('rotate drag — per-frame', () => {
bench('rotationFromPointer × 100 frames', () => {
for (let i = 0; i < DRAG_100.length; i++) {
rotationFromPointer(DRAG_100[i]!, PIVOT, START_POINTER_ANGLE, BOX.rotation);
}
});
bench('rotationFromPointer × 1000 frames', () => {
for (let i = 0; i < DRAG_1000.length; i++) {
rotationFromPointer(DRAG_1000[i]!, PIVOT, START_POINTER_ANGLE, BOX.rotation);
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// resizeEdge — the heaviest pure helper (anchor capture, aspect lock, flip,
// re-place, normalize). Driven every scale-handle pointer-move frame.
// ──────────────────────────────────────────────────────────────────────────────
describe('resizeEdge — per-frame', () => {
bench('resizeEdge corner (no options) × 100', () => {
for (let i = 0; i < POINTS_100.length; i++) {
resizeEdge(BOX, 'bottom-right', POINTS_100[i]!);
}
});
bench('resizeEdge corner (no options) × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
resizeEdge(BOX, 'bottom-right', POINTS_1000[i]!);
}
});
bench('resizeEdge aspect-locked corner × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
resizeEdge(BOX, 'bottom-right', POINTS_1000[i]!, { aspectRatio: 1.5 });
}
});
bench('resizeEdge symmetric (Alt) corner × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
resizeEdge(BOX, 'bottom-right', POINTS_1000[i]!, { symmetric: true, pivot: 'center' });
}
});
bench('resizeEdge edge handle × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
resizeEdge(BOX, 'right', POINTS_1000[i]!);
}
});
// Full scale-frame as the root runs it: rotate the screen delta into local
// axes first, then resize (the load-bearing step for rotated boxes).
bench('rotated scale frame (rotateVector → resizeEdge) × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
const local = rotateVector(POINTS_1000[i]!, -BOX.rotation);
resizeEdge(BOX, 'bottom-right', local, { minWidth: 40, minHeight: 40, allowFlip: true });
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// applyAspectRatio / handleAxes — small per-frame helpers.
// ──────────────────────────────────────────────────────────────────────────────
describe('aspect + axes helpers', () => {
const cornerAxes = handleAxes('bottom-right');
bench('applyAspectRatio × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
const b = BOXES_1000[i]!;
applyAspectRatio(b.width, b.height, 1.5, cornerAxes);
}
});
bench('handleAxes × 8 positions × 125 (=1000)', () => {
for (let i = 0; i < 125; i++) {
for (let j = 0; j < POSITIONS.length; j++) {
handleAxes(POSITIONS[j]!);
}
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// constrainRect / moveBox — commit-path normalization + whole-box drag.
// ──────────────────────────────────────────────────────────────────────────────
describe('constrain + move', () => {
bench('constrainRect × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
constrainRect(BOXES_1000[i]!, 40, 40);
}
});
bench('moveBox × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
moveBox(BOX, POINTS_1000[i]!);
}
});
bench('resolvePivot (center) × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
resolvePivot(BOXES_1000[i]!, 'center');
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// local⇄world round-trip — used to capture/re-place anchors against rotation.
// ──────────────────────────────────────────────────────────────────────────────
describe('local ⇄ world', () => {
bench('localToWorld → worldToLocal round-trip × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
const w = localToWorld(BOX, POINTS_1000[i]!);
worldToLocal(BOX, w);
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// decomposeTransform — Crop/overlay hot path: normalize + 4 rotated corners.
// ──────────────────────────────────────────────────────────────────────────────
describe('decomposeTransform — corners', () => {
bench('decomposeTransform × 100', () => {
for (let i = 0; i < BOXES_100.length; i++) {
decomposeTransform(BOXES_100[i]!);
}
});
bench('decomposeTransform × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
decomposeTransform(BOXES_1000[i]!);
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Component mount + update — realistic (1 root, 8 handles) and stress scale.
// Mirrors the existing Primitive.bench convention: mount() then unmount().
// ──────────────────────────────────────────────────────────────────────────────
/** A harness with `count` independent transform boxes, each with 8 handles. */
function makeStage(count: number, value: TransformBoxValue) {
return defineComponent({
setup() {
return () => h(
'div',
null,
Array.from({ length: count }, (_, i) =>
h(
TransformBoxRoot,
{
key: i,
modelValue: value,
minWidth: 40,
minHeight: 40,
rotationSnap: 15,
},
{
default: () => [
...POSITIONS.map(p => h(TransformBoxHandle, { key: p, position: p })),
h(TransformBoxRotateHandle),
h(TransformBoxStatus),
],
},
)),
);
},
});
}
describe('TransformBoxRoot — mount full part set', () => {
bench('mount + unmount — 1 box (root + 8 handles + rotate + status)', () => {
const w = mount(makeStage(1, BOX), { attachTo: document.body });
w.unmount();
});
bench('mount + unmount — 50 boxes', () => {
const w = mount(makeStage(50, BOX), { attachTo: document.body });
w.unmount();
});
bench('mount + unmount — 500 boxes (stress)', () => {
const w = mount(makeStage(500, BOX), { attachTo: document.body });
w.unmount();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Re-render after a prop (transform v-model) change — restyle + reflow path for
// every part. Mirrors the "mount + update" bench in Primitive.bench.
// ──────────────────────────────────────────────────────────────────────────────
const BOX_B: TransformBoxValue = { x: 140, y: 90, width: 260, height: 180, rotation: 42 };
function makeUpdatableStage(count: number) {
return defineComponent({
props: { v: { type: Object, required: true } },
setup(props) {
return () => h(
'div',
null,
Array.from({ length: count }, (_, i) =>
h(
TransformBoxRoot,
{ key: i, modelValue: props.v as TransformBoxValue, minWidth: 40, minHeight: 40 },
{
default: () => POSITIONS.map(p => h(TransformBoxHandle, { key: p, position: p })),
},
)),
);
},
});
}
describe('TransformBoxRoot — update after transform change', () => {
bench('mount → setProps(transform) → update — 50 boxes', async () => {
const w = mount(makeUpdatableStage(50), {
props: { v: BOX },
attachTo: document.body,
});
await w.setProps({ v: BOX_B });
await nextTick();
w.unmount();
});
});
@@ -0,0 +1,283 @@
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { buildSmoothPath } from '../../../internal/spline';
import { buildBars, buildPathPoints, countBars, resamplePeaks } from '../utils';
import { WaveformBars, WaveformPath, WaveformRoot } from '../index';
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic fixtures (NO Math.random, NO network — seeded by index/formula).
// Peaks model a signed `-1..1` PCM-style envelope: a decaying sinusoid summed
// with a faster ripple, so resampling has real transients to pick a max from.
// ─────────────────────────────────────────────────────────────────────────────
function makePeaks(n: number): number[] {
const out = Array.from<number>({ length: n });
for (let i = 0; i < n; i++) {
const t = i / n;
const envelope = 1 - 0.6 * t; // slow decay over the track
const carrier = Math.sin(t * Math.PI * 64); // audible-rate oscillation
const ripple = 0.35 * Math.sin(t * Math.PI * 503); // transient detail
out[i] = envelope * (carrier * 0.7 + ripple); // stays within -1..1
}
return out;
}
// A Float32Array variant exercises the `ArrayLike<number>` fast path the root
// passes through from a `Float32Array` peaks prop (typed-array element reads).
function makePeaksF32(n: number): Float32Array {
const src = makePeaks(n);
const out = new Float32Array(n);
for (let i = 0; i < n; i++) out[i] = src[i]!;
return out;
}
const PEAKS_100 = makePeaks(100);
const PEAKS_1000 = makePeaks(1000);
const PEAKS_10000 = makePeaks(10000);
const PEAKS_F32_10000 = makePeaksF32(10000);
// Realistic body widths: a small inline waveform (~150 bars) and a full-bleed
// editor lane (~600+ bars) at the demo's barWidth=2 / barGap=1 (pitch 3).
const BAR_WIDTH = 2;
const BAR_GAP = 1;
const WIDTH_SMALL = 300; // → 100 bars
const WIDTH_LARGE = 1800; // → 600 bars
const SIGNED = true; // peaksRange '-1..1' (the root default → rectify by abs)
// Pre-built point sets for the smoothing benches (path mode silhouette).
const PATH_POINTS_256 = buildPathPoints(PEAKS_1000, WIDTH_LARGE, 120, 256, SIGNED);
const PATH_POINTS_1024 = buildPathPoints(PEAKS_10000, WIDTH_LARGE, 120, 1024, SIGNED);
// ─────────────────────────────────────────────────────────────────────────────
// countBars — cheap per-render geometry guard (runs on every width change).
// ─────────────────────────────────────────────────────────────────────────────
describe('countBars', () => {
bench('small body (300px)', () => {
countBars(WIDTH_SMALL, BAR_WIDTH, BAR_GAP);
});
bench('large body (1800px)', () => {
countBars(WIDTH_LARGE, BAR_WIDTH, BAR_GAP);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// resamplePeaks — THE hot inner loop (max-magnitude reduction per bucket).
// Scales with BOTH source length and bucket count; benched across realistic and
// stress combinations.
// ─────────────────────────────────────────────────────────────────────────────
describe('resamplePeaks — by source length (100 buckets)', () => {
bench('100 peaks', () => {
resamplePeaks(PEAKS_100, 100, SIGNED);
});
bench('1000 peaks', () => {
resamplePeaks(PEAKS_1000, 100, SIGNED);
});
bench('10000 peaks', () => {
resamplePeaks(PEAKS_10000, 100, SIGNED);
});
bench('10000 peaks (Float32Array)', () => {
resamplePeaks(PEAKS_F32_10000, 100, SIGNED);
});
});
describe('resamplePeaks — by bucket count (10000 peaks)', () => {
bench('100 buckets', () => {
resamplePeaks(PEAKS_10000, 100, SIGNED);
});
bench('600 buckets', () => {
resamplePeaks(PEAKS_10000, 600, SIGNED);
});
bench('upsample → 2000 buckets', () => {
resamplePeaks(PEAKS_10000, 2000, SIGNED);
});
});
describe('resamplePeaks — windowed slice (zoom/scroll)', () => {
// The root maps the visible time window onto a peaks index slice; a zoomed-in
// view resamples a sub-range into the same bucket count.
bench('full window — 600 buckets over 10000', () => {
resamplePeaks(PEAKS_10000, 600, SIGNED, 0, PEAKS_10000.length);
});
bench('25% zoom window — 600 buckets over slice', () => {
resamplePeaks(PEAKS_10000, 600, SIGNED, 2500, 5000);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// buildBars — bars-mode render hot path (countBars + resample + layout loop).
// This is what `WaveformRoot.buckets` computes on every width / peaks / window
// change. Benched at realistic (100 bars) and stress (600 bars) scale.
// ─────────────────────────────────────────────────────────────────────────────
describe('buildBars — bars-mode geometry', () => {
bench('100 bars from 1000 peaks', () => {
buildBars(PEAKS_1000, WIDTH_SMALL, BAR_WIDTH, BAR_GAP, SIGNED);
});
bench('600 bars from 10000 peaks', () => {
buildBars(PEAKS_10000, WIDTH_LARGE, BAR_WIDTH, BAR_GAP, SIGNED);
});
bench('600 bars from 10000 peaks (Float32Array)', () => {
buildBars(PEAKS_F32_10000, WIDTH_LARGE, BAR_WIDTH, BAR_GAP, SIGNED);
});
});
describe('buildBars — sliding window (simulated scrub/zoom recompute)', () => {
// Each iteration recomputes the full bar geometry over a window shifted by a
// deterministic step — the per-frame cost when a user scrubs a zoomed lane.
let frame = 0;
const total = PEAKS_10000.length;
const span = Math.floor(total / 4); // a 25% zoom window
bench('600 bars, window slides per iteration', () => {
const start = (frame * 137) % (total - span); // deterministic, no PRNG
frame += 1;
buildBars(PEAKS_10000, WIDTH_LARGE, BAR_WIDTH, BAR_GAP, SIGNED, start, start + span);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// buildPathPoints — path-mode polyline hot path (resample + y-projection loop).
// ─────────────────────────────────────────────────────────────────────────────
describe('buildPathPoints — path-mode silhouette', () => {
bench('256 samples from 1000 peaks', () => {
buildPathPoints(PEAKS_1000, WIDTH_LARGE, 120, 256, SIGNED);
});
bench('1024 samples from 10000 peaks', () => {
buildPathPoints(PEAKS_10000, WIDTH_LARGE, 120, 1024, SIGNED);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// buildSmoothPath — Catmull-Rom smoothing over the path points (the d-string
// `WaveformPath` recomputes on every width/peaks/window change in path mode).
// ─────────────────────────────────────────────────────────────────────────────
describe('buildSmoothPath — Catmull-Rom path string', () => {
bench('256 points, tension 0', () => {
buildSmoothPath(PATH_POINTS_256, 0);
});
bench('256 points, tension 0.5', () => {
buildSmoothPath(PATH_POINTS_256, 0.5);
});
bench('1024 points, tension 0', () => {
buildSmoothPath(PATH_POINTS_1024, 0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Component mount + update via @vue/test-utils mount().
// `WaveformRoot` measures its own width (useElementSize → 0 synchronously under
// the bench), so to render a deterministic N bars we drive `WaveformBars`'
// default slot directly with pre-built geometry. This isolates the Vue render
// cost of N bar nodes (the real per-frame DOM work) from ResizeObserver timing.
// ─────────────────────────────────────────────────────────────────────────────
const BARS_50 = buildBars(PEAKS_1000, 150, BAR_WIDTH, BAR_GAP, SIGNED); // 50 bars
const BARS_500 = buildBars(PEAKS_10000, 1500, BAR_WIDTH, BAR_GAP, SIGNED); // 500 bars
function mountWaveform(peaks: number[], duration: number) {
return mount(
defineComponent({
props: {
peaks: { type: Array as () => number[], required: true },
duration: { type: Number, required: true },
currentTime: { type: Number, default: 0 },
},
setup(props) {
return () =>
h(
WaveformRoot,
{
peaks: props.peaks,
peaksRange: '-1..1',
duration: props.duration,
currentTime: props.currentTime,
barWidth: BAR_WIDTH,
barGap: BAR_GAP,
},
{ default: () => h(WaveformBars) },
);
},
}),
{ props: { peaks, duration: 100, currentTime: 0 } },
);
}
describe('WaveformRoot + WaveformBars — mount', () => {
bench('mount with ~50-bar fixture', () => {
const w = mountWaveform(PEAKS_1000, 100);
w.unmount();
});
bench('mount with ~500-bar fixture', () => {
const w = mountWaveform(PEAKS_10000, 100);
w.unmount();
});
});
describe('WaveformRoot — update after prop change', () => {
bench('currentTime change → patch', async () => {
const w = mountWaveform(PEAKS_1000, 100);
await w.setProps({ currentTime: 42 });
await nextTick();
w.unmount();
});
bench('peaks swap → re-resample + patch', async () => {
const w = mountWaveform(PEAKS_1000, 100);
await w.setProps({ peaks: PEAKS_10000 });
await nextTick();
w.unmount();
});
});
// Path-mode component render: mount the SVG silhouette part (its `d` recompute
// drives buildPathPoints + buildSmoothPath).
describe('WaveformRoot + WaveformPath — mount', () => {
function mountPath(peaks: number[], samples: number) {
return mount(
defineComponent({
setup() {
return () =>
h(
WaveformRoot,
{ peaks, peaksRange: '-1..1', duration: 100, mode: 'path' },
{ default: () => h(WaveformPath, { samples }) },
);
},
}),
);
}
bench('path mode, 256 samples', () => {
const w = mountPath(PEAKS_1000, 256);
w.unmount();
});
bench('path mode, 1024 samples', () => {
const w = mountPath(PEAKS_10000, 1024);
w.unmount();
});
});
// Silence "fixture is unused" tree-shake concerns: reference the prebuilt arrays
// so the bench harness retains them deterministically across runs.
void BARS_50.length;
void BARS_500.length;
@@ -0,0 +1,412 @@
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import type { Rect, Viewport, XYPosition } from '../types';
import {
clampViewport,
clampZoom,
contentToScreen,
fitViewTransform,
measureContentRect,
screenToContent,
wheelToZoomFactor,
zoomAtPointer,
} from '../utils';
import { ViewportRoot } from '../index';
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic fixtures (NO Math.random — every value is a closed form of its
// index). Built once at module scope so the benched body does only the hot work.
// ─────────────────────────────────────────────────────────────────────────────
const ORIGIN = { left: 0, top: 0 };
const SURFACE_ORIGIN = { left: 64, top: 40 };
/** A stable viewport with a non-trivial pan + zoom for coordinate math. */
const VIEWPORT: Viewport = { x: 120, y: -48, zoom: 1.5 };
/** Constraints WITHOUT a translate extent (the common pan/zoom clamp). */
const CONSTRAINTS_ZOOM_ONLY = { minZoom: 0.3, maxZoom: 4 } as const;
/** Constraints WITH a translate extent (the boundary-clamped pan case). */
const CONSTRAINTS_EXTENT = {
minZoom: 0.3,
maxZoom: 4,
translateExtent: { minX: -2000, maxX: 2000, minY: -2000, maxY: 2000 },
} as const;
/** Constraints with a DEGENERATE extent on x (min > max → centring branch). */
const CONSTRAINTS_DEGENERATE = {
minZoom: 0.3,
maxZoom: 4,
translateExtent: { minX: 500, maxX: -500, minY: -2000, maxY: 2000 },
} as const;
/** Content bounds for fit-view math (content space). */
const CONTENT_BOUNDS: Rect = { x: 0, y: 0, width: 720, height: 480 };
const SURFACE_SIZE = { width: 400, height: 300 };
/**
* Pre-built point batches at realistic (100) and stress (1000) scale. Each
* coordinate is a deterministic spread of its index so values are non-uniform
* (exercising the divide/multiply paths) without any RNG.
*/
function buildPoints(n: number): XYPosition[] {
const out: XYPosition[] = new Array(n);
for (let i = 0; i < n; i++) {
out[i] = { x: (i * 37) % 1280, y: (i * 53) % 720 };
}
return out;
}
const POINTS_100 = buildPoints(100);
const POINTS_1000 = buildPoints(1000);
/**
* Candidate viewports to clamp, at realistic and stress scale. Each is pushed
* deliberately out of bounds (large pan, out-of-range zoom) so the clamp does
* real work on most entries.
*/
function buildViewports(n: number): Viewport[] {
const out: Viewport[] = new Array(n);
for (let i = 0; i < n; i++) {
out[i] = {
x: ((i * 311) % 8000) - 4000,
y: ((i * 173) % 8000) - 4000,
zoom: 0.1 + ((i * 7) % 50) / 10, // 0.1 .. 5.0
};
}
return out;
}
const VIEWPORTS_100 = buildViewports(100);
const VIEWPORTS_1000 = buildViewports(1000);
/**
* Pre-built WheelEvent fixtures spanning the three deltaMode branches and the
* ctrlKey (pinch) amplifier. Constructed in browser mode (Playwright chromium)
* where the WheelEvent constructor is real.
*/
function buildWheelEvents(n: number): WheelEvent[] {
const out: WheelEvent[] = new Array(n);
for (let i = 0; i < n; i++) {
out[i] = new WheelEvent('wheel', {
deltaY: ((i % 7) - 3) * 40, // -120 .. 120, includes 0
deltaX: ((i % 5) - 2) * 30,
deltaMode: i % 3, // 0 px, 1 line, 2 page
ctrlKey: i % 4 === 0, // ~25% pinch
});
}
return out;
}
const WHEEL_100 = buildWheelEvents(100);
const WHEEL_1000 = buildWheelEvents(1000);
/**
* A simulated pointer-move stream (surface-relative client points) used to drive
* the zoom-at-pointer hot path the same way `useZoomPan`'s wheel handler does:
* read current vp → factor → clamp → zoomAtPointer. One step per fixture entry.
*/
const POINTER_STREAM_100 = buildPoints(100);
const POINTER_STREAM_1000 = buildPoints(1000);
// Sinks to defeat dead-code elimination of pure-function results.
let sinkNum = 0;
let sinkPoint: XYPosition = { x: 0, y: 0 };
let sinkVp: Viewport = { x: 0, y: 0, zoom: 1 };
// ─────────────────────────────────────────────────────────────────────────────
// Pure coordinate math — screen↔content (the per-frame hit-test conversions).
// ─────────────────────────────────────────────────────────────────────────────
describe('screenToContent — over N points', () => {
bench('100 points', () => {
for (let i = 0; i < POINTS_100.length; i++)
sinkPoint = screenToContent(POINTS_100[i], VIEWPORT, SURFACE_ORIGIN);
});
bench('1000 points', () => {
for (let i = 0; i < POINTS_1000.length; i++)
sinkPoint = screenToContent(POINTS_1000[i], VIEWPORT, SURFACE_ORIGIN);
});
});
describe('contentToScreen — over N points', () => {
bench('100 points', () => {
for (let i = 0; i < POINTS_100.length; i++)
sinkPoint = contentToScreen(POINTS_100[i], VIEWPORT, SURFACE_ORIGIN);
});
bench('1000 points', () => {
for (let i = 0; i < POINTS_1000.length; i++)
sinkPoint = contentToScreen(POINTS_1000[i], VIEWPORT, SURFACE_ORIGIN);
});
});
describe('round-trip screen→content→screen — over N points', () => {
bench('100 points', () => {
for (let i = 0; i < POINTS_100.length; i++) {
const c = screenToContent(POINTS_100[i], VIEWPORT, ORIGIN);
sinkPoint = contentToScreen(c, VIEWPORT, ORIGIN);
}
});
bench('1000 points', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
const c = screenToContent(POINTS_1000[i], VIEWPORT, ORIGIN);
sinkPoint = contentToScreen(c, VIEWPORT, ORIGIN);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// zoomAtPointer — the anchored-zoom transform run on every wheel/pinch step.
// ─────────────────────────────────────────────────────────────────────────────
describe('zoomAtPointer — over N anchor points', () => {
bench('100 points', () => {
for (let i = 0; i < POINTS_100.length; i++)
sinkVp = zoomAtPointer(VIEWPORT, VIEWPORT.zoom * 1.1, POINTS_100[i], SURFACE_ORIGIN);
});
bench('1000 points', () => {
for (let i = 0; i < POINTS_1000.length; i++)
sinkVp = zoomAtPointer(VIEWPORT, VIEWPORT.zoom * 1.1, POINTS_1000[i], SURFACE_ORIGIN);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// clampViewport — the per-write clamp. Three branches: zoom-only, with extent,
// and the degenerate (centring) extent. Each at realistic + stress scale.
// ─────────────────────────────────────────────────────────────────────────────
describe('clampViewport — zoom-only (no extent)', () => {
bench('100 viewports', () => {
for (let i = 0; i < VIEWPORTS_100.length; i++)
sinkVp = clampViewport(VIEWPORTS_100[i], CONSTRAINTS_ZOOM_ONLY);
});
bench('1000 viewports', () => {
for (let i = 0; i < VIEWPORTS_1000.length; i++)
sinkVp = clampViewport(VIEWPORTS_1000[i], CONSTRAINTS_ZOOM_ONLY);
});
});
describe('clampViewport — with translate extent', () => {
bench('100 viewports', () => {
for (let i = 0; i < VIEWPORTS_100.length; i++)
sinkVp = clampViewport(VIEWPORTS_100[i], CONSTRAINTS_EXTENT);
});
bench('1000 viewports', () => {
for (let i = 0; i < VIEWPORTS_1000.length; i++)
sinkVp = clampViewport(VIEWPORTS_1000[i], CONSTRAINTS_EXTENT);
});
});
describe('clampViewport — degenerate extent (centring branch)', () => {
bench('100 viewports', () => {
for (let i = 0; i < VIEWPORTS_100.length; i++)
sinkVp = clampViewport(VIEWPORTS_100[i], CONSTRAINTS_DEGENERATE);
});
bench('1000 viewports', () => {
for (let i = 0; i < VIEWPORTS_1000.length; i++)
sinkVp = clampViewport(VIEWPORTS_1000[i], CONSTRAINTS_DEGENERATE);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// wheelToZoomFactor — normalises deltaMode + ctrlKey on every wheel event.
// ─────────────────────────────────────────────────────────────────────────────
describe('wheelToZoomFactor — over N wheel events', () => {
bench('100 events', () => {
for (let i = 0; i < WHEEL_100.length; i++)
sinkNum += wheelToZoomFactor(WHEEL_100[i]);
});
bench('1000 events', () => {
for (let i = 0; i < WHEEL_1000.length; i++)
sinkNum += wheelToZoomFactor(WHEEL_1000[i]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Simulated wheel-zoom pipeline — the exact composition useZoomPan runs per
// wheel event: factor → clampZoom → zoomAtPointer (skipping the boundary no-op).
// ─────────────────────────────────────────────────────────────────────────────
describe('wheel-zoom pipeline (factor → clamp → zoomAtPointer)', () => {
bench('100 steps', () => {
let vp = VIEWPORT;
for (let i = 0; i < WHEEL_100.length; i++) {
const factor = wheelToZoomFactor(WHEEL_100[i]);
const next = clampZoom(vp.zoom * factor, CONSTRAINTS_ZOOM_ONLY.minZoom, CONSTRAINTS_ZOOM_ONLY.maxZoom);
if (next === vp.zoom) continue;
vp = zoomAtPointer(vp, next, POINTER_STREAM_100[i], SURFACE_ORIGIN);
}
sinkVp = vp;
});
bench('1000 steps', () => {
let vp = VIEWPORT;
for (let i = 0; i < WHEEL_1000.length; i++) {
const factor = wheelToZoomFactor(WHEEL_1000[i]);
const next = clampZoom(vp.zoom * factor, CONSTRAINTS_ZOOM_ONLY.minZoom, CONSTRAINTS_ZOOM_ONLY.maxZoom);
if (next === vp.zoom) continue;
vp = zoomAtPointer(vp, next, POINTER_STREAM_1000[i], SURFACE_ORIGIN);
}
sinkVp = vp;
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Simulated drag-pan move stream — the onMove body: translate from a base vp by
// the accumulated screen delta, then clamp. One scheduled write per move.
// ─────────────────────────────────────────────────────────────────────────────
describe('drag-pan move (translate + clamp)', () => {
bench('100 moves', () => {
const base = VIEWPORT;
for (let i = 0; i < POINTER_STREAM_100.length; i++) {
sinkVp = clampViewport(
{ zoom: base.zoom, x: base.x + POINTER_STREAM_100[i].x, y: base.y + POINTER_STREAM_100[i].y },
CONSTRAINTS_EXTENT,
);
}
});
bench('1000 moves', () => {
const base = VIEWPORT;
for (let i = 0; i < POINTER_STREAM_1000.length; i++) {
sinkVp = clampViewport(
{ zoom: base.zoom, x: base.x + POINTER_STREAM_1000[i].x, y: base.y + POINTER_STREAM_1000[i].y },
CONSTRAINTS_EXTENT,
);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// fitViewTransform — fit-to-view math (zoom + centre). Once per fit, but cheap
// enough that a tight loop gives a stable baseline.
// ─────────────────────────────────────────────────────────────────────────────
describe('fitViewTransform', () => {
bench('single fit', () => {
sinkVp = fitViewTransform(CONTENT_BOUNDS, SURFACE_SIZE, { padding: 0.1, minZoom: 0.3, maxZoom: 4 });
});
bench('100 fits', () => {
for (let i = 0; i < 100; i++)
sinkVp = fitViewTransform(CONTENT_BOUNDS, SURFACE_SIZE, { padding: 0.1, minZoom: 0.3, maxZoom: 4 });
});
});
// ─────────────────────────────────────────────────────────────────────────────
// measureContentRect — reads a real DOM rect (browser mode) and divides out zoom.
// A single live element measured repeatedly; the getBoundingClientRect read is
// the dominant cost, so this captures the measure hot path under real layout.
// ─────────────────────────────────────────────────────────────────────────────
const measureEl = document.createElement('div');
measureEl.style.cssText = 'position:absolute;left:0;top:0;width:200px;height:120px;';
document.body.appendChild(measureEl);
describe('measureContentRect (real getBoundingClientRect)', () => {
bench('100 measurements', () => {
for (let i = 0; i < 100; i++)
sinkVp = measureContentRect(measureEl, VIEWPORT, SURFACE_ORIGIN) as unknown as Viewport;
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Component: ViewportRoot mount with N content tiles (realistic 50, stress 500).
// Mirrors demo.vue: a single transformed content layer holding a grid of tiles.
// ─────────────────────────────────────────────────────────────────────────────
const CONTENT_EXTENT = { x: 0, y: 0, width: 720, height: 480 };
function makeTiles(n: number) {
return () =>
h(
'div',
{ style: 'display:grid;grid-template-columns:repeat(6,110px);gap:10px;width:720px;' },
Array.from({ length: n }, (_, i) =>
h('div', { key: i, style: 'height:110px;display:grid;place-items:center;' }, String(i)),
),
);
}
const tiles50 = makeTiles(50);
const tiles500 = makeTiles(500);
function mountRoot(tiles: () => ReturnType<typeof h>, viewport: Viewport) {
const Harness = defineComponent({
setup() {
return () =>
h(
ViewportRoot,
{
viewport,
'min-zoom': 0.3,
'max-zoom': 4,
'content-extent': CONTENT_EXTENT,
style: 'width:400px;height:300px;position:relative;overflow:hidden;',
},
{ default: tiles },
);
},
});
return mount(Harness, { attachTo: document.body });
}
describe('ViewportRoot — mount with N tiles', () => {
bench('50 tiles — mount + unmount', () => {
const w = mountRoot(tiles50, { x: 40, y: 40, zoom: 1 });
w.unmount();
});
bench('500 tiles — mount + unmount', () => {
const w = mountRoot(tiles500, { x: 40, y: 40, zoom: 1 });
w.unmount();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Component: re-render after a viewport prop change (the transform-update path —
// what every pan/zoom frame ultimately drives through the content layer).
// ─────────────────────────────────────────────────────────────────────────────
describe('ViewportRoot — update after viewport prop change', () => {
bench('50 tiles — mount + viewport update', async () => {
const viewport = ref<Viewport>({ x: 40, y: 40, zoom: 1 });
const Harness = defineComponent({
setup() {
return () =>
h(
ViewportRoot,
{
viewport: viewport.value,
'min-zoom': 0.3,
'max-zoom': 4,
'content-extent': CONTENT_EXTENT,
style: 'width:400px;height:300px;position:relative;overflow:hidden;',
},
{ default: tiles50 },
);
},
});
const w = mount(Harness, { attachTo: document.body });
viewport.value = { x: 80, y: -20, zoom: 1.75 };
await nextTick();
w.unmount();
});
bench('50 tiles — mount + minZoom prop update', async () => {
const w = mountRoot(tiles50, { x: 40, y: 40, zoom: 1 });
await w.setProps({});
await w.findComponent(ViewportRoot).setProps({ minZoom: 0.8 });
await nextTick();
w.unmount();
});
});
@@ -0,0 +1,240 @@
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick, ref, render } from 'vue';
import type { HSVA } from '../../../internal/color';
import { clampChannel, hsvToRgb, hsvaToCss } from '../../../internal/color';
import { useHsvaSetters } from '../../color-field/useColorState';
import { ColorAreaRoot, ColorAreaThumb } from '../index';
// ---------------------------------------------------------------------------
// Fixtures — deterministic, no Math.random / no network. The color area's hot
// path is per-pointer-move 2D saturation/value math + the preserve-hue setters
// that commit a fresh HSVA on every drag tick, plus the `hsvToRgb` background
// recompute. We seed every value by index/formula so runs are reproducible.
// ---------------------------------------------------------------------------
/** Build N deterministic HSVA samples sweeping hue/sat/val across their ranges. */
function makeColors(n: number): HSVA[] {
const out: HSVA[] = new Array(n);
for (let i = 0; i < n; i++) {
out[i] = {
h: (i * 360) / n, // 0 → 360 across the hue wheel (hits every sector)
s: (i % 100) / 100, // 0 → 0.99 (includes the grey s=0 edge)
v: ((i * 7) % 100) / 100, // de-correlated brightness sweep
a: 1,
};
}
return out;
}
const colors100 = makeColors(100);
const colors1000 = makeColors(1000);
/**
* Deterministic pointer samples relative to a 320x240 track rect, mimicking a
* drag that sweeps the whole square (the input to `setFromPointer`).
*/
const TRACK = { left: 16, top: 24, width: 320, height: 240 } as const;
function makePointers(n: number): Array<{ x: number; y: number }> {
const out = new Array<{ x: number; y: number }>(n);
for (let i = 0; i < n; i++) {
// Lissajous-ish deterministic sweep covering the rect (and slightly past it
// so clamping is exercised) without any randomness.
const t = i / n;
out[i] = {
x: TRACK.left + (Math.sin(i * 0.37) * 0.5 + 0.5) * TRACK.width,
y: TRACK.top + t * TRACK.height,
};
}
return out;
}
const pointers100 = makePointers(100);
const pointers1000 = makePointers(1000);
/**
* Pure replica of `ColorAreaRoot.setFromPointer`'s math (rect-relative
* normalize optional RTL flip clamp) so the pointer hot path is measured
* without DOM/layout noise. Returns the resolved saturation/value pair.
*/
function pointerToSV(
pt: { x: number; y: number },
rect: { left: number; top: number; width: number; height: number },
rtl: boolean,
): { s: number; v: number } {
if (rect.width === 0 || rect.height === 0) return { s: 0, v: 0 };
let sx = (pt.x - rect.left) / rect.width;
if (rtl) sx = 1 - sx;
const vy = 1 - (pt.y - rect.top) / rect.height;
return { s: clampChannel(sx, 1), v: clampChannel(vy, 1) };
}
// ---------------------------------------------------------------------------
// Pure pointer/clamp math — the per-move computation, by scale.
// ---------------------------------------------------------------------------
describe('pointer → saturation/value math', () => {
bench('pointerToSV — 100 moves (ltr)', () => {
for (let i = 0; i < pointers100.length; i++) {
pointerToSV(pointers100[i]!, TRACK, false);
}
});
bench('pointerToSV — 1000 moves (ltr)', () => {
for (let i = 0; i < pointers1000.length; i++) {
pointerToSV(pointers1000[i]!, TRACK, false);
}
});
bench('pointerToSV — 1000 moves (rtl flip)', () => {
for (let i = 0; i < pointers1000.length; i++) {
pointerToSV(pointers1000[i]!, TRACK, true);
}
});
});
describe('clampChannel — channel clamp', () => {
bench('clampChannel — 1000 calls', () => {
for (let i = 0; i < pointers1000.length; i++) {
// Drive past both rails to exercise both clamp branches deterministically.
clampChannel((i - 250) / 500, 1);
}
});
});
// ---------------------------------------------------------------------------
// hsvToRgb — the background-hue recompute (`hueColor`) + thumb swatch color.
// Runs on every hue change; sweeps all six hue sectors.
// ---------------------------------------------------------------------------
describe('hsvToRgb — hue background recompute', () => {
bench('hsvToRgb — 100 colors', () => {
for (let i = 0; i < colors100.length; i++) {
hsvToRgb({ h: colors100[i]!.h, s: 1, v: 1 });
}
});
bench('hsvToRgb — 1000 colors', () => {
for (let i = 0; i < colors1000.length; i++) {
hsvToRgb({ h: colors1000[i]!.h, s: 1, v: 1 });
}
});
bench('hsvaToCss — 1000 colors (full hsva)', () => {
for (let i = 0; i < colors1000.length; i++) {
hsvaToCss(colors1000[i]!);
}
});
});
// ---------------------------------------------------------------------------
// Preserve-hue setters — the state commit run on every drag tick / key nudge.
// `useHsvaSetters` tracks the last meaningful hue and rebuilds a fresh HSVA.
// We drive it through a deterministic sweep including the s=0 / v=0 grey edges.
// ---------------------------------------------------------------------------
describe('preserve-hue setters — drag/key commit', () => {
bench('setSaturationValue — 1000 commits (sweep incl. grey)', () => {
const hsva = ref<HSVA>({ h: 0, s: 1, v: 1, a: 1 });
const { setSaturationValue } = useHsvaSetters(hsva);
for (let i = 0; i < pointers1000.length; i++) {
const { s, v } = pointerToSV(pointers1000[i]!, TRACK, false);
setSaturationValue(s, v);
}
});
bench('setSaturation + setValue — 1000 key nudges', () => {
const hsva = ref<HSVA>({ h: 200, s: 0.5, v: 0.5, a: 1 });
const { setSaturation, setValue } = useHsvaSetters(hsva);
for (let i = 0; i < colors1000.length; i++) {
setSaturation(colors1000[i]!.s);
setValue(colors1000[i]!.v);
}
});
});
// ---------------------------------------------------------------------------
// Component mount — ColorAreaRoot + N ColorAreaThumb children. A single area
// normally has one thumb, but stacking N thumbs over the shared context stresses
// the provide/inject + per-thumb position/aria computeds at realistic (50) and
// heavy (500) scale.
// ---------------------------------------------------------------------------
function makeAreaWithThumbs(thumbCount: number, value: HSVA) {
return defineComponent({
setup() {
const model = ref<HSVA>(value);
return () =>
h(
ColorAreaRoot,
{
modelValue: model.value,
'onUpdate:modelValue': (v: HSVA | null) => {
if (v) model.value = v;
},
},
{
default: () => {
const thumbs = new Array(thumbCount);
for (let i = 0; i < thumbCount; i++) {
thumbs[i] = h(ColorAreaThumb, {
key: i,
'aria-label': `thumb-${i}`,
});
}
return thumbs;
},
},
);
},
});
}
describe('mount — ColorAreaRoot + N thumbs', () => {
const seed: HSVA = { h: 265, s: 0.72, v: 0.86, a: 1 };
bench('mount + unmount — 50 thumbs', () => {
const container = document.createElement('div');
render(h(makeAreaWithThumbs(50, seed)), container);
render(null, container);
});
bench('mount + unmount — 500 thumbs', () => {
const container = document.createElement('div');
render(h(makeAreaWithThumbs(500, seed)), container);
render(null, container);
});
});
// ---------------------------------------------------------------------------
// Re-render after a v-model change — the realistic interaction tick: updating
// the bound HSVA re-runs `hueColor` (hsvToRgb), the thumb `positionStyle`, and
// the `aria-valuetext`/`aria-valuenow` computeds, then patches the DOM.
// ---------------------------------------------------------------------------
describe('update — re-render after HSVA change', () => {
const a: HSVA = { h: 10, s: 0.3, v: 0.9, a: 1 };
const b: HSVA = { h: 280, s: 0.85, v: 0.4, a: 1 };
bench('1 thumb — mount then patch new HSVA', async () => {
const model = ref<HSVA>(a);
const Comp = defineComponent({
setup: () => () =>
h(
ColorAreaRoot,
{
modelValue: model.value,
'onUpdate:modelValue': (v: HSVA | null) => {
if (v) model.value = v;
},
},
{ default: () => h(ColorAreaThumb, { 'aria-label': 'thumb' }) },
),
});
const container = document.createElement('div');
render(h(Comp), container);
model.value = model.value === a ? b : a;
await nextTick();
render(null, container);
});
});
@@ -0,0 +1,313 @@
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick, render, shallowRef } from 'vue';
import { computeFrame, resolveAxisLock, usePointerDrag } from '..';
import type { DragBounds, DragModifiers, EffectiveAxis, Point } from '..';
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic fixtures (NO Math.random — every value is a pure formula of its
// index). Built once at module scope so bench bodies stay allocation-light.
// ─────────────────────────────────────────────────────────────────────────────
const ORIGIN: Point = { x: 0, y: 0 };
const NO_MOD: DragModifiers = { shift: false, alt: false, ctrl: false, meta: false };
const SHIFT: DragModifiers = { shift: true, alt: false, ctrl: false, meta: false };
const BOUNDS: DragBounds = { minX: -500, maxX: 500, minY: -500, maxY: 500 };
/** A tracked element rect (computeFrame only reads `.left` / `.top`). */
const RECT = { left: 120, top: 240 } as DOMRect;
/**
* Build a deterministic stream of pointer positions emulating one drag gesture.
* The x/y describe a slewing diagonal sweep so axis-lock, snap, and clamp all
* exercise non-trivial branches across the stream.
*/
function buildPointerStream(n: number): Point[] {
const out: Point[] = new Array(n);
for (let i = 0; i < n; i++) {
// Coupled but non-degenerate per-axis growth; integers keep snap meaningful.
out[i] = {
x: ((i * 7) % 211) - 105 + (i % 3),
y: ((i * 13) % 197) - 98 + (i % 5),
};
}
return out;
}
const STREAM_100 = buildPointerStream(100);
const STREAM_1000 = buildPointerStream(1000);
/** A matching stream of modifier snapshots so shift-lock fires on a 1/4 cadence. */
function buildModifierStream(n: number): DragModifiers[] {
const out: DragModifiers[] = new Array(n);
for (let i = 0; i < n; i++) out[i] = i % 4 === 0 ? SHIFT : NO_MOD;
return out;
}
const MODS_100 = buildModifierStream(100);
const MODS_1000 = buildModifierStream(1000);
// ─────────────────────────────────────────────────────────────────────────────
// resolveAxisLock — the per-frame axis-lock decision (called once per flush).
// ─────────────────────────────────────────────────────────────────────────────
describe('resolveAxisLock — per-frame axis decision', () => {
bench('static axis "x" — fast path (100 frames)', () => {
for (let i = 0; i < 100; i++) resolveAxisLock('x', true, MODS_100[i]!, STREAM_100[i]!);
});
bench('axis "both", no shift-lock (100 frames)', () => {
for (let i = 0; i < 100; i++) resolveAxisLock('both', false, MODS_100[i]!, STREAM_100[i]!);
});
bench('axis "both" + shift-lock dominant-axis pick (100 frames)', () => {
for (let i = 0; i < 100; i++) resolveAxisLock('both', true, MODS_100[i]!, STREAM_100[i]!);
});
bench('axis "both" + shift-lock dominant-axis pick (1000 frames)', () => {
for (let i = 0; i < 1000; i++) resolveAxisLock('both', true, MODS_1000[i]!, STREAM_1000[i]!);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// computeFrame — the pure per-frame math (raw total → axis lock → snap → clamp).
// This is THE hot path: one call per coalesced rAF flush per active gesture.
// ─────────────────────────────────────────────────────────────────────────────
describe('computeFrame — single frame (feature on/off matrix)', () => {
bench('free move, no snap/bounds/rect', () => {
computeFrame({
start: ORIGIN,
last: { x: 137, y: 211 },
rect: undefined,
axis: 'none',
snapGrid: undefined,
bounds: undefined,
prevTotal: ORIGIN,
});
});
bench('axis-locked + scalar snap + bounds + rect (all features)', () => {
computeFrame({
start: ORIGIN,
last: { x: 137, y: 211 },
rect: RECT,
axis: 'x',
snapGrid: 10,
bounds: BOUNDS,
prevTotal: { x: 50, y: 0 },
});
});
bench('tuple snap + bounds (per-axis grid)', () => {
computeFrame({
start: ORIGIN,
last: { x: 137, y: 211 },
rect: RECT,
axis: 'none',
snapGrid: [10, 25],
bounds: BOUNDS,
prevTotal: { x: 40, y: 60 },
});
});
});
describe('computeFrame — full gesture stream', () => {
bench('100 frames — free move (no snap/bounds)', () => {
const prev: Point = { x: 0, y: 0 };
for (let i = 0; i < 100; i++) {
const f = computeFrame({
start: ORIGIN,
last: STREAM_100[i]!,
rect: undefined,
axis: 'none',
snapGrid: undefined,
bounds: undefined,
prevTotal: prev,
});
prev.x = f.total.x;
prev.y = f.total.y;
}
});
bench('100 frames — snap + bounds + rect', () => {
const prev: Point = { x: 0, y: 0 };
for (let i = 0; i < 100; i++) {
const f = computeFrame({
start: ORIGIN,
last: STREAM_100[i]!,
rect: RECT,
axis: 'none',
snapGrid: 10,
bounds: BOUNDS,
prevTotal: prev,
});
prev.x = f.total.x;
prev.y = f.total.y;
}
});
bench('1000 frames — snap + bounds + rect (stress)', () => {
const prev: Point = { x: 0, y: 0 };
for (let i = 0; i < 1000; i++) {
const f = computeFrame({
start: ORIGIN,
last: STREAM_1000[i]!,
rect: RECT,
axis: 'none',
snapGrid: 10,
bounds: BOUNDS,
prevTotal: prev,
});
prev.x = f.total.x;
prev.y = f.total.y;
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Simulated flush pipeline — mirrors usePointerDrag's `flush()`: derive the raw
// total, resolveAxisLock against live modifiers, then computeFrame. This is the
// real per-pointermove cost minus the reactive write-back.
// ─────────────────────────────────────────────────────────────────────────────
function simulateFlush(
stream: Point[],
mods: DragModifiers[],
withFeatures: boolean,
): Point {
const start: Point = { x: 0, y: 0 };
const prev: Point = { x: 0, y: 0 };
const snapGrid = withFeatures ? 10 : undefined;
const bounds = withFeatures ? BOUNDS : undefined;
const rect = withFeatures ? RECT : undefined;
for (let i = 0; i < stream.length; i++) {
const last = stream[i]!;
const rawTotal: Point = { x: last.x - start.x, y: last.y - start.y };
const effectiveAxis: EffectiveAxis = resolveAxisLock('both', true, mods[i]!, rawTotal);
const frame = computeFrame({ start, last, rect, axis: effectiveAxis, snapGrid, bounds, prevTotal: prev });
prev.x = frame.total.x;
prev.y = frame.total.y;
}
return prev;
}
describe('simulated flush() pipeline — resolveAxisLock + computeFrame', () => {
bench('100 moves — shift-lock, no snap/bounds', () => {
simulateFlush(STREAM_100, MODS_100, false);
});
bench('100 moves — shift-lock + snap + bounds + rect', () => {
simulateFlush(STREAM_100, MODS_100, true);
});
bench('1000 moves — shift-lock + snap + bounds + rect (stress)', () => {
simulateFlush(STREAM_1000, MODS_1000, true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Component setup cost — usePointerDrag must run inside a component scope
// (reactive() + onScopeDispose). Bench the mount of N draggable instances, the
// realistic editor scale where one canvas hosts many independent drag handles.
// ─────────────────────────────────────────────────────────────────────────────
const DRAG_OPTIONS = {
axis: 'both',
lockAxisOnShift: true,
threshold: 3,
snapGrid: 10,
bounds: BOUNDS,
trackElementRect: true,
} as const;
/** A host that wires N independent usePointerDrag instances, one per handle. */
const DraggableHost = defineComponent({
props: { count: { type: Number, required: true } },
setup(props) {
const refs: Array<ReturnType<typeof shallowRef<HTMLElement | null>>> = [];
for (let i = 0; i < props.count; i++) {
const el = shallowRef<HTMLElement | null>(null);
usePointerDrag(el, DRAG_OPTIONS);
refs.push(el);
}
return () =>
h(
'div',
refs.map((el, i) => h('div', { ref: el, key: i, style: 'width:16px;height:16px;' })),
);
},
});
describe('usePointerDrag — mount N instances', () => {
bench('mount 50 draggable handles', () => {
const container = document.createElement('div');
render(h(DraggableHost, { count: 50 }), container);
render(null, container);
});
bench('mount 500 draggable handles (stress)', () => {
const container = document.createElement('div');
render(h(DraggableHost, { count: 500 }), container);
render(null, container);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Re-render after a prop change — remount the host at a new count to measure the
// teardown (onScopeDispose) + re-setup cost of the drag wiring under churn.
// ─────────────────────────────────────────────────────────────────────────────
describe('usePointerDrag — update after prop change', () => {
bench('50 handles → re-render to 60 handles', () => {
const container = document.createElement('div');
render(h(DraggableHost, { count: 50 }), container);
render(h(DraggableHost, { count: 60 }), container);
render(null, container);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Realistic single-handle drag round-trip via the live composable: real pointer
// events through the captured window listeners + rAF-coalesced flush.
// ─────────────────────────────────────────────────────────────────────────────
function dispatch(el: Element, type: string, x: number, y: number): void {
el.dispatchEvent(
new PointerEvent(type, { pointerId: 1, button: 0, clientX: x, clientY: y, bubbles: true, cancelable: true }),
);
}
function raf(): Promise<void> {
return new Promise(resolve => requestAnimationFrame(() => resolve()));
}
describe('usePointerDrag — live event round-trip (rAF-coalesced)', () => {
bench('mount + down + 20 moves + up', async () => {
const el = shallowRef<HTMLElement | null>(null);
const Harness = defineComponent({
setup() {
usePointerDrag(el, DRAG_OPTIONS);
return () => h('div', { ref: el, style: 'width:200px;height:200px;' });
},
});
const container = document.createElement('div');
document.body.appendChild(container);
render(h(Harness), container);
await nextTick();
const node = el.value!;
dispatch(node, 'pointerdown', 0, 0);
for (let i = 0; i < 20; i++) {
const p = STREAM_100[i]!;
dispatch(node, 'pointermove', p.x, p.y);
await raf(); // one coalesced flush per burst, as the composable schedules
}
dispatch(node, 'pointerup', STREAM_100[19]!.x, STREAM_100[19]!.y);
await raf();
render(null, container);
container.remove();
});
});
@@ -0,0 +1,66 @@
import { bench, describe } from 'vitest';
import { reactive } from 'vue';
// Isolates the core of the usePointerDrag fix. The drag state used to be a deep
// `reactive()` object whose ~13 nested fields are rewritten on every animation
// frame of a drag (see flush()), each assignment paying a Proxy set-trap plus a
// subscriber-less trigger. The fix makes it a plain object. This benches ONLY the
// per-frame writes (state allocated once at module scope, so the proxy-creation
// cost is excluded) — a deterministic, noise-immune view of what changed.
interface Pt { x: number; y: number }
interface DragStateShape {
startPoint: Pt;
point: Pt;
elementPoint: Pt;
delta: Pt;
total: Pt;
axis: string;
modifiers: { shift: boolean; alt: boolean; ctrl: boolean; meta: boolean };
pointerId: number;
pointerType: string;
}
function makeShape(): DragStateShape {
return {
startPoint: { x: 0, y: 0 },
point: { x: 0, y: 0 },
elementPoint: { x: 0, y: 0 },
delta: { x: 0, y: 0 },
total: { x: 0, y: 0 },
axis: 'none',
modifiers: { shift: false, alt: false, ctrl: false, meta: false },
pointerId: -1,
pointerType: '',
};
}
/** Mirror flush()'s per-frame field writes (the ~13 assignments per frame). */
function writeFrame(s: DragStateShape, i: number): void {
s.point.x = i;
s.point.y = i;
s.elementPoint.x = i;
s.elementPoint.y = i;
s.delta.x = 1;
s.delta.y = 1;
s.total.x = i;
s.total.y = i;
s.axis = i % 2 ? 'x' : 'none';
s.modifiers.shift = (i & 1) === 0;
}
// Allocated ONCE so the bench measures per-frame writes, not proxy construction.
const reactiveState = reactive(makeShape());
const plainState = makeShape();
const FRAMES = 1000;
describe('drag-state per-frame writes — OLD reactive() vs NEW plain object', () => {
bench('OLD — reactive() state · 1000 frames', () => {
for (let i = 0; i < FRAMES; i++) writeFrame(reactiveState, i);
});
bench('NEW — plain object state · 1000 frames', () => {
for (let i = 0; i < FRAMES; i++) writeFrame(plainState, i);
});
});
@@ -0,0 +1,155 @@
import { bench, describe } from 'vitest';
import { Comment, cloneVNode, createVNode, h, render } from 'vue';
import { Primitive, Slot } from '..';
const attrs1 = { class: 'a' };
const attrs5 = { class: 'a', id: 'b', role: 'button', tabindex: '0', title: 'tip' };
const attrs15 = {
class: 'a',
id: 'b',
style: { color: 'red' },
onClick: () => {},
role: 'button',
tabindex: '0',
title: 'tip',
'data-a': '1',
'data-b': '2',
'data-c': '3',
'data-d': '4',
'data-e': '5',
'data-f': '6',
'data-g': '7',
'data-h': '8',
};
const defaultSlot = { default: () => [h('span', 'content')] };
const noop = () => {};
// ---- Baselines (raw Vue calls) ----
describe('baseline: raw h()', () => {
bench('h() — 1 attr', () => {
h('div', attrs1, defaultSlot);
});
bench('h() — 5 attrs', () => {
h('div', attrs5, defaultSlot);
});
bench('h() — 15 attrs', () => {
h('div', attrs15, defaultSlot);
});
});
describe('baseline: raw cloneVNode()', () => {
const child = h('div', 'content');
bench('cloneVNode — 1 attr', () => {
cloneVNode(child, attrs1, true);
});
bench('cloneVNode — 5 attrs', () => {
cloneVNode(child, attrs5, true);
});
bench('cloneVNode — 15 attrs', () => {
cloneVNode(child, attrs15, true);
});
});
// ---- Primitive overhead vs raw h() ----
describe('Primitive vs h()', () => {
bench('h("div") — baseline', () => {
h('div', attrs5, defaultSlot);
});
bench('Primitive({ as: "div" })', () => {
Primitive({ as: 'div' }, { attrs: attrs5, slots: defaultSlot, emit: noop });
});
bench('Primitive({ as: "template" }) — Slot mode', () => {
Primitive({ as: 'template' }, { attrs: attrs5, slots: defaultSlot, emit: noop });
});
});
// ---- Slot scaling by attribute count ----
describe('Slot — scaling by attrs', () => {
bench('1 attr', () => {
Slot({} as never, { attrs: attrs1, slots: defaultSlot, emit: noop });
});
bench('5 attrs', () => {
Slot({} as never, { attrs: attrs5, slots: defaultSlot, emit: noop });
});
bench('15 attrs (mixed types)', () => {
Slot({} as never, { attrs: attrs15, slots: defaultSlot, emit: noop });
});
});
// ---- Slot edge cases ----
describe('Slot — edge cases', () => {
bench('child with comments to skip', () => {
Slot({} as never, {
attrs: attrs5,
slots: {
default: () => [
createVNode(Comment, null, 'skip'),
createVNode(Comment, null, 'skip'),
h('span', 'content'),
],
},
emit: noop,
});
});
bench('no default slot', () => {
Slot({} as never, { attrs: attrs5, slots: {}, emit: noop });
});
});
// ---- Slot — realistic attrs (fresh object per iteration) ----
describe('Slot — fresh attrs per call', () => {
bench('5 attrs (stable ref)', () => {
Slot({} as never, { attrs: attrs5, slots: defaultSlot, emit: noop });
});
bench('5 attrs (new object)', () => {
Slot({} as never, {
attrs: { class: 'a', id: 'b', role: 'button', tabindex: '0', title: 'tip' },
slots: defaultSlot,
emit: noop,
});
});
});
// ---- Realistic runtime: mount + update via render() ----
describe('Primitive — mount + update via render()', () => {
bench('h("div") — mount + update', () => {
const container = document.createElement('div');
render(h('div', attrs5, [h('span', 'content')]), container);
render(h('div', attrs15, [h('span', 'content')]), container);
render(null, container);
});
bench('Primitive({ as: "div" }) — mount + update', () => {
const container = document.createElement('div');
render(h(Primitive, { as: 'div', ...attrs5 }, defaultSlot), container);
render(h(Primitive, { as: 'div', ...attrs15 }, defaultSlot), container);
render(null, container);
});
bench('Primitive({ as: "template" }) — mount + update', () => {
const container = document.createElement('div');
render(h(Primitive, { as: 'template', ...attrs5 }, defaultSlot), container);
render(h(Primitive, { as: 'template', ...attrs15 }, defaultSlot), container);
render(null, container);
});
});
@@ -0,0 +1,324 @@
import { bench, describe } from 'vitest';
import { ref } from 'vue';
import {
formatClock,
formatFrames,
formatTimecode,
frameTicks,
framesToTimecode,
getClosestValueIndex,
getStepDecimals,
hasMinStepsBetweenSortedValues,
niceNum,
niceTicks,
roundToStep,
scaleLinear,
secondsToFrames,
timeTicks,
timecodeTicks,
useScale,
} from '..';
// ---------------------------------------------------------------------------
// Deterministic fixtures (NO Math.random — every value seeded by index/formula)
// ---------------------------------------------------------------------------
/** Pointer x-positions sweeping a 1000px range, 100 / 1000 samples. */
function buildPointerPx(n: number, span: number): number[] {
const out = new Array<number>(n);
for (let i = 0; i < n; i++) out[i] = (i / (n - 1)) * span;
return out;
}
/** Raw domain values (e.g. unsnapped seconds) sweeping a domain, 100 / 1000. */
function buildDomainValues(n: number, lo: number, hi: number): number[] {
const out = new Array<number>(n);
const span = hi - lo;
for (let i = 0; i < n; i++) {
// A deterministic non-linear sweep so snapping rounds in both directions.
const t = i / (n - 1);
out[i] = lo + span * (t * t * 0.5 + t * 0.5);
}
return out;
}
/** Sorted, evenly-spaced thumb values for the multi-thumb invariant checks. */
function buildSortedValues(n: number, lo: number, hi: number): number[] {
const out = new Array<number>(n);
const step = (hi - lo) / (n - 1);
for (let i = 0; i < n; i++) out[i] = lo + i * step;
return out;
}
const POINTER_100 = buildPointerPx(100, 1000);
const POINTER_1000 = buildPointerPx(1000, 1000);
const VALUES_100 = buildDomainValues(100, 0, 600);
const VALUES_1000 = buildDomainValues(1000, 0, 600);
const SORTED_100 = buildSortedValues(100, 0, 1000);
const SORTED_1000 = buildSortedValues(1000, 0, 1000);
// Frame numbers for timecode formatting (29.97 drop-frame is the costly path).
const FRAMES_100 = (() => {
const out = Array.from({ length: 100 });
for (let i = 0; i < 100; i++) out[i] = i * 1800; // ~1 min apart at 30fps
return out;
})();
const FRAMES_1000 = (() => {
const out = Array.from({ length: 1000 });
for (let i = 0; i < 1000; i++) out[i] = i * 180;
return out;
})();
// Realistic ruler geometry: 600s timeline at ~1.6px/s over a ~1000px viewport.
const REALISTIC = {
domain: [0, 600] as const,
range: [0, 1000] as const,
};
// Stress geometry: a 10-hour axis at high pixel density (deep tick generation).
const STRESS = {
domain: [0, 36_000] as const,
range: [0, 20_000] as const,
};
const STEP = 0.1;
const STEP_DECIMALS = getStepDecimals(STEP);
// ===========================================================================
// 1. Pure projection math — the pointermove hot path (scaleLinear / snapping)
// ===========================================================================
describe('math: scaleLinear (pointer projection)', () => {
bench('scaleLinear ×100', () => {
for (let i = 0; i < POINTER_100.length; i++) {
scaleLinear(POINTER_100[i]!, 0, 1000, 0, 600);
}
});
bench('scaleLinear ×1000', () => {
for (let i = 0; i < POINTER_1000.length; i++) {
scaleLinear(POINTER_1000[i]!, 0, 1000, 0, 600);
}
});
});
describe('math: roundToStep (snap-to-step hot path)', () => {
bench('roundToStep ×100', () => {
for (let i = 0; i < VALUES_100.length; i++) {
roundToStep(VALUES_100[i]!, STEP, 0, STEP_DECIMALS);
}
});
bench('roundToStep ×1000', () => {
for (let i = 0; i < VALUES_1000.length; i++) {
roundToStep(VALUES_1000[i]!, STEP, 0, STEP_DECIMALS);
}
});
});
describe('math: getStepDecimals (per-step cache miss)', () => {
// Vary the step so String() / indexOf actually run each call.
bench('getStepDecimals ×1000 (varied step)', () => {
for (let i = 0; i < 1000; i++) {
getStepDecimals(1 / 10 ** (i % 6));
}
});
});
describe('math: getClosestValueIndex (nearest-thumb pick)', () => {
bench('100 thumbs ×100 picks', () => {
for (let i = 0; i < POINTER_100.length; i++) {
getClosestValueIndex(SORTED_100, POINTER_100[i]!);
}
});
bench('1000 thumbs ×100 picks', () => {
for (let i = 0; i < POINTER_100.length; i++) {
getClosestValueIndex(SORTED_1000, POINTER_100[i]!);
}
});
});
describe('math: hasMinStepsBetweenSortedValues (drag invariant)', () => {
bench('100 values', () => {
hasMinStepsBetweenSortedValues(SORTED_100, 1, 1);
});
bench('1000 values', () => {
hasMinStepsBetweenSortedValues(SORTED_1000, 1, 1);
});
});
describe('math: niceNum (tick rounding primitive)', () => {
bench('niceNum ×1000 (varied magnitude)', () => {
for (let i = 0; i < 1000; i++) {
// Sweep magnitudes 1e-2 … 1e4 deterministically.
niceNum((1 + (i % 9)) * 10 ** ((i % 7) - 2), i % 2 === 0);
}
});
});
// ===========================================================================
// 2. Tick generators — the most expensive recompute per geometry change
// ===========================================================================
describe('ticks: niceTicks (realistic vs stress)', () => {
bench('realistic (600s axis)', () => {
niceTicks({ domain: REALISTIC.domain, range: REALISTIC.range });
});
bench('stress (10h axis, dense range)', () => {
niceTicks({ domain: STRESS.domain, range: STRESS.range });
});
bench('stress + custom format', () => {
niceTicks({
domain: STRESS.domain,
range: STRESS.range,
format: v => `#${v}`,
});
});
});
describe('ticks: timeTicks (human time ladder)', () => {
bench('realistic (600s axis)', () => {
timeTicks({ domain: REALISTIC.domain, range: REALISTIC.range });
});
bench('stress (10h axis, dense range)', () => {
timeTicks({ domain: STRESS.domain, range: STRESS.range });
});
});
describe('ticks: timecodeTicks (frame-aligned, fps conversion)', () => {
bench('realistic (600s @ 30fps)', () => {
timecodeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, fps: 30 });
});
bench('stress (10h @ 29.97fps drop-frame labels)', () => {
timecodeTicks({ domain: STRESS.domain, range: STRESS.range, fps: 29.97, dropFrame: true });
});
});
describe('ticks: frameTicks (integer-frame axis)', () => {
bench('realistic (18000-frame axis)', () => {
frameTicks({ domain: [0, 18_000], range: REALISTIC.range, fps: 30 });
});
bench('stress (1.08M-frame axis, dense range)', () => {
frameTicks({ domain: [0, 1_080_000], range: STRESS.range, fps: 30 });
});
});
// ===========================================================================
// 3. Timecode formatting — per-tick label cost (drop-frame is the worst case)
// ===========================================================================
describe('timecode: framesToTimecode label formatting', () => {
bench('non-drop ×100', () => {
for (let i = 0; i < FRAMES_100.length; i++) {
framesToTimecode(FRAMES_100[i]!, 30, false);
}
});
bench('drop-frame 29.97 ×100', () => {
for (let i = 0; i < FRAMES_100.length; i++) {
framesToTimecode(FRAMES_100[i]!, 29.97, true);
}
});
bench('drop-frame 29.97 ×1000', () => {
for (let i = 0; i < FRAMES_1000.length; i++) {
framesToTimecode(FRAMES_1000[i]!, 29.97, true);
}
});
});
describe('timecode: scalar label formatters', () => {
bench('formatClock ×1000', () => {
for (let i = 0; i < 1000; i++) formatClock(i * 7.5);
});
bench('formatTimecode ×1000 (@30fps)', () => {
for (let i = 0; i < 1000; i++) formatTimecode(i * 0.5, 30, false);
});
bench('formatFrames ×1000', () => {
for (let i = 0; i < 1000; i++) formatFrames(i * 137);
});
bench('secondsToFrames ×1000', () => {
for (let i = 0; i < 1000; i++) secondsToFrames(i * 0.0417, 23.976);
});
});
// ===========================================================================
// 4. useScale composable — build cost, pointer-move loop, reactive recompute
// ===========================================================================
describe('useScale: composable construction', () => {
bench('build (plain options)', () => {
useScale({ domain: REALISTIC.domain, range: REALISTIC.range });
});
bench('build (clamp + step + ticks)', () => {
useScale({
domain: REALISTIC.domain,
range: REALISTIC.range,
clamp: true,
step: 0.1,
tickKind: 'time',
});
});
});
describe('useScale: pointer-move loop (scale/invert/roundValue)', () => {
// Build once outside the bench body — the hot path is the per-event call,
// not construction. Closures read reactive sources at call time.
const s = useScale({
domain: REALISTIC.domain,
range: REALISTIC.range,
clamp: true,
step: 0.1,
});
bench('invert+round ×100 events', () => {
for (let i = 0; i < POINTER_100.length; i++) {
s.roundValue(s.invert(POINTER_100[i]!));
}
});
bench('invert+round ×1000 events', () => {
for (let i = 0; i < POINTER_1000.length; i++) {
s.roundValue(s.invert(POINTER_1000[i]!));
}
});
bench('scale ×1000 events', () => {
for (let i = 0; i < VALUES_1000.length; i++) {
s.scale(VALUES_1000[i]!);
}
});
});
describe('useScale: reactive tick recompute on domain change (zoom/pan)', () => {
// A reactive domain whose mutation invalidates the ticks computed, simulating
// a zoom/pan gesture forcing tick regeneration + major/minor split.
const domain = ref<readonly [number, number]>([0, 600]);
const s = useScale({ domain, range: REALISTIC.range, tickKind: 'time' });
// Prime the computed.
void s.ticks.value;
let frame = 0;
bench('zoom step → recompute ticks/major/minor', () => {
// Deterministic zoom: shrink/grow the window each iteration.
frame++;
const half = 50 + (frame % 250);
domain.value = [0, half * 2];
// Touch all three dependent computeds (what a ruler renders).
void s.ticks.value.length;
void s.majorTicks.value.length;
void s.minorTicks.value.length;
});
});
@@ -0,0 +1,319 @@
import { bench, describe } from 'vitest';
import {
buildBezierPath,
buildPolylinePath,
buildSmoothPath,
catmullRom,
cubicBezier1D,
cubicBezierTangent,
evalCubicBezier,
linearInterpolate,
monotoneCubic,
sampleFnToPolyline,
sampleToPolyline,
solveBezierX,
toLUT,
} from '../index';
import type { Point } from '../index';
// ---------------------------------------------------------------------------
// Deterministic fixtures (NO Math.random). Values are seeded by index/formula.
// These mirror realistic usage: knot lists for tone/gamma/levels curves, the
// 4 control points of a CSS easing bezier, and pointer coordinates for a drag.
// ---------------------------------------------------------------------------
/** Build `n` knots strictly ascending in x (required by the interpolants). */
function makeKnots(n: number): Point[] {
const out: Point[] = Array.from({ length: n });
for (let i = 0; i < n; i++) {
const x = i / (n - 1); // 0..1, strictly increasing
// A wobbly-but-monotone-ish y so monotoneCubic does real tangent work.
const y = 0.5 + 0.45 * Math.sin(i * 0.7) * (1 - i / (n * 4));
out[i] = { x, y };
}
return out;
}
/** Build `n` 2D points for Catmull-Rom / smooth-path (x need not be sorted). */
function makePath(n: number): Point[] {
const out: Point[] = Array.from({ length: n });
for (let i = 0; i < n; i++) {
const a = i * 0.37;
out[i] = { x: i * 4, y: 120 + 80 * Math.sin(a) + 30 * Math.cos(a * 2.1) };
}
return out;
}
/** Evenly spaced sample parameters in [0,1] for repeated evaluation. */
function makeParams(n: number): number[] {
const out: number[] = Array.from({ length: n });
for (let i = 0; i < n; i++)
out[i] = i / (n - 1);
return out;
}
const knots100 = makeKnots(100);
const knots1000 = makeKnots(1000);
const path50 = makePath(50);
const path500 = makePath(500);
const params100 = makeParams(100);
const params1000 = makeParams(1000);
// CSS "ease" control points — the canonical solveBezierX workload.
const EASE = { x1: 0.42, y1: 0, x2: 0.58, y2: 1 } as const;
// A single cubic bezier segment (e.g. one connector / handle).
const B0: Point = { x: 0, y: 0 };
const B1: Point = { x: 0.25, y: 1 };
const B2: Point = { x: 0.75, y: 0 };
const B3: Point = { x: 1, y: 1 };
// Pre-built smooth-path control points used by the simulated pointer-move bench.
const dragPath = makePath(64);
// ---------------------------------------------------------------------------
// evalCubicBezier — point on a cubic bezier, evaluated over many parameters.
// ---------------------------------------------------------------------------
describe('evalCubicBezier — sweep t', () => {
bench('100 params', () => {
for (let i = 0; i < params100.length; i++)
evalCubicBezier(B0, B1, B2, B3, params100[i]!);
});
bench('1000 params', () => {
for (let i = 0; i < params1000.length; i++)
evalCubicBezier(B0, B1, B2, B3, params1000[i]!);
});
});
// ---------------------------------------------------------------------------
// cubicBezierTangent — derivative/handle direction, evaluated over many t.
// ---------------------------------------------------------------------------
describe('cubicBezierTangent — sweep t', () => {
bench('100 params', () => {
for (let i = 0; i < params100.length; i++)
cubicBezierTangent(B0, B1, B2, B3, params100[i]!);
});
bench('1000 params', () => {
for (let i = 0; i < params1000.length; i++)
cubicBezierTangent(B0, B1, B2, B3, params1000[i]!);
});
});
// ---------------------------------------------------------------------------
// solveBezierX — Newton-Raphson easing solver (x → eased y). The hot path for
// every animation frame that maps progress through a CSS cubic-bezier easing.
// `cubicBezier1D` benched alongside as the scalar primitive it builds on.
// ---------------------------------------------------------------------------
describe('solveBezierX — ease (x→y)', () => {
bench('100 params', () => {
for (let i = 0; i < params100.length; i++)
solveBezierX(EASE.x1, EASE.y1, EASE.x2, EASE.y2, params100[i]!);
});
bench('1000 params', () => {
for (let i = 0; i < params1000.length; i++)
solveBezierX(EASE.x1, EASE.y1, EASE.x2, EASE.y2, params1000[i]!);
});
// Identity easing (x1===y1 && x2===y2) short-circuits — establishes the floor.
bench('1000 params — identity short-circuit', () => {
for (let i = 0; i < params1000.length; i++)
solveBezierX(0, 0, 1, 1, params1000[i]!);
});
});
describe('cubicBezier1D — scalar Bernstein', () => {
bench('1000 params', () => {
for (let i = 0; i < params1000.length; i++)
cubicBezier1D(0, EASE.x1, EASE.x2, 1, params1000[i]!);
});
});
// ---------------------------------------------------------------------------
// catmullRom — spline through N knots, evaluated over many t. Two axes vary:
// knot count (segment locating cost) and sample count (per-frame evaluation).
// ---------------------------------------------------------------------------
describe('catmullRom — sweep t', () => {
bench('50 knots × 100 params', () => {
for (let i = 0; i < params100.length; i++)
catmullRom(path50, params100[i]!);
});
bench('500 knots × 100 params', () => {
for (let i = 0; i < params100.length; i++)
catmullRom(path500, params100[i]!);
});
bench('500 knots × 1000 params', () => {
for (let i = 0; i < params1000.length; i++)
catmullRom(path500, params1000[i]!);
});
bench('500 knots × 1000 params — closed', () => {
for (let i = 0; i < params1000.length; i++)
catmullRom(path500, params1000[i]!, { closed: true });
});
});
// ---------------------------------------------------------------------------
// monotoneCubic — Fritsch-Carlson build (one-time, O(n)) then repeated lookup
// (binary-search per call). This is the real tone/gamma-curve workload: build
// the interpolant once when knots change, then apply it across a LUT every
// frame. Split into "build" and "build + apply 256-LUT" benches.
// ---------------------------------------------------------------------------
describe('monotoneCubic — build', () => {
bench('100 knots', () => {
monotoneCubic(knots100);
});
bench('1000 knots', () => {
monotoneCubic(knots1000);
});
});
describe('monotoneCubic — apply (pre-built fn)', () => {
const f100 = monotoneCubic(knots100);
const f1000 = monotoneCubic(knots1000);
bench('100 knots → 256-LUT', () => {
toLUT(f100, 256);
});
bench('1000 knots → 256-LUT', () => {
toLUT(f1000, 256);
});
// 8-bit channel LUT — the full per-channel color-correction pass.
bench('1000 knots → 1024-LUT', () => {
toLUT(f1000, 1024);
});
});
describe('monotoneCubic — build + apply (knots changed)', () => {
bench('100 knots → build + 256-LUT', () => {
toLUT(monotoneCubic(knots100), 256);
});
bench('1000 knots → build + 256-LUT', () => {
toLUT(monotoneCubic(knots1000), 256);
});
});
// ---------------------------------------------------------------------------
// linearInterpolate — binary-search piecewise-linear lookup over many queries.
// ---------------------------------------------------------------------------
describe('linearInterpolate — query sweep', () => {
bench('100 knots × 1000 queries', () => {
for (let i = 0; i < params1000.length; i++)
linearInterpolate(knots100, params1000[i]!);
});
bench('1000 knots × 1000 queries', () => {
for (let i = 0; i < params1000.length; i++)
linearInterpolate(knots1000, params1000[i]!);
});
});
// ---------------------------------------------------------------------------
// Polyline sampling — turn a parametric/scalar curve into render-ready points.
// ---------------------------------------------------------------------------
describe('sampleToPolyline — bezier curve', () => {
const curve = (t: number): Point => evalCubicBezier(B0, B1, B2, B3, t);
bench('100 segments', () => {
sampleToPolyline(curve, 100);
});
bench('1000 segments', () => {
sampleToPolyline(curve, 1000);
});
});
describe('sampleFnToPolyline — monotone curve', () => {
const f = monotoneCubic(knots100);
bench('100 segments', () => {
sampleFnToPolyline(f, 0, 1, 100);
});
bench('1000 segments', () => {
sampleFnToPolyline(f, 0, 1, 1000);
});
});
// ---------------------------------------------------------------------------
// SVG path string building — runs on every reactive re-render of a curve.
// ---------------------------------------------------------------------------
describe('buildPolylinePath — string concat', () => {
const poly100 = sampleToPolyline((t: number) => evalCubicBezier(B0, B1, B2, B3, t), 100);
const poly1000 = sampleToPolyline((t: number) => evalCubicBezier(B0, B1, B2, B3, t), 1000);
bench('100 points', () => {
buildPolylinePath(poly100);
});
bench('1000 points', () => {
buildPolylinePath(poly1000);
});
});
describe('buildSmoothPath — Catmull-Rom cubics', () => {
bench('50 points', () => {
buildSmoothPath(path50);
});
bench('500 points', () => {
buildSmoothPath(path500);
});
bench('500 points — tension 0.5', () => {
buildSmoothPath(path500, 0.5);
});
});
describe('buildBezierPath — single segment', () => {
bench('1 segment', () => {
buildBezierPath(B0, B1, B2, B3);
});
});
// ---------------------------------------------------------------------------
// Simulated pointer-move: a control point is dragged each frame, the smooth
// path is rebuilt, and a LUT is re-derived from the moved knots. This is the
// end-to-end per-frame cost during an interactive curve/handle drag.
// ---------------------------------------------------------------------------
describe('pointer-move — smooth path rebuild', () => {
bench('drag mutate + buildSmoothPath (64 points)', () => {
// Move one control point along a deterministic path (no Math.random).
const i = 32;
const base = dragPath[i]!;
dragPath[i] = { x: base.x, y: base.y + 1 };
buildSmoothPath(dragPath);
// Restore so the fixture stays stable across iterations.
dragPath[i] = base;
});
});
describe('pointer-move — curve recompute', () => {
// Drag a tone-curve knot → rebuild monotone interpolant → re-apply 256-LUT.
bench('mutate knot + monotoneCubic + 256-LUT (100 knots)', () => {
const i = 50;
const base = knots100[i]!;
knots100[i] = { x: base.x, y: base.y + 0.01 };
toLUT(monotoneCubic(knots100), 256);
knots100[i] = base;
});
});
@@ -0,0 +1,142 @@
import { bench, describe } from 'vitest';
import { Comment, Fragment, createVNode, h, render } from 'vue';
import { PatchFlags } from '@vue/shared';
import { getRawChildren } from '../getRawChildren';
// -- Helpers --
function keyedFragment(children: Array<ReturnType<typeof h>>) {
return createVNode(Fragment, null, children, PatchFlags.KEYED_FRAGMENT);
}
const flatChildren = [h('div'), h('span'), h('p')];
const keyedChildren = Array.from({ length: 10 }, (_, i) =>
h('div', { key: i }, `child-${i}`),
);
// ---- Processing cost ----
describe('getRawChildren', () => {
bench('flat elements', () => {
getRawChildren(flatChildren);
});
bench('mixed elements and comments', () => {
getRawChildren([
createVNode(Comment, null, 'c'),
h('div'),
createVNode(Comment, null, 'c'),
h('span'),
createVNode(Comment, null, 'c'),
]);
});
bench('single fragment with children', () => {
getRawChildren([createVNode(Fragment, null, [h('a'), h('b'), h('c')])]);
});
bench('nested fragments (depth 5)', () => {
let current: ReturnType<typeof h> = h('div');
for (let i = 0; i < 5; i++) {
current = createVNode(Fragment, null, [current, h('span')]);
}
getRawChildren([current]);
});
bench('wide fragment (50 children)', () => {
const children = Array.from({ length: 50 }, (_, i) => h('div', `child-${i}`));
getRawChildren([createVNode(Fragment, null, children)]);
});
});
// ---- BAIL path cost ----
describe('getRawChildren — BAIL path', () => {
bench('1 keyed fragment (no BAIL)', () => {
getRawChildren([keyedFragment([...keyedChildren])]);
});
bench('2 keyed fragments (BAIL triggered)', () => {
getRawChildren([
keyedFragment(keyedChildren.slice(0, 5)),
keyedFragment(keyedChildren.slice(5)),
]);
});
bench('3 keyed fragments (BAIL triggered)', () => {
getRawChildren([
keyedFragment(keyedChildren.slice(0, 3)),
keyedFragment(keyedChildren.slice(3, 7)),
keyedFragment(keyedChildren.slice(7)),
]);
});
});
// ---- Render impact: optimized patchFlags vs BAIL ----
describe('patch — optimized vs BAIL patchFlag', () => {
bench('patch with TEXT patchFlag', () => {
const container = document.createElement('div');
const initial = h('div', null, [
createVNode('span', null, 'a', PatchFlags.TEXT),
createVNode('span', null, 'b', PatchFlags.TEXT),
createVNode('span', null, 'c', PatchFlags.TEXT),
]);
const updated = h('div', null, [
createVNode('span', null, 'x', PatchFlags.TEXT),
createVNode('span', null, 'y', PatchFlags.TEXT),
createVNode('span', null, 'z', PatchFlags.TEXT),
]);
render(initial, container);
render(updated, container);
});
bench('patch with BAIL patchFlag', () => {
const container = document.createElement('div');
const initial = h('div', null, [
createVNode('span', null, 'a', PatchFlags.BAIL),
createVNode('span', null, 'b', PatchFlags.BAIL),
createVNode('span', null, 'c', PatchFlags.BAIL),
]);
const updated = h('div', null, [
createVNode('span', null, 'x', PatchFlags.BAIL),
createVNode('span', null, 'y', PatchFlags.BAIL),
createVNode('span', null, 'z', PatchFlags.BAIL),
]);
render(initial, container);
render(updated, container);
});
bench('patch with CLASS patchFlag', () => {
const container = document.createElement('div');
const initial = h('div', null, [
createVNode('span', { class: 'a' }, null, PatchFlags.CLASS),
createVNode('span', { class: 'b' }, null, PatchFlags.CLASS),
createVNode('span', { class: 'c' }, null, PatchFlags.CLASS),
]);
const updated = h('div', null, [
createVNode('span', { class: 'x' }, null, PatchFlags.CLASS),
createVNode('span', { class: 'y' }, null, PatchFlags.CLASS),
createVNode('span', { class: 'z' }, null, PatchFlags.CLASS),
]);
render(initial, container);
render(updated, container);
});
bench('patch with CLASS→BAIL patchFlag', () => {
const container = document.createElement('div');
const initial = h('div', null, [
createVNode('span', { class: 'a' }, null, PatchFlags.BAIL),
createVNode('span', { class: 'b' }, null, PatchFlags.BAIL),
createVNode('span', { class: 'c' }, null, PatchFlags.BAIL),
]);
const updated = h('div', null, [
createVNode('span', { class: 'x' }, null, PatchFlags.BAIL),
createVNode('span', { class: 'y' }, null, PatchFlags.BAIL),
createVNode('span', { class: 'z' }, null, PatchFlags.BAIL),
]);
render(initial, container);
render(updated, container);
});
});
@@ -0,0 +1,69 @@
import { bench, describe } from 'vitest';
// Baseline for the `getItems()` DOM-order sort in useCollection.ts. `getItems`
// runs per keystroke / per pointer-move across the roving-focus / menu / listbox
// / tree family, so its complexity dominates keyboard-nav and typeahead cost.
//
// This benches the SORT STRATEGY in isolation (the part the fix changed), at the
// list sizes those components realistically reach:
// OLD: comparator calls `orderedNodes.indexOf()` twice per comparison → each
// comparison is O(n), the whole sort O(n² log n).
// NEW: build a `Map<node, index>` once (O(n)), comparator does two O(1)
// lookups → O(n log n) overall.
// Fixtures are real detached elements (the same identity model querySelectorAll
// yields), seeded deterministically — NO Math.random.
interface Item { ref: HTMLElement; value: number }
/** Build `n` detached elements, the DOM-ordered array, and a deterministically
* shuffled `items` list (worst-ish case for a sort: not already ordered). */
function fixture(n: number): { ordered: HTMLElement[]; items: Item[] } {
const ordered: HTMLElement[] = [];
for (let i = 0; i < n; i++) ordered.push(document.createElement('div'));
// Deterministic shuffle: index-based stride so registration order ≠ DOM order.
const items: Item[] = [];
for (let i = 0; i < n; i++) {
const idx = (i * 7 + 3) % n;
items.push({ ref: ordered[idx]!, value: idx });
}
return { ordered, items };
}
/** OLD strategy: indexOf inside the comparator (O(n) per call). */
function sortOld(ordered: HTMLElement[], items: Item[]): Item[] {
const copy = items.slice();
copy.sort((a, b) => ordered.indexOf(a.ref) - ordered.indexOf(b.ref));
return copy;
}
/** NEW strategy: precomputed node→index Map, O(1) per comparison. */
function sortNew(ordered: HTMLElement[], items: Item[]): Item[] {
const copy = items.slice();
if (copy.length > 1) {
const orderByNode = new Map<Element, number>();
for (let i = 0; i < ordered.length; i++) orderByNode.set(ordered[i]!, i);
copy.sort((a, b) => {
const ai = orderByNode.get(a.ref);
const bi = orderByNode.get(b.ref);
return (ai === undefined ? -1 : ai) - (bi === undefined ? -1 : bi);
});
}
return copy;
}
const sizes = [12, 50, 200, 1000];
for (const n of sizes) {
const { ordered, items } = fixture(n);
describe(`getItems sort — ${n} items`, () => {
bench('OLD — indexOf-in-comparator (O(n² log n))', () => {
sortOld(ordered, items);
});
bench('NEW — Map<node,index> lookup (O(n log n))', () => {
sortNew(ordered, items);
});
});
}