GUI -- TopoView - Implemented much of the node selection logic. (WIP)
- introduced topoSelect.js.

Change-Id: Ic843c7d8dc2249fe0cb8c33de60dce12c07aea44
diff --git a/web/gui/src/main/webapp/app/fw/layer/panel.js b/web/gui/src/main/webapp/app/fw/layer/panel.js
index a29c175..46934f1 100644
--- a/web/gui/src/main/webapp/app/fw/layer/panel.js
+++ b/web/gui/src/main/webapp/app/fw/layer/panel.js
@@ -77,6 +77,7 @@
                 width: panelWidth,
                 height: panelHeight,
                 isVisible: panelIsVisible,
+                classed: classed,
                 el: panelEl
             };
 
@@ -146,6 +147,10 @@
             return p.on;
         }
 
+        function classed(cls, bool) {
+            return p.el.classed(cls, bool);
+        }
+
         function panelEl() {
             return p.el;
         }
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 ab56ddd..2c47d44 100644
--- a/web/gui/src/main/webapp/app/fw/svg/svgUtil.js
+++ b/web/gui/src/main/webapp/app/fw/svg/svgUtil.js
@@ -34,6 +34,7 @@
             $log = _$log_;
             fs = _fs_;
 
+            // TODO: change 'force' ref to be 'force.alpha' ref.
             function createDragBehavior(force, selectCb, atDragEnd,
                                         dragEnabled, clickEnabled) {
                 var draggedThreshold = d3.scale.linear()
diff --git a/web/gui/src/main/webapp/app/index.html b/web/gui/src/main/webapp/app/index.html
index b7b8af0..ccc737b 100644
--- a/web/gui/src/main/webapp/app/index.html
+++ b/web/gui/src/main/webapp/app/index.html
@@ -82,9 +82,10 @@
     <script src="view/topo/topo.js"></script>
     <script src="view/topo/topoEvent.js"></script>
     <script src="view/topo/topoForce.js"></script>
+    <script src="view/topo/topoInst.js"></script>
     <script src="view/topo/topoModel.js"></script>
     <script src="view/topo/topoPanel.js"></script>
-    <script src="view/topo/topoInst.js"></script>
+    <script src="view/topo/topoSelect.js"></script>
     <script src="view/device/device.js"></script>
     <!-- TODO: inject javascript refs server-side -->
 
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 751d280..845bf75 100644
--- a/web/gui/src/main/webapp/app/view/topo/topo.css
+++ b/web/gui/src/main/webapp/app/view/topo/topo.css
@@ -72,71 +72,121 @@
 
 #topo-p-summary {
     /* Base css from panel.css */
-    
 }
 
-#topo-p-summary svg {
+/* --- Topo Detail Panel --- */
+
+#topo-p-detail {
+    /* Base css from panel.css */
+    top: 320px;
+}
+
+/* --- general topo-panel styling --- */
+
+.topo-p svg {
     display: inline-block;
     width: 42px;
     height: 42px;
 }
 
-#topo-p-summary h2 {
+.light .topo-p svg .glyph {
+    fill: #222;
+}
+
+.dark .topo-p svg .glyph.overlay {
+    fill: #222;
+}
+
+.dark .topo-p svg .glyph {
+    fill: #ddd;
+}
+.light .topo-p svg .glyph.overlay {
+    fill: #fff;
+}
+
+
+.topo-p h2 {
     position: absolute;
     margin: 0 4px;
     top: 20px;
     left: 50px;
 }
-.light #topo-p-summary h2 {
+.light .topo-p h2 {
     color: black;
 }
-.dark #topo-p-summary h2 {
+.dark .topo-p h2 {
     color: #ddd;
 }
 
-#topo-p-summary h3 {
+.topo-p h3 {
     margin: 0 4px;
     top: 20px;
     left: 50px;
 }
-.light #topo-p-summary h3 {
+.light .topo-p h3 {
     color: black;
 }
