├── .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 | [](https://github.com/playcanvas/pcui-graph/blob/main/LICENSE)
4 | [](https://www.npmjs.com/package/@playcanvas/pcui-graph)
5 | [](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 | 
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
--------------------------------------------------------------------------------