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

Library-wide Vue+V8 perf/leak audit (PERF_AUDIT.md) plus bench baselines for the
hot-path modules (timeline, curve-editor, spline, pointer-drag, collection, etc.).
This commit is contained in:
2026-06-15 16:54:28 +07:00
parent 263c32002f
commit 661a55719e
19 changed files with 6507 additions and 0 deletions
@@ -0,0 +1,465 @@
import type { VueWrapper } from '@vue/test-utils';
import type { CurveEditorAnchor, CurveEditorInterpolation } from '../index';
import { mount } from '@vue/test-utils';
import { bench, describe } from 'vitest';
import { defineComponent, h } from 'vue';
import { CurveEditorCurve, CurveEditorPoint, CurveEditorRoot } from '../index';
import {
anchorsToPoints,
buildEvaluator,
clampAnchorX,
clampAnchorY,
sortAnchors,
} from '../utils';
import {
buildBezierPath,
buildPolylinePath,
catmullRom,
evalCubicBezier,
linearInterpolate,
monotoneCubic,
sampleFnToPolyline,
toLUT,
} from '../../../internal/spline';
// ─── deterministic fixtures (NO Math.random — values seeded by index) ────────
//
// A monotone-in-x anchor set across the [0,1] domain. `x` is strictly
// increasing (i / (n - 1)); `y` is a deterministic gentle S-shape via a
// smoothstep-ish formula so every interpolation mode has real curvature to
// chew on (a straight line would shortcut the monotone/bezier solvers).
function makeAnchors(n: number): CurveEditorAnchor[] {
const out: CurveEditorAnchor[] = Array.from({ length: n });
for (let i = 0; i < n; i++) {
const x = n === 1 ? 0 : i / (n - 1);
// smoothstep S-curve, nudged per-index so segments are not all identical.
const s = x * x * (3 - 2 * x);
const y = Math.min(1, Math.max(0, s + 0.05 * Math.sin(i)));
out[i] = { id: `a${i}`, x, y };
}
return out;
}
// Same set, but carrying bezier tangents so the 'bezier' build/eval/path paths
// exercise the per-anchor handle branch (deltas relative to the anchor).
function makeAnchorsWithHandles(n: number): CurveEditorAnchor[] {
const base = makeAnchors(n);
const dx = n > 1 ? 1 / (n - 1) : 0;
for (let i = 0; i < n; i++) {
const a = base[i]!;
base[i] = {
...a,
inHandle: { x: -dx / 3, y: -0.03 },
outHandle: { x: dx / 3, y: 0.03 },
};
}
return base;
}
// Realistic editor scale (a tone/easing curve is typically 416 anchors) and a
// stress scale (a dense imported LUT / many keyframes).
const anchors16 = makeAnchors(16);
const anchors256 = makeAnchors(256);
const bezierAnchors16 = makeAnchorsWithHandles(16);
const bezierAnchors256 = makeAnchorsWithHandles(256);
const points16 = anchorsToPoints(anchors16);
const points256 = anchorsToPoints(anchors256);
// Shuffled (deterministically reversed-ish) copies to give `sortAnchors` real
// work instead of an already-sorted no-op.
function unsorted(src: readonly CurveEditorAnchor[]): CurveEditorAnchor[] {
const a = src.slice();
// deterministic interleave: pull from both ends.
const out: CurveEditorAnchor[] = [];
let lo = 0;
let hi = a.length - 1;
while (lo <= hi) {
out.push(a[hi]!);
if (lo !== hi) out.push(a[lo]!);
lo++;
hi--;
}
return out;
}
const unsorted16 = unsorted(anchors16);
const unsorted256 = unsorted(anchors256);
// Pre-built evaluators (for the sampling-only benches that should NOT pay the
// build cost on every iteration).
const evalLinear16 = buildEvaluator(anchors16, 'linear');
const evalMonotone16 = buildEvaluator(anchors16, 'monotone');
const evalCatmull16 = buildEvaluator(anchors16, 'catmull-rom');
const evalBezier16 = buildEvaluator(bezierAnchors16, 'bezier');
const evalMonotone256 = buildEvaluator(anchors256, 'monotone');
// A fixed grid of probe x's to sample the evaluator at (the render/LUT density).
function probeGrid(count: number): number[] {
const xs: number[] = Array.from({ length: count });
for (let i = 0; i < count; i++) xs[i] = i / (count - 1);
return xs;
}
const probes256 = probeGrid(256);
const probes1024 = probeGrid(1024);
const clampOpts = {
domainMin: 0,
domainMax: 1,
monotonicX: true,
fixedEndpoints: true,
minGap: 0.001,
};
// ─── 1. evaluator BUILD cost (per interpolation mode × scale) ────────────────
// `buildEvaluator` does the heavy precompute (monotone tangents, catmull-rom
// resample to a 256-pt table, bezier x-segment table). Rebuilt on every anchor
// edit (`evaluator = computed(() => buildEvaluator(...))`), so its build cost is
// on the edit hot path.
describe('buildEvaluator — build cost', () => {
bench('linear — 16 anchors', () => {
buildEvaluator(anchors16, 'linear');
});
bench('linear — 256 anchors', () => {
buildEvaluator(anchors256, 'linear');
});
bench('monotone — 16 anchors', () => {
buildEvaluator(anchors16, 'monotone');
});
bench('monotone — 256 anchors', () => {
buildEvaluator(anchors256, 'monotone');
});
bench('catmull-rom — 16 anchors', () => {
buildEvaluator(anchors16, 'catmull-rom');
});
bench('catmull-rom — 256 anchors', () => {
buildEvaluator(anchors256, 'catmull-rom');
});
bench('bezier — 16 anchors', () => {
buildEvaluator(bezierAnchors16, 'bezier');
});
bench('bezier — 256 anchors', () => {
buildEvaluator(bezierAnchors256, 'bezier');
});
});
// ─── 2. evaluator SAMPLING cost (pre-built closure × sample density) ─────────
// Calling `f(x)` densely is the render/LUT hot path. Each `f(x)` does a binary
// search + Hermite/linear eval (bezier additionally runs a Newton-Raphson
// per call). 256 = the editor's default sample density; 1024 = a fine LUT.
describe('evaluator sampling — 256 samples', () => {
bench('linear', () => {
for (let i = 0; i < probes256.length; i++) evalLinear16(probes256[i]!);
});
bench('monotone', () => {
for (let i = 0; i < probes256.length; i++) evalMonotone16(probes256[i]!);
});
bench('catmull-rom', () => {
for (let i = 0; i < probes256.length; i++) evalCatmull16(probes256[i]!);
});
bench('bezier (Newton-Raphson per call)', () => {
for (let i = 0; i < probes256.length; i++) evalBezier16(probes256[i]!);
});
});
describe('evaluator sampling — 1024 samples (stress)', () => {
bench('monotone — 16 anchors', () => {
for (let i = 0; i < probes1024.length; i++) evalMonotone16(probes1024[i]!);
});
bench('monotone — 256 anchors (deep binary search)', () => {
for (let i = 0; i < probes1024.length; i++) evalMonotone256(probes1024[i]!);
});
bench('bezier — 16 anchors', () => {
for (let i = 0; i < probes1024.length; i++) evalBezier16(probes1024[i]!);
});
});
// ─── 3. build + sample combined (the full per-edit cost, default density) ────
// Mirrors what `CurveEditorRoot` pays each anchor edit: rebuild the evaluator,
// then re-sample the whole curve for the rendered polyline.
describe('build + sample 256 (full per-edit, 16 anchors)', () => {
bench('monotone', () => {
const f = buildEvaluator(anchors16, 'monotone');
for (let i = 0; i < probes256.length; i++) f(probes256[i]!);
});
bench('catmull-rom', () => {
const f = buildEvaluator(anchors16, 'catmull-rom');
for (let i = 0; i < probes256.length; i++) f(probes256[i]!);
});
bench('bezier', () => {
const f = buildEvaluator(bezierAnchors16, 'bezier');
for (let i = 0; i < probes256.length; i++) f(probes256[i]!);
});
});
// ─── 4. toLUT — pixel-application table (the consumer's apply hot path) ───────
// `CurveEditorRoot.toLUT()` → `splineToLUT(evaluator, size, x0, x1)`. The 256
// table backs an 8-bit channel; 1024 a higher-precision apply.
describe('toLUT — spline lookup table', () => {
bench('monotone — 256 entries', () => {
toLUT(evalMonotone16, 256, 0, 1);
});
bench('monotone — 1024 entries', () => {
toLUT(evalMonotone16, 1024, 0, 1);
});
bench('bezier — 256 entries', () => {
toLUT(evalBezier16, 256, 0, 1);
});
});
// ─── 5. CurveEditorCurve path build (the SVG `d` hot path) ────────────────────
// The rendered curve recomputes its `d` whenever anchors/interpolation/scale
// change. Sampled modes: `sampleFnToPolyline` → project → `buildPolylinePath`.
// Bezier mode: chain `buildBezierPath` per segment.
const pxScale = 320; // a typical plot box in px.
function project(p: { x: number; y: number }): { x: number; y: number } {
// value→pixel like CurveEditorPoint/Curve (y value-up): cheap linear map.
return { x: p.x * pxScale, y: (1 - p.y) * pxScale };
}
describe('curve path `d` build — sampled polyline (256 samples)', () => {
bench('monotone — sample + project + buildPolylinePath', () => {
const samples = sampleFnToPolyline(x => evalMonotone16(x), 0, 1, 256);
for (let i = 0; i < samples.length; i++) samples[i] = project(samples[i]!);
buildPolylinePath(samples);
});
bench('catmull-rom — sample + project + buildPolylinePath', () => {
const samples = sampleFnToPolyline(x => evalCatmull16(x), 0, 1, 256);
for (let i = 0; i < samples.length; i++) samples[i] = project(samples[i]!);
buildPolylinePath(samples);
});
});
describe('curve path `d` build — bezier segment chain', () => {
function buildBezierD(list: readonly CurveEditorAnchor[]): string {
let d = '';
for (let i = 0; i < list.length - 1; i++) {
const a = list[i]!;
const b = list[i + 1]!;
const dx = b.x - a.x;
const c1x = a.outHandle ? a.x + a.outHandle.x : a.x + dx / 3;
const c1y = a.outHandle ? a.y + a.outHandle.y : a.y + (b.y - a.y) / 3;
const c2x = b.inHandle ? b.x + b.inHandle.x : b.x - dx / 3;
const c2y = b.inHandle ? b.y + b.inHandle.y : b.y - (b.y - a.y) / 3;
const seg = buildBezierPath(
project({ x: a.x, y: a.y }),
project({ x: c1x, y: c1y }),
project({ x: c2x, y: c2y }),
project({ x: b.x, y: b.y }),
);
d += i === 0 ? seg : seg.replace(/^M[^C]*/, '');
}
return d;
}
bench('16 anchors (15 segments)', () => {
buildBezierD(bezierAnchors16);
});
bench('256 anchors (255 segments)', () => {
buildBezierD(bezierAnchors256);
});
});
// ─── 6. raw spline primitives (sub-operation baselines) ──────────────────────
describe('spline primitives — per-call baselines', () => {
bench('linearInterpolate — 256-pt table lookup', () => {
linearInterpolate(points256, 0.4321);
});
bench('catmullRom — 16-pt parametric eval', () => {
catmullRom(points16, 0.4321);
});
bench('evalCubicBezier — single cubic eval', () => {
evalCubicBezier(
{ x: 0, y: 0 },
{ x: 0.33, y: 0.1 },
{ x: 0.66, y: 0.9 },
{ x: 1, y: 1 },
0.4321,
);
});
bench('monotoneCubic — build closure (16 pts)', () => {
monotoneCubic(points16);
});
});
// ─── 7. anchor housekeeping (sort / project) at scale ────────────────────────
describe('anchor housekeeping', () => {
bench('sortAnchors — 16 (unsorted)', () => {
sortAnchors(unsorted16);
});
bench('sortAnchors — 256 (unsorted)', () => {
sortAnchors(unsorted256);
});
bench('anchorsToPoints — 16', () => {
anchorsToPoints(anchors16);
});
bench('anchorsToPoints — 256', () => {
anchorsToPoints(anchors256);
});
});
// ─── 8. pointer-move drag math (the pointermove hot path) ─────────────────────
// `CurveEditorPoint.onMove` → `clampAnchorX` + `clampAnchorY`. Pure clamp math,
// fired once per pointermove. Bench the clamp alone, and a simulated full
// `updateAnchor` step (clamp + slice-replace, the per-frame array churn).
describe('pointer-move clamp math', () => {
bench('clampAnchorX — interior anchor (neighbour clamp), 16', () => {
// index 8 has both neighbours → the monotonic-x branch does real work.
clampAnchorX(anchors16, 8, 0.5321, clampOpts);
});
bench('clampAnchorX — interior anchor (neighbour clamp), 256', () => {
clampAnchorX(anchors256, 128, 0.5321, clampOpts);
});
bench('clampAnchorY — domain clamp', () => {
clampAnchorY(1.2345, 0, 1);
});
bench('simulated updateAnchor step — clamp + slice-replace, 16', () => {
const list = anchors16;
const index = 8;
const cur = list[index]!;
const x = clampAnchorX(list, index, 0.5321, clampOpts);
const y = clampAnchorY(0.4567, 0, 1);
const candidate = list.slice();
candidate[index] = { ...cur, x, y };
});
bench('simulated updateAnchor step — clamp + slice-replace, 256', () => {
const list = anchors256;
const index = 128;
const cur = list[index]!;
const x = clampAnchorX(list, index, 0.5321, clampOpts);
const y = clampAnchorY(0.4567, 0, 1);
const candidate = list.slice();
candidate[index] = { ...cur, x, y };
});
});
// A full simulated drag stroke: 60 pointermove frames each rebuilding the
// evaluator + re-sampling (what one second of dragging at 60fps costs).
describe('simulated drag stroke (60 frames, monotone, 16 anchors)', () => {
bench('clamp + rebuild + sample-256 per frame', () => {
let list = anchors16;
for (let frame = 0; frame < 60; frame++) {
const index = 8;
const cur = list[index]!;
// deterministic sweep across the frame index (no Math.random).
const nx = clampAnchorX(list, index, 0.3 + (frame / 60) * 0.4, clampOpts);
const ny = clampAnchorY(0.2 + (frame / 60) * 0.6, 0, 1);
const candidate = list.slice();
candidate[index] = { ...cur, x: nx, y: ny };
list = candidate;
const f = buildEvaluator(list, 'monotone');
for (let i = 0; i < probes256.length; i++) f(probes256[i]!);
}
});
});
// ─── 9. component mount — Root + N Points + Curve (realistic & stress) ────────
const wrappers: Array<VueWrapper<any>> = [];
function teardown(): void {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
}
function makeHarness(
data: CurveEditorAnchor[],
interpolation: CurveEditorInterpolation,
) {
return defineComponent({
setup: () => () => h(
CurveEditorRoot,
{ defaultValue: data, interpolation },
{
default: ({ anchors }: { anchors: CurveEditorAnchor[] }) => [
h(CurveEditorCurve),
...anchors.map(a => h(CurveEditorPoint, { key: a.id, anchor: a })),
],
},
),
});
}
const Harness50Monotone = makeHarness(makeAnchors(50), 'monotone');
const Harness500Monotone = makeHarness(makeAnchors(500), 'monotone');
const Harness50Bezier = makeHarness(makeAnchorsWithHandles(50), 'bezier');
describe('mount — Root + Curve + N Points', () => {
bench('50 points (monotone)', () => {
const w = mount(Harness50Monotone, { attachTo: document.body });
wrappers.push(w);
teardown();
});
bench('500 points (monotone, stress)', () => {
const w = mount(Harness500Monotone, { attachTo: document.body });
wrappers.push(w);
teardown();
});
bench('50 points (bezier path)', () => {
const w = mount(Harness50Bezier, { attachTo: document.body });
wrappers.push(w);
teardown();
});
});
// ─── 10. re-render / update after a prop change ──────────────────────────────
// Switching interpolation and replacing the model both invalidate the evaluator
// + every point's projection + the curve `d`. Bench an in-place update via
// setProps (the realistic "user toggled mode / committed an edit" path).
const dataA = makeAnchors(50);
const dataB = makeAnchors(50).map((a, i) => ({ ...a, y: Math.min(1, a.y + 0.1 * Math.cos(i)) }));
function makeControlledHarness() {
return defineComponent({
props: {
modelValue: { type: Array as () => CurveEditorAnchor[], required: true },
interpolation: { type: String as () => CurveEditorInterpolation, default: 'monotone' },
},
setup: props => () => h(
CurveEditorRoot,
{ modelValue: props.modelValue, interpolation: props.interpolation },
{
default: ({ anchors }: { anchors: CurveEditorAnchor[] }) => [
h(CurveEditorCurve),
...anchors.map(a => h(CurveEditorPoint, { key: a.id, anchor: a })),
],
},
),
});
}
const ControlledHarness = makeControlledHarness();
describe('update after prop change (50 points)', () => {
bench('switch interpolation monotone→bezier→monotone', async () => {
const w = mount(ControlledHarness, {
attachTo: document.body,
props: { modelValue: dataA, interpolation: 'monotone' },
});
wrappers.push(w);
await w.setProps({ interpolation: 'bezier' });
await w.setProps({ interpolation: 'monotone' });
teardown();
});
bench('replace model array (commit an edit)', async () => {
const w = mount(ControlledHarness, {
attachTo: document.body,
props: { modelValue: dataA, interpolation: 'monotone' },
});
wrappers.push(w);
await w.setProps({ modelValue: dataB });
await w.setProps({ modelValue: dataA });
teardown();
});
});
@@ -0,0 +1,458 @@
import { bench, describe } from 'vitest';
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import {
FlowRoot,
addEdge,
applyEdgeChanges,
applyNodeChanges,
findClosestHandle,
fitViewTransform,
flowToScreen,
getBezierPath,
getNodePositionAbsolute,
getNodesBounds,
getNodesInsideRect,
getSmoothStepPath,
getStepPath,
getStraightPath,
getVisibleEdgeIds,
getVisibleNodeIds,
screenToFlow,
snapPoint,
visibleFlowRect,
zoomAtPointer,
} from '../index';
import type {
EdgeChange,
FlowEdge,
FlowNode,
HandleBound,
HandleBounds,
InternalNode,
NodeChange,
Position,
Rect,
Viewport,
XYPosition,
} from '../index';
// ── Deterministic fixtures (no Math.random; seeded by index/formula) ──────────
//
// A flow graph laid out on a grid. Node `i` sits at a spread-out position so the
// bounds/cull/spatial math sees a realistic non-degenerate spread, and edges chain
// node `i → i+1` plus a few cross-links so the visible-edge fan-out is non-trivial.
const SIDES: Position[] = ['top', 'right', 'bottom', 'left'];
const NODE_W = 160;
const NODE_H = 56;
function seededNode(i: number): FlowNode<{ label: string }> {
// Deterministic 2D layout: 40 columns, rows below, with a per-index jitter.
const col = i % 40;
const row = Math.floor(i / 40);
return {
id: `n${i}`,
type: 'process',
position: { x: col * 240 + (i % 7) * 13, y: row * 180 + (i % 5) * 11 },
data: { label: `Step ${i}` },
};
}
function seededInternalNode(i: number): InternalNode<{ label: string }> {
const base = seededNode(i);
const handleBounds: HandleBounds = {
target: [{ id: null, type: 'target', position: 'left', x: 0, y: NODE_H / 2, width: 10, height: 10 }],
source: [{ id: null, type: 'source', position: 'right', x: NODE_W, y: NODE_H / 2, width: 10, height: 10 }],
};
return {
...base,
measured: { width: NODE_W, height: NODE_H },
positionAbsolute: { ...base.position },
handleBounds,
};
}
function seededEdge(i: number): FlowEdge {
// Chain plus a deterministic cross-link every 3rd edge.
const source = `n${i}`;
const target = i % 3 === 0 ? `n${(i + 7) % 1000}` : `n${i + 1}`;
return {
id: `e${i}`,
source,
target,
type: i % 2 === 0 ? 'smoothstep' : 'bezier',
animated: i % 5 === 0,
};
}
function buildNodes(count: number): Array<FlowNode<{ label: string }>> {
const out: Array<FlowNode<{ label: string }>> = [];
for (let i = 0; i < count; i++) out.push(seededNode(i));
return out;
}
function buildInternalNodes(count: number): Array<InternalNode<{ label: string }>> {
const out: Array<InternalNode<{ label: string }>> = [];
for (let i = 0; i < count; i++) out.push(seededInternalNode(i));
return out;
}
function buildLookup(internals: InternalNode[]): Map<string, InternalNode> {
const map = new Map<string, InternalNode>();
for (const n of internals) map.set(n.id, n);
return map;
}
function buildEdges(count: number): FlowEdge[] {
const out: FlowEdge[] = [];
for (let i = 0; i < count; i++) out.push(seededEdge(i));
return out;
}
// Endpoint pairs feeding the path builders — varied handle sides so the
// smooth-step branch/corner logic is exercised, not just the fast collinear case.
interface EndpointPair {
sourceX: number;
sourceY: number;
sourcePosition: Position;
targetX: number;
targetY: number;
targetPosition: Position;
}
function buildEndpoints(count: number): EndpointPair[] {
const out: EndpointPair[] = [];
for (let i = 0; i < count; i++) {
out.push({
sourceX: (i % 13) * 40,
sourceY: (i % 17) * 30,
sourcePosition: SIDES[i % 4]!,
targetX: 400 + (i % 11) * 50,
targetY: 200 + (i % 7) * 45,
targetPosition: SIDES[(i + 2) % 4]!,
});
}
return out;
}
const VP: Viewport = { x: 120, y: -80, zoom: 1.5 };
const ORIGIN = { left: 24, top: 16 };
const CONTAINER = { width: 1280, height: 720 };
// Pointer-move samples (client-space pixels) for screenToFlow/zoom hot paths.
function buildPointers(count: number): XYPosition[] {
const out: XYPosition[] = [];
for (let i = 0; i < count; i++)
out.push({ x: (i * 37) % CONTAINER.width, y: (i * 53) % CONTAINER.height });
return out;
}
// Pre-built fixture sets at realistic (100) and stress (1000) scale.
const NODES_100 = buildNodes(100);
const NODES_1000 = buildNodes(1000);
const INTERNAL_100 = buildInternalNodes(100);
const INTERNAL_1000 = buildInternalNodes(1000);
const LOOKUP_100 = buildLookup(INTERNAL_100);
const LOOKUP_1000 = buildLookup(INTERNAL_1000);
const EDGES_100 = buildEdges(100);
const EDGES_1000 = buildEdges(1000);
const ENDPOINTS_100 = buildEndpoints(100);
const ENDPOINTS_1000 = buildEndpoints(1000);
const POINTERS_100 = buildPointers(100);
const POINTERS_1000 = buildPointers(1000);
// A `parentId` chain so getNodePositionAbsolute walks ancestors (subflow cost).
const CHAIN_LOOKUP = new Map<string, InternalNode>();
(() => {
for (let i = 0; i < 64; i++) {
const n = seededInternalNode(i);
if (i > 0) n.parentId = `n${i - 1}`;
CHAIN_LOOKUP.set(n.id, n);
}
})();
const CHAIN_LEAF = CHAIN_LOOKUP.get('n63')!;
const VISIBLE_RECT: Rect = visibleFlowRect(VP, CONTAINER, 200);
const VISIBLE_NODE_SET_100 = new Set(getVisibleNodeIds(NODES_100, LOOKUP_100, VISIBLE_RECT));
const VISIBLE_NODE_SET_1000 = new Set(getVisibleNodeIds(NODES_1000, LOOKUP_1000, VISIBLE_RECT));
// Marquee rect covering the upper-left quadrant of the laid-out graph.
const MARQUEE: Rect = { x: 0, y: 0, width: 4000, height: 1500 };
// Closest-handle drag origin: drag from n0's source toward a moving pointer.
const DRAG_FROM_HANDLE: HandleBound = INTERNAL_1000[0]!.handleBounds!.source[0]!;
// ── Edge-path math (per-edge, runs for every visible edge every transform) ────
describe('edge-paths — straight', () => {
bench('100 edges', () => {
for (let i = 0; i < ENDPOINTS_100.length; i++) getStraightPath(ENDPOINTS_100[i]!);
});
bench('1000 edges', () => {
for (let i = 0; i < ENDPOINTS_1000.length; i++) getStraightPath(ENDPOINTS_1000[i]!);
});
});
describe('edge-paths — bezier', () => {
bench('100 edges', () => {
for (let i = 0; i < ENDPOINTS_100.length; i++) getBezierPath(ENDPOINTS_100[i]!);
});
bench('1000 edges', () => {
for (let i = 0; i < ENDPOINTS_1000.length; i++) getBezierPath(ENDPOINTS_1000[i]!);
});
});
describe('edge-paths — smoothstep (corner builder)', () => {
bench('100 edges', () => {
for (let i = 0; i < ENDPOINTS_100.length; i++) getSmoothStepPath(ENDPOINTS_100[i]!);
});
bench('1000 edges', () => {
for (let i = 0; i < ENDPOINTS_1000.length; i++) getSmoothStepPath(ENDPOINTS_1000[i]!);
});
});
describe('edge-paths — step (zero-radius smoothstep)', () => {
bench('100 edges', () => {
for (let i = 0; i < ENDPOINTS_100.length; i++) getStepPath(ENDPOINTS_100[i]!);
});
bench('1000 edges', () => {
for (let i = 0; i < ENDPOINTS_1000.length; i++) getStepPath(ENDPOINTS_1000[i]!);
});
});
// ── Pointer / viewport transform math (runs on every pointermove & wheel) ─────
describe('pointer math — screenToFlow', () => {
bench('100 moves', () => {
for (let i = 0; i < POINTERS_100.length; i++) screenToFlow(POINTERS_100[i]!, VP, ORIGIN);
});
bench('1000 moves', () => {
for (let i = 0; i < POINTERS_1000.length; i++) screenToFlow(POINTERS_1000[i]!, VP, ORIGIN);
});
});
describe('pointer math — flowToScreen', () => {
bench('100 points', () => {
for (let i = 0; i < POINTERS_100.length; i++) flowToScreen(POINTERS_100[i]!, VP, ORIGIN);
});
bench('1000 points', () => {
for (let i = 0; i < POINTERS_1000.length; i++) flowToScreen(POINTERS_1000[i]!, VP, ORIGIN);
});
});
describe('pointer math — zoomAtPointer (wheel zoom)', () => {
bench('100 wheel steps', () => {
for (let i = 0; i < POINTERS_100.length; i++)
zoomAtPointer(VP, POINTERS_100[i]!, 1 + (i % 20) / 10);
});
bench('1000 wheel steps', () => {
for (let i = 0; i < POINTERS_1000.length; i++)
zoomAtPointer(VP, POINTERS_1000[i]!, 1 + (i % 20) / 10);
});
});
describe('pointer math — snapPoint (drag with snap-to-grid)', () => {
const grid: [number, number] = [16, 16];
bench('100 moves', () => {
for (let i = 0; i < POINTERS_100.length; i++) snapPoint(POINTERS_100[i]!, grid);
});
bench('1000 moves', () => {
for (let i = 0; i < POINTERS_1000.length; i++) snapPoint(POINTERS_1000[i]!, grid);
});
});
// ── Bounds / fit-view (runs on fitView and minimap recompute) ─────────────────
describe('getNodesBounds', () => {
bench('100 nodes', () => {
getNodesBounds(INTERNAL_100);
});
bench('1000 nodes', () => {
getNodesBounds(INTERNAL_1000);
});
});
describe('fitViewTransform (bounds + fit)', () => {
const opts = { padding: 0.1, minZoom: 0.2, maxZoom: 2.5 };
bench('100 nodes', () => {
fitViewTransform(getNodesBounds(INTERNAL_100), CONTAINER, opts);
});
bench('1000 nodes', () => {
fitViewTransform(getNodesBounds(INTERNAL_1000), CONTAINER, opts);
});
});
// ── Subflow absolute position (parentId chain walk) ───────────────────────────
describe('getNodePositionAbsolute — parent chain (depth 64)', () => {
bench('single leaf walk', () => {
getNodePositionAbsolute(CHAIN_LEAF, CHAIN_LOOKUP);
});
bench('64 nodes (all walked)', () => {
for (const n of CHAIN_LOOKUP.values()) getNodePositionAbsolute(n, CHAIN_LOOKUP);
});
});
// ── Virtualization / spatial culling (runs every viewport pan/zoom) ──────────
describe('visibleFlowRect + getVisibleNodeIds (node cull)', () => {
bench('100 nodes', () => {
const rect = visibleFlowRect(VP, CONTAINER, 200);
getVisibleNodeIds(NODES_100, LOOKUP_100, rect);
});
bench('1000 nodes', () => {
const rect = visibleFlowRect(VP, CONTAINER, 200);
getVisibleNodeIds(NODES_1000, LOOKUP_1000, rect);
});
});
describe('getVisibleEdgeIds (edge cull by visible node set)', () => {
bench('100 edges', () => {
getVisibleEdgeIds(EDGES_100, VISIBLE_NODE_SET_100);
});
bench('1000 edges', () => {
getVisibleEdgeIds(EDGES_1000, VISIBLE_NODE_SET_1000);
});
});
describe('getNodesInsideRect (marquee selection)', () => {
bench('100 nodes', () => {
getNodesInsideRect(INTERNAL_100, MARQUEE, 'partial');
});
bench('1000 nodes', () => {
getNodesInsideRect(INTERNAL_1000, MARQUEE, 'partial');
});
});
// ── Connection snapping (runs every pointermove during a connect drag) ────────
describe('findClosestHandle (connect-drag snapping)', () => {
bench('100 nodes', () => {
for (let i = 0; i < POINTERS_100.length; i++)
findClosestHandle(POINTERS_100[i]!, LOOKUP_100, 'source', 'n0', null, 'strict', 40);
});
bench('1000 nodes', () => {
for (let i = 0; i < POINTERS_100.length; i++)
findClosestHandle(POINTERS_100[i]!, LOOKUP_1000, 'source', 'n0', null, 'strict', 40);
});
});
void DRAG_FROM_HANDLE; // referenced for fixture parity; snapping uses lookup handles
// ── Controlled-mode change application (the @nodes-change / @edges-change path) ──
describe('applyNodeChanges (drag → position changes)', () => {
// A whole-graph position update, as emitted while dragging a multi-selection.
const changes100: NodeChange[] = NODES_100.map((n, i) => ({
type: 'position',
id: n.id,
position: { x: n.position.x + i, y: n.position.y + i },
}));
const changes1000: NodeChange[] = NODES_1000.map((n, i) => ({
type: 'position',
id: n.id,
position: { x: n.position.x + i, y: n.position.y + i },
}));
bench('100 position changes', () => {
applyNodeChanges(changes100, NODES_100);
});
bench('1000 position changes', () => {
applyNodeChanges(changes1000, NODES_1000);
});
});
describe('applyEdgeChanges (select changes)', () => {
const changes100: EdgeChange[] = EDGES_100.map(e => ({ type: 'select', id: e.id, selected: true }));
const changes1000: EdgeChange[] = EDGES_1000.map(e => ({ type: 'select', id: e.id, selected: true }));
bench('100 select changes', () => {
applyEdgeChanges(changes100, EDGES_100);
});
bench('1000 select changes', () => {
applyEdgeChanges(changes1000, EDGES_1000);
});
});
describe('addEdge (dedupe scan on connect)', () => {
bench('append into 100 edges', () => {
addEdge({ source: 'n5', target: 'n90', sourceHandle: null, targetHandle: null }, EDGES_100);
});
bench('append into 1000 edges', () => {
addEdge({ source: 'n5', target: 'n900', sourceHandle: null, targetHandle: null }, EDGES_1000);
});
});
// ── Component: FlowRoot mount + re-render (the realistic end-to-end path) ──────
const nodeSlot = (p: { data?: { label?: string } }) => h('div', { class: 'node-body' }, p.data?.label ?? '');
function mountFlow(nodes: FlowNode[], edges: FlowEdge[]) {
return mount(FlowRoot, {
attachTo: document.body,
props: { defaultNodes: nodes, defaultEdges: edges },
slots: { 'node-default': nodeSlot },
});
}
const NODES_50 = buildNodes(50);
const EDGES_50 = buildEdges(50);
const NODES_500 = buildNodes(500);
const EDGES_500 = buildEdges(500);
describe('FlowRoot — mount + unmount', () => {
bench('50 nodes / 50 edges', () => {
const w = mountFlow(NODES_50, EDGES_50);
w.unmount();
});
bench('500 nodes / 500 edges', () => {
const w = mountFlow(NODES_500, EDGES_500);
w.unmount();
});
});
describe('FlowRoot — re-render after prop change (viewport pan)', () => {
bench('50 nodes — viewport setProps', async () => {
const w = mount(FlowRoot, {
attachTo: document.body,
props: { defaultNodes: NODES_50, defaultEdges: EDGES_50, viewport: { x: 0, y: 0, zoom: 1 } },
slots: { 'node-default': nodeSlot },
});
await w.setProps({ viewport: { x: 120, y: -60, zoom: 1.4 } });
w.unmount();
});
bench('500 nodes — nodes setProps (controlled replace)', async () => {
const w = mount(FlowRoot, {
attachTo: document.body,
props: { nodes: NODES_500, edges: EDGES_500 },
slots: { 'node-default': nodeSlot },
});
// Move the first node — a single fresh object, rest keep identity.
const next = [{ ...NODES_500[0]!, position: { x: 999, y: 999 } }, ...NODES_500.slice(1)];
await w.setProps({ nodes: next });
w.unmount();
});
});
@@ -0,0 +1,238 @@
import { mount } from '@vue/test-utils';
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { HistogramBars, HistogramRoot } from '../index';
import type { HistogramBarChannel, HistogramData, HistogramScaleType } from '../utils';
import {
getChannelBins,
histogramMax,
projectBarHeight,
projectBars,
} from '../utils';
// ---------------------------------------------------------------------------
// Deterministic fixtures (NO Math.random). Bins are seeded by index/formula so
// every run measures identical work. Each builder produces a bell-ish tonal
// curve plus a highlight bump, the shape a real image histogram carries — tall
// central spike, long zero tails — which exercises both the linear and log
// projection branches and the all-zero guards.
// ---------------------------------------------------------------------------
/** A bell bump centred on `center` bins, scaled to `peak`, sampled over `n` bins. */
function bell(n: number, center: number, spread: number, peak: number): number[] {
const out: number[] = Array.from({ length: n });
for (let i = 0; i < n; i++) {
const d = (i - center) / spread;
out[i] = Math.round(peak * Math.exp(-0.5 * d * d));
}
return out;
}
/** Realistic single-channel bins of length `n` (midtone hump + highlight bump). */
function makeBins(n: number): number[] {
const a = bell(n, n * 0.44, n * 0.14, 1000);
const b = bell(n, n * 0.78, n * 0.08, 280);
const out: number[] = Array.from({ length: n });
for (let i = 0; i < n; i++) out[i] = a[i]! + b[i]!;
return out;
}
/** A per-channel record (r/g/b/l) of length `n`, each primary peaking elsewhere. */
function makeChannelData(n: number): HistogramData {
return {
l: makeBins(n),
r: bell(n, n * 0.53, n * 0.16, 760),
g: bell(n, n * 0.41, n * 0.12, 940),
b: bell(n, n * 0.31, n * 0.18, 620),
};
}
// 256 is the canonical bin count (8-bit channel); 100 / 1000 bracket realistic
// and stress scales. 50 / 500 bracket the rendered bar count for components.
const bins100 = makeBins(100);
const bins256 = makeBins(256);
const bins1000 = makeBins(1000);
const binsZero256 = Array.from<number>({ length: 256 }).fill(0);
const record100 = makeChannelData(100);
const record1000 = makeChannelData(1000);
const SCALES: HistogramScaleType[] = ['linear', 'log'];
const PRIMARIES: HistogramBarChannel[] = ['l', 'r', 'g', 'b'];
const bars50 = makeBins(50);
const bars500 = makeBins(500);
// Stable harnesses so the component benches measure render cost, not the cost
// of redefining a component each iteration.
const RootBarsHarness = defineComponent({
props: {
data: { type: [Array, Object] as unknown as () => HistogramData, required: true },
channel: { type: String, default: 'l' },
scaleType: { type: String, default: 'linear' },
},
setup(props) {
return () =>
h(
HistogramRoot,
{ data: props.data, channel: props.channel, scaleType: props.scaleType },
{ default: () => h(HistogramBars) },
);
},
});
// ---------------------------------------------------------------------------
// Pure projection math — the per-channel hot path the root runs on every data
// or prop change (peak scan → normalise every bin → fresh packed array).
// ---------------------------------------------------------------------------
describe('histogramMax — peak scan', () => {
bench('100 bins', () => {
histogramMax(bins100);
});
bench('256 bins', () => {
histogramMax(bins256);
});
bench('1000 bins', () => {
histogramMax(bins1000);
});
bench('256 bins — all zero (guard path)', () => {
histogramMax(binsZero256);
});
});
describe('projectBars — linear (peak scan + normalise + alloc)', () => {
bench('100 bins', () => {
projectBars(bins100, 'linear');
});
bench('256 bins', () => {
projectBars(bins256, 'linear');
});
bench('1000 bins', () => {
projectBars(bins1000, 'linear');
});
});
describe('projectBars — log (log1p per bin + alloc)', () => {
bench('100 bins', () => {
projectBars(bins100, 'log');
});
bench('256 bins', () => {
projectBars(bins256, 'log');
});
bench('1000 bins', () => {
projectBars(bins1000, 'log');
});
});
describe('projectBars — all-zero guard (no NaN, no divide)', () => {
bench('256 bins — linear', () => {
projectBars(binsZero256, 'linear');
});
bench('256 bins — log', () => {
projectBars(binsZero256, 'log');
});
});
describe('projectBarHeight — per-bin scalar (1000x loop)', () => {
const max = histogramMax(bins1000);
bench('linear x1000', () => {
for (let i = 0; i < bins1000.length; i++) projectBarHeight(bins1000[i]!, max, 'linear');
});
bench('log x1000', () => {
for (let i = 0; i < bins1000.length; i++) projectBarHeight(bins1000[i]!, max, 'log');
});
});
// ---------------------------------------------------------------------------
// Full root projection across every primary + both scales — the work the root's
// `bars()`/`hasData` perform when re-deriving an RGB composite. getChannelBins
// resolves the record per channel; projectBars then scans + packs each.
// ---------------------------------------------------------------------------
describe('per-channel projection (RGB composite, record data)', () => {
bench('4 channels x 100 bins x 2 scales', () => {
for (const scale of SCALES) {
for (const ch of PRIMARIES) {
projectBars(getChannelBins(record100, ch, 'l'), scale);
}
}
});
bench('4 channels x 1000 bins x 2 scales', () => {
for (const scale of SCALES) {
for (const ch of PRIMARIES) {
projectBars(getChannelBins(record1000, ch, 'l'), scale);
}
}
});
});
// ---------------------------------------------------------------------------
// Component mount — full Root → Bars → per-bar Primitive tree. Each bar is its
// own DOM node, so cost scales with the rendered bar count.
// ---------------------------------------------------------------------------
describe('HistogramRoot + HistogramBars — mount', () => {
bench('50 bars (linear)', () => {
const w = mount(RootBarsHarness, {
props: { data: bars50, channel: 'l', scaleType: 'linear' },
attachTo: document.body,
});
w.unmount();
});
bench('500 bars (linear)', () => {
const w = mount(RootBarsHarness, {
props: { data: bars500, channel: 'l', scaleType: 'linear' },
attachTo: document.body,
});
w.unmount();
});
bench('500 bars (log)', () => {
const w = mount(RootBarsHarness, {
props: { data: bars500, channel: 'l', scaleType: 'log' },
attachTo: document.body,
});
w.unmount();
});
});
// ---------------------------------------------------------------------------
// Re-render after a prop change — the realistic interaction (the demo toggles
// channel and scaleType). Re-projects + patches the existing bar tree in place.
// ---------------------------------------------------------------------------
describe('HistogramRoot + HistogramBars — update after prop change', () => {
bench('500 bars — scaleType linear → log', async () => {
const w = mount(RootBarsHarness, {
props: { data: bars500, channel: 'l', scaleType: 'linear' },
attachTo: document.body,
});
await w.setProps({ scaleType: 'log' });
await nextTick();
w.unmount();
});
bench('record data — channel l → rgb (expand to 3 primaries)', async () => {
const w = mount(RootBarsHarness, {
props: { data: record100, channel: 'l', scaleType: 'linear' },
attachTo: document.body,
});
await w.setProps({ channel: 'rgb' });
await nextTick();
w.unmount();
});
});
@@ -0,0 +1,279 @@
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick, render } from 'vue';
import { solveBezierX } from '../../../internal/spline';
import {
KeyframeTrackKeyframe,
KeyframeTrackRoot,
KeyframeTrackSegment,
clampKeyframeTime,
defaultKeyframeValueText,
sampleKeyframes,
snapTimeToFrame,
sortKeyframes,
} from '../index';
import type { KeyframeTrackKeyframeData } from '../index';
// ─────────────────────────────────────────────────────────────────────────────
// Fixtures — deterministic, built once at module scope (NO Math.random).
//
// `time` ramps monotonically (already sorted ascending) so the realistic scales
// hit the binary-search / neighbour-clamp paths the way the live sampler does;
// `value` and `easing` are seeded by index so the spline solve takes a non-trivial
// (non-identity) curve roughly half the time.
// ─────────────────────────────────────────────────────────────────────────────
const EASINGS: Array<[number, number, number, number]> = [
[0, 0, 1, 1], // linear (identity fast-path in solveBezierX)
[0, 0, 0.2, 1], // ease-out
[0.5, 0, 1, 1], // ease-in
[0.65, 0, 0.35, 1], // ease-in-out
];
/** Build `n` sorted keyframes spanning [0, n/10] seconds with cycling easings. */
function makeKeyframes(n: number): KeyframeTrackKeyframeData[] {
const out: KeyframeTrackKeyframeData[] = new Array(n);
for (let i = 0; i < n; i++) {
out[i] = {
id: `k${i}`,
time: i / 10, // 0.1s apart → strictly ascending, already sorted
value: (i % 11) / 10, // 0.0 .. 1.0, deterministic sawtooth
// Every 4th keyframe is un-eased (undefined → DEFAULT_KEYFRAME_EASING);
// the rest cycle through real curves to exercise the Newton-Raphson solve.
easing: i % 4 === 0 ? undefined : EASINGS[i % EASINGS.length],
};
}
return out;
}
/** Build `n` keyframes in REVERSE time order to force a real sort (worst case for sortKeyframes). */
function makeUnsortedKeyframes(n: number): KeyframeTrackKeyframeData[] {
const out: KeyframeTrackKeyframeData[] = new Array(n);
for (let i = 0; i < n; i++) {
const j = n - 1 - i;
out[i] = { id: `k${j}`, time: j / 10, value: (j % 11) / 10 };
}
return out;
}
const KF_100 = makeKeyframes(100);
const KF_1000 = makeKeyframes(1000);
const KF_100_UNSORTED = makeUnsortedKeyframes(100);
const KF_1000_UNSORTED = makeUnsortedKeyframes(1000);
const VALUE_RANGE: readonly [number, number] = [0, 1];
const FPS = 30;
// Pre-computed sample times spanning the full keyframe range, so each pass over
// a curve exercises the bracketing binary search end-to-end (the readout in the
// demo samples ~120 points per animation frame). Seeded by index, not random.
function makeSampleTimes(count: number, span: number): number[] {
const out: number[] = new Array(count);
for (let i = 0; i < count; i++) out[i] = (i / (count - 1)) * span;
return out;
}
const SAMPLE_TIMES_120 = makeSampleTimes(120, 10); // one demo curve frame over the 100-kf range
const SAMPLE_TIMES_120_WIDE = makeSampleTimes(120, 100); // same frame over the 1000-kf range
// Easing-solver probes in [0,1] (the curve-preview polyline samples ~33 points).
const BEZIER_X_64 = makeSampleTimes(64, 1);
// Simulated pointermove deltas (px-equivalent seconds) for the drag hot path —
// a deterministic back-and-forth sweep, no random jitter.
const MOVE_TIMES_100 = (() => {
const out: number[] = Array.from({ length: 100 });
for (let i = 0; i < 100; i++) out[i] = 5 + Math.sin(i / 8) * 4; // ∈ ~[1, 9]
return out;
})();
// ─────────────────────────────────────────────────────────────────────────────
// Pure hot-path maths — these run on the live render / pointer paths.
// ─────────────────────────────────────────────────────────────────────────────
describe('sampleKeyframes — single sample by curve size', () => {
bench('100 keyframes — sample mid-range', () => {
sampleKeyframes(KF_100, 5, VALUE_RANGE);
});
bench('1000 keyframes — sample mid-range', () => {
sampleKeyframes(KF_1000, 50, VALUE_RANGE);
});
});
describe('sampleKeyframes — full curve sweep (per-frame readout)', () => {
bench('100 keyframes × 120 samples', () => {
let acc = 0;
for (let i = 0; i < SAMPLE_TIMES_120.length; i++)
acc += sampleKeyframes(KF_100, SAMPLE_TIMES_120[i]!, VALUE_RANGE);
return acc;
});
bench('1000 keyframes × 120 samples', () => {
let acc = 0;
for (let i = 0; i < SAMPLE_TIMES_120_WIDE.length; i++)
acc += sampleKeyframes(KF_1000, SAMPLE_TIMES_120_WIDE[i]!, VALUE_RANGE);
return acc;
});
});
describe('solveBezierX — easing solve', () => {
bench('identity (linear) × 64', () => {
let acc = 0;
for (let i = 0; i < BEZIER_X_64.length; i++)
acc += solveBezierX(0, 0, 1, 1, BEZIER_X_64[i]!);
return acc;
});
bench('ease-in-out (Newton-Raphson) × 64', () => {
let acc = 0;
for (let i = 0; i < BEZIER_X_64.length; i++)
acc += solveBezierX(0.65, 0, 0.35, 1, BEZIER_X_64[i]!);
return acc;
});
});
describe('sortKeyframes — reconcile / commit', () => {
bench('100 keyframes (reverse-sorted input)', () => {
sortKeyframes(KF_100_UNSORTED);
});
bench('1000 keyframes (reverse-sorted input)', () => {
sortKeyframes(KF_1000_UNSORTED);
});
});
describe('clampKeyframeTime — neighbour clamp (pointer drag)', () => {
const opts = { allowOverlap: false, minTimeBetween: 1 / FPS, duration: 100 };
bench('100 keyframes × 100 moves', () => {
let acc = 0;
for (let i = 0; i < MOVE_TIMES_100.length; i++) {
const index = (i * 7) % KF_100.length; // deterministic spread of touched indices
acc += clampKeyframeTime(KF_100, index, MOVE_TIMES_100[i]!, opts);
}
return acc;
});
bench('1000 keyframes × 100 moves', () => {
let acc = 0;
for (let i = 0; i < MOVE_TIMES_100.length; i++) {
const index = (i * 71) % KF_1000.length;
acc += clampKeyframeTime(KF_1000, index, MOVE_TIMES_100[i]!, opts);
}
return acc;
});
});
describe('snapTimeToFrame — frame-grid quantize', () => {
bench('100 quantize ops @30fps', () => {
let acc = 0;
for (let i = 0; i < MOVE_TIMES_100.length; i++)
acc += snapTimeToFrame(MOVE_TIMES_100[i]!, FPS);
return acc;
});
});
describe('defaultKeyframeValueText — aria-valuetext', () => {
bench('100 value-text formats (with property)', () => {
for (let i = 0; i < MOVE_TIMES_100.length; i++)
defaultKeyframeValueText(MOVE_TIMES_100[i]! / 10, 'opacity');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Component mount + update via Vue render() — realistic (50) and stress (500).
//
// Each keyframe renders a `role="slider"` Primitive that reads the projection /
// sampler from context, plus a segment band between neighbours. We mount the full
// tree, flush a tick, then update a prop (duration) to time the reconcile +
// re-projection cost, and finally unmount.
// ─────────────────────────────────────────────────────────────────────────────
function makeHarness(initial: KeyframeTrackKeyframeData[]) {
return defineComponent({
props: {
keyframes: { type: Array as () => KeyframeTrackKeyframeData[], default: () => initial },
duration: { type: Number, default: 50 },
},
setup(props) {
return () =>
h(
KeyframeTrackRoot as any,
{
modelValue: props.keyframes,
property: 'opacity',
duration: props.duration,
fps: FPS,
valueRange: [0, 1],
// The Root measures its own box; give it a concrete lane so the
// projection / snap targets are non-degenerate during the bench.
style: 'width: 600px; height: 160px; position: relative; display: block;',
},
{
default: ({ keyframes }: { keyframes: KeyframeTrackKeyframeData[] }) => [
...keyframes
.slice(0, -1)
.map(k =>
h(KeyframeTrackSegment, { key: `seg-${k.id}`, keyframeId: k.id }),
),
...keyframes.map(k =>
h(KeyframeTrackKeyframe, { key: k.id, keyframeId: k.id, id: `kf-${k.id}` }),
),
],
},
);
},
});
}
const KF_50 = makeKeyframes(50);
const KF_500 = makeKeyframes(500);
const Harness50 = makeHarness(KF_50);
const Harness500 = makeHarness(KF_500);
describe('KeyframeTrackRoot — mount + unmount', () => {
bench('mount 50 keyframes', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
render(h(Harness50), container);
await nextTick();
render(null, container);
container.remove();
});
bench('mount 500 keyframes', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
render(h(Harness500), container);
await nextTick();
render(null, container);
container.remove();
});
});
describe('KeyframeTrackRoot — re-render after prop change', () => {
bench('50 keyframes — duration change + flush', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
render(h(Harness50, { duration: 50 }), container);
await nextTick();
// Prop change re-runs the time scale + every keyframe/segment projection.
render(h(Harness50, { duration: 80 }), container);
await nextTick();
render(null, container);
container.remove();
});
bench('500 keyframes — duration change + flush', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
render(h(Harness500, { duration: 50 }), container);
await nextTick();
render(h(Harness500, { duration: 80 }), container);
await nextTick();
render(null, container);
container.remove();
});
});
@@ -0,0 +1,381 @@
import { mount } from '@vue/test-utils';
import { bench, describe } from 'vitest';
import { h, nextTick } from 'vue';
// Pure hot-path math + tick generators live in the shared `internal/scale`
// module, re-exported from the package barrel. Import them relatively so the
// bench tracks the same source the ruler consumes.
import {
formatClock,
formatFrames,
formatTimecode,
frameTicks,
framesToTimecode,
getStepDecimals,
niceTicks,
roundToStep,
scaleLinear,
secondsToFrames,
timeTicks,
timecodeTicks,
useScale,
} from '../../../internal/scale';
import { formatTimeForMode, modeToTickKind, tickFormatFor } from '../utils';
import { TimeRulerCursor, TimeRulerRoot } from '../index';
// ---------------------------------------------------------------------------
// Fixtures (deterministic — values seeded by index/formula, no Math.random).
// ---------------------------------------------------------------------------
const FPS = 30;
// A "visible window" mirrors the demo: offset (left-edge seconds) + a width in
// pixels projected through a zoom (px/s). Two scales: realistic and stress.
// realistic: 600px wide @ 40 px/s over a 10-minute clip → window ~15s
// stress: a long zoomed-out window so the generators emit many ticks.
function windowFor(widthPx: number, zoomPxPerSec: number, offsetSec: number): {
domain: readonly [number, number];
range: readonly [number, number];
} {
const span = widthPx / zoomPxPerSec;
return {
domain: [offsetSec, offsetSec + span] as const,
range: [0, widthPx] as const,
};
}
// Realistic single-screen window (demo defaults: 600px, 40 px/s, offset 12s).
const REALISTIC = windowFor(600, 40, 12);
// Stress window: a wide, zoomed-out viewport that walks many tick candidates.
const STRESS = windowFor(4000, 4, 0);
// A bank of seconds values for scalar projection / formatter benches. Seeded by
// a deterministic formula so every run hits identical inputs.
function secondsBank(n: number): Float64Array {
const out = new Float64Array(n);
for (let i = 0; i < n; i++) {
// Spread across a 10-minute clip with frame-fractional offsets so the
// timecode/frame paths exercise rounding, not just integers.
out[i] = (i * 600) / n + (i % 30) / FPS;
}
return out;
}
// A bank of pixel offsets for the invert (pointer→time) hot path.
function pixelBank(n: number, widthPx: number): Float64Array {
const out = new Float64Array(n);
for (let i = 0; i < n; i++) {
out[i] = (i / (n - 1 || 1)) * widthPx;
}
return out;
}
const SECONDS_100 = secondsBank(100);
const SECONDS_1000 = secondsBank(1000);
const PIXELS_100 = pixelBank(100, 600);
const PIXELS_1000 = pixelBank(1000, 4000);
// A frame bank for the integer-frame generator / formatters.
const FRAMES_1000 = (() => {
const out = new Float64Array(1000);
for (let i = 0; i < out.length; i++) out[i] = i * 37; // arbitrary stable stride
return out;
})();
// ---------------------------------------------------------------------------
// 1. Tick generation — the ruler's heaviest per-frame compute. Each generator
// is benched at a realistic single-screen window and a stress window.
// ---------------------------------------------------------------------------
describe('tick generation — timeTicks (seconds mode)', () => {
bench('realistic window (~15s @ 40px/s)', () => {
timeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, targetDensity: 80 });
});
bench('stress window (1000s @ 4px/s)', () => {
timeTicks({ domain: STRESS.domain, range: STRESS.range, targetDensity: 80 });
});
});
describe('tick generation — timecodeTicks (timecode mode)', () => {
bench('realistic window', () => {
timecodeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, fps: FPS, targetDensity: 80 });
});
bench('stress window', () => {
timecodeTicks({ domain: STRESS.domain, range: STRESS.range, fps: FPS, targetDensity: 80 });
});
bench('realistic window — drop-frame labels', () => {
timecodeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, fps: 29.97, dropFrame: true, targetDensity: 80 });
});
});
describe('tick generation — frameTicks (frames mode)', () => {
// frames mode routes the seconds domain through the timecode ticker with a
// frame-number `format` override; bench that exact path too.
const frameFormat = tickFormatFor('frames', FPS);
bench('realistic window — timecode ticker w/ frame labels', () => {
timecodeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, fps: FPS, format: frameFormat, targetDensity: 80 });
});
bench('stress window — integer-frame axis', () => {
// A wide integer-frame domain (0..18000 frames ≈ 600s @ 30fps).
frameTicks({ domain: [0, 18000] as const, range: [0, 4000] as const, fps: FPS, targetDensity: 80 });
});
});
describe('tick generation — niceTicks (generic axis)', () => {
bench('realistic window', () => {
niceTicks({ domain: REALISTIC.domain, range: REALISTIC.range, targetDensity: 80 });
});
bench('stress window', () => {
niceTicks({ domain: STRESS.domain, range: STRESS.range, targetDensity: 80 });
});
});
// ---------------------------------------------------------------------------
// 2. Pure projection math — `scale` / `invert` run on the pointer hot path.
// Bench the underlying scaleLinear over 100 / 1000 inputs.
// ---------------------------------------------------------------------------
describe('projection math — scaleLinear (time → px)', () => {
const [d0, d1] = REALISTIC.domain;
const [r0, r1] = REALISTIC.range;
bench('100 values', () => {
let acc = 0;
for (let i = 0; i < SECONDS_100.length; i++) {
acc += scaleLinear(SECONDS_100[i]!, d0, d1, r0, r1);
}
return acc;
});
bench('1000 values', () => {
let acc = 0;
for (let i = 0; i < SECONDS_1000.length; i++) {
acc += scaleLinear(SECONDS_1000[i]!, d0, d1, r0, r1);
}
return acc;
});
});
describe('projection math — scaleLinear (px → time, invert)', () => {
const [d0, d1] = STRESS.domain;
const [r0, r1] = STRESS.range;
bench('100 pixels', () => {
let acc = 0;
for (let i = 0; i < PIXELS_100.length; i++) {
acc += scaleLinear(PIXELS_100[i]!, r0, r1, d0, d1);
}
return acc;
});
bench('1000 pixels', () => {
let acc = 0;
for (let i = 0; i < PIXELS_1000.length; i++) {
acc += scaleLinear(PIXELS_1000[i]!, r0, r1, d0, d1);
}
return acc;
});
});
describe('projection math — roundToStep (snap, pointer path)', () => {
const step = 0.5;
const decimals = getStepDecimals(step);
bench('100 values', () => {
let acc = 0;
for (let i = 0; i < SECONDS_100.length; i++) {
acc += roundToStep(SECONDS_100[i]!, step, 0, decimals);
}
return acc;
});
bench('1000 values', () => {
let acc = 0;
for (let i = 0; i < SECONDS_1000.length; i++) {
acc += roundToStep(SECONDS_1000[i]!, step, 0, decimals);
}
return acc;
});
});
// ---------------------------------------------------------------------------
// 3. Live scale via useScale — the projector closures the ruler actually calls
// (read domain/range/reverse at call time). Bench the realistic per-frame
// burst: project every tick value to a pixel + invert a pointer sweep.
// ---------------------------------------------------------------------------
describe('useScale — projector closures', () => {
const { scale, invert } = useScale({
domain: () => REALISTIC.domain,
range: () => REALISTIC.range,
tickKind: () => 'time',
tickOptions: () => ({ targetDensity: 80 }),
});
bench('scale() × 1000', () => {
let acc = 0;
for (let i = 0; i < SECONDS_1000.length; i++) acc += scale(SECONDS_1000[i]!);
return acc;
});
bench('invert() × 100 (pointer sweep)', () => {
let acc = 0;
for (let i = 0; i < PIXELS_100.length; i++) acc += invert(PIXELS_100[i]!);
return acc;
});
});
// ---------------------------------------------------------------------------
// 4. Per-mode label formatters — run once per major tick on every regenerate.
// ---------------------------------------------------------------------------
describe('label formatting — per mode', () => {
bench('formatClock × 1000 (seconds)', () => {
let len = 0;
for (let i = 0; i < SECONDS_1000.length; i++) len += formatClock(SECONDS_1000[i]!).length;
return len;
});
bench('formatTimecode × 1000 (timecode)', () => {
let len = 0;
for (let i = 0; i < SECONDS_1000.length; i++) len += formatTimecode(SECONDS_1000[i]!, FPS).length;
return len;
});
bench('framesToTimecode × 1000 — drop-frame', () => {
let len = 0;
for (let i = 0; i < FRAMES_1000.length; i++) len += framesToTimecode(FRAMES_1000[i]!, 29.97, true).length;
return len;
});
bench('formatFrames × 1000 (frames)', () => {
let len = 0;
for (let i = 0; i < FRAMES_1000.length; i++) len += formatFrames(FRAMES_1000[i]!).length;
return len;
});
bench('formatTimeForMode × 1000 — dispatch (timecode)', () => {
let len = 0;
for (let i = 0; i < SECONDS_1000.length; i++) len += formatTimeForMode(SECONDS_1000[i]!, 'timecode', FPS).length;
return len;
});
});
// ---------------------------------------------------------------------------
// 5. Mode → tick-kind / format selection (cheap, but runs on every prop change).
// ---------------------------------------------------------------------------
describe('mode plumbing', () => {
const modes = ['seconds', 'timecode', 'frames'] as const;
bench('modeToTickKind × 3 modes', () => {
for (const m of modes) modeToTickKind(m);
});
bench('tickFormatFor × 3 modes', () => {
for (const m of modes) tickFormatFor(m, FPS);
});
bench('secondsToFrames × 1000', () => {
let acc = 0;
for (let i = 0; i < SECONDS_1000.length; i++) acc += secondsToFrames(SECONDS_1000[i]!, FPS);
return acc;
});
});
// ---------------------------------------------------------------------------
// 6. Component lifecycle — mount cost (builds useScale + tick computeds + the
// accessible group), and a re-render after a prop change. The default slot
// renders one DOM node per tick, so this captures the real render-N cost.
// ---------------------------------------------------------------------------
// Render the ruler's tick layer + a cursor, exactly like the demo, so the slot
// work (v-for over `ticks`, per-tick class/style, cursor projection) is benched.
function rulerSlot() {
return {
default: ({ ticks, formatTime }: { ticks: Array<{ value: number; px: number; major: boolean; label: string }>; formatTime: (s: number) => string }) =>
ticks
.map(tick =>
h(
'div',
{ key: tick.value, class: tick.major ? 'major' : 'minor', style: { left: `${tick.px}px` } },
tick.major ? [h('span', tick.label)] : [],
),
)
.concat([
h(TimeRulerCursor as never, { time: 72 }, {
default: ({ time }: { time: number }) => h('span', formatTime(time)),
}),
]),
};
}
describe('TimeRulerRoot — mount', () => {
bench('mount — seconds mode', () => {
const w = mount(TimeRulerRoot, {
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'seconds', focusable: true, wheel: true, draggable: true },
slots: rulerSlot(),
attachTo: document.body,
});
w.unmount();
});
bench('mount — timecode mode', () => {
const w = mount(TimeRulerRoot, {
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'timecode', focusable: true },
slots: rulerSlot(),
attachTo: document.body,
});
w.unmount();
});
bench('mount — frames mode', () => {
const w = mount(TimeRulerRoot, {
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'frames', focusable: true },
slots: rulerSlot(),
attachTo: document.body,
});
w.unmount();
});
});
describe('TimeRulerRoot — re-render after prop change', () => {
bench('zoom change (pan/zoom gesture stream)', async () => {
const w = mount(TimeRulerRoot, {
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'timecode' },
slots: rulerSlot(),
attachTo: document.body,
});
// A zoom write re-derives the visible window → ticks computed → slot re-render.
await w.setProps({ zoom: 120 });
await nextTick();
w.unmount();
});
bench('offset change (pan stream)', async () => {
const w = mount(TimeRulerRoot, {
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'timecode' },
slots: rulerSlot(),
attachTo: document.body,
});
await w.setProps({ offset: 120 });
await nextTick();
w.unmount();
});
bench('mode change (timecode → frames, regenerate ladder)', async () => {
const w = mount(TimeRulerRoot, {
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'timecode' },
slots: rulerSlot(),
attachTo: document.body,
});
await w.setProps({ mode: 'frames' });
await nextTick();
w.unmount();
});
});
@@ -0,0 +1,397 @@
import { bench, describe } from 'vitest';
import { defineComponent, h, ref } from 'vue';
import { mount } from '@vue/test-utils';
import {
framesToTimecode,
scaleLinear,
secondsToFrames,
timeTicks,
timecodeTicks,
} from '../../../internal/scale';
import {
TimelineClip,
TimelinePlayhead,
TimelineRoot,
TimelineTrack,
TimelineTrackHeader,
TimelineTracks,
applyClipChanges,
applyTrackChanges,
clipIntersectsTime,
clipsDuration,
snapToFrame,
timeToTimecode,
} from '../index';
import type {
TimelineClipChange,
TimelineClipData,
TimelineTrackChange,
TimelineTrackData,
} from '../index';
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic fixtures (NO Math.random — every value is seeded by index).
//
// The timeline domain unit is SECONDS; pxPerSecond is the zoom. The hot paths
// are (a) the pure ruler/timecode/scale math recomputed on every pan/zoom, (b)
// the controlled-mode reducers folding @clips-change / @tracks-change batches,
// and (c) mounting + updating the headless component tree with N clips.
// ─────────────────────────────────────────────────────────────────────────────
const FPS = 30;
const PX_PER_SECOND = 90; // matches the demo's default zoom.
/** Build a deterministic clip array spread across `trackCount` lanes. */
function makeClips(count: number, trackCount: number): TimelineClipData[] {
const out: TimelineClipData[] = [];
for (let i = 0; i < count; i++) {
// Stagger starts so clips tile along time; vary duration by a fixed cycle.
const start = i * 1.5;
const duration = 0.5 + (i % 5) * 0.4;
out.push({
id: `c${i}`,
trackId: `t${i % trackCount}`,
start,
duration,
label: `Clip ${i}`,
color: i % 2 === 0 ? 'var(--color-accent)' : '#0ea5e9',
locked: i % 11 === 0,
});
}
return out;
}
/** Build a deterministic track array. */
function makeTracks(count: number): TimelineTrackData[] {
const out: TimelineTrackData[] = [];
for (let i = 0; i < count; i++) {
out.push({
id: `t${i}`,
label: `Track ${i}`,
height: 52 + (i % 3) * 6,
kind: i % 3 === 0 ? 'audio' : 'video',
});
}
return out;
}
// Pre-built fixture sets (module scope, simple loops — no randomness).
const clips100 = makeClips(100, 8);
const clips1000 = makeClips(1000, 16);
const tracks50 = makeTracks(50);
const tracks500 = makeTracks(500);
const clips50 = makeClips(50, 4);
const clips500 = makeClips(500, 8);
// A long visible time span so the tick generators emit a realistic tick count.
const SPAN_100 = clipsDuration(clips100); // ~150s
const SPAN_1000 = clipsDuration(clips1000); // ~1500s
const VIEWPORT_PX = 1200;
// Pre-built change batches for the reducers (mixed move/trim, deterministic).
function makeClipChanges(clips: TimelineClipData[], n: number): TimelineClipChange[] {
const out: TimelineClipChange[] = [];
for (let i = 0; i < n; i++) {
const clip = clips[i % clips.length]!;
if (i % 2 === 0) out.push({ type: 'move', id: clip.id, trackId: clip.trackId, start: clip.start + 0.25 });
else out.push({ type: 'trim', id: clip.id, start: clip.start, duration: clip.duration + 0.1 });
}
return out;
}
const clipChanges100 = makeClipChanges(clips100, 100);
const clipChanges1000 = makeClipChanges(clips1000, 1000);
function makeTrackChanges(tracks: TimelineTrackData[], n: number): TimelineTrackChange[] {
const out: TimelineTrackChange[] = [];
for (let i = 0; i < n; i++) {
const t = tracks[i % tracks.length]!;
out.push({ type: 'patch', id: t.id, patch: { height: 60 + (i % 4) * 4, muted: i % 2 === 0 } });
}
return out;
}
const trackChanges50 = makeTrackChanges(tracks50, 50);
const trackChanges500 = makeTrackChanges(tracks500, 500);
// ─────────────────────────────────────────────────────────────────────────────
// 1. Ruler tick generation — runs on EVERY pan / zoom / offset change.
// domain = [offset, offset + width / pxPerSecond] → range = [0, width].
// ─────────────────────────────────────────────────────────────────────────────
describe('ruler ticks — timecode (per pan/zoom)', () => {
bench('timecodeTicks — 100-clip span (~150s)', () => {
timecodeTicks({ domain: [0, SPAN_100], range: [0, SPAN_100 * PX_PER_SECOND], fps: FPS });
});
bench('timecodeTicks — 1000-clip span (~1500s)', () => {
timecodeTicks({ domain: [0, SPAN_1000], range: [0, SPAN_1000 * PX_PER_SECOND], fps: FPS });
});
bench('timecodeTicks — wide window, fixed viewport (1200px)', () => {
timecodeTicks({ domain: [0, SPAN_1000], range: [0, VIEWPORT_PX], fps: FPS });
});
});
describe('ruler ticks — wall clock (per pan/zoom)', () => {
bench('timeTicks — 100-clip span (~150s)', () => {
timeTicks({ domain: [0, SPAN_100], range: [0, SPAN_100 * PX_PER_SECOND] });
});
bench('timeTicks — 1000-clip span (~1500s)', () => {
timeTicks({ domain: [0, SPAN_1000], range: [0, SPAN_1000 * PX_PER_SECOND] });
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. Scale projection + timecode formatting — per clip / per tick, every render.
// ─────────────────────────────────────────────────────────────────────────────
describe('scale projection (scaleLinear over clips)', () => {
bench('scaleLinear — project 100 clip edges', () => {
let acc = 0;
for (let i = 0; i < clips100.length; i++) {
const c = clips100[i]!;
acc += scaleLinear(c.start, 0, SPAN_100, 0, VIEWPORT_PX);
acc += scaleLinear(c.start + c.duration, 0, SPAN_100, 0, VIEWPORT_PX);
}
return acc;
});
bench('scaleLinear — project 1000 clip edges', () => {
let acc = 0;
for (let i = 0; i < clips1000.length; i++) {
const c = clips1000[i]!;
acc += scaleLinear(c.start, 0, SPAN_1000, 0, VIEWPORT_PX);
acc += scaleLinear(c.start + c.duration, 0, SPAN_1000, 0, VIEWPORT_PX);
}
return acc;
});
});
describe('timecode formatting (per clip label)', () => {
bench('timeToTimecode — 100 clip durations', () => {
let len = 0;
for (let i = 0; i < clips100.length; i++) len += timeToTimecode(clips100[i]!.duration, FPS).length;
return len;
});
bench('timeToTimecode — 1000 clip durations', () => {
let len = 0;
for (let i = 0; i < clips1000.length; i++) len += timeToTimecode(clips1000[i]!.duration, FPS).length;
return len;
});
bench('framesToTimecode — 1000 (raw, pre-converted)', () => {
let len = 0;
for (let i = 0; i < clips1000.length; i++) {
const frames = secondsToFrames(clips1000[i]!.start, FPS);
len += framesToTimecode(frames, FPS).length;
}
return len;
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Snap-to-frame — keyboard nudge granularity + default snap grid.
// ─────────────────────────────────────────────────────────────────────────────
describe('snapToFrame (nudge / grid granularity)', () => {
bench('snapToFrame — 100 clip starts', () => {
let acc = 0;
for (let i = 0; i < clips100.length; i++) acc += snapToFrame(clips100[i]!.start + 0.017, FPS);
return acc;
});
bench('snapToFrame — 1000 clip starts', () => {
let acc = 0;
for (let i = 0; i < clips1000.length; i++) acc += snapToFrame(clips1000[i]!.start + 0.017, FPS);
return acc;
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. Marquee hit-testing — clipIntersectsTime per clip on every marquee move.
// Plus clipsDuration, the auto-duration recompute over the whole clip set.
// ─────────────────────────────────────────────────────────────────────────────
describe('marquee hit-test (clipIntersectsTime per pointer move)', () => {
// A simulated marquee window sweeping a fixed sub-range of the timeline.
const from = SPAN_100 * 0.3;
const to = SPAN_100 * 0.6;
bench('clipIntersectsTime — 100 clips', () => {
let hits = 0;
for (let i = 0; i < clips100.length; i++) if (clipIntersectsTime(clips100[i]!, from, to)) hits++;
return hits;
});
bench('clipIntersectsTime — 1000 clips', () => {
const f = SPAN_1000 * 0.3;
const t = SPAN_1000 * 0.6;
let hits = 0;
for (let i = 0; i < clips1000.length; i++) if (clipIntersectsTime(clips1000[i]!, f, t)) hits++;
return hits;
});
});
describe('clipsDuration (auto-duration recompute)', () => {
bench('clipsDuration — 100 clips', () => clipsDuration(clips100));
bench('clipsDuration — 1000 clips', () => clipsDuration(clips1000));
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. Controlled-mode reducers — fold a @clips-change / @tracks-change batch.
// This is the React-Flow-style applyNodeChanges hot path.
// ─────────────────────────────────────────────────────────────────────────────
describe('applyClipChanges (controlled reducer)', () => {
bench('applyClipChanges — 100 clips / 100 changes', () => {
applyClipChanges(clips100, clipChanges100);
});
bench('applyClipChanges — 1000 clips / 1000 changes', () => {
applyClipChanges(clips1000, clipChanges1000);
});
bench('applyClipChanges — 1000 clips / single move', () => {
applyClipChanges(clips1000, [
{ type: 'move', id: 'c500', trackId: 't0', start: 999 },
]);
});
});
describe('applyTrackChanges (controlled reducer)', () => {
bench('applyTrackChanges — 50 tracks / 50 patches', () => {
applyTrackChanges(tracks50, trackChanges50);
});
bench('applyTrackChanges — 500 tracks / 500 patches', () => {
applyTrackChanges(tracks500, trackChanges500);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. Component mount — the full headless tree with N clips/tracks.
// Builds a Root → Tracks → Track → Clip composition mirroring the demo, with
// a fixed-width viewport so the scale projects to real pixels.
// ─────────────────────────────────────────────────────────────────────────────
/** A full timeline composition over a live clips model (add/remove reflects). */
function makeTimeline(tracks: TimelineTrackData[], initialClips: TimelineClipData[]) {
return defineComponent({
props: { pxPerSecond: { type: Number, default: PX_PER_SECOND } },
setup(props) {
const clipsRef = ref<TimelineClipData[]>(initialClips.map(c => ({ ...c })));
return () => h(
TimelineRoot,
{
tracks,
clips: clipsRef.value,
'onUpdate:clips': (v: TimelineClipData[]) => { clipsRef.value = v; },
pxPerSecond: props.pxPerSecond,
'onUpdate:pxPerSecond': () => {},
fps: FPS,
trackHeight: 56,
style: 'width:1200px;display:block;',
},
{
default: () => [
h(TimelinePlayhead, {}, { default: () => 'PH' }),
h(
TimelineTracks,
{ style: 'position:relative;display:block;width:1200px;height:600px;' },
{
default: () => tracks.map(t => h(
TimelineTrack,
{ trackId: t.id, key: t.id, style: 'position:relative;display:block;' },
{
default: () => [
h(TimelineTrackHeader, {}, {}),
...clipsRef.value
.filter(c => c.trackId === t.id)
.map(c => h(
TimelineClip,
{ clipId: c.id, key: c.id },
{ default: () => c.label },
)),
],
},
)),
},
),
],
},
);
},
});
}
describe('TimelineRoot — mount (full tree)', () => {
bench('mount — 4 tracks / 50 clips', () => {
const w = mount(makeTimeline(tracks50.slice(0, 4), clips50), { attachTo: document.body });
w.unmount();
});
bench('mount — 8 tracks / 500 clips', () => {
const w = mount(makeTimeline(tracks500.slice(0, 8), clips500), { attachTo: document.body });
w.unmount();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 7. Re-render after a prop change — zoom (pxPerSecond) and clip-array updates.
// A zoom change re-projects every clip + rebuilds the ruler ticks; a clips
// swap re-reconciles the internal shallowRef Map.
// ─────────────────────────────────────────────────────────────────────────────
describe('TimelineRoot — update after prop change', () => {
bench('zoom change (pxPerSecond) — 8 tracks / 500 clips', async () => {
const w = mount(makeTimeline(tracks500.slice(0, 8), clips500), { attachTo: document.body });
await w.setProps({ pxPerSecond: PX_PER_SECOND * 2 });
w.unmount();
});
bench('clips-array swap — 8 tracks / 500 clips', async () => {
const Comp = defineComponent({
setup() {
const clipsRef = ref<TimelineClipData[]>(clips500.map(c => ({ ...c })));
const tracks = tracks500.slice(0, 8);
const swap = () => {
// Shift every start by a frame (new objects → reconcile path).
clipsRef.value = clipsRef.value.map(c => ({ ...c, start: c.start + 1 / FPS }));
};
return { clipsRef, tracks, swap };
},
render() {
return h(
TimelineRoot,
{ tracks: this.tracks, clips: this.clipsRef, pxPerSecond: PX_PER_SECOND, fps: FPS, style: 'width:1200px;display:block;' },
{
default: () => h(
TimelineTracks,
{ style: 'position:relative;display:block;width:1200px;height:600px;' },
{
default: () => this.tracks.map(t => h(
TimelineTrack,
{ trackId: t.id, key: t.id, style: 'position:relative;display:block;' },
{
default: () => this.clipsRef
.filter(c => c.trackId === t.id)
.map(c => h(TimelineClip, { clipId: c.id, key: c.id }, { default: () => c.label })),
},
)),
},
),
},
);
},
});
const w = mount(Comp, { attachTo: document.body });
(w.vm as unknown as { swap: () => void }).swap();
await w.vm.$nextTick();
w.unmount();
});
});
@@ -0,0 +1,386 @@
import { mount } from '@vue/test-utils';
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
TransformBoxHandle,
TransformBoxRoot,
TransformBoxRotateHandle,
TransformBoxStatus,
} from '../index';
import type { Point, TransformBoxHandlePosition, TransformBoxValue } from '../utils';
import {
applyAspectRatio,
boxCenter,
constrainRect,
decomposeTransform,
handleAxes,
localToWorld,
moveBox,
normalizeRotation,
pointerAngle,
resizeEdge,
resolvePivot,
rotatePoint,
rotateVector,
rotationFromPointer,
shortestAngleDelta,
snapRotation,
worldToLocal,
} from '../utils';
// ──────────────────────────────────────────────────────────────────────────────
// Deterministic fixtures (NO Math.random — every value is seeded by its index so
// runs are reproducible). Built once at module scope with simple loops.
// ──────────────────────────────────────────────────────────────────────────────
/** The 8 handle positions in the package's stable order. */
const POSITIONS: TransformBoxHandlePosition[] = [
'top-left',
'top',
'top-right',
'right',
'bottom-right',
'bottom',
'bottom-left',
'left',
];
/** A representative rotated box; the math hot path mostly trades on `rotation`. */
const BOX: TransformBoxValue = { x: 96, y: 64, width: 200, height: 130, rotation: -8 };
/** Build N deterministic boxes spread across position / size / rotation space. */
function makeBoxes(n: number): TransformBoxValue[] {
const out: TransformBoxValue[] = new Array(n);
for (let i = 0; i < n; i++) {
out[i] = {
x: (i % 400) - 200,
y: ((i * 3) % 400) - 200,
width: 20 + (i % 380),
height: 20 + ((i * 7) % 380),
// Sweep rotation across the full circle and across the ±180 seam.
rotation: ((i * 13) % 720) - 360,
};
}
return out;
}
/** Build N deterministic 2D points (used as pointer samples / deltas). */
function makePoints(n: number): Point[] {
const out: Point[] = new Array(n);
for (let i = 0; i < n; i++) {
// Lissajous-ish spread so x/y are decorrelated but fully deterministic.
out[i] = {
x: ((i * 31) % 800) - 400,
y: ((i * 17) % 600) - 300,
};
}
return out;
}
/** Pre-resolve handle axes for every position once. */
const AXES = POSITIONS.map(handleAxes);
const BOXES_100 = makeBoxes(100);
const BOXES_1000 = makeBoxes(1000);
const POINTS_100 = makePoints(100);
const POINTS_1000 = makePoints(1000);
/** A simulated pointer-move drag track about a pivot (rotation gesture frames). */
const PIVOT: Point = boxCenter(BOX);
const DRAG_100 = makePoints(100);
const DRAG_1000 = makePoints(1000);
const START_POINTER_ANGLE = pointerAngle(DRAG_100[0]!, PIVOT);
// ──────────────────────────────────────────────────────────────────────────────
// Pure rotation primitives — the innermost kernel every gesture frame calls.
// ──────────────────────────────────────────────────────────────────────────────
describe('rotatePoint — kernel', () => {
bench('rotatePoint × 100', () => {
for (let i = 0; i < POINTS_100.length; i++) {
rotatePoint(POINTS_100[i]!, 37, PIVOT);
}
});
bench('rotatePoint × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
rotatePoint(POINTS_1000[i]!, 37, PIVOT);
}
});
bench('rotateVector (origin-free) × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
rotateVector(POINTS_1000[i]!, -BOX.rotation);
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Pointer→angle math — the rotate-handle hot path (atan2 + seam-safe delta).
// ──────────────────────────────────────────────────────────────────────────────
describe('pointer angle math', () => {
bench('pointerAngle × 100', () => {
for (let i = 0; i < POINTS_100.length; i++) {
pointerAngle(POINTS_100[i]!, PIVOT);
}
});
bench('pointerAngle × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
pointerAngle(POINTS_1000[i]!, PIVOT);
}
});
bench('shortestAngleDelta × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
shortestAngleDelta(BOXES_1000[i]!.rotation, BOX.rotation);
}
});
bench('normalizeRotation × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
normalizeRotation(BOXES_1000[i]!.rotation);
}
});
bench('snapRotation (15°) × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
snapRotation(BOXES_1000[i]!.rotation, 15);
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Simulated rotate drag — one rotationFromPointer per pointer-move frame, the
// full per-frame computation the rotate handle does (angle + accumulate delta).
// ──────────────────────────────────────────────────────────────────────────────
describe('rotate drag — per-frame', () => {
bench('rotationFromPointer × 100 frames', () => {
for (let i = 0; i < DRAG_100.length; i++) {
rotationFromPointer(DRAG_100[i]!, PIVOT, START_POINTER_ANGLE, BOX.rotation);
}
});
bench('rotationFromPointer × 1000 frames', () => {
for (let i = 0; i < DRAG_1000.length; i++) {
rotationFromPointer(DRAG_1000[i]!, PIVOT, START_POINTER_ANGLE, BOX.rotation);
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// resizeEdge — the heaviest pure helper (anchor capture, aspect lock, flip,
// re-place, normalize). Driven every scale-handle pointer-move frame.
// ──────────────────────────────────────────────────────────────────────────────
describe('resizeEdge — per-frame', () => {
bench('resizeEdge corner (no options) × 100', () => {
for (let i = 0; i < POINTS_100.length; i++) {
resizeEdge(BOX, 'bottom-right', POINTS_100[i]!);
}
});
bench('resizeEdge corner (no options) × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
resizeEdge(BOX, 'bottom-right', POINTS_1000[i]!);
}
});
bench('resizeEdge aspect-locked corner × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
resizeEdge(BOX, 'bottom-right', POINTS_1000[i]!, { aspectRatio: 1.5 });
}
});
bench('resizeEdge symmetric (Alt) corner × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
resizeEdge(BOX, 'bottom-right', POINTS_1000[i]!, { symmetric: true, pivot: 'center' });
}
});
bench('resizeEdge edge handle × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
resizeEdge(BOX, 'right', POINTS_1000[i]!);
}
});
// Full scale-frame as the root runs it: rotate the screen delta into local
// axes first, then resize (the load-bearing step for rotated boxes).
bench('rotated scale frame (rotateVector → resizeEdge) × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
const local = rotateVector(POINTS_1000[i]!, -BOX.rotation);
resizeEdge(BOX, 'bottom-right', local, { minWidth: 40, minHeight: 40, allowFlip: true });
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// applyAspectRatio / handleAxes — small per-frame helpers.
// ──────────────────────────────────────────────────────────────────────────────
describe('aspect + axes helpers', () => {
const cornerAxes = handleAxes('bottom-right');
bench('applyAspectRatio × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
const b = BOXES_1000[i]!;
applyAspectRatio(b.width, b.height, 1.5, cornerAxes);
}
});
bench('handleAxes × 8 positions × 125 (=1000)', () => {
for (let i = 0; i < 125; i++) {
for (let j = 0; j < POSITIONS.length; j++) {
handleAxes(POSITIONS[j]!);
}
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// constrainRect / moveBox — commit-path normalization + whole-box drag.
// ──────────────────────────────────────────────────────────────────────────────
describe('constrain + move', () => {
bench('constrainRect × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
constrainRect(BOXES_1000[i]!, 40, 40);
}
});
bench('moveBox × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
moveBox(BOX, POINTS_1000[i]!);
}
});
bench('resolvePivot (center) × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
resolvePivot(BOXES_1000[i]!, 'center');
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// local⇄world round-trip — used to capture/re-place anchors against rotation.
// ──────────────────────────────────────────────────────────────────────────────
describe('local ⇄ world', () => {
bench('localToWorld → worldToLocal round-trip × 1000', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
const w = localToWorld(BOX, POINTS_1000[i]!);
worldToLocal(BOX, w);
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// decomposeTransform — Crop/overlay hot path: normalize + 4 rotated corners.
// ──────────────────────────────────────────────────────────────────────────────
describe('decomposeTransform — corners', () => {
bench('decomposeTransform × 100', () => {
for (let i = 0; i < BOXES_100.length; i++) {
decomposeTransform(BOXES_100[i]!);
}
});
bench('decomposeTransform × 1000', () => {
for (let i = 0; i < BOXES_1000.length; i++) {
decomposeTransform(BOXES_1000[i]!);
}
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Component mount + update — realistic (1 root, 8 handles) and stress scale.
// Mirrors the existing Primitive.bench convention: mount() then unmount().
// ──────────────────────────────────────────────────────────────────────────────
/** A harness with `count` independent transform boxes, each with 8 handles. */
function makeStage(count: number, value: TransformBoxValue) {
return defineComponent({
setup() {
return () => h(
'div',
null,
Array.from({ length: count }, (_, i) =>
h(
TransformBoxRoot,
{
key: i,
modelValue: value,
minWidth: 40,
minHeight: 40,
rotationSnap: 15,
},
{
default: () => [
...POSITIONS.map(p => h(TransformBoxHandle, { key: p, position: p })),
h(TransformBoxRotateHandle),
h(TransformBoxStatus),
],
},
)),
);
},
});
}
describe('TransformBoxRoot — mount full part set', () => {
bench('mount + unmount — 1 box (root + 8 handles + rotate + status)', () => {
const w = mount(makeStage(1, BOX), { attachTo: document.body });
w.unmount();
});
bench('mount + unmount — 50 boxes', () => {
const w = mount(makeStage(50, BOX), { attachTo: document.body });
w.unmount();
});
bench('mount + unmount — 500 boxes (stress)', () => {
const w = mount(makeStage(500, BOX), { attachTo: document.body });
w.unmount();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Re-render after a prop (transform v-model) change — restyle + reflow path for
// every part. Mirrors the "mount + update" bench in Primitive.bench.
// ──────────────────────────────────────────────────────────────────────────────
const BOX_B: TransformBoxValue = { x: 140, y: 90, width: 260, height: 180, rotation: 42 };
function makeUpdatableStage(count: number) {
return defineComponent({
props: { v: { type: Object, required: true } },
setup(props) {
return () => h(
'div',
null,
Array.from({ length: count }, (_, i) =>
h(
TransformBoxRoot,
{ key: i, modelValue: props.v as TransformBoxValue, minWidth: 40, minHeight: 40 },
{
default: () => POSITIONS.map(p => h(TransformBoxHandle, { key: p, position: p })),
},
)),
);
},
});
}
describe('TransformBoxRoot — update after transform change', () => {
bench('mount → setProps(transform) → update — 50 boxes', async () => {
const w = mount(makeUpdatableStage(50), {
props: { v: BOX },
attachTo: document.body,
});
await w.setProps({ v: BOX_B });
await nextTick();
w.unmount();
});
});
@@ -0,0 +1,283 @@
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { buildSmoothPath } from '../../../internal/spline';
import { buildBars, buildPathPoints, countBars, resamplePeaks } from '../utils';
import { WaveformBars, WaveformPath, WaveformRoot } from '../index';
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic fixtures (NO Math.random, NO network — seeded by index/formula).
// Peaks model a signed `-1..1` PCM-style envelope: a decaying sinusoid summed
// with a faster ripple, so resampling has real transients to pick a max from.
// ─────────────────────────────────────────────────────────────────────────────
function makePeaks(n: number): number[] {
const out = Array.from<number>({ length: n });
for (let i = 0; i < n; i++) {
const t = i / n;
const envelope = 1 - 0.6 * t; // slow decay over the track
const carrier = Math.sin(t * Math.PI * 64); // audible-rate oscillation
const ripple = 0.35 * Math.sin(t * Math.PI * 503); // transient detail
out[i] = envelope * (carrier * 0.7 + ripple); // stays within -1..1
}
return out;
}
// A Float32Array variant exercises the `ArrayLike<number>` fast path the root
// passes through from a `Float32Array` peaks prop (typed-array element reads).
function makePeaksF32(n: number): Float32Array {
const src = makePeaks(n);
const out = new Float32Array(n);
for (let i = 0; i < n; i++) out[i] = src[i]!;
return out;
}
const PEAKS_100 = makePeaks(100);
const PEAKS_1000 = makePeaks(1000);
const PEAKS_10000 = makePeaks(10000);
const PEAKS_F32_10000 = makePeaksF32(10000);
// Realistic body widths: a small inline waveform (~150 bars) and a full-bleed
// editor lane (~600+ bars) at the demo's barWidth=2 / barGap=1 (pitch 3).
const BAR_WIDTH = 2;
const BAR_GAP = 1;
const WIDTH_SMALL = 300; // → 100 bars
const WIDTH_LARGE = 1800; // → 600 bars
const SIGNED = true; // peaksRange '-1..1' (the root default → rectify by abs)
// Pre-built point sets for the smoothing benches (path mode silhouette).
const PATH_POINTS_256 = buildPathPoints(PEAKS_1000, WIDTH_LARGE, 120, 256, SIGNED);
const PATH_POINTS_1024 = buildPathPoints(PEAKS_10000, WIDTH_LARGE, 120, 1024, SIGNED);
// ─────────────────────────────────────────────────────────────────────────────
// countBars — cheap per-render geometry guard (runs on every width change).
// ─────────────────────────────────────────────────────────────────────────────
describe('countBars', () => {
bench('small body (300px)', () => {
countBars(WIDTH_SMALL, BAR_WIDTH, BAR_GAP);
});
bench('large body (1800px)', () => {
countBars(WIDTH_LARGE, BAR_WIDTH, BAR_GAP);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// resamplePeaks — THE hot inner loop (max-magnitude reduction per bucket).
// Scales with BOTH source length and bucket count; benched across realistic and
// stress combinations.
// ─────────────────────────────────────────────────────────────────────────────
describe('resamplePeaks — by source length (100 buckets)', () => {
bench('100 peaks', () => {
resamplePeaks(PEAKS_100, 100, SIGNED);
});
bench('1000 peaks', () => {
resamplePeaks(PEAKS_1000, 100, SIGNED);
});
bench('10000 peaks', () => {
resamplePeaks(PEAKS_10000, 100, SIGNED);
});
bench('10000 peaks (Float32Array)', () => {
resamplePeaks(PEAKS_F32_10000, 100, SIGNED);
});
});
describe('resamplePeaks — by bucket count (10000 peaks)', () => {
bench('100 buckets', () => {
resamplePeaks(PEAKS_10000, 100, SIGNED);
});
bench('600 buckets', () => {
resamplePeaks(PEAKS_10000, 600, SIGNED);
});
bench('upsample → 2000 buckets', () => {
resamplePeaks(PEAKS_10000, 2000, SIGNED);
});
});
describe('resamplePeaks — windowed slice (zoom/scroll)', () => {
// The root maps the visible time window onto a peaks index slice; a zoomed-in
// view resamples a sub-range into the same bucket count.
bench('full window — 600 buckets over 10000', () => {
resamplePeaks(PEAKS_10000, 600, SIGNED, 0, PEAKS_10000.length);
});
bench('25% zoom window — 600 buckets over slice', () => {
resamplePeaks(PEAKS_10000, 600, SIGNED, 2500, 5000);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// buildBars — bars-mode render hot path (countBars + resample + layout loop).
// This is what `WaveformRoot.buckets` computes on every width / peaks / window
// change. Benched at realistic (100 bars) and stress (600 bars) scale.
// ─────────────────────────────────────────────────────────────────────────────
describe('buildBars — bars-mode geometry', () => {
bench('100 bars from 1000 peaks', () => {
buildBars(PEAKS_1000, WIDTH_SMALL, BAR_WIDTH, BAR_GAP, SIGNED);
});
bench('600 bars from 10000 peaks', () => {
buildBars(PEAKS_10000, WIDTH_LARGE, BAR_WIDTH, BAR_GAP, SIGNED);
});
bench('600 bars from 10000 peaks (Float32Array)', () => {
buildBars(PEAKS_F32_10000, WIDTH_LARGE, BAR_WIDTH, BAR_GAP, SIGNED);
});
});
describe('buildBars — sliding window (simulated scrub/zoom recompute)', () => {
// Each iteration recomputes the full bar geometry over a window shifted by a
// deterministic step — the per-frame cost when a user scrubs a zoomed lane.
let frame = 0;
const total = PEAKS_10000.length;
const span = Math.floor(total / 4); // a 25% zoom window
bench('600 bars, window slides per iteration', () => {
const start = (frame * 137) % (total - span); // deterministic, no PRNG
frame += 1;
buildBars(PEAKS_10000, WIDTH_LARGE, BAR_WIDTH, BAR_GAP, SIGNED, start, start + span);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// buildPathPoints — path-mode polyline hot path (resample + y-projection loop).
// ─────────────────────────────────────────────────────────────────────────────
describe('buildPathPoints — path-mode silhouette', () => {
bench('256 samples from 1000 peaks', () => {
buildPathPoints(PEAKS_1000, WIDTH_LARGE, 120, 256, SIGNED);
});
bench('1024 samples from 10000 peaks', () => {
buildPathPoints(PEAKS_10000, WIDTH_LARGE, 120, 1024, SIGNED);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// buildSmoothPath — Catmull-Rom smoothing over the path points (the d-string
// `WaveformPath` recomputes on every width/peaks/window change in path mode).
// ─────────────────────────────────────────────────────────────────────────────
describe('buildSmoothPath — Catmull-Rom path string', () => {
bench('256 points, tension 0', () => {
buildSmoothPath(PATH_POINTS_256, 0);
});
bench('256 points, tension 0.5', () => {
buildSmoothPath(PATH_POINTS_256, 0.5);
});
bench('1024 points, tension 0', () => {
buildSmoothPath(PATH_POINTS_1024, 0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Component mount + update via @vue/test-utils mount().
// `WaveformRoot` measures its own width (useElementSize → 0 synchronously under
// the bench), so to render a deterministic N bars we drive `WaveformBars`'
// default slot directly with pre-built geometry. This isolates the Vue render
// cost of N bar nodes (the real per-frame DOM work) from ResizeObserver timing.
// ─────────────────────────────────────────────────────────────────────────────
const BARS_50 = buildBars(PEAKS_1000, 150, BAR_WIDTH, BAR_GAP, SIGNED); // 50 bars
const BARS_500 = buildBars(PEAKS_10000, 1500, BAR_WIDTH, BAR_GAP, SIGNED); // 500 bars
function mountWaveform(peaks: number[], duration: number) {
return mount(
defineComponent({
props: {
peaks: { type: Array as () => number[], required: true },
duration: { type: Number, required: true },
currentTime: { type: Number, default: 0 },
},
setup(props) {
return () =>
h(
WaveformRoot,
{
peaks: props.peaks,
peaksRange: '-1..1',
duration: props.duration,
currentTime: props.currentTime,
barWidth: BAR_WIDTH,
barGap: BAR_GAP,
},
{ default: () => h(WaveformBars) },
);
},
}),
{ props: { peaks, duration: 100, currentTime: 0 } },
);
}
describe('WaveformRoot + WaveformBars — mount', () => {
bench('mount with ~50-bar fixture', () => {
const w = mountWaveform(PEAKS_1000, 100);
w.unmount();
});
bench('mount with ~500-bar fixture', () => {
const w = mountWaveform(PEAKS_10000, 100);
w.unmount();
});
});
describe('WaveformRoot — update after prop change', () => {
bench('currentTime change → patch', async () => {
const w = mountWaveform(PEAKS_1000, 100);
await w.setProps({ currentTime: 42 });
await nextTick();
w.unmount();
});
bench('peaks swap → re-resample + patch', async () => {
const w = mountWaveform(PEAKS_1000, 100);
await w.setProps({ peaks: PEAKS_10000 });
await nextTick();
w.unmount();
});
});
// Path-mode component render: mount the SVG silhouette part (its `d` recompute
// drives buildPathPoints + buildSmoothPath).
describe('WaveformRoot + WaveformPath — mount', () => {
function mountPath(peaks: number[], samples: number) {
return mount(
defineComponent({
setup() {
return () =>
h(
WaveformRoot,
{ peaks, peaksRange: '-1..1', duration: 100, mode: 'path' },
{ default: () => h(WaveformPath, { samples }) },
);
},
}),
);
}
bench('path mode, 256 samples', () => {
const w = mountPath(PEAKS_1000, 256);
w.unmount();
});
bench('path mode, 1024 samples', () => {
const w = mountPath(PEAKS_10000, 1024);
w.unmount();
});
});
// Silence "fixture is unused" tree-shake concerns: reference the prebuilt arrays
// so the bench harness retains them deterministically across runs.
void BARS_50.length;
void BARS_500.length;
@@ -0,0 +1,412 @@
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import type { Rect, Viewport, XYPosition } from '../types';
import {
clampViewport,
clampZoom,
contentToScreen,
fitViewTransform,
measureContentRect,
screenToContent,
wheelToZoomFactor,
zoomAtPointer,
} from '../utils';
import { ViewportRoot } from '../index';
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic fixtures (NO Math.random — every value is a closed form of its
// index). Built once at module scope so the benched body does only the hot work.
// ─────────────────────────────────────────────────────────────────────────────
const ORIGIN = { left: 0, top: 0 };
const SURFACE_ORIGIN = { left: 64, top: 40 };
/** A stable viewport with a non-trivial pan + zoom for coordinate math. */
const VIEWPORT: Viewport = { x: 120, y: -48, zoom: 1.5 };
/** Constraints WITHOUT a translate extent (the common pan/zoom clamp). */
const CONSTRAINTS_ZOOM_ONLY = { minZoom: 0.3, maxZoom: 4 } as const;
/** Constraints WITH a translate extent (the boundary-clamped pan case). */
const CONSTRAINTS_EXTENT = {
minZoom: 0.3,
maxZoom: 4,
translateExtent: { minX: -2000, maxX: 2000, minY: -2000, maxY: 2000 },
} as const;
/** Constraints with a DEGENERATE extent on x (min > max → centring branch). */
const CONSTRAINTS_DEGENERATE = {
minZoom: 0.3,
maxZoom: 4,
translateExtent: { minX: 500, maxX: -500, minY: -2000, maxY: 2000 },
} as const;
/** Content bounds for fit-view math (content space). */
const CONTENT_BOUNDS: Rect = { x: 0, y: 0, width: 720, height: 480 };
const SURFACE_SIZE = { width: 400, height: 300 };
/**
* Pre-built point batches at realistic (100) and stress (1000) scale. Each
* coordinate is a deterministic spread of its index so values are non-uniform
* (exercising the divide/multiply paths) without any RNG.
*/
function buildPoints(n: number): XYPosition[] {
const out: XYPosition[] = new Array(n);
for (let i = 0; i < n; i++) {
out[i] = { x: (i * 37) % 1280, y: (i * 53) % 720 };
}
return out;
}
const POINTS_100 = buildPoints(100);
const POINTS_1000 = buildPoints(1000);
/**
* Candidate viewports to clamp, at realistic and stress scale. Each is pushed
* deliberately out of bounds (large pan, out-of-range zoom) so the clamp does
* real work on most entries.
*/
function buildViewports(n: number): Viewport[] {
const out: Viewport[] = new Array(n);
for (let i = 0; i < n; i++) {
out[i] = {
x: ((i * 311) % 8000) - 4000,
y: ((i * 173) % 8000) - 4000,
zoom: 0.1 + ((i * 7) % 50) / 10, // 0.1 .. 5.0
};
}
return out;
}
const VIEWPORTS_100 = buildViewports(100);
const VIEWPORTS_1000 = buildViewports(1000);
/**
* Pre-built WheelEvent fixtures spanning the three deltaMode branches and the
* ctrlKey (pinch) amplifier. Constructed in browser mode (Playwright chromium)
* where the WheelEvent constructor is real.
*/
function buildWheelEvents(n: number): WheelEvent[] {
const out: WheelEvent[] = new Array(n);
for (let i = 0; i < n; i++) {
out[i] = new WheelEvent('wheel', {
deltaY: ((i % 7) - 3) * 40, // -120 .. 120, includes 0
deltaX: ((i % 5) - 2) * 30,
deltaMode: i % 3, // 0 px, 1 line, 2 page
ctrlKey: i % 4 === 0, // ~25% pinch
});
}
return out;
}
const WHEEL_100 = buildWheelEvents(100);
const WHEEL_1000 = buildWheelEvents(1000);
/**
* A simulated pointer-move stream (surface-relative client points) used to drive
* the zoom-at-pointer hot path the same way `useZoomPan`'s wheel handler does:
* read current vp → factor → clamp → zoomAtPointer. One step per fixture entry.
*/
const POINTER_STREAM_100 = buildPoints(100);
const POINTER_STREAM_1000 = buildPoints(1000);
// Sinks to defeat dead-code elimination of pure-function results.
let sinkNum = 0;
let sinkPoint: XYPosition = { x: 0, y: 0 };
let sinkVp: Viewport = { x: 0, y: 0, zoom: 1 };
// ─────────────────────────────────────────────────────────────────────────────
// Pure coordinate math — screen↔content (the per-frame hit-test conversions).
// ─────────────────────────────────────────────────────────────────────────────
describe('screenToContent — over N points', () => {
bench('100 points', () => {
for (let i = 0; i < POINTS_100.length; i++)
sinkPoint = screenToContent(POINTS_100[i], VIEWPORT, SURFACE_ORIGIN);
});
bench('1000 points', () => {
for (let i = 0; i < POINTS_1000.length; i++)
sinkPoint = screenToContent(POINTS_1000[i], VIEWPORT, SURFACE_ORIGIN);
});
});
describe('contentToScreen — over N points', () => {
bench('100 points', () => {
for (let i = 0; i < POINTS_100.length; i++)
sinkPoint = contentToScreen(POINTS_100[i], VIEWPORT, SURFACE_ORIGIN);
});
bench('1000 points', () => {
for (let i = 0; i < POINTS_1000.length; i++)
sinkPoint = contentToScreen(POINTS_1000[i], VIEWPORT, SURFACE_ORIGIN);
});
});
describe('round-trip screen→content→screen — over N points', () => {
bench('100 points', () => {
for (let i = 0; i < POINTS_100.length; i++) {
const c = screenToContent(POINTS_100[i], VIEWPORT, ORIGIN);
sinkPoint = contentToScreen(c, VIEWPORT, ORIGIN);
}
});
bench('1000 points', () => {
for (let i = 0; i < POINTS_1000.length; i++) {
const c = screenToContent(POINTS_1000[i], VIEWPORT, ORIGIN);
sinkPoint = contentToScreen(c, VIEWPORT, ORIGIN);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// zoomAtPointer — the anchored-zoom transform run on every wheel/pinch step.
// ─────────────────────────────────────────────────────────────────────────────
describe('zoomAtPointer — over N anchor points', () => {
bench('100 points', () => {
for (let i = 0; i < POINTS_100.length; i++)
sinkVp = zoomAtPointer(VIEWPORT, VIEWPORT.zoom * 1.1, POINTS_100[i], SURFACE_ORIGIN);
});
bench('1000 points', () => {
for (let i = 0; i < POINTS_1000.length; i++)
sinkVp = zoomAtPointer(VIEWPORT, VIEWPORT.zoom * 1.1, POINTS_1000[i], SURFACE_ORIGIN);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// clampViewport — the per-write clamp. Three branches: zoom-only, with extent,
// and the degenerate (centring) extent. Each at realistic + stress scale.
// ─────────────────────────────────────────────────────────────────────────────
describe('clampViewport — zoom-only (no extent)', () => {
bench('100 viewports', () => {
for (let i = 0; i < VIEWPORTS_100.length; i++)
sinkVp = clampViewport(VIEWPORTS_100[i], CONSTRAINTS_ZOOM_ONLY);
});
bench('1000 viewports', () => {
for (let i = 0; i < VIEWPORTS_1000.length; i++)
sinkVp = clampViewport(VIEWPORTS_1000[i], CONSTRAINTS_ZOOM_ONLY);
});
});
describe('clampViewport — with translate extent', () => {
bench('100 viewports', () => {
for (let i = 0; i < VIEWPORTS_100.length; i++)
sinkVp = clampViewport(VIEWPORTS_100[i], CONSTRAINTS_EXTENT);
});
bench('1000 viewports', () => {
for (let i = 0; i < VIEWPORTS_1000.length; i++)
sinkVp = clampViewport(VIEWPORTS_1000[i], CONSTRAINTS_EXTENT);
});
});
describe('clampViewport — degenerate extent (centring branch)', () => {
bench('100 viewports', () => {
for (let i = 0; i < VIEWPORTS_100.length; i++)
sinkVp = clampViewport(VIEWPORTS_100[i], CONSTRAINTS_DEGENERATE);
});
bench('1000 viewports', () => {
for (let i = 0; i < VIEWPORTS_1000.length; i++)
sinkVp = clampViewport(VIEWPORTS_1000[i], CONSTRAINTS_DEGENERATE);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// wheelToZoomFactor — normalises deltaMode + ctrlKey on every wheel event.
// ─────────────────────────────────────────────────────────────────────────────
describe('wheelToZoomFactor — over N wheel events', () => {
bench('100 events', () => {
for (let i = 0; i < WHEEL_100.length; i++)
sinkNum += wheelToZoomFactor(WHEEL_100[i]);
});
bench('1000 events', () => {
for (let i = 0; i < WHEEL_1000.length; i++)
sinkNum += wheelToZoomFactor(WHEEL_1000[i]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Simulated wheel-zoom pipeline — the exact composition useZoomPan runs per
// wheel event: factor → clampZoom → zoomAtPointer (skipping the boundary no-op).
// ─────────────────────────────────────────────────────────────────────────────
describe('wheel-zoom pipeline (factor → clamp → zoomAtPointer)', () => {
bench('100 steps', () => {
let vp = VIEWPORT;
for (let i = 0; i < WHEEL_100.length; i++) {
const factor = wheelToZoomFactor(WHEEL_100[i]);
const next = clampZoom(vp.zoom * factor, CONSTRAINTS_ZOOM_ONLY.minZoom, CONSTRAINTS_ZOOM_ONLY.maxZoom);
if (next === vp.zoom) continue;
vp = zoomAtPointer(vp, next, POINTER_STREAM_100[i], SURFACE_ORIGIN);
}
sinkVp = vp;
});
bench('1000 steps', () => {
let vp = VIEWPORT;
for (let i = 0; i < WHEEL_1000.length; i++) {
const factor = wheelToZoomFactor(WHEEL_1000[i]);
const next = clampZoom(vp.zoom * factor, CONSTRAINTS_ZOOM_ONLY.minZoom, CONSTRAINTS_ZOOM_ONLY.maxZoom);
if (next === vp.zoom) continue;
vp = zoomAtPointer(vp, next, POINTER_STREAM_1000[i], SURFACE_ORIGIN);
}
sinkVp = vp;
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Simulated drag-pan move stream — the onMove body: translate from a base vp by
// the accumulated screen delta, then clamp. One scheduled write per move.
// ─────────────────────────────────────────────────────────────────────────────
describe('drag-pan move (translate + clamp)', () => {
bench('100 moves', () => {
const base = VIEWPORT;
for (let i = 0; i < POINTER_STREAM_100.length; i++) {
sinkVp = clampViewport(
{ zoom: base.zoom, x: base.x + POINTER_STREAM_100[i].x, y: base.y + POINTER_STREAM_100[i].y },
CONSTRAINTS_EXTENT,
);
}
});
bench('1000 moves', () => {
const base = VIEWPORT;
for (let i = 0; i < POINTER_STREAM_1000.length; i++) {
sinkVp = clampViewport(
{ zoom: base.zoom, x: base.x + POINTER_STREAM_1000[i].x, y: base.y + POINTER_STREAM_1000[i].y },
CONSTRAINTS_EXTENT,
);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// fitViewTransform — fit-to-view math (zoom + centre). Once per fit, but cheap
// enough that a tight loop gives a stable baseline.
// ─────────────────────────────────────────────────────────────────────────────
describe('fitViewTransform', () => {
bench('single fit', () => {
sinkVp = fitViewTransform(CONTENT_BOUNDS, SURFACE_SIZE, { padding: 0.1, minZoom: 0.3, maxZoom: 4 });
});
bench('100 fits', () => {
for (let i = 0; i < 100; i++)
sinkVp = fitViewTransform(CONTENT_BOUNDS, SURFACE_SIZE, { padding: 0.1, minZoom: 0.3, maxZoom: 4 });
});
});
// ─────────────────────────────────────────────────────────────────────────────
// measureContentRect — reads a real DOM rect (browser mode) and divides out zoom.
// A single live element measured repeatedly; the getBoundingClientRect read is
// the dominant cost, so this captures the measure hot path under real layout.
// ─────────────────────────────────────────────────────────────────────────────
const measureEl = document.createElement('div');
measureEl.style.cssText = 'position:absolute;left:0;top:0;width:200px;height:120px;';
document.body.appendChild(measureEl);
describe('measureContentRect (real getBoundingClientRect)', () => {
bench('100 measurements', () => {
for (let i = 0; i < 100; i++)
sinkVp = measureContentRect(measureEl, VIEWPORT, SURFACE_ORIGIN) as unknown as Viewport;
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Component: ViewportRoot mount with N content tiles (realistic 50, stress 500).
// Mirrors demo.vue: a single transformed content layer holding a grid of tiles.
// ─────────────────────────────────────────────────────────────────────────────
const CONTENT_EXTENT = { x: 0, y: 0, width: 720, height: 480 };
function makeTiles(n: number) {
return () =>
h(
'div',
{ style: 'display:grid;grid-template-columns:repeat(6,110px);gap:10px;width:720px;' },
Array.from({ length: n }, (_, i) =>
h('div', { key: i, style: 'height:110px;display:grid;place-items:center;' }, String(i)),
),
);
}
const tiles50 = makeTiles(50);
const tiles500 = makeTiles(500);
function mountRoot(tiles: () => ReturnType<typeof h>, viewport: Viewport) {
const Harness = defineComponent({
setup() {
return () =>
h(
ViewportRoot,
{
viewport,
'min-zoom': 0.3,
'max-zoom': 4,
'content-extent': CONTENT_EXTENT,
style: 'width:400px;height:300px;position:relative;overflow:hidden;',
},
{ default: tiles },
);
},
});
return mount(Harness, { attachTo: document.body });
}
describe('ViewportRoot — mount with N tiles', () => {
bench('50 tiles — mount + unmount', () => {
const w = mountRoot(tiles50, { x: 40, y: 40, zoom: 1 });
w.unmount();
});
bench('500 tiles — mount + unmount', () => {
const w = mountRoot(tiles500, { x: 40, y: 40, zoom: 1 });
w.unmount();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Component: re-render after a viewport prop change (the transform-update path —
// what every pan/zoom frame ultimately drives through the content layer).
// ─────────────────────────────────────────────────────────────────────────────
describe('ViewportRoot — update after viewport prop change', () => {
bench('50 tiles — mount + viewport update', async () => {
const viewport = ref<Viewport>({ x: 40, y: 40, zoom: 1 });
const Harness = defineComponent({
setup() {
return () =>
h(
ViewportRoot,
{
viewport: viewport.value,
'min-zoom': 0.3,
'max-zoom': 4,
'content-extent': CONTENT_EXTENT,
style: 'width:400px;height:300px;position:relative;overflow:hidden;',
},
{ default: tiles50 },
);
},
});
const w = mount(Harness, { attachTo: document.body });
viewport.value = { x: 80, y: -20, zoom: 1.75 };
await nextTick();
w.unmount();
});
bench('50 tiles — mount + minZoom prop update', async () => {
const w = mountRoot(tiles50, { x: 40, y: 40, zoom: 1 });
await w.setProps({});
await w.findComponent(ViewportRoot).setProps({ minZoom: 0.8 });
await nextTick();
w.unmount();
});
});
@@ -0,0 +1,240 @@
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick, ref, render } from 'vue';
import type { HSVA } from '../../../internal/color';
import { clampChannel, hsvToRgb, hsvaToCss } from '../../../internal/color';
import { useHsvaSetters } from '../../color-field/useColorState';
import { ColorAreaRoot, ColorAreaThumb } from '../index';
// ---------------------------------------------------------------------------
// Fixtures — deterministic, no Math.random / no network. The color area's hot
// path is per-pointer-move 2D saturation/value math + the preserve-hue setters
// that commit a fresh HSVA on every drag tick, plus the `hsvToRgb` background
// recompute. We seed every value by index/formula so runs are reproducible.
// ---------------------------------------------------------------------------
/** Build N deterministic HSVA samples sweeping hue/sat/val across their ranges. */
function makeColors(n: number): HSVA[] {
const out: HSVA[] = new Array(n);
for (let i = 0; i < n; i++) {
out[i] = {
h: (i * 360) / n, // 0 → 360 across the hue wheel (hits every sector)
s: (i % 100) / 100, // 0 → 0.99 (includes the grey s=0 edge)
v: ((i * 7) % 100) / 100, // de-correlated brightness sweep
a: 1,
};
}
return out;
}
const colors100 = makeColors(100);
const colors1000 = makeColors(1000);
/**
* Deterministic pointer samples relative to a 320x240 track rect, mimicking a
* drag that sweeps the whole square (the input to `setFromPointer`).
*/
const TRACK = { left: 16, top: 24, width: 320, height: 240 } as const;
function makePointers(n: number): Array<{ x: number; y: number }> {
const out = new Array<{ x: number; y: number }>(n);
for (let i = 0; i < n; i++) {
// Lissajous-ish deterministic sweep covering the rect (and slightly past it
// so clamping is exercised) without any randomness.
const t = i / n;
out[i] = {
x: TRACK.left + (Math.sin(i * 0.37) * 0.5 + 0.5) * TRACK.width,
y: TRACK.top + t * TRACK.height,
};
}
return out;
}
const pointers100 = makePointers(100);
const pointers1000 = makePointers(1000);
/**
* Pure replica of `ColorAreaRoot.setFromPointer`'s math (rect-relative
* normalize → optional RTL flip → clamp) so the pointer hot path is measured
* without DOM/layout noise. Returns the resolved saturation/value pair.
*/
function pointerToSV(
pt: { x: number; y: number },
rect: { left: number; top: number; width: number; height: number },
rtl: boolean,
): { s: number; v: number } {
if (rect.width === 0 || rect.height === 0) return { s: 0, v: 0 };
let sx = (pt.x - rect.left) / rect.width;
if (rtl) sx = 1 - sx;
const vy = 1 - (pt.y - rect.top) / rect.height;
return { s: clampChannel(sx, 1), v: clampChannel(vy, 1) };
}
// ---------------------------------------------------------------------------
// Pure pointer/clamp math — the per-move computation, by scale.
// ---------------------------------------------------------------------------
describe('pointer → saturation/value math', () => {
bench('pointerToSV — 100 moves (ltr)', () => {
for (let i = 0; i < pointers100.length; i++) {
pointerToSV(pointers100[i]!, TRACK, false);
}
});
bench('pointerToSV — 1000 moves (ltr)', () => {
for (let i = 0; i < pointers1000.length; i++) {
pointerToSV(pointers1000[i]!, TRACK, false);
}
});
bench('pointerToSV — 1000 moves (rtl flip)', () => {
for (let i = 0; i < pointers1000.length; i++) {
pointerToSV(pointers1000[i]!, TRACK, true);
}
});
});
describe('clampChannel — channel clamp', () => {
bench('clampChannel — 1000 calls', () => {
for (let i = 0; i < pointers1000.length; i++) {
// Drive past both rails to exercise both clamp branches deterministically.
clampChannel((i - 250) / 500, 1);
}
});
});
// ---------------------------------------------------------------------------
// hsvToRgb — the background-hue recompute (`hueColor`) + thumb swatch color.
// Runs on every hue change; sweeps all six hue sectors.
// ---------------------------------------------------------------------------
describe('hsvToRgb — hue background recompute', () => {
bench('hsvToRgb — 100 colors', () => {
for (let i = 0; i < colors100.length; i++) {
hsvToRgb({ h: colors100[i]!.h, s: 1, v: 1 });
}
});
bench('hsvToRgb — 1000 colors', () => {
for (let i = 0; i < colors1000.length; i++) {
hsvToRgb({ h: colors1000[i]!.h, s: 1, v: 1 });
}
});
bench('hsvaToCss — 1000 colors (full hsva)', () => {
for (let i = 0; i < colors1000.length; i++) {
hsvaToCss(colors1000[i]!);
}
});
});
// ---------------------------------------------------------------------------
// Preserve-hue setters — the state commit run on every drag tick / key nudge.
// `useHsvaSetters` tracks the last meaningful hue and rebuilds a fresh HSVA.
// We drive it through a deterministic sweep including the s=0 / v=0 grey edges.
// ---------------------------------------------------------------------------
describe('preserve-hue setters — drag/key commit', () => {
bench('setSaturationValue — 1000 commits (sweep incl. grey)', () => {
const hsva = ref<HSVA>({ h: 0, s: 1, v: 1, a: 1 });
const { setSaturationValue } = useHsvaSetters(hsva);
for (let i = 0; i < pointers1000.length; i++) {
const { s, v } = pointerToSV(pointers1000[i]!, TRACK, false);
setSaturationValue(s, v);
}
});
bench('setSaturation + setValue — 1000 key nudges', () => {
const hsva = ref<HSVA>({ h: 200, s: 0.5, v: 0.5, a: 1 });
const { setSaturation, setValue } = useHsvaSetters(hsva);
for (let i = 0; i < colors1000.length; i++) {
setSaturation(colors1000[i]!.s);
setValue(colors1000[i]!.v);
}
});
});
// ---------------------------------------------------------------------------
// Component mount — ColorAreaRoot + N ColorAreaThumb children. A single area
// normally has one thumb, but stacking N thumbs over the shared context stresses
// the provide/inject + per-thumb position/aria computeds at realistic (50) and
// heavy (500) scale.
// ---------------------------------------------------------------------------
function makeAreaWithThumbs(thumbCount: number, value: HSVA) {
return defineComponent({
setup() {
const model = ref<HSVA>(value);
return () =>
h(
ColorAreaRoot,
{
modelValue: model.value,
'onUpdate:modelValue': (v: HSVA | null) => {
if (v) model.value = v;
},
},
{
default: () => {
const thumbs = new Array(thumbCount);
for (let i = 0; i < thumbCount; i++) {
thumbs[i] = h(ColorAreaThumb, {
key: i,
'aria-label': `thumb-${i}`,
});
}
return thumbs;
},
},
);
},
});
}
describe('mount — ColorAreaRoot + N thumbs', () => {
const seed: HSVA = { h: 265, s: 0.72, v: 0.86, a: 1 };
bench('mount + unmount — 50 thumbs', () => {
const container = document.createElement('div');
render(h(makeAreaWithThumbs(50, seed)), container);
render(null, container);
});
bench('mount + unmount — 500 thumbs', () => {
const container = document.createElement('div');
render(h(makeAreaWithThumbs(500, seed)), container);
render(null, container);
});
});
// ---------------------------------------------------------------------------
// Re-render after a v-model change — the realistic interaction tick: updating
// the bound HSVA re-runs `hueColor` (hsvToRgb), the thumb `positionStyle`, and
// the `aria-valuetext`/`aria-valuenow` computeds, then patches the DOM.
// ---------------------------------------------------------------------------
describe('update — re-render after HSVA change', () => {
const a: HSVA = { h: 10, s: 0.3, v: 0.9, a: 1 };
const b: HSVA = { h: 280, s: 0.85, v: 0.4, a: 1 };
bench('1 thumb — mount then patch new HSVA', async () => {
const model = ref<HSVA>(a);
const Comp = defineComponent({
setup: () => () =>
h(
ColorAreaRoot,
{
modelValue: model.value,
'onUpdate:modelValue': (v: HSVA | null) => {
if (v) model.value = v;
},
},
{ default: () => h(ColorAreaThumb, { 'aria-label': 'thumb' }) },
),
});
const container = document.createElement('div');
render(h(Comp), container);
model.value = model.value === a ? b : a;
await nextTick();
render(null, container);
});
});
@@ -0,0 +1,313 @@
import { bench, describe } from 'vitest';
import { defineComponent, h, nextTick, render, shallowRef } from 'vue';
import { computeFrame, resolveAxisLock, usePointerDrag } from '..';
import type { DragBounds, DragModifiers, EffectiveAxis, Point } from '..';
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic fixtures (NO Math.random — every value is a pure formula of its
// index). Built once at module scope so bench bodies stay allocation-light.
// ─────────────────────────────────────────────────────────────────────────────
const ORIGIN: Point = { x: 0, y: 0 };
const NO_MOD: DragModifiers = { shift: false, alt: false, ctrl: false, meta: false };
const SHIFT: DragModifiers = { shift: true, alt: false, ctrl: false, meta: false };
const BOUNDS: DragBounds = { minX: -500, maxX: 500, minY: -500, maxY: 500 };
/** A tracked element rect (computeFrame only reads `.left` / `.top`). */
const RECT = { left: 120, top: 240 } as DOMRect;
/**
* Build a deterministic stream of pointer positions emulating one drag gesture.
* The x/y describe a slewing diagonal sweep so axis-lock, snap, and clamp all
* exercise non-trivial branches across the stream.
*/
function buildPointerStream(n: number): Point[] {
const out: Point[] = new Array(n);
for (let i = 0; i < n; i++) {
// Coupled but non-degenerate per-axis growth; integers keep snap meaningful.
out[i] = {
x: ((i * 7) % 211) - 105 + (i % 3),
y: ((i * 13) % 197) - 98 + (i % 5),
};
}
return out;
}
const STREAM_100 = buildPointerStream(100);
const STREAM_1000 = buildPointerStream(1000);
/** A matching stream of modifier snapshots so shift-lock fires on a 1/4 cadence. */
function buildModifierStream(n: number): DragModifiers[] {
const out: DragModifiers[] = new Array(n);
for (let i = 0; i < n; i++) out[i] = i % 4 === 0 ? SHIFT : NO_MOD;
return out;
}
const MODS_100 = buildModifierStream(100);
const MODS_1000 = buildModifierStream(1000);
// ─────────────────────────────────────────────────────────────────────────────
// resolveAxisLock — the per-frame axis-lock decision (called once per flush).
// ─────────────────────────────────────────────────────────────────────────────
describe('resolveAxisLock — per-frame axis decision', () => {
bench('static axis "x" — fast path (100 frames)', () => {
for (let i = 0; i < 100; i++) resolveAxisLock('x', true, MODS_100[i]!, STREAM_100[i]!);
});
bench('axis "both", no shift-lock (100 frames)', () => {
for (let i = 0; i < 100; i++) resolveAxisLock('both', false, MODS_100[i]!, STREAM_100[i]!);
});
bench('axis "both" + shift-lock dominant-axis pick (100 frames)', () => {
for (let i = 0; i < 100; i++) resolveAxisLock('both', true, MODS_100[i]!, STREAM_100[i]!);
});
bench('axis "both" + shift-lock dominant-axis pick (1000 frames)', () => {
for (let i = 0; i < 1000; i++) resolveAxisLock('both', true, MODS_1000[i]!, STREAM_1000[i]!);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// computeFrame — the pure per-frame math (raw total → axis lock → snap → clamp).
// This is THE hot path: one call per coalesced rAF flush per active gesture.
// ─────────────────────────────────────────────────────────────────────────────
describe('computeFrame — single frame (feature on/off matrix)', () => {
bench('free move, no snap/bounds/rect', () => {
computeFrame({
start: ORIGIN,
last: { x: 137, y: 211 },
rect: undefined,
axis: 'none',
snapGrid: undefined,
bounds: undefined,
prevTotal: ORIGIN,
});
});
bench('axis-locked + scalar snap + bounds + rect (all features)', () => {
computeFrame({
start: ORIGIN,
last: { x: 137, y: 211 },
rect: RECT,
axis: 'x',
snapGrid: 10,
bounds: BOUNDS,
prevTotal: { x: 50, y: 0 },
});
});
bench('tuple snap + bounds (per-axis grid)', () => {
computeFrame({
start: ORIGIN,
last: { x: 137, y: 211 },
rect: RECT,
axis: 'none',
snapGrid: [10, 25],
bounds: BOUNDS,
prevTotal: { x: 40, y: 60 },
});
});
});
describe('computeFrame — full gesture stream', () => {
bench('100 frames — free move (no snap/bounds)', () => {
const prev: Point = { x: 0, y: 0 };
for (let i = 0; i < 100; i++) {
const f = computeFrame({
start: ORIGIN,
last: STREAM_100[i]!,
rect: undefined,
axis: 'none',
snapGrid: undefined,
bounds: undefined,
prevTotal: prev,
});
prev.x = f.total.x;
prev.y = f.total.y;
}
});
bench('100 frames — snap + bounds + rect', () => {
const prev: Point = { x: 0, y: 0 };
for (let i = 0; i < 100; i++) {
const f = computeFrame({
start: ORIGIN,
last: STREAM_100[i]!,
rect: RECT,
axis: 'none',
snapGrid: 10,
bounds: BOUNDS,
prevTotal: prev,
});
prev.x = f.total.x;
prev.y = f.total.y;
}
});
bench('1000 frames — snap + bounds + rect (stress)', () => {
const prev: Point = { x: 0, y: 0 };
for (let i = 0; i < 1000; i++) {
const f = computeFrame({
start: ORIGIN,
last: STREAM_1000[i]!,
rect: RECT,
axis: 'none',
snapGrid: 10,
bounds: BOUNDS,
prevTotal: prev,
});
prev.x = f.total.x;
prev.y = f.total.y;
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Simulated flush pipeline — mirrors usePointerDrag's `flush()`: derive the raw
// total, resolveAxisLock against live modifiers, then computeFrame. This is the
// real per-pointermove cost minus the reactive write-back.
// ─────────────────────────────────────────────────────────────────────────────
function simulateFlush(
stream: Point[],
mods: DragModifiers[],
withFeatures: boolean,
): Point {
const start: Point = { x: 0, y: 0 };
const prev: Point = { x: 0, y: 0 };
const snapGrid = withFeatures ? 10 : undefined;
const bounds = withFeatures ? BOUNDS : undefined;
const rect = withFeatures ? RECT : undefined;
for (let i = 0; i < stream.length; i++) {
const last = stream[i]!;
const rawTotal: Point = { x: last.x - start.x, y: last.y - start.y };
const effectiveAxis: EffectiveAxis = resolveAxisLock('both', true, mods[i]!, rawTotal);
const frame = computeFrame({ start, last, rect, axis: effectiveAxis, snapGrid, bounds, prevTotal: prev });
prev.x = frame.total.x;
prev.y = frame.total.y;
}
return prev;
}
describe('simulated flush() pipeline — resolveAxisLock + computeFrame', () => {
bench('100 moves — shift-lock, no snap/bounds', () => {
simulateFlush(STREAM_100, MODS_100, false);
});
bench('100 moves — shift-lock + snap + bounds + rect', () => {
simulateFlush(STREAM_100, MODS_100, true);
});
bench('1000 moves — shift-lock + snap + bounds + rect (stress)', () => {
simulateFlush(STREAM_1000, MODS_1000, true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Component setup cost — usePointerDrag must run inside a component scope
// (reactive() + onScopeDispose). Bench the mount of N draggable instances, the
// realistic editor scale where one canvas hosts many independent drag handles.
// ─────────────────────────────────────────────────────────────────────────────
const DRAG_OPTIONS = {
axis: 'both',
lockAxisOnShift: true,
threshold: 3,
snapGrid: 10,
bounds: BOUNDS,
trackElementRect: true,
} as const;
/** A host that wires N independent usePointerDrag instances, one per handle. */
const DraggableHost = defineComponent({
props: { count: { type: Number, required: true } },
setup(props) {
const refs: Array<ReturnType<typeof shallowRef<HTMLElement | null>>> = [];
for (let i = 0; i < props.count; i++) {
const el = shallowRef<HTMLElement | null>(null);
usePointerDrag(el, DRAG_OPTIONS);
refs.push(el);
}
return () =>
h(
'div',
refs.map((el, i) => h('div', { ref: el, key: i, style: 'width:16px;height:16px;' })),
);
},
});
describe('usePointerDrag — mount N instances', () => {
bench('mount 50 draggable handles', () => {
const container = document.createElement('div');
render(h(DraggableHost, { count: 50 }), container);
render(null, container);
});
bench('mount 500 draggable handles (stress)', () => {
const container = document.createElement('div');
render(h(DraggableHost, { count: 500 }), container);
render(null, container);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Re-render after a prop change — remount the host at a new count to measure the
// teardown (onScopeDispose) + re-setup cost of the drag wiring under churn.
// ─────────────────────────────────────────────────────────────────────────────
describe('usePointerDrag — update after prop change', () => {
bench('50 handles → re-render to 60 handles', () => {
const container = document.createElement('div');
render(h(DraggableHost, { count: 50 }), container);
render(h(DraggableHost, { count: 60 }), container);
render(null, container);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Realistic single-handle drag round-trip via the live composable: real pointer
// events through the captured window listeners + rAF-coalesced flush.
// ─────────────────────────────────────────────────────────────────────────────
function dispatch(el: Element, type: string, x: number, y: number): void {
el.dispatchEvent(
new PointerEvent(type, { pointerId: 1, button: 0, clientX: x, clientY: y, bubbles: true, cancelable: true }),
);
}
function raf(): Promise<void> {
return new Promise(resolve => requestAnimationFrame(() => resolve()));
}
describe('usePointerDrag — live event round-trip (rAF-coalesced)', () => {
bench('mount + down + 20 moves + up', async () => {
const el = shallowRef<HTMLElement | null>(null);
const Harness = defineComponent({
setup() {
usePointerDrag(el, DRAG_OPTIONS);
return () => h('div', { ref: el, style: 'width:200px;height:200px;' });
},
});
const container = document.createElement('div');
document.body.appendChild(container);
render(h(Harness), container);
await nextTick();
const node = el.value!;
dispatch(node, 'pointerdown', 0, 0);
for (let i = 0; i < 20; i++) {
const p = STREAM_100[i]!;
dispatch(node, 'pointermove', p.x, p.y);
await raf(); // one coalesced flush per burst, as the composable schedules
}
dispatch(node, 'pointerup', STREAM_100[19]!.x, STREAM_100[19]!.y);
await raf();
render(null, container);
container.remove();
});
});
@@ -0,0 +1,66 @@
import { bench, describe } from 'vitest';
import { reactive } from 'vue';
// Isolates the core of the usePointerDrag fix. The drag state used to be a deep
// `reactive()` object whose ~13 nested fields are rewritten on every animation
// frame of a drag (see flush()), each assignment paying a Proxy set-trap plus a
// subscriber-less trigger. The fix makes it a plain object. This benches ONLY the
// per-frame writes (state allocated once at module scope, so the proxy-creation
// cost is excluded) — a deterministic, noise-immune view of what changed.
interface Pt { x: number; y: number }
interface DragStateShape {
startPoint: Pt;
point: Pt;
elementPoint: Pt;
delta: Pt;
total: Pt;
axis: string;
modifiers: { shift: boolean; alt: boolean; ctrl: boolean; meta: boolean };
pointerId: number;
pointerType: string;
}
function makeShape(): DragStateShape {
return {
startPoint: { x: 0, y: 0 },
point: { x: 0, y: 0 },
elementPoint: { x: 0, y: 0 },
delta: { x: 0, y: 0 },
total: { x: 0, y: 0 },
axis: 'none',
modifiers: { shift: false, alt: false, ctrl: false, meta: false },
pointerId: -1,
pointerType: '',
};
}
/** Mirror flush()'s per-frame field writes (the ~13 assignments per frame). */
function writeFrame(s: DragStateShape, i: number): void {
s.point.x = i;
s.point.y = i;
s.elementPoint.x = i;
s.elementPoint.y = i;
s.delta.x = 1;
s.delta.y = 1;
s.total.x = i;
s.total.y = i;
s.axis = i % 2 ? 'x' : 'none';
s.modifiers.shift = (i & 1) === 0;
}
// Allocated ONCE so the bench measures per-frame writes, not proxy construction.
const reactiveState = reactive(makeShape());
const plainState = makeShape();
const FRAMES = 1000;
describe('drag-state per-frame writes — OLD reactive() vs NEW plain object', () => {
bench('OLD — reactive() state · 1000 frames', () => {
for (let i = 0; i < FRAMES; i++) writeFrame(reactiveState, i);
});
bench('NEW — plain object state · 1000 frames', () => {
for (let i = 0; i < FRAMES; i++) writeFrame(plainState, i);
});
});
@@ -0,0 +1,155 @@
import { bench, describe } from 'vitest';
import { Comment, cloneVNode, createVNode, h, render } from 'vue';
import { Primitive, Slot } from '..';
const attrs1 = { class: 'a' };
const attrs5 = { class: 'a', id: 'b', role: 'button', tabindex: '0', title: 'tip' };
const attrs15 = {
class: 'a',
id: 'b',
style: { color: 'red' },
onClick: () => {},
role: 'button',
tabindex: '0',
title: 'tip',
'data-a': '1',
'data-b': '2',
'data-c': '3',
'data-d': '4',
'data-e': '5',
'data-f': '6',
'data-g': '7',
'data-h': '8',
};
const defaultSlot = { default: () => [h('span', 'content')] };
const noop = () => {};
// ---- Baselines (raw Vue calls) ----
describe('baseline: raw h()', () => {
bench('h() — 1 attr', () => {
h('div', attrs1, defaultSlot);
});
bench('h() — 5 attrs', () => {
h('div', attrs5, defaultSlot);
});
bench('h() — 15 attrs', () => {
h('div', attrs15, defaultSlot);
});
});
describe('baseline: raw cloneVNode()', () => {
const child = h('div', 'content');
bench('cloneVNode — 1 attr', () => {
cloneVNode(child, attrs1, true);
});
bench('cloneVNode — 5 attrs', () => {
cloneVNode(child, attrs5, true);
});
bench('cloneVNode — 15 attrs', () => {
cloneVNode(child, attrs15, true);
});
});
// ---- Primitive overhead vs raw h() ----
describe('Primitive vs h()', () => {
bench('h("div") — baseline', () => {
h('div', attrs5, defaultSlot);
});
bench('Primitive({ as: "div" })', () => {
Primitive({ as: 'div' }, { attrs: attrs5, slots: defaultSlot, emit: noop });
});
bench('Primitive({ as: "template" }) — Slot mode', () => {
Primitive({ as: 'template' }, { attrs: attrs5, slots: defaultSlot, emit: noop });
});
});
// ---- Slot scaling by attribute count ----
describe('Slot — scaling by attrs', () => {
bench('1 attr', () => {
Slot({} as never, { attrs: attrs1, slots: defaultSlot, emit: noop });
});
bench('5 attrs', () => {
Slot({} as never, { attrs: attrs5, slots: defaultSlot, emit: noop });
});
bench('15 attrs (mixed types)', () => {
Slot({} as never, { attrs: attrs15, slots: defaultSlot, emit: noop });
});
});
// ---- Slot edge cases ----
describe('Slot — edge cases', () => {
bench('child with comments to skip', () => {
Slot({} as never, {
attrs: attrs5,
slots: {
default: () => [
createVNode(Comment, null, 'skip'),
createVNode(Comment, null, 'skip'),
h('span', 'content'),
],
},
emit: noop,
});
});
bench('no default slot', () => {
Slot({} as never, { attrs: attrs5, slots: {}, emit: noop });
});
});
// ---- Slot — realistic attrs (fresh object per iteration) ----
describe('Slot — fresh attrs per call', () => {
bench('5 attrs (stable ref)', () => {
Slot({} as never, { attrs: attrs5, slots: defaultSlot, emit: noop });
});
bench('5 attrs (new object)', () => {
Slot({} as never, {
attrs: { class: 'a', id: 'b', role: 'button', tabindex: '0', title: 'tip' },
slots: defaultSlot,
emit: noop,
});
});
});
// ---- Realistic runtime: mount + update via render() ----
describe('Primitive — mount + update via render()', () => {
bench('h("div") — mount + update', () => {
const container = document.createElement('div');
render(h('div', attrs5, [h('span', 'content')]), container);
render(h('div', attrs15, [h('span', 'content')]), container);
render(null, container);
});
bench('Primitive({ as: "div" }) — mount + update', () => {
const container = document.createElement('div');
render(h(Primitive, { as: 'div', ...attrs5 }, defaultSlot), container);
render(h(Primitive, { as: 'div', ...attrs15 }, defaultSlot), container);
render(null, container);
});
bench('Primitive({ as: "template" }) — mount + update', () => {
const container = document.createElement('div');
render(h(Primitive, { as: 'template', ...attrs5 }, defaultSlot), container);
render(h(Primitive, { as: 'template', ...attrs15 }, defaultSlot), container);
render(null, container);
});
});
@@ -0,0 +1,324 @@
import { bench, describe } from 'vitest';
import { ref } from 'vue';
import {
formatClock,
formatFrames,
formatTimecode,
frameTicks,
framesToTimecode,
getClosestValueIndex,
getStepDecimals,
hasMinStepsBetweenSortedValues,
niceNum,
niceTicks,
roundToStep,
scaleLinear,
secondsToFrames,
timeTicks,
timecodeTicks,
useScale,
} from '..';
// ---------------------------------------------------------------------------
// Deterministic fixtures (NO Math.random — every value seeded by index/formula)
// ---------------------------------------------------------------------------
/** Pointer x-positions sweeping a 1000px range, 100 / 1000 samples. */
function buildPointerPx(n: number, span: number): number[] {
const out = new Array<number>(n);
for (let i = 0; i < n; i++) out[i] = (i / (n - 1)) * span;
return out;
}
/** Raw domain values (e.g. unsnapped seconds) sweeping a domain, 100 / 1000. */
function buildDomainValues(n: number, lo: number, hi: number): number[] {
const out = new Array<number>(n);
const span = hi - lo;
for (let i = 0; i < n; i++) {
// A deterministic non-linear sweep so snapping rounds in both directions.
const t = i / (n - 1);
out[i] = lo + span * (t * t * 0.5 + t * 0.5);
}
return out;
}
/** Sorted, evenly-spaced thumb values for the multi-thumb invariant checks. */
function buildSortedValues(n: number, lo: number, hi: number): number[] {
const out = new Array<number>(n);
const step = (hi - lo) / (n - 1);
for (let i = 0; i < n; i++) out[i] = lo + i * step;
return out;
}
const POINTER_100 = buildPointerPx(100, 1000);
const POINTER_1000 = buildPointerPx(1000, 1000);
const VALUES_100 = buildDomainValues(100, 0, 600);
const VALUES_1000 = buildDomainValues(1000, 0, 600);
const SORTED_100 = buildSortedValues(100, 0, 1000);
const SORTED_1000 = buildSortedValues(1000, 0, 1000);
// Frame numbers for timecode formatting (29.97 drop-frame is the costly path).
const FRAMES_100 = (() => {
const out = Array.from({ length: 100 });
for (let i = 0; i < 100; i++) out[i] = i * 1800; // ~1 min apart at 30fps
return out;
})();
const FRAMES_1000 = (() => {
const out = Array.from({ length: 1000 });
for (let i = 0; i < 1000; i++) out[i] = i * 180;
return out;
})();
// Realistic ruler geometry: 600s timeline at ~1.6px/s over a ~1000px viewport.
const REALISTIC = {
domain: [0, 600] as const,
range: [0, 1000] as const,
};
// Stress geometry: a 10-hour axis at high pixel density (deep tick generation).
const STRESS = {
domain: [0, 36_000] as const,
range: [0, 20_000] as const,
};
const STEP = 0.1;
const STEP_DECIMALS = getStepDecimals(STEP);
// ===========================================================================
// 1. Pure projection math — the pointermove hot path (scaleLinear / snapping)
// ===========================================================================
describe('math: scaleLinear (pointer projection)', () => {
bench('scaleLinear ×100', () => {
for (let i = 0; i < POINTER_100.length; i++) {
scaleLinear(POINTER_100[i]!, 0, 1000, 0, 600);
}
});
bench('scaleLinear ×1000', () => {
for (let i = 0; i < POINTER_1000.length; i++) {
scaleLinear(POINTER_1000[i]!, 0, 1000, 0, 600);
}
});
});
describe('math: roundToStep (snap-to-step hot path)', () => {
bench('roundToStep ×100', () => {
for (let i = 0; i < VALUES_100.length; i++) {
roundToStep(VALUES_100[i]!, STEP, 0, STEP_DECIMALS);
}
});
bench('roundToStep ×1000', () => {
for (let i = 0; i < VALUES_1000.length; i++) {
roundToStep(VALUES_1000[i]!, STEP, 0, STEP_DECIMALS);
}
});
});
describe('math: getStepDecimals (per-step cache miss)', () => {
// Vary the step so String() / indexOf actually run each call.
bench('getStepDecimals ×1000 (varied step)', () => {
for (let i = 0; i < 1000; i++) {
getStepDecimals(1 / 10 ** (i % 6));
}
});
});
describe('math: getClosestValueIndex (nearest-thumb pick)', () => {
bench('100 thumbs ×100 picks', () => {
for (let i = 0; i < POINTER_100.length; i++) {
getClosestValueIndex(SORTED_100, POINTER_100[i]!);
}
});
bench('1000 thumbs ×100 picks', () => {
for (let i = 0; i < POINTER_100.length; i++) {
getClosestValueIndex(SORTED_1000, POINTER_100[i]!);
}
});
});
describe('math: hasMinStepsBetweenSortedValues (drag invariant)', () => {
bench('100 values', () => {
hasMinStepsBetweenSortedValues(SORTED_100, 1, 1);
});
bench('1000 values', () => {
hasMinStepsBetweenSortedValues(SORTED_1000, 1, 1);
});
});
describe('math: niceNum (tick rounding primitive)', () => {
bench('niceNum ×1000 (varied magnitude)', () => {
for (let i = 0; i < 1000; i++) {
// Sweep magnitudes 1e-2 … 1e4 deterministically.
niceNum((1 + (i % 9)) * 10 ** ((i % 7) - 2), i % 2 === 0);
}
});
});
// ===========================================================================
// 2. Tick generators — the most expensive recompute per geometry change
// ===========================================================================
describe('ticks: niceTicks (realistic vs stress)', () => {
bench('realistic (600s axis)', () => {
niceTicks({ domain: REALISTIC.domain, range: REALISTIC.range });
});
bench('stress (10h axis, dense range)', () => {
niceTicks({ domain: STRESS.domain, range: STRESS.range });
});
bench('stress + custom format', () => {
niceTicks({
domain: STRESS.domain,
range: STRESS.range,
format: v => `#${v}`,
});
});
});
describe('ticks: timeTicks (human time ladder)', () => {
bench('realistic (600s axis)', () => {
timeTicks({ domain: REALISTIC.domain, range: REALISTIC.range });
});
bench('stress (10h axis, dense range)', () => {
timeTicks({ domain: STRESS.domain, range: STRESS.range });
});
});
describe('ticks: timecodeTicks (frame-aligned, fps conversion)', () => {
bench('realistic (600s @ 30fps)', () => {
timecodeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, fps: 30 });
});
bench('stress (10h @ 29.97fps drop-frame labels)', () => {
timecodeTicks({ domain: STRESS.domain, range: STRESS.range, fps: 29.97, dropFrame: true });
});
});
describe('ticks: frameTicks (integer-frame axis)', () => {
bench('realistic (18000-frame axis)', () => {
frameTicks({ domain: [0, 18_000], range: REALISTIC.range, fps: 30 });
});
bench('stress (1.08M-frame axis, dense range)', () => {
frameTicks({ domain: [0, 1_080_000], range: STRESS.range, fps: 30 });
});
});
// ===========================================================================
// 3. Timecode formatting — per-tick label cost (drop-frame is the worst case)
// ===========================================================================
describe('timecode: framesToTimecode label formatting', () => {
bench('non-drop ×100', () => {
for (let i = 0; i < FRAMES_100.length; i++) {
framesToTimecode(FRAMES_100[i]!, 30, false);
}
});
bench('drop-frame 29.97 ×100', () => {
for (let i = 0; i < FRAMES_100.length; i++) {
framesToTimecode(FRAMES_100[i]!, 29.97, true);
}
});
bench('drop-frame 29.97 ×1000', () => {
for (let i = 0; i < FRAMES_1000.length; i++) {
framesToTimecode(FRAMES_1000[i]!, 29.97, true);
}
});
});
describe('timecode: scalar label formatters', () => {
bench('formatClock ×1000', () => {
for (let i = 0; i < 1000; i++) formatClock(i * 7.5);
});
bench('formatTimecode ×1000 (@30fps)', () => {
for (let i = 0; i < 1000; i++) formatTimecode(i * 0.5, 30, false);
});
bench('formatFrames ×1000', () => {
for (let i = 0; i < 1000; i++) formatFrames(i * 137);
});
bench('secondsToFrames ×1000', () => {
for (let i = 0; i < 1000; i++) secondsToFrames(i * 0.0417, 23.976);
});
});
// ===========================================================================
// 4. useScale composable — build cost, pointer-move loop, reactive recompute
// ===========================================================================
describe('useScale: composable construction', () => {
bench('build (plain options)', () => {
useScale({ domain: REALISTIC.domain, range: REALISTIC.range });
});
bench('build (clamp + step + ticks)', () => {
useScale({
domain: REALISTIC.domain,
range: REALISTIC.range,
clamp: true,
step: 0.1,
tickKind: 'time',
});
});
});
describe('useScale: pointer-move loop (scale/invert/roundValue)', () => {
// Build once outside the bench body — the hot path is the per-event call,
// not construction. Closures read reactive sources at call time.
const s = useScale({
domain: REALISTIC.domain,
range: REALISTIC.range,
clamp: true,
step: 0.1,
});
bench('invert+round ×100 events', () => {
for (let i = 0; i < POINTER_100.length; i++) {
s.roundValue(s.invert(POINTER_100[i]!));
}
});
bench('invert+round ×1000 events', () => {
for (let i = 0; i < POINTER_1000.length; i++) {
s.roundValue(s.invert(POINTER_1000[i]!));
}
});
bench('scale ×1000 events', () => {
for (let i = 0; i < VALUES_1000.length; i++) {
s.scale(VALUES_1000[i]!);
}
});
});
describe('useScale: reactive tick recompute on domain change (zoom/pan)', () => {
// A reactive domain whose mutation invalidates the ticks computed, simulating
// a zoom/pan gesture forcing tick regeneration + major/minor split.
const domain = ref<readonly [number, number]>([0, 600]);
const s = useScale({ domain, range: REALISTIC.range, tickKind: 'time' });
// Prime the computed.
void s.ticks.value;
let frame = 0;
bench('zoom step → recompute ticks/major/minor', () => {
// Deterministic zoom: shrink/grow the window each iteration.
frame++;
const half = 50 + (frame % 250);
domain.value = [0, half * 2];
// Touch all three dependent computeds (what a ruler renders).
void s.ticks.value.length;
void s.majorTicks.value.length;
void s.minorTicks.value.length;
});
});
@@ -0,0 +1,319 @@
import { bench, describe } from 'vitest';
import {
buildBezierPath,
buildPolylinePath,
buildSmoothPath,
catmullRom,
cubicBezier1D,
cubicBezierTangent,
evalCubicBezier,
linearInterpolate,
monotoneCubic,
sampleFnToPolyline,
sampleToPolyline,
solveBezierX,
toLUT,
} from '../index';
import type { Point } from '../index';
// ---------------------------------------------------------------------------
// Deterministic fixtures (NO Math.random). Values are seeded by index/formula.
// These mirror realistic usage: knot lists for tone/gamma/levels curves, the
// 4 control points of a CSS easing bezier, and pointer coordinates for a drag.
// ---------------------------------------------------------------------------
/** Build `n` knots strictly ascending in x (required by the interpolants). */
function makeKnots(n: number): Point[] {
const out: Point[] = Array.from({ length: n });
for (let i = 0; i < n; i++) {
const x = i / (n - 1); // 0..1, strictly increasing
// A wobbly-but-monotone-ish y so monotoneCubic does real tangent work.
const y = 0.5 + 0.45 * Math.sin(i * 0.7) * (1 - i / (n * 4));
out[i] = { x, y };
}
return out;
}
/** Build `n` 2D points for Catmull-Rom / smooth-path (x need not be sorted). */
function makePath(n: number): Point[] {
const out: Point[] = Array.from({ length: n });
for (let i = 0; i < n; i++) {
const a = i * 0.37;
out[i] = { x: i * 4, y: 120 + 80 * Math.sin(a) + 30 * Math.cos(a * 2.1) };
}
return out;
}
/** Evenly spaced sample parameters in [0,1] for repeated evaluation. */
function makeParams(n: number): number[] {
const out: number[] = Array.from({ length: n });
for (let i = 0; i < n; i++)
out[i] = i / (n - 1);
return out;
}
const knots100 = makeKnots(100);
const knots1000 = makeKnots(1000);
const path50 = makePath(50);
const path500 = makePath(500);
const params100 = makeParams(100);
const params1000 = makeParams(1000);
// CSS "ease" control points — the canonical solveBezierX workload.
const EASE = { x1: 0.42, y1: 0, x2: 0.58, y2: 1 } as const;
// A single cubic bezier segment (e.g. one connector / handle).
const B0: Point = { x: 0, y: 0 };
const B1: Point = { x: 0.25, y: 1 };
const B2: Point = { x: 0.75, y: 0 };
const B3: Point = { x: 1, y: 1 };
// Pre-built smooth-path control points used by the simulated pointer-move bench.
const dragPath = makePath(64);
// ---------------------------------------------------------------------------
// evalCubicBezier — point on a cubic bezier, evaluated over many parameters.
// ---------------------------------------------------------------------------
describe('evalCubicBezier — sweep t', () => {
bench('100 params', () => {
for (let i = 0; i < params100.length; i++)
evalCubicBezier(B0, B1, B2, B3, params100[i]!);
});
bench('1000 params', () => {
for (let i = 0; i < params1000.length; i++)
evalCubicBezier(B0, B1, B2, B3, params1000[i]!);
});
});
// ---------------------------------------------------------------------------
// cubicBezierTangent — derivative/handle direction, evaluated over many t.
// ---------------------------------------------------------------------------
describe('cubicBezierTangent — sweep t', () => {
bench('100 params', () => {
for (let i = 0; i < params100.length; i++)
cubicBezierTangent(B0, B1, B2, B3, params100[i]!);
});
bench('1000 params', () => {
for (let i = 0; i < params1000.length; i++)
cubicBezierTangent(B0, B1, B2, B3, params1000[i]!);
});
});
// ---------------------------------------------------------------------------
// solveBezierX — Newton-Raphson easing solver (x → eased y). The hot path for
// every animation frame that maps progress through a CSS cubic-bezier easing.
// `cubicBezier1D` benched alongside as the scalar primitive it builds on.
// ---------------------------------------------------------------------------
describe('solveBezierX — ease (x→y)', () => {
bench('100 params', () => {
for (let i = 0; i < params100.length; i++)
solveBezierX(EASE.x1, EASE.y1, EASE.x2, EASE.y2, params100[i]!);
});
bench('1000 params', () => {
for (let i = 0; i < params1000.length; i++)
solveBezierX(EASE.x1, EASE.y1, EASE.x2, EASE.y2, params1000[i]!);
});
// Identity easing (x1===y1 && x2===y2) short-circuits — establishes the floor.
bench('1000 params — identity short-circuit', () => {
for (let i = 0; i < params1000.length; i++)
solveBezierX(0, 0, 1, 1, params1000[i]!);
});
});
describe('cubicBezier1D — scalar Bernstein', () => {
bench('1000 params', () => {
for (let i = 0; i < params1000.length; i++)
cubicBezier1D(0, EASE.x1, EASE.x2, 1, params1000[i]!);
});
});
// ---------------------------------------------------------------------------
// catmullRom — spline through N knots, evaluated over many t. Two axes vary:
// knot count (segment locating cost) and sample count (per-frame evaluation).
// ---------------------------------------------------------------------------
describe('catmullRom — sweep t', () => {
bench('50 knots × 100 params', () => {
for (let i = 0; i < params100.length; i++)
catmullRom(path50, params100[i]!);
});
bench('500 knots × 100 params', () => {
for (let i = 0; i < params100.length; i++)
catmullRom(path500, params100[i]!);
});
bench('500 knots × 1000 params', () => {
for (let i = 0; i < params1000.length; i++)
catmullRom(path500, params1000[i]!);
});
bench('500 knots × 1000 params — closed', () => {
for (let i = 0; i < params1000.length; i++)
catmullRom(path500, params1000[i]!, { closed: true });
});
});
// ---------------------------------------------------------------------------
// monotoneCubic — Fritsch-Carlson build (one-time, O(n)) then repeated lookup
// (binary-search per call). This is the real tone/gamma-curve workload: build
// the interpolant once when knots change, then apply it across a LUT every
// frame. Split into "build" and "build + apply 256-LUT" benches.
// ---------------------------------------------------------------------------
describe('monotoneCubic — build', () => {
bench('100 knots', () => {
monotoneCubic(knots100);
});
bench('1000 knots', () => {
monotoneCubic(knots1000);
});
});
describe('monotoneCubic — apply (pre-built fn)', () => {
const f100 = monotoneCubic(knots100);
const f1000 = monotoneCubic(knots1000);
bench('100 knots → 256-LUT', () => {
toLUT(f100, 256);
});
bench('1000 knots → 256-LUT', () => {
toLUT(f1000, 256);
});
// 8-bit channel LUT — the full per-channel color-correction pass.
bench('1000 knots → 1024-LUT', () => {
toLUT(f1000, 1024);
});
});
describe('monotoneCubic — build + apply (knots changed)', () => {
bench('100 knots → build + 256-LUT', () => {
toLUT(monotoneCubic(knots100), 256);
});
bench('1000 knots → build + 256-LUT', () => {
toLUT(monotoneCubic(knots1000), 256);
});
});
// ---------------------------------------------------------------------------
// linearInterpolate — binary-search piecewise-linear lookup over many queries.
// ---------------------------------------------------------------------------
describe('linearInterpolate — query sweep', () => {
bench('100 knots × 1000 queries', () => {
for (let i = 0; i < params1000.length; i++)
linearInterpolate(knots100, params1000[i]!);
});
bench('1000 knots × 1000 queries', () => {
for (let i = 0; i < params1000.length; i++)
linearInterpolate(knots1000, params1000[i]!);
});
});
// ---------------------------------------------------------------------------
// Polyline sampling — turn a parametric/scalar curve into render-ready points.
// ---------------------------------------------------------------------------
describe('sampleToPolyline — bezier curve', () => {
const curve = (t: number): Point => evalCubicBezier(B0, B1, B2, B3, t);
bench('100 segments', () => {
sampleToPolyline(curve, 100);
});
bench('1000 segments', () => {
sampleToPolyline(curve, 1000);
});
});
describe('sampleFnToPolyline — monotone curve', () => {
const f = monotoneCubic(knots100);
bench('100 segments', () => {
sampleFnToPolyline(f, 0, 1, 100);
});
bench('1000 segments', () => {
sampleFnToPolyline(f, 0, 1, 1000);
});
});
// ---------------------------------------------------------------------------
// SVG path string building — runs on every reactive re-render of a curve.
// ---------------------------------------------------------------------------
describe('buildPolylinePath — string concat', () => {
const poly100 = sampleToPolyline((t: number) => evalCubicBezier(B0, B1, B2, B3, t), 100);
const poly1000 = sampleToPolyline((t: number) => evalCubicBezier(B0, B1, B2, B3, t), 1000);
bench('100 points', () => {
buildPolylinePath(poly100);
});
bench('1000 points', () => {
buildPolylinePath(poly1000);
});
});
describe('buildSmoothPath — Catmull-Rom cubics', () => {
bench('50 points', () => {
buildSmoothPath(path50);
});
bench('500 points', () => {
buildSmoothPath(path500);
});
bench('500 points — tension 0.5', () => {
buildSmoothPath(path500, 0.5);
});
});
describe('buildBezierPath — single segment', () => {
bench('1 segment', () => {
buildBezierPath(B0, B1, B2, B3);
});
});
// ---------------------------------------------------------------------------
// Simulated pointer-move: a control point is dragged each frame, the smooth
// path is rebuilt, and a LUT is re-derived from the moved knots. This is the
// end-to-end per-frame cost during an interactive curve/handle drag.
// ---------------------------------------------------------------------------
describe('pointer-move — smooth path rebuild', () => {
bench('drag mutate + buildSmoothPath (64 points)', () => {
// Move one control point along a deterministic path (no Math.random).
const i = 32;
const base = dragPath[i]!;
dragPath[i] = { x: base.x, y: base.y + 1 };
buildSmoothPath(dragPath);
// Restore so the fixture stays stable across iterations.
dragPath[i] = base;
});
});
describe('pointer-move — curve recompute', () => {
// Drag a tone-curve knot → rebuild monotone interpolant → re-apply 256-LUT.
bench('mutate knot + monotoneCubic + 256-LUT (100 knots)', () => {
const i = 50;
const base = knots100[i]!;
knots100[i] = { x: base.x, y: base.y + 0.01 };
toLUT(monotoneCubic(knots100), 256);
knots100[i] = base;
});
});
@@ -0,0 +1,142 @@
import { bench, describe } from 'vitest';
import { Comment, Fragment, createVNode, h, render } from 'vue';
import { PatchFlags } from '@vue/shared';
import { getRawChildren } from '../getRawChildren';
// -- Helpers --
function keyedFragment(children: Array<ReturnType<typeof h>>) {
return createVNode(Fragment, null, children, PatchFlags.KEYED_FRAGMENT);
}
const flatChildren = [h('div'), h('span'), h('p')];
const keyedChildren = Array.from({ length: 10 }, (_, i) =>
h('div', { key: i }, `child-${i}`),
);
// ---- Processing cost ----
describe('getRawChildren', () => {
bench('flat elements', () => {
getRawChildren(flatChildren);
});
bench('mixed elements and comments', () => {
getRawChildren([
createVNode(Comment, null, 'c'),
h('div'),
createVNode(Comment, null, 'c'),
h('span'),
createVNode(Comment, null, 'c'),
]);
});
bench('single fragment with children', () => {
getRawChildren([createVNode(Fragment, null, [h('a'), h('b'), h('c')])]);
});
bench('nested fragments (depth 5)', () => {
let current: ReturnType<typeof h> = h('div');
for (let i = 0; i < 5; i++) {
current = createVNode(Fragment, null, [current, h('span')]);
}
getRawChildren([current]);
});
bench('wide fragment (50 children)', () => {
const children = Array.from({ length: 50 }, (_, i) => h('div', `child-${i}`));
getRawChildren([createVNode(Fragment, null, children)]);
});
});
// ---- BAIL path cost ----
describe('getRawChildren — BAIL path', () => {
bench('1 keyed fragment (no BAIL)', () => {
getRawChildren([keyedFragment([...keyedChildren])]);
});
bench('2 keyed fragments (BAIL triggered)', () => {
getRawChildren([
keyedFragment(keyedChildren.slice(0, 5)),
keyedFragment(keyedChildren.slice(5)),
]);
});
bench('3 keyed fragments (BAIL triggered)', () => {
getRawChildren([
keyedFragment(keyedChildren.slice(0, 3)),
keyedFragment(keyedChildren.slice(3, 7)),
keyedFragment(keyedChildren.slice(7)),
]);
});
});
// ---- Render impact: optimized patchFlags vs BAIL ----
describe('patch — optimized vs BAIL patchFlag', () => {
bench('patch with TEXT patchFlag', () => {
const container = document.createElement('div');
const initial = h('div', null, [
createVNode('span', null, 'a', PatchFlags.TEXT),
createVNode('span', null, 'b', PatchFlags.TEXT),
createVNode('span', null, 'c', PatchFlags.TEXT),
]);
const updated = h('div', null, [
createVNode('span', null, 'x', PatchFlags.TEXT),
createVNode('span', null, 'y', PatchFlags.TEXT),
createVNode('span', null, 'z', PatchFlags.TEXT),
]);
render(initial, container);
render(updated, container);
});
bench('patch with BAIL patchFlag', () => {
const container = document.createElement('div');
const initial = h('div', null, [
createVNode('span', null, 'a', PatchFlags.BAIL),
createVNode('span', null, 'b', PatchFlags.BAIL),
createVNode('span', null, 'c', PatchFlags.BAIL),
]);
const updated = h('div', null, [
createVNode('span', null, 'x', PatchFlags.BAIL),
createVNode('span', null, 'y', PatchFlags.BAIL),
createVNode('span', null, 'z', PatchFlags.BAIL),
]);
render(initial, container);
render(updated, container);
});
bench('patch with CLASS patchFlag', () => {
const container = document.createElement('div');
const initial = h('div', null, [
createVNode('span', { class: 'a' }, null, PatchFlags.CLASS),
createVNode('span', { class: 'b' }, null, PatchFlags.CLASS),
createVNode('span', { class: 'c' }, null, PatchFlags.CLASS),
]);
const updated = h('div', null, [
createVNode('span', { class: 'x' }, null, PatchFlags.CLASS),
createVNode('span', { class: 'y' }, null, PatchFlags.CLASS),
createVNode('span', { class: 'z' }, null, PatchFlags.CLASS),
]);
render(initial, container);
render(updated, container);
});
bench('patch with CLASS→BAIL patchFlag', () => {
const container = document.createElement('div');
const initial = h('div', null, [
createVNode('span', { class: 'a' }, null, PatchFlags.BAIL),
createVNode('span', { class: 'b' }, null, PatchFlags.BAIL),
createVNode('span', { class: 'c' }, null, PatchFlags.BAIL),
]);
const updated = h('div', null, [
createVNode('span', { class: 'x' }, null, PatchFlags.BAIL),
createVNode('span', { class: 'y' }, null, PatchFlags.BAIL),
createVNode('span', { class: 'z' }, null, PatchFlags.BAIL),
]);
render(initial, container);
render(updated, container);
});
});
@@ -0,0 +1,69 @@
import { bench, describe } from 'vitest';
// Baseline for the `getItems()` DOM-order sort in useCollection.ts. `getItems`
// runs per keystroke / per pointer-move across the roving-focus / menu / listbox
// / tree family, so its complexity dominates keyboard-nav and typeahead cost.
//
// This benches the SORT STRATEGY in isolation (the part the fix changed), at the
// list sizes those components realistically reach:
// OLD: comparator calls `orderedNodes.indexOf()` twice per comparison → each
// comparison is O(n), the whole sort O(n² log n).
// NEW: build a `Map<node, index>` once (O(n)), comparator does two O(1)
// lookups → O(n log n) overall.
// Fixtures are real detached elements (the same identity model querySelectorAll
// yields), seeded deterministically — NO Math.random.
interface Item { ref: HTMLElement; value: number }
/** Build `n` detached elements, the DOM-ordered array, and a deterministically
* shuffled `items` list (worst-ish case for a sort: not already ordered). */
function fixture(n: number): { ordered: HTMLElement[]; items: Item[] } {
const ordered: HTMLElement[] = [];
for (let i = 0; i < n; i++) ordered.push(document.createElement('div'));
// Deterministic shuffle: index-based stride so registration order ≠ DOM order.
const items: Item[] = [];
for (let i = 0; i < n; i++) {
const idx = (i * 7 + 3) % n;
items.push({ ref: ordered[idx]!, value: idx });
}
return { ordered, items };
}
/** OLD strategy: indexOf inside the comparator (O(n) per call). */
function sortOld(ordered: HTMLElement[], items: Item[]): Item[] {
const copy = items.slice();
copy.sort((a, b) => ordered.indexOf(a.ref) - ordered.indexOf(b.ref));
return copy;
}
/** NEW strategy: precomputed node→index Map, O(1) per comparison. */
function sortNew(ordered: HTMLElement[], items: Item[]): Item[] {
const copy = items.slice();
if (copy.length > 1) {
const orderByNode = new Map<Element, number>();
for (let i = 0; i < ordered.length; i++) orderByNode.set(ordered[i]!, i);
copy.sort((a, b) => {
const ai = orderByNode.get(a.ref);
const bi = orderByNode.get(b.ref);
return (ai === undefined ? -1 : ai) - (bi === undefined ? -1 : bi);
});
}
return copy;
}
const sizes = [12, 50, 200, 1000];
for (const n of sizes) {
const { ordered, items } = fixture(n);
describe(`getItems sort — ${n} items`, () => {
bench('OLD — indexOf-in-comparator (O(n² log n))', () => {
sortOld(ordered, items);
});
bench('NEW — Map<node,index> lookup (O(n log n))', () => {
sortNew(ordered, items);
});
});
}