/** * Licensed Materials - Property of IBM IBM Cognos Products: Modeling UI (C) Copyright IBM Corp. 2016, 2020 * US Government Users Restricted Rights - Use, duplication or disclosure restricted by GSA ADP * Schedule Contract with IBM Corp. */ define([ 'jquery', 'underscore', 'bi/glass/app/util/View', './DiagramNode', './DiagramLink', './util/StringUtils', './util/Queue', '../InternalBridge' ], function ($, _, View, DiagramNode, DiagramLink, StringUtils, Queue, InternalBridge) { var d3; var MAX_ZOOM_SCALE = 6; var MIN_ZOOM_SCALE = 0.2 // Ensures that nodes in diagram move away from each other var D3_FORCE_CHARGE = -1000; // ... but not too much var D3_FORCE_CHARGE_DISTANCE = 450; // Ensures connected nodes stay together at this distance var D3_FORCE_LINK_DISTANCE = 300; // Ensures that links do not drag nodes away from the dropped spot var D3_FORCE_LINK_STRENGTH = 0; var ModelDiagramView = View.extend({ _d3ForceContext: null, _SVGContext: null, /*--D3 DATA--*/ // List of current Nodes _nodeList: [], // List of current Links _linkList: [], _selectedNodeList: [], /*--INTERNAL DATA--*/ // Saves positional information of nodes for Drag-n-Drop undo/redo and so on _nodePosition: {}, // Which nodes (by identifier) are connected to which // nodes (as array of identifiers) _adjacencyList: {}, _diagramContentProvider: null, initialNodes: null, initialLinks: null, /** * Initialize the diagram view. Requires "options._diagramContentProvider" to be defined * * @param {object} * options - custom options to add to the diagram view * @param {object} * options._diagramContentProvider - [REQUIRED] Provides implementation of a diagram content provider API * @param {array} * options.initialNodes - Initial nodes wanted in the diagram on first load * @param {array} * options.initialLinks - Initial links wanted in the diagram on first load */ init: function (options) { ModelDiagramView.inherited('init', this, arguments); _.extend(this, options); d3 = options.d3; this._initializeD3(); }, _handleWindowKeyDown: function (e) { // To handle escape key during rename. if (e.which === 27) { this._onInputBlur(); } }, remove: function () { this._d3ForceContext && this._d3ForceContext.stop(); window.removeEventListener('keydown', this._handleWindowKeyDown); ModelDiagramView.inherited('remove', this); this._SVGContext.empty(); this.$el.empty(); }, _initializeD3: function () { var zoomAndPan = this.diagramStore.zoomAndPan; this._SVGContext = d3.select(this.$el[0]).append('svg').attr('width', '100%').attr('height', '100%'); this._SVGContext.on('click', function() { if (d3.event.defaultPrevented) return; // dragged this.diagramStore.clickContainer(); }.bind(this)); this._SVGContext.call(d3.zoom().transform, d3.zoomIdentity.translate(zoomAndPan.translate[0], zoomAndPan.translate[1]).scale(zoomAndPan.scale)); this._SVGContext.call(d3.zoom().scaleExtent([MIN_ZOOM_SCALE, MAX_ZOOM_SCALE]).filter(this._zoomFilter.bind(this)).on('zoom', this._onPan.bind(this))).on('dblclick.zoom', null); this._SVGContext.diagramContainer = this._SVGContext.append('svg:g').attr('class', 'mui-diagram-container'); this._SVGContext.diagramContainer.append('svg:g').attr('class', 'mui-link-container'); this._SVGContext.diagramContainer.append('svg:g').attr('class', 'mui-node-container'); this._SVGContext.diagramContainer.append('svg:g').attr('class', 'mui-link-upper-container'); this._SVGContext.diagramContainer.append('svg:g').attr('class', 'mui-node-upper-container'); this._handleWindowKeyDown = this._handleWindowKeyDown.bind(this); window.addEventListener('keydown', this._handleWindowKeyDown); }, _zoomFilter: function() { // Ignore right-click events, since it should open context menu, else check wheel events return !d3.event.button && (d3.event.type !== 'wheel' || !d3.event.ctrlKey); }, _onPan: function() { var transform = d3.event.transform; var scale = d3.event.sourceEvent && d3.event.sourceEvent.type === 'wheel' ? transform.k : this.diagramStore.zoomAndPan.scale; this.diagramStore.setZoomAndPan({ scale: scale, translate: [transform.x, transform.y] }); // remove the rename input if it exists and save changes this._onInputBlur(); this.diagramStore.hideContextMenu(); }, /** * Displays the cardinality of a link between nodes */ showCardinality: function (isVisible) { this.$el.removeClass('mui-diagram-cardinality-displayed'); if (isVisible) { this.$el.addClass('mui-diagram-cardinality-displayed'); } }, isAnimating: function () { return this._isAnimating; }, /** * Will hide any nodes in the diagram that are more than the number of stops away from the selected node. */ showDegreesOfSeparation: function () { var allNodeIDs = _.pluck(this._nodeList, 'identifier'); var selectedNodes = this.$el.find('.mui-diagram-selected'); if (this.diagramStore.isContextMode) { // This is to cope with proposal diagram view if (this.diagramStore.allVisibleNodes) { this.allVisibleNodes = this.diagramStore.allVisibleNodes; } this._fadeTo(this.allVisibleNodes); this._fadeTo(_.difference(allNodeIDs, this.allVisibleNodes), true); } else if (this.diagramStore.areAllNodesVisible) { this.allVisibleNodes = allNodeIDs; this._fadeTo(allNodeIDs); // When no node selected, all nodes are set to faded. } else if (selectedNodes.length === 0) { this._fadeTo(allNodeIDs, true); return; // FOR EACH 'exploreNode' } else { this.allVisibleNodes = []; _.each(selectedNodes, function (exploreNode) { exploreNode = ($(exploreNode).attr('class') || '').split(' '); exploreNode = _.without(exploreNode, 'mui-diagram-node', 'mui-reference-node', 'mui-diagram-selected'); exploreNode = exploreNode.toString(); var visibleNodes = (!this._adjacencyList[exploreNode]) ? [exploreNode] : _.uniq(this._getAdjacentNodes(this.diagramStore.degreesOfSeparation, exploreNode)); this.allVisibleNodes = _.union(this.allVisibleNodes, visibleNodes); }.bind(this)); this._fadeTo(this.allVisibleNodes); this._fadeTo(_.difference(allNodeIDs, this.allVisibleNodes), true); } // Bring the selected node into the front, so that it won't be covered by other visible nodes. this.bringNodesToFront(this.$el.find('.mui-diagram-selected')); }, _fadeTo: function (nodeIds, toFade) { var nodesToBack = []; var nodesToFront = []; var linksToFront = []; var linksToBack = []; if (nodeIds && nodeIds.length > 0) { for (var i=0; i= 0) { node = q.dequeue(); // don't need to re-process nodes if(!_.contains(visitedNodes, node)) { // mark node as visited visitedNodes = _.union(visitedNodes, node); // add adjacent nodes to queue to be processed var adjacencyList = this._adjacencyList[node] || []; for(var i = 0; i < adjacencyList.length; i++) { q.enqueue(adjacencyList[i]); nextCountToDepthIncrease++; } } // if we've processed everything from the previous depth, // move to next level of the graph if(--countToDepthIncrease === 0) { countToDepthIncrease = nextCountToDepthIncrease; nextCountToDepthIncrease = 0; depth--; } } return visitedNodes; }, /** * Add a new node to the diagram * * @param {object} * moserObject - A node object that should be added to the diagram */ addNode: function (moserObject, options, droppedPosition) { if (this.diagramStore.droppedPosition && !this.diagramStore.isDnD) { var id = moserObject.getIdentifier(); if (moserObject !== this.diagramStore.contextMenuFromMoserObject && this.allVisibleNodes.indexOf(id) > -1) { this.diagramStore.setPosition(moserObject); } } if (!this._hasPosition(moserObject)) { this._withNewNodes = true; } _.extend(options, { contentProvider: this._diagramContentProvider, moserObject: moserObject, fixed: this._hasPosition(moserObject), position: this._getPosition(moserObject, droppedPosition), moserModule: this.moserModule, isDetailsVisible: this.diagramStore.isDetailsVisible, tableRowCount: this.diagramStore.getObjectRowCount(moserObject), statisticsLoading: this.diagramStore.getObjectStatisticsLoading(moserObject) }); var newNode = new DiagramNode(options); this._nodeList.push(newNode); }, /** * Add a new link to the diagram * * @param {object} * linkObj - A link object that should be added to the diagram */ addLink: function (linkObj) { if (!linkObj.left || !linkObj.right) { return; } var leftID = linkObj.left.ref, rightID = linkObj.right.ref, useSpec = this.moserModule.getUseSpec(), leftRefObject = linkObj.left.getReferencedObject(), rightRefObject = linkObj.right.getReferencedObject(), leftIsFromPackage = InternalBridge.moserUtils.isPartOfPackage(leftRefObject), rightIsFromPackage = InternalBridge.moserUtils.isPartOfPackage(rightRefObject); if( leftIsFromPackage ){ leftID = InternalBridge.moserUtils.getUseSpecByRef(leftRefObject, linkObj.left.ref).identifier; } if( rightIsFromPackage ){ rightID = InternalBridge.moserUtils.getUseSpecByRef(rightRefObject, linkObj.right.ref).identifier; } var linkSource = _.find(this._nodeList, function (ele) { return leftID === ele.identifier; }); var linkTarget = _.find(this._nodeList, function (ele) { return rightID === ele.identifier; }); if (!linkSource || !linkTarget) { return; } var currentDiagramLink = _.find(this._linkList, function (ele){ return linkObj.identifier === ele.id; }); if (currentDiagramLink) { currentDiagramLink.addARepresentedLink({ linkObj: linkObj, left: linkObj.left, link: linkObj.link, right: linkObj.right }); } else { var newLink = new DiagramLink({ source: linkSource, target: linkTarget, instanceType: linkObj.instanceType ? linkObj.instanceType.value() : 'copy', id: linkObj.identifier, moserModule: this.moserModule, moserObject: linkObj, severityInfo: InternalBridge.validationUtils.getHighestSeverityErrorInfo(linkObj) }); newLink.addARepresentedLink({ linkObj: linkObj, left: linkObj.left, link: linkObj.link, right: linkObj.right }); linkSource.addAdjacentLink(newLink.id); linkTarget.addAdjacentLink(newLink.id); this._linkList.push(newLink); } this._adjacencyList[leftID] = this._adjacencyList[leftID] || []; this._adjacencyList[rightID] = this._adjacencyList[rightID] || []; // prevent bloat of the adjacencyList - don't need duplicates if (!_.contains(this._adjacencyList[leftID], rightID)) { this._adjacencyList[leftID].push(rightID); } if (!_.contains(this._adjacencyList[rightID], leftID)) { this._adjacencyList[rightID].push(leftID); } }, /** * Programmatically selects a node in the diagram (when selecting a node from somewhere OTHER than the diagram) * * @param {array} * identifiers - An array of strings of node IDs to be selected */ selectNodes: function () { var selectedNodeIdentifiers = _.map(this.diagramStore.selection, function (node) { return node.getIdentifier(); }); this.$el.find('.mui-diagram-selected').removeClass('mui-diagram-selected'); selectedNodeIdentifiers.forEach(function (id) { this.$el.find('.mui-diagram-node.' + id).addClass('mui-diagram-selected'); }.bind(this)); this._selectedNodeList = this._nodeList.filter(function(ele) { return selectedNodeIdentifiers.indexOf(ele.identifier) !== -1 }); }, _update: function () { this._d3ForceContext = d3.forceSimulation(this._nodeList) .force('charge', d3.forceManyBody().strength(D3_FORCE_CHARGE).distanceMax(D3_FORCE_CHARGE_DISTANCE)) .force('link', d3.forceLink(this._linkList) .strength(D3_FORCE_LINK_STRENGTH) .distance(D3_FORCE_LINK_DISTANCE)) .alpha(this._withNewNodes ? 0.1 : 0) .restart(); var links = this._diagramContentProvider.drawLinks(this._SVGContext, this._linkList); var nodes = this._diagramContentProvider.drawNodes(this._SVGContext, this._nodeList); var self = this; nodes.call(d3.drag().on('start', function(e) { this.diagramStore.hideContextMenu(); d3.event.sourceEvent.stopPropagation(); }.bind(this)).on('drag', function (d) { if (this._selectedNodeList.indexOf(d) !== -1) { this._selectedNodeList.forEach(function(node) { node.x += d3.event.dx; node.y += d3.event.dy; }); } else { d.x += d3.event.dx; d.y += d3.event.dy; } this._diagramContentProvider.animateNodeTick(nodes); this._diagramContentProvider.animateLinkTick(links); }.bind(this)).on('end', function (d) { if (this._selectedNodeList.indexOf(d) === -1) { this._setPosition(d.moserObject, { x: d.x, y: d.y }); } else { this._selectedNodeList.forEach(function(node) { this._setPosition(node.moserObject, { x: node.x, y: node.y }); }.bind(this)); } }.bind(this))); if (this._withNewNodes && !this._isAnimating) { this._isAnimating = true; } this._d3ForceContext.on('tick', function () { self._diagramContentProvider.animateNodeTick(nodes); self._diagramContentProvider.animateLinkTick(links); nodes.each(function (n) { if (self._d3ForceContext.alpha() < 0.01) { n.fx = n.x; n.fy = n.y; self._setPosition(n.moserObject, { x: n.x, y: n.y }); self._d3ForceContext.stop(); self._isAnimating = false; self._withNewNodes = false; } }); }); this.redraw(); }, render: function (moserModule) { if (this._isAnimating) { this.redraw(); return; } this._nodeList = []; this._linkList = []; // first add nodes for packages var diagramNodes = this.diagramStore.diagramNodes; diagramNodes.packages.forEach(function (p) { this.addNode(p, { filter: false, instanceType: 'package', isValid: true }, this.diagramStore.droppedPosition); }.bind(this)); // then add nodes for query subject not in packages diagramNodes.querySubjects.forEach(function (qs) { this.addNode(qs, { filter: qs.getFilter().length, instanceType: qs.instanceType ? qs.instanceType.value() : 'copy', isValid: InternalBridge.validationUtils.isValid(qs), severityInfo: InternalBridge.validationUtils.getHighestSeverityErrorInfo(qs) }, this.diagramStore.droppedPosition); }.bind(this)); // last add links this._adjacencyList = {}; moserModule.getRelationship().forEach(function (link) { this.addLink(link); }.bind(this)); this.diagramStore.setDroppedPosition(); this.diagramStore.setIsDnD(false); this._update(); }, /** * Position offset provides some randomness in the dropping of node positions to prevent them from "flying off" */ _positionOffset: function () { return Math.floor(500 * (Math.random() - 0.5)); }, _getOffsetPosition: function (nodePosition) { var zoomAndPan = this.diagramStore.zoomAndPan; return { x: (nodePosition.x + this._positionOffset() - zoomAndPan.translate[0]) / zoomAndPan.scale, y: (nodePosition.y + this._positionOffset() - zoomAndPan.translate[1]) / zoomAndPan.scale }; }, /** * Calculates the position of any new nodes that are dropped to the diagram, otherwise they are placed in the middle/ */ _getPosition: function (moserObject, droppedPosition) { if (this._hasPosition(moserObject)) { return this.diagramStore.getPosition(moserObject); } var defaultPosition = droppedPosition || { x: (this.$el.width() / 2), y: (this.$el.height() / 2) }; return this._getOffsetPosition(defaultPosition); }, _hasPosition: function (moserObject) { var position = this.diagramStore.getPosition(moserObject); return position.x != null && position.y != null; }, _setPosition: function (moserObject, nodePosition, withOffset) { this.diagramStore.setPosition(moserObject, withOffset ? this._getOffsetPosition(nodePosition) : nodePosition); }, /** * Show the input box on the diagram over the node that * will be renamed and send changes to model */ captureRename : function () { var focusedNode = d3.select(this.$el.find('.mui-diagram-selected')[0]); // occasionally get context menu without node focus if (focusedNode.size() === 0 || !this.diagramStore.isRenameActive) { return; } var nodeData; var nodeList = this._nodeList; for (var i = 0; i < nodeList.length; i++) { if (nodeList[i].identifier === focusedNode.node().__data__.identifier) { nodeData = nodeList[i]; break; } } this._drawInputBoxRename(focusedNode); if (nodeData) { // put existing title in input box this.$inputBox.val(nodeData.label).focus().select(); this._pushRenameToModel(focusedNode, nodeData.moserObject); } }, /** * Find a matching node to call the rename action on * * @param identifier * The identifier of the node that is to be * renamed */ searchNodeListRename : function (newName, identifier) { var self = this; this._nodeList.forEach(function (node) { if (node.identifier === identifier) { self._renameNode(newName, node); } }); }, /** * Rename the actual diagram node * * @param newName * @param node */ _renameNode : function (newName, node) { node.setLabel(newName); this.$el.find('.mui-diagram-node.' + node.identifier + ' text') .text(newName); this._shortenLabel(node.identifier); }, /** * Capture the new name of the query subject and push * changes to model and tree */ _pushRenameToModel : function (focusedNode, moserObject) { // change text of diagram node and propagate any // changes to fancytree on focus change var self = this; var modelInfo = { model: self._diagramContentProvider._model, node: focusedNode }; this.$inputBox.blur(modelInfo, function () { self._onInputBlur(moserObject); }).keypress(modelInfo, function (e) { if (e.which === 13) { self._onInputBlur(moserObject); } }); }, _onInputBlur : function (moserObject) { if (this.$inputBox && this.$inputBox.length > 0) { this.$inputBox.remove(); this.$imputBox = null; this.diagramStore.setRenameActive(false); if (moserObject) { this.diagramStore.setLabel(moserObject, this.$inputBox.val()); } } }, /** * Draw and size the input box used for diagram rename * action */ _drawInputBoxRename : function (focusedNode) { var node = this.$el.find('.mui-diagram-selected'); var bboxNode = node.find('.mui-diagram-node-box')[0].getBoundingClientRect(); var bboxSVG = this.$el.offset(); var renameLeft = bboxNode.left - bboxSVG.left; var renameTop = bboxNode.top - bboxSVG.top; var textSize = focusedNode.select('text').style('font-size') .replace(/[^-\d\.]/g, '') * this.diagramStore.zoomAndPan.scale; // change size of input box if filter icon is // visible // // 0.9 = % of node width used for input box when // filter icon is not present // // 0.78 = % of node width used for input box when // filter icon is present var widthOffset = 0.9; if (node[0].__data__.filter) { widthOffset = 0.78; } // add and size the input box based on the zoom // level // // 0.05 = % of node width used to give a buffer on // left of input box (between edge of node and start // of the input) // // 0.25 = % of node height used to buffer top of // input box (between top of node and input) this.$inputBox = $(''); this.$el.find('svg').before(this.$inputBox); }, /** * Properly shorten the label for the given identifier's * node * * @param identifier * A valid querySubject identifier */ _shortenLabel : function (identifier) { var node = d3.select(this.$el.find('.mui-diagram-node.' + identifier)[0]); this.$el.find('.mui-diagram-node.' + identifier + ' text')[0].textContent = StringUtils .shortenTextMidEllipsis( node.data()[0].label, StringUtils.getMaxDiagramLabelLength(node.data()[0].filter, node.data()[0].label)); }, /** * Update the diagram node's styling based on new properties * * @param node * The node */ updateNodeStyling: function (node) { if (node) { // find matching node in diagram node list var n = _.find(this._nodeList, function (ele) { return ele.identifier === node.identifier; }); if (n) { // set properties on matching diagram node n.setInstanceType(node.instanceType); n.setHidden(node.hidden); // update style on node to reflect new data this._diagramContentProvider.styleNode(d3.select(this.$el.find('.mui-diagram-node.' + n.identifier)[0])); } } }, updateLinkStyling: function (relationship) { if (relationship) { // find matching link in diagram link list var l = _.find(this._linkList, function (ele) { return ele.identifier === relationship.identifier; }); if (l) { l.setInstanceType(relationship.instanceType); this._diagramContentProvider.styleLink(d3.select(this.$el.find('.mui-diagram-link.' + l.identifier)[0])); } } }, // To simulate react update redraw: function () { var zoomAndPan = this.diagramStore.zoomAndPan; var transform = d3.zoomTransform(this._SVGContext.node()); transform.k = zoomAndPan.scale; transform.x = zoomAndPan.translate[0]; transform.y = zoomAndPan.translate[1]; d3.zoom().transform(this._SVGContext, transform); this._SVGContext.diagramContainer.attr('transform', 'translate(' + zoomAndPan.translate + ') ' + 'scale(' + zoomAndPan.scale + ')'); this.selectNodes(); this.showCardinality(this.diagramStore.isCardinalityVisible); this.showDegreesOfSeparation(this.diagramStore.degreesOfSeparation); this.captureRename(); } }); return ModelDiagramView; });