GUI -- Added handling of hosts and links. (Still WIP).

Change-Id: I0ad3b16d47b264b6812f732f220230a2ae92de02
diff --git a/web/gui/src/main/webapp/app/fw/svg/icon.js b/web/gui/src/main/webapp/app/fw/svg/icon.js
index 21f9b9d..bd686f7 100644
--- a/web/gui/src/main/webapp/app/fw/svg/icon.js
+++ b/web/gui/src/main/webapp/app/fw/svg/icon.js
@@ -20,7 +20,7 @@
 (function () {
     'use strict';
 
-    var $log, fs, gs;
+    var $log, fs, gs, sus;
 
     var vboxSize = 50,
         cornerSize = vboxSize / 10,
@@ -144,8 +144,21 @@
         return g;
     }
 
-    function addHostIcon(elem, glyphId) {
-        // TODO:
+    function addHostIcon(elem, radius, glyphId) {
+        var dim = radius * 1.5,
+            xlate = -dim / 2,
+            g = elem.append('g')
+                .attr('class', 'svgIcon hostIcon');
+
+        g.append('circle').attr('r', radius);
+
+        g.append('use').attr({
+            'xlink:href': '#' + glyphId,
+            width: dim,
+            height: dim,
+            transform: sus.translate(xlate,xlate)
+        });
+        return g;
     }
 
 
@@ -154,10 +167,13 @@
 
     angular.module('onosSvg')
         .factory('IconService', ['$log', 'FnService', 'GlyphService',
-        function (_$log_, _fs_, _gs_) {
+            'SvgUtilService',
+
+        function (_$log_, _fs_, _gs_, _sus_) {
             $log = _$log_;
             fs = _fs_;
             gs = _gs_;
+            sus = _sus_;
 
             return {
                 loadIcon: loadIcon,
diff --git a/web/gui/src/main/webapp/app/view/topo/topo.css b/web/gui/src/main/webapp/app/view/topo/topo.css
index ef61966..3ba7767 100644
--- a/web/gui/src/main/webapp/app/view/topo/topo.css
+++ b/web/gui/src/main/webapp/app/view/topo/topo.css
@@ -325,19 +325,121 @@
 /* Host Nodes */
 
 #ov-topo svg .node.host {
-    stroke: #000;
 }
 
 #ov-topo svg .node.host text {
-    fill: #846;
     stroke: none;
     font: 9pt sans-serif;
 }
+.light #ov-topo svg .node.host text {
+    fill: #846;
+}
+.dark #ov-topo svg .node.host text {
+    fill: #BB809D;
+}
 
-svg .node.host circle {
+.light svg .node.host circle {
     stroke: #000;
     fill: #edb;
 }
+.dark svg .node.host circle {
+    stroke: #eee;
+    fill: #B2A180;
+}
 
+.light svg .node.host .svgIcon {
+    fill: #444;
+}
+.dark svg .node.host .svgIcon {
+    fill: #222;
+}
 
+/* --- Topo Links --- */
 
+#ov-topo svg .link {
+    opacity: .9;
+}
+
+#ov-topo svg .link.inactive {
+    opacity: .5;
+    stroke-dasharray: 8 4;
+}
+
+#ov-topo svg .link.secondary {
+    stroke-width: 3px;
+}
+.light #ov-topo svg .link.secondary {
+    stroke: rgba(0,153,51,0.5);
+}
+.dark #ov-topo svg .link.secondary {
+    stroke: rgba(121,231,158,0.5);
+}
+
+#ov-topo svg .link.primary {
+    stroke-width: 4px;
+}
+.light #ov-topo svg .link.primary {
+    stroke: #ffA300;
+}
+.dark #ov-topo svg .link.primary {
+    stroke: #D58E0F;
+}
+
+.light #ov-topo svg .link.animated {
+    stroke: #ffA300;
+}
+.dark #ov-topo svg .link.animated {
+    stroke: #D58E0F;
+}
+
+#ov-topo svg .link.secondary.optical {
+    stroke-width: 4px;
+}
+.light #ov-topo svg .link.secondary.optical {
+    stroke: rgba(128,64,255,0.5);
+}
+.dark #ov-topo svg .link.secondary.optical {
+    stroke: rgba(164,139,215,0.5);
+}
+
+#ov-topo svg .link.primary.optical {
+    stroke-width: 6px;
+}
+.light #ov-topo svg .link.primary.optical {
+    stroke: #74f;
+}
+.dark #ov-topo svg .link.primary.optical {
+    stroke: #7352CD;
+}
+
+#ov-topo svg .link.animated.optical {
+    stroke-width: 10px;
+}
+.light #ov-topo svg .link.animated.optical {
+    stroke: #74f;
+}
+.dark #ov-topo svg .link.animated.optical {
+    stroke: #7352CD;
+}
+
+#ov-topo svg .linkLabel rect {
+    stroke: none;
+}
+.light #ov-topo svg .linkLabel rect {
+    fill: #eee;
+}
+.dark #ov-topo svg .linkLabel rect {
+    fill: #eee;
+}
+
+#ov-topo svg .linkLabel text {
+    text-anchor: middle;
+    stroke-width: 0.1;
+    font-size: 9pt;
+}
+.light #ov-topo svg .linkLabel text {
+    stroke: #777;
+}
+.dark #ov-topo svg .linkLabel text {
+    stroke: #777;
+}
diff --git a/web/gui/src/main/webapp/app/view/topo/topo.js b/web/gui/src/main/webapp/app/view/topo/topo.js
index 7c30f87..4a2ae8a 100644
--- a/web/gui/src/main/webapp/app/view/topo/topo.js
+++ b/web/gui/src/main/webapp/app/view/topo/topo.js
@@ -198,12 +198,6 @@
         return ms.loadMapInto(mapG, '*continental_us');
     }
 
