GUI -- TopoView - Refactored a bunch of functions out of topoForce into topoD3.
- finally, topoForce is a respectable sub-1000 LOC :)

Change-Id: I2a9ac2881c9d54663faecf338c512a368f17bc34
diff --git a/web/gui/src/main/resources/core/js.html b/web/gui/src/main/resources/core/js.html
index 35eeb3b..581b190 100644
--- a/web/gui/src/main/resources/core/js.html
+++ b/web/gui/src/main/resources/core/js.html
@@ -1,5 +1,6 @@
 <script src="app/view/sample/sample.js"></script>
 <script src="app/view/topo/topo.js"></script>
+<script src="app/view/topo/topoD3.js"></script>
 <script src="app/view/topo/topoEvent.js"></script>
 <script src="app/view/topo/topoFilter.js"></script>
 <script src="app/view/topo/topoForce.js"></script>
diff --git a/web/gui/src/main/webapp/app/view/topo/topoD3.js b/web/gui/src/main/webapp/app/view/topo/topoD3.js
new file mode 100644
index 0000000..ced1656
--- /dev/null
+++ b/web/gui/src/main/webapp/app/view/topo/topoD3.js
@@ -0,0 +1,476 @@
+/*
+ * Copyright 2015 Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ ONOS GUI -- Topology D3 Module.
+ Functions for manipulating the D3 visualizations of the Topology
+ */
+
+(function () {
+    'use strict';
+
+    // injected refs
+    var $log, fs, sus, is, ts;
+
+    // api to topoForce
+    var api;
+    /*
+     node()                 // get ref to D3 selection of nodes
+     link()                 // get ref to D3 selection of links
+     linkLabel()            // get ref to D3 selection of link labels
+     instVisible()          // true if instances panel is visible
+     posNode()              // position node
+     showHosts()            // true if hosts are to be shown
+     restyleLinkElement()   // update link styles based on backing data
+     updateLinkLabelModel() // update backing data for link labels
+     */
+
+    // configuration
+    var devCfg = {
+            xoff: -20,
+            yoff: -18
+        },
+        labelConfig = {
+            imgPad: 16,
+            padLR: 4,
+            padTB: 3,
+            marginLR: 3,
+            marginTB: 2,
+            port: {
+                gap: 3,
+                width: 18,
+                height: 14
+            }
+        },
+        icfg;
+
+    // internal state
+    var deviceLabelIndex = 0,
+        hostLabelIndex = 0;
+
+
+    var dCol = {
+        black: '#000',
+        paleblue: '#acf',
+        offwhite: '#ddd',
+        darkgrey: '#444',
+        midgrey: '#888',
+        lightgrey: '#bbb',
+        orange: '#f90'
+    };
+
+    // note: these are the device icon colors without affinity
+    var dColTheme = {
+        light: {
+            rfill: dCol.offwhite,
+            online: {
+                glyph: dCol.darkgrey,
+                rect: dCol.paleblue
+            },
+            offline: {
+                glyph: dCol.midgrey,
+                rect: dCol.lightgrey
+            }
+        },
+        dark: {
+            rfill: dCol.midgrey,
+            online: {
+                glyph: dCol.darkgrey,
+                rect: dCol.paleblue
+            },
+            offline: {
+                glyph: dCol.midgrey,
+                rect: dCol.darkgrey
+            }
+        }
+    };
+
+    function devBaseColor(d) {
+        var o = d.online ? 'online' : 'offline';
+        return dColTheme[ts.theme()][o];
+    }
+
+    function setDeviceColor(d) {
+        var o = d.online,
+            s = d.el.classed('selected'),
+            c = devBaseColor(d),
+            a = instColor(d.master, o),
+            icon = d.el.select('g.deviceIcon'),
+            g, r;
+
+        if (s) {
+            g = c.glyph;
+            r = dCol.orange;
+        } else if (api.instVisible()) {
+            g = o ? a : c.glyph;
+            r = o ? c.rfill : a;
+        } else {
+            g = c.glyph;
+            r = c.rect;
+        }
+
+        icon.select('use').style('fill', g);
+        icon.select('rect').style('fill', r);
+    }
+
+    function instColor(id, online) {
+        return sus.cat7().getColor(id, !online, ts.theme());
+    }
+
+    // ====
+
+    function incDevLabIndex() {
+        deviceLabelIndex = (deviceLabelIndex+1) % 3;
+    }
+
+    // Returns the newly computed bounding box of the rectangle
+    function adjustRectToFitText(n) {
+        var text = n.select('text'),
+            box = text.node().getBBox(),
+            lab = labelConfig;
+
+        text.attr('text-anchor', 'middle')
+            .attr('y', '-0.8em')
+            .attr('x', lab.imgPad/2);
+
+        // translate the bbox so that it is centered on [x,y]
+        box.x = -box.width / 2;
+        box.y = -box.height / 2;
+
+        // add padding
+        box.x -= (lab.padLR + lab.imgPad/2);
+        box.width += lab.padLR * 2 + lab.imgPad;
+        box.y -= lab.padTB;
+        box.height += lab.padTB * 2;
+
+        return box;
+    }
+
+    function hostLabel(d) {
+        var idx = (hostLabelIndex < d.labels.length) ? hostLabelIndex : 0;
+        return d.labels[idx];
+    }
+    function deviceLabel(d) {
+        var idx = (deviceLabelIndex < d.labels.length) ? deviceLabelIndex : 0;
+        return d.labels[idx];
+    }
+    function trimLabel(label) {
+        return (label && label.trim()) || '';
+    }
+
+    function emptyBox() {
+        return {
+            x: -2,
+            y: -2,
+            width: 4,
+            height: 4
+        };
+    }
+
+
+    function updateDeviceLabel(d) {
+        var label = trimLabel(deviceLabel(d)),
+            noLabel = !label,
+            node = d.el,
+            dim = icfg.device.dim,
+            box, dx, dy;
+
+        node.select('text')
+            .text(label)
+            .style('opacity', 0)
+            .transition()
+            .style('opacity', 1);
+
+        if (noLabel) {
+            box = emptyBox();
+            dx = -dim/2;
+            dy = -dim/2;
+        } else {
+            box = adjustRectToFitText(node);
+            dx = box.x + devCfg.xoff;
+            dy = box.y + devCfg.yoff;
+        }
+
+        node.select('rect')
+            .transition()
+            .attr(box);
+
+        node.select('g.deviceIcon')
+            .transition()
+            .attr('transform', sus.translate(dx, dy));
+    }
+
+    function updateHostLabel(d) {
+        var label = trimLabel(hostLabel(d));
+        d.el.select('text').text(label);
+    }
+
+    function updateDeviceColors(d) {
+        if (d) {
+            setDeviceColor(d);
+        } else {
+            api.node().filter('.device').each(function (d) {
+                setDeviceColor(d);
+            });
+        }
+    }
+
+
+    // ==========================
+    // updateNodes - subfunctions
+
+    function deviceExisting(d) {
+        var node = d.el;
+        node.classed('online', d.online);
+        updateDeviceLabel(d);
+        api.posNode(d, true);
+    }
+
+    function hostExisting(d) {
+        updateHostLabel(d);
+        api.posNode(d, true);
+    }
+
+    function deviceEnter(d) {
+        var node = d3.select(this),
+            glyphId = d.type || 'unknown',
+            label = trimLabel(deviceLabel(d)),
+            //devCfg = deviceIconConfig,
+            noLabel = !label,
+            box, dx, dy, icon;
+
+        d.el = node;
+
+        node.append('rect').attr({ rx: 5, ry: 5 });
+        node.append('text').text(label).attr('dy', '1.1em');
+        box = adjustRectToFitText(node);
+        node.select('rect').attr(box);
+
+        icon = is.addDeviceIcon(node, glyphId);
+
+        if (noLabel) {
+            dx = -icon.dim/2;
+            dy = -icon.dim/2;
+        } else {
+            box = adjustRectToFitText(node);
+            dx = box.x + devCfg.xoff;
+            dy = box.y + devCfg.yoff;
+        }
+
+        icon.attr('transform', sus.translate(dx, dy));
+    }
+
+    function hostEnter(d) {
+        var node = d3.select(this),
+            gid = d.type || 'unknown',
+            rad = icfg.host.radius,
+            r = d.type ? rad.withGlyph : rad.noGlyph,
+            textDy = r + 10;
+
+        d.el = node;
+        sus.visible(node, api.showHosts());
+
+        is.addHostIcon(node, r, gid);
+
+        node.append('text')
+            .text(hostLabel)
+            .attr('dy', textDy)
+            .attr('text-anchor', 'middle');
+    }
+
+    function hostExit(d) {
+        var node = d.el;
+        node.select('use')
+            .style('opacity', 0.5)
+            .transition()
+            .duration(800)
+            .style('opacity', 0);
+
+        node.select('text')
+            .style('opacity', 0.5)
+            .transition()
+            .duration(800)
+            .style('opacity', 0);
+
+        node.select('circle')
+            .style('stroke-fill', '#555')
+            .style('fill', '#888')
+            .style('opacity', 0.5)
+            .transition()
+            .duration(1500)
+            .attr('r', 0);
+    }
+
+    function deviceExit(d) {
+        var node = d.el;
+        node.select('use')
+            .style('opacity', 0.5)
+            .transition()
+            .duration(800)
+            .style('opacity', 0);
+
+        node.selectAll('rect')
+            .style('stroke-fill', '#555')
+            .style('fill', '#888')
+            .style('opacity', 0.5);
+    }
+
+
+    // ==========================
+    // updateLinks - subfunctions
+
+    function linkExisting(d) {
+        // this is supposed to be an existing link, but we have observed
+        //  occasions (where links are deleted and added rapidly?) where
+        //  the DOM element has not been defined. So protection against that...
+        if (d.el) {
+            api.restyleLinkElement(d, true);
+        }
+    }
+
+    function linkEntering(d) {
+        var link = d3.select(this);
+        d.el = link;
+        api.restyleLinkElement(d);
+        if (d.type() === 'hostLink') {
+            sus.visible(link, api.showHosts());
+        }
+    }
+
+    var linkLabelOffset = '0.3em';
+
+    function applyLinkLabels() {
+        var entering;
+
+        api.updateLinkLabelModel();
+
+        // for elements already existing, we need to update the text
+        // and adjust the rectangle size to fit
+        api.linkLabel().each(function (d) {
+            var el = d3.select(this),
+                rect = el.select('rect'),
+                text = el.select('text');
+            text.text(d.label);
+            rect.attr(rectAroundText(el));
+        });
+
+        entering = api.linkLabel().enter().append('g')
+            .classed('linkLabel', true)
+            .attr('id', function (d) { return d.id; });
+
+        entering.each(function (d) {
+            var el = d3.select(this),
+                rect,
+                text,
+                parms = {
+                    x1: d.ldata.source.x,
+                    y1: d.ldata.source.y,
+                    x2: d.ldata.target.x,
+                    y2: d.ldata.target.y
+                };
+
+            if (d.ldata.type() === 'hostLink') {
+                el.classed('hostLinkLabel', true);
+                sus.visible(el, api.showHosts());
+            }
+
+            d.el = el;
+            rect = el.append('rect');
+            text = el.append('text').text(d.label);
+            rect.attr(rectAroundText(el));
+            text.attr('dy', linkLabelOffset);
+
+            el.attr('transform', transformLabel(parms));
+        });
+
+        // Remove any labels that are no longer required.
+        api.linkLabel().exit().remove();
+    }
+
+    function rectAroundText(el) {
+        var text = el.select('text'),
+            box = text.node().getBBox();
+
+        // translate the bbox so that it is centered on [x,y]
+        box.x = -box.width / 2;
+        box.y = -box.height / 2;
+
+        // add padding
+        box.x -= 1;
+        box.width += 2;
+        return box;
+    }
+
+    function transformLabel(p) {
+        var dx = p.x2 - p.x1,
+            dy = p.y2 - p.y1,
+            xMid = dx/2 + p.x1,
+            yMid = dy/2 + p.y1;
+        return sus.translate(xMid, yMid);
+    }
+
+
+    // ==========================
+    // Module definition
+
+    angular.module('ovTopo')
+    .factory('TopoD3Service',
+        ['$log', 'FnService', 'SvgUtilService', 'IconService', 'ThemeService',
+
+        function (_$log_, _fs_, _sus_, _is_, _ts_) {
+            $log = _$log_;
+            fs = _fs_;
+            sus = _sus_;
+            is = _is_;
+            ts = _ts_;
+
+            icfg = is.iconConfig();
+
+            function initD3(_api_) {
+                api = _api_;
+            }
+
+            function destroyD3() { }
+
+            return {
+                initD3: initD3,
+                destroyD3: destroyD3,
+
+                incDevLabIndex: incDevLabIndex,
+                adjustRectToFitText: adjustRectToFitText,
+                hostLabel: hostLabel,
+                deviceLabel: deviceLabel,
+                trimLabel: trimLabel,
+
+                updateDeviceLabel: updateDeviceLabel,
+                updateHostLabel: updateHostLabel,
+                updateDeviceColors: updateDeviceColors,
+
+                deviceExisting: deviceExisting,
+                hostExisting: hostExisting,
+                deviceEnter: deviceEnter,
+                hostEnter: hostEnter,
+                hostExit: hostExit,
+                deviceExit: deviceExit,
+
+                linkExisting: linkExisting,
+                linkEntering: linkEntering,
+                applyLinkLabels: applyLinkLabels,
+
+                transformLabel: transformLabel
+            };
+        }]);
+}());
diff --git a/web/gui/src/main/webapp/app/view/topo/topoForce.js b/web/gui/src/main/webapp/app/view/topo/topoForce.js
index da42832..d97111f 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoForce.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoForce.js
@@ -23,28 +23,10 @@
     'use strict';
 
     // injected refs
