Skip to content

Commit

Permalink
Tab order based on distance from center (#596)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ahmadayubi authored Dec 17, 2021
1 parent 9f1d807 commit 4d57c2a
Show file tree
Hide file tree
Showing 14 changed files with 229 additions and 61 deletions.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/mapml-viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export class MapViewer extends HTMLElement {
query: true,
contextMenu: true,
announceMovement: M.options.announceMovement,
featureIndex: true,
mapEl: this,
crs: M[this.projection],
zoom: this.zoom,
Expand Down
15 changes: 13 additions & 2 deletions src/mapml/features/feature.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,19 @@ export var Feature = L.Path.extend({
this._coordinateToArrays(span, main, subParts, false, span.getAttribute("class"), parents.concat([span]));
}
let noSpan = coords.textContent.replace(/(<([^>]+)>)/ig, ''),
pairs = noSpan.match(/(\S+\s+\S+)/gim), local = [];
pairs = noSpan.match(/(\S+\s+\S+)/gim), local = [], bounds;
for (let p of pairs) {
let numPair = [];
p.split(/\s+/gim).forEach(M.parseNumber, numPair);
let point = M.pointToPCRSPoint(L.point(numPair), this.options.zoom, this.options.projection, this.options.nativeCS);
local.push(point);
this._bounds = this._bounds ? this._bounds.extend(point) : L.bounds(point, point);
bounds = bounds ? bounds.extend(point) : L.bounds(point, point);
}
if (this._bounds) {
this._bounds.extend(bounds.min);
this._bounds.extend(bounds.max);
} else {
this._bounds = bounds;
}
if (isFirst) {
main.push({ points: local });
Expand All @@ -303,6 +309,7 @@ export var Feature = L.Path.extend({
}
subParts.unshift({
points: local,
center: bounds.getCenter(),
cls: `${cls || ""} ${wrapperAttr.className || ""}`.trim(),
attr: attrMap,
link: wrapperAttr.link,
Expand Down Expand Up @@ -339,6 +346,10 @@ export var Feature = L.Path.extend({
if (!this._bounds) return null;
return this._map.options.crs.unproject(this._bounds.getCenter());
},

getPCRSCenter: function () {
return this._bounds.getCenter();
},
});

/**
Expand Down
93 changes: 66 additions & 27 deletions src/mapml/features/featureGroup.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export var FeatureGroup = L.FeatureGroup.extend({
L.LayerGroup.prototype.initialize.call(this, layers, options);

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

onAdd: function (map) {
L.LayerGroup.prototype.onAdd.call(this, map);
this.updateInteraction();
},

updateInteraction: function () {
let map = this._map || this.options._leafletLayer._map;
if((this.options.onEachFeature && this.options.properties) || this.options.link)
map.featureIndex.addToIndex(this, this.getPCRSCenter(), this.options.group);

for (let layerID in this._layers) {
let layer = this._layers[layerID];
for(let part of layer._parts){
if(layer.featureAttributes && layer.featureAttributes.tabindex)
map.featureIndex.addToIndex(layer, layer.getPCRSCenter(), part.path);
for(let subPart of part.subrings) {
if(subPart.attr && subPart.attr.tabindex) map.featureIndex.addToIndex(layer, subPart.center, subPart.path);
}
}
}
},

/**
* Handler for focus events
* @param {L.DOMEvent} e - Event that occurred
* @private
*/
_handleFocus: function(e) {
if((e.keyCode === 9 || e.keyCode === 16) && e.type === "keydown"){
let index = this._map.featureIndex.currentIndex;
if(e.keyCode === 9 && e.shiftKey) {
if(index === this._map.featureIndex.inBoundFeatures.length - 1)
this._map.featureIndex.inBoundFeatures[index].path.setAttribute("tabindex", -1);
if(index !== 0){
L.DomEvent.stop(e);
this._map.featureIndex.inBoundFeatures[index - 1].path.focus();
this._map.featureIndex.currentIndex--;
}
} else if (e.keyCode === 9) {
if(index !== this._map.featureIndex.inBoundFeatures.length - 1) {
L.DomEvent.stop(e);
this._map.featureIndex.inBoundFeatures[index + 1].path.focus();
this._map.featureIndex.currentIndex++;
} else {
this._map.featureIndex.inBoundFeatures[0].path.setAttribute("tabindex", -1);
this._map.featureIndex.inBoundFeatures[index].path.setAttribute("tabindex", 0);
}
}
} else if (!(e.keyCode === 9 || e.keyCode === 16 || e.keyCode === 13)){
this._map.featureIndex.currentIndex = 0;
this._map.featureIndex.inBoundFeatures[0].path.focus();
}

if(e.target.tagName.toUpperCase() !== "G") return;
if((e.keyCode === 9 || e.keyCode === 16 || e.keyCode === 13) && e.type === "keyup") {
this.openTooltip();
Expand Down Expand Up @@ -69,22 +115,10 @@ export var FeatureGroup = L.FeatureGroup.extend({
* @private
*/
_previousFeature: function(e){
let group = this._source.group.previousSibling;
if(!group){
let currentIndex = this._source.group.closest("div.mapml-layer").style.zIndex;
let overlays = this._map.getPane("overlayPane").children;
for(let i = overlays.length - 1; i >= 0; i--){
let layer = overlays[i];
if(layer.style.zIndex >= currentIndex) continue;
group = layer.querySelector("g.leaflet-interactive");
if(group){
group = group.parentNode.lastChild;
break;
}
}
if (!group) group = this._source.group;
}
group.focus();
L.DomEvent.stop(e);
this._map.featureIndex.currentIndex = Math.max(this._map.featureIndex.currentIndex - 1, 0);
let prevFocus = this._map.featureIndex.inBoundFeatures[this._map.featureIndex.currentIndex];
prevFocus.path.focus();
this._map.closePopup();
},

Expand All @@ -94,19 +128,24 @@ export var FeatureGroup = L.FeatureGroup.extend({
* @private
*/
_nextFeature: function(e){
let group = this._source.group.nextSibling;
if(!group){
let currentIndex = this._source.group.closest("div.mapml-layer").style.zIndex;
L.DomEvent.stop(e);
this._map.featureIndex.currentIndex = Math.min(this._map.featureIndex.currentIndex + 1, this._map.featureIndex.inBoundFeatures.length - 1);
let nextFocus = this._map.featureIndex.inBoundFeatures[this._map.featureIndex.currentIndex];
nextFocus.path.focus();
this._map.closePopup();
},

for(let layer of this._map.getPane("overlayPane").children){
if(layer.style.zIndex <= currentIndex) continue;
group = layer.querySelectorAll("g.leaflet-interactive");
if(group.length > 0)break;
getPCRSCenter: function () {
let bounds;
for(let l in this._layers){
let layer = this._layers[l];
if (!bounds) {
bounds = L.bounds(layer.getPCRSCenter(), layer.getPCRSCenter());
} else {
bounds.extend(layer.getPCRSCenter());
}
group = group && group.length > 0 ? group[0] : this._source.group;
}
group.focus();
this._map.closePopup();
return bounds.getCenter();
},
});

Expand Down
7 changes: 1 addition & 6 deletions src/mapml/features/featureRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
* @returns {*}
*/
export var FeatureRenderer = L.SVG.extend({


/**
* Override method of same name from L.SVG, use the this._container property
* to set up the role="none presentation" on featureGroupu container,
Expand Down Expand Up @@ -48,9 +46,6 @@ export var FeatureRenderer = L.SVG.extend({
if (p.subrings) {
for (let r of p.subrings) {
this._createPath(r, layer.options.className, r.attr['aria-label'], (r.link !== undefined), r.attr);
if(r.attr && r.attr.tabindex){
p.path.setAttribute('tabindex', r.attr.tabindex || '0');
}
}
}
this._updateStyle(layer);
Expand All @@ -77,7 +72,7 @@ export var FeatureRenderer = L.SVG.extend({
if (title) p.setAttribute('aria-label', title);
} else {
for(let [name, value] of Object.entries(attr)){
if(name === "id") continue;
if(name === "id" || name === "tabindex") continue;
p.setAttribute(name, value);
}
}
Expand Down
110 changes: 110 additions & 0 deletions src/mapml/handlers/FeatureIndex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
export var FeatureIndex = L.Handler.extend({
initialize: function (map) {
L.Handler.prototype.initialize.call(this, map);
this.inBoundFeatures = [];
this.outBoundFeatures = [];
this.currentIndex = 0;
this._mapPCRSBounds = M.pixelToPCRSBounds(
map.getPixelBounds(),
map.getZoom(),
map.options.projection);
},

addHooks: function () {
this._map.on("mapkeyboardfocused", this._updateMapBounds, this);
this._map.on('mapkeyboardfocused', this._sortIndex, this);
},

removeHooks: function () {
this._map.off("mapkeyboardfocused", this._updateMapBounds);
this._map.off('mapkeyboardfocused', this._sortIndex);
},

/**
* Adds a svg element to the index of tabbable features, it also keeps track of the layer it's associated + center
* @param layer - the layer object the feature is associated with
* @param lc - the layer center
* @param path - the svg element that needs to be focused, can be a path or g
*/
addToIndex: function (layer, lc, path) {
let mc = this._mapPCRSBounds.getCenter();
let dist = Math.sqrt(Math.pow(lc.x - mc.x, 2) + Math.pow(lc.y - mc.y, 2));
let index = this._mapPCRSBounds.contains(lc) ? this.inBoundFeatures : this.outBoundFeatures;

let elem = {path: path, layer: layer, center: lc, dist: dist};
path.setAttribute("tabindex", -1);

index.push(elem);

// TODO: this insertion loop has potential to be improved slightly
for (let i = index.length - 1; i > 0 && index[i].dist < index[i-1].dist; i--) {
let tmp = index[i];
index[i] = index[i-1];
index[i-1] = tmp;
}

if (this._mapPCRSBounds.contains(lc))
this.inBoundFeatures = index;
else
this.outBoundFeatures = index;
},

/**
* Removes features that are no longer on the map, also moves features to the respective array depending
* on whether the feature is in the maps viewport or not
*/
cleanIndex: function() {
this.currentIndex = 0;
this.inBoundFeatures = this.inBoundFeatures.filter((elem) => {
let inbound = this._mapPCRSBounds.contains(elem.center);
elem.path.setAttribute("tabindex", -1);
if (elem.layer._map && !inbound) {
this.outBoundFeatures.push(elem);
}
return elem.layer._map && inbound;
});
this.outBoundFeatures = this.outBoundFeatures.filter((elem) => {
let inbound = this._mapPCRSBounds.contains(elem.center);
elem.path.setAttribute("tabindex", -1);
if (elem.layer._map && inbound) {
this.inBoundFeatures.push(elem);
}
return elem.layer._map && !inbound;
});
},

/**
* Sorts the index of features in the map's viewport based on distance from center
* @private
*/
_sortIndex: function() {
this.cleanIndex();
if(this.inBoundFeatures.length === 0) return;

let mc = this._mapPCRSBounds.getCenter();

this.inBoundFeatures.sort(function(a, b) {
let ac = a.center;
let bc = b.center;
a.dist = Math.sqrt(Math.pow(ac.x - mc.x, 2) + Math.pow(ac.y - mc.y, 2));
b.dist = Math.sqrt(Math.pow(bc.x - mc.x, 2) + Math.pow(bc.y - mc.y, 2));
return a.dist - b.dist;
});

this.inBoundFeatures[0].path.setAttribute("tabindex", 0);
},

/**
* Event handler for 'mapfocused' event to update the map's bounds in terms of PCRS
* @param e - the event object
* @private
*/
_updateMapBounds: function (e) {
// TODO: map's PCRS bounds is used in other parts of the viewer, can be moved out to the map object directly
this._mapPCRSBounds = M.pixelToPCRSBounds(
this._map.getPixelBounds(),
this._map.getZoom(),
this._map.options.projection);
},
});

3 changes: 3 additions & 0 deletions src/mapml/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { Feature, feature } from "./features/feature";
import { FeatureRenderer, featureRenderer } from './features/featureRenderer';
import { FeatureGroup, featureGroup} from './features/featureGroup';
import {AnnounceMovement} from "./handlers/AnnounceMovement";
import { FeatureIndex } from "./handlers/FeatureIndex";
import { Options } from "./options";
import "./keyboard";

Expand Down Expand Up @@ -607,11 +608,13 @@ M.gcrsToTileMatrix = Util.gcrsToTileMatrix;
M.QueryHandler = QueryHandler;
M.ContextMenu = ContextMenu;
M.AnnounceMovement = AnnounceMovement;
M.FeatureIndex = FeatureIndex;

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

M.MapMLLayer = MapMLLayer;
M.mapMLLayer = mapMLLayer;
Expand Down
2 changes: 2 additions & 0 deletions src/mapml/layers/Crosshair.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export var Crosshair = L.Layer.extend({
return false;
},

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

if(this._map.isFocused) this._map.fire("mapkeyboardfocused");
this._addOrRemoveMapOutline();
this._addOrRemoveCrosshair();
},
Expand Down
3 changes: 3 additions & 0 deletions src/mapml/layers/FeatureLayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export var MapMLFeatures = L.FeatureGroup.extend({
L.DomUtil.remove(this._container);
}
L.FeatureGroup.prototype.onRemove.call(this, map);
this._map.featureIndex.cleanIndex();
},

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

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

0 comments on commit 4d57c2a

Please sign in to comment.