@vaadin-component-factory/vcf-network
Version:
Hierarchical network visualizing web component
652 lines (618 loc) • 23.1 kB
JavaScript
/**
* @license
* Copyright (C) 2015 Vaadin Ltd.
* This program is available under Commercial Vaadin Add-On License 3.0 (CVALv3).
* See the file LICENSE.md distributed with this software for more information about licensing.
* See [the website]{@link https://vaadin.com/license/cval-3} for the complete license.
*/
import { html, PolymerElement } from '@polymer/polymer/polymer-element';
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin';
import { colorVars, ComponentNode, IONode } from '../utils/vcf-network-shared';
import vis from 'vis-network/dist/vis-network.esm';
/**
* Right side panel of VCF Network.
* Contains buttons for CRUD operations and shows info for the currently selected items.
*/
class VcfNetworkInfoPanel extends ThemableMixin(PolymerElement) {
static get template() {
return html`
<style>
.panel-container {
display: flex;
flex-direction: column;
height: 100%;
width: 240px;
flex-shrink: 0;
box-shadow: inset 1px 0 0 0 var(--lumo-shade-10pct);
transition: width 0.2s;
}
span.selection {
align-items: center;
box-shadow: inset 0 -1px 0 0 var(--lumo-shade-10pct);
color: var(--lumo-tertiary-text-color);
display: flex;
flex-shrink: 0;
font-size: var(--lumo-font-size-s);
font-weight: 500;
height: var(--lumo-size-l);
padding: 0 var(--lumo-space-m);
}
span.selection.active {
background-color: var(--lumo-primary-color-10pct);
color: var(--lumo-primary-text-color);
}
.button-container {
align-items: center;
box-shadow: inset 0 -1px 0 0 var(--lumo-shade-10pct);
display: flex;
flex-shrink: 0;
height: var(--lumo-size-xl);
justify-content: space-around;
}
.details-container {
flex-grow: 1;
overflow: auto;
padding: 0 var(--lumo-space-m) var(--lumo-space-m) var(--lumo-space-m);
box-shadow: inset 0 -1px 0 0 var(--lumo-shade-10pct);
}
.details {
display: flex;
flex-direction: column;
opacity: 1;
transition: all 0.2s;
}
.details.hidden {
display: none;
opacity: 0;
transition: all 0.2s;
}
.coords vaadin-text-field {
width: 45%;
}
.coords vaadin-text-field:first-child {
margin-right: var(--lumo-space-m);
}
.section-footer {
cursor: pointer;
}
/** Closed **/
.panel-container.closed {
width: 36px;
}
.closed span {
display: none;
}
.closed .section-footer iron-icon {
transform: rotate(180deg);
}
.closed .section-footer {
text-align: center;
}
.closed .button-container {
flex-direction: column;
margin-top: var(--lumo-size-l);
height: auto;
}
.closed .details {
display: none;
}
</style>
<div class="panel-container" id="info-panel">
<span id="selection" class="selection">[[selectionText]]</span>
<div class="button-container">
<vaadin-button id="create-component-button" theme="tertiary" title="Create component" disabled>
<iron-icon icon="vcf-network:cached"></iron-icon>
</vaadin-button>
<vaadin-button id="export-button" theme="tertiary" title="Export component" disabled>
<iron-icon icon="vcf-network:swap-vert"></iron-icon>
</vaadin-button>
<vaadin-button id="copy-button" theme="tertiary" title="Copy" disabled>
<iron-icon icon="vcf-network:content-copy"></iron-icon>
</vaadin-button>
<vaadin-button id="save-button" class="details hidden" theme="tertiary" title="Save">
<iron-icon icon="vcf-network:save"></iron-icon>
</vaadin-button>
<vaadin-button id="delete-button" theme="tertiary error" title="Delete" disabled>
<iron-icon icon="vcf-network:delete"></iron-icon>
</vaadin-button>
</div>
<div class="details-container">
<div class="details hidden" id="node-details">
<vaadin-text-field id="node-name" label="Name" theme="small"></vaadin-text-field>
<vaadin-text-field id="node-id" label="ID" readonly autoselect theme="small"></vaadin-text-field>
<div class="coords">
<vaadin-text-field id="node-x" label="x" readonly autoselect theme="small"></vaadin-text-field>
<vaadin-text-field id="node-y" label="y" readonly autoselect theme="small"></vaadin-text-field>
</div>
</div>
<div class="details hidden" id="node-form">
<slot name="node-form"></slot>
</div>
<div class="details hidden" id="edge-details">
<vaadin-text-field id="edge-id" label="Id" readonly autoselect theme="small"></vaadin-text-field>
<vaadin-text-field id="edge-from" label="From" readonly autoselect theme="small"></vaadin-text-field>
<vaadin-text-field id="edge-to" label="To" readonly autoselect theme="small"></vaadin-text-field>
</div>
<div class="details hidden" id="component-details">
<vaadin-text-field id="component-name" label="Name" theme="small"></vaadin-text-field>
<vaadin-select id="component-color" label="Color">
<template>
<vaadin-list-box>
<template is="dom-repeat" items="[[_colors]]">
<vaadin-item>
<vcf-network-color-option color="[[index]]">[[index]]</vcf-network-color-option>
</vaadin-item>
</template>
</vaadin-list-box>
</template>
</vaadin-select>
<vaadin-text-field id="component-id" label="ID" readonly autoselect theme="small"></vaadin-text-field>
<div class="coords">
<vaadin-text-field id="component-x" label="x" readonly autoselect theme="small"></vaadin-text-field>
<vaadin-text-field id="component-y" label="y" readonly autoselect theme="small"></vaadin-text-field>
</div>
</div>
</div>
<div class="section-footer">
<iron-icon icon="vcf-network:keyboard-arrow-right"></iron-icon>
</div>
</div>
`;
}
static get is() {
return 'vcf-network-info-panel';
}
static get properties() {
return {
selection: {
type: Object,
observer: '_selectionChanged'
},
selectionText: {
type: String,
value: 'No selection'
},
_colors: {
type: Array,
value: () => colorVars
}
};
}
connectedCallback() {
super.connectedCallback();
this._initEventListeners();
}
_initEventListeners() {
this.$['node-name'].addEventListener('input', () => this._updateNode());
this.$['component-name'].addEventListener('input', () => this._updateComponent());
this.$['component-color'].addEventListener('change', () => this._updateComponent());
this.$['create-component-button'].addEventListener('click', () => this._createComponent());
this.$['export-button'].addEventListener('click', () => this._exportComponent());
this.$['delete-button'].addEventListener('click', () => this._deleteSelected());
this.$['save-button'].addEventListener('click', () => this._saveSelected());
this.$['copy-button'].addEventListener('click', () => {
this._copyCache = this.selection;
this._addCopy();
});
const sectionFooters = this.shadowRoot.querySelectorAll('.section-footer');
sectionFooters.forEach(footer => {
const section = footer.parentElement;
footer.addEventListener('click', () => {
if (section.classList.contains('closed')) {
section.classList.remove('closed');
} else {
section.classList.add('closed');
}
this.main._network.redraw();
});
});
}
/**
* Observer for the selection property.
* Sets selection text and shows details for currently selected item.
* @param {Object} selection Object containing arrays with selected item ids: `{ nodes, edges }`
* @memberof VcfNetworkInfoPanel
* @private
*/
_selectionChanged(selection) {
this._disableMenuButtons();
this._setSelectionText();
this.$['node-details'].classList.add('hidden');
this.$['node-form'].classList.add('hidden');
this.$['edge-details'].classList.add('hidden');
this.$['component-details'].classList.add('hidden');
this.$['save-button'].classList.add('hidden');
/* Set selection text class */
if (this.selectionText === 'No selection') {
this.$['selection'].classList.remove('active');
} else {
this.$['selection'].classList.add('active');
}
/* Show component, node, or edge details */
if (selection) {
if (selection.nodes.length === 1 && !selection.edges.length) {
this._selectedNode = this.main.data.nodes.get(selection.nodes[0]);
if (this._selectedNode.type === 'component') {
this._showComponentDetails();
} else {
this._showNodeDetails();
}
this.$['export-button'].disabled = false;
this.$['create-component-button'].disabled = false;
this.$['copy-button'].disabled = false;
this.$['delete-button'].disabled = false;
} else if (selection.nodes.length > 0) {
this.$['export-button'].disabled = false;
this.$['create-component-button'].disabled = false;
this.$['copy-button'].disabled = false;
this.$['delete-button'].disabled = false;
} else if (selection.edges.length === 1 && !selection.nodes.length) {
this._selectedEdge = this.main.data.edges.get(this.selection.edges[0]);
this._setEdgeDetails();
this.$['delete-button'].disabled = false;
} else {
this._selectedNode = null;
}
} else {
this._selectedNode = null;
}
}
_showComponentDetails() {
this.$['component-name'].value = this._selectedNode.label;
this.$['component-color'].value = String(this._selectedNode.componentColor);
this.$['component-id'].value = this._selectedNode.id;
this.$['component-id'].title = this._selectedNode.id;
this.$['component-x'].value = this._selectedNode.x;
this.$['component-y'].value = this._selectedNode.y;
this.$['component-details'].classList.remove('hidden');
}
_showNodeDetails() {
const evt = new CustomEvent('vcf-network-open-node-editor', {
detail: { id: this._selectedNode.id },
cancelable: true
});
const cancelled = !this.main.dispatchEvent(evt);
if (!cancelled) {
this.$['node-details'].classList.remove('hidden');
this.$['node-name'].value = this._selectedNode.label;
this.$['node-id'].value = this._selectedNode.id;
this.$['node-id'].title = this._selectedNode.id;
this.$['node-x'].value = this._selectedNode.x;
this.$['node-y'].value = this._selectedNode.y;
} else {
this.$['node-form'].classList.remove('hidden');
this.$['save-button'].classList.remove('hidden');
}
}
_setEdgeDetails() {
this.$['edge-id'].value = this._selectedEdge.id;
this.$['edge-from'].value = this._selectedEdge.from;
this.$['edge-to'].value = this._selectedEdge.to;
this.$['edge-details'].classList.remove('hidden');
}
_setSelectionText() {
let selectionText = '';
if (this.selection) {
const nodeCount = this.selection.nodes.length;
const edgeCount = this.selection.edges.length;
if (nodeCount) {
selectionText = nodeCount === 1 ? '1 node' : `${nodeCount} nodes`;
}
if (edgeCount) {
selectionText += selectionText && ', ';
selectionText += edgeCount === 1 ? '1 edge' : `${edgeCount} edges`;
}
selectionText += selectionText && ' selected';
}
this.selectionText = selectionText || 'No selection';
}
_exportComponent() {
const data = {
nodes: this._getSelectedNodes(),
edges: this._getSelectedEdges()
};
const removeUIProperties = data => {
data.nodes = data.nodes.map(node => {
let newNode = { ...node };
if (newNode.type === 'component') {
newNode = removeUIProperties(newNode);
}
return this._removeDisplayProperties(newNode);
});
data.edges = data.edges.map(edge => {
const newEdge = { ...edge };
if (newEdge.modelTo) {
delete newEdge.to;
delete newEdge.modelToPath;
}
if (newEdge.modelFrom) {
delete newEdge.from;
delete newEdge.modelFromPath;
}
return newEdge;
});
return this._removeDisplayProperties({ ...data });
};
let component;
if (this._selectedNode && this._selectedNode.type === 'component') {
component = this._selectedNode;
} else {
component = new ComponentNode(
removeUIProperties({
nodes: data.nodes.slice(),
edges: data.edges.slice()
})
);
this._createIONodes(component.nodes);
}
this.main._exportComponent = component;
this.main._exportNetwork = removeUIProperties(data);
this.main.$.exportdialog.open();
}
_createComponent() {
this.main.creatingComponent = true;
const evt = new CustomEvent('vcf-network-create-component', {
detail: { nodes: this.selection.nodes },
cancelable: true
});
const cancelled = !this.main.dispatchEvent(evt);
if (!cancelled) {
const nodeIds = this.selection.nodes;
const posNode = this.main.data.nodes.get(nodeIds[0]);
const nodes = this._getSelectedNodes();
const edges = this._getConnectedEdges(nodeIds);
this._createIONodes(nodes, nodeIds);
/* Create component node */
const newComponent = new ComponentNode({
label: this.main._getLabel('component'),
x: posNode.x,
y: posNode.y,
nodes,
edges
});
newComponent.edges.forEach(edge => {
if (!nodeIds.includes(edge.to) || !nodeIds.includes(edge.from)) {
newComponent.edges.splice(newComponent.edges.indexOf(edge), 1);
}
});
/* Update nodes */
this.main._removeFromDataSet('nodes', nodeIds);
this.main._addToDataSet('nodes', newComponent);
/* Update edges */
this.main._removeFromDataSet('edges', newComponent.edges);
/* Update IO */
const adjustPaths = component => {
component.nodes = component.nodes.map(node => {
if (node.type === 'component') {
node = adjustPaths(node);
if (node.inputs) {
Object.values(node.inputs).forEach(input => {
if (!this.main.context) {
input.forEach(pathObj => pathObj.path.unshift(newComponent.id));
} else {
input.forEach(pathObj => {
const contextComponentPathId = pathObj.path.indexOf(this.main.context.component.id);
pathObj.path.splice(contextComponentPathId + 1, 0, newComponent.id);
});
}
});
}
if (node.outputs) {
Object.values(node.outputs).forEach(output => {
if (!this.main.context) {
output.forEach(pathObj => pathObj.path.unshift(newComponent.id));
} else {
output.forEach(pathObj => {
const contextComponentPathId = pathObj.path.indexOf(this.main.context.component.id);
pathObj.path.splice(contextComponentPathId + 1, 0, newComponent.id);
});
}
});
}
}
return node;
});
component.edges = component.edges.map(edge => {
if (edge.modelToPath) edge.modelToPath.unshift(newComponent.id);
if (edge.modelFromPath) edge.modelFromPath.unshift(newComponent.id);
return edge;
});
return component;
};
adjustPaths(newComponent);
/* Finish creating component */
this.main.creatingComponent = false;
}
}
_createIONodes(nodes, nodeIds) {
/* Generate I/O nodes */
const first = nodes[0];
let x0 = first.x;
let x1 = first.x;
let y0 = first.y;
let y1 = first.y;
let hasInput = false;
let hasOutput = false;
nodes.forEach((node, i) => {
if (node.x < x0) x0 = node.x;
if (node.x > x1) x1 = node.x;
if (node.y < y0) y0 = node.y;
if (node.y > y1) y1 = node.y;
if (node.type === 'input') hasInput = true;
if (node.type === 'output') hasOutput = true;
});
if (!hasInput) {
const inputNode = new IONode({
type: 'input',
label: this.main._getLabel('input'),
x: x0 - 100,
y: y0 + (y1 - y0) / 2
});
nodes.push(inputNode);
if (nodeIds) nodeIds.push(inputNode.id);
}
if (!hasOutput) {
const outputNode = new IONode({
type: 'output',
label: this.main._getLabel('output'),
x: x1 + 100,
y: y0 + (y1 - y0) / 2
});
nodes.push(outputNode);
if (nodeIds) nodeIds.push(outputNode.id);
}
return nodes;
}
_updateNode() {
const label = this.$['node-name'].value;
this.main._updateDataSet('nodes', {
...this._selectedNode,
label
});
}
_updateComponent() {
const componentColor = Number.parseInt(this.$['component-color'].value);
const label = this.$['component-name'].value;
const componentStyles = ComponentNode.getComponentNodeStyles(componentColor);
this.main._updateDataSet('nodes', {
...this._selectedNode,
label,
componentColor,
...componentStyles
});
}
_updateCoords(opt) {
const nodeId = opt.nodes[0];
const bodyNode = this.main._network.body.nodes[nodeId];
const datasetNode = this.main.data.nodes.get(nodeId);
if (datasetNode !== this._selectedNode) this.selection = opt;
const x = Number.parseInt(bodyNode.x);
const y = Number.parseInt(bodyNode.y);
clearTimeout(this._updateCoordsTimeout);
this._updateCoordsTimeout = setTimeout(() => {
this.main._updateDataSet('nodes', { ...datasetNode, x, y });
}, 200);
this._refreshCoords(opt, x, y);
}
_refreshCoords(opt, x, y) {
const node = this.main._network.body.nodes[opt.nodes[0]];
if (node.options.type === 'component') {
this.$['component-x'].value = x;
this.$['component-y'].value = y;
} else {
this.$['node-x'].value = x;
this.$['node-y'].value = y;
}
}
_getSelectedNodes() {
return this.selection.nodes.map(id => this.main.data.nodes.get(id));
}
_getSelectedEdges() {
return this.selection.edges.map(id => this.main.data.edges.get(id));
}
_getConnectedEdges(nodeIds) {
/* create set of unique connected egde ids */
const edgeIdSet = new Set();
nodeIds.forEach(node => {
this.main._network.getConnectedEdges(node).forEach(edge => {
edgeIdSet.add(edge);
});
});
/* create array of edge objects from set */
const edges = [...edgeIdSet].map(id => this.main.data.edges.get(id));
return edges;
}
_removeDisplayProperties(node) {
const newNode = { ...node };
delete newNode.isRoot;
delete newNode.color;
delete newNode.margin;
delete newNode.shapeProperties;
delete newNode.font;
delete newNode.inputs;
delete newNode.outputs;
return newNode;
}
_deleteSelected() {
this.main._removeFromDataSet('nodes', this.selection.nodes);
this.main._removeFromDataSet('edges', this.selection.edges);
this.selection = { nodes: [], edges: [] };
}
_saveSelected() {
const evt = new CustomEvent('vcf-network-save-node-editor', { cancelable: true });
const cancelled = !this.main.dispatchEvent(evt);
if (!cancelled) {
// TODO
}
}
_addCopy() {
const nodes = this._copyCache.nodes.map(id => this.main.data.nodes.get(id));
const edges = this._copyCache.edges.map(id => this.main.data.edges.get(id));
const dataCopy = this._setUUIDs({ nodes, edges });
dataCopy.nodes.forEach(node => {
node.x = node.x + 50;
node.y = node.y + 50;
});
this.main.addNodes(dataCopy.nodes);
this.main.addEdges(dataCopy.edges);
const nodeIds = dataCopy.nodes.map(i => i.id);
const edgeIds = dataCopy.edges.map(i => i.id);
setTimeout(() => this.main.select({ nodeIds, edgeIds }));
}
closePanel() {
this.$['info-panel'].classList.add('closed');
}
openPanel() {
this.$['info-panel'].classList.remove('closed');
}
_disableMenuButtons() {
this.$['create-component-button'].disabled = true;
this.$['export-button'].disabled = true;
this.$['copy-button'].disabled = true;
this.$['delete-button'].disabled = true;
}
_setUUIDs(data) {
const json = JSON.stringify(data);
const idMap = {};
const idRegex = /"id":"([a-f\d-]+)"|"([a-f\d-]+)":/g;
const pathRegex = /\[([a-f\d-",]+)\]/g;
const fromToRegex = /[Ff]rom":"([a-f\d-]+)"|[Tt]o":"([a-f\d-]+)"/g;
let idMatches;
let pathMatches;
let fromToMatches;
let parsed = json.slice();
/* Replace node and edge template ids */
while ((idMatches = idRegex.exec(json)) !== null) {
const match = idMatches[0];
const templateId = idMatches[1] || idMatches[2];
const uuid = idMap[templateId] || vis.util.randomUUID();
const replaceString = uuid;
const uuidString = match.replace(templateId, replaceString);
parsed = parsed.replace(match, uuidString);
if (!idMap[templateId]) idMap[templateId] = uuid;
}
/* Replace edge from and to template ids */
while ((fromToMatches = fromToRegex.exec(json)) !== null) {
const match = fromToMatches[0];
const isFrom = match.includes('from') || match.includes('From');
const templateId = isFrom ? fromToMatches[1] : fromToMatches[2];
const uuid = idMap[templateId];
const uuidString = match.replace(templateId, uuid);
parsed = parsed.replace(match, uuidString);
}
/* Replace input/output path template ids */
while ((pathMatches = pathRegex.exec(json)) !== null) {
const match = pathMatches[0];
let templateIds = pathMatches[1];
templateIds = templateIds.substring(1, templateIds.length - 1);
const uuids = templateIds
.split(',')
.map(id => idMap[id])
.join(',');
const uuidString = match.replace(templateIds, uuids);
parsed = parsed.replace(match, uuidString);
}
return JSON.parse(parsed);
}
}
customElements.define(VcfNetworkInfoPanel.is, VcfNetworkInfoPanel);