break it up further into canvas

This commit is contained in:
Dylan Vorster
2019-08-04 13:27:55 +02:00
parent 0f7930e132
commit dd5a0b7d8d
187 changed files with 1521 additions and 1543 deletions

View File

@ -0,0 +1,4 @@
*
!dist/**/*
!package.json
!README.md

View File

@ -0,0 +1,3 @@
# Project STORM > React Diagrams > Dagre
This package adds dagre integration for laying out nodes and links

View File

@ -0,0 +1,6 @@
export * from './src/link/PathFindingLinkFactory';
export * from './src/link/PathFindingLinkModel';
export * from './src/link/PathFindingLinkWidget';
export * from './src/engine/PathFinding';
export * from './src/dagre/DagreEngine';

View File

@ -0,0 +1,8 @@
const path = require('path');
module.exports = {
transform: {
'^.+\\.tsx?$': 'ts-jest'
},
roots: [path.join(__dirname, 'tests')],
testMatch: ['**/*.test.{ts,tsx}']
};

View File

@ -0,0 +1,44 @@
{
"name": "@projectstorm/react-diagrams-routing",
"version": "6.0.0-alpha.4.2",
"author": "dylanvorster",
"repository": {
"type": "git",
"url": "https://github.com/projectstorm/react-diagrams.git"
},
"scripts": {
"clean": "rm -rf ./dist",
"build": "../../node_modules/.bin/webpack",
"build:prod": "NODE_ENV=production ../../node_modules/.bin/webpack",
"test": "../../node_modules/.bin/jest --no-cache"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"web",
"diagram",
"diagrams",
"react",
"typescript",
"flowchart",
"simple",
"links",
"nodes"
],
"main": "./dist/index.js",
"typings": "./dist/@types/index",
"dependencies": {
"@projectstorm/react-diagrams-core": "^6.0.0-alpha.4.2",
"@projectstorm/react-diagrams-defaults": "^6.0.0-alpha.4.2",
"@projectstorm/geometry": "^6.0.0-alpha.4.2"
},
"peerDependencies": {
"dagre": "^0.8.4",
"lodash": "4.*",
"pathfinding": "^0.4.18",
"paths-js": "^0.4.9",
"react": "16.*"
},
"gitHead": "bb878657ba0c2f81764f32901fd96158a0f8352e"
}

View File

@ -0,0 +1,79 @@
import { DiagramModel, PointModel } from '@projectstorm/react-diagrams-core';
import * as dagre from 'dagre';
import * as _ from 'lodash';
import { GraphLabel } from 'dagre';
import { Point } from '@projectstorm/geometry';
export interface DagreEngineOptions {
graph?: GraphLabel;
/**
* Will also layout links
*/
includeLinks?: boolean;
}
export class DagreEngine {
options: DagreEngineOptions;
constructor(options: DagreEngineOptions = {}) {
this.options = options;
}
redistribute(model: DiagramModel) {
// Create a new directed graph
var g = new dagre.graphlib.Graph({
multigraph: true
});
g.setGraph(this.options.graph || {});
g.setDefaultEdgeLabel(function() {
return {};
});
const processedlinks: { [id: string]: boolean } = {};
// set nodes
_.forEach(model.getNodes(), node => {
g.setNode(node.getID(), { width: node.width, height: node.height });
});
_.forEach(model.getLinks(), link => {
// set edges
if (link.getSourcePort() && link.getTargetPort()) {
processedlinks[link.getID()] = true;
g.setEdge({
v: link
.getSourcePort()
.getNode()
.getID(),
w: link
.getTargetPort()
.getNode()
.getID(),
name: link.getID()
});
}
});
// layout the graph
dagre.layout(g);
g.nodes().forEach(v => {
const node = g.node(v);
model.getNode(v).setPosition(node.x, node.y);
});
// also include links?
if (this.options.includeLinks) {
g.edges().forEach(e => {
const edge = g.edge(e);
const link = model.getLink(e.name);
const points = [link.getFirstPoint()];
for (let i = 1; i < edge.points.length - 2; i++) {
points.push(new PointModel({ link: link, position: new Point(edge.points[i].x, edge.points[i].y) }));
}
link.setPoints(points.concat(link.getLastPoint()));
});
}
}
}

View File

