├── .codeclimate.yml ├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── dist ├── components │ ├── element.d.ts │ ├── element.types.d.ts │ ├── graph.d.ts │ ├── index.d.ts │ ├── marker-default.d.ts │ ├── node-icon-default.d.ts │ ├── node-icon.d.ts │ ├── polyline.d.ts │ └── with-foreign-object.d.ts ├── core │ ├── graph-matrix.class.d.ts │ ├── graph-struct.class.d.ts │ ├── graph.class.d.ts │ ├── index.d.ts │ ├── matrix.class.d.ts │ ├── node.interface.d.ts │ └── traverse-queue.class.d.ts ├── fixtures │ ├── index.d.ts │ ├── with-joins-and-splits-one-root.d.ts │ └── with-joins-and-splits.d.ts ├── index.d.ts ├── index.es.js ├── index.es.js.map ├── index.js ├── index.js.map ├── test.d.ts └── utils │ └── index.d.ts ├── example ├── README.md ├── config-overrides.js ├── package-lock.json ├── package.json ├── public │ ├── index.html │ └── manifest.json └── src │ ├── App.jsx │ ├── components │ ├── Example.jsx │ ├── Title.jsx │ ├── basic │ │ ├── Basic.jsx │ │ ├── ExampleBasic.jsx │ │ └── index.js │ ├── complex │ │ ├── Complex.jsx │ │ ├── ExampleComplex.jsx │ │ └── index.js │ ├── custom │ │ ├── Custom.jsx │ │ ├── ExampleCustom.jsx │ │ └── index.js │ ├── editor │ │ ├── Editor.jsx │ │ ├── ExampleEdit.jsx │ │ └── index.js │ ├── events │ │ ├── Events.jsx │ │ ├── ExampleEvents.jsx │ │ └── index.js │ ├── index.js │ └── withNames │ │ ├── ExampleWithNames.jsx │ │ ├── WithNames.jsx │ │ └── index.js │ ├── data │ ├── basic.json │ └── graph.json │ ├── index.css │ └── index.js ├── img └── graph.png ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── components │ ├── element │ │ ├── element.tsx │ │ ├── element.types.ts │ │ └── index.ts │ ├── graph.tsx │ ├── index.ts │ ├── marker-default.tsx │ ├── node-icon │ │ ├── index.ts │ │ ├── node-icon-default.css │ │ ├── node-icon-default.tsx │ │ └── node-icon.ts │ ├── polyline │ │ ├── getPointWithResolver.ts │ │ ├── index.ts │ │ ├── polyline.tsx │ │ └── polyline.types.ts │ └── with-foreign-object.tsx ├── core │ ├── graph-matrix.class.ts │ ├── graph-struct.class.ts │ ├── graph.class.ts │ ├── index.ts │ ├── matrix.class.ts │ ├── node.interface.ts │ └── traverse-queue.class.ts ├── fixtures │ ├── index.ts │ ├── with-joins-and-splits-one-root.ts │ └── with-joins-and-splits.ts ├── index.tsx ├── test.ts ├── typings.d.ts └── utils │ └── index.ts ├── tsconfig.json └── tsconfig.test.json /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | checks: 2 | similar-code: 3 | enabled: true 4 | config: 5 | threshold: 100 6 | identical-code: 7 | enabled: true 8 | config: 9 | threshold: 60 10 | file-lines: 11 | enabled: true 12 | config: 13 | threshold: 300 14 | method-lines: 15 | enabled: true 16 | config: 17 | threshold: 40 18 | plugins: 19 | duplication: 20 | enabled: true 21 | config: 22 | threshold: 60 23 | exclude_patterns: 24 | - "**/fixtures" 25 | - "**/test.ts" 26 | - "**/*.test.ts" 27 | - "config/" 28 | - "db/" 29 | - "dist/" 30 | - "features/" 31 | - "**/node_modules/" 32 | - "script/" 33 | - "**/spec/" 34 | - "**/test/" 35 | - "**/tests/" 36 | - "Tests/" 37 | - "**/vendor/" 38 | - "**/*_test.go" 39 | - "**/*.d.ts" 40 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | max_line_length = 110 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | .rpt2_cache 10 | 11 | # misc 12 | .DS_Store 13 | .env 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | coverage 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | env: 2 | global: 3 | - CC_TEST_REPORTER_ID=75b96babe2042261a9ed5a892c724ac92a8f183ac97ab775868e254460ca887b 4 | language: node_js 5 | node_js: 6 | - 8 7 | - 9 8 | cache: 9 | directories: 10 | - node_modules 11 | before_script: 12 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 13 | - chmod +x ./cc-test-reporter 14 | - ./cc-test-reporter before-build 15 | script: 16 | - npm run build 17 | - npm test 18 | after_script: 19 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT --coverage-input-type clover 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Anton Lempiy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/lempiy/react-direct-graph.svg?branch=master)](https://travis-ci.org/lempiy/react-direct-graph) [![NPM](https://img.shields.io/npm/v/react-direct-graph.svg)](https://www.npmjs.com/package/react-direct-graph) [![Maintainability](https://api.codeclimate.com/v1/badges/d333ef5cfaaa6a432aca/maintainability)](https://codeclimate.com/github/lempiy/react-direct-graph/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/d333ef5cfaaa6a432aca/test_coverage)](https://codeclimate.com/github/lempiy/react-direct-graph/test_coverage) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 2 | 3 | ![react-direct-graph](./img/graph.png) 4 | 5 | > React component for drawing direct graphs with rectangular (non-curve) edge 6 | 7 | ## Examples 8 | 9 | [Samples with code and preview](https://lempiy.github.io/react-direct-graph/) 10 | 11 | ## Install 12 | 13 | ```bash 14 | npm install --save react-direct-graph 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```jsx 20 | import React, { Component } from "react"; 21 | 22 | import DirectGraph from "react-direct-graph"; 23 | 24 | const graph = [ 25 | { 26 | id: "A", 27 | next: ["B"] 28 | }, 29 | { 30 | id: "B", 31 | next: ["C", "D", "E"] 32 | }, 33 | { 34 | id: "C", 35 | next: ["F"] 36 | }, 37 | { 38 | id: "D", 39 | next: ["J"] 40 | }, 41 | { 42 | id: "E", 43 | next: ["J"] 44 | }, 45 | { 46 | id: "J", 47 | next: ["I"] 48 | }, 49 | { 50 | id: "I", 51 | next: ["H"] 52 | }, 53 | { 54 | id: "F", 55 | next: ["K"] 56 | }, 57 | { 58 | id: "K", 59 | next: ["L"] 60 | }, 61 | { 62 | id: "H", 63 | next: ["L"] 64 | }, 65 | { 66 | id: "L", 67 | next: ["P"] 68 | }, 69 | { 70 | id: "P", 71 | next: ["M", "N"] 72 | }, 73 | { 74 | id: "M", 75 | next: [] 76 | }, 77 | { 78 | id: "N", 79 | next: [] 80 | } 81 | ]; 82 | 83 | export class ExampleBasic extends Component { 84 | render() { 85 | const { cellSize, padding } = this.props; 86 | return ( 87 | 88 | ); 89 | } 90 | } 91 | ``` 92 | 93 | ## License 94 | 95 | MIT © [lempiy](https://github.com/lempiy) 96 | -------------------------------------------------------------------------------- /dist/components/element.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { IMatrixNode } from "../core"; 3 | import { GraphNodeIconComponentProps } from "./node-icon"; 4 | import { GraphEventFunc, DataProps } from "./element.types"; 5 | export declare type ViewProps = { 6 | component?: React.ComponentType>; 7 | onNodeMouseEnter?: GraphEventFunc; 8 | onNodeMouseLeave?: GraphEventFunc; 9 | onNodeClick?: GraphEventFunc; 10 | cellSize: number; 11 | padding: number; 12 | }; 13 | export declare class GraphElement extends React.Component & ViewProps> { 14 | getCoords(cellSize: number, padding: number, node: IMatrixNode): number[]; 15 | wrapEventHandler: (cb: GraphEventFunc, node: IMatrixNode, incomes: IMatrixNode[]) => (e: React.MouseEvent) => void; 16 | diveToNodeIncome: (node: IMatrixNode, nodesMap: { 17 | [id: string]: IMatrixNode; 18 | }) => IMatrixNode; 19 | checkAnchorRenderIncomes(node: IMatrixNode): void; 20 | getNodeIncomes: (node: IMatrixNode, nodesMap: { 21 | [id: string]: IMatrixNode; 22 | }) => IMatrixNode[]; 23 | getAllIncomes: (node: IMatrixNode, nodesMap: { 24 | [id: string]: IMatrixNode; 25 | }) => IMatrixNode[]; 26 | getNodeHandlers(): { 27 | [eventName: string]: (e: React.MouseEvent) => void; 28 | }; 29 | renderNode(): false | JSX.Element; 30 | render(): JSX.Element; 31 | } 32 | -------------------------------------------------------------------------------- /dist/components/element.types.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { IMatrixNode } from "../core"; 3 | export declare type DataProps = { 4 | node: IMatrixNode; 5 | nodesMap: { 6 | [id: string]: IMatrixNode; 7 | }; 8 | }; 9 | export declare type GraphEventFunc = (event: React.MouseEvent, node: IMatrixNode, incomes: IMatrixNode[]) => void; 10 | -------------------------------------------------------------------------------- /dist/components/graph.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { IMatrixNode } from "../core"; 3 | import { ViewProps as ElementViewProps } from "./element"; 4 | import { ViewProps as PolylineViewProps } from "./polyline"; 5 | export declare type Props = { 6 | nodesMap: { 7 | [id: string]: IMatrixNode; 8 | }; 9 | cellSize: number; 10 | padding: number; 11 | widthInCells: number; 12 | heightInCells: number; 13 | }; 14 | export declare type ViewProps = ElementViewProps & PolylineViewProps; 15 | interface INodeElementInput { 16 | node: IMatrixNode; 17 | } 18 | export declare class Graph extends React.Component & Props> { 19 | getNodeElementInputs: (nodesMap: { 20 | [id: string]: IMatrixNode; 21 | }) => INodeElementInput[]; 22 | renderElements(): JSX.Element; 23 | render(): JSX.Element; 24 | } 25 | export {}; 26 | -------------------------------------------------------------------------------- /dist/components/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./graph"; 2 | export { GraphNodeIconComponentProps } from "./node-icon"; 3 | -------------------------------------------------------------------------------- /dist/components/marker-default.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | declare type Props = { 3 | id: string; 4 | width: number; 5 | height: number; 6 | }; 7 | export declare class DefaultMarkerBody extends React.PureComponent { 8 | render(): JSX.Element; 9 | } 10 | export declare class DefaultMarker extends React.PureComponent { 11 | render(): JSX.Element; 12 | } 13 | export {}; 14 | -------------------------------------------------------------------------------- /dist/components/node-icon-default.d.ts: -------------------------------------------------------------------------------- 1 | import { GraphNodeIconComponentProps } from "./node-icon"; 2 | import { INodeInput } from "../core"; 3 | import * as React from "react"; 4 | export declare class DefaultNodeIcon extends React.Component> { 5 | getClass(node: INodeInput, incomes: INodeInput[]): string; 6 | renderText(id: string): JSX.Element; 7 | render(): JSX.Element; 8 | } 9 | -------------------------------------------------------------------------------- /dist/components/node-icon.d.ts: -------------------------------------------------------------------------------- 1 | import { INodeInput } from "../core"; 2 | export declare type GraphNodeIconComponentProps = { 3 | node: INodeInput; 4 | incomes: INodeInput[]; 5 | }; 6 | -------------------------------------------------------------------------------- /dist/components/polyline.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { IMatrixNode } from "../core"; 3 | import { GraphEventFunc, DataProps } from "./element.types"; 4 | export declare type ViewProps = { 5 | edgeComponent?: React.ComponentType; 6 | onEdgeMouseEnter?: GraphEventFunc; 7 | onEdgeMouseLeave?: GraphEventFunc; 8 | onEdgeClick?: GraphEventFunc; 9 | cellSize: number; 10 | padding: number; 11 | }; 12 | interface LineBranch { 13 | node: IMatrixNode; 14 | income: IMatrixNode; 15 | line: number[][]; 16 | } 17 | export declare class GraphPolyline extends React.Component & ViewProps> { 18 | getPolyline(cellSize: number, padding: number, branch: IMatrixNode[]): number[][]; 19 | getLineToIncome(cellSize: number, padding: number, node: IMatrixNode, income: IMatrixNode): { 20 | node: IMatrixNode; 21 | income: IMatrixNode; 22 | line: number[]; 23 | }; 24 | getLines(cellSize: number, padding: number, node: IMatrixNode, nodesMap: { 25 | [id: string]: IMatrixNode; 26 | }): LineBranch[]; 27 | getCoords(cellSize: number, padding: number, node: IMatrixNode): number[]; 28 | getSize(cellSize: number, padding: number): number; 29 | wrapEventHandler: (cb: GraphEventFunc, node: IMatrixNode, incomes: IMatrixNode[]) => (e: React.MouseEvent) => void; 30 | diveToNodeIncome: (node: IMatrixNode, nodesMap: { 31 | [id: string]: IMatrixNode; 32 | }) => IMatrixNode; 33 | getNodeBranches: (node: IMatrixNode, nodesMap: { 34 | [id: string]: IMatrixNode; 35 | }) => IMatrixNode[][]; 36 | getIncomeBranch: (lastIncome: IMatrixNode, nodesMap: { 37 | [id: string]: IMatrixNode; 38 | }) => IMatrixNode[]; 39 | checkAnchorRenderIncomes(node: IMatrixNode): void; 40 | getAllIncomes: (node: IMatrixNode, nodesMap: { 41 | [id: string]: IMatrixNode; 42 | }) => IMatrixNode[]; 43 | getLineHandlers(node: IMatrixNode, income: IMatrixNode): { 44 | [eventName: string]: (e: React.MouseEvent) => void; 45 | }; 46 | getMarker(markerHash: string, incomeId: string): { 47 | [key: string]: string; 48 | }; 49 | getMarkerId(markerHash: string, incomeId: string): string; 50 | getLineNameCoords(income: IMatrixNode): number[]; 51 | getLineComponentCoords(income: IMatrixNode): number[]; 52 | lineComponent(income: IMatrixNode): JSX.Element | null; 53 | lineName(income: IMatrixNode): JSX.Element; 54 | getLinePoints(line: LineBranch): string; 55 | renderLines(node: IMatrixNode, lines: LineBranch[]): JSX.Element[]; 56 | render(): 0 | JSX.Element; 57 | } 58 | export {}; 59 | -------------------------------------------------------------------------------- /dist/components/with-foreign-object.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | interface WithForeignObjectProps { 3 | width: number; 4 | height: number; 5 | x: number; 6 | y: number; 7 | } 8 | export declare const withForeignObject:

(WrappedSVGComponent: React.ComponentType

) => React.FunctionComponent

