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:
@@ -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 4–16 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user