GUI -- Migrating the add/update device functionality to the Topology View. (WIP)
- still a lot of work to do.

Change-Id: I0453b7e2ec20a8a8149fd9d6440a13a3d43fbfd6
diff --git a/web/gui/src/main/webapp/app/fw/svg/icon.css b/web/gui/src/main/webapp/app/fw/svg/icon.css
index 20d8440..ff1bb5a 100644
--- a/web/gui/src/main/webapp/app/fw/svg/icon.css
+++ b/web/gui/src/main/webapp/app/fw/svg/icon.css
@@ -70,3 +70,7 @@
 .dark svg.embeddedIcon .icon rect {
     stroke: #ccc;
 }
+
+svg .svgIcon {
+    fill-rule: evenodd;
+}
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 d8c2584..21f9b9d 100644
--- a/web/gui/src/main/webapp/app/fw/svg/icon.js
+++ b/web/gui/src/main/webapp/app/fw/svg/icon.js
@@ -26,15 +26,17 @@
         cornerSize = vboxSize / 10,
         viewBox = '0 0 ' + vboxSize + ' ' + vboxSize;
 
-    // maps icon id to the glyph id it uses.
-    // note: icon id maps to a CSS class for styling that icon
+    // Maps icon ID to the glyph ID it uses.
+    // NOTE: icon ID maps to a CSS class for styling that icon
     var glyphMapping = {
-            deviceOnline: 'checkMark',
-            deviceOffline: 'xMark',
-            tableColSortAsc: 'triangleUp',
-            tableColSortDesc: 'triangleDown',
-            tableColSortNone: '-'
-        };
+        deviceOnline: 'checkMark',
+        deviceOffline: 'xMark',
+        tableColSortAsc: 'triangleUp',
+        tableColSortDesc: 'triangleDown',
+        tableColSortNone: '-'
+    };
+
+
 
     function ensureIconLibDefs() {
         var body = d3.select('body'),
@@ -48,6 +50,108 @@
         return svg.select('defs');
     }
 
