Skip to content

Commit 7ca9c4b

Browse files
authored
Merge pull request #661 from OpenGeoscience/keyboard-control
Add keyboard support for map interaction.
2 parents 57608b8 + 06f1a0b commit 7ca9c4b

File tree

6 files changed

+341
-14
lines changed

6 files changed

+341
-14
lines changed

src/event.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,22 @@ geo_event.actionselection = 'geo_actionselection';
281281
//////////////////////////////////////////////////////////////////////////////
282282
geo_event.actionwheel = 'geo_actionwheel';
283283

284+
//////////////////////////////////////////////////////////////////////////////
285+
/**
286+
* Triggered when an action is triggered via the keyboard.
287+
*
288+
* @property {object} move The movement that would happen if the action is
289+
* passed through, possibly containing zoomDelta, zoom (absolute),
290+
* rotationDelta (in radians), rotation (absolute in radians), panX (in
291+
* display pixels), panY (in display pixels). Set move.cancel to cancel
292+
* the entire movement.
293+
* @property {string} action Action based on key
294+
* @property {number} factor Factor based on metakeys [0-2].
295+
* @property {object} event The triggering event
296+
*/
297+
//////////////////////////////////////////////////////////////////////////////
298+
geo_event.keyaction = 'geo_keyaction';
299+
284300
//////////////////////////////////////////////////////////////////////////////
285301
/**
286302
* Triggered before a map navigation animation begins. Set

src/main.styl

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,27 @@
4141
&.annotation-input
4242
cursor crosshair
4343

44+
&.highlight-focus
45+
&:after
46+
content ""
47+
display block
48+
position absolute
49+
box-sizing border-box
50+
left 0px
51+
top 0px
52+
right 0px
53+
bottom 0px
54+
// Highlight is technically a css2 color. We may need to specify it
55+
// explicitly. #3B66A6 seems close
56+
border 3px solid Highlight
57+
opacity 1
58+
transition opacity 0s
59+
visibility hidden
60+
&:focus:after
61+
visibility visible
62+
transition opacity 2.5s ease-in
63+
opacity 0
64+
4465
.geo-tile-container
4566
position absolute
4667
&.crop

src/map.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,9 @@ var map = function (arg) {
974974
if (arg === undefined) {
975975
return m_interactor;
976976
}
977+
if (m_interactor && m_interactor !== arg) {
978+
m_interactor.destroy();
979+
}
977980
m_interactor = arg;
978981

979982
// this makes it possible to set a null interactor

src/mapInteractor.js

Lines changed: 221 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
var inherit = require('./inherit');
22
var object = require('./object');
33
var util = require('./util');
4+
var Mousetrap = require('mousetrap');
45

56
//////////////////////////////////////////////////////////////////////////////
67
/**
@@ -29,10 +30,11 @@ var mapInteractor = function (args) {
2930
var actionMatch = require('./util').actionMatch;
3031
var quadFeature = require('./quadFeature');
3132

32-
var m_options = args || {},
33+
var m_options,
3334
m_this = this,
3435
m_mouse,
35-
m_keyboard,
36+
m_keyHandler,
37+
m_boundKeys,
3638
m_state,
3739
m_queue,
3840
$node,
@@ -147,6 +149,50 @@ var mapInteractor = function (args) {
147149
cancelOnMove: true
148150
},
149151

152+
keyboard: {
153+
actions: {
154+
/* Specific actions can be disabled by removing them from this object
155+
* or stting an empty list as the key bindings. Additional actions
156+
* can be added to the dictionary, each of which gets a list of key
157+
* bindings. See Mousetrap documentation for special key names. */
158+
'zoom.in': ['plus', 'shift+plus', 'shift+ctrl+plus', '=', 'shift+=', 'shift+ctrl+='],
159+
'zoom.out': ['-', 'shift+-', 'shift+ctrl+-', '_', 'shift+_', 'shift+ctrl+_'],
160+
'zoom.0': ['1'],
161+
'zoom.3': ['2'],
162+
'zoom.6': ['3'],
163+
'zoom.9': ['4'],
164+
'zoom.12': ['5'],
165+
'zoom.15': ['6'],
166+
'zoom.18': ['7'],
167+
'pan.left': ['left', 'shift+left', 'shift+ctrl+left'],
168+
'pan.right': ['right', 'shift+right', 'shift+ctrl+right'],
169+
'pan.up': ['up', 'shift+up', 'shift+ctrl+up'],
170+
'pan.down': ['down', 'shift+down', 'shift+ctrl+down'],
171+
'rotate.ccw': ['<', 'shift+<', 'shift+ctrl+<', '.', 'shift+.', 'shift+ctrl+.'],
172+
'rotate.cw': ['>', 'shift+>', 'shift+ctrl+>', ',', 'shift+,', 'shift+ctrl+,'],
173+
'rotate.0': ['0']
174+
},
175+
meta: {
176+
/* the metakeys that are down during a key event determine the
177+
* magnitude of the action, where 0 is the default small action
178+
* (1-pixel pan, small zoom, small rotation), 1 is a middle-sized
179+
* action, and 2 is the largest action. Metakeys that aren't listed
180+
* are ignored. Metakeys include shift, ctrl, alt, and meta (alt is
181+
* either the alt or option key, and meta is either windows or
182+
* command). */
183+
0: {shift: false, ctrl: false},
184+
1: {shift: true, ctrl: true},
185+
2: {shift: true, ctrl: false}
186+
},
187+
/* if focusHighlight is truthy, then a class is added to the map such
188+
* that when the map gets focus, it is indicated inside the border of
189+
* the map -- browsers usually show focus on the outside, which isn't
190+
* useful if the map is full window. It might be desireable to change
191+
* this so it is only present if the focus is reached via the keyboard
192+
* (which would propably require detecting keyup events). */
193+
focusHighlight: true
194+
},
195+
150196
wheelScaleX: 1,
151197
wheelScaleY: 1,
152198
zoomScale: 1,
@@ -169,10 +215,10 @@ var mapInteractor = function (args) {
169215
ease: function (t) { return (2 - t) * t; }
170216
}
171217
},
172-
m_options
218+
args || {}
173219
);
174-
/* We don't want to merge the original arrays array with a array passed in
175-
* the args, so override that as necessary for actions. */
220+
/* We don't want to merge the original arrays with arrays passed in the args,
221+
* so override that as necessary for actions. */
176222
if (args && args.actions) {
177223
m_options.actions = $.extend(true, [], args.actions);
178224
}
@@ -357,11 +403,6 @@ var mapInteractor = function (args) {
357403
}
358404
};
359405

