init 🚀
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const Path = styled.path`
|
||||
fill: none;
|
||||
stroke: #333;
|
||||
stroke-width: 2;
|
||||
`;
|
||||
|
||||
export default (props) => {
|
||||
const { targetNode, sourceNode } = props;
|
||||
|
||||
const sourceX = sourceNode.position.x + (sourceNode.data.__width / 2);
|
||||
const sourceY = sourceNode.position.y + sourceNode.data.__height;
|
||||
|
||||
const targetX = targetNode.position.x + (targetNode.data.__width / 2);
|
||||
const targetY = targetNode.position.y;
|
||||
|
||||
return (
|
||||
<Path
|
||||
d={`M ${sourceX},${sourceY}L ${targetX},${targetY}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import Edge from './Edge';
|
||||
import { Consumer } from '../GraphContext';
|
||||
|
||||
function renderEdge(e, nodes) {
|
||||
const sourceNode = nodes.find(n => n.data.id === e.data.source);
|
||||
const targetNode = nodes.find(n => n.data.id === e.data.target);
|
||||
|
||||
if (!sourceNode) {
|
||||
throw new Error(`couldn't create edge for source id: ${e.data.source}`);
|
||||
}
|
||||
|
||||
if (!targetNode) {
|
||||
throw new Error(`couldn't create edge for source id: ${e.data.target}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Edge
|
||||
key={`${e.data.source}-${e.data.target}`}
|
||||
sourceNode={sourceNode}
|
||||
targetNode={targetNode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
class EdgeRenderer extends PureComponent {
|
||||
render() {
|
||||
const { width, height } = this.props;
|
||||
|
||||
if (!width) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Consumer>
|
||||
{({ transform, edges, nodes }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<g
|
||||
transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}
|
||||
>
|
||||
{edges.map(e => renderEdge(e, nodes))}
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
</Consumer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default EdgeRenderer;
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { createContext, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { zoomIdentity } from 'd3-zoom';
|
||||
import { getBoundingBox } from '../graph-utils';
|
||||
|
||||
export const GraphContext = createContext({});
|
||||
|
||||
export const Provider = (props) => {
|
||||
const {
|
||||
nodes: initNodes,
|
||||
edges: initEdges,
|
||||
onNodeClick,
|
||||
children
|
||||
} = props;
|
||||
|
||||
const [width, setWidth] = useState(0);
|
||||
const [height, setHeight] = useState(0);
|
||||
const [d3ZoomState, initD3ZoomState] = useState({
|
||||
zoom: null, selection: null, initialised: false
|
||||
});
|
||||
const [nodes, setNodes] = useState(initNodes);
|
||||
const [edges, setEdges] = useState(initEdges);
|
||||
const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 });
|
||||
|
||||
const updateNodeData = (nodeId, updateData) => {
|
||||
const updatedNodes = nodes.map((n) => {
|
||||
if (n.data.id === nodeId) {
|
||||
n.data = {
|
||||
...n.data,
|
||||
...updateData
|
||||
};
|
||||
}
|
||||
|
||||
return n;
|
||||
});
|
||||
|
||||
setNodes(updatedNodes);
|
||||
};
|
||||
|
||||
const updateNodePos = (nodeId, pos) => {
|
||||
const updatedNodes = nodes.map((n) => {
|
||||
if (n.data.id === nodeId) {
|
||||
n.position = pos;
|
||||
}
|
||||
|
||||
return n;
|
||||
});
|
||||
|
||||
setNodes(updatedNodes);
|
||||
};
|
||||
|
||||
const updateTransform = (nextTransform) => {
|
||||
setTransform({
|
||||
k: Math.round(nextTransform.k * 1000) / 1000,
|
||||
x: nextTransform.x,
|
||||
y: nextTransform.y
|
||||
});
|
||||
};
|
||||
|
||||
const fitView = () => {
|
||||
const bounds = getBoundingBox(nodes);
|
||||
const k = Math.min(width, height) / Math.max(bounds.width, bounds.height);
|
||||
const boundsCenterX = bounds.x + (bounds.width / 2);
|
||||
const boundsCenterY = bounds.y + (bounds.height / 2);
|
||||
const translate = [(width / 2) - (boundsCenterX * k), (height / 2) - (boundsCenterY * k)];
|
||||
const initialTransform = zoomIdentity.translate(translate[0], translate[1]).scale(k);
|
||||
|
||||
d3ZoomState.selection.call(d3ZoomState.zoom.transform, initialTransform);
|
||||
};
|
||||
|
||||
const updateSize = (size) => {
|
||||
setWidth(size.width);
|
||||
setHeight(size.height);
|
||||
};
|
||||
|
||||
const graphContext = {
|
||||
width,
|
||||
height,
|
||||
updateSize,
|
||||
d3ZoomState,
|
||||
initD3ZoomState,
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
updateNodeData,
|
||||
updateNodePos,
|
||||
transform,
|
||||
updateTransform,
|
||||
onNodeClick,
|
||||
fitView
|
||||
};
|
||||
|
||||
return (
|
||||
<GraphContext.Provider
|
||||
value={graphContext}
|
||||
>
|
||||
{children}
|
||||
</GraphContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const { Consumer } = GraphContext;
|
||||
|
||||
Provider.propTypes = {
|
||||
nodes: PropTypes.arrayOf(PropTypes.object),
|
||||
edges: PropTypes.arrayOf(PropTypes.object)
|
||||
};
|
||||
|
||||
Provider.defaultProps = {
|
||||
nodes: [],
|
||||
edges: []
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useContext, useRef } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import * as d3Zoom from 'd3-zoom';
|
||||
import { select, event } from 'd3-selection';
|
||||
import ReactSizeMe from 'react-sizeme';
|
||||
|
||||
import { GraphContext } from '../GraphContext';
|
||||
import NodeRenderer from '../NodeRenderer';
|
||||
import EdgeRenderer from '../EdgeRenderer';
|
||||
|
||||
const GraphViewWrapper = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const ZoomNode = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
`;
|
||||
|
||||
const GraphView = (props) => {
|
||||
const zoomNode = useRef(null);
|
||||
const graphContext = useContext(GraphContext);
|
||||
|
||||
useEffect(() => {
|
||||
const zoom = d3Zoom.zoom()
|
||||
.scaleExtent([0.5, 2])
|
||||
.on('zoom', () => {
|
||||
if (event.sourceEvent && event.sourceEvent.target !== zoomNode.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
graphContext.updateTransform(event.transform);
|
||||
|
||||
props.onMove();
|
||||
});
|
||||
|
||||
const selection = select(zoomNode.current).call(zoom);
|
||||
|
||||
graphContext.initD3ZoomState({ zoom, selection, initialised: true });
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => graphContext.updateSize(props.size),
|
||||
[props.size.width, props.size.height]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (graphContext.d3ZoomState.initialised) {
|
||||
props.onLoad({
|
||||
nodes: graphContext.nodes,
|
||||
edges: graphContext.edges,
|
||||
fitView: graphContext.fitView
|
||||
});
|
||||
}
|
||||
}, [graphContext.d3ZoomState.initialised]);
|
||||
|
||||
return (
|
||||
<GraphViewWrapper>
|
||||
<NodeRenderer />
|
||||
<EdgeRenderer width={graphContext.width} height={graphContext.height} />
|
||||
<ZoomNode ref={zoomNode} />
|
||||
</GraphViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default ReactSizeMe.withSize({ monitorHeight: true })(GraphView);
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const Handle = styled.div`
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #222;
|
||||
left: 50%;
|
||||
border-radius: 50%;
|
||||
`;
|
||||
|
||||
export default props => <Handle {...props} />;
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import wrapNode from './wrapNode';
|
||||
import Handle from '../Handle';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
background: #ff6060;
|
||||
padding: 10px;
|
||||
`;
|
||||
|
||||
export default wrapNode(({ data, style }) => (
|
||||
<Wrapper style={style}>
|
||||
<Handle style={{ top: 0 }} />
|
||||
{data.label}
|
||||
<Handle style={{ bottom: 0, top: 'auto', transform: 'translate(-50%, 50%)' }} />
|
||||
</Wrapper>
|
||||
));
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import wrapNode from './wrapNode';
|
||||
import Handle from '../Handle';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
background: #9999ff;
|
||||
padding: 10px;
|
||||
`;
|
||||
|
||||
export default wrapNode(({ data, style }) => (
|
||||
<Wrapper style={style}>
|
||||
{data.label}
|
||||
<Handle style={{ bottom: 0, top: 'auto', transform: 'translate(-50%, 50%)' }} />
|
||||
</Wrapper>
|
||||
));
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import wrapNode from './wrapNode';
|
||||
import Handle from '../Handle';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
background: #55ff99;
|
||||
padding: 10px;
|
||||
`;
|
||||
|
||||
export default wrapNode(({ data, style }) => (
|
||||
<Wrapper style={style}>
|
||||
<Handle style={{ top: 0 }} />
|
||||
{data.label}
|
||||
</Wrapper>
|
||||
));
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useRef, useContext } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import ReactDraggable from 'react-draggable';
|
||||
|
||||
import { GraphContext } from '../../GraphContext';
|
||||
|
||||
const NodeWrapper = styled.div`
|
||||
position: absolute;
|
||||
width: 150px;
|
||||
color: #222;
|
||||
font-family: sans-serif;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
cursor: grab;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 2px;
|
||||
user-select: none;
|
||||
pointer-events: all;
|
||||
transform-origin: 0 0;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 1px 5px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
`;
|
||||
|
||||
export default NodeComponent => (props) => {
|
||||
const { position, data, onNodeClick } = props;
|
||||
const { id, __width, __height } = data;
|
||||
const nodeElement = useRef(null);
|
||||
const graphContext = useContext(GraphContext);
|
||||
const { x, y, k } = graphContext.transform;
|
||||
|
||||
useEffect(() => {
|
||||
const bounds = nodeElement.current.getBoundingClientRect();
|
||||
|
||||
if (__width !== bounds.width || __height !== bounds.height) {
|
||||
graphContext.updateNodeData(id, { __width: bounds.width, __height: bounds.height });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const nodePosition = {
|
||||
x: (k * position.x) + x,
|
||||
y: (k * position.y) + y
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactDraggable.DraggableCore
|
||||
grid={[1, 1]}
|
||||
onStart={(e) => {
|
||||
const offsetX = e.clientX - position.x - x;
|
||||
const offsetY = e.clientY - position.y - y;
|
||||
|
||||
graphContext.updateNodeData(id, { __offsetX: offsetX, __offsetY: offsetY });
|
||||
}}
|
||||
onDrag={(e, d) => {
|
||||
const { __offsetX = 0, __offsetY = 0 } = data;
|
||||
|
||||
graphContext.updateNodePos(id, {
|
||||
x: e.clientX - x - __offsetX,
|
||||
y: e.clientY - y - __offsetY
|
||||
});
|
||||
}}
|
||||
scale={k}
|
||||
>
|
||||
<NodeWrapper
|
||||
className="node"
|
||||
ref={nodeElement}
|
||||
style={{ transform: `translate(${position.x}px,${position.y}px)` }}
|
||||
onClick={() => onNodeClick(data)}
|
||||
// style={{ transform: `translate(${nodePosition.x}px,${nodePosition.y}px) scale(${k})` }}
|
||||
>
|
||||
<NodeComponent {...props} />
|
||||
</NodeWrapper>
|
||||
</ReactDraggable.DraggableCore>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Consumer } from '../GraphContext';
|
||||
import DefaultNode from './NodeTypes/DefaultNode';
|
||||
import InputNode from './NodeTypes/InputNode';
|
||||
import OutputNode from './NodeTypes/OutputNode';
|
||||
|
||||
const Nodes = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
transform-origin: 0 0;
|
||||
`;
|
||||
|
||||
class NodeRenderer extends PureComponent {
|
||||
|
||||
renderNode(d, onNodeClick) {
|
||||
const nodeType = d.data.type || 'default';
|
||||
const NodeComponent = this.props.nodeTypes[nodeType];
|
||||
|
||||
return (
|
||||
<NodeComponent
|
||||
key={d.data.id}
|
||||
position={d.position}
|
||||
data={d.data}
|
||||
style={d.style || {}}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Consumer>
|
||||
{({ transform, nodes, onNodeClick }) => (
|
||||
<Nodes
|
||||
style={{
|
||||
transform: `translate(${transform.x}px,${transform.y}px) scale(${transform.k})`
|
||||
}}
|
||||
>
|
||||
{nodes.map(d => this.renderNode(d, onNodeClick))}
|
||||
</Nodes>
|
||||
)}
|
||||
</Consumer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
NodeRenderer.defaultProps = {
|
||||
nodeTypes: {
|
||||
input: InputNode,
|
||||
default: DefaultNode,
|
||||
output: OutputNode
|
||||
}
|
||||
};
|
||||
|
||||
export default NodeRenderer;
|
||||
@@ -0,0 +1,49 @@
|
||||
export const isEdge = element => element.data.source && element.data.target;
|
||||
|
||||
export const separateElements = elements => ({
|
||||
nodes: elements.filter(e => !isEdge(e)),
|
||||
edges: elements.filter(e => isEdge(e))
|
||||
});
|
||||
|
||||
export const getBoundingBox = (nodes) => {
|
||||
const bbox = nodes.reduce((res, node) => {
|
||||
const x2 = node.position.x + node.data.__width;
|
||||
const y2 = node.position.y + node.data.__height;
|
||||
|
||||
if (node.position.x < res.minX) {
|
||||
res.minX = node.position.x;
|
||||
}
|
||||
|
||||
if (x2 > res.maxX) {
|
||||
res.maxX = x2;
|
||||
}
|
||||
|
||||
if (node.position.y < res.minY) {
|
||||
res.minY = node.position.y;
|
||||
}
|
||||
|
||||
if (y2 > res.maxY) {
|
||||
res.maxY = y2;
|
||||
}
|
||||
|
||||
return res;
|
||||
}, {
|
||||
minX: Number.MAX_VALUE,
|
||||
minY: Number.MAX_VALUE,
|
||||
maxX: 0,
|
||||
maxY: 0
|
||||
});
|
||||
|
||||
return {
|
||||
x: bbox.minX,
|
||||
y: bbox.minY,
|
||||
width: bbox.maxX - bbox.minX,
|
||||
height: bbox.maxY - bbox.minY
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
isEdge,
|
||||
separateElements,
|
||||
getBoundingBox
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import isEqual from 'lodash.isequal';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { separateElements } from './graph-utils';
|
||||
import GraphView from './GraphView';
|
||||
import { Provider } from './GraphContext';
|
||||
|
||||
const GraphWrapper = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
class ReactGraph extends PureComponent {
|
||||
constructor(props) {
|
||||
super();
|
||||
|
||||
const { elements } = props;
|
||||
|
||||
this.state = {
|
||||
...separateElements(elements)
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const { elements } = this.props;
|
||||
const { nodes, edges } = separateElements(elements);
|
||||
const nodesChanged = !isEqual(nodes, prevState.nodes);
|
||||
const edgesChanged = !isEqual(edges, prevState.edges);
|
||||
|
||||
if (!nodesChanged && !edgesChanged) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nodesChanged) {
|
||||
this.setState({ nodes });
|
||||
}
|
||||
|
||||
if (edgesChanged) {
|
||||
this.setState({ edges });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
style, onNodeClick, children, onLoad, onMove
|
||||
} = this.props;
|
||||
const { nodes, edges } = this.state;
|
||||
|
||||
return (
|
||||
<GraphWrapper style={style}>
|
||||
<Provider nodes={nodes} edges={edges} onNodeClick={onNodeClick}>
|
||||
<GraphView onLoad={onLoad} onMove={onMove} />
|
||||
{children}
|
||||
</Provider>
|
||||
</GraphWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReactGraph.defaultProps = {
|
||||
onNodeClick: () => {},
|
||||
onLoad: () => {},
|
||||
onMove: () => {}
|
||||
};
|
||||
|
||||
export default ReactGraph;
|
||||
Reference in New Issue
Block a user