diff --git a/docs/modules/graph-layers/api-reference/layouts/graph-layout.md b/docs/modules/graph-layers/api-reference/layouts/graph-layout.md index be43826b..3a3e0cda 100644 --- a/docs/modules/graph-layers/api-reference/layouts/graph-layout.md +++ b/docs/modules/graph-layers/api-reference/layouts/graph-layout.md @@ -39,6 +39,16 @@ export class MyLayout extends GraphLayout { We will start with a `RandomLayout` as an example, you can follow the steps one by one and find the source code at the bottom. +### Updating layout props + +All layouts expose a `setProps(partialProps)` method that merges new configuration into the current layout instance. The helper validates incoming values and returns `true` when the layout should recompute. Most importantly, the graph data consumed by the layout must now be supplied through this method: + +```js +layout.setProps({graph, gap: [10, 10]}); +``` + +Whenever the `graph` prop changes, `GraphLayout` automatically calls the protected `updateGraph(graph)` hook so subclasses can rebuild their internal caches. + ## The Layout Lifecycle For a graph layout, everything goes through a set of events. In each event, the layout will need to take the inputs and do the different computations. Lifecycle methods are various methods which are invoked at different phases of the lifecycle of a graph layout. If you are aware of these lifecycle events, it will enable you to control their entire flow and it will definitely help us to produce better results. @@ -74,17 +84,14 @@ startDragging => lockNodePosition => release => unlockNodePosition => resume ### Update the graph data -GraphGL will call `initializeGraph` to pass the graph data into the layout. -If the graph is the same one but part ofthe data is changed, GraphGL will call `updateGraph` method to notify the layout. - -In this case, we can just simply update the `this._nodePositionMap` by going through all nodes in the graph. +GraphGL supplies the current graph data through `setProps({graph})` before invoking `initializeGraph`. If you instantiate a layout yourself, follow the same pattern and call `layout.setProps({graph})` to notify the base class about the new data. Override the protected `updateGraph(graph)` hook to rebuild caches when the data changes. ```js initializeGraph(graph) { - this.updateGraph(graph); + this.setProps({graph}); } - updateGraph(grpah) { + protected updateGraph(graph) { this._graph = graph; this._nodePositionMap = graph.getNodes().reduce((res, node) => { res[node.getId()] = this._nodePositionMap[node.getId()] || [0, 0]; @@ -153,11 +160,6 @@ export default class RandomLayout extends GraphLayout { super(options); // give a name to this layout this._name = 'RandomLayout'; - // combine the default options with user input - this.props = { - ...this.defaultProps, - ...options - }; // a map to persis the position of nodes. this._nodePositionMap = {}; } diff --git a/modules/graph-layers/src/core/graph-engine.ts b/modules/graph-layers/src/core/graph-engine.ts index 711c0973..93cc584d 100644 --- a/modules/graph-layers/src/core/graph-engine.ts +++ b/modules/graph-layers/src/core/graph-engine.ts @@ -245,8 +245,10 @@ export class GraphEngine extends EventTarget { _updateLayout = () => { log.log(0, 'GraphEngine: layout update'); - this._layout.updateGraph(this._graph); - this._layout.update(); + const shouldUpdate = this._layout.setProps({graph: this._graph}); + if (shouldUpdate) { + this._layout.update(); + } this._layoutDirty = false; }; diff --git a/modules/graph-layers/src/core/graph-layout.ts b/modules/graph-layers/src/core/graph-layout.ts index b807171e..55827057 100644 --- a/modules/graph-layers/src/core/graph-layout.ts +++ b/modules/graph-layers/src/core/graph-layout.ts @@ -17,7 +17,18 @@ export type GraphLayoutEventDetail = { bounds: Bounds2D | null; }; -export type GraphLayoutProps = {}; +export type GraphLayoutProps = { + /** + * Graph data consumed by this layout instance. + * + * External callers should update the layout's graph by calling {@link setProps} + * with a new `graph` value instead of invoking {@link updateGraph} directly. + */ + graph?: LegacyGraph; +}; + +export type GraphLayoutDefaultProps = + Omit, 'graph'> & Pick; /** All the layout classes are extended from this base layout class. */ export abstract class GraphLayout< @@ -26,7 +37,9 @@ export abstract class GraphLayout< /** Name of the layout. */ protected readonly _name: string = 'GraphLayout'; /** Extra configuration props of the layout. */ - protected props: Required; + protected props: GraphLayoutDefaultProps; + /** Baseline configuration that new props are merged against. */ + private readonly _defaultProps: GraphLayoutDefaultProps; /** * Last computed layout bounds in local layout coordinates. @@ -43,9 +56,46 @@ export abstract class GraphLayout< * Constructor of GraphLayout * @param props extra configuration props of the layout */ - constructor(props: GraphLayoutProps, defaultProps?: Required) { + constructor(props: GraphLayoutProps, defaultProps?: GraphLayoutDefaultProps) { super(); - this.props = {...defaultProps, ...props}; + this._defaultProps = defaultProps + ? {...defaultProps} + : ({} as GraphLayoutDefaultProps); + this.props = {...this._defaultProps, ...props} as GraphLayoutDefaultProps; + } + + setProps(partial: Partial): boolean { + if (!partial || Object.keys(partial).length === 0) { + return false; + } + + const nextProps = { + ...this._defaultProps, + ...this.props, + ...partial + } as GraphLayoutDefaultProps; + const validatedProps = this._validateProps(nextProps); + const previousProps = this.props; + const changedProps = this._getChangedProps(previousProps, validatedProps, partial); + + if (!changedProps) { + return false; + } + + this.props = validatedProps; + + if ('graph' in changedProps) { + const graph = (changedProps as Partial).graph; + if (graph) { + this.updateGraph(graph); + } + } + + this._onPropsUpdated(previousProps, validatedProps, changedProps); + + return 'graph' in changedProps + ? true + : this._shouldRecomputeLayout(previousProps, validatedProps, changedProps); } /** @@ -101,7 +151,7 @@ export abstract class GraphLayout< /** first time to pass the graph data into this layout */ abstract initializeGraph(graph: LegacyGraph); /** update the existing graph */ - abstract updateGraph(graph: LegacyGraph); + protected abstract updateGraph(graph: LegacyGraph): void; /** start the layout calculation */ abstract start(); /** update the layout calculation */ @@ -114,6 +164,71 @@ export abstract class GraphLayout< // INTERNAL METHODS + /** Allow subclasses to coerce or validate the next props object. */ + // eslint-disable-next-line class-methods-use-this + protected _validateProps( + nextProps: GraphLayoutDefaultProps + ): GraphLayoutDefaultProps { + return nextProps; + } + + /** + * Hook invoked after props are committed. Subclasses can perform additional + * bookkeeping or cache invalidation in response to the change. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars, class-methods-use-this + protected _onPropsUpdated( + previousProps: Readonly>, + nextProps: Readonly>, + changedProps: Partial + ): void {} + + /** + * Determine whether the layout should recompute after the supplied change. + * + * Subclasses can override to defer work if a prop change does not influence + * the resulting layout. + */ + protected _shouldRecomputeLayout( + previousProps: Readonly>, + nextProps: Readonly>, + changedProps: Partial + ): boolean { + return Object.keys(changedProps).length > 0; + } + + private _getChangedProps( + previousProps: Readonly>, + nextProps: Readonly>, + partial: Partial + ): Partial | null { + const changedProps: Partial = {}; + let changed = false; + + type DefaultKey = keyof GraphLayoutDefaultProps; + type PropsKey = Extract; + + for (const key of Object.keys(partial) as PropsKey[]) { + const defaultKey = key as DefaultKey; + const nextValue = nextProps[defaultKey]; + if (key === 'graph') { + // Always treat graph updates as significant so layout caches refresh. + changedProps[key] = nextValue as PropsT[typeof key]; + changed = true; + // eslint-disable-next-line no-continue + continue; + } + + const previousValue = previousProps[defaultKey]; + if (previousValue !== nextValue) { + changedProps[key] = nextValue as PropsT[typeof key]; + changed = true; + } + } + + return changed ? changedProps : null; + } + /** Hook for subclasses to update bounds prior to emitting events. */ // eslint-disable-next-line @typescript-eslint/no-empty-function protected _updateBounds(): void {} diff --git a/modules/graph-layers/src/core/graph-runtime-layout.ts b/modules/graph-layers/src/core/graph-runtime-layout.ts index fc5388cc..e1f00378 100644 --- a/modules/graph-layers/src/core/graph-runtime-layout.ts +++ b/modules/graph-layers/src/core/graph-runtime-layout.ts @@ -11,7 +11,7 @@ export interface GraphRuntimeLayout extends EventTarget { readonly version: number; readonly state: GraphLayoutState; initializeGraph(graph: Graph): void; - updateGraph(graph: Graph): void; + setProps(props: Record): boolean; start(): void; update(): void; resume(): void; diff --git a/modules/graph-layers/src/graph/legacy-graph.ts b/modules/graph-layers/src/graph/legacy-graph.ts index 8b672ae3..b610974b 100644 --- a/modules/graph-layers/src/graph/legacy-graph.ts +++ b/modules/graph-layers/src/graph/legacy-graph.ts @@ -6,7 +6,7 @@ import {warn} from '../utils/log'; import {Cache} from '../core/cache'; import {Edge} from './edge'; import {Node} from './node'; -import {GraphLayout, type GraphLayoutState} from '../core/graph-layout'; +import {GraphLayout, type GraphLayoutProps, type GraphLayoutState} from '../core/graph-layout'; import type {GraphRuntimeLayout} from '../core/graph-runtime-layout'; import type {EdgeInterface, Graph, NodeInterface} from './graph'; @@ -396,10 +396,6 @@ export class LegacyGraphLayoutAdapter extends EventTarget implements GraphRuntim this.layout.initializeGraph(this._assertLegacyGraph(graph)); } - updateGraph(graph: Graph): void { - this.layout.updateGraph(this._assertLegacyGraph(graph)); - } - start(): void { this.layout.start(); } @@ -443,6 +439,16 @@ export class LegacyGraphLayoutAdapter extends EventTarget implements GraphRuntim this.eventForwarders.clear(); } + setProps(props: Record): boolean { + if ('graph' in props && props.graph) { + return this.layout.setProps({ + ...(props as Partial), + graph: this._assertLegacyGraph(props.graph as Graph) + }); + } + return this.layout.setProps(props as Partial); + } + private _assertLegacyGraph(graph: Graph): LegacyGraph { if (graph instanceof LegacyGraph) { return graph; diff --git a/modules/graph-layers/src/layouts/d3-dag/collapsable-d3-dag-layout.ts b/modules/graph-layers/src/layouts/d3-dag/collapsable-d3-dag-layout.ts index c56de4e1..fd0f8a59 100644 --- a/modules/graph-layers/src/layouts/d3-dag/collapsable-d3-dag-layout.ts +++ b/modules/graph-layers/src/layouts/d3-dag/collapsable-d3-dag-layout.ts @@ -6,6 +6,7 @@ import type {LegacyGraph} from '../../graph/legacy-graph'; import type {NodeInterface, EdgeInterface} from '../../graph/graph'; +import {GraphLayoutDefaultProps} from '../../core/graph-layout'; import {log} from '../../utils/log'; import {D3DagLayout, type D3DagLayoutProps} from './d3-dag-layout'; @@ -23,10 +24,10 @@ export type CollapsableD3DagLayoutProps = D3DagLayoutProps & { } export class CollapsableD3DagLayout extends D3DagLayout { - static override defaultProps: Required = { + static override defaultProps = { ...D3DagLayout.defaultProps, collapseLinearChains: false - } + } as const satisfies GraphLayoutDefaultProps; private _chainDescriptors = new Map(); private _nodeToChainId = new Map(); @@ -37,14 +38,15 @@ export class CollapsableD3DagLayout extends D3DagLayout): void { - super.setProps(props); + override setProps(props: Partial): boolean { + const shouldUpdate = super.setProps(props); if (props.collapseLinearChains !== undefined && this._graph) { this._runLayout(); } + return shouldUpdate; } - override updateGraph(graph: LegacyGraph): void { + protected override updateGraph(graph: LegacyGraph): void { super.updateGraph(graph); this._chainDescriptors.clear(); this._nodeToChainId.clear(); diff --git a/modules/graph-layers/src/layouts/d3-dag/d3-dag-layout.ts b/modules/graph-layers/src/layouts/d3-dag/d3-dag-layout.ts index 7981296c..91246579 100644 --- a/modules/graph-layers/src/layouts/d3-dag/d3-dag-layout.ts +++ b/modules/graph-layers/src/layouts/d3-dag/d3-dag-layout.ts @@ -4,7 +4,11 @@ /* eslint-disable no-continue, complexity, max-statements */ -import {GraphLayout, GraphLayoutProps} from '../../core/graph-layout'; +import { + GraphLayout, + GraphLayoutDefaultProps, + GraphLayoutProps +} from '../../core/graph-layout'; import type {LegacyGraph} from '../../graph/legacy-graph'; import type {NodeInterface, EdgeInterface} from '../../graph/graph'; import {Node} from '../../graph/node'; @@ -155,7 +159,7 @@ function isEdgeInterface(value: unknown): value is EdgeInterface { * Layout that orchestrates d3-dag operators from declarative options. */ export class D3DagLayout extends GraphLayout { - static defaultProps: Readonly> = { + static defaultProps = { layout: 'sugiyama', layering: 'topological', decross: 'twoLayer', @@ -173,7 +177,7 @@ export class D3DagLayout ext customDecross: undefined, customCoord: undefined, customDagBuilder: undefined - } as const; + } as const satisfies GraphLayoutDefaultProps; protected readonly _name = 'D3DagLayout'; @@ -193,32 +197,24 @@ export class D3DagLayout ext protected _edgeLookup = new Map(); protected _incomingParentMap = new Map(); - constructor(props: D3DagLayoutProps, defaultProps?: Required) { - // @ts-expect-error TS2345 - Type 'Required' is not assignable to type 'Required'. - super(props, defaultProps || D3DagLayout.defaultProps); + constructor(props: PropsT, defaultProps?: GraphLayoutDefaultProps) { + super(props, defaultProps ?? (D3DagLayout.defaultProps as GraphLayoutDefaultProps)); } - setProps(options: Partial): void { - this.props = {...this.props, ...options}; - if ( - options.layout !== undefined || - options.layering !== undefined || - options.decross !== undefined || - options.coord !== undefined || - options.nodeSize !== undefined || - options.gap !== undefined || - options.separation !== undefined - ) { + override setProps(options: Partial): boolean { + const shouldUpdate = super.setProps(options as Partial); + if (this._shouldResetLayoutOperator(options)) { this._layoutOperator = null; } + return shouldUpdate; } initializeGraph(graph: LegacyGraph): void { - this.updateGraph(graph); + this.setProps({graph}); } - updateGraph(graph: LegacyGraph): void { + protected override updateGraph(graph: LegacyGraph): void { this._graph = graph; this._nodeLookup = new Map(); this._stringIdLookup = new Map(); @@ -526,6 +522,18 @@ export class D3DagLayout ext return dag as unknown as MutGraph; } + private _shouldResetLayoutOperator(options: Partial): boolean { + return ( + options.layout !== undefined || + options.layering !== undefined || + options.decross !== undefined || + options.coord !== undefined || + options.nodeSize !== undefined || + options.gap !== undefined || + options.separation !== undefined + ); + } + private _getLayoutOperator(): LayoutWithConfiguration { if (this._layoutOperator) { return this._layoutOperator; 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..f7216350 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 @@ -2,7 +2,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {GraphLayout, GraphLayoutProps} from '../../core/graph-layout'; +import {GraphLayout, GraphLayoutDefaultProps, GraphLayoutProps} from '../../core/graph-layout'; +import type {LegacyGraph} from '../../graph/legacy-graph'; import {log} from '../../utils/log'; export type D3ForceLayoutOptions = GraphLayoutProps & { @@ -22,30 +23,32 @@ export class D3ForceLayout extends GraphLayout { nBodyDistanceMin: 100, nBodyDistanceMax: 400, getCollisionRadius: 0 - } as const satisfies Readonly>; + } as const satisfies GraphLayoutDefaultProps; protected readonly _name = 'D3'; private _positionsByNodeId = new Map(); - private _graph: any; + private _graph: LegacyGraph | null = null; private _worker: any; - constructor(props?: D3ForceLayoutOptions) { - super({ - ...D3ForceLayout.defaultProps, - ...props - }); + constructor(props: D3ForceLayoutOptions = {}) { + super(props, D3ForceLayout.defaultProps); } - initializeGraph(graph) { - this._graph = graph; + initializeGraph(graph: LegacyGraph) { + this.setProps({graph}); } // for streaming new data on the same graph - updateGraph(graph) { + protected override updateGraph(graph: LegacyGraph) { this._graph = graph; this._positionsByNodeId = new Map( - this._graph.getNodes().map((node) => [node.id, this._positionsByNodeId.get(node.id)]) + this._graph + .getNodes() + .map((node) => { + const nodeId = node.getId(); + return [nodeId, this._positionsByNodeId.get(nodeId)]; + }) ); } @@ -65,19 +68,28 @@ export class D3ForceLayout extends GraphLayout { this._worker.terminate(); } + if (!this._graph) { + return; + } + this._worker = new Worker(new URL('./worker.js', import.meta.url).href); + const options = (({graph: _graph, ...rest}) => rest)(this.props); + this._worker.postMessage({ - nodes: this._graph.getNodes().map((node) => ({ - id: node.id, - ...this._positionsByNodeId.get(node.id) - })), + nodes: this._graph.getNodes().map((node) => { + const nodeId = node.getId(); + return { + id: nodeId, + ...this._positionsByNodeId.get(nodeId) + }; + }), edges: this._graph.getEdges().map((edge) => ({ - id: edge.id, + id: edge.getId(), source: edge.getSourceNodeId(), target: edge.getTargetNodeId() })), - options: this.props + options }); this._worker.onmessage = (event) => { @@ -111,6 +123,10 @@ export class D3ForceLayout extends GraphLayout { } getEdgePosition = (edge) => { + if (!this._graph) { + return null; + } + const sourceNode = this._graph.findNode(edge.getSourceNodeId()); const targetNode = this._graph.findNode(edge.getTargetNodeId()); if (!sourceNode || !targetNode) { @@ -137,7 +153,7 @@ export class D3ForceLayout extends GraphLayout { return null; } - const d3Node = this._positionsByNodeId.get(node.id); + const d3Node = this._positionsByNodeId.get(node.getId()); if (d3Node) { return d3Node.coordinates; } @@ -146,8 +162,9 @@ export class D3ForceLayout extends GraphLayout { }; lockNodePosition = (node, x, y) => { - const d3Node = this._positionsByNodeId.get(node.id); - this._positionsByNodeId.set(node.id, { + const nodeId = node.getId(); + const d3Node = this._positionsByNodeId.get(nodeId); + this._positionsByNodeId.set(nodeId, { ...d3Node, x, y, @@ -160,7 +177,7 @@ export class D3ForceLayout extends GraphLayout { }; unlockNodePosition = (node) => { - const d3Node = this._positionsByNodeId.get(node.id); + const d3Node = this._positionsByNodeId.get(node.getId()); d3Node.fx = null; d3Node.fy = null; }; diff --git a/modules/graph-layers/src/layouts/experimental/force-multi-graph-layout.ts b/modules/graph-layers/src/layouts/experimental/force-multi-graph-layout.ts index c0a9abbb..1ba79356 100644 --- a/modules/graph-layers/src/layouts/experimental/force-multi-graph-layout.ts +++ b/modules/graph-layers/src/layouts/experimental/force-multi-graph-layout.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {GraphLayout, GraphLayoutProps} from '../../core/graph-layout'; +import {GraphLayout, GraphLayoutDefaultProps, GraphLayoutProps} from '../../core/graph-layout'; import {Node} from '../../graph/node'; import {Edge} from '../../graph/edge'; import {LegacyGraph} from '../../graph/legacy-graph'; @@ -21,7 +21,7 @@ export class ForceMultiGraphLayout extends GraphLayout>; + } as const satisfies GraphLayoutDefaultProps; _name = 'ForceMultiGraphLayout'; _graph: LegacyGraph; @@ -34,14 +34,11 @@ export class ForceMultiGraphLayout extends GraphLayout { @@ -100,7 +97,7 @@ export class ForceMultiGraphLayout extends GraphLayout { - const oldD3Node = this._nodeMap[node.id]; - const newD3Node = oldD3Node ? oldD3Node : {id: node.id}; - newNodeMap[node.id] = newD3Node; + const nodeId = node.getId(); + const oldD3Node = this._nodeMap[nodeId]; + const newD3Node = oldD3Node ? oldD3Node : {id: nodeId}; + newNodeMap[nodeId] = newD3Node; return newD3Node; }); @@ -170,9 +168,10 @@ export class ForceMultiGraphLayout extends GraphLayout { - newEdgeMap[e.id] = { + const edgeId = e.getId(); + newEdgeMap[edgeId] = { type: 'spline-curve', - id: e.id, + id: edgeId, source: newNodeMap[e.getSourceNodeId()], target: newNodeMap[e.getTargetNodeId()], virtualEdgeId: pairId, @@ -189,7 +188,7 @@ export class ForceMultiGraphLayout extends GraphLayout { - const d3Node = this._nodeMap[node.id]; + const d3Node = this._nodeMap[node.getId()]; if (d3Node) { return [d3Node.x, d3Node.y]; } @@ -198,7 +197,8 @@ export class ForceMultiGraphLayout extends GraphLayout { - const d3Edge = this._edgeMap[edge.id]; + const edgeId = edge.getId(); + const d3Edge = this._edgeMap[edgeId]; if (d3Edge) { if (!d3Edge.isVirtual) { return { @@ -246,7 +246,7 @@ export class ForceMultiGraphLayout extends GraphLayout { - const d3Node = this._nodeMap[node.id]; + const d3Node = this._nodeMap[node.getId()]; d3Node.x = x; d3Node.y = y; this._onLayoutChange(); diff --git a/modules/graph-layers/src/layouts/experimental/hive-plot-layout.ts b/modules/graph-layers/src/layouts/experimental/hive-plot-layout.ts index bca36a6b..a9dc2855 100644 --- a/modules/graph-layers/src/layouts/experimental/hive-plot-layout.ts +++ b/modules/graph-layers/src/layouts/experimental/hive-plot-layout.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {GraphLayout, GraphLayoutProps} from '../../core/graph-layout'; +import {GraphLayout, GraphLayoutDefaultProps, GraphLayoutProps} from '../../core/graph-layout'; import {Node} from '../../graph/node'; import {LegacyGraph} from '../../graph/legacy-graph'; @@ -17,7 +17,7 @@ export class HivePlotLayout extends GraphLayout { innerRadius: 100, outerRadius: 500, getNodeAxis: (node: Node) => node.getPropertyValue('group') - } as const satisfies Readonly>; + } as const satisfies GraphLayoutDefaultProps; _name = 'HivePlot'; _graph: LegacyGraph; @@ -27,14 +27,14 @@ export class HivePlotLayout extends GraphLayout { _nodePositionMap = {}; constructor(props: HivePlotLayoutProps = {}) { - super({...HivePlotLayout.defaultProps, ...props}); + super(props, HivePlotLayout.defaultProps); } initializeGraph(graph: LegacyGraph) { - this.updateGraph(graph); + this.setProps({graph}); } - updateGraph(graph: LegacyGraph) { + protected override updateGraph(graph: LegacyGraph) { const {getNodeAxis, innerRadius, outerRadius} = this.props; this._graph = graph; const nodes = Array.isArray(graph.getNodes()) @@ -145,7 +145,7 @@ export class HivePlotLayout extends GraphLayout { }; lockNodePosition = (node, x, y) => { - this._nodePositionMap[node.id] = [x, y]; + this._nodePositionMap[node.getId()] = [x, y]; this._onLayoutChange(); this._onLayoutDone(); }; diff --git a/modules/graph-layers/src/layouts/experimental/radial-layout.ts b/modules/graph-layers/src/layouts/experimental/radial-layout.ts index c583baf9..2602aff5 100644 --- a/modules/graph-layers/src/layouts/experimental/radial-layout.ts +++ b/modules/graph-layers/src/layouts/experimental/radial-layout.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {GraphLayout, GraphLayoutProps} from '../../core/graph-layout'; +import {GraphLayout, GraphLayoutDefaultProps, GraphLayoutProps} from '../../core/graph-layout'; import {LegacyGraph} from '../../graph/legacy-graph'; import type {Node} from '../../graph/node'; @@ -56,7 +56,7 @@ export class RadialLayout extends GraphLayout { static defaultProps = { radius: 500, tree: [] - } as const satisfies Readonly>; + } as const satisfies GraphLayoutDefaultProps; _name = 'RadialLayout'; _graph: LegacyGraph | null = null; @@ -65,14 +65,14 @@ export class RadialLayout extends GraphLayout { nestedTree; constructor(props: RadialLayoutProps = {}) { - super({...RadialLayout.defaultProps, ...props}); + super(props, RadialLayout.defaultProps); } initializeGraph(graph: LegacyGraph): void { - this.updateGraph(graph); + this.setProps({graph}); } - updateGraph(graph: LegacyGraph): void { + protected override updateGraph(graph: LegacyGraph): void { this._graph = graph; } 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 c7719789..603694b2 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 @@ -2,7 +2,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {GraphLayout, GraphLayoutProps} from '../../core/graph-layout'; +import {GraphLayout, GraphLayoutDefaultProps, GraphLayoutProps} from '../../core/graph-layout'; +import type {LegacyGraph} from '../../graph/legacy-graph'; export type GPUForceLayoutOptions = GraphLayoutProps & { alpha?: number; @@ -17,7 +18,7 @@ export type GPUForceLayoutOptions = GraphLayoutProps & { * @todo this layout should be updated with the organizational and logic improvements made in d3-force */ export class GPUForceLayout extends GraphLayout { - static defaultProps: Required = { + static defaultProps: GraphLayoutDefaultProps = { alpha: 0.3, resumeAlpha: 0.1, nBodyStrength: -900, @@ -30,20 +31,13 @@ export class GPUForceLayout extends GraphLayout { private _d3Graph: any; private _nodeMap: any; private _edgeMap: any; - private _graph: any; + private _graph: LegacyGraph | null = null; private _worker: Worker | null = null; private _callbacks: any; constructor(options: GPUForceLayoutOptions = {}) { - const props = { - ...GPUForceLayout.defaultProps, - ...options - }; - - super(props); + super(options, GPUForceLayout.defaultProps); - this._name = 'GPU'; - this.props = props; // store graph and prepare internal data this._d3Graph = {nodes: [], edges: []}; this._nodeMap = {}; @@ -54,43 +48,8 @@ export class GPUForceLayout extends GraphLayout { }; } - initializeGraph(graph) { - this._graph = graph; - this._nodeMap = {}; - this._edgeMap = {}; - // nodes - const d3Nodes = graph.getNodes().map((node) => { - const id = node.id; - const locked = node.getPropertyValue('locked') || false; - const x = node.getPropertyValue('x') || 0; - const y = node.getPropertyValue('y') || 0; - const collisionRadius = node.getPropertyValue('collisionRadius') || 0; - const d3Node = { - id, - x, - y, - fx: locked ? x : null, - fy: locked ? y : null, - collisionRadius, - locked - }; - this._nodeMap[node.id] = d3Node; - return d3Node; - }); - // edges - const d3Edges = graph.getEdges().map((edge) => { - const d3Edge = { - id: edge.id, - source: this._nodeMap[edge.getSourceNodeId()], - target: this._nodeMap[edge.getTargetNodeId()] - }; - this._edgeMap[edge.id] = d3Edge; - return d3Edge; - }); - this._d3Graph = { - nodes: d3Nodes, - edges: d3Edges - }; + initializeGraph(graph: LegacyGraph) { + this.setProps({graph}); } start() { @@ -152,8 +111,8 @@ export class GPUForceLayout extends GraphLayout { } // for steaming new data on the same graph - updateGraph(graph) { - if (this._graph.getGraphName() !== graph.getGraphName()) { + protected override updateGraph(graph: LegacyGraph) { + if (this._graph && this._graph.getGraphName() !== graph.getGraphName()) { // reset the maps this._nodeMap = {}; this._edgeMap = {}; @@ -163,7 +122,7 @@ export class GPUForceLayout extends GraphLayout { // nodes const newNodeMap = {}; const newD3Nodes = graph.getNodes().map((node) => { - const id = node.id; + const id = node.getId(); const locked = node.getPropertyValue('locked') || false; const x = node.getPropertyValue('x') || 0; const y = node.getPropertyValue('y') || 0; @@ -171,9 +130,9 @@ export class GPUForceLayout extends GraphLayout { const fy = locked ? y : null; const collisionRadius = node.getPropertyValue('collisionRadius') || 0; - const oldD3Node = this._nodeMap[node.id]; + const oldD3Node = this._nodeMap[id]; const newD3Node = oldD3Node ? oldD3Node : {id, x, y, fx, fy, collisionRadius}; - newNodeMap[node.id] = newD3Node; + newNodeMap[id] = newD3Node; return newD3Node; }); this._nodeMap = newNodeMap; @@ -181,13 +140,14 @@ export class GPUForceLayout extends GraphLayout { // edges const newEdgeMap = {}; const newD3Edges = graph.getEdges().map((edge) => { - const oldD3Edge = this._edgeMap[edge.id]; + const edgeId = edge.getId(); + const oldD3Edge = this._edgeMap[edgeId]; const newD3Edge = oldD3Edge || { - id: edge.id, + id: edgeId, source: newNodeMap[edge.getSourceNodeId()], target: newNodeMap[edge.getTargetNodeId()] }; - newEdgeMap[edge.id] = newD3Edge; + newEdgeMap[edgeId] = newD3Edge; return newD3Edge; }); this._edgeMap = newEdgeMap; @@ -198,6 +158,9 @@ export class GPUForceLayout extends GraphLayout { const existingNodes = this._graph.getNodes(); // update internal layout data // nodes + if (!this._graph) { + return; + } const newNodeMap = {}; const newD3Nodes = graph.nodes.map((node) => { // Update existing _graph with the new values @@ -224,7 +187,7 @@ export class GPUForceLayout extends GraphLayout { } getNodePosition = (node): [number, number] => { - const d3Node = this._nodeMap[node.id]; + const d3Node = this._nodeMap[node.getId()]; if (d3Node) { return [d3Node.x, d3Node.y]; } diff --git a/modules/graph-layers/src/layouts/gpu-force/worker.js b/modules/graph-layers/src/layouts/gpu-force/worker.js index d8c94c6d..55a1c482 100644 --- a/modules/graph-layers/src/layouts/gpu-force/worker.js +++ b/modules/graph-layers/src/layouts/gpu-force/worker.js @@ -2,136 +2,157 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -/* global importScripts GPU*/ +/* global GPU */ -importScripts('https://cdn.jsdelivr.net/npm/gpu.js@latest/dist/gpu-browser.js'); +const workerScope = typeof self !== 'undefined' ? self : undefined; -onmessage = function (event) { - const {nodes: sourceNodes, edges: sourceEdges} = event.data; - const {nBodyStrength, nBodyDistanceMin, nBodyDistanceMax, getCollisionRadius} = - event.data.options; - // FIXME remove cpu mode - // @ts-expect-error TODO - const gpu = new GPU.GPU({ - mode: 'cpu' - }); - function getDistance(node1, node2) { - const dx = node1[1] - node2[1]; - const dy = node1[2] - node2[2]; - return Math.sqrt(dx * dx + dy * dy); - } - function isCollision(node1, node2, radius) { - return getDistance(node1, node2) < radius; - } +if (workerScope && typeof workerScope.importScripts === 'function') { + workerScope.importScripts('https://cdn.jsdelivr.net/npm/gpu.js@latest/dist/gpu-browser.js'); +} - function forceCollide(nodes, currentNode, nodesSize, radius) { - let collisons = true; - while (collisons) { - collisons = false; - for (let i = 0; i < nodesSize; i++) { - while (nodes[i][0] !== currentNode[0] && isCollision(currentNode, nodes[i], radius)) { - collisons = true; - const xMove = currentNode[1] + Math.random() - 0.5; - currentNode[1] = currentNode[1] + xMove; - const yMove = currentNode[2] + Math.random() - 0.5; - currentNode[2] = currentNode[2] + yMove; - } +function getDistance(node1, node2) { + const dx = node1[1] - node2[1]; + const dy = node1[2] - node2[2]; + return Math.sqrt(dx * dx + dy * dy); +} + +function isCollision(node1, node2, radius) { + return getDistance(node1, node2) < radius; +} + +function forceCollide(nodes, currentNode, nodesSize, radius) { + let collisons = true; + while (collisons) { + collisons = false; + for (let i = 0; i < nodesSize; i++) { + while (nodes[i][0] !== currentNode[0] && isCollision(currentNode, nodes[i], radius)) { + collisons = true; + const xMove = currentNode[1] + Math.random() - 0.5; + currentNode[1] = currentNode[1] + xMove; + const yMove = currentNode[2] + Math.random() - 0.5; + currentNode[2] = currentNode[2] + yMove; } } - return [currentNode[1], currentNode[2]]; } - // FIXME correct lint errors - // eslint-disable-next-line max-params - function forceLink(nodes, edges, currentNode, nodesSize, edgesSize, radius) { - let x1 = currentNode[1]; - let y1 = currentNode[2]; - for (let i = 0; i < edgesSize; i++) { - const edge = edges[i]; - if (edge[0] === currentNode[0] || edge[1] === currentNode[0]) { - const otherNodeId = edge[0] === currentNode[0] ? edge[1] : edge[0]; - // FIXME: deal with the fact that GPUjs doesn't like array.find or undefined - for (let j = 0; j < nodesSize; j++) { + return [currentNode[1], currentNode[2]]; +} + +// FIXME correct lint errors +// eslint-disable-next-line max-params +function forceLink(nodes, edges, currentNode, nodesSize, edgesSize, radius) { + let x1 = currentNode[1]; + let y1 = currentNode[2]; + for (let i = 0; i < edgesSize; i++) { + const edge = edges[i]; + if (edge[0] === currentNode[0] || edge[1] === currentNode[0]) { + const otherNodeId = edge[0] === currentNode[0] ? edge[1] : edge[0]; + // FIXME: deal with the fact that GPUjs doesn't like array.find or undefined + for (let j = 0; j < nodesSize; j++) { + // eslint-disable-next-line max-depth + if (nodes[j][0] === otherNodeId) { + const otherNode = nodes[j]; + const x2 = otherNode[1]; + const y2 = otherNode[2]; + const dx = x1 - x2; + const dy = y1 - y2; + const distance = Math.sqrt(dx * dx + dy * dy); + const force = 1; // eslint-disable-next-line max-depth - if (nodes[i][0] === otherNodeId) { - const otherNode = nodes[j]; - const x2 = otherNode[1]; - const y2 = otherNode[2]; - const dx = x1 - x2; - const dy = y1 - y2; - const distance = Math.sqrt(dx * dx + dy * dy); - const force = 1; - // eslint-disable-next-line max-depth - if (distance > radius + force) { - x1 = dx > 0 ? x1 - force / 2 : x1 + force / 2; - y1 = dy > 0 ? y1 - force / 2 : y1 + force / 2; - } + if (distance > radius + force) { + x1 = dx > 0 ? x1 - force / 2 : x1 + force / 2; + y1 = dy > 0 ? y1 - force / 2 : y1 + force / 2; } } } } - return [x1, y1]; } + return [x1, y1]; +} - gpu.addFunction(forceCollide); - gpu.addFunction(forceLink); - gpu.addFunction(isCollision); - gpu.addFunction(getDistance); - const kernel = gpu.createKernel( - function (kernelNodes, kernelEdges) { - const currentNode = kernelNodes[this.thread.x]; - forceCollide( - kernelNodes, - currentNode, - this.constants.nodesSize, - this.constants.collisionRadius - ); - const forceLinkResult = forceLink( - kernelNodes, - kernelEdges, - currentNode, - this.constants.nodesSize, - this.constants.edgesSize, - this.constants.collisionRadius - ); - currentNode[1] = forceLinkResult[0]; - currentNode[2] = forceLinkResult[1]; - return [currentNode[1], currentNode[2]]; +if (typeof globalThis !== 'undefined') { + Object.defineProperty(globalThis, '__GPU_FORCE_WORKER_TEST__', { + value: { + forceCollide, + forceLink }, - { - constants: { - nodesSize: sourceNodes.length, - edgesSize: sourceEdges.length, - collisionRadius: getCollisionRadius, - nBodyStrength, - nBodyDistanceMin, - nBodyDistanceMax - }, - output: [sourceNodes.length] - } - ); - const tempNodes = sourceNodes.map((node) => [node.id, node.x, node.y, node.locked ? 1 : 0]); - const tempEdges = sourceEdges.map((edge) => [edge.source.id, edge.target.id]); - kernel(tempNodes, tempEdges); - const newNodes = sourceNodes.map((node, index) => { - const updatedNode = tempNodes.find((n) => n[0] === node.id); - return { - ...node, - x: updatedNode[1], - y: updatedNode[2] - }; - }); - const newEdges = sourceEdges.map((edge) => { - return { - ...edge, - source: newNodes.find((node) => node.id === edge.source.id), - target: newNodes.find((node) => node.id === edge.target.id) - }; + configurable: true, + enumerable: false, + writable: true }); - postMessage({ - type: 'end', - nodes: newNodes, - edges: newEdges - }); - // FIXME cleanup per gpu documentation - this.self.close(); -}; +} + +if (workerScope) { + workerScope.onmessage = function (event) { + const {nodes: sourceNodes, edges: sourceEdges} = event.data; + const {nBodyStrength, nBodyDistanceMin, nBodyDistanceMax, getCollisionRadius} = + event.data.options; + // FIXME remove cpu mode + // @ts-expect-error TODO + const gpu = new GPU.GPU({ + mode: 'cpu' + }); + + gpu.addFunction(forceCollide); + gpu.addFunction(forceLink); + gpu.addFunction(isCollision); + gpu.addFunction(getDistance); + const kernel = gpu.createKernel( + function (kernelNodes, kernelEdges) { + const currentNode = kernelNodes[this.thread.x]; + forceCollide( + kernelNodes, + currentNode, + this.constants.nodesSize, + this.constants.collisionRadius + ); + const forceLinkResult = forceLink( + kernelNodes, + kernelEdges, + currentNode, + this.constants.nodesSize, + this.constants.edgesSize, + this.constants.collisionRadius + ); + currentNode[1] = forceLinkResult[0]; + currentNode[2] = forceLinkResult[1]; + return [currentNode[1], currentNode[2]]; + }, + { + constants: { + nodesSize: sourceNodes.length, + edgesSize: sourceEdges.length, + collisionRadius: getCollisionRadius, + nBodyStrength, + nBodyDistanceMin, + nBodyDistanceMax + }, + output: [sourceNodes.length] + } + ); + const tempNodes = sourceNodes.map((node) => [node.id, node.x, node.y, node.locked ? 1 : 0]); + const tempEdges = sourceEdges.map((edge) => [edge.source.id, edge.target.id]); + kernel(tempNodes, tempEdges); + const newNodes = sourceNodes.map((node) => { + const updatedNode = tempNodes.find((n) => n[0] === node.id); + return { + ...node, + x: updatedNode[1], + y: updatedNode[2] + }; + }); + const newEdges = sourceEdges.map((edge) => { + return { + ...edge, + source: newNodes.find((node) => node.id === edge.source.id), + target: newNodes.find((node) => node.id === edge.target.id) + }; + }); + workerScope.postMessage({ + type: 'end', + nodes: newNodes, + edges: newEdges + }); + // FIXME cleanup per gpu documentation + workerScope.close(); + }; +} diff --git a/modules/graph-layers/src/layouts/simple-layout.ts b/modules/graph-layers/src/layouts/simple-layout.ts index 019b9683..7e57c888 100644 --- a/modules/graph-layers/src/layouts/simple-layout.ts +++ b/modules/graph-layers/src/layouts/simple-layout.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {GraphLayout, GraphLayoutProps} from '../core/graph-layout'; +import {GraphLayout, GraphLayoutDefaultProps, GraphLayoutProps} from '../core/graph-layout'; import {Node} from '../graph/node'; import {Edge} from '../graph/edge'; import {LegacyGraph} from '../graph/legacy-graph'; @@ -29,10 +29,10 @@ export type SimpleLayoutProps = GraphLayoutProps & { /** A basic layout where the application controls positions of each node */ export class SimpleLayout extends GraphLayout { - static defaultProps: Required = { + static defaultProps = { nodePositionAccessor: (node) => [node.getPropertyValue('x'), node.getPropertyValue('y')] as [number, number] - }; + } as const satisfies GraphLayoutDefaultProps; protected readonly _name = 'SimpleLayout'; protected _graph: LegacyGraph | null = null; @@ -40,11 +40,19 @@ export class SimpleLayout extends GraphLayout { protected _nodePositionMap: Record = {}; constructor(options: SimpleLayoutProps = {}) { - super({...SimpleLayout.defaultProps, ...options}); + super(options, SimpleLayout.defaultProps); + } + + override setProps(props: Partial): boolean { + const shouldUpdate = super.setProps(props); + if ('nodePositionAccessor' in props) { + this._refreshNodePositions(); + } + return shouldUpdate; } initializeGraph(graph: LegacyGraph): void { - this.updateGraph(graph); + this.setProps({graph}); } start(): void { @@ -61,7 +69,7 @@ export class SimpleLayout extends GraphLayout { this._notifyLayoutComplete(); } - updateGraph(graph: LegacyGraph): void { + protected override updateGraph(graph: LegacyGraph): void { this._graph = graph; const nodes = Array.isArray(graph.getNodes()) ? (graph.getNodes() as Node[]) @@ -76,10 +84,6 @@ export class SimpleLayout extends GraphLayout { }, {}); } - setNodePositionAccessor = (accessor) => { - (this.props as any).nodePositionAccessor = accessor; - }; - getNodePosition = (node: Node | null): [number, number] => { if (!node) { return [0, 0] as [number, number]; @@ -120,4 +124,17 @@ export class SimpleLayout extends GraphLayout { ); this._bounds = this._calculateBounds(positions); } + + private _refreshNodePositions(): void { + if (!this._graph) { + return; + } + const nodes = Array.isArray(this._graph.getNodes()) + ? (this._graph.getNodes() as Node[]) + : (Array.from(this._graph.getNodes()) as Node[]); + this._nodePositionMap = nodes.reduce>((res, node) => { + res[node.getId()] = this._normalizePosition(this.props.nodePositionAccessor(node)); + return res; + }, {}); + } } diff --git a/modules/graph-layers/test/core/graph-layout.spec.ts b/modules/graph-layers/test/core/graph-layout.spec.ts index aca2a221..33823264 100644 --- a/modules/graph-layers/test/core/graph-layout.spec.ts +++ b/modules/graph-layers/test/core/graph-layout.spec.ts @@ -2,10 +2,71 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {describe, it, expect} from 'vitest'; +import {describe, expect, it} from 'vitest'; -describe('core/base-layout', () => { - it('nothing', () => { - expect(1).toBe(1); +import {GraphLayout, type GraphLayoutProps} from '../../src/core/graph-layout'; +import {LegacyGraph} from '../../src/graph/legacy-graph'; + +type TestLayoutProps = GraphLayoutProps & { + foo?: number; +}; + +class TestLayout extends GraphLayout { + static defaultProps = { + graph: undefined as unknown as LegacyGraph, + foo: 0 + } as const satisfies Required; + + public updateGraphCalls = 0; + public lastGraph: LegacyGraph | null = null; + + constructor(props: TestLayoutProps = {}) { + super(props, TestLayout.defaultProps); + } + + initializeGraph(graph: LegacyGraph): void { + this.setProps({graph}); + } + + protected override updateGraph(graph: LegacyGraph): void { + this.lastGraph = graph; + this.updateGraphCalls += 1; + } + + start(): void {} + + update(): void {} + + resume(): void {} + + stop(): void {} + + getFoo(): number { + return this.props.foo ?? 0; + } +} + +describe('core/graph-layout#setProps', () => { + it('merges props and triggers graph updates', () => { + const layout = new TestLayout(); + + expect(layout.setProps({})).toBe(false); + + const fooChanged = layout.setProps({foo: 3}); + expect(fooChanged).toBe(true); + expect(layout.getFoo()).toBe(3); + + const fooUnchanged = layout.setProps({foo: 3}); + expect(fooUnchanged).toBe(false); + + const graph = new LegacyGraph(); + const firstGraphChange = layout.setProps({graph}); + expect(firstGraphChange).toBe(true); + expect(layout.updateGraphCalls).toBe(1); + expect(layout.lastGraph).toBe(graph); + + const repeatedGraphChange = layout.setProps({graph}); + expect(repeatedGraphChange).toBe(true); + expect(layout.updateGraphCalls).toBe(2); }); }); diff --git a/modules/graph-layers/test/layouts/gpu-force/worker.spec.ts b/modules/graph-layers/test/layouts/gpu-force/worker.spec.ts new file mode 100644 index 00000000..97055f44 --- /dev/null +++ b/modules/graph-layers/test/layouts/gpu-force/worker.spec.ts @@ -0,0 +1,49 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {beforeAll, describe, expect, it} from 'vitest'; + +type ForceLinkFn = ( + nodes: number[][], + edges: number[][], + currentNode: number[], + nodesSize: number, + edgesSize: number, + radius: number +) => [number, number]; + +let forceLink: ForceLinkFn; + +beforeAll(async () => { + await import('../../../src/layouts/gpu-force/worker.js'); + const testExports = (globalThis as unknown as { + __GPU_FORCE_WORKER_TEST__?: {forceLink: ForceLinkFn}; + }).__GPU_FORCE_WORKER_TEST__; + + if (!testExports) { + throw new Error('GPU force worker test exports are not available'); + } + + forceLink = testExports.forceLink; +}); + +describe('layouts/gpu-force worker forceLink', () => { + it('indexes nodes by node id instead of edge index', () => { + const nodes = [ + [1, 0, 0], + [2, 1, 1] + ]; + const edges = [ + [1, 2], + [2, 1], + [1, 2] + ]; + const currentNode = [...nodes[0]]; + + const result = forceLink(nodes, edges, currentNode, nodes.length, edges.length, 0); + + expect(result).toHaveLength(2); + expect(result.every((value) => Number.isFinite(value))).toBe(true); + }); +}); diff --git a/modules/graph-layers/test/layouts/simple-layout.spec.ts b/modules/graph-layers/test/layouts/simple-layout.spec.ts new file mode 100644 index 00000000..106c407c --- /dev/null +++ b/modules/graph-layers/test/layouts/simple-layout.spec.ts @@ -0,0 +1,34 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {describe, expect, it} from 'vitest'; + +import {SimpleLayout} from '../../src/layouts/simple-layout'; +import {LegacyGraph} from '../../src/graph/legacy-graph'; +import {Node} from '../../src/graph/node'; + +describe('layouts/simple-layout', () => { + it('updates node positions when nodePositionAccessor changes', () => { + const graph = new LegacyGraph(); + const node = new Node({id: 'a'}); + node.setDataProperty('x', 1); + node.setDataProperty('y', 2); + graph.addNode(node); + + const layout = new SimpleLayout(); + layout.initializeGraph(graph); + + expect(layout.getNodePosition(node)).toEqual([1, 2]); + + layout.setProps({ + nodePositionAccessor: (target) => + [ + target.getPropertyValue('y') as number, + target.getPropertyValue('x') as number + ] as [number, number] + }); + + expect(layout.getNodePosition(node)).toEqual([2, 1]); + }); +});