├── .babelrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── .storybook ├── base-component.jsx ├── main.js ├── preview.js ├── stories │ ├── advanced │ │ ├── directed-graph.stories.jsx │ │ ├── version-control.stories.jsx │ │ └── visual-programming-graph.stories.jsx │ └── basic │ │ ├── directed-graph.stories.jsx │ │ ├── node-attribute-error.stories.jsx │ │ ├── node-attributes.stories.jsx │ │ ├── styling.stories.jsx │ │ └── visual-programming-graph.stories.jsx └── test.json ├── .stylelintrc.json ├── LICENSE ├── README.md ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── renovate.json ├── rollup.config.mjs ├── src ├── assets │ ├── source-marker-active.png │ ├── source-marker-deactive.png │ └── source-marker-default.png ├── constants.js ├── graph-view-edge.js ├── graph-view-node.js ├── graph-view.js ├── index.js ├── joint-graph.js ├── joint-shape-node.js ├── lib │ ├── joint.scss │ ├── layout.scss │ ├── material.scss │ └── vec2.js ├── selected-item.js ├── styles │ ├── index.js │ └── style.scss └── util.js ├── styles └── package.json ├── tsconfig.json ├── typedoc.json └── utils └── typedoc └── favicon.ico /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceType": "unambiguous", 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "chrome": 100, 9 | "safari": 15, 10 | "firefox": 91 11 | } 12 | } 13 | ], 14 | "@babel/preset-react" 15 | ], 16 | "plugins": [] 17 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 10 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Node.js 18.x 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 22.x 24 | 25 | - name: Install dependencies 26 | run: npm clean-install --progress=false --no-fund 27 | 28 | - name: Build PCUI Graph 29 | run: npm run build 30 | 31 | - name: Build Types 32 | run: npm run build:types 33 | 34 | - name: Run Publint 35 | run: npm run lint:package 36 | 37 | lint: 38 | name: Lint 39 | runs-on: ubuntu-latest 40 | timeout-minutes: 10 41 | 42 | steps: 43 | - name: Checkout code 44 | uses: actions/checkout@v4 45 | 46 | - name: Setup Node.js 18.x 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: 22.x 50 | 51 | - name: Install dependencies 52 | run: npm clean-install --progress=false --no-fund 53 | 54 | - name: Run ESLint 55 | run: npm run lint 56 | 57 | lint-styles: 58 | name: Lint Styles 59 | runs-on: ubuntu-latest 60 | timeout-minutes: 10 61 | 62 | steps: 63 | - name: Checkout code 64 | uses: actions/checkout@v4 65 | 66 | - name: Setup Node.js 18.x 67 | uses: actions/setup-node@v4 68 | with: 69 | node-version: 22.x 70 | 71 | - name: Install dependencies 72 | run: npm clean-install --progress=false --no-fund 73 | 74 | - name: Run Stylelint 75 | run: npm run lint:styles 76 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: "Deploy" 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | # Allow only one concurrent deployment 14 | concurrency: 15 | group: "pages" 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | deploy: 20 | runs-on: ubuntu-latest 21 | environment: 22 | name: github-pages 23 | url: ${{ steps.deployment.outputs.page_url }} 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | 28 | - name: Setup Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: '22' 32 | 33 | - name: Install dependencies 34 | run: npm ci 35 | 36 | - name: Build 37 | run: npm run build 38 | 39 | - name: Build Storybook 40 | run: npm run build:storybook 41 | 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v5 44 | 45 | - name: Upload artifact 46 | uses: actions/upload-pages-artifact@v3 47 | with: 48 | path: '.' 49 | 50 | - name: Deploy to GitHub Pages 51 | id: deployment 52 | uses: actions/deploy-pages@v4 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | /docs 14 | /styles/dist 15 | /types 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /.storybook/base-component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Graph from '../src/index.js'; 3 | import '@playcanvas/pcui/styles'; 4 | import '../src/styles/index.js'; 5 | 6 | class BaseComponent extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | } 10 | attachElement = (nodeElement, containerElement) => { 11 | if (!nodeElement) return; 12 | this.element = new Graph(this.props.schema, { 13 | ...this.props.options, 14 | dom: nodeElement, 15 | }); 16 | if (this.onClick) { 17 | this.element.on('click', this.onClick); 18 | } 19 | if (this.onChange) { 20 | this.element.on('change', this.onChange); 21 | } 22 | if (this.props.parent) { 23 | this.element.parent = this.props.parent; 24 | } 25 | } 26 | getPropertyDescriptor = (obj, prop) => { 27 | let desc; 28 | do { 29 | desc = Object.getOwnPropertyDescriptor(obj, prop); 30 | } while (!desc && (obj = Object.getPrototypeOf(obj))); 31 | return desc; 32 | } 33 | 34 | componentDidMount() { 35 | if (this.link) { 36 | this.element.link(this.link.observer, this.link.path); 37 | } 38 | } 39 | 40 | componentDidUpdate(prevProps) { 41 | Object.keys(this.props).forEach(prop => { 42 | var propDescriptor = this.getPropertyDescriptor(this.element, prop); 43 | if (propDescriptor && propDescriptor.set) { 44 | this.element[prop] = this.props[prop]; 45 | } 46 | }); 47 | if (prevProps.link !== this.props.link) { 48 | this.element.link(this.props.link.observer, this.props.link.path); 49 | } 50 | } 51 | 52 | render() { 53 | return
54 | } 55 | } 56 | 57 | export default BaseComponent; 58 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | stories: ['./**/*.stories.@(js|jsx|ts|tsx|mdx)'], 3 | 4 | addons: [ 5 | '@storybook/addon-essentials' 6 | ], 7 | 8 | webpackFinal: async (config, { configType }) => { 9 | config.module.rules = config.module.rules.filter(rule => { 10 | if (!rule.test) return true; 11 | return !rule.test.test(".scss"); 12 | }); 13 | config.module.rules.push({ 14 | test: /\.(js|jsx)$/, 15 | exclude: /node_modules/, 16 | use: { 17 | loader: 'babel-loader', 18 | options: { 19 | presets: ['@babel/preset-env', '@babel/preset-react'] 20 | } 21 | } 22 | }); 23 | config.module.rules.push({ 24 | test: /\.scss$/, 25 | use: [ 26 | 'style-loader', 27 | 'css-loader', 28 | 'sass-loader' 29 | ], 30 | }); 31 | 32 | config.resolve.extensions.push('.js', '.jsx', '.ts', '.tsx'); 33 | 34 | return config; 35 | }, 36 | 37 | framework: { 38 | name: '@storybook/react-webpack5', 39 | options: {} 40 | }, 41 | 42 | docs: {} 43 | }; 44 | 45 | export default config; 46 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | const preview = { 2 | parameters: { 3 | backgrounds: { 4 | default: 'playcanvas', 5 | values: [ 6 | { 7 | name: 'playcanvas', 8 | value: '#374346' 9 | }, 10 | { 11 | name: 'white', 12 | value: '#FFFFFF' 13 | } 14 | ] 15 | }, 16 | controls: { expanded: true } 17 | }, 18 | 19 | tags: ['autodocs'] 20 | }; 21 | 22 | export default preview; 23 | -------------------------------------------------------------------------------- /.storybook/stories/advanced/directed-graph.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GRAPH_ACTIONS } from '../../../src/constants'; 3 | import Graph from '../../base-component'; 4 | 5 | export default { 6 | title: 'Advanced/Directed Graph', 7 | component: Graph, 8 | argTypes: { 9 | // Define the args that you want to be editable in the Storybook UI 10 | } 11 | }; 12 | 13 | const GRAPH_ENUM = { 14 | NODE: { 15 | STATE: 0, 16 | }, 17 | EDGE: { 18 | EDGE: 0, 19 | } 20 | }; 21 | 22 | const GRAPH_SCHEMA = { 23 | nodes: { 24 | [GRAPH_ENUM.NODE.STATE]: { 25 | name: 'state', 26 | fill: 'rgb(54, 67, 70, 0.8)', 27 | stroke: '#20292b', 28 | icon: '', 29 | iconColor: '#FFFFFF', 30 | contextMenuItems: [ 31 | { 32 | text: 'Add transition', 33 | action: GRAPH_ACTIONS.ADD_EDGE, 34 | edgeType: GRAPH_ENUM.EDGE.EDGE 35 | }, 36 | { 37 | text: 'Delete state', 38 | action: GRAPH_ACTIONS.DELETE_NODE 39 | } 40 | ] 41 | } 42 | }, 43 | edges: { 44 | [GRAPH_ENUM.EDGE.EDGE]: { 45 | stroke: '#0379EE', 46 | strokeWidth: 2, 47 | targetMarkerStroke: '#0379EE', 48 | targetMarker: true, 49 | from: [ 50 | GRAPH_ENUM.NODE.STATE, 51 | GRAPH_ENUM.NODE.START_STATE, 52 | GRAPH_ENUM.NODE.DEFAULT_STATE 53 | ], 54 | to: [ 55 | GRAPH_ENUM.NODE.STATE, 56 | GRAPH_ENUM.NODE.DEFAULT_STATE, 57 | GRAPH_ENUM.NODE.END_STATE 58 | ], 59 | contextMenuItems: [ 60 | { 61 | text: 'Delete edge', 62 | action: GRAPH_ACTIONS.DELETE_EDGE 63 | } 64 | ] 65 | } 66 | } 67 | }; 68 | 69 | var GRAPH_DATA = { 70 | nodes: { 71 | 1234: { 72 | id: 1234, 73 | nodeType: GRAPH_ENUM.NODE.STATE, 74 | name: 'NODE A', 75 | posX: 100, 76 | posY: 100 77 | }, 78 | 1235: { 79 | id: 1235, 80 | nodeType: GRAPH_ENUM.NODE.STATE, 81 | name: 'NODE B', 82 | posX: 100, 83 | posY: 300 84 | }, 85 | 1236: { 86 | id: 1236, 87 | nodeType: GRAPH_ENUM.NODE.STATE, 88 | name: 'NODE C', 89 | posX: 300, 90 | posY: 200 91 | } 92 | }, 93 | edges: { 94 | '1234-1235': { 95 | edgeType: GRAPH_ENUM.EDGE.EDGE, 96 | from: 1234, 97 | to: 1235 98 | }, 99 | '1235-1236': { 100 | edgeType: GRAPH_ENUM.EDGE.EDGE, 101 | from: 1235, 102 | to: 1236 103 | }, 104 | '1236-1235': { 105 | edgeType: GRAPH_ENUM.EDGE.EDGE, 106 | from: 1236, 107 | to: 1235 108 | } 109 | } 110 | }; 111 | 112 | const GRAPH_CONTEXT_MENU_ITEMS_ITEMS = [ 113 | { 114 | text: 'Add new state', 115 | action: GRAPH_ACTIONS.ADD_NODE, 116 | nodeType: GRAPH_ENUM.NODE.STATE, 117 | attributes: { 118 | name: 'New state', 119 | speed: 1.0, 120 | loop: false 121 | } 122 | } 123 | ]; 124 | 125 | // Template function 126 | const Template = (args) => ; 127 | 128 | // Default story using the template 129 | export const DirectedGraphExample = Template.bind({}); 130 | 131 | // Default args for the story 132 | DirectedGraphExample.args = { 133 | initialData: GRAPH_DATA, 134 | contextMenuItems: GRAPH_CONTEXT_MENU_ITEMS_ITEMS, 135 | passiveUIEvents: false, 136 | includeFonts: true, 137 | incrementNodeNames: true, 138 | adjustVertices: true, 139 | defaultStyles: { 140 | background: { 141 | color: '#20292B', 142 | gridSize: 10 143 | }, 144 | edge: { 145 | connectionStyle: 'default' 146 | } 147 | } 148 | }; 149 | 150 | document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%'); 151 | document.body.setAttribute('style', 'margin: 0px; padding: 0px;'); 152 | 153 | setTimeout(() => { 154 | Object.keys(GRAPH_ACTIONS).forEach((key) => { 155 | const graphAction = GRAPH_ACTIONS[key]; 156 | document.querySelector('.pcui-graph').ui.on(graphAction, (data) => { 157 | console.log(graphAction, data); 158 | }); 159 | }); 160 | }, 500); -------------------------------------------------------------------------------- /.storybook/stories/advanced/version-control.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Graph from '../../base-component'; 3 | 4 | export default { 5 | title: 'Advanced/Version Control Graph', 6 | component: Graph, 7 | argTypes: { 8 | // Define the args that you want to be editable in the Storybook UI 9 | } 10 | }; 11 | 12 | const GRAPH_SCHEMA = { 13 | nodes: { 14 | 0: { 15 | fill: '#ead1db', 16 | stroke: '#965070', 17 | strokeSelected: '#965070', 18 | strokeHover: '#965070', 19 | textColor: '#20292b', 20 | baseHeight: 60, 21 | baseWidth: 150, 22 | textAlignMiddle: true, 23 | includeIcon: false 24 | }, 25 | 1: { 26 | fill: '#fbe5cd', 27 | stroke: '#ff574b', 28 | strokeSelected: '#ff574b', 29 | strokeHover: '#ff574b', 30 | textColor: '#20292b', 31 | baseHeight: 60, 32 | baseWidth: 150, 33 | textAlignMiddle: true, 34 | includeIcon: false 35 | }, 36 | 2: { 37 | fill: '#d0e1f2', 38 | stroke: '#4d7cd7', 39 | strokeSelected: '#4d7cd7', 40 | strokeHover: '#4d7cd7', 41 | textColor: '#20292b', 42 | baseHeight: 60, 43 | baseWidth: 150, 44 | textAlignMiddle: true, 45 | includeIcon: false 46 | }, 47 | 3: { 48 | fill: '#d9ead3', 49 | stroke: '#43fb39', 50 | strokeSelected: '#43fb39', 51 | strokeHover: '#43fb39', 52 | textColor: '#20292b', 53 | baseHeight: 60, 54 | baseWidth: 150, 55 | textAlignMiddle: true, 56 | includeIcon: false 57 | }, 58 | }, 59 | edges: { 60 | 0: { 61 | from: [ 62 | 0, 63 | ], 64 | to: [ 65 | 0, 1, 2, 3 66 | ], 67 | stroke: '#965070', 68 | strokeWidth: 3, 69 | connectionStyle: 'smoothInOut' 70 | }, 71 | 1: { 72 | from: [ 73 | 1, 74 | ], 75 | to: [ 76 | 0, 1, 2, 3 77 | ], 78 | stroke: '#ff574b', 79 | strokeWidth: 3, 80 | connectionStyle: 'smoothInOut' 81 | }, 82 | 2: { 83 | from: [ 84 | 2, 85 | ], 86 | to: [ 87 | 0, 1, 2, 3 88 | ], 89 | stroke: '#4d7cd7', 90 | strokeWidth: 3, 91 | connectionStyle: 'smoothInOut' 92 | }, 93 | 3: { 94 | from: [ 95 | 3, 96 | ], 97 | to: [ 98 | 0, 1, 2, 3 99 | ], 100 | stroke: '#43fb39', 101 | strokeWidth: 3, 102 | connectionStyle: 'smoothInOut' 103 | } 104 | } 105 | }; 106 | 107 | const GRAPH_DATA = { 108 | nodes: {}, 109 | edges: { 110 | '02-12': { 111 | from: '02', 112 | to: '12', 113 | edgeType: 0 114 | }, 115 | '17-04': { 116 | from: '17', 117 | to: '04', 118 | edgeType: 1 119 | }, 120 | '13-30': { 121 | from: '13', 122 | to: '30', 123 | edgeType: 1 124 | }, 125 | '24-32': { 126 | from: '24', 127 | to: '32', 128 | edgeType: 2 129 | }, 130 | '25-14': { 131 | from: '25', 132 | to: '14', 133 | edgeType: 2 134 | }, 135 | '36-26': { 136 | from: '36', 137 | to: '26', 138 | edgeType: 3 139 | } 140 | } 141 | }; 142 | 143 | [ 144 | [ 145 | 'Branch 1, Commit 5\nAug 23, 21 zpaul', 146 | 'Branch 1, Commit 4\nAug 23, 21 zpaul', 147 | 'Branch 1, Commit 3\nAug 23, 21 zpaul', 148 | 'Branch 1, Commit 2\nAug 23, 21 zpaul', 149 | 'Branch 1, Commit 1\nAug 23, 21 zpaul' 150 | ], 151 | [ 152 | 'Branch 2, Commit 8\nAug 23, 21 zpaul', 153 | 'Branch 2, Commit 7\nAug 23, 21 zpaul', 154 | 'Branch 2, Commit 6\nAug 23, 21 zpaul', 155 | 'Branch 2, Commit 5\nAug 23, 21 zpaul', 156 | 'Branch 2, Commit 4\nAug 23, 21 zpaul', 157 | 'Branch 2, Commit 3\nAug 23, 21 zpaul', 158 | 'Branch 2, Commit 2\nAug 23, 21 zpaul', 159 | 'Branch 2, Commit 1\nAug 23, 21 zpaul' 160 | ], 161 | [ 162 | 'Branch 3, Commit 7\nAug 23, 21 zpaul', 163 | 'Branch 3, Commit 6\nAug 23, 21 zpaul', 164 | 'Branch 3, Commit 5\nAug 23, 21 zpaul', 165 | 'Branch 3, Commit 4\nAug 23, 21 zpaul', 166 | 'Branch 3, Commit 3\nAug 23, 21 zpaul', 167 | 'Branch 3, Commit 2\nAug 23, 21 zpaul', 168 | 'Branch 3, Commit 1\nAug 23, 21 zpaul' 169 | ], 170 | [ 171 | 'Branch 4, Commit 7\nAug 23, 21 zpaul', 172 | 'Branch 4, Commit 6\nAug 23, 21 zpaul', 173 | 'Branch 4, Commit 5\nAug 23, 21 zpaul', 174 | 'Branch 4, Commit 4\nAug 23, 21 zpaul', 175 | 'Branch 4, Commit 3\nAug 23, 21 zpaul', 176 | 'Branch 4, Commit 2\nAug 23, 21 zpaul', 177 | 'Branch 4, Commit 1\nAug 23, 21 zpaul' 178 | ] 179 | ].forEach((commits, i) => { 180 | commits.forEach((commit, j) => { 181 | GRAPH_DATA.nodes[`${i}${j}`] = { 182 | id: `${i}${j}`, 183 | name: commit, 184 | nodeType: i, 185 | posX: 250 * i + 50, 186 | posY: 100 * j + 100, 187 | marker: ['17', '31', '36'].includes(`${i}${j}`) 188 | }; 189 | if (j === 0) return; 190 | GRAPH_DATA.edges[`${i}${j - 1}-${i}${j}`] = { 191 | to: `${i}${j - 1}`, 192 | from: `${i}${j}`, 193 | edgeType: i 194 | }; 195 | }); 196 | }); 197 | 198 | // Template function 199 | const Template = (args) => ; 200 | 201 | // Default story using the template 202 | export const VersionControlGraphExample = Template.bind({}); 203 | 204 | // Default args for the story 205 | VersionControlGraphExample.args = { 206 | initialData: GRAPH_DATA, 207 | passiveUIEvents: false, 208 | includeFonts: true, 209 | defaultStyles: { 210 | initialScale: 0.75, 211 | background: { 212 | color: '#20292B', 213 | gridSize: 1 214 | }, 215 | edge: { 216 | connectionStyle: 'default', 217 | targetMarker: true, 218 | sourceMarker: true 219 | } 220 | }, 221 | readOnly: true 222 | }; 223 | 224 | document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%'); 225 | document.body.setAttribute('style', 'margin: 0px; padding: 0px;'); 226 | 227 | setTimeout(() => { 228 | const graph = document.querySelector('.pcui-graph').ui; 229 | graph.on('EVENT_SELECT_NODE', ({node}) => { 230 | if (node.id === '00') { 231 | graph.createNode({ 232 | id: `4848583`, 233 | name: 'Branch 1, Commit 6\nAug 23, 21 zpaul', 234 | nodeType: 0, 235 | posX: node.posX, 236 | posY: node.posY - 100 237 | }); 238 | graph.createEdge({ 239 | to: '4848583', 240 | from :'00', 241 | edgeType: 0 242 | }, `00-${4848583}`); 243 | } 244 | }); 245 | }, 0); -------------------------------------------------------------------------------- /.storybook/stories/advanced/visual-programming-graph.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GRAPH_ACTIONS } from '../../../src/constants'; 3 | import Graph from '../../base-component'; 4 | 5 | export default { 6 | title: 'Advanced/Visual Programming Graph', 7 | component: Graph, 8 | argTypes: { 9 | // Define the args that you want to be editable in the Storybook UI 10 | } 11 | }; 12 | 13 | var GRAPH_ENUM = { 14 | NODE: { 15 | VARIABLE_FLOAT: 0, 16 | MULTIPLY: 1, 17 | OUT: 2, 18 | ADD: 3, 19 | SINE: 4, 20 | TEXTURE: 5, 21 | VARIABLE_VEC_2: 6, 22 | }, 23 | EDGE: { 24 | FLOAT: 1, 25 | VEC_2: 2, 26 | VEC_3: 3, 27 | VEC_4: 4, 28 | MATRIX: 5 29 | } 30 | }; 31 | 32 | var GRAPH_SCHEMA = { 33 | nodes: { 34 | [GRAPH_ENUM.NODE.VARIABLE_FLOAT]: { 35 | name: 'Variable Float', 36 | fill: 'rgb(54, 67, 70, 0.8)', 37 | stroke: '#20292b', 38 | contextMenuItems: [], 39 | outPorts: [ 40 | { 41 | name: 'output', 42 | type: GRAPH_ENUM.EDGE.FLOAT 43 | } 44 | ] 45 | }, 46 | [GRAPH_ENUM.NODE.VARIABLE_VEC_2]: { 47 | name: 'Variable Vec2', 48 | fill: 'rgb(54, 67, 70, 0.8)', 49 | stroke: '#20292b', 50 | contextMenuItems: [], 51 | outPorts: [ 52 | { 53 | name: 'output', 54 | type: GRAPH_ENUM.EDGE.VEC_2 55 | } 56 | ] 57 | }, 58 | [GRAPH_ENUM.NODE.MULTIPLY]: { 59 | name: 'Multiply', 60 | fill: 'rgb(54, 67, 70, 0.8)', 61 | stroke: '#20292b', 62 | contextMenuItems: [], 63 | inPorts: [ 64 | { 65 | name: 'left', 66 | type: GRAPH_ENUM.EDGE.FLOAT 67 | }, 68 | { 69 | name: 'right', 70 | type: GRAPH_ENUM.EDGE.FLOAT 71 | } 72 | ], 73 | outPorts: [ 74 | { 75 | name: 'output', 76 | type: GRAPH_ENUM.EDGE.FLOAT 77 | } 78 | ] 79 | }, 80 | [GRAPH_ENUM.NODE.ADD]: { 81 | name: 'Add', 82 | fill: 'rgb(54, 67, 70, 0.8)', 83 | stroke: '#20292b', 84 | contextMenuItems: [ 85 | { 86 | text: 'Delete node', 87 | action: GRAPH_ACTIONS.DELETE_NODE 88 | } 89 | ], 90 | inPorts: [ 91 | { 92 | name: 'left', 93 | type: GRAPH_ENUM.EDGE.FLOAT 94 | }, 95 | { 96 | name: 'right', 97 | type: GRAPH_ENUM.EDGE.FLOAT 98 | } 99 | ], 100 | outPorts: [ 101 | { 102 | name: 'output', 103 | type: GRAPH_ENUM.EDGE.FLOAT 104 | } 105 | ] 106 | }, 107 | [GRAPH_ENUM.NODE.SINE]: { 108 | name: 'Sine', 109 | fill: 'rgb(54, 67, 70, 0.8)', 110 | stroke: '#20292b', 111 | contextMenuItems: [ 112 | { 113 | text: 'Delete node', 114 | action: GRAPH_ACTIONS.DELETE_NODE 115 | } 116 | ], 117 | inPorts: [ 118 | { 119 | name: 'input', 120 | type: GRAPH_ENUM.EDGE.FLOAT 121 | } 122 | ], 123 | outPorts: [ 124 | { 125 | name: 'output', 126 | type: GRAPH_ENUM.EDGE.FLOAT 127 | } 128 | ] 129 | }, 130 | [GRAPH_ENUM.NODE.FRAGMENT_OUTPUT]: { 131 | name: 'Fragment Output', 132 | fill: 'rgb(54, 67, 70, 0.8)', 133 | stroke: '#20292b', 134 | contextMenuItems: [], 135 | inPorts: [ 136 | { 137 | name: 'rgba', 138 | type: GRAPH_ENUM.EDGE.VEC_4 139 | }, 140 | { 141 | name: 'rgb', 142 | type: GRAPH_ENUM.EDGE.VEC_3 143 | }, 144 | { 145 | name: 'a', 146 | type: GRAPH_ENUM.EDGE.FLOAT 147 | } 148 | ] 149 | }, 150 | [GRAPH_ENUM.NODE.TEXTURE]: { 151 | name: 'Texture', 152 | fill: 'rgb(54, 67, 70, 0.8)', 153 | stroke: '#20292b', 154 | contextMenuItems: [], 155 | inPorts: [ 156 | { 157 | name: 'uv', 158 | type: GRAPH_ENUM.EDGE.VEC_2 159 | } 160 | ], 161 | outPorts: [ 162 | { 163 | name: 'rgba', 164 | type: GRAPH_ENUM.EDGE.VEC_4 165 | }, 166 | { 167 | name: 'rgb', 168 | type: GRAPH_ENUM.EDGE.VEC_3 169 | }, 170 | { 171 | name: 'r', 172 | type: GRAPH_ENUM.EDGE.FLOAT 173 | }, 174 | { 175 | name: 'g', 176 | type: GRAPH_ENUM.EDGE.FLOAT 177 | }, 178 | { 179 | name: 'b', 180 | type: GRAPH_ENUM.EDGE.FLOAT 181 | } 182 | ] 183 | } 184 | }, 185 | edges: { 186 | [GRAPH_ENUM.EDGE.FLOAT]: { 187 | stroke: '#0379EE', 188 | fill: 'rgb(54, 67, 70, 0.8)', 189 | strokeWidth: 2, 190 | targetMarker: null, 191 | contextMenuItems: [ 192 | { 193 | text: 'Delete edge', 194 | action: GRAPH_ACTIONS.DELETE_EDGE 195 | } 196 | ], 197 | }, 198 | [GRAPH_ENUM.EDGE.VEC_2]: { 199 | stroke: '#0379EE', 200 | strokeWidth: 2, 201 | targetMarker: null, 202 | contextMenuItems: [ 203 | { 204 | text: 'Delete edge', 205 | action: GRAPH_ACTIONS.DELETE_EDGE 206 | } 207 | ], 208 | }, 209 | [GRAPH_ENUM.EDGE.VEC_3]: { 210 | stroke: '#0379EE', 211 | strokeWidth: 2, 212 | targetMarker: null, 213 | contextMenuItems: [ 214 | { 215 | text: 'Delete edge', 216 | action: GRAPH_ACTIONS.DELETE_EDGE 217 | } 218 | ], 219 | }, 220 | [GRAPH_ENUM.EDGE.VEC_4]: { 221 | stroke: '#0379EE', 222 | strokeWidth: 2, 223 | targetMarker: null, 224 | contextMenuItems: [ 225 | { 226 | text: 'Delete edge', 227 | action: GRAPH_ACTIONS.DELETE_EDGE 228 | } 229 | ], 230 | }, 231 | [GRAPH_ENUM.EDGE.MATRIX]: { 232 | stroke: '#0379EE', 233 | strokeWidth: 2, 234 | targetMarker: null, 235 | contextMenuItems: [ 236 | { 237 | text: 'Delete edge', 238 | action: GRAPH_ACTIONS.DELETE_EDGE 239 | } 240 | ], 241 | } 242 | } 243 | }; 244 | 245 | var GRAPH_DATA = { 246 | nodes: { 247 | 1234: { 248 | id: 1234, 249 | nodeType: GRAPH_ENUM.NODE.VARIABLE_FLOAT, 250 | name: 'maxAlpha', 251 | posX: 100, 252 | posY: 150, 253 | attributes: { 254 | name: 'maxAlpha' 255 | } 256 | }, 257 | 1235: { 258 | id: 1235, 259 | nodeType: GRAPH_ENUM.NODE.VARIABLE_FLOAT, 260 | posX: 100, 261 | posY: 350, 262 | attributes: { 263 | name: 'time' 264 | } 265 | }, 266 | 1236: { 267 | id: 1236, 268 | nodeType: GRAPH_ENUM.NODE.MULTIPLY, 269 | name: 'Multiply', 270 | posX: 650, 271 | posY: 250 272 | }, 273 | 1237: { 274 | id: 1237, 275 | nodeType: GRAPH_ENUM.NODE.FRAGMENT_OUTPUT, 276 | name: 'Fragment Output', 277 | posX: 1050, 278 | posY: 50 279 | }, 280 | 1238: { 281 | id: 1238, 282 | nodeType: GRAPH_ENUM.NODE.SINE, 283 | name: 'Sine', 284 | posX: 350, 285 | posY: 350 286 | }, 287 | 1239: { 288 | id: 1239, 289 | nodeType: GRAPH_ENUM.NODE.TEXTURE, 290 | name: 'Texture', 291 | posX: 650, 292 | posY: 50, 293 | // texture: 'https://cdnb.artstation.com/p/assets/images/images/008/977/853/large/brandon-liu-mod9-grass-bliu2.jpg?1516424810' 294 | }, 295 | 1240: { 296 | id: 1240, 297 | nodeType: GRAPH_ENUM.NODE.VARIABLE_VEC_2, 298 | name: 'meshUV', 299 | posX: 100, 300 | posY: 50, 301 | attributes: { 302 | name: 'uvCoords' 303 | } 304 | } 305 | }, 306 | edges: { 307 | '1234,0-1236,0': { 308 | edgeType: GRAPH_ENUM.EDGE.FLOAT, 309 | from: 1234, 310 | to: 1236, 311 | outPort: 0, 312 | inPort: 0 313 | }, 314 | '1235,0-1238,0': { 315 | edgeType: GRAPH_ENUM.EDGE.FLOAT, 316 | from: 1235, 317 | to: 1238, 318 | outPort: 0, 319 | inPort: 0 320 | }, 321 | '1238,0-1236,1': { 322 | edgeType: GRAPH_ENUM.EDGE.FLOAT, 323 | from: 1238, 324 | to: 1236, 325 | outPort: 0, 326 | inPort: 1 327 | }, 328 | '1236,0-1237,2': { 329 | edgeType: GRAPH_ENUM.EDGE.FLOAT, 330 | from: 1236, 331 | to: 1237, 332 | outPort: 0, 333 | inPort: 2 334 | }, 335 | '1239,1-1237,1': { 336 | edgeType: GRAPH_ENUM.EDGE.VEC_3, 337 | from: 1239, 338 | to: 1237, 339 | outPort: 1, 340 | inPort: 1 341 | }, 342 | '1240,0-1239,0': { 343 | edgeType: GRAPH_ENUM.EDGE.VEC_2, 344 | from: 1240, 345 | to: 1239, 346 | outPort: 0, 347 | inPort: 0 348 | } 349 | } 350 | }; 351 | 352 | var GRAPH_CONTEXT_MENU_ITEMS = [ 353 | { 354 | text: 'New add', 355 | action: GRAPH_ACTIONS.ADD_NODE, 356 | nodeType: GRAPH_ENUM.NODE.ADD, 357 | name: 'Add' 358 | }, 359 | { 360 | text: 'New multiply', 361 | action: GRAPH_ACTIONS.ADD_NODE, 362 | nodeType: GRAPH_ENUM.NODE.MULTIPLY, 363 | name: 'Multiply' 364 | }, 365 | { 366 | text: 'New sine', 367 | action: GRAPH_ACTIONS.ADD_NODE, 368 | nodeType: GRAPH_ENUM.NODE.SINE, 369 | name: 'Sine' 370 | }, 371 | { 372 | text: 'New texture', 373 | action: GRAPH_ACTIONS.ADD_NODE, 374 | nodeType: GRAPH_ENUM.NODE.TEXTURE, 375 | name: 'Texture' 376 | }, 377 | ]; 378 | 379 | // Template function 380 | const Template = (args) => ; 381 | 382 | // Default story using the template 383 | export const VisualProgrammingGraphExample = Template.bind({}); 384 | 385 | // Default args for the story 386 | VisualProgrammingGraphExample.args = { 387 | initialData: GRAPH_DATA, 388 | contextMenuItems: GRAPH_CONTEXT_MENU_ITEMS, 389 | passiveUIEvents: false, 390 | includeFonts: true, 391 | defaultStyles: { 392 | edge: { 393 | connectionStyle: 'smoothInOut' 394 | }, 395 | background: { 396 | color: '#20292B', 397 | gridSize: 10 398 | } 399 | } 400 | }; 401 | 402 | document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%'); 403 | document.body.setAttribute('style', 'margin: 0px; padding: 0px;'); -------------------------------------------------------------------------------- /.storybook/stories/basic/directed-graph.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Graph from '../../base-component'; 3 | 4 | export default { 5 | title: 'Basic/Directed Graph', 6 | component: Graph, 7 | argTypes: { 8 | // Define the args that you want to be editable in the Storybook UI 9 | } 10 | }; 11 | 12 | const GRAPH_ENUM = { 13 | NODE: { 14 | HELLO: 0, 15 | WORLD: 0 16 | }, 17 | EDGE: { 18 | HELLO_TO_WORLD: 0 19 | } 20 | }; 21 | 22 | const GRAPH_SCHEMA = { 23 | nodes: { 24 | [GRAPH_ENUM.NODE.HELLO]: { 25 | name: 'Hello' 26 | }, 27 | [GRAPH_ENUM.NODE.WORLD]: { 28 | name: 'World' 29 | } 30 | }, 31 | edges: { 32 | [GRAPH_ENUM.EDGE.HELLO_TO_WORLD]: { 33 | from: [ 34 | GRAPH_ENUM.NODE.HELLO 35 | ], 36 | to: [ 37 | GRAPH_ENUM.NODE.WORLD 38 | ] 39 | } 40 | } 41 | }; 42 | 43 | var GRAPH_DATA = { 44 | nodes: { 45 | 1234: { 46 | id: 1234, 47 | nodeType: GRAPH_ENUM.NODE.HELLO, 48 | name: 'Hello', 49 | posX: 100, 50 | posY: 100 51 | }, 52 | 1235: { 53 | id: 1235, 54 | nodeType: GRAPH_ENUM.NODE.WORLD, 55 | name: 'World', 56 | posX: 100, 57 | posY: 300 58 | }, 59 | }, 60 | edges: { 61 | '1234-1235': { 62 | edgeType: GRAPH_ENUM.EDGE.HELLO_TO_WORLD, 63 | from: 1234, 64 | to: 1235 65 | } 66 | } 67 | }; 68 | 69 | // Template function 70 | const Template = (args) => ; 71 | 72 | // Default story using the template 73 | export const DirectedGraphExample = Template.bind({}); 74 | 75 | // Default args for the story 76 | DirectedGraphExample.args = { 77 | initialData: GRAPH_DATA, 78 | passiveUIEvents: false, 79 | includeFonts: true, 80 | defaultStyles: { 81 | background: { 82 | color: '#20292B', 83 | gridSize: 10 84 | }, 85 | edge: { 86 | connectionStyle: 'default', 87 | targetMarker: true 88 | } 89 | } 90 | }; 91 | 92 | document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%'); 93 | document.body.setAttribute('style', 'margin: 0px; padding: 0px;'); 94 | -------------------------------------------------------------------------------- /.storybook/stories/basic/node-attribute-error.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GRAPH_ACTIONS } from '../../../src/constants'; 3 | import Graph from '../../base-component'; 4 | 5 | export default { 6 | title: 'Basic/Node Attribute Error Graph', 7 | component: Graph, 8 | argTypes: { 9 | // Define the args that you want to be editable in the Storybook UI 10 | } 11 | }; 12 | 13 | const GRAPH_ENUM = { 14 | NODE: { 15 | HELLO_WORLD: 0, 16 | } 17 | }; 18 | 19 | const GRAPH_SCHEMA = { 20 | nodes: { 21 | [GRAPH_ENUM.NODE.HELLO_WORLD]: { 22 | name: 'Alphabet Only', 23 | attributes: [ 24 | { 25 | name: 'text', 26 | type: 'TEXT_INPUT' 27 | } 28 | ] 29 | } 30 | } 31 | }; 32 | 33 | var GRAPH_DATA = { 34 | nodes: { 35 | 1234: { 36 | id: 1234, 37 | nodeType: GRAPH_ENUM.NODE.HELLO_WORLD, 38 | name: 'Alphabet Only', 39 | posX: 200, 40 | posY: 200, 41 | attributes: { 42 | text: 'abcdef' 43 | } 44 | } 45 | }, 46 | edges: {} 47 | }; 48 | 49 | // Template function 50 | const Template = (args) => ; 51 | 52 | // Default story using the template 53 | export const NodeAttributeErrorGraphExample = Template.bind({}); 54 | 55 | // Default args for the story 56 | NodeAttributeErrorGraphExample.args = { 57 | initialData: GRAPH_DATA, 58 | passiveUIEvents: false, 59 | includeFonts: true, 60 | defaultStyles: { 61 | edge: { 62 | connectionStyle: 'smoothInOut' 63 | }, 64 | background: { 65 | color: '#20292B', 66 | gridSize: 10 67 | } 68 | } 69 | }; 70 | 71 | document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%'); 72 | document.body.setAttribute('style', 'margin: 0px; padding: 0px;'); 73 | 74 | setTimeout(() => { 75 | const graph = document.querySelector('.pcui-graph').ui; 76 | graph.on(GRAPH_ACTIONS.UPDATE_NODE_ATTRIBUTE, (data) => { 77 | if (data.node.attributes[data.attribute].match('^[A-Za-z]*$')) { 78 | graph.updateNodeAttribute(1234, data.attribute, data.node.attributes[data.attribute]); 79 | graph.setNodeAttributeErrorState(1234, data.attribute, false); 80 | } else { 81 | graph.setNodeAttributeErrorState(1234, data.attribute, true); 82 | } 83 | }); 84 | }, 500); -------------------------------------------------------------------------------- /.storybook/stories/basic/node-attributes.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GRAPH_ACTIONS } from '../../../src/constants'; 3 | import Graph from '../../base-component'; 4 | 5 | export default { 6 | title: 'Basic/Node Attributes Graph', 7 | component: Graph, 8 | argTypes: { 9 | // Define the args that you want to be editable in the Storybook UI 10 | } 11 | }; 12 | 13 | const GRAPH_ENUM = { 14 | NODE: { 15 | HELLO: 0, 16 | WORLD: 1, 17 | }, 18 | EDGE: { 19 | HELLO_TO_WORLD: 0, 20 | } 21 | }; 22 | 23 | const GRAPH_SCHEMA = { 24 | nodes: { 25 | [GRAPH_ENUM.NODE.HELLO]: { 26 | name: 'Hello', 27 | headerTextFormatter: (attributes) => `Hello ${attributes.foo}`, 28 | outPorts: [ 29 | { 30 | name: 'output', 31 | type: GRAPH_ENUM.EDGE.HELLO_TO_WORLD, 32 | textFormatter: (attributes) => `output (${attributes.foo})` 33 | } 34 | ], 35 | attributes: [ 36 | { 37 | name: 'foo', 38 | type: 'TEXT_INPUT' 39 | } 40 | ] 41 | }, 42 | [GRAPH_ENUM.NODE.WORLD]: { 43 | name: 'World', 44 | inPorts: [ 45 | { 46 | name: 'input', 47 | type: GRAPH_ENUM.EDGE.HELLO_TO_WORLD, 48 | textFormatter: (attributes) => `input (${attributes.foo})` 49 | } 50 | ], 51 | attributes: [ 52 | { 53 | name: 'foo', 54 | type: 'TEXT_INPUT', 55 | hidden: true 56 | } 57 | ] 58 | } 59 | }, 60 | edges: { 61 | [GRAPH_ENUM.EDGE.HELLO_TO_WORLD]: { 62 | from: GRAPH_ENUM.NODE.HELLO, 63 | to: GRAPH_ENUM.NODE.WORLD, 64 | } 65 | } 66 | }; 67 | 68 | var GRAPH_DATA = { 69 | nodes: { 70 | 1234: { 71 | id: 1234, 72 | nodeType: GRAPH_ENUM.NODE.HELLO, 73 | name: 'Hello', 74 | posX: 200, 75 | posY: 200, 76 | attributes: { 77 | foo: 'bar' 78 | } 79 | }, 80 | 1235: { 81 | id: 1235, 82 | nodeType: GRAPH_ENUM.NODE.WORLD, 83 | name: 'World', 84 | posX: 500, 85 | posY: 200, 86 | attributes: { 87 | foo: 'bar' 88 | } 89 | }, 90 | }, 91 | edges: { 92 | '1234,0-1235,0': { 93 | edgeType: GRAPH_ENUM.EDGE.HELLO_TO_WORLD, 94 | from: 1234, 95 | to: 1235, 96 | inPort: 0, 97 | outPort: 0, 98 | } 99 | } 100 | }; 101 | 102 | // Template function 103 | const Template = (args) => ; 104 | 105 | // Default story using the template 106 | export const NodeAttributesGraphExample = Template.bind({}); 107 | 108 | // Default args for the story 109 | NodeAttributesGraphExample.args = { 110 | initialData: GRAPH_DATA, 111 | passiveUIEvents: false, 112 | includeFonts: true, 113 | defaultStyles: { 114 | edge: { 115 | connectionStyle: 'smoothInOut' 116 | }, 117 | background: { 118 | color: '#20292B', 119 | gridSize: 10 120 | } 121 | } 122 | }; 123 | 124 | document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%'); 125 | document.body.setAttribute('style', 'margin: 0px; padding: 0px;'); 126 | 127 | setTimeout(() => { 128 | const graph = document.querySelector('.pcui-graph').ui; 129 | graph.on(GRAPH_ACTIONS.UPDATE_NODE_ATTRIBUTE, (data) => { 130 | graph.updateNodeAttribute(1235, data.attribute, data.node.attributes[data.attribute]); 131 | }); 132 | }, 500); -------------------------------------------------------------------------------- /.storybook/stories/basic/styling.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Graph from '../../base-component'; 3 | 4 | export default { 5 | title: 'Basic/Styled Graph', 6 | component: Graph, 7 | argTypes: { 8 | // Define the args that you want to be editable in the Storybook UI 9 | } 10 | }; 11 | 12 | const GRAPH_ENUM = { 13 | NODE: { 14 | RED: 0, 15 | GREEN: 1, 16 | BLUE: 2, 17 | }, 18 | EDGE: { 19 | RED_TO_BLUE: 0, 20 | RED_TO_GREEN: 1, 21 | BLUE_TO_GREEN:2, 22 | } 23 | }; 24 | 25 | const GRAPH_SCHEMA = { 26 | nodes: { 27 | [GRAPH_ENUM.NODE.RED]: { 28 | name: 'Red', 29 | fill: 'red', 30 | stroke: 'darkRed' 31 | }, 32 | [GRAPH_ENUM.NODE.GREEN]: { 33 | name: 'Green', 34 | fill: 'green', 35 | stroke: 'darkGreen' 36 | }, 37 | [GRAPH_ENUM.NODE.BLUE]: { 38 | name: 'Blue', 39 | fill: 'blue', 40 | stroke: 'darkBlue' 41 | } 42 | }, 43 | edges: { 44 | [GRAPH_ENUM.EDGE.RED_TO_BLUE]: { 45 | from: [ 46 | GRAPH_ENUM.NODE.RED, 47 | ], 48 | to: [ 49 | GRAPH_ENUM.NODE.BLUE, 50 | ], 51 | stroke: 'magenta' 52 | }, 53 | [GRAPH_ENUM.EDGE.RED_TO_GREEN]: { 54 | from: [ 55 | GRAPH_ENUM.NODE.RED, 56 | ], 57 | to: [ 58 | GRAPH_ENUM.NODE.GREEN, 59 | ], 60 | stroke: 'yellow' 61 | }, 62 | [GRAPH_ENUM.EDGE.BLUE_TO_GREEN]: { 63 | from: [ 64 | GRAPH_ENUM.NODE.BLUE, 65 | ], 66 | to: [ 67 | GRAPH_ENUM.NODE.GREEN, 68 | ], 69 | stroke: 'cyan' 70 | } 71 | } 72 | }; 73 | 74 | var GRAPH_DATA = { 75 | nodes: { 76 | 1234: { 77 | id: 1234, 78 | nodeType: GRAPH_ENUM.NODE.RED, 79 | name: 'Red', 80 | posX: 100, 81 | posY: 100, 82 | }, 83 | 1235: { 84 | id: 1235, 85 | nodeType: GRAPH_ENUM.NODE.GREEN, 86 | name: 'Green', 87 | posX: 100, 88 | posY: 300 89 | }, 90 | 1236: { 91 | id: 1236, 92 | nodeType: GRAPH_ENUM.NODE.BLUE, 93 | name: 'Blue', 94 | posX: 300, 95 | posY: 200 96 | }, 97 | }, 98 | edges: { 99 | '1234-1236': { 100 | edgeType: GRAPH_ENUM.EDGE.RED_TO_BLUE, 101 | from: 1234, 102 | to: 1236 103 | }, 104 | '1234-1235': { 105 | edgeType: GRAPH_ENUM.EDGE.RED_TO_GREEN, 106 | from: 1234, 107 | to: 1235 108 | }, 109 | '1236-1235': { 110 | edgeType: GRAPH_ENUM.EDGE.BLUE_TO_GREEN, 111 | from: 1236, 112 | to: 1235 113 | } 114 | } 115 | }; 116 | 117 | // Template function 118 | const Template = (args) => ; 119 | 120 | // Default story using the template 121 | export const StyledGraphExample = Template.bind({}); 122 | 123 | // Default args for the story 124 | StyledGraphExample.args = { 125 | initialData: GRAPH_DATA, 126 | passiveUIEvents: false, 127 | includeFonts: true, 128 | defaultStyles: { 129 | background: { 130 | color: 'white', 131 | gridSize: 1 132 | }, 133 | edge: { 134 | connectionStyle: 'default', 135 | targetMarker: false 136 | } 137 | } 138 | }; 139 | 140 | 141 | document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%'); 142 | document.body.setAttribute('style', 'margin: 0px; padding: 0px;'); -------------------------------------------------------------------------------- /.storybook/stories/basic/visual-programming-graph.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Graph from '../../base-component'; 3 | 4 | export default { 5 | title: 'Basic/Visual Programming Graph', 6 | component: Graph, 7 | argTypes: { 8 | // Define the args that you want to be editable in the Storybook UI 9 | } 10 | }; 11 | 12 | const GRAPH_ENUM = { 13 | NODE: { 14 | HELLO: 0, 15 | WORLD: 1, 16 | }, 17 | EDGE: { 18 | HELLO_TO_WORLD: 0, 19 | } 20 | }; 21 | 22 | const GRAPH_SCHEMA = { 23 | nodes: { 24 | [GRAPH_ENUM.NODE.HELLO]: { 25 | name: 'Hello', 26 | outPorts: [ 27 | { 28 | name: 'output', 29 | type: GRAPH_ENUM.EDGE.HELLO_TO_WORLD 30 | } 31 | ] 32 | }, 33 | [GRAPH_ENUM.NODE.WORLD]: { 34 | name: 'World', 35 | inPorts: [ 36 | { 37 | name: 'input', 38 | type: GRAPH_ENUM.EDGE.HELLO_TO_WORLD 39 | } 40 | ] 41 | } 42 | }, 43 | edges: { 44 | [GRAPH_ENUM.EDGE.HELLO_TO_WORLD]: { 45 | from: GRAPH_ENUM.NODE.HELLO, 46 | to: GRAPH_ENUM.NODE.WORLD, 47 | } 48 | } 49 | }; 50 | 51 | var GRAPH_DATA = { 52 | nodes: { 53 | 1234: { 54 | id: 1234, 55 | nodeType: GRAPH_ENUM.NODE.HELLO, 56 | name: 'Hello', 57 | posX: 200, 58 | posY: 200 59 | }, 60 | 1235: { 61 | id: 1235, 62 | nodeType: GRAPH_ENUM.NODE.WORLD, 63 | name: 'World', 64 | posX: 500, 65 | posY: 200 66 | }, 67 | }, 68 | edges: { 69 | '1234,0-1235,0': { 70 | edgeType: GRAPH_ENUM.EDGE.HELLO_TO_WORLD, 71 | from: 1234, 72 | to: 1235, 73 | inPort: 0, 74 | outPort: 0, 75 | } 76 | } 77 | }; 78 | 79 | // Template function 80 | const Template = (args) => ; 81 | 82 | // Default story using the template 83 | export const VisualProgrammingGraphExample = Template.bind({}); 84 | 85 | // Default args for the story 86 | VisualProgrammingGraphExample.args = { 87 | initialData: GRAPH_DATA, 88 | passiveUIEvents: false, 89 | includeFonts: true, 90 | defaultStyles: { 91 | edge: { 92 | connectionStyle: 'smoothInOut' 93 | }, 94 | background: { 95 | color: '#20292B', 96 | gridSize: 10 97 | } 98 | } 99 | }; 100 | 101 | document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%'); 102 | document.body.setAttribute('style', 'margin: 0px; padding: 0px;'); -------------------------------------------------------------------------------- /.storybook/test.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playcanvas/pcui-graph/26d018b33dc62dd4956137e18f80a135ff68adbb/.storybook/test.json -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard-scss", 3 | "rules": { 4 | "font-family-no-missing-generic-family-keyword": [ 5 | true, 6 | { 7 | "ignoreFontFamilies": [ 8 | "pc-icon" 9 | ] 10 | } 11 | ], 12 | "no-descending-specificity": null, 13 | "no-duplicate-selectors": null, 14 | "scss/no-global-function-names": null, 15 | "scss/at-extend-no-missing-placeholder": null 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2025 PlayCanvas Ltd. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PCUI Graph - Node-based Graphs for PCUI 2 | 3 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/playcanvas/pcui-graph/blob/main/LICENSE) 4 | [![NPM Version](https://img.shields.io/npm/v/@playcanvas/pcui-graph.svg?style=flat?style=flat)](https://www.npmjs.com/package/@playcanvas/pcui-graph) 5 | [![NPM Downloads](https://img.shields.io/npm/dw/@playcanvas/pcui-graph)](https://npmtrends.com/@playcanvas/pcui=gra[j) 6 | 7 | | [User Guide](https://developer.playcanvas.com/user-manual/pcui/pcui-graph/) | [API Reference](https://api.playcanvas.com/pcui-graph/) | [React Examples](https://playcanvas.github.io/pcui-graph/storybook/) | [Blog](https://blog.playcanvas.com/) | [Forum](https://forum.playcanvas.com/) | [Discord](https://discord.gg/RSaMRzg) | 8 | 9 | ![pcui-graph-banner](https://github.com/user-attachments/assets/e0aa8953-221b-4122-b8ce-247c389ae6d6) 10 | 11 | Create node based visual graphs in the browser. Supports undirected / directed graphs as well as visual scripting graphs containing nodes with input / output ports. Your graphs can be saved to a JSON file and loaded back into a new graph view at any time. 12 | 13 | ## Getting Started 14 | 15 | First install PCUI Graph into your npm project: 16 | 17 | npm install @playcanvas/pcui-graph --save-dev 18 | 19 | You can then use the library in your own project by importing the PCUI Graph build and its styling file into your project. The graph can then be instantiated as follows: 20 | 21 | ```javascript 22 | import Graph from '@playcanvas/pcui-graph'; 23 | import '@playcanvas/pcui/styles'; 24 | import '@playcanvas/pcui-graph/styles'; 25 | 26 | const schema = { 27 | nodes: { 28 | 0: { 29 | name: 'Hello', 30 | fill: 'red' 31 | }, 32 | 1: { 33 | name: 'World', 34 | fill: 'green' 35 | } 36 | }, 37 | edges: { 38 | 0: { 39 | from: [0], // this edge can connect nodes of type 0 40 | to: [1], // to nodes of type 1, 41 | stroke: 'blue' 42 | } 43 | } 44 | } 45 | 46 | const graph = new Graph(schema); 47 | document.body.appendChild(graph.dom); 48 | ``` 49 | 50 | The library is also available on [npm](https://www.npmjs.com/package/@playcanvas/pcui-graph) and can be installed in your project with: 51 | 52 | npm install --save @playcanvas/pcui-graph @playcanvas/pcui @playcanvas/observer 53 | 54 | The npm package includes two builds of the library: 55 | 56 | @playcanvas/pcui-graph/dist/pcui-graph.js // UMD build (requires that the pcui and observer libraries are present in the global namespace) 57 | @playcanvas/pcui-graph/dist/pcui-graph.mjs // module build (requires a build tool like rollup / webpack) 58 | 59 | ## Storybook 60 | 61 | Examples of graphs created using PCUI Graph are available in this library's [storybook](https://playcanvas.github.io/pcui-graph/storybook/). Alternatively you can run the storybook locally and use it as a development environment for your own graphs. To do so, run the following commands in this projects root directory: 62 | 63 | npm install 64 | npm run storybook 65 | 66 | This will automatically open the storybook in a new browser tab. 67 | 68 | # Documentation 69 | 70 | Information on building the documentation can be found in the [docs](./docs/README.md) directory. 71 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import playcanvasConfig from '@playcanvas/eslint-config'; 2 | import babelParser from '@babel/eslint-parser'; 3 | import globals from 'globals'; 4 | 5 | export default [ 6 | ...playcanvasConfig, 7 | { 8 | files: ['**/*.js', '**/*.mjs'], 9 | languageOptions: { 10 | ecmaVersion: 2022, 11 | sourceType: 'module', 12 | parser: babelParser, 13 | parserOptions: { 14 | requireConfigFile: false 15 | }, 16 | globals: { 17 | ...globals.browser, 18 | ...globals.mocha, 19 | ...globals.node 20 | } 21 | } 22 | } 23 | ]; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@playcanvas/pcui-graph", 3 | "version": "4.1.0", 4 | "author": "PlayCanvas ", 5 | "homepage": "https://github.com/playcanvas/pcui-graph", 6 | "description": "A PCUI plugin for creating node-based graphs", 7 | "keywords": [ 8 | "components", 9 | "css", 10 | "dom", 11 | "graph", 12 | "html", 13 | "javascript", 14 | "nodes", 15 | "pcui", 16 | "playcanvas", 17 | "react", 18 | "sass", 19 | "typescript", 20 | "ui" 21 | ], 22 | "license": "MIT", 23 | "main": "dist/pcui-graph.js", 24 | "module": "dist/pcui-graph.mjs", 25 | "types": "types/index.d.ts", 26 | "type": "module", 27 | "bugs": { 28 | "url": "https://github.com/playcanvas/pcui-graph/issues" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/playcanvas/pcui-graph.git" 33 | }, 34 | "scripts": { 35 | "build": "cross-env NODE_ENV=production rollup -c --environment target:all && npm run bundle:styles", 36 | "build:storybook": "cross-env ENVIRONMENT=production storybook build -o storybook", 37 | "build:types": "tsc --project ./tsconfig.json --declaration --emitDeclarationOnly --outDir types", 38 | "bundle:styles": "scss-bundle -e ./src/styles/style.scss -o ./dist/pcui-graph.scss", 39 | "docs": "typedoc", 40 | "lint": "eslint src", 41 | "lint:package": "publint", 42 | "lint:styles": "stylelint src/styles/style.scss", 43 | "serve": "serve", 44 | "storybook": "storybook dev -p 9000", 45 | "watch": "rollup -c --environment target:all --watch", 46 | "watch:umd": "rollup -c --environment target:umd --watch", 47 | "watch:module": "rollup -c --environment target:module --watch" 48 | }, 49 | "files": [ 50 | "dist", 51 | "styles", 52 | "types" 53 | ], 54 | "browserslist": { 55 | "production": [ 56 | ">0.2%", 57 | "not dead", 58 | "not op_mini all" 59 | ], 60 | "development": [ 61 | "last 1 chrome version", 62 | "last 1 firefox version", 63 | "last 1 safari version" 64 | ] 65 | }, 66 | "devDependencies": { 67 | "@babel/core": "7.26.10", 68 | "@babel/eslint-parser": "7.27.0", 69 | "@babel/preset-env": "7.26.9", 70 | "@babel/preset-react": "7.26.3", 71 | "@playcanvas/eslint-config": "2.0.9", 72 | "@playcanvas/observer": "1.6.6", 73 | "@playcanvas/pcui": "5.2.0", 74 | "@rollup/plugin-alias": "5.1.1", 75 | "@rollup/plugin-babel": "6.0.4", 76 | "@rollup/plugin-commonjs": "28.0.3", 77 | "@rollup/plugin-node-resolve": "16.0.1", 78 | "@rollup/plugin-terser": "0.4.4", 79 | "@storybook/addon-essentials": "8.6.10", 80 | "@storybook/react-webpack5": "8.6.10", 81 | "@storybook/test": "8.6.10", 82 | "@types/react": "19.0.12", 83 | "babel-loader": "10.0.0", 84 | "backbone": "1.6.0", 85 | "cross-env": "7.0.3", 86 | "css-loader": "7.1.2", 87 | "eslint": "9.23.0", 88 | "globals": "16.0.0", 89 | "jointjs": "3.7.7", 90 | "jquery": "3.7.1", 91 | "lodash": "4.17.21", 92 | "prop-types": "15.8.1", 93 | "publint": "0.3.9", 94 | "rollup": "4.37.0", 95 | "rollup-plugin-jscc": "2.0.0", 96 | "rollup-plugin-node-builtins": "2.1.2", 97 | "rollup-plugin-node-globals": "1.4.0", 98 | "rollup-plugin-postcss": "4.0.2", 99 | "sass-loader": "16.0.5", 100 | "scss-bundle": "3.1.2", 101 | "serve": "14.2.4", 102 | "storybook": "8.6.10", 103 | "style-loader": "4.0.0", 104 | "stylelint": "16.17.0", 105 | "stylelint-config-standard-scss": "14.0.0", 106 | "typedoc": "0.28.1", 107 | "typedoc-plugin-mdn-links": "5.0.1", 108 | "typedoc-plugin-rename-defaults": "0.7.3", 109 | "typescript": "5.8.2" 110 | }, 111 | "peerDependencies": { 112 | "react": "^18.2.0 || ^19.0.0", 113 | "react-dom": "^18.2.0 || ^19.0.0" 114 | }, 115 | "directories": { 116 | "doc": "docs" 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchManagers": [ 9 | "npm" 10 | ], 11 | "groupName": "all npm dependencies", 12 | "schedule": [ 13 | "on monday at 10:00am" 14 | ] 15 | }, 16 | { 17 | "matchDepTypes": ["devDependencies"], 18 | "rangeStrategy": "pin" 19 | }, 20 | { 21 | "matchDepTypes": ["dependencies"], 22 | "rangeStrategy": "widen" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | // 1st party plugins 4 | import alias from '@rollup/plugin-alias'; 5 | import { babel } from '@rollup/plugin-babel'; 6 | import commonjs from '@rollup/plugin-commonjs'; 7 | import resolve from '@rollup/plugin-node-resolve'; 8 | import terser from '@rollup/plugin-terser'; 9 | 10 | // 3rd party plugins 11 | import jscc from 'rollup-plugin-jscc'; 12 | import builtins from 'rollup-plugin-node-builtins'; 13 | import globals from 'rollup-plugin-node-globals'; 14 | import postcss from 'rollup-plugin-postcss'; 15 | 16 | const PCUI_DIR = process.env.PCUI_PATH || 'node_modules/@playcanvas/pcui'; 17 | 18 | const PCUI_PATH = path.resolve(PCUI_DIR, 'react'); 19 | 20 | // define supported module overrides 21 | const aliasEntries = { 22 | 'pcui': PCUI_PATH 23 | }; 24 | 25 | const umd = { 26 | input: 'src/index.js', 27 | output: { 28 | file: 'dist/pcui-graph.js', 29 | format: 'umd', 30 | name: 'pcuiGraph', 31 | globals: { 32 | '@playcanvas/observer': 'observer', 33 | '@playcanvas/pcui': 'pcui' 34 | } 35 | }, 36 | external: ['@playcanvas/observer', '@playcanvas/pcui'], 37 | plugins: [ 38 | jscc({ 39 | values: { _STRIP_SCSS: process.env.STRIP_SCSS } 40 | }), 41 | postcss({ 42 | minimize: false, 43 | extensions: ['.css', '.scss'] 44 | }), 45 | alias({ entries: aliasEntries }), 46 | commonjs({ transformMixedEsModules: true }), 47 | globals(), 48 | builtins(), 49 | babel({ babelHelpers: 'bundled' }), 50 | resolve(), 51 | process.env.NODE_ENV === 'production' && terser() 52 | ] 53 | }; 54 | 55 | const module = { 56 | input: 'src/index.js', 57 | output: { 58 | file: 'dist/pcui-graph.mjs', 59 | format: 'module' 60 | }, 61 | external: ['@playcanvas/observer', '@playcanvas/pcui'], 62 | plugins: [ 63 | jscc({ 64 | values: { _STRIP_SCSS: process.env.STRIP_SCSS } 65 | }), 66 | alias({ entries: aliasEntries }), 67 | commonjs({ transformMixedEsModules: true }), 68 | globals(), 69 | builtins(), 70 | babel({ babelHelpers: 'bundled' }), 71 | postcss({ 72 | minimize: false, 73 | extensions: ['.css', '.scss'] 74 | }), 75 | resolve(), 76 | process.env.NODE_ENV === 'production' && terser() 77 | ] 78 | }; 79 | 80 | 81 | const styles = { 82 | input: 'src/styles/index.js', 83 | output: { 84 | file: 'styles/dist/index.mjs', 85 | format: 'esm' 86 | }, 87 | plugins: [ 88 | resolve(), 89 | postcss({ 90 | minimize: false, 91 | extensions: ['.css', '.scss'] 92 | }) 93 | ] 94 | }; 95 | 96 | 97 | let targets; 98 | if (process.env.target) { 99 | switch (process.env.target.toLowerCase()) { 100 | case "umd": targets = [umd]; break; 101 | case "module": targets = [module]; break; 102 | case "styles": targets = [styles]; break; 103 | case "all": targets = [umd, module, styles]; break; 104 | } 105 | } 106 | 107 | export default targets; 108 | -------------------------------------------------------------------------------- /src/assets/source-marker-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playcanvas/pcui-graph/26d018b33dc62dd4956137e18f80a135ff68adbb/src/assets/source-marker-active.png -------------------------------------------------------------------------------- /src/assets/source-marker-deactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playcanvas/pcui-graph/26d018b33dc62dd4956137e18f80a135ff68adbb/src/assets/source-marker-deactive.png -------------------------------------------------------------------------------- /src/assets/source-marker-default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playcanvas/pcui-graph/26d018b33dc62dd4956137e18f80a135ff68adbb/src/assets/source-marker-default.png -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const GRAPH_ACTIONS = { 2 | ADD_NODE: 'EVENT_ADD_NODE', 3 | DELETE_NODE: 'EVENT_DELETE_NODE', 4 | SELECT_NODE: 'EVENT_SELECT_NODE', 5 | UPDATE_NODE_POSITION: 'EVENT_UPDATE_NODE_POSITION', 6 | UPDATE_NODE_ATTRIBUTE: 'EVENT_UPDATE_NODE_ATTRIBUTE', 7 | ADD_EDGE: 'EVENT_ADD_EDGE', 8 | DELETE_EDGE: 'EVENT_DELETE_EDGE', 9 | SELECT_EDGE: 'EVENT_SELECT_EDGE', 10 | DESELECT_ITEM: 'EVENT_DESELECT_ITEM', 11 | UPDATE_TRANSLATE: 'EVENT_UPDATE_TRANSLATE', 12 | UPDATE_SCALE: 'EVENT_UPDATE_SCALE' 13 | }; 14 | 15 | export const DEFAULT_CONFIG = { 16 | readOnly: false, 17 | passiveUIEvents: false, 18 | incrementNodeNames: false, 19 | restrictTranslate: false, 20 | edgeHoverEffect: true, 21 | includeFonts: true, 22 | useGlobalPCUI: false, 23 | adjustVertices: false, 24 | defaultStyles: { 25 | initialScale: 1, 26 | initialPosition: { 27 | x: 0, 28 | y: 0 29 | }, 30 | background: { 31 | color: '#20292B', 32 | gridSize: 10 33 | }, 34 | node: { 35 | fill: '#2c393c', 36 | fillSecondary: '#364346', 37 | stroke: '#293538', 38 | strokeSelected: '#F60', 39 | strokeHover: 'rgba(255, 102, 0, 0.32)', 40 | textColor: '#FFFFFF', 41 | textColorSecondary: '#b1b8ba', 42 | includeIcon: true, 43 | icon: '', 44 | iconColor: '#F60', 45 | baseHeight: 28, 46 | baseWidth: 226, 47 | textAlignMiddle: false, 48 | lineHeight: 12 49 | 50 | }, 51 | edge: { 52 | stroke: 'rgb(3, 121, 238)', 53 | strokeSelected: '#F60', 54 | strokeWidth: 2, 55 | strokeWidthSelected: 2, 56 | targetMarker: true, 57 | connectionStyle: 'default' 58 | } 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /src/graph-view-edge.js: -------------------------------------------------------------------------------- 1 | import { Menu } from '@playcanvas/pcui'; 2 | import * as joint from 'jointjs/dist/joint.min.js'; 3 | 4 | joint.connectors.smoothInOut = function (sourcePoint, targetPoint, vertices, args) { 5 | const p1 = sourcePoint.clone(); 6 | p1.offset(30, 0); 7 | 8 | const p2 = targetPoint.clone(); 9 | p2.offset(-30, 0); 10 | 11 | const path = new joint.g.Path(joint.g.Path.createSegment('M', sourcePoint)); 12 | path.appendSegment(joint.g.Path.createSegment('C', p1, p2, targetPoint)); 13 | return path; 14 | }; 15 | 16 | class GraphViewEdge { 17 | constructor(graphView, paper, graph, graphSchema, edgeData, edgeSchema, onEdgeSelected) { 18 | this._graphView = graphView; 19 | this._config = graphView._config; 20 | this._paper = paper; 21 | this._graph = graph; 22 | this._graphSchema = graphSchema; 23 | this.edgeData = edgeData; 24 | this._edgeSchema = edgeSchema; 25 | this.state = GraphViewEdge.STATES.DEFAULT; 26 | 27 | const link = GraphViewEdge.createLink(this._config.defaultStyles, edgeSchema, edgeData); 28 | const sourceNode = this._graphView.getNode(edgeData.from); 29 | if (edgeData && Number.isFinite(edgeData.outPort)) { 30 | link.source({ 31 | id: sourceNode.model.id, 32 | port: `out${edgeData.outPort}` 33 | }); 34 | } else { 35 | if (sourceNode.model) { 36 | link.source(sourceNode.model); 37 | } 38 | } 39 | const targetNode = this._graphView.getNode(edgeData.to); 40 | if (edgeData && Number.isFinite(edgeData.inPort)) { 41 | link.target({ 42 | id: targetNode.model.id, 43 | port: `in${edgeData.inPort}` 44 | }); 45 | } else { 46 | link.target(targetNode.model); 47 | } 48 | 49 | const onCellMountedToDom = () => { 50 | this._paper.findViewByModel(link).on('cell:pointerdown', () => { 51 | if (this._config.readOnly) return; 52 | onEdgeSelected(edgeData); 53 | }); 54 | if (edgeData && Number.isFinite(edgeData.inPort)) { 55 | this._graphView.updatePortStatesForEdge(link, true); 56 | } 57 | link.toBack(); 58 | }; 59 | if (this._graphView._batchingCells) { 60 | this._graphView._cells.push(link); 61 | this._graphView._cellMountedFunctions.push(onCellMountedToDom); 62 | } else { 63 | this._graph.addCell(link); 64 | onCellMountedToDom(); 65 | } 66 | 67 | this.model = link; 68 | } 69 | 70 | static createLink(defaultStyles, edgeSchema, edgeData) { 71 | const link = new joint.shapes.standard.Link(); 72 | link.attr({ 73 | line: { 74 | strokeWidth: edgeSchema.strokeWidth || defaultStyles.edge.strokeWidth, 75 | stroke: edgeSchema.stroke || defaultStyles.edge.stroke 76 | } 77 | }); 78 | if (edgeSchema.smooth || defaultStyles.edge.connectionStyle === 'smooth') { 79 | link.set('connector', { name: 'smooth' }); 80 | } else if (edgeSchema.smoothInOut || defaultStyles.edge.connectionStyle === 'smoothInOut') { 81 | link.set('connector', { name: 'smoothInOut' }); 82 | } 83 | if (edgeData && Number.isFinite(edgeData.outPort)) { 84 | link.attr('line/targetMarker', null); 85 | return link; 86 | } 87 | if (edgeSchema.targetMarker || defaultStyles.edge.targetMarker) { 88 | link.attr('line/targetMarker', { 89 | 'type': 'path', 90 | 'd': 'm1.18355,0.8573c-0.56989,-0.39644 -0.57234,-1.2387 -0.00478,-1.63846l7.25619,-5.11089c0.66255,-0.46663 1.57585,0.00721 1.57585,0.81756l0,10.1587c0,0.8077 -0.908,1.2821 -1.57106,0.8209l-7.2562,-5.04781z', 91 | 'stroke': edgeSchema.stroke || defaultStyles.edge.stroke, 92 | 'fill': edgeSchema.stroke || defaultStyles.edge.stroke 93 | }); 94 | } else { 95 | link.attr('line/targetMarker', null); 96 | } 97 | 98 | if (edgeSchema.sourceMarker || defaultStyles.edge.sourceMarker) { 99 | link.attr('line/sourceMarker', { 100 | d: 'M 6 0 a 6 6 0 1 0 0 1' 101 | }); 102 | } 103 | return link; 104 | } 105 | 106 | addContextMenu(items) { 107 | if (this._graphView._config.readOnly) return; 108 | this._contextMenu = new Menu({ 109 | items: items 110 | }); 111 | this._paper.el.appendChild(this._contextMenu.dom); 112 | const edgeElement = this._paper.findViewByModel(this.model).el; 113 | edgeElement.addEventListener('contextmenu', (e) => { 114 | e.preventDefault(); 115 | this._contextMenu.position(e.clientX, e.clientY); 116 | this._contextMenu.hidden = false; 117 | }); 118 | } 119 | 120 | select() { 121 | const edgeSchema = this._edgeSchema; 122 | this.model.attr('line/stroke', edgeSchema.strokeSelected || this._config.defaultStyles.edge.strokeSelected); 123 | this.model.attr('line/strokeWidth', edgeSchema.strokeWidthSelected || this._config.defaultStyles.edge.strokeWidthSelected); 124 | this.model.attr('line/targetMarker', { 125 | stroke: edgeSchema.strokeSelected || this._config.defaultStyles.edge.strokeSelected, 126 | fill: edgeSchema.strokeSelected || this._config.defaultStyles.edge.strokeSelected 127 | }); 128 | } 129 | 130 | deselect() { 131 | const edgeSchema = this._edgeSchema; 132 | this.model.attr('line/stroke', edgeSchema.stroke || this._config.defaultStyles.edge.stroke); 133 | this.model.attr('line/strokeWidth', edgeSchema.strokeWidth || this._config.defaultStyles.edge.strokeWidth); 134 | this.model.attr('line/targetMarker', { 135 | 'stroke': edgeSchema.stroke || this._config.defaultStyles.edge.stroke, 136 | 'fill': edgeSchema.stroke || this._config.defaultStyles.edge.stroke 137 | }); 138 | this.state = GraphViewEdge.STATES.DEFAULT; 139 | } 140 | 141 | mute() { 142 | const edgeSchema = this._edgeSchema; 143 | this.model.attr('line/stroke', '#42495B'); 144 | this.model.attr('line/strokeWidth', edgeSchema.strokeWidth || this._config.defaultStyles.edge.stroke); 145 | this.model.attr('line/targetMarker', { 146 | stroke: '#9BA1A3', 147 | fill: '#9BA1A3' 148 | }); 149 | } 150 | 151 | addSourceMarker() { 152 | const edgeSchema = this._edgeSchema; 153 | this.model.attr('line/sourceMarker', { 154 | 'type': 'path', 155 | 'd': 'm-2.57106,0.93353c-0.56989,-0.39644 -0.57234,-1.2387 -0.00478,-1.63846l7.25619,-5.11089c0.66251,-0.46663 1.57585,0.00721 1.57585,0.81756l0,10.1587c0,0.8077 -0.90803,1.2821 -1.57106,0.8209l-7.2562,-5.04781z', 156 | 'stroke': edgeSchema.stroke || this._config.defaultStyles.edge.stroke, 157 | 'fill': edgeSchema.stroke || this._config.defaultStyles.edge.stroke 158 | }); 159 | } 160 | 161 | addTargetMarker() { 162 | const edgeSchema = this._edgeSchema; 163 | this.model.attr('line/targetMarker', { 164 | 'type': 'path', 165 | 'd': 'm-2.57106,0.93353c-0.56989,-0.39644 -0.57234,-1.2387 -0.00478,-1.63846l7.25619,-5.11089c0.66251,-0.46663 1.57585,0.00721 1.57585,0.81756l0,10.1587c0,0.8077 -0.90803,1.2821 -1.57106,0.8209l-7.2562,-5.04781z', 166 | 'stroke': edgeSchema.stroke || this._config.defaultStyles.edge.stroke, 167 | 'fill': edgeSchema.stroke || this._config.defaultStyles.edge.stroke 168 | }); 169 | } 170 | } 171 | 172 | GraphViewEdge.STATES = { 173 | DEFAULT: 0, 174 | SELECTED: 1 175 | }; 176 | 177 | export default GraphViewEdge; 178 | -------------------------------------------------------------------------------- /src/graph-view-node.js: -------------------------------------------------------------------------------- 1 | import { Menu, Container, Label, TextInput, BooleanInput, NumericInput, VectorInput } from '@playcanvas/pcui'; 2 | import * as joint from 'jointjs/dist/joint.min.js'; 3 | 4 | const Colors = { 5 | bcgDarkest: '#20292b', 6 | bcgDarker: '#293538', 7 | bcgDark: '#2c393c', 8 | bcgPrimary: '#364346', 9 | textDarkest: '#5b7073', 10 | textDark: '#9ba1a3', 11 | textSecondary: '#b1b8ba', 12 | textPrimary: '#ffffff', 13 | textActive: '#f60' 14 | }; 15 | 16 | class GraphViewNode { 17 | constructor(graphView, paper, graph, graphSchema, nodeData, nodeSchema, onCreateEdge, onNodeSelected) { 18 | this._graphView = graphView; 19 | this._config = graphView._config; 20 | this._paper = paper; 21 | this._graph = graph; 22 | this._graphSchema = graphSchema; 23 | this.nodeData = nodeData; 24 | this.nodeSchema = nodeSchema; 25 | this.state = GraphViewNode.STATES.DEFAULT; 26 | 27 | const rectHeight = this.getSchemaValue('baseHeight'); 28 | let portHeight = 0; 29 | let attributeHeight = 0; 30 | if (nodeSchema.inPorts) { 31 | portHeight = (nodeSchema.inPorts.length * 25) + 10; 32 | } 33 | if (nodeSchema.outPorts) { 34 | const outHeight = (nodeSchema.outPorts.length * 25) + 10; 35 | if (outHeight > portHeight) portHeight = outHeight; 36 | } 37 | const visibleAttributes = nodeSchema.attributes && nodeSchema.attributes.filter(a => !a.hidden); 38 | if (visibleAttributes && visibleAttributes.length > 0) { 39 | attributeHeight = visibleAttributes.length * 32 + 10; 40 | } 41 | const rectSize = { x: this.getSchemaValue('baseWidth'), y: rectHeight + portHeight + attributeHeight }; 42 | 43 | let labelName; 44 | const formattedText = nodeSchema.headerTextFormatter && nodeSchema.headerTextFormatter(nodeData.attributes, nodeData.id); 45 | if (typeof formattedText === 'string') { 46 | labelName = nodeSchema.headerTextFormatter(nodeData.attributes, nodeData.id); 47 | } else if (nodeSchema.outPorts || nodeSchema.inPorts) { 48 | labelName = nodeData.attributes && nodeData.attributes.name ? `${nodeData.attributes.name} (${nodeSchema.name})` : nodeSchema.name; 49 | } else { 50 | labelName = nodeData.attributes && nodeData.attributes.name || nodeData.name; 51 | } 52 | const rect = new joint.shapes.html.Element({ 53 | attrs: { 54 | body: { 55 | fill: this.getSchemaValue('fill'), 56 | stroke: this.getSchemaValue('stroke'), 57 | strokeWidth: 2, 58 | width: rectSize.x, 59 | height: rectSize.y 60 | }, 61 | labelBackground: { 62 | fill: this.getSchemaValue('fill'), 63 | refX: 2, 64 | refY: 2, 65 | width: rectSize.x - 4, 66 | height: rectHeight - 4 67 | }, 68 | labelSeparator: { 69 | fill: this.getSchemaValue('stroke'), 70 | width: rectSize.x - 2, 71 | height: this.getSchemaValue('inPorts') || this.getSchemaValue('outPorts') ? 2 : 0, 72 | refX: 1, 73 | refY: rectHeight - 1 74 | }, 75 | inBackground: { 76 | fill: this.getSchemaValue('fillSecondary'), 77 | width: this.getSchemaValue('inPorts') ? rectSize.x / 2 - 1 : rectSize.x - 2, 78 | height: (rectSize.y - rectHeight - 2) >= 0 ? rectSize.y - rectHeight - 2 : 0, 79 | refX: 1, 80 | refY: rectHeight + 1 81 | }, 82 | outBackground: { 83 | fill: this.getSchemaValue('fill'), 84 | width: this.getSchemaValue('inPorts') ? rectSize.x / 2 - 1 : 0, 85 | height: (rectSize.y - rectHeight - 2) >= 0 ? rectSize.y - rectHeight - 2 : 0, 86 | refX: rectSize.x / 2, 87 | refY: rectHeight + 1 88 | }, 89 | icon: this.getSchemaValue('includeIcon') ? { 90 | text: this.getSchemaValue('icon'), 91 | fontFamily: 'pc-icon', 92 | fontSize: 14, 93 | fill: this.getSchemaValue('iconColor'), 94 | refX: 8, 95 | refY: 8 96 | } : undefined, 97 | label: { 98 | text: labelName, 99 | fill: this.getSchemaValue('textColor'), 100 | textAnchor: this.getSchemaValue('textAlignMiddle') ? 'middle' : 'left', 101 | refX: !this.getSchemaValue('textAlignMiddle') ? (this.getSchemaValue('includeIcon') ? 28 : 14) : rectSize.x / 2, 102 | refY: !this.getSchemaValue('textAlignMiddle') ? 14 : rectHeight / 2, 103 | fontSize: 12, 104 | fontWeight: 600, 105 | width: rectSize.x, 106 | height: rectHeight, 107 | lineSpacing: 50, 108 | lineHeight: this.getSchemaValue('lineHeight') 109 | }, 110 | marker: nodeData.marker ? { 111 | refX: rectSize.x - 20, 112 | fill: this.getSchemaValue('stroke'), 113 | d: 'M0 0 L20 0 L20 20 Z' 114 | } : null, 115 | texture: nodeData.texture ? { 116 | href: nodeData.texture, 117 | fill: 'red', 118 | width: 95, 119 | height: 95, 120 | refX: 5, 121 | refY: 65 122 | } : null 123 | }, 124 | ports: { 125 | groups: { 126 | 'in': { 127 | position: { 128 | name: 'line', 129 | args: { 130 | start: { x: 0, y: rectHeight }, 131 | end: { x: 0, y: rectHeight + (25 * (nodeSchema.inPorts ? nodeSchema.inPorts.length : 0)) } 132 | } 133 | }, 134 | label: { 135 | position: { 136 | name: 'right', 137 | args: { 138 | y: 5 139 | } 140 | } 141 | }, 142 | markup: '', 143 | attrs: { 144 | '.port-body': { 145 | strokeWidth: 2, 146 | fill: Colors.bcgDarkest, 147 | magnet: true, 148 | r: 5, 149 | cy: 5, 150 | cx: 1 151 | }, 152 | '.port-inner-body': { 153 | strokeWidth: 2, 154 | stroke: this._config.defaultStyles.edge.stroke, 155 | r: 1, 156 | cy: 5, 157 | cx: 1 158 | } 159 | } 160 | }, 161 | 'out': { 162 | position: { 163 | name: 'line', 164 | args: { 165 | start: { x: rectSize.x - 10, y: rectHeight }, 166 | end: { x: rectSize.x - 10, y: rectHeight + (25 * (nodeSchema.outPorts ? nodeSchema.outPorts.length : 0)) } 167 | } 168 | }, 169 | label: { 170 | position: { 171 | name: 'left', args: { y: 5, x: -5 } 172 | } 173 | }, 174 | markup: '', 175 | attrs: { 176 | '.port-body': { 177 | strokeWidth: 2, 178 | fill: Colors.bcgDarkest, 179 | magnet: true, 180 | r: 5, 181 | cy: 5, 182 | cx: 9 183 | }, 184 | '.port-inner-body': { 185 | strokeWidth: 2, 186 | stroke: this._config.defaultStyles.edge.stroke, 187 | r: 1, 188 | cy: 5, 189 | cx: 9 190 | } 191 | } 192 | } 193 | } 194 | } 195 | }); 196 | rect.position(nodeData.posX, nodeData.posY); 197 | rect.resize(rectSize.x, rectSize.y); 198 | 199 | if (nodeSchema.inPorts) { 200 | nodeSchema.inPorts.forEach((port, i) => { 201 | rect.addPort({ 202 | id: `in${i}`, 203 | group: 'in', 204 | edgeType: port.edgeType, 205 | markup: ``, 206 | attrs: { 207 | '.port-body': { 208 | stroke: this._graphSchema.edges[port.type].stroke || this._config.defaultStyles.edge.stroke 209 | }, 210 | text: { 211 | text: port.textFormatter ? port.textFormatter(nodeData.attributes) : port.name, 212 | fill: this.getSchemaValue('textColorSecondary'), 213 | 'font-size': 14 214 | } 215 | } 216 | }); 217 | this._graph.on('change:target', (cell) => { 218 | if (this._suppressChangeTargetEvent) return; 219 | let target = cell.get('target'); 220 | let source = cell.get('source'); 221 | if (!target || !source) return; 222 | if (target && target.port && target.port.includes('out')) { 223 | const temp = target; 224 | target = source; 225 | source = temp; 226 | } 227 | if (!target || !target.id || target.id !== this.model.id) return; 228 | if (source && source.port && target.port && Number(target.port.replace('in', '')) === i) { 229 | const sourceNodeId = this._graphView.getNode(source.id).nodeData.id; 230 | const edgeId = `${sourceNodeId},${source.port.replace('out', '')}-${this.nodeData.id},${target.port.replace('in', '')}`; 231 | const edge = { 232 | to: this.nodeData.id, 233 | from: sourceNodeId, 234 | outPort: Number(source.port.replace('out', '')), 235 | inPort: Number(target.port.replace('in', '')), 236 | edgeType: port.type 237 | }; 238 | this._suppressChangeTargetEvent = true; 239 | this._graph.removeCells(cell); 240 | this._suppressChangeTargetEvent = false; 241 | onCreateEdge(edgeId, edge); 242 | } 243 | }); 244 | }); 245 | } 246 | 247 | if (nodeSchema.outPorts) { 248 | nodeSchema.outPorts.forEach((port, i) => rect.addPort({ 249 | id: `out${i}`, 250 | group: 'out', 251 | markup: ``, 252 | attrs: { 253 | type: port.type, 254 | '.port-body': { 255 | stroke: this._graphSchema.edges[port.type].stroke || this._config.defaultStyles.edge.stroke 256 | }, 257 | text: { 258 | text: port.textFormatter ? port.textFormatter(nodeData.attributes) : port.name, 259 | fill: this.getSchemaValue('textColorSecondary'), 260 | 'font-size': 14 261 | } 262 | } 263 | })); 264 | } 265 | 266 | const containers = []; 267 | if (visibleAttributes) { 268 | visibleAttributes.forEach((attribute, i) => { 269 | const container = new Container({ class: 'graph-node-container' }); 270 | const label = new Label({ text: attribute.name, class: 'graph-node-label' }); 271 | let input; 272 | let nodeValue; 273 | if (nodeData.attributes) { 274 | if (nodeData.attributes[attribute.name] !== undefined) { 275 | nodeValue = nodeData.attributes[attribute.name]; 276 | } else { 277 | Object.keys(nodeData.attributes).forEach((k) => { 278 | const a = nodeData.attributes[k]; 279 | if (a.name === attribute.name) { 280 | nodeValue = a.defaultValue; 281 | } 282 | }); 283 | } 284 | } 285 | if (!nodeValue) { 286 | nodeValue = nodeData[attribute.name]; 287 | } 288 | switch (attribute.type) { 289 | case 'TEXT_INPUT': 290 | input = new TextInput({ class: 'graph-node-input', value: nodeValue }); 291 | break; 292 | case 'BOOLEAN_INPUT': 293 | input = new BooleanInput({ class: 'graph-node-input', value: nodeValue }); 294 | break; 295 | case 'NUMERIC_INPUT': 296 | input = new NumericInput({ class: 'graph-node-input', hideSlider: true, value: nodeValue && nodeValue.x ? nodeValue.x : nodeValue }); 297 | break; 298 | case 'VEC_2_INPUT': 299 | input = new VectorInput({ dimensions: 2, 300 | class: 'graph-node-input', 301 | hideSlider: true, 302 | value: [ 303 | nodeValue.x, 304 | nodeValue.y 305 | ] }); 306 | input.dom.setAttribute('style', 'margin-right: 6px;'); 307 | input.inputs.forEach(i => i._sliderControl.dom.remove()); 308 | break; 309 | case 'VEC_3_INPUT': 310 | input = new VectorInput({ dimensions: 3, 311 | class: 'graph-node-input', 312 | hideSlider: true, 313 | value: [ 314 | nodeValue.x, 315 | nodeValue.y, 316 | nodeValue.z 317 | ] }); 318 | input.dom.setAttribute('style', 'margin-right: 6px;'); 319 | input.inputs.forEach(i => i._sliderControl.dom.remove()); 320 | break; 321 | case 'VEC_4_INPUT': 322 | input = new VectorInput({ dimensions: 4, 323 | class: 'graph-node-input', 324 | hideSlider: true, 325 | value: [ 326 | nodeValue.x, 327 | nodeValue.y, 328 | nodeValue.z, 329 | nodeValue.w 330 | ] }); 331 | input.dom.setAttribute('style', 'margin-right: 6px;'); 332 | input.inputs.forEach(i => i._sliderControl.dom.remove()); 333 | break; 334 | } 335 | input.enabled = !this._graphView._config.readOnly; 336 | input.dom.setAttribute('id', `input_${attribute.name}`); 337 | container.dom.setAttribute('style', `margin-top: ${i === 0 ? 33 + portHeight : 5}px; margin-bottom: 5px;`); 338 | container.append(label); 339 | container.append(input); 340 | containers.push(container); 341 | }); 342 | } 343 | 344 | const onCellMountedToDom = () => { 345 | const nodeDiv = document.querySelector(`#nodediv_${rect.id}`); 346 | containers.forEach((container) => { 347 | nodeDiv.appendChild(container.dom); 348 | }); 349 | this._paper.findViewByModel(rect).on('element:pointerdown', () => { 350 | if (this._hasLinked) { 351 | this._hasLinked = false; 352 | return; 353 | } 354 | onNodeSelected(this.nodeData); 355 | }); 356 | }; 357 | 358 | if (this._graphView._batchingCells) { 359 | this._graphView._cells.push(rect); 360 | this._graphView._cellMountedFunctions.push(onCellMountedToDom); 361 | } else { 362 | this._graph.addCell(rect); 363 | onCellMountedToDom(); 364 | } 365 | 366 | this.model = rect; 367 | } 368 | 369 | getSchemaValue(item) { 370 | return this.nodeSchema[item] !== undefined ? this.nodeSchema[item] : this._config.defaultStyles.node[item]; 371 | } 372 | 373 | addContextMenu(items) { 374 | if (this._graphView._config.readOnly) return; 375 | this._contextMenu = new Menu({ 376 | items: this._graphView._parent._initializeNodeContextMenuItems(this.nodeData, items) 377 | }); 378 | this._paper.el.appendChild(this._contextMenu.dom); 379 | const nodeElement = this._paper.findViewByModel(this.model).el; 380 | nodeElement.addEventListener('contextmenu', (e) => { 381 | e.preventDefault(); 382 | this._contextMenu.position(e.clientX, e.clientY); 383 | this._contextMenu.hidden = false; 384 | }); 385 | } 386 | 387 | 388 | mapVectorToArray(v) { 389 | const arr = []; 390 | if (Number.isFinite(v.x)) arr.push(v.x); 391 | if (Number.isFinite(v.y)) arr.push(v.y); 392 | if (Number.isFinite(v.z)) arr.push(v.z); 393 | if (Number.isFinite(v.w)) arr.push(v.w); 394 | return arr; 395 | } 396 | 397 | updateFormattedTextFields() { 398 | if (this.nodeSchema.headerTextFormatter) { 399 | const formattedText = this.nodeSchema.headerTextFormatter(this.nodeData.attributes, this.nodeData.id); 400 | if (typeof formattedText === 'string') { 401 | this.model.attr('label/text', formattedText); 402 | } 403 | } 404 | if (this.nodeSchema.outPorts) { 405 | this.nodeSchema.outPorts.forEach((port, i) => { 406 | if (port.textFormatter) { 407 | document.getElementById(`${this.nodeData.id}-out${i}`).parentElement.parentElement.querySelector('tspan').innerHTML = port.textFormatter(this.nodeData.attributes); 408 | } 409 | }); 410 | } 411 | if (this.nodeSchema.inPorts) { 412 | this.nodeSchema.inPorts.forEach((port, i) => { 413 | if (port.textFormatter) { 414 | document.getElementById(`${this.nodeData.id}-in${i}`).parentElement.parentElement.querySelector('tspan').innerHTML = port.textFormatter(this.nodeData.attributes); 415 | } 416 | }); 417 | } 418 | } 419 | 420 | updateAttribute(attribute, value) { 421 | this.nodeData.attributes[attribute] = value; 422 | const attributeElement = document.querySelector(`#nodediv_${this.model.id}`).querySelector(`#input_${attribute}`); 423 | if (attributeElement) { 424 | attributeElement.ui.suspendEvents = true; 425 | if (Number.isFinite(value.x)) { 426 | attributeElement.ui.value = this.mapVectorToArray(value); 427 | } else { 428 | attributeElement.ui.value = value; 429 | } 430 | attributeElement.ui.error = false; 431 | attributeElement.ui.suspendEvents = false; 432 | } 433 | this.updateFormattedTextFields(); 434 | } 435 | 436 | setAttributeErrorState(attribute, value) { 437 | const attributeElement = document.querySelector(`#nodediv_${this.model.id}`).querySelector(`#input_${attribute}`); 438 | if (attributeElement) { 439 | attributeElement.ui.error = value; 440 | } 441 | } 442 | 443 | updateNodeType(nodeType) { 444 | this._paper.findViewByModel(this.model).el.removeEventListener('contextmenu', this._contextMenu._contextMenuEvent); 445 | this.addContextMenu(this._graphSchema.nodes[nodeType].contextMenuItems); 446 | } 447 | 448 | updatePosition(pos) { 449 | this.model.position(pos.x, pos.y); 450 | } 451 | 452 | addEvent(event, callback, attribute) { 453 | const nodeView = this._paper.findViewByModel(this.model); 454 | switch (event) { 455 | case 'updatePosition': { 456 | nodeView.on('element:pointerup', () => { 457 | const newPos = this._graphView.getWindowToGraphPosition(nodeView.getBBox(), false); 458 | callback(this.nodeData.id, newPos); 459 | }); 460 | break; 461 | } 462 | case 'updateAttribute': { 463 | const attributeElement = document.querySelector(`#nodediv_${this.model.id}`).querySelector(`#input_${attribute.name}`); 464 | if (!attributeElement) break; 465 | attributeElement.ui.on('change', (value) => { 466 | if (attribute.name === 'name') { 467 | let nameTaken = false; 468 | Object.keys(this._graphView._graphData.get('data.nodes')).forEach((nodeKey) => { 469 | const node = this._graphView._graphData.get('data.nodes')[nodeKey]; 470 | if (node.name === value) { 471 | nameTaken = true; 472 | } 473 | }); 474 | const attributeElement = document.querySelector(`#nodediv_${this.model.id}`).querySelector(`#input_${attribute.name}`); 475 | if (nameTaken) { 476 | attributeElement.ui.error = true; 477 | return; 478 | } 479 | attributeElement.ui.error = false; 480 | } 481 | callback(this.nodeData.id, attribute, value); 482 | }); 483 | break; 484 | } 485 | } 486 | } 487 | 488 | select() { 489 | this.model.attr('body/stroke', this.getSchemaValue('strokeSelected')); 490 | this.state = GraphViewNode.STATES.SELECTED; 491 | } 492 | 493 | hover() { 494 | if (this.state === GraphViewNode.STATES.SELECTED) return; 495 | this.model.attr('body/stroke', this.getSchemaValue('strokeHover')); 496 | } 497 | 498 | hoverRemove() { 499 | if (this.state === GraphViewNode.STATES.DEFAULT) { 500 | this.deselect(); 501 | } else if (this.state === GraphViewNode.STATES.SELECTED) { 502 | this.select(); 503 | } 504 | } 505 | 506 | deselect() { 507 | this.model.attr('body/stroke', this.getSchemaValue('stroke')); 508 | this.state = GraphViewNode.STATES.DEFAULT; 509 | } 510 | } 511 | 512 | GraphViewNode.STATES = { 513 | DEFAULT: 0, 514 | SELECTED: 1 515 | }; 516 | 517 | export default GraphViewNode; 518 | -------------------------------------------------------------------------------- /src/graph-view.js: -------------------------------------------------------------------------------- 1 | import { Menu } from '@playcanvas/pcui'; 2 | import * as joint from 'jointjs/dist/joint.min.js'; 3 | 4 | import { GRAPH_ACTIONS } from './constants.js'; 5 | import GraphViewEdge from './graph-view-edge.js'; 6 | import GraphViewNode from './graph-view-node.js'; 7 | import JointGraph from './joint-graph.js'; 8 | import { jointShapeElement, jointShapeElementView } from './joint-shape-node.js'; 9 | // TODO replace with a lighter math library 10 | import { Vec2 } from './lib/vec2.js'; 11 | 12 | 13 | class GraphView extends JointGraph { 14 | constructor(parent, dom, graphSchema, graphData, config) { 15 | super(dom, config); 16 | 17 | this._parent = parent; 18 | this._dom = dom; 19 | this._graphSchema = graphSchema; 20 | this._graphData = graphData; 21 | 22 | this._config = config; 23 | 24 | this._nodes = {}; 25 | this._edges = {}; 26 | 27 | this._cells = []; 28 | this._cellMountedFunctions = []; 29 | 30 | joint.shapes.html = {}; 31 | joint.shapes.html.Element = jointShapeElement(); 32 | joint.shapes.html.ElementView = jointShapeElementView(this._paper); 33 | 34 | this._graph.on('remove', cell => this.updatePortStatesForEdge(cell, false)); 35 | this._graph.on('change:target', cell => this.updatePortStatesForEdge(cell, true)); 36 | 37 | this._paper.on('cell:mousewheel', () => { 38 | parent._dispatchEvent(GRAPH_ACTIONS.UPDATE_SCALE, { scale: this._paper.scale().sx }); 39 | }); 40 | this._paper.on('blank:mousewheel', () => { 41 | parent._dispatchEvent(GRAPH_ACTIONS.UPDATE_SCALE, { scale: this._paper.scale().sx }); 42 | }); 43 | this._paper.on('blank:pointerup', (event) => { 44 | parent._dispatchEvent(GRAPH_ACTIONS.UPDATE_TRANSLATE, { pos: { x: this._paper.translate().tx, y: this._paper.translate().ty } }); 45 | }); 46 | this._paper.on({ 47 | 'blank:contextmenu': (event) => { 48 | this._viewMenu.position(event.clientX, event.clientY); 49 | this._viewMenu.hidden = false; 50 | } 51 | }); 52 | 53 | this._paper.on({ 54 | 'cell:mouseenter': (cellView) => { 55 | let selectedEdge; 56 | let selectedEdgeId; 57 | const node = this.getNode(cellView.model.id); 58 | if (node && node.state !== GraphViewNode.STATES.SELECTED) { 59 | node.hover(); 60 | selectedEdge = this._parent._selectedItem && this._parent._selectedItem._type === 'EDGE' ? this.getEdge(this._parent._selectedItem._id) : null; 61 | if (selectedEdge) selectedEdgeId = selectedEdge.model.id; 62 | if (this._config.edgeHoverEffect) { 63 | Object.keys(this._edges).forEach((edgeKey) => { 64 | const currEdge = this.getEdge(edgeKey); 65 | if (currEdge.model.id === selectedEdgeId) return; 66 | if (![currEdge.edgeData.from, currEdge.edgeData.to].includes(node.nodeData.id)) { 67 | currEdge.mute(); 68 | } else { 69 | currEdge.deselect(); 70 | } 71 | }); 72 | } 73 | } 74 | const edge = this.getEdge(cellView.model.id); 75 | if (this._config.edgeHoverEffect && edge && edge.state !== GraphViewEdge.STATES.SELECTED) { 76 | edge.deselect(); 77 | selectedEdge = this._parent._selectedItem && this._parent._selectedItem._type === 'EDGE' ? this.getEdge(this._parent._selectedItem._id) : null; 78 | if (selectedEdge) selectedEdgeId = selectedEdge.model.id; 79 | Object.keys(this._edges).forEach((edgeKey) => { 80 | const currEdge = this.getEdge(edgeKey); 81 | if ((edge.model.id !== currEdge.model.id) && (selectedEdgeId !== currEdge.model.id)) { 82 | currEdge.mute(); 83 | } 84 | }); 85 | this.getNode(edge.edgeData.from).hover(); 86 | this.getNode(edge.edgeData.to).hover(); 87 | } 88 | }, 89 | 'cell:mouseleave': (cellView, e) => { 90 | let selectedEdge; 91 | 92 | if (e.relatedTarget && e.relatedTarget.classList.contains('graph-node-input')) return; 93 | const node = this.getNode(cellView.model.id); 94 | if (node && node.state !== GraphViewNode.STATES.SELECTED) { 95 | selectedEdge = this._parent._selectedItem && this._parent._selectedItem._type === 'EDGE' ? this.getEdge(this._parent._selectedItem._id) : null; 96 | if (!selectedEdge || ![selectedEdge.edgeData.from, selectedEdge.edgeData.to].includes(node.nodeData.id)) { 97 | node.hoverRemove(); 98 | } 99 | if (this._config.edgeHoverEffect) { 100 | Object.keys(this._edges).forEach((edgeKey) => { 101 | const currEdge = this.getEdge(edgeKey); 102 | if (selectedEdge && currEdge.model.id === selectedEdge.model.id) return; 103 | currEdge.deselect(); 104 | }); 105 | } 106 | } 107 | const edge = this.getEdge(cellView.model.id); 108 | if (this._config.edgeHoverEffect && edge && edge.state !== GraphViewEdge.STATES.SELECTED) { 109 | Object.keys(this._edges).forEach((edgeKey) => { 110 | const currEdge = this.getEdge(edgeKey); 111 | if (currEdge.state === GraphViewEdge.STATES.SELECTED) { 112 | currEdge.select(); 113 | } else if (currEdge.state === GraphViewEdge.STATES.DEFAULT) { 114 | if (this._parent._selectedItem && this._parent._selectedItem._type === 'EDGE') { 115 | currEdge.mute(); 116 | } else { 117 | currEdge.deselect(); 118 | } 119 | } 120 | }); 121 | selectedEdge = this._parent._selectedItem && this._parent._selectedItem._type === 'EDGE' ? this.getEdge(this._parent._selectedItem._id) : null; 122 | if (!selectedEdge || ![selectedEdge.edgeData.from, selectedEdge.edgeData.to].includes(edge.edgeData.from)) { 123 | this.getNode(edge.edgeData.from).hoverRemove(); 124 | } 125 | if (!selectedEdge || ![selectedEdge.edgeData.from, selectedEdge.edgeData.to].includes(edge.edgeData.to)) { 126 | this.getNode(edge.edgeData.to).hoverRemove(); 127 | } 128 | } 129 | } 130 | }); 131 | } 132 | 133 | batchCells() { 134 | this._batchingCells = true; 135 | } 136 | 137 | isBatchingCells() { 138 | return this._batchingCells; 139 | } 140 | 141 | addCellMountedFunction(f) { 142 | this._cellMountedFunctions.push(f); 143 | } 144 | 145 | applyBatchedCells() { 146 | this._batchingCells = false; 147 | this._graph.addCells(this._cells); 148 | this._cellMountedFunctions.forEach(f => f()); 149 | this._cells = []; 150 | this._cellMountedFunctions = []; 151 | } 152 | 153 | updatePortStatesForEdge(cell, connected) { 154 | const source = cell.get('source'); 155 | const target = cell.get('target'); 156 | if (source && source.port && target && target.port) { 157 | this._paper.findViewByModel(source.id)._portElementsCache[source.port].portContentElement.children()[1].attr('visibility', connected ? 'visible' : 'hidden'); 158 | this._paper.findViewByModel(target.id)._portElementsCache[target.port].portContentElement.children()[1].attr('visibility', connected ? 'visible' : 'hidden'); 159 | } 160 | } 161 | 162 | getWindowToGraphPosition(pos, usePaperPosition = true) { 163 | const scale = this._paper.scale().sx; 164 | const translate = this._paper.translate(); 165 | if (usePaperPosition) { 166 | const paperPosition = this._paper.el.getBoundingClientRect(); 167 | pos.x -= paperPosition.x; 168 | pos.y -= paperPosition.y; 169 | } 170 | return new Vec2( 171 | (-translate.tx / scale) + (pos.x / scale), 172 | (-translate.ty / scale) + (pos.y / scale) 173 | ); 174 | } 175 | 176 | addCanvasContextMenu(items) { 177 | this._viewMenu = new Menu({ 178 | items: items 179 | }); 180 | this._paper.el.appendChild(this._viewMenu.dom); 181 | return this._viewMenu._contextMenuEvent; 182 | } 183 | 184 | addNodeContextMenu(id, items) { 185 | const addNodeContextMenuFunction = () => { 186 | const node = this.getNode(id); 187 | node.addContextMenu(items); 188 | }; 189 | if (this._batchingCells) { 190 | this._cellMountedFunctions.push(addNodeContextMenuFunction); 191 | } else { 192 | addNodeContextMenuFunction(); 193 | } 194 | } 195 | 196 | addEdgeContextMenu(id, items) { 197 | const edge = this.getEdge(id); 198 | edge.addContextMenu(items); 199 | } 200 | 201 | getNode(id) { 202 | return this._nodes[id]; 203 | } 204 | 205 | addNode(nodeData, nodeSchema, onCreateEdge, onNodeSelected) { 206 | const node = new GraphViewNode( 207 | this, 208 | this._paper, 209 | this._graph, 210 | this._graphSchema, 211 | nodeData, 212 | nodeSchema, 213 | onCreateEdge, 214 | onNodeSelected 215 | ); 216 | 217 | this._nodes[nodeData.id] = node; 218 | this._nodes[node.model.id] = node; 219 | 220 | return node.nodeData; 221 | } 222 | 223 | removeNode(modelId) { 224 | const node = this.getNode(modelId); 225 | this._graph.removeCells(node.model); 226 | delete this._nodes[node.nodeData.id]; 227 | delete this._nodes[modelId]; 228 | } 229 | 230 | updateNodeAttribute(id, attribute, value) { 231 | this.getNode(id).updateAttribute(attribute, value); 232 | } 233 | 234 | setNodeAttributeErrorState(id, attribute, value) { 235 | this.getNode(id).setAttributeErrorState(attribute, value); 236 | } 237 | 238 | updateNodePosition(id, pos) { 239 | this.getNode(id).updatePosition(pos); 240 | } 241 | 242 | updateNodeType(id, nodeType) { 243 | this.getNode(id).updateNodeType(nodeType); 244 | } 245 | 246 | addNodeEvent(id, event, callback, attribute) { 247 | const addNodeEventFunction = () => { 248 | const node = this.getNode(id); 249 | node.addEvent(event, callback, attribute); 250 | }; 251 | if (this._batchingCells) { 252 | this._cellMountedFunctions.push(addNodeEventFunction); 253 | } else { 254 | addNodeEventFunction(); 255 | } 256 | } 257 | 258 | getEdge(id) { 259 | if (this._edges[id]) { 260 | return this._edges[id]; 261 | } 262 | } 263 | 264 | addEdge(edgeData, edgeSchema, onEdgeSelected) { 265 | let edge; 266 | if (Number.isFinite(edgeData.outPort)) { 267 | edge = this.getEdge(`${edgeData.from},${edgeData.outPort}-${edgeData.to},${edgeData.inPort}`); 268 | } else { 269 | edge = this.getEdge(`${edgeData.from}-${edgeData.to}`); 270 | } 271 | if (edge) { 272 | if (edgeData.to === edge.edgeData.to) { 273 | if (!edgeData.outPort) { 274 | edge.addTargetMarker(); 275 | } 276 | } else { 277 | if (!edgeData.inPort) { 278 | edge.addSourceMarker(); 279 | } 280 | } 281 | } else { 282 | edge = new GraphViewEdge( 283 | this, 284 | this._paper, 285 | this._graph, 286 | this._graphSchema, 287 | edgeData, 288 | edgeSchema, 289 | onEdgeSelected 290 | ); 291 | if (Number.isFinite(edgeData.outPort)) { 292 | this._edges[`${edgeData.from},${edgeData.outPort}-${edgeData.to},${edgeData.inPort}`] = edge; 293 | } else { 294 | this._edges[`${edgeData.from}-${edgeData.to}`] = edge; 295 | } 296 | this._edges[edge.model.id] = edge; 297 | } 298 | return edge.edgeData; 299 | } 300 | 301 | removeEdge(id) { 302 | const edge = this.getEdge(id); 303 | if (edge) { 304 | this._graph.removeCells(edge.model); 305 | delete this._edges[edge.model.id]; 306 | } 307 | delete this._edges[id]; 308 | } 309 | 310 | disableInputEvents() { 311 | document.querySelectorAll('.graph-node-input').forEach((input) => { 312 | input.classList.add('graph-node-input-no-pointer-events'); 313 | }); 314 | } 315 | 316 | enableInputEvents() { 317 | document.querySelectorAll('.graph-node-input').forEach((input) => { 318 | input.classList.remove('graph-node-input-no-pointer-events'); 319 | }); 320 | } 321 | 322 | addUnconnectedEdge(nodeId, edgeType, edgeSchema, validateEdge, onEdgeConnected) { 323 | this.disableInputEvents(); 324 | const link = GraphViewEdge.createLink(this._config.defaultStyles, edgeSchema); 325 | link.source(this.getNode(nodeId).model); 326 | link.target(this.getNode(nodeId).model); 327 | const mouseMoveEvent = (e) => { 328 | const mousePos = this.getWindowToGraphPosition(new Vec2(e.clientX, e.clientY)); 329 | const sourceNodeView = this._paper.findViewByModel(this.getNode(nodeId).model); 330 | const sourceNodePos = this.getGraphPosition(sourceNodeView.el.getBoundingClientRect()); 331 | let pointerVector = mousePos.clone().sub(sourceNodePos); 332 | const direction = (new Vec2(e.clientX, e.clientY)).clone().sub(sourceNodeView.el.getBoundingClientRect()).normalize().mulScalar(20); 333 | pointerVector = sourceNodePos.add(pointerVector).sub(direction); 334 | link.target({ 335 | x: pointerVector.x, 336 | y: pointerVector.y 337 | }); 338 | }; 339 | const cellPointerDownEvent = (cellView) => { 340 | if (!this.getNode(cellView.model.id)) return; 341 | const targetNodeId = this.getNode(cellView.model.id).nodeData.id; 342 | const nodeModel = this.getNode(nodeId).model; 343 | // test whether a valid connection has been made 344 | if ((cellView.model.id !== nodeModel.id) && !cellView.model.isLink() && validateEdge(edgeType, nodeId, targetNodeId)) { 345 | link.target(cellView.model); 346 | onEdgeConnected(edgeType, nodeId, targetNodeId); 347 | } 348 | this._graph.removeCells(link); 349 | document.removeEventListener('mousemove', mouseMoveEvent); 350 | this._paper.off('cell:pointerdown', cellPointerDownEvent); 351 | this.enableInputEvents(); 352 | }; 353 | const mouseDownEvent = () => { 354 | this._paper.off('cell:pointerdown', cellPointerDownEvent); 355 | document.removeEventListener('mousemove', mouseMoveEvent); 356 | this._graph.removeCells(link); 357 | this.enableInputEvents(); 358 | }; 359 | 360 | document.addEventListener('mousemove', mouseMoveEvent); 361 | document.addEventListener('mousedown', mouseDownEvent); 362 | this._paper.on('cell:pointerdown', cellPointerDownEvent); 363 | 364 | this._graph.addCell(link); 365 | } 366 | 367 | onBlankSelection(callback) { 368 | this._paper.on('blank:pointerdown', () => { 369 | callback(); 370 | }); 371 | } 372 | 373 | selectNode(id) { 374 | const node = this.getNode(id); 375 | if (node) { 376 | node.select(); 377 | Object.keys(this._edges).forEach((edgeKey) => { 378 | const currEdge = this.getEdge(edgeKey); 379 | currEdge.deselect(); 380 | }); 381 | } 382 | } 383 | 384 | deselectNode(id) { 385 | const node = this.getNode(id); 386 | if (node) node.deselect(); 387 | } 388 | 389 | selectEdge(id) { 390 | const edge = this.getEdge(id); 391 | if (edge) { 392 | edge.select(); 393 | Object.keys(this._edges).forEach((edgeKey) => { 394 | const currEdge = this.getEdge(edgeKey); 395 | if (edge.model.id !== currEdge.model.id) { 396 | currEdge.mute(); 397 | } 398 | }); 399 | this.getNode(edge.edgeData.from).hover(); 400 | this.getNode(edge.edgeData.to).hover(); 401 | } 402 | } 403 | 404 | deselectEdge(id) { 405 | const edge = this.getEdge(id); 406 | if (edge) { 407 | Object.keys(this._edges).forEach((edgeKey) => { 408 | const currEdge = this.getEdge(edgeKey); 409 | currEdge.deselect(); 410 | }); 411 | this.getNode(edge.edgeData.from).hoverRemove(); 412 | this.getNode(edge.edgeData.to).hoverRemove(); 413 | } 414 | } 415 | 416 | setGraphPosition(posX, posY) { 417 | this._paper.translate(posX, posY); 418 | } 419 | 420 | getGraphPosition() { 421 | const t = this._paper.translate(); 422 | return new Vec2([t.tx, t.ty]); 423 | } 424 | 425 | setGraphScale(scale) { 426 | this._paper.scale(scale); 427 | } 428 | 429 | getGraphScale() { 430 | return this._paper.scale().sx; 431 | } 432 | 433 | getNodeDomElement(id) { 434 | return this.getNode(id).model.findView(this._paper).el; 435 | } 436 | 437 | getEdgeDomElement(id) { 438 | return this.getEdge(id).model.findView(this._paper).el; 439 | } 440 | 441 | destroy() { 442 | this._graph.clear(); 443 | this._paper.remove(); 444 | } 445 | } 446 | 447 | export default GraphView; 448 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The PCUIGraph module is an extension of the PlayCanvas User Interface (PCUI) framework. It 3 | * provides a new PCUI Element type for building interactive, node-based graphs. 4 | * 5 | * Key features include: 6 | * 7 | * - Scalable and customizable node-based graphs for visualizing complex data. 8 | * - Interactive elements such as draggable nodes, clickable edges, and zoomable views. 9 | * - Easy integration within a PCUI-based user interface. 10 | * 11 | * Whether it's for displaying network topologies, process flows, or complex relational data, 12 | * PCUIGraph provides a robust and flexible solution for integrating graph visualizations into your 13 | * web projects. 14 | * 15 | * @module PCUIGraph 16 | */ 17 | 18 | import { Observer } from '@playcanvas/observer'; 19 | import { Element } from '@playcanvas/pcui'; 20 | 21 | import { GRAPH_ACTIONS, DEFAULT_CONFIG } from './constants.js'; 22 | import GraphView from './graph-view.js'; 23 | import SelectedItem from './selected-item.js'; 24 | import { deepCopyFunction } from './util.js'; 25 | 26 | /** 27 | * Represents a new Graph. 28 | */ 29 | class Graph extends Element { 30 | /** 31 | * Creates a new Graph. 32 | * 33 | * @param {object} schema - The graph schema. 34 | * @param {object} [options] - The graph configuration. Optional. 35 | * @param {object} [options.initialData] - The graph data to initialize the graph with. 36 | * @param {HTMLElement} [options.dom] - If supplied, the graph will be attached to this element. 37 | * @param {object[]} [options.contextMenuItems] - The context menu items to add to the graph. 38 | * @param {boolean} [options.readOnly] - Whether the graph is read only. Optional. Defaults to 39 | * false. 40 | * @param {boolean} [options.passiveUIEvents] - If true, the graph will not update its data and 41 | * view upon user interaction. Instead, these interactions can be handled explicitly by 42 | * listening to fired events. Optional. Defaults to false. 43 | * @param {boolean} [options.incrementNodeNames] - Whether the graph should increment the node 44 | * name when a node with the same name already exists. Optional. Defaults to false. 45 | * @param {boolean} [options.restrictTranslate] - Whether the graph should restrict the 46 | * translate graph operation to the graph area. Optional. Defaults to false. 47 | * @param {boolean} [options.edgeHoverEffect] - Whether the graph should show an edge highlight 48 | * effect when the mouse is hovering over edges. Optional. Defaults to true. 49 | * @param {object} [options.defaultStyles] - Used to override the graph's default styling. Check 50 | * ./constants.js for a full list of style properties. 51 | * @param {object} [options.adjustVertices] - If true, multiple edges connected between two 52 | * nodes will be spaced apart. 53 | */ 54 | constructor(schema, options = {}) { 55 | super({ 56 | dom: options.dom 57 | }); 58 | this.class.add('pcui-graph'); 59 | this._graphSchema = schema; 60 | this._graphData = new Observer({ data: options.initialData ? options.initialData : {} }); 61 | this._contextMenuItems = options.contextMenuItems || []; 62 | this._suppressGraphDataEvents = false; 63 | 64 | this._config = { 65 | ...DEFAULT_CONFIG, 66 | readOnly: options.readOnly, 67 | passiveUIEvents: options.passiveUIEvents, 68 | incrementNodeNames: options.incrementNodeNames, 69 | restrictTranslate: options.restrictTranslate, 70 | edgeHoverEffect: options.edgeHoverEffect, 71 | includeFonts: options.includeFonts, 72 | adjustVertices: options.adjustVertices 73 | }; 74 | if (options.defaultStyles) { 75 | if (options.defaultStyles.background) { 76 | this._config.defaultStyles.background = { 77 | ...this._config.defaultStyles.background, 78 | ...options.defaultStyles.background 79 | }; 80 | } 81 | if (options.defaultStyles.edge) { 82 | this._config.defaultStyles.edge = { 83 | ...this._config.defaultStyles.edge, 84 | ...options.defaultStyles.edge 85 | }; 86 | } 87 | if (options.defaultStyles.node) { 88 | this._config.defaultStyles.node = { 89 | ...this._config.defaultStyles.node, 90 | ...options.defaultStyles.node 91 | }; 92 | } 93 | } 94 | if (this._config.readOnly) this._config.selfContainedMode = true; 95 | 96 | this._buildGraphFromData(); 97 | if (options.defaultStyles.initialScale) { 98 | this.setGraphScale(options.defaultStyles.initialScale); 99 | } 100 | if (options.defaultStyles.initialPosition) { 101 | this.setGraphPosition(options.defaultStyles.initialPosition.x, options.defaultStyles.initialPosition.y); 102 | } 103 | } 104 | 105 | /** 106 | * The current graph data. Contains an object with any nodes and edges present in the graph. 107 | * This can be passed into the graph constructor to reload the current graph. 108 | * 109 | * @type {object} 110 | */ 111 | get data() { 112 | return this._graphData.get('data'); 113 | } 114 | 115 | /** 116 | * Destroy the graph. Clears the graph from the DOM and removes all event listeners associated 117 | * with the graph. 118 | */ 119 | destroy() { 120 | this.view.destroy(); 121 | } 122 | 123 | _buildGraphFromData() { 124 | this.view = new GraphView(this, this.dom, this._graphSchema, this._graphData, this._config); 125 | 126 | this.view.batchCells(); 127 | const nodes = this._graphData.get('data.nodes'); 128 | if (nodes) { 129 | Object.keys(nodes).forEach((nodeKey) => { 130 | const node = nodes[nodeKey]; 131 | const nodeSchema = this._graphSchema.nodes[node.nodeType]; 132 | if (nodeSchema.attributes) { 133 | if (nodeSchema.attributes && !node.attributes) { 134 | node.attributes = {}; 135 | } 136 | nodeSchema.attributes.forEach((attribute) => { 137 | if (!node.attributes[attribute.name] && attribute.defaultValue) { 138 | this._suppressGraphDataEvents = true; 139 | this._graphData.set(`data.nodes.${nodeKey}.attributes.${attribute.name}`, attribute.defaultValue); 140 | this._suppressGraphDataEvents = false; 141 | } 142 | }); 143 | } 144 | this.createNode(this._graphData.get(`data.nodes.${nodeKey}`), undefined, true); 145 | }); 146 | } 147 | const edges = this._graphData.get('data.edges'); 148 | if (edges) { 149 | Object.keys(edges).forEach((edgeKey) => { 150 | this.createEdge(edges[edgeKey], edgeKey, true); 151 | }); 152 | } 153 | this.view.applyBatchedCells(); 154 | 155 | // handle context menus 156 | if (!this._config.readOnly) { 157 | this._addCanvasContextMenu(); 158 | } 159 | 160 | this._selectedItem = null; 161 | this.view.onBlankSelection(() => { 162 | this._dispatchEvent(GRAPH_ACTIONS.DESELECT_ITEM, { prevItem: this._selectedItem }); 163 | }); 164 | 165 | if (!this._config.passiveUIEvents) { 166 | this._registerInternalEventListeners(); 167 | } 168 | } 169 | 170 | _addCanvasContextMenu() { 171 | const updateItem = (item) => { 172 | switch (item.action) { 173 | case GRAPH_ACTIONS.ADD_NODE: { 174 | item.onSelect = (e) => { 175 | const node = { 176 | ...item, 177 | id: Number(`${Date.now()}${Math.floor(Math.random() * 10000)}`) 178 | }; 179 | if (item.attributes) { 180 | node.attributes = { ...item.attributes }; 181 | } 182 | delete node.action; 183 | delete node.text; 184 | delete node.onClick; 185 | const nodeSchema = this._graphSchema.nodes[node.nodeType]; 186 | if (nodeSchema.attributes && !node.attributes) { 187 | node.attributes = {}; 188 | } 189 | if (nodeSchema.attributes) { 190 | nodeSchema.attributes.forEach((attribute) => { 191 | if (!node.attributes[attribute.name] && attribute.defaultValue) { 192 | node.attributes[attribute.name] = attribute.defaultValue; 193 | } 194 | }); 195 | } 196 | if (this._config.incrementNodeNames && node.attributes.name) { 197 | node.attributes.name = `${node.attributes.name} ${Object.keys(this._graphData.get('data.nodes')).length}`; 198 | } 199 | let element = e.target; 200 | while (!element.classList.contains('pcui-menu-items')) { 201 | element = element.parentElement; 202 | } 203 | let pos = { 204 | x: Number(element.style.left.replace('px', '')), 205 | y: Number(element.style.top.replace('px', '')) 206 | }; 207 | pos = this.getWindowToGraphPosition(pos); 208 | node.posX = pos.x; 209 | node.posY = pos.y; 210 | this._dispatchEvent(GRAPH_ACTIONS.ADD_NODE, { node }); 211 | }; 212 | } 213 | } 214 | return item; 215 | }; 216 | const viewContextMenuItems = this._contextMenuItems.map((item) => { 217 | item = updateItem(item); 218 | if (!item.items) return item; 219 | item.items.map((subitem) => { 220 | return updateItem(subitem); 221 | }); 222 | return item; 223 | }); 224 | this.view.addCanvasContextMenu(viewContextMenuItems); 225 | } 226 | 227 | /** 228 | * Select a node in the current graph. 229 | * 230 | * @param {object} node - The node to select 231 | */ 232 | selectNode(node) { 233 | this.deselectItem(); 234 | this._selectedItem = new SelectedItem(this, 'NODE', node.id); 235 | this._selectedItem.selectItem(); 236 | } 237 | 238 | /** 239 | * Select an edge in the current graph. 240 | * 241 | * @param {object} edge - The edge to select 242 | * @param {number} edgeId - The edge id of the edge to select 243 | */ 244 | selectEdge(edge, edgeId) { 245 | this.deselectItem(); 246 | this._selectedItem = new SelectedItem(this, 'EDGE', `${edge.from}-${edge.to}`, edgeId); 247 | this._selectedItem.selectItem(); 248 | } 249 | 250 | /** 251 | * Deselect the currently selected item in the graph. 252 | */ 253 | deselectItem() { 254 | if (this._selectedItem) { 255 | this._selectedItem.deselectItem(); 256 | this._selectedItem = null; 257 | } 258 | } 259 | 260 | _isValidEdge(edgeType, source, target) { 261 | const edge = this._graphSchema.edges[edgeType]; 262 | return edge.from.includes(this._graphData.get(`data.nodes.${source}.nodeType`)) && edge.to.includes(this._graphData.get(`data.nodes.${target}.nodeType`)); 263 | } 264 | 265 | /** 266 | * Add an edge to the graph. 267 | * 268 | * @param {object} edge - The edge to add. 269 | * @param {number} edgeId - The edge id for the new edge. 270 | */ 271 | createEdge(edge, edgeId) { 272 | const edgeSchema = this._graphSchema.edges[edge.edgeType]; 273 | this.view.addEdge(edge, edgeSchema, (edge) => { 274 | this._dispatchEvent(GRAPH_ACTIONS.SELECT_EDGE, { edge, prevItem: this._selectedItem }); 275 | }); 276 | if (edgeSchema.contextMenuItems) { 277 | const contextMenuItems = deepCopyFunction(edgeSchema.contextMenuItems).map((item) => { 278 | if (item.action === GRAPH_ACTIONS.DELETE_EDGE) { 279 | item.onSelect = () => { 280 | this._dispatchEvent(GRAPH_ACTIONS.DELETE_EDGE, { edgeId: edgeId, edge: this._graphData.get(`data.edges.${edgeId}`) }); 281 | }; 282 | } 283 | return item; 284 | }); 285 | const addEdgeContextMenuFunction = () => { 286 | if (Number.isFinite(edge.outPort)) { 287 | this.view.addEdgeContextMenu(`${edge.from},${edge.outPort}-${edge.to},${edge.inPort}`, contextMenuItems); 288 | } else { 289 | this.view.addEdgeContextMenu(`${edge.from}-${edge.to}`, contextMenuItems); 290 | } 291 | }; 292 | 293 | if (this.view.isBatchingCells()) { 294 | this.view.addCellMountedFunction(addEdgeContextMenuFunction); 295 | } else { 296 | addEdgeContextMenuFunction(); 297 | } 298 | } 299 | 300 | if (!this._graphData.get(`data.edges.${edgeId}`)) { 301 | this._graphData.set(`data.edges.${edgeId}`, edge); 302 | } 303 | } 304 | 305 | 306 | _onEdgeConnected(edgeType, from, to) { 307 | const edgeId = Number(`${Date.now()}${Math.floor(Math.random() * 10000)}`); 308 | const edge = { 309 | from: from, 310 | to: to, 311 | edgeType: edgeType, 312 | conditions: {} 313 | }; 314 | this._dispatchEvent(GRAPH_ACTIONS.ADD_EDGE, { edge, edgeId }); 315 | } 316 | 317 | _createUnconnectedEdgeForNode(node, edgeType) { 318 | const edgeSchema = this._graphSchema.edges[edgeType]; 319 | this.view.addUnconnectedEdge(node.id, edgeType, edgeSchema, this._isValidEdge.bind(this), this._onEdgeConnected.bind(this)); 320 | } 321 | 322 | _onCreateEdge(edgeId, edge) { 323 | this._dispatchEvent(GRAPH_ACTIONS.ADD_EDGE, { edge, edgeId }); 324 | } 325 | 326 | _onNodeSelected(node) { 327 | if (this.suppressNodeSelect) { 328 | this.suppressNodeSelect = false; 329 | } else { 330 | this._dispatchEvent(GRAPH_ACTIONS.SELECT_NODE, { node, prevItem: this._selectedItem }); 331 | } 332 | } 333 | 334 | _onNodePositionUpdated(nodeId, pos) { 335 | const node = this._graphData.get(`data.nodes.${nodeId}`); 336 | const prevPosX = node.posX; 337 | const prevPosY = node.posY; 338 | if (pos.x !== node.posX || pos.y !== node.posY) { 339 | node.posX = pos.x; 340 | node.posY = pos.y; 341 | this.updateNodePosition(nodeId, { x: prevPosX, y: prevPosY }); 342 | this._dispatchEvent(GRAPH_ACTIONS.UPDATE_NODE_POSITION, { nodeId, node }); 343 | } 344 | } 345 | 346 | _onNodeAttributeUpdated(nodeId, attribute, value) { 347 | const node = this._graphData.get(`data.nodes.${nodeId}`); 348 | let prevAttributeValue; 349 | let attributeKey = node.attributes[attribute.name] !== undefined ? attribute.name : undefined; 350 | if (!attributeKey) { 351 | Object.keys(node.attributes).forEach((k) => { 352 | const item = node.attributes[k]; 353 | if (item.name === attribute.name) attributeKey = k; 354 | }); 355 | } 356 | if (Number.isFinite(node.attributes[attributeKey].x)) { 357 | prevAttributeValue = { ...node.attributes[attributeKey] }; 358 | } else { 359 | prevAttributeValue = node.attributes[attributeKey]; 360 | } 361 | if (Array.isArray(value)) { 362 | const keyMap = ['x', 'y', 'z', 'w']; 363 | value.forEach((v, i) => { 364 | node.attributes[attributeKey][keyMap[i]] = v; 365 | }); 366 | } else if (Object.keys(prevAttributeValue).includes('x') && Number.isFinite(value)) { 367 | node.attributes[attributeKey].x = value; 368 | } else { 369 | node.attributes[attributeKey] = value; 370 | } 371 | if (JSON.stringify(node.attributes[attributeKey]) === JSON.stringify(prevAttributeValue)) return; 372 | this.updateNodeAttribute(nodeId, attribute.name, value); 373 | this._dispatchEvent( 374 | GRAPH_ACTIONS.UPDATE_NODE_ATTRIBUTE, 375 | { 376 | node: node, 377 | attribute: attribute.name, 378 | attributeKey: attributeKey 379 | } 380 | ); 381 | } 382 | 383 | _initializeNodeContextMenuItems(node, items) { 384 | const contextMenuItems = deepCopyFunction(items).map((item) => { 385 | if (item.action === GRAPH_ACTIONS.ADD_EDGE) { 386 | item.onSelect = () => this._createUnconnectedEdgeForNode(node, item.edgeType); 387 | } 388 | if (item.action === GRAPH_ACTIONS.DELETE_NODE) { 389 | item.onSelect = () => { 390 | this._dispatchEvent(GRAPH_ACTIONS.DELETE_NODE, this._deleteNode(node.id)); 391 | }; 392 | } 393 | return item; 394 | }); 395 | return contextMenuItems; 396 | } 397 | 398 | /** 399 | * Add a node to the graph. 400 | * 401 | * @param {object} node - The node to add. 402 | */ 403 | createNode(node) { 404 | const nodeSchema = this._graphSchema.nodes[node.nodeType]; 405 | node = this.view.addNode( 406 | node, 407 | nodeSchema, 408 | this._onCreateEdge.bind(this), 409 | this._onNodeSelected.bind(this) 410 | ); 411 | 412 | if (!this._graphData.get(`data.nodes.${node.id}`)) { 413 | this._graphData.set(`data.nodes.${node.id}`, node); 414 | } 415 | this.view.addNodeEvent( 416 | node.id, 417 | 'updatePosition', 418 | this._onNodePositionUpdated.bind(this) 419 | ); 420 | if (nodeSchema.attributes) { 421 | nodeSchema.attributes.forEach((attribute) => { 422 | this.view.addNodeEvent( 423 | node.id, 424 | 'updateAttribute', 425 | this._onNodeAttributeUpdated.bind(this), 426 | attribute 427 | ); 428 | }); 429 | } 430 | if (nodeSchema.contextMenuItems) { 431 | const contextMenuItems = this._initializeNodeContextMenuItems(node, nodeSchema.contextMenuItems); 432 | this.view.addNodeContextMenu(node.id, contextMenuItems); 433 | } 434 | } 435 | 436 | /** 437 | * Update the position of a node. 438 | * 439 | * @param {number} nodeId - The node to add. 440 | * @param {object} pos - The new position, given as an object containing x and y properties. 441 | */ 442 | updateNodePosition(nodeId, pos) { 443 | if (!this._graphData.get(`data.nodes.${nodeId}`)) return; 444 | this._graphData.set(`data.nodes.${nodeId}.posX`, pos.x); 445 | this._graphData.set(`data.nodes.${nodeId}.posY`, pos.y); 446 | this.view.updateNodePosition(nodeId, pos); 447 | } 448 | 449 | /** 450 | * Update the value of an attribute of a node. 451 | * 452 | * @param {number} nodeId - The node to update. 453 | * @param {string} attributeName - The name of the attribute to update. 454 | * @param {object} value - The new value for the attribute. 455 | */ 456 | updateNodeAttribute(nodeId, attributeName, value) { 457 | if (!this._graphData.get(`data.nodes.${nodeId}`)) return; 458 | this._graphData.set(`data.nodes.${nodeId}.attributes.${attributeName}`, value); 459 | this.view.updateNodeAttribute(nodeId, attributeName, value); 460 | } 461 | 462 | /** 463 | * Set the error state of a node attribute. 464 | * 465 | * @param {number} nodeId - The node to update. 466 | * @param {string} attributeName - The name of the attribute to update. 467 | * @param {boolean} value - Whether the attribute should be set in the error state. 468 | */ 469 | setNodeAttributeErrorState(nodeId, attributeName, value) { 470 | if (!this._graphData.get(`data.nodes.${nodeId}`)) return; 471 | this.view.setNodeAttributeErrorState(nodeId, attributeName, value); 472 | } 473 | 474 | /** 475 | * Update the type of a node. 476 | * 477 | * @param {number} nodeId - The node to update. 478 | * @param {string} nodeType - The new type for the node. 479 | */ 480 | updateNodeType(nodeId, nodeType) { 481 | if (Number.isFinite(nodeType) && this._graphData.get(`data.nodes.${nodeId}`)) { 482 | this._graphData.set(`data.nodes.${nodeId}.nodeType`, nodeType); 483 | this.view.updateNodeType(nodeId, nodeType); 484 | } 485 | } 486 | 487 | _deleteNode(nodeId) { 488 | if (!this._graphData.get(`data.nodes.${nodeId}`)) return; 489 | if (this._selectedItem && this._selectedItem._id === nodeId) this.deselectItem(); 490 | const node = this._graphData.get(`data.nodes.${nodeId}`); 491 | const edges = []; 492 | const edgeData = {}; 493 | const edgeKeys = Object.keys(this._graphData.get('data.edges')); 494 | for (let i = 0; i < edgeKeys.length; i++) { 495 | const edge = this._graphData.get(`data.edges.${edgeKeys[i]}`); 496 | edgeData[edgeKeys[i]] = edge; 497 | if (edge.from === nodeId || edge.to === nodeId) { 498 | edges.push(edgeKeys[i]); 499 | } 500 | } 501 | return { node, edges, edgeData }; 502 | } 503 | 504 | /** 505 | * Delete a node from the graph. 506 | * 507 | * @param {number} nodeId - The node to delete. 508 | */ 509 | deleteNode(nodeId) { 510 | const { node, edges, edgeData } = this._deleteNode(nodeId); 511 | Object.values(edges).forEach((e) => { 512 | const edge = edgeData[e]; 513 | this.deleteEdge(`${edge.from}-${edge.to}`); 514 | }); 515 | this._graphData.unset(`data.nodes.${nodeId}`); 516 | this.view.removeNode(node.id); 517 | } 518 | 519 | /** 520 | * Delete an edge from the graph. 521 | * 522 | * @param {string} edgeId - The edge to delete. 523 | */ 524 | deleteEdge(edgeId) { 525 | if (!this._graphData.get(`data.edges.${edgeId}`)) return; 526 | const { from, to, outPort, inPort } = this._graphData.get(`data.edges.${edgeId}`) || {}; 527 | if (this._selectedItem && this._selectedItem._id === `${from}-${to}`) this.deselectItem(); 528 | 529 | if (Number.isFinite(outPort)) { 530 | this.view.removeEdge(`${from},${outPort}-${to},${inPort}`); 531 | } else { 532 | this.view.removeEdge(`${from}-${to}`); 533 | } 534 | this.view.removeEdge(`${from}-${to}`); 535 | this._graphData.unset(`data.edges.${edgeId}`); 536 | const edges = this._graphData.get('data.edges'); 537 | Object.keys(edges).forEach((edgeKey) => { 538 | const edge = edges[edgeKey]; 539 | const edgeSchema = this._graphSchema.edges[edge.edgeType]; 540 | if ([edge.from, edge.to].includes(from) && [edge.from, edge.to].includes(to)) { 541 | this.view.addEdge(edge, edgeSchema, (edge) => { 542 | this.selectEdge(edge, edgeKey); 543 | }); 544 | this.selectEdge(edge, edgeKey); 545 | } 546 | }); 547 | } 548 | 549 | /** 550 | * Set the center of the viewport to the given position. 551 | * 552 | * @param {number} posX - The x position to set the center of the viewport to. 553 | * @param {number} posY - The y position to set the center of the viewport to. 554 | */ 555 | setGraphPosition(posX, posY) { 556 | this.view.setGraphPosition(posX, posY); 557 | } 558 | 559 | /** 560 | * Get the current center position of the viewport in the graph. 561 | * 562 | * @returns {object} The current center position of the viewport in the graph as an object 563 | * containing x and y. 564 | */ 565 | getGraphPosition() { 566 | return this.view.getGraphPosition(); 567 | } 568 | 569 | /** 570 | * Set the scale of the graph. 571 | * 572 | * @param {number} scale - The new scale of the graph. 573 | */ 574 | setGraphScale(scale) { 575 | this.view.setGraphScale(scale); 576 | Object.keys(this.view._nodes).forEach((nodeKey) => { 577 | this.view._paper.findViewByModel(this.view._nodes[nodeKey].model).updateBox(); 578 | }); 579 | } 580 | 581 | /** 582 | * Get the current scale of the graph. 583 | * 584 | * @returns {number} The current scale of the graph. 585 | */ 586 | getGraphScale() { 587 | return this.view.getGraphScale(); 588 | } 589 | 590 | /** 591 | * Convert a position in window space to a position in graph space. 592 | * 593 | * @param {object} pos - A position in the window, as an object containing x and y. 594 | * @returns {object} The position in the graph based on the given window position, as an object 595 | * containing x and y. 596 | */ 597 | getWindowToGraphPosition(pos) { 598 | return this.view.getWindowToGraphPosition(pos); 599 | } 600 | 601 | /** 602 | * Add an event listener to the graph. 603 | * 604 | * @param {string} eventName - The name of the event to listen for. 605 | * @param {Function} callback - The callback to call when the event is triggered. 606 | */ 607 | on(eventName, callback) { 608 | if (this._config.readOnly && (!eventName.includes('EVENT_SELECT_') && !eventName.includes('EVENT_DESELECT'))) return; 609 | this.dom.addEventListener(eventName, (e) => { 610 | callback(e.detail); 611 | }); 612 | } 613 | 614 | _dispatchEvent(action, data) { 615 | this.dom.dispatchEvent(new CustomEvent(action, { detail: data })); 616 | } 617 | 618 | _registerInternalEventListeners() { 619 | this.on(GRAPH_ACTIONS.ADD_NODE, ({ node }) => { 620 | this.createNode(node); 621 | this.selectNode(node); 622 | }); 623 | this.on(GRAPH_ACTIONS.DELETE_NODE, ({ node, edgeData, edges }) => { 624 | this.deleteNode(node.id); 625 | }); 626 | this.on(GRAPH_ACTIONS.SELECT_NODE, ({ node }) => { 627 | if (this._selectedItem) { 628 | this._selectedItem.deselectItem(); 629 | } 630 | this._selectedItem = new SelectedItem(this, 'NODE', node.id); 631 | this._selectedItem.selectItem(); 632 | }); 633 | this.on(GRAPH_ACTIONS.UPDATE_NODE_POSITION, ({ nodeId, node }) => { 634 | this.updateNodePosition(nodeId, { x: node.posX, y: node.posY }); 635 | }); 636 | this.on(GRAPH_ACTIONS.UPDATE_NODE_ATTRIBUTE, ({ node }) => { 637 | this._graphData.set(`data.nodes.${node.id}`, node); 638 | }); 639 | this.on(GRAPH_ACTIONS.ADD_EDGE, ({ edge, edgeId }) => { 640 | if (Number.isFinite(edge.inPort)) { 641 | Object.keys(this._graphData.get('data.edges')).forEach((edgeKey) => { 642 | const edgeToCompare = this._graphData.get(`data.edges.${edgeKey}`); 643 | if (edgeToCompare.to === edge.to && edgeToCompare.inPort === edge.inPort) { 644 | this.deleteEdge(edgeKey); 645 | } 646 | }); 647 | } 648 | this.createEdge(edge, edgeId); 649 | this.suppressNodeSelect = true; 650 | this.selectEdge(edge, edgeId); 651 | }); 652 | this.on(GRAPH_ACTIONS.DELETE_EDGE, ({ edgeId }) => { 653 | this.deleteEdge(edgeId); 654 | }); 655 | this.on(GRAPH_ACTIONS.SELECT_EDGE, ({ edge }) => { 656 | if (this._selectedItem) { 657 | this._selectedItem.deselectItem(); 658 | } 659 | this._selectedItem = new SelectedItem(this, 'EDGE', `${edge.from}-${edge.to}`); 660 | this._selectedItem.selectItem(); 661 | }); 662 | this.on(GRAPH_ACTIONS.DESELECT_ITEM, () => { 663 | this.deselectItem(); 664 | }); 665 | } 666 | } 667 | 668 | Graph.GRAPH_ACTIONS = GRAPH_ACTIONS; 669 | 670 | export default Graph; 671 | -------------------------------------------------------------------------------- /src/joint-graph.js: -------------------------------------------------------------------------------- 1 | import 'jquery'; 2 | import * as joint from 'jointjs/dist/joint.min.js'; 3 | import _ from 'lodash'; 4 | import 'backbone'; 5 | 6 | // TODO replace with a lighter math library 7 | import { Vec2 } from './lib/vec2.js'; 8 | 9 | joint.V.matrixToTransformString = function (matrix) { 10 | matrix || (matrix = true); // eslint-disable-line no-unused-expressions 11 | return `matrix(${[ 12 | matrix.a || 1, 13 | matrix.b || 0, 14 | matrix.c || 0, 15 | matrix.d || 1, 16 | matrix.e || 0, 17 | matrix.f || 0 18 | ]})`; 19 | }; 20 | 21 | joint.V.prototype.transform = function (matrix, opt) { 22 | 23 | const node = this.node; 24 | if (joint.V.isUndefined(matrix)) { 25 | return (node.parentNode) ? 26 | this.getTransformToElement(node.parentNode) : 27 | node.getScreenCTM(); 28 | } 29 | 30 | if (opt && opt.absolute) { 31 | return this.attr('transform', joint.V.matrixToTransformString(matrix)); 32 | } 33 | 34 | const svgTransform = joint.V.createSVGTransform(matrix); 35 | node.transform.baseVal.appendItem(svgTransform); 36 | return this; 37 | }; 38 | 39 | class JointGraph { 40 | constructor(dom, config = {}) { 41 | 42 | this._config = config; 43 | this._graph = new joint.dia.Graph({}, { cellNamespace: joint.shape }); 44 | 45 | this._paper = new joint.dia.Paper({ 46 | el: dom, 47 | model: this._graph, 48 | width: dom.offsetWidth, 49 | cellViewNamespace: joint.shapes, 50 | height: dom.offsetHeight, 51 | clickThreshold: 1, 52 | restrictTranslate: this._config.restrictTranslate, 53 | background: { 54 | color: config.defaultStyles.background.color 55 | }, 56 | gridSize: config.defaultStyles.background.gridSize, 57 | linkPinning: false, 58 | interactive: !this._config.readOnly, 59 | defaultLink: (cellView, magnet) => { 60 | const defaultLink = new joint.shapes.standard.Link({ 61 | connector: { 62 | name: 'normal' 63 | } 64 | }); 65 | defaultLink.attr({ 66 | line: { 67 | stroke: joint.V(magnet).attr('stroke'), 68 | strokeWidth: 2, 69 | targetMarker: null 70 | } 71 | }); 72 | return defaultLink; 73 | }, 74 | validateConnection: (cellViewS, magnetS, cellViewT, magnetT, end, linkView) => { 75 | if (joint.V(cellViewS).id === joint.V(cellViewT).id) return false; 76 | if (!joint.V(magnetS) || !joint.V(magnetT)) return false; 77 | const sPort = joint.V(magnetS).attr('port'); 78 | const tPort = joint.V(magnetT.parentNode).attr('port'); 79 | if ((sPort.includes('in') && tPort.includes('in')) || (sPort.includes('out') && tPort.includes('out'))) return false; 80 | if (sPort.includes('in') && joint.V(magnetS.children[1]).attr().visibility !== 'hidden') return false; 81 | // if (tPort.includes('in') && joint.V(magnetT.parentNode.children[1]).attr().visibility !== 'hidden') return false; 82 | if (cellViewS._portElementsCache[sPort].portContentElement.children()[0].attr().edgeType !== cellViewT._portElementsCache[tPort].portContentElement.children()[0].attr().edgeType) return false; 83 | return true; 84 | }, 85 | markAvailable: true, 86 | drawGrid: { 87 | name: 'doubleMesh', 88 | args: [ 89 | { color: '#0e1923', thickness: 1 }, 90 | { color: '#06101b', scaleFactor: 10, thickness: 2 } 91 | ] 92 | } 93 | }); 94 | 95 | const graphResizeObserver = new ResizeObserver((_) => { 96 | this._resizeGraph(dom); 97 | }); 98 | graphResizeObserver.observe(dom); 99 | 100 | this._panPaper = false; 101 | this._translate = new Vec2(); 102 | this._totalTranslate = new Vec2(); 103 | this._pan = new Vec2(); 104 | this._mousePos = new Vec2(); 105 | this._paper.on('blank:pointerdown', (e) => { 106 | this._panPaper = true; 107 | this._mousePos = new Vec2(e.offsetX, e.offsetY); 108 | }); 109 | this._paper.on('blank:pointerup', () => { 110 | this._panPaper = false; 111 | this._translate.add(this._pan); 112 | }); 113 | dom.addEventListener('mousemove', (e) => { 114 | if (this._panPaper) { 115 | this._pan = this._mousePos.clone().sub(new Vec2(e.offsetX, e.offsetY)); 116 | this._mousePos = new Vec2(e.offsetX, e.offsetY); 117 | this._paper.translate(this._paper.translate().tx - this._pan.x, this._paper.translate().ty - this._pan.y); 118 | } 119 | }); 120 | 121 | const handleCanvasMouseWheel = (e, x, y, delta) => { 122 | e.preventDefault(); 123 | 124 | const oldScale = this._paper.scale().sx; 125 | const newScale = oldScale + delta * 0.025; 126 | 127 | this._scaleToPoint(newScale, x, y); 128 | }; 129 | const handleCellMouseWheel = (cellView, e, x, y, delta) => handleCanvasMouseWheel(e, x, y, delta); 130 | 131 | this._paper.on('cell:mousewheel', handleCellMouseWheel); 132 | this._paper.on('blank:mousewheel', handleCanvasMouseWheel); 133 | 134 | if (config.adjustVertices) { 135 | const adjustGraphVertices = _.partial(this.adjustVertices.bind(this), this._graph); 136 | 137 | // adjust vertices when a cell is removed or its source/target was changed 138 | this._graph.on('add remove change:source change:target', adjustGraphVertices); 139 | 140 | // adjust vertices when the user stops interacting with an element 141 | this._paper.on('cell:pointerup', adjustGraphVertices); 142 | } 143 | 144 | 145 | } 146 | 147 | _resizeGraph(dom) { 148 | this._paper.setDimensions(dom.offsetWidth, dom.offsetHeight); 149 | } 150 | 151 | _scaleToPoint(nextScale, x, y) { 152 | if (nextScale >= (this._config.minZoom || 0.25) && nextScale <= (this._config.maxZoom || 1.5)) { 153 | const currentScale = this._paper.scale().sx; 154 | 155 | const beta = currentScale / nextScale; 156 | 157 | const ax = x - (x * beta); 158 | const ay = y - (y * beta); 159 | 160 | const translate = this._paper.translate(); 161 | 162 | const nextTx = translate.tx - ax * nextScale; 163 | const nextTy = translate.ty - ay * nextScale; 164 | 165 | this._paper.translate(nextTx, nextTy); 166 | 167 | const ctm = this._paper.matrix(); 168 | 169 | ctm.a = nextScale; 170 | ctm.d = nextScale; 171 | 172 | this._paper.matrix(ctm); 173 | } 174 | } 175 | 176 | adjustVertices(graph, cell) { 177 | if (this.ignoreAdjustVertices) return; 178 | // if `cell` is a view, find its model 179 | cell = cell.model || cell; 180 | if (cell instanceof joint.dia.Element) { 181 | // `cell` is an element 182 | _.chain(graph.getConnectedLinks(cell)) 183 | .groupBy((link) => { 184 | // the key of the group is the model id of the link's source or target 185 | // cell id is omitted 186 | return _.omit([link.source().id, link.target().id], cell.id)[0]; 187 | }) 188 | .each((group, key) => { 189 | // if the member of the group has both source and target model 190 | // then adjust vertices 191 | if (key !== 'undefined') this.adjustVertices(graph, _.first(group)); 192 | }) 193 | .value(); 194 | return; 195 | } 196 | // `cell` is a link 197 | // get its source and target model IDs 198 | const sourceId = cell.get('source').id || cell.previous('source').id; 199 | const targetId = cell.get('target').id || cell.previous('target').id; 200 | // if one of the ends is not a model 201 | // (if the link is pinned to paper at a point) 202 | // the link is interpreted as having no siblings 203 | if (!sourceId || !targetId) return; 204 | // identify link siblings 205 | const siblings = _.filter(graph.getLinks(), (sibling) => { 206 | const siblingSourceId = sibling.source().id; 207 | const siblingTargetId = sibling.target().id; 208 | // if source and target are the same 209 | // or if source and target are reversed 210 | return ((siblingSourceId === sourceId) && (siblingTargetId === targetId)) || 211 | ((siblingSourceId === targetId) && (siblingTargetId === sourceId)); 212 | }); 213 | const numSiblings = siblings.length; 214 | switch (numSiblings) { 215 | case 0: { 216 | // the link has no siblings 217 | break; 218 | } case 1: { 219 | // there is only one link 220 | // no vertices needed 221 | cell.unset('vertices'); 222 | cell.set('connector', { name: 'normal' }); 223 | break; 224 | } default: { 225 | // there are multiple siblings 226 | // we need to create vertices 227 | // find the middle point of the link 228 | const sourceCenter = graph.getCell(sourceId).getBBox().center(); 229 | const targetCenter = graph.getCell(targetId).getBBox().center(); 230 | joint.g.Line(sourceCenter, targetCenter).midpoint(); 231 | // find the angle of the link 232 | const theta = sourceCenter.theta(targetCenter); 233 | // constant 234 | // the maximum distance between two sibling links 235 | const GAP = 20; 236 | _.each(siblings, (sibling, index) => { 237 | // we want offset values to be calculated as 0, 20, 20, 40, 40, 60, 60 ... 238 | let offset = GAP * Math.ceil(index / 2); 239 | // place the vertices at points which are `offset` pixels perpendicularly away 240 | // from the first link 241 | // 242 | // as index goes up, alternate left and right 243 | // 244 | // ^ odd indices 245 | // | 246 | // |----> index 0 sibling - centerline (between source and target centers) 247 | // | 248 | // v even indices 249 | const sign = ((index % 2) ? 1 : -1); 250 | // to assure symmetry, if there is an even number of siblings 251 | // shift all vertices leftward perpendicularly away from the centerline 252 | if ((numSiblings % 2) === 0) { 253 | offset -= ((GAP / 2) * sign); 254 | } 255 | // make reverse links count the same as non-reverse 256 | const reverse = ((theta < 180) ? 1 : -1); 257 | // we found the vertex 258 | const angle = joint.g.toRad(theta + (sign * reverse * 90)); 259 | 260 | const shift = joint.g.Point.fromPolar(offset * sign, angle, 0); 261 | this.ignoreAdjustVertices = true; 262 | sibling.source(sibling.getSourceCell(), { 263 | anchor: { 264 | name: 'center', 265 | args: { 266 | dx: shift.x, 267 | dy: shift.y 268 | } 269 | } 270 | }); 271 | sibling.target(sibling.getTargetCell(), { 272 | anchor: { 273 | name: 'center', 274 | args: { 275 | dx: shift.x, 276 | dy: shift.y 277 | } 278 | } 279 | }); 280 | this.ignoreAdjustVertices = false; 281 | }); 282 | } 283 | } 284 | } 285 | } 286 | 287 | export default JointGraph; 288 | -------------------------------------------------------------------------------- /src/joint-shape-node.js: -------------------------------------------------------------------------------- 1 | import 'jquery'; 2 | import 'backbone'; 3 | import * as joint from 'jointjs/dist/joint.min.js'; 4 | import _ from 'lodash'; 5 | 6 | const jointShapeElement = () => joint.shapes.standard.Rectangle.extend({ 7 | defaults: joint.util.deepSupplement({ 8 | type: 'html.Element', 9 | markup: [{ 10 | tagName: 'rect', 11 | selector: 'body' 12 | }, { 13 | tagName: 'rect', 14 | selector: 'labelBackground' 15 | }, { 16 | tagName: 'rect', 17 | selector: 'labelSeparator' 18 | }, { 19 | tagName: 'rect', 20 | selector: 'inBackground' 21 | }, { 22 | tagName: 'rect', 23 | selector: 'outBackground' 24 | }, { 25 | tagName: 'text', 26 | selector: 'icon' 27 | }, { 28 | tagName: 'text', 29 | selector: 'label' 30 | }, { 31 | tagName: 'image', 32 | selector: 'texture' 33 | }, { 34 | tagName: 'path', 35 | selector: 'marker' 36 | }] 37 | }, joint.shapes.standard.Rectangle.prototype.defaults) 38 | }); 39 | 40 | const jointShapeElementView = paper => joint.dia.ElementView.extend({ 41 | initialize: function () { 42 | _.bindAll(this, 'updateBox'); 43 | joint.dia.ElementView.prototype.initialize.apply(this, arguments); 44 | 45 | this.div = document.createElement('div'); 46 | this.div.setAttribute('id', `nodediv_${this.model.id}`); 47 | this.div.classList.add('graph-node-div'); 48 | 49 | // // Update the box position whenever the underlying model changes. 50 | this.model.on('change', this.updateBox, this); 51 | paper.on('cell:mousewheel', this.updateBox, this); 52 | paper.on('blank:mousewheel', this.updateBox, this); 53 | paper.on('blank:pointerup', this.updateBox, this); 54 | document.addEventListener('mousemove', (e) => { 55 | this.updateBox(); 56 | }); 57 | // // Remove the box when the model gets removed from the graph. 58 | this.model.on('remove', this.removeBox, this); 59 | 60 | this.updateBox(); 61 | }, 62 | render: function () { 63 | joint.dia.ElementView.prototype.render.apply(this, arguments); 64 | paper.$el.append(this.div); 65 | this.updateBox(); 66 | return this; 67 | }, 68 | updateBox: function () { 69 | // Set the position and dimension of the box so that it covers the JointJS element. 70 | const bbox = this.model.getBBox(); 71 | // Example of updating the HTML with a data stored in the cell model. 72 | this.div.setAttribute('style', ` 73 | position: absolute; 74 | width: ${bbox.width}px; 75 | height: ${bbox.height}px; 76 | left: ${bbox.width / 2 * paper.scale().sx}px; 77 | top: ${bbox.height / 2 * paper.scale().sx}px; 78 | transform: translate(${paper.translate().tx + paper.scale().sx * bbox.x - bbox.width / 2}px, ${paper.translate().ty + paper.scale().sx * bbox.y - (bbox.height / 2)}px) scale(${paper.scale().sx}); 79 | `); 80 | }, 81 | removeBox: function (evt) { 82 | this.div.remove(); 83 | } 84 | }); 85 | 86 | export { 87 | jointShapeElement, 88 | jointShapeElementView 89 | }; 90 | -------------------------------------------------------------------------------- /src/lib/joint.scss: -------------------------------------------------------------------------------- 1 | /*! JointJS v3.4.1 (2021-08-18) - JavaScript diagramming library 2 | 3 | 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | */ 8 | /* 9 | A complete list of SVG properties that can be set through CSS is here: 10 | http://www.w3.org/TR/SVG/styling.html 11 | 12 | Important note: Presentation attributes have a lower precedence over CSS style rules. 13 | */ 14 | 15 | 16 | /* .viewport is a node wrapping all diagram elements in the paper */ 17 | .joint-viewport { 18 | -webkit-user-select: none; 19 | -moz-user-select: none; 20 | user-select: none; 21 | } 22 | 23 | .joint-paper > svg, 24 | .joint-paper-background, 25 | .joint-paper-grid { 26 | position: absolute; 27 | top: 0; 28 | left: 0; 29 | right: 0; 30 | bottom: 0; 31 | } 32 | 33 | /* 34 | 1. IE can't handle paths without the `d` attribute for bounding box calculation 35 | 2. IE can't even handle 'd' attribute as a css selector (e.g path[d]) so the following rule will 36 | break the links rendering. 37 | 38 | path:not([d]) { 39 | display: none; 40 | } 41 | 42 | */ 43 | 44 | 45 | /* magnet is an element that can be either source or a target of a link */ 46 | [magnet=true]:not(.joint-element) { 47 | cursor: crosshair; 48 | } 49 | [magnet=true]:not(.joint-element):hover { 50 | opacity: .7; 51 | } 52 | 53 | /* 54 | 55 | Elements have CSS classes named by their types. E.g. type: basic.Rect has a CSS class "element basic Rect". 56 | This makes it possible to easily style elements in CSS and have generic CSS rules applying to 57 | the whole group of elements. Each plugin can provide its own stylesheet. 58 | 59 | */ 60 | 61 | .joint-element { 62 | /* Give the user a hint that he can drag&drop the element. */ 63 | cursor: move; 64 | } 65 | 66 | .joint-element * { 67 | user-drag: none; 68 | } 69 | 70 | .joint-element .scalable * { 71 | /* The default behavior when scaling an element is not to scale the stroke in order to prevent the ugly effect of stroke with different proportions. */ 72 | vector-effect: non-scaling-stroke; 73 | } 74 | /* 75 | 76 | connection-wrap is a element of the joint.dia.Link that follows the .connection of that link. 77 | In other words, the `d` attribute of the .connection-wrap contains the same data as the `d` attribute of the 78 | .connection . The advantage of using .connection-wrap is to be able to catch pointer events 79 | in the neighborhood of the .connection . This is especially handy if the .connection is 80 | very thin. 81 | 82 | */ 83 | 84 | .marker-source, 85 | .marker-target { 86 | /* This makes the arrowheads point to the border of objects even though the transform: scale() is applied on them. */ 87 | vector-effect: non-scaling-stroke; 88 | } 89 | 90 | /* Paper */ 91 | .joint-paper { 92 | position: relative; 93 | } 94 | /* Paper */ 95 | 96 | /* Highlighting */ 97 | .joint-highlight-opacity { 98 | opacity: 0.3; 99 | } 100 | /* Highlighting */ 101 | 102 | /* 103 | 104 | Vertex markers are `` elements that appear at connection vertex positions. 105 | 106 | */ 107 | 108 | .joint-link .connection-wrap, 109 | .joint-link .connection { 110 | fill: none; 111 | } 112 | 113 | /* element wrapping .marker-vertex-group. */ 114 | .marker-vertices { 115 | opacity: 0; 116 | cursor: move; 117 | } 118 | .marker-arrowheads { 119 | opacity: 0; 120 | cursor: move; 121 | cursor: -webkit-grab; 122 | cursor: -moz-grab; 123 | /* display: none; */ /* setting `display: none` on .marker-arrowheads effectively switches of links reconnecting */ 124 | } 125 | .link-tools { 126 | opacity: 0; 127 | cursor: pointer; 128 | } 129 | .link-tools .tool-options { 130 | display: none; /* by default, we don't display link options tool */ 131 | } 132 | .joint-link:hover .marker-vertices, 133 | .joint-link:hover .marker-arrowheads, 134 | .joint-link:hover .link-tools { 135 | opacity: 1; 136 | } 137 | 138 | /* element used to remove a vertex */ 139 | .marker-vertex-remove { 140 | cursor: pointer; 141 | opacity: .1; 142 | } 143 | 144 | .marker-vertex-group:hover .marker-vertex-remove { 145 | opacity: 1; 146 | } 147 | 148 | .marker-vertex-remove-area { 149 | opacity: .1; 150 | cursor: pointer; 151 | } 152 | .marker-vertex-group:hover .marker-vertex-remove-area { 153 | opacity: 1; 154 | } 155 | 156 | /* 157 | Example of custom changes (in pure CSS only!): 158 | 159 | Do not show marker vertices at all: .marker-vertices { display: none; } 160 | Do not allow adding new vertices: .connection-wrap { pointer-events: none; } 161 | */ 162 | 163 | /* foreignObject inside the elements (i.e joint.shapes.basic.TextBlock) */ 164 | .joint-element .fobj { 165 | overflow: hidden; 166 | } 167 | .joint-element .fobj body { 168 | background-color: transparent; 169 | margin: 0px; 170 | position: static; 171 | } 172 | .joint-element .fobj div { 173 | text-align: center; 174 | vertical-align: middle; 175 | display: table-cell; 176 | padding: 0px 5px 0px 5px; 177 | } 178 | 179 | /* Paper */ 180 | .joint-paper.joint-theme-dark { 181 | background-color: #18191b; 182 | } 183 | /* Paper */ 184 | 185 | /* Links */ 186 | .joint-link.joint-theme-dark .connection-wrap { 187 | stroke: #8F8FF3; 188 | stroke-width: 15; 189 | stroke-linecap: round; 190 | stroke-linejoin: round; 191 | opacity: 0; 192 | cursor: move; 193 | } 194 | .joint-link.joint-theme-dark .connection-wrap:hover { 195 | opacity: .4; 196 | stroke-opacity: .4; 197 | } 198 | .joint-link.joint-theme-dark .connection { 199 | stroke-linejoin: round; 200 | } 201 | .joint-link.joint-theme-dark .link-tools .tool-remove circle { 202 | fill: #F33636; 203 | } 204 | .joint-link.joint-theme-dark .link-tools .tool-remove path { 205 | fill: white; 206 | } 207 | .joint-link.joint-theme-dark .link-tools [event="link:options"] circle { 208 | fill: green; 209 | } 210 | /* element inside .marker-vertex-group element */ 211 | .joint-link.joint-theme-dark .marker-vertex { 212 | fill: #5652DB; 213 | } 214 | .joint-link.joint-theme-dark .marker-vertex:hover { 215 | fill: #8E8CE1; 216 | stroke: none; 217 | } 218 | .joint-link.joint-theme-dark .marker-arrowhead { 219 | fill: #5652DB; 220 | } 221 | .joint-link.joint-theme-dark .marker-arrowhead:hover { 222 | fill: #8E8CE1; 223 | stroke: none; 224 | } 225 | /* element used to remove a vertex */ 226 | .joint-link.joint-theme-dark .marker-vertex-remove-area { 227 | fill: green; 228 | stroke: darkgreen; 229 | } 230 | .joint-link.joint-theme-dark .marker-vertex-remove { 231 | fill: white; 232 | stroke: white; 233 | } 234 | /* Links */ 235 | /* Paper */ 236 | .joint-paper.joint-theme-default { 237 | background-color: #FFFFFF; 238 | } 239 | /* Paper */ 240 | 241 | /* Links */ 242 | .joint-link.joint-theme-default .connection-wrap { 243 | stroke: #000000; 244 | stroke-width: 15; 245 | stroke-linecap: round; 246 | stroke-linejoin: round; 247 | opacity: 0; 248 | cursor: move; 249 | } 250 | .joint-link.joint-theme-default .connection-wrap:hover { 251 | opacity: .4; 252 | stroke-opacity: .4; 253 | } 254 | .joint-link.joint-theme-default .connection { 255 | stroke-linejoin: round; 256 | } 257 | .joint-link.joint-theme-default .link-tools .tool-remove circle { 258 | fill: #FF0000; 259 | } 260 | .joint-link.joint-theme-default .link-tools .tool-remove path { 261 | fill: #FFFFFF; 262 | } 263 | 264 | /* element inside .marker-vertex-group element */ 265 | .joint-link.joint-theme-default .marker-vertex { 266 | fill: #1ABC9C; 267 | } 268 | .joint-link.joint-theme-default .marker-vertex:hover { 269 | fill: #34495E; 270 | stroke: none; 271 | } 272 | 273 | .joint-link.joint-theme-default .marker-arrowhead { 274 | fill: #1ABC9C; 275 | } 276 | .joint-link.joint-theme-default .marker-arrowhead:hover { 277 | fill: #F39C12; 278 | stroke: none; 279 | } 280 | 281 | /* element used to remove a vertex */ 282 | .joint-link.joint-theme-default .marker-vertex-remove { 283 | fill: #FFFFFF; 284 | } 285 | 286 | /* Links */ 287 | .joint-link.joint-theme-material .connection-wrap { 288 | stroke: #000000; 289 | stroke-width: 15; 290 | stroke-linecap: round; 291 | stroke-linejoin: round; 292 | opacity: 0; 293 | cursor: move; 294 | } 295 | .joint-link.joint-theme-material .connection-wrap:hover { 296 | opacity: .4; 297 | stroke-opacity: .4; 298 | } 299 | .joint-link.joint-theme-material .connection { 300 | stroke-linejoin: round; 301 | } 302 | .joint-link.joint-theme-material .link-tools .tool-remove circle { 303 | fill: #C64242; 304 | } 305 | .joint-link.joint-theme-material .link-tools .tool-remove path { 306 | fill: #FFFFFF; 307 | } 308 | 309 | /* element inside .marker-vertex-group element */ 310 | .joint-link.joint-theme-material .marker-vertex { 311 | fill: #d0d8e8; 312 | } 313 | .joint-link.joint-theme-material .marker-vertex:hover { 314 | fill: #5fa9ee; 315 | stroke: none; 316 | } 317 | 318 | .joint-link.joint-theme-material .marker-arrowhead { 319 | fill: #d0d8e8; 320 | } 321 | .joint-link.joint-theme-material .marker-arrowhead:hover { 322 | fill: #5fa9ee; 323 | stroke: none; 324 | } 325 | 326 | /* element used to remove a vertex */ 327 | .joint-link.joint-theme-material .marker-vertex-remove-area { 328 | fill: #5fa9ee; 329 | } 330 | .joint-link.joint-theme-material .marker-vertex-remove { 331 | fill: white; 332 | } 333 | /* Links */ 334 | 335 | /* Links */ 336 | .joint-link.joint-theme-modern .connection-wrap { 337 | stroke: #000000; 338 | stroke-width: 15; 339 | stroke-linecap: round; 340 | stroke-linejoin: round; 341 | opacity: 0; 342 | cursor: move; 343 | } 344 | .joint-link.joint-theme-modern .connection-wrap:hover { 345 | opacity: .4; 346 | stroke-opacity: .4; 347 | } 348 | .joint-link.joint-theme-modern .connection { 349 | stroke-linejoin: round; 350 | } 351 | .joint-link.joint-theme-modern .link-tools .tool-remove circle { 352 | fill: #FF0000; 353 | } 354 | .joint-link.joint-theme-modern .link-tools .tool-remove path { 355 | fill: #FFFFFF; 356 | } 357 | 358 | /* element inside .marker-vertex-group element */ 359 | .joint-link.joint-theme-modern .marker-vertex { 360 | fill: #1ABC9C; 361 | } 362 | .joint-link.joint-theme-modern .marker-vertex:hover { 363 | fill: #34495E; 364 | stroke: none; 365 | } 366 | 367 | .joint-link.joint-theme-modern .marker-arrowhead { 368 | fill: #1ABC9C; 369 | } 370 | .joint-link.joint-theme-modern .marker-arrowhead:hover { 371 | fill: #F39C12; 372 | stroke: none; 373 | } 374 | 375 | /* element used to remove a vertex */ 376 | .joint-link.joint-theme-modern .marker-vertex-remove { 377 | fill: white; 378 | } 379 | /* Links */ 380 | -------------------------------------------------------------------------------- /src/lib/layout.scss: -------------------------------------------------------------------------------- 1 | /* 2 | A complete list of SVG properties that can be set through CSS is here: 3 | http://www.w3.org/TR/SVG/styling.html 4 | 5 | Important note: Presentation attributes have a lower precedence over CSS style rules. 6 | */ 7 | 8 | 9 | /* .viewport is a node wrapping all diagram elements in the paper */ 10 | .joint-viewport { 11 | -webkit-user-select: none; 12 | -moz-user-select: none; 13 | user-select: none; 14 | } 15 | 16 | .joint-paper > svg, 17 | .joint-paper-background, 18 | .joint-paper-grid { 19 | position: absolute; 20 | top: 0; 21 | left: 0; 22 | right: 0; 23 | bottom: 0; 24 | } 25 | 26 | /* 27 | 1. IE can't handle paths without the `d` attribute for bounding box calculation 28 | 2. IE can't even handle 'd' attribute as a css selector (e.g path[d]) so the following rule will 29 | break the links rendering. 30 | 31 | path:not([d]) { 32 | display: none; 33 | } 34 | 35 | */ 36 | 37 | 38 | /* magnet is an element that can be either source or a target of a link */ 39 | [magnet=true]:not(.joint-element) { 40 | cursor: crosshair; 41 | } 42 | [magnet=true]:not(.joint-element):hover { 43 | opacity: .7; 44 | } 45 | 46 | /* 47 | 48 | Elements have CSS classes named by their types. E.g. type: basic.Rect has a CSS class "element basic Rect". 49 | This makes it possible to easily style elements in CSS and have generic CSS rules applying to 50 | the whole group of elements. Each plugin can provide its own stylesheet. 51 | 52 | */ 53 | 54 | .joint-element { 55 | /* Give the user a hint that he can drag&drop the element. */ 56 | cursor: move; 57 | } 58 | 59 | .joint-element * { 60 | user-drag: none; 61 | } 62 | 63 | .joint-element .scalable * { 64 | /* The default behavior when scaling an element is not to scale the stroke in order to prevent the ugly effect of stroke with different proportions. */ 65 | vector-effect: non-scaling-stroke; 66 | } 67 | /* 68 | 69 | connection-wrap is a element of the joint.dia.Link that follows the .connection of that link. 70 | In other words, the `d` attribute of the .connection-wrap contains the same data as the `d` attribute of the 71 | .connection . The advantage of using .connection-wrap is to be able to catch pointer events 72 | in the neighborhood of the .connection . This is especially handy if the .connection is 73 | very thin. 74 | 75 | */ 76 | 77 | .marker-source, 78 | .marker-target { 79 | /* This makes the arrowheads point to the border of objects even though the transform: scale() is applied on them. */ 80 | vector-effect: non-scaling-stroke; 81 | } 82 | 83 | /* Paper */ 84 | .joint-paper { 85 | position: relative; 86 | } 87 | /* Paper */ 88 | 89 | /* Highlighting */ 90 | .joint-highlight-opacity { 91 | opacity: 0.3; 92 | } 93 | /* Highlighting */ 94 | 95 | /* 96 | 97 | Vertex markers are `` elements that appear at connection vertex positions. 98 | 99 | */ 100 | 101 | .joint-link .connection-wrap, 102 | .joint-link .connection { 103 | fill: none; 104 | } 105 | 106 | /* element wrapping .marker-vertex-group. */ 107 | .marker-vertices { 108 | opacity: 0; 109 | cursor: move; 110 | } 111 | .marker-arrowheads { 112 | opacity: 0; 113 | cursor: move; 114 | cursor: -webkit-grab; 115 | cursor: -moz-grab; 116 | /* display: none; */ /* setting `display: none` on .marker-arrowheads effectively switches of links reconnecting */ 117 | } 118 | .link-tools { 119 | opacity: 0; 120 | cursor: pointer; 121 | } 122 | .link-tools .tool-options { 123 | display: none; /* by default, we don't display link options tool */ 124 | } 125 | .joint-link:hover .marker-vertices, 126 | .joint-link:hover .marker-arrowheads, 127 | .joint-link:hover .link-tools { 128 | opacity: 1; 129 | } 130 | 131 | /* element used to remove a vertex */ 132 | .marker-vertex-remove { 133 | cursor: pointer; 134 | opacity: .1; 135 | } 136 | 137 | .marker-vertex-group:hover .marker-vertex-remove { 138 | opacity: 1; 139 | } 140 | 141 | .marker-vertex-remove-area { 142 | opacity: .1; 143 | cursor: pointer; 144 | } 145 | .marker-vertex-group:hover .marker-vertex-remove-area { 146 | opacity: 1; 147 | } 148 | 149 | /* 150 | Example of custom changes (in pure CSS only!): 151 | 152 | Do not show marker vertices at all: .marker-vertices { display: none; } 153 | Do not allow adding new vertices: .connection-wrap { pointer-events: none; } 154 | */ 155 | 156 | /* foreignObject inside the elements (i.e joint.shapes.basic.TextBlock) */ 157 | .joint-element .fobj { 158 | overflow: hidden; 159 | } 160 | .joint-element .fobj body { 161 | background-color: transparent; 162 | margin: 0px; 163 | position: static; 164 | } 165 | .joint-element .fobj div { 166 | text-align: center; 167 | vertical-align: middle; 168 | display: table-cell; 169 | padding: 0px 5px 0px 5px; 170 | } 171 | -------------------------------------------------------------------------------- /src/lib/material.scss: -------------------------------------------------------------------------------- 1 | /* Links */ 2 | .joint-link.joint-theme-material .connection-wrap { 3 | stroke: #000000; 4 | stroke-width: 15; 5 | stroke-linecap: round; 6 | stroke-linejoin: round; 7 | opacity: 0; 8 | cursor: move; 9 | } 10 | .joint-link.joint-theme-material .connection-wrap:hover { 11 | opacity: .4; 12 | stroke-opacity: .4; 13 | } 14 | .joint-link.joint-theme-material .connection { 15 | stroke-linejoin: round; 16 | } 17 | .joint-link.joint-theme-material .link-tools .tool-remove circle { 18 | fill: #C64242; 19 | } 20 | .joint-link.joint-theme-material .link-tools .tool-remove path { 21 | fill: #FFFFFF; 22 | } 23 | 24 | /* element inside .marker-vertex-group element */ 25 | .joint-link.joint-theme-material .marker-vertex { 26 | fill: #d0d8e8; 27 | } 28 | .joint-link.joint-theme-material .marker-vertex:hover { 29 | fill: #5fa9ee; 30 | stroke: none; 31 | } 32 | 33 | .joint-link.joint-theme-material .marker-arrowhead { 34 | fill: #d0d8e8; 35 | } 36 | .joint-link.joint-theme-material .marker-arrowhead:hover { 37 | fill: #5fa9ee; 38 | stroke: none; 39 | } 40 | 41 | /* element used to remove a vertex */ 42 | .joint-link.joint-theme-material .marker-vertex-remove-area { 43 | fill: #5fa9ee; 44 | } 45 | .joint-link.joint-theme-material .marker-vertex-remove { 46 | fill: white; 47 | } 48 | /* Links */ 49 | -------------------------------------------------------------------------------- /src/lib/vec2.js: -------------------------------------------------------------------------------- 1 | // Lib from https://raw.githubusercontent.com/playcanvas/engine/9083d81072c32d5dbb4394a72925e644fddc1c8a/src/math/vec2.js 2 | 3 | /** 4 | * A 2-dimensional vector. 5 | * 6 | * @ignore 7 | */ 8 | class Vec2 { 9 | /** 10 | * Create a new Vec2 instance. 11 | * 12 | * @param {number|number[]} [x] - The x value. Defaults to 0. If x is an array of length 2, the 13 | * array will be used to populate all components. 14 | * @param {number} [y] - The y value. Defaults to 0. 15 | * @example 16 | * var v = new pc.Vec2(1, 2); 17 | */ 18 | constructor(x = 0, y = 0) { 19 | if (x.length === 2) { 20 | /** 21 | * The first component of the vector. 22 | * 23 | * @type {number} 24 | */ 25 | this.x = x[0]; 26 | /** 27 | * The second component of the vector. 28 | * 29 | * @type {number} 30 | */ 31 | this.y = x[1]; 32 | } else { 33 | this.x = x; 34 | this.y = y; 35 | } 36 | } 37 | 38 | /** 39 | * Adds a 2-dimensional vector to another in place. 40 | * 41 | * @param {Vec2} rhs - The vector to add to the specified vector. 42 | * @returns {Vec2} Self for chaining. 43 | * @example 44 | * var a = new pc.Vec2(10, 10); 45 | * var b = new pc.Vec2(20, 20); 46 | * 47 | * a.add(b); 48 | * 49 | * // Outputs [30, 30] 50 | * console.log("The result of the addition is: " + a.toString()); 51 | */ 52 | add(rhs) { 53 | this.x += rhs.x; 54 | this.y += rhs.y; 55 | 56 | return this; 57 | } 58 | 59 | /** 60 | * Adds two 2-dimensional vectors together and returns the result. 61 | * 62 | * @param {Vec2} lhs - The first vector operand for the addition. 63 | * @param {Vec2} rhs - The second vector operand for the addition. 64 | * @returns {Vec2} Self for chaining. 65 | * @example 66 | * var a = new pc.Vec2(10, 10); 67 | * var b = new pc.Vec2(20, 20); 68 | * var r = new pc.Vec2(); 69 | * 70 | * r.add2(a, b); 71 | * // Outputs [30, 30] 72 | * 73 | * console.log("The result of the addition is: " + r.toString()); 74 | */ 75 | add2(lhs, rhs) { 76 | this.x = lhs.x + rhs.x; 77 | this.y = lhs.y + rhs.y; 78 | 79 | return this; 80 | } 81 | 82 | /** 83 | * Adds a number to each element of a vector. 84 | * 85 | * @param {number} scalar - The number to add. 86 | * @returns {Vec2} Self for chaining. 87 | * @example 88 | * var vec = new pc.Vec2(3, 4); 89 | * 90 | * vec.addScalar(2); 91 | * 92 | * // Outputs [5, 6] 93 | * console.log("The result of the addition is: " + vec.toString()); 94 | */ 95 | addScalar(scalar) { 96 | this.x += scalar; 97 | this.y += scalar; 98 | 99 | return this; 100 | } 101 | 102 | /** 103 | * Returns an identical copy of the specified 2-dimensional vector. 104 | * 105 | * @returns {Vec2} A 2-dimensional vector containing the result of the cloning. 106 | * @example 107 | * var v = new pc.Vec2(10, 20); 108 | * var vclone = v.clone(); 109 | * console.log("The result of the cloning is: " + vclone.toString()); 110 | */ 111 | clone() { 112 | return new Vec2(this.x, this.y); 113 | } 114 | 115 | /** 116 | * Copies the contents of a source 2-dimensional vector to a destination 2-dimensional vector. 117 | * 118 | * @param {Vec2} rhs - A vector to copy to the specified vector. 119 | * @returns {Vec2} Self for chaining. 120 | * @example 121 | * var src = new pc.Vec2(10, 20); 122 | * var dst = new pc.Vec2(); 123 | * 124 | * dst.copy(src); 125 | * 126 | * console.log("The two vectors are " + (dst.equals(src) ? "equal" : "different")); 127 | */ 128 | copy(rhs) { 129 | this.x = rhs.x; 130 | this.y = rhs.y; 131 | 132 | return this; 133 | } 134 | 135 | /** 136 | * Returns the result of a cross product operation performed on the two specified 2-dimensional 137 | * vectors. 138 | * 139 | * @param {Vec2} rhs - The second 2-dimensional vector operand of the cross product. 140 | * @returns {number} The cross product of the two vectors. 141 | * @example 142 | * var right = new pc.Vec2(1, 0); 143 | * var up = new pc.Vec2(0, 1); 144 | * var crossProduct = right.cross(up); 145 | * 146 | * // Prints 1 147 | * console.log("The result of the cross product is: " + crossProduct); 148 | */ 149 | cross(rhs) { 150 | return this.x * rhs.y - this.y * rhs.x; 151 | } 152 | 153 | /** 154 | * Returns the distance between the two specified 2-dimensional vectors. 155 | * 156 | * @param {Vec2} rhs - The second 2-dimensional vector to test. 157 | * @returns {number} The distance between the two vectors. 158 | * @example 159 | * var v1 = new pc.Vec2(5, 10); 160 | * var v2 = new pc.Vec2(10, 20); 161 | * var d = v1.distance(v2); 162 | * console.log("The distance between v1 and v2 is: " + d); 163 | */ 164 | distance(rhs) { 165 | const x = this.x - rhs.x; 166 | const y = this.y - rhs.y; 167 | return Math.sqrt(x * x + y * y); 168 | } 169 | 170 | /** 171 | * Divides a 2-dimensional vector by another in place. 172 | * 173 | * @param {Vec2} rhs - The vector to divide the specified vector by. 174 | * @returns {Vec2} Self for chaining. 175 | * @example 176 | * var a = new pc.Vec2(4, 9); 177 | * var b = new pc.Vec2(2, 3); 178 | * 179 | * a.div(b); 180 | * 181 | * // Outputs [2, 3] 182 | * console.log("The result of the division is: " + a.toString()); 183 | */ 184 | div(rhs) { 185 | this.x /= rhs.x; 186 | this.y /= rhs.y; 187 | 188 | return this; 189 | } 190 | 191 | /** 192 | * Divides one 2-dimensional vector by another and writes the result to the specified vector. 193 | * 194 | * @param {Vec2} lhs - The dividend vector (the vector being divided). 195 | * @param {Vec2} rhs - The divisor vector (the vector dividing the dividend). 196 | * @returns {Vec2} Self for chaining. 197 | * @example 198 | * var a = new pc.Vec2(4, 9); 199 | * var b = new pc.Vec2(2, 3); 200 | * var r = new pc.Vec2(); 201 | * 202 | * r.div2(a, b); 203 | * // Outputs [2, 3] 204 | * 205 | * console.log("The result of the division is: " + r.toString()); 206 | */ 207 | div2(lhs, rhs) { 208 | this.x = lhs.x / rhs.x; 209 | this.y = lhs.y / rhs.y; 210 | 211 | return this; 212 | } 213 | 214 | /** 215 | * Divides each element of a vector by a number. 216 | * 217 | * @param {number} scalar - The number to divide by. 218 | * @returns {Vec2} Self for chaining. 219 | * @example 220 | * var vec = new pc.Vec2(3, 6); 221 | * 222 | * vec.divScalar(3); 223 | * 224 | * // Outputs [1, 2] 225 | * console.log("The result of the division is: " + vec.toString()); 226 | */ 227 | divScalar(scalar) { 228 | this.x /= scalar; 229 | this.y /= scalar; 230 | 231 | return this; 232 | } 233 | 234 | /** 235 | * Returns the result of a dot product operation performed on the two specified 2-dimensional 236 | * vectors. 237 | * 238 | * @param {Vec2} rhs - The second 2-dimensional vector operand of the dot product. 239 | * @returns {number} The result of the dot product operation. 240 | * @example 241 | * var v1 = new pc.Vec2(5, 10); 242 | * var v2 = new pc.Vec2(10, 20); 243 | * var v1dotv2 = v1.dot(v2); 244 | * console.log("The result of the dot product is: " + v1dotv2); 245 | */ 246 | dot(rhs) { 247 | return this.x * rhs.x + this.y * rhs.y; 248 | } 249 | 250 | /** 251 | * Reports whether two vectors are equal. 252 | * 253 | * @param {Vec2} rhs - The vector to compare to the specified vector. 254 | * @returns {boolean} True if the vectors are equal and false otherwise. 255 | * @example 256 | * var a = new pc.Vec2(1, 2); 257 | * var b = new pc.Vec2(4, 5); 258 | * console.log("The two vectors are " + (a.equals(b) ? "equal" : "different")); 259 | */ 260 | equals(rhs) { 261 | return this.x === rhs.x && this.y === rhs.y; 262 | } 263 | 264 | /** 265 | * Returns the magnitude of the specified 2-dimensional vector. 266 | * 267 | * @returns {number} The magnitude of the specified 2-dimensional vector. 268 | * @example 269 | * var vec = new pc.Vec2(3, 4); 270 | * var len = vec.length(); 271 | * // Outputs 5 272 | * console.log("The length of the vector is: " + len); 273 | */ 274 | length() { 275 | return Math.sqrt(this.x * this.x + this.y * this.y); 276 | } 277 | 278 | /** 279 | * Returns the magnitude squared of the specified 2-dimensional vector. 280 | * 281 | * @returns {number} The magnitude of the specified 2-dimensional vector. 282 | * @example 283 | * var vec = new pc.Vec2(3, 4); 284 | * var len = vec.lengthSq(); 285 | * // Outputs 25 286 | * console.log("The length squared of the vector is: " + len); 287 | */ 288 | lengthSq() { 289 | return this.x * this.x + this.y * this.y; 290 | } 291 | 292 | /** 293 | * Returns the result of a linear interpolation between two specified 2-dimensional vectors. 294 | * 295 | * @param {Vec2} lhs - The 2-dimensional to interpolate from. 296 | * @param {Vec2} rhs - The 2-dimensional to interpolate to. 297 | * @param {number} alpha - The value controlling the point of interpolation. Between 0 and 1, 298 | * the linear interpolant will occur on a straight line between lhs and rhs. Outside of this 299 | * range, the linear interpolant will occur on a ray extrapolated from this line. 300 | * @returns {Vec2} Self for chaining. 301 | * @example 302 | * var a = new pc.Vec2(0, 0); 303 | * var b = new pc.Vec2(10, 10); 304 | * var r = new pc.Vec2(); 305 | * 306 | * r.lerp(a, b, 0); // r is equal to a 307 | * r.lerp(a, b, 0.5); // r is 5, 5 308 | * r.lerp(a, b, 1); // r is equal to b 309 | */ 310 | lerp(lhs, rhs, alpha) { 311 | this.x = lhs.x + alpha * (rhs.x - lhs.x); 312 | this.y = lhs.y + alpha * (rhs.y - lhs.y); 313 | 314 | return this; 315 | } 316 | 317 | /** 318 | * Multiplies a 2-dimensional vector to another in place. 319 | * 320 | * @param {Vec2} rhs - The 2-dimensional vector used as the second multiplicand of the operation. 321 | * @returns {Vec2} Self for chaining. 322 | * @example 323 | * var a = new pc.Vec2(2, 3); 324 | * var b = new pc.Vec2(4, 5); 325 | * 326 | * a.mul(b); 327 | * 328 | * // Outputs 8, 15 329 | * console.log("The result of the multiplication is: " + a.toString()); 330 | */ 331 | mul(rhs) { 332 | this.x *= rhs.x; 333 | this.y *= rhs.y; 334 | 335 | return this; 336 | } 337 | 338 | /** 339 | * Returns the result of multiplying the specified 2-dimensional vectors together. 340 | * 341 | * @param {Vec2} lhs - The 2-dimensional vector used as the first multiplicand of the operation. 342 | * @param {Vec2} rhs - The 2-dimensional vector used as the second multiplicand of the operation. 343 | * @returns {Vec2} Self for chaining. 344 | * @example 345 | * var a = new pc.Vec2(2, 3); 346 | * var b = new pc.Vec2(4, 5); 347 | * var r = new pc.Vec2(); 348 | * 349 | * r.mul2(a, b); 350 | * 351 | * // Outputs 8, 15 352 | * console.log("The result of the multiplication is: " + r.toString()); 353 | */ 354 | mul2(lhs, rhs) { 355 | this.x = lhs.x * rhs.x; 356 | this.y = lhs.y * rhs.y; 357 | 358 | return this; 359 | } 360 | 361 | /** 362 | * Multiplies each element of a vector by a number. 363 | * 364 | * @param {number} scalar - The number to multiply by. 365 | * @returns {Vec2} Self for chaining. 366 | * @example 367 | * var vec = new pc.Vec2(3, 6); 368 | * 369 | * vec.mulScalar(3); 370 | * 371 | * // Outputs [9, 18] 372 | * console.log("The result of the multiplication is: " + vec.toString()); 373 | */ 374 | mulScalar(scalar) { 375 | this.x *= scalar; 376 | this.y *= scalar; 377 | 378 | return this; 379 | } 380 | 381 | /** 382 | * Returns this 2-dimensional vector converted to a unit vector in place. If the vector has a 383 | * length of zero, the vector's elements will be set to zero. 384 | * 385 | * @returns {Vec2} Self for chaining. 386 | * @example 387 | * var v = new pc.Vec2(25, 0); 388 | * 389 | * v.normalize(); 390 | * 391 | * // Outputs 1, 0 392 | * console.log("The result of the vector normalization is: " + v.toString()); 393 | */ 394 | normalize() { 395 | const lengthSq = this.x * this.x + this.y * this.y; 396 | if (lengthSq > 0) { 397 | const invLength = 1 / Math.sqrt(lengthSq); 398 | this.x *= invLength; 399 | this.y *= invLength; 400 | } 401 | 402 | return this; 403 | } 404 | 405 | /** 406 | * Each element is set to the largest integer less than or equal to its value. 407 | * 408 | * @returns {Vec2} Self for chaining. 409 | */ 410 | floor() { 411 | this.x = Math.floor(this.x); 412 | this.y = Math.floor(this.y); 413 | return this; 414 | } 415 | 416 | /** 417 | * Each element is rounded up to the next largest integer. 418 | * 419 | * @returns {Vec2} Self for chaining. 420 | */ 421 | ceil() { 422 | this.x = Math.ceil(this.x); 423 | this.y = Math.ceil(this.y); 424 | return this; 425 | } 426 | 427 | /** 428 | * Each element is rounded up or down to the nearest integer. 429 | * 430 | * @returns {Vec2} Self for chaining. 431 | */ 432 | round() { 433 | this.x = Math.round(this.x); 434 | this.y = Math.round(this.y); 435 | return this; 436 | } 437 | 438 | /** 439 | * Each element is assigned a value from rhs parameter if it is smaller. 440 | * 441 | * @param {Vec2} rhs - The 2-dimensional vector used as the source of elements to compare to. 442 | * @returns {Vec2} Self for chaining. 443 | */ 444 | min(rhs) { 445 | if (rhs.x < this.x) this.x = rhs.x; 446 | if (rhs.y < this.y) this.y = rhs.y; 447 | return this; 448 | } 449 | 450 | /** 451 | * Each element is assigned a value from rhs parameter if it is larger. 452 | * 453 | * @param {Vec2} rhs - The 2-dimensional vector used as the source of elements to compare to. 454 | * @returns {Vec2} Self for chaining. 455 | */ 456 | max(rhs) { 457 | if (rhs.x > this.x) this.x = rhs.x; 458 | if (rhs.y > this.y) this.y = rhs.y; 459 | return this; 460 | } 461 | 462 | /** 463 | * Sets the specified 2-dimensional vector to the supplied numerical values. 464 | * 465 | * @param {number} x - The value to set on the first component of the vector. 466 | * @param {number} y - The value to set on the second component of the vector. 467 | * @returns {Vec2} Self for chaining. 468 | * @example 469 | * var v = new pc.Vec2(); 470 | * v.set(5, 10); 471 | * 472 | * // Outputs 5, 10 473 | * console.log("The result of the vector set is: " + v.toString()); 474 | */ 475 | set(x, y) { 476 | this.x = x; 477 | this.y = y; 478 | 479 | return this; 480 | } 481 | 482 | /** 483 | * Subtracts a 2-dimensional vector from another in place. 484 | * 485 | * @param {Vec2} rhs - The vector to add to the specified vector. 486 | * @returns {Vec2} Self for chaining. 487 | * @example 488 | * var a = new pc.Vec2(10, 10); 489 | * var b = new pc.Vec2(20, 20); 490 | * 491 | * a.sub(b); 492 | * 493 | * // Outputs [-10, -10] 494 | * console.log("The result of the subtraction is: " + a.toString()); 495 | */ 496 | sub(rhs) { 497 | this.x -= rhs.x; 498 | this.y -= rhs.y; 499 | 500 | return this; 501 | } 502 | 503 | /** 504 | * Subtracts two 2-dimensional vectors from one another and returns the result. 505 | * 506 | * @param {Vec2} lhs - The first vector operand for the addition. 507 | * @param {Vec2} rhs - The second vector operand for the addition. 508 | * @returns {Vec2} Self for chaining. 509 | * @example 510 | * var a = new pc.Vec2(10, 10); 511 | * var b = new pc.Vec2(20, 20); 512 | * var r = new pc.Vec2(); 513 | * 514 | * r.sub2(a, b); 515 | * 516 | * // Outputs [-10, -10] 517 | * console.log("The result of the subtraction is: " + r.toString()); 518 | */ 519 | sub2(lhs, rhs) { 520 | this.x = lhs.x - rhs.x; 521 | this.y = lhs.y - rhs.y; 522 | 523 | return this; 524 | } 525 | 526 | /** 527 | * Subtracts a number from each element of a vector. 528 | * 529 | * @param {number} scalar - The number to subtract. 530 | * @returns {Vec2} Self for chaining. 531 | * @example 532 | * var vec = new pc.Vec2(3, 4); 533 | * 534 | * vec.subScalar(2); 535 | * 536 | * // Outputs [1, 2] 537 | * console.log("The result of the subtraction is: " + vec.toString()); 538 | */ 539 | subScalar(scalar) { 540 | this.x -= scalar; 541 | this.y -= scalar; 542 | 543 | return this; 544 | } 545 | 546 | /** 547 | * Converts the vector to string form. 548 | * 549 | * @returns {string} The vector in string form. 550 | * @example 551 | * var v = new pc.Vec2(20, 10); 552 | * // Outputs [20, 10] 553 | * console.log(v.toString()); 554 | */ 555 | toString() { 556 | return `[${this.x}, ${this.y}]`; 557 | } 558 | 559 | /** 560 | * Calculates the angle between two Vec2's in radians. 561 | * 562 | * @param {Vec2} lhs - The first vector operand for the calculation. 563 | * @param {Vec2} rhs - The second vector operand for the calculation. 564 | * @returns {number} The calculated angle in radians. 565 | * @ignore 566 | */ 567 | static angleRad(lhs, rhs) { 568 | return Math.atan2(lhs.x * rhs.y - lhs.y * rhs.x, lhs.x * rhs.x + lhs.y * rhs.y); 569 | } 570 | 571 | /** 572 | * A constant vector set to [0, 0]. 573 | * 574 | * @type {Vec2} 575 | * @readonly 576 | */ 577 | static ZERO = Object.freeze(new Vec2(0, 0)); 578 | 579 | /** 580 | * A constant vector set to [1, 1]. 581 | * 582 | * @type {Vec2} 583 | * @readonly 584 | */ 585 | static ONE = Object.freeze(new Vec2(1, 1)); 586 | 587 | /** 588 | * A constant vector set to [0, 1]. 589 | * 590 | * @type {Vec2} 591 | * @readonly 592 | */ 593 | static UP = Object.freeze(new Vec2(0, 1)); 594 | 595 | /** 596 | * A constant vector set to [0, -1]. 597 | * 598 | * @type {Vec2} 599 | * @readonly 600 | */ 601 | static DOWN = Object.freeze(new Vec2(0, -1)); 602 | 603 | /** 604 | * A constant vector set to [1, 0]. 605 | * 606 | * @type {Vec2} 607 | * @readonly 608 | */ 609 | static RIGHT = Object.freeze(new Vec2(1, 0)); 610 | 611 | /** 612 | * A constant vector set to [-1, 0]. 613 | * 614 | * @type {Vec2} 615 | * @readonly 616 | */ 617 | static LEFT = Object.freeze(new Vec2(-1, 0)); 618 | } 619 | 620 | export { Vec2 }; 621 | -------------------------------------------------------------------------------- /src/selected-item.js: -------------------------------------------------------------------------------- 1 | class SelectedItem { 2 | constructor(graph, type, id, edgeId) { 3 | this._graph = graph; 4 | this._type = type; 5 | this._id = id; 6 | this._edgeId = edgeId; 7 | } 8 | 9 | get type() { 10 | return this._type; 11 | } 12 | 13 | get id() { 14 | return this._id; 15 | } 16 | 17 | get edgeId() { 18 | return this._edgeId; 19 | } 20 | 21 | selectItem() { 22 | switch (this._type) { 23 | case 'NODE': 24 | this._graph.view.selectNode(this._id); 25 | break; 26 | case 'EDGE': 27 | this._graph.view.selectEdge(this._id); 28 | break; 29 | } 30 | } 31 | 32 | deselectItem() { 33 | switch (this._type) { 34 | case 'NODE': 35 | this._graph.view.deselectNode(this._id); 36 | break; 37 | case 'EDGE': 38 | this._graph.view.deselectEdge(this._id); 39 | break; 40 | } 41 | } 42 | } 43 | 44 | export default SelectedItem; 45 | -------------------------------------------------------------------------------- /src/styles/index.js: -------------------------------------------------------------------------------- 1 | import './style.scss'; 2 | -------------------------------------------------------------------------------- /src/styles/style.scss: -------------------------------------------------------------------------------- 1 | @import '../lib/joint'; 2 | @import '../lib/layout'; 3 | @import '../lib/material'; 4 | 5 | .pcui-graph { 6 | font-family: 'Helvetica Neue', Arial, Helvetica, sans-serif; 7 | width: 100%; 8 | height: 100%; 9 | 10 | .available-magnet { 11 | fill: greenyellow; 12 | } 13 | 14 | #paper-html-elements { 15 | position: relative; 16 | border: 1px solid gray; 17 | display: inline-block; 18 | background: transparent; 19 | overflow: hidden; 20 | } 21 | 22 | #paper-html-elements svg { 23 | background: transparent; 24 | } 25 | 26 | #paper-html-elements svg .link { 27 | z-index: 2; 28 | } 29 | 30 | .html-element { 31 | position: absolute; 32 | background: #3498db; 33 | 34 | /* Make sure events are propagated to the JointJS element so, e.g. dragging works. */ 35 | pointer-events: none; 36 | user-select: none; 37 | border-radius: 4px; 38 | border: 2px solid #2980b9; 39 | box-shadow: inset 0 0 5px black, 2px 2px 1px gray; 40 | padding: 5px; 41 | box-sizing: border-box; 42 | z-index: 2; 43 | } 44 | 45 | .html-element select, 46 | .html-element input, 47 | .html-element button { 48 | /* Enable interacting with inputs only. */ 49 | pointer-events: auto; 50 | } 51 | 52 | .html-element button.delete { 53 | color: white; 54 | border: none; 55 | background-color: #c0392b; 56 | border-radius: 20px; 57 | width: 15px; 58 | height: 15px; 59 | line-height: 15px; 60 | text-align: middle; 61 | position: absolute; 62 | top: -15px; 63 | left: -15px; 64 | padding: 0; 65 | margin: 0; 66 | font-weight: bold; 67 | cursor: pointer; 68 | } 69 | 70 | .html-element button.delete:hover { 71 | width: 20px; 72 | height: 20px; 73 | line-height: 20px; 74 | } 75 | 76 | .html-element select { 77 | position: absolute; 78 | right: 2px; 79 | bottom: 28px; 80 | } 81 | 82 | .html-element input { 83 | position: absolute; 84 | bottom: 0; 85 | left: 0; 86 | right: 0; 87 | border: none; 88 | color: #333; 89 | padding: 5px; 90 | height: 16px; 91 | } 92 | 93 | .html-element label { 94 | color: #333; 95 | text-shadow: 1px 0 0 lightgray; 96 | font-weight: bold; 97 | } 98 | 99 | .html-element span { 100 | position: absolute; 101 | top: 2px; 102 | right: 9px; 103 | color: white; 104 | font-size: 10px; 105 | } 106 | 107 | .graph-node-div { 108 | pointer-events: none; 109 | } 110 | 111 | .graph-node-input { 112 | pointer-events: all; 113 | } 114 | 115 | .graph-node-input-no-pointer-events { 116 | pointer-events: none; 117 | } 118 | 119 | .graph-node-container { 120 | margin-top: 5px; 121 | margin-bottom: 5px; 122 | height: 28px; 123 | display: flex; 124 | align-items: center; 125 | pointer-events: inherit; 126 | } 127 | 128 | .graph-node-container:first-child { 129 | // margin-top: 33px !important; 130 | } 131 | 132 | .graph-node-label { 133 | max-width: 50px; 134 | min-width: 50px; 135 | font-size: 12px; 136 | margin-left: 13px; 137 | } 138 | 139 | .port-inner-body { 140 | pointer-events: none; 141 | } 142 | 143 | .pcui-contextmenu-parent, 144 | .pcui-contextmenu-child { 145 | height: 27px; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | export const deepCopyFunction = (inObject) => { 2 | let value, key; 3 | 4 | if (typeof inObject !== 'object' || inObject === null) { 5 | return inObject; // Return the value if inObject is not an object 6 | } 7 | 8 | // Create an array or object to hold the values 9 | const outObject = Array.isArray(inObject) ? [] : {}; 10 | 11 | for (key in inObject) { 12 | value = inObject[key]; 13 | 14 | // Recursively (deep) copy for nested objects, including arrays 15 | outObject[key] = deepCopyFunction(value); 16 | } 17 | 18 | return outObject; 19 | }; 20 | -------------------------------------------------------------------------------- /styles/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pcui-graph-styles", 3 | "version": "1.0.0", 4 | "author": "PlayCanvas ", 5 | "homepage": "https://playcanvas.github.io/pcui-graph", 6 | "description": "PCUI graph styles", 7 | "private": true, 8 | "main": "dist/index.mjs", 9 | "license": "MIT", 10 | "bugs": { 11 | "url": "https://github.com/playcanvas/pcui-graph/issues" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/playcanvas/pcui-graph.git" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "allowJs": true, 5 | "target": "es6", 6 | "jsx": "react", 7 | "types": ["react"], 8 | "lib": [ 9 | "es2019", 10 | "dom", 11 | "dom.iterable" 12 | ], 13 | "esModuleInterop" : true, 14 | "sourceMap": true, 15 | "moduleResolution": "node" 16 | }, 17 | "include": ["./src/index.js"], 18 | "exclude": ["node_modules/**/*", "node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": [ 4 | "./src/index.js" 5 | ], 6 | "exclude": [ 7 | "**/node_modules/**" 8 | ], 9 | "excludeNotDocumented": true, 10 | "externalSymbolLinkMappings": { 11 | "@playcanvas/pcui": { 12 | "Element": "https://api.playcanvas.com/pcui/classes/Element.html" 13 | } 14 | }, 15 | "favicon": "utils/typedoc/favicon.ico", 16 | "hostedBaseUrl": "https://api.playcanvas.com/pcui-graph/", 17 | "includeVersion": true, 18 | "name": "PCUI Graph API Reference", 19 | "navigationLinks": { 20 | "Developer Site": "https://developer.playcanvas.com/", 21 | "Blog": "https://blog.playcanvas.com/", 22 | "Discord": "https://discord.gg/RSaMRzg", 23 | "Forum": "https://forum.playcanvas.com/", 24 | "GitHub": "https://github.com/playcanvas/pcui-graph" 25 | }, 26 | "sidebarLinks": { 27 | "Home": "/" 28 | }, 29 | "plugin": [ 30 | "typedoc-plugin-mdn-links", 31 | "typedoc-plugin-rename-defaults" 32 | ], 33 | "readme": "none", 34 | "searchGroupBoosts": { 35 | "Classes": 2 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /utils/typedoc/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playcanvas/pcui-graph/26d018b33dc62dd4956137e18f80a135ff68adbb/utils/typedoc/favicon.ico --------------------------------------------------------------------------------