Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
79 changes: 79 additions & 0 deletions apps/e2e/tests/connection/viewport-culling.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { expect, test } from "@playwright/test";
import type { TConnection, TMultipointConnection } from "@gravity-ui/graph";

import { GraphPageObject } from "../../page-objects/GraphPageObject";

for (const kind of ["bezier", "multipoint"] as const) {
test(`${kind} keeps real Path2D hit testing before its first visible batch draw`, async ({ page }) => {
const graph = new GraphPageObject(page);
await page.goto("/base.html");
await page.waitForFunction(() => window.graphLibraryLoaded === true);
await page.evaluate((connectionKind) => {
const { Graph, BlockConnection, MultipointConnection } = window.GraphModule;
class ObservedConnection extends BlockConnection<TConnection> {
public draws = 0;
public override afterRender(ctx: CanvasRenderingContext2D) {
this.draws++;
super.afterRender?.(ctx);
}
}
class ObservedMultipoint extends MultipointConnection {
public draws = 0;
public override afterRender(ctx: CanvasRenderingContext2D) {
this.draws++;
super.afterRender?.(ctx);
}
}
const root = document.getElementById("root");
if (!(root instanceof HTMLDivElement)) throw new Error("Root not found");
const target = new Graph(
{
connections: [{ id: "far", sourcePortId: "source", targetPortId: "target" }],
settings: {
connection: connectionKind === "multipoint" ? ObservedMultipoint : ObservedConnection,
useBezierConnections: true,
},
},
root
);
const state = target.connections.getConnectionState("far");
if (!state) throw new Error("Connection not found");
const source = { x: 50_000, y: 50_000 };
const destination = { x: 50_400, y: 50_000 };
if (connectionKind === "multipoint") {
const update: TMultipointConnection = { id: "far", points: [source, destination] };
state.updateConnection(update);
}
state.$sourcePortState.value.updatePort({ ...source, lookup: false });
state.$targetPortState.value.updatePort({ ...destination, lookup: false });
target.cameraService.set({ x: 0, y: 0, scale: 1 });
target.start();
window.graph = target;
}, kind);
await graph.waitForFrames(3);

const inspect = () =>
graph.evaluate((target) => {
const view = target.connections.getConnectionState("far")?.getViewComponent();
return {
draws: view && "draws" in view ? view.draws : null,
hit: target.getElementOverPoint({ x: 50_200, y: 50_000 })?.getEntityId(),
miss: target.getElementOverPoint({ x: 50_200, y: 50_100 })?.getEntityId() ?? null,
rectangle: target
.getElementsOverRect({ x: 50_190, y: 49_990, width: 20, height: 20 })
.map((element) => element.getEntityId()),
};
});

expect(await inspect()).toEqual({ draws: 0, hit: "far", miss: null, rectangle: ["far"] });
await graph.evaluate((target) => target.cameraService.set({ x: -49_800, y: -49_800 }));
await graph.waitForFrames(3);
const visible = await inspect();
expect(visible.draws).toBeGreaterThan(0);
expect(visible.hit).toBe("far");

await graph.evaluate((target) => target.cameraService.set({ x: 0, y: 0 }));
await graph.waitForFrames(3);
expect(await inspect()).toEqual(visible);
});
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import React, { useEffect, useState } from "react";

import {
BlockConnection,
Graph,
TBlock,
TConnection,
cachedMeasureText,
getFontSize,
getLabelCoords,
} from "@gravity-ui/graph";
import { BlockConnection, Graph, TBlock, TConnection, TConnectionProps, getFontSize } from "@gravity-ui/graph";
import { GraphCanvas, useGraph } from "@gravity-ui/graph-react";
import { Flex, Switch, ThemeProvider } from "@gravity-ui/uikit";
import type { Meta, StoryFn } from "@storybook/react-webpack5";
Expand All @@ -24,39 +16,16 @@ storyConfig.connections?.forEach((connection) => {
connection.label = "Custom Label";
});

function getRenderLabelText(this: BlockConnection<TConnection>, showLabelOnlyOnSelected: boolean) {
return (ctx: CanvasRenderingContext2D) => {
if (!this.isVisible() || !this.state.label) {
export class CustomConnection extends BlockConnection<TConnection> {
protected override renderLabelText(ctx: CanvasRenderingContext2D) {
if (!this.isVisible() || !this.state.label || !this.labelGeometry) {
return;
}

const [labelInnerTopPadding, labelInnerRightPadding, labelInnerBottomPadding, labelInnerLeftPadding] =
this.context.constants.connection.LABEL.INNER_PADDINGS;
const [labelInnerTopPadding, , , labelInnerLeftPadding] = this.context.constants.connection.LABEL.INNER_PADDINGS;
const fontSize = Math.max(14, getFontSize(9, this.context.camera.getCameraScale()));
const font = `${fontSize}px sans-serif`;

const measure = cachedMeasureText(this.state.label, {
font,
});

if (!measure) {
return;
}

const { x, y } = getLabelCoords(
this.geometry.x1,
this.geometry.y1,
this.geometry.x2,
this.geometry.y2,
measure.width + labelInnerLeftPadding + labelInnerRightPadding,
measure.height + labelInnerTopPadding + labelInnerBottomPadding,
this.context.constants.system.GRID_SIZE
);

if (showLabelOnlyOnSelected && !this.state.selected) {
this.labelGeometry = undefined;
return;
}
const { x: rectX, y: rectY, width: rectWidth, height: rectHeight } = this.labelGeometry;

if (this.context.colors.connectionLabel?.background) {
ctx.fillStyle = this.context.colors.connectionLabel.background;
Expand All @@ -70,18 +39,6 @@ function getRenderLabelText(this: BlockConnection<TConnection>, showLabelOnlyOnS
ctx.fillStyle = this.context.colors.connectionLabel.selectedBackground;
}

const rectX = x;
const rectY = y;
const rectWidth = measure.width + labelInnerLeftPadding + labelInnerRightPadding;
const rectHeight = measure.height + labelInnerTopPadding + labelInnerBottomPadding;

this.labelGeometry = {
x: rectX,
y: rectY,
width: rectWidth,
height: rectHeight,
};

ctx.fillRect(rectX, rectY, rectWidth, rectHeight);

ctx.strokeStyle = "red";
Expand All @@ -105,18 +62,13 @@ function getRenderLabelText(this: BlockConnection<TConnection>, showLabelOnlyOnS
ctx.font = font;
ctx.textAlign = "left";
ctx.fillText(this.state.label, rectX + labelInnerLeftPadding, rectY + labelInnerTopPadding);
};
}

export class CustomConnection extends BlockConnection<TConnection> {
protected renderLabelText(ctx: CanvasRenderingContext2D) {
getRenderLabelText.call(this, false)(ctx);
}
}

export class CustomConnectionOnSelected extends BlockConnection<TConnection> {
protected renderLabelText(ctx: CanvasRenderingContext2D) {
getRenderLabelText.call(this, true)(ctx);
export class CustomConnectionOnSelected extends CustomConnection {
protected override updateLabelGeometry(props: TConnectionProps): void {
super.updateLabelGeometry(props);
if (!this.state.selected) this.labelGeometry = undefined;
}
}

Expand Down
5 changes: 5 additions & 0 deletions apps/storybook/src/stories/main/GraphEditor.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ const meta: Meta<typeof GraphApp> = {
control: "boolean",
description: "Render connection's label",
},
EXP_VISIBILITY_CULLING: {
control: "boolean",
description: "Walk only blocks, connections and groups near the viewport (tile index)",
},
},
args: {
useBezierConnections: true,
Expand All @@ -79,6 +83,7 @@ const meta: Meta<typeof GraphApp> = {
canDrag: ECanDrag.ALL,
scaleFontSize: 1,
showConnectionArrows: true,
EXP_VISIBILITY_CULLING: true,
},
};

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"test:e2e:debug": "pnpm --filter @gravity-ui/graph-e2e run test:e2e:debug",
"test:e2e:ci": "pnpm --filter @gravity-ui/graph-e2e run test:e2e:ci",
"test:package-contract": "node scripts/package-contract/run.mjs",
"bench:culling": "node scripts/bench/culling/run.mjs",
"e2e:dev": "pnpm --filter @gravity-ui/graph-e2e run e2e:dev",
"test:type-configs": "node --test scripts/css-assets.test.mjs scripts/style-observer-types.test.mjs scripts/tsconfig-strict.test.mjs"
},
Expand Down
64 changes: 44 additions & 20 deletions packages/graph/docs/components/canvas-graph-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,25 +80,51 @@ public setHitBox(minX: number, minY: number, maxX: number, maxY: number, force?:
}
```

### 2. Camera-based Culling with HitBox Visibility Checks
### 2. Tree-based Viewport Culling

GraphComponent only renders when its hitbox is within the camera's view area:
Each layer owns one tile index for its entire component tree. `setHitBox()` updates both hit testing
and the component's own bounds in that index. Custom components and containers participate
automatically; they do not create or query tiles themselves.

```typescript
protected willIterate(): void {
super.willIterate();
if (!this.firstIterate) {
// This optimization prevents drawing components that aren't visible
this.shouldRender = this.isVisible();
}
// The tree decides spatial visibility and passes it to iterate(); a component only vetoes painting.
protected isRenderAllowed(): boolean {
return !this.hidden;
}
```

**What's happening here:**
- Before each render cycle, the component checks if it's within the camera's visible area
- If not in view, the component skips rendering - saving precious CPU/GPU time
- This is crucial for performance when handling graphs with hundreds or thousands of elements
- The first iteration always renders (to establish initial state) but subsequent renders are optimized
- The tree prepares new components and components that requested an update before publishing visibility; clean visible components update inside their own `iterate()`, right before painting.
- `iterate()` runs only for visible components and for offscreen components whose update was prepared; the latter complete without paint hooks.
- Offscreen components still mount and process changes, including ports, children, and geometry.
- `willRender`, `render`, and `didRender` run only for eligible components, including on the first frame.
- Clean offscreen components are skipped entirely. A visible child is still reached through its offscreen parent.
- A camera move rechecks only components near the viewport edges; visibility is kept between frames.
- Unknown bounds stay eligible. Set bounds during initialization or data updates, before rendering.
- Connection batches skip whole chunks outside the viewport, and world-coordinate hit queries remain independent of the viewport.

Override `isRenderAllowed()` for additional semantic conditions, such as hidden elements or missing
connection endpoints; `isVisible()` combines it with `isInViewport()` for queries. Use `firstIterate`
for initialization; `firstRender` stays true until the component is actually painted.
`EXP_VISIBILITY_CULLING = false` enables a full traversal while retaining the same viewport paint gate.

`isVisible()` reads the current viewport and bounds between frames and in update hooks that run before
painting. A custom `willIterate()` can therefore refresh deferred external state on the first frame that
returns to the viewport. During painting it returns the frame's decision. Change
geometry through `setState()`, `setProps()` or `performRender()`: a change a clean component makes inside
its own update takes effect for culling from the next frame.

For a custom paint condition, assign `shouldRender` after `super.willIterate()`. This flag controls
permission to paint; use `isVisible()` to query spatial visibility. The final paint gate also checks
geometry that requested updates produced before painting. A `shouldRender = false` set during `willMount()`
is preserved for the first frame.

```typescript
protected override willIterate(): void {
super.willIterate();
this.shouldRender = this.hasContentToDraw();
}
```

**Visualization of the process:**
```
Expand Down Expand Up @@ -356,7 +382,7 @@ sequenceDiagram
participant Graph as Graph System
participant Comp as GraphComponent
participant HitTest as Spatial Index
participant Camera as Camera System
participant Tree as Layer Tree

App->>Graph: Create new component
Graph->>Comp: Initialize component
Expand All @@ -365,9 +391,10 @@ sequenceDiagram
Note over Comp,HitTest: Component now exists in spatial system

loop Each Frame
Graph->>Comp: iterate()
Comp->>Camera: isVisible check
Camera-->>Comp: Visibility result
Graph->>Tree: Traverse layer
Tree->>Comp: prepareUpdate() when new or requested
Tree->>Tree: Update visibility from viewport delta and changed bounds
Tree->>Comp: iterate() to paint or complete pending work

alt Component is visible
Comp->>Comp: render()
Expand Down Expand Up @@ -912,9 +939,6 @@ When working with graph visualizations, especially those with hundreds or thousa

```typescript
protected updateChildren() {
// Only create children if parent is visible
if (!this.isVisible()) return [];

// Use stable keys so React/GraphComponents don't recreate unnecessarily
return [
ChildComponent.create({...}, { key: 'stable-id-1' }),
Expand Down Expand Up @@ -1138,4 +1162,4 @@ class EditorNode extends GraphComponent {
}
```

By leveraging GraphComponent's core capabilities, you can build sophisticated, high-performance graph visualizations tailored to your specific use case.
By leveraging GraphComponent's core capabilities, you can build sophisticated, high-performance graph visualizations tailored to your specific use case.
17 changes: 17 additions & 0 deletions packages/graph/docs/connections/batch-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ The `group` parameter is key to batching efficiency. It's generated by `getClass

Connections with identical group names are batched together - they share the same canvas context state (color, line width, dash pattern) and are drawn in a single operation.

Each group keeps up to 100 items per chunk in registration order and draws a chunk with one
`fill`/`stroke`. The combined path is rebuilt only when an item is added, removed or marked dirty;
camera moves never rebuild it. A chunk whose items' bounds are all outside the viewport is skipped
entirely. `afterRender` hooks run for every visible item of a drawn chunk, in registration order;
connections skip their labels themselves when the layer tree reports them offscreen.

## Batch Management Methods

The batch system provides three main methods for managing connections:
Expand Down Expand Up @@ -91,6 +97,17 @@ The batch rendering system provides significant performance improvements:

When extending BlockConnection, the batch system works automatically. However, if you need to customize batching behavior:

`getPath()` is called when a chunk is rebuilt and should return cached geometry: `BlockConnection`
returns the path built by its last geometry change and builds its arrow once per change. Normal
connection state, props and endpoint updates mark the item dirty through `applyShape()`. If a custom
batch item changes geometry outside those updates, call `batch.markDirty(item)` or
`batch.update(item, params)`; when only its bounds change, `batch.markBoundsDirty(item)`.

Items should provide `getPathBounds(): readonly [left, top, right, bottom] | undefined` in world
coordinates, covering everything they paint. A chunk with an item without valid bounds is never
culled. `isPathVisible()` is semantic only (hidden items, missing geometry); viewport culling is the
renderer's job.

### Custom Group Names

Override `getClassName()` to create custom batching groups:
Expand Down
Loading
Loading