From 420f2395e78d8f1af4ea848b423977becb10055f Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Tue, 10 Dec 2024 14:28:08 -0500 Subject: [PATCH] chore(graph-layers): Add source code for additional examples (#169) --- examples/graph-layers/hive-plot/app.ts | 77 +++++ .../hive-plot/hive-plot-layout.ts | 176 ++++++++++++ examples/graph-layers/multi-graph/app.ts | 78 ++++++ .../multi-graph/multi-graph-layout.ts | 262 ++++++++++++++++++ .../multi-graph/sample-graph.json | 97 +++++++ examples/graph-layers/radial-layout/app.ts | 92 ++++++ .../radial-layout/radial-layout.ts | 197 +++++++++++++ 7 files changed, 979 insertions(+) create mode 100644 examples/graph-layers/hive-plot/app.ts create mode 100644 examples/graph-layers/hive-plot/hive-plot-layout.ts create mode 100644 examples/graph-layers/multi-graph/app.ts create mode 100644 examples/graph-layers/multi-graph/multi-graph-layout.ts create mode 100644 examples/graph-layers/multi-graph/sample-graph.json create mode 100644 examples/graph-layers/radial-layout/app.ts create mode 100644 examples/graph-layers/radial-layout/radial-layout.ts diff --git a/examples/graph-layers/hive-plot/app.ts b/examples/graph-layers/hive-plot/app.ts new file mode 100644 index 00000000..facd6ce9 --- /dev/null +++ b/examples/graph-layers/hive-plot/app.ts @@ -0,0 +1,77 @@ +import React, {Component} from 'react'; +import {scaleOrdinal} from 'd3-scale'; +import {schemeAccent} from 'd3-scale-chromatic'; +import {extent} from 'd3-array'; +import Color from 'color'; +import {fetchJSONFromS3} from '../../utils/data/io'; + +// graph.gl +import GraphGL, {JSONLoader, NODE_TYPE} from '../../src'; +import HivePlot from './hive-plot-layout'; + +const DEFAULT_NODE_SIZE = 3; +const DEFAULT_EDGE_COLOR = 'rgba(80, 80, 80, 0.3)'; +const DEFAULT_EDGE_WIDTH = 1; +const DEFAULT_WIDTH = 1000; + +export default class HivePlotExample extends Component { + state = {graph: null}; + + componentDidMount() { + fetchJSONFromS3(['wits.json']).then(([sampleGraph]) => { + const {nodes} = sampleGraph; + const nodeIndexMap = nodes.reduce((res, node, idx) => { + res[idx] = node.name; + return res; + }, {}); + const graph = JSONLoader({ + json: sampleGraph, + nodeParser: node => ({id: node.name}), + edgeParser: edge => ({ + id: `${edge.source}-${edge.target}`, + sourceId: nodeIndexMap[edge.source], + targetId: nodeIndexMap[edge.target], + directed: true, + }), + }); + this.setState({graph}); + const groupExtent = extent(nodes, n => n.group); + this._nodeColorScale = scaleOrdinal(schemeAccent).domain(groupExtent); + }); + } + + // node accessors + getNodeColor = node => { + const hex = this._nodeColorScale(node.getPropertyValue('group')); + return Color(hex).array(); + }; + + render() { + if (!this.state.graph) { + return null; + } + return ( + node.getPropertyValue('group'), + }) + } + nodeStyle={[ + { + type: NODE_TYPE.CIRCLE, + radius: DEFAULT_NODE_SIZE, + fill: this.getNodeColor, + }, + ]} + edgeStyle={{ + stroke: DEFAULT_EDGE_COLOR, + strokeWidth: DEFAULT_EDGE_WIDTH, + }} + /> + ); + } +} diff --git a/examples/graph-layers/hive-plot/hive-plot-layout.ts b/examples/graph-layers/hive-plot/hive-plot-layout.ts new file mode 100644 index 00000000..054ff0e0 --- /dev/null +++ b/examples/graph-layers/hive-plot/hive-plot-layout.ts @@ -0,0 +1,176 @@ +import {BaseLayout, EDGE_TYPE} from '../../src'; + +const defaultOptions = { + innerRadius: 100, + outerRadius: 500, + getNodeAxis: node => node.getPropertyValue('group'), +}; + +const computeControlPoint = ({ + sourcePosition, + sourceNodeAxis, + targetPosition, + targetNodeAxis, + totalAxis, +}) => { + const halfAxis = (totalAxis - 1) / 2; + // check whether the source/target are at the same side. + const sameSide = + (sourceNodeAxis <= halfAxis && targetNodeAxis <= halfAxis) || + (sourceNodeAxis > halfAxis && targetNodeAxis > halfAxis); + // curve direction + const direction = + sameSide && (sourceNodeAxis <= halfAxis && targetNodeAxis <= halfAxis) + ? 1 + : -1; + + // flip the source/target to follow the clockwise diretion + const source = + sourceNodeAxis < targetNodeAxis && sameSide + ? sourcePosition + : targetPosition; + const target = + sourceNodeAxis < targetNodeAxis && sameSide + ? targetPosition + : sourcePosition; + + // calculate offset + const distance = Math.hypot(source[0] - target[0], source[1] - target[1]); + const offset = distance * 0.2; + + const midPoint = [(source[0] + target[0]) / 2, (source[1] + target[1]) / 2]; + const dx = target[0] - source[0]; + const dy = target[1] - source[1]; + const normal = [dy, -dx]; + const length = Math.hypot(dy, -dx); + const normalized = [normal[0] / length, normal[1] / length]; + return [ + midPoint[0] + normalized[0] * offset * direction, + midPoint[1] + normalized[1] * offset * direction, + ]; +}; + +export default class HivePlot extends BaseLayout { + constructor(options) { + super(options); + this._name = 'HivePlot'; + this._options = { + ...defaultOptions, + ...options, + }; + this._nodePositionMap = {}; + } + + initializeGraph(graph) { + this.updateGraph(graph); + } + + updateGraph(graph) { + const {getNodeAxis, innerRadius, outerRadius} = this._options; + this._graph = graph; + this._nodeMap = graph.getNodes().reduce((res, node) => { + res[node.getId()] = node; + return res; + }, {}); + + // bucket nodes into few axis + + this._axis = graph.getNodes().reduce((res, node) => { + const axis = getNodeAxis(node); + if (!res[axis]) { + res[axis] = []; + } + res[axis].push(node); + return res; + }, {}); + + // sort nodes along the same axis by degree + this._axis = Object.keys(this._axis).reduce((res, axis) => { + const bucketedNodes = this._axis[axis]; + const sortedNodes = bucketedNodes.sort((a, b) => { + if (a.getDegree() > b.getDegree()) { + return 1; + } + if (a.getDegree() === b.getDegree()) { + return 0; + } + return -1; + }); + res[axis] = sortedNodes; + return res; + }, {}); + this._totalAxis = Object.keys(this._axis).length; + const center = [0, 0]; + const angleInterval = 360 / Object.keys(this._axis).length; + + // calculate positions + this._nodePositionMap = Object.keys(this._axis).reduce( + (res, axis, axisIdx) => { + const axisAngle = angleInterval * axisIdx; + const bucketedNodes = this._axis[axis]; + const interval = (outerRadius - innerRadius) / bucketedNodes.length; + + bucketedNodes.forEach((node, idx) => { + const radius = innerRadius + idx * interval; + const x = Math.cos((axisAngle / 180) * Math.PI) * radius + center[0]; + const y = Math.sin((axisAngle / 180) * Math.PI) * radius + center[1]; + res[node.getId()] = [x, y]; + }); + return res; + }, + {} + ); + } + + start() { + this._callbacks.onLayoutChange(); + this._callbacks.onLayoutDone(); + } + + getNodePosition = node => this._nodePositionMap[node.getId()]; + + getEdgePosition = edge => { + const {getNodeAxis} = this._options; + const sourceNodeId = edge.getSourceNodeId(); + const targetNodeId = edge.getTargetNodeId(); + + const sourcePosition = this._nodePositionMap[sourceNodeId]; + const targetPosition = this._nodePositionMap[targetNodeId]; + + const sourceNode = this._nodeMap[sourceNodeId]; + const targetNode = this._nodeMap[targetNodeId]; + + const sourceNodeAxis = getNodeAxis(sourceNode); + const targetNodeAxis = getNodeAxis(targetNode); + + if (sourceNodeAxis === targetNodeAxis) { + return { + type: EDGE_TYPE.LINE, + sourcePosition, + targetPosition, + controlPoints: [], + }; + } + + const controlPoint = computeControlPoint({ + sourcePosition, + sourceNodeAxis, + targetPosition, + targetNodeAxis, + totalAxis: this._totalAxis, + }); + + return { + type: EDGE_TYPE.SPLINE_CURVE, + sourcePosition, + targetPosition, + controlPoints: [controlPoint], + }; + }; + + lockNodePosition = (node, x, y) => { + this._nodePositionMap[node.id] = [x, y]; + this._callbacks.onLayoutChange(); + this._callbacks.onLayoutDone(); + }; +} diff --git a/examples/graph-layers/multi-graph/app.ts b/examples/graph-layers/multi-graph/app.ts new file mode 100644 index 00000000..20037701 --- /dev/null +++ b/examples/graph-layers/multi-graph/app.ts @@ -0,0 +1,78 @@ +iimport React, {Component} from 'react'; +import Color from 'color'; + +// data +import sampleGraph from './sample-graph.json'; + +// graph.gl +import GraphGL, {EDGE_DECORATOR_TYPE, JSONLoader, NODE_TYPE} from '../../src'; +import MultiGraphLayout from './multi-graph-layout'; + +const DEFAULT_NODE_SIZE = 30; +const DEFAULT_NODE_PLACEHOLDER_SIZE = 40; +const DEFAULT_NODE_PLACEHOLDER_COLOR = 'rgb(240, 240, 240)'; + +export default class MultiGraphExample extends Component { + static defaultProps = { + showNodePlaceholder: true, + showNodeCircle: true, + nodeColor: '#cf4569', + showNodeLabel: true, + nodeLabelColor: '#ffffff', + nodeLabelSize: 14, + edgeColor: '#cf4569', + edgeWidth: 2, + showEdgeLabel: true, + edgeLabelColor: '#000000', + edgeLabelSize: 14, + }; + + render() { + return ( + (node.getPropertyValue('star') ? 6 : 0), + fill: [255, 255, 0], + offset: [18, -18], + }, + this.props.showNodeLabel && { + type: NODE_TYPE.LABEL, + text: node => node.getId(), + color: Color(this.props.nodeLabelColor).array(), + fontSize: this.props.nodeLabelSize, + }, + ]} + edgeStyle={{ + stroke: this.props.edgeColor, + strokeWidth: this.props.edgeWidth, + decorators: [ + this.props.showEdgeLabel && { + type: EDGE_DECORATOR_TYPE.LABEL, + text: edge => edge.getPropertyValue('type'), + color: Color(this.props.edgeLabelColor).array(), + fontSize: this.props.edgeLabelSize, + }, + ], + }} + /> + ); + } +} diff --git a/examples/graph-layers/multi-graph/multi-graph-layout.ts b/examples/graph-layers/multi-graph/multi-graph-layout.ts new file mode 100644 index 00000000..6e3dfbde --- /dev/null +++ b/examples/graph-layers/multi-graph/multi-graph-layout.ts @@ -0,0 +1,262 @@ +import {BaseLayout, EDGE_TYPE} from '../../src'; +import * as d3 from 'd3-force'; + +const defaultOptions = { + alpha: 3, + nBodyStrength: -1200, + nBodyDistanceMin: 100, + nBodyDistanceMax: 1400, +}; + +/** + * A helper function to compute the control point of a curve + * @param {number[]} source - the coordinates of source point, ex: [x, y, z] + * @param {number[]} target - the coordinates of target point, ex: [x, y, z] + * @param {number} direction - the direction of the curve, 1 or -1 + * @param {number} offset - offset from the midpoint + * @return {number[]} - the coordinates of the control point + */ +function computeControlPoint(source, target, direction, offset) { + const midPoint = [(source[0] + target[0]) / 2, (source[1] + target[1]) / 2]; + const dx = target[0] - source[0]; + const dy = target[1] - source[1]; + const normal = [dy, -dx]; + const length = Math.sqrt(Math.pow(normal[0], 2.0) + Math.pow(normal[1], 2.0)); + const normalized = [normal[0] / length, normal[1] / length]; + return [ + midPoint[0] + normalized[0] * offset * direction, + midPoint[1] + normalized[1] * offset * direction, + ]; +} + +export default class ForceMultiGraphLayout extends BaseLayout { + constructor(options) { + super(options); + this._name = 'ForceMultiGraphLayout'; + this._options = { + ...defaultOptions, + ...options, + }; + // d3 part + // custom graph data + this._d3Graph = {nodes: [], edges: []}; + this._nodeMap = {}; + this._edgeMap = {}; + } + + initializeGraph(graph) { + this.updateGraph(graph); + } + + _strength = d3Edge => { + if (d3Edge.isVirtual) { + return 1 / d3Edge.edgeCount; + } + const sourceDegree = this._graph.getDegree(d3Edge.source.id); + const targetDegree = this._graph.getDegree(d3Edge.target.id); + return 1 / Math.min(sourceDegree, targetDegree); + }; + + _generateSimulator() { + if (this._simulator) { + this._simulator.on('tick', null).on('end', null); + this._simulator = null; + } + const { + alpha, + nBodyStrength, + nBodyDistanceMin, + nBodyDistanceMax, + } = this._options; + + const g = this._d3Graph; + this._simulator = d3 + .forceSimulation(g.nodes) + .force( + 'edge', + d3 + .forceLink(g.edges) + .id(n => n.id) + .strength(this._strength) + ) + .force( + 'charge', + d3 + .forceManyBody() + .strength(nBodyStrength) + .distanceMin(nBodyDistanceMin) + .distanceMax(nBodyDistanceMax) + ) + .force('center', d3.forceCenter()) + .alpha(alpha); + // register event callbacks + this._simulator + .on('tick', this._callbacks.onLayoutChange) + .on('end', this._callbacks.onLayoutDone); + } + + start() { + this._generateSimulator(); + this._simulator.restart(); + } + + resume() { + this._simulator.restart(); + } + + stop() { + this._simulator.stop(); + } + + updateGraph(graph) { + this._graph = graph; + + // nodes + const newNodeMap = {}; + const newD3Nodes = graph.getNodes().map(node => { + const oldD3Node = this._nodeMap[node.id]; + const newD3Node = oldD3Node ? oldD3Node : {id: node.id}; + newNodeMap[node.id] = newD3Node; + return newD3Node; + }); + + // edges + // bucket edges between the same source/target node pairs. + const nodePairs = graph.getEdges().reduce((res, edge) => { + const nodes = [edge.getSourceNodeId(), edge.getTargetNodeId()]; + // sort the node ids to count the edges with the same pair + // but different direction (a -> b or b -> a) + const pairId = nodes.sort().toString(); + // push this edge into the bucket + if (!res[pairId]) { + res[pairId] = [edge]; + } else { + res[pairId].push(edge); + } + return res; + }, {}); + + // go through each pair of edges, + // if only one edge between two nodes, create a straight line + // otherwise, create one virtual node and two edges for each edge + const newD3Edges = []; + const newEdgeMap = {}; + + Object.keys(nodePairs).forEach(pairId => { + const betweenEdges = nodePairs[pairId]; + const firstEdge = betweenEdges[0]; + if (betweenEdges.length === 1) { + // do nothing, this is a real edge + const newD3Edge = { + type: EDGE_TYPE.LINE, + id: firstEdge.getId(), + source: newNodeMap[firstEdge.getSourceNodeId()], + target: newNodeMap[firstEdge.getTargetNodeId()], + isVirtual: false, + }; + newEdgeMap[firstEdge.getId()] = newD3Edge; + newD3Edges.push(newD3Edge); + return; + } + + // else reduce to one virtual edge + const newD3Edge = { + type: EDGE_TYPE.LINE, + id: pairId, + source: newNodeMap[firstEdge.getSourceNodeId()], + target: newNodeMap[firstEdge.getTargetNodeId()], + isVirtual: true, + edgeCount: betweenEdges.length, + }; + newEdgeMap[pairId] = newD3Edge; + newD3Edges.push(newD3Edge); + + betweenEdges.forEach((e, idx) => { + newEdgeMap[e.id] = { + type: EDGE_TYPE.SPLINE_CURVE, + id: e.id, + source: newNodeMap[e.getSourceNodeId()], + target: newNodeMap[e.getTargetNodeId()], + virtualEdgeId: pairId, + isVirtual: true, + index: idx, + }; + }); + }); + + this._nodeMap = newNodeMap; + this._d3Graph.nodes = newD3Nodes; + this._edgeMap = newEdgeMap; + this._d3Graph.edges = newD3Edges; + } + + getNodePosition = node => { + const d3Node = this._nodeMap[node.id]; + if (d3Node) { + return [d3Node.x, d3Node.y]; + } + // default value + return [0, 0]; + }; + + getEdgePosition = edge => { + const d3Edge = this._edgeMap[edge.id]; + if (d3Edge) { + if (!d3Edge.isVirtual) { + return { + type: EDGE_TYPE.LINE, + sourcePosition: [d3Edge.source.x, d3Edge.source.y], + targetPosition: [d3Edge.target.x, d3Edge.target.y], + controlPoints: [], + }; + } + // else, check the referenced virtual edge + const virtualEdge = this._edgeMap[d3Edge.virtualEdgeId]; + const edgeCount = virtualEdge.edgeCount; + // get the position of source and target nodes + const sourcePosition = [virtualEdge.source.x, virtualEdge.source.y]; + const targetPosition = [virtualEdge.target.x, virtualEdge.target.y]; + // calculate a symmetric curve + const distance = Math.hypot( + sourcePosition[0] - targetPosition[0], + sourcePosition[1] - targetPosition[1] + ); + const index = d3Edge.index; + // curve direction: inward vs. outward + const direction = index % 2 ? 1 : -1; + // if the number of the parallel edges is an even number => symmetric shape + // otherwise, the 0th node will be a staight line, and rest of them are symmetrical. + const symmetricShape = edgeCount % 2 === 0; + const offset = + Math.max(distance / 10, 5) * + (symmetricShape ? Math.floor(index / 2 + 1) : Math.ceil(index / 2)); + const controlPoint = computeControlPoint( + sourcePosition, + targetPosition, + direction, + offset + ); + return { + type: EDGE_TYPE.SPLINE_CURVE, + sourcePosition, + targetPosition, + controlPoints: [controlPoint], + }; + } + // default value + return { + type: EDGE_TYPE.LINE, + sourcePosition: [0, 0], + targetPosition: [0, 0], + controlPoints: [], + }; + }; + + lockNodePosition = (node, x, y) => { + const d3Node = this._nodeMap[node.id]; + d3Node.x = x; + d3Node.y = y; + this._callbacks.onLayoutChange(); + this._callbacks.onLayoutDone(); + }; +} diff --git a/examples/graph-layers/multi-graph/sample-graph.json b/examples/graph-layers/multi-graph/sample-graph.json new file mode 100644 index 00000000..2514bb2f --- /dev/null +++ b/examples/graph-layers/multi-graph/sample-graph.json @@ -0,0 +1,97 @@ +{ + "nodes": [ + {"id": "Alan", "type": "user", "star": true}, + {"id": "Bob", "type": "user", "star": false}, + {"id": "Cathy", "type": "user", "star": true}, + {"id": "Denise", "type": "user", "star": false}, + {"id": "Edward", "type": "user", "star": true}, + {"id": "Frank", "type": "user", "star": false}, + {"id": "George", "type": "user", "star": false} + ], + "edges": [ + { + "id": "0", + "sourceId": "Alan", + "targetId": "Bob", + "type": "is_friend" + }, + { + "id": "1", + "sourceId": "Alan", + "targetId": "Bob", + "type": "is_neighbor" + }, + { + "id": "2", + "sourceId": "Bob", + "targetId": "Cathy", + "type": "is_neighbor" + }, + { + "id": "3", + "sourceId": "Cathy", + "targetId": "Frank", + "type": "is_neighbor" + }, + { + "id": "4", + "sourceId": "Edward", + "targetId": "Denise", + "type": "is_friend" + }, + { + "id": "5", + "sourceId": "Edward", + "targetId": "Frank", + "type": "is_friend" + }, + { + "id": "6", + "sourceId": "Alan", + "targetId": "Denise", + "type": "is_classmate" + }, + { + "id": "7", + "sourceId": "Bob", + "targetId": "Denise", + "type": "is_classmate" + }, + { + "id": "8", + "sourceId": "Edward", + "targetId": "Cathy", + "type": "is_classmate" + }, + { + "id": "9", + "sourceId": "Edward", + "targetId": "Cathy", + "type": "is_friend" + }, + { + "id": "10", + "sourceId": "George", + "targetId": "Denise", + "type": "is_friend" + }, + { + "id": "11", + "sourceId": "George", + "targetId": "Edward", + "type": "is_friend" + }, + { + "id": "12", + "sourceId": "Denise", + "targetId": "Edward", + "type": "is_neighbor" + }, + { + "id": "13", + "sourceId": "Bob", + "targetId": "Cathy", + "type": "is_friend" + } + ] +} diff --git a/examples/graph-layers/radial-layout/app.ts b/examples/graph-layers/radial-layout/app.ts new file mode 100644 index 00000000..4b732a34 --- /dev/null +++ b/examples/graph-layers/radial-layout/app.ts @@ -0,0 +1,92 @@ +import React, {Component} from 'react'; +import {scaleOrdinal} from 'd3-scale'; +import {schemeAccent} from 'd3-scale-chromatic'; +import {extent} from 'd3-array'; +import Color from 'color'; + +// data +import {fetchJSONFromS3} from '../../utils/data/io'; + +// graph.gl +import GraphGL, {JSONLoader, NODE_TYPE} from '../../src'; +import RadialLayout from './radial-layout'; + +const DEFAULT_NODE_SIZE = 5; +const DEFAULT_NODE_LABEL_COLOR = '#646464'; +const DEFAULT_EDGE_COLOR = 'rgba(80, 80, 80, 0.3)'; +const RADIUS = 150; + +export default class BasicRadialExample extends Component { + state = {graph: null, tree: null}; + + componentDidMount() { + fetchJSONFromS3(['wits.json']).then(([sampleGraph]) => { + const {nodes} = sampleGraph; + const nodeIndexMap = nodes.reduce((res, node, idx) => { + res[idx] = node.name; + return res; + }, {}); + const graph = JSONLoader({ + json: sampleGraph, + nodeParser: node => ({id: node.name}), + edgeParser: edge => ({ + id: `${edge.source}-${edge.target}`, + sourceId: nodeIndexMap[edge.source], + targetId: nodeIndexMap[edge.target], + directed: true, + }), + }); + this.setState({graph, tree: sampleGraph.tree}); + // parse attributes + const groupExtent = extent(nodes, n => n.group); + this._nodeColorScale = scaleOrdinal(schemeAccent).domain(groupExtent); + }); + } + + // node accessors + getNodeColor = node => { + const hex = this._nodeColorScale(node.getPropertyValue('group')); + return Color(hex).array(); + }; + + render() { + if (!this.state.graph) { + return null; + } + + return ( + node.getPropertyValue('name'), + color: DEFAULT_NODE_LABEL_COLOR, + textAnchor: 'start', + fontSize: 8, + // TODO: figure out how to get node position without engine + // angle: n => { + // const nodePos = this._engine.getNodePosition(n); + // return (Math.atan2(nodePos[1], nodePos[0]) * -180) / Math.PI; + // }, + }, + ]} + edgeStyle={{ + stroke: DEFAULT_EDGE_COLOR, + strokeWidth: 1, + }} + /> + ); + } +} diff --git a/examples/graph-layers/radial-layout/radial-layout.ts b/examples/graph-layers/radial-layout/radial-layout.ts new file mode 100644 index 00000000..3104b631 --- /dev/null +++ b/examples/graph-layers/radial-layout/radial-layout.ts @@ -0,0 +1,197 @@ +import {BaseLayout, EDGE_TYPE} from '../../src'; + +const defaultOptions = { + radius: 500, +}; + +function rotate(cx, cy, x, y, angle) { + const radians = (Math.PI / 180) * angle; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const nx = cos * (x - cx) + sin * (y - cy) + cx; + const ny = cos * (y - cy) - sin * (x - cx) + cy; + return [nx, ny]; +} + +const traverseTree = (nodeId, nodeMap) => { + const node = nodeMap[nodeId]; + if (node.isLeaf) { + return node; + } + return { + ...node, + children: node.children.map(nid => traverseTree(nid, nodeMap)), + }; +}; + +const getLeafNodeCount = (node, count) => { + if (!node.children || node.children.length === 0) { + return count + 1; + } + const sum = node.children.reduce((res, c) => { + return res + getLeafNodeCount(c, 0); + }, 0); + return count + sum; +}; + +const getTreeDepth = (node, depth = 0) => { + if (node.isLeaf) { + return depth; + } + return getTreeDepth(node.children[0], depth + 1); +}; + +const getPath = (node, targetId, path) => { + if (node.id === targetId) { + path.push(node.id); + return true; + } + const inChildren = + node.children && node.children.some(c => getPath(c, targetId, path)); + if (inChildren) { + path.push(node.id); + return true; + } + return false; +}; + +export default class RadialLayout extends BaseLayout { + constructor(options) { + super(options); + this._name = 'RadialLayout'; + this._options = { + ...defaultOptions, + ...options, + }; + // custom layout data structure + this._graph = null; + this._hierarchicalPoints = {}; + } + + initializeGraph(graph) { + this.updateGraph(graph); + } + + updateGraph(graph) { + this._graph = graph; + } + + start() { + const nodeCount = this._graph.getNodes().length; + if (nodeCount === 0) { + return; + } + + const {tree} = this._options; + + if (!tree || tree.length === 0) { + return; + } + + const {radius} = this._options; + const unitAngle = 360 / nodeCount; + + // hierarchical positions + const rootNode = tree[0]; + + const nodeMap = tree.reduce((res, node) => { + res[node.id] = { + ...node, + isLeaf: !node.children || node.children.length === 0, + }; + return res; + }, {}); + // nested structure + this.nestedTree = traverseTree(rootNode.id, nodeMap); + + const totalLevels = getTreeDepth(this.nestedTree, 0); + const distanceBetweenLevels = radius / (totalLevels - 1); + + const calculatePosition = (node, level, startAngle, positionMap) => { + const isRoot = node.id === rootNode.id; + + if (node.children && node.children.length !== 0) { + const groupSize = getLeafNodeCount(node, 0); + // center the pos + positionMap[node.id] = isRoot + ? [0, 0] + : rotate( + 0, + 0, + 0, + distanceBetweenLevels * (level + 1), + startAngle + unitAngle * (groupSize / 2) + ); + // calculate children position + let tempAngle = startAngle; + node.children.forEach(n => { + calculatePosition(n, level + 1, tempAngle, positionMap); + tempAngle += getLeafNodeCount(n, 0) * unitAngle; + }); + } else { + positionMap[node.id] = rotate( + 0, + 0, + 0, + distanceBetweenLevels * (level + 1), + startAngle + unitAngle + ); + } + }; + + this._hierarchicalPoints = {}; + calculatePosition(this.nestedTree, 0, 0, this._hierarchicalPoints); + // layout completes: notifiy component to re-render + this._callbacks.onLayoutChange(); + this._callbacks.onLayoutDone(); + } + + getNodePosition = node => { + return this._hierarchicalPoints[node.id]; + }; + + // spline curve version + getEdgePosition = edge => { + const sourceNodeId = edge.getSourceNodeId(); + const targetNodeId = edge.getTargetNodeId(); + const sourceNodePos = this._hierarchicalPoints[sourceNodeId]; + const targetNodePos = this._hierarchicalPoints[targetNodeId]; + + const sourcePath = []; + getPath(this.nestedTree, sourceNodeId, sourcePath); + const targetPath = []; + getPath(this.nestedTree, targetNodeId, targetPath); + + const totalLevels = sourcePath.length; + let commonAncestorLevel = totalLevels - 1; // root + for (let i = 0; i < totalLevels; i++) { + if (sourcePath[i] === targetPath[i]) { + commonAncestorLevel = i; + break; + } + } + + const wayPoints = []; + for (let i = 1; i <= commonAncestorLevel; i++) { + const nodeId = sourcePath[i]; + wayPoints.push(this._hierarchicalPoints[nodeId]); + } + for (let i = commonAncestorLevel - 1; i > 0; i--) { + const nodeId = targetPath[i]; + wayPoints.push(this._hierarchicalPoints[nodeId]); + } + + return { + type: EDGE_TYPE.SPLINE_CURVE, + sourcePosition: sourceNodePos, + targetPosition: targetNodePos, + controlPoints: wayPoints, + }; + }; + + lockNodePosition = (node, x, y) => { + this._hierarchicalPoints[node.id] = [x, y]; + this._callbacks.onLayoutChange(); + this._callbacks.onLayoutDone(); + }; +}