-    var $log, fs, sus, is, ts, flash, tis, tms, tss, tts, tos, fltr,
+    var $log, fs, sus, is, ts, flash, tis, tms, td3, tss, tts, tos, fltr,
         icfg, uplink;
 
     // configuration
-    var labelConfig = {
-            imgPad: 16,
-            padLR: 4,
-            padTB: 3,
-            marginLR: 3,
-            marginTB: 2,
-            port: {
-                gap: 3,
-                width: 18,
-                height: 14
-            }
-        };
-
-    var deviceIconConfig = {
-         xoff: -20,
-         yoff: -18
-    };
-
     var linkConfig = {
         light: {
             baseColor: '#666',
@@ -72,8 +54,6 @@
         },
         lu = network.lookup,    // shorthand
         rlk = network.revLinkToKey,
-        deviceLabelIndex = 0,   // for device label cycling
-        hostLabelIndex = 0,     // for host label cycling
         showHosts = false,      // whether hosts are displayed
         showOffline = true,     // whether offline devices are displayed
         nodeLock = false,       // whether nodes can be dragged or not (locked)
@@ -152,9 +132,6 @@
                 tms.findAttachedLinks(d.id).forEach(restyleLinkElement);
                 updateOfflineVisibility(d);
             }
-        } else {
-            // TODO: decide whether we want to capture logic errors
-            //logicError('updateDevice lookup fail. ID = "' + id + '"');
         }
     }
 