; 9 | export {}; 10 | -------------------------------------------------------------------------------- /dist/core/graph-matrix.class.d.ts: -------------------------------------------------------------------------------- 1 | import { INodeInput, INodeOutput } from "./node.interface"; 2 | import { TraverseQueue } from "./traverse-queue.class"; 3 | import { Matrix } from "./matrix.class"; 4 | import { GraphStruct } from "./graph-struct.class"; 5 | /** 6 | * Holds iteration state of the graph 7 | */ 8 | interface State { 9 | mtx: Matrix; 10 | queue: TraverseQueue; 11 | x: number; 12 | y: number; 13 | } 14 | /** 15 | * @class GraphMatrix 16 | * Compute graph subclass used to interact with matrix 17 | */ 18 | export declare class GraphMatrix extends GraphStruct { 19 | constructor(list: INodeInput[]); 20 | /** 21 | * Check if item has unresolved incomes 22 | * @param item item to check 23 | */ 24 | protected _joinHasUnresolvedIncomes(item: INodeOutput): boolean; 25 | /** 26 | * Main insertion method - inserts item on matrix using state x and y 27 | * or skips if it has collision on current row. Skipping is done 28 | * by passing item back to the end of the queue 29 | * @param item item to insert 30 | * @param state state of current iteration 31 | * @param checkCollision whether to check horizontal collision with existing point 32 | * on 2D matrix 33 | * @returns true if item was inserted false if skipped 34 | */ 35 | private _insertOrSkipNodeOnMatrix; 36 | /** 37 | * Get all items incomes and find parent Y with the lowest 38 | * Y coordinate on the matrix 39 | * @param item target item 40 | * @param mtx matrix to use as source 41 | */ 42 | private _getLowestYAmongIncomes; 43 | /** 44 | * Main processing nodes method. 45 | * If node has incomes it finds lowest Y among them and 46 | * sets state.y as lowest income Y value. 47 | * Then inserts item on matrix using state x and y 48 | * or skips if it has collision on current column. Skipping is done 49 | * by passing item back to the end of the queue 50 | * @param item item to insert 51 | * @param state state of current iteration 52 | * on 2D matrix 53 | * @returns true if item was inserted false if skipped 54 | */ 55 | protected _processOrSkipNodeOnMatrix(item: INodeOutput, state: State): boolean; 56 | private hasLoops; 57 | private _handleLoopEdges; 58 | private _markIncomesAsPassed; 59 | protected _resolveCurrentJoinIncomes(mtx: Matrix, join: INodeOutput): void; 60 | private _insertLoopEdges; 61 | /** 62 | * Insert outcomes of split node 63 | * @param item item to handle 64 | * @param state current state of iteration 65 | * @param levelQueue 66 | */ 67 | protected _insertSplitOutcomes(item: INodeOutput, state: State, levelQueue: TraverseQueue): void; 68 | /** 69 | * Insert incomes of join node 70 | * @param item item to handle 71 | * @param state current state of iteration 72 | * @param levelQueue 73 | * @param addItemToQueue 74 | */ 75 | protected _insertJoinIncomes(item: INodeOutput, state: State, levelQueue: TraverseQueue, addItemToQueue: boolean): void; 76 | } 77 | export {}; 78 | -------------------------------------------------------------------------------- /dist/core/graph-struct.class.d.ts: -------------------------------------------------------------------------------- 1 | import { INodeInput, NodeType } from "./node.interface"; 2 | /** 3 | * @class GraphStruct 4 | * Frame parent-class to simplify graph 5 | * elements recognition 6 | */ 7 | export declare class GraphStruct { 8 | protected _list: INodeInput[]; 9 | private _nodesMap; 10 | private _incomesByNodeIdMap; 11 | private _outcomesByNodeIdMap; 12 | private _loopsByNodeIdMap; 13 | constructor(list: INodeInput[]); 14 | /** 15 | * Fill graph with new nodes 16 | * @param list input linked list of nodes 17 | */ 18 | applyList(list: INodeInput[]): void; 19 | detectIncomesAndOutcomes(): void; 20 | traverseVertically(node: INodeInput, branchSet: Set, totalSet: Set): Set; 21 | /** 22 | * Get graph roots. 23 | * Roots is nodes without incomes 24 | */ 25 | roots(): INodeInput[]; 26 | /** 27 | * Get type of node 28 | * @param id id of node 29 | * @returns type of the node 30 | */ 31 | protected nodeType(id: string): NodeType; 32 | /** 33 | * Whether or node is split 34 | * @param id id of node 35 | */ 36 | private isSplit; 37 | /** 38 | * Whether or node is join 39 | * @param id id of node 40 | */ 41 | private isJoin; 42 | /** 43 | * Whether or node is root 44 | * @param id id of node 45 | */ 46 | private isRoot; 47 | protected isLoopEdge(nodeId: string, outcomeId: string): boolean; 48 | /** 49 | * Get loops of node by id 50 | * @param id id of node 51 | */ 52 | protected loops(id: string): string[]; 53 | /** 54 | * Get outcomes of node by id 55 | * @param id id of node 56 | */ 57 | protected outcomes(id: string): string[]; 58 | /** 59 | * Get incomes of node by id 60 | * @param id id of node 61 | */ 62 | protected incomes(id: string): string[]; 63 | /** 64 | * Get node by id 65 | * @param id node id 66 | */ 67 | protected node(id: string): INodeInput; 68 | /** 69 | * get outcomes inputs helper 70 | * @param itemId node id 71 | */ 72 | protected getOutcomesArray(itemId: string): INodeInput[]; 73 | } 74 | -------------------------------------------------------------------------------- /dist/core/graph.class.d.ts: -------------------------------------------------------------------------------- 1 | import { INodeInput } from "./node.interface"; 2 | import { Matrix } from "./matrix.class"; 3 | import { GraphMatrix } from "./graph-matrix.class"; 4 | /** 5 | * @class Graph 6 | * Main iteration class used to transform 7 | * linked list of nodes to coordinate matrix 8 | */ 9 | export declare class Graph extends GraphMatrix { 10 | constructor(list: INodeInput[]); 11 | /** 12 | * Function to handle split nodes 13 | * @param item item to handle 14 | * @param state current state of iteration 15 | * @param levelQueue buffer subqueue of iteration 16 | */ 17 | private _handleSplitNode; 18 | /** 19 | * Function to handle splitjoin nodes 20 | * @param item item to handle 21 | * @param state current state of iteration 22 | * @param levelQueue buffer subqueue of iteration 23 | */ 24 | private _handleSplitJoinNode; 25 | /** 26 | * Function to handle join nodes 27 | * @param item item to handle 28 | * @param state current state of iteration 29 | * @param levelQueue buffer subqueue of iteration 30 | */ 31 | private _handleJoinNode; 32 | /** 33 | * Function to handle simple nodes 34 | * @param item item to handle 35 | * @param state current state of iteration 36 | * @param levelQueue buffer subqueue of iteration 37 | */ 38 | private _handleSimpleNode; 39 | /** 40 | * Method to handle single iteration item 41 | * @param item queue item to process 42 | * @param state state of iteration 43 | * @param levelQueue 44 | */ 45 | private _traverseItem; 46 | /** 47 | * Iterate over one level of graph 48 | * starting from queue top item 49 | */ 50 | private _traverseLevel; 51 | /** 52 | * Iterate over graph 53 | * starting from queue root items 54 | */ 55 | private _traverseList; 56 | /** 57 | * traverse main method to get coordinates matrix from graph 58 | * @returns 2D matrix containing all nodes and anchors 59 | */ 60 | traverse(): Matrix; 61 | } 62 | -------------------------------------------------------------------------------- /dist/core/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./graph.class"; 2 | export * from "./matrix.class"; 3 | export * from "./traverse-queue.class"; 4 | export * from "./node.interface"; 5 | -------------------------------------------------------------------------------- /dist/core/matrix.class.d.ts: -------------------------------------------------------------------------------- 1 | import { INodeOutput, IMatrixNode } from "./node.interface"; 2 | /** 3 | * @class Matrix 4 | * Low level class used to compute 2D polar coordinates for each node 5 | * and anchor. Use this class if you want to skip D3 rendering in favor of 6 | * something else, for example, HTML or Canvas drawing. 7 | */ 8 | export declare class Matrix { 9 | private _; 10 | /** 11 | * Get with of matrix 12 | */ 13 | readonly width: number; 14 | /** 15 | * Get height of matrix 16 | */ 17 | readonly height: number; 18 | /** 19 | * Checks whether or not candidate point collides 20 | * with present points by X vertex. 21 | * @param point coordinates of point to check 22 | */ 23 | hasHorizontalCollision([_, y]: number[]): boolean; 24 | /** 25 | * Checks whether or not candidate point collides 26 | * with present points by Y vertex. 27 | * @param point coordinates of point to check 28 | */ 29 | hasVerticalCollision([x, y]: number[]): boolean; 30 | /** 31 | * Check if all next items of node already placed in matrix 32 | */ 33 | private isAllChildrenOnMatrix; 34 | /** 35 | * Inspects matrix by Y vertex from top to bottom to 36 | * search first unused Y coordinate (row). 37 | * If there no free row on the matrix it returns 38 | * matrix height (Which is equal to first unused row, 39 | * that currently not exist). 40 | * @param x column coordinate to use for search 41 | */ 42 | getFreeRowForColumn(x: number): number; 43 | /** 44 | * Extend matrix with empty rows 45 | * @param toValue rows to add to matrix 46 | */ 47 | private _extendHeight; 48 | /** 49 | * Extend matrix with empty columns 50 | * @param toValue columns to add to matrix 51 | */ 52 | private _extendWidth; 53 | /** 54 | * Insert row before y 55 | * @param y coordinate 56 | */ 57 | insertRowBefore(y: number): void; 58 | /** 59 | * Insert column before x 60 | * @param x coordinate 61 | */ 62 | insertColumnBefore(x: number): void; 63 | /** 64 | * Find x, y coordinate of first point item that 65 | * satisfies condition defined in callback 66 | * @param callback similar to [].find. Returns boolean 67 | */ 68 | find(callback: (item: INodeOutput) => boolean): number[] | null; 69 | /** 70 | * Find first node item that 71 | * satisfies condition defined in callback 72 | * @param callback similar to [].find. Returns boolean 73 | */ 74 | findNode(callback: (item: INodeOutput) => boolean): [number[], INodeOutput] | null; 75 | /** 76 | * Return point by x, y coordinate 77 | */ 78 | getByCoords(x: number, y: number): INodeOutput | null; 79 | /** 80 | * Paste item to particular cell 81 | * @param coords x and y coordinates for item 82 | * @param item item to insert 83 | */ 84 | insert([x, y]: number[], item: INodeOutput): void; 85 | /** 86 | * @returns key value object where key is node id and 87 | * value is node with its coordinates 88 | */ 89 | normalize(): { 90 | [id: string]: IMatrixNode; 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /dist/core/node.interface.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Types of nodes for internal usage 3 | */ 4 | export declare enum NodeType { 5 | RootSimple = "ROOT-SIMPLE", 6 | RootSplit = "ROOT-SPLIT", 7 | Simple = "SIMPLE", 8 | Split = "SPLIT", 9 | Join = "JOIN", 10 | SplitJoin = "SPLIT-JOIN" 11 | } 12 | /** 13 | * Types of anchors. 14 | * Join is anchor (vertex) between node and its join outcome: 15 | * N-J 16 | * N_| 17 | * N_| 18 | * Split is anchor (vertex) between node and its split income: 19 | * S-N 20 | * |_N 21 | * |_N 22 | */ 23 | export declare enum AnchorType { 24 | Join = "JOIN", 25 | Split = "SPLIT", 26 | Loop = "LOOP" 27 | } 28 | export declare enum AnchorMargin { 29 | None = "NONE", 30 | Left = "LEFT", 31 | Right = "RIGHT" 32 | } 33 | export interface INodeInput { 34 | /** 35 | * Unique key for node. Duplicates are not allowed. 36 | */ 37 | id: string; 38 | /** 39 | * Outcomes of current node. Empty array if node is leaf. 40 | */ 41 | next: string[]; 42 | /** 43 | * Name of current node. Empty if the node without a name. 44 | */ 45 | name?: string; 46 | /** 47 | * Name of current node. Empty if the node orientation is bottom. 48 | */ 49 | nameOrientation?: "bottom" | "top"; 50 | /** 51 | * Name of node edges. Matched to the edge index with the next. Empty array if the edges without a name. 52 | */ 53 | edgeNames?: string[]; 54 | /** 55 | * Payload data to transfer with current node events. Use whatever you want here. 56 | */ 57 | payload: T; 58 | } 59 | export interface INodeOutput extends INodeInput { 60 | /** 61 | * Defines whether or not node is pseudo-node - anchor. 62 | * Which is used to draw split and join edges. 63 | * @default false 64 | */ 65 | isAnchor?: boolean; 66 | /** 67 | * Type of anchor. Only exists if isAnchor is true. 68 | */ 69 | anchorType?: AnchorType; 70 | /** 71 | * Id if the anchor income. Only exists if isAnchor is true. 72 | */ 73 | anchorFrom?: string; 74 | /** 75 | * Id if the anchor outcome. Only exists if isAnchor is true. 76 | */ 77 | anchorTo?: string; 78 | /** 79 | * Anchor position inside cell over x axis. 80 | */ 81 | anchorMargin?: AnchorMargin; 82 | /** 83 | * First level node incomes passed during traversal. Ignores join 84 | * anchor. Mostly for tech usage. To recognize rendering parents 85 | * Use renderIncomes. 86 | */ 87 | passedIncomes: string[]; 88 | /** 89 | * First level node incomes in rendering context. Can be used for 90 | * backward travesal. Includes both types of anchors. 91 | */ 92 | renderIncomes: string[]; 93 | /** 94 | * Number of outcomes that already been placed on matrix 95 | */ 96 | childrenOnMatrix: number; 97 | } 98 | export interface IMatrixNode extends INodeOutput { 99 | /** 100 | * X coordinate of node 101 | */ 102 | x: number; 103 | /** 104 | * Y coordinate of node 105 | */ 106 | y: number; 107 | } 108 | -------------------------------------------------------------------------------- /dist/core/traverse-queue.class.d.ts: -------------------------------------------------------------------------------- 1 | import { INodeOutput } from "./node.interface"; 2 | export interface IQueueItem { 3 | id: string; 4 | payload: T; 5 | next: string[]; 6 | name?: string; 7 | nameOrientation?: "bottom" | "top"; 8 | edgeNames?: string[]; 9 | } 10 | /** 11 | * @class TraverseQueue 12 | * Special queue that is used for horizontal 13 | * graph traversing 14 | */ 15 | export declare class TraverseQueue { 16 | private _; 17 | /** 18 | * Add items to queue. If items already exist in this queue 19 | * or bufferQueue do nothing but push new passed income to 20 | * existing queue item 21 | * @param incomeId income id for each element 22 | * @param bufferQueue buffer queue to also check for duplicates 23 | * @param items queue items to add 24 | */ 25 | add(incomeId: string | null, bufferQueue: TraverseQueue | null, ...items: IQueueItem[]): void; 26 | find(cb: (item: INodeOutput) => boolean): INodeOutput | void; 27 | /** 28 | * Push item to queue. Skipping `add` method additional phases. 29 | * @param item node item to add 30 | */ 31 | push(item: INodeOutput): void; 32 | /** 33 | * get current queue length 34 | */ 35 | readonly length: number; 36 | /** 37 | * @param cb callback with condition to check 38 | * @returns true if at list one item satified condition in callback 39 | */ 40 | some(cb: (item: INodeOutput) => boolean): boolean; 41 | /** 42 | * Shift first element 43 | * @returns first element from the queue 44 | */ 45 | shift(): INodeOutput | void; 46 | /** 47 | * Create new queue and extract of current 48 | * elements of this queue new clone 49 | * @returns newQueue new queue with items from old queue 50 | */ 51 | drain(): TraverseQueue; 52 | } 53 | -------------------------------------------------------------------------------- /dist/fixtures/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./with-joins-and-splits"; 2 | export * from "./with-joins-and-splits-one-root"; 3 | -------------------------------------------------------------------------------- /dist/fixtures/with-joins-and-splits-one-root.d.ts: -------------------------------------------------------------------------------- 1 | export declare const withJoinsAndSplitsFixtureOneRoot: { 2 | id: string; 3 | next: string[]; 4 | payload: { 5 | exist: boolean; 6 | }; 7 | }[]; 8 | -------------------------------------------------------------------------------- /dist/fixtures/with-joins-and-splits.d.ts: -------------------------------------------------------------------------------- 1 | export declare const withJoinsAndSplitsFixture: { 2 | id: string; 3 | next: string[]; 4 | payload: { 5 | exist: boolean; 6 | }; 7 | }[]; 8 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @class DirectGraph 3 | */ 4 | import * as React from "react"; 5 | import { INodeInput, IMatrixNode } from "./core"; 6 | import { ViewProps } from "./components"; 7 | declare type Props = { 8 | list: INodeInput[]; 9 | cellSize: number; 10 | padding: number; 11 | }; 12 | export { INodeInput, IMatrixNode } from "./core"; 13 | export declare type GraphProps = Props & ViewProps; 14 | declare type GraphViewData = { 15 | nodesMap: { 16 | [id: string]: IMatrixNode; 17 | }; 18 | widthInCells: number; 19 | heightInCells: number; 20 | }; 21 | export default class DirectGraph extends React.Component> { 22 | getNodesMap: (list: INodeInput[]) => GraphViewData; 23 | render(): JSX.Element; 24 | } 25 | -------------------------------------------------------------------------------- /dist/test.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /dist/utils/index.d.ts: -------------------------------------------------------------------------------- 1 | import { AnchorMargin, IMatrixNode, INodeOutput } from "../core"; 2 | import { GraphEventFunc } from "../components/element"; 3 | import * as React from "react"; 4 | export declare enum VectorDirection { 5 | Top = "top", 6 | Bottom = "bottom", 7 | Right = "right", 8 | Left = "left" 9 | } 10 | export declare const getEdgeMargins: (node: INodeOutput, income: INodeOutput) => AnchorMargin[]; 11 | export declare const getVectorDirection: (x1: number, y1: number, x2: number, y2: number) => VectorDirection; 12 | export declare const getCellCenter: (cellSize: number, padding: number, cellX: number, cellY: number, margin: AnchorMargin) => number[]; 13 | export declare const getCellEntry: (direction: VectorDirection, cellSize: number, padding: number, cellX: number, cellY: number, margin: AnchorMargin) => number[]; 14 | export declare function uniqueId(prefix: string): string; 15 | export declare function getSize(cellSize: number, padding: number): number; 16 | export declare function getCoords(cellSize: number, padding: number, node: IMatrixNode): number[]; 17 | export declare function checkAnchorRenderIncomes(node: IMatrixNode): void; 18 | export declare const getAllIncomes: (node: IMatrixNode, nodesMap: { 19 | [id: string]: IMatrixNode; 20 | }) => IMatrixNode[]; 21 | export declare const wrapEventHandler: (cb: GraphEventFunc, node: IMatrixNode, incomes: IMatrixNode[]) => (e: React.MouseEvent) => void; 22 | -------------------------------------------------------------------------------- /example/config-overrides.js: -------------------------------------------------------------------------------- 1 | const rewireRawLoader = require("@baristalabs/react-app-rewire-raw-loader"); 2 | 3 | module.exports = function override(config, env) { 4 | config = rewireRawLoader(config, env); 5 | return config; 6 | }; 7 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-direct-graph-example", 3 | "homepage": "https://lempiy.github.io/react-direct-graph", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "ajv": "^6.10.0", 9 | "prop-types": "^15.6.2", 10 | "react": "^16.4.1", 11 | "react-direct-graph": "file:..", 12 | "react-dom": "^16.4.1", 13 | "react-scripts": "^1.1.4", 14 | "react-syntax-highlighter": "^10.3.0" 15 | }, 16 | "scripts": { 17 | "start": "react-app-rewired start", 18 | "build": "react-app-rewired build", 19 | "test": "react-app-rewired test --env=jsdom", 20 | "eject": "react-app-rewired eject" 21 | }, 22 | "devDependencies": { 23 | "@baristalabs/react-app-rewire-raw-loader": "^0.1.3", 24 | "raw-loader": "^3.0.0", 25 | "react-app-rewired": "^2.1.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | react-direct-graph 11 | 12 | 13 | 14 | 17 | 18 |

