GUI -- Implemented ZoomService, with unit tests.
- Added zoomer to topo.js; we are at least generating the events. - Added GlyphService.clear() Change-Id: I5400e52b58ee584866d8ffbb20d5bde70b336985
Showing
5 changed files
with
288 additions
and
16 deletions
... | @@ -124,9 +124,13 @@ | ... | @@ -124,9 +124,13 @@ |
124 | .factory('GlyphService', ['$log', function (_$log_) { | 124 | .factory('GlyphService', ['$log', function (_$log_) { |
125 | $log = _$log_; | 125 | $log = _$log_; |
126 | 126 | ||
127 | - function init() { | 127 | + function clear() { |
128 | // start with a fresh map | 128 | // start with a fresh map |
129 | glyphs = d3.map(); | 129 | glyphs = d3.map(); |
130 | + } | ||
131 | + | ||
132 | + function init() { | ||
133 | + clear(); | ||
130 | register(birdViewBox, birdData); | 134 | register(birdViewBox, birdData); |
131 | register(glyphViewBox, glyphData); | 135 | register(glyphViewBox, glyphData); |
132 | register(badgeViewBox, badgeData); | 136 | register(badgeViewBox, badgeData); |
... | @@ -175,6 +179,7 @@ | ... | @@ -175,6 +179,7 @@ |
175 | } | 179 | } |
176 | 180 | ||
177 | return { | 181 | return { |
182 | + clear: clear, | ||
178 | init: init, | 183 | init: init, |
179 | register: register, | 184 | register: register, |
180 | ids: ids, | 185 | ids: ids, | ... | ... |
... | @@ -22,14 +22,112 @@ | ... | @@ -22,14 +22,112 @@ |
22 | (function () { | 22 | (function () { |
23 | 'use strict'; | 23 | 'use strict'; |
24 | 24 | ||
25 | + // configuration | ||
26 | + var defaultSettings = { | ||
27 | + zoomMin: 0.25, | ||
28 | + zoomMax: 10, | ||
29 | + zoomEnabled: function (ev) { return true; }, | ||
30 | + zoomCallback: function () {} | ||
31 | + }; | ||
32 | + | ||
33 | + // injected references to services | ||
25 | var $log; | 34 | var $log; |
26 | 35 | ||
27 | angular.module('onosSvg') | 36 | angular.module('onosSvg') |
28 | - .factory('ZoomService', ['$log', function (_$log_) { | 37 | + .factory('ZoomService', ['$log', |
38 | + | ||
39 | + function (_$log_) { | ||
29 | $log = _$log_; | 40 | $log = _$log_; |
30 | 41 | ||
42 | +/* | ||
43 | + NOTE: opts is an object: | ||
44 | + { | ||
45 | + svg: svg, D3 selection of <svg> element | ||
46 | + zoomLayer: zoomLayer, D3 selection of <g> element | ||
47 | + zoomEnabled: function (ev) { ... }, | ||
48 | + zoomCallback: function () { ... } | ||
49 | + } | ||
50 | + | ||
51 | + where: | ||
52 | + * svg and zoomLayer should be D3 selections of DOM elements. | ||
53 | + * zoomLayer <g> is a child of <svg> element. | ||
54 | + * zoomEnabled is an optional predicate based on D3 event. | ||
55 | + * default is always enabled. | ||
56 | + * zoomCallback is an optional callback invoked each time we pan/zoom. | ||
57 | + * default is do nothing. | ||
58 | + | ||
59 | + Optionally, zoomMin and zoomMax also can be defined. | ||
60 | + These default to 0.25 and 10 respectively. | ||
61 | +*/ | ||
62 | + function createZoomer(opts) { | ||
63 | + var cz = 'ZoomService.createZoomer(): ', | ||
64 | + d3s = ' (D3 selection) property defined', | ||
65 | + settings = $.extend({}, defaultSettings, opts), | ||
66 | + zoom = d3.behavior.zoom() | ||
67 | + .translate([0, 0]) | ||
68 | + .scale(1) | ||
69 | + .scaleExtent([settings.zoomMin, settings.zoomMax]) | ||
70 | + .on('zoom', zoomed), | ||
71 | + fail = false, | ||
72 | + zoomer; | ||
73 | + | ||
74 | + if (!settings.svg) { | ||
75 | + $log.error(cz + 'No "svg" (svg tag)' + d3s); | ||
76 | + fail = true; | ||
77 | + } | ||
78 | + if (!settings.zoomLayer) { | ||
79 | + $log.error(cz + 'No "zoomLayer" (g tag)' + d3s); | ||
80 | + fail = true; | ||
81 | + } | ||
82 | + | ||
83 | + if (fail) { | ||
84 | + return null; | ||
85 | + } | ||
86 | + | ||
87 | + // zoom events from mouse gestures... | ||
88 | + function zoomed() { | ||
89 | + var ev = d3.event.sourceEvent; | ||
90 | + if (settings.zoomEnabled(ev)) { | ||
91 | + adjustZoomLayer(d3.event.translate, d3.event.scale); | ||
92 | + } | ||
93 | + } | ||
94 | + | ||
95 | + function adjustZoomLayer(translate, scale) { | ||
96 | + settings.zoomLayer.attr('transform', | ||
97 | + 'translate(' + translate + ')scale(' + scale + ')'); | ||
98 | + settings.zoomCallback(); | ||
99 | + } | ||
100 | + | ||
101 | + zoomer = { | ||
102 | + panZoom: function (translate, scale) { | ||
103 | + zoom.translate(translate).scale(scale); | ||
104 | + adjustZoomLayer(translate, scale); | ||
105 | + }, | ||
106 | + | ||
107 | + reset: function () { | ||
108 | + zoomer.panZoom([0,0], 1); | ||
109 | + }, | ||
110 | + | ||
111 | + translate: function () { | ||
112 | + return zoom.translate(); | ||
113 | + }, | ||
114 | + | ||
115 | + scale: function () { | ||
116 | + return zoom.scale(); | ||
117 | + }, | ||
118 | + | ||
119 | + scaleExtent: function () { | ||
120 | + return zoom.scaleExtent(); | ||
121 | + } | ||
122 | + }; | ||
123 | + | ||
124 | + // apply the zoom behavior to the SVG element | ||
125 | + settings.svg && settings.svg.call(zoom); | ||
126 | + return zoomer; | ||
127 | + } | ||
128 | + | ||
31 | return { | 129 | return { |
32 | - tbd: function () {} | 130 | + createZoomer: createZoomer |
33 | }; | 131 | }; |
34 | }]); | 132 | }]); |
35 | 133 | ... | ... |
... | @@ -29,19 +29,22 @@ | ... | @@ -29,19 +29,22 @@ |
29 | ]; | 29 | ]; |
30 | 30 | ||
31 | // references to injected services etc. | 31 | // references to injected services etc. |
32 | - var $log, ks, gs; | 32 | + var $log, ks, zs, gs; |
33 | 33 | ||
34 | // DOM elements | 34 | // DOM elements |
35 | - var defs; | 35 | + var svg, defs; |
36 | 36 | ||
37 | // Internal state | 37 | // Internal state |
38 | - // ... | 38 | + var zoomer; |
39 | 39 | ||
40 | // Note: "exported" state should be properties on 'self' variable | 40 | // Note: "exported" state should be properties on 'self' variable |
41 | 41 | ||
42 | + // --- Short Cut Keys ------------------------------------------------ | ||
43 | + | ||
42 | var keyBindings = { | 44 | var keyBindings = { |
43 | - W: [logWarning, 'log a warning'], | 45 | + W: [logWarning, '(temp) log a warning'], |
44 | - E: [logError, 'log an error'] | 46 | + E: [logError, '(temp) log an error'], |
47 | + R: [resetZoom, 'Reset pan / zoom'] | ||
45 | }; | 48 | }; |
46 | 49 | ||
47 | // ----------------- | 50 | // ----------------- |
... | @@ -54,32 +57,72 @@ | ... | @@ -54,32 +57,72 @@ |
54 | } | 57 | } |
55 | // ----------------- | 58 | // ----------------- |
56 | 59 | ||
60 | + function resetZoom() { | ||
61 | + zoomer.reset(); | ||
62 | + } | ||
63 | + | ||
57 | function setUpKeys() { | 64 | function setUpKeys() { |
58 | ks.keyBindings(keyBindings); | 65 | ks.keyBindings(keyBindings); |
59 | } | 66 | } |
60 | 67 | ||
68 | + | ||
69 | + // --- Glyphs, Icons, and the like ----------------------------------- | ||
70 | + | ||
61 | function setUpDefs() { | 71 | function setUpDefs() { |
62 | - defs = d3.select('#ov-topo svg').append('defs'); | 72 | + defs = svg.append('defs'); |
63 | gs.loadDefs(defs); | 73 | gs.loadDefs(defs); |
64 | } | 74 | } |
65 | 75 | ||
66 | 76 | ||
77 | + // --- Pan and Zoom -------------------------------------------------- | ||
78 | + | ||
79 | + // zoom enabled predicate. ev is a D3 source event. | ||
80 | + function zoomEnabled(ev) { | ||
81 | + return (ev.metaKey || ev.altKey); | ||
82 | + } | ||
83 | + | ||
84 | + function zoomCallback() { | ||
85 | + var tr = zoomer.translate(), | ||
86 | + sc = zoomer.scale(); | ||
87 | + $log.log('ZOOM: translate = ' + tr + ', scale = ' + sc); | ||
88 | + | ||
89 | + // TODO: keep the map lines constant width while zooming | ||
90 | + //bgImg.style('stroke-width', 2.0 / scale + 'px'); | ||
91 | + } | ||
92 | + | ||
93 | + function setUpZoom() { | ||
94 | + var zoomLayer = svg.append('g').attr('id', 'topo-zoomlayer'); | ||
95 | + zoomer = zs.createZoomer({ | ||
96 | + svg: svg, | ||
97 | + zoomLayer: zoomLayer, | ||
98 | + zoomEnabled: zoomEnabled, | ||
99 | + zoomCallback: zoomCallback | ||
100 | + }); | ||
101 | + } | ||
102 | + | ||
103 | + | ||
104 | + // --- Controller Definition ----------------------------------------- | ||
105 | + | ||
67 | angular.module('ovTopo', moduleDependencies) | 106 | angular.module('ovTopo', moduleDependencies) |
68 | 107 | ||
69 | .controller('OvTopoCtrl', [ | 108 | .controller('OvTopoCtrl', [ |
70 | - '$log', 'KeyService', 'GlyphService', | 109 | + '$log', 'KeyService', 'ZoomService', 'GlyphService', |
71 | 110 | ||
72 | - function (_$log_, _ks_, _gs_) { | 111 | + function (_$log_, _ks_, _zs_, _gs_) { |
73 | var self = this; | 112 | var self = this; |
74 | - | ||
75 | $log = _$log_; | 113 | $log = _$log_; |
76 | ks = _ks_; | 114 | ks = _ks_; |
115 | + zs = _zs_; | ||
77 | gs = _gs_; | 116 | gs = _gs_; |
78 | 117 | ||
118 | + // exported state | ||
79 | self.message = 'Topo View Rocks!'; | 119 | self.message = 'Topo View Rocks!'; |
80 | 120 | ||
121 | + // svg layer and initialization of components | ||
122 | + svg = d3.select('#ov-topo svg'); | ||
81 | setUpKeys(); | 123 | setUpKeys(); |
82 | setUpDefs(); | 124 | setUpDefs(); |
125 | + setUpZoom(); | ||
83 | 126 | ||
84 | $log.log('OvTopoCtrl has been created'); | 127 | $log.log('OvTopoCtrl has been created'); |
85 | }]); | 128 | }]); | ... | ... |
... | @@ -38,6 +38,7 @@ describe('factory: fw/svg/glyph.js', function() { | ... | @@ -38,6 +38,7 @@ describe('factory: fw/svg/glyph.js', function() { |
38 | 38 | ||
39 | afterEach(function () { | 39 | afterEach(function () { |
40 | d3.select('#myDefs').remove(); | 40 | d3.select('#myDefs').remove(); |
41 | + gs.clear(); | ||
41 | }); | 42 | }); |
42 | 43 | ||
43 | it('should define GlyphService', function () { | 44 | it('should define GlyphService', function () { |
... | @@ -59,6 +60,13 @@ describe('factory: fw/svg/glyph.js', function() { | ... | @@ -59,6 +60,13 @@ describe('factory: fw/svg/glyph.js', function() { |
59 | expect(gs.ids().length).toEqual(numBaseGlyphs); | 60 | expect(gs.ids().length).toEqual(numBaseGlyphs); |
60 | }); | 61 | }); |
61 | 62 | ||
63 | + it('should remove glyphs on clear', function () { | ||
64 | + gs.init(); | ||
65 | + expect(gs.ids().length).toEqual(numBaseGlyphs); | ||
66 | + gs.clear(); | ||
67 | + expect(gs.ids().length).toEqual(0); | ||
68 | + }); | ||
69 | + | ||
62 | function verifyGlyphLoaded(id, vbox, prefix) { | 70 | function verifyGlyphLoaded(id, vbox, prefix) { |
63 | var glyph = gs.glyph(id), | 71 | var glyph = gs.glyph(id), |
64 | plen = prefix.length; | 72 | plen = prefix.length; | ... | ... |
... | @@ -20,17 +20,135 @@ | ... | @@ -20,17 +20,135 @@ |
20 | @author Simon Hunt | 20 | @author Simon Hunt |
21 | */ | 21 | */ |
22 | describe('factory: fw/svg/zoom.js', function() { | 22 | describe('factory: fw/svg/zoom.js', function() { |
23 | - var zs; | 23 | + var $log, fs, zs, svg, zoomLayer, zoomer; |
24 | 24 | ||
25 | - beforeEach(module('onosSvg')); | 25 | + var cz = 'ZoomService.createZoomer(): ', |
26 | + d3s = ' (D3 selection) property defined'; | ||
26 | 27 | ||
27 | - beforeEach(inject(function (ZoomService) { | 28 | + beforeEach(module('onosUtil', 'onosSvg')); |
29 | + | ||
30 | + beforeEach(inject(function (_$log_, FnService, ZoomService) { | ||
31 | + $log = _$log_; | ||
32 | + fs = FnService; | ||
28 | zs = ZoomService; | 33 | zs = ZoomService; |
34 | + svg = d3.select('body').append('svg').attr('id', 'mySvg'); | ||
35 | + zoomLayer = svg.append('g').attr('id', 'myZoomlayer'); | ||
29 | })); | 36 | })); |
30 | 37 | ||
38 | + afterEach(function () { | ||
39 | + d3.select('#mySvg').remove(); | ||
40 | + // Note: since zoomLayer is a child of svg, it should be removed also | ||
41 | + }); | ||
42 | + | ||
31 | it('should define ZoomService', function () { | 43 | it('should define ZoomService', function () { |
32 | expect(zs).toBeDefined(); | 44 | expect(zs).toBeDefined(); |
33 | }); | 45 | }); |
34 | 46 | ||
35 | - // TODO: unit tests for map functions | 47 | + it('should define api functions', function () { |
48 | + expect(fs.areFunctions(zs, ['createZoomer'])).toBeTruthy(); | ||
49 | + }); | ||
50 | + | ||
51 | + function verifyZoomerApi() { | ||
52 | + expect(fs.areFunctions(zoomer, [ | ||
53 | + 'panZoom', 'reset', 'translate', 'scale' | ||
54 | + ])).toBeTruthy(); | ||
55 | + } | ||
56 | + | ||
57 | + it('should fail gracefully with no option object', function () { | ||
58 | + spyOn($log, 'error'); | ||
59 | + | ||
60 | + zoomer = zs.createZoomer(); | ||
61 | + expect($log.error).toHaveBeenCalledWith(cz + 'No "svg" (svg tag)' + d3s); | ||
62 | + expect($log.error).toHaveBeenCalledWith(cz + 'No "zoomLayer" (g tag)' + d3s); | ||
63 | + expect(zoomer).toBeNull(); | ||
64 | + }); | ||
65 | + | ||
66 | + it('should complain if we miss required options', function () { | ||
67 | + spyOn($log, 'error'); | ||
68 | + | ||
69 | + zoomer = zs.createZoomer({}); | ||
70 | + expect($log.error).toHaveBeenCalledWith(cz + 'No "svg" (svg tag)' + d3s); | ||
71 | + expect($log.error).toHaveBeenCalledWith(cz + 'No "zoomLayer" (g tag)' + d3s); | ||
72 | + expect(zoomer).toBeNull(); | ||
73 | + }); | ||
74 | + | ||
75 | + it('should work with minimal parameters', function () { | ||
76 | + spyOn($log, 'error'); | ||
77 | + | ||
78 | + zoomer = zs.createZoomer({ | ||
79 | + svg: svg, | ||
80 | + zoomLayer: zoomLayer | ||
81 | + }); | ||
82 | + expect($log.error).not.toHaveBeenCalled(); | ||
83 | + verifyZoomerApi(); | ||
84 | + }); | ||
85 | + | ||
86 | + it('should start at scale 1 and translate 0,0', function () { | ||
87 | + zoomer = zs.createZoomer({ | ||
88 | + svg: svg, | ||
89 | + zoomLayer: zoomLayer | ||
90 | + }); | ||
91 | + verifyZoomerApi(); | ||
92 | + expect(zoomer.translate()).toEqual([0,0]); | ||
93 | + expect(zoomer.scale()).toEqual(1); | ||
94 | + }); | ||
95 | + | ||
96 | + it('should allow programmatic pan/zoom', function () { | ||
97 | + zoomer = zs.createZoomer({ | ||
98 | + svg: svg, | ||
99 | + zoomLayer: zoomLayer | ||
100 | + }); | ||
101 | + verifyZoomerApi(); | ||
102 | + expect(zoomer.translate()).toEqual([0,0]); | ||
103 | + expect(zoomer.scale()).toEqual(1); | ||
104 | + | ||
105 | + zoomer.panZoom([20,30], 3); | ||
106 | + expect(zoomer.translate()).toEqual([20,30]); | ||
107 | + expect(zoomer.scale()).toEqual(3); | ||
108 | + }); | ||
109 | + | ||
110 | + it('should provide default scale extent', function () { | ||
111 | + zoomer = zs.createZoomer({ | ||
112 | + svg: svg, | ||
113 | + zoomLayer: zoomLayer | ||
114 | + }); | ||
115 | + expect(zoomer.scaleExtent()).toEqual([0.25, 10]); | ||
116 | + }); | ||
117 | + | ||
118 | + it('should allow us to override the minimum zoom', function () { | ||
119 | + zoomer = zs.createZoomer({ | ||
120 | + svg: svg, | ||
121 | + zoomLayer: zoomLayer, | ||
122 | + zoomMin: 1.23 | ||
123 | + }); | ||
124 | + expect(zoomer.scaleExtent()).toEqual([1.23, 10]); | ||
125 | + }); | ||
126 | + | ||
127 | + it('should allow us to override the maximum zoom', function () { | ||
128 | + zoomer = zs.createZoomer({ | ||
129 | + svg: svg, | ||
130 | + zoomLayer: zoomLayer, | ||
131 | + zoomMax: 13 | ||
132 | + }); | ||
133 | + expect(zoomer.scaleExtent()).toEqual([0.25, 13]); | ||
134 | + }); | ||
135 | + | ||
136 | + // TODO: test zoomed() where we fake out the d3.event.sourceEvent etc... | ||
137 | + // need to check default enabled (true) and custom enabled predicate | ||
138 | + // need to check that the callback is invoked also | ||
139 | + | ||
140 | + it('should invoke the callback on programmatic pan/zoom', function () { | ||
141 | + var foo = { cb: function () {} }; | ||
142 | + spyOn(foo, 'cb'); | ||
143 | + | ||
144 | + zoomer = zs.createZoomer({ | ||
145 | + svg: svg, | ||
146 | + zoomLayer: zoomLayer, | ||
147 | + zoomCallback: foo.cb | ||
148 | + }); | ||
149 | + | ||
150 | + zoomer.panZoom([0,0], 2); | ||
151 | + expect(foo.cb).toHaveBeenCalled(); | ||
152 | + }); | ||
153 | + | ||
36 | }); | 154 | }); | ... | ... |
-
Please register or login to post a comment