@@ -163,9 +140,6 @@
             d = lu[id];
         if (d) {
             removeDeviceElement(d);
-        } else {
-            // TODO: decide whether we want to capture logic errors
-            //logicError('removeDevice lookup fail. ID = "' + id + '"');
         }
     }
 
@@ -206,9 +180,6 @@
                 sendUpdateMeta(d);
             }
             updateNodes();
-        } else {
-            // TODO: decide whether we want to capture logic errors
-            //logicError('updateHost lookup fail. ID = "' + id + '"');
         }
     }
 
@@ -217,9 +188,6 @@
             d = lu[id];
         if (d) {
             removeHostElement(d, true);
-        } else {
-            // may have already removed host, if attached to removed device
-            //console.warn('removeHost lookup fail. ID = "' + id + '"');
         }
     }
 
@@ -260,14 +228,11 @@
     }
 
     function removeLink(data) {
-        var result = tms.findLink(data, 'remove'),
-            bad = result.badLogic;
-        if (bad) {
-            // may have already removed link, if attached to removed device
-            //console.warn(bad + ': ' + link.id);
-            return;
+        var result = tms.findLink(data, 'remove');
+
+        if (!result.badLogic) {
+            result.removeRawLink();
         }
-        result.removeRawLink();
     }
 
     // ========================
@@ -417,107 +382,10 @@
     }
 
 
