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
37 changes: 32 additions & 5 deletions docs/rendering/rendering-mechanism.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,39 @@ For more details on this process, see [Component Lifecycle](../system/component-

## Z-Index Management

Components support z-index ordering through the Tree class:
The z-index system uses a **two-tier stacking model** that separates component *type* priority from visual *stacking order* within the same type.

1. Each component has a z-index value (default: 1)
2. Parent components maintain z-index groups to sort children
3. Children are rendered in z-index order during traversal
4. Z-index changes trigger re-ordering in the parent
### Tier 1: zIndex — Component Type Priority

`zIndex` encodes the architectural importance of a component class. Different component types are assigned different base values:
- Blocks have a higher `DEFAULT_Z_INDEX` than connections
- Connections render above group backgrounds
- When two components of different types overlap, the one with the higher `zIndex` always wins, regardless of render order

This value is set per component class in `graphConfig.ts` and changed via `updateZIndex()` (e.g., when a block is selected or dragged, its zIndex is incremented to rise above its unselected peers).

### Tier 2: renderOrder — Visual Stacking Within a Type

`renderOrder` determines stacking *within the same zIndex tier*. It is assigned by the scheduler during `_walkDown` traversal: the later a component is visited in the tree, the higher its `renderOrder`.

`Tree.updateChildZIndex()` is the key mechanism: every time a component's `zIndex` changes, the child is **re-inserted at the end** of the parent's `children` Set (`delete` + `add`). Because the `children` Set preserves insertion order, the most recently interacted component gets the highest `renderOrder` in its tier — it appears on top visually.

This means drag-and-drop and selection automatically bring the interacted block to the front of its z-group without any extra bookkeeping.

### Hit Testing

`HitTest.testHitBox()` sorts candidates by both criteria (descending — highest = topmost):
1. First by `zIndex` (type priority)
2. Then by `renderOrder` (within-type visual stacking)

The first element of the returned array is the component the user most likely intended to interact with.

### Summary

| Criterion | Controls | Set by |
|---|---|---|
| `zIndex` | Which component *type* wins | `updateZIndex()`, class constant |
| `renderOrder` | Which instance wins *within its type* | Scheduler traversal order + `updateChildZIndex()` |

## Integration with Animation

Expand Down
25 changes: 25 additions & 0 deletions e2e/page-objects/GraphBlockComponentObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,29 @@ export class GraphBlockComponentObject {
getId(): string {
return this.blockId;
}

/**
* Get the block's viewState (zIndex and renderOrder) from the canvas component.
* zIndex: 1 = normal, 2 = elevated (selected or being dragged)
* order: position in the sorted render list
*/
async getViewState(): Promise<{ zIndex: number; order: number }> {
return await this.page.evaluate((id) => {
const blockState = window.graph.blocks.getBlockState(id);
if (!blockState) throw new Error(`Block ${id} not found`);
const viewComponent = blockState.$viewComponent.value;
if (!viewComponent) throw new Error(`Block ${id} has no canvas view component`);
return viewComponent.$viewState.value;
}, this.blockId);
}

/**
* Get combined z-index value (zIndex + renderOrder).
* Higher value means the block renders on top of blocks with lower values.
* This mirrors the CSS z-index calculation used in the React/HTML layer.
*/
async getCombinedZIndex(): Promise<number> {
const { zIndex, order } = await this.getViewState();
return zIndex + order;
}
}
69 changes: 19 additions & 50 deletions e2e/page-objects/GraphPageObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,7 @@ export class GraphPageObject {
}, config);

// Wait for graph to be ready
await this.page.waitForFunction(
() => window.graphInitialized === true,
{ timeout: 5000 }
);
await this.page.waitForFunction(() => window.graphInitialized === true, { timeout: 5000 });

