Skip to content

Commit 4d57c2a

Browse files
authored
Tab order based on distance from center (#596)
* Tab order based on distance from center * Use global sorting, handle tabbing using event handlers rather than tabindex * Fix feature order * Update popup navigation button function * Refactor, only tab features within map bounds * Fix link tabbing * Fix feature button navigation * Fix issues templated features, add comments * Merge with upstream/main * Update linkTypes, featureLinks tests * Unit test update
1 parent 9f1d807 commit 4d57c2a

File tree

14 files changed

+229
-61
lines changed

14 files changed

+229
-61
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/mapml-viewer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ export class MapViewer extends HTMLElement {
199199
query: true,
200200
contextMenu: true,
201201
announceMovement: M.options.announceMovement,
202+
featureIndex: true,
202203
mapEl: this,
203204
crs: M[this.projection],
204205
zoom: this.zoom,

src/mapml/features/feature.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,13 +284,19 @@ export var Feature = L.Path.extend({
284284
this._coordinateToArrays(span, main, subParts, false, span.getAttribute("class"), parents.concat([span]));
285285
}
286286
let noSpan = coords.textContent.replace(/(<([^>]+)>)/ig, ''),
287-
pairs = noSpan.match(/(\S+\s+\S+)/gim), local = [];
287+
pairs = noSpan.match(/(\S+\s+\S+)/gim), local = [], bounds;
288288
for (let p of pairs) {
289289
let numPair = [];
290290
p.split(/\s+/gim).forEach(M.parseNumber, numPair);
291291
let point = M.pointToPCRSPoint(L.point(numPair), this.options.zoom, this.options.projection, this.options.nativeCS);
292292
local.push(point);
293-
this._bounds = this._bounds ? this._bounds.extend(point) : L.bounds(point, point);
293+
bounds = bounds ? bounds.extend(point) : L.bounds(point, point);
294+
}
295+
if (this._bounds) {
296+
this._bounds.extend(bounds.min);
297+
this._bounds.extend(bounds.max);
298+
} else {
299+
this._bounds = bounds;
294300
}
295301
if (isFirst) {
296302
main.push({ points: local });
@@ -303,6 +309,7 @@ export var Feature = L.Path.extend({
303309
}
304310
subParts.unshift({
305311
points: local,
312+
center: bounds.getCenter(),
306313
cls: `${cls || ""} ${wrapperAttr.className || ""}`.trim(),
307314
attr: attrMap,
308315
link: wrapperAttr.link,
@@ -339,6 +346,10 @@ export var Feature = L.Path.extend({
339346
if (!this._bounds) return null;
340347
return this._map.options.crs.unproject(this._bounds.getCenter());
341348
},
349+
350+
getPCRSCenter: function () {
351+
return this._bounds.getCenter();
352+
},
342353
});
343354

344355
/**

src/mapml/features/featureGroup.js

Lines changed: 66 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ export var FeatureGroup = L.FeatureGroup.extend({
1212
L.LayerGroup.prototype.initialize.call(this, layers, options);
1313

1414
if((this.options.onEachFeature && this.options.properties) || this.options.link) {
15-
this.options.group.setAttribute('tabindex', '0');
1615
L.DomUtil.addClass(this.options.group, "leaflet-interactive");
1716
L.DomEvent.on(this.options.group, "keyup keydown mousedown", this._handleFocus, this);
1817
let firstLayer = layers[Object.keys(layers)[0]];
@@ -32,12 +31,59 @@ export var FeatureGroup = L.FeatureGroup.extend({
3231
if(this.options.featureID) this.options.group.setAttribute("data-fid", this.options.featureID);
3332
},
3433

34+
onAdd: function (map) {
35+
L.LayerGroup.prototype.onAdd.call(this, map);
36+
this.updateInteraction();
37+
},
38+
39+
updateInteraction: function () {
40+
let map = this._map || this.options._leafletLayer._map;
41+
if((this.options.onEachFeature && this.options.properties) || this.options.link)
42+
map.featureIndex.addToIndex(this, this.getPCRSCenter(), this.options.group);
43+
44+
for (let layerID in this._layers) {
45+
let layer = this._layers[layerID];
46+
for(let part of layer._parts){
47+
if(layer.featureAttributes && layer.featureAttributes.tabindex)
48+
map.featureIndex.addToIndex(layer, layer.getPCRSCenter(), part.path);
49+
for(let subPart of part.subrings) {
50+
if(subPart.attr && subPart.attr.tabindex) map.featureIndex.addToIndex(layer, subPart.center, subPart.path);
51+
}
52+
}
53+
}
54+
},
55+
3556
/**
3657
* Handler for focus events
3758
* @param {L.DOMEvent} e - Event that occurred
3859
* @private
3960
*/
4061
_handleFocus: function(e) {
62+
if((e.keyCode === 9 || e.keyCode === 16) && e.type === "keydown"){
63+
let index = this._map.featureIndex.currentIndex;
64+
if(e.keyCode === 9 && e.shiftKey) {
65+
if(index === this._map.featureIndex.inBoundFeatures.length - 1)
66+
this._map.featureIndex.inBoundFeatures[index].path.setAttribute("tabindex", -1);
67+
if(index !== 0){
68+
L.DomEvent.stop(e);
69+
this._map.featureIndex.inBoundFeatures[index - 1].path.focus();
70+
this._map.featureIndex.currentIndex--;
71+
}
72+
} else if (e.keyCode === 9) {
73+
if(index !== this._map.featureIndex.inBoundFeatures.length - 1) {
74+
L.DomEvent.stop(e);
75+
this._map.featureIndex.inBoundFeatures[index + 1].path.focus();
76+
this._map.featureIndex.currentIndex++;
77+
} else {
78+
this._map.featureIndex.inBoundFeatures[0].path.setAttribute("tabindex", -1);
79+
this._map.featureIndex.inBoundFeatures[index].path.setAttribute("tabindex", 0);
80+
}
81+
}
82+
} else if (!(e.keyCode === 9 || e.keyCode === 16 || e.keyCode === 13)){
83+
this._map.featureIndex.currentIndex = 0;
84+
this._map.featureIndex.inBoundFeatures[0].path.focus();
85+
}
86+
4187
if(e.target.tagName.toUpperCase() !== "G") return;
4288
if((e.keyCode === 9 || e.keyCode === 16 || e.keyCode === 13) && e.type === "keyup") {
4389
this.openTooltip();
@@ -69,22 +115,10 @@ export var FeatureGroup = L.FeatureGroup.extend({
69115
* @private
70116
*/
71117
_previousFeature: function(e){
72-
let group = this._source.group.previousSibling;
73-
if(!group){
74-
let currentIndex = this._source.group.closest("div.mapml-layer").style.zIndex;
75-
let overlays = this._map.getPane("overlayPane").children;
76-
for(let i = overlays.length - 1; i >= 0; i--){
77-
let layer = overlays[i];
78-
if(layer.style.zIndex >= currentIndex) continue;
79-
group = layer.querySelector("g.leaflet-interactive");
80-
if(group){
81-
group = group.parentNode.lastChild;
82-
break;
83-
}
84-
}
85-
if (!group) group = this._source.group;
86-
}
87-
group.focus();
118+
L.DomEvent.stop(e);
119+
this._map.featureIndex.currentIndex = Math.max(this._map.featureIndex.currentIndex - 1, 0);
120+
let prevFocus = this._map.featureIndex.inBoundFeatures[this._map.featureIndex.currentIndex];
121+
prevFocus.path.focus();
88122
this._map.closePopup();
89123
},
90124

@@ -94,19 +128,24 @@ export var FeatureGroup = L.FeatureGroup.extend({
94128
* @private
95129
*/
96130
_nextFeature: function(e){
97-
let group = this._source.group.nextSibling;
98-
if(!group){
99-
let currentIndex = this._source.group.closest("div.mapml-layer").style.zIndex;
131+
L.DomEvent.stop(e);
132+
this._map.featureIndex.currentIndex = Math.min(this._map.featureIndex.currentIndex + 1, this._map.featureIndex.inBoundFeatures.length - 1);
133+
let nextFocus = this._map.featureIndex.inBoundFeatures[this._map.featureIndex.currentIndex];
134+
nextFocus.path.focus();
135+
this._map.closePopup();
136+
},
100137

101-
for(let layer of this._map.getPane("overlayPane").children){
102-
if(layer.style.zIndex <= currentIndex) continue;
103-
group = layer.querySelectorAll("g.leaflet-interactive");
104-
if(group.length > 0)break;
138+
getPCRSCenter: function () {
139+
let bounds;
140+
for(let l in this._layers){
141+
let layer = this._layers[l];
142+
if (!bounds) {
143+
bounds = L.bounds(layer.getPCRSCenter(), layer.getPCRSCenter());
144+
} else {
145+
bounds.extend(layer.getPCRSCenter());
105146
}
106-
group = group && group.length > 0 ? group[0] : this._source.group;
107147
}
108-
group.focus();
109-
this._map.closePopup();
148+
return bounds.getCenter();
110149
},
111150
});
112151

src/mapml/features/featureRenderer.js

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
* @returns {*}
55
*/
66
export var FeatureRenderer = L.SVG.extend({
7-
8-
97
/**
108
* Override method of same name from L.SVG, use the this._container property
119
* to set up the role="none presentation" on featureGroupu container,
@@ -48,9 +46,6 @@ export var FeatureRenderer = L.SVG.extend({
4846
if (p.subrings) {
4947
for (let r of p.subrings) {
5048
this._createPath(r, layer.options.className, r.attr['aria-label'], (r.link !== undefined), r.attr);
51-
if(r.attr && r.attr.tabindex){
52-
p.path.setAttribute('tabindex', r.attr.tabindex || '0');
53-
}
5449
}
5550
}
5651
this._updateStyle(layer);
@@ -77,7 +72,7 @@ export var FeatureRenderer = L.SVG.extend({
7772
if (title) p.setAttribute('aria-label', title);
7873
} else {
7974
for(let [name, value] of Object.entries(attr)){
80-
if(name === "id") continue;
75+
if(name === "id" || name === "tabindex") continue;
8176
p.setAttribute(name, value);
8277
}
8378
}

src/mapml/handlers/FeatureIndex.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
export var FeatureIndex = L.Handler.extend({
2+
initialize: function (map) {
3+
L.Handler.prototype.initialize.call(this, map);
4+
this.inBoundFeatures = [];
5+
this.outBoundFeatures = [];
6+
this.currentIndex = 0;
7+
this._mapPCRSBounds = M.pixelToPCRSBounds(
8+
map.getPixelBounds(),
9+
map.getZoom(),
10+
map.options.projection);
11+
},
12+
13+
addHooks: function () {
14+
this._map.on("mapkeyboardfocused", this._updateMapBounds, this);
15+
this._map.on('mapkeyboardfocused', this._sortIndex, this);
16+
},
17+
18+
removeHooks: function () {
19+
this._map.off("mapkeyboardfocused", this._updateMapBounds);
20+
this._map.off('mapkeyboardfocused', this._sortIndex);
21+
},
22+
23+
/**
24+
* Adds a svg element to the index of tabbable features, it also keeps track of the layer it's associated + center
25+
* @param layer - the layer object the feature is associated with
26+
* @param lc - the layer center
27+
* @param path - the svg element that needs to be focused, can be a path or g
28+
*/
29+
addToIndex: function (layer, lc, path) {
30+
let mc = this._mapPCRSBounds.getCenter();
31+
let dist = Math.sqrt(Math.pow(lc.x - mc.x, 2) + Math.pow(lc.y - mc.y, 2));
32+
let index = this._mapPCRSBounds.contains(lc) ? this.inBoundFeatures : this.outBoundFeatures;
33+
34+
let elem = {path: path, layer: layer, center: lc, dist: dist};
35+
path.setAttribute("tabindex", -1);
36+
37+
index.push(elem);
38+
39+
// TODO: this insertion loop has potential to be improved slightly
40+
for (let i = index.length - 1; i > 0 && index[i].dist < index[i-1].dist; i--) {
41+
let tmp = index[i];
42+
index[i] = index[i-1];
43+
index[i-1] = tmp;
44+
}
45+
46+
if (this._mapPCRSBounds.contains(lc))
47+
this.inBoundFeatures = index;
48+
else
49+
this.outBoundFeatures = index;
50+
},
51+
52+
/**
53+
* Removes features that are no longer on the map, also moves features to the respective array depending
54+
* on whether the feature is in the maps viewport or not
55+
*/
56+
cleanIndex: function() {
57+
this.currentIndex = 0;
58+
this.inBoundFeatures = this.inBoundFeatures.filter((elem) => {
59+
let inbound = this._mapPCRSBounds.contains(elem.center);
60+
elem.path.setAttribute("tabindex", -1);
61+
if (elem.layer._map && !inbound) {
62+
this.outBoundFeatures.push(elem);
63+
}
64+
return elem.layer._map && inbound;
65+
});
66+
this.outBoundFeatures = this.outBoundFeatures.filter((elem) => {
67+
let inbound = this._mapPCRSBounds.contains(elem.center);
68+
elem.path.setAttribute("tabindex", -1);
69+
if (elem.layer._map && inbound) {
70+
this.inBoundFeatures.push(elem);
71+
}
72+
return elem.layer._map && !inbound;
73+
});
74+
},
75+
76+
/**
77+
* Sorts the index of features in the map's viewport based on distance from center
78+
* @private
79+
*/
80+
_sortIndex: function() {
81+
this.cleanIndex();
82+
if(this.inBoundFeatures.length === 0) return;
83+
84+
let mc = this._mapPCRSBounds.getCenter();
85+
86+
this.inBoundFeatures.sort(function(a, b) {
87+
let ac = a.center;
88+
let bc = b.center;
89+
a.dist = Math.sqrt(Math.pow(ac.x - mc.x, 2) + Math.pow(ac.y - mc.y, 2));
90+
b.dist = Math.sqrt(Math.pow(bc.x - mc.x, 2) + Math.pow(bc.y - mc.y, 2));
91+
return a.dist - b.dist;
92+
});
93+
94+
this.inBoundFeatures[0].path.setAttribute("tabindex", 0);
95+
},
96+
97+
/**
98+
* Event handler for 'mapfocused' event to update the map's bounds in terms of PCRS
99+
* @param e - the event object
100+
* @private
101+
*/
102+
_updateMapBounds: function (e) {
103+
// TODO: map's PCRS bounds is used in other parts of the viewer, can be moved out to the map object directly
104+
this._mapPCRSBounds = M.pixelToPCRSBounds(
105+
this._map.getPixelBounds(),
106+
this._map.getZoom(),
107+
this._map.options.projection);
108+
},
109+
});
110+

src/mapml/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { Feature, feature } from "./features/feature";
5858
import { FeatureRenderer, featureRenderer } from './features/featureRenderer';
5959
import { FeatureGroup, featureGroup} from './features/featureGroup';
6060
import {AnnounceMovement} from "./handlers/AnnounceMovement";
61+
import { FeatureIndex } from "./handlers/FeatureIndex";
6162
import { Options } from "./options";
6263
import "./keyboard";
6364

@@ -607,11 +608,13 @@ M.gcrsToTileMatrix = Util.gcrsToTileMatrix;
607608
M.QueryHandler = QueryHandler;
608609
M.ContextMenu = ContextMenu;
609610
M.AnnounceMovement = AnnounceMovement;
611+
M.FeatureIndex = FeatureIndex;
610612

611613
// see https://leafletjs.com/examples/extending/extending-3-controls.html#handlers
612614
L.Map.addInitHook('addHandler', 'query', M.QueryHandler);
613615
L.Map.addInitHook('addHandler', 'contextMenu', M.ContextMenu);
614616
L.Map.addInitHook('addHandler', 'announceMovement', M.AnnounceMovement);
617+
L.Map.addInitHook('addHandler', 'featureIndex', M.FeatureIndex);
615618

616619
M.MapMLLayer = MapMLLayer;
617620
M.mapMLLayer = mapMLLayer;

src/mapml/layers/Crosshair.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export var Crosshair = L.Layer.extend({
6262
return false;
6363
},
6464

65+
// TODO: should be merged with the 'mapfocused' event emitted by mapml-viewer and map, not trivial
6566
_isMapFocused: function (e) {
6667
//set this._map.isFocused = true if arrow buttons are used
6768
if(!this._map._container.parentNode.activeElement){
@@ -73,6 +74,7 @@ export var Crosshair = L.Layer.extend({
7374
this._map.isFocused = false;
7475
} else this._map.isFocused = isLeafletContainer && ["keyup", "keydown"].includes(e.type);
7576

77+
if(this._map.isFocused) this._map.fire("mapkeyboardfocused");
7678
this._addOrRemoveMapOutline();
7779
this._addOrRemoveCrosshair();
7880
},

src/mapml/layers/FeatureLayer.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export var MapMLFeatures = L.FeatureGroup.extend({
5555
L.DomUtil.remove(this._container);
5656
}
5757
L.FeatureGroup.prototype.onRemove.call(this, map);
58+
this._map.featureIndex.cleanIndex();
5859
},
5960

6061
getEvents: function(){
@@ -146,6 +147,8 @@ export var MapMLFeatures = L.FeatureGroup.extend({
146147

147148
_resetFeatures : function (zoom){
148149
this.clearLayers();
150+
// since features are removed and re-added by zoom level, need to clean the feature index before re-adding
151+
if(this._map) this._map.featureIndex.cleanIndex();
149152
if(this._features && this._features[zoom]){
150153
for(let k =0;k < this._features[zoom].length;k++){
151154
this.addLayer(this._features[zoom][k]);

0 commit comments

Comments
 (0)