-    // ==========================
-    // === Devices and hosts - D3 rendering
-
-
-    // Returns the newly computed bounding box of the rectangle
-    function adjustRectToFitText(n) {
-        var text = n.select('text'),
-            box = text.node().getBBox(),
-            lab = labelConfig;
-
-        text.attr('text-anchor', 'middle')
-            .attr('y', '-0.8em')
-            .attr('x', lab.imgPad/2);
-
-        // translate the bbox so that it is centered on [x,y]
-        box.x = -box.width / 2;
-        box.y = -box.height / 2;
-
-        // add padding
-        box.x -= (lab.padLR + lab.imgPad/2);
-        box.width += lab.padLR * 2 + lab.imgPad;
-        box.y -= lab.padTB;
-        box.height += lab.padTB * 2;
-
-        return box;
-    }
-
     function mkSvgClass(d) {
         return d.fixed ? d.svgClass + ' fixed' : d.svgClass;
     }
 
-    function hostLabel(d) {
-        var idx = (hostLabelIndex < d.labels.length) ? hostLabelIndex : 0;
-        return d.labels[idx];
-    }
-    function deviceLabel(d) {
-        var idx = (deviceLabelIndex < d.labels.length) ? deviceLabelIndex : 0;
-        return d.labels[idx];
-    }
-    function trimLabel(label) {
-        return (label && label.trim()) || '';
-    }
-
-    function emptyBox() {
-        return {
-            x: -2,
-            y: -2,
-            width: 4,
-            height: 4
-        };
-    }
-
-
-    function updateDeviceLabel(d) {
-        var label = trimLabel(deviceLabel(d)),
-            noLabel = !label,
-            node = d.el,
-            dim = icfg.device.dim,
-            devCfg = deviceIconConfig,
-            box, dx, dy;
-
-        node.select('text')
-            .text(label)
-            .style('opacity', 0)
-            .transition()
-            .style('opacity', 1);
-
-        if (noLabel) {
-            box = emptyBox();
-            dx = -dim/2;
-            dy = -dim/2;
-        } else {
-            box = adjustRectToFitText(node);
-            dx = box.x + devCfg.xoff;
-            dy = box.y + devCfg.yoff;
-        }
-
-        node.select('rect')
-            .transition()
-            .attr(box);
-
-        node.select('g.deviceIcon')
-            .transition()
-            .attr('transform', sus.translate(dx, dy));
-    }
-
-    function updateHostLabel(d) {
-        var label = trimLabel(hostLabel(d));
-        d.el.select('text').text(label);
-    }
-
-    function updateDeviceColors(d) {
-        if (d) {
-            setDeviceColor(d);
-        } else {
-            node.filter('.device').each(function (d) {
-                setDeviceColor(d);
-            });
-        }
-    }
-
     function vis(b) {
         return b ? 'visible' : 'hidden';
     }
