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();
+}