From 0f6a4284a871691e44a0908af051d41a600a191c Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 1 Dec 2025 12:47:48 +0100 Subject: [PATCH 01/42] Virtualization PoC --- apps/angular-demo/src/app/app.component.ts | 208 +++------- .../api/Types/Configuration/FlowConfig.md | 9 + .../commands/__tests__/init.test.ts | 26 +- .../core/src/command-handler/commands/init.ts | 14 +- .../src/flow-config/default-flow-config.ts | 8 + .../ng-diagram/src/core/src/flow-core.test.ts | 9 +- .../ng-diagram/src/core/src/flow-core.ts | 82 +++- .../middleware-manager/middleware-executor.ts | 18 +- .../__tests__/diagram-init.emitter.test.ts | 4 + .../emitters/diagram-init.emitter.ts | 46 ++- .../direct-render-strategy.test.ts | 57 +++ .../render-strategy/direct-render-strategy.ts | 15 + .../src/core/src/render-strategy/index.ts | 3 + .../render-strategy.interface.ts | 13 + .../virtualized-render-strategy.test.ts | 364 ++++++++++++++++++ .../virtualized-render-strategy.ts | 219 +++++++++++ .../core/src/types/flow-config.interface.ts | 36 ++ .../ng-diagram/src/core/src/types/index.ts | 3 + .../core/src/types/middleware.interface.ts | 16 + .../updater/init-updater/init-updater.test.ts | 32 +- .../src/updater/init-updater/init-updater.ts | 38 +- .../flow-resize-processor.service.ts | 21 + .../lib/services/renderer/renderer.service.ts | 28 +- 23 files changed, 1068 insertions(+), 201 deletions(-) create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.test.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts diff --git a/apps/angular-demo/src/app/app.component.ts b/apps/angular-demo/src/app/app.component.ts index 76319bfba..743c346d3 100644 --- a/apps/angular-demo/src/app/app.component.ts +++ b/apps/angular-demo/src/app/app.component.ts @@ -55,10 +55,6 @@ export class AppComponent { config = { zoom: { max: 2, - zoomToFit: { - onInit: true, - padding: [50, 50, 100, 350], - }, }, background: { cellSize: { width: 10, height: 10 }, @@ -66,6 +62,9 @@ export class AppComponent { snapping: { shouldSnapDragForNode: () => true, }, + virtualization: { + enabled: true, + }, shortcuts: configureShortcuts([ { actionName: 'keyboardMoveSelectionUp', @@ -194,148 +193,61 @@ export class AppComponent { }); } - model = initializeModel({ - nodes: [ - { - id: '1', - type: 'image', - position: { x: 100, y: 50 }, - data: { imageUrl: 'https://tinyurl.com/bddnt44s' }, - }, - { id: '2', type: 'input-field', position: { x: 400, y: 100 }, data: {} }, - { - id: '3', - type: 'resizable', - position: { x: 700, y: 200 }, - size: { width: 300, height: 400 }, - autoSize: false, - data: {}, - angle: 45, - }, - { - id: '4', - position: { x: 800, y: 350 }, - data: {}, - isGroup: true, - }, - { - id: '5', - position: { x: 100, y: 250 }, - data: { label: 'edge is manual' }, - }, - { - id: '6', - position: { x: 463, y: 382 }, - data: { label: "so it's ok it's unconnected after move" }, - }, - { - id: '7', - position: { x: 100, y: 450 }, - data: {}, - }, - { - id: '8', - position: { x: 550, y: 550 }, - data: {}, - }, - { - id: '9', - position: { x: 100, y: 650 }, - data: { label: 'just bezier edge' }, - }, - { - id: '10', - position: { x: 450, y: 750 }, - data: {}, - }, - { - id: '11', - position: { x: 600, y: 650 }, - data: { label: 'test linking' }, - }, - { - id: '12', - position: { x: 1000, y: 550 }, - data: {}, - }, - { - id: '12', - position: { x: 700, y: 750 }, - data: {}, - type: 'customized-default', - resizable: true, - rotatable: true, - }, - ], - edges: [ - { - id: '1', - source: '1', - target: '2', - data: {}, - sourcePort: 'port-right', - targetPort: 'port-left', - routing: 'orthogonal', - }, - { - id: '2', - source: '2', - target: '3', - data: {}, - sourcePort: 'port-right', - targetPort: 'port-left-1', - type: 'button-edge', - }, - { - id: '3', - source: '5', - target: '6', - data: { labelPosition: '0.45' }, - sourcePort: 'port-right', - targetPort: 'port-left', - type: 'labelled-edge', - routing: 'orthogonal', - routingMode: 'manual', - points: [ - { x: 300, y: 274 }, - { x: 380, y: 274 }, - { x: 380, y: 314 }, - { x: 420, y: 314 }, - { x: 420, y: 354 }, - { x: 380, y: 354 }, - { x: 380, y: 407 }, - { x: 460, y: 407 }, - ], - }, - { - id: '4', - source: '7', - target: '8', - data: {}, - sourcePort: 'port-right', - targetPort: 'port-left', - type: 'custom-polyline-edge', - }, - { - id: '5', - source: '9', - target: '10', - data: { labelPosition: 0.7 }, - sourcePort: 'port-right', - targetPort: 'port-left', - type: 'labelled-edge', - routing: 'bezier', - }, - { - id: '6', - source: '11', - target: '12', - data: {}, - sourcePort: 'port-right', - targetPort: 'port-left', - type: 'dashed-edge', - routing: 'orthogonal', - }, - ], - }); + // Generate 20k nodes in a grid pattern for virtualization testing + model = initializeModel(this.generateLargeModel(5000)); + + private generateLargeModel(nodeCount: number): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = []; + const edges: Edge[] = []; + + // Calculate grid dimensions (roughly square) + const cols = Math.ceil(Math.sqrt(nodeCount)); + const rows = Math.ceil(nodeCount / cols); + + // Node spacing + const spacingX = 200; + const spacingY = 150; + + // Generate nodes in a grid + for (let i = 0; i < nodeCount; i++) { + const row = Math.floor(i / cols); + const col = i % cols; + + nodes.push({ + id: `node-${i}`, + position: { + x: col * spacingX, + y: row * spacingY, + }, + data: { label: `Node ${i}` }, + }); + + // Create horizontal edge (to the right neighbor) + if (col < cols - 1 && i + 1 < nodeCount) { + edges.push({ + id: `edge-h-${i}`, + source: `node-${i}`, + target: `node-${i + 1}`, + sourcePort: 'port-right', + targetPort: 'port-left', + data: {}, + }); + } + + // Create vertical edge (to the bottom neighbor) - only every 5th row to reduce edge count + if (row < rows - 1 && i + cols < nodeCount && col % 5 === 0) { + edges.push({ + id: `edge-v-${i}`, + source: `node-${i}`, + target: `node-${i + cols}`, + sourcePort: 'port-right', + targetPort: 'port-left', + data: {}, + }); + } + } + + console.log(`Generated ${nodes.length} nodes and ${edges.length} edges`); + return { nodes, edges }; + } } diff --git a/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md index bc4b6cc80..a5454b03f 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md @@ -150,6 +150,15 @@ Configuration for snapping behavior. *** +### virtualization + +> **virtualization**: `VirtualizationConfig` + +Configuration for viewport virtualization. +Improves performance for large diagrams by only rendering visible elements. + +*** + ### zIndex > **zIndex**: [`ZIndexConfig`](/docs/api/types/configuration/features/zindexconfig/) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/init.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/init.test.ts index f3204067d..c571fa38b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/init.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/init.test.ts @@ -4,7 +4,7 @@ import { FlowCore } from '../../../flow-core'; import { mockMetadata } from '../../../test-utils'; import { FlowState } from '../../../types'; import { CommandHandler } from '../../command-handler'; -import { init } from '../init'; +import { init, InitCommand } from '../init'; describe('init command', () => { let flowCore: FlowCore; @@ -21,9 +21,27 @@ describe('init command', () => { commandHandler = new CommandHandler(flowCore); }); - it('should execute init command on middlewares', () => { - init(commandHandler); + it('should execute init command on middlewares', async () => { + const command: InitCommand = { name: 'init' }; + await init(commandHandler, command); - expect(flowCore.applyUpdate).toHaveBeenCalledWith({}, 'init'); + expect(flowCore.applyUpdate).toHaveBeenCalledWith( + { renderedNodeIds: undefined, renderedEdgeIds: undefined }, + 'init' + ); + }); + + it('should pass rendered IDs when provided', async () => { + const command: InitCommand = { + name: 'init', + renderedNodeIds: ['node1', 'node2'], + renderedEdgeIds: ['edge1'], + }; + await init(commandHandler, command); + + expect(flowCore.applyUpdate).toHaveBeenCalledWith( + { renderedNodeIds: ['node1', 'node2'], renderedEdgeIds: ['edge1'] }, + 'init' + ); }); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/init.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/init.ts index 530162cb9..92871007f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/init.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/init.ts @@ -2,8 +2,18 @@ import type { CommandHandler } from '../../types'; export interface InitCommand { name: 'init'; + /** IDs of nodes currently rendered (for virtualization) */ + renderedNodeIds?: string[]; + /** IDs of edges currently rendered (for virtualization) */ + renderedEdgeIds?: string[]; } -export const init = async (commandHandler: CommandHandler) => { - await commandHandler.flowCore.applyUpdate({}, 'init'); +export const init = async (commandHandler: CommandHandler, command: InitCommand) => { + await commandHandler.flowCore.applyUpdate( + { + renderedNodeIds: command.renderedNodeIds, + renderedEdgeIds: command.renderedEdgeIds, + }, + 'init' + ); }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts index 066961feb..981d43db3 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts @@ -12,6 +12,7 @@ import type { ResizeConfig, SelectionMovingConfig, SnappingConfig, + VirtualizationConfig, ZIndexConfig, ZoomConfig, ZoomToFitConfig, @@ -133,6 +134,12 @@ const defaultBoxSelectionConfig: BoxSelectionConfig = { realtime: true, }; +const defaultVirtualizationConfig: VirtualizationConfig = { + enabled: true, + padding: 500, // Larger padding reduces frequency of hitting viewport edge during fast panning + nodeCountThreshold: 500, +}; + /** * Default configuration for the flow system. */ @@ -152,6 +159,7 @@ export const createFlowConfig = (config: DeepPartial, flowCore: Flow edgeRouting: defaultEdgeRoutingConfig, zIndex: defaultZIndexConfig, boxSelection: defaultBoxSelectionConfig, + virtualization: defaultVirtualizationConfig, shortcuts: DEFAULT_SHORTCUTS, debugMode: false, }, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts index 1ea1dafb7..62922519d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts @@ -147,13 +147,16 @@ describe('FlowCore', () => { it('should emit init command after initialization completes', async () => { // Initially, init command should not be called yet - expect(mockCommandHandler.emit).not.toHaveBeenCalledWith('init'); + expect(mockCommandHandler.emit).not.toHaveBeenCalledWith('init', expect.anything()); // Wait for the initialization callback to be executed await new Promise((resolve) => setTimeout(resolve, 10)); - // Now init command should have been emitted - expect(mockCommandHandler.emit).toHaveBeenCalledWith('init'); + // Now init command should have been emitted with rendered node/edge IDs + expect(mockCommandHandler.emit).toHaveBeenCalledWith('init', { + renderedNodeIds: expect.any(Array), + renderedEdgeIds: expect.any(Array), + }); }); it('should initialize with default getFlowOffset when not provided', () => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts index ae0f1d8d4..fb942c3e7 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts @@ -9,6 +9,7 @@ import { MiddlewareManager } from './middleware-manager/middleware-manager'; import { loggerMiddleware } from './middleware-manager/middlewares'; import { ModelLookup } from './model-lookup/model-lookup'; import { PortBatchProcessor } from './port-batch-processor/port-batch-processor'; +import { DirectRenderStrategy, RenderStrategy, VirtualizedRenderStrategy } from './render-strategy'; import { ShortcutManager } from './shortcut-manager'; import { SpatialHash } from './spatial-hash/spatial-hash'; import { @@ -62,6 +63,9 @@ export class FlowCore { readonly eventManager: EventManager; readonly shortcutManager: ShortcutManager; + private readonly directRenderStrategy: DirectRenderStrategy; + private readonly virtualizedRenderStrategy: VirtualizedRenderStrategy; + readonly getFlowOffset: () => Point; constructor( @@ -81,6 +85,8 @@ export class FlowCore { this.initUpdater = new InitUpdater(this); this.internalUpdater = new InternalUpdater(this); this.modelLookup = new ModelLookup(this); + this.directRenderStrategy = new DirectRenderStrategy(); + this.virtualizedRenderStrategy = new VirtualizedRenderStrategy(this); this.middlewareManager = new MiddlewareManager(this, middlewares); this.transactionManager = new TransactionManager(this); this.eventManager = new EventManager(); @@ -105,20 +111,32 @@ export class FlowCore { this.model.destroy(); } - /** - * Starts listening to model changes and emits init command - */ + private lastNodesRef: Node[] | null = null; + private init() { - this.render(); + this.spatialHash.process(this.model.getNodes()); + this.lastNodesRef = this.model.getNodes(); + + // this.render(); this.model.onChange((state) => { - this.spatialHash.process(state.nodes); - this.modelLookup.desynchronize(); + // Only update spatial hash when nodes actually changed (skip during panning/zooming) + if (state.nodes !== this.lastNodesRef) { + this.spatialHash.process(state.nodes); + this.modelLookup.desynchronize(); + this.lastNodesRef = state.nodes; + } this.render(); }); this.initUpdater.start(async () => { - await this.commandHandler.emit('init'); + const { nodes, edges } = this.getRenderedModel(); + // Only pass rendered IDs when virtualization is active (undefined = all rendered) + const virtualized = this.config.virtualization.enabled; + await this.commandHandler.emit('init', { + renderedNodeIds: virtualized ? nodes.map((n) => n.id) : undefined, + renderedEdgeIds: virtualized ? edges.map((e) => e.id) : undefined, + }); }); } @@ -181,6 +199,25 @@ export class FlowCore { }; } + /** + * Gets the active render strategy based on virtualization config. + * When virtualization is enabled, uses VirtualizedRenderStrategy. + * When disabled, uses DirectRenderStrategy. + */ + get renderStrategy(): RenderStrategy { + return this.config.virtualization.enabled ? this.virtualizedRenderStrategy : this.directRenderStrategy; + } + + /** + * Gets nodes and edges after applying the active render strategy. + * When virtualization is enabled, returns only the subset visible in the viewport. + * When disabled, returns all nodes and edges. + */ + getRenderedModel(): { nodes: Node[]; edges: Edge[] } { + const { nodes, edges, metadata } = this.getState(); + return this.renderStrategy.process(nodes, edges, metadata.viewport); + } + /** * Sets the current state of the flow * @param state State to set @@ -262,16 +299,28 @@ export class FlowCore { await this.updateSemaphore.acquire(); try { - // Get the current state - guaranteed to be fresh since we hold the lock + // ===== PERF LOGGING - only for resize-related actions ===== + const logActions = ['resizeNode', 'updatePorts', 'updateEdgeLabels', 'addPorts', 'addEdgeLabels']; + const shouldLog = logActions.includes(modelActionType as string); + + if (shouldLog) console.time(`[PERF] applyUpdate TOTAL (${modelActionType})`); + const currentState = this.getState(); + + if (shouldLog) console.time(`[PERF] middleware.execute (${modelActionType})`); const finalState = await this.middlewareManager.execute(currentState, stateUpdate, modelActionType); + if (shouldLog) console.timeEnd(`[PERF] middleware.execute (${modelActionType})`); if (finalState) { + if (shouldLog) console.time(`[PERF] setState`); this.setState(finalState); + if (shouldLog) console.timeEnd(`[PERF] setState`); this.eventManager.flushDeferredEmits(); } else { this.eventManager.clearDeferredEmits(); } + + if (shouldLog) console.timeEnd(`[PERF] applyUpdate TOTAL (${modelActionType})`); } finally { // Always release the semaphore, even if an error occurs this.updateSemaphore.release(); @@ -319,14 +368,27 @@ export class FlowCore { }; } + private lastRenderedNodeCount = 0; + /** * Renders the flow */ private render(): void { const { nodes, edges, metadata } = this.getState(); const temporaryEdge = this.actionStateManager.linking?.temporaryEdge; - const finalEdges = temporaryEdge && temporaryEdge.temporary ? [...edges, temporaryEdge] : edges; - this.renderer.draw(nodes, finalEdges, metadata.viewport); + + // Apply render strategy (virtualization or direct) + const { nodes: visibleNodes, edges: visibleEdges } = this.renderStrategy.process(nodes, edges, metadata.viewport); + + // Log when node count changes (new nodes rendered) + const diff = visibleNodes.length - this.lastRenderedNodeCount; + if (diff > 0) { + console.log(`[PERF] render: ${diff} NEW nodes (${this.lastRenderedNodeCount} -> ${visibleNodes.length})`); + } + this.lastRenderedNodeCount = visibleNodes.length; + + const finalEdges = temporaryEdge?.temporary ? [...visibleEdges, temporaryEdge] : visibleEdges; + this.renderer.draw(visibleNodes, finalEdges, metadata.viewport); } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts index 443737fe3..0e57ab004 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts @@ -44,16 +44,32 @@ export class MiddlewareExecutor { stateUpdate: FlowStateUpdate, modelActionType: ModelActionType ): Promise { + const logActions = ['resizeNode', 'updatePorts', 'updateEdgeLabels', 'addPorts', 'addEdgeLabels']; + const shouldLog = logActions.includes(modelActionType); + + if (shouldLog) console.time(`[PERF] executor.run setup (${modelActionType})`); + this.initialState = initialState; this.modelActionType = modelActionType; this.metadata = initialState.metadata; + + if (shouldLog) console.time(`[PERF] map copies`); this.nodesMap = new Map(this.flowCore.modelLookup.nodesMap); this.edgesMap = new Map(this.flowCore.modelLookup.edgesMap); this.initialNodesMap = new Map(this.flowCore.modelLookup.nodesMap); this.initialEdgesMap = new Map(this.flowCore.modelLookup.edgesMap); + if (shouldLog) console.timeEnd(`[PERF] map copies`); + this.initialStateUpdate = stateUpdate; this.applyStateUpdate(stateUpdate); - return this.resolveMiddlewares(); + + if (shouldLog) console.timeEnd(`[PERF] executor.run setup (${modelActionType})`); + + if (shouldLog) console.time(`[PERF] resolveMiddlewares (${modelActionType})`); + const result = await this.resolveMiddlewares(); + if (shouldLog) console.timeEnd(`[PERF] resolveMiddlewares (${modelActionType})`); + + return result; } helpers = () => ({ diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/diagram-init.emitter.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/diagram-init.emitter.test.ts index bafac7784..402028a6f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/diagram-init.emitter.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/diagram-init.emitter.test.ts @@ -45,6 +45,7 @@ describe('DiagramInitEmitter', () => { context.modelActionType = 'init'; context.nodesMap.set('node1', node); + // undefined renderedNodeIds means "all rendered" (DirectRenderStrategy) emitter.emit(context, eventManager); @@ -64,6 +65,7 @@ describe('DiagramInitEmitter', () => { context.modelActionType = 'init'; context.nodesMap.set('node1', node); + // undefined renderedNodeIds means "all rendered" (DirectRenderStrategy) emitter.emit(context, eventManager); @@ -85,6 +87,7 @@ describe('DiagramInitEmitter', () => { context.modelActionType = 'init'; context.nodesMap.set('node1', measuredNode); context.nodesMap.set('node2', unmeasuredNode); + // undefined renderedNodeIds means "all rendered" (DirectRenderStrategy) emitter.emit(context, eventManager); @@ -748,6 +751,7 @@ describe('DiagramInitEmitter', () => { context.nodesMap.set('node2', unmeasuredNode); context.nodesMap.set('node3', nodeWithUnmeasuredPort); context.edgesMap.set('edge1', edgeWithUnmeasuredLabel); + // undefined renderedNodeIds/renderedEdgeIds means "all rendered" (DirectRenderStrategy) emitter.emit(context, eventManager); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/diagram-init.emitter.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/diagram-init.emitter.ts index 69c46db62..4df2773b7 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/diagram-init.emitter.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/diagram-init.emitter.ts @@ -38,11 +38,14 @@ export class DiagramInitEmitter implements EventEmitter { } private handleInit(context: MiddlewareContext, eventManager: EventManager): void { - const { nodesMap, edgesMap } = context; - - this.collectUnmeasuredItems(nodesMap, edgesMap); this.initialized = true; + const { nodesMap, edgesMap, initialUpdate } = context; + + // With virtualization, only track rendered nodes/edges for measurement + // Non-rendered nodes won't be measured until they enter the viewport + this.collectUnmeasuredItems(nodesMap, edgesMap, initialUpdate.renderedNodeIds, initialUpdate.renderedEdgeIds); + if (this.areAllMeasured()) { this.emitInitEvent(context, eventManager); } else { @@ -76,12 +79,24 @@ export class DiagramInitEmitter implements EventEmitter { } } - private collectUnmeasuredItems(nodesMap: Map, edgesMap: Map): void { + private collectUnmeasuredItems( + nodesMap: Map, + edgesMap: Map, + renderedNodeIds?: string[], + renderedEdgeIds?: string[] + ): void { this.unmeasuredNodes.clear(); this.unmeasuredNodePorts.clear(); this.unmeasuredEdgeLabels.clear(); - for (const [nodeId, node] of nodesMap) { + // If renderedNodeIds provided (virtualization), only track those nodes + // Otherwise track all nodes (no virtualization or fallback) + const nodeIdsToTrack = renderedNodeIds ?? Array.from(nodesMap.keys()); + + for (const nodeId of nodeIdsToTrack) { + const node = nodesMap.get(nodeId); + if (!node) continue; + if (!isValidSize(node.size)) { this.unmeasuredNodes.add(nodeId); } @@ -93,7 +108,14 @@ export class DiagramInitEmitter implements EventEmitter { } } - for (const [edgeId, edge] of edgesMap) { + // If renderedEdgeIds provided (virtualization), only track those edges + // Otherwise track all edges (no virtualization or fallback) + const edgeIdsToTrack = renderedEdgeIds ?? Array.from(edgesMap.keys()); + + for (const edgeId of edgeIdsToTrack) { + const edge = edgesMap.get(edgeId); + if (!edge) continue; + for (const label of edge.measuredLabels ?? []) { if (!isValidSize(label.size) || !isValidPosition(label.position)) { this.unmeasuredEdgeLabels.add(`${edgeId}:${label.id}`); @@ -157,10 +179,16 @@ export class DiagramInitEmitter implements EventEmitter { private emitInitEvent(context: MiddlewareContext, eventManager: EventManager, useDeferred = true): void { this.clearSafetyHatchTimeout(); - const { nodesMap, edgesMap } = context; + const { nodesMap, edgesMap, initialUpdate } = context; const event: DiagramInitEvent = { - nodes: Array.from(nodesMap.values()), - edges: Array.from(edgesMap.values()), + // undefined means all nodes/edges are rendered (DirectRenderStrategy) + // string[] means only those specific IDs are rendered (VirtualizedRenderStrategy) + nodes: initialUpdate.renderedNodeIds + ? Array.from(nodesMap.values()).filter((x) => initialUpdate.renderedNodeIds!.includes(x.id)) + : Array.from(nodesMap.values()), + edges: initialUpdate.renderedEdgeIds + ? Array.from(edgesMap.values()).filter((x) => initialUpdate.renderedEdgeIds!.includes(x.id)) + : Array.from(edgesMap.values()), viewport: context.state.metadata.viewport, }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.test.ts new file mode 100644 index 000000000..b08cf3eb7 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { mockEdge, mockNode } from '../test-utils'; +import type { Edge, Node } from '../types'; +import { DirectRenderStrategy } from './direct-render-strategy'; + +describe('DirectRenderStrategy', () => { + const strategy = new DirectRenderStrategy(); + + it('should return all nodes and edges unchanged', () => { + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 0, y: 0 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 1000, y: 1000 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '3', position: { x: 5000, y: 5000 }, size: { width: 50, height: 50 } }, + ]; + const edges: Edge[] = [ + { ...mockEdge, id: 'e1', source: '1', target: '2' }, + { ...mockEdge, id: 'e2', source: '2', target: '3' }, + ]; + + const result = strategy.process(nodes, edges); + + expect(result.nodes).toBe(nodes); + expect(result.edges).toBe(edges); + }); + + it('should return empty sets for nodeIds and edgeIds', () => { + const nodes: Node[] = [{ ...mockNode, id: '1', position: { x: 0, y: 0 }, size: { width: 50, height: 50 } }]; + const edges: Edge[] = []; + + const result = strategy.process(nodes, edges); + + expect(result.nodeIds.size).toBe(0); + expect(result.edgeIds.size).toBe(0); + }); + + it('should work with undefined viewport', () => { + const nodes: Node[] = [{ ...mockNode, id: '1', position: { x: 0, y: 0 }, size: { width: 50, height: 50 } }]; + const edges: Edge[] = []; + + const result = strategy.process(nodes, edges); + + expect(result.nodes).toBe(nodes); + expect(result.edges).toBe(edges); + }); + + it('should reuse the same empty set instance across calls', () => { + const nodes: Node[] = []; + const edges: Edge[] = []; + + const result1 = strategy.process(nodes, edges); + const result2 = strategy.process(nodes, edges); + + // Same empty set instance should be reused + expect(result1.nodeIds).toBe(result2.nodeIds); + expect(result1.edgeIds).toBe(result2.edgeIds); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts new file mode 100644 index 000000000..5a876799d --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts @@ -0,0 +1,15 @@ +import type { Edge, Node } from '../types'; +import type { RenderStrategy, RenderStrategyResult } from './render-strategy.interface'; + +// Reusable empty set for direct rendering (avoids allocation on every call) +const EMPTY_SET = new Set(); + +/** + * Direct render strategy - returns all nodes and edges without virtualization. + * Used when virtualization is disabled. + */ +export class DirectRenderStrategy implements RenderStrategy { + process(nodes: Node[], edges: Edge[]): RenderStrategyResult { + return { nodes, edges, nodeIds: EMPTY_SET, edgeIds: EMPTY_SET }; + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts new file mode 100644 index 000000000..7aea953d4 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts @@ -0,0 +1,3 @@ +export * from './render-strategy.interface'; +export * from './direct-render-strategy'; +export * from './virtualized-render-strategy'; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts new file mode 100644 index 000000000..61af9acd0 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts @@ -0,0 +1,13 @@ +import type { Edge, Node, Viewport } from '../types'; + +export interface RenderStrategyResult { + nodes: Node[]; + edges: Edge[]; + nodeIds: Set; + edgeIds: Set; +} + +export interface RenderStrategy { + process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult; + invalidateCache?(): void; +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts new file mode 100644 index 000000000..fe04eaedb --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts @@ -0,0 +1,364 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlowCore } from '../flow-core'; +import { SpatialHash } from '../spatial-hash/spatial-hash'; +import { mockEdge, mockGroupNode, mockNode } from '../test-utils'; +import type { Edge, Node, Viewport, VirtualizationConfig } from '../types'; +import { VirtualizedRenderStrategy } from './virtualized-render-strategy'; + +describe('VirtualizedRenderStrategy', () => { + let spatialHash: SpatialHash; + let mockFlowCore: FlowCore; + let strategy: VirtualizedRenderStrategy; + let config: VirtualizationConfig; + + const defaultViewport: Viewport = { + x: 0, + y: 0, + scale: 1, + width: 800, + height: 600, + }; + + const defaultConfig: VirtualizationConfig = { + enabled: true, + padding: 100, + nodeCountThreshold: 2, + }; + + beforeEach(() => { + spatialHash = new SpatialHash(); + config = { ...defaultConfig }; + + // Create a mock FlowCore with required properties + const nodesMap = new Map(); + mockFlowCore = { + config: { + virtualization: config, + }, + spatialHash, + modelLookup: { + nodesMap, + getNodeById: vi.fn((id: string) => nodesMap.get(id)), + getConnectedEdges: vi.fn().mockReturnValue([]), + getAllDescendantIds: vi.fn().mockReturnValue([]), + }, + } as unknown as FlowCore; + + strategy = new VirtualizedRenderStrategy(mockFlowCore); + }); + + // Helper to update nodesMap when nodes change + function updateNodesMap(nodes: Node[]): void { + const nodesMap = mockFlowCore.modelLookup.nodesMap as Map; + nodesMap.clear(); + for (const node of nodes) { + nodesMap.set(node.id, node); + } + } + + describe('bypass conditions', () => { + it('should return all nodes when node count is below threshold', () => { + config.nodeCountThreshold = 10; + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 0, y: 0 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 1000, y: 1000 }, size: { width: 50, height: 50 } }, + ]; + const edges: Edge[] = []; + spatialHash.process(nodes); + + const result = strategy.process(nodes, edges, defaultViewport); + + expect(result.nodes).toEqual(nodes); + }); + + it('should return all nodes when viewport is undefined', () => { + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 0, y: 0 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 1000, y: 1000 }, size: { width: 50, height: 50 } }, + ]; + const edges: Edge[] = []; + spatialHash.process(nodes); + + const result = strategy.process(nodes, edges, undefined); + + expect(result.nodes).toEqual(nodes); + expect(result.edges).toEqual(edges); + }); + }); + + describe('viewport filtering', () => { + it('should include nodes inside viewport', () => { + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 200, y: 200 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '3', position: { x: 2000, y: 2000 }, size: { width: 50, height: 50 } }, + ]; + const edges: Edge[] = []; + spatialHash.process(nodes); + updateNodesMap(nodes); + + const result = strategy.process(nodes, edges, defaultViewport); + + expect(result.nodes.map((n) => n.id)).toContain('1'); + expect(result.nodes.map((n) => n.id)).toContain('2'); + expect(result.nodes.map((n) => n.id)).not.toContain('3'); + }); + + it('should include nodes within padding area', () => { + config.padding = 200; + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: -150, y: 100 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '3', position: { x: 2000, y: 2000 }, size: { width: 50, height: 50 } }, + ]; + const edges: Edge[] = []; + spatialHash.process(nodes); + updateNodesMap(nodes); + + const result = strategy.process(nodes, edges, defaultViewport); + + expect(result.nodes.map((n) => n.id)).toContain('1'); + expect(result.nodes.map((n) => n.id)).toContain('2'); + expect(result.nodes.map((n) => n.id)).not.toContain('3'); + }); + }); + + describe('edge handling', () => { + it('should include edges connected to visible nodes', () => { + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 200, y: 200 }, size: { width: 50, height: 50 } }, + ]; + const edge: Edge = { ...mockEdge, id: 'e1', source: '1', target: '2' }; + const edges: Edge[] = [edge]; + spatialHash.process(nodes); + updateNodesMap(nodes); + vi.mocked(mockFlowCore.modelLookup.getConnectedEdges).mockImplementation((nodeId: string) => { + if (nodeId === '1' || nodeId === '2') return [edge]; + return []; + }); + + const result = strategy.process(nodes, edges, defaultViewport); + + expect(result.edges).toContain(edge); + }); + + it('should include external node when edge spans viewport boundary', () => { + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 2000, y: 2000 }, size: { width: 50, height: 50 } }, + ]; + const edge: Edge = { ...mockEdge, id: 'e1', source: '1', target: '2' }; + const edges: Edge[] = [edge]; + spatialHash.process(nodes); + updateNodesMap(nodes); + vi.mocked(mockFlowCore.modelLookup.getConnectedEdges).mockImplementation((nodeId: string) => { + if (nodeId === '1') return [edge]; + return []; + }); + + const result = strategy.process(nodes, edges, defaultViewport); + + expect(result.nodes.map((n) => n.id)).toContain('1'); + expect(result.nodes.map((n) => n.id)).toContain('2'); + expect(result.edges.map((e) => e.id)).toContain('e1'); + }); + + it('should not include edges where both endpoints are outside viewport', () => { + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 2000, y: 2000 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '3', position: { x: 3000, y: 3000 }, size: { width: 50, height: 50 } }, + ]; + const edgeInViewport: Edge = { ...mockEdge, id: 'e1', source: '1', target: '1' }; + const edgeOutsideViewport: Edge = { ...mockEdge, id: 'e2', source: '2', target: '3' }; + const edges: Edge[] = [edgeInViewport, edgeOutsideViewport]; + spatialHash.process(nodes); + updateNodesMap(nodes); + vi.mocked(mockFlowCore.modelLookup.getConnectedEdges).mockImplementation((nodeId: string) => { + if (nodeId === '1') return [edgeInViewport]; + if (nodeId === '2') return [edgeOutsideViewport]; + if (nodeId === '3') return [edgeOutsideViewport]; + return []; + }); + + const result = strategy.process(nodes, edges, defaultViewport); + + expect(result.edges.map((e) => e.id)).not.toContain('e2'); + }); + }); + + describe('group node handling', () => { + it('should include descendants when group node is visible', () => { + const groupNode = { ...mockGroupNode, id: 'g1', position: { x: 100, y: 100 }, size: { width: 200, height: 200 } }; + const childNode = { + ...mockNode, + id: 'c1', + position: { x: 2000, y: 2000 }, + size: { width: 50, height: 50 }, + groupId: 'g1', + }; + const nodes: Node[] = [groupNode, childNode]; + const edges: Edge[] = []; + spatialHash.process(nodes); + updateNodesMap(nodes); + vi.mocked(mockFlowCore.modelLookup.getAllDescendantIds).mockImplementation((groupId: string) => { + if (groupId === 'g1') return ['c1']; + return []; + }); + + const result = strategy.process(nodes, edges, defaultViewport); + + expect(result.nodes.map((n) => n.id)).toContain('g1'); + expect(result.nodes.map((n) => n.id)).toContain('c1'); + }); + }); + + describe('viewport with pan and zoom', () => { + it('should correctly calculate viewport rect when panned', () => { + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 500, y: 400 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, + ]; + const edges: Edge[] = []; + const pannedViewport: Viewport = { + x: -500, + y: -400, + scale: 1, + width: 800, + height: 600, + }; + spatialHash.process(nodes); + updateNodesMap(nodes); + + const result = strategy.process(nodes, edges, pannedViewport); + + expect(result.nodes.map((n) => n.id)).toContain('1'); + }); + + it('should correctly calculate viewport rect when zoomed', () => { + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 1000, y: 1000 }, size: { width: 50, height: 50 } }, + ]; + const edges: Edge[] = []; + const zoomedViewport: Viewport = { + x: 0, + y: 0, + scale: 0.5, + width: 800, + height: 600, + }; + spatialHash.process(nodes); + updateNodesMap(nodes); + + const result = strategy.process(nodes, edges, zoomedViewport); + + expect(result.nodes.map((n) => n.id)).toContain('1'); + expect(result.nodes.map((n) => n.id)).toContain('2'); + }); + }); + + describe('caching', () => { + it('should reuse cached ID Sets when viewport has not changed significantly', () => { + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 200, y: 200 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '3', position: { x: 300, y: 300 }, size: { width: 50, height: 50 } }, + ]; + const edges: Edge[] = []; + spatialHash.process(nodes); + updateNodesMap(nodes); + + const result1 = strategy.process(nodes, edges, defaultViewport); + const result2 = strategy.process(nodes, edges, defaultViewport); + + // The cached Sets should be the same reference (optimization: no new Set allocation) + expect(result1.nodeIds).toBe(result2.nodeIds); + expect(result1.edgeIds).toBe(result2.edgeIds); + }); + + it('should reuse cached ID Sets for small viewport movements (within 25% threshold)', () => { + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 200, y: 200 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '3', position: { x: 300, y: 300 }, size: { width: 50, height: 50 } }, + ]; + const edges: Edge[] = []; + spatialHash.process(nodes); + updateNodesMap(nodes); + + const result1 = strategy.process(nodes, edges, defaultViewport); + // Small movement (100px is less than 25% of 800+200 padding = 250) + const smallMovement: Viewport = { ...defaultViewport, x: -100 }; + const result2 = strategy.process(nodes, edges, smallMovement); + + // The cached Sets should be the same reference (optimization: no new Set allocation) + expect(result1.nodeIds).toBe(result2.nodeIds); + expect(result1.edgeIds).toBe(result2.edgeIds); + }); + + it('should create new ID Sets when viewport moves significantly (beyond 25% threshold)', () => { + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 200, y: 200 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '3', position: { x: 300, y: 300 }, size: { width: 50, height: 50 } }, + ]; + const edges: Edge[] = []; + spatialHash.process(nodes); + updateNodesMap(nodes); + + const result1 = strategy.process(nodes, edges, defaultViewport); + // Large movement (500px is more than 25% of viewport) + const largeMovement: Viewport = { ...defaultViewport, x: -500 }; + const result2 = strategy.process(nodes, edges, largeMovement); + + // New Sets should be created when cache is invalidated + expect(result1.nodeIds).not.toBe(result2.nodeIds); + expect(result1.edgeIds).not.toBe(result2.edgeIds); + }); + + it('should create new ID Sets when node count changes', () => { + const nodes1: Node[] = [ + { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 200, y: 200 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '3', position: { x: 300, y: 300 }, size: { width: 50, height: 50 } }, + ]; + const nodes2: Node[] = [ + ...nodes1, + { ...mockNode, id: '4', position: { x: 400, y: 400 }, size: { width: 50, height: 50 } }, + ]; + const edges: Edge[] = []; + spatialHash.process(nodes1); + updateNodesMap(nodes1); + + const result1 = strategy.process(nodes1, edges, defaultViewport); + + spatialHash.process(nodes2); + updateNodesMap(nodes2); + const result2 = strategy.process(nodes2, edges, defaultViewport); + + // New Sets should be created when node count changes + expect(result1.nodeIds).not.toBe(result2.nodeIds); + }); + + it('should create new ID Sets after manual cache invalidation', () => { + const nodes: Node[] = [ + { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '2', position: { x: 200, y: 200 }, size: { width: 50, height: 50 } }, + { ...mockNode, id: '3', position: { x: 300, y: 300 }, size: { width: 50, height: 50 } }, + ]; + const edges: Edge[] = []; + spatialHash.process(nodes); + updateNodesMap(nodes); + + const result1 = strategy.process(nodes, edges, defaultViewport); + strategy.invalidateCache(); + const result2 = strategy.process(nodes, edges, defaultViewport); + + // New Sets should be created after invalidation + expect(result1.nodeIds).not.toBe(result2.nodeIds); + expect(result1.edgeIds).not.toBe(result2.edgeIds); + }); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts new file mode 100644 index 000000000..e84884fa6 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts @@ -0,0 +1,219 @@ +import { FlowCore } from '../flow-core'; +import type { Edge, Node, Rect, Viewport, VirtualizationConfig } from '../types'; +import { isGroup } from '../utils'; +import type { RenderStrategy, RenderStrategyResult } from './render-strategy.interface'; + +const DEFAULT_VIEWPORT_WIDTH = 1920; +const DEFAULT_VIEWPORT_HEIGHT = 1080; + +// Percentage of viewport dimensions that triggers recomputation +const RECOMPUTE_THRESHOLD = 0.25; + +// Reusable empty set for bypass results (avoids allocation on every call) +const EMPTY_SET = new Set(); + +/** + * Virtualized render strategy - returns only nodes and edges visible in the viewport. + * Used when virtualization is enabled for large diagrams. + */ +export class VirtualizedRenderStrategy implements RenderStrategy { + private lastViewportRect: Rect | null = null; + private lastNodesLength = 0; + private lastEdgesLength = 0; + // Cache only IDs, not objects - objects may be updated (e.g., edge routing adds positions) + private cachedNodeIds: Set | null = null; + private cachedEdgeIds: Set | null = null; + + constructor(private readonly flowCore: FlowCore) {} + + process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult { + const config = this.flowCore.config.virtualization; + + if (this.shouldBypass(nodes, viewport, config)) { + return { nodes, edges, nodeIds: EMPTY_SET, edgeIds: EMPTY_SET }; + } + + const viewportRect = this.getViewportRect(viewport!, config.padding); + + if (this.canUseCachedResult(nodes.length, edges.length, viewportRect)) { + // Use cached IDs but look up fresh objects from input arrays + return this.buildResultFromCachedIds(nodes, edges); + } + + const result = this.computeVisibleElements(viewportRect); + + // Cache the IDs directly from the result (already computed, no extra allocation) + this.cachedNodeIds = result.nodeIds; + this.cachedEdgeIds = result.edgeIds; + this.lastViewportRect = viewportRect; + this.lastNodesLength = nodes.length; + this.lastEdgesLength = edges.length; + + return result; + } + + private buildResultFromCachedIds(nodes: Node[], edges: Edge[]): RenderStrategyResult { + const filteredNodes = nodes.filter((n) => this.cachedNodeIds!.has(n.id)); + const filteredEdges = edges.filter((e) => this.cachedEdgeIds!.has(e.id)); + // Reuse cached Sets - no new allocation + return { nodes: filteredNodes, edges: filteredEdges, nodeIds: this.cachedNodeIds!, edgeIds: this.cachedEdgeIds! }; + } + + invalidateCache(): void { + this.cachedNodeIds = null; + this.cachedEdgeIds = null; + this.lastViewportRect = null; + this.lastNodesLength = 0; + this.lastEdgesLength = 0; + } + + private shouldBypass(nodes: Node[], viewport: Viewport | undefined, config: VirtualizationConfig): boolean { + // Note: config.enabled check is handled by strategy selection in FlowCore + return !viewport || nodes.length < config.nodeCountThreshold; + } + + private getViewportRect(viewport: Viewport, padding: number): Rect { + const { x, y, scale, width, height } = viewport; + + const effectiveWidth = width || DEFAULT_VIEWPORT_WIDTH; + const effectiveHeight = height || DEFAULT_VIEWPORT_HEIGHT; + + const flowX = -x / scale; + const flowY = -y / scale; + const flowWidth = effectiveWidth / scale; + const flowHeight = effectiveHeight / scale; + + return { + x: flowX - padding, + y: flowY - padding, + width: flowWidth + padding * 2, + height: flowHeight + padding * 2, + }; + } + + private canUseCachedResult(nodesLength: number, edgesLength: number, viewportRect: Rect): boolean { + if (!this.cachedNodeIds || !this.cachedEdgeIds || !this.lastViewportRect) { + return false; + } + + if (nodesLength !== this.lastNodesLength || edgesLength !== this.lastEdgesLength) { + return false; + } + + return this.isViewportSimilar(this.lastViewportRect, viewportRect); + } + + private isViewportSimilar(prev: Rect, current: Rect): boolean { + // Only recompute when viewport moved by more than 25% of its dimensions + const xThreshold = prev.width * RECOMPUTE_THRESHOLD; + const yThreshold = prev.height * RECOMPUTE_THRESHOLD; + + return ( + Math.abs(prev.x - current.x) < xThreshold && + Math.abs(prev.y - current.y) < yThreshold && + Math.abs(prev.width - current.width) < 10 && + Math.abs(prev.height - current.height) < 10 + ); + } + + private computeVisibleElements(viewportRect: Rect): RenderStrategyResult { + const primaryVisibleIds = this.getPrimaryVisibleIds(viewportRect); + const { edges, edgeIds, externalNodeIds } = this.collectVisibleEdges(primaryVisibleIds); + const { nodes, nodeIds } = this.buildNodeList(primaryVisibleIds, externalNodeIds); + + return { nodes, edges, nodeIds, edgeIds }; + } + + /** + * Gets node IDs visible in viewport, including descendants of visible groups. + */ + private getPrimaryVisibleIds(viewportRect: Rect): Set { + const primaryVisibleIds = new Set(this.flowCore.spatialHash.queryIds(viewportRect)); + + this.addGroupDescendants(primaryVisibleIds); + + return primaryVisibleIds; + } + + /** + * Expands the set to include all descendants of visible group nodes. + */ + private addGroupDescendants(nodeIds: Set): void { + const nodesMap = this.flowCore.modelLookup.nodesMap; + + // Collect group IDs first to avoid mutating set while iterating + const groupIds: string[] = []; + for (const nodeId of nodeIds) { + const node = nodesMap.get(nodeId); + if (node && isGroup(node)) { + groupIds.push(nodeId); + } + } + + // Add descendants directly to set + for (const groupId of groupIds) { + for (const descendantId of this.flowCore.modelLookup.getAllDescendantIds(groupId)) { + nodeIds.add(descendantId); + } + } + } + + /** + * Collects edges connected to primary visible nodes and identifies external nodes. + * External nodes are nodes outside viewport but connected to visible nodes. + */ + private collectVisibleEdges(primaryVisibleIds: Set): { + edges: Edge[]; + edgeIds: Set; + externalNodeIds: Set; + } { + const edges: Edge[] = []; + const edgeIds = new Set(); + const externalNodeIds = new Set(); + + for (const nodeId of primaryVisibleIds) { + for (const edge of this.flowCore.modelLookup.getConnectedEdges(nodeId)) { + if (edgeIds.has(edge.id)) continue; + + edges.push(edge); + edgeIds.add(edge.id); + + // Add external nodes (endpoints not in primary visible set) + if (!primaryVisibleIds.has(edge.source)) externalNodeIds.add(edge.source); + if (!primaryVisibleIds.has(edge.target)) externalNodeIds.add(edge.target); + } + } + + return { edges, edgeIds, externalNodeIds }; + } + + /** + * Builds the final node list from primary visible and external node IDs. + */ + private buildNodeList( + primaryVisibleIds: Set, + externalNodeIds: Set + ): { nodes: Node[]; nodeIds: Set } { + const nodesMap = this.flowCore.modelLookup.nodesMap; + const nodes: Node[] = []; + const nodeIds = new Set(); + + for (const id of primaryVisibleIds) { + const node = nodesMap.get(id); + if (node) { + nodes.push(node); + nodeIds.add(id); + } + } + + for (const id of externalNodeIds) { + const node = nodesMap.get(id); + if (node) { + nodes.push(node); + nodeIds.add(id); + } + } + + return { nodes, nodeIds }; + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index e4788429f..f39f33e5e 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -395,6 +395,36 @@ export interface BoxSelectionConfig { realtime?: boolean; } +/** + * Configuration for viewport virtualization behavior. + * When enabled, only nodes and edges visible in the viewport (plus padding) are rendered, + * significantly improving performance for large diagrams. + * + * @category Types/Configuration/Features + */ +export interface VirtualizationConfig { + /** + * Whether viewport virtualization is enabled. + * When disabled, all nodes/edges are rendered regardless of viewport. + * @default true + */ + enabled: boolean; + + /** + * Padding in flow coordinates around the viewport. + * Nodes within this padding area are pre-rendered for smoother scrolling. + * @default 200 + */ + padding: number; + + /** + * Maximum number of nodes below which virtualization is skipped. + * If fewer nodes exist than this threshold, render all nodes. + * @default 500 + */ + nodeCountThreshold: number; +} + /** * The main configuration interface for the flow system. * @@ -472,6 +502,12 @@ export interface FlowConfig { */ boxSelection: BoxSelectionConfig; + /** + * Configuration for viewport virtualization. + * Improves performance for large diagrams by only rendering visible elements. + */ + virtualization: VirtualizationConfig; + /** * Configuration for keyboard shortcuts. */ diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/index.ts index fe35af17b..0c6d9802c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/index.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/index.ts @@ -30,3 +30,6 @@ export * from './action-state.interface'; // Shortcuts and Actions export * from './shortcut-action.interface'; export * from './shortcut.interface'; + +// Render strategy types +export * from '../render-strategy/render-strategy.interface'; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts index 4330d66de..239a1dc4c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts @@ -134,6 +134,22 @@ export interface FlowStateUpdate { edgesToRemove?: string[]; /** Partial metadata update (viewport, selection, etc.) */ metadataUpdate?: Partial; + /** + * IDs of nodes currently rendered in the viewport. + * - `undefined` means all nodes are rendered (DirectRenderStrategy / no virtualization) + * - `string[]` means only those specific IDs are rendered (VirtualizedRenderStrategy) + * Used by 'init' action to track which nodes need measurement. + * @internal + */ + renderedNodeIds?: string[]; + /** + * IDs of edges currently rendered in the viewport. + * - `undefined` means all edges are rendered (DirectRenderStrategy / no virtualization) + * - `string[]` means only those specific IDs are rendered (VirtualizedRenderStrategy) + * Used by 'init' action to track which edges need measurement. + * @internal + */ + renderedEdgeIds?: string[]; } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.test.ts index 27528325d..14693676b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.test.ts @@ -8,6 +8,7 @@ describe('InitUpdater', () => { let mockFlowCore: { getState: Mock; setState: Mock; + getRenderedModel: Mock; internalUpdater: { addPort: Mock; addEdgeLabel: Mock; @@ -74,6 +75,7 @@ describe('InitUpdater', () => { mockFlowCore = { getState: vi.fn(), setState: vi.fn(), + getRenderedModel: vi.fn(), internalUpdater: { addPort: vi.fn(), addEdgeLabel: vi.fn(), @@ -90,7 +92,7 @@ describe('InitUpdater', () => { describe('constructor', () => { it('should initialize with isInitialized=false when no nodes or edges', () => { - mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [] }); + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -98,7 +100,7 @@ describe('InitUpdater', () => { }); it('should initialize with isInitialized=false when nodes exist', () => { - mockFlowCore.getState.mockReturnValue({ + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [createMockNode('node1')], edges: [], }); @@ -109,7 +111,7 @@ describe('InitUpdater', () => { }); it('should initialize with isInitialized=false when edges exist', () => { - mockFlowCore.getState.mockReturnValue({ + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [createMockEdge('edge1')], }); @@ -122,6 +124,7 @@ describe('InitUpdater', () => { describe('start', () => { it('should mark as initialized immediately with no entities', async () => { + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -134,6 +137,7 @@ describe('InitUpdater', () => { }); it('should call onComplete callback after initialization', async () => { + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -148,6 +152,7 @@ describe('InitUpdater', () => { it('should wait for node measurements before finishing', async () => { const node = { ...createMockNode('node1'), size: undefined }; + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -181,6 +186,7 @@ describe('InitUpdater', () => { }, ], }; + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -212,6 +218,7 @@ describe('InitUpdater', () => { }, ], }; + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [edge] }); mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [edge] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -231,6 +238,7 @@ describe('InitUpdater', () => { }); it('should handle async onComplete callback', async () => { + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -248,6 +256,7 @@ describe('InitUpdater', () => { describe('applyNodeSize', () => { it('should record node size measurement and apply on finish', async () => { const node = { ...createMockNode('node1'), size: undefined }; + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -268,6 +277,7 @@ describe('InitUpdater', () => { it('should queue measurement if finishing', async () => { const node = { ...createMockNode('node1'), size: undefined }; + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -297,6 +307,7 @@ describe('InitUpdater', () => { describe('addPort', () => { beforeEach(() => { const node = createMockNode('node1'); + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); }); @@ -352,6 +363,7 @@ describe('InitUpdater', () => { }, ], }; + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -384,6 +396,7 @@ describe('InitUpdater', () => { }, ], }; + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -406,6 +419,7 @@ describe('InitUpdater', () => { describe('addEdgeLabel', () => { beforeEach(() => { const edge = createMockEdge('edge1'); + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [edge] }); mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [edge] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); }); @@ -448,6 +462,7 @@ describe('InitUpdater', () => { describe('applyEdgeLabelSize', () => { beforeEach(() => { const edge = createMockEdge('edge1', true); + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [edge] }); mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [edge] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); }); @@ -469,6 +484,7 @@ describe('InitUpdater', () => { describe('late arrival queueing', () => { it('should queue port additions that arrive during finish', async () => { const node = { ...createMockNode('node1'), size: undefined }; + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -496,6 +512,7 @@ describe('InitUpdater', () => { it('should queue measurements that arrive during finish', async () => { const node = { ...createMockNode('node1'), size: undefined }; + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -525,6 +542,7 @@ describe('InitUpdater', () => { }); it('should queue edge label additions that arrive during finish', async () => { + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -549,6 +567,7 @@ describe('InitUpdater', () => { describe('state application', () => { it('should apply all collected data on finish', async () => { const node = createMockNode('node1'); + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -565,6 +584,7 @@ describe('InitUpdater', () => { it('should merge new ports with existing ports', async () => { const node = createMockNode('node1', true); + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -589,6 +609,7 @@ describe('InitUpdater', () => { it('should merge new labels with existing labels', async () => { const edge = createMockEdge('edge1', true); + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [edge] }); mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [edge] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -612,6 +633,7 @@ describe('InitUpdater', () => { describe('safety timeout', () => { it('should force finish after measurement timeout when measurements never arrive', async () => { const node = { ...createMockNode('node1'), size: undefined }; + mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); @@ -651,6 +673,10 @@ describe('InitUpdater', () => { ], }; const edge1 = createMockEdge('edge1'); + mockFlowCore.getRenderedModel.mockReturnValue({ + nodes: [node1, node2], + edges: [edge1], + }); mockFlowCore.getState.mockReturnValue({ nodes: [node1, node2], edges: [edge1], diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts index 06dea4be5..5dc4fec0e 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts @@ -38,10 +38,13 @@ Documentation: https://www.ngdiagram.dev/docs/guides/model-initialization/ * Strategy: * 1. Wait for entity creation to stabilize (addPort, addEdgeLabel) using StabilityDetectors * 2. Immediately collect measurements (applyNodeSize, applyPortsSizesAndPositions, applyEdgeLabelSize) - * 3. Finish when: entities stabilized AND all entities have measurements + * 3. Finish when: entities stabilized AND all rendered entities have measurements * 4. Apply everything in one setState * 5. Queue late arrivals to prevent data loss during finish transition * 6. Safety timeout: If measurements don't arrive within MEASUREMENT_TIMEOUT, force finish + * + * Note: With virtualization, only rendered nodes/edges are tracked for initialization. + * Non-rendered elements will be initialized when they become visible. */ export class InitUpdater implements Updater { /** Flag indicating whether initialization has completed */ @@ -51,10 +54,10 @@ export class InitUpdater implements Updater { private entitiesStabilized = false; /** Detects when port additions have stabilized */ - private portStabilityDetector: StabilityDetector; + private portStabilityDetector!: StabilityDetector; /** Detects when label additions have stabilized */ - private labelStabilityDetector: StabilityDetector; + private labelStabilityDetector!: StabilityDetector; /** Collects all initialization data and applies it to diagram state */ private initState: InitState; @@ -70,18 +73,10 @@ export class InitUpdater implements Updater { /** * Creates a new InitUpdater. - * Initializes stability detectors based on whether nodes/edges exist. * * @param flowCore - The FlowCore instance to update */ constructor(private flowCore: FlowCore) { - const state = flowCore.getState(); - const hasNodes = state.nodes.length > 0; - const hasEdges = state.edges.length > 0; - - this.portStabilityDetector = new StabilityDetector(hasNodes, STABILITY_DELAY); - this.labelStabilityDetector = new StabilityDetector(hasEdges, STABILITY_DELAY); - this.initState = new InitState(); this.lateArrivalQueue = new LateArrivalQueue(); } @@ -95,8 +90,15 @@ export class InitUpdater implements Updater { start(onComplete?: () => void | Promise) { this.onCompleteCallback = onComplete; - const state = this.flowCore.getState(); - this.initState.collectAlreadyMeasuredItems(state.nodes, state.edges); + const { nodes, edges } = this.flowCore.getRenderedModel(); + + const hasNodes = nodes.length > 0; + const hasEdges = edges.length > 0; + + this.portStabilityDetector = new StabilityDetector(hasNodes, STABILITY_DELAY); + this.labelStabilityDetector = new StabilityDetector(hasEdges, STABILITY_DELAY); + + this.initState.collectAlreadyMeasuredItems(nodes, edges); Promise.all([this.portStabilityDetector.waitForStability(), this.labelStabilityDetector.waitForStability()]) .then(() => { @@ -202,7 +204,7 @@ export class InitUpdater implements Updater { /** * Attempts to finish initialization if conditions are met. - * Conditions: not already finishing, not initialized, entities stabilized, all entities measured. + * Conditions: not already finishing, not initialized, entities stabilized, all rendered entities measured. */ private tryFinish() { if (this.lateArrivalQueue.isFinishing || this.isInitialized) { @@ -213,8 +215,8 @@ export class InitUpdater implements Updater { return; } - const state = this.flowCore.getState(); - if (this.initState.allEntitiesHaveMeasurements(state.nodes.length)) { + const { nodes } = this.flowCore.getRenderedModel(); + if (this.initState.allEntitiesHaveMeasurements(nodes.length)) { this.finish(); } } @@ -254,8 +256,8 @@ export class InitUpdater implements Updater { private startMeasurementTimeout(): void { this.measurementTimeout = setTimeout(() => { if (!this.isInitialized) { - const state = this.flowCore.getState(); - const nodeCount = state.nodes.length; + const { nodes } = this.flowCore.getRenderedModel(); + const nodeCount = nodes.length; const expectedPorts = this.initState.portsToMeasure.size; const measuredPorts = this.initState.measuredPorts.size; const expectedLabels = this.initState.labelsToMeasure.size; diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-resize-observer/flow-resize-processor.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-resize-observer/flow-resize-processor.service.ts index 8834c224f..7afabfcee 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-resize-observer/flow-resize-processor.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-resize-observer/flow-resize-processor.service.ts @@ -43,6 +43,9 @@ export class FlowResizeBatchProcessorService { * Main batch processor - handles all resize events in one go */ private processAllResizes(entries: ResizeObserverEntry[]): void { + console.log(`[PERF] processAllResizes called with ${entries.length} entries`); + console.time(`[PERF] processAllResizes TOTAL`); + // Ensure service is initialized if (!this.isInitialized) { console.warn('FlowResizeBatchProcessorService not initialized yet, skipping resize processing'); @@ -74,19 +77,31 @@ export class FlowResizeBatchProcessorService { } } + console.log( + `[PERF] Categorized: ${nodeEntries.length} nodes, ${portEntries.length} ports, ${edgeLabelEntries.length} labels` + ); + // Process all ports together if (portEntries.length > 0) { + console.time(`[PERF] processPortBatch`); this.processPortBatch(portEntries); + console.timeEnd(`[PERF] processPortBatch`); } // Process all edge labels together if (edgeLabelEntries.length > 0) { + console.time(`[PERF] processEdgeLabelBatch`); this.processEdgeLabelBatch(edgeLabelEntries); + console.timeEnd(`[PERF] processEdgeLabelBatch`); } if (nodeEntries.length > 0) { + console.time(`[PERF] processNodeBatch`); this.processNodeBatch(nodeEntries); + console.timeEnd(`[PERF] processNodeBatch`); } + + console.timeEnd(`[PERF] processAllResizes TOTAL`); } /** @@ -149,6 +164,8 @@ export class FlowResizeBatchProcessorService { */ private processNodeBatch(entries: ProcessedEntry[]): void { const flowCore = this.flowCoreProvider.provide(); + let appliedCount = 0; + let skippedCount = 0; for (const { entry, metadata } of entries) { if (metadata?.type !== 'node') continue; @@ -158,9 +175,11 @@ export class FlowResizeBatchProcessorService { const currentSize = flowCore.getNodeById(metadata.nodeId)?.size; if (currentSize && !this.isSizeChanged(currentSize, size)) { + skippedCount++; continue; } + appliedCount++; flowCore.updater.applyNodeSize(metadata.nodeId, size); // Skip port measurement during active resize performed by user to avoid redundant updates @@ -170,6 +189,8 @@ export class FlowResizeBatchProcessorService { flowCore.updater.applyPortsSizesAndPositions(metadata.nodeId, portsData); } } + + console.log(`[PERF] processNodeBatch: ${appliedCount} applied, ${skippedCount} skipped (size unchanged)`); } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts index 23e3a27c1..91f7355a3 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts @@ -17,9 +17,31 @@ export class RendererService implements Renderer { scale: 1, }); + private lastNodeCount = 0; + draw(nodes: Node[], edges: Edge[], viewport: Viewport): void { - this.nodes.set(nodes); - this.edges.set(edges); - this.viewport.set(viewport); + const nodeCountDiff = nodes.length - this.lastNodeCount; + + // Only log when new nodes are being added (not during panning) + if (nodeCountDiff > 0) { + const startTime = performance.now(); + console.log(`[PERF] renderer.draw: adding ${nodeCountDiff} nodes (${this.lastNodeCount} -> ${nodes.length})`); + + this.nodes.set(nodes); + this.edges.set(edges); + this.viewport.set(viewport); + + // Measure time until Angular finishes DOM creation (next frame) + requestAnimationFrame(() => { + const afterAngularTime = performance.now(); + console.log(`[PERF] Angular DOM created in ${(afterAngularTime - startTime).toFixed(2)}ms`); + }); + } else { + this.nodes.set(nodes); + this.edges.set(edges); + this.viewport.set(viewport); + } + + this.lastNodeCount = nodes.length; } } From 78f94a3d0ab6c584ed592d59f9304376463ca598 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 11 Dec 2025 08:14:30 +0100 Subject: [PATCH 02/42] fix logs --- .../projects/ng-diagram/src/core/src/flow-core.ts | 10 +++++----- .../core/src/middleware-manager/middleware-executor.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts index fe58dc34d..4632f624a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts @@ -307,15 +307,15 @@ export class FlowCore { try { // ===== PERF LOGGING - only for resize-related actions ===== const logActions = ['resizeNode', 'updatePorts', 'updateEdgeLabels', 'addPorts', 'addEdgeLabels']; - const shouldLog = logActions.includes(modelActionType as string); + const shouldLog = logActions.includes(modelActionTypes as string); - if (shouldLog) console.time(`[PERF] applyUpdate TOTAL (${modelActionType})`); + if (shouldLog) console.time(`[PERF] applyUpdate TOTAL (${modelActionTypes})`); const currentState = this.getState(); - if (shouldLog) console.time(`[PERF] middleware.execute (${modelActionType})`); + if (shouldLog) console.time(`[PERF] middleware.execute (${modelActionTypes})`); const finalState = await this.middlewareManager.execute(currentState, stateUpdate, actionTypesArray); - if (shouldLog) console.timeEnd(`[PERF] middleware.execute (${modelActionType})`); + if (shouldLog) console.timeEnd(`[PERF] middleware.execute (${modelActionTypes})`); if (finalState) { if (shouldLog) console.time(`[PERF] setState`); @@ -326,7 +326,7 @@ export class FlowCore { this.eventManager.clearDeferredEmits(); } - if (shouldLog) console.timeEnd(`[PERF] applyUpdate TOTAL (${modelActionType})`); + if (shouldLog) console.timeEnd(`[PERF] applyUpdate TOTAL (${modelActionTypes})`); } finally { // Always release the semaphore, even if an error occurs this.updateSemaphore.release(); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts index e8521168d..b77c95d56 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts @@ -46,9 +46,9 @@ export class MiddlewareExecutor { modelActionTypes: ModelActionTypes ): Promise { const logActions = ['resizeNode', 'updatePorts', 'updateEdgeLabels', 'addPorts', 'addEdgeLabels']; - const shouldLog = logActions.includes(modelActionType); + const shouldLog = modelActionTypes.some((type) => logActions.includes(type)); - if (shouldLog) console.time(`[PERF] executor.run setup (${modelActionType})`); + if (shouldLog) console.time(`[PERF] executor.run setup (${modelActionTypes})`); this.initialState = initialState; this.modelActionTypes = modelActionTypes; @@ -64,11 +64,11 @@ export class MiddlewareExecutor { this.initialStateUpdate = stateUpdate; this.applyStateUpdate(stateUpdate); - if (shouldLog) console.timeEnd(`[PERF] executor.run setup (${modelActionType})`); + if (shouldLog) console.timeEnd(`[PERF] executor.run setup (${modelActionTypes})`); - if (shouldLog) console.time(`[PERF] resolveMiddlewares (${modelActionType})`); + if (shouldLog) console.time(`[PERF] resolveMiddlewares (${modelActionTypes})`); const result = await this.resolveMiddlewares(); - if (shouldLog) console.timeEnd(`[PERF] resolveMiddlewares (${modelActionType})`); + if (shouldLog) console.timeEnd(`[PERF] resolveMiddlewares (${modelActionTypes})`); return result; } From 680e790a1b05fa311e78d20917b7fc648b6bbfbc Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Fri, 12 Dec 2025 08:41:11 +0100 Subject: [PATCH 03/42] performance improvements --- apps/angular-demo/src/app/app.component.html | 2 - apps/angular-demo/src/app/app.component.ts | 17 +- .../commands/add-update-delete.ts | 109 +++++++++++++ .../src/command-handler/commands/index.ts | 6 + .../command-handler/commands/resize-node.ts | 4 +- .../src/event-manager/internal-event-types.ts | 34 ++++ .../src/flow-config/default-flow-config.ts | 12 +- .../ng-diagram/src/core/src/flow-core.ts | 63 +++++--- .../handlers/panning/panning.handler.ts | 58 ++++++- .../handlers/panning/panning.test.ts | 67 +++++++- .../middleware-manager/middleware-executor.ts | 65 +++++--- .../port-batch-processor.ts | 151 +++++++++++++++++- .../render-strategy/buffer-fill-manager.ts | 54 +++++++ .../src/core/src/render-strategy/index.ts | 1 + .../virtualized-render-strategy.test.ts | 5 + .../virtualized-render-strategy.ts | 27 ++++ .../src/types/command-handler.interface.ts | 6 + .../core/src/types/flow-config.interface.ts | 34 +++- .../src/updater/init-updater/init-updater.ts | 18 +++ .../init-updater/late-arrival-queue.ts | 4 + .../internal-updater/internal-updater.ts | 72 +++++++-- .../src/core/src/updater/updater.interface.ts | 5 + .../node/ng-diagram-node.component.ts | 27 +++- .../port/ng-diagram-port.component.ts | 25 +-- .../flow-core-provider.service.ts | 1 + .../flow-resize-processor.service.ts | 51 +++--- .../lib/services/renderer/renderer.service.ts | 10 +- 27 files changed, 803 insertions(+), 125 deletions(-) create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/buffer-fill-manager.ts diff --git a/apps/angular-demo/src/app/app.component.html b/apps/angular-demo/src/app/app.component.html index 8a6b729ee..4083bd95f 100644 --- a/apps/angular-demo/src/app/app.component.html +++ b/apps/angular-demo/src/app/app.component.html @@ -6,10 +6,8 @@ [config]="config" (diagramInit)="onDiagramInit($event)" (selectionChanged)="onSelectionChanged($event)" - (selectionMoved)="onSelectionMoved($event)" (groupMembershipChanged)="onGroupMembershipChanged($event)" (selectionRotated)="onSelectionRotated($event)" - (viewportChanged)="onViewportChanged($event)" (edgeDrawn)="onEdgeDrawn($event)" (clipboardPasted)="onClipboardPasted($event)" (nodeResized)="onNodeResized($event)" diff --git a/apps/angular-demo/src/app/app.component.ts b/apps/angular-demo/src/app/app.component.ts index 743c346d3..748936fd2 100644 --- a/apps/angular-demo/src/app/app.component.ts +++ b/apps/angular-demo/src/app/app.component.ts @@ -16,10 +16,8 @@ import { PaletteItemDroppedEvent, provideNgDiagram, SelectionChangedEvent, - SelectionMovedEvent, SelectionRemovedEvent, SelectionRotatedEvent, - ViewportChangedEvent, type Edge, type EdgeLabel, type Node, @@ -114,19 +112,6 @@ export class AppComponent { }); } - onSelectionMoved(event: SelectionMovedEvent): void { - console.log('Selection Moved:', { - nodes: event.nodes.map((n: Node) => n.id), - }); - } - - onViewportChanged(event: ViewportChangedEvent): void { - console.log('Viewport Changed:', { - current: event.viewport, - previous: event.previousViewport, - }); - } - onEdgeDrawn(event: EdgeDrawnEvent): void { console.log('Edge Drawn:', { edge: event.edge.id, @@ -194,7 +179,7 @@ export class AppComponent { } // Generate 20k nodes in a grid pattern for virtualization testing - model = initializeModel(this.generateLargeModel(5000)); + model = initializeModel(this.generateLargeModel(20000)); private generateLargeModel(nodeCount: number): { nodes: Node[]; edges: Edge[] } { const nodes: Node[] = []; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts index aad331ca4..d65ab301c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts @@ -144,6 +144,41 @@ export const addPorts = async (commandHandler: CommandHandler, command: AddPorts await commandHandler.flowCore.applyUpdate({ nodesToUpdate: [{ id: nodeId, measuredPorts: newPorts }] }, 'updateNode'); }; +/** + * Bulk add ports for multiple nodes in a single middleware execution. + * This is optimized for virtualization scenarios where many nodes need port additions simultaneously. + */ +export interface AddPortsBulkCommand { + name: 'addPortsBulk'; + additions: Map; +} + +export const addPortsBulk = async (commandHandler: CommandHandler, command: AddPortsBulkCommand) => { + const { additions } = command; + const nodesToUpdate: { id: string; measuredPorts: Port[] }[] = []; + + additions.forEach((ports, nodeId) => { + const node = commandHandler.flowCore.getNodeById(nodeId); + if (!node) { + return; + } + + // Same logic as addPorts - update existing ports with matching IDs + const newPortIds = new Set(ports.map((port) => port.id)); + const existingPortsToKeep = (node.measuredPorts ?? []).filter((port) => !newPortIds.has(port.id)); + const newPorts = [...existingPortsToKeep, ...ports]; + + nodesToUpdate.push({ id: nodeId, measuredPorts: newPorts }); + }); + + if (nodesToUpdate.length === 0) { + return; + } + + // Single middleware execution for all nodes + await commandHandler.flowCore.applyUpdate({ nodesToUpdate }, 'addPortsBulk'); +}; + export interface UpdatePortsCommand { name: 'updatePorts'; nodeId: string; @@ -175,6 +210,47 @@ export const updatePorts = async (commandHandler: CommandHandler, command: Updat ); }; +/** + * Bulk update ports for multiple nodes in a single middleware execution. + * This is optimized for virtualization scenarios where many nodes need port updates simultaneously. + */ +export interface UpdatePortsBulkCommand { + name: 'updatePortsBulk'; + updates: Map }[]>; +} + +export const updatePortsBulk = async (commandHandler: CommandHandler, command: UpdatePortsBulkCommand) => { + const { updates } = command; + const nodesToUpdate: { id: string; measuredPorts: Port[] }[] = []; + + updates.forEach((portUpdates, nodeId) => { + const node = commandHandler.flowCore.getNodeById(nodeId); + if (!node || !node.measuredPorts) { + return; + } + + const updatedPorts = node.measuredPorts.map((port) => { + const portChanges = portUpdates.find(({ portId }) => portId === port.id)?.portChanges; + if (!portChanges) { + return port; + } + return { + ...port, + ...portChanges, + }; + }); + + nodesToUpdate.push({ id: nodeId, measuredPorts: updatedPorts }); + }); + + if (nodesToUpdate.length === 0) { + return; + } + + // Single middleware execution for all nodes + await commandHandler.flowCore.applyUpdate({ nodesToUpdate }, 'updatePortsBulk'); +}; + export interface DeletePortsCommand { name: 'deletePorts'; nodeId: string; @@ -194,6 +270,39 @@ export const deletePorts = async (commandHandler: CommandHandler, command: Delet ); }; +/** + * Bulk delete ports for multiple nodes in a single middleware execution. + * This is optimized for virtualization scenarios where many nodes are destroyed simultaneously. + */ +export interface DeletePortsBulkCommand { + name: 'deletePortsBulk'; + deletions: Map; +} + +export const deletePortsBulk = async (commandHandler: CommandHandler, command: DeletePortsBulkCommand) => { + const { deletions } = command; + const nodesToUpdate: { id: string; measuredPorts: Port[] }[] = []; + + deletions.forEach((portIds, nodeId) => { + const node = commandHandler.flowCore.getNodeById(nodeId); + if (!node) { + return; + } + + const portIdsSet = new Set(portIds); + const remainingPorts = (node.measuredPorts ?? []).filter((port) => !portIdsSet.has(port.id)); + + nodesToUpdate.push({ id: nodeId, measuredPorts: remainingPorts }); + }); + + if (nodesToUpdate.length === 0) { + return; + } + + // Single middleware execution for all nodes + await commandHandler.flowCore.applyUpdate({ nodesToUpdate }, 'deletePortsBulk'); +}; + export interface AddEdgeLabelsCommand { name: 'addEdgeLabels'; edgeId: string; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/index.ts index f5b9d646c..872286122 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/index.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/index.ts @@ -5,11 +5,13 @@ import { addEdges, addNodes, addPorts, + addPortsBulk, clearModel, deleteEdgeLabels, deleteEdges, deleteNodes, deletePorts, + deletePortsBulk, paletteDropNode, updateEdge, updateEdgeLabels, @@ -17,6 +19,7 @@ import { updateNode, updateNodes, updatePorts, + updatePortsBulk, } from './add-update-delete'; import { centerOnNode, centerOnRect } from './centering'; import { copy, paste } from './copy-paste'; @@ -80,8 +83,11 @@ export const commands: CommandMap = { resizeNode, zoom, addPorts, + addPortsBulk, updatePorts, + updatePortsBulk, deletePorts, + deletePortsBulk, bringToFront, sendToBack, addEdgeLabels, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/resize-node.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/resize-node.ts index d18430b6a..53df99eb3 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/resize-node.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/resize-node.ts @@ -188,7 +188,9 @@ const handleSingleNodeResize = async (commandHandler: CommandHandler, command: R }; /** - * Handles missing initial size in case of dropping from the palette + * Handles missing initial size in case of dropping from the palette. + * Note: For bulk initial size updates (e.g., virtualization), prefer using + * processNodeBatch in FlowResizeBatchProcessorService which batches these updates. */ const handleMissingInitialSize = async (commandHandler: CommandHandler, command: ResizeNodeCommand): Promise => { const node = commandHandler.flowCore.getNodeById(command.id); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/internal-event-types.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/internal-event-types.ts index df5c48c1c..11de1c563 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/internal-event-types.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/internal-event-types.ts @@ -17,6 +17,22 @@ export interface InternalDiagramEventMap extends DiagramEventMap { * @internal - For internal use only. Use the `actionState` signal on NgDiagramService instead. */ actionStateChanged: ActionStateChangedEvent; + + /** + * Event emitted when panning starts (mouse down on canvas for pan). + * Used internally by BufferFillManager to cancel pending buffer fills. + * + * @internal + */ + panStarted: PanStartedEvent; + + /** + * Event emitted when panning ends (mouse up after pan). + * Used internally by BufferFillManager to schedule buffer fill. + * + * @internal + */ + panEnded: PanEndedEvent; } /** @@ -31,3 +47,21 @@ export interface ActionStateChangedEvent { /** The current action state */ actionState: Readonly; } + +/** + * Event payload emitted when panning starts. + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface PanStartedEvent { + // Empty payload - just signals pan started +} + +/** + * Event payload emitted when panning ends. + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface PanEndedEvent { + // Empty payload - just signals pan ended +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts index 981d43db3..8d2dbdf4b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts @@ -4,6 +4,7 @@ import type { Edge } from '../types/edge.interface'; import type { BackgroundConfig, BoxSelectionConfig, + BufferFillConfig, EdgeRoutingConfig, FlowConfig, GroupingConfig, @@ -134,10 +135,17 @@ const defaultBoxSelectionConfig: BoxSelectionConfig = { realtime: true, }; -const defaultVirtualizationConfig: VirtualizationConfig = { +const defaultBufferFillConfig: BufferFillConfig = { enabled: true, - padding: 500, // Larger padding reduces frequency of hitting viewport edge during fast panning + idleThreshold: 100, // ms to wait after pan stops before filling buffer +}; + +const defaultVirtualizationConfig: VirtualizationConfig = { + enabled: false, // Disabled by default - users must explicitly enable for large diagrams + padding: 300, // Smaller padding during active pan for better performance + expandedPadding: 1500, // Large buffer filled during idle time after panning stops nodeCountThreshold: 500, + bufferFill: defaultBufferFillConfig, }; /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts index 4632f624a..ead0ca29b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts @@ -9,7 +9,7 @@ import { MiddlewareManager } from './middleware-manager/middleware-manager'; import { loggerMiddleware } from './middleware-manager/middlewares'; import { ModelLookup } from './model-lookup/model-lookup'; import { PortBatchProcessor } from './port-batch-processor/port-batch-processor'; -import { DirectRenderStrategy, RenderStrategy, VirtualizedRenderStrategy } from './render-strategy'; +import { BufferFillManager, DirectRenderStrategy, RenderStrategy, VirtualizedRenderStrategy } from './render-strategy'; import { ShortcutManager } from './shortcut-manager'; import { SpatialHash } from './spatial-hash/spatial-hash'; import { @@ -66,6 +66,7 @@ export class FlowCore { private readonly directRenderStrategy: DirectRenderStrategy; private readonly virtualizedRenderStrategy: VirtualizedRenderStrategy; + private bufferFillManager: BufferFillManager | null = null; readonly getFlowOffset: () => Point; @@ -102,12 +103,18 @@ export class FlowCore { this.shortcutManager = new ShortcutManager(this); + // Initialize buffer fill manager if virtualization and buffer fill are enabled + if (this.config.virtualization.enabled && this.config.virtualization.bufferFill.enabled) { + this.bufferFillManager = new BufferFillManager(this, this.config.virtualization.bufferFill.idleThreshold); + } + this.inputEventsRouter.registerDefaultCallbacks(this); this.init(); } destroy() { + this.bufferFillManager?.destroy(); this.eventManager.offAll(); this.model.destroy(); } @@ -121,8 +128,10 @@ export class FlowCore { // this.render(); this.model.onChange((state) => { + const nodesChanged = state.nodes !== this.lastNodesRef; + // Only update spatial hash when nodes actually changed (skip during panning/zooming) - if (state.nodes !== this.lastNodesRef) { + if (nodesChanged) { this.spatialHash.process(state.nodes); this.modelLookup.desynchronize(); this.lastNodesRef = state.nodes; @@ -305,28 +314,15 @@ export class FlowCore { await this.updateSemaphore.acquire(); try { - // ===== PERF LOGGING - only for resize-related actions ===== - const logActions = ['resizeNode', 'updatePorts', 'updateEdgeLabels', 'addPorts', 'addEdgeLabels']; - const shouldLog = logActions.includes(modelActionTypes as string); - - if (shouldLog) console.time(`[PERF] applyUpdate TOTAL (${modelActionTypes})`); - const currentState = this.getState(); - - if (shouldLog) console.time(`[PERF] middleware.execute (${modelActionTypes})`); const finalState = await this.middlewareManager.execute(currentState, stateUpdate, actionTypesArray); - if (shouldLog) console.timeEnd(`[PERF] middleware.execute (${modelActionTypes})`); if (finalState) { - if (shouldLog) console.time(`[PERF] setState`); this.setState(finalState); - if (shouldLog) console.timeEnd(`[PERF] setState`); this.eventManager.flushDeferredEmits(); } else { this.eventManager.clearDeferredEmits(); } - - if (shouldLog) console.timeEnd(`[PERF] applyUpdate TOTAL (${modelActionTypes})`); } finally { // Always release the semaphore, even if an error occurs this.updateSemaphore.release(); @@ -374,8 +370,6 @@ export class FlowCore { }; } - private lastRenderedNodeCount = 0; - /** * Renders the flow */ @@ -386,14 +380,32 @@ export class FlowCore { // Apply render strategy (virtualization or direct) const { nodes: visibleNodes, edges: visibleEdges } = this.renderStrategy.process(nodes, edges, metadata.viewport); - // Log when node count changes (new nodes rendered) - const diff = visibleNodes.length - this.lastRenderedNodeCount; - if (diff > 0) { - console.log(`[PERF] render: ${diff} NEW nodes (${this.lastRenderedNodeCount} -> ${visibleNodes.length})`); + const finalEdges = temporaryEdge?.temporary ? [...visibleEdges, temporaryEdge] : visibleEdges; + + this.renderer.draw(visibleNodes, finalEdges, metadata.viewport); + } + + /** + * Renders with expanded buffer padding - called during pan idle to preload more nodes. + * Only effective when virtualization is enabled. + */ + renderWithExpandedBuffer(): void { + if (!this.config.virtualization.enabled) { + return; } - this.lastRenderedNodeCount = visibleNodes.length; + + const { nodes, edges, metadata } = this.getState(); + const temporaryEdge = this.actionStateManager.linking?.temporaryEdge; + + // Use expanded buffer for preloading + const { nodes: visibleNodes, edges: visibleEdges } = this.virtualizedRenderStrategy.processWithExpandedBuffer( + nodes, + edges, + metadata.viewport + ); const finalEdges = temporaryEdge?.temporary ? [...visibleEdges, temporaryEdge] : visibleEdges; + this.renderer.draw(visibleNodes, finalEdges, metadata.viewport); } @@ -547,6 +559,13 @@ export class FlowCore { return this.initUpdater.isInitialized ? this.internalUpdater : this.initUpdater; } + /** + * Returns true if the diagram has completed its initialization phase. + */ + get isInitialized(): boolean { + return this.initUpdater.isInitialized; + } + setDebugMode(debugMode: boolean): void { if (debugMode) { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts index 2fda9c381..30e1c2ea4 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts @@ -2,15 +2,32 @@ import { Point } from '../../../types'; import { EventHandler } from '../event-handler'; import { PanningEvent } from './panning.event'; +/** + * Handles panning events with RAF-based throttling to reduce middleware executions. + * Accumulates viewport deltas and emits them once per animation frame instead of + * on every pointer move event. + */ export class PanningEventHandler extends EventHandler { private lastPoint: Point | undefined; private isPanning = false; + /** Accumulated delta since last RAF flush */ + private accumulatedDelta: Point = { x: 0, y: 0 }; + + /** Whether a RAF callback is scheduled */ + private rafScheduled = false; + + /** Count of accumulated events since last flush */ + private accumulatedEventCount = 0; + handle(event: PanningEvent): void { switch (event.phase) { case 'start': { this.lastPoint = event.lastInputPoint; this.isPanning = true; + this.accumulatedDelta = { x: 0, y: 0 }; + this.accumulatedEventCount = 0; + this.flow.eventManager.emit('panStarted', {}); break; } case 'continue': { @@ -18,17 +35,56 @@ export class PanningEventHandler extends EventHandler { break; } + // Accumulate delta instead of emitting immediately const x = event.lastInputPoint.x - this.lastPoint.x; const y = event.lastInputPoint.y - this.lastPoint.y; - this.flow.commandHandler.emit('moveViewportBy', { x, y }); + this.accumulatedDelta.x += x; + this.accumulatedDelta.y += y; this.lastPoint = event.lastInputPoint; + this.accumulatedEventCount++; + + // Schedule RAF if not already scheduled + this.scheduleFlush(); break; } case 'end': + // Flush any remaining delta immediately on end + this.flushDelta(); this.lastPoint = undefined; this.isPanning = false; + this.rafScheduled = false; + this.flow.eventManager.emit('panEnded', {}); break; } } + + /** + * Schedules a RAF callback to flush accumulated delta. + * Only one callback is scheduled at a time. + */ + private scheduleFlush(): void { + if (this.rafScheduled) { + return; + } + + this.rafScheduled = true; + requestAnimationFrame(() => { + this.flushDelta(); + this.rafScheduled = false; + }); + } + + /** + * Emits the accumulated viewport delta if non-zero, then resets it. + */ + private flushDelta(): void { + const { x, y } = this.accumulatedDelta; + + if (x !== 0 || y !== 0) { + this.flow.commandHandler.emit('moveViewportBy', { x, y }); + this.accumulatedDelta = { x: 0, y: 0 }; + this.accumulatedEventCount = 0; + } + } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts index f4eb2fd54..27577f98d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FlowCore } from '../../../flow-core'; import { mockEnvironment } from '../../../test-utils'; import { PanningEvent } from './panning.event'; @@ -27,9 +27,17 @@ describe('PanningEventHandler', () => { const mockCommandHandler = { emit: vi.fn() }; let mockFlowCore: FlowCore; let instance: PanningEventHandler; + let rafCallbacks: FrameRequestCallback[]; beforeEach(() => { vi.clearAllMocks(); + rafCallbacks = []; + + // Mock requestAnimationFrame to capture callbacks + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }); mockFlowCore = { getState: vi.fn(), @@ -41,6 +49,17 @@ describe('PanningEventHandler', () => { instance = new PanningEventHandler(mockFlowCore); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** Flush all pending RAF callbacks */ + function flushRAF() { + const callbacks = [...rafCallbacks]; + rafCallbacks = []; + callbacks.forEach((cb) => cb(performance.now())); + } + describe('handle', () => { describe('start phase', () => { it('should initialize panning state', () => { @@ -59,6 +78,9 @@ describe('PanningEventHandler', () => { instance.handle(continueEvent); + // Flush RAF to trigger the emit + flushRAF(); + expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 10, y: 10 }); }); }); @@ -72,9 +94,10 @@ describe('PanningEventHandler', () => { }); instance.handle(startEvent); vi.clearAllMocks(); + rafCallbacks = []; }); - it('should emit moveViewportBy with correct delta', () => { + it('should emit moveViewportBy with correct delta after RAF', () => { const event = getSamplePanningEvent({ phase: 'continue', lastInputPoint: { x: 110, y: 120 }, @@ -82,10 +105,16 @@ describe('PanningEventHandler', () => { instance.handle(event); + // Should not emit immediately + expect(mockCommandHandler.emit).not.toHaveBeenCalled(); + + // Flush RAF to trigger the emit + flushRAF(); + expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 10, y: 20 }); }); - it('should calculate movement relative to last position after multiple moves', () => { + it('should accumulate multiple moves and emit once per RAF', () => { // First move const firstEvent = getSamplePanningEvent({ phase: 'continue', @@ -93,15 +122,21 @@ describe('PanningEventHandler', () => { }); instance.handle(firstEvent); - // Second move + // Second move (before RAF fires) const secondEvent = getSamplePanningEvent({ phase: 'continue', lastInputPoint: { x: 120, y: 125 }, }); instance.handle(secondEvent); - // Should calculate delta from previous position (110,110) to new position (120,125) - expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 10, y: 15 }); + // Should not emit yet + expect(mockCommandHandler.emit).not.toHaveBeenCalled(); + + // Flush RAF - should emit accumulated delta (100,100) -> (120,125) = (20, 25) + flushRAF(); + + expect(mockCommandHandler.emit).toHaveBeenCalledTimes(1); + expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 20, y: 25 }); }); it('should not emit when not panning', () => { @@ -114,6 +149,7 @@ describe('PanningEventHandler', () => { }); freshInstance.handle(event); + flushRAF(); expect(mockCommandHandler.emit).not.toHaveBeenCalled(); }); @@ -128,6 +164,24 @@ describe('PanningEventHandler', () => { }); instance.handle(startEvent); vi.clearAllMocks(); + rafCallbacks = []; + }); + + it('should flush remaining delta immediately on end', () => { + // Continue with some movement + const continueEvent = getSamplePanningEvent({ + phase: 'continue', + lastInputPoint: { x: 110, y: 110 }, + }); + instance.handle(continueEvent); + + // End panning - should flush immediately without waiting for RAF + const endEvent = getSamplePanningEvent({ + phase: 'end', + }); + instance.handle(endEvent); + + expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 10, y: 10 }); }); it('should stop panning and clear state', () => { @@ -144,6 +198,7 @@ describe('PanningEventHandler', () => { }); instance.handle(continueEvent); + flushRAF(); expect(mockCommandHandler.emit).not.toHaveBeenCalled(); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts index b77c95d56..9b2ee66de 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts @@ -45,32 +45,44 @@ export class MiddlewareExecutor { stateUpdate: FlowStateUpdate, modelActionTypes: ModelActionTypes ): Promise { - const logActions = ['resizeNode', 'updatePorts', 'updateEdgeLabels', 'addPorts', 'addEdgeLabels']; - const shouldLog = modelActionTypes.some((type) => logActions.includes(type)); - - if (shouldLog) console.time(`[PERF] executor.run setup (${modelActionTypes})`); - this.initialState = initialState; this.modelActionTypes = modelActionTypes; this.metadata = initialState.metadata; - if (shouldLog) console.time(`[PERF] map copies`); - this.nodesMap = new Map(this.flowCore.modelLookup.nodesMap); - this.edgesMap = new Map(this.flowCore.modelLookup.edgesMap); - this.initialNodesMap = new Map(this.flowCore.modelLookup.nodesMap); - this.initialEdgesMap = new Map(this.flowCore.modelLookup.edgesMap); - if (shouldLog) console.timeEnd(`[PERF] map copies`); + // Optimization: Skip map copies for metadata-only updates (viewport changes) + // Middlewares for viewport changes early-exit without modifying nodes/edges + const isMetadataOnly = this.isMetadataOnlyUpdate(stateUpdate); + + if (isMetadataOnly) { + // Use direct references (read-only) - no copy needed + this.nodesMap = this.flowCore.modelLookup.nodesMap; + this.edgesMap = this.flowCore.modelLookup.edgesMap; + this.initialNodesMap = this.nodesMap; + this.initialEdgesMap = this.edgesMap; + } else { + // Full copy for node/edge changes + this.nodesMap = new Map(this.flowCore.modelLookup.nodesMap); + this.edgesMap = new Map(this.flowCore.modelLookup.edgesMap); + this.initialNodesMap = new Map(this.flowCore.modelLookup.nodesMap); + this.initialEdgesMap = new Map(this.flowCore.modelLookup.edgesMap); + } this.initialStateUpdate = stateUpdate; this.applyStateUpdate(stateUpdate); - if (shouldLog) console.timeEnd(`[PERF] executor.run setup (${modelActionTypes})`); - - if (shouldLog) console.time(`[PERF] resolveMiddlewares (${modelActionTypes})`); - const result = await this.resolveMiddlewares(); - if (shouldLog) console.timeEnd(`[PERF] resolveMiddlewares (${modelActionTypes})`); + return await this.resolveMiddlewares(); + } - return result; + private isMetadataOnlyUpdate(stateUpdate: FlowStateUpdate): boolean { + return ( + !stateUpdate.nodesToAdd?.length && + !stateUpdate.nodesToUpdate?.length && + !stateUpdate.nodesToRemove?.length && + !stateUpdate.edgesToAdd?.length && + !stateUpdate.edgesToUpdate?.length && + !stateUpdate.edgesToRemove?.length && + !!stateUpdate.metadataUpdate + ); } helpers = () => ({ @@ -106,11 +118,20 @@ export class MiddlewareExecutor { .filter((edge): edge is Edge => edge !== undefined), }); - private getState = (): FlowState => ({ - nodes: Array.from(this.nodesMap.values()), - edges: Array.from(this.edgesMap.values()), - metadata: this.metadata, - }); + private getState = (): FlowState => { + // Only create new arrays if nodes/edges were actually modified + // This preserves reference equality for viewport-only changes (panning, zooming) + const nodesChanged = + this.addedNodesIds.size > 0 || this.removedNodesIds.size > 0 || this.updatedNodeIdsToProps.size > 0; + const edgesChanged = + this.addedEdgesIds.size > 0 || this.removedEdgesIds.size > 0 || this.updatedEdgeIdsToProps.size > 0; + + return { + nodes: nodesChanged ? Array.from(this.nodesMap.values()) : this.initialState.nodes, + edges: edgesChanged ? Array.from(this.edgesMap.values()) : this.initialState.edges, + metadata: this.metadata, + }; + }; private getContext = (): MiddlewareContext => ({ state: this.getState(), diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/port-batch-processor/port-batch-processor.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/port-batch-processor/port-batch-processor.ts index 97cdfbb82..6473c4755 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/port-batch-processor/port-batch-processor.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/port-batch-processor/port-batch-processor.ts @@ -7,19 +7,33 @@ export interface PortUpdate { /** * Processes and batches port operations for nodes to prevent race conditions when multiple ports - * are added or updated simultaneously (e.g., during node initialization). + * are added or updated simultaneously (e.g., during node initialization or virtualization). * * This class collects port operations that occur in the same JavaScript tick and * processes them together in a single batch, ensuring all ports are properly * persisted to the state. + * + * Standard mode: Uses per-node batching (processAdd, processUpdate) + * Virtualization mode: Uses global batching (processAddBatched, processUpdateBatched, processDeleteBatched) */ export class PortBatchProcessor { + // ===== STANDARD MODE: Per-node batching ===== private readonly pendingPortAdditions = new Map(); private readonly scheduledAdditionFlushes = new Set(); private readonly pendingPortUpdates = new Map(); private readonly scheduledUpdateFlushes = new Set(); + // ===== VIRTUALIZATION MODE: Global batching ===== + private globalAdditionFlushScheduled = false; + private globalUpdateFlushScheduled = false; + private readonly pendingPortDeletions = new Map(); + private globalDeletionFlushScheduled = false; + + // ============================================= + // STANDARD MODE METHODS (original behavior) + // ============================================= + /** * Processes a port addition for batching with the specified node. * If this is the first port addition for the node in the current tick, @@ -97,4 +111,139 @@ export class PortBatchProcessor { this.pendingPortUpdates.delete(nodeId); this.scheduledUpdateFlushes.delete(nodeId); } + + // ============================================= + // VIRTUALIZATION MODE METHODS (global batching) + // ============================================= + + /** + * Processes port additions using a global batch callback that receives all additions at once. + * This is the preferred method for virtualization scenarios where many nodes + * need port additions simultaneously. + * + * @param nodeId - The ID of the node to add the port to + * @param port - The port to add + * @param onBatchFlush - Callback that receives ALL pending additions across all nodes + */ + processAddBatched(nodeId: string, port: Port, onBatchFlush: (additions: Map) => void): void { + if (!this.pendingPortAdditions.has(nodeId)) { + this.pendingPortAdditions.set(nodeId, []); + } + + this.pendingPortAdditions.get(nodeId)!.push(port); + + if (!this.globalAdditionFlushScheduled) { + this.globalAdditionFlushScheduled = true; + + queueMicrotask(() => { + this.flushAllPortAdditionsBatched(onBatchFlush); + }); + } + } + + /** + * Processes port updates using a global batch callback that receives all updates at once. + * This is the preferred method for virtualization scenarios where many nodes + * need port updates simultaneously. + * + * @param nodeId - The ID of the node containing the port + * @param portUpdate - The port update containing ID and changes + * @param onBatchFlush - Callback that receives ALL pending updates across all nodes + */ + processUpdateBatched( + nodeId: string, + portUpdate: PortUpdate, + onBatchFlush: (updates: Map) => void + ): void { + if (!this.pendingPortUpdates.has(nodeId)) { + this.pendingPortUpdates.set(nodeId, []); + } + + this.pendingPortUpdates.get(nodeId)!.push(portUpdate); + + if (!this.globalUpdateFlushScheduled) { + this.globalUpdateFlushScheduled = true; + + queueMicrotask(() => { + this.flushAllPortUpdatesBatched(onBatchFlush); + }); + } + } + + /** + * Processes port deletions using a global batch callback that receives all deletions at once. + * This is essential for virtualization scenarios where many nodes are destroyed simultaneously. + * + * @param nodeId - The ID of the node containing the port to delete + * @param portId - The ID of the port to delete + * @param onBatchFlush - Callback that receives ALL pending deletions across all nodes + */ + processDeleteBatched(nodeId: string, portId: string, onBatchFlush: (deletions: Map) => void): void { + if (!this.pendingPortDeletions.has(nodeId)) { + this.pendingPortDeletions.set(nodeId, []); + } + + this.pendingPortDeletions.get(nodeId)!.push(portId); + + if (!this.globalDeletionFlushScheduled) { + this.globalDeletionFlushScheduled = true; + + queueMicrotask(() => { + this.flushAllPortDeletionsBatched(onBatchFlush); + }); + } + } + + private flushAllPortAdditionsBatched(onBatchFlush: (additions: Map) => void): void { + if (this.pendingPortAdditions.size === 0) { + this.globalAdditionFlushScheduled = false; + return; + } + + // Copy the pending additions before clearing + const additions = new Map(this.pendingPortAdditions); + + // Clear state first to allow new batches to accumulate + this.pendingPortAdditions.clear(); + this.scheduledAdditionFlushes.clear(); + this.globalAdditionFlushScheduled = false; + + // Execute the batch callback with all additions at once + onBatchFlush(additions); + } + + private flushAllPortUpdatesBatched(onBatchFlush: (updates: Map) => void): void { + if (this.pendingPortUpdates.size === 0) { + this.globalUpdateFlushScheduled = false; + return; + } + + // Copy the pending updates before clearing + const updates = new Map(this.pendingPortUpdates); + + // Clear state first to allow new batches to accumulate + this.pendingPortUpdates.clear(); + this.scheduledUpdateFlushes.clear(); + this.globalUpdateFlushScheduled = false; + + // Execute the batch callback with all updates at once + onBatchFlush(updates); + } + + private flushAllPortDeletionsBatched(onBatchFlush: (deletions: Map) => void): void { + if (this.pendingPortDeletions.size === 0) { + this.globalDeletionFlushScheduled = false; + return; + } + + // Copy the pending deletions before clearing + const deletions = new Map(this.pendingPortDeletions); + + // Clear state first to allow new batches to accumulate + this.pendingPortDeletions.clear(); + this.globalDeletionFlushScheduled = false; + + // Execute the batch callback with all deletions at once + onBatchFlush(deletions); + } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/buffer-fill-manager.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/buffer-fill-manager.ts new file mode 100644 index 000000000..e4d92dd92 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/buffer-fill-manager.ts @@ -0,0 +1,54 @@ +import type { FlowCore } from '../flow-core'; + +/** + * Manages buffer filling during pan idle time. + * After panning stops, schedules rendering with expanded buffer to preload + * nodes in all directions for smoother subsequent panning. + */ +export class BufferFillManager { + private fillTimer: ReturnType | null = null; + private unsubscribePanStarted: (() => void) | null = null; + private unsubscribePanEnded: (() => void) | null = null; + + constructor( + private readonly flowCore: FlowCore, + private readonly idleThreshold: number + ) { + this.setupListeners(); + } + + private setupListeners(): void { + this.unsubscribePanEnded = this.flowCore.eventManager.on('panEnded', () => { + this.scheduleFill(); + }); + + this.unsubscribePanStarted = this.flowCore.eventManager.on('panStarted', () => { + this.cancelFill(); + }); + } + + private scheduleFill(): void { + this.cancelFill(); + this.fillTimer = setTimeout(() => { + this.executeFill(); + }, this.idleThreshold); + } + + private cancelFill(): void { + if (this.fillTimer !== null) { + clearTimeout(this.fillTimer); + this.fillTimer = null; + } + } + + private executeFill(): void { + this.flowCore.renderWithExpandedBuffer(); + this.fillTimer = null; + } + + destroy(): void { + this.cancelFill(); + this.unsubscribePanStarted?.(); + this.unsubscribePanEnded?.(); + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts index 7aea953d4..7937e522e 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts @@ -1,3 +1,4 @@ export * from './render-strategy.interface'; export * from './direct-render-strategy'; export * from './virtualized-render-strategy'; +export * from './buffer-fill-manager'; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts index fe04eaedb..4975ab476 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts @@ -23,6 +23,11 @@ describe('VirtualizedRenderStrategy', () => { enabled: true, padding: 100, nodeCountThreshold: 2, + expandedPadding: 1500, + bufferFill: { + enabled: true, + idleThreshold: 100, + }, }; beforeEach(() => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts index e84884fa6..34b40060f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts @@ -67,6 +67,33 @@ export class VirtualizedRenderStrategy implements RenderStrategy { this.lastEdgesLength = 0; } + /** + * Process with expanded buffer padding - used during pan idle to preload more nodes. + * Uses expandedPadding from config instead of normal padding. + */ + processWithExpandedBuffer(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult { + const config = this.flowCore.config.virtualization; + + if (this.shouldBypass(nodes, viewport, config)) { + return { nodes, edges, nodeIds: EMPTY_SET, edgeIds: EMPTY_SET }; + } + + // Use expanded padding for buffer fill + const viewportRect = this.getViewportRect(viewport!, config.expandedPadding); + + // Force fresh computation with expanded buffer + const result = this.computeVisibleElements(viewportRect); + + // Update cache with expanded buffer results + this.cachedNodeIds = result.nodeIds; + this.cachedEdgeIds = result.edgeIds; + this.lastViewportRect = viewportRect; + this.lastNodesLength = nodes.length; + this.lastEdgesLength = edges.length; + + return result; + } + private shouldBypass(nodes: Node[], viewport: Viewport | undefined, config: VirtualizationConfig): boolean { // Note: config.enabled check is handled by strategy selection in FlowCore return !viewport || nodes.length < config.nodeCountThreshold; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/command-handler.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/command-handler.interface.ts index 613830d7a..74a070a1f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/command-handler.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/command-handler.interface.ts @@ -3,11 +3,13 @@ import { AddEdgeLabelsCommand, AddEdgesCommand, AddNodesCommand, + AddPortsBulkCommand, AddPortsCommand, ClearModelCommand, DeleteEdgeLabelsCommand, DeleteEdgesCommand, DeleteNodesCommand, + DeletePortsBulkCommand, DeletePortsCommand, PaletteDropNodeCommand, UpdateEdgeCommand, @@ -15,6 +17,7 @@ import { UpdateEdgesCommand, UpdateNodeCommand, UpdateNodesCommand, + UpdatePortsBulkCommand, UpdatePortsCommand, } from '../command-handler/commands/add-update-delete'; import { CenterOnNodeCommand, CenterOnRectCommand } from '../command-handler/commands/centering'; @@ -79,8 +82,11 @@ export type Command = | ResizeNodeCommand | ZoomCommand | AddPortsCommand + | AddPortsBulkCommand | UpdatePortsCommand + | UpdatePortsBulkCommand | DeletePortsCommand + | DeletePortsBulkCommand | BringToFrontCommand | SendToBackCommand | AddEdgeLabelsCommand diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index e99d584ec..bff15c1fd 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -419,6 +419,26 @@ export interface BoxSelectionConfig { realtime?: boolean; } +/** + * Configuration for buffer fill behavior during pan idle. + * + * @category Types/Configuration/Features + */ +export interface BufferFillConfig { + /** + * Whether buffer fill is enabled. + * When enabled, the diagram will fill an expanded buffer during pan idle time. + * @default true + */ + enabled: boolean; + + /** + * Time in milliseconds to wait after panning stops before filling the buffer. + * @default 100 + */ + idleThreshold: number; +} + /** * Configuration for viewport virtualization behavior. * When enabled, only nodes and edges visible in the viewport (plus padding) are rendered, @@ -437,16 +457,28 @@ export interface VirtualizationConfig { /** * Padding in flow coordinates around the viewport. * Nodes within this padding area are pre-rendered for smoother scrolling. - * @default 200 + * @default 300 */ padding: number; + /** + * Expanded padding used when filling buffer during pan idle. + * This larger padding is applied after panning stops to preload more nodes. + * @default 1500 + */ + expandedPadding: number; + /** * Maximum number of nodes below which virtualization is skipped. * If fewer nodes exist than this threshold, render all nodes. * @default 500 */ nodeCountThreshold: number; + + /** + * Configuration for buffer fill during pan idle. + */ + bufferFill: BufferFillConfig; } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts index 5dc4fec0e..76bd443c1 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts @@ -146,6 +146,24 @@ export class InitUpdater implements Updater { this.portStabilityDetector.notify(); } + /** + * Deletes a port during initialization. + * Queues if finishing, otherwise delegates to internal updater since deletions + * during initialization are rare and don't need to be batched in init state. + * + * @param nodeId - The node ID the port belongs to + * @param portId - The port ID to delete + */ + deletePort(nodeId: string, portId: string): void { + if (this.lateArrivalQueue.isFinishing) { + this.lateArrivalQueue.enqueue({ method: 'deletePort', args: [nodeId, portId] }); + return; + } + + // During initialization, deletions are rare - delegate directly to internal updater + this.flowCore.internalUpdater.deletePort(nodeId, portId); + } + /** * Records port measurements (sizes and positions). * Queues if finishing, otherwise records all measurements and attempts to finish. diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/late-arrival-queue.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/late-arrival-queue.ts index 43372db22..c864526cb 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/late-arrival-queue.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/late-arrival-queue.ts @@ -7,6 +7,7 @@ import { Updater } from '../updater.interface'; */ export type LateArrival = | { method: 'addPort'; args: [nodeId: string, port: Port] } + | { method: 'deletePort'; args: [nodeId: string, portId: string] } | { method: 'addEdgeLabel'; args: [edgeId: string, label: EdgeLabel] } | { method: 'applyNodeSize'; args: [nodeId: string, size: NonNullable] } | { @@ -80,6 +81,9 @@ export class LateArrivalQueue { case 'addPort': updater.addPort(...lateArrival.args); break; + case 'deletePort': + updater.deletePort(...lateArrival.args); + break; case 'addEdgeLabel': updater.addEdgeLabel(...lateArrival.args); break; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts index 4a54a01dc..71527c86c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts @@ -34,9 +34,39 @@ export class InternalUpdater implements Updater { * @param port Port */ addPort(nodeId: string, port: Port): void { - this.flowCore.portBatchProcessor.processAdd(nodeId, port, (nodeId, ports) => { - this.flowCore.commandHandler.emit('addPorts', { nodeId, ports }); - }); + if (this.flowCore.config.virtualization.enabled) { + // VIRTUALIZATION MODE: skip if port already has measurements (node re-entered viewport) + const node = this.flowCore.getNodeById(nodeId); + const existingPort = node?.measuredPorts?.find((p) => p.id === port.id); + if (existingPort?.size && existingPort?.position) { + return; // Port already measured, skip redundant add + } + // Port not measured yet, proceed with batched add for performance + this.flowCore.portBatchProcessor.processAddBatched(nodeId, port, (allAdditions) => { + this.flowCore.commandHandler.emit('addPortsBulk', { additions: allAdditions }); + }); + } else { + // STANDARD MODE: original behavior - per-node batching within same tick + this.flowCore.portBatchProcessor.processAdd(nodeId, port, (nodeId, ports) => { + this.flowCore.commandHandler.emit('addPorts', { nodeId, ports }); + }); + } + } + + /** + * @internal + * Internal method to delete a port from the flow + * @param nodeId Node id + * @param portId Port id + */ + deletePort(nodeId: string, portId: string): void { + if (this.flowCore.config.virtualization.enabled) { + // VIRTUALIZATION MODE: skip delete - ports persist in model, only DOM unmounts + return; + } else { + // STANDARD MODE: original behavior - single command per port + this.flowCore.commandHandler.emit('deletePorts', { nodeId, portIds: [portId] }); + } } /** @@ -54,15 +84,33 @@ export class InternalUpdater implements Updater { const portsToUpdate = this.getPortsToUpdate(node, ports); - portsToUpdate.forEach(({ id, size, position }) => { - this.flowCore.portBatchProcessor.processUpdate( - nodeId, - { portId: id, portChanges: { size, position } }, - (nodeId, portUpdates) => { - this.flowCore.commandHandler.emit('updatePorts', { nodeId, ports: portUpdates }); - } - ); - }); + if (portsToUpdate.length === 0) { + return; + } + + if (this.flowCore.config.virtualization.enabled) { + // VIRTUALIZATION MODE: batched updates for performance + portsToUpdate.forEach(({ id, size, position }) => { + this.flowCore.portBatchProcessor.processUpdateBatched( + nodeId, + { portId: id, portChanges: { size, position } }, + (allUpdates) => { + this.flowCore.commandHandler.emit('updatePortsBulk', { updates: allUpdates }); + } + ); + }); + } else { + // STANDARD MODE: original behavior - per-node batching within same tick + portsToUpdate.forEach(({ id, size, position }) => { + this.flowCore.portBatchProcessor.processUpdate( + nodeId, + { portId: id, portChanges: { size, position } }, + (nodeId, portUpdates) => { + this.flowCore.commandHandler.emit('updatePorts', { nodeId, ports: portUpdates }); + } + ); + }); + } } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/updater.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/updater.interface.ts index 4e6be5ebc..183c9d0dc 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/updater.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/updater.interface.ts @@ -11,6 +11,11 @@ export interface Updater { */ addPort(nodeId: string, port: Port): void; + /** + * Delete a port from a node + */ + deletePort(nodeId: string, portId: string): void; + /** * Apply port size and position updates */ diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/node/ng-diagram-node.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/node/ng-diagram-node.component.ts index a4f7aa6a7..212e5e507 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/node/ng-diagram-node.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/node/ng-diagram-node.component.ts @@ -55,9 +55,32 @@ export class NgDiagramNodeComponent { } private setupPortSyncEffect(): void { + let isFirstRun = true; + let prevSize: { width?: number; height?: number } | undefined; + let prevRotate: string | undefined; + effect(() => { - this.size(); - this.rotate(); + const size = this.size(); + const rotate = this.rotate(); + + // Skip initial run - ports aren't measured yet, ResizeObserver will handle initial sync + if (isFirstRun) { + isFirstRun = false; + prevSize = size; + prevRotate = rotate; + return; + } + + // Skip if size and rotate haven't actually changed (just object reference change) + const sizeChanged = size?.width !== prevSize?.width || size?.height !== prevSize?.height; + const rotateChanged = rotate !== prevRotate; + + if (!sizeChanged && !rotateChanged) { + return; + } + + prevSize = size; + prevRotate = rotate; this.syncPorts(); }); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts index 8336f9be7..13bb6c5ce 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts @@ -105,11 +105,14 @@ export class NgDiagramPortComponent extends NodeContextGuardBase implements OnIn super(); effect(() => { const nodeData = this.nodeData(); - if (this.isInitialized() && nodeData && this.lastSide() !== this.side()) { - this.lastSide.set(this.side()); + const initialized = this.isInitialized(); + const lastSide = this.lastSide(); + const currentSide = this.side(); + if (initialized && nodeData && lastSide !== currentSide) { + this.lastSide.set(currentSide); this.flowCoreProvider.provide().commandHandler.emit('updatePorts', { nodeId: nodeData.id, - ports: [{ portId: this.id(), portChanges: { side: this.side() } }], + ports: [{ portId: this.id(), portChanges: { side: currentSide } }], }); } }); @@ -126,14 +129,17 @@ export class NgDiagramPortComponent extends NodeContextGuardBase implements OnIn effect(() => { const nodeData = this.nodeData(); - if (this.isInitialized() && this.lastType() !== this.type() && nodeData) { + const initialized = this.isInitialized(); + const lastType = this.lastType(); + const currentType = this.type(); + if (initialized && lastType !== currentType && nodeData) { // Angular 18 backward compatibility untracked(() => { - this.lastType.set(this.type()); + this.lastType.set(currentType); }); this.flowCoreProvider.provide().commandHandler.emit('updatePorts', { nodeId: nodeData.id, - ports: [{ portId: this.id(), portChanges: { type: this.type() } }], + ports: [{ portId: this.id(), portChanges: { type: currentType } }], }); } }); @@ -148,6 +154,7 @@ export class NgDiagramPortComponent extends NodeContextGuardBase implements OnIn return; } + // Always call addPort - InternalUpdater handles virtualization logic this.flowCoreProvider.provide().updater.addPort(nodeData.id, { id: this.id(), type: this.type(), @@ -170,10 +177,8 @@ export class NgDiagramPortComponent extends NodeContextGuardBase implements OnIn return; } - this.flowCoreProvider.provide().commandHandler.emit('deletePorts', { - nodeId: nodeData.id, - portIds: [this.id()], - }); + // Always call deletePort - InternalUpdater handles virtualization logic + this.flowCoreProvider.provide().updater.deletePort(nodeData.id, this.id()); this.batchResizeObserver.unobserve(this.hostElement.nativeElement); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-core-provider/flow-core-provider.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-core-provider/flow-core-provider.service.ts index d97f4a06b..492d40566 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-core-provider/flow-core-provider.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-core-provider/flow-core-provider.service.ts @@ -36,6 +36,7 @@ export class FlowCoreProviderService { getFlowOffset, config ); + this._isInitialized.set(true); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-resize-observer/flow-resize-processor.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-resize-observer/flow-resize-processor.service.ts index 7afabfcee..f8c37c643 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-resize-observer/flow-resize-processor.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-resize-observer/flow-resize-processor.service.ts @@ -43,9 +43,6 @@ export class FlowResizeBatchProcessorService { * Main batch processor - handles all resize events in one go */ private processAllResizes(entries: ResizeObserverEntry[]): void { - console.log(`[PERF] processAllResizes called with ${entries.length} entries`); - console.time(`[PERF] processAllResizes TOTAL`); - // Ensure service is initialized if (!this.isInitialized) { console.warn('FlowResizeBatchProcessorService not initialized yet, skipping resize processing'); @@ -77,31 +74,19 @@ export class FlowResizeBatchProcessorService { } } - console.log( - `[PERF] Categorized: ${nodeEntries.length} nodes, ${portEntries.length} ports, ${edgeLabelEntries.length} labels` - ); - // Process all ports together if (portEntries.length > 0) { - console.time(`[PERF] processPortBatch`); this.processPortBatch(portEntries); - console.timeEnd(`[PERF] processPortBatch`); } // Process all edge labels together if (edgeLabelEntries.length > 0) { - console.time(`[PERF] processEdgeLabelBatch`); this.processEdgeLabelBatch(edgeLabelEntries); - console.timeEnd(`[PERF] processEdgeLabelBatch`); } if (nodeEntries.length > 0) { - console.time(`[PERF] processNodeBatch`); this.processNodeBatch(nodeEntries); - console.timeEnd(`[PERF] processNodeBatch`); } - - console.timeEnd(`[PERF] processAllResizes TOTAL`); } /** @@ -164,8 +149,9 @@ export class FlowResizeBatchProcessorService { */ private processNodeBatch(entries: ProcessedEntry[]): void { const flowCore = this.flowCoreProvider.provide(); - let appliedCount = 0; - let skippedCount = 0; + + // Collect nodes that need initial size (no size property yet) + const nodesNeedingInitialSize: { id: string; size: Size }[] = []; for (const { entry, metadata } of entries) { if (metadata?.type !== 'node') continue; @@ -173,13 +159,21 @@ export class FlowResizeBatchProcessorService { const size = this.getBorderBoxSize(entry); if (!size) continue; - const currentSize = flowCore.getNodeById(metadata.nodeId)?.size; - if (currentSize && !this.isSizeChanged(currentSize, size)) { - skippedCount++; + const node = flowCore.getNodeById(metadata.nodeId); + if (!node) continue; + + const currentSize = node.size; + + // Nodes without initial size - collect for batch update + if (!currentSize) { + nodesNeedingInitialSize.push({ id: metadata.nodeId, size }); + continue; + } + + if (!this.isSizeChanged(currentSize, size)) { continue; } - appliedCount++; flowCore.updater.applyNodeSize(metadata.nodeId, size); // Skip port measurement during active resize performed by user to avoid redundant updates @@ -190,7 +184,20 @@ export class FlowResizeBatchProcessorService { } } - console.log(`[PERF] processNodeBatch: ${appliedCount} applied, ${skippedCount} skipped (size unchanged)`); + // Batch update nodes without initial size + if (nodesNeedingInitialSize.length > 0) { + if (flowCore.isInitialized) { + // After init: batch update directly for performance + flowCore.commandHandler.emit('updateNodes', { + nodes: nodesNeedingInitialSize, + }); + } else { + // During init: use updater so InitUpdater can track measurements + for (const { id, size } of nodesNeedingInitialSize) { + flowCore.updater.applyNodeSize(id, size); + } + } + } } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts index 91f7355a3..5b54d2955 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts @@ -22,19 +22,19 @@ export class RendererService implements Renderer { draw(nodes: Node[], edges: Edge[], viewport: Viewport): void { const nodeCountDiff = nodes.length - this.lastNodeCount; - // Only log when new nodes are being added (not during panning) if (nodeCountDiff > 0) { const startTime = performance.now(); - console.log(`[PERF] renderer.draw: adding ${nodeCountDiff} nodes (${this.lastNodeCount} -> ${nodes.length})`); this.nodes.set(nodes); this.edges.set(edges); this.viewport.set(viewport); - // Measure time until Angular finishes DOM creation (next frame) requestAnimationFrame(() => { - const afterAngularTime = performance.now(); - console.log(`[PERF] Angular DOM created in ${(afterAngularTime - startTime).toFixed(2)}ms`); + requestAnimationFrame(() => { + console.log( + `[PERF] Render +${nodeCountDiff} nodes (${this.lastNodeCount} -> ${nodes.length}): ${(performance.now() - startTime).toFixed(2)}ms` + ); + }); }); } else { this.nodes.set(nodes); From f81b9bc15e89dc8c558b5fe9bc8daf39e74b2b69 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 16 Dec 2025 09:15:44 +0100 Subject: [PATCH 04/42] Fix linking --- apps/angular-demo/src/app/app.component.ts | 2 +- .../middleware-manager/middleware-executor.ts | 19 +++++-------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/apps/angular-demo/src/app/app.component.ts b/apps/angular-demo/src/app/app.component.ts index 748936fd2..3a57cf731 100644 --- a/apps/angular-demo/src/app/app.component.ts +++ b/apps/angular-demo/src/app/app.component.ts @@ -179,7 +179,7 @@ export class AppComponent { } // Generate 20k nodes in a grid pattern for virtualization testing - model = initializeModel(this.generateLargeModel(20000)); + model = initializeModel(this.generateLargeModel(5000)); private generateLargeModel(nodeCount: number): { nodes: Node[]; edges: Edge[] } { const nodes: Node[] = []; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts index 9b2ee66de..d808c8a22 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts @@ -118,20 +118,11 @@ export class MiddlewareExecutor { .filter((edge): edge is Edge => edge !== undefined), }); - private getState = (): FlowState => { - // Only create new arrays if nodes/edges were actually modified - // This preserves reference equality for viewport-only changes (panning, zooming) - const nodesChanged = - this.addedNodesIds.size > 0 || this.removedNodesIds.size > 0 || this.updatedNodeIdsToProps.size > 0; - const edgesChanged = - this.addedEdgesIds.size > 0 || this.removedEdgesIds.size > 0 || this.updatedEdgeIdsToProps.size > 0; - - return { - nodes: nodesChanged ? Array.from(this.nodesMap.values()) : this.initialState.nodes, - edges: edgesChanged ? Array.from(this.edgesMap.values()) : this.initialState.edges, - metadata: this.metadata, - }; - }; + private getState = (): FlowState => ({ + nodes: Array.from(this.nodesMap.values()), + edges: Array.from(this.edgesMap.values()), + metadata: this.metadata, + }); private getContext = (): MiddlewareContext => ({ state: this.getState(), From 79a22baa882a22a3620b35bf035b42adef6781f6 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 16 Dec 2025 09:53:58 +0100 Subject: [PATCH 05/42] Fix padding for virtual viewport --- .../src/core/src/flow-config/default-flow-config.ts | 4 ++-- .../render-strategy/virtualized-render-strategy.ts | 6 +++++- .../src/core/src/types/flow-config.interface.ts | 12 +++++++----- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts index 7d02b8f09..4aff3cfa0 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts @@ -142,8 +142,8 @@ const defaultBufferFillConfig: BufferFillConfig = { const defaultVirtualizationConfig: VirtualizationConfig = { enabled: false, // Disabled by default - users must explicitly enable for large diagrams - padding: 300, // Smaller padding during active pan for better performance - expandedPadding: 1500, // Large buffer filled during idle time after panning stops + padding: 0.3, // 0.3x viewport size padding (~1.6x viewport area total) + expandedPadding: 0.7, // 0.7x viewport size buffer during idle (~2.4x viewport area) nodeCountThreshold: 500, bufferFill: defaultBufferFillConfig, }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts index 34b40060f..04e61ef11 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts @@ -99,7 +99,7 @@ export class VirtualizedRenderStrategy implements RenderStrategy { return !viewport || nodes.length < config.nodeCountThreshold; } - private getViewportRect(viewport: Viewport, padding: number): Rect { + private getViewportRect(viewport: Viewport, paddingMultiplier: number): Rect { const { x, y, scale, width, height } = viewport; const effectiveWidth = width || DEFAULT_VIEWPORT_WIDTH; @@ -110,6 +110,10 @@ export class VirtualizedRenderStrategy implements RenderStrategy { const flowWidth = effectiveWidth / scale; const flowHeight = effectiveHeight / scale; + // Calculate padding as a multiple of the largest viewport dimension (in flow coordinates) + const maxFlowDimension = Math.max(flowWidth, flowHeight); + const padding = maxFlowDimension * paddingMultiplier; + return { x: flowX - padding, y: flowY - padding, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index fc97d508e..48934a181 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -455,16 +455,18 @@ export interface VirtualizationConfig { enabled: boolean; /** - * Padding in flow coordinates around the viewport. - * Nodes within this padding area are pre-rendered for smoother scrolling. - * @default 300 + * Padding multiplier relative to viewport size. + * The actual padding is calculated as: max(viewportWidth, viewportHeight) * padding + * For example, 0.3 means 30% of the viewport size as padding in each direction. + * @default 0.3 */ padding: number; /** - * Expanded padding used when filling buffer during pan idle. + * Expanded padding multiplier used when filling buffer during pan idle. * This larger padding is applied after panning stops to preload more nodes. - * @default 1500 + * Calculated as: max(viewportWidth, viewportHeight) * expandedPadding + * @default 0.7 */ expandedPadding: number; From e054ad2699c7f552cebbdc9268a4d18cdd52ed32 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 16 Dec 2025 10:03:43 +0100 Subject: [PATCH 06/42] Add zoom tracking to the render strategy with debounced updates --- .../virtualized-render-strategy.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts index 04e61ef11..91e36b2c2 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts @@ -9,6 +9,9 @@ const DEFAULT_VIEWPORT_HEIGHT = 1080; // Percentage of viewport dimensions that triggers recomputation const RECOMPUTE_THRESHOLD = 0.25; +// Delay before recomputing after zoom stops (ms) +const ZOOM_IDLE_DELAY = 150; + // Reusable empty set for bypass results (avoids allocation on every call) const EMPTY_SET = new Set(); @@ -24,6 +27,11 @@ export class VirtualizedRenderStrategy implements RenderStrategy { private cachedNodeIds: Set | null = null; private cachedEdgeIds: Set | null = null; + // Zoom tracking for deferred recomputation + private lastScale: number | null = null; + private zoomIdleTimeout: ReturnType | null = null; + private isZooming = false; + constructor(private readonly flowCore: FlowCore) {} process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult { @@ -33,6 +41,22 @@ export class VirtualizedRenderStrategy implements RenderStrategy { return { nodes, edges, nodeIds: EMPTY_SET, edgeIds: EMPTY_SET }; } + // Detect zoom changes and defer recomputation during active zooming + const currentScale = viewport!.scale; + const scaleChanged = this.lastScale !== null && this.lastScale !== currentScale; + + if (scaleChanged) { + this.isZooming = true; + this.scheduleZoomIdle(); + } + + this.lastScale = currentScale; + + // During active zooming, use cached result if available to avoid jank + if (this.isZooming && this.cachedNodeIds && this.cachedEdgeIds) { + return this.buildResultFromCachedIds(nodes, edges); + } + const viewportRect = this.getViewportRect(viewport!, config.padding); if (this.canUseCachedResult(nodes.length, edges.length, viewportRect)) { @@ -52,6 +76,25 @@ export class VirtualizedRenderStrategy implements RenderStrategy { return result; } + /** + * Schedules a callback to mark zooming as complete after idle period. + * Resets the timer on each call to debounce rapid zoom events. + */ + private scheduleZoomIdle(): void { + if (this.zoomIdleTimeout) { + clearTimeout(this.zoomIdleTimeout); + } + + this.zoomIdleTimeout = setTimeout(() => { + this.isZooming = false; + this.zoomIdleTimeout = null; + // Invalidate cache to force recomputation on next render + this.lastViewportRect = null; + // Trigger a render to update with new zoom level + this.flowCore.renderWithExpandedBuffer(); + }, ZOOM_IDLE_DELAY); + } + private buildResultFromCachedIds(nodes: Node[], edges: Edge[]): RenderStrategyResult { const filteredNodes = nodes.filter((n) => this.cachedNodeIds!.has(n.id)); const filteredEdges = edges.filter((e) => this.cachedEdgeIds!.has(e.id)); From e166a4a438c2386fe08317e0f34be14901d4ca80 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 16 Dec 2025 11:06:52 +0100 Subject: [PATCH 07/42] Fix unit-tests --- .../projects/ng-diagram/src/core/src/flow-core.test.ts | 7 ++++--- .../core/src/input-events/handlers/panning/panning.test.ts | 1 + .../render-strategy/virtualized-render-strategy.test.ts | 7 ++++--- .../src/updater/internal-updater/internal-updater.test.ts | 1 + 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts index b3aff6c25..0c52b0268 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts @@ -154,10 +154,11 @@ describe('FlowCore', () => { // Wait for the initialization callback to be executed await new Promise((resolve) => setTimeout(resolve, 10)); - // Now init command should have been emitted with rendered node/edge IDs + // Now init command should have been emitted + // When virtualization is disabled, renderedNodeIds/renderedEdgeIds are undefined (meaning "all rendered") expect(mockCommandHandler.emit).toHaveBeenCalledWith('init', { - renderedNodeIds: expect.any(Array), - renderedEdgeIds: expect.any(Array), + renderedNodeIds: undefined, + renderedEdgeIds: undefined, }); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts index 27577f98d..542d5bc6b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts @@ -43,6 +43,7 @@ describe('PanningEventHandler', () => { getState: vi.fn(), applyUpdate: vi.fn(), commandHandler: mockCommandHandler, + eventManager: { emit: vi.fn() }, environment: mockEnvironment, } as unknown as FlowCore; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts index 4975ab476..f872568d7 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts @@ -21,9 +21,9 @@ describe('VirtualizedRenderStrategy', () => { const defaultConfig: VirtualizationConfig = { enabled: true, - padding: 100, + padding: 0.1, // 10% of viewport size as padding nodeCountThreshold: 2, - expandedPadding: 1500, + expandedPadding: 0.5, // 50% of viewport size for expanded buffer bufferFill: { enabled: true, idleThreshold: 100, @@ -47,6 +47,7 @@ describe('VirtualizedRenderStrategy', () => { getConnectedEdges: vi.fn().mockReturnValue([]), getAllDescendantIds: vi.fn().mockReturnValue([]), }, + renderWithExpandedBuffer: vi.fn(), } as unknown as FlowCore; strategy = new VirtualizedRenderStrategy(mockFlowCore); @@ -110,7 +111,7 @@ describe('VirtualizedRenderStrategy', () => { }); it('should include nodes within padding area', () => { - config.padding = 200; + config.padding = 0.25; // 25% of viewport size as padding const nodes: Node[] = [ { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, { ...mockNode, id: '2', position: { x: -150, y: 100 }, size: { width: 50, height: 50 } }, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.test.ts index f0875e7c4..91186aa67 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.test.ts @@ -38,6 +38,7 @@ describe('InternalUpdater', () => { portBatchProcessor, labelBatchProcessor, actionStateManager, + config: { virtualization: { enabled: false } }, } as unknown as FlowCore; internalUpdater = new InternalUpdater(flowCore); }); From 1660d6e706e911bcbe2c3722785307582b8fa986 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 16 Dec 2025 13:06:13 +0100 Subject: [PATCH 08/42] fix panning --- .../action-state-manager.ts | 33 +++++++++++++++++++ .../handlers/panning/panning.handler.ts | 2 ++ .../handlers/panning/panning.test.ts | 1 + .../render-strategy.interface.ts | 1 + .../virtualized-render-strategy.test.ts | 3 ++ .../virtualized-render-strategy.ts | 11 +++++-- .../core/src/types/action-state.interface.ts | 16 +++++++++ 7 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/action-state-manager/action-state-manager.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/action-state-manager/action-state-manager.ts index 427509cf2..8690a4352 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/action-state-manager/action-state-manager.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/action-state-manager/action-state-manager.ts @@ -5,6 +5,7 @@ import type { DraggingActionState, HighlightGroupActionState, LinkingActionState, + PanningActionState, ResizeActionState, RotationActionState, } from '../types/action-state.interface'; @@ -178,6 +179,24 @@ export class ActionStateManager { this.state.dragging = value; } + /** + * Gets the current panning action state. + * + * @returns The panning state if viewport is being panned, undefined otherwise + */ + get panning(): PanningActionState | undefined { + return this.state.panning; + } + + /** + * Sets the panning action state. + * + * @param value - The panning state to set, or undefined to clear + */ + set panning(value: PanningActionState | undefined) { + this.state.panning = value; + } + /** * Clears the resize action state. */ @@ -247,4 +266,18 @@ export class ActionStateManager { isDragging(): boolean { return !!this.state.dragging; } + + /** + * Clears the panning action state. + */ + clearPanning() { + this.state.panning = undefined; + } + + /** + * Checks if a panning operation is currently in progress. + */ + isPanning(): boolean { + return !!this.state.panning?.active; + } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts index 30e1c2ea4..8bdd278c0 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts @@ -27,6 +27,7 @@ export class PanningEventHandler extends EventHandler { this.isPanning = true; this.accumulatedDelta = { x: 0, y: 0 }; this.accumulatedEventCount = 0; + this.flow.actionStateManager.panning = { active: true }; this.flow.eventManager.emit('panStarted', {}); break; } @@ -54,6 +55,7 @@ export class PanningEventHandler extends EventHandler { this.lastPoint = undefined; this.isPanning = false; this.rafScheduled = false; + this.flow.actionStateManager.clearPanning(); this.flow.eventManager.emit('panEnded', {}); break; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts index 542d5bc6b..f60a11d26 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts @@ -44,6 +44,7 @@ describe('PanningEventHandler', () => { applyUpdate: vi.fn(), commandHandler: mockCommandHandler, eventManager: { emit: vi.fn() }, + actionStateManager: { panning: undefined, clearPanning: vi.fn() }, environment: mockEnvironment, } as unknown as FlowCore; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts index 61af9acd0..1ffda0c45 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts @@ -10,4 +10,5 @@ export interface RenderStrategyResult { export interface RenderStrategy { process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult; invalidateCache?(): void; + destroy?(): void; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts index f872568d7..3b2865aba 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts @@ -47,6 +47,9 @@ describe('VirtualizedRenderStrategy', () => { getConnectedEdges: vi.fn().mockReturnValue([]), getAllDescendantIds: vi.fn().mockReturnValue([]), }, + actionStateManager: { + isPanning: vi.fn().mockReturnValue(false), + }, renderWithExpandedBuffer: vi.fn(), } as unknown as FlowCore; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts index 91e36b2c2..68660b688 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts @@ -52,8 +52,9 @@ export class VirtualizedRenderStrategy implements RenderStrategy { this.lastScale = currentScale; - // During active zooming, use cached result if available to avoid jank - if (this.isZooming && this.cachedNodeIds && this.cachedEdgeIds) { + // During active zooming or panning, use cached result if available to avoid jank + const isPanning = this.flowCore.actionStateManager.isPanning(); + if ((this.isZooming || isPanning) && this.cachedNodeIds && this.cachedEdgeIds) { return this.buildResultFromCachedIds(nodes, edges); } @@ -110,6 +111,12 @@ export class VirtualizedRenderStrategy implements RenderStrategy { this.lastEdgesLength = 0; } + destroy(): void { + if (this.zoomIdleTimeout) { + clearTimeout(this.zoomIdleTimeout); + } + } + /** * Process with expanded buffer padding - used during pan idle to preload more nodes. * Uses expandedPadding from config instead of normal padding. diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/action-state.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/action-state.interface.ts index 3e8f5ec22..ff32eed58 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/action-state.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/action-state.interface.ts @@ -102,6 +102,18 @@ export interface DraggingActionState { accumulatedDeltas: Map; } +/** + * State tracking a panning operation in progress. + * + * @public + * @since 1.0.0 + * @category Internals + */ +export interface PanningActionState { + /** Whether panning is currently active. */ + active: boolean; +} + /** * Interface representing the current state of various user interactions in the diagram. * @@ -138,4 +150,8 @@ export interface ActionState { * State related to dragging elements */ dragging?: DraggingActionState; + /** + * State related to panning the viewport + */ + panning?: PanningActionState; } From c94f907ec8fc03ad89c5bc71ace92e7d41fdba7d Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 16 Dec 2025 14:12:16 +0100 Subject: [PATCH 09/42] Increase viewport padding and support quick panning --- .../src/flow-config/default-flow-config.ts | 4 +-- .../virtualized-render-strategy.ts | 30 +++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts index 4aff3cfa0..961731f42 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts @@ -142,8 +142,8 @@ const defaultBufferFillConfig: BufferFillConfig = { const defaultVirtualizationConfig: VirtualizationConfig = { enabled: false, // Disabled by default - users must explicitly enable for large diagrams - padding: 0.3, // 0.3x viewport size padding (~1.6x viewport area total) - expandedPadding: 0.7, // 0.7x viewport size buffer during idle (~2.4x viewport area) + padding: 0.5, // 0.5x viewport size padding (~2x viewport area total) + expandedPadding: 1.0, // 1.0x viewport size buffer during idle (~3x viewport area) nodeCountThreshold: 500, bufferFill: defaultBufferFillConfig, }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts index 68660b688..3a0cf6c67 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts @@ -12,6 +12,9 @@ const RECOMPUTE_THRESHOLD = 0.25; // Delay before recomputing after zoom stops (ms) const ZOOM_IDLE_DELAY = 150; +// Distance threshold (as fraction of viewport) that triggers recompute during panning +const PAN_DISTANCE_THRESHOLD = 0.5; + // Reusable empty set for bypass results (avoids allocation on every call) const EMPTY_SET = new Set(); @@ -52,13 +55,23 @@ export class VirtualizedRenderStrategy implements RenderStrategy { this.lastScale = currentScale; - // During active zooming or panning, use cached result if available to avoid jank + const viewportRect = this.getViewportRect(viewport!, config.padding); + + // During active zooming or panning, use cached result to avoid lag const isPanning = this.flowCore.actionStateManager.isPanning(); - if ((this.isZooming || isPanning) && this.cachedNodeIds && this.cachedEdgeIds) { + const hasCache = this.cachedNodeIds && this.cachedEdgeIds; + + if (this.isZooming && hasCache) { return this.buildResultFromCachedIds(nodes, edges); } - const viewportRect = this.getViewportRect(viewport!, config.padding); + if (isPanning && hasCache) { + // During panning, use cache unless we've moved too far from last recompute + if (this.lastViewportRect && !this.hasMovedTooFar(viewportRect, this.lastViewportRect)) { + return this.buildResultFromCachedIds(nodes, edges); + } + // Moved too far - fall through to recompute + } if (this.canUseCachedResult(nodes.length, edges.length, viewportRect)) { // Use cached IDs but look up fresh objects from input arrays @@ -197,6 +210,17 @@ export class VirtualizedRenderStrategy implements RenderStrategy { ); } + /** + * Checks if viewport has moved too far from the cached position. + * Used during panning to trigger recompute when accumulated distance is large. + */ + private hasMovedTooFar(current: Rect, cached: Rect): boolean { + const xThreshold = cached.width * PAN_DISTANCE_THRESHOLD; + const yThreshold = cached.height * PAN_DISTANCE_THRESHOLD; + + return Math.abs(current.x - cached.x) > xThreshold || Math.abs(current.y - cached.y) > yThreshold; + } + private computeVisibleElements(viewportRect: Rect): RenderStrategyResult { const primaryVisibleIds = this.getPrimaryVisibleIds(viewportRect); const { edges, edgeIds, externalNodeIds } = this.collectVisibleEdges(primaryVisibleIds); From 757b62fb4a1870ad41eaebcc782c8f571e68d062 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 16 Dec 2025 15:16:11 +0100 Subject: [PATCH 10/42] Fix performance issue during selection --- .../content/docs/api/Internals/ActionState.md | 8 ++ .../docs/api/Internals/ActionStateManager.md | 58 ++++++++ .../commands/__tests__/selection.test.ts | 125 +++++------------- .../src/command-handler/commands/selection.ts | 73 ++++++---- .../z-index-assignment/z-index-assignment.ts | 39 ++++-- 5 files changed, 171 insertions(+), 132 deletions(-) diff --git a/apps/docs/src/content/docs/api/Internals/ActionState.md b/apps/docs/src/content/docs/api/Internals/ActionState.md index 85505eccd..87d9c2921 100644 --- a/apps/docs/src/content/docs/api/Internals/ActionState.md +++ b/apps/docs/src/content/docs/api/Internals/ActionState.md @@ -46,6 +46,14 @@ State related to linking nodes *** +### panning? + +> `optional` **panning**: `PanningActionState` + +State related to panning the viewport + +*** + ### resize? > `optional` **resize**: [`ResizeActionState`](/docs/api/internals/resizeactionstate/) diff --git a/apps/docs/src/content/docs/api/Internals/ActionStateManager.md b/apps/docs/src/content/docs/api/Internals/ActionStateManager.md index 18b6e1cae..00c6622a3 100644 --- a/apps/docs/src/content/docs/api/Internals/ActionStateManager.md +++ b/apps/docs/src/content/docs/api/Internals/ActionStateManager.md @@ -169,6 +169,40 @@ The linking state to set, or undefined to clear *** +### panning + +#### Get Signature + +> **get** **panning**(): `undefined` \| `PanningActionState` + +Gets the current panning action state. + +##### Returns + +`undefined` \| `PanningActionState` + +The panning state if viewport is being panned, undefined otherwise + +#### Set Signature + +> **set** **panning**(`value`): `void` + +Sets the panning action state. + +##### Parameters + +###### value + +The panning state to set, or undefined to clear + +`undefined` | `PanningActionState` + +##### Returns + +`void` + +*** + ### resize #### Get Signature @@ -285,6 +319,18 @@ Clears the linking action state. *** +### clearPanning() + +> **clearPanning**(): `void` + +Clears the panning action state. + +#### Returns + +`void` + +*** + ### clearResize() > **clearResize**(): `void` @@ -347,6 +393,18 @@ Checks if a linking operation is currently in progress. *** +### isPanning() + +> **isPanning**(): `boolean` + +Checks if a panning operation is currently in progress. + +#### Returns + +`boolean` + +*** + ### isResizing() > **isResizing**(): `boolean` diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/selection.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/selection.test.ts index d57fcac63..f78e561f4 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/selection.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/selection.test.ts @@ -1,31 +1,35 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import type { Edge, Node } from '../../../types'; import { FlowCore } from '../../../flow-core'; -import { mockEdge, mockMetadata, mockNode } from '../../../test-utils'; +import { mockEdge, mockNode } from '../../../test-utils'; import { CommandHandler } from '../../command-handler'; import { deselect, deselectAll, select, selectAll } from '../selection'; -describe('Selection Commands', () => { - let commandHandler: CommandHandler; - - beforeEach(() => { - commandHandler = { - flowCore: { - getState: vi.fn(), - applyUpdate: vi.fn(), - } as unknown as FlowCore, - } as unknown as CommandHandler; - }); +/** + * Creates a mock modelLookup from nodes and edges arrays. + */ +const createMockModelLookup = (nodes: Node[], edges: Edge[]) => ({ + nodesMap: new Map(nodes.map((n) => [n.id, n])), + edgesMap: new Map(edges.map((e) => [e.id, e])), +}); + +/** + * Creates a mock command handler with the given nodes and edges. + */ +const createCommandHandler = (nodes: Node[], edges: Edge[]): CommandHandler => + ({ + flowCore: { + applyUpdate: vi.fn(), + modelLookup: createMockModelLookup(nodes, edges), + } as unknown as FlowCore, + }) as unknown as CommandHandler; +describe('Selection Commands', () => { describe('select', () => { it('should select single node', () => { const nodes = [mockNode, { ...mockNode, id: 'node2' }]; const edges = [mockEdge]; - - vi.spyOn(commandHandler.flowCore, 'getState').mockReturnValue({ - nodes, - edges, - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler(nodes, edges); select(commandHandler, { name: 'select', nodeIds: ['node1'] }); @@ -41,12 +45,7 @@ describe('Selection Commands', () => { it('should select single edge', () => { const nodes = [mockNode]; const edges = [mockEdge, { ...mockEdge, id: 'edge2' }]; - - vi.spyOn(commandHandler.flowCore, 'getState').mockReturnValue({ - nodes, - edges, - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler(nodes, edges); select(commandHandler, { name: 'select', edgeIds: ['edge1'] }); @@ -69,12 +68,7 @@ describe('Selection Commands', () => { { ...mockEdge, selected: true }, { ...mockEdge, id: 'edge2' }, ]; - - vi.spyOn(commandHandler.flowCore, 'getState').mockReturnValue({ - nodes, - edges, - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler(nodes, edges); select(commandHandler, { name: 'select', @@ -94,12 +88,7 @@ describe('Selection Commands', () => { it('should not update state if selection has not changed', () => { const nodes = [{ ...mockNode, selected: true }]; const edges = [{ ...mockEdge, selected: true }]; - - vi.spyOn(commandHandler.flowCore, 'getState').mockReturnValue({ - nodes, - edges, - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler(nodes, edges); select(commandHandler, { name: 'select', @@ -118,12 +107,7 @@ describe('Selection Commands', () => { { ...mockNode, id: 'node2', selected: true }, ]; const edges = [mockEdge]; - - vi.spyOn(commandHandler.flowCore, 'getState').mockReturnValue({ - nodes, - edges, - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler(nodes, edges); deselect(commandHandler, { name: 'deselect', nodeIds: ['node1'] }); @@ -142,12 +126,7 @@ describe('Selection Commands', () => { { ...mockEdge, selected: true }, { ...mockEdge, id: 'edge2', selected: true }, ]; - - vi.spyOn(commandHandler.flowCore, 'getState').mockReturnValue({ - nodes, - edges, - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler(nodes, edges); deselect(commandHandler, { name: 'deselect', edgeIds: ['edge1'] }); @@ -163,12 +142,7 @@ describe('Selection Commands', () => { it('should not update state if no elements are selected', () => { const nodes = [mockNode]; const edges = [mockEdge]; - - vi.spyOn(commandHandler.flowCore, 'getState').mockReturnValue({ - nodes, - edges, - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler(nodes, edges); deselect(commandHandler, { name: 'deselect', nodeIds: ['node1'] }); @@ -186,12 +160,7 @@ describe('Selection Commands', () => { { ...mockEdge, selected: true }, { ...mockEdge, id: 'edge2', selected: true }, ]; - - vi.spyOn(commandHandler.flowCore, 'getState').mockReturnValue({ - nodes, - edges, - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler(nodes, edges); deselectAll(commandHandler); @@ -213,12 +182,7 @@ describe('Selection Commands', () => { it('should not update state if no elements are selected', () => { const nodes = [mockNode]; const edges = [mockEdge]; - - vi.spyOn(commandHandler.flowCore, 'getState').mockReturnValue({ - nodes, - edges, - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler(nodes, edges); deselectAll(commandHandler); @@ -236,12 +200,7 @@ describe('Selection Commands', () => { { ...mockEdge, id: 'e1', selected: false }, { ...mockEdge, id: 'e2', selected: false }, ]; - - (commandHandler.flowCore.getState as ReturnType).mockReturnValue({ - nodes, - edges, - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler(nodes, edges); selectAll(commandHandler); @@ -269,12 +228,7 @@ describe('Selection Commands', () => { { ...mockEdge, id: 'e1', selected: true }, { ...mockEdge, id: 'e2', selected: true }, ]; - - (commandHandler.flowCore.getState as ReturnType).mockReturnValue({ - nodes, - edges, - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler(nodes, edges); selectAll(commandHandler); @@ -290,12 +244,7 @@ describe('Selection Commands', () => { { ...mockEdge, id: 'e1', selected: false }, { ...mockEdge, id: 'e2', selected: true }, ]; - - (commandHandler.flowCore.getState as ReturnType).mockReturnValue({ - nodes, - edges, - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler(nodes, edges); selectAll(commandHandler); @@ -309,11 +258,7 @@ describe('Selection Commands', () => { }); it('should handle empty diagram', () => { - (commandHandler.flowCore.getState as ReturnType).mockReturnValue({ - nodes: [], - edges: [], - metadata: mockMetadata, - }); + const commandHandler = createCommandHandler([], []); selectAll(commandHandler); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/selection.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/selection.ts index 434ba86bb..7b53456d6 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/selection.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/selection.ts @@ -1,8 +1,12 @@ -import type { CommandHandler, Edge, FlowStateUpdate, Node } from '../../types'; +import type { ModelLookup } from '../../model-lookup/model-lookup'; +import type { CommandHandler, FlowStateUpdate } from '../../types'; +/** + * Computes selection changes using modelLookup for O(1) lookups. + * Converts selection arrays to Sets to avoid O(n²) complexity. + */ const changeSelection = ( - nodes: Node[], - edges: Edge[], + modelLookup: ModelLookup, selectedNodeIds: string[], selectedEdgeIds: string[], multiSelection = false @@ -10,20 +14,26 @@ const changeSelection = ( const nodesToUpdate: FlowStateUpdate['nodesToUpdate'] = []; const edgesToUpdate: FlowStateUpdate['edgesToUpdate'] = []; - nodes.forEach((node) => { - const isSelected = selectedNodeIds.includes(node.id); + // Convert to Sets for O(1) lookups instead of O(n) array.includes() + const selectedNodeIdSet = new Set(selectedNodeIds); + const selectedEdgeIdSet = new Set(selectedEdgeIds); + + for (const node of modelLookup.nodesMap.values()) { + const isSelected = selectedNodeIdSet.has(node.id); if (!!node.selected === isSelected || (multiSelection && !!node.selected)) { - return; + continue; } nodesToUpdate.push({ id: node.id, selected: isSelected }); - }); - edges.forEach((edge) => { - const isSelected = selectedEdgeIds.includes(edge.id); + } + + for (const edge of modelLookup.edgesMap.values()) { + const isSelected = selectedEdgeIdSet.has(edge.id); if (!!edge.selected === isSelected || (multiSelection && !!edge.selected)) { - return; + continue; } edgesToUpdate.push({ id: edge.id, selected: isSelected }); - }); + } + return { nodesToUpdate, edgesToUpdate }; }; @@ -38,8 +48,8 @@ export const select = async ( commandHandler: CommandHandler, { nodeIds, edgeIds, multiSelection = false }: SelectCommand ) => { - const { nodes, edges } = commandHandler.flowCore.getState(); - const { nodesToUpdate, edgesToUpdate } = changeSelection(nodes, edges, nodeIds ?? [], edgeIds ?? [], multiSelection); + const { modelLookup } = commandHandler.flowCore; + const { nodesToUpdate, edgesToUpdate } = changeSelection(modelLookup, nodeIds ?? [], edgeIds ?? [], multiSelection); if (nodesToUpdate?.length === 0 && edgesToUpdate?.length === 0) { return; } @@ -53,19 +63,26 @@ export interface DeselectCommand { } export const deselect = async (commandHandler: CommandHandler, { nodeIds, edgeIds }: DeselectCommand) => { - const { nodes, edges } = commandHandler.flowCore.getState(); + const { modelLookup } = commandHandler.flowCore; const nodeIdSet = new Set(nodeIds); const edgeIdSet = new Set(edgeIds); - const nodesToLeftSelected = nodes.filter(({ id, selected }) => !nodeIdSet.has(id) && !!selected).map(({ id }) => id); + // Find nodes/edges that should remain selected (not in the deselect list) + const nodesToLeftSelected: string[] = []; + for (const node of modelLookup.nodesMap.values()) { + if (!nodeIdSet.has(node.id) && !!node.selected) { + nodesToLeftSelected.push(node.id); + } + } + + const edgesToLeftSelected: string[] = []; + for (const edge of modelLookup.edgesMap.values()) { + if (!edgeIdSet.has(edge.id) && !!edge.selected) { + edgesToLeftSelected.push(edge.id); + } + } - const edgesToLeftSelected = edges.filter(({ id, selected }) => !edgeIdSet.has(id) && !!selected).map(({ id }) => id); - const { nodesToUpdate, edgesToUpdate } = changeSelection( - nodes, - edges, - nodesToLeftSelected ?? [], - edgesToLeftSelected ?? [] - ); + const { nodesToUpdate, edgesToUpdate } = changeSelection(modelLookup, nodesToLeftSelected, edgesToLeftSelected); if (nodesToUpdate?.length === 0 && edgesToUpdate?.length === 0) { return; } @@ -77,8 +94,8 @@ export interface DeselectAllCommand { } export const deselectAll = async (commandHandler: CommandHandler) => { - const { nodes, edges } = commandHandler.flowCore.getState(); - const { nodesToUpdate, edgesToUpdate } = changeSelection(nodes, edges, [], []); + const { modelLookup } = commandHandler.flowCore; + const { nodesToUpdate, edgesToUpdate } = changeSelection(modelLookup, [], []); if (nodesToUpdate?.length === 0 && edgesToUpdate?.length === 0) { return; } @@ -90,10 +107,10 @@ export interface SelectAllCommand { } export const selectAll = async (commandHandler: CommandHandler) => { - const { nodes, edges } = commandHandler.flowCore.getState(); - const allNodeIds = nodes.map((node) => node.id); - const allEdgeIds = edges.map((edge) => edge.id); - const { nodesToUpdate, edgesToUpdate } = changeSelection(nodes, edges, allNodeIds, allEdgeIds); + const { modelLookup } = commandHandler.flowCore; + const allNodeIds = Array.from(modelLookup.nodesMap.keys()); + const allEdgeIds = Array.from(modelLookup.edgesMap.keys()); + const { nodesToUpdate, edgesToUpdate } = changeSelection(modelLookup, allNodeIds, allEdgeIds); if (nodesToUpdate?.length === 0 && edgesToUpdate?.length === 0) { return; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/z-index-assignment.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/z-index-assignment.ts index b77a7a7a6..83bc02a13 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/z-index-assignment.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/z-index-assignment.ts @@ -11,7 +11,7 @@ export const zIndexMiddleware: Middleware<'z-index'> = { name: 'z-index', execute: (context, next) => { const { - state: { edges, nodes }, + state: { edges }, nodesMap, edgesMap, helpers, @@ -45,28 +45,31 @@ export const zIndexMiddleware: Middleware<'z-index'> = { const nodesToUpdate: FlowStateUpdate['nodesToUpdate'] = []; const edgesToUpdate: FlowStateUpdate['edgesToUpdate'] = []; let nodesWithZIndex: Node[] = []; + const nodesWithZIndexMap = new Map(); const processedNodeIds = new Set(); let edgesWithZIndex: Edge[] = []; if (isInit) { nodesWithZIndex = initializeZIndex(nodesMap); - nodesWithZIndex.forEach((node) => processedNodeIds.add(node.id)); + nodesWithZIndex.forEach((node) => { + processedNodeIds.add(node.id); + nodesWithZIndexMap.set(node.id, node); + }); } if (isNodeAdded) { - nodesWithZIndex = [ - ...nodesWithZIndex, - ...initializeZIndex( - helpers.getAddedNodes().reduce((map, node) => map.set(node.id, node), new Map()) - ), - ]; + const addedNodesWithZIndex = initializeZIndex( + helpers.getAddedNodes().reduce((map, node) => map.set(node.id, node), new Map()) + ); + nodesWithZIndex = [...nodesWithZIndex, ...addedNodesWithZIndex]; + addedNodesWithZIndex.forEach((node) => nodesWithZIndexMap.set(node.id, node)); } const selectedZIndex = zIndexConfig.selectedZIndex; const getGroupCurrentZIndex = (node: Node): number => { return node.groupId - ? ((nodesWithZIndex.find((n) => n.id === node.groupId) ?? nodesMap.get(node.groupId))?.computedZIndex ?? -1) + ? ((nodesWithZIndexMap.get(node.groupId) ?? nodesMap.get(node.groupId))?.computedZIndex ?? -1) : -1; }; @@ -109,7 +112,10 @@ export const zIndexMiddleware: Middleware<'z-index'> = { node.selected && zIndexConfig.elevateOnSelection ); nodesWithZIndex.push(...assignedNodes); - assignedNodes.forEach((n) => processedNodeIds.add(n.id)); + assignedNodes.forEach((n) => { + processedNodeIds.add(n.id); + nodesWithZIndexMap.set(n.id, n); + }); } } else if (shouldSnapGroupIdNode) { for (const nodeId of helpers.getAffectedNodeIds(['groupId']).sort(sortWithGroupBefore)) { @@ -122,7 +128,10 @@ export const zIndexMiddleware: Middleware<'z-index'> = { const baseZIndex = node.groupId ? getGroupCurrentZIndex(node) + 1 : 0; const assignedNodes = assignNodeZIndex(node, nodesMap, baseZIndex, node.selected); nodesWithZIndex.push(...assignedNodes); - assignedNodes.forEach((n) => processedNodeIds.add(n.id)); + assignedNodes.forEach((n) => { + processedNodeIds.add(n.id); + nodesWithZIndexMap.set(n.id, n); + }); } } @@ -130,13 +139,15 @@ export const zIndexMiddleware: Middleware<'z-index'> = { for (const nodeId of helpers.getAffectedNodeIds(['zOrder']).sort(sortWithGroupBefore)) { const node = nodesMap.get(nodeId); if (!node) continue; - nodesWithZIndex.push({ ...node, computedZIndex: node.zOrder }); + const nodeWithZIndex = { ...node, computedZIndex: node.zOrder }; + nodesWithZIndex.push(nodeWithZIndex); + nodesWithZIndexMap.set(node.id, nodeWithZIndex); processedNodeIds.add(node.id); } } for (const node of nodesWithZIndex) { - const currentNode = nodes.find((nodeData) => nodeData.id === node.id); + const currentNode = nodesMap.get(node.id); if (!currentNode || node.computedZIndex === currentNode.computedZIndex) { continue; } @@ -172,7 +183,7 @@ export const zIndexMiddleware: Middleware<'z-index'> = { } else edgesWithZIndex = assignEdgesZIndex(edges, nodesWithZIndex, nodesMap, zIndexConfig.edgesAboveConnectedNodes); for (const edge of edgesWithZIndex) { - const currentEdge = edges.find((edgeData) => edgeData.id === edge.id); + const currentEdge = edgesMap.get(edge.id); if (!currentEdge || edge.computedZIndex === currentEdge.computedZIndex) { continue; } From 2a1ddf8efe7979299c05d67e29d2150146b1d10d Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 18 Dec 2025 09:13:33 +0100 Subject: [PATCH 11/42] Improve implementation --- .../src/event-manager/internal-event-types.ts | 34 --- .../ng-diagram/src/core/src/flow-core.test.ts | 13 +- .../ng-diagram/src/core/src/flow-core.ts | 85 +----- .../panning/panning-handler-factory.ts | 15 ++ .../handlers/panning/panning.handler.ts | 66 +---- .../handlers/panning/panning.test.ts | 90 ++----- .../panning/virtualized-panning.handler.ts | 78 ++++++ .../panning/virtualized-panning.test.ts | 217 ++++++++++++++++ .../src/input-events/input-events.router.ts | 4 +- .../render-strategy/buffer-fill-manager.ts | 54 ---- .../direct-render-strategy.test.ts | 4 +- .../render-strategy/direct-render-strategy.ts | 19 ++ .../src/core/src/render-strategy/index.ts | 1 - .../render-strategy.interface.ts | 4 + .../virtualized-render-strategy.test.ts | 6 +- .../virtualized-render-strategy.ts | 175 +++++++++---- .../updater/init-updater/init-updater.test.ts | 241 ++++++++++++------ .../src/updater/init-updater/init-updater.ts | 24 +- .../internal-updater/internal-updater.ts | 183 +++++++------ 19 files changed, 801 insertions(+), 512 deletions(-) create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning-handler-factory.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.handler.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.test.ts delete mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/buffer-fill-manager.ts diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/internal-event-types.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/internal-event-types.ts index 11de1c563..df5c48c1c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/internal-event-types.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/internal-event-types.ts @@ -17,22 +17,6 @@ export interface InternalDiagramEventMap extends DiagramEventMap { * @internal - For internal use only. Use the `actionState` signal on NgDiagramService instead. */ actionStateChanged: ActionStateChangedEvent; - - /** - * Event emitted when panning starts (mouse down on canvas for pan). - * Used internally by BufferFillManager to cancel pending buffer fills. - * - * @internal - */ - panStarted: PanStartedEvent; - - /** - * Event emitted when panning ends (mouse up after pan). - * Used internally by BufferFillManager to schedule buffer fill. - * - * @internal - */ - panEnded: PanEndedEvent; } /** @@ -47,21 +31,3 @@ export interface ActionStateChangedEvent { /** The current action state */ actionState: Readonly; } - -/** - * Event payload emitted when panning starts. - * @internal - */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface PanStartedEvent { - // Empty payload - just signals pan started -} - -/** - * Event payload emitted when panning ends. - * @internal - */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface PanEndedEvent { - // Empty payload - just signals pan ended -} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts index 0c52b0268..b9d6dcb67 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts @@ -16,10 +16,10 @@ import type { TransactionOptions } from './types/transaction.interface'; vi.mock('./updater/init-updater/init-updater', () => ({ InitUpdater: vi.fn(() => ({ - start: vi.fn((callback) => { + start: vi.fn((_nodes, _edges, onComplete) => { // Simulate async initialization - if (callback) { - setTimeout(callback, 0); + if (onComplete) { + setTimeout(onComplete, 0); } }), isInitialized: false, @@ -155,11 +155,8 @@ describe('FlowCore', () => { await new Promise((resolve) => setTimeout(resolve, 10)); // Now init command should have been emitted - // When virtualization is disabled, renderedNodeIds/renderedEdgeIds are undefined (meaning "all rendered") - expect(mockCommandHandler.emit).toHaveBeenCalledWith('init', { - renderedNodeIds: undefined, - renderedEdgeIds: undefined, - }); + // When virtualization is disabled, init is called with empty object (all nodes/edges rendered) + expect(mockCommandHandler.emit).toHaveBeenCalledWith('init', {}); }); it('should initialize with default getFlowOffset when not provided', () => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts index 37df4ee11..572645142 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts @@ -10,7 +10,7 @@ import { MiddlewareManager } from './middleware-manager/middleware-manager'; import { loggerMiddleware } from './middleware-manager/middlewares'; import { ModelLookup } from './model-lookup/model-lookup'; import { PortBatchProcessor } from './port-batch-processor/port-batch-processor'; -import { BufferFillManager, DirectRenderStrategy, RenderStrategy, VirtualizedRenderStrategy } from './render-strategy'; +import { DirectRenderStrategy, RenderStrategy, VirtualizedRenderStrategy } from './render-strategy'; import { ShortcutManager } from './shortcut-manager'; import { SpatialHash } from './spatial-hash/spatial-hash'; import { @@ -54,7 +54,7 @@ export class FlowCore { private _model: ModelAdapter; private _config: FlowConfig; - private readonly initUpdater: InitUpdater; + readonly initUpdater: InitUpdater; readonly internalUpdater: InternalUpdater; private readonly updateSemaphore = new Semaphore(1); @@ -73,7 +73,6 @@ export class FlowCore { private readonly directRenderStrategy: DirectRenderStrategy; private readonly virtualizedRenderStrategy: VirtualizedRenderStrategy; - private bufferFillManager: BufferFillManager | null = null; readonly getFlowOffset: () => Point; @@ -94,12 +93,12 @@ export class FlowCore { this.initUpdater = new InitUpdater(this); this.internalUpdater = new InternalUpdater(this); this.modelLookup = new ModelLookup(this); - this.directRenderStrategy = new DirectRenderStrategy(); + this.eventManager = new EventManager(); + this.actionStateManager = new ActionStateManager(this.eventManager); + this.directRenderStrategy = new DirectRenderStrategy(this); this.virtualizedRenderStrategy = new VirtualizedRenderStrategy(this); this.middlewareManager = new MiddlewareManager(this, middlewares); this.transactionManager = new TransactionManager(this); - this.eventManager = new EventManager(); - this.actionStateManager = new ActionStateManager(this.eventManager); this.portBatchProcessor = new PortBatchProcessor(); this.labelBatchProcessor = new LabelBatchProcessor(); this.measurementTracker = new MeasurementTracker(); @@ -111,51 +110,19 @@ export class FlowCore { this.shortcutManager = new ShortcutManager(this); - // Initialize buffer fill manager if virtualization and buffer fill are enabled - if (this.config.virtualization.enabled && this.config.virtualization.bufferFill.enabled) { - this.bufferFillManager = new BufferFillManager(this, this.config.virtualization.bufferFill.idleThreshold); - } - this.inputEventsRouter.registerDefaultCallbacks(this); this.init(); } destroy() { - this.bufferFillManager?.destroy(); + this.virtualizedRenderStrategy.destroy(); this.eventManager.offAll(); this.model.destroy(); } - private lastNodesRef: Node[] | null = null; - private init() { - this.spatialHash.process(this.model.getNodes()); - this.lastNodesRef = this.model.getNodes(); - - // this.render(); - - this.model.onChange((state) => { - const nodesChanged = state.nodes !== this.lastNodesRef; - - // Only update spatial hash when nodes actually changed (skip during panning/zooming) - if (nodesChanged) { - this.spatialHash.process(state.nodes); - this.modelLookup.desynchronize(); - this.lastNodesRef = state.nodes; - } - this.render(); - }); - - this.initUpdater.start(async () => { - const { nodes, edges } = this.getRenderedModel(); - // Only pass rendered IDs when virtualization is active (undefined = all rendered) - const virtualized = this.config.virtualization.enabled; - await this.commandHandler.emit('init', { - renderedNodeIds: virtualized ? nodes.map((n) => n.id) : undefined, - renderedEdgeIds: virtualized ? edges.map((e) => e.id) : undefined, - }); - }); + this.renderStrategy.init(); } /** @@ -226,16 +193,6 @@ export class FlowCore { return this.config.virtualization.enabled ? this.virtualizedRenderStrategy : this.directRenderStrategy; } - /** - * Gets nodes and edges after applying the active render strategy. - * When virtualization is enabled, returns only the subset visible in the viewport. - * When disabled, returns all nodes and edges. - */ - getRenderedModel(): { nodes: Node[]; edges: Edge[] } { - const { nodes, edges, metadata } = this.getState(); - return this.renderStrategy.process(nodes, edges, metadata.viewport); - } - /** * Sets the current state of the flow * @param state State to set @@ -413,9 +370,9 @@ export class FlowCore { } /** - * Renders the flow + * Renders the flow by applying the render strategy and drawing visible elements. */ - private render(): void { + render(): void { const { nodes, edges, metadata } = this.getState(); const temporaryEdge = this.actionStateManager.linking?.temporaryEdge; @@ -427,30 +384,6 @@ export class FlowCore { this.renderer.draw(visibleNodes, finalEdges, metadata.viewport); } - /** - * Renders with expanded buffer padding - called during pan idle to preload more nodes. - * Only effective when virtualization is enabled. - */ - renderWithExpandedBuffer(): void { - if (!this.config.virtualization.enabled) { - return; - } - - const { nodes, edges, metadata } = this.getState(); - const temporaryEdge = this.actionStateManager.linking?.temporaryEdge; - - // Use expanded buffer for preloading - const { nodes: visibleNodes, edges: visibleEdges } = this.virtualizedRenderStrategy.processWithExpandedBuffer( - nodes, - edges, - metadata.viewport - ); - - const finalEdges = temporaryEdge?.temporary ? [...visibleEdges, temporaryEdge] : visibleEdges; - - this.renderer.draw(visibleNodes, finalEdges, metadata.viewport); - } - /** * Gets a node by id * @param nodeId Node id diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning-handler-factory.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning-handler-factory.ts new file mode 100644 index 000000000..4ae64a60a --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning-handler-factory.ts @@ -0,0 +1,15 @@ +import type { FlowCore } from '../../../flow-core'; +import type { EventHandler } from '../event-handler'; +import type { PanningEvent } from './panning.event'; +import { PanningEventHandler } from './panning.handler'; +import { VirtualizedPanningEventHandler } from './virtualized-panning.handler'; + +/** + * Factory function that creates the appropriate panning handler based on configuration. + * + * - Standard mode: Emits viewport movement on every pointer move + * - Virtualized mode: Uses RAF throttling and buffer fill management for better performance + */ +export function panningHandlerFactory(flow: FlowCore): EventHandler { + return flow.config.virtualization.enabled ? new VirtualizedPanningEventHandler(flow) : new PanningEventHandler(flow); +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts index 8bdd278c0..1011d42dd 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts @@ -3,90 +3,36 @@ import { EventHandler } from '../event-handler'; import { PanningEvent } from './panning.event'; /** - * Handles panning events with RAF-based throttling to reduce middleware executions. - * Accumulates viewport deltas and emits them once per animation frame instead of - * on every pointer move event. + * Standard panning handler - emits viewport movement on every pointer move. + * Used when virtualization is disabled. */ export class PanningEventHandler extends EventHandler { private lastPoint: Point | undefined; - private isPanning = false; - - /** Accumulated delta since last RAF flush */ - private accumulatedDelta: Point = { x: 0, y: 0 }; - - /** Whether a RAF callback is scheduled */ - private rafScheduled = false; - - /** Count of accumulated events since last flush */ - private accumulatedEventCount = 0; handle(event: PanningEvent): void { switch (event.phase) { case 'start': { this.lastPoint = event.lastInputPoint; - this.isPanning = true; - this.accumulatedDelta = { x: 0, y: 0 }; - this.accumulatedEventCount = 0; this.flow.actionStateManager.panning = { active: true }; - this.flow.eventManager.emit('panStarted', {}); break; } case 'continue': { - if (!this.isPanning || !this.lastPoint) { + if (!this.flow.actionStateManager.isPanning() || !this.lastPoint) { break; } - // Accumulate delta instead of emitting immediately const x = event.lastInputPoint.x - this.lastPoint.x; const y = event.lastInputPoint.y - this.lastPoint.y; - this.accumulatedDelta.x += x; - this.accumulatedDelta.y += y; + this.flow.commandHandler.emit('moveViewportBy', { x, y }); this.lastPoint = event.lastInputPoint; - this.accumulatedEventCount++; - - // Schedule RAF if not already scheduled - this.scheduleFlush(); break; } - case 'end': - // Flush any remaining delta immediately on end - this.flushDelta(); + case 'end': { this.lastPoint = undefined; - this.isPanning = false; - this.rafScheduled = false; this.flow.actionStateManager.clearPanning(); - this.flow.eventManager.emit('panEnded', {}); break; - } - } - - /** - * Schedules a RAF callback to flush accumulated delta. - * Only one callback is scheduled at a time. - */ - private scheduleFlush(): void { - if (this.rafScheduled) { - return; - } - - this.rafScheduled = true; - requestAnimationFrame(() => { - this.flushDelta(); - this.rafScheduled = false; - }); - } - - /** - * Emits the accumulated viewport delta if non-zero, then resets it. - */ - private flushDelta(): void { - const { x, y } = this.accumulatedDelta; - - if (x !== 0 || y !== 0) { - this.flow.commandHandler.emit('moveViewportBy', { x, y }); - this.accumulatedDelta = { x: 0, y: 0 }; - this.accumulatedEventCount = 0; + } } } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts index f60a11d26..7a4ec3643 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FlowCore } from '../../../flow-core'; import { mockEnvironment } from '../../../test-utils'; import { PanningEvent } from './panning.event'; @@ -27,41 +27,29 @@ describe('PanningEventHandler', () => { const mockCommandHandler = { emit: vi.fn() }; let mockFlowCore: FlowCore; let instance: PanningEventHandler; - let rafCallbacks: FrameRequestCallback[]; beforeEach(() => { vi.clearAllMocks(); - rafCallbacks = []; - // Mock requestAnimationFrame to capture callbacks - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - rafCallbacks.push(callback); - return rafCallbacks.length; - }); + const mockActionStateManager = { + panning: undefined as { active: boolean } | undefined, + clearPanning: vi.fn(() => { + mockActionStateManager.panning = undefined; + }), + isPanning: vi.fn(() => !!mockActionStateManager.panning?.active), + }; mockFlowCore = { getState: vi.fn(), applyUpdate: vi.fn(), commandHandler: mockCommandHandler, - eventManager: { emit: vi.fn() }, - actionStateManager: { panning: undefined, clearPanning: vi.fn() }, + actionStateManager: mockActionStateManager, environment: mockEnvironment, } as unknown as FlowCore; instance = new PanningEventHandler(mockFlowCore); }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - - /** Flush all pending RAF callbacks */ - function flushRAF() { - const callbacks = [...rafCallbacks]; - rafCallbacks = []; - callbacks.forEach((cb) => cb(performance.now())); - } - describe('handle', () => { describe('start phase', () => { it('should initialize panning state', () => { @@ -72,34 +60,21 @@ describe('PanningEventHandler', () => { instance.handle(event); - // Verify internal state by testing subsequent continue phase - const continueEvent = getSamplePanningEvent({ - phase: 'continue', - lastInputPoint: { x: 110, y: 110 }, - }); - - instance.handle(continueEvent); - - // Flush RAF to trigger the emit - flushRAF(); - - expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 10, y: 10 }); + expect(mockFlowCore.actionStateManager.panning).toEqual({ active: true }); }); }); describe('continue phase', () => { beforeEach(() => { - // Start panning first const startEvent = getSamplePanningEvent({ phase: 'start', lastInputPoint: { x: 100, y: 100 }, }); instance.handle(startEvent); vi.clearAllMocks(); - rafCallbacks = []; }); - it('should emit moveViewportBy with correct delta after RAF', () => { + it('should emit moveViewportBy immediately on each move', () => { const event = getSamplePanningEvent({ phase: 'continue', lastInputPoint: { x: 110, y: 120 }, @@ -107,42 +82,29 @@ describe('PanningEventHandler', () => { instance.handle(event); - // Should not emit immediately - expect(mockCommandHandler.emit).not.toHaveBeenCalled(); - - // Flush RAF to trigger the emit - flushRAF(); - + // Should emit immediately (no RAF throttling) expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 10, y: 20 }); }); - it('should accumulate multiple moves and emit once per RAF', () => { - // First move + it('should emit on every move event', () => { const firstEvent = getSamplePanningEvent({ phase: 'continue', lastInputPoint: { x: 110, y: 110 }, }); instance.handle(firstEvent); - // Second move (before RAF fires) const secondEvent = getSamplePanningEvent({ phase: 'continue', lastInputPoint: { x: 120, y: 125 }, }); instance.handle(secondEvent); - // Should not emit yet - expect(mockCommandHandler.emit).not.toHaveBeenCalled(); - - // Flush RAF - should emit accumulated delta (100,100) -> (120,125) = (20, 25) - flushRAF(); - - expect(mockCommandHandler.emit).toHaveBeenCalledTimes(1); - expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 20, y: 25 }); + expect(mockCommandHandler.emit).toHaveBeenCalledTimes(2); + expect(mockCommandHandler.emit).toHaveBeenNthCalledWith(1, 'moveViewportBy', { x: 10, y: 10 }); + expect(mockCommandHandler.emit).toHaveBeenNthCalledWith(2, 'moveViewportBy', { x: 10, y: 15 }); }); it('should not emit when not panning', () => { - // Create a fresh instance (not panning) const freshInstance = new PanningEventHandler(mockFlowCore); const event = getSamplePanningEvent({ @@ -151,7 +113,6 @@ describe('PanningEventHandler', () => { }); freshInstance.handle(event); - flushRAF(); expect(mockCommandHandler.emit).not.toHaveBeenCalled(); }); @@ -159,48 +120,37 @@ describe('PanningEventHandler', () => { describe('end phase', () => { beforeEach(() => { - // Start panning first const startEvent = getSamplePanningEvent({ phase: 'start', lastInputPoint: { x: 100, y: 100 }, }); instance.handle(startEvent); vi.clearAllMocks(); - rafCallbacks = []; }); - it('should flush remaining delta immediately on end', () => { - // Continue with some movement - const continueEvent = getSamplePanningEvent({ - phase: 'continue', - lastInputPoint: { x: 110, y: 110 }, - }); - instance.handle(continueEvent); - - // End panning - should flush immediately without waiting for RAF + it('should clear panning state', () => { const endEvent = getSamplePanningEvent({ phase: 'end', }); + instance.handle(endEvent); - expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 10, y: 10 }); + expect(mockFlowCore.actionStateManager.clearPanning).toHaveBeenCalled(); }); - it('should stop panning and clear state', () => { + it('should stop panning after end', () => { const endEvent = getSamplePanningEvent({ phase: 'end', }); instance.handle(endEvent); - // Verify panning stopped by trying to continue const continueEvent = getSamplePanningEvent({ phase: 'continue', lastInputPoint: { x: 110, y: 110 }, }); instance.handle(continueEvent); - flushRAF(); expect(mockCommandHandler.emit).not.toHaveBeenCalled(); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.handler.ts new file mode 100644 index 000000000..a70a7817e --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.handler.ts @@ -0,0 +1,78 @@ +import { Point } from '../../../types'; +import { EventHandler } from '../event-handler'; +import { PanningEvent } from './panning.event'; + +/** + * Virtualized panning handler with RAF-based throttling. + * Accumulates viewport deltas and emits them once per animation frame + * to reduce middleware executions and improve performance with large diagrams. + * + * Pan idle buffer fill is handled automatically by VirtualizedRenderStrategy + * via actionStateChanged events. + */ +export class VirtualizedPanningEventHandler extends EventHandler { + private lastPoint: Point | undefined; + private accumulatedDelta: Point = { x: 0, y: 0 }; + private rafScheduled = false; + + handle(event: PanningEvent): void { + switch (event.phase) { + case 'start': { + this.lastPoint = event.lastInputPoint; + this.accumulatedDelta = { x: 0, y: 0 }; + this.flow.actionStateManager.panning = { active: true }; + break; + } + case 'continue': { + if (!this.flow.actionStateManager.isPanning() || !this.lastPoint) { + break; + } + + const x = event.lastInputPoint.x - this.lastPoint.x; + const y = event.lastInputPoint.y - this.lastPoint.y; + + this.accumulatedDelta.x += x; + this.accumulatedDelta.y += y; + this.lastPoint = event.lastInputPoint; + + this.scheduleFlush(); + break; + } + case 'end': { + this.flushDelta(); + this.lastPoint = undefined; + this.rafScheduled = false; + this.flow.actionStateManager.clearPanning(); + break; + } + } + } + + /** + * Schedules a RAF callback to flush accumulated delta. + * Only one callback is scheduled at a time. + */ + private scheduleFlush(): void { + if (this.rafScheduled) { + return; + } + + this.rafScheduled = true; + requestAnimationFrame(() => { + this.flushDelta(); + this.rafScheduled = false; + }); + } + + /** + * Emits the accumulated viewport delta if non-zero, then resets it. + */ + private flushDelta(): void { + const { x, y } = this.accumulatedDelta; + + if (x !== 0 || y !== 0) { + this.flow.commandHandler.emit('moveViewportBy', { x, y }); + this.accumulatedDelta = { x: 0, y: 0 }; + } + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.test.ts new file mode 100644 index 000000000..e0626309e --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FlowCore } from '../../../flow-core'; +import { mockEnvironment } from '../../../test-utils'; +import { PanningEvent } from './panning.event'; +import { VirtualizedPanningEventHandler } from './virtualized-panning.handler'; + +function getSamplePanningEvent(overrides: Partial = {}): PanningEvent { + return { + name: 'panning', + id: 'test-id', + timestamp: Date.now(), + modifiers: { + primary: false, + secondary: false, + shift: false, + meta: false, + }, + target: undefined, + targetType: 'diagram', + lastInputPoint: { x: 100, y: 100 }, + phase: 'start', + ...overrides, + }; +} + +describe('VirtualizedPanningEventHandler', () => { + const mockCommandHandler = { emit: vi.fn() }; + let mockFlowCore: FlowCore; + let instance: VirtualizedPanningEventHandler; + let rafCallbacks: FrameRequestCallback[]; + + beforeEach(() => { + vi.clearAllMocks(); + rafCallbacks = []; + + // Mock requestAnimationFrame to capture callbacks + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }); + + const mockActionStateManager = { + panning: undefined as { active: boolean } | undefined, + clearPanning: vi.fn(() => { + mockActionStateManager.panning = undefined; + }), + isPanning: vi.fn(() => !!mockActionStateManager.panning?.active), + }; + + mockFlowCore = { + getState: vi.fn(), + applyUpdate: vi.fn(), + commandHandler: mockCommandHandler, + actionStateManager: mockActionStateManager, + bufferFillManager: { cancelFill: vi.fn(), scheduleFill: vi.fn() }, + environment: mockEnvironment, + } as unknown as FlowCore; + + instance = new VirtualizedPanningEventHandler(mockFlowCore); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** Flush all pending RAF callbacks */ + function flushRAF() { + const callbacks = [...rafCallbacks]; + rafCallbacks = []; + callbacks.forEach((cb) => cb(performance.now())); + } + + describe('handle', () => { + describe('start phase', () => { + it('should initialize panning state', () => { + const event = getSamplePanningEvent({ + phase: 'start', + lastInputPoint: { x: 100, y: 100 }, + }); + + instance.handle(event); + + // Verify internal state by testing subsequent continue phase + const continueEvent = getSamplePanningEvent({ + phase: 'continue', + lastInputPoint: { x: 110, y: 110 }, + }); + + instance.handle(continueEvent); + + // Flush RAF to trigger the emit + flushRAF(); + + expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 10, y: 10 }); + }); + }); + + describe('continue phase', () => { + beforeEach(() => { + // Start panning first + const startEvent = getSamplePanningEvent({ + phase: 'start', + lastInputPoint: { x: 100, y: 100 }, + }); + instance.handle(startEvent); + vi.clearAllMocks(); + rafCallbacks = []; + }); + + it('should emit moveViewportBy with correct delta after RAF', () => { + const event = getSamplePanningEvent({ + phase: 'continue', + lastInputPoint: { x: 110, y: 120 }, + }); + + instance.handle(event); + + // Should not emit immediately + expect(mockCommandHandler.emit).not.toHaveBeenCalled(); + + // Flush RAF to trigger the emit + flushRAF(); + + expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 10, y: 20 }); + }); + + it('should accumulate multiple moves and emit once per RAF', () => { + // First move + const firstEvent = getSamplePanningEvent({ + phase: 'continue', + lastInputPoint: { x: 110, y: 110 }, + }); + instance.handle(firstEvent); + + // Second move (before RAF fires) + const secondEvent = getSamplePanningEvent({ + phase: 'continue', + lastInputPoint: { x: 120, y: 125 }, + }); + instance.handle(secondEvent); + + // Should not emit yet + expect(mockCommandHandler.emit).not.toHaveBeenCalled(); + + // Flush RAF - should emit accumulated delta (100,100) -> (120,125) = (20, 25) + flushRAF(); + + expect(mockCommandHandler.emit).toHaveBeenCalledTimes(1); + expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 20, y: 25 }); + }); + + it('should not emit when not panning', () => { + // Create a fresh instance (not panning) + const freshInstance = new VirtualizedPanningEventHandler(mockFlowCore); + + const event = getSamplePanningEvent({ + phase: 'continue', + lastInputPoint: { x: 110, y: 110 }, + }); + + freshInstance.handle(event); + flushRAF(); + + expect(mockCommandHandler.emit).not.toHaveBeenCalled(); + }); + }); + + describe('end phase', () => { + beforeEach(() => { + // Start panning first + const startEvent = getSamplePanningEvent({ + phase: 'start', + lastInputPoint: { x: 100, y: 100 }, + }); + instance.handle(startEvent); + vi.clearAllMocks(); + rafCallbacks = []; + }); + + it('should flush remaining delta immediately on end', () => { + // Continue with some movement + const continueEvent = getSamplePanningEvent({ + phase: 'continue', + lastInputPoint: { x: 110, y: 110 }, + }); + instance.handle(continueEvent); + + // End panning - should flush immediately without waiting for RAF + const endEvent = getSamplePanningEvent({ + phase: 'end', + }); + instance.handle(endEvent); + + expect(mockCommandHandler.emit).toHaveBeenCalledWith('moveViewportBy', { x: 10, y: 10 }); + }); + + it('should stop panning and clear state', () => { + const endEvent = getSamplePanningEvent({ + phase: 'end', + }); + + instance.handle(endEvent); + + // Verify panning stopped by trying to continue + const continueEvent = getSamplePanningEvent({ + phase: 'continue', + lastInputPoint: { x: 110, y: 110 }, + }); + + instance.handle(continueEvent); + flushRAF(); + + expect(mockCommandHandler.emit).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.router.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.router.ts index 81d6dbd2e..6ce6106c6 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.router.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.router.ts @@ -8,7 +8,7 @@ import { KeyboardMoveSelectionEventHandler } from './handlers/keyboard-move-sele import { KeyboardPanningEventHandler } from './handlers/keyboard-panning/keyboard-panning.handler'; import { LinkingEventHandler } from './handlers/linking/linking.handler'; import { PaletteDropEventHandler } from './handlers/palette-drop/palette-drop.handler'; -import { PanningEventHandler } from './handlers/panning/panning.handler'; +import { panningHandlerFactory } from './handlers/panning/panning-handler-factory'; import { PasteEventHandler } from './handlers/paste/paste.handler'; import { PointerMoveSelectionEventHandler } from './handlers/pointer-move-selection/pointer-move-selection.handler'; import { RedoEventHandler } from './handlers/redo/redo.handler'; @@ -41,7 +41,7 @@ export abstract class InputEventsRouter { this.register('copy', new CopyEventHandler(flow)); this.register('select', new SelectEventHandler(flow)); this.register('selectAll', new SelectAllEventHandler(flow)); - this.register('panning', new PanningEventHandler(flow)); + this.register('panning', panningHandlerFactory(flow)); this.register('keyboardPanning', new KeyboardPanningEventHandler(flow)); this.register('pointerMoveSelection', new PointerMoveSelectionEventHandler(flow)); this.register('keyboardMoveSelection', new KeyboardMoveSelectionEventHandler(flow)); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/buffer-fill-manager.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/buffer-fill-manager.ts deleted file mode 100644 index e4d92dd92..000000000 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/buffer-fill-manager.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { FlowCore } from '../flow-core'; - -/** - * Manages buffer filling during pan idle time. - * After panning stops, schedules rendering with expanded buffer to preload - * nodes in all directions for smoother subsequent panning. - */ -export class BufferFillManager { - private fillTimer: ReturnType | null = null; - private unsubscribePanStarted: (() => void) | null = null; - private unsubscribePanEnded: (() => void) | null = null; - - constructor( - private readonly flowCore: FlowCore, - private readonly idleThreshold: number - ) { - this.setupListeners(); - } - - private setupListeners(): void { - this.unsubscribePanEnded = this.flowCore.eventManager.on('panEnded', () => { - this.scheduleFill(); - }); - - this.unsubscribePanStarted = this.flowCore.eventManager.on('panStarted', () => { - this.cancelFill(); - }); - } - - private scheduleFill(): void { - this.cancelFill(); - this.fillTimer = setTimeout(() => { - this.executeFill(); - }, this.idleThreshold); - } - - private cancelFill(): void { - if (this.fillTimer !== null) { - clearTimeout(this.fillTimer); - this.fillTimer = null; - } - } - - private executeFill(): void { - this.flowCore.renderWithExpandedBuffer(); - this.fillTimer = null; - } - - destroy(): void { - this.cancelFill(); - this.unsubscribePanStarted?.(); - this.unsubscribePanEnded?.(); - } -} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.test.ts index b08cf3eb7..497a08c62 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; +import type { FlowCore } from '../flow-core'; import { mockEdge, mockNode } from '../test-utils'; import type { Edge, Node } from '../types'; import { DirectRenderStrategy } from './direct-render-strategy'; describe('DirectRenderStrategy', () => { - const strategy = new DirectRenderStrategy(); + const mockFlowCore = {} as FlowCore; + const strategy = new DirectRenderStrategy(mockFlowCore); it('should return all nodes and edges unchanged', () => { const nodes: Node[] = [ diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts index 5a876799d..f4ca5dd25 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts @@ -1,3 +1,4 @@ +import type { FlowCore } from '../flow-core'; import type { Edge, Node } from '../types'; import type { RenderStrategy, RenderStrategyResult } from './render-strategy.interface'; @@ -9,6 +10,24 @@ const EMPTY_SET = new Set(); * Used when virtualization is disabled. */ export class DirectRenderStrategy implements RenderStrategy { + constructor(private readonly flowCore: FlowCore) {} + + init(): void { + this.flowCore.render(); + + this.flowCore.model.onChange((state) => { + this.flowCore.spatialHash.process(state.nodes); + this.flowCore.modelLookup.desynchronize(); + this.flowCore.render(); + }); + + const nodes = this.flowCore.model.getNodes(); + const edges = this.flowCore.model.getEdges(); + this.flowCore.initUpdater.start(nodes, edges, async () => { + await this.flowCore.commandHandler.emit('init', {}); + }); + } + process(nodes: Node[], edges: Edge[]): RenderStrategyResult { return { nodes, edges, nodeIds: EMPTY_SET, edgeIds: EMPTY_SET }; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts index 7937e522e..7aea953d4 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts @@ -1,4 +1,3 @@ export * from './render-strategy.interface'; export * from './direct-render-strategy'; export * from './virtualized-render-strategy'; -export * from './buffer-fill-manager'; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts index 1ffda0c45..bad7ab7c8 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts @@ -8,6 +8,10 @@ export interface RenderStrategyResult { } export interface RenderStrategy { + /** + * Initializes the strategy. Sets up model change handlers and starts the init process. + */ + init(): void; process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult; invalidateCache?(): void; destroy?(): void; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts index 3b2865aba..fa3004280 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts @@ -50,7 +50,11 @@ describe('VirtualizedRenderStrategy', () => { actionStateManager: { isPanning: vi.fn().mockReturnValue(false), }, - renderWithExpandedBuffer: vi.fn(), + eventManager: { + // eslint-disable-next-line @typescript-eslint/no-empty-function + on: vi.fn().mockReturnValue(() => {}), // Returns unsubscribe function + }, + render: vi.fn(), } as unknown as FlowCore; strategy = new VirtualizedRenderStrategy(mockFlowCore); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts index 3a0cf6c67..39266dfd2 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts @@ -1,3 +1,4 @@ +import type { EventManager } from '../event-manager/event-manager'; import { FlowCore } from '../flow-core'; import type { Edge, Node, Rect, Viewport, VirtualizationConfig } from '../types'; import { isGroup } from '../utils'; @@ -21,6 +22,10 @@ const EMPTY_SET = new Set(); /** * Virtualized render strategy - returns only nodes and edges visible in the viewport. * Used when virtualization is enabled for large diagrams. + * + * Handles idle management for both panning and zooming: + * - During active panning/zooming: uses cached results for performance + * - After idle period: renders with expanded buffer to preload more nodes */ export class VirtualizedRenderStrategy implements RenderStrategy { private lastViewportRect: Rect | null = null; @@ -35,7 +40,65 @@ export class VirtualizedRenderStrategy implements RenderStrategy { private zoomIdleTimeout: ReturnType | null = null; private isZooming = false; - constructor(private readonly flowCore: FlowCore) {} + // Pan idle tracking + private panIdleTimeout: ReturnType | null = null; + private wasPanning = false; + private unsubscribeActionState: (() => void) | null = null; + + // Flag to use expanded buffer on next process() call + private pendingExpandedBuffer = false; + + // Track nodes reference for optimization (skip spatialHash update during panning/zooming) + private lastNodesRef: Node[] | null = null; + + constructor(private readonly flowCore: FlowCore) { + this.subscribeToActionState(flowCore.eventManager); + } + + init(): void { + this.flowCore.spatialHash.process(this.flowCore.model.getNodes()); + + this.flowCore.model.onChange((state) => { + // Optimization: skip spatialHash update during panning/zooming (nodes reference stays the same) + if (state.nodes !== this.lastNodesRef) { + this.flowCore.spatialHash.process(state.nodes); + this.flowCore.modelLookup.desynchronize(); + this.lastNodesRef = state.nodes; + } + this.flowCore.render(); + }); + + // Trigger initial render to ensure consistent visible nodes + this.flowCore.render(); + + const { nodes, edges, metadata } = this.flowCore.getState(); + const result = this.process(nodes, edges, metadata.viewport); + this.flowCore.initUpdater.start(result.nodes, result.edges, async () => { + await this.flowCore.commandHandler.emit('init', { + renderedNodeIds: result.nodes.map((n) => n.id), + renderedEdgeIds: result.edges.map((e) => e.id), + }); + }); + } + + /** + * Subscribes to actionStateChanged to detect pan start/end. + */ + private subscribeToActionState(eventManager: EventManager): void { + this.unsubscribeActionState = eventManager.on('actionStateChanged', ({ actionState }) => { + const isPanning = !!actionState.panning?.active; + + if (this.wasPanning && !isPanning) { + // Panning ended - schedule idle fill + this.schedulePanIdle(); + } else if (!this.wasPanning && isPanning) { + // Panning started - cancel any pending fill + this.cancelPanIdle(); + } + + this.wasPanning = isPanning; + }); + } process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult { const config = this.flowCore.config.virtualization; @@ -44,6 +107,12 @@ export class VirtualizedRenderStrategy implements RenderStrategy { return { nodes, edges, nodeIds: EMPTY_SET, edgeIds: EMPTY_SET }; } + // Check if we should use expanded buffer (set by idle callbacks) + const useExpandedBuffer = this.pendingExpandedBuffer; + if (useExpandedBuffer) { + this.pendingExpandedBuffer = false; + } + // Detect zoom changes and defer recomputation during active zooming const currentScale = viewport!.scale; const scaleChanged = this.lastScale !== null && this.lastScale !== currentScale; @@ -55,27 +124,31 @@ export class VirtualizedRenderStrategy implements RenderStrategy { this.lastScale = currentScale; - const viewportRect = this.getViewportRect(viewport!, config.padding); + const paddingMultiplier = useExpandedBuffer ? config.expandedPadding : config.padding; + const viewportRect = this.getViewportRect(viewport!, paddingMultiplier); // During active zooming or panning, use cached result to avoid lag const isPanning = this.flowCore.actionStateManager.isPanning(); const hasCache = this.cachedNodeIds && this.cachedEdgeIds; - if (this.isZooming && hasCache) { - return this.buildResultFromCachedIds(nodes, edges); - } - - if (isPanning && hasCache) { - // During panning, use cache unless we've moved too far from last recompute - if (this.lastViewportRect && !this.hasMovedTooFar(viewportRect, this.lastViewportRect)) { + // Skip caching when using expanded buffer - we want fresh computation + if (!useExpandedBuffer) { + if (this.isZooming && hasCache) { return this.buildResultFromCachedIds(nodes, edges); } - // Moved too far - fall through to recompute - } - if (this.canUseCachedResult(nodes.length, edges.length, viewportRect)) { - // Use cached IDs but look up fresh objects from input arrays - return this.buildResultFromCachedIds(nodes, edges); + if (isPanning && hasCache) { + // During panning, use cache unless we've moved too far from last recompute + if (this.lastViewportRect && !this.hasMovedTooFar(viewportRect, this.lastViewportRect)) { + return this.buildResultFromCachedIds(nodes, edges); + } + // Moved too far - fall through to recompute + } + + if (this.canUseCachedResult(nodes.length, edges.length, viewportRect)) { + // Use cached IDs but look up fresh objects from input arrays + return this.buildResultFromCachedIds(nodes, edges); + } } const result = this.computeVisibleElements(viewportRect); @@ -102,13 +175,48 @@ export class VirtualizedRenderStrategy implements RenderStrategy { this.zoomIdleTimeout = setTimeout(() => { this.isZooming = false; this.zoomIdleTimeout = null; - // Invalidate cache to force recomputation on next render - this.lastViewportRect = null; - // Trigger a render to update with new zoom level - this.flowCore.renderWithExpandedBuffer(); + this.triggerExpandedBufferRender(); }, ZOOM_IDLE_DELAY); } + /** + * Schedules pan idle fill after panning ends. + * Uses the configured idle threshold from virtualization config. + */ + private schedulePanIdle(): void { + if (!this.flowCore.config.virtualization.bufferFill.enabled) { + return; + } + + this.cancelPanIdle(); + const idleThreshold = this.flowCore.config.virtualization.bufferFill.idleThreshold; + + this.panIdleTimeout = setTimeout(() => { + this.panIdleTimeout = null; + this.triggerExpandedBufferRender(); + }, idleThreshold); + } + + /** + * Cancels any pending pan idle fill. + */ + private cancelPanIdle(): void { + if (this.panIdleTimeout !== null) { + clearTimeout(this.panIdleTimeout); + this.panIdleTimeout = null; + } + } + + /** + * Triggers a render with expanded buffer by setting the flag and requesting render. + */ + private triggerExpandedBufferRender(): void { + // Invalidate cache to force recomputation + this.lastViewportRect = null; + this.pendingExpandedBuffer = true; + this.flowCore.render(); + } + private buildResultFromCachedIds(nodes: Node[], edges: Edge[]): RenderStrategyResult { const filteredNodes = nodes.filter((n) => this.cachedNodeIds!.has(n.id)); const filteredEdges = edges.filter((e) => this.cachedEdgeIds!.has(e.id)); @@ -127,34 +235,11 @@ export class VirtualizedRenderStrategy implements RenderStrategy { destroy(): void { if (this.zoomIdleTimeout) { clearTimeout(this.zoomIdleTimeout); + this.zoomIdleTimeout = null; } - } - - /** - * Process with expanded buffer padding - used during pan idle to preload more nodes. - * Uses expandedPadding from config instead of normal padding. - */ - processWithExpandedBuffer(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult { - const config = this.flowCore.config.virtualization; - - if (this.shouldBypass(nodes, viewport, config)) { - return { nodes, edges, nodeIds: EMPTY_SET, edgeIds: EMPTY_SET }; - } - - // Use expanded padding for buffer fill - const viewportRect = this.getViewportRect(viewport!, config.expandedPadding); - - // Force fresh computation with expanded buffer - const result = this.computeVisibleElements(viewportRect); - - // Update cache with expanded buffer results - this.cachedNodeIds = result.nodeIds; - this.cachedEdgeIds = result.edgeIds; - this.lastViewportRect = viewportRect; - this.lastNodesLength = nodes.length; - this.lastEdgesLength = edges.length; - - return result; + this.cancelPanIdle(); + this.unsubscribeActionState?.(); + this.unsubscribeActionState = null; } private shouldBypass(nodes: Node[], viewport: Viewport | undefined, config: VirtualizationConfig): boolean { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.test.ts index 14693676b..514b7ba4c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.test.ts @@ -8,7 +8,9 @@ describe('InitUpdater', () => { let mockFlowCore: { getState: Mock; setState: Mock; - getRenderedModel: Mock; + renderStrategy: { + process: Mock; + }; internalUpdater: { addPort: Mock; addEdgeLabel: Mock; @@ -17,6 +19,7 @@ describe('InitUpdater', () => { applyEdgeLabelSize: Mock; }; }; + let mockRenderedModel: { nodes: Node[]; edges: Edge[] }; const createMockNode = (id: string, withPorts = false): Node => ({ id, @@ -75,7 +78,9 @@ describe('InitUpdater', () => { mockFlowCore = { getState: vi.fn(), setState: vi.fn(), - getRenderedModel: vi.fn(), + renderStrategy: { + process: vi.fn().mockReturnValue({ nodes: [], edges: [] }), + }, internalUpdater: { addPort: vi.fn(), addEdgeLabel: vi.fn(), @@ -84,6 +89,15 @@ describe('InitUpdater', () => { applyEdgeLabelSize: vi.fn(), }, }; + + mockRenderedModel = { nodes: [], edges: [] }; + + // Default getState mock - tests will override as needed + mockFlowCore.getState.mockReturnValue({ + nodes: [], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); }); afterEach(() => { @@ -92,30 +106,18 @@ describe('InitUpdater', () => { describe('constructor', () => { it('should initialize with isInitialized=false when no nodes or edges', () => { - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [] }); - initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); expect(initUpdater.isInitialized).toBe(false); }); it('should initialize with isInitialized=false when nodes exist', () => { - mockFlowCore.getRenderedModel.mockReturnValue({ - nodes: [createMockNode('node1')], - edges: [], - }); - initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); expect(initUpdater.isInitialized).toBe(false); }); it('should initialize with isInitialized=false when edges exist', () => { - mockFlowCore.getRenderedModel.mockReturnValue({ - nodes: [], - edges: [createMockEdge('edge1')], - }); - initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); expect(initUpdater.isInitialized).toBe(false); @@ -124,11 +126,15 @@ describe('InitUpdater', () => { describe('start', () => { it('should mark as initialized immediately with no entities', async () => { - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [] }); + mockRenderedModel = { nodes: [], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); // Should resolve immediately without waiting for stability delay await vi.runAllTimersAsync(); @@ -137,12 +143,16 @@ describe('InitUpdater', () => { }); it('should call onComplete callback after initialization', async () => { - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [] }); + mockRenderedModel = { nodes: [], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); const onComplete = vi.fn(); - initUpdater.start(onComplete); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges, onComplete); vi.advanceTimersByTime(STABILITY_DELAY); await vi.runAllTimersAsync(); @@ -152,11 +162,15 @@ describe('InitUpdater', () => { it('should wait for node measurements before finishing', async () => { const node = { ...createMockNode('node1'), size: undefined }; - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); + mockRenderedModel = { nodes: [node], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [node], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); // Advance only stability delay, not the measurement timeout vi.advanceTimersByTime(STABILITY_DELAY); @@ -186,11 +200,15 @@ describe('InitUpdater', () => { }, ], }; - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); + mockRenderedModel = { nodes: [node], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [node], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); // Advance only stability delay, not the measurement timeout vi.advanceTimersByTime(STABILITY_DELAY); @@ -218,11 +236,15 @@ describe('InitUpdater', () => { }, ], }; - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [edge] }); - mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [edge] }); + mockRenderedModel = { nodes: [], edges: [edge] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [], + edges: [edge], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); // Advance only stability delay, not the measurement timeout vi.advanceTimersByTime(STABILITY_DELAY); @@ -238,12 +260,16 @@ describe('InitUpdater', () => { }); it('should handle async onComplete callback', async () => { - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [] }); + mockRenderedModel = { nodes: [], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); const onComplete = vi.fn().mockResolvedValue(undefined); - initUpdater.start(onComplete); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges, onComplete); vi.advanceTimersByTime(STABILITY_DELAY); await vi.runAllTimersAsync(); @@ -256,11 +282,15 @@ describe('InitUpdater', () => { describe('applyNodeSize', () => { it('should record node size measurement and apply on finish', async () => { const node = { ...createMockNode('node1'), size: undefined }; - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); + mockRenderedModel = { nodes: [node], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [node], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); vi.advanceTimersByTime(STABILITY_DELAY); await Promise.resolve(); @@ -277,8 +307,12 @@ describe('InitUpdater', () => { it('should queue measurement if finishing', async () => { const node = { ...createMockNode('node1'), size: undefined }; - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); + mockRenderedModel = { nodes: [node], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [node], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); const onComplete = vi.fn(() => { @@ -286,7 +320,7 @@ describe('InitUpdater', () => { initUpdater.applyNodeSize('node2', { width: 200, height: 200 }); }); - initUpdater.start(onComplete); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges, onComplete); vi.advanceTimersByTime(STABILITY_DELAY); await vi.runAllTimersAsync(); @@ -307,13 +341,17 @@ describe('InitUpdater', () => { describe('addPort', () => { beforeEach(() => { const node = createMockNode('node1'); - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); + mockRenderedModel = { nodes: [node], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [node], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); }); it('should add port and delay stabilization', async () => { - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); const port = createMockPort('port1'); initUpdater.addPort('node1', port); @@ -326,7 +364,7 @@ describe('InitUpdater', () => { }); it('should reset stability timer on multiple port additions', async () => { - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); initUpdater.addPort('node1', createMockPort('port1')); @@ -363,11 +401,15 @@ describe('InitUpdater', () => { }, ], }; - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); + mockRenderedModel = { nodes: [node], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [node], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); vi.advanceTimersByTime(STABILITY_DELAY); await vi.runAllTimersAsync(); @@ -396,11 +438,15 @@ describe('InitUpdater', () => { }, ], }; - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); + mockRenderedModel = { nodes: [node], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [node], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); vi.advanceTimersByTime(STABILITY_DELAY); await Promise.resolve(); @@ -419,13 +465,17 @@ describe('InitUpdater', () => { describe('addEdgeLabel', () => { beforeEach(() => { const edge = createMockEdge('edge1'); - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [edge] }); - mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [edge] }); + mockRenderedModel = { nodes: [], edges: [edge] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [], + edges: [edge], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); }); it('should add label and delay stabilization', async () => { - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); const label = createMockEdgeLabel('label1'); initUpdater.addEdgeLabel('edge1', label); @@ -438,7 +488,7 @@ describe('InitUpdater', () => { }); it('should reset stability timer on multiple label additions', async () => { - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); initUpdater.addEdgeLabel('edge1', createMockEdgeLabel('label1')); @@ -462,13 +512,17 @@ describe('InitUpdater', () => { describe('applyEdgeLabelSize', () => { beforeEach(() => { const edge = createMockEdge('edge1', true); - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [edge] }); - mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [edge] }); + mockRenderedModel = { nodes: [], edges: [edge] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [], + edges: [edge], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); }); it('should record label size measurement', async () => { - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); vi.advanceTimersByTime(STABILITY_DELAY); await vi.runAllTimersAsync(); @@ -484,8 +538,12 @@ describe('InitUpdater', () => { describe('late arrival queueing', () => { it('should queue port additions that arrive during finish', async () => { const node = { ...createMockNode('node1'), size: undefined }; - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); + mockRenderedModel = { nodes: [node], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [node], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); let finishCallbackExecuted = false; @@ -495,7 +553,7 @@ describe('InitUpdater', () => { finishCallbackExecuted = true; }); - initUpdater.start(onComplete); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges, onComplete); vi.advanceTimersByTime(STABILITY_DELAY); await vi.runAllTimersAsync(); @@ -512,8 +570,12 @@ describe('InitUpdater', () => { it('should queue measurements that arrive during finish', async () => { const node = { ...createMockNode('node1'), size: undefined }; - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); + mockRenderedModel = { nodes: [node], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [node], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); let finishCallbackExecuted = false; @@ -523,7 +585,7 @@ describe('InitUpdater', () => { finishCallbackExecuted = true; }); - initUpdater.start(onComplete); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges, onComplete); vi.advanceTimersByTime(STABILITY_DELAY); await vi.runAllTimersAsync(); @@ -542,8 +604,12 @@ describe('InitUpdater', () => { }); it('should queue edge label additions that arrive during finish', async () => { - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [] }); + mockRenderedModel = { nodes: [], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); let finishCallbackExecuted = false; @@ -553,7 +619,7 @@ describe('InitUpdater', () => { finishCallbackExecuted = true; }); - initUpdater.start(onComplete); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges, onComplete); vi.advanceTimersByTime(STABILITY_DELAY); await vi.runAllTimersAsync(); @@ -567,11 +633,15 @@ describe('InitUpdater', () => { describe('state application', () => { it('should apply all collected data on finish', async () => { const node = createMockNode('node1'); - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); + mockRenderedModel = { nodes: [node], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [node], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); initUpdater.applyNodeSize('node1', { width: 150, height: 150 }); vi.advanceTimersByTime(STABILITY_DELAY); @@ -584,11 +654,15 @@ describe('InitUpdater', () => { it('should merge new ports with existing ports', async () => { const node = createMockNode('node1', true); - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); + mockRenderedModel = { nodes: [node], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [node], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); const newPort = createMockPort('port2'); initUpdater.addPort('node1', newPort); @@ -609,11 +683,15 @@ describe('InitUpdater', () => { it('should merge new labels with existing labels', async () => { const edge = createMockEdge('edge1', true); - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [], edges: [edge] }); - mockFlowCore.getState.mockReturnValue({ nodes: [], edges: [edge] }); + mockRenderedModel = { nodes: [], edges: [edge] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [], + edges: [edge], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); const newLabel = createMockEdgeLabel('label2'); initUpdater.addEdgeLabel('edge1', newLabel); @@ -633,11 +711,15 @@ describe('InitUpdater', () => { describe('safety timeout', () => { it('should force finish after measurement timeout when measurements never arrive', async () => { const node = { ...createMockNode('node1'), size: undefined }; - mockFlowCore.getRenderedModel.mockReturnValue({ nodes: [node], edges: [] }); - mockFlowCore.getState.mockReturnValue({ nodes: [node], edges: [] }); + mockRenderedModel = { nodes: [node], edges: [] }; + mockFlowCore.getState.mockReturnValue({ + nodes: [node], + edges: [], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, + }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); - initUpdater.start(); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges); // Advance stability delay vi.advanceTimersByTime(STABILITY_DELAY); @@ -673,19 +755,20 @@ describe('InitUpdater', () => { ], }; const edge1 = createMockEdge('edge1'); - mockFlowCore.getRenderedModel.mockReturnValue({ + mockRenderedModel = { nodes: [node1, node2], edges: [edge1], - }); + }; mockFlowCore.getState.mockReturnValue({ nodes: [node1, node2], edges: [edge1], + metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } }, }); initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore); const onComplete = vi.fn(); - initUpdater.start(onComplete); + initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges, onComplete); // Add new entities initUpdater.addPort('node1', createMockPort('port1')); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts index 76bd443c1..008f1d041 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts @@ -1,5 +1,5 @@ import { FlowCore } from '../../flow-core'; -import { EdgeLabel, Node, Port, Size } from '../../types'; +import { Edge, EdgeLabel, Node, Port, Size } from '../../types'; import { Updater } from '../updater.interface'; import { InitState } from './init-state'; import { LateArrivalQueue } from './late-arrival-queue'; @@ -68,6 +68,9 @@ export class InitUpdater implements Updater { /** Callback to execute when initialization completes */ private onCompleteCallback?: () => void | Promise; + /** Number of rendered nodes (captured at start for measurement tracking) */ + private renderedNodeCount = 0; + /** Safety timeout to prevent indefinite waiting for measurements */ private measurementTimeout: ReturnType | null = null; @@ -85,13 +88,14 @@ export class InitUpdater implements Updater { * Starts the initialization process. * Collects pre-existing measurements and waits for entity additions to stabilize. * + * @param nodes - Rendered nodes to track for initialization + * @param edges - Rendered edges to track for initialization * @param onComplete - Optional callback to execute after initialization completes */ - start(onComplete?: () => void | Promise) { + start(nodes: Node[], edges: Edge[], onComplete?: () => void | Promise) { + this.renderedNodeCount = nodes.length; this.onCompleteCallback = onComplete; - const { nodes, edges } = this.flowCore.getRenderedModel(); - const hasNodes = nodes.length > 0; const hasEdges = edges.length > 0; @@ -233,8 +237,7 @@ export class InitUpdater implements Updater { return; } - const { nodes } = this.flowCore.getRenderedModel(); - if (this.initState.allEntitiesHaveMeasurements(nodes.length)) { + if (this.initState.allEntitiesHaveMeasurements(this.renderedNodeCount)) { this.finish(); } } @@ -274,8 +277,7 @@ export class InitUpdater implements Updater { private startMeasurementTimeout(): void { this.measurementTimeout = setTimeout(() => { if (!this.isInitialized) { - const { nodes } = this.flowCore.getRenderedModel(); - const nodeCount = nodes.length; + const nodeCount = this.getNodeCount(); const expectedPorts = this.initState.portsToMeasure.size; const measuredPorts = this.initState.measuredPorts.size; const expectedLabels = this.initState.labelsToMeasure.size; @@ -305,4 +307,10 @@ export class InitUpdater implements Updater { this.measurementTimeout = null; } } + + private getNodeCount() { + const { nodes, edges, metadata } = this.flowCore.getState(); + const result = this.flowCore.renderStrategy.process(nodes, edges, metadata.viewport); + return result.nodes.length; + } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts index 71527c86c..1eb127a8c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts @@ -7,117 +7,128 @@ import { Updater } from '../updater.interface'; export class InternalUpdater implements Updater { constructor(private readonly flowCore: FlowCore) {} + private get isVirtualizationEnabled(): boolean { + return this.flowCore.config.virtualization.enabled; + } + /** * @internal * Internal method to initialize a node size - * @param nodeId Node id - * @param size Size */ applyNodeSize(nodeId: string, size: NonNullable): void { const node = this.flowCore.getNodeById(nodeId); - // If the node size is the same the ports should be the same too - if (!node || isSameRect(getRect(node), getRect({ size })) || this.flowCore.actionStateManager.isResizing()) { + if (!node || this.isResizing() || this.hasSameSize(node, size)) { return; } - this.flowCore.commandHandler.emit('resizeNode', { - id: nodeId, - size, - }); + this.flowCore.commandHandler.emit('resizeNode', { id: nodeId, size }); + } + + private isResizing(): boolean { + return this.flowCore.actionStateManager.isResizing(); + } + + private hasSameSize(node: Node, size: NonNullable): boolean { + return isSameRect(getRect(node), getRect({ size })); } /** * @internal * Internal method to add a new port to the flow - * @param nodeId Node id - * @param port Port */ addPort(nodeId: string, port: Port): void { - if (this.flowCore.config.virtualization.enabled) { - // VIRTUALIZATION MODE: skip if port already has measurements (node re-entered viewport) - const node = this.flowCore.getNodeById(nodeId); - const existingPort = node?.measuredPorts?.find((p) => p.id === port.id); - if (existingPort?.size && existingPort?.position) { - return; // Port already measured, skip redundant add - } - // Port not measured yet, proceed with batched add for performance - this.flowCore.portBatchProcessor.processAddBatched(nodeId, port, (allAdditions) => { - this.flowCore.commandHandler.emit('addPortsBulk', { additions: allAdditions }); - }); + if (this.isVirtualizationEnabled) { + this.addPortVirtualized(nodeId, port); } else { - // STANDARD MODE: original behavior - per-node batching within same tick - this.flowCore.portBatchProcessor.processAdd(nodeId, port, (nodeId, ports) => { - this.flowCore.commandHandler.emit('addPorts', { nodeId, ports }); - }); + this.addPortStandard(nodeId, port); } } + private addPortVirtualized(nodeId: string, port: Port): void { + if (this.isPortAlreadyMeasured(nodeId, port.id)) { + return; + } + + this.flowCore.portBatchProcessor.processAddBatched(nodeId, port, (allAdditions) => { + this.flowCore.commandHandler.emit('addPortsBulk', { additions: allAdditions }); + }); + } + + private addPortStandard(nodeId: string, port: Port): void { + this.flowCore.portBatchProcessor.processAdd(nodeId, port, (nodeId, ports) => { + this.flowCore.commandHandler.emit('addPorts', { nodeId, ports }); + }); + } + + private isPortAlreadyMeasured(nodeId: string, portId: string): boolean { + const node = this.flowCore.getNodeById(nodeId); + const existingPort = node?.measuredPorts?.find((p) => p.id === portId); + return !!(existingPort?.size && existingPort?.position); + } + /** * @internal - * Internal method to delete a port from the flow - * @param nodeId Node id - * @param portId Port id + * Internal method to delete a port from the flow. + * In virtualization mode, ports persist in model (only DOM unmounts). */ deletePort(nodeId: string, portId: string): void { - if (this.flowCore.config.virtualization.enabled) { - // VIRTUALIZATION MODE: skip delete - ports persist in model, only DOM unmounts + if (this.isVirtualizationEnabled) { return; - } else { - // STANDARD MODE: original behavior - single command per port - this.flowCore.commandHandler.emit('deletePorts', { nodeId, portIds: [portId] }); } + + this.flowCore.commandHandler.emit('deletePorts', { nodeId, portIds: [portId] }); } /** * @internal * Internal method to apply a port size and position - * @param nodeId Node id - * @param ports Ports with size and position */ applyPortsSizesAndPositions(nodeId: string, ports: NonNullable>[]): void { const node = this.flowCore.getNodeById(nodeId); - if (!node) { return; } const portsToUpdate = this.getPortsToUpdate(node, ports); - if (portsToUpdate.length === 0) { return; } - if (this.flowCore.config.virtualization.enabled) { - // VIRTUALIZATION MODE: batched updates for performance - portsToUpdate.forEach(({ id, size, position }) => { - this.flowCore.portBatchProcessor.processUpdateBatched( - nodeId, - { portId: id, portChanges: { size, position } }, - (allUpdates) => { - this.flowCore.commandHandler.emit('updatePortsBulk', { updates: allUpdates }); - } - ); - }); + if (this.isVirtualizationEnabled) { + this.updatePortsVirtualized(nodeId, portsToUpdate); } else { - // STANDARD MODE: original behavior - per-node batching within same tick - portsToUpdate.forEach(({ id, size, position }) => { - this.flowCore.portBatchProcessor.processUpdate( - nodeId, - { portId: id, portChanges: { size, position } }, - (nodeId, portUpdates) => { - this.flowCore.commandHandler.emit('updatePorts', { nodeId, ports: portUpdates }); - } - ); - }); + this.updatePortsStandard(nodeId, portsToUpdate); + } + } + + private updatePortsVirtualized(nodeId: string, ports: Pick[]): void { + for (const { id, size, position } of ports) { + this.flowCore.portBatchProcessor.processUpdateBatched( + nodeId, + { portId: id, portChanges: { size, position } }, + (allUpdates) => { + this.flowCore.commandHandler.emit('updatePortsBulk', { updates: allUpdates }); + } + ); + } + } + + private updatePortsStandard(nodeId: string, ports: Pick[]): void { + for (const { id, size, position } of ports) { + this.flowCore.portBatchProcessor.processUpdate( + nodeId, + { portId: id, portChanges: { size, position } }, + (nodeId, portUpdates) => { + this.flowCore.commandHandler.emit('updatePorts', { nodeId, ports: portUpdates }); + } + ); } } /** * @internal * Internal method to add a new edge label to the flow - * @param edgeId Edge id - * @param label Label */ addEdgeLabel(edgeId: string, label: EdgeLabel): void { this.flowCore.labelBatchProcessor.processAdd(edgeId, label, (edgeId, labels) => { @@ -128,15 +139,11 @@ export class InternalUpdater implements Updater { /** * @internal * Internal method to apply an edge label size - * @param edgeId Edge id - * @param labelId Label id - * @param size Size */ applyEdgeLabelSize(edgeId: string, labelId: string, size: NonNullable): void { - const edge = this.flowCore.getEdgeById(edgeId); - const label = edge?.measuredLabels?.find((label) => label.id === labelId); + const label = this.findEdgeLabel(edgeId, labelId); - if (!label || isSameRect(getRect({ size: label.size }), getRect({ size }))) { + if (!label || this.hasSameLabelSize(label, size)) { return; } @@ -149,14 +156,44 @@ export class InternalUpdater implements Updater { ); } - private getPortsToUpdate(node: Node, ports: NonNullable>[]) { - const allPortsMap = new Map(); - node?.measuredPorts?.forEach(({ id, size, position }) => allPortsMap.set(id, { size, position })); + private findEdgeLabel(edgeId: string, labelId: string): EdgeLabel | undefined { + const edge = this.flowCore.getEdgeById(edgeId); + return edge?.measuredLabels?.find((label) => label.id === labelId); + } - return ports.filter(({ id, size, position }) => { - const port = allPortsMap.get(id); + private hasSameLabelSize(label: EdgeLabel, size: NonNullable): boolean { + return isSameRect(getRect({ size: label.size }), getRect({ size })); + } - return port && !isSameRect(getRect(port), getRect({ size, position })); - }); + private getPortsToUpdate( + node: Node, + ports: Pick[] + ): Pick[] { + const measuredPortsMap = this.buildMeasuredPortsMap(node); + + return ports.filter((port) => this.hasPortChanged(port, measuredPortsMap)); + } + + private buildMeasuredPortsMap(node: Node): Map { + const map = new Map(); + + for (const { id, size, position } of node.measuredPorts ?? []) { + map.set(id, { size, position }); + } + + return map; + } + + private hasPortChanged( + port: Pick, + measuredPortsMap: Map + ): boolean { + const measuredPort = measuredPortsMap.get(port.id); + + if (!measuredPort) { + return false; + } + + return !isSameRect(getRect(measuredPort), getRect({ size: port.size, position: port.position })); } } From a5006e2dd7db5a85ff1d7a4302bb606357af9fbd Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 18 Dec 2025 12:06:15 +0100 Subject: [PATCH 12/42] Optimize algorithm to find nearest nodes to eliminate lag during linking --- .../box-selection.handler.test.ts | 9 +++ .../src/core/src/spatial-hash/utils.test.ts | 58 +++++++++++++------ .../src/core/src/spatial-hash/utils.ts | 19 +++--- 3 files changed, 58 insertions(+), 28 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.test.ts index f1e9e6c05..a1de0c6eb 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.test.ts @@ -86,6 +86,10 @@ describe('BoxSelectionEventHandler', () => { getEdges: vi.fn(), }; + const mockModelLookup = { + getNodeById: vi.fn(), + }; + beforeEach(() => { vi.clearAllMocks(); @@ -105,10 +109,15 @@ describe('BoxSelectionEventHandler', () => { }, spatialHash: mockSpatialHash, model: mockModel, + modelLookup: mockModelLookup, clientToFlowPosition: vi.fn((point) => point), } as unknown as FlowCore; mockModel.getEdges.mockReturnValue([edge1, edge2, edge3]); + mockModelLookup.getNodeById.mockImplementation((id: string) => { + const nodes = [node1, node2, node3, node4, node5]; + return nodes.find((n) => n.id === id) ?? null; + }); instance = new BoxSelectionEventHandler(mockFlowCore); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/spatial-hash/utils.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/spatial-hash/utils.test.ts index 97d4a6bec..aa9d8c8b1 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/spatial-hash/utils.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/spatial-hash/utils.test.ts @@ -31,9 +31,7 @@ describe('SpatialHash utils', () => { describe('getNodesInRange', () => { it('should call queryIds with the correct parameters', () => { - mockGetState.mockReturnValue({ - nodes: [], - }); + mockQueryIds.mockReturnValue([]); getNodesInRange(flowCore, { x: 0, y: 0 }, 10); @@ -42,8 +40,11 @@ describe('SpatialHash utils', () => { it('should return nodes found by spatial hash and which exists in state', () => { const node = { ...mockNode, id: '1', size: { width: 100, height: 100 } }; - mockGetState.mockReturnValue({ - nodes: [node, { ...mockNode, id: '3', size: { width: 100, height: 100 } }], + const node3 = { ...mockNode, id: '3', size: { width: 100, height: 100 } }; + mockGetNodeById.mockImplementation((id: string) => { + if (id === '1') return node; + if (id === '3') return node3; + return null; }); mockQueryIds.mockReturnValue(['1', '2']); @@ -81,8 +82,11 @@ describe('SpatialHash utils', () => { const node2 = { ...mockNode, id: '2', position: { x: 10, y: 10 }, size: { width: 5, height: 5 } }; const node3 = { ...mockNode, id: '3', position: { x: 20, y: 20 }, size: { width: 5, height: 5 } }; - mockGetState.mockReturnValue({ - nodes: [node1, node2, node3], + mockGetNodeById.mockImplementation((id: string) => { + if (id === '1') return node1; + if (id === '2') return node2; + if (id === '3') return node3; + return null; }); mockQueryIds.mockReturnValue(['1', '2', '3']); @@ -130,8 +134,11 @@ describe('SpatialHash utils', () => { measuredPorts: [{ id: '3', position: { x: 0, y: 0 }, size: { width: 2, height: 2 } }], }; - mockGetState.mockReturnValue({ - nodes: [node1, node2, node3], + mockGetNodeById.mockImplementation((id: string) => { + if (id === '1') return node1; + if (id === '2') return node2; + if (id === '3') return node3; + return null; }); mockQueryIds.mockReturnValue(['1', '2', '3']); @@ -158,8 +165,11 @@ describe('SpatialHash utils', () => { const node2 = { ...mockNode, id: '2', position: { x: 20, y: 20 }, size: { width: 10, height: 10 } }; const node3 = { ...mockNode, id: '3', position: { x: 50, y: 50 }, size: { width: 10, height: 10 } }; - mockGetState.mockReturnValue({ - nodes: [node1, node2, node3], + mockGetNodeById.mockImplementation((id: string) => { + if (id === '1') return node1; + if (id === '2') return node2; + if (id === '3') return node3; + return null; }); mockQueryIds.mockReturnValue(['1', '2']); @@ -184,8 +194,11 @@ describe('SpatialHash utils', () => { const node2 = { ...mockNode, id: '2', position: { x: 15, y: 15 }, size: { width: 10, height: 10 } }; const node3 = { ...mockNode, id: '3', position: { x: 50, y: 50 }, size: { width: 10, height: 10 } }; - mockGetState.mockReturnValue({ - nodes: [node1, node2, node3], + mockGetNodeById.mockImplementation((id: string) => { + if (id === '1') return node1; + if (id === '2') return node2; + if (id === '3') return node3; + return null; }); mockQueryIds.mockReturnValue(['1', '2', '3']); @@ -198,8 +211,10 @@ describe('SpatialHash utils', () => { const node1 = { ...mockNode, id: '1', position: { x: 0, y: 0 }, size: { width: 10, height: 10 } }; const node2 = { ...mockNode, id: '2', position: { x: 15, y: 15 }, size: { width: 10, height: 10 } }; - mockGetState.mockReturnValue({ - nodes: [node1, node2], + mockGetNodeById.mockImplementation((id: string) => { + if (id === '1') return node1; + if (id === '2') return node2; + return null; }); mockQueryIds.mockReturnValue(['1', '2']); @@ -213,8 +228,11 @@ describe('SpatialHash utils', () => { const node2 = { ...mockNode, id: '2', position: { x: 25, y: 25 }, size: { width: 10, height: 10 } }; const node3 = { ...mockNode, id: '3', position: { x: 50, y: 50 }, size: { width: 10, height: 10 } }; - mockGetState.mockReturnValue({ - nodes: [node1, node2, node3], + mockGetNodeById.mockImplementation((id: string) => { + if (id === '1') return node1; + if (id === '2') return node2; + if (id === '3') return node3; + return null; }); mockQueryIds.mockReturnValue(['1', '2', '3']); @@ -227,8 +245,10 @@ describe('SpatialHash utils', () => { const node1 = { ...mockNode, id: '1', position: { x: 0, y: 0 }, size: { width: 20, height: 20 } }; const node2 = { ...mockNode, id: '2', position: { x: 15, y: 15 }, size: { width: 20, height: 20 } }; - mockGetState.mockReturnValue({ - nodes: [node1, node2], + mockGetNodeById.mockImplementation((id: string) => { + if (id === '1') return node1; + if (id === '2') return node2; + return null; }); mockQueryIds.mockReturnValue(['1', '2']); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/spatial-hash/utils.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/spatial-hash/utils.ts index 31fa4a2c0..7fc0de2f8 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/spatial-hash/utils.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/spatial-hash/utils.ts @@ -6,13 +6,14 @@ import { checkCollision } from './collision-detection'; export const getNodesInRange = (flowCore: FlowCore, point: Point, range: number): Node[] => { const rangeRect = getPointRangeRect(point, range); - const foundNodesIds = new Set(flowCore.spatialHash.queryIds(rangeRect)); + const candidateIds = flowCore.spatialHash.queryIds(rangeRect); const foundNodes: Node[] = []; - flowCore.getState().nodes.forEach((node) => { - if (foundNodesIds.has(node.id) && checkCollision(rangeRect, node)) { + for (const nodeId of candidateIds) { + const node = flowCore.modelLookup.getNodeById(nodeId); + if (node && checkCollision(rangeRect, node)) { foundNodes.push(node); } - }); + } return foundNodes; }; @@ -56,15 +57,15 @@ export const getNearestPortInRange = (flowCore: FlowCore, point: Point, range: n }; export const getNodesInRect = (flowCore: FlowCore, rect: Rect, partialInclusion = true): Node[] => { - const foundNodesIds = new Set(flowCore.spatialHash.queryIds(rect)); + const candidateIds = flowCore.spatialHash.queryIds(rect); const foundNodes: Node[] = []; - const nodes = flowCore.getState().nodes; - nodes.forEach((node) => { - if (foundNodesIds.has(node.id) && checkCollision(rect, node)) { + for (const nodeId of candidateIds) { + const node = flowCore.modelLookup.getNodeById(nodeId); + if (node && checkCollision(rect, node)) { foundNodes.push(node); } - }); + } if (partialInclusion) { return foundNodes; From 40f8806db57b0990d6795e8f073b9d71c692897c Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 18 Dec 2025 13:01:00 +0100 Subject: [PATCH 13/42] Move render method to the dedicated base class --- .../ng-diagram/src/core/src/flow-core.ts | 21 +++--------------- .../render-strategy/base-render-strategy.ts | 22 +++++++++++++++++++ .../render-strategy/direct-render-strategy.ts | 13 ++++++----- .../src/core/src/render-strategy/index.ts | 1 + .../virtualized-render-strategy.ts | 16 ++++++++------ 5 files changed, 43 insertions(+), 30 deletions(-) create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts index 572645142..4bee2026f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts @@ -78,9 +78,9 @@ export class FlowCore { constructor( modelAdapter: ModelAdapter, - private readonly renderer: Renderer, - public readonly inputEventsRouter: InputEventsRouter, - public readonly environment: EnvironmentInfo, + readonly renderer: Renderer, + readonly inputEventsRouter: InputEventsRouter, + readonly environment: EnvironmentInfo, middlewares?: MiddlewareChain, getFlowOffset?: () => Point, config: DeepPartial = {} @@ -369,21 +369,6 @@ export class FlowCore { }; } - /** - * Renders the flow by applying the render strategy and drawing visible elements. - */ - render(): void { - const { nodes, edges, metadata } = this.getState(); - const temporaryEdge = this.actionStateManager.linking?.temporaryEdge; - - // Apply render strategy (virtualization or direct) - const { nodes: visibleNodes, edges: visibleEdges } = this.renderStrategy.process(nodes, edges, metadata.viewport); - - const finalEdges = temporaryEdge?.temporary ? [...visibleEdges, temporaryEdge] : visibleEdges; - - this.renderer.draw(visibleNodes, finalEdges, metadata.viewport); - } - /** * Gets a node by id * @param nodeId Node id diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts new file mode 100644 index 000000000..d562dfa85 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts @@ -0,0 +1,22 @@ +import type { FlowCore } from '../flow-core'; +import type { Edge, Node, Viewport } from '../types'; +import type { RenderStrategy, RenderStrategyResult } from './render-strategy.interface'; + +export abstract class BaseRenderStrategy implements RenderStrategy { + constructor(protected readonly flowCore: FlowCore) {} + + abstract init(): void; + + abstract process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult; + + protected render(): void { + const { nodes, edges, metadata } = this.flowCore.getState(); + const temporaryEdge = this.flowCore.actionStateManager.linking?.temporaryEdge; + + const { nodes: visibleNodes, edges: visibleEdges } = this.process(nodes, edges, metadata.viewport); + + const finalEdges = temporaryEdge?.temporary ? [...visibleEdges, temporaryEdge] : visibleEdges; + + this.flowCore.renderer.draw(visibleNodes, finalEdges, metadata.viewport); + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts index f4ca5dd25..15b195865 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts @@ -1,6 +1,7 @@ import type { FlowCore } from '../flow-core'; import type { Edge, Node } from '../types'; -import type { RenderStrategy, RenderStrategyResult } from './render-strategy.interface'; +import { BaseRenderStrategy } from './base-render-strategy'; +import type { RenderStrategyResult } from './render-strategy.interface'; // Reusable empty set for direct rendering (avoids allocation on every call) const EMPTY_SET = new Set(); @@ -9,16 +10,18 @@ const EMPTY_SET = new Set(); * Direct render strategy - returns all nodes and edges without virtualization. * Used when virtualization is disabled. */ -export class DirectRenderStrategy implements RenderStrategy { - constructor(private readonly flowCore: FlowCore) {} +export class DirectRenderStrategy extends BaseRenderStrategy { + constructor(flowCore: FlowCore) { + super(flowCore); + } init(): void { - this.flowCore.render(); + this.render(); this.flowCore.model.onChange((state) => { this.flowCore.spatialHash.process(state.nodes); this.flowCore.modelLookup.desynchronize(); - this.flowCore.render(); + this.render(); }); const nodes = this.flowCore.model.getNodes(); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts index 7aea953d4..fdcde6513 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts @@ -1,3 +1,4 @@ export * from './render-strategy.interface'; +export * from './base-render-strategy'; export * from './direct-render-strategy'; export * from './virtualized-render-strategy'; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts index 39266dfd2..214d0bbe3 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts @@ -1,8 +1,9 @@ import type { EventManager } from '../event-manager/event-manager'; -import { FlowCore } from '../flow-core'; +import type { FlowCore } from '../flow-core'; import type { Edge, Node, Rect, Viewport, VirtualizationConfig } from '../types'; import { isGroup } from '../utils'; -import type { RenderStrategy, RenderStrategyResult } from './render-strategy.interface'; +import { BaseRenderStrategy } from './base-render-strategy'; +import type { RenderStrategyResult } from './render-strategy.interface'; const DEFAULT_VIEWPORT_WIDTH = 1920; const DEFAULT_VIEWPORT_HEIGHT = 1080; @@ -27,7 +28,7 @@ const EMPTY_SET = new Set(); * - During active panning/zooming: uses cached results for performance * - After idle period: renders with expanded buffer to preload more nodes */ -export class VirtualizedRenderStrategy implements RenderStrategy { +export class VirtualizedRenderStrategy extends BaseRenderStrategy { private lastViewportRect: Rect | null = null; private lastNodesLength = 0; private lastEdgesLength = 0; @@ -51,7 +52,8 @@ export class VirtualizedRenderStrategy implements RenderStrategy { // Track nodes reference for optimization (skip spatialHash update during panning/zooming) private lastNodesRef: Node[] | null = null; - constructor(private readonly flowCore: FlowCore) { + constructor(flowCore: FlowCore) { + super(flowCore); this.subscribeToActionState(flowCore.eventManager); } @@ -65,11 +67,11 @@ export class VirtualizedRenderStrategy implements RenderStrategy { this.flowCore.modelLookup.desynchronize(); this.lastNodesRef = state.nodes; } - this.flowCore.render(); + this.render(); }); // Trigger initial render to ensure consistent visible nodes - this.flowCore.render(); + this.render(); const { nodes, edges, metadata } = this.flowCore.getState(); const result = this.process(nodes, edges, metadata.viewport); @@ -214,7 +216,7 @@ export class VirtualizedRenderStrategy implements RenderStrategy { // Invalidate cache to force recomputation this.lastViewportRect = null; this.pendingExpandedBuffer = true; - this.flowCore.render(); + this.render(); } private buildResultFromCachedIds(nodes: Node[], edges: Edge[]): RenderStrategyResult { From 57f6a6091a25b42a1a3680566dbb4c92585c7564 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Fri, 19 Dec 2025 18:03:02 +0100 Subject: [PATCH 14/42] Refactor internal updater --- .../ng-diagram/src/core/src/flow-core.test.ts | 7 +- .../emitters/diagram-init.emitter.ts | 41 +++++----- .../render-strategy/direct-render-strategy.ts | 5 +- .../direct-port-update-strategy.ts | 33 ++++++++ .../internal-updater/internal-updater.ts | 75 +++---------------- .../port-update-strategy.interface.ts | 24 ++++++ .../virtualized-port-update-strategy.ts | 39 ++++++++++ 7 files changed, 134 insertions(+), 90 deletions(-) create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/direct-port-update-strategy.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/port-update-strategy.interface.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/virtualized-port-update-strategy.ts diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts index b9d6dcb67..60594e143 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts @@ -155,8 +155,11 @@ describe('FlowCore', () => { await new Promise((resolve) => setTimeout(resolve, 10)); // Now init command should have been emitted - // When virtualization is disabled, init is called with empty object (all nodes/edges rendered) - expect(mockCommandHandler.emit).toHaveBeenCalledWith('init', {}); + // Both strategies provide explicit renderedNodeIds/renderedEdgeIds (empty arrays when no nodes/edges) + expect(mockCommandHandler.emit).toHaveBeenCalledWith('init', { + renderedNodeIds: [], + renderedEdgeIds: [], + }); }); it('should initialize with default getFlowOffset when not provided', () => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/diagram-init.emitter.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/diagram-init.emitter.ts index 2ed4f2671..55612673d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/diagram-init.emitter.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/diagram-init.emitter.ts @@ -38,9 +38,13 @@ export class DiagramInitEmitter implements EventEmitter { const { nodesMap, edgesMap, initialUpdate } = context; - // With virtualization, only track rendered nodes/edges for measurement - // Non-rendered nodes won't be measured until they enter the viewport - this.collectUnmeasuredItems(nodesMap, edgesMap, initialUpdate.renderedNodeIds, initialUpdate.renderedEdgeIds); + // Strategies provide explicit IDs; fallback to all nodes/edges for safety + this.collectUnmeasuredItems( + nodesMap, + edgesMap, + initialUpdate.renderedNodeIds ?? Array.from(nodesMap.keys()), + initialUpdate.renderedEdgeIds ?? Array.from(edgesMap.keys()) + ); if (this.areAllMeasured()) { this.emitInitEvent(context, eventManager); @@ -79,18 +83,14 @@ export class DiagramInitEmitter implements EventEmitter { private collectUnmeasuredItems( nodesMap: Map, edgesMap: Map, - renderedNodeIds?: string[], - renderedEdgeIds?: string[] + renderedNodeIds: string[], + renderedEdgeIds: string[] ): void { this.unmeasuredNodes.clear(); this.unmeasuredNodePorts.clear(); this.unmeasuredEdgeLabels.clear(); - // If renderedNodeIds provided (virtualization), only track those nodes - // Otherwise track all nodes (no virtualization or fallback) - const nodeIdsToTrack = renderedNodeIds ?? Array.from(nodesMap.keys()); - - for (const nodeId of nodeIdsToTrack) { + for (const nodeId of renderedNodeIds) { const node = nodesMap.get(nodeId); if (!node) continue; @@ -105,11 +105,7 @@ export class DiagramInitEmitter implements EventEmitter { } } - // If renderedEdgeIds provided (virtualization), only track those edges - // Otherwise track all edges (no virtualization or fallback) - const edgeIdsToTrack = renderedEdgeIds ?? Array.from(edgesMap.keys()); - - for (const edgeId of edgeIdsToTrack) { + for (const edgeId of renderedEdgeIds) { const edge = edgesMap.get(edgeId); if (!edge) continue; @@ -177,15 +173,14 @@ export class DiagramInitEmitter implements EventEmitter { this.clearSafetyHatchTimeout(); const { nodesMap, edgesMap, initialUpdate } = context; + + // Strategies provide explicit IDs; fallback to all nodes/edges for safety + const renderedNodeIds = new Set(initialUpdate.renderedNodeIds ?? nodesMap.keys()); + const renderedEdgeIds = new Set(initialUpdate.renderedEdgeIds ?? edgesMap.keys()); + const event: DiagramInitEvent = { - // undefined means all nodes/edges are rendered (DirectRenderStrategy) - // string[] means only those specific IDs are rendered (VirtualizedRenderStrategy) - nodes: initialUpdate.renderedNodeIds - ? Array.from(nodesMap.values()).filter((x) => initialUpdate.renderedNodeIds!.includes(x.id)) - : Array.from(nodesMap.values()), - edges: initialUpdate.renderedEdgeIds - ? Array.from(edgesMap.values()).filter((x) => initialUpdate.renderedEdgeIds!.includes(x.id)) - : Array.from(edgesMap.values()), + nodes: Array.from(nodesMap.values()).filter((x) => renderedNodeIds.has(x.id)), + edges: Array.from(edgesMap.values()).filter((x) => renderedEdgeIds.has(x.id)), viewport: context.state.metadata.viewport, }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts index 15b195865..aded5a5db 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts @@ -27,7 +27,10 @@ export class DirectRenderStrategy extends BaseRenderStrategy { const nodes = this.flowCore.model.getNodes(); const edges = this.flowCore.model.getEdges(); this.flowCore.initUpdater.start(nodes, edges, async () => { - await this.flowCore.commandHandler.emit('init', {}); + await this.flowCore.commandHandler.emit('init', { + renderedNodeIds: nodes.map((n) => n.id), + renderedEdgeIds: edges.map((e) => e.id), + }); }); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/direct-port-update-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/direct-port-update-strategy.ts new file mode 100644 index 000000000..3018a9dea --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/direct-port-update-strategy.ts @@ -0,0 +1,33 @@ +import type { FlowCore } from '../../flow-core'; +import type { Port } from '../../types'; +import type { PortUpdateStrategy } from './port-update-strategy.interface'; + +/** + * Direct port update strategy - uses per-node batching. + * Used when virtualization is disabled. + */ +export class DirectPortUpdateStrategy implements PortUpdateStrategy { + constructor(private readonly flowCore: FlowCore) {} + + addPort(nodeId: string, port: Port): void { + this.flowCore.portBatchProcessor.processAdd(nodeId, port, (nodeId, ports) => { + this.flowCore.commandHandler.emit('addPorts', { nodeId, ports }); + }); + } + + deletePort(nodeId: string, portId: string): void { + this.flowCore.commandHandler.emit('deletePorts', { nodeId, portIds: [portId] }); + } + + updatePorts(nodeId: string, ports: Pick[]): void { + for (const { id, size, position } of ports) { + this.flowCore.portBatchProcessor.processUpdate( + nodeId, + { portId: id, portChanges: { size, position } }, + (nodeId, portUpdates) => { + this.flowCore.commandHandler.emit('updatePorts', { nodeId, ports: portUpdates }); + } + ); + } + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts index 1eb127a8c..fe1873548 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts @@ -3,12 +3,17 @@ import type { Node, Port } from '../../types'; import { EdgeLabel } from '../../types'; import { getRect, isSameRect } from '../../utils'; import { Updater } from '../updater.interface'; +import { DirectPortUpdateStrategy } from './direct-port-update-strategy'; +import type { PortUpdateStrategy } from './port-update-strategy.interface'; +import { VirtualizedPortUpdateStrategy } from './virtualized-port-update-strategy'; export class InternalUpdater implements Updater { - constructor(private readonly flowCore: FlowCore) {} + private readonly portUpdateStrategy: PortUpdateStrategy; - private get isVirtualizationEnabled(): boolean { - return this.flowCore.config.virtualization.enabled; + constructor(private readonly flowCore: FlowCore) { + this.portUpdateStrategy = this.flowCore.config.virtualization.enabled + ? new VirtualizedPortUpdateStrategy(flowCore) + : new DirectPortUpdateStrategy(flowCore); } /** @@ -38,33 +43,7 @@ export class InternalUpdater implements Updater { * Internal method to add a new port to the flow */ addPort(nodeId: string, port: Port): void { - if (this.isVirtualizationEnabled) { - this.addPortVirtualized(nodeId, port); - } else { - this.addPortStandard(nodeId, port); - } - } - - private addPortVirtualized(nodeId: string, port: Port): void { - if (this.isPortAlreadyMeasured(nodeId, port.id)) { - return; - } - - this.flowCore.portBatchProcessor.processAddBatched(nodeId, port, (allAdditions) => { - this.flowCore.commandHandler.emit('addPortsBulk', { additions: allAdditions }); - }); - } - - private addPortStandard(nodeId: string, port: Port): void { - this.flowCore.portBatchProcessor.processAdd(nodeId, port, (nodeId, ports) => { - this.flowCore.commandHandler.emit('addPorts', { nodeId, ports }); - }); - } - - private isPortAlreadyMeasured(nodeId: string, portId: string): boolean { - const node = this.flowCore.getNodeById(nodeId); - const existingPort = node?.measuredPorts?.find((p) => p.id === portId); - return !!(existingPort?.size && existingPort?.position); + this.portUpdateStrategy.addPort(nodeId, port); } /** @@ -73,11 +52,7 @@ export class InternalUpdater implements Updater { * In virtualization mode, ports persist in model (only DOM unmounts). */ deletePort(nodeId: string, portId: string): void { - if (this.isVirtualizationEnabled) { - return; - } - - this.flowCore.commandHandler.emit('deletePorts', { nodeId, portIds: [portId] }); + this.portUpdateStrategy.deletePort?.(nodeId, portId); } /** @@ -95,35 +70,7 @@ export class InternalUpdater implements Updater { return; } - if (this.isVirtualizationEnabled) { - this.updatePortsVirtualized(nodeId, portsToUpdate); - } else { - this.updatePortsStandard(nodeId, portsToUpdate); - } - } - - private updatePortsVirtualized(nodeId: string, ports: Pick[]): void { - for (const { id, size, position } of ports) { - this.flowCore.portBatchProcessor.processUpdateBatched( - nodeId, - { portId: id, portChanges: { size, position } }, - (allUpdates) => { - this.flowCore.commandHandler.emit('updatePortsBulk', { updates: allUpdates }); - } - ); - } - } - - private updatePortsStandard(nodeId: string, ports: Pick[]): void { - for (const { id, size, position } of ports) { - this.flowCore.portBatchProcessor.processUpdate( - nodeId, - { portId: id, portChanges: { size, position } }, - (nodeId, portUpdates) => { - this.flowCore.commandHandler.emit('updatePorts', { nodeId, ports: portUpdates }); - } - ); - } + this.portUpdateStrategy.updatePorts(nodeId, portsToUpdate); } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/port-update-strategy.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/port-update-strategy.interface.ts new file mode 100644 index 000000000..7e2dc62ee --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/port-update-strategy.interface.ts @@ -0,0 +1,24 @@ +import type { Port } from '../../types'; + +/** + * Strategy interface for port update operations. + * Abstracts the differences between direct and virtualized rendering modes. + */ +export interface PortUpdateStrategy { + /** + * Add a port to a node + */ + addPort(nodeId: string, port: Port): void; + + /** + * Delete a port from a node. + * Optional because virtualization mode doesn't delete ports on DOM unmount - + * ports persist in the model and are restored when the node scrolls back into view. + */ + deletePort?(nodeId: string, portId: string): void; + + /** + * Update port sizes and positions + */ + updatePorts(nodeId: string, ports: Pick[]): void; +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/virtualized-port-update-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/virtualized-port-update-strategy.ts new file mode 100644 index 000000000..5aa4e44ca --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/virtualized-port-update-strategy.ts @@ -0,0 +1,39 @@ +import type { FlowCore } from '../../flow-core'; +import type { Port } from '../../types'; +import type { PortUpdateStrategy } from './port-update-strategy.interface'; + +/** + * Virtualized port update strategy - uses global batching. + * Used when virtualization is enabled for better performance with many nodes. + */ +export class VirtualizedPortUpdateStrategy implements PortUpdateStrategy { + constructor(private readonly flowCore: FlowCore) {} + + addPort(nodeId: string, port: Port): void { + if (this.isPortAlreadyMeasured(nodeId, port.id)) { + return; + } + + this.flowCore.portBatchProcessor.processAddBatched(nodeId, port, (allAdditions) => { + this.flowCore.commandHandler.emit('addPortsBulk', { additions: allAdditions }); + }); + } + + updatePorts(nodeId: string, ports: Pick[]): void { + for (const { id, size, position } of ports) { + this.flowCore.portBatchProcessor.processUpdateBatched( + nodeId, + { portId: id, portChanges: { size, position } }, + (allUpdates) => { + this.flowCore.commandHandler.emit('updatePortsBulk', { updates: allUpdates }); + } + ); + } + } + + private isPortAlreadyMeasured(nodeId: string, portId: string): boolean { + const node = this.flowCore.getNodeById(nodeId); + const existingPort = node?.measuredPorts?.find((p) => p.id === portId); + return !!(existingPort?.size && existingPort?.position); + } +} From 7202ed9ba12ba18669013c6b242cf4c6ec2493db Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Wed, 7 Jan 2026 13:28:38 +0100 Subject: [PATCH 15/42] Refactor --- .../commands/add-update-delete.ts | 74 ++-- .../middleware-manager/middleware-executor.ts | 2 - .../direct-render-strategy.test.ts | 6 +- .../{ => direct}/direct-render-strategy.ts | 8 +- .../src/core/src/render-strategy/index.ts | 6 +- .../virtualized-render-strategy.ts | 411 ------------------ .../virtualized/idle-manager.ts | 124 ++++++ .../virtualized/result-cache.ts | 73 ++++ .../virtualized/viewport-utils.ts | 77 ++++ .../virtualized-render-strategy.test.ts | 8 +- .../virtualized-render-strategy.ts | 121 ++++++ .../virtualized/visible-elements-resolver.ts | 109 +++++ 12 files changed, 549 insertions(+), 470 deletions(-) rename packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/{ => direct}/direct-render-strategy.test.ts (92%) rename packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/{ => direct}/direct-render-strategy.ts (82%) delete mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/idle-manager.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/result-cache.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts rename packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/{ => virtualized}/virtualized-render-strategy.test.ts (98%) create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts index d65ab301c..ab3403ebc 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts @@ -1,5 +1,29 @@ import type { CommandHandler, Edge, EdgeLabel, Node, Port } from '../../types'; +const computeAddedPorts = (node: Node, ports: Port[]): Port[] => { + const newPortIds = new Set(ports.map((port) => port.id)); + const existingPortsToKeep = (node.measuredPorts ?? []).filter((port) => !newPortIds.has(port.id)); + return [...existingPortsToKeep, ...ports]; +}; + +const computeUpdatedPorts = ( + measuredPorts: Port[], + portUpdates: { portId: string; portChanges: Partial }[] +): Port[] => { + return measuredPorts.map((port) => { + const portChanges = portUpdates.find(({ portId }) => portId === port.id)?.portChanges; + if (!portChanges) { + return port; + } + return { ...port, ...portChanges }; + }); +}; + +const computeRemainingPorts = (measuredPorts: Port[] | undefined, portIds: string[]): Port[] => { + const portIdsSet = new Set(portIds); + return (measuredPorts ?? []).filter((port) => !portIdsSet.has(port.id)); +}; + export interface AddNodesCommand { name: 'addNodes'; nodes: Node[]; @@ -137,9 +161,7 @@ export const addPorts = async (commandHandler: CommandHandler, command: AddPorts // Even though we have a separate method to update ports, this method also updates existing ports with matching IDs // instead of skipping them. This ensures the adapter stays synchronized with the core. // The front-end is considered the source of truth in this context. - const newPortIds = new Set(ports.map((port) => port.id)); - const existingPortsToKeep = (node.measuredPorts ?? []).filter((port) => !newPortIds.has(port.id)); - const newPorts = [...existingPortsToKeep, ...ports]; + const newPorts = computeAddedPorts(node, ports); await commandHandler.flowCore.applyUpdate({ nodesToUpdate: [{ id: nodeId, measuredPorts: newPorts }] }, 'updateNode'); }; @@ -163,19 +185,13 @@ export const addPortsBulk = async (commandHandler: CommandHandler, command: AddP return; } - // Same logic as addPorts - update existing ports with matching IDs - const newPortIds = new Set(ports.map((port) => port.id)); - const existingPortsToKeep = (node.measuredPorts ?? []).filter((port) => !newPortIds.has(port.id)); - const newPorts = [...existingPortsToKeep, ...ports]; - - nodesToUpdate.push({ id: nodeId, measuredPorts: newPorts }); + nodesToUpdate.push({ id: nodeId, measuredPorts: computeAddedPorts(node, ports) }); }); if (nodesToUpdate.length === 0) { return; } - // Single middleware execution for all nodes await commandHandler.flowCore.applyUpdate({ nodesToUpdate }, 'addPortsBulk'); }; @@ -188,22 +204,10 @@ export interface UpdatePortsCommand { export const updatePorts = async (commandHandler: CommandHandler, command: UpdatePortsCommand) => { const { nodeId, ports } = command; const node = commandHandler.flowCore.getNodeById(nodeId); - if (!node) { - return; - } - const portsToUpdate = node.measuredPorts?.map((port) => { - const portChanges = ports.find(({ portId }) => portId === port.id)?.portChanges; - if (!portChanges) { - return port; - } - return { - ...port, - ...portChanges, - }; - }); - if (!portsToUpdate) { + if (!node || !node.measuredPorts) { return; } + const portsToUpdate = computeUpdatedPorts(node.measuredPorts, ports); await commandHandler.flowCore.applyUpdate( { nodesToUpdate: [{ id: nodeId, measuredPorts: portsToUpdate }] }, 'updateNode' @@ -229,25 +233,13 @@ export const updatePortsBulk = async (commandHandler: CommandHandler, command: U return; } - const updatedPorts = node.measuredPorts.map((port) => { - const portChanges = portUpdates.find(({ portId }) => portId === port.id)?.portChanges; - if (!portChanges) { - return port; - } - return { - ...port, - ...portChanges, - }; - }); - - nodesToUpdate.push({ id: nodeId, measuredPorts: updatedPorts }); + nodesToUpdate.push({ id: nodeId, measuredPorts: computeUpdatedPorts(node.measuredPorts, portUpdates) }); }); if (nodesToUpdate.length === 0) { return; } - // Single middleware execution for all nodes await commandHandler.flowCore.applyUpdate({ nodesToUpdate }, 'updatePortsBulk'); }; @@ -263,7 +255,7 @@ export const deletePorts = async (commandHandler: CommandHandler, command: Delet if (!node) { return; } - const leftPorts = node.measuredPorts?.filter((port) => !portIds.includes(port.id)); + const leftPorts = computeRemainingPorts(node.measuredPorts, portIds); await commandHandler.flowCore.applyUpdate( { nodesToUpdate: [{ id: nodeId, measuredPorts: leftPorts }] }, 'updateNode' @@ -289,17 +281,13 @@ export const deletePortsBulk = async (commandHandler: CommandHandler, command: D return; } - const portIdsSet = new Set(portIds); - const remainingPorts = (node.measuredPorts ?? []).filter((port) => !portIdsSet.has(port.id)); - - nodesToUpdate.push({ id: nodeId, measuredPorts: remainingPorts }); + nodesToUpdate.push({ id: nodeId, measuredPorts: computeRemainingPorts(node.measuredPorts, portIds) }); }); if (nodesToUpdate.length === 0) { return; } - // Single middleware execution for all nodes await commandHandler.flowCore.applyUpdate({ nodesToUpdate }, 'deletePortsBulk'); }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts index d808c8a22..63e482a0b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts @@ -54,13 +54,11 @@ export class MiddlewareExecutor { const isMetadataOnly = this.isMetadataOnlyUpdate(stateUpdate); if (isMetadataOnly) { - // Use direct references (read-only) - no copy needed this.nodesMap = this.flowCore.modelLookup.nodesMap; this.edgesMap = this.flowCore.modelLookup.edgesMap; this.initialNodesMap = this.nodesMap; this.initialEdgesMap = this.edgesMap; } else { - // Full copy for node/edge changes this.nodesMap = new Map(this.flowCore.modelLookup.nodesMap); this.edgesMap = new Map(this.flowCore.modelLookup.edgesMap); this.initialNodesMap = new Map(this.flowCore.modelLookup.nodesMap); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.test.ts similarity index 92% rename from packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.test.ts rename to packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.test.ts index 497a08c62..8104af1df 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { FlowCore } from '../flow-core'; -import { mockEdge, mockNode } from '../test-utils'; -import type { Edge, Node } from '../types'; +import type { FlowCore } from '../../flow-core'; +import { mockEdge, mockNode } from '../../test-utils'; +import type { Edge, Node } from '../../types'; import { DirectRenderStrategy } from './direct-render-strategy'; describe('DirectRenderStrategy', () => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts similarity index 82% rename from packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts rename to packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts index aded5a5db..fe6bc6f09 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts @@ -1,7 +1,7 @@ -import type { FlowCore } from '../flow-core'; -import type { Edge, Node } from '../types'; -import { BaseRenderStrategy } from './base-render-strategy'; -import type { RenderStrategyResult } from './render-strategy.interface'; +import type { FlowCore } from '../../flow-core'; +import type { Edge, Node } from '../../types'; +import { BaseRenderStrategy } from '../base-render-strategy'; +import type { RenderStrategyResult } from '../render-strategy.interface'; // Reusable empty set for direct rendering (avoids allocation on every call) const EMPTY_SET = new Set(); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts index fdcde6513..28c57817a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/index.ts @@ -1,4 +1,4 @@ -export * from './render-strategy.interface'; export * from './base-render-strategy'; -export * from './direct-render-strategy'; -export * from './virtualized-render-strategy'; +export * from './direct/direct-render-strategy'; +export * from './render-strategy.interface'; +export * from './virtualized/virtualized-render-strategy'; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts deleted file mode 100644 index 214d0bbe3..000000000 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.ts +++ /dev/null @@ -1,411 +0,0 @@ -import type { EventManager } from '../event-manager/event-manager'; -import type { FlowCore } from '../flow-core'; -import type { Edge, Node, Rect, Viewport, VirtualizationConfig } from '../types'; -import { isGroup } from '../utils'; -import { BaseRenderStrategy } from './base-render-strategy'; -import type { RenderStrategyResult } from './render-strategy.interface'; - -const DEFAULT_VIEWPORT_WIDTH = 1920; -const DEFAULT_VIEWPORT_HEIGHT = 1080; - -// Percentage of viewport dimensions that triggers recomputation -const RECOMPUTE_THRESHOLD = 0.25; - -// Delay before recomputing after zoom stops (ms) -const ZOOM_IDLE_DELAY = 150; - -// Distance threshold (as fraction of viewport) that triggers recompute during panning -const PAN_DISTANCE_THRESHOLD = 0.5; - -// Reusable empty set for bypass results (avoids allocation on every call) -const EMPTY_SET = new Set(); - -/** - * Virtualized render strategy - returns only nodes and edges visible in the viewport. - * Used when virtualization is enabled for large diagrams. - * - * Handles idle management for both panning and zooming: - * - During active panning/zooming: uses cached results for performance - * - After idle period: renders with expanded buffer to preload more nodes - */ -export class VirtualizedRenderStrategy extends BaseRenderStrategy { - private lastViewportRect: Rect | null = null; - private lastNodesLength = 0; - private lastEdgesLength = 0; - // Cache only IDs, not objects - objects may be updated (e.g., edge routing adds positions) - private cachedNodeIds: Set | null = null; - private cachedEdgeIds: Set | null = null; - - // Zoom tracking for deferred recomputation - private lastScale: number | null = null; - private zoomIdleTimeout: ReturnType | null = null; - private isZooming = false; - - // Pan idle tracking - private panIdleTimeout: ReturnType | null = null; - private wasPanning = false; - private unsubscribeActionState: (() => void) | null = null; - - // Flag to use expanded buffer on next process() call - private pendingExpandedBuffer = false; - - // Track nodes reference for optimization (skip spatialHash update during panning/zooming) - private lastNodesRef: Node[] | null = null; - - constructor(flowCore: FlowCore) { - super(flowCore); - this.subscribeToActionState(flowCore.eventManager); - } - - init(): void { - this.flowCore.spatialHash.process(this.flowCore.model.getNodes()); - - this.flowCore.model.onChange((state) => { - // Optimization: skip spatialHash update during panning/zooming (nodes reference stays the same) - if (state.nodes !== this.lastNodesRef) { - this.flowCore.spatialHash.process(state.nodes); - this.flowCore.modelLookup.desynchronize(); - this.lastNodesRef = state.nodes; - } - this.render(); - }); - - // Trigger initial render to ensure consistent visible nodes - this.render(); - - const { nodes, edges, metadata } = this.flowCore.getState(); - const result = this.process(nodes, edges, metadata.viewport); - this.flowCore.initUpdater.start(result.nodes, result.edges, async () => { - await this.flowCore.commandHandler.emit('init', { - renderedNodeIds: result.nodes.map((n) => n.id), - renderedEdgeIds: result.edges.map((e) => e.id), - }); - }); - } - - /** - * Subscribes to actionStateChanged to detect pan start/end. - */ - private subscribeToActionState(eventManager: EventManager): void { - this.unsubscribeActionState = eventManager.on('actionStateChanged', ({ actionState }) => { - const isPanning = !!actionState.panning?.active; - - if (this.wasPanning && !isPanning) { - // Panning ended - schedule idle fill - this.schedulePanIdle(); - } else if (!this.wasPanning && isPanning) { - // Panning started - cancel any pending fill - this.cancelPanIdle(); - } - - this.wasPanning = isPanning; - }); - } - - process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult { - const config = this.flowCore.config.virtualization; - - if (this.shouldBypass(nodes, viewport, config)) { - return { nodes, edges, nodeIds: EMPTY_SET, edgeIds: EMPTY_SET }; - } - - // Check if we should use expanded buffer (set by idle callbacks) - const useExpandedBuffer = this.pendingExpandedBuffer; - if (useExpandedBuffer) { - this.pendingExpandedBuffer = false; - } - - // Detect zoom changes and defer recomputation during active zooming - const currentScale = viewport!.scale; - const scaleChanged = this.lastScale !== null && this.lastScale !== currentScale; - - if (scaleChanged) { - this.isZooming = true; - this.scheduleZoomIdle(); - } - - this.lastScale = currentScale; - - const paddingMultiplier = useExpandedBuffer ? config.expandedPadding : config.padding; - const viewportRect = this.getViewportRect(viewport!, paddingMultiplier); - - // During active zooming or panning, use cached result to avoid lag - const isPanning = this.flowCore.actionStateManager.isPanning(); - const hasCache = this.cachedNodeIds && this.cachedEdgeIds; - - // Skip caching when using expanded buffer - we want fresh computation - if (!useExpandedBuffer) { - if (this.isZooming && hasCache) { - return this.buildResultFromCachedIds(nodes, edges); - } - - if (isPanning && hasCache) { - // During panning, use cache unless we've moved too far from last recompute - if (this.lastViewportRect && !this.hasMovedTooFar(viewportRect, this.lastViewportRect)) { - return this.buildResultFromCachedIds(nodes, edges); - } - // Moved too far - fall through to recompute - } - - if (this.canUseCachedResult(nodes.length, edges.length, viewportRect)) { - // Use cached IDs but look up fresh objects from input arrays - return this.buildResultFromCachedIds(nodes, edges); - } - } - - const result = this.computeVisibleElements(viewportRect); - - // Cache the IDs directly from the result (already computed, no extra allocation) - this.cachedNodeIds = result.nodeIds; - this.cachedEdgeIds = result.edgeIds; - this.lastViewportRect = viewportRect; - this.lastNodesLength = nodes.length; - this.lastEdgesLength = edges.length; - - return result; - } - - /** - * Schedules a callback to mark zooming as complete after idle period. - * Resets the timer on each call to debounce rapid zoom events. - */ - private scheduleZoomIdle(): void { - if (this.zoomIdleTimeout) { - clearTimeout(this.zoomIdleTimeout); - } - - this.zoomIdleTimeout = setTimeout(() => { - this.isZooming = false; - this.zoomIdleTimeout = null; - this.triggerExpandedBufferRender(); - }, ZOOM_IDLE_DELAY); - } - - /** - * Schedules pan idle fill after panning ends. - * Uses the configured idle threshold from virtualization config. - */ - private schedulePanIdle(): void { - if (!this.flowCore.config.virtualization.bufferFill.enabled) { - return; - } - - this.cancelPanIdle(); - const idleThreshold = this.flowCore.config.virtualization.bufferFill.idleThreshold; - - this.panIdleTimeout = setTimeout(() => { - this.panIdleTimeout = null; - this.triggerExpandedBufferRender(); - }, idleThreshold); - } - - /** - * Cancels any pending pan idle fill. - */ - private cancelPanIdle(): void { - if (this.panIdleTimeout !== null) { - clearTimeout(this.panIdleTimeout); - this.panIdleTimeout = null; - } - } - - /** - * Triggers a render with expanded buffer by setting the flag and requesting render. - */ - private triggerExpandedBufferRender(): void { - // Invalidate cache to force recomputation - this.lastViewportRect = null; - this.pendingExpandedBuffer = true; - this.render(); - } - - private buildResultFromCachedIds(nodes: Node[], edges: Edge[]): RenderStrategyResult { - const filteredNodes = nodes.filter((n) => this.cachedNodeIds!.has(n.id)); - const filteredEdges = edges.filter((e) => this.cachedEdgeIds!.has(e.id)); - // Reuse cached Sets - no new allocation - return { nodes: filteredNodes, edges: filteredEdges, nodeIds: this.cachedNodeIds!, edgeIds: this.cachedEdgeIds! }; - } - - invalidateCache(): void { - this.cachedNodeIds = null; - this.cachedEdgeIds = null; - this.lastViewportRect = null; - this.lastNodesLength = 0; - this.lastEdgesLength = 0; - } - - destroy(): void { - if (this.zoomIdleTimeout) { - clearTimeout(this.zoomIdleTimeout); - this.zoomIdleTimeout = null; - } - this.cancelPanIdle(); - this.unsubscribeActionState?.(); - this.unsubscribeActionState = null; - } - - private shouldBypass(nodes: Node[], viewport: Viewport | undefined, config: VirtualizationConfig): boolean { - // Note: config.enabled check is handled by strategy selection in FlowCore - return !viewport || nodes.length < config.nodeCountThreshold; - } - - private getViewportRect(viewport: Viewport, paddingMultiplier: number): Rect { - const { x, y, scale, width, height } = viewport; - - const effectiveWidth = width || DEFAULT_VIEWPORT_WIDTH; - const effectiveHeight = height || DEFAULT_VIEWPORT_HEIGHT; - - const flowX = -x / scale; - const flowY = -y / scale; - const flowWidth = effectiveWidth / scale; - const flowHeight = effectiveHeight / scale; - - // Calculate padding as a multiple of the largest viewport dimension (in flow coordinates) - const maxFlowDimension = Math.max(flowWidth, flowHeight); - const padding = maxFlowDimension * paddingMultiplier; - - return { - x: flowX - padding, - y: flowY - padding, - width: flowWidth + padding * 2, - height: flowHeight + padding * 2, - }; - } - - private canUseCachedResult(nodesLength: number, edgesLength: number, viewportRect: Rect): boolean { - if (!this.cachedNodeIds || !this.cachedEdgeIds || !this.lastViewportRect) { - return false; - } - - if (nodesLength !== this.lastNodesLength || edgesLength !== this.lastEdgesLength) { - return false; - } - - return this.isViewportSimilar(this.lastViewportRect, viewportRect); - } - - private isViewportSimilar(prev: Rect, current: Rect): boolean { - // Only recompute when viewport moved by more than 25% of its dimensions - const xThreshold = prev.width * RECOMPUTE_THRESHOLD; - const yThreshold = prev.height * RECOMPUTE_THRESHOLD; - - return ( - Math.abs(prev.x - current.x) < xThreshold && - Math.abs(prev.y - current.y) < yThreshold && - Math.abs(prev.width - current.width) < 10 && - Math.abs(prev.height - current.height) < 10 - ); - } - - /** - * Checks if viewport has moved too far from the cached position. - * Used during panning to trigger recompute when accumulated distance is large. - */ - private hasMovedTooFar(current: Rect, cached: Rect): boolean { - const xThreshold = cached.width * PAN_DISTANCE_THRESHOLD; - const yThreshold = cached.height * PAN_DISTANCE_THRESHOLD; - - return Math.abs(current.x - cached.x) > xThreshold || Math.abs(current.y - cached.y) > yThreshold; - } - - private computeVisibleElements(viewportRect: Rect): RenderStrategyResult { - const primaryVisibleIds = this.getPrimaryVisibleIds(viewportRect); - const { edges, edgeIds, externalNodeIds } = this.collectVisibleEdges(primaryVisibleIds); - const { nodes, nodeIds } = this.buildNodeList(primaryVisibleIds, externalNodeIds); - - return { nodes, edges, nodeIds, edgeIds }; - } - - /** - * Gets node IDs visible in viewport, including descendants of visible groups. - */ - private getPrimaryVisibleIds(viewportRect: Rect): Set { - const primaryVisibleIds = new Set(this.flowCore.spatialHash.queryIds(viewportRect)); - - this.addGroupDescendants(primaryVisibleIds); - - return primaryVisibleIds; - } - - /** - * Expands the set to include all descendants of visible group nodes. - */ - private addGroupDescendants(nodeIds: Set): void { - const nodesMap = this.flowCore.modelLookup.nodesMap; - - // Collect group IDs first to avoid mutating set while iterating - const groupIds: string[] = []; - for (const nodeId of nodeIds) { - const node = nodesMap.get(nodeId); - if (node && isGroup(node)) { - groupIds.push(nodeId); - } - } - - // Add descendants directly to set - for (const groupId of groupIds) { - for (const descendantId of this.flowCore.modelLookup.getAllDescendantIds(groupId)) { - nodeIds.add(descendantId); - } - } - } - - /** - * Collects edges connected to primary visible nodes and identifies external nodes. - * External nodes are nodes outside viewport but connected to visible nodes. - */ - private collectVisibleEdges(primaryVisibleIds: Set): { - edges: Edge[]; - edgeIds: Set; - externalNodeIds: Set; - } { - const edges: Edge[] = []; - const edgeIds = new Set(); - const externalNodeIds = new Set(); - - for (const nodeId of primaryVisibleIds) { - for (const edge of this.flowCore.modelLookup.getConnectedEdges(nodeId)) { - if (edgeIds.has(edge.id)) continue; - - edges.push(edge); - edgeIds.add(edge.id); - - // Add external nodes (endpoints not in primary visible set) - if (!primaryVisibleIds.has(edge.source)) externalNodeIds.add(edge.source); - if (!primaryVisibleIds.has(edge.target)) externalNodeIds.add(edge.target); - } - } - - return { edges, edgeIds, externalNodeIds }; - } - - /** - * Builds the final node list from primary visible and external node IDs. - */ - private buildNodeList( - primaryVisibleIds: Set, - externalNodeIds: Set - ): { nodes: Node[]; nodeIds: Set } { - const nodesMap = this.flowCore.modelLookup.nodesMap; - const nodes: Node[] = []; - const nodeIds = new Set(); - - for (const id of primaryVisibleIds) { - const node = nodesMap.get(id); - if (node) { - nodes.push(node); - nodeIds.add(id); - } - } - - for (const id of externalNodeIds) { - const node = nodesMap.get(id); - if (node) { - nodes.push(node); - nodeIds.add(id); - } - } - - return { nodes, nodeIds }; - } -} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/idle-manager.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/idle-manager.ts new file mode 100644 index 000000000..fd72e1905 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/idle-manager.ts @@ -0,0 +1,124 @@ +import type { EventManager } from '../../event-manager/event-manager'; +import type { VirtualizationConfig } from '../../types'; + +// Delay before recomputing after zoom stops (ms) +const ZOOM_IDLE_DELAY = 150; + +/** + * Manages idle detection for pan/zoom operations. + * Triggers expanded buffer rendering after user stops interacting. + */ +export class IdleManager { + private lastScale: number | null = null; + private zoomIdleTimeout: ReturnType | null = null; + private isZooming = false; + + private panIdleTimeout: ReturnType | null = null; + private wasPanning = false; + private unsubscribeActionState: (() => void) | null = null; + + private pendingExpandedBuffer = false; + + constructor( + private readonly config: VirtualizationConfig, + private readonly onIdle: () => void + ) {} + + /** + * Subscribes to action state changes to detect pan start/end. + */ + subscribeToActionState(eventManager: EventManager): void { + this.unsubscribeActionState = eventManager.on('actionStateChanged', ({ actionState }) => { + const isPanning = !!actionState.panning?.active; + + if (this.wasPanning && !isPanning) { + this.schedulePanIdle(); + } else if (!this.wasPanning && isPanning) { + this.cancelPanIdle(); + } + + this.wasPanning = isPanning; + }); + } + + /** + * Checks for scale changes and manages zoom idle state. + * Call this on each process() with the current scale. + */ + handleScaleChange(currentScale: number): void { + const scaleChanged = this.lastScale !== null && this.lastScale !== currentScale; + + if (scaleChanged) { + this.isZooming = true; + this.scheduleZoomIdle(); + } + + this.lastScale = currentScale; + } + + getIsZooming(): boolean { + return this.isZooming; + } + + /** + * Consumes and returns the pending expanded buffer flag. + * Returns true if expanded buffer should be used, then clears the flag. + */ + consumePendingExpandedBuffer(): boolean { + if (this.pendingExpandedBuffer) { + this.pendingExpandedBuffer = false; + return true; + } + return false; + } + + destroy(): void { + if (this.zoomIdleTimeout) { + clearTimeout(this.zoomIdleTimeout); + this.zoomIdleTimeout = null; + } + this.cancelPanIdle(); + this.unsubscribeActionState?.(); + this.unsubscribeActionState = null; + } + + /** + * Schedules a callback to mark zooming as complete after idle period. + */ + private scheduleZoomIdle(): void { + if (this.zoomIdleTimeout) { + clearTimeout(this.zoomIdleTimeout); + } + + this.zoomIdleTimeout = setTimeout(() => { + this.isZooming = false; + this.zoomIdleTimeout = null; + this.triggerExpandedBufferRender(); + }, ZOOM_IDLE_DELAY); + } + + private schedulePanIdle(): void { + if (!this.config.bufferFill.enabled) { + return; + } + + this.cancelPanIdle(); + const idleThreshold = this.config.bufferFill.idleThreshold; + + this.panIdleTimeout = setTimeout(() => { + this.panIdleTimeout = null; + this.triggerExpandedBufferRender(); + }, idleThreshold); + } + private cancelPanIdle(): void { + if (this.panIdleTimeout !== null) { + clearTimeout(this.panIdleTimeout); + this.panIdleTimeout = null; + } + } + + private triggerExpandedBufferRender(): void { + this.pendingExpandedBuffer = true; + this.onIdle(); + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/result-cache.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/result-cache.ts new file mode 100644 index 000000000..f63afbea1 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/result-cache.ts @@ -0,0 +1,73 @@ +import type { Edge, Node, Rect } from '../../types'; +import type { RenderStrategyResult } from '../render-strategy.interface'; +import { hasMovedTooFar, isViewportSimilar } from './viewport-utils'; + +/** + * Manages cached render results to avoid recomputation during pan/zoom. + * Caches node/edge IDs and filters from current arrays when cache is used. + */ +export class ResultCache { + private cachedResult: RenderStrategyResult | null = null; + private lastViewportRect: Rect | null = null; + private lastNodesLength = 0; + private lastEdgesLength = 0; + + hasCache(): boolean { + return this.cachedResult !== null; + } + + /** + * Returns cached result by filtering from current arrays using cached IDs. + * Always creates fresh filtered arrays to ensure consistency with rendered content. + */ + get(nodes: Node[], edges: Edge[]): RenderStrategyResult { + const cached = this.cachedResult!; + + const filteredNodes = nodes.filter((n) => cached.nodeIds.has(n.id)); + const filteredEdges = edges.filter((e) => cached.edgeIds.has(e.id)); + + return { + nodes: filteredNodes, + edges: filteredEdges, + nodeIds: cached.nodeIds, + edgeIds: cached.edgeIds, + }; + } + + set(result: RenderStrategyResult, nodes: Node[], edges: Edge[], viewportRect: Rect): void { + this.cachedResult = result; + this.lastViewportRect = viewportRect; + this.lastNodesLength = nodes.length; + this.lastEdgesLength = edges.length; + } + + canUse(nodesLength: number, edgesLength: number, viewportRect: Rect): boolean { + if (!this.cachedResult || !this.lastViewportRect) { + return false; + } + + if (nodesLength !== this.lastNodesLength || edgesLength !== this.lastEdgesLength) { + return false; + } + + return isViewportSimilar(this.lastViewportRect, viewportRect); + } + + hasMovedTooFar(viewportRect: Rect): boolean { + if (!this.lastViewportRect) { + return true; + } + return hasMovedTooFar(viewportRect, this.lastViewportRect); + } + + invalidateViewport(): void { + this.lastViewportRect = null; + } + + invalidate(): void { + this.cachedResult = null; + this.lastViewportRect = null; + this.lastNodesLength = 0; + this.lastEdgesLength = 0; + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts new file mode 100644 index 000000000..78ca5ebcc --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts @@ -0,0 +1,77 @@ +import type { Node, Rect, Viewport, VirtualizationConfig } from '../../types'; + +const DEFAULT_VIEWPORT_WIDTH = 1920; +const DEFAULT_VIEWPORT_HEIGHT = 1080; + +// Percentage of viewport dimensions that triggers recomputation +const RECOMPUTE_THRESHOLD = 0.25; + +// Distance threshold (as fraction of viewport) that triggers recompute during panning +const PAN_DISTANCE_THRESHOLD = 0.5; + +// Tolerance for viewport dimension changes (pixels) before recomputation +const DIMENSION_CHANGE_TOLERANCE = 10; + +/** + * Checks if virtualization should be bypassed (too few nodes or no viewport). + */ +export function shouldBypassVirtualization( + nodes: Node[], + viewport: Viewport | undefined, + config: VirtualizationConfig +): boolean { + return !viewport || nodes.length < config.nodeCountThreshold; +} + +/** + * Converts screen viewport to flow coordinates with padding. + */ +export function getViewportRect(viewport: Viewport, paddingMultiplier: number): Rect { + const { x, y, scale, width, height } = viewport; + + const effectiveWidth = width || DEFAULT_VIEWPORT_WIDTH; + const effectiveHeight = height || DEFAULT_VIEWPORT_HEIGHT; + + const flowX = -x / scale; + const flowY = -y / scale; + const flowWidth = effectiveWidth / scale; + const flowHeight = effectiveHeight / scale; + + // Calculate padding as a multiple of the largest viewport dimension (in flow coordinates) + const maxFlowDimension = Math.max(flowWidth, flowHeight); + const padding = maxFlowDimension * paddingMultiplier; + + return { + x: flowX - padding, + y: flowY - padding, + width: flowWidth + padding * 2, + height: flowHeight + padding * 2, + }; +} + +/** + * Checks if two viewports are similar enough to reuse cached results. + * Returns true if the viewport hasn't moved significantly. + */ +export function isViewportSimilar(prev: Rect, current: Rect): boolean { + const xThreshold = prev.width * RECOMPUTE_THRESHOLD; + const yThreshold = prev.height * RECOMPUTE_THRESHOLD; + + return ( + Math.abs(prev.x - current.x) < xThreshold && + Math.abs(prev.y - current.y) < yThreshold && + Math.abs(prev.width - current.width) < DIMENSION_CHANGE_TOLERANCE && + Math.abs(prev.height - current.height) < DIMENSION_CHANGE_TOLERANCE + ); +} + +/** + * Checks if viewport has moved too far from the cached position. + * Used during panning to trigger recompute when accumulated distance is large. + */ +export function hasMovedTooFar(current: Rect, cached: Rect): boolean { + const xThreshold = cached.width * PAN_DISTANCE_THRESHOLD; + const yThreshold = cached.height * PAN_DISTANCE_THRESHOLD; + + return Math.abs(current.x - cached.x) > xThreshold || Math.abs(current.y - cached.y) > yThreshold; +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts similarity index 98% rename from packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts rename to packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts index fa3004280..2c1224674 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized-render-strategy.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { FlowCore } from '../flow-core'; -import { SpatialHash } from '../spatial-hash/spatial-hash'; -import { mockEdge, mockGroupNode, mockNode } from '../test-utils'; -import type { Edge, Node, Viewport, VirtualizationConfig } from '../types'; +import type { FlowCore } from '../../flow-core'; +import { SpatialHash } from '../../spatial-hash/spatial-hash'; +import { mockEdge, mockGroupNode, mockNode } from '../../test-utils'; +import type { Edge, Node, Viewport, VirtualizationConfig } from '../../types'; import { VirtualizedRenderStrategy } from './virtualized-render-strategy'; describe('VirtualizedRenderStrategy', () => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts new file mode 100644 index 000000000..35b90077a --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts @@ -0,0 +1,121 @@ +import type { FlowCore } from '../../flow-core'; +import type { Edge, Node, Viewport } from '../../types'; +import { BaseRenderStrategy } from '../base-render-strategy'; +import type { RenderStrategyResult } from '../render-strategy.interface'; +import { IdleManager } from './idle-manager'; +import { ResultCache } from './result-cache'; +import { getViewportRect, shouldBypassVirtualization } from './viewport-utils'; +import { VisibleElementsResolver } from './visible-elements-resolver'; + +// Reusable empty set for bypass results (avoids allocation on every call) +const EMPTY_SET = new Set(); + +/** + * Virtualized render strategy - returns only nodes and edges visible in the viewport. + * Used when virtualization is enabled for large diagrams. + * + * Handles idle management for both panning and zooming: + * - During active panning/zooming: uses cached results for performance + * - After idle period: renders with expanded buffer to preload more nodes + */ +export class VirtualizedRenderStrategy extends BaseRenderStrategy { + private readonly cache = new ResultCache(); + private readonly visibleElementsResolver: VisibleElementsResolver; + private readonly idleManager: IdleManager; + + // Track nodes reference for optimization (skip spatialHash update during panning/zooming) + private lastNodesRef: Node[] | null = null; + + constructor(flowCore: FlowCore) { + super(flowCore); + + this.visibleElementsResolver = new VisibleElementsResolver(flowCore); + this.idleManager = new IdleManager(flowCore.config.virtualization, () => { + this.cache.invalidateViewport(); + this.render(); + }); + + this.idleManager.subscribeToActionState(flowCore.eventManager); + } + + init(): void { + this.flowCore.spatialHash.process(this.flowCore.model.getNodes()); + + this.flowCore.model.onChange((state) => { + // Optimization: skip spatialHash update during panning/zooming (nodes reference stays the same) + if (state.nodes !== this.lastNodesRef) { + this.flowCore.spatialHash.process(state.nodes); + this.flowCore.modelLookup.desynchronize(); + this.lastNodesRef = state.nodes; + } + this.render(); + }); + + // Trigger initial render to ensure consistent visible nodes + this.render(); + + const { nodes, edges, metadata } = this.flowCore.getState(); + const result = this.process(nodes, edges, metadata.viewport); + this.flowCore.initUpdater.start(result.nodes, result.edges, async () => { + await this.flowCore.commandHandler.emit('init', { + renderedNodeIds: result.nodes.map((n) => n.id), + renderedEdgeIds: result.edges.map((e) => e.id), + }); + }); + } + + process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult { + const config = this.flowCore.config.virtualization; + + if (shouldBypassVirtualization(nodes, viewport, config)) { + return { nodes, edges, nodeIds: EMPTY_SET, edgeIds: EMPTY_SET }; + } + + // Check if we should use expanded buffer (set by idle callbacks) + const useExpandedBuffer = this.idleManager.consumePendingExpandedBuffer(); + + // Handle zoom tracking + this.idleManager.handleScaleChange(viewport!.scale); + + const paddingMultiplier = useExpandedBuffer ? config.expandedPadding : config.padding; + const viewportRect = getViewportRect(viewport!, paddingMultiplier); + + // During active zooming or panning, use cached result to avoid lag + const isPanning = this.flowCore.actionStateManager.isPanning(); + const hasCache = this.cache.hasCache(); + + // Skip caching when using expanded buffer - we want fresh computation + if (!useExpandedBuffer) { + if (this.idleManager.getIsZooming() && hasCache) { + return this.cache.get(nodes, edges); + } + + if (isPanning && hasCache) { + // During panning, use cache unless we've moved too far from last recompute + if (!this.cache.hasMovedTooFar(viewportRect)) { + return this.cache.get(nodes, edges); + } + // Moved too far - fall through to recompute + } + + if (this.cache.canUse(nodes.length, edges.length, viewportRect)) { + return this.cache.get(nodes, edges); + } + } + + const result = this.visibleElementsResolver.resolve(viewportRect); + + // Cache the full result + this.cache.set(result, nodes, edges, viewportRect); + + return result; + } + + invalidateCache(): void { + this.cache.invalidate(); + } + + destroy(): void { + this.idleManager.destroy(); + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts new file mode 100644 index 000000000..df0e5c8a8 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts @@ -0,0 +1,109 @@ +import type { FlowCore } from '../../flow-core'; +import type { Edge, Node, Rect } from '../../types'; +import { isGroup } from '../../utils'; +import type { RenderStrategyResult } from '../render-strategy.interface'; + +/** + * Resolves which nodes and edges are visible within a viewport rect. + * Uses spatial hashing for efficient queries and handles group node descendants. + */ +export class VisibleElementsResolver { + constructor(private readonly flowCore: FlowCore) {} + + /** + * Resolves visible elements for the given viewport rect. + */ + resolve(viewportRect: Rect): RenderStrategyResult { + const primaryVisibleIds = this.getPrimaryVisibleIds(viewportRect); + const { edges, edgeIds, externalNodeIds } = this.collectVisibleEdges(primaryVisibleIds); + const { nodes, nodeIds } = this.buildNodeList(primaryVisibleIds, externalNodeIds); + + return { nodes, edges, nodeIds, edgeIds }; + } + + /** + * Gets node IDs visible in viewport, including descendants of visible groups. + */ + private getPrimaryVisibleIds(viewportRect: Rect): Set { + const primaryVisibleIds = new Set(this.flowCore.spatialHash.queryIds(viewportRect)); + + this.addGroupDescendants(primaryVisibleIds); + + return primaryVisibleIds; + } + + /** + * Expands the set to include all descendants of visible group nodes. + */ + private addGroupDescendants(nodeIds: Set): void { + const nodesMap = this.flowCore.modelLookup.nodesMap; + + // Snapshot current IDs to avoid mutating set while iterating + const currentIds = Array.from(nodeIds); + + for (const nodeId of currentIds) { + const node = nodesMap.get(nodeId); + if (node && isGroup(node)) { + for (const descendantId of this.flowCore.modelLookup.getAllDescendantIds(nodeId)) { + nodeIds.add(descendantId); + } + } + } + } + + /** + * Collects edges connected to primary visible nodes and identifies external nodes. + * External nodes are nodes outside viewport but connected to visible nodes. + */ + private collectVisibleEdges(primaryVisibleIds: Set): { + edges: Edge[]; + edgeIds: Set; + externalNodeIds: Set; + } { + const edges: Edge[] = []; + const edgeIds = new Set(); + const externalNodeIds = new Set(); + + for (const nodeId of primaryVisibleIds) { + for (const edge of this.flowCore.modelLookup.getConnectedEdges(nodeId)) { + if (edgeIds.has(edge.id)) continue; + + edges.push(edge); + edgeIds.add(edge.id); + + // Add external nodes (endpoints not in primary visible set) + if (!primaryVisibleIds.has(edge.source)) externalNodeIds.add(edge.source); + if (!primaryVisibleIds.has(edge.target)) externalNodeIds.add(edge.target); + } + } + + return { edges, edgeIds, externalNodeIds }; + } + + /** + * Builds the final node list from primary visible and external node IDs. + */ + private buildNodeList( + primaryVisibleIds: Set, + externalNodeIds: Set + ): { nodes: Node[]; nodeIds: Set } { + const nodesMap = this.flowCore.modelLookup.nodesMap; + + // Merge IDs into single set (primaryVisibleIds already contains most, just add externals) + const nodeIds = new Set(primaryVisibleIds); + for (const id of externalNodeIds) { + nodeIds.add(id); + } + + // Single iteration to build nodes array + const nodes: Node[] = []; + for (const id of nodeIds) { + const node = nodesMap.get(id); + if (node) { + nodes.push(node); + } + } + + return { nodes, nodeIds }; + } +} From 36d8f88e7428b4073d3670ff846f9c27c839d9d2 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Wed, 7 Jan 2026 13:35:53 +0100 Subject: [PATCH 16/42] Refactor --- .../virtualized/visible-elements-resolver.ts | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts index df0e5c8a8..a6b0e9650 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts @@ -10,9 +10,6 @@ import type { RenderStrategyResult } from '../render-strategy.interface'; export class VisibleElementsResolver { constructor(private readonly flowCore: FlowCore) {} - /** - * Resolves visible elements for the given viewport rect. - */ resolve(viewportRect: Rect): RenderStrategyResult { const primaryVisibleIds = this.getPrimaryVisibleIds(viewportRect); const { edges, edgeIds, externalNodeIds } = this.collectVisibleEdges(primaryVisibleIds); @@ -21,9 +18,6 @@ export class VisibleElementsResolver { return { nodes, edges, nodeIds, edgeIds }; } - /** - * Gets node IDs visible in viewport, including descendants of visible groups. - */ private getPrimaryVisibleIds(viewportRect: Rect): Set { const primaryVisibleIds = new Set(this.flowCore.spatialHash.queryIds(viewportRect)); @@ -32,9 +26,6 @@ export class VisibleElementsResolver { return primaryVisibleIds; } - /** - * Expands the set to include all descendants of visible group nodes. - */ private addGroupDescendants(nodeIds: Set): void { const nodesMap = this.flowCore.modelLookup.nodesMap; @@ -66,23 +57,27 @@ export class VisibleElementsResolver { for (const nodeId of primaryVisibleIds) { for (const edge of this.flowCore.modelLookup.getConnectedEdges(nodeId)) { - if (edgeIds.has(edge.id)) continue; + if (edgeIds.has(edge.id)) { + continue; + } edges.push(edge); edgeIds.add(edge.id); // Add external nodes (endpoints not in primary visible set) - if (!primaryVisibleIds.has(edge.source)) externalNodeIds.add(edge.source); - if (!primaryVisibleIds.has(edge.target)) externalNodeIds.add(edge.target); + if (!primaryVisibleIds.has(edge.source)) { + externalNodeIds.add(edge.source); + } + + if (!primaryVisibleIds.has(edge.target)) { + externalNodeIds.add(edge.target); + } } } return { edges, edgeIds, externalNodeIds }; } - /** - * Builds the final node list from primary visible and external node IDs. - */ private buildNodeList( primaryVisibleIds: Set, externalNodeIds: Set @@ -95,7 +90,6 @@ export class VisibleElementsResolver { nodeIds.add(id); } - // Single iteration to build nodes array const nodes: Node[] = []; for (const id of nodeIds) { const node = nodesMap.get(id); From 3bab8e825976e724f4a774f3ade22cbff3f2e672 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Wed, 7 Jan 2026 16:06:15 +0100 Subject: [PATCH 17/42] Improve init --- .../updater/init-updater/init-state.test.ts | 48 +++++++++++++++---- .../src/updater/init-updater/init-state.ts | 12 +++-- .../src/updater/init-updater/init-updater.ts | 18 ++----- 3 files changed, 50 insertions(+), 28 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-state.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-state.test.ts index cf8652b25..c6e848509 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-state.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-state.test.ts @@ -278,24 +278,34 @@ describe('InitState', () => { }); describe('allEntitiesHaveMeasurements', () => { - it('should return true when all nodes are measured', () => { + it('should return true when all tracked nodes are measured', () => { + // Track nodes via collectAlreadyMeasuredItems + const node1 = createMockNode('node1', false); + const node2 = createMockNode('node2', false); + initState.collectAlreadyMeasuredItems([node1, node2], []); + initState.trackNodeMeasurement('node1', createValidSize()); initState.trackNodeMeasurement('node2', createValidSize()); - expect(initState.allEntitiesHaveMeasurements(2)).toBe(true); + expect(initState.allEntitiesHaveMeasurements()).toBe(true); }); - it('should return false when not all nodes are measured', () => { + it('should return false when not all tracked nodes are measured', () => { + // Track 2 nodes but only measure 1 + const node1 = createMockNode('node1', false); + const node2 = createMockNode('node2', false); + initState.collectAlreadyMeasuredItems([node1, node2], []); + initState.trackNodeMeasurement('node1', createValidSize()); - expect(initState.allEntitiesHaveMeasurements(2)).toBe(false); + expect(initState.allEntitiesHaveMeasurements()).toBe(false); }); it('should return true when all ports are measured', () => { initState.addPort('node1', createMockPort('port1', 'node1')); initState.trackPortMeasurement('node1', 'port1', createValidSize(), createValidPosition()); - expect(initState.allEntitiesHaveMeasurements(0)).toBe(true); + expect(initState.allEntitiesHaveMeasurements()).toBe(true); }); it('should return false when not all ports are measured', () => { @@ -303,14 +313,14 @@ describe('InitState', () => { initState.addPort('node1', createMockPort('port2', 'node1')); initState.trackPortMeasurement('node1', 'port1', createValidSize(), createValidPosition()); - expect(initState.allEntitiesHaveMeasurements(0)).toBe(false); + expect(initState.allEntitiesHaveMeasurements()).toBe(false); }); it('should return true when all labels are measured', () => { initState.addLabel('edge1', createMockEdgeLabel('label1')); initState.trackLabelMeasurement('edge1', 'label1', createValidSize()); - expect(initState.allEntitiesHaveMeasurements(0)).toBe(true); + expect(initState.allEntitiesHaveMeasurements()).toBe(true); }); it('should return false when not all labels are measured', () => { @@ -318,24 +328,42 @@ describe('InitState', () => { initState.addLabel('edge1', createMockEdgeLabel('label2')); initState.trackLabelMeasurement('edge1', 'label1', createValidSize()); - expect(initState.allEntitiesHaveMeasurements(0)).toBe(false); + expect(initState.allEntitiesHaveMeasurements()).toBe(false); }); it('should return true when all entities (nodes, ports, labels) are measured', () => { + const node1 = createMockNode('node1', false); + initState.collectAlreadyMeasuredItems([node1], []); + initState.trackNodeMeasurement('node1', createValidSize()); initState.addPort('node1', createMockPort('port1', 'node1')); initState.trackPortMeasurement('node1', 'port1', createValidSize(), createValidPosition()); initState.addLabel('edge1', createMockEdgeLabel('label1')); initState.trackLabelMeasurement('edge1', 'label1', createValidSize()); - expect(initState.allEntitiesHaveMeasurements(1)).toBe(true); + expect(initState.allEntitiesHaveMeasurements()).toBe(true); }); it('should return false when any entity is not measured', () => { + const node1 = createMockNode('node1', false); + initState.collectAlreadyMeasuredItems([node1], []); + initState.trackNodeMeasurement('node1', createValidSize()); initState.addPort('node1', createMockPort('port1', 'node1')); - expect(initState.allEntitiesHaveMeasurements(1)).toBe(false); + expect(initState.allEntitiesHaveMeasurements()).toBe(false); + }); + + it('should ignore measurements for nodes not in nodesToMeasure', () => { + // Only track node1, but measure both node1 and node2 + const node1 = createMockNode('node1', false); + initState.collectAlreadyMeasuredItems([node1], []); + + initState.trackNodeMeasurement('node1', createValidSize()); + initState.trackNodeMeasurement('node2', createValidSize()); // Not tracked + + // Should still pass because node1 (the only tracked node) is measured + expect(initState.allEntitiesHaveMeasurements()).toBe(true); }); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-state.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-state.ts index 579ee0dcd..75a874eaf 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-state.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-state.ts @@ -38,6 +38,9 @@ export class InitState { /** All labels that need measurement (both pre-existing and newly added), keyed by compound ID */ readonly labelsToMeasure = new Set(); + /** All nodes that need measurement (rendered nodes at init time), keyed by nodeId */ + readonly nodesToMeasure = new Set(); + /** Nodes that have valid measurements, keyed by nodeId */ readonly measuredNodes = new Set(); @@ -129,6 +132,8 @@ export class InitState { */ collectAlreadyMeasuredItems(nodes: Node[], edges: Edge[]): void { for (const node of nodes) { + this.nodesToMeasure.add(node.id); + if (isValidSize(node.size)) { this.measuredNodes.add(node.id); } @@ -157,13 +162,12 @@ export class InitState { /** * Checks if all entities have received valid measurements. - * Compares the count of measured entities against expected counts. + * Verifies that all tracked nodes, ports, and labels have been measured. * - * @param nodeCount - Expected number of nodes * @returns True if all nodes, ports, and labels have valid measurements */ - allEntitiesHaveMeasurements(nodeCount: number): boolean { - const allNodesMeasured = this.measuredNodes.size === nodeCount; + allEntitiesHaveMeasurements(): boolean { + const allNodesMeasured = [...this.nodesToMeasure].every((id) => this.measuredNodes.has(id)); const allPortsMeasured = this.measuredPorts.size === this.portsToMeasure.size; const allLabelsMeasured = this.measuredLabels.size === this.labelsToMeasure.size; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts index 008f1d041..83b6d8d8c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts @@ -68,9 +68,6 @@ export class InitUpdater implements Updater { /** Callback to execute when initialization completes */ private onCompleteCallback?: () => void | Promise; - /** Number of rendered nodes (captured at start for measurement tracking) */ - private renderedNodeCount = 0; - /** Safety timeout to prevent indefinite waiting for measurements */ private measurementTimeout: ReturnType | null = null; @@ -93,7 +90,6 @@ export class InitUpdater implements Updater { * @param onComplete - Optional callback to execute after initialization completes */ start(nodes: Node[], edges: Edge[], onComplete?: () => void | Promise) { - this.renderedNodeCount = nodes.length; this.onCompleteCallback = onComplete; const hasNodes = nodes.length > 0; @@ -237,7 +233,7 @@ export class InitUpdater implements Updater { return; } - if (this.initState.allEntitiesHaveMeasurements(this.renderedNodeCount)) { + if (this.initState.allEntitiesHaveMeasurements()) { this.finish(); } } @@ -277,17 +273,17 @@ export class InitUpdater implements Updater { private startMeasurementTimeout(): void { this.measurementTimeout = setTimeout(() => { if (!this.isInitialized) { - const nodeCount = this.getNodeCount(); + const expectedNodes = this.initState.nodesToMeasure.size; + const measuredNodes = this.initState.measuredNodes.size; const expectedPorts = this.initState.portsToMeasure.size; const measuredPorts = this.initState.measuredPorts.size; const expectedLabels = this.initState.labelsToMeasure.size; const measuredLabels = this.initState.measuredLabels.size; - const measuredNodes = this.initState.measuredNodes.size; console.warn( '[InitUpdater] Measurement timeout reached. Some entities may not be measurable (e.g., display: none).', { - nodes: { expected: nodeCount, measured: measuredNodes }, + nodes: { expected: expectedNodes, measured: measuredNodes }, ports: { expected: expectedPorts, measured: measuredPorts }, labels: { expected: expectedLabels, measured: measuredLabels }, } @@ -307,10 +303,4 @@ export class InitUpdater implements Updater { this.measurementTimeout = null; } } - - private getNodeCount() { - const { nodes, edges, metadata } = this.flowCore.getState(); - const result = this.flowCore.renderStrategy.process(nodes, edges, metadata.viewport); - return result.nodes.length; - } } From 341c0068b66d823777f173ab5a90dc07e7e3d3fb Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 8 Jan 2026 09:05:04 +0100 Subject: [PATCH 18/42] disable zoomToFit when virtualization is enabled --- .../src/command-handler/commands/zoom-to-fit.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/zoom-to-fit.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/zoom-to-fit.ts index 7041a0205..5d6f50426 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/zoom-to-fit.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/zoom-to-fit.ts @@ -8,6 +8,14 @@ Scale must be a positive finite number. Documentation: https://www.ngdiagram.dev/docs/intro/coordinate-system/#viewport-and-scaling`; +const VIRTUALIZATION_WARNING = `[ngDiagram] zoomToFit is disabled when virtualization is enabled. + +When virtualization is active, only visible nodes and edges are rendered for performance reasons. +Zoom to fit requires all elements to be available for calculating bounds, which conflicts with virtualization. + +To use zoomToFit, disable virtualization in your config: + virtualization: { enabled: false }`; + export interface ZoomToFitCommand { name: 'zoomToFit'; nodeIds?: string[]; @@ -101,6 +109,11 @@ const isValidViewport = (viewport: { width?: number; height?: number }): boolean * Calculates optimal viewport position and scale to fit specified content */ export const zoomToFit = async (commandHandler: CommandHandler, { nodeIds, edgeIds, padding }: ZoomToFitCommand) => { + if (commandHandler.flowCore.config.virtualization.enabled) { + console.warn(VIRTUALIZATION_WARNING); + return; + } + const { nodes, edges, metadata } = commandHandler.flowCore.getState(); const { viewport } = metadata; From 5affd978b16ab1fe347eb7ebee10edc19a0c9e7c Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 8 Jan 2026 11:26:23 +0100 Subject: [PATCH 19/42] Improve implementation - remove buffer Filling and introduce zoomTracker for smooth zooming --- .../src/flow-config/default-flow-config.ts | 10 +- .../panning/virtualized-panning.test.ts | 1 - .../__tests__/edges-routing.test.ts | 16 +++ .../edges-routing/edges-routing.ts | 6 + .../render-strategy.interface.ts | 2 - .../virtualized/idle-manager.ts | 124 ------------------ .../virtualized/viewport-utils.ts | 15 ++- .../virtualized-render-strategy.test.ts | 24 ---- .../virtualized-render-strategy.ts | 57 +++----- .../virtualized/zoom-tracker.ts | 49 +++++++ .../core/src/types/flow-config.interface.ts | 39 +----- .../lib/services/renderer/renderer.service.ts | 21 ++- 12 files changed, 111 insertions(+), 253 deletions(-) delete mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/idle-manager.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/zoom-tracker.ts diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts index 961731f42..70bae5fe7 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts @@ -4,7 +4,6 @@ import type { Edge } from '../types/edge.interface'; import type { BackgroundConfig, BoxSelectionConfig, - BufferFillConfig, EdgeRoutingConfig, FlowConfig, GroupingConfig, @@ -135,17 +134,10 @@ const defaultBoxSelectionConfig: BoxSelectionConfig = { realtime: true, }; -const defaultBufferFillConfig: BufferFillConfig = { - enabled: true, - idleThreshold: 100, // ms to wait after pan stops before filling buffer -}; - const defaultVirtualizationConfig: VirtualizationConfig = { enabled: false, // Disabled by default - users must explicitly enable for large diagrams - padding: 0.5, // 0.5x viewport size padding (~2x viewport area total) - expandedPadding: 1.0, // 1.0x viewport size buffer during idle (~3x viewport area) + padding: 0.8, // 0.8x viewport size padding for smooth panning nodeCountThreshold: 500, - bufferFill: defaultBufferFillConfig, }; /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.test.ts index e0626309e..cb3e164ea 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.test.ts @@ -52,7 +52,6 @@ describe('VirtualizedPanningEventHandler', () => { applyUpdate: vi.fn(), commandHandler: mockCommandHandler, actionStateManager: mockActionStateManager, - bufferFillManager: { cancelFill: vi.fn(), scheduleFill: vi.fn() }, environment: mockEnvironment, } as unknown as FlowCore; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing.test.ts index d7f7050e7..9b3a40d34 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing.test.ts @@ -130,6 +130,22 @@ describe('Edges Routing Middleware', () => { expect(nextMock).toHaveBeenCalled(); }); + it('should skip edge routing during zoom', () => { + context.modelActionTypes = ['zoom']; + + edgesRoutingMiddleware.execute(context as any, nextMock, () => null); + + expect(nextMock).toHaveBeenCalledWith(); + }); + + it('should skip edge routing during pan (moveViewport)', () => { + context.modelActionTypes = ['moveViewport']; + + edgesRoutingMiddleware.execute(context as any, nextMock, () => null); + + expect(nextMock).toHaveBeenCalledWith(); + }); + it('should proceed when edges need routing on init', () => { context.modelActionTypes = ['init']; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts index 00e08d7d0..6e37b4b9a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts @@ -12,6 +12,12 @@ export const checkIfShouldRouteEdges = ({ modelActionTypes, actionStateManager, }: MiddlewareContext): boolean => { + // Skip edge routing during viewport-only operations (zoom/pan) + // Node positions don't change, only the viewport transform changes + if (modelActionTypes.includes('zoom') || modelActionTypes.includes('moveViewport')) { + return false; + } + // Skip edge routing during resize - ports will be updated separately and trigger routing then // This prevents visual lag where edges are drawn with old port positions if (modelActionTypes.includes('resizeNode') && actionStateManager.isResizing()) { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts index bad7ab7c8..0a5139f01 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts @@ -13,6 +13,4 @@ export interface RenderStrategy { */ init(): void; process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult; - invalidateCache?(): void; - destroy?(): void; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/idle-manager.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/idle-manager.ts deleted file mode 100644 index fd72e1905..000000000 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/idle-manager.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { EventManager } from '../../event-manager/event-manager'; -import type { VirtualizationConfig } from '../../types'; - -// Delay before recomputing after zoom stops (ms) -const ZOOM_IDLE_DELAY = 150; - -/** - * Manages idle detection for pan/zoom operations. - * Triggers expanded buffer rendering after user stops interacting. - */ -export class IdleManager { - private lastScale: number | null = null; - private zoomIdleTimeout: ReturnType | null = null; - private isZooming = false; - - private panIdleTimeout: ReturnType | null = null; - private wasPanning = false; - private unsubscribeActionState: (() => void) | null = null; - - private pendingExpandedBuffer = false; - - constructor( - private readonly config: VirtualizationConfig, - private readonly onIdle: () => void - ) {} - - /** - * Subscribes to action state changes to detect pan start/end. - */ - subscribeToActionState(eventManager: EventManager): void { - this.unsubscribeActionState = eventManager.on('actionStateChanged', ({ actionState }) => { - const isPanning = !!actionState.panning?.active; - - if (this.wasPanning && !isPanning) { - this.schedulePanIdle(); - } else if (!this.wasPanning && isPanning) { - this.cancelPanIdle(); - } - - this.wasPanning = isPanning; - }); - } - - /** - * Checks for scale changes and manages zoom idle state. - * Call this on each process() with the current scale. - */ - handleScaleChange(currentScale: number): void { - const scaleChanged = this.lastScale !== null && this.lastScale !== currentScale; - - if (scaleChanged) { - this.isZooming = true; - this.scheduleZoomIdle(); - } - - this.lastScale = currentScale; - } - - getIsZooming(): boolean { - return this.isZooming; - } - - /** - * Consumes and returns the pending expanded buffer flag. - * Returns true if expanded buffer should be used, then clears the flag. - */ - consumePendingExpandedBuffer(): boolean { - if (this.pendingExpandedBuffer) { - this.pendingExpandedBuffer = false; - return true; - } - return false; - } - - destroy(): void { - if (this.zoomIdleTimeout) { - clearTimeout(this.zoomIdleTimeout); - this.zoomIdleTimeout = null; - } - this.cancelPanIdle(); - this.unsubscribeActionState?.(); - this.unsubscribeActionState = null; - } - - /** - * Schedules a callback to mark zooming as complete after idle period. - */ - private scheduleZoomIdle(): void { - if (this.zoomIdleTimeout) { - clearTimeout(this.zoomIdleTimeout); - } - - this.zoomIdleTimeout = setTimeout(() => { - this.isZooming = false; - this.zoomIdleTimeout = null; - this.triggerExpandedBufferRender(); - }, ZOOM_IDLE_DELAY); - } - - private schedulePanIdle(): void { - if (!this.config.bufferFill.enabled) { - return; - } - - this.cancelPanIdle(); - const idleThreshold = this.config.bufferFill.idleThreshold; - - this.panIdleTimeout = setTimeout(() => { - this.panIdleTimeout = null; - this.triggerExpandedBufferRender(); - }, idleThreshold); - } - private cancelPanIdle(): void { - if (this.panIdleTimeout !== null) { - clearTimeout(this.panIdleTimeout); - this.panIdleTimeout = null; - } - } - - private triggerExpandedBufferRender(): void { - this.pendingExpandedBuffer = true; - this.onIdle(); - } -} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts index 78ca5ebcc..82aa2fbde 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts @@ -7,10 +7,11 @@ const DEFAULT_VIEWPORT_HEIGHT = 1080; const RECOMPUTE_THRESHOLD = 0.25; // Distance threshold (as fraction of viewport) that triggers recompute during panning -const PAN_DISTANCE_THRESHOLD = 0.5; +const PAN_DISTANCE_THRESHOLD = 0.4; -// Tolerance for viewport dimension changes (pixels) before recomputation -const DIMENSION_CHANGE_TOLERANCE = 10; +// Tolerance for viewport dimension changes (as fraction of dimensions) before recomputation +// Using percentage instead of fixed pixels to handle zoom changes properly +const DIMENSION_CHANGE_TOLERANCE = 0.1; /** * Checks if virtualization should be bypassed (too few nodes or no viewport). @@ -57,11 +58,15 @@ export function isViewportSimilar(prev: Rect, current: Rect): boolean { const xThreshold = prev.width * RECOMPUTE_THRESHOLD; const yThreshold = prev.height * RECOMPUTE_THRESHOLD; + // Use percentage-based tolerance for dimensions (handles zoom better than fixed pixels) + const widthTolerance = prev.width * DIMENSION_CHANGE_TOLERANCE; + const heightTolerance = prev.height * DIMENSION_CHANGE_TOLERANCE; + return ( Math.abs(prev.x - current.x) < xThreshold && Math.abs(prev.y - current.y) < yThreshold && - Math.abs(prev.width - current.width) < DIMENSION_CHANGE_TOLERANCE && - Math.abs(prev.height - current.height) < DIMENSION_CHANGE_TOLERANCE + Math.abs(prev.width - current.width) < widthTolerance && + Math.abs(prev.height - current.height) < heightTolerance ); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts index 2c1224674..b00e267f1 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts @@ -23,11 +23,6 @@ describe('VirtualizedRenderStrategy', () => { enabled: true, padding: 0.1, // 10% of viewport size as padding nodeCountThreshold: 2, - expandedPadding: 0.5, // 50% of viewport size for expanded buffer - bufferFill: { - enabled: true, - idleThreshold: 100, - }, }; beforeEach(() => { @@ -354,24 +349,5 @@ describe('VirtualizedRenderStrategy', () => { // New Sets should be created when node count changes expect(result1.nodeIds).not.toBe(result2.nodeIds); }); - - it('should create new ID Sets after manual cache invalidation', () => { - const nodes: Node[] = [ - { ...mockNode, id: '1', position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }, - { ...mockNode, id: '2', position: { x: 200, y: 200 }, size: { width: 50, height: 50 } }, - { ...mockNode, id: '3', position: { x: 300, y: 300 }, size: { width: 50, height: 50 } }, - ]; - const edges: Edge[] = []; - spatialHash.process(nodes); - updateNodesMap(nodes); - - const result1 = strategy.process(nodes, edges, defaultViewport); - strategy.invalidateCache(); - const result2 = strategy.process(nodes, edges, defaultViewport); - - // New Sets should be created after invalidation - expect(result1.nodeIds).not.toBe(result2.nodeIds); - expect(result1.edgeIds).not.toBe(result2.edgeIds); - }); }); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts index 35b90077a..009bf79e6 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts @@ -2,10 +2,10 @@ import type { FlowCore } from '../../flow-core'; import type { Edge, Node, Viewport } from '../../types'; import { BaseRenderStrategy } from '../base-render-strategy'; import type { RenderStrategyResult } from '../render-strategy.interface'; -import { IdleManager } from './idle-manager'; import { ResultCache } from './result-cache'; import { getViewportRect, shouldBypassVirtualization } from './viewport-utils'; import { VisibleElementsResolver } from './visible-elements-resolver'; +import { ZoomTracker } from './zoom-tracker'; // Reusable empty set for bypass results (avoids allocation on every call) const EMPTY_SET = new Set(); @@ -13,29 +13,22 @@ const EMPTY_SET = new Set(); /** * Virtualized render strategy - returns only nodes and edges visible in the viewport. * Used when virtualization is enabled for large diagrams. - * - * Handles idle management for both panning and zooming: - * - During active panning/zooming: uses cached results for performance - * - After idle period: renders with expanded buffer to preload more nodes */ export class VirtualizedRenderStrategy extends BaseRenderStrategy { private readonly cache = new ResultCache(); private readonly visibleElementsResolver: VisibleElementsResolver; - private readonly idleManager: IdleManager; + private readonly zoomTracker: ZoomTracker; // Track nodes reference for optimization (skip spatialHash update during panning/zooming) private lastNodesRef: Node[] | null = null; constructor(flowCore: FlowCore) { super(flowCore); - this.visibleElementsResolver = new VisibleElementsResolver(flowCore); - this.idleManager = new IdleManager(flowCore.config.virtualization, () => { + this.zoomTracker = new ZoomTracker(() => { this.cache.invalidateViewport(); this.render(); }); - - this.idleManager.subscribeToActionState(flowCore.eventManager); } init(): void { @@ -71,51 +64,35 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { return { nodes, edges, nodeIds: EMPTY_SET, edgeIds: EMPTY_SET }; } - // Check if we should use expanded buffer (set by idle callbacks) - const useExpandedBuffer = this.idleManager.consumePendingExpandedBuffer(); - - // Handle zoom tracking - this.idleManager.handleScaleChange(viewport!.scale); - - const paddingMultiplier = useExpandedBuffer ? config.expandedPadding : config.padding; - const viewportRect = getViewportRect(viewport!, paddingMultiplier); + this.zoomTracker.handleScaleChange(viewport!.scale); - // During active zooming or panning, use cached result to avoid lag + const viewportRect = getViewportRect(viewport!, config.padding); const isPanning = this.flowCore.actionStateManager.isPanning(); const hasCache = this.cache.hasCache(); - // Skip caching when using expanded buffer - we want fresh computation - if (!useExpandedBuffer) { - if (this.idleManager.getIsZooming() && hasCache) { - return this.cache.get(nodes, edges); - } - - if (isPanning && hasCache) { - // During panning, use cache unless we've moved too far from last recompute - if (!this.cache.hasMovedTooFar(viewportRect)) { - return this.cache.get(nodes, edges); - } - // Moved too far - fall through to recompute - } + // During active zooming, use cached result to avoid lag + if (this.zoomTracker.getIsZooming() && hasCache) { + return this.cache.get(nodes, edges); + } - if (this.cache.canUse(nodes.length, edges.length, viewportRect)) { + // During panning, use cache unless we've moved too far + if (isPanning && hasCache) { + if (!this.cache.hasMovedTooFar(viewportRect)) { return this.cache.get(nodes, edges); } } - const result = this.visibleElementsResolver.resolve(viewportRect); + if (this.cache.canUse(nodes.length, edges.length, viewportRect)) { + return this.cache.get(nodes, edges); + } - // Cache the full result + const result = this.visibleElementsResolver.resolve(viewportRect); this.cache.set(result, nodes, edges, viewportRect); return result; } - invalidateCache(): void { - this.cache.invalidate(); - } - destroy(): void { - this.idleManager.destroy(); + this.zoomTracker.destroy(); } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/zoom-tracker.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/zoom-tracker.ts new file mode 100644 index 000000000..d9feda6b4 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/zoom-tracker.ts @@ -0,0 +1,49 @@ +// Delay before considering zoom complete (ms) +const ZOOM_IDLE_DELAY = 300; + +/** + * Tracks zoom state to enable cache usage during active zooming. + * During zooming, signals that cached results should be used to avoid lag. + * After zoom stops, triggers a callback to refresh the view. + */ +export class ZoomTracker { + private lastScale: number | null = null; + private isZooming = false; + private zoomIdleTimeout: ReturnType | null = null; + + constructor(private readonly onZoomEnd: () => void) {} + + handleScaleChange(currentScale: number): void { + const scaleChanged = this.lastScale !== null && this.lastScale !== currentScale; + + if (scaleChanged) { + this.isZooming = true; + this.scheduleZoomIdle(); + } + + this.lastScale = currentScale; + } + + getIsZooming(): boolean { + return this.isZooming; + } + + destroy(): void { + if (this.zoomIdleTimeout) { + clearTimeout(this.zoomIdleTimeout); + this.zoomIdleTimeout = null; + } + } + + private scheduleZoomIdle(): void { + if (this.zoomIdleTimeout) { + clearTimeout(this.zoomIdleTimeout); + } + + this.zoomIdleTimeout = setTimeout(() => { + this.isZooming = false; + this.zoomIdleTimeout = null; + this.onZoomEnd(); + }, ZOOM_IDLE_DELAY); + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index 48934a181..c05791109 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -419,26 +419,6 @@ export interface BoxSelectionConfig { realtime?: boolean; } -/** - * Configuration for buffer fill behavior during pan idle. - * - * @category Types/Configuration/Features - */ -export interface BufferFillConfig { - /** - * Whether buffer fill is enabled. - * When enabled, the diagram will fill an expanded buffer during pan idle time. - * @default true - */ - enabled: boolean; - - /** - * Time in milliseconds to wait after panning stops before filling the buffer. - * @default 100 - */ - idleThreshold: number; -} - /** * Configuration for viewport virtualization behavior. * When enabled, only nodes and edges visible in the viewport (plus padding) are rendered, @@ -450,37 +430,24 @@ export interface VirtualizationConfig { /** * Whether viewport virtualization is enabled. * When disabled, all nodes/edges are rendered regardless of viewport. - * @default true + * @default false */ enabled: boolean; /** * Padding multiplier relative to viewport size. * The actual padding is calculated as: max(viewportWidth, viewportHeight) * padding - * For example, 0.3 means 30% of the viewport size as padding in each direction. - * @default 0.3 + * For example, 0.8 means 80% of the viewport size as padding in each direction. + * @default 0.8 */ padding: number; - /** - * Expanded padding multiplier used when filling buffer during pan idle. - * This larger padding is applied after panning stops to preload more nodes. - * Calculated as: max(viewportWidth, viewportHeight) * expandedPadding - * @default 0.7 - */ - expandedPadding: number; - /** * Maximum number of nodes below which virtualization is skipped. * If fewer nodes exist than this threshold, render all nodes. * @default 500 */ nodeCountThreshold: number; - - /** - * Configuration for buffer fill during pan idle. - */ - bufferFill: BufferFillConfig; } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts index a67f61926..82b1d3eda 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts @@ -21,28 +21,25 @@ export class RendererService implements Renderer { private lastNodeCount = 0; draw(nodes: Node[], edges: Edge[], viewport: Viewport): void { - const nodeCountDiff = nodes.length - this.lastNodeCount; + const prevCount = this.lastNodeCount; + const currentCount = nodes.length; + const nodeCountDiff = currentCount - prevCount; + + this.nodes.set(nodes); + this.edges.set(edges); + this.viewport.set(viewport); + this.lastNodeCount = currentCount; if (nodeCountDiff > 0) { const startTime = performance.now(); - this.nodes.set(nodes); - this.edges.set(edges); - this.viewport.set(viewport); - requestAnimationFrame(() => { requestAnimationFrame(() => { console.log( - `[PERF] Render +${nodeCountDiff} nodes (${this.lastNodeCount} -> ${nodes.length}): ${(performance.now() - startTime).toFixed(2)}ms` + `[PERF] Render +${nodeCountDiff} nodes (${prevCount} -> ${currentCount}): ${(performance.now() - startTime).toFixed(2)}ms` ); }); }); - } else { - this.nodes.set(nodes); - this.edges.set(edges); - this.viewport.set(viewport); } - - this.lastNodeCount = nodes.length; } } From 71d8e3219d98be107188cbbf4c443a09aefb5d8b Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Fri, 9 Jan 2026 13:37:35 +0100 Subject: [PATCH 20/42] optimization --- apps/angular-demo/src/app/app.component.ts | 1 + .../command-handler/commands/move-viewport.ts | 9 ++++ .../src/flow-config/default-flow-config.ts | 4 +- .../ng-diagram/src/core/src/flow-core.ts | 33 ++++++++++++ .../direct/direct-render-strategy.ts | 21 +++++++- .../virtualized-render-strategy.ts | 50 ++++++++++++++++++- .../core/src/types/flow-config.interface.ts | 19 +++++-- .../src/core/src/types/renderer.interface.ts | 8 +++ .../lib/services/renderer/renderer.service.ts | 24 ++++++++- 9 files changed, 160 insertions(+), 9 deletions(-) diff --git a/apps/angular-demo/src/app/app.component.ts b/apps/angular-demo/src/app/app.component.ts index 3a57cf731..57eeef439 100644 --- a/apps/angular-demo/src/app/app.component.ts +++ b/apps/angular-demo/src/app/app.component.ts @@ -62,6 +62,7 @@ export class AppComponent { }, virtualization: { enabled: true, + padding: 0.4, }, shortcuts: configureShortcuts([ { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/move-viewport.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/move-viewport.ts index b94a96038..1b96710d6 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/move-viewport.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/move-viewport.ts @@ -14,6 +14,15 @@ export const moveViewportBy = async (commandHandler: CommandHandler, { x, y }: M return; } + // Use fast-path for viewport-only updates during panning (bypasses middleware chain) + if (commandHandler.flowCore.actionStateManager.isPanning()) { + commandHandler.flowCore.applyViewportOnlyUpdate({ + x: metadata.viewport.x + x, + y: metadata.viewport.y + y, + }); + return; + } + await commandHandler.flowCore.applyUpdate( { metadataUpdate: { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts index 70bae5fe7..22991b85b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts @@ -136,7 +136,9 @@ const defaultBoxSelectionConfig: BoxSelectionConfig = { const defaultVirtualizationConfig: VirtualizationConfig = { enabled: false, // Disabled by default - users must explicitly enable for large diagrams - padding: 0.8, // 0.8x viewport size padding for smooth panning + padding: 0.2, // 0.4x viewport size padding during active panning + expandedPadding: 0.4, // 0.8x viewport size padding when idle (after panning stops) + idleDelay: 100, // ms to wait after panning stops before expanding buffer nodeCountThreshold: 500, }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts index 4bee2026f..5d6a2079b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts @@ -38,6 +38,7 @@ import type { Point, Port, Renderer, + Viewport, } from './types'; import { InternalTransactionOptions, @@ -328,6 +329,38 @@ export class FlowCore { } } + /** + * Fast-path for viewport-only updates (panning/zooming). + * Bypasses middleware chain and only updates viewport metadata. + * Use this for high-frequency viewport changes where middleware processing is unnecessary. + */ + applyViewportOnlyUpdate(viewportUpdate: Partial): void { + const currentMetadata = this.model.getMetadata(); + const currentViewport = currentMetadata.viewport; + const newViewport = { ...currentViewport, ...viewportUpdate }; + + // Skip if no actual change + if ( + newViewport.x === currentViewport.x && + newViewport.y === currentViewport.y && + newViewport.scale === currentViewport.scale + ) { + return; + } + + // Update model directly (bypasses middleware) + this.model.updateMetadata({ + ...currentMetadata, + viewport: newViewport, + }); + + // Emit viewport changed event directly + this.eventManager.emit('viewportChanged', { + viewport: newViewport, + previousViewport: currentViewport, + }); + } + /** * Converts a client position to a flow position * @param clientPosition Client position diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts index fe6bc6f09..448006933 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts @@ -11,6 +11,10 @@ const EMPTY_SET = new Set(); * Used when virtualization is disabled. */ export class DirectRenderStrategy extends BaseRenderStrategy { + // Track last references for change detection + private lastNodesRef: Node[] = []; + private lastEdgesRef: Edge[] = []; + constructor(flowCore: FlowCore) { super(flowCore); } @@ -18,9 +22,22 @@ export class DirectRenderStrategy extends BaseRenderStrategy { init(): void { this.render(); + // Initialize reference tracking + this.lastNodesRef = this.flowCore.model.getNodes(); + this.lastEdgesRef = this.flowCore.model.getEdges(); + this.flowCore.model.onChange((state) => { - this.flowCore.spatialHash.process(state.nodes); - this.flowCore.modelLookup.desynchronize(); + const nodesChanged = state.nodes !== this.lastNodesRef; + const edgesChanged = state.edges !== this.lastEdgesRef; + + // Only process spatial hash and model lookup if nodes/edges actually changed + if (nodesChanged || edgesChanged) { + this.flowCore.spatialHash.process(state.nodes); + this.flowCore.modelLookup.desynchronize(); + this.lastNodesRef = state.nodes; + this.lastEdgesRef = state.edges; + } + this.render(); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts index 009bf79e6..a0ca443f7 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts @@ -22,6 +22,11 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { // Track nodes reference for optimization (skip spatialHash update during panning/zooming) private lastNodesRef: Node[] | null = null; + // Pan-stop detection for expanded buffer rendering + private wasPanning = false; + private idleTimeout: ReturnType | null = null; + private unsubscribeActionState: (() => void) | null = null; + constructor(flowCore: FlowCore) { super(flowCore); this.visibleElementsResolver = new VisibleElementsResolver(flowCore); @@ -44,6 +49,22 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { this.render(); }); + // Subscribe to action state changes to detect pan stop + this.unsubscribeActionState = this.flowCore.eventManager.on('actionStateChanged', ({ actionState }) => { + const isPanning = !!actionState.panning?.active; + + if (this.wasPanning && !isPanning) { + // Pan just stopped - schedule expanded buffer render + this.scheduleIdleRender(); + } else if (isPanning && this.idleTimeout) { + // Panning started again - cancel pending idle render + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + + this.wasPanning = isPanning; + }); + // Trigger initial render to ensure consistent visible nodes this.render(); @@ -57,6 +78,23 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { }); } + /** + * Schedules an expanded buffer render after panning stops. + * Uses idleDelay config to wait before rendering with expandedPadding. + */ + private scheduleIdleRender(): void { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + } + + const delay = this.flowCore.config.virtualization.idleDelay ?? 100; + this.idleTimeout = setTimeout(() => { + this.cache.invalidateViewport(); + this.render(); + this.idleTimeout = null; + }, delay); + } + process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult { const config = this.flowCore.config.virtualization; @@ -66,8 +104,10 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { this.zoomTracker.handleScaleChange(viewport!.scale); - const viewportRect = getViewportRect(viewport!, config.padding); + // Use smaller padding during panning, larger padding when idle const isPanning = this.flowCore.actionStateManager.isPanning(); + const padding = isPanning ? config.padding : (config.expandedPadding ?? config.padding); + const viewportRect = getViewportRect(viewport!, padding); const hasCache = this.cache.hasCache(); // During active zooming, use cached result to avoid lag @@ -94,5 +134,13 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { destroy(): void { this.zoomTracker.destroy(); + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + if (this.unsubscribeActionState) { + this.unsubscribeActionState(); + this.unsubscribeActionState = null; + } } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index c05791109..de1f02574 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -435,13 +435,26 @@ export interface VirtualizationConfig { enabled: boolean; /** - * Padding multiplier relative to viewport size. + * Padding multiplier relative to viewport size used during active panning. * The actual padding is calculated as: max(viewportWidth, viewportHeight) * padding - * For example, 0.8 means 80% of the viewport size as padding in each direction. - * @default 0.8 + * For example, 0.4 means 40% of the viewport size as padding in each direction. + * @default 0.4 */ padding: number; + /** + * Expanded padding multiplier used when idle (after panning stops). + * This larger padding preloads more nodes for smoother subsequent panning. + * @default 0.8 + */ + expandedPadding?: number; + + /** + * Delay in milliseconds after panning stops before rendering with expanded padding. + * @default 100 + */ + idleDelay?: number; + /** * Maximum number of nodes below which virtualization is skipped. * If fewer nodes exist than this threshold, render all nodes. diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/renderer.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/renderer.interface.ts index 04e2ca510..4f9f5d0da 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/renderer.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/renderer.interface.ts @@ -13,4 +13,12 @@ export interface Renderer { * @param viewport Viewport to render */ draw(nodes: Node[], edges: Edge[], viewport: Viewport): void; + + /** + * Fast-path for viewport-only updates. + * Only updates viewport without touching nodes/edges. + * Optional - implementations may not support this optimization. + * @param viewport Viewport to render + */ + drawViewportOnly?(viewport: Viewport): void; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts index 82b1d3eda..426bcf0d0 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts @@ -18,6 +18,9 @@ export class RendererService implements Renderer { scale: 1, }); + // Track last references for change detection + private lastNodes: Node[] = []; + private lastEdges: Edge[] = []; private lastNodeCount = 0; draw(nodes: Node[], edges: Edge[], viewport: Viewport): void { @@ -25,8 +28,17 @@ export class RendererService implements Renderer { const currentCount = nodes.length; const nodeCountDiff = currentCount - prevCount; - this.nodes.set(nodes); - this.edges.set(edges); + // Only update signals if data actually changed (reference check) + if (nodes !== this.lastNodes) { + this.nodes.set(nodes); + this.lastNodes = nodes; + } + + if (edges !== this.lastEdges) { + this.edges.set(edges); + this.lastEdges = edges; + } + this.viewport.set(viewport); this.lastNodeCount = currentCount; @@ -42,4 +54,12 @@ export class RendererService implements Renderer { }); } } + + /** + * Fast-path: Updates only the viewport signal. + * Used during panning/zooming when nodes/edges don't change. + */ + drawViewportOnly(viewport: Viewport): void { + this.viewport.set(viewport); + } } From 553f0825e4a18e8f7f27d973fb66539487761ccc Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 12 Jan 2026 10:42:48 +0100 Subject: [PATCH 21/42] Fix viewport calculation --- .../src/render-strategy/virtualized/viewport-utils.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts index 82aa2fbde..67861461f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts @@ -26,6 +26,7 @@ export function shouldBypassVirtualization( /** * Converts screen viewport to flow coordinates with padding. + * Padding is scaled down at low zoom levels to prevent excessive rendering. */ export function getViewportRect(viewport: Viewport, paddingMultiplier: number): Rect { const { x, y, scale, width, height } = viewport; @@ -38,9 +39,13 @@ export function getViewportRect(viewport: Viewport, paddingMultiplier: number): const flowWidth = effectiveWidth / scale; const flowHeight = effectiveHeight / scale; - // Calculate padding as a multiple of the largest viewport dimension (in flow coordinates) - const maxFlowDimension = Math.max(flowWidth, flowHeight); - const padding = maxFlowDimension * paddingMultiplier; + // Scale down padding at low zoom to prevent excessive rendering + // At scale < 1, reduce padding proportionally so we don't render too many nodes + const scaleAdjustedMultiplier = paddingMultiplier * Math.min(1, scale); + + // Use screen dimensions for padding calculation (constant screen-space buffer) + const maxScreenDimension = Math.max(effectiveWidth, effectiveHeight); + const padding = (maxScreenDimension * scaleAdjustedMultiplier) / scale; return { x: flowX - padding, From dce3856683b407bce711cbe959de37ce13bd8efa Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 12 Jan 2026 13:57:26 +0100 Subject: [PATCH 22/42] Refactor --- apps/angular-demo/src/app/app.component.ts | 1 - .../src/flow-config/default-flow-config.ts | 7 +- .../direct/direct-render-strategy.ts | 21 +----- .../virtualized/idle-render-scheduler.ts | 54 ++++++++++++++ .../virtualized-render-strategy.ts | 71 ++++--------------- .../core/src/types/flow-config.interface.ts | 15 ++-- .../src/core/src/types/renderer.interface.ts | 8 --- .../lib/services/renderer/renderer.service.ts | 24 +------ 8 files changed, 79 insertions(+), 122 deletions(-) create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/idle-render-scheduler.ts diff --git a/apps/angular-demo/src/app/app.component.ts b/apps/angular-demo/src/app/app.component.ts index 57eeef439..3a57cf731 100644 --- a/apps/angular-demo/src/app/app.component.ts +++ b/apps/angular-demo/src/app/app.component.ts @@ -62,7 +62,6 @@ export class AppComponent { }, virtualization: { enabled: true, - padding: 0.4, }, shortcuts: configureShortcuts([ { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts index 22991b85b..c9dd41830 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts @@ -135,10 +135,9 @@ const defaultBoxSelectionConfig: BoxSelectionConfig = { }; const defaultVirtualizationConfig: VirtualizationConfig = { - enabled: false, // Disabled by default - users must explicitly enable for large diagrams - padding: 0.2, // 0.4x viewport size padding during active panning - expandedPadding: 0.4, // 0.8x viewport size padding when idle (after panning stops) - idleDelay: 100, // ms to wait after panning stops before expanding buffer + enabled: false, + padding: 0.5, + idleDelay: 100, nodeCountThreshold: 500, }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts index 448006933..fe6bc6f09 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts @@ -11,10 +11,6 @@ const EMPTY_SET = new Set(); * Used when virtualization is disabled. */ export class DirectRenderStrategy extends BaseRenderStrategy { - // Track last references for change detection - private lastNodesRef: Node[] = []; - private lastEdgesRef: Edge[] = []; - constructor(flowCore: FlowCore) { super(flowCore); } @@ -22,22 +18,9 @@ export class DirectRenderStrategy extends BaseRenderStrategy { init(): void { this.render(); - // Initialize reference tracking - this.lastNodesRef = this.flowCore.model.getNodes(); - this.lastEdgesRef = this.flowCore.model.getEdges(); - this.flowCore.model.onChange((state) => { - const nodesChanged = state.nodes !== this.lastNodesRef; - const edgesChanged = state.edges !== this.lastEdgesRef; - - // Only process spatial hash and model lookup if nodes/edges actually changed - if (nodesChanged || edgesChanged) { - this.flowCore.spatialHash.process(state.nodes); - this.flowCore.modelLookup.desynchronize(); - this.lastNodesRef = state.nodes; - this.lastEdgesRef = state.edges; - } - + this.flowCore.spatialHash.process(state.nodes); + this.flowCore.modelLookup.desynchronize(); this.render(); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/idle-render-scheduler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/idle-render-scheduler.ts new file mode 100644 index 000000000..a729a3f0c --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/idle-render-scheduler.ts @@ -0,0 +1,54 @@ +import type { FlowCore } from '../../flow-core'; + +/** + * Schedules render callbacks after panning stops. + * Debounces rapid pan-stop-pan sequences using configurable delay. + */ +export class IdleRenderScheduler { + private wasPanning = false; + private idleTimeout: ReturnType | null = null; + private unsubscribeActionState: (() => void) | null = null; + + constructor( + private readonly flowCore: FlowCore, + private readonly onIdle: () => void + ) {} + + init(): void { + this.unsubscribeActionState = this.flowCore.eventManager.on('actionStateChanged', ({ actionState }) => { + const isPanning = !!actionState.panning?.active; + + if (this.wasPanning && !isPanning) { + this.scheduleIdleRender(); + } else if (isPanning && this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + + this.wasPanning = isPanning; + }); + } + + private scheduleIdleRender(): void { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + } + + const delay = this.flowCore.config.virtualization.idleDelay ?? 100; + this.idleTimeout = setTimeout(() => { + this.onIdle(); + this.idleTimeout = null; + }, delay); + } + + destroy(): void { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + if (this.unsubscribeActionState) { + this.unsubscribeActionState(); + this.unsubscribeActionState = null; + } + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts index a0ca443f7..9ba7aa0a2 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts @@ -2,6 +2,7 @@ import type { FlowCore } from '../../flow-core'; import type { Edge, Node, Viewport } from '../../types'; import { BaseRenderStrategy } from '../base-render-strategy'; import type { RenderStrategyResult } from '../render-strategy.interface'; +import { IdleRenderScheduler } from './idle-render-scheduler'; import { ResultCache } from './result-cache'; import { getViewportRect, shouldBypassVirtualization } from './viewport-utils'; import { VisibleElementsResolver } from './visible-elements-resolver'; @@ -18,22 +19,16 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { private readonly cache = new ResultCache(); private readonly visibleElementsResolver: VisibleElementsResolver; private readonly zoomTracker: ZoomTracker; + private readonly idleRenderScheduler: IdleRenderScheduler; // Track nodes reference for optimization (skip spatialHash update during panning/zooming) private lastNodesRef: Node[] | null = null; - // Pan-stop detection for expanded buffer rendering - private wasPanning = false; - private idleTimeout: ReturnType | null = null; - private unsubscribeActionState: (() => void) | null = null; - constructor(flowCore: FlowCore) { super(flowCore); this.visibleElementsResolver = new VisibleElementsResolver(flowCore); - this.zoomTracker = new ZoomTracker(() => { - this.cache.invalidateViewport(); - this.render(); - }); + this.zoomTracker = new ZoomTracker(() => this.invalidateAndRender()); + this.idleRenderScheduler = new IdleRenderScheduler(flowCore, () => this.invalidateAndRender()); } init(): void { @@ -49,21 +44,7 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { this.render(); }); - // Subscribe to action state changes to detect pan stop - this.unsubscribeActionState = this.flowCore.eventManager.on('actionStateChanged', ({ actionState }) => { - const isPanning = !!actionState.panning?.active; - - if (this.wasPanning && !isPanning) { - // Pan just stopped - schedule expanded buffer render - this.scheduleIdleRender(); - } else if (isPanning && this.idleTimeout) { - // Panning started again - cancel pending idle render - clearTimeout(this.idleTimeout); - this.idleTimeout = null; - } - - this.wasPanning = isPanning; - }); + this.idleRenderScheduler.init(); // Trigger initial render to ensure consistent visible nodes this.render(); @@ -78,23 +59,6 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { }); } - /** - * Schedules an expanded buffer render after panning stops. - * Uses idleDelay config to wait before rendering with expandedPadding. - */ - private scheduleIdleRender(): void { - if (this.idleTimeout) { - clearTimeout(this.idleTimeout); - } - - const delay = this.flowCore.config.virtualization.idleDelay ?? 100; - this.idleTimeout = setTimeout(() => { - this.cache.invalidateViewport(); - this.render(); - this.idleTimeout = null; - }, delay); - } - process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult { const config = this.flowCore.config.virtualization; @@ -104,9 +68,7 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { this.zoomTracker.handleScaleChange(viewport!.scale); - // Use smaller padding during panning, larger padding when idle - const isPanning = this.flowCore.actionStateManager.isPanning(); - const padding = isPanning ? config.padding : (config.expandedPadding ?? config.padding); + const padding = config.padding; const viewportRect = getViewportRect(viewport!, padding); const hasCache = this.cache.hasCache(); @@ -115,11 +77,8 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { return this.cache.get(nodes, edges); } - // During panning, use cache unless we've moved too far - if (isPanning && hasCache) { - if (!this.cache.hasMovedTooFar(viewportRect)) { - return this.cache.get(nodes, edges); - } + if (this.flowCore.actionStateManager.isPanning() && hasCache) { + return this.cache.get(nodes, edges); } if (this.cache.canUse(nodes.length, edges.length, viewportRect)) { @@ -134,13 +93,11 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { destroy(): void { this.zoomTracker.destroy(); - if (this.idleTimeout) { - clearTimeout(this.idleTimeout); - this.idleTimeout = null; - } - if (this.unsubscribeActionState) { - this.unsubscribeActionState(); - this.unsubscribeActionState = null; - } + this.idleRenderScheduler.destroy(); + } + + private invalidateAndRender(): void { + this.cache.invalidateViewport(); + this.render(); } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index de1f02574..5f1dc646b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -435,22 +435,15 @@ export interface VirtualizationConfig { enabled: boolean; /** - * Padding multiplier relative to viewport size used during active panning. + * Padding multiplier relative to viewport size. * The actual padding is calculated as: max(viewportWidth, viewportHeight) * padding - * For example, 0.4 means 40% of the viewport size as padding in each direction. - * @default 0.4 + * For example, 0.5 means 50% of the viewport size as padding in each direction. + * @default 0.5 */ padding: number; /** - * Expanded padding multiplier used when idle (after panning stops). - * This larger padding preloads more nodes for smoother subsequent panning. - * @default 0.8 - */ - expandedPadding?: number; - - /** - * Delay in milliseconds after panning stops before rendering with expanded padding. + * Delay in milliseconds after panning stops before re-rendering visible nodes. * @default 100 */ idleDelay?: number; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/renderer.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/renderer.interface.ts index 4f9f5d0da..04e2ca510 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/renderer.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/renderer.interface.ts @@ -13,12 +13,4 @@ export interface Renderer { * @param viewport Viewport to render */ draw(nodes: Node[], edges: Edge[], viewport: Viewport): void; - - /** - * Fast-path for viewport-only updates. - * Only updates viewport without touching nodes/edges. - * Optional - implementations may not support this optimization. - * @param viewport Viewport to render - */ - drawViewportOnly?(viewport: Viewport): void; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts index 426bcf0d0..82b1d3eda 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts @@ -18,9 +18,6 @@ export class RendererService implements Renderer { scale: 1, }); - // Track last references for change detection - private lastNodes: Node[] = []; - private lastEdges: Edge[] = []; private lastNodeCount = 0; draw(nodes: Node[], edges: Edge[], viewport: Viewport): void { @@ -28,17 +25,8 @@ export class RendererService implements Renderer { const currentCount = nodes.length; const nodeCountDiff = currentCount - prevCount; - // Only update signals if data actually changed (reference check) - if (nodes !== this.lastNodes) { - this.nodes.set(nodes); - this.lastNodes = nodes; - } - - if (edges !== this.lastEdges) { - this.edges.set(edges); - this.lastEdges = edges; - } - + this.nodes.set(nodes); + this.edges.set(edges); this.viewport.set(viewport); this.lastNodeCount = currentCount; @@ -54,12 +42,4 @@ export class RendererService implements Renderer { }); } } - - /** - * Fast-path: Updates only the viewport signal. - * Used during panning/zooming when nodes/edges don't change. - */ - drawViewportOnly(viewport: Viewport): void { - this.viewport.set(viewport); - } } From 820f501ff0621b98e4f4e58d57f8f4a9b103afcf Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 13 Jan 2026 15:37:13 +0100 Subject: [PATCH 23/42] improvements --- apps/angular-demo/src/app/app.component.html | 2 +- apps/angular-demo/src/app/app.component.ts | 93 ++----- .../src/app/data/default-model.ts | 256 ++++++++++++++++++ .../src/app/data/generate-model.ts | 51 ++++ .../app/data/virtualization-test.config.ts | 12 + .../src/app/toolbar/toolbar.component.html | 1 + .../src/app/toolbar/toolbar.component.ts | 1 + .../render-strategy/base-render-strategy.ts | 13 +- .../render-performance-logger.ts | 47 ++++ .../lib/services/renderer/renderer.service.ts | 19 -- 10 files changed, 409 insertions(+), 86 deletions(-) create mode 100644 apps/angular-demo/src/app/data/default-model.ts create mode 100644 apps/angular-demo/src/app/data/generate-model.ts create mode 100644 apps/angular-demo/src/app/data/virtualization-test.config.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-performance-logger.ts diff --git a/apps/angular-demo/src/app/app.component.html b/apps/angular-demo/src/app/app.component.html index 4083bd95f..ab11f6359 100644 --- a/apps/angular-demo/src/app/app.component.html +++ b/apps/angular-demo/src/app/app.component.html @@ -16,6 +16,6 @@ > - + diff --git a/apps/angular-demo/src/app/app.component.ts b/apps/angular-demo/src/app/app.component.ts index 3a57cf731..ed91e4cae 100644 --- a/apps/angular-demo/src/app/app.component.ts +++ b/apps/angular-demo/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, Injector } from '@angular/core'; import { ClipboardPastedEvent, configureShortcuts, @@ -23,8 +23,11 @@ import { type Node, type Port, } from 'ng-diagram'; +import { defaultModel } from './data/default-model'; +import { generateModel } from './data/generate-model'; import { nodeTemplateMap } from './data/node-template'; import { paletteModel } from './data/palette-model'; +import { virtualizationConfigOverrides, virtualizationTestConfig } from './data/virtualization-test.config'; import { ButtonEdgeComponent } from './edge-template/button-edge/button-edge.component'; import { CustomPolylineEdgeComponent } from './edge-template/custom-polyline-edge/custom-polyline-edge.component'; import { DashedEdgeComponent } from './edge-template/dashed-edge/dashed-edge.component'; @@ -41,6 +44,8 @@ import { ToolbarComponent } from './toolbar/toolbar.component'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent { + private readonly injector = inject(Injector); + paletteModel: NgDiagramPaletteItem[] = paletteModel; nodeTemplateMap: NgDiagramNodeTemplateMap = nodeTemplateMap; edgeTemplateMap = new NgDiagramEdgeTemplateMap([ @@ -50,9 +55,16 @@ export class AppComponent { ['dashed-edge', DashedEdgeComponent], ]); - config = { + config: NgDiagramConfig = { zoom: { max: 2, + zoomToFit: { + onInit: true, + padding: [50, 50, 100, 350], + }, + }, + resize: { + allowResizeBelowChildrenBounds: false, }, background: { cellSize: { width: 10, height: 10 }, @@ -60,9 +72,6 @@ export class AppComponent { snapping: { shouldSnapDragForNode: () => true, }, - virtualization: { - enabled: true, - }, shortcuts: configureShortcuts([ { actionName: 'keyboardMoveSelectionUp', @@ -81,7 +90,21 @@ export class AppComponent { bindings: [{ key: 'd' }, { key: 'ArrowRight' }], }, ]), - } satisfies NgDiagramConfig; + }; + + model = initializeModel(defaultModel); + + enableVirtualizationTest(): void { + this.config = { + ...this.config, + ...virtualizationConfigOverrides, + zoom: { + ...this.config.zoom, + zoomToFit: undefined, + }, + }; + this.model = initializeModel(generateModel(virtualizationTestConfig.nodeCount), this.injector); + } onDiagramInit(event: DiagramInitEvent): void { console.log('INIT'); @@ -177,62 +200,4 @@ export class AppComponent { previousAngle: event.previousAngle, }); } - - // Generate 20k nodes in a grid pattern for virtualization testing - model = initializeModel(this.generateLargeModel(5000)); - - private generateLargeModel(nodeCount: number): { nodes: Node[]; edges: Edge[] } { - const nodes: Node[] = []; - const edges: Edge[] = []; - - // Calculate grid dimensions (roughly square) - const cols = Math.ceil(Math.sqrt(nodeCount)); - const rows = Math.ceil(nodeCount / cols); - - // Node spacing - const spacingX = 200; - const spacingY = 150; - - // Generate nodes in a grid - for (let i = 0; i < nodeCount; i++) { - const row = Math.floor(i / cols); - const col = i % cols; - - nodes.push({ - id: `node-${i}`, - position: { - x: col * spacingX, - y: row * spacingY, - }, - data: { label: `Node ${i}` }, - }); - - // Create horizontal edge (to the right neighbor) - if (col < cols - 1 && i + 1 < nodeCount) { - edges.push({ - id: `edge-h-${i}`, - source: `node-${i}`, - target: `node-${i + 1}`, - sourcePort: 'port-right', - targetPort: 'port-left', - data: {}, - }); - } - - // Create vertical edge (to the bottom neighbor) - only every 5th row to reduce edge count - if (row < rows - 1 && i + cols < nodeCount && col % 5 === 0) { - edges.push({ - id: `edge-v-${i}`, - source: `node-${i}`, - target: `node-${i + cols}`, - sourcePort: 'port-right', - targetPort: 'port-left', - data: {}, - }); - } - } - - console.log(`Generated ${nodes.length} nodes and ${edges.length} edges`); - return { nodes, edges }; - } } diff --git a/apps/angular-demo/src/app/data/default-model.ts b/apps/angular-demo/src/app/data/default-model.ts new file mode 100644 index 000000000..90d173b6a --- /dev/null +++ b/apps/angular-demo/src/app/data/default-model.ts @@ -0,0 +1,256 @@ +import { type Edge, type Node } from 'ng-diagram'; + +export interface DiagramModel { + nodes: Node[]; + edges: Edge[]; +} + +export const defaultModel: DiagramModel = { + nodes: [ + { + id: '1', + type: 'image', + position: { x: 100, y: 50 }, + data: { imageUrl: 'https://tinyurl.com/bddnt44s' }, + }, + { id: '2', type: 'input-field', position: { x: 400, y: 100 }, data: {} }, + { + id: '3', + type: 'resizable', + position: { x: 700, y: 200 }, + size: { width: 300, height: 400 }, + autoSize: false, + data: {}, + angle: 45, + }, + { + id: '4', + position: { x: 800, y: 350 }, + data: {}, + isGroup: true, + }, + { + id: '5', + position: { x: 100, y: 250 }, + data: { label: 'edge is manual' }, + }, + { + id: '6', + position: { x: 463, y: 382 }, + data: { label: "so it's ok it's unconnected after move" }, + }, + { + id: '7', + position: { x: 100, y: 450 }, + data: {}, + }, + { + id: '8', + position: { x: 550, y: 550 }, + data: {}, + }, + { + id: '9', + position: { x: 100, y: 650 }, + data: { label: 'just bezier edge' }, + }, + { + id: '10', + position: { x: 450, y: 750 }, + data: {}, + }, + { + id: '11', + position: { x: 600, y: 650 }, + data: { label: 'test linking' }, + }, + { + id: '12', + position: { x: 1000, y: 550 }, + data: {}, + }, + { + id: '12', + position: { x: 700, y: 750 }, + data: {}, + type: 'customized-default', + resizable: true, + rotatable: true, + }, + { + id: '13', + position: { x: 1200, y: 250 }, + data: { text: 'SN 777888' }, + type: 'chip', + autoSize: false, + size: { width: 200, height: 200 }, + }, + { + id: '14', + position: { x: 1200, y: 550 }, + data: { text: 'SN 877889' }, + type: 'chip', + autoSize: false, + size: { width: 200, height: 300 }, + }, + { + id: '15', + position: { x: -200, y: 500 }, + data: {}, + size: { width: 300, height: 200 }, + autoSize: false, + type: 'custom-group', + isGroup: true, + rotatable: true, + }, + ], + edges: [ + { + id: '1', + source: '1', + target: '2', + data: {}, + sourcePort: 'port-right', + targetPort: 'port-left', + routing: 'orthogonal', + }, + { + id: '2', + source: '2', + target: '3', + data: {}, + sourcePort: 'port-right', + targetPort: 'port-left-1', + type: 'button-edge', + }, + { + id: '3', + source: '5', + target: '6', + data: { labelPosition: '0.45' }, + sourcePort: 'port-right', + targetPort: 'port-left', + type: 'labelled-edge', + routing: 'orthogonal', + routingMode: 'manual', + points: [ + { x: 300, y: 274 }, + { x: 380, y: 274 }, + { x: 380, y: 314 }, + { x: 420, y: 314 }, + { x: 420, y: 354 }, + { x: 380, y: 354 }, + { x: 380, y: 407 }, + { x: 460, y: 407 }, + ], + }, + { + id: '4', + source: '7', + target: '8', + data: {}, + sourcePort: 'port-right', + targetPort: 'port-left', + type: 'custom-polyline-edge', + }, + { + id: '5', + source: '9', + target: '10', + data: { labelPosition: 0.7 }, + type: 'labelled-edge', + routing: 'bezier', + }, + { + id: '6', + source: '11', + target: '12', + data: {}, + sourcePort: 'port-right', + targetPort: 'port-left', + type: 'dashed-edge', + routing: 'orthogonal', + }, + { + id: '11', + data: {}, + source: '13', + sourcePort: 'port-left-4', + target: '14', + targetPort: 'port-left-1', + targetArrowhead: 'ng-diagram-arrow', + }, + { + id: '12', + data: {}, + source: '13', + sourcePort: 'port-left-4', + target: '14', + targetPort: 'port-left-2', + targetArrowhead: 'ng-diagram-arrow', + }, + { + id: '13', + data: {}, + source: '13', + sourcePort: 'port-left-4', + target: '14', + targetPort: 'port-left-3', + targetArrowhead: 'ng-diagram-arrow', + }, + { + id: '14', + data: {}, + source: '14', + sourcePort: 'port-right-3', + target: '13', + targetPort: 'port-right-4', + targetArrowhead: 'ng-diagram-arrow', + }, + { + id: '15', + data: {}, + source: '13', + sourcePort: 'port-right-3', + target: '14', + targetPort: 'port-right-4', + targetArrowhead: 'ng-diagram-arrow', + }, + { + id: '16', + data: {}, + source: '13', + sourcePort: 'port-right-2', + target: '14', + targetPort: 'port-right-1', + targetArrowhead: 'ng-diagram-arrow', + }, + { + id: '17', + data: {}, + source: '14', + sourcePort: 'port-right-2', + target: '13', + targetPort: 'port-right-2', + targetArrowhead: 'ng-diagram-arrow', + }, + { + id: '18', + data: {}, + source: '13', + sourcePort: 'port-right-1', + target: '14', + targetPort: 'port-left-4', + targetArrowhead: 'ng-diagram-arrow', + }, + { + id: '19', + data: {}, + source: '13', + sourcePort: 'port-left-2', + target: '14', + targetPort: 'port-right-1', + targetArrowhead: 'ng-diagram-arrow', + }, + ], +}; diff --git a/apps/angular-demo/src/app/data/generate-model.ts b/apps/angular-demo/src/app/data/generate-model.ts new file mode 100644 index 000000000..d1423799a --- /dev/null +++ b/apps/angular-demo/src/app/data/generate-model.ts @@ -0,0 +1,51 @@ +import { type Edge, type Node } from 'ng-diagram'; + +export function generateModel(nodeCount: number): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = []; + const edges: Edge[] = []; + + const cols = Math.ceil(Math.sqrt(nodeCount)); + const rows = Math.ceil(nodeCount / cols); + + const spacingX = 200; + const spacingY = 150; + + for (let i = 0; i < nodeCount; i++) { + const row = Math.floor(i / cols); + const col = i % cols; + + nodes.push({ + id: `node-${i}`, + position: { + x: col * spacingX, + y: row * spacingY, + }, + data: { label: `Node ${i}` }, + }); + + if (col < cols - 1 && i + 1 < nodeCount) { + edges.push({ + id: `edge-h-${i}`, + source: `node-${i}`, + target: `node-${i + 1}`, + sourcePort: 'port-right', + targetPort: 'port-left', + data: {}, + }); + } + + if (row < rows - 1 && i + cols < nodeCount && col % 5 === 0) { + edges.push({ + id: `edge-v-${i}`, + source: `node-${i}`, + target: `node-${i + cols}`, + sourcePort: 'port-right', + targetPort: 'port-left', + data: {}, + }); + } + } + + console.log(`Generated ${nodes.length} nodes and ${edges.length} edges`); + return { nodes, edges }; +} diff --git a/apps/angular-demo/src/app/data/virtualization-test.config.ts b/apps/angular-demo/src/app/data/virtualization-test.config.ts new file mode 100644 index 000000000..cffbb5883 --- /dev/null +++ b/apps/angular-demo/src/app/data/virtualization-test.config.ts @@ -0,0 +1,12 @@ +import { type NgDiagramConfig } from 'ng-diagram'; + +export const virtualizationTestConfig = { + /** Number of nodes to generate for virtualization test */ + nodeCount: 5000, +} as const; + +export const virtualizationConfigOverrides: Partial = { + virtualization: { + enabled: true, + }, +}; diff --git a/apps/angular-demo/src/app/toolbar/toolbar.component.html b/apps/angular-demo/src/app/toolbar/toolbar.component.html index 4e250c93a..e2f4bae11 100644 --- a/apps/angular-demo/src/app/toolbar/toolbar.component.html +++ b/apps/angular-demo/src/app/toolbar/toolbar.component.html @@ -5,4 +5,5 @@ + diff --git a/apps/angular-demo/src/app/toolbar/toolbar.component.ts b/apps/angular-demo/src/app/toolbar/toolbar.component.ts index 059b92ffb..c1ee9909d 100644 --- a/apps/angular-demo/src/app/toolbar/toolbar.component.ts +++ b/apps/angular-demo/src/app/toolbar/toolbar.component.ts @@ -24,6 +24,7 @@ export class ToolbarComponent { private readonly nodeTypes = Array.from(nodeTemplateMap.keys()) as NodeTemplateType[]; toggleDebugModeClick = output(); + testVirtualizationClick = output(); isNodeSelected = computed(() => this.ngDiagramSelectionService.selection().nodes.length > 0); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts index d562dfa85..ce59cae4e 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts @@ -1,9 +1,14 @@ import type { FlowCore } from '../flow-core'; import type { Edge, Node, Viewport } from '../types'; +import { RenderPerformanceLogger } from './render-performance-logger'; import type { RenderStrategy, RenderStrategyResult } from './render-strategy.interface'; export abstract class BaseRenderStrategy implements RenderStrategy { - constructor(protected readonly flowCore: FlowCore) {} + private readonly performanceLogger: RenderPerformanceLogger; + + constructor(protected readonly flowCore: FlowCore) { + this.performanceLogger = new RenderPerformanceLogger(); + } abstract init(): void; @@ -17,6 +22,10 @@ export abstract class BaseRenderStrategy implements RenderStrategy { const finalEdges = temporaryEdge?.temporary ? [...visibleEdges, temporaryEdge] : visibleEdges; - this.flowCore.renderer.draw(visibleNodes, finalEdges, metadata.viewport); + this.performanceLogger.withPerformanceLogging( + () => this.flowCore.renderer.draw(visibleNodes, finalEdges, metadata.viewport), + visibleNodes, + this.flowCore.config.debugMode + ); } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-performance-logger.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-performance-logger.ts new file mode 100644 index 000000000..1f8dbd6d0 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-performance-logger.ts @@ -0,0 +1,47 @@ +import type { Node } from '../types'; + +export class RenderPerformanceLogger { + private lastVisibleNodeIds: Set | null = null; + + withPerformanceLogging(drawFn: () => void, nodes: Node[], enabled: boolean): void { + if (!enabled) { + drawFn(); + return; + } + + const { added, removed, total } = this.calculateNodeDiff(nodes); + const shouldLog = added > 0 || removed > 0; + + const startTime = performance.now(); + drawFn(); + + if (shouldLog) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + console.log( + `[ngDiagram] Render: +${added} -${removed} nodes (total: ${total}): ${(performance.now() - startTime).toFixed(2)}ms` + ); + }); + }); + } + } + + private calculateNodeDiff(nodes: Node[]): { added: number; removed: number; total: number } { + const currentNodeIds = new Set(nodes.map((n) => n.id)); + const prevNodeIds = this.lastVisibleNodeIds ?? new Set(); + + let added = 0; + let removed = 0; + + for (const id of currentNodeIds) { + if (!prevNodeIds.has(id)) added++; + } + for (const id of prevNodeIds) { + if (!currentNodeIds.has(id)) removed++; + } + + this.lastVisibleNodeIds = currentNodeIds; + + return { added, removed, total: currentNodeIds.size }; + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts index 82b1d3eda..c7596913f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/renderer/renderer.service.ts @@ -18,28 +18,9 @@ export class RendererService implements Renderer { scale: 1, }); - private lastNodeCount = 0; - draw(nodes: Node[], edges: Edge[], viewport: Viewport): void { - const prevCount = this.lastNodeCount; - const currentCount = nodes.length; - const nodeCountDiff = currentCount - prevCount; - this.nodes.set(nodes); this.edges.set(edges); this.viewport.set(viewport); - this.lastNodeCount = currentCount; - - if (nodeCountDiff > 0) { - const startTime = performance.now(); - - requestAnimationFrame(() => { - requestAnimationFrame(() => { - console.log( - `[PERF] Render +${nodeCountDiff} nodes (${prevCount} -> ${currentCount}): ${(performance.now() - startTime).toFixed(2)}ms` - ); - }); - }); - } } } From e8dc1395281de2c831a76ebcdf4dea017bdc34e4 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Wed, 14 Jan 2026 14:05:55 +0100 Subject: [PATCH 24/42] Fix unit-tests --- .../command-handler/commands/__tests__/move-viewport.test.ts | 3 +++ .../command-handler/commands/__tests__/zoom-to-fit.test.ts | 1 + .../src/core/src/updater/init-updater/stability-detector.ts | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/move-viewport.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/move-viewport.test.ts index 573bce6bf..2e4406cf0 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/move-viewport.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/move-viewport.test.ts @@ -14,6 +14,9 @@ describe('Move Viewport Commands', () => { flowCore = { getState: vi.fn(), applyUpdate: vi.fn(), + actionStateManager: { + isPanning: vi.fn().mockReturnValue(false), + }, } as unknown as FlowCore; commandHandler = new CommandHandler(flowCore); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/zoom-to-fit.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/zoom-to-fit.test.ts index 8d493cabc..adcd247a9 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/zoom-to-fit.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/zoom-to-fit.test.ts @@ -15,6 +15,7 @@ describe('zoomToFit command', () => { applyUpdate: mockApplyUpdate, config: { zoom: { min: 0.1, max: 2, step: 0.1, zoomToFit: { padding: 20 } }, + virtualization: { enabled: false }, }, }, } as unknown as CommandHandler; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/stability-detector.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/stability-detector.ts index 66de66b55..ea78f8e0a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/stability-detector.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/stability-detector.ts @@ -91,9 +91,9 @@ export class StabilityDetector { clearTimeout(this.stabilityTimeout); } - this.stabilityTimeout = window.setTimeout(() => { + this.stabilityTimeout = setTimeout(() => { this.resolveStability(); this.stabilityTimeout = null; - }, this.stabilityDelay); + }, this.stabilityDelay) as unknown as number; } } From 2207de8d7d7530c5e5a6d8d80ac57f33341d70b1 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 15 Jan 2026 12:32:13 +0100 Subject: [PATCH 25/42] Update public API --- .../ng-diagram/api-report/ng-diagram.api.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index 3e0280d3f..3d5b2eeae 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -24,6 +24,7 @@ export interface ActionState { dragging?: DraggingActionState; highlightGroup?: HighlightGroupActionState; linking?: LinkingActionState; + panning?: PanningActionState; resize?: ResizeActionState; rotation?: RotationActionState; } @@ -35,6 +36,7 @@ export class ActionStateManager { clearDragging(): void; clearHighlightGroup(): void; clearLinking(): void; + clearPanning(): void; clearResize(): void; clearRotation(): void; get copyPaste(): CopyPasteActionState | undefined; @@ -46,10 +48,13 @@ export class ActionStateManager { set highlightGroup(value: HighlightGroupActionState | undefined); isDragging(): boolean; isLinking(): boolean; + isPanning(): boolean; isResizing(): boolean; isRotating(): boolean; get linking(): LinkingActionState | undefined; set linking(value: LinkingActionState | undefined); + get panning(): PanningActionState | undefined; + set panning(value: PanningActionState | undefined); get resize(): ResizeActionState | undefined; set resize(value: ResizeActionState | undefined); get rotation(): RotationActionState | undefined; @@ -156,7 +161,7 @@ export interface DiagramInitEvent { // @public (undocumented) export class DiagramSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | Node_2 | undefined>; + readonly targetData: InputSignal | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -267,7 +272,7 @@ export type EdgeRoutingName = LooseAutocomplete; // @public (undocumented) export class EdgeSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | Node_2 | undefined>; + readonly targetData: InputSignal | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -302,6 +307,7 @@ export interface FlowConfig { shortcuts: ShortcutDefinition[]; snapping: SnappingConfig; viewportPanningEnabled: boolean; + virtualization: VirtualizationConfig; zIndex: ZIndexConfig; zoom: ZoomConfig; } @@ -326,6 +332,10 @@ export interface FlowStateUpdate { nodesToUpdate?: (Partial & { id: Node_2['id']; })[]; + // @internal + renderedEdgeIds?: string[]; + // @internal + renderedNodeIds?: string[]; } // @public @@ -1082,7 +1092,7 @@ export interface NodeRotationConfig { // @public (undocumented) export class NodeSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | Node_2 | undefined>; + readonly targetData: InputSignal | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -1373,7 +1383,7 @@ export interface ZIndexConfig { // @public (undocumented) export class ZIndexDirective { // (undocumented) - data: InputSignal | Node_2>; + data: InputSignal>; // (undocumented) zIndex: Signal; // (undocumented) From 956bf12eab2861230e9c63b4438b87810fa36504 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 15 Jan 2026 13:53:55 +0100 Subject: [PATCH 26/42] update public API file --- packages/ng-diagram/api-report/ng-diagram.api.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index 3d5b2eeae..703d14114 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -161,7 +161,7 @@ export interface DiagramInitEvent { // @public (undocumented) export class DiagramSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | undefined>; + readonly targetData: InputSignal | Node_2 | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -272,7 +272,7 @@ export type EdgeRoutingName = LooseAutocomplete; // @public (undocumented) export class EdgeSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | undefined>; + readonly targetData: InputSignal | Node_2 | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -1092,7 +1092,7 @@ export interface NodeRotationConfig { // @public (undocumented) export class NodeSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | undefined>; + readonly targetData: InputSignal | Node_2 | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -1383,7 +1383,7 @@ export interface ZIndexConfig { // @public (undocumented) export class ZIndexDirective { // (undocumented) - data: InputSignal>; + data: InputSignal | Node_2>; // (undocumented) zIndex: Signal; // (undocumented) From 7fa51cf87370ff74b3bff850f3be85df97dbe630 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 15 Jan 2026 14:03:21 +0100 Subject: [PATCH 27/42] Add the article about virtualization to the docs --- .../content/docs/guides/virtualization.mdx | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 apps/docs/src/content/docs/guides/virtualization.mdx diff --git a/apps/docs/src/content/docs/guides/virtualization.mdx b/apps/docs/src/content/docs/guides/virtualization.mdx new file mode 100644 index 000000000..2c2e81e28 --- /dev/null +++ b/apps/docs/src/content/docs/guides/virtualization.mdx @@ -0,0 +1,95 @@ +--- +version: 'since v0.9.1' +title: Virtualization +description: Optimizing performance for large diagrams with viewport virtualization +--- + +Virtualization is a performance optimization technique that renders only the nodes and edges visible within the current viewport. For diagrams with hundreds or thousands of elements, this dramatically improves rendering performance and responsiveness. + +## How It Works + +When virtualization is enabled, ngDiagram calculates which elements are visible in the current viewport (plus a configurable padding area) and only renders those elements. As you pan or zoom around the diagram, elements are dynamically added and removed from the DOM. + +**What to expect:** + +- Nodes and edges outside the viewport are not rendered until you pan to them +- During fast panning, you may briefly see empty areas before elements appear +- Elements are rendered after a short idle delay once panning stops +- The virtual viewport (rendering area) extends beyond the visible viewport by a configurable padding + +## Enabling Virtualization + +To enable virtualization, set the `enabled` property in your diagram configuration: + +```typescript +import { NgDiagramConfig } from 'ng-diagram'; + +const config: NgDiagramConfig = { + virtualization: { + enabled: true, + }, +}; +``` + +## Configuration Options + +The virtualization behavior can be fine-tuned with the following options: + +### enabled + +Whether viewport virtualization is active. + +```typescript +virtualization: { + enabled: true, // default: false +} +``` + +### padding + +Controls the size of the virtual viewport beyond the visible area. This is a multiplier relative to viewport size. For example, `0.5` means the rendering area extends by 50% of the viewport size in each direction. + +```typescript +virtualization: { + enabled: true, + padding: 0.5, // default: 0.5 +} +``` + +A larger padding value means more elements are pre-rendered outside the visible area, reducing the chance of seeing empty spaces during panning. However, this comes at the cost of rendering more elements, which may impact performance. + +### nodeCountThreshold + +The minimum number of nodes required for virtualization to activate. If your diagram has fewer nodes than this threshold, all nodes are rendered regardless of the virtualization setting. + +```typescript +virtualization: { + enabled: true, + nodeCountThreshold: 500, // default: 500 +} +``` + +This prevents the overhead of virtualization calculations for smaller diagrams where it provides no benefit. + +### idleDelay + +The delay in milliseconds after panning stops before re-rendering visible nodes. + +```typescript +virtualization: { + enabled: true, + idleDelay: 100, // default: 100 +} +``` + +## Performance Considerations + +When configuring virtualization, consider these trade-offs: + +| Setting | Higher Value | Lower Value | +| -------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | +| `padding` | More pre-rendered elements, smoother panning, higher memory usage | Fewer elements rendered, may see empty areas during fast panning | +| `nodeCountThreshold` | Virtualization activates only for very large diagrams | Virtualization activates for smaller diagrams | +| `idleDelay` | Longer wait before rendering, fewer intermediate renders | Faster visual feedback, more frequent renders | + +For most use cases, the default values provide a good balance between performance and visual smoothness. \ No newline at end of file From 1d09228c80751e3986e32f96f28bde2d93937056 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 15 Jan 2026 14:24:30 +0100 Subject: [PATCH 28/42] Fix doc --- apps/docs/src/content/docs/guides/virtualization.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/docs/src/content/docs/guides/virtualization.mdx b/apps/docs/src/content/docs/guides/virtualization.mdx index 2c2e81e28..867240a94 100644 --- a/apps/docs/src/content/docs/guides/virtualization.mdx +++ b/apps/docs/src/content/docs/guides/virtualization.mdx @@ -33,7 +33,9 @@ const config: NgDiagramConfig = { ## Configuration Options -The virtualization behavior can be fine-tuned with the following options: +Virtualization behavior can be configured via the [`virtualization`](/docs/api/types/configuration/flowconfig/#virtualization) property in your diagram config. + +The following options are available: ### enabled @@ -92,4 +94,4 @@ When configuring virtualization, consider these trade-offs: | `nodeCountThreshold` | Virtualization activates only for very large diagrams | Virtualization activates for smaller diagrams | | `idleDelay` | Longer wait before rendering, fewer intermediate renders | Faster visual feedback, more frequent renders | -For most use cases, the default values provide a good balance between performance and visual smoothness. \ No newline at end of file +For most use cases, the default values provide a good balance between performance and visual smoothness. From 75d11a7abaa4ee874f9fc1373db6df89300dc6e5 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 15 Jan 2026 14:39:11 +0100 Subject: [PATCH 29/42] add missing docs --- .../Features/VirtualizationConfig.md | 71 +++++++++++++++++++ .../api/Types/Configuration/FlowConfig.md | 2 +- apps/docs/src/content/docs/api/_readme.md | 1 + .../core/src/types/flow-config.interface.ts | 2 + .../projects/ng-diagram/src/public-api.ts | 1 + 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/content/docs/api/Types/Configuration/Features/VirtualizationConfig.md diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/VirtualizationConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/VirtualizationConfig.md new file mode 100644 index 000000000..9dbd84cdd --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/VirtualizationConfig.md @@ -0,0 +1,71 @@ +--- +version: "since v0.9.1" +editUrl: false +next: false +prev: false +title: "VirtualizationConfig" +--- + +Configuration for viewport virtualization behavior. +When enabled, only nodes and edges visible in the viewport (plus padding) are rendered, +significantly improving performance for large diagrams. + +## Properties + +### enabled + +> **enabled**: `boolean` + +Whether viewport virtualization is enabled. +When disabled, all nodes/edges are rendered regardless of viewport. + +#### Default + +```ts +false +``` + +*** + +### idleDelay? + +> `optional` **idleDelay**: `number` + +Delay in milliseconds after panning stops before re-rendering visible nodes. + +#### Default + +```ts +100 +``` + +*** + +### nodeCountThreshold + +> **nodeCountThreshold**: `number` + +Maximum number of nodes below which virtualization is skipped. +If fewer nodes exist than this threshold, render all nodes. + +#### Default + +```ts +500 +``` + +*** + +### padding + +> **padding**: `number` + +Padding multiplier relative to viewport size. +The actual padding is calculated as: max(viewportWidth, viewportHeight) * padding +For example, 0.5 means 50% of the viewport size as padding in each direction. + +#### Default + +```ts +0.5 +``` diff --git a/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md index e6a4e527b..7665427a4 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md @@ -176,7 +176,7 @@ true ### virtualization -> **virtualization**: `VirtualizationConfig` +> **virtualization**: [`VirtualizationConfig`](/docs/api/types/configuration/features/virtualizationconfig/) Configuration for viewport virtualization. Improves performance for large diagrams by only rendering visible elements. diff --git a/apps/docs/src/content/docs/api/_readme.md b/apps/docs/src/content/docs/api/_readme.md index da8bc80ab..590983698 100644 --- a/apps/docs/src/content/docs/api/_readme.md +++ b/apps/docs/src/content/docs/api/_readme.md @@ -67,6 +67,7 @@ title: "ng-diagram" - [ResizeConfig](/docs/api/types/configuration/features/resizeconfig/) - [SelectionMovingConfig](/docs/api/types/configuration/features/selectionmovingconfig/) - [SnappingConfig](/docs/api/types/configuration/features/snappingconfig/) +- [VirtualizationConfig](/docs/api/types/configuration/features/virtualizationconfig/) - [ZIndexConfig](/docs/api/types/configuration/features/zindexconfig/) - [ZoomConfig](/docs/api/types/configuration/features/zoomconfig/) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index 5f1dc646b..1214e39be 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -424,6 +424,8 @@ export interface BoxSelectionConfig { * When enabled, only nodes and edges visible in the viewport (plus padding) are rendered, * significantly improving performance for large diagrams. * + * @public + * @since 0.9.1 * @category Types/Configuration/Features */ export interface VirtualizationConfig { diff --git a/packages/ng-diagram/projects/ng-diagram/src/public-api.ts b/packages/ng-diagram/projects/ng-diagram/src/public-api.ts index dc267c75e..bfceac49f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/public-api.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/public-api.ts @@ -146,6 +146,7 @@ export type { TransactionResult, Viewport, ViewportChangedEvent, + VirtualizationConfig, ZIndexConfig, ZoomConfig, } from './core/src'; From e7ca06d76e609393912026e2b0e35946e71ff312 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 15 Jan 2026 15:17:22 +0100 Subject: [PATCH 30/42] fix style --- apps/angular-demo/src/app/app.component.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/angular-demo/src/app/app.component.html b/apps/angular-demo/src/app/app.component.html index 48bc0488f..9c7869ef9 100644 --- a/apps/angular-demo/src/app/app.component.html +++ b/apps/angular-demo/src/app/app.component.html @@ -16,6 +16,9 @@ > - + From c35d3db154b51d0f91469447c9056e3324458e8c Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 15 Jan 2026 15:36:43 +0100 Subject: [PATCH 31/42] update public api --- packages/ng-diagram/api-report/ng-diagram.api.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index 31c83054b..27f72e105 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -1421,6 +1421,14 @@ export class ViewportDirective { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export interface VirtualizationConfig { + enabled: boolean; + idleDelay?: number; + nodeCountThreshold: number; + padding: number; +} + // @public export interface ZIndexConfig { edgesAboveConnectedNodes: boolean; From 0bd4ad1110e8e93bfac90d342f1294afaf8393b9 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 15 Jan 2026 17:21:50 +0100 Subject: [PATCH 32/42] port deletion cleanup --- .../commands/add-update-delete.ts | 2 ++ .../ng-diagram/src/core/src/flow-core.ts | 12 +++++++++ .../render-strategy/base-render-strategy.ts | 2 ++ .../direct/direct-render-strategy.ts | 5 ++++ .../render-strategy.interface.ts | 6 +++++ .../virtualized/result-cache.ts | 4 +++ .../virtualized-render-strategy.ts | 4 +++ .../src/updater/init-updater/init-updater.ts | 18 ------------- .../init-updater/late-arrival-queue.ts | 4 --- .../direct-port-update-strategy.ts | 4 --- .../internal-updater/internal-updater.ts | 9 ------- .../port-update-strategy.interface.ts | 7 ----- .../src/core/src/updater/updater.interface.ts | 5 ---- .../port/ng-diagram-port.component.ts | 26 +++++++++++++++++-- 14 files changed, 59 insertions(+), 49 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts index ab3403ebc..941e03fbe 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts @@ -275,6 +275,8 @@ export const deletePortsBulk = async (commandHandler: CommandHandler, command: D const { deletions } = command; const nodesToUpdate: { id: string; measuredPorts: Port[] }[] = []; + console.log('deletePortsBulk', deletions); + deletions.forEach((portIds, nodeId) => { const node = commandHandler.flowCore.getNodeById(nodeId); if (!node) { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts index 67d396e0a..e892dbbab 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts @@ -554,6 +554,18 @@ export class FlowCore { return getOverlappingNodes(this, nodeOrId as Node & string); } + /** + * Checks if a node is currently rendered (visible in viewport). + * In direct mode, always returns true. In virtualized mode, checks if the node + * is in the current set of visible nodes. + * + * @param nodeId Node id to check + * @returns True if the node is currently rendered + */ + isNodeCurrentlyRendered(nodeId: string): boolean { + return this.renderStrategy.isNodeRendered(nodeId); + } + /** * Returns the current zoom scale */ diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts index ce59cae4e..0b1a4a681 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts @@ -14,6 +14,8 @@ export abstract class BaseRenderStrategy implements RenderStrategy { abstract process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult; + abstract isNodeRendered(nodeId: string): boolean; + protected render(): void { const { nodes, edges, metadata } = this.flowCore.getState(); const temporaryEdge = this.flowCore.actionStateManager.linking?.temporaryEdge; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts index fe6bc6f09..9a7ed65ac 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/direct/direct-render-strategy.ts @@ -37,4 +37,9 @@ export class DirectRenderStrategy extends BaseRenderStrategy { process(nodes: Node[], edges: Edge[]): RenderStrategyResult { return { nodes, edges, nodeIds: EMPTY_SET, edgeIds: EMPTY_SET }; } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isNodeRendered(_nodeId: string): boolean { + return true; // All nodes are always rendered in direct mode + } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts index 0a5139f01..3e0745658 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/render-strategy.interface.ts @@ -13,4 +13,10 @@ export interface RenderStrategy { */ init(): void; process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult; + + /** + * Checks if a node is currently rendered (visible in viewport). + * In direct mode, always returns true. In virtualized mode, checks the cached visible nodes. + */ + isNodeRendered(nodeId: string): boolean; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/result-cache.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/result-cache.ts index f63afbea1..37daf276d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/result-cache.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/result-cache.ts @@ -16,6 +16,10 @@ export class ResultCache { return this.cachedResult !== null; } + isNodeInCache(nodeId: string): boolean { + return this.cachedResult?.nodeIds.has(nodeId) ?? false; + } + /** * Returns cached result by filtering from current arrays using cached IDs. * Always creates fresh filtered arrays to ensure consistency with rendered content. diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts index 9ba7aa0a2..81ed9ef02 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts @@ -96,6 +96,10 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { this.idleRenderScheduler.destroy(); } + isNodeRendered(nodeId: string): boolean { + return this.cache.isNodeInCache(nodeId); + } + private invalidateAndRender(): void { this.cache.invalidateViewport(); this.render(); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts index 83b6d8d8c..ff74b7b83 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/init-updater.ts @@ -146,24 +146,6 @@ export class InitUpdater implements Updater { this.portStabilityDetector.notify(); } - /** - * Deletes a port during initialization. - * Queues if finishing, otherwise delegates to internal updater since deletions - * during initialization are rare and don't need to be batched in init state. - * - * @param nodeId - The node ID the port belongs to - * @param portId - The port ID to delete - */ - deletePort(nodeId: string, portId: string): void { - if (this.lateArrivalQueue.isFinishing) { - this.lateArrivalQueue.enqueue({ method: 'deletePort', args: [nodeId, portId] }); - return; - } - - // During initialization, deletions are rare - delegate directly to internal updater - this.flowCore.internalUpdater.deletePort(nodeId, portId); - } - /** * Records port measurements (sizes and positions). * Queues if finishing, otherwise records all measurements and attempts to finish. diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/late-arrival-queue.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/late-arrival-queue.ts index c864526cb..43372db22 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/late-arrival-queue.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/init-updater/late-arrival-queue.ts @@ -7,7 +7,6 @@ import { Updater } from '../updater.interface'; */ export type LateArrival = | { method: 'addPort'; args: [nodeId: string, port: Port] } - | { method: 'deletePort'; args: [nodeId: string, portId: string] } | { method: 'addEdgeLabel'; args: [edgeId: string, label: EdgeLabel] } | { method: 'applyNodeSize'; args: [nodeId: string, size: NonNullable] } | { @@ -81,9 +80,6 @@ export class LateArrivalQueue { case 'addPort': updater.addPort(...lateArrival.args); break; - case 'deletePort': - updater.deletePort(...lateArrival.args); - break; case 'addEdgeLabel': updater.addEdgeLabel(...lateArrival.args); break; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/direct-port-update-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/direct-port-update-strategy.ts index 3018a9dea..9a495f62b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/direct-port-update-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/direct-port-update-strategy.ts @@ -15,10 +15,6 @@ export class DirectPortUpdateStrategy implements PortUpdateStrategy { }); } - deletePort(nodeId: string, portId: string): void { - this.flowCore.commandHandler.emit('deletePorts', { nodeId, portIds: [portId] }); - } - updatePorts(nodeId: string, ports: Pick[]): void { for (const { id, size, position } of ports) { this.flowCore.portBatchProcessor.processUpdate( diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts index fe1873548..27fc4b9e2 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts @@ -46,15 +46,6 @@ export class InternalUpdater implements Updater { this.portUpdateStrategy.addPort(nodeId, port); } - /** - * @internal - * Internal method to delete a port from the flow. - * In virtualization mode, ports persist in model (only DOM unmounts). - */ - deletePort(nodeId: string, portId: string): void { - this.portUpdateStrategy.deletePort?.(nodeId, portId); - } - /** * @internal * Internal method to apply a port size and position diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/port-update-strategy.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/port-update-strategy.interface.ts index 7e2dc62ee..dc8707d08 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/port-update-strategy.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/port-update-strategy.interface.ts @@ -10,13 +10,6 @@ export interface PortUpdateStrategy { */ addPort(nodeId: string, port: Port): void; - /** - * Delete a port from a node. - * Optional because virtualization mode doesn't delete ports on DOM unmount - - * ports persist in the model and are restored when the node scrolls back into view. - */ - deletePort?(nodeId: string, portId: string): void; - /** * Update port sizes and positions */ diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/updater.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/updater.interface.ts index 183c9d0dc..4e6be5ebc 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/updater.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/updater.interface.ts @@ -11,11 +11,6 @@ export interface Updater { */ addPort(nodeId: string, port: Port): void; - /** - * Delete a port from a node - */ - deletePort(nodeId: string, portId: string): void; - /** * Apply port size and position updates */ diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts index 13bb6c5ce..b3528c57d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts @@ -177,10 +177,32 @@ export class NgDiagramPortComponent extends NodeContextGuardBase implements OnIn return; } - // Always call deletePort - InternalUpdater handles virtualization logic - this.flowCoreProvider.provide().updater.deletePort(nodeData.id, this.id()); + const flowCore = this.flowCoreProvider.provide(); + + // Skip cleanup if FlowCore is still initializing + // (handles case where old components from previous render are destroyed + // while new FlowCore is initializing after model reinitialization) + if (!flowCore.isInitialized) { + return; + } this.batchResizeObserver.unobserve(this.hostElement.nativeElement); + + // Skip if node was deleted - ports are removed with the node + const nodeStillExists = flowCore.getNodeById(nodeData.id); + if (!nodeStillExists) { + return; + } + + // In virtualization mode, skip if node is just virtualized (scrolled out of view) + if (flowCore.config.virtualization.enabled && !flowCore.isNodeCurrentlyRendered(nodeData.id)) { + return; + } + + flowCore.commandHandler.emit('deletePorts', { + nodeId: nodeData.id, + portIds: [this.id()], + }); } private readonly custom = viewChild>('contentProjection'); From d1976c8d950ab9445088ac02a858b2b57289414b Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Fri, 16 Jan 2026 12:29:20 +0100 Subject: [PATCH 33/42] cleanup --- .../Features/VirtualizationConfig.md | 15 ------- .../content/docs/guides/virtualization.mdx | 39 ++++++++++--------- .../ng-diagram/api-report/ng-diagram.api.md | 1 - .../command-handler/commands/zoom-to-fit.ts | 2 +- .../src/flow-config/default-flow-config.ts | 1 - .../ng-diagram/src/core/src/flow-core.ts | 9 ++++- .../panning/panning-handler-factory.ts | 2 +- .../virtualized/viewport-utils.ts | 12 ++---- .../virtualized-render-strategy.test.ts | 15 ------- .../virtualized-render-strategy.ts | 9 ++++- .../core/src/types/flow-config.interface.ts | 7 ---- .../internal-updater/internal-updater.ts | 2 +- .../port/ng-diagram-port.component.ts | 2 +- 13 files changed, 44 insertions(+), 72 deletions(-) diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/VirtualizationConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/VirtualizationConfig.md index 9dbd84cdd..c28a3ca42 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/Features/VirtualizationConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/VirtualizationConfig.md @@ -41,21 +41,6 @@ Delay in milliseconds after panning stops before re-rendering visible nodes. *** -### nodeCountThreshold - -> **nodeCountThreshold**: `number` - -Maximum number of nodes below which virtualization is skipped. -If fewer nodes exist than this threshold, render all nodes. - -#### Default - -```ts -500 -``` - -*** - ### padding > **padding**: `number` diff --git a/apps/docs/src/content/docs/guides/virtualization.mdx b/apps/docs/src/content/docs/guides/virtualization.mdx index 867240a94..5afd95991 100644 --- a/apps/docs/src/content/docs/guides/virtualization.mdx +++ b/apps/docs/src/content/docs/guides/virtualization.mdx @@ -17,6 +17,10 @@ When virtualization is enabled, ngDiagram calculates which elements are visible - Elements are rendered after a short idle delay once panning stops - The virtual viewport (rendering area) extends beyond the visible viewport by a configurable padding +:::note +The `zoomToFit` command is disabled when virtualization is enabled, as it requires all elements to calculate bounds. +::: + ## Enabling Virtualization To enable virtualization, set the `enabled` property in your diagram configuration: @@ -31,6 +35,19 @@ const config: NgDiagramConfig = { }; ``` +## Changing Virtualization at Runtime + +The virtualization setting is applied during diagram initialization. To change the virtualization mode at runtime, you must update the config and reinitialize the model: + +```typescript +// Update config and reinitialize model +this.config = { + ...this.config, + virtualization: { enabled: true }, +}; +this.model = initializeModel(getModel(), this.injector); +``` + ## Configuration Options Virtualization behavior can be configured via the [`virtualization`](/docs/api/types/configuration/flowconfig/#virtualization) property in your diagram config. @@ -60,19 +77,6 @@ virtualization: { A larger padding value means more elements are pre-rendered outside the visible area, reducing the chance of seeing empty spaces during panning. However, this comes at the cost of rendering more elements, which may impact performance. -### nodeCountThreshold - -The minimum number of nodes required for virtualization to activate. If your diagram has fewer nodes than this threshold, all nodes are rendered regardless of the virtualization setting. - -```typescript -virtualization: { - enabled: true, - nodeCountThreshold: 500, // default: 500 -} -``` - -This prevents the overhead of virtualization calculations for smaller diagrams where it provides no benefit. - ### idleDelay The delay in milliseconds after panning stops before re-rendering visible nodes. @@ -88,10 +92,9 @@ virtualization: { When configuring virtualization, consider these trade-offs: -| Setting | Higher Value | Lower Value | -| -------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | -| `padding` | More pre-rendered elements, smoother panning, higher memory usage | Fewer elements rendered, may see empty areas during fast panning | -| `nodeCountThreshold` | Virtualization activates only for very large diagrams | Virtualization activates for smaller diagrams | -| `idleDelay` | Longer wait before rendering, fewer intermediate renders | Faster visual feedback, more frequent renders | +| Setting | Higher Value | Lower Value | +| ------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | +| `padding` | More pre-rendered elements, smoother panning, higher memory usage | Fewer elements rendered, may see empty areas during fast panning | +| `idleDelay` | Longer wait before rendering, fewer intermediate renders | Faster visual feedback, more frequent renders | For most use cases, the default values provide a good balance between performance and visual smoothness. diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index 27f72e105..b909dc0ed 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -1425,7 +1425,6 @@ export class ViewportDirective { export interface VirtualizationConfig { enabled: boolean; idleDelay?: number; - nodeCountThreshold: number; padding: number; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/zoom-to-fit.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/zoom-to-fit.ts index 5d6f50426..9b7a23f2e 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/zoom-to-fit.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/zoom-to-fit.ts @@ -109,7 +109,7 @@ const isValidViewport = (viewport: { width?: number; height?: number }): boolean * Calculates optimal viewport position and scale to fit specified content */ export const zoomToFit = async (commandHandler: CommandHandler, { nodeIds, edgeIds, padding }: ZoomToFitCommand) => { - if (commandHandler.flowCore.config.virtualization.enabled) { + if (commandHandler.flowCore.isVirtualizationActive) { console.warn(VIRTUALIZATION_WARNING); return; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts index c9dd41830..0815635c1 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts @@ -138,7 +138,6 @@ const defaultVirtualizationConfig: VirtualizationConfig = { enabled: false, padding: 0.5, idleDelay: 100, - nodeCountThreshold: 500, }; /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts index e892dbbab..5558479d2 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts @@ -211,7 +211,7 @@ export class FlowCore { * When disabled, uses DirectRenderStrategy. */ get renderStrategy(): RenderStrategy { - return this.config.virtualization.enabled ? this.virtualizedRenderStrategy : this.directRenderStrategy; + return this.isVirtualizationActive ? this.virtualizedRenderStrategy : this.directRenderStrategy; } /** @@ -591,6 +591,13 @@ export class FlowCore { return this.initUpdater.isInitialized; } + /** + * Returns true if virtualization is enabled. + */ + get isVirtualizationActive(): boolean { + return this.config.virtualization.enabled; + } + setDebugMode(debugMode: boolean): void { if (debugMode) { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning-handler-factory.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning-handler-factory.ts index 4ae64a60a..68dafe95a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning-handler-factory.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning-handler-factory.ts @@ -11,5 +11,5 @@ import { VirtualizedPanningEventHandler } from './virtualized-panning.handler'; * - Virtualized mode: Uses RAF throttling and buffer fill management for better performance */ export function panningHandlerFactory(flow: FlowCore): EventHandler { - return flow.config.virtualization.enabled ? new VirtualizedPanningEventHandler(flow) : new PanningEventHandler(flow); + return flow.isVirtualizationActive ? new VirtualizedPanningEventHandler(flow) : new PanningEventHandler(flow); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts index 67861461f..beb03196c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/viewport-utils.ts @@ -1,4 +1,4 @@ -import type { Node, Rect, Viewport, VirtualizationConfig } from '../../types'; +import type { Rect, Viewport } from '../../types'; const DEFAULT_VIEWPORT_WIDTH = 1920; const DEFAULT_VIEWPORT_HEIGHT = 1080; @@ -14,14 +14,10 @@ const PAN_DISTANCE_THRESHOLD = 0.4; const DIMENSION_CHANGE_TOLERANCE = 0.1; /** - * Checks if virtualization should be bypassed (too few nodes or no viewport). + * Checks if viewport is valid for virtualization. */ -export function shouldBypassVirtualization( - nodes: Node[], - viewport: Viewport | undefined, - config: VirtualizationConfig -): boolean { - return !viewport || nodes.length < config.nodeCountThreshold; +export function isViewportValid(viewport: Viewport | undefined): boolean { + return !!viewport; } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts index b00e267f1..5662a2802 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts @@ -22,7 +22,6 @@ describe('VirtualizedRenderStrategy', () => { const defaultConfig: VirtualizationConfig = { enabled: true, padding: 0.1, // 10% of viewport size as padding - nodeCountThreshold: 2, }; beforeEach(() => { @@ -65,20 +64,6 @@ describe('VirtualizedRenderStrategy', () => { } describe('bypass conditions', () => { - it('should return all nodes when node count is below threshold', () => { - config.nodeCountThreshold = 10; - const nodes: Node[] = [ - { ...mockNode, id: '1', position: { x: 0, y: 0 }, size: { width: 50, height: 50 } }, - { ...mockNode, id: '2', position: { x: 1000, y: 1000 }, size: { width: 50, height: 50 } }, - ]; - const edges: Edge[] = []; - spatialHash.process(nodes); - - const result = strategy.process(nodes, edges, defaultViewport); - - expect(result.nodes).toEqual(nodes); - }); - it('should return all nodes when viewport is undefined', () => { const nodes: Node[] = [ { ...mockNode, id: '1', position: { x: 0, y: 0 }, size: { width: 50, height: 50 } }, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts index 81ed9ef02..2a997f25a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts @@ -4,7 +4,7 @@ import { BaseRenderStrategy } from '../base-render-strategy'; import type { RenderStrategyResult } from '../render-strategy.interface'; import { IdleRenderScheduler } from './idle-render-scheduler'; import { ResultCache } from './result-cache'; -import { getViewportRect, shouldBypassVirtualization } from './viewport-utils'; +import { getViewportRect, isViewportValid } from './viewport-utils'; import { VisibleElementsResolver } from './visible-elements-resolver'; import { ZoomTracker } from './zoom-tracker'; @@ -62,7 +62,7 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { process(nodes: Node[], edges: Edge[], viewport: Viewport | undefined): RenderStrategyResult { const config = this.flowCore.config.virtualization; - if (shouldBypassVirtualization(nodes, viewport, config)) { + if (!isViewportValid(viewport)) { return { nodes, edges, nodeIds: EMPTY_SET, edgeIds: EMPTY_SET }; } @@ -97,6 +97,11 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { } isNodeRendered(nodeId: string): boolean { + // When viewport is invalid, all nodes are rendered + const viewport = this.flowCore.model.getMetadata().viewport; + if (!isViewportValid(viewport)) { + return true; + } return this.cache.isNodeInCache(nodeId); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index 1214e39be..e1ad4403d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -449,13 +449,6 @@ export interface VirtualizationConfig { * @default 100 */ idleDelay?: number; - - /** - * Maximum number of nodes below which virtualization is skipped. - * If fewer nodes exist than this threshold, render all nodes. - * @default 500 - */ - nodeCountThreshold: number; } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts index 27fc4b9e2..c15e5c240 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts @@ -11,7 +11,7 @@ export class InternalUpdater implements Updater { private readonly portUpdateStrategy: PortUpdateStrategy; constructor(private readonly flowCore: FlowCore) { - this.portUpdateStrategy = this.flowCore.config.virtualization.enabled + this.portUpdateStrategy = this.flowCore.isVirtualizationActive ? new VirtualizedPortUpdateStrategy(flowCore) : new DirectPortUpdateStrategy(flowCore); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts index b3528c57d..3798df072 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts @@ -195,7 +195,7 @@ export class NgDiagramPortComponent extends NodeContextGuardBase implements OnIn } // In virtualization mode, skip if node is just virtualized (scrolled out of view) - if (flowCore.config.virtualization.enabled && !flowCore.isNodeCurrentlyRendered(nodeData.id)) { + if (flowCore.isVirtualizationActive && !flowCore.isNodeCurrentlyRendered(nodeData.id)) { return; } From af80f093d7a8da8f19b181077f4ec0609fb7d145 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Fri, 16 Jan 2026 12:30:38 +0100 Subject: [PATCH 34/42] fix the article style --- apps/docs/src/content/docs/guides/virtualization.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/docs/src/content/docs/guides/virtualization.mdx b/apps/docs/src/content/docs/guides/virtualization.mdx index 5afd95991..64770f45b 100644 --- a/apps/docs/src/content/docs/guides/virtualization.mdx +++ b/apps/docs/src/content/docs/guides/virtualization.mdx @@ -92,9 +92,9 @@ virtualization: { When configuring virtualization, consider these trade-offs: -| Setting | Higher Value | Lower Value | -| ------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | -| `padding` | More pre-rendered elements, smoother panning, higher memory usage | Fewer elements rendered, may see empty areas during fast panning | -| `idleDelay` | Longer wait before rendering, fewer intermediate renders | Faster visual feedback, more frequent renders | +| Setting | Higher Value | Lower Value | +| ----------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | +| `padding` | More pre-rendered elements, smoother panning, higher memory usage | Fewer elements rendered, may see empty areas during fast panning | +| `idleDelay` | Longer wait before rendering, fewer intermediate renders | Faster visual feedback, more frequent renders | For most use cases, the default values provide a good balance between performance and visual smoothness. From 88d1196a289fa4808302926c564ad4e775dc63e3 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 19 Jan 2026 13:54:11 +0100 Subject: [PATCH 35/42] Support port only deletion (node exists) - fix example in the docs --- apps/angular-demo/src/app/app.component.ts | 10 +- .../src/app/data/default-model.ts | 6 + .../src/app/data/node-template.ts | 3 + .../port-toggle-node.component.html | 18 ++ .../port-toggle-node.component.scss | 22 ++ .../port-toggle-node.component.ts | 23 ++ .../node/node.component.ts | 2 +- .../commands/add-update-delete.ts | 2 - .../port-batch-processor.spec.ts | 250 ++++++++++++++++++ .../port-batch-processor.ts | 242 ++++++----------- .../direct-port-update-strategy.ts | 6 + .../internal-updater/internal-updater.ts | 8 + .../port-update-strategy.interface.ts | 5 + .../virtualized-port-update-strategy.ts | 6 + .../port/ng-diagram-port.component.ts | 6 +- 15 files changed, 444 insertions(+), 165 deletions(-) create mode 100644 apps/angular-demo/src/app/node-template/port-toggle-node/port-toggle-node.component.html create mode 100644 apps/angular-demo/src/app/node-template/port-toggle-node/port-toggle-node.component.scss create mode 100644 apps/angular-demo/src/app/node-template/port-toggle-node/port-toggle-node.component.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/port-batch-processor/port-batch-processor.spec.ts diff --git a/apps/angular-demo/src/app/app.component.ts b/apps/angular-demo/src/app/app.component.ts index e00559cc1..0fdfcbbc1 100644 --- a/apps/angular-demo/src/app/app.component.ts +++ b/apps/angular-demo/src/app/app.component.ts @@ -98,10 +98,6 @@ export class AppComponent { this.config = { ...this.config, ...virtualizationConfigOverrides, - zoom: { - ...this.config.zoom, - zoomToFit: undefined, - }, }; this.model = initializeModel(generateModel(virtualizationTestConfig.nodeCount), this.injector); } @@ -202,6 +198,12 @@ export class AppComponent { } onReinitializeModel(): void { + this.config = { + ...this.config, + virtualization: { + enabled: false, + }, + }; this.model = initializeModel(defaultModel, this.injector); } } diff --git a/apps/angular-demo/src/app/data/default-model.ts b/apps/angular-demo/src/app/data/default-model.ts index 90d173b6a..8777b5771 100644 --- a/apps/angular-demo/src/app/data/default-model.ts +++ b/apps/angular-demo/src/app/data/default-model.ts @@ -103,6 +103,12 @@ export const defaultModel: DiagramModel = { isGroup: true, rotatable: true, }, + { + id: 'port-toggle-test', + type: 'port-toggle', + position: { x: 100, y: 850 }, + data: { text: 'Port Toggle Test' }, + }, ], edges: [ { diff --git a/apps/angular-demo/src/app/data/node-template.ts b/apps/angular-demo/src/app/data/node-template.ts index 22b6475b6..d9bccd71d 100644 --- a/apps/angular-demo/src/app/data/node-template.ts +++ b/apps/angular-demo/src/app/data/node-template.ts @@ -4,6 +4,7 @@ import { CustomizedDefaultNodeComponent } from '../node-template/customized-defa import { GroupNodeComponent } from '../node-template/group-node/group-node.component'; import { ImageNodeComponent } from '../node-template/image-node/image-node.component'; import { InputFieldNodeComponent } from '../node-template/input-field-node/input-field-node.component'; +import { PortToggleNodeComponent } from '../node-template/port-toggle-node/port-toggle-node.component'; import { ResizableNodeComponent } from '../node-template/resizable-node/resizable-node.component'; export enum NodeTemplateType { @@ -13,6 +14,7 @@ export enum NodeTemplateType { CustomizedDefault = 'customized-default', Group = 'custom-group', Chip = 'chip', + PortToggle = 'port-toggle', } export const nodeTemplateMap = new NgDiagramNodeTemplateMap([ @@ -22,4 +24,5 @@ export const nodeTemplateMap = new NgDiagramNodeTemplateMap([ [NodeTemplateType.Group, GroupNodeComponent], [NodeTemplateType.CustomizedDefault, CustomizedDefaultNodeComponent], [NodeTemplateType.Chip, ChipNodeComponent], + [NodeTemplateType.PortToggle, PortToggleNodeComponent], ]); diff --git a/apps/angular-demo/src/app/node-template/port-toggle-node/port-toggle-node.component.html b/apps/angular-demo/src/app/node-template/port-toggle-node/port-toggle-node.component.html new file mode 100644 index 000000000..bbe8541cf --- /dev/null +++ b/apps/angular-demo/src/app/node-template/port-toggle-node/port-toggle-node.component.html @@ -0,0 +1,18 @@ + +
+ @if (showPorts()) { + + + } +
diff --git a/apps/angular-demo/src/app/node-template/port-toggle-node/port-toggle-node.component.scss b/apps/angular-demo/src/app/node-template/port-toggle-node/port-toggle-node.component.scss new file mode 100644 index 000000000..dc9bdabcb --- /dev/null +++ b/apps/angular-demo/src/app/node-template/port-toggle-node/port-toggle-node.component.scss @@ -0,0 +1,22 @@ +:host { + display: flex; + width: 100%; + height: 100%; + padding: 0.5rem; + background-color: var(--ngd-node-bg-primary-default); + border: var(--ngd-node-border-size) solid var(--ngd-node-border-color); + color: var(--ngd-txt-primary-default); + border-radius: var(--ngd-node-border-radius); + min-width: 150px; + + .port-toggle-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + + .port-toggle-checkbox { + cursor: pointer; + } + } +} diff --git a/apps/angular-demo/src/app/node-template/port-toggle-node/port-toggle-node.component.ts b/apps/angular-demo/src/app/node-template/port-toggle-node/port-toggle-node.component.ts new file mode 100644 index 000000000..fbcee89b6 --- /dev/null +++ b/apps/angular-demo/src/app/node-template/port-toggle-node/port-toggle-node.component.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core'; +import { NgDiagramNodeSelectedDirective, NgDiagramNodeTemplate, NgDiagramPortComponent, Node } from 'ng-diagram'; + +@Component({ + selector: 'app-port-toggle-node', + imports: [NgDiagramPortComponent], + templateUrl: './port-toggle-node.component.html', + styleUrls: ['./port-toggle-node.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [{ directive: NgDiagramNodeSelectedDirective, inputs: ['node'] }], + host: { + '[class.ng-diagram-port-hoverable]': 'true', + }, +}) +export class PortToggleNodeComponent implements NgDiagramNodeTemplate<{ text: string }> { + node = input.required>(); + + showPorts = signal(true); + + togglePorts(): void { + this.showPorts.update((v) => !v); + } +} diff --git a/apps/docs/src/components/angular/nodes/custom-default-node/node/node.component.ts b/apps/docs/src/components/angular/nodes/custom-default-node/node/node.component.ts index c64907d8b..19d3dbd54 100644 --- a/apps/docs/src/components/angular/nodes/custom-default-node/node/node.component.ts +++ b/apps/docs/src/components/angular/nodes/custom-default-node/node/node.component.ts @@ -8,7 +8,7 @@ import { @Component({ imports: [NgDiagramBaseNodeTemplateComponent], template: ` - + { const node = commandHandler.flowCore.getNodeById(nodeId); if (!node) { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/port-batch-processor/port-batch-processor.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/port-batch-processor/port-batch-processor.spec.ts new file mode 100644 index 000000000..232c3d0d9 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/port-batch-processor/port-batch-processor.spec.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Port } from '../types'; +import { PortBatchProcessor, PortUpdate } from './port-batch-processor'; + +describe('PortBatchProcessor', () => { + let processor: PortBatchProcessor; + + beforeEach(() => { + processor = new PortBatchProcessor(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const createPort = (id: string, nodeId = 'node1'): Port => ({ + id, + nodeId, + type: 'source', + side: 'left', + }); + + const createPortUpdate = (portId: string): PortUpdate => ({ + portId, + portChanges: { side: 'right' }, + }); + + describe('Standard Mode (per-node batching)', () => { + describe('processAdd', () => { + it('should batch multiple port additions for the same node in the same tick', async () => { + const onFlush = vi.fn(); + + processor.processAdd('node1', createPort('port1'), onFlush); + processor.processAdd('node1', createPort('port2'), onFlush); + processor.processAdd('node1', createPort('port3'), onFlush); + + expect(onFlush).not.toHaveBeenCalled(); + + await vi.runAllTimersAsync(); + + expect(onFlush).toHaveBeenCalledTimes(1); + expect(onFlush).toHaveBeenCalledWith('node1', [createPort('port1'), createPort('port2'), createPort('port3')]); + }); + + it('should batch separately for different nodes', async () => { + const onFlush = vi.fn(); + + processor.processAdd('node1', createPort('port1'), onFlush); + processor.processAdd('node2', createPort('port2'), onFlush); + + await vi.runAllTimersAsync(); + + expect(onFlush).toHaveBeenCalledTimes(2); + expect(onFlush).toHaveBeenCalledWith('node1', [createPort('port1')]); + expect(onFlush).toHaveBeenCalledWith('node2', [createPort('port2')]); + }); + + it('should handle subsequent batches after flush', async () => { + const onFlush = vi.fn(); + + processor.processAdd('node1', createPort('port1'), onFlush); + await vi.runAllTimersAsync(); + + processor.processAdd('node1', createPort('port2'), onFlush); + await vi.runAllTimersAsync(); + + expect(onFlush).toHaveBeenCalledTimes(2); + expect(onFlush).toHaveBeenNthCalledWith(1, 'node1', [createPort('port1')]); + expect(onFlush).toHaveBeenNthCalledWith(2, 'node1', [createPort('port2')]); + }); + }); + + describe('processUpdate', () => { + it('should batch multiple port updates for the same node in the same tick', async () => { + const onFlush = vi.fn(); + + processor.processUpdate('node1', createPortUpdate('port1'), onFlush); + processor.processUpdate('node1', createPortUpdate('port2'), onFlush); + + await vi.runAllTimersAsync(); + + expect(onFlush).toHaveBeenCalledTimes(1); + expect(onFlush).toHaveBeenCalledWith('node1', [createPortUpdate('port1'), createPortUpdate('port2')]); + }); + + it('should batch separately for different nodes', async () => { + const onFlush = vi.fn(); + + processor.processUpdate('node1', createPortUpdate('port1'), onFlush); + processor.processUpdate('node2', createPortUpdate('port2'), onFlush); + + await vi.runAllTimersAsync(); + + expect(onFlush).toHaveBeenCalledTimes(2); + }); + }); + + describe('processDelete', () => { + it('should batch multiple port deletions for the same node in the same tick', async () => { + const onFlush = vi.fn(); + + processor.processDelete('node1', 'port1', onFlush); + processor.processDelete('node1', 'port2', onFlush); + processor.processDelete('node1', 'port3', onFlush); + + expect(onFlush).not.toHaveBeenCalled(); + + await vi.runAllTimersAsync(); + + expect(onFlush).toHaveBeenCalledTimes(1); + expect(onFlush).toHaveBeenCalledWith('node1', ['port1', 'port2', 'port3']); + }); + + it('should batch separately for different nodes', async () => { + const onFlush = vi.fn(); + + processor.processDelete('node1', 'port1', onFlush); + processor.processDelete('node2', 'port2', onFlush); + + await vi.runAllTimersAsync(); + + expect(onFlush).toHaveBeenCalledTimes(2); + expect(onFlush).toHaveBeenCalledWith('node1', ['port1']); + expect(onFlush).toHaveBeenCalledWith('node2', ['port2']); + }); + + it('should handle subsequent batches after flush', async () => { + const onFlush = vi.fn(); + + processor.processDelete('node1', 'port1', onFlush); + await vi.runAllTimersAsync(); + + processor.processDelete('node1', 'port2', onFlush); + await vi.runAllTimersAsync(); + + expect(onFlush).toHaveBeenCalledTimes(2); + expect(onFlush).toHaveBeenNthCalledWith(1, 'node1', ['port1']); + expect(onFlush).toHaveBeenNthCalledWith(2, 'node1', ['port2']); + }); + + it('should fix race condition when multiple ports are deleted simultaneously', async () => { + // This is the core bug we fixed: when @if toggles off, multiple ports + // call ngOnDestroy in the same tick. Without batching, each emits + // a separate command causing race conditions. + const onFlush = vi.fn(); + + // Simulate two ports being destroyed in the same tick + processor.processDelete('node1', 'port-left', onFlush); + processor.processDelete('node1', 'port-right', onFlush); + + await vi.runAllTimersAsync(); + + // Should result in a single batched deletion + expect(onFlush).toHaveBeenCalledTimes(1); + expect(onFlush).toHaveBeenCalledWith('node1', ['port-left', 'port-right']); + }); + }); + }); + + describe('Virtualization Mode (global batching)', () => { + describe('processAddBatched', () => { + it('should batch all additions across all nodes into single callback', async () => { + const onBatchFlush = vi.fn(); + + processor.processAddBatched('node1', createPort('port1'), onBatchFlush); + processor.processAddBatched('node2', createPort('port2'), onBatchFlush); + processor.processAddBatched('node1', createPort('port3'), onBatchFlush); + + await vi.runAllTimersAsync(); + + expect(onBatchFlush).toHaveBeenCalledTimes(1); + + const additions = onBatchFlush.mock.calls[0][0] as Map; + expect(additions.get('node1')).toEqual([createPort('port1'), createPort('port3')]); + expect(additions.get('node2')).toEqual([createPort('port2')]); + }); + }); + + describe('processUpdateBatched', () => { + it('should batch all updates across all nodes into single callback', async () => { + const onBatchFlush = vi.fn(); + + processor.processUpdateBatched('node1', createPortUpdate('port1'), onBatchFlush); + processor.processUpdateBatched('node2', createPortUpdate('port2'), onBatchFlush); + + await vi.runAllTimersAsync(); + + expect(onBatchFlush).toHaveBeenCalledTimes(1); + + const updates = onBatchFlush.mock.calls[0][0] as Map; + expect(updates.get('node1')).toEqual([createPortUpdate('port1')]); + expect(updates.get('node2')).toEqual([createPortUpdate('port2')]); + }); + }); + + describe('processDeleteBatched', () => { + it('should batch all deletions across all nodes into single callback', async () => { + const onBatchFlush = vi.fn(); + + processor.processDeleteBatched('node1', 'port1', onBatchFlush); + processor.processDeleteBatched('node2', 'port2', onBatchFlush); + processor.processDeleteBatched('node1', 'port3', onBatchFlush); + + await vi.runAllTimersAsync(); + + expect(onBatchFlush).toHaveBeenCalledTimes(1); + + const deletions = onBatchFlush.mock.calls[0][0] as Map; + expect(deletions.get('node1')).toEqual(['port1', 'port3']); + expect(deletions.get('node2')).toEqual(['port2']); + }); + + it('should handle virtualization scenario where many nodes are destroyed simultaneously', async () => { + const onBatchFlush = vi.fn(); + + // Simulate many nodes being virtualized (scrolled out of view) + for (let i = 0; i < 100; i++) { + processor.processDeleteBatched(`node${i}`, `port${i}`, onBatchFlush); + } + + await vi.runAllTimersAsync(); + + // Should result in a single batched callback + expect(onBatchFlush).toHaveBeenCalledTimes(1); + + const deletions = onBatchFlush.mock.calls[0][0] as Map; + expect(deletions.size).toBe(100); + }); + }); + }); + + describe('Mixed operations', () => { + it('should keep add, update, and delete operations separate', async () => { + const onAddFlush = vi.fn(); + const onUpdateFlush = vi.fn(); + const onDeleteFlush = vi.fn(); + + processor.processAdd('node1', createPort('port1'), onAddFlush); + processor.processUpdate('node1', createPortUpdate('port2'), onUpdateFlush); + processor.processDelete('node1', 'port3', onDeleteFlush); + + await vi.runAllTimersAsync(); + + expect(onAddFlush).toHaveBeenCalledTimes(1); + expect(onUpdateFlush).toHaveBeenCalledTimes(1); + expect(onDeleteFlush).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/port-batch-processor/port-batch-processor.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/port-batch-processor/port-batch-processor.ts index 6473c4755..6885582cd 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/port-batch-processor/port-batch-processor.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/port-batch-processor/port-batch-processor.ts @@ -5,6 +5,73 @@ export interface PortUpdate { portChanges: Partial; } +/** + * Generic batcher that supports both per-node and global batching patterns. + */ +class OperationBatcher { + private readonly pending = new Map(); + private readonly scheduledPerNode = new Set(); + private globalFlushScheduled = false; + + /** + * Per-node batching: schedules a separate flush for each node. + */ + addPerNode(nodeId: string, item: T, onFlush: (nodeId: string, items: T[]) => void): void { + this.getOrCreateArray(nodeId).push(item); + + if (!this.scheduledPerNode.has(nodeId)) { + this.scheduledPerNode.add(nodeId); + queueMicrotask(() => this.flushPerNode(nodeId, onFlush)); + } + } + + /** + * Global batching: schedules a single flush for all nodes. + */ + addGlobal(nodeId: string, item: T, onBatchFlush: (all: Map) => void): void { + this.getOrCreateArray(nodeId).push(item); + + if (!this.globalFlushScheduled) { + this.globalFlushScheduled = true; + queueMicrotask(() => this.flushGlobal(onBatchFlush)); + } + } + + private getOrCreateArray(nodeId: string): T[] { + if (!this.pending.has(nodeId)) { + this.pending.set(nodeId, []); + } + return this.pending.get(nodeId)!; + } + + private flushPerNode(nodeId: string, onFlush: (nodeId: string, items: T[]) => void): void { + const items = this.pending.get(nodeId); + if (!items || items.length === 0) { + return; + } + + onFlush(nodeId, items); + + this.pending.delete(nodeId); + this.scheduledPerNode.delete(nodeId); + } + + private flushGlobal(onBatchFlush: (all: Map) => void): void { + if (this.pending.size === 0) { + this.globalFlushScheduled = false; + return; + } + + const all = new Map(this.pending); + + this.pending.clear(); + this.scheduledPerNode.clear(); + this.globalFlushScheduled = false; + + onBatchFlush(all); + } +} + /** * Processes and batches port operations for nodes to prevent race conditions when multiple ports * are added or updated simultaneously (e.g., during node initialization or virtualization). @@ -13,25 +80,16 @@ export interface PortUpdate { * processes them together in a single batch, ensuring all ports are properly * persisted to the state. * - * Standard mode: Uses per-node batching (processAdd, processUpdate) + * Standard mode: Uses per-node batching (processAdd, processUpdate, processDelete) * Virtualization mode: Uses global batching (processAddBatched, processUpdateBatched, processDeleteBatched) */ export class PortBatchProcessor { - // ===== STANDARD MODE: Per-node batching ===== - private readonly pendingPortAdditions = new Map(); - private readonly scheduledAdditionFlushes = new Set(); - - private readonly pendingPortUpdates = new Map(); - private readonly scheduledUpdateFlushes = new Set(); - - // ===== VIRTUALIZATION MODE: Global batching ===== - private globalAdditionFlushScheduled = false; - private globalUpdateFlushScheduled = false; - private readonly pendingPortDeletions = new Map(); - private globalDeletionFlushScheduled = false; + private readonly additionBatcher = new OperationBatcher(); + private readonly updateBatcher = new OperationBatcher(); + private readonly deletionBatcher = new OperationBatcher(); // ============================================= - // STANDARD MODE METHODS (original behavior) + // STANDARD MODE METHODS (per-node batching) // ============================================= /** @@ -44,19 +102,7 @@ export class PortBatchProcessor { * @param onFlush - Callback function to execute with all batched ports */ processAdd(nodeId: string, port: Port, onFlush: (nodeId: string, ports: Port[]) => void): void { - if (!this.pendingPortAdditions.has(nodeId)) { - this.pendingPortAdditions.set(nodeId, []); - } - - this.pendingPortAdditions.get(nodeId)!.push(port); - - if (!this.scheduledAdditionFlushes.has(nodeId)) { - this.scheduledAdditionFlushes.add(nodeId); - - queueMicrotask(() => { - this.flushPortAdditions(nodeId, onFlush); - }); - } + this.additionBatcher.addPerNode(nodeId, port, onFlush); } /** @@ -73,43 +119,20 @@ export class PortBatchProcessor { portUpdate: PortUpdate, onFlush: (nodeId: string, portUpdates: PortUpdate[]) => void ): void { - if (!this.pendingPortUpdates.has(nodeId)) { - this.pendingPortUpdates.set(nodeId, []); - } - - this.pendingPortUpdates.get(nodeId)!.push(portUpdate); - - if (!this.scheduledUpdateFlushes.has(nodeId)) { - this.scheduledUpdateFlushes.add(nodeId); - - queueMicrotask(() => { - this.flushPortUpdates(nodeId, onFlush); - }); - } + this.updateBatcher.addPerNode(nodeId, portUpdate, onFlush); } - private flushPortAdditions(nodeId: string, onFlush: (nodeId: string, ports: Port[]) => void): void { - const ports = this.pendingPortAdditions.get(nodeId); - if (!ports || ports.length === 0) { - return; - } - - onFlush(nodeId, ports); - - this.pendingPortAdditions.delete(nodeId); - this.scheduledAdditionFlushes.delete(nodeId); - } - - private flushPortUpdates(nodeId: string, onFlush: (nodeId: string, portUpdates: PortUpdate[]) => void): void { - const portUpdates = this.pendingPortUpdates.get(nodeId); - if (!portUpdates || portUpdates.length === 0) { - return; - } - - onFlush(nodeId, portUpdates); - - this.pendingPortUpdates.delete(nodeId); - this.scheduledUpdateFlushes.delete(nodeId); + /** + * Processes a port deletion for batching with the specified node. + * If this is the first port deletion for the node in the current tick, + * schedules a flush to process all collected deletions. + * + * @param nodeId - The ID of the node containing the port + * @param portId - The ID of the port to delete + * @param onFlush - Callback function to execute with all batched port IDs + */ + processDelete(nodeId: string, portId: string, onFlush: (nodeId: string, portIds: string[]) => void): void { + this.deletionBatcher.addPerNode(nodeId, portId, onFlush); } // ============================================= @@ -126,19 +149,7 @@ export class PortBatchProcessor { * @param onBatchFlush - Callback that receives ALL pending additions across all nodes */ processAddBatched(nodeId: string, port: Port, onBatchFlush: (additions: Map) => void): void { - if (!this.pendingPortAdditions.has(nodeId)) { - this.pendingPortAdditions.set(nodeId, []); - } - - this.pendingPortAdditions.get(nodeId)!.push(port); - - if (!this.globalAdditionFlushScheduled) { - this.globalAdditionFlushScheduled = true; - - queueMicrotask(() => { - this.flushAllPortAdditionsBatched(onBatchFlush); - }); - } + this.additionBatcher.addGlobal(nodeId, port, onBatchFlush); } /** @@ -155,19 +166,7 @@ export class PortBatchProcessor { portUpdate: PortUpdate, onBatchFlush: (updates: Map) => void ): void { - if (!this.pendingPortUpdates.has(nodeId)) { - this.pendingPortUpdates.set(nodeId, []); - } - - this.pendingPortUpdates.get(nodeId)!.push(portUpdate); - - if (!this.globalUpdateFlushScheduled) { - this.globalUpdateFlushScheduled = true; - - queueMicrotask(() => { - this.flushAllPortUpdatesBatched(onBatchFlush); - }); - } + this.updateBatcher.addGlobal(nodeId, portUpdate, onBatchFlush); } /** @@ -179,71 +178,6 @@ export class PortBatchProcessor { * @param onBatchFlush - Callback that receives ALL pending deletions across all nodes */ processDeleteBatched(nodeId: string, portId: string, onBatchFlush: (deletions: Map) => void): void { - if (!this.pendingPortDeletions.has(nodeId)) { - this.pendingPortDeletions.set(nodeId, []); - } - - this.pendingPortDeletions.get(nodeId)!.push(portId); - - if (!this.globalDeletionFlushScheduled) { - this.globalDeletionFlushScheduled = true; - - queueMicrotask(() => { - this.flushAllPortDeletionsBatched(onBatchFlush); - }); - } - } - - private flushAllPortAdditionsBatched(onBatchFlush: (additions: Map) => void): void { - if (this.pendingPortAdditions.size === 0) { - this.globalAdditionFlushScheduled = false; - return; - } - - // Copy the pending additions before clearing - const additions = new Map(this.pendingPortAdditions); - - // Clear state first to allow new batches to accumulate - this.pendingPortAdditions.clear(); - this.scheduledAdditionFlushes.clear(); - this.globalAdditionFlushScheduled = false; - - // Execute the batch callback with all additions at once - onBatchFlush(additions); - } - - private flushAllPortUpdatesBatched(onBatchFlush: (updates: Map) => void): void { - if (this.pendingPortUpdates.size === 0) { - this.globalUpdateFlushScheduled = false; - return; - } - - // Copy the pending updates before clearing - const updates = new Map(this.pendingPortUpdates); - - // Clear state first to allow new batches to accumulate - this.pendingPortUpdates.clear(); - this.scheduledUpdateFlushes.clear(); - this.globalUpdateFlushScheduled = false; - - // Execute the batch callback with all updates at once - onBatchFlush(updates); - } - - private flushAllPortDeletionsBatched(onBatchFlush: (deletions: Map) => void): void { - if (this.pendingPortDeletions.size === 0) { - this.globalDeletionFlushScheduled = false; - return; - } - - // Copy the pending deletions before clearing - const deletions = new Map(this.pendingPortDeletions); - - // Clear state first to allow new batches to accumulate - this.pendingPortDeletions.clear(); - this.globalDeletionFlushScheduled = false; - - // Execute the batch callback with all deletions at once - onBatchFlush(deletions); + this.deletionBatcher.addGlobal(nodeId, portId, onBatchFlush); } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/direct-port-update-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/direct-port-update-strategy.ts index 9a495f62b..e92cc5143 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/direct-port-update-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/direct-port-update-strategy.ts @@ -26,4 +26,10 @@ export class DirectPortUpdateStrategy implements PortUpdateStrategy { ); } } + + deletePort(nodeId: string, portId: string): void { + this.flowCore.portBatchProcessor.processDelete(nodeId, portId, (nodeId, portIds) => { + this.flowCore.commandHandler.emit('deletePorts', { nodeId, portIds }); + }); + } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts index c15e5c240..581ab59e5 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/internal-updater.ts @@ -46,6 +46,14 @@ export class InternalUpdater implements Updater { this.portUpdateStrategy.addPort(nodeId, port); } + /** + * @internal + * Internal method to delete a port from the flow + */ + deletePort(nodeId: string, portId: string): void { + this.portUpdateStrategy.deletePort(nodeId, portId); + } + /** * @internal * Internal method to apply a port size and position diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/port-update-strategy.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/port-update-strategy.interface.ts index dc8707d08..a22d6f71d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/port-update-strategy.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/port-update-strategy.interface.ts @@ -14,4 +14,9 @@ export interface PortUpdateStrategy { * Update port sizes and positions */ updatePorts(nodeId: string, ports: Pick[]): void; + + /** + * Delete a port from a node + */ + deletePort(nodeId: string, portId: string): void; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/virtualized-port-update-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/virtualized-port-update-strategy.ts index 5aa4e44ca..1f8a3d764 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/virtualized-port-update-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/updater/internal-updater/virtualized-port-update-strategy.ts @@ -31,6 +31,12 @@ export class VirtualizedPortUpdateStrategy implements PortUpdateStrategy { } } + deletePort(nodeId: string, portId: string): void { + this.flowCore.portBatchProcessor.processDeleteBatched(nodeId, portId, (allDeletions) => { + this.flowCore.commandHandler.emit('deletePortsBulk', { deletions: allDeletions }); + }); + } + private isPortAlreadyMeasured(nodeId: string, portId: string): boolean { const node = this.flowCore.getNodeById(nodeId); const existingPort = node?.measuredPorts?.find((p) => p.id === portId); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts index 3798df072..4360a5937 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/port/ng-diagram-port.component.ts @@ -172,6 +172,7 @@ export class NgDiagramPortComponent extends NodeContextGuardBase implements OnIn /** @internal */ ngOnDestroy(): void { + const portId = this.id(); const nodeData = this.nodeData(); if (!nodeData) { return; @@ -199,10 +200,7 @@ export class NgDiagramPortComponent extends NodeContextGuardBase implements OnIn return; } - flowCore.commandHandler.emit('deletePorts', { - nodeId: nodeData.id, - portIds: [this.id()], - }); + flowCore.internalUpdater.deletePort(nodeData.id, portId); } private readonly custom = viewChild>('contentProjection'); From 26c342f96f7e2a89acb021da4c8bd129c2f5d4d7 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Fri, 30 Jan 2026 14:16:41 +0100 Subject: [PATCH 36/42] FIx comments --- .../api/Types/Configuration/Features/VirtualizationConfig.md | 2 +- apps/docs/src/content/docs/guides/virtualization.mdx | 4 +++- .../src/core/src/command-handler/commands/selection.ts | 1 - .../ng-diagram/src/core/src/types/flow-config.interface.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/VirtualizationConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/VirtualizationConfig.md index c28a3ca42..ae4428623 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/Features/VirtualizationConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/VirtualizationConfig.md @@ -1,5 +1,5 @@ --- -version: "since v0.9.1" +version: "since v1.0.0" editUrl: false next: false prev: false diff --git a/apps/docs/src/content/docs/guides/virtualization.mdx b/apps/docs/src/content/docs/guides/virtualization.mdx index 64770f45b..f26e7c27b 100644 --- a/apps/docs/src/content/docs/guides/virtualization.mdx +++ b/apps/docs/src/content/docs/guides/virtualization.mdx @@ -1,7 +1,9 @@ --- -version: 'since v0.9.1' +version: 'since v1.0.0' title: Virtualization description: Optimizing performance for large diagrams with viewport virtualization +sidebar: + badge: New --- Virtualization is a performance optimization technique that renders only the nodes and edges visible within the current viewport. For diagrams with hundreds or thousands of elements, this dramatically improves rendering performance and responsiveness. diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/selection.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/selection.ts index 7b53456d6..3dc54cae4 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/selection.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/selection.ts @@ -14,7 +14,6 @@ const changeSelection = ( const nodesToUpdate: FlowStateUpdate['nodesToUpdate'] = []; const edgesToUpdate: FlowStateUpdate['edgesToUpdate'] = []; - // Convert to Sets for O(1) lookups instead of O(n) array.includes() const selectedNodeIdSet = new Set(selectedNodeIds); const selectedEdgeIdSet = new Set(selectedEdgeIds); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index e1ad4403d..5e4bbc105 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -425,7 +425,7 @@ export interface BoxSelectionConfig { * significantly improving performance for large diagrams. * * @public - * @since 0.9.1 + * @since 1.0.0 * @category Types/Configuration/Features */ export interface VirtualizationConfig { From 2b9b321b0762de2259c97f1e4338272210883e5c Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Fri, 30 Jan 2026 14:33:53 +0100 Subject: [PATCH 37/42] Fix routing during node dragging --- .../middlewares/edges-routing/edges-routing.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts index 6e37b4b9a..fe545c895 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts @@ -12,9 +12,9 @@ export const checkIfShouldRouteEdges = ({ modelActionTypes, actionStateManager, }: MiddlewareContext): boolean => { - // Skip edge routing during viewport-only operations (zoom/pan) + // Skip edge routing during viewport-only operation (zoom) // Node positions don't change, only the viewport transform changes - if (modelActionTypes.includes('zoom') || modelActionTypes.includes('moveViewport')) { + if (modelActionTypes.includes('zoom')) { return false; } From b854fe317ca351bcd10a60f242cb5b777e235782 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 2 Feb 2026 10:40:41 +0100 Subject: [PATCH 38/42] Fix unit-test --- .../middlewares/edges-routing/__tests__/edges-routing.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing.test.ts index 9b3a40d34..7abac4d26 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing.test.ts @@ -138,12 +138,12 @@ describe('Edges Routing Middleware', () => { expect(nextMock).toHaveBeenCalledWith(); }); - it('should skip edge routing during pan (moveViewport)', () => { + it('should allow edge routing during pan (moveViewport)', () => { context.modelActionTypes = ['moveViewport']; edgesRoutingMiddleware.execute(context as any, nextMock, () => null); - expect(nextMock).toHaveBeenCalledWith(); + expect(nextMock).toHaveBeenCalled(); }); it('should proceed when edges need routing on init', () => { From 028a88989ef7b137ff49f5aff6c9b3f1f96bc3a7 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 5 Feb 2026 11:04:11 +0100 Subject: [PATCH 39/42] adjust minimap to support virtuazliation --- ...gram-minimap-diagram-bounds.component.scss | 6 ++ ...iagram-minimap-diagram-bounds.component.ts | 40 +++++++++++ ...ng-diagram-minimap-navigation.directive.ts | 13 ++++ .../ng-diagram-minimap.calculations.ts | 30 ++++++++- .../minimap/ng-diagram-minimap.component.html | 7 ++ .../minimap/ng-diagram-minimap.component.ts | 67 +++++++++++-------- .../minimap/ng-diagram-minimap.types.ts | 23 ++++++- .../strategy/direct-minimap-strategy.ts | 38 +++++++++++ .../strategy/virtualized-minimap-strategy.ts | 23 +++++++ .../ng-diagram-model.service.ts | 5 ++ .../ng-diagram/src/lib/styles/styles.css | 3 + 11 files changed, 226 insertions(+), 29 deletions(-) create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/diagram-bounds/ng-diagram-minimap-diagram-bounds.component.scss create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/diagram-bounds/ng-diagram-minimap-diagram-bounds.component.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/strategy/direct-minimap-strategy.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/strategy/virtualized-minimap-strategy.ts diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/diagram-bounds/ng-diagram-minimap-diagram-bounds.component.scss b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/diagram-bounds/ng-diagram-minimap-diagram-bounds.component.scss new file mode 100644 index 000000000..ca3ca5a00 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/diagram-bounds/ng-diagram-minimap-diagram-bounds.component.scss @@ -0,0 +1,6 @@ +:host { + fill: var(--ngd-minimap-diagram-bounds-color); + opacity: var(--ngd-minimap-diagram-bounds-opacity); + stroke: none; + pointer-events: none; +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/diagram-bounds/ng-diagram-minimap-diagram-bounds.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/diagram-bounds/ng-diagram-minimap-diagram-bounds.component.ts new file mode 100644 index 000000000..ab61faf6b --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/diagram-bounds/ng-diagram-minimap-diagram-bounds.component.ts @@ -0,0 +1,40 @@ +/* eslint-disable @angular-eslint/component-selector */ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { Rect } from '../../../../core/src'; +import { MinimapTransform } from '../ng-diagram-minimap.types'; + +/** + * Internal component for rendering the diagram bounds outline on the minimap. + * Used in virtualized mode to show the extent of the full diagram. + * + * @internal + */ +@Component({ + selector: 'rect[ng-diagram-minimap-diagram-bounds]', + standalone: true, + template: '', + styleUrl: './ng-diagram-minimap-diagram-bounds.component.scss', + host: { + '[attr.x]': 'rect().x', + '[attr.y]': 'rect().y', + '[attr.width]': 'rect().width', + '[attr.height]': 'rect().height', + }, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NgDiagramMinimapDiagramBoundsComponent { + diagramBounds = input.required(); + minimapTransform = input.required(); + + protected rect = computed(() => { + const bounds = this.diagramBounds(); + const transform = this.minimapTransform(); + + return { + x: bounds.x * transform.scale + transform.offsetX, + y: bounds.y * transform.scale + transform.offsetY, + width: bounds.width * transform.scale, + height: bounds.height * transform.scale, + }; + }); +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.ts index 1af55786a..cc100d40a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.ts @@ -1,6 +1,7 @@ import { Directive, inject, input, OnDestroy } from '@angular/core'; import { Viewport } from '../../../core/src'; import { NgDiagramViewportService } from '../../public-services/ng-diagram-viewport.service'; +import { FlowCoreProviderService } from '../../services'; import { MinimapTransform } from './ng-diagram-minimap.types'; interface Point { @@ -34,6 +35,7 @@ interface DragState { }) export class NgDiagramMinimapNavigationDirective implements OnDestroy { private readonly viewportService = inject(NgDiagramViewportService); + private readonly flowCoreProvider = inject(FlowCoreProviderService); transform = input.required(); viewport = input.required(); @@ -58,6 +60,7 @@ export class NgDiagramMinimapNavigationDirective implements OnDestroy { this.capturePointer(event); this.dragState.isDragging = true; this.dragState.lastPosition = { x: event.clientX, y: event.clientY }; + this.setPanningState(true); this.attachDocumentListeners(); } @@ -75,6 +78,7 @@ export class NgDiagramMinimapNavigationDirective implements OnDestroy { private onPointerUp = (event: PointerEvent): void => { this.dragState.isDragging = false; + this.setPanningState(false); this.releasePointer(event); this.removeDocumentListeners(); }; @@ -109,6 +113,15 @@ export class NgDiagramMinimapNavigationDirective implements OnDestroy { document.removeEventListener('pointercancel', this.onPointerUp); } + private setPanningState(active: boolean): void { + const actionStateManager = this.flowCoreProvider.provide().actionStateManager; + if (active) { + actionStateManager.panning = { active: true }; + } else { + actionStateManager.clearPanning(); + } + } + private calculateClientDelta(event: PointerEvent): Point { return { x: event.clientX - this.dragState.lastPosition.x, diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.calculations.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.calculations.ts index 82bb90b7c..cf54c4924 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.calculations.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.calculations.ts @@ -1,4 +1,4 @@ -import { Node, Rect, unionRect } from '../../../core/src'; +import { Node, Rect, unionRect, getRotatedBoundingRect } from '../../../core/src'; import { MinimapBounds, MinimapTransform, MinimapViewportRect } from './ng-diagram-minimap.types'; /** @@ -118,6 +118,34 @@ export const extractNodeBounds = ( }; }; +/** + * Calculates bounding rectangle from node positions and sizes. + * Works without measuredBounds - suitable for virtualized mode + * where non-rendered nodes have no measurement data. + * Accounts for node rotation by using axis-aligned bounding boxes. + */ +export const calculateBoundsFromPositions = ( + nodes: Node[], + defaultSize: { width: number; height: number } = DEFAULT_NODE_SIZE +): Rect => { + if (nodes.length === 0) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + + const rects = nodes.map((node) => { + const size = node.size ?? defaultSize; + const baseRect: Rect = { + x: node.position.x, + y: node.position.y, + width: size.width, + height: size.height, + }; + return getRotatedBoundingRect(baseRect, node.angle ?? 0); + }); + + return unionRect(rects); +}; + /** * Transforms the viewport rectangle to minimap coordinate space. */ diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.html b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.html index 189b61907..b25c8974f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.html +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.html @@ -29,6 +29,13 @@ } } + @if (showDiagramBounds()) { + + } @if (hasValidViewport()) { (1); @@ -122,7 +127,6 @@ export class NgDiagramMinimapComponent implements AfterViewInit { } isDiagramInitialized = this.renderer.isInitialized; - nodes = this.renderer.nodes; viewport = this.renderer.viewport; hasValidViewport = computed(() => { @@ -132,6 +136,18 @@ export class NgDiagramMinimapComponent implements AfterViewInit { viewportRect = computed(() => transformViewportToMinimapSpace(this.viewport(), this.transform())); + /** + * @internal + * Active minimap strategy based on virtualization mode. + * Delegates mode-specific logic (node rendering, bounds) to the strategy. + */ + private strategy = computed((): MinimapStrategy => { + if (!this.isDiagramInitialized()) { + return this.directStrategy; + } + return this.flowCoreProvider.provide().isVirtualizationActive ? this.virtualizedStrategy : this.directStrategy; + }); + /** * @internal * Main transform for minimap - updates when viewport or diagram bounds change. @@ -157,23 +173,20 @@ export class NgDiagramMinimapComponent implements AfterViewInit { * The SVG group transform handles the coordinate conversion, so nodes only recalculate * when diagram content changes, NOT during pan/zoom. */ - protected minimapNodes = computed((): MinimapNodeData[] => { - const nodes = this.nodes(); - const styleFn = this.nodeStyle(); - const templateMap = this.minimapNodeTemplateMap(); - - return nodes.map((node) => ({ - bounds: extractNodeBounds(node), - diagramNode: node, - nodeStyle: styleFn?.(node) ?? {}, - template: node.type ? (templateMap.get(node.type) ?? null) : null, - })); - }); + protected minimapNodes = computed((): MinimapNodeData[] => + this.strategy().computeMinimapNodes(this.nodeStyle(), this.minimapNodeTemplateMap()) + ); - private diagramBounds = computed(() => { - const nodes = this.nodes(); - return this.modelService.computePartsBounds(nodes, []); - }); + /** + * @internal + * Whether the diagram bounds outline should be shown on the minimap. + * Diagram bounds are shown in virtualized mode to indicate the full diagram extent. + */ + protected showDiagramBounds = computed( + () => this.isDiagramInitialized() && this.flowCoreProvider.provide().isVirtualizationActive + ); + + protected diagramBounds = computed(() => this.strategy().computeDiagramBounds()); private viewportBoundsInDiagramSpace = computed(() => convertViewportToDiagramBounds(this.viewport())); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.types.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.types.ts index 2f2649b60..5824e5cdd 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.types.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.types.ts @@ -1,5 +1,5 @@ import { InputSignal, Type } from '@angular/core'; -import { Node } from '../../../core/src'; +import { Node, Rect } from '../../../core/src'; /** * Represents the calculated transform data for minimap rendering. @@ -155,3 +155,24 @@ export interface NgDiagramMinimapNodeTemplate { * ``` */ export class NgDiagramMinimapNodeTemplateMap extends Map> {} + +/** + * Strategy interface for minimap rendering behavior. + * Different implementations handle direct vs. virtualized rendering modes. + * + * @internal + */ +export interface MinimapStrategy { + /** + * Returns the minimap node data to render. + */ + computeMinimapNodes( + styleFn: MinimapNodeStyleFn | undefined, + templateMap: NgDiagramMinimapNodeTemplateMap + ): MinimapNodeData[]; + + /** + * Computes the bounding rectangle of the entire diagram. + */ + computeDiagramBounds(): Rect; +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/strategy/direct-minimap-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/strategy/direct-minimap-strategy.ts new file mode 100644 index 000000000..ee9c6fb7c --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/strategy/direct-minimap-strategy.ts @@ -0,0 +1,38 @@ +import { inject, Injectable } from '@angular/core'; +import { Rect } from '../../../../core/src'; +import { calculatePartsBounds } from '../../../../core/src/utils/dimensions'; +import { RendererService } from '../../../services/renderer/renderer.service'; +import { extractNodeBounds } from '../ng-diagram-minimap.calculations'; +import { + MinimapNodeData, + MinimapNodeStyleFn, + MinimapStrategy, + NgDiagramMinimapNodeTemplateMap, +} from '../ng-diagram-minimap.types'; + +/** + * Minimap strategy for direct (non-virtualized) rendering mode. + * All rendered nodes are displayed on the minimap. + * Diagram bounds are computed from rendered nodes using measuredBounds. + */ +@Injectable() +export class DirectMinimapStrategy implements MinimapStrategy { + private readonly renderer = inject(RendererService); + + computeMinimapNodes( + styleFn: MinimapNodeStyleFn | undefined, + templateMap: NgDiagramMinimapNodeTemplateMap + ): MinimapNodeData[] { + const nodes = this.renderer.nodes(); + return nodes.map((node) => ({ + bounds: extractNodeBounds(node), + diagramNode: node, + nodeStyle: styleFn?.(node) ?? {}, + template: node.type ? (templateMap.get(node.type) ?? null) : null, + })); + } + + computeDiagramBounds(): Rect { + return calculatePartsBounds(this.renderer.nodes(), []); + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/strategy/virtualized-minimap-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/strategy/virtualized-minimap-strategy.ts new file mode 100644 index 000000000..37aa36c22 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/strategy/virtualized-minimap-strategy.ts @@ -0,0 +1,23 @@ +import { inject, Injectable } from '@angular/core'; +import { Rect } from '../../../../core/src'; +import { NgDiagramModelService } from '../../../public-services/ng-diagram-model.service'; +import { calculateBoundsFromPositions } from '../ng-diagram-minimap.calculations'; +import { MinimapNodeData, MinimapStrategy } from '../ng-diagram-minimap.types'; + +/** + * Minimap strategy for virtualized rendering mode. + * No nodes are displayed on the minimap — only the viewport frame and diagram bounds outline. + * Diagram bounds are computed from all model nodes using position and size (not measuredBounds). + */ +@Injectable() +export class VirtualizedMinimapStrategy implements MinimapStrategy { + private readonly modelService = inject(NgDiagramModelService); + + computeMinimapNodes(): MinimapNodeData[] { + return []; + } + + computeDiagramBounds(): Rect { + return calculateBoundsFromPositions(this.modelService.nodes()); + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts index cb4d92391..44db8eab1 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts @@ -47,6 +47,11 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes effect(() => { if (this.diagramService.isInitialized()) { this.flowCore.model.onChange(this.modelListener); + this.modelListener({ + nodes: this.flowCore.model.getNodes(), + edges: this.flowCore.model.getEdges(), + metadata: this.flowCore.model.getMetadata(), + }); } }); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css b/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css index 83a872131..1522cbed2 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css @@ -75,6 +75,9 @@ --ngd-minimap-node-color: var(--ngd-colors-gray-400); --ngd-minimap-node-opacity: 0.8; + --ngd-minimap-diagram-bounds-opacity: 0.3; + --ngd-minimap-diagram-bounds-color: var(--ngd-colors-gray-400); + --ngd-minimap-viewport-stroke-color: var(--ngd-colors-gray-400); --ngd-minimap-viewport-stroke-width: 1; From b99e501250893eb29cd860dd78b57b845380c47a Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 5 Feb 2026 12:09:29 +0100 Subject: [PATCH 40/42] Update api report and support Angular 18 --- packages/ng-diagram/api-report/ng-diagram.api.md | 14 ++++++++------ .../minimap/ng-diagram-minimap.component.ts | 4 ++++ .../public-services/ng-diagram-model.service.ts | 14 ++++++++------ 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index 6a87cddde..918dbf143 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -161,7 +161,7 @@ export interface DiagramInitEvent { // @public (undocumented) export class DiagramSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | Node_2 | undefined>; + readonly targetData: InputSignal | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -272,7 +272,7 @@ export type EdgeRoutingName = LooseAutocomplete; // @public (undocumented) export class EdgeSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | Node_2 | undefined>; + readonly targetData: InputSignal | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -850,6 +850,8 @@ export const NgDiagramMath: { // @public export class NgDiagramMinimapComponent implements AfterViewInit { + // (undocumented) + protected diagramBounds: Signal; // (undocumented) hasValidViewport: Signal; height: InputSignal; @@ -860,12 +862,12 @@ export class NgDiagramMinimapComponent implements AfterViewInit { minimapNodeTemplateMap: InputSignal; // (undocumented) ngAfterViewInit(): void; - // (undocumented) - nodes: WritableSignal; // @internal protected nodesGroupTransform: Signal; nodeStyle: InputSignal; position: InputSignal; + // @internal + protected showDiagramBounds: Signal; showZoomControls: InputSignal; // @internal protected transform: Signal; @@ -1218,7 +1220,7 @@ export interface NodeRotationConfig { // @public (undocumented) export class NodeSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | Node_2 | undefined>; + readonly targetData: InputSignal | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -1523,7 +1525,7 @@ export interface ZIndexConfig { // @public (undocumented) export class ZIndexDirective { // (undocumented) - data: InputSignal | Node_2>; + data: InputSignal>; // (undocumented) zIndex: Signal; // (undocumented) diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.ts index 11ac39199..d59492200 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.ts @@ -140,6 +140,10 @@ export class NgDiagramMinimapComponent implements AfterViewInit { * @internal * Active minimap strategy based on virtualization mode. * Delegates mode-specific logic (node rendering, bounds) to the strategy. + * + * Note: isVirtualizationActive is a plain getter (not a signal). + * This works because switching virtualization mode requires model recreation, + * which triggers a full diagram re-init cycle (isInitialized: true → false → true). */ private strategy = computed((): MinimapStrategy => { if (!this.isDiagramInitialized()) { diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts index 44db8eab1..f44c0fffa 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts @@ -1,4 +1,4 @@ -import { effect, inject, Injectable, OnDestroy, signal } from '@angular/core'; +import { effect, inject, Injectable, OnDestroy, signal, untracked } from '@angular/core'; import { Edge, GroupNode, Metadata, Node, Point, Port, Rect } from '../../core/src'; import { calculatePartsBounds } from '../../core/src/utils/dimensions'; import { NgDiagramBaseService } from './ng-diagram-base.service'; @@ -47,11 +47,13 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes effect(() => { if (this.diagramService.isInitialized()) { this.flowCore.model.onChange(this.modelListener); - this.modelListener({ - nodes: this.flowCore.model.getNodes(), - edges: this.flowCore.model.getEdges(), - metadata: this.flowCore.model.getMetadata(), - }); + untracked(() => + this.modelListener({ + nodes: this.flowCore.model.getNodes(), + edges: this.flowCore.model.getEdges(), + metadata: this.flowCore.model.getMetadata(), + }) + ); } }); } From 5f53d0b35b5fe4209613c82291f382a48b961b60 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 5 Feb 2026 15:13:10 +0100 Subject: [PATCH 41/42] Fix custom model example --- .../angular/examples/custom-model/diagram.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/src/components/angular/examples/custom-model/diagram.component.ts b/apps/docs/src/components/angular/examples/custom-model/diagram.component.ts index dcb71e0c6..7385d81d8 100644 --- a/apps/docs/src/components/angular/examples/custom-model/diagram.component.ts +++ b/apps/docs/src/components/angular/examples/custom-model/diagram.component.ts @@ -95,8 +95,8 @@ export class DiagramComponent { id: 'edge-1', source: '1', target: '2', - sourcePort: 'port-right', - targetPort: 'port-left', + sourcePort: 'port-bottom', + targetPort: 'port-top', data: {}, }, ], From 585e9e2ca16406431a4fa2f1ef2caece325183b9 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Thu, 5 Feb 2026 15:42:54 +0100 Subject: [PATCH 42/42] update api report --- packages/ng-diagram/api-report/ng-diagram.api.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index 918dbf143..3845cfb4b 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -161,7 +161,7 @@ export interface DiagramInitEvent { // @public (undocumented) export class DiagramSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | undefined>; + readonly targetData: InputSignal | Node_2 | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -272,7 +272,7 @@ export type EdgeRoutingName = LooseAutocomplete; // @public (undocumented) export class EdgeSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | undefined>; + readonly targetData: InputSignal | Node_2 | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -1220,7 +1220,7 @@ export interface NodeRotationConfig { // @public (undocumented) export class NodeSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | undefined>; + readonly targetData: InputSignal | Node_2 | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -1525,7 +1525,7 @@ export interface ZIndexConfig { // @public (undocumented) export class ZIndexDirective { // (undocumented) - data: InputSignal>; + data: InputSignal | Node_2>; // (undocumented) zIndex: Signal; // (undocumented)