@@ -535,9 +403,9 @@
     }
 
     function cycleDeviceLabels() {
-        deviceLabelIndex = (deviceLabelIndex+1) % 3;
+        td3.incDevLabIndex();
         tms.findDevices().forEach(function (d) {
-            updateDeviceLabel(d);
+            td3.updateDeviceLabel(d);
         });
     }
 
@@ -583,84 +451,14 @@
 
     // ==========================================
 
-    var dCol = {
-        black: '#000',
-        paleblue: '#acf',
-        offwhite: '#ddd',
-        darkgrey: '#444',
-        midgrey: '#888',
-        lightgrey: '#bbb',
-        orange: '#f90'
-    };
-
-    // note: these are the device icon colors without affinity
-    var dColTheme = {
-        light: {
-            rfill: dCol.offwhite,
-            online: {
-                glyph: dCol.darkgrey,
-                rect: dCol.paleblue
-            },
-            offline: {
-                glyph: dCol.midgrey,
-                rect: dCol.lightgrey
-            }
-        },
-        dark: {
-            rfill: dCol.midgrey,
-            online: {
-                glyph: dCol.darkgrey,
-                rect: dCol.paleblue
-            },
-            offline: {
-                glyph: dCol.midgrey,
-                rect: dCol.darkgrey
-            }
-        }
-    };
-
-    function devBaseColor(d) {
-        var o = d.online ? 'online' : 'offline';
-        return dColTheme[ts.theme()][o];
-    }
-
-    function setDeviceColor(d) {
-        var o = d.online,
-            s = d.el.classed('selected'),
-            c = devBaseColor(d),
-            a = instColor(d.master, o),
-            icon = d.el.select('g.deviceIcon'),
-            g, r;
-
-        if (s) {
-            g = c.glyph;
-            r = dCol.orange;
-        } else if (tis.isVisible()) {
-            g = o ? a : c.glyph;
-            r = o ? c.rfill : a;
-        } else {
-            g = c.glyph;
-            r = c.rect;
-        }
-
-        icon.select('use').style('fill', g);
-        icon.select('rect').style('fill', r);
-    }
-
-    function instColor(id, online) {
-        return sus.cat7().getColor(id, !online, ts.theme());
-    }
-
-    // ==========================
-
     function updateNodes() {
         // select all the nodes in the layout:
         node = nodeG.selectAll('.node')
             .data(network.nodes, function (d) { return d.id; });
 
         // operate on existing nodes:
-        node.filter('.device').each(deviceExisting);
-        node.filter('.host').each(hostExisting);
+        node.filter('.device').each(td3.deviceExisting);
+        node.filter('.host').each(td3.hostExisting);
 
         // operate on entering nodes:
         var entering = node.enter()
@@ -678,11 +476,11 @@
             .attr('opacity', 1);
 
         // augment entering nodes:
-        entering.filter('.device').each(deviceEnter);
-        entering.filter('.host').each(hostEnter);
+        entering.filter('.device').each(td3.deviceEnter);
+        entering.filter('.host').each(td3.hostEnter);
 
         // operate on both existing and new nodes:
-        updateDeviceColors();
+        td3.updateDeviceColors();
 
         // operate on exiting nodes:
         // Note that the node is removed after 2 seconds.
@@ -694,113 +492,14 @@
             .remove();
 
         // exiting node specifics:
-        exiting.filter('.host').each(hostExit);
-        exiting.filter('.device').each(deviceExit);
+        exiting.filter('.host').each(td3.hostExit);
+        exiting.filter('.device').each(td3.deviceExit);
 
         // finally, resume the force layout
         fResume();
     }
 
     // ==========================