+    // div is a D3 selection of the <DIV> element into which icon should load
+    // iconCls is the CSS class used to identify the icon
+    // size is dimension of icon in pixels. Defaults to 20.
+    // installGlyph, if truthy, will cause the glyph to be added to
+    //      well-known defs element. Defaults to false.
+    // svgClass is the CSS class used to identify the SVG layer.
+    //      Defaults to 'embeddedIcon'.
+    function loadIcon(div, iconCls, size, installGlyph, svgClass) {
+        var dim = size || 20,
+            svgCls = svgClass || 'embeddedIcon',
+            gid = glyphMapping[iconCls] || 'unknown',
+            svg, g;
+
+        if (installGlyph) {
+            gs.loadDefs(ensureIconLibDefs(), [gid], true);
+        }
+
+        svg = div.append('svg').attr({
+            'class': svgCls,
+            width: dim,
+            height: dim,
+            viewBox: viewBox
+        });
+
+        g = svg.append('g').attr({
+            'class': 'icon ' + iconCls
+        });
+
+        g.append('rect').attr({
+            width: vboxSize,
+            height: vboxSize,
+            rx: cornerSize
+        });
+
+        if (gid !== '-') {
+            g.append('use').attr({
+                width: vboxSize,
+                height: vboxSize,
+                'class': 'glyph',
+                'xlink:href': '#' + gid
+            });
+        }
+    }
+
+    function loadEmbeddedIcon(div, iconCls, size) {
+        loadIcon(div, iconCls, size, true);
+    }
+
+
+    // configuration for device and host icons in the topology view
+    var config = {
+        device: {
+            dim: 36,
+            rx: 4
+        },
+        host: {
+            radius: {
+                noGlyph: 9,
+                withGlyph: 14
+            },
+            glyphed: {
+                endstation: 1,
+                bgpSpeaker: 1,
+                router: 1
+            }
+        }
+    };
+
+
+    // Adds a device icon to the specified element, using the given glyph.
+    // Returns the D3 selection of the icon.
+    function addDeviceIcon(elem, glyphId) {
+        var cfg = config.device,
+            g = elem.append('g')
+                .attr('class', 'svgIcon deviceIcon');
+
+        g.append('rect').attr({
+            x: 0,
+            y: 0,
+            rx: cfg.rx,
+            width: cfg.dim,
+            height: cfg.dim
+        });
+
+        g.append('use').attr({
+            'xlink:href': '#' + glyphId,
+            width: cfg.dim,
+            height: cfg.dim
+        });
+
+        g.dim = cfg.dim;
+        return g;
+    }
+
+    function addHostIcon(elem, glyphId) {
+        // TODO:
+    }
+
+
+    // =========================
+    // === DEFINE THE MODULE
+
     angular.module('onosSvg')
         .factory('IconService', ['$log', 'FnService', 'GlyphService',
         function (_$log_, _fs_, _gs_) {
@@ -55,57 +159,12 @@
             fs = _fs_;
             gs = _gs_;
 
-            // div is a D3 selection of the <DIV> element into which icon should load
-            // iconCls is the CSS class used to identify the icon
-            // size is dimension of icon in pixels. Defaults to 20.
-            // installGlyph, if truthy, will cause the glyph to be added to
-            //      well-known defs element. Defaults to false.
-            // svgClass is the CSS class used to identify the SVG layer.
-            //      Defaults to 'embeddedIcon'.
-            function loadIcon(div, iconCls, size, installGlyph, svgClass) {
-                var dim = size || 20,
-                    svgCls = svgClass || 'embeddedIcon',
-                    gid = glyphMapping[iconCls] || 'unknown',
-                    svg, g;
-
-                if (installGlyph) {
-                    gs.loadDefs(ensureIconLibDefs(), [gid], true);
-                }
-
-                svg = div.append('svg').attr({
-                        'class': svgCls,
-                        width: dim,
-                        height: dim,
-                        viewBox: viewBox
-                    });
-
-                g = svg.append('g').attr({
-                    'class': 'icon ' + iconCls
-                });
-
-                g.append('rect').attr({
-                    width: vboxSize,
-                    height: vboxSize,
-                    rx: cornerSize
-                });
-
-                if (gid !== '-') {
-                    g.append('use').attr({
-                        width: vboxSize,
-                        height: vboxSize,
-                        'class': 'glyph',
-                        'xlink:href': '#' + gid
-                    });
-                }
-            }
-
-            function loadEmbeddedIcon(div, iconCls, size) {
-                loadIcon(div, iconCls, size, true);
-            }
-
             return {
                 loadIcon: loadIcon,
-                loadEmbeddedIcon: loadEmbeddedIcon
+                loadEmbeddedIcon: loadEmbeddedIcon,
+                addDeviceIcon: addDeviceIcon,
+                addHostIcon: addHostIcon,
+                iconConfig: function () { return config; }
             };
         }]);
 
diff --git a/web/gui/src/main/webapp/app/fw/svg/map.js b/web/gui/src/main/webapp/app/fw/svg/map.js
index f44ffe0..1faf6e2 100644
--- a/web/gui/src/main/webapp/app/fw/svg/map.js
+++ b/web/gui/src/main/webapp/app/fw/svg/map.js
@@ -22,45 +22,53 @@
     The Map Service provides a simple API for loading geographical maps into
     an SVG layer. For example, as a background to the Topology View.
 
-    e.g.  var ok = MapService.loadMapInto(svgLayer, '*continental-us');
+    e.g.  var promise = MapService.loadMapInto(svgLayer, '*continental-us');
 
     The Map Service makes use of the GeoDataService to load the required data
     from the server and to create the appropriate geographical projection.
 