-.dark #topo-p-summary h3 {
+.dark .topo-p h3 {
     color: #ddd;
 }
 
-#topo-p-summary p, table {
+.topo-p p, table {
     margin: 4px 4px;
 }
 
-#topo-p-summary td.label {
+.topo-p td.label {
     font-style: italic;
     padding-right: 12px;
     /* works for both light and dark themes ... */
     color: #777;
 }
 
-#topo-p-summary td.value {
+.topo-p td.value {
 }
 
-#topo-p-summary hr {
+.topo-p hr {
     height: 1px;
     border: 0;
 }
-.light #topo-p-summary hr {
+.light .topo-p hr {
     background-color: #ccc;
     color: #ccc;
 }
-.dark #topo-p-summary hr {
+.dark .topo-p hr {
     background-color: #888;
     color: #888;
 }
 
 
-/* --- Topo Detail Panel --- */
+.topo-p .actionBtn {
+    margin: 6px 12px;
+    padding: 2px 6px;
+    font-size: 9pt;
+    cursor: pointer;
+    width: 200px;
+    text-align: center;
+    border-radius: 4px;
+}
+.light .topo-p .actionBtn {
+    border: 2px solid #ddd;
+    color: #eee;
+    background: #888;
+}
+.dark .topo-p .actionBtn {
+    border: 2px solid #222;
+    color: #888;
+    background: #444;
+}
 
-/* TODO: add CSS rules */
+.light .topo-p .actionBtn:hover {
+    color: #eee;
+    background: #444;
+}
+.dark .topo-p .actionBtn:hover {
+    color: #eee;
+    background: #666;
+}
+
 
 
 /* --- Topo Instance Panel --- */
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 a512704..a835d6b 100644
--- a/web/gui/src/main/webapp/app/view/topo/topo.js
+++ b/web/gui/src/main/webapp/app/view/topo/topo.js
@@ -66,7 +66,7 @@
 
             //E: [equalizeMasters, 'Equalize mastership roles'],
 
-            //esc: handleEscape,
+            esc: handleEscape,
 
             _helpFormat: [
                 ['O', 'I', 'D', '-', 'H', 'M', 'B', 'P' ],
@@ -85,12 +85,29 @@
         ];
     }
 
+    // --- Keystroke functions -------------------------------------------
 
     function toggleInstances() {
         tis.toggle();
         tfs.updateDeviceColors();
     }
 
+    function resetZoom() {
+        zoomer.reset();
+    }
+
+    function handleEscape() {
+        $log.debug("TODO: handle-ESCAPE...");
+        // if showingAffinity: cancelAffinity
+
+        // else if showingDetails: deselectAll
+
+        // else if oiBox visible: hide oiBox
+
+        // else if summary panel visible: cancel Summary
+
+        // else: hoverMode = hoverModeNone
+    }
 
     // --- Glyphs, Icons, and the like -----------------------------------
 
@@ -124,10 +141,6 @@
         });
     }
 
