From fa496cd9d3abe05fe04587b1330467b65f10dcf9 Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Sat, 8 Nov 2025 09:13:22 -0500 Subject: [PATCH 1/2] Fix GPU layout start events and add regression test --- .../src/layouts/gpu-force/gpu-force-layout.ts | 31 +++++++--- .../test/core/graph-engine.spec.ts | 56 ++++++++++++++++++- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/modules/graph-layers/src/layouts/gpu-force/gpu-force-layout.ts b/modules/graph-layers/src/layouts/gpu-force/gpu-force-layout.ts index c77197896..308118d9f 100644 --- a/modules/graph-layers/src/layouts/gpu-force/gpu-force-layout.ts +++ b/modules/graph-layers/src/layouts/gpu-force/gpu-force-layout.ts @@ -32,6 +32,7 @@ export class GPUForceLayout extends GraphLayout { private _edgeMap: any; private _graph: any; private _worker: Worker | null = null; + private _isWorkerRunning = false; private _callbacks: any; constructor(options: GPUForceLayoutOptions = {}) { @@ -94,22 +95,30 @@ export class GPUForceLayout extends GraphLayout { } start() { - this._engageWorker(); + this._engageWorker(() => this._onLayoutStart()); } update() { - this._engageWorker(); + this._engageWorker(() => this._onLayoutStart()); } - _engageWorker() { - // prevent multiple start + _engageWorker(beforePost?: () => void) { + if (this._isWorkerRunning) { + return; + } + if (this._worker) { this._worker.terminate(); + this._worker = null; } this._worker = new Worker(new URL('./worker.js', import.meta.url).href); + this._isWorkerRunning = true; const {alpha, nBodyStrength, nBodyDistanceMin, nBodyDistanceMax, getCollisionRadius} = this.props; + + beforePost?.(); + this._worker.postMessage({ nodes: this._d3Graph.nodes, edges: this._d3Graph.edges, @@ -140,15 +149,13 @@ export class GPUForceLayout extends GraphLayout { this.updateD3Graph({nodes, edges}); this._onLayoutChange(); this._onLayoutDone(); + this._disengageWorker(); } resume() { throw new Error('Resume unavailable'); } stop() { - if (this._worker) { - this._worker.terminate(); - this._worker = null; - } + this._disengageWorker(); } // for steaming new data on the same graph @@ -223,6 +230,14 @@ export class GPUForceLayout extends GraphLayout { this._d3Graph.edges = newD3Edges; } + private _disengageWorker() { + if (this._worker) { + this._worker.terminate(); + this._worker = null; + } + this._isWorkerRunning = false; + } + getNodePosition = (node): [number, number] => { const d3Node = this._nodeMap[node.id]; if (d3Node) { diff --git a/modules/graph-layers/test/core/graph-engine.spec.ts b/modules/graph-layers/test/core/graph-engine.spec.ts index 247dc7d7b..4dd0af40c 100644 --- a/modules/graph-layers/test/core/graph-engine.spec.ts +++ b/modules/graph-layers/test/core/graph-engine.spec.ts @@ -2,10 +2,60 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {describe, it, expect} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import {GraphEngine} from '../../src/core/graph-engine'; +import {Graph} from '../../src/graph/graph'; +import {Node} from '../../src/graph/node'; +import {Edge} from '../../src/graph/edge'; +import {GPUForceLayout} from '../../src/layouts/gpu-force/gpu-force-layout'; + +class MockWorker { + static lastInstance: MockWorker | null = null; + + onmessage: ((event: {data: any}) => void) | null = null; + + constructor(_url: string) { + MockWorker.lastInstance = this; + } + + postMessage(_data: unknown) {} + + terminate() {} +} describe('core/graph-engine', () => { - it('nothing', () => { - expect(1).toBe(1); + const OriginalWorker = globalThis.Worker; + + beforeEach(() => { + globalThis.Worker = MockWorker as unknown as typeof Worker; + }); + + afterEach(() => { + globalThis.Worker = OriginalWorker; + MockWorker.lastInstance = null; + }); + + it('fires onLayoutStart when GPUForceLayout starts', () => { + const layout = new GPUForceLayout(); + const graph = new Graph({ + name: 'test', + nodes: [new Node({id: 'a'}), new Node({id: 'b'})], + edges: [new Edge({id: 'edge-a-b', sourceId: 'a', targetId: 'b'})] + }); + const engine = new GraphEngine({graph, layout}); + const onLayoutStart = vi.fn(); + + engine.addEventListener('onLayoutStart', onLayoutStart); + engine.run(); + + expect(onLayoutStart).toHaveBeenCalledTimes(1); + + MockWorker.lastInstance?.onmessage?.({ + data: {type: 'end', nodes: [], edges: []} + }); + + engine.stop(); + engine.clear(); }); }); From b143ffb6bb22a74331fd476fcb568ead2835e5f5 Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Sat, 8 Nov 2025 17:02:09 -0500 Subject: [PATCH 2/2] Fix GPU layout bounds updates --- .../src/layouts/gpu-force/gpu-force-layout.ts | 85 +++++++++++++++++-- .../test/core/graph-engine.spec.ts | 45 ++++++++++ 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/modules/graph-layers/src/layouts/gpu-force/gpu-force-layout.ts b/modules/graph-layers/src/layouts/gpu-force/gpu-force-layout.ts index 308118d9f..b3c496f4f 100644 --- a/modules/graph-layers/src/layouts/gpu-force/gpu-force-layout.ts +++ b/modules/graph-layers/src/layouts/gpu-force/gpu-force-layout.ts @@ -103,9 +103,7 @@ export class GPUForceLayout extends GraphLayout { } _engageWorker(beforePost?: () => void) { - if (this._isWorkerRunning) { - return; - } + const shouldDispatchStart = !this._isWorkerRunning; if (this._worker) { this._worker.terminate(); @@ -117,7 +115,9 @@ export class GPUForceLayout extends GraphLayout { const {alpha, nBodyStrength, nBodyDistanceMin, nBodyDistanceMax, getCollisionRadius} = this.props; - beforePost?.(); + if (shouldDispatchStart) { + beforePost?.(); + } this._worker.postMessage({ nodes: this._d3Graph.nodes, @@ -143,7 +143,18 @@ export class GPUForceLayout extends GraphLayout { } }; } - ticked(data) {} + ticked(data) { + const nodesUpdated = this._applyTickNodes(data?.nodes); + if (!nodesUpdated) { + return; + } + + if (Array.isArray(data?.edges) && data.edges.length > 0) { + this._applyTickEdges(data.edges); + } + + this._onLayoutChange(); + } ended(data) { const {nodes, edges} = data; this.updateD3Graph({nodes, edges}); @@ -238,6 +249,70 @@ export class GPUForceLayout extends GraphLayout { this._isWorkerRunning = false; } + private _applyTickNodes(nodes: any[] | undefined): boolean { + if (!Array.isArray(nodes) || nodes.length === 0) { + return false; + } + + for (const node of nodes) { + const existingNode = this._nodeMap[node.id]; + if (existingNode) { + existingNode.x = node.x; + existingNode.y = node.y; + if ('fx' in node) { + existingNode.fx = node.fx; + } + if ('fy' in node) { + existingNode.fy = node.fy; + } + if ('locked' in node) { + existingNode.locked = node.locked; + } + if ('collisionRadius' in node) { + existingNode.collisionRadius = node.collisionRadius; + } + } else { + const newNode = {...node}; + this._nodeMap[node.id] = newNode; + this._d3Graph.nodes.push(newNode); + } + } + + return true; + } + + private _applyTickEdges(edges: any[]): void { + for (const edge of edges) { + const sourceId = this._resolveNodeId(edge.source); + const targetId = this._resolveNodeId(edge.target); + const source = sourceId ? this._nodeMap[sourceId] : undefined; + const target = targetId ? this._nodeMap[targetId] : undefined; + if (source && target) { + const existingEdge = this._edgeMap[edge.id]; + if (existingEdge) { + existingEdge.source = source; + existingEdge.target = target; + } else { + const newEdge = { + ...edge, + source, + target + }; + this._edgeMap[edge.id] = newEdge; + this._d3Graph.edges.push(newEdge); + } + } + } + } + + private _resolveNodeId(node: any): string | number | undefined { + if (node && typeof node === 'object') { + return node.id; + } + + return node; + } + getNodePosition = (node): [number, number] => { const d3Node = this._nodeMap[node.id]; if (d3Node) { diff --git a/modules/graph-layers/test/core/graph-engine.spec.ts b/modules/graph-layers/test/core/graph-engine.spec.ts index 4dd0af40c..ddeade96c 100644 --- a/modules/graph-layers/test/core/graph-engine.spec.ts +++ b/modules/graph-layers/test/core/graph-engine.spec.ts @@ -5,6 +5,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {GraphEngine} from '../../src/core/graph-engine'; +import type {GraphLayoutEventDetail} from '../../src/core/graph-layout'; import {Graph} from '../../src/graph/graph'; import {Node} from '../../src/graph/node'; import {Edge} from '../../src/graph/edge'; @@ -58,4 +59,48 @@ describe('core/graph-engine', () => { engine.stop(); engine.clear(); }); + + it('updates bounds on each GPU tick event', () => { + const layout = new GPUForceLayout(); + const graph = new Graph({ + name: 'bounds-test', + nodes: [new Node({id: 'a'}), new Node({id: 'b'})], + edges: [new Edge({id: 'edge-a-b', sourceId: 'a', targetId: 'b'})] + }); + const engine = new GraphEngine({graph, layout}); + const onLayoutChange = vi.fn(); + + engine.addEventListener('onLayoutChange', onLayoutChange); + engine.run(); + + const tickNodes = [ + {id: 'a', x: 10, y: 5, fx: null, fy: null, locked: false, collisionRadius: 0}, + {id: 'b', x: 110, y: 105, fx: null, fy: null, locked: false, collisionRadius: 0} + ]; + const tickEdges = [ + { + id: 'edge-a-b', + source: tickNodes[0], + target: tickNodes[1] + } + ]; + + MockWorker.lastInstance?.onmessage?.({ + data: {type: 'tick', nodes: tickNodes, edges: tickEdges} + }); + + expect(onLayoutChange).toHaveBeenCalled(); + const lastEvent = onLayoutChange.mock.calls.at(-1)?.[0] as CustomEvent; + expect(lastEvent?.detail?.bounds).toEqual([ + [10, 5], + [110, 105] + ]); + + MockWorker.lastInstance?.onmessage?.({ + data: {type: 'end', nodes: tickNodes, edges: tickEdges} + }); + + engine.stop(); + engine.clear(); + }); });