initial commit

This commit is contained in:
equippedcoding-master
2025-09-17 09:37:06 -05:00
parent 86108ca47e
commit e2c98790b2
55389 changed files with 6206730 additions and 0 deletions

View File

@@ -0,0 +1,2 @@

View File

@@ -0,0 +1,76 @@
{
"name": "playcanvas",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "playcanvas",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playcanvas/pcui": "^5.1.0"
}
},
"node_modules/@playcanvas/observer": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@playcanvas/observer/-/observer-1.6.6.tgz",
"integrity": "sha512-UMpiEG6VFmgDI2KkKE1I1swommG2AxBQA/zuCoUt+HeUzNOhSSdfED6tVTPluQ+nxp2ScUYSaBCBsjVC/goSiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@playcanvas/pcui": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@playcanvas/pcui/-/pcui-5.1.0.tgz",
"integrity": "sha512-YskQbfuu/f+Qg3A/9YS3wOAQqajY8LrqyQUegqbWBhZcBKN9Ddby+FXj6HIiID51PngKI9QQjYrG1+PPsbO7jg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@playcanvas/observer": "^1.5.1"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"react": "^18.3.1 || ^19.0.0",
"react-dom": "^18.3.1 || ^19.0.0"
}
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.25.0"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"dev": true,
"license": "MIT",
"peer": true
}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "playcanvas",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@playcanvas/pcui": "^5.1.0"
}
}

View File

@@ -0,0 +1,17 @@
{
"sourceType": "unambiguous",
"presets": [
[
"@babel/preset-env",
{
"targets": {
"chrome": 100,
"safari": 15,
"firefox": 91
}
}
],
"@babel/preset-react"
],
"plugins": []
}

View File

@@ -0,0 +1,28 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
/dist
/styles/dist
/typedocs
/.storybook/utils/jsdoc-ast.json
/types
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,57 @@
import React from 'react';
import Graph from '../src/index.js';
import '@playcanvas/pcui/styles';
import '../src/styles/index.js';
class BaseComponent extends React.Component {
constructor(props) {
super(props);
}
attachElement = (nodeElement, containerElement) => {
if (!nodeElement) return;
this.element = new Graph(this.props.schema, {
...this.props.options,
dom: nodeElement,
});
if (this.onClick) {
this.element.on('click', this.onClick);
}
if (this.onChange) {
this.element.on('change', this.onChange);
}
if (this.props.parent) {
this.element.parent = this.props.parent;
}
}
getPropertyDescriptor = (obj, prop) => {
let desc;
do {
desc = Object.getOwnPropertyDescriptor(obj, prop);
} while (!desc && (obj = Object.getPrototypeOf(obj)));
return desc;
}
componentDidMount() {
if (this.link) {
this.element.link(this.link.observer, this.link.path);
}
}
componentDidUpdate(prevProps) {
Object.keys(this.props).forEach(prop => {
var propDescriptor = this.getPropertyDescriptor(this.element, prop);
if (propDescriptor && propDescriptor.set) {
this.element[prop] = this.props[prop];
}
});
if (prevProps.link !== this.props.link) {
this.element.link(this.props.link.observer, this.props.link.path);
}
}
render() {
return <div ref={this.attachElement} />
}
}
export default BaseComponent;

View File

@@ -0,0 +1,32 @@
module.exports = {
stories: ['./**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: [
'@storybook/addon-actions',
'@storybook/addon-links',
'@storybook/addon-docs',
'@storybook/addon-backgrounds/register',
'@storybook/preset-create-react-app'
],
webpackFinal: async (config, { configType }) => {
// `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
// You can change the configuration based on that.
// 'PRODUCTION' is used when building the static version of storybook.
config.module.rules = config.module.rules.filter(rule => {
if (!rule.test) return true;
return !rule.test.test(".scss");
});
// Return the altered config
return config;
},
framework: {
name: '@storybook/react-webpack5',
options: {}
},
docs: {}
};

View File

@@ -0,0 +1,22 @@
const preview = {
parameters: {
backgrounds: {
default: 'playcanvas',
values: [
{
name: 'playcanvas',
value: '#374346'
},
{
name: 'white',
value: '#FFFFFF'
}
]
},
controls: { expanded: true }
},
tags: ['autodocs']
};
export default preview;

View File

@@ -0,0 +1,160 @@
import React from 'react';
import { GRAPH_ACTIONS } from '../../../src/constants';
import Graph from '../../base-component';
export default {
title: 'Advanced/Directed Graph',
component: Graph,
argTypes: {
// Define the args that you want to be editable in the Storybook UI
}
};
const GRAPH_ENUM = {
NODE: {
STATE: 0,
},
EDGE: {
EDGE: 0,
}
};
const GRAPH_SCHEMA = {
nodes: {
[GRAPH_ENUM.NODE.STATE]: {
name: 'state',
fill: 'rgb(54, 67, 70, 0.8)',
stroke: '#20292b',
icon: '',
iconColor: '#FFFFFF',
contextMenuItems: [
{
text: 'Add transition',
action: GRAPH_ACTIONS.ADD_EDGE,
edgeType: GRAPH_ENUM.EDGE.EDGE
},
{
text: 'Delete state',
action: GRAPH_ACTIONS.DELETE_NODE
}
]
}
},
edges: {
[GRAPH_ENUM.EDGE.EDGE]: {
stroke: '#0379EE',
strokeWidth: 2,
targetMarkerStroke: '#0379EE',
targetMarker: true,
from: [
GRAPH_ENUM.NODE.STATE,
GRAPH_ENUM.NODE.START_STATE,
GRAPH_ENUM.NODE.DEFAULT_STATE
],
to: [
GRAPH_ENUM.NODE.STATE,
GRAPH_ENUM.NODE.DEFAULT_STATE,
GRAPH_ENUM.NODE.END_STATE
],
contextMenuItems: [
{
text: 'Delete edge',
action: GRAPH_ACTIONS.DELETE_EDGE
}
]
}
}
};
var GRAPH_DATA = {
nodes: {
1234: {
id: 1234,
nodeType: GRAPH_ENUM.NODE.STATE,
name: 'NODE A',
posX: 100,
posY: 100
},
1235: {
id: 1235,
nodeType: GRAPH_ENUM.NODE.STATE,
name: 'NODE B',
posX: 100,
posY: 300
},
1236: {
id: 1236,
nodeType: GRAPH_ENUM.NODE.STATE,
name: 'NODE C',
posX: 300,
posY: 200
}
},
edges: {
'1234-1235': {
edgeType: GRAPH_ENUM.EDGE.EDGE,
from: 1234,
to: 1235
},
'1235-1236': {
edgeType: GRAPH_ENUM.EDGE.EDGE,
from: 1235,
to: 1236
},
'1236-1235': {
edgeType: GRAPH_ENUM.EDGE.EDGE,
from: 1236,
to: 1235
}
}
};
const GRAPH_CONTEXT_MENU_ITEMS_ITEMS = [
{
text: 'Add new state',
action: GRAPH_ACTIONS.ADD_NODE,
nodeType: GRAPH_ENUM.NODE.STATE,
attributes: {
name: 'New state',
speed: 1.0,
loop: false
}
}
];
// Template function
const Template = (args) => <Graph schema={GRAPH_SCHEMA} options={{...args}} />;
// Default story using the template
export const DirectedGraphExample = Template.bind({});
// Default args for the story
DirectedGraphExample.args = {
initialData: GRAPH_DATA,
contextMenuItems: GRAPH_CONTEXT_MENU_ITEMS_ITEMS,
passiveUIEvents: false,
includeFonts: true,
incrementNodeNames: true,
adjustVertices: true,
defaultStyles: {
background: {
color: '#20292B',
gridSize: 10
},
edge: {
connectionStyle: 'default'
}
}
};
document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%');
document.body.setAttribute('style', 'margin: 0px; padding: 0px;');
setTimeout(() => {
Object.keys(GRAPH_ACTIONS).forEach((key) => {
const graphAction = GRAPH_ACTIONS[key];
document.querySelector('.pcui-graph').ui.on(graphAction, (data) => {
console.log(graphAction, data);
});
});
}, 500);

View File

@@ -0,0 +1,245 @@
import React from 'react';
import Graph from '../../base-component';
export default {
title: 'Advanced/Version Control Graph',
component: Graph,
argTypes: {
// Define the args that you want to be editable in the Storybook UI
}
};
const GRAPH_SCHEMA = {
nodes: {
0: {
fill: '#ead1db',
stroke: '#965070',
strokeSelected: '#965070',
strokeHover: '#965070',
textColor: '#20292b',
baseHeight: 60,
baseWidth: 150,
textAlignMiddle: true,
includeIcon: false
},
1: {
fill: '#fbe5cd',
stroke: '#ff574b',
strokeSelected: '#ff574b',
strokeHover: '#ff574b',
textColor: '#20292b',
baseHeight: 60,
baseWidth: 150,
textAlignMiddle: true,
includeIcon: false
},
2: {
fill: '#d0e1f2',
stroke: '#4d7cd7',
strokeSelected: '#4d7cd7',
strokeHover: '#4d7cd7',
textColor: '#20292b',
baseHeight: 60,
baseWidth: 150,
textAlignMiddle: true,
includeIcon: false
},
3: {
fill: '#d9ead3',
stroke: '#43fb39',
strokeSelected: '#43fb39',
strokeHover: '#43fb39',
textColor: '#20292b',
baseHeight: 60,
baseWidth: 150,
textAlignMiddle: true,
includeIcon: false
},
},
edges: {
0: {
from: [
0,
],
to: [
0, 1, 2, 3
],
stroke: '#965070',
strokeWidth: 3,
connectionStyle: 'smoothInOut'
},
1: {
from: [
1,
],
to: [
0, 1, 2, 3
],
stroke: '#ff574b',
strokeWidth: 3,
connectionStyle: 'smoothInOut'
},
2: {
from: [
2,
],
to: [
0, 1, 2, 3
],
stroke: '#4d7cd7',
strokeWidth: 3,
connectionStyle: 'smoothInOut'
},
3: {
from: [
3,
],
to: [
0, 1, 2, 3
],
stroke: '#43fb39',
strokeWidth: 3,
connectionStyle: 'smoothInOut'
}
}
};
const GRAPH_DATA = {
nodes: {},
edges: {
'02-12': {
from: '02',
to: '12',
edgeType: 0
},
'17-04': {
from: '17',
to: '04',
edgeType: 1
},
'13-30': {
from: '13',
to: '30',
edgeType: 1
},
'24-32': {
from: '24',
to: '32',
edgeType: 2
},
'25-14': {
from: '25',
to: '14',
edgeType: 2
},
'36-26': {
from: '36',
to: '26',
edgeType: 3
}
}
};
[
[
'Branch 1, Commit 5\nAug 23, 21 zpaul',
'Branch 1, Commit 4\nAug 23, 21 zpaul',
'Branch 1, Commit 3\nAug 23, 21 zpaul',
'Branch 1, Commit 2\nAug 23, 21 zpaul',
'Branch 1, Commit 1\nAug 23, 21 zpaul'
],
[
'Branch 2, Commit 8\nAug 23, 21 zpaul',
'Branch 2, Commit 7\nAug 23, 21 zpaul',
'Branch 2, Commit 6\nAug 23, 21 zpaul',
'Branch 2, Commit 5\nAug 23, 21 zpaul',
'Branch 2, Commit 4\nAug 23, 21 zpaul',
'Branch 2, Commit 3\nAug 23, 21 zpaul',
'Branch 2, Commit 2\nAug 23, 21 zpaul',
'Branch 2, Commit 1\nAug 23, 21 zpaul'
],
[
'Branch 3, Commit 7\nAug 23, 21 zpaul',
'Branch 3, Commit 6\nAug 23, 21 zpaul',
'Branch 3, Commit 5\nAug 23, 21 zpaul',
'Branch 3, Commit 4\nAug 23, 21 zpaul',
'Branch 3, Commit 3\nAug 23, 21 zpaul',
'Branch 3, Commit 2\nAug 23, 21 zpaul',
'Branch 3, Commit 1\nAug 23, 21 zpaul'
],
[
'Branch 4, Commit 7\nAug 23, 21 zpaul',
'Branch 4, Commit 6\nAug 23, 21 zpaul',
'Branch 4, Commit 5\nAug 23, 21 zpaul',
'Branch 4, Commit 4\nAug 23, 21 zpaul',
'Branch 4, Commit 3\nAug 23, 21 zpaul',
'Branch 4, Commit 2\nAug 23, 21 zpaul',
'Branch 4, Commit 1\nAug 23, 21 zpaul'
]
].forEach((commits, i) => {
commits.forEach((commit, j) => {
GRAPH_DATA.nodes[`${i}${j}`] = {
id: `${i}${j}`,
name: commit,
nodeType: i,
posX: 250 * i + 50,
posY: 100 * j + 100,
marker: ['17', '31', '36'].includes(`${i}${j}`)
};
if (j === 0) return;
GRAPH_DATA.edges[`${i}${j - 1}-${i}${j}`] = {
to: `${i}${j - 1}`,
from: `${i}${j}`,
edgeType: i
};
});
});
// Template function
const Template = (args) => <Graph schema={GRAPH_SCHEMA} options={{...args}} />;
// Default story using the template
export const VersionControlGraphExample = Template.bind({});
// Default args for the story
VersionControlGraphExample.args = {
initialData: GRAPH_DATA,
passiveUIEvents: false,
includeFonts: true,
defaultStyles: {
initialScale: 0.75,
background: {
color: '#20292B',
gridSize: 1
},
edge: {
connectionStyle: 'default',
targetMarker: true,
sourceMarker: true
}
},
readOnly: true
};
document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%');
document.body.setAttribute('style', 'margin: 0px; padding: 0px;');
setTimeout(() => {
const graph = document.querySelector('.pcui-graph').ui;
graph.on('EVENT_SELECT_NODE', ({node}) => {
if (node.id === '00') {
graph.createNode({
id: `4848583`,
name: 'Branch 1, Commit 6\nAug 23, 21 zpaul',
nodeType: 0,
posX: node.posX,
posY: node.posY - 100
});
graph.createEdge({
to: '4848583',
from :'00',
edgeType: 0
}, `00-${4848583}`);
}
});
}, 0);

View File

@@ -0,0 +1,403 @@
import React from 'react';
import { GRAPH_ACTIONS } from '../../../src/constants';
import Graph from '../../base-component';
export default {
title: 'Advanced/Visual Programming Graph',
component: Graph,
argTypes: {
// Define the args that you want to be editable in the Storybook UI
}
};
var GRAPH_ENUM = {
NODE: {
VARIABLE_FLOAT: 0,
MULTIPLY: 1,
OUT: 2,
ADD: 3,
SINE: 4,
TEXTURE: 5,
VARIABLE_VEC_2: 6,
},
EDGE: {
FLOAT: 1,
VEC_2: 2,
VEC_3: 3,
VEC_4: 4,
MATRIX: 5
}
};
var GRAPH_SCHEMA = {
nodes: {
[GRAPH_ENUM.NODE.VARIABLE_FLOAT]: {
name: 'Variable Float',
fill: 'rgb(54, 67, 70, 0.8)',
stroke: '#20292b',
contextMenuItems: [],
outPorts: [
{
name: 'output',
type: GRAPH_ENUM.EDGE.FLOAT
}
]
},
[GRAPH_ENUM.NODE.VARIABLE_VEC_2]: {
name: 'Variable Vec2',
fill: 'rgb(54, 67, 70, 0.8)',
stroke: '#20292b',
contextMenuItems: [],
outPorts: [
{
name: 'output',
type: GRAPH_ENUM.EDGE.VEC_2
}
]
},
[GRAPH_ENUM.NODE.MULTIPLY]: {
name: 'Multiply',
fill: 'rgb(54, 67, 70, 0.8)',
stroke: '#20292b',
contextMenuItems: [],
inPorts: [
{
name: 'left',
type: GRAPH_ENUM.EDGE.FLOAT
},
{
name: 'right',
type: GRAPH_ENUM.EDGE.FLOAT
}
],
outPorts: [
{
name: 'output',
type: GRAPH_ENUM.EDGE.FLOAT
}
]
},
[GRAPH_ENUM.NODE.ADD]: {
name: 'Add',
fill: 'rgb(54, 67, 70, 0.8)',
stroke: '#20292b',
contextMenuItems: [
{
text: 'Delete node',
action: GRAPH_ACTIONS.DELETE_NODE
}
],
inPorts: [
{
name: 'left',
type: GRAPH_ENUM.EDGE.FLOAT
},
{
name: 'right',
type: GRAPH_ENUM.EDGE.FLOAT
}
],
outPorts: [
{
name: 'output',
type: GRAPH_ENUM.EDGE.FLOAT
}
]
},
[GRAPH_ENUM.NODE.SINE]: {
name: 'Sine',
fill: 'rgb(54, 67, 70, 0.8)',
stroke: '#20292b',
contextMenuItems: [
{
text: 'Delete node',
action: GRAPH_ACTIONS.DELETE_NODE
}
],
inPorts: [
{
name: 'input',
type: GRAPH_ENUM.EDGE.FLOAT
}
],
outPorts: [
{
name: 'output',
type: GRAPH_ENUM.EDGE.FLOAT
}
]
},
[GRAPH_ENUM.NODE.FRAGMENT_OUTPUT]: {
name: 'Fragment Output',
fill: 'rgb(54, 67, 70, 0.8)',
stroke: '#20292b',
contextMenuItems: [],
inPorts: [
{
name: 'rgba',
type: GRAPH_ENUM.EDGE.VEC_4
},
{
name: 'rgb',
type: GRAPH_ENUM.EDGE.VEC_3
},
{
name: 'a',
type: GRAPH_ENUM.EDGE.FLOAT
}
]
},
[GRAPH_ENUM.NODE.TEXTURE]: {
name: 'Texture',
fill: 'rgb(54, 67, 70, 0.8)',
stroke: '#20292b',
contextMenuItems: [],
inPorts: [
{
name: 'uv',
type: GRAPH_ENUM.EDGE.VEC_2
}
],
outPorts: [
{
name: 'rgba',
type: GRAPH_ENUM.EDGE.VEC_4
},
{
name: 'rgb',
type: GRAPH_ENUM.EDGE.VEC_3
},
{
name: 'r',
type: GRAPH_ENUM.EDGE.FLOAT
},
{
name: 'g',
type: GRAPH_ENUM.EDGE.FLOAT
},
{
name: 'b',
type: GRAPH_ENUM.EDGE.FLOAT
}
]
}
},
edges: {
[GRAPH_ENUM.EDGE.FLOAT]: {
stroke: '#0379EE',
fill: 'rgb(54, 67, 70, 0.8)',
strokeWidth: 2,
targetMarker: null,
contextMenuItems: [
{
text: 'Delete edge',
action: GRAPH_ACTIONS.DELETE_EDGE
}
],
},
[GRAPH_ENUM.EDGE.VEC_2]: {
stroke: '#0379EE',
strokeWidth: 2,
targetMarker: null,
contextMenuItems: [
{
text: 'Delete edge',
action: GRAPH_ACTIONS.DELETE_EDGE
}
],
},
[GRAPH_ENUM.EDGE.VEC_3]: {
stroke: '#0379EE',
strokeWidth: 2,
targetMarker: null,
contextMenuItems: [
{
text: 'Delete edge',
action: GRAPH_ACTIONS.DELETE_EDGE
}
],
},
[GRAPH_ENUM.EDGE.VEC_4]: {
stroke: '#0379EE',
strokeWidth: 2,
targetMarker: null,
contextMenuItems: [
{
text: 'Delete edge',
action: GRAPH_ACTIONS.DELETE_EDGE
}
],
},
[GRAPH_ENUM.EDGE.MATRIX]: {
stroke: '#0379EE',
strokeWidth: 2,
targetMarker: null,
contextMenuItems: [
{
text: 'Delete edge',
action: GRAPH_ACTIONS.DELETE_EDGE
}
],
}
}
};
var GRAPH_DATA = {
nodes: {
1234: {
id: 1234,
nodeType: GRAPH_ENUM.NODE.VARIABLE_FLOAT,
name: 'maxAlpha',
posX: 100,
posY: 150,
attributes: {
name: 'maxAlpha'
}
},
1235: {
id: 1235,
nodeType: GRAPH_ENUM.NODE.VARIABLE_FLOAT,
posX: 100,
posY: 350,
attributes: {
name: 'time'
}
},
1236: {
id: 1236,
nodeType: GRAPH_ENUM.NODE.MULTIPLY,
name: 'Multiply',
posX: 650,
posY: 250
},
1237: {
id: 1237,
nodeType: GRAPH_ENUM.NODE.FRAGMENT_OUTPUT,
name: 'Fragment Output',
posX: 1050,
posY: 50
},
1238: {
id: 1238,
nodeType: GRAPH_ENUM.NODE.SINE,
name: 'Sine',
posX: 350,
posY: 350
},
1239: {
id: 1239,
nodeType: GRAPH_ENUM.NODE.TEXTURE,
name: 'Texture',
posX: 650,
posY: 50,
// texture: 'https://cdnb.artstation.com/p/assets/images/images/008/977/853/large/brandon-liu-mod9-grass-bliu2.jpg?1516424810'
},
1240: {
id: 1240,
nodeType: GRAPH_ENUM.NODE.VARIABLE_VEC_2,
name: 'meshUV',
posX: 100,
posY: 50,
attributes: {
name: 'uvCoords'
}
}
},
edges: {
'1234,0-1236,0': {
edgeType: GRAPH_ENUM.EDGE.FLOAT,
from: 1234,
to: 1236,
outPort: 0,
inPort: 0
},
'1235,0-1238,0': {
edgeType: GRAPH_ENUM.EDGE.FLOAT,
from: 1235,
to: 1238,
outPort: 0,
inPort: 0
},
'1238,0-1236,1': {
edgeType: GRAPH_ENUM.EDGE.FLOAT,
from: 1238,
to: 1236,
outPort: 0,
inPort: 1
},
'1236,0-1237,2': {
edgeType: GRAPH_ENUM.EDGE.FLOAT,
from: 1236,
to: 1237,
outPort: 0,
inPort: 2
},
'1239,1-1237,1': {
edgeType: GRAPH_ENUM.EDGE.VEC_3,
from: 1239,
to: 1237,
outPort: 1,
inPort: 1
},
'1240,0-1239,0': {
edgeType: GRAPH_ENUM.EDGE.VEC_2,
from: 1240,
to: 1239,
outPort: 0,
inPort: 0
}
}
};
var GRAPH_CONTEXT_MENU_ITEMS = [
{
text: 'New add',
action: GRAPH_ACTIONS.ADD_NODE,
nodeType: GRAPH_ENUM.NODE.ADD,
name: 'Add'
},
{
text: 'New multiply',
action: GRAPH_ACTIONS.ADD_NODE,
nodeType: GRAPH_ENUM.NODE.MULTIPLY,
name: 'Multiply'
},
{
text: 'New sine',
action: GRAPH_ACTIONS.ADD_NODE,
nodeType: GRAPH_ENUM.NODE.SINE,
name: 'Sine'
},
{
text: 'New texture',
action: GRAPH_ACTIONS.ADD_NODE,
nodeType: GRAPH_ENUM.NODE.TEXTURE,
name: 'Texture'
},
];
// Template function
const Template = (args) => <Graph schema={GRAPH_SCHEMA} options={{...args}} />;
// Default story using the template
export const VisualProgrammingGraphExample = Template.bind({});
// Default args for the story
VisualProgrammingGraphExample.args = {
initialData: GRAPH_DATA,
contextMenuItems: GRAPH_CONTEXT_MENU_ITEMS,
passiveUIEvents: false,
includeFonts: true,
defaultStyles: {
edge: {
connectionStyle: 'smoothInOut'
},
background: {
color: '#20292B',
gridSize: 10
}
}
};
document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%');
document.body.setAttribute('style', 'margin: 0px; padding: 0px;');

View File

