GUI -- Implemented ZoomService, with unit tests.
- Added zoomer to topo.js; we are at least generating the events.
- Added GlyphService.clear()

Change-Id: I5400e52b58ee584866d8ffbb20d5bde70b336985
diff --git a/web/gui/src/main/webapp/app/fw/svg/glyph.js b/web/gui/src/main/webapp/app/fw/svg/glyph.js
index 8129c16..621436c 100644
--- a/web/gui/src/main/webapp/app/fw/svg/glyph.js
+++ b/web/gui/src/main/webapp/app/fw/svg/glyph.js
@@ -124,9 +124,13 @@
         .factory('GlyphService', ['$log', function (_$log_) {
             $log = _$log_;
 
-            function init() {
+            function clear() {
                 // start with a fresh map
                 glyphs = d3.map();
+            }
+
+            function init() {
+                clear();
                 register(birdViewBox, birdData);
                 register(glyphViewBox, glyphData);
                 register(badgeViewBox, badgeData);
@@ -175,6 +179,7 @@
             }
 
             return {
+                clear: clear,
                 init: init,
                 register: register,
                 ids: ids,
diff --git a/web/gui/src/main/webapp/app/fw/svg/zoom.js b/web/gui/src/main/webapp/app/fw/svg/zoom.js
index 2dc5236..09c8455 100644
--- a/web/gui/src/main/webapp/app/fw/svg/zoom.js
+++ b/web/gui/src/main/webapp/app/fw/svg/zoom.js
@@ -22,14 +22,112 @@
 (function () {
     'use strict';
 
+    // configuration
+    var defaultSettings = {
+        zoomMin: 0.25,
+        zoomMax: 10,
+        zoomEnabled: function (ev) { return true; },
+        zoomCallback: function () {}
+    };
+
+    // injected references to services
     var $log;
 
     angular.module('onosSvg')
-        .factory('ZoomService', ['$log', function (_$log_) {
+        .factory('ZoomService', ['$log',
+
+        function (_$log_) {
             $log = _$log_;
 
+/*
+            NOTE: opts is an object:
+            {
+                svg: svg,                       D3 selection of <svg> element
+                zoomLayer: zoomLayer,           D3 selection of <g> element
+                zoomEnabled: function (ev) { ... },
+                zoomCallback: function () { ... }
+            }
+
+            where:
+                * svg and zoomLayer should be D3 selections of DOM elements.
+                    * zoomLayer <g> is a child of <svg> element.
+                * zoomEnabled is an optional predicate based on D3 event.
+                    * default is always enabled.
+                * zoomCallback is an optional callback invoked each time we pan/zoom.
+                    * default is do nothing.
+
+            Optionally, zoomMin and zoomMax also can be defined.
+            These default to 0.25 and 10 respectively.
+*/
+            function createZoomer(opts) {
+                var cz = 'ZoomService.createZoomer(): ',
+                    d3s = ' (D3 selection) property defined',
+                    settings = $.extend({}, defaultSettings, opts),
+                    zoom = d3.behavior.zoom()
+                        .translate([0, 0])
+                        .scale(1)
+                        .scaleExtent([settings.zoomMin, settings.zoomMax])
+                        .on('zoom', zoomed),
+                    fail = false,
+                    zoomer;
+
+                if (!settings.svg) {
+                    $log.error(cz + 'No "svg" (svg tag)' + d3s);
+                    fail = true;
+                }
+                if (!settings.zoomLayer) {
+                    $log.error(cz + 'No "zoomLayer" (g tag)' + d3s);
+                    fail = true;
+                }
+
+                if (fail) {
+                    return null;
+                }
+
+                // zoom events from mouse gestures...
+                function zoomed() {
+                    var ev = d3.event.sourceEvent;
+                    if (settings.zoomEnabled(ev)) {
+                        adjustZoomLayer(d3.event.translate, d3.event.scale);
+                    }
+                }
+
+                function adjustZoomLayer(translate, scale) {
+                    settings.zoomLayer.attr('transform',
+                        'translate(' + translate + ')scale(' + scale + ')');
+                    settings.zoomCallback();
+                }
+
+                zoomer = {
+                    panZoom: function (translate, scale) {
+                        zoom.translate(translate).scale(scale);
+                        adjustZoomLayer(translate, scale);
+                    },
+
+                    reset: function () {
+                        zoomer.panZoom([0,0], 1);
+                    },
+
+                    translate: function () {
+                        return zoom.translate();
+                    },
+
+                    scale: function () {
+                        return zoom.scale();
+                    },
+
+                    scaleExtent: function () {
+                        return zoom.scaleExtent();
+                    }
+                };
+
+                // apply the zoom behavior to the SVG element
+                settings.svg && settings.svg.call(zoom);
+                return zoomer;
+            }
+
             return {
-                tbd: function () {}
+                createZoomer: createZoomer
             };
         }]);
 
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 adf7d2a..603a5a1 100644
--- a/web/gui/src/main/webapp/app/view/topo/topo.js
+++ b/web/gui/src/main/webapp/app/view/topo/topo.js
@@ -29,19 +29,22 @@
     ];
 
     // references to injected services etc.
-    var $log, ks, gs;
+    var $log, ks, zs, gs;
 
     // DOM elements
-    var defs;
+    var svg, defs;
 
     // Internal state
-    // ...
+    var zoomer;
 
     // Note: "exported" state should be properties on 'self' variable
 
+    // --- Short Cut Keys ------------------------------------------------
+
     var keyBindings = {
-        W: [logWarning, 'log a warning'],
-        E: [logError, 'log an error']
+        W: [logWarning, '(temp) log a warning'],
+        E: [logError, '(temp) log an error'],
+        R: [resetZoom, 'Reset pan / zoom']
     };
 
     // -----------------
@@ -54,32 +57,72 @@
     }
     // -----------------
 
+    function resetZoom() {
+        zoomer.reset();
+    }
+
     function setUpKeys() {
         ks.keyBindings(keyBindings);
     }
 
+
+    // --- Glyphs, Icons, and the like -----------------------------------
+
     function setUpDefs() {
-        defs = d3.select('#ov-topo svg').append('defs');
+        defs = svg.append('defs');
         gs.loadDefs(defs);
     }
 
 
+    // --- Pan and Zoom --------------------------------------------------
+
+    // zoom enabled predicate. ev is a D3 source event.
+    function zoomEnabled(ev) {
+        return (ev.metaKey || ev.altKey);
+    }
+
+    function zoomCallback() {
+        var tr = zoomer.translate(),
+            sc = zoomer.scale();
+        $log.log('ZOOM: translate = ' + tr + ', scale = ' + sc);
+
+        // TODO: keep the map lines constant width while zooming
+        //bgImg.style('stroke-width', 2.0 / scale + 'px');
+    }
+
+    function setUpZoom() {
+        var zoomLayer = svg.append('g').attr('id', 'topo-zoomlayer');
+        zoomer = zs.createZoomer({
+            svg: svg,
+            zoomLayer: zoomLayer,
+            zoomEnabled: zoomEnabled,
+            zoomCallback: zoomCallback
+        });
+    }
+
+
+    // --- Controller Definition -----------------------------------------
+
     angular.module('ovTopo', moduleDependencies)
 
         .controller('OvTopoCtrl', [
-            '$log', 'KeyService', 'GlyphService',
+            '$log', 'KeyService', 'ZoomService', 'GlyphService',
 
-        function (_$log_, _ks_, _gs_) {
+        function (_$log_, _ks_, _zs_, _gs_) {
             var self = this;
-
             $log = _$log_;
             ks = _ks_;
+            zs = _zs_;
             gs = _gs_;
 
+            // exported state
             self.message = 'Topo View Rocks!';
 
+            // svg layer and initialization of components
+            svg = d3.select('#ov-topo svg');
             setUpKeys();
             setUpDefs();
+            setUpZoom();
 
             $log.log('OvTopoCtrl has been created');
         }]);
diff --git a/web/gui/src/main/webapp/tests/app/fw/svg/glyph-spec.js b/web/gui/src/main/webapp/tests/app/fw/svg/glyph-spec.js
index d743205..8ae2937 100644
--- a/web/gui/src/main/webapp/tests/app/fw/svg/glyph-spec.js
+++ b/web/gui/src/main/webapp/tests/app/fw/svg/glyph-spec.js
@@ -38,6 +38,7 @@
 
     afterEach(function () {
         d3.select('#myDefs').remove();
+        gs.clear();
     });
 
     it('should define GlyphService', function () {
@@ -59,6 +60,13 @@
         expect(gs.ids().length).toEqual(numBaseGlyphs);
     });
 
+    it('should remove glyphs on clear', function () {
+        gs.init();
+        expect(gs.ids().length).toEqual(numBaseGlyphs);
+        gs.clear();
+        expect(gs.ids().length).toEqual(0);
+    });
+
     function verifyGlyphLoaded(id, vbox, prefix) {
         var glyph = gs.glyph(id),
             plen = prefix.length;
diff --git a/web/gui/src/main/webapp/tests/app/fw/svg/zoom-spec.js b/web/gui/src/main/webapp/tests/app/fw/svg/zoom-spec.js
index 0346f19..467d866 100644
--- a/web/gui/src/main/webapp/tests/app/fw/svg/zoom-spec.js
+++ b/web/gui/src/main/webapp/tests/app/fw/svg/zoom-spec.js
@@ -20,17 +20,135 @@
  @author Simon Hunt
  */
 describe('factory: fw/svg/zoom.js', function() {
-    var zs;
+    var $log, fs, zs, svg, zoomLayer, zoomer;
 
-    beforeEach(module('onosSvg'));
+    var cz = 'ZoomService.createZoomer(): ',
+        d3s = ' (D3 selection) property defined';
 
-    beforeEach(inject(function (ZoomService) {
+    beforeEach(module('onosUtil', 'onosSvg'));
+
+    beforeEach(inject(function (_$log_, FnService, ZoomService) {
+        $log = _$log_;
+        fs = FnService;
         zs = ZoomService;
+        svg = d3.select('body').append('svg').attr('id', 'mySvg');
+        zoomLayer = svg.append('g').attr('id', 'myZoomlayer');
     }));
 
+    afterEach(function () {
+        d3.select('#mySvg').remove();
+        // Note: since zoomLayer is a child of svg, it should be removed also
+    });
+
     it('should define ZoomService', function () {
         expect(zs).toBeDefined();
     });
 
-    // TODO: unit tests for map functions
+    it('should define api functions', function () {
+        expect(fs.areFunctions(zs, ['createZoomer'])).toBeTruthy();
+    });
+
+    function verifyZoomerApi() {
+        expect(fs.areFunctions(zoomer, [
+            'panZoom', 'reset', 'translate', 'scale'
+        ])).toBeTruthy();
+    }
+
+    it('should fail gracefully with no option object', function () {
+        spyOn($log, 'error');
+
+        zoomer = zs.createZoomer();
+        expect($log.error).toHaveBeenCalledWith(cz + 'No "svg" (svg tag)' + d3s);
+        expect($log.error).toHaveBeenCalledWith(cz + 'No "zoomLayer" (g tag)' + d3s);
+        expect(zoomer).toBeNull();
+    });
+
+    it('should complain if we miss required options', function () {
+        spyOn($log, 'error');
+
+        zoomer = zs.createZoomer({});
+        expect($log.error).toHaveBeenCalledWith(cz + 'No "svg" (svg tag)' + d3s);
+        expect($log.error).toHaveBeenCalledWith(cz + 'No "zoomLayer" (g tag)' + d3s);
+        expect(zoomer).toBeNull();
+    });
+
+    it('should work with minimal parameters', function () {
+        spyOn($log, 'error');
+
+        zoomer = zs.createZoomer({
+            svg: svg,
+            zoomLayer: zoomLayer
+        });
+        expect($log.error).not.toHaveBeenCalled();
+        verifyZoomerApi();
+    });
+
+    it('should start at scale 1 and translate 0,0', function () {
+        zoomer = zs.createZoomer({
+            svg: svg,
+            zoomLayer: zoomLayer
+        });
+        verifyZoomerApi();
+        expect(zoomer.translate()).toEqual([0,0]);
+        expect(zoomer.scale()).toEqual(1);
+    });
+
+    it('should allow programmatic pan/zoom', function () {
+        zoomer = zs.createZoomer({
+            svg: svg,
+            zoomLayer: zoomLayer
+        });
+        verifyZoomerApi();
+        expect(zoomer.translate()).toEqual([0,0]);
+        expect(zoomer.scale()).toEqual(1);
+
+        zoomer.panZoom([20,30], 3);
+        expect(zoomer.translate()).toEqual([20,30]);
+        expect(zoomer.scale()).toEqual(3);
+    });
+
+    it('should provide default scale extent', function () {
+        zoomer = zs.createZoomer({
+            svg: svg,
+            zoomLayer: zoomLayer
+        });
+        expect(zoomer.scaleExtent()).toEqual([0.25, 10]);
+    });
+
+    it('should allow us to override the minimum zoom', function () {
+        zoomer = zs.createZoomer({
+            svg: svg,
+            zoomLayer: zoomLayer,
+            zoomMin: 1.23
+        });
+        expect(zoomer.scaleExtent()).toEqual([1.23, 10]);
+    });
+
+    it('should allow us to override the maximum zoom', function () {
+        zoomer = zs.createZoomer({
+            svg: svg,
+            zoomLayer: zoomLayer,
+            zoomMax: 13
+        });
+        expect(zoomer.scaleExtent()).toEqual([0.25, 13]);
+    });
+
+    // TODO: test zoomed() where we fake out the d3.event.sourceEvent etc...
+    //  need to check default enabled (true) and custom enabled predicate
+    //  need to check that the callback is invoked also
+
+    it('should invoke the callback on programmatic pan/zoom', function () {
+        var foo = { cb: function () {} };
+        spyOn(foo, 'cb');
+
+        zoomer = zs.createZoomer({
+            svg: svg,
+            zoomLayer: zoomLayer,
+            zoomCallback: foo.cb
+        });
+
+        zoomer.panZoom([0,0], 2);
+        expect(foo.cb).toHaveBeenCalled();
+    });
+
 });