diff --git a/src/event.js b/src/event.js index c32385857e..cc036c0612 100644 --- a/src/event.js +++ b/src/event.js @@ -197,6 +197,33 @@ geo_event.brushend = 'geo_brushend'; ////////////////////////////////////////////////////////////////////////////// geo_event.brushstart = 'geo_brushstart'; +////////////////////////////////////////////////////////////////////////////// +/** + * Triggered after a selection ends. + * The event object extends {@link geo.brushSelection}. + * @mixes geo.brushSelection + */ +////////////////////////////////////////////////////////////////////////////// +geo_event.select = 'geo_select'; + +////////////////////////////////////////////////////////////////////////////// +/** + * Triggered after a zoom selection ends. + * The event object extends {@link geo.brushSelection}. + * @mixes geo.brushSelection + */ +////////////////////////////////////////////////////////////////////////////// +geo_event.zoomselect = 'geo_zoomselect'; + +////////////////////////////////////////////////////////////////////////////// +/** + * Triggered after an unzoom selection ends. + * The event object extends {@link geo.brushSelection}. + * @mixes geo.brushSelection + */ +////////////////////////////////////////////////////////////////////////////// +geo_event.unzoomselect = 'geo_unzoomselect'; + ////////////////////////////////////////////////////////////////////////////// /** * Triggered before a map navigation animation begins. Set diff --git a/src/mapInteractor.js b/src/mapInteractor.js index 727e785056..84bb33a3b2 100644 --- a/src/mapInteractor.js +++ b/src/mapInteractor.js @@ -68,19 +68,23 @@ var mapInteractor = function (args) { zoomMoveButton: 'right', zoomMoveModifiers: {}, rotateMoveButton: 'left', - rotateMoveModifiers: {'ctrl': true}, + rotateMoveModifiers: {ctrl: true}, panWheelEnabled: false, panWheelModifiers: {}, zoomWheelEnabled: true, zoomWheelModifiers: {}, rotateWheelEnabled: true, - rotateWheelModifiers: {'ctrl': true}, + rotateWheelModifiers: {ctrl: true}, wheelScaleX: 1, wheelScaleY: 1, zoomScale: 1, rotateWheelScale: 6 * Math.PI / 180, selectionButton: 'left', - selectionModifiers: {'shift': true}, + selectionModifiers: {shift: true, ctrl: true}, + zoomSelectionButton: 'left', + zoomSelectionModifiers: {shift: true}, + unzoomSelectionButton: 'right', + unzoomSelectionModifiers: {shift: true}, momentum: { enabled: true, maxSpeed: 2.5, @@ -202,7 +206,7 @@ var mapInteractor = function (args) { // // a mousemove. // click: { // enabled: true | false, - // buttons: {'left': true, 'right': true, 'middle': true} + // buttons: {left: true, right: true, middle: true} // duration: 0, // cancelOnMove: true // cancels click if the mouse is moved before release // } @@ -406,7 +410,9 @@ var mapInteractor = function (args) { if (m_options.panMoveButton === 'right' || m_options.zoomMoveButton === 'right' || m_options.rotateMoveButton === 'right' || - m_options.selectionButton === 'right') { + m_options.selectionButton === 'right' || + m_options.zoomSelectionButton === 'right' || + m_options.unzoomSelectionButton === 'right') { $node.on('contextmenu.geojs', function () { return false; }); } return m_this; @@ -644,6 +650,10 @@ var mapInteractor = function (args) { action = 'rotate'; } else if (eventMatch(m_options.selectionButton, m_options.selectionModifiers)) { action = 'select'; + } else if (eventMatch(m_options.zoomSelectionButton, m_options.zoomSelectionModifiers)) { + action = 'zoomselect'; + } else if (eventMatch(m_options.unzoomSelectionButton, m_options.unzoomSelectionModifiers)) { + action = 'unzoomselect'; } // cancel transitions and momentum on click @@ -668,7 +678,7 @@ var mapInteractor = function (args) { delta: {x: 0, y: 0} }; - if (action === 'select') { + if (action === 'select' || action === 'zoomselect' || action === 'unzoomselect') { // Make sure the old selection layer is gone. if (m_selectionLayer) { m_selectionLayer.clear(); @@ -789,7 +799,7 @@ var mapInteractor = function (args) { cx = m_mouse.map.x - m_this.map().size().width / 2; cy = m_mouse.map.y - m_this.map().size().height / 2; m_this.map().rotation(m_state.origin.rotation + Math.atan2(cy, cx)); - } else if (m_state.action === 'select') { + } else if (m_state.action === 'select' || m_state.action === 'zoomselect' || m_state.action === 'unzoomselect') { // Get the bounds of the current selection selectionObj = m_this._getSelection(); m_this.map().geoTrigger(geo_event.brush, selectionObj); @@ -876,6 +886,71 @@ var mapInteractor = function (args) { }; } + //////////////////////////////////////////////////////////////////////////// + /** + * Based on the screen coodinates of a selection, zoom or unzoom and + * recenter. + * + * @private + * @param {string} action Either 'zoomselect' or 'unzoomselect'. + * @param {object} lowerLeft the x and y coordinates of the lower left corner + * of the zoom rectangle. + * @param {object} upperRight the x and y coordinates of the upper right + * corner of the zoom rectangle. + */ + //////////////////////////////////////////////////////////////////////////// + this._zoomFromSelection = function (action, lowerLeft, upperRight) { + if (action !== 'zoomselect' && action !== 'unzoomselect') { + return; + } + if (lowerLeft.x === upperRight.x || lowerLeft.y === upperRight.y) { + return; + } + var zoom, center, + map = m_this.map(), + mapsize = map.size(); + /* To arbitrarily handle rotation and projection, we center the map at the + * central coordinate of the selection and set the zoom level such that the + * four corners are just barely on the map. When unzooming (zooming out), + * we ensure that the previous view is centered in the selection but use + * the maximal size for the zoom factor. */ + var scaling = { + x: Math.abs((upperRight.x - lowerLeft.x) / mapsize.width), + y: Math.abs((upperRight.y - lowerLeft.y) / mapsize.height) + }; + if (action === 'zoomselect') { + center = map.displayToGcs({ + x: (lowerLeft.x + upperRight.x) / 2, + y: (lowerLeft.y + upperRight.y) / 2 + }, null); + zoom = map.zoom() - Math.log2(Math.max(scaling.x, scaling.y)); + } else { /* unzoom */ + /* To make the existing visible map entirely within the selection + * rectangle, this would be changed to Math.min instead of Math.max of + * the scaling factors. This felt wrong, though. */ + zoom = map.zoom() + Math.log2(Math.max(scaling.x, scaling.y)); + /* Record the current center. Later, this is panned to the center of the + * selection rectangle. */ + center = map.center(undefined, null); + } + /* When discrete zoom is enable, always round down. We have to do this + * explicitly, as otherwise we may zoom too far and the selection will not + * be completely visible. */ + if (map.discreteZoom()) { + zoom = Math.floor(zoom); + } + map.zoom(zoom); + if (action === 'zoomselect') { + map.center(center, null); + } else { + var newcenter = map.gcsToDisplay(center, null); + map.pan({ + x: (lowerLeft.x + upperRight.x) / 2 - newcenter.x, + y: (lowerLeft.y + upperRight.y) / 2 - newcenter.y + }); + } + }; + //////////////////////////////////////////////////////////////////////////// /** * Handle event when a mouse button is unpressed on the document. @@ -903,7 +978,8 @@ var mapInteractor = function (args) { evt.preventDefault(); } - if (m_state.action === 'select') { + if (m_state.action === 'select' || m_state.action === 'zoomselect' || m_state.action === 'unzoomselect') { + m_this._getMousePosition(evt); selectionObj = m_this._getSelection(); m_selectionLayer.clear(); @@ -912,6 +988,9 @@ var mapInteractor = function (args) { m_selectionQuad = null; m_this.map().geoTrigger(geo_event.brushend, selectionObj); + m_this.map().geoTrigger(geo_event[m_state.action], selectionObj); + m_this._zoomFromSelection(m_state.action, selectionObj.display.lowerLeft, + selectionObj.display.upperRight); } // reset the interactor state @@ -1410,6 +1489,9 @@ var mapInteractor = function (args) { } } ); + if (type.indexOf('.geojs') >= 0) { + $(document).trigger(evt); + } $node.trigger(evt); }; this._connectEvents(); diff --git a/src/renderer.js b/src/renderer.js index 086c406455..44416bd455 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -62,17 +62,6 @@ var renderer = function (arg) { } }; - //////////////////////////////////////////////////////////////////////////// - /** - * Get base layer that belongs to this renderer - */ - //////////////////////////////////////////////////////////////////////////// - this.baseLayer = function () { - if (m_this.map()) { - return m_this.map().baseLayer(); - } - }; - //////////////////////////////////////////////////////////////////////////// /** * Get/Set if renderer has been initialized diff --git a/tests/cases/mapInteractor.js b/tests/cases/mapInteractor.js index 58fad62a6b..e7ce75cab6 100644 --- a/tests/cases/mapInteractor.js +++ b/tests/cases/mapInteractor.js @@ -36,11 +36,12 @@ describe('mapInteractor', function () { function mockedMap(node) { var map = geo.object(); - var base = geo.object(); + var voidfunc = function () {}; var info = { pan: 0, zoom: 0, rotation: 0, + centerCalls: 0, rotationArgs: {}, panArgs: {}, zoomArgs: {}, @@ -48,19 +49,18 @@ describe('mapInteractor', function () { }; map.node = function () { return $(node); }; - base.displayToGcs = function (val) { + map.displayToGcs = function (val) { return { x: val.x - info.center.x - $(node).width() / 2, y: val.y - info.center.y - $(node).height() / 2 }; }; - base.gcsToDisplay = function (val) { + map.gcsToDisplay = function (val) { return { x: val.x + info.center.x + $(node).width() / 2, y: val.y + info.center.y + $(node).height() / 2 }; }; - map.baseLayer = function () { return base; }; map.zoom = function (arg) { if (arg === undefined) { return 2; @@ -83,8 +83,14 @@ describe('mapInteractor', function () { info.center.x += info.panArgs.x || 0; info.center.y += info.panArgs.y || 0; }; - map.center = function () { - return {x: info.center.x, y: info.center.y}; + map.center = function (arg) { + if (arg === undefined) { + return {x: info.center.x, y: info.center.y}; + } + info.center.x = arg.x; + info.center.y = arg.y; + info.centerCalls += 1; + info.centerArgs = arg; }; map.size = function () { return {width: 100, height: 100}; @@ -106,9 +112,27 @@ describe('mapInteractor', function () { map.maxBounds = function () { return {left: -200, top: 200, right: 200, bottom: -200}; }; - map.displayToGcs = base.displayToGcs; - map.gcsToDisplay = base.gcsToDisplay; map.info = info; + + map.createLayer = function () { + var layer = geo.object(); + layer.createFeature = function () { + var feature = geo.object(); + feature.style = voidfunc; + feature.data = voidfunc; + feature.draw = voidfunc; + return feature; + }; + layer.clear = voidfunc; + return layer; + }; + map.deleteLayer = voidfunc; + map.discreteZoom = function (arg) { + if (arg === undefined) { + return map.info.discreteZoom; + } + map.info.discreteZoom = arg; + }; return map; } @@ -426,7 +450,7 @@ describe('mapInteractor', function () { zoomMoveButton: null, zoomWheelEnabled: false, rotateMoveButton: 'left', - rotateMoveModifiers: {'ctrl': false}, + rotateMoveModifiers: {ctrl: false}, rotateWheelEnabled: false, throttle: false }); @@ -469,6 +493,146 @@ describe('mapInteractor', function () { 0.1 - Math.atan2(20 - 50, 20 - 50) + Math.atan2(25 - 50, 30 - 50)); }); + it('Test zoom selection event propagation', function () { + var map = mockedMap('#mapNode1'); + + var interactor = geo.mapInteractor({ + map: map, + panMoveButton: null, + panWheelEnabled: false, + zoomMoveButton: null, + zoomWheelEnabled: false, + rotateMoveButton: null, + rotateWheelEnabled: false, + zoomSelectionButton: 'left', + zoomSelectionModifiers: {shift: false}, + unzoomSelectionButton: 'middle', + unzoomSelectionModifiers: {shift: false}, + throttle: false + }); + + // initialize the selection + interactor.simulateEvent( + 'mousedown', {map: {x: 20, y: 20}, button: 'left'} + ); + interactor.simulateEvent( + 'mousemove', {map: {x: 30, y: 20}, button: 'left'} + ); + interactor.simulateEvent( + 'mouseup.geojs', {map: {x: 40, y: 50}, button: 'left'} + ); + + // check the selection event was called + expect(map.info.zoom).toBe(1); + expect(map.info.zoomArgs).toBeCloseTo(3.75, 1); + expect(map.info.centerCalls).toBe(1); + expect(map.info.centerArgs.x).toBeCloseTo(-370); + expect(map.info.centerArgs.y).toBeCloseTo(35); + + map.discreteZoom(true); + + // start with an unzoom, but switch to a zoom + interactor.simulateEvent( + 'mousedown', {map: {x: 20, y: 20}, button: 'middle'} + ); + interactor.simulateEvent( + 'mousedown', {map: {x: 20, y: 20}, button: 'left'} + ); + interactor.simulateEvent( + 'mouseup.geojs', {map: {x: 0, y: -30}, button: 'left'} + ); + expect(map.info.zoom).toBe(2); + expect(map.info.zoomArgs).toBe(3); + expect(map.info.centerCalls).toBe(2); + expect(map.info.centerArgs.x).toBeCloseTo(-20); + expect(map.info.centerArgs.y).toBeCloseTo(-40); + + /* If tehre is no movement, nothing should happen */ + interactor.simulateEvent( + 'mousedown', {map: {x: 20, y: 20}, button: 'left'} + ); + interactor.simulateEvent( + 'mouseup.geojs', {map: {x: 20, y: 20}, button: 'left'} + ); + expect(map.info.zoom).toBe(2); + }); + + it('Test unzoom selection event propagation', function () { + var map = mockedMap('#mapNode1'); + + var interactor = geo.mapInteractor({ + map: map, + panMoveButton: null, + panWheelEnabled: false, + zoomMoveButton: null, + zoomWheelEnabled: false, + rotateMoveButton: null, + rotateWheelEnabled: false, + zoomSelectionButton: 'left', + zoomSelectionModifiers: {shift: false}, + unzoomSelectionButton: 'middle', + unzoomSelectionModifiers: {shift: false}, + throttle: false + }); + + // initialize the selection + interactor.simulateEvent( + 'mousedown', {map: {x: 20, y: 20}, button: 'middle'} + ); + interactor.simulateEvent( + 'mousemove', {map: {x: 30, y: 20}, button: 'middle'} + ); + interactor.simulateEvent( + 'mouseup.geojs', {map: {x: 40, y: 50}, button: 'middle'} + ); + + // check the selection event was called + expect(map.info.zoom).toBe(1); + expect(map.info.zoomArgs).toBeCloseTo(0.25, 1); + expect(map.info.centerCalls).toBe(0); + expect(map.info.pan).toBe(1); + expect(map.info.panArgs.x).toBeCloseTo(-370); + expect(map.info.panArgs.y).toBeCloseTo(35); + }); + + it('Test selection event propagation', function () { + var map = mockedMap('#mapNode1'), + triggered = 0; + + var interactor = geo.mapInteractor({ + map: map, + panMoveButton: null, + panWheelEnabled: false, + zoomMoveButton: null, + zoomWheelEnabled: false, + rotateMoveButton: null, + rotateWheelEnabled: false, + selectionButton: 'left', + selectionModifiers: {shift: false, ctrl: false}, + throttle: false + }); + map.geoOn(geo.event.select, function () { + triggered += 1; + }); + + // initialize the selection + interactor.simulateEvent( + 'mousedown', {map: {x: 20, y: 20}, button: 'left'} + ); + interactor.simulateEvent( + 'mousemove', {map: {x: 30, y: 20}, button: 'left'} + ); + interactor.simulateEvent( + 'mouseup.geojs', {map: {x: 40, y: 50}, button: 'left'} + ); + + // check the selection event was called + expect(map.info.zoom).toBe(0); + expect(map.info.centerCalls).toBe(0); + expect(map.info.pan).toBe(0); + expect(triggered).toBe(1); + }); + describe('pause state', function () { it('defaults', function () { expect(geo.mapInteractor().pause()).toBe(false);