@@ -0,0 +1,93 @@
import React from 'react';
import Graph from '../../base-component';
export default {
title: 'Basic/Directed Graph',
component: Graph,
argTypes: {
// Define the args that you want to be editable in the Storybook UI
}
};
const GRAPH_ENUM = {
NODE: {
HELLO: 0,
WORLD: 0
},
EDGE: {
HELLO_TO_WORLD: 0
}
};
const GRAPH_SCHEMA = {
nodes: {
[GRAPH_ENUM.NODE.HELLO]: {
name: 'Hello'
},
[GRAPH_ENUM.NODE.WORLD]: {
name: 'World'
}
},
edges: {
[GRAPH_ENUM.EDGE.HELLO_TO_WORLD]: {
from: [
GRAPH_ENUM.NODE.HELLO
],
to: [
GRAPH_ENUM.NODE.WORLD
]
}
}
};
var GRAPH_DATA = {
nodes: {
1234: {
id: 1234,
nodeType: GRAPH_ENUM.NODE.HELLO,
name: 'Hello',
posX: 100,
posY: 100
},
1235: {
id: 1235,
nodeType: GRAPH_ENUM.NODE.WORLD,
name: 'World',
posX: 100,
posY: 300
},
},
edges: {
'1234-1235': {
edgeType: GRAPH_ENUM.EDGE.HELLO_TO_WORLD,
from: 1234,
to: 1235
}
}
};
// Template function
const Template = (args) => <Graph schema={GRAPH_SCHEMA} options={{...args}} />;
// Default story using the template
export const DirectedGraphExample = Template.bind({});
// Default args for the story
DirectedGraphExample.args = {
initialData: GRAPH_DATA,
passiveUIEvents: false,
includeFonts: true,
defaultStyles: {
background: {
color: '#20292B',
gridSize: 10
},
edge: {
connectionStyle: 'default',
targetMarker: true
}
}
};
document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%');
document.body.setAttribute('style', 'margin: 0px; padding: 0px;');

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { GRAPH_ACTIONS } from '../../../src/constants';
import Graph from '../../base-component';
export default {
title: 'Basic/Node Attribute Error Graph',
component: Graph,
argTypes: {
// Define the args that you want to be editable in the Storybook UI
}
};
const GRAPH_ENUM = {
NODE: {
HELLO_WORLD: 0,
}
};
const GRAPH_SCHEMA = {
nodes: {
[GRAPH_ENUM.NODE.HELLO_WORLD]: {
name: 'Alphabet Only',
attributes: [
{
name: 'text',
type: 'TEXT_INPUT'
}
]
}
}
};
var GRAPH_DATA = {
nodes: {
1234: {
id: 1234,
nodeType: GRAPH_ENUM.NODE.HELLO_WORLD,
name: 'Alphabet Only',
posX: 200,
posY: 200,
attributes: {
text: 'abcdef'
}
}
},
edges: {}
};
// Template function
const Template = (args) => <Graph schema={GRAPH_SCHEMA} options={{...args}} />;
// Default story using the template
export const NodeAttributeErrorGraphExample = Template.bind({});
// Default args for the story
NodeAttributeErrorGraphExample.args = {
initialData: GRAPH_DATA,
passiveUIEvents: false,
includeFonts: true,
defaultStyles: {
edge: {
connectionStyle: 'smoothInOut'
},
background: {
color: '#20292B',
gridSize: 10
}
}
};
document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%');
document.body.setAttribute('style', 'margin: 0px; padding: 0px;');
setTimeout(() => {
const graph = document.querySelector('.pcui-graph').ui;
graph.on(GRAPH_ACTIONS.UPDATE_NODE_ATTRIBUTE, (data) => {
if (data.node.attributes[data.attribute].match('^[A-Za-z]*$')) {
graph.updateNodeAttribute(1234, data.attribute, data.node.attributes[data.attribute]);
graph.setNodeAttributeErrorState(1234, data.attribute, false);
} else {
graph.setNodeAttributeErrorState(1234, data.attribute, true);
}
});
}, 500);

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { GRAPH_ACTIONS } from '../../../src/constants';
import Graph from '../../base-component';
export default {
title: 'Basic/Node Attributes Graph',
component: Graph,
argTypes: {
// Define the args that you want to be editable in the Storybook UI
}
};
const GRAPH_ENUM = {
NODE: {
HELLO: 0,
WORLD: 1,
},
EDGE: {
HELLO_TO_WORLD: 0,
}
};
const GRAPH_SCHEMA = {
nodes: {
[GRAPH_ENUM.NODE.HELLO]: {
name: 'Hello',
headerTextFormatter: (attributes) => `Hello ${attributes.foo}`,
outPorts: [
{
name: 'output',
type: GRAPH_ENUM.EDGE.HELLO_TO_WORLD,
textFormatter: (attributes) => `output (${attributes.foo})`
}
],
attributes: [
{
name: 'foo',
type: 'TEXT_INPUT'
}
]
},
[GRAPH_ENUM.NODE.WORLD]: {
name: 'World',
inPorts: [
{
name: 'input',
type: GRAPH_ENUM.EDGE.HELLO_TO_WORLD,
textFormatter: (attributes) => `input (${attributes.foo})`
}
],
attributes: [
{
name: 'foo',
type: 'TEXT_INPUT',
hidden: true
}
]
}
},
edges: {
[GRAPH_ENUM.EDGE.HELLO_TO_WORLD]: {
from: GRAPH_ENUM.NODE.HELLO,
to: GRAPH_ENUM.NODE.WORLD,
}
}
};
var GRAPH_DATA = {
nodes: {
1234: {
id: 1234,
nodeType: GRAPH_ENUM.NODE.HELLO,
name: 'Hello',
posX: 200,
posY: 200,
attributes: {
foo: 'bar'
}
},
1235: {
id: 1235,
nodeType: GRAPH_ENUM.NODE.WORLD,
name: 'World',
posX: 500,
posY: 200,
attributes: {
foo: 'bar'
}
},
},
edges: {
'1234,0-1235,0': {
edgeType: GRAPH_ENUM.EDGE.HELLO_TO_WORLD,
from: 1234,
to: 1235,
inPort: 0,
outPort: 0,
}
}
};
// Template function
const Template = (args) => <Graph schema={GRAPH_SCHEMA} options={{...args}} />;
// Default story using the template
export const NodeAttributesGraphExample = Template.bind({});
// Default args for the story
NodeAttributesGraphExample.args = {
initialData: GRAPH_DATA,
passiveUIEvents: false,
includeFonts: true,
defaultStyles: {
edge: {
connectionStyle: 'smoothInOut'
},
background: {
color: '#20292B',
gridSize: 10
}
}
};
document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%');
document.body.setAttribute('style', 'margin: 0px; padding: 0px;');
setTimeout(() => {
const graph = document.querySelector('.pcui-graph').ui;
graph.on(GRAPH_ACTIONS.UPDATE_NODE_ATTRIBUTE, (data) => {
graph.updateNodeAttribute(1235, data.attribute, data.node.attributes[data.attribute]);
});
}, 500);

View File

@@ -0,0 +1,142 @@
import React from 'react';
import Graph from '../../base-component';
export default {
title: 'Basic/Styled Graph',
component: Graph,
argTypes: {
// Define the args that you want to be editable in the Storybook UI
}
};
const GRAPH_ENUM = {
NODE: {
RED: 0,
GREEN: 1,
BLUE: 2,
},
EDGE: {
RED_TO_BLUE: 0,
RED_TO_GREEN: 1,
BLUE_TO_GREEN:2,
}
};
const GRAPH_SCHEMA = {
nodes: {
[GRAPH_ENUM.NODE.RED]: {
name: 'Red',
fill: 'red',
stroke: 'darkRed'
},
[GRAPH_ENUM.NODE.GREEN]: {
name: 'Green',
fill: 'green',
stroke: 'darkGreen'
},
[GRAPH_ENUM.NODE.BLUE]: {
name: 'Blue',
fill: 'blue',
stroke: 'darkBlue'
}
},
edges: {
[GRAPH_ENUM.EDGE.RED_TO_BLUE]: {
from: [
GRAPH_ENUM.NODE.RED,
],
to: [
GRAPH_ENUM.NODE.BLUE,
],
stroke: 'magenta'
},
[GRAPH_ENUM.EDGE.RED_TO_GREEN]: {
from: [
GRAPH_ENUM.NODE.RED,
],
to: [
GRAPH_ENUM.NODE.GREEN,
],
stroke: 'yellow'
},
[GRAPH_ENUM.EDGE.BLUE_TO_GREEN]: {
from: [
GRAPH_ENUM.NODE.BLUE,
],
to: [
GRAPH_ENUM.NODE.GREEN,
],
stroke: 'cyan'
}
}
};
var GRAPH_DATA = {
nodes: {
1234: {
id: 1234,
nodeType: GRAPH_ENUM.NODE.RED,
name: 'Red',
posX: 100,
posY: 100,
},
1235: {
id: 1235,
nodeType: GRAPH_ENUM.NODE.GREEN,
name: 'Green',
posX: 100,
posY: 300
},
1236: {
id: 1236,
nodeType: GRAPH_ENUM.NODE.BLUE,
name: 'Blue',
posX: 300,
posY: 200
},
},
edges: {
'1234-1236': {
edgeType: GRAPH_ENUM.EDGE.RED_TO_BLUE,
from: 1234,
to: 1236
},
'1234-1235': {
edgeType: GRAPH_ENUM.EDGE.RED_TO_GREEN,
from: 1234,
to: 1235
},
'1236-1235': {
edgeType: GRAPH_ENUM.EDGE.BLUE_TO_GREEN,
from: 1236,
to: 1235
}
}
};
// Template function
const Template = (args) => <Graph schema={GRAPH_SCHEMA} options={{...args}} />;
// Default story using the template
export const StyledGraphExample = Template.bind({});
// Default args for the story
StyledGraphExample.args = {
initialData: GRAPH_DATA,
passiveUIEvents: false,
includeFonts: true,
defaultStyles: {
background: {
color: 'white',
gridSize: 1
},
edge: {
connectionStyle: 'default',
targetMarker: false
}
}
};
document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%');
document.body.setAttribute('style', 'margin: 0px; padding: 0px;');

View File

@@ -0,0 +1,102 @@
import React from 'react';
import Graph from '../../base-component';
export default {
title: 'Basic/Visual Programming Graph',
component: Graph,
argTypes: {
// Define the args that you want to be editable in the Storybook UI
}
};
const GRAPH_ENUM = {
NODE: {
HELLO: 0,
WORLD: 1,
},
EDGE: {
HELLO_TO_WORLD: 0,
}
};
const GRAPH_SCHEMA = {
nodes: {
[GRAPH_ENUM.NODE.HELLO]: {
name: 'Hello',
outPorts: [
{
name: 'output',
type: GRAPH_ENUM.EDGE.HELLO_TO_WORLD
}
]
},
[GRAPH_ENUM.NODE.WORLD]: {
name: 'World',
inPorts: [
{
name: 'input',
type: GRAPH_ENUM.EDGE.HELLO_TO_WORLD
}
]
}
},
edges: {
[GRAPH_ENUM.EDGE.HELLO_TO_WORLD]: {
from: GRAPH_ENUM.NODE.HELLO,
to: GRAPH_ENUM.NODE.WORLD,
}
}
};
var GRAPH_DATA = {
nodes: {
1234: {
id: 1234,
nodeType: GRAPH_ENUM.NODE.HELLO,
name: 'Hello',
posX: 200,
posY: 200
},
1235: {
id: 1235,
nodeType: GRAPH_ENUM.NODE.WORLD,
name: 'World',
posX: 500,
posY: 200
},
},
edges: {
'1234,0-1235,0': {
edgeType: GRAPH_ENUM.EDGE.HELLO_TO_WORLD,
from: 1234,
to: 1235,
inPort: 0,
outPort: 0,
}
}
};
// Template function
const Template = (args) => <Graph schema={GRAPH_SCHEMA} options={{...args}} />;
// Default story using the template
export const VisualProgrammingGraphExample = Template.bind({});
// Default args for the story
VisualProgrammingGraphExample.args = {
initialData: GRAPH_DATA,
passiveUIEvents: false,
includeFonts: true,
defaultStyles: {
edge: {
connectionStyle: 'smoothInOut'
},
background: {
color: '#20292B',
gridSize: 10
}
}
};
document.getElementById('storybook-root').setAttribute('style', 'position: fixed; width: 100%; height: 100%');
document.body.setAttribute('style', 'margin: 0px; padding: 0px;');

View File

