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