@ -0,0 +1,124 @@
import * as PF from 'pathfinding';
import { PathFindingLinkFactory } from '../link/PathFindingLinkFactory';
import { PointModel } from '@projectstorm/react-diagrams-core';
/*
it can be very expensive to calculate routes when every single pixel on the canvas
is individually represented. Using the factor below, we combine values in order
to achieve the best trade-off between accuracy and performance.
*/
const pathFinderInstance = new PF.JumpPointFinder({
heuristic: PF.Heuristic.manhattan,
diagonalMovement: PF.DiagonalMovement.Never
});
export default class PathFinding {
instance: any;
factory: PathFindingLinkFactory;
constructor(factory: PathFindingLinkFactory) {
this.instance = pathFinderInstance;
this.factory = factory;
}
/**
* Taking as argument a fully unblocked walking matrix, this method
* finds a direct path from point A to B.
*/
calculateDirectPath(from: PointModel, to: PointModel): number[][] {
const matrix = this.factory.getCanvasMatrix();
const grid = new PF.Grid(matrix);
return pathFinderInstance.findPath(
this.factory.translateRoutingX(Math.floor(from.getX() / this.factory.ROUTING_SCALING_FACTOR)),
this.factory.translateRoutingY(Math.floor(from.getY() / this.factory.ROUTING_SCALING_FACTOR)),
this.factory.translateRoutingX(Math.floor(to.getX() / this.factory.ROUTING_SCALING_FACTOR)),
this.factory.translateRoutingY(Math.floor(to.getY() / this.factory.ROUTING_SCALING_FACTOR)),
grid
);
}
/**
* Using @link{#calculateDirectPath}'s result as input, we here
* determine the first walkable point found in the matrix that includes
* blocked paths.
*/
calculateLinkStartEndCoords(
matrix: number[][],
path: number[][]
): {
start: {
x: number;
y: number;
};
end: {
x: number;
y: number;
};
pathToStart: number[][];
pathToEnd: number[][];
} {
const startIndex = path.findIndex(point => matrix[point[1]][point[0]] === 0);
const endIndex =
path.length -
1 -
path
.slice()
.reverse()
.findIndex(point => matrix[point[1]][point[0]] === 0);
// are we trying to create a path exclusively through blocked areas?
// if so, let's fallback to the linear routing
if (startIndex === -1 || endIndex === -1) {
return undefined;
}
const pathToStart = path.slice(0, startIndex);
const pathToEnd = path.slice(endIndex);
return {
start: {
x: path[startIndex][0],
y: path[startIndex][1]
},
end: {
x: path[endIndex][0],
y: path[endIndex][1]
},
pathToStart,
pathToEnd
};
}
/**
* Puts everything together: merges the paths from/to the centre of the ports,
* with the path calculated around other elements.
*/
calculateDynamicPath(
routingMatrix: number[][],
start: {
x: number;
y: number;
},
end: {
x: number;
y: number;
},
pathToStart: number[][],
pathToEnd: number[][]
) {
// generate the path based on the matrix with obstacles
const grid = new PF.Grid(routingMatrix);
const dynamicPath = pathFinderInstance.findPath(start.x, start.y, end.x, end.y, grid);
// aggregate everything to have the calculated path ready for rendering
const pathCoords = pathToStart
.concat(dynamicPath, pathToEnd)
.map(coords => [
this.factory.translateRoutingX(coords[0], true),
this.factory.translateRoutingY(coords[1], true)
]);
return PF.Util.compressPath(pathCoords);
}
}

View File