-    // --- Force Layout --------------------------------------------------
-
-    function setUpForce(xlink) {
-        forceG = zoomLayer.append('g').attr('id', 'topo-force');
-        tfs.initForce(forceG, xlink, svg.attr('width'), svg.attr('height'));
-    }
 
     // --- Controller Definition -----------------------------------------
 
@@ -219,8 +213,12 @@
         function ($scope, _$log_, $loc, $timeout, _fs_, mast,
                   _ks_, _zs_, _gs_, _ms_, _sus_, tes, _tfs_, tps, _tis_) {
             var self = this,
-                xlink = {
-                    showNoDevs: showNoDevs
+                projection,
+                uplink = {
+                    // provides function calls back into this space
+                    showNoDevs: showNoDevs,
+                    projection: function () { return projection; },
+                    sendEvent: tes.sendEvent
                 };
 
             $log = _$log_;
@@ -255,9 +253,15 @@
             setUpDefs();
             setUpZoom();
             setUpNoDevs();
-            xlink.projectionPromise = setUpMap();
-            setUpForce(xlink);
+            setUpMap().then(
+                function (proj) {
+                    projection = proj;
+                    $log.debug('** We installed the projection: ', proj);
+                }
+            );
 
+            forceG = zoomLayer.append('g').attr('id', 'topo-force');
+            tfs.initForce(forceG, uplink, svg.attr('width'), svg.attr('height'));
             tis.initInst();
             tps.initPanels();
             tes.openSock();
diff --git a/web/gui/src/main/webapp/app/view/topo/topoEvent.js b/web/gui/src/main/webapp/app/view/topo/topoEvent.js
index 6cd634f..e39e11c 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoEvent.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoEvent.js
@@ -34,9 +34,16 @@
         updateInstance: updateInstance,
         removeInstance: removeInstance,
         addDevice: addDevice,
-        updateDevice: updateDevice
-        // TODO: implement remaining handlers..
+        updateDevice: updateDevice,
+        removeDevice: removeDevice,
+        addHost: addHost,
+        updateHost: updateHost,
+        removeHost: removeHost,
+        addLink: addLink,
+        updateLink: updateLink,
+        removeLink: removeLink
 
+        // TODO: implement remaining handlers..
     };
 
     function unknownEvent(ev) {
@@ -45,6 +52,9 @@
 
     // === Event Handlers ===
 
+    // NOTE: --- once these are done, we will collapse them into
+    // a more compact data structure... but for now, write in full..
+
     function showSummary(ev) {
         $log.debug('  **** Show Summary ****  ', ev.payload);
         tps.showSummary(ev.payload);
@@ -75,6 +85,42 @@
         tfs.updateDevice(ev.payload);
     }
 
+    function removeDevice(ev) {
+        $log.debug('  **** Remove Device **** ', ev.payload);
+        tfs.removeDevice(ev.payload);
+    }
+
+    function addHost(ev) {
+        $log.debug('  **** Add Host **** ', ev.payload);
+        tfs.addHost(ev.payload);
+    }
+
+    function updateHost(ev) {
+        $log.debug('  **** Update Host **** ', ev.payload);
+        tfs.updateHost(ev.payload);
+    }
+
+    function removeHost(ev) {
+        $log.debug('  **** Remove Host **** ', ev.payload);
+        tfs.removeHost(ev.payload);
+    }
+
+    function addLink(ev) {
+        $log.debug('  **** Add Link **** ', ev.payload);
+        tfs.addLink(ev.payload);
+    }
+
+    function updateLink(ev) {
+        $log.debug('  **** Update Link **** ', ev.payload);
+        tfs.updateLink(ev.payload);
+    }
+
+    function removeLink(ev) {
+        $log.debug('  **** Remove Link **** ', ev.payload);
+        tfs.removeLink(ev.payload);
+    }
+
+
     // ==========================
 
     var dispatcher = {
@@ -122,14 +168,9 @@
             tis = _tis_;
             tfs = _tfs_;
 
-            function bindDispatcher(TopoDomElementsPassedHere) {
-                // TODO: store refs to topo DOM elements...
-
-                return dispatcher;
-            }
-
             // TODO: handle "guiSuccessor" functionality (replace host)
             // TODO: implement retry on close functionality
+
             function openSock() {
                 wsock = wss.createWebSocket('topology', {
                     onOpen: onWsOpen,
@@ -151,9 +192,9 @@
             }
 
             return {
-                bindDispatcher: bindDispatcher,
                 openSock: openSock,
-                closeSock: closeSock
+                closeSock: closeSock,
+                sendEvent: dispatcher.sendEvent
             };
         }]);
 }());
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 73cf9bf..01b719e 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoForce.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoForce.js
@@ -23,7 +23,9 @@
     'use strict';
 
     // injected refs
