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