diff --git a/src/mapml-viewer.js b/src/mapml-viewer.js index f7fbbb155..68b9465e5 100644 --- a/src/mapml-viewer.js +++ b/src/mapml-viewer.js @@ -100,7 +100,6 @@ export class MapViewer extends HTMLElement { constructor() { // Always call super first in constructor super(); - this._source = this.outerHTML; let tmpl = document.createElement('template'); tmpl.innerHTML = @@ -110,7 +109,10 @@ export class MapViewer extends HTMLElement { let shadowRoot = this.attachShadow({mode: 'open'}); this._container = document.createElement('div'); - + + let output = ""; + this._container.insertAdjacentHTML("beforeend", output); + // Set default styles for the map element. let mapDefaultCSS = document.createElement('style'); mapDefaultCSS.innerHTML = @@ -198,6 +200,7 @@ export class MapViewer extends HTMLElement { projection: this.projection, query: true, contextMenu: true, + announceMovement: M.options.announceMovement, mapEl: this, crs: M[this.projection], zoom: this.zoom, @@ -361,6 +364,19 @@ export class MapViewer extends HTMLElement { this.dispatchEvent(new CustomEvent("layerchange", {details:{target: this, originalEvent: e}})); } }, false); + + this.parentElement.addEventListener('keyup', function (e) { + if(e.keyCode === 9 && document.activeElement.nodeName === "MAPML-VIEWER"){ + document.activeElement.dispatchEvent(new CustomEvent('mapfocused', {detail: + {target: this}})); + } + }); + this.parentElement.addEventListener('mousedown', function (e) { + if(document.activeElement.nodeName === "MAPML-VIEWER"){ + document.activeElement.dispatchEvent(new CustomEvent('mapfocused', {detail: + {target: this}})); + } + }); this._map.on('load', function () { this.dispatchEvent(new CustomEvent('load', {detail: {target: this}})); diff --git a/src/mapml.css b/src/mapml.css index b2f279be0..239a3b035 100644 --- a/src/mapml.css +++ b/src/mapml.css @@ -73,7 +73,7 @@ .mapml-layer-item-name a { color: revert; } - + .leaflet-top .leaflet-control { margin-top: 5px; } @@ -703,3 +703,13 @@ label.mapml-layer-item-toggle { padding-inline-start: 2rem; padding-inline-end: 1rem; } + +.mapml-screen-reader-output { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} \ No newline at end of file diff --git a/src/mapml/handlers/AnnounceMovement.js b/src/mapml/handlers/AnnounceMovement.js new file mode 100644 index 000000000..cd8b932db --- /dev/null +++ b/src/mapml/handlers/AnnounceMovement.js @@ -0,0 +1,130 @@ +export var AnnounceMovement = L.Handler.extend({ + addHooks: function () { + this._map.on({ + layeradd: this.totalBounds, + layerremove: this.totalBounds, + }); + + this._map.options.mapEl.addEventListener('moveend', this.announceBounds); + this._map.dragging._draggable.addEventListener('dragstart', this.dragged); + this._map.options.mapEl.addEventListener('mapfocused', this.focusAnnouncement); + }, + removeHooks: function () { + this._map.off({ + layeradd: this.totalBounds, + layerremove: this.totalBounds, + }); + + this._map.options.mapEl.removeEventListener('moveend', this.announceBounds); + this._map.dragging._draggable.removeEventListener('dragstart', this.dragged); + this._map.options.mapEl.removeEventListener('mapfocused', this.focusAnnouncement); + }, + + focusAnnouncement: function () { + let mapEl = this; + setTimeout(function (){ + let el = mapEl.querySelector(".mapml-web-map") ? mapEl.querySelector(".mapml-web-map").shadowRoot.querySelector(".leaflet-container") : + mapEl.shadowRoot.querySelector(".leaflet-container"); + + let mapZoom = mapEl._map.getZoom(); + let location = M.gcrsToTileMatrix(mapEl); + let standard = M.options.locale.amZoom + " " + mapZoom + " " + M.options.locale.amColumn + " " + location[0] + " " + M.options.locale.amRow + " " + location[1]; + + if(mapZoom === mapEl._map._layersMaxZoom){ + standard = M.options.locale.amMaxZoom + " " + standard; + } + else if(mapZoom === mapEl._map._layersMinZoom){ + standard = M.options.locale.amMinZoom + " " + standard; + } + + el.setAttribute("aria-roledescription", "region " + standard); + setTimeout(function () { + el.removeAttribute("aria-roledescription"); + }, 2000); + }, 0); + }, + + announceBounds: function () { + if(this._traversalCall > 0){ + return; + } + let mapZoom = this._map.getZoom(); + let mapBounds = M.pixelToPCRSBounds(this._map.getPixelBounds(),mapZoom,this._map.options.projection); + + let visible = true; + if(this._map.totalLayerBounds){ + visible = mapZoom <= this._map._layersMaxZoom && mapZoom >= this._map._layersMinZoom && + this._map.totalLayerBounds.overlaps(mapBounds); + } + + let output = this.querySelector(".mapml-web-map") ? this.querySelector(".mapml-web-map").shadowRoot.querySelector(".mapml-screen-reader-output") : + this.shadowRoot.querySelector(".mapml-screen-reader-output"); + + //GCRS to TileMatrix + let location = M.gcrsToTileMatrix(this); + let standard = M.options.locale.amZoom + " " + mapZoom + " " + M.options.locale.amColumn + " " + location[0] + " " + M.options.locale.amRow + " " + location[1]; + + if(!visible){ + let outOfBoundsPos = this._history[this._historyIndex]; + let inBoundsPos = this._history[this._historyIndex - 1]; + this.back(); + this._history.pop(); + + if(outOfBoundsPos.zoom !== inBoundsPos.zoom){ + output.innerText = M.options.locale.amZoomedOut; + } + else if(this._map.dragging._draggable.wasDragged){ + output.innerText = M.options.locale.amDraggedOut; + } + else if(outOfBoundsPos.x > inBoundsPos.x){ + output.innerText = M.options.locale.amEastBound; + } + else if(outOfBoundsPos.x < inBoundsPos.x){ + output.innerText = M.options.locale.amWestBound; + } + else if(outOfBoundsPos.y < inBoundsPos.y){ + output.innerText = M.options.locale.amNorthBound; + } + else if(outOfBoundsPos.y > inBoundsPos.y){ + output.innerText = M.options.locale.amSouthBound; + } + + } + else{ + let prevZoom = this._history[this._historyIndex - 1] ? this._history[this._historyIndex - 1].zoom : this._history[this._historyIndex].zoom; + if(mapZoom === this._map._layersMaxZoom && mapZoom !== prevZoom){ + output.innerText = M.options.locale.amMaxZoom + " " + standard; + } + else if(mapZoom === this._map._layersMinZoom && mapZoom !== prevZoom){ + output.innerText = M.options.locale.amMinZoom + " " + standard; + } + else { + output.innerText = standard; + } + } + this._map.dragging._draggable.wasDragged = false; + }, + + totalBounds: function () { + let layers = Object.keys(this._layers); + let bounds = L.bounds(); + + layers.forEach(i => { + if(this._layers[i].layerBounds){ + if(!bounds){ + let point = this._layers[i].layerBounds.getCenter(); + bounds = L.bounds(point, point); + } + bounds.extend(this._layers[i].layerBounds.min); + bounds.extend(this._layers[i].layerBounds.max); + } + }); + + this.totalLayerBounds = bounds; + }, + + dragged: function () { + this.wasDragged = true; + } + +}); \ No newline at end of file diff --git a/src/mapml/index.js b/src/mapml/index.js index 5501f0065..6f4a9b97a 100644 --- a/src/mapml/index.js +++ b/src/mapml/index.js @@ -56,6 +56,7 @@ import { Crosshair, crosshair } from "./layers/Crosshair"; import { Feature, feature } from "./features/feature"; import { FeatureRenderer, featureRenderer } from './features/featureRenderer'; import { FeatureGroup, featureGroup} from './features/featureGroup'; +import {AnnounceMovement} from "./handlers/AnnounceMovement"; import { Options } from "./options"; import "./keyboard"; @@ -600,13 +601,16 @@ M.coordsToArray = Util.coordsToArray; M.parseStylesheetAsHTML = Util.parseStylesheetAsHTML; M.pointToPCRSPoint = Util.pointToPCRSPoint; M.pixelToPCRSPoint = Util.pixelToPCRSPoint; +M.gcrsToTileMatrix = Util.gcrsToTileMatrix; M.QueryHandler = QueryHandler; M.ContextMenu = ContextMenu; +M.AnnounceMovement = AnnounceMovement; // 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); M.MapMLLayer = MapMLLayer; M.mapMLLayer = mapMLLayer; diff --git a/src/mapml/layers/DebugLayer.js b/src/mapml/layers/DebugLayer.js index 66d02e205..0b2a0bf07 100644 --- a/src/mapml/layers/DebugLayer.js +++ b/src/mapml/layers/DebugLayer.js @@ -178,6 +178,7 @@ export var DebugVectors = L.LayerGroup.extend({ j = 0; this.addLayer(this._centerVector); + for (let i of id) { if (layers[i].layerBounds) { let boundsArray = [ @@ -199,6 +200,20 @@ export var DebugVectors = L.LayerGroup.extend({ j++; } } + + if(map.totalLayerBounds){ + let totalBoundsArray = [ + map.totalLayerBounds.min, + L.point(map.totalLayerBounds.max.x, map.totalLayerBounds.min.y), + map.totalLayerBounds.max, + L.point(map.totalLayerBounds.min.x, map.totalLayerBounds.max.y) + ]; + + let totalBounds = projectedExtent( + totalBoundsArray, + {color: "#808080", weight: 5, opacity: 0.5, fill: false}); + this.addLayer(totalBounds); + } }, _mapLayerUpdate: function (e) { diff --git a/src/mapml/layers/TemplatedTileLayer.js b/src/mapml/layers/TemplatedTileLayer.js index 6431ca192..f30518ebd 100644 --- a/src/mapml/layers/TemplatedTileLayer.js +++ b/src/mapml/layers/TemplatedTileLayer.js @@ -57,7 +57,7 @@ export var TemplatedTileLayer = L.TileLayer.extend({ let mapBounds = M.pixelToPCRSBounds(this._map.getPixelBounds(),mapZoom,this._map.options.projection); this.isVisible = mapZoom <= this.options.maxZoom && mapZoom >= this.options.minZoom && this.layerBounds.overlaps(mapBounds); - if(!(this.isVisible))return; + if(!(this.isVisible))return; this._parentOnMoveEnd(); }, createTile: function (coords) { diff --git a/src/mapml/options.js b/src/mapml/options.js index bdccc4e95..72785bf38 100644 --- a/src/mapml/options.js +++ b/src/mapml/options.js @@ -15,6 +15,17 @@ export var Options = { lcOpacity: "Opacity", btnZoomIn: "Zoom in", btnZoomOut: "Zoom out", - btnFullScreen: "View fullscreen" + btnFullScreen: "View fullscreen", + amZoom: "zoom level", + amColumn: "column", + amRow: "row", + amMaxZoom: "At maximum zoom level, zoom in disabled", + amMinZoom: "At minimum zoom level, zoom out disabled", + amZoomedOut: "Zoomed out of bounds, returning to", + amDraggedOut: "Dragged out of bounds, returning to", + amEastBound: "Reached east bound, panning east disabled", + amWestBound: "Reached west bound, panning west disabled", + amNorthBound: "Reached north bound, panning north disabled", + amSouthBound: "Reached south bound, panning south disabled" } }; \ No newline at end of file diff --git a/src/mapml/utils/Util.js b/src/mapml/utils/Util.js index 3b7a74851..5e969a27a 100644 --- a/src/mapml/utils/Util.js +++ b/src/mapml/utils/Util.js @@ -385,4 +385,12 @@ export var Util = { map.getContainer().focus(); } }, + + gcrsToTileMatrix: function (mapEl) { + let point = mapEl._map.project(mapEl._map.getCenter()); + let tileSize = mapEl._map.options.crs.options.crs.tile.bounds.max.y; + let column = Math.trunc(point.x / tileSize); + let row = Math.trunc(point.y / tileSize); + return [column, row]; + } }; \ No newline at end of file diff --git a/src/web-map.js b/src/web-map.js index 0fb1624f7..b072a1481 100644 --- a/src/web-map.js +++ b/src/web-map.js @@ -117,7 +117,10 @@ export class WebMap extends HTMLMapElement { let shadowRoot = rootDiv.attachShadow({mode: 'open'}); this._container = document.createElement('div'); - + + let output = ""; + this._container.insertAdjacentHTML("beforeend", output); + // Set default styles for the map element. let mapDefaultCSS = document.createElement('style'); mapDefaultCSS.innerHTML = @@ -211,6 +214,7 @@ export class WebMap extends HTMLMapElement { projection: this.projection, query: true, contextMenu: true, + announceMovement: M.options.announceMovement, mapEl: this, crs: M[this.projection], zoom: this.zoom, @@ -398,6 +402,19 @@ export class WebMap extends HTMLMapElement { this.dispatchEvent(new CustomEvent("layerchange", {details:{target: this, originalEvent: e}})); } }, false); + + this.parentElement.addEventListener('keyup', function (e) { + if(e.keyCode === 9 && document.activeElement.nodeName === "MAPML-VIEWER"){ + document.activeElement.dispatchEvent(new CustomEvent('mapfocused', {detail: + {target: this}})); + } + }); + this.parentElement.addEventListener('mousedown', function (e) { + if(document.activeElement.nodeName === "MAPML-VIEWER"){ + document.activeElement.dispatchEvent(new CustomEvent('mapfocused', {detail: + {target: this}})); + } + }); this._map.on('load', function () { this.dispatchEvent(new CustomEvent('load', {detail: {target: this}})); diff --git a/test/e2e/core/announceMovement.test.js b/test/e2e/core/announceMovement.test.js new file mode 100644 index 000000000..6df5b48df --- /dev/null +++ b/test/e2e/core/announceMovement.test.js @@ -0,0 +1,90 @@ +describe("Announce movement test", ()=> { + beforeAll(async () => { + await page.goto(PATH + "mapml-viewer.html"); + }); + + afterAll(async function () { + await context.close(); + }); + + test("Output values are correct during regular movement", async ()=>{ + await page.keyboard.press("Tab"); + await page.waitForTimeout(500); + await page.keyboard.press("ArrowUp"); + await page.waitForTimeout(1000); + + const movedUp = await page.$eval( + "body > mapml-viewer div > output", + (output) => output.innerHTML + ); + expect(movedUp).toEqual("zoom level 0 column 3 row 3"); + + for(let i = 0; i < 2; i++){ + await page.keyboard.press("ArrowLeft"); + await page.waitForTimeout(1000); + } + + const movedLeft = await page.$eval( + "body > mapml-viewer div > output", + (output) => output.innerHTML + ); + expect(movedLeft).toEqual("zoom level 0 column 2 row 3"); + + await page.keyboard.press("Equal"); + await page.waitForTimeout(1000); + + const zoomedIn = await page.$eval( + "body > mapml-viewer div > output", + (output) => output.innerHTML + ); + expect(zoomedIn).toEqual("zoom level 1 column 4 row 6"); + }); + + test("Output values are correct at bounds and bounces back", async ()=>{ + //Zoom out to min layer bound + await page.keyboard.press("Minus"); + await page.waitForTimeout(1000); + + const minZoom = await page.$eval( + "body > mapml-viewer div > output", + (output) => output.innerHTML + ); + expect(minZoom).toEqual("At minimum zoom level, zoom out disabled zoom level 0 column 2 row 3"); + + //Pan out of west bounds, expect the map to bounce back + for(let i = 0; i < 4; i++){ + await page.waitForTimeout(1000); + await page.keyboard.press("ArrowLeft"); + } + + const westBound = await page.waitForFunction(() => + document.querySelector("body > mapml-viewer").shadowRoot.querySelector("div > output").innerHTML === "Reached west bound, panning west disabled", + {}, {timeout: 1000} + ); + expect(await westBound.jsonValue()).toEqual(true); + + await page.waitForTimeout(1000); + const bouncedBack = await page.$eval( + "body > mapml-viewer div > output", + (output) => output.innerHTML + ); + expect(bouncedBack).toEqual("zoom level 0 column 1 row 3"); + + //Zoom in out of bounds, expect the map to zoom back + await page.keyboard.press("Equal"); + + const zoomedOutOfBounds = await page.waitForFunction(() => + document.querySelector("body > mapml-viewer").shadowRoot.querySelector("div > output").innerHTML === "Zoomed out of bounds, returning to", + {}, {timeout: 1000} + ); + expect(await zoomedOutOfBounds.jsonValue()).toEqual(true); + + await page.waitForTimeout(1000); + const zoomedBack = await page.$eval( + "body > mapml-viewer div > output", + (output) => output.innerHTML + ); + expect(zoomedBack).toEqual("zoom level 0 column 1 row 3"); + + }); +}); \ No newline at end of file diff --git a/test/e2e/mapml-viewer/mapml-viewer.html b/test/e2e/mapml-viewer/mapml-viewer.html index 8efbfec45..1982a71db 100644 --- a/test/e2e/mapml-viewer/mapml-viewer.html +++ b/test/e2e/mapml-viewer/mapml-viewer.html @@ -4,6 +4,11 @@