@@ -0,0 +1,17 @@
{
"extends": "stylelint-config-standard-scss",
"rules": {
"font-family-no-missing-generic-family-keyword": [
true,
{
"ignoreFontFamilies": [
"pc-icon"
]
}
],
"no-descending-specificity": null,
"no-duplicate-selectors": null,
"scss/no-global-function-names": null,
"scss/at-extend-no-missing-placeholder": null
}
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2011-2025 PlayCanvas Ltd.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,68 @@
# PCUI Graph - Node-based Graphs for PCUI
[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/playcanvas/pcui-graph/blob/main/LICENSE)
[![NPM Version](https://img.shields.io/npm/v/@playcanvas/pcui-graph.svg?style=flat?style=flat)](https://www.npmjs.com/package/@playcanvas/pcui-graph)
[![NPM Downloads](https://img.shields.io/npm/dw/@playcanvas/pcui-graph)](https://npmtrends.com/@playcanvas/pcui=gra[j)
![PCUI Graph](./docs/assets/pcui-graph-banner.png)
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.
## Getting Started
First install PCUI Graph into your npm project:
npm install @playcanvas/pcui-graph --save-dev
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:
```javascript
import Graph from '@playcanvas/pcui-graph';
import '@playcanvas/pcui/styles';
import '@playcanvas/pcui-graph/styles';
const schema = {
nodes: {
0: {
name: 'Hello',
fill: 'red'
},
1: {
name: 'World',
fill: 'green'
}
},
edges: {
0: {
from: [0], // this edge can connect nodes of type 0
to: [1], // to nodes of type 1,
stroke: 'blue'
}
}
}
const graph = new Graph(schema);
document.body.appendChild(graph.dom);
```
The library is also available on [npm](https://www.npmjs.com/package/@playcanvas/pcui-graph) and can be installed in your project with:
npm install --save @playcanvas/pcui-graph @playcanvas/pcui @playcanvas/observer
The npm package includes two builds of the library:
@playcanvas/pcui-graph/dist/pcui-graph.js // UMD build (requires that the pcui and observer libraries are present in the global namespace)
@playcanvas/pcui-graph/dist/pcui-graph.mjs // module build (requires a build tool like rollup / webpack)
## Storybook
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:
npm install
npm run storybook
This will automatically open the storybook in a new browser tab.
# Documentation
Information on building the documentation can be found in the [docs](./docs/README.md) directory.

View File

@@ -0,0 +1,5 @@
_site
.sass-cache
.jekyll-cache
.jekyll-metadata
vendor

View File

@@ -0,0 +1,31 @@
source "https://rubygems.org"
# Hello! This is where you manage which Jekyll version is used to run.
# When you want to use a different version, change it below, save the
# file and run `bundle install`. Run Jekyll with `bundle exec`, like so:
#
# bundle exec jekyll serve
#
# This will help ensure the proper Jekyll version is running.
# Happy Jekylling!
gem "jekyll", "~> 4.4.0"
gem "just-the-docs"
# This is the default theme for new Jekyll sites. You may change this to anything you like.
gem "minima", "~> 2.5"
# If you want to use GitHub Pages, remove the "gem "jekyll"" above and
# uncomment the line below. To upgrade, run `bundle update github-pages`.
# gem "github-pages", group: :jekyll_plugins
# If you have any plugins, put them here!
group :jekyll_plugins do
gem "jekyll-feed", "~> 0.12"
end
# Windows and JRuby does not include zoneinfo files, so bundle the tzinfo-data gem
# and associated library.
platforms :mingw, :x64_mingw, :mswin, :jruby do
gem "tzinfo", "~> 2.0"
gem "tzinfo-data"
end
# Performance-booster for watching directories on Windows
gem "wdm", "~> 0.2.0", :platforms => [:mingw, :x64_mingw, :mswin]

View File

@@ -0,0 +1,104 @@
GEM
remote: https://rubygems.org/
specs:
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
base64 (0.2.0)
colorator (1.1.0)
concurrent-ruby (1.3.5)
csv (3.3.2)
em-websocket (0.5.3)
eventmachine (>= 0.12.9)
http_parser.rb (~> 0)
eventmachine (1.2.7)
ffi (1.17.1-x64-mingw-ucrt)
ffi (1.17.1-x86_64-linux-gnu)
forwardable-extended (2.6.0)
http_parser.rb (0.8.0)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
jekyll (4.4.1)
addressable (~> 2.4)
base64 (~> 0.2)
colorator (~> 1.0)
csv (~> 3.0)
em-websocket (~> 0.5)
i18n (~> 1.0)
jekyll-sass-converter (>= 2.0, < 4.0)
jekyll-watch (~> 2.0)
json (~> 2.6)
kramdown (~> 2.3, >= 2.3.1)
kramdown-parser-gfm (~> 1.0)
liquid (~> 4.0)
mercenary (~> 0.3, >= 0.3.6)
pathutil (~> 0.9)
rouge (>= 3.0, < 5.0)
safe_yaml (~> 1.0)
terminal-table (>= 1.8, < 4.0)
webrick (~> 1.7)
jekyll-feed (0.17.0)
jekyll (>= 3.7, < 5.0)
jekyll-include-cache (0.2.1)
jekyll (>= 3.7, < 5.0)
jekyll-sass-converter (2.2.0)
sassc (> 2.0.1, < 3.0)
jekyll-seo-tag (2.8.0)
jekyll (>= 3.8, < 5.0)
jekyll-watch (2.2.1)
listen (~> 3.0)
json (2.10.1)
just-the-docs (0.10.1)
jekyll (>= 3.8.5)
jekyll-include-cache
jekyll-seo-tag (>= 2.0)
rake (>= 12.3.1)
kramdown (2.5.1)
rexml (>= 3.3.9)
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
liquid (4.0.4)
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
mercenary (0.4.0)
minima (2.5.2)
jekyll (>= 3.5, < 5.0)
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
pathutil (0.16.2)
forwardable-extended (~> 2.6)
public_suffix (5.1.1)
rake (13.2.1)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
rexml (3.4.0)
rouge (3.30.0)
safe_yaml (1.0.5)
sassc (2.4.0)
ffi (~> 1.9)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2025.1)
tzinfo (>= 1.0.0)
unicode-display_width (1.8.0)
wdm (0.2.0)
webrick (1.9.1)
PLATFORMS
x64-mingw-ucrt
x86_64-linux
DEPENDENCIES
jekyll (~> 4.4.0)
jekyll-feed (~> 0.12)
just-the-docs
minima (~> 2.5)
tzinfo (~> 2.0)
tzinfo-data
wdm (~> 0.2.0)
BUNDLED WITH
2.4.14

View File

@@ -0,0 +1,31 @@
# PCUI-Graph Docs Guide
The PCUI-Graph documentation website is built using a Jekyll template. The markdown pages for the site can be found and edited in the `docs/pages` directory.
The docs site also makes use of Storybook to display React components. If you are developing the PCUI library, you should use `npm run storybook` directly to generate the storybook. The following guide is for updating and publishing the documentation site.
### Developing Docs Locally
Ensure you have Ruby 3.x installed. Go [here](https://rubyinstaller.org/downloads/) for a Windows installer.
To install the Ruby dependencies, run:
cd docs
bundle install
cd ..
If you are having trouble with the install, try deleting the `Gemfile.lock` file.
You are now able to build the site:
npm run build:docsite:local
To view the built site, run:
npm run serve:docs
Open your browser and visit: http://localhost:3000/
### Publishing Docs
The PCUI-Graph docs site is automatically redeployed one every commit to the `main` branch.

View File

@@ -0,0 +1,60 @@
# Welcome to Jekyll!
#
# This config file is meant for settings that affect your whole blog, values
# which you are expected to set up once and rarely edit after that. If you find
# yourself editing this file very often, consider using Jekyll's data files
# feature for the data you need to update frequently.
#
# For technical reasons, this file is *NOT* reloaded automatically when you use
# 'bundle exec jekyll serve'. If you change this file, please restart the server process.
#
# If you need help with YAML syntax, here are some quick references for you:
# https://learn-the-web.algonquindesign.ca/topics/markdown-yaml-cheat-sheet/#yaml
# https://learnxinyminutes.com/docs/yaml/
#
# Site settings
# These are used to personalize your new site. If you look in the HTML files,
# you will see them accessed via {{ site.title }}, {{ site.email }}, and so on.
# You can create any custom variable you would like, and they will be accessible
# in the templates via {{ site.myvariable }}.
title: PCUI-Graph
email: support@playcanvas.com
description: >- # this means to ignore newlines until "baseurl:"
PCUI-Graph - PCUI extension for node-based graphs
baseurl: "/pcui-graph" # the subpath of your site, e.g. /blog
url: "https://playcanvas.github.io" # the base hostname & protocol for your site, e.g. http://example.com
twitter_username: playcanvas
github_username: playcanvas
# Build settings
theme: "just-the-docs"
color_scheme: pcui
plugins:
- jekyll-feed
aux_links:
"PCUI-Graph on GitHub":
- "//github.com/playcanvas/pcui-graph"
# Exclude from processing.
# The following items will not be processed, by default.
# Any item listed under the `exclude:` key here will be automatically added to
# the internal "default list".
#
# Excluded items can be processed by explicitly listing the directories or
# their entries' file path in the `include:` list.
#
exclude:
- .sass-cache/
- .jekyll-cache/
- gemfiles/
- Gemfile
- Gemfile.lock
- node_modules/
- vendor/bundle/
- vendor/cache/
- vendor/gems/
- vendor/ruby/
- create-component-pages.js
- README.md

View File

@@ -0,0 +1,60 @@
# Welcome to Jekyll!
#
# This config file is meant for settings that affect your whole blog, values
# which you are expected to set up once and rarely edit after that. If you find
# yourself editing this file very often, consider using Jekyll's data files
# feature for the data you need to update frequently.
#
# For technical reasons, this file is *NOT* reloaded automatically when you use
# 'bundle exec jekyll serve'. If you change this file, please restart the server process.
#
# If you need help with YAML syntax, here are some quick references for you:
# https://learn-the-web.algonquindesign.ca/topics/markdown-yaml-cheat-sheet/#yaml
# https://learnxinyminutes.com/docs/yaml/
#
# Site settings
# These are used to personalize your new site. If you look in the HTML files,
# you will see them accessed via {{ site.title }}, {{ site.email }}, and so on.
# You can create any custom variable you would like, and they will be accessible
# in the templates via {{ site.myvariable }}.
title: PCUI-Graph
email: support@playcanvas.com
description: >- # this means to ignore newlines until "baseurl:"
PCUI-Graph - PCUI extension for node-based graphs
baseurl: "/" # the subpath of your site, e.g. /blog
url: "http://localhost:3497" # the base hostname & protocol for your site, e.g. http://example.com
twitter_username: playcanvas
github_username: playcanvas
# Build settings
theme: "just-the-docs"
color_scheme: pcui
plugins:
- jekyll-feed
aux_links:
"PCUI-Graph on GitHub":
- "//github.com/playcanvas/pcui-graph"
# Exclude from processing.
# The following items will not be processed, by default.
# Any item listed under the `exclude:` key here will be automatically added to
# the internal "default list".
#
# Excluded items can be processed by explicitly listing the directories or
# their entries' file path in the `include:` list.
#
exclude:
- .sass-cache/
- .jekyll-cache/
- gemfiles/
- Gemfile
- Gemfile.lock
- node_modules/
- vendor/bundle/
- vendor/cache/
- vendor/gems/
- vendor/ruby/
- create-component-pages.js
- README.md

View File

@@ -0,0 +1 @@
$link-color: #2aa198;

View File

@@ -0,0 +1,18 @@
html, body, .main, .main-content, .component-iframe {
height: 100% !important;
}
.component-iframe {
width: 100%;
border: none;
margin-left: -20px;
opacity: 0;
}
.main-content-wrap {
height: calc(100% - 60px) !important;
}
.example-background {
background-color: #364346 !important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

View File

@@ -0,0 +1,12 @@
---
# Feel free to add content and custom Front Matter to this file.
# To modify the layout, see https://jekyllrb.com/docs/themes/#overriding-theme-defaults
layout: home
---
# PCUI-Graph - Node-based Graphs Made Easy
![PCUI splash](assets/pcui-graph-banner.png)
PCUI-Graph is an extension of PCUI. It provides an simple API for creating node-based graphs that can be easily integrated with any PCUI application.

View File

@@ -0,0 +1,9 @@
---
layout: page
title: API
permalink: /api/
---
# API
The graph API can be used to programmatically interact with a graph you have instantiated. You can control the view of the graph using the translate / scale functions as well as create / delete / update graph nodes and edges. The full API is documented [here](https://api.playcanvas.com/modules/PCUIGraph.html).

View File

@@ -0,0 +1,18 @@
---
layout: page
title: Config Options
permalink: /config-options/
---
# Config Options
Options can be passed to the graph constructor as a JSON object which change the default behavior of the graph. You can do so as follows:
```javascript
const graph = new Graph(schema, {
readOnly: true,
initialData: { ... }
});
```
You can see a full list of options [here](https://api.playcanvas.com/classes/PCUIGraph.Graph.html#constructor).

View File

@@ -0,0 +1,70 @@
---
layout: page
title: Context Menus
permalink: /context-menus/
---
# Context Menus
It is possible to create context menus on your graph which display when right clicking various graph items. There are three types of context menus; background, node and edge. You can define a set of actions which will display in each of these menus and each action item in the menu will fire an action event when selected.
The background context menu appears when you right click on any blank space in the canvas. This context menu is used to add new nodes to the graph. It can be created by adding a `contextMenuItems` array to the options object passed to the graph constructor:
```javascript
const graph = new Graph(schema, {
contextMenuItems: [
{
{
text: 'Add a hello node',
action: GRAPH.GRAPH_ACTIONS.ADD_NODE,
nodeType: NODE_KEYS.HELLO,
attributes: {
name: 'New hello'
'Editable boolean': true
}
},
{
text: 'Add a world node',
action: GRAPH.GRAPH_ACTIONS.ADD_NODE,
nodeType: NODE_KEYS.WORLD,
attributes: {
name: 'New world'
'Editable boolean': true
}
}
}
]
})
```
The text property defines the display text of the context menu item. The action property tells the graph that this context menu item should fire an `ADD_NODE` action when it is selected. The other properties define the type of node that will be created when this item is selected. The node type references one of the node keys defined in the graphs schema. The attributes object defines the initial values of any editable attributes that exist in that nodes schema. The name attribute will also show up in the header for the node.
Context menus can also be added to nodes and edges by including contextMenu properties in their schemas as follows:
```javascript
const schema = {
edges: {
0: {
contextMenuItems: [
{
text: 'Delete edge', // name of the context menu item
action: Graph.GRAPH_ACTIONS.DELETE_EDGE // action to carry out when item is selected
}
]
}
}
};
```
Currently node context menus support two actions:
``` javascript
Graph.GRAPH_ACTIONS.DELTE_NODE // Delete the node associated with this context menu.
Graph.GRAPH_ACTIONS.ADD_EDGE // Add an edge that starts from the node associated with this context menu, selecting another node will complete the edge connection. Selecting the background canvas will cancel adding an edge.
```
While edges support their own deletion by adding this action in their context menu:
``` javascript
Graph.GRAPH_ACTIONS.DELETE_EDGE // Delete the edge associated with this context menu.
```

View File

@@ -0,0 +1,92 @@
---
layout: page
title: Events
permalink: /events/
---
# Events
After creating a graph, you can register a callback for various events. This is achieved using the graphs [on function](https://api.playcanvas.com/classes/PCUIGraph.Graph.html#on). The following events are supported:
```javascript
import Graph from '@playcanvas/pcui-graph';
const schema = { ... };
const graph = new Graph(schema);
/*
* @event
* @param {object} args.node - The node that was added
*/
graph.on(Graph.GRAPH_ACTIONS.ADD_NODE, ({ node }) => { ... });
/*
* @event
* @param {object} args.node - The node that was deleted
* @param {object} args.edgeData - The edges contained in the graph
* @param {object} args.edges - The edges that were removed when deleting this node
*/
graph.on(Graph.GRAPH_ACTIONS.DELETE_NODE, ({ node, edgeData, edges }) => { ... });
/*
* @event
* @param {object} args.node - The node that was selected
* @param {object} args.prevItem - The previously selected item, either a node or an edge
*/
graph.on(Graph.GRAPH_ACTIONS.SELECT_NODE, ({ node, prevItem }) => { ... });
/*
* @event
* @param {object} args.node - The node that was updated
* @param {object} args.nodeId - The node id of the node that was updated
*/
graph.on(Graph.GRAPH_ACTIONS.UPDATE_NODE_POSITION, ({ node, nodeId }) => { ... });
/*
* @event
* @param {object} args.node - The node that was updated
* @param {object} args.attribute - The name of the attribute that was updated
* @param {object} args.attributeKey - The key of the attribute that was updated
*/
graph.on(Graph.GRAPH_ACTIONS.UPDATE_NODE_ATTRIBUTE, ({ node, attribute, attributeKey }) => { ... });
/*
* @event
* @param {object} args.edge - The edge that was updated
* @param {object} args.edgeId - The id of the edge that was updated
*/
graph.on(Graph.GRAPH_ACTIONS.ADD_EDGE, ({ edge, edgeId }) => { ... });
/*
* @event
* @param {object} args.edge - The edge that was updated
* @param {object} args.edgeId - The id of the edge that was updated
*/
graph.on(Graph.GRAPH_ACTIONS.DELETE_EDGE, ({ edge, edgeId }) => { ... });
/*
* @event
* @param {object} args.edge - The edge that was selected
* @param {object} args.prevItem - The previously selected item, either a node or an edge
*/
graph.on(Graph.GRAPH_ACTIONS.SELECT_EDGE, ({ edge, prevItem }) => { ... });
/*
* @event
* @param {object} args.prevItem - The previously selected item, either a node or an edge
*/
graph.on(Graph.GRAPH_ACTIONS.DESELECT_ITEM, ({ prevItem }) => { ... });
/*
* @event
* @param {number} args.pos.x - The x position of the viewport in relation to the graph
* @param {number} args.pos.y - The y position of the viewport in relation to the graph
*/
graph.on(Graph.GRAPH_ACTIONS.UPDATE_TRANSLATE, ({ pos }) => { ... });
/*
* @event
* param {number} args.scale - The current scale of the graph
*/
graph.on(Graph.GRAPH_ACTIONS.UPDATE_SCALE, ({ scale }) => { ... });
```

View File

@@ -0,0 +1,134 @@
---
layout: page
title: Schema
permalink: /schema/
---
# Schema
The schema object is used to define what type of graph you will be initializing. More specifically, it defines which node your graph can contain and how those nodes can be connected together with edges.
It should contain a set of nodes and edges which can be created in the graph. Each node and edge that is defined will need a unique number key which is used to reference that particular part of the schema. In the above example the single edge type defined references the two nodes contained in the schema when defining which node types it can connect. When creating large schemas, it can be useful to define these keys before creating the schema, so they can be easily referenced:
```javascript
const NODE_KEYS = {
HELLO: 0,
WORLD: 1
};
const EDGE_KEYS = {
HELLO_TO_WORLD: 0
};
const schema = {
nodes: {
[NODE_KEYS.HELLO]: {
name: 'Hello',
fill: 'red'
},
[NODE_KEYS.WORLD]: {
name: 'World',
fill: 'green'
}
},
edges: {
[EDGE_KEYS.HELLO_TO_WORLD]: {
from: [NODE_KEYS.HELLO], // this edge can connect nodes of type NODE_KEYS.HELLO
to: [NODE_KEYS.WORLD] // to nodes of type NODE_KEYS.WORLD,
stroke: 'blue'
}
}
};
```
The schemas above are used to created directed graphs, as they define edges which contain `from` and `to` attributes. These attributes tell an edge which nodes they can connect, creating a directed edge from one node to another.
When creating visual programming graphs, nodes are not connected directly. Instead, they contain input and output ports which can be connected together. This will need to be expressed in the schema you create. To achieve this, you can add `inPorts` and `outPorts` attributes to your nodes in the schema. These will define a set of ports which will be created on a given node, specifying which edges can connect those ports.
The schema defined above can be reworked to support port connections as follows:
```javascript
const NODE_KEYS = {
HELLO: 0,
WORLD: 1
};
const EDGE_KEYS = {
HELLO_TO_WORLD: 0
};
const schema = {
nodes: {
[NODE_KEYS.HELLO]: {
name: 'Hello',
fill: 'red',
outPorts: [
{
name: 'output',
type: EDGE_KEYS.HELLO_TO_WORLD
}
]
},
[NODE_KEYS.WORLD]: {
name: 'World',
fill: 'green',
inPorts: [
{
name: 'input',
type: EDGE_KEYS.HELLO_TO_WORLD
}
]
}
},
edges: {
[EDGE_KEYS.HELLO_TO_WORLD]: {
stroke: 'blue'
}
}
};
```
You can see that created ports have a type which defines the edge type each port accepts. Only input and output ports of the same type can be connected together in the graph. Ports also contain a name which will appear next to the port in the graph.
Nodes can also contain editable attributes, which will show up as input fields within them. These attributes can be set in a node as follows:
```javascript
const schema = {
nodes: {
0: {
name: 'Foobar',
attributes: [
{
name: 'Editable boolean',
type: 'BOOLEAN_INPUT'
},
{
name: 'Editable text',
type: 'TEXT_INPUT'
},
{
name: 'Editable number',
type: 'NUMERIC_INPUT'
},
{
name: 'Editable 2D vector',
type: 'VEC2_INPUT'
},
{
name: 'Editable 3D vector',
type: 'VEC3_INPUT'
},
{
name: 'Editable 4D vector',
type: 'VEC4_INPUT'
}
]
}
}
};
```
Editable attributes for a given node type must have unique names as they are stored in the graph data in a dictionary. When a node with an editable attribute is created, it can be accessed via the graph data as follows:
```javascript
const selectedItemId = graph.selectedItem.id;
const currentBooleanValue = graph.data.nodes[selectedItemId].attributes['Editable boolean'].value;
```

View File

@@ -0,0 +1,11 @@
---
layout: page
title: Storybook
permalink: /storybook-docs/
---
# Storybook
The storybook showcases PCUI-Graph's Graph component in a number of different contexts.
[Click here](../storybook/) to view the storybook.

View File

@@ -0,0 +1,45 @@
---
layout: page
title: Styling
permalink: /styling/
---
# Styling
You can style your graph by overriding it's default style properties. This can be achieved by modifying the defaultStyles passed in as part of an options object to the graph constructor.
```javascript
const graph = new Graph(schema, {
defaultStyles: {
background: {
color: 'black'
}
}
})
```
The defaultStyles object contains styling options for the graphs background as well as node and edge styles. A full list of these overridable properties can be see [here](https://github.com/playcanvas/pcui-graph/blob/main/src/constants.js).
If you'd like to update the styling of particular node / edge types, you can override any of the node or edge properties given in the defaultStyles object by defining them in the schema for a particular node or edge as follows:
```javascript
const schema = {
nodes: {
0: {
name: 'standard node'
},
1: {
name: 'red node'
fill: 'red' // Update the background color of any nodes of this type to red
},
}
};
const graph = new Graph(schema, {
defaultStyles: {
node: {
fill: 'grey' // All other node types will have a grey background
}
}
})
```

View File

@@ -0,0 +1,23 @@
import playcanvasConfig from '@playcanvas/eslint-config';
import babelParser from '@babel/eslint-parser';
import globals from 'globals';
export default [
...playcanvasConfig,
{
files: ['**/*.js', '**/*.mjs'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parser: babelParser,
parserOptions: {
requireConfigFile: false
},
globals: {
...globals.browser,
...globals.mocha,
...globals.node
}
}
}
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
{
"name": "@playcanvas/pcui-graph",
"version": "4.0.1",
"author": "PlayCanvas <support@playcanvas.com>",
"homepage": "https://github.com/playcanvas/pcui-graph",
"description": "A PCUI plugin for creating node-based graphs",
"keywords": [
"components",
"css",
"dom",
"graph",
"html",
"javascript",
"nodes",
"pcui",
"playcanvas",
"react",
"sass",
"typescript",
"ui"
],
"license": "MIT",
"main": "dist/pcui-graph.js",
"module": "dist/pcui-graph.mjs",
"types": "types/index.d.ts",
"type": "module",
"bugs": {
"url": "https://github.com/playcanvas/pcui-graph/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/playcanvas/pcui-graph.git"
},
"scripts": {
"build": "cross-env NODE_ENV=production rollup -c --environment target:all && npm run bundle:styles",
"build:docs": "typedoc",
"build:docsite:local": "cd docs && bundle exec jekyll build --config _config_local.yml && cd .. && npm run build:storybook",
"build:docsite:production": "cd docs && bundle exec jekyll build --config _config.yml && cd .. && npm run build:storybook",
"build:storybook": "cross-env ENVIRONMENT=production storybook build -o ./docs/_site/storybook",
"build:types": "tsc --project ./tsconfig.json --declaration --emitDeclarationOnly --outDir types",
"bundle:styles": "scss-bundle -e ./src/styles/style.scss -o ./dist/pcui-graph.scss",
"lint": "eslint src",
"lint:package": "publint",
"lint:styles": "stylelint src/styles/style.scss",
"serve:docs": "serve docs/_site",
"storybook": "storybook dev",
"watch": "rollup -c --environment target:all --watch",
"watch:umd": "rollup -c --environment target:umd --watch",
"watch:module": "rollup -c --environment target:module --watch"
},
"files": [
"dist/pcui-graph.js",
"dist/pcui-graph.mjs",
"dist/pcui-graph.scss",
"package.json",
"README.md",
"LICENSE",
"styles",
"types"
],
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@babel/core": "7.26.8",
"@babel/preset-env": "7.26.8",
"@babel/preset-react": "7.26.3",
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
"@playcanvas/eslint-config": "2.0.9",
"@playcanvas/observer": "1.4.0",
"@playcanvas/pcui": "4.1.2",
"@rollup/plugin-alias": "5.1.1",
"@rollup/plugin-babel": "6.0.4",
"@rollup/plugin-commonjs": "28.0.2",
"@rollup/plugin-node-resolve": "16.0.0",
"@rollup/plugin-terser": "0.4.4",
"@storybook/addon-actions": "^8.5.4",
"@storybook/addon-backgrounds": "^8.5.4",
"@storybook/addon-controls": "^8.5.4",
"@storybook/addon-docs": "^8.5.4",
"@storybook/addon-links": "^8.5.4",
"@storybook/preset-create-react-app": "^8.5.4",
"@storybook/react": "^8.5.4",
"@storybook/react-webpack5": "^8.5.4",
"@storybook/test": "8.5.4",
"@types/react": "19.0.8",
"babel-loader": "9.2.1",
"backbone": "1.6.0",
"cross-env": "7.0.3",
"eslint": "9.20.1",
"globals": "15.14.0",
"jointjs": "3.7.7",
"jquery": "3.7.1",
"lodash": "4.17.21",
"prop-types": "15.8.1",
"publint": "0.3.4",
"rollup": "4.34.6",
"rollup-plugin-jscc": "2.0.0",
"rollup-plugin-node-builtins": "2.1.2",
"rollup-plugin-node-globals": "1.4.0",
"rollup-plugin-postcss": "4.0.2",
"sass-loader": "14.0.0",
"scss-bundle": "3.1.2",
"serve": "14.2.4",
"storybook": "^8.5.4",
"stylelint": "16.14.1",
"stylelint-config-standard-scss": "14.0.0",
"typedoc": "0.25.8",
"typedoc-plugin-mdn-links": "3.1.16",
"typedoc-plugin-rename-defaults": "0.7.0",
"typescript": "4.9.5"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0"
},
"directories": {
"doc": "docs"
}
}

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"packageRules": [
{
"matchManagers": [
"npm"
],
"groupName": "all npm dependencies",
"schedule": [
"on monday at 10:00am"
]
},
{
"matchDepTypes": ["devDependencies"],
"rangeStrategy": "pin"
},
{
"matchDepTypes": ["dependencies"],
"rangeStrategy": "widen"
}
]
}

View File

@@ -0,0 +1,107 @@
import path from 'path';
// 1st party plugins
import alias from '@rollup/plugin-alias';
import { babel } from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
// 3rd party plugins
import jscc from 'rollup-plugin-jscc';
import builtins from 'rollup-plugin-node-builtins';
import globals from 'rollup-plugin-node-globals';
import postcss from 'rollup-plugin-postcss';
const PCUI_DIR = process.env.PCUI_PATH || 'node_modules/@playcanvas/pcui';
const PCUI_PATH = path.resolve(PCUI_DIR, 'react');
// define supported module overrides
const aliasEntries = {
'pcui': PCUI_PATH
};
const umd = {
input: 'src/index.js',
output: {
file: 'dist/pcui-graph.js',
format: 'umd',
name: 'pcuiGraph',
globals: {
'@playcanvas/observer': 'observer',
'@playcanvas/pcui': 'pcui'
}
},
external: ['@playcanvas/observer', '@playcanvas/pcui'],
plugins: [
jscc({
values: { _STRIP_SCSS: process.env.STRIP_SCSS }
}),
postcss({
minimize: false,
extensions: ['.css', '.scss']
}),
alias({ entries: aliasEntries }),
commonjs({ transformMixedEsModules: true }),
globals(),
builtins(),
babel({ babelHelpers: 'bundled' }),
resolve(),
process.env.NODE_ENV === 'production' && terser()
]
};
const module = {
input: 'src/index.js',
output: {
file: 'dist/pcui-graph.mjs',
format: 'module'
},
external: ['@playcanvas/observer', '@playcanvas/pcui'],
plugins: [
jscc({
values: { _STRIP_SCSS: process.env.STRIP_SCSS }
}),
alias({ entries: aliasEntries }),
commonjs({ transformMixedEsModules: true }),
globals(),
builtins(),
babel({ babelHelpers: 'bundled' }),
postcss({
minimize: false,
extensions: ['.css', '.scss']
}),
resolve(),
process.env.NODE_ENV === 'production' && terser()
]
};
const styles = {
input: 'src/styles/index.js',
output: {
file: 'styles/dist/index.mjs',
format: 'esm'
},
plugins: [
resolve(),
postcss({
minimize: false,
extensions: ['.css', '.scss']
})
]
};
let targets;
if (process.env.target) {
switch (process.env.target.toLowerCase()) {
case "umd": targets = [umd]; break;
case "module": targets = [module]; break;
case "styles": targets = [styles]; break;
case "all": targets = [umd, module, styles]; break;
}
}
export default targets;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,60 @@
export const GRAPH_ACTIONS = {
ADD_NODE: 'EVENT_ADD_NODE',
DELETE_NODE: 'EVENT_DELETE_NODE',
SELECT_NODE: 'EVENT_SELECT_NODE',
UPDATE_NODE_POSITION: 'EVENT_UPDATE_NODE_POSITION',
UPDATE_NODE_ATTRIBUTE: 'EVENT_UPDATE_NODE_ATTRIBUTE',
ADD_EDGE: 'EVENT_ADD_EDGE',
DELETE_EDGE: 'EVENT_DELETE_EDGE',
SELECT_EDGE: 'EVENT_SELECT_EDGE',
DESELECT_ITEM: 'EVENT_DESELECT_ITEM',
UPDATE_TRANSLATE: 'EVENT_UPDATE_TRANSLATE',
UPDATE_SCALE: 'EVENT_UPDATE_SCALE'
};
export const DEFAULT_CONFIG = {
readOnly: false,
passiveUIEvents: false,
incrementNodeNames: false,
restrictTranslate: false,
edgeHoverEffect: true,
includeFonts: true,
useGlobalPCUI: false,
adjustVertices: false,
defaultStyles: {
initialScale: 1,
initialPosition: {
x: 0,
y: 0
},
background: {
color: '#20292B',
gridSize: 10
},
node: {
fill: '#2c393c',
fillSecondary: '#364346',
stroke: '#293538',
strokeSelected: '#F60',
strokeHover: 'rgba(255, 102, 0, 0.32)',
textColor: '#FFFFFF',
textColorSecondary: '#b1b8ba',
includeIcon: true,
icon: '',
iconColor: '#F60',
baseHeight: 28,
baseWidth: 226,
textAlignMiddle: false,
lineHeight: 12
},
edge: {
stroke: 'rgb(3, 121, 238)',
strokeSelected: '#F60',
strokeWidth: 2,
strokeWidthSelected: 2,
targetMarker: true,
connectionStyle: 'default'
}
}
};

View File

@@ -0,0 +1,177 @@
import { Menu } from '@playcanvas/pcui';
import * as joint from 'jointjs/dist/joint.min.js';
joint.connectors.smoothInOut = function (sourcePoint, targetPoint, vertices, args) {
const p1 = sourcePoint.clone();
p1.offset(30, 0);
const p2 = targetPoint.clone();
p2.offset(-30, 0);
const path = new joint.g.Path(joint.g.Path.createSegment('M', sourcePoint));
path.appendSegment(joint.g.Path.createSegment('C', p1, p2, targetPoint));
return path;
};
class GraphViewEdge {
constructor(graphView, paper, graph, graphSchema, edgeData, edgeSchema, onEdgeSelected) {
this._graphView = graphView;
this._config = graphView._config;
this._paper = paper;
this._graph = graph;
this._graphSchema = graphSchema;
this.edgeData = edgeData;
this._edgeSchema = edgeSchema;
this.state = GraphViewEdge.STATES.DEFAULT;
const link = GraphViewEdge.createLink(this._config.defaultStyles, edgeSchema, edgeData);
const sourceNode = this._graphView.getNode(edgeData.from);
if (edgeData && Number.isFinite(edgeData.outPort)) {
link.source({
id: sourceNode.model.id,
port: `out${edgeData.outPort}`
});
} else {
if (sourceNode.model) {
link.source(sourceNode.model);
}
}
const targetNode = this._graphView.getNode(edgeData.to);
if (edgeData && Number.isFinite(edgeData.inPort)) {
link.target({
id: targetNode.model.id,
port: `in${edgeData.inPort}`
});
} else {
link.target(targetNode.model);
}
const onCellMountedToDom = () => {
this._paper.findViewByModel(link).on('cell:pointerdown', () => {
if (this._config.readOnly) return;
onEdgeSelected(edgeData);
});
if (edgeData && Number.isFinite(edgeData.inPort)) {
this._graphView.updatePortStatesForEdge(link, true);
}
link.toBack();
};
if (this._graphView._batchingCells) {
this._graphView._cells.push(link);
this._graphView._cellMountedFunctions.push(onCellMountedToDom);
} else {
this._graph.addCell(link);
onCellMountedToDom();
}
this.model = link;
}
static createLink(defaultStyles, edgeSchema, edgeData) {
const link = new joint.shapes.standard.Link();
link.attr({
line: {
strokeWidth: edgeSchema.strokeWidth || defaultStyles.edge.strokeWidth,
stroke: edgeSchema.stroke || defaultStyles.edge.stroke
}
});
if (edgeSchema.smooth || defaultStyles.edge.connectionStyle === 'smooth') {
link.set('connector', { name: 'smooth' });
} else if (edgeSchema.smoothInOut || defaultStyles.edge.connectionStyle === 'smoothInOut') {
link.set('connector', { name: 'smoothInOut' });
}
if (edgeData && Number.isFinite(edgeData.outPort)) {
link.attr('line/targetMarker', null);
return link;
}
if (edgeSchema.targetMarker || defaultStyles.edge.targetMarker) {
link.attr('line/targetMarker', {
'type': 'path',
'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',
'stroke': edgeSchema.stroke || defaultStyles.edge.stroke,
'fill': edgeSchema.stroke || defaultStyles.edge.stroke
});
} else {
link.attr('line/targetMarker', null);
}
if (edgeSchema.sourceMarker || defaultStyles.edge.sourceMarker) {
link.attr('line/sourceMarker', {
d: 'M 6 0 a 6 6 0 1 0 0 1'
});
}
return link;
}
addContextMenu(items) {
if (this._graphView._config.readOnly) return;
this._contextMenu = new Menu({
items: items
});
this._paper.el.appendChild(this._contextMenu.dom);
const edgeElement = this._paper.findViewByModel(this.model).el;
edgeElement.addEventListener('contextmenu', (e) => {
e.preventDefault();
this._contextMenu.position(e.clientX, e.clientY);
this._contextMenu.hidden = false;
});
}
select() {
const edgeSchema = this._edgeSchema;
this.model.attr('line/stroke', edgeSchema.strokeSelected || this._config.defaultStyles.edge.strokeSelected);
this.model.attr('line/strokeWidth', edgeSchema.strokeWidthSelected || this._config.defaultStyles.edge.strokeWidthSelected);
this.model.attr('line/targetMarker', {
stroke: edgeSchema.strokeSelected || this._config.defaultStyles.edge.strokeSelected,
fill: edgeSchema.strokeSelected || this._config.defaultStyles.edge.strokeSelected
});
}
deselect() {
const edgeSchema = this._edgeSchema;
this.model.attr('line/stroke', edgeSchema.stroke || this._config.defaultStyles.edge.stroke);
this.model.attr('line/strokeWidth', edgeSchema.strokeWidth || this._config.defaultStyles.edge.strokeWidth);
this.model.attr('line/targetMarker', {
'stroke': edgeSchema.stroke || this._config.defaultStyles.edge.stroke,
'fill': edgeSchema.stroke || this._config.defaultStyles.edge.stroke
});
this.state = GraphViewEdge.STATES.DEFAULT;
}
mute() {
const edgeSchema = this._edgeSchema;
this.model.attr('line/stroke', '#42495B');
this.model.attr('line/strokeWidth', edgeSchema.strokeWidth || this._config.defaultStyles.edge.stroke);
this.model.attr('line/targetMarker', {
stroke: '#9BA1A3',
fill: '#9BA1A3'
});
}
addSourceMarker() {
const edgeSchema = this._edgeSchema;
this.model.attr('line/sourceMarker', {
'type': 'path',
'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',
'stroke': edgeSchema.stroke || this._config.defaultStyles.edge.stroke,
'fill': edgeSchema.stroke || this._config.defaultStyles.edge.stroke
});
}
addTargetMarker() {
const edgeSchema = this._edgeSchema;
this.model.attr('line/targetMarker', {
'type': 'path',
'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',
'stroke': edgeSchema.stroke || this._config.defaultStyles.edge.stroke,
'fill': edgeSchema.stroke || this._config.defaultStyles.edge.stroke
});
}
}
GraphViewEdge.STATES = {
DEFAULT: 0,
SELECTED: 1
};
export default GraphViewEdge;

View File

@@ -0,0 +1,517 @@
import { Menu, Container, Label, TextInput, BooleanInput, NumericInput, VectorInput } from '@playcanvas/pcui';
import * as joint from 'jointjs/dist/joint.min.js';
const Colors = {
bcgDarkest: '#20292b',
bcgDarker: '#293538',
bcgDark: '#2c393c',
bcgPrimary: '#364346',
textDarkest: '#5b7073',
textDark: '#9ba1a3',
textSecondary: '#b1b8ba',
textPrimary: '#ffffff',
textActive: '#f60'
};
class GraphViewNode {
constructor(graphView, paper, graph, graphSchema, nodeData, nodeSchema, onCreateEdge, onNodeSelected) {
this._graphView = graphView;
this._config = graphView._config;
this._paper = paper;
this._graph = graph;
this._graphSchema = graphSchema;
this.nodeData = nodeData;
this.nodeSchema = nodeSchema;
this.state = GraphViewNode.STATES.DEFAULT;
const rectHeight = this.getSchemaValue('baseHeight');
let portHeight = 0;
let attributeHeight = 0;
if (nodeSchema.inPorts) {
portHeight = (nodeSchema.inPorts.length * 25) + 10;
}
if (nodeSchema.outPorts) {
const outHeight = (nodeSchema.outPorts.length * 25) + 10;
if (outHeight > portHeight) portHeight = outHeight;
}
const visibleAttributes = nodeSchema.attributes && nodeSchema.attributes.filter(a => !a.hidden);
if (visibleAttributes && visibleAttributes.length > 0) {
attributeHeight = visibleAttributes.length * 32 + 10;
}
const rectSize = { x: this.getSchemaValue('baseWidth'), y: rectHeight + portHeight + attributeHeight };
let labelName;
const formattedText = nodeSchema.headerTextFormatter && nodeSchema.headerTextFormatter(nodeData.attributes, nodeData.id);
if (typeof formattedText === 'string') {
labelName = nodeSchema.headerTextFormatter(nodeData.attributes, nodeData.id);
} else if (nodeSchema.outPorts || nodeSchema.inPorts) {
labelName = nodeData.attributes && nodeData.attributes.name ? `${nodeData.attributes.name} (${nodeSchema.name})` : nodeSchema.name;
} else {
labelName = nodeData.attributes && nodeData.attributes.name || nodeData.name;
}
const rect = new joint.shapes.html.Element({
attrs: {
body: {
fill: this.getSchemaValue('fill'),
stroke: this.getSchemaValue('stroke'),
strokeWidth: 2,
width: rectSize.x,
height: rectSize.y
},
labelBackground: {
fill: this.getSchemaValue('fill'),
refX: 2,
refY: 2,
width: rectSize.x - 4,
height: rectHeight - 4
},
labelSeparator: {
fill: this.getSchemaValue('stroke'),
width: rectSize.x - 2,
height: this.getSchemaValue('inPorts') || this.getSchemaValue('outPorts') ? 2 : 0,
refX: 1,
refY: rectHeight - 1
},
inBackground: {
fill: this.getSchemaValue('fillSecondary'),
width: this.getSchemaValue('inPorts') ? rectSize.x / 2 - 1 : rectSize.x - 2,
height: (rectSize.y - rectHeight - 2) >= 0 ? rectSize.y - rectHeight - 2 : 0,
refX: 1,
refY: rectHeight + 1
},
outBackground: {
fill: this.getSchemaValue('fill'),
width: this.getSchemaValue('inPorts') ? rectSize.x / 2 - 1 : 0,
height: (rectSize.y - rectHeight - 2) >= 0 ? rectSize.y - rectHeight - 2 : 0,
refX: rectSize.x / 2,
refY: rectHeight + 1
},
icon: this.getSchemaValue('includeIcon') ? {
text: this.getSchemaValue('icon'),
fontFamily: 'pc-icon',
fontSize: 14,
fill: this.getSchemaValue('iconColor'),
refX: 8,
refY: 8
} : undefined,
label: {
text: labelName,
fill: this.getSchemaValue('textColor'),
textAnchor: this.getSchemaValue('textAlignMiddle') ? 'middle' : 'left',
refX: !this.getSchemaValue('textAlignMiddle') ? (this.getSchemaValue('includeIcon') ? 28 : 14) : rectSize.x / 2,
refY: !this.getSchemaValue('textAlignMiddle') ? 14 : rectHeight / 2,
fontSize: 12,
fontWeight: 600,
width: rectSize.x,
height: rectHeight,
lineSpacing: 50,
lineHeight: this.getSchemaValue('lineHeight')
},
marker: nodeData.marker ? {
refX: rectSize.x - 20,
fill: this.getSchemaValue('stroke'),
d: 'M0 0 L20 0 L20 20 Z'
} : null,
texture: nodeData.texture ? {
href: nodeData.texture,
fill: 'red',
width: 95,
height: 95,
refX: 5,
refY: 65
} : null
},
ports: {
groups: {
'in': {
position: {
name: 'line',
args: {
start: { x: 0, y: rectHeight },
end: { x: 0, y: rectHeight + (25 * (nodeSchema.inPorts ? nodeSchema.inPorts.length : 0)) }
}
},
label: {
position: {
name: 'right',
args: {
y: 5
}
}
},
markup: '<circle class="port-body"></circle><circle class="port-inner-body" visibility="hidden"></circle>',
attrs: {
'.port-body': {
strokeWidth: 2,
fill: Colors.bcgDarkest,
magnet: true,
r: 5,
cy: 5,
cx: 1
},
'.port-inner-body': {
strokeWidth: 2,
stroke: this._config.defaultStyles.edge.stroke,
r: 1,
cy: 5,
cx: 1
}
}
},
'out': {
position: {
name: 'line',
args: {
start: { x: rectSize.x - 10, y: rectHeight },
end: { x: rectSize.x - 10, y: rectHeight + (25 * (nodeSchema.outPorts ? nodeSchema.outPorts.length : 0)) }
}
},
label: {
position: {
name: 'left', args: { y: 5, x: -5 }
}
},
markup: '<circle class="port-body"></circle><circle class="port-inner-body" visibility="hidden"></circle>',
attrs: {
'.port-body': {
strokeWidth: 2,
fill: Colors.bcgDarkest,
magnet: true,
r: 5,
cy: 5,
cx: 9
},
'.port-inner-body': {
strokeWidth: 2,
stroke: this._config.defaultStyles.edge.stroke,
r: 1,
cy: 5,
cx: 9
}
}
}
}
}
});
rect.position(nodeData.posX, nodeData.posY);
rect.resize(rectSize.x, rectSize.y);
if (nodeSchema.inPorts) {
nodeSchema.inPorts.forEach((port, i) => {
rect.addPort({
id: `in${i}`,
group: 'in',
edgeType: port.edgeType,
markup: `<circle class="port-body" id="${nodeData.id}-in${i}" edgeType="${port.type}"></circle><circle class="port-inner-body" visibility="hidden"></circle>`,
attrs: {
'.port-body': {
stroke: this._graphSchema.edges[port.type].stroke || this._config.defaultStyles.edge.stroke
},
text: {
text: port.textFormatter ? port.textFormatter(nodeData.attributes) : port.name,
fill: this.getSchemaValue('textColorSecondary'),
'font-size': 14
}
}
});
this._graph.on('change:target', (cell) => {
if (this._suppressChangeTargetEvent) return;
let target = cell.get('target');
let source = cell.get('source');
if (!target || !source) return;
if (target && target.port && target.port.includes('out')) {
const temp = target;
target = source;
source = temp;
}
if (!target || !target.id || target.id !== this.model.id) return;
if (source && source.port && target.port && Number(target.port.replace('in', '')) === i) {
const sourceNodeId = this._graphView.getNode(source.id).nodeData.id;
const edgeId = `${sourceNodeId},${source.port.replace('out', '')}-${this.nodeData.id},${target.port.replace('in', '')}`;
const edge = {
to: this.nodeData.id,
from: sourceNodeId,
outPort: Number(source.port.replace('out', '')),
inPort: Number(target.port.replace('in', '')),
edgeType: port.type
};
this._suppressChangeTargetEvent = true;
this._graph.removeCells(cell);
this._suppressChangeTargetEvent = false;
onCreateEdge(edgeId, edge);
}
});
});
}
if (nodeSchema.outPorts) {
nodeSchema.outPorts.forEach((port, i) => rect.addPort({
id: `out${i}`,
group: 'out',
markup: `<circle class="port-body" id="${nodeData.id}-out${i}" edgeType="${port.type}"></circle><circle class="port-inner-body" visibility="hidden"></circle>`,
attrs: {
type: port.type,
'.port-body': {
stroke: this._graphSchema.edges[port.type].stroke || this._config.defaultStyles.edge.stroke
},
text: {
text: port.textFormatter ? port.textFormatter(nodeData.attributes) : port.name,
fill: this.getSchemaValue('textColorSecondary'),
'font-size': 14
}
}
}));
}
const containers = [];
if (visibleAttributes) {
visibleAttributes.forEach((attribute, i) => {
const container = new Container({ class: 'graph-node-container' });
const label = new Label({ text: attribute.name, class: 'graph-node-label' });
let input;
let nodeValue;
if (nodeData.attributes) {
if (nodeData.attributes[attribute.name] !== undefined) {
nodeValue = nodeData.attributes[attribute.name];
} else {
Object.keys(nodeData.attributes).forEach((k) => {
const a = nodeData.attributes[k];
if (a.name === attribute.name) {
nodeValue = a.defaultValue;
}
});
}
}
if (!nodeValue) {
nodeValue = nodeData[attribute.name];
}
switch (attribute.type) {
case 'TEXT_INPUT':
input = new TextInput({ class: 'graph-node-input', value: nodeValue });
break;
case 'BOOLEAN_INPUT':
input = new BooleanInput({ class: 'graph-node-input', value: nodeValue });
break;
case 'NUMERIC_INPUT':
input = new NumericInput({ class: 'graph-node-input', hideSlider: true, value: nodeValue && nodeValue.x ? nodeValue.x : nodeValue });
break;
case 'VEC_2_INPUT':
input = new VectorInput({ dimensions: 2,
class: 'graph-node-input',
hideSlider: true,
value: [
nodeValue.x,
nodeValue.y
] });
input.dom.setAttribute('style', 'margin-right: 6px;');
input.inputs.forEach(i => i._sliderControl.dom.remove());
break;
case 'VEC_3_INPUT':
input = new VectorInput({ dimensions: 3,
class: 'graph-node-input',
hideSlider: true,
value: [
nodeValue.x,
nodeValue.y,
nodeValue.z
] });
input.dom.setAttribute('style', 'margin-right: 6px;');
input.inputs.forEach(i => i._sliderControl.dom.remove());
break;
case 'VEC_4_INPUT':
input = new VectorInput({ dimensions: 4,
class: 'graph-node-input',
hideSlider: true,
value: [
nodeValue.x,
nodeValue.y,
nodeValue.z,
nodeValue.w
] });
input.dom.setAttribute('style', 'margin-right: 6px;');
input.inputs.forEach(i => i._sliderControl.dom.remove());
break;
}
input.enabled = !this._graphView._config.readOnly;
input.dom.setAttribute('id', `input_${attribute.name}`);
container.dom.setAttribute('style', `margin-top: ${i === 0 ? 33 + portHeight : 5}px; margin-bottom: 5px;`);
container.append(label);
container.append(input);
containers.push(container);
});
}
const onCellMountedToDom = () => {
const nodeDiv = document.querySelector(`#nodediv_${rect.id}`);
containers.forEach((container) => {
nodeDiv.appendChild(container.dom);
});
this._paper.findViewByModel(rect).on('element:pointerdown', () => {
if (this._hasLinked) {
this._hasLinked = false;
return;
}
onNodeSelected(this.nodeData);
});
};
if (this._graphView._batchingCells) {
this._graphView._cells.push(rect);
this._graphView._cellMountedFunctions.push(onCellMountedToDom);
} else {
this._graph.addCell(rect);
onCellMountedToDom();
}
this.model = rect;
}
getSchemaValue(item) {
return this.nodeSchema[item] !== undefined ? this.nodeSchema[item] : this._config.defaultStyles.node[item];
}
addContextMenu(items) {
if (this._graphView._config.readOnly) return;
this._contextMenu = new Menu({
items: this._graphView._parent._initializeNodeContextMenuItems(this.nodeData, items)
});
this._paper.el.appendChild(this._contextMenu.dom);
const nodeElement = this._paper.findViewByModel(this.model).el;
nodeElement.addEventListener('contextmenu', (e) => {
e.preventDefault();
this._contextMenu.position(e.clientX, e.clientY);
this._contextMenu.hidden = false;
});
}
mapVectorToArray(v) {
const arr = [];
if (Number.isFinite(v.x)) arr.push(v.x);
if (Number.isFinite(v.y)) arr.push(v.y);
if (Number.isFinite(v.z)) arr.push(v.z);
if (Number.isFinite(v.w)) arr.push(v.w);
return arr;
}
updateFormattedTextFields() {
if (this.nodeSchema.headerTextFormatter) {
const formattedText = this.nodeSchema.headerTextFormatter(this.nodeData.attributes, this.nodeData.id);
if (typeof formattedText === 'string') {
this.model.attr('label/text', formattedText);
}
}
if (this.nodeSchema.outPorts) {
this.nodeSchema.outPorts.forEach((port, i) => {
if (port.textFormatter) {
document.getElementById(`${this.nodeData.id}-out${i}`).parentElement.parentElement.querySelector('tspan').innerHTML = port.textFormatter(this.nodeData.attributes);
}
});
}
if (this.nodeSchema.inPorts) {
this.nodeSchema.inPorts.forEach((port, i) => {
if (port.textFormatter) {
document.getElementById(`${this.nodeData.id}-in${i}`).parentElement.parentElement.querySelector('tspan').innerHTML = port.textFormatter(this.nodeData.attributes);
}
});
}
}
updateAttribute(attribute, value) {
this.nodeData.attributes[attribute] = value;
const attributeElement = document.querySelector(`#nodediv_${this.model.id}`).querySelector(`#input_${attribute}`);
if (attributeElement) {
attributeElement.ui.suspendEvents = true;
if (Number.isFinite(value.x)) {
attributeElement.ui.value = this.mapVectorToArray(value);
} else {
attributeElement.ui.value = value;
}
attributeElement.ui.error = false;
attributeElement.ui.suspendEvents = false;
}
this.updateFormattedTextFields();
}
setAttributeErrorState(attribute, value) {
const attributeElement = document.querySelector(`#nodediv_${this.model.id}`).querySelector(`#input_${attribute}`);
if (attributeElement) {
attributeElement.ui.error = value;
}
}
updateNodeType(nodeType) {
this._paper.findViewByModel(this.model).el.removeEventListener('contextmenu', this._contextMenu._contextMenuEvent);
this.addContextMenu(this._graphSchema.nodes[nodeType].contextMenuItems);
}
updatePosition(pos) {
this.model.position(pos.x, pos.y);
}
addEvent(event, callback, attribute) {
const nodeView = this._paper.findViewByModel(this.model);
switch (event) {
case 'updatePosition': {
nodeView.on('element:pointerup', () => {
const newPos = this._graphView.getWindowToGraphPosition(nodeView.getBBox(), false);
callback(this.nodeData.id, newPos);
});
break;
}
case 'updateAttribute': {
const attributeElement = document.querySelector(`#nodediv_${this.model.id}`).querySelector(`#input_${attribute.name}`);
if (!attributeElement) break;
attributeElement.ui.on('change', (value) => {
if (attribute.name === 'name') {
let nameTaken = false;
Object.keys(this._graphView._graphData.get('data.nodes')).forEach((nodeKey) => {
const node = this._graphView._graphData.get('data.nodes')[nodeKey];
if (node.name === value) {
nameTaken = true;
}
});
const attributeElement = document.querySelector(`#nodediv_${this.model.id}`).querySelector(`#input_${attribute.name}`);
if (nameTaken) {
attributeElement.ui.error = true;
return;
}
attributeElement.ui.error = false;
}
callback(this.nodeData.id, attribute, value);
});
break;
}
}
}
select() {
this.model.attr('body/stroke', this.getSchemaValue('strokeSelected'));
this.state = GraphViewNode.STATES.SELECTED;
}
hover() {
if (this.state === GraphViewNode.STATES.SELECTED) return;
this.model.attr('body/stroke', this.getSchemaValue('strokeHover'));
}
hoverRemove() {
if (this.state === GraphViewNode.STATES.DEFAULT) {
this.deselect();
} else if (this.state === GraphViewNode.STATES.SELECTED) {
this.select();
}
}
deselect() {
this.model.attr('body/stroke', this.getSchemaValue('stroke'));
this.state = GraphViewNode.STATES.DEFAULT;
}
}
GraphViewNode.STATES = {
DEFAULT: 0,
SELECTED: 1
};
export default GraphViewNode;

View File

@@ -0,0 +1,447 @@
import { Menu } from '@playcanvas/pcui';
import * as joint from 'jointjs/dist/joint.min.js';
import { GRAPH_ACTIONS } from './constants.js';
import GraphViewEdge from './graph-view-edge.js';
import GraphViewNode from './graph-view-node.js';
import JointGraph from './joint-graph.js';
import { jointShapeElement, jointShapeElementView } from './joint-shape-node.js';
// TODO replace with a lighter math library
import { Vec2 } from './lib/vec2.js';
class GraphView extends JointGraph {
constructor(parent, dom, graphSchema, graphData, config) {
super(dom, config);
this._parent = parent;
this._dom = dom;
this._graphSchema = graphSchema;
this._graphData = graphData;
this._config = config;
this._nodes = {};
this._edges = {};
this._cells = [];
this._cellMountedFunctions = [];
joint.shapes.html = {};
joint.shapes.html.Element = jointShapeElement();
joint.shapes.html.ElementView = jointShapeElementView(this._paper);
this._graph.on('remove', cell => this.updatePortStatesForEdge(cell, false));
this._graph.on('change:target', cell => this.updatePortStatesForEdge(cell, true));
this._paper.on('cell:mousewheel', () => {
parent._dispatchEvent(GRAPH_ACTIONS.UPDATE_SCALE, { scale: this._paper.scale().sx });
});
this._paper.on('blank:mousewheel', () => {
parent._dispatchEvent(GRAPH_ACTIONS.UPDATE_SCALE, { scale: this._paper.scale().sx });
});
this._paper.on('blank:pointerup', (event) => {
parent._dispatchEvent(GRAPH_ACTIONS.UPDATE_TRANSLATE, { pos: { x: this._paper.translate().tx, y: this._paper.translate().ty } });
});
this._paper.on({
'blank:contextmenu': (event) => {
this._viewMenu.position(event.clientX, event.clientY);
this._viewMenu.hidden = false;
}
});
this._paper.on({
'cell:mouseenter': (cellView) => {
let selectedEdge;
let selectedEdgeId;
const node = this.getNode(cellView.model.id);
if (node && node.state !== GraphViewNode.STATES.SELECTED) {
node.hover();
selectedEdge = this._parent._selectedItem && this._parent._selectedItem._type === 'EDGE' ? this.getEdge(this._parent._selectedItem._id) : null;
if (selectedEdge) selectedEdgeId = selectedEdge.model.id;
if (this._config.edgeHoverEffect) {
Object.keys(this._edges).forEach((edgeKey) => {
const currEdge = this.getEdge(edgeKey);
if (currEdge.model.id === selectedEdgeId) return;
if (![currEdge.edgeData.from, currEdge.edgeData.to].includes(node.nodeData.id)) {
currEdge.mute();
} else {
currEdge.deselect();
}
});
}
}
const edge = this.getEdge(cellView.model.id);
if (this._config.edgeHoverEffect && edge && edge.state !== GraphViewEdge.STATES.SELECTED) {
edge.deselect();
selectedEdge = this._parent._selectedItem && this._parent._selectedItem._type === 'EDGE' ? this.getEdge(this._parent._selectedItem._id) : null;
if (selectedEdge) selectedEdgeId = selectedEdge.model.id;
Object.keys(this._edges).forEach((edgeKey) => {
const currEdge = this.getEdge(edgeKey);
if ((edge.model.id !== currEdge.model.id) && (selectedEdgeId !== currEdge.model.id)) {
currEdge.mute();
}
});
this.getNode(edge.edgeData.from).hover();
this.getNode(edge.edgeData.to).hover();
}
},
'cell:mouseleave': (cellView, e) => {
let selectedEdge;
if (e.relatedTarget && e.relatedTarget.classList.contains('graph-node-input')) return;
const node = this.getNode(cellView.model.id);
if (node && node.state !== GraphViewNode.STATES.SELECTED) {
selectedEdge = this._parent._selectedItem && this._parent._selectedItem._type === 'EDGE' ? this.getEdge(this._parent._selectedItem._id) : null;
if (!selectedEdge || ![selectedEdge.edgeData.from, selectedEdge.edgeData.to].includes(node.nodeData.id)) {
node.hoverRemove();
}
if (this._config.edgeHoverEffect) {
Object.keys(this._edges).forEach((edgeKey) => {
const currEdge = this.getEdge(edgeKey);
if (selectedEdge && currEdge.model.id === selectedEdge.model.id) return;
currEdge.deselect();
});
}
}
const edge = this.getEdge(cellView.model.id);
if (this._config.edgeHoverEffect && edge && edge.state !== GraphViewEdge.STATES.SELECTED) {
Object.keys(this._edges).forEach((edgeKey) => {
const currEdge = this.getEdge(edgeKey);
if (currEdge.state === GraphViewEdge.STATES.SELECTED) {
currEdge.select();
} else if (currEdge.state === GraphViewEdge.STATES.DEFAULT) {
if (this._parent._selectedItem && this._parent._selectedItem._type === 'EDGE') {
currEdge.mute();
} else {
currEdge.deselect();
}
}
});
selectedEdge = this._parent._selectedItem && this._parent._selectedItem._type === 'EDGE' ? this.getEdge(this._parent._selectedItem._id) : null;
if (!selectedEdge || ![selectedEdge.edgeData.from, selectedEdge.edgeData.to].includes(edge.edgeData.from)) {
this.getNode(edge.edgeData.from).hoverRemove();
}
if (!selectedEdge || ![selectedEdge.edgeData.from, selectedEdge.edgeData.to].includes(edge.edgeData.to)) {
this.getNode(edge.edgeData.to).hoverRemove();
}
}
}
});
}
batchCells() {
this._batchingCells = true;
}
isBatchingCells() {
return this._batchingCells;
}
addCellMountedFunction(f) {
this._cellMountedFunctions.push(f);
}
applyBatchedCells() {
this._batchingCells = false;
this._graph.addCells(this._cells);
this._cellMountedFunctions.forEach(f => f());
this._cells = [];
this._cellMountedFunctions = [];
}
updatePortStatesForEdge(cell, connected) {
const source = cell.get('source');
const target = cell.get('target');
if (source && source.port && target && target.port) {
this._paper.findViewByModel(source.id)._portElementsCache[source.port].portContentElement.children()[1].attr('visibility', connected ? 'visible' : 'hidden');
this._paper.findViewByModel(target.id)._portElementsCache[target.port].portContentElement.children()[1].attr('visibility', connected ? 'visible' : 'hidden');
}
}
getWindowToGraphPosition(pos, usePaperPosition = true) {
const scale = this._paper.scale().sx;
const translate = this._paper.translate();
if (usePaperPosition) {
const paperPosition = this._paper.el.getBoundingClientRect();
pos.x -= paperPosition.x;
pos.y -= paperPosition.y;
}
return new Vec2(
(-translate.tx / scale) + (pos.x / scale),
(-translate.ty / scale) + (pos.y / scale)
);
}
addCanvasContextMenu(items) {
this._viewMenu = new Menu({
items: items
});
this._paper.el.appendChild(this._viewMenu.dom);
return this._viewMenu._contextMenuEvent;
}
addNodeContextMenu(id, items) {
const addNodeContextMenuFunction = () => {
const node = this.getNode(id);
node.addContextMenu(items);
};
if (this._batchingCells) {
this._cellMountedFunctions.push(addNodeContextMenuFunction);
} else {
addNodeContextMenuFunction();
}
}
addEdgeContextMenu(id, items) {
const edge = this.getEdge(id);
edge.addContextMenu(items);
}
getNode(id) {
return this._nodes[id];
}
addNode(nodeData, nodeSchema, onCreateEdge, onNodeSelected) {
const node = new GraphViewNode(
this,
this._paper,
this._graph,
this._graphSchema,
nodeData,
nodeSchema,
onCreateEdge,
onNodeSelected
);
this._nodes[nodeData.id] = node;
this._nodes[node.model.id] = node;
return node.nodeData;
}
removeNode(modelId) {
const node = this.getNode(modelId);
this._graph.removeCells(node.model);
delete this._nodes[node.nodeData.id];
delete this._nodes[modelId];
}
updateNodeAttribute(id, attribute, value) {
this.getNode(id).updateAttribute(attribute, value);
}
setNodeAttributeErrorState(id, attribute, value) {
this.getNode(id).setAttributeErrorState(attribute, value);
}
updateNodePosition(id, pos) {
this.getNode(id).updatePosition(pos);
}
updateNodeType(id, nodeType) {
this.getNode(id).updateNodeType(nodeType);
}
addNodeEvent(id, event, callback, attribute) {
const addNodeEventFunction = () => {
const node = this.getNode(id);
node.addEvent(event, callback, attribute);
};
if (this._batchingCells) {
this._cellMountedFunctions.push(addNodeEventFunction);
} else {
addNodeEventFunction();
}
}
getEdge(id) {
if (this._edges[id]) {
return this._edges[id];
}
}
addEdge(edgeData, edgeSchema, onEdgeSelected) {
let edge;
if (Number.isFinite(edgeData.outPort)) {
edge = this.getEdge(`${edgeData.from},${edgeData.outPort}-${edgeData.to},${edgeData.inPort}`);
} else {
edge = this.getEdge(`${edgeData.from}-${edgeData.to}`);
}
if (edge) {
if (edgeData.to === edge.edgeData.to) {
if (!edgeData.outPort) {
edge.addTargetMarker();
}
} else {
if (!edgeData.inPort) {
edge.addSourceMarker();
}
}
} else {
edge = new GraphViewEdge(
this,
this._paper,
this._graph,
this._graphSchema,
edgeData,
edgeSchema,
onEdgeSelected
);
if (Number.isFinite(edgeData.outPort)) {
this._edges[`${edgeData.from},${edgeData.outPort}-${edgeData.to},${edgeData.inPort}`] = edge;
} else {
this._edges[`${edgeData.from}-${edgeData.to}`] = edge;
}
this._edges[edge.model.id] = edge;
}
return edge.edgeData;
}
removeEdge(id) {
const edge = this.getEdge(id);
if (edge) {
this._graph.removeCells(edge.model);
delete this._edges[edge.model.id];
}
delete this._edges[id];
}
disableInputEvents() {
document.querySelectorAll('.graph-node-input').forEach((input) => {
input.classList.add('graph-node-input-no-pointer-events');
});
}
enableInputEvents() {
document.querySelectorAll('.graph-node-input').forEach((input) => {
input.classList.remove('graph-node-input-no-pointer-events');
});
}
addUnconnectedEdge(nodeId, edgeType, edgeSchema, validateEdge, onEdgeConnected) {
this.disableInputEvents();
const link = GraphViewEdge.createLink(this._config.defaultStyles, edgeSchema);
link.source(this.getNode(nodeId).model);
link.target(this.getNode(nodeId).model);
const mouseMoveEvent = (e) => {
const mousePos = this.getWindowToGraphPosition(new Vec2(e.clientX, e.clientY));
const sourceNodeView = this._paper.findViewByModel(this.getNode(nodeId).model);
const sourceNodePos = this.getGraphPosition(sourceNodeView.el.getBoundingClientRect());
let pointerVector = mousePos.clone().sub(sourceNodePos);
const direction = (new Vec2(e.clientX, e.clientY)).clone().sub(sourceNodeView.el.getBoundingClientRect()).normalize().mulScalar(20);
pointerVector = sourceNodePos.add(pointerVector).sub(direction);
link.target({
x: pointerVector.x,
y: pointerVector.y
});
};
const cellPointerDownEvent = (cellView) => {
if (!this.getNode(cellView.model.id)) return;
const targetNodeId = this.getNode(cellView.model.id).nodeData.id;
const nodeModel = this.getNode(nodeId).model;
// test whether a valid connection has been made
if ((cellView.model.id !== nodeModel.id) && !cellView.model.isLink() && validateEdge(edgeType, nodeId, targetNodeId)) {
link.target(cellView.model);
onEdgeConnected(edgeType, nodeId, targetNodeId);
}
this._graph.removeCells(link);
document.removeEventListener('mousemove', mouseMoveEvent);
this._paper.off('cell:pointerdown', cellPointerDownEvent);
this.enableInputEvents();
};
const mouseDownEvent = () => {
this._paper.off('cell:pointerdown', cellPointerDownEvent);
document.removeEventListener('mousemove', mouseMoveEvent);
this._graph.removeCells(link);
this.enableInputEvents();
};
document.addEventListener('mousemove', mouseMoveEvent);
document.addEventListener('mousedown', mouseDownEvent);
this._paper.on('cell:pointerdown', cellPointerDownEvent);
this._graph.addCell(link);
}
onBlankSelection(callback) {
this._paper.on('blank:pointerdown', () => {
callback();
});
}
selectNode(id) {
const node = this.getNode(id);
if (node) {
node.select();
Object.keys(this._edges).forEach((edgeKey) => {
const currEdge = this.getEdge(edgeKey);
currEdge.deselect();
});
}
}
deselectNode(id) {
const node = this.getNode(id);
if (node) node.deselect();
}
selectEdge(id) {
const edge = this.getEdge(id);
if (edge) {
edge.select();
Object.keys(this._edges).forEach((edgeKey) => {
const currEdge = this.getEdge(edgeKey);
if (edge.model.id !== currEdge.model.id) {
currEdge.mute();
}
});
this.getNode(edge.edgeData.from).hover();
this.getNode(edge.edgeData.to).hover();
}
}
deselectEdge(id) {
const edge = this.getEdge(id);
if (edge) {
Object.keys(this._edges).forEach((edgeKey) => {
const currEdge = this.getEdge(edgeKey);
currEdge.deselect();
});
this.getNode(edge.edgeData.from).hoverRemove();
this.getNode(edge.edgeData.to).hoverRemove();
}
}
setGraphPosition(posX, posY) {
this._paper.translate(posX, posY);
}
getGraphPosition() {
const t = this._paper.translate();
return new Vec2([t.tx, t.ty]);
}
setGraphScale(scale) {
this._paper.scale(scale);
}
getGraphScale() {
return this._paper.scale().sx;
}
getNodeDomElement(id) {
return this.getNode(id).model.findView(this._paper).el;
}
getEdgeDomElement(id) {
return this.getEdge(id).model.findView(this._paper).el;
}
destroy() {
this._graph.clear();
this._paper.remove();
}
}
export default GraphView;

View File

@@ -0,0 +1,670 @@
/**
* The PCUIGraph module is an extension of the PlayCanvas User Interface (PCUI) framework. It
* provides a new PCUI Element type for building interactive, node-based graphs.
*
* Key features include:
*
* - Scalable and customizable node-based graphs for visualizing complex data.
* - Interactive elements such as draggable nodes, clickable edges, and zoomable views.
* - Easy integration within a PCUI-based user interface.
*
* Whether it's for displaying network topologies, process flows, or complex relational data,
* PCUIGraph provides a robust and flexible solution for integrating graph visualizations into your
* web projects.
*
* @module PCUIGraph
*/
import { Observer } from '@playcanvas/observer';
import { Element } from '@playcanvas/pcui';
import { GRAPH_ACTIONS, DEFAULT_CONFIG } from './constants.js';
import GraphView from './graph-view.js';
import SelectedItem from './selected-item.js';
import { deepCopyFunction } from './util.js';
/**
* Represents a new Graph.
*/
class Graph extends Element {
/**
* Creates a new Graph.
*
* @param {object} schema - The graph schema.
* @param {object} [options] - The graph configuration. Optional.
* @param {object} [options.initialData] - The graph data to initialize the graph with.
* @param {HTMLElement} [options.dom] - If supplied, the graph will be attached to this element.
* @param {object[]} [options.contextMenuItems] - The context menu items to add to the graph.
* @param {boolean} [options.readOnly] - Whether the graph is read only. Optional. Defaults to
* false.
* @param {boolean} [options.passiveUIEvents] - If true, the graph will not update its data and
* view upon user interaction. Instead, these interactions can be handled explicitly by
* listening to fired events. Optional. Defaults to false.
* @param {boolean} [options.incrementNodeNames] - Whether the graph should increment the node
* name when a node with the same name already exists. Optional. Defaults to false.
* @param {boolean} [options.restrictTranslate] - Whether the graph should restrict the
* translate graph operation to the graph area. Optional. Defaults to false.
* @param {boolean} [options.edgeHoverEffect] - Whether the graph should show an edge highlight
* effect when the mouse is hovering over edges. Optional. Defaults to true.
* @param {object} [options.defaultStyles] - Used to override the graph's default styling. Check
* ./constants.js for a full list of style properties.
* @param {object} [options.adjustVertices] - If true, multiple edges connected between two
* nodes will be spaced apart.
*/
constructor(schema, options = {}) {
super({
dom: options.dom
});
this.class.add('pcui-graph');
this._graphSchema = schema;
this._graphData = new Observer({ data: options.initialData ? options.initialData : {} });
this._contextMenuItems = options.contextMenuItems || [];
this._suppressGraphDataEvents = false;
this._config = {
...DEFAULT_CONFIG,
readOnly: options.readOnly,
passiveUIEvents: options.passiveUIEvents,
incrementNodeNames: options.incrementNodeNames,
restrictTranslate: options.restrictTranslate,
edgeHoverEffect: options.edgeHoverEffect,
includeFonts: options.includeFonts,
adjustVertices: options.adjustVertices
};
if (options.defaultStyles) {
if (options.defaultStyles.background) {
this._config.defaultStyles.background = {
...this._config.defaultStyles.background,
...options.defaultStyles.background
};
}
if (options.defaultStyles.edge) {
this._config.defaultStyles.edge = {
...this._config.defaultStyles.edge,
...options.defaultStyles.edge
};
}
if (options.defaultStyles.node) {
this._config.defaultStyles.node = {
...this._config.defaultStyles.node,
...options.defaultStyles.node
};
}
}
if (this._config.readOnly) this._config.selfContainedMode = true;
this._buildGraphFromData();
if (options.defaultStyles.initialScale) {
this.setGraphScale(options.defaultStyles.initialScale);
}
if (options.defaultStyles.initialPosition) {
this.setGraphPosition(options.defaultStyles.initialPosition.x, options.defaultStyles.initialPosition.y);
}
}
/**
* The current graph data. Contains an object with any nodes and edges present in the graph.
* This can be passed into the graph constructor to reload the current graph.
*
* @type {object}
*/
get data() {
return this._graphData.get('data');
}
/**
* Destroy the graph. Clears the graph from the DOM and removes all event listeners associated
* with the graph.
*/
destroy() {
this.view.destroy();
}
_buildGraphFromData() {
this.view = new GraphView(this, this.dom, this._graphSchema, this._graphData, this._config);
this.view.batchCells();
const nodes = this._graphData.get('data.nodes');
if (nodes) {
Object.keys(nodes).forEach((nodeKey) => {
const node = nodes[nodeKey];
const nodeSchema = this._graphSchema.nodes[node.nodeType];
if (nodeSchema.attributes) {
if (nodeSchema.attributes && !node.attributes) {
node.attributes = {};
}
nodeSchema.attributes.forEach((attribute) => {
if (!node.attributes[attribute.name] && attribute.defaultValue) {
this._suppressGraphDataEvents = true;
this._graphData.set(`data.nodes.${nodeKey}.attributes.${attribute.name}`, attribute.defaultValue);
this._suppressGraphDataEvents = false;
}
});
}
this.createNode(this._graphData.get(`data.nodes.${nodeKey}`), undefined, true);
});
}
const edges = this._graphData.get('data.edges');
if (edges) {
Object.keys(edges).forEach((edgeKey) => {
this.createEdge(edges[edgeKey], edgeKey, true);
});
}
this.view.applyBatchedCells();
// handle context menus
if (!this._config.readOnly) {
this._addCanvasContextMenu();
}
this._selectedItem = null;
this.view.onBlankSelection(() => {
this._dispatchEvent(GRAPH_ACTIONS.DESELECT_ITEM, { prevItem: this._selectedItem });
});
if (!this._config.passiveUIEvents) {
this._registerInternalEventListeners();
}
}
_addCanvasContextMenu() {
const updateItem = (item) => {
switch (item.action) {
case GRAPH_ACTIONS.ADD_NODE: {
item.onSelect = (e) => {
const node = {
...item,
id: Number(`${Date.now()}${Math.floor(Math.random() * 10000)}`)
};
if (item.attributes) {
node.attributes = { ...item.attributes };
}
delete node.action;
delete node.text;
delete node.onClick;
const nodeSchema = this._graphSchema.nodes[node.nodeType];
if (nodeSchema.attributes && !node.attributes) {
node.attributes = {};
}
if (nodeSchema.attributes) {
nodeSchema.attributes.forEach((attribute) => {
if (!node.attributes[attribute.name] && attribute.defaultValue) {
node.attributes[attribute.name] = attribute.defaultValue;
}
});
}
if (this._config.incrementNodeNames && node.attributes.name) {
node.attributes.name = `${node.attributes.name} ${Object.keys(this._graphData.get('data.nodes')).length}`;
}
let element = e.target;
while (!element.classList.contains('pcui-menu-items')) {
element = element.parentElement;
}
let pos = {
x: Number(element.style.left.replace('px', '')),
y: Number(element.style.top.replace('px', ''))
};
pos = this.getWindowToGraphPosition(pos);
node.posX = pos.x;
node.posY = pos.y;
this._dispatchEvent(GRAPH_ACTIONS.ADD_NODE, { node });
};
}
}
return item;
};
const viewContextMenuItems = this._contextMenuItems.map((item) => {
item = updateItem(item);
if (!item.items) return item;
item.items.map((subitem) => {
return updateItem(subitem);
});
return item;
});
this.view.addCanvasContextMenu(viewContextMenuItems);
}
/**
* Select a node in the current graph.
*
* @param {object} node - The node to select
*/
selectNode(node) {
this.deselectItem();
this._selectedItem = new SelectedItem(this, 'NODE', node.id);
this._selectedItem.selectItem();
}
/**
* Select an edge in the current graph.
*
* @param {object} edge - The edge to select
* @param {number} edgeId - The edge id of the edge to select
*/
selectEdge(edge, edgeId) {
this.deselectItem();
this._selectedItem = new SelectedItem(this, 'EDGE', `${edge.from}-${edge.to}`, edgeId);
this._selectedItem.selectItem();
}
/**
* Deselect the currently selected item in the graph.
*/
deselectItem() {
if (this._selectedItem) {
this._selectedItem.deselectItem();
this._selectedItem = null;
}
}
_isValidEdge(edgeType, source, target) {
const edge = this._graphSchema.edges[edgeType];
return edge.from.includes(this._graphData.get(`data.nodes.${source}.nodeType`)) && edge.to.includes(this._graphData.get(`data.nodes.${target}.nodeType`));
}
/**
* Add an edge to the graph.
*
* @param {object} edge - The edge to add.
* @param {number} edgeId - The edge id for the new edge.
*/
createEdge(edge, edgeId) {
const edgeSchema = this._graphSchema.edges[edge.edgeType];
this.view.addEdge(edge, edgeSchema, (edge) => {
this._dispatchEvent(GRAPH_ACTIONS.SELECT_EDGE, { edge, prevItem: this._selectedItem });
});
if (edgeSchema.contextMenuItems) {
const contextMenuItems = deepCopyFunction(edgeSchema.contextMenuItems).map((item) => {
if (item.action === GRAPH_ACTIONS.DELETE_EDGE) {
item.onSelect = () => {
this._dispatchEvent(GRAPH_ACTIONS.DELETE_EDGE, { edgeId: edgeId, edge: this._graphData.get(`data.edges.${edgeId}`) });
};
}
return item;
});
const addEdgeContextMenuFunction = () => {
if (Number.isFinite(edge.outPort)) {
this.view.addEdgeContextMenu(`${edge.from},${edge.outPort}-${edge.to},${edge.inPort}`, contextMenuItems);
} else {
this.view.addEdgeContextMenu(`${edge.from}-${edge.to}`, contextMenuItems);
}
};
if (this.view.isBatchingCells()) {
this.view.addCellMountedFunction(addEdgeContextMenuFunction);
} else {
addEdgeContextMenuFunction();
}
}
if (!this._graphData.get(`data.edges.${edgeId}`)) {
this._graphData.set(`data.edges.${edgeId}`, edge);
}
}
_onEdgeConnected(edgeType, from, to) {
const edgeId = Number(`${Date.now()}${Math.floor(Math.random() * 10000)}`);
const edge = {
from: from,
to: to,
edgeType: edgeType,
conditions: {}
};
this._dispatchEvent(GRAPH_ACTIONS.ADD_EDGE, { edge, edgeId });
}
_createUnconnectedEdgeForNode(node, edgeType) {
const edgeSchema = this._graphSchema.edges[edgeType];
this.view.addUnconnectedEdge(node.id, edgeType, edgeSchema, this._isValidEdge.bind(this), this._onEdgeConnected.bind(this));
}
_onCreateEdge(edgeId, edge) {
this._dispatchEvent(GRAPH_ACTIONS.ADD_EDGE, { edge, edgeId });
}
_onNodeSelected(node) {
if (this.suppressNodeSelect) {
this.suppressNodeSelect = false;
} else {
this._dispatchEvent(GRAPH_ACTIONS.SELECT_NODE, { node, prevItem: this._selectedItem });
}
}
_onNodePositionUpdated(nodeId, pos) {
const node = this._graphData.get(`data.nodes.${nodeId}`);
const prevPosX = node.posX;
const prevPosY = node.posY;
if (pos.x !== node.posX || pos.y !== node.posY) {
node.posX = pos.x;
node.posY = pos.y;
this.updateNodePosition(nodeId, { x: prevPosX, y: prevPosY });
this._dispatchEvent(GRAPH_ACTIONS.UPDATE_NODE_POSITION, { nodeId, node });
}
}
_onNodeAttributeUpdated(nodeId, attribute, value) {
const node = this._graphData.get(`data.nodes.${nodeId}`);
let prevAttributeValue;
let attributeKey = node.attributes[attribute.name] !== undefined ? attribute.name : undefined;
if (!attributeKey) {
Object.keys(node.attributes).forEach((k) => {
const item = node.attributes[k];
if (item.name === attribute.name) attributeKey = k;
});
}
if (Number.isFinite(node.attributes[attributeKey].x)) {
prevAttributeValue = { ...node.attributes[attributeKey] };
} else {
prevAttributeValue = node.attributes[attributeKey];
}
if (Array.isArray(value)) {
const keyMap = ['x', 'y', 'z', 'w'];
value.forEach((v, i) => {
node.attributes[attributeKey][keyMap[i]] = v;
});
} else if (Object.keys(prevAttributeValue).includes('x') && Number.isFinite(value)) {
node.attributes[attributeKey].x = value;
} else {
node.attributes[attributeKey] = value;
}
if (JSON.stringify(node.attributes[attributeKey]) === JSON.stringify(prevAttributeValue)) return;
this.updateNodeAttribute(nodeId, attribute.name, value);
this._dispatchEvent(
GRAPH_ACTIONS.UPDATE_NODE_ATTRIBUTE,
{
node: node,
attribute: attribute.name,
attributeKey: attributeKey
}
);
}
_initializeNodeContextMenuItems(node, items) {
const contextMenuItems = deepCopyFunction(items).map((item) => {
if (item.action === GRAPH_ACTIONS.ADD_EDGE) {
item.onSelect = () => this._createUnconnectedEdgeForNode(node, item.edgeType);
}
if (item.action === GRAPH_ACTIONS.DELETE_NODE) {
item.onSelect = () => {
this._dispatchEvent(GRAPH_ACTIONS.DELETE_NODE, this._deleteNode(node.id));
};
}
return item;
});
return contextMenuItems;
}
/**
* Add a node to the graph.
*
* @param {object} node - The node to add.
*/
createNode(node) {
const nodeSchema = this._graphSchema.nodes[node.nodeType];
node = this.view.addNode(
node,
nodeSchema,
this._onCreateEdge.bind(this),
this._onNodeSelected.bind(this)
);
if (!this._graphData.get(`data.nodes.${node.id}`)) {
this._graphData.set(`data.nodes.${node.id}`, node);
}
this.view.addNodeEvent(
node.id,
'updatePosition',
this._onNodePositionUpdated.bind(this)
);
if (nodeSchema.attributes) {
nodeSchema.attributes.forEach((attribute) => {
this.view.addNodeEvent(
node.id,
'updateAttribute',
this._onNodeAttributeUpdated.bind(this),
attribute
);
});
}
if (nodeSchema.contextMenuItems) {
const contextMenuItems = this._initializeNodeContextMenuItems(node, nodeSchema.contextMenuItems);
this.view.addNodeContextMenu(node.id, contextMenuItems);
}
}
/**
* Update the position of a node.
*
* @param {number} nodeId - The node to add.
* @param {object} pos - The new position, given as an object containing x and y properties.
*/
updateNodePosition(nodeId, pos) {
if (!this._graphData.get(`data.nodes.${nodeId}`)) return;
this._graphData.set(`data.nodes.${nodeId}.posX`, pos.x);
this._graphData.set(`data.nodes.${nodeId}.posY`, pos.y);
this.view.updateNodePosition(nodeId, pos);
}
/**
* Update the value of an attribute of a node.
*
* @param {number} nodeId - The node to update.
* @param {string} attributeName - The name of the attribute to update.
* @param {object} value - The new value for the attribute.
*/
updateNodeAttribute(nodeId, attributeName, value) {
if (!this._graphData.get(`data.nodes.${nodeId}`)) return;
this._graphData.set(`data.nodes.${nodeId}.attributes.${attributeName}`, value);
this.view.updateNodeAttribute(nodeId, attributeName, value);
}
/**
* Set the error state of a node attribute.
*
* @param {number} nodeId - The node to update.
* @param {string} attributeName - The name of the attribute to update.
* @param {boolean} value - Whether the attribute should be set in the error state.
*/
setNodeAttributeErrorState(nodeId, attributeName, value) {
if (!this._graphData.get(`data.nodes.${nodeId}`)) return;
this.view.setNodeAttributeErrorState(nodeId, attributeName, value);
}
/**
* Update the type of a node.
*
* @param {number} nodeId - The node to update.
* @param {string} nodeType - The new type for the node.
*/
updateNodeType(nodeId, nodeType) {
if (Number.isFinite(nodeType) && this._graphData.get(`data.nodes.${nodeId}`)) {
this._graphData.set(`data.nodes.${nodeId}.nodeType`, nodeType);
this.view.updateNodeType(nodeId, nodeType);
}
}
_deleteNode(nodeId) {
if (!this._graphData.get(`data.nodes.${nodeId}`)) return;
if (this._selectedItem && this._selectedItem._id === nodeId) this.deselectItem();
const node = this._graphData.get(`data.nodes.${nodeId}`);
const edges = [];
const edgeData = {};
const edgeKeys = Object.keys(this._graphData.get('data.edges'));
for (let i = 0; i < edgeKeys.length; i++) {
const edge = this._graphData.get(`data.edges.${edgeKeys[i]}`);
edgeData[edgeKeys[i]] = edge;
if (edge.from === nodeId || edge.to === nodeId) {
edges.push(edgeKeys[i]);
}
}
return { node, edges, edgeData };
}
/**
* Delete a node from the graph.
*
* @param {number} nodeId - The node to delete.
*/
deleteNode(nodeId) {
const { node, edges, edgeData } = this._deleteNode(nodeId);
Object.values(edges).forEach((e) => {
const edge = edgeData[e];
this.deleteEdge(`${edge.from}-${edge.to}`);
});
this._graphData.unset(`data.nodes.${nodeId}`);
this.view.removeNode(node.id);
}
/**
* Delete an edge from the graph.
*
* @param {string} edgeId - The edge to delete.
*/
deleteEdge(edgeId) {
if (!this._graphData.get(`data.edges.${edgeId}`)) return;
const { from, to, outPort, inPort } = this._graphData.get(`data.edges.${edgeId}`) || {};
if (this._selectedItem && this._selectedItem._id === `${from}-${to}`) this.deselectItem();
if (Number.isFinite(outPort)) {
this.view.removeEdge(`${from},${outPort}-${to},${inPort}`);
} else {
this.view.removeEdge(`${from}-${to}`);
}
this.view.removeEdge(`${from}-${to}`);
this._graphData.unset(`data.edges.${edgeId}`);
const edges = this._graphData.get('data.edges');
Object.keys(edges).forEach((edgeKey) => {
const edge = edges[edgeKey];
const edgeSchema = this._graphSchema.edges[edge.edgeType];
if ([edge.from, edge.to].includes(from) && [edge.from, edge.to].includes(to)) {
this.view.addEdge(edge, edgeSchema, (edge) => {
this.selectEdge(edge, edgeKey);
});
this.selectEdge(edge, edgeKey);
}
});
}
/**
* Set the center of the viewport to the given position.
*
* @param {number} posX - The x position to set the center of the viewport to.
* @param {number} posY - The y position to set the center of the viewport to.
*/
setGraphPosition(posX, posY) {
this.view.setGraphPosition(posX, posY);
}
/**
* Get the current center position of the viewport in the graph.
*
* @returns {object} The current center position of the viewport in the graph as an object
* containing x and y.
*/
getGraphPosition() {
return this.view.getGraphPosition();
}
/**
* Set the scale of the graph.
*
* @param {number} scale - The new scale of the graph.
*/
setGraphScale(scale) {
this.view.setGraphScale(scale);
Object.keys(this.view._nodes).forEach((nodeKey) => {
this.view._paper.findViewByModel(this.view._nodes[nodeKey].model).updateBox();
});
}
/**
* Get the current scale of the graph.
*
* @returns {number} The current scale of the graph.
*/
getGraphScale() {
return this.view.getGraphScale();
}
/**
* Convert a position in window space to a position in graph space.
*
* @param {object} pos - A position in the window, as an object containing x and y.
* @returns {object} The position in the graph based on the given window position, as an object
* containing x and y.
*/
getWindowToGraphPosition(pos) {
return this.view.getWindowToGraphPosition(pos);
}
/**
* Add an event listener to the graph.
*
* @param {string} eventName - The name of the event to listen for.
* @param {Function} callback - The callback to call when the event is triggered.
*/
on(eventName, callback) {
if (this._config.readOnly && (!eventName.includes('EVENT_SELECT_') && !eventName.includes('EVENT_DESELECT'))) return;
this.dom.addEventListener(eventName, (e) => {
callback(e.detail);
});
}
_dispatchEvent(action, data) {
this.dom.dispatchEvent(new CustomEvent(action, { detail: data }));
}
_registerInternalEventListeners() {
this.on(GRAPH_ACTIONS.ADD_NODE, ({ node }) => {
this.createNode(node);
this.selectNode(node);
});
this.on(GRAPH_ACTIONS.DELETE_NODE, ({ node, edgeData, edges }) => {
this.deleteNode(node.id);
});
this.on(GRAPH_ACTIONS.SELECT_NODE, ({ node }) => {
if (this._selectedItem) {
this._selectedItem.deselectItem();
}
this._selectedItem = new SelectedItem(this, 'NODE', node.id);
this._selectedItem.selectItem();
});
this.on(GRAPH_ACTIONS.UPDATE_NODE_POSITION, ({ nodeId, node }) => {
this.updateNodePosition(nodeId, { x: node.posX, y: node.posY });
});
this.on(GRAPH_ACTIONS.UPDATE_NODE_ATTRIBUTE, ({ node }) => {
this._graphData.set(`data.nodes.${node.id}`, node);
});
this.on(GRAPH_ACTIONS.ADD_EDGE, ({ edge, edgeId }) => {
if (Number.isFinite(edge.inPort)) {
Object.keys(this._graphData.get('data.edges')).forEach((edgeKey) => {
const edgeToCompare = this._graphData.get(`data.edges.${edgeKey}`);
if (edgeToCompare.to === edge.to && edgeToCompare.inPort === edge.inPort) {
this.deleteEdge(edgeKey);
}
});
}
this.createEdge(edge, edgeId);
this.suppressNodeSelect = true;
this.selectEdge(edge, edgeId);
});
this.on(GRAPH_ACTIONS.DELETE_EDGE, ({ edgeId }) => {
this.deleteEdge(edgeId);
});
this.on(GRAPH_ACTIONS.SELECT_EDGE, ({ edge }) => {
if (this._selectedItem) {
this._selectedItem.deselectItem();
}
this._selectedItem = new SelectedItem(this, 'EDGE', `${edge.from}-${edge.to}`);
this._selectedItem.selectItem();
});
this.on(GRAPH_ACTIONS.DESELECT_ITEM, () => {
this.deselectItem();
});
}
}
Graph.GRAPH_ACTIONS = GRAPH_ACTIONS;
export default Graph;

View File

@@ -0,0 +1,287 @@
import 'jquery';
import * as joint from 'jointjs/dist/joint.min.js';
import _ from 'lodash';
import 'backbone';
// TODO replace with a lighter math library
import { Vec2 } from './lib/vec2.js';
joint.V.matrixToTransformString = function (matrix) {
matrix || (matrix = true); // eslint-disable-line no-unused-expressions
return `matrix(${[
matrix.a || 1,
matrix.b || 0,
matrix.c || 0,
matrix.d || 1,
matrix.e || 0,
matrix.f || 0
]})`;
};
joint.V.prototype.transform = function (matrix, opt) {
const node = this.node;
if (joint.V.isUndefined(matrix)) {
return (node.parentNode) ?
this.getTransformToElement(node.parentNode) :
node.getScreenCTM();
}
if (opt && opt.absolute) {
return this.attr('transform', joint.V.matrixToTransformString(matrix));
}
const svgTransform = joint.V.createSVGTransform(matrix);
node.transform.baseVal.appendItem(svgTransform);
return this;
};
class JointGraph {
constructor(dom, config = {}) {
this._config = config;
this._graph = new joint.dia.Graph({}, { cellNamespace: joint.shape });
this._paper = new joint.dia.Paper({
el: dom,
model: this._graph,
width: dom.offsetWidth,
cellViewNamespace: joint.shapes,
height: dom.offsetHeight,
clickThreshold: 1,
restrictTranslate: this._config.restrictTranslate,
background: {
color: config.defaultStyles.background.color
},
gridSize: config.defaultStyles.background.gridSize,
linkPinning: false,
interactive: !this._config.readOnly,
defaultLink: (cellView, magnet) => {
const defaultLink = new joint.shapes.standard.Link({
connector: {
name: 'normal'
}
});
defaultLink.attr({
line: {
stroke: joint.V(magnet).attr('stroke'),
strokeWidth: 2,
targetMarker: null
}
});
return defaultLink;
},
validateConnection: (cellViewS, magnetS, cellViewT, magnetT, end, linkView) => {
if (joint.V(cellViewS).id === joint.V(cellViewT).id) return false;
if (!joint.V(magnetS) || !joint.V(magnetT)) return false;
const sPort = joint.V(magnetS).attr('port');
const tPort = joint.V(magnetT.parentNode).attr('port');
if ((sPort.includes('in') && tPort.includes('in')) || (sPort.includes('out') && tPort.includes('out'))) return false;
if (sPort.includes('in') && joint.V(magnetS.children[1]).attr().visibility !== 'hidden') return false;
// if (tPort.includes('in') && joint.V(magnetT.parentNode.children[1]).attr().visibility !== 'hidden') return false;
if (cellViewS._portElementsCache[sPort].portContentElement.children()[0].attr().edgeType !== cellViewT._portElementsCache[tPort].portContentElement.children()[0].attr().edgeType) return false;
return true;
},
markAvailable: true,
drawGrid: {
name: 'doubleMesh',
args: [
{ color: '#0e1923', thickness: 1 },
{ color: '#06101b', scaleFactor: 10, thickness: 2 }
]
}
});
const graphResizeObserver = new ResizeObserver((_) => {
this._resizeGraph(dom);
});
graphResizeObserver.observe(dom);
this._panPaper = false;
this._translate = new Vec2();
this._totalTranslate = new Vec2();
this._pan = new Vec2();
this._mousePos = new Vec2();
this._paper.on('blank:pointerdown', (e) => {
this._panPaper = true;
this._mousePos = new Vec2(e.offsetX, e.offsetY);
});
this._paper.on('blank:pointerup', () => {
this._panPaper = false;
this._translate.add(this._pan);
});
dom.addEventListener('mousemove', (e) => {
if (this._panPaper) {
this._pan = this._mousePos.clone().sub(new Vec2(e.offsetX, e.offsetY));
this._mousePos = new Vec2(e.offsetX, e.offsetY);
this._paper.translate(this._paper.translate().tx - this._pan.x, this._paper.translate().ty - this._pan.y);
}
});
const handleCanvasMouseWheel = (e, x, y, delta) => {
e.preventDefault();
const oldScale = this._paper.scale().sx;
const newScale = oldScale + delta * 0.025;
this._scaleToPoint(newScale, x, y);
};
const handleCellMouseWheel = (cellView, e, x, y, delta) => handleCanvasMouseWheel(e, x, y, delta);
this._paper.on('cell:mousewheel', handleCellMouseWheel);
this._paper.on('blank:mousewheel', handleCanvasMouseWheel);
if (config.adjustVertices) {
const adjustGraphVertices = _.partial(this.adjustVertices.bind(this), this._graph);
// adjust vertices when a cell is removed or its source/target was changed
this._graph.on('add remove change:source change:target', adjustGraphVertices);
// adjust vertices when the user stops interacting with an element
this._paper.on('cell:pointerup', adjustGraphVertices);
}
}
_resizeGraph(dom) {
this._paper.setDimensions(dom.offsetWidth, dom.offsetHeight);
}
_scaleToPoint(nextScale, x, y) {
if (nextScale >= (this._config.minZoom || 0.25) && nextScale <= (this._config.maxZoom || 1.5)) {
const currentScale = this._paper.scale().sx;
const beta = currentScale / nextScale;
const ax = x - (x * beta);
const ay = y - (y * beta);
const translate = this._paper.translate();
const nextTx = translate.tx - ax * nextScale;
const nextTy = translate.ty - ay * nextScale;
this._paper.translate(nextTx, nextTy);
const ctm = this._paper.matrix();
ctm.a = nextScale;
ctm.d = nextScale;
this._paper.matrix(ctm);
}
}
adjustVertices(graph, cell) {
if (this.ignoreAdjustVertices) return;
// if `cell` is a view, find its model
cell = cell.model || cell;
if (cell instanceof joint.dia.Element) {
// `cell` is an element
_.chain(graph.getConnectedLinks(cell))
.groupBy((link) => {
// the key of the group is the model id of the link's source or target
// cell id is omitted
return _.omit([link.source().id, link.target().id], cell.id)[0];
})
.each((group, key) => {
// if the member of the group has both source and target model
// then adjust vertices
if (key !== 'undefined') this.adjustVertices(graph, _.first(group));
})
.value();
return;
}
// `cell` is a link
// get its source and target model IDs
const sourceId = cell.get('source').id || cell.previous('source').id;
const targetId = cell.get('target').id || cell.previous('target').id;
// if one of the ends is not a model
// (if the link is pinned to paper at a point)
// the link is interpreted as having no siblings
if (!sourceId || !targetId) return;
// identify link siblings
const siblings = _.filter(graph.getLinks(), (sibling) => {
const siblingSourceId = sibling.source().id;
const siblingTargetId = sibling.target().id;
// if source and target are the same
// or if source and target are reversed
return ((siblingSourceId === sourceId) && (siblingTargetId === targetId)) ||
((siblingSourceId === targetId) && (siblingTargetId === sourceId));
});
const numSiblings = siblings.length;
switch (numSiblings) {
case 0: {
// the link has no siblings
break;
} case 1: {
// there is only one link
// no vertices needed
cell.unset('vertices');
cell.set('connector', { name: 'normal' });
break;
} default: {
// there are multiple siblings
// we need to create vertices
// find the middle point of the link
const sourceCenter = graph.getCell(sourceId).getBBox().center();
const targetCenter = graph.getCell(targetId).getBBox().center();
joint.g.Line(sourceCenter, targetCenter).midpoint();
// find the angle of the link
const theta = sourceCenter.theta(targetCenter);
// constant
// the maximum distance between two sibling links
const GAP = 20;
_.each(siblings, (sibling, index) => {
// we want offset values to be calculated as 0, 20, 20, 40, 40, 60, 60 ...
let offset = GAP * Math.ceil(index / 2);
// place the vertices at points which are `offset` pixels perpendicularly away
// from the first link
//
// as index goes up, alternate left and right
//
// ^ odd indices
// |
// |----> index 0 sibling - centerline (between source and target centers)
// |
// v even indices
const sign = ((index % 2) ? 1 : -1);
// to assure symmetry, if there is an even number of siblings
// shift all vertices leftward perpendicularly away from the centerline
if ((numSiblings % 2) === 0) {
offset -= ((GAP / 2) * sign);
}
// make reverse links count the same as non-reverse
const reverse = ((theta < 180) ? 1 : -1);
// we found the vertex
const angle = joint.g.toRad(theta + (sign * reverse * 90));
const shift = joint.g.Point.fromPolar(offset * sign, angle, 0);
this.ignoreAdjustVertices = true;
sibling.source(sibling.getSourceCell(), {
anchor: {
name: 'center',
args: {
dx: shift.x,
dy: shift.y
}
}
});
sibling.target(sibling.getTargetCell(), {
anchor: {
name: 'center',
args: {
dx: shift.x,
dy: shift.y
}
}
});
this.ignoreAdjustVertices = false;
});
}
}
}
}
export default JointGraph;

View File

@@ -0,0 +1,89 @@
import 'jquery';
import 'backbone';
import * as joint from 'jointjs/dist/joint.min.js';
import _ from 'lodash';
const jointShapeElement = () => joint.shapes.standard.Rectangle.extend({
defaults: joint.util.deepSupplement({
type: 'html.Element',
markup: [{
tagName: 'rect',
selector: 'body'
}, {
tagName: 'rect',
selector: 'labelBackground'
}, {
tagName: 'rect',
selector: 'labelSeparator'
}, {
tagName: 'rect',
selector: 'inBackground'
}, {
tagName: 'rect',
selector: 'outBackground'
}, {
tagName: 'text',
selector: 'icon'
}, {
tagName: 'text',
selector: 'label'
}, {
tagName: 'image',
selector: 'texture'
}, {
tagName: 'path',
selector: 'marker'
}]
}, joint.shapes.standard.Rectangle.prototype.defaults)
});
const jointShapeElementView = paper => joint.dia.ElementView.extend({
initialize: function () {
_.bindAll(this, 'updateBox');
joint.dia.ElementView.prototype.initialize.apply(this, arguments);
this.div = document.createElement('div');
this.div.setAttribute('id', `nodediv_${this.model.id}`);
this.div.classList.add('graph-node-div');
// // Update the box position whenever the underlying model changes.
this.model.on('change', this.updateBox, this);
paper.on('cell:mousewheel', this.updateBox, this);
paper.on('blank:mousewheel', this.updateBox, this);
paper.on('blank:pointerup', this.updateBox, this);
document.addEventListener('mousemove', (e) => {
this.updateBox();
});
// // Remove the box when the model gets removed from the graph.
this.model.on('remove', this.removeBox, this);
this.updateBox();
},
render: function () {
joint.dia.ElementView.prototype.render.apply(this, arguments);
paper.$el.append(this.div);
this.updateBox();
return this;
},
updateBox: function () {
// Set the position and dimension of the box so that it covers the JointJS element.
const bbox = this.model.getBBox();
// Example of updating the HTML with a data stored in the cell model.
this.div.setAttribute('style', `
position: absolute;
width: ${bbox.width}px;
height: ${bbox.height}px;
left: ${bbox.width / 2 * paper.scale().sx}px;
top: ${bbox.height / 2 * paper.scale().sx}px;
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});
`);
},
removeBox: function (evt) {
this.div.remove();
}
});
export {
jointShapeElement,
jointShapeElementView
};

View File

@@ -0,0 +1,379 @@
/*! JointJS v3.4.1 (2021-08-18) - JavaScript diagramming library
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/*
A complete list of SVG properties that can be set through CSS is here:
http://www.w3.org/TR/SVG/styling.html
Important note: Presentation attributes have a lower precedence over CSS style rules.
*/
/* .viewport is a <g> node wrapping all diagram elements in the paper */
.joint-viewport {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.joint-paper > svg,
.joint-paper-background,
.joint-paper-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/*
1. IE can't handle paths without the `d` attribute for bounding box calculation
2. IE can't even handle 'd' attribute as a css selector (e.g path[d]) so the following rule will
break the links rendering.
path:not([d]) {
display: none;
}
*/
/* magnet is an element that can be either source or a target of a link */
[magnet=true]:not(.joint-element) {
cursor: crosshair;
}
[magnet=true]:not(.joint-element):hover {
opacity: .7;
}
/*
Elements have CSS classes named by their types. E.g. type: basic.Rect has a CSS class "element basic Rect".
This makes it possible to easily style elements in CSS and have generic CSS rules applying to
the whole group of elements. Each plugin can provide its own stylesheet.
*/
.joint-element {
/* Give the user a hint that he can drag&drop the element. */
cursor: move;
}
.joint-element * {
user-drag: none;
}
.joint-element .scalable * {
/* 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. */
vector-effect: non-scaling-stroke;
}
/*
connection-wrap is a <path> element of the joint.dia.Link that follows the .connection <path> of that link.
In other words, the `d` attribute of the .connection-wrap contains the same data as the `d` attribute of the
.connection <path>. The advantage of using .connection-wrap is to be able to catch pointer events
in the neighborhood of the .connection <path>. This is especially handy if the .connection <path> is
very thin.
*/
.marker-source,
.marker-target {
/* This makes the arrowheads point to the border of objects even though the transform: scale() is applied on them. */
vector-effect: non-scaling-stroke;
}
/* Paper */
.joint-paper {
position: relative;
}
/* Paper */
/* Highlighting */
.joint-highlight-opacity {
opacity: 0.3;
}
/* Highlighting */
/*
Vertex markers are `<circle>` elements that appear at connection vertex positions.
*/
.joint-link .connection-wrap,
.joint-link .connection {
fill: none;
}
/* <g> element wrapping .marker-vertex-group. */
.marker-vertices {
opacity: 0;
cursor: move;
}
.marker-arrowheads {
opacity: 0;
cursor: move;
cursor: -webkit-grab;
cursor: -moz-grab;
/* display: none; */ /* setting `display: none` on .marker-arrowheads effectively switches of links reconnecting */
}
.link-tools {
opacity: 0;
cursor: pointer;
}
.link-tools .tool-options {
display: none; /* by default, we don't display link options tool */
}
.joint-link:hover .marker-vertices,
.joint-link:hover .marker-arrowheads,
.joint-link:hover .link-tools {
opacity: 1;
}
/* <circle> element used to remove a vertex */
.marker-vertex-remove {
cursor: pointer;
opacity: .1;
}
.marker-vertex-group:hover .marker-vertex-remove {
opacity: 1;
}
.marker-vertex-remove-area {
opacity: .1;
cursor: pointer;
}
.marker-vertex-group:hover .marker-vertex-remove-area {
opacity: 1;
}
/*
Example of custom changes (in pure CSS only!):
Do not show marker vertices at all: .marker-vertices { display: none; }
Do not allow adding new vertices: .connection-wrap { pointer-events: none; }
*/
/* foreignObject inside the elements (i.e joint.shapes.basic.TextBlock) */
.joint-element .fobj {
overflow: hidden;
}
.joint-element .fobj body {
background-color: transparent;
margin: 0px;
position: static;
}
.joint-element .fobj div {
text-align: center;
vertical-align: middle;
display: table-cell;
padding: 0px 5px 0px 5px;
}
/* Paper */
.joint-paper.joint-theme-dark {
background-color: #18191b;
}
/* Paper */
/* Links */
.joint-link.joint-theme-dark .connection-wrap {
stroke: #8F8FF3;
stroke-width: 15;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
cursor: move;
}
.joint-link.joint-theme-dark .connection-wrap:hover {
opacity: .4;
stroke-opacity: .4;
}
.joint-link.joint-theme-dark .connection {
stroke-linejoin: round;
}
.joint-link.joint-theme-dark .link-tools .tool-remove circle {
fill: #F33636;
}
.joint-link.joint-theme-dark .link-tools .tool-remove path {
fill: white;
}
.joint-link.joint-theme-dark .link-tools [event="link:options"] circle {
fill: green;
}
/* <circle> element inside .marker-vertex-group <g> element */
.joint-link.joint-theme-dark .marker-vertex {
fill: #5652DB;
}
.joint-link.joint-theme-dark .marker-vertex:hover {
fill: #8E8CE1;
stroke: none;
}
.joint-link.joint-theme-dark .marker-arrowhead {
fill: #5652DB;
}
.joint-link.joint-theme-dark .marker-arrowhead:hover {
fill: #8E8CE1;
stroke: none;
}
/* <circle> element used to remove a vertex */
.joint-link.joint-theme-dark .marker-vertex-remove-area {
fill: green;
stroke: darkgreen;
}
.joint-link.joint-theme-dark .marker-vertex-remove {
fill: white;
stroke: white;
}
/* Links */
/* Paper */
.joint-paper.joint-theme-default {
background-color: #FFFFFF;
}
/* Paper */
/* Links */
.joint-link.joint-theme-default .connection-wrap {
stroke: #000000;
stroke-width: 15;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
cursor: move;
}
.joint-link.joint-theme-default .connection-wrap:hover {
opacity: .4;
stroke-opacity: .4;
}
.joint-link.joint-theme-default .connection {
stroke-linejoin: round;
}
.joint-link.joint-theme-default .link-tools .tool-remove circle {
fill: #FF0000;
}
.joint-link.joint-theme-default .link-tools .tool-remove path {
fill: #FFFFFF;
}
/* <circle> element inside .marker-vertex-group <g> element */
.joint-link.joint-theme-default .marker-vertex {
fill: #1ABC9C;
}
.joint-link.joint-theme-default .marker-vertex:hover {
fill: #34495E;
stroke: none;
}
.joint-link.joint-theme-default .marker-arrowhead {
fill: #1ABC9C;
}
.joint-link.joint-theme-default .marker-arrowhead:hover {
fill: #F39C12;
stroke: none;
}
/* <circle> element used to remove a vertex */
.joint-link.joint-theme-default .marker-vertex-remove {
fill: #FFFFFF;
}
/* Links */
.joint-link.joint-theme-material .connection-wrap {
stroke: #000000;
stroke-width: 15;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
cursor: move;
}
.joint-link.joint-theme-material .connection-wrap:hover {
opacity: .4;
stroke-opacity: .4;
}
.joint-link.joint-theme-material .connection {
stroke-linejoin: round;
}
.joint-link.joint-theme-material .link-tools .tool-remove circle {
fill: #C64242;
}
.joint-link.joint-theme-material .link-tools .tool-remove path {
fill: #FFFFFF;
}
/* <circle> element inside .marker-vertex-group <g> element */
.joint-link.joint-theme-material .marker-vertex {
fill: #d0d8e8;
}
.joint-link.joint-theme-material .marker-vertex:hover {
fill: #5fa9ee;
stroke: none;
}
.joint-link.joint-theme-material .marker-arrowhead {
fill: #d0d8e8;
}
.joint-link.joint-theme-material .marker-arrowhead:hover {
fill: #5fa9ee;
stroke: none;
}
/* <circle> element used to remove a vertex */
.joint-link.joint-theme-material .marker-vertex-remove-area {
fill: #5fa9ee;
}
.joint-link.joint-theme-material .marker-vertex-remove {
fill: white;
}
/* Links */
/* Links */
.joint-link.joint-theme-modern .connection-wrap {
stroke: #000000;
stroke-width: 15;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
cursor: move;
}
.joint-link.joint-theme-modern .connection-wrap:hover {
opacity: .4;
stroke-opacity: .4;
}
.joint-link.joint-theme-modern .connection {
stroke-linejoin: round;
}
.joint-link.joint-theme-modern .link-tools .tool-remove circle {
fill: #FF0000;
}
.joint-link.joint-theme-modern .link-tools .tool-remove path {
fill: #FFFFFF;
}
/* <circle> element inside .marker-vertex-group <g> element */
.joint-link.joint-theme-modern .marker-vertex {
fill: #1ABC9C;
}
.joint-link.joint-theme-modern .marker-vertex:hover {
fill: #34495E;
stroke: none;
}
.joint-link.joint-theme-modern .marker-arrowhead {
fill: #1ABC9C;
}
.joint-link.joint-theme-modern .marker-arrowhead:hover {
fill: #F39C12;
stroke: none;
}
/* <circle> element used to remove a vertex */
.joint-link.joint-theme-modern .marker-vertex-remove {
fill: white;
}
/* Links */

View File

@@ -0,0 +1,170 @@
/*
A complete list of SVG properties that can be set through CSS is here:
http://www.w3.org/TR/SVG/styling.html
Important note: Presentation attributes have a lower precedence over CSS style rules.
*/
/* .viewport is a <g> node wrapping all diagram elements in the paper */
.joint-viewport {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.joint-paper > svg,
.joint-paper-background,
.joint-paper-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/*
1. IE can't handle paths without the `d` attribute for bounding box calculation
2. IE can't even handle 'd' attribute as a css selector (e.g path[d]) so the following rule will
break the links rendering.
path:not([d]) {
display: none;
}
*/
/* magnet is an element that can be either source or a target of a link */
[magnet=true]:not(.joint-element) {
cursor: crosshair;
}
[magnet=true]:not(.joint-element):hover {
opacity: .7;
}
/*
Elements have CSS classes named by their types. E.g. type: basic.Rect has a CSS class "element basic Rect".
This makes it possible to easily style elements in CSS and have generic CSS rules applying to
the whole group of elements. Each plugin can provide its own stylesheet.
*/
.joint-element {
/* Give the user a hint that he can drag&drop the element. */
cursor: move;
}
.joint-element * {
user-drag: none;
}
.joint-element .scalable * {
/* 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. */
vector-effect: non-scaling-stroke;
}
/*
connection-wrap is a <path> element of the joint.dia.Link that follows the .connection <path> of that link.
In other words, the `d` attribute of the .connection-wrap contains the same data as the `d` attribute of the
.connection <path>. The advantage of using .connection-wrap is to be able to catch pointer events
in the neighborhood of the .connection <path>. This is especially handy if the .connection <path> is
very thin.
*/
.marker-source,
.marker-target {
/* This makes the arrowheads point to the border of objects even though the transform: scale() is applied on them. */
vector-effect: non-scaling-stroke;
}
/* Paper */
.joint-paper {
position: relative;
}
/* Paper */
/* Highlighting */
.joint-highlight-opacity {
opacity: 0.3;
}
/* Highlighting */
/*
Vertex markers are `<circle>` elements that appear at connection vertex positions.
*/
.joint-link .connection-wrap,
.joint-link .connection {
fill: none;
}
/* <g> element wrapping .marker-vertex-group. */
.marker-vertices {
opacity: 0;
cursor: move;
}
.marker-arrowheads {
opacity: 0;
cursor: move;
cursor: -webkit-grab;
cursor: -moz-grab;
/* display: none; */ /* setting `display: none` on .marker-arrowheads effectively switches of links reconnecting */
}
.link-tools {
opacity: 0;
cursor: pointer;
}
.link-tools .tool-options {
display: none; /* by default, we don't display link options tool */
}
.joint-link:hover .marker-vertices,
.joint-link:hover .marker-arrowheads,
.joint-link:hover .link-tools {
opacity: 1;
}
/* <circle> element used to remove a vertex */
.marker-vertex-remove {
cursor: pointer;
opacity: .1;
}
.marker-vertex-group:hover .marker-vertex-remove {
opacity: 1;
}
.marker-vertex-remove-area {
opacity: .1;
cursor: pointer;
}
.marker-vertex-group:hover .marker-vertex-remove-area {
opacity: 1;
}
/*
Example of custom changes (in pure CSS only!):
Do not show marker vertices at all: .marker-vertices { display: none; }
Do not allow adding new vertices: .connection-wrap { pointer-events: none; }
*/
/* foreignObject inside the elements (i.e joint.shapes.basic.TextBlock) */
.joint-element .fobj {
overflow: hidden;
}
.joint-element .fobj body {
background-color: transparent;
margin: 0px;
position: static;
}
.joint-element .fobj div {
text-align: center;
vertical-align: middle;
display: table-cell;
padding: 0px 5px 0px 5px;
}

View File

@@ -0,0 +1,48 @@
/* Links */
.joint-link.joint-theme-material .connection-wrap {
stroke: #000000;
stroke-width: 15;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
cursor: move;
}
.joint-link.joint-theme-material .connection-wrap:hover {
opacity: .4;
stroke-opacity: .4;
}
.joint-link.joint-theme-material .connection {
stroke-linejoin: round;
}
.joint-link.joint-theme-material .link-tools .tool-remove circle {
fill: #C64242;
}
.joint-link.joint-theme-material .link-tools .tool-remove path {
fill: #FFFFFF;
}
/* <circle> element inside .marker-vertex-group <g> element */
.joint-link.joint-theme-material .marker-vertex {
fill: #d0d8e8;
}
.joint-link.joint-theme-material .marker-vertex:hover {
fill: #5fa9ee;
stroke: none;
}
.joint-link.joint-theme-material .marker-arrowhead {
fill: #d0d8e8;
}
.joint-link.joint-theme-material .marker-arrowhead:hover {
fill: #5fa9ee;
stroke: none;
}
/* <circle> element used to remove a vertex */
.joint-link.joint-theme-material .marker-vertex-remove-area {
fill: #5fa9ee;
}
.joint-link.joint-theme-material .marker-vertex-remove {
fill: white;
}
/* Links */

View File

@@ -0,0 +1,620 @@
// Lib from https://raw.githubusercontent.com/playcanvas/engine/9083d81072c32d5dbb4394a72925e644fddc1c8a/src/math/vec2.js
/**
* A 2-dimensional vector.
*
* @ignore
*/
class Vec2 {
/**
* Create a new Vec2 instance.
*
* @param {number|number[]} [x] - The x value. Defaults to 0. If x is an array of length 2, the
* array will be used to populate all components.
* @param {number} [y] - The y value. Defaults to 0.
* @example
* var v = new pc.Vec2(1, 2);
*/
constructor(x = 0, y = 0) {
if (x.length === 2) {
/**
* The first component of the vector.
*
* @type {number}
*/
this.x = x[0];
/**
* The second component of the vector.
*
* @type {number}
*/
this.y = x[1];
} else {
this.x = x;
this.y = y;
}
}
/**
* Adds a 2-dimensional vector to another in place.
*
* @param {Vec2} rhs - The vector to add to the specified vector.
* @returns {Vec2} Self for chaining.
* @example
* var a = new pc.Vec2(10, 10);
* var b = new pc.Vec2(20, 20);
*
* a.add(b);
*
* // Outputs [30, 30]
* console.log("The result of the addition is: " + a.toString());
*/
add(rhs) {
this.x += rhs.x;
this.y += rhs.y;
return this;
}
/**
* Adds two 2-dimensional vectors together and returns the result.
*
* @param {Vec2} lhs - The first vector operand for the addition.
* @param {Vec2} rhs - The second vector operand for the addition.
* @returns {Vec2} Self for chaining.
* @example
* var a = new pc.Vec2(10, 10);
* var b = new pc.Vec2(20, 20);
* var r = new pc.Vec2();
*
* r.add2(a, b);
* // Outputs [30, 30]
*
* console.log("The result of the addition is: " + r.toString());
*/
add2(lhs, rhs) {
this.x = lhs.x + rhs.x;
this.y = lhs.y + rhs.y;
return this;
}
/**
* Adds a number to each element of a vector.
*
* @param {number} scalar - The number to add.
* @returns {Vec2} Self for chaining.
* @example
* var vec = new pc.Vec2(3, 4);
*
* vec.addScalar(2);
*
* // Outputs [5, 6]
* console.log("The result of the addition is: " + vec.toString());
*/
addScalar(scalar) {
this.x += scalar;
this.y += scalar;
return this;
}
/**
* Returns an identical copy of the specified 2-dimensional vector.
*
* @returns {Vec2} A 2-dimensional vector containing the result of the cloning.
* @example
* var v = new pc.Vec2(10, 20);
* var vclone = v.clone();
* console.log("The result of the cloning is: " + vclone.toString());
*/
clone() {
return new Vec2(this.x, this.y);
}
/**
* Copies the contents of a source 2-dimensional vector to a destination 2-dimensional vector.
*
* @param {Vec2} rhs - A vector to copy to the specified vector.
* @returns {Vec2} Self for chaining.
* @example
* var src = new pc.Vec2(10, 20);
* var dst = new pc.Vec2();
*
* dst.copy(src);
*
* console.log("The two vectors are " + (dst.equals(src) ? "equal" : "different"));
*/
copy(rhs) {
this.x = rhs.x;
this.y = rhs.y;
return this;
}
/**
* Returns the result of a cross product operation performed on the two specified 2-dimensional
* vectors.
*
* @param {Vec2} rhs - The second 2-dimensional vector operand of the cross product.
* @returns {number} The cross product of the two vectors.
* @example
* var right = new pc.Vec2(1, 0);
* var up = new pc.Vec2(0, 1);
* var crossProduct = right.cross(up);
*
* // Prints 1
* console.log("The result of the cross product is: " + crossProduct);
*/
cross(rhs) {
return this.x * rhs.y - this.y * rhs.x;
}
/**
* Returns the distance between the two specified 2-dimensional vectors.
*
* @param {Vec2} rhs - The second 2-dimensional vector to test.
* @returns {number} The distance between the two vectors.
* @example
* var v1 = new pc.Vec2(5, 10);
* var v2 = new pc.Vec2(10, 20);
* var d = v1.distance(v2);
* console.log("The distance between v1 and v2 is: " + d);
*/
distance(rhs) {
const x = this.x - rhs.x;
const y = this.y - rhs.y;
return Math.sqrt(x * x + y * y);
}
/**
* Divides a 2-dimensional vector by another in place.
*
* @param {Vec2} rhs - The vector to divide the specified vector by.
* @returns {Vec2} Self for chaining.
* @example
* var a = new pc.Vec2(4, 9);
* var b = new pc.Vec2(2, 3);
*
* a.div(b);
*
* // Outputs [2, 3]
* console.log("The result of the division is: " + a.toString());
*/
div(rhs) {
this.x /= rhs.x;
this.y /= rhs.y;
return this;
}
/**
* Divides one 2-dimensional vector by another and writes the result to the specified vector.
*
* @param {Vec2} lhs - The dividend vector (the vector being divided).
* @param {Vec2} rhs - The divisor vector (the vector dividing the dividend).
* @returns {Vec2} Self for chaining.
* @example
* var a = new pc.Vec2(4, 9);
* var b = new pc.Vec2(2, 3);
* var r = new pc.Vec2();
*
* r.div2(a, b);
* // Outputs [2, 3]
*
* console.log("The result of the division is: " + r.toString());
*/
div2(lhs, rhs) {
this.x = lhs.x / rhs.x;
this.y = lhs.y / rhs.y;
return this;
}
/**
* Divides each element of a vector by a number.
*
* @param {number} scalar - The number to divide by.
* @returns {Vec2} Self for chaining.
* @example
* var vec = new pc.Vec2(3, 6);
*
* vec.divScalar(3);
*
* // Outputs [1, 2]
* console.log("The result of the division is: " + vec.toString());
*/
divScalar(scalar) {
this.x /= scalar;
this.y /= scalar;
return this;
}
/**
* Returns the result of a dot product operation performed on the two specified 2-dimensional
* vectors.
*
* @param {Vec2} rhs - The second 2-dimensional vector operand of the dot product.
* @returns {number} The result of the dot product operation.
* @example
* var v1 = new pc.Vec2(5, 10);
* var v2 = new pc.Vec2(10, 20);
* var v1dotv2 = v1.dot(v2);
* console.log("The result of the dot product is: " + v1dotv2);
*/
dot(rhs) {
return this.x * rhs.x + this.y * rhs.y;
}
/**
* Reports whether two vectors are equal.
*
* @param {Vec2} rhs - The vector to compare to the specified vector.
* @returns {boolean} True if the vectors are equal and false otherwise.
* @example
* var a = new pc.Vec2(1, 2);
* var b = new pc.Vec2(4, 5);
* console.log("The two vectors are " + (a.equals(b) ? "equal" : "different"));
*/
equals(rhs) {
return this.x === rhs.x && this.y === rhs.y;
}
/**
* Returns the magnitude of the specified 2-dimensional vector.
*
* @returns {number} The magnitude of the specified 2-dimensional vector.
* @example
* var vec = new pc.Vec2(3, 4);
* var len = vec.length();
* // Outputs 5
* console.log("The length of the vector is: " + len);
*/
length() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
/**
* Returns the magnitude squared of the specified 2-dimensional vector.
*
* @returns {number} The magnitude of the specified 2-dimensional vector.
* @example
* var vec = new pc.Vec2(3, 4);
* var len = vec.lengthSq();
* // Outputs 25
* console.log("The length squared of the vector is: " + len);
*/
lengthSq() {
return this.x * this.x + this.y * this.y;
}
/**
* Returns the result of a linear interpolation between two specified 2-dimensional vectors.
*
* @param {Vec2} lhs - The 2-dimensional to interpolate from.
* @param {Vec2} rhs - The 2-dimensional to interpolate to.
* @param {number} alpha - The value controlling the point of interpolation. Between 0 and 1,
* the linear interpolant will occur on a straight line between lhs and rhs. Outside of this
* range, the linear interpolant will occur on a ray extrapolated from this line.
* @returns {Vec2} Self for chaining.
* @example
* var a = new pc.Vec2(0, 0);
* var b = new pc.Vec2(10, 10);
* var r = new pc.Vec2();
*
* r.lerp(a, b, 0); // r is equal to a
* r.lerp(a, b, 0.5); // r is 5, 5
* r.lerp(a, b, 1); // r is equal to b
*/
lerp(lhs, rhs, alpha) {
this.x = lhs.x + alpha * (rhs.x - lhs.x);
this.y = lhs.y + alpha * (rhs.y - lhs.y);
return this;
}
/**
* Multiplies a 2-dimensional vector to another in place.
*
* @param {Vec2} rhs - The 2-dimensional vector used as the second multiplicand of the operation.
* @returns {Vec2} Self for chaining.
* @example
* var a = new pc.Vec2(2, 3);
* var b = new pc.Vec2(4, 5);
*
* a.mul(b);
*
* // Outputs 8, 15
* console.log("The result of the multiplication is: " + a.toString());
*/
mul(rhs) {
this.x *= rhs.x;
this.y *= rhs.y;
return this;
}
/**
* Returns the result of multiplying the specified 2-dimensional vectors together.
*
* @param {Vec2} lhs - The 2-dimensional vector used as the first multiplicand of the operation.
* @param {Vec2} rhs - The 2-dimensional vector used as the second multiplicand of the operation.
* @returns {Vec2} Self for chaining.
* @example
* var a = new pc.Vec2(2, 3);
* var b = new pc.Vec2(4, 5);
* var r = new pc.Vec2();
*
* r.mul2(a, b);
*
* // Outputs 8, 15
* console.log("The result of the multiplication is: " + r.toString());
*/
mul2(lhs, rhs) {
this.x = lhs.x * rhs.x;
this.y = lhs.y * rhs.y;
return this;
}
/**
* Multiplies each element of a vector by a number.
*
* @param {number} scalar - The number to multiply by.
* @returns {Vec2} Self for chaining.
* @example
* var vec = new pc.Vec2(3, 6);
*
* vec.mulScalar(3);
*
* // Outputs [9, 18]
* console.log("The result of the multiplication is: " + vec.toString());
*/
mulScalar(scalar) {
this.x *= scalar;
this.y *= scalar;
return this;
}
/**
* Returns this 2-dimensional vector converted to a unit vector in place. If the vector has a
* length of zero, the vector's elements will be set to zero.
*
* @returns {Vec2} Self for chaining.
* @example
* var v = new pc.Vec2(25, 0);
*
* v.normalize();
*
* // Outputs 1, 0
* console.log("The result of the vector normalization is: " + v.toString());
*/
normalize() {
const lengthSq = this.x * this.x + this.y * this.y;
if (lengthSq > 0) {
const invLength = 1 / Math.sqrt(lengthSq);
this.x *= invLength;
this.y *= invLength;
}
return this;
}
/**
* Each element is set to the largest integer less than or equal to its value.
*
* @returns {Vec2} Self for chaining.
*/
floor() {
this.x = Math.floor(this.x);
this.y = Math.floor(this.y);
return this;
}
/**
* Each element is rounded up to the next largest integer.
*
* @returns {Vec2} Self for chaining.
*/
ceil() {
this.x = Math.ceil(this.x);
this.y = Math.ceil(this.y);
return this;
}
/**
* Each element is rounded up or down to the nearest integer.
*
* @returns {Vec2} Self for chaining.
*/
round() {
this.x = Math.round(this.x);
this.y = Math.round(this.y);
return this;
}
/**
* Each element is assigned a value from rhs parameter if it is smaller.
*
* @param {Vec2} rhs - The 2-dimensional vector used as the source of elements to compare to.
* @returns {Vec2} Self for chaining.
*/
min(rhs) {
if (rhs.x < this.x) this.x = rhs.x;
if (rhs.y < this.y) this.y = rhs.y;
return this;
}
/**
* Each element is assigned a value from rhs parameter if it is larger.
*
* @param {Vec2} rhs - The 2-dimensional vector used as the source of elements to compare to.
* @returns {Vec2} Self for chaining.
*/
max(rhs) {
if (rhs.x > this.x) this.x = rhs.x;
if (rhs.y > this.y) this.y = rhs.y;
return this;
}
/**
* Sets the specified 2-dimensional vector to the supplied numerical values.
*
* @param {number} x - The value to set on the first component of the vector.
* @param {number} y - The value to set on the second component of the vector.
* @returns {Vec2} Self for chaining.
* @example
* var v = new pc.Vec2();
* v.set(5, 10);
*
* // Outputs 5, 10
* console.log("The result of the vector set is: " + v.toString());
*/
set(x, y) {
this.x = x;
this.y = y;
return this;
}
/**
* Subtracts a 2-dimensional vector from another in place.
*
* @param {Vec2} rhs - The vector to add to the specified vector.
* @returns {Vec2} Self for chaining.
* @example
* var a = new pc.Vec2(10, 10);
* var b = new pc.Vec2(20, 20);
*
* a.sub(b);
*
* // Outputs [-10, -10]
* console.log("The result of the subtraction is: " + a.toString());
*/
sub(rhs) {
this.x -= rhs.x;
this.y -= rhs.y;
return this;
}
/**
* Subtracts two 2-dimensional vectors from one another and returns the result.
*
* @param {Vec2} lhs - The first vector operand for the addition.
* @param {Vec2} rhs - The second vector operand for the addition.
* @returns {Vec2} Self for chaining.
* @example
* var a = new pc.Vec2(10, 10);
* var b = new pc.Vec2(20, 20);
* var r = new pc.Vec2();
*
* r.sub2(a, b);
*
* // Outputs [-10, -10]
* console.log("The result of the subtraction is: " + r.toString());
*/
sub2(lhs, rhs) {
this.x = lhs.x - rhs.x;
this.y = lhs.y - rhs.y;
return this;
}
/**
* Subtracts a number from each element of a vector.
*
* @param {number} scalar - The number to subtract.
* @returns {Vec2} Self for chaining.
* @example
* var vec = new pc.Vec2(3, 4);
*
* vec.subScalar(2);
*
* // Outputs [1, 2]
* console.log("The result of the subtraction is: " + vec.toString());
*/
subScalar(scalar) {
this.x -= scalar;
this.y -= scalar;
return this;
}
/**
* Converts the vector to string form.
*
* @returns {string} The vector in string form.
* @example
* var v = new pc.Vec2(20, 10);
* // Outputs [20, 10]
* console.log(v.toString());
*/
toString() {
return `[${this.x}, ${this.y}]`;
}
/**
* Calculates the angle between two Vec2's in radians.
*
* @param {Vec2} lhs - The first vector operand for the calculation.
* @param {Vec2} rhs - The second vector operand for the calculation.
* @returns {number} The calculated angle in radians.
* @ignore
*/
static angleRad(lhs, rhs) {
return Math.atan2(lhs.x * rhs.y - lhs.y * rhs.x, lhs.x * rhs.x + lhs.y * rhs.y);
}
/**
* A constant vector set to [0, 0].
*
* @type {Vec2}
* @readonly
*/
static ZERO = Object.freeze(new Vec2(0, 0));
/**
* A constant vector set to [1, 1].
*
* @type {Vec2}
* @readonly
*/
static ONE = Object.freeze(new Vec2(1, 1));
/**
* A constant vector set to [0, 1].
*
* @type {Vec2}
* @readonly
*/
static UP = Object.freeze(new Vec2(0, 1));
/**
* A constant vector set to [0, -1].
*
* @type {Vec2}
* @readonly
*/
static DOWN = Object.freeze(new Vec2(0, -1));
/**
* A constant vector set to [1, 0].
*
* @type {Vec2}
* @readonly
*/
static RIGHT = Object.freeze(new Vec2(1, 0));
/**
* A constant vector set to [-1, 0].
*
* @type {Vec2}
* @readonly
*/
static LEFT = Object.freeze(new Vec2(-1, 0));
}
export { Vec2 };

View File

@@ -0,0 +1,44 @@
class SelectedItem {
constructor(graph, type, id, edgeId) {
this._graph = graph;
this._type = type;
this._id = id;
this._edgeId = edgeId;
}
get type() {
return this._type;
}
get id() {
return this._id;
}
get edgeId() {
return this._edgeId;
}
selectItem() {
switch (this._type) {
case 'NODE':
this._graph.view.selectNode(this._id);
break;
case 'EDGE':
this._graph.view.selectEdge(this._id);
break;
}
}
deselectItem() {
switch (this._type) {
case 'NODE':
this._graph.view.deselectNode(this._id);
break;
case 'EDGE':
this._graph.view.deselectEdge(this._id);
break;
}
}
}
export default SelectedItem;

View File

@@ -0,0 +1 @@
import './style.scss';

View File

@@ -0,0 +1,147 @@
@import '../lib/joint';
@import '../lib/layout';
@import '../lib/material';
.pcui-graph {
font-family: 'Helvetica Neue', Arial, Helvetica, sans-serif;
width: 100%;
height: 100%;
.available-magnet {
fill: greenyellow;
}
#paper-html-elements {
position: relative;
border: 1px solid gray;
display: inline-block;
background: transparent;
overflow: hidden;
}
#paper-html-elements svg {
background: transparent;
}
#paper-html-elements svg .link {
z-index: 2;
}
.html-element {
position: absolute;
background: #3498db;
/* Make sure events are propagated to the JointJS element so, e.g. dragging works. */
pointer-events: none;
user-select: none;
border-radius: 4px;
border: 2px solid #2980b9;
box-shadow: inset 0 0 5px black, 2px 2px 1px gray;
padding: 5px;
box-sizing: border-box;
z-index: 2;
}
.html-element select,
.html-element input,
.html-element button {
/* Enable interacting with inputs only. */
pointer-events: auto;
}
.html-element button.delete {
color: white;
border: none;
background-color: #c0392b;
border-radius: 20px;
width: 15px;
height: 15px;
line-height: 15px;
text-align: middle;
position: absolute;
top: -15px;
left: -15px;
padding: 0;
margin: 0;
font-weight: bold;
cursor: pointer;
}
.html-element button.delete:hover {
width: 20px;
height: 20px;
line-height: 20px;
}
.html-element select {
position: absolute;
right: 2px;
bottom: 28px;
}
.html-element input {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border: none;
color: #333;
padding: 5px;
height: 16px;
}
.html-element label {
color: #333;
text-shadow: 1px 0 0 lightgray;
font-weight: bold;
}
.html-element span {
position: absolute;
top: 2px;
right: 9px;
color: white;
font-size: 10px;
}
.graph-node-div {
pointer-events: none;
}
.graph-node-input {
pointer-events: all;
}
.graph-node-input-no-pointer-events {
pointer-events: none;
}
.graph-node-container {
margin-top: 5px;
margin-bottom: 5px;
height: 28px;
display: flex;
align-items: center;
pointer-events: inherit;
}
.graph-node-container:first-child {
// margin-top: 33px !important;
}
.graph-node-label {
max-width: 50px;
min-width: 50px;
font-size: 12px;
margin-left: 13px;
}
.port-inner-body {
pointer-events: none;
}
.pcui-contextmenu-parent,
.pcui-contextmenu-child {
height: 27px;
}
}

View File

@@ -0,0 +1,19 @@
export const deepCopyFunction = (inObject) => {
let value, key;
if (typeof inObject !== 'object' || inObject === null) {
return inObject; // Return the value if inObject is not an object
}
// Create an array or object to hold the values
const outObject = Array.isArray(inObject) ? [] : {};
for (key in inObject) {
value = inObject[key];
// Recursively (deep) copy for nested objects, including arrays
outObject[key] = deepCopyFunction(value);
}
return outObject;
};

View File

@@ -0,0 +1,17 @@
{
"name": "pcui-graph-styles",
"version": "1.0.0",
"author": "PlayCanvas <support@playcanvas.com>",
"homepage": "https://playcanvas.github.io/pcui-graph",
"description": "PCUI graph styles",
"private": true,
"main": "dist/index.mjs",
"license": "MIT",
"bugs": {
"url": "https://github.com/playcanvas/pcui-graph/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/playcanvas/pcui-graph.git"
}
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"noImplicitAny": true,
"allowJs": true,
"target": "es6",
"jsx": "react",
"types": ["react"],
"lib": [
"es2019",
"dom",
"dom.iterable"
],
"esModuleInterop" : true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["./src/index.js"],
"exclude": ["node_modules/**/*", "node_modules"]
}

View File

@@ -0,0 +1,18 @@
{
"$schema": "https://typedoc.org/schema.json",
"entryPoints": ["./src/index.js"],
"githubPages": false,
"name": "PCUI-Graph API",
"navigationLinks": {
"Developer Site": "https://developer.playcanvas.com/",
"Discord": "https://discord.gg/RSaMRzg",
"Forum": "https://forum.playcanvas.com/",
"GitHub": "https://github.com/playcanvas/pcui-graph"
},
"out": "typedocs",
"plugin": [
"typedoc-plugin-mdn-links",
"typedoc-plugin-rename-defaults"
],
"readme": "none"
}

View File

@@ -0,0 +1,18 @@
{
"sourceType": "unambiguous",
"presets": [
[
"@babel/preset-env",
{
"targets": {
"chrome": 100,
"safari": 15,
"firefox": 91
}
}
],
"@babel/preset-typescript",
"@babel/preset-react"
],
"plugins": []
}

View File

@@ -0,0 +1,34 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.storybook/utils/
# testing
/coverage
# production
/dist
/docs
/storybook
/types
/react/dist/
/react/types/
/styles/dist/
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
yarn.lock
# tools
.vscode

View File

@@ -0,0 +1,42 @@
import path from 'path';
const config = {
stories: ['../src/**/*.stories.tsx'],
addons: ['@storybook/addon-essentials', '@storybook/addon-webpack5-compiler-swc'],
webpackFinal: async (config, { configType }) => {
config.module.rules = config.module.rules.filter((rule) => {
if (!rule.test) return true;
return !rule.test.test(".scss");
});
config.module.rules.unshift({
test: /\.s[ac]ss$/i,
use: [
'style-loader',
'css-loader',
{
loader: 'sass-loader',
options: {
sassOptions: {
includePaths: [
path.resolve(__dirname, '../src/scss')
]
}
}
}
]
});
return config;
},
framework: {
name: '@storybook/react-webpack5',
options: {}
},
docs: {}
};
export default config;

View File

@@ -0,0 +1,24 @@
import '../src/scss/pcui-theme-green.scss';
const preview = {
parameters: {
backgrounds: {
default: 'playcanvas',
values: [
{
name: 'playcanvas',
value: '#374346'
},
{
name: 'white',
value: '#FFFFFF'
}
]
},
controls: { expanded: true }
},
tags: ['autodocs']
};
export default preview;

View File

@@ -0,0 +1,16 @@
{
"extends": "stylelint-config-standard-scss",
"rules": {
"font-family-no-missing-generic-family-keyword": [
true,
{
"ignoreFontFamilies": [
"pc-icon"
]
}
],
"no-descending-specificity": null,
"no-duplicate-selectors": null,
"scss/at-extend-no-missing-placeholder": null
}
}

View File

@@ -0,0 +1,77 @@
# Building a UMD Bundle
If you need a UMD version of the library (for example, to use it in a PlayCanvas Editor project), follow these steps:
1. Create a new folder and navigate to it in your terminal
2. Create a new NPM project:
```bash
npm init -y
```
3. Install the required packages:
```bash
npm install --save @babel/core babel-loader webpack webpack-cli @playcanvas/observer @playcanvas/pcui
```
4. Create `index.js` with the following content:
```js
import * as pcui from '@playcanvas/pcui';
import '@playcanvas/pcui/styles';
window.pcui = pcui;
```
5. Create `webpack.config.js`:
```js
const path = require('path');
module.exports = {
mode: 'production',
entry: './index.js',
output: {
path: path.resolve('dist'),
filename: 'pcui-bundle.min.js',
libraryTarget: 'window'
},
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: 'babel-loader'
}]
},
resolve: {
extensions: ['.js']
}
};
```
6. Add the build script to `package.json`:
```json
{
"scripts": {
"build": "webpack --mode production"
}
}
```
7. Build the bundle:
```bash
npm run build
```
The UMD bundle will be created in the `dist` folder as `pcui-bundle.min.js`. You can now use PCUI components through the global `pcui` object:
```js
const panel = new pcui.Panel({
flex: true,
collapsible: true,
headerText: 'Settings'
});
```

View File

@@ -0,0 +1,19 @@
Copyright (c) 2011-2025 PlayCanvas Ltd.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,148 @@
# PCUI - User Interface Component Library for the Web
[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/playcanvas/pcui/blob/main/LICENSE)
[![NPM Version](https://img.shields.io/npm/v/@playcanvas/pcui.svg?style=flat?style=flat)](https://www.npmjs.com/package/@playcanvas/pcui)
[![NPM Downloads](https://img.shields.io/npm/dw/@playcanvas/pcui)](https://npmtrends.com/@playcanvas/pcui)
| [User Guide](https://developer.playcanvas.com/user-manual/pcui/) | [API Reference](https://api.playcanvas.com/modules/PCUI.html) | [ESM Examples](https://playcanvas.github.io/pcui/examples/) | [React Examples](https://playcanvas.github.io/pcui/storybook/) | [Blog](https://blog.playcanvas.com/) | [Forum](https://forum.playcanvas.com/) | [Discord](https://discord.gg/RSaMRzg) |
![PCUI Banner](https://forum-files-playcanvas-com.s3.dualstack.eu-west-1.amazonaws.com/original/2X/7/7e51de8ae69fa499dcad292efd21d7722dcf2dbd.jpeg)
This library enables the creation of reliable and visually pleasing user interfaces by providing fully styled components that you can use directly on your site. The components are useful in a wide range of use cases, from creating simple forms to building graphical user interfaces for complex web tools.
A full guide to using the PCUI library can be found [here](https://developer.playcanvas.com/user-manual/pcui/).
## Getting Started
To install the PCUI NPM module, run the following command:
npm install @playcanvas/pcui --save-dev
You can then import each individual element from PCUI. In the example below, you can see how the PCUI `Label` component is imported from the PCUI library. The styles for PCUI are then imported into the example. Styles only need to be imported once per project.
```javascript
import { Label } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles';
const label = new Label({
text: 'Hello World'
});
document.body.appendChild(label.dom);
```
If you'd like to include PCUI in your React project, you can import the individual components as follows:
```javascript
import * as React from 'react';
import ReactDOM from 'react-dom';
import { TextInput } from '@playcanvas/pcui/react';
import '@playcanvas/pcui/styles';
ReactDOM.render(
<TextInput text='Hello World'/>,
document.body
);
```
## Building a UMD bundle
If you need a UMD version of the PCUI library (say, to use it in a PlayCanvas Editor project), please refer to our [build guide](BUILDGUIDE.md).
## Fonts in PCUI
PCUI uses four CSS classes for fonts across its components: `.font-regular`, `.font-bold`, `.font-thin` and `.font-light`. By default, these use the Helvetica Neue font stack:
```css
font-family: 'Helvetica Neue', Arial, Helvetica, sans-serif;
```
### Using your own Font
You can override PCUI's default font by adding your own `font-family` CSS rules to these classes on your webpage:
```css
.font-regular, .font-bold, .font-thin, .font-light {
font-family: 'Your Font', sans-serif;
}
```
## Data Binding
The PCUI library offers a data binding layer that can be used to synchronize data across multiple components. It offers two way binding to a given observer object, so updates made in a component are reflected in the observer's data and distributed out to all other subscribed components. A simple use case is shown below:
```javascript
import { Observer } from '@playcanvas/observer';
import { Label, TextInput, BindingObserversToElement, BindingElementToObservers } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles';
// create a new observer for a simple object which contains a text string
const observer = new Observer({
text: 'Hello World'
});
// create a label which will listen to updates from the observer
const label = new Label({
binding: new BindingObserversToElement()
});
// link the observer to the label, telling it to use the text variable as its value
label.link(observer, 'text');
// create a text input which will send updates to the observer
const textInput = new TextInput({
binding: new BindingElementToObservers()
});
// link the observer to the label, telling it to set the text variable on change
textInput.link(observer, 'text');
```
In the above example, the created label will start with `Hello World` as its text value. When a user enters a value into the text input, the label will be updated with the new value.
Observers can also be bound bi-directionally, in which case an element can both send and receive updates through its observer. The following example shows a two way binding between two text inputs, where either input can update the value of the other. It's been written in React to showcase binding with React components:
```jsx
import { Observer } from '@playcanvas/observer';
import { BindingTwoWay, TextInput } from '@playcanvas/pcui/react';
import '@playcanvas/pcui/styles';
// create a new observer for a simple object which contains a text string
const observer = new Observer({
text: 'Hello World'
});
// create two text inputs, which can both send and receive updates through the linked observer
const TextInput1 = () => <TextInput binding={new BindingTwoWay()} link={{ observer, path: 'text'} />;
const TextInput2 = () => <TextInput binding={new BindingTwoWay()} link={{ observer, path: 'text'} />;
```
## Development
Each component exists in its own folder within the `./src/components` directory. They each contain:
- `index.ts`: The PCUI element.
- `style.scss`: The SASS styles for the PCUI element.
- `component.tsx`: A React component wrapping the PCUI element.
- `component.stories.tsx`: The Storybook entry for the React component.
Locally developed components can be viewed & tested by running the Storybook app, as mentioned in the following section.
If you'd like to build your own custom version of the library you can run the `npm run build` command which will create a `dist` directory with your custom build.
## Storybook
If you wish to view all components available to you in the library, you can run a local version Storybook. It allows you to browse the entire collection of components and test any changes you make to them. Each component page also includes component documentation and allows you to test each component in all of its configuration states.
Run Storybook as follows:
npm install
npm run storybook
## API Documentation
To build the PCUI API Reference to the `docs` folder, do:
```bash
npm run docs
```

View File

@@ -0,0 +1,41 @@
import playcanvasConfig from '@playcanvas/eslint-config';
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import globals from 'globals';
export default [
...playcanvasConfig,
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
project: true
},
globals: {
...globals.browser,
...globals.mocha,
...globals.node
}
},
plugins: {
'@typescript-eslint': tsPlugin
},
settings: {
'import/resolver': {
typescript: {}
}
},
rules: {
...tsPlugin.configs['recommended'].rules,
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'jsdoc/require-param-type': 'off',
'jsdoc/require-returns': 'off',
'jsdoc/require-returns-type': 'off'
}
}
];

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI ArrayInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { ArrayInput, LabelGroup } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const types = [ 'boolean', 'number', 'string', 'vec2', 'vec3', 'vec4' ];
for (const type of types) {
const arrayInput = new ArrayInput({
type: type
});
const labelGroup = new LabelGroup({
field: arrayInput,
text: type + ':'
});
document.body.appendChild(labelGroup.dom);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI BooleanInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { BooleanInput, LabelGroup } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const checkbox = new BooleanInput();
const checkboxGroup = new LabelGroup({
field: checkbox,
text: 'Checkbox:'
});
document.body.appendChild(checkboxGroup.dom);
const toggle = new BooleanInput({
type: 'toggle'
});
const toggleGroup = new LabelGroup({
field: toggle,
text: 'Toggle:'
});
document.body.appendChild(toggleGroup.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Button</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Button } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const button = new Button({
icon: 'E400',
text: 'Click Me'
});
document.body.appendChild(button.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Canvas</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Canvas } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const canvas = new Canvas({
useDevicePixelRatio: true
});
canvas.resize(512, 512);
// draw a smiling face with the 2D canvas API
const ctx = canvas.dom.getContext('2d');
// face
ctx.beginPath();
ctx.arc(256, 256, 200, 0, 2 * Math.PI);
ctx.fillStyle = 'yellow';
ctx.fill();
ctx.lineWidth = 10;
ctx.strokeStyle = 'black';
ctx.stroke();
// left eye
ctx.beginPath();
ctx.arc(200, 200, 50, 0, 2 * Math.PI);
ctx.fillStyle = 'black';
ctx.fill();
// right eye
ctx.beginPath();
ctx.arc(312, 200, 50, 0, 2 * Math.PI);
ctx.fillStyle = 'black';
ctx.fill();
// mouth
ctx.beginPath();
ctx.arc(256, 256, 100, 0, Math.PI);
ctx.stroke();
document.body.appendChild(canvas.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Code</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Code } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
// get the code for this example
const src = document.querySelector('script[type="module"]').innerHTML;
const code = new Code({
text: src
});
document.body.appendChild(code.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI ColorPicker</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
.pcui-label-group > .pcui-label:first-child {
font-size: 16px;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { ColorPicker, LabelGroup } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const rgb = new ColorPicker();
const rgbGroup = new LabelGroup({
field: rgb,
text: 'RGB:'
});
const rgba = new ColorPicker({
channels: 4
});
const rgbaGroup = new LabelGroup({
field: rgba,
text: 'RGB + Alpha:'
});
document.body.appendChild(rgbGroup.dom);
document.body.appendChild(rgbaGroup.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Divider</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Divider, LabelGroup, TextInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const aboveGroup = new LabelGroup({
field: new TextInput(),
text: 'Above Divider'
});
document.body.appendChild(aboveGroup.dom);
const divider = new Divider();
document.body.appendChild(divider.dom);
const belowGroup = new LabelGroup({
field: new TextInput(),
text: 'Below Divider'
});
document.body.appendChild(belowGroup.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI GradientPicker</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { GradientPicker } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const gradientPicker = new GradientPicker();
document.body.appendChild(gradientPicker.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI GridView</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { GridView, GridViewItem } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const gridView = new GridView();
for (let i = 1; i <= 100; i++) {
const item = new GridViewItem({
text: `Item ${i}`
});
gridView.append(item);
}
document.body.appendChild(gridView.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI InfoBox</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { InfoBox } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const info = new InfoBox({
unsafe: true,
icon: 'E400',
text: 'An info box can be used to display information to the user. It can contain a title, icon and text. You can even include HTML such as <a href="https://github.com/playcanvas/pcui" target="_none">hyperlinks</a> if you set the unsafe property of the info box to true!',
title: 'Info Box'
});
document.body.appendChild(info.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Label</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Label } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const label = new Label({
text: 'This is a Label'
});
document.body.appendChild(label.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI LabelGroup</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { LabelGroup, TextInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
for (let i = 1; i <= 10; i++) {
const labelGroup = new LabelGroup({
text: 'Label ' + i,
field: new TextInput()
})
document.body.appendChild(labelGroup.dom);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Menu</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Label, Menu } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const label = new Label({
text: 'Right click to open menu'
});
document.body.appendChild(label.dom);
const menu = new Menu({
items: [
{
text: 'Item 1'
},
{
text: 'Item 2'
},
{
text: 'Item 3',
items: [
{
text: 'Item 3.1'
},
{
text: 'Item 3.2'
},
{
text: 'Item 3.3'
}
]
}
]
});
document.body.appendChild(menu.dom);
window.addEventListener('contextmenu', (event) => {
event.stopPropagation();
event.preventDefault();
menu.hidden = false;
menu.position(event.clientX, event.clientY);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI NumericInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
.pcui-element.pcui-label-group > .pcui-label:first-child {
width: 125px;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { InfoBox, LabelGroup, NumericInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const numericInput = new NumericInput();
const labelGroup = new LabelGroup({
field: numericInput,
text: 'Enter a number:'
});
const infoBox = new InfoBox({
icon: 'E400',
text: 'NumericInput also supports expressions. For example, you can enter "10 * (2 + 3)" to get a value of 50.'
});
document.body.appendChild(labelGroup.dom);
document.body.appendChild(infoBox.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Overlay</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
.pcui-overlay-content {
z-index: 0;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Button, Label, Overlay } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const overlay = new Overlay();
const label = new Label({
class: 'text',
text: 'Dismiss for 1 second?'
});
overlay.append(label);
const btnYes = new Button({
text: 'Yes'
});
btnYes.on('click', () => {
overlay.hidden = true;
setTimeout(() => {
overlay.hidden = false;
}, 1000);
});
overlay.append(btnYes);
document.body.appendChild(overlay.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Panel</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { LabelGroup, Panel, TextInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const panel = new Panel({
collapseHorizontally: false,
collapsible: true,
collapsed: false,
headerText: 'Panel Header Text (click to collapse)'
});
for (let i = 1; i <= 10; i++) {
const labelGroup = new LabelGroup({
text: 'Property ' + i,
field: new TextInput()
})
panel.append(labelGroup);
}
document.body.appendChild(panel.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Progress</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Progress } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const progress = new Progress({
value: 0
});
setInterval(() => {
progress.value++;
if (progress.value === 100) {
progress.value = 0;
}
}, 20);
document.body.appendChild(progress.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI RadioButton</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { LabelGroup, RadioButton } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const radioButtons = [];
for (let i = 0; i < 5; i++) {
const radioButton = new RadioButton();
radioButton.on('click', () => {
radioButtons.forEach(radioButton => {
radioButton.value = false;
});
radioButton.value = true;
});
radioButtons.push(radioButton);
const group = new LabelGroup({
text: `Option ${i + 1}`,
field: radioButton
});
document.body.appendChild(group.dom);
}
radioButtons[0].value = true;
</script>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI SelectInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
.pcui-element.pcui-label-group > .pcui-label:first-child {
width: 175px;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { LabelGroup, SelectInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const select = new SelectInput({
options: [
{ t: 'Option 1', v: 1 },
{ t: 'Option 2', v: 2 },
{ t: 'Option 3', v: 3 },
{ t: 'Default', v: 4 }
],
defaultValue: 4,
type: 'number'
});
const labelGroup1 = new LabelGroup({
text: 'SelectInput:',
field: select
});
const selectWithFilter = new SelectInput({
allowInput: true,
options: [
{ t: 'Option 1', v: 1 },
{ t: 'Option 2', v: 2 },
{ t: 'Option 3', v: 3 },
{ t: 'Default', v: 4 }
],
defaultValue: 4,
type: 'number'
});
const labelGroup2 = new LabelGroup({
text: 'SelectInput with Filter:',
field: selectWithFilter
});
document.body.appendChild(labelGroup1.dom);
document.body.appendChild(labelGroup2.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI SliderInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { SliderInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const sliderInput = new SliderInput();
document.body.appendChild(sliderInput.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Spinner</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Spinner } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const spinner = new Spinner();
document.body.appendChild(spinner.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI TextAreaInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { TextAreaInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const textAreaInput1 = new TextAreaInput({
readOnly: true,
value: 'This is a read-only, fixed size TextAreaInput.'
});
document.body.appendChild(textAreaInput1.dom);
const textAreaInput2 = new TextAreaInput({
resizable: 'vertical',
width: 500,
value: 'This is an editable, resizable TextAreaInput. Try dragging the resize handle. The element also supports Shift-Enter for new lines.'
});
document.body.appendChild(textAreaInput2.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI TextInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { TextInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const textInput = new TextInput({
value: 'Enter text here...'
});
document.body.appendChild(textInput.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI TreeView</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
.pcui-treeview-item-text,
.pcui-treeview-item-icon {
font-size: 14px;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { TreeView, TreeViewItem } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const treeView = new TreeView({
allowRenaming: true
});
const root = new TreeViewItem({
open: true,
text: 'My Drive',
icon: 'E229' // Drive icon
});
treeView.append(root);
const generateChildren = (parent, depth) => {
if (depth > 0) {
for (let i = 0; i < 5; i++) {
const isFolder = depth > 1;
const item = new TreeViewItem({
allowDrop: isFolder,
text: (isFolder ? 'Folder ' : 'File ') + i,
icon: isFolder ? 'E139' : 'E208' // Folder or file icon
});
parent.append(item);
generateChildren(item, depth - 1);
}
}
};
generateChildren(root, 3);
document.body.appendChild(treeView.dom);
</script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI VectorInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { LabelGroup, VectorInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
for (let i = 2; i <= 4; i++) {
const group = new LabelGroup({
text: `${i}D Vector`,
field: new VectorInput({
dimensions: i,
min: 0,
max: 1,
placeholder: ['X', 'Y', 'Z', 'W'].slice(0, i),
precision: 4,
step: 0.01
})
});
document.body.appendChild(group.dom);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Example Browser</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body, html {
background-color: #364346;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
iframe {
flex-grow: 1;
border: 2px solid rgb(175, 175, 175);
}
.pcui-treeview-item-text,
.pcui-treeview-item-icon {
font-size: 14px;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Container, Panel, TextInput, TreeView, TreeViewItem } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const root = new Container({
flex: true,
flexDirection: 'row',
width: '100%',
height: '100%'
});
document.body.appendChild(root.dom);
const panel = new Panel({
collapseHorizontally: true,
collapsible: true,
scrollable: true,
headerText: 'PCUI Example Browser',
width: 200,
height: '100%'
});
const treeView = new TreeView({
allowRenaming: false,
allowReordering: false,
allowDrag: false
});
const iframe = document.createElement('iframe');
const categories = [
{
categoryName: 'Elements',
examples: [
'ArrayInput',
'BooleanInput',
'Button',
'Canvas',
'Code',
'ColorPicker',
'Divider',
'GradientPicker',
'GridView',
'InfoBox',
'Label',
'LabelGroup',
'Menu',
'NumericInput',
'Overlay',
'Panel',
'Progress',
'RadioButton',
'SelectInput',
'SliderInput',
'Spinner',
'TextAreaInput',
'TextInput',
'TreeView',
'VectorInput'
]
},
{
categoryName: 'Utilities',
examples: [
'Icon Browser',
]
}
];
for (const category of categories) {
const categoryItem = new TreeViewItem({
open: true,
text: category.categoryName
});
treeView.append(categoryItem);
for (const example of category.examples) {
const item = new TreeViewItem({
text: example
});
item.on('select', () => {
const path = category.categoryName.toLowerCase();
const name = example.toLowerCase().replace(/ /g, '-');
iframe.src = `${path}/${name}.html`;
});
categoryItem.append(item);
}
}
const filter = new TextInput({
keyChange: true,
placeholder: 'Filter',
width: 'calc(100% - 14px)'
});
filter.on('change', (value) => {
treeView.filter = value;
});
root.append(panel);
panel.append(filter);
panel.append(treeView);
root.dom.appendChild(iframe);
</script>
</body>
</html>

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI GridView</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
.icon-info > .pcui-label {
font-size: 16px;
line-height: 2;
}
.pcui-gridview-item-text::before {
font-family: pc-icon;
content: attr(data-before);
padding-right: 10px;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { GridView, GridViewItem } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const gridView = new GridView({
multiSelect: false
});
const ranges = [
[ 111, 392 ],
[ 394, 415 ],
[ 421, 426 ],
[ 430, 439 ]
];
for (const range of ranges) {
for (let i = range[0]; i <= range[1]; i++) {
const item = new GridViewItem({
class: 'icon-info',
text: `E${i}`
});
const span = item.dom.childNodes[0];
const icon = String.fromCodePoint(parseInt(`E${i}`, 16));
span.setAttribute('data-before', icon);
gridView.append(item);
}
}
document.body.appendChild(gridView.dom);
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More