@ -0,0 +1,258 @@
import * as React from 'react';
import {
DiagramEngine,
} from '@projectstorm/react-diagrams-core';
import { PathFindingLinkModel } from './PathFindingLinkModel';
import { PathFindingLinkWidget } from './PathFindingLinkWidget';
import * as _ from 'lodash';
import * as Path from 'paths-js/path';
import { DefaultLinkFactory } from '@projectstorm/react-diagrams-defaults';
import {AbstractFactory, FactoryBank, ListenerHandle} from "@projectstorm/react-canvas-core";
export class PathFindingLinkFactory extends DefaultLinkFactory<PathFindingLinkModel> {
ROUTING_SCALING_FACTOR: number = 5;
// calculated only when smart routing is active
canvasMatrix: number[][] = [];
routingMatrix: number[][] = [];
// used when at least one element has negative coordinates
hAdjustmentFactor: number = 0;
vAdjustmentFactor: number = 0;
static NAME = 'pathfinding';
listener: ListenerHandle;
constructor() {
super(PathFindingLinkFactory.NAME);
}
setDiagramEngine(engine: DiagramEngine): void {
super.setDiagramEngine(engine);
this.listener = engine.registerListener({
canvasReady: () => {
_.defer(() => {
this.calculateRoutingMatrix();
engine.repaintCanvas();
});
}
});
}
setFactoryBank(bank: FactoryBank<AbstractFactory>): void {
super.setFactoryBank(bank);
if (!bank && this.listener) {
this.listener.deregister();
}
}
generateReactWidget(event): JSX.Element {
return <PathFindingLinkWidget diagramEngine={this.engine} link={event.model} factory={this} />;
}
generateModel(event): PathFindingLinkModel {
return new PathFindingLinkModel();
}
/**
* A representation of the canvas in the following format:
*
* +-----------------+
* | 0 0 0 0 0 0 0 0 |
* | 0 0 0 0 0 0 0 0 |
* | 0 0 0 0 0 0 0 0 |
* | 0 0 0 0 0 0 0 0 |
* | 0 0 0 0 0 0 0 0 |
* +-----------------+
*
* In which all walkable points are marked by zeros.
* It uses @link{#ROUTING_SCALING_FACTOR} to reduce the matrix dimensions and improve performance.
*/
getCanvasMatrix(): number[][] {
if (this.canvasMatrix.length === 0) {
this.calculateCanvasMatrix();
}
return this.canvasMatrix;
}
calculateCanvasMatrix() {
const {
width: canvasWidth,
hAdjustmentFactor,
height: canvasHeight,
vAdjustmentFactor
} = this.calculateMatrixDimensions();
this.hAdjustmentFactor = hAdjustmentFactor;
this.vAdjustmentFactor = vAdjustmentFactor;
const matrixWidth = Math.ceil(canvasWidth / this.ROUTING_SCALING_FACTOR);
const matrixHeight = Math.ceil(canvasHeight / this.ROUTING_SCALING_FACTOR);
this.canvasMatrix = _.range(0, matrixHeight).map(() => {
return new Array(matrixWidth).fill(0);
});
}
/**
* A representation of the canvas in the following format:
*
* +-----------------+
* | 0 0 1 1 0 0 0 0 |
* | 0 0 1 1 0 0 1 1 |
* | 0 0 0 0 0 0 1 1 |
* | 1 1 0 0 0 0 0 0 |
* | 1 1 0 0 0 0 0 0 |
* +-----------------+
*
* In which all points blocked by a node (and its ports) are
* marked as 1; points were there is nothing (ie, free) receive 0.
*/
getRoutingMatrix(): number[][] {
if (this.routingMatrix.length === 0) {
this.calculateRoutingMatrix();
}
return this.routingMatrix;
}
calculateRoutingMatrix(): void {
const matrix = _.cloneDeep(this.getCanvasMatrix());
// nodes need to be marked as blocked points
this.markNodes(matrix);
// same thing for ports
this.markPorts(matrix);
this.routingMatrix = matrix;
}
/**
* The routing matrix does not have negative indexes, but elements could be negatively positioned.
* We use the functions below to translate back and forth between these coordinates, relying on the
* calculated values of hAdjustmentFactor and vAdjustmentFactor.
*/
translateRoutingX(x: number, reverse: boolean = false) {
return x + this.hAdjustmentFactor * (reverse ? -1 : 1);
}
translateRoutingY(y: number, reverse: boolean = false) {
return y + this.vAdjustmentFactor * (reverse ? -1 : 1);
}
/**
* Despite being a long method, we simply iterate over all three collections (nodes, ports and points)
* to find the highest X and Y dimensions, so we can build the matrix large enough to contain all elements.
*/
calculateMatrixDimensions = (): {
width: number;
hAdjustmentFactor: number;
height: number;
vAdjustmentFactor: number;
} => {
const allNodesCoords = _.values(this.engine.getModel().getNodes()).map(item => ({
x: item.getX(),
width: item.width,
y: item.getY(),
height: item.height
}));
const allLinks = _.values(this.engine.getModel().getLinks());
const allPortsCoords = _.flatMap(allLinks.map(link => [link.getSourcePort(), link.getTargetPort()]))
.filter(port => port !== null)
.map(item => ({
x: item.getX(),
width: item.width,
y: item.getY(),
height: item.height
}));
const allPointsCoords = _.flatMap(allLinks.map(link => link.getPoints())).map(item => ({
// points don't have width/height, so let's just use 0
x: item.getX(),
width: 0,
y: item.getY(),
height: 0
}));
const canvas = this.engine.getCanvas() as HTMLDivElement;
const minX =
Math.floor(
Math.min(_.minBy(_.concat(allNodesCoords, allPortsCoords, allPointsCoords), item => item.x).x, 0) /
this.ROUTING_SCALING_FACTOR
) * this.ROUTING_SCALING_FACTOR;
const maxXElement = _.maxBy(_.concat(allNodesCoords, allPortsCoords, allPointsCoords), item => item.x + item.width);
const maxX = Math.max(maxXElement.x + maxXElement.width, canvas.offsetWidth);
const minY =
Math.floor(
Math.min(_.minBy(_.concat(allNodesCoords, allPortsCoords, allPointsCoords), item => item.y).y, 0) /
this.ROUTING_SCALING_FACTOR
) * this.ROUTING_SCALING_FACTOR;
const maxYElement = _.maxBy(
_.concat(allNodesCoords, allPortsCoords, allPointsCoords),
item => item.y + item.height
);
const maxY = Math.max(maxYElement.y + maxYElement.height, canvas.offsetHeight);
return {
width: Math.ceil(Math.abs(minX) + maxX),
hAdjustmentFactor: Math.abs(minX) / this.ROUTING_SCALING_FACTOR + 1,
height: Math.ceil(Math.abs(minY) + maxY),
vAdjustmentFactor: Math.abs(minY) / this.ROUTING_SCALING_FACTOR + 1
};
};
/**
* Updates (by reference) where nodes will be drawn on the matrix passed in.
*/
markNodes = (matrix: number[][]): void => {
_.values(this.engine.getModel().getNodes()).forEach(node => {
const startX = Math.floor(node.getX() / this.ROUTING_SCALING_FACTOR);
const endX = Math.ceil((node.getX() + node.width) / this.ROUTING_SCALING_FACTOR);
const startY = Math.floor(node.getY() / this.ROUTING_SCALING_FACTOR);
const endY = Math.ceil((node.getY() + node.height) / this.ROUTING_SCALING_FACTOR);
for (let x = startX - 1; x <= endX + 1; x++) {
for (let y = startY - 1; y < endY + 1; y++) {
this.markMatrixPoint(matrix, this.translateRoutingX(x), this.translateRoutingY(y));
}
}
});
};
/**
* Updates (by reference) where ports will be drawn on the matrix passed in.
*/
markPorts = (matrix: number[][]): void => {
const allElements = _.flatMap(
_.values(this.engine.getModel().getLinks()).map(link => [].concat(link.getSourcePort(), link.getTargetPort()))
);
allElements
.filter(port => port !== null)
.forEach(port => {
const startX = Math.floor(port.x / this.ROUTING_SCALING_FACTOR);
const endX = Math.ceil((port.x + port.width) / this.ROUTING_SCALING_FACTOR);
const startY = Math.floor(port.y / this.ROUTING_SCALING_FACTOR);
const endY = Math.ceil((port.y + port.height) / this.ROUTING_SCALING_FACTOR);
for (let x = startX - 1; x <= endX + 1; x++) {
for (let y = startY - 1; y < endY + 1; y++) {
this.markMatrixPoint(matrix, this.translateRoutingX(x), this.translateRoutingY(y));
}
}
});
};
markMatrixPoint = (matrix: number[][], x: number, y: number) => {
if (matrix[y] !== undefined && matrix[y][x] !== undefined) {
matrix[y][x] = 1;
}
};
generateDynamicPath(pathCoords: number[][]) {
let path = Path();
path = path.moveto(pathCoords[0][0] * this.ROUTING_SCALING_FACTOR, pathCoords[0][1] * this.ROUTING_SCALING_FACTOR);
pathCoords.slice(1).forEach(coords => {
path = path.lineto(coords[0] * this.ROUTING_SCALING_FACTOR, coords[1] * this.ROUTING_SCALING_FACTOR);
});
return path.print();
}
}