19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-direct-graph", 3 | "name": "react-direct-graph", 4 | "start_url": "./index.html", 5 | "display": "standalone", 6 | "theme_color": "#000000", 7 | "background_color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /example/src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { Editor, Basic, WithNames, Complex, Custom, Events, Title } from "./components"; 4 | const cellSize = 130; 5 | const padding = cellSize * 0.125; 6 | export default class App extends Component { 7 | render() { 8 | return ( 9 |
10 | 11 | <h1>Examples</h1> 12 | <Basic /> 13 | <WithNames /> 14 | <Custom /> 15 | <Complex /> 16 | <Events /> 17 | <Editor /> 18 | </main> 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /example/src/components/Example.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 3 | import { darcula } from "react-syntax-highlighter/dist/esm/styles/prism"; 4 | 5 | export class Example extends Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { 9 | showCode: false 10 | }; 11 | } 12 | toggleCode = () => { 13 | this.setState({ 14 | showCode: !this.state.showCode 15 | }); 16 | }; 17 | render() { 18 | const { children, title, code, description } = this.props; 19 | return ( 20 | <section className="example"> 21 | <h2>{title}</h2> 22 | <div 23 | className="description" 24 | dangerouslySetInnerHTML={{ __html: description }} 25 | /> 26 | <button 27 | className={this.state.showCode ? "hide-code" : "show-code"} 28 | onClick={this.toggleCode} 29 | > 30 | {this.state.showCode ? "Hide code" : "Show code"} 31 | </button> 32 | <SyntaxHighlighter 33 | className={this.state.showCode ? "" : "hidden"} 34 | language="jsx" 35 | style={darcula} 36 | > 37 | {code} 38 | </SyntaxHighlighter> 39 | <p> 40 | <b>Output:</b> 41 | </p> 42 | <div className="graph-holder">{children}</div> 43 | </section> 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /example/src/components/Title.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from "react"; 2 | 3 | import DirectGraph from "react-direct-graph"; 4 | 5 | const react = [ 6 | { 7 | id: "R", 8 | next: ["E"] 9 | }, 10 | { 11 | id: "E", 12 | next: ["A"] 13 | }, 14 | { 15 | id: "A", 16 | next: ["C"] 17 | }, 18 | { 19 | id: "C", 20 | next: ["T"] 21 | }, 22 | { 23 | id: "T", 24 | next: [] 25 | } 26 | ]; 27 | 28 | const direct = [ 29 | { 30 | id: "D", 31 | next: ["I"] 32 | }, 33 | { 34 | id: "I", 35 | next: ["R"] 36 | }, 37 | { 38 | id: "R", 39 | next: ["E"] 40 | }, 41 | { 42 | id: "E", 43 | next: ["C"] 44 | }, 45 | { 46 | id: "C", 47 | next: ["T"] 48 | }, 49 | { 50 | id: "T", 51 | next: [] 52 | } 53 | ]; 54 | 55 | const graph = [ 56 | { 57 | id: "G", 58 | next: ["R"] 59 | }, 60 | { 61 | id: "R", 62 | next: ["A"] 63 | }, 64 | { 65 | id: "A", 66 | next: ["P"] 67 | }, 68 | { 69 | id: "P", 70 | next: ["H"] 71 | }, 72 | { 73 | id: "H", 74 | next: [] 75 | } 76 | ]; 77 | 78 | export class Title extends Component { 79 | render() { 80 | const { cellSize, padding } = this.props; 81 | return ( 82 | <Fragment> 83 | <DirectGraph 84 | list={react} 85 | cellSize={cellSize} 86 | padding={padding} 87 | /> 88 | <DirectGraph 89 | list={direct} 90 | cellSize={cellSize} 91 | padding={padding} 92 | /> 93 | <DirectGraph 94 | list={graph} 95 | cellSize={cellSize} 96 | padding={padding} 97 | /> 98 | </Fragment> 99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /example/src/components/basic/Basic.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { ExampleBasic } from "./"; 4 | import { Example } from "../Example.jsx"; 5 | 6 | /* eslint import/no-webpack-loader-syntax: off */ 7 | const { default: exampleBasicCode } = require("!!raw-loader!./ExampleBasic.jsx"); 8 | 9 | const cellSize = 100; 10 | const padding = cellSize * 0.25; 11 | 12 | export class Basic extends Component { 13 | description = `<i>Basic graph rendering requires array of nodes, dimensions of cell and padding of node icon as props.<br> 14 | Node object should has following properties:</i><br> 15 | <br> 16 | <b>id</b> - unique identifier of the node<br> 17 | <b>next</b> array of id's - node outcomes<br> 18 | <b>payload</b> some additional data to hold in`; 19 | 20 | render() { 21 | return ( 22 | <Example 23 | code={exampleBasicCode} 24 | title={"Basic Example"} 25 | description={this.description} 26 | > 27 | <ExampleBasic cellSize={cellSize} padding={padding} /> 28 | </Example> 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /example/src/components/basic/ExampleBasic.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import DirectGraph from "react-direct-graph"; 4 | 5 | const graph = [ 6 | { 7 | id: "A", 8 | next: ["B"] 9 | }, 10 | { 11 | id: "B", 12 | next: ["C", "D", "E"] 13 | }, 14 | { 15 | id: "C", 16 | next: ["F"] 17 | }, 18 | { 19 | id: "D", 20 | next: ["J"] 21 | }, 22 | { 23 | id: "E", 24 | next: ["J"] 25 | }, 26 | { 27 | id: "J", 28 | next: ["I"] 29 | }, 30 | { 31 | id: "I", 32 | next: ["H"] 33 | }, 34 | { 35 | id: "F", 36 | next: ["K"] 37 | }, 38 | { 39 | id: "K", 40 | next: ["L"] 41 | }, 42 | { 43 | id: "H", 44 | next: ["L"] 45 | }, 46 | { 47 | id: "L", 48 | next: ["P"] 49 | }, 50 | { 51 | id: "P", 52 | next: ["M", "N"] 53 | }, 54 | { 55 | id: "M", 56 | next: [] 57 | }, 58 | { 59 | id: "N", 60 | next: [] 61 | } 62 | ]; 63 | 64 | export class ExampleBasic extends Component { 65 | render() { 66 | const { cellSize, padding } = this.props; 67 | return <DirectGraph list={graph} cellSize={cellSize} padding={padding} />; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /example/src/components/basic/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | ExampleBasic 3 | } 4 | from "./ExampleBasic" 5 | export { 6 | Basic 7 | } 8 | from "./Basic" 9 | -------------------------------------------------------------------------------- /example/src/components/complex/Complex.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { ExampleComplex } from "./"; 4 | import { Example } from "../Example.jsx"; 5 | 6 | /* eslint import/no-webpack-loader-syntax: off */ 7 | const { default: exampleComplexCode } = require("!!raw-loader!./ExampleComplex.jsx"); 8 | 9 | const cellSize = 100; 10 | const padding = cellSize * 0.25; 11 | 12 | export class Complex extends Component { 13 | description = `<i>Library renders graphs with any complexity.<br> 14 | Supported features:</i><br> 15 | <br> 16 | <b>loops</b> - backward references between nodes<br> 17 | <b>split-joins</b> - nodes with more then one income and outcome<br> 18 | <b>multigraphs</b> - multiple graphs in one view and graphs with more then one root`; 19 | 20 | render() { 21 | return ( 22 | <Example 23 | code={exampleComplexCode} 24 | title={"Complex graphs"} 25 | description={this.description} 26 | > 27 | <ExampleComplex cellSize={cellSize} padding={padding} /> 28 | </Example> 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /example/src/components/complex/ExampleComplex.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import DirectGraph from "react-direct-graph"; 4 | 5 | const graph = [ 6 | { 7 | id: "1", 8 | next: ["2"] 9 | }, 10 | { 11 | id: "2", 12 | next: ["3", "4"] 13 | }, 14 | { 15 | id: "3", 16 | next: ["5"] 17 | }, 18 | { 19 | id: "6", 20 | next: [] 21 | }, 22 | { 23 | id: "7", 24 | next: ["4"] 25 | }, 26 | { 27 | id: "4", 28 | next: ["8"] 29 | }, 30 | { 31 | id: "8", 32 | next: ["9"] 33 | }, 34 | { 35 | id: "10", 36 | next: ["11"] 37 | }, 38 | { 39 | id: "11", 40 | next: ["12", "13"] 41 | }, 42 | { 43 | id: "12", 44 | next: ["14"] 45 | }, 46 | { 47 | id: "14", 48 | next: ["15", "16"] 49 | }, 50 | { 51 | id: "15", 52 | next: ["17", "12"] 53 | }, 54 | { 55 | id: "16", 56 | next: ["17"] 57 | }, 58 | { 59 | id: "17", 60 | next: [] 61 | }, 62 | { 63 | id: "13", 64 | next: [] 65 | }, 66 | { 67 | id: "9", 68 | next: ["18"] 69 | }, 70 | { 71 | id: "5", 72 | next: ["19"] 73 | }, 74 | { 75 | id: "19", 76 | next: ["20"] 77 | }, 78 | { 79 | id: "18", 80 | next: ["20"] 81 | }, 82 | { 83 | id: "20", 84 | next: ["21", "6"] 85 | }, 86 | { 87 | id: "21", 88 | next: ["21"] 89 | } 90 | ]; 91 | 92 | export class ExampleComplex extends Component { 93 | render() { 94 | const { cellSize, padding } = this.props; 95 | return <DirectGraph list={graph} cellSize={cellSize} padding={padding} />; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /example/src/components/complex/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | ExampleComplex 3 | } 4 | from "./ExampleComplex" 5 | export { 6 | Complex 7 | } 8 | from "./Complex" 9 | -------------------------------------------------------------------------------- /example/src/components/custom/Custom.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { ExampleCustom } from "./"; 4 | import { Example } from "../Example.jsx"; 5 | 6 | /* eslint import/no-webpack-loader-syntax: off */ 7 | const { default: exampleCustomCode } = require("!!raw-loader!./ExampleCustom.jsx"); 8 | 9 | const cellSize = 100; 10 | const padding = cellSize * 0.25; 11 | 12 | export class Custom extends Component { 13 | description = `<i>You can specify custom component for icon of graph elements using "component" prop.<br> 14 | Custom icon component receives following props:</i><br> 15 | <br> 16 | <b>node</b> - graph node<br> 17 | <b>incomes</b> - incomes of graph node or empty array if node is root`; 18 | 19 | render() { 20 | return ( 21 | <Example 22 | code={exampleCustomCode} 23 | title={"Graph Custom"} 24 | description={this.description} 25 | > 26 | <ExampleCustom cellSize={cellSize} padding={padding} /> 27 | </Example> 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example/src/components/custom/ExampleCustom.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import DirectGraph from "react-direct-graph"; 4 | 5 | const graph = [ 6 | { 7 | id: "A", 8 | next: ["B"] 9 | }, 10 | { 11 | id: "B", 12 | next: ["C", "J"] 13 | }, 14 | { 15 | id: "C", 16 | next: ["F"] 17 | }, 18 | { 19 | id: "D", 20 | next: ["J"] 21 | }, 22 | { 23 | id: "E", 24 | next: ["J"] 25 | }, 26 | { 27 | id: "J", 28 | next: ["I"] 29 | }, 30 | { 31 | id: "I", 32 | next: ["H", "K"] 33 | }, 34 | { 35 | id: "F", 36 | next: ["K"] 37 | }, 38 | { 39 | id: "K", 40 | next: ["L"] 41 | }, 42 | { 43 | id: "H", 44 | next: ["N"] 45 | }, 46 | { 47 | id: "L", 48 | next: ["P"] 49 | }, 50 | { 51 | id: "P", 52 | next: ["M"] 53 | }, 54 | { 55 | id: "M", 56 | next: [] 57 | }, 58 | { 59 | id: "N", 60 | next: ["P"] 61 | } 62 | ]; 63 | 64 | class CustomNodeIcon extends Component { 65 | getColor(node, incomes) { 66 | if (incomes && incomes.length > 1) return "#501570"; 67 | if (node.next && node.next.length > 1) return "#501570"; 68 | return "#169676"; 69 | } 70 | renderText(id) { 71 | return ( 72 | <g fill="#fff" stroke="#fff"> 73 | <text 74 | strokeWidth="1" 75 | x="0" 76 | y="0" 77 | dx="256" 78 | dy="310" 79 | textAnchor="middle" 80 | fontSize="160" 81 | > 82 | {id} 83 | </text> 84 | </g> 85 | ); 86 | } 87 | render() { 88 | const { node, incomes } = this.props; 89 | return ( 90 | <svg version="1.1" x="0px" y="0px" viewBox="0 0 512 512"> 91 | <path 92 | style={{ 93 | fill: this.getColor(node, incomes), 94 | stroke: this.getColor(node, incomes) 95 | }} 96 | d="M504.1,256C504.1,119,393,7.9,256,7.9C119,7.9,7.9,119,7.9, 97 | 256C7.9,393,119,504.1,256,504.1 C393,504.1,504.1,393,504.1,256z" 98 | /> 99 | <path 100 | fill="#FFFFFF" 101 | d=" 102 | M416.2,275.3v-38.6l-36.6-11.5c-3.1-12.4-8-24.1-14.5-34.8l17.8-34.1L355.6, 103 | 129l-34.2,17.8c-10.6-6.4-22.2-11.2-34.6-14.3l-11.6-36.8h-38.7l-11.6, 104 | 36.8c-12.3,3.1-24,7.9-34.6,14.3L156.4,129L129,156.4l17.8,34.1 105 | c-6.4,10.7-11.4,22.3-14.5,34.8l-36.6,11.5v38.6l36.4, 106 | 11.5c3.1,12.5,8,24.3,14.5,35.1L129,355.6l27.3,27.3l33.7-17.6 107 | c10.8,6.5,22.7,11.5,35.3,14.6l11.4,36.2h38.7l11.4-36.2c12.6-3.1, 108 | 24.4-8.1,35.3-14.6l33.7,17.6l27.3-27.3l-17.6-33.8 109 | c6.5-10.8,11.4-22.6,14.5-35.1L416.2,275.3z M256,340.8c-46.7, 110 | 0-84.6-37.9-84.6-84.6c0-46.7,37.9-84.6,84.6-84.6 111 | c46.7,0,84.5,37.9,84.5,84.6C340.5,303,302.7,340.8,256,340.8z" 112 | /> 113 | <g>{this.renderText(node.id)}</g> 114 | </svg> 115 | ); 116 | } 117 | } 118 | 119 | export class ExampleCustom extends Component { 120 | render() { 121 | const { cellSize, padding } = this.props; 122 | return ( 123 | <DirectGraph 124 | list={graph} 125 | cellSize={cellSize} 126 | padding={padding} 127 | component={CustomNodeIcon} 128 | /> 129 | ); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /example/src/components/custom/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | ExampleCustom 3 | } 4 | from "./ExampleCustom" 5 | export { 6 | Custom 7 | } 8 | from "./Custom" 9 | -------------------------------------------------------------------------------- /example/src/components/editor/Editor.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { ExampleEdit } from "./"; 4 | import { Example } from "../Example.jsx"; 5 | 6 | /* eslint import/no-webpack-loader-syntax: off */ 7 | const { default: exampleEditCode } = require("!!raw-loader!./ExampleEdit.jsx"); 8 | 9 | const cellSize = 100; 10 | const padding = cellSize * 0.25; 11 | 12 | export class Editor extends Component { 13 | description = `<i>Taking advantage of graph rendering loop and event listeners it's possible to create graph editor</i>.<br> 14 | <br> 15 | <b>Click</b> on edge to insert node<br> 16 | <b>Click</b> on node to delete node<br> 17 | <b>Shift+Click</b> on two node to create new edge`; 18 | 19 | render() { 20 | return ( 21 | <Example 22 | code={exampleEditCode} 23 | title={"Graph Editor"} 24 | description={this.description} 25 | > 26 | <ExampleEdit cellSize={cellSize} padding={padding} /> 27 | </Example> 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example/src/components/editor/ExampleEdit.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import DirectGraph from "react-direct-graph"; 4 | import graph from "../../data/graph.json"; 5 | 6 | let counter = 1; 7 | 8 | export class ExampleEdit extends Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | graph: graph.map(n => ({ ...n, next: [...n.next] })), 13 | inSelect: false, 14 | selected: [] 15 | }; 16 | } 17 | 18 | componentDidMount() { 19 | window.addEventListener("keydown", this.onKeyDown); 20 | window.addEventListener("keyup", this.onKeyUp); 21 | } 22 | 23 | componentWillUnmount() { 24 | window.removeEventListener("keydown", this.onKeyDown); 25 | window.removeEventListener("keyup", this.onKeyUp); 26 | } 27 | 28 | onNodeClick = (event, node, incomes) => { 29 | if (this.state.inSelect) { 30 | this.connectNode(event, node, incomes); 31 | } else { 32 | this.softDeleteNode(event, node, incomes); 33 | } 34 | }; 35 | 36 | onEdgeClick = (event, node, incomes) => { 37 | this.insertNode(node, incomes[0]); 38 | }; 39 | 40 | onEdgeMouseEnter = (event, node, incomes) => { 41 | event.currentTarget.style.stroke = "#f00"; 42 | event.currentTarget.style.fill = "#f00"; 43 | }; 44 | 45 | onEdgeMouseLeave = (event, node, incomes) => { 46 | event.currentTarget.style.stroke = "rgb(45, 87, 139)"; 47 | event.currentTarget.style.fill = "rgb(45, 87, 139)"; 48 | }; 49 | 50 | onKeyDown = event => { 51 | if (event.which === 16) { 52 | this.setState({ 53 | ...this.state, 54 | ...{ 55 | inSelect: true 56 | } 57 | }); 58 | } 59 | }; 60 | 61 | onKeyUp = event => { 62 | if (event.which === 16) { 63 | this.setState({ 64 | ...this.state, 65 | ...{ 66 | inSelect: false, 67 | selected: [] 68 | } 69 | }); 70 | } 71 | }; 72 | 73 | addToNodeToSelectBuffer = (node, incomes) => { 74 | this.setState({ 75 | ...this.state, 76 | ...{ 77 | selected: [node] 78 | } 79 | }); 80 | }; 81 | 82 | connectNode = (event, node, incomes) => { 83 | event.preventDefault(); 84 | if (!this.state.selected.length) { 85 | this.addToNodeToSelectBuffer(node, incomes); 86 | } else { 87 | this.applyNodeConnection(node, incomes); 88 | } 89 | }; 90 | 91 | applyNodeConnection = (node, incomes) => { 92 | const target = this.state.selected[0]; 93 | if (target.id === node.id) return; 94 | const outcome = node; 95 | const newGraph = [...this.state.graph]; 96 | const targetInArray = newGraph.find(n => n.id === target.id); 97 | if (targetInArray.next.find(outcomeId => outcomeId === outcome.id)) 98 | return; 99 | targetInArray.next.push(outcome.id); 100 | this.setState({ 101 | ...this.state, 102 | ...{ 103 | selected: [], 104 | graph: newGraph 105 | } 106 | }); 107 | }; 108 | 109 | softDeleteNode = (event, node, incomes) => { 110 | const nodeIndex = this.state.graph.findIndex(n => n.id === node.id); 111 | incomes.forEach((income, index) => { 112 | const find = n => n.id === income.anchorFrom; 113 | while (income.isAnchor) { 114 | income = this.state.graph.find(find); 115 | } 116 | const inc = this.state.graph.find(n => n.id === income.id); 117 | const i = inc.next.findIndex(outcomeId => outcomeId === node.id); 118 | const incNext = node.next.filter(nextId => nextId !== inc.id); 119 | if (!index) inc.next.splice(i, 1, ...incNext); 120 | else inc.next.splice(i, 1); 121 | }); 122 | const newGraph = [...this.state.graph]; 123 | newGraph.splice(nodeIndex, 1); 124 | this.setState({ 125 | ...this.state, 126 | ...{ 127 | graph: newGraph 128 | } 129 | }); 130 | }; 131 | 132 | insertNode = (node, income) => { 133 | const newId = `#${counter}`; 134 | const nodeIndex = this.state.graph.findIndex(n => n.id === node.id); 135 | const inc = this.state.graph.find(n => n.id === income.id); 136 | const newGraph = [...this.state.graph]; 137 | newGraph.splice(nodeIndex, 0, { 138 | id: newId, 139 | next: [node.id], 140 | payload: { 141 | exist: true 142 | } 143 | }); 144 | const i = inc.next.findIndex(outcomeId => outcomeId === node.id); 145 | inc.next.splice(i, 1, newId); 146 | this.setState({ 147 | ...this.state, 148 | ...{ 149 | graph: newGraph 150 | } 151 | }); 152 | counter++; 153 | }; 154 | 155 | render() { 156 | const { cellSize, padding } = this.props; 157 | return ( 158 | <DirectGraph 159 | list={this.state.graph} 160 | cellSize={cellSize} 161 | padding={padding} 162 | onNodeClick={this.onNodeClick} 163 | onEdgeClick={this.onEdgeClick} 164 | onEdgeMouseEnter={this.onEdgeMouseEnter} 165 | onEdgeMouseLeave={this.onEdgeMouseLeave} 166 | /> 167 | ); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /example/src/components/editor/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | ExampleEdit 3 | } 4 | from "./ExampleEdit" 5 | export { 6 | Editor 7 | } 8 | from "./Editor" 9 | -------------------------------------------------------------------------------- /example/src/components/events/Events.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { ExampleEvents } from "./"; 4 | import { Example } from "../Example.jsx"; 5 | 6 | /* eslint import/no-webpack-loader-syntax: off */ 7 | const { default: exampleEventsCode } = require("!!raw-loader!./ExampleEvents.jsx"); 8 | 9 | const cellSize = 100; 10 | const padding = cellSize * 0.25; 11 | 12 | export class Events extends Component { 13 | description = `<i>It's possible to handle mouse events on nodes and edges.<br>All event handlers recieve three arguments:</i><br> 14 | <br> 15 | <b>event</b> React synthetic event<br> 16 | <b>node</b> node object bound to event target<br> 17 | <b>incomes</b> incomes of bound event object<br> 18 | <br> 19 | <i>Following event handlers available:</i><br> 20 | <br> 21 | <b>onNodeClick</b> - click on node<br> 22 | <b>onEdgeClick</b> - click on edge<br> 23 | <b>onNodeMouseEnter</b> - mouseenter on node<br> 24 | <b>onNodeMouseLeave</b> - mouseleave on node<br> 25 | <b>onEdgeMouseEnter</b> - mouseenter on edge<br> 26 | <b>onEdgeMouseLeave</b> - mouseleave on edge<br>`; 27 | 28 | render() { 29 | return ( 30 | <Example 31 | code={exampleEventsCode} 32 | title={"Graph Events"} 33 | description={this.description} 34 | > 35 | <ExampleEvents cellSize={cellSize} padding={padding} /> 36 | </Example> 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /example/src/components/events/ExampleEvents.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from "react"; 2 | 3 | import DirectGraph from "react-direct-graph"; 4 | import graph from "../../data/graph.json"; 5 | 6 | export class ExampleEvents extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | event: `Click event info will be here...` 11 | }; 12 | } 13 | onNodeClick = (event, node, incomes) => { 14 | this.setState({ 15 | event: `Click on node "${node.id}"` 16 | }); 17 | }; 18 | 19 | onEdgeClick = (event, node, incomes) => { 20 | this.setState({ 21 | event: `Click on edge from income "${incomes[0].id}" to node "${ 22 | node.id 23 | }"` 24 | }); 25 | }; 26 | 27 | onNodeMouseEnter = (event, node, incomes) => { 28 | const path = event.currentTarget.querySelector( 29 | ".node-icon-default path" 30 | ); 31 | path.style.stroke = "#f00"; 32 | path.style.fill = "#f00"; 33 | }; 34 | 35 | onNodeMouseLeave = (event, node, incomes) => { 36 | const path = event.currentTarget.querySelector( 37 | ".node-icon-default path" 38 | ); 39 | path.style.stroke = null; 40 | path.style.fill = null; 41 | }; 42 | 43 | onEdgeMouseEnter = (event, node, incomes) => { 44 | event.currentTarget.style.stroke = "#f00"; 45 | event.currentTarget.style.fill = "#f00"; 46 | }; 47 | 48 | onEdgeMouseLeave = (event, node, incomes) => { 49 | event.currentTarget.style.stroke = "rgb(45, 87, 139)"; 50 | event.currentTarget.style.fill = "rgb(45, 87, 139)"; 51 | }; 52 | 53 | render() { 54 | const { cellSize, padding } = this.props; 55 | return ( 56 | <Fragment> 57 | <p>{this.state.event}</p> 58 | <DirectGraph 59 | list={graph.map(n => ({ ...n, next: [...n.next] }))} 60 | cellSize={cellSize} 61 | padding={padding} 62 | onNodeClick={this.onNodeClick} 63 | onEdgeClick={this.onEdgeClick} 64 | onNodeMouseEnter={this.onNodeMouseEnter} 65 | onNodeMouseLeave={this.onNodeMouseLeave} 66 | onEdgeMouseEnter={this.onEdgeMouseEnter} 67 | onEdgeMouseLeave={this.onEdgeMouseLeave} 68 | /> 69 | </Fragment> 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /example/src/components/events/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | ExampleEvents 3 | } 4 | from "./ExampleEvents" 5 | export { 6 | Events 7 | } 8 | from "./Events" 9 | -------------------------------------------------------------------------------- /example/src/components/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | Editor 3 | } 4 | from "./editor" 5 | export { 6 | Basic 7 | } 8 | from "./basic" 9 | export { 10 | WithNames 11 | } 12 | from "./withNames" 13 | export { 14 | Complex 15 | } 16 | from "./complex" 17 | export { 18 | Custom 19 | } 20 | from "./custom" 21 | export { 22 | Events 23 | } 24 | from "./events" 25 | export { 26 | Title 27 | } 28 | from "./Title" 29 | -------------------------------------------------------------------------------- /example/src/components/withNames/ExampleWithNames.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import DirectGraph from "react-direct-graph"; 4 | 5 | const graph = [ 6 | { 7 | id: "A", 8 | next: ["B"], 9 | name: "Node A", 10 | edgeNames: ["Edge AB"] 11 | }, 12 | { 13 | id: "B", 14 | next: ["C", "D", "E"], 15 | name: "Node B", 16 | edgeNames: ["Edge BC", "Edge BD", "Edge BE"] 17 | }, 18 | { 19 | id: "C", 20 | next: ["F"], 21 | name: "Node C", 22 | edgeNames: ["Edge CF"] 23 | }, 24 | { 25 | id: "D", 26 | next: ["J"], 27 | name: "Node D", 28 | edgeNames: ["Edge DJ"] 29 | }, 30 | { 31 | id: "E", 32 | next: ["J"], 33 | name: "Node E", 34 | edgeNames: ["Edge EJ"] 35 | }, 36 | { 37 | id: "J", 38 | next: ["I"], 39 | name: "Node J", 40 | edgeNames: ["Edge JI"] 41 | }, 42 | { 43 | id: "I", 44 | next: ["H"], 45 | name: "Node I", 46 | edgeNames: ["Edge IH"] 47 | }, 48 | { 49 | id: "F", 50 | next: ["K"], 51 | name: "Node F", 52 | edgeNames: ["Edge FK"] 53 | }, 54 | { 55 | id: "K", 56 | next: ["L"], 57 | name: "Node K", 58 | edgeNames: ["Edge KL"] 59 | }, 60 | { 61 | id: "H", 62 | next: ["L"], 63 | name: "Node H", 64 | edgeNames: ["Edge HL"] 65 | }, 66 | { 67 | id: "L", 68 | next: ["P"], 69 | name: "Node L", 70 | edgeNames: ["Edge LP"] 71 | }, 72 | { 73 | id: "P", 74 | next: ["M", "N"], 75 | name: "Node P", 76 | edgeNames: ["Edge PM", "Edge PN"] 77 | }, 78 | { 79 | id: "M", 80 | next: [], 81 | name: "Node M" 82 | }, 83 | { 84 | id: "N", 85 | next: [], 86 | name: "Node N" 87 | } 88 | ]; 89 | 90 | export class ExampleWithNames extends Component { 91 | render() { 92 | const { cellSize, padding } = this.props; 93 | return ( 94 | <div style={{ 'font-size': '11px ' }}> 95 | <DirectGraph list={graph} cellSize={cellSize} padding={padding} />; 96 | </div> 97 | ) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /example/src/components/withNames/WithNames.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { ExampleWithNames } from "./"; 4 | import { Example } from "../Example.jsx"; 5 | 6 | /* eslint import/no-webpack-loader-syntax: off */ 7 | const { default: exampleWithNamesCode } = require("!!raw-loader!./ExampleWithNames.jsx"); 8 | 9 | const cellSize = 100; 10 | const padding = cellSize * 0.25; 11 | 12 | export class WithNames extends Component { 13 | description = `<i>You can give the name of the nodes and edges.<br> 14 | Node object can has following additional properties:</i><br> 15 | <br> 16 | <b>name</b> - name of the node<br> 17 | <b>edgeNames</b> array of names - outcome edges`; 18 | 19 | render() { 20 | return ( 21 | <Example 22 | code={exampleWithNamesCode} 23 | title={"Graph with names"} 24 | description={this.description} 25 | > 26 | <ExampleWithNames cellSize={cellSize} padding={padding} /> 27 | </Example> 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example/src/components/withNames/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | ExampleWithNames 3 | } 4 | from "./ExampleWithNames" 5 | export { 6 | WithNames 7 | } 8 | from "./WithNames" 9 | -------------------------------------------------------------------------------- /example/src/data/basic.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": "A", 3 | "next": ["B"] 4 | }, { 5 | "id": "B", 6 | "next": ["C", "D", "E"] 7 | }, { 8 | "id": "C", 9 | "next": ["F"] 10 | }, { 11 | "id": "D", 12 | "next": ["J"] 13 | }, 14 | { 15 | "id": "E", 16 | "next": ["J"] 17 | }, { 18 | "id": "J", 19 | "next": ["I"] 20 | }, 21 | { 22 | "id": "I", 23 | "next": ["H"] 24 | }, 25 | { 26 | "id": "F", 27 | "next": "K" 28 | }, 29 | { 30 | "id": "K", 31 | "next": [] 32 | }, 33 | { 34 | "id": "H", 35 | "next": [] 36 | } 37 | ] 38 | -------------------------------------------------------------------------------- /example/src/data/graph.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": "A", 3 | "next": ["B"], 4 | "payload": { 5 | "exist": true 6 | } 7 | }, { 8 | "id": "U", 9 | "next": ["G"], 10 | "payload": { 11 | "exist": true 12 | } 13 | }, { 14 | "id": "B", 15 | "next": ["C", "D", "E", "F", "M"], 16 | "payload": { 17 | "exist": true 18 | } 19 | }, { 20 | "id": "C", 21 | "next": ["G"], 22 | "payload": { 23 | "exist": true 24 | } 25 | }, { 26 | "id": "D", 27 | "next": ["H"], 28 | "payload": { 29 | "exist": true 30 | } 31 | }, { 32 | "id": "E", 33 | "next": ["H"], 34 | "payload": { 35 | "exist": true 36 | } 37 | }, { 38 | "id": "F", 39 | "next": ["N", "O"], 40 | "payload": { 41 | "exist": true 42 | } 43 | }, { 44 | "id": "N", 45 | "next": ["I"], 46 | "payload": { 47 | "exist": true 48 | } 49 | }, { 50 | "id": "O", 51 | "next": ["P"], 52 | "payload": { 53 | "exist": true 54 | } 55 | }, { 56 | "id": "P", 57 | "next": ["I"], 58 | "payload": { 59 | "exist": true 60 | } 61 | }, { 62 | "id": "M", 63 | "next": ["L"], 64 | "payload": { 65 | "exist": true 66 | } 67 | }, { 68 | "id": "G", 69 | "next": ["I"], 70 | "payload": { 71 | "exist": true 72 | } 73 | }, { 74 | "id": "H", 75 | "next": ["J"], 76 | "payload": { 77 | "exist": true 78 | } 79 | }, { 80 | "id": "I", 81 | "next": [], 82 | "payload": { 83 | "exist": true 84 | } 85 | }, { 86 | "id": "J", 87 | "next": ["K"], 88 | "payload": { 89 | "exist": true 90 | } 91 | }, { 92 | "id": "K", 93 | "next": ["L"], 94 | "payload": { 95 | "exist": true 96 | } 97 | }, { 98 | "id": "L", 99 | "next": [], 100 | "payload": { 101 | "exist": true 102 | } 103 | }] 104 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: "Courier"; 5 | background: url("https://www.toptal.com/designers/subtlepatterns/patterns/prism.png"); 6 | } 7 | 8 | main { 9 | max-width: 960px; 10 | padding: 30px; 11 | margin: 0 auto; 12 | background: #fff; 13 | } 14 | 15 | main>h1 { 16 | text-align: center; 17 | } 18 | 19 | main>svg { 20 | margin: 0 auto; 21 | display: block; 22 | } 23 | 24 | main>svg:first-child .node-icon-default path { 25 | fill: rgb(185, 0, 0); 26 | stroke: rgb(185, 0, 0); 27 | } 28 | 29 | main>svg:nth-child(2) .node-icon-default path { 30 | fill: rgb(48, 0, 160); 31 | stroke: rgb(48, 0, 160); 32 | } 33 | 34 | main>svg:nth-child(3) .node-icon-default path { 35 | fill: rgb(0, 160, 8); 36 | stroke: rgb(0, 160, 8); 37 | } 38 | 39 | .example svg { 40 | margin: 0 auto; 41 | } 42 | 43 | .description { 44 | margin-bottom: 20px; 45 | } 46 | 47 | .show-code { 48 | padding: 2px 10px; 49 | background: none; 50 | border: 2px solid #333; 51 | cursor: pointer; 52 | border-radius: 3px; 53 | user-select: none; 54 | color: #333; 55 | } 56 | 57 | .hide-code { 58 | padding: 2px 10px; 59 | background: none; 60 | border: 2px solid #2b2ba8; 61 | color: #2b2ba8; 62 | cursor: pointer; 63 | border-radius: 3px; 64 | user-select: none; 65 | } 66 | 67 | .graph-holder { 68 | overflow-x: auto; 69 | } 70 | 71 | .hidden { 72 | display: none !important; 73 | } 74 | 75 | pre { 76 | border-radius: 10px; 77 | } 78 | 79 | .hide-code:focus, 80 | .show-code:focus { 81 | outline: none 82 | } 83 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import './index.css' 5 | import App from './App' 6 | 7 | ReactDOM.render( < App / > , document.getElementById('root')) 8 | -------------------------------------------------------------------------------- /img/graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lempiy/react-direct-graph/42b999115fb193b976002db1e3291263fb88d028/img/graph.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-direct-graph", 3 | "version": "1.5.0", 4 | "description": "React component for drawing direct graphs with rectangular (non-curve) edges", 5 | "author": "lempiy", 6 | "license": "MIT", 7 | "repository": "https://github.com/lempiy/react-direct-graph.git", 8 | "main": "dist/index.js", 9 | "module": "dist/index.es.js", 10 | "jsnext:main": "dist/index.es.js", 11 | "engines": { 12 | "node": ">=8", 13 | "npm": ">=5" 14 | }, 15 | "scripts": { 16 | "test": "cross-env CI=1 react-scripts-ts test --env=jsdom --coverage --collectCoverageFrom='src/**/*.{ts|tsx}' --collectCoverageFrom=!src/fixtures/** --collectCoverageFrom=!src/**/*.d.ts", 17 | "test:watch": "react-scripts-ts test --env=jsdom", 18 | "build": "rollup -c", 19 | "start": "rollup -c -w", 20 | "prepare": "npm run build", 21 | "predeploy": "cd example && npm install && npm run build", 22 | "deploy": "gh-pages -d example/build" 23 | }, 24 | "dependencies": {}, 25 | "peerDependencies": { 26 | "prop-types": "^15.5.4", 27 | "react": "^15.0.0 || ^16.0.0", 28 | "react-dom": "^15.0.0 || ^16.0.0" 29 | }, 30 | "devDependencies": { 31 | "@svgr/rollup": "^2.4.1", 32 | "@types/jest": "^23.1.5", 33 | "@types/react": "^16.3.13", 34 | "@types/react-dom": "^16.0.5", 35 | "babel-core": "^6.26.3", 36 | "babel-runtime": "^6.26.0", 37 | "cross-env": "^5.1.4", 38 | "gh-pages": "^1.2.0", 39 | "react": "^16.4.1", 40 | "react-dom": "^16.4.1", 41 | "react-scripts-ts": "^2.16.0", 42 | "rollup": "^0.62.0", 43 | "rollup-plugin-babel": "^3.0.7", 44 | "rollup-plugin-commonjs": "^9.1.3", 45 | "rollup-plugin-node-resolve": "^3.3.0", 46 | "rollup-plugin-peer-deps-external": "^2.2.0", 47 | "rollup-plugin-postcss": "^1.6.2", 48 | "rollup-plugin-typescript2": "^0.17.0", 49 | "rollup-plugin-url": "^1.4.0", 50 | "typescript": "^2.8.3" 51 | }, 52 | "files": [ 53 | "dist" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import external from 'rollup-plugin-peer-deps-external' 4 | // import postcss from 'rollup-plugin-postcss-modules' 5 | import postcss from 'rollup-plugin-postcss' 6 | import resolve from 'rollup-plugin-node-resolve' 7 | import url from 'rollup-plugin-url' 8 | import svgr from '@svgr/rollup' 9 | 10 | import pkg from './package.json' 11 | 12 | export default { 13 | input: 'src/index.tsx', 14 | output: [ 15 | { 16 | file: pkg.main, 17 | format: 'cjs', 18 | exports: 'named', 19 | sourcemap: true 20 | }, 21 | { 22 | file: pkg.module, 23 | format: 'es', 24 | exports: 'named', 25 | sourcemap: true 26 | } 27 | ], 28 | plugins: [ 29 | external(), 30 | postcss({ 31 | modules: true 32 | }), 33 | url(), 34 | svgr(), 35 | resolve(), 36 | typescript({ 37 | rollupCommonJSResolveHack: true, 38 | clean: true 39 | }), 40 | commonjs() 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/components/element/element.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { GraphNodeIconComponentProps, DefaultNodeIcon } from "../node-icon"; 3 | import { withForeignObject } from "../with-foreign-object"; 4 | import { 5 | getSize, 6 | getCoords, 7 | getAllIncomes, 8 | wrapEventHandler, 9 | checkAnchorRenderIncomes 10 | } from "../../utils"; 11 | import { IMatrixNode } from "../../core"; 12 | import { GraphEventFunc, DataProps } from "./element.types"; 13 | 14 | export type ViewProps<T> = { 15 | component?: React.ComponentType<GraphNodeIconComponentProps<T>>; 16 | onNodeMouseEnter?: GraphEventFunc<T>; 17 | onNodeMouseLeave?: GraphEventFunc<T>; 18 | onNodeClick?: GraphEventFunc<T>; 19 | cellSize: number; 20 | padding: number; 21 | }; 22 | 23 | export class GraphElement<T> extends React.Component< 24 | DataProps<T> & ViewProps<T> 25 | > { 26 | diveToNodeIncome = ( 27 | node: IMatrixNode<T>, 28 | nodesMap: { [id: string]: IMatrixNode<T> } 29 | ): IMatrixNode<T> => { 30 | if (!node.isAnchor) return node; 31 | checkAnchorRenderIncomes<T>(node); 32 | return this.diveToNodeIncome(nodesMap[node.renderIncomes[0]], nodesMap); 33 | }; 34 | 35 | getNodeIncomes = ( 36 | node: IMatrixNode<T>, 37 | nodesMap: { [id: string]: IMatrixNode<T> } 38 | ): IMatrixNode<T>[] => getAllIncomes<T>(node, nodesMap).map(n => 39 | this.diveToNodeIncome(n, nodesMap) 40 | ); 41 | 42 | getNodeHandlers(): { [eventName: string]: (e: React.MouseEvent) => void } { 43 | const { 44 | node, 45 | nodesMap, 46 | onNodeClick, 47 | onNodeMouseEnter, 48 | onNodeMouseLeave 49 | } = this.props; 50 | const incomes = this.getNodeIncomes(node, nodesMap); 51 | const handlers: { 52 | [eventName: string]: (e: React.MouseEvent) => void; 53 | } = {}; 54 | if (onNodeClick) 55 | handlers.onClick = wrapEventHandler<T>( 56 | onNodeClick, 57 | node, 58 | incomes 59 | ); 60 | if (onNodeMouseEnter) 61 | handlers.onMouseEnter = wrapEventHandler<T>( 62 | onNodeMouseEnter, 63 | node, 64 | incomes 65 | ); 66 | if (onNodeMouseLeave) 67 | handlers.onMouseLeave = wrapEventHandler<T>( 68 | onNodeMouseLeave, 69 | node, 70 | incomes 71 | ); 72 | return handlers; 73 | } 74 | 75 | renderNode() { 76 | const { node, node: { isAnchor, name, nameOrientation = "bottom" }, nodesMap, cellSize, padding } = this.props; 77 | const [x, y] = getCoords<T>(cellSize, padding, node); 78 | const size = getSize(cellSize, padding); 79 | const NodeIcon = withForeignObject<GraphNodeIconComponentProps<T>>( 80 | this.props.component ? this.props.component : DefaultNodeIcon 81 | ); 82 | const incomes = this.getNodeIncomes(node, nodesMap); 83 | const textY = nameOrientation === "top" 84 | ? y - size * 0.2 85 | : y + size * 1.2; 86 | return ( 87 | !isAnchor && ( 88 | <g className="node-icon-group" {...this.getNodeHandlers()}> 89 | <NodeIcon 90 | x={x} 91 | y={y} 92 | height={size} 93 | width={size} 94 | node={node} 95 | incomes={incomes} 96 | /> 97 | {!!name && ( 98 | <text 99 | x={x + size * 0.5} 100 | y={textY} 101 | textAnchor="middle" 102 | dominantBaseline="middle" 103 | style={{ 104 | stroke: "#fff", 105 | strokeWidth: 3, 106 | fill: "#2d578b", 107 | paintOrder: "stroke" 108 | }} 109 | > 110 | {name} 111 | </text> 112 | )} 113 | </g> 114 | ) 115 | ); 116 | } 117 | 118 | render() { 119 | return ( 120 | <g 121 | className="node-group" 122 | style={{ 123 | strokeWidth: 2, 124 | fill: "#ffffff", 125 | stroke: "#2d578b" 126 | }} 127 | > 128 | {this.renderNode()} 129 | </g> 130 | ); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/components/element/element.types.ts: -------------------------------------------------------------------------------- 1 | import { IMatrixNode } from "../../core"; 2 | 3 | export type DataProps<T> = { 4 | node: IMatrixNode<T>; 5 | nodesMap: { [id: string]: IMatrixNode<T> }; 6 | }; 7 | 8 | export type GraphEventFunc<T> = ( 9 | event: React.MouseEvent, 10 | node: IMatrixNode<T>, 11 | incomes: IMatrixNode<T>[] 12 | ) => void; 13 | -------------------------------------------------------------------------------- /src/components/element/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./element"; 2 | export * from "./element.types"; 3 | -------------------------------------------------------------------------------- /src/components/graph.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { IMatrixNode } from "../core"; 3 | import { GraphElement, ViewProps as ElementViewProps } from "./element"; 4 | import { GraphPolyline, ViewProps as PolylineViewProps } from "./polyline"; 5 | import { DefaultMarkerBody } from "./marker-default"; 6 | 7 | export type Props<T> = { 8 | nodesMap: { [id: string]: IMatrixNode<T> }; 9 | cellSize: number; 10 | padding: number; 11 | widthInCells: number; 12 | heightInCells: number; 13 | }; 14 | 15 | export type ViewProps<T> = ElementViewProps<T> & PolylineViewProps<T>; 16 | 17 | interface INodeElementInput<T> { 18 | node: IMatrixNode<T>; 19 | } 20 | 21 | export class Graph<T> extends React.Component<ViewProps<T> & Props<T>> { 22 | getNodeElementInputs = (nodesMap: { [id: string]: IMatrixNode<T> }): INodeElementInput<T>[] => { 23 | return Object.entries(nodesMap) 24 | .filter(([_, node]) => !node.isAnchor) 25 | .map(([_, node]) => ({ 26 | node 27 | })); 28 | }; 29 | renderElements() { 30 | const { nodesMap, cellSize, padding, widthInCells, heightInCells, ...restProps } = this.props; 31 | const elements = this.getNodeElementInputs(nodesMap); 32 | return ( 33 | <> 34 | {elements.map(props => ( 35 | <GraphPolyline 36 | key={`polyline__${props.node.id}`} 37 | cellSize={cellSize} 38 | padding={padding} 39 | nodesMap={nodesMap} 40 | {...props} 41 | {...restProps} 42 | /> 43 | ))} 44 | {elements.map(props => ( 45 | <GraphElement 46 | key={`element__${props.node.id}`} 47 | cellSize={cellSize} 48 | padding={padding} 49 | nodesMap={nodesMap} 50 | {...props} 51 | {...restProps} 52 | /> 53 | ))} 54 | </> 55 | ) 56 | } 57 | render() { 58 | const { cellSize, widthInCells, heightInCells } = this.props; 59 | return ( 60 | <svg 61 | xmlns="http://www.w3.org/2000/svg" 62 | xmlnsXlink="http://www.w3.org/1999/xlink" 63 | version="1" 64 | width={widthInCells * cellSize} 65 | height={heightInCells * cellSize} 66 | > 67 | <DefaultMarkerBody /> 68 | {this.renderElements()} 69 | </svg> 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./graph"; 2 | export { GraphNodeIconComponentProps } from "./node-icon"; 3 | -------------------------------------------------------------------------------- /src/components/marker-default.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | type Props = { 4 | id: string; 5 | width: number; 6 | height: number; 7 | }; 8 | 9 | export class DefaultMarkerBody extends React.PureComponent { 10 | render() { 11 | return ( 12 | <defs> 13 | <symbol id="markerPoly"> 14 | <polyline 15 | stroke="none" 16 | points={"0,0 20,9 20,11 0,20 5,10"} 17 | /> 18 | </symbol> 19 | </defs> 20 | ); 21 | } 22 | } 23 | 24 | export class DefaultMarker extends React.PureComponent<Props> { 25 | render() { 26 | const { id, width, height } = this.props; 27 | return ( 28 | <marker 29 | id={id} 30 | viewBox={`0 0 20 20`} 31 | refX={20} 32 | refY={10} 33 | markerUnits="userSpaceOnUse" 34 | orient="auto" 35 | markerWidth={width} 36 | markerHeight={height} 37 | > 38 | <use xlinkHref="#markerPoly" /> 39 | </marker> 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/node-icon/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./node-icon"; 2 | export * from "./node-icon-default"; 3 | -------------------------------------------------------------------------------- /src/components/node-icon/node-icon-default.css: -------------------------------------------------------------------------------- 1 | .nodeOrange path { 2 | fill: #e25300; 3 | stroke: #e25300; 4 | } 5 | 6 | .nodeGreen path { 7 | fill: #008c15; 8 | stroke: #008c15; 9 | } 10 | 11 | .nodeBlue path { 12 | fill: #193772; 13 | stroke: #193772; 14 | } 15 | 16 | .nodePurple { 17 | fill: #6304a3; 18 | stroke: #6304a3; 19 | } 20 | 21 | .nodeDefaultIcon text { 22 | font-size: 14px; 23 | } 24 | 25 | .nodeDefaultIconGroup g { 26 | fill: #ffffff; 27 | stroke: #ffffff; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/node-icon/node-icon-default.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { GraphNodeIconComponentProps } from "./node-icon"; 3 | import { INodeInput } from "../../core"; 4 | import styles from "./node-icon-default.css"; 5 | 6 | export class DefaultNodeIcon<T> extends React.Component< 7 | GraphNodeIconComponentProps<T> 8 | > { 9 | getClass(node: INodeInput<T>, incomes: INodeInput<T>[]): string { 10 | if (incomes && incomes.length > 1 && node.next && node.next.length > 1) 11 | return styles.nodePurple; 12 | if (incomes && incomes.length > 1) return styles.nodeOrange; 13 | if (node.next && node.next.length > 1) return styles.nodeGreen; 14 | return styles.nodeBlue; 15 | } 16 | renderText(id: string) { 17 | return ( 18 | <g> 19 | <text 20 | strokeWidth="1" 21 | x="0" 22 | y="0" 23 | dx="26" 24 | dy="30" 25 | textAnchor="middle" 26 | > 27 | {id} 28 | </text> 29 | </g> 30 | ); 31 | } 32 | render() { 33 | const { node, incomes } = this.props; 34 | return ( 35 | <svg 36 | version="1.1" 37 | x="0px" 38 | y="0px" 39 | viewBox="0 0 52 52" 40 | className={styles.nodeDefaultIcon} 41 | > 42 | <g 43 | className={`node-icon-default ${ 44 | styles.nodeDefaultIconGroup 45 | } ${this.getClass(node, incomes)}`} 46 | > 47 | <path d="M40.824,52H11.176C5.003,52,0,46.997,0,40.824V11.176C0,5.003,5.003,0,11.176,0h29.649 C46.997,0,52,5.003,52,11.176v29.649C52,46.997,46.997,52,40.824,52z" /> 48 | {this.renderText(node.id)} 49 | </g> 50 | </svg> 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/node-icon/node-icon.ts: -------------------------------------------------------------------------------- 1 | import { INodeInput } from "../../core" 2 | 3 | export type GraphNodeIconComponentProps<T> = { 4 | node: INodeInput<T> 5 | incomes: INodeInput<T>[] 6 | } 7 | -------------------------------------------------------------------------------- /src/components/polyline/getPointWithResolver.ts: -------------------------------------------------------------------------------- 1 | import {getCellCenter, getCellEntry, VectorDirection} from "../../utils"; 2 | import {AnchorMargin, IMatrixNode} from "../../core"; 3 | 4 | export function getPointWithResolver<T>( 5 | direction: VectorDirection, 6 | cellSize: number, 7 | padding: number, 8 | item: IMatrixNode<T>, 9 | margin: AnchorMargin 10 | ): number[] { 11 | let x1, y1; 12 | if (item.isAnchor) { 13 | [x1, y1] = getCellCenter(cellSize, padding, item.x, item.y, margin); 14 | } else { 15 | [x1, y1] = getCellEntry( 16 | direction, 17 | cellSize, 18 | padding, 19 | item.x, 20 | item.y, 21 | margin 22 | ); 23 | } 24 | return [x1, y1]; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/polyline/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./polyline"; 2 | export * from "./polyline.types"; 3 | -------------------------------------------------------------------------------- /src/components/polyline/polyline.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { DefaultMarker } from "../marker-default"; 3 | import { getPointWithResolver } from "./getPointWithResolver"; 4 | import { 5 | getSize, 6 | uniqueId, 7 | getCoords, 8 | getAllIncomes, 9 | getEdgeMargins, 10 | wrapEventHandler, 11 | getVectorDirection, 12 | checkAnchorRenderIncomes, 13 | } from "../../utils"; 14 | import { IMatrixNode } from "../../core"; 15 | import { DataProps } from "../element"; 16 | import { LineBranch, pointResolversMap, ViewProps } from "./polyline.types"; 17 | 18 | export class GraphPolyline<T> extends React.Component< 19 | DataProps<T> & ViewProps<T> 20 | > { 21 | getPolyline = ( 22 | cellSize: number, 23 | padding: number, 24 | branch: IMatrixNode<T>[] 25 | ): number[][] => branch 26 | .filter((_, i) => (i + 1) !== branch.length) 27 | .reduce((result: number[][], node, i) => { 28 | const nextNode = branch[i + 1]; 29 | const line = this.getLineToIncome( 30 | cellSize, 31 | padding, 32 | node, 33 | nextNode 34 | ); 35 | const [x1, y1, x2, y2] = line.line; 36 | if (i === 0) { 37 | result.push([x1, y1], [x2, y2]); 38 | } else { 39 | result.push([x2, y2]); 40 | } 41 | return result; 42 | }, []); 43 | 44 | getLineToIncome( 45 | cellSize: number, 46 | padding: number, 47 | node: IMatrixNode<T>, 48 | income: IMatrixNode<T> 49 | ) { 50 | const margins = getEdgeMargins(node, income); 51 | const direction = getVectorDirection( 52 | node.x, 53 | node.y, 54 | income.x, 55 | income.y 56 | ); 57 | const [from, to] = pointResolversMap[direction]; 58 | const [nodeMargin, incomeMargin] = margins; 59 | const [x1, y1] = getPointWithResolver( 60 | from, 61 | cellSize, 62 | padding, 63 | node, 64 | nodeMargin 65 | ); 66 | const [x2, y2] = getPointWithResolver( 67 | to, 68 | cellSize, 69 | padding, 70 | income, 71 | incomeMargin 72 | ); 73 | return { 74 | node, 75 | income, 76 | line: [x1, y1, x2, y2] 77 | }; 78 | } 79 | 80 | getLines = ( 81 | cellSize: number, 82 | padding: number, 83 | node: IMatrixNode<T>, 84 | nodesMap: { [id: string]: IMatrixNode<T> } 85 | ): LineBranch<T>[] => this.getNodeBranches(node, nodesMap).map(branch => ({ 86 | node: node, 87 | income: branch[branch.length - 1], 88 | line: this.getPolyline(cellSize, padding, branch) 89 | })); 90 | 91 | getNodeBranches = ( 92 | node: IMatrixNode<T>, 93 | nodesMap: { [id: string]: IMatrixNode<T> } 94 | ): IMatrixNode<T>[][] => getAllIncomes<T>(node, nodesMap).map(n => [ 95 | node, 96 | ...this.getIncomeBranch(n, nodesMap) 97 | ]); 98 | 99 | getIncomeBranch = ( 100 | lastIncome: IMatrixNode<T>, 101 | nodesMap: { [id: string]: IMatrixNode<T> } 102 | ): IMatrixNode<T>[] => { 103 | const branch: IMatrixNode<T>[] = []; 104 | while (lastIncome.isAnchor) { 105 | checkAnchorRenderIncomes<T>(lastIncome); 106 | branch.push(lastIncome); 107 | lastIncome = nodesMap[lastIncome.renderIncomes[0]]; 108 | } 109 | branch.push(lastIncome); 110 | return branch; 111 | }; 112 | 113 | getLineHandlers( 114 | node: IMatrixNode<T>, 115 | income: IMatrixNode<T> 116 | ): { [eventName: string]: (e: React.MouseEvent) => void } { 117 | const { onEdgeClick, onEdgeMouseEnter, onEdgeMouseLeave } = this.props; 118 | const handlers: { 119 | [eventName: string]: (e: React.MouseEvent) => void; 120 | } = {}; 121 | if (onEdgeClick) 122 | handlers.onClick = wrapEventHandler<T>(onEdgeClick, node, [ 123 | income 124 | ]); 125 | if (onEdgeMouseEnter) 126 | handlers.onMouseEnter = wrapEventHandler<T>( 127 | onEdgeMouseEnter, 128 | node, 129 | [income] 130 | ); 131 | if (onEdgeMouseLeave) 132 | handlers.onMouseLeave = wrapEventHandler<T>( 133 | onEdgeMouseLeave, 134 | node, 135 | [income] 136 | ); 137 | return handlers; 138 | } 139 | 140 | getMarker(markerHash: string, incomeId: string): { [key: string]: string } { 141 | const markerId = this.getMarkerId(markerHash, incomeId); 142 | return markerId ? { markerEnd: `url(#${markerId})` } : {}; 143 | } 144 | 145 | getMarkerId(markerHash: string, incomeId: string): string { 146 | const { node } = this.props; 147 | return `${markerHash}-${node.id.trim()}-${incomeId.trim()}`; 148 | } 149 | 150 | getLineNameCoords(income: IMatrixNode<T>): number[] { 151 | const { node, node: {id}, cellSize, padding } = this.props; 152 | const index = income.next.findIndex(uuid => uuid === id); 153 | 154 | const [, nodeY] = getCoords<T>(cellSize, padding, node); 155 | const [x, incomeY] = getCoords<T>(cellSize, padding, income); 156 | 157 | let y = nodeY; 158 | if (incomeY > nodeY) { 159 | y = incomeY; 160 | } 161 | if (incomeY === nodeY) { 162 | y = y + cellSize * index; 163 | } 164 | 165 | return [x, y]; 166 | }; 167 | 168 | getLineComponentCoords(income: IMatrixNode<T>): number[] { 169 | const { node, node: {id}, cellSize, padding } = this.props; 170 | const index = income.next.findIndex(uuid => uuid === id); 171 | 172 | const [nodeX, nodeY] = getCoords<T>(cellSize, padding, node); 173 | const [incomeX, incomeY] = getCoords<T>(cellSize, padding, income); 174 | 175 | const x = incomeX/2 + nodeX/2; 176 | 177 | let y = nodeY; 178 | if (incomeY > nodeY) { 179 | y = incomeY; 180 | } 181 | if (incomeY === nodeY) { 182 | y = y + cellSize * index; 183 | } 184 | 185 | return [x, y]; 186 | }; 187 | 188 | lineComponent(income: IMatrixNode<T>) { 189 | const { cellSize, padding, edgeComponent } = this.props; 190 | const [x, y] = this.getLineComponentCoords(income); 191 | const size = getSize(cellSize, padding); 192 | 193 | if (!edgeComponent) { 194 | return null; 195 | } 196 | 197 | const Component = edgeComponent; 198 | return ( 199 | <foreignObject 200 | className="edge-icon" 201 | x={x + size * 0.5 - cellSize * 0.1} 202 | y={y + size * 0.5 - cellSize * 0.1} 203 | width={cellSize * 0.2} 204 | height={cellSize * 0.2} 205 | style={{ display: "none" }} 206 | > 207 | <Component /> 208 | </foreignObject> 209 | ); 210 | } 211 | 212 | lineName(income: IMatrixNode<T>) { 213 | const { node: {id}, cellSize, padding } = this.props; 214 | const { next, edgeNames = [] } = income; 215 | 216 | const [nameX, nameY] = this.getLineNameCoords(income); 217 | const [circleX, circleY] = this.getLineComponentCoords(income); 218 | const size = getSize(cellSize, padding); 219 | const index = next.findIndex(uuid => uuid === id); 220 | return ( 221 | <> 222 | <circle 223 | cx={circleX + size * 0.5} 224 | cy={circleY + size * 0.5} 225 | r={cellSize * 0.15} 226 | style={{ 227 | stroke: "none", 228 | fill: "fff", 229 | opacity: 0.01 230 | }} 231 | /> 232 | {!!edgeNames[index] && ( 233 | <text 234 | x={nameX + size * 1.5} 235 | y={nameY + size * 0.3} 236 | textAnchor="middle" 237 | dominantBaseline="middle" 238 | style={{ 239 | stroke: "#fff", 240 | strokeWidth: 3, 241 | fill: "#2d578b", 242 | paintOrder: "stroke" 243 | }} 244 | > 245 | {edgeNames[index]} 246 | </text> 247 | )} 248 | </> 249 | ) 250 | } 251 | 252 | getLinePoints = (line: LineBranch<T>): string => line.line 253 | .map(point => point.join(",")) 254 | .reverse() 255 | .join(" "); 256 | 257 | renderLines(node: IMatrixNode<T>, lines: LineBranch<T>[]) { 258 | const markerHash = uniqueId("marker-"); 259 | return lines.map(line => ( 260 | <g 261 | key={`line-${node.id}-${line.income.id}`} 262 | {...this.getLineHandlers(line.node, line.income)} 263 | style={{ 264 | strokeWidth: 2, 265 | fill: "#2d578b", 266 | stroke: "#2d578b" 267 | }} 268 | > 269 | {this.lineName(line.income)} 270 | <DefaultMarker 271 | id={this.getMarkerId(markerHash, line.income.id)} 272 | width={12} 273 | height={12} 274 | /> 275 | <polyline 276 | fill={"none"} 277 | className="node-line" 278 | points={this.getLinePoints(line)} 279 | style={{ 280 | strokeWidth: 6, 281 | stroke: "#ffffff" 282 | }} 283 | /> 284 | <polyline 285 | {...this.getMarker(markerHash, line.income.id)} 286 | fill={"none"} 287 | className="node-line" 288 | points={this.getLinePoints(line)} 289 | /> 290 | {this.lineComponent(line.income)} 291 | </g> 292 | )); 293 | } 294 | 295 | render() { 296 | const { node, nodesMap, cellSize, padding } = this.props; 297 | const lines = this.getLines(cellSize, padding, node, nodesMap); 298 | return ( 299 | lines.length && ( 300 | <g className="line-group">{this.renderLines(node, lines)}</g> 301 | ) 302 | ); 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /src/components/polyline/polyline.types.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { GraphEventFunc } from "../element"; 3 | import { IMatrixNode } from "../../core"; 4 | import { VectorDirection } from "../../utils"; 5 | 6 | export type ViewProps<T> = { 7 | edgeComponent?: React.ComponentType<T>; 8 | onEdgeMouseEnter?: GraphEventFunc<T>; 9 | onEdgeMouseLeave?: GraphEventFunc<T>; 10 | onEdgeClick?: GraphEventFunc<T>; 11 | cellSize: number; 12 | padding: number; 13 | }; 14 | 15 | export interface LineBranch<T> { 16 | node: IMatrixNode<T>; 17 | income: IMatrixNode<T>; 18 | line: number[][]; 19 | } 20 | 21 | export const pointResolversMap: { [key in VectorDirection]: VectorDirection[] } = { 22 | [VectorDirection.Top]: [VectorDirection.Top, VectorDirection.Bottom], 23 | [VectorDirection.Bottom]: [VectorDirection.Bottom, VectorDirection.Top], 24 | [VectorDirection.Right]: [VectorDirection.Right, VectorDirection.Left], 25 | [VectorDirection.Left]: [VectorDirection.Left, VectorDirection.Right] 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/with-foreign-object.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | interface WithForeignObjectProps { 4 | width: number 5 | height: number 6 | x: number 7 | y: number 8 | } 9 | 10 | export const withForeignObject = <P extends Object>( 11 | WrappedSVGComponent: React.ComponentType<P> 12 | ): React.FC<P & WithForeignObjectProps> => ({ 13 | width, 14 | height, 15 | x, 16 | y, 17 | ...props 18 | }: WithForeignObjectProps) => ( 19 | <foreignObject 20 | x={x} 21 | y={y} 22 | width={width} 23 | height={height} 24 | className={"node-icon"} 25 | > 26 | <WrappedSVGComponent {...props as P} /> 27 | </foreignObject> 28 | ); 29 | -------------------------------------------------------------------------------- /src/core/graph-matrix.class.ts: -------------------------------------------------------------------------------- 1 | import {AnchorMargin, AnchorType, INodeInput, INodeOutput} from "./node.interface"; 2 | import {TraverseQueue} from "./traverse-queue.class"; 3 | import {Matrix} from "./matrix.class"; 4 | import {GraphStruct} from "./graph-struct.class"; 5 | 6 | /** 7 | * Holds iteration state of the graph 8 | */ 9 | interface State<T> { 10 | mtx: Matrix<T>; 11 | queue: TraverseQueue<T>; 12 | x: number; 13 | y: number; 14 | } 15 | 16 | interface LoopNode<T> { 17 | id: string; 18 | node: INodeOutput<T>; 19 | coords: number[]; 20 | isSelfLoop?: boolean; 21 | } 22 | 23 | /** 24 | * @class GraphMatrix 25 | * Compute graph subclass used to interact with matrix 26 | */ 27 | export class GraphMatrix<T> extends GraphStruct<T> { 28 | constructor(list: INodeInput<T>[]) { 29 | super(list); 30 | } 31 | /** 32 | * Check if item has unresolved incomes 33 | * @param item item to check 34 | */ 35 | protected _joinHasUnresolvedIncomes(item: INodeOutput<T>): boolean { 36 | return item.passedIncomes.length != this.incomes(item.id).length; 37 | } 38 | /** 39 | * Main insertion method - inserts item on matrix using state x and y 40 | * or skips if it has collision on current row. Skipping is done 41 | * by passing item back to the end of the queue 42 | * @param item item to insert 43 | * @param state state of current iteration 44 | * @param checkCollision whether to check horizontal collision with existing point 45 | * on 2D matrix 46 | * @returns true if item was inserted false if skipped 47 | */ 48 | private _insertOrSkipNodeOnMatrix(item: INodeOutput<T>, state: State<T>, checkCollision: boolean) { 49 | const { mtx } = state; 50 | // if point collides by x vertex, insert new row before y position 51 | if (checkCollision && mtx.hasHorizontalCollision([state.x, state.y])) { 52 | mtx.insertRowBefore(state.y); 53 | } 54 | mtx.insert([state.x, state.y], item); 55 | this._markIncomesAsPassed(mtx, item); 56 | } 57 | /** 58 | * Get all items incomes and find parent Y with the lowest 59 | * Y coordinate on the matrix 60 | * @param item target item 61 | * @param mtx matrix to use as source 62 | */ 63 | private _getLowestYAmongIncomes(item: INodeOutput<T>, mtx: Matrix<T>): number { 64 | const incomes = item.passedIncomes; 65 | if (incomes && incomes.length) { 66 | // get lowest income y 67 | const items = incomes.map(id => { 68 | const coords = mtx.find(item => item.id === id); 69 | if (!coords) throw new Error(`Cannot find coordinates for passed income: "${id}"`); 70 | return coords[1]; 71 | }); 72 | return Math.min(...items); 73 | } 74 | return 0; 75 | } 76 | 77 | /** 78 | * Main processing nodes method. 79 | * If node has incomes it finds lowest Y among them and 80 | * sets state.y as lowest income Y value. 81 | * Then inserts item on matrix using state x and y 82 | * or skips if it has collision on current column. Skipping is done 83 | * by passing item back to the end of the queue 84 | * @param item item to insert 85 | * @param state state of current iteration 86 | * on 2D matrix 87 | * @returns true if item was inserted false if skipped 88 | */ 89 | protected _processOrSkipNodeOnMatrix(item: INodeOutput<T>, state: State<T>): boolean { 90 | const { mtx, queue } = state; 91 | if (item.passedIncomes && item.passedIncomes.length) { 92 | state.y = this._getLowestYAmongIncomes(item, mtx); 93 | } 94 | const hasLoops = this.hasLoops(item); 95 | const loopNodes = hasLoops ? this._handleLoopEdges(item, state) : null; 96 | const needsLoopSkip = hasLoops && !loopNodes; 97 | // if point collides by y vertex, skip it to next x 98 | if (mtx.hasVerticalCollision([state.x, state.y]) || needsLoopSkip) { 99 | queue.push(item); 100 | return false; 101 | } 102 | this._insertOrSkipNodeOnMatrix(item, state, false); 103 | if (loopNodes) { 104 | this._insertLoopEdges(item, state, loopNodes); 105 | } 106 | return true; 107 | } 108 | 109 | private hasLoops(item: INodeOutput<T>): boolean { 110 | return !!this.loops(item.id); 111 | } 112 | 113 | private _handleLoopEdges(item: INodeOutput<T>, state: State<T>): LoopNode<T>[] | null { 114 | const { mtx, x, y } = state; 115 | const loops = this.loops(item.id); 116 | if (!loops) throw new Error(`No loops found for node ${item.id}`); 117 | 118 | const loopNodes = loops.map(incomeId => { 119 | if (item.id === incomeId) { 120 | return { 121 | id: incomeId, 122 | node: item, 123 | coords: [x, y], 124 | isSelfLoop: true 125 | }; 126 | } 127 | 128 | const coords = mtx.find(({ id }) => id === incomeId); 129 | if (!coords) { 130 | throw new Error(`Loop target '${incomeId}' not found on matrix`); 131 | } 132 | 133 | const node = mtx.getByCoords(coords[0], coords[1]); 134 | if (!node) { 135 | throw new Error(`Loop target node'${incomeId}' not found on matrix`); 136 | } 137 | 138 | return { 139 | id: incomeId, 140 | node, 141 | coords 142 | }; 143 | }); 144 | 145 | const skip = loopNodes.some(({ coords }) => mtx 146 | .hasVerticalCollision([x, coords[1] ? coords[1] - 1 : 0])); 147 | if (skip) { 148 | return null; 149 | } 150 | 151 | return loopNodes; 152 | } 153 | 154 | private _markIncomesAsPassed(mtx: Matrix<T>, item: INodeOutput<T>) { 155 | item.renderIncomes.forEach(incomeId => { 156 | const found = mtx.findNode(n => n.id === incomeId); 157 | if (!found) throw new Error(`Income ${incomeId} is not on matrix yet`); 158 | const [coords, income] = found; 159 | income.childrenOnMatrix = Math.min(income.childrenOnMatrix + 1, income.next.length); 160 | mtx.insert(coords, income); 161 | }); 162 | } 163 | 164 | protected _resolveCurrentJoinIncomes(mtx: Matrix<T>, join: INodeOutput<T>) { 165 | this._markIncomesAsPassed(mtx, join); 166 | join.renderIncomes = []; 167 | } 168 | 169 | private _insertLoopEdges(item: INodeOutput<T>, state: State<T>, loopNodes: LoopNode<T>[]): boolean { 170 | const { mtx, x: initialX } = state; 171 | let { y: initialY } = state; 172 | loopNodes.forEach(({ id, coords, node, isSelfLoop }) => { 173 | let renderIncomeId = item.id; 174 | const insertItem = { 175 | anchorType: AnchorType.Loop, 176 | anchorFrom: item.id, 177 | anchorTo: id, 178 | isAnchor: true, 179 | passedIncomes: [item.id], 180 | payload: item.payload, 181 | next: [id], 182 | childrenOnMatrix: 0 183 | }; 184 | 185 | if (isSelfLoop) { 186 | state.y = initialY; 187 | state.x = initialX + 1; 188 | const selfLoopId = `${id}-self`; 189 | renderIncomeId = selfLoopId; 190 | this._insertOrSkipNodeOnMatrix( 191 | { 192 | id: selfLoopId, 193 | renderIncomes: [node.id], 194 | anchorMargin: AnchorMargin.Left, 195 | ...insertItem, 196 | }, 197 | state, 198 | false 199 | ); 200 | } 201 | state.y = coords[1]; 202 | const initialHeight = mtx.height; 203 | const fromId = `${id}-${item.id}-from`; 204 | const idTo = `${id}-${item.id}-to`; 205 | node.renderIncomes = node.renderIncomes ? [...node.renderIncomes, fromId] : [fromId]; 206 | this._insertOrSkipNodeOnMatrix( 207 | { 208 | id: idTo, 209 | renderIncomes: [renderIncomeId], 210 | anchorMargin: AnchorMargin.Left, 211 | ...insertItem, 212 | }, 213 | state, 214 | true 215 | ); 216 | if (initialHeight !== mtx.height) initialY++; 217 | state.x = coords[0]; 218 | this._insertOrSkipNodeOnMatrix( 219 | { 220 | id: fromId, 221 | renderIncomes: [idTo], 222 | anchorMargin: AnchorMargin.Right, 223 | ...insertItem, 224 | }, 225 | state, 226 | false 227 | ); 228 | state.x = initialX; 229 | }); 230 | state.y = initialY; 231 | return false; 232 | } 233 | /** 234 | * Insert outcomes of split node 235 | * @param item item to handle 236 | * @param state current state of iteration 237 | * @param levelQueue 238 | */ 239 | protected _insertSplitOutcomes(item: INodeOutput<T>, state: State<T>, levelQueue: TraverseQueue<T>) { 240 | const { queue } = state; 241 | const outcomes = this.outcomes(item.id); 242 | // first will be on the same y level as parent split 243 | const firstOutcomeId = outcomes.shift(); 244 | if (!firstOutcomeId) throw new Error(`Split "${item.id}" has no outcomes`); 245 | const first = this.node(firstOutcomeId); 246 | queue.add(item.id, levelQueue, { 247 | id: first.id, 248 | next: first.next, 249 | name: first.name, 250 | nameOrientation: first.nameOrientation, 251 | edgeNames: first.edgeNames, 252 | payload: first.payload 253 | }); 254 | // rest will create anchor with shift down by one 255 | outcomes.forEach(outcomeId => { 256 | state.y++; 257 | const id = `${item.id}-${outcomeId}`; 258 | 259 | this._insertOrSkipNodeOnMatrix( 260 | { 261 | id: id, 262 | anchorType: AnchorType.Split, 263 | anchorMargin: AnchorMargin.Right, 264 | anchorFrom: item.id, 265 | anchorTo: outcomeId, 266 | isAnchor: true, 267 | renderIncomes: [item.id], 268 | passedIncomes: [item.id], 269 | payload: item.payload, 270 | next: [outcomeId], 271 | childrenOnMatrix: 0 272 | }, 273 | state, 274 | true 275 | ); 276 | queue.add(id, levelQueue, { ...this.node(outcomeId) }); 277 | }); 278 | } 279 | /** 280 | * Insert incomes of join node 281 | * @param item item to handle 282 | * @param state current state of iteration 283 | * @param levelQueue 284 | * @param addItemToQueue 285 | */ 286 | protected _insertJoinIncomes( 287 | item: INodeOutput<T>, 288 | state: State<T>, 289 | levelQueue: TraverseQueue<T>, 290 | addItemToQueue: boolean 291 | ) { 292 | const { mtx, queue } = state; 293 | const incomes = item.passedIncomes; 294 | 295 | const lowestY = this._getLowestYAmongIncomes(item, mtx); 296 | incomes.forEach(incomeId => { 297 | const found = mtx.findNode(n => n.id === incomeId); 298 | if (!found) throw new Error(`Income ${incomeId} is not on matrix yet`); 299 | const [[, y], income] = found; 300 | if (lowestY === y) { 301 | item.renderIncomes.push(incomeId); 302 | income.childrenOnMatrix = Math.min(income.childrenOnMatrix + 1, income.next.length); 303 | return; 304 | } 305 | state.y = y; 306 | const id = `${incomeId}-${item.id}`; 307 | item.renderIncomes.push(id); 308 | this._insertOrSkipNodeOnMatrix( 309 | { 310 | id: id, 311 | anchorType: AnchorType.Join, 312 | anchorMargin: AnchorMargin.Left, 313 | anchorFrom: incomeId, 314 | anchorTo: item.id, 315 | isAnchor: true, 316 | renderIncomes: [incomeId], 317 | passedIncomes: [incomeId], 318 | payload: item.payload, 319 | next: [item.id], 320 | childrenOnMatrix: 1 // if we're adding income - join is allready on matrix 321 | }, 322 | state, 323 | false 324 | ); 325 | }); 326 | if (addItemToQueue) queue.add(item.id, levelQueue, ...this.getOutcomesArray(item.id)); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /src/core/graph-struct.class.ts: -------------------------------------------------------------------------------- 1 | import { INodeInput, NodeType } from "./node.interface"; 2 | 3 | const isMultiple = (obj: { [id: string]: string[] }, id: string): boolean => obj[id] && obj[id].length > 1; 4 | 5 | /** 6 | * @class GraphStruct 7 | * Frame parent-class to simplify graph 8 | * elements recognition 9 | */ 10 | export class GraphStruct<T> { 11 | protected _list: INodeInput<T>[] = []; 12 | private _nodesMap: { [id: string]: INodeInput<T> } = {}; 13 | private _incomesByNodeIdMap: { [id: string]: string[] } = {}; 14 | private _outcomesByNodeIdMap: { [id: string]: string[] } = {}; 15 | private _loopsByNodeIdMap: { [id: string]: string[] } = {}; 16 | constructor(list: INodeInput<T>[]) { 17 | this.applyList(list); 18 | } 19 | /** 20 | * Fill graph with new nodes 21 | * @param list input linked list of nodes 22 | */ 23 | applyList(list: INodeInput<T>[]): void { 24 | this._incomesByNodeIdMap = {}; 25 | this._outcomesByNodeIdMap = {}; 26 | this._nodesMap = {}; 27 | this._loopsByNodeIdMap = {}; 28 | this._list = list; 29 | this._nodesMap = list.reduce((map, node) => { 30 | if (map[node.id]) throw new Error(`Duplicate id ${node.id}`); 31 | map[node.id] = node; 32 | return map; 33 | }, {}); 34 | this.detectIncomesAndOutcomes(); 35 | } 36 | detectIncomesAndOutcomes() { 37 | this._list.reduce((totalSet, node) => { 38 | if (totalSet.has(node.id)) return totalSet; 39 | return this.traverseVertically(node, new Set(), totalSet); 40 | }, new Set<string>()); 41 | } 42 | traverseVertically(node: INodeInput<T>, branchSet: Set<string>, totalSet: Set<string>): Set<string> { 43 | if (branchSet.has(node.id)) throw new Error(`Duplicate incomes for node id ${node.id}`); 44 | branchSet.add(node.id); 45 | totalSet.add(node.id); 46 | node.next.forEach(outcomeId => { 47 | // skip loops which are already detected 48 | if (this.isLoopEdge(node.id, outcomeId)) return; 49 | // detect loops 50 | if (branchSet.has(outcomeId)) { 51 | this._loopsByNodeIdMap[node.id] = this._loopsByNodeIdMap[node.id] 52 | ? Array.from(new Set([...this._loopsByNodeIdMap[node.id], outcomeId])) 53 | : [outcomeId]; 54 | return; 55 | } 56 | this._incomesByNodeIdMap[outcomeId] = this._incomesByNodeIdMap[outcomeId] 57 | ? Array.from(new Set([...this._incomesByNodeIdMap[outcomeId], node.id])) 58 | : [node.id]; 59 | this._outcomesByNodeIdMap[node.id] = this._outcomesByNodeIdMap[node.id] 60 | ? Array.from(new Set([...this._outcomesByNodeIdMap[node.id], outcomeId])) 61 | : [outcomeId]; 62 | totalSet = this.traverseVertically(this._nodesMap[outcomeId], new Set(branchSet), totalSet); 63 | return; 64 | }); 65 | 66 | return totalSet; 67 | } 68 | /** 69 | * Get graph roots. 70 | * Roots is nodes without incomes 71 | */ 72 | roots(): INodeInput<T>[] { 73 | return this._list.filter(node => this.isRoot(node.id)); 74 | } 75 | /** 76 | * Get type of node 77 | * @param id id of node 78 | * @returns type of the node 79 | */ 80 | protected nodeType(id: string): NodeType { 81 | let nodeType = NodeType.Simple; 82 | switch (true) { 83 | case this.isRoot(id) && this.isSplit(id): 84 | nodeType = NodeType.RootSplit; 85 | break; 86 | case this.isRoot(id): 87 | nodeType = NodeType.RootSimple; 88 | break; 89 | case this.isSplit(id) && this.isJoin(id): 90 | nodeType = NodeType.SplitJoin; 91 | break; 92 | case this.isSplit(id): 93 | nodeType = NodeType.Split; 94 | break; 95 | case this.isJoin(id): 96 | nodeType = NodeType.Join; 97 | break; 98 | } 99 | return nodeType; 100 | } 101 | /** 102 | * Whether or node is split 103 | * @param id id of node 104 | */ 105 | private isSplit(id: string): boolean { 106 | return isMultiple(this._outcomesByNodeIdMap, id); 107 | } 108 | /** 109 | * Whether or node is join 110 | * @param id id of node 111 | */ 112 | private isJoin(id: string): boolean { 113 | return isMultiple(this._incomesByNodeIdMap, id); 114 | } 115 | /** 116 | * Whether or node is root 117 | * @param id id of node 118 | */ 119 | private isRoot(id: string): boolean { 120 | return !this._incomesByNodeIdMap[id] || !this._incomesByNodeIdMap[id].length; 121 | } 122 | protected isLoopEdge(nodeId: string, outcomeId: string): boolean { 123 | return this._loopsByNodeIdMap[nodeId] && this._loopsByNodeIdMap[nodeId].includes(outcomeId); 124 | } 125 | /** 126 | * Get loops of node by id 127 | * @param id id of node 128 | */ 129 | protected loops(id: string): string[] { 130 | return this._loopsByNodeIdMap[id]; 131 | } 132 | /** 133 | * Get outcomes of node by id 134 | * @param id id of node 135 | */ 136 | protected outcomes(id: string): string[] { 137 | return this._outcomesByNodeIdMap[id] || []; 138 | } 139 | /** 140 | * Get incomes of node by id 141 | * @param id id of node 142 | */ 143 | protected incomes(id: string): string[] { 144 | return this._incomesByNodeIdMap[id]; 145 | } 146 | /** 147 | * Get node by id 148 | * @param id node id 149 | */ 150 | protected node(id: string): INodeInput<T> { 151 | return this._nodesMap[id]; 152 | } 153 | /** 154 | * get outcomes inputs helper 155 | * @param itemId node id 156 | */ 157 | protected getOutcomesArray(itemId: string): INodeInput<T>[] { 158 | return this.outcomes(itemId).map(outcomeId => { 159 | const out = this.node(outcomeId); 160 | return { 161 | id: out.id, 162 | next: out.next, 163 | name: out.name, 164 | nameOrientation: out.nameOrientation, 165 | edgeNames: out.edgeNames, 166 | payload: out.payload 167 | }; 168 | }); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/core/graph.class.ts: -------------------------------------------------------------------------------- 1 | import { INodeInput, NodeType, INodeOutput } from "./node.interface"; 2 | import { TraverseQueue } from "./traverse-queue.class"; 3 | import { Matrix } from "./matrix.class"; 4 | import { GraphMatrix } from "./graph-matrix.class"; 5 | 6 | const MAX_ITERATIONS = 10000; 7 | 8 | /** 9 | * Holds iteration state of the graph 10 | */ 11 | interface State<T> { 12 | mtx: Matrix<T>; 13 | queue: TraverseQueue<T>; 14 | x: number; 15 | y: number; 16 | } 17 | 18 | /** 19 | * @class Graph 20 | * Main iteration class used to transform 21 | * linked list of nodes to coordinate matrix 22 | */ 23 | export class Graph<T> extends GraphMatrix<T> { 24 | constructor(list: INodeInput<T>[]) { 25 | super(list); 26 | } 27 | /** 28 | * Function to handle split nodes 29 | * @param item item to handle 30 | * @param state current state of iteration 31 | * @param levelQueue buffer subqueue of iteration 32 | */ 33 | private _handleSplitNode( 34 | item: INodeOutput<T>, 35 | state: State<T>, 36 | levelQueue: TraverseQueue<T> 37 | ) { 38 | let isInserted = this._processOrSkipNodeOnMatrix(item, state); 39 | if (isInserted) { 40 | this._insertSplitOutcomes(item, state, levelQueue); 41 | } 42 | } 43 | /** 44 | * Function to handle splitjoin nodes 45 | * @param item item to handle 46 | * @param state current state of iteration 47 | * @param levelQueue buffer subqueue of iteration 48 | */ 49 | private _handleSplitJoinNode( 50 | item: INodeOutput<T>, 51 | state: State<T>, 52 | levelQueue: TraverseQueue<T> 53 | ): boolean { 54 | const { queue, mtx } = state; 55 | let isInserted = false; 56 | if (this._joinHasUnresolvedIncomes(item)) { 57 | queue.push(item); 58 | } else { 59 | this._resolveCurrentJoinIncomes(mtx, item); 60 | isInserted = this._processOrSkipNodeOnMatrix(item, state); 61 | if (isInserted) { 62 | this._insertJoinIncomes(item, state, levelQueue, false); 63 | this._insertSplitOutcomes(item, state, levelQueue); 64 | } 65 | } 66 | return isInserted; 67 | } 68 | /** 69 | * Function to handle join nodes 70 | * @param item item to handle 71 | * @param state current state of iteration 72 | * @param levelQueue buffer subqueue of iteration 73 | */ 74 | private _handleJoinNode( 75 | item: INodeOutput<T>, 76 | state: State<T>, 77 | levelQueue: TraverseQueue<T> 78 | ): boolean { 79 | const { queue, mtx } = state; 80 | let isInserted = false; 81 | if (this._joinHasUnresolvedIncomes(item)) { 82 | queue.push(item); 83 | } else { 84 | this._resolveCurrentJoinIncomes(mtx, item); 85 | isInserted = this._processOrSkipNodeOnMatrix(item, state); 86 | if (isInserted) { 87 | this._insertJoinIncomes(item, state, levelQueue, true); 88 | } 89 | } 90 | return isInserted; 91 | } 92 | /** 93 | * Function to handle simple nodes 94 | * @param item item to handle 95 | * @param state current state of iteration 96 | * @param levelQueue buffer subqueue of iteration 97 | */ 98 | private _handleSimpleNode( 99 | item: INodeOutput<T>, 100 | state: State<T>, 101 | levelQueue: TraverseQueue<T> 102 | ): boolean { 103 | const { queue } = state; 104 | let isInserted = this._processOrSkipNodeOnMatrix(item, state); 105 | if (isInserted) { 106 | queue.add(item.id, levelQueue, ...this.getOutcomesArray(item.id)); 107 | } 108 | return isInserted; 109 | } 110 | /** 111 | * Method to handle single iteration item 112 | * @param item queue item to process 113 | * @param state state of iteration 114 | * @param levelQueue 115 | */ 116 | private _traverseItem( 117 | item: INodeOutput<T>, 118 | state: State<T>, 119 | levelQueue: TraverseQueue<T> 120 | ) { 121 | const { mtx } = state; 122 | switch (this.nodeType(item.id)) { 123 | case NodeType.RootSimple: 124 | // find free column and fallthrough 125 | state.y = mtx.getFreeRowForColumn(0); 126 | case NodeType.Simple: 127 | this._handleSimpleNode(item, state, levelQueue); 128 | break; 129 | case NodeType.RootSplit: 130 | // find free column and fallthrough 131 | state.y = mtx.getFreeRowForColumn(0); 132 | case NodeType.Split: 133 | this._handleSplitNode(item, state, levelQueue); 134 | break; 135 | case NodeType.Join: 136 | this._handleJoinNode(item, state, levelQueue); 137 | break; 138 | case NodeType.SplitJoin: 139 | this._handleSplitJoinNode(item, state, levelQueue); 140 | break; 141 | } 142 | } 143 | /** 144 | * Iterate over one level of graph 145 | * starting from queue top item 146 | */ 147 | private _traverseLevel(iterations: number, state: State<T>): number { 148 | const { queue } = state; 149 | const levelQueue = queue.drain(); 150 | while (levelQueue.length) { 151 | iterations++; 152 | const item = levelQueue.shift(); 153 | if (!item) throw new Error("Cannot shift from buffer queue"); 154 | this._traverseItem(item, state, levelQueue); 155 | if (iterations > MAX_ITERATIONS) { 156 | throw new Error(`Infinite loop`); 157 | } 158 | } 159 | return iterations; 160 | } 161 | /** 162 | * Iterate over graph 163 | * starting from queue root items 164 | */ 165 | private _traverseList(state: State<T>): Matrix<T> { 166 | let _safe = 0; 167 | const { mtx, queue } = state; 168 | while (queue.length) { 169 | _safe = this._traverseLevel(_safe, state); 170 | state.x++; 171 | } 172 | return mtx; 173 | } 174 | /** 175 | * traverse main method to get coordinates matrix from graph 176 | * @returns 2D matrix containing all nodes and anchors 177 | */ 178 | traverse(): Matrix<T> { 179 | const roots = this.roots(); 180 | const state: State<T> = { 181 | mtx: new Matrix<T>(), 182 | queue: new TraverseQueue(), 183 | x: 0, 184 | y: 0 185 | }; 186 | if (!roots.length) { 187 | if (this._list.length) throw new Error(`No roots in graph`); 188 | return state.mtx; 189 | } 190 | const { mtx, queue } = state; 191 | queue.add( 192 | null, 193 | null, 194 | ...roots.map(r => ({ 195 | id: r.id, 196 | next: r.next, 197 | name: r.name, 198 | nameOrientation: r.nameOrientation, 199 | edgeNames: r.edgeNames, 200 | payload: r.payload 201 | })) 202 | ); 203 | this._traverseList(state); 204 | return mtx; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./graph.class" 2 | export * from "./matrix.class" 3 | export * from "./traverse-queue.class" 4 | export * from "./node.interface" 5 | -------------------------------------------------------------------------------- /src/core/matrix.class.ts: -------------------------------------------------------------------------------- 1 | import { INodeOutput, IMatrixNode } from "./node.interface"; 2 | 3 | /** 4 | * @class Matrix 5 | * Low level class used to compute 2D polar coordinates for each node 6 | * and anchor. Use this class if you want to skip D3 rendering in favor of 7 | * something else, for example, HTML or Canvas drawing. 8 | */ 9 | export class Matrix<T> { 10 | private _: Array<Array<INodeOutput<T> | null>> = []; 11 | 12 | /** 13 | * Get with of matrix 14 | */ 15 | get width(): number { 16 | return this._.reduce((length, row) => (row.length > length ? row.length : length), 0) || 0; 17 | } 18 | /** 19 | * Get height of matrix 20 | */ 21 | get height(): number { 22 | return this._.length; 23 | } 24 | /** 25 | * Checks whether or not candidate point collides 26 | * with present points by X vertex. 27 | * @param point coordinates of point to check 28 | */ 29 | hasHorizontalCollision([_, y]: number[]): boolean { 30 | const row = this._[y]; 31 | if (!row) return false; 32 | return row.some((point: INodeOutput<T> | null) => { 33 | return !!point && !this.isAllChildrenOnMatrix(point); 34 | }); 35 | } 36 | 37 | /** 38 | * Checks whether or not candidate point collides 39 | * with present points by Y vertex. 40 | * @param point coordinates of point to check 41 | */ 42 | hasVerticalCollision([x, y]: number[]): boolean { 43 | if (x >= this.width) { 44 | return false; 45 | } 46 | return this._.some((row, index) => { 47 | if (index < y) { 48 | return false; 49 | } 50 | return !!row[x]; 51 | }); 52 | } 53 | 54 | /** 55 | * Check if all next items of node already placed in matrix 56 | */ 57 | private isAllChildrenOnMatrix(item: INodeOutput<T>) { 58 | return item.next.length === item.childrenOnMatrix; 59 | } 60 | 61 | /** 62 | * Inspects matrix by Y vertex from top to bottom to 63 | * search first unused Y coordinate (row). 64 | * If there no free row on the matrix it returns 65 | * matrix height (Which is equal to first unused row, 66 | * that currently not exist). 67 | * @param x column coordinate to use for search 68 | */ 69 | getFreeRowForColumn(x: number): number { 70 | if (this.height === 0) return 0; 71 | let y = this._.findIndex(row => { 72 | return !row[x]; 73 | }); 74 | if (y === -1) { 75 | y = this.height; 76 | } 77 | return y; 78 | } 79 | /** 80 | * Extend matrix with empty rows 81 | * @param toValue rows to add to matrix 82 | */ 83 | private _extendHeight(toValue: number): void { 84 | while (this.height < toValue) { 85 | const row: Array<INodeOutput<T> | null> = []; 86 | row.length = this.width; 87 | row.fill(null); 88 | this._.push(row); 89 | } 90 | } 91 | /** 92 | * Extend matrix with empty columns 93 | * @param toValue columns to add to matrix 94 | */ 95 | private _extendWidth(toValue: number): void { 96 | this._.forEach(row => { 97 | while (row.length < toValue) { 98 | row.push(null); 99 | } 100 | }); 101 | } 102 | /** 103 | * Insert row before y 104 | * @param y coordinate 105 | */ 106 | insertRowBefore(y: number) { 107 | const row: Array<INodeOutput<T> | null> = []; 108 | row.length = this.width; 109 | row.fill(null); 110 | this._.splice(y, 0, row); 111 | } 112 | /** 113 | * Insert column before x 114 | * @param x coordinate 115 | */ 116 | insertColumnBefore(x: number) { 117 | this._.forEach(row => { 118 | row.splice(x, 0, null); 119 | }); 120 | } 121 | /** 122 | * Find x, y coordinate of first point item that 123 | * satisfies condition defined in callback 124 | * @param callback similar to [].find. Returns boolean 125 | */ 126 | find(callback: (item: INodeOutput<T>) => boolean): number[] | null { 127 | let result = null; 128 | this._.forEach((row, y) => { 129 | row.some((point, x) => { 130 | if (!point) return false; 131 | if (callback(point)) { 132 | result = [x, y]; 133 | return true; 134 | } 135 | return false; 136 | }); 137 | }); 138 | return result; 139 | } 140 | /** 141 | * Find first node item that 142 | * satisfies condition defined in callback 143 | * @param callback similar to [].find. Returns boolean 144 | */ 145 | findNode(callback: (item: INodeOutput<T>) => boolean): [number[], INodeOutput<T>] | null { 146 | let result = null; 147 | this._.forEach((row, y) => { 148 | row.some((point, x) => { 149 | if (!point) return false; 150 | if (callback(point)) { 151 | result = [[x, y], point]; 152 | return true; 153 | } 154 | return false; 155 | }); 156 | }); 157 | return result; 158 | } 159 | /** 160 | * Return point by x, y coordinate 161 | */ 162 | getByCoords(x: number, y: number): INodeOutput<T> | null { 163 | return this._[y][x]; 164 | } 165 | /** 166 | * Paste item to particular cell 167 | * @param coords x and y coordinates for item 168 | * @param item item to insert 169 | */ 170 | insert([x, y]: number[], item: INodeOutput<T>) { 171 | if (this.height <= y) { 172 | this._extendHeight(y + 1); 173 | } 174 | if (this.width <= x) { 175 | this._extendWidth(x + 1); 176 | } 177 | 178 | this._[y][x] = item; 179 | } 180 | /** 181 | * @returns key value object where key is node id and 182 | * value is node with its coordinates 183 | */ 184 | normalize(): { [id: string]: IMatrixNode<T> } { 185 | return this._.reduce((acc, row, y) => { 186 | row.forEach((item, x) => { 187 | if (!item) return; 188 | acc[item.id] = { ...item, x, y }; 189 | }); 190 | return acc; 191 | }, {}); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/core/node.interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Types of nodes for internal usage 3 | */ 4 | export enum NodeType { 5 | RootSimple = "ROOT-SIMPLE", 6 | RootSplit = "ROOT-SPLIT", 7 | Simple = "SIMPLE", 8 | Split = "SPLIT", 9 | Join = "JOIN", 10 | SplitJoin = "SPLIT-JOIN" 11 | } 12 | /** 13 | * Types of anchors. 14 | * Join is anchor (vertex) between node and its join outcome: 15 | * N-J 16 | * N_| 17 | * N_| 18 | * Split is anchor (vertex) between node and its split income: 19 | * S-N 20 | * |_N 21 | * |_N 22 | */ 23 | export enum AnchorType { 24 | Join = "JOIN", 25 | Split = "SPLIT", 26 | Loop = "LOOP" 27 | } 28 | 29 | export enum AnchorMargin { 30 | None = "NONE", 31 | Left = "LEFT", 32 | Right = "RIGHT" 33 | } 34 | 35 | export interface INodeInput<T> { 36 | /** 37 | * Unique key for node. Duplicates are not allowed. 38 | */ 39 | id: string; 40 | /** 41 | * Outcomes of current node. Empty array if node is leaf. 42 | */ 43 | next: string[]; 44 | /** 45 | * Name of current node. Empty if the node without a name. 46 | */ 47 | name?: string; 48 | /** 49 | * Name of current node. Empty if the node orientation is bottom. 50 | */ 51 | nameOrientation?: "bottom" | "top"; 52 | /** 53 | * Name of node edges. Matched to the edge index with the next. Empty array if the edges without a name. 54 | */ 55 | edgeNames?: string[]; 56 | /** 57 | * Payload data to transfer with current node events. Use whatever you want here. 58 | */ 59 | payload: T; 60 | } 61 | 62 | export interface INodeOutput<T> extends INodeInput<T> { 63 | /** 64 | * Defines whether or not node is pseudo-node - anchor. 65 | * Which is used to draw split and join edges. 66 | * @default false 67 | */ 68 | isAnchor?: boolean; 69 | /** 70 | * Type of anchor. Only exists if isAnchor is true. 71 | */ 72 | anchorType?: AnchorType; 73 | /** 74 | * Id if the anchor income. Only exists if isAnchor is true. 75 | */ 76 | anchorFrom?: string; 77 | /** 78 | * Id if the anchor outcome. Only exists if isAnchor is true. 79 | */ 80 | anchorTo?: string; 81 | /** 82 | * Anchor position inside cell over x axis. 83 | */ 84 | anchorMargin?: AnchorMargin; 85 | /** 86 | * First level node incomes passed during traversal. Ignores join 87 | * anchor. Mostly for tech usage. To recognize rendering parents 88 | * Use renderIncomes. 89 | */ 90 | passedIncomes: string[]; 91 | /** 92 | * First level node incomes in rendering context. Can be used for 93 | * backward travesal. Includes both types of anchors. 94 | */ 95 | renderIncomes: string[]; 96 | /** 97 | * Number of outcomes that already been placed on matrix 98 | */ 99 | childrenOnMatrix: number; 100 | } 101 | 102 | export interface IMatrixNode<T> extends INodeOutput<T> { 103 | /** 104 | * X coordinate of node 105 | */ 106 | x: number; 107 | /** 108 | * Y coordinate of node 109 | */ 110 | y: number; 111 | } 112 | -------------------------------------------------------------------------------- /src/core/traverse-queue.class.ts: -------------------------------------------------------------------------------- 1 | import { INodeOutput } from "./node.interface" 2 | 3 | export interface IQueueItem<T> { 4 | id: string 5 | payload: T 6 | next: string[] 7 | name?: string 8 | nameOrientation?: "bottom" | "top" 9 | edgeNames?: string[] 10 | } 11 | 12 | /** 13 | * @class TraverseQueue 14 | * Special queue that is used for horizontal 15 | * graph traversing 16 | */ 17 | export class TraverseQueue<T> { 18 | private _: INodeOutput<T>[] = []; 19 | /** 20 | * Add items to queue. If items already exist in this queue 21 | * or bufferQueue do nothing but push new passed income to 22 | * existing queue item 23 | * @param incomeId income id for each element 24 | * @param bufferQueue buffer queue to also check for duplicates 25 | * @param items queue items to add 26 | */ 27 | add( 28 | incomeId: string | null, 29 | bufferQueue: TraverseQueue<T> | null, 30 | ...items: IQueueItem<T>[] 31 | ) { 32 | items.forEach(itm => { 33 | const item = 34 | this.find(item => item.id === itm.id) || 35 | (bufferQueue 36 | ? bufferQueue.find(item => item.id === itm.id) 37 | : null); 38 | if (item && incomeId) { 39 | item.passedIncomes.push(incomeId); 40 | return 41 | } 42 | this._.push({ 43 | id: itm.id, 44 | next: itm.next, 45 | name: itm.name, 46 | nameOrientation: itm.nameOrientation, 47 | edgeNames: itm.edgeNames, 48 | payload: itm.payload, 49 | passedIncomes: incomeId ? [incomeId] : [], 50 | renderIncomes: incomeId ? [incomeId] : [], 51 | childrenOnMatrix: 0 52 | }) 53 | }) 54 | } 55 | 56 | find(cb: (item: INodeOutput<T>) => boolean): INodeOutput<T> | void { 57 | return this._.find(cb) 58 | } 59 | /** 60 | * Push item to queue. Skipping `add` method additional phases. 61 | * @param item node item to add 62 | */ 63 | push(item: INodeOutput<T>): void { 64 | this._.push(item) 65 | } 66 | /** 67 | * get current queue length 68 | */ 69 | get length(): number { 70 | return this._.length 71 | } 72 | /** 73 | * @param cb callback with condition to check 74 | * @returns true if at list one item satified condition in callback 75 | */ 76 | some(cb: (item: INodeOutput<T>) => boolean): boolean { 77 | return this._.some(cb) 78 | } 79 | /** 80 | * Shift first element 81 | * @returns first element from the queue 82 | */ 83 | shift(): INodeOutput<T> | void { 84 | return this._.shift() 85 | } 86 | /** 87 | * Create new queue and extract of current 88 | * elements of this queue new clone 89 | * @returns newQueue new queue with items from old queue 90 | */ 91 | drain(): TraverseQueue<T> { 92 | const newQueue = new TraverseQueue<T>(); 93 | newQueue._ = newQueue._.concat(this._); 94 | this._ = []; 95 | return newQueue 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/fixtures/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./with-joins-and-splits" 2 | export * from "./with-joins-and-splits-one-root" 3 | -------------------------------------------------------------------------------- /src/fixtures/with-joins-and-splits-one-root.ts: -------------------------------------------------------------------------------- 1 | export const withJoinsAndSplitsFixtureOneRoot = [ 2 | { 3 | id: "A", 4 | next: ["B"], 5 | payload: { 6 | exist: true 7 | } 8 | }, 9 | { 10 | id: "U", 11 | next: ["G"], 12 | payload: { 13 | exist: true 14 | } 15 | }, 16 | { 17 | id: "B", 18 | next: ["C", "D", "E", "F", "M"], 19 | payload: { 20 | exist: true 21 | } 22 | }, 23 | { 24 | id: "C", 25 | next: ["G"], 26 | payload: { 27 | exist: true 28 | } 29 | }, 30 | { 31 | id: "D", 32 | next: ["H"], 33 | payload: { 34 | exist: true 35 | } 36 | }, 37 | { 38 | id: "E", 39 | next: ["H"], 40 | payload: { 41 | exist: true 42 | } 43 | }, 44 | { 45 | id: "F", 46 | next: ["N", "O"], 47 | payload: { 48 | exist: true 49 | } 50 | }, 51 | { 52 | id: "N", 53 | next: ["I"], 54 | payload: { 55 | exist: true 56 | } 57 | }, 58 | { 59 | id: "O", 60 | next: ["P"], 61 | payload: { 62 | exist: true 63 | } 64 | }, 65 | { 66 | id: "P", 67 | next: ["I"], 68 | payload: { 69 | exist: true 70 | } 71 | }, 72 | { 73 | id: "M", 74 | next: ["L"], 75 | payload: { 76 | exist: true 77 | } 78 | }, 79 | { 80 | id: "G", 81 | next: ["I"], 82 | payload: { 83 | exist: true 84 | } 85 | }, 86 | { 87 | id: "H", 88 | next: ["J"], 89 | payload: { 90 | exist: true 91 | } 92 | }, 93 | { 94 | id: "I", 95 | next: [], 96 | payload: { 97 | exist: true 98 | } 99 | }, 100 | { 101 | id: "J", 102 | next: ["K"], 103 | payload: { 104 | exist: true 105 | } 106 | }, 107 | { 108 | id: "K", 109 | next: ["L"], 110 | payload: { 111 | exist: true 112 | } 113 | }, 114 | { 115 | id: "L", 116 | next: [], 117 | payload: { 118 | exist: true 119 | } 120 | } 121 | ] 122 | -------------------------------------------------------------------------------- /src/fixtures/with-joins-and-splits.ts: -------------------------------------------------------------------------------- 1 | export const withJoinsAndSplitsFixture = [ 2 | { 3 | id: "A", 4 | next: ["B"], 5 | payload: { 6 | exist: true 7 | } 8 | }, 9 | { 10 | id: "U", 11 | next: ["G"], 12 | payload: { 13 | exist: true 14 | } 15 | }, 16 | { 17 | id: "B", 18 | next: ["C", "D", "E", "F", "M"], 19 | payload: { 20 | exist: true 21 | } 22 | }, 23 | { 24 | id: "C", 25 | next: ["G"], 26 | payload: { 27 | exist: true 28 | } 29 | }, 30 | { 31 | id: "D", 32 | next: ["H"], 33 | payload: { 34 | exist: true 35 | } 36 | }, 37 | { 38 | id: "E", 39 | next: ["H"], 40 | payload: { 41 | exist: true 42 | } 43 | }, 44 | { 45 | id: "F", 46 | next: ["N", "O"], 47 | payload: { 48 | exist: true 49 | } 50 | }, 51 | { 52 | id: "N", 53 | next: ["I"], 54 | payload: { 55 | exist: true 56 | } 57 | }, 58 | { 59 | id: "O", 60 | next: ["P"], 61 | payload: { 62 | exist: true 63 | } 64 | }, 65 | { 66 | id: "P", 67 | next: ["I"], 68 | payload: { 69 | exist: true 70 | } 71 | }, 72 | { 73 | id: "M", 74 | next: ["L"], 75 | payload: { 76 | exist: true 77 | } 78 | }, 79 | { 80 | id: "G", 81 | next: ["I"], 82 | payload: { 83 | exist: true 84 | } 85 | }, 86 | { 87 | id: "H", 88 | next: ["J"], 89 | payload: { 90 | exist: true 91 | } 92 | }, 93 | { 94 | id: "I", 95 | next: [], 96 | payload: { 97 | exist: true 98 | } 99 | }, 100 | { 101 | id: "J", 102 | next: ["K"], 103 | payload: { 104 | exist: true 105 | } 106 | }, 107 | { 108 | id: "K", 109 | next: ["L"], 110 | payload: { 111 | exist: true 112 | } 113 | }, 114 | { 115 | id: "L", 116 | next: [], 117 | payload: { 118 | exist: true 119 | } 120 | } 121 | ] 122 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @class DirectGraph 3 | */ 4 | 5 | import * as React from "react"; 6 | import { INodeInput, IMatrixNode, Graph } from "./core"; 7 | import { Graph as GraphView, ViewProps } from "./components"; 8 | 9 | type Props<T> = { 10 | list: INodeInput<T>[]; 11 | cellSize: number; 12 | padding: number; 13 | }; 14 | 15 | export { INodeInput, IMatrixNode } from "./core"; 16 | export type GraphProps<T> = Props<T> & ViewProps<T>; 17 | 18 | type GraphViewData<T> = { 19 | nodesMap: { [id: string]: IMatrixNode<T> }; 20 | widthInCells: number; 21 | heightInCells: number; 22 | }; 23 | 24 | export default class DirectGraph<T> extends React.Component<GraphProps<T>> { 25 | getNodesMap = (list: INodeInput<T>[]): GraphViewData<T> => { 26 | const graph = new Graph(list); 27 | const mtx = graph.traverse(); 28 | return { 29 | nodesMap: mtx.normalize(), 30 | widthInCells: mtx.width, 31 | heightInCells: mtx.height 32 | }; 33 | }; 34 | 35 | render() { 36 | const { list, ...viewProps } = this.props; 37 | const dataProps = this.getNodesMap(list); 38 | 39 | return <GraphView {...dataProps} {...viewProps} />; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | import { Graph } from "./core" 2 | import { 3 | withJoinsAndSplitsFixture, 4 | withJoinsAndSplitsFixtureOneRoot 5 | } from "./fixtures" 6 | 7 | describe("Graph traverse", () => { 8 | describe(`with one node`, () => { 9 | const graph = new Graph([{ id: "A", next: [], payload: null }]) 10 | const mtx = graph.traverse() 11 | const nodeMap = mtx.normalize() 12 | it("returns 2d matrix", () => { 13 | expect(mtx).toBeTruthy() 14 | }) 15 | it("should return non empty matrix", () => { 16 | expect(mtx.height).toBeGreaterThan(0) 17 | expect(mtx.width).toBeGreaterThan(0) 18 | }) 19 | it("should contain matrix with all nodes", () => { 20 | expect(nodeMap).toBeTruthy() 21 | expect(nodeMap).toHaveProperty("A") 22 | }) 23 | }) 24 | describe(`with empty`, () => { 25 | const graph = new Graph([]) 26 | const mtx = graph.traverse() 27 | const nodeMap = mtx.normalize() 28 | it("returns 2d matrix", () => { 29 | expect(mtx).toBeTruthy() 30 | }) 31 | it("should return empty matrix", () => { 32 | expect(mtx.height).toEqual(0) 33 | expect(mtx.width).toEqual(0) 34 | }) 35 | it("should contain empty matrix values", () => { 36 | expect(nodeMap).toBeTruthy() 37 | expect(Object.values(nodeMap)).toHaveLength(0) 38 | }) 39 | }); 40 | [withJoinsAndSplitsFixture, withJoinsAndSplitsFixtureOneRoot].forEach( 41 | (fixture, i) => { 42 | describe(`with joins and splits version ${i + 1}`, () => { 43 | const graph = new Graph(fixture) 44 | const mtx = graph.traverse() 45 | const nodeMap = mtx.normalize() 46 | it("returns 2d matrix", () => { 47 | expect(mtx).toBeTruthy() 48 | }) 49 | it("should return non empty matrix", () => { 50 | expect(mtx.height).toBeGreaterThan(0) 51 | expect(mtx.width).toBeGreaterThan(0) 52 | }) 53 | it("should contain matrix with all nodes", () => { 54 | expect(nodeMap).toBeTruthy() 55 | fixture.forEach(node => { 56 | expect(nodeMap).toHaveProperty(node.id) 57 | }) 58 | }) 59 | it("should contain anchors for splits", () => { 60 | const splits = fixture.filter(node => node.next.length > 1) 61 | splits.forEach(split => { 62 | const [, ...outcomesWithAnchors] = [split.next] 63 | outcomesWithAnchors.forEach(outcomeId => { 64 | const anchorId = `${split}-${outcomeId}` 65 | const anchor = nodeMap[anchorId] 66 | expect(anchor).toBeTruthy() 67 | expect(anchor.isAnchor).toBeTruthy() 68 | expect(anchor.anchorFrom).toEqual(split.id) 69 | expect(anchor.anchorTo).toEqual(outcomeId) 70 | }) 71 | }) 72 | }) 73 | it("should contain anchors for joins", () => { 74 | const { joins } = fixture.reduce( 75 | ( 76 | { 77 | joins, 78 | _map 79 | }: { 80 | joins: string[] 81 | _map: { [_id: string]: boolean } 82 | }, 83 | node 84 | ) => { 85 | node.next.forEach(outcomeId => { 86 | if (_map[outcomeId]) { 87 | joins.push(outcomeId) 88 | } else { 89 | _map[outcomeId] = true 90 | } 91 | }) 92 | return { joins, _map } 93 | }, 94 | { joins: [], _map: {} } 95 | ) 96 | joins.forEach(joinId => { 97 | const join = fixture.find(n => n.id === joinId) 98 | if (!join) { 99 | throw new Error(`Join ${joinId} not found`) 100 | } 101 | const incomes = fixture.filter(n => 102 | n.next.includes(joinId) 103 | ) 104 | let nonAnchorJoinPassed = false 105 | incomes.forEach(income => { 106 | const anchorId = `${income.id}-${join.id}` 107 | const anchor = nodeMap[anchorId] 108 | if (!anchor && !nonAnchorJoinPassed) { 109 | nonAnchorJoinPassed = true 110 | return 111 | } 112 | expect(anchor).toBeTruthy() 113 | expect(anchor.isAnchor).toBeTruthy() 114 | expect(anchor.anchorFrom).toEqual(income.id) 115 | expect(anchor.anchorTo).toEqual(join.id) 116 | }) 117 | }) 118 | }) 119 | }) 120 | } 121 | ) 122 | }) 123 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Default CSS definition for typescript, 3 | * will be overridden with file-specific definitions by rollup 4 | */ 5 | declare module "*.css" { 6 | const content: { [className: string]: string } 7 | export default content 8 | } 9 | 10 | interface SvgrComponent 11 | extends React.StatelessComponent<React.SVGAttributes<SVGElement>> {} 12 | 13 | declare module "*.svg" { 14 | const svgUrl: string 15 | const svgComponent: SvgrComponent 16 | export default svgUrl 17 | export { svgComponent as ReactComponent } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import {AnchorMargin, IMatrixNode, INodeOutput} from "../core"; 2 | import {GraphEventFunc} from "../components/element"; 3 | import * as React from "react"; 4 | 5 | export enum VectorDirection { 6 | Top = "top", 7 | Bottom = "bottom", 8 | Right = "right", 9 | Left = "left" 10 | } 11 | 12 | const getXVertexDirection = (x1: number, x2: number): VectorDirection => { 13 | return x1 < x2 ? VectorDirection.Right : VectorDirection.Left; 14 | }; 15 | 16 | const getYVertexDirection = (y1: number, y2: number): VectorDirection => { 17 | return y1 < y2 ? VectorDirection.Bottom : VectorDirection.Top; 18 | }; 19 | 20 | export const getEdgeMargins = <T>( 21 | node: INodeOutput<T>, 22 | income: INodeOutput<T> 23 | ): AnchorMargin[] => { 24 | let result = [AnchorMargin.None, AnchorMargin.None]; 25 | switch (true) { 26 | case node.isAnchor && income.isAnchor: 27 | result = [ 28 | node.anchorMargin as AnchorMargin, 29 | income.anchorMargin as AnchorMargin 30 | ]; 31 | break; 32 | case node.isAnchor: 33 | result = [ 34 | node.anchorMargin as AnchorMargin, 35 | node.anchorMargin as AnchorMargin 36 | ]; 37 | break; 38 | case income.isAnchor: 39 | result = [ 40 | income.anchorMargin as AnchorMargin, 41 | income.anchorMargin as AnchorMargin 42 | ]; 43 | break; 44 | } 45 | return result; 46 | }; 47 | 48 | export const getVectorDirection = ( 49 | x1: number, 50 | y1: number, 51 | x2: number, 52 | y2: number 53 | ): VectorDirection => { 54 | return y1 === y2 55 | ? getXVertexDirection(x1, x2) 56 | : getYVertexDirection(y1, y2); 57 | }; 58 | 59 | const getMargin = ( 60 | margin: AnchorMargin, 61 | padding: number, 62 | cellSize: number 63 | ): number => { 64 | if (margin === AnchorMargin.None) return 0; 65 | const size = Math.round((cellSize - padding * 2) * 0.15); 66 | return margin === AnchorMargin.Left ? -size : size; 67 | }; 68 | 69 | export const getCellCenter = ( 70 | cellSize: number, 71 | padding: number, 72 | cellX: number, 73 | cellY: number, 74 | margin: AnchorMargin 75 | ): number[] => { 76 | const outset = getMargin(margin, padding, cellSize); 77 | const x = cellX * cellSize + cellSize * 0.5 + outset; 78 | const y = cellY * cellSize + cellSize * 0.5; 79 | return [x, y]; 80 | }; 81 | 82 | export const getCellEntry = ( 83 | direction: VectorDirection, 84 | cellSize: number, 85 | padding: number, 86 | cellX: number, 87 | cellY: number, 88 | margin: AnchorMargin 89 | ): number[] => { 90 | switch (direction) { 91 | case VectorDirection.Top: 92 | const [xTop] = getCellCenter(cellSize, padding, cellX, cellY, margin); 93 | const yTop = cellY * cellSize + padding; 94 | return [xTop, yTop]; 95 | case VectorDirection.Bottom: 96 | const [xBottom] = getCellCenter(cellSize, padding, cellX, cellY, margin); 97 | const yBottom = cellY * cellSize + (cellSize - padding); 98 | return [xBottom, yBottom]; 99 | case VectorDirection.Right: 100 | const [, yRight] = getCellCenter(cellSize, padding, cellX, cellY, margin); 101 | const xRight = cellX * cellSize + (cellSize - padding); 102 | return [xRight, yRight]; 103 | case VectorDirection.Left: 104 | const [, yLeft] = getCellCenter(cellSize, padding, cellX, cellY, margin); 105 | const xLeft = cellX * cellSize + padding; 106 | return [xLeft, yLeft]; 107 | } 108 | }; 109 | 110 | function gen4(): string { 111 | return Math.random() 112 | .toString(16) 113 | .slice(-4); 114 | } 115 | 116 | export function uniqueId(prefix: string): string { 117 | return (prefix || "").concat([gen4(), gen4(), gen4(), gen4()].join("-")); 118 | } 119 | 120 | export function getSize(cellSize: number, padding: number): number { 121 | return cellSize - padding * 2; 122 | } 123 | 124 | export function getCoords<T>( 125 | cellSize: number, 126 | padding: number, 127 | node: IMatrixNode<T> 128 | ): number[] { 129 | return [node.x * cellSize + padding, node.y * cellSize + padding]; 130 | } 131 | 132 | export function checkAnchorRenderIncomes<T>(node: IMatrixNode<T>) { 133 | if (node.renderIncomes.length != 1) 134 | throw new Error( 135 | `Anchor has non 1 income: ${ 136 | node.id 137 | }. Incomes ${node.renderIncomes.join(",")}` 138 | ); 139 | } 140 | 141 | export const getAllIncomes = <T>( 142 | node: IMatrixNode<T>, 143 | nodesMap: { [id: string]: IMatrixNode<T> } 144 | ): IMatrixNode<T>[] => node.renderIncomes.map(id => nodesMap[id]); 145 | 146 | export const wrapEventHandler = <T>( 147 | cb: GraphEventFunc<T>, 148 | node: IMatrixNode<T>, 149 | incomes: IMatrixNode<T>[] 150 | ): ((e: React.MouseEvent) => void) => (e: React.MouseEvent) => cb(e, node, incomes); 151 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build", 4 | "module": "esnext", 5 | "target": "es5", 6 | "lib": ["es6", "dom", "es2016", "es2017"], 7 | "sourceMap": true, 8 | "allowJs": false, 9 | "jsx": "react", 10 | "declaration": true, 11 | "moduleResolution": "node", 12 | "forceConsistentCasingInFileNames": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | "suppressImplicitAnyIndexErrors": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true 20 | }, 21 | "include": ["src"], 22 | "exclude": ["node_modules", "build", "dist", "example", "rollup.config.js"] 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } --------------------------------------------------------------------------------