Skip to content
Draft
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
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
59 changes: 39 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,46 @@ 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();
}
protected isVisible(): boolean {
return !this.hidden && this.isInViewport();
}
```

**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 visible, new, or dirty components before selecting elements to paint.
- 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.
- Unknown bounds stay eligible. Set bounds during initialization or data updates, before rendering.
- Connection batches use the same viewport decision, and world-coordinate hit queries remain independent of the viewport.

Keep `isVisible()` overrides for additional semantic conditions, such as hidden elements or missing
connection endpoints. 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 during update hooks and between frames. A custom
`willIterate()` can therefore refresh deferred external state on the first frame that returns to the
viewport. During painting, components and batches use the finalized Tree selection.

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 updated later in the preparation phase. 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 +377,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 +386,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 visible, new, or dirty
Tree->>Tree: Resolve viewport membership
Tree->>Comp: iterate() to paint or complete pending work

alt Component is visible
Comp->>Comp: render()
Expand Down Expand Up @@ -912,9 +934,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 +1157,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 retains its ordered draw chunks. Within a chunk, geometry and viewport visibility
are cached separately: leaving or entering the viewport changes the combined path without
regenerating unchanged connection and arrow geometry. Large chunks lazily cache spatial
subpaths after a visibility change, while retaining the same `fill`/`stroke` operations and
ordered `afterRender` hooks. Tree remains the source of viewport visibility.

## 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()` results are copied and cached, including `null` and `undefined`. Normal connection
state, props and endpoint updates already invalidate this cache through `applyShape()`. If a
custom batch item changes geometry outside those updates, call `batch.markDirty(item)` or
`batch.update(item, params)`. `batch.invalidateVisibility(item)` only refreshes visibility;
it does not regenerate geometry or schedule another frame.

Custom items may provide `getPathBounds(): readonly [left, top, right, bottom] | undefined`
in world coordinates to improve spatial grouping. `BlockConnection` and its arrow already
provide this hint. Missing or invalid bounds use a fallback group and never hide a path;
the hint does not replace `isPathVisible()` or Tree culling bounds.

### Custom Group Names

Override `getClassName()` to create custom batching groups:
Expand Down
54 changes: 54 additions & 0 deletions packages/graph/docs/connections/canvas-connection-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,60 @@ class CustomPathConnection extends BlockConnection {
}
```

### Custom Labels and Badges

Prepare custom label bounds in `updateLabelGeometry()`, then draw in `renderLabelText()`. Call `super.updateLabelGeometry(props)` first to obtain the standard label's current position and size, including zoom and label visibility. This example adds a square status badge before the text:

```typescript
import { BlockConnection, type TConnection, type TConnectionProps, type TRect } from "@gravity-ui/graph";

function getBadgeBounds(label: TRect): TRect {
return { x: label.x - label.height - 4, y: label.y, width: label.height, height: label.height };
}

class BadgeConnection extends BlockConnection<TConnection> {
private badgeGeometry?: TRect;

protected override updateLabelGeometry(props: TConnectionProps): void {
super.updateLabelGeometry(props);
const label = this.labelGeometry;
this.badgeGeometry = label ? getBadgeBounds(label) : undefined;
if (!label || !this.badgeGeometry) return;

this.labelGeometry = {
...label,
x: this.badgeGeometry.x,
width: label.x + label.width - this.badgeGeometry.x,
};
}

protected override renderLabelText(ctx: CanvasRenderingContext2D): void {
if (this.badgeGeometry) {
const { x, y, width, height } = this.badgeGeometry;
ctx.save();
ctx.fillStyle = "#4285f4";
ctx.fillRect(x, y, width, height);
ctx.restore();
}
super.renderLabelText(ctx);
}
}

graph.updateSettings({ connection: BadgeConnection, showConnectionLabels: true });
// Both the badge and text follow this setting and the standard zoom threshold.
graph.updateSettings({ showConnectionLabels: false });
graph.updateSettings({ showConnectionLabels: true });
```

The protected `labelGeometry` describes the combined bounds for culling and hit testing. The base class keeps its own cached paint rectangle, so `super.renderLabelText()` draws only the standard label and does not paint over the badge. Preparing the combined bounds before painting makes them available in the same frame, including when only the badge intersects the viewport.

Existing overrides that calculate decoration bounds only inside `renderLabelText()` can still draw their decoration, but cannot make an initially offscreen connection visible through that decoration alone: viewport selection has already happened. Move that calculation to `updateLabelGeometry()` as above; the hook also clears stale decoration geometry when labels are disabled or hidden by zoom.

Apply custom visibility conditions in that hook as well. For a label shown only while the connection
is selected, call `super.updateLabelGeometry(props)` and clear `this.labelGeometry` when
`this.state.selected` is false. This keeps viewport selection and hit testing consistent with painting
as selection changes, including when the line itself is outside the viewport.

### Custom Connection with Special Rendering

Example of a connection with custom visual elements:
Expand Down
Loading
Loading