Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions src/core/CoreNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import type { IAnimationController } from '../common/IAnimationController.js';
import { createAnimation } from './animations/CoreAnimation.js';
import type { CoreShaderNode } from './renderers/CoreShaderNode.js';
import { AutosizeMode, Autosizer } from './Autosizer.js';
import { removeChild } from './lib/collectionUtils.js';
import { removeChild, sortByZIndexStable } from './lib/collectionUtils.js';

export enum CoreNodeRenderState {
Init = 0,
Expand All @@ -63,11 +63,6 @@ const NO_CLIPPING_RECT: RectWithValid = {
valid: false,
};

// Hoisted so `sortChildren` doesn't allocate a fresh comparator closure on
// every z-index reorder.
const compareZIndex = (a: CoreNode, b: CoreNode): number =>
a.props.zIndex - b.props.zIndex;

const CoreNodeRenderStateMap: Map<CoreNodeRenderState, string> = new Map();
CoreNodeRenderStateMap.set(CoreNodeRenderState.Init, 'init');
CoreNodeRenderStateMap.set(CoreNodeRenderState.OutOfBounds, 'outOfBounds');
Expand Down Expand Up @@ -2207,7 +2202,7 @@ export class CoreNode extends EventEmitter {
}

sortChildren() {
this.children.sort(compareZIndex);
sortByZIndexStable(this.children);
this.stage.requestRenderListUpdate();
}

Expand Down
91 changes: 91 additions & 0 deletions src/core/lib/collectionUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import type { CoreNode } from '../CoreNode.js';
import { sortByZIndexStable } from './collectionUtils.js';

const makeNode = (zIndex: number, tag: number): CoreNode =>
({ props: { zIndex }, tag } as unknown as CoreNode);

const tags = (nodes: CoreNode[]): number[] => {
const out: number[] = [];
for (let i = 0; i < nodes.length; i++) {
out.push((nodes[i] as unknown as { tag: number }).tag);
}
return out;
};

describe('sortByZIndexStable', () => {
it('should sort nodes ascending by zIndex', () => {
const nodes = [makeNode(3, 0), makeNode(1, 1), makeNode(2, 2)];

sortByZIndexStable(nodes);

expect(tags(nodes)).toEqual([1, 2, 0]);
});

it('should preserve insertion order for equal zIndex values', () => {
const nodes = [
makeNode(1, 0),
makeNode(0, 1),
makeNode(1, 2),
makeNode(0, 3),
makeNode(1, 4),
];

sortByZIndexStable(nodes);

expect(tags(nodes)).toEqual([1, 3, 0, 2, 4]);
});

it('should be stable for arrays longer than 10 elements', () => {
// V8 on Chrome < 70 switched to an unstable quicksort above 10 elements;
// this guards the case the native sort got wrong.
const nodes: CoreNode[] = [];
for (let i = 0; i < 20; i++) {
nodes.push(makeNode(i % 2, i));
}

sortByZIndexStable(nodes);

const expected: number[] = [];
for (let i = 0; i < 20; i += 2) {
expected.push(i);
}
for (let i = 1; i < 20; i += 2) {
expected.push(i);
}
expect(tags(nodes)).toEqual(expected);
});

it('should handle negative and fractional zIndex values', () => {
const nodes = [
makeNode(0.5, 0),
makeNode(-2, 1),
makeNode(1000000000, 2),
makeNode(0, 3),
makeNode(-2, 4),
];

sortByZIndexStable(nodes);

expect(tags(nodes)).toEqual([1, 4, 3, 0, 2]);
});

it('should leave an already sorted array unchanged', () => {
const nodes = [makeNode(0, 0), makeNode(0, 1), makeNode(1, 2)];

sortByZIndexStable(nodes);

expect(tags(nodes)).toEqual([0, 1, 2]);
});

it('should handle empty and single-element arrays', () => {
const empty: CoreNode[] = [];
const single = [makeNode(5, 0)];

sortByZIndexStable(empty);
sortByZIndexStable(single);

expect(empty.length).toBe(0);
expect(tags(single)).toEqual([0]);
});
});
47 changes: 13 additions & 34 deletions src/core/lib/collectionUtils.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,23 @@
import type { CoreNode } from '../CoreNode.js';

//Bucket sort implementation for sorting CoreNode arrays by zIndex
export const bucketSortByZIndex = (nodes: CoreNode[], min: number): void => {
const buckets: CoreNode[][] = [];
const bucketIndices: number[] = [];
//distribute nodes into buckets
for (let i = 0; i < nodes.length; i++) {
// Stable in-place sort by zIndex. Array.prototype.sort is not stable on
// Chrome < 70 (V8 quicksorts arrays longer than 10), and equal-zIndex
// siblings must keep insertion order or paint order silently reshuffles.
// Children arrays are small and nearly sorted (kept sorted; a re-sort
// usually follows a single zIndex change), so insertion sort is O(n) in
// practice and allocation-free.
export const sortByZIndexStable = (nodes: CoreNode[]): void => {
const len = nodes.length;
for (let i = 1; i < len; i++) {
const node = nodes[i]!;
const index = node.props.zIndex - min;
//create bucket if it doesn't exist
if (buckets[index] === undefined) {
buckets[index] = [];
bucketIndices.push(index);
}
buckets[index]!.push(node);
}

//sort each bucket using insertion sort
for (let i = 1; i < bucketIndices.length; i++) {
const key = bucketIndices[i]!;
const z = node.props.zIndex;
let j = i - 1;
while (j >= 0 && bucketIndices[j]! > key) {
bucketIndices[j + 1] = bucketIndices[j]!;
while (j >= 0 && nodes[j]!.props.zIndex > z) {
nodes[j + 1] = nodes[j]!;
j--;
}
bucketIndices[j + 1] = key;
nodes[j + 1] = node;
}

//flatten buckets
let idx = 0;
for (let i = 0; i < bucketIndices.length; i++) {
const bucket = buckets[bucketIndices[i]!]!;
for (let j = 0; j < bucket.length; j++) {
nodes[idx++] = bucket[j]!;
}
}

//clean up
buckets.length = 0;
bucketIndices.length = 0;
};

export const incrementalRepositionByZIndex = (
Expand Down
Loading