-    // updateNodes - subfunctions
-
-    function deviceExisting(d) {
-        var node = d.el;
-        node.classed('online', d.online);
-        updateDeviceLabel(d);
-        tms.positionNode(d, true);
-    }
-
-    function hostExisting(d) {
-        updateHostLabel(d);
-        tms.positionNode(d, true);
-    }
-
-    function deviceEnter(d) {
-        var node = d3.select(this),
-            glyphId = d.type || 'unknown',
-            label = trimLabel(deviceLabel(d)),
-            devCfg = deviceIconConfig,
-            noLabel = !label,
-            box, dx, dy, icon;
-
-        d.el = node;
-
-        node.append('rect').attr({ rx: 5, ry: 5 });
-        node.append('text').text(label).attr('dy', '1.1em');
-        box = adjustRectToFitText(node);
-        node.select('rect').attr(box);
-
-        icon = is.addDeviceIcon(node, glyphId);
-
-        if (noLabel) {
-            dx = -icon.dim/2;
-            dy = -icon.dim/2;
-        } else {
-            box = adjustRectToFitText(node);
-            dx = box.x + devCfg.xoff;
-            dy = box.y + devCfg.yoff;
-        }
-
-        icon.attr('transform', sus.translate(dx, dy));
-    }
-
-    function hostEnter(d) {
-        var node = d3.select(this),
-            gid = d.type || 'unknown',
-            rad = icfg.host.radius,
-            r = d.type ? rad.withGlyph : rad.noGlyph,
-            textDy = r + 10;
-
-        d.el = node;
-        sus.visible(node, showHosts);
-
-        is.addHostIcon(node, r, gid);
-
-        node.append('text')
-            .text(hostLabel)
-            .attr('dy', textDy)
-            .attr('text-anchor', 'middle');
-    }
-
-    function hostExit(d) {
-        var node = d.el;
-        node.select('use')
-            .style('opacity', 0.5)
-            .transition()
-            .duration(800)
-            .style('opacity', 0);
-
-        node.select('text')
-            .style('opacity', 0.5)
-            .transition()
-            .duration(800)
-            .style('opacity', 0);
-
-        node.select('circle')
-            .style('stroke-fill', '#555')
-            .style('fill', '#888')
-            .style('opacity', 0.5)
-            .transition()
-            .duration(1500)
-            .attr('r', 0);
-    }
-
-    function deviceExit(d) {
-        var node = d.el;
-        node.select('use')
-            .style('opacity', 0.5)
-            .transition()
-            .duration(800)
-            .style('opacity', 0);
-
-        node.selectAll('rect')
-            .style('stroke-fill', '#555')
-            .style('fill', '#888')
-            .style('opacity', 0.5);
-    }
-
-    // ==========================
 
     function updateLinks() {
         var th = ts.theme();
@@ -809,7 +508,7 @@
             .data(network.links, function (d) { return d.key; });
 
         // operate on existing links:
-        link.each(linkExisting);
+        link.each(td3.linkExisting);
 
         // operate on entering links:
         var entering = link.enter()
@@ -824,14 +523,13 @@
             });
 
         // augment links
