diff --git a/modules/graph-layers/src/layouts/d3-force/d3-force-layout.ts b/modules/graph-layers/src/layouts/d3-force/d3-force-layout.ts index f0893d57..6122fbcf 100644 --- a/modules/graph-layers/src/layouts/d3-force/d3-force-layout.ts +++ b/modules/graph-layers/src/layouts/d3-force/d3-force-layout.ts @@ -50,16 +50,16 @@ export class D3ForceLayout extends GraphLayout { } start() { - this._engageWorker(); - this._onLayoutStart(); + this._engageWorker(); } update() { + this._onLayoutStart(); this._engageWorker(); } - _engageWorker() { + _engageWorker(isResume = false) { // prevent multiple start if (this._worker) { this._worker.terminate(); @@ -67,6 +67,13 @@ export class D3ForceLayout extends GraphLayout { this._worker = new Worker(new URL('./worker.js', import.meta.url).href); + const options = isResume + ? { + ...this.props, + alpha: this.props.resumeAlpha + } + : this.props; + this._worker.postMessage({ nodes: this._graph.getNodes().map((node) => ({ id: node.id, @@ -77,30 +84,31 @@ export class D3ForceLayout extends GraphLayout { source: edge.getSourceNodeId(), target: edge.getTargetNodeId() })), - options: this.props + options }); this._worker.onmessage = (event) => { log.log(0, 'D3ForceLayout: worker message', event.data?.type, event.data); - if (event.data.type !== 'end') { - return; + const {type} = event.data ?? {}; + switch (type) { + case 'tick': + this._refreshCachedPositions(event.data.nodes); + this._onLayoutChange(); + break; + case 'end': + this._refreshCachedPositions(event.data.nodes); + this._onLayoutChange(); + this._onLayoutDone(); + break; + default: + break; } - - event.data.nodes.forEach(({id, ...d3}) => - this._positionsByNodeId.set(id, { - ...d3, - // precompute so that when we return the node position we do not need to do the conversion - coordinates: [d3.x, d3.y] - }) - ); - - this._onLayoutChange(); - this._onLayoutDone(); }; } resume() { - throw new Error('Resume unavailable'); + this._onLayoutStart(); + this._engageWorker(true); } stop() { @@ -165,6 +173,37 @@ export class D3ForceLayout extends GraphLayout { d3Node.fy = null; }; + private _refreshCachedPositions(nodes?: Array<{id: string | number}>) { + if (!Array.isArray(nodes)) { + return; + } + + nodes.forEach((node) => { + if (!node || node.id === undefined) { + return; + } + + const {id, ...rest} = node as {id: string | number; x?: number; y?: number}; + const existing = this._positionsByNodeId.get(id) ?? {}; + const next = { + ...existing, + ...rest + } as { + x?: number; + y?: number; + coordinates?: [number, number]; + }; + + if (typeof next.x === 'number' && Number.isFinite(next.x) && typeof next.y === 'number' && Number.isFinite(next.y)) { + next.coordinates = [next.x, next.y]; + } else if (existing.coordinates) { + next.coordinates = existing.coordinates; + } + + this._positionsByNodeId.set(id, next); + }); + } + protected override _updateBounds(): void { const positions = Array.from( this._positionsByNodeId.values(), diff --git a/modules/graph-layers/test/core/graph-engine-layout-events.spec.ts b/modules/graph-layers/test/core/graph-engine-layout-events.spec.ts new file mode 100644 index 00000000..98d9100c --- /dev/null +++ b/modules/graph-layers/test/core/graph-engine-layout-events.spec.ts @@ -0,0 +1,145 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {describe, it, expect} from 'vitest'; + +import type {Bounds2D} from '@math.gl/types'; + +import {GraphEngine} from '../../src/core/graph-engine'; +import {GraphLayout, type GraphLayoutEventDetail} from '../../src/core/graph-layout'; +import type {Graph, EdgeInterface, NodeInterface} from '../../src/graph/graph'; +import type {GraphStyleEngine, GraphStylesheet} from '../../src/style/graph-style-engine'; + +class TestGraph extends EventTarget implements Graph { + version = 0; + + constructor( + private readonly nodes: NodeInterface[] = [], + private readonly edges: EdgeInterface[] = [] + ) { + super(); + } + + getNodes(): Iterable { + return this.nodes; + } + + getEdges(): Iterable { + return this.edges; + } + + // eslint-disable-next-line class-methods-use-this + createStylesheetEngine(_style: GraphStylesheet, _options?: {stateUpdateTrigger?: unknown}): GraphStyleEngine { + throw new Error('Not implemented in tests.'); + } +} + +class LifecycleLayout extends GraphLayout { + public startCalls = 0; + public updateCalls = 0; + public resumeCalls = 0; + public updateGraphCalls = 0; + + constructor() { + super({}, {}); + } + + initializeGraph(): void {} + + updateGraph(): void { + this.updateGraphCalls += 1; + } + + start(): void { + this.startCalls += 1; + this._emitLifecycleCycle(); + } + + update(): void { + this.updateCalls += 1; + this._emitLifecycleCycle(); + } + + resume(): void { + this.resumeCalls += 1; + this._emitLifecycleCycle(); + } + + stop(): void {} + + private _emitLifecycleCycle() { + this._onLayoutStart(); + this._onLayoutChange(); + this._onLayoutDone(); + } +} + +type CapturedEvent = { + type: 'start' | 'change' | 'done'; + bounds?: Bounds2D | null; +}; + +function recordEngineEvents(engine: GraphEngine): CapturedEvent[] { + const events: CapturedEvent[] = []; + const handler = (type: CapturedEvent['type']) => (event: Event) => { + const detail = event instanceof CustomEvent ? (event.detail as GraphLayoutEventDetail) : undefined; + events.push({type, bounds: detail?.bounds}); + }; + + engine.addEventListener('onLayoutStart', handler('start')); + engine.addEventListener('onLayoutChange', handler('change')); + engine.addEventListener('onLayoutDone', handler('done')); + + return events; +} + +describe('GraphEngine layout lifecycle events', () => { + it('emits start/change/done in order on initial run', () => { + const graph = new TestGraph(); + const layout = new LifecycleLayout(); + const engine = new GraphEngine({graph, layout}); + + const events = recordEngineEvents(engine); + + engine.run(); + + expect(events.map((event) => event.type)).toEqual(['start', 'change', 'done']); + expect(layout.startCalls).toBe(1); + expect(layout.updateCalls).toBe(0); + expect(layout.resumeCalls).toBe(0); + }); + + it('emits start/change/done when the graph updates', () => { + const graph = new TestGraph(); + const layout = new LifecycleLayout(); + const engine = new GraphEngine({graph, layout}); + + const events = recordEngineEvents(engine); + + engine.run(); + events.length = 0; + + graph.dispatchEvent(new Event('onNodeAdded')); + + expect(layout.updateGraphCalls).toBe(1); + expect(layout.updateCalls).toBe(1); + expect(events.map((event) => event.type)).toEqual(['start', 'change', 'done']); + }); + + it('emits start/change/done when resuming the layout', () => { + const graph = new TestGraph(); + const layout = new LifecycleLayout(); + const engine = new GraphEngine({graph, layout}); + + const events = recordEngineEvents(engine); + + engine.run(); + events.length = 0; + + engine.resume(); + + expect(layout.resumeCalls).toBe(1); + expect(events.map((event) => event.type)).toEqual(['start', 'change', 'done']); + }); +}); diff --git a/modules/graph-layers/test/core/graph-engine-layout-events.spec.ts.disabled b/modules/graph-layers/test/core/graph-engine-layout-events.spec.ts.disabled deleted file mode 100644 index 8a747f80..00000000 --- a/modules/graph-layers/test/core/graph-engine-layout-events.spec.ts.disabled +++ /dev/null @@ -1,79 +0,0 @@ -// deck.gl-community -// SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors - -import {describe, it, expect} from 'vitest'; - -import type {Bounds2D} from '@math.gl/types'; - -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 {HivePlotLayout} from '../../src/layouts/experimental/hive-plot-layout'; -import {RadialLayout} from '../../src/layouts/experimental/radial-layout'; - -type CapturedEvent = { - type: 'start' | 'change' | 'done'; - bounds?: Bounds2D | null; -}; - -function recordEngineEvents(engine: GraphEngine): CapturedEvent[] { - const events: CapturedEvent[] = []; - const handler = (type: CapturedEvent['type']) => (event: Event) => { - const detail = event instanceof CustomEvent ? (event.detail as GraphLayoutEventDetail) : undefined; - events.push({type, bounds: detail?.bounds}); - }; - - engine.addEventListener('onLayoutStart', handler('start')); - engine.addEventListener('onLayoutChange', handler('change')); - engine.addEventListener('onLayoutDone', handler('done')); - - return events; -} - -describe('GraphEngine layout lifecycle events', () => { - it('emits start/change/done in order for HivePlotLayout', () => { - const nodes = [ - new Node({id: 'a', data: {group: 0}}), - new Node({id: 'b', data: {group: 1}}), - new Node({id: 'c', data: {group: 0}}) - ]; - const graph = new Graph({nodes}); - const layout = new HivePlotLayout(); - const engine = new GraphEngine({graph, layout}); - - const events = recordEngineEvents(engine); - - engine.run(); - - expect(events.map((event) => event.type)).toEqual(['start', 'change', 'done']); - expect(events[0]?.bounds).not.toBeNull(); - expect(events[1]?.bounds).toEqual(events[2]?.bounds); - expect(events[2]?.bounds).toEqual(layout.getBounds()); - }); - - it('emits start/change/done in order for RadialLayout', () => { - const nodes = ['root', 'child-1', 'child-2', 'leaf-1', 'leaf-2'].map((id) => new Node({id})); - const graph = new Graph({nodes}); - const layout = new RadialLayout({ - tree: [ - {id: 'root', children: ['child-1', 'child-2']}, - {id: 'child-1', children: ['leaf-1']}, - {id: 'child-2', children: ['leaf-2']}, - {id: 'leaf-1', children: []}, - {id: 'leaf-2', children: []} - ] - }); - const engine = new GraphEngine({graph, layout}); - - const events = recordEngineEvents(engine); - - engine.run(); - - expect(events.map((event) => event.type)).toEqual(['start', 'change', 'done']); - expect(events[0]?.bounds).toBeNull(); - expect(events[1]?.bounds).toEqual(events[2]?.bounds); - expect(events[2]?.bounds).toEqual(layout.getBounds()); - }); -});