-    function resetZoom() {
-        zoomer.reset();
-    }
-
 
     // callback invoked when the SVG view has been resized..
     function svgResized(s) {
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 887fe4a..edebb52 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoEvent.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoEvent.js
@@ -27,7 +27,7 @@
     'use strict';
 
     // injected refs
-    var $log, wss, wes, tps, tis, tfs;
+    var $log, wss, wes, tps, tis, tfs, tss;
 
     // internal state
     var wsock, evApis;
@@ -37,9 +37,13 @@
     function bindApis() {
         evApis = {
             showSummary: tps,
+
+            showDetails: tss,
+
             addInstance: tis,
             updateInstance: tis,
             removeInstance: tis,
+
             addDevice: tfs,
             updateDevice: tfs,
             removeDevice: tfs,
@@ -100,14 +104,16 @@
     .factory('TopoEventService',
         ['$log', '$location', 'WebSocketService', 'WsEventService',
             'TopoPanelService', 'TopoInstService', 'TopoForceService',
+            'TopoSelectService',
 
-        function (_$log_, $loc, _wss_, _wes_, _tps_, _tis_, _tfs_) {
+        function (_$log_, $loc, _wss_, _wes_, _tps_, _tis_, _tfs_, _tss_) {
             $log = _$log_;
             wss = _wss_;
             wes = _wes_;
             tps = _tps_;
             tis = _tis_;
             tfs = _tfs_;
+            tss = _tss_;
 
             bindApis();
 
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 703212f..87b554e 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,7 @@
     'use strict';
 
     // injected refs
-    var $log, fs, sus, is, ts, flash, tis, tms, icfg, uplink;
+    var $log, fs, sus, is, ts, flash, tis, tms, tss, icfg, uplink;
 
     // configuration
     var labelConfig = {
@@ -77,10 +77,7 @@
         showOffline = true,     // whether offline devices are displayed
         oblique = false,        // whether we are in the oblique view
         nodeLock = false,       // whether nodes can be dragged or not (locked)
-        dim,                    // the dimensions of the force layout [w,h]
-        hovered,                // the node over which the mouse is hovering
-        selections = {},        // what is currently selected
-        selectOrder = [];       // the order in which we made selections
+        dim;                    // the dimensions of the force layout [w,h]
 
     // SVG elements;
     var linkG, linkLabelG, nodeG;
@@ -311,8 +308,6 @@
             .attr('stroke', linkConfig[th].baseColor);
     }
 
-
-
     function removeLinkElement(d) {
         var idx = fs.find(d.key, network.links, 'key'),
             removed;
@@ -418,34 +413,10 @@
         });
     }
 
-    function requestTrafficForMode() {
-        $log.debug('TODO: requestTrafficForMode()...');
-    }
-
 
     // ==========================
     // === Devices and hosts - D3 rendering
 
-    function nodeMouseOver(m) {
-        if (!m.dragStarted) {
-            $log.debug("MouseOver()...", m);
-            if (hovered != m) {
-                hovered = m;
-                requestTrafficForMode();
-            }
-        }
-    }
-
-    function nodeMouseOut(m) {
-        if (!m.dragStarted) {
-            if (hovered) {
-                hovered = null;
-                requestTrafficForMode();
-            }
-            $log.debug("MouseOut()...", m);
-        }
-    }
-
 
     // Returns the newly computed bounding box of the rectangle
     function adjustRectToFitText(n) {
@@ -568,10 +539,11 @@
     }
 
     function unpin() {
-        if (hovered) {
-            sendUpdateMeta(hovered, true);
-            hovered.fixed = false;
-            hovered.el.classed('fixed', false);
+        var hov = tss.hovered();
+        if (hov) {
+            sendUpdateMeta(hov, true);
+            hov.fixed = false;
+            hov.el.classed('fixed', false);
             fResume();
         }
     }
@@ -668,8 +640,8 @@
                 opacity: 0
             })
             .call(drag)
-            .on('mouseover', nodeMouseOver)
-            .on('mouseout', nodeMouseOut)
+            .on('mouseover', tss.nodeMouseOver)
+            .on('mouseout', tss.nodeMouseOut)
             .transition()
             .attr('opacity', 1);
 
@@ -998,72 +970,6 @@
     }
 
 
-    function updateDetailPanel() {
-        // TODO update detail panel
-        $log.debug("TODO: updateDetailPanel() ...");
-    }
-
-
-    // ==========================
-    // === SELECTION / DESELECTION
-
-    function selectObject(obj) {
-        var el = this,
-            ev = d3.event.sourceEvent,
-            n;
-
-        if (zoomingOrPanning(ev)) {
-            return;
-        }
-
-        if (el) {
-            n = d3.select(el);
-        } else {
-            node.each(function (d) {
-                if (d == obj) {
-                    n = d3.select(el = this);
-                }
-            });
-        }
-        if (!n) return;
-
-        if (ev.shiftKey && n.classed('selected')) {
-            deselectObject(obj.id);
-            updateDetailPanel();
-            return;
-        }
-
-        if (!ev.shiftKey) {
-            deselectAll();
-        }
-
-        selections[obj.id] = { obj: obj, el: el };
-        selectOrder.push(obj.id);
-
-        n.classed('selected', true);
-        updateDeviceColors(obj);
-        updateDetailPanel();
-    }
-
-    function deselectObject(id) {
-        var obj = selections[id];
-        if (obj) {
-            d3.select(obj.el).classed('selected', false);
-            delete selections[id];
-            fs.removeFromArray(id, selectOrder);
-            updateDeviceColors(obj.obj);
-        }
-    }
-
-    function deselectAll() {
-        // deselect all nodes in the network...
-        node.classed('selected', false);
-        selections = {};
-        selectOrder = [];
-        updateDeviceColors();
-        updateDetailPanel();
-    }
-
     // ==========================
     // === MOUSE GESTURE HANDLERS
 
@@ -1103,12 +1009,22 @@
         };
     }
 
+    function mkSelectApi(uplink) {
+        return {
+            node: function () { return node; },
+            zoomingOrPanning: zoomingOrPanning,
+            updateDeviceColors: updateDeviceColors,
+            sendEvent: uplink.sendEvent
+        };
+    }
+
     angular.module('ovTopo')
     .factory('TopoForceService',
         ['$log', 'FnService', 'SvgUtilService', 'IconService', 'ThemeService',
             'FlashService', 'TopoInstService', 'TopoModelService',
+            'TopoSelectService',
 
-        function (_$log_, _fs_, _sus_, _is_, _ts_, _flash_, _tis_, _tms_) {
+        function (_$log_, _fs_, _sus_, _is_, _ts_, _flash_, _tis_, _tms_, _tss_) {
             $log = _$log_;
             fs = _fs_;
             sus = _sus_;
@@ -1117,6 +1033,7 @@
             flash = _flash_;
             tis = _tis_;
             tms = _tms_;
+            tss = _tss_;
 
             icfg = is.iconConfig();
 
@@ -1131,6 +1048,7 @@
                 $log.debug('initForce().. dim = ' + dim);
 
                 tms.initModel(mkModelApi(uplink), dim);
+                tss.initSelect(mkSelectApi(uplink));
 
                 settings = angular.extend({}, defaultSettings, opts);
 
@@ -1154,7 +1072,7 @@
                     .on('tick', tick);
 
                 drag = sus.createDragBehavior(force,
-                    selectObject, atDragEnd, dragEnabled, clickEnabled);
+                    tss.selectObject, atDragEnd, dragEnabled, clickEnabled);
             }
 
             function newDim(_dim_) {
diff --git a/web/gui/src/main/webapp/app/view/topo/topoModel.js b/web/gui/src/main/webapp/app/view/topo/topoModel.js
index 4754f60..de31eef 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoModel.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoModel.js
@@ -24,18 +24,21 @@
     'use strict';
 
     // injected refs
-    var $log, fs, rnd, api;
+    var $log, fs, rnd;
+
+    // api to topoForce
+    var api;
+    /*
+       projection()
+       network {...}
+       restyleLinkElement( ldata )
+       removeLinkElement( ldata )
+     */
 
     // shorthand
     var lu, rlk, nodes, links;
 
-    // api:
-    //   projection: func()
-    //   network {...}
-    //   restyleLinkElement: func(ldata)
-    //   removeLinkElement: func(ldata)
-
-    var dim;    // dimensions of layout, as [w,h]
+    var dim;    // dimensions of layout [w,h]
 
     // configuration 'constants'
     var defaultLinkType = 'direct',
diff --git a/web/gui/src/main/webapp/app/view/topo/topoPanel.js b/web/gui/src/main/webapp/app/view/topo/topoPanel.js
index 627a94e..3053643 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoPanel.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoPanel.js
@@ -26,7 +26,8 @@
     var $log, ps, gs;
 
     // constants
-    var idSum = 'topo-p-summary',
+    var pCls = 'topo-p',
+        idSum = 'topo-p-summary',
         idDet = 'topo-p-detail',
         panelOpts = {
             width: 260
@@ -36,35 +37,9 @@
     var summaryPanel,
         detailPanel;
 
-    // ==========================
-    // *** SHOW SUMMARY ***
 
-    function showSummary(data) {
-        populateSummary(data);
-        showSummaryPanel();
-    }
-
-    function populateSummary(data) {
-        summaryPanel.empty();
-
-        var svg = summaryPanel.append('svg'),
-            title = summaryPanel.append('h2'),
-            table = summaryPanel.append('table'),
-            tbody = table.append('tbody');
-
-        gs.addGlyph(svg, 'node', 40);
-        gs.addGlyph(svg, 'bird', 24, true, [8,12]);
-
-        title.text(data.id);
-
-        data.propOrder.forEach(function(p) {
-            if (p === '-') {
-                addSep(tbody);
-            } else {
-                addProp(tbody, p, data.props[p]);
-            }
-        });
-    }
+    // === -----------------------------------------------------
+    // Utility functions
 
     function addSep(tbody) {
         tbody.append('tr').append('td').attr('colspan', 2).append('hr');
@@ -80,16 +55,116 @@
         addCell('value', value);
     }
 
+    function listProps(tbody, data) {
+        data.propOrder.forEach(function(p) {
+            if (p === '-') {
+                addSep(tbody);
+            } else {
+                addProp(tbody, p, data.props[p]);
+            }
+        });
+    }
+
+    function dpa(x) {
+        return detailPanel.append(x);
+    }
+
+    function spa(x) {
+        return summaryPanel.append(x);
+    }
+
+    // === -----------------------------------------------------
+    //  Functions for populating the summary panel
+
+    function populateSummary(data) {
+        summaryPanel.empty();
+
+        var svg = spa('svg'),
+            title = spa('h2'),
+            table = spa('table'),
+            tbody = table.append('tbody');
+
+        gs.addGlyph(svg, 'node', 40);
+        gs.addGlyph(svg, 'bird', 24, true, [8,12]);
+
+        title.text(data.id);
+        listProps(tbody, data);
+    }
+
+    // === -----------------------------------------------------
+    //  Functions for populating the detail panel
+
+    function displaySingle(data) {
+        detailPanel.empty();
+
+        var svg = dpa('svg'),
+            title = dpa('h2'),
+            table = dpa('table'),
+            tbody = table.append('tbody');
+
+        gs.addGlyph(svg, (data.type || 'unknown'), 40);
+        title.text(data.id);
+        listProps(tbody, data);
+        dpa('hr');
+    }
+
+    function displayMulti(ids) {
+        detailPanel.empty();
+
+        var title = dpa('h3'),
+            table = dpa('table'),
+            tbody = table.append('tbody');
+
+        title.text('Selected Nodes');
+        ids.forEach(function (d, i) {
+            addProp(tbody, i+1, d);
+        });
+        dpa('hr');
+    }
+
+    function addAction(text, cb) {
+        dpa('div')
+            .classed('actionBtn', true)
+            .text(text)
+            .on('click', cb);
+    }
+
+    // === -----------------------------------------------------
+    //  Event Handlers
+
+    function showSummary(data) {
+        populateSummary(data);
+        showSummaryPanel();
+    }
+
+
+    // === -----------------------------------------------------
+    // === LOGIC For showing/hiding summary and detail panels...
+
     function showSummaryPanel() {
         summaryPanel.show();
         // TODO: augment, once we have the details pane also
     }
 
+    function showDetailPanel() {
+        // TODO: augment with summary-accomodation-logic
+        detailPanel.show();
+    }
+
+    function hideDetailPanel() {
+        detailPanel.hide();
+    }
+
+
+
     // ==========================
 
     function initPanels() {
         summaryPanel = ps.createPanel(idSum, panelOpts);
         detailPanel = ps.createPanel(idDet, panelOpts);
+
+        summaryPanel.classed(pCls, true);
+        detailPanel.classed(pCls, true);
     }
 
     function destroyPanels() {
@@ -112,7 +187,18 @@
             return {
                 initPanels: initPanels,
                 destroyPanels: destroyPanels,
-                showSummary: showSummary
+
+                showSummary: showSummary,
+
+                displaySingle: displaySingle,
+                displayMulti: displayMulti,
+                addAction: addAction,
+
+                showDetailPanel: showDetailPanel,
+                hideDetailPanel: hideDetailPanel,
+
+                detailVisible: function () { return detailPanel.isVisible(); },
+                summaryVisible: function () { return summaryPanel.isVisible(); }
             };
         }]);
 }());
diff --git a/web/gui/src/main/webapp/app/view/topo/topoSelect.js b/web/gui/src/main/webapp/app/view/topo/topoSelect.js
new file mode 100644
index 0000000..b7790f2
--- /dev/null
+++ b/web/gui/src/main/webapp/app/view/topo/topoSelect.js
@@ -0,0 +1,293 @@
+/*
+ * Copyright 2015 Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ ONOS GUI -- Topology Selection Module.
+ Defines behavior when selecting nodes.
+ */
+
+(function () {
+    'use strict';
+
+    // injected refs
+    var $log, fs, tps;
+
+    // api to topoForce
+    var api;
+    /*
+       node()                         // get ref to D3 selection of nodes
+       zoomingOrPanning( ev )
+       updateDeviceColors( [dev] )
+       sendEvent( type, {payload} )
+     */
+
+    // internal state
+    var hovered,                // the node over which the mouse is hovering
+        selections = {},        // currently selected nodes (by id)
+        selectOrder = [],       // the order in which we made selections
+        haveDetails = false,    // do we have details of one or more nodes?
+        useDetails = true;      // should we show details if we have 'em?
+
+    // ==========================
+
+    function nSel() {
+        return selectOrder.length;
+    }
+    function getSel(idx) {
+        return selections[selectOrder[idx]];
+    }
+    function allSelectionsClass(cls) {
+        for (var i=0, n=nSel(); i<n; i++) {
+            if (getSel(i).obj.class !== cls) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    // ==========================
+
+    function nodeMouseOver(m) {
+        if (!m.dragStarted) {
+            $log.debug("MouseOver()...", m);
+            if (hovered != m) {
+                hovered = m;
+                requestTrafficForMode();
+            }
+        }
+    }
+
+    function nodeMouseOut(m) {
+        if (!m.dragStarted) {
+            if (hovered) {
+                hovered = null;
+                requestTrafficForMode();
+            }
+            $log.debug("MouseOut()...", m);
+        }
+    }
+
+    // ==========================
+
+    function selectObject(obj) {
+        var el = this,
+            ev = d3.event.sourceEvent,
+            n;
+
+        if (api.zoomingOrPanning(ev)) {
+            return;
+        }
+
+        if (el) {
+            n = d3.select(el);
+        } else {
+            api.node().each(function (d) {
+                if (d == obj) {
+                    n = d3.select(el = this);
+                }
+            });
+        }
+        if (!n) return;
+
+        if (ev.shiftKey && n.classed('selected')) {
+            deselectObject(obj.id);
+            updateDetail();
+            return;
+        }
+
+        if (!ev.shiftKey) {
+            deselectAll();
+        }
+
+        selections[obj.id] = { obj: obj, el: el };
+        selectOrder.push(obj.id);
+
+        n.classed('selected', true);
+        api.updateDeviceColors(obj);
+        updateDetail();
+
+        debugSel();
+    }
+
+    function deselectObject(id) {
+        var obj = selections[id];
+        if (obj) {
+            d3.select(obj.el).classed('selected', false);
+            delete selections[id];
+            fs.removeFromArray(id, selectOrder);
+            api.updateDeviceColors(obj.obj);
+        }
+
+        debugSel();
+    }
+
+    function deselectAll() {
+        // deselect all nodes in the network...
+        api.node().classed('selected', false);
+        selections = {};
+        selectOrder = [];
+        api.updateDeviceColors();
+        updateDetail();
+
+        debugSel();
+    }
+
+    function debugSel() {
+        $log.debug(' ..... Selected now >> ', selectOrder);
+    }
+
+    // === -----------------------------------------------------
+
+    function requestDetails() {
+        var data = getSel(0).obj;
+        api.sendEvent('requestDetails', {
+            id: data.id,
+            class: data.class
+        });
+    }
+
+    // === -----------------------------------------------------
+
+    function updateDetail() {
+        var nSel = selectOrder.length;
+        if (!nSel) {
+            emptySelect();
+        } else if (nSel === 1) {
+            singleSelect();
+        } else {
+            multiSelect();
+        }
+    }
+
+    function emptySelect() {
+        haveDetails = false;
+        tps.hideDetailPanel();
+        cancelTraffic();
+    }
+
+    function singleSelect() {
+        // NOTE: detail is shown from 'showDetails' event callback
+        requestDetails();
+        cancelTraffic();
+        requestTrafficForMode();
+    }
+
+    function multiSelect() {
+        haveDetails = true;
+
+        // display the selected nodes in the detail panel
+        tps.displayMulti(selectOrder);
+
+        // always add the 'show traffic' action
+        tps.addAction('Show Related Traffic', showRelatedIntentsAction);
+
+        // add other actions, based on what is selected...
+        if (nSel() === 2 && allSelectionsClass('host')) {
+            tps.addAction('Create Host-to-Host Flow', addHostIntentAction);
+        } else if (nSel() >= 2 && allSelectionsClass('host')) {
+            tps.addAction('Create Multi-Source Flow', addMultiSourceIntentAction);
+        }
+
+        cancelTraffic();
+        requestTrafficForMode();
+    }
+
+
+    // === -----------------------------------------------------
+    //  Event Handlers
+
+    function showDetails(data) {
+        haveDetails = true;
+
+        // display the data for the single selected node
+        tps.displaySingle(data);
+
+        // always add the 'show traffic' action
+        tps.addAction('Show Related Traffic', showRelatedIntentsAction);
+
+        // add other actions, based on what is selected...
+        if (data.type === 'switch') {
+            tps.addAction('Show Device Flows', showDeviceLinkFlowsAction);
+        }
+
+        // only show the details panel if the user hasn't "hidden" it
+        if (useDetails) {
+            tps.showDetailPanel();
+        }
+    }
+
+    // === -----------------------------------------------------
+    //  TODO: migrate these to topoTraffic.js
+
+    function cancelTraffic() {
+        $log.debug('TODO: cancelTraffic');
+
+    }
+    function requestTrafficForMode() {
+        $log.debug('TODO: requestTrafficForMode');
+
+    }
+    function showRelatedIntentsAction () {
+        $log.debug('TODO: showRelatedIntentsAction');
+
+    }
+    function addHostIntentAction () {
+        $log.debug('TODO: addHostIntentAction');
+
+    }
+    function addMultiSourceIntentAction () {
+        $log.debug('TODO: addMultiSourceIntentAction');
+
+    }
+    function showDeviceLinkFlowsAction () {
+        $log.debug('TODO: showDeviceLinkFlowsAction');
+
+    }
+
+
+    // === -----------------------------------------------------
+    // === MODULE DEFINITION ===
+
+    angular.module('ovTopo')
+        .factory('TopoSelectService',
+        ['$log', 'FnService', 'TopoPanelService',
+
+            function (_$log_, _fs_, _tps_) {
+                $log = _$log_;
+                fs = _fs_;
+                tps = _tps_;
+
+                function initSelect(_api_) {
+                    api = _api_;
+                }
+
+                function destroySelect() { }
+
+                return {
+                    initSelect: initSelect,
+                    destroySelect: destroySelect,
+
+                    showDetails: showDetails,
+
+                    nodeMouseOver: nodeMouseOver,
+                    nodeMouseOut: nodeMouseOut,
+                    selectObject: selectObject,
+                    deselectObject: deselectObject,
+                    deselectAll: deselectAll,
+                    hovered: function () { return hovered; }
+                };
+            }]);
+}());
diff --git a/web/gui/src/main/webapp/tests/app/fw/layer/panel-spec.js b/web/gui/src/main/webapp/tests/app/fw/layer/panel-spec.js
index c6c63c3..8637258 100644
--- a/web/gui/src/main/webapp/tests/app/fw/layer/panel-spec.js
+++ b/web/gui/src/main/webapp/tests/app/fw/layer/panel-spec.js
@@ -88,7 +88,7 @@
         var p = ps.createPanel('foo');
         expect(fs.areFunctions(p, [
             'show', 'hide', 'toggle', 'empty', 'append',
-            'width', 'height', 'isVisible', 'el'
+            'width', 'height', 'isVisible', 'classed', 'el'
         ])).toBeTruthy();
     });
 
diff --git a/web/gui/src/main/webapp/tests/app/view/topo/topoPanel-spec.js b/web/gui/src/main/webapp/tests/app/view/topo/topoPanel-spec.js
index 6018370..fe8fd68 100644
--- a/web/gui/src/main/webapp/tests/app/view/topo/topoPanel-spec.js
+++ b/web/gui/src/main/webapp/tests/app/view/topo/topoPanel-spec.js
@@ -34,7 +34,16 @@
 
     it('should define api functions', function () {
         expect(fs.areFunctions(tps, [
-            'initPanels', 'destroyPanels', 'showSummary'
+            'initPanels',
+            'destroyPanels',
+            'showSummary',
+            'displaySingle',
+            'displayMulti',
+            'addAction',
+            'showDetailPanel',
+            'hideDetailPanel',
+            'detailVisible',
+            'summaryVisible'
         ])).toBeTruthy();
     });
 
diff --git a/web/gui/src/main/webapp/tests/app/view/topo/topoSelect-spec.js b/web/gui/src/main/webapp/tests/app/view/topo/topoSelect-spec.js
new file mode 100644
index 0000000..f8bc0e7
--- /dev/null
+++ b/web/gui/src/main/webapp/tests/app/view/topo/topoSelect-spec.js
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2015 Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ ONOS GUI -- Topo View -- Topo Selection Service - Unit Tests
+ */
+describe('factory: view/topo/topoSelect.js', function() {
+    var $log, fs, tss;
+
+    beforeEach(module('ovTopo', 'onosUtil', 'onosLayer'));
+
+    beforeEach(inject(function (_$log_, FnService, TopoSelectService) {
+        $log = _$log_;
+        fs = FnService;
+        tss = TopoSelectService;
+    }));
+
+    it('should define TopoSelectService', function () {
+        expect(tss).toBeDefined();
+    });
+
+    it('should define api functions', function () {
+        expect(fs.areFunctions(tss, [
+            'initSelect', 'destroySelect', 'showDetails',
+            'nodeMouseOver', 'nodeMouseOut', 'selectObject', 'deselectObject',
+            'deselectAll', 'hovered'
+        ])).toBeTruthy();
+    });
+
+    // TODO: more tests...
+});