+    A promise is returned to the caller, which is resolved with the
+    map projection once created.
 */
 
 (function () {
     'use strict';
 
     // injected references
-    var $log, fs, gds;
+    var $log, $q, fs, gds;
+
+    function loadMapInto(mapLayer, id, opts) {
+        var promise = gds.fetchTopoData(id),
+            deferredProjection = $q.defer();
+
+        if (!promise) {
+            $log.warn('Failed to load map: ' + id);
+            return false;
+        }
+
+        promise.then(function () {
+            var gen = gds.createPathGenerator(promise.topodata, opts);
+
+            deferredProjection.resolve(gen.settings.projection);
+
+            mapLayer.selectAll('path')
+                .data(gen.geodata.features)
+                .enter()
+                .append('path')
+                .attr('d', gen.pathgen);
+        });
+        return deferredProjection.promise;
+    }
+
 
     angular.module('onosSvg')
-        .factory('MapService', ['$log', 'FnService', 'GeoDataService',
-        function (_$log_, _fs_, _gds_) {
+        .factory('MapService', ['$log', '$q', 'FnService', 'GeoDataService',
+        function (_$log_, _$q_, _fs_, _gds_) {
             $log = _$log_;
+            $q = _$q_;
             fs = _fs_;
             gds = _gds_;
 
-            function loadMapInto(mapLayer, id, opts) {
-                var promise = gds.fetchTopoData(id);
-                if (!promise) {
-                    $log.warn('Failed to load map: ' + id);
-                    return false;
-                }
-
-                promise.then(function () {
-                    var gen = gds.createPathGenerator(promise.topodata, opts);
-
-                    mapLayer.selectAll('path')
-                        .data(gen.geodata.features)
-                        .enter()
-                        .append('path')
-                        .attr('d', gen.pathgen);
-                });
-                return true;
-            }
-
             return {
                 loadMapInto: loadMapInto
             };
diff --git a/web/gui/src/main/webapp/app/fw/svg/svgUtil.js b/web/gui/src/main/webapp/app/fw/svg/svgUtil.js
index bef8248..3a35e9f 100644
--- a/web/gui/src/main/webapp/app/fw/svg/svgUtil.js
+++ b/web/gui/src/main/webapp/app/fw/svg/svgUtil.js
@@ -240,13 +240,18 @@
                 el.style('visibility', (b ? 'visible' : 'hidden'));
             }
 
+            function safeId(s) {
+                return s.replace(/[^a-z0-9]/gi, '-');
+            }
+
             return {
                 createDragBehavior: createDragBehavior,
                 loadGlow: loadGlow,
                 cat7: cat7,
                 translate: translate,
                 stripPx: stripPx,
-                makeVisible: makeVisible
+                makeVisible: makeVisible,
+                safeId: safeId
             };
         }]);
 }());
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 c3d4ee5..7333a8d 100644
--- a/web/gui/src/main/webapp/app/view/topo/topo.css
+++ b/web/gui/src/main/webapp/app/view/topo/topo.css
@@ -245,3 +245,94 @@
     /* TODO: add blue glow */
     /*filter: url(#blue-glow);*/
 }
+
+
+/* --- Topo Nodes --- */
+
+#ov-topo svg .node {
+    cursor: pointer;
+}
+
+#ov-topo svg .node.selected rect,
+#ov-topo svg .node.selected circle {
+    fill: #f90;
+    /* TODO: add blue glow filter */
+    /*filter: url(#blue-glow);*/
+}
+
+#ov-topo svg .node text {
+    pointer-events: none;
+}
+
+/* Device Nodes */
+
+#ov-topo svg .node.device {
+}
+
+#ov-topo svg .node.device rect {
+    stroke-width: 1.5;
+}
+
+#ov-topo svg .node.device.fixed rect {
+    stroke-width: 1.5;
+    stroke: #ccc;
+}
+
+/* note: device is offline without the 'online' class */
+#ov-topo svg .node.device {
+    fill: #777;
+}
+
+#ov-topo svg .node.device.online {
+    fill: #6e7fa3;
+}
+
+/* note: device is offline without the 'online' class */
+#ov-topo svg .node.device text {
+    fill: #bbb;
+    font: 10pt sans-serif;
+}
+
+#ov-topo svg .node.device.online text {
+    fill: white;
+}
+
+#ov-topo svg .node.device .svgIcon rect {
+    fill: #aaa;
+}
+#ov-topo svg .node.device .svgIcon use {
+    fill: #777;
+}
+#ov-topo svg .node.device.selected .svgIcon rect {
+    fill: #f90;
+}
+#ov-topo svg .node.device.online .svgIcon rect {
+    fill: #ccc;
+}
+#ov-topo svg .node.device.online .svgIcon use {
+    fill: #000;
+}
+#ov-topo svg .node.device.online.selected .svgIcon rect {
+    fill: #f90;
+}
+
+
+/* Host Nodes */
+
+#ov-topo svg .node.host {
+    stroke: #000;
+}
+
+#ov-topo svg .node.host text {
+    fill: #846;
+    stroke: none;
+    font: 9pt sans-serif;
+}
+
+svg .node.host circle {
+    stroke: #000;
+    fill: #edb;
+}
+
+
+
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 28b79ac..7c30f87 100644
--- a/web/gui/src/main/webapp/app/view/topo/topo.js
+++ b/web/gui/src/main/webapp/app/view/topo/topo.js
@@ -28,7 +28,7 @@
     ];
 
     // references to injected services etc.