-    var $log, sus, is, ts, tis, xlink;
+    var $log, fs, sus, is, ts, tis, uplink;
+
+    var icfg;
 
     // configuration
     var labelConfig = {
@@ -44,6 +46,21 @@
          yoff: -18
     };
 
+    var linkConfig = {
+        light: {
+            baseColor: '#666',
+            inColor: '#66f',
+            outColor: '#f00',
+        },
+        dark: {
+            baseColor: '#666',
+            inColor: '#66f',
+            outColor: '#f00',
+        },
+        inWidth: 12,
+        outWidth: 10
+    };
+
     // internal state
     var settings,   // merged default settings and options
         force,      // force layout object
@@ -54,9 +71,11 @@
             lookup: {},
             revLinkToKey: {}
         },
-        projection,             // background map projection
+        lu = network.lookup,    // shorthand
         deviceLabelIndex = 0,   // for device label cycling
-        hostLabelIndex = 0;     // for host label cycling
+        hostLabelIndex = 0,     // for host label cycling
+        showHosts = 1,          // whether hosts are displayed
+        width, height;
 
     // SVG elements;
     var linkG, linkLabelG, nodeG;
@@ -99,18 +118,18 @@
         var id = data.id,
             d;
 
-        xlink.showNoDevs(false);
+        uplink.showNoDevs(false);
 
         // although this is an add device event, if we already have the
         //  device, treat it as an update instead..