View File

@ -0,0 +1,11 @@
import { PathFindingLinkFactory } from './PathFindingLinkFactory';
import { DefaultLinkModel, DefaultLinkModelOptions } from '@projectstorm/react-diagrams-defaults';
export class PathFindingLinkModel extends DefaultLinkModel {
constructor(options: DefaultLinkModelOptions = {}) {
super({
type: PathFindingLinkFactory.NAME,
...options
});
}
}

View File

@ -0,0 +1,101 @@
import * as React from 'react';
import * as _ from 'lodash';
import { DiagramEngine } from '@projectstorm/react-diagrams-core';
import PathFinding from '../engine/PathFinding';
import { PathFindingLinkFactory } from './PathFindingLinkFactory';
import { PathFindingLinkModel } from './PathFindingLinkModel';
import { DefaultLinkSegmentWidget } from '@projectstorm/react-diagrams-defaults';
export interface PathFindingLinkWidgetProps {
color?: string;
width?: number;
smooth?: boolean;
link: PathFindingLinkModel;
diagramEngine: DiagramEngine;
factory: PathFindingLinkFactory;
}
export interface PathFindingLinkWidgetState {
selected: boolean;
}
export class PathFindingLinkWidget extends React.Component<PathFindingLinkWidgetProps, PathFindingLinkWidgetState> {
refPaths: React.RefObject<SVGPathElement>[];
pathFinding: PathFinding;
constructor(props: PathFindingLinkWidgetProps) {
super(props);
this.refPaths = [];
this.state = {
selected: false
};
this.pathFinding = new PathFinding(this.props.factory);
}
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, id: string | number): JSX.Element {
const ref = React.createRef<SVGPathElement>();
this.refPaths.push(ref);
return (
<DefaultLinkSegmentWidget
key={`link-${id}`}
path={path}
selected={this.state.selected}
diagramEngine={this.props.diagramEngine}
factory={this.props.diagramEngine.getFactoryForLink(this.props.link)}
link={this.props.link}
forwardRef={ref}
onSelection={selected => {
this.setState({ selected: selected });
}}
extras={{}}
/>
);
}
render() {
this.refPaths = [];
//ensure id is present for all points on the path
var points = this.props.link.getPoints();
var paths = [];
// first step: calculate a direct path between the points being linked
const directPathCoords = this.pathFinding.calculateDirectPath(_.first(points), _.last(points));
const routingMatrix = this.props.factory.getRoutingMatrix();
// now we need to extract, from the routing matrix, the very first walkable points
// so they can be used as origin and destination of the link to be created
const smartLink = this.pathFinding.calculateLinkStartEndCoords(routingMatrix, directPathCoords);
if (smartLink) {
const { start, end, pathToStart, pathToEnd } = smartLink;
// second step: calculate a path avoiding hitting other elements
const simplifiedPath = this.pathFinding.calculateDynamicPath(routingMatrix, start, end, pathToStart, pathToEnd);
paths.push(
//smooth: boolean, extraProps: any, id: string | number, firstPoint: PointModel, lastPoint: PointModel
this.generateLink(this.props.factory.generateDynamicPath(simplifiedPath), '0')
);
}
return <>{paths}</>;
}
}

