radial d3 tree
FIRST WORKING VERSION
Change-Id: Idf24da1ec46651991744966c62999f5f08055654
diff --git a/package.json b/package.json
index 2a8a843..1273f87 100644
--- a/package.json
+++ b/package.json
@@ -30,8 +30,8 @@
"marked": "^0.3.2",
"mercury": "^12.0.0",
"routes": "^1.2.0",
+ "d3": "~3.5.5",
"lodash": "~3.0.0",
- "vis": "~3.9.1",
"extend": "~2.0.0",
"bluebird": "~2.3.2"
}
diff --git a/src/components/browse/items/visualize-view/index.css b/src/components/browse/items/visualize-view/index.css
index 931668e..bcd18dd 100644
--- a/src/components/browse/items/visualize-view/index.css
+++ b/src/components/browse/items/visualize-view/index.css
@@ -1,23 +1,80 @@
+@import "common-style/sizes.css";
+@import "common-style/theme.css";
+
.network {
position: absolute;
+ overflow: hidden;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
+.network svg.overlay {
+ overflow: hidden;
+}
+
.vismenu {
position: absolute;
top: 5px;
right: 10px;
}
-paper-fab.mode,
-paper-fab.zoom
+
+paper-fab.zoom, paper-fab.rotate, paper-fab.menubutton
{
- background-color: white;
+ background-color: var(--color-white);
margin: 5px 3px;
}
-paper-fab.selected {
+/*paper-fab.selected {
background-color: #6B0E9C;
- color: white;
+ color: var(--color-white);
+}*/
+
+paper-shadow.dropdown {
+ background-color: var(--color-white);
+ display: none;
+ position: absolute;
+ width: 11em;
+ right: 10px;
+ top: 55px;
+}
+
+paper-shadow.dropdown paper-item::shadow .button-content {
+ padding: 5px 10px;
+ font-size: var(--size-font-small);
+}
+
+paper-shadow.dropdown paper-item div.sc {
+ position: absolute;
+ right: 10px;
+}
+
+/* d3 */
+.node {
+ cursor: pointer;
+}
+
+.node circle {
+ fill: var(--color-white);
+}
+
+.node text {
+ font-size: 12px;
+ font-family: var(--font-family);
+ text-shadow: 4px 4px 3px var(--color-white),
+ -4px -4px 3px var(--color-white);
+}
+.node text:hover {
+ font-size: var(--size-font-large);
+ transition: font-size 0.1s;
+}
+.node text:not(:hover) {
+ transition: font-size 1s;
+ transition-delay: 0.5s;
+}
+
+.link {
+ fill: none;
+ stroke: #ccc;
+ stroke-width: 1.5px;
}
diff --git a/src/components/browse/items/visualize-view/index.js b/src/components/browse/items/visualize-view/index.js
index f228769..7f3529c 100644
--- a/src/components/browse/items/visualize-view/index.js
+++ b/src/components/browse/items/visualize-view/index.js
@@ -1,9 +1,9 @@
var mercury = require('mercury');
var insertCss = require('insert-css');
-var vis = require('vis');
+var d3 = require('d3');
var namespaceService = require('../../../../services/namespace/service');
-
+// var ItemTypes = require('../../../../services/namespace/item-types');
// var getServiceIcon = require('../../get-service-icon');
var log = require('../../../../lib/log'
@@ -15,43 +15,77 @@
module.exports = create;
module.exports.render = render;
-// Matches HEX value of --color-text-primary in theme.css
-var TEXT_COLOR = '#333333';
-// Matches --color-bright in theme.css
-var NODE_COLOR = '#6B0E9C';
-// Matches --color-grey-dark in theme.css
-var NODE_BORDER = '#263238';
-// Matches --color-dark in theme.css
-var ROOT_NODE_COLOR = '#4A068E';
+// Maximum number of levels that are automatically loaded below the root
+var MAX_AUTO_LOAD_DEPTH = 3;
-function create() {}
+var DURATION = 500; // d3 animation duration
+var STAGGERN = 4; // delay for each node
+var STAGGERD = 200; // delay for each depth
+var NODE_DIAMETER = 4; // diameter of circular nodes
+var MIN_ZOOM = 0.5; // minimum zoom allowed
+var MAX_ZOOM = 10; // maximum zoom allowed
+var CIRCLE_STROKE_COLOR = '#00838F';
+var HAS_CHILDREN_COLOR = '#00ACC1';
+var NO_CHILDREN_COLOR = 'white';
+var SELECTED_COLOR = '#E65100'; // color of selected node
+var ZOOM_INC = 0.06; // zoom factor per animation frame
+var PAN_INC = 3; // pan per animation frame
+var ROT_INC = 0.5; // rotation per animation frame
+
+var networkElem; // DOM element for visualization
+var curNode; // currently selected node
+// var curPath; // array of nodes in the path to the currently selected node
+var showDetails; // function to show details in right pane
+
+var width, height; // size of the diagram
+
+var curX, curY, curZ, curR; // transforms
+
+var diagonal; // d3 diagonal projection for use by the node paths
+var treeD3; // d3 tree layout
+var svgBase, svgGroup;
+
+var root, namespaceRoot; // data trees
+var rootIndex = {}; // index id to nodes
+
+// keyboard key codes
+var KEY_PLUS = 187; // + (zoom in)
+var KEY_MINUS = 189; // - (zoom out)
+var KEY_SLASH = 191; // / (slash)
+var KEY_PAGEUP = 33; // (rotate CCW)
+var KEY_PAGEDOWN = 34; // (rotate CW)
+var KEY_LEFT = 37; // left arrow
+var KEY_UP = 38; // up arrow
+var KEY_RIGHT = 39; // right arrow
+var KEY_DOWN = 40; // down arrow
+var KEY_SPACE = 32; // (expand node)
+var KEY_RETURN = 13; // (expand tree)
+var KEY_HOME = 36; // (center root)
+var KEY_END = 35; // (center selection)
+
+function create() {
+ // console.log('create');
+}
function render(itemsState, browseState, browseEvents, navEvents) {
insertCss(css);
+ // console.log('render');
+
return [
- new TreeWidget(browseState, browseEvents),
+ new D3Widget(browseState, browseEvents),
h('div.vismenu', { // visualization menu
}, [
- // to add hierarchy view, add this button and the vismode handler
- // h('paper-fab.mode', {
- // attributes: {
- // mini: true,
- // icon: 'image:grain',
- // title: 'change mode',
- // 'aria-label': 'change mode'
- // },
- // 'ev-tap': vismode
- // }),
h('paper-fab.zoom', {
attributes: {
mini: true,
+ raised: true,
icon: 'add',
title: 'zoom in',
'aria-label': 'zoom in'
},
- 'ev-down': zoom.bind(undefined, true),
- 'ev-up': stopzoom
+ 'ev-down': keydown.bind(undefined, KEY_PLUS),
+ 'ev-up': keyup.bind(undefined, KEY_PLUS)
}),
h('paper-fab.zoom', {
attributes: {
@@ -60,258 +94,844 @@
title: 'zoom out',
'aria-label': 'zoom out'
},
- 'ev-down': zoom.bind(undefined, false),
- 'ev-up': stopzoom
+ 'ev-down': keydown.bind(undefined, KEY_MINUS),
+ 'ev-up': keyup.bind(undefined, KEY_MINUS),
}),
- ])
+ h('paper-fab.rotate', {
+ attributes: {
+ mini: true,
+ icon: 'image:rotate-left',
+ title: 'rotate counterclockwise',
+ 'aria-label': 'rotate counterclockwise'
+ },
+ 'ev-down': keydown.bind(undefined, KEY_PAGEUP),
+ 'ev-up': keyup.bind(undefined, KEY_PAGEUP)
+ }),
+ h('paper-fab.rotate', {
+ attributes: {
+ mini: true,
+ icon: 'image:rotate-right',
+ title: 'rotate clockwise',
+ 'aria-label': 'rotate clockwise'
+ },
+ 'ev-down': keydown.bind(undefined, KEY_PAGEDOWN),
+ 'ev-up': keyup.bind(undefined, KEY_PAGEDOWN)
+ }),
+ h('paper-fab.menubutton', {
+ attributes: {
+ mini: true,
+ icon: 'more-horiz',
+ title: 'visualization commands',
+ 'aria-label': 'visualization commands'
+ },
+ 'ev-click': dropmenu
+ })
+ ] ),
+ h('paper-shadow.dropdown', {
+ attributes: {
+ z: 3 // height above background
+ }
+ }, [
+ h('paper-item', { 'ev-click': tool.bind(undefined, KEY_SPACE) },
+ [ h('div', 'Toggle Node'), h('div.sc', 'Space') ]),
+ h('paper-item', { 'ev-click': tool.bind(undefined, KEY_RETURN) },
+ [ h('div', 'Toggle Subtree'), h('div.sc', 'Return') ]),
+ h('paper-item', { 'ev-click': tool.bind(undefined, KEY_HOME) },
+ [ h('div', 'View Home'), h('div.sc', 'Home') ]),
+ h('paper-item', { 'ev-click': tool.bind(undefined, KEY_END) },
+ [ h('div', 'View Selected'), h('div.sc', 'End') ]) // ,
+ // move selection using keyboard shift-arrow
+ // h('paper-item', { 'ev-click': tool.bind(undefined, KEY_UP, true) },
+ // [ h('div', 'Select Previous') ]),
+ // h('paper-item', { 'ev-click': tool.bind(undefined, KEY_DOWN, true) },
+ // [ h('div', 'Select Next') ]),
+ // h('paper-item', { 'ev-click': tool.bind(undefined, KEY_LEFT, true) },
+ // [ h('div', 'Select Parent') ]),
+ // h('paper-item', { 'ev-click': tool.bind(undefined, KEY_RIGHT, true) },
+ // [ h('div', 'Select Child') ])
+ ]
+ )
];
}
-// The visjs visualization
-var network, rootNodeId;
-
-// change visualization between hierarchical and network modes
-// function vismode() {
-// network._restoreNodes();
-// var el = document.querySelector('paper-fab.mode');
-// if (network.constants.hierarchicalLayout.enabled) {
-// el.className = 'mode';
-// network.constants.hierarchicalLayout.enabled = false;
-// network.constants.physics.hierarchicalRepulsion.enabled = false;
-// network.constants.physics.barnesHut.enabled = true;
-// network.focusOnNode(rootNodeId, { animation: true });
-// network.zoomExtent(true);
-// } else {
-// el.className = 'mode selected';
-// network.constants.physics.barnesHut.enabled = false;
-// network.constants.physics.hierarchicalRepulsion = {
-// enabled: true,
-// nodeDistance: 70
-// };
-// network.constants.hierarchicalLayout.enabled = true;
-// network.constants.physics.hierarchicalRepulsion.enabled = true;
-// network.constants.physics.hierarchicalRepulsion.nodeDistance = 70;
-// network._setupHierarchicalLayout();
-// network.zoomExtent(true);
-// }
-// network._loadSelectedForceSolver();
-// network.moving = true;
-// network.start();
-// }
-
-var ZOOM_FACTOR = 0.01; // same as network.constants.keyboard.speed.zoom
-
-// start zooming on button press
-function zoom(zin, event) {
- var zi = zin ? ZOOM_FACTOR : -ZOOM_FACTOR;
- var selnodes; // selected nodes
- if (event.shiftKey) {
- selnodes = network.getSelection();
- if (selnodes.nodes.length > 0) {
- network.focusOnNode(selnodes.nodes[0], { animation: true });
- } else {
- network.zoomExtent(true);
- }
- }
- network.zoomIncrement = zi;
- network.start();
-}
-
-// stop zooming on button release
-function stopzoom() {
- network.zoomIncrement = 0;
-}
-
-// repaint visualization canvas when window resizes
-window.addEventListener('resize', function redraw(e) {
- if (network) {
- network.redraw();
- }
-});
-
-// Maximum number of levels that are automatically loaded below the root
-var MAX_AUTO_LOAD_DEPTH = 4;
-
-function TreeWidget(browseState, browseEvents) {
+// Constructor for mercury widget for d3 element
+function D3Widget(browseState, browseEvents) {
+ // console.log('new D3Widget');
this.browseState = browseState;
this.browseEvents = browseEvents;
- this.nodes = new vis.DataSet();
- this.edges = new vis.DataSet();
+
+ showDetails = browseEvents.selectItem.bind(browseEvents);
}
-TreeWidget.prototype.type = 'Widget';
+D3Widget.prototype.type = 'Widget';
-// Dom element to initialize the network in
-var networkElem;
+D3Widget.prototype.init = function() {
-TreeWidget.prototype.init = function() {
- this.initNetworkElem();
+ // console.log('D3Widget.init');
- requestAnimationFrame(this.updateNetwork.bind(this));
+ if (!networkElem) {
+ networkElem = document.createElement('div');
+ networkElem.className = 'network';
+ networkElem.setAttribute('tabindex', 0); // allow focus
+ requestAnimationFrame(initD3);
+ }
// wrap in a new element, needed for Mercury vdom to patch properly.
var wrapper = document.createElement('div');
wrapper.appendChild(networkElem);
- return wrapper;
-};
-TreeWidget.prototype.initNetworkElem = function() {
- if (!networkElem) {
- networkElem = document.createElement('div');
- networkElem.className = 'network';
- }
+ // this.updateRoot();
+ requestAnimationFrame(this.updateRoot.bind(this));
+
+ return wrapper;
};
// Keep track of previous namespace that was browsed to so we can
// know when navigating to a different namespace happens.
var previousNamespace;
-TreeWidget.prototype.updateNetwork = function() {
- if (network && network.constants.hierarchicalLayout.enabled) {
- document.querySelector('paper-fab.mode').className = 'mode selected';
- }
-
- if (previousNamespace === this.browseState.namespace) {
- return;
- }
-
- previousNamespace = this.browseState.namespace;
-
- var self = this;
-
- // Add the initial node.
- rootNodeId = this.browseState.namespace;
- this.nodes.add({
- id: rootNodeId,
- label: rootNodeId || '<root>',
- level: 0,
- shape: 'star',
- color: {
- background: ROOT_NODE_COLOR,
- border: NODE_BORDER
- }
- });
-
- // Load the subnodes.
- this.rootNode = this.nodes.get(rootNodeId);
- this.loadSubNodes(this.rootNode);
-
- var options = {
- hover: false,
- selectable: true, // Need this or nodes won't be click-able
- smoothCurves: false,
- physics: {
- barnesHut: {
- enabled: true,
- gravitationalConstant: -2200,
- centralGravity: 0.2,
- springLength: 64,
- springConstant: 0.075,
- damping: 0.12
- }
- },
- hierarchicalLayout: {
- enabled: false,
- direction: 'LR',
- nodeSpacing: 70,
- levelSeparation: 180
- },
- keyboard: { speed: { x: -2, y: -2, zoom: 0.01 }},
- edges: {
- width: 1
- },
- nodes: {
- radiusMin: 16,
- radiusMax: 32,
- fontColor: TEXT_COLOR
- }
- };
-
- // Start drawing the network.
- network = new vis.Network(networkElem, {
- nodes: this.nodes,
- edges: this.edges
- }, options);
-
- // Event listeners.
- network.on('click', function onClick(data) {
- // refresh side view
- var nodeId = data.nodes[0];
- var node = network.nodes[nodeId];
-
- if (node) {
- self.browseEvents.selectItem({
- name: nodeId
- });
- }
- });
-
- network.on('doubleClick', function onClick(data) {
- // drill
- var nodeId = data.nodes[0];
- var node = network.nodes[nodeId];
-
- if (node && !node.subNodesLoaded) {
- self.loadSubNodes(node);
- }
- });
-
- return network;
+D3Widget.prototype.update = function(prev, networkElem) {
+ // console.log('D3Widget.update', this);
+ this.updateRoot();
+ updateD3(root);
};
-TreeWidget.prototype.loadSubNodes = function(node) {
+// build new data tree
+D3Widget.prototype.updateRoot = function() {
+
+ if (previousNamespace === this.browseState.namespace) { return; }
+ // console.log('D3Widget.updateRoot', this.browseState.namespace);
+ previousNamespace = this.browseState.namespace;
+
+ // Add the initial node
+ var rootNodeId = this.browseState.namespace;
+ var basename = namespaceService.util.basename(rootNodeId);
+
+ root = rootIndex[rootNodeId];
+ if (root) {
+ namespaceRoot = root;
+ } else {
+ root = namespaceRoot =
+ {
+ id: rootNodeId,
+ name: basename || '<root>',
+ level: 0,
+ x0: curY,
+ y0: 0
+ };
+ rootIndex[rootNodeId] = root; // put in index
+ }
+ loadSubItems(namespaceRoot); // Load the subnodes
+ selectNode(root);
+};
+
+// initialize d3 HTML elements
+function initD3() {
+
+ // console.log('initD3');
+
+ // size of the diagram
+ width = networkElem.offsetWidth - 20;
+ height = networkElem.offsetHeight - 20;
+
+ // current pan, zoom, and rotation
+ curX = width / 2;
+ curY = height / 2;
+ curZ = 1.0; // current zoom
+ curR = 0; // current rotation
+
+ // d3 diagonal projection for use by the node paths
+ diagonal= d3.svg.diagonal.radial().
+ projection(function (d) {
+ return [d.y, d.x / 180 * Math.PI];
+ });
+
+ // d3 tree layout
+ treeD3 = d3.layout.tree().
+ // circular coordinates to fit in window
+ // 120 is to allow space for text strings
+ size([360, Math.min(width, height) / 2 - 120]).
+ // space between nodes, depends on if they have same parent
+ // dividing by a.depth is for radial coordinates
+ separation(function (a, b) {
+ return (a.parent === b.parent ? 1 : 2) / (a.depth + 1);
+ });
+
+ // define the svgBase, attaching a class for styling and the zoomListener
+ svgBase = d3.select('.network').append('svg').
+ attr('width', width).
+ attr('height', height).
+ attr('class', 'overlay').
+ on('mousedown', mousedown);
+
+ // Group which holds all nodes and manages pan, zoom, rotate
+ svgGroup = svgBase.append('g').
+ attr('transform', 'translate(' + curX + ',' + curY + ')');
+
+ networkElem.focus();
+ d3.select('.network'). // set up document events
+ on('wheel', wheel). // zoom, rotate
+ on('keydown', keydown).
+ on('keyup', keyup).
+ on('mouseover', function() {
+ networkElem.focus();
+ });
+ d3.select(window).on('resize', resize);
+}
+
+// draw tree using d3js
+function updateD3(subroot) {
+ // console.log('updateD3');
+
+ // length of d3 animation
+ var duration = d3.event && d3.event.altKey ? DURATION * 4 : DURATION;
+
+ // Compute the new tree layout.
+ var d3nodes = treeD3.nodes(root);
+ var d3links = treeD3.links(d3nodes);
+
+ // Update the view
+ svgGroup.transition().duration(duration).
+ attr('transform',
+ 'rotate(' + curR + ' ' + curX + ' ' + curY +
+ ')translate(' + curX + ' ' + curY +
+ ')scale(' + curZ + ')');
+
+ var gnode = svgGroup.selectAll('g.node').
+ data(d3nodes, function(d) {
+ return d.id;
+ });
+
+ // Enter any new nodes at the parent's previous position
+ var nodeEnter = gnode.enter().insert('g', ':first-child').
+ attr('class', 'node').
+ attr('transform', 'rotate(' + (subroot.x0 - 90) +
+ ')translate(' + subroot.y0 + ')').
+ on('click', click).on('dblclick', dblclick);
+
+ nodeEnter.append('title').text(function(d) { return d.id; });
+
+ nodeEnter.append('circle').
+ attr('r', 1e-6).
+ style('fill', function (d) {
+ return d._children || d.isExpandable && !d.children ?
+ HAS_CHILDREN_COLOR : NO_CHILDREN_COLOR;
+ });
+
+ nodeEnter.append('text').
+ text(function (d) {
+ return d.name;
+ }).
+ style('opacity', 0.9).
+ style('fill-opacity', 0).
+ attr('transform', function () {
+ return ((subroot.x0 + curR) % 360 <= 180 ?
+ 'translate(8)scale(' :
+ 'rotate(180)translate(-8)scale('
+ ) + reduceZ(curZ) + ')';
+ });
+
+ // update existing graph nodes
+
+ // set circle fill depending on whether it has children and is collapsed
+ gnode.select('circle').
+ attr('r', NODE_DIAMETER * reduceZ(curZ)).
+ style('fill', function (d) {
+ return d._children || d.isExpandable && !d.children ?
+ HAS_CHILDREN_COLOR : NO_CHILDREN_COLOR;
+ }).
+ attr('stroke', function(d) {
+ return d.selected ? SELECTED_COLOR : CIRCLE_STROKE_COLOR;
+ }).
+ attr('stroke-width', function(d) {
+ return d.selected ? 3 : 1.5;
+ });
+
+ gnode.select('text').
+ attr('text-anchor', function (d) {
+ return (d.x + curR) % 360 <= 180 ? 'start' : 'end';
+ }).
+ attr('transform', function (d) {
+ return ((d.x + curR) % 360 <= 180 ?
+ 'translate(8)scale(' :
+ 'rotate(180)translate(-8)scale('
+ ) + reduceZ(curZ) +')';
+ }).
+ attr('fill', function(d) {
+ return d.selected ? SELECTED_COLOR : 'black';
+ });
+
+ var nodeUpdate = gnode.transition().duration(duration).
+ delay(function(d, i) {
+ return i * STAGGERN +
+ Math.max(0, d.depth - curNode.depth) * STAGGERD;
+ }).
+ attr('transform', function (d) {
+ return 'rotate(' + (d.x - 90) + ')translate(' + d.y + ')';
+ });
+
+ nodeUpdate.select('circle').
+ attr('r', NODE_DIAMETER * reduceZ(curZ)).
+ style('fill', function (d) {
+ return d._children || d.isExpandable && !d.children ?
+ HAS_CHILDREN_COLOR : NO_CHILDREN_COLOR;
+ });
+
+ nodeUpdate.select('text').
+ style('fill-opacity', 1).
+ attr('dy', '.35em');
+
+ // Transition exiting nodes to the parent's new position and remove
+ var nodeExit = gnode.exit().transition().duration(duration).
+ attr('transform', function () {
+ return 'rotate(' + (subroot.x - 90) +')translate(' + subroot.y + ')';
+ }).
+ remove();
+
+ nodeExit.select('circle').attr('r', 0);
+ nodeExit.select('text').style('fill-opacity', 0);
+
+ // Update the links…
+ var glink = svgGroup.selectAll('path.link').
+ data(d3links, function(d) {
+ return d.target.id;
+ });
+
+ // Enter any new links at the parent's previous position
+ glink.enter().insert('path', 'g').
+ attr('class', 'link').
+ attr('d', function () {
+ var o = {
+ x: subroot.x0,
+ y: subroot.y0
+ };
+ return diagonal({
+ source: o,
+ target: o
+ });
+ });
+
+ // Transition links to their new position
+ glink.transition().duration(duration).
+ delay(function(d, i) {
+ return i * STAGGERN +
+ Math.max(0, d.source.depth - curNode.depth) * STAGGERD;
+ }).
+ attr('d', diagonal);
+
+ // Transition exiting nodes to the parent's new position
+ glink.exit().transition().duration(duration).
+ attr('d', function () {
+ var o = {
+ x: subroot.x,
+ y: subroot.y
+ };
+ return diagonal({
+ source: o,
+ target: o
+ });
+ }).
+ remove();
+
+ // Stash the old positions for transition
+ d3nodes.forEach(function (d) {
+ d.x0 = d.x;
+ d.y0 = d.y;
+ });
+
+} // end updateD3
+
+// find place to insert new node in children
+var bisectfun = d3.bisector(function(d) { return d.name; }).right;
+
+// load children items
+function loadSubItems(node) {
+ if (node.subNodesLoaded) { return; }
+ // console.log('loadSubItems', node);
var namespace = node.id;
+ if (node._children) {
+ node.children = node._children;
+ node._children = null;
+ }
node.subNodesLoaded = true;
- node.title = undefined;
- var self = this;
+
namespaceService.getChildren(namespace).then(function(resultObservable) {
mercury.watch(resultObservable, function(results) {
- // TODO(aghassemi) support removed and updated nodes when we switch to
- // watchGlob
- var existingIds = self.nodes.getIds();
- var nodesToAdd = results.filter(function(item) {
- var isNew = existingIds.indexOf(item.objectName) === -1;
- return isNew;
- });
- var newNodes = nodesToAdd.map(function(item) {
+ // TODO(wmleler) support removed and updated nodes for watchGlob
- var shape = 'dot';
- var color = {
- background: NODE_COLOR,
- border: NODE_BORDER
- };
- return {
+ var item = results._diff[0][2]; // changed item from Mercury
+ var name = item.mountedName;
+ var children = node.children;
+ var newNode;
+ // console.log('subNodesLoaded', results);
+
+ if (item._diff === undefined) { // create new child node
+ newNode = {
id: item.objectName,
- label: item.mountedName,
+ name: name,
level: node.level + 1,
- shape: shape,
- color: color
+ isExpandable: item.isGlobbable,
+ itemType: item.itemType,
+ // title: ItemTypes[item.itemType],
+ x0: node.x,
+ y0: node.y
};
- });
- var newEdges = nodesToAdd.map(function(item) {
- return {
- from: namespace,
- to: item.objectName,
- color: TEXT_COLOR
- };
- });
- newNodes.forEach(function(item) {
- // recurse if within the MAX_AUTO_LOAD_DEPTH
- if (item.level - self.rootNode.level < MAX_AUTO_LOAD_DEPTH) {
- self.loadSubNodes(item);
- } else {
- item.title = 'Double-click to expand';
+ rootIndex[item.objectName] = newNode; // put in index
+ if (children === undefined) { // first child
+ node.children = [ newNode ];
+ } else { // insert in order
+ children.splice(bisectfun(children, name), 0, newNode);
}
- });
- self.nodes.add(newNodes);
- self.edges.add(newEdges);
+ if (newNode.level - root.level < MAX_AUTO_LOAD_DEPTH) {
+ loadSubItems(newNode);
+ }
+ batchUpdates(node);
+ } else { // update existing child node
+ children.some(function(ch) {
+ if (ch.name === name) {
+ if (item._diff.isGlobbable !== undefined &&
+ item._diff.isGlobbable !== ch.isExpandable) {
+ ch.isExpandable = item._diff.isGlobbable;
+ batchUpdates(node);
+ }
+ if (item._diff.itemType !== undefined &&
+ item._diff.itemType !== ch.itemType) {
+ ch.itemType = item._diff.itemType;
+ batchUpdates(node);
+ }
+ return true;
+ }
+ return false;
+ });
+ }
});
}).catch(function(err) {
log.error('glob failed', err);
});
-};
+} // end loadSubItems
-TreeWidget.prototype.update = function(prev, networkElem) {
- requestAnimationFrame(this.updateNetwork.bind(this));
-};
+var batchNode = null;
+var batchTimer = null;
+
+// batch together updates for a single node
+function batchUpdates(node) {
+ if (node === batchNode) {
+ if (batchTimer === null && batchNode) {
+ batchTimer = setTimeout(function() {
+ var n = batchNode;
+ batchNode = null;
+ updateD3(n);
+ }, 500);
+ }
+ return;
+ }
+ if (batchNode !== null) {
+ if (batchTimer) {
+ clearTimeout(batchTimer);
+ batchTimer = null;
+ }
+ updateD3(batchNode);
+ }
+ batchNode = node;
+}
+
+function selectNode(node) {
+ if (curNode) {
+ delete curNode.selected;
+ }
+ curNode = node;
+ curNode.selected = true;
+ // curPath = []; // filled in by fullpath
+ // d3.select('#selection').html(fullpath(node));
+ showDetails({ name: node.id });
+}
+
+// for displaying full path of node in tree
+// function fullpath(d, idx) {
+// // console.log('fullpath', d);
+// idx = idx || 0;
+// // curPath.push(d);
+// return (d.parent ? fullpath(d.parent, curPath.length) : '') +
+// '/<span class="nodepath" data-sel="'+ idx +'">' +
+// d.name + '</span>';
+// }
+
+// set view with no animation
+function setview() {
+ svgGroup.attr('transform',
+ 'rotate(' + curR + ' ' + curX + ' ' + curY +
+ ')translate(' + curX + ' ' + curY +
+ ')scale(' + curZ + ')');
+ svgGroup.selectAll('text').
+ attr('text-anchor', function (d) {
+ return (d.x + curR) % 360 <= 180 ? 'start' : 'end';
+ }).
+ attr('transform', function (d) {
+ return ((d.x + curR) % 360 <= 180 ?
+ 'translate(8)scale(' :
+ 'rotate(180)translate(-8)scale('
+ ) + reduceZ(curZ) +')';
+ });
+ svgGroup.selectAll('circle').
+ attr('r', NODE_DIAMETER * reduceZ(curZ));
+}
+
+//
+// Helper functions for collapsing and expanding nodes
+//
+
+// Toggle expand / collapse
+function toggle(d) {
+ if (d.children) {
+ d._children = d.children;
+ d.children = null;
+ } else if (d._children) {
+ d.children = d._children;
+ d._children = null;
+ } else {
+ loadSubItems(d);
+ }
+}
+
+function toggleTree(d) {
+ if (d.children) {
+ collapseTree(d);
+ } else {
+ expandTree(d);
+ loadSubItems(d);
+ }
+}
+
+// function expand(d) {
+// if (d._children) {
+// d.children = d._children;
+// d._children = null;
+// }
+// }
+
+// function collapse(d) {
+// if (d.children) {
+// d._children = d.children;
+// d.children = null;
+// }
+// }
+
+// expand all children, whether expanded or collapsed
+function expandTree(d) {
+ if (d._children) {
+ d.children = d._children;
+ d._children = null;
+ }
+ if (d.children) {
+ d.children.forEach(expandTree);
+ }
+}
+
+// collapse all children
+function collapseTree(d) {
+ if (d.children) {
+ d._children = d.children;
+ d.children = null;
+ }
+ if (d._children) {
+ d._children.forEach(collapseTree);
+ }
+}
+
+var moveX = 0, moveY = 0, moveZ = 0, moveR = 0; // animations
+var keysdown = []; // which keys are currently down
+var animation = null;
+var aniTime = null; // time since last animation frame
+
+// update animation frame
+function frame(frametime) {
+ var diff = aniTime ? (frametime - aniTime) / 16 : 0;
+ aniTime = frametime;
+
+ var dz = Math.pow(1.2, diff * moveZ);
+ var newZ = limitZ(curZ * dz);
+ dz = newZ / curZ;
+ curZ = newZ;
+ curX += diff * moveX - (width / 2- curX) * (dz - 1);
+ curY += diff * moveY - (height / 2 - curY) * (dz - 1);
+ curR = limitR(curR + diff * moveR);
+ setview();
+ animation = requestAnimationFrame(frame);
+}
+
+// enforce zoom extent
+function limitZ(z) {
+ return Math.max(Math.min(z, MAX_ZOOM), MIN_ZOOM);
+}
+
+// keep rotation between 0 and 360
+function limitR(r) {
+ return (r + 360) % 360;
+}
+
+// limit size of text and nodes as scale increases
+function reduceZ(z) {
+ return Math.pow(1.1, -z);
+}
+
+//
+// d3 event handlers
+//
+
+// switchroot is done through right pane
+
+function resize() { // window resize
+ var oldwidth = width;
+ var oldheight = height;
+ width = networkElem.offsetWidth - 20;
+ height = networkElem.offsetHeight - 20;
+ treeD3.size([360, Math.min(width, height) / 2 - 120]);
+ svgBase.attr('width', width).attr('height', height);
+ curX += (width - oldwidth) / 2;
+ curY += (height - oldheight) / 2;
+ svgGroup.attr('transform',
+ 'rotate(' + curR + ' ' + curX + ' ' + curY +
+ ')translate(' + curX + ' ' + curY +
+ ')scale(' + curZ + ')');
+ updateD3(root);
+}
+
+function click(d) { // Select node
+ if (d3.event.defaultPrevented || d === curNode) { return; }
+ selectNode(d);
+ updateD3(d);
+ d3.event.preventDefault();
+}
+
+function dblclick(d) { // Toggle children of node
+ if (d3.event.defaultPrevented) { return; } // click suppressed
+ if (d3.event.shiftKey) {
+ toggleTree(d);
+ } else {
+ toggle(d);
+ }
+ updateD3(d);
+ d3.event.preventDefault();
+}
+
+var startposX, startposY; // initial position on mouse button down for pan
+
+function mousedown() { // pan action from mouse drag
+ if (d3.event.which !== 1) { return; } // ingore other mouse buttons
+ startposX = curX - d3.event.clientX;
+ startposY = curY - d3.event.clientY;
+ d3.select(document).on('mousemove', mousemove);
+ d3.select(document).on('mouseup', mouseup);
+ networkElem.focus();
+ d3.event.preventDefault();
+}
+
+function mousemove() { // drag
+ curX = startposX + d3.event.clientX;
+ curY = startposY + d3.event.clientY;
+ setview();
+ d3.event.preventDefault();
+}
+
+function mouseup() { // cleanup
+ d3.select(document).on('mousemove', null);
+ d3.select(document).on('mouseup', null);
+}
+
+function wheel() { // mousewheel (including left-right)
+ var dz, newZ;
+ var slow = d3.event.altKey ? 0.25 : 1;
+ if (d3.event.wheelDeltaY !== 0) { // up-down = zoom
+ dz = Math.pow(1.2, d3.event.wheelDeltaY * 0.001 * slow);
+ newZ = limitZ(curZ * dz);
+ dz = newZ / curZ;
+ curZ = newZ;
+ // zoom around mouse position
+ curX -= (d3.event.clientX - curX) * (dz - 1);
+ curY -= (d3.event.clientY - curY) * (dz - 1);
+ setview();
+ }
+ if (d3.event.wheelDeltaX !== 0) { // left-right = rotate
+ curR = limitR(curR + d3.event.wheelDeltaX * 0.01 * slow);
+ updateD3(root);
+ }
+}
+
+// Keyboard actions
+// Almost all UI actions pass through here,
+// even if they are not originally generated from the keyboard
+// There are two types of actions:
+// * Press-and-Hold actions perform some action while they are pressed,
+// until they are released, like pan, zoom, and rotate. These actions end
+// with "break", so the key can be saved, and keyup can stop the action.
+// * Click actions mostly happen on keydown, like toggling children.
+function keydown(key, shift) {
+ if (!key) {
+ key = d3.event.which; // fake key
+ shift = d3.event.shiftKey;
+ }
+ var parch; // parent's children
+ // TODO(wm): pass altKey through
+ var slow = (d3.event && d3.event.altKey) ? 0.25 : 1;
+ if (keysdown.indexOf(key) >= 0) { return; } // defeat auto repeat
+ switch(key) {
+ case KEY_PLUS: // zoom in
+ moveZ = ZOOM_INC * slow;
+ break;
+ case KEY_MINUS: // zoom out
+ moveZ = -ZOOM_INC * slow;
+ break;
+ case KEY_SLASH: // toggle root to selection
+ root = root === curNode ? namespaceRoot : curNode;
+ updateD3(root);
+ return;
+ case KEY_PAGEUP: // rotate counterclockwise
+ moveR = -ROT_INC * slow;
+ break;
+ case KEY_PAGEDOWN: // zoom out
+ moveR = ROT_INC * slow; // rotate clockwise
+ break;
+ case KEY_LEFT:
+ if (shift) { // move selection to parent
+ if (!curNode) {
+ selectNode(root);
+ } else if (curNode.parent) {
+ selectNode(curNode.parent);
+ }
+ updateD3(curNode);
+ return;
+ }
+ moveX = -PAN_INC * slow; // pan left
+ break;
+ case KEY_UP:
+ if (shift) { // move selection to previous child
+ if (!curNode) {
+ selectNode(root);
+ } else if (curNode.parent) {
+ parch = curNode.parent.children;
+ selectNode(parch[(parch.indexOf(curNode) +
+ parch.length - 1) % parch.length]);
+ }
+ updateD3(curNode);
+ return;
+ }
+ moveY = -PAN_INC * slow; // pan up
+ break;
+ case KEY_RIGHT:
+ if (shift) { // move selection to first/last child
+ if (!curNode) {
+ selectNode(root);
+ } else {
+ if (curNode.children) {
+ selectNode(curNode.children[0]);
+ }
+ }
+ updateD3(curNode);
+ return;
+ }
+ moveX = PAN_INC * slow; // pan right
+ break;
+ case KEY_DOWN:
+ if (shift) { // move selection to next child
+ if (!curNode) {
+ selectNode(root);
+ } else if (curNode.parent) {
+ parch = curNode.parent.children;
+ selectNode(
+ parch[(parch.indexOf(curNode) + 1) % parch.length]);
+ }
+ updateD3(curNode);
+ return;
+ }
+ moveY = PAN_INC * slow; // pan down
+ break;
+ case KEY_SPACE: // expand/collapse node
+ if (!curNode) {
+ selectNode(root);
+ }
+ toggle(curNode);
+ updateD3(curNode);
+ return;
+ case KEY_RETURN: // expand/collapse tree
+ if (!curNode) {
+ selectNode(root);
+ }
+ if (shift) {
+ expandTree(curNode);
+ loadSubItems(curNode);
+ } else {
+ toggleTree(curNode);
+ }
+ updateD3(curNode);
+ return;
+ case KEY_HOME: // reset transform
+ if (shift) {
+ root = namespaceRoot;
+ }
+ curX = width / 2;
+ curY = height / 2;
+ curR = 0;
+ curZ = 1;
+ updateD3(root);
+ return;
+ case KEY_END: // zoom to selection
+ if (!curNode) { return; }
+ curX = width / 2 - curNode.y * curZ;
+ curY = height / 2;
+ curR = limitR(90 - curNode.x);
+ updateD3(curNode);
+ return;
+ default: return; // ignore other keys
+ }
+ keysdown.push(key);
+ // start animation if anything happening
+ if (keysdown.length > 0 && animation === null) {
+ animation = requestAnimationFrame(frame);
+ }
+}
+
+function keyup(key) {
+ key = key || d3.event.which;
+ var pos = keysdown.indexOf(key);
+ if (pos < 0) { return; }
+
+ switch(key) {
+ case KEY_PLUS: // - = zoom out
+ case KEY_MINUS: // + = zoom in
+ moveZ = 0;
+ break;
+ case KEY_PAGEUP: // page up = zoom out / rotate
+ case KEY_PAGEDOWN: // page down = zoom in / rotate
+ moveR = 0;
+ break;
+ case KEY_LEFT: // left arrow
+ case KEY_RIGHT: // right arrow
+ moveX = 0;
+ break;
+ case KEY_UP: // up arrow
+ case KEY_DOWN: // down arrow
+ moveY = 0;
+ break;
+ }
+ keysdown.splice(pos, 1); // remove key
+ if (keysdown.length > 0 || animation === null) { return; }
+ cancelAnimationFrame(animation);
+ animation = aniTime = null;
+}
+
+var menudisplayed = false;
+
+// display the dropdown menu
+function dropmenu() {
+ document.querySelector('paper-shadow.dropdown').style.display =
+ menudisplayed ? 'none' : 'block';
+ menudisplayed = !menudisplayed;
+ networkElem.focus();
+}
+
+function tool(key, shift) {
+ keydown(key, shift);
+ dropmenu();
+ networkElem.focus();
+}