-        if (network.lookup[id]) {
+        if (lu[id]) {
             updateDevice(data);
             return;
         }
 
         d = createDeviceNode(data);
         network.nodes.push(d);
-        network.lookup[id] = d;
+        lu[id] = d;
 
         $log.debug("Created new device.. ", d.id, d.x, d.y);
 
@@ -120,7 +139,7 @@
 
     function updateDevice(data) {
         var id = data.id,
-            d = network.lookup[id],
+            d = lu[id],
             wasOnline;
 
         if (d) {
@@ -141,26 +160,379 @@
         }
     }
 
+    function removeDevice(data) {
+        var id = data.id,
+            d = lu[id];
+        if (d) {
+            removeDeviceElement(d);
+        } else {
+            // TODO: decide whether we want to capture logic errors
+            //logicError('removeDevice lookup fail. ID = "' + id + '"');
+        }
+    }
+
+    function addHost(data) {
+        var id = data.id,
+            d, lnk;
+
+        // although this is an add host event, if we already have the
+        //  host, treat it as an update instead..
+        if (lu[id]) {
+            updateHost(data);
+            return;
+        }
+
+        d = createHostNode(data);
+        network.nodes.push(d);
+        lu[id] = d;
+
+        $log.debug("Created new host.. ", d.id, d.x, d.y);
+
+        updateNodes();
+
+        lnk = createHostLink(data);
+        if (lnk) {
+
+            $log.debug("Created new host-link.. ", lnk.key);
+
+            d.linkData = lnk;    // cache ref on its host
+            network.links.push(lnk);
+            lu[d.ingress] = lnk;
+            lu[d.egress] = lnk;
+            updateLinks();
+        }
+
+        fStart();
+    }
+
+    function updateHost(data) {
+        var id = data.id,
+            d = lu[id];
+        if (d) {
+            angular.extend(d, data);
+            if (positionNode(d, true)) {
+                sendUpdateMeta(d, true);
+            }
+            updateNodes();
+        } else {
+            // TODO: decide whether we want to capture logic errors
+            //logicError('updateHost lookup fail. ID = "' + id + '"');
+        }
+    }
+
+    function removeHost(data) {
+        var id = data.id,
+            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 + '"');
+        }
+    }
+
+    function addLink(data) {
+        var result = findLink(data, 'add'),
+            bad = result.badLogic,
+            d = result.ldata;
+
+        if (bad) {
+            //logicError(bad + ': ' + link.id);
+            return;
+        }
+
+        if (d) {
+            // we already have a backing store link for src/dst nodes
+            addLinkUpdate(d, data);
+            return;
+        }
+
+        // no backing store link yet
+        d = createLink(data);
+        if (d) {
+            network.links.push(d);
+            lu[d.key] = d;
+            updateLinks();
+            fStart();
+        }
+    }
+
+    function updateLink(data) {
+        var result = findLink(data, 'update'),
+            bad = result.badLogic;
+        if (bad) {
+            //logicError(bad + ': ' + link.id);
+            return;
+        }
+        result.updateWith(link);
+    }
+
+    function removeLink(data) {
+        var result = findLink(data, 'remove'),
+            bad = result.badLogic;
+        if (bad) {
+            // may have already removed link, if attached to removed device
+            //console.warn(bad + ': ' + link.id);
+            return;
+        }
+        result.removeRawLink();
+    }
+
+    // ========================
+
+    function addLinkUpdate(ldata, link) {
+        // add link event, but we already have the reverse link installed
+        ldata.fromTarget = link;
+        network.revLinkToKey[link.id] = ldata.key;
+        restyleLinkElement(ldata);
+    }
+
+    function createLink(link) {
+        var lnk = linkEndPoints(link.src, link.dst);
+
+        if (!lnk) {
+            return null;
+        }
+
+        angular.extend(lnk, {
+            key: link.id,
+            class: 'link',
+            fromSource: link,
+
+            // functions to aggregate dual link state
+            type: function () {
+                var s = lnk.fromSource,
+                    t = lnk.fromTarget;
+                return (s && s.type) || (t && t.type) || defaultLinkType;
+            },
+            online: function () {
+                var s = lnk.fromSource,
+                    t = lnk.fromTarget,
+                    both = lnk.source.online && lnk.target.online;
+                return both && ((s && s.online) || (t && t.online));
+            },
+            linkWidth: function () {
+                var s = lnk.fromSource,
+                    t = lnk.fromTarget,
+                    ws = (s && s.linkWidth) || 0,
+                    wt = (t && t.linkWidth) || 0;
+                return Math.max(ws, wt);
+            }
+        });
+        return lnk;
+    }
+
+
+    function makeNodeKey(d, what) {
+        var port = what + 'Port';
+        return d[what] + '/' + d[port];
+    }
+
+    function makeLinkKey(d, flipped) {
+        var one = flipped ? makeNodeKey(d, 'dst') : makeNodeKey(d, 'src'),
+            two = flipped ? makeNodeKey(d, 'src') : makeNodeKey(d, 'dst');
+        return one + '-' + two;
+    }
+
+    var widthRatio = 1.4,
+        linkScale = d3.scale.linear()
+            .domain([1, 12])
+            .range([widthRatio, 12 * widthRatio])
+            .clamp(true);
+
+    var allLinkTypes = 'direct indirect optical tunnel',
+        defaultLinkType = 'direct';
+
+    function restyleLinkElement(ldata) {
+        // this fn's job is to look at raw links and decide what svg classes
+        // need to be applied to the line element in the DOM
+        var th = ts.theme(),
+            el = ldata.el,
+            type = ldata.type(),
+            lw = ldata.linkWidth(),
+            online = ldata.online();
+
+        el.classed('link', true);
+        el.classed('inactive', !online);
+        el.classed(allLinkTypes, false);
+        if (type) {
+            el.classed(type, true);
+        }
+        el.transition()
+            .duration(1000)
+            .attr('stroke-width', linkScale(lw))
+            .attr('stroke', linkConfig[th].baseColor);
+    }
+
+    function findLink(linkData, op) {
+        var key = makeLinkKey(linkData),
+            keyrev = makeLinkKey(linkData, 1),
+            link = lu[key],
+            linkRev = lu[keyrev],
+            result = {},
+            ldata = link || linkRev,
+            rawLink;
+
+        if (op === 'add') {
+            if (link) {
+                // trying to add a link that we already know about
+                result.ldata = link;
+                result.badLogic = 'addLink: link already added';
+
+            } else if (linkRev) {
+                // we found the reverse of the link to be added
+                result.ldata = linkRev;
+                if (linkRev.fromTarget) {
+                    result.badLogic = 'addLink: link already added';
+                }
+            }
+        } else if (op === 'update') {
+            if (!ldata) {
+                result.badLogic = 'updateLink: link not found';
+            } else {
+                rawLink = link ? ldata.fromSource : ldata.fromTarget;
+                result.updateWith = function (data) {
+                    angular.extend(rawLink, data);
+                    restyleLinkElement(ldata);
+                }
+            }
+        } else if (op === 'remove') {
+            if (!ldata) {
+                result.badLogic = 'removeLink: link not found';
+            } else {
+                rawLink = link ? ldata.fromSource : ldata.fromTarget;
+
+                if (!rawLink) {
+                    result.badLogic = 'removeLink: link not found';
+
+                } else {
+                    result.removeRawLink = function () {
+                        if (link) {
+                            // remove fromSource
+                            ldata.fromSource = null;
+                            if (ldata.fromTarget) {
+                                // promote target into source position
+                                ldata.fromSource = ldata.fromTarget;
+                                ldata.fromTarget = null;
+                                ldata.key = keyrev;
+                                delete network.lookup[key];
+                                network.lookup[keyrev] = ldata;
+                                delete network.revLinkToKey[keyrev];
+                            }
+                        } else {
+                            // remove fromTarget
+                            ldata.fromTarget = null;
+                            delete network.revLinkToKey[keyrev];
+                        }
+                        if (ldata.fromSource) {
+                            restyleLinkElement(ldata);
+                        } else {
+                            removeLinkElement(ldata);
+                        }
+                    }
+                }
+            }
+        }
+        return result;
+    }
+
+
+    function findAttachedHosts(devId) {
+        var hosts = [];
+        network.nodes.forEach(function (d) {
+            if (d.class === 'host' && d.cp.device === devId) {
+                hosts.push(d);
+            }
+        });
+        return hosts;
+    }
+
+    function findAttachedLinks(devId) {
+        var links = [];
+        network.links.forEach(function (d) {
+            if (d.source.id === devId || d.target.id === devId) {
+                links.push(d);
+            }
+        });
+        return links;
+    }
+
+    function removeLinkElement(d) {
+        var idx = fs.find(d.key, network.links, 'key'),
+            removed;
+        if (idx >=0) {
+            // remove from links array
+            removed = network.links.splice(idx, 1);
+            // remove from lookup cache
+            delete lu[removed[0].key];
+            updateLinks();
+            fResume();
+        }
+    }
+
+    function removeHostElement(d, upd) {
+        // first, remove associated hostLink...
+        removeLinkElement(d.linkData);
+
+        // remove hostLink bindings
+        delete lu[d.ingress];
+        delete lu[d.egress];
+
+        // remove from lookup cache
+        delete lu[d.id];
+        // remove from nodes array
+        var idx = fs.find(d.id, network.nodes);
+        network.nodes.splice(idx, 1);
+
+        // remove from SVG
+        // NOTE: upd is false if we were called from removeDeviceElement()
+        if (upd) {
+            updateNodes();
+            fResume();
+        }
+    }
+
+    function removeDeviceElement(d) {
+        var id = d.id;
+        // first, remove associated hosts and links..
+        findAttachedHosts(id).forEach(removeHostElement);
+        findAttachedLinks(id).forEach(removeLinkElement);
+
+        // remove from lookup cache
+        delete lu[id];
+        // remove from nodes array
+        var idx = fs.find(id, network.nodes);
+        network.nodes.splice(idx, 1);
+
+        if (!network.nodes.length) {
+            xlink.showNoDevs(true);
+        }
+
+        // remove from SVG
+        updateNodes();
+        fResume();
+    }
+
+
     function sendUpdateMeta(d, store) {
         var metaUi = {},
             ll;
 
-        // TODO: fix this code to send event to server...
-        //if (store) {
-        //    ll = geoMapProj.invert([d.x, d.y]);
-        //    metaUi = {
-        //        x: d.x,
-        //        y: d.y,
-        //        lng: ll[0],
-        //        lat: ll[1]
-        //    };
-        //}
-        //d.metaUi = metaUi;
-        //sendMessage('updateMeta', {
-        //    id: d.id,
-        //    'class': d.class,
-        //    memento: metaUi
-        //});
+        if (store) {
+            ll = lngLatFromCoord([d.x, d.y]);
+            metaUi = {
+                x: d.x,
+                y: d.y,
+                lng: ll[0],
+                lat: ll[1]
+            };
+        }
+        d.metaUi = metaUi;
+        uplink.sendEvent('updateMeta', {
+            id: d.id,
+            'class': d.class,
+            memento: metaUi
+        });
     }
 
 