-        entering.each(linkEntering);
+        entering.each(td3.linkEntering);
 
         // operate on both existing and new links:
         //link.each(...)
 
         // apply or remove labels
-        var labelData = getLabelData();
-        applyLinkLabels(labelData);
+        td3.applyLinkLabels();
 
         // operate on exiting links:
         link.exit()
@@ -848,117 +546,6 @@
             .remove();
     }
 
-    // ==========================
-    // updateLinks - subfunctions
-
-    function getLabelData() {
-        // create the backing data for showing labels..
-        var data = [];
-        link.each(function (d) {
-            if (d.label) {
-                data.push({
-                    id: 'lab-' + d.key,
-                    key: d.key,
-                    label: d.label,
-                    ldata: d
-                });
-            }
-        });
-        return data;
-    }
-
-    function linkExisting(d) {
-        // this is supposed to be an existing link, but we have observed
-        //  occasions (where links are deleted and added rapidly?) where
-        //  the DOM element has not been defined. So protection against that...
-        if (d.el) {
-            restyleLinkElement(d, true);
-        }
-    }
-
-    function linkEntering(d) {
-        var link = d3.select(this);
-        d.el = link;
-        restyleLinkElement(d);
-        if (d.type() === 'hostLink') {
-            sus.visible(link, showHosts);
-        }
-    }
-
-    //function linkExiting(d) { }
-
-    var linkLabelOffset = '0.3em';
-
-    function applyLinkLabels(data) {
-        var entering;
-
-        linkLabel = linkLabelG.selectAll('.linkLabel')
-            .data(data, function (d) { return d.id; });
-
-        // for elements already existing, we need to update the text
-        // and adjust the rectangle size to fit
-        linkLabel.each(function (d) {
-            var el = d3.select(this),
-                rect = el.select('rect'),
-                text = el.select('text');
-            text.text(d.label);
-            rect.attr(rectAroundText(el));
-        });
-
-        entering = linkLabel.enter().append('g')
-            .classed('linkLabel', true)
-            .attr('id', function (d) { return d.id; });
-
-        entering.each(function (d) {
-            var el = d3.select(this),
-                rect,
-                text,
-                parms = {
-                    x1: d.ldata.source.x,
-                    y1: d.ldata.source.y,
-                    x2: d.ldata.target.x,
-                    y2: d.ldata.target.y
-                };
-
-            if (d.ldata.type() === 'hostLink') {
-                el.classed('hostLinkLabel', true);
-                sus.visible(el, showHosts);
-            }
-
-            d.el = el;
-            rect = el.append('rect');
-            text = el.append('text').text(d.label);
-            rect.attr(rectAroundText(el));
-            text.attr('dy', linkLabelOffset);
-
-            el.attr('transform', transformLabel(parms));
-        });
-
-        // Remove any labels that are no longer required.
-        linkLabel.exit().remove();
-    }
-
-    function rectAroundText(el) {
-        var text = el.select('text'),
-            box = text.node().getBBox();
-
-        // translate the bbox so that it is centered on [x,y]
-        box.x = -box.width / 2;
-        box.y = -box.height / 2;
-
-        // add padding
-        box.x -= 1;
-        box.width += 2;
-        return box;
-    }
-
-    function transformLabel(p) {
-        var dx = p.x2 - p.x1,
-            dy = p.y2 - p.y1,
-            xMid = dx/2 + p.x1,
-            yMid = dy/2 + p.y1;
-        return sus.translate(xMid, yMid);
-    }
 
     // ==========================
     // force layout tick function
