| /* |
| * 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 |
| }; |
| } |