@@ -178,9 +550,13 @@
     // === Devices and hosts - helper functions
 
     function coordFromLngLat(loc) {
-        // Our hope is that the projection is installed before we start
-        // handling incoming nodes. But if not, we'll just return the origin.
-        return projection ? projection([loc.lng, loc.lat]) : [0, 0];
+        var p = uplink.projection();
+        return p ? p([loc.lng, loc.lat]) : [0, 0];
+    }
+
+    function lngLatFromCoord(coord) {
+        var p = uplink.projection();
+        return p ? p.invert([coord.x, coord.y]) : [0, 0];
     }
 
     function positionNode(node, forUpdate) {
@@ -230,8 +606,8 @@
 
         function rand() {
             return {
-                x: randDim(network.view.width()),
-                y: randDim(network.view.height())
+                x: randDim(width),
+                y: randDim(height)
             };
         }
 
@@ -246,7 +622,7 @@
         }
 
         function getDevice(cp) {
-            var d = network.lookup[cp.device];
+            var d = lu[cp.device];
             return d || rand();
         }
 
@@ -267,9 +643,83 @@
         return node;
     }
 
+    function createHostNode(host) {
+        var node = host;
+
+        // Augment as needed...
+        node.class = 'host';
+        if (!node.type) {
+            node.type = 'endstation';
+        }
+        node.svgClass = 'node host ' + node.type;
+        positionNode(node);
+        return node;
+    }
+
+    function createHostLink(host) {
+        var src = host.id,
+            dst = host.cp.device,
+            id = host.ingress,
+            lnk = linkEndPoints(src, dst);
+
+        if (!lnk) {
+            return null;
+        }
+
+        // Synthesize link ...
+        angular.extend(lnk, {
+            key: id,
+            class: 'link',
+
+            type: function () { return 'hostLink'; },
+            online: function () {
+                // hostlink target is edge switch
+                return lnk.target.online;
+            },
+            linkWidth: function () { return 1; }
+        });
+        return lnk;
+    }
+
+    function linkEndPoints(srcId, dstId) {
+        var srcNode = lu[srcId],
+            dstNode = lu[dstId],
+            sMiss = !srcNode ? missMsg('src', srcId) : '',
+            dMiss = !dstNode ? missMsg('dst', dstId) : '';
+
+        if (sMiss || dMiss) {
+            $log.error('Node(s) not on map for link:\n' + sMiss + dMiss);
+            //logicError('Node(s) not on map for link:\n' + sMiss + dMiss);
+            return null;
+        }
+        return {
+            source: srcNode,
+            target: dstNode,
+            x1: srcNode.x,
+            y1: srcNode.y,
+            x2: dstNode.x,
+            y2: dstNode.y
+        };
+    }
+
+    function missMsg(what, id) {
+        return '\n[' + what + '] "' + id + '" missing ';
+    }
+
     // ==========================
     // === Devices and hosts - D3 rendering
 
