diff --git a/modules/graph-layers/src/graph/edge.ts b/modules/graph-layers/src/graph/edge.ts index 12036edd..1eb68c5a 100644 --- a/modules/graph-layers/src/graph/edge.ts +++ b/modules/graph-layers/src/graph/edge.ts @@ -16,7 +16,7 @@ export interface EdgeOptions { /** whether the edge is directed or not */ directed?: boolean; /** origin data reference */ - data: Record; + data?: Record; } /** Basic edge data structure */ diff --git a/modules/graph-layers/src/graph/graph.ts b/modules/graph-layers/src/graph/graph.ts index 6e9acc59..3019b7ca 100644 --- a/modules/graph-layers/src/graph/graph.ts +++ b/modules/graph-layers/src/graph/graph.ts @@ -7,6 +7,12 @@ import {Cache} from '../core/cache'; import {Edge} from './edge'; import {Node} from './node'; +export type GraphProps = { + name?: string; + nodes?: Node[]; + edges?: Edge[]; +}; + /** Basic graph data structure */ export class Graph extends EventTarget { /** List object of nodes. */ @@ -23,19 +29,28 @@ export class Graph extends EventTarget { /** Cached data: create array data from maps. */ private _cache = new Cache<'nodes' | 'edges', Node[] | Edge[]>(); + constructor(props?: GraphProps); + constructor(graph: Graph); + /** * The constructor of the Graph class. * @param graph - copy the graph if this exists. */ - constructor(graph: Graph | null = null) { + constructor(propsOrGraph?: GraphProps | Graph) { super(); - // copy the graph if it exists in the parameter - if (graph) { - // start copying the graph + if (propsOrGraph instanceof Graph) { + // if a Graph instance was supplied, copy the supplied graph into this graph + const graph = propsOrGraph; + this._name = graph?._name || this._name; this._nodeMap = graph._nodeMap; this._edgeMap = graph._edgeMap; - this._name = graph && graph._name; + } else { + // If graphProps were supplied, initialize this graph from the supplied props + const props = propsOrGraph; + this._name = props?.name || this._name; + this.batchAddNodes(props?.nodes || []); + this.batchAddEdges(props?.edges || []); } } diff --git a/modules/graph-layers/src/graph/node.ts b/modules/graph-layers/src/graph/node.ts index 779eda14..77523580 100644 --- a/modules/graph-layers/src/graph/node.ts +++ b/modules/graph-layers/src/graph/node.ts @@ -12,7 +12,7 @@ export interface NodeOptions { selectable?: boolean; highlightConnectedEdges?: boolean; /* origin data reference */ - data: Record; + data?: Record; } /** Basic data structure of a node */ diff --git a/modules/graph-layers/src/index.ts b/modules/graph-layers/src/index.ts index 1061ed00..d2a77b27 100644 --- a/modules/graph-layers/src/index.ts +++ b/modules/graph-layers/src/index.ts @@ -6,7 +6,6 @@ export {Graph} from './graph/graph'; export {Node} from './graph/node'; export {Edge} from './graph/edge'; -export {createGraph} from './graph/create-graph'; export {GraphEngine} from './core/graph-engine'; @@ -39,4 +38,5 @@ export {mixedGetPosition} from './utils/layer-utils'; export {log} from './utils/log'; // DEPRECATED +export {createGraph} from './loaders/create-graph'; export {JSONLoader} from './loaders/simple-json-graph-loader'; diff --git a/modules/graph-layers/src/layers/graph-layer.ts b/modules/graph-layers/src/layers/graph-layer.ts index 7324000d..e243aa1a 100644 --- a/modules/graph-layers/src/layers/graph-layer.ts +++ b/modules/graph-layers/src/layers/graph-layer.ts @@ -5,8 +5,12 @@ import type {CompositeLayerProps} from '@deck.gl/core'; import {COORDINATE_SYSTEM, CompositeLayer} from '@deck.gl/core'; -import {Stylesheet} from '../style/style-sheet'; import {NODE_TYPE, EDGE_DECORATOR_TYPE} from '../core/constants'; +import {Graph} from '../graph/graph'; +import {GraphLayout} from '../core/graph-layout'; +import {GraphEngine} from '../core/graph-engine'; + +import {Stylesheet} from '../style/style-sheet'; import {mixedGetPosition} from '../utils/layer-utils'; import {InteractionManager} from '../core/interaction-manager'; @@ -18,14 +22,15 @@ import {ImageLayer} from './node-layers/image-layer'; import {LabelLayer} from './node-layers/label-layer'; import {RectangleLayer} from './node-layers/rectangle-layer'; import {RoundedRectangleLayer} from './node-layers/rounded-rectangle-layer'; -import {PathBasedRoundedRectangleLayer} from './node-layers/path-rounded-rectange-layer'; +import {PathBasedRoundedRectangleLayer} from './node-layers/path-rounded-rectangle-layer'; import {ZoomableMarkerLayer} from './node-layers/zoomable-marker-layer'; // edge layers import {EdgeLayer} from './edge-layer'; import {EdgeLabelLayer} from './edge-layers/edge-label-layer'; import {FlowLayer} from './edge-layers/flow-layer'; -import {GraphEngine} from '../core/graph-engine'; + +import {JSONLoader} from '../loaders/json-loader'; const NODE_LAYER_MAP = { [NODE_TYPE.RECTANGLE]: RectangleLayer, @@ -49,8 +54,14 @@ const SHARED_LAYER_PROPS = { } }; -export type GraphLayerProps = { - engine: GraphEngine; +export type GraphLayerProps = CompositeLayerProps & _GraphLayerProps; + +export type _GraphLayerProps = { + graph?: Graph; + layout?: GraphLayout; + graphLoader?: (opts: {json: any}) => Graph; + engine?: GraphEngine; + // an array of styles for layers nodeStyle?: any[]; edgeStyle?: { @@ -76,9 +87,14 @@ export type GraphLayerProps = { export class GraphLayer extends CompositeLayer { static layerName = 'GraphLayer'; - static defaultProps: Required = { + static defaultProps: Required<_GraphLayerProps> = { + // Composite layer props // @ts-expect-error composite layer props pickable: true, + + // Graph props + graphLoader: JSONLoader, + nodeStyle: [], nodeEvents: { onMouseLeave: () => {}, @@ -100,6 +116,12 @@ export class GraphLayer extends CompositeLayer { enableDragging: false }; + // @ts-expect-error Some typescript confusion due to override of base class state + state!: CompositeLayer['state'] & { + interactionManager: InteractionManager; + graphEngine?: GraphEngine; + }; + forceUpdate = () => { if (this.context && this.context.layerManager) { this.setNeedsUpdate(); @@ -109,33 +131,74 @@ export class GraphLayer extends CompositeLayer { constructor(props: GraphLayerProps & CompositeLayerProps) { super(props); - - // added or removed a node, or in general something layout related changed - props.engine.addEventListener('onLayoutChange', this.forceUpdate); } initializeState() { - const interactionManager = new InteractionManager(this.props as any, () => this.forceUpdate()); - this.state = {interactionManager}; + this.state = { + interactionManager: new InteractionManager(this.props as any, () => this.forceUpdate()) + }; + const engine = this.props.engine; + this._setGraphEngine(engine); } shouldUpdateState({changeFlags}) { return changeFlags.dataChanged || changeFlags.propsChanged; } - updateState({props}) { - (this.state.interactionManager as any).updateProps(props); + updateState({props, oldProps, changeFlags}) { + if ( + changeFlags.dataChanged && + props.data && + !(Array.isArray(props.data) && props.data.length === 0) + ) { + // console.log(props.data); + const graph = this.props.graphLoader({json: props.data}); + const layout = this.props.layout; + const graphEngine = new GraphEngine({graph, layout}); + this._setGraphEngine(graphEngine); + this.state.interactionManager.updateProps(props); + this.forceUpdate(); + } else if (changeFlags.propsChanged && props.graph !== oldProps.graph) { + const graphEngine = new GraphEngine({graph: props.graph, layout: props.layout}); + this._setGraphEngine(graphEngine); + this.state.interactionManager.updateProps(props); + this.forceUpdate(); + } } finalize() { - (this.props as any).engine.removeEventListener('onLayoutChange', this.forceUpdate); + this._removeGraphEngine(); + } + + _setGraphEngine(graphEngine: GraphEngine) { + if (graphEngine === this.state.graphEngine) { + return; + } + + this._removeGraphEngine(); + if (graphEngine) { + this.state.graphEngine = graphEngine; + this.state.graphEngine.run(); + // added or removed a node, or in general something layout related changed + this.state.graphEngine.addEventListener('onLayoutChange', this.forceUpdate); + } + } + + _removeGraphEngine() { + if (this.state.graphEngine) { + this.state.graphEngine.removeEventListener('onLayoutChange', this.forceUpdate); + this.state.graphEngine.clear(); + this.state.graphEngine = null; + } } createNodeLayers() { - const {engine, nodeStyle} = this.props; - if (!nodeStyle || !Array.isArray(nodeStyle) || nodeStyle.length === 0) { + const engine = this.state.graphEngine; + const {nodeStyle} = this.props; + if (!engine || !nodeStyle || !Array.isArray(nodeStyle) || nodeStyle.length === 0) { return []; } + return nodeStyle.filter(Boolean).map((style, idx) => { const {pickable = true, visible = true, data = (nodes) => nodes, ...restStyle} = style; const LayerType = NODE_LAYER_MAP[style.type]; @@ -165,9 +228,10 @@ export class GraphLayer extends CompositeLayer { } createEdgeLayers() { - const {edgeStyle, engine} = this.props as any; + const engine = this.state.graphEngine; + const {edgeStyle} = this.props; - if (!edgeStyle) { + if (!engine || !edgeStyle) { return []; } @@ -223,23 +287,23 @@ export class GraphLayer extends CompositeLayer { } onClick(info, event): boolean { - return (this.state.interactionManager as any).onClick(info, event) || false; + return (this.state.interactionManager.onClick(info, event) as unknown as boolean) || false; } onHover(info, event): boolean { - return (this.state.interactionManager as any).onHover(info, event) || false; + return (this.state.interactionManager.onHover(info, event) as unknown as boolean) || false; } onDragStart(info, event) { - (this.state.interactionManager as any).onDragStart(info, event); + this.state.interactionManager.onDragStart(info, event); } onDrag(info, event) { - (this.state.interactionManager as any).onDrag(info, event); + this.state.interactionManager.onDrag(info, event); } onDragEnd(info, event) { - (this.state.interactionManager as any).onDragEnd(info, event); + this.state.interactionManager.onDragEnd(info, event); } renderLayers() { diff --git a/modules/graph-layers/src/layers/node-layers/path-rounded-rectange-layer.ts b/modules/graph-layers/src/layers/node-layers/path-rounded-rectangle-layer.ts similarity index 100% rename from modules/graph-layers/src/layers/node-layers/path-rounded-rectange-layer.ts rename to modules/graph-layers/src/layers/node-layers/path-rounded-rectangle-layer.ts diff --git a/modules/graph-layers/src/graph/create-graph.ts b/modules/graph-layers/src/loaders/create-graph.ts similarity index 79% rename from modules/graph-layers/src/graph/create-graph.ts rename to modules/graph-layers/src/loaders/create-graph.ts index c91b902e..81385d30 100644 --- a/modules/graph-layers/src/graph/create-graph.ts +++ b/modules/graph-layers/src/loaders/create-graph.ts @@ -2,11 +2,14 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {Edge} from './edge'; -import {Node} from './node'; -import {Graph} from './graph'; +import {Edge} from '../graph/edge'; +import {Node} from '../graph/node'; +import {Graph} from '../graph/graph'; -/** Create a graph from a list of Nodes and edges */ +/** + * @deprecated Use `new Graph(name, nodes, edges)` + * Create a graph from a list of Nodes and edges + */ export function createGraph(props: {name; nodes; edges; nodeParser; edgeParser}) { const {name, nodes, edges, nodeParser, edgeParser} = props; // create a new empty graph diff --git a/modules/graph-layers/src/loaders/json-loader.ts b/modules/graph-layers/src/loaders/json-loader.ts index e5c2a344..da034109 100644 --- a/modules/graph-layers/src/loaders/json-loader.ts +++ b/modules/graph-layers/src/loaders/json-loader.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {createGraph} from '../graph/create-graph'; +import {createGraph} from './create-graph'; import {basicNodeParser} from './node-parsers'; import {basicEdgeParser} from './edge-parsers'; import {log} from '../utils/log'; diff --git a/modules/graph-layers/src/loaders/simple-json-graph-loader.ts b/modules/graph-layers/src/loaders/simple-json-graph-loader.ts index 38f53a48..2737a382 100644 --- a/modules/graph-layers/src/loaders/simple-json-graph-loader.ts +++ b/modules/graph-layers/src/loaders/simple-json-graph-loader.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {createGraph} from '../graph/create-graph'; +import {createGraph} from './create-graph'; import {log} from '../utils/log'; import {basicNodeParser} from './node-parsers'; import {basicEdgeParser} from './edge-parsers'; diff --git a/modules/graph-layers/src/loaders/table-graph-loader.ts b/modules/graph-layers/src/loaders/table-graph-loader.ts new file mode 100644 index 00000000..f7bae7ce --- /dev/null +++ b/modules/graph-layers/src/loaders/table-graph-loader.ts @@ -0,0 +1,124 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {NodeOptions} from '../graph/node'; +import type {EdgeOptions} from '../graph/edge'; +import {Edge} from '../graph/edge'; +import {Node} from '../graph/node'; +import {Graph} from '../graph/graph'; + +import {log} from '../utils/log'; + +export type ParseGraphOptions = { + nodeIdField?: string; + edgeSourceField?: string; + edgeTargetField?: string; + edgeDirectedField?: string; + edgeDirected?: boolean; + nodeParser?: (nodeRow: any) => NodeOptions; + edgeParser?: (edgeRow: any) => EdgeOptions; +}; + +const defaultParseGraphOptions = { + nodeIdField: 'id', + edgeSourceField: 'sourceId', + edgeTargetField: 'targetId', + edgeDirectedField: undefined, + edgeDirected: false + // nodeParser: (nodeRow: any, options: ParseGraphOptions) => ({id: nodeRow.name, data: nodeRow}), + // edgeParser: (edgeRow: any, nodeIndexMap, options: ParseGraphOptions)=> { + // const sourceNodeId = edge[options.edgeSourceField]; + // const targetNodeId = edge[options.edgeTargetField]; + // return { + // id: `${sourceNodeId}-${targetNodeId}`, + // sourceId: nodeIndexMap[sourceNodeId], + // targetId: nodeIndexMap[targetNodeId], + // directed: true + // }; + // }; +} as const satisfies ParseGraphOptions; + +export function tableGraphLoader( + tables: {nodes: any[]; edges: any[]}, + options?: ParseGraphOptions +): Graph { + options = {...defaultParseGraphOptions, ...options}; + + const {nodes, edges} = tables; + + // const nodeIndexMap = nodes.reduce((res, node, idx) => { + // res[idx] = node[options.nodeIdField]; + // return res; + // }, {}); + + function defaultNodeParser(nodeRow: any): NodeOptions { + return {id: nodeRow[options.nodeIdField]}; + } + + function defaultEdgeParser(edgeRow: any): EdgeOptions { + const sourceNodeId = edgeRow[options.edgeSourceField]; + const targetNodeId = edgeRow[options.edgeTargetField]; + return { + id: edgeRow.id || `${sourceNodeId}-${targetNodeId}`, + sourceId: sourceNodeId, + targetId: targetNodeId, + directed: options.edgeDirected + }; + } + + const nodeParser = options.nodeParser || defaultNodeParser; + const edgeParser = options.edgeParser || defaultEdgeParser; + + // add nodes + + if (!nodes) { + log.error('Invalid graph: nodes is missing.')(); + return null; + } + + const glNodes = nodes.map((node) => { + const {id} = nodeParser(node); + return new Node({id, data: node}); + }); + + const glEdges = edges.map((edge) => { + const {id, sourceId, targetId, directed} = edgeParser(edge); + return new Edge({ + id, + sourceId, + targetId, + directed: directed || false, + data: edge + }); + }); + + // create a new empty graph + const name = 'loaded'; + const graph = new Graph({name, nodes: glNodes, edges: glEdges}); + return graph; +} + +// export function basicNodeParser(node: any): Pick { +// if (node.id === undefined) { +// log.error('Invalid node: id is missing.')(); +// return null; +// } +// return {id: node.id}; +// } + +// export function basicEdgeParser(edge: any): Omit { +// const {id, directed, sourceId, targetId} = edge; + +// if (sourceId === undefined || targetId === undefined) { +// log.error('Invalid edge: sourceId or targetId is missing.')(); +// return null; +// } + +// return { +// id, +// directed: directed || false, +// sourceId, +// targetId +// }; +// } diff --git a/modules/graph-layers/test/graph/create-graph.spec.ts b/modules/graph-layers/test/loaders/create-graph.spec.ts similarity index 94% rename from modules/graph-layers/test/graph/create-graph.spec.ts rename to modules/graph-layers/test/loaders/create-graph.spec.ts index 073f04cc..78cd5a08 100644 --- a/modules/graph-layers/test/graph/create-graph.spec.ts +++ b/modules/graph-layers/test/loaders/create-graph.spec.ts @@ -4,7 +4,7 @@ import {beforeAll, describe, it, expect} from 'vitest'; import SAMPLE_GRAPH from '../__fixtures__/graph.json'; -import {createGraph} from '../../src/graph/create-graph'; +import {createGraph} from '../../src/loaders/create-graph'; beforeAll(() => { global.CustomEvent = Event as any; diff --git a/modules/graph-layers/test/loaders/table-graph-loader.spec.ts b/modules/graph-layers/test/loaders/table-graph-loader.spec.ts new file mode 100644 index 00000000..9535d040 --- /dev/null +++ b/modules/graph-layers/test/loaders/table-graph-loader.spec.ts @@ -0,0 +1,49 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {beforeAll, describe, it, expect} from 'vitest'; + +import SAMPLE_GRAPH1 from '../__fixtures__/graph1.json'; +import SAMPLE_GRAPH2 from '../__fixtures__/graph2.json'; + +import {tableGraphLoader} from '../../src/loaders/table-graph-loader'; + +beforeAll(() => { + global.CustomEvent = Event as any; +}); + +describe('loaders/node-parsers', () => { + it('should work with default options', () => { + const graph = tableGraphLoader(SAMPLE_GRAPH1); + + expect( + graph.getNodes().map((n) => n.getId()), + 'node ids' + ).toEqual(expect.arrayContaining(SAMPLE_GRAPH1.nodes.map((n) => n.id))); + expect( + graph.getEdges().map((e) => e.getId()), + 'edge ids' + ).toEqual(expect.arrayContaining(SAMPLE_GRAPH1.edges.map((e) => e.id))); + }); + + it('should work with custom parsers', () => { + const graph = tableGraphLoader(SAMPLE_GRAPH2, { + nodeParser: (node) => ({id: node.name}), + edgeParser: (edge) => ({ + id: edge.name, + directed: false, + sourceId: edge.source, + targetId: edge.target + }) + }); + expect( + graph.getNodes().map((n) => n.getId()), + 'node ids' + ).toEqual(expect.arrayContaining(SAMPLE_GRAPH2.nodes.map((n) => n.name))); + expect( + graph.getEdges().map((n) => n.getId()), + 'edge ids' + ).toEqual(expect.arrayContaining(SAMPLE_GRAPH2.edges.map((e) => e.name))); + }); +}); diff --git a/modules/graph-layers/test/setup-tests.ts b/modules/graph-layers/test/setup-tests.ts new file mode 100644 index 00000000..729b07dd --- /dev/null +++ b/modules/graph-layers/test/setup-tests.ts @@ -0,0 +1,6 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +// https://medium.com/@MussieTeshome/react-testing-library-with-vitest-the-basics-explained-d583e62945fd +import '@testing-library/jest-dom';