View File

@ -0,0 +1,41 @@
import PathFinding from '../src/engine/PathFinding';
describe('calculating start and end points', () => {
beforeEach(() => {
this.pathFinding = new PathFinding(null);
});
test('return correct object for valid walkable input', () => {
const matrix = [
[0, 0, 0, 0, 1, 1],
[0, 0, 0, 0, 1, 1],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[1, 1, 0, 0, 0, 0],
[1, 1, 0, 0, 0, 0]
];
const path = [[0, 5], [1, 4], [2, 3], [3, 2], [4, 1], [5, 0]];
const result = this.pathFinding.calculateLinkStartEndCoords(matrix, path);
expect(result.start).toEqual({
x: 2,
y: 3
});
expect(result.end).toEqual({
x: 3,
y: 2
});
expect(result.pathToStart).toEqual([[0, 5], [1, 4]]);
expect(result.pathToEnd).toEqual([[3, 2], [4, 1], [5, 0]]);
});
test('undefined is returned when no walkable path exists', () => {
const matrix = [[0, 0, 1, 1], [0, 0, 1, 1], [1, 1, 0, 0], [1, 1, 0, 0]];
const path = [[0, 3], [1, 2], [2, 1], [3, 0]];
const result = this.pathFinding.calculateLinkStartEndCoords(matrix, path);
expect(result).toBeUndefined();
});
});

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig",
"compilerOptions": {
"declaration": true,
"declarationDir": "dist/@types"
},
"include": [
"./src"
],
"exclude": [
"./dist"
]
}

View File

@ -0,0 +1,8 @@
const config = require('../../webpack.shared')(__dirname);
module.exports = {
...config,
output: {
...config.output,
library: 'projectstorm/react-diagrams-routing'
}
};