+    function nodeMouseOver(m) {
+        // TODO
+        $log.debug("TODO nodeMouseOver()...", m);
+    }
+
+    function nodeMouseOut(m) {
+        // TODO
+        $log.debug("TODO nodeMouseOut()...", m);
+    }
+
+
     // Returns the newly computed bounding box of the rectangle
     function adjustRectToFitText(n) {
         var text = n.select('text'),
@@ -323,7 +773,7 @@
         var label = trimLabel(deviceLabel(d)),
             noLabel = !label,
             node = d.el,
-            dim = is.iconConfig().device.dim,
+            dim = icfg.device.dim,
             devCfg = deviceIconConfig,
             box, dx, dy;
 
@@ -357,16 +807,6 @@
         d.el.select('text').text(label);
     }
 
-    function nodeMouseOver(m) {
-        // TODO
-        $log.debug("TODO nodeMouseOver()...", m);
-    }
-
-    function nodeMouseOut(m) {
-        // TODO
-        $log.debug("TODO nodeMouseOut()...", m);
-    }
-
     function updateDeviceColors(d) {
         if (d) {
             setDeviceColor(d);
@@ -445,13 +885,14 @@
         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...
+        // operate on existing nodes:
         node.filter('.device').each(deviceExisting);
         node.filter('.host').each(hostExisting);
 
@@ -470,7 +911,7 @@
             .transition()
             .attr('opacity', 1);
 
-        // augment nodes...
+        // augment entering nodes:
         entering.filter('.device').each(deviceEnter);
         entering.filter('.host').each(hostEnter);
 
@@ -486,7 +927,7 @@
             .style('opacity', 0)
             .remove();
 
-        // node specific....
+        // exiting node specifics:
         exiting.filter('.host').each(hostExit);
         exiting.filter('.device').each(deviceExit);
 
@@ -539,25 +980,20 @@
     }
 
     function hostEnter(d) {
-        var node = d3.select(this);
-
-            //cfg = config.icons.host,
-            //r = cfg.radius[d.type] || cfg.defaultRadius,
-            //textDy = r + 10,
-        //TODO:     iid = iconGlyphUrl(d),
-        //    _dummy;
+        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.makeVisible(node, showHosts);
 
-        //TODO: showHostVis(node);
+        is.addHostIcon(node, r, gid);
 
-        node.append('circle').attr('r', r);
-        //if (iid) {
-            //TODO: addHostIcon(node, r, iid);
-        //}
         node.append('text')
             .text(hostLabel)
-            //.attr('dy', textDy)
+            .attr('dy', textDy)
             .attr('text-anchor', 'middle');
     }
 
@@ -598,6 +1034,160 @@
             .style('opacity', 0.5);
     }
 