// Wait for initial render frames
await this.waitForFrames(3);
Expand All @@ -97,14 +94,11 @@ export class GraphPageObject {
await this.page.evaluate((frameCount) => {
return new Promise<void>((resolve) => {
const { schedule, ESchedulerPriority } = window.GraphModule;
schedule(
() => resolve(),
{
priority: ESchedulerPriority.LOWEST,
frameInterval: frameCount,
once: true,
}
);
schedule(() => resolve(), {
priority: ESchedulerPriority.LOWEST,
frameInterval: frameCount,
once: true,
});
});
}, count);
}
Expand All @@ -118,22 +112,19 @@ export class GraphPageObject {
return new Promise<void>((resolve, reject) => {
const startTime = Date.now();
const { schedule, ESchedulerPriority } = window.GraphModule;

const check = () => {
if (Date.now() - startTime > timeoutMs) {
reject(new Error(`Scheduler did not become idle within ${timeoutMs}ms`));
return;
}

// Use graph's scheduler to wait for a frame
schedule(
() => resolve(),
{
priority: ESchedulerPriority.LOWEST,
frameInterval: 2,
once: true,
}
);
schedule(() => resolve(), {
priority: ESchedulerPriority.LOWEST,
frameInterval: 2,
once: true,
});
};

check();
Expand All @@ -149,11 +140,7 @@ export class GraphPageObject {
({ eventName, timeout }) => {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(
new Error(
`Event ${eventName} did not fire within ${timeout}ms`
)
);
reject(new Error(`Event ${eventName} did not fire within ${timeout}ms`));
}, timeout);

const handler = (event: any) => {
Expand Down Expand Up @@ -205,9 +192,7 @@ export class GraphPageObject {
if (options?.shift) {
modifierKey = "Shift";
} else if (options?.ctrl || options?.meta) {
const isMac = await this.page.evaluate(() =>
navigator.platform.toLowerCase().includes("mac")
);
const isMac = await this.page.evaluate(() => navigator.platform.toLowerCase().includes("mac"));
modifierKey = isMac ? "Meta" : "Control";
}

Expand All @@ -232,11 +217,7 @@ export class GraphPageObject {
/**
* Double click at world coordinates
*/
async doubleClick(
worldX: number,
worldY: number,
options?: { waitFrames?: number }
): Promise<void> {
async doubleClick(worldX: number, worldY: number, options?: { waitFrames?: number }): Promise<void> {
const { screenX, screenY, canvasBounds } = await this.page.evaluate(
({ wx, wy }) => {
const [sx, sy] = window.graph.cameraService.getAbsoluteXY(wx, wy);
Expand Down Expand Up @@ -264,11 +245,7 @@ export class GraphPageObject {
/**
* Hover at world coordinates
*/
async hover(
worldX: number,
worldY: number,
options?: { waitFrames?: number }
): Promise<void> {
async hover(worldX: number, worldY: number, options?: { waitFrames?: number }): Promise<void> {
const { screenX, screenY, canvasBounds } = await this.page.evaluate(
({ wx, wy }) => {
const [sx, sy] = window.graph.cameraService.getAbsoluteXY(wx, wy);
Expand Down Expand Up @@ -305,10 +282,7 @@ export class GraphPageObject {
): Promise<void> {
const { fromScreen, toScreen, canvasBounds } = await this.page.evaluate(
({ fx, fy, tx, ty }) => {
const [fromSX, fromSY] = window.graph.cameraService.getAbsoluteXY(
fx,
fy
);
const [fromSX, fromSY] = window.graph.cameraService.getAbsoluteXY(fx, fy);
const [toSX, toSY] = window.graph.cameraService.getAbsoluteXY(tx, ty);

const canvas = window.graph.getGraphCanvas();
Expand Down Expand Up @@ -367,17 +341,12 @@ export class GraphPageObject {
/**
* Check if connection exists between two blocks
*/
async hasConnectionBetween(
sourceBlockId: string,
targetBlockId: string
): Promise<boolean> {
async hasConnectionBetween(sourceBlockId: string, targetBlockId: string): Promise<boolean> {
return await this.page.evaluate(
({ sourceBlockId, targetBlockId }) => {
const connections = window.graph.connections.toJSON();
return connections.some(
(conn: any) =>
conn.sourceBlockId === sourceBlockId &&
conn.targetBlockId === targetBlockId
(conn: any) => conn.sourceBlockId === sourceBlockId && conn.targetBlockId === targetBlockId
);
},
{ sourceBlockId, targetBlockId }
Expand Down
125 changes: 125 additions & 0 deletions e2e/tests/block/block-zindex.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { test, expect } from "@playwright/test";
import { GraphPageObject } from "../../page-objects/GraphPageObject";

/**
* Concrete overlap scenario for z-index and hit-test ordering.
*
* World layout:
*
* y=50 ┌──────B──────┐
* y=100 ┌──A──┐ │ ┌──C──┐
* │ │ (B∩A) │ │ │
* y=150 │ ├───────┼──────────┤ │ ← A→C connection at y=150
* │ │ │ │ │
* y=200 └─────┘ └──────────┘─────┘
* x=50 x=150 x=300 x=400 x=550
*
* B overlaps:
* - A's right side at x=150..200
* - Connection A→C from x=200..300 at y=150
*
* Overlap test point: (250, 150) — inside B AND on the A→C connection line
*
* Tests validate:
* 1. Click A → A renders above B
* 2. Click B → B renders above A
* 3. Hover/click at (250,150): B occludes the connection — B is hit, not A→C
* 4. Drag B over C → B renders above C
*/

const BLOCK_A = {
id: "block-a",
is: "Block" as const,
x: 50,
y: 100,
width: 150,
height: 100,
name: "A",
anchors: [],
selected: false,
};
const BLOCK_B = {
id: "block-b",
is: "Block" as const,
x: 150,
y: 50,
width: 150,
height: 150,
name: "B",
anchors: [],
selected: false,
};
const BLOCK_C = {
id: "block-c",
is: "Block" as const,
x: 400,
y: 100,
width: 150,
height: 100,
name: "C",
anchors: [],
selected: false,
};

const CONN_AC = { id: "conn-ac", sourceBlockId: "block-a", targetBlockId: "block-c" };

test.describe("Z-Index and hit-test: overlap scenario", () => {
let graphPO: GraphPageObject;

test.beforeEach(async ({ page }) => {
graphPO = new GraphPageObject(page);
await graphPO.initialize({
blocks: [BLOCK_A, BLOCK_B, BLOCK_C],
connections: [CONN_AC],
settings: { canDrag: "all", dragThreshold: 0 },
});
});

test("clicking A brings it above B", async () => {
const blockA = graphPO.getBlockCOM("block-a");
const blockB = graphPO.getBlockCOM("block-b");

await blockA.click();
await graphPO.waitForFrames(3);

expect(await blockA.getCombinedZIndex()).toBeGreaterThan(await blockB.getCombinedZIndex());
});

test("clicking B brings it above A", async () => {
const blockA = graphPO.getBlockCOM("block-a");
const blockB = graphPO.getBlockCOM("block-b");

// First bring A on top
await blockA.click();
await graphPO.waitForFrames(3);
expect(await blockA.getCombinedZIndex()).toBeGreaterThan(await blockB.getCombinedZIndex());

// Now click B — B should take the top spot
await blockB.click();
await graphPO.waitForFrames(3);

expect(await blockB.getCombinedZIndex()).toBeGreaterThan(await blockA.getCombinedZIndex());
});

test("B occludes connection A→C: clicking at overlap point (250, 150) selects B, not the connection", async () => {
const blockB = graphPO.getBlockCOM("block-b");

// (250, 150) is inside B and lies on the A→C connection line.
// Since blocks have a higher zIndex than connections, B should win the hit test.
await graphPO.click(250, 150);
await graphPO.waitForFrames(3);

expect(await blockB.isSelected()).toBe(true);
});

test("dragging B over C: B renders above C", async () => {
const blockB = graphPO.getBlockCOM("block-b");
const blockC = graphPO.getBlockCOM("block-c");

// Drag B so it overlaps C; drag triggers updateChildZIndex which brings B to front
const cCenter = await blockC.getWorldCenter();
await blockB.dragTo(cCenter);

expect(await blockB.getCombinedZIndex()).toBeGreaterThan(await blockC.getCombinedZIndex());
});
});
4 changes: 4 additions & 0 deletions src/components/canvas/blocks/Block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ export class Block<T extends TBlock = TBlock, Props extends TBlockProps = TBlock
...this.connectedState.$state.value,
anchors: this.connectedState.$anchors.value,
});
// Initialize zIndex based on current selection state so that blocks
// initialized as selected are properly elevated in rendering order.
this.zIndex = this.calcZIndex();
this.updateViewState({
zIndex: this.zIndex,
order: this.renderOrder,
Expand Down Expand Up @@ -370,6 +373,7 @@ export class Block<T extends TBlock = TBlock, Props extends TBlockProps = TBlock

this.lastDragEvent = undefined;
this.startDragCoords = [];
this.raiseBlock();
this.updateHitBox(this.state);
}

Expand Down
Loading
Loading