From 94a0628221bbf90bb7baf1f17f535aaca284e15b Mon Sep 17 00:00:00 2001 From: draedful Date: Tue, 20 Oct 2026 13:14:52 +0300 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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; From 754553055a7a57e22782002e185023db0c510c5f Mon Sep 17 00:00:00 2001 From: draedful Date: Sat, 24 Oct 2026 02:37:52 +0300 Subject: [PATCH 14/16] perf(graph): keep tree culling visibility incremental Prepare only new and requested nodes before painting, then publish viewport visibility incrementally: a camera move rechecks nodes in the strips between the previous and current viewport plus nodes with new bounds, and a structural change runs one complete selection. A single flat pass over the cached paint order visits published and prepared nodes; clean components update inside their own iterate(). This removes the per-frame preparation pass, linear selection and the dense/sparse paint paths that made zoomed-out views slower than v2. Connections compute geometry once per real input change instead of up to three times on mount. Co-Authored-By: Claude Opus 5.5 --- .../docs/components/canvas-graph-component.md | 17 +- .../docs/rendering/rendering-mechanism.md | 58 +- .../graph/docs/system/component-lifecycle.md | 20 +- .../system/component-rendering-lifecycle.md | 36 +- packages/graph/docs/system/graph-settings.md | 2 +- .../graph/docs/system/scheduler-system.md | 8 +- .../canvas/connections/BlockConnection.ts | 25 +- .../connections/geometryUpdates.test.ts | 73 +++ packages/graph/src/lib/CoreComponent.ts | 5 + packages/graph/src/lib/Tree.dense.test.ts | 69 ++- .../graph/src/lib/Tree.denseSelection.test.ts | 60 +- .../graph/src/lib/Tree.invalidation.test.ts | 18 +- packages/graph/src/lib/Tree.subtree.test.ts | 4 +- packages/graph/src/lib/Tree.ts | 19 +- .../lib/Tree.visibilityPublication.test.ts | 23 +- .../src/lib/TreeCulling.incremental.test.ts | 180 ++++++ packages/graph/src/lib/TreeCulling.test.ts | 33 +- packages/graph/src/lib/TreeCulling.ts | 526 +++++++++--------- 18 files changed, 790 insertions(+), 386 deletions(-) create mode 100644 packages/graph/src/components/canvas/connections/geometryUpdates.test.ts create mode 100644 packages/graph/src/lib/TreeCulling.incremental.test.ts diff --git a/packages/graph/docs/components/canvas-graph-component.md b/packages/graph/docs/components/canvas-graph-component.md index 9a725077..5ea8a359 100644 --- a/packages/graph/docs/components/canvas-graph-component.md +++ b/packages/graph/docs/components/canvas-graph-component.md @@ -93,10 +93,11 @@ protected isVisible(): boolean { ``` **What's happening here:** -- The tree prepares visible, new, or dirty components before selecting elements to paint. +- The tree prepares new components and components that requested an update before publishing visibility; clean visible components update inside their own `iterate()`, right before painting. - Offscreen components still mount and process changes, including ports, children, and geometry. - `willRender`, `render`, and `didRender` run only for eligible components, including on the first frame. - Clean offscreen components are skipped entirely. A visible child is still reached through its offscreen parent. +- A camera move rechecks only components near the viewport edges; visibility is kept between frames. - Unknown bounds stay eligible. Set bounds during initialization or data updates, before rendering. - Connection batches use the same viewport decision, and world-coordinate hit queries remain independent of the viewport. @@ -105,13 +106,15 @@ 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. +`isVisible()` reads the current viewport and bounds between frames and in update hooks that run before +painting. A custom `willIterate()` can therefore refresh deferred external state on the first frame that +returns to the viewport. During painting, components and batches use the published Tree visibility. Change +geometry through `setState()`, `setProps()` or `performRender()`: a change a clean component makes inside +its own update takes effect for culling from the next frame. For a custom paint condition, assign `shouldRender` after `super.willIterate()`. This flag controls permission to paint; use `isVisible()` to query spatial visibility. The final paint gate also checks -geometry updated later in the preparation phase. A `shouldRender = false` set during `willMount()` +geometry that requested updates produced before painting. A `shouldRender = false` set during `willMount()` is preserved for the first frame. ```typescript @@ -387,8 +390,8 @@ sequenceDiagram loop Each Frame Graph->>Tree: Traverse layer - Tree->>Comp: prepareUpdate() when visible, new, or dirty - Tree->>Tree: Resolve viewport membership + Tree->>Comp: prepareUpdate() when new or requested + Tree->>Tree: Update visibility from viewport delta and changed bounds Tree->>Comp: iterate() to paint or complete pending work alt Component is visible diff --git a/packages/graph/docs/rendering/rendering-mechanism.md b/packages/graph/docs/rendering/rendering-mechanism.md index 6da7c0ba..2af84a70 100644 --- a/packages/graph/docs/rendering/rendering-mechanism.md +++ b/packages/graph/docs/rendering/rendering-mechanism.md @@ -26,31 +26,35 @@ The `Tree` class provides the foundational hierarchy structure with: ### 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. - -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. +Each configured tree root owns one `TreeCulling` coordinator with a shared tile index. A frame has +three steps: + +1. **Requested work.** The root, new nodes and nodes that requested an update through + `performRender()` prepare first, ancestors before descendants. Their data, children and geometry are + final before anything paints, including when they are offscreen. +2. **Visibility.** When the viewport contains every indexed bound, every eligible node is visible. + Otherwise visibility is kept between frames: a camera move rechecks only nodes in the parts of the + previous and current viewports outside each other, plus nodes whose bounds changed. After a + structural change the coordinator runs one complete selection, through tiles or a linear bounds scan + for dense views. Visibility callbacks may move nodes, change the viewport, change the structure or + request updates; publication repeats until it is stable, so batches paint the final membership. +3. **Painting.** One pass over the cached local paint order visits published-visible and already + prepared nodes. A clean visited component applies its own pending work inside `iterate()` right + before it paints (`ITree.preparesOnIterate`); other `ITree` nodes are prepared by the coordinator at + the same point. Clean offscreen nodes receive no hooks, and their visible descendants are still + reached through them. + +A frame therefore costs the visible nodes plus the work that changed. Components and batch paths use +the same published visibility. Hooks of a node being prepared read live visibility from current bounds; +nodes being painted read the published value. + +Structure that a clean node changes while painting (for example children it adds from `render()`) is +painted from the next frame. An explicit child-traversal veto from painting suppresses descendants in +that frame; for a node that does not prepare itself, its next preparation decides again before the +next publication. Prepared descendants of a vetoed branch complete without painting. 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. +existing responsibilities. ### CoreComponent Class @@ -83,9 +87,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. 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 +4. The tree's culling coordinator prepares new and requested components and their ancestors +5. The coordinator publishes viewport visibility, rechecking only what the camera move or changed bounds affect +6. `iterate()` updates and paints visible components; prepared offscreen components complete their update; clean offscreen nodes are skipped 7. The process repeats for each animation frame when changes occur ## Component Lifecycle @@ -168,7 +172,7 @@ 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 -- `prepareUpdate()` - Apply pending data and reconcile children before painting +- `prepareUpdate()` - Apply pending data and reconcile children; runs before painting for requested and new components, and inside `iterate()` for clean visible ones - `iterate()` - Paint eligible components and complete the update - `isInViewport()` - Read the tree's current spatial visibility decision - `static create(props, options)` - Factory method diff --git a/packages/graph/docs/system/component-lifecycle.md b/packages/graph/docs/system/component-lifecycle.md index d336d5b3..2733e03a 100644 --- a/packages/graph/docs/system/component-lifecycle.md +++ b/packages/graph/docs/system/component-lifecycle.md @@ -44,8 +44,8 @@ The CoreComponent provides the foundation with four main methods: | Method | Purpose | When Called | |--------|---------|-------------| -| `prepareUpdate()` | Prepares data and children before viewport selection | For visible, new, or dirty components | -| `iterate()` | Completes the update and paints eligible components | After preparation | +| `prepareUpdate()` | Applies pending data and reconciles children | Before painting for new or requested components; inside `iterate()` for clean visible ones | +| `iterate()` | Completes the update and paints eligible components | For visible components and prepared offscreen ones | | `updateChildren()` | Defines and updates child components | During children update | | `render()` | Handles visual rendering | During render phase | @@ -57,9 +57,11 @@ The Component class extends CoreComponent with additional lifecycle hooks: #### Method Sequence -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. +Each layer tree first prepares new components and components that requested an update, then publishes +viewport visibility, then paints. New or requested components outside the viewport still prepare and +complete their update; clean offscreen components are skipped. A clean visible component prepares +inside its own `iterate()`, right before painting. Preparation is idempotent within an iteration, +including when `iterate()` is called directly. Methods are called in this order: @@ -74,7 +76,7 @@ Methods are called in this order: H -->|Yes| I[willUpdateChildren] I --> J[updateChildren] J --> K[didUpdateChildren] - H -->|No| V[Tree resolves viewport selection] + H -->|No| V[Tree's published viewport visibility] K --> V V --> C{shouldRender and in viewport?} C -->|Yes| D[willRender] @@ -85,7 +87,9 @@ Methods are called in this order: F --> L ``` -Children and their geometry are prepared before the layer draws. Keep initialization, port +Children and geometry of new and requested components are prepared before the layer draws. Change +geometry through `setState()`, `setProps()` or `performRender()`: a change a clean component makes +inside its own update hook takes effect for culling from the next frame. 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 @@ -102,7 +106,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` and the tree's viewport selection | +| 6 | Render evaluated | Based on `shouldRender` and the tree's published viewport visibility | ### 5. Control Flags diff --git a/packages/graph/docs/system/component-rendering-lifecycle.md b/packages/graph/docs/system/component-rendering-lifecycle.md index 93c919d2..56604242 100644 --- a/packages/graph/docs/system/component-rendering-lifecycle.md +++ b/packages/graph/docs/system/component-rendering-lifecycle.md @@ -33,7 +33,7 @@ sequenceDiagram Browser->>Scheduler: requestAnimationFrame Scheduler->>Scheduler: tick() Scheduler->>Tree: traverseDown() - Tree->>Component: prepareUpdate() for visible, new, or dirty nodes + Tree->>Component: prepareUpdate() for new or requested nodes Component->>Component: checkData() Component->>Component: willIterate() @@ -43,9 +43,12 @@ sequenceDiagram Component->>Component: didUpdateChildren() end - Tree->>Tree: Resolve own bounds and visible descendants + Tree->>Tree: Update visibility from viewport delta and changed bounds Tree->>Component: onViewportChange() when membership changes - Tree->>Component: iterate() in local z-index order + Tree->>Component: iterate() in local z-index order for visible or prepared nodes + opt clean visible component + Component->>Component: prepareUpdate() inside iterate() + end alt shouldRender and in viewport Component->>Component: willRender() Component->>Rendering: render() @@ -68,8 +71,8 @@ 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. 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 +5. The tree prepares the requested component, new components and their ancestors +6. The tree publishes viewport visibility, then `iterate()` updates and paints visible components and completes prepared offscreen 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. @@ -81,7 +84,7 @@ not discard state or props updates. The component lifecycle controls the rendering process through several key methods: -1. `prepareUpdate()` - Applies data and reconciles children before painting +1. `prepareUpdate()` - Applies data and reconciles children; before painting for requested and new components, inside `iterate()` for clean visible ones 2. `checkData()` - Processes any pending state/props changes 3. `shouldRender` flag and `isInViewport()` - Together control whether rendering occurs 4. `iterate()` / `render()` - Complete the update and create eligible visual output @@ -99,14 +102,16 @@ All three systems interact with the Tree structure, which: - 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 +Each component reports its own bounds. The tree keeps the published visible set between frames and +rechecks only nodes that a camera move or a bounds change can affect. A visible child is reached through +a clean offscreen parent without running the parent's hooks, so an offscreen parent does not hide a +visible child. Unknown bounds remain eligible. New and requested 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. +affected chunks before drawing. Both the component lifecycle and batch renderer use the same published +visibility. 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 @@ -129,15 +134,16 @@ Combining all three documents, we can map the complete lifecycle from state chan - On the next frame, scheduler traverses the component tree 3. **Preparation** - - Component's `prepareUpdate()` method is called + - Component's `prepareUpdate()` method is called: before painting when the component is new or + requested an update, otherwise inside its own `iterate()` when it is visible - `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 + - If `shouldUpdateChildren` is true, children are reconciled + - For requested components, the tree publishes the resulting bounds before anything paints 4. **Rendering Decision** - - Component evaluates `shouldRender` and the tree's viewport selection + - Component evaluates `shouldRender` and the tree's published viewport visibility - If true: `willRender()` → `render()` → `didRender()` - If false: `willNotRender()` diff --git a/packages/graph/docs/system/graph-settings.md b/packages/graph/docs/system/graph-settings.md index 40d9d6c3..4ab374dc 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 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 | +| `EXP_VISIBILITY_CULLING` | boolean | `true` | Experimental: each layer tree keeps viewport visibility in one world-space tile index and walks only visible and changed components; set to `false` to walk every component. 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. | diff --git a/packages/graph/docs/system/scheduler-system.md b/packages/graph/docs/system/scheduler-system.md index 7092c7d7..26f47a38 100644 --- a/packages/graph/docs/system/scheduler-system.md +++ b/packages/graph/docs/system/scheduler-system.md @@ -114,9 +114,9 @@ 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() + Tree->>Component: prepareUpdate() for new and requested components + Tree->>Tree: Update viewport visibility + Tree->>Component: iterate() for visible and prepared components Component->>Component: process lifecycle Browser->>GlobalScheduler: requestAnimationFrame (next frame) ``` @@ -201,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. In layer trees, visible, new, or dirty components prepare their data and children before `iterate()` paints eligible nodes; clean offscreen nodes are skipped +5. In layer trees, new and requested components prepare their data and children before visibility is published; `iterate()` then updates and paints visible nodes; clean offscreen nodes are skipped 6. Components can control whether their children are processed by returning true/false from `iterate()` ## Priority Levels diff --git a/packages/graph/src/components/canvas/connections/BlockConnection.ts b/packages/graph/src/components/canvas/connections/BlockConnection.ts index 1fec2bda..8e49ba28 100644 --- a/packages/graph/src/components/canvas/connections/BlockConnection.ts +++ b/packages/graph/src/components/canvas/connections/BlockConnection.ts @@ -34,6 +34,14 @@ export type TBlockConnection = { removeFromRenderOrder(cmp: Path2DRenderInstance): void; }; +function hasChangedValue(next: V | undefined, current: V): boolean { + if (!next) return false; + for (const key of Object.keys(next) as Array) { + if (!Object.is(next[key], current[key])) return true; + } + return false; +} + export class BlockConnection extends BaseConnection implements Path2DRenderInstance @@ -48,6 +56,9 @@ export class BlockConnection private labelFont = ""; + /** Context the current geometry was computed with; constants and label sizes live there. */ + private geometryContext?: TGraphConnectionsContext; + private unsubscribeLabelFontSize?: () => void; protected geometry: { x1: number; x2: number; y1: number; y2: number } = { x1: 0, x2: 0, y1: 0, y2: 0 }; @@ -273,11 +284,16 @@ export class BlockConnection } protected override checkData(): boolean { + // Mounting computes geometry from the $geometry subscription; later, only changed inputs recompute it. + const { nextProps, nextState } = this.__data; + const geometryInputsChanged = + !this.firstIterate && + (this.context !== this.geometryContext || + hasChangedValue(nextProps, this.props) || + hasChangedValue(nextState, this.state)); const changed = super.checkData(); - if (changed) { - this.updatePoints(); - this.syncLabelSubscription(); - } + if (geometryInputsChanged) this.updatePoints(); + if (changed) this.syncLabelSubscription(); return changed; } @@ -324,6 +340,7 @@ export class BlockConnection } protected override updatePoints(additionalPoints?: TPoint[], props: TConnectionProps = this.props) { + this.geometryContext = this.context; super.updatePoints(additionalPoints, props); if (this.connectionPoints) { diff --git a/packages/graph/src/components/canvas/connections/geometryUpdates.test.ts b/packages/graph/src/components/canvas/connections/geometryUpdates.test.ts new file mode 100644 index 00000000..ce7a0800 --- /dev/null +++ b/packages/graph/src/components/canvas/connections/geometryUpdates.test.ts @@ -0,0 +1,73 @@ +import { Graph } from "../../../graph"; +import { globalScheduler } from "../../../lib/Scheduler"; +import { TConnection } from "../../../store/connection/ConnectionState"; + +import { BlockConnection } from "./BlockConnection"; + +class CountingConnection extends BlockConnection { + public pointUpdates = 0; + + protected override updatePoints(...args: Parameters["updatePoints"]>): void { + this.pointUpdates++; + super.updatePoints(...args); + } +} + +function frames(count = 1) { + for (let i = 0; i < count; i++) globalScheduler.performUpdate(); +} + +describe("connection geometry updates", () => { + let graph: Graph; + + function view(id: string): CountingConnection { + const component = graph.connections.getConnectionState(id)?.getViewComponent(); + if (!(component instanceof CountingConnection)) throw new Error(`Expected connection view ${id}`); + return component; + } + + function mount() { + const root = document.createElement("div"); + Object.defineProperties(root, { clientWidth: { value: 1000 }, clientHeight: { value: 800 } }); + graph = new Graph( + { + connections: [{ id: "edge", label: "Edge", sourcePortId: "source", targetPortId: "target" }], + settings: { connection: CountingConnection, useBezierConnections: false, showConnectionLabels: true }, + }, + root + ); + const state = graph.connections.getConnectionState("edge"); + if (!state) throw new Error("Expected connection edge"); + state.$sourcePortState.value.updatePort({ x: 100, y: 100, lookup: false }); + state.$targetPortState.value.updatePort({ x: 300, y: 100, lookup: false }); + graph.cameraService.set({ width: 1000, height: 800, x: 0, y: 0, scale: 1 }); + graph.start(); + frames(3); + } + + afterEach(() => graph?.unmount()); + + it("computes a mounted connection's geometry once", () => { + mount(); + expect(view("edge").pointUpdates).toBe(1); + }); + + it("recomputes geometry once per real data change and never for camera moves", () => { + mount(); + const edge = view("edge"); + edge.pointUpdates = 0; + + graph.cameraService.set({ x: -50, y: -20 }); + frames(2); + expect(edge.pointUpdates).toBe(0); + + graph.updateSettings({ useBezierConnections: true }); + frames(2); + expect(edge.pointUpdates).toBe(1); + + const state = graph.connections.getConnectionState("edge"); + state?.$sourcePortState.value.updatePort({ x: 120, y: 140, lookup: false }); + frames(2); + expect(edge.pointUpdates).toBe(2); + }); +}); diff --git a/packages/graph/src/lib/CoreComponent.ts b/packages/graph/src/lib/CoreComponent.ts index 46d8b757..0a7ccc69 100644 --- a/packages/graph/src/lib/CoreComponent.ts +++ b/packages/graph/src/lib/CoreComponent.ts @@ -93,6 +93,11 @@ export class CoreComponent< this.performRender(); } + /** {@link iterate} applies pending updates when the tree has not prepared this component. */ + public get preparesOnIterate(): boolean { + return true; + } + public get renderOrder() { return this.__comp.treeNode.renderOrder; } diff --git a/packages/graph/src/lib/Tree.dense.test.ts b/packages/graph/src/lib/Tree.dense.test.ts index 42c656b0..9f084e2e 100644 --- a/packages/graph/src/lib/Tree.dense.test.ts +++ b/packages/graph/src/lib/Tree.dense.test.ts @@ -54,6 +54,15 @@ function setup() { return { root, state, add, make, walk }; } +/** Every visited node prepares in this frame, and never after its own paint. */ +function expectPreparedBeforeOwnPaint(result: { events: string[]; visited: string[] }): void { + for (const name of result.visited) { + const prepared = result.events.indexOf(`prepare:${name}`); + expect(prepared).toBeGreaterThanOrEqual(0); + expect(prepared).toBeLessThan(result.events.indexOf(`iterate:${name}`)); + } +} + 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(); @@ -127,7 +136,7 @@ describe("Tree full-viewport traversal", () => { nodes[0].updateZIndex(5); state.viewport = viewport(365); let result = walk(); - expect(result.prepared).toEqual(["root", ...names.slice(0, 18)]); + expect(result.prepared).toEqual(result.visited); 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(",")}`); @@ -147,7 +156,7 @@ describe("Tree full-viewport traversal", () => { 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", () => { + it("paints a visible subtree that a requested preparation moves under an offscreen ancestor", () => { const { state, add, walk } = setup(); state.viewport = viewport(100); const original = add("original", 10); @@ -163,14 +172,18 @@ describe("Tree full-viewport traversal", () => { far.append(child); return true; }; + trigger.requestWalk(); + // Reparenting registers the subtree again: it prepares before painting with its offscreen ancestor, + // and each node's own bounds keep the moved nodes visible below that ancestor. 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"]); + expect(result.prepared.slice(0, 5)).toEqual(["root", "trigger", "far", "child", "grandchild"]); + expect(result.drawn).toEqual(["root", "original", "trigger", ...fillers, "child", "grandchild"]); + expect(result.visited).toEqual(["root", "original", "trigger", ...fillers, "far", "child", "grandchild"]); + expectPreparedBeforeOwnPaint(result); }); - it("defers a node appended by a viewport callback until its first preparation in the next frame", () => { + it("prepares and paints a node appended by a viewport callback in the same frame", () => { const { root, state, add, make, walk } = setup(); add("resident", 10); const trigger = add("trigger", 500); @@ -185,17 +198,17 @@ describe("Tree full-viewport traversal", () => { }; state.viewport = viewport(1000); - expect(walk()).toMatchObject({ - prepared: ["root", "resident", "trigger"], - visited: ["root", "resident", "trigger"], - }); + const result = walk(); + expect(result.drawn).toEqual(["root", "resident", "trigger", "created"]); + expect(result.events.indexOf("prepare:created")).toBeLessThan(result.events.indexOf("draw:root")); + expectPreparedBeforeOwnPaint(result); 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", () => { + it("uses final geometry and child order when a requested preparation changes a fully visible tree", () => { const { root, add, make, walk } = setup(); const early = add("early", 10); add("visibleChild", 20, early); @@ -210,10 +223,11 @@ describe("Tree full-viewport traversal", () => { tail.updateZIndex(0); return true; }; + parent.requestWalk(); const result = walk(); expect(result.drawn).toEqual(["root", "tail", "visibleChild", "parent", "replacement"]); - expect(result.prepared.filter((name) => name === "early")).toHaveLength(1); + expect(result.prepared).not.toContain("early"); 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")); @@ -221,7 +235,7 @@ describe("Tree full-viewport traversal", () => { expect(root["culling"]?.index.size).toBe(5); }); - it("prepares and completes a node once when a later preparation reparents it in the same scope", () => { + it("prepares and completes a node once when a requested preparation reparents it in the same scope", () => { const { add, walk } = setup(); const original = add("original", 10); const child = add("child", 20, original); @@ -231,9 +245,11 @@ describe("Tree full-viewport traversal", () => { destination.append(child); return true; }; + destination.requestWalk(); const result = walk(); - expect(result.prepared).toEqual(["root", "original", "child", "destination"]); + // Reparenting registers the child again, so it prepares before painting. + expect(result.prepared).toEqual(["root", "destination", "child", "original"]); expect(result.visited).toEqual(["root", "original", "destination", "child"]); expect(result.drawn).toEqual(["root", "original", "destination", "child"]); expect(child.parent).toBe(destination); @@ -251,6 +267,7 @@ describe("Tree full-viewport traversal", () => { return true; }; destination.data.prepare = () => false; + mover.requestWalk(); const result = walk(); expect(result.prepared).toEqual(["root", "original", "mover", "destination"]); @@ -258,7 +275,7 @@ describe("Tree full-viewport traversal", () => { expect(moved.isTraversalAllowed()).toBe(false); }); - it("does not reuse a detached node after a later preparation removes it from a warmed tree", () => { + it("does not reuse a detached node after a requested preparation removes it from a warmed tree", () => { const { root, add, walk } = setup(); const removed = add("removed", 10); const later = add("later", 20); @@ -269,14 +286,15 @@ describe("Tree full-viewport traversal", () => { later.data.prepare = undefined; return true; }; + later.requestWalk(); - expect(walk()).toMatchObject({ prepared: ["root", "removed", "later"], visited: ["root", "later"] }); + expect(walk()).toMatchObject({ prepared: ["root", "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", () => { + it("discovers a new child in an earlier branch and honors suppression from a requested preparation", () => { const { add, make, walk } = setup(); const earlier = add("earlier", 10); add("retained", 20, earlier); @@ -290,14 +308,16 @@ describe("Tree full-viewport traversal", () => { later.data.prepare = () => false; return false; }; + later.requestWalk(); const result = walk(); - expect(result.prepared).toEqual(["root", "earlier", "retained", "later", "created"]); + expect(result.prepared.slice().sort()).toEqual(["created", "earlier", "later", "retained", "root"]); 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; + later.requestWalk(); expect(walk().drawn).toEqual(["root", "earlier", "retained", "created", "later", "suppressed"]); }); @@ -364,8 +384,7 @@ describe("Tree full-viewport traversal", () => { 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); + expect(scope["paintOrder"]?.nodes.some((node) => node !== root) ?? false).toBe(false); }); it("prepares and completes transferred descendants once in every destination scope", () => { @@ -458,7 +477,7 @@ describe("Tree full-viewport traversal", () => { expect(walk().drawn).toEqual(["root", "left", "moved"]); }); - it("finishes prepared descendants without paint when an ancestor suppresses them during paint", () => { + it("skips clean descendants when an ancestor suppresses them during paint", () => { const { add, walk } = setup(); const parent = add("parent", 10); const child = add("child", 20, parent); @@ -466,8 +485,8 @@ describe("Tree full-viewport traversal", () => { 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.prepared).toEqual(["root", "parent"]); + expect(result.visited).toEqual(["root", "parent"]); expect(result.drawn).toEqual(["root", "parent"]); expect([child.isVisible(), leaf.isVisible()]).toEqual([false, false]); @@ -476,6 +495,7 @@ describe("Tree full-viewport traversal", () => { expect(result.prepared).toEqual(["root", "parent"]); expect(result.visited).toEqual(["root", "parent"]); parent.data.prepare = parent.data.paint = () => true; + parent.requestWalk(); expect(walk().drawn).toEqual(["root", "parent", "child", "leaf"]); }); @@ -496,7 +516,8 @@ describe("Tree full-viewport traversal", () => { }; expect(walk().drawn).toEqual(["root", "parent"]); - expect(allowed).toEqual([true, false]); + // The clean child is not reached once its ancestor suppresses the branch. + expect(allowed).toEqual([true]); expect(leaf.isTraversalAllowed()).toBe(false); root.append(child); expect(leaf.isTraversalAllowed()).toBe(true); diff --git a/packages/graph/src/lib/Tree.denseSelection.test.ts b/packages/graph/src/lib/Tree.denseSelection.test.ts index e9f936e5..03eedc0a 100644 --- a/packages/graph/src/lib/Tree.denseSelection.test.ts +++ b/packages/graph/src/lib/Tree.denseSelection.test.ts @@ -63,11 +63,20 @@ function setup(fullWalkShare: number) { return { root, state, add, fill, walk }; } +/** Every visited node prepares in this frame, and never after its own paint. */ +function expectPreparedBeforeOwnPaint(result: { events: string[]; visited: string[] }): void { + for (const name of result.visited) { + const prepared = result.events.indexOf(`prepare:${name}`); + expect(prepared).toBeGreaterThanOrEqual(0); + expect(prepared).toBeLessThan(result.events.indexOf(`iterate:${name}`)); + } +} + 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", () => { + it("reaches a visible child through a clean offscreen ancestor without admitting its clean offscreen children", () => { const { state, add, fill, walk } = setup(fullWalkShare); const parent = add("parent", 1000); const leaf = add("leaf", 30, parent); @@ -78,17 +87,17 @@ describe.each([ 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.prepared).toEqual(["root", "leaf", "unknown", ...residents]); 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(result.visited).toEqual(["root", "leaf", "unknown", ...residents]); + expectPreparedBeforeOwnPaint(result); + expect(parent.isSkippedByCulling(state.token)).toBe(true); 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", () => { + it("prepares and paints in local z-order after a partial traversal warms", () => { const { add, fill, walk } = setup(fullWalkShare); const first = add("first", 10); add("child", 20, first); @@ -100,7 +109,7 @@ describe.each([ walk(); expect(walk()).toMatchObject({ - prepared: ["root", "first", "child", "second", ...residents], + prepared: ["root", "second", ...residents, "first", "child"], drawn: ["root", "second", ...residents, "first", "child"], }); first.updateZIndex(0); @@ -110,7 +119,7 @@ describe.each([ }); }); - it("uses the final selection after preparation moves one candidate out and another into the viewport", () => { + it("uses the final selection after a requested preparation moves one candidate out and another in", () => { const { add, fill, walk } = setup(fullWalkShare); const trigger = add("trigger", 10); const exiting = add("exiting", 30); @@ -123,19 +132,22 @@ describe.each([ entering.setBounds(40, 40, 50, 50); return true; }; + trigger.requestWalk(); const result = walk(); expect(result.drawn).toEqual(["root", "trigger", "entering", ...residents]); expect(result.prepared.filter((name) => name === "entering")).toEqual(["entering"]); + expect(result.prepared).not.toContain("exiting"); expect(result.events).toContain(`batch:${["trigger", "entering", ...residents].sort().join(",")}`); - for (const event of ["visible:exiting:false", "visible:entering:true", "prepare:entering"]) { + for (const event of ["prepare:trigger", "visible:exiting:false", "visible:entering:true"]) { expect(result.events.indexOf(event)).toBeGreaterThanOrEqual(0); expect(result.events.indexOf(event)).toBeLessThan(result.events.indexOf("draw:root")); } + expectPreparedBeforeOwnPaint(result); expect(walk().prepared).not.toContain("exiting"); }); - it("preserves partial preparation order when a child is attached after its parent's sibling", () => { + it("paints a child attached after its parent's sibling in local order", () => { const { add, fill, walk } = setup(fullWalkShare); const first = add("first", 10); add("second", 30); @@ -147,7 +159,7 @@ describe.each([ walk(); expect(walk()).toMatchObject({ - prepared: ["root", "first", "second", ...residents, "lateChild"], + prepared: ["root", "first", "lateChild", "second", ...residents], drawn: ["root", "first", "lateChild", "second", ...residents], }); }); @@ -166,7 +178,7 @@ describe.each([ state.viewport = { minX: 0, minY: 0, maxX: 150, maxY: 150 }; const result = walk(); - expect(result.prepared).toContain("changed"); + expect(result.prepared).not.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( @@ -193,6 +205,7 @@ describe.each([ changeViewport(); return true; }; + trigger.requestWalk(); } else { trigger.data.visibility = (visible) => { if (visible) changeViewport(); @@ -200,16 +213,18 @@ describe.each([ state.viewport.maxX = state.viewport.maxY = 150; } + // Publication repeats for the final viewport, so the trigger is drawn only if it still intersects it. const result = walk(); - expect(result.prepared).toContain("target"); - expect(result.drawn).toEqual(["root", "trigger", ...residents]); - expect(result.events).toContain(`batch:${["trigger", ...residents].sort().join(",")}`); + const shown = phase === "preparation" ? ["trigger", ...residents] : residents; + expect(result.prepared).not.toContain("target"); + expect(result.drawn).toEqual(["root", ...shown]); + expect(result.events).toContain(`batch:${shown.slice().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", () => { + it("hides descendants when a visibility callback requests their ancestor to suppress them", () => { const { state, add, fill, walk } = setup(fullWalkShare); const trigger = add("trigger", 120); const parent = add("parent", 30); @@ -218,15 +233,20 @@ describe.each([ add("outside", 1000); walk(); walk(); + let suppressed = false; + parent.data.prepare = () => !suppressed; trigger.data.visibility = (visible) => { - if (visible) parent.setChildrenWalkAllowed(false); + if (!visible) return; + suppressed = true; + parent.requestWalk(); }; state.viewport = { minX: 0, minY: 0, maxX: 150, maxY: 150 }; const result = walk(); - expect(result.prepared).toContain("child"); + expect(result.prepared).not.toContain("child"); + expect(result.events.indexOf("prepare:parent")).toBeLessThan(result.events.indexOf("draw:root")); expect(result.drawn).toEqual(["root", "trigger", "parent", ...residents]); - expect(result.visited.filter((name) => name === "child")).toEqual(["child"]); + expect(result.visited).not.toContain("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")); @@ -266,7 +286,7 @@ describe.each([ pending.requestWalk(); const result = walk(); - expect(result.prepared).toEqual(["root", ...residents, "parent", "pending"]); + expect(result.prepared).toEqual(["root", "parent", "pending", ...residents]); 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); diff --git a/packages/graph/src/lib/Tree.invalidation.test.ts b/packages/graph/src/lib/Tree.invalidation.test.ts index 8d79c6a5..eb1c8c40 100644 --- a/packages/graph/src/lib/Tree.invalidation.test.ts +++ b/packages/graph/src/lib/Tree.invalidation.test.ts @@ -216,7 +216,7 @@ describe("Tree geometry and structure invalidation", () => { const sibling = add("sibling", 40); const nodes = [parent, child, leaf, sibling]; const initial = walk(); - expect(initial.work.children).toBeGreaterThan(0); + // The first traversal enumerates the hierarchy once to build the paint order. expect(initial.work.order).toBeGreaterThan(0); walk(); const stable = walk(); @@ -273,7 +273,7 @@ describe("Tree geometry and structure invalidation", () => { expect(walk().drawn).toEqual(["root", "resident", "moved"]); }); - it("uses new ancestry after a prepared child moves between warmed branches", () => { + it("uses new ancestry after a requested preparation moves a child between warmed branches", () => { const { add, walk } = setup(); const original = add("original", 10); const child = add("child", 20, original); @@ -287,16 +287,18 @@ describe("Tree geometry and structure invalidation", () => { destination.data.prepare = undefined; return true; }; + destination.requestWalk(); + // Reparenting registers the moved subtree again, so it prepares before painting. expect(walk()).toMatchObject({ - prepared: ["root", "original", "child", "leaf", "destination"], + prepared: ["root", "destination", "child", "leaf", "original"], 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", () => { + it("updates z-order alongside bounds from a requested preparation", () => { const { state, add, walk } = setup(); const first = add("first", 10); add("child", 20, first); @@ -308,9 +310,10 @@ describe("Tree geometry and structure invalidation", () => { first.setBounds(60, 60, 70, 70); return true; }; + second.requestWalk(); expect(walk()).toMatchObject({ - prepared: ["root", "first", "child", "second"], + prepared: ["root", "second", "first", "child"], drawn: ["root", "second", "first", "child"], }); state.viewport = viewport(50); @@ -332,6 +335,7 @@ describe("Tree geometry and structure invalidation", () => { child.setBounds(50, 50, 60, 60); return false; }; + parent.requestWalk(); let result = walk(); expect(result.prepared).toEqual(["root", "parent", "sibling"]); @@ -345,8 +349,10 @@ describe("Tree geometry and structure invalidation", () => { child.setBounds(300, 300, 310, 310); return true; }; + parent.requestWalk(); result = walk(); - expect(result.prepared).toEqual(["root", "parent", "child", "leaf", "sibling"]); + // The clean offscreen child passes its visible leaf through without preparing. + expect(result.prepared).toEqual(["root", "parent", "leaf", "sibling"]); expect(result.drawn).toEqual(["root", "parent", "leaf", "sibling"]); expect(leaf.isTraversalAllowed()).toBe(true); state.viewport = viewport(1000); diff --git a/packages/graph/src/lib/Tree.subtree.test.ts b/packages/graph/src/lib/Tree.subtree.test.ts index e4c5fa4b..29b93ba1 100644 --- a/packages/graph/src/lib/Tree.subtree.test.ts +++ b/packages/graph/src/lib/Tree.subtree.test.ts @@ -27,13 +27,13 @@ function setup() { } describe("Tree descendant visibility", () => { - it("traverses finite offscreen ancestors to a visible leaf without enlarging their own bounds", () => { + it("reaches a visible leaf through finite offscreen ancestors without visiting them or enlarging their bounds", () => { const { root, add, walk } = setup(); const parent = add(root, 1000); const child = add(parent, 2000); const leaf = add(child, 20); walk(); - expect(walk()).toEqual([parent, child, leaf]); + expect(walk()).toEqual([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); diff --git a/packages/graph/src/lib/Tree.ts b/packages/graph/src/lib/Tree.ts index ce22201f..ca080826 100644 --- a/packages/graph/src/lib/Tree.ts +++ b/packages/graph/src/lib/Tree.ts @@ -9,6 +9,11 @@ export interface ITree { prepareUpdate?(): boolean; /** Runs before painting when this node's own viewport visibility changes. */ onViewportChange?(visible: boolean): void; + /** + * `iterate()` applies pending updates itself when `prepareUpdate()` has not run in this traversal. + * The tree then lets clean nodes update inside their own `iterate()`. Read once per node. + */ + readonly preparesOnIterate?: boolean; } export class Tree { @@ -50,9 +55,13 @@ export class Tree { private eligibilityRevision = -1; private traversalAllowed = true; + /** @internal See {@link ITree.preparesOnIterate}. */ + public readonly preparesOnIterate: boolean; + constructor(data: T, parent?: Tree) { this.data = data; this.parent = parent; + this.preparesOnIterate = data.preparesOnIterate === true; } public append(node: Tree) { @@ -160,7 +169,10 @@ export class Tree { const scope = this.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()); + const visible = + scope.painting && scope.preparingNode !== this + ? this.visible + : scope.intersects(this, scope.options.getViewport()); return ( visible && ((scope.painting && scope.visibilityRevision === scope.structureRevision) || this.isEligible(scope)) ); @@ -211,6 +223,11 @@ export class Tree { this.scope?.invalidateStructure(); } + /** @internal Published viewport visibility; the paint walk visits published nodes. */ + public get publishedVisible(): boolean { + return this.visible; + } + /** @internal */ public setVisible(visible: boolean): boolean { if (this.visible === visible) return false; diff --git a/packages/graph/src/lib/Tree.visibilityPublication.test.ts b/packages/graph/src/lib/Tree.visibilityPublication.test.ts index 629eb354..72b45693 100644 --- a/packages/graph/src/lib/Tree.visibilityPublication.test.ts +++ b/packages/graph/src/lib/Tree.visibilityPublication.test.ts @@ -3,6 +3,7 @@ import { ITree, Tree } from "./Tree"; type Probe = ITree & { name: string; + prepare?: () => boolean; paint?: () => boolean; visibility?: (visible: boolean) => void; }; @@ -16,7 +17,7 @@ function setup() { name, prepareUpdate: () => { events.push(`prepare:${name}`); - return true; + return node.data.prepare?.() ?? true; }, onViewportChange: (visible) => { events.push(`visible:${name}:${visible}`); @@ -69,17 +70,23 @@ describe("Tree visibility publication", () => { const residents = fill(); walk(); walk(); + let suppressed = false; + parent.data.prepare = () => !suppressed; 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); + else { + // Components suppress children from their own update, requested like any other change. + suppressed = true; + parent.requestWalk(); + } }; 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.prepared).not.toContain("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( @@ -106,7 +113,7 @@ describe("Tree visibility publication", () => { expect(walk().prepared).not.toContain("new"); }); - it("restores visibility before the next batch paint after painting suppressed a prepared branch", () => { + it("restores visibility before the next batch paint after painting suppressed a branch", () => { const { state, add, fill, walk } = setup(); const parent = add("parent", 30); const child = add("child", 40, parent); @@ -117,17 +124,17 @@ describe("Tree visibility publication", () => { 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(result.events).not.toContain("iterate:child"); 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]); + // The veto only lived inside one paint, so the child's published membership never changed. 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(result.events).not.toContain("visible:child:false"); + expect(result.events.indexOf("prepare:parent")).toBeLessThan(result.events.indexOf("draw:root")); expect(child.isVisible()).toBe(true); }); }); diff --git a/packages/graph/src/lib/TreeCulling.incremental.test.ts b/packages/graph/src/lib/TreeCulling.incremental.test.ts new file mode 100644 index 00000000..6a39c3dc --- /dev/null +++ b/packages/graph/src/lib/TreeCulling.incremental.test.ts @@ -0,0 +1,180 @@ +import type { TTileRect } from "./TileIndex"; +import { ITree, Tree } from "./Tree"; + +type Probe = ITree & { name: string; paint?: () => boolean }; + +const rect = (minX: number, minY: number, maxX: number, maxY: number): TTileRect => ({ minX, minY, maxX, maxY }); + +function setup(options: { selfPreparing?: boolean } = {}) { + const state = { viewport: rect(0, 0, 100, 100), token: 0 }; + const events: string[] = []; + const make = (name: string, x?: number, y = x): Tree => { + const node: Tree = new Tree({ + name, + preparesOnIterate: options.selfPreparing, + prepareUpdate: () => { + events.push(`prepare:${name}`); + return true; + }, + onViewportChange: (visible) => events.push(`visible:${name}:${visible}`), + iterate: () => { + events.push(`iterate:${name}`); + if (node.isVisible()) events.push(`draw:${name}`); + return node.data.paint?.() ?? true; + }, + }); + if (x !== undefined && y !== undefined) node.setBounds(x, y, x + 10, y + 10); + return node; + }; + const root = make("root"); + root.setCulling({ tileSize: 32, getViewport: () => state.viewport, getWalkToken: () => state.token }); + const add = (name: string, x: number, y = x, parent: Tree = root): Tree => { + const node = make(name, x, y); + parent.append(node); + return node; + }; + const walk = () => { + events.length = 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:"), drawn: names("draw:"), visited: names("iterate:"), events: events.slice() }; + }; + return { root, state, make, add, walk }; +} + +describe("TreeCulling incremental visibility", () => { + it("matches a complete selection over random pans, zooms and jumps", () => { + const { root, state, add, walk } = setup(); + let seed = 7; + const random = () => { + seed = (seed * 16807) % 2147483647; + return seed / 2147483647; + }; + const nodes: Array> = []; + for (let i = 0; i < 400; i++) { + nodes.push(add(`n${i}`, Math.floor(random() * 2000), Math.floor(random() * 2000))); + } + const expected = () => + nodes + .filter((node) => { + const { minX, minY, maxX, maxY } = state.viewport; + return ( + node.boundsMinX <= maxX && node.boundsMaxX >= minX && node.boundsMinY <= maxY && node.boundsMaxY >= minY + ); + }) + .map((node) => node.data.name) + .sort(); + + for (let frame = 0; frame < 120; frame++) { + const { minX, minY, maxX, maxY } = state.viewport; + const kind = frame % 3; + if (kind === 0) { + const dx = Math.floor(random() * 60 - 30); + const dy = Math.floor(random() * 60 - 30); + state.viewport = rect(minX + dx, minY + dy, maxX + dx, maxY + dy); + } else if (kind === 1) { + const grow = Math.floor(random() * 80 - 40); + state.viewport = rect( + minX - grow, + minY - grow, + Math.max(minX - grow, maxX + grow), + Math.max(minY - grow, maxY + grow) + ); + } else if (frame % 10 === 2) { + const x = Math.floor(random() * 1800); + const y = Math.floor(random() * 1800); + state.viewport = rect(x, y, x + 200, y + 150); + } + if (frame % 17 === 5) { + const node = nodes[Math.floor(random() * nodes.length)]; + const x = Math.floor(random() * 2000); + const y = Math.floor(random() * 2000); + node.setBounds(x, y, x + 10, y + 10); + } + const result = walk(); + expect(result.drawn.filter((name) => name !== "root").sort()).toEqual(expected()); + expect( + nodes + .filter((node) => node.isVisible()) + .map((node) => node.data.name) + .sort() + ).toEqual(expected()); + } + expect(root["culling"]?.nodes.size).toBe(400); + }); + + it("rechecks only nodes near the viewport edges when the camera pans", () => { + const { root, state, add, walk } = setup(); + for (let x = 0; x < 20; x++) { + for (let y = 0; y < 20; y++) add(`n${x}_${y}`, x * 50, y * 50); + } + state.viewport = rect(0, 0, 400, 400); + walk(); + walk(); + const scope = root["culling"]; + if (!scope) throw new Error("Missing scope"); + const query = jest.spyOn(scope.index, "query"); + const linearScan = jest.spyOn(scope.nodes, Symbol.iterator); + const checked = new Set(); + const intersects = jest.spyOn(scope, "intersects").mockImplementation(function (this: unknown, node, viewport) { + checked.add(node); + return ( + !viewport || + Number.isNaN(node.boundsMinX) || + (node.boundsMinX <= viewport.maxX && + node.boundsMaxX >= viewport.minX && + node.boundsMinY <= viewport.maxY && + node.boundsMaxY >= viewport.minY) + ); + }); + + state.viewport = rect(20, 0, 420, 400); + const result = walk(); + expect(linearScan).not.toHaveBeenCalled(); + for (const [strip] of query.mock.calls) expect(strip.maxX - strip.minX).toBeLessThanOrEqual(20); + // Only the columns crossing the left and right edges are rechecked, not the 81 visible nodes. + expect(checked.size).toBeLessThanOrEqual(2 * 10 + 1); + // Edges are inclusive: the column at x = 400 was already visible, only the first column leaves. + expect(result.events.filter((event) => event.startsWith("visible:")).sort()).toEqual( + Array.from({ length: 9 }, (_, y) => `visible:n0_${y}:false`).sort() + ); + expect(result.drawn).toHaveLength(1 + 8 * 9); + intersects.mockRestore(); + }); + + it("lets self-preparing nodes update inside iterate unless an update was requested", () => { + const { add, walk } = setup({ selfPreparing: true }); + const first = add("first", 10); + add("second", 30); + add("outside", 1000); + walk(); + let result = walk(); + expect(result.prepared).toEqual(["root"]); + expect(result.drawn).toEqual(["root", "first", "second"]); + + first.requestWalk(); + result = walk(); + expect(result.prepared).toEqual(["root", "first"]); + expect(result.events.indexOf("prepare:first")).toBeLessThan(result.events.indexOf("draw:root")); + }); + + it("paints children that a clean node adds while painting from the next frame", () => { + const { make, add, walk } = setup(); + const parent = add("parent", 10); + walk(); + const late = make("late", 20); + parent.data.paint = () => { + parent.append(late); + parent.data.paint = undefined; + return true; + }; + + let result = walk(); + expect(result.drawn).toEqual(["root", "parent"]); + result = walk(); + expect(result.drawn).toEqual(["root", "parent", "late"]); + expect(result.events.indexOf("prepare:late")).toBeLessThan(result.events.indexOf("draw:root")); + }); +}); diff --git a/packages/graph/src/lib/TreeCulling.test.ts b/packages/graph/src/lib/TreeCulling.test.ts index 7ae3ab3e..f828fe26 100644 --- a/packages/graph/src/lib/TreeCulling.test.ts +++ b/packages/graph/src/lib/TreeCulling.test.ts @@ -100,7 +100,12 @@ describe("Tree native spatial scope", () => { 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")); + // The requested parent and the node it registers prepare before painting; the clean node it moved + // into view prepares right before its own paint. + for (const name of ["parent", "new"]) { + expect(result.events.indexOf(`prepare:${name}`)).toBeLessThan(result.events.indexOf("draw:root")); + } + expect(result.events.indexOf("prepare:moved")).toBeLessThan(result.events.indexOf("draw:moved")); expect(root.children.size).toBe(2); }); @@ -290,7 +295,7 @@ describe("Tree native spatial scope", () => { expect(siblings).not.toHaveBeenCalled(); }); - it("uses the tile query when large offscreen items are a minority", () => { + it("selects through the tile query after a structural change when large offscreen items are a minority", () => { const { root, add, walk } = setup(); for (let i = 0; i < 1000; i++) { const x = 1000 + i * 1024; @@ -303,12 +308,21 @@ describe("Tree native spatial scope", () => { const query = jest.spyOn(scope.index, "query"); const linearScan = jest.spyOn(scope.nodes, Symbol.iterator); + // An unchanged frame keeps the published selection without any selection work. + expect(walk().drawn).toEqual(["root"]); + expect(query).not.toHaveBeenCalled(); + expect(linearScan).not.toHaveBeenCalled(); + + add("late", 2000); + query.mockClear(); + linearScan.mockClear(); expect(walk().drawn).toEqual(["root"]); expect(query).toHaveBeenCalledTimes(1); expect(linearScan).not.toHaveBeenCalled(); }); it("preserves full traversal order for sparse and dense randomized visibility selections", () => { + // Each viewport starts from a new scope, so its first frame runs a complete selection. const { root, state, add, walk, configure } = setup(); let seed = 3; const random = () => { @@ -330,18 +344,21 @@ describe("Tree native spatial scope", () => { 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); + let query = jest.spyOn(scope.index, "query"); + let 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; - // Warm the structural preparation order before counting per-frame selection work. - walk(); - query.mockClear(); - linearScan.mockClear(); + configure(); + const current = root["culling"]; + if (!current) throw new Error("Missing root scope"); + query.mockRestore(); + linearScan.mockRestore(); + query = jest.spyOn(current.index, "query"); + linearScan = jest.spyOn(current.nodes, Symbol.iterator); const visible = new Set( Array.from(positions.entries()) .filter( diff --git a/packages/graph/src/lib/TreeCulling.ts b/packages/graph/src/lib/TreeCulling.ts index 6a8bfed4..2888dbd2 100644 --- a/packages/graph/src/lib/TreeCulling.ts +++ b/packages/graph/src/lib/TreeCulling.ts @@ -10,11 +10,22 @@ export type TTreeCullingOptions = { isEnabled?: () => boolean; /** Identifies the current traversal, see {@link Tree.isSkippedByCulling}. */ getWalkToken?: () => unknown; - /** Candidate share above which a linear bounds check is cheaper than a tile query. @default 0.75 */ + /** Candidate share above which a full selection scans nodes instead of querying tiles. @default 0.75 */ fullWalkShare?: number; }; -/** Owns preparation, viewport selection and painting for one configured Tree root. */ +function sameRect(a: TTileRect, b: TTileRect): boolean { + return a.minX === b.minX && a.minY === b.minY && a.maxX === b.maxX && a.maxY === b.maxY; +} + +/** + * Owns preparation, viewport visibility and painting for one configured Tree root. + * + * A frame prepares the owner, new and requested nodes, publishes viewport visibility and then walks + * the tree once. A visited node that is still clean prepares right before its own paint, so a frame + * costs the visible nodes plus the work that changed. Visibility is maintained between frames: a + * camera move rechecks only nodes in the parts of the old and new viewports outside each other. + */ export class TreeCulling { // Moving between scopes must not repeat a node's lifecycle in the same traversal. private static walkStamp = 0; @@ -28,35 +39,52 @@ export class TreeCulling { private readonly fresh = new Set(); + /** Nodes published as visible. */ private readonly active = new Set(); + /** Nodes whose bounds changed since visibility was last published. */ + private readonly boundsDirty = new Set(); + + /** Any change that can affect this frame's preparation: membership, order, permission or bounds. */ private revision = 0; /** Membership, local order and traversal permission; bounds do not change these. */ public structureRevision = 0; + /** Structure revision the published visibility belongs to; -1 while it is being rebuilt. */ + public visibilityRevision = -1; + public stamp = 0; public walking = false; public painting = false; - public fullVisibilityRevision = -1; + /** Viewport and structure of the last exact publication: the base of the next incremental one. */ + private selectionBase?: TTileRect & { structure: number }; - /** Ancestry revision used by the published visibility snapshot. */ - public visibilityRevision = -1; + /** Structure revision of the last publication that made every eligible node visible. */ + private allVisibleRevision = -1; - public fullPreparation?: { revision: number; nodes: Tree[] }; + /** Viewport of the current frame's paint gate; undefined when every eligible node is visible. */ + private frameViewport?: TTileRect; - private densePreparation?: { revision: number; nodes: Tree[]; parents: number[] }; + /** Culling is disabled: every eligible node is visited while the paint gate stays exact. */ + private visitAll = false; - private selectionStamp = 0; + /** Nodes prepared ahead of painting in the current frame. */ + private prepared: Tree[] = []; + + private paintOrder?: { revision: number; nodes: Tree[]; ends: number[] }; - private selectionRevision = -1; + private readonly visitList: Tree[] = []; - private selectionViewport?: TTileRect; + private readonly visitPositions: number[] = []; - public fullPaint?: { revision: number; nodes: Tree[]; subtreeEnds: number[] }; + private selectionStamp = 0; + + /** Counts update requests and registrations, so publication can tell whether callbacks added work. */ + private requests = 0; private walkToken: unknown; @@ -75,304 +103,300 @@ 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 { - this.prepareAndWalk(iterator); + this.runFrame(iterator); } finally { this.walking = wasWalking; this.painting = wasPainting; + this.prepared = []; } } - 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; - if (parent.preparedStamp !== this.stamp) 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)); + private runFrame(iterator: TIterator): void { + this.prepared = []; + this.prepare(this.owner); 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); - 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); - // 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; + for (let attempt = 0; ; attempt++) { + let revision: number; + do { + revision = this.revision; + for (const node of this.fresh) this.prepare(node); + for (const node of this.pending) this.prepare(node); + // Reconciliation and geometry updates can register or request more nodes in this same frame. + } while (revision !== this.revision); + const requests = this.requests; + this.publish(); + // Visibility callbacks can request updates or register nodes; those are applied before painting too. + if (requests === this.requests || attempt >= 3) break; } - - this.publishVisibility(prepared, viewport); this.painting = true; - this.walkPrepared(iterator, prepared, fullWalk); + this.paint(iterator); + this.finishPrepared(iterator); } - 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 }; + /** Prepares a node after its ancestors, unless an ancestor forbids walking its branch. */ + private 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; + if (parent.preparedStamp !== this.stamp) this.prepare(parent); + if (parent.preparedStamp !== this.stamp || !parent.childrenWalkAllowed || node.scope !== this) return; } - 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; + this.prepared.push(node); + this.prepareNode(node); + } + + 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); + } + + /** + * Publishes visibility for the current viewport before painting. Visibility callbacks can move nodes, + * change the structure or the viewport itself; publication repeats until it is stable. + */ + private publish(): void { + for (let attempt = 0; ; attempt++) { + // Viewport sources may reuse one object; callbacks must not change the rect being published. + const source = this.options.getViewport(); + const viewport = source && { ...source }; + const structure = this.structureRevision; + this.publishFor(viewport); + this.settleBounds(); + const current = this.options.getViewport(); + const stable = + structure === this.structureRevision && + (current && viewport ? sameRect(current, viewport) : !current && !viewport); + if (stable || attempt >= 3) { + if (structure === this.structureRevision) this.visibilityRevision = structure; + return; } } - return true; } - 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; - } + /** Rechecks nodes whose bounds changed during publication, for example from visibility callbacks. */ + private settleBounds(): void { + const viewport = this.frameViewport; + if (viewport) { + // Set iteration also reaches nodes that callbacks add while it runs. + for (const node of this.boundsDirty) { + if (node.scope === this) this.show(node, node.isEligible(this) && this.intersects(node, viewport)); } } - 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; + this.boundsDirty.clear(); } - private publishVisibility(prepared: Tree[], viewport: TTileRect | undefined): void { - const visibilityRevision = this.structureRevision; - const publishedRevision = this.visibilityRevision; + private publishFor(viewport: TTileRect | undefined): void { this.visibilityRevision = -1; - // Publish hidden state for new descendants whose preparation an ancestor blocked. + this.visitAll = false; + this.didCull = Boolean(viewport && (this.options.isEnabled?.() ?? true)); + // New descendants whose preparation an ancestor blocked stay hidden. 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) { - 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); - 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 - ? 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); - } + if (!viewport || this.index.covers(viewport)) { + this.frameViewport = undefined; + this.publishAll(); + } else { + this.frameViewport = viewport; + if (!this.didCull) { + this.visitAll = true; + this.publishExact(this.select(viewport, true), viewport); + } else if (!this.publishDelta(viewport)) { + this.publishExact(this.select(viewport), viewport); } - if (fullyVisible && this.structureRevision === visibilityRevision) - this.fullVisibilityRevision = visibilityRevision; } - if (this.structureRevision === visibilityRevision) this.visibilityRevision = visibilityRevision; + // The owner is not indexed: its own bounds are always checked against the real viewport. + this.owner.setVisible(this.owner.isEligible(this) && this.intersects(this.owner, viewport)); } - 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 show(node: Tree, visible: boolean): void { + node.setVisible(visible); + if (node.activeInViewport !== visible && node.scope === this) { + node.activeInViewport = visible; + if (visible) this.active.add(node); + else this.active.delete(node); + } } - 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); + /** The viewport contains every indexed node: eligibility alone decides visibility. */ + private publishAll(): void { + this.selectionBase = undefined; + this.boundsDirty.clear(); + const structure = this.structureRevision; + if (this.allVisibleRevision === structure) return; + for (const node of this.nodes) this.show(node, node.isEligible(this)); + this.allVisibleRevision = this.structureRevision === structure ? structure : -1; } - 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); + /** Publishes a complete selection: candidates become visible, every other published node hides. */ + private publishExact(candidates: Tree[], viewport: TTileRect): void { + this.allVisibleRevision = -1; + // The selection already used current bounds; later changes are settled after publication. + this.boundsDirty.clear(); + const selection = this.selectionStamp; + for (const node of this.active) { + if (node.selectedStamp !== selection) this.show(node, false); } - 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.activeInViewport = false; - node.setVisible(false); - node.cullingStamp = this.stamp; - const allowed = iterator(node); - if (!allowed) node.setChildrenWalkAllowed(false); + // Prepared nodes outside the selection publish their exit or their initial hidden state. + for (const node of this.prepared) { + if (node !== this.owner && node.scope === this && node.selectedStamp !== selection) this.show(node, false); + } + for (const node of candidates) { + if (node.scope === this) this.show(node, node.isEligible(this)); + } + this.selectionBase = this.didCull ? { ...viewport, structure: this.structureRevision } : undefined; + } + + /** + * Republishes only nodes that can change visibility since the last exact publication: nodes in the + * parts of the old and new viewports outside each other, nodes with new bounds and prepared nodes. + */ + private publishDelta(viewport: TTileRect): boolean { + const base = this.selectionBase; + if (!base || base.structure !== this.structureRevision) return false; + const check = (node: Tree): void => { + if (node.scope === this) this.show(node, node.isEligible(this) && this.intersects(node, viewport)); + }; + if (!sameRect(base, viewport)) { + this.queryOutside(viewport, base, check); + this.queryOutside(base, viewport, check); + base.minX = viewport.minX; + base.minY = viewport.minY; + base.maxX = viewport.maxX; + base.maxY = viewport.maxY; + } + for (const node of this.prepared) { + if (node !== this.owner) check(node); + } + return true; + } + + /** Visits indexed nodes intersecting the part of `outer` outside `inner`; edges are inclusive. */ + private queryOutside(outer: TTileRect, inner: TTileRect, visit: (node: Tree) => void): void { + if (outer.minX > inner.maxX || outer.maxX < inner.minX || outer.minY > inner.maxY || outer.maxY < inner.minY) { + this.index.query(outer, visit); + return; + } + if (outer.minY < inner.minY) { + this.index.query({ minX: outer.minX, minY: outer.minY, maxX: outer.maxX, maxY: inner.minY }, visit); + } + if (outer.maxY > inner.maxY) { + this.index.query({ minX: outer.minX, minY: inner.maxY, maxX: outer.maxX, maxY: outer.maxY }, visit); } + const minY = Math.max(outer.minY, inner.minY); + const maxY = Math.min(outer.maxY, inner.maxY); + if (outer.minX < inner.minX) this.index.query({ minX: outer.minX, minY, maxX: inner.minX, maxY }, visit); + if (outer.maxX > inner.maxX) this.index.query({ minX: inner.maxX, minY, maxX: outer.maxX, maxY }, visit); } - private paintFull(iterator: TIterator): number { - let ordered = this.fullPaint; - if (ordered?.revision !== this.structureRevision) { + /** Local paint order of eligible nodes with the end of each subtree; rebuilt after structural changes. */ + private getPaintOrder(): { revision: number; nodes: Tree[]; ends: number[] } { + let order = this.paintOrder; + if (order?.revision !== this.structureRevision) { const nodes: Tree[] = []; - const subtreeEnds: number[] = []; + const ends: number[] = []; const collect = (node: Tree): void => { if (node !== this.owner && !node.isEligible(this)) return; const index = nodes.length; nodes.push(node); - subtreeEnds.push(0); + ends.push(0); if (node.childrenWalkAllowed && node.children.size) { for (const child of node.getOrderedChildren()) { if (child.parent === node) collect(child); } } - subtreeEnds[index] = nodes.length; + ends[index] = nodes.length; }; collect(this.owner); - this.fullPaint = ordered = { revision: this.structureRevision, nodes, subtreeEnds }; + this.paintOrder = order = { revision: this.structureRevision, nodes, ends }; } - 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; + return order; + } + + /** + * Paints in local z-order. The first loop picks published, prepared or (with culling disabled) all + * eligible nodes; clean offscreen nodes pass through to their descendants. The second loop runs + * iterate() only for picked nodes, so the per-node path never sees a culled node. + */ + private paint(iterator: TIterator): void { + const { nodes, ends } = this.getPaintOrder(); + const list = this.visitList; + const positions = this.visitPositions; + list.length = 0; + positions.length = 0; + const stamp = this.stamp; + const owner = this.owner; + const visitAll = this.visitAll; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node !== owner && !visitAll && node.preparedStamp !== stamp && !node.publishedVisible) { + if (!node.childrenWalkAllowed) i = ends[i] - 1; continue; } - node.cullingStamp = this.stamp; - const allowed = iterator(node); + list.push(node); + positions.push(i); + } + for (let k = 0; k < list.length; k++) { + const node = list[k]; + // Painting can remove a node; nodes it adds are painted from the next frame. + if (node !== owner && node.scope !== this) continue; + if (node.preparedStamp !== stamp) { + // A component applies its own pending work inside iterate(); other nodes prepare here. + if (node.preparesOnIterate) node.preparedStamp = stamp; + else this.prepareLate(node); + } + node.cullingStamp = stamp; + let allowed = iterator(node); + if (allowed && !node.childrenWalkAllowed) { + // A component's own update can resume its children; another node's preparation suppressed them. + if (node.preparesOnIterate) node.setChildrenWalkAllowed(true); + else allowed = false; + } if (!allowed) { + // A paint-time veto of a node that does not prepare itself is decided again by its next preparation, + // before the next publication. + if (node.childrenWalkAllowed && !node.preparesOnIterate) this.markPending(node); node.setChildrenWalkAllowed(false); - i = ordered.subtreeEnds[i] - 1; + const end = ends[positions[k]]; + while (k + 1 < list.length && positions[k + 1] < end) k++; } - 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); - } - } + /** Node whose update hooks run inside painting; it reads live visibility like any update hook. */ + public preparingNode?: Tree; + + /** Prepares a clean node that does not prepare itself, right before its own paint. */ + private prepareLate(node: Tree): void { + const revision = this.revision; + this.preparingNode = node; + try { + this.prepareNode(node); + } finally { + this.preparingNode = undefined; + } + // New bounds decide this node's own paint gate. + if (revision !== this.revision) this.show(node, node.isEligible(this) && this.intersects(node, this.frameViewport)); + } + + /** A parent can suppress children while painting; finish their already prepared lifecycles. */ + private finishPrepared(iterator: TIterator): void { + for (const node of this.prepared) { + if (node.cullingStamp === this.stamp || !node.data.prepareUpdate || node.scope !== this) continue; + this.show(node, false); + node.cullingStamp = this.stamp; + if (!iterator(node)) node.setChildrenWalkAllowed(false); } - 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.requests++; this.update(node); this.invalidateStructure(); } @@ -384,25 +408,26 @@ export class TreeCulling { public update(node: Tree): void { if (!this.nodes.has(node)) return; + this.boundsDirty.add(node); this.index.set(node, node.boundsMinX, node.boundsMinY, node.boundsMaxX, node.boundsMaxY); this.revision++; } public delete(node: Tree): void { - this.fullPreparation = undefined; - this.densePreparation = undefined; - this.fullPaint = undefined; + this.paintOrder = undefined; this.index.delete(node); this.nodes.delete(node); this.pending.delete(node); this.fresh.delete(node); this.active.delete(node); + this.boundsDirty.delete(node); node.activeInViewport = false; this.invalidateStructure(); } public markPending(node: Tree): void { this.pending.add(node); + this.requests++; } public isSkipped(node: Tree, token: unknown): boolean { @@ -420,11 +445,12 @@ export class TreeCulling { ); } - public select(viewport: TTileRect | undefined): Tree[] { + /** Marks and returns every node whose bounds intersect the viewport. */ + public select(viewport: TTileRect | undefined, linear = false): Tree[] { const selected: Tree[] = []; 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) { + if (linear || !viewport || this.index.covers(viewport) || this.index.countCandidates(viewport, limit) > limit) { for (const node of this.nodes) { if (this.intersects(node, viewport)) { node.selectedStamp = stamp; @@ -437,8 +463,6 @@ export class TreeCulling { selected.push(node); }); } - this.selectionViewport = viewport && { ...viewport }; - this.selectionRevision = this.revision; return selected; } } From d25cb4ec53592662f48caa55b065a10e91c4a4b5 Mon Sep 17 00:00:00 2001 From: draedful Date: Sat, 24 Oct 2026 14:29:22 +0300 Subject: [PATCH 15/16] perf(graph): run tree culling as prepare, publish and paint steps A culled layer root now runs each frame in three steps owned by TreeCulling: new and requested nodes prepare, viewport visibility is published incrementally, and one flat pass over the cached paint order calls iterate(prepared, visible) only for visible and prepared nodes. - ITree.iterate(prepared, visible) replaces the per-node viewport callbacks; components veto painting through isRenderAllowed(). - Coordinator node sets are arrays with slots stored on the nodes, and the paint order marks eligible nodes for publication. - Tree.setChildren() with unchanged children no longer invalidates the scope. - Connection batches keep base-sized chunks in registration order and skip chunks outside the viewport; connections cache line and arrow paths. Tree and Component internals change without compatibility shims: ITree.iterate and the tree iterator take (prepared, visible); onViewportChange, isSkippedByCulling, getWalkToken, preparesOnIterate, invalidateVisibility, getChildrenArray, activeInViewport and Tree.setVisible are removed; override isRenderAllowed() instead of isVisible(); batch items return cached geometry from getPath() and report getPathBounds(). Co-Authored-By: Claude Opus 5.5 --- .../docs/components/canvas-graph-component.md | 18 +- .../graph/docs/connections/batch-rendering.md | 28 +- .../docs/rendering/rendering-mechanism.md | 39 +- .../graph/docs/system/component-lifecycle.md | 8 +- .../system/component-rendering-lifecycle.md | 17 +- .../components/canvas/GraphComponent/index.ts | 11 +- .../src/components/canvas/blocks/Block.ts | 6 +- .../canvas/connections/Arrow/index.ts | 2 +- .../canvas/connections/BaseConnection.ts | 4 +- .../connections/BatchPath2D/index.test.ts | 306 +++--------- .../canvas/connections/BatchPath2D/index.ts | 279 ++++------- .../connections/BatchPath2D/spatial.test.ts | 351 -------------- .../canvas/connections/BlockConnection.ts | 41 +- .../canvas/connections/BlockConnections.ts | 12 +- .../connections/MultipointConnection.ts | 2 +- .../canvas/connections/visibility.test.ts | 48 +- .../graph/src/lib/Component.phases.test.ts | 24 +- packages/graph/src/lib/Component.ts | 13 +- packages/graph/src/lib/CoreComponent.ts | 47 +- .../graph/src/lib/Scheduler/Scheduler.test.ts | 2 +- packages/graph/src/lib/Scheduler/Scheduler.ts | 8 +- packages/graph/src/lib/Scheduler/index.ts | 2 +- packages/graph/src/lib/Tree.dense.test.ts | 79 ++- .../graph/src/lib/Tree.denseSelection.test.ts | 141 ++---- .../graph/src/lib/Tree.invalidation.test.ts | 90 ++-- packages/graph/src/lib/Tree.spec.ts | 51 +- packages/graph/src/lib/Tree.subtree.test.ts | 35 +- packages/graph/src/lib/Tree.ts | 132 +++-- .../lib/Tree.visibilityPublication.test.ts | 81 +--- .../src/lib/TreeCulling.incremental.test.ts | 47 +- packages/graph/src/lib/TreeCulling.test.ts | 81 ++-- packages/graph/src/lib/TreeCulling.ts | 459 +++++++++--------- .../graph/src/services/visibilityCulling.ts | 5 +- 33 files changed, 923 insertions(+), 1546 deletions(-) delete mode 100644 packages/graph/src/components/canvas/connections/BatchPath2D/spatial.test.ts diff --git a/packages/graph/docs/components/canvas-graph-component.md b/packages/graph/docs/components/canvas-graph-component.md index 5ea8a359..4619f758 100644 --- a/packages/graph/docs/components/canvas-graph-component.md +++ b/packages/graph/docs/components/canvas-graph-component.md @@ -87,28 +87,30 @@ and the component's own bounds in that index. Custom components and containers p automatically; they do not create or query tiles themselves. ```typescript -protected isVisible(): boolean { - return !this.hidden && this.isInViewport(); +// The tree decides spatial visibility and passes it to iterate(); a component only vetoes painting. +protected isRenderAllowed(): boolean { + return !this.hidden; } ``` **What's happening here:** - The tree prepares new components and components that requested an update before publishing visibility; clean visible components update inside their own `iterate()`, right before painting. +- `iterate()` runs only for visible components and for offscreen components whose update was prepared; the latter complete without paint hooks. - Offscreen components still mount and process changes, including ports, children, and geometry. - `willRender`, `render`, and `didRender` run only for eligible components, including on the first frame. - Clean offscreen components are skipped entirely. A visible child is still reached through its offscreen parent. - A camera move rechecks only components near the viewport edges; visibility is kept between frames. - Unknown bounds stay eligible. Set bounds during initialization or data updates, before rendering. -- Connection batches use the same viewport decision, and world-coordinate hit queries remain independent of the viewport. +- Connection batches skip whole chunks outside the viewport, 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. +Override `isRenderAllowed()` for additional semantic conditions, such as hidden elements or missing +connection endpoints; `isVisible()` combines it with `isInViewport()` for queries. Use `firstIterate` +for initialization; `firstRender` stays true until the component is actually painted. +`EXP_VISIBILITY_CULLING = false` enables a full traversal while retaining the same viewport paint gate. `isVisible()` reads the current viewport and bounds between frames and in update hooks that run before painting. A custom `willIterate()` can therefore refresh deferred external state on the first frame that -returns to the viewport. During painting, components and batches use the published Tree visibility. Change +returns to the viewport. During painting it returns the frame's decision. Change geometry through `setState()`, `setProps()` or `performRender()`: a change a clean component makes inside its own update takes effect for culling from the next frame. diff --git a/packages/graph/docs/connections/batch-rendering.md b/packages/graph/docs/connections/batch-rendering.md index 7d65edb8..4a9b6975 100644 --- a/packages/graph/docs/connections/batch-rendering.md +++ b/packages/graph/docs/connections/batch-rendering.md @@ -26,11 +26,11 @@ 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. +Each group keeps up to 100 items per chunk in registration order and draws a chunk with one +`fill`/`stroke`. The combined path is rebuilt only when an item is added, removed or marked dirty; +camera moves never rebuild it. A chunk whose items' bounds are all outside the viewport is skipped +entirely. `afterRender` hooks run for every visible item of a drawn chunk, in registration order; +connections skip their labels themselves when the layer tree reports them offscreen. ## Batch Management Methods @@ -97,16 +97,16 @@ 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. +`getPath()` is called when a chunk is rebuilt and should return cached geometry: `BlockConnection` +returns the path built by its last geometry change and builds its arrow once per change. Normal +connection state, props and endpoint updates mark the item dirty through `applyShape()`. If a custom +batch item changes geometry outside those updates, call `batch.markDirty(item)` or +`batch.update(item, params)`; when only its bounds change, `batch.markBoundsDirty(item)`. -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. +Items should provide `getPathBounds(): readonly [left, top, right, bottom] | undefined` in world +coordinates, covering everything they paint. A chunk with an item without valid bounds is never +culled. `isPathVisible()` is semantic only (hidden items, missing geometry); viewport culling is the +renderer's job. ### Custom Group Names diff --git a/packages/graph/docs/rendering/rendering-mechanism.md b/packages/graph/docs/rendering/rendering-mechanism.md index 2af84a70..50ceddc6 100644 --- a/packages/graph/docs/rendering/rendering-mechanism.md +++ b/packages/graph/docs/rendering/rendering-mechanism.md @@ -36,22 +36,23 @@ three steps: Otherwise visibility is kept between frames: a camera move rechecks only nodes in the parts of the previous and current viewports outside each other, plus nodes whose bounds changed. After a structural change the coordinator runs one complete selection, through tiles or a linear bounds scan - for dense views. Visibility callbacks may move nodes, change the viewport, change the structure or - request updates; publication repeats until it is stable, so batches paint the final membership. -3. **Painting.** One pass over the cached local paint order visits published-visible and already - prepared nodes. A clean visited component applies its own pending work inside `iterate()` right - before it paints (`ITree.preparesOnIterate`); other `ITree` nodes are prepared by the coordinator at - the same point. Clean offscreen nodes receive no hooks, and their visible descendants are still - reached through them. - -A frame therefore costs the visible nodes plus the work that changed. Components and batch paths use -the same published visibility. Hooks of a node being prepared read live visibility from current bounds; -nodes being painted read the published value. - -Structure that a clean node changes while painting (for example children it adds from `render()`) is -painted from the next frame. An explicit child-traversal veto from painting suppresses descendants in -that frame; for a node that does not prepare itself, its next preparation decides again before the -next publication. Prepared descendants of a vetoed branch complete without painting. + for dense views. Bounds reach the tile index once per frame, right before publication. +3. **Painting.** One pass over the cached local paint order calls `iterate(prepared, visible)` for + visible and already prepared nodes. `prepared` tells the node whether its update already ran in this + frame; a clean visible node applies its own pending work inside `iterate()` right before it paints. + `visible` is the tree's paint decision; a component may still veto painting. Clean offscreen nodes + receive no call, and their visible descendants are still reached through them. + +A frame therefore costs the visible nodes plus the work that changed. Hooks that run before painting +read live visibility from current bounds; during painting `isVisible()` returns the frame's decision. + +Structure that a node changes while painting (for example children it adds or moves from `render()`) is +painted from the next frame. A child-traversal veto returned from painting suppresses descendants in that +frame and is decided again by the node's preparation before the next publication. Prepared descendants +of a vetoed branch complete without painting. + +Connection batches do not depend on per-item visibility: each chunk is skipped when its items' bounds +are outside the viewport, see [Batch Rendering](../connections/batch-rendering.md). This coordination does not own a drawing context: layers and component render methods retain their existing responsibilities. @@ -172,9 +173,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 -- `prepareUpdate()` - Apply pending data and reconcile children; runs before painting for requested and new components, and inside `iterate()` for clean visible ones -- `iterate()` - Paint eligible components and complete the update -- `isInViewport()` - Read the tree's current spatial visibility decision +- `prepareUpdate()` - Apply pending data and reconcile children; the tree calls it before painting for requested and new components +- `iterate(prepared, visible)` - Run the component's frame: update unless `prepared`, paint if `visible` and not vetoed +- `isInViewport()` - Read the tree's spatial visibility: the frame's decision while painting, current bounds otherwise - `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 2733e03a..006474dd 100644 --- a/packages/graph/docs/system/component-lifecycle.md +++ b/packages/graph/docs/system/component-lifecycle.md @@ -45,7 +45,7 @@ The CoreComponent provides the foundation with four main methods: | Method | Purpose | When Called | |--------|---------|-------------| | `prepareUpdate()` | Applies pending data and reconciles children | Before painting for new or requested components; inside `iterate()` for clean visible ones | -| `iterate()` | Completes the update and paints eligible components | For visible components and prepared offscreen ones | +| `iterate(prepared, visible)` | Updates unless `prepared`, then paints if `visible` and not vetoed | For visible components and prepared offscreen ones | | `updateChildren()` | Defines and updates child components | During children update | | `render()` | Handles visual rendering | During render phase | @@ -76,9 +76,9 @@ Methods are called in this order: H -->|Yes| I[willUpdateChildren] I --> J[updateChildren] J --> K[didUpdateChildren] - H -->|No| V[Tree's published viewport visibility] + H -->|No| V[Tree's visible argument] K --> V - V --> C{shouldRender and in viewport?} + V --> C{visible, shouldRender and isRenderAllowed?} C -->|Yes| D[willRender] D --> E[render] E --> F[didRender] @@ -106,7 +106,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` and the tree's published viewport visibility | +| 6 | Render evaluated | Based on the tree's `visible` argument, `shouldRender` and `isRenderAllowed()` | ### 5. Control Flags diff --git a/packages/graph/docs/system/component-rendering-lifecycle.md b/packages/graph/docs/system/component-rendering-lifecycle.md index 56604242..eb5ad19b 100644 --- a/packages/graph/docs/system/component-rendering-lifecycle.md +++ b/packages/graph/docs/system/component-rendering-lifecycle.md @@ -44,12 +44,11 @@ sequenceDiagram end Tree->>Tree: Update visibility from viewport delta and changed bounds - Tree->>Component: onViewportChange() when membership changes - Tree->>Component: iterate() in local z-index order for visible or prepared nodes - opt clean visible component + Tree->>Component: iterate(prepared, visible) in local z-index order for visible or prepared nodes + opt not prepared yet in this frame Component->>Component: prepareUpdate() inside iterate() end - alt shouldRender and in viewport + alt visible, shouldRender and not vetoed by isRenderAllowed() Component->>Component: willRender() Component->>Rendering: render() Component->>Component: didRender() @@ -86,7 +85,7 @@ The component lifecycle controls the rendering process through several key metho 1. `prepareUpdate()` - Applies data and reconciles children; before painting for requested and new components, inside `iterate()` for clean visible ones 2. `checkData()` - Processes any pending state/props changes -3. `shouldRender` flag and `isInViewport()` - Together control whether rendering occurs +3. The tree's `visible` argument, the `shouldRender` flag and `isRenderAllowed()` - Together control whether rendering occurs 4. `iterate()` / `render()` - Complete the update and create eligible visual output 5. `shouldUpdateChildren` flag - Controls whether children are updated @@ -109,9 +108,9 @@ visible child. Unknown bounds remain eligible. New and requested offscreen nodes 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 published -visibility. Hit testing retains its separate spatial index and supports queries outside the viewport. +Visibility is published before the layer paints. Connection batches do not track it per item: they +skip whole chunks outside the viewport. 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 @@ -143,7 +142,7 @@ Combining all three documents, we can map the complete lifecycle from state chan - For requested components, the tree publishes the resulting bounds before anything paints 4. **Rendering Decision** - - Component evaluates `shouldRender` and the tree's published viewport visibility + - Component paints when the tree passes `visible`, `shouldRender` is set and `isRenderAllowed()` agrees - If true: `willRender()` → `render()` → `didRender()` - If false: `willNotRender()` diff --git a/packages/graph/src/components/canvas/GraphComponent/index.ts b/packages/graph/src/components/canvas/GraphComponent/index.ts index b3bd1e3c..cb9647c4 100644 --- a/packages/graph/src/components/canvas/GraphComponent/index.ts +++ b/packages/graph/src/components/canvas/GraphComponent/index.ts @@ -368,16 +368,13 @@ export class GraphComponent< if (!this.firstIterate) this.shouldRender = true; } - public override iterate(): boolean { - if (!this.updatePrepared) this.prepareUpdate(); - // Preserve a subclass's paint veto; geometry may still change after its update hook. - this.shouldRender = this.shouldRender && this.isVisible(); - return super.iterate(); + protected override isRenderAllowed(): boolean { + return !this.hidden; } + /** Whether this component can paint now: not hidden and inside the viewport. */ protected isVisible(): boolean { - if (this.hidden) return false; - return this.isInViewport(); + return this.isRenderAllowed() && this.isInViewport(); } protected getHitBoxRect(): TRect { diff --git a/packages/graph/src/components/canvas/blocks/Block.ts b/packages/graph/src/components/canvas/blocks/Block.ts index 3ddf6d10..c15aef11 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) { @@ -181,9 +181,9 @@ export class Block(flagPath: K) { diff --git a/packages/graph/src/components/canvas/connections/Arrow/index.ts b/packages/graph/src/components/canvas/connections/Arrow/index.ts index a7995ff8..a03264fe 100644 --- a/packages/graph/src/components/canvas/connections/Arrow/index.ts +++ b/packages/graph/src/components/canvas/connections/Arrow/index.ts @@ -14,7 +14,7 @@ export class ConnectionArrow implements Path2DRenderInsta } public getPath() { - return this.connection.createArrowPath(); + return this.connection.getArrowPath(); } public style(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined { diff --git a/packages/graph/src/components/canvas/connections/BaseConnection.ts b/packages/graph/src/components/canvas/connections/BaseConnection.ts index 9a7828ed..c5037a5f 100644 --- a/packages/graph/src/components/canvas/connections/BaseConnection.ts +++ b/packages/graph/src/components/canvas/connections/BaseConnection.ts @@ -178,10 +178,10 @@ export class BaseConnection< return this.connectedState.$geometry.value !== undefined; } - protected override isVisible(): boolean { + protected override isRenderAllowed(): boolean { if (!this.isEntityAvailable() || !this.hasGeometry()) return false; if (this.connectedState.$hidden.value) return false; - return super.isVisible(); + return super.isRenderAllowed(); } protected override handleEvent(event: MouseEvent) { 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 15a4a053..a5ae5a67 100644 --- a/packages/graph/src/components/canvas/connections/BatchPath2D/index.test.ts +++ b/packages/graph/src/components/canvas/connections/BatchPath2D/index.test.ts @@ -1,20 +1,6 @@ -import { BatchPath2DRenderer } from "./index"; +import type { TTileRect } from "../../../../lib/TileIndex"; -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; -}); +import { BatchPath2DRenderer, TPathBounds } from "./index"; function createPath(end: number) { const path = new Path2D(); @@ -23,16 +9,19 @@ function createPath(end: number) { return path; } -function createItem(end: number) { - const state = { end, visible: true }; +function createItem(end: number, bounds?: TPathBounds) { + const state: { end: number; visible: boolean; bounds: TPathBounds | undefined } = { end, visible: true, bounds }; return { state, getPath: jest.fn(() => createPath(state.end)), + getPathBounds: () => state.bounds, isPathVisible: () => state.visible, style: () => ({ type: "stroke" as const }), }; } +const viewport = (minX: number, maxX: number): TTileRect => ({ minX, minY: -10, maxX, maxY: 10 }); + function createFixture(chunkSize = 100) { const ctx = document.createElement("canvas").getContext("2d"); if (!ctx) throw new Error("Expected canvas context"); @@ -43,10 +32,11 @@ function createFixture(chunkSize = 100) { ctx, batch, schedule, - render() { + /** Returns the line ends drawn by each stroke call. */ + render(rect?: TTileRect) { stroke.mockClear(); - batch.orderedPaths.get().forEach((group) => group.render(ctx)); - return stroke.mock.calls.flatMap(([path]) => { + batch.render(ctx, rect); + return stroke.mock.calls.map(([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); }); @@ -54,223 +44,58 @@ function createFixture(chunkSize = 100) { }; } -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"); - let visible = true; - const item = { - isPathVisible: jest.fn(() => visible), - getPath: jest.fn(() => new Path2D()), - style: () => ({ type: "stroke" as const }), - }; - const batch = new BatchPath2DRenderer(() => {}); - batch.add(item, { zIndex: 0, group: "connections" }); - for (let i = 0; i < 10; i++) batch.orderedPaths.get().forEach((group) => group.render(ctx)); - expect(item.isPathVisible).toHaveBeenCalledTimes(1); - expect(item.getPath).toHaveBeenCalledTimes(1); - visible = false; - batch.markDirty(item); - batch.orderedPaths.get().forEach((group) => group.render(ctx)); - expect(item.isPathVisible).toHaveBeenCalledTimes(2); - expect(item.getPath).toHaveBeenCalledTimes(1); - visible = true; - batch.markDirty(item); - 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(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); +test("stable frames and camera moves reuse chunk paths; markDirty rebuilds only that chunk", () => { + const { batch, render, schedule } = createFixture(2); + const items = [10, 20, 30].map((end) => createItem(end, [0, 0, end, 0])); + for (const item of items) batch.add(item, { zIndex: 0, group: "connections" }); - 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(); -}); + expect(render(viewport(0, 100))).toEqual([[10, 20], [30]]); + expect(render(viewport(5, 105))).toEqual([[10, 20], [30]]); + expect(items.map((item) => item.getPath.mock.calls.length)).toEqual([1, 1, 1]); -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); + items[2].state.end = 35; + items[2].state.bounds = [0, 0, 35, 0]; + batch.markDirty(items[2]); 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); + expect(render(viewport(0, 100))).toEqual([[10, 20], [35]]); + expect(items.map((item) => item.getPath.mock.calls.length)).toEqual([1, 1, 2]); }); -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("skips a chunk entirely outside the viewport without rebuilding it when it returns", () => { + const { batch, render } = createFixture(2); + const near = [createItem(10, [0, 0, 10, 0]), createItem(20, [0, 0, 20, 0])]; + const far = [createItem(1010, [1000, 0, 1010, 0]), createItem(1020, [1000, 0, 1020, 0])]; + const afterRender = jest.fn(); + for (const item of [...near, ...far]) batch.add({ ...item, afterRender }, { zIndex: 0, group: "connections" }); + + expect(render(viewport(0, 100))).toEqual([[10, 20]]); + expect(afterRender).toHaveBeenCalledTimes(2); + expect(far.map((item) => item.getPath.mock.calls.length)).toEqual([0, 0]); + + expect(render(viewport(990, 1100))).toEqual([[1010, 1020]]); + expect(render(viewport(0, 1100))).toEqual([ + [10, 20], + [1010, 1020], + ]); + expect([...near, ...far].map((item) => item.getPath.mock.calls.length)).toEqual([1, 1, 1, 1]); }); -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("never culls a chunk with an item whose bounds are missing or invalid", () => { + const { batch, render } = createFixture(2); + const known = createItem(1010, [1000, 0, 1010, 0]); + for (const bounds of [undefined, [NaN, 0, 1, 1] as const, [10, 0, 0, 0] as const]) { + const fixture = createFixture(2); + fixture.batch.add(createItem(1020, [1000, 0, 1020, 0]), { zIndex: 0, group: "connections" }); + fixture.batch.add(createItem(1030, bounds), { zIndex: 0, group: "connections" }); + expect(fixture.render(viewport(0, 100))).toEqual([[1020, 1030]]); + } + batch.add(known, { zIndex: 0, group: "connections" }); + expect(render(viewport(0, 100))).toEqual([]); + expect(render()).toEqual([[1010]]); }); -test("custom styles and afterRender keep registration and z order with the first visible item's fill rule", () => { +test("semantic visibility applies after markDirty and keeps the first visible item's style", () => { const { batch, ctx, render } = createFixture(3); const calls: string[] = []; const first = createItem(10); @@ -291,16 +116,33 @@ test("custom styles and afterRender keep registration and z order with the first 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(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]); + batch.markDirty(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); }); + +test("update moves an item between groups and z-levels, and deleting it removes its path", () => { + const { batch, render } = createFixture(100); + const a = createItem(10); + const b = createItem(20); + batch.add(a, { zIndex: 0, group: "connections" }); + batch.add(b, { zIndex: 0, group: "connections" }); + expect(render()).toEqual([[10, 20]]); + + batch.update(a, { zIndex: 1, group: "connections" }); + expect(render()).toEqual([[20], [10]]); + batch.update(a, { zIndex: 1, group: "custom" }); + expect(render()).toEqual([[20], [10]]); + batch.delete(a); + expect(render()).toEqual([[20]]); + batch.add(a, { zIndex: 0, group: "connections" }); + expect(render()).toEqual([[20, 10]]); +}); diff --git a/packages/graph/src/components/canvas/connections/BatchPath2D/index.ts b/packages/graph/src/components/canvas/connections/BatchPath2D/index.ts index 9a69ffc6..59dc5521 100644 --- a/packages/graph/src/components/canvas/connections/BatchPath2D/index.ts +++ b/packages/graph/src/components/canvas/connections/BatchPath2D/index.ts @@ -1,3 +1,4 @@ +import type { TTileRect } from "../../../../lib/TileIndex"; import { cache } from "../../../../lib/utils"; export type Path2DRenderStyleResult = @@ -5,190 +6,112 @@ export type Path2DRenderStyleResult = | { type: "fill"; fillRule?: CanvasFillRule } | { type: "both"; fillRule?: CanvasFillRule }; +/** World-space rectangle as `[minX, minY, maxX, maxY]`. */ +export type TPathBounds = readonly [number, number, number, number]; + 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; + /** World-space bounds of the item's path; a chunk with an item without valid bounds is never culled. */ + getPathBounds?(): TPathBounds | undefined; style(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined; + /** Runs for every visible item of a drawn chunk; items that draw extra content cull it themselves. */ afterRender?(ctx: CanvasRenderingContext2D): void; + /** Semantic visibility, such as hidden items or missing geometry. Viewport culling is the renderer's job. */ 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 isValidBounds(bounds: TPathBounds | undefined): bounds is TPathBounds { + return Boolean( + bounds && + Number.isFinite(bounds[0]) && + Number.isFinite(bounds[1]) && + Number.isFinite(bounds[2]) && + Number.isFinite(bounds[3]) && + bounds[0] <= bounds[2] && + bounds[1] <= bounds[3] + ); } -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]; +function intersects(bounds: TPathBounds | undefined, viewport: TTileRect | undefined): boolean { + if (!viewport || !isValidBounds(bounds)) return true; + return ( + bounds[0] <= viewport.maxX && bounds[2] >= viewport.minX && bounds[1] <= viewport.maxY && bounds[3] >= viewport.minY + ); } +/** + * Up to `chunkSize` items drawn with one fill or stroke. The combined path is rebuilt only when an + * item is added, removed or changed; a chunk entirely outside the viewport is skipped. + */ class Path2DChunk { - protected items = new Map(); - private spatial = false; - private pathBuilt = false; - private cells?: Map; - private extent = [0, 0, 0, 0]; + protected items: Set = new Set(); protected visibleItems = cache(() => { - 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; + return Array.from(this.items).filter((item) => item.isPathVisible?.() ?? true); + }); + + /** Union of the visible items' bounds; undefined when any of them is unknown. */ + protected bounds = cache((): TPathBounds | undefined => { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const item of this.visibleItems.get()) { + const bounds = item.getPathBounds?.(); + if (!isValidBounds(bounds)) return undefined; + minX = Math.min(minX, bounds[0]); + minY = Math.min(minY, bounds[1]); + maxX = Math.max(maxX, bounds[2]); + maxY = Math.max(maxY, bounds[3]); + } + return [minX, minY, maxX, maxY]; }); 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); - 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); - } + for (const item of this.visibleItems.get()) { + const subPath = item.getPath(); + if (subPath) path.addPath(subPath); } - this.pathBuilt = true; return path; }); - 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; - } - - 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(); + this.items.add(item); + this.reset(); } 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(); } - private reset() { + public reset() { this.path.reset(); this.visibleItems.reset(); + this.bounds.reset(); } - public render(ctx: CanvasRenderingContext2D) { - const vis = this.visibleItems.get(); - if (!vis.length) return; + public resetBounds() { + this.bounds.reset(); + } + + public render(ctx: CanvasRenderingContext2D, viewport?: TTileRect) { + const visible = this.visibleItems.get(); + if (!visible.length || !intersects(this.bounds.get(), viewport)) return; ctx.save(); - const style = vis[0].item.style(ctx); + // Style comes from the first visible item. + const style = visible[0].style(ctx); if (style) { - const p = this.path.get(); - if (style.type === "fill" || style.type === "both") { - ctx.fill(p, style.fillRule ?? "nonzero"); - } - if (style.type === "stroke" || style.type === "both") { - ctx.stroke(p); - } + const path = this.path.get(); + if (style.type === "fill" || style.type === "both") ctx.fill(path, style.fillRule ?? "nonzero"); + if (style.type === "stroke" || style.type === "both") ctx.stroke(path); } ctx.restore(); - for (const record of vis) { - record.item.afterRender?.(ctx); - } + for (const item of visible) item.afterRender?.(ctx); } public get size() { @@ -216,27 +139,25 @@ class Path2DGroup { public delete(item: Path2DRenderInstance) { const chunk = this.itemToChunk.get(item); - if (chunk) { - chunk.delete(item); - this.itemToChunk.delete(item); - if (chunk.size === 0 && this.chunks.length > 1) { - const index = this.chunks.indexOf(chunk); - if (index > -1) { - this.chunks.splice(index, 1); - } - } + if (!chunk) return; + chunk.delete(item); + this.itemToChunk.delete(item); + if (chunk.size === 0 && this.chunks.length > 1) { + const index = this.chunks.indexOf(chunk); + if (index > -1) this.chunks.splice(index, 1); } } - public resetItem(item: Path2DRenderInstance, geometry: boolean) { - const chunk = this.itemToChunk.get(item); - chunk?.resetItem(item, geometry); + public reset(item: Path2DRenderInstance) { + this.itemToChunk.get(item)?.reset(); } - public render(ctx: CanvasRenderingContext2D) { - for (const chunk of this.chunks) { - chunk.render(ctx); - } + public resetBounds(item: Path2DRenderInstance) { + this.itemToChunk.get(item)?.resetBounds(); + } + + public render(ctx: CanvasRenderingContext2D, viewport?: TTileRect) { + for (const chunk of this.chunks) chunk.render(ctx, viewport); } } @@ -275,6 +196,11 @@ export class BatchPath2DRenderer { return bucket; } + /** Draws every group in z-order; chunks entirely outside the world-space viewport are skipped. */ + public render(ctx: CanvasRenderingContext2D, viewport?: TTileRect) { + for (const group of this.orderedPaths.get()) group.render(ctx, viewport); + } + public add(item: Path2DRenderInstance, params: { zIndex: number; group: string }) { if (this.itemParams.has(item)) { this.update(item, params); @@ -287,18 +213,10 @@ export class BatchPath2DRenderer { this.requestRender(); } + /** A zIndex or group change moves the item; otherwise only its chunk is rebuilt. */ public update(item: Path2DRenderInstance, params: { zIndex: number; group: string }) { const prev = this.itemParams.get(item); if (prev) { - /** - * Reasons to update path2d is different - * 1. zIndex changed - * 2. group changed - * 3. item changes geometry - * - * So is item change zIndex or group, we need recalculate groups - * But if item change geomertry we have ot only mark item as dirty to recalc groups path2d - * */ if (prev.zIndex === params.zIndex && prev.group === params.group) { this.markDirty(item); return; @@ -310,30 +228,25 @@ export class BatchPath2DRenderer { public delete(item: Path2DRenderInstance) { const params = this.itemParams.get(item); - if (!params) { - return; - } - const bucket = this.getGroup(params.zIndex, params.group); - bucket.delete(item); + if (!params) return; + this.getGroup(params.zIndex, params.group).delete(item); this.itemParams.delete(item); this.orderedPaths.reset(); this.requestRender(); } + /** The item's path, bounds or semantic visibility changed. */ public markDirty(item: Path2DRenderInstance) { const params = this.itemParams.get(item); - if (params) { - this.getGroup(params.zIndex, params.group).resetItem(item, true); - this.requestRender(); - } + if (!params) return; + this.getGroup(params.zIndex, params.group).reset(item); + this.requestRender(); } - /** Visibility is finalized during the current traversal, before this batch draws. */ - public invalidateVisibility(item: Path2DRenderInstance) { + /** Only the item's bounds changed, for example a label that grows with zoom; its path is kept. */ + public markBoundsDirty(item: Path2DRenderInstance) { const params = this.itemParams.get(item); - if (params) { - const group = this.getGroup(params.zIndex, params.group); - group.resetItem(item, false); - } + if (!params) return; + this.getGroup(params.zIndex, params.group).resetBounds(item); } } diff --git a/packages/graph/src/components/canvas/connections/BatchPath2D/spatial.test.ts b/packages/graph/src/components/canvas/connections/BatchPath2D/spatial.test.ts deleted file mode 100644 index 3cf8527f..00000000 --- a/packages/graph/src/components/canvas/connections/BatchPath2D/spatial.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -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 8e49ba28..21fb6c25 100644 --- a/packages/graph/src/components/canvas/connections/BlockConnection.ts +++ b/packages/graph/src/components/canvas/connections/BlockConnection.ts @@ -9,7 +9,7 @@ import { TPoint } from "../../../utils/types/shapes"; import { ConnectionArrow } from "./Arrow"; import { BaseConnection, TBaseConnectionProps, TBaseConnectionState } from "./BaseConnection"; -import { Path2DRenderInstance, Path2DRenderStyleResult } from "./BatchPath2D"; +import { Path2DRenderInstance, Path2DRenderStyleResult, TPathBounds } from "./BatchPath2D"; import { BlockConnections, TGraphConnectionsContext } from "./BlockConnections"; import { bezierCurveLine, @@ -56,6 +56,10 @@ export class BlockConnection private labelFont = ""; + private paintBounds: TPathBounds | undefined; + + private arrowPath: Path2D | undefined; + /** Context the current geometry was computed with; constants and label sizes live there. */ private geometryContext?: TGraphConnectionsContext; @@ -139,26 +143,23 @@ export class BlockConnection } public isPathVisible(): boolean { - return ( - this.isInViewport() && - this.isEntityAvailable() && - this.hasGeometry() && - !this.hidden && - !this.connectedState.$hidden.value - ); + return 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); + /** Bounds of the line, its arrow and its label, shared by tree culling and batch culling. */ + public getPathBounds(): TPathBounds | undefined { + return this.paintBounds; } - public getPathBounds() { - return this.getBBox(); + /** The line built by the last geometry change; batch rebuilds reuse it. */ + public getPath(): Path2D { + return this.path2d; } - public getPath(): Path2D { - return this.generatePath(); + /** The arrow for the current geometry, built on first use after a geometry change. */ + public getArrowPath(): Path2D { + this.arrowPath ??= this.createArrowPath(); + return this.arrowPath; } /** @@ -204,6 +205,7 @@ export class BlockConnection protected generatePath() { /* Setting this.path2D is important, as hotbox checking uses the isPointInStroke method. */ this.path2d = this.createPath(); + this.arrowPath = undefined; return this.path2d; } @@ -262,7 +264,8 @@ export class BlockConnection const cameraClose = this.context.camera.getCameraScale() >= this.context.constants.connection.MIN_ZOOM_FOR_CONNECTION_ARROW_AND_LABEL; - if (this.state.label && this.props.showConnectionLabels && cameraClose) { + // The batch draws whole chunks; a label outside the viewport is skipped by the tree's decision. + if (this.state.label && this.props.showConnectionLabels && cameraClose && this.isInViewport()) { this.renderLabelText(ctx); } } @@ -373,8 +376,14 @@ export class BlockConnection maxY = Math.max(maxY, point.y + arrowPadding); } } + this.paintBounds = [minX, minY, maxX, maxY]; this.setCullingBounds(minX, minY, maxX, maxY); + } else { + this.paintBounds = undefined; } + // Chunks cache the union of their items' bounds. + this.context.batch.markBoundsDirty(this); + this.context.batch.markBoundsDirty(this.arrowShape); } protected override handleEvent(event: MouseEvent) { diff --git a/packages/graph/src/components/canvas/connections/BlockConnections.ts b/packages/graph/src/components/canvas/connections/BlockConnections.ts index 98933730..b2504179 100644 --- a/packages/graph/src/components/canvas/connections/BlockConnections.ts +++ b/packages/graph/src/components/canvas/connections/BlockConnections.ts @@ -102,9 +102,13 @@ export class BlockConnections extends Component } public afterRender?(ctx: CanvasRenderingContext2D): void { - this.renderLabelsText(ctx); + if (this.isInViewport()) this.renderLabelsText(ctx); } public updatePoints(additionalPoints?: TPoint[], props: TConnectionProps = this.props): void { diff --git a/packages/graph/src/components/canvas/connections/visibility.test.ts b/packages/graph/src/components/canvas/connections/visibility.test.ts index 6f9ddef8..393c4fb3 100644 --- a/packages/graph/src/components/canvas/connections/visibility.test.ts +++ b/packages/graph/src/components/canvas/connections/visibility.test.ts @@ -7,9 +7,14 @@ import { BlockConnection } from "./BlockConnection"; class ObservedConnection extends BlockConnection { public draws: string[] = []; - public override getPath() { + protected override createPath() { this.draws.push("path"); - return super.getPath(); + return super.createPath(); + } + + protected override renderLabelText(ctx: CanvasRenderingContext2D) { + this.draws.push("label"); + super.renderLabelText(ctx); } public override createArrowPath() { @@ -83,14 +88,16 @@ describe("connection batch viewport visibility", () => { afterEach(() => graph?.unmount()); - it("omits offscreen paths, arrows and after-render hooks from the first batch draw", () => { + it("builds geometry once and skips the label of an offscreen connection in a drawn chunk", () => { mount([ - { id: "near", x: 100, y: 100 }, - { id: "far", x: 50_000, y: 50_000 }, + { id: "near", x: 100, y: 100, label: "near" }, + { id: "far", x: 50_000, y: 50_000, label: "far" }, ]); - expect(view("near").draws).toContain("path"); - expect(view("far").draws).toEqual([]); + expect(view("near").draws.filter((draw) => draw === "path")).toEqual(["path"]); + expect(view("near").draws).toContain("label"); + expect(view("far").draws.filter((draw) => draw === "path")).toEqual(["path"]); + expect(view("far").draws).not.toContain("label"); }); it("reuses line and arrow geometry when the camera leaves and reenters", () => { @@ -103,17 +110,21 @@ describe("connection batch viewport visibility", () => { 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"])); + expect(view("far").draws).toContain("afterRender"); + for (const id of ["near", "far"]) { + expect(view(id).draws).not.toContain("path"); + expect(view(id).draws).not.toContain("arrow"); + } view("near").draws = []; view("far").draws = []; graph.cameraService.set({ x: 0, y: 0 }); frames(2); 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([]); + for (const id of ["near", "far"]) { + expect(view(id).draws).not.toContain("path"); + expect(view(id).draws).not.toContain("arrow"); + } }); it.each([true, false])("keeps custom line and arrow geometry cached across pan with Tree culling %s", (culling) => { @@ -122,10 +133,11 @@ describe("connection batch viewport visibility", () => { 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. + // One previously visible connection exits; its chunk stays drawn for the visible neighbors. graph.cameraService.set({ x: -210 }); frames(2); - expect(view("0").draws).toEqual([]); + expect(view("0").draws).not.toContain("path"); + expect(view("0").draws).not.toContain("arrow"); expect(view("20").draws).toContain("afterRender"); for (let index = 1; index < 80; index++) { expect(view(String(index)).draws).not.toContain("path"); @@ -186,7 +198,9 @@ describe("connection batch viewport visibility", () => { expect(view("label").labelBounds()).toBeUndefined(); expect(view("label").getHitBox()[0]).toBeGreaterThan(1000); - expect(view("label").draws).toEqual([]); + // The offscreen connection rebuilds geometry for the new props but does not draw. + expect(view("label").draws).not.toContain("afterRender"); + expect(view("label").draws).not.toContain("label"); graph.updateSettings({ showConnectionLabels: true }); frames(3); @@ -257,7 +271,9 @@ describe("connection batch viewport visibility", () => { view("connection").setRenderDelegated(false); frames(2); - expect(view("connection").draws).toEqual(expect.arrayContaining(["path", "arrow", "afterRender"])); + // It draws again with the geometry it already had. + expect(view("connection").draws).toEqual(expect.arrayContaining(["style", "arrowStyle", "afterRender"])); + expect(view("connection").draws).not.toContain("path"); }); it("keeps offscreen line geometry in world rectangle and point hit queries", () => { diff --git a/packages/graph/src/lib/Component.phases.test.ts b/packages/graph/src/lib/Component.phases.test.ts index dcfbc16a..b9f401ad 100644 --- a/packages/graph/src/lib/Component.phases.test.ts +++ b/packages/graph/src/lib/Component.phases.test.ts @@ -3,17 +3,12 @@ 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 }); } @@ -47,46 +42,43 @@ class Probe extends Component<{ value: number }, { value: number }> { test("mounts and commits state outside the viewport without running render hooks", () => { const probe = new Probe(); probe.change(1); - probe.iterate(); + probe.iterate(false, false); expect(probe.state.value).toBe(1); expect(probe.events).toEqual(["mount", "children", "complete"]); probe.change(2); - probe.iterate(); + probe.iterate(false, false); expect(probe.state.value).toBe(2); expect(probe.events).toEqual(["mount", "children", "complete", "complete"]); - probe.inViewport = true; - probe.iterate(); + probe.iterate(false, true); expect(probe.events.slice(-4)).toEqual(["willRender", "render:2", "didRender", "complete"]); CoreComponent.unmount(probe); }); -test("prepares children and data before painting, without repeating preparation", () => { +test("prepares children and data before painting", () => { 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(); + probe.iterate(true, true); expect(probe.events).toEqual(["mount", "children", "willRender", "render:4", "didRender", "complete"]); CoreComponent.unmount(probe); }); -test("reuses explicit preparation and prepares a standalone iteration only once", () => { +test("prepares inside iterate only when the tree has not prepared the component", () => { const probe = new Probe(); const prepare = jest.spyOn(probe, "prepareUpdate"); probe.change(1); probe.prepareUpdate(); - probe.iterate(); + probe.iterate(true, true); expect(prepare).toHaveBeenCalledTimes(1); expect(probe.state.value).toBe(1); probe.change(2); - probe.iterate(); + probe.iterate(false, true); expect(prepare).toHaveBeenCalledTimes(2); expect(probe.state.value).toBe(2); expect(probe.events.filter((event) => event === "complete")).toHaveLength(2); diff --git a/packages/graph/src/lib/Component.ts b/packages/graph/src/lib/Component.ts index 4c191102..35ce0068 100644 --- a/packages/graph/src/lib/Component.ts +++ b/packages/graph/src/lib/Component.ts @@ -148,8 +148,12 @@ export class Component< // noop } + /** Semantic paint veto checked after the tree's viewport decision, for example for hidden elements. */ + protected isRenderAllowed(): boolean { + return true; + } + public override prepareUpdate(): boolean { - if (this.updatePrepared) return this.shouldRenderChildren; super.prepareUpdate(); this.checkData(); this.willIterate(); @@ -161,11 +165,10 @@ export class Component< return this.shouldRenderChildren; } - public iterate() { - if (!this.updatePrepared) this.prepareUpdate(); - super.iterate(); + public override iterate(prepared = false, visible = true): boolean { + if (!prepared) this.prepareUpdate(); - if (this.shouldRender && this.isInViewport()) { + if (visible && this.shouldRender && this.isRenderAllowed()) { this.renderLifeCycle(); } else { this.willNotRender(); diff --git a/packages/graph/src/lib/CoreComponent.ts b/packages/graph/src/lib/CoreComponent.ts index 0a7ccc69..4ca5933b 100644 --- a/packages/graph/src/lib/CoreComponent.ts +++ b/packages/graph/src/lib/CoreComponent.ts @@ -82,8 +82,6 @@ export class CoreComponent< protected __comp: TPrivateComponentData; - protected updatePrepared = false; - public get zIndex() { return this.__comp.treeNode.zIndex; } @@ -93,11 +91,6 @@ export class CoreComponent< this.performRender(); } - /** {@link iterate} applies pending updates when the tree has not prepared this component. */ - public get preparesOnIterate(): boolean { - return true; - } - public get renderOrder() { return this.__comp.treeNode.renderOrder; } @@ -119,14 +112,14 @@ export class CoreComponent< this.props = props ?? ({} as Props); } + /** + * Whether this component took part in the latest traversal. A culled tree skips clean offscreen + * components on purpose, so there it means "attached to the tree in a branch that is not suppressed". + */ public isIterated(): boolean { - 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.treeNode.isTraversalAllowed() && - (this.__comp.iterateId === iterateId || this.__comp.treeNode.isSkippedByCulling(iterateId)) - ); + const treeNode = this.__comp.treeNode; + if (treeNode.scope) return treeNode.isTraversalAllowed(); + return this.__comp.iterateId === this.__comp.context.globalIterateId; } /** Whether this node's own bounds intersect its tree's viewport. */ @@ -134,11 +127,6 @@ export class CoreComponent< return this.__comp.treeNode.isVisible(); } - /** @internal Called before painting when the tree's viewport selection changes. */ - public onViewportChange(_visible: boolean): void { - // noop - } - public performRender = () => { this.__comp.context.scheduler.scheduleUpdate(); this.__comp.treeNode.requestWalk(); @@ -147,8 +135,8 @@ export class CoreComponent< /** * 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 }); + protected setChildrenCulling(options: TTreeCullingOptions | undefined): void { + this.__comp.treeNode.setCulling(options); } /** Reports this component's own bounds to its tree's spatial index. */ @@ -202,20 +190,19 @@ export class CoreComponent< this.performRender(); } - /** Applies pending work before the tree selects the elements to paint. */ + /** Applies pending work without painting; the tree calls it ahead of painting for requested and new components. */ public prepareUpdate(): boolean { - if (this.updatePrepared) return true; - if (!this.__comp.parent) { - this.__comp.context.globalIterateId = Math.random(); - } + if (!this.__comp.parent) this.__comp.context.globalIterateId++; this.__comp.iterateId = this.__comp.context.globalIterateId; - this.updatePrepared = true; return true; } - public iterate(): boolean { - if (!this.updatePrepared) this.prepareUpdate(); - this.updatePrepared = false; + /** + * Runs this component's frame. `prepared` tells whether the tree already called prepareUpdate() in + * this traversal; `visible` is the tree's viewport decision. + */ + public iterate(prepared = false, _visible = true): boolean { + if (!prepared) this.prepareUpdate(); return true; } diff --git a/packages/graph/src/lib/Scheduler/Scheduler.test.ts b/packages/graph/src/lib/Scheduler/Scheduler.test.ts index b56c9d2f..a809c21b 100644 --- a/packages/graph/src/lib/Scheduler/Scheduler.test.ts +++ b/packages/graph/src/lib/Scheduler/Scheduler.test.ts @@ -180,7 +180,7 @@ describe("Scheduler root readiness", () => { const iterate = jest.fn(() => true); scheduler.setRoot({ traverseDown: (iterator) => { - iterator({ data: { iterate } }); + iterator({ data: { iterate } }, false, true); }, }); scheduler.performUpdate(); diff --git a/packages/graph/src/lib/Scheduler/Scheduler.ts b/packages/graph/src/lib/Scheduler/Scheduler.ts index 2c8c68c2..0255b70a 100644 --- a/packages/graph/src/lib/Scheduler/Scheduler.ts +++ b/packages/graph/src/lib/Scheduler/Scheduler.ts @@ -21,12 +21,12 @@ export type TSchedulerPriority = 0 | 1 | 2 | 3 | 4; type TSchedulerTreeNode = { data: { - iterate: () => boolean; + iterate: (prepared: boolean, visible: boolean) => boolean; }; }; type TSchedulerTree = { - traverseDown: (iterator: (node: TSchedulerTreeNode) => boolean) => void; + traverseDown: (iterator: (node: TSchedulerTreeNode, prepared: boolean, visible: boolean) => boolean) => void; }; const MEDIUM_PRIORITY: TSchedulerPriority = 2; @@ -187,8 +187,8 @@ export class Scheduler { this.root?.traverseDown(this.iterator); } - public iterator(node: TSchedulerTreeNode) { - return node.data.iterate(); + public iterator(node: TSchedulerTreeNode, prepared: boolean, visible: boolean) { + return node.data.iterate(prepared, visible); } public scheduleUpdate() { diff --git a/packages/graph/src/lib/Scheduler/index.ts b/packages/graph/src/lib/Scheduler/index.ts index b412f7e5..48ff56be 100644 --- a/packages/graph/src/lib/Scheduler/index.ts +++ b/packages/graph/src/lib/Scheduler/index.ts @@ -45,7 +45,7 @@ export const scheduler: GlobalScheduler = runtimeScheduler; export interface Scheduler { setRoot(root: Tree | undefined): void; update(): void; - iterator(node: Tree): boolean; + iterator(node: Tree, prepared: boolean, visible: boolean): boolean; scheduleUpdate(): void; performUpdate(): void; } diff --git a/packages/graph/src/lib/Tree.dense.test.ts b/packages/graph/src/lib/Tree.dense.test.ts index 9f084e2e..11f70ac8 100644 --- a/packages/graph/src/lib/Tree.dense.test.ts +++ b/packages/graph/src/lib/Tree.dense.test.ts @@ -5,9 +5,22 @@ type Probe = ITree & { name: string; prepare?: () => boolean; paint?: () => bool const viewport = (maxX: number): TTileRect => ({ minX: 0, minY: 0, maxX, maxY: maxX }); function setup() { - const state = { viewport: viewport(1000), enabled: true, token: 0 }; + const state = { viewport: viewport(1000), enabled: true }; const events: string[] = []; - const batch = new Set(); + const nodes: Array> = []; + const published = new Map, boolean>(); + // The root paints first: it records the visibility published for this frame, as a batch would see it. + const recordPublished = () => { + const batch: string[] = []; + for (const node of nodes) { + const visible = node.isVisible(); + const previous = published.get(node); + if (previous !== undefined && previous !== visible) events.push(`visible:${node.data.name}:${visible}`); + published.set(node, visible); + if (visible) batch.push(node.data.name); + } + events.push(`batch:${batch.sort().join(",")}`); + }; const make = (name: string, x?: number) => { const node: Tree = new Tree({ name, @@ -15,20 +28,18 @@ function setup() { 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 => { + iterate: (prepared): boolean => { + // A node the tree did not prepare applies its own pending work first. + const allowed = prepared ? true : node.data.prepareUpdate?.() ?? true; events.push(`iterate:${name}`); - if (name === "root") events.push(`batch:${Array.from(batch).sort().join(",")}`); + if (name === "root") recordPublished(); if (node.isVisible()) events.push(`draw:${name}`); - return node.data.paint?.() ?? true; + const painted = node.data.paint?.() ?? true; + return allowed && painted; }, }); if (x !== undefined) node.setBounds(x, x, x + 10, x + 10); - if (name !== "root") batch.add(name); + if (name !== "root") nodes.push(node); return node; }; const root = make("root"); @@ -36,7 +47,6 @@ function setup() { tileSize: 64, getViewport: () => state.viewport, isEnabled: () => state.enabled, - getWalkToken: () => state.token, }); const add = (name: string, x?: number, parent = root) => { const node = make(name, x); @@ -45,8 +55,7 @@ function setup() { }; const walk = () => { events.length = 0; - state.token++; - root.traverseDown((node) => node.data.iterate()); + root.traverseDown((node, prepared, visible) => node.data.iterate(prepared, visible)); 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() }; @@ -104,14 +113,16 @@ describe("Tree full-viewport traversal", () => { 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); + // The skipped leaf stays attached and eligible for point queries. + expect(result.visited).not.toContain("leaf"); + expect(leaf.isTraversalAllowed()).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); + expect(result.visited).toContain("leaf"); state.viewport = viewport(100); state.enabled = false; @@ -119,7 +130,7 @@ describe("Tree full-viewport traversal", () => { 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); + expect(result.visited).toEqual(["root", "left", "right", "leaf"]); state.enabled = true; expect(walk().visited).toEqual(["root", "left"]); @@ -183,31 +194,6 @@ describe("Tree full-viewport traversal", () => { expectPreparedBeforeOwnPaint(result); }); - it("prepares and paints a node appended by a viewport callback in the same 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); - const result = walk(); - expect(result.drawn).toEqual(["root", "resident", "trigger", "created"]); - expect(result.events.indexOf("prepare:created")).toBeLessThan(result.events.indexOf("draw:root")); - expectPreparedBeforeOwnPaint(result); - expect(walk()).toMatchObject({ - prepared: ["root", "resident", "trigger", "created"], - drawn: ["root", "resident", "trigger", "created"], - }); - }); - it("uses final geometry and child order when a requested preparation changes a fully visible tree", () => { const { root, add, make, walk } = setup(); const early = add("early", 10); @@ -354,7 +340,7 @@ 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", () => { + it("paints a child that painting moves to a later branch from the next frame", () => { const { add, walk } = setup(); const original = add("original", 10); const child = add("child", 20, original); @@ -366,9 +352,10 @@ describe("Tree full-viewport traversal", () => { return true; }; + // Moving registers the child again; nodes registered while painting wait for the next frame. expect(walk()).toMatchObject({ - visited: ["root", "original", "child", "destination"], - drawn: ["root", "original", "child", "destination"], + visited: ["root", "original", "destination"], + drawn: ["root", "original", "destination"], }); expect(walk().drawn).toEqual(["root", "original", "destination", "child"]); }); @@ -383,7 +370,7 @@ describe("Tree full-viewport traversal", () => { const scope = root["culling"]; if (!scope) throw new Error("Missing scope"); - expect(scope.nodes.size).toBe(0); + expect(scope.nodes).toHaveLength(0); expect(scope["paintOrder"]?.nodes.some((node) => node !== root) ?? false).toBe(false); }); diff --git a/packages/graph/src/lib/Tree.denseSelection.test.ts b/packages/graph/src/lib/Tree.denseSelection.test.ts index 03eedc0a..25ed7942 100644 --- a/packages/graph/src/lib/Tree.denseSelection.test.ts +++ b/packages/graph/src/lib/Tree.denseSelection.test.ts @@ -4,13 +4,25 @@ 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 state = { viewport: { minX: 0, minY: 0, maxX: 100, maxY: 100 } as TTileRect }; const events: string[] = []; - const batch = new Set(); + const nodes: Array> = []; + const published = new Map, boolean>(); + // The root paints first: it records the visibility published for this frame, as a batch would see it. + const recordPublished = () => { + const batch: string[] = []; + for (const node of nodes) { + const visible = node.isVisible(); + const previous = published.get(node); + if (previous !== undefined && previous !== visible) events.push(`visible:${node.data.name}:${visible}`); + published.set(node, visible); + if (visible) batch.push(node.data.name); + } + events.push(`batch:${batch.sort().join(",")}`); + }; const make = (name: string, x?: number) => { const node: Tree = new Tree({ name, @@ -18,28 +30,23 @@ function setup(fullWalkShare: number) { 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: () => { + iterate: (prepared) => { + // A node the tree did not prepare applies its own pending work first. + const allowed = prepared ? true : node.data.prepareUpdate?.() ?? true; + if (name === "root") recordPublished(); events.push(`iterate:${name}`); if (node.isVisible()) events.push(`draw:${name}`); - if (name === "root") events.push(`batch:${Array.from(batch).sort().join(",")}`); - return true; + return allowed; }, }); if (x !== undefined) node.setBounds(x, x, x + 10, x + 10); - if (name !== "root") batch.add(name); + if (name !== "root") nodes.push(node); 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) => { @@ -54,8 +61,7 @@ function setup(fullWalkShare: number) { }; const walk = () => { events.length = 0; - state.token++; - root.traverseDown((node) => node.data.iterate()); + root.traverseDown((node, prepared, visible) => node.data.iterate(prepared, visible)); 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() }; @@ -77,7 +83,7 @@ describe.each([ ["sparse", 1], ] as const)("Tree %s partial selection", (_strategy, fullWalkShare) => { it("reaches a visible child through a clean offscreen ancestor without admitting its clean offscreen children", () => { - const { state, add, fill, walk } = setup(fullWalkShare); + const { add, fill, walk } = setup(fullWalkShare); const parent = add("parent", 1000); const leaf = add("leaf", 30, parent); const hiddenSibling = add("hiddenSibling", 1100, parent); @@ -91,9 +97,9 @@ describe.each([ expect(result.drawn).toEqual(["root", "leaf", "unknown", ...residents]); expect(result.visited).toEqual(["root", "leaf", "unknown", ...residents]); expectPreparedBeforeOwnPaint(result); - expect(parent.isSkippedByCulling(state.token)).toBe(true); - expect(leaf.isSkippedByCulling(state.token)).toBe(false); - expect(hiddenSibling.isSkippedByCulling(state.token)).toBe(true); + // Skipped nodes stay attached and eligible for point queries. + expect([parent, leaf, hiddenSibling].every((node) => node.isTraversalAllowed())).toBe(true); + expect(hiddenSibling.isVisible()).toBe(false); expect(unknown.isVisible()).toBe(true); }); @@ -164,93 +170,28 @@ describe.each([ }); }); - 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).not.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; - }; - trigger.requestWalk(); - } else { - trigger.data.visibility = (visible) => { - if (visible) changeViewport(); - }; - state.viewport.maxX = state.viewport.maxY = 150; - } - - // Publication repeats for the final viewport, so the trigger is drawn only if it still intersects it. - const result = walk(); - const shown = phase === "preparation" ? ["trigger", ...residents] : residents; - expect(result.prepared).not.toContain("target"); - expect(result.drawn).toEqual(["root", ...shown]); - expect(result.events).toContain(`batch:${shown.slice().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("hides descendants when a visibility callback requests their ancestor to suppress them", () => { + it("rechecks a selected node when a requested preparation changes the existing viewport object", () => { const { state, add, fill, walk } = setup(fullWalkShare); - const trigger = add("trigger", 120); - const parent = add("parent", 30); - const child = add("child", 40, parent); + const trigger = add("trigger", 10); + add("target", 40); const residents = fill(); add("outside", 1000); walk(); walk(); - let suppressed = false; - parent.data.prepare = () => !suppressed; - trigger.data.visibility = (visible) => { - if (!visible) return; - suppressed = true; - parent.requestWalk(); + trigger.data.prepare = () => { + state.viewport.maxX = state.viewport.maxY = 35; + return true; }; - state.viewport = { minX: 0, minY: 0, maxX: 150, maxY: 150 }; + trigger.requestWalk(); + // Publication reads the viewport after requested preparation. const result = walk(); - expect(result.prepared).not.toContain("child"); - expect(result.events.indexOf("prepare:parent")).toBeLessThan(result.events.indexOf("draw:root")); - expect(result.drawn).toEqual(["root", "trigger", "parent", ...residents]); - expect(result.visited).not.toContain("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); + const shown = ["trigger", ...residents]; + expect(result.prepared).not.toContain("target"); + expect(result.drawn).toEqual(["root", ...shown]); + expect(result.events).toContain(`batch:${shown.slice().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("publishes the exit of a node initially visible without a preceding visibility callback", () => { @@ -268,7 +209,7 @@ describe.each([ 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); + expect(exiting.isVisible()).toBe(false); }); it("prepares a requested offscreen update and reveals its changed bounds before any paint", () => { diff --git a/packages/graph/src/lib/Tree.invalidation.test.ts b/packages/graph/src/lib/Tree.invalidation.test.ts index eb1c8c40..974ac281 100644 --- a/packages/graph/src/lib/Tree.invalidation.test.ts +++ b/packages/graph/src/lib/Tree.invalidation.test.ts @@ -8,16 +8,12 @@ interface IProbe extends ITree { } interface IWork { - children: number; order: number; parent: number; } class CountingTree extends Tree { - constructor( - data: IProbe, - private readonly work: IWork - ) { + constructor(data: IProbe, work: IWork) { super(data); const getOrderedChildren = this.zIndexChildrenCache.get; this.zIndexChildrenCache.get = (): Tree[] => { @@ -36,19 +32,25 @@ class CountingTree extends Tree { }, }); } - - 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 state = { viewport: viewport(1000) }; const events: string[] = []; - const work: IWork = { children: 0, order: 0, parent: 0 }; + const work: IWork = { order: 0, parent: 0 }; + const nodes: CountingTree[] = []; + const published = new Map(); + // The root paints first: it records visibility transitions published for this frame. + const recordPublished = (): void => { + for (const node of nodes) { + const visible = node.isVisible(); + const previous = published.get(node); + if (previous !== undefined && previous !== visible) events.push(`visible:${node.data.name}:${visible}`); + published.set(node, visible); + } + }; const make = (name: string, x?: number): CountingTree => { const node = new CountingTree( { @@ -57,25 +59,25 @@ function setup() { events.push(`prepare:${name}`); return node.data.prepare?.() ?? true; }, - onViewportChange: (visible): void => { - events.push(`visible:${name}:${visible}`); - }, - iterate: (): boolean => { + iterate: (prepared): boolean => { + // A node the tree did not prepare applies its own pending work first. + const allowed = prepared ? true : node.data.prepareUpdate?.() ?? true; events.push(`iterate:${name}`); + if (name === "root") recordPublished(); if (node.isVisible()) events.push(`draw:${name}`); - return true; + return allowed; }, }, work ); if (x !== undefined) node.setBounds(x, x, x + 10, x + 10); + if (name !== "root") nodes.push(node); 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); @@ -84,9 +86,8 @@ function setup() { }; const walk = () => { events.length = 0; - work.children = work.order = work.parent = 0; - state.token++; - root.traverseDown((node) => node.data.iterate()); + work.order = work.parent = 0; + root.traverseDown((node, prepared, visible) => node.data.iterate(prepared, visible)); const names = (prefix: string): string[] => events.filter((event) => event.startsWith(prefix)).map((event) => event.slice(prefix.length)); return { @@ -114,7 +115,7 @@ describe("Tree geometry and structure invalidation", () => { 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.order).toBe(0); expect(result.work.parent).toBeLessThanOrEqual(4); }); @@ -144,28 +145,24 @@ describe("Tree geometry and structure invalidation", () => { 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(); + it("does not rewrite unchanged visible membership or recheck published eligibility during paint", () => { + 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 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) { + let published = node.published; + Object.defineProperty(node, "published", { + configurable: true, + get: (): boolean => published, + set: (value: boolean): void => { + work.writes++; + published = value; + }, + }); const isEligible = node.isEligible.bind(node); node.isEligible = (currentScope) => { if (currentScope.painting) work.paintChecks++; @@ -191,11 +188,15 @@ describe("Tree geometry and structure invalidation", () => { work.eligibility++; return isEligible(scope); }; - const setVisible = node.setVisible.bind(node); - node.setVisible = (visible) => { - work.publication++; - return setVisible(visible); - }; + let visible = node.published; + Object.defineProperty(node, "published", { + configurable: true, + get: (): boolean => visible, + set: (value: boolean): void => { + work.publication++; + visible = value; + }, + }); } state.viewport = viewport(466); @@ -220,7 +221,6 @@ describe("Tree geometry and structure invalidation", () => { 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()) { @@ -254,7 +254,7 @@ describe("Tree geometry and structure invalidation", () => { 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); + expect(result.visited).not.toContain("moved"); root.data.prepare = (): boolean => { moved.setBounds(40, 40, 50, 50); @@ -265,7 +265,7 @@ describe("Tree geometry and structure invalidation", () => { 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); + expect(result.visited).toContain("moved"); state.viewport = viewport(1000); expect(walk().drawn).toEqual(["root", "resident", "moved", "distant"]); diff --git a/packages/graph/src/lib/Tree.spec.ts b/packages/graph/src/lib/Tree.spec.ts index dc9da554..dae43adc 100644 --- a/packages/graph/src/lib/Tree.spec.ts +++ b/packages/graph/src/lib/Tree.spec.ts @@ -117,9 +117,56 @@ test("a scoped legacy iterator can resume child traversal after returning false" }, }) ); - root.traverseDown((node) => node.data.iterate()); + root.traverseDown((node, prepared, visible) => node.data.iterate(prepared, visible)); expect(calls).toBe(0); allow = true; - root.traverseDown((node) => node.data.iterate()); + root.traverseDown((node, prepared, visible) => node.data.iterate(prepared, visible)); expect(calls).toBe(1); }); + +describe("setChildren", () => { + const leaf = (zIndex = 1): Tree => { + const node = new Tree({ iterate: () => true }); + node.zIndex = zIndex; + return node; + }; + const order = (parent: Tree): Tree[] => parent.getOrderedChildren(); + + function scoped(): { root: Tree; revision: () => number } { + const root = new Tree({ iterate: () => true }); + root.setCulling({ tileSize: 64, getViewport: () => ({ minX: 0, minY: 0, maxX: 100, maxY: 100 }) }); + return { root, revision: () => root.scope?.structureRevision ?? -1 }; + } + + it("keeps the scope structure when a component reconciles the same children", () => { + const { root, revision } = scoped(); + const children = [leaf(), leaf(2), leaf()]; + root.setChildren(children); + const before = revision(); + root.setChildren([...children]); + expect(revision()).toBe(before); + expect(order(root)).toEqual([children[0], children[2], children[1]]); + }); + + it("rebuilds the order when the same children arrive in another order", () => { + const { root, revision } = scoped(); + const [a, b, c] = [leaf(), leaf(), leaf()]; + root.setChildren([a, b, c]); + const before = revision(); + root.setChildren([c, a, b]); + expect(revision()).toBeGreaterThan(before); + expect(order(root)).toEqual([c, a, b]); + }); + + it("restores the list order within a z-index after a child moved between z-indexes", () => { + const { root } = scoped(); + const [a, b, c] = [leaf(), leaf(), leaf()]; + root.setChildren([a, b, c]); + b.updateZIndex(2); + b.updateZIndex(1); + // The group now lists b last; reconciling the original list puts it back between a and c. + expect(order(root)).toEqual([a, c, b]); + root.setChildren([a, b, c]); + expect(order(root)).toEqual([a, b, c]); + }); +}); diff --git a/packages/graph/src/lib/Tree.subtree.test.ts b/packages/graph/src/lib/Tree.subtree.test.ts index 29b93ba1..448017ec 100644 --- a/packages/graph/src/lib/Tree.subtree.test.ts +++ b/packages/graph/src/lib/Tree.subtree.test.ts @@ -1,29 +1,30 @@ import { ITree, 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, }); const add = (parent: Tree, x?: number) => { - const node = new Tree({ prepareUpdate: () => true, iterate: () => true }); + const node: Tree = new Tree({ + prepareUpdate: () => true, + // A node the tree did not prepare applies its own pending work first. + iterate: (prepared) => (prepared ? true : node.data.prepareUpdate?.() ?? true), + }); if (x !== undefined) node.setBounds(x, x, x + 10, x + 10); parent.append(node); return node; }; const walk = () => { - token++; const visited: Tree[] = []; - root.traverseDown((node) => { + root.traverseDown((node, prepared, visible) => { if (node !== root) visited.push(node); - return node.data.iterate(); + return node.data.iterate(prepared, visible); }); return visited; }; - return { root, add, walk, token: () => token }; + return { root, add, walk }; } describe("Tree descendant visibility", () => { @@ -56,21 +57,21 @@ describe("Tree descendant visibility", () => { } ); - it("preserves point-query eligibility for untouched branches but rejects removed descendants", () => { - const { root, add, walk, token } = setup(); + it("keeps a skipped offscreen branch eligible for point queries until it is removed", () => { + const { root, add, walk } = setup(); const parent = add(root, 1000); const child = add(parent, 2000); const leaf = add(child, 3000); walk(); expect(walk()).toEqual([]); - expect(leaf.isSkippedByCulling(token())).toBe(true); - expect(leaf.isSkippedByCulling(token() - 1)).toBe(false); + expect(leaf.scope).toBe(root.scope); + expect(leaf.isTraversalAllowed()).toBe(true); parent.remove(child); - expect(leaf.isSkippedByCulling(token())).toBe(false); + expect(leaf.scope).toBeUndefined(); }); it("rejects intentionally suppressed descendants even after their ancestors are culled", () => { - const { root, add, walk, token } = setup(); + const { add, root, walk } = setup(); const parent = add(root, 1000); const child = add(parent, 2000); const leaf = add(child, 3000); @@ -79,14 +80,14 @@ describe("Tree descendant visibility", () => { child.requestWalk(); walk(); walk(); - expect(parent.isSkippedByCulling(token())).toBe(true); - expect(child.isSkippedByCulling(token())).toBe(true); - expect(leaf.isSkippedByCulling(token())).toBe(false); + expect(parent.isTraversalAllowed()).toBe(true); + expect(child.isTraversalAllowed()).toBe(true); + expect(leaf.isTraversalAllowed()).toBe(false); child.data.prepareUpdate = () => true; child.requestWalk(); walk(); walk(); - expect(leaf.isSkippedByCulling(token())).toBe(true); + expect(leaf.isTraversalAllowed()).toBe(true); }); it("reconciles reordered children without leaving stale entries", () => { diff --git a/packages/graph/src/lib/Tree.ts b/packages/graph/src/lib/Tree.ts index ca080826..63eaa774 100644 --- a/packages/graph/src/lib/Tree.ts +++ b/packages/graph/src/lib/Tree.ts @@ -1,35 +1,39 @@ import { TTreeCullingOptions, TreeCulling } from "./TreeCulling"; import { cache } from "./utils"; -export type TIterator = (node: Tree) => boolean; +/** + * Runs one node's frame. `prepared` tells whether the tree already called `prepareUpdate()` for this + * node in this traversal; `visible` is the tree's paint decision for the node's own bounds. + */ +export type TIterator = (node: Tree, prepared: boolean, visible: boolean) => 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; /** - * `iterate()` applies pending updates itself when `prepareUpdate()` has not run in this traversal. - * The tree then lets clean nodes update inside their own `iterate()`. Read once per node. + * Runs this node's frame. When `prepared` is false, `iterate()` applies its pending work itself first. + * `visible` is the tree's paint decision; the node may still veto painting. False suppresses children. */ - readonly preparesOnIterate?: boolean; + iterate(prepared: boolean, visible: boolean): boolean; + /** Applies pending work without painting; false suppresses this node's descendants. */ + prepareUpdate?(): boolean; } export class Tree { public data: T; public parent?: Tree; public children: Set = new Set(); - private childrenArray: Tree[] = []; /** @internal Parent traversal permission, independent of own viewport visibility. */ public 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) - .flatMap((index) => Array.from(this.zIndexGroups.get(index) || [])); - for (let i = 0; i < ordered.length; i++) ordered[i].renderOrder = i; + const groups = this.zIndexGroups; + const ordered: Tree[] = []; + const indexes = groups.size > 1 ? Array.from(groups.keys()).sort((a, b) => a - b) : groups.keys(); + for (const index of indexes) { + for (const node of groups.get(index) ?? []) { + node.renderOrder = ordered.length; + ordered.push(node); + } + } return ordered; }); public renderOrder = 0; @@ -40,35 +44,37 @@ export class Tree { public boundsMinY = NaN; public boundsMaxX = NaN; public boundsMaxY = NaN; - /** @internal Traversal stamp in the shared spatial scope. */ - public cullingStamp = 0; protected culling?: TreeCulling; /** @internal Shared frame coordinator for this node. */ public scope?: TreeCulling; - private visible = true; + /** @internal Viewport visibility published by the scope for the current frame. */ + public published = false; /** @internal Last traversal that prepared this node. */ public preparedStamp = 0; + /** @internal Last traversal that ran this node's iterate(). */ + public paintedStamp = 0; /** @internal Exact selection for one spatial query. */ public selectedStamp = 0; - /** @internal Membership in the scope's published visibility registry. */ - public activeInViewport = false; + /** @internal Paint order that lists this node; it marks the node eligible while that order is current. */ + public orderStamp = 0; + /** @internal Positions in the scope's node lists; -1 when absent. */ + public scopeSlot = -1; + public visibleSlot = -1; + public freshSlot = -1; + public pendingSlot = -1; + public boundsSlot = -1; private eligibilityRevision = -1; private traversalAllowed = true; - /** @internal See {@link ITree.preparesOnIterate}. */ - public readonly preparesOnIterate: boolean; - constructor(data: T, parent?: Tree) { this.data = data; this.parent = parent; - this.preparesOnIterate = data.preparesOnIterate === true; } 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); node.setScope(node.culling ?? this.scope); } @@ -98,27 +104,44 @@ export class Tree { } if (node.parent !== this) return; this.children.delete(node); - this.childrenArray = []; - this.childrenDirty = true; this.removeZIndex(node); node.setScope(undefined); node.parent = undefined; } public setChildren(nodes: Tree[]) { + // Components reconcile on every update; unchanged children must not invalidate the whole scope. + if (this.hasChildrenInOrder(nodes)) return; this.scope?.invalidateStructure(); - const next = new Set(nodes); - for (const child of this.children) { - if (!next.has(child)) this.remove(child); + if (this.children.size) { + 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; this.zIndexGroups.clear(); this.zIndexChildrenCache.clear(); for (const node of nodes) this.append(node); } + /** Whether the children are exactly `nodes`, in this order and in this order within each z-index. */ + private hasChildrenInOrder(nodes: Tree[]): boolean { + if (nodes.length !== this.children.size) return false; + let i = 0; + for (const child of this.children) { + if (child !== nodes[i++]) return false; + } + for (const [zIndex, group] of this.zIndexGroups) { + let j = 0; + for (const node of group) { + while (j < nodes.length && nodes[j].zIndex !== zIndex) j++; + if (nodes[j++] !== node) return false; + } + } + return true; + } + /** Configures a shared spatial scope for this root and all its descendants. */ public setCulling(options: TTreeCullingOptions | undefined): void { this.setScope(undefined); @@ -160,36 +183,31 @@ export class Tree { this.scope?.update(this); } + /** Asks the scope to prepare this node before the next frame publishes visibility and paints. */ public requestWalk(): void { this.scope?.markPending(this); } - /** Own bounds intersect the current exact viewport and ancestors allow traversal. */ + /** + * Own bounds intersect the viewport and ancestors allow traversal. While the scope paints this is the + * frame's published decision; otherwise it reads the current viewport and bounds. + */ public isVisible(): boolean { const scope = this.scope; if (!scope) return true; - // Update hooks need current geometry; painting shares the finalized batch selection. - const visible = - scope.painting && scope.preparingNode !== this - ? this.visible - : scope.intersects(this, scope.options.getViewport()); + const visible = scope.painting ? this.published : scope.intersects(this, scope.options.getViewport()); return ( visible && ((scope.painting && scope.visibilityRevision === scope.structureRevision) || this.isEligible(scope)) ); } - 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 { const scope = this.scope; if (scope?.walking) { if ( scope.painting && - this.visible && + this.published && this !== scope.owner && scope.visibilityRevision === scope.structureRevision ) @@ -223,19 +241,6 @@ export class Tree { this.scope?.invalidateStructure(); } - /** @internal Published viewport visibility; the paint walk visits published nodes. */ - public get publishedVisible(): boolean { - return this.visible; - } - - /** @internal */ - public setVisible(visible: boolean): boolean { - if (this.visible === visible) return false; - this.visible = visible; - this.data.onViewportChange?.(visible); - return true; - } - public updateZIndex(index: number) { if (this.zIndex === index) return; const parent = this.parent; @@ -259,8 +264,6 @@ export class Tree { 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(); } @@ -276,15 +279,6 @@ export class Tree { this.setChildrenWalkAllowed(this.data.prepareUpdate?.() ?? true); } - /** @internal Children in reconciliation order. */ - public getChildrenArray() { - if (this.childrenDirty) { - this.childrenArray = Array.from(this.children); - this.childrenDirty = false; - } - return this.childrenArray; - } - /** @internal Children in local paint order. */ public getOrderedChildren(): Tree[] { return this.zIndexChildrenCache.get(); @@ -292,7 +286,7 @@ export class Tree { protected _walkDown(iterator: TIterator, order: number) { this.renderOrder = order; - this.setChildrenWalkAllowed(iterator(this)); + this.setChildrenWalkAllowed(iterator(this, false, true)); if (!this.childrenWalkAllowed) return; const children = this.zIndexChildrenCache.get(); for (let i = 0; i < children.length; i++) { diff --git a/packages/graph/src/lib/Tree.visibilityPublication.test.ts b/packages/graph/src/lib/Tree.visibilityPublication.test.ts index 72b45693..67e19a69 100644 --- a/packages/graph/src/lib/Tree.visibilityPublication.test.ts +++ b/packages/graph/src/lib/Tree.visibilityPublication.test.ts @@ -5,13 +5,25 @@ type Probe = ITree & { name: string; prepare?: () => boolean; 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 nodes: Array> = []; + const published = new Map, boolean>(); + // The root paints first: it records the visibility published for this frame, as a batch would see it. + const recordPublished = () => { + const batch: string[] = []; + for (const node of nodes) { + const visible = node.isVisible(); + const previous = published.get(node); + if (previous !== undefined && previous !== visible) events.push(`visible:${node.data.name}:${visible}`); + published.set(node, visible); + if (visible) batch.push(node.data.name); + } + events.push(`batch:${batch.sort().join(",")}`); + }; const make = (name: string, x?: number): Tree => { const node: Tree = new Tree({ name, @@ -19,21 +31,18 @@ function setup() { 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: () => { + iterate: (prepared) => { + // A node the tree did not prepare applies its own pending work first. + const allowed = prepared ? true : node.data.prepareUpdate?.() ?? true; + if (name === "root") recordPublished(); 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; + const painted = node.data.paint?.() ?? true; + return allowed && painted; }, }); if (x !== undefined) node.setBounds(x, x, x + 5, x + 5); - if (name !== "root") batch.add(name); + if (name !== "root") nodes.push(node); return node; }; const root = make("root"); @@ -51,7 +60,7 @@ function setup() { }; const walk = () => { events.length = 0; - root.traverseDown((node) => node.data.iterate()); + root.traverseDown((node, prepared, visible) => node.data.iterate(prepared, visible)); 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() }; @@ -60,56 +69,18 @@ function setup() { } 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(); - let suppressed = false; - parent.data.prepare = () => !suppressed; - 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 { - // Components suppress children from their own update, requested like any other change. - suppressed = true; - parent.requestWalk(); - } - }; - state.viewport.minX = state.viewport.minY = 20; - - const result = walk(); - expect(result.prepared).not.toContain("exiting"); - expect(result.prepared).not.toContain("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", () => { + it("keeps a new offscreen node out of the published set after an unchanged partial viewport warms", () => { const { add, fill, walk } = setup(); const residents = fill(); walk(); walk(); - add("new", 2000); + const node = 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(node.isVisible()).toBe(false); expect(walk().prepared).not.toContain("new"); }); @@ -131,7 +102,7 @@ describe("Tree visibility publication", () => { state.viewport.maxX = state.viewport.maxY = 99; result = walk(); expect(result.drawn).toEqual(["root", "parent", "child", ...residents]); - // The veto only lived inside one paint, so the child's published membership never changed. + // The veto only lived inside one paint: the parent prepares before the next publication. expect(result.events).toContain(`batch:${["parent", "child", ...residents].sort().join(",")}`); expect(result.events).not.toContain("visible:child:false"); expect(result.events.indexOf("prepare:parent")).toBeLessThan(result.events.indexOf("draw:root")); diff --git a/packages/graph/src/lib/TreeCulling.incremental.test.ts b/packages/graph/src/lib/TreeCulling.incremental.test.ts index 6a39c3dc..9d66e249 100644 --- a/packages/graph/src/lib/TreeCulling.incremental.test.ts +++ b/packages/graph/src/lib/TreeCulling.incremental.test.ts @@ -2,32 +2,46 @@ import type { TTileRect } from "./TileIndex"; import { ITree, Tree } from "./Tree"; type Probe = ITree & { name: string; paint?: () => boolean }; +/** The private complete scan, spied on to tell it from tile queries. */ +type TScanProbe = { publishScan(viewport: TTileRect, keepBase: boolean): void }; const rect = (minX: number, minY: number, maxX: number, maxY: number): TTileRect => ({ minX, minY, maxX, maxY }); -function setup(options: { selfPreparing?: boolean } = {}) { - const state = { viewport: rect(0, 0, 100, 100), token: 0 }; +function setup() { + const state = { viewport: rect(0, 0, 100, 100) }; const events: string[] = []; + const nodes: Array> = []; + const published = new Map, boolean>(); const make = (name: string, x?: number, y = x): Tree => { const node: Tree = new Tree({ name, - preparesOnIterate: options.selfPreparing, prepareUpdate: () => { events.push(`prepare:${name}`); return true; }, - onViewportChange: (visible) => events.push(`visible:${name}:${visible}`), - iterate: () => { + iterate: (prepared) => { + // A node the tree did not prepare applies its own pending work first. + if (!prepared) node.data.prepareUpdate?.(); + if (name === "root") { + // The root paints first: record visibility published for this frame before anything draws. + for (const other of nodes) { + const visible = other.isVisible(); + const previous = published.get(other); + if (previous !== undefined && previous !== visible) events.push(`visible:${other.data.name}:${visible}`); + published.set(other, visible); + } + } events.push(`iterate:${name}`); if (node.isVisible()) events.push(`draw:${name}`); return node.data.paint?.() ?? true; }, }); if (x !== undefined && y !== undefined) node.setBounds(x, y, x + 10, y + 10); + if (name !== "root") nodes.push(node); return node; }; const root = make("root"); - root.setCulling({ tileSize: 32, getViewport: () => state.viewport, getWalkToken: () => state.token }); + root.setCulling({ tileSize: 32, getViewport: () => state.viewport }); const add = (name: string, x: number, y = x, parent: Tree = root): Tree => { const node = make(name, x, y); parent.append(node); @@ -35,8 +49,7 @@ function setup(options: { selfPreparing?: boolean } = {}) { }; const walk = () => { events.length = 0; - state.token++; - root.traverseDown((node) => node.data.iterate()); + root.traverseDown((node, prepared, visible) => node.data.iterate(prepared, visible)); const names = (prefix: string): 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() }; @@ -102,7 +115,7 @@ describe("TreeCulling incremental visibility", () => { .sort() ).toEqual(expected()); } - expect(root["culling"]?.nodes.size).toBe(400); + expect(root["culling"]?.nodes).toHaveLength(400); }); it("rechecks only nodes near the viewport edges when the camera pans", () => { @@ -116,7 +129,7 @@ describe("TreeCulling incremental visibility", () => { const scope = root["culling"]; if (!scope) throw new Error("Missing scope"); const query = jest.spyOn(scope.index, "query"); - const linearScan = jest.spyOn(scope.nodes, Symbol.iterator); + const linearScan = jest.spyOn(scope as unknown as TScanProbe, "publishScan"); const checked = new Set(); const intersects = jest.spyOn(scope, "intersects").mockImplementation(function (this: unknown, node, viewport) { checked.add(node); @@ -144,20 +157,24 @@ describe("TreeCulling incremental visibility", () => { intersects.mockRestore(); }); - it("lets self-preparing nodes update inside iterate unless an update was requested", () => { - const { add, walk } = setup({ selfPreparing: true }); + it("lets clean visible nodes prepare inside iterate and requested nodes before painting", () => { + const { root, add, walk } = setup(); const first = add("first", 10); add("second", 30); add("outside", 1000); walk(); + const prepare = jest.fn(root.data.prepareUpdate); + root.data.prepareUpdate = prepare; let result = walk(); - expect(result.prepared).toEqual(["root"]); - expect(result.drawn).toEqual(["root", "first", "second"]); + expect(result.prepared).toEqual(["root", "first", "second"]); + expect(result.events.indexOf("prepare:first")).toBeGreaterThan(result.events.indexOf("draw:root")); + expect(prepare).toHaveBeenCalledTimes(1); first.requestWalk(); result = walk(); - expect(result.prepared).toEqual(["root", "first"]); + expect(result.prepared).toEqual(["root", "first", "second"]); expect(result.events.indexOf("prepare:first")).toBeLessThan(result.events.indexOf("draw:root")); + expect(result.events.filter((event) => event === "prepare:first")).toHaveLength(1); }); it("paints children that a clean node adds while painting from the next frame", () => { diff --git a/packages/graph/src/lib/TreeCulling.test.ts b/packages/graph/src/lib/TreeCulling.test.ts index f828fe26..feb1e659 100644 --- a/packages/graph/src/lib/TreeCulling.test.ts +++ b/packages/graph/src/lib/TreeCulling.test.ts @@ -1,9 +1,9 @@ -import { BatchPath2DRenderer } from "../components/canvas/connections/BatchPath2D"; - import type { TTileRect } from "./TileIndex"; import { ITree, Tree } from "./Tree"; type TNodeData = ITree & { name: string }; +/** The private complete scan, spied on to tell it from tile queries. */ +type TScanProbe = { publishScan(viewport: TTileRect, keepBase: boolean): void }; const rect = (x: number, y = x, size = 10): TTileRect => ({ minX: x, minY: y, maxX: x + size, maxY: y + size }); function setup() { @@ -11,33 +11,44 @@ function setup() { const drawn: string[] = []; const visited: string[] = []; const events: string[] = []; - const state: { viewport: TTileRect | undefined; token: number } = { viewport: rect(0, 0, 100), token: 0 }; + const state: { viewport: TTileRect | undefined } = { viewport: rect(0, 0, 100) }; + const nodes: Array> = []; + const published = new Map, boolean>(); + const recordPublished = () => { + for (const node of nodes) { + const visible = node.isVisible(); + const previous = published.get(node); + if (previous !== undefined && previous !== visible) events.push(`visible:${node.data.name}:${visible}`); + published.set(node, visible); + } + }; const make = (name: string, x?: number) => { - const node = new Tree({ + const node: Tree = new Tree({ name, prepareUpdate: () => { prepared.push(name); events.push(`prepare:${name}`); return true; }, - onViewportChange: (visible) => { - events.push(`visible:${name}:${visible}`); - }, - iterate: () => { + iterate: (isPrepared) => { + // A node the tree did not prepare applies its own pending work first. + const allowed = isPrepared ? true : node.data.prepareUpdate?.() ?? true; + // The root paints first: record visibility published for this frame before anything draws. + if (name === "root") recordPublished(); visited.push(name); if (node.isVisible()) { drawn.push(name); events.push(`draw:${name}`); } - return true; + return allowed; }, }); if (x !== undefined) node.setBounds(x, x, x + 10, x + 10); + if (name !== "root") nodes.push(node); return node; }; const root = make("root"); - const configure = () => - root.setCulling({ tileSize: 64, getViewport: () => state.viewport, getWalkToken: () => state.token }); + const configure = () => root.setCulling({ tileSize: 64, getViewport: () => state.viewport }); configure(); const add = (name: string, x?: number, parent = root) => { const node = make(name, x); @@ -46,8 +57,7 @@ function setup() { }; const walk = () => { prepared.length = drawn.length = visited.length = events.length = 0; - state.token++; - root.traverseDown((node) => node.data.iterate()); + root.traverseDown((node, isPrepared, visible) => node.data.iterate(isPrepared, visible)); return { prepared: prepared.slice(), drawn: drawn.slice(), visited: visited.slice(), events: events.slice() }; }; return { root, state, add, make, walk, configure }; @@ -149,7 +159,7 @@ describe("Tree native spatial scope", () => { expect(walk().drawn).toEqual(["root", "out"]); }); - it("reads live visibility in update hooks without publishing intermediate transitions", () => { + it("reads live visibility in hooks that run before painting without publishing intermediate transitions", () => { const { state, add, walk } = setup(); const node = add("node", 300); walk(); @@ -161,6 +171,7 @@ describe("Tree native spatial scope", () => { node.setBounds(300, 300, 310, 310); return true; }; + node.requestWalk(); state.viewport = rect(250, 250, 100); const result = walk(); @@ -191,7 +202,6 @@ describe("Tree native spatial scope", () => { walk(); state.viewport = undefined; expect(walk()).toMatchObject({ prepared: ["root", "parent", "child"], drawn: ["root", "parent", "child"] }); - expect(parent.isSkippedByCulling(state.token)).toBe(false); }); it("disables traversal filtering without allowing offscreen paint", () => { @@ -243,35 +253,6 @@ describe("Tree native spatial scope", () => { 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); @@ -306,7 +287,7 @@ describe("Tree native spatial scope", () => { 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); + const linearScan = jest.spyOn(scope as unknown as TScanProbe, "publishScan"); // An unchanged frame keeps the published selection without any selection work. expect(walk().drawn).toEqual(["root"]); @@ -345,7 +326,7 @@ describe("Tree native spatial scope", () => { const scope = root["culling"]; if (!scope) throw new Error("Missing root scope"); let query = jest.spyOn(scope.index, "query"); - let linearScan = jest.spyOn(scope.nodes, Symbol.iterator); + let linearScan = jest.spyOn(scope as unknown as TScanProbe, "publishScan"); for (const [viewport, usesQuery] of [ [rect(100, 100, 100), true], @@ -358,7 +339,7 @@ describe("Tree native spatial scope", () => { query.mockRestore(); linearScan.mockRestore(); query = jest.spyOn(current.index, "query"); - linearScan = jest.spyOn(current.nodes, Symbol.iterator); + linearScan = jest.spyOn(current as unknown as TScanProbe, "publishScan"); const visible = new Set( Array.from(positions.entries()) .filter( @@ -415,9 +396,9 @@ describe("Tree native spatial scope", () => { 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(scope["pending"].filter(Boolean)).toEqual([]); + expect(scope["fresh"].filter(Boolean)).toEqual([]); + expect(scope["visible"]).toEqual([]); expect(walk().visited).toEqual(["root"]); root.append(parent); expect(walk().visited).toEqual(["root", "parent", "child", "fresh"]); diff --git a/packages/graph/src/lib/TreeCulling.ts b/packages/graph/src/lib/TreeCulling.ts index 2888dbd2..7d0ca61c 100644 --- a/packages/graph/src/lib/TreeCulling.ts +++ b/packages/graph/src/lib/TreeCulling.ts @@ -8,12 +8,16 @@ export type TTreeCullingOptions = { 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; - /** Candidate share above which a full selection scans nodes instead of querying tiles. @default 0.75 */ + /** Candidate share above which a complete selection scans nodes instead of querying tiles. @default 0.75 */ fullWalkShare?: number; }; +/** Eligible nodes in local paint order; `ends[i]` is the end of node i's subtree. */ +type TPaintOrder = { revision: number; stamp: number; nodes: Tree[]; ends: number[] }; + +/** A work list in insertion order; nodes that leave it leave a hole until the list is compacted. */ +type TWorkList = Array; + function sameRect(a: TTileRect, b: TTileRect): boolean { return a.minX === b.minX && a.minY === b.minY && a.maxX === b.maxX && a.maxY === b.maxY; } @@ -21,29 +25,40 @@ function sameRect(a: TTileRect, b: TTileRect): boolean { /** * Owns preparation, viewport visibility and painting for one configured Tree root. * - * A frame prepares the owner, new and requested nodes, publishes viewport visibility and then walks - * the tree once. A visited node that is still clean prepares right before its own paint, so a frame - * costs the visible nodes plus the work that changed. Visibility is maintained between frames: a - * camera move rechecks only nodes in the parts of the old and new viewports outside each other. + * A frame has three steps: + * 1. The owner, new nodes and nodes that requested an update prepare, ancestors first. + * 2. Visibility is published. It is kept between frames: a camera move rechecks only nodes in the + * parts of the old and new viewports outside each other, plus nodes with new bounds. A structural + * change runs one complete selection. + * 3. One pass over the cached local paint order runs `iterate()` for visible and prepared nodes. A + * clean visible node applies its own pending work there. Clean offscreen nodes get no call, and + * their visible descendants are still reached through them. + * + * A frame therefore costs the visible nodes plus the work that changed. Node sets are arrays indexed + * by slots stored on the nodes, so registering, publishing and preparing a node costs no hashing. */ 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; + private static orderStamp = 0; public readonly index: TileIndex; - public readonly nodes = new Set(); + /** Registered nodes except the owner, at `Tree.scopeSlot`. */ + public readonly nodes: Tree[] = []; - private readonly pending = new Set(); + /** Nodes published as visible, at `Tree.visibleSlot`. */ + private readonly visible: Tree[] = []; - private readonly fresh = new Set(); + /** Nodes not prepared since they were registered, at `Tree.freshSlot`. */ + private readonly fresh: TWorkList = []; - /** Nodes published as visible. */ - private readonly active = new Set(); + /** Nodes that requested preparation, at `Tree.pendingSlot`. */ + private readonly pending: TWorkList = []; - /** Nodes whose bounds changed since visibility was last published. */ - private readonly boundsDirty = new Set(); + /** Nodes whose bounds changed since visibility was last published, at `Tree.boundsSlot`. */ + private readonly boundsDirty: TWorkList = []; /** Any change that can affect this frame's preparation: membership, order, permission or bounds. */ private revision = 0; @@ -66,30 +81,21 @@ export class TreeCulling { /** Structure revision of the last publication that made every eligible node visible. */ private allVisibleRevision = -1; - /** Viewport of the current frame's paint gate; undefined when every eligible node is visible. */ - private frameViewport?: TTileRect; - - /** Culling is disabled: every eligible node is visited while the paint gate stays exact. */ + /** Culling is disabled: every eligible node runs iterate() while the paint gate stays exact. */ private visitAll = false; /** Nodes prepared ahead of painting in the current frame. */ private prepared: Tree[] = []; - private paintOrder?: { revision: number; nodes: Tree[]; ends: number[] }; + /** How many of them were painted; the rest finish their lifecycle without painting. */ + private preparedPainted = 0; + + private paintOrder?: TPaintOrder; private readonly visitList: Tree[] = []; private readonly visitPositions: number[] = []; - private selectionStamp = 0; - - /** Counts update requests and registrations, so publication can tell whether callbacks added work. */ - private requests = 0; - - private walkToken: unknown; - - private didCull = false; - constructor( public readonly owner: Tree, public readonly options: TTreeCullingOptions @@ -106,34 +112,39 @@ export class TreeCulling { // An ancestor outside this scope can change its traversal permission independently. if (this.owner.parent) this.invalidateStructure(); try { - this.runFrame(iterator); - } finally { - this.walking = wasWalking; - this.painting = wasPainting; this.prepared = []; - } - } - - private runFrame(iterator: TIterator): void { - this.prepared = []; - this.prepare(this.owner); - this.walkToken = this.options.getWalkToken?.(); - for (let attempt = 0; ; attempt++) { + this.preparedPainted = 0; + this.prepare(this.owner); let revision: number; do { revision = this.revision; - for (const node of this.fresh) this.prepare(node); - for (const node of this.pending) this.prepare(node); - // Reconciliation and geometry updates can register or request more nodes in this same frame. + // Reconciliation and geometry updates can register or request more nodes in this same frame; + // the lists grow while they are read. + const fresh = this.fresh; + for (let i = 0; i < fresh.length; i++) { + const node = fresh[i]; + if (node) this.prepare(node); + } + const pending = this.pending; + for (let i = 0; i < pending.length; i++) { + const node = pending[i]; + if (node) this.prepare(node); + } } while (revision !== this.revision); - const requests = this.requests; - this.publish(); - // Visibility callbacks can request updates or register nodes; those are applied before painting too. - if (requests === this.requests || attempt >= 3) break; + // Only nodes whose preparation an ancestor blocked are left. + this.compactFresh(); + this.compactPending(); + // The structure is final for this frame: the paint order also tells publication which nodes are eligible. + const order = this.getPaintOrder(); + this.publish(order.stamp); + this.painting = true; + this.paint(iterator, order); + if (this.preparedPainted !== this.prepared.length) this.finishPrepared(iterator); + } finally { + this.walking = wasWalking; + this.painting = wasPainting; + this.prepared = []; } - this.painting = true; - this.paint(iterator); - this.finishPrepared(iterator); } /** Prepares a node after its ancestors, unless an ancestor forbids walking its branch. */ @@ -146,119 +157,150 @@ export class TreeCulling { if (parent.preparedStamp !== this.stamp || !parent.childrenWalkAllowed || node.scope !== this) return; } this.prepared.push(node); - this.prepareNode(node); - } - - private prepareNode(node: Tree): void { - if (this.pending.size) this.pending.delete(node); - if (this.fresh.size) this.fresh.delete(node); + if (node.pendingSlot >= 0) { + this.pending[node.pendingSlot] = undefined; + node.pendingSlot = -1; + } + if (node.freshSlot >= 0) { + this.fresh[node.freshSlot] = undefined; + node.freshSlot = -1; + } node.prepareData(this.stamp); } - /** - * Publishes visibility for the current viewport before painting. Visibility callbacks can move nodes, - * change the structure or the viewport itself; publication repeats until it is stable. - */ - private publish(): void { - for (let attempt = 0; ; attempt++) { - // Viewport sources may reuse one object; callbacks must not change the rect being published. - const source = this.options.getViewport(); - const viewport = source && { ...source }; - const structure = this.structureRevision; - this.publishFor(viewport); - this.settleBounds(); - const current = this.options.getViewport(); - const stable = - structure === this.structureRevision && - (current && viewport ? sameRect(current, viewport) : !current && !viewport); - if (stable || attempt >= 3) { - if (structure === this.structureRevision) this.visibilityRevision = structure; - return; - } + private compactFresh(): void { + const list = this.fresh; + let size = 0; + for (let i = 0; i < list.length; i++) { + const node = list[i]; + if (!node) continue; + node.freshSlot = size; + list[size++] = node; } + list.length = size; } - /** Rechecks nodes whose bounds changed during publication, for example from visibility callbacks. */ - private settleBounds(): void { - const viewport = this.frameViewport; - if (viewport) { - // Set iteration also reaches nodes that callbacks add while it runs. - for (const node of this.boundsDirty) { - if (node.scope === this) this.show(node, node.isEligible(this) && this.intersects(node, viewport)); - } + private compactPending(): void { + const list = this.pending; + let size = 0; + for (let i = 0; i < list.length; i++) { + const node = list[i]; + if (!node) continue; + node.pendingSlot = size; + list[size++] = node; } - this.boundsDirty.clear(); + list.length = size; } - private publishFor(viewport: TTileRect | undefined): void { + private publish(eligible: number): void { + // Bounds can change several times per frame, for example while components mount; index them once. + const dirty = this.boundsDirty; + for (let i = 0; i < dirty.length; i++) { + const node = dirty[i]; + if (node) this.index.set(node, node.boundsMinX, node.boundsMinY, node.boundsMaxX, node.boundsMaxY); + } + // Viewport sources may reuse one object; publication works on a stable copy. + const source = this.options.getViewport(); + const viewport = source && { ...source }; this.visibilityRevision = -1; this.visitAll = false; - this.didCull = Boolean(viewport && (this.options.isEnabled?.() ?? true)); // New descendants whose preparation an ancestor blocked stay hidden. - for (const node of this.fresh) node.setVisible(false); + const fresh = this.fresh; + for (let i = 0; i < fresh.length; i++) { + const node = fresh[i]; + if (node) this.show(node, false); + } if (!viewport || this.index.covers(viewport)) { - this.frameViewport = undefined; - this.publishAll(); - } else { - this.frameViewport = viewport; - if (!this.didCull) { - this.visitAll = true; - this.publishExact(this.select(viewport, true), viewport); - } else if (!this.publishDelta(viewport)) { - this.publishExact(this.select(viewport), viewport); - } + this.publishAll(eligible); + } else if (!(this.options.isEnabled?.() ?? true)) { + this.visitAll = true; + this.publishScan(viewport, eligible, false); + } else if (!this.publishDelta(viewport, eligible)) { + this.publishSelection(viewport, eligible); + } + for (let i = 0; i < dirty.length; i++) { + const node = dirty[i]; + if (node) node.boundsSlot = -1; } - // The owner is not indexed: its own bounds are always checked against the real viewport. - this.owner.setVisible(this.owner.isEligible(this) && this.intersects(this.owner, viewport)); + dirty.length = 0; + // The owner is not indexed: its own bounds are always checked against the viewport. + this.owner.published = this.owner.isEligible(this) && this.intersects(this.owner, viewport); + this.visibilityRevision = this.structureRevision; } private show(node: Tree, visible: boolean): void { - node.setVisible(visible); - if (node.activeInViewport !== visible && node.scope === this) { - node.activeInViewport = visible; - if (visible) this.active.add(node); - else this.active.delete(node); + if (node.published === visible) return; + node.published = visible; + const list = this.visible; + if (visible) { + node.visibleSlot = list.length; + list.push(node); + return; } + const slot = node.visibleSlot; + if (slot < 0) return; + const last = list.pop() as Tree; + if (last !== node) { + list[slot] = last; + last.visibleSlot = slot; + } + node.visibleSlot = -1; } - /** The viewport contains every indexed node: eligibility alone decides visibility. */ - private publishAll(): void { + /** + * The viewport contains every indexed node: eligibility alone decides visibility. `eligible` is the + * stamp of the current paint order, which lists exactly the eligible nodes. + */ + private publishAll(eligible: number): void { this.selectionBase = undefined; - this.boundsDirty.clear(); - const structure = this.structureRevision; - if (this.allVisibleRevision === structure) return; - for (const node of this.nodes) this.show(node, node.isEligible(this)); - this.allVisibleRevision = this.structureRevision === structure ? structure : -1; + if (this.allVisibleRevision === this.structureRevision) return; + const nodes = this.nodes; + for (let i = 0; i < nodes.length; i++) this.show(nodes[i], nodes[i].orderStamp === eligible); + this.allVisibleRevision = this.structureRevision; } - /** Publishes a complete selection: candidates become visible, every other published node hides. */ - private publishExact(candidates: Tree[], viewport: TTileRect): void { + /** Publishes a complete selection by checking every node: for dense views and disabled culling. */ + private publishScan(viewport: TTileRect, eligible: number, keepBase: boolean): void { this.allVisibleRevision = -1; - // The selection already used current bounds; later changes are settled after publication. - this.boundsDirty.clear(); - const selection = this.selectionStamp; - for (const node of this.active) { - if (node.selectedStamp !== selection) this.show(node, false); + const nodes = this.nodes; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + this.show(node, node.orderStamp === eligible && this.intersects(node, viewport)); } - // Prepared nodes outside the selection publish their exit or their initial hidden state. - for (const node of this.prepared) { - if (node !== this.owner && node.scope === this && node.selectedStamp !== selection) this.show(node, false); + this.selectionBase = keepBase ? { ...viewport, structure: this.structureRevision } : undefined; + } + + /** Publishes a complete selection through the tiles, or by a scan when most nodes are candidates. */ + private publishSelection(viewport: TTileRect, eligible: number): void { + const limit = this.nodes.length * (this.options.fullWalkShare ?? 0.75); + if (this.index.countCandidates(viewport, limit) > limit) { + this.publishScan(viewport, eligible, true); + return; } - for (const node of candidates) { - if (node.scope === this) this.show(node, node.isEligible(this)); + this.allVisibleRevision = -1; + const stamp = ++TreeCulling.selectionStamp; + this.index.query(viewport, (node) => { + node.selectedStamp = stamp; + if (node.scope === this) this.show(node, node.orderStamp === eligible); + }); + // Hiding moves the last entry into the freed slot, so the list is read from the end. + const visible = this.visible; + for (let i = visible.length - 1; i >= 0; i--) { + const node = visible[i]; + if (node.selectedStamp !== stamp) this.show(node, false); } - this.selectionBase = this.didCull ? { ...viewport, structure: this.structureRevision } : undefined; + this.selectionBase = { ...viewport, structure: this.structureRevision }; } /** * Republishes only nodes that can change visibility since the last exact publication: nodes in the - * parts of the old and new viewports outside each other, nodes with new bounds and prepared nodes. + * parts of the old and new viewports outside each other, and nodes with new bounds. */ - private publishDelta(viewport: TTileRect): boolean { + private publishDelta(viewport: TTileRect, eligible: number): boolean { const base = this.selectionBase; if (!base || base.structure !== this.structureRevision) return false; const check = (node: Tree): void => { - if (node.scope === this) this.show(node, node.isEligible(this) && this.intersects(node, viewport)); + if (node.scope === this) this.show(node, node.orderStamp === eligible && this.intersects(node, viewport)); }; if (!sameRect(base, viewport)) { this.queryOutside(viewport, base, check); @@ -268,8 +310,10 @@ export class TreeCulling { base.maxX = viewport.maxX; base.maxY = viewport.maxY; } - for (const node of this.prepared) { - if (node !== this.owner) check(node); + const dirty = this.boundsDirty; + for (let i = 0; i < dirty.length; i++) { + const node = dirty[i]; + if (node) check(node); } return true; } @@ -292,37 +336,51 @@ export class TreeCulling { if (outer.maxX > inner.maxX) this.index.query({ minX: inner.maxX, minY, maxX: outer.maxX, maxY }, visit); } - /** Local paint order of eligible nodes with the end of each subtree; rebuilt after structural changes. */ - private getPaintOrder(): { revision: number; nodes: Tree[]; ends: number[] } { + /** + * Local paint order of eligible nodes with the end of each subtree; rebuilt after structural changes. + * A child reached from a node that allows walking its children is eligible if it is in this scope; + * listed nodes carry the order's stamp. + */ + private getPaintOrder(): TPaintOrder { let order = this.paintOrder; if (order?.revision !== this.structureRevision) { + const stamp = ++TreeCulling.orderStamp; const nodes: Tree[] = []; const ends: number[] = []; const collect = (node: Tree): void => { - if (node !== this.owner && !node.isEligible(this)) return; const index = nodes.length; + node.orderStamp = stamp; nodes.push(node); ends.push(0); if (node.childrenWalkAllowed && node.children.size) { - for (const child of node.getOrderedChildren()) { - if (child.parent === node) collect(child); + const children = node.getOrderedChildren(); + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.parent !== node || child.scope !== this) continue; + if (child.childrenWalkAllowed && child.children.size) { + collect(child); + } else { + // Most nodes are leaves: list them without another call. + child.orderStamp = stamp; + nodes.push(child); + ends.push(nodes.length); + } } } ends[index] = nodes.length; }; collect(this.owner); - this.paintOrder = order = { revision: this.structureRevision, nodes, ends }; + this.paintOrder = order = { revision: this.structureRevision, stamp, nodes, ends }; } return order; } /** - * Paints in local z-order. The first loop picks published, prepared or (with culling disabled) all - * eligible nodes; clean offscreen nodes pass through to their descendants. The second loop runs - * iterate() only for picked nodes, so the per-node path never sees a culled node. + * Paints in local z-order. The first loop picks visible and prepared nodes (or every eligible node + * while culling is disabled); clean offscreen nodes pass through to their descendants. The second + * loop runs iterate() only for picked nodes, so the per-node path never sees a culled node. */ - private paint(iterator: TIterator): void { - const { nodes, ends } = this.getPaintOrder(); + private paint(iterator: TIterator, { nodes, ends }: TPaintOrder): void { const list = this.visitList; const positions = this.visitPositions; list.length = 0; @@ -332,7 +390,7 @@ export class TreeCulling { const visitAll = this.visitAll; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; - if (node !== owner && !visitAll && node.preparedStamp !== stamp && !node.publishedVisible) { + if (node !== owner && !visitAll && !node.published && node.preparedStamp !== stamp) { if (!node.childrenWalkAllowed) i = ends[i] - 1; continue; } @@ -341,24 +399,21 @@ export class TreeCulling { } for (let k = 0; k < list.length; k++) { const node = list[k]; - // Painting can remove a node; nodes it adds are painted from the next frame. - if (node !== owner && node.scope !== this) continue; - if (node.preparedStamp !== stamp) { - // A component applies its own pending work inside iterate(); other nodes prepare here. - if (node.preparesOnIterate) node.preparedStamp = stamp; - else this.prepareLate(node); - } - node.cullingStamp = stamp; - let allowed = iterator(node); + // Painting can remove or re-register a node; nodes it adds or moves are painted from the next frame. + if (node !== owner && (node.scope !== this || node.freshSlot >= 0)) continue; + const prepared = node.preparedStamp === stamp; + if (prepared) this.preparedPainted++; + node.preparedStamp = stamp; + node.paintedStamp = stamp; + let allowed = iterator(node, prepared, node.published); if (allowed && !node.childrenWalkAllowed) { - // A component's own update can resume its children; another node's preparation suppressed them. - if (node.preparesOnIterate) node.setChildrenWalkAllowed(true); - else allowed = false; + // A node's own update can resume its children; a preparation that suppressed them still applies. + if (prepared) allowed = false; + else node.setChildrenWalkAllowed(true); } if (!allowed) { - // A paint-time veto of a node that does not prepare itself is decided again by its next preparation, - // before the next publication. - if (node.childrenWalkAllowed && !node.preparesOnIterate) this.markPending(node); + // A veto from painting is decided again by the node's next preparation, before that publication. + if (node.childrenWalkAllowed) this.markPending(node); node.setChildrenWalkAllowed(false); const end = ends[positions[k]]; while (k + 1 < list.length && positions[k + 1] < end) k++; @@ -366,37 +421,22 @@ export class TreeCulling { } } - /** Node whose update hooks run inside painting; it reads live visibility like any update hook. */ - public preparingNode?: Tree; - - /** Prepares a clean node that does not prepare itself, right before its own paint. */ - private prepareLate(node: Tree): void { - const revision = this.revision; - this.preparingNode = node; - try { - this.prepareNode(node); - } finally { - this.preparingNode = undefined; - } - // New bounds decide this node's own paint gate. - if (revision !== this.revision) this.show(node, node.isEligible(this) && this.intersects(node, this.frameViewport)); - } - /** A parent can suppress children while painting; finish their already prepared lifecycles. */ private finishPrepared(iterator: TIterator): void { for (const node of this.prepared) { - if (node.cullingStamp === this.stamp || !node.data.prepareUpdate || node.scope !== this) continue; + if (node.paintedStamp === this.stamp || !node.data.prepareUpdate || node.scope !== this) continue; this.show(node, false); - node.cullingStamp = this.stamp; - if (!iterator(node)) node.setChildrenWalkAllowed(false); + node.paintedStamp = this.stamp; + if (!iterator(node, true, false)) node.setChildrenWalkAllowed(false); } } public add(node: Tree): void { - if (node === this.owner || this.nodes.has(node)) return; - this.nodes.add(node); - this.fresh.add(node); - this.requests++; + if (node === this.owner || node.scopeSlot >= 0) return; + node.scopeSlot = this.nodes.length; + this.nodes.push(node); + node.freshSlot = this.fresh.length; + this.fresh.push(node); this.update(node); this.invalidateStructure(); } @@ -407,31 +447,39 @@ export class TreeCulling { } public update(node: Tree): void { - if (!this.nodes.has(node)) return; - this.boundsDirty.add(node); - this.index.set(node, node.boundsMinX, node.boundsMinY, node.boundsMaxX, node.boundsMaxY); + if (node.scopeSlot < 0) return; + if (node.boundsSlot < 0) { + node.boundsSlot = this.boundsDirty.length; + this.boundsDirty.push(node); + } this.revision++; } public delete(node: Tree): void { this.paintOrder = undefined; this.index.delete(node); - this.nodes.delete(node); - this.pending.delete(node); - this.fresh.delete(node); - this.active.delete(node); - this.boundsDirty.delete(node); - node.activeInViewport = false; + const slot = node.scopeSlot; + if (slot >= 0) { + const last = this.nodes.pop() as Tree; + if (last !== node) { + this.nodes[slot] = last; + last.scopeSlot = slot; + } + node.scopeSlot = -1; + } + this.show(node, false); + node.published = false; + if (node.freshSlot >= 0) this.fresh[node.freshSlot] = undefined; + if (node.pendingSlot >= 0) this.pending[node.pendingSlot] = undefined; + if (node.boundsSlot >= 0) this.boundsDirty[node.boundsSlot] = undefined; + node.freshSlot = node.pendingSlot = node.boundsSlot = -1; this.invalidateStructure(); } public markPending(node: Tree): void { - this.pending.add(node); - this.requests++; - } - - public isSkipped(node: Tree, token: unknown): boolean { - return this.didCull && this.walkToken === token && this.nodes.has(node) && node.cullingStamp !== this.stamp; + if (node.pendingSlot >= 0) return; + node.pendingSlot = this.pending.length; + this.pending.push(node); } public intersects(node: Tree, viewport: TTileRect | undefined): boolean { @@ -444,25 +492,4 @@ export class TreeCulling { node.boundsMaxY >= viewport.minY) ); } - - /** Marks and returns every node whose bounds intersect the viewport. */ - public select(viewport: TTileRect | undefined, linear = false): Tree[] { - const selected: Tree[] = []; - const stamp = (this.selectionStamp = ++TreeCulling.selectionStamp); - const limit = this.nodes.size * (this.options.fullWalkShare ?? 0.75); - if (linear || !viewport || this.index.covers(viewport) || this.index.countCandidates(viewport, limit) > limit) { - for (const node of this.nodes) { - if (this.intersects(node, viewport)) { - node.selectedStamp = stamp; - selected.push(node); - } - } - } else { - this.index.query(viewport, (node) => { - node.selectedStamp = stamp; - selected.push(node); - }); - } - return selected; - } } diff --git a/packages/graph/src/services/visibilityCulling.ts b/packages/graph/src/services/visibilityCulling.ts index a0b94410..e340b8ef 100644 --- a/packages/graph/src/services/visibilityCulling.ts +++ b/packages/graph/src/services/visibilityCulling.ts @@ -7,10 +7,7 @@ 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 { +export function createViewportCulling(graph: Graph, getCamera: () => ICamera): TTreeCullingOptions { const viewport: TTileRect = { minX: 0, minY: 0, maxX: 0, maxY: 0 }; return { tileSize: graph.graphConstants.system.VISIBILITY_TILE_SIZE, From 266505b8047d8f8595b424f0071bb5c8901ef59b Mon Sep 17 00:00:00 2001 From: draedful Date: Sat, 24 Oct 2026 14:29:22 +0300 Subject: [PATCH 16/16] chore(bench): add a culling benchmark for graph versions node scripts/bench/culling/run.mjs builds several versions of packages/graph (git refs or the working tree) and measures GraphLayer update time while panning and zooming the Nirvana and Playground stress scenes, plus the first two frames after mounting, and compares their screenshots. Co-Authored-By: Claude Opus 5.5 --- package.json | 1 + scripts/bench/culling/README.md | 30 ++++ scripts/bench/culling/entry.ts | 300 ++++++++++++++++++++++++++++++++ scripts/bench/culling/run.mjs | 222 +++++++++++++++++++++++ 4 files changed, 553 insertions(+) create mode 100644 scripts/bench/culling/README.md create mode 100644 scripts/bench/culling/entry.ts create mode 100644 scripts/bench/culling/run.mjs diff --git a/package.json b/package.json index 5d0c43eb..d862f762 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:e2e:debug": "pnpm --filter @gravity-ui/graph-e2e run test:e2e:debug", "test:e2e:ci": "pnpm --filter @gravity-ui/graph-e2e run test:e2e:ci", "test:package-contract": "node scripts/package-contract/run.mjs", + "bench:culling": "node scripts/bench/culling/run.mjs", "e2e:dev": "pnpm --filter @gravity-ui/graph-e2e run e2e:dev", "test:type-configs": "node --test scripts/css-assets.test.mjs scripts/style-observer-types.test.mjs scripts/tsconfig-strict.test.mjs" }, diff --git a/scripts/bench/culling/README.md b/scripts/bench/culling/README.md new file mode 100644 index 00000000..1b144172 --- /dev/null +++ b/scripts/bench/culling/README.md @@ -0,0 +1,30 @@ +# Culling benchmark + +Measures the main-thread duration of one GraphLayer update while panning and zooming large scenes, +for several versions of `packages/graph` side by side, and checks that they draw the same pixels. + +- **Scenes:** `nirvana` mirrors the Storybook `NirvanaMaxGraphTest` (13,916 blocks, bezier connections + with arrows, no anchors); `playground` mirrors the Playground "10 000" preset (10,476 action blocks with + two anchors each, straight connections). Both are generated with fixed seeds. +- **Views:** `near` is 1600x1000 CSS px, `full` is 1600x1600; DPR 2. +- **Variants:** `name=ref`, where `ref` is a git ref or `.` for the working tree. The first variant is the + pixel reference. +- Every variant runs the same zoom history before measuring: the hot path is sensitive to JIT feedback. + +```bash +node scripts/bench/culling/run.mjs --variants base=origin/v2,current=. --scales 0.01,0.02,0.05,0.1 +node scripts/bench/culling/run.mjs --scenes nirvana --views near,full --scales 0.01 --repeats 5 +node scripts/bench/culling/run.mjs --first --repeats 4 +``` + +Options: `--scenes`, `--scales`, `--views`, `--modes pan,zoom`, `--repeats`, `--steps`, `--noop` (replace +fill/stroke calls with no-ops to isolate JavaScript cost), `--first` (only the first two frames after +mounting), `--shots

` (keep the compared screenshots), `--headed`. + +The screenshot is taken after the measured camera moves, once the camera is back at the measured view. +In the densest views (scale 0.01) GPU rasterization is not bit-exact between page loads: the same version +can differ from itself by a few grey levels in a few hundred pixels. A culling error, such as a missing +or extra block or connection, differs by a much larger delta. + +Chromium comes from Playwright (`npx playwright install chromium`). On macOS it uses ANGLE Metal for a +hardware canvas. Inside a sandbox that blocks Mach ports, run it outside the sandbox. diff --git a/scripts/bench/culling/entry.ts b/scripts/bench/culling/entry.ts new file mode 100644 index 00000000..b210f579 --- /dev/null +++ b/scripts/bench/culling/entry.ts @@ -0,0 +1,300 @@ +// Browser side of the culling benchmark. Bundled once per variant with `GRAPH` aliased to that +// variant's packages/graph/src/index.ts, so every variant runs the same scenes and measurement. +import { CanvasBlock, ECanDrag, EAnchorType, Graph } from "GRAPH"; + +type TBlockData = Record & { x: number; y: number; width: number; height: number }; +type TScene = { blocks: TBlockData[]; connections: Array> }; + +/** Internals the benchmark reads; they are not part of the public API. */ +type TLayerInternals = { + __comp: { context: { scheduler: { update: () => void } }; treeNode: { children: Set } }; +}; + +function mulberry32(seed: number): () => number { + return () => { + let t = (seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function anchorY(index: number): number { + return 18 * index + (index >= 1 ? 8 * index : 0) + 18; +} + +/** The CanvasBlock members the benchmark block uses; they are protected in the library. */ +interface IBlockBase { + connectedState: { + $state: { value: { anchors?: Array<{ type: string }> } }; + $anchorIndexs: { value?: Map }; + }; + state: { x: number; y: number; width: number; height: number; selected: boolean }; + context: { + camera: { getCameraScale(): number }; + colors: { block: { background: string; border: string; selectedBorder: string } }; + }; + getContentRect(): { height: number }; +} + +const BlockBase = CanvasBlock as unknown as new (...args: unknown[]) => IBlockBase; + +/** Storybook Playground ActionBlock at the scales measured here: a rounded body with two anchors. */ +class ActionBlock extends BlockBase { + public getAnchorPosition(anchor: { id: string; type: string }) { + const state = this.connectedState.$state.value; + const sameType = (state.anchors ?? []).filter((item: { type: string }) => item.type === anchor.type); + const offset = (this.getContentRect().height - anchorY(sameType.length - 1)) / 2; + const index = this.connectedState.$anchorIndexs.value?.get(anchor.id) || 0; + return { x: anchor.type === EAnchorType.OUT ? this.state.width : 0, y: offset + anchorY(index) }; + } + + public renderBody(ctx: CanvasRenderingContext2D) { + ctx.lineWidth = Math.min(Math.round(2 / this.context.camera.getCameraScale()), 12); + ctx.fillStyle = this.context.colors.block.background; + ctx.beginPath(); + ctx.roundRect(this.state.x, this.state.y, this.state.width, this.state.height, 8); + ctx.fill(); + ctx.strokeStyle = this.state.selected ? this.context.colors.block.selectedBorder : this.context.colors.block.border; + ctx.stroke(); + ctx.closePath(); + } + + public renderMinimalisticBlock(ctx: CanvasRenderingContext2D) { + this.renderBody(ctx); + } + + public renderSchematicView(ctx: CanvasRenderingContext2D) { + this.renderBody(ctx); + } +} + +/** Playground "10 000" scene: 10,476 action blocks with two anchors each and straight connections. */ +function playground(): TScene { + const random = mulberry32(42); + const blocks: TBlockData[] = []; + const connections: TScene["connections"] = []; + const id = (index: number) => `action_${index}`; + let previous: TBlockData[] = []; + let index = 0; + for (let layer = 0; layer <= 50; layer++) { + const count = layer >= 25 ? (50 - layer) ** 2 : layer ** 2; + const current: TBlockData[] = []; + for (let row = 0; row <= count; row++) { + const blockId = id(++index); + const block = { + is: "block-action", + id: blockId, + x: 1250 * layer, + y: (500 - 200 * count) / 2 + 200 * row, + width: 126, + height: 126, + selected: false, + name: `Block #${index}`, + anchors: [ + { id: `${blockId}_in`, blockId, type: EAnchorType.IN }, + { id: `${blockId}_out`, blockId, type: EAnchorType.OUT }, + ], + }; + blocks.push(block); + current.push(block); + } + if (layer > 1) { + for (let c = 0; c <= 150; c++) { + const from = blocks.length - current.length - previous.length - 1; + const source = (random() * (blocks.length - current.length - 1 - from) + from) | 0; + const target = (random() * current.length + blocks.length - current.length - 1) | 0; + if (source === target) continue; + connections.push({ + sourceBlockId: id(source), + sourceAnchorId: `${id(source)}_out`, + targetBlockId: id(target), + targetAnchorId: `${id(target)}_in`, + }); + } + previous = current; + } + } + return { blocks, connections }; +} + +/** Storybook NirvanaMaxGraphTest: 13,916 blocks without anchors and bezier connections with arrows. */ +function nirvana(): TScene { + const random = mulberry32(7); + const blocks: TBlockData[] = []; + const connections: TScene["connections"] = []; + const seen = new Set(); + let previous: TBlockData[] = []; + let index = 0; + for (let layer = 0; layer <= 55; layer++) { + const count = layer >= 27.5 ? (55 - layer) ** 2 : layer ** 2; + const current: TBlockData[] = []; + for (let row = 0; row <= count; row++) { + const blockId = `block_${++index}`; + const block = { + id: blockId, + is: (CanvasBlock as unknown as { IS: string }).IS, + x: 1250 * layer, + y: (500 - 200 * count) / 2 + 200 * row, + width: 200, + height: 150, + selected: false, + name: blockId, + anchors: [], + }; + blocks.push(block); + current.push(block); + } + if (layer > 1) { + for (let c = 0; c <= 400; c++) { + const from = blocks.length - current.length - previous.length - 1; + const source = (random() * (blocks.length - current.length - 1 - from) + from) | 0; + const target = (random() * current.length + blocks.length - current.length - 1) | 0; + const connectionId = `block_${source}/block_${target}`; + if (seen.has(connectionId)) continue; + seen.add(connectionId); + connections.push({ + id: connectionId, + sourceBlockId: `block_${source}`, + targetBlockId: `block_${target}`, + label: "Some label", + }); + } + previous = current; + } + } + return { blocks, connections }; +} + +const raf = () => new Promise((resolve) => requestAnimationFrame(resolve)); + +const state: { graph?: Graph; samples: number[]; center: { x: number; y: number } } = { + samples: [], + center: { x: 0, y: 0 }, +}; + +function centerAt(scale: number): void { + const graph = state.graph; + if (!graph) throw new Error("setup() first"); + const camera = graph.cameraService.getCameraState(); + graph.cameraService.set({ + scale, + x: camera.width / 2 - state.center.x * scale, + y: camera.height / 2 - state.center.y * scale, + }); +} + +async function waitForUpdate(previous: number): Promise { + for (let i = 0; i < 20 && state.samples.length === previous; i++) await raf(); +} + +/** Mounts a scene in a `width`x`height` viewport and returns the duration of its first GraphLayer updates. */ +async function setup(options: { + scene: "playground" | "nirvana"; + width: number; + height: number; + scale: number; + noopCanvas?: boolean; +}) { + if (options.noopCanvas) { + const proto = CanvasRenderingContext2D.prototype as unknown as Record; + for (const method of ["fill", "stroke", "fillRect", "strokeRect", "roundRect", "arc", "fillText", "clearRect"]) { + proto[method] = () => undefined; + } + } + const root = document.getElementById("root") as HTMLDivElement; + root.style.width = `${options.width}px`; + root.style.height = `${options.height}px`; + const scene = options.scene === "nirvana" ? nirvana() : playground(); + const settings = + options.scene === "nirvana" + ? { useBezierConnections: true, showConnectionArrows: true, useBlocksAnchors: true } + : { useBezierConnections: false, showConnectionArrows: false, useBlocksAnchors: true }; + const graph = new Graph( + { + settings: { + canDragCamera: true, + canZoomCamera: true, + canDrag: ECanDrag.ALL, + showConnectionLabels: false, + blockComponents: { "block-action": ActionBlock }, + ...settings, + }, + } as unknown as ConstructorParameters[0], + root + ); + state.graph = graph; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const block of scene.blocks) { + minX = Math.min(minX, block.x); + minY = Math.min(minY, block.y); + maxX = Math.max(maxX, block.x + block.width); + maxY = Math.max(maxY, block.y + block.height); + } + state.center = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }; + + const layer = graph.getGraphLayer() as unknown as TLayerInternals; + const scheduler = layer.__comp.context.scheduler; + const update = scheduler.update.bind(scheduler); + scheduler.update = () => { + const start = performance.now(); + update(); + state.samples.push(performance.now() - start); + }; + + graph.setEntities(scene as unknown as Parameters[0]); + graph.start(); + centerAt(options.scale); + for (let i = 0; i < 10; i++) await raf(); + const first = state.samples.slice(0, 2); + state.samples = []; + return { blocks: scene.blocks.length, connections: scene.connections.length, first }; +} + +/** Pans or zooms for `warmup + steps` frames and returns the GraphLayer update durations of the last `steps`. */ +async function run(options: { mode: "pan" | "zoom"; warmup: number; steps: number; scale: number }) { + const graph = state.graph; + if (!graph) throw new Error("setup() first"); + const camera = graph.cameraService; + // Every variant gets the same history across sparse, partial and full views before measuring. + centerAt(0.01); + await raf(); + for (let i = 0; i < 60; i++) { + const previous = state.samples.length; + const k = i < 30 ? i / 30 : (60 - i) / 30; + const { width, height } = camera.getCameraState(); + camera.zoom(width / 2, height / 2, 0.01 * (1 + 3 * k)); + await waitForUpdate(previous); + } + centerAt(options.scale); + await raf(); + await raf(); + state.samples = []; + const base = camera.getCameraState().scale; + for (let i = 0; i < options.warmup + options.steps; i++) { + const previous = state.samples.length; + if (options.mode === "pan") { + const direction = Math.floor(i / 15) % 2 === 0 ? 1 : -1; + camera.move(2 * direction, direction); + } else { + const phase = i % 30; + const { width, height } = camera.getCameraState(); + camera.zoom(width / 2, height / 2, base * (1 + 0.003 * (phase < 15 ? phase : 30 - phase))); + } + await waitForUpdate(previous); + if (i === options.warmup - 1) state.samples = []; + } + return state.samples.slice(); +} + +/** Moves back to the measured view and waits until it is painted, for screenshot comparison. */ +async function settle(options: { scale: number }) { + centerAt(options.scale); + for (let i = 0; i < 10; i++) await raf(); + return state.graph?.cameraService.getCameraState(); +} + +(window as unknown as { bench: unknown }).bench = { setup, run, settle }; diff --git a/scripts/bench/culling/run.mjs b/scripts/bench/culling/run.mjs new file mode 100644 index 00000000..bff3fac2 --- /dev/null +++ b/scripts/bench/culling/run.mjs @@ -0,0 +1,222 @@ +#!/usr/bin/env node +/** + * Culling benchmark: main-thread duration of one GraphLayer update while panning or zooming large + * scenes, for several versions of packages/graph side by side. See README.md. + * + * node scripts/bench/culling/run.mjs --variants base=origin/v2,current=. --scales 0.01,0.02,0.05 + */ +import { chromium } from "@playwright/test"; +import esbuild from "esbuild"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(here, "../../.."); + +const args = process.argv.slice(2); +function option(name, fallback) { + const index = args.indexOf(`--${name}`); + if (index === -1) return fallback; + const value = args[index + 1]; + return value === undefined || value.startsWith("--") ? true : value; +} +const list = (value) => String(value).split(",").filter(Boolean); + +const variants = list(option("variants", "base=origin/v2,current=.")).map((entry) => { + const [name, ref = name] = entry.split("="); + return { name, ref }; +}); +const scenes = list(option("scenes", "nirvana,playground")); +const scales = list(option("scales", "0.01,0.02,0.05,0.1")).map(Number); +const views = list(option("views", "near")); +const modes = list(option("modes", "pan,zoom")); +const repeats = Number(option("repeats", 3)); +const steps = Number(option("steps", 40)); +const noopCanvas = Boolean(option("noop", false)); +const firstOnly = Boolean(option("first", false)); +const headed = Boolean(option("headed", false)); +const shotsDir = option("shots", undefined); + +const VIEWS = { full: { width: 1600, height: 1600 }, near: { width: 1600, height: 1000 } }; + +const work = fs.mkdtempSync(path.join(os.tmpdir(), "graph-culling-bench-")); + +/** Builds one variant's bundle and page; `.` is the working tree, anything else a git ref. */ +async function build({ name, ref }) { + let source = path.join(repo, "packages/graph/src"); + if (ref !== ".") { + const target = path.join(work, `src-${name}`); + fs.mkdirSync(target, { recursive: true }); + const archive = execFileSync("git", ["archive", ref, "packages/graph/src"], { cwd: repo, maxBuffer: 1 << 30 }); + execFileSync("tar", ["-x", "-C", target], { input: archive }); + source = path.join(target, "packages/graph/src"); + } + await esbuild.build({ + entryPoints: [path.join(here, "entry.ts")], + bundle: true, + outfile: path.join(work, `${name}.js`), + format: "iife", + platform: "browser", + target: ["es2022"], + alias: { GRAPH: path.join(source, "index.ts") }, + nodePaths: [path.join(repo, "node_modules")], + plugins: [ + { + // Layer styles position the canvases; inject them like the library's consumers do. + name: "inline-css", + setup(css) { + css.onLoad({ filter: /\.css$/ }, async (file) => ({ + contents: `const s=document.createElement("style");s.textContent=${JSON.stringify( + await fs.promises.readFile(file.path, "utf8") + )};document.head.appendChild(s);`, + loader: "js", + })); + }, + }, + ], + logLevel: "error", + }); + const page = path.join(work, `${name}.html`); + fs.writeFileSync( + page, + `` + + `
` + ); + return page; +} + +const median = (values) => { + const sorted = [...values].sort((a, b) => a - b); + const middle = sorted.length >> 1; + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +}; +const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length; + +async function countDifferentPixels(browser, a, b) { + const page = await browser.newPage(); + const url = (buffer) => `data:image/png;base64,${buffer.toString("base64")}`; + const count = await page.evaluate( + async ([first, second]) => { + const load = (src) => + new Promise((resolve) => { + const image = new Image(); + image.onload = () => resolve(image); + image.src = src; + }); + const pixels = async (src) => { + const image = await load(src); + const canvas = Object.assign(document.createElement("canvas"), { width: image.width, height: image.height }); + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0); + return ctx.getImageData(0, 0, image.width, image.height).data; + }; + const [left, right] = await Promise.all([pixels(first), pixels(second)]); + const width = (await load(first)).width; + let different = 0; + let delta = 0; + const box = [Infinity, Infinity, -Infinity, -Infinity]; + for (let i = 0; i < left.length; i += 4) { + const d = Math.max( + Math.abs(left[i] - right[i]), + Math.abs(left[i + 1] - right[i + 1]), + Math.abs(left[i + 2] - right[i + 2]) + ); + if (d) { + different++; + delta = Math.max(delta, d); + const x = (i / 4) % width; + const y = Math.floor(i / 4 / width); + box[0] = Math.min(box[0], x); + box[1] = Math.min(box[1], y); + box[2] = Math.max(box[2], x); + box[3] = Math.max(box[3], y); + } + } + return `${different} px differ by up to ${delta} in [${box.join(", ")}]`; + }, + [url(a), url(b)] + ); + await page.close(); + return count; +} + +const pages = {}; +for (const variant of variants) pages[variant.name] = await build(variant); + +const browser = await chromium.launch({ + headless: !headed, + channel: "chromium", + // Hardware canvas on macOS; elsewhere Chromium picks its default backend. + args: process.platform === "darwin" ? ["--use-angle=metal", "--use-gl=angle"] : [], + ignoreDefaultArgs: ["--enable-unsafe-swiftshader"], +}); + +console.log(`repeats=${repeats} steps=${steps}${noopCanvas ? " noop-canvas" : ""}`); +for (const scene of scenes) { + for (const scale of firstOnly ? [0.01] : scales) { + for (const view of views) { + const results = Object.fromEntries(variants.map(({ name }) => [name, { first: [], pan: [], zoom: [] }])); + const shots = {}; + for (let repeat = 0; repeat < repeats; repeat++) { + // Rotate the order so no variant always runs first. + const order = variants.map((_, i) => variants[(i + repeat) % variants.length]); + for (const { name } of order) { + const context = await browser.newContext({ viewport: { width: 1700, height: 1700 }, deviceScaleFactor: 2 }); + const page = await context.newPage(); + page.on("pageerror", (error) => console.error(name, error.message)); + await page.goto(`file://${pages[name]}`); + const info = await page.evaluate((o) => window.bench.setup(o), { + ...VIEWS[view], + scene, + scale, + noopCanvas, + }); + results[name].info = info; + results[name].first.push(info.first[0] + (info.first[1] ?? 0)); + if (!firstOnly) { + for (const mode of modes) { + const samples = await page.evaluate((o) => window.bench.run(o), { + mode, + warmup: Math.floor(steps / 2), + steps, + scale, + }); + results[name][mode].push(mean(samples)); + } + if (repeat === 0) { + await page.evaluate((o) => window.bench.settle(o), { scale }); + shots[name] = await page.screenshot(); + if (shotsDir) { + fs.mkdirSync(shotsDir, { recursive: true }); + fs.writeFileSync(path.join(shotsDir, `${scene}-${scale}-${view}-${name}.png`), shots[name]); + } + } + } + await context.close(); + } + } + for (const { name } of variants) { + const result = results[name]; + const columns = [`first two frames ${median(result.first).toFixed(0)} ms`]; + if (!firstOnly) for (const mode of modes) columns.push(`${mode} ${median(result[mode]).toFixed(2)} ms`); + console.log( + `${scene.padEnd(10)} scale=${String(scale).padEnd(5)} ${view.padEnd(4)} ${name.padEnd(10)}`, + columns.join(" | ") + ); + } + if (!firstOnly) { + const reference = variants[0].name; + for (const { name } of variants.slice(1)) { + const same = Buffer.compare(shots[reference], shots[name]) === 0; + const detail = same ? "identical" : await countDifferentPixels(browser, shots[reference], shots[name]); + console.log(`${" ".repeat(10)} pixels ${name} vs ${reference}: ${detail}`); + } + } + } + } +} +await browser.close(); +fs.rmSync(work, { recursive: true, force: true });