-    var $log, fs, ks, zs, gs, ms, sus, tfs;
+    var $log, fs, ks, zs, gs, ms, sus, tfs, tis;
 
     // DOM elements
     var ovtopo, svg, defs, zoomLayer, mapG, forceG, noDevsLayer;
@@ -41,20 +41,61 @@
     // --- Short Cut Keys ------------------------------------------------
 
     var keyBindings = {
-        W: [logWarning, '(temp) log a warning'],
-        E: [logError, '(temp) log an error'],
-        R: [resetZoom, 'Reset pan / zoom']
+        //O: [toggleSummary, 'Toggle ONOS summary pane'],
+        I: [toggleInstances, 'Toggle ONOS instances pane'],
+        //D: [toggleDetails, 'Disable / enable details pane'],
+
+        //H: [toggleHosts, 'Toggle host visibility'],
+        //M: [toggleOffline, 'Toggle offline visibility'],
+        //B: [toggleBg, 'Toggle background image'],
+        //P: togglePorts,
+
+        //X: [toggleNodeLock, 'Lock / unlock node positions'],
+        //Z: [toggleOblique, 'Toggle oblique view (Experimental)'],
+        L: [cycleLabels, 'Cycle device labels'],
+        //U: [unpin, 'Unpin node (hover mouse over)'],
+        R: [resetZoom, 'Reset pan / zoom'],
+
+        //V: [showRelatedIntentsAction, 'Show all related intents'],
+        //rightArrow: [showNextIntentAction, 'Show next related intent'],
+        //leftArrow: [showPrevIntentAction, 'Show previous related intent'],
+        //W: [showSelectedIntentTrafficAction, 'Monitor traffic of selected intent'],
+        //A: [showAllTrafficAction, 'Monitor all traffic'],
+        //F: [showDeviceLinkFlowsAction, 'Show device link flows'],
+
+        //E: [equalizeMasters, 'Equalize mastership roles'],
+
+        //esc: handleEscape,
+
+        _helpFormat: [
+            ['O', 'I', 'D', '-', 'H', 'M', 'B', 'P' ],
+            ['X', 'Z', 'L', 'U', 'R' ],
+            ['V', 'rightArrow', 'leftArrow', 'W', 'A', 'F', '-', 'E' ]
+        ]
+
     };
 
