From 94a0628221bbf90bb7baf1f17f535aaca284e15b Mon Sep 17 00:00:00 2001 From: draedful Date: Tue, 20 Oct 2026 13:14:52 +0300 Subject: [PATCH 01/13] feat(graph): walk only components near the viewport via a tile index Blocks, connections and groups containers keep their children in a world-space TileIndex. Each frame a container walks the children whose bounds intersect the camera viewport (plus a margin), children that requested a render, newly appended children and children that just left the view, in the same zIndex/renderOrder order as a full walk. It falls back to a full walk when most children are near the viewport. - performRender() marks the branch pending in culling ancestors, so pending props/state, first iterate and anchor updates of off-screen components are still processed in the next frame. - renderOrder is assigned to every child when the z-order is rebuilt, and isIterated() is true for children skipped by culling, so point hit tests keep today's results. - EXP_VISIBILITY_CULLING setting (default true), VISIBILITY_TILE_SIZE and VISIBILITY_CULLING_MARGIN constants. - Fix Tree.updateZIndex leaving the node in its previous zIndex group (a raised block was walked and drawn twice) and removeZIndex keeping removed nodes in the cached order. Co-Authored-By: Claude Opus 5.5 --- packages/graph/docs/system/graph-settings.md | 3 + .../components/canvas/GraphComponent/index.ts | 2 + .../src/components/canvas/blocks/Blocks.ts | 2 + .../canvas/connections/BlockConnections.ts | 2 + .../components/canvas/groups/BlockGroups.ts | 2 + .../canvas/visibilityCulling.test.ts | 116 ++++++ .../components/canvas/visibilityCulling.ts | 31 ++ packages/graph/src/graphConfig.ts | 9 + packages/graph/src/lib/CoreComponent.ts | 21 +- packages/graph/src/lib/TileIndex.test.ts | 172 +++++++++ packages/graph/src/lib/TileIndex.ts | 352 ++++++++++++++++++ packages/graph/src/lib/Tree.spec.ts | 27 ++ packages/graph/src/lib/Tree.ts | 92 ++++- packages/graph/src/lib/TreeCulling.test.ts | 240 ++++++++++++ packages/graph/src/lib/TreeCulling.ts | 137 +++++++ packages/graph/src/store/settings.ts | 6 + 16 files changed, 1210 insertions(+), 4 deletions(-) create mode 100644 packages/graph/src/components/canvas/visibilityCulling.test.ts create mode 100644 packages/graph/src/components/canvas/visibilityCulling.ts create mode 100644 packages/graph/src/lib/TileIndex.test.ts create mode 100644 packages/graph/src/lib/TileIndex.ts create mode 100644 packages/graph/src/lib/TreeCulling.test.ts create mode 100644 packages/graph/src/lib/TreeCulling.ts diff --git a/packages/graph/docs/system/graph-settings.md b/packages/graph/docs/system/graph-settings.md index 1ca42e5a..819dd624 100644 --- a/packages/graph/docs/system/graph-settings.md +++ b/packages/graph/docs/system/graph-settings.md @@ -72,6 +72,7 @@ These settings control the interactive behaviors and visual styles of the graph. | `useBezierConnections` | boolean | `true` | Use bezier curves for connections instead of straight lines | | `bezierConnectionDirection` | 'vertical' \| 'horizontal' | `'horizontal'` | Direction of bezier connections | | `EXP_USE_TIGHT_BEZIER_BOUNDS` | boolean | `true` | Experimental: use tight bounds of the rendered Bezier curve; set to `false` to use the legacy control-point bounds | +| `EXP_VISIBILITY_CULLING` | boolean | `true` | Experimental: each frame walks only the blocks, connections and groups near the viewport, found through a world-space tile index; set to `false` to walk every component | | `useBlocksAnchors` | boolean | `true` | Controls whether blocks should display anchors for connections | | `showConnectionLabels` | boolean | `false` | Controls visibility of connection labels | | `blockComponents` | Record | `{}` | Registry of custom block components. The built-in renderer constructs them with `id`, `initialIndex` and `font` only: put custom data into the block's `meta`, or give extra props defaults in the constructor. | @@ -143,6 +144,8 @@ Constants control the sizing, spacing, and other numerical values used throughou | `GRID_SIZE` | `16` | Base grid size for layout calculations | | `USABLE_RECT_GAP` | `400` | Gap around the usable rect area | | `CAMERA_VIEWPORT_TRESHOLD` | `0.5` | Threshold for camera viewport | +| `VISIBILITY_TILE_SIZE` | `1024` | Edge of a visibility tile in world units (see `EXP_VISIBILITY_CULLING`); read when a container mounts | +| `VISIBILITY_CULLING_MARGIN` | `0.1` | Extra walked area around the viewport for visibility culling, as a fraction of the viewport size per side | ### Camera Constants diff --git a/packages/graph/src/components/canvas/GraphComponent/index.ts b/packages/graph/src/components/canvas/GraphComponent/index.ts index 4dbfdb1f..5d6c7edc 100644 --- a/packages/graph/src/components/canvas/GraphComponent/index.ts +++ b/packages/graph/src/components/canvas/GraphComponent/index.ts @@ -359,6 +359,8 @@ export class GraphComponent< public setHitBox(minX: number, minY: number, maxX: number, maxY: number, force?: boolean) { this.hitBox.update(minX, minY, maxX, maxY, force); + // isVisible() checks the hit box, so the parent's culling uses the same rect. + if (!this.hitBox.destroyed) this.setCullingBounds(minX, minY, maxX, maxY); } protected willIterate(): void { diff --git a/packages/graph/src/components/canvas/blocks/Blocks.ts b/packages/graph/src/components/canvas/blocks/Blocks.ts index 9b2d6ed8..234d0e0a 100644 --- a/packages/graph/src/components/canvas/blocks/Blocks.ts +++ b/packages/graph/src/components/canvas/blocks/Blocks.ts @@ -3,6 +3,7 @@ import { CoreComponent } from "../../../lib/CoreComponent"; import { BlockState } from "../../../store/block/Block"; import { TGraphSettingsConfig } from "../../../store/settings"; import { TGraphLayerContext } from "../layers/graphLayer/GraphLayer"; +import { createViewportCulling } from "../visibilityCulling"; import { Block } from "./Block"; @@ -19,6 +20,7 @@ export class Blocks extends Component { constructor(props: {}, context: CoreComponent) { super(props, context); + this.setChildrenCulling(createViewportCulling(this.context.graph, () => this.context.camera)); this.unsubscribe = this.subscribe(); this.font = this.prepareFont(this.getFontScale()); diff --git a/packages/graph/src/components/canvas/connections/BlockConnections.ts b/packages/graph/src/components/canvas/connections/BlockConnections.ts index d9db98fc..8749c0d4 100644 --- a/packages/graph/src/components/canvas/connections/BlockConnections.ts +++ b/packages/graph/src/components/canvas/connections/BlockConnections.ts @@ -4,6 +4,7 @@ import { ESchedulerPriority } from "../../../lib/Scheduler"; import { ConnectionState } from "../../../store/connection/ConnectionState"; import { debounce } from "../../../utils/utils/schedule"; import { TGraphLayerContext } from "../layers/graphLayer/GraphLayer"; +import { createViewportCulling } from "../visibilityCulling"; import { BatchPath2DRenderer } from "./BatchPath2D"; import { BlockConnection, TConnectionProps } from "./BlockConnection"; @@ -38,6 +39,7 @@ export class BlockConnections extends Component this.context.camera)); this.batch = new BatchPath2DRenderer( () => this.performRender(), this.context.constants.connection.PATH2D_CHUNK_SIZE || 100 diff --git a/packages/graph/src/components/canvas/groups/BlockGroups.ts b/packages/graph/src/components/canvas/groups/BlockGroups.ts index adf81b2e..aee858ff 100644 --- a/packages/graph/src/components/canvas/groups/BlockGroups.ts +++ b/packages/graph/src/components/canvas/groups/BlockGroups.ts @@ -9,6 +9,7 @@ import { BlockState } from "../../../store/block/Block"; import { GroupState, TGroup, TGroupId } from "../../../store/group/Group"; import { getBlocksRect } from "../../../utils/functions"; import { TRect } from "../../../utils/types/shapes"; +import { createViewportCulling } from "../visibilityCulling"; import { Group } from "./Group"; @@ -174,6 +175,7 @@ export class BlockGroups

extends }); this.requireCanvas(); + this.setChildrenCulling(createViewportCulling(this.props.graph, () => this.context.camera)); } protected afterInit(): void { diff --git a/packages/graph/src/components/canvas/visibilityCulling.test.ts b/packages/graph/src/components/canvas/visibilityCulling.test.ts new file mode 100644 index 00000000..b2bf67d5 --- /dev/null +++ b/packages/graph/src/components/canvas/visibilityCulling.test.ts @@ -0,0 +1,116 @@ +import { Graph } from "../../graph"; +import { globalScheduler } from "../../lib/Scheduler"; +import { TBlockId } from "../../store/block/Block"; + +import { Block, TBlock } from "./blocks/Block"; +import { BlockConnection } from "./connections/BlockConnection"; + +const block = (id: string, x: number, y: number): TBlock => ({ + id, + is: "Block", + name: id, + x, + y, + width: 100, + height: 100, + anchors: [], +}); + +function frames(count = 1): void { + for (let i = 0; i < count; i++) globalScheduler.performUpdate(); +} + +describe("Visibility culling of graph components", () => { + let graph: Graph; + + const view = (id: TBlockId): Block => { + const component = graph.rootStore.blocksList.getBlockState(id)?.getViewComponent(); + if (!component) throw new Error(`no view for ${String(id)}`); + return component; + }; + + /** Camera showing the world rect [x, x + 1000] x [y, y + 800] at scale 1. */ + const lookAt = (x: number, y: number): void => { + graph.cameraService.set({ x: -x, y: -y, scale: 1 }); + }; + + beforeEach(() => { + graph = new Graph({}, document.createElement("div")); + graph.setEntities({ + blocks: [block("near", 100, 100), block("far", 50_000, 50_000)], + connections: [{ id: "near-far", sourceBlockId: "near", targetBlockId: "far" }], + }); + graph.start(); + frames(2); + // The jsdom root has no size; give the camera a 1000x800 viewport after the layers attached. + graph.cameraService.resize({ width: 1000, height: 800 }); + lookAt(0, 0); + frames(3); + }); + + afterEach(() => { + jest.restoreAllMocks(); + graph.unmount(); + }); + + it("walks only components near the viewport", () => { + const near = jest.spyOn(view("near"), "iterate"); + const far = jest.spyOn(view("far"), "iterate"); + graph.getGraphLayer().performRender(); + frames(3); + + expect(near.mock.calls.length).toBeGreaterThan(0); + expect(far).not.toHaveBeenCalled(); + expect(view("near").isRendered()).toBe(true); + expect(view("far").isRendered()).toBe(false); + }); + + it("walks every component when the setting is off", () => { + graph.updateSettings({ EXP_VISIBILITY_CULLING: false }); + const far = jest.spyOn(view("far"), "iterate"); + graph.getGraphLayer().performRender(); + frames(2); + + expect(far).toHaveBeenCalled(); + }); + + it("renders a component once the camera reaches it and stops when it leaves", () => { + lookAt(49_800, 49_800); + frames(2); + expect(view("far").isRendered()).toBe(true); + expect(view("near").isRendered()).toBe(false); + + lookAt(0, 0); + frames(2); + expect(view("far").isRendered()).toBe(false); + expect(view("near").isRendered()).toBe(true); + }); + + it("processes state changes of components out of view", () => { + graph.api.selectBlocks(["far"], true); + frames(2); + + expect(view("far").state.selected).toBe(true); + // Raising the selected block moved it to the end of the draw order. + expect(view("far").renderOrder).toBe(1); + expect(view("near").renderOrder).toBe(0); + }); + + it("keeps out-of-view components hittable", () => { + expect(graph.getElementOverPoint({ x: 50_050, y: 50_050 })).toBe(view("far")); + expect(graph.getElementsOverRect({ x: 49_000, y: 49_000, width: 2000, height: 2000 }, [Block])).toEqual([ + view("far"), + ]); + }); + + it("keeps a visible connection attached to a block out of view", () => { + const connection = graph.rootStore.connectionsList.getConnectionState("near-far")?.getViewComponent(); + expect(connection).toBeInstanceOf(BlockConnection); + + graph.api.updateBlock({ id: "far", x: 60_000, y: 70_000 }); + frames(2); + + expect(connection?.connectionPoints?.[1]).toEqual({ x: 60_000, y: 70_050 }); + expect(view("far").isRendered()).toBe(false); + }); +}); diff --git a/packages/graph/src/components/canvas/visibilityCulling.ts b/packages/graph/src/components/canvas/visibilityCulling.ts new file mode 100644 index 00000000..d69b4401 --- /dev/null +++ b/packages/graph/src/components/canvas/visibilityCulling.ts @@ -0,0 +1,31 @@ +import type { Graph } from "../../graph"; +import type { TTileRect } from "../../lib/TileIndex"; +import type { TTreeCullingOptions } from "../../lib/TreeCulling"; +import type { ICamera } from "../../services/camera/CameraService"; + +/** + * Culling options for a container of graph components (blocks, connections, groups): each frame + * walks the children near the camera viewport. Disabled by the `EXP_VISIBILITY_CULLING` setting. + */ +export function createViewportCulling( + graph: Graph, + getCamera: () => ICamera +): Omit { + const viewport: TTileRect = { minX: 0, minY: 0, maxX: 0, maxY: 0 }; + return { + tileSize: graph.graphConstants.system.VISIBILITY_TILE_SIZE, + getViewport: () => { + if (!graph.rootStore.settings.getConfigFlag("EXP_VISIBILITY_CULLING")) return undefined; + // Same rect as CameraService.isRectVisible: the full viewport, insets ignored. + const { relativeX, relativeY, relativeWidth, relativeHeight } = getCamera().getCameraState(); + const margin = graph.graphConstants.system.VISIBILITY_CULLING_MARGIN; + const marginX = relativeWidth * margin; + const marginY = relativeHeight * margin; + viewport.minX = -relativeX - marginX; + viewport.minY = -relativeY - marginY; + viewport.maxX = -relativeX + relativeWidth + marginX; + viewport.maxY = -relativeY + relativeHeight + marginY; + return viewport; + }, + }; +} diff --git a/packages/graph/src/graphConfig.ts b/packages/graph/src/graphConfig.ts index 51b778a6..6bcc0603 100644 --- a/packages/graph/src/graphConfig.ts +++ b/packages/graph/src/graphConfig.ts @@ -192,6 +192,13 @@ export type TGraphConstants = { USABLE_RECT_GAP: number; /** For preload blocks on the html layer (camera dimensions * (1 + this value)) */ CAMERA_VIEWPORT_TRESHOLD: number; + /** + * Edge of a visibility tile in world units. Blocks, connections and groups are indexed by + * tile so a frame walks only the components near the viewport. Read when a container mounts. + */ + VISIBILITY_TILE_SIZE: number; + /** Extra walked area around the viewport for visibility culling, as a fraction of its size per side. */ + VISIBILITY_CULLING_MARGIN: number; }; camera: { @@ -312,6 +319,8 @@ export const initGraphConstants: TGraphConstants = { PIXEL_RATIO: typeof globalThis !== "undefined" ? globalThis.devicePixelRatio || 1 : 1, USABLE_RECT_GAP: 400, CAMERA_VIEWPORT_TRESHOLD: 0.5, + VISIBILITY_TILE_SIZE: 1024, + VISIBILITY_CULLING_MARGIN: 0.1, }, camera: { SPEED: 1, diff --git a/packages/graph/src/lib/CoreComponent.ts b/packages/graph/src/lib/CoreComponent.ts index 91b2e506..14aa6f63 100644 --- a/packages/graph/src/lib/CoreComponent.ts +++ b/packages/graph/src/lib/CoreComponent.ts @@ -2,6 +2,7 @@ import { GraphClassConstructor } from "../utils/types/classes"; import { Scheduler } from "./Scheduler"; import { ITree, Tree } from "./Tree"; +import type { TTreeCullingOptions } from "./TreeCulling"; export type ComponentOptions = { readonly key?: string | number; @@ -112,13 +113,30 @@ export class CoreComponent< } public isIterated(): boolean { - return this.__comp.iterateId === this.__comp.context.globalIterateId; + const iterateId = this.__comp.context.globalIterateId; + // A child skipped by its parent's visibility culling was out of view with nothing to process, + // so for this traversal it counts as iterated. + return this.__comp.iterateId === iterateId || this.__comp.treeNode.isSkippedByCulling(iterateId); } public performRender = () => { this.__comp.context.scheduler.scheduleUpdate(); + this.__comp.treeNode.requestWalk(); }; + /** + * Walks only the children whose bounds (see {@link setCullingBounds}) intersect the viewport + * returned by `getViewport`, plus children that requested a render. `undefined` disables it. + */ + protected setChildrenCulling(options: Omit | undefined): void { + this.__comp.treeNode.setCulling(options && { ...options, getWalkToken: () => this.__comp.context.globalIterateId }); + } + + /** Reports the world-space bounds this component draws in, used by its parent's culling. */ + protected setCullingBounds(minX: number, minY: number, maxX: number, maxY: number): void { + this.__comp.treeNode.setBounds(minX, minY, maxX, maxY); + } + public getParent(): CoreComponent | undefined { return this.__comp.parent; } @@ -193,6 +211,7 @@ export class CoreComponent< if (ref !== undefined && this.$[ref] === child) delete this.$[ref]; child.__unmount(); this.__comp.children.delete(key); + this.__comp.treeNode.remove(child.__comp.treeNode); } protected __updateChildren() { diff --git a/packages/graph/src/lib/TileIndex.test.ts b/packages/graph/src/lib/TileIndex.test.ts new file mode 100644 index 00000000..33e10baf --- /dev/null +++ b/packages/graph/src/lib/TileIndex.test.ts @@ -0,0 +1,172 @@ +import { TTileRect, TileIndex } from "./TileIndex"; + +const rect = (minX: number, minY: number, maxX: number, maxY: number): TTileRect => ({ minX, minY, maxX, maxY }); + +function collect(index: TileIndex, query: TTileRect): T[] { + const found: T[] = []; + index.query(query, (item) => found.push(item)); + return found; +} + +function intersects(a: TTileRect, b: TTileRect): boolean { + return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY; +} + +describe("TileIndex", () => { + it("finds an item by any rect touching its bounds and only by those", () => { + const index = new TileIndex({ tileSize: 100 }); + index.set("a", 10, 10, 50, 50); + + expect(collect(index, rect(0, 0, 20, 20))).toEqual(["a"]); + expect(collect(index, rect(50, 50, 60, 60))).toEqual(["a"]); + // Same tile, outside the exact bounds. + expect(collect(index, rect(60, 60, 90, 90))).toEqual([]); + expect(collect(index, rect(500, 500, 600, 600))).toEqual([]); + }); + + it("reports an item stored in several tiles once", () => { + const index = new TileIndex({ tileSize: 100 }); + index.set("a", 50, 50, 350, 250); + + expect(collect(index, rect(0, 0, 1000, 1000))).toEqual(["a"]); + }); + + it("moves an item between tiles and inside a tile", () => { + const index = new TileIndex({ tileSize: 100 }); + index.set("a", 10, 10, 20, 20); + index.set("a", 30, 30, 40, 40); + expect(collect(index, rect(0, 0, 25, 25))).toEqual([]); + expect(collect(index, rect(35, 35, 36, 36))).toEqual(["a"]); + + index.set("a", 1010, 1010, 1020, 1020); + expect(collect(index, rect(0, 0, 100, 100))).toEqual([]); + expect(collect(index, rect(1000, 1000, 1100, 1100))).toEqual(["a"]); + expect(index.size).toBe(1); + }); + + it("deletes items", () => { + const index = new TileIndex({ tileSize: 100 }); + index.set("a", 0, 0, 10, 10); + index.set("b", 0, 0, 10, 10); + + expect(index.delete("a")).toBe(true); + expect(index.delete("a")).toBe(false); + expect(collect(index, rect(0, 0, 10, 10))).toEqual(["b"]); + expect(index.has("a")).toBe(false); + }); + + it("handles negative coordinates", () => { + const index = new TileIndex({ tileSize: 100 }); + index.set("a", -250, -250, -210, -210); + + expect(collect(index, rect(-260, -260, -240, -240))).toEqual(["a"]); + expect(collect(index, rect(-200, -200, -100, -100))).toEqual([]); + }); + + it("indexes long and huge items exactly", () => { + const index = new TileIndex({ tileSize: 100, maxCellSpan: 2, maxStripSpan: 2 }); + index.set("tall", 0, 0, 150, 100_000); + index.set("wide", 0, 0, 100_000, 150); + index.set("huge", 0, 0, 100_000, 100_000); + + expect(collect(index, rect(120, 50_000, 130, 50_010)).sort()).toEqual(["huge", "tall"]); + expect(collect(index, rect(50_000, 120, 50_010, 130)).sort()).toEqual(["huge", "wide"]); + expect(collect(index, rect(50_000, 50_000, 50_010, 50_010))).toEqual(["huge"]); + expect(collect(index, rect(-500, -500, -400, -400))).toEqual([]); + + // Shrinking a huge item moves it back to tiles. + index.set("huge", 0, 0, 10, 10); + expect(collect(index, rect(50_000, 50_000, 50_010, 50_010))).toEqual([]); + }); + + it("reports items without bounds to every query", () => { + const index = new TileIndex({ tileSize: 100 }); + index.set("unknown", NaN, NaN, NaN, NaN); + index.set("inverted", 10, 10, 0, 0); + + expect(collect(index, rect(5000, 5000, 5001, 5001)).sort()).toEqual(["inverted", "unknown"]); + + index.set("unknown", 0, 0, 10, 10); + expect(collect(index, rect(5000, 5000, 5001, 5001))).toEqual(["inverted"]); + }); + + it("tells whether a rect covers everything stored", () => { + const index = new TileIndex({ tileSize: 100 }); + index.set("a", 0, 0, 10, 10); + index.set("b", 500, 500, 510, 510); + + expect(index.covers(rect(-1, -1, 600, 600))).toBe(true); + expect(index.covers(rect(-1, -1, 505, 600))).toBe(false); + }); + + it("counts the bucket entries a query would scan, up to a limit", () => { + const index = new TileIndex({ tileSize: 100, maxCellSpan: 2, maxStripSpan: 2 }); + index.set("a", 10, 10, 20, 20); + index.set("b", 50, 50, 150, 60); // spans two tiles, stored in the first + index.set("far", 5000, 5000, 5010, 5010); + index.set("tall", 0, 0, 150, 100_000); // stored in column 0 + + expect(index.countCandidates(rect(0, 0, 99, 99), 100)).toBe(3); + // Every item is stored once, so a wider rect does not count "b" or "tall" twice. + expect(index.countCandidates(rect(0, 0, 199, 99), 100)).toBe(3); + // Items stored to the left can still reach in. + expect(index.countCandidates(rect(120, 0, 199, 99), 100)).toBe(3); + expect(index.countCandidates(rect(4000, 4000, 4100, 4100), 100)).toBe(0); + // Stops early once the limit is exceeded. + expect(index.countCandidates(rect(0, 0, 10_000, 10_000), 1)).toBeGreaterThan(1); + }); + + it.each([ + { tileSize: 512, maxCellSpan: 2, maxStripSpan: 4 }, + { tileSize: 128, maxCellSpan: 1, maxStripSpan: 1 }, + { tileSize: 2048, maxCellSpan: 3, maxStripSpan: 6 }, + ])("matches a brute-force search under random inserts, moves and deletes (%o)", (options) => { + let seed = 7; + const random = () => { + seed = (seed * 16807) % 2147483647; + return seed / 2147483647; + }; + const randomRect = (): TTileRect => { + const x = (random() - 0.5) * 20_000; + const y = (random() - 0.5) * 20_000; + // Mostly block-sized items, some long connections and a few huge groups. + const kind = random(); + const shapes: Array<[share: number, width: number, height: number]> = [ + [0.7, 200, 150], + [0.85, 300, 9_000], + [0.95, 6_000, 300], + [1, 15_000, 15_000], + ]; + const [, width, height] = shapes.find(([share]) => kind < share) ?? shapes[shapes.length - 1]; + return rect(x, y, x + width * random(), y + height * random()); + }; + + const index = new TileIndex(options); + const truth = new Map(); + for (let step = 0; step < 4000; step++) { + const id = Math.floor(random() * 600); + if (random() < 0.15) { + index.delete(id); + truth.delete(id); + } else { + const r = randomRect(); + index.set(id, r.minX, r.minY, r.maxX, r.maxY); + truth.set(id, r); + } + + if (step % 20 === 0) { + const size = random() < 0.2 ? 50_000 : 3_000 * random(); + const x = (random() - 0.5) * 20_000; + const y = (random() - 0.5) * 20_000; + const query = rect(x, y, x + size, y + size); + const expected = Array.from(truth.entries()) + .filter(([, r]) => intersects(r, query)) + .map(([key]) => key) + .sort((a, b) => a - b); + const found = collect(index, query).sort((a, b) => a - b); + expect(found).toEqual(expected); + } + } + expect(index.size).toBe(truth.size); + }); +}); diff --git a/packages/graph/src/lib/TileIndex.ts b/packages/graph/src/lib/TileIndex.ts new file mode 100644 index 00000000..1978159f --- /dev/null +++ b/packages/graph/src/lib/TileIndex.ts @@ -0,0 +1,352 @@ +/** World-space axis-aligned rectangle given by its edges (inclusive). */ +export type TTileRect = { + minX: number; + minY: number; + maxX: number; + maxY: number; +}; + +export type TTileIndexOptions = { + /** Edge of a square tile in world units. */ + tileSize: number; + /** + * Items spanning at most this many tiles in both directions are stored in the tile of their + * top-left corner. Larger items go to a column or row (see `maxStripSpan`) or, if they are + * large in both directions, to a list that every query checks. + * + * @default 2 + */ + maxCellSpan?: number; + /** + * A tall item spanning at most this many columns is stored in the column of its left edge; + * a wide item spanning at most this many rows is stored in the row of its top edge. + * + * @default 4 + */ + maxStripSpan?: number; +}; + +enum EBucket { + Unbounded, + Cells, + Columns, + Rows, + Large, +} + +type TRecord = TTileRect & { + item: T; + bucket: EBucket; + /** Key of the bucket list in its map (unused for the large and unbounded lists). */ + key: number; + /** Position in the bucket list, for constant-time removal. */ + slot: number; +}; + +type TBuckets = Map[]>; + +/** + * Tile coordinates are clamped to [-TILE_OFFSET, TILE_OFFSET) so cell keys stay small integers + * (fast Map keys). Items beyond that range share the border tiles, which stays correct. + */ +const TILE_OFFSET = 2 ** 14; +const TILE_STRIDE = 2 ** 15; + +function cellKey(tx: number, ty: number): number { + return (tx + TILE_OFFSET) * TILE_STRIDE + (ty + TILE_OFFSET); +} + +function cellX(key: number): number { + return Math.floor(key / TILE_STRIDE) - TILE_OFFSET; +} + +function cellY(key: number): number { + return (key % TILE_STRIDE) - TILE_OFFSET; +} + +function intersects(record: TTileRect, rect: TTileRect): boolean { + return record.minX <= rect.maxX && record.maxX >= rect.minX && record.minY <= rect.maxY && record.maxY >= rect.minY; +} + +function pushTo(list: TRecord[], record: TRecord): void { + record.slot = list.length; + list.push(record); +} + +/** Swap-remove: the last record takes the freed slot. */ +function removeAt(list: TRecord[], record: TRecord): void { + const last = list.pop(); + if (last && last !== record) { + list[record.slot] = last; + last.slot = record.slot; + } +} + +/** + * Sparse world-space grid used as a visibility index. + * + * Every item lives in exactly one bucket, so moving it is a constant-time bucket change and a + * query reports it at most once. Small items are stored in the tile of their top-left corner and + * queries widen their tile range to catch items reaching in from the left and top. Long thin items + * (typical for connections) are stored per column or per row in the same way, and items large in + * both directions are kept in a list that every query checks. + */ +export class TileIndex { + public readonly tileSize: number; + + private readonly maxCellSpan: number; + + private readonly maxStripSpan: number; + + private readonly records = new Map>(); + + private readonly cells: TBuckets = new Map(); + + private readonly columns: TBuckets = new Map(); + + private readonly rows: TBuckets = new Map(); + + private readonly large: TRecord[] = []; + + private readonly unbounded: TRecord[] = []; + + /** Union of every bounds ever stored. It never shrinks, so it is a safe outer bound. */ + private readonly extent: TTileRect = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; + + constructor({ tileSize, maxCellSpan = 2, maxStripSpan = 4 }: TTileIndexOptions) { + if (!(tileSize > 0)) { + throw new Error(`TileIndex tile size must be positive, got ${tileSize}`); + } + this.tileSize = tileSize; + this.maxCellSpan = Math.max(1, maxCellSpan); + this.maxStripSpan = Math.max(this.maxCellSpan, maxStripSpan); + } + + public get size(): number { + return this.records.size; + } + + public has(item: T): boolean { + return this.records.has(item); + } + + /** Stores or moves an item. Non-finite or inverted bounds make the item match every query. */ + public set(item: T, minX: number, minY: number, maxX: number, maxY: number): void { + let bucket: EBucket; + let key = 0; + if (!Number.isFinite(minX + minY + maxX + maxY) || maxX < minX || maxY < minY) { + bucket = EBucket.Unbounded; + } else { + const tx0 = this.toTile(minX); + const ty0 = this.toTile(minY); + const spanX = this.toTile(maxX) - tx0 + 1; + const spanY = this.toTile(maxY) - ty0 + 1; + if (spanX <= this.maxCellSpan && spanY <= this.maxCellSpan) { + bucket = EBucket.Cells; + key = cellKey(tx0, ty0); + } else if (spanX <= this.maxStripSpan) { + bucket = EBucket.Columns; + key = tx0; + } else if (spanY <= this.maxStripSpan) { + bucket = EBucket.Rows; + key = ty0; + } else { + bucket = EBucket.Large; + } + } + + let record = this.records.get(item); + if (!record) { + record = { item, minX, minY, maxX, maxY, bucket, key, slot: 0 }; + this.records.set(item, record); + this.link(record); + } else if (record.bucket !== bucket || record.key !== key) { + this.unlink(record); + record.bucket = bucket; + record.key = key; + this.link(record); + } + record.minX = minX; + record.minY = minY; + record.maxX = maxX; + record.maxY = maxY; + if (bucket !== EBucket.Unbounded) this.growExtent(record); + } + + public delete(item: T): boolean { + const record = this.records.get(item); + if (!record) return false; + this.unlink(record); + this.records.delete(item); + return true; + } + + public clear(): void { + this.records.clear(); + this.cells.clear(); + this.columns.clear(); + this.rows.clear(); + this.large.length = 0; + this.unbounded.length = 0; + this.extent.minX = Infinity; + this.extent.minY = Infinity; + this.extent.maxX = -Infinity; + this.extent.maxY = -Infinity; + } + + /** Whether the rect contains every stored item (items without bounds aside). */ + public covers(rect: TTileRect): boolean { + const extent = this.extent; + return rect.minX <= extent.minX && rect.minY <= extent.minY && rect.maxX >= extent.maxX && rect.maxY >= extent.maxY; + } + + /** + * Counts the items a query of the rect would test, stopping once the count exceeds `limit`. + * It is an upper bound of the query result and a measure of its cost. + */ + public countCandidates(rect: TTileRect, limit: number): number { + let count = this.unbounded.length + this.large.length; + const visit = (list: TRecord[]): boolean => { + count += list.length; + return count > limit; + }; + if (count > limit) return count; + this.scan(rect, visit); + return count; + } + + /** + * Calls `visit` once for every item whose bounds intersect the rect (edges inclusive) and for + * every item without bounds. + */ + public query(rect: TTileRect, visit: (item: T) => void): void { + const unbounded = this.unbounded; + for (let i = 0; i < unbounded.length; i++) visit(unbounded[i].item); + this.scan(rect, (list) => { + for (let i = 0; i < list.length; i++) { + const record = list[i]; + if (intersects(record, rect)) visit(record.item); + } + return false; + }); + } + + /** Calls `visit` with every bucket list a query of the rect has to test; stops when it returns true. */ + private scan(rect: TTileRect, visit: (list: TRecord[]) => boolean): void { + if (this.large.length && visit(this.large)) return; + + const tx0 = this.toTile(rect.minX); + const ty0 = this.toTile(rect.minY); + const tx1 = this.toTile(rect.maxX); + const ty1 = this.toTile(rect.maxY); + + // Items are stored at their top-left tile, so items starting up to span - 1 tiles to the left + // or above can still reach into the rect. + const cellReach = this.maxCellSpan - 1; + if (this.scanCells(tx0 - cellReach, ty0 - cellReach, tx1, ty1, visit)) return; + const stripReach = this.maxStripSpan - 1; + if (this.scanStrips(this.columns, tx0 - stripReach, tx1, visit)) return; + this.scanStrips(this.rows, ty0 - stripReach, ty1, visit); + } + + private scanCells( + tx0: number, + ty0: number, + tx1: number, + ty1: number, + visit: (list: TRecord[]) => boolean + ): boolean { + const cells = this.cells; + if ((tx1 - tx0 + 1) * (ty1 - ty0 + 1) <= cells.size) { + for (let tx = tx0; tx <= tx1; tx++) { + for (let ty = ty0; ty <= ty1; ty++) { + const list = cells.get(cellKey(tx, ty)); + if (list && visit(list)) return true; + } + } + return false; + } + // The range covers more tiles than are occupied: walk the occupied ones instead. + for (const [key, list] of cells) { + const tx = cellX(key); + const ty = cellY(key); + if (tx >= tx0 && tx <= tx1 && ty >= ty0 && ty <= ty1 && visit(list)) return true; + } + return false; + } + + private scanStrips(strips: TBuckets, from: number, to: number, visit: (list: TRecord[]) => boolean): boolean { + if (to - from + 1 <= strips.size) { + for (let i = from; i <= to; i++) { + const list = strips.get(i); + if (list && visit(list)) return true; + } + return false; + } + for (const [index, list] of strips) { + if (index >= from && index <= to && visit(list)) return true; + } + return false; + } + + private toTile(value: number): number { + const tile = Math.floor(value / this.tileSize); + return Math.max(-TILE_OFFSET, Math.min(TILE_OFFSET - 1, tile)); + } + + private growExtent(record: TTileRect): void { + const extent = this.extent; + if (record.minX < extent.minX) extent.minX = record.minX; + if (record.minY < extent.minY) extent.minY = record.minY; + if (record.maxX > extent.maxX) extent.maxX = record.maxX; + if (record.maxY > extent.maxY) extent.maxY = record.maxY; + } + + private bucketsOf(bucket: EBucket): TBuckets | undefined { + switch (bucket) { + case EBucket.Cells: + return this.cells; + case EBucket.Columns: + return this.columns; + case EBucket.Rows: + return this.rows; + default: + return undefined; + } + } + + private link(record: TRecord): void { + if (record.bucket === EBucket.Large) { + pushTo(this.large, record); + return; + } + if (record.bucket === EBucket.Unbounded) { + pushTo(this.unbounded, record); + return; + } + const buckets = this.bucketsOf(record.bucket); + if (!buckets) return; + let list = buckets.get(record.key); + if (!list) { + list = []; + buckets.set(record.key, list); + } + pushTo(list, record); + } + + private unlink(record: TRecord): void { + if (record.bucket === EBucket.Large) { + removeAt(this.large, record); + return; + } + if (record.bucket === EBucket.Unbounded) { + removeAt(this.unbounded, record); + return; + } + const buckets = this.bucketsOf(record.bucket); + const list = buckets?.get(record.key); + if (!buckets || !list) return; + removeAt(list, record); + if (!list.length) buckets.delete(record.key); + } +} diff --git a/packages/graph/src/lib/Tree.spec.ts b/packages/graph/src/lib/Tree.spec.ts index 9af8289b..046c307c 100644 --- a/packages/graph/src/lib/Tree.spec.ts +++ b/packages/graph/src/lib/Tree.spec.ts @@ -57,6 +57,33 @@ describe("Tree", () => { expect(child2.zIndex).toBe(2); }); + test("A child whose z-index changes is walked once, in its new group", () => { + const parent = new Tree(createChildData()); + const child1 = new Tree(createChildData()); + const child2 = new Tree(createChildData()); + parent.append(child1); + parent.append(child2); + + const walk = () => { + const visited: Tree[] = []; + parent.traverseDown((node) => { + if (node !== parent) visited.push(node); + return true; + }); + return visited; + }; + + child1.updateZIndex(5); + expect(walk()).toEqual([child2, child1]); + expect(child1.renderOrder).toBe(1); + + child1.updateZIndex(1); + expect(walk()).toEqual([child2, child1]); + + parent.remove(child2); + expect(walk()).toEqual([child1]); + }); + test("Traversing the tree", () => { const root = new Tree(createChildData()); const child1 = new Tree(createChildData()); diff --git a/packages/graph/src/lib/Tree.ts b/packages/graph/src/lib/Tree.ts index 262bb660..b45c65ad 100644 --- a/packages/graph/src/lib/Tree.ts +++ b/packages/graph/src/lib/Tree.ts @@ -1,3 +1,4 @@ +import { TTreeCullingOptions, TreeCulling } from "./TreeCulling"; import { cache } from "./utils"; type TIterator = (node: Tree) => boolean; @@ -19,16 +20,37 @@ export class Tree { protected zIndexGroups: Map> = new Map(); protected zIndexChildrenCache = cache(() => { - return Array.from(this.zIndexGroups.keys()) + const ordered = Array.from(this.zIndexGroups.keys()) .sort((a, b) => a - b) .map((index) => Array.from(this.zIndexGroups.get(index) || [])) .flat(2) as Tree[]; + if (this.culling) { + // A culled walk skips children, so every child gets its draw position here instead. + for (let i = 0; i < ordered.length; i++) { + ordered[i].renderOrder = i; + } + } + return ordered; }); public renderOrder = 0; public zIndex = 1; + /** World-space bounds reported by the node's data for its parent's culling; NaN when unknown. */ + public boundsMinX = NaN; + + public boundsMinY = NaN; + + public boundsMaxX = NaN; + + public boundsMaxY = NaN; + + /** @internal Selection stamp of the parent's culling. */ + public cullingStamp = 0; + + protected culling?: TreeCulling; + constructor(data: T, parent?: Tree) { this.data = data; this.parent = parent; @@ -39,6 +61,7 @@ export class Tree { this.children.add(node); this.childrenDirty = true; this.addInZIndex(node); + this.culling?.add(node); } protected addInZIndex(node: Tree) { @@ -58,8 +81,8 @@ export class Tree { set.delete(node); if (!set.size) { this.zIndexGroups.delete(zIndex); - this.zIndexChildrenCache.reset(); } + this.zIndexChildrenCache.reset(); } public remove(node: Tree = this) { @@ -67,6 +90,7 @@ export class Tree { this.children.delete(node); this.childrenDirty = true; this.removeZIndex(node); + this.culling?.delete(node); } public setChildren(nodes: Tree[]) { @@ -75,15 +99,70 @@ export class Tree { nodes.forEach((item) => { this.addInZIndex(item); + this.culling?.add(item); }); } + /** + * Enables visibility culling of the children: a traversal walks only children whose bounds + * intersect the viewport, plus children that requested an update (see {@link requestWalk}). + * `undefined` disables it. + */ + public setCulling(options: TTreeCullingOptions | undefined): void { + if (!options) { + this.culling = undefined; + } else { + const culling = new TreeCulling(this, options); + this.children.forEach((node) => culling.add(node)); + this.culling = culling; + } + this.zIndexChildrenCache.reset(); + } + + /** Reports the world-space bounds this node draws in to the parent's culling. */ + public setBounds(minX: number, minY: number, maxX: number, maxY: number): void { + this.boundsMinX = minX; + this.boundsMinY = minY; + this.boundsMaxX = maxX; + this.boundsMaxY = maxY; + this.parent?.culling?.update(this); + } + + /** Makes every culling ancestor walk the branch with this node in the next traversal. */ + public requestWalk(): void { + let node: Tree = this; + let parent = node.parent; + while (parent) { + parent.culling?.markPending(node); + node = parent; + parent = node.parent; + } + } + + /** + * Whether the parent's culling chose the children to walk in the traversal identified by + * `token` and this node is still a child. A node like that which was not walked was out of view + * with nothing to process. + */ + public isSkippedByCulling(token: unknown): boolean { + const parent = this.parent; + if (!parent?.culling) return false; + return parent.culling.walkToken === token && parent.children.has(this); + } + public updateZIndex(index: number) { if (this.zIndex === index) { return; } + const parent = this.parent; + if (!parent?.children.has(this)) { + this.zIndex = index; + return; + } + // Leave the group of the previous zIndex before switching to the new one. + parent.removeZIndex(this); this.zIndex = index; - this.parent?.updateChildZIndex(this); + parent.addInZIndex(this); } public updateChildZIndex(child: Tree) { @@ -121,6 +200,13 @@ export class Tree { return; } const children = this.zIndexChildrenCache.get(); + const selected = this.culling?.select(children); + if (selected) { + for (let i = 0; i < selected.length; i++) { + selected[i]._walkDown(iterator, selected[i].renderOrder); + } + return; + } for (let i = 0; i < children.length; i++) { children[i]._walkDown(iterator, i); } diff --git a/packages/graph/src/lib/TreeCulling.test.ts b/packages/graph/src/lib/TreeCulling.test.ts new file mode 100644 index 00000000..9ea868b3 --- /dev/null +++ b/packages/graph/src/lib/TreeCulling.test.ts @@ -0,0 +1,240 @@ +import type { TTileRect } from "./TileIndex"; +import { ITree, Tree } from "./Tree"; + +type TNodeData = ITree & { name: string }; + +const rect = (minX: number, minY: number, maxX: number, maxY: number): TTileRect => ({ minX, minY, maxX, maxY }); + +function setup({ fullWalkShare = Infinity }: { fullWalkShare?: number } = {}) { + const walked: string[] = []; + const state: { viewport: TTileRect | undefined; token: number } = { viewport: rect(0, 0, 100, 100), token: 0 }; + const data = (name: string): TNodeData => ({ + name, + iterate: () => { + walked.push(name); + return true; + }, + }); + const root = new Tree(data("root")); + root.setCulling({ + tileSize: 64, + getViewport: () => state.viewport, + getWalkToken: () => state.token, + fullWalkShare, + }); + + const add = (name: string, x: number, y: number, zIndex = 1, size = 10): Tree => { + const node = new Tree(data(name)); + node.zIndex = zIndex; + node.setBounds(x, y, x + size, y + size); + root.append(node); + return node; + }; + + const walk = (): string[] => { + state.token += 1; + walked.length = 0; + root.traverseDown((node) => node.data.iterate()); + return walked.filter((name) => name !== "root"); + }; + + /** New children are walked twice (first iterate, then to notice they are out of view). */ + const settle = (): string[] => { + walk(); + walk(); + return walk(); + }; + + const fullOrder = (): string[] => { + const viewport = state.viewport; + state.viewport = undefined; + const order = walk(); + state.viewport = viewport; + // The full walk made every child "active"; walk once more so they settle again. + walk(); + return order; + }; + + return { root, state, add, walk, settle, fullOrder }; +} + +describe("Tree visibility culling", () => { + it("walks only children in view, in the order of a full walk", () => { + const { add, settle, fullOrder } = setup(); + add("a", 10, 10, 2); + add("b", 500, 500, 1); + add("c", 20, 20, 1); + add("d", 30, 30, 3); + add("e", 90, 90, 1); + add("f", -500, 40, 2); + + expect(settle()).toEqual(["c", "e", "a", "d"]); + expect(fullOrder()).toEqual(["b", "c", "e", "a", "f", "d"]); + }); + + it("walks every new child, then once more, then only those in view", () => { + const { add, walk } = setup(); + add("in", 10, 10); + add("out", 1000, 1000); + + expect(walk()).toEqual(["in", "out"]); + expect(walk()).toEqual(["in", "out"]); + expect(walk()).toEqual(["in"]); + }); + + it("keeps renderOrder of every child equal to its position in a full walk", () => { + const { root, add, settle } = setup(); + const nodes = [add("a", 10, 10), add("b", 1000, 1000), add("c", 20, 20), add("d", 2000, 2000)]; + settle(); + + // Raise an out-of-view child: it moves to the end of the draw order. + nodes[1].updateZIndex(5); + settle(); + + const expected = ["a", "c", "d", "b"]; + for (const node of nodes) { + expect(node.renderOrder).toBe(expected.indexOf(node.data.name)); + } + expect(root.children.size).toBe(4); + }); + + it("walks an out-of-view child that requested an update once", () => { + const { add, settle, walk } = setup(); + add("in", 10, 10); + const out = add("out", 1000, 1000); + settle(); + + out.requestWalk(); + expect(walk()).toEqual(["in", "out"]); + expect(walk()).toEqual(["in"]); + }); + + it("walks the branch of a descendant that requested an update", () => { + const { add, settle, walk } = setup(); + const out = add("out", 1000, 1000); + const leaf = new Tree({ name: "leaf", iterate: () => true }); + out.append(leaf); + settle(); + + leaf.requestWalk(); + expect(walk()).toEqual(["out"]); + expect(walk()).toEqual([]); + }); + + it("walks a child once more after it leaves the view", () => { + const { state, add, settle, walk } = setup(); + add("a", 10, 10); + add("b", 300, 300); + expect(settle()).toEqual(["a"]); + + state.viewport = rect(250, 250, 350, 350); + expect(walk()).toEqual(["a", "b"]); + expect(walk()).toEqual(["b"]); + }); + + it("follows bounds changes", () => { + const { add, settle, walk } = setup(); + const node = add("a", 1000, 1000); + expect(settle()).toEqual([]); + + node.setBounds(50, 50, 60, 60); + expect(walk()).toEqual(["a"]); + + node.setBounds(5000, 5000, 5010, 5010); + // Once more to notice it left the view. + expect(walk()).toEqual(["a"]); + expect(walk()).toEqual([]); + }); + + it("walks children without known bounds every time", () => { + const { root, settle } = setup(); + const node = new Tree({ name: "unknown", iterate: () => true }); + root.append(node); + expect(settle()).toEqual([]); + expect(root.children.has(node)).toBe(true); + }); + + it("does not walk removed children", () => { + const { root, add, settle, walk } = setup(); + add("in", 10, 10); + const out = add("out", 1000, 1000); + settle(); + + out.requestWalk(); + root.remove(out); + expect(walk()).toEqual(["in"]); + }); + + it("walks every child without a viewport or when the viewport covers all of them", () => { + const { state, add, settle, walk } = setup(); + add("a", 10, 10); + add("b", 1000, 1000); + settle(); + + state.viewport = undefined; + expect(walk()).toEqual(["a", "b"]); + + state.viewport = rect(-10, -10, 2000, 2000); + expect(walk()).toEqual(["a", "b"]); + }); + + it("walks every child when most of them are near the viewport", () => { + const { add, settle } = setup({ fullWalkShare: 0.5 }); + add("a", 10, 10); + add("b", 20, 20); + add("c", 1000, 1000); + // Two of three children are candidates: walking all of them is cheaper than culling. + expect(settle()).toEqual(["a", "b", "c"]); + + add("d", 2000, 2000); + add("e", 3000, 3000); + expect(settle()).toEqual(["a", "b"]); + }); + + it("marks out-of-view children as skipped for the traversal that skipped them", () => { + const { root, state, add, settle } = setup(); + const inView = add("in", 10, 10); + const out = add("out", 1000, 1000); + settle(); + + expect(out.isSkippedByCulling(state.token)).toBe(true); + expect(out.isSkippedByCulling(state.token - 1)).toBe(false); + // Also true for walked children: the parent walked its children in this traversal. + expect(inView.isSkippedByCulling(state.token)).toBe(true); + + root.remove(out); + expect(out.isSkippedByCulling(state.token)).toBe(false); + }); + + it("orders large and small selections like a full walk", () => { + const { state, add, settle, fullOrder } = setup(); + let seed = 3; + const random = () => { + seed = (seed * 16807) % 2147483647; + return seed / 2147483647; + }; + const positions = new Map(); + for (let i = 0; i < 300; i++) { + const x = Math.floor(random() * 1000); + const y = Math.floor(random() * 1000); + positions.set(`n${i}`, [x, y]); + add(`n${i}`, x, y, 1 + Math.floor(random() * 3)); + } + + // Small viewport takes the sort path, a large one takes the filter path. + for (const viewport of [rect(100, 100, 200, 200), rect(0, 0, 900, 950)]) { + state.viewport = viewport; + const culled = settle(); + const visible = new Set( + Array.from(positions.entries()) + .filter( + ([, [x, y]]) => + x <= viewport.maxX && x + 10 >= viewport.minX && y <= viewport.maxY && y + 10 >= viewport.minY + ) + .map(([name]) => name) + ); + expect(culled).toEqual(fullOrder().filter((name) => visible.has(name))); + expect(culled.length).toBe(visible.size); + } + }); +}); diff --git a/packages/graph/src/lib/TreeCulling.ts b/packages/graph/src/lib/TreeCulling.ts new file mode 100644 index 00000000..68e380d6 --- /dev/null +++ b/packages/graph/src/lib/TreeCulling.ts @@ -0,0 +1,137 @@ +import { TTileRect, TileIndex } from "./TileIndex"; +import type { Tree } from "./Tree"; + +export type TTreeCullingOptions = { + /** Edge of a visibility tile in world units. */ + tileSize: number; + /** + * World rect whose children are walked in the current traversal. Called once per traversal of + * the owner; `undefined` walks every child, as a tree without culling does. + */ + getViewport: () => TTileRect | undefined; + /** Identifies the current traversal, see {@link Tree.isSkippedByCulling}. */ + getWalkToken?: () => unknown; + /** + * When the index buckets around the viewport hold more entries than this share of the + * children, every child is walked: a full walk is then cheaper than querying and ordering. + * + * @default 0.75 + */ + fullWalkShare?: number; +}; + +function byRenderOrder(a: Tree, b: Tree): number { + return a.renderOrder - b.renderOrder; +} + +/** + * Chooses which children of a tree node are walked in a traversal. + * + * A child is walked when its bounds intersect the viewport, when it asked for an update + * ({@link markPending}), when it was appended since the last traversal, or when it was in the + * viewport during the previous traversal (so it can notice that it left it). Every other child + * was already out of view in its last walk and has nothing to process, so skipping it changes + * nothing. The walk keeps the order of a full walk: zIndex groups, then position in the group. + */ +export class TreeCulling { + public readonly index: TileIndex; + + /** Token of the last traversal that walked the owner's children. */ + public walkToken: unknown = undefined; + + private pending = new Set(); + + private fresh: Tree[] = []; + + /** Children in the viewport during the last traversal, or every child after a full walk. */ + private active: readonly Tree[] = []; + + private stamp = 0; + + constructor( + private readonly owner: Tree, + private readonly options: TTreeCullingOptions + ) { + this.index = new TileIndex({ tileSize: options.tileSize }); + } + + /** Indexes a newly appended child; it is walked in the next traversal. */ + public add(node: Tree): void { + if (this.index.has(node)) return; + this.index.set(node, node.boundsMinX, node.boundsMinY, node.boundsMaxX, node.boundsMaxY); + this.fresh.push(node); + } + + /** Moves an indexed child to its current bounds. */ + public update(node: Tree): void { + if (!this.index.has(node)) return; + this.index.set(node, node.boundsMinX, node.boundsMinY, node.boundsMaxX, node.boundsMaxY); + } + + public delete(node: Tree): void { + this.index.delete(node); + this.pending.delete(node); + } + + /** Walks the child in the next traversal regardless of its bounds. */ + public markPending(node: Tree): void { + this.pending.add(node); + } + + /** + * Returns the children to walk in draw order, or `undefined` when every child should be walked. + * `ordered` is the full draw order; each child's `renderOrder` is its index in it. + */ + public select(ordered: readonly Tree[]): readonly Tree[] | undefined { + this.walkToken = this.options.getWalkToken?.(); + const viewport = this.options.getViewport(); + // Requests made while the children are walked belong to the next traversal. + const pending = this.pending; + const fresh = this.fresh; + if (pending.size) this.pending = new Set(); + if (fresh.length) this.fresh = []; + + const limit = ordered.length * (this.options.fullWalkShare ?? 0.75); + if (!viewport || this.index.covers(viewport) || this.index.countCandidates(viewport, limit) > limit) { + this.active = ordered; + return undefined; + } + + const owner = this.owner; + const stamp = ++this.stamp; + const selected: Tree[] = []; + const nextActive: Tree[] = []; + // Pending, new and previously active nodes may have been removed since; the index holds + // only current children. + const take = (node: Tree): boolean => { + if (node.cullingStamp === stamp || node.parent !== owner || !owner.children.has(node)) return false; + node.cullingStamp = stamp; + selected.push(node); + return true; + }; + + this.index.query(viewport, (node) => { + if (node.cullingStamp === stamp) return; + node.cullingStamp = stamp; + selected.push(node); + nextActive.push(node); + }); + // A new child renders on its first iterate wherever it is; walking it once more lets it + // see that it is out of view. + for (let i = 0; i < fresh.length; i++) { + if (take(fresh[i])) nextActive.push(fresh[i]); + } + pending.forEach(take); + // Children that were in view: one more walk lets them notice that they left it. + const previous = this.active; + for (let i = 0; i < previous.length; i++) { + take(previous[i]); + } + this.active = nextActive; + + if (selected.length * Math.log2(selected.length + 1) < ordered.length) { + return selected.sort(byRenderOrder); + } + return ordered.filter((node) => node.cullingStamp === stamp); + } +} diff --git a/packages/graph/src/store/settings.ts b/packages/graph/src/store/settings.ts index f227e4c8..c0e4a7fc 100644 --- a/packages/graph/src/store/settings.ts +++ b/packages/graph/src/store/settings.ts @@ -43,6 +43,11 @@ export type TGraphSettingsConfig Date: Tue, 20 Oct 2026 13:32:13 +0300 Subject: [PATCH 02/13] chore(storybook): add visibility culling control to graph editor stories Lets GraphStressTest and NirvanaMaxGraphTest be compared with EXP_VISIBILITY_CULLING on and off on the preview stand. Co-Authored-By: Claude Opus 5.5 --- apps/storybook/src/stories/main/GraphEditor.stories.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/storybook/src/stories/main/GraphEditor.stories.tsx b/apps/storybook/src/stories/main/GraphEditor.stories.tsx index a1fcec48..d7ac6181 100644 --- a/apps/storybook/src/stories/main/GraphEditor.stories.tsx +++ b/apps/storybook/src/stories/main/GraphEditor.stories.tsx @@ -70,6 +70,10 @@ const meta: Meta = { 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, @@ -79,6 +83,7 @@ const meta: Meta = { canDrag: ECanDrag.ALL, scaleFontSize: 1, showConnectionArrows: true, + EXP_VISIBILITY_CULLING: true, }, }; From af87e86a8d78ba09e07be0cfa36a1c07e8686177 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 23 Oct 2026 00:59:50 +0300 Subject: [PATCH 03/13] fix(graph): preserve behavior when culling component subtrees --- .../components/canvas/GraphComponent/index.ts | 2 +- .../canvas/visibilityCulling.test.ts | 41 ++++++ packages/graph/src/lib/CoreComponent.ts | 4 +- packages/graph/src/lib/TileIndex.test.ts | 14 +++ packages/graph/src/lib/TileIndex.ts | 2 +- packages/graph/src/lib/Tree.subtree.test.ts | 117 ++++++++++++++++++ packages/graph/src/lib/Tree.ts | 77 ++++++++++-- packages/graph/src/lib/TreeCulling.test.ts | 43 ++++++- packages/graph/src/lib/TreeCulling.ts | 13 ++ 9 files changed, 294 insertions(+), 19 deletions(-) create mode 100644 packages/graph/src/lib/Tree.subtree.test.ts diff --git a/packages/graph/src/components/canvas/GraphComponent/index.ts b/packages/graph/src/components/canvas/GraphComponent/index.ts index 5d6c7edc..b8604b88 100644 --- a/packages/graph/src/components/canvas/GraphComponent/index.ts +++ b/packages/graph/src/components/canvas/GraphComponent/index.ts @@ -359,7 +359,7 @@ export class GraphComponent< public setHitBox(minX: number, minY: number, maxX: number, maxY: number, force?: boolean) { this.hitBox.update(minX, minY, maxX, maxY, force); - // isVisible() checks the hit box, so the parent's culling uses the same rect. + // Culling includes this hit box and the bounds reported by descendants. if (!this.hitBox.destroyed) this.setCullingBounds(minX, minY, maxX, maxY); } diff --git a/packages/graph/src/components/canvas/visibilityCulling.test.ts b/packages/graph/src/components/canvas/visibilityCulling.test.ts index b2bf67d5..78550157 100644 --- a/packages/graph/src/components/canvas/visibilityCulling.test.ts +++ b/packages/graph/src/components/canvas/visibilityCulling.test.ts @@ -2,6 +2,7 @@ import { Graph } from "../../graph"; import { globalScheduler } from "../../lib/Scheduler"; import { TBlockId } from "../../store/block/Block"; +import { Anchor } from "./anchors"; import { Block, TBlock } from "./blocks/Block"; import { BlockConnection } from "./connections/BlockConnection"; @@ -113,4 +114,44 @@ describe("Visibility culling of graph components", () => { expect(connection?.connectionPoints?.[1]).toEqual({ x: 60_000, y: 70_050 }); expect(view("far").isRendered()).toBe(false); }); + + it("draws visible anchors outside their off-screen block's bounds", () => { + graph.setEntities({ + blocks: [ + { + ...block("overflow", 100, -400), + anchors: Array.from({ length: 20 }, (_, i) => ({ id: `a${i}`, blockId: "overflow", type: "OUT" })), + }, + block("far", 50_000, 50_000), + ], + connections: [], + }); + graph.cameraService.set({ x: 0, y: 0, scale: 0.5 }); + frames(8); + const anchor = graph.rootStore.blocksList.getBlockState("overflow")?.getAnchorById("a15")?.getViewComponent(); + expect(anchor?.getPosition()).toEqual({ x: 200, y: 168 }); + const arc = jest.spyOn(view("overflow").context.ctx, "arc").mockClear(); + + graph.getGraphLayer().performRender(); + frames(3); + + expect(view("overflow").isRendered()).toBe(false); + expect(arc).toHaveBeenCalledWith(200, 168, 9, 0, 2 * Math.PI); + expect(graph.getElementOverPoint({ x: 200, y: 168 }, [Anchor])).toBe(anchor); + }); + + it("keeps point and rectangle queries consistent for anchors in a skipped branch", () => { + graph.api.updateBlock({ id: "far", anchors: [{ id: "out", blockId: "far", type: "OUT" }] }); + frames(5); + const anchor = graph.rootStore.blocksList.getBlockState("far")?.getAnchorById("out")?.getViewComponent(); + expect(anchor).toBeInstanceOf(Anchor); + const far = jest.spyOn(view("far"), "iterate"); + + graph.getGraphLayer().performRender(); + frames(3); + + expect(far).not.toHaveBeenCalled(); + expect(graph.getElementsOverRect({ x: 50_099, y: 50_087, width: 2, height: 2 }, [Anchor])).toContain(anchor); + expect(graph.getElementOverPoint({ x: 50_100, y: 50_088 }, [Anchor])).toBe(anchor); + }); }); diff --git a/packages/graph/src/lib/CoreComponent.ts b/packages/graph/src/lib/CoreComponent.ts index 14aa6f63..54774ba8 100644 --- a/packages/graph/src/lib/CoreComponent.ts +++ b/packages/graph/src/lib/CoreComponent.ts @@ -114,7 +114,7 @@ export class CoreComponent< public isIterated(): boolean { const iterateId = this.__comp.context.globalIterateId; - // A child skipped by its parent's visibility culling was out of view with nothing to process, + // A child skipped by an ancestor's visibility culling was out of view with nothing to process, // so for this traversal it counts as iterated. return this.__comp.iterateId === iterateId || this.__comp.treeNode.isSkippedByCulling(iterateId); } @@ -132,7 +132,7 @@ export class CoreComponent< this.__comp.treeNode.setCulling(options && { ...options, getWalkToken: () => this.__comp.context.globalIterateId }); } - /** Reports the world-space bounds this component draws in, used by its parent's culling. */ + /** Reports this component's own bounds; culling also includes its descendants' bounds. */ protected setCullingBounds(minX: number, minY: number, maxX: number, maxY: number): void { this.__comp.treeNode.setBounds(minX, minY, maxX, maxY); } diff --git a/packages/graph/src/lib/TileIndex.test.ts b/packages/graph/src/lib/TileIndex.test.ts index 33e10baf..74999aa3 100644 --- a/packages/graph/src/lib/TileIndex.test.ts +++ b/packages/graph/src/lib/TileIndex.test.ts @@ -116,6 +116,20 @@ describe("TileIndex", () => { expect(index.countCandidates(rect(0, 0, 10_000, 10_000), 1)).toBeGreaterThan(1); }); + it("counts large and unbounded candidates once each", () => { + const index = new TileIndex({ tileSize: 100 }); + index.set("huge", 0, 0, 1000, 1000); + index.set("unknown", NaN, NaN, NaN, NaN); + index.set("near", 0, 0, 10, 10); + const viewport = rect(100, 100, 110, 110); + + expect(index.countCandidates(viewport, Infinity)).toBe(3); + expect(index.countCandidates(viewport, 3)).toBe(3); + expect(index.countCandidates(viewport, 1)).toBeGreaterThan(1); + expect(collect(index, viewport).sort()).toEqual(["huge", "unknown"]); + expect(index.countCandidates(rect(5000, 5000, 5010, 5010), Infinity)).toBe(2); + }); + it.each([ { tileSize: 512, maxCellSpan: 2, maxStripSpan: 4 }, { tileSize: 128, maxCellSpan: 1, maxStripSpan: 1 }, diff --git a/packages/graph/src/lib/TileIndex.ts b/packages/graph/src/lib/TileIndex.ts index 1978159f..608db280 100644 --- a/packages/graph/src/lib/TileIndex.ts +++ b/packages/graph/src/lib/TileIndex.ts @@ -205,7 +205,7 @@ export class TileIndex { * It is an upper bound of the query result and a measure of its cost. */ public countCandidates(rect: TTileRect, limit: number): number { - let count = this.unbounded.length + this.large.length; + let count = this.unbounded.length; const visit = (list: TRecord[]): boolean => { count += list.length; return count > limit; diff --git a/packages/graph/src/lib/Tree.subtree.test.ts b/packages/graph/src/lib/Tree.subtree.test.ts new file mode 100644 index 00000000..846b7ddd --- /dev/null +++ b/packages/graph/src/lib/Tree.subtree.test.ts @@ -0,0 +1,117 @@ +import { Tree } from "./Tree"; + +function setup() { + let token = 0; + const root = new Tree({ iterate: () => true }); + root.setCulling({ + tileSize: 64, + getViewport: () => ({ minX: 0, minY: 0, maxX: 100, maxY: 100 }), + getWalkToken: () => token, + fullWalkShare: Infinity, + }); + const add = (parent: Tree, x: number) => { + const node = new Tree({ iterate: () => true }); + node.setBounds(x, x, x + 10, x + 10); + parent.append(node); + return node; + }; + const walk = (blocked?: Tree) => { + token++; + const visited: Tree[] = []; + root.traverseDown((node) => { + if (node !== root) visited.push(node); + return node !== blocked; + }); + return visited; + }; + const settle = (blocked?: Tree) => { + walk(blocked); + walk(blocked); + return walk(blocked); + }; + return { root, add, walk, settle, token: () => token }; +} + +describe("Culling component subtrees", () => { + it("follows descendant bounds while preserving the parent's own bounds", () => { + const { root, add, settle } = setup(); + const parent = add(root, 1000); + const child = add(parent, 2000); + const leaf = add(child, 20); + expect(settle()).toEqual([parent, child, leaf]); + + leaf.setBounds(3000, 3000, 3010, 3010); + expect(settle()).toEqual([]); + parent.setBounds(30, 30, 40, 40); + expect(settle()).toEqual([parent, child, leaf]); + }); + + it("shrinks subtree bounds after removal and child reconciliation", () => { + const { root, add, settle } = setup(); + const parent = add(root, 1000); + const child = add(parent, 20); + expect(settle()).toEqual([parent, child]); + + parent.remove(child); + expect(settle()).toEqual([]); + parent.append(child); + expect(settle()).toEqual([parent, child]); + parent.clearChildren(); + expect(settle()).toEqual([]); + }); + + it("keeps a branch walkable until every descendant reports bounds", () => { + const { root, add, settle } = setup(); + const parent = add(root, 1000); + const child = new Tree({ iterate: () => true }); + parent.append(child); + expect(settle()).toEqual([parent, child]); + + child.setBounds(2000, 2000, 2010, 2010); + expect(settle()).toEqual([]); + }); + + it.each([NaN, Infinity, 3000])("keeps invalid own bounds unbounded (minX: %s)", (minX) => { + const { root, add, settle } = setup(); + const parent = add(root, 1000); + const child = add(parent, 1000); + parent.setBounds(minX, 1000, 2000, 2000); + expect(settle()).toEqual([parent, child]); + + parent.setBounds(1000, 1000, 2000, 2000); + expect(settle()).toEqual([]); + }); + + it("recognizes skipped descendants without admitting removed nodes", () => { + const { root, add, settle, token } = setup(); + const parent = add(root, 1000); + const child = add(parent, 2000); + const leaf = add(child, 3000); + expect(settle()).toEqual([]); + expect(leaf.isSkippedByCulling(token())).toBe(true); + expect(leaf.isSkippedByCulling(token() - 1)).toBe(false); + + parent.remove(child); + expect(leaf.isSkippedByCulling(token())).toBe(false); + }); + + it("preserves intentional child suppression before and after the parent is culled", () => { + const { root, add, walk, settle, token } = setup(); + const parent = add(root, 1000); + const child = add(parent, 2000); + const leaf = add(child, 3000); + walk(); + parent.requestWalk(); + walk(child); + expect(leaf.isSkippedByCulling(token())).toBe(false); + settle(child); + expect(parent.isSkippedByCulling(token())).toBe(true); + expect(child.isSkippedByCulling(token())).toBe(true); + expect(leaf.isSkippedByCulling(token())).toBe(false); + + child.requestWalk(); + walk(); + settle(); + expect(leaf.isSkippedByCulling(token())).toBe(true); + }); +}); diff --git a/packages/graph/src/lib/Tree.ts b/packages/graph/src/lib/Tree.ts index b45c65ad..eb69bc14 100644 --- a/packages/graph/src/lib/Tree.ts +++ b/packages/graph/src/lib/Tree.ts @@ -1,3 +1,4 @@ +import type { TTileRect } from "./TileIndex"; import { TTreeCullingOptions, TreeCulling } from "./TreeCulling"; import { cache } from "./utils"; @@ -15,6 +16,8 @@ export class Tree { private childrenArray: Tree[] = []; + private childrenWalkAllowed = false; + protected childrenDirty = false; protected zIndexGroups: Map> = new Map(); @@ -37,7 +40,9 @@ export class Tree { public zIndex = 1; - /** World-space bounds reported by the node's data for its parent's culling; NaN when unknown. */ + private ownBounds?: TTileRect; + + /** World-space bounds of this node and its descendants; NaN when unknown. */ public boundsMinX = NaN; public boundsMinY = NaN; @@ -62,6 +67,7 @@ export class Tree { this.childrenDirty = true; this.addInZIndex(node); this.culling?.add(node); + this.updateBounds(); } protected addInZIndex(node: Tree) { @@ -91,6 +97,7 @@ export class Tree { this.childrenDirty = true; this.removeZIndex(node); this.culling?.delete(node); + this.updateBounds(); } public setChildren(nodes: Tree[]) { @@ -101,6 +108,7 @@ export class Tree { this.addInZIndex(item); this.culling?.add(item); }); + this.updateBounds(); } /** @@ -119,13 +127,55 @@ export class Tree { this.zIndexChildrenCache.reset(); } - /** Reports the world-space bounds this node draws in to the parent's culling. */ + /** Reports this node's own world-space bounds; culling also includes its descendants. */ public setBounds(minX: number, minY: number, maxX: number, maxY: number): void { + if (!this.ownBounds) this.ownBounds = { minX, minY, maxX, maxY }; + else { + this.ownBounds.minX = minX; + this.ownBounds.minY = minY; + this.ownBounds.maxX = maxX; + this.ownBounds.maxY = maxY; + } + this.updateBounds(); + } + + private updateBounds(): void { + // Containers without their own bounds are always walked, regardless of their children. + if (!this.ownBounds) return; + let { minX, minY, maxX, maxY } = this.ownBounds; + if ( + !Number.isFinite(minX) || + !Number.isFinite(minY) || + !Number.isFinite(maxX) || + !Number.isFinite(maxY) || + minX > maxX || + minY > maxY + ) { + minX = minY = maxX = maxY = NaN; + } else { + for (const child of this.children) { + minX = Math.min(minX, child.boundsMinX); + minY = Math.min(minY, child.boundsMinY); + maxX = Math.max(maxX, child.boundsMaxX); + maxY = Math.max(maxY, child.boundsMaxY); + } + } + if ( + Object.is(this.boundsMinX, minX) && + Object.is(this.boundsMinY, minY) && + Object.is(this.boundsMaxX, maxX) && + Object.is(this.boundsMaxY, maxY) + ) { + return; + } this.boundsMinX = minX; this.boundsMinY = minY; this.boundsMaxX = maxX; this.boundsMaxY = maxY; - this.parent?.culling?.update(this); + if (this.parent?.children.has(this)) { + this.parent.culling?.update(this); + this.parent.updateBounds(); + } } /** Makes every culling ancestor walk the branch with this node in the next traversal. */ @@ -139,15 +189,16 @@ export class Tree { } } - /** - * Whether the parent's culling chose the children to walk in the traversal identified by - * `token` and this node is still a child. A node like that which was not walked was out of view - * with nothing to process. - */ + /** Whether this node was skipped by an ancestor's culling in the traversal identified by `token`. */ public isSkippedByCulling(token: unknown): boolean { - const parent = this.parent; - if (!parent?.culling) return false; - return parent.culling.walkToken === token && parent.children.has(this); + let node: Tree = this; + while (node.parent) { + const parent = node.parent; + if (!parent.children.has(node) || !parent.childrenWalkAllowed) return false; + if (parent.culling?.isSkipped(node, token)) return true; + node = parent; + } + return false; } public updateZIndex(index: number) { @@ -179,6 +230,7 @@ export class Tree { this.zIndexGroups.clear(); this.zIndexChildrenCache.clear(); + this.updateBounds(); } public traverseDown(iterator: TIterator) { @@ -195,7 +247,8 @@ export class Tree { protected _walkDown(iterator: TIterator, order: number) { this.renderOrder = order; - if (iterator(this)) { + this.childrenWalkAllowed = iterator(this); + if (this.childrenWalkAllowed) { if (!this.children.size) { return; } diff --git a/packages/graph/src/lib/TreeCulling.test.ts b/packages/graph/src/lib/TreeCulling.test.ts index 9ea868b3..6e69b2d1 100644 --- a/packages/graph/src/lib/TreeCulling.test.ts +++ b/packages/graph/src/lib/TreeCulling.test.ts @@ -113,6 +113,7 @@ describe("Tree visibility culling", () => { const { add, settle, walk } = setup(); const out = add("out", 1000, 1000); const leaf = new Tree({ name: "leaf", iterate: () => true }); + leaf.setBounds(1000, 1000, 1010, 1010); out.append(leaf); settle(); @@ -165,14 +166,41 @@ describe("Tree visibility culling", () => { expect(walk()).toEqual(["in"]); }); + it.each([false, true])("releases removed children when the container becomes empty (reconcile: %s)", (reconcile) => { + const { root, add, settle, walk } = setup(); + const active = add("active", 10, 10); + const out = add("out", 1000, 1000); + expect(settle()).toEqual(["active"]); + const fresh = add("fresh", 2000, 2000); + fresh.requestWalk(); + + if (reconcile) root.clearChildren(); + for (const node of [active, out, fresh]) root.remove(node); + expect(walk()).toEqual([]); + // An empty tree does not select children again, so retained references must be gone already. + expect(root["culling"]).toMatchObject({ + index: expect.objectContaining({ size: 0 }), + active: [], + fresh: [], + pending: new Set(), + }); + + add("replacement", 3000, 3000); + expect(walk()).toEqual(["replacement"]); + expect(walk()).toEqual(["replacement"]); + expect(walk()).toEqual([]); + }); + it("walks every child without a viewport or when the viewport covers all of them", () => { const { state, add, settle, walk } = setup(); add("a", 10, 10); - add("b", 1000, 1000); + const out = add("b", 1000, 1000); settle(); + expect(out.isSkippedByCulling(state.token)).toBe(true); state.viewport = undefined; expect(walk()).toEqual(["a", "b"]); + expect(out.isSkippedByCulling(state.token)).toBe(false); state.viewport = rect(-10, -10, 2000, 2000); expect(walk()).toEqual(["a", "b"]); @@ -191,6 +219,16 @@ describe("Tree visibility culling", () => { expect(settle()).toEqual(["a", "b"]); }); + it("does not fall back to a full walk when large offscreen children are a minority", () => { + const { add, settle } = setup({ fullWalkShare: 0.75 }); + for (let i = 0; i < 1000; i++) { + add(`out${i}`, 1000 + i * 1024, 1000, 1, i < 400 ? 512 : 10); + } + + // Only the 400 large children are candidates, below the default full-walk threshold of 750. + expect(settle()).toEqual([]); + }); + it("marks out-of-view children as skipped for the traversal that skipped them", () => { const { root, state, add, settle } = setup(); const inView = add("in", 10, 10); @@ -199,8 +237,7 @@ describe("Tree visibility culling", () => { expect(out.isSkippedByCulling(state.token)).toBe(true); expect(out.isSkippedByCulling(state.token - 1)).toBe(false); - // Also true for walked children: the parent walked its children in this traversal. - expect(inView.isSkippedByCulling(state.token)).toBe(true); + expect(inView.isSkippedByCulling(state.token)).toBe(false); root.remove(out); expect(out.isSkippedByCulling(state.token)).toBe(false); diff --git a/packages/graph/src/lib/TreeCulling.ts b/packages/graph/src/lib/TreeCulling.ts index 68e380d6..a80a1b4d 100644 --- a/packages/graph/src/lib/TreeCulling.ts +++ b/packages/graph/src/lib/TreeCulling.ts @@ -48,6 +48,8 @@ export class TreeCulling { private stamp = 0; + private didCull = false; + constructor( private readonly owner: Tree, private readonly options: TTreeCullingOptions @@ -71,6 +73,11 @@ export class TreeCulling { public delete(node: Tree): void { this.index.delete(node); this.pending.delete(node); + // An empty owner skips selection, so it cannot release these references on the next walk. + if (!this.index.size) { + this.active = []; + this.fresh = []; + } } /** Walks the child in the next traversal regardless of its bounds. */ @@ -78,11 +85,16 @@ export class TreeCulling { this.pending.add(node); } + public isSkipped(node: Tree, token: unknown): boolean { + return this.didCull && this.walkToken === token && node.cullingStamp !== this.stamp; + } + /** * Returns the children to walk in draw order, or `undefined` when every child should be walked. * `ordered` is the full draw order; each child's `renderOrder` is its index in it. */ public select(ordered: readonly Tree[]): readonly Tree[] | undefined { + this.didCull = false; this.walkToken = this.options.getWalkToken?.(); const viewport = this.options.getViewport(); // Requests made while the children are walked belong to the next traversal. @@ -97,6 +109,7 @@ export class TreeCulling { return undefined; } + this.didCull = true; const owner = this.owner; const stamp = ++this.stamp; const selected: Tree[] = []; From 531a96e6070681134da8ed2a5056ac123a01f7dd Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 23 Oct 2026 01:45:08 +0300 Subject: [PATCH 04/13] feat(graph): make viewport culling native to layer trees --- .../tests/connection/viewport-culling.spec.ts | 79 +++ .../docs/components/canvas-graph-component.md | 43 +- .../docs/rendering/rendering-mechanism.md | 17 +- .../graph/docs/system/component-lifecycle.md | 42 +- .../system/component-rendering-lifecycle.md | 75 +-- packages/graph/docs/system/graph-settings.md | 5 +- .../graph/docs/system/scheduler-system.md | 7 +- .../components/canvas/GraphComponent/index.ts | 15 +- .../src/components/canvas/anchors/index.ts | 5 - .../src/components/canvas/blocks/Block.ts | 4 +- .../src/components/canvas/blocks/Blocks.ts | 2 - .../canvas/connections/BaseConnection.ts | 2 +- .../connections/BatchPath2D/index.test.ts | 40 ++ .../canvas/connections/BatchPath2D/index.ts | 9 +- .../connections/BezierMultipointConnection.ts | 23 +- .../canvas/connections/BlockConnection.ts | 149 ++++-- .../canvas/connections/BlockConnections.ts | 6 +- .../connections/MultipointConnection.ts | 67 ++- .../connections/arrowVisibility.test.ts | 89 ++++ .../canvas/connections/labelBounds.test.ts | 80 +++ .../canvas/connections/visibility.test.ts | 242 +++++++++ .../components/canvas/groups/BlockGroups.ts | 2 - .../canvas/nativeVisibilityCulling.test.ts | 339 ++++++++++++ .../components/canvas/visibilityCulling.ts | 31 -- packages/graph/src/graphConfig.ts | 7 +- .../graph/src/lib/Component.phases.test.ts | 138 +++++ packages/graph/src/lib/Component.ts | 24 +- packages/graph/src/lib/CoreComponent.ts | 39 +- packages/graph/src/lib/Tree.spec.ts | 20 + packages/graph/src/lib/Tree.subtree.test.ts | 123 ++--- packages/graph/src/lib/Tree.ts | 281 +++++----- packages/graph/src/lib/TreeCulling.test.ts | 484 ++++++++++-------- packages/graph/src/lib/TreeCulling.ts | 149 ++---- packages/graph/src/services/Layer.ts | 2 + .../graph/src/services/visibilityCulling.ts | 28 + packages/graph/src/utils/shapes/triangle.ts | 29 +- 36 files changed, 1936 insertions(+), 761 deletions(-) create mode 100644 apps/e2e/tests/connection/viewport-culling.spec.ts create mode 100644 packages/graph/src/components/canvas/connections/arrowVisibility.test.ts create mode 100644 packages/graph/src/components/canvas/connections/labelBounds.test.ts create mode 100644 packages/graph/src/components/canvas/connections/visibility.test.ts create mode 100644 packages/graph/src/components/canvas/nativeVisibilityCulling.test.ts delete mode 100644 packages/graph/src/components/canvas/visibilityCulling.ts create mode 100644 packages/graph/src/lib/Component.phases.test.ts create mode 100644 packages/graph/src/services/visibilityCulling.ts diff --git a/apps/e2e/tests/connection/viewport-culling.spec.ts b/apps/e2e/tests/connection/viewport-culling.spec.ts new file mode 100644 index 00000000..c702e845 --- /dev/null +++ b/apps/e2e/tests/connection/viewport-culling.spec.ts @@ -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 { + 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); + }); +} diff --git a/packages/graph/docs/components/canvas-graph-component.md b/packages/graph/docs/components/canvas-graph-component.md index 78add9c3..e6b0f6cb 100644 --- a/packages/graph/docs/components/canvas-graph-component.md +++ b/packages/graph/docs/components/canvas-graph-component.md @@ -80,25 +80,30 @@ 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. **Visualization of the process:** ``` @@ -356,7 +361,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 @@ -365,9 +370,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() @@ -912,9 +918,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' }), @@ -1138,4 +1141,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. \ No newline at end of file +By leveraging GraphComponent's core capabilities, you can build sophisticated, high-performance graph visualizations tailored to your specific use case. diff --git a/packages/graph/docs/rendering/rendering-mechanism.md b/packages/graph/docs/rendering/rendering-mechanism.md index a09ad12c..66cc6fea 100644 --- a/packages/graph/docs/rendering/rendering-mechanism.md +++ b/packages/graph/docs/rendering/rendering-mechanism.md @@ -21,6 +21,7 @@ The `Tree` class provides the foundational hierarchy structure with: - Parent-child relationship management - Z-index based ordering of children - Traversal methods for walking the tree +- One shared tile index per layer tree for viewport selection ### CoreComponent Class @@ -53,9 +54,9 @@ The rendering process follows these steps: 1. Component state/props update via `setState`/`setProps` 2. `performRender()` schedules an update through the `Scheduler` 3. On the next animation frame, `Scheduler` traverses the component tree -4. Each component's `iterate()` method is called -5. Components process their data and execute their lifecycle methods -6. Child components are created, updated, or removed as needed +4. Tree calls `prepareUpdate()` on visible, new, or dirty components and their ancestors +5. Components apply data and reconcile children before the tree resolves final viewport membership +6. `iterate()` paints visible components and completes prepared updates; clean offscreen nodes are skipped 7. The process repeats for each animation frame when changes occur ## Component Lifecycle @@ -99,9 +100,11 @@ This value is set per component class in `graphConfig.ts` and changed via `updat ### 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`. +`renderOrder` determines stacking *within the same zIndex tier*. Tree assigns every child's order +when rebuilding its parent's sorted child cache, including children outside the viewport. -`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. +`Tree.updateZIndex()` moves a child between its parent's z-index groups; `updateChildZIndex()` +re-inserts it at the end of its current group. Group insertion order determines stacking within a tier. This means drag-and-drop and selection automatically bring the interacted block to the front of its z-group without any extra bookkeeping. @@ -136,7 +139,9 @@ The rendering mechanism integrates with the browser's animation frame: - `constructor(props, parent)` - Create a component instance - `performRender()` - Schedule a render update - `setContext(context)` - Update the context -- `iterate()` - Process component updates +- `prepareUpdate()` - Apply pending data and reconcile children before painting +- `iterate()` - Paint eligible components and complete the update +- `isInViewport()` - Read the tree's current spatial visibility decision - `static create(props, options)` - Factory method - `static mount(Component, props)` - Mount a component as root - `static unmount(instance)` - Unmount a component diff --git a/packages/graph/docs/system/component-lifecycle.md b/packages/graph/docs/system/component-lifecycle.md index a462fc0f..d336d5b3 100644 --- a/packages/graph/docs/system/component-lifecycle.md +++ b/packages/graph/docs/system/component-lifecycle.md @@ -40,11 +40,12 @@ required state in the constructor, as in the [basic example](#basic-component). ### 2. CoreComponent Lifecycle -The CoreComponent provides the foundation with three main methods: +The CoreComponent provides the foundation with four main methods: | Method | Purpose | When Called | |--------|---------|-------------| -| `iterate()` | Manages the update cycle | Each frame | +| `prepareUpdate()` | Prepares data and children before viewport selection | For visible, new, or dirty components | +| `iterate()` | Completes the update and paints eligible components | After preparation | | `updateChildren()` | Defines and updates child components | During children update | | `render()` | Handles visual rendering | During render phase | @@ -56,29 +57,40 @@ The Component class extends CoreComponent with additional lifecycle hooks: #### Method Sequence -During component iteration, methods are called in this order: +Each layer tree prepares selected components before painting them. New or dirty components outside +the viewport still prepare and complete their update; clean offscreen components are skipped. +Preparation is idempotent within an iteration, including when `iterate()` is called directly. + +Methods are called in this order: 1. First iteration only: `willMount()` runs inside `willIterate`, after `checkData`. State queued - there is applied on the next iteration, so the first render sees the constructor state. + there is applied on the next iteration. Mounting does not depend on the first render. 2. Every Iteration: ```mermaid flowchart TD A[checkData] --> B[willIterate] - B --> C{shouldRender?} + B --> H{shouldUpdateChildren?} + H -->|Yes| I[willUpdateChildren] + I --> J[updateChildren] + J --> K[didUpdateChildren] + H -->|No| V[Tree resolves viewport selection] + K --> V + V --> C{shouldRender and in viewport?} C -->|Yes| D[willRender] D --> E[render] E --> F[didRender] C -->|No| G[willNotRender] - G --> H{shouldUpdateChildren?} - F --> H - H -->|Yes| I[willUpdateChildren] - I --> J[updateChildren] - J --> K[didUpdateChildren] - H -->|No| L[didIterate] - K --> L + G --> L[didIterate] + F --> L ``` +Children and their geometry are prepared before the layer draws. Keep initialization, port +registration, and hit-box updates in constructors or update hooks: `willRender`, `render`, and +`didRender` are never called for a component whose known bounds are outside the viewport. +`firstRender` stays true until the component is actually painted; use `firstIterate` for mount-related +work. A visible child can be painted even when its parent's own bounds are offscreen. + ### 4. State and Props Management When state or props change: @@ -90,7 +102,7 @@ When state or props change: | 3 | Update scheduled | `performRender()` | | 4 | Changes processed | `checkData()` | | 5 | Callbacks triggered | `propsChanged()` or `stateChanged()` | -| 6 | Render evaluated | Based on `shouldRender` flag | +| 6 | Render evaluated | Based on `shouldRender` and the tree's viewport selection | ### 5. Control Flags @@ -103,7 +115,7 @@ Components use several boolean flags to control their behavior: | `firstUpdateChildren` | Tracks first children update | `true` | | `shouldRender` | Controls render() call | `true` | | `shouldUpdateChildren` | Controls children updates | `true` | -| `shouldRenderChildren` | Controls children rendering | `true` | +| `shouldRenderChildren` | Explicitly allows or suppresses traversal of descendants | `true` | ## Examples @@ -120,7 +132,7 @@ class MyComponent extends Component { } protected willMount() { - // Subscribe here. setState called here applies on the next iteration, after the first render. + // Subscribe here. setState called here applies on the next iteration, even while offscreen. } protected render() { diff --git a/packages/graph/docs/system/component-rendering-lifecycle.md b/packages/graph/docs/system/component-rendering-lifecycle.md index 75b80009..93c919d2 100644 --- a/packages/graph/docs/system/component-rendering-lifecycle.md +++ b/packages/graph/docs/system/component-rendering-lifecycle.md @@ -26,28 +26,33 @@ The following sequence diagram shows how these systems interact during a typical sequenceDiagram participant Browser participant Scheduler + participant Tree participant Component participant Rendering Browser->>Scheduler: requestAnimationFrame Scheduler->>Scheduler: tick() - Scheduler->>Component: iterate() + Scheduler->>Tree: traverseDown() + Tree->>Component: prepareUpdate() for visible, new, or dirty nodes Component->>Component: checkData() Component->>Component: willIterate() - alt shouldRender is true - Component->>Component: willRender() - Component->>Rendering: render() - Component->>Component: didRender() - else shouldRender is false - Component->>Component: willNotRender() - end - alt shouldUpdateChildren is true Component->>Component: willUpdateChildren() Component->>Component: updateChildren() Component->>Component: didUpdateChildren() end + + Tree->>Tree: Resolve own bounds and visible descendants + Tree->>Component: onViewportChange() when membership changes + Tree->>Component: iterate() in local z-index order + alt shouldRender and in viewport + Component->>Component: willRender() + Component->>Rendering: render() + Component->>Component: didRender() + else Offscreen or explicitly hidden + Component->>Component: willNotRender() + end Component->>Component: didIterate() Scheduler->>Browser: Next animation frame @@ -63,30 +68,27 @@ When a component's state or props change: 2. `performRender()` schedules an update with the `Scheduler` 3. The `Scheduler` marks the component tree for update 4. On the next animation frame, the `Scheduler` traverses the component tree -5. Each component's `iterate()` method is called -6. The component processes its lifecycle methods based on its current state +5. The tree prepares visible, new, or dirty components and their ancestors +6. After geometry and children are current, `iterate()` paints eligible components and completes updates From `scheduler-system.md`: > Component calls `performRender()` to schedule an update. This method sets the `scheduled` flag in the component's `Scheduler` instance, indicating that the component tree needs to be updated. -From `component-lifecycle.md`: -> When state or props change, the system follows this sequence: User->Component: setState/setProps, Component->Buffer: Store in __data, Component->Scheduler: performRender(), Scheduler->Component: iterate() on next frame +The pending node remains eligible for preparation even outside the viewport, so spatial culling does +not discard state or props updates. ### 2. Component Lifecycle and Rendering The component lifecycle controls the rendering process through several key methods: -1. `iterate()` - The main entry point called by the scheduler +1. `prepareUpdate()` - Applies data and reconciles children before painting 2. `checkData()` - Processes any pending state/props changes -3. `shouldRender` flag - Controls whether rendering occurs -4. `render()` - Creates the visual output when needed +3. `shouldRender` flag and `isInViewport()` - Together control whether rendering occurs +4. `iterate()` / `render()` - Complete the update and create eligible visual output 5. `shouldUpdateChildren` flag - Controls whether children are updated -From `rendering-mechanism.md`: -> The rendering process follows these steps: Component state/props update via `setState`/`setProps`, `performRender()` schedules an update through the `Scheduler`, On the next animation frame, `Scheduler` traverses the component tree, Each component's `iterate()` method is called, Components process their data and execute their lifecycle methods, Child components are created, updated, or removed as needed - -From `component-lifecycle.md`: -> The CoreComponent has three main methods: `iterate()` - Called on each frame, manages the update cycle, `updateChildren()` - Defines and updates child components, `render()` - Handles the visual rendering of the component +See [Component Lifecycle](./component-lifecycle.md) for the hook order and the distinction between +`firstIterate` and `firstRender`. ### 3. Tree Structure and Z-Index @@ -94,6 +96,17 @@ All three systems interact with the Tree structure, which: - Maintains the parent-child relationships between components - Manages the z-index ordering for proper rendering - Provides traversal methods for the scheduler +- Owns one `TileIndex` per layer root, shared by every descendant + +The layer supplies its camera's world-space viewport; Tree and Scheduler do not depend on Camera. +Each component reports its own bounds. The tree selects intersecting nodes plus the ancestors needed +to reach them, so an offscreen parent does not hide a visible child. Unknown bounds remain eligible. +New and dirty offscreen nodes still update, but never enter the painting lifecycle. Setting +`shouldRenderChildren` to false explicitly suppresses a branch, independently of spatial culling. + +Visibility changes are published before the layer paints, allowing connection batches to invalidate +affected chunks before drawing. Both the component lifecycle and batch renderer use the same tree +selection. Hit testing retains its separate spatial index and supports queries outside the viewport. From `scheduler-system.md`: > During an update, the Scheduler traverses the component tree in depth-first order: Tree._walkDown, Iterator returns true/false, Process/Skip children @@ -115,24 +128,20 @@ Combining all three documents, we can map the complete lifecycle from state chan - GlobalScheduler manages animation frame timing - On the next frame, scheduler traverses the component tree -3. **Component Processing** - - Component's `iterate()` method is called - - For first iteration only, `willMount()` is called - - `willIterate()` prepares for the update +3. **Preparation** + - Component's `prepareUpdate()` method is called - `checkData()` processes buffered state/props changes - `propsChanged()` or `stateChanged()` handle specific changes + - `willIterate()` runs, including `willMount()` on the first iteration + - If `shouldUpdateChildren` is true, children are reconciled before painting + - Tree resolves the resulting bounds and viewport membership 4. **Rendering Decision** - - Component evaluates `shouldRender` flag + - Component evaluates `shouldRender` and the tree's viewport selection - If true: `willRender()` → `render()` → `didRender()` - If false: `willNotRender()` -5. **Children Management** - - Component evaluates `shouldUpdateChildren` flag - - If true: `willUpdateChildren()` → `updateChildren()` → `didUpdateChildren()` - - Child components are created, updated, or removed - -6. **Completion** +5. **Completion** - `didIterate()` finalizes the component update - Scheduler moves to the next component or completes the frame - Browser renders the updated view @@ -207,4 +216,4 @@ Understanding these connections is essential for effectively using and extending - [Component Lifecycle](./component-lifecycle.md) - In-depth details about component lifecycle methods - [Rendering Mechanism](../rendering/rendering-mechanism.md) - Architectural details of the rendering system -- [Scheduler System](./scheduler-system.md) - Explanation of the scheduling and update system \ No newline at end of file +- [Scheduler System](./scheduler-system.md) - Explanation of the scheduling and update system diff --git a/packages/graph/docs/system/graph-settings.md b/packages/graph/docs/system/graph-settings.md index 819dd624..40d9d6c3 100644 --- a/packages/graph/docs/system/graph-settings.md +++ b/packages/graph/docs/system/graph-settings.md @@ -72,7 +72,7 @@ These settings control the interactive behaviors and visual styles of the graph. | `useBezierConnections` | boolean | `true` | Use bezier curves for connections instead of straight lines | | `bezierConnectionDirection` | 'vertical' \| 'horizontal' | `'horizontal'` | Direction of bezier connections | | `EXP_USE_TIGHT_BEZIER_BOUNDS` | boolean | `true` | Experimental: use tight bounds of the rendered Bezier curve; set to `false` to use the legacy control-point bounds | -| `EXP_VISIBILITY_CULLING` | boolean | `true` | Experimental: each frame walks only the blocks, connections and groups near the viewport, found through a world-space tile index; set to `false` to walk every component | +| `EXP_VISIBILITY_CULLING` | boolean | `true` | Experimental: each layer tree selects visible and changed components through one world-space tile index; set to `false` for a full traversal. Both modes exclude offscreen components from painting | | `useBlocksAnchors` | boolean | `true` | Controls whether blocks should display anchors for connections | | `showConnectionLabels` | boolean | `false` | Controls visibility of connection labels | | `blockComponents` | Record | `{}` | Registry of custom block components. The built-in renderer constructs them with `id`, `initialIndex` and `font` only: put custom data into the block's `meta`, or give extra props defaults in the constructor. | @@ -144,8 +144,7 @@ Constants control the sizing, spacing, and other numerical values used throughou | `GRID_SIZE` | `16` | Base grid size for layout calculations | | `USABLE_RECT_GAP` | `400` | Gap around the usable rect area | | `CAMERA_VIEWPORT_TRESHOLD` | `0.5` | Threshold for camera viewport | -| `VISIBILITY_TILE_SIZE` | `1024` | Edge of a visibility tile in world units (see `EXP_VISIBILITY_CULLING`); read when a container mounts | -| `VISIBILITY_CULLING_MARGIN` | `0.1` | Extra walked area around the viewport for visibility culling, as a fraction of the viewport size per side | +| `VISIBILITY_TILE_SIZE` | `1024` | Edge of a visibility tile in world units (see `EXP_VISIBILITY_CULLING`); read when a layer is constructed | ### Camera Constants diff --git a/packages/graph/docs/system/scheduler-system.md b/packages/graph/docs/system/scheduler-system.md index 9713d014..7092c7d7 100644 --- a/packages/graph/docs/system/scheduler-system.md +++ b/packages/graph/docs/system/scheduler-system.md @@ -104,6 +104,7 @@ sequenceDiagram participant GlobalScheduler participant Graph participant Scheduler + participant Tree participant Component Browser->>GlobalScheduler: requestAnimationFrame @@ -113,6 +114,8 @@ sequenceDiagram Graph->>Scheduler: performUpdate() for each layer Scheduler->>Scheduler: update() Scheduler->>Tree: traverseDown(iterator) + Tree->>Component: prepareUpdate() for selected components + Tree->>Tree: Resolve viewport selection Tree->>Component: iterate() Component->>Component: process lifecycle Browser->>GlobalScheduler: requestAnimationFrame (next frame) @@ -198,7 +201,7 @@ The scheduling system coordinates when component updates happen: 2. This sets a `scheduled` flag in the Scheduler 3. On the next animation frame, the graph's frame task updates the trees of its layers that have the flag set 4. The Scheduler traverses the component tree in a depth-first order -5. Each component's `iterate()` method is called during traversal +5. In layer trees, visible, new, or dirty components prepare their data and children before `iterate()` paints eligible nodes; clean offscreen nodes are skipped 6. Components can control whether their children are processed by returning true/false from `iterate()` ## Priority Levels @@ -604,4 +607,4 @@ Debugging issues with the scheduler can be challenging, but there are several te - [Component Lifecycle](./component-lifecycle.md) - In-depth details about component lifecycle methods - [Rendering Mechanism](../rendering/rendering-mechanism.md) - Architectural details of the rendering system -- [Component Rendering and Lifecycle Integration](./component-rendering-lifecycle.md) - Comprehensive guide showing how these systems work together \ No newline at end of file +- [Component Rendering and Lifecycle Integration](./component-rendering-lifecycle.md) - Comprehensive guide showing how these systems work together diff --git a/packages/graph/src/components/canvas/GraphComponent/index.ts b/packages/graph/src/components/canvas/GraphComponent/index.ts index b8604b88..0eaf7c2e 100644 --- a/packages/graph/src/components/canvas/GraphComponent/index.ts +++ b/packages/graph/src/components/canvas/GraphComponent/index.ts @@ -154,7 +154,7 @@ export class GraphComponent< protected contextChanged(_nextContext: Context): void { // If affectsUsableRect changed in context and there's no explicit prop override if ( - this.firstRender || + this.firstIterate || (this.context.affectsUsableRect !== _nextContext.affectsUsableRect && this.props.affectsUsableRect === undefined) ) { this.hitBox.setAffectsUsableRect(this.props.affectsUsableRect ?? _nextContext.affectsUsableRect ?? true); @@ -359,20 +359,19 @@ export class GraphComponent< public setHitBox(minX: number, minY: number, maxX: number, maxY: number, force?: boolean) { this.hitBox.update(minX, minY, maxX, maxY, force); - // Culling includes this hit box and the bounds reported by descendants. + // Every component reports its own bounds to the layer's shared index. if (!this.hitBox.destroyed) this.setCullingBounds(minX, minY, maxX, maxY); } - protected willIterate(): void { - super.willIterate(); - if (!this.firstIterate) { - this.shouldRender = this.isVisible(); - } + public override iterate(): boolean { + this.prepareUpdate(); + this.shouldRender = this.isVisible(); + return super.iterate(); } protected isVisible(): boolean { if (this.hidden) return false; - return this.context.camera.isRectVisible(...this.getHitBox()); + return this.isInViewport(); } protected getHitBoxRect(): TRect { diff --git a/packages/graph/src/components/canvas/anchors/index.ts b/packages/graph/src/components/canvas/anchors/index.ts index 02881806..c11879e8 100644 --- a/packages/graph/src/components/canvas/anchors/index.ts +++ b/packages/graph/src/components/canvas/anchors/index.ts @@ -161,11 +161,6 @@ export class Anchor extends GraphComponen this.connectedState.block.getViewComponent()?.handleDragEnd(context); } - protected isVisible() { - const params = this.getHitBox(); - return params ? this.context.camera.isRectVisible(...params) : true; - } - protected unmount() { this.props.port.removeOwner(); this.connectedState.unsetViewComponent(); diff --git a/packages/graph/src/components/canvas/blocks/Block.ts b/packages/graph/src/components/canvas/blocks/Block.ts index fadd28ad..3ddf6d10 100644 --- a/packages/graph/src/components/canvas/blocks/Block.ts +++ b/packages/graph/src/components/canvas/blocks/Block.ts @@ -147,7 +147,7 @@ export class Block) { @@ -269,7 +269,7 @@ export class Block this.context.camera)); this.unsubscribe = this.subscribe(); this.font = this.prepareFont(this.getFontScale()); diff --git a/packages/graph/src/components/canvas/connections/BaseConnection.ts b/packages/graph/src/components/canvas/connections/BaseConnection.ts index fefc46be..d65d1c97 100644 --- a/packages/graph/src/components/canvas/connections/BaseConnection.ts +++ b/packages/graph/src/components/canvas/connections/BaseConnection.ts @@ -161,7 +161,7 @@ export class BaseConnection< }); // When both endpoints are hidden (e.g. both blocks inside a collapsed group) - // schedule a re-render so willIterate() picks up the new isVisible() result. + // Schedule an update so component and batch visibility use the new hidden state. this.subscribeSignal(this.connectedState.$hidden, () => { this.performRender(); }); diff --git a/packages/graph/src/components/canvas/connections/BatchPath2D/index.test.ts b/packages/graph/src/components/canvas/connections/BatchPath2D/index.test.ts index f432939f..049328f7 100644 --- a/packages/graph/src/components/canvas/connections/BatchPath2D/index.test.ts +++ b/packages/graph/src/components/canvas/connections/BatchPath2D/index.test.ts @@ -24,3 +24,43 @@ test("stable frames reuse visibility and paths; markDirty refreshes availability batch.orderedPaths.get().forEach((group) => group.render(ctx)); expect(item.getPath).toHaveBeenCalledTimes(2); }); + +test("visibility transitions reset only their chunk without scheduling another frame", () => { + const ctx = document.createElement("canvas").getContext("2d"); + if (!ctx) throw new Error("Expected canvas context"); + let visible = true; + const affected = { + isPathVisible: () => visible, + getPath: jest.fn(() => new Path2D()), + style: jest.fn(() => ({ type: "stroke" as const })), + afterRender: jest.fn(), + }; + const unaffected = { + getPath: jest.fn(() => new Path2D()), + style: () => ({ type: "stroke" as const }), + }; + const schedule = jest.fn(); + const batch = new BatchPath2DRenderer(schedule, 1); + batch.add(affected, { zIndex: 0, group: "connections" }); + batch.add(unaffected, { zIndex: 0, group: "connections" }); + const render = () => batch.orderedPaths.get().forEach((group) => group.render(ctx)); + render(); + schedule.mockClear(); + affected.style.mockClear(); + affected.afterRender.mockClear(); + + visible = false; + batch.invalidateVisibility(affected); + render(); + expect(affected.style).not.toHaveBeenCalled(); + expect(affected.afterRender).not.toHaveBeenCalled(); + expect(unaffected.getPath).toHaveBeenCalledTimes(1); + expect(schedule).not.toHaveBeenCalled(); + + visible = true; + batch.invalidateVisibility(affected); + render(); + expect(affected.getPath).toHaveBeenCalledTimes(2); + expect(unaffected.getPath).toHaveBeenCalledTimes(1); + expect(schedule).not.toHaveBeenCalled(); +}); diff --git a/packages/graph/src/components/canvas/connections/BatchPath2D/index.ts b/packages/graph/src/components/canvas/connections/BatchPath2D/index.ts index 44c01106..1012e366 100644 --- a/packages/graph/src/components/canvas/connections/BatchPath2D/index.ts +++ b/packages/graph/src/components/canvas/connections/BatchPath2D/index.ts @@ -62,7 +62,7 @@ class Path2DChunk { if (style) { const p = this.path.get(); if (style.type === "fill" || style.type === "both") { - ctx.fill(p, style.fillRule); + ctx.fill(p, style.fillRule ?? "nonzero"); } if (style.type === "stroke" || style.type === "both") { ctx.stroke(p); @@ -205,11 +205,16 @@ export class BatchPath2DRenderer { } public markDirty(item: Path2DRenderInstance) { + this.invalidateVisibility(item); + if (this.itemParams.has(item)) this.requestRender(); + } + + /** Visibility is finalized during the current traversal, before this batch draws. */ + public invalidateVisibility(item: Path2DRenderInstance) { const params = this.itemParams.get(item); if (params) { const group = this.getGroup(params.zIndex, params.group); group.resetItem(item); - this.requestRender(); } } } diff --git a/packages/graph/src/components/canvas/connections/BezierMultipointConnection.ts b/packages/graph/src/components/canvas/connections/BezierMultipointConnection.ts index b61c5ddb..c28b159d 100644 --- a/packages/graph/src/components/canvas/connections/BezierMultipointConnection.ts +++ b/packages/graph/src/components/canvas/connections/BezierMultipointConnection.ts @@ -1,5 +1,6 @@ +import { TConnectionProps } from "./BlockConnection"; import { MultipointConnection } from "./MultipointConnection"; -import { bezierCurveLine, generateBezierParams } from "./bezierHelpers"; +import { bezierCurveLine, generateBezierParams, getBezierCurveBounds } from "./bezierHelpers"; /** * Multipoint connection that draws segments as Bezier curves and keeps straight @@ -7,6 +8,26 @@ import { bezierCurveLine, generateBezierParams } from "./bezierHelpers"; * From commit a90f9c65 (ytsaurus-ui) — alternative line representation for layout graphs. */ export class BezierMultipointConnection extends MultipointConnection { + protected override collectBBoxPoints(props: TConnectionProps = this.props) { + const bounds = super.collectBBoxPoints(props); + const points = this.getPoints(); + for (let i = 1; i < points.length; i++) { + const start = points[i - 1]; + const end = points[i]; + if (points.length > 2 && (props.bezierDirection === "vertical" ? start.x === end.x : start.y === end.y)) { + continue; + } + if (props.useTightBezierBounds === false) { + const [, first, second] = generateBezierParams(start, end, props.bezierDirection); + bounds.push(first, second); + } else { + const { x, y, width, height } = getBezierCurveBounds(start, end, props.bezierDirection); + bounds.push({ x, y }, { x: x + width, y: y + height }); + } + } + return bounds; + } + public override createPath(): Path2D { const points = this.getPoints(); const direction = this.props.bezierDirection; diff --git a/packages/graph/src/components/canvas/connections/BlockConnection.ts b/packages/graph/src/components/canvas/connections/BlockConnection.ts index 1dbc4184..a5ad17fc 100644 --- a/packages/graph/src/components/canvas/connections/BlockConnection.ts +++ b/packages/graph/src/components/canvas/connections/BlockConnection.ts @@ -45,6 +45,8 @@ export class BlockConnection protected labelGeometry: { x: number; y: number; width: number; height: number } | undefined = undefined; + private labelFont = ""; + protected geometry: { x1: number; x2: number; y1: number; y2: number } = { x1: 0, x2: 0, y1: 0, y2: 0 }; /** @@ -97,6 +99,9 @@ export class BlockConnection protected override willMount(): void { super.willMount(); this.subscribeSignal(this.connectedState.$hidden, () => this.applyShape()); + this.subscribeSignal(this.context.connectionLabelScale, () => { + if (this.state.label && this.props.showConnectionLabels) this.updatePoints(); + }); } protected override setVisibility(visible: boolean, options: { removeHitbox: boolean }): void { @@ -105,7 +110,18 @@ export class BlockConnection } public isPathVisible(): boolean { - return this.isEntityAvailable() && this.hasGeometry() && !this.hidden && !this.connectedState.$hidden.value; + return ( + this.isInViewport() && + this.isEntityAvailable() && + this.hasGeometry() && + !this.hidden && + !this.connectedState.$hidden.value + ); + } + + public override onViewportChange(): void { + this.context.batch.invalidateVisibility(this); + this.context.batch.invalidateVisibility(this.arrowShape); } public getPath(): Path2D { @@ -119,19 +135,28 @@ export class BlockConnection * @returns A Path2D object representing the arrow shape */ public createArrowPath() { + const path = new Path2D(); + const [first, ...points] = this.getArrowPoints(); + if (!first) return path; + path.moveTo(first.x, first.y); + for (const point of points) path.lineTo(point.x, point.y); + return path; + } + + protected getArrowPoints(props: TConnectionProps = this.props): TPoint[] { const coords = getArrowCoords( - this.props.useBezier, + props.useBezier, this.geometry.x1, this.geometry.y1, this.geometry.x2, this.geometry.y2, - this.props.bezierDirection + props.bezierDirection ); - const path = new Path2D(); - path.moveTo(coords[0], coords[1]); - path.lineTo(coords[2], coords[3]); - path.lineTo(coords[4], coords[5]); - return path; + return [ + { x: coords[0], y: coords[1] }, + { x: coords[2], y: coords[3] }, + { x: coords[4], y: coords[5] }, + ]; } public styleArrow(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined { @@ -225,12 +250,19 @@ export class BlockConnection this.applyShape(nextState); } + protected override checkData(): boolean { + const changed = super.checkData(); + if (changed) this.updatePoints(); + return changed; + } + public get zIndex() { return this.context.constants.connection.DEFAULT_Z_INDEX; } protected override collectBBoxPoints(props: TConnectionProps = this.props) { const points = super.collectBBoxPoints(props); + this.updateLabelGeometry(props); if (this.labelGeometry) { points.push( @@ -269,17 +301,33 @@ export class BlockConnection protected override updatePoints(additionalPoints?: TPoint[], props: TConnectionProps = this.props) { super.updatePoints(additionalPoints, props); - if (!this.connectionPoints) { - this.applyShape(this.state, props); - return; + if (this.connectionPoints) { + this.geometry.x1 = this.connectionPoints[0].x; + this.geometry.y1 = this.connectionPoints[0].y; + this.geometry.x2 = this.connectionPoints[1].x; + this.geometry.y2 = this.connectionPoints[1].y; } - this.geometry.x1 = this.connectionPoints[0].x; - this.geometry.y1 = this.connectionPoints[0].y; - this.geometry.x2 = this.connectionPoints[1].x; - this.geometry.y2 = this.connectionPoints[1].y; - this.generatePath(); + if (this.hasGeometry()) { + const padding = this.state.hovered || this.state.selected ? 2 : 1; + const [left, top, right, bottom] = this.getBBox(); + let minX = left - padding; + let minY = top - padding; + let maxX = right + padding; + let maxY = bottom + padding; + if (props.showConnectionArrows) { + // A sharp arrow join can extend past its vertices by the canvas miter limit. + const arrowPadding = padding * this.context.ctx.miterLimit; + for (const point of this.getArrowPoints(props)) { + minX = Math.min(minX, point.x - arrowPadding); + minY = Math.min(minY, point.y - arrowPadding); + maxX = Math.max(maxX, point.x + arrowPadding); + maxY = Math.max(maxY, point.y + arrowPadding); + } + } + this.setCullingBounds(minX, minY, maxX, maxY); + } this.applyShape(this.state, props); } @@ -328,32 +376,12 @@ export class BlockConnection } protected renderLabelText(ctx: CanvasRenderingContext2D) { - if (!this.isVisible() || !this.state.label) { - return; - } - - const [labelInnerTopPadding, labelInnerRightPadding, labelInnerBottomPadding, 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) { + if (!this.state.label || !this.labelGeometry) { 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 - ); + const [labelInnerTopPadding, , , labelInnerLeftPadding] = this.context.constants.connection.LABEL.INNER_PADDINGS; + const { x: rectX, y: rectY, width: rectWidth, height: rectHeight } = this.labelGeometry; if (this.context.colors.connectionLabel?.background) { ctx.fillStyle = this.context.colors.connectionLabel.background; @@ -367,18 +395,6 @@ export class BlockConnection 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); if (this.context.colors.connectionLabel?.text) { @@ -394,11 +410,40 @@ export class BlockConnection } ctx.textBaseline = "top"; - ctx.font = font; + ctx.font = this.labelFont; ctx.textAlign = "left"; ctx.fillText(this.state.label, rectX + labelInnerLeftPadding, rectY + labelInnerTopPadding); } + protected updateLabelGeometry(props: TConnectionProps): void { + this.labelGeometry = undefined; + const scale = this.context.camera.getCameraScale(); + if ( + !this.state.label || + !props.showConnectionLabels || + !this.connectionPoints || + scale < this.context.constants.connection.MIN_ZOOM_FOR_CONNECTION_ARROW_AND_LABEL + ) { + return; + } + const [top, right, bottom, left] = this.context.constants.connection.LABEL.INNER_PADDINGS; + this.labelFont = `${Math.max(14, getFontSize(9, scale))}px sans-serif`; + const measure = cachedMeasureText(this.state.label, { font: this.labelFont }); + const width = measure.width + left + right; + const height = measure.height + top + bottom; + const [source, target] = this.connectionPoints; + const { x, y } = getLabelCoords( + source.x, + source.y, + target.x, + target.y, + width, + height, + this.context.constants.system.GRID_SIZE + ); + this.labelGeometry = { x, y, width, height }; + } + public getStrokeColor(state: TConnection) { if (state.selected) return state.styles?.selectedBackground || this.context.colors.connection?.selectedBackground; diff --git a/packages/graph/src/components/canvas/connections/BlockConnections.ts b/packages/graph/src/components/canvas/connections/BlockConnections.ts index 8749c0d4..e6497b43 100644 --- a/packages/graph/src/components/canvas/connections/BlockConnections.ts +++ b/packages/graph/src/components/canvas/connections/BlockConnections.ts @@ -1,16 +1,18 @@ +import { ReadonlySignal, computed } from "@preact/signals-core"; + import { Component, TComponentState } from "../../../lib/Component"; import { CoreComponentProps } from "../../../lib/CoreComponent"; import { ESchedulerPriority } from "../../../lib/Scheduler"; import { ConnectionState } from "../../../store/connection/ConnectionState"; import { debounce } from "../../../utils/utils/schedule"; import { TGraphLayerContext } from "../layers/graphLayer/GraphLayer"; -import { createViewportCulling } from "../visibilityCulling"; import { BatchPath2DRenderer } from "./BatchPath2D"; import { BlockConnection, TConnectionProps } from "./BlockConnection"; export type TGraphConnectionsContext = TGraphLayerContext & { batch: BatchPath2DRenderer; + connectionLabelScale: ReadonlySignal; }; export class BlockConnections extends Component { @@ -39,7 +41,6 @@ export class BlockConnections extends Component this.context.camera)); this.batch = new BatchPath2DRenderer( () => this.performRender(), this.context.constants.connection.PATH2D_CHUNK_SIZE || 100 @@ -47,6 +48,7 @@ export class BlockConnections extends Component this.context.graph.$camera.value.scale), }); } diff --git a/packages/graph/src/components/canvas/connections/MultipointConnection.ts b/packages/graph/src/components/canvas/connections/MultipointConnection.ts index 8ac929bd..48bba6cb 100644 --- a/packages/graph/src/components/canvas/connections/MultipointConnection.ts +++ b/packages/graph/src/components/canvas/connections/MultipointConnection.ts @@ -2,7 +2,7 @@ import intersects from "intersects"; import { HitBoxData } from "../../../services/HitTest"; import { curvePolyline } from "../../../utils/shapes/curvePolyline"; -import { trangleArrowForVector } from "../../../utils/shapes/triangle"; +import { getTriangleArrowPoints } from "../../../utils/shapes/triangle"; import { TPoint, TRect } from "../../../utils/types/shapes"; import { Path2DRenderStyleResult } from "./BatchPath2D"; @@ -13,7 +13,7 @@ import type { TMultipointConnection } from "./types"; const DEFAULT_FONT_SIZE = 14; export class MultipointConnection extends BlockConnection { - private labelsGeometry: { x: number; y: number; width: number; height: number }[] = []; + private labelsGeometry: { x: number; y: number; width: number; height: number; text: string }[] = []; protected override hasGeometry(): boolean { const count = this.getPoints().length; @@ -21,7 +21,15 @@ export class MultipointConnection extends BlockConnection } protected override collectBBoxPoints(props: TConnectionProps = this.props) { - return [...this.getPoints(), ...super.collectBBoxPoints(props)]; + const points = [...this.getPoints(), ...super.collectBBoxPoints(props)]; + const [top, right, bottom, left] = this.context.constants.connection.LABEL.INNER_PADDINGS; + for (const label of this.labelsGeometry) { + points.push( + { x: label.x - left, y: label.y - top }, + { x: label.x + label.width + right, y: label.y + label.height + bottom } + ); + } + return points; } public createPath() { @@ -32,12 +40,18 @@ export class MultipointConnection extends BlockConnection } public createArrowPath(): Path2D { + const path = super.createArrowPath(); + if (this.getPoints().length >= 2) path.closePath(); + return path; + } + + protected override getArrowPoints(props: TConnectionProps = this.props): TPoint[] { const points = this.getPoints(); - if (points.length === 0) return super.createArrowPath(); - if (points.length === 1) return new Path2D(); + if (points.length === 0) return super.getArrowPoints(props); + if (points.length === 1) return []; const [start, end] = points.slice(points.length - 2); - return trangleArrowForVector(start, end, 16, 10); + return getTriangleArrowPoints(start, end, 16, 10); } public styleArrow(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult { @@ -63,19 +77,7 @@ export class MultipointConnection extends BlockConnection } public getBBox() { - const points = this.getPoints(); - if (!points.length) { - return super.getBBox(); - } - - const x: number[] = []; - const y: number[] = []; - points.forEach((point) => { - x.push(point.x); - y.push(point.y); - }); - - return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)] as const; + return super.getBBox(); } protected override getHitBoxRect(): TRect { @@ -115,24 +117,7 @@ export class MultipointConnection extends BlockConnection const [labelInnerTopPadding, labelInnerRightPadding, labelInnerBottomPadding, labelInnerLeftPadding] = this.context.constants.connection.LABEL.INNER_PADDINGS; - const { labels } = this.connectedState.$state.value; - if (!labels || !labels.length) { - return; - } - - this.labelsGeometry = []; - labels.forEach(({ x, y, text, height, width }) => { - if (x === undefined || y === undefined || text === undefined || width === undefined || height === undefined) { - return; - } - - this.labelsGeometry.push({ - x, - y, - width, - height, - }); - + this.labelsGeometry.forEach(({ x, y, text, height, width }) => { ctx.fillStyle = this.context.colors.connectionLabel.text; if (this.state.hovered) ctx.fillStyle = this.context.colors.connectionLabel.hoverText; @@ -157,4 +142,12 @@ export class MultipointConnection extends BlockConnection return; } + + protected override updateLabelGeometry(): void { + this.labelsGeometry = (this.connectedState.$state.value.labels ?? []).flatMap(({ x, y, text, width, height }) => + x === undefined || y === undefined || text === undefined || width === undefined || height === undefined + ? [] + : [{ x, y, text, width, height }] + ); + } } diff --git a/packages/graph/src/components/canvas/connections/arrowVisibility.test.ts b/packages/graph/src/components/canvas/connections/arrowVisibility.test.ts new file mode 100644 index 00000000..4e454698 --- /dev/null +++ b/packages/graph/src/components/canvas/connections/arrowVisibility.test.ts @@ -0,0 +1,89 @@ +import { Graph } from "../../../graph"; +import { globalScheduler } from "../../../lib/Scheduler"; +import { TConnection } from "../../../store/connection/ConnectionState"; + +import { BlockConnection } from "./BlockConnection"; +import { MultipointConnection } from "./MultipointConnection"; +import { TMultipointConnection } from "./types"; + +describe("connection paint bounds at the viewport edge", () => { + let graph: Graph; + + afterEach(() => { + graph?.unmount(); + jest.restoreAllMocks(); + }); + + function start( + connection: TConnection, + Component: typeof BlockConnection = BlockConnection, + threshold = 0, + arrows = true + ) { + const root = document.createElement("div"); + Object.defineProperties(root, { clientWidth: { value: 1000 }, clientHeight: { value: 800 } }); + graph = new Graph( + { + connections: [connection], + settings: { connection: Component, useBezierConnections: false, showConnectionArrows: arrows }, + }, + root, + undefined, + { connection: { THRESHOLD_LINE_HIT: threshold } } + ); + graph.cameraService.set({ width: 1000, height: 800, x: 0, y: 0, scale: 1 }); + return graph.connections.getConnectionState("c"); + } + + function draw() { + graph.start(); + for (let i = 0; i < 3; i++) globalScheduler.performUpdate(); + return graph.connections.getConnectionState("c")?.getViewComponent(); + } + + it("draws a short multipoint arrow entering the viewport while its hitbox stays outside", () => { + const connection: TMultipointConnection = { + id: "c", + points: [ + { x: 1010, y: 100 }, + { x: 1011, y: 100 }, + ], + }; + start(connection, MultipointConnection, 8); + const arrow = jest.spyOn(MultipointConnection.prototype, "createArrowPath"); + const view = draw(); + expect(view?.getHitBox()[0]).toBe(1002); + expect(view?.isInViewport()).toBe(true); + expect(arrow).toHaveBeenCalled(); + expect(arrow.mock.results[0].value.lineTo).toHaveBeenCalledWith(995, 105); + }); + + it.each([1005, 1008])( + "draws a regular arrow or its stroke entering the viewport from x=%s with no hit padding", + (x) => { + const state = start({ id: "c", sourcePortId: "s", targetPortId: "t", selected: true }); + if (!state) throw new Error("Expected connection"); + state.$sourcePortState.value.updatePort({ x, y: 100, lookup: false }); + state.$targetPortState.value.updatePort({ x: x + 1, y: 100, lookup: false }); + graph.api.selectConnections(["c"], true); + const arrow = jest.spyOn(BlockConnection.prototype, "createArrowPath"); + const view = draw(); + expect(view?.getHitBox()[0]).toBe(x); + expect(view?.isInViewport()).toBe(true); + expect(arrow).toHaveBeenCalled(); + } + ); + + it("draws the selected line stroke entering the viewport with no arrows or hit padding", () => { + const state = start({ id: "c", sourcePortId: "s", targetPortId: "t" }, BlockConnection, 0, false); + if (!state) throw new Error("Expected connection"); + state.$sourcePortState.value.updatePort({ x: 1001.5, y: 100, lookup: false }); + state.$targetPortState.value.updatePort({ x: 1001.5, y: 200, lookup: false }); + graph.api.selectConnections(["c"], true); + const path = jest.spyOn(BlockConnection.prototype, "getPath"); + const view = draw(); + expect(view?.getHitBox()[0]).toBe(1001.5); + expect(view?.isInViewport()).toBe(true); + expect(path).toHaveBeenCalled(); + }); +}); diff --git a/packages/graph/src/components/canvas/connections/labelBounds.test.ts b/packages/graph/src/components/canvas/connections/labelBounds.test.ts new file mode 100644 index 00000000..2c535042 --- /dev/null +++ b/packages/graph/src/components/canvas/connections/labelBounds.test.ts @@ -0,0 +1,80 @@ +import { Graph } from "../../../graph"; +import { Component } from "../../../lib"; + +import { BezierMultipointConnection } from "./BezierMultipointConnection"; +import { BlockConnections } from "./BlockConnections"; +import { MultipointConnection } from "./MultipointConnection"; +import { TMultipointConnection } from "./types"; + +const props = { + id: "c", + useBezier: false, + bezierDirection: "horizontal" as const, + showConnectionArrows: true, + showConnectionLabels: true, +}; + +test("multipoint bounds include its label before drawing and discard removed labels", () => { + const connection: TMultipointConnection = { + id: "c", + points: [ + { x: 2000, y: 0 }, + { x: 2100, y: 0 }, + ], + labels: [{ x: 100, y: 100, width: 50, height: 20, text: "label" }], + }; + const graph = new Graph({ + connections: [connection], + }); + const view = new MultipointConnection(props, new BlockConnections({}, graph.getGraphLayer())); + + view.updatePoints(); + expect(view.getBBox()).toEqual([100, 0, 2100, 120]); + const patch: TMultipointConnection = { id: "c", labels: [] }; + const state = graph.connections.getConnectionState("c"); + if (!state) throw new Error("Expected connection"); + state.updateConnection(patch); + view.updatePoints(); + expect(view.getBBox()).toEqual([2000, 0, 2100, 0]); + Component.unmount(view); + graph.unmount(); +}); + +test("offscreen multipoint geometry is available to world hit queries before drawing", () => { + const connection: TMultipointConnection = { + id: "c", + points: [ + { x: 2000, y: 0 }, + { x: 2100, y: 0 }, + ], + }; + const graph = new Graph({ + connections: [connection], + }); + const view = new MultipointConnection(props, new BlockConnections({}, graph.getGraphLayer())); + view.updatePoints(); + const hit = jest.spyOn(view.context.ctx, "isPointInStroke"); + view.onHitBox({ x: 2050, y: 0, minX: 2050, maxX: 2050, minY: 0, maxY: 0 }); + const path = hit.mock.calls[0][0] as Path2D; + expect(path.lineTo).toHaveBeenCalledWith(2100, 0); + Component.unmount(view); + graph.unmount(); +}); + +test("bezier multipoint bounds include curve extrema outside its endpoints", () => { + const connection: TMultipointConnection = { + id: "c", + points: [ + { x: 700, y: 100 }, + { x: 0, y: 350 }, + ], + }; + const graph = new Graph({ connections: [connection] }); + const view = new BezierMultipointConnection(props, new BlockConnections({}, graph.getGraphLayer())); + view.updatePoints(); + const bounds = view.getBBox(); + expect(bounds[0]).toBeCloseTo(-56.6633, 3); + expect(bounds[2]).toBeCloseTo(756.6633, 3); + Component.unmount(view); + graph.unmount(); +}); diff --git a/packages/graph/src/components/canvas/connections/visibility.test.ts b/packages/graph/src/components/canvas/connections/visibility.test.ts new file mode 100644 index 00000000..a03a77d1 --- /dev/null +++ b/packages/graph/src/components/canvas/connections/visibility.test.ts @@ -0,0 +1,242 @@ +import { Graph } from "../../../graph"; +import { globalScheduler } from "../../../lib/Scheduler"; +import { TConnection } from "../../../store/connection/ConnectionState"; + +import { BlockConnection } from "./BlockConnection"; + +class ObservedConnection extends BlockConnection { + public draws: string[] = []; + + public override getPath() { + this.draws.push("path"); + return super.getPath(); + } + + public override createArrowPath() { + this.draws.push("arrow"); + return super.createArrowPath(); + } + + public override style(ctx: CanvasRenderingContext2D) { + this.draws.push("style"); + return super.style(ctx); + } + + public override styleArrow(ctx: CanvasRenderingContext2D) { + this.draws.push("arrowStyle"); + return super.styleArrow(ctx); + } + + public override afterRender(ctx: CanvasRenderingContext2D) { + this.draws.push("afterRender"); + super.afterRender?.(ctx); + } + + public labelBounds() { + return this.labelGeometry; + } +} + +function frames(count = 1) { + for (let i = 0; i < count; i++) globalScheduler.performUpdate(); +} + +describe("connection batch viewport visibility", () => { + let graph: Graph; + + function connectionState(id: string) { + const state = graph.connections.getConnectionState(id); + if (!state) throw new Error(`Expected connection ${id}`); + return state; + } + + function mount(connections: Array<{ id: string; x: number; y: number; label?: string }>) { + const root = document.createElement("div"); + Object.defineProperties(root, { clientWidth: { value: 1000 }, clientHeight: { value: 800 } }); + graph = new Graph( + { + connections: connections.map(({ id, label }) => ({ + id, + label, + sourcePortId: `${id}-source`, + targetPortId: `${id}-target`, + })), + settings: { connection: ObservedConnection, useBezierConnections: false, showConnectionLabels: true }, + }, + root + ); + for (const { id, x, y } of connections) { + const state = connectionState(id); + state.$sourcePortState.value.updatePort({ x, y, lookup: false }); + state.$targetPortState.value.updatePort({ x: x + 100, y, lookup: false }); + } + graph.cameraService.set({ width: 1000, height: 800, x: 0, y: 0, scale: 1 }); + graph.start(); + frames(3); + } + + function view(id: string) { + const component = connectionState(id).getViewComponent(); + if (!(component instanceof ObservedConnection)) throw new Error(`Expected connection view ${id}`); + return component; + } + + afterEach(() => graph?.unmount()); + + it("omits offscreen paths, arrows and after-render hooks from the first batch draw", () => { + mount([ + { id: "near", x: 100, y: 100 }, + { id: "far", x: 50_000, y: 50_000 }, + ]); + + expect(view("near").draws).toContain("path"); + expect(view("far").draws).toEqual([]); + }); + + it("invalidates cached line and arrow chunks when the camera enters and leaves", () => { + mount([ + { id: "near", x: 100, y: 100 }, + { id: "far", x: 50_000, y: 50_000 }, + ]); + view("near").draws = []; + view("far").draws = []; + + graph.cameraService.set({ x: -49_800, y: -49_800 }); + frames(2); + expect(view("near").draws).toEqual([]); + expect(view("far").draws).toEqual(expect.arrayContaining(["path", "arrow", "afterRender"])); + + view("near").draws = []; + view("far").draws = []; + graph.cameraService.set({ x: 0, y: 0 }); + frames(2); + expect(view("near").draws).toEqual(expect.arrayContaining(["path", "arrow", "afterRender"])); + expect(view("far").draws).toEqual([]); + }); + + it("finds a label intersecting the viewport before its outside line ever draws", () => { + mount([{ id: "label", x: 1010, y: 100, label: "a".repeat(200) }]); + + expect(view("label").draws).toContain("afterRender"); + expect(view("label").getHitBox()[0]).toBeLessThan(1000); + expect(view("label").labelBounds()?.x).toBeLessThan(1000); + }); + + it("refreshes label bounds while an offscreen connection receives a longer label", () => { + mount([{ id: "label", x: 1010, y: 100, label: "a" }]); + view("label").draws = []; + + graph.api.updateConnection("label", { label: "b".repeat(200) }); + frames(2); + + expect(view("label").draws).toContain("afterRender"); + expect(view("label").getHitBox()[0]).toBeLessThan(1000); + }); + + it("lets an offscreen label enter when zoom increases its world-space height", () => { + mount([{ id: "label", x: 100, y: 850, label: "label" }]); + view("label").draws = []; + + // Keep the bottom of the visible world at y=800 while the label grows upwards. + graph.cameraService.set({ scale: 0.25, y: 600 }); + frames(2); + + expect(view("label").labelBounds()?.y).toBeLessThan(800); + expect(view("label").draws).toContain("afterRender"); + }); + + it("removes stale label bounds before the batch when labels are disabled", () => { + mount([{ id: "label", x: 1010, y: 100, label: "a".repeat(200) }]); + view("label").draws = []; + graph.updateSettings({ showConnectionLabels: false }); + frames(3); + + expect(view("label").labelBounds()).toBeUndefined(); + expect(view("label").getHitBox()[0]).toBeGreaterThan(1000); + expect(view("label").draws).toEqual([]); + + graph.updateSettings({ showConnectionLabels: true }); + frames(3); + expect(view("label").draws).toContain("afterRender"); + }); + + it("refreshes an offscreen label when its padding constants change", () => { + mount([{ id: "label", x: 1010, y: 100, label: "a" }]); + view("label").draws = []; + + graph.setConstants({ connection: { LABEL: { INNER_PADDINGS: [0, 0, 0, 200] } } }); + frames(2); + + expect(view("label").getHitBox()[0]).toBeLessThan(1000); + expect(view("label").draws).toContain("afterRender"); + }); + + it("keeps a connection hidden while both endpoint owners are hidden", () => { + mount([{ id: "connection", x: 100, y: 100 }]); + graph.rootStore.blocksList.setBlocks( + ["source", "target"].map((id) => ({ + id, + is: "Block", + name: id, + x: 100, + y: 100, + width: 100, + height: 100, + anchors: [], + })) + ); + frames(2); + const source = graph.rootStore.blocksList.getBlockState("source"); + const target = graph.rootStore.blocksList.getBlockState("target"); + const sourceView = source?.getViewComponent(); + const targetView = target?.getViewComponent(); + if (!source || !target || !sourceView || !targetView) throw new Error("Expected endpoint owners"); + const connection = connectionState("connection"); + connection.$sourcePortState.value.setOwner(sourceView); + connection.$targetPortState.value.setOwner(targetView); + source.requestHidden(true); + target.requestHidden(true); + view("connection").draws = []; + frames(2); + graph.cameraService.set({ x: 10 }); + frames(2); + expect(view("connection").draws).toEqual([]); + + source.requestHidden(false); + frames(2); + expect(view("connection").draws).toContain("afterRender"); + }); + + it("keeps delegated connections out of batches across camera changes", () => { + mount([{ id: "connection", x: 100, y: 100 }]); + view("connection").setRenderDelegated(true); + frames(2); + view("connection").draws = []; + + graph.cameraService.set({ x: -50_000, y: -50_000 }); + frames(2); + graph.cameraService.set({ x: 0, y: 0 }); + frames(2); + expect(view("connection").draws).toEqual([]); + expect(graph.getElementsOverRect({ x: 90, y: 90, width: 120, height: 20 }, [BlockConnection])).toContain( + view("connection") + ); + + view("connection").setRenderDelegated(false); + frames(2); + expect(view("connection").draws).toEqual(expect.arrayContaining(["path", "arrow", "afterRender"])); + }); + + it("keeps offscreen line geometry in world rectangle and point hit queries", () => { + mount([{ id: "far", x: 50_000, y: 50_000 }]); + expect(graph.getElementsOverRect({ x: 49_990, y: 49_990, width: 120, height: 20 }, [BlockConnection])).toContain( + view("far") + ); + // jsdom cannot intersect Path2D; inspect the real path passed to the canvas hit-test boundary. + const hit = jest.spyOn(view("far").context.ctx, "isPointInStroke").mockReturnValue(true); + expect(graph.getElementOverPoint({ x: 50_050, y: 50_000 }, [BlockConnection])).toBe(view("far")); + const path = hit.mock.calls[0][0] as Path2D; + expect(path.lineTo).toHaveBeenCalledWith(50_100, 50_000); + hit.mockRestore(); + }); +}); diff --git a/packages/graph/src/components/canvas/groups/BlockGroups.ts b/packages/graph/src/components/canvas/groups/BlockGroups.ts index aee858ff..adf81b2e 100644 --- a/packages/graph/src/components/canvas/groups/BlockGroups.ts +++ b/packages/graph/src/components/canvas/groups/BlockGroups.ts @@ -9,7 +9,6 @@ import { BlockState } from "../../../store/block/Block"; import { GroupState, TGroup, TGroupId } from "../../../store/group/Group"; import { getBlocksRect } from "../../../utils/functions"; import { TRect } from "../../../utils/types/shapes"; -import { createViewportCulling } from "../visibilityCulling"; import { Group } from "./Group"; @@ -175,7 +174,6 @@ export class BlockGroups

extends }); this.requireCanvas(); - this.setChildrenCulling(createViewportCulling(this.props.graph, () => this.context.camera)); } protected afterInit(): void { diff --git a/packages/graph/src/components/canvas/nativeVisibilityCulling.test.ts b/packages/graph/src/components/canvas/nativeVisibilityCulling.test.ts new file mode 100644 index 00000000..be8116e7 --- /dev/null +++ b/packages/graph/src/components/canvas/nativeVisibilityCulling.test.ts @@ -0,0 +1,339 @@ +import { Graph, TGraphConfig } from "../../graph"; +import { Component } from "../../lib/Component"; +import { ChildDescriptor } from "../../lib/CoreComponent"; +import { globalScheduler } from "../../lib/Scheduler"; +import { Layer, LayerProps } from "../../services/Layer"; + +import { GraphComponent, TGraphComponentProps } from "./GraphComponent"; +import { Anchor, TAnchor, TAnchorProps } from "./anchors"; +import { Block, TBlock } from "./blocks/Block"; + +class TracedAnchor extends Anchor { + public readonly paint: string[] = []; + + protected willRender() { + this.paint.push("willRender"); + super.willRender(); + } + + protected render() { + this.paint.push("render"); + super.render(); + } + + protected didRender() { + this.paint.push("didRender"); + super.didRender(); + } +} + +const AnchorView: typeof Anchor = TracedAnchor; + +class TracedBlock extends Block { + public readonly paint: string[] = []; + + protected willRender() { + this.paint.push("willRender"); + super.willRender(); + } + + protected render() { + this.paint.push("render"); + super.render(); + } + + protected didRender() { + this.paint.push("didRender"); + super.didRender(); + } + + protected renderAnchor(anchor: TAnchor) { + return AnchorView.create( + { ...anchor, zIndex: this.zIndex, size: 18, lineWidth: 2, port: this.getAnchorPort(anchor.id) }, + { key: anchor.id } + ); + } +} + +type MarkerProps = TGraphComponentProps & { id: string; x: number; y: number }; + +class Marker extends GraphComponent { + public readonly paint: string[] = []; + + public getEntityId() { + return this.props.id; + } + + protected willMount() { + super.willMount(); + this.updateBounds(this.props); + } + + protected propsChanged(props: MarkerProps) { + super.propsChanged(props); + this.updateBounds(props); + } + + private updateBounds({ x, y }: MarkerProps) { + this.setHitBox(x, y, x + 20, y + 20); + } + + protected willRender() { + this.paint.push("willRender"); + super.willRender(); + } + + protected render() { + this.paint.push("render"); + this.context.ctx.fillRect(this.props.x, this.props.y, 20, 20); + } + + protected didRender() { + this.paint.push("didRender"); + super.didRender(); + } +} + +class CustomContainer extends Component<{ children: ChildDescriptor[] }> { + protected updateChildren() { + return this.props.children; + } +} + +class CustomLayer extends Layer { + protected updateChildren() { + return [CustomContainer.create({ children: this.props.children })]; + } +} + +const block = (id: string, x: number, y: number, anchors: TAnchor[] = []): TBlock => ({ + id, + is: "TracedBlock", + name: id, + x, + y, + width: 100, + height: 100, + anchors, +}); + +function frames(count = 1) { + for (let i = 0; i < count; i++) globalScheduler.performUpdate(); +} + +describe("Native viewport traversal through Graph", () => { + let graph: Graph; + + function start(config: TGraphConfig = {}) { + const root = document.createElement("div"); + Object.defineProperties(root, { clientWidth: { value: 1000 }, clientHeight: { value: 800 } }); + graph = new Graph({ ...config, settings: { blockComponents: { TracedBlock }, ...config.settings } }, root); + graph.start(); + frames(4); + } + + function view(id: string): TracedBlock { + const result = graph.rootStore.blocksList.getBlockState(id)?.getViewComponent(); + if (!(result instanceof TracedBlock)) throw new Error(`Missing block ${id}`); + return result; + } + + function anchor(id: string, anchorId: string): TracedAnchor { + const result = graph.rootStore.blocksList.getBlockState(id)?.getAnchorById(anchorId)?.getViewComponent(); + if (!(result instanceof TracedAnchor)) throw new Error(`Missing anchor ${anchorId}`); + return result; + } + + afterEach(() => { + graph?.unmount(); + jest.restoreAllMocks(); + }); + + it("mounts offscreen ports and connection geometry without any paint lifecycle, including the first frame", () => { + start({ + blocks: [block("near", 100, 100), block("far", 50_000, 50_000, [{ id: "out", blockId: "far", type: "OUT" }])], + connections: [{ id: "edge", sourceBlockId: "far", sourceAnchorId: "out", targetBlockId: "near" }], + }); + + expect(anchor("far", "out").isMounted()).toBe(true); + expect(view("far").getInputPort().getPoint()).toEqual({ x: 50_000, y: 50_050 }); + expect(view("far").getAnchorPort("out").component).toBe(anchor("far", "out")); + expect(graph.rootStore.connectionsList.getConnectionState("edge")?.getViewComponent()?.connectionPoints).toEqual([ + { x: 50_100, y: 50_088 }, + { x: 100, y: 150 }, + ]); + expect(view("near").paint).toContain("render"); + expect(view("far").paint).toEqual([]); + expect(anchor("far", "out").paint).toEqual([]); + }); + + it("reconciles and removes offscreen anchors while keeping connection endpoints current", () => { + start({ + blocks: [block("near", 100, 100), block("far", 50_000, 50_000, [{ id: "old", blockId: "far", type: "OUT" }])], + connections: [{ id: "edge", sourceBlockId: "near", targetBlockId: "far" }], + }); + const far = view("far"); + const oldAnchor = anchor("far", "old"); + const removed = jest.fn(); + oldAnchor.onUnmounted(removed); + graph.api.updateBlock({ + id: "far", + x: 60_000, + y: 70_000, + anchors: [{ id: "new", blockId: "far", type: "OUT" }], + }); + frames(4); + + const nextAnchor = anchor("far", "new"); + expect(far.state).toMatchObject({ x: 60_000, y: 70_000 }); + expect(oldAnchor.isMounted()).toBe(false); + expect(removed).toHaveBeenCalledTimes(1); + expect(nextAnchor.isMounted()).toBe(true); + expect(nextAnchor.getPosition()).toEqual({ x: 60_100, y: 70_088 }); + expect( + graph.rootStore.connectionsList.getConnectionState("edge")?.getViewComponent()?.connectionPoints?.[1] + ).toEqual({ + x: 60_000, + y: 70_050, + }); + expect(far.paint).toEqual([]); + expect(nextAnchor.paint).toEqual([]); + + const port = far.getInputPort(); + const nextRemoved = jest.fn(); + nextAnchor.onUnmounted(nextRemoved); + graph.setEntities({ blocks: [], connections: [] }); + frames(3); + expect(nextRemoved).toHaveBeenCalledTimes(1); + expect(nextAnchor.isMounted()).toBe(false); + expect(far.isEntityAvailable()).toBe(false); + expect(graph.rootStore.connectionsList.getPort(port.id)).toBeUndefined(); + expect(graph.getElementsOverRect({ x: 59_000, y: 69_000, width: 2000, height: 2000 }, [Block, Anchor])).toEqual([]); + }); + + it("paints a visible anchor without painting its offscreen parent", () => { + class OverflowBlock extends TracedBlock { + public getAnchorPosition() { + return { x: 100, y: 5100 }; + } + } + start({ + blocks: [block("overflow", 100, -5000, [{ id: "out", blockId: "overflow", type: "OUT" }])], + settings: { blockComponents: { TracedBlock: OverflowBlock } }, + }); + + expect(anchor("overflow", "out").getPosition()).toEqual({ x: 200, y: 100 }); + expect(anchor("overflow", "out").paint).toContain("render"); + expect(view("overflow").paint).toEqual([]); + expect(graph.getElementOverPoint({ x: 200, y: 100 }, [Anchor])).toBe(anchor("overflow", "out")); + }); + + it("keeps world point and rectangle hits consistent in a skipped branch before and after an update", () => { + start({ blocks: [block("far", 50_000, 50_000, [{ id: "out", blockId: "far", type: "OUT" }])] }); + const target = anchor("far", "out"); + const iterate = jest.spyOn(view("far"), "iterate"); + graph.getGraphLayer().performRender(); + frames(2); + expect(iterate).not.toHaveBeenCalled(); + + for (const [x, y] of [ + [50_000, 50_000], + [60_000, 70_000], + ]) { + graph.api.updateBlock({ id: "far", x, y }); + frames(3); + expect(graph.getElementOverPoint({ x: x + 100, y: y + 88 }, [Anchor])).toBe(target); + expect(graph.getElementsOverRect({ x: x + 99, y: y + 87, width: 2, height: 2 }, [Anchor])).toEqual([target]); + expect(graph.getElementOverPoint({ x: x + 50, y: y + 50 }, [Block])).toBe(view("far")); + expect(graph.getElementsOverRect({ x: x + 49, y: y + 49, width: 2, height: 2 }, [Block])).toEqual([view("far")]); + } + expect(graph.getElementOverPoint({ x: 50_100, y: 50_088 }, [Anchor])).toBeUndefined(); + expect(target.paint).toEqual([]); + }); + + it("raises a selected block that has never been painted above overlapping offscreen siblings", () => { + start({ blocks: [block("first", 50_000, 50_000), block("second", 50_000, 50_000)] }); + expect(view("first").paint).toEqual([]); + expect(view("second").paint).toEqual([]); + expect(graph.getElementOverPoint({ x: 50_050, y: 50_050 }, [Block])).toBe(view("second")); + + graph.api.selectBlocks(["first"], true); + frames(3); + + expect(view("first").state.selected).toBe(true); + expect(view("first").zIndex).toBeGreaterThan(view("second").zIndex); + expect(graph.getElementOverPoint({ x: 50_050, y: 50_050 }, [Block])).toBe(view("first")); + expect(view("first").paint).toEqual([]); + }); + + it("automatically culls custom components nested in a custom container and processes their props", () => { + start(); + const markers = new Map(); + const layer = graph.addLayer(CustomLayer, { + canvas: { zIndex: 5 }, + children: ["near", "far"].map((id) => + Marker.create( + { id, x: id === "near" ? 100 : 50_000, y: 100 }, + { key: id, ref: (marker) => markers.set(id, marker) } + ) + ), + }); + frames(4); + const near = markers.get("near"); + const far = markers.get("far"); + if (!near || !far) throw new Error("Missing custom markers"); + expect(far.isMounted()).toBe(true); + expect(far.paint).toEqual([]); + const iterate = jest.spyOn(far, "iterate"); + near.paint.length = 0; + layer.performRender(); + frames(2); + expect(near.paint).toContain("render"); + expect(iterate).not.toHaveBeenCalled(); + + far.setProps({ x: 200 }); + frames(3); + expect(far.props.x).toBe(200); + expect(far.paint).toContain("render"); + expect(graph.getElementOverPoint({ x: 210, y: 110 }, [Marker])).toBe(far); + }); + + it("begins painting when the camera arrives and stops again when it leaves", () => { + start({ blocks: [block("far", 50_000, 50_000)] }); + const far = view("far"); + expect(far.paint).toEqual([]); + + graph.cameraService.set({ x: -49_800, y: -49_800, scale: 1 }); + frames(3); + expect(far.paint).toContain("render"); + const calls = far.paint.length; + + graph.cameraService.set({ x: 0, y: 0, scale: 1 }); + frames(3); + expect(far.paint).toHaveLength(calls); + expect(graph.getElementOverPoint({ x: 50_050, y: 50_050 }, [Block])).toBe(far); + }); + + it("does not make a render request in one layer visit another layer's components", () => { + start(); + const markers: Marker[] = []; + const layers = [0, 1].map((id) => + graph.addLayer(CustomLayer, { + canvas: { zIndex: 5 + id }, + children: [Marker.create({ id: String(id), x: 100, y: 100 }, { ref: (marker) => markers.push(marker) })], + }) + ); + frames(4); + markers.forEach((marker) => { + marker.paint.length = 0; + }); + const other = jest.spyOn(markers[1], "iterate"); + + layers[0].performRender(); + frames(1); + + expect(markers[0].paint).toContain("render"); + expect(markers[1].paint).toEqual([]); + expect(other).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/graph/src/components/canvas/visibilityCulling.ts b/packages/graph/src/components/canvas/visibilityCulling.ts deleted file mode 100644 index d69b4401..00000000 --- a/packages/graph/src/components/canvas/visibilityCulling.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Graph } from "../../graph"; -import type { TTileRect } from "../../lib/TileIndex"; -import type { TTreeCullingOptions } from "../../lib/TreeCulling"; -import type { ICamera } from "../../services/camera/CameraService"; - -/** - * Culling options for a container of graph components (blocks, connections, groups): each frame - * walks the children near the camera viewport. Disabled by the `EXP_VISIBILITY_CULLING` setting. - */ -export function createViewportCulling( - graph: Graph, - getCamera: () => ICamera -): Omit { - const viewport: TTileRect = { minX: 0, minY: 0, maxX: 0, maxY: 0 }; - return { - tileSize: graph.graphConstants.system.VISIBILITY_TILE_SIZE, - getViewport: () => { - if (!graph.rootStore.settings.getConfigFlag("EXP_VISIBILITY_CULLING")) return undefined; - // Same rect as CameraService.isRectVisible: the full viewport, insets ignored. - const { relativeX, relativeY, relativeWidth, relativeHeight } = getCamera().getCameraState(); - const margin = graph.graphConstants.system.VISIBILITY_CULLING_MARGIN; - const marginX = relativeWidth * margin; - const marginY = relativeHeight * margin; - viewport.minX = -relativeX - marginX; - viewport.minY = -relativeY - marginY; - viewport.maxX = -relativeX + relativeWidth + marginX; - viewport.maxY = -relativeY + relativeHeight + marginY; - return viewport; - }, - }; -} diff --git a/packages/graph/src/graphConfig.ts b/packages/graph/src/graphConfig.ts index 6bcc0603..5a78b863 100644 --- a/packages/graph/src/graphConfig.ts +++ b/packages/graph/src/graphConfig.ts @@ -193,12 +193,10 @@ export type TGraphConstants = { /** For preload blocks on the html layer (camera dimensions * (1 + this value)) */ CAMERA_VIEWPORT_TRESHOLD: number; /** - * Edge of a visibility tile in world units. Blocks, connections and groups are indexed by - * tile so a frame walks only the components near the viewport. Read when a container mounts. + * Edge of a visibility tile in world units. Each layer tree indexes its descendants. + * Read when the layer is constructed. */ VISIBILITY_TILE_SIZE: number; - /** Extra walked area around the viewport for visibility culling, as a fraction of its size per side. */ - VISIBILITY_CULLING_MARGIN: number; }; camera: { @@ -320,7 +318,6 @@ export const initGraphConstants: TGraphConstants = { USABLE_RECT_GAP: 400, CAMERA_VIEWPORT_TRESHOLD: 0.5, VISIBILITY_TILE_SIZE: 1024, - VISIBILITY_CULLING_MARGIN: 0.1, }, camera: { SPEED: 1, diff --git a/packages/graph/src/lib/Component.phases.test.ts b/packages/graph/src/lib/Component.phases.test.ts new file mode 100644 index 00000000..7a7a542c --- /dev/null +++ b/packages/graph/src/lib/Component.phases.test.ts @@ -0,0 +1,138 @@ +import { Component } from "./Component"; +import { CoreComponent } from "./CoreComponent"; + +class Probe extends Component<{ value: number }, { value: number }> { + public events: string[] = []; + public inViewport = false; + + constructor() { + super({ value: 0 }); + this.state = { value: 0 }; + } + + public isInViewport() { + return this.inViewport; + } + + public change(value: number) { + this.setState({ value }); + } + + protected willMount() { + this.events.push("mount"); + } + + protected updateChildren() { + this.events.push("children"); + return []; + } + + protected willRender() { + this.events.push("willRender"); + } + + protected render() { + this.events.push(`render:${this.state.value}`); + } + + protected didRender() { + this.events.push("didRender"); + } + + protected didIterate() { + this.events.push("complete"); + } +} + +test("mounts and commits state outside the viewport without running render hooks", () => { + const probe = new Probe(); + probe.change(1); + probe.iterate(); + expect(probe.state.value).toBe(1); + expect(probe.events).toEqual(["mount", "children", "complete"]); + + probe.change(2); + probe.iterate(); + expect(probe.state.value).toBe(2); + expect(probe.events).toEqual(["mount", "children", "complete", "complete"]); + + probe.inViewport = true; + probe.iterate(); + expect(probe.events.slice(-4)).toEqual(["willRender", "render:2", "didRender", "complete"]); + CoreComponent.unmount(probe); +}); + +test("prepares children and data before painting, without repeating preparation", () => { + const probe = new Probe(); + probe.inViewport = true; + probe.change(4); + expect(probe.prepareUpdate()).toBe(true); + expect(probe.state.value).toBe(4); + expect(probe.events).toEqual(["mount", "children"]); + + probe.prepareUpdate(); + probe.iterate(); + expect(probe.events).toEqual(["mount", "children", "willRender", "render:4", "didRender", "complete"]); + CoreComponent.unmount(probe); +}); + +test("completes prepared children when parent paint suppresses them, then consumes later props", () => { + class Child extends Component<{ value: number }> { + public painted: number[] = []; + public mounts = 0; + public completions = 0; + protected willMount() { + this.mounts++; + } + protected didIterate() { + this.completions++; + } + protected render() { + this.painted.push(this.props.value); + } + } + class Parent extends Component { + public child?: Child; + private suppressInPaint = true; + constructor() { + super({}); + this.setChildrenCulling({ tileSize: 64, getViewport: () => ({ minX: 0, minY: 0, maxX: 100, maxY: 100 }) }); + } + protected updateChildren() { + return [ + Child.create( + { value: 0 }, + { + ref: (child) => { + this.child = child; + }, + } + ), + ]; + } + protected render() { + if (this.suppressInPaint) this.shouldRenderChildren = false; + } + public resume() { + this.suppressInPaint = false; + this.shouldRenderChildren = true; + this.performRender(); + } + } + const parent = CoreComponent.mount(Parent); + CoreComponent.update(parent); + const child = parent.child; + if (!child) throw new Error("Missing mounted child"); + expect(child.painted).toEqual([]); + expect(child.completions).toBe(1); + expect(child.isIterated()).toBe(false); + child.setProps({ value: 1 }); + parent.resume(); + CoreComponent.update(parent); + CoreComponent.update(parent); + expect(child.props.value).toBe(1); + expect(child.isIterated()).toBe(true); + expect(child.painted).toEqual([1]); + expect(child.mounts).toBe(1); + CoreComponent.unmount(parent); +}); diff --git a/packages/graph/src/lib/Component.ts b/packages/graph/src/lib/Component.ts index ec1325e1..b35fd060 100644 --- a/packages/graph/src/lib/Component.ts +++ b/packages/graph/src/lib/Component.ts @@ -148,22 +148,28 @@ export class Component< // noop } - public iterate() { - super.iterate(); - + public override prepareUpdate(): boolean { + if (this.updatePrepared) return this.shouldRenderChildren; + super.prepareUpdate(); this.checkData(); this.willIterate(); - if (this.shouldRender) { - this.renderLifeCycle(); - } else { - this.willNotRender(); - } - if (this.shouldUpdateChildren) { this.shouldUpdateChildren = false; this.childrenLifeCycle(); } + return this.shouldRenderChildren; + } + + public iterate() { + this.prepareUpdate(); + super.iterate(); + + if (this.shouldRender && this.isInViewport()) { + this.renderLifeCycle(); + } else { + this.willNotRender(); + } this.didIterate(); diff --git a/packages/graph/src/lib/CoreComponent.ts b/packages/graph/src/lib/CoreComponent.ts index 54774ba8..f01c28cc 100644 --- a/packages/graph/src/lib/CoreComponent.ts +++ b/packages/graph/src/lib/CoreComponent.ts @@ -82,6 +82,8 @@ export class CoreComponent< protected __comp: TPrivateComponentData; + protected updatePrepared = false; + public get zIndex() { return this.__comp.treeNode.zIndex; } @@ -116,7 +118,20 @@ export class CoreComponent< const iterateId = this.__comp.context.globalIterateId; // A child skipped by an ancestor's visibility culling was out of view with nothing to process, // so for this traversal it counts as iterated. - return this.__comp.iterateId === iterateId || this.__comp.treeNode.isSkippedByCulling(iterateId); + return ( + this.__comp.treeNode.isTraversalAllowed() && + (this.__comp.iterateId === iterateId || this.__comp.treeNode.isSkippedByCulling(iterateId)) + ); + } + + /** Whether this node's own bounds are visible in the current tree frame. */ + public isInViewport(): boolean { + return this.__comp.treeNode.isVisible(); + } + + /** @internal Called before painting when the tree's viewport selection changes. */ + public onViewportChange(_visible: boolean): void { + // noop } public performRender = () => { @@ -125,14 +140,13 @@ export class CoreComponent< }; /** - * Walks only the children whose bounds (see {@link setCullingBounds}) intersect the viewport - * returned by `getViewport`, plus children that requested a render. `undefined` disables it. + * Configures the viewport for this tree root and all its descendants. */ protected setChildrenCulling(options: Omit | undefined): void { this.__comp.treeNode.setCulling(options && { ...options, getWalkToken: () => this.__comp.context.globalIterateId }); } - /** Reports this component's own bounds; culling also includes its descendants' bounds. */ + /** Reports this component's own bounds to its tree's spatial index. */ protected setCullingBounds(minX: number, minY: number, maxX: number, maxY: number): void { this.__comp.treeNode.setBounds(minX, minY, maxX, maxY); } @@ -183,12 +197,20 @@ export class CoreComponent< this.performRender(); } - public iterate(): boolean { + /** Applies pending work before the tree selects the elements to paint. */ + public prepareUpdate(): boolean { + if (this.updatePrepared) return true; if (!this.__comp.parent) { this.__comp.context.globalIterateId = Math.random(); } this.__comp.iterateId = this.__comp.context.globalIterateId; + this.updatePrepared = true; + return true; + } + public iterate(): boolean { + this.prepareUpdate(); + this.updatePrepared = false; return true; } @@ -221,7 +243,6 @@ export class CoreComponent< const nextKeys: string[] = []; const descriptorsToMount: Array<{ key: string; descriptor: ChildDescriptor }> = []; this.__comp.prevChildrenArr = next; - this.__comp.treeNode.clearChildren(); next.forEach((descriptor, index) => { const key = String(descriptor.options.key ?? `${descriptor.klass.name}|${index}|defaultKey`); @@ -244,18 +265,20 @@ export class CoreComponent< }); descriptorsToMount.forEach(({ key, descriptor }) => this.mountChild(descriptor, key)); this.__comp.childrenKeys = nextKeys; + const children: Tree[] = []; nextKeys.forEach((key) => { const child = this.__comp.children.get(key); - if (child) this.__comp.treeNode.append(child.__comp.treeNode); + if (child) children.push(child.__comp.treeNode); }); + this.__comp.treeNode.setChildren(children); } private __unmountChildren() { - this.__comp.treeNode.clearChildren(); this.__comp.childrenKeys.forEach((key) => { const child = this.__comp.children.get(key); if (child) this.removeChild(key, child); }); + this.__comp.treeNode.clearChildren(); this.__comp.childrenKeys = []; this.__comp.prevChildrenArr = []; } diff --git a/packages/graph/src/lib/Tree.spec.ts b/packages/graph/src/lib/Tree.spec.ts index 046c307c..dc9da554 100644 --- a/packages/graph/src/lib/Tree.spec.ts +++ b/packages/graph/src/lib/Tree.spec.ts @@ -103,3 +103,23 @@ describe("Tree", () => { expect(count).toBe(3); // Including the root }); }); + +test("a scoped legacy iterator can resume child traversal after returning false", () => { + let allow = false; + let calls = 0; + const root = new Tree({ iterate: () => allow }); + root.setCulling({ tileSize: 64, getViewport: () => ({ minX: 0, minY: 0, maxX: 100, maxY: 100 }) }); + root.append( + new Tree({ + iterate: () => { + calls++; + return true; + }, + }) + ); + root.traverseDown((node) => node.data.iterate()); + expect(calls).toBe(0); + allow = true; + root.traverseDown((node) => node.data.iterate()); + expect(calls).toBe(1); +}); diff --git a/packages/graph/src/lib/Tree.subtree.test.ts b/packages/graph/src/lib/Tree.subtree.test.ts index 846b7ddd..e4c5fa4b 100644 --- a/packages/graph/src/lib/Tree.subtree.test.ts +++ b/packages/graph/src/lib/Tree.subtree.test.ts @@ -1,4 +1,4 @@ -import { Tree } from "./Tree"; +import { ITree, Tree } from "./Tree"; function setup() { let token = 0; @@ -7,111 +7,100 @@ function setup() { tileSize: 64, getViewport: () => ({ minX: 0, minY: 0, maxX: 100, maxY: 100 }), getWalkToken: () => token, - fullWalkShare: Infinity, }); - const add = (parent: Tree, x: number) => { - const node = new Tree({ iterate: () => true }); - node.setBounds(x, x, x + 10, x + 10); + const add = (parent: Tree, x?: number) => { + const node = new Tree({ prepareUpdate: () => true, iterate: () => true }); + if (x !== undefined) node.setBounds(x, x, x + 10, x + 10); parent.append(node); return node; }; - const walk = (blocked?: Tree) => { + const walk = () => { token++; const visited: Tree[] = []; root.traverseDown((node) => { if (node !== root) visited.push(node); - return node !== blocked; + return node.data.iterate(); }); return visited; }; - const settle = (blocked?: Tree) => { - walk(blocked); - walk(blocked); - return walk(blocked); - }; - return { root, add, walk, settle, token: () => token }; + return { root, add, walk, token: () => token }; } -describe("Culling component subtrees", () => { - it("follows descendant bounds while preserving the parent's own bounds", () => { - const { root, add, settle } = setup(); +describe("Tree descendant visibility", () => { + it("traverses finite offscreen ancestors to a visible leaf without enlarging their own bounds", () => { + const { root, add, walk } = setup(); const parent = add(root, 1000); const child = add(parent, 2000); const leaf = add(child, 20); - expect(settle()).toEqual([parent, child, leaf]); - + walk(); + expect(walk()).toEqual([parent, child, leaf]); + expect([parent.isVisible(), child.isVisible(), leaf.isVisible()]).toEqual([false, false, true]); + expect([parent.boundsMinX, parent.boundsMaxX]).toEqual([1000, 1010]); + expect(root["culling"]?.index.size).toBe(3); + expect(parent["culling"]).toBeUndefined(); leaf.setBounds(3000, 3000, 3010, 3010); - expect(settle()).toEqual([]); + expect(walk()).toEqual([]); parent.setBounds(30, 30, 40, 40); - expect(settle()).toEqual([parent, child, leaf]); + expect(walk()).toEqual([parent]); }); - it("shrinks subtree bounds after removal and child reconciliation", () => { - const { root, add, settle } = setup(); - const parent = add(root, 1000); - const child = add(parent, 20); - expect(settle()).toEqual([parent, child]); - - parent.remove(child); - expect(settle()).toEqual([]); - parent.append(child); - expect(settle()).toEqual([parent, child]); - parent.clearChildren(); - expect(settle()).toEqual([]); - }); - - it("keeps a branch walkable until every descendant reports bounds", () => { - const { root, add, settle } = setup(); - const parent = add(root, 1000); - const child = new Tree({ iterate: () => true }); - parent.append(child); - expect(settle()).toEqual([parent, child]); - - child.setBounds(2000, 2000, 2010, 2010); - expect(settle()).toEqual([]); - }); - - it.each([NaN, Infinity, 3000])("keeps invalid own bounds unbounded (minX: %s)", (minX) => { - const { root, add, settle } = setup(); - const parent = add(root, 1000); - const child = add(parent, 1000); - parent.setBounds(minX, 1000, 2000, 2000); - expect(settle()).toEqual([parent, child]); - - parent.setBounds(1000, 1000, 2000, 2000); - expect(settle()).toEqual([]); - }); + it.each([undefined, NaN, Infinity])( + "walks unknown bounds conservatively without admitting offscreen children (%s)", + (bound) => { + const { root, add, walk } = setup(); + const parent = add(root, bound); + add(parent, 2000); + walk(); + expect(walk()).toEqual([parent]); + expect(parent.isVisible()).toBe(true); + } + ); - it("recognizes skipped descendants without admitting removed nodes", () => { - const { root, add, settle, token } = setup(); + it("preserves point-query eligibility for untouched branches but rejects removed descendants", () => { + const { root, add, walk, token } = setup(); const parent = add(root, 1000); const child = add(parent, 2000); const leaf = add(child, 3000); - expect(settle()).toEqual([]); + walk(); + expect(walk()).toEqual([]); expect(leaf.isSkippedByCulling(token())).toBe(true); expect(leaf.isSkippedByCulling(token() - 1)).toBe(false); - parent.remove(child); expect(leaf.isSkippedByCulling(token())).toBe(false); }); - it("preserves intentional child suppression before and after the parent is culled", () => { - const { root, add, walk, settle, token } = setup(); + it("rejects intentionally suppressed descendants even after their ancestors are culled", () => { + const { root, add, walk, token } = setup(); const parent = add(root, 1000); const child = add(parent, 2000); const leaf = add(child, 3000); walk(); - parent.requestWalk(); - walk(child); - expect(leaf.isSkippedByCulling(token())).toBe(false); - settle(child); + child.data.prepareUpdate = () => false; + child.requestWalk(); + walk(); + walk(); expect(parent.isSkippedByCulling(token())).toBe(true); expect(child.isSkippedByCulling(token())).toBe(true); expect(leaf.isSkippedByCulling(token())).toBe(false); - + child.data.prepareUpdate = () => true; child.requestWalk(); walk(); - settle(); + walk(); expect(leaf.isSkippedByCulling(token())).toBe(true); }); + + it("reconciles reordered children without leaving stale entries", () => { + const { root, add, walk } = setup(); + const a = add(root, 10); + const b = add(root, 20); + const leaf = add(b, 30); + walk(); + root.clearChildren(); + root.append(b); + root.append(a); + expect(walk()).toEqual([b, leaf, a]); + root.setChildren([a]); + expect(walk()).toEqual([a]); + expect(root["culling"]?.index.size).toBe(1); + }); }); diff --git a/packages/graph/src/lib/Tree.ts b/packages/graph/src/lib/Tree.ts index eb69bc14..57e4bb44 100644 --- a/packages/graph/src/lib/Tree.ts +++ b/packages/graph/src/lib/Tree.ts @@ -1,4 +1,3 @@ -import type { TTileRect } from "./TileIndex"; import { TTreeCullingOptions, TreeCulling } from "./TreeCulling"; import { cache } from "./utils"; @@ -6,55 +5,40 @@ type TIterator = (node: Tree) => boolean; export interface ITree { iterate(): boolean; + /** Applies updates before any node paints; false suppresses this node's descendants. */ + prepareUpdate?(): boolean; + /** Runs before painting when this node's own viewport visibility changes. */ + onViewportChange?(visible: boolean): void; } export class Tree { public data: T; public parent?: Tree; - public children: Set = new Set(); - private childrenArray: Tree[] = []; - - private childrenWalkAllowed = false; - + private childrenWalkAllowed = true; protected childrenDirty = false; - protected zIndexGroups: Map> = new Map(); - protected zIndexChildrenCache = cache(() => { const ordered = Array.from(this.zIndexGroups.keys()) .sort((a, b) => a - b) - .map((index) => Array.from(this.zIndexGroups.get(index) || [])) - .flat(2) as Tree[]; - if (this.culling) { - // A culled walk skips children, so every child gets its draw position here instead. - for (let i = 0; i < ordered.length; i++) { - ordered[i].renderOrder = i; - } - } + .flatMap((index) => Array.from(this.zIndexGroups.get(index) || [])); + for (let i = 0; i < ordered.length; i++) ordered[i].renderOrder = i; return ordered; }); - public renderOrder = 0; - public zIndex = 1; - private ownBounds?: TTileRect; - - /** World-space bounds of this node and its descendants; NaN when unknown. */ + /** This node's own world-space bounds; NaN when unknown. */ public boundsMinX = NaN; - public boundsMinY = NaN; - public boundsMaxX = NaN; - public boundsMaxY = NaN; - - /** @internal Selection stamp of the parent's culling. */ + /** @internal Traversal stamp in the shared spatial scope. */ public cullingStamp = 0; - protected culling?: TreeCulling; + private scope?: TreeCulling; + private visible = true; constructor(data: T, parent?: Tree) { this.data = data; @@ -62,12 +46,12 @@ export class Tree { } public append(node: Tree) { + if (node.parent && node.parent !== this) node.parent.remove(node); node.parent = this; this.children.add(node); this.childrenDirty = true; this.addInZIndex(node); - this.culling?.add(node); - this.updateBounds(); + node.setScope(node.culling ?? this.scope); } protected addInZIndex(node: Tree) { @@ -81,68 +65,58 @@ export class Tree { } protected removeZIndex(node: Tree) { - const zIndex = node.zIndex; const set = this.zIndexGroups.get(node.zIndex); if (!set) return; set.delete(node); - if (!set.size) { - this.zIndexGroups.delete(zIndex); - } - this.zIndexChildrenCache.reset(); + if (!set.size) this.zIndexGroups.delete(node.zIndex); + this.zIndexChildrenCache.clear(); } public remove(node: Tree = this) { - if (node.parent === null) return; + if (node === this) { + this.parent?.remove(this); + return; + } + if (node.parent !== this) return; this.children.delete(node); + this.childrenArray = []; this.childrenDirty = true; this.removeZIndex(node); - this.culling?.delete(node); - this.updateBounds(); + node.setScope(undefined); + node.parent = undefined; } public setChildren(nodes: Tree[]) { - this.children = new Set(nodes); + const next = new Set(nodes); + for (const child of this.children) { + if (!next.has(child)) this.remove(child); + } + this.children.clear(); + this.childrenArray = []; this.childrenDirty = true; - - nodes.forEach((item) => { - this.addInZIndex(item); - this.culling?.add(item); - }); - this.updateBounds(); + this.zIndexGroups.clear(); + this.zIndexChildrenCache.clear(); + for (const node of nodes) this.append(node); } - /** - * Enables visibility culling of the children: a traversal walks only children whose bounds - * intersect the viewport, plus children that requested an update (see {@link requestWalk}). - * `undefined` disables it. - */ + /** Configures a shared spatial scope for this root and all its descendants. */ public setCulling(options: TTreeCullingOptions | undefined): void { - if (!options) { - this.culling = undefined; - } else { - const culling = new TreeCulling(this, options); - this.children.forEach((node) => culling.add(node)); - this.culling = culling; - } - this.zIndexChildrenCache.reset(); + this.setScope(undefined); + this.culling = options ? new TreeCulling(this, options) : undefined; + this.setScope(this.culling ?? this.parent?.scope); } - /** Reports this node's own world-space bounds; culling also includes its descendants. */ - public setBounds(minX: number, minY: number, maxX: number, maxY: number): void { - if (!this.ownBounds) this.ownBounds = { minX, minY, maxX, maxY }; - else { - this.ownBounds.minX = minX; - this.ownBounds.minY = minY; - this.ownBounds.maxX = maxX; - this.ownBounds.maxY = maxY; - } - this.updateBounds(); + private setScope(scope: TreeCulling | undefined): void { + if (this.scope === scope) return; + this.scope?.delete(this); + this.scope = scope; + this.cullingStamp = 0; + scope?.add(this); + for (const child of this.children) child.setScope(scope); } - private updateBounds(): void { - // Containers without their own bounds are always walked, regardless of their children. - if (!this.ownBounds) return; - let { minX, minY, maxX, maxY } = this.ownBounds; + /** Reports this node's own bounds without including descendants. */ + public setBounds(minX: number, minY: number, maxX: number, maxY: number): void { if ( !Number.isFinite(minX) || !Number.isFinite(minY) || @@ -150,91 +124,163 @@ export class Tree { !Number.isFinite(maxY) || minX > maxX || minY > maxY - ) { + ) minX = minY = maxX = maxY = NaN; - } else { - for (const child of this.children) { - minX = Math.min(minX, child.boundsMinX); - minY = Math.min(minY, child.boundsMinY); - maxX = Math.max(maxX, child.boundsMaxX); - maxY = Math.max(maxY, child.boundsMaxY); - } - } if ( Object.is(this.boundsMinX, minX) && Object.is(this.boundsMinY, minY) && Object.is(this.boundsMaxX, maxX) && Object.is(this.boundsMaxY, maxY) - ) { + ) return; - } this.boundsMinX = minX; this.boundsMinY = minY; this.boundsMaxX = maxX; this.boundsMaxY = maxY; - if (this.parent?.children.has(this)) { - this.parent.culling?.update(this); - this.parent.updateBounds(); - } + this.scope?.update(this); } - /** Makes every culling ancestor walk the branch with this node in the next traversal. */ public requestWalk(): void { - let node: Tree = this; - let parent = node.parent; - while (parent) { - parent.culling?.markPending(node); - node = parent; - parent = node.parent; - } + this.scope?.markPending(this); + } + + /** Own bounds intersect the current exact viewport and ancestors allow traversal. */ + public isVisible(): boolean { + return !this.scope || (this.visible && this.isEligible(this.scope)); } - /** Whether this node was skipped by an ancestor's culling in the traversal identified by `token`. */ public isSkippedByCulling(token: unknown): boolean { + const scope = this.scope; + return Boolean(scope && this.isEligible(scope) && scope.isSkipped(this, token)); + } + + /** Whether every ancestor still contains this branch and permits its traversal. */ + public isTraversalAllowed(): boolean { let node: Tree = this; while (node.parent) { const parent = node.parent; if (!parent.children.has(node) || !parent.childrenWalkAllowed) return false; - if (parent.culling?.isSkipped(node, token)) return true; node = parent; } - return false; + return true; + } + + private isEligible(scope: TreeCulling): boolean { + return this.scope === scope && this.isTraversalAllowed(); + } + + private setVisible(visible: boolean): void { + if (this.visible === visible) return; + this.visible = visible; + this.data.onViewportChange?.(visible); } public updateZIndex(index: number) { - if (this.zIndex === index) { - return; - } + if (this.zIndex === index) return; const parent = this.parent; if (!parent?.children.has(this)) { this.zIndex = index; return; } - // Leave the group of the previous zIndex before switching to the new one. parent.removeZIndex(this); this.zIndex = index; parent.addInZIndex(this); } public updateChildZIndex(child: Tree) { - if (!this.children.has(child)) { - return; - } + if (!this.children.has(child)) return; this.removeZIndex(child); this.addInZIndex(child); } public clearChildren() { + for (const child of this.children) child.setScope(undefined); this.children.clear(); + this.childrenArray = []; this.childrenDirty = true; - this.zIndexGroups.clear(); this.zIndexChildrenCache.clear(); - this.updateBounds(); } public traverseDown(iterator: TIterator) { - this._walkDown(iterator, 0); + if (this.culling) this.walkScope(iterator, this.culling); + else this._walkDown(iterator, 0); + } + + private walkScope(iterator: TIterator, scope: TreeCulling): void { + const prepared = new Set(); + const prepare = (node: Tree): void => { + if (prepared.has(node) || node.scope !== scope) return; + if (node !== this) { + const parent = node.parent; + if (!parent || !parent.children.has(node)) return; + prepare(parent); + if (!prepared.has(parent) || !parent.childrenWalkAllowed || node.scope !== scope) return; + } + prepared.add(node); + scope.pending.delete(node); + scope.fresh.delete(node); + node.childrenWalkAllowed = node.data.prepareUpdate?.() ?? true; + }; + prepare(this); + const viewport = scope.options.getViewport(); + scope.didCull = Boolean(viewport && (scope.options.isEnabled?.() ?? true)); + scope.walkToken = scope.options.getWalkToken?.(); + scope.stamp++; + let revision: number; + do { + revision = scope.revision; + const candidates = scope.select(scope.didCull ? viewport : undefined); + for (const node of candidates) prepare(node); + for (const node of scope.fresh) prepare(node); + for (const node of scope.pending) prepare(node); + // Reconciliation and geometry updates can expose additional nodes in this same frame. + } while (revision !== scope.revision); + + const active = new Set(); + for (const node of prepared) { + if (node.isEligible(scope) && scope.intersects(node, viewport)) active.add(node); + } + for (const node of scope.active) node.setVisible(active.has(node)); + for (const node of prepared) node.setVisible(active.has(node)); + scope.active = active; + // The root is retained for clearing its canvas, not by the descendant visibility registry. + scope.active.delete(this); + this.walkPrepared(iterator, scope, prepared); + } + + private walkPrepared(iterator: TIterator, scope: TreeCulling, prepared: Set): void { + const children = new Map(); + for (const node of prepared) { + if (node === this || !node.isEligible(scope)) continue; + const parent = node.parent; + if (!parent) continue; + let selected = children.get(parent); + if (!selected) { + parent.zIndexChildrenCache.get(); + children.set(parent, (selected = [])); + } + selected.push(node); + } + for (const selected of children.values()) selected.sort((a, b) => a.renderOrder - b.renderOrder); + const walk = (node: Tree) => { + node.cullingStamp = scope.stamp; + const allowed = iterator(node); + node.childrenWalkAllowed = allowed && node.childrenWalkAllowed; + if (node.childrenWalkAllowed) { + for (const child of children.get(node) ?? []) walk(child); + } + }; + walk(this); + // A parent can suppress children while painting; finish their already prepared lifecycles. + for (const node of prepared) { + if (node.cullingStamp === scope.stamp || !node.data.prepareUpdate || node.scope !== scope) continue; + scope.active.delete(node); + node.setVisible(false); + node.cullingStamp = scope.stamp; + const allowed = iterator(node); + node.childrenWalkAllowed = allowed && node.childrenWalkAllowed; + } } protected getChildrenArray() { @@ -248,21 +294,12 @@ export class Tree { protected _walkDown(iterator: TIterator, order: number) { this.renderOrder = order; this.childrenWalkAllowed = iterator(this); - if (this.childrenWalkAllowed) { - if (!this.children.size) { - return; - } - const children = this.zIndexChildrenCache.get(); - const selected = this.culling?.select(children); - if (selected) { - for (let i = 0; i < selected.length; i++) { - selected[i]._walkDown(iterator, selected[i].renderOrder); - } - return; - } - for (let i = 0; i < children.length; i++) { - children[i]._walkDown(iterator, i); - } + if (!this.childrenWalkAllowed) return; + const children = this.zIndexChildrenCache.get(); + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.culling) child.walkScope(iterator, child.culling); + else child._walkDown(iterator, i); } } } diff --git a/packages/graph/src/lib/TreeCulling.test.ts b/packages/graph/src/lib/TreeCulling.test.ts index 6e69b2d1..9bf36c25 100644 --- a/packages/graph/src/lib/TreeCulling.test.ts +++ b/packages/graph/src/lib/TreeCulling.test.ts @@ -2,249 +2,219 @@ import type { TTileRect } from "./TileIndex"; import { ITree, Tree } from "./Tree"; type TNodeData = ITree & { name: string }; - -const rect = (minX: number, minY: number, maxX: number, maxY: number): TTileRect => ({ minX, minY, maxX, maxY }); - -function setup({ fullWalkShare = Infinity }: { fullWalkShare?: number } = {}) { - const walked: string[] = []; - const state: { viewport: TTileRect | undefined; token: number } = { viewport: rect(0, 0, 100, 100), token: 0 }; - const data = (name: string): TNodeData => ({ - name, - iterate: () => { - walked.push(name); - return true; - }, - }); - const root = new Tree(data("root")); - root.setCulling({ - tileSize: 64, - getViewport: () => state.viewport, - getWalkToken: () => state.token, - fullWalkShare, - }); - - const add = (name: string, x: number, y: number, zIndex = 1, size = 10): Tree => { - const node = new Tree(data(name)); - node.zIndex = zIndex; - node.setBounds(x, y, x + size, y + size); - root.append(node); +const rect = (x: number, y = x, size = 10): TTileRect => ({ minX: x, minY: y, maxX: x + size, maxY: y + size }); + +function setup() { + const prepared: string[] = []; + const drawn: string[] = []; + const visited: string[] = []; + const events: string[] = []; + const state: { viewport: TTileRect | undefined; token: number } = { viewport: rect(0, 0, 100), token: 0 }; + const make = (name: string, x?: number) => { + const node = new Tree({ + name, + prepareUpdate: () => { + prepared.push(name); + events.push(`prepare:${name}`); + return true; + }, + onViewportChange: (visible) => { + events.push(`visible:${name}:${visible}`); + }, + iterate: () => { + visited.push(name); + if (node.isVisible()) { + drawn.push(name); + events.push(`draw:${name}`); + } + return true; + }, + }); + if (x !== undefined) node.setBounds(x, x, x + 10, x + 10); return node; }; - - const walk = (): string[] => { - state.token += 1; - walked.length = 0; - root.traverseDown((node) => node.data.iterate()); - return walked.filter((name) => name !== "root"); - }; - - /** New children are walked twice (first iterate, then to notice they are out of view). */ - const settle = (): string[] => { - walk(); - walk(); - return walk(); + const root = make("root"); + const configure = () => + root.setCulling({ tileSize: 64, getViewport: () => state.viewport, getWalkToken: () => state.token }); + configure(); + const add = (name: string, x?: number, parent = root) => { + const node = make(name, x); + parent.append(node); + return node; }; - - const fullOrder = (): string[] => { - const viewport = state.viewport; - state.viewport = undefined; - const order = walk(); - state.viewport = viewport; - // The full walk made every child "active"; walk once more so they settle again. - walk(); - return order; + const walk = () => { + prepared.length = drawn.length = visited.length = events.length = 0; + state.token++; + root.traverseDown((node) => node.data.iterate()); + return { prepared: prepared.slice(), drawn: drawn.slice(), visited: visited.slice(), events: events.slice() }; }; - - return { root, state, add, walk, settle, fullOrder }; + return { root, state, add, make, walk, configure }; } -describe("Tree visibility culling", () => { - it("walks only children in view, in the order of a full walk", () => { - const { add, settle, fullOrder } = setup(); - add("a", 10, 10, 2); - add("b", 500, 500, 1); - add("c", 20, 20, 1); - add("d", 30, 30, 3); - add("e", 90, 90, 1); - add("f", -500, 40, 2); - - expect(settle()).toEqual(["c", "e", "a", "d"]); - expect(fullOrder()).toEqual(["b", "c", "e", "a", "f", "d"]); - }); - - it("walks every new child, then once more, then only those in view", () => { +describe("Tree native spatial scope", () => { + it("prepares new offscreen nodes once without painting them", () => { const { add, walk } = setup(); - add("in", 10, 10); - add("out", 1000, 1000); - - expect(walk()).toEqual(["in", "out"]); - expect(walk()).toEqual(["in", "out"]); - expect(walk()).toEqual(["in"]); + add("visible", 10); + add("offscreen", 1000); + expect(walk()).toMatchObject({ + prepared: ["root", "visible", "offscreen"], + drawn: ["root", "visible"], + visited: ["root", "visible", "offscreen"], + }); + expect(walk()).toMatchObject({ prepared: ["root", "visible"], visited: ["root", "visible"] }); }); - it("keeps renderOrder of every child equal to its position in a full walk", () => { - const { root, add, settle } = setup(); - const nodes = [add("a", 10, 10), add("b", 1000, 1000), add("c", 20, 20), add("d", 2000, 2000)]; - settle(); - - // Raise an out-of-view child: it moves to the end of the draw order. - nodes[1].updateZIndex(5); - settle(); - - const expected = ["a", "c", "d", "b"]; - for (const node of nodes) { - expect(node.renderOrder).toBe(expected.indexOf(node.data.name)); - } - expect(root.children.size).toBe(4); + it("prepares pending nodes and their ancestors without walking clean siblings", () => { + const { add, walk } = setup(); + const parent = add("parent", 1000); + const child = add("child", 2000, parent); + add("sibling", 3000, parent); + walk(); + child.requestWalk(); + expect(walk()).toMatchObject({ + prepared: ["root", "parent", "child"], + visited: ["root", "parent", "child"], + drawn: ["root"], + }); + expect(walk().visited).toEqual(["root"]); }); - it("walks an out-of-view child that requested an update once", () => { - const { add, settle, walk } = setup(); - add("in", 10, 10); - const out = add("out", 1000, 1000); - settle(); - - out.requestWalk(); - expect(walk()).toEqual(["in", "out"]); - expect(walk()).toEqual(["in"]); + it("finds children and geometry changed during preparation before any painting", () => { + const { add, make, root, walk } = setup(); + const parent = add("parent", 1000); + const moved = add("moved", 2000); + walk(); + const child = make("new", 3000); + parent.data.prepareUpdate = () => { + parent.append(child); + moved.setBounds(30, 30, 40, 40); + return true; + }; + child.data.prepareUpdate = () => { + child.setBounds(50, 50, 60, 60); + return true; + }; + parent.requestWalk(); + const result = walk(); + expect(result.drawn).toEqual(["root", "new", "moved"]); + expect(result.visited).toEqual(["root", "parent", "new", "moved"]); + expect(result.events.indexOf("prepare:moved")).toBeLessThan(result.events.indexOf("draw:root")); + expect(root.children.size).toBe(2); }); - it("walks the branch of a descendant that requested an update", () => { - const { add, settle, walk } = setup(); - const out = add("out", 1000, 1000); - const leaf = new Tree({ name: "leaf", iterate: () => true }); - leaf.setBounds(1000, 1000, 1010, 1010); - out.append(leaf); - settle(); - - leaf.requestWalk(); - expect(walk()).toEqual(["out"]); - expect(walk()).toEqual([]); + it("defers requests from an already prepared node to the next frame", () => { + const { add, walk } = setup(); + const out = add("out", 1000); + let count = 0; + out.data.prepareUpdate = () => { + count++; + if (count === 1) out.requestWalk(); + return true; + }; + walk(); + expect(count).toBe(1); + walk(); + expect(count).toBe(2); + walk(); + expect(count).toBe(2); }); - it("walks a child once more after it leaves the view", () => { - const { state, add, settle, walk } = setup(); - add("a", 10, 10); - add("b", 300, 300); - expect(settle()).toEqual(["a"]); - - state.viewport = rect(250, 250, 350, 350); - expect(walk()).toEqual(["a", "b"]); - expect(walk()).toEqual(["b"]); + it("publishes both viewport transitions before the root draws without iterating a clean exit", () => { + const { state, add, walk } = setup(); + add("left", 10); + add("right", 300); + walk(); + state.viewport = rect(250, 250, 100); + const result = walk(); + expect(result.visited).toEqual(["root", "right"]); + expect(result.events.indexOf("visible:left:false")).toBeLessThan(result.events.indexOf("draw:root")); + expect(result.events.indexOf("visible:right:true")).toBeLessThan(result.events.indexOf("draw:root")); }); - it("follows bounds changes", () => { - const { add, settle, walk } = setup(); - const node = add("a", 1000, 1000); - expect(settle()).toEqual([]); - - node.setBounds(50, 50, 60, 60); - expect(walk()).toEqual(["a"]); - - node.setBounds(5000, 5000, 5010, 5010); - // Once more to notice it left the view. - expect(walk()).toEqual(["a"]); - expect(walk()).toEqual([]); + it("uses current exact bounds even for nodes just outside the viewport", () => { + const { add, walk } = setup(); + const edge = add("edge", 100); + const out = add("out", 100.01); + expect(walk().drawn).toEqual(["root", "edge"]); + expect(out.isVisible()).toBe(false); + edge.setBounds(1000, 1000, 1010, 1010); + out.setBounds(30, 30, 40, 40); + expect(walk().drawn).toEqual(["root", "out"]); }); - it("walks children without known bounds every time", () => { - const { root, settle } = setup(); - const node = new Tree({ name: "unknown", iterate: () => true }); - root.append(node); - expect(settle()).toEqual([]); - expect(root.children.has(node)).toBe(true); + it("prepares and paints everything when the viewport is disabled", () => { + const { state, add, walk } = setup(); + const parent = add("parent", 1000); + add("child", 2000, parent); + walk(); + state.viewport = undefined; + expect(walk()).toMatchObject({ prepared: ["root", "parent", "child"], drawn: ["root", "parent", "child"] }); + expect(parent.isSkippedByCulling(state.token)).toBe(false); }); - it("does not walk removed children", () => { - const { root, add, settle, walk } = setup(); - add("in", 10, 10); - const out = add("out", 1000, 1000); - settle(); - - out.requestWalk(); - root.remove(out); - expect(walk()).toEqual(["in"]); + it("disables traversal filtering without allowing offscreen paint", () => { + const { root, add, walk } = setup(); + const out = add("out", 1000); + root.setCulling({ tileSize: 64, getViewport: () => rect(0, 0, 100), isEnabled: () => false }); + walk(); + expect(walk()).toMatchObject({ prepared: ["root", "out"], visited: ["root", "out"], drawn: ["root"] }); + expect(out.isVisible()).toBe(false); }); - it.each([false, true])("releases removed children when the container becomes empty (reconcile: %s)", (reconcile) => { - const { root, add, settle, walk } = setup(); - const active = add("active", 10, 10); - const out = add("out", 1000, 1000); - expect(settle()).toEqual(["active"]); - const fresh = add("fresh", 2000, 2000); - fresh.requestWalk(); - - if (reconcile) root.clearChildren(); - for (const node of [active, out, fresh]) root.remove(node); - expect(walk()).toEqual([]); - // An empty tree does not select children again, so retained references must be gone already. - expect(root["culling"]).toMatchObject({ - index: expect.objectContaining({ size: 0 }), - active: [], - fresh: [], - pending: new Set(), - }); - - add("replacement", 3000, 3000); - expect(walk()).toEqual(["replacement"]); - expect(walk()).toEqual(["replacement"]); - expect(walk()).toEqual([]); + it("never prepares descendants behind a suppressed ancestor", () => { + const { add, walk } = setup(); + const parent = add("parent", 10); + const child = add("child", 20, parent); + add("leaf", 30, child); + parent.data.prepareUpdate = () => false; + const result = walk(); + expect(result.prepared).toEqual(["root"]); + expect(result.visited).toEqual(["root", "parent"]); }); - it("walks every child without a viewport or when the viewport covers all of them", () => { - const { state, add, settle, walk } = setup(); - add("a", 10, 10); - const out = add("b", 1000, 1000); - settle(); - expect(out.isSkippedByCulling(state.token)).toBe(true); - - state.viewport = undefined; - expect(walk()).toEqual(["a", "b"]); - expect(out.isSkippedByCulling(state.token)).toBe(false); - - state.viewport = rect(-10, -10, 2000, 2000); - expect(walk()).toEqual(["a", "b"]); + it("keeps a configured root when attached below an unscoped tree", () => { + const { root, add, walk } = setup(); + add("out", 1000); + const outer = new Tree({ iterate: () => true }); + outer.append(root); + expect(walk()).toMatchObject({ prepared: ["root", "out"], visited: ["root", "out"], drawn: ["root"] }); }); - it("walks every child when most of them are near the viewport", () => { - const { add, settle } = setup({ fullWalkShare: 0.5 }); - add("a", 10, 10); - add("b", 20, 20); - add("c", 1000, 1000); - // Two of three children are candidates: walking all of them is cheaper than culling. - expect(settle()).toEqual(["a", "b", "c"]); - - add("d", 2000, 2000); - add("e", 3000, 3000); - expect(settle()).toEqual(["a", "b"]); + it("keeps local z-index ordering after updates without scanning clean siblings each frame", () => { + const { root, add, walk } = setup(); + const a = add("a", 10); + const b = add("b", 1000); + const c = add("c", 20); + a.updateZIndex(5); + expect(walk().drawn).toEqual(["root", "c", "a"]); + b.updateZIndex(10); + expect(walk().drawn).toEqual(["root", "c", "a"]); + expect([c.renderOrder, a.renderOrder, b.renderOrder]).toEqual([0, 1, 2]); + const siblings = jest.spyOn(root.children, Symbol.iterator); + expect(walk().drawn).toEqual(["root", "c", "a"]); + expect(siblings).not.toHaveBeenCalled(); }); - it("does not fall back to a full walk when large offscreen children are a minority", () => { - const { add, settle } = setup({ fullWalkShare: 0.75 }); + it("uses the tile query when large offscreen items are a minority", () => { + const { root, add, walk } = setup(); for (let i = 0; i < 1000; i++) { - add(`out${i}`, 1000 + i * 1024, 1000, 1, i < 400 ? 512 : 10); + const x = 1000 + i * 1024; + const size = i < 400 ? 512 : 10; + add(`out${i}`).setBounds(x, 1000, x + size, 1000 + size); } - - // Only the 400 large children are candidates, below the default full-walk threshold of 750. - expect(settle()).toEqual([]); - }); - - it("marks out-of-view children as skipped for the traversal that skipped them", () => { - const { root, state, add, settle } = setup(); - const inView = add("in", 10, 10); - const out = add("out", 1000, 1000); - settle(); - - expect(out.isSkippedByCulling(state.token)).toBe(true); - expect(out.isSkippedByCulling(state.token - 1)).toBe(false); - expect(inView.isSkippedByCulling(state.token)).toBe(false); - - root.remove(out); - expect(out.isSkippedByCulling(state.token)).toBe(false); + walk(); + const scope = root["culling"]; + if (!scope) throw new Error("Missing root scope"); + const query = jest.spyOn(scope.index, "query"); + const linearScan = jest.spyOn(scope.nodes, Symbol.iterator); + + expect(walk().drawn).toEqual(["root"]); + expect(query).toHaveBeenCalledTimes(1); + expect(linearScan).not.toHaveBeenCalled(); }); - it("orders large and small selections like a full walk", () => { - const { state, add, settle, fullOrder } = setup(); + it("preserves full traversal order for sparse and dense randomized visibility selections", () => { + const { root, state, add, walk, configure } = setup(); let seed = 3; const random = () => { seed = (seed * 16807) % 2147483647; @@ -254,14 +224,27 @@ describe("Tree visibility culling", () => { for (let i = 0; i < 300; i++) { const x = Math.floor(random() * 1000); const y = Math.floor(random() * 1000); - positions.set(`n${i}`, [x, y]); - add(`n${i}`, x, y, 1 + Math.floor(random() * 3)); + const name = `n${i}`; + positions.set(name, [x, y]); + const node = add(name); + node.setBounds(x, y, x + 10, y + 10); + node.updateZIndex(1 + Math.floor(random() * 3)); } - - // Small viewport takes the sort path, a large one takes the filter path. - for (const viewport of [rect(100, 100, 200, 200), rect(0, 0, 900, 950)]) { + root.setCulling(undefined); + const fullOrder = walk().drawn; + configure(); + const scope = root["culling"]; + if (!scope) throw new Error("Missing root scope"); + const query = jest.spyOn(scope.index, "query"); + const linearScan = jest.spyOn(scope.nodes, Symbol.iterator); + + for (const [viewport, usesQuery] of [ + [rect(100, 100, 100), true], + [rect(0, 0, 950), false], + ] as const) { state.viewport = viewport; - const culled = settle(); + query.mockClear(); + linearScan.mockClear(); const visible = new Set( Array.from(positions.entries()) .filter( @@ -270,8 +253,73 @@ describe("Tree visibility culling", () => { ) .map(([name]) => name) ); - expect(culled).toEqual(fullOrder().filter((name) => visible.has(name))); - expect(culled.length).toBe(visible.size); + expect(walk().drawn).toEqual(fullOrder.filter((name) => name === "root" || visible.has(name))); + expect(query).toHaveBeenCalledTimes(usesQuery ? 1 : 0); + expect(linearScan).toHaveBeenCalledTimes(usesQuery ? 0 : 1); } }); + + it("keeps retained descendants indexed and clean during reconciliation", () => { + const { root, add, walk } = setup(); + const container = add("container"); + add("out", 1000, container); + walk(); + const index = root["culling"]?.index; + if (!index) throw new Error("Missing root scope"); + const updates = jest.spyOn(index, "set"); + root.data.prepareUpdate = () => { + root.setChildren([container]); + return true; + }; + expect(walk()).toMatchObject({ prepared: ["container"], visited: ["root", "container"] }); + expect(updates).not.toHaveBeenCalled(); + expect(index.size).toBe(2); + }); + + it("unregisters descendants even when nested children were temporarily cleared", () => { + const { root, add, walk } = setup(); + const parent = add("parent", 10); + const child = add("child", 20, parent); + add("leaf", 30, child); + walk(); + child.clearChildren(); + parent.clearChildren(); + root.remove(parent); + expect(root["culling"]?.index.size).toBe(0); + expect(walk().visited).toEqual(["root"]); + }); + + it("releases removed subtrees from the index and every retained work set", () => { + const { root, add, walk } = setup(); + const parent = add("parent", 10); + const child = add("child", 1000, parent); + walk(); + add("fresh", 2000, child); + child.requestWalk(); + root.clearChildren(); + root.remove(parent); + const scope = root["culling"]; + if (!scope) throw new Error("Missing root scope"); + expect(scope.index.size).toBe(0); + expect(scope["pending"].size).toBe(0); + expect(scope["fresh"].size).toBe(0); + expect(scope["active"].size).toBe(0); + expect(walk().visited).toEqual(["root"]); + root.append(parent); + expect(walk().visited).toEqual(["root", "parent", "child", "fresh"]); + expect(scope.index.size).toBe(3); + }); + + it("detaches the old scope when culling is removed", () => { + const { root, add, walk, configure } = setup(); + const node = add("out", 1000); + walk(); + const scope = root["culling"]; + if (!scope) throw new Error("Missing root scope"); + root.setCulling(undefined); + expect(scope.index.size).toBe(0); + expect(node.isVisible()).toBe(true); + configure(); + expect(walk().drawn).toEqual(["root"]); + }); }); diff --git a/packages/graph/src/lib/TreeCulling.ts b/packages/graph/src/lib/TreeCulling.ts index a80a1b4d..98207c6c 100644 --- a/packages/graph/src/lib/TreeCulling.ts +++ b/packages/graph/src/lib/TreeCulling.ts @@ -4,147 +4,94 @@ import type { Tree } from "./Tree"; export type TTreeCullingOptions = { /** Edge of a visibility tile in world units. */ tileSize: number; - /** - * World rect whose children are walked in the current traversal. Called once per traversal of - * the owner; `undefined` walks every child, as a tree without culling does. - */ + /** Exact world-space viewport; undefined makes every node visible. */ getViewport: () => TTileRect | undefined; + /** Disables traversal filtering while retaining the exact viewport paint gate. */ + isEnabled?: () => boolean; /** Identifies the current traversal, see {@link Tree.isSkippedByCulling}. */ getWalkToken?: () => unknown; - /** - * When the index buckets around the viewport hold more entries than this share of the - * children, every child is walked: a full walk is then cheaper than querying and ordering. - * - * @default 0.75 - */ + /** Candidate share above which a linear bounds check is cheaper than a tile query. @default 0.75 */ fullWalkShare?: number; }; -function byRenderOrder(a: Tree, b: Tree): number { - return a.renderOrder - b.renderOrder; -} - -/** - * Chooses which children of a tree node are walked in a traversal. - * - * A child is walked when its bounds intersect the viewport, when it asked for an update - * ({@link markPending}), when it was appended since the last traversal, or when it was in the - * viewport during the previous traversal (so it can notice that it left it). Every other child - * was already out of view in its last walk and has nothing to process, so skipping it changes - * nothing. The walk keeps the order of a full walk: zIndex groups, then position in the group. - */ +/** One spatial index and update queue for every descendant of a configured Tree root. */ export class TreeCulling { public readonly index: TileIndex; - /** Token of the last traversal that walked the owner's children. */ - public walkToken: unknown = undefined; + public readonly nodes = new Set(); + + public readonly pending = new Set(); + + public readonly fresh = new Set(); - private pending = new Set(); + public active = new Set(); - private fresh: Tree[] = []; + public revision = 0; - /** Children in the viewport during the last traversal, or every child after a full walk. */ - private active: readonly Tree[] = []; + public stamp = 0; - private stamp = 0; + public walkToken: unknown; - private didCull = false; + public didCull = false; constructor( - private readonly owner: Tree, - private readonly options: TTreeCullingOptions + public readonly owner: Tree, + public readonly options: TTreeCullingOptions ) { this.index = new TileIndex({ tileSize: options.tileSize }); } - /** Indexes a newly appended child; it is walked in the next traversal. */ public add(node: Tree): void { - if (this.index.has(node)) return; - this.index.set(node, node.boundsMinX, node.boundsMinY, node.boundsMaxX, node.boundsMaxY); - this.fresh.push(node); + if (node === this.owner || this.nodes.has(node)) return; + this.nodes.add(node); + this.fresh.add(node); + this.update(node); } - /** Moves an indexed child to its current bounds. */ public update(node: Tree): void { - if (!this.index.has(node)) return; + if (!this.nodes.has(node)) return; this.index.set(node, node.boundsMinX, node.boundsMinY, node.boundsMaxX, node.boundsMaxY); + this.revision++; } public delete(node: Tree): void { this.index.delete(node); + this.nodes.delete(node); this.pending.delete(node); - // An empty owner skips selection, so it cannot release these references on the next walk. - if (!this.index.size) { - this.active = []; - this.fresh = []; - } + this.fresh.delete(node); + this.active.delete(node); + this.revision++; } - /** Walks the child in the next traversal regardless of its bounds. */ public markPending(node: Tree): void { this.pending.add(node); } public isSkipped(node: Tree, token: unknown): boolean { - return this.didCull && this.walkToken === token && node.cullingStamp !== this.stamp; + return this.didCull && this.walkToken === token && this.nodes.has(node) && node.cullingStamp !== this.stamp; } - /** - * Returns the children to walk in draw order, or `undefined` when every child should be walked. - * `ordered` is the full draw order; each child's `renderOrder` is its index in it. - */ - public select(ordered: readonly Tree[]): readonly Tree[] | undefined { - this.didCull = false; - this.walkToken = this.options.getWalkToken?.(); - const viewport = this.options.getViewport(); - // Requests made while the children are walked belong to the next traversal. - const pending = this.pending; - const fresh = this.fresh; - if (pending.size) this.pending = new Set(); - if (fresh.length) this.fresh = []; - - const limit = ordered.length * (this.options.fullWalkShare ?? 0.75); - if (!viewport || this.index.covers(viewport) || this.index.countCandidates(viewport, limit) > limit) { - this.active = ordered; - return undefined; - } - - this.didCull = true; - const owner = this.owner; - const stamp = ++this.stamp; - const selected: Tree[] = []; - const nextActive: Tree[] = []; - // Pending, new and previously active nodes may have been removed since; the index holds - // only current children. - const take = (node: Tree): boolean => { - if (node.cullingStamp === stamp || node.parent !== owner || !owner.children.has(node)) return false; - node.cullingStamp = stamp; - selected.push(node); - return true; - }; - - this.index.query(viewport, (node) => { - if (node.cullingStamp === stamp) return; - node.cullingStamp = stamp; - selected.push(node); - nextActive.push(node); - }); - // A new child renders on its first iterate wherever it is; walking it once more lets it - // see that it is out of view. - for (let i = 0; i < fresh.length; i++) { - if (take(fresh[i])) nextActive.push(fresh[i]); - } - pending.forEach(take); - // Children that were in view: one more walk lets them notice that they left it. - const previous = this.active; - for (let i = 0; i < previous.length; i++) { - take(previous[i]); - } - this.active = nextActive; + public intersects(node: Tree, viewport: TTileRect | undefined): boolean { + return ( + !viewport || + Number.isNaN(node.boundsMinX) || + (node.boundsMinX <= viewport.maxX && + node.boundsMaxX >= viewport.minX && + node.boundsMinY <= viewport.maxY && + node.boundsMaxY >= viewport.minY) + ); + } - if (selected.length * Math.log2(selected.length + 1) < ordered.length) { - return selected.sort(byRenderOrder); + public select(viewport: TTileRect | undefined): Set { + const selected = new Set(); + const limit = this.nodes.size * (this.options.fullWalkShare ?? 0.75); + if (!viewport || this.index.covers(viewport) || this.index.countCandidates(viewport, limit) > limit) { + for (const node of this.nodes) { + if (this.intersects(node, viewport)) selected.add(node); + } + } else { + this.index.query(viewport, (node) => selected.add(node)); } - return ordered.filter((node) => node.cullingStamp === stamp); + return selected; } } diff --git a/packages/graph/src/services/Layer.ts b/packages/graph/src/services/Layer.ts index 8c1616ec..bc321c06 100644 --- a/packages/graph/src/services/Layer.ts +++ b/packages/graph/src/services/Layer.ts @@ -12,6 +12,7 @@ import type { GraphClassConstructor } from "../utils/types/classes"; import { debounce } from "../utils/utils/schedule"; import { ICamera, TCameraState } from "./camera/CameraService"; +import { createViewportCulling } from "./visibilityCulling"; import "./Layer.css"; @@ -318,6 +319,7 @@ export class Layer< ctx: undefined, }); + this.setChildrenCulling(createViewportCulling(this.props.graph, () => this.context.camera)); this.init(); } diff --git a/packages/graph/src/services/visibilityCulling.ts b/packages/graph/src/services/visibilityCulling.ts new file mode 100644 index 00000000..a0b94410 --- /dev/null +++ b/packages/graph/src/services/visibilityCulling.ts @@ -0,0 +1,28 @@ +import type { Graph } from "../graph"; +import type { TTileRect } from "../lib/TileIndex"; +import type { TTreeCullingOptions } from "../lib/TreeCulling"; + +import type { ICamera } from "./camera/CameraService"; + +/** + * One viewport source for a layer tree. Disabling the index preserves the exact paint gate. + */ +export function createViewportCulling( + graph: Graph, + getCamera: () => ICamera +): Omit { + const viewport: TTileRect = { minX: 0, minY: 0, maxX: 0, maxY: 0 }; + return { + tileSize: graph.graphConstants.system.VISIBILITY_TILE_SIZE, + isEnabled: () => graph.rootStore.settings.getConfigFlag("EXP_VISIBILITY_CULLING"), + getViewport: () => { + // Same rect as CameraService.isRectVisible: the full viewport, insets ignored. + const { relativeX, relativeY, relativeWidth, relativeHeight } = getCamera().getCameraState(); + viewport.minX = -relativeX; + viewport.minY = -relativeY; + viewport.maxX = -relativeX + relativeWidth; + viewport.maxY = -relativeY + relativeHeight; + return viewport; + }, + }; +} diff --git a/packages/graph/src/utils/shapes/triangle.ts b/packages/graph/src/utils/shapes/triangle.ts index 9e3e97d8..f2f3d0a7 100644 --- a/packages/graph/src/utils/shapes/triangle.ts +++ b/packages/graph/src/utils/shapes/triangle.ts @@ -1,4 +1,5 @@ import { clamp } from "../functions/clamp"; +import { TPoint } from "../types/shapes"; /** * Creates a Path2D object that represents a triangle pointing towards the direction defined by @@ -18,6 +19,22 @@ export function trangleArrowForVector( baseWidth = 5, t = 1 ): Path2D { + const [tip, left, right] = getTriangleArrowPoints(start, end, height, baseWidth, t); + const trianglePath = new Path2D(); + trianglePath.moveTo(tip.x, tip.y); + trianglePath.lineTo(left.x, left.y); + trianglePath.lineTo(right.x, right.y); + trianglePath.closePath(); + return trianglePath; +} + +export function getTriangleArrowPoints( + start: TPoint, + end: TPoint, + height = 10, + baseWidth = 5, + t = 1 +): [TPoint, TPoint, TPoint] { const normalizedT = clamp(t, 0, 1); // Calculate the tip of the triangle on the line between start and end const tipx = (1 - normalizedT) * start.x + normalizedT * end.x; @@ -45,11 +62,9 @@ export function trangleArrowForVector( const rightx = baseMidX + Math.cos(rightAngle) * baseHalfWidth; const righty = baseMidY + Math.sin(rightAngle) * baseHalfWidth; - const trianglePath = new Path2D(); - trianglePath.moveTo(tipx, tipy); // Tip of the triangle - trianglePath.lineTo(leftx, lefty); // Left point of the base - trianglePath.lineTo(rightx, righty); // Right point of the base - trianglePath.closePath(); - - return trianglePath; + return [ + { x: tipx, y: tipy }, + { x: leftx, y: lefty }, + { x: rightx, y: righty }, + ]; } From 6ad1daacc845daccbbdd2087dd325795d0896ed9 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 23 Oct 2026 03:58:58 +0300 Subject: [PATCH 05/13] perf(graph): avoid redundant connection label updates on camera changes --- .../canvas/connections/BaseConnection.ts | 4 + .../canvas/connections/BlockConnection.ts | 48 +++- .../canvas/connections/BlockConnections.ts | 17 +- .../canvas/connections/labelScale.test.ts | 254 ++++++++++++++++++ 4 files changed, 306 insertions(+), 17 deletions(-) create mode 100644 packages/graph/src/components/canvas/connections/labelScale.test.ts diff --git a/packages/graph/src/components/canvas/connections/BaseConnection.ts b/packages/graph/src/components/canvas/connections/BaseConnection.ts index d65d1c97..9a7828ed 100644 --- a/packages/graph/src/components/canvas/connections/BaseConnection.ts +++ b/packages/graph/src/components/canvas/connections/BaseConnection.ts @@ -237,6 +237,10 @@ export class BaseConnection< protected updatePoints(additionalPoints?: TPoint[], props: Props = this.props): void { const geometry = this.connectedState.$geometry.value; this.connectionPoints = geometry ? [{ ...geometry[0] }, { ...geometry[1] }] : undefined; + this.updateBounds(additionalPoints, props); + } + + protected updateBounds(additionalPoints?: TPoint[], props: Props = this.props): void { if (!this.isEntityAvailable() || !this.hasGeometry()) { this.bBox = [0, 0, 0, 0]; this.removeHitBox(); diff --git a/packages/graph/src/components/canvas/connections/BlockConnection.ts b/packages/graph/src/components/canvas/connections/BlockConnection.ts index a5ad17fc..309775f5 100644 --- a/packages/graph/src/components/canvas/connections/BlockConnection.ts +++ b/packages/graph/src/components/canvas/connections/BlockConnection.ts @@ -4,7 +4,6 @@ import { HitBoxData } from "../../../services/HitTest"; import { ESelectionStrategy } from "../../../services/selection/types"; import { TConnection } from "../../../store/connection/ConnectionState"; import { isMetaKeyEvent } from "../../../utils/functions"; -import { getFontSize } from "../../../utils/functions/text"; import { cachedMeasureText } from "../../../utils/renderers/text"; import { TPoint } from "../../../utils/types/shapes"; @@ -47,6 +46,8 @@ export class BlockConnection private labelFont = ""; + private unsubscribeLabelFontSize?: () => void; + protected geometry: { x1: number; x2: number; y1: number; y2: number } = { x1: 0, x2: 0, y1: 0, y2: 0 }; /** @@ -99,9 +100,24 @@ export class BlockConnection protected override willMount(): void { super.willMount(); this.subscribeSignal(this.connectedState.$hidden, () => this.applyShape()); - this.subscribeSignal(this.context.connectionLabelScale, () => { - if (this.state.label && this.props.showConnectionLabels) this.updatePoints(); - }); + this.syncLabelSubscription(); + } + + private syncLabelSubscription(): void { + if (!this.state.label || !this.props.showConnectionLabels) { + this.unsubscribeLabelFontSize?.(); + this.unsubscribeLabelFontSize = undefined; + } else if (!this.unsubscribeLabelFontSize) { + const fontSize = this.context.connectionLabelFontSize; + let previous = fontSize.peek(); + this.unsubscribeLabelFontSize = fontSize.subscribe((next) => { + if (previous === next) return; + previous = next; + // Offscreen labels can grow into the viewport; keep their bounds current without rebuilding paths. + this.updateBounds(); + this.updateCullingBounds(); + }); + } } protected override setVisibility(visible: boolean, options: { removeHitbox: boolean }): void { @@ -252,7 +268,10 @@ export class BlockConnection protected override checkData(): boolean { const changed = super.checkData(); - if (changed) this.updatePoints(); + if (changed) { + this.updatePoints(); + this.syncLabelSubscription(); + } return changed; } @@ -309,6 +328,11 @@ export class BlockConnection } this.generatePath(); + this.updateCullingBounds(props); + this.applyShape(this.state, props); + } + + private updateCullingBounds(props: TConnectionProps = this.props): void { if (this.hasGeometry()) { const padding = this.state.hovered || this.state.selected ? 2 : 1; const [left, top, right, bottom] = this.getBBox(); @@ -328,7 +352,6 @@ export class BlockConnection } this.setCullingBounds(minX, minY, maxX, maxY); } - this.applyShape(this.state, props); } protected override handleEvent(event: MouseEvent) { @@ -417,17 +440,12 @@ export class BlockConnection protected updateLabelGeometry(props: TConnectionProps): void { this.labelGeometry = undefined; - const scale = this.context.camera.getCameraScale(); - if ( - !this.state.label || - !props.showConnectionLabels || - !this.connectionPoints || - scale < this.context.constants.connection.MIN_ZOOM_FOR_CONNECTION_ARROW_AND_LABEL - ) { + const fontSize = this.context.connectionLabelFontSize.peek(); + if (!this.state.label || !props.showConnectionLabels || !this.connectionPoints || !fontSize) { return; } const [top, right, bottom, left] = this.context.constants.connection.LABEL.INNER_PADDINGS; - this.labelFont = `${Math.max(14, getFontSize(9, scale))}px sans-serif`; + this.labelFont = `${fontSize}px sans-serif`; const measure = cachedMeasureText(this.state.label, { font: this.labelFont }); const width = measure.width + left + right; const height = measure.height + top + bottom; @@ -451,6 +469,8 @@ export class BlockConnection } protected unmount(): void { + this.unsubscribeLabelFontSize?.(); + this.unsubscribeLabelFontSize = undefined; super.unmount(); this.context.batch.delete(this); this.context.batch.delete(this.arrowShape); diff --git a/packages/graph/src/components/canvas/connections/BlockConnections.ts b/packages/graph/src/components/canvas/connections/BlockConnections.ts index e6497b43..98933730 100644 --- a/packages/graph/src/components/canvas/connections/BlockConnections.ts +++ b/packages/graph/src/components/canvas/connections/BlockConnections.ts @@ -1,9 +1,10 @@ -import { ReadonlySignal, computed } from "@preact/signals-core"; +import { ReadonlySignal, computed, signal } from "@preact/signals-core"; import { Component, TComponentState } from "../../../lib/Component"; import { CoreComponentProps } from "../../../lib/CoreComponent"; import { ESchedulerPriority } from "../../../lib/Scheduler"; import { ConnectionState } from "../../../store/connection/ConnectionState"; +import { getFontSize } from "../../../utils/functions/text"; import { debounce } from "../../../utils/utils/schedule"; import { TGraphLayerContext } from "../layers/graphLayer/GraphLayer"; @@ -12,7 +13,7 @@ import { BlockConnection, TConnectionProps } from "./BlockConnection"; export type TGraphConnectionsContext = TGraphLayerContext & { batch: BatchPath2DRenderer; - connectionLabelScale: ReadonlySignal; + connectionLabelFontSize: ReadonlySignal; }; export class BlockConnections extends Component { @@ -24,6 +25,8 @@ export class BlockConnections extends Component this.context.graph.$camera.value.scale), + connectionLabelFontSize: this.connectionLabelFontSize, }); } protected subscribe() { return [ + computed(() => { + const scale = this.context.graph.$camera.value.scale; + const threshold = this.context.graph.$graphConstants.value.connection.MIN_ZOOM_FOR_CONNECTION_ARROW_AND_LABEL; + return scale < threshold ? 0 : Math.max(14, getFontSize(9, scale)); + }).subscribe((fontSize) => { + // An eager signal stops unchanged camera updates before they reach individual connections. + this.connectionLabelFontSize.value = fontSize; + }), this.context.graph.rootStore.settings.$connectionsSettings.subscribe(() => { this.scheduleUpdate(); }), diff --git a/packages/graph/src/components/canvas/connections/labelScale.test.ts b/packages/graph/src/components/canvas/connections/labelScale.test.ts new file mode 100644 index 00000000..ddcf15f2 --- /dev/null +++ b/packages/graph/src/components/canvas/connections/labelScale.test.ts @@ -0,0 +1,254 @@ +import { Graph } from "../../../graph"; +import { globalScheduler } from "../../../lib/Scheduler"; +import { TConnection } from "../../../store/connection/ConnectionState"; + +import { BlockConnection, TConnectionProps } from "./BlockConnection"; + +class ObservedConnection extends BlockConnection { + public labelUpdates = 0; + public boundsUpdates = 0; + public pathUpdates = 0; + public labelDraws = 0; + + public labelBounds() { + return this.labelGeometry; + } + + public linePath() { + return this.path2d; + } + + public resetCounts() { + this.labelUpdates = this.boundsUpdates = this.pathUpdates = this.labelDraws = 0; + } + + protected override updateLabelGeometry(props: TConnectionProps) { + this.labelUpdates++; + super.updateLabelGeometry(props); + } + + protected override setCullingBounds(minX: number, minY: number, maxX: number, maxY: number) { + this.boundsUpdates++; + super.setCullingBounds(minX, minY, maxX, maxY); + } + + protected override createPath() { + this.pathUpdates++; + return super.createPath(); + } + + protected override renderLabelText(ctx: CanvasRenderingContext2D) { + this.labelDraws++; + super.renderLabelText(ctx); + } +} + +function frames(count = 3) { + for (let i = 0; i < count; i++) globalScheduler.performUpdate(); +} + +describe("connection label scale updates", () => { + let graph: Graph; + let view: ObservedConnection; + + function mount(options: { label?: string; enabled?: boolean; scale?: number; y?: number; useBezier?: boolean } = {}) { + const { label, enabled, scale, y, useBezier } = { + label: "label", + enabled: true, + scale: 0.4, + y: 100, + useBezier: false, + ...options, + }; + const root = document.createElement("div"); + Object.defineProperties(root, { clientWidth: { value: 1000 }, clientHeight: { value: 800 } }); + graph = new Graph( + { + connections: [{ id: "connection", label, sourcePortId: "source", targetPortId: "target" }], + settings: { + connection: ObservedConnection, + useBezierConnections: useBezier, + showConnectionLabels: enabled, + }, + }, + root + ); + const state = graph.connections.getConnectionState("connection"); + if (!state) throw new Error("Expected connection state"); + state.$sourcePortState.value.updatePort({ x: 100, y, lookup: false }); + state.$targetPortState.value.updatePort({ x: 200, y, lookup: false }); + graph.cameraService.set({ width: 1000, height: 800, x: 0, y: 0, scale }); + graph.start(); + frames(); + const component = state.getViewComponent(); + if (!(component instanceof ObservedConnection)) throw new Error("Expected connection view"); + view = component; + view.resetCounts(); + } + + function expectNoGeometryWork() { + expect(view.labelUpdates).toBe(0); + expect(view.boundsUpdates).toBe(0); + expect(view.pathUpdates).toBe(0); + } + + function labelHeight() { + const height = view.labelBounds()?.height; + if (height === undefined) throw new Error("Expected visible label geometry"); + return height; + } + + afterEach(() => graph?.unmount()); + + it.each(["disabled", "missing"])("starts tracking zoom after an initially %s label is enabled", (initial) => { + mount({ label: initial === "missing" ? undefined : "label", enabled: initial !== "disabled" }); + expect(view.labelBounds()).toBeUndefined(); + + graph.cameraService.set({ scale: 0.3 }); + frames(); + expectNoGeometryWork(); + + if (initial === "disabled") graph.updateSettings({ showConnectionLabels: true }); + else graph.api.updateConnection("connection", { label: "label" }); + frames(); + const height = labelHeight(); + view.resetCounts(); + + graph.cameraService.set({ scale: 0.25 }); + frames(); + expect(labelHeight()).toBeGreaterThan(height); + expect(view.labelDraws).toBeGreaterThan(0); + }); + + it.each(["disabled", "removed"])("stops tracking a %s label and resumes at the current zoom", (change) => { + mount(); + if (change === "disabled") graph.updateSettings({ showConnectionLabels: false }); + else graph.api.updateConnection("connection", { label: undefined }); + frames(); + expect(view.labelBounds()).toBeUndefined(); + view.resetCounts(); + + graph.cameraService.set({ scale: 0.3 }); + frames(); + expectNoGeometryWork(); + + if (change === "disabled") graph.updateSettings({ showConnectionLabels: true }); + else graph.api.updateConnection("connection", { label: "restored" }); + frames(); + const height = labelHeight(); + view.resetCounts(); + + graph.cameraService.set({ scale: 0.25 }); + frames(); + expect(labelHeight()).toBeGreaterThan(height); + expect(view.labelDraws).toBeGreaterThan(0); + }); + + it.each([false, true])("updates label bounds without recreating the line path on zoom (Bezier: %s)", (useBezier) => { + mount({ useBezier }); + const path = view.linePath(); + const height = labelHeight(); + + graph.cameraService.set({ scale: 0.3 }); + frames(); + + expect(labelHeight()).toBeGreaterThan(height); + expect(view.pathUpdates).toBe(0); + expect(view.linePath()).toBe(path); + expect(view.labelDraws).toBeGreaterThan(0); + }); + + it.each([ + [1, 0.9], + [0.4, 0.405], + [0.1, 0.12], + ])("skips geometry and index updates when zoom %s to %s keeps the same label size or visibility", (from, to) => { + mount({ scale: from }); + const bounds = view.labelBounds(); + + graph.cameraService.set({ scale: to }); + frames(); + + expectNoGeometryWork(); + expect(view.labelBounds()).toBe(bounds); + }); + + it("does not recompute label geometry or bounds during pan", () => { + mount(); + const bounds = view.labelBounds(); + + graph.cameraService.set({ x: 10, y: 20 }); + frames(); + + expectNoGeometryWork(); + expect(view.labelBounds()).toBe(bounds); + }); + + it("updates offscreen bounds before culling as a label grows, shrinks and crosses the visibility threshold", () => { + mount({ y: 850, scale: 1 }); + expect(view.isInViewport()).toBe(false); + + // Keep the visible world's bottom at y=800; only the label reaches the viewport. + graph.cameraService.set({ scale: 0.25, y: 600 }); + frames(); + expect(view.labelBounds()?.y).toBeLessThan(800); + expect(view.isInViewport()).toBe(true); + expect(view.labelDraws).toBeGreaterThan(0); + view.resetCounts(); + + graph.cameraService.set({ scale: 1, y: 0 }); + frames(); + expect(view.labelBounds()?.y).toBeGreaterThan(800); + expect(view.isInViewport()).toBe(false); + expect(view.labelDraws).toBe(0); + + graph.cameraService.set({ scale: 0.24, y: 608 }); + frames(); + expect(view.labelBounds()).toBeUndefined(); + expect(view.isInViewport()).toBe(false); + view.resetCounts(); + + graph.cameraService.set({ scale: 0.25, y: 600 }); + frames(); + expect(view.isInViewport()).toBe(true); + expect(view.labelDraws).toBeGreaterThan(0); + }); + + it("does not update a removed connection when the camera changes", () => { + mount(); + const state = graph.connections.getConnectionState("connection"); + if (!state) throw new Error("Expected connection state"); + graph.connections.deleteConnections([state]); + frames(); + expect(view.isEntityAvailable()).toBe(false); + view.resetCounts(); + + graph.cameraService.set({ scale: 0.3 }); + frames(); + + expectNoGeometryWork(); + expect(view.labelDraws).toBe(0); + }); + + it("refreshes label visibility when the zoom threshold changes without moving the camera", () => { + mount({ scale: 0.2 }); + expect(view.labelBounds()).toBeUndefined(); + + graph.setConstants({ connection: { MIN_ZOOM_FOR_CONNECTION_ARROW_AND_LABEL: 0.1 } }); + frames(); + expect(view.labelBounds()).toBeDefined(); + expect(view.labelDraws).toBeGreaterThan(0); + view.resetCounts(); + + graph.setConstants({ connection: { MIN_ZOOM_FOR_CONNECTION_ARROW_AND_LABEL: 0.3 } }); + frames(); + expect(view.labelBounds()).toBeUndefined(); + expect(view.labelDraws).toBe(0); + view.resetCounts(); + + graph.cameraService.set({ scale: 0.35 }); + frames(); + expect(view.labelBounds()).toBeDefined(); + expect(view.labelDraws).toBeGreaterThan(0); + }); +}); From 44de2de1dad858f2102cd04b6e3bb768938b8393 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 23 Oct 2026 13:48:55 +0300 Subject: [PATCH 06/13] perf(graph): reuse full-viewport tree traversal --- .../components/canvas/GraphComponent/index.ts | 2 +- .../graph/src/lib/Component.phases.test.ts | 17 + packages/graph/src/lib/Component.ts | 2 +- packages/graph/src/lib/CoreComponent.ts | 2 +- packages/graph/src/lib/Tree.dense.test.ts | 447 ++++++++++++++++++ packages/graph/src/lib/Tree.ts | 233 +++++++-- packages/graph/src/lib/TreeCulling.ts | 12 +- 7 files changed, 667 insertions(+), 48 deletions(-) create mode 100644 packages/graph/src/lib/Tree.dense.test.ts diff --git a/packages/graph/src/components/canvas/GraphComponent/index.ts b/packages/graph/src/components/canvas/GraphComponent/index.ts index 0eaf7c2e..1e44630d 100644 --- a/packages/graph/src/components/canvas/GraphComponent/index.ts +++ b/packages/graph/src/components/canvas/GraphComponent/index.ts @@ -364,7 +364,7 @@ export class GraphComponent< } public override iterate(): boolean { - this.prepareUpdate(); + if (!this.updatePrepared) this.prepareUpdate(); this.shouldRender = this.isVisible(); return super.iterate(); } diff --git a/packages/graph/src/lib/Component.phases.test.ts b/packages/graph/src/lib/Component.phases.test.ts index 7a7a542c..dcfbc16a 100644 --- a/packages/graph/src/lib/Component.phases.test.ts +++ b/packages/graph/src/lib/Component.phases.test.ts @@ -76,6 +76,23 @@ test("prepares children and data before painting, without repeating preparation" CoreComponent.unmount(probe); }); +test("reuses explicit preparation and prepares a standalone iteration only once", () => { + const probe = new Probe(); + const prepare = jest.spyOn(probe, "prepareUpdate"); + probe.change(1); + probe.prepareUpdate(); + probe.iterate(); + expect(prepare).toHaveBeenCalledTimes(1); + expect(probe.state.value).toBe(1); + + probe.change(2); + probe.iterate(); + expect(prepare).toHaveBeenCalledTimes(2); + expect(probe.state.value).toBe(2); + expect(probe.events.filter((event) => event === "complete")).toHaveLength(2); + CoreComponent.unmount(probe); +}); + test("completes prepared children when parent paint suppresses them, then consumes later props", () => { class Child extends Component<{ value: number }> { public painted: number[] = []; diff --git a/packages/graph/src/lib/Component.ts b/packages/graph/src/lib/Component.ts index b35fd060..4c191102 100644 --- a/packages/graph/src/lib/Component.ts +++ b/packages/graph/src/lib/Component.ts @@ -162,7 +162,7 @@ export class Component< } public iterate() { - this.prepareUpdate(); + if (!this.updatePrepared) this.prepareUpdate(); super.iterate(); if (this.shouldRender && this.isInViewport()) { diff --git a/packages/graph/src/lib/CoreComponent.ts b/packages/graph/src/lib/CoreComponent.ts index f01c28cc..9c6dcf40 100644 --- a/packages/graph/src/lib/CoreComponent.ts +++ b/packages/graph/src/lib/CoreComponent.ts @@ -209,7 +209,7 @@ export class CoreComponent< } public iterate(): boolean { - this.prepareUpdate(); + if (!this.updatePrepared) this.prepareUpdate(); this.updatePrepared = false; return true; } diff --git a/packages/graph/src/lib/Tree.dense.test.ts b/packages/graph/src/lib/Tree.dense.test.ts new file mode 100644 index 00000000..f634e3bc --- /dev/null +++ b/packages/graph/src/lib/Tree.dense.test.ts @@ -0,0 +1,447 @@ +import type { TTileRect } from "./TileIndex"; +import { ITree, Tree } from "./Tree"; + +type Probe = ITree & { name: string; prepare?: () => boolean; paint?: () => boolean }; +const viewport = (maxX: number): TTileRect => ({ minX: 0, minY: 0, maxX, maxY: maxX }); + +function setup() { + const state = { viewport: viewport(1000), enabled: true, token: 0 }; + const events: string[] = []; + const batch = new Set(); + const make = (name: string, x?: number) => { + const node: Tree = new Tree({ + name, + prepareUpdate: () => { + events.push(`prepare:${name}`); + return node.data.prepare?.() ?? true; + }, + onViewportChange: (visible) => { + events.push(`visible:${name}:${visible}`); + if (visible) batch.add(name); + else batch.delete(name); + }, + iterate: (): boolean => { + events.push(`iterate:${name}`); + if (name === "root") events.push(`batch:${Array.from(batch).sort().join(",")}`); + if (node.isVisible()) events.push(`draw:${name}`); + return node.data.paint?.() ?? true; + }, + }); + if (x !== undefined) node.setBounds(x, x, x + 10, x + 10); + if (name !== "root") batch.add(name); + return node; + }; + const root = make("root"); + root.setCulling({ + tileSize: 64, + getViewport: () => state.viewport, + isEnabled: () => state.enabled, + getWalkToken: () => state.token, + }); + const add = (name: string, x?: number, parent = root) => { + const node = make(name, x); + parent.append(node); + return node; + }; + const walk = () => { + events.length = 0; + state.token++; + root.traverseDown((node) => node.data.iterate()); + const names = (prefix: string) => + events.filter((event) => event.startsWith(prefix)).map((event) => event.slice(prefix.length)); + return { prepared: names("prepare:"), drawn: names("draw:"), visited: names("iterate:"), events: events.slice() }; + }; + return { root, state, add, make, walk }; +} + +describe("Tree full-viewport traversal", () => { + it.each(["deep", "wide"])("keeps ancestor checks linear when every node in a %s tree is visible", (shape) => { + const { root, add, walk } = setup(); + const nodes = [root]; + for (let i = 0; i < 64; i++) nodes.push(add(`node${i}`, 10, shape === "deep" ? nodes[nodes.length - 1] : root)); + walk(); + const reads = { parent: 0 }; + for (const node of nodes) { + let parent = node.parent; + Object.defineProperty(node, "parent", { + configurable: true, + get: () => { + reads.parent++; + return parent; + }, + set: (value: Tree | undefined) => { + parent = value; + }, + }); + } + + const result = walk(); + expect(result.drawn).toHaveLength(nodes.length); + expect(reads.parent).toBeLessThanOrEqual(nodes.length * 12); + }); + + it("updates the shared batch visibility before painting across full, sparse and disabled culling", () => { + const { root, state, add, walk } = setup(); + add("left", 10); + const right = add("right", 300); + const leaf = add("leaf", 600, right); + const scope = root["culling"]; + expect(walk().drawn).toEqual(["root", "left", "right", "leaf"]); + + state.viewport = viewport(100); + let result = walk(); + expect(result.prepared).toEqual(["root", "left"]); + expect(result.drawn).toEqual(["root", "left"]); + expect(result.events).toContain("batch:left"); + expect(result.events.indexOf("visible:right:false")).toBeLessThan(result.events.indexOf("draw:root")); + expect(result.events.indexOf("visible:leaf:false")).toBeLessThan(result.events.indexOf("draw:root")); + expect(leaf.isSkippedByCulling(state.token)).toBe(true); + + state.viewport = viewport(1000); + result = walk(); + expect(result.drawn).toEqual(["root", "left", "right", "leaf"]); + expect(result.events).toContain("batch:leaf,left,right"); + expect(result.events.indexOf("visible:leaf:true")).toBeLessThan(result.events.indexOf("draw:root")); + expect(leaf.isSkippedByCulling(state.token)).toBe(false); + + state.viewport = viewport(100); + state.enabled = false; + result = walk(); + expect(result.prepared).toEqual(["root", "left", "right", "leaf"]); + expect(result.drawn).toEqual(["root", "left"]); + expect(result.events).toContain("batch:left"); + expect(leaf.isSkippedByCulling(state.token)).toBe(false); + + state.enabled = true; + expect(walk().visited).toEqual(["root", "left"]); + expect(root["culling"]).toBe(scope); + expect(scope?.index.size).toBe(3); + }); + + it("uses final geometry and child order when preparation changes a fully visible tree", () => { + const { root, add, make, walk } = setup(); + const early = add("early", 10); + add("visibleChild", 20, early); + const parent = add("parent", 30); + const removed = add("removed", 50, parent); + const tail = add("tail", 70); + walk(); + const replacement = make("replacement", 90); + parent.data.prepare = () => { + parent.setChildren([replacement]); + early.setBounds(2000, 2000, 2010, 2010); + tail.updateZIndex(0); + return true; + }; + + const result = walk(); + expect(result.drawn).toEqual(["root", "tail", "visibleChild", "parent", "replacement"]); + expect(result.prepared.filter((name) => name === "early")).toHaveLength(1); + expect(result.visited).not.toContain("removed"); + expect(result.events.indexOf("prepare:replacement")).toBeLessThan(result.events.indexOf("draw:root")); + expect(result.events.indexOf("visible:early:false")).toBeLessThan(result.events.indexOf("draw:root")); + expect(removed.parent).toBeUndefined(); + expect(root["culling"]?.index.size).toBe(5); + }); + + it("prepares and completes a node once when a later preparation reparents it in the same scope", () => { + const { add, walk } = setup(); + const original = add("original", 10); + const child = add("child", 20, original); + const destination = add("destination", 30); + walk(); + destination.data.prepare = () => { + destination.append(child); + return true; + }; + + const result = walk(); + expect(result.prepared).toEqual(["root", "original", "child", "destination"]); + expect(result.visited).toEqual(["root", "original", "destination", "child"]); + expect(result.drawn).toEqual(["root", "original", "destination", "child"]); + expect(child.parent).toBe(destination); + }); + + it("does not prepare a child moved under a suppressed parent from a captured sibling list", () => { + const { add, walk } = setup(); + const original = add("original", 10); + const mover = add("mover", 20, original); + const moved = add("moved", 30, original); + const destination = add("destination", 40); + walk(); + mover.data.prepare = () => { + destination.append(moved); + return true; + }; + destination.data.prepare = () => false; + + const result = walk(); + expect(result.prepared).toEqual(["root", "original", "mover", "destination"]); + expect(result.visited).toEqual(["root", "original", "mover", "destination"]); + expect(moved.isTraversalAllowed()).toBe(false); + }); + + it("does not reuse a detached node after a later preparation removes it from a warmed tree", () => { + const { root, add, walk } = setup(); + const removed = add("removed", 10); + const later = add("later", 20); + walk(); + walk(); + later.data.prepare = () => { + root.remove(removed); + later.data.prepare = undefined; + return true; + }; + + expect(walk()).toMatchObject({ prepared: ["root", "removed", "later"], visited: ["root", "later"] }); + expect(walk()).toMatchObject({ prepared: ["root", "later"], visited: ["root", "later"] }); + expect(walk().prepared).toEqual(["root", "later"]); + expect(removed.parent).toBeUndefined(); + }); + + it("discovers a new child in an earlier branch and honors suppression during cached preparation", () => { + const { add, make, walk } = setup(); + const earlier = add("earlier", 10); + add("retained", 20, earlier); + const later = add("later", 30); + add("suppressed", 40, later); + walk(); + walk(); + const created = make("created", 50); + later.data.prepare = () => { + earlier.append(created); + later.data.prepare = () => false; + return false; + }; + + const result = walk(); + expect(result.prepared).toEqual(["root", "earlier", "retained", "later", "created"]); + expect(result.drawn).toEqual(["root", "earlier", "retained", "created", "later"]); + expect(result.events.indexOf("prepare:created")).toBeLessThan(result.events.indexOf("draw:root")); + expect(result.events.indexOf("visible:suppressed:false")).toBeLessThan(result.events.indexOf("draw:root")); + expect(walk().prepared.sort()).toEqual(["created", "earlier", "later", "retained", "root"]); + later.data.prepare = () => true; + expect(walk().drawn).toEqual(["root", "earlier", "retained", "created", "later", "suppressed"]); + }); + + it("updates the paint order after a z-index change in a warmed fully visible tree", () => { + const { add, walk } = setup(); + const first = add("first", 10); + add("child", 20, first); + const second = add("second", 30); + walk(); + walk(); + first.updateZIndex(5); + expect(walk().drawn).toEqual(["root", "second", "first", "child"]); + second.updateZIndex(10); + expect(walk().drawn).toEqual(["root", "first", "child", "second"]); + }); + + it("completes a child once when painting reparents it into a branch visited later", () => { + const { add, walk } = setup(); + const original = add("original", 10); + const child = add("child", 20, original); + const mover = add("mover", 30); + const destination = add("destination", 40); + walk(); + walk(); + mover.data.paint = () => { + destination.append(child); + return true; + }; + + expect(walk()).toMatchObject({ + visited: ["root", "original", "child", "mover", "destination"], + drawn: ["root", "original", "child", "mover", "destination"], + }); + expect(walk().drawn).toEqual(["root", "original", "mover", "destination", "child"]); + }); + + it("releases cached preparation references when a subtree is removed without another traversal", () => { + const { root, add, walk } = setup(); + const parent = add("parent", 10); + add("child", 20, parent); + walk(); + walk(); + root.remove(parent); + + const scope = root["culling"]; + if (!scope) throw new Error("Missing scope"); + expect(scope.nodes.size).toBe(0); + expect(scope.fullPreparation?.nodes.some((node) => node !== root) ?? false).toBe(false); + expect(scope.fullPaint?.nodes.some((node) => node !== root) ?? false).toBe(false); + }); + + it("prepares and completes transferred descendants once in every destination scope", () => { + const source = setup(); + const destination = setup(); + const parent = source.add("parent", 10); + const child = source.add("child", 20, parent); + const completed: string[] = []; + const prepared: string[] = []; + for (const node of [parent, child]) { + node.data.prepare = () => { + prepared.push(node.data.name); + return true; + }; + node.data.paint = () => { + completed.push(node.data.name); + return true; + }; + } + source.walk(); + destination.root.append(parent); + destination.walk(); + source.walk(); + source.root.append(parent); + source.walk(); + + expect(prepared).toEqual(["parent", "child", "parent", "child", "parent", "child"]); + expect(completed).toEqual(prepared); + expect(source.root["culling"]?.index.size).toBe(2); + expect(destination.root["culling"]?.index.size).toBe(0); + }); + + it("invalidates cached full visibility when an attached node was hidden in its previous scope", () => { + const source = setup(); + source.state.viewport = viewport(100); + const child = source.add("child", 300); + source.walk(); + expect(child.isVisible()).toBe(false); + const destination = setup(); + destination.add("resident", 10); + destination.walk(); + + destination.root.append(child); + destination.walk(); + expect(child.isVisible()).toBe(true); + destination.state.viewport = viewport(100); + destination.walk(); + expect(child.isVisible()).toBe(false); + }); + + it("updates the root's own visibility when its bounds change while all descendants remain visible", () => { + const { root, add, walk } = setup(); + add("child", 10); + walk(); + root.setBounds(2000, 2000, 2010, 2010); + expect(walk().drawn).toEqual(["child"]); + root.setBounds(20, 20, 30, 30); + expect(walk().drawn).toEqual(["root", "child"]); + }); + + it("checks finite root bounds against viewport changes even when every descendant stays visible", () => { + const { root, state, add, walk } = setup(); + root.setBounds(500, 500, 510, 510); + add("child", 10); + state.viewport = viewport(100); + expect(walk().drawn).toEqual(["child"]); + state.viewport = viewport(1000); + expect(walk().drawn).toEqual(["root", "child"]); + state.viewport = viewport(100); + expect(walk().drawn).toEqual(["child"]); + }); + + it("consumes dirty offscreen geometry before switching back to a full viewport", () => { + const { state, add, walk } = setup(); + add("left", 10); + const moved = add("moved", 300); + walk(); + state.viewport = viewport(100); + walk(); + moved.data.prepare = () => { + moved.setBounds(40, 40, 50, 50); + return true; + }; + moved.requestWalk(); + const result = walk(); + expect(result.drawn).toEqual(["root", "left", "moved"]); + expect(result.events).toContain("batch:left,moved"); + expect(result.events.indexOf("prepare:moved")).toBeLessThan(result.events.indexOf("draw:root")); + state.viewport = viewport(1000); + expect(walk().drawn).toEqual(["root", "left", "moved"]); + }); + + it("finishes prepared descendants without paint when an ancestor suppresses them during paint", () => { + const { add, walk } = setup(); + const parent = add("parent", 10); + const child = add("child", 20, parent); + const leaf = add("leaf", 30, child); + walk(); + parent.data.paint = () => false; + let result = walk(); + expect(result.prepared).toEqual(["root", "parent", "child", "leaf"]); + expect(result.visited).toEqual(["root", "parent", "child", "leaf"]); + expect(result.drawn).toEqual(["root", "parent"]); + expect([child.isVisible(), leaf.isVisible()]).toEqual([false, false]); + + parent.data.prepare = () => false; + result = walk(); + expect(result.prepared).toEqual(["root", "parent"]); + expect(result.visited).toEqual(["root", "parent"]); + parent.data.prepare = parent.data.paint = () => true; + expect(walk().drawn).toEqual(["root", "parent", "child", "leaf"]); + }); + + it("invalidates an ancestry check when painting suppresses a previously allowed branch", () => { + const { root, add, walk } = setup(); + const parent = add("parent", 10); + const child = add("child", 20, parent); + const leaf = add("leaf", 30, child); + walk(); + const allowed: boolean[] = []; + parent.data.paint = () => { + allowed.push(leaf.isTraversalAllowed()); + return false; + }; + child.data.paint = () => { + allowed.push(leaf.isTraversalAllowed()); + return true; + }; + + expect(walk().drawn).toEqual(["root", "parent"]); + expect(allowed).toEqual([true, false]); + expect(leaf.isTraversalAllowed()).toBe(false); + root.append(child); + expect(leaf.isTraversalAllowed()).toBe(true); + parent.append(child); + expect(leaf.isTraversalAllowed()).toBe(false); + }); + + it("uses live ancestry when a later iterator reparents an already queried branch", () => { + const { add, walk } = setup(); + const parent = add("parent", 10); + const child = add("child", 20, parent); + const leaf = add("leaf", 30, child); + const suppressed = add("suppressed", 40); + suppressed.data.prepare = () => false; + walk(); + const allowed: boolean[] = []; + suppressed.data.paint = () => { + allowed.push(leaf.isTraversalAllowed()); + suppressed.append(child); + allowed.push(leaf.isTraversalAllowed()); + return true; + }; + + walk(); + expect(allowed).toEqual([true, false]); + expect(leaf.isVisible()).toBe(false); + expect(leaf.isTraversalAllowed()).toBe(false); + }); + + it("does not lose a preparation request when a full-viewport node requests the next frame", () => { + const { state, add, walk } = setup(); + const node = add("node", 500); + walk(); + node.data.prepare = () => { + node.requestWalk(); + node.data.prepare = undefined; + return true; + }; + walk(); + state.viewport = viewport(100); + expect(walk().prepared).toEqual(["root", "node"]); + expect(walk().prepared).toEqual(["root"]); + }); +}); diff --git a/packages/graph/src/lib/Tree.ts b/packages/graph/src/lib/Tree.ts index 57e4bb44..85153105 100644 --- a/packages/graph/src/lib/Tree.ts +++ b/packages/graph/src/lib/Tree.ts @@ -12,6 +12,8 @@ export interface ITree { } export class Tree { + // Moving between scopes must not repeat a node's lifecycle in the same traversal. + private static walkStamp = 0; public data: T; public parent?: Tree; public children: Set = new Set(); @@ -39,6 +41,9 @@ export class Tree { protected culling?: TreeCulling; private scope?: TreeCulling; private visible = true; + private preparedStamp = 0; + private eligibilityRevision = -1; + private traversalAllowed = true; constructor(data: T, parent?: Tree) { this.data = data; @@ -87,6 +92,7 @@ export class Tree { } public setChildren(nodes: Tree[]) { + if (this.scope) this.scope.revision++; const next = new Set(nodes); for (const child of this.children) { if (!next.has(child)) this.remove(child); @@ -110,7 +116,7 @@ export class Tree { if (this.scope === scope) return; this.scope?.delete(this); this.scope = scope; - this.cullingStamp = 0; + this.eligibilityRevision = -1; scope?.add(this); for (const child of this.children) child.setScope(scope); } @@ -146,7 +152,11 @@ export class Tree { /** Own bounds intersect the current exact viewport and ancestors allow traversal. */ public isVisible(): boolean { - return !this.scope || (this.visible && this.isEligible(this.scope)); + const scope = this.scope; + return ( + !scope || + (this.visible && ((scope.walking && scope.fullVisibilityRevision === scope.revision) || this.isEligible(scope))) + ); } public isSkippedByCulling(token: unknown): boolean { @@ -156,6 +166,17 @@ export class Tree { /** Whether every ancestor still contains this branch and permits its traversal. */ public isTraversalAllowed(): boolean { + const scope = this.scope; + if (scope?.walking) { + if (this.visible && this !== scope.owner && scope.fullVisibilityRevision === scope.revision) return true; + if (this.eligibilityRevision !== scope.revision) { + const parent = this.parent; + this.traversalAllowed = + !parent || (parent.children.has(this) && parent.childrenWalkAllowed && parent.isTraversalAllowed()); + this.eligibilityRevision = scope.revision; + } + return this.traversalAllowed; + } let node: Tree = this; while (node.parent) { const parent = node.parent; @@ -169,6 +190,12 @@ export class Tree { return this.scope === scope && this.isTraversalAllowed(); } + private setChildrenWalkAllowed(allowed: boolean): void { + if (this.childrenWalkAllowed === allowed) return; + this.childrenWalkAllowed = allowed; + if (this.scope) this.scope.revision++; + } + private setVisible(visible: boolean): void { if (this.visible === visible) return; this.visible = visible; @@ -185,12 +212,14 @@ export class Tree { parent.removeZIndex(this); this.zIndex = index; parent.addInZIndex(this); + if (this.scope) this.scope.revision++; } public updateChildZIndex(child: Tree) { if (!this.children.has(child)) return; this.removeZIndex(child); this.addInZIndex(child); + if (this.scope) this.scope.revision++; } public clearChildren() { @@ -208,70 +237,132 @@ export class Tree { } private walkScope(iterator: TIterator, scope: TreeCulling): void { - const prepared = new Set(); + const wasWalking = scope.walking; + scope.walking = true; + scope.stamp = ++Tree.walkStamp; + // An ancestor outside this scope can change its traversal permission independently. + if (this.parent) scope.revision++; + try { + this.prepareAndWalk(iterator, scope); + } finally { + scope.walking = wasWalking; + } + } + + private prepareData(scope: TreeCulling): void { + this.preparedStamp = scope.stamp; + if (scope.pending.size) scope.pending.delete(this); + if (scope.fresh.size) scope.fresh.delete(this); + this.setChildrenWalkAllowed(this.data.prepareUpdate?.() ?? true); + } + + private prepareAndWalk(iterator: TIterator, scope: TreeCulling): void { + let prepared: Tree[] = []; + const prepareNode = (node: Tree): void => { + prepared.push(node); + node.prepareData(scope); + }; const prepare = (node: Tree): void => { - if (prepared.has(node) || node.scope !== scope) return; + if (node.preparedStamp === scope.stamp || node.scope !== scope) return; if (node !== this) { const parent = node.parent; if (!parent || !parent.children.has(node)) return; prepare(parent); - if (!prepared.has(parent) || !parent.childrenWalkAllowed || node.scope !== scope) return; + if (parent.preparedStamp !== scope.stamp || !parent.childrenWalkAllowed || node.scope !== scope) return; } - prepared.add(node); - scope.pending.delete(node); - scope.fresh.delete(node); - node.childrenWalkAllowed = node.data.prepareUpdate?.() ?? true; + prepareNode(node); }; prepare(this); const viewport = scope.options.getViewport(); scope.didCull = Boolean(viewport && (scope.options.isEnabled?.() ?? true)); scope.walkToken = scope.options.getWalkToken?.(); - scope.stamp++; + const fullWalk = !scope.didCull || !viewport || scope.index.covers(viewport); + const preparationRevision = scope.revision; + if (fullWalk) { + const cached = scope.fullPreparation; + if (cached?.revision === scope.revision) { + prepared = cached.nodes; + for (let i = 1; i < cached.nodes.length; i++) { + cached.nodes[i].prepareData(scope); + if (cached.revision !== scope.revision) { + prepared = cached.nodes.slice(0, i + 1); + break; + } + } + } + const prepareBranch = (node: Tree): void => { + if (node.scope !== scope) return; + if (node.preparedStamp !== scope.stamp) prepareNode(node); + if (!node.childrenWalkAllowed || !node.children.size) return; + for (const child of node.getChildrenArray()) { + if (child.parent === node) prepareBranch(child); + } + }; + if (cached?.revision !== scope.revision) prepareBranch(this); + } let revision: number; do { revision = scope.revision; - const candidates = scope.select(scope.didCull ? viewport : undefined); - for (const node of candidates) prepare(node); + if (!fullWalk) { + const candidates = scope.select(scope.didCull ? viewport : undefined); + for (const node of candidates) prepare(node); + } for (const node of scope.fresh) prepare(node); for (const node of scope.pending) prepare(node); // Reconciliation and geometry updates can expose additional nodes in this same frame. } while (revision !== scope.revision); + if (fullWalk) { + scope.fullPreparation = + preparationRevision === scope.revision ? { revision: scope.revision, nodes: prepared } : undefined; + } - const active = new Set(); - for (const node of prepared) { - if (node.isEligible(scope) && scope.intersects(node, viewport)) active.add(node); + const fullyVisible = (!viewport || scope.index.covers(viewport)) && this.isTraversalAllowed(); + this.setVisible(this.isEligible(scope) && scope.intersects(this, viewport)); + // Camera movement cannot change visibility while it still contains the unchanged tree. + if (!fullyVisible || scope.fullVisibilityRevision !== scope.revision) { + const visibilityRevision = scope.revision; + scope.fullVisibilityRevision = -1; + const active = scope.active; + for (const node of active) { + if (node.preparedStamp !== scope.stamp) { + active.delete(node); + node.setVisible(false); + } + } + for (const node of prepared) { + const visible = node.isEligible(scope) && scope.intersects(node, viewport); + node.setVisible(visible); + if (visible) active.add(node); + else active.delete(node); + } + // The root is retained for clearing its canvas, not by the descendant visibility registry. + active.delete(this); + if (fullyVisible && scope.revision === visibilityRevision) scope.fullVisibilityRevision = visibilityRevision; } - for (const node of scope.active) node.setVisible(active.has(node)); - for (const node of prepared) node.setVisible(active.has(node)); - scope.active = active; - // The root is retained for clearing its canvas, not by the descendant visibility registry. - scope.active.delete(this); - this.walkPrepared(iterator, scope, prepared); + this.walkPrepared(iterator, scope, prepared, fullWalk); } - private walkPrepared(iterator: TIterator, scope: TreeCulling, prepared: Set): void { - const children = new Map(); - for (const node of prepared) { - if (node === this || !node.isEligible(scope)) continue; - const parent = node.parent; - if (!parent) continue; - let selected = children.get(parent); - if (!selected) { - parent.zIndexChildrenCache.get(); - children.set(parent, (selected = [])); + private walkPrepared(iterator: TIterator, scope: TreeCulling, prepared: Tree[], fullWalk: boolean): void { + let walked: number; + if (fullWalk) { + walked = this.paintFull(iterator, scope); + } else { + const children = new Map(); + for (const node of prepared) { + if (node === this || !node.isEligible(scope)) continue; + const parent = node.parent; + if (!parent) continue; + let selected = children.get(parent); + if (!selected) { + parent.zIndexChildrenCache.get(); + children.set(parent, (selected = [])); + } + selected.push(node); } - selected.push(node); + for (const selected of children.values()) selected.sort((a, b) => a.renderOrder - b.renderOrder); + walked = this.paintPrepared(iterator, scope, children); } - for (const selected of children.values()) selected.sort((a, b) => a.renderOrder - b.renderOrder); - const walk = (node: Tree) => { - node.cullingStamp = scope.stamp; - const allowed = iterator(node); - node.childrenWalkAllowed = allowed && node.childrenWalkAllowed; - if (node.childrenWalkAllowed) { - for (const child of children.get(node) ?? []) walk(child); - } - }; - walk(this); + if (walked === prepared.length) return; // A parent can suppress children while painting; finish their already prepared lifecycles. for (const node of prepared) { if (node.cullingStamp === scope.stamp || !node.data.prepareUpdate || node.scope !== scope) continue; @@ -279,8 +370,62 @@ export class Tree { node.setVisible(false); node.cullingStamp = scope.stamp; const allowed = iterator(node); - node.childrenWalkAllowed = allowed && node.childrenWalkAllowed; + if (!allowed) node.setChildrenWalkAllowed(false); + } + } + + private paintFull(iterator: TIterator, scope: TreeCulling): number { + let ordered = scope.fullPaint; + if (ordered?.revision !== scope.revision) { + const nodes: Tree[] = []; + const collect = (node: Tree): void => { + if (node !== this && (node.preparedStamp !== scope.stamp || !node.isEligible(scope))) return; + nodes.push(node); + if (!node.childrenWalkAllowed || !node.children.size) return; + for (const child of node.zIndexChildrenCache.get()) { + if (child.parent === node) collect(child); + } + }; + collect(this); + scope.fullPaint = ordered = { revision: scope.revision, nodes }; + } + let walked = 0; + for (let i = 0; i < ordered.nodes.length; i++) { + const node = ordered.nodes[i]; + if ( + scope.revision !== ordered.revision && + node !== this && + (node.preparedStamp !== scope.stamp || node.cullingStamp === scope.stamp || !node.isEligible(scope)) + ) + continue; + node.cullingStamp = scope.stamp; + const allowed = iterator(node); + if (!allowed) node.setChildrenWalkAllowed(false); + walked++; + } + return walked; + } + + private paintPrepared(iterator: TIterator, scope: TreeCulling, children: Map): number { + this.cullingStamp = scope.stamp; + const allowed = iterator(this); + if (!allowed) this.setChildrenWalkAllowed(false); + let walked = 1; + if (this.childrenWalkAllowed && this.children.size) { + const selected = children.get(this) ?? []; + for (const child of selected) { + if ( + child.preparedStamp === scope.stamp && + child.cullingStamp !== scope.stamp && + child.scope === scope && + child.parent === this && + child.isTraversalAllowed() + ) { + walked += child.paintPrepared(iterator, scope, children); + } + } } + return walked; } protected getChildrenArray() { @@ -293,7 +438,7 @@ export class Tree { protected _walkDown(iterator: TIterator, order: number) { this.renderOrder = order; - this.childrenWalkAllowed = iterator(this); + this.setChildrenWalkAllowed(iterator(this)); if (!this.childrenWalkAllowed) return; const children = this.zIndexChildrenCache.get(); for (let i = 0; i < children.length; i++) { diff --git a/packages/graph/src/lib/TreeCulling.ts b/packages/graph/src/lib/TreeCulling.ts index 98207c6c..5b444478 100644 --- a/packages/graph/src/lib/TreeCulling.ts +++ b/packages/graph/src/lib/TreeCulling.ts @@ -24,12 +24,20 @@ export class TreeCulling { public readonly fresh = new Set(); - public active = new Set(); + public readonly active = new Set(); public revision = 0; public stamp = 0; + public walking = false; + + public fullVisibilityRevision = -1; + + public fullPreparation?: { revision: number; nodes: Tree[] }; + + public fullPaint?: { revision: number; nodes: Tree[] }; + public walkToken: unknown; public didCull = false; @@ -55,6 +63,8 @@ export class TreeCulling { } public delete(node: Tree): void { + this.fullPreparation = undefined; + this.fullPaint = undefined; this.index.delete(node); this.nodes.delete(node); this.pending.delete(node); From 2a109cf6ca08a8364a65aca91ce6a0aaff318308 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 23 Oct 2026 14:21:43 +0300 Subject: [PATCH 07/13] perf(graph): reuse tree order for dense viewport selections --- packages/graph/src/lib/Tree.dense.test.ts | 96 +++++++++++++++++++++++ packages/graph/src/lib/Tree.ts | 33 +++++--- packages/graph/src/lib/TreeCulling.ts | 10 +-- 3 files changed, 123 insertions(+), 16 deletions(-) diff --git a/packages/graph/src/lib/Tree.dense.test.ts b/packages/graph/src/lib/Tree.dense.test.ts index f634e3bc..42c656b0 100644 --- a/packages/graph/src/lib/Tree.dense.test.ts +++ b/packages/graph/src/lib/Tree.dense.test.ts @@ -118,6 +118,83 @@ describe("Tree full-viewport traversal", () => { expect(scope?.index.size).toBe(3); }); + it("updates a dense partial selection without preparing clean exits or losing new entrants", () => { + const { state, add, walk } = setup(); + const names = Array.from({ length: 20 }, (_, i) => `node${i}`); + const nodes = names.map((name, i) => add(name, 10 + i * 20)); + walk(); + walk(); + nodes[0].updateZIndex(5); + state.viewport = viewport(365); + let result = walk(); + expect(result.prepared).toEqual(["root", ...names.slice(0, 18)]); + expect(result.visited).toEqual(["root", ...names.slice(1, 18), "node0"]); + expect(result.drawn).toEqual(result.visited); + expect(result.events).toContain(`batch:${names.slice(0, 18).sort().join(",")}`); + + state.viewport = { minX: 41, minY: 41, maxX: 410, maxY: 410 }; + result = walk(); + expect(result.prepared).toEqual(["root", ...names.slice(2)]); + expect(result.visited).toEqual(["root", ...names.slice(2)]); + expect(result.drawn).toEqual(result.visited); + expect(result.events).toContain(`batch:${names.slice(2).sort().join(",")}`); + for (const event of ["visible:node0:false", "visible:node1:false", "visible:node18:true", "visible:node19:true"]) { + expect(result.events.indexOf(event)).toBeGreaterThanOrEqual(0); + expect(result.events.indexOf(event)).toBeLessThan(result.events.indexOf("draw:root")); + } + + state.viewport = viewport(1000); + expect(walk().drawn).toEqual(["root", ...names.slice(1), "node0"]); + }); + + it("finishes a prepared subtree without painting it after preparation moves it under an unprepared ancestor", () => { + const { state, add, walk } = setup(); + state.viewport = viewport(100); + const original = add("original", 10); + const child = add("child", 20, original); + add("grandchild", 30, child); + const trigger = add("trigger", 40); + const fillers = Array.from({ length: 8 }, (_, i) => `filler${i}`); + for (const name of fillers) add(name, 50); + const far = add("far", 5000); + walk(); + walk(); + trigger.data.prepare = () => { + far.append(child); + return true; + }; + + const result = walk(); + expect(result.prepared).toEqual(["root", "original", "child", "grandchild", "trigger", ...fillers]); + expect(result.drawn).toEqual(["root", "original", "trigger", ...fillers]); + expect(result.visited).toEqual(["root", "original", "trigger", ...fillers, "child", "grandchild"]); + }); + + it("defers a node appended by a viewport callback until its first preparation in the next frame", () => { + const { root, state, add, make, walk } = setup(); + add("resident", 10); + const trigger = add("trigger", 500); + walk(); + state.viewport = viewport(100); + walk(); + const created = make("created", 20); + const onViewportChange = trigger.data.onViewportChange; + trigger.data.onViewportChange = (visible) => { + onViewportChange?.(visible); + if (visible) root.append(created); + }; + + state.viewport = viewport(1000); + expect(walk()).toMatchObject({ + prepared: ["root", "resident", "trigger"], + visited: ["root", "resident", "trigger"], + }); + expect(walk()).toMatchObject({ + prepared: ["root", "resident", "trigger", "created"], + drawn: ["root", "resident", "trigger", "created"], + }); + }); + it("uses final geometry and child order when preparation changes a fully visible tree", () => { const { root, add, make, walk } = setup(); const early = add("early", 10); @@ -257,6 +334,25 @@ describe("Tree full-viewport traversal", () => { expect(walk().drawn).toEqual(["root", "original", "mover", "destination", "child"]); }); + it("keeps the selected paint order when an iterator reparents an unvisited child to a later prepared branch", () => { + const { add, walk } = setup(); + const original = add("original", 10); + const child = add("child", 20, original); + const destination = add("destination", 30); + walk(); + walk(); + original.data.paint = () => { + destination.append(child); + return true; + }; + + expect(walk()).toMatchObject({ + visited: ["root", "original", "child", "destination"], + drawn: ["root", "original", "child", "destination"], + }); + expect(walk().drawn).toEqual(["root", "original", "destination", "child"]); + }); + it("releases cached preparation references when a subtree is removed without another traversal", () => { const { root, add, walk } = setup(); const parent = add("parent", 10); diff --git a/packages/graph/src/lib/Tree.ts b/packages/graph/src/lib/Tree.ts index 85153105..2792003c 100644 --- a/packages/graph/src/lib/Tree.ts +++ b/packages/graph/src/lib/Tree.ts @@ -344,7 +344,7 @@ export class Tree { private walkPrepared(iterator: TIterator, scope: TreeCulling, prepared: Tree[], fullWalk: boolean): void { let walked: number; - if (fullWalk) { + if (fullWalk || prepared.length > scope.nodes.size * (scope.options.fullWalkShare ?? 0.75)) { walked = this.paintFull(iterator, scope); } else { const children = new Map(); @@ -378,29 +378,40 @@ export class Tree { let ordered = scope.fullPaint; if (ordered?.revision !== scope.revision) { const nodes: Tree[] = []; + const subtreeEnds: number[] = []; const collect = (node: Tree): void => { - if (node !== this && (node.preparedStamp !== scope.stamp || !node.isEligible(scope))) return; + if (node !== this && !node.isEligible(scope)) return; + const index = nodes.length; nodes.push(node); - if (!node.childrenWalkAllowed || !node.children.size) return; - for (const child of node.zIndexChildrenCache.get()) { - if (child.parent === node) collect(child); + subtreeEnds.push(0); + if (node.childrenWalkAllowed && node.children.size) { + for (const child of node.zIndexChildrenCache.get()) { + if (child.parent === node) collect(child); + } } + subtreeEnds[index] = nodes.length; }; collect(this); - scope.fullPaint = ordered = { revision: scope.revision, nodes }; + scope.fullPaint = ordered = { revision: scope.revision, nodes, subtreeEnds }; } let walked = 0; for (let i = 0; i < ordered.nodes.length; i++) { const node = ordered.nodes[i]; if ( - scope.revision !== ordered.revision && - node !== this && - (node.preparedStamp !== scope.stamp || node.cullingStamp === scope.stamp || !node.isEligible(scope)) - ) + node.preparedStamp !== scope.stamp || + (scope.revision !== ordered.revision && + node !== this && + (node.cullingStamp === scope.stamp || !node.isEligible(scope))) + ) { + i = ordered.subtreeEnds[i] - 1; continue; + } node.cullingStamp = scope.stamp; const allowed = iterator(node); - if (!allowed) node.setChildrenWalkAllowed(false); + if (!allowed) { + node.setChildrenWalkAllowed(false); + i = ordered.subtreeEnds[i] - 1; + } walked++; } return walked; diff --git a/packages/graph/src/lib/TreeCulling.ts b/packages/graph/src/lib/TreeCulling.ts index 5b444478..2a8a1ec8 100644 --- a/packages/graph/src/lib/TreeCulling.ts +++ b/packages/graph/src/lib/TreeCulling.ts @@ -36,7 +36,7 @@ export class TreeCulling { public fullPreparation?: { revision: number; nodes: Tree[] }; - public fullPaint?: { revision: number; nodes: Tree[] }; + public fullPaint?: { revision: number; nodes: Tree[]; subtreeEnds: number[] }; public walkToken: unknown; @@ -92,15 +92,15 @@ export class TreeCulling { ); } - public select(viewport: TTileRect | undefined): Set { - const selected = new Set(); + public select(viewport: TTileRect | undefined): Tree[] { + const selected: Tree[] = []; const limit = this.nodes.size * (this.options.fullWalkShare ?? 0.75); if (!viewport || this.index.covers(viewport) || this.index.countCandidates(viewport, limit) > limit) { for (const node of this.nodes) { - if (this.intersects(node, viewport)) selected.add(node); + if (this.intersects(node, viewport)) selected.push(node); } } else { - this.index.query(viewport, (node) => selected.add(node)); + this.index.query(viewport, (node) => selected.push(node)); } return selected; } From 6a36111ce3318770f8c36a8061f3f1b5dea33c78 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 23 Oct 2026 17:19:09 +0300 Subject: [PATCH 08/13] fix(graph): preserve custom component lifecycle and label geometry --- .../docs/components/canvas-graph-component.md | 16 ++ .../connections/canvas-connection-system.md | 49 ++++++ .../GraphComponent/customLifecycle.test.ts | 153 ++++++++++++++++++ .../components/canvas/GraphComponent/index.ts | 8 +- .../canvas/connections/BlockConnection.ts | 11 +- .../connections/labelCompatibility.test.ts | 134 +++++++++++++++ packages/graph/src/lib/CoreComponent.ts | 2 +- packages/graph/src/lib/Tree.ts | 15 +- packages/graph/src/lib/TreeCulling.test.ts | 50 ++++++ packages/graph/src/lib/TreeCulling.ts | 2 + 10 files changed, 430 insertions(+), 10 deletions(-) create mode 100644 packages/graph/src/components/canvas/GraphComponent/customLifecycle.test.ts create mode 100644 packages/graph/src/components/canvas/connections/labelCompatibility.test.ts diff --git a/packages/graph/docs/components/canvas-graph-component.md b/packages/graph/docs/components/canvas-graph-component.md index e6b0f6cb..9a725077 100644 --- a/packages/graph/docs/components/canvas-graph-component.md +++ b/packages/graph/docs/components/canvas-graph-component.md @@ -105,6 +105,22 @@ connection endpoints. Use `firstIterate` for initialization; `firstRender` stays 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:** ``` ┌────────────────────┐ diff --git a/packages/graph/docs/connections/canvas-connection-system.md b/packages/graph/docs/connections/canvas-connection-system.md index 93b6de1c..ad0a2bc7 100644 --- a/packages/graph/docs/connections/canvas-connection-system.md +++ b/packages/graph/docs/connections/canvas-connection-system.md @@ -382,6 +382,55 @@ 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 { + 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. + ### Custom Connection with Special Rendering Example of a connection with custom visual elements: diff --git a/packages/graph/src/components/canvas/GraphComponent/customLifecycle.test.ts b/packages/graph/src/components/canvas/GraphComponent/customLifecycle.test.ts new file mode 100644 index 00000000..2bded072 --- /dev/null +++ b/packages/graph/src/components/canvas/GraphComponent/customLifecycle.test.ts @@ -0,0 +1,153 @@ +import { Graph } from "../../../graph"; +import { globalScheduler } from "../../../lib/Scheduler"; +import { Block } from "../blocks/Block"; + +class CustomBlock extends Block { + public sourceValue = 0; + public snapshot = 0; + public painted: number[] = []; + public allowPaint = true; + public prepareGeometry?: () => void; + + public override isVisible(): boolean { + return super.isVisible(); + } + + protected override willIterate(): void { + // Consumers defer external-store snapshots until the block is visible again. + if (this.isVisible()) this.snapshot = this.sourceValue; + super.willIterate(); + if (!this.allowPaint) this.shouldRender = false; + this.prepareGeometry?.(); + } + + protected override render(): void { + this.painted.push(this.snapshot); + super.render(); + } +} + +class InitiallySuppressedBlock extends CustomBlock { + protected override willMount(): void { + super.willMount(); + this.shouldRender = false; + } +} + +describe.each([true, false])("custom lifecycle with traversal culling %s", (enabled) => { + let graph: Graph; + let block: CustomBlock; + + const frame = () => globalScheduler.performUpdate(); + const lookAt = (x: number) => graph.cameraService.set({ x: -x, y: 0, scale: 1 }); + + beforeEach(() => { + graph = new Graph( + { + settings: { + blockComponents: { custom: CustomBlock, suppressed: InitiallySuppressedBlock }, + EXP_VISIBILITY_CULLING: enabled, + }, + }, + document.createElement("div") + ); + graph.setEntities({ + blocks: [{ id: "custom", is: "custom", name: "custom", x: 100, y: 100, width: 100, height: 100, anchors: [] }], + connections: [], + }); + graph.start(); + frame(); + frame(); + graph.cameraService.resize({ width: 1000, height: 800 }); + lookAt(0); + frame(); + frame(); + const view = graph.rootStore.blocksList.getBlockState("custom")?.getViewComponent(); + if (!(view instanceof CustomBlock)) throw new Error("Custom block was not mounted"); + block = view; + block.painted.length = 0; + }); + + afterEach(() => graph.unmount()); + + it("refreshes a deferred snapshot before the first paint after returning to the viewport", () => { + lookAt(5000); + frame(); + block.sourceValue = 7; + block.performRender(); + frame(); + expect(block.snapshot).toBe(0); + expect(block.painted).toEqual([]); + + lookAt(0); + frame(); + expect(block.snapshot).toBe(7); + expect(block.painted).toEqual([7]); + }); + + it("reads current visibility between frames without waiting for the next tree traversal", () => { + lookAt(5000); + expect(block.isVisible()).toBe(false); + frame(); + lookAt(0); + expect(block.isVisible()).toBe(true); + block.setHitBox(6000, 100, 6100, 200); + expect(block.isVisible()).toBe(false); + }); + + it("honors a custom willIterate paint veto and lets the component resume later", () => { + block.allowPaint = false; + block.performRender(); + frame(); + expect(block.painted).toEqual([]); + expect(block.isRendered()).toBe(false); + + block.allowPaint = true; + block.performRender(); + frame(); + expect(block.painted).toEqual([0]); + expect(block.isRendered()).toBe(true); + }); + + it("uses geometry finalized during preparation for the same frame's paint gate", () => { + block.setHitBox(6000, 100, 6100, 200); + block.performRender(); + frame(); + expect(block.painted).toEqual([]); + + block.prepareGeometry = () => block.setHitBox(100, 100, 200, 200); + block.performRender(); + frame(); + expect(block.painted).toEqual([0]); + + block.prepareGeometry = () => block.setHitBox(6000, 100, 6100, 200); + block.performRender(); + frame(); + expect(block.painted).toEqual([0]); + }); + + it("keeps render delegation effective and resumes canvas paint after it is removed", () => { + block.setRenderDelegated(true); + frame(); + expect(block.painted).toEqual([]); + expect(block.isVisible()).toBe(false); + + block.setRenderDelegated(false); + frame(); + expect(block.painted).toEqual([0]); + }); + + it("preserves a first-frame paint veto from willMount", () => { + graph.setEntities({ + blocks: [{ id: "new", is: "suppressed", name: "new", x: 100, y: 100, width: 100, height: 100, anchors: [] }], + connections: [], + }); + frame(); + const view = graph.rootStore.blocksList.getBlockState("new")?.getViewComponent(); + if (!(view instanceof InitiallySuppressedBlock)) throw new Error("New block was not mounted"); + expect(view.painted).toEqual([]); + view.performRender(); + frame(); + expect(view.painted).toEqual([0]); + }); +}); diff --git a/packages/graph/src/components/canvas/GraphComponent/index.ts b/packages/graph/src/components/canvas/GraphComponent/index.ts index 1e44630d..b3bd1e3c 100644 --- a/packages/graph/src/components/canvas/GraphComponent/index.ts +++ b/packages/graph/src/components/canvas/GraphComponent/index.ts @@ -363,9 +363,15 @@ export class GraphComponent< if (!this.hitBox.destroyed) this.setCullingBounds(minX, minY, maxX, maxY); } + protected override willIterate(): void { + super.willIterate(); + if (!this.firstIterate) this.shouldRender = true; + } + public override iterate(): boolean { if (!this.updatePrepared) this.prepareUpdate(); - this.shouldRender = this.isVisible(); + // Preserve a subclass's paint veto; geometry may still change after its update hook. + this.shouldRender = this.shouldRender && this.isVisible(); return super.iterate(); } diff --git a/packages/graph/src/components/canvas/connections/BlockConnection.ts b/packages/graph/src/components/canvas/connections/BlockConnection.ts index 309775f5..95db6fa7 100644 --- a/packages/graph/src/components/canvas/connections/BlockConnection.ts +++ b/packages/graph/src/components/canvas/connections/BlockConnection.ts @@ -44,6 +44,8 @@ export class BlockConnection protected labelGeometry: { x: number; y: number; width: number; height: number } | undefined = undefined; + private labelPaintGeometry: typeof this.labelGeometry; + private labelFont = ""; private unsubscribeLabelFontSize?: () => void; @@ -399,12 +401,12 @@ export class BlockConnection } protected renderLabelText(ctx: CanvasRenderingContext2D) { - if (!this.state.label || !this.labelGeometry) { + if (!this.state.label || !this.labelPaintGeometry) { return; } const [labelInnerTopPadding, , , labelInnerLeftPadding] = this.context.constants.connection.LABEL.INNER_PADDINGS; - const { x: rectX, y: rectY, width: rectWidth, height: rectHeight } = this.labelGeometry; + const { x: rectX, y: rectY, width: rectWidth, height: rectHeight } = this.labelPaintGeometry; if (this.context.colors.connectionLabel?.background) { ctx.fillStyle = this.context.colors.connectionLabel.background; @@ -440,6 +442,7 @@ export class BlockConnection protected updateLabelGeometry(props: TConnectionProps): void { this.labelGeometry = undefined; + this.labelPaintGeometry = undefined; const fontSize = this.context.connectionLabelFontSize.peek(); if (!this.state.label || !props.showConnectionLabels || !this.connectionPoints || !fontSize) { return; @@ -459,7 +462,9 @@ export class BlockConnection height, this.context.constants.system.GRID_SIZE ); - this.labelGeometry = { x, y, width, height }; + this.labelPaintGeometry = { x, y, width, height }; + // Subclasses may extend labelGeometry for their own decoration without resizing the standard label. + this.labelGeometry = { ...this.labelPaintGeometry }; } public getStrokeColor(state: TConnection) { diff --git a/packages/graph/src/components/canvas/connections/labelCompatibility.test.ts b/packages/graph/src/components/canvas/connections/labelCompatibility.test.ts new file mode 100644 index 00000000..19f46909 --- /dev/null +++ b/packages/graph/src/components/canvas/connections/labelCompatibility.test.ts @@ -0,0 +1,134 @@ +import { Graph } from "../../../graph"; +import { globalScheduler } from "../../../lib/Scheduler"; +import { TConnection } from "../../../store/connection/ConnectionState"; + +import { BlockConnection, TConnectionProps } from "./BlockConnection"; + +class BadgeConnection extends BlockConnection { + protected badge = { x: 100, y: 50, width: 40, height: 18 }; + + public labelBounds() { + return this.labelGeometry; + } + + protected includeBadgeBounds() { + const label = this.labelGeometry; + if (!label) return; + const x = Math.min(label.x, this.badge.x); + const y = Math.min(label.y, this.badge.y); + this.labelGeometry = { + x, + y, + width: Math.max(label.x + label.width, this.badge.x + this.badge.width) - x, + height: Math.max(label.y + label.height, this.badge.y + this.badge.height) - y, + }; + } + + protected override renderLabelText(ctx: CanvasRenderingContext2D) { + this.includeBadgeBounds(); + ctx.fillRect(this.badge.x, this.badge.y, this.badge.width, this.badge.height); + super.renderLabelText(ctx); + } +} + +class PreparedBadgeConnection extends BadgeConnection { + protected override badge = { x: 990, y: 50, width: 40, height: 18 }; + + protected override updateLabelGeometry(props: TConnectionProps) { + super.updateLabelGeometry(props); + this.includeBadgeBounds(); + } +} + +class MutatingBadgeConnection extends BadgeConnection { + protected override includeBadgeBounds() { + const label = this.labelGeometry; + super.includeBadgeBounds(); + if (label && this.labelGeometry) this.labelGeometry = Object.assign(label, this.labelGeometry); + } +} + +function frames(count = 3) { + for (let i = 0; i < count; i++) globalScheduler.performUpdate(); +} + +describe("custom connection label geometry", () => { + let graph: Graph; + + function mount(Connection = BadgeConnection, x = 100) { + const root = document.createElement("div"); + Object.defineProperties(root, { clientWidth: { value: 1000 }, clientHeight: { value: 800 } }); + graph = new Graph( + { + connections: [{ id: "connection", label: "label", sourcePortId: "source", targetPortId: "target" }], + settings: { connection: Connection, useBezierConnections: false, showConnectionLabels: true }, + }, + root + ); + const state = graph.connections.getConnectionState("connection"); + if (!state) throw new Error("Expected connection state"); + state.$sourcePortState.value.updatePort({ x, y: 100, lookup: false }); + state.$targetPortState.value.updatePort({ x: x + 100, y: 100, lookup: false }); + graph.cameraService.set({ width: 1000, height: 800, x: 0, y: 0, scale: 1 }); + graph.start(); + frames(); + const view = state.getViewComponent(); + if (!(view instanceof BadgeConnection)) throw new Error("Expected badge connection"); + const fill = jest.spyOn(view.context.ctx, "fillRect"); + fill.mockClear(); + return { view, fill }; + } + + afterEach(() => graph?.unmount()); + + it.each([ + { change: "replaces", Connection: BadgeConnection }, + { change: "mutates", Connection: MutatingBadgeConnection }, + ])( + "does not paint the standard label over a badge when a subclass $change protected labelGeometry", + ({ Connection }) => { + const { view, fill } = mount(Connection); + view.performRender(); + frames(1); + + // Canvas text metrics in this environment give 'label' width 6 and height 18. + expect(fill.mock.calls).toEqual([ + [100, 50, 40, 18], + [178, 66, 6, 18], + ]); + expect(view.labelBounds()).toEqual({ x: 100, y: 50, width: 84, height: 34 }); + expect(view.onHitBox({ x: 120, y: 59, minX: 120, minY: 59, maxX: 120, maxY: 59 })).toBe(true); + } + ); + + it("uses custom bounds prepared before painting when only the badge intersects the viewport", () => { + const { view, fill } = mount(PreparedBadgeConnection, 1100); + view.performRender(); + frames(1); + + expect(view.isInViewport()).toBe(true); + expect(fill.mock.calls).toEqual([ + [990, 50, 40, 18], + [1178, 66, 6, 18], + ]); + expect(graph.getElementOverPoint({ x: 995, y: 59 }, [BlockConnection])).toBe(view); + }); + + it("refreshes the standard label after labels are disabled, changed and enabled at a new scale", () => { + const { view, fill } = mount(); + graph.updateSettings({ showConnectionLabels: false }); + frames(); + expect(fill).not.toHaveBeenCalled(); + expect(view.labelBounds()).toBeUndefined(); + + graph.api.updateConnection("connection", { label: "longerlabel" }); + graph.cameraService.set({ scale: 0.25 }); + graph.updateSettings({ showConnectionLabels: true }); + frames(); + + expect(fill.mock.calls.slice(-2)).toEqual([ + [100, 50, 40, 18], + [172, 38, 12, 46], + ]); + }); +}); diff --git a/packages/graph/src/lib/CoreComponent.ts b/packages/graph/src/lib/CoreComponent.ts index 9c6dcf40..46d8b757 100644 --- a/packages/graph/src/lib/CoreComponent.ts +++ b/packages/graph/src/lib/CoreComponent.ts @@ -124,7 +124,7 @@ export class CoreComponent< ); } - /** Whether this node's own bounds are visible in the current tree frame. */ + /** Whether this node's own bounds intersect its tree's viewport. */ public isInViewport(): boolean { return this.__comp.treeNode.isVisible(); } diff --git a/packages/graph/src/lib/Tree.ts b/packages/graph/src/lib/Tree.ts index 2792003c..953fd106 100644 --- a/packages/graph/src/lib/Tree.ts +++ b/packages/graph/src/lib/Tree.ts @@ -153,10 +153,10 @@ export class Tree { /** Own bounds intersect the current exact viewport and ancestors allow traversal. */ public isVisible(): boolean { const scope = this.scope; - return ( - !scope || - (this.visible && ((scope.walking && scope.fullVisibilityRevision === scope.revision) || this.isEligible(scope))) - ); + if (!scope) return true; + // Update hooks need current geometry; painting shares the finalized batch selection. + const visible = scope.painting ? this.visible : scope.intersects(this, scope.options.getViewport()); + return visible && ((scope.painting && scope.fullVisibilityRevision === scope.revision) || this.isEligible(scope)); } public isSkippedByCulling(token: unknown): boolean { @@ -168,7 +168,8 @@ export class Tree { public isTraversalAllowed(): boolean { const scope = this.scope; if (scope?.walking) { - if (this.visible && this !== scope.owner && scope.fullVisibilityRevision === scope.revision) return true; + if (scope.painting && this.visible && this !== scope.owner && scope.fullVisibilityRevision === scope.revision) + return true; if (this.eligibilityRevision !== scope.revision) { const parent = this.parent; this.traversalAllowed = @@ -238,7 +239,9 @@ export class Tree { private walkScope(iterator: TIterator, scope: TreeCulling): void { const wasWalking = scope.walking; + const wasPainting = scope.painting; scope.walking = true; + scope.painting = false; scope.stamp = ++Tree.walkStamp; // An ancestor outside this scope can change its traversal permission independently. if (this.parent) scope.revision++; @@ -246,6 +249,7 @@ export class Tree { this.prepareAndWalk(iterator, scope); } finally { scope.walking = wasWalking; + scope.painting = wasPainting; } } @@ -339,6 +343,7 @@ export class Tree { active.delete(this); if (fullyVisible && scope.revision === visibilityRevision) scope.fullVisibilityRevision = visibilityRevision; } + scope.painting = true; this.walkPrepared(iterator, scope, prepared, fullWalk); } diff --git a/packages/graph/src/lib/TreeCulling.test.ts b/packages/graph/src/lib/TreeCulling.test.ts index 9bf36c25..2a6176de 100644 --- a/packages/graph/src/lib/TreeCulling.test.ts +++ b/packages/graph/src/lib/TreeCulling.test.ts @@ -142,6 +142,41 @@ describe("Tree native spatial scope", () => { expect(walk().drawn).toEqual(["root", "out"]); }); + it("reads live visibility in update hooks without publishing intermediate transitions", () => { + const { state, add, walk } = setup(); + const node = add("node", 300); + walk(); + const visibility: boolean[] = []; + node.data.prepareUpdate = () => { + visibility.push(node.isVisible()); + node.setBounds(2000, 2000, 2010, 2010); + visibility.push(node.isVisible()); + node.setBounds(300, 300, 310, 310); + return true; + }; + state.viewport = rect(250, 250, 100); + + const result = walk(); + expect(visibility).toEqual([true, false]); + expect(result.events.filter((event) => event.startsWith("visible:node:"))).toEqual(["visible:node:true"]); + expect(result.events.indexOf("visible:node:true")).toBeLessThan(result.events.indexOf("draw:root")); + expect(result.drawn).toEqual(["root", "node"]); + }); + + it("resumes live visibility reads after a paint callback throws", () => { + const { state, add, walk } = setup(); + const node = add("node", 10); + node.data.iterate = () => { + throw new Error("paint failed"); + }; + expect(walk).toThrow("paint failed"); + + state.viewport = rect(250, 250, 100); + expect(node.isVisible()).toBe(false); + node.setBounds(300, 300, 310, 310); + expect(node.isVisible()).toBe(true); + }); + it("prepares and paints everything when the viewport is disabled", () => { const { state, add, walk } = setup(); const parent = add("parent", 1000); @@ -172,6 +207,21 @@ describe("Tree native spatial scope", () => { expect(result.visited).toEqual(["root", "parent"]); }); + it("keeps a suppressed descendant invisible to update hooks after a full-viewport traversal", () => { + const { root, add, walk } = setup(); + const parent = add("parent", 10); + const child = add("child", 20, parent); + parent.data.prepareUpdate = () => false; + walk(); + const observed: boolean[] = []; + root.data.prepareUpdate = () => { + observed.push(child.isVisible()); + return true; + }; + walk(); + expect(observed).toEqual([false]); + }); + it("keeps a configured root when attached below an unscoped tree", () => { const { root, add, walk } = setup(); add("out", 1000); diff --git a/packages/graph/src/lib/TreeCulling.ts b/packages/graph/src/lib/TreeCulling.ts index 2a8a1ec8..20280a9d 100644 --- a/packages/graph/src/lib/TreeCulling.ts +++ b/packages/graph/src/lib/TreeCulling.ts @@ -32,6 +32,8 @@ export class TreeCulling { public walking = false; + public painting = false; + public fullVisibilityRevision = -1; public fullPreparation?: { revision: number; nodes: Tree[] }; From c80debbf6459953a9bddb2bba7b70ad8fe71444b Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 23 Oct 2026 17:35:34 +0300 Subject: [PATCH 09/13] fix(graph): publish visibility for initially suppressed descendants --- packages/graph/src/lib/Tree.ts | 2 + packages/graph/src/lib/TreeCulling.test.ts | 45 ++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/graph/src/lib/Tree.ts b/packages/graph/src/lib/Tree.ts index 953fd106..e3cf69c6 100644 --- a/packages/graph/src/lib/Tree.ts +++ b/packages/graph/src/lib/Tree.ts @@ -320,6 +320,8 @@ export class Tree { preparationRevision === scope.revision ? { revision: scope.revision, nodes: prepared } : undefined; } + // Publish hidden state for new descendants whose preparation an ancestor blocked. + for (const node of scope.fresh) node.setVisible(false); const fullyVisible = (!viewport || scope.index.covers(viewport)) && this.isTraversalAllowed(); this.setVisible(this.isEligible(scope) && scope.intersects(this, viewport)); // Camera movement cannot change visibility while it still contains the unchanged tree. diff --git a/packages/graph/src/lib/TreeCulling.test.ts b/packages/graph/src/lib/TreeCulling.test.ts index 2a6176de..2435a86d 100644 --- a/packages/graph/src/lib/TreeCulling.test.ts +++ b/packages/graph/src/lib/TreeCulling.test.ts @@ -1,3 +1,5 @@ +import { BatchPath2DRenderer } from "../components/canvas/connections/BatchPath2D"; + import type { TTileRect } from "./TileIndex"; import { ITree, Tree } from "./Tree"; @@ -222,6 +224,49 @@ describe("Tree native spatial scope", () => { expect(observed).toEqual([false]); }); + it("excludes a never-prepared descendant from batch visibility when its parent suppresses traversal", () => { + const { root, add, walk } = setup(); + const parent = add("parent", 10); + const child = add("child", 20, parent); + parent.data.prepareUpdate = () => false; + const batchVisible: boolean[] = []; + root.data.iterate = () => { + batchVisible.push(child.isVisible()); + return true; + }; + walk(); + expect(batchVisible).toEqual([false]); + }); + + it("invalidates a batch cached before a suppressed child's first preparation when the branch resumes", () => { + const { root, add, walk } = setup(); + const parent = add("parent", 10); + const child = add("child", 20, parent); + parent.data.prepareUpdate = () => false; + const batch = new BatchPath2DRenderer(() => {}); + let draws = 0; + const shape = { + getPath: () => new Path2D(), + style: () => ({ type: "stroke" as const }), + isPathVisible: () => child.isVisible(), + afterRender: () => draws++, + }; + batch.add(shape, { zIndex: 0, group: "custom" }); + child.data.onViewportChange = () => batch.invalidateVisibility(shape); + const ctx = document.createElement("canvas").getContext("2d"); + if (!ctx) throw new Error("Missing canvas context"); + root.data.iterate = () => { + for (const group of batch.orderedPaths.get()) group.render(ctx); + return true; + }; + walk(); + expect(draws).toBe(0); + + parent.data.prepareUpdate = () => true; + walk(); + expect(draws).toBe(1); + }); + it("keeps a configured root when attached below an unscoped tree", () => { const { root, add, walk } = setup(); add("out", 1000); From ba5281bf3077b93938ab894fe4afde28ab2c9fa3 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 23 Oct 2026 19:25:41 +0300 Subject: [PATCH 10/13] refactor: centralize tree culling frame coordination --- .../customConnectionLabel.stories.tsx | 68 +---- .../connections/canvas-connection-system.md | 5 + .../docs/rendering/rendering-mechanism.md | 27 +- .../connections/customLabelSelection.test.ts | 101 +++++++ .../graph/src/lib/Tree.invalidation.test.ts | 250 ++++++++++++++++ packages/graph/src/lib/Tree.ts | 267 +++--------------- packages/graph/src/lib/TreeCulling.ts | 251 +++++++++++++++- 7 files changed, 671 insertions(+), 298 deletions(-) create mode 100644 packages/graph/src/components/canvas/connections/customLabelSelection.test.ts create mode 100644 packages/graph/src/lib/Tree.invalidation.test.ts diff --git a/apps/storybook/src/stories/examples/customConnectionLabel/customConnectionLabel.stories.tsx b/apps/storybook/src/stories/examples/customConnectionLabel/customConnectionLabel.stories.tsx index 18ecb627..d2ee3ee1 100644 --- a/apps/storybook/src/stories/examples/customConnectionLabel/customConnectionLabel.stories.tsx +++ b/apps/storybook/src/stories/examples/customConnectionLabel/customConnectionLabel.stories.tsx @@ -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"; @@ -24,39 +16,16 @@ storyConfig.connections?.forEach((connection) => { connection.label = "Custom Label"; }); -function getRenderLabelText(this: BlockConnection, showLabelOnlyOnSelected: boolean) { - return (ctx: CanvasRenderingContext2D) => { - if (!this.isVisible() || !this.state.label) { +export class CustomConnection extends BlockConnection { + 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; @@ -70,18 +39,6 @@ function getRenderLabelText(this: BlockConnection, 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"; @@ -105,18 +62,13 @@ function getRenderLabelText(this: BlockConnection, showLabelOnlyOnS ctx.font = font; ctx.textAlign = "left"; ctx.fillText(this.state.label, rectX + labelInnerLeftPadding, rectY + labelInnerTopPadding); - }; -} - -export class CustomConnection extends BlockConnection { - protected renderLabelText(ctx: CanvasRenderingContext2D) { - getRenderLabelText.call(this, false)(ctx); } } -export class CustomConnectionOnSelected extends BlockConnection { - 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; } } diff --git a/packages/graph/docs/connections/canvas-connection-system.md b/packages/graph/docs/connections/canvas-connection-system.md index ad0a2bc7..ffa56bca 100644 --- a/packages/graph/docs/connections/canvas-connection-system.md +++ b/packages/graph/docs/connections/canvas-connection-system.md @@ -431,6 +431,11 @@ The protected `labelGeometry` describes the combined bounds for culling and hit 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: diff --git a/packages/graph/docs/rendering/rendering-mechanism.md b/packages/graph/docs/rendering/rendering-mechanism.md index 66cc6fea..32684895 100644 --- a/packages/graph/docs/rendering/rendering-mechanism.md +++ b/packages/graph/docs/rendering/rendering-mechanism.md @@ -9,9 +9,10 @@ This document provides a detailed explanation of the rendering mechanism impleme The rendering mechanism is built upon a hierarchical tree structure with the following key components: 1. **Tree** - The base hierarchical structure that manages parent-child relationships with z-index ordering -2. **CoreComponent** - The fundamental component class that implements tree traversal and child management -3. **Component** - Extends CoreComponent to add lifecycle hooks and state management -4. **Scheduler** - Manages the timing of updates and traversal of the component tree +2. **TreeCulling** - Coordinates preparation, viewport selection, and painting for a configured tree root +3. **CoreComponent** - The fundamental component class that implements tree traversal and child management +4. **Component** - Extends CoreComponent to add lifecycle hooks and state management +5. **Scheduler** - Manages the timing of updates and traversal of the component tree ## Component Structure @@ -21,7 +22,23 @@ The `Tree` class provides the foundational hierarchy structure with: - Parent-child relationship management - Z-index based ordering of children - Traversal methods for walking the tree -- One shared tile index per layer tree for viewport selection +- Own world-space bounds and per-node lifecycle state + +### TreeCulling Class + +Each configured tree root owns one `TreeCulling` coordinator with a shared tile index. It prepares +visible, new, and dirty nodes, publishes their final visibility, and then runs painting in local +z-index order. Components and batch paths use the same published selection. A dirty offscreen +component still applies data, reconciles children, and reports geometry; its paint hooks are skipped. + +Geometry changes update the spatial index without invalidating cached hierarchy or paint order. +Membership, sibling order, and traversal permissions invalidate those caches separately. When the +viewport contains all indexed bounds, the coordinator reuses the full traversal order; otherwise it +selects candidates from tiles or a linear bounds scan for dense views. + +This coordination does not own a drawing context: layers and component render methods retain their +existing responsibilities. An explicit child-traversal veto still suppresses descendants, including +when a parent sets it during painting; already prepared descendants then complete without painting. ### CoreComponent Class @@ -54,7 +71,7 @@ The rendering process follows these steps: 1. Component state/props update via `setState`/`setProps` 2. `performRender()` schedules an update through the `Scheduler` 3. On the next animation frame, `Scheduler` traverses the component tree -4. Tree calls `prepareUpdate()` on visible, new, or dirty components and their ancestors +4. The tree's culling coordinator calls `prepareUpdate()` on visible, new, or dirty components and their ancestors 5. Components apply data and reconcile children before the tree resolves final viewport membership 6. `iterate()` paints visible components and completes prepared updates; clean offscreen nodes are skipped 7. The process repeats for each animation frame when changes occur diff --git a/packages/graph/src/components/canvas/connections/customLabelSelection.test.ts b/packages/graph/src/components/canvas/connections/customLabelSelection.test.ts new file mode 100644 index 00000000..63800308 --- /dev/null +++ b/packages/graph/src/components/canvas/connections/customLabelSelection.test.ts @@ -0,0 +1,101 @@ +import { Graph } from "../../../graph"; +import { globalScheduler } from "../../../lib/Scheduler"; +import { TConnection } from "../../../store/connection/ConnectionState"; + +import { BlockConnection, TConnectionProps } from "./BlockConnection"; + +class SelectedLabelConnection extends BlockConnection { + protected override updateLabelGeometry(props: TConnectionProps): void { + super.updateLabelGeometry(props); + if (!this.state.selected) this.labelGeometry = undefined; + } + + protected override renderLabelText(ctx: CanvasRenderingContext2D): void { + if (this.labelGeometry) super.renderLabelText(ctx); + } +} + +function frame() { + globalScheduler.performUpdate(); +} + +describe.each([true, false])("selected-only custom labels with traversal culling %s", (enabled) => { + let graph: Graph; + + function mount(label: string | undefined) { + const root = document.createElement("div"); + Object.defineProperties(root, { clientWidth: { value: 1000 }, clientHeight: { value: 800 } }); + graph = new Graph( + { + connections: [{ id: "connection", label, sourcePortId: "source", targetPortId: "target" }], + settings: { + connection: SelectedLabelConnection, + useBezierConnections: false, + showConnectionArrows: false, + showConnectionLabels: true, + EXP_VISIBILITY_CULLING: enabled, + }, + }, + root + ); + const state = graph.connections.getConnectionState("connection"); + if (!state) throw new Error("Expected connection state"); + // The line is below the viewport; its label alone can reach y=786. + state.$sourcePortState.value.updatePort({ x: 100, y: 820, lookup: false }); + state.$targetPortState.value.updatePort({ x: 200, y: 820, lookup: false }); + graph.cameraService.set({ width: 1000, height: 800, x: 0, y: 0, scale: 1 }); + graph.start(); + frame(); + frame(); + const view = state.getViewComponent(); + if (!(view instanceof SelectedLabelConnection)) throw new Error("Expected custom connection"); + return { view, text: jest.mocked(view.context.ctx.fillText) }; + } + + afterEach(() => graph?.unmount()); + + it("prepares selected-only bounds before the first paint and updates them while the line stays offscreen", () => { + const { view, text } = mount("label"); + expect(view.isInViewport()).toBe(false); + expect(text).not.toHaveBeenCalled(); + expect(graph.getElementOverPoint({ x: 180, y: 790 }, [BlockConnection])).toBeUndefined(); + + graph.api.selectConnections(["connection"], true); + frame(); + + expect(view.isInViewport()).toBe(true); + expect(text).toHaveBeenLastCalledWith("label", 178, 786); + expect(graph.getElementOverPoint({ x: 180, y: 790 }, [BlockConnection])).toBe(view); + text.mockClear(); + + graph.api.selectConnections(["connection"], false); + frame(); + + expect(view.isInViewport()).toBe(false); + expect(text).not.toHaveBeenCalled(); + expect(graph.getElementOverPoint({ x: 180, y: 790 }, [BlockConnection])).toBeUndefined(); + }); + + it("refreshes an offscreen selected connection when its label is added or removed", () => { + const { view, text } = mount(undefined); + graph.api.selectConnections(["connection"], true); + frame(); + expect(view.isInViewport()).toBe(false); + expect(text).not.toHaveBeenCalled(); + + graph.api.updateConnection("connection", { label: "longerlabel" }); + frame(); + + expect(view.isInViewport()).toBe(true); + expect(text).toHaveBeenLastCalledWith("longerlabel", 172, 786); + expect(graph.getElementOverPoint({ x: 174, y: 790 }, [BlockConnection])).toBe(view); + text.mockClear(); + + graph.api.updateConnection("connection", { label: undefined }); + frame(); + + expect(view.isInViewport()).toBe(false); + expect(text).not.toHaveBeenCalled(); + expect(graph.getElementOverPoint({ x: 174, y: 790 }, [BlockConnection])).toBeUndefined(); + }); +}); diff --git a/packages/graph/src/lib/Tree.invalidation.test.ts b/packages/graph/src/lib/Tree.invalidation.test.ts new file mode 100644 index 00000000..2755a762 --- /dev/null +++ b/packages/graph/src/lib/Tree.invalidation.test.ts @@ -0,0 +1,250 @@ +import type { TTileRect } from "./TileIndex"; +import { Tree } from "./Tree"; +import type { ITree } from "./Tree"; + +interface IProbe extends ITree { + name: string; + prepare?: () => boolean; +} + +interface IWork { + children: number; + order: number; + parent: number; +} + +class CountingTree extends Tree { + constructor( + data: IProbe, + private readonly work: IWork + ) { + super(data); + const getOrderedChildren = this.zIndexChildrenCache.get; + this.zIndexChildrenCache.get = (): Tree[] => { + work.order++; + return getOrderedChildren(); + }; + let parent: Tree | undefined; + Object.defineProperty(this, "parent", { + configurable: true, + get: (): Tree | undefined => { + work.parent++; + return parent; + }, + set: (value: Tree | undefined): void => { + parent = value; + }, + }); + } + + public override getChildrenArray(): Tree[] { + this.work.children++; + return super.getChildrenArray(); + } +} + +const viewport = (maxX: number): TTileRect => ({ minX: 0, minY: 0, maxX, maxY: maxX }); + +function setup() { + const state = { viewport: viewport(1000), token: 0 }; + const events: string[] = []; + const work: IWork = { children: 0, order: 0, parent: 0 }; + const make = (name: string, x?: number): CountingTree => { + const node = new CountingTree( + { + name, + prepareUpdate: (): boolean => { + events.push(`prepare:${name}`); + return node.data.prepare?.() ?? true; + }, + onViewportChange: (visible): void => { + events.push(`visible:${name}:${visible}`); + }, + iterate: (): boolean => { + events.push(`iterate:${name}`); + if (node.isVisible()) events.push(`draw:${name}`); + return true; + }, + }, + work + ); + if (x !== undefined) node.setBounds(x, x, x + 10, x + 10); + return node; + }; + const root = make("root"); + root.setCulling({ + tileSize: 64, + getViewport: () => state.viewport, + getWalkToken: () => state.token, + }); + const add = (name: string, x: number, parent = root): CountingTree => { + const node = make(name, x); + parent.append(node); + return node; + }; + const walk = () => { + events.length = 0; + work.children = work.order = work.parent = 0; + state.token++; + root.traverseDown((node) => node.data.iterate()); + const names = (prefix: string): string[] => + events.filter((event) => event.startsWith(prefix)).map((event) => event.slice(prefix.length)); + return { + prepared: names("prepare:"), + visited: names("iterate:"), + drawn: names("draw:"), + events: events.slice(), + work: { ...work }, + }; + }; + return { root, state, add, walk }; +} + +describe("Tree geometry and structure invalidation", () => { + it.each(["before", "during"])( + "does not enumerate unchanged hierarchy or ancestry when bounds move %s preparation", + (timing) => { + const { add, walk } = setup(); + const parent = add("parent", 10); + const child = add("child", 20, parent); + const leaf = add("leaf", 30, child); + const sibling = add("sibling", 40); + const nodes = [parent, child, leaf, sibling]; + const initial = walk(); + expect(initial.work.children).toBeGreaterThan(0); + expect(initial.work.order).toBeGreaterThan(0); + walk(); + const stable = walk(); + expect(stable.work.children).toBe(0); + expect(stable.work.order).toBe(0); + + for (const [index, node] of nodes.entries()) { + const move = (): boolean => { + const x = 100 + index * 20; + node.setBounds(x, x, x + 10, x + 10); + return true; + }; + if (timing === "before") move(); + else node.data.prepare = move; + } + + const moved = walk(); + expect(moved.prepared).toEqual(["root", "parent", "child", "leaf", "sibling"]); + expect(moved.drawn).toEqual(["root", "parent", "child", "leaf", "sibling"]); + expect(moved.work).toEqual(stable.work); + } + ); + + it("publishes geometry exits and entrants before painting across full and sparse viewports", () => { + const { root, state, add, walk } = setup(); + add("resident", 10); + const moved = add("moved", 30); + add("distant", 500); + walk(); + walk(); + + state.viewport = viewport(100); + moved.setBounds(300, 300, 310, 310); + let result = walk(); + expect(result.drawn).toEqual(["root", "resident"]); + expect(result.events.indexOf("visible:moved:false")).toBeGreaterThanOrEqual(0); + expect(result.events.indexOf("visible:moved:false")).toBeLessThan(result.events.indexOf("draw:root")); + expect(moved.isSkippedByCulling(state.token)).toBe(true); + + root.data.prepare = (): boolean => { + moved.setBounds(40, 40, 50, 50); + return true; + }; + result = walk(); + expect(result.prepared).toEqual(["root", "resident", "moved"]); + expect(result.drawn).toEqual(["root", "resident", "moved"]); + expect(result.events.indexOf("visible:moved:true")).toBeGreaterThanOrEqual(0); + expect(result.events.indexOf("visible:moved:true")).toBeLessThan(result.events.indexOf("draw:root")); + expect(moved.isSkippedByCulling(state.token)).toBe(false); + + state.viewport = viewport(1000); + expect(walk().drawn).toEqual(["root", "resident", "moved", "distant"]); + state.viewport = viewport(100); + expect(walk().drawn).toEqual(["root", "resident", "moved"]); + }); + + it("uses new ancestry after a prepared child moves between warmed branches", () => { + const { add, walk } = setup(); + const original = add("original", 10); + const child = add("child", 20, original); + add("leaf", 30, child); + const destination = add("destination", 40); + walk(); + walk(); + destination.data.prepare = (): boolean => { + destination.append(child); + child.setBounds(60, 60, 70, 70); + destination.data.prepare = undefined; + return true; + }; + + expect(walk()).toMatchObject({ + prepared: ["root", "original", "child", "leaf", "destination"], + drawn: ["root", "original", "destination", "child", "leaf"], + visited: ["root", "original", "destination", "child", "leaf"], + }); + expect(walk().prepared).toEqual(["root", "original", "destination", "child", "leaf"]); + }); + + it("updates z-order alongside bounds without changing preparation order", () => { + const { state, add, walk } = setup(); + const first = add("first", 10); + add("child", 20, first); + const second = add("second", 30); + walk(); + walk(); + second.data.prepare = (): boolean => { + first.updateZIndex(5); + first.setBounds(60, 60, 70, 70); + return true; + }; + + expect(walk()).toMatchObject({ + prepared: ["root", "first", "child", "second"], + drawn: ["root", "second", "first", "child"], + }); + state.viewport = viewport(50); + expect(walk().drawn).toEqual(["root", "second", "child"]); + second.updateZIndex(10); + state.viewport = viewport(1000); + expect(walk().drawn).toEqual(["root", "first", "child", "second"]); + }); + + it("invalidates descendant eligibility when suppression changes alongside bounds", () => { + const { state, add, walk } = setup(); + const parent = add("parent", 10); + const child = add("child", 20, parent); + const leaf = add("leaf", 30, child); + add("sibling", 40); + walk(); + walk(); + parent.data.prepare = (): boolean => { + child.setBounds(50, 50, 60, 60); + return false; + }; + + let result = walk(); + expect(result.prepared).toEqual(["root", "parent", "sibling"]); + expect(result.drawn).toEqual(["root", "parent", "sibling"]); + expect([child.isVisible(), leaf.isVisible()]).toEqual([false, false]); + expect(result.events.indexOf("visible:child:false")).toBeGreaterThanOrEqual(0); + expect(result.events.indexOf("visible:child:false")).toBeLessThan(result.events.indexOf("draw:root")); + + state.viewport = viewport(100); + parent.data.prepare = (): boolean => { + child.setBounds(300, 300, 310, 310); + return true; + }; + result = walk(); + expect(result.prepared).toEqual(["root", "parent", "child", "leaf", "sibling"]); + expect(result.drawn).toEqual(["root", "parent", "leaf", "sibling"]); + expect(leaf.isTraversalAllowed()).toBe(true); + state.viewport = viewport(1000); + expect(walk().drawn).toEqual(["root", "parent", "child", "leaf", "sibling"]); + }); +}); diff --git a/packages/graph/src/lib/Tree.ts b/packages/graph/src/lib/Tree.ts index e3cf69c6..040ff7f0 100644 --- a/packages/graph/src/lib/Tree.ts +++ b/packages/graph/src/lib/Tree.ts @@ -1,7 +1,7 @@ import { TTreeCullingOptions, TreeCulling } from "./TreeCulling"; import { cache } from "./utils"; -type TIterator = (node: Tree) => boolean; +export type TIterator = (node: Tree) => boolean; export interface ITree { iterate(): boolean; @@ -12,13 +12,12 @@ export interface ITree { } export class Tree { - // Moving between scopes must not repeat a node's lifecycle in the same traversal. - private static walkStamp = 0; public data: T; public parent?: Tree; public children: Set = new Set(); private childrenArray: Tree[] = []; - private childrenWalkAllowed = true; + /** @internal Parent traversal permission, independent of own viewport visibility. */ + public childrenWalkAllowed = true; protected childrenDirty = false; protected zIndexGroups: Map> = new Map(); protected zIndexChildrenCache = cache(() => { @@ -39,9 +38,11 @@ export class Tree { /** @internal Traversal stamp in the shared spatial scope. */ public cullingStamp = 0; protected culling?: TreeCulling; - private scope?: TreeCulling; + /** @internal Shared frame coordinator for this node. */ + public scope?: TreeCulling; private visible = true; - private preparedStamp = 0; + /** @internal Last traversal that prepared this node. */ + public preparedStamp = 0; private eligibilityRevision = -1; private traversalAllowed = true; @@ -92,7 +93,7 @@ export class Tree { } public setChildren(nodes: Tree[]) { - if (this.scope) this.scope.revision++; + this.scope?.invalidateStructure(); const next = new Set(nodes); for (const child of this.children) { if (!next.has(child)) this.remove(child); @@ -156,7 +157,10 @@ export class Tree { if (!scope) return true; // Update hooks need current geometry; painting shares the finalized batch selection. const visible = scope.painting ? this.visible : scope.intersects(this, scope.options.getViewport()); - return visible && ((scope.painting && scope.fullVisibilityRevision === scope.revision) || this.isEligible(scope)); + return ( + visible && + ((scope.painting && scope.fullVisibilityRevision === scope.structureRevision) || this.isEligible(scope)) + ); } public isSkippedByCulling(token: unknown): boolean { @@ -168,13 +172,18 @@ export class Tree { public isTraversalAllowed(): boolean { const scope = this.scope; if (scope?.walking) { - if (scope.painting && this.visible && this !== scope.owner && scope.fullVisibilityRevision === scope.revision) + if ( + scope.painting && + this.visible && + this !== scope.owner && + scope.fullVisibilityRevision === scope.structureRevision + ) return true; - if (this.eligibilityRevision !== scope.revision) { + if (this.eligibilityRevision !== scope.structureRevision) { const parent = this.parent; this.traversalAllowed = !parent || (parent.children.has(this) && parent.childrenWalkAllowed && parent.isTraversalAllowed()); - this.eligibilityRevision = scope.revision; + this.eligibilityRevision = scope.structureRevision; } return this.traversalAllowed; } @@ -187,17 +196,20 @@ export class Tree { return true; } - private isEligible(scope: TreeCulling): boolean { + /** @internal */ + public isEligible(scope: TreeCulling): boolean { return this.scope === scope && this.isTraversalAllowed(); } - private setChildrenWalkAllowed(allowed: boolean): void { + /** @internal */ + public setChildrenWalkAllowed(allowed: boolean): void { if (this.childrenWalkAllowed === allowed) return; this.childrenWalkAllowed = allowed; - if (this.scope) this.scope.revision++; + this.scope?.invalidateStructure(); } - private setVisible(visible: boolean): void { + /** @internal */ + public setVisible(visible: boolean): void { if (this.visible === visible) return; this.visible = visible; this.data.onViewportChange?.(visible); @@ -213,14 +225,14 @@ export class Tree { parent.removeZIndex(this); this.zIndex = index; parent.addInZIndex(this); - if (this.scope) this.scope.revision++; + this.scope?.invalidateStructure(); } public updateChildZIndex(child: Tree) { if (!this.children.has(child)) return; this.removeZIndex(child); this.addInZIndex(child); - if (this.scope) this.scope.revision++; + this.scope?.invalidateStructure(); } public clearChildren() { @@ -233,220 +245,18 @@ export class Tree { } public traverseDown(iterator: TIterator) { - if (this.culling) this.walkScope(iterator, this.culling); + if (this.culling) this.culling.walk(iterator); else this._walkDown(iterator, 0); } - private walkScope(iterator: TIterator, scope: TreeCulling): void { - const wasWalking = scope.walking; - const wasPainting = scope.painting; - scope.walking = true; - scope.painting = false; - scope.stamp = ++Tree.walkStamp; - // An ancestor outside this scope can change its traversal permission independently. - if (this.parent) scope.revision++; - try { - this.prepareAndWalk(iterator, scope); - } finally { - scope.walking = wasWalking; - scope.painting = wasPainting; - } - } - - private prepareData(scope: TreeCulling): void { - this.preparedStamp = scope.stamp; - if (scope.pending.size) scope.pending.delete(this); - if (scope.fresh.size) scope.fresh.delete(this); + /** @internal */ + public prepareData(stamp: number): void { + this.preparedStamp = stamp; this.setChildrenWalkAllowed(this.data.prepareUpdate?.() ?? true); } - private prepareAndWalk(iterator: TIterator, scope: TreeCulling): void { - let prepared: Tree[] = []; - const prepareNode = (node: Tree): void => { - prepared.push(node); - node.prepareData(scope); - }; - const prepare = (node: Tree): void => { - if (node.preparedStamp === scope.stamp || node.scope !== scope) return; - if (node !== this) { - const parent = node.parent; - if (!parent || !parent.children.has(node)) return; - prepare(parent); - if (parent.preparedStamp !== scope.stamp || !parent.childrenWalkAllowed || node.scope !== scope) return; - } - prepareNode(node); - }; - prepare(this); - const viewport = scope.options.getViewport(); - scope.didCull = Boolean(viewport && (scope.options.isEnabled?.() ?? true)); - scope.walkToken = scope.options.getWalkToken?.(); - const fullWalk = !scope.didCull || !viewport || scope.index.covers(viewport); - const preparationRevision = scope.revision; - if (fullWalk) { - const cached = scope.fullPreparation; - if (cached?.revision === scope.revision) { - prepared = cached.nodes; - for (let i = 1; i < cached.nodes.length; i++) { - cached.nodes[i].prepareData(scope); - if (cached.revision !== scope.revision) { - prepared = cached.nodes.slice(0, i + 1); - break; - } - } - } - const prepareBranch = (node: Tree): void => { - if (node.scope !== scope) return; - if (node.preparedStamp !== scope.stamp) prepareNode(node); - if (!node.childrenWalkAllowed || !node.children.size) return; - for (const child of node.getChildrenArray()) { - if (child.parent === node) prepareBranch(child); - } - }; - if (cached?.revision !== scope.revision) prepareBranch(this); - } - let revision: number; - do { - revision = scope.revision; - if (!fullWalk) { - const candidates = scope.select(scope.didCull ? viewport : undefined); - for (const node of candidates) prepare(node); - } - for (const node of scope.fresh) prepare(node); - for (const node of scope.pending) prepare(node); - // Reconciliation and geometry updates can expose additional nodes in this same frame. - } while (revision !== scope.revision); - if (fullWalk) { - scope.fullPreparation = - preparationRevision === scope.revision ? { revision: scope.revision, nodes: prepared } : undefined; - } - - // Publish hidden state for new descendants whose preparation an ancestor blocked. - for (const node of scope.fresh) node.setVisible(false); - const fullyVisible = (!viewport || scope.index.covers(viewport)) && this.isTraversalAllowed(); - this.setVisible(this.isEligible(scope) && scope.intersects(this, viewport)); - // Camera movement cannot change visibility while it still contains the unchanged tree. - if (!fullyVisible || scope.fullVisibilityRevision !== scope.revision) { - const visibilityRevision = scope.revision; - scope.fullVisibilityRevision = -1; - const active = scope.active; - for (const node of active) { - if (node.preparedStamp !== scope.stamp) { - active.delete(node); - node.setVisible(false); - } - } - for (const node of prepared) { - const visible = node.isEligible(scope) && scope.intersects(node, viewport); - node.setVisible(visible); - if (visible) active.add(node); - else active.delete(node); - } - // The root is retained for clearing its canvas, not by the descendant visibility registry. - active.delete(this); - if (fullyVisible && scope.revision === visibilityRevision) scope.fullVisibilityRevision = visibilityRevision; - } - scope.painting = true; - this.walkPrepared(iterator, scope, prepared, fullWalk); - } - - private walkPrepared(iterator: TIterator, scope: TreeCulling, prepared: Tree[], fullWalk: boolean): void { - let walked: number; - if (fullWalk || prepared.length > scope.nodes.size * (scope.options.fullWalkShare ?? 0.75)) { - walked = this.paintFull(iterator, scope); - } else { - const children = new Map(); - for (const node of prepared) { - if (node === this || !node.isEligible(scope)) continue; - const parent = node.parent; - if (!parent) continue; - let selected = children.get(parent); - if (!selected) { - parent.zIndexChildrenCache.get(); - children.set(parent, (selected = [])); - } - selected.push(node); - } - for (const selected of children.values()) selected.sort((a, b) => a.renderOrder - b.renderOrder); - walked = this.paintPrepared(iterator, scope, children); - } - if (walked === prepared.length) return; - // A parent can suppress children while painting; finish their already prepared lifecycles. - for (const node of prepared) { - if (node.cullingStamp === scope.stamp || !node.data.prepareUpdate || node.scope !== scope) continue; - scope.active.delete(node); - node.setVisible(false); - node.cullingStamp = scope.stamp; - const allowed = iterator(node); - if (!allowed) node.setChildrenWalkAllowed(false); - } - } - - private paintFull(iterator: TIterator, scope: TreeCulling): number { - let ordered = scope.fullPaint; - if (ordered?.revision !== scope.revision) { - const nodes: Tree[] = []; - const subtreeEnds: number[] = []; - const collect = (node: Tree): void => { - if (node !== this && !node.isEligible(scope)) return; - const index = nodes.length; - nodes.push(node); - subtreeEnds.push(0); - if (node.childrenWalkAllowed && node.children.size) { - for (const child of node.zIndexChildrenCache.get()) { - if (child.parent === node) collect(child); - } - } - subtreeEnds[index] = nodes.length; - }; - collect(this); - scope.fullPaint = ordered = { revision: scope.revision, nodes, subtreeEnds }; - } - let walked = 0; - for (let i = 0; i < ordered.nodes.length; i++) { - const node = ordered.nodes[i]; - if ( - node.preparedStamp !== scope.stamp || - (scope.revision !== ordered.revision && - node !== this && - (node.cullingStamp === scope.stamp || !node.isEligible(scope))) - ) { - i = ordered.subtreeEnds[i] - 1; - continue; - } - node.cullingStamp = scope.stamp; - const allowed = iterator(node); - if (!allowed) { - node.setChildrenWalkAllowed(false); - i = ordered.subtreeEnds[i] - 1; - } - walked++; - } - return walked; - } - - private paintPrepared(iterator: TIterator, scope: TreeCulling, children: Map): number { - this.cullingStamp = scope.stamp; - const allowed = iterator(this); - if (!allowed) this.setChildrenWalkAllowed(false); - let walked = 1; - if (this.childrenWalkAllowed && this.children.size) { - const selected = children.get(this) ?? []; - for (const child of selected) { - if ( - child.preparedStamp === scope.stamp && - child.cullingStamp !== scope.stamp && - child.scope === scope && - child.parent === this && - child.isTraversalAllowed() - ) { - walked += child.paintPrepared(iterator, scope, children); - } - } - } - return walked; - } - - protected getChildrenArray() { + /** @internal Children in reconciliation order. */ + public getChildrenArray() { if (this.childrenDirty) { this.childrenArray = Array.from(this.children); this.childrenDirty = false; @@ -454,6 +264,11 @@ export class Tree { return this.childrenArray; } + /** @internal Children in local paint order. */ + public getOrderedChildren(): Tree[] { + return this.zIndexChildrenCache.get(); + } + protected _walkDown(iterator: TIterator, order: number) { this.renderOrder = order; this.setChildrenWalkAllowed(iterator(this)); @@ -461,7 +276,7 @@ export class Tree { const children = this.zIndexChildrenCache.get(); for (let i = 0; i < children.length; i++) { const child = children[i]; - if (child.culling) child.walkScope(iterator, child.culling); + if (child.culling) child.culling.walk(iterator); else child._walkDown(iterator, i); } } diff --git a/packages/graph/src/lib/TreeCulling.ts b/packages/graph/src/lib/TreeCulling.ts index 20280a9d..467d6cf8 100644 --- a/packages/graph/src/lib/TreeCulling.ts +++ b/packages/graph/src/lib/TreeCulling.ts @@ -1,5 +1,5 @@ import { TTileRect, TileIndex } from "./TileIndex"; -import type { Tree } from "./Tree"; +import type { TIterator, Tree } from "./Tree"; export type TTreeCullingOptions = { /** Edge of a visibility tile in world units. */ @@ -14,19 +14,25 @@ export type TTreeCullingOptions = { fullWalkShare?: number; }; -/** One spatial index and update queue for every descendant of a configured Tree root. */ +/** Owns preparation, viewport selection and painting for one configured Tree root. */ export class TreeCulling { + // Moving between scopes must not repeat a node's lifecycle in the same traversal. + private static walkStamp = 0; + public readonly index: TileIndex; public readonly nodes = new Set(); - public readonly pending = new Set(); + private readonly pending = new Set(); + + private readonly fresh = new Set(); - public readonly fresh = new Set(); + private readonly active = new Set(); - public readonly active = new Set(); + private revision = 0; - public revision = 0; + /** Membership, local order and traversal permission; bounds do not change these. */ + public structureRevision = 0; public stamp = 0; @@ -40,9 +46,9 @@ export class TreeCulling { public fullPaint?: { revision: number; nodes: Tree[]; subtreeEnds: number[] }; - public walkToken: unknown; + private walkToken: unknown; - public didCull = false; + private didCull = false; constructor( public readonly owner: Tree, @@ -51,11 +57,238 @@ export class TreeCulling { this.index = new TileIndex({ tileSize: options.tileSize }); } + public walk(iterator: TIterator): void { + const wasWalking = this.walking; + const wasPainting = this.painting; + this.walking = true; + this.painting = false; + this.stamp = ++TreeCulling.walkStamp; + // An ancestor outside this scope can change its traversal permission independently. + if (this.owner.parent) this.invalidateStructure(); + try { + this.prepareAndWalk(iterator); + } finally { + this.walking = wasWalking; + this.painting = wasPainting; + } + } + + private prepareAndWalk(iterator: TIterator): void { + let prepared: Tree[] = []; + const prepareNode = (node: Tree): void => { + prepared.push(node); + this.prepareNode(node); + }; + const prepare = (node: Tree): void => { + if (node.preparedStamp === this.stamp || node.scope !== this) return; + if (node !== this.owner) { + const parent = node.parent; + if (!parent || !parent.children.has(node)) return; + prepare(parent); + if (parent.preparedStamp !== this.stamp || !parent.childrenWalkAllowed || node.scope !== this) return; + } + prepareNode(node); + }; + prepare(this.owner); + const viewport = this.options.getViewport(); + this.didCull = Boolean(viewport && (this.options.isEnabled?.() ?? true)); + this.walkToken = this.options.getWalkToken?.(); + const fullWalk = !this.didCull || !viewport || this.index.covers(viewport); + const preparationRevision = this.structureRevision; + if (fullWalk) prepared = this.prepareFull(prepared); + let revision: number; + do { + revision = this.revision; + if (!fullWalk) { + const candidates = this.select(this.didCull ? viewport : undefined); + for (const node of candidates) prepare(node); + } + for (const node of this.fresh) prepare(node); + for (const node of this.pending) prepare(node); + // Reconciliation and geometry updates can expose additional nodes in this same frame. + } while (revision !== this.revision); + if (fullWalk) { + this.fullPreparation = + preparationRevision === this.structureRevision + ? { revision: this.structureRevision, nodes: prepared } + : undefined; + } + + this.publishVisibility(prepared, viewport); + this.painting = true; + this.walkPrepared(iterator, prepared, fullWalk); + } + + private prepareFull(prepared: Tree[]): Tree[] { + const cached = this.fullPreparation; + if (cached?.revision === this.structureRevision) { + prepared = cached.nodes; + for (let i = 1; i < cached.nodes.length; i++) { + this.prepareNode(cached.nodes[i]); + if (cached.revision !== this.structureRevision) { + prepared = cached.nodes.slice(0, i + 1); + break; + } + } + } + const prepareBranch = (node: Tree): void => { + if (node.scope !== this) return; + if (node.preparedStamp !== this.stamp) { + prepared.push(node); + this.prepareNode(node); + } + if (!node.childrenWalkAllowed || !node.children.size) return; + for (const child of node.getChildrenArray()) { + if (child.parent === node) prepareBranch(child); + } + }; + if (cached?.revision !== this.structureRevision) prepareBranch(this.owner); + return prepared; + } + + private publishVisibility(prepared: Tree[], viewport: TTileRect | undefined): void { + // Publish hidden state for new descendants whose preparation an ancestor blocked. + for (const node of this.fresh) node.setVisible(false); + const fullyVisible = (!viewport || this.index.covers(viewport)) && this.owner.isTraversalAllowed(); + this.owner.setVisible(this.owner.isEligible(this) && this.intersects(this.owner, viewport)); + // Camera movement cannot change visibility while it still contains the unchanged tree. + if (!fullyVisible || this.fullVisibilityRevision !== this.structureRevision) { + const visibilityRevision = this.structureRevision; + this.fullVisibilityRevision = -1; + const active = this.active; + for (const node of active) { + if (node.preparedStamp !== this.stamp) { + active.delete(node); + node.setVisible(false); + } + } + for (const node of prepared) { + const visible = node.isEligible(this) && this.intersects(node, viewport); + node.setVisible(visible); + if (visible) active.add(node); + else active.delete(node); + } + // The root is retained for clearing its canvas, not by the descendant visibility registry. + active.delete(this.owner); + if (fullyVisible && this.structureRevision === visibilityRevision) + this.fullVisibilityRevision = visibilityRevision; + } + } + + private prepareNode(node: Tree): void { + if (this.pending.size) this.pending.delete(node); + if (this.fresh.size) this.fresh.delete(node); + node.prepareData(this.stamp); + } + + private walkPrepared(iterator: TIterator, prepared: Tree[], fullWalk: boolean): void { + let walked: number; + if (fullWalk || prepared.length > this.nodes.size * (this.options.fullWalkShare ?? 0.75)) { + walked = this.paintFull(iterator); + } else { + const children = new Map(); + for (const node of prepared) { + if (node === this.owner || !node.isEligible(this)) continue; + const parent = node.parent; + if (!parent) continue; + let selected = children.get(parent); + if (!selected) { + parent.getOrderedChildren(); + children.set(parent, (selected = [])); + } + selected.push(node); + } + for (const selected of children.values()) selected.sort((a, b) => a.renderOrder - b.renderOrder); + walked = this.paintPrepared(this.owner, iterator, children); + } + if (walked === prepared.length) return; + // A parent can suppress children while painting; finish their already prepared lifecycles. + for (const node of prepared) { + if (node.cullingStamp === this.stamp || !node.data.prepareUpdate || node.scope !== this) continue; + this.active.delete(node); + node.setVisible(false); + node.cullingStamp = this.stamp; + const allowed = iterator(node); + if (!allowed) node.setChildrenWalkAllowed(false); + } + } + + private paintFull(iterator: TIterator): number { + let ordered = this.fullPaint; + if (ordered?.revision !== this.structureRevision) { + const nodes: Tree[] = []; + const subtreeEnds: number[] = []; + const collect = (node: Tree): void => { + if (node !== this.owner && !node.isEligible(this)) return; + const index = nodes.length; + nodes.push(node); + subtreeEnds.push(0); + if (node.childrenWalkAllowed && node.children.size) { + for (const child of node.getOrderedChildren()) { + if (child.parent === node) collect(child); + } + } + subtreeEnds[index] = nodes.length; + }; + collect(this.owner); + this.fullPaint = ordered = { revision: this.structureRevision, nodes, subtreeEnds }; + } + let walked = 0; + for (let i = 0; i < ordered.nodes.length; i++) { + const node = ordered.nodes[i]; + if ( + node.preparedStamp !== this.stamp || + (this.structureRevision !== ordered.revision && + node !== this.owner && + (node.cullingStamp === this.stamp || !node.isEligible(this))) + ) { + i = ordered.subtreeEnds[i] - 1; + continue; + } + node.cullingStamp = this.stamp; + const allowed = iterator(node); + if (!allowed) { + node.setChildrenWalkAllowed(false); + i = ordered.subtreeEnds[i] - 1; + } + walked++; + } + return walked; + } + + private paintPrepared(node: Tree, iterator: TIterator, children: Map): number { + node.cullingStamp = this.stamp; + const allowed = iterator(node); + if (!allowed) node.setChildrenWalkAllowed(false); + let walked = 1; + if (node.childrenWalkAllowed && node.children.size) { + const selected = children.get(node) ?? []; + for (const child of selected) { + if ( + child.preparedStamp === this.stamp && + child.cullingStamp !== this.stamp && + child.scope === this && + child.parent === node && + child.isTraversalAllowed() + ) { + walked += this.paintPrepared(child, iterator, children); + } + } + } + return walked; + } + public add(node: Tree): void { if (node === this.owner || this.nodes.has(node)) return; this.nodes.add(node); this.fresh.add(node); this.update(node); + this.invalidateStructure(); + } + + public invalidateStructure(): void { + this.structureRevision++; + this.revision++; } public update(node: Tree): void { @@ -72,7 +305,7 @@ export class TreeCulling { this.pending.delete(node); this.fresh.delete(node); this.active.delete(node); - this.revision++; + this.invalidateStructure(); } public markPending(node: Tree): void { From 3a5f3c3d4f217c2e51bbabb491f15cd7a43baccb Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 23 Oct 2026 20:46:40 +0300 Subject: [PATCH 11/13] perf(graph): streamline dense tree culling --- .../docs/rendering/rendering-mechanism.md | 12 + .../graph/src/lib/Tree.denseSelection.test.ts | 275 ++++++++++++++++++ .../graph/src/lib/Tree.invalidation.test.ts | 77 +++++ packages/graph/src/lib/Tree.ts | 14 +- packages/graph/src/lib/TreeCulling.test.ts | 2 + packages/graph/src/lib/TreeCulling.ts | 105 ++++++- 6 files changed, 471 insertions(+), 14 deletions(-) create mode 100644 packages/graph/src/lib/Tree.denseSelection.test.ts diff --git a/packages/graph/docs/rendering/rendering-mechanism.md b/packages/graph/docs/rendering/rendering-mechanism.md index 32684895..6da7c0ba 100644 --- a/packages/graph/docs/rendering/rendering-mechanism.md +++ b/packages/graph/docs/rendering/rendering-mechanism.md @@ -36,6 +36,18 @@ Membership, sibling order, and traversal permissions invalidate those caches sep viewport contains all indexed bounds, the coordinator reuses the full traversal order; otherwise it selects candidates from tiles or a linear bounds scan for dense views. +Dense partial views reuse a cached registration order and parent positions. Exact selection marks +identify visible nodes; their unprepared ancestors are included when needed, so this path still skips clean +offscreen branches. Structural changes during preparation fall back to the ordinary ancestor-aware +walk and repeat selection before painting. Partial preparation retains registration order with +required ancestors first, independently of paint z-order, including children attached later. + +The coordinator reuses exact selection while geometry, structure, and viewport edges remain unchanged, +including checking for invalidation from synchronous visibility callbacks. Published visibility also carries +its ancestry revision, avoiding repeated eligibility checks during painting until structure changes. +The active registry is updated only when membership changes, including the first registration of a +new node whose initial visibility is already true. + This coordination does not own a drawing context: layers and component render methods retain their existing responsibilities. An explicit child-traversal veto still suppresses descendants, including when a parent sets it during painting; already prepared descendants then complete without painting. diff --git a/packages/graph/src/lib/Tree.denseSelection.test.ts b/packages/graph/src/lib/Tree.denseSelection.test.ts new file mode 100644 index 00000000..e9f936e5 --- /dev/null +++ b/packages/graph/src/lib/Tree.denseSelection.test.ts @@ -0,0 +1,275 @@ +import type { TTileRect } from "./TileIndex"; +import { ITree, Tree } from "./Tree"; + +type Probe = ITree & { + name: string; + prepare?: () => boolean; + visibility?: (visible: boolean) => void; +}; + +function setup(fullWalkShare: number) { + const state = { viewport: { minX: 0, minY: 0, maxX: 100, maxY: 100 } as TTileRect, token: 0 }; + const events: string[] = []; + const batch = new Set(); + const make = (name: string, x?: number) => { + const node: Tree = new Tree({ + name, + prepareUpdate: (): boolean => { + events.push(`prepare:${name}`); + return node.data.prepare?.() ?? true; + }, + onViewportChange: (visible) => { + events.push(`visible:${name}:${visible}`); + if (visible) batch.add(name); + else batch.delete(name); + node.data.visibility?.(visible); + }, + iterate: () => { + events.push(`iterate:${name}`); + if (node.isVisible()) events.push(`draw:${name}`); + if (name === "root") events.push(`batch:${Array.from(batch).sort().join(",")}`); + return true; + }, + }); + if (x !== undefined) node.setBounds(x, x, x + 10, x + 10); + if (name !== "root") batch.add(name); + return node; + }; + const root = make("root"); + root.setCulling({ + tileSize: 32, + getViewport: () => state.viewport, + getWalkToken: () => state.token, + fullWalkShare, + }); + const add = (name: string, x?: number, parent = root) => { + const node = make(name, x); + parent.append(node); + return node; + }; + const fill = () => { + const names = Array.from({ length: 20 }, (_, i) => `resident${i}`); + for (const name of names) add(name, 20); + return names; + }; + const walk = () => { + events.length = 0; + state.token++; + root.traverseDown((node) => node.data.iterate()); + const names = (prefix: string) => + events.filter((event) => event.startsWith(prefix)).map((event) => event.slice(prefix.length)); + return { prepared: names("prepare:"), drawn: names("draw:"), visited: names("iterate:"), events: events.slice() }; + }; + return { root, state, add, fill, walk }; +} + +describe.each([ + ["dense", 0.75], + ["sparse", 1], +] as const)("Tree %s partial selection", (_strategy, fullWalkShare) => { + it("prepares required offscreen ancestors without admitting their clean offscreen children", () => { + const { state, add, fill, walk } = setup(fullWalkShare); + const parent = add("parent", 1000); + const leaf = add("leaf", 30, parent); + const hiddenSibling = add("hiddenSibling", 1100, parent); + const unknown = add("unknown"); + const residents = fill(); + walk(); + walk(); + + const result = walk(); + expect(result.prepared.slice().sort()).toEqual(["root", "parent", "leaf", "unknown", ...residents].sort()); + expect(result.prepared.indexOf("parent")).toBeLessThan(result.prepared.indexOf("leaf")); + expect(result.drawn).toEqual(["root", "leaf", "unknown", ...residents]); + expect(result.visited).toEqual(["root", "parent", "leaf", "unknown", ...residents]); + expect(parent.isSkippedByCulling(state.token)).toBe(false); + expect(leaf.isSkippedByCulling(state.token)).toBe(false); + expect(hiddenSibling.isSkippedByCulling(state.token)).toBe(true); + expect(unknown.isVisible()).toBe(true); + }); + + it("keeps reconciliation preparation order separate from z-order after a partial traversal warms", () => { + const { add, fill, walk } = setup(fullWalkShare); + const first = add("first", 10); + add("child", 20, first); + add("second", 30); + const residents = fill(); + add("outside", 1000); + first.updateZIndex(5); + walk(); + walk(); + + expect(walk()).toMatchObject({ + prepared: ["root", "first", "child", "second", ...residents], + drawn: ["root", "second", ...residents, "first", "child"], + }); + first.updateZIndex(0); + expect(walk()).toMatchObject({ + prepared: ["root", "first", "child", "second", ...residents], + drawn: ["root", "first", "child", "second", ...residents], + }); + }); + + it("uses the final selection after preparation moves one candidate out and another into the viewport", () => { + const { add, fill, walk } = setup(fullWalkShare); + const trigger = add("trigger", 10); + const exiting = add("exiting", 30); + const entering = add("entering", 1000); + const residents = fill(); + walk(); + walk(); + trigger.data.prepare = () => { + exiting.setBounds(1000, 1000, 1010, 1010); + entering.setBounds(40, 40, 50, 50); + return true; + }; + + const result = walk(); + expect(result.drawn).toEqual(["root", "trigger", "entering", ...residents]); + expect(result.prepared.filter((name) => name === "entering")).toEqual(["entering"]); + expect(result.events).toContain(`batch:${["trigger", "entering", ...residents].sort().join(",")}`); + for (const event of ["visible:exiting:false", "visible:entering:true", "prepare:entering"]) { + expect(result.events.indexOf(event)).toBeGreaterThanOrEqual(0); + expect(result.events.indexOf(event)).toBeLessThan(result.events.indexOf("draw:root")); + } + expect(walk().prepared).not.toContain("exiting"); + }); + + it("preserves partial preparation order when a child is attached after its parent's sibling", () => { + const { add, fill, walk } = setup(fullWalkShare); + const first = add("first", 10); + add("second", 30); + const residents = fill(); + add("outside", 1000); + walk(); + walk(); + add("lateChild", 20, first); + walk(); + + expect(walk()).toMatchObject({ + prepared: ["root", "first", "second", ...residents, "lateChild"], + drawn: ["root", "first", "lateChild", "second", ...residents], + }); + }); + + it("rechecks a later node when an earlier visibility callback changes its bounds", () => { + const { state, add, fill, walk } = setup(fullWalkShare); + const trigger = add("trigger", 120); + const changed = add("changed", 40); + const residents = fill(); + add("outside", 1000); + walk(); + walk(); + trigger.data.visibility = (visible) => { + if (visible) changed.setBounds(1000, 1000, 1010, 1010); + }; + state.viewport = { minX: 0, minY: 0, maxX: 150, maxY: 150 }; + + const result = walk(); + expect(result.prepared).toContain("changed"); + expect(result.drawn).toEqual(["root", "trigger", ...residents]); + expect(result.events).toContain(`batch:${["trigger", ...residents].sort().join(",")}`); + expect(result.events.indexOf("visible:changed:false")).toBeGreaterThan( + result.events.indexOf("visible:trigger:true") + ); + expect(result.events.indexOf("visible:changed:false")).toBeLessThan(result.events.indexOf("draw:root")); + }); + + it.each(["preparation", "visibility callback"])( + "rechecks a selected node when %s changes the existing viewport object", + (phase) => { + const { state, add, fill, walk } = setup(fullWalkShare); + const trigger = add("trigger", phase === "preparation" ? 10 : 120); + add("target", 40); + const residents = fill(); + add("outside", 1000); + walk(); + walk(); + const changeViewport = () => { + state.viewport.maxX = state.viewport.maxY = 35; + }; + if (phase === "preparation") { + trigger.data.prepare = () => { + changeViewport(); + return true; + }; + } else { + trigger.data.visibility = (visible) => { + if (visible) changeViewport(); + }; + state.viewport.maxX = state.viewport.maxY = 150; + } + + const result = walk(); + expect(result.prepared).toContain("target"); + expect(result.drawn).toEqual(["root", "trigger", ...residents]); + expect(result.events).toContain(`batch:${["trigger", ...residents].sort().join(",")}`); + expect(result.events.indexOf("visible:target:false")).toBeGreaterThanOrEqual(0); + expect(result.events.indexOf("visible:target:false")).toBeLessThan(result.events.indexOf("draw:root")); + } + ); + + it("rechecks descendants when a visibility callback suppresses their prepared ancestor", () => { + const { state, add, fill, walk } = setup(fullWalkShare); + const trigger = add("trigger", 120); + const parent = add("parent", 30); + const child = add("child", 40, parent); + const residents = fill(); + add("outside", 1000); + walk(); + walk(); + trigger.data.visibility = (visible) => { + if (visible) parent.setChildrenWalkAllowed(false); + }; + state.viewport = { minX: 0, minY: 0, maxX: 150, maxY: 150 }; + + const result = walk(); + expect(result.prepared).toContain("child"); + expect(result.drawn).toEqual(["root", "trigger", "parent", ...residents]); + expect(result.visited.filter((name) => name === "child")).toEqual(["child"]); + expect(result.events).toContain(`batch:${["trigger", "parent", ...residents].sort().join(",")}`); + expect(result.events.indexOf("visible:child:false")).toBeGreaterThanOrEqual(0); + expect(result.events.indexOf("visible:child:false")).toBeLessThan(result.events.indexOf("draw:root")); + expect(child.isVisible()).toBe(false); + }); + + it("publishes the exit of a node initially visible without a preceding visibility callback", () => { + const { state, add, fill, walk } = setup(fullWalkShare); + const exiting = add("exiting", 40); + const residents = fill(); + add("outside", 1000); + expect(walk().events).not.toContain("visible:exiting:true"); + walk(); + state.viewport = { minX: 0, minY: 0, maxX: 35, maxY: 35 }; + + const result = walk(); + expect(result.prepared).toEqual(["root", ...residents]); + expect(result.drawn).toEqual(["root", ...residents]); + expect(result.events).toContain(`batch:${residents.slice().sort().join(",")}`); + expect(result.events.indexOf("visible:exiting:false")).toBeGreaterThanOrEqual(0); + expect(result.events.indexOf("visible:exiting:false")).toBeLessThan(result.events.indexOf("draw:root")); + expect(exiting.isSkippedByCulling(state.token)).toBe(true); + }); + + it("prepares a requested offscreen update and reveals its changed bounds before any paint", () => { + const { add, fill, walk } = setup(fullWalkShare); + const residents = fill(); + const parent = add("parent", 1000); + const pending = add("pending", 1100, parent); + add("cleanSibling", 1200, parent); + walk(); + walk(); + pending.data.prepare = () => { + pending.setBounds(40, 40, 50, 50); + return true; + }; + pending.requestWalk(); + + const result = walk(); + expect(result.prepared).toEqual(["root", ...residents, "parent", "pending"]); + expect(result.drawn).toEqual(["root", ...residents, "pending"]); + expect(result.events).toContain(`batch:${[...residents, "pending"].sort().join(",")}`); + expect(result.events.indexOf("visible:pending:true")).toBeGreaterThanOrEqual(0); + expect(result.events.indexOf("visible:pending:true")).toBeLessThan(result.events.indexOf("draw:root")); + }); +}); diff --git a/packages/graph/src/lib/Tree.invalidation.test.ts b/packages/graph/src/lib/Tree.invalidation.test.ts index 2755a762..6ee95e4f 100644 --- a/packages/graph/src/lib/Tree.invalidation.test.ts +++ b/packages/graph/src/lib/Tree.invalidation.test.ts @@ -101,6 +101,83 @@ function setup() { } describe("Tree geometry and structure invalidation", () => { + it("reuses unchanged ancestry for a dense partial viewport without preparing clean offscreen nodes", () => { + const { state, add, walk } = setup(); + const names = Array.from({ length: 25 }, (_, i) => `node${i}`); + names.forEach((name, i) => add(name, 10 + i * 20)); + walk(); + state.viewport = viewport(465); + walk(); + walk(); + + state.viewport = viewport(466); + const result = walk(); + expect(result.prepared).toEqual(["root", ...names.slice(0, 23)]); + expect(result.drawn).toEqual(result.prepared); + expect(result.work.children).toBe(0); + expect(result.work.parent).toBeLessThanOrEqual(4); + }); + + it("checks each unchanged dense node's bounds only once before publishing visibility", () => { + const { state, add, walk } = setup(); + const nodes = Array.from({ length: 25 }, (_, i) => add(`node${i}`, 10 + i * 20)); + walk(); + state.viewport = viewport(465); + walk(); + walk(); + const reads = { minX: 0 }; + for (const node of nodes) { + const minX = node.boundsMinX; + Object.defineProperty(node, "boundsMinX", { + configurable: true, + get: () => { + reads.minX++; + return minX; + }, + }); + } + + state.viewport = viewport(466); + const result = walk(); + expect(result.drawn).toHaveLength(24); + // One exact bounds check reads minX for the unknown-bounds gate and the left edge. + expect(reads.minX).toBeLessThanOrEqual(nodes.length * 2); + }); + + it("does not rewrite unchanged active membership or recheck published eligibility during paint", () => { + const { root, state, add, walk } = setup(); + const nodes = Array.from({ length: 25 }, (_, i) => add(`node${i}`, 10 + i * 20)); + walk(); + state.viewport = viewport(465); + walk(); + walk(); + const scope = root.scope; + if (!scope) throw new Error("Missing tree culling scope"); + const active = scope["active"]; + const work = { writes: 0, paintChecks: 0 }; + const addActive = active.add.bind(active); + const deleteActive = active.delete.bind(active); + active.add = (node) => { + work.writes++; + return addActive(node); + }; + active.delete = (node) => { + work.writes++; + return deleteActive(node); + }; + for (const node of nodes) { + const isEligible = node.isEligible.bind(node); + node.isEligible = (currentScope) => { + if (currentScope.painting) work.paintChecks++; + return isEligible(currentScope); + }; + } + + const result = walk(); + expect(result.drawn).toHaveLength(24); + expect(work).toEqual({ writes: 0, paintChecks: 0 }); + }); + it.each(["before", "during"])( "does not enumerate unchanged hierarchy or ancestry when bounds move %s preparation", (timing) => { diff --git a/packages/graph/src/lib/Tree.ts b/packages/graph/src/lib/Tree.ts index 040ff7f0..ce22201f 100644 --- a/packages/graph/src/lib/Tree.ts +++ b/packages/graph/src/lib/Tree.ts @@ -43,6 +43,10 @@ export class Tree { private visible = true; /** @internal Last traversal that prepared this node. */ public preparedStamp = 0; + /** @internal Exact selection for one spatial query. */ + public selectedStamp = 0; + /** @internal Membership in the scope's published visibility registry. */ + public activeInViewport = false; private eligibilityRevision = -1; private traversalAllowed = true; @@ -158,8 +162,7 @@ export class Tree { // Update hooks need current geometry; painting shares the finalized batch selection. const visible = scope.painting ? this.visible : scope.intersects(this, scope.options.getViewport()); return ( - visible && - ((scope.painting && scope.fullVisibilityRevision === scope.structureRevision) || this.isEligible(scope)) + visible && ((scope.painting && scope.visibilityRevision === scope.structureRevision) || this.isEligible(scope)) ); } @@ -176,7 +179,7 @@ export class Tree { scope.painting && this.visible && this !== scope.owner && - scope.fullVisibilityRevision === scope.structureRevision + scope.visibilityRevision === scope.structureRevision ) return true; if (this.eligibilityRevision !== scope.structureRevision) { @@ -209,10 +212,11 @@ export class Tree { } /** @internal */ - public setVisible(visible: boolean): void { - if (this.visible === visible) return; + public setVisible(visible: boolean): boolean { + if (this.visible === visible) return false; this.visible = visible; this.data.onViewportChange?.(visible); + return true; } public updateZIndex(index: number) { diff --git a/packages/graph/src/lib/TreeCulling.test.ts b/packages/graph/src/lib/TreeCulling.test.ts index 2435a86d..7ae3ab3e 100644 --- a/packages/graph/src/lib/TreeCulling.test.ts +++ b/packages/graph/src/lib/TreeCulling.test.ts @@ -338,6 +338,8 @@ describe("Tree native spatial scope", () => { [rect(0, 0, 950), false], ] as const) { state.viewport = viewport; + // Warm the structural preparation order before counting per-frame selection work. + walk(); query.mockClear(); linearScan.mockClear(); const visible = new Set( diff --git a/packages/graph/src/lib/TreeCulling.ts b/packages/graph/src/lib/TreeCulling.ts index 467d6cf8..e361ec6f 100644 --- a/packages/graph/src/lib/TreeCulling.ts +++ b/packages/graph/src/lib/TreeCulling.ts @@ -18,6 +18,7 @@ export type TTreeCullingOptions = { export class TreeCulling { // Moving between scopes must not repeat a node's lifecycle in the same traversal. private static walkStamp = 0; + private static selectionStamp = 0; public readonly index: TileIndex; @@ -42,8 +43,19 @@ export class TreeCulling { public fullVisibilityRevision = -1; + /** Ancestry revision used by the published visibility snapshot. */ + public visibilityRevision = -1; + public fullPreparation?: { revision: number; nodes: Tree[] }; + private densePreparation?: { revision: number; nodes: Tree[]; parents: number[] }; + + private selectionStamp = 0; + + private selectionRevision = -1; + + private selectionViewport?: TTileRect; + public fullPaint?: { revision: number; nodes: Tree[]; subtreeEnds: number[] }; private walkToken: unknown; @@ -63,6 +75,7 @@ export class TreeCulling { this.walking = true; this.painting = false; this.stamp = ++TreeCulling.walkStamp; + this.selectionRevision = -1; // An ancestor outside this scope can change its traversal permission independently. if (this.owner.parent) this.invalidateStructure(); try { @@ -84,7 +97,7 @@ export class TreeCulling { if (node !== this.owner) { const parent = node.parent; if (!parent || !parent.children.has(node)) return; - prepare(parent); + if (parent.preparedStamp !== this.stamp) prepare(parent); if (parent.preparedStamp !== this.stamp || !parent.childrenWalkAllowed || node.scope !== this) return; } prepareNode(node); @@ -101,7 +114,12 @@ export class TreeCulling { revision = this.revision; if (!fullWalk) { const candidates = this.select(this.didCull ? viewport : undefined); - for (const node of candidates) prepare(node); + if ( + candidates.length <= this.nodes.size * (this.options.fullWalkShare ?? 0.75) || + !this.prepareDense(prepared) + ) { + for (const node of candidates) prepare(node); + } } for (const node of this.fresh) prepare(node); for (const node of this.pending) prepare(node); @@ -119,6 +137,43 @@ export class TreeCulling { this.walkPrepared(iterator, prepared, fullWalk); } + private prepareDense(prepared: Tree[]): boolean { + let ordered = this.densePreparation; + if (ordered?.revision !== this.structureRevision) { + // Preserve partial selection's registration order, including children attached later. + const nodes = [this.owner, ...this.nodes]; + const positions = new Map(nodes.map((node, index) => [node, index])); + const parents = nodes.map((node) => (node.parent ? positions.get(node.parent) ?? -1 : -1)); + this.densePreparation = ordered = { revision: this.structureRevision, nodes, parents }; + } + const { nodes, parents } = ordered; + const branch: number[] = []; + for (let i = 1; i < nodes.length; i++) { + const node = nodes[i]; + if (node.preparedStamp === this.stamp || node.selectedStamp !== this.selectionStamp) continue; + // Include an offscreen ancestor only when its selected descendant first needs it. + let index = i; + while (index > 0 && nodes[index].preparedStamp !== this.stamp) { + branch.push(index); + index = parents[index]; + } + while (branch.length) { + const next = branch.pop(); + if (next === undefined) break; + const parent = nodes[parents[next]]; + if (!parent || parent.preparedStamp !== this.stamp || !parent.childrenWalkAllowed) { + branch.length = 0; + break; + } + prepared.push(nodes[next]); + this.prepareNode(nodes[next]); + // Custom preparation may reconcile, reparent or suppress a branch. + if (ordered.revision !== this.structureRevision) return false; + } + } + return true; + } + private prepareFull(prepared: Tree[]): Tree[] { const cached = this.fullPreparation; if (cached?.revision === this.structureRevision) { @@ -147,32 +202,57 @@ export class TreeCulling { } private publishVisibility(prepared: Tree[], viewport: TTileRect | undefined): void { + const visibilityRevision = this.structureRevision; + this.visibilityRevision = -1; // Publish hidden state for new descendants whose preparation an ancestor blocked. for (const node of this.fresh) node.setVisible(false); const fullyVisible = (!viewport || this.index.covers(viewport)) && this.owner.isTraversalAllowed(); this.owner.setVisible(this.owner.isEligible(this) && this.intersects(this.owner, viewport)); // Camera movement cannot change visibility while it still contains the unchanged tree. if (!fullyVisible || this.fullVisibilityRevision !== this.structureRevision) { - const visibilityRevision = this.structureRevision; this.fullVisibilityRevision = -1; const active = this.active; for (const node of active) { if (node.preparedStamp !== this.stamp) { active.delete(node); + node.activeInViewport = false; node.setVisible(false); } } + let reuseSelection = this.isSelectionCurrent(viewport); for (const node of prepared) { - const visible = node.isEligible(this) && this.intersects(node, viewport); - node.setVisible(visible); - if (visible) active.add(node); - else active.delete(node); + const visible = + node.isEligible(this) && + (node !== this.owner && reuseSelection && this.selectionRevision === this.revision + ? node.selectedStamp === this.selectionStamp + : this.intersects(node, viewport)); + if (node.setVisible(visible) && reuseSelection) reuseSelection = this.isSelectionCurrent(viewport); + if (node !== this.owner && node.scope === this && node.activeInViewport !== visible) { + node.activeInViewport = visible; + if (visible) active.add(node); + else active.delete(node); + } } - // The root is retained for clearing its canvas, not by the descendant visibility registry. - active.delete(this.owner); if (fullyVisible && this.structureRevision === visibilityRevision) this.fullVisibilityRevision = visibilityRevision; } + if (this.structureRevision === visibilityRevision) this.visibilityRevision = visibilityRevision; + } + + private isSelectionCurrent(viewport: TTileRect | undefined): boolean { + const selectedViewport = this.selectionViewport; + return ( + this.selectionRevision === this.revision && + (!viewport + ? !selectedViewport + : Boolean( + selectedViewport && + viewport.minX === selectedViewport.minX && + viewport.minY === selectedViewport.minY && + viewport.maxX === selectedViewport.maxX && + viewport.maxY === selectedViewport.maxY + )) + ); } private prepareNode(node: Tree): void { @@ -206,6 +286,7 @@ export class TreeCulling { for (const node of prepared) { if (node.cullingStamp === this.stamp || !node.data.prepareUpdate || node.scope !== this) continue; this.active.delete(node); + node.activeInViewport = false; node.setVisible(false); node.cullingStamp = this.stamp; const allowed = iterator(node); @@ -299,12 +380,14 @@ export class TreeCulling { public delete(node: Tree): void { this.fullPreparation = undefined; + this.densePreparation = undefined; this.fullPaint = undefined; this.index.delete(node); this.nodes.delete(node); this.pending.delete(node); this.fresh.delete(node); this.active.delete(node); + node.activeInViewport = false; this.invalidateStructure(); } @@ -329,6 +412,7 @@ export class TreeCulling { public select(viewport: TTileRect | undefined): Tree[] { const selected: Tree[] = []; + this.selectionStamp = ++TreeCulling.selectionStamp; const limit = this.nodes.size * (this.options.fullWalkShare ?? 0.75); if (!viewport || this.index.covers(viewport) || this.index.countCandidates(viewport, limit) > limit) { for (const node of this.nodes) { @@ -337,6 +421,9 @@ export class TreeCulling { } else { this.index.query(viewport, (node) => selected.push(node)); } + for (const node of selected) node.selectedStamp = this.selectionStamp; + this.selectionViewport = viewport && { ...viewport }; + this.selectionRevision = this.revision; return selected; } } From 7b543eeaf233227d6c9b34e7abeefe70fbf65fab Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 23 Oct 2026 22:03:27 +0300 Subject: [PATCH 12/13] perf: separate batch geometry and spatial visibility caches --- .../graph/docs/connections/batch-rendering.md | 17 + .../canvas/connections/Arrow/index.ts | 4 + .../connections/BatchPath2D/index.test.ts | 242 +++++++++++- .../canvas/connections/BatchPath2D/index.ts | 163 ++++++-- .../connections/BatchPath2D/spatial.test.ts | 351 ++++++++++++++++++ .../canvas/connections/BlockConnection.ts | 4 + .../canvas/connections/visibility.test.ts | 37 +- 7 files changed, 793 insertions(+), 25 deletions(-) create mode 100644 packages/graph/src/components/canvas/connections/BatchPath2D/spatial.test.ts diff --git a/packages/graph/docs/connections/batch-rendering.md b/packages/graph/docs/connections/batch-rendering.md index 1c6434f6..7d65edb8 100644 --- a/packages/graph/docs/connections/batch-rendering.md +++ b/packages/graph/docs/connections/batch-rendering.md @@ -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: @@ -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: diff --git a/packages/graph/src/components/canvas/connections/Arrow/index.ts b/packages/graph/src/components/canvas/connections/Arrow/index.ts index b32c19cd..a7995ff8 100644 --- a/packages/graph/src/components/canvas/connections/Arrow/index.ts +++ b/packages/graph/src/components/canvas/connections/Arrow/index.ts @@ -9,6 +9,10 @@ export class ConnectionArrow implements Path2DRenderInsta return this.connection.isPathVisible(); } + public getPathBounds() { + return this.connection.getPathBounds(); + } + public getPath() { return this.connection.createArrowPath(); } diff --git a/packages/graph/src/components/canvas/connections/BatchPath2D/index.test.ts b/packages/graph/src/components/canvas/connections/BatchPath2D/index.test.ts index 049328f7..15a4a053 100644 --- a/packages/graph/src/components/canvas/connections/BatchPath2D/index.test.ts +++ b/packages/graph/src/components/canvas/connections/BatchPath2D/index.test.ts @@ -1,5 +1,59 @@ import { BatchPath2DRenderer } from "./index"; +const CanvasPath2D = globalThis.Path2D; + +beforeAll(() => { + // jest-canvas-mock ignores the source path; match the browser's copying constructor. + globalThis.Path2D = class extends CanvasPath2D { + constructor(source?: Path2D | string) { + super(source); + if (source instanceof CanvasPath2D) super.addPath(source); + } + }; +}); + +afterAll(() => { + globalThis.Path2D = CanvasPath2D; +}); + +function createPath(end: number) { + const path = new Path2D(); + path.moveTo(0, 0); + path.lineTo(end, 0); + return path; +} + +function createItem(end: number) { + const state = { end, visible: true }; + return { + state, + getPath: jest.fn(() => createPath(state.end)), + isPathVisible: () => state.visible, + style: () => ({ type: "stroke" as const }), + }; +} + +function createFixture(chunkSize = 100) { + const ctx = document.createElement("canvas").getContext("2d"); + if (!ctx) throw new Error("Expected canvas context"); + const stroke = jest.spyOn(ctx, "stroke"); + const schedule = jest.fn(); + const batch = new BatchPath2DRenderer(schedule, chunkSize); + return { + ctx, + batch, + schedule, + render() { + stroke.mockClear(); + batch.orderedPaths.get().forEach((group) => group.render(ctx)); + return stroke.mock.calls.flatMap(([path]) => { + const events = (path as Path2D & { _path: { type: string; props: { x: number } }[] })._path; + return events.filter((event) => event.type === "lineTo").map((event) => event.props.x); + }); + }, + }; +} + test("stable frames reuse visibility and paths; markDirty refreshes availability", () => { const ctx = document.createElement("canvas").getContext("2d"); if (!ctx) throw new Error("Expected canvas context"); @@ -60,7 +114,193 @@ test("visibility transitions reset only their chunk without scheduling another f visible = true; batch.invalidateVisibility(affected); render(); - expect(affected.getPath).toHaveBeenCalledTimes(2); + expect(affected.getPath).toHaveBeenCalledTimes(1); expect(unaffected.getPath).toHaveBeenCalledTimes(1); expect(schedule).not.toHaveBeenCalled(); }); + +test("visibility changes rebuild the drawn path while reusing unchanged geometry in the same chunk", () => { + const { batch, render, schedule } = createFixture(3); + const first = createItem(10); + const initiallyHidden = createItem(20); + const last = createItem(30); + initiallyHidden.state.visible = false; + for (const item of [first, initiallyHidden, last]) batch.add(item, { zIndex: 0, group: "connections" }); + expect(render()).toEqual([10, 30]); + expect(initiallyHidden.getPath).not.toHaveBeenCalled(); + schedule.mockClear(); + + first.state.visible = false; + batch.invalidateVisibility(first); + expect(render()).toEqual([30]); + expect(last.getPath).toHaveBeenCalledTimes(1); + + initiallyHidden.state.visible = true; + batch.invalidateVisibility(initiallyHidden); + expect(render()).toEqual([20, 30]); + first.state.visible = true; + batch.invalidateVisibility(first); + expect(render()).toEqual([10, 20, 30]); + for (const item of [first, initiallyHidden, last]) expect(item.getPath).toHaveBeenCalledTimes(1); + expect(schedule).not.toHaveBeenCalled(); +}); + +test("markDirty refreshes only the changed geometry and keeps hidden changes lazy until entry", () => { + const { batch, render, schedule } = createFixture(3); + const changed = createItem(10); + const neighbor = createItem(20); + for (const item of [changed, neighbor]) batch.add(item, { zIndex: 0, group: "connections" }); + expect(render()).toEqual([10, 20]); + schedule.mockClear(); + + changed.state.end = 11; + batch.markDirty(changed); + expect(render()).toEqual([11, 20]); + expect(changed.getPath).toHaveBeenCalledTimes(2); + expect(neighbor.getPath).toHaveBeenCalledTimes(1); + expect(schedule).toHaveBeenCalledTimes(1); + + changed.state.visible = false; + batch.invalidateVisibility(changed); + expect(render()).toEqual([20]); + changed.state.end = 12; + batch.markDirty(changed); + expect(render()).toEqual([20]); + expect(changed.getPath).toHaveBeenCalledTimes(2); + changed.state.visible = true; + batch.invalidateVisibility(changed); + expect(render()).toEqual([12, 20]); + expect(changed.getPath).toHaveBeenCalledTimes(3); + expect(neighbor.getPath).toHaveBeenCalledTimes(1); +}); + +test.each([ + { zIndex: 0, group: "connections" }, + { zIndex: 0, group: "custom" }, + { zIndex: -1, group: "connections" }, +])("update refreshes custom geometry with params %j without rebuilding a neighbor", (params) => { + const { batch, render } = createFixture(3); + const changed = createItem(10); + const neighbor = createItem(20); + for (const item of [changed, neighbor]) batch.add(item, { zIndex: 0, group: "connections" }); + expect(render()).toEqual([10, 20]); + + changed.state.end = 11; + batch.update(changed, params); + expect(render()).toEqual(params.group === "custom" ? [20, 11] : [11, 20]); + expect(changed.getPath).toHaveBeenCalledTimes(2); + expect(neighbor.getPath).toHaveBeenCalledTimes(1); +}); + +test.each([null, undefined])("an empty custom path (%s) stays cached until markDirty", (empty) => { + const { batch, render } = createFixture(3); + const absent = createItem(10); + const neighbor = createItem(20); + let available = false; + const getPath = jest.fn(() => (available ? createPath(10) : empty)); + const item = { ...absent, getPath }; + for (const member of [item, neighbor]) batch.add(member, { zIndex: 0, group: "connections" }); + expect(render()).toEqual([20]); + + available = true; + neighbor.state.visible = false; + batch.invalidateVisibility(neighbor); + expect(render()).toEqual([]); + neighbor.state.visible = true; + batch.invalidateVisibility(neighbor); + expect(render()).toEqual([20]); + expect(getPath).toHaveBeenCalledTimes(1); + expect(neighbor.getPath).toHaveBeenCalledTimes(1); + + batch.markDirty(item); + expect(render()).toEqual([10, 20]); + expect(getPath).toHaveBeenCalledTimes(2); + expect(neighbor.getPath).toHaveBeenCalledTimes(1); +}); + +test("deleting and adding the same custom item again discards its previous geometry", () => { + const { batch, render } = createFixture(3); + const removed = createItem(10); + const neighbor = createItem(20); + for (const item of [removed, neighbor]) batch.add(item, { zIndex: 0, group: "connections" }); + expect(render()).toEqual([10, 20]); + + batch.delete(removed); + removed.state.end = 11; + expect(render()).toEqual([20]); + batch.add(removed, { zIndex: 0, group: "connections" }); + expect(render()).toEqual([20, 11]); + expect(removed.getPath).toHaveBeenCalledTimes(2); + expect(neighbor.getPath).toHaveBeenCalledTimes(1); +}); + +test("each custom path is captured before the next getPath mutates a shared path", () => { + const { batch, render } = createFixture(3); + const shared = createPath(10); + const first = { getPath: () => shared, style: () => ({ type: "stroke" as const }) }; + const second = { + getPath: () => { + shared.lineTo(20, 0); + return shared; + }, + style: () => ({ type: "stroke" as const }), + }; + for (const item of [first, second]) batch.add(item, { zIndex: 0, group: "connections" }); + + expect(render()).toEqual([10, 10, 20]); +}); + +test("mutating a returned custom path does not change its cached snapshot until markDirty", () => { + const { batch, render } = createFixture(3); + const shared = createPath(10); + const custom = { getPath: jest.fn(() => shared), style: () => ({ type: "stroke" as const }) }; + const neighbor = createItem(20); + for (const item of [custom, neighbor]) batch.add(item, { zIndex: 0, group: "connections" }); + expect(render()).toEqual([10, 20]); + + shared.lineTo(11, 0); + expect(render()).toEqual([10, 20]); + neighbor.state.visible = false; + batch.invalidateVisibility(neighbor); + expect(render()).toEqual([10]); + expect(custom.getPath).toHaveBeenCalledTimes(1); + + batch.markDirty(custom); + expect(render()).toEqual([10, 11]); + expect(custom.getPath).toHaveBeenCalledTimes(2); +}); + +test("custom styles and afterRender keep registration and z order with the first visible item's fill rule", () => { + const { batch, ctx, render } = createFixture(3); + const calls: string[] = []; + const first = createItem(10); + const second = createItem(20); + const upper = createItem(30); + const makeCustom = (item: ReturnType, name: string, fillRule: CanvasFillRule) => ({ + ...item, + style: () => { + calls.push(`style:${name}`); + return { type: "both" as const, fillRule }; + }, + afterRender: () => calls.push(`after:${name}`), + }); + const upperCustom = makeCustom(upper, "upper", "nonzero"); + const firstCustom = makeCustom(first, "first", "nonzero"); + const secondCustom = makeCustom(second, "second", "evenodd"); + batch.add(upperCustom, { zIndex: 1, group: "connections" }); + batch.add(firstCustom, { zIndex: 0, group: "connections" }); + batch.add(secondCustom, { zIndex: 0, group: "connections" }); + const fill = jest.spyOn(ctx, "fill"); + expect(render()).toEqual([10, 20, 30]); + expect(calls).toEqual(["style:first", "after:first", "after:second", "style:upper", "after:upper"]); + + calls.length = 0; + fill.mockClear(); + first.state.visible = false; + batch.invalidateVisibility(firstCustom); + expect(render()).toEqual([20, 30]); + expect(calls).toEqual(["style:second", "after:second", "style:upper", "after:upper"]); + expect(fill.mock.calls.map(([, rule]) => rule)).toEqual(["evenodd", "nonzero"]); + expect(second.getPath).toHaveBeenCalledTimes(1); + expect(upper.getPath).toHaveBeenCalledTimes(1); +}); diff --git a/packages/graph/src/components/canvas/connections/BatchPath2D/index.ts b/packages/graph/src/components/canvas/connections/BatchPath2D/index.ts index 1012e366..9a69ffc6 100644 --- a/packages/graph/src/components/canvas/connections/BatchPath2D/index.ts +++ b/packages/graph/src/components/canvas/connections/BatchPath2D/index.ts @@ -6,49 +6,165 @@ export type Path2DRenderStyleResult = | { type: "both"; fillRule?: CanvasFillRule }; export interface Path2DRenderInstance { + /** Cached until update/markDirty; viewport changes only refresh visibility. */ getPath(): Path2D | undefined | null; + /** World-space locality hint only; visibility is always decided by isPathVisible. */ + getPathBounds?(): readonly [number, number, number, number] | undefined; style(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined; afterRender?(ctx: CanvasRenderingContext2D): void; isPathVisible?(): boolean; } +interface PathRecord { + item: Path2DRenderInstance; + path?: Path2D; + ready: boolean; + visible: boolean; + cell?: SpatialPath; +} + +class SpatialPath { + public records = new Set(); + public path = cache(() => { + let path: Path2D | undefined; + for (const record of this.records) { + if (record.visible && record.path) { + path ??= new Path2D(); + path.addPath(record.path); + } + } + return path; + }); +} + +function getCenter(item: Path2DRenderInstance): [number, number] | undefined { + const bounds = item.getPathBounds?.(); + if (!bounds || !bounds.every(Number.isFinite) || bounds[0] > bounds[2] || bounds[1] > bounds[3]) return undefined; + return [bounds[0] / 2 + bounds[2] / 2, bounds[1] / 2 + bounds[3] / 2]; +} + class Path2DChunk { - protected items: Set = new Set(); + protected items = new Map(); + private spatial = false; + private pathBuilt = false; + private cells?: Map; + private extent = [0, 0, 0, 0]; protected visibleItems = cache(() => { - return Array.from(this.items).filter((item) => item.isPathVisible?.() ?? true); + const visible: PathRecord[] = []; + for (const record of this.items.values()) { + const next = record.item.isPathVisible?.() ?? true; + if (record.visible !== next) record.cell?.path.reset(); + record.visible = next; + if (record.visible) visible.push(record); + } + return visible; }); protected path = cache(() => { + const visible = this.visibleItems.get(); + // Run custom hooks in registration order, copying mutable paths immediately. + for (const record of visible) { + if (!record.ready) { + const source = record.item.getPath(); + record.path = source ? new Path2D(source) : undefined; + record.ready = true; + if (this.cells) this.place(record, getCenter(record.item), this.cells); + } + } const path = new Path2D(); path.moveTo(0, 0); - // Use already filtered visibleItems - no need for additional visibility checks - for (const item of this.visibleItems.get()) { - const subPath = item.getPath(); - if (subPath) { - path.addPath(subPath); + if (this.spatial) { + const cells = this.cells ?? this.partition(); + for (const cell of cells.values()) { + const subPath = cell.path.get(); + if (subPath) path.addPath(subPath); + } + } else { + for (const record of visible) { + if (record.path) path.addPath(record.path); } } + this.pathBuilt = true; return path; }); - protected applyStyles(ctx: CanvasRenderingContext2D) { - // Style comes from first visible item - const first = this.visibleItems.get()[0]; - return first?.style(ctx); + private partition() { + const centers = Array.from(this.items.values(), ({ item }) => getCenter(item)); + const extent = [Infinity, Infinity, -Infinity, -Infinity]; + for (const center of centers) { + if (!center) continue; + extent[0] = Math.min(extent[0], center[0]); + extent[1] = Math.min(extent[1], center[1]); + extent[2] = Math.max(extent[2], center[0]); + extent[3] = Math.max(extent[3], center[1]); + } + this.extent = extent; + const cells = new Map(); + this.cells = cells; + let index = 0; + for (const record of this.items.values()) this.place(record, centers[index++], cells); + return cells; } - public add(item: Path2DRenderInstance) { - this.items.add(item); + private place(record: PathRecord, center: [number, number] | undefined, cells: Map) { + let key = -1; + if (center) { + const coordinate = (axis: number) => { + // Halving first avoids overflow for valid, very large world coordinates. + const min = this.extent[axis] / 2; + const span = this.extent[axis + 2] / 2 - min; + return span > 0 ? Math.max(0, Math.min(3, Math.floor(((center[axis] / 2 - min) / span) * 4))) : 0; + }; + key = coordinate(0) + 4 * coordinate(1); + } + let cell = cells.get(key); + if (!cell) { + cell = new SpatialPath(); + cells.set(key, cell); + } + if (record.cell !== cell) { + record.cell?.records.delete(record); + record.cell?.path.reset(); + cell.records.add(record); + cell.path.reset(); + record.cell = cell; + } + } + + private resetMembership() { + if (this.cells) { + this.cells = undefined; + for (const record of this.items.values()) record.cell = undefined; + } this.reset(); } + public add(item: Path2DRenderInstance) { + this.items.set(item, { item, ready: false, visible: false }); + this.resetMembership(); + } + public delete(item: Path2DRenderInstance) { this.items.delete(item); + this.resetMembership(); + } + + public resetItem(item: Path2DRenderInstance, geometry: boolean) { + const record = this.items.get(item); + if (!record) return; + if (geometry) { + record.ready = false; + record.path = undefined; + } else if (this.pathBuilt && this.items.size > 16) { + // Full overview stays flat; spatial caches pay for themselves on viewport changes. + this.spatial = true; + } + record.cell?.path.reset(); this.reset(); } - public reset() { + private reset() { this.path.reset(); this.visibleItems.reset(); } @@ -58,7 +174,7 @@ class Path2DChunk { if (!vis.length) return; ctx.save(); - const style = this.applyStyles(ctx); + const style = vis[0].item.style(ctx); if (style) { const p = this.path.get(); if (style.type === "fill" || style.type === "both") { @@ -70,8 +186,8 @@ class Path2DChunk { } ctx.restore(); - for (const item of vis) { - item.afterRender?.(ctx); + for (const record of vis) { + record.item.afterRender?.(ctx); } } @@ -112,9 +228,9 @@ class Path2DGroup { } } - public resetItem(item: Path2DRenderInstance) { + public resetItem(item: Path2DRenderInstance, geometry: boolean) { const chunk = this.itemToChunk.get(item); - chunk?.reset(); + chunk?.resetItem(item, geometry); } public render(ctx: CanvasRenderingContext2D) { @@ -205,8 +321,11 @@ export class BatchPath2DRenderer { } public markDirty(item: Path2DRenderInstance) { - this.invalidateVisibility(item); - if (this.itemParams.has(item)) this.requestRender(); + const params = this.itemParams.get(item); + if (params) { + this.getGroup(params.zIndex, params.group).resetItem(item, true); + this.requestRender(); + } } /** Visibility is finalized during the current traversal, before this batch draws. */ @@ -214,7 +333,7 @@ export class BatchPath2DRenderer { const params = this.itemParams.get(item); if (params) { const group = this.getGroup(params.zIndex, params.group); - group.resetItem(item); + group.resetItem(item, false); } } } diff --git a/packages/graph/src/components/canvas/connections/BatchPath2D/spatial.test.ts b/packages/graph/src/components/canvas/connections/BatchPath2D/spatial.test.ts new file mode 100644 index 00000000..3cf8527f --- /dev/null +++ b/packages/graph/src/components/canvas/connections/BatchPath2D/spatial.test.ts @@ -0,0 +1,351 @@ +import { BatchPath2DRenderer } from "./index"; + +type Bounds = readonly [number, number, number, number]; +type MockPath = Path2D & { _path: { type: string; props: { x: number } }[] }; + +const CanvasPath2D = globalThis.Path2D; +const paths: Path2D[] = []; + +beforeAll(() => { + // jest-canvas-mock ignores the source path; match the browser's copying constructor. + globalThis.Path2D = class extends CanvasPath2D { + constructor(source?: Path2D | string) { + super(source); + if (source instanceof CanvasPath2D) super.addPath(source); + paths.push(this); + } + }; +}); + +beforeEach(() => { + paths.length = 0; +}); + +afterAll(() => { + globalThis.Path2D = CanvasPath2D; +}); + +function countPathAdds() { + return paths.reduce((count, path) => count + jest.mocked(path.addPath).mock.calls.length, 0); +} + +function createItem(id: number) { + const x = (id % 10) * 1000; + const y = Math.floor(id / 10) * 1000; + const state: { end: number; visible: boolean; bounds: Bounds | undefined } = { + end: id + 1, + visible: true, + bounds: [x, y, x + 10, y + 10], + }; + return { + state, + getPathBounds: () => state.bounds, + getPath: jest.fn(() => { + const path = new Path2D(); + path.moveTo(state.end - 1, 0); + path.lineTo(state.end, 0); + return path; + }), + isPathVisible: () => state.visible, + style: () => ({ type: "stroke" as const }), + }; +} + +function createFixture() { + const ctx = document.createElement("canvas").getContext("2d"); + if (!ctx) throw new Error("Expected canvas context"); + const stroke = jest.spyOn(ctx, "stroke"); + const batch = new BatchPath2DRenderer(() => {}, 100); + return { + ctx, + batch, + render() { + stroke.mockClear(); + batch.orderedPaths.get().forEach((group) => group.render(ctx)); + return stroke.mock.calls.map(([path]) => + (path as MockPath)._path.filter((event) => event.type === "lineTo").map((event) => event.props.x) + ); + }, + }; +} + +function visibleEnds(items: { state: { visible: boolean; end: number } }[]) { + return items + .filter((item) => item.state.visible) + .map((item) => item.state.end) + .sort((a, b) => a - b); +} + +test("a nearby visibility transition reuses geometry and rebuilds only a small part of an interleaved chunk", () => { + const { batch, render } = createFixture(); + const items = Array.from({ length: 100 }, (_, index) => createItem(index)); + for (let index = 0; index < items.length; index++) { + batch.add(items[(index * 37) % items.length], { zIndex: 0, group: "connections" }); + } + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); + items[0].state.visible = false; + batch.invalidateVisibility(items[0]); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); + for (const item of items) item.getPath.mockClear(); + const before = countPathAdds(); + + items[1].state.visible = false; + batch.invalidateVisibility(items[1]); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); + expect(countPathAdds() - before).toBeLessThan(40); + for (const item of items) expect(item.getPath).not.toHaveBeenCalled(); +}); + +test.each([true, false])( + "one invalidation refreshes shared custom visibility in another region, initially visible: %s", + (initiallyVisible) => { + const { batch, render } = createFixture(); + const items = Array.from({ length: 100 }, (_, index) => createItem(index)); + const shared = { remoteVisible: initiallyVisible }; + items[99].isPathVisible = () => shared.remoteVisible; + const expected = () => visibleEnds(items.filter((item) => item.isPathVisible())); + for (const item of items) batch.add(item, { zIndex: 0, group: "connections" }); + render(); + items[0].state.visible = false; + batch.invalidateVisibility(items[0]); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(expected()); + + shared.remoteVisible = !initiallyVisible; + items[1].state.visible = false; + batch.invalidateVisibility(items[1]); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(expected()); + + shared.remoteVisible = initiallyVisible; + items[1].state.visible = true; + batch.invalidateVisibility(items[1]); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(expected()); + } +); + +test("moving geometry between spatial regions removes its old path and updates only that item", () => { + const { batch, render } = createFixture(); + const items = Array.from({ length: 100 }, (_, index) => createItem(index)); + for (const item of items) batch.add(item, { zIndex: 0, group: "connections" }); + render(); + items[0].state.visible = false; + batch.invalidateVisibility(items[0]); + render(); + for (const item of items) item.getPath.mockClear(); + + const moved = items[1]; + moved.state.end = 1000; + moved.state.bounds = [50_000, 50_000, 50_010, 50_010]; + batch.markDirty(moved); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); + expect(moved.getPath).toHaveBeenCalledTimes(1); + for (const item of items.filter((item) => item !== moved)) expect(item.getPath).not.toHaveBeenCalled(); + + moved.state.visible = false; + batch.invalidateVisibility(moved); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); + moved.state.visible = true; + batch.invalidateVisibility(moved); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); + expect(moved.getPath).toHaveBeenCalledTimes(1); +}); + +test("missing, unknown and invalid custom bounds remain visible through spatial cache updates", () => { + const { batch, render } = createFixture(); + const items = Array.from({ length: 100 }, (_, index) => createItem(index)); + const unsupported: (Bounds | undefined)[] = [ + undefined, + [Number.NaN, 0, 10, 10], + [0, 0, Number.POSITIVE_INFINITY, 10], + [10, 0, 0, 10], + [0, 10, 10, 0], + ]; + unsupported.forEach((bounds, index) => { + items[index + 1].state.bounds = bounds; + }); + const noBounds = { ...items[6], getPathBounds: undefined }; + for (const item of items) batch.add(item === items[6] ? noBounds : item, { zIndex: 0, group: "connections" }); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); + + items[0].state.visible = false; + batch.invalidateVisibility(items[0]); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); + items[99].state.visible = false; + batch.invalidateVisibility(items[99]); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); + + noBounds.state.visible = false; + batch.invalidateVisibility(noBounds); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); + items[2].state.bounds = [9000, 9000, 9010, 9010]; + items[2].state.end = 1000; + batch.markDirty(items[2]); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); +}); + +test("adding and removing members after spatial activation preserves unchanged geometry", () => { + const { batch, render } = createFixture(); + const items = Array.from({ length: 100 }, (_, index) => createItem(index)); + for (const item of items) batch.add(item, { zIndex: 0, group: "connections" }); + render(); + items[0].state.visible = false; + batch.invalidateVisibility(items[0]); + render(); + for (const item of items) item.getPath.mockClear(); + + batch.delete(items[1]); + const remaining = items.filter((_, index) => index !== 1); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(remaining)); + const added = createItem(100); + batch.add(added, { zIndex: 0, group: "connections" }); + remaining.push(added); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(remaining)); + expect(added.getPath).toHaveBeenCalledTimes(1); + items[0].state.visible = true; + batch.invalidateVisibility(items[0]); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(remaining)); + for (const item of items) expect(item.getPath).not.toHaveBeenCalled(); +}); + +test("a chunk with entirely unknown bounds can acquire known geometry after spatial activation", () => { + const { batch, render } = createFixture(); + const items = Array.from({ length: 100 }, (_, index) => createItem(index)); + for (const item of items) { + item.state.bounds = undefined; + batch.add(item, { zIndex: 0, group: "connections" }); + } + render(); + items[0].state.visible = false; + batch.invalidateVisibility(items[0]); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); + + items[1].state.end = 1000; + items[1].state.bounds = [1000, 1000, 1010, 1010]; + batch.markDirty(items[1]); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); + items[2].state.visible = false; + batch.invalidateVisibility(items[2]); + expect( + render() + .flat() + .sort((a, b) => a - b) + ).toEqual(visibleEnds(items)); +}); + +test("spatial caching preserves draw boundaries and custom hook order across chunks and z groups", () => { + const { batch, ctx, render } = createFixture(); + const calls: string[] = []; + const custom = (id: number) => ({ + ...createItem(id), + style() { + calls.push(`style:${id}`); + return { type: "both" as const, fillRule: id === 1 ? ("evenodd" as const) : ("nonzero" as const) }; + }, + afterRender() { + calls.push(`after:${id}`); + }, + }); + const items = Array.from({ length: 120 }, (_, index) => custom(index)); + const upper = custom(120); + batch.add(upper, { zIndex: 1, group: "connections" }); + for (const item of items) batch.add(item, { zIndex: 0, group: "connections" }); + expect(render().map((path) => path.length)).toEqual([100, 20, 1]); + items[0].state.visible = false; + batch.invalidateVisibility(items[0]); + render(); + calls.length = 0; + const fill = jest.spyOn(ctx, "fill"); + fill.mockClear(); + + items[2].state.visible = false; + batch.invalidateVisibility(items[2]); + const drawn = render(); + expect(drawn.map((path) => path.length)).toEqual([98, 20, 1]); + expect(drawn[0].sort((a, b) => a - b)).toEqual(visibleEnds(items.slice(0, 100))); + expect(drawn[1].sort((a, b) => a - b)).toEqual(visibleEnds(items.slice(100))); + expect(drawn[2]).toEqual([121]); + expect(calls).toEqual([ + "style:1", + ...items.slice(0, 100).flatMap((item, index) => (item.state.visible ? [`after:${index}`] : [])), + "style:100", + ...items.slice(100).map((_, index) => `after:${index + 100}`), + "style:120", + "after:120", + ]); + expect(fill.mock.calls.map(([, rule]) => rule)).toEqual(["evenodd", "nonzero", "nonzero"]); +}); diff --git a/packages/graph/src/components/canvas/connections/BlockConnection.ts b/packages/graph/src/components/canvas/connections/BlockConnection.ts index 95db6fa7..1fec2bda 100644 --- a/packages/graph/src/components/canvas/connections/BlockConnection.ts +++ b/packages/graph/src/components/canvas/connections/BlockConnection.ts @@ -142,6 +142,10 @@ export class BlockConnection this.context.batch.invalidateVisibility(this.arrowShape); } + public getPathBounds() { + return this.getBBox(); + } + public getPath(): Path2D { return this.generatePath(); } diff --git a/packages/graph/src/components/canvas/connections/visibility.test.ts b/packages/graph/src/components/canvas/connections/visibility.test.ts index a03a77d1..6f9ddef8 100644 --- a/packages/graph/src/components/canvas/connections/visibility.test.ts +++ b/packages/graph/src/components/canvas/connections/visibility.test.ts @@ -93,7 +93,7 @@ describe("connection batch viewport visibility", () => { expect(view("far").draws).toEqual([]); }); - it("invalidates cached line and arrow chunks when the camera enters and leaves", () => { + it("reuses line and arrow geometry when the camera leaves and reenters", () => { mount([ { id: "near", x: 100, y: 100 }, { id: "far", x: 50_000, y: 50_000 }, @@ -110,10 +110,43 @@ describe("connection batch viewport visibility", () => { view("far").draws = []; graph.cameraService.set({ x: 0, y: 0 }); frames(2); - expect(view("near").draws).toEqual(expect.arrayContaining(["path", "arrow", "afterRender"])); + expect(view("near").draws).toEqual(expect.arrayContaining(["style", "arrowStyle", "afterRender"])); + expect(view("near").draws).not.toContain("path"); + expect(view("near").draws).not.toContain("arrow"); expect(view("far").draws).toEqual([]); }); + it.each([true, false])("keeps custom line and arrow geometry cached across pan with Tree culling %s", (culling) => { + mount(Array.from({ length: 100 }, (_, index) => ({ id: String(index), x: 100 + index * 10, y: 100 }))); + graph.updateSettings({ EXP_VISIBILITY_CULLING: culling }); + frames(2); + for (let index = 0; index < 100; index++) view(String(index)).draws = []; + + // One previously visible connection exits; all its visible neighbors share the draw chunk. + graph.cameraService.set({ x: -210 }); + frames(2); + expect(view("0").draws).toEqual([]); + expect(view("20").draws).toContain("afterRender"); + for (let index = 1; index < 80; index++) { + expect(view(String(index)).draws).not.toContain("path"); + expect(view(String(index)).draws).not.toContain("arrow"); + } + + for (let index = 0; index < 100; index++) view(String(index)).draws = []; + graph.cameraService.set({ x: 0 }); + frames(2); + expect(view("0").draws).toContain("afterRender"); + expect(view("0").draws).not.toContain("path"); + expect(view("0").draws).not.toContain("arrow"); + + connectionState("20").$targetPortState.value.updatePort({ x: 450, y: 150, lookup: false }); + frames(2); + expect(view("20").draws).toContain("path"); + expect(view("20").draws).toContain("arrow"); + expect(view("21").draws).not.toContain("path"); + expect(view("21").draws).not.toContain("arrow"); + }); + it("finds a label intersecting the viewport before its outside line ever draws", () => { mount([{ id: "label", x: 1010, y: 100, label: "a".repeat(200) }]); From 8135039154c7d300e4e5b5375c37f0e20567ce0d Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 23 Oct 2026 23:10:28 +0300 Subject: [PATCH 13/13] perf(graph): reuse unchanged viewport visibility publication --- .../graph/src/lib/Tree.invalidation.test.ts | 28 ++++ .../lib/Tree.visibilityPublication.test.ts | 133 ++++++++++++++++++ packages/graph/src/lib/TreeCulling.ts | 23 ++- 3 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 packages/graph/src/lib/Tree.visibilityPublication.test.ts diff --git a/packages/graph/src/lib/Tree.invalidation.test.ts b/packages/graph/src/lib/Tree.invalidation.test.ts index 6ee95e4f..8d79c6a5 100644 --- a/packages/graph/src/lib/Tree.invalidation.test.ts +++ b/packages/graph/src/lib/Tree.invalidation.test.ts @@ -178,6 +178,34 @@ describe("Tree geometry and structure invalidation", () => { expect(work).toEqual({ writes: 0, paintChecks: 0 }); }); + it("does not republish unchanged visibility when a dense viewport moves within the same selection", () => { + const { state, add, walk } = setup(); + const nodes = Array.from({ length: 25 }, (_, i) => add(`node${i}`, 10 + i * 20)); + walk(); + state.viewport = viewport(465); + walk(); + const work = { eligibility: 0, publication: 0 }; + for (const node of nodes) { + const isEligible = node.isEligible.bind(node); + node.isEligible = (scope) => { + work.eligibility++; + return isEligible(scope); + }; + const setVisible = node.setVisible.bind(node); + node.setVisible = (visible) => { + work.publication++; + return setVisible(visible); + }; + } + + state.viewport = viewport(466); + const result = walk(); + expect(result.prepared).toEqual(["root", ...nodes.slice(0, 23).map((node) => node.data.name)]); + expect(result.drawn).toEqual(result.prepared); + expect(result.events.filter((event) => event.startsWith("visible:"))).toEqual([]); + expect(work).toEqual({ eligibility: 0, publication: 0 }); + }); + it.each(["before", "during"])( "does not enumerate unchanged hierarchy or ancestry when bounds move %s preparation", (timing) => { diff --git a/packages/graph/src/lib/Tree.visibilityPublication.test.ts b/packages/graph/src/lib/Tree.visibilityPublication.test.ts new file mode 100644 index 00000000..629eb354 --- /dev/null +++ b/packages/graph/src/lib/Tree.visibilityPublication.test.ts @@ -0,0 +1,133 @@ +import type { TTileRect } from "./TileIndex"; +import { ITree, Tree } from "./Tree"; + +type Probe = ITree & { + name: string; + paint?: () => boolean; + visibility?: (visible: boolean) => void; +}; + +function setup() { + const state = { viewport: { minX: 0, minY: 0, maxX: 100, maxY: 100 } as TTileRect }; + const events: string[] = []; + const batch = new Set(); + const make = (name: string, x?: number): Tree => { + const node: Tree = new Tree({ + name, + prepareUpdate: () => { + events.push(`prepare:${name}`); + return true; + }, + onViewportChange: (visible) => { + events.push(`visible:${name}:${visible}`); + if (visible) batch.add(name); + else batch.delete(name); + node.data.visibility?.(visible); + }, + iterate: () => { + events.push(`iterate:${name}`); + if (name === "root") events.push(`batch:${Array.from(batch).sort().join(",")}`); + if (node.isVisible()) events.push(`draw:${name}`); + return node.data.paint?.() ?? true; + }, + }); + if (x !== undefined) node.setBounds(x, x, x + 5, x + 5); + if (name !== "root") batch.add(name); + return node; + }; + const root = make("root"); + root.setCulling({ tileSize: 32, getViewport: () => state.viewport }); + const add = (name: string, x: number, parent = root): Tree => { + const node = make(name, x); + parent.append(node); + return node; + }; + const fill = (): string[] => { + const names = Array.from({ length: 20 }, (_, i) => `resident${i}`); + for (const name of names) add(name, 25); + add("outside", 1000); + return names; + }; + const walk = () => { + events.length = 0; + root.traverseDown((node) => node.data.iterate()); + const names = (prefix: string): string[] => + events.filter((event) => event.startsWith(prefix)).map((event) => event.slice(prefix.length)); + return { prepared: names("prepare:"), drawn: names("draw:"), events: events.slice() }; + }; + return { root, state, add, fill, walk }; +} + +describe("Tree visibility publication", () => { + it.each(["bounds", "viewport", "ancestor"] as const)( + "rechecks a resident when a clean exit callback changes its %s before publication", + (change) => { + const { state, add, fill, walk } = setup(); + const exiting = add("exiting", 5); + const parent = add("parent", 30); + const target = add("target", 40, parent); + const residents = fill(); + walk(); + walk(); + exiting.data.visibility = (visible) => { + if (visible) return; + if (change === "bounds") target.setBounds(1000, 1000, 1005, 1005); + else if (change === "viewport") state.viewport.maxX = state.viewport.maxY = 35; + else parent.setChildrenWalkAllowed(false); + }; + state.viewport.minX = state.viewport.minY = 20; + + const result = walk(); + expect(result.prepared).not.toContain("exiting"); + expect(result.prepared.filter((name) => name === "target")).toEqual(["target"]); + expect(result.drawn).toEqual(["root", "parent", ...residents]); + expect(result.events).toContain(`batch:${["parent", ...residents].sort().join(",")}`); + expect(result.events.indexOf("visible:target:false")).toBeGreaterThan( + result.events.indexOf("visible:exiting:false") + ); + expect(result.events.indexOf("visible:target:false")).toBeLessThan(result.events.indexOf("draw:root")); + expect(target.isVisible()).toBe(false); + } + ); + + it("publishes a new offscreen node's initial exit after an unchanged partial viewport warms", () => { + const { add, fill, walk } = setup(); + const residents = fill(); + walk(); + walk(); + add("new", 2000); + + const result = walk(); + expect(result.prepared.filter((name) => name === "new")).toEqual(["new"]); + expect(result.drawn).toEqual(["root", ...residents]); + expect(result.events).toContain(`batch:${residents.slice().sort().join(",")}`); + expect(result.events.indexOf("visible:new:false")).toBeGreaterThanOrEqual(0); + expect(result.events.indexOf("visible:new:false")).toBeLessThan(result.events.indexOf("draw:root")); + expect(walk().prepared).not.toContain("new"); + }); + + it("restores visibility before the next batch paint after painting suppressed a prepared branch", () => { + const { state, add, fill, walk } = setup(); + const parent = add("parent", 30); + const child = add("child", 40, parent); + const residents = fill(); + walk(); + walk(); + parent.data.paint = () => false; + + let result = walk(); + expect(result.drawn).toEqual(["root", "parent", ...residents]); + expect(result.events.filter((event) => event === "iterate:child")).toEqual(["iterate:child"]); + expect(result.events).toContain("visible:child:false"); + expect(child.isVisible()).toBe(false); + + parent.data.paint = undefined; + state.viewport.maxX = state.viewport.maxY = 99; + result = walk(); + expect(result.drawn).toEqual(["root", "parent", "child", ...residents]); + expect(result.events).toContain(`batch:${["parent", "child", ...residents].sort().join(",")}`); + expect(result.events.indexOf("visible:child:true")).toBeGreaterThanOrEqual(0); + expect(result.events.indexOf("visible:child:true")).toBeLessThan(result.events.indexOf("draw:root")); + expect(child.isVisible()).toBe(true); + }); +}); diff --git a/packages/graph/src/lib/TreeCulling.ts b/packages/graph/src/lib/TreeCulling.ts index e361ec6f..6a8bfed4 100644 --- a/packages/graph/src/lib/TreeCulling.ts +++ b/packages/graph/src/lib/TreeCulling.ts @@ -203,6 +203,7 @@ export class TreeCulling { private publishVisibility(prepared: Tree[], viewport: TTileRect | undefined): void { const visibilityRevision = this.structureRevision; + const publishedRevision = this.visibilityRevision; this.visibilityRevision = -1; // Publish hidden state for new descendants whose preparation an ancestor blocked. for (const node of this.fresh) node.setVisible(false); @@ -220,7 +221,16 @@ export class TreeCulling { } } let reuseSelection = this.isSelectionCurrent(viewport); + const reusePublished = publishedRevision === this.structureRevision; for (const node of prepared) { + // A valid prior snapshot already published this membership and its ancestor permissions. + if ( + node !== this.owner && + reusePublished && + reuseSelection && + node.activeInViewport === (node.selectedStamp === this.selectionStamp) + ) + continue; const visible = node.isEligible(this) && (node !== this.owner && reuseSelection && this.selectionRevision === this.revision @@ -412,16 +422,21 @@ export class TreeCulling { public select(viewport: TTileRect | undefined): Tree[] { const selected: Tree[] = []; - this.selectionStamp = ++TreeCulling.selectionStamp; + const stamp = (this.selectionStamp = ++TreeCulling.selectionStamp); const limit = this.nodes.size * (this.options.fullWalkShare ?? 0.75); if (!viewport || this.index.covers(viewport) || this.index.countCandidates(viewport, limit) > limit) { for (const node of this.nodes) { - if (this.intersects(node, viewport)) selected.push(node); + if (this.intersects(node, viewport)) { + node.selectedStamp = stamp; + selected.push(node); + } } } else { - this.index.query(viewport, (node) => selected.push(node)); + this.index.query(viewport, (node) => { + node.selectedStamp = stamp; + selected.push(node); + }); } - for (const node of selected) node.selectedStamp = this.selectionStamp; this.selectionViewport = viewport && { ...viewport }; this.selectionRevision = this.revision; return selected;