perf(primitives): add performance audit report and vitest bench baselines
Library-wide Vue+V8 perf/leak audit (PERF_AUDIT.md) plus bench baselines for the hot-path modules (timeline, curve-editor, spline, pointer-drag, collection, etc.).
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
import type { VueWrapper } from '@vue/test-utils';
|
||||
import type { CurveEditorAnchor, CurveEditorInterpolation } from '../index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { bench, describe } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { CurveEditorCurve, CurveEditorPoint, CurveEditorRoot } from '../index';
|
||||
import {
|
||||
anchorsToPoints,
|
||||
buildEvaluator,
|
||||
clampAnchorX,
|
||||
clampAnchorY,
|
||||
sortAnchors,
|
||||
} from '../utils';
|
||||
import {
|
||||
buildBezierPath,
|
||||
buildPolylinePath,
|
||||
catmullRom,
|
||||
evalCubicBezier,
|
||||
linearInterpolate,
|
||||
monotoneCubic,
|
||||
sampleFnToPolyline,
|
||||
toLUT,
|
||||
} from '../../../internal/spline';
|
||||
|
||||
// ─── deterministic fixtures (NO Math.random — values seeded by index) ────────
|
||||
//
|
||||
// A monotone-in-x anchor set across the [0,1] domain. `x` is strictly
|
||||
// increasing (i / (n - 1)); `y` is a deterministic gentle S-shape via a
|
||||
// smoothstep-ish formula so every interpolation mode has real curvature to
|
||||
// chew on (a straight line would shortcut the monotone/bezier solvers).
|
||||
|
||||
function makeAnchors(n: number): CurveEditorAnchor[] {
|
||||
const out: CurveEditorAnchor[] = Array.from({ length: n });
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = n === 1 ? 0 : i / (n - 1);
|
||||
// smoothstep S-curve, nudged per-index so segments are not all identical.
|
||||
const s = x * x * (3 - 2 * x);
|
||||
const y = Math.min(1, Math.max(0, s + 0.05 * Math.sin(i)));
|
||||
out[i] = { id: `a${i}`, x, y };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Same set, but carrying bezier tangents so the 'bezier' build/eval/path paths
|
||||
// exercise the per-anchor handle branch (deltas relative to the anchor).
|
||||
function makeAnchorsWithHandles(n: number): CurveEditorAnchor[] {
|
||||
const base = makeAnchors(n);
|
||||
const dx = n > 1 ? 1 / (n - 1) : 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = base[i]!;
|
||||
base[i] = {
|
||||
...a,
|
||||
inHandle: { x: -dx / 3, y: -0.03 },
|
||||
outHandle: { x: dx / 3, y: 0.03 },
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// Realistic editor scale (a tone/easing curve is typically 4–16 anchors) and a
|
||||
// stress scale (a dense imported LUT / many keyframes).
|
||||
const anchors16 = makeAnchors(16);
|
||||
const anchors256 = makeAnchors(256);
|
||||
const bezierAnchors16 = makeAnchorsWithHandles(16);
|
||||
const bezierAnchors256 = makeAnchorsWithHandles(256);
|
||||
const points16 = anchorsToPoints(anchors16);
|
||||
const points256 = anchorsToPoints(anchors256);
|
||||
|
||||
// Shuffled (deterministically reversed-ish) copies to give `sortAnchors` real
|
||||
// work instead of an already-sorted no-op.
|
||||
function unsorted(src: readonly CurveEditorAnchor[]): CurveEditorAnchor[] {
|
||||
const a = src.slice();
|
||||
// deterministic interleave: pull from both ends.
|
||||
const out: CurveEditorAnchor[] = [];
|
||||
let lo = 0;
|
||||
let hi = a.length - 1;
|
||||
while (lo <= hi) {
|
||||
out.push(a[hi]!);
|
||||
if (lo !== hi) out.push(a[lo]!);
|
||||
lo++;
|
||||
hi--;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const unsorted16 = unsorted(anchors16);
|
||||
const unsorted256 = unsorted(anchors256);
|
||||
|
||||
// Pre-built evaluators (for the sampling-only benches that should NOT pay the
|
||||
// build cost on every iteration).
|
||||
const evalLinear16 = buildEvaluator(anchors16, 'linear');
|
||||
const evalMonotone16 = buildEvaluator(anchors16, 'monotone');
|
||||
const evalCatmull16 = buildEvaluator(anchors16, 'catmull-rom');
|
||||
const evalBezier16 = buildEvaluator(bezierAnchors16, 'bezier');
|
||||
const evalMonotone256 = buildEvaluator(anchors256, 'monotone');
|
||||
|
||||
// A fixed grid of probe x's to sample the evaluator at (the render/LUT density).
|
||||
function probeGrid(count: number): number[] {
|
||||
const xs: number[] = Array.from({ length: count });
|
||||
for (let i = 0; i < count; i++) xs[i] = i / (count - 1);
|
||||
return xs;
|
||||
}
|
||||
const probes256 = probeGrid(256);
|
||||
const probes1024 = probeGrid(1024);
|
||||
|
||||
const clampOpts = {
|
||||
domainMin: 0,
|
||||
domainMax: 1,
|
||||
monotonicX: true,
|
||||
fixedEndpoints: true,
|
||||
minGap: 0.001,
|
||||
};
|
||||
|
||||
// ─── 1. evaluator BUILD cost (per interpolation mode × scale) ────────────────
|
||||
// `buildEvaluator` does the heavy precompute (monotone tangents, catmull-rom
|
||||
// resample to a 256-pt table, bezier x-segment table). Rebuilt on every anchor
|
||||
// edit (`evaluator = computed(() => buildEvaluator(...))`), so its build cost is
|
||||
// on the edit hot path.
|
||||
|
||||
describe('buildEvaluator — build cost', () => {
|
||||
bench('linear — 16 anchors', () => {
|
||||
buildEvaluator(anchors16, 'linear');
|
||||
});
|
||||
bench('linear — 256 anchors', () => {
|
||||
buildEvaluator(anchors256, 'linear');
|
||||
});
|
||||
|
||||
bench('monotone — 16 anchors', () => {
|
||||
buildEvaluator(anchors16, 'monotone');
|
||||
});
|
||||
bench('monotone — 256 anchors', () => {
|
||||
buildEvaluator(anchors256, 'monotone');
|
||||
});
|
||||
|
||||
bench('catmull-rom — 16 anchors', () => {
|
||||
buildEvaluator(anchors16, 'catmull-rom');
|
||||
});
|
||||
bench('catmull-rom — 256 anchors', () => {
|
||||
buildEvaluator(anchors256, 'catmull-rom');
|
||||
});
|
||||
|
||||
bench('bezier — 16 anchors', () => {
|
||||
buildEvaluator(bezierAnchors16, 'bezier');
|
||||
});
|
||||
bench('bezier — 256 anchors', () => {
|
||||
buildEvaluator(bezierAnchors256, 'bezier');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. evaluator SAMPLING cost (pre-built closure × sample density) ─────────
|
||||
// Calling `f(x)` densely is the render/LUT hot path. Each `f(x)` does a binary
|
||||
// search + Hermite/linear eval (bezier additionally runs a Newton-Raphson
|
||||
// per call). 256 = the editor's default sample density; 1024 = a fine LUT.
|
||||
|
||||
describe('evaluator sampling — 256 samples', () => {
|
||||
bench('linear', () => {
|
||||
for (let i = 0; i < probes256.length; i++) evalLinear16(probes256[i]!);
|
||||
});
|
||||
bench('monotone', () => {
|
||||
for (let i = 0; i < probes256.length; i++) evalMonotone16(probes256[i]!);
|
||||
});
|
||||
bench('catmull-rom', () => {
|
||||
for (let i = 0; i < probes256.length; i++) evalCatmull16(probes256[i]!);
|
||||
});
|
||||
bench('bezier (Newton-Raphson per call)', () => {
|
||||
for (let i = 0; i < probes256.length; i++) evalBezier16(probes256[i]!);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator sampling — 1024 samples (stress)', () => {
|
||||
bench('monotone — 16 anchors', () => {
|
||||
for (let i = 0; i < probes1024.length; i++) evalMonotone16(probes1024[i]!);
|
||||
});
|
||||
bench('monotone — 256 anchors (deep binary search)', () => {
|
||||
for (let i = 0; i < probes1024.length; i++) evalMonotone256(probes1024[i]!);
|
||||
});
|
||||
bench('bezier — 16 anchors', () => {
|
||||
for (let i = 0; i < probes1024.length; i++) evalBezier16(probes1024[i]!);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. build + sample combined (the full per-edit cost, default density) ────
|
||||
// Mirrors what `CurveEditorRoot` pays each anchor edit: rebuild the evaluator,
|
||||
// then re-sample the whole curve for the rendered polyline.
|
||||
|
||||
describe('build + sample 256 (full per-edit, 16 anchors)', () => {
|
||||
bench('monotone', () => {
|
||||
const f = buildEvaluator(anchors16, 'monotone');
|
||||
for (let i = 0; i < probes256.length; i++) f(probes256[i]!);
|
||||
});
|
||||
bench('catmull-rom', () => {
|
||||
const f = buildEvaluator(anchors16, 'catmull-rom');
|
||||
for (let i = 0; i < probes256.length; i++) f(probes256[i]!);
|
||||
});
|
||||
bench('bezier', () => {
|
||||
const f = buildEvaluator(bezierAnchors16, 'bezier');
|
||||
for (let i = 0; i < probes256.length; i++) f(probes256[i]!);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. toLUT — pixel-application table (the consumer's apply hot path) ───────
|
||||
// `CurveEditorRoot.toLUT()` → `splineToLUT(evaluator, size, x0, x1)`. The 256
|
||||
// table backs an 8-bit channel; 1024 a higher-precision apply.
|
||||
|
||||
describe('toLUT — spline lookup table', () => {
|
||||
bench('monotone — 256 entries', () => {
|
||||
toLUT(evalMonotone16, 256, 0, 1);
|
||||
});
|
||||
bench('monotone — 1024 entries', () => {
|
||||
toLUT(evalMonotone16, 1024, 0, 1);
|
||||
});
|
||||
bench('bezier — 256 entries', () => {
|
||||
toLUT(evalBezier16, 256, 0, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. CurveEditorCurve path build (the SVG `d` hot path) ────────────────────
|
||||
// The rendered curve recomputes its `d` whenever anchors/interpolation/scale
|
||||
// change. Sampled modes: `sampleFnToPolyline` → project → `buildPolylinePath`.
|
||||
// Bezier mode: chain `buildBezierPath` per segment.
|
||||
|
||||
const pxScale = 320; // a typical plot box in px.
|
||||
function project(p: { x: number; y: number }): { x: number; y: number } {
|
||||
// value→pixel like CurveEditorPoint/Curve (y value-up): cheap linear map.
|
||||
return { x: p.x * pxScale, y: (1 - p.y) * pxScale };
|
||||
}
|
||||
|
||||
describe('curve path `d` build — sampled polyline (256 samples)', () => {
|
||||
bench('monotone — sample + project + buildPolylinePath', () => {
|
||||
const samples = sampleFnToPolyline(x => evalMonotone16(x), 0, 1, 256);
|
||||
for (let i = 0; i < samples.length; i++) samples[i] = project(samples[i]!);
|
||||
buildPolylinePath(samples);
|
||||
});
|
||||
bench('catmull-rom — sample + project + buildPolylinePath', () => {
|
||||
const samples = sampleFnToPolyline(x => evalCatmull16(x), 0, 1, 256);
|
||||
for (let i = 0; i < samples.length; i++) samples[i] = project(samples[i]!);
|
||||
buildPolylinePath(samples);
|
||||
});
|
||||
});
|
||||
|
||||
describe('curve path `d` build — bezier segment chain', () => {
|
||||
function buildBezierD(list: readonly CurveEditorAnchor[]): string {
|
||||
let d = '';
|
||||
for (let i = 0; i < list.length - 1; i++) {
|
||||
const a = list[i]!;
|
||||
const b = list[i + 1]!;
|
||||
const dx = b.x - a.x;
|
||||
const c1x = a.outHandle ? a.x + a.outHandle.x : a.x + dx / 3;
|
||||
const c1y = a.outHandle ? a.y + a.outHandle.y : a.y + (b.y - a.y) / 3;
|
||||
const c2x = b.inHandle ? b.x + b.inHandle.x : b.x - dx / 3;
|
||||
const c2y = b.inHandle ? b.y + b.inHandle.y : b.y - (b.y - a.y) / 3;
|
||||
const seg = buildBezierPath(
|
||||
project({ x: a.x, y: a.y }),
|
||||
project({ x: c1x, y: c1y }),
|
||||
project({ x: c2x, y: c2y }),
|
||||
project({ x: b.x, y: b.y }),
|
||||
);
|
||||
d += i === 0 ? seg : seg.replace(/^M[^C]*/, '');
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
bench('16 anchors (15 segments)', () => {
|
||||
buildBezierD(bezierAnchors16);
|
||||
});
|
||||
bench('256 anchors (255 segments)', () => {
|
||||
buildBezierD(bezierAnchors256);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. raw spline primitives (sub-operation baselines) ──────────────────────
|
||||
|
||||
describe('spline primitives — per-call baselines', () => {
|
||||
bench('linearInterpolate — 256-pt table lookup', () => {
|
||||
linearInterpolate(points256, 0.4321);
|
||||
});
|
||||
bench('catmullRom — 16-pt parametric eval', () => {
|
||||
catmullRom(points16, 0.4321);
|
||||
});
|
||||
bench('evalCubicBezier — single cubic eval', () => {
|
||||
evalCubicBezier(
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0.33, y: 0.1 },
|
||||
{ x: 0.66, y: 0.9 },
|
||||
{ x: 1, y: 1 },
|
||||
0.4321,
|
||||
);
|
||||
});
|
||||
bench('monotoneCubic — build closure (16 pts)', () => {
|
||||
monotoneCubic(points16);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 7. anchor housekeeping (sort / project) at scale ────────────────────────
|
||||
|
||||
describe('anchor housekeeping', () => {
|
||||
bench('sortAnchors — 16 (unsorted)', () => {
|
||||
sortAnchors(unsorted16);
|
||||
});
|
||||
bench('sortAnchors — 256 (unsorted)', () => {
|
||||
sortAnchors(unsorted256);
|
||||
});
|
||||
bench('anchorsToPoints — 16', () => {
|
||||
anchorsToPoints(anchors16);
|
||||
});
|
||||
bench('anchorsToPoints — 256', () => {
|
||||
anchorsToPoints(anchors256);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 8. pointer-move drag math (the pointermove hot path) ─────────────────────
|
||||
// `CurveEditorPoint.onMove` → `clampAnchorX` + `clampAnchorY`. Pure clamp math,
|
||||
// fired once per pointermove. Bench the clamp alone, and a simulated full
|
||||
// `updateAnchor` step (clamp + slice-replace, the per-frame array churn).
|
||||
|
||||
describe('pointer-move clamp math', () => {
|
||||
bench('clampAnchorX — interior anchor (neighbour clamp), 16', () => {
|
||||
// index 8 has both neighbours → the monotonic-x branch does real work.
|
||||
clampAnchorX(anchors16, 8, 0.5321, clampOpts);
|
||||
});
|
||||
bench('clampAnchorX — interior anchor (neighbour clamp), 256', () => {
|
||||
clampAnchorX(anchors256, 128, 0.5321, clampOpts);
|
||||
});
|
||||
bench('clampAnchorY — domain clamp', () => {
|
||||
clampAnchorY(1.2345, 0, 1);
|
||||
});
|
||||
|
||||
bench('simulated updateAnchor step — clamp + slice-replace, 16', () => {
|
||||
const list = anchors16;
|
||||
const index = 8;
|
||||
const cur = list[index]!;
|
||||
const x = clampAnchorX(list, index, 0.5321, clampOpts);
|
||||
const y = clampAnchorY(0.4567, 0, 1);
|
||||
const candidate = list.slice();
|
||||
candidate[index] = { ...cur, x, y };
|
||||
});
|
||||
bench('simulated updateAnchor step — clamp + slice-replace, 256', () => {
|
||||
const list = anchors256;
|
||||
const index = 128;
|
||||
const cur = list[index]!;
|
||||
const x = clampAnchorX(list, index, 0.5321, clampOpts);
|
||||
const y = clampAnchorY(0.4567, 0, 1);
|
||||
const candidate = list.slice();
|
||||
candidate[index] = { ...cur, x, y };
|
||||
});
|
||||
});
|
||||
|
||||
// A full simulated drag stroke: 60 pointermove frames each rebuilding the
|
||||
// evaluator + re-sampling (what one second of dragging at 60fps costs).
|
||||
describe('simulated drag stroke (60 frames, monotone, 16 anchors)', () => {
|
||||
bench('clamp + rebuild + sample-256 per frame', () => {
|
||||
let list = anchors16;
|
||||
for (let frame = 0; frame < 60; frame++) {
|
||||
const index = 8;
|
||||
const cur = list[index]!;
|
||||
// deterministic sweep across the frame index (no Math.random).
|
||||
const nx = clampAnchorX(list, index, 0.3 + (frame / 60) * 0.4, clampOpts);
|
||||
const ny = clampAnchorY(0.2 + (frame / 60) * 0.6, 0, 1);
|
||||
const candidate = list.slice();
|
||||
candidate[index] = { ...cur, x: nx, y: ny };
|
||||
list = candidate;
|
||||
const f = buildEvaluator(list, 'monotone');
|
||||
for (let i = 0; i < probes256.length; i++) f(probes256[i]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 9. component mount — Root + N Points + Curve (realistic & stress) ────────
|
||||
|
||||
const wrappers: Array<VueWrapper<any>> = [];
|
||||
function teardown(): void {
|
||||
while (wrappers.length) wrappers.pop()!.unmount();
|
||||
document.body.innerHTML = '';
|
||||
}
|
||||
|
||||
function makeHarness(
|
||||
data: CurveEditorAnchor[],
|
||||
interpolation: CurveEditorInterpolation,
|
||||
) {
|
||||
return defineComponent({
|
||||
setup: () => () => h(
|
||||
CurveEditorRoot,
|
||||
{ defaultValue: data, interpolation },
|
||||
{
|
||||
default: ({ anchors }: { anchors: CurveEditorAnchor[] }) => [
|
||||
h(CurveEditorCurve),
|
||||
...anchors.map(a => h(CurveEditorPoint, { key: a.id, anchor: a })),
|
||||
],
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const Harness50Monotone = makeHarness(makeAnchors(50), 'monotone');
|
||||
const Harness500Monotone = makeHarness(makeAnchors(500), 'monotone');
|
||||
const Harness50Bezier = makeHarness(makeAnchorsWithHandles(50), 'bezier');
|
||||
|
||||
describe('mount — Root + Curve + N Points', () => {
|
||||
bench('50 points (monotone)', () => {
|
||||
const w = mount(Harness50Monotone, { attachTo: document.body });
|
||||
wrappers.push(w);
|
||||
teardown();
|
||||
});
|
||||
bench('500 points (monotone, stress)', () => {
|
||||
const w = mount(Harness500Monotone, { attachTo: document.body });
|
||||
wrappers.push(w);
|
||||
teardown();
|
||||
});
|
||||
bench('50 points (bezier path)', () => {
|
||||
const w = mount(Harness50Bezier, { attachTo: document.body });
|
||||
wrappers.push(w);
|
||||
teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 10. re-render / update after a prop change ──────────────────────────────
|
||||
// Switching interpolation and replacing the model both invalidate the evaluator
|
||||
// + every point's projection + the curve `d`. Bench an in-place update via
|
||||
// setProps (the realistic "user toggled mode / committed an edit" path).
|
||||
|
||||
const dataA = makeAnchors(50);
|
||||
const dataB = makeAnchors(50).map((a, i) => ({ ...a, y: Math.min(1, a.y + 0.1 * Math.cos(i)) }));
|
||||
|
||||
function makeControlledHarness() {
|
||||
return defineComponent({
|
||||
props: {
|
||||
modelValue: { type: Array as () => CurveEditorAnchor[], required: true },
|
||||
interpolation: { type: String as () => CurveEditorInterpolation, default: 'monotone' },
|
||||
},
|
||||
setup: props => () => h(
|
||||
CurveEditorRoot,
|
||||
{ modelValue: props.modelValue, interpolation: props.interpolation },
|
||||
{
|
||||
default: ({ anchors }: { anchors: CurveEditorAnchor[] }) => [
|
||||
h(CurveEditorCurve),
|
||||
...anchors.map(a => h(CurveEditorPoint, { key: a.id, anchor: a })),
|
||||
],
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
const ControlledHarness = makeControlledHarness();
|
||||
|
||||
describe('update after prop change (50 points)', () => {
|
||||
bench('switch interpolation monotone→bezier→monotone', async () => {
|
||||
const w = mount(ControlledHarness, {
|
||||
attachTo: document.body,
|
||||
props: { modelValue: dataA, interpolation: 'monotone' },
|
||||
});
|
||||
wrappers.push(w);
|
||||
await w.setProps({ interpolation: 'bezier' });
|
||||
await w.setProps({ interpolation: 'monotone' });
|
||||
teardown();
|
||||
});
|
||||
|
||||
bench('replace model array (commit an edit)', async () => {
|
||||
const w = mount(ControlledHarness, {
|
||||
attachTo: document.body,
|
||||
props: { modelValue: dataA, interpolation: 'monotone' },
|
||||
});
|
||||
wrappers.push(w);
|
||||
await w.setProps({ modelValue: dataB });
|
||||
await w.setProps({ modelValue: dataA });
|
||||
teardown();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { h } from 'vue';
|
||||
import {
|
||||
FlowRoot,
|
||||
addEdge,
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
findClosestHandle,
|
||||
fitViewTransform,
|
||||
flowToScreen,
|
||||
getBezierPath,
|
||||
getNodePositionAbsolute,
|
||||
getNodesBounds,
|
||||
getNodesInsideRect,
|
||||
getSmoothStepPath,
|
||||
getStepPath,
|
||||
getStraightPath,
|
||||
getVisibleEdgeIds,
|
||||
getVisibleNodeIds,
|
||||
screenToFlow,
|
||||
snapPoint,
|
||||
visibleFlowRect,
|
||||
zoomAtPointer,
|
||||
} from '../index';
|
||||
import type {
|
||||
EdgeChange,
|
||||
FlowEdge,
|
||||
FlowNode,
|
||||
HandleBound,
|
||||
HandleBounds,
|
||||
InternalNode,
|
||||
NodeChange,
|
||||
Position,
|
||||
Rect,
|
||||
Viewport,
|
||||
XYPosition,
|
||||
} from '../index';
|
||||
|
||||
// ── Deterministic fixtures (no Math.random; seeded by index/formula) ──────────
|
||||
//
|
||||
// A flow graph laid out on a grid. Node `i` sits at a spread-out position so the
|
||||
// bounds/cull/spatial math sees a realistic non-degenerate spread, and edges chain
|
||||
// node `i → i+1` plus a few cross-links so the visible-edge fan-out is non-trivial.
|
||||
|
||||
const SIDES: Position[] = ['top', 'right', 'bottom', 'left'];
|
||||
const NODE_W = 160;
|
||||
const NODE_H = 56;
|
||||
|
||||
function seededNode(i: number): FlowNode<{ label: string }> {
|
||||
// Deterministic 2D layout: 40 columns, rows below, with a per-index jitter.
|
||||
const col = i % 40;
|
||||
const row = Math.floor(i / 40);
|
||||
return {
|
||||
id: `n${i}`,
|
||||
type: 'process',
|
||||
position: { x: col * 240 + (i % 7) * 13, y: row * 180 + (i % 5) * 11 },
|
||||
data: { label: `Step ${i}` },
|
||||
};
|
||||
}
|
||||
|
||||
function seededInternalNode(i: number): InternalNode<{ label: string }> {
|
||||
const base = seededNode(i);
|
||||
const handleBounds: HandleBounds = {
|
||||
target: [{ id: null, type: 'target', position: 'left', x: 0, y: NODE_H / 2, width: 10, height: 10 }],
|
||||
source: [{ id: null, type: 'source', position: 'right', x: NODE_W, y: NODE_H / 2, width: 10, height: 10 }],
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
measured: { width: NODE_W, height: NODE_H },
|
||||
positionAbsolute: { ...base.position },
|
||||
handleBounds,
|
||||
};
|
||||
}
|
||||
|
||||
function seededEdge(i: number): FlowEdge {
|
||||
// Chain plus a deterministic cross-link every 3rd edge.
|
||||
const source = `n${i}`;
|
||||
const target = i % 3 === 0 ? `n${(i + 7) % 1000}` : `n${i + 1}`;
|
||||
return {
|
||||
id: `e${i}`,
|
||||
source,
|
||||
target,
|
||||
type: i % 2 === 0 ? 'smoothstep' : 'bezier',
|
||||
animated: i % 5 === 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNodes(count: number): Array<FlowNode<{ label: string }>> {
|
||||
const out: Array<FlowNode<{ label: string }>> = [];
|
||||
for (let i = 0; i < count; i++) out.push(seededNode(i));
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildInternalNodes(count: number): Array<InternalNode<{ label: string }>> {
|
||||
const out: Array<InternalNode<{ label: string }>> = [];
|
||||
for (let i = 0; i < count; i++) out.push(seededInternalNode(i));
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildLookup(internals: InternalNode[]): Map<string, InternalNode> {
|
||||
const map = new Map<string, InternalNode>();
|
||||
for (const n of internals) map.set(n.id, n);
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildEdges(count: number): FlowEdge[] {
|
||||
const out: FlowEdge[] = [];
|
||||
for (let i = 0; i < count; i++) out.push(seededEdge(i));
|
||||
return out;
|
||||
}
|
||||
|
||||
// Endpoint pairs feeding the path builders — varied handle sides so the
|
||||
// smooth-step branch/corner logic is exercised, not just the fast collinear case.
|
||||
interface EndpointPair {
|
||||
sourceX: number;
|
||||
sourceY: number;
|
||||
sourcePosition: Position;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
targetPosition: Position;
|
||||
}
|
||||
|
||||
function buildEndpoints(count: number): EndpointPair[] {
|
||||
const out: EndpointPair[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
out.push({
|
||||
sourceX: (i % 13) * 40,
|
||||
sourceY: (i % 17) * 30,
|
||||
sourcePosition: SIDES[i % 4]!,
|
||||
targetX: 400 + (i % 11) * 50,
|
||||
targetY: 200 + (i % 7) * 45,
|
||||
targetPosition: SIDES[(i + 2) % 4]!,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const VP: Viewport = { x: 120, y: -80, zoom: 1.5 };
|
||||
const ORIGIN = { left: 24, top: 16 };
|
||||
const CONTAINER = { width: 1280, height: 720 };
|
||||
|
||||
// Pointer-move samples (client-space pixels) for screenToFlow/zoom hot paths.
|
||||
function buildPointers(count: number): XYPosition[] {
|
||||
const out: XYPosition[] = [];
|
||||
for (let i = 0; i < count; i++)
|
||||
out.push({ x: (i * 37) % CONTAINER.width, y: (i * 53) % CONTAINER.height });
|
||||
return out;
|
||||
}
|
||||
|
||||
// Pre-built fixture sets at realistic (100) and stress (1000) scale.
|
||||
const NODES_100 = buildNodes(100);
|
||||
const NODES_1000 = buildNodes(1000);
|
||||
const INTERNAL_100 = buildInternalNodes(100);
|
||||
const INTERNAL_1000 = buildInternalNodes(1000);
|
||||
const LOOKUP_100 = buildLookup(INTERNAL_100);
|
||||
const LOOKUP_1000 = buildLookup(INTERNAL_1000);
|
||||
const EDGES_100 = buildEdges(100);
|
||||
const EDGES_1000 = buildEdges(1000);
|
||||
const ENDPOINTS_100 = buildEndpoints(100);
|
||||
const ENDPOINTS_1000 = buildEndpoints(1000);
|
||||
const POINTERS_100 = buildPointers(100);
|
||||
const POINTERS_1000 = buildPointers(1000);
|
||||
|
||||
// A `parentId` chain so getNodePositionAbsolute walks ancestors (subflow cost).
|
||||
const CHAIN_LOOKUP = new Map<string, InternalNode>();
|
||||
(() => {
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const n = seededInternalNode(i);
|
||||
if (i > 0) n.parentId = `n${i - 1}`;
|
||||
CHAIN_LOOKUP.set(n.id, n);
|
||||
}
|
||||
})();
|
||||
const CHAIN_LEAF = CHAIN_LOOKUP.get('n63')!;
|
||||
|
||||
const VISIBLE_RECT: Rect = visibleFlowRect(VP, CONTAINER, 200);
|
||||
const VISIBLE_NODE_SET_100 = new Set(getVisibleNodeIds(NODES_100, LOOKUP_100, VISIBLE_RECT));
|
||||
const VISIBLE_NODE_SET_1000 = new Set(getVisibleNodeIds(NODES_1000, LOOKUP_1000, VISIBLE_RECT));
|
||||
|
||||
// Marquee rect covering the upper-left quadrant of the laid-out graph.
|
||||
const MARQUEE: Rect = { x: 0, y: 0, width: 4000, height: 1500 };
|
||||
|
||||
// Closest-handle drag origin: drag from n0's source toward a moving pointer.
|
||||
const DRAG_FROM_HANDLE: HandleBound = INTERNAL_1000[0]!.handleBounds!.source[0]!;
|
||||
|
||||
// ── Edge-path math (per-edge, runs for every visible edge every transform) ────
|
||||
|
||||
describe('edge-paths — straight', () => {
|
||||
bench('100 edges', () => {
|
||||
for (let i = 0; i < ENDPOINTS_100.length; i++) getStraightPath(ENDPOINTS_100[i]!);
|
||||
});
|
||||
|
||||
bench('1000 edges', () => {
|
||||
for (let i = 0; i < ENDPOINTS_1000.length; i++) getStraightPath(ENDPOINTS_1000[i]!);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge-paths — bezier', () => {
|
||||
bench('100 edges', () => {
|
||||
for (let i = 0; i < ENDPOINTS_100.length; i++) getBezierPath(ENDPOINTS_100[i]!);
|
||||
});
|
||||
|
||||
bench('1000 edges', () => {
|
||||
for (let i = 0; i < ENDPOINTS_1000.length; i++) getBezierPath(ENDPOINTS_1000[i]!);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge-paths — smoothstep (corner builder)', () => {
|
||||
bench('100 edges', () => {
|
||||
for (let i = 0; i < ENDPOINTS_100.length; i++) getSmoothStepPath(ENDPOINTS_100[i]!);
|
||||
});
|
||||
|
||||
bench('1000 edges', () => {
|
||||
for (let i = 0; i < ENDPOINTS_1000.length; i++) getSmoothStepPath(ENDPOINTS_1000[i]!);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge-paths — step (zero-radius smoothstep)', () => {
|
||||
bench('100 edges', () => {
|
||||
for (let i = 0; i < ENDPOINTS_100.length; i++) getStepPath(ENDPOINTS_100[i]!);
|
||||
});
|
||||
|
||||
bench('1000 edges', () => {
|
||||
for (let i = 0; i < ENDPOINTS_1000.length; i++) getStepPath(ENDPOINTS_1000[i]!);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Pointer / viewport transform math (runs on every pointermove & wheel) ─────
|
||||
|
||||
describe('pointer math — screenToFlow', () => {
|
||||
bench('100 moves', () => {
|
||||
for (let i = 0; i < POINTERS_100.length; i++) screenToFlow(POINTERS_100[i]!, VP, ORIGIN);
|
||||
});
|
||||
|
||||
bench('1000 moves', () => {
|
||||
for (let i = 0; i < POINTERS_1000.length; i++) screenToFlow(POINTERS_1000[i]!, VP, ORIGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointer math — flowToScreen', () => {
|
||||
bench('100 points', () => {
|
||||
for (let i = 0; i < POINTERS_100.length; i++) flowToScreen(POINTERS_100[i]!, VP, ORIGIN);
|
||||
});
|
||||
|
||||
bench('1000 points', () => {
|
||||
for (let i = 0; i < POINTERS_1000.length; i++) flowToScreen(POINTERS_1000[i]!, VP, ORIGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointer math — zoomAtPointer (wheel zoom)', () => {
|
||||
bench('100 wheel steps', () => {
|
||||
for (let i = 0; i < POINTERS_100.length; i++)
|
||||
zoomAtPointer(VP, POINTERS_100[i]!, 1 + (i % 20) / 10);
|
||||
});
|
||||
|
||||
bench('1000 wheel steps', () => {
|
||||
for (let i = 0; i < POINTERS_1000.length; i++)
|
||||
zoomAtPointer(VP, POINTERS_1000[i]!, 1 + (i % 20) / 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointer math — snapPoint (drag with snap-to-grid)', () => {
|
||||
const grid: [number, number] = [16, 16];
|
||||
|
||||
bench('100 moves', () => {
|
||||
for (let i = 0; i < POINTERS_100.length; i++) snapPoint(POINTERS_100[i]!, grid);
|
||||
});
|
||||
|
||||
bench('1000 moves', () => {
|
||||
for (let i = 0; i < POINTERS_1000.length; i++) snapPoint(POINTERS_1000[i]!, grid);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bounds / fit-view (runs on fitView and minimap recompute) ─────────────────
|
||||
|
||||
describe('getNodesBounds', () => {
|
||||
bench('100 nodes', () => {
|
||||
getNodesBounds(INTERNAL_100);
|
||||
});
|
||||
|
||||
bench('1000 nodes', () => {
|
||||
getNodesBounds(INTERNAL_1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitViewTransform (bounds + fit)', () => {
|
||||
const opts = { padding: 0.1, minZoom: 0.2, maxZoom: 2.5 };
|
||||
|
||||
bench('100 nodes', () => {
|
||||
fitViewTransform(getNodesBounds(INTERNAL_100), CONTAINER, opts);
|
||||
});
|
||||
|
||||
bench('1000 nodes', () => {
|
||||
fitViewTransform(getNodesBounds(INTERNAL_1000), CONTAINER, opts);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Subflow absolute position (parentId chain walk) ───────────────────────────
|
||||
|
||||
describe('getNodePositionAbsolute — parent chain (depth 64)', () => {
|
||||
bench('single leaf walk', () => {
|
||||
getNodePositionAbsolute(CHAIN_LEAF, CHAIN_LOOKUP);
|
||||
});
|
||||
|
||||
bench('64 nodes (all walked)', () => {
|
||||
for (const n of CHAIN_LOOKUP.values()) getNodePositionAbsolute(n, CHAIN_LOOKUP);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Virtualization / spatial culling (runs every viewport pan/zoom) ──────────
|
||||
|
||||
describe('visibleFlowRect + getVisibleNodeIds (node cull)', () => {
|
||||
bench('100 nodes', () => {
|
||||
const rect = visibleFlowRect(VP, CONTAINER, 200);
|
||||
getVisibleNodeIds(NODES_100, LOOKUP_100, rect);
|
||||
});
|
||||
|
||||
bench('1000 nodes', () => {
|
||||
const rect = visibleFlowRect(VP, CONTAINER, 200);
|
||||
getVisibleNodeIds(NODES_1000, LOOKUP_1000, rect);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVisibleEdgeIds (edge cull by visible node set)', () => {
|
||||
bench('100 edges', () => {
|
||||
getVisibleEdgeIds(EDGES_100, VISIBLE_NODE_SET_100);
|
||||
});
|
||||
|
||||
bench('1000 edges', () => {
|
||||
getVisibleEdgeIds(EDGES_1000, VISIBLE_NODE_SET_1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodesInsideRect (marquee selection)', () => {
|
||||
bench('100 nodes', () => {
|
||||
getNodesInsideRect(INTERNAL_100, MARQUEE, 'partial');
|
||||
});
|
||||
|
||||
bench('1000 nodes', () => {
|
||||
getNodesInsideRect(INTERNAL_1000, MARQUEE, 'partial');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Connection snapping (runs every pointermove during a connect drag) ────────
|
||||
|
||||
describe('findClosestHandle (connect-drag snapping)', () => {
|
||||
bench('100 nodes', () => {
|
||||
for (let i = 0; i < POINTERS_100.length; i++)
|
||||
findClosestHandle(POINTERS_100[i]!, LOOKUP_100, 'source', 'n0', null, 'strict', 40);
|
||||
});
|
||||
|
||||
bench('1000 nodes', () => {
|
||||
for (let i = 0; i < POINTERS_100.length; i++)
|
||||
findClosestHandle(POINTERS_100[i]!, LOOKUP_1000, 'source', 'n0', null, 'strict', 40);
|
||||
});
|
||||
});
|
||||
|
||||
void DRAG_FROM_HANDLE; // referenced for fixture parity; snapping uses lookup handles
|
||||
|
||||
// ── Controlled-mode change application (the @nodes-change / @edges-change path) ──
|
||||
|
||||
describe('applyNodeChanges (drag → position changes)', () => {
|
||||
// A whole-graph position update, as emitted while dragging a multi-selection.
|
||||
const changes100: NodeChange[] = NODES_100.map((n, i) => ({
|
||||
type: 'position',
|
||||
id: n.id,
|
||||
position: { x: n.position.x + i, y: n.position.y + i },
|
||||
}));
|
||||
const changes1000: NodeChange[] = NODES_1000.map((n, i) => ({
|
||||
type: 'position',
|
||||
id: n.id,
|
||||
position: { x: n.position.x + i, y: n.position.y + i },
|
||||
}));
|
||||
|
||||
bench('100 position changes', () => {
|
||||
applyNodeChanges(changes100, NODES_100);
|
||||
});
|
||||
|
||||
bench('1000 position changes', () => {
|
||||
applyNodeChanges(changes1000, NODES_1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyEdgeChanges (select changes)', () => {
|
||||
const changes100: EdgeChange[] = EDGES_100.map(e => ({ type: 'select', id: e.id, selected: true }));
|
||||
const changes1000: EdgeChange[] = EDGES_1000.map(e => ({ type: 'select', id: e.id, selected: true }));
|
||||
|
||||
bench('100 select changes', () => {
|
||||
applyEdgeChanges(changes100, EDGES_100);
|
||||
});
|
||||
|
||||
bench('1000 select changes', () => {
|
||||
applyEdgeChanges(changes1000, EDGES_1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addEdge (dedupe scan on connect)', () => {
|
||||
bench('append into 100 edges', () => {
|
||||
addEdge({ source: 'n5', target: 'n90', sourceHandle: null, targetHandle: null }, EDGES_100);
|
||||
});
|
||||
|
||||
bench('append into 1000 edges', () => {
|
||||
addEdge({ source: 'n5', target: 'n900', sourceHandle: null, targetHandle: null }, EDGES_1000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Component: FlowRoot mount + re-render (the realistic end-to-end path) ──────
|
||||
|
||||
const nodeSlot = (p: { data?: { label?: string } }) => h('div', { class: 'node-body' }, p.data?.label ?? '');
|
||||
|
||||
function mountFlow(nodes: FlowNode[], edges: FlowEdge[]) {
|
||||
return mount(FlowRoot, {
|
||||
attachTo: document.body,
|
||||
props: { defaultNodes: nodes, defaultEdges: edges },
|
||||
slots: { 'node-default': nodeSlot },
|
||||
});
|
||||
}
|
||||
|
||||
const NODES_50 = buildNodes(50);
|
||||
const EDGES_50 = buildEdges(50);
|
||||
const NODES_500 = buildNodes(500);
|
||||
const EDGES_500 = buildEdges(500);
|
||||
|
||||
describe('FlowRoot — mount + unmount', () => {
|
||||
bench('50 nodes / 50 edges', () => {
|
||||
const w = mountFlow(NODES_50, EDGES_50);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('500 nodes / 500 edges', () => {
|
||||
const w = mountFlow(NODES_500, EDGES_500);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FlowRoot — re-render after prop change (viewport pan)', () => {
|
||||
bench('50 nodes — viewport setProps', async () => {
|
||||
const w = mount(FlowRoot, {
|
||||
attachTo: document.body,
|
||||
props: { defaultNodes: NODES_50, defaultEdges: EDGES_50, viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
slots: { 'node-default': nodeSlot },
|
||||
});
|
||||
await w.setProps({ viewport: { x: 120, y: -60, zoom: 1.4 } });
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('500 nodes — nodes setProps (controlled replace)', async () => {
|
||||
const w = mount(FlowRoot, {
|
||||
attachTo: document.body,
|
||||
props: { nodes: NODES_500, edges: EDGES_500 },
|
||||
slots: { 'node-default': nodeSlot },
|
||||
});
|
||||
// Move the first node — a single fresh object, rest keep identity.
|
||||
const next = [{ ...NODES_500[0]!, position: { x: 999, y: 999 } }, ...NODES_500.slice(1)];
|
||||
await w.setProps({ nodes: next });
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { bench, describe } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { HistogramBars, HistogramRoot } from '../index';
|
||||
import type { HistogramBarChannel, HistogramData, HistogramScaleType } from '../utils';
|
||||
import {
|
||||
getChannelBins,
|
||||
histogramMax,
|
||||
projectBarHeight,
|
||||
projectBars,
|
||||
} from '../utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic fixtures (NO Math.random). Bins are seeded by index/formula so
|
||||
// every run measures identical work. Each builder produces a bell-ish tonal
|
||||
// curve plus a highlight bump, the shape a real image histogram carries — tall
|
||||
// central spike, long zero tails — which exercises both the linear and log
|
||||
// projection branches and the all-zero guards.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A bell bump centred on `center` bins, scaled to `peak`, sampled over `n` bins. */
|
||||
function bell(n: number, center: number, spread: number, peak: number): number[] {
|
||||
const out: number[] = Array.from({ length: n });
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = (i - center) / spread;
|
||||
out[i] = Math.round(peak * Math.exp(-0.5 * d * d));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Realistic single-channel bins of length `n` (midtone hump + highlight bump). */
|
||||
function makeBins(n: number): number[] {
|
||||
const a = bell(n, n * 0.44, n * 0.14, 1000);
|
||||
const b = bell(n, n * 0.78, n * 0.08, 280);
|
||||
const out: number[] = Array.from({ length: n });
|
||||
for (let i = 0; i < n; i++) out[i] = a[i]! + b[i]!;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A per-channel record (r/g/b/l) of length `n`, each primary peaking elsewhere. */
|
||||
function makeChannelData(n: number): HistogramData {
|
||||
return {
|
||||
l: makeBins(n),
|
||||
r: bell(n, n * 0.53, n * 0.16, 760),
|
||||
g: bell(n, n * 0.41, n * 0.12, 940),
|
||||
b: bell(n, n * 0.31, n * 0.18, 620),
|
||||
};
|
||||
}
|
||||
|
||||
// 256 is the canonical bin count (8-bit channel); 100 / 1000 bracket realistic
|
||||
// and stress scales. 50 / 500 bracket the rendered bar count for components.
|
||||
const bins100 = makeBins(100);
|
||||
const bins256 = makeBins(256);
|
||||
const bins1000 = makeBins(1000);
|
||||
|
||||
const binsZero256 = Array.from<number>({ length: 256 }).fill(0);
|
||||
|
||||
const record100 = makeChannelData(100);
|
||||
const record1000 = makeChannelData(1000);
|
||||
|
||||
const SCALES: HistogramScaleType[] = ['linear', 'log'];
|
||||
const PRIMARIES: HistogramBarChannel[] = ['l', 'r', 'g', 'b'];
|
||||
|
||||
const bars50 = makeBins(50);
|
||||
const bars500 = makeBins(500);
|
||||
|
||||
// Stable harnesses so the component benches measure render cost, not the cost
|
||||
// of redefining a component each iteration.
|
||||
const RootBarsHarness = defineComponent({
|
||||
props: {
|
||||
data: { type: [Array, Object] as unknown as () => HistogramData, required: true },
|
||||
channel: { type: String, default: 'l' },
|
||||
scaleType: { type: String, default: 'linear' },
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
h(
|
||||
HistogramRoot,
|
||||
{ data: props.data, channel: props.channel, scaleType: props.scaleType },
|
||||
{ default: () => h(HistogramBars) },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure projection math — the per-channel hot path the root runs on every data
|
||||
// or prop change (peak scan → normalise every bin → fresh packed array).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('histogramMax — peak scan', () => {
|
||||
bench('100 bins', () => {
|
||||
histogramMax(bins100);
|
||||
});
|
||||
|
||||
bench('256 bins', () => {
|
||||
histogramMax(bins256);
|
||||
});
|
||||
|
||||
bench('1000 bins', () => {
|
||||
histogramMax(bins1000);
|
||||
});
|
||||
|
||||
bench('256 bins — all zero (guard path)', () => {
|
||||
histogramMax(binsZero256);
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectBars — linear (peak scan + normalise + alloc)', () => {
|
||||
bench('100 bins', () => {
|
||||
projectBars(bins100, 'linear');
|
||||
});
|
||||
|
||||
bench('256 bins', () => {
|
||||
projectBars(bins256, 'linear');
|
||||
});
|
||||
|
||||
bench('1000 bins', () => {
|
||||
projectBars(bins1000, 'linear');
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectBars — log (log1p per bin + alloc)', () => {
|
||||
bench('100 bins', () => {
|
||||
projectBars(bins100, 'log');
|
||||
});
|
||||
|
||||
bench('256 bins', () => {
|
||||
projectBars(bins256, 'log');
|
||||
});
|
||||
|
||||
bench('1000 bins', () => {
|
||||
projectBars(bins1000, 'log');
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectBars — all-zero guard (no NaN, no divide)', () => {
|
||||
bench('256 bins — linear', () => {
|
||||
projectBars(binsZero256, 'linear');
|
||||
});
|
||||
|
||||
bench('256 bins — log', () => {
|
||||
projectBars(binsZero256, 'log');
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectBarHeight — per-bin scalar (1000x loop)', () => {
|
||||
const max = histogramMax(bins1000);
|
||||
|
||||
bench('linear x1000', () => {
|
||||
for (let i = 0; i < bins1000.length; i++) projectBarHeight(bins1000[i]!, max, 'linear');
|
||||
});
|
||||
|
||||
bench('log x1000', () => {
|
||||
for (let i = 0; i < bins1000.length; i++) projectBarHeight(bins1000[i]!, max, 'log');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full root projection across every primary + both scales — the work the root's
|
||||
// `bars()`/`hasData` perform when re-deriving an RGB composite. getChannelBins
|
||||
// resolves the record per channel; projectBars then scans + packs each.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('per-channel projection (RGB composite, record data)', () => {
|
||||
bench('4 channels x 100 bins x 2 scales', () => {
|
||||
for (const scale of SCALES) {
|
||||
for (const ch of PRIMARIES) {
|
||||
projectBars(getChannelBins(record100, ch, 'l'), scale);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
bench('4 channels x 1000 bins x 2 scales', () => {
|
||||
for (const scale of SCALES) {
|
||||
for (const ch of PRIMARIES) {
|
||||
projectBars(getChannelBins(record1000, ch, 'l'), scale);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component mount — full Root → Bars → per-bar Primitive tree. Each bar is its
|
||||
// own DOM node, so cost scales with the rendered bar count.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('HistogramRoot + HistogramBars — mount', () => {
|
||||
bench('50 bars (linear)', () => {
|
||||
const w = mount(RootBarsHarness, {
|
||||
props: { data: bars50, channel: 'l', scaleType: 'linear' },
|
||||
attachTo: document.body,
|
||||
});
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('500 bars (linear)', () => {
|
||||
const w = mount(RootBarsHarness, {
|
||||
props: { data: bars500, channel: 'l', scaleType: 'linear' },
|
||||
attachTo: document.body,
|
||||
});
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('500 bars (log)', () => {
|
||||
const w = mount(RootBarsHarness, {
|
||||
props: { data: bars500, channel: 'l', scaleType: 'log' },
|
||||
attachTo: document.body,
|
||||
});
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-render after a prop change — the realistic interaction (the demo toggles
|
||||
// channel and scaleType). Re-projects + patches the existing bar tree in place.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('HistogramRoot + HistogramBars — update after prop change', () => {
|
||||
bench('500 bars — scaleType linear → log', async () => {
|
||||
const w = mount(RootBarsHarness, {
|
||||
props: { data: bars500, channel: 'l', scaleType: 'linear' },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await w.setProps({ scaleType: 'log' });
|
||||
await nextTick();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('record data — channel l → rgb (expand to 3 primaries)', async () => {
|
||||
const w = mount(RootBarsHarness, {
|
||||
props: { data: record100, channel: 'l', scaleType: 'linear' },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await w.setProps({ channel: 'rgb' });
|
||||
await nextTick();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { defineComponent, h, nextTick, render } from 'vue';
|
||||
import { solveBezierX } from '../../../internal/spline';
|
||||
import {
|
||||
KeyframeTrackKeyframe,
|
||||
KeyframeTrackRoot,
|
||||
KeyframeTrackSegment,
|
||||
clampKeyframeTime,
|
||||
defaultKeyframeValueText,
|
||||
sampleKeyframes,
|
||||
snapTimeToFrame,
|
||||
sortKeyframes,
|
||||
} from '../index';
|
||||
import type { KeyframeTrackKeyframeData } from '../index';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fixtures — deterministic, built once at module scope (NO Math.random).
|
||||
//
|
||||
// `time` ramps monotonically (already sorted ascending) so the realistic scales
|
||||
// hit the binary-search / neighbour-clamp paths the way the live sampler does;
|
||||
// `value` and `easing` are seeded by index so the spline solve takes a non-trivial
|
||||
// (non-identity) curve roughly half the time.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const EASINGS: Array<[number, number, number, number]> = [
|
||||
[0, 0, 1, 1], // linear (identity fast-path in solveBezierX)
|
||||
[0, 0, 0.2, 1], // ease-out
|
||||
[0.5, 0, 1, 1], // ease-in
|
||||
[0.65, 0, 0.35, 1], // ease-in-out
|
||||
];
|
||||
|
||||
/** Build `n` sorted keyframes spanning [0, n/10] seconds with cycling easings. */
|
||||
function makeKeyframes(n: number): KeyframeTrackKeyframeData[] {
|
||||
const out: KeyframeTrackKeyframeData[] = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
out[i] = {
|
||||
id: `k${i}`,
|
||||
time: i / 10, // 0.1s apart → strictly ascending, already sorted
|
||||
value: (i % 11) / 10, // 0.0 .. 1.0, deterministic sawtooth
|
||||
// Every 4th keyframe is un-eased (undefined → DEFAULT_KEYFRAME_EASING);
|
||||
// the rest cycle through real curves to exercise the Newton-Raphson solve.
|
||||
easing: i % 4 === 0 ? undefined : EASINGS[i % EASINGS.length],
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build `n` keyframes in REVERSE time order to force a real sort (worst case for sortKeyframes). */
|
||||
function makeUnsortedKeyframes(n: number): KeyframeTrackKeyframeData[] {
|
||||
const out: KeyframeTrackKeyframeData[] = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = n - 1 - i;
|
||||
out[i] = { id: `k${j}`, time: j / 10, value: (j % 11) / 10 };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const KF_100 = makeKeyframes(100);
|
||||
const KF_1000 = makeKeyframes(1000);
|
||||
|
||||
const KF_100_UNSORTED = makeUnsortedKeyframes(100);
|
||||
const KF_1000_UNSORTED = makeUnsortedKeyframes(1000);
|
||||
|
||||
const VALUE_RANGE: readonly [number, number] = [0, 1];
|
||||
const FPS = 30;
|
||||
|
||||
// Pre-computed sample times spanning the full keyframe range, so each pass over
|
||||
// a curve exercises the bracketing binary search end-to-end (the readout in the
|
||||
// demo samples ~120 points per animation frame). Seeded by index, not random.
|
||||
function makeSampleTimes(count: number, span: number): number[] {
|
||||
const out: number[] = new Array(count);
|
||||
for (let i = 0; i < count; i++) out[i] = (i / (count - 1)) * span;
|
||||
return out;
|
||||
}
|
||||
|
||||
const SAMPLE_TIMES_120 = makeSampleTimes(120, 10); // one demo curve frame over the 100-kf range
|
||||
const SAMPLE_TIMES_120_WIDE = makeSampleTimes(120, 100); // same frame over the 1000-kf range
|
||||
|
||||
// Easing-solver probes in [0,1] (the curve-preview polyline samples ~33 points).
|
||||
const BEZIER_X_64 = makeSampleTimes(64, 1);
|
||||
|
||||
// Simulated pointermove deltas (px-equivalent seconds) for the drag hot path —
|
||||
// a deterministic back-and-forth sweep, no random jitter.
|
||||
const MOVE_TIMES_100 = (() => {
|
||||
const out: number[] = Array.from({ length: 100 });
|
||||
for (let i = 0; i < 100; i++) out[i] = 5 + Math.sin(i / 8) * 4; // ∈ ~[1, 9]
|
||||
return out;
|
||||
})();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pure hot-path maths — these run on the live render / pointer paths.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('sampleKeyframes — single sample by curve size', () => {
|
||||
bench('100 keyframes — sample mid-range', () => {
|
||||
sampleKeyframes(KF_100, 5, VALUE_RANGE);
|
||||
});
|
||||
|
||||
bench('1000 keyframes — sample mid-range', () => {
|
||||
sampleKeyframes(KF_1000, 50, VALUE_RANGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sampleKeyframes — full curve sweep (per-frame readout)', () => {
|
||||
bench('100 keyframes × 120 samples', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < SAMPLE_TIMES_120.length; i++)
|
||||
acc += sampleKeyframes(KF_100, SAMPLE_TIMES_120[i]!, VALUE_RANGE);
|
||||
return acc;
|
||||
});
|
||||
|
||||
bench('1000 keyframes × 120 samples', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < SAMPLE_TIMES_120_WIDE.length; i++)
|
||||
acc += sampleKeyframes(KF_1000, SAMPLE_TIMES_120_WIDE[i]!, VALUE_RANGE);
|
||||
return acc;
|
||||
});
|
||||
});
|
||||
|
||||
describe('solveBezierX — easing solve', () => {
|
||||
bench('identity (linear) × 64', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < BEZIER_X_64.length; i++)
|
||||
acc += solveBezierX(0, 0, 1, 1, BEZIER_X_64[i]!);
|
||||
return acc;
|
||||
});
|
||||
|
||||
bench('ease-in-out (Newton-Raphson) × 64', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < BEZIER_X_64.length; i++)
|
||||
acc += solveBezierX(0.65, 0, 0.35, 1, BEZIER_X_64[i]!);
|
||||
return acc;
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortKeyframes — reconcile / commit', () => {
|
||||
bench('100 keyframes (reverse-sorted input)', () => {
|
||||
sortKeyframes(KF_100_UNSORTED);
|
||||
});
|
||||
|
||||
bench('1000 keyframes (reverse-sorted input)', () => {
|
||||
sortKeyframes(KF_1000_UNSORTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampKeyframeTime — neighbour clamp (pointer drag)', () => {
|
||||
const opts = { allowOverlap: false, minTimeBetween: 1 / FPS, duration: 100 };
|
||||
|
||||
bench('100 keyframes × 100 moves', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < MOVE_TIMES_100.length; i++) {
|
||||
const index = (i * 7) % KF_100.length; // deterministic spread of touched indices
|
||||
acc += clampKeyframeTime(KF_100, index, MOVE_TIMES_100[i]!, opts);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
|
||||
bench('1000 keyframes × 100 moves', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < MOVE_TIMES_100.length; i++) {
|
||||
const index = (i * 71) % KF_1000.length;
|
||||
acc += clampKeyframeTime(KF_1000, index, MOVE_TIMES_100[i]!, opts);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapTimeToFrame — frame-grid quantize', () => {
|
||||
bench('100 quantize ops @30fps', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < MOVE_TIMES_100.length; i++)
|
||||
acc += snapTimeToFrame(MOVE_TIMES_100[i]!, FPS);
|
||||
return acc;
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultKeyframeValueText — aria-valuetext', () => {
|
||||
bench('100 value-text formats (with property)', () => {
|
||||
for (let i = 0; i < MOVE_TIMES_100.length; i++)
|
||||
defaultKeyframeValueText(MOVE_TIMES_100[i]! / 10, 'opacity');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Component mount + update via Vue render() — realistic (50) and stress (500).
|
||||
//
|
||||
// Each keyframe renders a `role="slider"` Primitive that reads the projection /
|
||||
// sampler from context, plus a segment band between neighbours. We mount the full
|
||||
// tree, flush a tick, then update a prop (duration) to time the reconcile +
|
||||
// re-projection cost, and finally unmount.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeHarness(initial: KeyframeTrackKeyframeData[]) {
|
||||
return defineComponent({
|
||||
props: {
|
||||
keyframes: { type: Array as () => KeyframeTrackKeyframeData[], default: () => initial },
|
||||
duration: { type: Number, default: 50 },
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
h(
|
||||
KeyframeTrackRoot as any,
|
||||
{
|
||||
modelValue: props.keyframes,
|
||||
property: 'opacity',
|
||||
duration: props.duration,
|
||||
fps: FPS,
|
||||
valueRange: [0, 1],
|
||||
// The Root measures its own box; give it a concrete lane so the
|
||||
// projection / snap targets are non-degenerate during the bench.
|
||||
style: 'width: 600px; height: 160px; position: relative; display: block;',
|
||||
},
|
||||
{
|
||||
default: ({ keyframes }: { keyframes: KeyframeTrackKeyframeData[] }) => [
|
||||
...keyframes
|
||||
.slice(0, -1)
|
||||
.map(k =>
|
||||
h(KeyframeTrackSegment, { key: `seg-${k.id}`, keyframeId: k.id }),
|
||||
),
|
||||
...keyframes.map(k =>
|
||||
h(KeyframeTrackKeyframe, { key: k.id, keyframeId: k.id, id: `kf-${k.id}` }),
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const KF_50 = makeKeyframes(50);
|
||||
const KF_500 = makeKeyframes(500);
|
||||
|
||||
const Harness50 = makeHarness(KF_50);
|
||||
const Harness500 = makeHarness(KF_500);
|
||||
|
||||
describe('KeyframeTrackRoot — mount + unmount', () => {
|
||||
bench('mount 50 keyframes', async () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
render(h(Harness50), container);
|
||||
await nextTick();
|
||||
render(null, container);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
bench('mount 500 keyframes', async () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
render(h(Harness500), container);
|
||||
await nextTick();
|
||||
render(null, container);
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KeyframeTrackRoot — re-render after prop change', () => {
|
||||
bench('50 keyframes — duration change + flush', async () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
render(h(Harness50, { duration: 50 }), container);
|
||||
await nextTick();
|
||||
// Prop change re-runs the time scale + every keyframe/segment projection.
|
||||
render(h(Harness50, { duration: 80 }), container);
|
||||
await nextTick();
|
||||
render(null, container);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
bench('500 keyframes — duration change + flush', async () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
render(h(Harness500, { duration: 50 }), container);
|
||||
await nextTick();
|
||||
render(h(Harness500, { duration: 80 }), container);
|
||||
await nextTick();
|
||||
render(null, container);
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { bench, describe } from 'vitest';
|
||||
import { h, nextTick } from 'vue';
|
||||
// Pure hot-path math + tick generators live in the shared `internal/scale`
|
||||
// module, re-exported from the package barrel. Import them relatively so the
|
||||
// bench tracks the same source the ruler consumes.
|
||||
import {
|
||||
formatClock,
|
||||
formatFrames,
|
||||
formatTimecode,
|
||||
frameTicks,
|
||||
framesToTimecode,
|
||||
getStepDecimals,
|
||||
niceTicks,
|
||||
roundToStep,
|
||||
scaleLinear,
|
||||
secondsToFrames,
|
||||
timeTicks,
|
||||
timecodeTicks,
|
||||
useScale,
|
||||
} from '../../../internal/scale';
|
||||
import { formatTimeForMode, modeToTickKind, tickFormatFor } from '../utils';
|
||||
import { TimeRulerCursor, TimeRulerRoot } from '../index';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures (deterministic — values seeded by index/formula, no Math.random).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FPS = 30;
|
||||
|
||||
// A "visible window" mirrors the demo: offset (left-edge seconds) + a width in
|
||||
// pixels projected through a zoom (px/s). Two scales: realistic and stress.
|
||||
// realistic: 600px wide @ 40 px/s over a 10-minute clip → window ~15s
|
||||
// stress: a long zoomed-out window so the generators emit many ticks.
|
||||
function windowFor(widthPx: number, zoomPxPerSec: number, offsetSec: number): {
|
||||
domain: readonly [number, number];
|
||||
range: readonly [number, number];
|
||||
} {
|
||||
const span = widthPx / zoomPxPerSec;
|
||||
return {
|
||||
domain: [offsetSec, offsetSec + span] as const,
|
||||
range: [0, widthPx] as const,
|
||||
};
|
||||
}
|
||||
|
||||
// Realistic single-screen window (demo defaults: 600px, 40 px/s, offset 12s).
|
||||
const REALISTIC = windowFor(600, 40, 12);
|
||||
// Stress window: a wide, zoomed-out viewport that walks many tick candidates.
|
||||
const STRESS = windowFor(4000, 4, 0);
|
||||
|
||||
// A bank of seconds values for scalar projection / formatter benches. Seeded by
|
||||
// a deterministic formula so every run hits identical inputs.
|
||||
function secondsBank(n: number): Float64Array {
|
||||
const out = new Float64Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
// Spread across a 10-minute clip with frame-fractional offsets so the
|
||||
// timecode/frame paths exercise rounding, not just integers.
|
||||
out[i] = (i * 600) / n + (i % 30) / FPS;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A bank of pixel offsets for the invert (pointer→time) hot path.
|
||||
function pixelBank(n: number, widthPx: number): Float64Array {
|
||||
const out = new Float64Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
out[i] = (i / (n - 1 || 1)) * widthPx;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const SECONDS_100 = secondsBank(100);
|
||||
const SECONDS_1000 = secondsBank(1000);
|
||||
const PIXELS_100 = pixelBank(100, 600);
|
||||
const PIXELS_1000 = pixelBank(1000, 4000);
|
||||
|
||||
// A frame bank for the integer-frame generator / formatters.
|
||||
const FRAMES_1000 = (() => {
|
||||
const out = new Float64Array(1000);
|
||||
for (let i = 0; i < out.length; i++) out[i] = i * 37; // arbitrary stable stride
|
||||
return out;
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Tick generation — the ruler's heaviest per-frame compute. Each generator
|
||||
// is benched at a realistic single-screen window and a stress window.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('tick generation — timeTicks (seconds mode)', () => {
|
||||
bench('realistic window (~15s @ 40px/s)', () => {
|
||||
timeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, targetDensity: 80 });
|
||||
});
|
||||
|
||||
bench('stress window (1000s @ 4px/s)', () => {
|
||||
timeTicks({ domain: STRESS.domain, range: STRESS.range, targetDensity: 80 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tick generation — timecodeTicks (timecode mode)', () => {
|
||||
bench('realistic window', () => {
|
||||
timecodeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, fps: FPS, targetDensity: 80 });
|
||||
});
|
||||
|
||||
bench('stress window', () => {
|
||||
timecodeTicks({ domain: STRESS.domain, range: STRESS.range, fps: FPS, targetDensity: 80 });
|
||||
});
|
||||
|
||||
bench('realistic window — drop-frame labels', () => {
|
||||
timecodeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, fps: 29.97, dropFrame: true, targetDensity: 80 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tick generation — frameTicks (frames mode)', () => {
|
||||
// frames mode routes the seconds domain through the timecode ticker with a
|
||||
// frame-number `format` override; bench that exact path too.
|
||||
const frameFormat = tickFormatFor('frames', FPS);
|
||||
|
||||
bench('realistic window — timecode ticker w/ frame labels', () => {
|
||||
timecodeTicks({ domain: REALISTIC.domain, range: REALISTIC.range, fps: FPS, format: frameFormat, targetDensity: 80 });
|
||||
});
|
||||
|
||||
bench('stress window — integer-frame axis', () => {
|
||||
// A wide integer-frame domain (0..18000 frames ≈ 600s @ 30fps).
|
||||
frameTicks({ domain: [0, 18000] as const, range: [0, 4000] as const, fps: FPS, targetDensity: 80 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tick generation — niceTicks (generic axis)', () => {
|
||||
bench('realistic window', () => {
|
||||
niceTicks({ domain: REALISTIC.domain, range: REALISTIC.range, targetDensity: 80 });
|
||||
});
|
||||
|
||||
bench('stress window', () => {
|
||||
niceTicks({ domain: STRESS.domain, range: STRESS.range, targetDensity: 80 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Pure projection math — `scale` / `invert` run on the pointer hot path.
|
||||
// Bench the underlying scaleLinear over 100 / 1000 inputs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('projection math — scaleLinear (time → px)', () => {
|
||||
const [d0, d1] = REALISTIC.domain;
|
||||
const [r0, r1] = REALISTIC.range;
|
||||
|
||||
bench('100 values', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < SECONDS_100.length; i++) {
|
||||
acc += scaleLinear(SECONDS_100[i]!, d0, d1, r0, r1);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
|
||||
bench('1000 values', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < SECONDS_1000.length; i++) {
|
||||
acc += scaleLinear(SECONDS_1000[i]!, d0, d1, r0, r1);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
});
|
||||
|
||||
describe('projection math — scaleLinear (px → time, invert)', () => {
|
||||
const [d0, d1] = STRESS.domain;
|
||||
const [r0, r1] = STRESS.range;
|
||||
|
||||
bench('100 pixels', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < PIXELS_100.length; i++) {
|
||||
acc += scaleLinear(PIXELS_100[i]!, r0, r1, d0, d1);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
|
||||
bench('1000 pixels', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < PIXELS_1000.length; i++) {
|
||||
acc += scaleLinear(PIXELS_1000[i]!, r0, r1, d0, d1);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
});
|
||||
|
||||
describe('projection math — roundToStep (snap, pointer path)', () => {
|
||||
const step = 0.5;
|
||||
const decimals = getStepDecimals(step);
|
||||
|
||||
bench('100 values', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < SECONDS_100.length; i++) {
|
||||
acc += roundToStep(SECONDS_100[i]!, step, 0, decimals);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
|
||||
bench('1000 values', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < SECONDS_1000.length; i++) {
|
||||
acc += roundToStep(SECONDS_1000[i]!, step, 0, decimals);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Live scale via useScale — the projector closures the ruler actually calls
|
||||
// (read domain/range/reverse at call time). Bench the realistic per-frame
|
||||
// burst: project every tick value to a pixel + invert a pointer sweep.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useScale — projector closures', () => {
|
||||
const { scale, invert } = useScale({
|
||||
domain: () => REALISTIC.domain,
|
||||
range: () => REALISTIC.range,
|
||||
tickKind: () => 'time',
|
||||
tickOptions: () => ({ targetDensity: 80 }),
|
||||
});
|
||||
|
||||
bench('scale() × 1000', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < SECONDS_1000.length; i++) acc += scale(SECONDS_1000[i]!);
|
||||
return acc;
|
||||
});
|
||||
|
||||
bench('invert() × 100 (pointer sweep)', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < PIXELS_100.length; i++) acc += invert(PIXELS_100[i]!);
|
||||
return acc;
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Per-mode label formatters — run once per major tick on every regenerate.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('label formatting — per mode', () => {
|
||||
bench('formatClock × 1000 (seconds)', () => {
|
||||
let len = 0;
|
||||
for (let i = 0; i < SECONDS_1000.length; i++) len += formatClock(SECONDS_1000[i]!).length;
|
||||
return len;
|
||||
});
|
||||
|
||||
bench('formatTimecode × 1000 (timecode)', () => {
|
||||
let len = 0;
|
||||
for (let i = 0; i < SECONDS_1000.length; i++) len += formatTimecode(SECONDS_1000[i]!, FPS).length;
|
||||
return len;
|
||||
});
|
||||
|
||||
bench('framesToTimecode × 1000 — drop-frame', () => {
|
||||
let len = 0;
|
||||
for (let i = 0; i < FRAMES_1000.length; i++) len += framesToTimecode(FRAMES_1000[i]!, 29.97, true).length;
|
||||
return len;
|
||||
});
|
||||
|
||||
bench('formatFrames × 1000 (frames)', () => {
|
||||
let len = 0;
|
||||
for (let i = 0; i < FRAMES_1000.length; i++) len += formatFrames(FRAMES_1000[i]!).length;
|
||||
return len;
|
||||
});
|
||||
|
||||
bench('formatTimeForMode × 1000 — dispatch (timecode)', () => {
|
||||
let len = 0;
|
||||
for (let i = 0; i < SECONDS_1000.length; i++) len += formatTimeForMode(SECONDS_1000[i]!, 'timecode', FPS).length;
|
||||
return len;
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Mode → tick-kind / format selection (cheap, but runs on every prop change).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('mode plumbing', () => {
|
||||
const modes = ['seconds', 'timecode', 'frames'] as const;
|
||||
|
||||
bench('modeToTickKind × 3 modes', () => {
|
||||
for (const m of modes) modeToTickKind(m);
|
||||
});
|
||||
|
||||
bench('tickFormatFor × 3 modes', () => {
|
||||
for (const m of modes) tickFormatFor(m, FPS);
|
||||
});
|
||||
|
||||
bench('secondsToFrames × 1000', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < SECONDS_1000.length; i++) acc += secondsToFrames(SECONDS_1000[i]!, FPS);
|
||||
return acc;
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Component lifecycle — mount cost (builds useScale + tick computeds + the
|
||||
// accessible group), and a re-render after a prop change. The default slot
|
||||
// renders one DOM node per tick, so this captures the real render-N cost.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Render the ruler's tick layer + a cursor, exactly like the demo, so the slot
|
||||
// work (v-for over `ticks`, per-tick class/style, cursor projection) is benched.
|
||||
function rulerSlot() {
|
||||
return {
|
||||
default: ({ ticks, formatTime }: { ticks: Array<{ value: number; px: number; major: boolean; label: string }>; formatTime: (s: number) => string }) =>
|
||||
ticks
|
||||
.map(tick =>
|
||||
h(
|
||||
'div',
|
||||
{ key: tick.value, class: tick.major ? 'major' : 'minor', style: { left: `${tick.px}px` } },
|
||||
tick.major ? [h('span', tick.label)] : [],
|
||||
),
|
||||
)
|
||||
.concat([
|
||||
h(TimeRulerCursor as never, { time: 72 }, {
|
||||
default: ({ time }: { time: number }) => h('span', formatTime(time)),
|
||||
}),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
describe('TimeRulerRoot — mount', () => {
|
||||
bench('mount — seconds mode', () => {
|
||||
const w = mount(TimeRulerRoot, {
|
||||
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'seconds', focusable: true, wheel: true, draggable: true },
|
||||
slots: rulerSlot(),
|
||||
attachTo: document.body,
|
||||
});
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('mount — timecode mode', () => {
|
||||
const w = mount(TimeRulerRoot, {
|
||||
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'timecode', focusable: true },
|
||||
slots: rulerSlot(),
|
||||
attachTo: document.body,
|
||||
});
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('mount — frames mode', () => {
|
||||
const w = mount(TimeRulerRoot, {
|
||||
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'frames', focusable: true },
|
||||
slots: rulerSlot(),
|
||||
attachTo: document.body,
|
||||
});
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimeRulerRoot — re-render after prop change', () => {
|
||||
bench('zoom change (pan/zoom gesture stream)', async () => {
|
||||
const w = mount(TimeRulerRoot, {
|
||||
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'timecode' },
|
||||
slots: rulerSlot(),
|
||||
attachTo: document.body,
|
||||
});
|
||||
// A zoom write re-derives the visible window → ticks computed → slot re-render.
|
||||
await w.setProps({ zoom: 120 });
|
||||
await nextTick();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('offset change (pan stream)', async () => {
|
||||
const w = mount(TimeRulerRoot, {
|
||||
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'timecode' },
|
||||
slots: rulerSlot(),
|
||||
attachTo: document.body,
|
||||
});
|
||||
await w.setProps({ offset: 120 });
|
||||
await nextTick();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('mode change (timecode → frames, regenerate ladder)', async () => {
|
||||
const w = mount(TimeRulerRoot, {
|
||||
props: { duration: 600, zoom: 40, offset: 0, fps: FPS, mode: 'timecode' },
|
||||
slots: rulerSlot(),
|
||||
attachTo: document.body,
|
||||
});
|
||||
await w.setProps({ mode: 'frames' });
|
||||
await nextTick();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import {
|
||||
framesToTimecode,
|
||||
scaleLinear,
|
||||
secondsToFrames,
|
||||
timeTicks,
|
||||
timecodeTicks,
|
||||
} from '../../../internal/scale';
|
||||
import {
|
||||
TimelineClip,
|
||||
TimelinePlayhead,
|
||||
TimelineRoot,
|
||||
TimelineTrack,
|
||||
TimelineTrackHeader,
|
||||
TimelineTracks,
|
||||
applyClipChanges,
|
||||
applyTrackChanges,
|
||||
clipIntersectsTime,
|
||||
clipsDuration,
|
||||
snapToFrame,
|
||||
timeToTimecode,
|
||||
} from '../index';
|
||||
import type {
|
||||
TimelineClipChange,
|
||||
TimelineClipData,
|
||||
TimelineTrackChange,
|
||||
TimelineTrackData,
|
||||
} from '../index';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Deterministic fixtures (NO Math.random — every value is seeded by index).
|
||||
//
|
||||
// The timeline domain unit is SECONDS; pxPerSecond is the zoom. The hot paths
|
||||
// are (a) the pure ruler/timecode/scale math recomputed on every pan/zoom, (b)
|
||||
// the controlled-mode reducers folding @clips-change / @tracks-change batches,
|
||||
// and (c) mounting + updating the headless component tree with N clips.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const FPS = 30;
|
||||
const PX_PER_SECOND = 90; // matches the demo's default zoom.
|
||||
|
||||
/** Build a deterministic clip array spread across `trackCount` lanes. */
|
||||
function makeClips(count: number, trackCount: number): TimelineClipData[] {
|
||||
const out: TimelineClipData[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Stagger starts so clips tile along time; vary duration by a fixed cycle.
|
||||
const start = i * 1.5;
|
||||
const duration = 0.5 + (i % 5) * 0.4;
|
||||
out.push({
|
||||
id: `c${i}`,
|
||||
trackId: `t${i % trackCount}`,
|
||||
start,
|
||||
duration,
|
||||
label: `Clip ${i}`,
|
||||
color: i % 2 === 0 ? 'var(--color-accent)' : '#0ea5e9',
|
||||
locked: i % 11 === 0,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a deterministic track array. */
|
||||
function makeTracks(count: number): TimelineTrackData[] {
|
||||
const out: TimelineTrackData[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
out.push({
|
||||
id: `t${i}`,
|
||||
label: `Track ${i}`,
|
||||
height: 52 + (i % 3) * 6,
|
||||
kind: i % 3 === 0 ? 'audio' : 'video',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Pre-built fixture sets (module scope, simple loops — no randomness).
|
||||
const clips100 = makeClips(100, 8);
|
||||
const clips1000 = makeClips(1000, 16);
|
||||
|
||||
const tracks50 = makeTracks(50);
|
||||
const tracks500 = makeTracks(500);
|
||||
|
||||
const clips50 = makeClips(50, 4);
|
||||
const clips500 = makeClips(500, 8);
|
||||
|
||||
// A long visible time span so the tick generators emit a realistic tick count.
|
||||
const SPAN_100 = clipsDuration(clips100); // ~150s
|
||||
const SPAN_1000 = clipsDuration(clips1000); // ~1500s
|
||||
const VIEWPORT_PX = 1200;
|
||||
|
||||
// Pre-built change batches for the reducers (mixed move/trim, deterministic).
|
||||
function makeClipChanges(clips: TimelineClipData[], n: number): TimelineClipChange[] {
|
||||
const out: TimelineClipChange[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const clip = clips[i % clips.length]!;
|
||||
if (i % 2 === 0) out.push({ type: 'move', id: clip.id, trackId: clip.trackId, start: clip.start + 0.25 });
|
||||
else out.push({ type: 'trim', id: clip.id, start: clip.start, duration: clip.duration + 0.1 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const clipChanges100 = makeClipChanges(clips100, 100);
|
||||
const clipChanges1000 = makeClipChanges(clips1000, 1000);
|
||||
|
||||
function makeTrackChanges(tracks: TimelineTrackData[], n: number): TimelineTrackChange[] {
|
||||
const out: TimelineTrackChange[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = tracks[i % tracks.length]!;
|
||||
out.push({ type: 'patch', id: t.id, patch: { height: 60 + (i % 4) * 4, muted: i % 2 === 0 } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const trackChanges50 = makeTrackChanges(tracks50, 50);
|
||||
const trackChanges500 = makeTrackChanges(tracks500, 500);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Ruler tick generation — runs on EVERY pan / zoom / offset change.
|
||||
// domain = [offset, offset + width / pxPerSecond] → range = [0, width].
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ruler ticks — timecode (per pan/zoom)', () => {
|
||||
bench('timecodeTicks — 100-clip span (~150s)', () => {
|
||||
timecodeTicks({ domain: [0, SPAN_100], range: [0, SPAN_100 * PX_PER_SECOND], fps: FPS });
|
||||
});
|
||||
|
||||
bench('timecodeTicks — 1000-clip span (~1500s)', () => {
|
||||
timecodeTicks({ domain: [0, SPAN_1000], range: [0, SPAN_1000 * PX_PER_SECOND], fps: FPS });
|
||||
});
|
||||
|
||||
bench('timecodeTicks — wide window, fixed viewport (1200px)', () => {
|
||||
timecodeTicks({ domain: [0, SPAN_1000], range: [0, VIEWPORT_PX], fps: FPS });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ruler ticks — wall clock (per pan/zoom)', () => {
|
||||
bench('timeTicks — 100-clip span (~150s)', () => {
|
||||
timeTicks({ domain: [0, SPAN_100], range: [0, SPAN_100 * PX_PER_SECOND] });
|
||||
});
|
||||
|
||||
bench('timeTicks — 1000-clip span (~1500s)', () => {
|
||||
timeTicks({ domain: [0, SPAN_1000], range: [0, SPAN_1000 * PX_PER_SECOND] });
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Scale projection + timecode formatting — per clip / per tick, every render.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('scale projection (scaleLinear over clips)', () => {
|
||||
bench('scaleLinear — project 100 clip edges', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < clips100.length; i++) {
|
||||
const c = clips100[i]!;
|
||||
acc += scaleLinear(c.start, 0, SPAN_100, 0, VIEWPORT_PX);
|
||||
acc += scaleLinear(c.start + c.duration, 0, SPAN_100, 0, VIEWPORT_PX);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
|
||||
bench('scaleLinear — project 1000 clip edges', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < clips1000.length; i++) {
|
||||
const c = clips1000[i]!;
|
||||
acc += scaleLinear(c.start, 0, SPAN_1000, 0, VIEWPORT_PX);
|
||||
acc += scaleLinear(c.start + c.duration, 0, SPAN_1000, 0, VIEWPORT_PX);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
});
|
||||
|
||||
describe('timecode formatting (per clip label)', () => {
|
||||
bench('timeToTimecode — 100 clip durations', () => {
|
||||
let len = 0;
|
||||
for (let i = 0; i < clips100.length; i++) len += timeToTimecode(clips100[i]!.duration, FPS).length;
|
||||
return len;
|
||||
});
|
||||
|
||||
bench('timeToTimecode — 1000 clip durations', () => {
|
||||
let len = 0;
|
||||
for (let i = 0; i < clips1000.length; i++) len += timeToTimecode(clips1000[i]!.duration, FPS).length;
|
||||
return len;
|
||||
});
|
||||
|
||||
bench('framesToTimecode — 1000 (raw, pre-converted)', () => {
|
||||
let len = 0;
|
||||
for (let i = 0; i < clips1000.length; i++) {
|
||||
const frames = secondsToFrames(clips1000[i]!.start, FPS);
|
||||
len += framesToTimecode(frames, FPS).length;
|
||||
}
|
||||
return len;
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Snap-to-frame — keyboard nudge granularity + default snap grid.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('snapToFrame (nudge / grid granularity)', () => {
|
||||
bench('snapToFrame — 100 clip starts', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < clips100.length; i++) acc += snapToFrame(clips100[i]!.start + 0.017, FPS);
|
||||
return acc;
|
||||
});
|
||||
|
||||
bench('snapToFrame — 1000 clip starts', () => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < clips1000.length; i++) acc += snapToFrame(clips1000[i]!.start + 0.017, FPS);
|
||||
return acc;
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. Marquee hit-testing — clipIntersectsTime per clip on every marquee move.
|
||||
// Plus clipsDuration, the auto-duration recompute over the whole clip set.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('marquee hit-test (clipIntersectsTime per pointer move)', () => {
|
||||
// A simulated marquee window sweeping a fixed sub-range of the timeline.
|
||||
const from = SPAN_100 * 0.3;
|
||||
const to = SPAN_100 * 0.6;
|
||||
|
||||
bench('clipIntersectsTime — 100 clips', () => {
|
||||
let hits = 0;
|
||||
for (let i = 0; i < clips100.length; i++) if (clipIntersectsTime(clips100[i]!, from, to)) hits++;
|
||||
return hits;
|
||||
});
|
||||
|
||||
bench('clipIntersectsTime — 1000 clips', () => {
|
||||
const f = SPAN_1000 * 0.3;
|
||||
const t = SPAN_1000 * 0.6;
|
||||
let hits = 0;
|
||||
for (let i = 0; i < clips1000.length; i++) if (clipIntersectsTime(clips1000[i]!, f, t)) hits++;
|
||||
return hits;
|
||||
});
|
||||
});
|
||||
|
||||
describe('clipsDuration (auto-duration recompute)', () => {
|
||||
bench('clipsDuration — 100 clips', () => clipsDuration(clips100));
|
||||
bench('clipsDuration — 1000 clips', () => clipsDuration(clips1000));
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 5. Controlled-mode reducers — fold a @clips-change / @tracks-change batch.
|
||||
// This is the React-Flow-style applyNodeChanges hot path.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('applyClipChanges (controlled reducer)', () => {
|
||||
bench('applyClipChanges — 100 clips / 100 changes', () => {
|
||||
applyClipChanges(clips100, clipChanges100);
|
||||
});
|
||||
|
||||
bench('applyClipChanges — 1000 clips / 1000 changes', () => {
|
||||
applyClipChanges(clips1000, clipChanges1000);
|
||||
});
|
||||
|
||||
bench('applyClipChanges — 1000 clips / single move', () => {
|
||||
applyClipChanges(clips1000, [
|
||||
{ type: 'move', id: 'c500', trackId: 't0', start: 999 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTrackChanges (controlled reducer)', () => {
|
||||
bench('applyTrackChanges — 50 tracks / 50 patches', () => {
|
||||
applyTrackChanges(tracks50, trackChanges50);
|
||||
});
|
||||
|
||||
bench('applyTrackChanges — 500 tracks / 500 patches', () => {
|
||||
applyTrackChanges(tracks500, trackChanges500);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 6. Component mount — the full headless tree with N clips/tracks.
|
||||
// Builds a Root → Tracks → Track → Clip composition mirroring the demo, with
|
||||
// a fixed-width viewport so the scale projects to real pixels.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A full timeline composition over a live clips model (add/remove reflects). */
|
||||
function makeTimeline(tracks: TimelineTrackData[], initialClips: TimelineClipData[]) {
|
||||
return defineComponent({
|
||||
props: { pxPerSecond: { type: Number, default: PX_PER_SECOND } },
|
||||
setup(props) {
|
||||
const clipsRef = ref<TimelineClipData[]>(initialClips.map(c => ({ ...c })));
|
||||
return () => h(
|
||||
TimelineRoot,
|
||||
{
|
||||
tracks,
|
||||
clips: clipsRef.value,
|
||||
'onUpdate:clips': (v: TimelineClipData[]) => { clipsRef.value = v; },
|
||||
pxPerSecond: props.pxPerSecond,
|
||||
'onUpdate:pxPerSecond': () => {},
|
||||
fps: FPS,
|
||||
trackHeight: 56,
|
||||
style: 'width:1200px;display:block;',
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(TimelinePlayhead, {}, { default: () => 'PH' }),
|
||||
h(
|
||||
TimelineTracks,
|
||||
{ style: 'position:relative;display:block;width:1200px;height:600px;' },
|
||||
{
|
||||
default: () => tracks.map(t => h(
|
||||
TimelineTrack,
|
||||
{ trackId: t.id, key: t.id, style: 'position:relative;display:block;' },
|
||||
{
|
||||
default: () => [
|
||||
h(TimelineTrackHeader, {}, {}),
|
||||
...clipsRef.value
|
||||
.filter(c => c.trackId === t.id)
|
||||
.map(c => h(
|
||||
TimelineClip,
|
||||
{ clipId: c.id, key: c.id },
|
||||
{ default: () => c.label },
|
||||
)),
|
||||
],
|
||||
},
|
||||
)),
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('TimelineRoot — mount (full tree)', () => {
|
||||
bench('mount — 4 tracks / 50 clips', () => {
|
||||
const w = mount(makeTimeline(tracks50.slice(0, 4), clips50), { attachTo: document.body });
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('mount — 8 tracks / 500 clips', () => {
|
||||
const w = mount(makeTimeline(tracks500.slice(0, 8), clips500), { attachTo: document.body });
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 7. Re-render after a prop change — zoom (pxPerSecond) and clip-array updates.
|
||||
// A zoom change re-projects every clip + rebuilds the ruler ticks; a clips
|
||||
// swap re-reconciles the internal shallowRef Map.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TimelineRoot — update after prop change', () => {
|
||||
bench('zoom change (pxPerSecond) — 8 tracks / 500 clips', async () => {
|
||||
const w = mount(makeTimeline(tracks500.slice(0, 8), clips500), { attachTo: document.body });
|
||||
await w.setProps({ pxPerSecond: PX_PER_SECOND * 2 });
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('clips-array swap — 8 tracks / 500 clips', async () => {
|
||||
const Comp = defineComponent({
|
||||
setup() {
|
||||
const clipsRef = ref<TimelineClipData[]>(clips500.map(c => ({ ...c })));
|
||||
const tracks = tracks500.slice(0, 8);
|
||||
const swap = () => {
|
||||
// Shift every start by a frame (new objects → reconcile path).
|
||||
clipsRef.value = clipsRef.value.map(c => ({ ...c, start: c.start + 1 / FPS }));
|
||||
};
|
||||
return { clipsRef, tracks, swap };
|
||||
},
|
||||
render() {
|
||||
return h(
|
||||
TimelineRoot,
|
||||
{ tracks: this.tracks, clips: this.clipsRef, pxPerSecond: PX_PER_SECOND, fps: FPS, style: 'width:1200px;display:block;' },
|
||||
{
|
||||
default: () => h(
|
||||
TimelineTracks,
|
||||
{ style: 'position:relative;display:block;width:1200px;height:600px;' },
|
||||
{
|
||||
default: () => this.tracks.map(t => h(
|
||||
TimelineTrack,
|
||||
{ trackId: t.id, key: t.id, style: 'position:relative;display:block;' },
|
||||
{
|
||||
default: () => this.clipsRef
|
||||
.filter(c => c.trackId === t.id)
|
||||
.map(c => h(TimelineClip, { clipId: c.id, key: c.id }, { default: () => c.label })),
|
||||
},
|
||||
)),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
const w = mount(Comp, { attachTo: document.body });
|
||||
(w.vm as unknown as { swap: () => void }).swap();
|
||||
await w.vm.$nextTick();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { bench, describe } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import {
|
||||
TransformBoxHandle,
|
||||
TransformBoxRoot,
|
||||
TransformBoxRotateHandle,
|
||||
TransformBoxStatus,
|
||||
} from '../index';
|
||||
import type { Point, TransformBoxHandlePosition, TransformBoxValue } from '../utils';
|
||||
import {
|
||||
applyAspectRatio,
|
||||
boxCenter,
|
||||
constrainRect,
|
||||
decomposeTransform,
|
||||
handleAxes,
|
||||
localToWorld,
|
||||
moveBox,
|
||||
normalizeRotation,
|
||||
pointerAngle,
|
||||
resizeEdge,
|
||||
resolvePivot,
|
||||
rotatePoint,
|
||||
rotateVector,
|
||||
rotationFromPointer,
|
||||
shortestAngleDelta,
|
||||
snapRotation,
|
||||
worldToLocal,
|
||||
} from '../utils';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Deterministic fixtures (NO Math.random — every value is seeded by its index so
|
||||
// runs are reproducible). Built once at module scope with simple loops.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The 8 handle positions in the package's stable order. */
|
||||
const POSITIONS: TransformBoxHandlePosition[] = [
|
||||
'top-left',
|
||||
'top',
|
||||
'top-right',
|
||||
'right',
|
||||
'bottom-right',
|
||||
'bottom',
|
||||
'bottom-left',
|
||||
'left',
|
||||
];
|
||||
|
||||
/** A representative rotated box; the math hot path mostly trades on `rotation`. */
|
||||
const BOX: TransformBoxValue = { x: 96, y: 64, width: 200, height: 130, rotation: -8 };
|
||||
|
||||
/** Build N deterministic boxes spread across position / size / rotation space. */
|
||||
function makeBoxes(n: number): TransformBoxValue[] {
|
||||
const out: TransformBoxValue[] = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
out[i] = {
|
||||
x: (i % 400) - 200,
|
||||
y: ((i * 3) % 400) - 200,
|
||||
width: 20 + (i % 380),
|
||||
height: 20 + ((i * 7) % 380),
|
||||
// Sweep rotation across the full circle and across the ±180 seam.
|
||||
rotation: ((i * 13) % 720) - 360,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build N deterministic 2D points (used as pointer samples / deltas). */
|
||||
function makePoints(n: number): Point[] {
|
||||
const out: Point[] = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
// Lissajous-ish spread so x/y are decorrelated but fully deterministic.
|
||||
out[i] = {
|
||||
x: ((i * 31) % 800) - 400,
|
||||
y: ((i * 17) % 600) - 300,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Pre-resolve handle axes for every position once. */
|
||||
const AXES = POSITIONS.map(handleAxes);
|
||||
|
||||
const BOXES_100 = makeBoxes(100);
|
||||
const BOXES_1000 = makeBoxes(1000);
|
||||
const POINTS_100 = makePoints(100);
|
||||
const POINTS_1000 = makePoints(1000);
|
||||
|
||||
/** A simulated pointer-move drag track about a pivot (rotation gesture frames). */
|
||||
const PIVOT: Point = boxCenter(BOX);
|
||||
const DRAG_100 = makePoints(100);
|
||||
const DRAG_1000 = makePoints(1000);
|
||||
const START_POINTER_ANGLE = pointerAngle(DRAG_100[0]!, PIVOT);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Pure rotation primitives — the innermost kernel every gesture frame calls.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rotatePoint — kernel', () => {
|
||||
bench('rotatePoint × 100', () => {
|
||||
for (let i = 0; i < POINTS_100.length; i++) {
|
||||
rotatePoint(POINTS_100[i]!, 37, PIVOT);
|
||||
}
|
||||
});
|
||||
|
||||
bench('rotatePoint × 1000', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++) {
|
||||
rotatePoint(POINTS_1000[i]!, 37, PIVOT);
|
||||
}
|
||||
});
|
||||
|
||||
bench('rotateVector (origin-free) × 1000', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++) {
|
||||
rotateVector(POINTS_1000[i]!, -BOX.rotation);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Pointer→angle math — the rotate-handle hot path (atan2 + seam-safe delta).
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('pointer angle math', () => {
|
||||
bench('pointerAngle × 100', () => {
|
||||
for (let i = 0; i < POINTS_100.length; i++) {
|
||||
pointerAngle(POINTS_100[i]!, PIVOT);
|
||||
}
|
||||
});
|
||||
|
||||
bench('pointerAngle × 1000', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++) {
|
||||
pointerAngle(POINTS_1000[i]!, PIVOT);
|
||||
}
|
||||
});
|
||||
|
||||
bench('shortestAngleDelta × 1000', () => {
|
||||
for (let i = 0; i < BOXES_1000.length; i++) {
|
||||
shortestAngleDelta(BOXES_1000[i]!.rotation, BOX.rotation);
|
||||
}
|
||||
});
|
||||
|
||||
bench('normalizeRotation × 1000', () => {
|
||||
for (let i = 0; i < BOXES_1000.length; i++) {
|
||||
normalizeRotation(BOXES_1000[i]!.rotation);
|
||||
}
|
||||
});
|
||||
|
||||
bench('snapRotation (15°) × 1000', () => {
|
||||
for (let i = 0; i < BOXES_1000.length; i++) {
|
||||
snapRotation(BOXES_1000[i]!.rotation, 15);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Simulated rotate drag — one rotationFromPointer per pointer-move frame, the
|
||||
// full per-frame computation the rotate handle does (angle + accumulate delta).
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rotate drag — per-frame', () => {
|
||||
bench('rotationFromPointer × 100 frames', () => {
|
||||
for (let i = 0; i < DRAG_100.length; i++) {
|
||||
rotationFromPointer(DRAG_100[i]!, PIVOT, START_POINTER_ANGLE, BOX.rotation);
|
||||
}
|
||||
});
|
||||
|
||||
bench('rotationFromPointer × 1000 frames', () => {
|
||||
for (let i = 0; i < DRAG_1000.length; i++) {
|
||||
rotationFromPointer(DRAG_1000[i]!, PIVOT, START_POINTER_ANGLE, BOX.rotation);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// resizeEdge — the heaviest pure helper (anchor capture, aspect lock, flip,
|
||||
// re-place, normalize). Driven every scale-handle pointer-move frame.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('resizeEdge — per-frame', () => {
|
||||
bench('resizeEdge corner (no options) × 100', () => {
|
||||
for (let i = 0; i < POINTS_100.length; i++) {
|
||||
resizeEdge(BOX, 'bottom-right', POINTS_100[i]!);
|
||||
}
|
||||
});
|
||||
|
||||
bench('resizeEdge corner (no options) × 1000', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++) {
|
||||
resizeEdge(BOX, 'bottom-right', POINTS_1000[i]!);
|
||||
}
|
||||
});
|
||||
|
||||
bench('resizeEdge aspect-locked corner × 1000', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++) {
|
||||
resizeEdge(BOX, 'bottom-right', POINTS_1000[i]!, { aspectRatio: 1.5 });
|
||||
}
|
||||
});
|
||||
|
||||
bench('resizeEdge symmetric (Alt) corner × 1000', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++) {
|
||||
resizeEdge(BOX, 'bottom-right', POINTS_1000[i]!, { symmetric: true, pivot: 'center' });
|
||||
}
|
||||
});
|
||||
|
||||
bench('resizeEdge edge handle × 1000', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++) {
|
||||
resizeEdge(BOX, 'right', POINTS_1000[i]!);
|
||||
}
|
||||
});
|
||||
|
||||
// Full scale-frame as the root runs it: rotate the screen delta into local
|
||||
// axes first, then resize (the load-bearing step for rotated boxes).
|
||||
bench('rotated scale frame (rotateVector → resizeEdge) × 1000', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++) {
|
||||
const local = rotateVector(POINTS_1000[i]!, -BOX.rotation);
|
||||
resizeEdge(BOX, 'bottom-right', local, { minWidth: 40, minHeight: 40, allowFlip: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// applyAspectRatio / handleAxes — small per-frame helpers.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('aspect + axes helpers', () => {
|
||||
const cornerAxes = handleAxes('bottom-right');
|
||||
|
||||
bench('applyAspectRatio × 1000', () => {
|
||||
for (let i = 0; i < BOXES_1000.length; i++) {
|
||||
const b = BOXES_1000[i]!;
|
||||
applyAspectRatio(b.width, b.height, 1.5, cornerAxes);
|
||||
}
|
||||
});
|
||||
|
||||
bench('handleAxes × 8 positions × 125 (=1000)', () => {
|
||||
for (let i = 0; i < 125; i++) {
|
||||
for (let j = 0; j < POSITIONS.length; j++) {
|
||||
handleAxes(POSITIONS[j]!);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// constrainRect / moveBox — commit-path normalization + whole-box drag.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('constrain + move', () => {
|
||||
bench('constrainRect × 1000', () => {
|
||||
for (let i = 0; i < BOXES_1000.length; i++) {
|
||||
constrainRect(BOXES_1000[i]!, 40, 40);
|
||||
}
|
||||
});
|
||||
|
||||
bench('moveBox × 1000', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++) {
|
||||
moveBox(BOX, POINTS_1000[i]!);
|
||||
}
|
||||
});
|
||||
|
||||
bench('resolvePivot (center) × 1000', () => {
|
||||
for (let i = 0; i < BOXES_1000.length; i++) {
|
||||
resolvePivot(BOXES_1000[i]!, 'center');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// local⇄world round-trip — used to capture/re-place anchors against rotation.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('local ⇄ world', () => {
|
||||
bench('localToWorld → worldToLocal round-trip × 1000', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++) {
|
||||
const w = localToWorld(BOX, POINTS_1000[i]!);
|
||||
worldToLocal(BOX, w);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// decomposeTransform — Crop/overlay hot path: normalize + 4 rotated corners.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decomposeTransform — corners', () => {
|
||||
bench('decomposeTransform × 100', () => {
|
||||
for (let i = 0; i < BOXES_100.length; i++) {
|
||||
decomposeTransform(BOXES_100[i]!);
|
||||
}
|
||||
});
|
||||
|
||||
bench('decomposeTransform × 1000', () => {
|
||||
for (let i = 0; i < BOXES_1000.length; i++) {
|
||||
decomposeTransform(BOXES_1000[i]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Component mount + update — realistic (1 root, 8 handles) and stress scale.
|
||||
// Mirrors the existing Primitive.bench convention: mount() then unmount().
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A harness with `count` independent transform boxes, each with 8 handles. */
|
||||
function makeStage(count: number, value: TransformBoxValue) {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
return () => h(
|
||||
'div',
|
||||
null,
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
h(
|
||||
TransformBoxRoot,
|
||||
{
|
||||
key: i,
|
||||
modelValue: value,
|
||||
minWidth: 40,
|
||||
minHeight: 40,
|
||||
rotationSnap: 15,
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
...POSITIONS.map(p => h(TransformBoxHandle, { key: p, position: p })),
|
||||
h(TransformBoxRotateHandle),
|
||||
h(TransformBoxStatus),
|
||||
],
|
||||
},
|
||||
)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('TransformBoxRoot — mount full part set', () => {
|
||||
bench('mount + unmount — 1 box (root + 8 handles + rotate + status)', () => {
|
||||
const w = mount(makeStage(1, BOX), { attachTo: document.body });
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('mount + unmount — 50 boxes', () => {
|
||||
const w = mount(makeStage(50, BOX), { attachTo: document.body });
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('mount + unmount — 500 boxes (stress)', () => {
|
||||
const w = mount(makeStage(500, BOX), { attachTo: document.body });
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Re-render after a prop (transform v-model) change — restyle + reflow path for
|
||||
// every part. Mirrors the "mount + update" bench in Primitive.bench.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const BOX_B: TransformBoxValue = { x: 140, y: 90, width: 260, height: 180, rotation: 42 };
|
||||
|
||||
function makeUpdatableStage(count: number) {
|
||||
return defineComponent({
|
||||
props: { v: { type: Object, required: true } },
|
||||
setup(props) {
|
||||
return () => h(
|
||||
'div',
|
||||
null,
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
h(
|
||||
TransformBoxRoot,
|
||||
{ key: i, modelValue: props.v as TransformBoxValue, minWidth: 40, minHeight: 40 },
|
||||
{
|
||||
default: () => POSITIONS.map(p => h(TransformBoxHandle, { key: p, position: p })),
|
||||
},
|
||||
)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('TransformBoxRoot — update after transform change', () => {
|
||||
bench('mount → setProps(transform) → update — 50 boxes', async () => {
|
||||
const w = mount(makeUpdatableStage(50), {
|
||||
props: { v: BOX },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await w.setProps({ v: BOX_B });
|
||||
await nextTick();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { buildSmoothPath } from '../../../internal/spline';
|
||||
import { buildBars, buildPathPoints, countBars, resamplePeaks } from '../utils';
|
||||
import { WaveformBars, WaveformPath, WaveformRoot } from '../index';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Deterministic fixtures (NO Math.random, NO network — seeded by index/formula).
|
||||
// Peaks model a signed `-1..1` PCM-style envelope: a decaying sinusoid summed
|
||||
// with a faster ripple, so resampling has real transients to pick a max from.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makePeaks(n: number): number[] {
|
||||
const out = Array.from<number>({ length: n });
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = i / n;
|
||||
const envelope = 1 - 0.6 * t; // slow decay over the track
|
||||
const carrier = Math.sin(t * Math.PI * 64); // audible-rate oscillation
|
||||
const ripple = 0.35 * Math.sin(t * Math.PI * 503); // transient detail
|
||||
out[i] = envelope * (carrier * 0.7 + ripple); // stays within -1..1
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A Float32Array variant exercises the `ArrayLike<number>` fast path the root
|
||||
// passes through from a `Float32Array` peaks prop (typed-array element reads).
|
||||
function makePeaksF32(n: number): Float32Array {
|
||||
const src = makePeaks(n);
|
||||
const out = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) out[i] = src[i]!;
|
||||
return out;
|
||||
}
|
||||
|
||||
const PEAKS_100 = makePeaks(100);
|
||||
const PEAKS_1000 = makePeaks(1000);
|
||||
const PEAKS_10000 = makePeaks(10000);
|
||||
const PEAKS_F32_10000 = makePeaksF32(10000);
|
||||
|
||||
// Realistic body widths: a small inline waveform (~150 bars) and a full-bleed
|
||||
// editor lane (~600+ bars) at the demo's barWidth=2 / barGap=1 (pitch 3).
|
||||
const BAR_WIDTH = 2;
|
||||
const BAR_GAP = 1;
|
||||
const WIDTH_SMALL = 300; // → 100 bars
|
||||
const WIDTH_LARGE = 1800; // → 600 bars
|
||||
|
||||
const SIGNED = true; // peaksRange '-1..1' (the root default → rectify by abs)
|
||||
|
||||
// Pre-built point sets for the smoothing benches (path mode silhouette).
|
||||
const PATH_POINTS_256 = buildPathPoints(PEAKS_1000, WIDTH_LARGE, 120, 256, SIGNED);
|
||||
const PATH_POINTS_1024 = buildPathPoints(PEAKS_10000, WIDTH_LARGE, 120, 1024, SIGNED);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// countBars — cheap per-render geometry guard (runs on every width change).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('countBars', () => {
|
||||
bench('small body (300px)', () => {
|
||||
countBars(WIDTH_SMALL, BAR_WIDTH, BAR_GAP);
|
||||
});
|
||||
|
||||
bench('large body (1800px)', () => {
|
||||
countBars(WIDTH_LARGE, BAR_WIDTH, BAR_GAP);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// resamplePeaks — THE hot inner loop (max-magnitude reduction per bucket).
|
||||
// Scales with BOTH source length and bucket count; benched across realistic and
|
||||
// stress combinations.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('resamplePeaks — by source length (100 buckets)', () => {
|
||||
bench('100 peaks', () => {
|
||||
resamplePeaks(PEAKS_100, 100, SIGNED);
|
||||
});
|
||||
|
||||
bench('1000 peaks', () => {
|
||||
resamplePeaks(PEAKS_1000, 100, SIGNED);
|
||||
});
|
||||
|
||||
bench('10000 peaks', () => {
|
||||
resamplePeaks(PEAKS_10000, 100, SIGNED);
|
||||
});
|
||||
|
||||
bench('10000 peaks (Float32Array)', () => {
|
||||
resamplePeaks(PEAKS_F32_10000, 100, SIGNED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resamplePeaks — by bucket count (10000 peaks)', () => {
|
||||
bench('100 buckets', () => {
|
||||
resamplePeaks(PEAKS_10000, 100, SIGNED);
|
||||
});
|
||||
|
||||
bench('600 buckets', () => {
|
||||
resamplePeaks(PEAKS_10000, 600, SIGNED);
|
||||
});
|
||||
|
||||
bench('upsample → 2000 buckets', () => {
|
||||
resamplePeaks(PEAKS_10000, 2000, SIGNED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resamplePeaks — windowed slice (zoom/scroll)', () => {
|
||||
// The root maps the visible time window onto a peaks index slice; a zoomed-in
|
||||
// view resamples a sub-range into the same bucket count.
|
||||
bench('full window — 600 buckets over 10000', () => {
|
||||
resamplePeaks(PEAKS_10000, 600, SIGNED, 0, PEAKS_10000.length);
|
||||
});
|
||||
|
||||
bench('25% zoom window — 600 buckets over slice', () => {
|
||||
resamplePeaks(PEAKS_10000, 600, SIGNED, 2500, 5000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// buildBars — bars-mode render hot path (countBars + resample + layout loop).
|
||||
// This is what `WaveformRoot.buckets` computes on every width / peaks / window
|
||||
// change. Benched at realistic (100 bars) and stress (600 bars) scale.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildBars — bars-mode geometry', () => {
|
||||
bench('100 bars from 1000 peaks', () => {
|
||||
buildBars(PEAKS_1000, WIDTH_SMALL, BAR_WIDTH, BAR_GAP, SIGNED);
|
||||
});
|
||||
|
||||
bench('600 bars from 10000 peaks', () => {
|
||||
buildBars(PEAKS_10000, WIDTH_LARGE, BAR_WIDTH, BAR_GAP, SIGNED);
|
||||
});
|
||||
|
||||
bench('600 bars from 10000 peaks (Float32Array)', () => {
|
||||
buildBars(PEAKS_F32_10000, WIDTH_LARGE, BAR_WIDTH, BAR_GAP, SIGNED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBars — sliding window (simulated scrub/zoom recompute)', () => {
|
||||
// Each iteration recomputes the full bar geometry over a window shifted by a
|
||||
// deterministic step — the per-frame cost when a user scrubs a zoomed lane.
|
||||
let frame = 0;
|
||||
const total = PEAKS_10000.length;
|
||||
const span = Math.floor(total / 4); // a 25% zoom window
|
||||
|
||||
bench('600 bars, window slides per iteration', () => {
|
||||
const start = (frame * 137) % (total - span); // deterministic, no PRNG
|
||||
frame += 1;
|
||||
buildBars(PEAKS_10000, WIDTH_LARGE, BAR_WIDTH, BAR_GAP, SIGNED, start, start + span);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// buildPathPoints — path-mode polyline hot path (resample + y-projection loop).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildPathPoints — path-mode silhouette', () => {
|
||||
bench('256 samples from 1000 peaks', () => {
|
||||
buildPathPoints(PEAKS_1000, WIDTH_LARGE, 120, 256, SIGNED);
|
||||
});
|
||||
|
||||
bench('1024 samples from 10000 peaks', () => {
|
||||
buildPathPoints(PEAKS_10000, WIDTH_LARGE, 120, 1024, SIGNED);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// buildSmoothPath — Catmull-Rom smoothing over the path points (the d-string
|
||||
// `WaveformPath` recomputes on every width/peaks/window change in path mode).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildSmoothPath — Catmull-Rom path string', () => {
|
||||
bench('256 points, tension 0', () => {
|
||||
buildSmoothPath(PATH_POINTS_256, 0);
|
||||
});
|
||||
|
||||
bench('256 points, tension 0.5', () => {
|
||||
buildSmoothPath(PATH_POINTS_256, 0.5);
|
||||
});
|
||||
|
||||
bench('1024 points, tension 0', () => {
|
||||
buildSmoothPath(PATH_POINTS_1024, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Component mount + update via @vue/test-utils mount().
|
||||
// `WaveformRoot` measures its own width (useElementSize → 0 synchronously under
|
||||
// the bench), so to render a deterministic N bars we drive `WaveformBars`'
|
||||
// default slot directly with pre-built geometry. This isolates the Vue render
|
||||
// cost of N bar nodes (the real per-frame DOM work) from ResizeObserver timing.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const BARS_50 = buildBars(PEAKS_1000, 150, BAR_WIDTH, BAR_GAP, SIGNED); // 50 bars
|
||||
const BARS_500 = buildBars(PEAKS_10000, 1500, BAR_WIDTH, BAR_GAP, SIGNED); // 500 bars
|
||||
|
||||
function mountWaveform(peaks: number[], duration: number) {
|
||||
return mount(
|
||||
defineComponent({
|
||||
props: {
|
||||
peaks: { type: Array as () => number[], required: true },
|
||||
duration: { type: Number, required: true },
|
||||
currentTime: { type: Number, default: 0 },
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
h(
|
||||
WaveformRoot,
|
||||
{
|
||||
peaks: props.peaks,
|
||||
peaksRange: '-1..1',
|
||||
duration: props.duration,
|
||||
currentTime: props.currentTime,
|
||||
barWidth: BAR_WIDTH,
|
||||
barGap: BAR_GAP,
|
||||
},
|
||||
{ default: () => h(WaveformBars) },
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ props: { peaks, duration: 100, currentTime: 0 } },
|
||||
);
|
||||
}
|
||||
|
||||
describe('WaveformRoot + WaveformBars — mount', () => {
|
||||
bench('mount with ~50-bar fixture', () => {
|
||||
const w = mountWaveform(PEAKS_1000, 100);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('mount with ~500-bar fixture', () => {
|
||||
const w = mountWaveform(PEAKS_10000, 100);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WaveformRoot — update after prop change', () => {
|
||||
bench('currentTime change → patch', async () => {
|
||||
const w = mountWaveform(PEAKS_1000, 100);
|
||||
await w.setProps({ currentTime: 42 });
|
||||
await nextTick();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('peaks swap → re-resample + patch', async () => {
|
||||
const w = mountWaveform(PEAKS_1000, 100);
|
||||
await w.setProps({ peaks: PEAKS_10000 });
|
||||
await nextTick();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// Path-mode component render: mount the SVG silhouette part (its `d` recompute
|
||||
// drives buildPathPoints + buildSmoothPath).
|
||||
describe('WaveformRoot + WaveformPath — mount', () => {
|
||||
function mountPath(peaks: number[], samples: number) {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
WaveformRoot,
|
||||
{ peaks, peaksRange: '-1..1', duration: 100, mode: 'path' },
|
||||
{ default: () => h(WaveformPath, { samples }) },
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
bench('path mode, 256 samples', () => {
|
||||
const w = mountPath(PEAKS_1000, 256);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('path mode, 1024 samples', () => {
|
||||
const w = mountPath(PEAKS_10000, 1024);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// Silence "fixture is unused" tree-shake concerns: reference the prebuilt arrays
|
||||
// so the bench harness retains them deterministically across runs.
|
||||
void BARS_50.length;
|
||||
void BARS_500.length;
|
||||
@@ -0,0 +1,412 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import type { Rect, Viewport, XYPosition } from '../types';
|
||||
import {
|
||||
clampViewport,
|
||||
clampZoom,
|
||||
contentToScreen,
|
||||
fitViewTransform,
|
||||
measureContentRect,
|
||||
screenToContent,
|
||||
wheelToZoomFactor,
|
||||
zoomAtPointer,
|
||||
} from '../utils';
|
||||
import { ViewportRoot } from '../index';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Deterministic fixtures (NO Math.random — every value is a closed form of its
|
||||
// index). Built once at module scope so the benched body does only the hot work.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ORIGIN = { left: 0, top: 0 };
|
||||
const SURFACE_ORIGIN = { left: 64, top: 40 };
|
||||
|
||||
/** A stable viewport with a non-trivial pan + zoom for coordinate math. */
|
||||
const VIEWPORT: Viewport = { x: 120, y: -48, zoom: 1.5 };
|
||||
|
||||
/** Constraints WITHOUT a translate extent (the common pan/zoom clamp). */
|
||||
const CONSTRAINTS_ZOOM_ONLY = { minZoom: 0.3, maxZoom: 4 } as const;
|
||||
|
||||
/** Constraints WITH a translate extent (the boundary-clamped pan case). */
|
||||
const CONSTRAINTS_EXTENT = {
|
||||
minZoom: 0.3,
|
||||
maxZoom: 4,
|
||||
translateExtent: { minX: -2000, maxX: 2000, minY: -2000, maxY: 2000 },
|
||||
} as const;
|
||||
|
||||
/** Constraints with a DEGENERATE extent on x (min > max → centring branch). */
|
||||
const CONSTRAINTS_DEGENERATE = {
|
||||
minZoom: 0.3,
|
||||
maxZoom: 4,
|
||||
translateExtent: { minX: 500, maxX: -500, minY: -2000, maxY: 2000 },
|
||||
} as const;
|
||||
|
||||
/** Content bounds for fit-view math (content space). */
|
||||
const CONTENT_BOUNDS: Rect = { x: 0, y: 0, width: 720, height: 480 };
|
||||
const SURFACE_SIZE = { width: 400, height: 300 };
|
||||
|
||||
/**
|
||||
* Pre-built point batches at realistic (100) and stress (1000) scale. Each
|
||||
* coordinate is a deterministic spread of its index so values are non-uniform
|
||||
* (exercising the divide/multiply paths) without any RNG.
|
||||
*/
|
||||
function buildPoints(n: number): XYPosition[] {
|
||||
const out: XYPosition[] = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
out[i] = { x: (i * 37) % 1280, y: (i * 53) % 720 };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const POINTS_100 = buildPoints(100);
|
||||
const POINTS_1000 = buildPoints(1000);
|
||||
|
||||
/**
|
||||
* Candidate viewports to clamp, at realistic and stress scale. Each is pushed
|
||||
* deliberately out of bounds (large pan, out-of-range zoom) so the clamp does
|
||||
* real work on most entries.
|
||||
*/
|
||||
function buildViewports(n: number): Viewport[] {
|
||||
const out: Viewport[] = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
out[i] = {
|
||||
x: ((i * 311) % 8000) - 4000,
|
||||
y: ((i * 173) % 8000) - 4000,
|
||||
zoom: 0.1 + ((i * 7) % 50) / 10, // 0.1 .. 5.0
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const VIEWPORTS_100 = buildViewports(100);
|
||||
const VIEWPORTS_1000 = buildViewports(1000);
|
||||
|
||||
/**
|
||||
* Pre-built WheelEvent fixtures spanning the three deltaMode branches and the
|
||||
* ctrlKey (pinch) amplifier. Constructed in browser mode (Playwright chromium)
|
||||
* where the WheelEvent constructor is real.
|
||||
*/
|
||||
function buildWheelEvents(n: number): WheelEvent[] {
|
||||
const out: WheelEvent[] = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
out[i] = new WheelEvent('wheel', {
|
||||
deltaY: ((i % 7) - 3) * 40, // -120 .. 120, includes 0
|
||||
deltaX: ((i % 5) - 2) * 30,
|
||||
deltaMode: i % 3, // 0 px, 1 line, 2 page
|
||||
ctrlKey: i % 4 === 0, // ~25% pinch
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const WHEEL_100 = buildWheelEvents(100);
|
||||
const WHEEL_1000 = buildWheelEvents(1000);
|
||||
|
||||
/**
|
||||
* A simulated pointer-move stream (surface-relative client points) used to drive
|
||||
* the zoom-at-pointer hot path the same way `useZoomPan`'s wheel handler does:
|
||||
* read current vp → factor → clamp → zoomAtPointer. One step per fixture entry.
|
||||
*/
|
||||
const POINTER_STREAM_100 = buildPoints(100);
|
||||
const POINTER_STREAM_1000 = buildPoints(1000);
|
||||
|
||||
// Sinks to defeat dead-code elimination of pure-function results.
|
||||
let sinkNum = 0;
|
||||
let sinkPoint: XYPosition = { x: 0, y: 0 };
|
||||
let sinkVp: Viewport = { x: 0, y: 0, zoom: 1 };
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pure coordinate math — screen↔content (the per-frame hit-test conversions).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('screenToContent — over N points', () => {
|
||||
bench('100 points', () => {
|
||||
for (let i = 0; i < POINTS_100.length; i++)
|
||||
sinkPoint = screenToContent(POINTS_100[i], VIEWPORT, SURFACE_ORIGIN);
|
||||
});
|
||||
|
||||
bench('1000 points', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++)
|
||||
sinkPoint = screenToContent(POINTS_1000[i], VIEWPORT, SURFACE_ORIGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contentToScreen — over N points', () => {
|
||||
bench('100 points', () => {
|
||||
for (let i = 0; i < POINTS_100.length; i++)
|
||||
sinkPoint = contentToScreen(POINTS_100[i], VIEWPORT, SURFACE_ORIGIN);
|
||||
});
|
||||
|
||||
bench('1000 points', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++)
|
||||
sinkPoint = contentToScreen(POINTS_1000[i], VIEWPORT, SURFACE_ORIGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip screen→content→screen — over N points', () => {
|
||||
bench('100 points', () => {
|
||||
for (let i = 0; i < POINTS_100.length; i++) {
|
||||
const c = screenToContent(POINTS_100[i], VIEWPORT, ORIGIN);
|
||||
sinkPoint = contentToScreen(c, VIEWPORT, ORIGIN);
|
||||
}
|
||||
});
|
||||
|
||||
bench('1000 points', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++) {
|
||||
const c = screenToContent(POINTS_1000[i], VIEWPORT, ORIGIN);
|
||||
sinkPoint = contentToScreen(c, VIEWPORT, ORIGIN);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// zoomAtPointer — the anchored-zoom transform run on every wheel/pinch step.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('zoomAtPointer — over N anchor points', () => {
|
||||
bench('100 points', () => {
|
||||
for (let i = 0; i < POINTS_100.length; i++)
|
||||
sinkVp = zoomAtPointer(VIEWPORT, VIEWPORT.zoom * 1.1, POINTS_100[i], SURFACE_ORIGIN);
|
||||
});
|
||||
|
||||
bench('1000 points', () => {
|
||||
for (let i = 0; i < POINTS_1000.length; i++)
|
||||
sinkVp = zoomAtPointer(VIEWPORT, VIEWPORT.zoom * 1.1, POINTS_1000[i], SURFACE_ORIGIN);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// clampViewport — the per-write clamp. Three branches: zoom-only, with extent,
|
||||
// and the degenerate (centring) extent. Each at realistic + stress scale.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('clampViewport — zoom-only (no extent)', () => {
|
||||
bench('100 viewports', () => {
|
||||
for (let i = 0; i < VIEWPORTS_100.length; i++)
|
||||
sinkVp = clampViewport(VIEWPORTS_100[i], CONSTRAINTS_ZOOM_ONLY);
|
||||
});
|
||||
|
||||
bench('1000 viewports', () => {
|
||||
for (let i = 0; i < VIEWPORTS_1000.length; i++)
|
||||
sinkVp = clampViewport(VIEWPORTS_1000[i], CONSTRAINTS_ZOOM_ONLY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampViewport — with translate extent', () => {
|
||||
bench('100 viewports', () => {
|
||||
for (let i = 0; i < VIEWPORTS_100.length; i++)
|
||||
sinkVp = clampViewport(VIEWPORTS_100[i], CONSTRAINTS_EXTENT);
|
||||
});
|
||||
|
||||
bench('1000 viewports', () => {
|
||||
for (let i = 0; i < VIEWPORTS_1000.length; i++)
|
||||
sinkVp = clampViewport(VIEWPORTS_1000[i], CONSTRAINTS_EXTENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampViewport — degenerate extent (centring branch)', () => {
|
||||
bench('100 viewports', () => {
|
||||
for (let i = 0; i < VIEWPORTS_100.length; i++)
|
||||
sinkVp = clampViewport(VIEWPORTS_100[i], CONSTRAINTS_DEGENERATE);
|
||||
});
|
||||
|
||||
bench('1000 viewports', () => {
|
||||
for (let i = 0; i < VIEWPORTS_1000.length; i++)
|
||||
sinkVp = clampViewport(VIEWPORTS_1000[i], CONSTRAINTS_DEGENERATE);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// wheelToZoomFactor — normalises deltaMode + ctrlKey on every wheel event.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('wheelToZoomFactor — over N wheel events', () => {
|
||||
bench('100 events', () => {
|
||||
for (let i = 0; i < WHEEL_100.length; i++)
|
||||
sinkNum += wheelToZoomFactor(WHEEL_100[i]);
|
||||
});
|
||||
|
||||
bench('1000 events', () => {
|
||||
for (let i = 0; i < WHEEL_1000.length; i++)
|
||||
sinkNum += wheelToZoomFactor(WHEEL_1000[i]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Simulated wheel-zoom pipeline — the exact composition useZoomPan runs per
|
||||
// wheel event: factor → clampZoom → zoomAtPointer (skipping the boundary no-op).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('wheel-zoom pipeline (factor → clamp → zoomAtPointer)', () => {
|
||||
bench('100 steps', () => {
|
||||
let vp = VIEWPORT;
|
||||
for (let i = 0; i < WHEEL_100.length; i++) {
|
||||
const factor = wheelToZoomFactor(WHEEL_100[i]);
|
||||
const next = clampZoom(vp.zoom * factor, CONSTRAINTS_ZOOM_ONLY.minZoom, CONSTRAINTS_ZOOM_ONLY.maxZoom);
|
||||
if (next === vp.zoom) continue;
|
||||
vp = zoomAtPointer(vp, next, POINTER_STREAM_100[i], SURFACE_ORIGIN);
|
||||
}
|
||||
sinkVp = vp;
|
||||
});
|
||||
|
||||
bench('1000 steps', () => {
|
||||
let vp = VIEWPORT;
|
||||
for (let i = 0; i < WHEEL_1000.length; i++) {
|
||||
const factor = wheelToZoomFactor(WHEEL_1000[i]);
|
||||
const next = clampZoom(vp.zoom * factor, CONSTRAINTS_ZOOM_ONLY.minZoom, CONSTRAINTS_ZOOM_ONLY.maxZoom);
|
||||
if (next === vp.zoom) continue;
|
||||
vp = zoomAtPointer(vp, next, POINTER_STREAM_1000[i], SURFACE_ORIGIN);
|
||||
}
|
||||
sinkVp = vp;
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Simulated drag-pan move stream — the onMove body: translate from a base vp by
|
||||
// the accumulated screen delta, then clamp. One scheduled write per move.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('drag-pan move (translate + clamp)', () => {
|
||||
bench('100 moves', () => {
|
||||
const base = VIEWPORT;
|
||||
for (let i = 0; i < POINTER_STREAM_100.length; i++) {
|
||||
sinkVp = clampViewport(
|
||||
{ zoom: base.zoom, x: base.x + POINTER_STREAM_100[i].x, y: base.y + POINTER_STREAM_100[i].y },
|
||||
CONSTRAINTS_EXTENT,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
bench('1000 moves', () => {
|
||||
const base = VIEWPORT;
|
||||
for (let i = 0; i < POINTER_STREAM_1000.length; i++) {
|
||||
sinkVp = clampViewport(
|
||||
{ zoom: base.zoom, x: base.x + POINTER_STREAM_1000[i].x, y: base.y + POINTER_STREAM_1000[i].y },
|
||||
CONSTRAINTS_EXTENT,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// fitViewTransform — fit-to-view math (zoom + centre). Once per fit, but cheap
|
||||
// enough that a tight loop gives a stable baseline.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('fitViewTransform', () => {
|
||||
bench('single fit', () => {
|
||||
sinkVp = fitViewTransform(CONTENT_BOUNDS, SURFACE_SIZE, { padding: 0.1, minZoom: 0.3, maxZoom: 4 });
|
||||
});
|
||||
|
||||
bench('100 fits', () => {
|
||||
for (let i = 0; i < 100; i++)
|
||||
sinkVp = fitViewTransform(CONTENT_BOUNDS, SURFACE_SIZE, { padding: 0.1, minZoom: 0.3, maxZoom: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// measureContentRect — reads a real DOM rect (browser mode) and divides out zoom.
|
||||
// A single live element measured repeatedly; the getBoundingClientRect read is
|
||||
// the dominant cost, so this captures the measure hot path under real layout.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const measureEl = document.createElement('div');
|
||||
measureEl.style.cssText = 'position:absolute;left:0;top:0;width:200px;height:120px;';
|
||||
document.body.appendChild(measureEl);
|
||||
|
||||
describe('measureContentRect (real getBoundingClientRect)', () => {
|
||||
bench('100 measurements', () => {
|
||||
for (let i = 0; i < 100; i++)
|
||||
sinkVp = measureContentRect(measureEl, VIEWPORT, SURFACE_ORIGIN) as unknown as Viewport;
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Component: ViewportRoot mount with N content tiles (realistic 50, stress 500).
|
||||
// Mirrors demo.vue: a single transformed content layer holding a grid of tiles.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const CONTENT_EXTENT = { x: 0, y: 0, width: 720, height: 480 };
|
||||
|
||||
function makeTiles(n: number) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ style: 'display:grid;grid-template-columns:repeat(6,110px);gap:10px;width:720px;' },
|
||||
Array.from({ length: n }, (_, i) =>
|
||||
h('div', { key: i, style: 'height:110px;display:grid;place-items:center;' }, String(i)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const tiles50 = makeTiles(50);
|
||||
const tiles500 = makeTiles(500);
|
||||
|
||||
function mountRoot(tiles: () => ReturnType<typeof h>, viewport: Viewport) {
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
ViewportRoot,
|
||||
{
|
||||
viewport,
|
||||
'min-zoom': 0.3,
|
||||
'max-zoom': 4,
|
||||
'content-extent': CONTENT_EXTENT,
|
||||
style: 'width:400px;height:300px;position:relative;overflow:hidden;',
|
||||
},
|
||||
{ default: tiles },
|
||||
);
|
||||
},
|
||||
});
|
||||
return mount(Harness, { attachTo: document.body });
|
||||
}
|
||||
|
||||
describe('ViewportRoot — mount with N tiles', () => {
|
||||
bench('50 tiles — mount + unmount', () => {
|
||||
const w = mountRoot(tiles50, { x: 40, y: 40, zoom: 1 });
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('500 tiles — mount + unmount', () => {
|
||||
const w = mountRoot(tiles500, { x: 40, y: 40, zoom: 1 });
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Component: re-render after a viewport prop change (the transform-update path —
|
||||
// what every pan/zoom frame ultimately drives through the content layer).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ViewportRoot — update after viewport prop change', () => {
|
||||
bench('50 tiles — mount + viewport update', async () => {
|
||||
const viewport = ref<Viewport>({ x: 40, y: 40, zoom: 1 });
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
ViewportRoot,
|
||||
{
|
||||
viewport: viewport.value,
|
||||
'min-zoom': 0.3,
|
||||
'max-zoom': 4,
|
||||
'content-extent': CONTENT_EXTENT,
|
||||
style: 'width:400px;height:300px;position:relative;overflow:hidden;',
|
||||
},
|
||||
{ default: tiles50 },
|
||||
);
|
||||
},
|
||||
});
|
||||
const w = mount(Harness, { attachTo: document.body });
|
||||
viewport.value = { x: 80, y: -20, zoom: 1.75 };
|
||||
await nextTick();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
bench('50 tiles — mount + minZoom prop update', async () => {
|
||||
const w = mountRoot(tiles50, { x: 40, y: 40, zoom: 1 });
|
||||
await w.setProps({});
|
||||
await w.findComponent(ViewportRoot).setProps({ minZoom: 0.8 });
|
||||
await nextTick();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user