esdoc-plugin-dependency-graphs
Version:
A plugin for ESDoc that adds interactive D3 powered dependency graphs for source code including linking JSPM / NPM managed code and packages.
1,217 lines (1,016 loc) • 39.5 kB
JavaScript
/* eslint-disable */
(function()
{
'use strict';
var circleRadius = 6;
var minScaleExtent = 0.2, maxScaleExtent = 5;
var graph, layout, zoom, nodes, links;
var selectedContextNode, selectedDragNode;
var linkedByIndex = {};
var graphWidth, graphHeight;
var data;
var appOptions =
{
currentLevel: 0,
currentScope: 'all', // Stores package / source scope: main, dev, all
maxDepthSticky: true,
showFullNames: false,
showTableView: false
};
// Stores menu options that have a toggle state.
var appMenuToggleOptions = ['maxDepthSticky', 'showFullNames', 'showTableView'];
// Gathers data from template filled functions.
var dataPackageMap =
{
'all': getPackageDataAll(),
'dev': getPackageDataDev(),
'main': getPackageDataMain()
};
// Provides a cache of recycled SVG elements.
var svgElementMap =
{
circle: [],
g: [],
path: [],
text: []
};
function cerialize(text)
{
var svgxml = (new XMLSerializer()).serializeToString(d3.select('svg').node());
console.log('!!! serialize data -- text: ' + text);
console.log('!!! svg: ' + svgxml);
if (data)
{
console.log('!!! data.links: ' + JSON.stringify(data.links));
console.log('!!! data.nodes: ' + JSON.stringify(data.nodes));
}
}
/**
* Bootstraps the setup of the graph.
*/
function bootstrap()
{
// Hook up controllers
$('.control-zoom button').on('click', onControlZoomClicked);
$('.control-level input').on('change', onControlLevelChanged);
$('.control-deps input').on('click', onControlDepsClicked);
$('.control-links input').on('click', onControlLinksClicked);
$('.control-menu li').on('click', onControlMenuClicked);
$('#contextpopup li[data-action]').on('click', onNodeContextMenuClick);
// Gather current level and scope from template values.
appOptions.currentLevel = parseInt($('.control-level input').val());
appOptions.currentScope = $('.control-deps input:radio[name=dep]:checked').val();
zoom = d3.behavior.zoom();
zoom.scaleExtent([minScaleExtent, maxScaleExtent]);
zoom.on('zoom', onZoomChanged);
graphWidth = window.innerWidth;
graphHeight = window.innerHeight;
d3.select(window).on('resize', onResize);
// Setup layout
layout = d3.layout.force()
.gravity(.05)
.charge(-300)
.linkDistance(80)
.size([graphWidth, graphHeight])
.on('tick', onTick);
// Setup drag callbacks for nodes. If a node is clicked and dragged it becomes fixed.
layout.drag()
.on('dragstart', function(targetNode)
{
d3.event.sourceEvent.stopPropagation();
d3.select(this).classed('dragging', true).classed('fixed', targetNode.fixed = true);
detectAllNodesFixed();
})
.on('drag', function(targetNode)
{
d3.select(this).attr('cx', targetNode.x = d3.event.x).attr('cy', targetNode.y = d3.event.y);
})
.on('dragend', function()
{
d3.select(this).classed('dragging', false);
});
// Setup graph
d3.select('.graph')
.append('svg')
.attr('transform', 'rotate(0)')
.on('contextmenu', function() { d3.event.preventDefault(); })
.attr('pointer-events', 'all')
.call(zoom).on('dblclick.zoom', null);
// Markers Def
d3.select('.graph svg')
.append('defs').selectAll('marker')
.data(['regular'])
.enter().append('marker')
.attr('id', String)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 15)
.attr('refY', -1.5)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5');
// Top level SVGGElement for dragging / centering graph
graph = d3.select('.graph svg')
.append(getSVG('g'))
.attr('width', graphWidth)
.attr('height', graphHeight)
.attr('transform', 'translate(' + zoom.translate() + ')' + ' scale(' + zoom.scale() + ')');
// Initialize tablesorter
$('#nodeTable').tablesorter(
{
widgets: ['stickyHeaders'],
widgetOptions:
{
stickyHeaders_filteredToTop: true,
stickyHeaders_cloneId : '-sticky',
stickyHeaders_attachTo : '.control-table-inner'
},
sortMultiSortKey: ''
});
updateAll();
// Center graph w/ zoom fit w/ 1 second transition applied after 4 seconds delay for debounce.
centerGraph(zoomFit, 1000, 4000);
}
/**
* Centers the graph. All parameters can be either a number or a function evaluated for a number result.
*
* `newScale` and `duration` are evaluated in the centering anonymous function allowing values to be determined
* after debounce.
*
* @param {number|function} newScale - New scale value for graph.
* @param {number|function} duration - Duration of centering transition.
* @param {number|function} delay - Delay for debounce before centering occurs.
*/
function centerGraph(newScale, duration, delay)
{
if (typeof delay === 'function') { delay = delay.call(this); }
delay = typeof delay === 'number' ? delay : 0;
setTimeout(function()
{
if (typeof newScale === 'function') { newScale = newScale.call(this); }
if (typeof duration === 'function') { duration = duration.call(this); }
newScale = typeof newScale === 'number' ? newScale : zoom.scale();
duration = typeof duration === 'number' ? duration : 200;
if (typeof newScale !== 'number') { throw new TypeError("centerGraph error: 'newScale' is not a 'number'."); }
if (typeof duration !== 'number') { throw new TypeError("centerGraph error: 'duration' is not a 'number'."); }
var bounds = graph.node().getBBox();
var centerSVGX = (graphWidth * newScale / 2);
var centerSVGY = (graphHeight * newScale / 2);
var centerGraphX = (bounds.x * newScale) + (bounds.width * newScale / 2);
var centerGraphY = (bounds.y * newScale) + (bounds.height * newScale / 2);
// Translate
var centerTranslate =
[
(graphWidth / 2) - centerSVGX + (centerSVGX - centerGraphX),
(graphHeight / 2) - centerSVGY + (centerSVGY - centerGraphY)
];
// Store values
zoom
.translate(centerTranslate)
.scale(newScale);
// Render transition
graph.transition()
.duration(duration)
.attr('transform', 'translate(' + zoom.translate() + ')' + ' scale(' + zoom.scale() + ')');
// Hides any existing node context menu.
hideNodeContextMenu();
}, delay);
}
/**
* Helper function to determin if all nodes are fixed. This is run after any node is dragged and set to fixed.
*/
function detectAllNodesFixed()
{
if (data)
{
var currentNodesFixed = data.allNodesFixed;
var allNodesFixed = true;
data.nodes.forEach(function(node) { if (!node.fixed) { allNodesFixed = false; } });
data.allNodesFixed = allNodesFixed;
if (currentNodesFixed !== allNodesFixed) { updateMenuUI(); } // Update freeze / unfreeze menu option.
}
}
/**
* Fades and unfades connected nodes to a given `targetNode`.
*
* @param {object} targetNode - The target node from which fading occurs / connections are calculated.
* @param {boolean} selected - Indicates if the fade is in / out; true fades nodes / false un-fades nodes.
* @param {Array} nodes - An array of all graph nodes.
* @param {Array} links - An array of all graph links.
*/
function fadeRelatedNodes(targetNode, selected, nodes, links)
{
var opacity = selected ? 0.1 : 1;
var elm = findElementByNode('circle', targetNode);
// Highlight circle
elm.classed('selected', opacity < 1);
// Clean links
$('path.link').removeAttr('data-show');
// Traverse all nodes and set `dimmed` class to nodes that are dimmed / not connected in addition to setting
// fill and stroke opacity.
nodes.style('stroke-opacity', function(otherNode)
{
var thisOpacity = isConnected(targetNode, otherNode) ? 1 : opacity;
this.setAttribute('fill-opacity', thisOpacity);
this.setAttribute('stroke-opacity', thisOpacity);
// Depending on opacity add or remove 'dimmed' class.
this.classList[thisOpacity === 1 ? 'remove' : 'add']('dimmed');
return thisOpacity;
});
// Traverse all links and set `data-show` and `marker-end` for connected links given the `targetNode`.
links.style('stroke-opacity', function(otherNode)
{
if (otherNode.source === targetNode)
{
// Highlight target / sources of the link
var elmNodes = graph.selectAll('.' + formatClassName('node', otherNode.target));
elmNodes.attr('fill-opacity', 1);
elmNodes.attr('stroke-opacity', 1);
elmNodes.classed('dimmed', false);
// Highlight arrows
var elmCurrentLink = $('path.link[data-source=' + otherNode.source.index + ']');
elmCurrentLink.attr('data-show', true);
elmCurrentLink.attr('marker-end', 'url(#regular)');
return 1;
}
else
{
return opacity;
}
});
// Modify all links that have not had 'data-show' added above.
var elmAllLinks = $('path.link:not([data-show])');
elmAllLinks.attr('marker-end', opacity === 1 ? 'url(#regular)' : '');
}
/**
* A helper to select a given SVG element from given node data.
*
* @param {string} prefix - String prefix.
* @param {object} node - Node object
*/
function findElementByNode(prefix, node)
{
var selector = '.' + formatClassName(prefix, node);
return graph.select(selector);
}
/**
* Replaces semver and other special characters with `-`.
*
* @param {string} prefix - String prefix.
* @param {object} node - Node object
* @returns {string}
*/
function formatClassName(prefix, node)
{
return prefix + '-' + node.id;
}
/**
* Pass in the element and the screen coordinates are returned.
*
* @param element
*
* @returns {{x: number, y: number}}
*/
function getElementCoords(element)
{
var ctm = element.getCTM();
return { x: ctm.e + element.getAttribute('cx') * ctm.a, y: ctm.f + element.getAttribute('cy') * ctm.d };
}
/**
* Gets a recycled SVG element from the pool, `svgElementMap`, or creates a new element for the given type. Any
* data specified by D3 will be copied to the element. Returns a function which is evaluated by D3.
*
* @param {string} elementType - SVG element: `circle`, `g`, `path`, or `text`.
*
* @returns {*}
*/
function getSVG(elementType)
{
var returnVal;
var svgElement;
var cached;
switch (elementType)
{
case 'circle':
case 'g':
case 'path':
case 'text':
returnVal = function(data)
{
svgElement = svgElementMap[elementType].pop();
cached = svgElement != null;
svgElement = svgElement != null ? svgElement : document.createElementNS('http://www.w3.org/2000/svg',
elementType);
// Copy data to SVG element.
if (typeof data === 'object') { for (var key in data) { svgElement.setAttribute(key, data[key]); } }
return svgElement;
};
break;
default:
throw new TypeError('getSVG error: unknown elementType.');
}
return returnVal;
}
/**
* Hides the node context menu if visible and removes any node highlighting. If an event is supplied it is checked
* against any existing context menu and is ignored if the context menu is within the parent hierarchy.
*
* @param {object|undefined} event - Optional event
*/
function hideNodeContextMenu(event)
{
// Provide an early out if there is no selected context node.
if (typeof selectedContextNode === 'undefined') { return; }
var contextMenuButton = $('#context-menu');
var popupmenu = $('#contextpopup .mdl-menu__container');
// If an event is defined then make sure it isn't targeting the context menu.
if (event)
{
event.preventDefault();
// Picked element is not the menu
if (!$(event.target).parents('#contextpopup').length > 0)
{
// Hide menu if currently visible
if (popupmenu.hasClass('is-visible')) { contextMenuButton.click(); }
fadeRelatedNodes(selectedContextNode, false, nodes, links);
selectedContextNode = undefined;
}
}
else // No event defined so always close context menu and remove node highlighting.
{
// Hide menu if currently visible
if (popupmenu.hasClass('is-visible')) { contextMenuButton.click(); }
fadeRelatedNodes(selectedContextNode, false, nodes, links);
selectedContextNode = undefined;
}
}
/**
* Checks if a target node is connected to another given node by checking `index` or the `linkedByIndex` map.
*
* @param {object} targetNode
* @param {object} otherNode
*
* @returns {boolean}
*/
function isConnected(targetNode, otherNode)
{
return targetNode.index === otherNode.index || linkedByIndex[targetNode.index + ',' + otherNode.index];
}
/**
* Returns whether there is a currently selected node (drag or context).
*
* @returns {boolean}
*/
function isNodeSelected()
{
return typeof selectedDragNode !== 'undefined' || typeof selectedContextNode !== 'undefined';
}
/**
* Handles responding to the dependencies radio group.
*/
function onControlDepsClicked()
{
// Do nothing if scope has not changed
if (this.value === appOptions.currentScope) { return; }
// If max depth is set to sticky and current level is equal max level of old scope then set max level for
// the new scope.
if (appOptions.maxDepthSticky && appOptions.currentLevel === dataPackageMap[appOptions.currentScope].maxLevel)
{
appOptions.currentLevel = dataPackageMap[this.value].maxLevel
}
appOptions.currentScope = this.value;
var maxLevel = dataPackageMap[appOptions.currentScope].maxLevel;
// Adjust current level if it is greater than max level for current scope.
if (appOptions.currentLevel > maxLevel) { appOptions.currentLevel = maxLevel; }
// Update control level UI based on current and max level for given scope.
$('.control-level input').attr({ max: maxLevel });
$('.control-level input').val(appOptions.currentLevel);
$('.control-level label').html(appOptions.currentLevel);
// Redraw graph data
updateAll({ redrawOnly: true });
// Center graph w/ zoom fit w/ 1 second transition applied after a potential 2 seconds delay for debounce.
centerGraph(zoomFit, 1000, data.allNodesFixed ? 0 : 2000);
}
/**
* Handles responding to the level slider.
*/
function onControlLevelChanged()
{
appOptions.currentLevel = parseInt(this.value);
$('.control-level input').val(appOptions.currentLevel);
$('.control-level label').html(appOptions.currentLevel);
// Redraw graph data
updateAll({ redrawOnly: true });
// Center graph w/ zoom fit w/ 1 second transition applied after a potential 2 seconds delay for debounce.
centerGraph(zoomFit, 1000, data.allNodesFixed ? 0 : 2000);
}
/**
* Handles reverse links input reversing the graph links then redrawing the graph.
*/
function onControlLinksClicked()
{
reverseGraphLinks();
renderGraph({ redrawOnly: true });
}
/**
* Handles responding to overflow menu selections.
*/
function onControlMenuClicked()
{
switch ($(this).data('action'))
{
case 'toggleFreezeAllNodes':
setNodesFixed(!data.allNodesFixed);
break;
case 'showFullNames':
appOptions.showFullNames = !appOptions.showFullNames;
updateAll({ redrawOnly: true });
break;
case 'showTableView':
appOptions.showTableView = !appOptions.showTableView;
$('.control-table').toggleClass('hidden', !appOptions.showTableView);
updateTableUIExtent();
break;
case 'maxDepthSticky':
appOptions.maxDepthSticky = !appOptions.maxDepthSticky;
break;
}
// Defer updating menu UI until menu is hidden.
setTimeout(updateMenuUI, 200);
}
/**
* Handles a context click on a table row showing the related node context menu.
*
* @param {object} node - Target node.
* @param {object} event - mouse event
*/
function onControlTableRowContextClick(node, event)
{
event.preventDefault(); // Prevents default browser context menu from showing.
onNodeContextClick(node, { x: event.pageX, y: event.pageY });
}
/**
* Handles a context click on a table row showing the related node context menu. Defers to `onNodeMouseOverOut`.
*
* @param {Array} nodes - All graph nodes.
* @param {Array} links - All graph links.
* @param {object} node - Target node.
* @param {boolean} enter - true when mouse entering / false when mouse exiting.
*/
function onControlTableRowMouseOver(nodes, links, node, enter)
{
// Hide the node context menu if currently showing when a new table row / node is moused over.
if (node !== selectedContextNode) { hideNodeContextMenu(event); }
onNodeMouseOverOut(nodes, links, enter, node);
}
/**
* Handles clicks on zoom control buttons and invokes `centerGraph` with new scale value.
*/
function onControlZoomClicked()
{
var newScale = 1;
var scalePercentile = 0.20;
// Add or subtract scale percentile from current scale value.
switch ($(this).data('action'))
{
case 'zoom_in':
newScale = Math.max(Math.min(zoom.scale() * (1 + scalePercentile), maxScaleExtent), minScaleExtent);
break;
case 'zoom_out':
newScale = Math.max(zoom.scale() * (1 - scalePercentile), minScaleExtent);
break;
case 'zoom_all_out':
newScale = zoomFit();
break;
}
centerGraph(newScale);
}
/**
* Handles clicks on the node context menu invoking any active actions.
*/
function onNodeContextMenuClick()
{
// When a context menu is selected remove node highlighting.
hideNodeContextMenu();
console.log('!! action: ' + $(this).data('action') + '; link: ' + $(this).data('link'));
switch ($(this).data('action'))
{
case 'openLink':
var link = $(this).data('link');
if (typeof link === 'string')
{
window.open(link, '_blank', 'location=yes,menubar=yes,scrollbars=yes,status=yes');
}
break;
}
}
/**
* Shows the node context menu
*
* @param {object} targetNode - The target node.
*
* @param {object} coords - Object containing x / y position for context menu; if not provided Node position
* determines coords.
*/
function onNodeContextClick(targetNode, coords)
{
// Hides any existing node context menu.
hideNodeContextMenu();
if (typeof coords !== 'object') { coords = getElementCoords(this); }
var popupmenu = $('#contextpopup .mdl-menu__container');
var packageData = targetNode.packageData;
var packageLink, packageType, scmLink, scmType;
if (packageData)
{
if (packageData.packageLink)
{
packageLink = packageData.packageLink.link;
packageType = packageData.packageLink.type;
// Create proper name for package type.
switch (packageType)
{
case 'npm':
packageType = 'NPM';
break;
}
}
if (packageData.scmLink)
{
scmLink = packageData.scmLink.link;
scmType = packageData.scmLink.type;
// Create proper name for SCM type.
switch (scmType)
{
case 'github':
scmType = 'Github';
break;
}
}
}
// Populate data for the context menu.
popupmenu.find('li').each(function(index)
{
var liTarget = $(this);
switch (index)
{
case 0:
if (scmLink && scmType)
{
liTarget.text('Open on ' + scmType);
liTarget.data('link', scmLink);
liTarget.removeClass('hidden');
}
else
{
liTarget.addClass('hidden');
}
break;
case 1:
if (packageLink && packageType)
{
liTarget.text('Open on ' + packageType);
liTarget.data('link', packageLink);
liTarget.removeClass('hidden');
}
else
{
liTarget.addClass('hidden');
}
break;
case 2:
if (packageData && packageData.version)
{
liTarget.text('Version: ' + packageData.version);
liTarget.removeClass('hidden');
}
else
{
liTarget.addClass('hidden');
}
break;
}
});
// Wrapping in a 100ms timeout allows MDL to draw animation when showing a context menu after one has been hidden.
setTimeout(function()
{
// Assign new selected context node and highlight related nodes.
selectedContextNode = targetNode;
fadeRelatedNodes(selectedContextNode, true, nodes, links);
// For MDL a programmatic click of the hidden context menu.
var contextMenuButton = $("#context-menu");
contextMenuButton.click();
// Necessary to defer reposition of the context menu.
setTimeout(function()
{
popupmenu.parent().css({ position: 'relative' });
popupmenu.css({ left: coords.x, top: coords.y, position:'absolute' });
}, 0);
}, 100);
}
/**
* Handles a mouse down action on a graph node. Hides any showing context menu. For left clicks the selected node
* becomes the drag target and related nodes are faded. Any other mouse button is ignored.
*
* @param {Array} nodes - All graph nodes.
* @param {Array} links - All graph links.
* @param {object} targetNode - The target node.
*/
function onNodeMouseDown(nodes, links, targetNode)
{
hideNodeContextMenu();
// Only select / drag nodes with left clicked otherwise stop propagation of event.
if (d3.event.button === 0)
{
selectedDragNode = targetNode;
// Select / highlight related nodes or remove attributes based on enter state.
fadeRelatedNodes(targetNode, true, nodes, links);
}
else
{
d3.event.stopPropagation();
}
}
/**
* Handles fading related nodes to the given `targetNode` if there is no currently selected node.
*
* @param {Array} nodes - All graph nodes.
* @param {Array} links - All graph links.
* @param {boolean} enter - true when mouse entering / false when mouse exiting.
* @param {object} targetNode - The target node.
*/
function onNodeMouseOverOut(nodes, links, enter, targetNode)
{
// If there is an existing selected node then exit early.
if (isNodeSelected()) { return; }
// Select / highlight related nodes or remove attributes based on enter state.
fadeRelatedNodes(targetNode, enter, nodes, links);
}
/**
* Handles the window resize event.
*/
function onResize()
{
// Update graph parameters.
graphWidth = window.innerWidth;
graphHeight = window.innerHeight;
graph.attr('width', graphWidth).attr('height', graphHeight);
layout.size([graphWidth, graphHeight]).resume();
updateMenuUI();
updateTableUIExtent();
// Hides any existing node context menu.
hideNodeContextMenu();
centerGraph(zoomFit, 1000, data.allNodesFixed ? 0 : 2000);
}
/**
* D3 tick handler for the forced graph layout.
*/
function onTick()
{
nodes.attr('cx', function(node) { return node.x; })
.attr('cy', function(node) { return node.y; })
.attr('transform', function(node) { return 'translate(' + node.x + ',' + node.y + ')'; });
// Pull data from data.nodes array. Provides a curve to the links.
links.attr('d', function(link)
{
var sourceX = data.nodes[link.source.index].x;
var sourceY = data.nodes[link.source.index].y;
var targetX = data.nodes[link.target.index].x;
var targetY = data.nodes[link.target.index].y;
var dx = targetX - sourceX,
dy = targetY - sourceY,
dr = Math.sqrt(dx * dx + dy * dy);
return 'M' + sourceX + ',' + sourceY + 'A' + dr + ',' + dr + ' 0 0,1 ' + targetX + ',' + targetY;
});
}
/**
* Handles the D3 zoom / scale event.
*/
function onZoomChanged()
{
var newScale = Math.max(d3.event.scale, minScaleExtent);
zoom.scale(newScale);
graph.attr('transform', 'translate(' + d3.event.translate + ')' + ' scale(' + newScale + ')');
}
/**
* Recycles all SVG elements above the top level SVGGElement defining the graph.
*/
function recycleGraph()
{
var childNodes = graph.selectAll('g > *').remove();
if (!Array.isArray(childNodes) && !Array.isArray(childNodes['0'])) { return; }
// Get the child nodes group from selection.
childNodes = childNodes['0'];
for (var cntr = 0; cntr < childNodes.length; cntr++)
{
var childNode = childNodes[cntr];
if (childNode instanceof SVGPathElement) { svgElementMap['path'].push(childNode); }
else if (childNode instanceof SVGCircleElement) { svgElementMap['circle'].push(childNode); }
else if (childNode instanceof SVGTextElement) { svgElementMap['text'].push(childNode); }
else if (childNode instanceof SVGGElement)
{
childNode.removeAttribute('transform'); // Must remove current transform.
svgElementMap['g'].push(childNode);
}
}
}
/**
* Renders the graph after recycling any nodes above the top level `g` SVGGElement.
*
* @param {object} options - Optional parameters:
* ```
* (boolean) redrawOnly - sets layout.alpha to a low value to reduce bounce.
* ```
*/
function renderGraph(options)
{
options = typeof options === 'object' ? options : {};
options.redrawOnly = typeof options.redrawOnly === 'boolean' ? options.redrawOnly : false;
// Recycle all SVG elements above the first SVGGElement.
recycleGraph();
// Lines
// Note: on second render link.source / target will be an object instead of a number.
links = graph.append(getSVG('g')).selectAll('line')
.data(data.links)
.enter().append(getSVG('path'))
.attr('class', 'link')
.attr('marker-end', function() { return 'url(#regular)'; })
.attr('data-source', function(link)
{
return typeof link.source === 'number' ? link.source : link.source.index;
})
.attr('data-target', function(link)
{
return typeof link.target === 'number' ? link.target : link.target.index;
});
// Nodes
nodes = graph.append(getSVG('g')).selectAll('node')
.data(data.nodes)
.enter().append(getSVG('g'))
.call(layout.drag)
.attr('class', 'node');
// Circles
nodes.attr('class', function(node) { return formatClassName('node', node); });
nodes.append(getSVG('circle'))
.attr('id', function(node) { return formatClassName('id', node); })
.attr('class', function(node) { return formatClassName('circle', node) + ' ' + node.packageData.jspmType; })
.attr('r', circleRadius)
.on('contextmenu', onNodeContextClick)
.on('mousedown', onNodeMouseDown.bind(this, nodes, links))
.on('mouseover', onNodeMouseOverOut.bind(this, nodes, links, true))
.on('mouseout', onNodeMouseOverOut.bind(this, nodes, links, false))
.on('dblclick.zoom', function(node) // Centers view on node.
{
d3.event.stopPropagation();
var dcx = (window.innerWidth / 2 - node.x * zoom.scale());
var dcy = (window.innerHeight / 2 - node.y * zoom.scale());
zoom.translate([dcx, dcy]);
graph.transition()
.duration(500)
.attr('transform', 'translate('+ dcx + ',' + dcy + ')scale(' + zoom.scale() + ')');
});
// A copy of the text with a thick white stroke for legibility.
nodes.append(getSVG('text'))
.attr('x', 15)
.attr('y', '.31em')
.attr('class', function(node) { return 'shadow ' + formatClassName('text', node); })
.text(function(node)
{
return (appOptions.showFullNames ? node.packageData.actualPackageName : node.packageData.packageName) +
' (' + node.minLevel +')';
});
nodes.append(getSVG('text'))
.attr('class', function(node)
{
return node.packageData.isAlias ? 'isAliased ' : '' + formatClassName('text', node);
})
.attr('x', 15)
.attr('y', '.31em')
.text(function(node)
{
return (appOptions.showFullNames ? node.packageData.actualPackageName : node.packageData.packageName) +
' (' + node.minLevel +')';
});
// Set the force layout nodes / links and start the bounce.
layout.nodes(data.nodes);
layout.links(data.links);
layout.start();
linkedByIndex = {};
// Build linked index
data.links.forEach(function(node) { linkedByIndex[node.source.index + ',' + node.target.index] = true; });
// Set a low alpha to provide minimal bounce when just redrawing.
if (options.redrawOnly) { layout.alpha(0.01); }
}
/**
* Reverses all graph links.
*/
function reverseGraphLinks()
{
for (var key in dataPackageMap)
{
var graphData = dataPackageMap[key];
graphData.links.forEach(function(link)
{
var linkSource = link.source;
link.source = link.target;
link.target = linkSource;
});
}
}
/**
* Sets all nodes fixed (freeze) or unfixed (unfreeze).
*
* @param {boolean} fixed - Fixed state for all nodes.
*/
function setNodesFixed(fixed)
{
// Resets any fixed node state.
if (nodes) { nodes.each(function(node) { d3.select(this).classed('fixed', node.fixed = fixed); }); }
// Copy existing sim data to any package scope that contains the same node ID.
for (var key in dataPackageMap) { dataPackageMap[key].nodes.forEach(function(node) { node.fixed = fixed; }); }
if (data) { data.allNodesFixed = fixed; }
}
/**
* Helper function that simply defers to the standard update / render process.
*
* @param renderOptions
*/
function updateAll(renderOptions)
{
// Load graph data
updateGraphData();
renderGraph(renderOptions);
updateMenuUI();
updateTableUI();
}
/**
* Filters graph data based on current app option parameters: `currentScope` and `currentLevel`.
*/
function updateGraphData()
{
// Copy existing data / node parameters to new filtered data;
if (data)
{
var existingNodeSimMap = {};
// Collect existing simulation data.
data.nodes.forEach(function(node)
{
existingNodeSimMap[node.id] =
{
weight: node.weight,
x: node.x,
y: node.y,
px: node.px,
py: node.py,
fixed: node.fixed ? node.fixed : false
};
});
// Copy existing sim data to any package scope that contains the same node ID.
for (var mapKey in dataPackageMap)
{
var graphData = dataPackageMap[mapKey];
graphData.nodes.forEach(function(node)
{
if (existingNodeSimMap[node.id])
{
for (var key in existingNodeSimMap[node.id]) { node[key] = existingNodeSimMap[node.id][key]; }
}
});
}
}
var allNodesFixed = true;
// Set new data
data =
{
directed: dataPackageMap[appOptions.currentScope].directed,
multigraph: dataPackageMap[appOptions.currentScope].multigraph,
graph: dataPackageMap[appOptions.currentScope].graph,
links: dataPackageMap[appOptions.currentScope].links.filter(function(link)
{
return link.minLevel <= appOptions.currentLevel;
}),
nodes: dataPackageMap[appOptions.currentScope].nodes.filter(function(node)
{
if (typeof node.fixed === 'undefined' || node.fixed === false) { allNodesFixed = false; }
return node.minLevel <= appOptions.currentLevel;
})
};
data.allNodesFixed = allNodesFixed;
}
/**
* Provides handling of changing menu text & material icons based on current state.
*/
function updateMenuUI()
{
if (data)
{
$('.control-menu li[data-action=toggleFreezeAllNodes]').html(data.allNodesFixed ?
'Unfreeze nodes' : 'Freeze nodes');
}
appMenuToggleOptions.forEach(function(key)
{
var icon = appOptions[key] ? 'check_box' : 'check_box_outline_blank';
$('.control-menu li[data-action=' + key + '] i').html(icon);
});
}
/**
* Builds the table UI with all current node data and associates the active events on each row.
*/
function updateTableUI()
{
var table = $('.control-table tbody');
table.off('mouseenter', 'tr', onControlTableRowMouseOver);
table.off('mouseleave', 'tr', onControlTableRowMouseOver);
table.off('contextmenu', 'tr', onControlTableRowContextClick);
table.empty();
if (data)
{
data.nodes.forEach(function(node)
{
var nd = node.packageData;
var name = appOptions.showFullNames ? nd.actualPackageName : nd.packageName;
var isAliased = nd.isAlias ? ' isAliased' : '';
var tr = $(
'<tr>' +
'<td class="mdl-data-table__cell--non-numeric' + isAliased + '">' + name + '</td>' +
'<td class="mdl-data-table__cell--non-numeric">' + nd.jspmType + '</td>' +
'<td class="mdl-data-table__cell--non-numeric">' + nd.version + '</td>' +
'<td class="mdl-data-table__cell--non-numeric">' + node.minLevel + '</td>' +
'</tr>');
table.append(tr);
tr.on('mouseenter', onControlTableRowMouseOver.bind(this, nodes, links, node, true));
tr.on('mouseleave', onControlTableRowMouseOver.bind(this, nodes, links, node, false));
tr.on('contextmenu', onControlTableRowContextClick.bind(this, node));
});
// Removes sort order for any header and signals update for new data
$('#nodeTable th').removeClass('headerSortDown');
$('#nodeTable th').removeClass('headerSortUp');
$('#nodeTable').trigger('update');
updateTableUIExtent();
}
}
/**
* Updates the node table max-height enabling scrolling as necessary.
*/
function updateTableUIExtent()
{
var tableDiv = $('.control-table-inner');
var nodeTable = $('#nodeTable');
var tableHeight = nodeTable.height();
var offset = tableDiv.offset();
var maxTableHeight = window.innerHeight - offset.top - 20;
tableDiv.css('max-height', maxTableHeight);
nodeTable.css('margin-right', tableHeight > maxTableHeight ? '10px' : '0px');
}
/**
* Determines a new scale that fits the entire graph into view.
*
* @returns {number}
*/
function zoomFit()
{
var bounds = graph.node().getBBox();
var parent = graph.node().parentElement;
var fullWidth = parent.clientWidth, fullHeight = parent.clientHeight;
var width = bounds.width, height = bounds.height;
if (width === 0 || height === 0) { return 1; } // nothing to fit
var scale = 0.75 / Math.max(width / fullWidth, height / fullHeight);
scale = Math.max(Math.min(scale, maxScaleExtent), minScaleExtent);
return scale;
}
// Prevent default browser context menu from being triggered.
$(document).bind('contextmenu', function(event) { event.preventDefault(); });
// Properly handle closing context menu when document mouse down clicked
// This works when a node context click occurs because the new context menu is shown with a small timeout.
$(document).bind('mousedown', hideNodeContextMenu);
/**
* This function removes the selected drag node highlighting if document mouse up event occurs outside the window
* bounds the distance between the document mouse up value is greater than the radius of the current location
* of the circle SVG element.
*/
$(document).bind('mouseup', function(event)
{
if (typeof selectedDragNode !== 'undefined')
{
var pageX = event.pageX;
var pageY = event.pageY;
// Detect a drag / mouse up outside window bounds and automatically reset.
if (pageX < 0 || pageY < 0 || pageX > window.innerWidth || pageY > window.innerHeight)
{
fadeRelatedNodes(selectedDragNode, false, nodes, links);
selectedDragNode = undefined;
return;
}
// Otherwise verify if the mouse up distance is greater than the SVGCircleElement location.
var elm = findElementByNode('circle', selectedDragNode);
var coords = getElementCoords(elm.node());
var a = pageX - coords.x;
var b = pageY - coords.y;
var distance = Math.sqrt(a * a + b * b);
// Remove node highlight and related nodes
if (distance > circleRadius) { fadeRelatedNodes(selectedDragNode, false, nodes, links); }
selectedDragNode = undefined;
}
});
bootstrap();
})();