Add keyboard support for map interaction.
Add some css to show when the map gets focus.  This is only added if the map has the appropriate class, which is controlled by a flag in the keyboard settings for the interactor.
MODIFY
src/event.js
src/event.js
@@ -281,6 +281,22 @@ geo_event.actionselection = 'geo_actionselection';
//////////////////////////////////////////////////////////////////////////////
geo_event.actionwheel = 'geo_actionwheel';
+//////////////////////////////////////////////////////////////////////////////
+/**
+ * Triggered when an action is triggered via the keyboard.
+ *
+ * @property {object} move The movement that would happen if the action is
+ * passed through, possibly containing zoomDelta, zoom (absolute),
+ * rotationDelta (in radians), rotation (absolute in radians), panX (in
+ * display pixels), panY (in display pixels). Set move.cancel to cancel
+ * the entire movement.
+ * @property {string} action Action based on key
+ * @property {number} factor Factor based on metakeys [0-2].
+ * @property {object} event The triggering event
+ */
+//////////////////////////////////////////////////////////////////////////////
+geo_event.keyaction = 'geo_keyaction';
+
//////////////////////////////////////////////////////////////////////////////
/**
* Triggered before a map navigation animation begins. Set

MODIFY
src/main.styl
src/main.styl
&.annotation-input
cursor crosshair
+ &.highlight-focus
+ &:after
+ content ""
+ display block
+ position absolute
+ box-sizing border-box
+ left 0px
+ top 0px
+ right 0px
+ bottom 0px
+ // Highlight is technically a css2 color. We may need to specify it
+ // explicitly. #3B66A6 seems close
+ border 3px solid Highlight
+ opacity 1
+ transition opacity 0s
+ visibility hidden
+ &:focus:after
+ visibility visible
+ transition opacity 2.5s ease-in
+ opacity 0
+
.geo-tile-container
position absolute
&.crop

MODIFY
src/map.js
src/map.js
@@ -974,6 +974,9 @@ var map = function (arg) {
if (arg === undefined) {
return m_interactor;
}
+ if (m_interactor && m_interactor !== arg) {
+ m_interactor.destroy();
+ }
m_interactor = arg;
// this makes it possible to set a null interactor

MODIFY
src/mapInteractor.js
src/mapInteractor.js
var inherit = require('./inherit');
var object = require('./object');
var util = require('./util');
+var Mousetrap = require('mousetrap');
//////////////////////////////////////////////////////////////////////////////
/**
@@ -29,10 +30,11 @@ var mapInteractor = function (args) {
var actionMatch = require('./util').actionMatch;
var quadFeature = require('./quadFeature');
- var m_options = args || {},
+ var m_options,
m_this = this,
m_mouse,
- m_keyboard,
+ m_keyHandler,
+ m_boundKeys,
m_state,
m_queue,
$node,
@@ -147,6 +149,50 @@ var mapInteractor = function (args) {
cancelOnMove: true
},
+ keyboard: {
+ actions: {
+ /* Specific actions can be disabled by removing them from this object
+ * or stting an empty list as the key bindings. Additional actions
+ * can be added to the dictionary, each of which gets a list of key
+ * bindings. See Mousetrap documentation for special key names. */
+ 'zoom.in': ['plus', 'shift+plus', 'shift+ctrl+plus', '=', 'shift+=', 'shift+ctrl+='],
+ 'zoom.out': ['-', 'shift+-', 'shift+ctrl+-', '_', 'shift+_', 'shift+ctrl+_'],
+ 'zoom.0': ['1'],
+ 'zoom.3': ['2'],
+ 'zoom.6': ['3'],
+ 'zoom.9': ['4'],
+ 'zoom.12': ['5'],
+ 'zoom.15': ['6'],
+ 'zoom.18': ['7'],
+ 'pan.left': ['left', 'shift+left', 'shift+ctrl+left'],
+ 'pan.right': ['right', 'shift+right', 'shift+ctrl+right'],
+ 'pan.up': ['up', 'shift+up', 'shift+ctrl+up'],
+ 'pan.down': ['down', 'shift+down', 'shift+ctrl+down'],
+ 'rotate.ccw': ['<', 'shift+<', 'shift+ctrl+<', '.', 'shift+.', 'shift+ctrl+.'],
+ 'rotate.cw': ['>', 'shift+>', 'shift+ctrl+>', ',', 'shift+,', 'shift+ctrl+,'],
+ 'rotate.0': ['0']
+ },
+ meta: {
+ /* the metakeys that are down during a key event determine the
+ * magnitude of the action, where 0 is the default small action
+ * (1-pixel pan, small zoom, small rotation), 1 is a middle-sized
+ * action, and 2 is the largest action. Metakeys that aren't listed
+ * are ignored. Metakeys include shift, ctrl, alt, and meta (alt is
+ * either the alt or option key, and meta is either windows or
+ * command). */
+ 0: {shift: false, ctrl: false},
+ 1: {shift: true, ctrl: true},
+ 2: {shift: true, ctrl: false}
+ },
+ /* if focusHighlight is truthy, then a class is added to the map such
+ * that when the map gets focus, it is indicated inside the border of
+ * the map -- browsers usually show focus on the outside, which isn't
+ * useful if the map is full window. It might be desireable to change
+ * this so it is only present if the focus is reached via the keyboard
+ * (which would propably require detecting keyup events). */
+ focusHighlight: true
+ },
+
wheelScaleX: 1,
wheelScaleY: 1,
zoomScale: 1,
@@ -169,10 +215,10 @@ var mapInteractor = function (args) {
ease: function (t) { return (2 - t) * t; }
}
},
- m_options
+ args || {}
);
- /* We don't want to merge the original arrays array with a array passed in
- * the args, so override that as necessary for actions. */
+ /* We don't want to merge the original arrays with arrays passed in the args,
+ * so override that as necessary for actions. */
if (args && args.actions) {
m_options.actions = $.extend(true, [], args.actions);
}
@@ -357,11 +403,6 @@ var mapInteractor = function (args) {
}
};
- // default keyboard object
- // (keyboard events not implemented yet)
- m_keyboard = {
- };
-
// The interactor state determines what actions are taken in response to
// core browser events.
//
@@ -409,6 +450,120 @@ var mapInteractor = function (args) {
*/
m_queue = {};
+ /**
+ * Process keys that we've captured. Metakeys determine the magnitude of
+ * the action.
+ *
+ * @param {string} action: the basic action to take.
+ * @param {object} evt: the event with metakeys.
+ * @param {object} keys: keys used to trigger the event. keys.simualted is
+ * true if artificially triggered.
+ */
+ this._handleKeys = function (action, evt, keys) {
+ if (keys && keys.simulated === true) {
+ evt = keys;
+ }
+ var meta = m_options.keyboard.meta || {0: {}},
+ map = m_this.map(),
+ mapSize = map.size(),
+ actionBase = action, actionValue = '',
+ value, factor, move = {};
+
+ for (value in meta) {
+ if (meta.hasOwnProperty(value)) {
+ if ((meta[value].shift === undefined || evt.shiftKey === !!meta[value].shift) &&
+ (meta[value].ctrl === undefined || evt.ctrlKey === !!meta[value].ctrl) &&
+ (meta[value].alt === undefined || evt.altKey === !!meta[value].alt) &&
+ (meta[value].meta === undefined || evt.metaKey === !!meta[value].meta)) {
+ factor = value;
+ }
+ }
+ }
+ console.log(JSON.stringify([meta, evt, factor])); //DWM::
+ if (factor === undefined) {
+ /* metakeys don't match, so don't trigger an event. */
+ return;
+ }
+
+ evt.stopPropagation();
+ evt.preventDefault();
+
+ if (action.indexOf('.') >= 0) {
+ actionBase = action.substr(0, action.indexOf('.'));
+ actionValue = action.substr(action.indexOf('.') + 1);
+ }
+ switch (actionBase) {
+ case 'zoom':
+ switch (actionValue) {
+ case 'in':
+ move.zoomDelta = [0.05, 0.25, 1][factor];
+ break;
+ case 'out':
+ move.zoomDelta = -[0.05, 0.25, 1][factor];
+ break;
+ default:
+ if (!isNaN(parseFloat(actionValue))) {
+ move.zoom = parseFloat(actionValue);
+ }
+ break;
+ }
+ break;
+ case 'pan':
+ switch (actionValue) {
+ case 'down':
+ move.panY = -Math.max(1, [0, 0.05, 0.5][factor] * mapSize.height);
+ break;
+ case 'left':
+ move.panX = Math.max(1, [0, 0.05, 0.5][factor] * mapSize.width);
+ break;
+ case 'right':
+ move.panX = -Math.max(1, [0, 0.05, 0.5][factor] * mapSize.width);
+ break;
+ case 'up':
+ move.panY = Math.max(1, [0, 0.05, 0.5][factor] * mapSize.height);
+ break;
+ }
+ break;
+ case 'rotate':
+ switch (actionValue) {
+ case 'ccw':
+ move.rotationDelta = [1, 5, 90][factor] * Math.PI / 180;
+ break;
+ case 'cw':
+ move.rotationDelta = -[1, 5, 90][factor] * Math.PI / 180;
+ break;
+ default:
+ if (!isNaN(parseFloat(actionValue))) {
+ move.rotation = parseFloat(actionValue);
+ }
+ break;
+ }
+ break;
+ }
+ map.geoTrigger(geo_event.keyaction, {
+ move: move,
+ action: action,
+ factor: factor,
+ event: evt
+ });
+ if (move.cancel) {
+ return;
+ }
+ if (move.zoom !== undefined) {
+ map.zoom(move.zoom);
+ } else if (move.zoomDelta) {
+ map.zoom(map.zoom() + move.zoomDelta);
+ }
+ if (move.rotation !== undefined) {
+ map.rotation(move.rotation);
+ } else if (move.rotationDelta) {
+ map.rotation(map.rotation() + move.rotationDelta);
+ }
+ if (move.panX || move.panY) {
+ map.pan({x: move.panX || 0, y: move.panY || 0});
+ }
+ };
+
////////////////////////////////////////////////////////////////////////////
/**
* Connects events to a map. If the map is not set, then this does nothing.
@@ -443,6 +598,28 @@ var mapInteractor = function (args) {
})) {
$node.on('contextmenu.geojs', function () { return false; });
}
+
+ if (m_options.keyboard && m_options.keyboard.actions) {
+ m_keyHandler = Mousetrap($node[0]);
+ var bound = [];
+ for (var keyAction in m_options.keyboard.actions) {
+ if (m_options.keyboard.actions.hasOwnProperty(keyAction)) {
+ m_keyHandler.bind(
+ m_options.keyboard.actions[keyAction],
+ (function (action) {
+ return function (evt, keys) {
+ m_this._handleKeys(action, evt, keys);
+ };
+ })(keyAction)
+ );
+ bound = bound.concat(m_options.keyboard.actions[keyAction]);
+ }
+ }
+ m_boundKeys = bound;
+ }
+ $node.toggleClass('highlight-focus',
+ m_boundKeys && m_boundKeys.length && m_options.keyboard.focusHighlight);
+
return m_this;
};
@@ -453,12 +630,20 @@ var mapInteractor = function (args) {
*/
////////////////////////////////////////////////////////////////////////////
this._disconnectEvents = function () {
+ if (m_boundKeys) {
+ if (m_keyHandler) {
+ m_boundKeys.every(m_keyHandler.unbind, m_keyHandler);
+ }
+ m_boundKeys = null;
+ m_keyHandler = null;
+ }
if ($node) {
$node.off('.geojs');
$node = null;
}
m_this._handleMouseWheel = function () {};
m_callZoom = function () {};
+
return m_this;
};
@@ -1454,6 +1639,9 @@ var mapInteractor = function (args) {
////////////////////////////////////////////////////////////////////////////
this.destroy = function () {
m_this._disconnectEvents();
+ if (m_this.map()) {
+ $(m_this.map().node()).removeClass('highlight-focus');
+ }
m_this.map(null);
};
@@ -1468,11 +1656,14 @@ var mapInteractor = function (args) {
////////////////////////////////////////////////////////////////////////////
/**
- * Get current keyboard information
+ * Get/set current keyboard information
*/
////////////////////////////////////////////////////////////////////////////
- this.keyboard = function () {
- return $.extend(true, {}, m_keyboard);
+ this.keyboard = function (newValue) {
+ if (newValue === undefined) {
+ return $.extend(true, {}, m_options.keyboard || {});
+ }
+ return m_this.options({keyboard: newValue});
};
////////////////////////////////////////////////////////////////////////////
@@ -1589,6 +1780,24 @@ var mapInteractor = function (args) {
return m_this;
}
+ if (type === 'keyboard' && m_keyHandler) {
+ /* Mousetrap passes through the keys we send, but not an event object,
+ * so we construct an artifical event object as the keys, and use that.
+ */
+ var keys = {
+ shiftKey: options.shift || options.shiftKey || false,
+ ctrlKey: options.ctrl || options.ctrlKey || false,
+ altKey: options.alt || options.altKey || false,
+ metaKey: options.meta || options.metaKey || false,
+ toString: function () { return options.keys; },
+ stopPropagation: function () {},
+ preventDefault: function () {},
+ simulated: true
+ };
+ m_keyHandler.trigger(keys);
+ return;
+ }
+
page = options.page || {};
if (options.map) {

MODIFY
src/object.js
src/object.js
@@ -123,7 +123,7 @@ var object = function () {
//////////////////////////////////////////////////////////////////////////////
/**
* Remove handlers from an event (or an array of events). If no event is
- * provided all hanlders will be removed.
+ * provided all handlers will be removed.
*
* @param {string?} event An event from {geo.events}
* @param {object?} arg A function or array of functions to remove from the events

MODIFY
tests/cases/mapInteractor.js
tests/cases/mapInteractor.js
@@ -1603,4 +1603,83 @@ describe('mapInteractor', function () {
expect(lastmap).toEqual({x:47, y: 57});
});
});
+
+ it('Test keyboard interaction and event and propagation', function () {
+ var map = mockedMap('#mapNode1'),
+ interactor = geo.mapInteractor({map: map}),
+ keyboardSettings,
+ cancel = false,
+ lastmove,
+ triggered = 0;
+
+ // check the zoom event was called
+ interactor.simulateEvent('keyboard', {keys: 'shift+plus', shift: true});
+ expect(map.info.zoom).toBe(1);
+ expect(map.info.zoomArgs).toBe(3);
+ expect(map.zoom()).toBe(2);
+
+ // check the zoom event was called with a smaller factor
+ interactor.simulateEvent('keyboard', {keys: 'plus'});
+ expect(map.info.zoom).toBe(2);
+ expect(map.info.zoomArgs).toBe(2.05);
+ expect(map.zoom()).toBe(2);
+
+ // check the zoom event is not called with the wrong meta keys
+ interactor.simulateEvent('keyboard', {keys: 'plus', shift: false, ctrl: true});
+ expect(map.info.zoom).toBe(2);
+
+ // check that the keyaction is triggered
+ map.geoOn(geo.event.keyaction, function (evt) {
+ if (cancel) {
+ evt.move.cancel = true;
+ }
+ lastmove = evt.move;
+ triggered += 1;
+ });
+ interactor.simulateEvent('keyboard', {keys: 'plus'});
+ expect(map.info.zoom).toBe(3);
+ expect(triggered).toBe(1);
+ // check that the action can be canceled
+ cancel = true;
+ interactor.simulateEvent('keyboard', {keys: 'plus'});
+ expect(map.info.zoom).toBe(3);
+ expect(triggered).toBe(2);
+ cancel = false;
+ // test a variety of keyboard events
+ var keyTests = {
+ '1': {zoom: 0},
+ '2': {zoom: 3},
+ 'plus': {zoomDelta: 0.05},
+ '-': {zoomDelta: -0.05},
+ '0': {rotation: 0},
+ '>': {rotationDelta: -1 * Math.PI / 180},
+ '<': {rotationDelta: 1 * Math.PI / 180},
+ 'up': {panY: 1},
+ 'down': {panY: -1},
+ 'left': {panX: 1},
+ 'right': {panX: -1}
+ };
+ for (var key in keyTests) {
+ if (keyTests.hasOwnProperty(key)) {
+ triggered = 0;
+ interactor.simulateEvent('keyboard', {keys: key});
+ expect(triggered).toBe(1);
+ for (var prop in keyTests[key]) {
+ if (keyTests[key].hasOwnProperty(prop)) {
+ expect(lastmove[prop]).toBe(keyTests[key][prop]);
+ }
+ }
+ }
+ }
+
+ // test modifying the keyboard settings and the focus highlight class
+ keyboardSettings = interactor.keyboard();
+ expect(keyboardSettings.focusHighlight).not.toBe(undefined);
+ keyboardSettings.focusHighlight = false;
+ expect(interactor.keyboard(keyboardSettings)).toBe(interactor);
+ expect(map.node().hasClass('highlight-focus')).toBe(false);
+ keyboardSettings.focusHighlight = true;
+ expect(interactor.keyboard(keyboardSettings)).toBe(interactor);
+ expect(map.node().hasClass('highlight-focus')).toBe(true);
+ });
});

Add keyboard support for map interaction.

Add some css to show when the map gets focus.  This is only added if the map has the appropriate class, which is controlled by a flag in the keyboard settings for the interactor.