Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 99 additions & 9 deletions modules/graph-layers/src/layouts/gpu-force/gpu-force-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class GPUForceLayout extends GraphLayout<GPUForceLayoutOptions> {
private _edgeMap: any;
private _graph: any;
private _worker: Worker | null = null;
private _isWorkerRunning = false;
private _callbacks: any;

constructor(options: GPUForceLayoutOptions = {}) {
Expand Down Expand Up @@ -94,22 +95,30 @@ export class GPUForceLayout extends GraphLayout<GPUForceLayoutOptions> {
}

start() {
this._engageWorker();
this._engageWorker(() => this._onLayoutStart());
}

update() {
this._engageWorker();
this._engageWorker(() => this._onLayoutStart());
}

_engageWorker() {
// prevent multiple start
_engageWorker(beforePost?: () => void) {
const shouldDispatchStart = !this._isWorkerRunning;

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;

if (shouldDispatchStart) {
beforePost?.();
}

this._worker.postMessage({
nodes: this._d3Graph.nodes,
edges: this._d3Graph.edges,
Expand All @@ -134,21 +143,30 @@ export class GPUForceLayout extends GraphLayout<GPUForceLayoutOptions> {
}
};
}
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});
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
Expand Down Expand Up @@ -223,6 +241,78 @@ export class GPUForceLayout extends GraphLayout<GPUForceLayoutOptions> {
this._d3Graph.edges = newD3Edges;
}

private _disengageWorker() {
if (this._worker) {
this._worker.terminate();
this._worker = null;
}
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) {
Expand Down
101 changes: 98 additions & 3 deletions modules/graph-layers/test/core/graph-engine.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,105 @@
// 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 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';
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({

Check failure on line 42 in modules/graph-layers/test/core/graph-engine.spec.ts

View workflow job for this annotation

GitHub Actions / test-node

modules/graph-layers/test/core/graph-engine.spec.ts > core/graph-engine > fires onLayoutStart when GPUForceLayout starts

TypeError: __vite_ssr_import_2__.Graph is not a constructor ❯ modules/graph-layers/test/core/graph-engine.spec.ts:42:19
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();
});

it('updates bounds on each GPU tick event', () => {
const layout = new GPUForceLayout();
const graph = new Graph({

Check failure on line 65 in modules/graph-layers/test/core/graph-engine.spec.ts

View workflow job for this annotation

GitHub Actions / test-node

modules/graph-layers/test/core/graph-engine.spec.ts > core/graph-engine > updates bounds on each GPU tick event

TypeError: __vite_ssr_import_2__.Graph is not a constructor ❯ modules/graph-layers/test/core/graph-engine.spec.ts:65:19
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<GraphLayoutEventDetail>;
expect(lastEvent?.detail?.bounds).toEqual([
[10, 5],
[110, 105]
]);

MockWorker.lastInstance?.onmessage?.({
data: {type: 'end', nodes: tickNodes, edges: tickEdges}
});

engine.stop();
engine.clear();
});
});
Loading