@@ -989,7 +576,7 @@
             transform: function (d) {
                 var lnk = tms.findLinkById(d.key);
                 if (lnk) {
-                    return transformLabel({
+                    return td3.transformLabel({
                         x1: lnk.source.x,
                         y1: lnk.source.y,
                         x2: lnk.target.x,
@@ -1049,6 +636,24 @@
         });
     }
 
+    function updateLinkLabelModel() {
+        // create the backing data for showing labels..
+        var data = [];
+        link.each(function (d) {
+            if (d.label) {
+                data.push({
+                    id: 'lab-' + d.key,
+                    key: d.key,
+                    label: d.label,
+                    ldata: d
+                });
+            }
+        });
+
+        linkLabel = linkLabelG.selectAll('.linkLabel')
+            .data(data, function (d) { return d.id; });
+    }
+
     // ==========================
     // Module definition
 
@@ -1061,11 +666,24 @@
         };
     }
 
+    function mkD3Api(uplink) {
+        return {
+            node: function () { return node; },
+            link: function () { return link; },
+            linkLabel: function () { return linkLabel; },
+            instVisible: function () { return tis.isVisible(); },
+            posNode: tms.positionNode,
+            showHosts: function () { return showHosts; },
+            restyleLinkElement: restyleLinkElement,
+            updateLinkLabelModel: updateLinkLabelModel
+        }
+    }
+
     function mkSelectApi(uplink) {
         return {
             node: function () { return node; },
             zoomingOrPanning: zoomingOrPanning,
-            updateDeviceColors: updateDeviceColors,
+            updateDeviceColors: td3.updateDeviceColors,
             sendEvent: uplink.sendEvent
         };
     }
@@ -1114,11 +732,11 @@
     .factory('TopoForceService',
         ['$log', 'FnService', 'SvgUtilService', 'IconService', 'ThemeService',
             'FlashService', 'TopoInstService', 'TopoModelService',
-            'TopoSelectService', 'TopoTrafficService',
+            'TopoD3Service', 'TopoSelectService', 'TopoTrafficService',
             'TopoObliqueService', 'TopoFilterService',
 
         function (_$log_, _fs_, _sus_, _is_, _ts_, _flash_,
-                  _tis_, _tms_, _tss_, _tts_, _tos_, _fltr_) {
+                  _tis_, _tms_, _td3_, _tss_, _tts_, _tos_, _fltr_) {
             $log = _$log_;
             fs = _fs_;
             sus = _sus_;
@@ -1127,6 +745,7 @@
             flash = _flash_;
             tis = _tis_;
             tms = _tms_;
+            td3 = _td3_;
             tss = _tss_;
             tts = _tts_;
             tos = _tos_;
@@ -1150,6 +769,7 @@
                 $log.debug('initForce().. dim = ' + dim);
 
                 tms.initModel(mkModelApi(uplink), dim);
+                td3.initD3(mkD3Api(uplink));
                 tss.initSelect(mkSelectApi(uplink));
                 tts.initTraffic(mkTrafficApi(uplink));
                 tos.initOblique(mkObliqueApi(uplink, fltr));
@@ -1192,6 +812,7 @@
                 tos.destroyOblique();
                 tts.destroyTraffic();
                 tss.destroySelect();
+                td3.destroyD3();
                 tms.destroyModel();
                 ts.removeListener(themeListener);
                 themeListener = null;
@@ -1202,7 +823,7 @@
                 newDim: newDim,
                 destroyForce: destroyForce,
 
-                updateDeviceColors: updateDeviceColors,
+                updateDeviceColors: td3.updateDeviceColors,
                 toggleHosts: toggleHosts,
                 toggleOffline: toggleOffline,
                 cycleDeviceLabels: cycleDeviceLabels,
diff --git a/web/gui/src/main/webapp/index.html b/web/gui/src/main/webapp/index.html
index 083162a..530bfa9 100644
--- a/web/gui/src/main/webapp/index.html
+++ b/web/gui/src/main/webapp/index.html
@@ -86,6 +86,7 @@
     <!-- {INJECTED-JAVASCRIPT-START} -->
     <script src="app/view/sample/sample.js"></script>
     <script src="app/view/topo/topo.js"></script>
+    <script src="app/view/topo/topoD3.js"></script>
     <script src="app/view/topo/topoEvent.js"></script>
     <script src="app/view/topo/topoFilter.js"></script>
     <script src="app/view/topo/topoForce.js"></script>
@@ -104,7 +105,6 @@
     <link rel="stylesheet" href="app/view/sample/sample.css">
     <link rel="stylesheet" href="app/view/topo/topo.css">
     <link rel="stylesheet" href="app/view/device/device.css">
-    <!-- TODO: inject style-sheet refs server-side -->
     <!-- {INJECTED-STYLESHEETS-END} -->
 
 </head>