-    // -----------------
-    // these functions are necessarily temporary examples....
-    function logWarning() {
-        $log.warn('You have been warned!');
+    // mouse gestures
+    var gestures = [
+        ['click', 'Select the item and show details'],
+        ['shift-click', 'Toggle selection state'],
+        ['drag', 'Reposition (and pin) device / host'],
+        ['cmd-scroll', 'Zoom in / out'],
+        ['cmd-drag', 'Pan']
+    ];
+
+    function toggleInstances() {
+        if (tis.isVisible()) {
+            tis.hide();
+        } else {
+            tis.show();
+        }
+        tfs.updateDeviceColors();
     }
-    function logError() {
-        $log.error('You are erroneous!');
+
+    function cycleLabels() {
+        $log.debug('Cycle Labels.....');
     }
-    // -----------------
 
     function resetZoom() {
         zoomer.reset();
@@ -83,7 +124,6 @@
     function zoomCallback() {
         var tr = zoomer.translate(),
             sc = zoomer.scale();
-        $log.log('ZOOM: translate = ' + tr + ', scale = ' + sc);
 
         // keep the map lines constant width while zooming
         mapG.style('stroke-width', (2.0 / sc) + 'px');
@@ -150,16 +190,19 @@
 
     function setUpMap() {
         mapG = zoomLayer.append('g').attr('id', 'topo-map');
-        //ms.loadMapInto(map, '*continental_us', {mapFillScale:0.5});
-        ms.loadMapInto(mapG, '*continental_us');
+
         //showCallibrationPoints();
+        //return ms.loadMapInto(map, '*continental_us', {mapFillScale:0.5});
+
+        // returns a promise for the projection...
+        return ms.loadMapInto(mapG, '*continental_us');
     }
 
     // --- Force Layout --------------------------------------------------
 
-    function setUpForce() {
+    function setUpForce(xlink) {
         forceG = zoomLayer.append('g').attr('id', 'topo-force');
-        tfs.initForce(forceG, svg.attr('width'), svg.attr('height'));
+        tfs.initForce(forceG, xlink, svg.attr('width'), svg.attr('height'));
     }
 
     // --- Controller Definition -----------------------------------------
@@ -174,8 +217,12 @@
             'TopoInstService',
 
         function ($scope, _$log_, $loc, $timeout, _fs_, mast,
-                  _ks_, _zs_, _gs_, _ms_, _sus_, tes, _tfs_, tps, tis) {
-            var self = this;
+                  _ks_, _zs_, _gs_, _ms_, _sus_, tes, _tfs_, tps, _tis_) {
+            var self = this,
+                xlink = {
+                    showNoDevs: showNoDevs
+                };
+
             $log = _$log_;
             fs = _fs_;
             ks = _ks_;
@@ -184,6 +231,7 @@
             ms = _ms_;
             sus = _sus_;
             tfs = _tfs_;
+            tis = _tis_;
 
             self.notifyResize = function () {
                 svgResized(fs.windowSize(mast.mastHeight()));
@@ -207,8 +255,8 @@
             setUpDefs();
             setUpZoom();
             setUpNoDevs();
-            setUpMap();
-            setUpForce();
+            xlink.projectionPromise = setUpMap();
+            setUpForce(xlink);
 
             tis.initInst();
             tps.initPanels();
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 b4b5c74..6cd634f 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoEvent.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoEvent.js
@@ -23,7 +23,7 @@
     'use strict';
 
     // injected refs
-    var $log, wss, wes, tps, tis;
+    var $log, wss, wes, tps, tis, tfs;
 
     // internal state
     var wsock;
@@ -32,7 +32,9 @@
         showSummary: showSummary,
         addInstance: addInstance,
         updateInstance: updateInstance,
-        removeInstance: removeInstance
+        removeInstance: removeInstance,
+        addDevice: addDevice,
+        updateDevice: updateDevice
         // TODO: implement remaining handlers..
 
     };
@@ -63,6 +65,16 @@
         tis.removeInstance(ev.payload);
     }
 
+    function addDevice(ev) {
+        $log.debug('  **** Add Device **** ', ev.payload);
+        tfs.addDevice(ev.payload);
+    }
+
+    function updateDevice(ev) {
+        $log.debug('  **** Update Device **** ', ev.payload);
+        tfs.updateDevice(ev.payload);
+    }
+
     // ==========================
 
     var dispatcher = {
@@ -100,14 +112,15 @@
     angular.module('ovTopo')
     .factory('TopoEventService',
         ['$log', '$location', 'WebSocketService', 'WsEventService',
-            'TopoPanelService', 'TopoInstService',
+            'TopoPanelService', 'TopoInstService', 'TopoForceService',
 
-        function (_$log_, $loc, _wss_, _wes_, _tps_, _tis_) {
+        function (_$log_, $loc, _wss_, _wes_, _tps_, _tis_, _tfs_) {
             $log = _$log_;
             wss = _wss_;
             wes = _wes_;
             tps = _tps_;
             tis = _tis_;
+            tfs = _tfs_;
 
             function bindDispatcher(TopoDomElementsPassedHere) {
                 // TODO: store refs to topo DOM elements...
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 3a0791c..fb6ca06 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoForce.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoForce.js
@@ -23,10 +23,29 @@
     'use strict';
 
     // injected refs
-    var $log, sus;
+    var $log, sus, is, ts, tis, xlink;
+
+    // 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
+    };
 
     // internal state
-    var settings,
+    var settings,   // merged default settings and options
         force,      // force layout object
         drag,       // drag behavior handler
         network = {
@@ -34,8 +53,10 @@
             links: [],
             lookup: {},
             revLinkToKey: {}
-        };
-
+        },
+        projection,             // background map projection
+        deviceLabelIndex = 0,   // for device label cycling
+        hostLabelIndex = 0;     // for host label cycling
 
     // SVG elements;
     var linkG, linkLabelG, nodeG;
@@ -71,12 +92,517 @@
     };
 
 
+    // ==========================
+    // === EVENT HANDLERS
+
+    function addDevice(data) {
+        var id = data.id,
+            d;
+
+        xlink.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]) {
+            updateDevice(data);
+            return;
+        }
+
+        d = createDeviceNode(data);
+        network.nodes.push(d);
+        network.lookup[id] = d;
+
+        $log.debug("Created new device.. ", d.id, d.x, d.y);
+
+        updateNodes();
+        fStart();
+    }
+
+    function updateDevice(data) {
+        var id = data.id,
+            d = network.lookup[id],
+            wasOnline;
+
+        if (d) {
+            wasOnline = d.online;
+            angular.extend(d, data);
+            if (positionNode(d, true)) {
+                sendUpdateMeta(d, true);
+            }
+            updateNodes();
+            if (wasOnline !== d.online) {
+                // TODO: re-instate link update, and offline visibility
+                //findAttachedLinks(d.id).forEach(restyleLinkElement);
+                //updateOfflineVisibility(d);
+            }
+        } else {
+            // TODO: decide whether we want to capture logic errors
+            //logicError('updateDevice lookup fail. ID = "' + id + '"');
+        }
+    }
+
+    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
+        //});
+    }
+
+
+    function updateNodes() {
+        $log.debug('TODO updateNodes()...');
+        // TODO...
+    }
+
+    function fStart() {
+        $log.debug('TODO fStart()...');
+        // TODO...
+    }
+
+    function fResume() {
+        $log.debug('TODO fResume()...');
+        // TODO...
+    }
+
+    // ==========================
+    // === 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];
+    }
+
+    function positionNode(node, forUpdate) {
+        var meta = node.metaUi,
+            x = meta && meta.x,
+            y = meta && meta.y,
+            xy;
+
+        // If we have [x,y] already, use that...
+        if (x && y) {
+            node.fixed = true;
+            node.px = node.x = x;
+            node.py = node.y = y;
+            return;
+        }
+
+        var location = node.location,
+            coord;
+
+        if (location && location.type === 'latlng') {
+            coord = coordFromLngLat(location);
+            node.fixed = true;
+            node.px = node.x = coord[0];
+            node.py = node.y = coord[1];
+            return true;
+        }
+
+        // if this is a node update (not a node add).. skip randomizer
+        if (forUpdate) {
+            return;
+        }
+
+        // Note: Placing incoming unpinned nodes at exactly the same point
+        //        (center of the view) causes them to explode outwards when
+        //        the force layout kicks in. So, we spread them out a bit
+        //        initially, to provide a more serene layout convergence.
+        //       Additionally, if the node is a host, we place it near
+        //        the device it is connected to.
+
+        function spread(s) {
+            return Math.floor((Math.random() * s) - s/2);
+        }
+
+        function randDim(dim) {
+            return dim / 2 + spread(dim * 0.7071);
+        }
+
+        function rand() {
+            return {
+                x: randDim(network.view.width()),
+                y: randDim(network.view.height())
+            };
+        }
+
+        function near(node) {
+            var min = 12,
+                dx = spread(12),
+                dy = spread(12);
+            return {
+                x: node.x + min + dx,
+                y: node.y + min + dy
+            };
+        }
+
+        function getDevice(cp) {
+            var d = network.lookup[cp.device];
+            return d || rand();
+        }
+
+        xy = (node.class === 'host') ? near(getDevice(node.cp)) : rand();
+        angular.extend(node, xy);
+    }
+
+    function createDeviceNode(device) {
+        // start with the object as is
+        var node = device,
+            type = device.type,
+            svgCls = type ? 'node device ' + type : 'node device';
+
+        // Augment as needed...
+        node.class = 'device';
+        node.svgClass = device.online ? svgCls + ' online' : svgCls;
+        positionNode(node);
+        return node;
+    }
+
+    // ==========================
+    // === 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 = is.iconConfig().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 nodeMouseOver(m) {
+        // TODO
+        $log.debug("TODO nodeMouseOver()...", m);
+    }
+
+    function nodeMouseOut(m) {
+        // TODO
+        $log.debug("TODO nodeMouseOut()...", m);
+    }
+
+    function updateDeviceColors(d) {
+        if (d) {
+            setDeviceColor(d);
+        } else {
+            node.filter('.device').each(function (d) {
+                setDeviceColor(d);
+            });
+        }
+    }
+
+    var dCol = {
+        black: '#000',
+        paleblue: '#acf',
+        offwhite: '#ddd',
+        midgrey: '#888',
+        lightgrey: '#bbb',
+        orange: '#f90'
+    };
+
+    // note: these are the device icon colors without affinity
+    var dColTheme = {
+        light: {
+            online: {
+                glyph: dCol.black,
+                rect: dCol.paleblue
+            },
+            offline: {
+                glyph: dCol.midgrey,
+                rect: dCol.lightgrey
+            }
+        },
+        // TODO: theme
+        dark: {
+            online: {
+                glyph: dCol.black,
+                rect: dCol.paleblue
+            },
+            offline: {
+                glyph: dCol.midgrey,
+                rect: dCol.lightgrey
+            }
+        }
+    };
+
+    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),
+            g, r,
+            icon = d.el.select('g.deviceIcon');
+
+        if (s) {
+            g = c.glyph;
+            r = dCol.orange;
+        } else if (tis.isVisible()) {
+            g = o ? a : c.glyph;
+            r = o ? dCol.offwhite : 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() {
+        node = nodeG.selectAll('.node')
+            .data(network.nodes, function (d) { return d.id; });
+
+        // operate on existing nodes...
+        node.filter('.device').each(function (d) {
+            var node = d.el;
+            node.classed('online', d.online);
+            updateDeviceLabel(d);
+            positionNode(d, true);
+        });
+
+        node.filter('.host').each(function (d) {
+            updateHostLabel(d);
+            positionNode(d, true);
+        });
+
+        // operate on entering nodes:
+        var entering = node.enter()
+            .append('g')
+            .attr({
+                id: function (d) { return sus.safeId(d.id); },
+                class: mkSvgClass,
+                transform: function (d) { return sus.translate(d.x, d.y); },
+                opacity: 0
+            })
+            .call(drag)
+            .on('mouseover', nodeMouseOver)
+            .on('mouseout', nodeMouseOut)
+            .transition()
+            .attr('opacity', 1);
+
+        // augment device nodes...
+        entering.filter('.device').each(function (d) {
+            var node = d3.select(this),
+                glyphId = d.type || 'unknown',
+                label = trimLabel(deviceLabel(d)),
+                noLabel = !label,
+                box, dx, dy, icon;
+
+            // provide ref to element from backing data....
+            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);
+            d.iconDim = icon.dim;
+
+            if (noLabel) {
+                dx = -icon.dim/2;
+                dy = -icon.dim/2;
+            } else {
+                box = adjustRectToFitText(node);
+                dx = box.x + iconConfig.xoff;
+                dy = box.y + iconConfig.yoff;
+            }
+
+            icon.attr('transform', sus.translate(dx, dy));
+        });
+
+        // augment host nodes...
+        entering.filter('.host').each(function (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;
+
+            // provide ref to element from backing data....
+            d.el = node;
+
+            //TODO: showHostVis(node);
+
+            node.append('circle').attr('r', r);
+            if (iid) {
+                //TODO: addHostIcon(node, r, iid);
+            }
+            node.append('text')
+                .text(hostLabel)
+                .attr('dy', textDy)
+                .attr('text-anchor', 'middle');
+        });
+
+        // operate on both existing and new nodes, if necessary
+        updateDeviceColors();
+
+        // operate on exiting nodes:
+        // Note that the node is removed after 2 seconds.
+        // Sub element animations should be shorter than 2 seconds.
+        var exiting = node.exit()
+            .transition()
+            .duration(2000)
+            .style('opacity', 0)
+            .remove();
+
+        // host node exits....
+        exiting.filter('.host').each(function (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);
+        });
+
+        // device node exits....
+        exiting.filter('.device').each(function (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);
+        });
+        fResume();
+    }
+
+
+    // ==========================
     // force layout tick function
     function tick() {
 
     }
 
 
