blob: 2b9ecb58a7b647bdde7fc52ee1f3f88a65653e9c [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as d3 from 'd3';
import {PREDICATE_MEMBERSPEC} from './model/entity.model';
import addIcon from '../../img/icon-add.svg';
import {ISSUE_LEVEL} from './model/issue.model';
export function D3Blueprint(container) {
let _svg = d3.select(container).append('svg').attr('class', 'blueprint-canvas');
let _mirror = _svg.append('path').style('display', 'none');
let _zoomGroup = _svg.append('g').attr('class', 'zoom-group');
let _parentGroup = _zoomGroup.append('g').attr('class', 'parent-group');
let _linkGroup = _parentGroup.append('g').attr('class', 'link-group');
let _relationGroup = _parentGroup.append('g').attr('class', 'relation-group');
let _specNodeGroup = _parentGroup.append('g').attr('class', 'spec-node-group');
let _dropZoneGroup = _parentGroup.append('g').attr('class', 'dropzone-group');
let _ghostNodeGroup = _parentGroup.append('g').attr('class', 'ghost-node-group');
let _nodeGroup = _parentGroup.append('g').attr('class', 'node-group');
let _cloneGroup = _parentGroup.append('g').attr('class', 'clone-group');
let _dragState = {
dragInProgress: false,
dragStarted: false,
clone: null,
cloneX: 0,
cloneY: 0,
};
const _configHolder = {
nodes: {
root: {
rect: {
class: 'node-root',
x: -125,
y: -50,
width: 250,
height: 100,
rx: 50,
ry: 50,
},
text: {
class: 'node-name',
width: 250,
height: 100
},
maxNameLength: 18
},
child: {
circle: {
r: 50,
class: (d)=>(`node-cluster node-cluster-${d}`)
},
image: {
class: 'node-icon',
width: 64,
height: 64,
x: -32,
y: -32,
opacity: 0
}
},
location: {
rect: {
x: -50,
y: -110,
width: 100,
height: 50
},
image: {
x: -50,
y: -110,
width: 100,
height: 50,
opacity: 0
}
},
dropzonePrev: {
circle: {
cx: -150,
r: 30,
class: 'dropzone dropzone-prev'
},
},
dropzoneNext: {
circle: {
cx: 150,
r: 30,
class: 'dropzone dropzone-next'
}
},
adjunct: {
rect: {
id: (d)=>(`entity-${d._id}`),
class: 'node-adjunct adjunct entity',
width: 20,
height: 20,
transform: 'scale(0)'
}
},
memberspec: {
circle: {
r: 35,
cx: 0,
cy: 170,
class: 'node-spec-entity',
'transform-origin': 0
},
image: {
x: -20,
y: 150,
width: 40,
height: 40,
opacity: 0,
class: 'node-spec-image',
'transform-origin': 0
}
},
buttongroup: {
line: {
class: 'link',
x1: 0,
x2: 0,
y1: (d)=>(isRootNode(d) ? _configHolder.nodes.root.rect.height / 2 : _configHolder.nodes.child.circle.r),
y2: (d)=>((isRootNode(d) ? _configHolder.nodes.root.rect.height / 2 : _configHolder.nodes.child.circle.r) + 30),
},
circle: {
class: 'connector',
r: 6,
cy: (d)=>(isRootNode(d) ? _configHolder.nodes.root.rect.height / 2 : _configHolder.nodes.child.circle.r),
}
},
buttonAdd: {
circle: {
r: 20,
cy: 100
},
image: {
width: 50,
height: 50,
x: -25,
y: 75,
'xlink:href': addIcon
}
}
},
transition: 300,
grid: {
itemPerCol: 3,
gutter: 15
},
};
let _d3DataHolder = {
nodes: [],
ghostNodes: [],
orphans: [],
links: [],
relationships: [],
};
let viewportWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
let zoom = d3.zoom().scaleExtent([0.1, Math.max(1, 1 + Math.log(viewportWidth/1024))]).on('zoom', onSvgZoom);
_svg
.attr('preserveAspectRatio', 'xMinYMin meet')
.attr('viewBox', () => {
return `0 0 ${parseInt(_svg.style('width'))} ${parseInt(_svg.style('height'))}`;
})
.on('click', onSvgClick)
.on('dragover', onSvgDragOver)
.on('dragleave', onSvgDragLeave)
.call(zoom);
let pattern = _svg.append('pattern')
.attr('id', 'fill-has-issues')
.attr('width', 4)
.attr('height', 4)
.attr('patternUnits', 'userSpaceOnUse');
pattern.append('rect')
.attr('width', 4)
.attr('height', 4);
pattern.append('path')
.attr('d', 'M1 3h1v1H1V3zm2-2h1v1H3V1z');
let defs = _svg.append('defs');
let arrowhead = defs.append('marker')
.attr('id', 'arrowhead')
.attr('orient', 'auto')
.attr('markerWidth', 10)
.attr('markerHeight', 20)
.attr('markerUnits', 'userSpaceOnUse')
.attr('refX', 0)
.attr('refY', 10);
arrowhead.append('path')
.attr('d', 'M0,0 V20 L10,10 Z')
.attr('class', 'arrowhead');
let arrowheadHighlight = defs.append('marker')
.attr('id', 'arrowhead-highlight')
.attr('orient', 'auto')
.attr('markerWidth', 10)
.attr('markerHeight', 20)
.attr('markerUnits', 'userSpaceOnUse')
.attr('refX', 0)
.attr('refY', 10);
arrowheadHighlight.append('path')
.attr('d', 'M0,0 V20 L10,10 Z')
.attr('class', 'arrowhead-highlight');
/*****************************
** EVENT HANDLERS :: START **
*****************************/
/**
* Handles translation and scaling of the zoom group
*/
function onSvgZoom() {
_zoomGroup.attr('transform', d3.event.transform);
}
/**
* Fires a custom event "click-svg" when the use clicks anywhere on the canvas, except nodes.
*/
function onSvgClick() {
if (d3.event.defaultPrevented) return;
let event = new CustomEvent('click-svg', {});
container.dispatchEvent(event);
}
/**
* Applies the "is-dragging" class to the canvas when a drag is initiated.
*/
function onSvgDragOver() {
_svg.classed('is-dragging', true);
if (d3.event.dataTransfer && d3.event.dataTransfer.types.indexOf('entity') === -1) {
_dropZoneGroup.selectAll('.dropzone-prev').classed('hidden', true);
_dropZoneGroup.selectAll('.dropzone-next').classed('hidden', true);
}
}
/**
* Removes the "is-dragging" class from the canvas when a drag is finished.
*/
function onSvgDragLeave() {
_svg.classed('is-dragging', false);
if (d3.event.dataTransfer && d3.event.dataTransfer.types.indexOf('entity') === -1) {
_dropZoneGroup.selectAll('.dropzone-prev').classed('hidden', false);
_dropZoneGroup.selectAll('.dropzone-next').classed('hidden', false);
}
}
/**
* Mouse Enter Event Handler
*
* @param {object} node The graph node the mouse is over
*/
function onGhostOver(node) {
d3.select(`#ghost-node-${node.data._id} g.buttons`).classed('active', true);
// show whole group not just buttons
}
/**
* Mouse Leave Event Handler
*
* @param {object} node The graph node the mouse just left
*/
function onGhostLeave(node) {
d3.select(`#ghost-node-${node.data._id} g.buttons`).classed('active', false);
}
/**
* Fires a custom event "click-entity" when a graph node is clicked.
*
* @param {object} node The node for the clicked entity
*/
function onEntityClick(node) {
if (d3.event.defaultPrevented) return;
d3.event.stopPropagation();
let event = new CustomEvent('click-entity', {
detail: {
entity: node.data || node,
}
});
container.dispatchEvent(event);
}
/**
* Fires a custom event "click-add-child" when the plus button is clicked.
*
* @param {object} node The node for the entity to add a child to
*/
function onAddChildClick(node) {
d3.event.stopPropagation();
let event = new CustomEvent('click-add-child', {
detail: {
entity: node.data,
}
});
container.dispatchEvent(event);
}
/**
* Triggered when a key is release on the page.
*
* * Ignores where key press did not originate from the page body (i.e. ignores input to text fields)
* * Fires a custom event "delete-entity" when the delete key is pressed.
*/
function onKeyUp() {
d3.event.stopPropagation();
if (d3.event.target.nodeName == 'BODY') {
if (d3.event.key === "Delete" || d3.event.key === "Backspace") {
var selected = _svg.selectAll('.entity.selected');
var nItemsSelected = selected._groups[0].length;
if (nItemsSelected > 0) {
let event = new CustomEvent("delete-entity", {
detail: {
entity: selected.data()[0].data,
}
});
container.dispatchEvent(event);
}
}
}
}
/**
* Handles the start of a drag operation. Note that this callback is to be used with the internal D3 drag feature.
*
* @param node The node for the dragged entity
*/
function onDragStart(node) {
if (_dragState.clone) {
_dragState.clone.remove();
_dragState.clone = null;
}
if (node.depth) { // exclude the root element
onSvgDragOver();
hideInvalidDropzones(node);
d3.event.sourceEvent.preventDefault(); // disable browser text selection
d3.event.sourceEvent.stopPropagation();
_dragState.dragInProgress = true;
// at this point, we still don't know if this will be a click or a real drag
// so we defer the visual effects to the first 'dragging' event
_dragState.dragStarted = true;
let entityId = node.data._id;
let mouseCoords = d3.mouse(_nodeGroup.select(`#entity-${node.data._id}`).node());
_dragState.cloneX = node.x + _configHolder.nodes.child.circle.r + mouseCoords[0];
_dragState.cloneY = node.y + _configHolder.nodes.child.circle.r + mouseCoords[1];
_dragState.clone = _cloneGroup.append('use')
.attr('xlink:href', function(d) {
return `#entity-${entityId}` } )
.attr('opacity', 0)
.attr('transform', (d)=>(`translate(${_dragState.cloneX}, ${_dragState.cloneY})`));
}
}
/**
* Handles the dragging operation. Note that this callback is to be used with the internal D3 drag feature.
*
* @param node The node for the dragged entity
*/
function onDrag(node) {
if (_dragState.dragInProgress) {
if (_dragState.dragStarted) {
// deferred initialization of visual effects
_dragState.dragStarted = false;
hideRelationships(node);
_dragState.clone.attr('opacity', 0.5);
}
if (_dragState.clone) {
_dragState.cloneX += d3.event.dx;
_dragState.cloneY += d3.event.dy;
_dragState.clone.attr('transform', (d)=>(`translate(${_dragState.cloneX}, ${_dragState.cloneY})`));
}
}
}
/**
* Fires a custom event "move-entity" when an graph node has been dragged and dropped in a valid dropzone.
* Note that this callback is to be used with the internal D3 drag feature.
*
* @param node The node for the dragged entity
*/
function onDragEnd(node) {
if (_dragState.dragInProgress) {
_dragState.dragInProgress = false;
// Firefox support (target)
let dropZone = d3.event.sourceEvent.toElement ? d3.event.sourceEvent.toElement : d3.event.sourceEvent.target;
if (['node-root', 'node-name', 'node-icon', 'node-cluster'].some(className => dropZone.classList.contains(className))) {
dropZone = dropZone.parentElement;
}
if (dropZone && dropZone.classList.contains('dropzone')) {
let parentId = dropZone.getAttribute('parentId');
let dropzoneId = dropZone.classList.contains('dropzone-next') || dropZone.classList.contains('dropzone-prev')
? dropZone.id
: `dropzone-self-${parentId}`;
onDragLeave(node, dropzoneId);
if (node.data._id !== parentId) {
let event = new CustomEvent('move-entity', {
detail: {
nodeId: node.data._id,
parentId: parentId,
targetIndex: dropZone.getAttribute('targetIndex'),
},
});
container.dispatchEvent(event);
}
} else {
setTimeout(() => {
showRelationships();
showDropzones();
}, _configHolder.transition);
}
}
if (_dragState.clone) {
_dragState.clone.remove();
_dragState.clone = null;
}
onSvgDragLeave();
}
/**
* Applies the class "active" to the currently hovered dropzone, or the dropzone with the optional given ID.
*
* @param {*} node the graph node the event refers to
* @param {String} id Optional Id of the dropzone
*/
function onDragOver(node, id) {
if (_dragState.dragInProgress || (d3.event && d3.event.type === 'dragover')) {
if (!id) {
id = (d3.event.sourceEvent ? d3.event.sourceEvent : d3.event).target.id;
}
if (id) {
_dropZoneGroup.select(`#${id}`).classed('active', true)
}
} else {
onGhostOver(node);
}
}
/**
* Removes the class "active" from the previously hovered dropzone, or the dropzone with the optional given ID.
*
* @param {*} node the graph node the event refers to
* @param {String} id Optional Id of the dropzone
*/
function onDragLeave(node, id) {
if (_dragState.dragInProgress ||
(d3.event && (['end','drop','dragleave'].includes(d3.event.type)))) {
if (!id) {
id = (d3.event.sourceEvent ? d3.event.sourceEvent : d3.event).target.id;
}
if (id) {
_dropZoneGroup.select(`#${id}`).classed('active', false)
}
} else {
onGhostLeave(node);
}
}
/**
* Fires a custom event "drop-node" when an external node has been dragged and dropped in a valid dropzone.
* Note that this callback is to be used with the external drag feature, i.e. HTML5
*
* @param {String} id Optional Id of the dropzone
*/
function onExternalDrop(node, id) {
let dropZone = d3.event.toElement ? d3.event.toElement : d3.event.target;
if (['node-root', 'node-name', 'node-icon', 'node-cluster'].some(className => dropZone.classList.contains(className))) {
dropZone = dropZone.parentElement;
}
if (dropZone && dropZone.classList.contains('dropzone')) {
onDragLeave(node, id);
let event = new CustomEvent('drop-external-node', {
detail: {
parentId: dropZone.getAttribute('parentId'),
targetIndex: dropZone.getAttribute('targetIndex'),
},
});
container.dispatchEvent(event);
}
onSvgDragLeave();
}
/***************************
** EVENT HANDLERS :: END **
***************************/
/**
* Update the graph data
*
* @param {object} blueprint The graph
*/
function update(blueprint, relationships) {
let tree = d3.tree()
.nodeSize([_configHolder.nodes.child.circle.r * 6, _configHolder.nodes.child.circle.r * 6])
.separation((right, left)=> {
let maxColumnsBeforeExpand = 2;
let adjuncts = getImportantAdjuncts(left).length;
let currentCols = Math.floor(adjuncts / _configHolder.grid.itemPerCol) + (adjuncts > 0 && adjuncts % _configHolder.grid.itemPerCol !== 0 ? 1 : 0);
let additionalCol = currentCols > maxColumnsBeforeExpand ? currentCols - maxColumnsBeforeExpand : 0;
let colWidth = _configHolder.nodes.adjunct.rect.width + 15;
return 1 + (colWidth / (_configHolder.nodes.child.circle.r * 6)) * additionalCol;
});
let root = d3.hierarchy(blueprint);
tree(root);
_d3DataHolder.nodes = root.descendants();
_d3DataHolder.links = root.links();
_d3DataHolder.relationships = relationships;
return this;
}
/**
* Redraw the graph
*/
function draw() {
drawLinks();
drawRelationships();
drawNodeGroup();
drawSpecNodeGroup();
drawGhostNode();
drawDropZoneGroup();
return this;
}
function drawNodeGroup() {
let nodeData = _nodeGroup.selectAll('g.node')
.data(_d3DataHolder.nodes, (d)=>(`node-${d.data._id}`));
// Draw group that contains all SVG element: node representation and location/policies/enricher indicators
// -----------------------------------------------------
let nodeGroup = nodeData
.enter()
.append('g')
.attr('id', (d)=>(`node-group-${d.data._id}`))
.attr('class', 'node')
.classed('node-root', isRootNode)
.classed('node-child', isChildNode)
.attr('transform', (d)=>(`translate(${d.x}, ${d.y}) scale(${isRootNode(d) ? 1 : 0})`))
.attr('opacity', (d)=> (isRootNode(d) ? 0 : 1));
nodeData.transition()
.duration(_configHolder.transition)
.attr('transform', (d)=>(`translate(${d.x}, ${d.y}) scale(1)`))
.attr('opacity', 1);
nodeData.exit()
.transition()
.duration(_configHolder.transition)
.attr('transform', (d)=>(`translate(${d.x}, ${d.y}) scale(${isRootNode(d) ? 1 : 0})`))
.attr('opacity', (d)=> (isRootNode(d) ? 0 : 1))
.remove();
// Draw the node-entity group that will contain the node representation
// -----------------------------------------------------
let entity = nodeGroup.append('g')
.attr('class', 'node-entity entity')
.on('click', onEntityClick)
.on('mouseenter', onGhostOver)
.on('mouseleave', onGhostLeave)
.call(d3.drag()
.on('start', onDragStart)
.on('drag', onDrag)
.on('end', onDragEnd));
nodeData.select('g.node-entity')
.attr('id', (d)=>(`entity-${d.data._id}`))
.classed('clustered', (d)=>(d.data.isCluster()))
.classed('has-warnings', (d)=>(d.data.hasIssues() && d.data.issues.some(issue => issue.level === ISSUE_LEVEL.WARN)))
.classed('has-errors', (d)=>(d.data.hasIssues() && d.data.issues.some(issue => issue.level === ISSUE_LEVEL.ERROR)))
.classed('loading', (d)=>(d.data.miscData.get('loading')));
// Draw root node
appendElements(entity.filter(isRootNode), _configHolder.nodes.root);
nodeData.filter(isRootNode).select('.node-entity text')
.text(trimNodeText)
.transition()
.duration(_configHolder.transition)
.text(trimNodeText);
nodeData.filter(isChildNode).select('.node-entity image')
.transition()
.duration(_configHolder.transition)
.attr('opacity', (d)=>(d.data.hasIcon() ? 1 : 0))
.attr('xlink:href', (d)=>(d.data.icon));
// Draw child nodes
appendElement(entity.filter(isChildNode).selectAll('circle').data([2, 1, 0]).enter(), 'circle', _configHolder.nodes.child.circle);
appendElement(entity.filter(isChildNode), 'image', _configHolder.nodes.child.image);
// Draw location
// -----------------------------------------------------
let location = nodeGroup.append('g')
.attr('class', 'node-location')
.classed('loading', (d)=>(d.data.miscData.get('loading')));
nodeData.select('g.node-location')
.transition()
.duration(_configHolder.transition)
.attr('opacity', (d)=>(d.data.hasLocation() ? 1 : 0));
appendElements(location, _configHolder.nodes.location);
nodeData.select('g.node-location image')
.transition()
.duration(_configHolder.transition)
.attr('opacity', (d)=>(d.data.miscData.get('locationIcon') ? 1 : 0));
nodeData.select('g.node-location image')
.attr('xlink:href', (d)=>d.data.miscData.get('locationIcon'));
// Draw important adjuncts (i.e policies/enrichers)
// -----------------------------------------------------
nodeGroup.append('g')
.attr('class', 'node-adjuncts');
let adjunctData = nodeData.select('g.node-adjuncts')
.selectAll('rect.adjunct')
.data((d)=>(getImportantAdjuncts(d)), (d)=>(`adjunct-${d._id}`));
adjunctData
.classed('has-warnings', (d)=>(d.hasIssues() && d.issues.some(issue => issue.level === ISSUE_LEVEL.WARN)))
.classed('has-errors', (d)=>(d.hasIssues() && d.issues.some(issue => issue.level === ISSUE_LEVEL.ERROR)))
.classed('loading', (d)=>(d.miscData.get('loading')))
.on('click', onEntityClick);
adjunctData.transition()
.duration(_configHolder.transition)
.attr('x', (d, i)=>(getGridX(d, i)))
.attr('y', (d, i)=>(getGridY(d, i)))
.attr('transform', 'scale(1)')
.attr('transform-origin', (d, i)=>(getGridItemCenter(d, i)));
adjunctData.exit()
.transition()
.duration(_configHolder.transition)
.attr('transform', 'scale(0)')
.remove();
appendElement(adjunctData.enter(), 'rect', _configHolder.nodes.adjunct.rect);
}
function drawLinks() {
let link = _linkGroup.selectAll('line.link')
.data(_d3DataHolder.links, (d)=>(d.source.data._id + '_to_' + d.target.data._id));
link.enter().insert('line')
.attr('class', 'link')
.attr('x1', (d)=>(d.source.x))
.attr('y1', (d)=>(d.source.y))
.attr('x2', (d)=>(d.source.x))
.attr('y2', (d)=>(d.source.y));
link.transition()
.duration(_configHolder.transition)
.attr('x1', (d)=>(d.source.x))
.attr('y1', (d)=>(d.source.y))
.attr('x2', (d)=>(d.target.x))
.attr('y2', (d)=>(d.target.y));
link.exit()
.transition()
.attr('opacity', 0)
.remove();
}
/**
* returns the D3 tree node for a given Entity
* @param {Entity} entity
* @return {*} a D3 tree node
*/
function nodeForEntity(entity) {
let node = _d3DataHolder.nodes.find(d => {
let predicate = d.data._id === entity._id;
if (!!d.data.getClusterMemberspecEntity(PREDICATE_MEMBERSPEC)) {
predicate |= d.data.getClusterMemberspecEntity(PREDICATE_MEMBERSPEC)._id === entity._id;
}
return predicate;
});
if (!node) {
throw new Error('Node for Entity ' + entity._id + ' not found');
}
return node;
}
function drawRelationships() {
showRelationships();
let relationData = _relationGroup.selectAll('.relation')
.data(_d3DataHolder.relationships, (d)=>(d.source._id + '_related_to_' + d.target._id));
relationData.enter().insert('path')
.attr('class', 'relation')
.attr('opacity', 0)
.attr('from', (d)=>(d.source._id))
.attr('to', (d)=>(d.target._id));
relationData.transition()
.duration(_configHolder.transition)
.attr('opacity', 1)
.attr('stroke', 'red')
.attr('d', function(d) {
let targetNode = nodeForEntity(d.target);
let sourceNode = nodeForEntity(d.source);
let sourceY = sourceNode.y + (d.source.isMemberSpec() ? _configHolder.nodes.memberspec.circle.cy : 0);
let targetY = targetNode.y + (d.target.isMemberSpec() ? _configHolder.nodes.memberspec.circle.cy : 0);
let dx = targetNode.x - sourceNode.x;
let dy = targetY - sourceY;
let dr = Math.sqrt(dx * dx + dy * dy);
let sweep = dx * dy > 0 ? 0 : 1;
_mirror.attr('d', `M ${sourceNode.x},${sourceY} A ${dr},${dr} 0 0,${sweep} ${targetNode.x},${targetY}`);
let m = _mirror._groups[0][0].getPointAtLength(_mirror._groups[0][0].getTotalLength() - _configHolder.nodes.child.circle.r - 20);
dx = m.x - sourceNode.x;
dy = m.y - sourceY;
dr = Math.sqrt(dx * dx + dy * dy);
return `M ${sourceNode.x},${sourceY} A ${dr},${dr} 0 0,${sweep} ${m.x},${m.y}`;
});
relationData.exit()
.transition()
.duration(_configHolder.transition)
.attr('opacity', 0)
.remove();
}
function drawGhostNode() {
let ghostNodeData = _ghostNodeGroup.selectAll('g.ghost-node')
.data(_d3DataHolder.nodes, (d)=>(`ghost-node-${d.data._id}`));
let ghostNode = ghostNodeData
.enter()
.append('g')
.attr('id', (d)=>(`ghost-node-${d.data._id}`))
.attr('class', 'ghost-node')
.attr('transform', (d)=>(`translate(${d.x}, ${d.y})`))
.on('mouseenter', onGhostOver)
.on('mouseleave', onGhostLeave);
ghostNodeData
.transition()
.duration(_configHolder.transition)
.attr('transform', (d)=>(`translate(${d.x}, ${d.y})`));
ghostNodeData.exit().remove();
ghostNode.append('rect')
.attr('class', 'ghost')
.attr('width', (d)=>(isRootNode(d) ? _configHolder.nodes.root.rect.width : _configHolder.nodes.child.circle.r * 2))
.attr('height', (d)=>((isRootNode(d) ? _configHolder.nodes.root.rect.height : _configHolder.nodes.child.circle.r * 2) + 80))
.attr('x', (d)=>(isRootNode(d) ? _configHolder.nodes.root.rect.x : -_configHolder.nodes.child.circle.r))
.attr('y', (d)=>(isRootNode(d) ? _configHolder.nodes.root.rect.y : -_configHolder.nodes.child.circle.r));
let buttonsGroup = ghostNode.append('g')
.attr('class', 'buttons');
appendElements(buttonsGroup, _configHolder.nodes.buttongroup);
let buttonAdd = buttonsGroup.append('g')
.attr('class', 'button button-add')
.on('click', onAddChildClick);
appendElements(buttonAdd, _configHolder.nodes.buttonAdd);
}
function drawDropZoneGroup() {
showDropzones();
let dropZoneData = _dropZoneGroup.selectAll('g.dropzone-group-node')
.data(_d3DataHolder.nodes, (d)=>(`dropzone-${d.data._id}`));
let dropZoneGroup = dropZoneData
.enter()
.append('g')
.attr('id', (d)=>(`dropzone-group-node-${d.data._id}`))
.attr('class', 'dropzone-group-node')
.attr('transform', (d)=>(`translate(${d.x}, ${d.y})`));
dropZoneData
.transition()
.duration(_configHolder.transition)
.attr('transform', (d)=>(`translate(${d.x}, ${d.y})`));
dropZoneData.exit().remove();
appendElement(dropZoneGroup.filter(isRootNode), 'rect', Object.assign({},
_configHolder.nodes.root.rect,
// expand above by 7
{x: -132, y: -57, rx: 57, ry: 57, width: 264, height: 114, class: 'dropzone dropzone-self'}));
appendElement(dropZoneGroup.filter(isChildNode), 'circle', Object.assign({},
_configHolder.nodes.child.circle,
{transform: (d) => (`scale(${d.data.isCluster() ? 1.5 : 1.15})`), class: 'dropzone dropzone-self'}));
appendElements(dropZoneGroup.filter(isChildNode), _configHolder.nodes.dropzonePrev);
appendElements(dropZoneGroup.filter(isChildNode), _configHolder.nodes.dropzoneNext);
dropZoneData.select('.dropzone-self')
.attr('id', (d)=>(`dropzone-self-${d.data._id}`))
.attr('parentId', (d) => (d.data._id))
.attr('targetIndex', -1);
dropZoneData.select('.dropzone-prev')
.attr('id', (d)=>(`dropzone-prev-${d.data._id}`))
.attr('parentId', (d) => (d.data.parent ? d.data.parent._id : ''))
.attr('targetIndex', (d) => (d.data.parent ? d.data.parent.children.indexOf(d.data) : -1));
dropZoneData.select('.dropzone-next')
.attr('id', (d)=>(`dropzone-next-${d.data._id}`))
.attr('parentId', (d) => (d.data.parent ? d.data.parent._id : ''))
.attr('targetIndex', (d) => (d.data.parent ? d.data.parent.children.indexOf(d.data) + 1 : -1));
dropZoneData.selectAll('.dropzone')
// D3 drag
.on('mouseenter', (d) => onDragOver(d))
.on('mouseleave', (d) => onDragLeave(d))
// Palette drag
.on('dragover', (d) => {
// We prevent the default to mark this dropzone as valid. Not doing so means that the "drop" event won't be fired.
d3.event.preventDefault();
onDragOver(d);
})
.on('dragleave', (d) => onDragLeave(d))
.on('drop', (d) => onExternalDrop(d));
_nodeGroup.selectAll('.node-entity')
.classed('dropzone', true)
.attr('parentId', (d)=>(d.data._id))
.attr('targetIndex', -1)
// D3 drag
.on('mouseenter', (d) => (onDragOver(d, `dropzone-self-${d.data._id}`)))
.on('mouseleave', (d) => (onDragLeave(d, `dropzone-self-${d.data._id}`)))
// Palette drag
.on('dragover', (d) => {
// We prevent the default to mark this dropzone as valid. Not doing so means that the "drop" event won't be fired.
d3.event.preventDefault();
onDragOver(d, `dropzone-self-${d.data._id}`);
})
.on('dragleave', (d) => (onDragLeave(d, `dropzone-self-${d.data._id}`)))
.on('drop', (d) => {
// Prevent the default to stop Firefox from navigating to the icon for the dropped entity.
d3.event.preventDefault();
onExternalDrop(d, `dropzone-self-${d.data._id}`)
});
}
function drawSpecNodeGroup() {
let specNodeData = _specNodeGroup.selectAll('g.spec-node')
.data(_d3DataHolder.nodes.filter((node)=>{
return !!node.data.getClusterMemberspecEntity(PREDICATE_MEMBERSPEC);
}), (d)=>(`spec-node-${d.data._id}`));
let specNodeGroup = specNodeData
.enter()
.append('g')
.attr('id', (d)=>(`spec-node-${d.data.getClusterMemberspecEntity(PREDICATE_MEMBERSPEC)._id}`))
.attr('class', 'spec-node')
.attr('transform', (d)=>(`translate(${d.x}, ${d.y})`));
specNodeData.transition()
.duration(_configHolder.transition)
.attr('transform', (d)=>(`translate(${d.x}, ${d.y}) rotate(${d.data.hasChildren() ? -45 : 0})`));
specNodeData.exit()
.transition()
.duration(_configHolder.transition)
.attr('opacity', 0)
.remove();
specNodeGroup.append('polygon')
.attr('class', 'node-memberspec-link')
.attr('points', (d)=> {
let left = _configHolder.nodes.memberspec.circle.r * -1;
let right = _configHolder.nodes.memberspec.circle.r;
let bottom = _configHolder.nodes.memberspec.circle.cy;
return `0,0 ${right},${bottom} ${left},${bottom}`;
})
.attr('transform', 'scale(0)');
specNodeData.select('polygon')
.transition()
.duration(_configHolder.transition)
.attr('transform', 'scale(1)');
let specNode = specNodeGroup.append('g')
.attr('class', 'node-memberspec entity')
.attr('id', (d)=>(`entity-${d.data.getClusterMemberspecEntity(PREDICATE_MEMBERSPEC)._id}`))
.attr('transform-origin', `0 ${_configHolder.nodes.memberspec.circle.cy}`)
.attr('transform', 'scale(0)')
.on('click', (d)=>(onEntityClick({data: d.data.getClusterMemberspecEntity(PREDICATE_MEMBERSPEC)})));
specNodeData.select('.node-memberspec')
.classed('has-issues', (d)=>(d.data.getClusterMemberspecEntity(PREDICATE_MEMBERSPEC).hasIssues()))
.classed('loading', (d)=>(d.data.getClusterMemberspecEntity(PREDICATE_MEMBERSPEC).miscData.get('loading')));
specNodeData.select('.node-memberspec')
.transition()
.duration(_configHolder.transition)
.attr('transform', 'scale(1)');
appendElements(specNode, _configHolder.nodes.memberspec);
specNodeData.select('image')
.transition()
.duration(_configHolder.transition)
.attr('opacity', (d)=>(d.data.getClusterMemberspecEntity(PREDICATE_MEMBERSPEC).hasIcon() ? 1 : 0))
.attr('xlink:href', (d)=>(d.data.getClusterMemberspecEntity(PREDICATE_MEMBERSPEC).icon));
}
function appendElements(node, definition) {
let elements = [];
Object.keys(definition).forEach((tag)=> {
let properties = definition[tag];
let element = appendElement(node, tag, properties);
elements.push(element);
});
return elements;
}
function appendElement(node, tag, properties) {
let element = node.append(tag);
Object.keys(properties).forEach((property)=> {
element.attr(property, properties[property]);
});
return element;
}
/**
* Calculate the X coordinate of a policies/enricher to place it on the grid
*
* @param d the current {entity}
* @param i the index
* @returns {number} The X coordinate within the grid
*/
function getGridX(d, i) {
let nodeWidth = isRootNode(d.parent) ? _configHolder.nodes.root.rect.width : _configHolder.nodes.child.circle.r * 2;
let offset = (_configHolder.nodes.adjunct.rect.width + _configHolder.grid.gutter) * Math.floor(i / _configHolder.grid.itemPerCol);
if (d.parent.isCluster()) {
offset += 20;
}
return _configHolder.grid.gutter + (nodeWidth/2) + offset;
}
/**
* Calculate the Y coordinate of a policies/enricher to place it on the grid
*
* @param d the current {entity}
* @param i the index
* @returns {number} The Y coordinate within the grid
*/
function getGridY(d, i) {
let nodeHeight = isRootNode(d.parent) ? _configHolder.nodes.root.rect.height : _configHolder.nodes.child.circle.r * 2;
let columnHeight = _configHolder.nodes.adjunct.rect.height * _configHolder.grid.itemPerCol + _configHolder.grid.gutter * (_configHolder.grid.itemPerCol - 1);
let offset = nodeHeight > columnHeight ? (nodeHeight - columnHeight) / 2 : 0;
return (_configHolder.nodes.adjunct.rect.height + _configHolder.grid.gutter) * (i%_configHolder.grid.itemPerCol) - (nodeHeight/2) + offset;
}
/**
* Calculate the center coordinates of a policies/enricher to place it on the grid
*
* @param d the current {entity}
* @param i the index
* @returns {number} The center coordinates within the grid
*/
function getGridItemCenter(d, i) {
let centerX = getGridX(d, i) + _configHolder.nodes.adjunct.rect.width / 2;
let centerY = getGridY(d, i) + _configHolder.nodes.adjunct.rect.height / 2;
return `${centerX} ${centerY}`;
}
/**
* Center the graph in the view, considering palette
*/
function center() {
let newX = window.innerWidth/2 + (window.innerWidth > 660 ? 220 : 0);
let newY = _configHolder.nodes.child.circle.r + (_configHolder.nodes.child.circle.r * 2);
zoom.translateBy(_svg, newX, newY);
return this;
}
function trimNodeText(d) {
if (!d.data.metadata.has('name') || d.data.metadata.get('name').length === 0) {
return 'New application';
} else {
let name = d.data.metadata.get('name');
return name.length > _configHolder.nodes.root.maxNameLength ? name.substring(0, _configHolder.nodes.root.maxNameLength) + '...' : name
}
}
function isRootNode(d) {
return d.depth === 0;
}
function isChildNode(d) {
return d.depth > 0;
}
function getImportantAdjuncts(d) {
let adjuncts = d.data.getPoliciesAsArray().concat(d.data.getEnrichersAsArray());
return adjuncts.filter((adjunct)=>(adjunct.miscData.has('important') && adjunct.miscData.get('important') === true));
}
function selectNode(id) {
_svg.selectAll('.entity.selected').classed('selected', false);
_svg.selectAll('.relation.highlight').classed('highlight', false);
_svg.select(`#entity-${id}`).classed('selected', true);
_svg.selectAll(`.relation[from='${id}']`).classed('highlight', true);
_svg.selectAll(`.relation[to='${id}']`).classed('highlight', true);
return this;
}
function unselectNode() {
_svg.selectAll('.entity.selected').classed('selected', false);
_svg.selectAll('.relation.highlight').classed('highlight', false);
return this;
}
/**
* Hide the relationships for the dragged entity and its descendants
* @param node the node for the dragged entity
*/
function hideRelationships(node) {
_d3DataHolder.relationships
.filter(r => r.source.hasAncestor(node.data) || r.target.hasAncestor(node.data))
.forEach(r => {
_relationGroup.selectAll(`.relation[from='${r.source._id}'][to='${r.target._id}']`).classed('hidden', true);
});
}
/**
* Shows all relationships
*/
function showRelationships() {
_relationGroup.selectAll('.relation').classed('hidden', false);
}
/**
* Hide the invalid dropzones for the dragged node
* @param node the node that is being dragged
*/
function hideInvalidDropzones(node) {
_d3DataHolder.nodes
.filter(d => d.data.hasAncestor(node.data))
.forEach(d => {
_dropZoneGroup.selectAll(`#dropzone-group-node-${d.data._id} .dropzone`).classed('hidden', true);
_dropZoneGroup.selectAll(`.dropzone-prev[parentId='${d.data.parent._id}'][targetIndex='${d.data.parent.children.indexOf(d.data)+1}']`).classed('hidden', true);
_dropZoneGroup.selectAll(`.dropzone-next[parentId='${d.data.parent._id}'][targetIndex='${d.data.parent.children.indexOf(d.data)}']`).classed('hidden', true);
});
}
/**
* Shows all dropzones
*/
function showDropzones() {
_dropZoneGroup.selectAll('.dropzone').classed('hidden', false);
}
// register global key events
d3.select('body').on('keyup.body', onKeyUp);
return {
draw: draw,
update: update,
center: center,
select: selectNode,
unselect: unselectNode
};
}