diff --git a/packages/diagrams-demo-gallery/demos/demo-right-angles-routing/index.tsx b/packages/diagrams-demo-gallery/demos/demo-right-angles-routing/index.tsx new file mode 100644 index 0000000..022fffb --- /dev/null +++ b/packages/diagrams-demo-gallery/demos/demo-right-angles-routing/index.tsx @@ -0,0 +1,68 @@ +import createEngine, { + DiagramModel, + DefaultNodeModel, + DefaultPortModel, + RightAngleLinkFactory, LinkModel, RightAngleLinkModel, +} from '@projectstorm/react-diagrams'; +import * as React from 'react'; +import { DemoButton, DemoWorkspaceWidget } from '../helpers/DemoWorkspaceWidget'; +import { action } from '@storybook/addon-actions'; +import {AbstractModelFactory, CanvasWidget} from '@projectstorm/react-canvas-core'; +import { DemoCanvasWidget } from '../helpers/DemoCanvasWidget'; + +// When new link is created by clicking on port the RightAngleLinkModel needs to be returned. +export class RightAnglePortModel extends DefaultPortModel { + createLinkModel(factory?: AbstractModelFactory) { + return new RightAngleLinkModel(); + } +} + +export default () => { + // setup the diagram engine + const engine = createEngine(); + engine.getLinkFactories().registerFactory(new RightAngleLinkFactory()); + + // setup the diagram model + const model = new DiagramModel(); + + // create four nodes in a way that straight links wouldn't work + const node1 = new DefaultNodeModel("Node A", "rgb(0,192,255)"); + const port1 = node1.addPort(new RightAnglePortModel(false, "out-1", "Out")); + node1.setPosition(340, 350); + + const node2 = new DefaultNodeModel("Node B", "rgb(255,255,0)"); + const port2 = node2.addPort(new RightAnglePortModel(false, "out-1", "Out")); + node2.setPosition(240, 80); + const node3 = new DefaultNodeModel("Node C", "rgb(192,255,255)"); + const port3 = node3.addPort(new RightAnglePortModel(true, "in-1", "In")); + node3.setPosition(540, 180); + const node4 = new DefaultNodeModel("Node D", "rgb(192,0,255)"); + const port4 = node4.addPort(new RightAnglePortModel(true, "in-1", "In")); + node4.setPosition(95, 185); + + // linking things together + const link1 = port1.link(port4); + const link2 = port2.link(port3); + + // add all to the main model + model.addAll(node1, node2, node3, node4, link1, link2); + + // load model into engine and render + engine.setModel(model); + + return ( + { + action('Serialized Graph')(JSON.stringify(model.serialize(), null, 2)); + }}> + Serialize Graph + + }> + + + + + ); +}; diff --git a/packages/diagrams-demo-gallery/index.tsx b/packages/diagrams-demo-gallery/index.tsx index 7ee255a..f12ea92 100644 --- a/packages/diagrams-demo-gallery/index.tsx +++ b/packages/diagrams-demo-gallery/index.tsx @@ -50,13 +50,15 @@ import demo_adv_ser_des from './demos/demo-serializing'; import demo_adv_prog from './demos/demo-mutate-graph'; import demo_adv_dnd from './demos/demo-drag-and-drop'; import demo_smart_routing from './demos/demo-smart-routing'; +import demo_right_angles_routing from './demos/demo-right-angles-routing'; storiesOf('Advanced Techniques', module) .add('Clone Selected', demo_adv_clone_selected) .add('Serializing and de-serializing', demo_adv_ser_des) .add('Programatically modifying graph', demo_adv_prog) .add('Drag and drop', demo_adv_dnd) - .add('Smart routing', demo_smart_routing); + .add('Smart routing', demo_smart_routing) + .add('Right angles routing', demo_right_angles_routing); import demo_cust_nodes from './demos/demo-custom-node1'; import demo_cust_links from './demos/demo-custom-link1'; diff --git a/packages/react-diagrams-routing/index.ts b/packages/react-diagrams-routing/index.ts index fbab7be..eac701c 100644 --- a/packages/react-diagrams-routing/index.ts +++ b/packages/react-diagrams-routing/index.ts @@ -1,6 +1,9 @@ export * from './src/link/PathFindingLinkFactory'; export * from './src/link/PathFindingLinkModel'; export * from './src/link/PathFindingLinkWidget'; +export * from './src/link/RightAngleLinkWidget'; +export * from './src/link/RightAngleLinkFactory'; +export * from './src/link/RightAngleLinkModel'; export * from './src/engine/PathFinding'; export * from './src/dagre/DagreEngine'; diff --git a/packages/react-diagrams-routing/src/engine/PathFinding.ts b/packages/react-diagrams-routing/src/engine/PathFinding.ts index 48665f0..dab5f47 100644 --- a/packages/react-diagrams-routing/src/engine/PathFinding.ts +++ b/packages/react-diagrams-routing/src/engine/PathFinding.ts @@ -59,14 +59,22 @@ export default class PathFinding { pathToStart: number[][]; pathToEnd: number[][]; } { - const startIndex = path.findIndex(point => matrix[point[1]][point[0]] === 0); + const startIndex = path.findIndex(point => { + if (matrix[point[1]]) + return matrix[point[1]][point[0]] === 0; + else return false; + }); const endIndex = path.length - 1 - path .slice() .reverse() - .findIndex(point => matrix[point[1]][point[0]] === 0); + .findIndex(point => { + if (matrix[point[1]]) + return matrix[point[1]][point[0]] === 0; + else return false; + }); // are we trying to create a path exclusively through blocked areas? // if so, let's fallback to the linear routing diff --git a/packages/react-diagrams-routing/src/link/RightAngleLinkFactory.tsx b/packages/react-diagrams-routing/src/link/RightAngleLinkFactory.tsx new file mode 100644 index 0000000..f653c6c --- /dev/null +++ b/packages/react-diagrams-routing/src/link/RightAngleLinkFactory.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { RightAngleLinkWidget } from "./RightAngleLinkWidget"; +import { DiagramEngine } from '@projectstorm/react-diagrams-core'; +import { DefaultLinkFactory, DefaultLinkModel } from '@projectstorm/react-diagrams-defaults'; +import {RightAngleLinkModel} from "./RightAngleLinkModel"; + +/** + * @author Daniel Lazar + */ +export class RightAngleLinkFactory extends DefaultLinkFactory { + + static NAME = 'rightAngle'; + + constructor() { + super(RightAngleLinkFactory.NAME); + } + + generateModel(event): RightAngleLinkModel { + return new RightAngleLinkModel(); + } + + generateReactWidget(event): JSX.Element { + return + } +} diff --git a/packages/react-diagrams-routing/src/link/RightAngleLinkModel.ts b/packages/react-diagrams-routing/src/link/RightAngleLinkModel.ts new file mode 100644 index 0000000..f8decc2 --- /dev/null +++ b/packages/react-diagrams-routing/src/link/RightAngleLinkModel.ts @@ -0,0 +1,15 @@ +import { DefaultLinkModel, DefaultLinkModelOptions } from '@projectstorm/react-diagrams-defaults'; +import {RightAngleLinkFactory} from "./RightAngleLinkFactory"; + +export class RightAngleLinkModel extends DefaultLinkModel { + constructor(options: DefaultLinkModelOptions = {}) { + super({ + type: RightAngleLinkFactory.NAME, + ...options + }); + } + + performanceTune() { + return false; + } +} diff --git a/packages/react-diagrams-routing/src/link/RightAngleLinkWidget.tsx b/packages/react-diagrams-routing/src/link/RightAngleLinkWidget.tsx new file mode 100644 index 0000000..984d720 --- /dev/null +++ b/packages/react-diagrams-routing/src/link/RightAngleLinkWidget.tsx @@ -0,0 +1,266 @@ +import * as React from "react"; +import {DiagramEngine, LinkWidget, PointModel} from '@projectstorm/react-diagrams-core'; +import { RightAngleLinkFactory } from "./RightAngleLinkFactory"; +import {DefaultLinkModel, DefaultLinkSegmentWidget} from "@projectstorm/react-diagrams-defaults"; +import {Point} from "@projectstorm/geometry"; +import {MouseEvent} from "react"; +import {RightAngleLinkModel} from "./RightAngleLinkModel"; + +export interface RightAngleLinkProps { + color?: string; + width?: number; + smooth?: boolean; + link: RightAngleLinkModel; + diagramEngine: DiagramEngine; + factory: RightAngleLinkFactory; +} + +export interface RightAngleLinkState { + selected: boolean, + canDrag: boolean, +} + +export class RightAngleLinkWidget extends React.Component { + public static defaultProps: RightAngleLinkProps = { + color: "red", + width: 3, + link: null, + smooth: false, + diagramEngine: null, + factory: null, + }; + + refPaths: React.RefObject[]; + + // DOM references to the label and paths (if label is given), used to calculate dynamic positioning + refLabels: { [id: string]: HTMLElement }; + dragging_index: number; + + constructor(props: RightAngleLinkProps) { + super(props); + + this.refPaths = []; + this.state = { + selected: false, + canDrag: false, + }; + + this.dragging_index = 0; + } + + componentDidUpdate(): void { + this.props.link.setRenderedPaths( + this.refPaths.map(ref => { + return ref.current; + }) + ); + } + + componentDidMount(): void { + this.props.link.setRenderedPaths( + this.refPaths.map(ref => { + return ref.current; + }) + ); + } + + componentWillUnmount(): void { + this.props.link.setRenderedPaths([]); + } + + generateLink(path: string, extraProps: any, id: string | number): JSX.Element { + const ref = React.createRef(); + this.refPaths.push(ref); + return ( + { + this.setState({ selected: selected }); + }} + extras={extraProps} + /> + ); + } + + calculatePositions(points: PointModel[], event: MouseEvent, index: number, coordinate: string) { + // If path is first or last add another point to keep node port on its position + if (index === 0) { + let point = new PointModel({ + link: this.props.link, + position: new Point(points[index].getX(), points[index].getY()) + },); + this.props.link.addPoint(point, index); + this.dragging_index++; + return; + } else if (index === points.length - 2) { + let point = new PointModel({ + link: this.props.link, + position: new Point(points[index + 1].getX(), points[index + 1].getY()) + },); + this.props.link.addPoint(point, index + 1); + return; + } + + // Merge two points if it is not close to node port and close to each other + if (index - 2 > 0) { + let _points = { + [index - 2]: points[index - 2].getPosition(), + [index + 1]: points[index + 1].getPosition(), + [index - 1]: points[index - 1].getPosition(), + }; + if (Math.abs(_points[index - 1][coordinate] - _points[index + 1][coordinate]) < 5) { + _points[index - 2][coordinate] = this.props.diagramEngine.getRelativeMousePoint(event)[coordinate]; + _points[index + 1][coordinate] = this.props.diagramEngine.getRelativeMousePoint(event)[coordinate]; + points[index - 2].setPosition(_points[index - 2]); + points[index + 1].setPosition(_points[index + 1]); + points[index - 1].remove(); + points[index - 1].remove(); + this.dragging_index--; + this.dragging_index--; + return; + } + } + + // Merge two points if it is not close to node port + if (index + 2 < points.length - 2) { + let _points = { + [index + 3]: points[index + 3].getPosition(), + [index + 2]: points[index + 2].getPosition(), + [index + 1]: points[index + 1].getPosition(), + [index]: points[index].getPosition(), + }; + if (Math.abs(_points[index + 1][coordinate] - _points[index + 2][coordinate]) < 5) { + _points[index][coordinate] = this.props.diagramEngine.getRelativeMousePoint(event)[coordinate]; + _points[index + 3][coordinate] = this.props.diagramEngine.getRelativeMousePoint(event)[coordinate]; + points[index].setPosition(_points[index]); + points[index + 3].setPosition(_points[index + 3]); + points[index + 1].remove(); + points[index + 1].remove(); + return; + } + } + + // If no condition above handled then just update path points position + let _points = { + [index]: points[index].getPosition(), + [index + 1]: points[index + 1].getPosition(), + }; + _points[index][coordinate] = this.props.diagramEngine.getRelativeMousePoint(event)[coordinate]; + _points[index + 1][coordinate] = this.props.diagramEngine.getRelativeMousePoint(event)[coordinate]; + points[index].setPosition(_points[index]); + points[index + 1].setPosition(_points[index + 1]); + } + + draggingEvent(event: MouseEvent, index: number) { + let points = this.props.link.getPoints(); + // get moving difference. Index + 1 will work because links indexes has + // length = points.lenght - 1 + let dx = Math.abs(points[index].getX() - points[index + 1].getX()); + let dy = Math.abs(points[index].getY() - points[index + 1].getY()); + + // moving with y direction + if (dx === 0) { + this.calculatePositions(points, event, index, 'x'); + } else if (dy === 0) { + this.calculatePositions(points, event, index, 'y'); + } + } + + handleMove = function (event: MouseEvent) { + this.draggingEvent(event, this.dragging_index); + }.bind(this); + + handleUp = function (event: MouseEvent) { + // Unregister handlers to avoid multiple event handlers for other links + this.setState({ canDrag: false, selected: false }); + window.removeEventListener('mousemove', this.handleMove); + window.removeEventListener('mouseup', this.handleUp); + }.bind(this); + + render() { + //ensure id is present for all points on the path + let points = this.props.link.getPoints(); + let paths = []; + + // Get points based on link orientation + let pointLeft = points[0]; + let pointRight = points[points.length - 1]; + if (pointLeft.getX() > pointRight.getX()) { + pointLeft = points[points.length - 1]; + pointRight = points[0]; + } + let dy = Math.abs(points[0].getY() - points[points.length - 1].getY()); + + // When new link add one middle point to get everywhere 90° angle + if (this.props.link.getTargetPort() === null && points.length === 2) { + this.props.link.addPoint(new PointModel({ + link: this.props.link, + position: new Point(pointLeft.getX(), pointRight.getY()) + }), 1); + + } + // When new link is moving and not connected to target port move with middle point + else if (this.props.link.getTargetPort() === null) { + points[1].setPosition(pointLeft.getX(), pointRight.getY()); + } + // Render was called but link is not moved but user. + // Node is moved and in this case fix coordinates to get 90° angle. + // For loop just for first and last path + else if (!this.state.canDrag && points.length > 2) { + for (let i = 1; i < points.length; i+= points.length - 2) { + let dx = Math.abs(points[i].getX() - points[i - 1].getX()); + let dy = Math.abs(points[i].getY() - points[i - 1].getY()); + if (dx !== 0 || dy !== 0) { + if (dx < dy) { + if (i - 1 === 0) { points[i].setPosition(points[i - 1].getX(), points[i].getY()); } + else if (i === points.length - 1) { points[i - 1].setPosition(points[i].getX(), points[i - 1].getY());} + } else { + if (i - 1 === 0) { points[i].setPosition(points[i].getX(), points[i - 1].getY());} + else if (i === points.length - 1) { points[i - 1].setPosition(points[i - 1].getX(), points[i].getY());} + } + } + } + } + + // If there is existing link which has two points add one + // NOTE: It doesn't matter if check is for dy or dx + if (points.length === 2 && dy !== 0 && !this.state.canDrag) { + this.props.link.addPoint(new PointModel({ + link: this.props.link, + position: new Point(pointLeft.getX(), pointRight.getY()) + }), 1); + } + + for (let j = 0; j < points.length - 1; j++) { + paths.push( + this.generateLink( + LinkWidget.generateLinePath(points[j], points[j + 1]), + { + 'data-linkid': this.props.link.getID(), + 'data-point': j, + onMouseDown: (event: MouseEvent) => { + if (event.button === 0) { + this.setState({ canDrag: true }); + this.dragging_index = j; + // Register mouse move event to track mouse position + // On mouse up these events are unregistered check "this.handleUp" + window.addEventListener('mousemove', this.handleMove); + window.addEventListener('mouseup', this.handleUp); + } + } + }, + j + ) + ); + } + + this.refPaths = []; + return {paths}; + } +}