+    // ==========================
+    // === MOUSE GESTURE HANDLERS
+
     function selectCb() { }
     function atDragEnd() {}
     function dragEnabled() {}
@@ -84,23 +610,38 @@
 
 
     // ==========================
+    // Module definition
 
     angular.module('ovTopo')
     .factory('TopoForceService',
-        ['$log', 'SvgUtilService',
+        ['$log', 'SvgUtilService', 'IconService', 'ThemeService',
+            'TopoInstService',
 
-        function (_$log_, _sus_) {
+        function (_$log_, _sus_, _is_, _ts_, _tis_) {
             $log = _$log_;
             sus = _sus_;
+            is = _is_;
+            ts = _ts_;
+            tis = _tis_;
 
             // 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, w, h, opts) {
+            function initForce(forceG, _xlink_, w, h, opts) {
                 $log.debug('initForce().. WxH = ' + w + 'x' + h);
+                xlink = _xlink_;
 
                 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');
@@ -127,12 +668,16 @@
             function resize(dim) {
                 force.size([dim.width, dim.height]);
                 // Review -- do we need to nudge the layout ?
-
             }
 
             return {
                 initForce: initForce,
-                resize: resize
+                resize: resize,
+
+                updateDeviceColors: updateDeviceColors,
+
+                addDevice: addDevice,
+                updateDevice: updateDevice
             };
         }]);
 }());
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 2ff51af..f68e46d 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoInst.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoInst.js
@@ -325,7 +325,10 @@
                 destroyInst: destroyInst,
                 addInstance: addInstance,
                 updateInstance: updateInstance,
-                removeInstance: removeInstance
+                removeInstance: removeInstance,
+                isVisible: function () { return oiBox.isVisible(); },
+                show: function () { oiBox.show(); },
+                hide: function () { oiBox.hide(); }
             };
         }]);
 }());