360-
// default keyboard object
361-
// (keyboard events not implemented yet)
362-
m_keyboard = {
363-
};
364-
365406
// The interactor state determines what actions are taken in response to
366407
// core browser events.
367408
//
@@ -409,6 +450,119 @@ var mapInteractor = function (args) {
409450
*/
410451
m_queue = {};
411452

453+
/**
454+
* Process keys that we've captured. Metakeys determine the magnitude of
455+
* the action.
456+
*
457+
* @param {string} action: the basic action to take.
458+
* @param {object} evt: the event with metakeys.
459+
* @param {object} keys: keys used to trigger the event. keys.simualted is
460+
* true if artificially triggered.
461+
*/
462+
this._handleKeys = function (action, evt, keys) {
463+
if (keys && keys.simulated === true) {
464+
evt = keys;
465+
}
466+
var meta = m_options.keyboard.meta || {0: {}},
467+
map = m_this.map(),
468+
mapSize = map.size(),
469+
actionBase = action, actionValue = '',
470+
value, factor, move = {};
471+
472+
for (value in meta) {
473+
if (meta.hasOwnProperty(value)) {
474+
if ((meta[value].shift === undefined || evt.shiftKey === !!meta[value].shift) &&
475+
(meta[value].ctrl === undefined || evt.ctrlKey === !!meta[value].ctrl) &&
476+
(meta[value].alt === undefined || evt.altKey === !!meta[value].alt) &&
477+
(meta[value].meta === undefined || evt.metaKey === !!meta[value].meta)) {
478+
factor = value;
479+
}
480+
}
481+
}
482+
if (factor === undefined) {
483+
/* metakeys don't match, so don't trigger an event. */
484+
return;
485+
}
486+
487+
evt.stopPropagation();
488+
evt.preventDefault();
489+
490+
if (action.indexOf('.') >= 0) {
491+
actionBase = action.substr(0, action.indexOf('.'));
492+
actionValue = action.substr(action.indexOf('.') + 1);
493+
}
494+
switch (actionBase) {
495+
case 'zoom':
496+
switch (actionValue) {
497+
case 'in':
498+
move.zoomDelta = [0.05, 0.25, 1][factor];
499+
break;
500+
case 'out':
501+
move.zoomDelta = -[0.05, 0.25, 1][factor];
502+
break;
503+
default:
504+
if (!isNaN(parseFloat(actionValue))) {
505+
move.zoom = parseFloat(actionValue);
506+
}
507+
break;
508+
}
509+
break;
510+
case 'pan':
511+
switch (actionValue) {
512+
case 'down':
513+
move.panY = -Math.max(1, [0, 0.05, 0.5][factor] * mapSize.height);
514+
break;
515+
case 'left':
516+
move.panX = Math.max(1, [0, 0.05, 0.5][factor] * mapSize.width);
517+
break;
518+
case 'right':
519+
move.panX = -Math.max(1, [0, 0.05, 0.5][factor] * mapSize.width);
520+
break;
521+
case 'up':
522+
move.panY = Math.max(1, [0, 0.05, 0.5][factor] * mapSize.height);
523+
break;
524+
}
525+
break;
526+
case 'rotate':
527+
switch (actionValue) {
528+
case 'ccw':
529+
move.rotationDelta = [1, 5, 90][factor] * Math.PI / 180;
530+
break;
531+
case 'cw':
532+
move.rotationDelta = -[1, 5, 90][factor] * Math.PI / 180;
533+
break;
534+
default:
535+
if (!isNaN(parseFloat(actionValue))) {
536+
move.rotation = parseFloat(actionValue);
537+
}
538+
break;
539+
}
540+
break;
541+
}
542+
map.geoTrigger(geo_event.keyaction, {
543+
move: move,
544+
action: action,
545+
factor: factor,
546+
event: evt
547+
});
548+
if (move.cancel) {
549+
return;
550+
}
551+
if (move.zoom !== undefined) {
552+
map.zoom(move.zoom);
553+
} else if (move.zoomDelta) {
554+
map.zoom(map.zoom() + move.zoomDelta);
555+
}
556+
if (move.rotation !== undefined) {
557+
map.rotation(move.rotation);
558+
} else if (move.rotationDelta) {
559+
map.rotation(map.rotation() + move.rotationDelta);
560+
}
561+
if (move.panX || move.panY) {
562+
map.pan({x: move.panX || 0, y: move.panY || 0});
563+
}
564+
};
565+
412566
////////////////////////////////////////////////////////////////////////////
413567
/**
414568
* Connects events to a map. If the map is not set, then this does nothing.
@@ -443,6 +597,28 @@ var mapInteractor = function (args) {
443597
})) {
444598
$node.on('contextmenu.geojs', function () { return false; });
445599
}
600+
601+
if (m_options.keyboard && m_options.keyboard.actions) {
602+
m_keyHandler = Mousetrap($node[0]);
603+
var bound = [];
604+
for (var keyAction in m_options.keyboard.actions) {
605+
if (m_options.keyboard.actions.hasOwnProperty(keyAction)) {
606+
m_keyHandler.bind(
607+
m_options.keyboard.actions[keyAction],
608+
(function (action) {
609+
return function (evt, keys) {
610+
m_this._handleKeys(action, evt, keys);
611+
};
612+
})(keyAction)
613+
);
614+
bound = bound.concat(m_options.keyboard.actions[keyAction]);
615+
}
616+
}
617+
m_boundKeys = bound;
618+
}
619+
$node.toggleClass('highlight-focus',
620+
m_boundKeys && m_boundKeys.length && m_options.keyboard.focusHighlight);
621+
446622
return m_this;
447623
};
448624

@@ -453,12 +629,20 @@ var mapInteractor = function (args) {
453629
*/
454630
////////////////////////////////////////////////////////////////////////////
455631
this._disconnectEvents = function () {
632+
if (m_boundKeys) {
633+
if (m_keyHandler) {
634+
m_boundKeys.every(m_keyHandler.unbind, m_keyHandler);
635+
}
636+
m_boundKeys = null;
637+
m_keyHandler = null;
638+
}
456639
if ($node) {
457640
$node.off('.geojs');
458641
$node = null;
459642
}
460643
m_this._handleMouseWheel = function () {};
461644
m_callZoom = function () {};
645+
462646
return m_this;
463647
};
464648

@@ -1454,6 +1638,9 @@ var mapInteractor = function (args) {
14541638
////////////////////////////////////////////////////////////////////////////
14551639
this.destroy = function () {
14561640
m_this._disconnectEvents();
1641+
if (m_this.map()) {
1642+
$(m_this.map().node()).removeClass('highlight-focus');
1643+
}
14571644
m_this.map(null);
14581645
};
14591646

@@ -1468,11 +1655,14 @@ var mapInteractor = function (args) {
14681655

14691656
////////////////////////////////////////////////////////////////////////////
14701657
/**
1471-
* Get current keyboard information
1658+
* Get/set current keyboard information
14721659
*/
14731660
////////////////////////////////////////////////////////////////////////////
1474-
this.keyboard = function () {
1475-
return $.extend(true, {}, m_keyboard);
1661+
this.keyboard = function (newValue) {
1662+
if (newValue === undefined) {
1663+
return $.extend(true, {}, m_options.keyboard || {});
1664+
}
1665+
return m_this.options({keyboard: newValue});
14761666
};
14771667

14781668
////////////////////////////////////////////////////////////////////////////
@@ -1589,6 +1779,24 @@ var mapInteractor = function (args) {
15891779
return m_this;
15901780
}
15911781

1782+
if (type === 'keyboard' && m_keyHandler) {
1783+
/* Mousetrap passes through the keys we send, but not an event object,
1784+
* so we construct an artifical event object as the keys, and use that.
1785+
*/
1786+
var keys = {
1787+
shiftKey: options.shift || options.shiftKey || false,
1788+
ctrlKey: options.ctrl || options.ctrlKey || false,
1789+
altKey: options.alt || options.altKey || false,
1790+
metaKey: options.meta || options.metaKey || false,
1791+
toString: function () { return options.keys; },
1792+
stopPropagation: function () {},
1793+
preventDefault: function () {},
1794+
simulated: true
1795+
};
1796+
m_keyHandler.trigger(keys);
1797+
return;
1798+
}
1799+
15921800
page = options.page || {};
15931801

15941802
if (options.map) {

src/object.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ var object = function () {
123123
//////////////////////////////////////////////////////////////////////////////
124124
/**
125125
* Remove handlers from an event (or an array of events). If no event is
126-
* provided all hanlders will be removed.
126+
* provided all handlers will be removed.
127127
*
128128
* @param {string?} event An event from {geo.events}
129129
* @param {object?} arg A function or array of functions to remove from the events

0 commit comments

Comments
 (0)