+    // ==========================
+
+    function updateLinks() {
+        var th = ts.theme();
+
+        link = linkG.selectAll('.link')
+            .data(network.links, function (d) { return d.key; });
+
+        // operate on existing links:
+        //link.each(linkExisting);
+
+        // operate on entering links:
+        var entering = link.enter()
+            .append('line')
+            .attr({
+                x1: function (d) { return d.x1; },
+                y1: function (d) { return d.y1; },
+                x2: function (d) { return d.x2; },
+                y2: function (d) { return d.y2; },
+                stroke: linkConfig[th].inColor,
+                'stroke-width': linkConfig.inWidth
+            });
+
+        // augment links
+        entering.each(linkEntering);
+
+        // operate on both existing and new links:
+        //link.each(...)
+
+        // apply or remove labels
+        var labelData = getLabelData();
+        applyLinkLabels(labelData);
+
+        // operate on exiting links:
+        link.exit()
+            .attr('stroke-dasharray', '3 3')
+            .style('opacity', 0.5)
+            .transition()
+            .duration(1500)
+            .attr({
+                'stroke-dasharray': '3 12',
+                stroke: linkConfig[th].outColor,
+                'stroke-width': linkConfig.outWidth
+            })
+            .style('opacity', 0.0)
+            .remove();
+
+        // NOTE: invoke a single tick to force the labels to position
+        //        onto their links.
+        tick();
+        // FIXME: this is a bug when in oblique view
+        // It causes the nodes to jump into "overhead" view positions, even
+        //  though the oblique planes are still showing...
+    }
+
+    // ==========================
+    // 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) { }
+
+    function linkEntering(d) {
+        var link = d3.select(this);
+        d.el = link;
+        restyleLinkElement(d);
+        if (d.type() === 'hostLink') {
+            sus.makeVisible(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.x1,
+                    y1: d.ldata.y1,
+                    x2: d.ldata.x2,
+                    y2: d.ldata.y2
+                };
+
+            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
@@ -620,34 +1210,31 @@
 
     angular.module('ovTopo')
     .factory('TopoForceService',
-        ['$log', 'SvgUtilService', 'IconService', 'ThemeService',
+        ['$log', 'FnService', 'SvgUtilService', 'IconService', 'ThemeService',
             'TopoInstService',
 
-        function (_$log_, _sus_, _is_, _ts_, _tis_) {
+        function (_$log_, _fs_, _sus_, _is_, _ts_, _tis_) {
             $log = _$log_;
+            fs = _fs_;
             sus = _sus_;
             is = _is_;
             ts = _ts_;
             tis = _tis_;
 
+            icfg = is.iconConfig();
+
             // forceG is the SVG group to display the force layout in
             // xlink is the cross-link api from the main topo source file
             // w, h are the initial dimensions of the SVG
             // opts are, well, optional :)
-            function initForce(forceG, _xlink_, w, h, opts) {
+            function initForce(forceG, _uplink_, w, h, opts) {
                 $log.debug('initForce().. WxH = ' + w + 'x' + h);
-                xlink = _xlink_;
+                uplink = _uplink_;
+                width = w;
+                height = h;
 
                 settings = angular.extend({}, defaultSettings, opts);
 
-                // when the projection promise is resolved, cache the projection
-                xlink.projectionPromise.then(
-                    function (proj) {
-                        projection = proj;
-                        $log.debug('** We installed the projection: ', proj);
-                    }
-                );
-
                 linkG = forceG.append('g').attr('id', 'topo-links');
                 linkLabelG = forceG.append('g').attr('id', 'topo-linkLabels');
                 nodeG = forceG.append('g').attr('id', 'topo-nodes');
@@ -672,7 +1259,9 @@
             }
 
             function resize(dim) {
-                force.size([dim.width, dim.height]);
+                width = dim.width;
+                height = dim.height;
+                force.size([width, height]);
                 // Review -- do we need to nudge the layout ?
             }
 
@@ -683,7 +1272,14 @@
                 updateDeviceColors: updateDeviceColors,
 
                 addDevice: addDevice,
-                updateDevice: updateDevice
+                updateDevice: updateDevice,
+                removeDevice: removeDevice,
+                addHost: addHost,
+                updateHost: updateHost,
+                removeHost: removeHost,
+                addLink: addLink,
+                updateLink: updateLink,
+                removeLink: removeLink
             };
         }]);
 }());
diff --git a/web/gui/src/main/webapp/app/view/topo/topoInst.js b/web/gui/src/main/webapp/app/view/topo/topoInst.js
index f68e46d..ee5c96c 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoInst.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoInst.js
@@ -323,9 +323,11 @@
             return {
                 initInst: initInst,
                 destroyInst: destroyInst,
+
                 addInstance: addInstance,
                 updateInstance: updateInstance,
                 removeInstance: removeInstance,
+
                 isVisible: function () { return oiBox.isVisible(); },
                 show: function () { oiBox.show(); },
                 hide: function () { oiBox.hide(); }
diff --git a/web/gui/src/main/webapp/tests/app/view/topo/topoEvent-spec.js b/web/gui/src/main/webapp/tests/app/view/topo/topoEvent-spec.js
index 8cd6d25..b0a5be8 100644
--- a/web/gui/src/main/webapp/tests/app/view/topo/topoEvent-spec.js
+++ b/web/gui/src/main/webapp/tests/app/view/topo/topoEvent-spec.js
@@ -34,7 +34,7 @@
 
     it('should define api functions', function () {
         expect(fs.areFunctions(tes, [
-            'bindDispatcher', 'openSock', 'closeSock'
+            'openSock', 'closeSock', 'sendEvent'
         ])).toBeTruthy();
     });
 
diff --git a/web/gui/src/main/webapp/tests/app/view/topo/topoForce-spec.js b/web/gui/src/main/webapp/tests/app/view/topo/topoForce-spec.js
index cf4f4d6..b85272d 100644
--- a/web/gui/src/main/webapp/tests/app/view/topo/topoForce-spec.js
+++ b/web/gui/src/main/webapp/tests/app/view/topo/topoForce-spec.js
@@ -35,7 +35,9 @@
     it('should define api functions', function () {
         expect(fs.areFunctions(tfs, [
             'initForce', 'resize', 'updateDeviceColors',
-            'addDevice', 'updateDevice'
+            'addDevice', 'updateDevice', 'removeDevice',
+            'addHost', 'updateHost', 'removeHost',
+            'addLink', 'updateLink', 'removeLink'
         ])).toBeTruthy();
     });
 
diff --git a/web/gui/src/test/_karma/ev/simple/ev_8_addHost_03.json b/web/gui/src/test/_karma/ev/simple/ev_8_addHost_03.json
index 993570b..35e4572 100644
--- a/web/gui/src/test/_karma/ev/simple/ev_8_addHost_03.json
+++ b/web/gui/src/test/_karma/ev/simple/ev_8_addHost_03.json
@@ -12,6 +12,10 @@
       "unknown",
       "0E:2A:69:30:13:86"
     ],
+    "metaUi": {
+      "x": 800,
+      "y": 180
+    },
     "props": {}
   }
 }
diff --git a/web/gui/src/test/_karma/ev/simple/ev_9_addHost_08.json b/web/gui/src/test/_karma/ev/simple/ev_9_addHost_08.json
index 17864a6..3d368c8 100644
--- a/web/gui/src/test/_karma/ev/simple/ev_9_addHost_08.json
+++ b/web/gui/src/test/_karma/ev/simple/ev_9_addHost_08.json
@@ -12,6 +12,10 @@
       "unknown",
       "A6:96:E5:03:52:5F"
     ],
+    "metaUi": {
+      "x": 520,
+      "y": 250
+    },
     "props": {}
   }
 }