├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ ├── gh-pages.yml │ ├── on-pull-request.yml │ ├── on-push-main.yml │ └── on-push-tags.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── examples ├── basic.html ├── basic.ts ├── germany.geojson ├── index.html ├── nominatim.html ├── nominatim.ts ├── shared.css ├── use-geographic.html ├── use-geographic.ts ├── vector.html ├── vector.ts ├── wms-verbose.css ├── wms-verbose.html ├── wms-verbose.ts ├── wms.html └── wms.ts ├── jest.config.ts ├── jest ├── FixJSDOMEnvironment.ts └── resizeObserverMock.js ├── package-lock.json ├── package.json ├── src ├── defaultLayerDescriber.test.ts ├── defaultLayerDescriber.ts ├── defaultLayerFilter.ts ├── defaultTextualDescriber.ts ├── defaultViewDescriber.test.ts ├── defaultViewDescriber.ts ├── determineLayerType.test.ts ├── determineLayerType.ts ├── determineSourceType.test.ts ├── determineSourceType.ts ├── fetchSpy.ts ├── index.test.ts ├── index.ts ├── layerDescriptionsToText.test.ts ├── layerDescriptionsToText.ts ├── nominatimTextualDescriber.ts ├── types.ts ├── util.test.ts ├── util.ts ├── viewDescriptionToText.test.ts └── viewDescriptionToText.ts ├── testdata └── capabilites-example.xml ├── tsconfig.json ├── typedoc.json └── vite.config.mjs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | [*] 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | [*.md] 10 | indent_size = 2 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@terrestris/eslint-config-typescript' 4 | ], 5 | rules: { 6 | 'no-underscore-dangle': 'off', 7 | '@typescript-eslint/member-ordering': 'off' 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | name: Deploy to GitHub Pages 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 18 17 | cache: npm 18 | 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Build website 22 | run: npm run build:all 23 | 24 | # Popular action to deploy to GitHub Pages: 25 | # Docs: https://github.com/peaceiris/actions-gh-pages 26 | - name: Deploy to GitHub Pages 27 | uses: peaceiris/actions-gh-pages@v3 28 | with: 29 | github_token: ${{ secrets.GITHUB_TOKEN }} 30 | # publish from dist folder… 31 | publish_dir: ./dist 32 | # …to the main folder for merges against main 33 | destination_dir: main 34 | # tagged releases will live in their own directory later 35 | -------------------------------------------------------------------------------- /.github/workflows/on-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: lint, typecheck, test and build 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: 18 13 | - uses: actions/cache@v3 14 | with: 15 | path: ~/.npm 16 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 17 | restore-keys: | 18 | ${{ runner.OS }}-node- 19 | ${{ runner.OS }}- 20 | - run: npm ci 21 | - run: npm test 22 | - run: npm run build:all 23 | -------------------------------------------------------------------------------- /.github/workflows/on-push-main.yml: -------------------------------------------------------------------------------- 1 | name: lint, typecheck, test, build & report coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | - uses: actions/cache@v3 17 | with: 18 | path: ~/.npm 19 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 20 | restore-keys: | 21 | ${{ runner.OS }}-node- 22 | ${{ runner.OS }}- 23 | - run: npm ci 24 | - run: npm test 25 | - run: npm run build:all 26 | - uses: coverallsapp/github-action@master 27 | with: 28 | github-token: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/on-push-tags.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish artefacts to GitHub Pages for tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | - run: npm ci 17 | - run: npm run build:all 18 | # Popular action to deploy to GitHub Pages: 19 | # Docs: https://github.com/peaceiris/actions-gh-pages 20 | - name: Deploy to GitHub Pages 21 | uses: peaceiris/actions-gh-pages@v3 22 | if: ${{ github.ref_type == 'tag' }} 23 | with: 24 | github_token: ${{ secrets.GITHUB_TOKEN }} 25 | # publish from dist folder… 26 | publish_dir: ./dist 27 | # …to a folder named like the tag 28 | destination_dir: ${{ github.ref_name }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | 3 | dist/ 4 | 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023-present terrestris GmbH & Co. KG 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 1. Redistributions of source code must retain the above copyright notice, this 6 | list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, 9 | this list of conditions and the following disclaimer in the documentation 10 | and/or other materials provided with the distribution. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 13 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 14 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 15 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 16 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 17 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 18 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 19 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 20 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 21 | OF THE POSSIBILITY OF SUCH DAMAGE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ol-describe-map 2 | 3 | [![npm version](https://img.shields.io/npm/v/@terrestris/ol-describe-map.svg?style=flat-square)](https://www.npmjs.com/package/@terrestris/ol-describe-map) 4 | [![GitHub license](https://img.shields.io/github/license/terrestris/ol-describe-map?style=flat-square)](https://github.com/terrestris/ol-describe-map/blob/main/LICENSE) 5 | [![Coverage Status](https://img.shields.io/coveralls/github/terrestris/ol-describe-map?style=flat-square)](https://coveralls.io/github/terrestris/ol-describe-map) 6 | ![GitHub action build](https://img.shields.io/github/actions/workflow/status/terrestris/ol-describe-map/on-push-main.yml?branch=main&style=flat-square) 7 | ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square) 8 | 9 | The purpose of the `ol-describe-map` library is to provide configurable means of getting 10 | a textual description of an [OpenLayers](https://openlayers.org/) map. 11 | 12 | This description could enhance OpenLayers map applications by 13 | * providing additional information 14 | * adding semantics 15 | * making them more accessible (e.g. for visually impaired users) 16 | 17 | The library ships with the most basic functionality to describe maps, which most 18 | applications will most likely adjust to their specific purpose. It is easy to configure 19 | more specific describers that take care of the specialties of your actual application. 20 | 21 | By default a gathered map description is added to the `aria-description`-attribute of 22 | the `div`-element of the map. 23 | 24 | # Usage 25 | 26 | Install as dependency: 27 | 28 | ```bash 29 | npm install @terrestris/ol-describe-map 30 | ``` 31 | 32 | Use it to describe your map, e.g. in a `moveend`-handler: 33 | 34 | ```javascript 35 | // import the describe function 36 | import { describe } from '@terrestris/ol-describe-map'; 37 | 38 | // initialise your map as usual 39 | const map = new Map({ /* configuration left-out for brevity */ }); 40 | 41 | // whenever the map's moveend event occurs, get a description 42 | map.on('moveend', async () => { 43 | let desc = await describe(map); 44 | console.log(desc.text); 45 | // …by default the aria-description attribute of the map-div is automatically 46 | // updated with the description. This can be configured, of course. 47 | }); 48 | 49 | // call the describe-function with a configuration object to adjust for your specific 50 | // needs, see the examples below. 51 | ``` 52 | 53 | The library ships with some textual describers, and they can be quite useful as they are. 54 | But applications might have more specific ways of describing the map content, and you can 55 | easily pass your own describer: 56 | 57 | ```javascript 58 | // instead of the following line from the above example… 59 | let desc = await describe(map); 60 | ``` 61 | 62 | ```javascript 63 | // …you can create… 64 | const myDescriber = async () => { 65 | return 'HumptyDumpty'; 66 | }; 67 | // …and pass your own textual describer, e.g. 68 | let desc = await describe(map, {textualDescriber: myDescriber}); 69 | ``` 70 | 71 | Your own `myDescriber` function will receive objects with details of the view and all 72 | layers that were described. 73 | 74 | # Examples 75 | 76 | These examples are all based on the `main`-branch: 77 | 78 | * [Basic usage](https://terrestris.github.io/ol-describe-map/main/examples/basic.html) 79 | * [Nominatim describer](https://terrestris.github.io/ol-describe-map/main/examples/nominatim.html) 80 | * [It's OK to `useGeographic()`](https://terrestris.github.io/ol-describe-map/main/examples/use-geographic.html) 81 | * [Describing a vector layer](https://terrestris.github.io/ol-describe-map/main/examples/vector.html) 82 | * [Basic WMS layer example](https://terrestris.github.io/ol-describe-map/main/examples/wms.html) 83 | * [More detailed and verbose WMS layer example](https://terrestris.github.io/ol-describe-map/main/examples/wms-verbose.html) 84 | 85 | # API 86 | 87 | [Typedoc for all exported types and functions](https://terrestris.github.io/ol-describe-map/main/doc/index.html) again for the `main`-branch 88 | 89 | # Development 90 | 91 | ```bash 92 | # install dependencies 93 | npm install 94 | 95 | # run tests (also lints & does a typecheck) 96 | npm test 97 | 98 | # run tests in watch mode 99 | npm run test:watch 100 | 101 | # preview examples 102 | npm run serve-examples 103 | # examples are now listed under http://localhost:5173/ 104 | 105 | # build (library only) 106 | npm run build 107 | 108 | # build (examples only, rarely used) 109 | npm run build-examples 110 | 111 | # build (examples and library) 112 | npm run build:all 113 | # check the contents of the dist-folder 114 | 115 | # cleanup build or coverage artifacts 116 | npm run clean 117 | 118 | ``` 119 | 120 | # TODOs 121 | 122 | * OGC! 123 | 124 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /examples/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Basic example 7 | 8 | 9 | 10 | 14 |
15 |
16 | 17 |

18 |     
23 |     
24 |   
25 | 
26 | 


--------------------------------------------------------------------------------
/examples/basic.ts:
--------------------------------------------------------------------------------
 1 | import Map from 'ol/Map.js';
 2 | import OSM from 'ol/source/OSM.js';
 3 | import TileLayer from 'ol/layer/Tile.js';
 4 | import View from 'ol/View.js';
 5 | import DragRotateAndZoom from 'ol/interaction/DragRotateAndZoom';
 6 | import 'ol/ol.css';
 7 | 
 8 | import hljs from 'highlight.js/lib/core';
 9 | import json from 'highlight.js/lib/languages/json';
10 | import 'highlight.js/styles/atom-one-dark.css';
11 | 
12 | hljs.registerLanguage('json', json);
13 | 
14 | import { describe as describeOlMap } from '../src/index';
15 | 
16 | const map = new Map({
17 |   layers: [
18 |     new TileLayer({
19 |       source: new OSM(),
20 |     }),
21 |   ],
22 |   target: 'map',
23 |   view: new View({
24 |     center: [0, 0],
25 |     zoom: 2,
26 |     rotation: 0.08
27 |   }),
28 | });
29 | 
30 | map.addInteraction(new DragRotateAndZoom());
31 | 
32 | const descElem = document.getElementById('map-description');
33 | const rawElem = document.getElementById('raw-description');
34 | const speakBtn= document.getElementById('speak');
35 | 
36 | const describeMapAndUpdateInfo = async () => {
37 |   const description = await describeOlMap(map);
38 | 
39 |   const highlighted = hljs.highlight(
40 |     JSON.stringify(description, undefined, '  '),
41 |     {language: 'json'}
42 |   ).value;
43 | 
44 |   (descElem as HTMLDivElement).innerHTML = description.text;
45 |   (rawElem as HTMLDivElement).innerHTML = highlighted;
46 | 
47 |   map.getTargetElement().setAttribute('aria-description', description.text);
48 | 
49 |   (speakBtn as HTMLButtonElement).disabled = description.text === '';
50 | };
51 | 
52 | map.on('moveend', describeMapAndUpdateInfo);
53 | 
54 | describeMapAndUpdateInfo();
55 | 
56 | if (speakBtn) {
57 |   speakBtn.addEventListener('click', () => {
58 |     const text = descElem?.innerHTML;
59 |     if (text) {
60 |       var msg = new SpeechSynthesisUtterance();
61 |       msg.text = text;
62 |       window.speechSynthesis.speak(msg);
63 |     }
64 |   });
65 | }
66 | 


--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |   
 4 |     
 5 |     
 6 |     List of examples
 7 |     
 8 |   
 9 |   
10 |     

List of examples

11 | 19 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /examples/nominatim.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Nominatim example 7 | 8 | 9 | 10 | 14 |
15 |
16 | 17 |

18 |     
23 |     
24 |   
25 | 
26 | 


--------------------------------------------------------------------------------
/examples/nominatim.ts:
--------------------------------------------------------------------------------
 1 | import Map from 'ol/Map.js';
 2 | import OSM from 'ol/source/OSM.js';
 3 | import TileLayer from 'ol/layer/Tile.js';
 4 | import View from 'ol/View.js';
 5 | import DragRotateAndZoom from 'ol/interaction/DragRotateAndZoom';
 6 | import 'ol/ol.css';
 7 | 
 8 | import hljs from 'highlight.js/lib/core';
 9 | import json from 'highlight.js/lib/languages/json';
10 | import 'highlight.js/styles/atom-one-dark.css';
11 | 
12 | hljs.registerLanguage('json', json);
13 | 
14 | import { describe as describeOlMap } from '../src/index';
15 | import { nominatimTextualDescriber } from '../src/nominatimTextualDescriber';
16 | import { transform } from 'ol/proj';
17 | 
18 | const map = new Map({
19 |   layers: [
20 |     new TileLayer({
21 |       source: new OSM(),
22 |     }),
23 |   ],
24 |   target: 'map',
25 |   view: new View({
26 |     center: transform([7, 51], 'EPSG:4326', 'EPSG:3857'),
27 |     zoom: 10,
28 |   }),
29 | });
30 | 
31 | map.addInteraction(new DragRotateAndZoom());
32 | 
33 | 
34 | const descElem = document.getElementById('map-description');
35 | const rawElem = document.getElementById('raw-description');
36 | const speakBtn = document.getElementById('speak');
37 | 
38 | const describeMapAndUpdateInfo = async () => {
39 |   const description = await describeOlMap(map, {textualDescriber: nominatimTextualDescriber});
40 | 
41 |   const highlighted = hljs.highlight(
42 |     JSON.stringify(description, undefined, '  '),
43 |     {language: 'json'}
44 |   ).value;
45 | 
46 |   (descElem as HTMLDivElement).innerHTML = description.text;
47 |   (rawElem as HTMLDivElement).innerHTML = highlighted;
48 | 
49 |   map.getTargetElement().setAttribute('aria-description', description.text);
50 | 
51 |   (speakBtn as HTMLButtonElement).disabled = description.text === '';
52 | };
53 | 
54 | map.on('moveend', describeMapAndUpdateInfo);
55 | 
56 | describeMapAndUpdateInfo();
57 | 
58 | if (speakBtn) {
59 |   speakBtn.addEventListener('click', () => {
60 |     const text = descElem?.innerHTML;
61 |     if (text) {
62 |       var msg = new SpeechSynthesisUtterance();
63 |       msg.text = text;
64 |       window.speechSynthesis.speak(msg);
65 |     }
66 |   });
67 | }
68 | 


--------------------------------------------------------------------------------
/examples/shared.css:
--------------------------------------------------------------------------------
 1 | body {
 2 |   font-family: Arial, Helvetica, sans-serif;
 3 |   font-size: 1.3em;
 4 |   line-height: 1.7em;
 5 |   margin: 0;
 6 |   padding: 1em;
 7 | }
 8 | #footer {
 9 |   font-size: 0.7em;
10 | }
11 | pre {
12 |   font-size: 0.9em;
13 |   line-height: 1.2em;
14 |   padding: 1em;
15 |   overflow-x: scroll;
16 | }
17 | #map-description {
18 |   margin-top: 1em;
19 | }
20 | #map {
21 |   height: 300px;
22 | }
23 | 


--------------------------------------------------------------------------------
/examples/use-geographic.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |   
 4 |     
 5 |     
 6 |     useGeographic example
 7 |     
 8 |   
 9 |   
10 |     
14 |     
15 |
16 | 17 |

18 |     
23 |     
24 |   
25 | 
26 | 


--------------------------------------------------------------------------------
/examples/use-geographic.ts:
--------------------------------------------------------------------------------
 1 | import Map from 'ol/Map.js';
 2 | import OSM from 'ol/source/OSM.js';
 3 | import TileLayer from 'ol/layer/Tile.js';
 4 | import View from 'ol/View.js';
 5 | import DragRotateAndZoom from 'ol/interaction/DragRotateAndZoom';
 6 | import 'ol/ol.css';
 7 | 
 8 | import hljs from 'highlight.js/lib/core';
 9 | import json from 'highlight.js/lib/languages/json';
10 | import 'highlight.js/styles/atom-one-dark.css';
11 | 
12 | hljs.registerLanguage('json', json);
13 | 
14 | import { describe as describeOlMap } from '../src/index';
15 | 
16 | import { useGeographic } from 'ol/proj';
17 | 
18 | // the next line makes user facing API methods return 4326-coordinates, regardless of
19 | // the actual view projection. therefore we need to check whether a userProjection is
20 | // active, when we describe the view settings.
21 | useGeographic();
22 | 
23 | const map = new Map({
24 |   layers: [
25 |     new TileLayer({
26 |       source: new OSM(),
27 |     }),
28 |   ],
29 |   target: 'map',
30 |   view: new View({
31 |     // Amsterdam, we can pass latlon coordinates because of useGeographic()
32 |     center: [4.890444, 52.370197],
33 |     zoom: 12
34 |   }),
35 | });
36 | 
37 | map.addInteraction(new DragRotateAndZoom());
38 | 
39 | const descElem = document.getElementById('map-description');
40 | const rawElem = document.getElementById('raw-description');
41 | const speakBtn= document.getElementById('speak');
42 | 
43 | const describeMapAndUpdateInfo = async () => {
44 |   const description = await describeOlMap(map);
45 |   const highlighted = hljs.highlight(
46 |     JSON.stringify(description, undefined, '  '),
47 |     {language: 'json'}
48 |   ).value;
49 | 
50 |   (descElem as HTMLDivElement).innerHTML = description.text;
51 |   (rawElem as HTMLDivElement).innerHTML = highlighted;
52 | 
53 |   map.getTargetElement().setAttribute('aria-description', description.text);
54 | 
55 |   (speakBtn as HTMLButtonElement).disabled = description.text === '';
56 | };
57 | 
58 | map.on('moveend', describeMapAndUpdateInfo);
59 | 
60 | describeMapAndUpdateInfo();
61 | 
62 | if (speakBtn) {
63 |   speakBtn.addEventListener('click', () => {
64 |     const text = descElem?.innerHTML;
65 |     if (text) {
66 |       var msg = new SpeechSynthesisUtterance();
67 |       msg.text = text;
68 |       window.speechSynthesis.speak(msg);
69 |     }
70 |   });
71 | }
72 | 


--------------------------------------------------------------------------------
/examples/vector.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |   
 4 |     
 5 |     
 6 |     Vector layer example
 7 |     
 8 |   
 9 |   
10 |     
15 |     
16 |
17 | 18 |

19 |     
24 |     
25 |   
26 | 
27 | 


--------------------------------------------------------------------------------
/examples/vector.ts:
--------------------------------------------------------------------------------
  1 | import 'ol/ol.css';
  2 | import { FeatureLike } from 'ol/Feature';
  3 | import Map from 'ol/Map.js';
  4 | import OSM from 'ol/source/OSM.js';
  5 | import TileLayer from 'ol/layer/Tile.js';
  6 | import VectorLayer from 'ol/layer/Vector';
  7 | import VectorSource from 'ol/source/Vector';
  8 | import GeoJSON from 'ol/format/GeoJSON';
  9 | import { useGeographic } from 'ol/proj';
 10 | import { StyleFunction } from 'ol/style/Style';
 11 | import { Circle, Fill, RegularShape, Stroke, Style, Text } from 'ol/style';
 12 | import View from 'ol/View.js';
 13 | 
 14 | import hljs from 'highlight.js/lib/core';
 15 | import json from 'highlight.js/lib/languages/json';
 16 | import 'highlight.js/styles/atom-one-dark.css';
 17 | hljs.registerLanguage('json', json);
 18 | 
 19 | import { describe } from '../src/index';
 20 | 
 21 | const fillTiny = new Fill({
 22 |   color: '#843ac5',
 23 | });
 24 | const fillMed = new Fill({
 25 |   color: 'rgba(255,30,30,0.5)',
 26 | });
 27 | const fillBig = new Fill({
 28 |   color: 'rgba(255,165,30,0.5)',
 29 | });
 30 | 
 31 | const strokeTiny = new Stroke({
 32 |   color: 'rgba(255,255,255, 0.4)',
 33 |   width: 1
 34 | });
 35 | const strokeMed = new Stroke({
 36 |   color: 'rgb(255,30,30)',
 37 |   width: 2
 38 | });
 39 | const strokeBig = new Stroke({
 40 |   color: 'rgb(255, 165, 30)',
 41 |   width: 2
 42 | });
 43 | 
 44 | let tiny = new Style({
 45 |   image: new Circle({
 46 |     fill: fillTiny,
 47 |     stroke: strokeTiny,
 48 |     radius: 3
 49 |   })
 50 | });
 51 | let medium = new Style({
 52 |   image: new Circle({
 53 |     fill: fillMed,
 54 |     stroke: strokeMed,
 55 |     radius: 6
 56 |   }),
 57 |   zIndex: 1
 58 | });
 59 | let big = new Style({
 60 |   image: new RegularShape({
 61 |     fill: fillBig,
 62 |     stroke: strokeBig,
 63 |     points: 5,
 64 |     radius1: 5,
 65 |     radius2: 12,
 66 |     rotation: Math.PI/5
 67 |   }),
 68 |   zIndex: 2
 69 | });
 70 | 
 71 | let label = new Style({
 72 |   text: new Text({
 73 |     offsetY: 13,
 74 |     fill: new Fill({
 75 |       color: '#000',
 76 |     }),
 77 |     stroke: new Stroke({
 78 |       color: 'rgba(255, 255, 255, 0.7)',
 79 |       width: 4,
 80 |     }),
 81 |   }),
 82 |   zIndex: 3
 83 | });
 84 | 
 85 | const THRESHOLD_POP_TINY = 500_000;
 86 | const THRESHOLD_POP_MEDIUM = 1_000_000;
 87 | const THRESHOLD_RESOLUTION_SHOW_TINY = 1_000;
 88 | const FONT_MEDIUM = '10px Calibri,sans-serif';
 89 | const FONT_BIG = 'bold 13px Calibri,sans-serif';
 90 | 
 91 | const styleFunction: StyleFunction = (feature: FeatureLike, resolution: number): Style|Style[]|undefined => {
 92 |   const {
 93 |     pop,
 94 |     name
 95 |   } = feature.getProperties();
 96 |   let t = label.getText();
 97 |   if (pop < THRESHOLD_POP_TINY) {
 98 |     if (resolution < THRESHOLD_RESOLUTION_SHOW_TINY) {
 99 |       return tiny;
100 |     }
101 |   } else if (pop < THRESHOLD_POP_MEDIUM) {
102 |     t.setFont(FONT_MEDIUM);
103 |     t.setText(name);
104 |     return [medium, label];
105 |   } else {
106 |     t.setFont(FONT_BIG);
107 |     t.setText(name);
108 |     return [big, label];
109 |   }
110 | };
111 | 
112 | const vectorLayer = new VectorLayer({
113 |   source: new VectorSource({
114 |     url: './germany.geojson',
115 |     format: new GeoJSON(),
116 |     attributions: 'Cities of Germany'
117 |   }),
118 |   style: styleFunction
119 | });
120 | 
121 | useGeographic();
122 | const map = new Map({
123 |   layers: [
124 |     new TileLayer({
125 |       source: new OSM(),
126 |       opacity: 0.33
127 |     }),
128 |     vectorLayer
129 |   ],
130 |   target: 'map',
131 |   view: new View({
132 |     center: [8.8, 51.3],
133 |     zoom: 5
134 |   })
135 | });
136 | 
137 | const descElem = document.getElementById('map-description');
138 | const rawElem = document.getElementById('raw-description');
139 | const speakBtn= document.getElementById('speak');
140 | 
141 | const describeMapAndUpdateInfo = async () => {
142 |   const description = await describe(map, {viewDescriber: () => {}});
143 | 
144 |   const highlighted = hljs.highlight(
145 |     JSON.stringify(description, undefined, '  '),
146 |     {language: 'json'}
147 |   ).value;
148 | 
149 |   (descElem as HTMLDivElement).innerHTML = description.text;
150 |   (rawElem as HTMLDivElement).innerHTML = highlighted;
151 |   (speakBtn as HTMLButtonElement).disabled = description.text === '';
152 | };
153 | 
154 | map.on('moveend', describeMapAndUpdateInfo);
155 | vectorLayer.on('change', describeMapAndUpdateInfo);
156 | 
157 | describeMapAndUpdateInfo();
158 | 
159 | if (speakBtn) {
160 |   speakBtn.addEventListener('click', () => {
161 |     const text = descElem?.innerHTML;
162 |     if (text) {
163 |       var msg = new SpeechSynthesisUtterance();
164 |       msg.text = text;
165 |       window.speechSynthesis.speak(msg);
166 |     }
167 |   });
168 | }
169 | 


--------------------------------------------------------------------------------
/examples/wms-verbose.css:
--------------------------------------------------------------------------------
 1 | .wms-desc {
 2 |   line-height: 1.3em;
 3 | }
 4 | .wms-desc table {
 5 |   border-collapse: collapse;
 6 |   margin: 1.2em 0;
 7 |   font-size: 0.9em;
 8 |   font-family: sans-serif;
 9 |   min-width: 100vH;
10 |   box-shadow: 0.3em 0.3em 1em rgba(0, 0, 0, 0.7)
11 | }
12 | .wms-desc thead tr {
13 |   background-color: #009879;
14 |   color: #ffffff;
15 |   text-align: left;
16 | }
17 | .wms-desc th,
18 | .wms-desc td,
19 | .wms-desc caption {
20 |   padding: 0.4em 0.6em;
21 | }
22 | .wms-desc td a {
23 |   color: #040273
24 | }
25 | .wms-desc tbody tr {
26 |   border-bottom: 1px solid #dddddd;
27 | }
28 | .wms-desc tbody tr:nth-of-type(even) {
29 |   background-color: #f3f3f3;
30 | }
31 | .wms-desc tbody tr:last-of-type {
32 |   border-bottom: 2px solid #009879;
33 | }
34 | .wms-desc tbody tr:hover {
35 |   background-color: #dddddd
36 | }
37 | .wms-desc caption {
38 |   font-weight: bold;
39 |   text-align: left;
40 |   background: #dddddd;
41 |   box-shadow: 0.3em 0.3em 1em rgba(0, 0, 0, 0.7)
42 | }
43 | .wms-desc dl {
44 |   display: flex;
45 |   flex-flow: row wrap;
46 |   border: solid #dddddd;
47 |   border-width: 1px 1px 0 0;
48 |   box-shadow: 0.3em 0.3em 1em rgba(0, 0, 0, 0.7)
49 | }
50 | .wms-desc dt {
51 |   flex-basis: 20%;
52 |   padding: 2px 4px;
53 |   background: #009879;
54 |   text-align: right;
55 |   color: #fff;
56 |   font-weight: bold;
57 | }
58 | .wms-desc dd {
59 |   flex-basis: 70%;
60 |   flex-grow: 1;
61 |   margin: 0;
62 |   padding: 2px 4px;
63 |   border-bottom: 1px solid #dddddd;
64 | }
65 | 
66 | .wms-desc dd:nth-of-type(even) {
67 |   background-color: #f3f3f3;
68 | }
69 | .wms-desc dd:hover {
70 |   background-color: #dddddd
71 | }
72 | 


--------------------------------------------------------------------------------
/examples/wms-verbose.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |   
 4 |     
 5 |     
 6 |     A more detailed and verbose WMS example
 7 |     
 8 |     
 9 |   
10 |   
11 |     
21 |     
22 |
23 | 24 |

25 |     
30 |     
31 |   
32 | 
33 | 


--------------------------------------------------------------------------------
/examples/wms-verbose.ts:
--------------------------------------------------------------------------------
  1 | import 'ol/ol.css';
  2 | import Map from 'ol/Map.js';
  3 | import TileLayer from 'ol/layer/Tile';
  4 | import ImageLayer from 'ol/layer/Image';
  5 | import { useGeographic } from 'ol/proj';
  6 | import ImageWMS from 'ol/source/ImageWMS';
  7 | import TileWMS from 'ol/source/TileWMS';
  8 | import View from 'ol/View.js';
  9 | 
 10 | import hljs from 'highlight.js/lib/core';
 11 | import json from 'highlight.js/lib/languages/json';
 12 | import 'highlight.js/styles/atom-one-dark.css';
 13 | hljs.registerLanguage('json', json);
 14 | 
 15 | import { describe } from '../src/index';
 16 | import { LayerDescriberFunc, TextualDescriberFunc, WMSLayerDetails } from '../src/types';
 17 | 
 18 | // a tiled layer with WMS source
 19 | const bodenuebersichtskarte = new TileLayer({
 20 |   source: new TileWMS({
 21 |     url: 'https://services.bgr.de/wms/boden/buek1000de/',
 22 |     params: {
 23 |       LAYERS: '0'
 24 |     },
 25 |     attributions: 'BÜK1000 V2.1, (C) BGR, Hannover, 2013'
 28 |   })
 29 | });
 30 | 
 31 | // a single tile layer with WMS source
 32 | const verwaltungsgebiete = new ImageLayer({
 33 |   source: new ImageWMS({
 34 |     url: 'https://sgx.geodatenzentrum.de/wms_vg5000_1231',
 35 |     params: {
 36 |       LAYERS: 'vg5000_lan,vg5000_rbz,vg5000_krs'
 37 |     },
 38 |     attributions: 'Verwaltungsgebiete: © GeoBasis-DE / BKG (' + (new Date()).getFullYear() +
 41 |       ')'
 42 |   })
 43 | });
 44 | 
 45 | useGeographic();
 46 | const map = new Map({
 47 |   layers: [
 48 |     bodenuebersichtskarte,
 49 |     verwaltungsgebiete
 50 |   ],
 51 |   target: 'map',
 52 |   view: new View({
 53 |     center: [9.85, 49.31],
 54 |     zoom: 8
 55 |   }),
 56 | });
 57 | 
 58 | const descElem = document.getElementById('map-description');
 59 | const rawElem = document.getElementById('raw-description');
 60 | const speakBtn = document.getElementById('speak');
 61 | 
 62 | const valOrFallback = (val, fallback = '—') => {
 63 |   return `${val || fallback}`;
 64 | };
 65 | const dlEntry = (term, definition) => {
 66 |   if (definition) {
 67 |     return `
${term}
${definition}
`; 68 | } 69 | return ''; 70 | }; 71 | const toUnorderedList = (arr: string[]) => { 72 | if (arr.length > 0) { 73 | return ``; 74 | } 75 | return ''; 76 | }; 77 | 78 | 79 | const myTextualDescriber: TextualDescriberFunc = async (viewDesc, layerDescs = []) => { 80 | let parts: string[] = []; 81 | let pluralS = layerDescs.length !== 1 ? 's' : ''; 82 | 83 | parts.push('
'); 84 | parts.push('

Layerdescriptions

'); 85 | parts.push(`The map contains ${layerDescs.length} layer${pluralS}: `); 86 | 87 | let columns = ['Name', 'Title', 'Abstract', 'Metadata']; 88 | layerDescs.forEach(layerDesc => { 89 | const { 90 | wmsLayerNames = [], 91 | wmsLayerAbstracts = [], 92 | wmsLayerTitles = [], 93 | wmsLayerMetadataURLs = [], 94 | topLevelLayerAbstract = '', 95 | topLevelLayerTitle = '', 96 | serviceAbstract = '', 97 | serviceKeywords = [], 98 | serviceTitle = '' 99 | } = layerDesc.details as WMSLayerDetails; 100 | 101 | parts.push('
'); 102 | if (serviceTitle) { 103 | parts.push(`

${serviceTitle}

`); 104 | } 105 | parts.push(''); 106 | parts.push(``); 107 | parts.push(''); 108 | parts.push(''); 109 | columns.forEach(col => { 110 | parts.push(``); 111 | }); 112 | parts.push(''); 113 | parts.push(''); 114 | parts.push(''); 115 | wmsLayerNames.forEach((layerName, idx) => { 116 | parts.push(''); 117 | let layerTitle = wmsLayerTitles[idx]; 118 | let layerAbstract = wmsLayerAbstracts[idx]; 119 | let layerMetadataUrl = wmsLayerMetadataURLs[idx]; 120 | parts.push(``); 121 | parts.push(``); 122 | parts.push(``); 123 | if (!layerMetadataUrl) { 124 | parts.push(''); 125 | } else { 126 | let linkTitle = `Link to the metadata-document of layer ${layerName}`; 127 | let linkText = 'Link to Metadata-document'; 128 | parts.push(``); 129 | } 130 | parts.push(''); 131 | }); 132 | parts.push(''); 133 | parts.push('
Displayed layers from WMS "${serviceTitle}"
${col}
${valOrFallback(layerName)}${valOrFallback(layerTitle)}${valOrFallback(layerAbstract)}${linkText}
'); 134 | if (serviceAbstract || serviceKeywords.length > 0 || topLevelLayerAbstract || topLevelLayerTitle) { 135 | parts.push('
'); 136 | parts.push(dlEntry('Abstract of service', serviceAbstract)); 137 | parts.push(dlEntry('Keywords of service', toUnorderedList(serviceKeywords))); 138 | parts.push(dlEntry('Title of top-level layer', topLevelLayerTitle)); 139 | parts.push(dlEntry('Abstract of top-level layer', topLevelLayerAbstract)); 140 | parts.push('
'); 141 | } 142 | parts.push('
'); 143 | }); 144 | 145 | parts.push('
'); 146 | return parts.join(''); 147 | }; 148 | 149 | const describeMapAndUpdateInfo = async () => { 150 | const description = await describe( 151 | map, { 152 | viewDescriber: null, 153 | textualDescriber: myTextualDescriber, 154 | updateAriaDescription: false 155 | }); 156 | if (descElem) { 157 | descElem.innerHTML = description.text; 158 | map.getTargetElement().setAttribute('aria-describedby', descElem.id); 159 | } 160 | const highlighted = hljs.highlight( 161 | JSON.stringify(description, undefined, ' '), 162 | {language: 'json'} 163 | ).value; 164 | 165 | (descElem as HTMLDivElement).innerHTML = description.text; 166 | (rawElem as HTMLDivElement).innerHTML = highlighted; 167 | (speakBtn as HTMLButtonElement).disabled = description.text === ''; 168 | }; 169 | 170 | map.on('moveend', describeMapAndUpdateInfo); 171 | describeMapAndUpdateInfo(); 172 | 173 | if (speakBtn) { 174 | speakBtn.addEventListener('click', () => { 175 | const text = descElem?.innerHTML; 176 | if (text) { 177 | var msg = new SpeechSynthesisUtterance(); 178 | msg.text = text; 179 | window.speechSynthesis.speak(msg); 180 | } 181 | }); 182 | } 183 | -------------------------------------------------------------------------------- /examples/wms.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Basic WMS layer example 7 | 8 | 9 | 10 | 17 |
18 |
19 | 20 |

21 |     
26 |     
27 |   
28 | 
29 | 


--------------------------------------------------------------------------------
/examples/wms.ts:
--------------------------------------------------------------------------------
 1 | import 'ol/ol.css';
 2 | import Map from 'ol/Map.js';
 3 | import TileLayer from 'ol/layer/Tile';
 4 | import ImageLayer from 'ol/layer/Image';
 5 | import { useGeographic } from 'ol/proj';
 6 | import ImageWMS from 'ol/source/ImageWMS';
 7 | import TileWMS from 'ol/source/TileWMS';
 8 | import View from 'ol/View.js';
 9 | 
10 | import hljs from 'highlight.js/lib/core';
11 | import json from 'highlight.js/lib/languages/json';
12 | import 'highlight.js/styles/atom-one-dark.css';
13 | hljs.registerLanguage('json', json);
14 | 
15 | import { describe } from '../src/index';
16 | 
17 | // a tiled layer with WMS source
18 | const bodenuebersichtskarte = new TileLayer({
19 |   source: new TileWMS({
20 |     url: 'https://services.bgr.de/wms/boden/buek1000de/',
21 |     params: {
22 |       LAYERS: '0'
23 |     },
24 |     attributions: 'BÜK1000 V2.1, (C) BGR, Hannover, 2013'
27 |   })
28 | });
29 | 
30 | // a single tile layer with WMS source
31 | const verwaltungsgebiete = new ImageLayer({
32 |   source: new ImageWMS({
33 |     url: 'https://sgx.geodatenzentrum.de/wms_vg5000_1231',
34 |     params: {
35 |       LAYERS: 'vg5000_lan,vg5000_rbz,vg5000_krs'
36 |     },
37 |     attributions: 'Verwaltungsgebiete: © GeoBasis-DE / BKG (' + (new Date()).getFullYear() +
40 |       ')'
41 |   })
42 | });
43 | 
44 | useGeographic();
45 | const map = new Map({
46 |   layers: [
47 |     bodenuebersichtskarte,
48 |     verwaltungsgebiete
49 |   ],
50 |   target: 'map',
51 |   view: new View({
52 |     center: [9.85, 49.31],
53 |     zoom: 8
54 |   }),
55 | });
56 | 
57 | const descElem = document.getElementById('map-description');
58 | const rawElem = document.getElementById('raw-description');
59 | const speakBtn = document.getElementById('speak');
60 | 
61 | const describeMapAndUpdateInfo = async () => {
62 |   const description = await describe(map, {viewDescriber: null});
63 |   const highlighted = hljs.highlight(
64 |     JSON.stringify(description, undefined, '  '),
65 |     {language: 'json'}
66 |   ).value;
67 | 
68 |   (descElem as HTMLDivElement).innerHTML = description.text;
69 |   (rawElem as HTMLDivElement).innerHTML = highlighted;
70 |   (speakBtn as HTMLButtonElement).disabled = description.text === '';
71 | };
72 | 
73 | map.on('moveend', describeMapAndUpdateInfo);
74 | describeMapAndUpdateInfo();
75 | 
76 | if (speakBtn) {
77 |   speakBtn.addEventListener('click', () => {
78 |     const text = descElem?.innerHTML;
79 |     if (text) {
80 |       var msg = new SpeechSynthesisUtterance();
81 |       msg.text = text;
82 |       window.speechSynthesis.speak(msg);
83 |     }
84 |   });
85 | }
86 | 


--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
  1 | /**
  2 |  * For a detailed explanation regarding each configuration property, visit:
  3 |  * https://jestjs.io/docs/configuration
  4 |  */
  5 | 
  6 | import type {Config} from 'jest';
  7 | 
  8 | const config: Config = {
  9 |   // All imported modules in your tests should be mocked automatically
 10 |   // automock: false,
 11 | 
 12 |   // Stop running tests after `n` failures
 13 |   // bail: 0,
 14 | 
 15 |   // The directory where Jest should store its cached dependency information
 16 |   // cacheDirectory: "/tmp/jest_rt",
 17 | 
 18 |   // Automatically clear mock calls, instances, contexts and results before every test
 19 |   clearMocks: true,
 20 | 
 21 |   // Indicates whether the coverage information should be collected while executing the test
 22 |   collectCoverage: true,
 23 | 
 24 |   // An array of glob patterns indicating a set of files for which coverage information should be collected
 25 |   // collectCoverageFrom: undefined,
 26 | 
 27 |   // The directory where Jest should output its coverage files
 28 |   coverageDirectory: "coverage",
 29 | 
 30 |   // An array of regexp pattern strings used to skip coverage collection
 31 |   // coveragePathIgnorePatterns: [
 32 |   //   "/node_modules/"
 33 |   // ],
 34 | 
 35 |   // Indicates which provider should be used to instrument code for coverage
 36 |   coverageProvider: "v8",
 37 | 
 38 |   // A list of reporter names that Jest uses when writing coverage reports
 39 |   // coverageReporters: [
 40 |   //   "json",
 41 |   //   "text",
 42 |   //   "lcov",
 43 |   //   "clover"
 44 |   // ],
 45 | 
 46 |   // An object that configures minimum threshold enforcement for coverage results
 47 |   // coverageThreshold: undefined,
 48 | 
 49 |   // A path to a custom dependency extractor
 50 |   // dependencyExtractor: undefined,
 51 | 
 52 |   // Make calling deprecated APIs throw helpful error messages
 53 |   // errorOnDeprecated: false,
 54 | 
 55 |   // The default configuration for fake timers
 56 |   // fakeTimers: {
 57 |   //   "enableGlobally": false
 58 |   // },
 59 | 
 60 |   // Force coverage collection from ignored files using an array of glob patterns
 61 |   // forceCoverageMatch: [],
 62 | 
 63 |   // A path to a module which exports an async function that is triggered once before all test suites
 64 |   // globalSetup: undefined,
 65 | 
 66 |   // A path to a module which exports an async function that is triggered once after all test suites
 67 |   // globalTeardown: undefined,
 68 | 
 69 |   // A set of global variables that need to be available in all test environments
 70 |   // globals: {},
 71 | 
 72 |   // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
 73 |   // maxWorkers: "50%",
 74 | 
 75 |   // An array of directory names to be searched recursively up from the requiring module's location
 76 |   // moduleDirectories: [
 77 |   //   "node_modules"
 78 |   // ],
 79 | 
 80 |   // An array of file extensions your modules use
 81 |   // moduleFileExtensions: [
 82 |   //   "js",
 83 |   //   "mjs",
 84 |   //   "cjs",
 85 |   //   "jsx",
 86 |   //   "ts",
 87 |   //   "tsx",
 88 |   //   "json",
 89 |   //   "node"
 90 |   // ],
 91 | 
 92 |   // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
 93 |   // moduleNameMapper: {},
 94 | 
 95 |   // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
 96 |   // modulePathIgnorePatterns: [],
 97 | 
 98 |   // Activates notifications for test results
 99 |   // notify: false,
100 | 
101 |   // An enum that specifies notification mode. Requires { notify: true }
102 |   // notifyMode: "failure-change",
103 | 
104 |   // A preset that is used as a base for Jest's configuration
105 |   // preset: undefined,
106 | 
107 |   // Run tests from one or more projects
108 |   // projects: undefined,
109 | 
110 |   // Use this configuration option to add custom reporters to Jest
111 |   // reporters: undefined,
112 | 
113 |   // Automatically reset mock state before every test
114 |   // resetMocks: false,
115 | 
116 |   // Reset the module registry before running each individual test
117 |   // resetModules: false,
118 | 
119 |   // A path to a custom resolver
120 |   // resolver: undefined,
121 | 
122 |   // Automatically restore mock state and implementation before every test
123 |   // restoreMocks: false,
124 | 
125 |   // The root directory that Jest should scan for tests and modules within
126 |   // rootDir: undefined,
127 | 
128 |   // A list of paths to directories that Jest should use to search for files in
129 |   // roots: [
130 |   //   ""
131 |   // ],
132 | 
133 |   // Allows you to use a custom runner instead of Jest's default test runner
134 |   // runner: "jest-runner",
135 | 
136 |   // The paths to modules that run some code to configure or set up the testing environment before each test
137 |   // setupFiles: [],
138 | 
139 |   // A list of paths to modules that run some code to configure or set up the testing framework before each test
140 |   // setupFilesAfterEnv: [],
141 |   setupFilesAfterEnv: [
142 |     './jest/resizeObserverMock.js'
143 |   ],
144 | 
145 |   // The number of seconds after which a test is considered as slow and reported as such in the results.
146 |   // slowTestThreshold: 5,
147 | 
148 |   // A list of paths to snapshot serializer modules Jest should use for snapshot testing
149 |   // snapshotSerializers: [],
150 | 
151 |   // The test environment that will be used for testing
152 |   testEnvironment: './jest/FixJSDOMEnvironment.ts',
153 | 
154 |   // Options that will be passed to the testEnvironment
155 |   // testEnvironmentOptions: {},
156 | 
157 |   // Adds a location field to test results
158 |   // testLocationInResults: false,
159 | 
160 |   // The glob patterns Jest uses to detect test files
161 |   // testMatch: [
162 |   //   "**/__tests__/**/*.[jt]s?(x)",
163 |   //   "**/?(*.)+(spec|test).[tj]s?(x)"
164 |   // ],
165 | 
166 |   // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
167 |   // testPathIgnorePatterns: [
168 |   //   "/node_modules/"
169 |   // ],
170 | 
171 |   // The regexp pattern or array of patterns that Jest uses to detect test files
172 |   // testRegex: [],
173 | 
174 |   // This option allows the use of a custom results processor
175 |   // testResultsProcessor: undefined,
176 | 
177 |   // This option allows use of a custom test runner
178 |   // testRunner: "jest-circus/runner",
179 | 
180 |   // A map from regular expressions to paths to transformers
181 |   // transform: undefined,
182 | 
183 |   // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
184 |   // transformIgnorePatterns: [
185 |   //   "/node_modules/",
186 |   //   "\\.pnp\\.[^\\/]+$"
187 |   // ],
188 |   transformIgnorePatterns: [
189 |     '/node_modules/(?!(ol))'
190 |   ],
191 | 
192 |   // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
193 |   // unmockedModulePathPatterns: undefined,
194 | 
195 |   // Indicates whether each individual test should be reported during the run
196 |   // verbose: undefined,
197 | 
198 |   // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
199 |   // watchPathIgnorePatterns: [],
200 | 
201 |   // Whether to use watchman for file crawling
202 |   // watchman: true,
203 | };
204 | 
205 | export default config;
206 | 


--------------------------------------------------------------------------------
/jest/FixJSDOMEnvironment.ts:
--------------------------------------------------------------------------------
 1 | import JSDOMEnvironment from 'jest-environment-jsdom';
 2 | 
 3 | // https://github.com/facebook/jest/blob/v29.4.3/website/versioned_docs/version-29.4/Configuration.md#testenvironment-string
 4 | export default class FixJSDOMEnvironment extends JSDOMEnvironment {
 5 |   constructor(...args: ConstructorParameters) {
 6 |     super(...args);
 7 | 
 8 |     // FIXME https://github.com/jsdom/jsdom/issues/1724
 9 |     this.global.fetch = fetch;
10 |     this.global.Headers = Headers;
11 |     this.global.Request = Request;
12 |     this.global.Response = Response;
13 |   }
14 | }
15 | 


--------------------------------------------------------------------------------
/jest/resizeObserverMock.js:
--------------------------------------------------------------------------------
1 | Object.defineProperty(global, 'ResizeObserver', {
2 |   writable: true,
3 |   value: jest.fn().mockImplementation(() => ({
4 |     observe: jest.fn(),
5 |     unobserve: jest.fn(),
6 |     disconnect: jest.fn()
7 |   }))
8 | });
9 | 


--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "@terrestris/ol-describe-map",
 3 |   "version": "0.0.9",
 4 |   "description": "Get a configurable textual description of an OpenLayers map, to e.g. enhance accessibility",
 5 |   "keywords": [
 6 |     "openlayers",
 7 |     "ol",
 8 |     "accessibility",
 9 |     "semantic",
10 |     "geo",
11 |     "mapping",
12 |     "cartography"
13 |   ],
14 |   "repository": {
15 |     "type": "git",
16 |     "url": "https://github.com/terrestris/ol-describe-map.git"
17 |   },
18 |   "homepage": "https://terrestris.github.io/ol-describe-map",
19 |   "bugs": "https://github.com/terrestris/ol-describe-map/issues",
20 |   "main": "dist/src/index.js",
21 |   "files": [
22 |     "dist/src"
23 |   ],
24 |   "author": "terrestris GmbH & Co. KG ",
25 |   "contributors": [
26 |     {
27 |       "name": "Marc Jansen",
28 |       "email": "jansen@terrestris.de",
29 |       "url": "https://github.com/marcjansen"
30 |     },
31 |     {
32 |       "name": "Fritz Höing",
33 |       "email": "hoeing@terrestris.de",
34 |       "url": "https://github.com/FritzHoing"
35 |     },
36 |     {
37 |       "name": "Jan Suleiman",
38 |       "email": "suleiman@terrestris.de",
39 |       "url": "https://github.com/jansule"
40 |     },
41 |     {
42 |       "name": "Daniel Koch",
43 |       "email": "koch@terrestris.de",
44 |       "url": "https://github.com/dnlkoch"
45 |     }
46 |   ],
47 |   "license": "BSD-2-Clause",
48 |   "scripts": {
49 |     "clean": "shx rm -rf ./dist ./coverage",
50 |     "copy-example-data": "shx mkdir -p ./dist/examples && shx cp ./examples/germany.geojson ./dist/examples",
51 |     "build": "tsc",
52 |     "build-examples": "vite build --config vite.config.mjs && npm run copy-example-data",
53 |     "build-docs": "typedoc --options typedoc.json",
54 |     "build:all": "npm run build-examples && npm run build-docs && npm run build",
55 |     "lint": "eslint -c .eslintrc.js --ext ts src/",
56 |     "pretest": "npm run typecheck && npm run lint",
57 |     "release": "npm run clean && npm run build:all && release-it",
58 |     "serve-examples": "vite examples",
59 |     "test": "jest",
60 |     "test:watch": "jest --watch",
61 |     "typecheck": "tsc --noEmit --project tsconfig.json"
62 |   },
63 |   "devDependencies": {
64 |     "@babel/core": "^7.24.0",
65 |     "@babel/preset-env": "^7.24.0",
66 |     "@babel/preset-typescript": "^7.23.3",
67 |     "@terrestris/eslint-config-typescript": "^4.0.0",
68 |     "@types/jest": "^29.5.12",
69 |     "babel-jest": "^29.7.0",
70 |     "eslint": "^8.57.0",
71 |     "highlight.js": "^11.9.0",
72 |     "jest": "^29.7.0",
73 |     "jest-environment-jsdom": "^29.7.0",
74 |     "release-it": "^17.1.1",
75 |     "shx": "^0.3.4",
76 |     "ts-node": "^10.9.2",
77 |     "typedoc": "^0.25.12",
78 |     "typescript": "^5.4.2",
79 |     "vite": "^5.1.6"
80 |   },
81 |   "peerDependencies": {
82 |     "ol": "^8.0.0"
83 |   },
84 |   "engines": {
85 |     "node": ">= 18"
86 |   }
87 | }
88 | 


--------------------------------------------------------------------------------
/src/defaultLayerDescriber.test.ts:
--------------------------------------------------------------------------------
  1 | import Feature from 'ol/Feature';
  2 | import Map from 'ol/Map';
  3 | import View from 'ol/View';
  4 | import Point from 'ol/geom/Point';
  5 | import ImageLayer from 'ol/layer/Image';
  6 | import Layer from 'ol/layer/Layer';
  7 | import TileLayer from 'ol/layer/Tile';
  8 | import VectorLayer from 'ol/layer/Vector';
  9 | import ImageWMS from 'ol/source/ImageWMS';
 10 | import OSM from 'ol/source/OSM';
 11 | import TileWMS from 'ol/source/TileWMS';
 12 | import VectorSource from 'ol/source/Vector';
 13 | 
 14 | import { fetchSpy, successResponse } from './fetchSpy';
 15 | import { readFileSync } from 'fs';
 16 | import * as path from 'path';
 17 | 
 18 | import { VectorLayerDetails, WMSLayerDetails } from './types';
 19 | import {
 20 |   defaultLayerDescriber
 21 | } from './defaultLayerDescriber';
 22 | 
 23 | let div;
 24 | let map;
 25 | let view;
 26 | let tileLayer;
 27 | let vectorLayer;
 28 | let tilewms;
 29 | let imagewms;
 30 | 
 31 | const capaFile = path.join(__dirname, '..', 'testdata', 'capabilites-example.xml');
 32 | const capaXML = readFileSync(capaFile).toString();
 33 | 
 34 | describe('defaultLayerDescriber', () => {
 35 |   beforeEach(() => {
 36 |     div = document.createElement('div');
 37 |     document.body.appendChild(div);
 38 | 
 39 |     tileLayer = new TileLayer({
 40 |       source: new OSM(),
 41 |       opacity: 0.33
 42 |     });
 43 |     let vectorSource = new VectorSource();
 44 |     vectorSource.addFeature(new Feature({
 45 |       geometry: new Point([0, 0]),
 46 |       name: 'Null Island',
 47 |       someProp: 1
 48 |     }));
 49 |     vectorLayer = new VectorLayer({
 50 |       source: vectorSource
 51 |     });
 52 | 
 53 |     tilewms = new TileLayer({
 54 |       source: new TileWMS({
 55 |         url: 'some-url',
 56 |         params: {
 57 |           LAYERS: 'layer-number-1-1-2'
 58 |         }
 59 |       })
 60 |     });
 61 |     imagewms = new ImageLayer({
 62 |       source: new ImageWMS({
 63 |         url: 'some-other-url',
 64 |         params: {
 65 |           LAYERS: 'a,b,c'
 66 |         }
 67 |       })
 68 |     });
 69 | 
 70 |     view = new View({
 71 |       center: [0, 0],
 72 |       zoom: 2
 73 |     });
 74 | 
 75 |     map = new Map({
 76 |       layers: [
 77 |         tileLayer
 78 |       ],
 79 |       view,
 80 |       target: div
 81 |     });
 82 |   });
 83 | 
 84 |   afterEach(() => {
 85 |     map.dispose();
 86 |     div.parentNode?.removeChild(div);
 87 |   });
 88 | 
 89 |   test('describes a layer with unknown source', async () => {
 90 |     let got = await defaultLayerDescriber(new Layer({}), view);
 91 |     expect(got.source).toStrictEqual('unknown');
 92 |   });
 93 | 
 94 |   test('describes a tile layer with OSM source', async () => {
 95 |     let got = await defaultLayerDescriber(tileLayer, view);
 96 |     expect(got.source).toStrictEqual('OpenStreetMap');
 97 |   });
 98 | 
 99 |   test('describes a vector layer', async () => {
100 |     let got = await defaultLayerDescriber(vectorLayer, view);
101 |     expect(got.source).toStrictEqual('Vector');
102 |     expect(got.details).not.toBeNull();
103 |     const details = got.details as VectorLayerDetails;
104 |     expect(details.numTotalFeaturesInSource).toBe(1);
105 |     expect(details.numFeaturesInExtent).toBe(1);
106 |     expect(details.numRenderedFeaturesInExtent).toBe(1);
107 |     expect(details.numSkippedFeaturesInExtent).toBe(0);
108 |   });
109 | 
110 |   test('describes a vector layer (wo/ features)', async () => {
111 |     (vectorLayer.getSource() as VectorSource).clear();
112 |     let got = await defaultLayerDescriber(vectorLayer, view);
113 |     expect(got.source).toStrictEqual('Vector');
114 |     expect(got.details).not.toBeNull();
115 |     const details = got.details as VectorLayerDetails;
116 |     expect(details.numTotalFeaturesInSource).toBe(0);
117 |     expect(details.numFeaturesInExtent).toBe(0);
118 |     expect(details.numRenderedFeaturesInExtent).toBe(0);
119 |     expect(details.numSkippedFeaturesInExtent).toBe(0);
120 |   });
121 | 
122 |   test('describes a tiled WMS layer', async () => {
123 |     const mockResponse = successResponse(capaXML);
124 |     fetchSpy(mockResponse);
125 | 
126 |     let got = await defaultLayerDescriber(tilewms, view);
127 |     expect(got.source).toStrictEqual('TileWMS');
128 |     expect(got.details).not.toBeNull();
129 |     const details = got.details as WMSLayerDetails;
130 |     expect(details.serviceAbstract).toBe('Foo Abstract');
131 |   });
132 | 
133 |   test('describes a single tile WMS layer (image)', async () => {
134 |     const mockResponse = successResponse(capaXML);
135 |     fetchSpy(mockResponse);
136 | 
137 |     let got = await defaultLayerDescriber(imagewms, view);
138 |     expect(got.source).toStrictEqual('ImageWMS');
139 |     expect(got.details).not.toBeNull();
140 |     const details = got.details as WMSLayerDetails;
141 |     expect(details.serviceAbstract).toBe('Foo Abstract');
142 |   });
143 | });
144 | 


--------------------------------------------------------------------------------
/src/defaultLayerDescriber.ts:
--------------------------------------------------------------------------------
  1 | import Feature from 'ol/Feature';
  2 | import Layer from 'ol/layer/Layer';
  3 | import ImageLayer from 'ol/layer/Image';
  4 | import TileLayer from 'ol/layer/Tile';
  5 | import VectorLayer from 'ol/layer/Vector';
  6 | import ImageWMS from 'ol/source/ImageWMS';
  7 | import TileWMS from 'ol/source/TileWMS';
  8 | import VectorSource from 'ol/source/Vector';
  9 | import View from 'ol/View';
 10 | import WMSCapabilities from 'ol/format/WMSCapabilities.js';
 11 | 
 12 | import {
 13 |   CapaLayer,
 14 |   LayerDescriberFunc,
 15 |   LayerDescription,
 16 |   MetadataURLObject,
 17 |   VectorLayerDetails,
 18 |   WMSLayerDetails
 19 | } from './types';
 20 | import { determineLayerType } from './determineLayerType';
 21 | import { determineSourceType } from './determineSourceType';
 22 | import {
 23 |   getNameAttribute,
 24 |   getWmsResponse as getPossiblyCachedWmsResponse,
 25 |   simpleStats
 26 | } from './util';
 27 | 
 28 | 
 29 | /**
 30 |  * Returns a basic description of the passed layer.
 31 |  *
 32 |  * @param layer Layer A layer to describe.
 33 |  * @returns LayerDescription A description of the layer.
 34 |  */
 35 | export const defaultLayerDescriber: LayerDescriberFunc = async (layer: Layer, view: View) => {
 36 |   const layerType = determineLayerType(layer);
 37 |   const source = layer.getSource();
 38 |   const sourceType = source == null ? 'unknown' : determineSourceType(source);
 39 |   let details: WMSLayerDetails | VectorLayerDetails | null = null;
 40 |   if (sourceType === 'Vector') {
 41 |     details = determineVectorLayerDetails(layer as VectorLayer, view);
 42 |   }
 43 |   if (sourceType === 'TileWMS') {
 44 |     details = await determineWmsLayerDetails(layer as TileLayer);
 45 |   }
 46 |   if (sourceType === 'ImageWMS') {
 47 |     details = await determineWmsLayerDetails(layer as ImageLayer);
 48 |   }
 49 |   let desc: LayerDescription = {
 50 |     type: layerType,
 51 |     source: sourceType,
 52 |     details: details
 53 |   };
 54 |   return desc;
 55 | };
 56 | 
 57 | const reStartsWithHttpOrHttps = /^https?:\/\//gi;
 58 | const ensureAbsoluteUrl = (u: string) => {
 59 |   if (reStartsWithHttpOrHttps.test(u)) {
 60 |     return u;
 61 |   }
 62 |   let loc = document.location.href;
 63 |   let url = (new URL(u, loc)).href;
 64 |   return url;
 65 | };
 66 | 
 67 | /**
 68 |  *
 69 |  * @param layer
 70 |  * @returns
 71 |  */
 72 | const determineWmsLayerDetails = async (layer: TileLayer | ImageLayer): Promise => {
 73 |   const parser = new WMSCapabilities();
 74 |   let details: WMSLayerDetails = {};
 75 |   const source = layer.getSource();
 76 |   if (source == null) {
 77 |     return details;
 78 |   }
 79 |   const params = source.getParams();
 80 |   let urls;
 81 |   if (source instanceof TileWMS) {
 82 |     urls = source.getUrls();
 83 |   } else if (source instanceof ImageWMS) {
 84 |     urls = [source.getUrl()];
 85 |   }
 86 | 
 87 |   let url = urls && urls[0] ? urls[0] : '';
 88 |   if (!url) {
 89 |     return details;
 90 |   }
 91 | 
 92 |   url = ensureAbsoluteUrl(url);
 93 | 
 94 |   let responseTxt = await getPossiblyCachedWmsResponse(url, {
 95 |     VERSION: params.VERSION || '1.3.0',
 96 |     SERVICE: params.SERVICE || 'WMS',
 97 |     REQUEST: 'GetCapabilities'
 98 |   });
 99 | 
100 |   let json = parser.read(responseTxt);
101 | 
102 |   // add service information
103 |   details.serviceAbstract = json?.Service?.Abstract;
104 |   details.serviceKeywords = json?.Service?.KeywordList;
105 |   details.serviceTitle = json?.Service?.Title;
106 | 
107 |   // add outermost/embracing layer information
108 |   details.topLevelLayerAbstract = json?.Capability?.Layer?.Abstract;
109 |   details.topLevelLayerTitle = json?.Capability?.Layer?.Title;
110 | 
111 |   // add actual layer information, there might be multiple (LAYERS=foo,bar)
112 |   let actualLayers = findLayersByLayerNames(json?.Capability?.Layer, params.LAYERS);
113 |   details.wmsLayerNames = [];
114 |   details.wmsLayerAbstracts = [];
115 |   details.wmsLayerTitles = [];
116 |   details.wmsLayerMetadataURLs = [];
117 | 
118 |   actualLayers.forEach((actualLayer: CapaLayer) => {
119 |     details.wmsLayerNames?.push(actualLayer?.Name || '');
120 |     details.wmsLayerAbstracts?.push(actualLayer?.Abstract || '');
121 |     details.wmsLayerTitles?.push(actualLayer?.Title || '');
122 |     details.wmsLayerMetadataURLs?.push(findBestMetadataURL(actualLayer));
123 |   });
124 | 
125 |   return details;
126 | };
127 | 
128 | /**
129 |  *
130 |  * @param layerCapabilities
131 |  * @returns
132 |  */
133 | const findBestMetadataURL = (layerCapabilities: CapaLayer) => {
134 |   let allURLObjects: MetadataURLObject[] = layerCapabilities?.MetadataURL || [];
135 |   let preferredFormats = ['text/html', 'html', 'text/xml', 'xml'];
136 | 
137 |   let bestUrl = '';
138 |   let bestScore = -Infinity;
139 |   allURLObjects.forEach((oneUrlObj: any) => {
140 |     let currFormat = oneUrlObj.Format;
141 |     let currURL = oneUrlObj.OnlineResource;
142 |     let indexOfCurrFormat = preferredFormats.findIndex(entry => entry === currFormat);
143 |     let currScore = indexOfCurrFormat < 0
144 |       ? indexOfCurrFormat
145 |       : preferredFormats.length - indexOfCurrFormat;
146 | 
147 |     if (currScore > bestScore) {
148 |       bestUrl = currURL;
149 |       bestScore = currScore;
150 |     }
151 |   });
152 |   return bestUrl;
153 | };
154 | 
155 | /**
156 |  *
157 |  * @param capabilityLayer
158 |  * @param layersParam
159 |  * @returns
160 |  */
161 | const findLayersByLayerNames = (capabilityLayer: any, layersParam: string, addTo = []): object[] => {
162 |   let layerNames = (layersParam || '').split(',');
163 |   let jsonLayers: Array = capabilityLayer?.Layer;
164 |   let found: any = addTo;
165 |   jsonLayers?.forEach(jsonLayer => {
166 |     if (layerNames.includes(jsonLayer.Name)) {
167 |       found.push(jsonLayer);
168 |     }
169 |     if (jsonLayer.Layer) {
170 |       found = findLayersByLayerNames(jsonLayer, layersParam, found);
171 |     }
172 |   });
173 | 
174 |   return found;
175 | };
176 | 
177 | /**
178 |  * Determines details for the passed vector layer and current Map view.
179 |  * @param layer
180 |  * @param view
181 |  * @returns
182 |  */
183 | const determineVectorLayerDetails = (layer: VectorLayer, view: View): VectorLayerDetails => {
184 |   let details: VectorLayerDetails = {};
185 |   const source = layer.getSource() as VectorSource;
186 |   const extent = view.calculateExtent();
187 |   const totalFeatures = source.getFeatures();
188 | 
189 |   if (totalFeatures.length === 0) {
190 |     details.numTotalFeaturesInSource = 0;
191 |     details.numFeaturesInExtent = 0;
192 |     details.numRenderedFeaturesInExtent = 0;
193 |     details.numSkippedFeaturesInExtent = 0;
194 |     return details;
195 |   }
196 |   const extentFeatures = source.getFeaturesInExtent(extent);
197 |   const numTotalFeatures = totalFeatures.length;
198 |   const numExtentFeatures = extentFeatures.length;
199 |   const res = view.getResolution();
200 |   const styleFunc = layer.getStyleFunction();
201 | 
202 |   let renderedFeatures: Feature[] = [];
203 |   let skippedFeatures: Feature[] = [];
204 |   if (styleFunc !== undefined && res !== undefined) {
205 |     extentFeatures.forEach(feature => {
206 |       let styles = styleFunc(feature, res);
207 |       if (styles === undefined) {
208 |         skippedFeatures.push(feature);
209 |       } else {
210 |         renderedFeatures.push(feature);
211 |       }
212 |     });
213 |   }
214 | 
215 |   details.numTotalFeaturesInSource = numTotalFeatures;
216 |   details.numFeaturesInExtent = numExtentFeatures;
217 |   details.numRenderedFeaturesInExtent = renderedFeatures.length;
218 |   details.numSkippedFeaturesInExtent = skippedFeatures.length;
219 | 
220 |   let keys: string[]|undefined = undefined;
221 |   let renderedData: object[] = [];
222 |   let statsKeys: string[] = [];
223 |   renderedFeatures.forEach((renderedFeat: Feature) => {
224 |     const featureProps = renderedFeat.getProperties();
225 |     delete featureProps.geometry;
226 |     if (keys === undefined) {
227 |       keys = Object.keys(featureProps);
228 |       for (const key of keys) {
229 |         if (typeof featureProps[key] === 'number') {
230 |           statsKeys.push(key);
231 |         }
232 |       }
233 |     }
234 |     renderedData.push(featureProps);
235 |   });
236 |   let nameAttribute = getNameAttribute(renderedFeatures[0]);
237 |   details.renderedStatistics = simpleStats(renderedData, statsKeys, nameAttribute);
238 |   return details;
239 | };
240 | 
241 | export default defaultLayerDescriber;
242 | 


--------------------------------------------------------------------------------
/src/defaultLayerFilter.ts:
--------------------------------------------------------------------------------
 1 | import { LayerFilterFunc } from './types';
 2 | 
 3 | /**
 4 |  * A basic layer filter, which filters nothing and returns true for any passed layer.
 5 |  *
 6 |  * @returns boolean Always true.
 7 |  */
 8 | export const defaultLayerFilter: LayerFilterFunc = () => true;
 9 | 
10 | export default defaultLayerFilter;
11 | 


--------------------------------------------------------------------------------
/src/defaultTextualDescriber.ts:
--------------------------------------------------------------------------------
 1 | import { layerDescriptionsToText } from './layerDescriptionsToText';
 2 | import {
 3 |   LayerDescription,
 4 |   TextualDescriberFunc,
 5 |   ViewDescription
 6 | } from './types';
 7 | import { viewDescriptionToText } from './viewDescriptionToText';
 8 | 
 9 | /**
10 |  * Returns a basic description based on the view description and any layer
11 |  * descriptions.
12 |  *
13 |  * @param viewDesc ViewDescription A view description.
14 |  * @param layerDescs LayerDescription[] An array of layer descriptions.
15 |  * @returns string A textual description.
16 |  */
17 | export const defaultTextualDescriber: TextualDescriberFunc =
18 | async (viewDesc?: ViewDescription, layerDescs?: LayerDescription[]) => {
19 |   let parts: string[] = [];
20 |   if (viewDesc !== undefined) {
21 |     parts.push(
22 |       ...viewDescriptionToText(viewDesc), ' '
23 |     );
24 |   }
25 |   if (layerDescs !== undefined) {
26 |     parts.push(
27 |       ...layerDescriptionsToText(layerDescs)
28 |     );
29 |   }
30 | 
31 |   return parts.join('');
32 | };
33 | 
34 | export default defaultTextualDescriber;
35 | 


--------------------------------------------------------------------------------
/src/defaultViewDescriber.test.ts:
--------------------------------------------------------------------------------
 1 | import { View, Map } from 'ol';
 2 | import defaultViewDescriber from './defaultViewDescriber';
 3 | import { transform, get, Projection, useGeographic } from 'ol/proj';
 4 | 
 5 | const epsg4326 = get('EPSG:4326') as Projection;
 6 | const epsg3857 = get('EPSG:3857') as Projection;
 7 | const amsterdamIn4326 = [4.890444, 52.370197];
 8 | const amsterdamIn3857 = transform([4.890444, 52.370197], epsg4326, epsg3857);
 9 | 
10 | describe('defaultViewDescriber', () => {
11 |   test('returns a correct view description', async () => {
12 |     let map = new Map({
13 |       layers: [],
14 |       view: new View({
15 |         center: amsterdamIn3857,
16 |         resolution: 1
17 |       })
18 |     });
19 | 
20 |     let viewDescription = await defaultViewDescriber(map.getView());
21 |     expect(viewDescription.center?.[0]).toBeCloseTo(amsterdamIn3857[0], 5);
22 |     expect(viewDescription.center?.[1]).toBeCloseTo(amsterdamIn3857[1], 5);
23 |     expect(viewDescription.epsg4326?.center?.[0]).toBeCloseTo(amsterdamIn4326[0], 5);
24 |     expect(viewDescription.epsg4326?.center?.[1]).toBeCloseTo(amsterdamIn4326[1], 5);
25 |   });
26 | 
27 |   test('cannot be tricked by useGeographic', async () => {
28 |     useGeographic();
29 |     let map = new Map({
30 |       layers: [],
31 |       view: new View({
32 |         center: amsterdamIn4326,
33 |         resolution: 1
34 |       })
35 |     });
36 | 
37 |     let viewDescription = await defaultViewDescriber(map.getView());
38 | 
39 |     expect(viewDescription.center?.[0]).toBeCloseTo(amsterdamIn4326[0], 5);
40 |     expect(viewDescription.center?.[1]).toBeCloseTo(amsterdamIn4326[1], 5);
41 | 
42 |     expect(viewDescription.epsg4326?.center?.[0]).toBeCloseTo(amsterdamIn4326[0], 5);
43 |     expect(viewDescription.epsg4326?.center?.[1]).toBeCloseTo(amsterdamIn4326[1], 5);
44 |   });
45 | });
46 | 


--------------------------------------------------------------------------------
/src/defaultViewDescriber.ts:
--------------------------------------------------------------------------------
 1 | import { View } from 'ol';
 2 | import { ViewDescriberFunc } from './types';
 3 | import { METERS_PER_UNIT, Projection, get, getUserProjection, transform } from 'ol/proj';
 4 | import { Units } from 'ol/proj/Units';
 5 | import { Extent } from 'ol/extent';
 6 | 
 7 | const calculateScale = (view: View): number => {
 8 |   const unit: Units = view.getProjection().getUnits();
 9 |   const resolution: number = view.getResolution() || 1;
10 |   const inchesPerMetre = 39.37;
11 |   const dpi = 90;
12 |   let mpu = 1;
13 |   if (unit !== 'pixels' && unit !== 'tile-pixels') {
14 |     mpu = METERS_PER_UNIT[unit];
15 |   }
16 |   return resolution * mpu * inchesPerMetre * dpi;
17 | };
18 | 
19 | 
20 | const get4326Coordinates = (bbox: number[], center: number[], proj: Projection) => {
21 |   const epsg4326 = get('EPSG:4326');
22 |   if (epsg4326 === null || proj.getCode() === epsg4326?.getCode()) {
23 |     return {
24 |       bbox,
25 |       center
26 |     };
27 |   }
28 |   let ll = [bbox[0], bbox[1]];
29 |   let ur = [bbox[2], bbox[3]];
30 |   return {
31 |     bbox: [...transform(ll, proj, epsg4326), ...transform(ur, proj, epsg4326)],
32 |     center: transform(center, proj, epsg4326)
33 |   };
34 | };
35 | 
36 | 
37 | /**
38 |  * A basic view describer.
39 |  *
40 |  * @param view View An OpenLayers view to describe.
41 |  * @returns ViewDescription A description of the view.
42 |  */
43 | export const defaultViewDescriber: ViewDescriberFunc = async (view: View) => {
44 |   const bbox = view.calculateExtent() as Extent;
45 |   const center = view.getCenter() as number[];
46 |   const viewProjection = view.getProjection();
47 |   const userProjection = getUserProjection();
48 | 
49 |   // viewProjection isn't necessarily the projection of the center and bbox coordinates,
50 |   // because a user might have set a userProjection (e.g. via useGeographic())
51 |   // and in that case the bbox and the center would already be in the userProjection
52 |   const epsg4326 = get4326Coordinates(bbox, center, userProjection || viewProjection);
53 |   let viewDesc = {
54 |     bbox,
55 |     center,
56 |     viewProjection: viewProjection ? viewProjection.getCode() : undefined,
57 |     userProjection: userProjection ? userProjection.getCode() : undefined,
58 |     rotation: view.getRotation(),
59 |     zoom: view.getZoom(),
60 |     scale: calculateScale(view),
61 |     epsg4326
62 |   };
63 |   return viewDesc;
64 | };
65 | 
66 | export default defaultViewDescriber;
67 | 


--------------------------------------------------------------------------------
/src/determineLayerType.test.ts:
--------------------------------------------------------------------------------
 1 | import { determineLayerType } from './determineLayerType';
 2 | 
 3 | import Layer from 'ol/layer/Layer';
 4 | import BaseImageLayer from 'ol/layer/BaseImage';
 5 | import BaseTileLayer from 'ol/layer/BaseTile';
 6 | import BaseVectorLayer from 'ol/layer/BaseVector';
 7 | 
 8 | describe('determineLayerType', () => {
 9 |   test('detects BaseImageLayer', () => {
10 |     const layer = new BaseImageLayer();
11 |     const detected = determineLayerType(layer);
12 |     expect(detected).toMatch(/image/gi);
13 |   });
14 |   test('detects BaseTileLayer', () => {
15 |     const layer = new BaseTileLayer();
16 |     const detected = determineLayerType(layer);
17 |     expect(detected).toMatch(/tile/gi);
18 |   });
19 |   test('detects BaseVectorLayer', () => {
20 |     const layer = new BaseVectorLayer();
21 |     const detected = determineLayerType(layer);
22 |     expect(detected).toMatch(/vector/gi);
23 |   });
24 |   test('has unknown as fallback', () => {
25 |     const layer = new Layer({});
26 |     const detected = determineLayerType(layer);
27 |     expect(detected).toStrictEqual('unknown');
28 |   });
29 | });
30 | 


--------------------------------------------------------------------------------
/src/determineLayerType.ts:
--------------------------------------------------------------------------------
 1 | import Layer from 'ol/layer/Layer';
 2 | import BaseImageLayer from 'ol/layer/BaseImage';
 3 | import BaseTileLayer from 'ol/layer/BaseTile';
 4 | import BaseVectorLayer from 'ol/layer/BaseVector';
 5 | 
 6 | export const determineLayerType = (layer: Layer): string => {
 7 |   let layerType = 'unknown';
 8 | 
 9 |   if (layer instanceof BaseImageLayer) {
10 |     layerType = 'image-layer (server-rendered & for arbitrary extents and resolutions)';
11 |   } else if (layer instanceof BaseTileLayer) {
12 |     layerType = 'tile-layer (pre-rendered, tiled images in grids organized by zoom levels)';
13 |   } else if (layer instanceof BaseVectorLayer) {
14 |     layerType = 'vector-layer (vector data that is rendered client-side)';
15 |   }
16 | 
17 |   return layerType;
18 | };
19 | 


--------------------------------------------------------------------------------
/src/determineSourceType.test.ts:
--------------------------------------------------------------------------------
  1 | import { determineSourceType } from './determineSourceType';
  2 | 
  3 | import Source from 'ol/source/Source';
  4 | 
  5 | import BingMaps from 'ol/source/BingMaps';
  6 | import Cluster from 'ol/source/Cluster';
  7 | import Vector from 'ol/source/Vector';
  8 | import GeoTIFF from 'ol/source/GeoTIFF';
  9 | import DataTile from 'ol/source/DataTile';
 10 | import IIIF from 'ol/source/IIIF';
 11 | import ImageArcGISRest from 'ol/source/ImageArcGISRest';
 12 | import ImageCanvas from 'ol/source/ImageCanvas';
 13 | import ImageMapGuide from 'ol/source/ImageMapGuide';
 14 | import ImageStatic from 'ol/source/ImageStatic';
 15 | import ImageWMS from 'ol/source/ImageWMS';
 16 | import OGCMapTile from 'ol/source/OGCMapTile';
 17 | import OGCVectorTile from 'ol/source/OGCVectorTile';
 18 | import MVT from 'ol/format/MVT';
 19 | import VectorTile from 'ol/source/VectorTile';
 20 | import UrlTile from 'ol/source/UrlTile';
 21 | import CartoDB from 'ol/source/CartoDB';
 22 | import OSM from 'ol/source/OSM';
 23 | import StadiaMaps from 'ol/source/StadiaMaps';
 24 | import TileDebug from 'ol/source/TileDebug.js';
 25 | import XYZ from 'ol/source/XYZ';
 26 | import Raster from 'ol/source/Raster';
 27 | import TileArcGISRest from 'ol/source/TileArcGISRest';
 28 | import TileJSON from 'ol/source/TileJSON';
 29 | import TileWMS from 'ol/source/TileWMS';
 30 | import UTFGrid from 'ol/source/UTFGrid';
 31 | import WMTS from 'ol/source/WMTS';
 32 | import Zoomify from 'ol/source/Zoomify';
 33 | import WMTSTileGrid from 'ol/tilegrid/WMTS';
 34 | 
 35 | let globalFetch: any = null;
 36 | 
 37 | describe('determineSourceType', () => {
 38 |   beforeEach(() => {
 39 |     globalFetch = global.fetch;
 40 |     global.fetch = async (): Promise => {
 41 |       return new Response('{}');
 42 |     };
 43 |   });
 44 |   afterEach(() => {
 45 |     global.fetch = globalFetch;
 46 |   });
 47 | 
 48 |   test('detects BaseImageLayer', () => {
 49 |     const source = new BingMaps({key: '', imagerySet: 'Aerial'});
 50 |     const detected = determineSourceType(source);
 51 |     expect(detected).toMatch(/BingMaps/gi);
 52 |   });
 53 |   test('detects Cluster', () => {
 54 |     const source = new Cluster({});
 55 |     const detected = determineSourceType(source);
 56 |     expect(detected).toMatch(/Cluster/gi);
 57 |   });
 58 |   test('detects Vector', () => {
 59 |     const source = new Vector();
 60 |     const detected = determineSourceType(source);
 61 |     expect(detected).toMatch(/Vector/gi);
 62 |   });
 63 |   test('detects DataTile', () => {
 64 |     const source = new DataTile({});
 65 |     const detected = determineSourceType(source);
 66 |     expect(detected).toMatch(/DataTile/gi);
 67 |   });
 68 |   test('detects IIIF', () => {
 69 |     const source = new IIIF({size: [1, 1]});
 70 |     const detected = determineSourceType(source);
 71 |     expect(detected).toMatch(/IIIF/gi);
 72 |   });
 73 |   test('detects ImageArcGISRest', () => {
 74 |     const source = new ImageArcGISRest({});
 75 |     const detected = determineSourceType(source);
 76 |     expect(detected).toMatch(/ImageArcGISRest/gi);
 77 |   });
 78 |   test('detects ImageCanvas', () => {
 79 |     const source = new ImageCanvas();
 80 |     const detected = determineSourceType(source);
 81 |     expect(detected).toMatch(/ImageCanvas/gi);
 82 |   });
 83 |   test('detects ImageMapGuide', () => {
 84 |     const source = new ImageMapGuide({});
 85 |     const detected = determineSourceType(source);
 86 |     expect(detected).toMatch(/ImageMapGuide/gi);
 87 |   });
 88 |   test('detects ImageStatic', () => {
 89 |     const source = new ImageStatic({url:'', imageExtent:[0,0,1,1]});
 90 |     const detected = determineSourceType(source);
 91 |     expect(detected).toMatch(/ImageStatic/gi);
 92 |   });
 93 |   test('detects ImageWMS', () => {
 94 |     const source = new ImageWMS();
 95 |     const detected = determineSourceType(source);
 96 |     expect(detected).toMatch(/ImageWMS/gi);
 97 |   });
 98 |   test('detects OGCMapTile', () => {
 99 |     const source = new OGCMapTile({url: ''});
100 |     const detected = determineSourceType(source);
101 |     expect(detected).toMatch(/OGCMapTile/gi);
102 |   });
103 |   // test('detects OGCVectorTile', () => {
104 |   //   const source = new OGCVectorTile({
105 |   //     url: 'https://maps.gnosis.earth/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0
106 |   //     countries/tiles/WebMercatorQuad',
107 |   //     format: new MVT()
108 |   //   });
109 |   //   const detected = determineSourceType(source);
110 |   //   expect(detected).toMatch(/OGCVectorTile/gi);
111 |   // });
112 |   test('detects VectorTile', () => {
113 |     const source = new VectorTile({});
114 |     const detected = determineSourceType(source);
115 |     expect(detected).toMatch(/VectorTile/gi);
116 |   });
117 |   test('detects UrlTile', () => {
118 |     const source = new UrlTile({tileLoadFunction:()=>{}});
119 |     const detected = determineSourceType(source);
120 |     expect(detected).toMatch(/UrlTile/gi);
121 |   });
122 |   test('detects CartoDB', () => {
123 |     const source = new CartoDB({});
124 |     const detected = determineSourceType(source);
125 |     expect(detected).toMatch(/CartoDB/gi);
126 |   });
127 |   test('detects OpenStreetMap', () => {
128 |     const source = new OSM();
129 |     const detected = determineSourceType(source);
130 |     expect(detected).toMatch(/OpenStreetMap/gi);
131 |   });
132 |   test('detects StadiaMaps', () => {
133 |     const source = new StadiaMaps({layer: 'stamen_terrain', apiKey: '', retina: false});
134 |     const detected = determineSourceType(source);
135 |     expect(detected).toMatch(/StadiaMaps/gi);
136 |   });
137 |   test('detects TileDebug', () => {
138 |     const source = new TileDebug();
139 |     const detected = determineSourceType(source);
140 |     expect(detected).toMatch(/TileDebug/gi);
141 |   });
142 |   test('detects XYZ', () => {
143 |     const source = new XYZ();
144 |     const detected = determineSourceType(source);
145 |     expect(detected).toMatch(/XYZ/gi);
146 |   });
147 |   test('detects Raster', () => {
148 |     const source = new Raster({sources: [new XYZ()]});
149 |     const detected = determineSourceType(source);
150 |     expect(detected).toMatch(/Raster/gi);
151 |   });
152 |   test('detects TileArcGISRest', () => {
153 |     const source = new TileArcGISRest();
154 |     const detected = determineSourceType(source);
155 |     expect(detected).toMatch(/TileArcGISRest/gi);
156 |   });
157 |   test('detects TileJSON', () => {
158 |     const source = new TileJSON({url: 'foo'});
159 |     const detected = determineSourceType(source);
160 |     expect(detected).toMatch(/TileJSON/gi);
161 |   });
162 |   test('detects TileWMS', () => {
163 |     const source = new TileWMS();
164 |     const detected = determineSourceType(source);
165 |     expect(detected).toMatch(/TileWMS/gi);
166 |   });
167 |   test('detects UTFGrid', () => {
168 |     const source = new UTFGrid({url: 'foo'});
169 |     const detected = determineSourceType(source);
170 |     expect(detected).toMatch(/UTFGrid/gi);
171 |   });
172 |   test('detects WMTS', () => {
173 |     const source = new WMTS({
174 |       tileGrid:  new WMTSTileGrid({
175 |         resolutions: [1],
176 |         matrixIds: ['1'],
177 |         origin: [0, 0]
178 |       }),
179 |       layer: '',
180 |       style: '',
181 |       matrixSet: ''
182 |     });
183 |     const detected = determineSourceType(source);
184 |     expect(detected).toMatch(/WMTS/gi);
185 |   });
186 |   test('detects Zoomify', () => {
187 |     const source = new Zoomify({url: '', size: [1, 1]});
188 |     const detected = determineSourceType(source);
189 |     expect(detected).toMatch(/Zoomify/gi);
190 |   });
191 | });
192 | 


--------------------------------------------------------------------------------
/src/determineSourceType.ts:
--------------------------------------------------------------------------------
 1 | import Source from 'ol/source/Source';
 2 | 
 3 | import BingMaps from 'ol/source/BingMaps';
 4 | import Cluster from 'ol/source/Cluster';
 5 | import Vector from 'ol/source/Vector';
 6 | // import GeoTIFF from 'ol/source/GeoTIFF';
 7 | import DataTile from 'ol/source/DataTile';
 8 | import IIIF from 'ol/source/IIIF';
 9 | import ImageArcGISRest from 'ol/source/ImageArcGISRest';
10 | import ImageCanvas from 'ol/source/ImageCanvas';
11 | import ImageMapGuide from 'ol/source/ImageMapGuide';
12 | import ImageStatic from 'ol/source/ImageStatic';
13 | import ImageWMS from 'ol/source/ImageWMS';
14 | import OGCMapTile from 'ol/source/OGCMapTile';
15 | import OGCVectorTile from 'ol/source/OGCVectorTile';
16 | import VectorTile from 'ol/source/VectorTile';
17 | import UrlTile from 'ol/source/UrlTile';
18 | import CartoDB from 'ol/source/CartoDB';
19 | import OSM from 'ol/source/OSM';
20 | import StadiaMaps from 'ol/source/StadiaMaps';
21 | import TileDebug from 'ol/source/TileDebug.js';
22 | import XYZ from 'ol/source/XYZ';
23 | import Raster from 'ol/source/Raster';
24 | import TileArcGISRest from 'ol/source/TileArcGISRest';
25 | import TileJSON from 'ol/source/TileJSON';
26 | import TileWMS from 'ol/source/TileWMS';
27 | import UTFGrid from 'ol/source/UTFGrid';
28 | import WMTS from 'ol/source/WMTS';
29 | import Zoomify from 'ol/source/Zoomify';
30 | 
31 | 
32 | export const determineSourceType = (source: Source): string => {
33 |   let sourceType = 'unknown';
34 | 
35 |   if (source instanceof BingMaps) {
36 |     sourceType = 'BingMaps';
37 |   } else if (source instanceof Cluster) { // check Cluster first => child of vector
38 |     sourceType = 'Cluster';
39 |   } else if (source instanceof Vector) {
40 |     sourceType = 'Vector';
41 |   // Throws error when imported... TODO investigate
42 |   // } else if (source instanceof GeoTIFF) { // check GeoTIFF first => child of DataTile
43 |   //   sourceType = 'GeoTIFF';
44 |   } else if (source instanceof DataTile) {
45 |     sourceType = 'DataTile';
46 |   } else if (source instanceof IIIF) {
47 |     sourceType = 'IIIF';
48 |   } else if (source instanceof ImageArcGISRest) {
49 |     sourceType = 'ImageArcGISRest';
50 |   } else if (source instanceof ImageCanvas) {
51 |     sourceType = 'ImageCanvas';
52 |   } else if (source instanceof ImageMapGuide) {
53 |     sourceType = 'ImageMapGuide';
54 |   } else if (source instanceof ImageStatic) {
55 |     sourceType = 'ImageStatic';
56 |   } else if (source instanceof ImageWMS) {
57 |     sourceType = 'ImageWMS';
58 |   } else if (source instanceof OGCMapTile) {
59 |     sourceType = 'OGCMapTile';
60 |   } else if (source instanceof OGCVectorTile) { // check OGCVectorTile first => child of VectorTile
61 |     sourceType = 'OGCVectorTile'; // untested as of now
62 |   } else if (source instanceof VectorTile) {
63 |     sourceType = 'VectorTile';
64 |   } else if (source instanceof CartoDB) { // Check CartoDB first => child of XYZ
65 |     sourceType = 'CartoDB';
66 |   } else if (source instanceof OSM) { // Check OSM first => child of XYZ
67 |     sourceType = 'OpenStreetMap';
68 |   } else if (source instanceof StadiaMaps) { // Check StadiaMaps first => child of XYZ
69 |     sourceType = 'StadiaMaps';
70 |   } else if (source instanceof TileDebug) { // Check TileDebug first => child of XYZ
71 |     sourceType = 'TileDebug';
72 |   } else if (source instanceof XYZ) {
73 |     sourceType = 'XYZ';
74 |   } else if (source instanceof Raster) {
75 |     sourceType = 'Raster';
76 |   } else if (source instanceof TileArcGISRest) {
77 |     sourceType = 'TileArcGISRest';
78 |   } else if (source instanceof TileJSON) {
79 |     sourceType = 'TileJSON';
80 |   } else if (source instanceof TileWMS) {
81 |     sourceType = 'TileWMS';
82 |   } else if (source instanceof UTFGrid) {
83 |     sourceType = 'UTFGrid';
84 |   } else if (source instanceof WMTS) {
85 |     sourceType = 'WMTS';
86 |   } else if (source instanceof Zoomify) {
87 |     sourceType = 'Zoomify';
88 |   } else if (source instanceof UrlTile) {
89 |     sourceType = 'UrlTile';
90 |   }
91 |   return sourceType;
92 | };
93 | 


--------------------------------------------------------------------------------
/src/fetchSpy.ts:
--------------------------------------------------------------------------------
 1 | export const successResponse = (text?: any, status: number = 200): Partial => {
 2 |   return {
 3 |     ok: true,
 4 |     status: status,
 5 |     text: () => Promise.resolve(text)
 6 |   };
 7 | };
 8 | 
 9 | // not used for now, and as such this should be commented out, also to better reflect
10 | // the actual test coverage
11 | // export const failureResponse = (text?: any, status: number = 500): Partial => {
12 | //   return {
13 | //     ok: false,
14 | //     status: status,
15 | //     text: () => Promise.resolve(text)
16 | //   };
17 | // };
18 | 
19 | export const fetchSpy = (response: Partial) => {
20 |   return jest
21 |     .spyOn(global, 'fetch')
22 |     .mockImplementation(jest.fn(() => {
23 |       return Promise.resolve(response);
24 |     }) as jest.Mock);
25 | };
26 | 
27 | export default fetchSpy;
28 | 


--------------------------------------------------------------------------------
/src/index.test.ts:
--------------------------------------------------------------------------------
 1 | import Map from 'ol/Map';
 2 | import Layer from 'ol/layer/Layer';
 3 | import Source from 'ol/source/Source';
 4 | import View from 'ol/View';
 5 | 
 6 | import { describe as describeOlMap } from '.';
 7 | 
 8 | const makeLayer = (): Layer => {
 9 |   let source = new Source({});
10 |   return new Layer({source});
11 | };
12 | 
13 | let map: Map;
14 | let div: HTMLDivElement;
15 | 
16 | describe('describe function', () => {
17 |   beforeEach(() =>  {
18 |     div = document.createElement('div');
19 |     document.body.appendChild(div);
20 |     map = new Map({
21 |       layers: [
22 |         makeLayer(),
23 |         makeLayer()
24 |       ],
25 |       view: new View({
26 |         center: [0, 0],
27 |         resolution: 1,
28 |         rotation: -Math.PI/4
29 |       }),
30 |       target: div
31 |     });
32 |   });
33 |   afterEach(() => {
34 |     map.dispose();
35 |     div.parentNode?.removeChild(div);
36 |   });
37 | 
38 |   test('basic functionality', async () => {
39 |     let description = await describeOlMap(map);
40 |     expect(description.text).not.toBe('');
41 |     expect(description.view?.bbox).toBeInstanceOf(Array);
42 |     expect(description.view?.viewProjection).toStrictEqual('EPSG:3857');
43 |     expect(description.view?.userProjection).toBe(undefined);
44 |     expect(description.layers?.length).not.toBe(0);
45 |   });
46 |   describe('default describers', () => {
47 |     test('default textual description', async () => {
48 |       let description = await describeOlMap(map);
49 |       expect(description.text).toContain('coordinate [0, 0]');
50 |       expect(description.text).toContain('counter-clockwise');
51 |       expect(description.text).toContain('first');
52 |       expect(description.text).toContain('second');
53 |       expect(description.text).toContain('top-most');
54 |     });
55 |     test('default view description', async () => {
56 |       let expectedBbox = map.getView().calculateExtent();
57 |       let expectedProj = map.getView().getProjection().getCode();
58 |       let description = await describeOlMap(map);
59 |       expect(description.view?.bbox).toStrictEqual(expectedBbox);
60 |       expect(description.view?.viewProjection).toStrictEqual(expectedProj);
61 |     });
62 |     test('default layer description', async () => {
63 |       let description = await describeOlMap(map);
64 |       const expectedLen = map.getAllLayers().length;
65 |       expect(description.layers?.length).toBe(expectedLen);
66 |       expect(description.layers?.[0].type).toStrictEqual('unknown');
67 |     });
68 |   });
69 |   test('disabling viewDescriber', async () => {
70 |     let description = await describeOlMap(map, {viewDescriber: null});
71 |     let emptyViewDescription = {};
72 |     expect(description.view).toStrictEqual(emptyViewDescription);
73 |   });
74 |   test('disabling layerDescriber', async () => {
75 |     let description = await describeOlMap(map, {layerDescriber: null});
76 |     let emptyLayerDescription = {details: null, source: '', type: ''};
77 |     let expected = [emptyLayerDescription, emptyLayerDescription];
78 |     expect(description.layers).toStrictEqual(expected);
79 |   });
80 | });
81 | 
82 | 


--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
 1 | import Map from 'ol/Map';
 2 | 
 3 | import {
 4 |   DescribeConfiguration,
 5 |   LayerDescriberFunc,
 6 |   ViewDescriberFunc
 7 | } from './types';
 8 | 
 9 | import defaultLayerFilter from './defaultLayerFilter';
10 | import defaultViewDescriber from './defaultViewDescriber';
11 | import defaultLayerDescriber from './defaultLayerDescriber';
12 | import defaultTextualDescriber from './defaultTextualDescriber';
13 | import { voidLayersDescriber, voidViewDescriber } from './util';
14 | 
15 | /**
16 |  * Describes the passed map according to the passed configuration and returns that
17 |  * description. By default also the 'aria-description' attribute of the map's DOM
18 |  * element is updated.
19 |  *
20 |  * @param map Map An OpenLayers Map you want to have a description for.
21 |  * @param conf DescribeConfiguration A configuration how you want the
22 |  *   map to be described and whether the 'aria-description' in the DOM
23 |  *   should directly be updated.
24 |  * @returns MapDescription A map description object.
25 |  */
26 | export async function describe(map: Map, conf: DescribeConfiguration = {}) {
27 |   let finalViewDescriber: ViewDescriberFunc;
28 |   let finalLayerDescriber: LayerDescriberFunc;
29 | 
30 |   const {
31 |     layerFilter = defaultLayerFilter,
32 |     viewDescriber = defaultViewDescriber,
33 |     layerDescriber = defaultLayerDescriber,
34 |     textualDescriber = defaultTextualDescriber,
35 |     updateAriaDescription = true
36 |   } = conf;
37 | 
38 |   if (viewDescriber == null) {
39 |     finalViewDescriber = voidViewDescriber;
40 |   } else {
41 |     finalViewDescriber = viewDescriber;
42 |   }
43 |   if (layerDescriber == null) {
44 |     finalLayerDescriber = voidLayersDescriber;
45 |   } else {
46 |     finalLayerDescriber = layerDescriber;
47 |   }
48 | 
49 |   const view = map.getView();
50 |   let layers = map.getAllLayers().filter(layerFilter);
51 |   let viewDescription = await finalViewDescriber(view);
52 |   let layerDescriptions = await Promise.all(layers.map(async (layer) => {
53 |     return await finalLayerDescriber(layer, view);
54 |   }));
55 |   let textualDescription = await textualDescriber(viewDescription, layerDescriptions);
56 | 
57 |   const targetElement = map.getTargetElement();
58 |   if (updateAriaDescription && targetElement) {
59 |     targetElement.setAttribute('aria-description', textualDescription);
60 |   }
61 | 
62 |   return {
63 |     text: textualDescription,
64 |     view: viewDescription,
65 |     layers: layerDescriptions
66 |   };
67 | };
68 | 


--------------------------------------------------------------------------------
/src/layerDescriptionsToText.test.ts:
--------------------------------------------------------------------------------
  1 | import Source from 'ol/source/Source';
  2 | import {
  3 |   layerDescriptionsToText
  4 | } from './layerDescriptionsToText';
  5 | import { LayerDescription, WMSLayerDetails } from './types';
  6 | import { determineLayerType } from './determineLayerType';
  7 | import { determineSourceType } from './determineSourceType';
  8 | import TileLayer from 'ol/layer/Tile';
  9 | import VectorLayer from 'ol/layer/Vector';
 10 | import VectorSource from 'ol/source/Vector';
 11 | import TileWMS from 'ol/source/TileWMS';
 12 | 
 13 | 
 14 | let tileLayerBasicDesc: LayerDescription = {
 15 |   details: null,
 16 |   type: determineLayerType(new TileLayer()),
 17 |   source: determineSourceType(new Source({}))
 18 | };
 19 | 
 20 | let vectorLayerBasicDesc: LayerDescription = {
 21 |   details: null,
 22 |   type: determineLayerType(new VectorLayer()),
 23 |   source: determineSourceType(new VectorSource({}))
 24 | };
 25 | 
 26 | let vectorLayerDesc: LayerDescription = {
 27 |   details: {
 28 |     numFeaturesInExtent: 4,
 29 |     numRenderedFeaturesInExtent: 1,
 30 |     numSkippedFeaturesInExtent: 3,
 31 |     numTotalFeaturesInSource: 8,
 32 |     renderedStatistics: {
 33 |       propFoo: {
 34 |         min: 1,
 35 |         minName: 'Luffy',
 36 |         max: 1,
 37 |         maxName: 'Luffy',
 38 |         avg: 1,
 39 |         sum: 1
 40 |       }
 41 |     }
 42 |   },
 43 |   type: determineLayerType(new VectorLayer()),
 44 |   source: determineSourceType(new VectorSource({}))
 45 | };
 46 | 
 47 | let vectorLayerDescMoreFeatures: LayerDescription = {
 48 |   details: {
 49 |     numFeaturesInExtent: 4,
 50 |     numRenderedFeaturesInExtent: 3,
 51 |     numSkippedFeaturesInExtent: 1,
 52 |     numTotalFeaturesInSource: 8,
 53 |     renderedStatistics: {
 54 |       someProp: {
 55 |         min: 1,
 56 |         minName: 'min value',
 57 |         max: 3,
 58 |         maxName: 'MAX VALUE',
 59 |         avg: 2,
 60 |         sum: 6
 61 |       },
 62 |       anotherOne: {
 63 |         min: -10,
 64 |         minName: 'min',
 65 |         max: 10,
 66 |         maxName: 'MAX',
 67 |         avg: 0,
 68 |         sum: 0
 69 |       }
 70 |     }
 71 |   },
 72 |   type: determineLayerType(new VectorLayer()),
 73 |   source: determineSourceType(new VectorSource({}))
 74 | };
 75 | 
 76 | let vectorLayerFeaturesWithoutNames: LayerDescription = {
 77 |   details: {
 78 |     numFeaturesInExtent: 2,
 79 |     numRenderedFeaturesInExtent: 2,
 80 |     numSkippedFeaturesInExtent: 2,
 81 |     numTotalFeaturesInSource: 2,
 82 |     renderedStatistics: {
 83 |       scribble: {
 84 |         min: 1,
 85 |         max: 1,
 86 |         avg: 1,
 87 |         sum: 1
 88 |       }
 89 |     }
 90 |   },
 91 |   type: determineLayerType(new VectorLayer()),
 92 |   source: determineSourceType(new VectorSource({}))
 93 | };
 94 | 
 95 | let singleWmsLayerDesc: LayerDescription = {
 96 |   details: {
 97 |     serviceAbstract: 'Foo Abstract',
 98 |     serviceKeywords: [ 'Service keyword 1', 'Service keyword 2' ],
 99 |     serviceTitle: 'Foo',
100 |     topLevelLayerAbstract: 'The abstract of layer-number-1',
101 |     topLevelLayerTitle: 'The title of layer-number-1',
102 |     wmsLayerNames: [ 'a' ],
103 |     wmsLayerAbstracts: [ 'The abstract of a' ],
104 |     wmsLayerTitles: [ 'Title of a' ],
105 |     wmsLayerMetadataURLs: [
106 |       'http://www.example.com/metadata/a.xml'
107 |     ]
108 |   },
109 |   type: determineLayerType(new TileLayer()),
110 |   source: determineSourceType(new TileWMS({params:{}}))
111 | };
112 | 
113 | let multiWmsLayerDesc: LayerDescription = {
114 |   details: {
115 |     serviceAbstract: 'Foo Abstract',
116 |     serviceKeywords: [ 'Service keyword 1', 'Service keyword 2' ],
117 |     serviceTitle: 'Foo',
118 |     topLevelLayerAbstract: 'The abstract of layer-number-1',
119 |     topLevelLayerTitle: 'The title of layer-number-1',
120 |     wmsLayerNames: [ 'a', 'b' ],
121 |     wmsLayerAbstracts: [ 'The abstract of a', 'The abstract of b' ],
122 |     wmsLayerTitles: [ 'Title of a', 'Title of b' ],
123 |     wmsLayerMetadataURLs: [
124 |       'http://www.example.com/metadata/a.xml',
125 |       'http://www.example.com/metadata/b.xml'
126 |     ]
127 |   },
128 |   type: determineLayerType(new TileLayer()),
129 |   source: determineSourceType(new TileWMS({params:{}}))
130 | };
131 | 
132 | describe('layerDescriptionsToText', () => {
133 |   test('describes a very basic tile layer', () => {
134 |     let descriptions: LayerDescription[] = [
135 |       tileLayerBasicDesc
136 |     ];
137 |     let got: string[] = layerDescriptionsToText(descriptions);
138 |     expect(got.length).toBeGreaterThanOrEqual(1);
139 |     let text = got.join('');
140 |     expect(text).toMatch(/1 layer./);
141 |     expect(text).toMatch(/tile-layer/);
142 |     expect(text).toMatch(/unknown-source/);
143 |   });
144 | 
145 |   test('describes a very basic vector layer without details', () => {
146 |     let descriptions: LayerDescription[] = [
147 |       vectorLayerBasicDesc
148 |     ];
149 |     let got: string[] = layerDescriptionsToText(descriptions);
150 |     expect(got.length).toBeGreaterThanOrEqual(1);
151 |     let text = got.join('');
152 |     expect(text).toMatch(/1 layer./);
153 |     expect(text).toMatch(/vector-layer/);
154 |     expect(text).toMatch(/Vector-source/);
155 |   });
156 | 
157 |   test('describes details for vector layers', () => {
158 |     let descriptions: LayerDescription[] = [
159 |       vectorLayerDesc
160 |     ];
161 |     let got: string[] = layerDescriptionsToText(descriptions);
162 |     expect(got.length).toBeGreaterThanOrEqual(1);
163 |     let text = got.join('');
164 |     expect(text).toMatch(/1 layer./);
165 |     expect(text).toMatch(/vector-layer/);
166 |     expect(text).toMatch(/Vector-source/);
167 |     expect(text).toMatch(/contains 8 features/);
168 |     expect(text).toMatch(/4 \(50%\) intersect/);
169 |     expect(text).toMatch(/rendered was 1 \(25%\) feature/);
170 |     expect(text).toMatch(/basic statistical information/);
171 |     expect(text).toMatch(/propFoo/);
172 |     expect(text).toMatch(/the value is 1/);
173 |     expect(text).toMatch(/named 'Luffy'/);
174 |   });
175 | 
176 |   test('describes details for vector layers (part 2)', () => {
177 |     let descriptions: LayerDescription[] = [
178 |       vectorLayerDesc, vectorLayerDescMoreFeatures
179 |     ];
180 |     let got: string[] = layerDescriptionsToText(descriptions);
181 |     expect(got.length).toBeGreaterThanOrEqual(1);
182 |     let text = got.join('');
183 |     expect(text).toMatch(/2 layers./);
184 |     expect(text).toMatch(/first/);
185 |     expect(text).toMatch(/second/);
186 |     expect(text).toMatch(/'someProp', 'anotherOne'/);
187 |     expect(text).toMatch(/minimal value is 1 \(feature named 'min value'\)/);
188 |     expect(text).toMatch(/maximum value is 3 \(for the feature with name 'MAX VALUE'\)/);
189 |     expect(text).toMatch(/average value is 2 and the sum is 6/);
190 |     expect(text).toMatch(/minimal value is -10 \(feature named 'min'\)/);
191 |     expect(text).toMatch(/maximum value is 10 \(for the feature with name 'MAX'\)/);
192 |     expect(text).toMatch(/average value is 0 and the sum is 0/);
193 |   });
194 | 
195 |   test('describes details for vector layers, even when unnamed', () => {
196 |     let descriptions: LayerDescription[] = [
197 |       vectorLayerFeaturesWithoutNames
198 |     ];
199 |     let got: string[] = layerDescriptionsToText(descriptions);
200 |     expect(got.length).toBeGreaterThanOrEqual(1);
201 |     let text = got.join('');
202 |     expect(text).toMatch(/1 layer./);
203 |     expect(text).toMatch(/'scribble'/);
204 |     expect(text).not.toMatch(/(named|with name)/);
205 |   });
206 |   test('describes details for single wms layer', () => {
207 |     let descriptions: LayerDescription[] = [
208 |       singleWmsLayerDesc
209 |     ];
210 |     let got: string[] = layerDescriptionsToText(descriptions);
211 | 
212 |     let text = got.join('');
213 |     expect(text).not.toMatch(/composition/);
214 |     expect(text).toMatch(/"a" \(/);
215 |     expect(text).toMatch(/title: "Title of a"/);
216 |     expect(text).toMatch(/abstract: "The abstract of a"/);
217 |   });
218 |   test('describes details for multi wms layer', () => {
219 |     let descriptions: LayerDescription[] = [
220 |       multiWmsLayerDesc
221 |     ];
222 |     let got: string[] = layerDescriptionsToText(descriptions);
223 |     let text = got.join('');
224 | 
225 |     expect(text).toMatch(/composition of 2/);
226 |     expect(text).toMatch(/"a" \(/);
227 |     expect(text).toMatch(/title: "Title of a"/);
228 |     expect(text).toMatch(/abstract: "The abstract of a"/);
229 |     expect(text).toMatch(/"b" \(/);
230 |     expect(text).toMatch(/title: "Title of b"/);
231 |     expect(text).toMatch(/abstract: "The abstract of b"/);
232 |   });
233 |   test('describes details for wms layers, but without redundancy 1', () => {
234 |     // adjust singleWmsLayerDesc, so that infos are less perfect
235 |     let clonedDesc = Object.assign({}, singleWmsLayerDesc) as LayerDescription;
236 |     (clonedDesc.details as WMSLayerDetails).wmsLayerAbstracts = ['a']; // same as name
237 |     (clonedDesc.details as WMSLayerDetails).wmsLayerTitles = ['a']; // same as name
238 | 
239 |     let descriptions: LayerDescription[] = [
240 |       clonedDesc
241 |     ];
242 |     let got: string[] = layerDescriptionsToText(descriptions);
243 |     let text = got.join('');
244 |     expect(text).not.toMatch(/composition/);
245 |     expect(text).toMatch(/"a"/);
246 |     expect(text).not.toMatch(/"a" \(/);
247 |     expect(text).not.toMatch(/title: /);
248 |     expect(text).not.toMatch(/abstract: /);
249 |   });
250 |   test('describes details for wms layers, but without redundancy 2', () => {
251 |     // adjust singleWmsLayerDesc, so that infos are less perfect
252 |     let clonedDesc = Object.assign({}, singleWmsLayerDesc) as LayerDescription;
253 |     (clonedDesc.details as WMSLayerDetails).wmsLayerAbstracts = ['Humba']; // same as title
254 |     (clonedDesc.details as WMSLayerDetails).wmsLayerTitles = ['Humba']; // same as abstract
255 | 
256 |     let descriptions: LayerDescription[] = [
257 |       clonedDesc
258 |     ];
259 |     let got: string[] = layerDescriptionsToText(descriptions);
260 |     let text = got.join('');
261 |     expect(text).not.toMatch(/composition/);
262 |     expect(text).toMatch(/"a" \(/);
263 |     expect(text).not.toMatch(/title: /);
264 |     expect(text).toMatch(/title\/abstract: /);
265 |   });
266 |   test('describes details for wms layers, handling empty infos 1', () => {
267 |     // adjust singleWmsLayerDesc, so that infos are less perfect
268 |     let clonedDesc = Object.assign({}, singleWmsLayerDesc) as LayerDescription;
269 |     (clonedDesc.details as WMSLayerDetails).wmsLayerAbstracts = ['']; // no abstract
270 | 
271 |     let descriptions: LayerDescription[] = [
272 |       clonedDesc
273 |     ];
274 |     let got: string[] = layerDescriptionsToText(descriptions);
275 |     let text = got.join('');
276 |     expect(text).not.toMatch(/composition/);
277 |     expect(text).toMatch(/"a" \(/);
278 |     expect(text).toMatch(/title: /);
279 |     expect(text).not.toMatch(/abstract: /);
280 |   });
281 | 
282 |   test('describes details for wms layers, handling empty infos 1', () => {
283 |     // adjust singleWmsLayerDesc, so that infos are less perfect
284 |     let clonedDesc = Object.assign({}, singleWmsLayerDesc) as LayerDescription;
285 |     (clonedDesc.details as WMSLayerDetails).wmsLayerTitles = ['']; // no title
286 | 
287 |     let descriptions: LayerDescription[] = [
288 |       clonedDesc
289 |     ];
290 |     let got: string[] = layerDescriptionsToText(descriptions);
291 |     let text = got.join('');
292 |     expect(text).not.toMatch(/composition/);
293 |     expect(text).toMatch(/"a" \(/);
294 |     expect(text).not.toMatch(/title: /);
295 |     expect(text).toMatch(/abstract: /);
296 |   });
297 | });
298 | 
299 | 


--------------------------------------------------------------------------------
/src/layerDescriptionsToText.ts:
--------------------------------------------------------------------------------
  1 | import {
  2 |   LayerDescription,
  3 |   VectorLayerDetails,
  4 |   WMSLayerDetails
  5 | } from './types';
  6 | import {
  7 |   localNum,
  8 |   makePercentInfo,
  9 |   startsWithVowel,
 10 |   stringifyNumber
 11 | } from './util';
 12 | 
 13 | /**
 14 |  *
 15 |  * @param layerDescs LayerDescription[] Descriptions of layers.
 16 |  * @returns string[]
 17 |  */
 18 | export const layerDescriptionsToText = (layerDescs: LayerDescription[]): string[] => {
 19 |   let parts: string[] = [];
 20 |   let pluralS = layerDescs.length !== 1 ? 's' : '';
 21 |   let andOnly = layerDescs.length === 1 ? ' and only ' : '';
 22 | 
 23 |   parts.push(`The map contains ${layerDescs.length} layer${pluralS}. `);
 24 | 
 25 |   layerDescs.forEach((layerDesc, idx) => {
 26 |     let n1 = startsWithVowel(layerDesc.type) ? 'n' : '';
 27 |     let n2 = startsWithVowel(layerDesc.source) ? 'n' : '';
 28 | 
 29 |     parts.push(`The ${stringifyNumber(idx + 1)}${andOnly} layer is a${n1} ${layerDesc.type}. `);
 30 |     parts.push();
 31 |     parts.push(`It uses a${n2} ${layerDesc.source}-source as source for it's data. `);
 32 |     // add vector details to textual description
 33 |     if (layerDesc.source === 'Vector') {
 34 |       parts = vectorLayersDetailsToText(layerDesc, parts);
 35 |     }
 36 |     // add WMS details to textual description
 37 |     if (layerDesc.source === 'TileWMS' || layerDesc.source === 'ImageWMS') {
 38 |       parts = wmsLayersDetailsToText(layerDesc, parts);
 39 |     }
 40 |     if (idx === 0 && layerDescs.length > 1) {
 41 |       parts.push('This layer is the lowest in the drawing order, other layers are drawn atop of it. ');
 42 |     }
 43 |     if (idx === layerDescs.length - 1 && layerDescs.length !== 1) {
 44 |       parts.push('This layer is top-most in the drawing order.');
 45 |     }
 46 |   });
 47 |   return parts;
 48 | };
 49 | 
 50 | const wmsLayersDetailsToText = (layerDesc: LayerDescription, parts: string[]) => {
 51 |   if (layerDesc.details == null) {
 52 |     return parts;
 53 |   }
 54 |   const {
 55 |     wmsLayerNames = [],
 56 |     wmsLayerAbstracts = [],
 57 |     wmsLayerTitles = []
 58 |   } = layerDesc.details as WMSLayerDetails;
 59 | 
 60 |   const numLayers = wmsLayerNames.length;
 61 | 
 62 |   if (numLayers > 1) {
 63 |     parts.push(`This layer is a composition of ${numLayers} layers, those are: `);
 64 |   } else {
 65 |     parts.push('This layer is named ');
 66 |   }
 67 |   wmsLayerNames.forEach((layerName, idx) => {
 68 |     let layerTitle = wmsLayerTitles[idx];
 69 |     let layerAbstract = wmsLayerAbstracts[idx];
 70 | 
 71 |     let nameEqualsTitle = layerName === layerTitle;
 72 |     let nameEqualsAbstract = layerName === layerAbstract;
 73 |     let titleEqualsAbstract = layerTitle === layerAbstract;
 74 | 
 75 |     let details = [];
 76 | 
 77 |     if (nameEqualsTitle && nameEqualsAbstract) {
 78 |       // no details needed
 79 |     } else if (titleEqualsAbstract) {
 80 |       details.push(`title/abstract: "${layerTitle}"`);
 81 |     } else {
 82 |       if (layerTitle) {
 83 |         details.push(`title: "${layerTitle}"`);
 84 |       }
 85 |       if (wmsLayerAbstracts[idx]) {
 86 |         details.push(`abstract: "${layerAbstract}"`);
 87 |       }
 88 |     }
 89 | 
 90 |     parts.push(`"${layerName}"`);
 91 |     if (details.length > 0) {
 92 |       parts.push(` (${details.join(', ')})`);
 93 |     }
 94 |     parts.push(idx < numLayers - 1 ? ', ' : '. ');
 95 |   });
 96 |   return parts;
 97 | };
 98 | 
 99 | const vectorLayersDetailsToText = (layerDesc: LayerDescription, parts: string[]) => {
100 |   if (layerDesc.details == null) {
101 |     return parts;
102 |   }
103 |   const {
104 |     numTotalFeaturesInSource: total,
105 |     numFeaturesInExtent: inExtent = 0,
106 |     numRenderedFeaturesInExtent: rendered = 0,
107 |     renderedStatistics: renderStats = undefined
108 |   } = layerDesc.details as VectorLayerDetails;
109 |   const pluralSTotal = total === 1 ? '' : 's';
110 |   const wereWas = rendered === 1 ? 'was' : 'were';
111 |   const renderedPlurals = rendered === 1 ? '' : 's';
112 |   parts.push(`The layer source contains ${localNum(total)} feature${pluralSTotal}. `);
113 |   if (inExtent > 0) {
114 |     const percentInExtent = makePercentInfo(inExtent, total);
115 |     const percentRendered = makePercentInfo(rendered, inExtent);
116 |     parts.push(`A total number of ${localNum(inExtent)}${percentInExtent} `);
117 |     parts.push(`intersect with the current map-extent; actually rendered ${wereWas} `);
118 |     parts.push(`${localNum(rendered)}${percentRendered} feature${renderedPlurals}. `);
119 |   }
120 |   if (renderStats) {
121 |     const properties = Object.keys(renderStats);
122 |     if (properties.length > 0) {
123 |       const yOrIes = properties.length === 1 ? 'y' : 'ies';
124 |       parts.push(`From the rendered feature${renderedPlurals} `);
125 |       parts.push(`basic statistical information for the following propert${yOrIes} `);
126 |       parts.push(`can be obtained: '${properties.join('\', \'')}'. `);
127 |       properties.forEach(prop => {
128 |         const stats = (renderStats as any)[prop];
129 |         const minPlace = stats.minName ? ` (feature named '${stats.minName}')` : '';
130 |         const maxPlace = stats.maxName ? ` (for the feature with name '${stats.maxName}')` : '';
131 |         if (rendered > 1) {
132 |           parts.push(`Property '${prop}': the minimal value is ${localNum(stats.min)}${minPlace} `);
133 |           parts.push(`while the maximum value is ${localNum(stats.max)}${maxPlace}. The average `);
134 |           parts.push(`value is ${localNum(stats.avg)} and the sum is ${localNum(stats.sum)}. `);
135 |         } else {
136 |           parts.push(`For the property ${prop} the value is ${localNum(stats.min)}${minPlace}. `);
137 |         }
138 |       });
139 |     }
140 |   }
141 |   return parts;
142 | };
143 | 


--------------------------------------------------------------------------------
/src/nominatimTextualDescriber.ts:
--------------------------------------------------------------------------------
 1 | import { layerDescriptionsToText } from './layerDescriptionsToText';
 2 | import {
 3 |   LayerDescription,
 4 |   TextualDescriberFunc,
 5 |   ViewDescription
 6 | } from './types';
 7 | 
 8 | import { roundTo } from './util';
 9 | 
10 | const reverseGeocode = async (lon = 0, lat = 0, zoom = 0): Promise => {
11 |   let result = null;
12 |   const nominatimUrl = 'https://nominatim.terrestris.de/reverse.php';
13 |   const resp = await fetch(nominatimUrl + '?' + new URLSearchParams({
14 |     lon: `${roundTo(lon, 5)}`,
15 |     lat: `${roundTo(lat, 5)}`,
16 |     zoom: `${Math.round(zoom)}`,
17 |     format: 'jsonv2'
18 |   }));
19 |   const data = await resp.json();
20 |   if (!data.error) {
21 |     result = data.display_name;
22 |   }
23 |   return result;
24 | };
25 | 
26 | const fetchNominatimPlaces = async (viewDesc: ViewDescription) => {
27 |   const {
28 |     zoom = 0
29 |   } = viewDesc;
30 |   const center = viewDesc?.epsg4326?.center || [0, 0];
31 |   const bbox = viewDesc?.epsg4326?.bbox || [0, 0, 0, 0];
32 |   const places = [];
33 | 
34 |   let centerData = await reverseGeocode(center[0], center[1], zoom);
35 |   let llData = await reverseGeocode(bbox[0], bbox[1], zoom);
36 |   let ulData = await reverseGeocode(bbox[0], bbox[3], zoom);
37 |   let urData = await reverseGeocode(bbox[2], bbox[3], zoom);
38 |   let lrData = await reverseGeocode(bbox[2], bbox[1], zoom);
39 | 
40 |   if (centerData) {
41 |     places.push(`The map is centered at ${centerData}.`);
42 |   }
43 |   if (llData) {
44 |     places.push(`The lower left of the visible map extent is at ${llData}.`);
45 |   }
46 |   if (ulData) {
47 |     places.push(`In the upper left corner of the visible extent, ${ulData} is located.`);
48 |   }
49 |   if (urData) {
50 |     places.push(`${urData} is in the upper right corner of the map.`);
51 |   }
52 |   if (lrData) {
53 |     places.push(`The lower right corner of the map shows ${lrData}.`);
54 |   }
55 | 
56 |   if (centerData || llData || ulData || urData || lrData) {
57 |     places.push(
58 |       'Place determination uses a Nominatim service from terrestris' +
59 |       ' – https://nominatim.terrestris.de/ –, based on data from the OpenStreetMap' +
60 |       ' project, © OpenStreetMap contributors.'
61 |     );
62 |   }
63 | 
64 |   return places.join(' ');
65 | };
66 | 
67 | 
68 | /**
69 |  * Returns a basic description based on the view description and any layer
70 |  * descriptions.
71 |  *
72 |  * @param viewDesc ViewDescription A view description.
73 |  * @param layerDescs LayerDescription[] An array of layer descriptions.
74 |  * @returns string A textual description.
75 |  */
76 | export const nominatimTextualDescriber: TextualDescriberFunc =
77 | async (viewDesc?: ViewDescription, layerDescs?: LayerDescription[]) => {
78 |   let parts: string[] = [];
79 | 
80 |   if (viewDesc !== undefined) {
81 |     parts.push(await fetchNominatimPlaces(viewDesc));
82 |   }
83 |   if (layerDescs !== undefined) {
84 |     parts.push(
85 |       ...layerDescriptionsToText(layerDescs)
86 |     );
87 |   }
88 | 
89 |   return parts.join('');
90 | };
91 | 
92 | export default nominatimTextualDescriber;
93 | 


--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
 1 | import View from 'ol/View';
 2 | import Layer from 'ol/layer/Layer';
 3 | 
 4 | export type ViewDescription = {
 5 |   bbox?: number[];
 6 |   center?: number[];
 7 |   viewProjection?: string;
 8 |   userProjection?: string;
 9 |   rotation?: number;
10 |   scale?: number;
11 |   zoom?: number;
12 |   epsg4326?: {
13 |     bbox?: number[];
14 |     center?: number[];
15 |   };
16 | };
17 | 
18 | export type VectorLayerDetails = {
19 |   numTotalFeaturesInSource?: number;
20 |   numFeaturesInExtent?: number;
21 |   numRenderedFeaturesInExtent?: number;
22 |   numSkippedFeaturesInExtent?: number;
23 |   renderedStatistics?: object;
24 | };
25 | 
26 | export type WMSLayerDetails = {
27 |   wmsLayerNames?: string[];
28 |   wmsLayerAbstracts?: string[];
29 |   wmsLayerTitles?: string[];
30 |   wmsLayerMetadataURLs?: string[];
31 |   topLevelLayerAbstract?: string;
32 |   topLevelLayerTitle?: string;
33 |   serviceAbstract?: string;
34 |   serviceKeywords?: string[];
35 |   serviceTitle?: string;
36 | };
37 | 
38 | export type LayerDescription = {
39 |   type: string;
40 |   source: string;
41 |   details: VectorLayerDetails | WMSLayerDetails | null;
42 | };
43 | 
44 | export type LayerFilterFunc = (layer: Layer) => boolean | undefined;
45 | export type ViewDescriberFunc = (view: View) => Promise;
46 | export type LayerDescriberFunc = (layer: Layer, view: View) => Promise;
47 | export type TextualDescriberFunc = (viewDescription?: ViewDescription
48 |   , layerDescriptions?: LayerDescription[]) => Promise;
49 | 
50 | export type DescribeConfiguration = {
51 |   layerFilter?: LayerFilterFunc;
52 |   viewDescriber?: ViewDescriberFunc | null;
53 |   layerDescriber?: LayerDescriberFunc | null;
54 |   textualDescriber?: TextualDescriberFunc;
55 |   updateAriaDescription?: boolean;
56 | };
57 | 
58 | export type MapDescription = {
59 |   text: string;
60 |   view?: ViewDescription;
61 |   layers?: LayerDescription[];
62 | };
63 | 
64 | export type CapaLayer = {
65 |   Name?: string;
66 |   Abstract?: string;
67 |   Title?: string;
68 |   MetadataURL?: MetadataURLObject[];
69 | };
70 | export type MetadataURLObject = {
71 |   Format?: string;
72 |   OnlineResource?: string;
73 | };
74 | 


--------------------------------------------------------------------------------
/src/util.test.ts:
--------------------------------------------------------------------------------
  1 | import Feature from 'ol/Feature';
  2 | import {
  3 |   formatBBOX,
  4 |   formatCoordinate,
  5 |   getNameAttribute,
  6 |   localNum,
  7 |   makePercentInfo,
  8 |   rad2deg,
  9 |   roundTo,
 10 |   simpleStats,
 11 |   startsWithVowel,
 12 |   stringifyNumber
 13 | } from './util';
 14 | 
 15 | 
 16 | const randomAttributeName = () => {
 17 |   let s1 = btoa('' + (Math.random() || new Date())).slice(-8, -2);
 18 |   let s2 = btoa('' + (Math.random() || new Date())).slice(-8, -2);
 19 |   return `random-${s1}-${s2}`;
 20 | };
 21 | const makeFeat = (numRandomAttributes, namedAtribute) => {
 22 |   let attrNames: string[] = [];
 23 |   for (let i = 0; i < numRandomAttributes; i++) {
 24 |     attrNames.push(randomAttributeName());
 25 |   }
 26 |   attrNames.push(namedAtribute);
 27 |   attrNames.sort(() => 0.5 - Math.random());
 28 |   let properties = {};
 29 |   attrNames.forEach(attrName => {
 30 |     properties[attrName] = Math.random();
 31 |   });
 32 |   return new Feature(properties);
 33 | };
 34 | 
 35 | 
 36 | describe('utility functions', () => {
 37 |   describe('startsWithVowel', () => {
 38 |     let suffix = 'humpty';
 39 |     test('startsWithVowel, empty string', () => {
 40 |       expect(startsWithVowel('')).toBe(false);
 41 |     });
 42 |     test('startsWithVowel, lowercase a, e, o, i, u', () => {
 43 |       let checks = ['a', 'e', 'o', 'i', 'u'];
 44 |       checks.forEach(check => {
 45 |         expect(startsWithVowel(`${check}${suffix}`)).toBe(true);
 46 |       });
 47 |     });
 48 |     test('startsWithVowel, uppercase A, E, O, I, U', () => {
 49 |       let checks = ['A', 'E', 'O', 'I', 'U'];
 50 |       checks.forEach(check => {
 51 |         expect(startsWithVowel(`${check}${suffix}`)).toBe(true);
 52 |       });
 53 |     });
 54 |     test('startsWithVowel, some consonants', () => {
 55 |       let checks = ['d', 'F', 'k', 'C', 'P', 'y'];
 56 |       checks.forEach(check => {
 57 |         expect(startsWithVowel(`${check}${suffix}`)).toBe(false);
 58 |       });
 59 |     });
 60 |     test('startsWithVowel, some specialchars', () => {
 61 |       let checks = ['!', '%', '€', ':', '-', ' '];
 62 |       checks.forEach(check => {
 63 |         expect(startsWithVowel(`${check}${suffix}`)).toBe(false);
 64 |       });
 65 |     });
 66 |   });
 67 | 
 68 |   describe('stringifyNumber', () => {
 69 |     test('transforms 1, 2, 3 as expected', () => {
 70 |       expect(stringifyNumber(1)).toBe('first');
 71 |       expect(stringifyNumber(2)).toBe('second');
 72 |       expect(stringifyNumber(3)).toBe('third');
 73 |     });
 74 |     test('transforms 11, 22, 33 as expected', () => {
 75 |       expect(stringifyNumber(11)).toBe('eleventh');
 76 |       expect(stringifyNumber(22)).toBe('twenty-second');
 77 |       expect(stringifyNumber(33)).toBe('thirty-third');
 78 |     });
 79 |     test('transforms 20, 30, 70 as expected', () => {
 80 |       expect(stringifyNumber(20)).toBe('twentieth');
 81 |       expect(stringifyNumber(30)).toBe('thirtieth');
 82 |       expect(stringifyNumber(70)).toBe('seventieth');
 83 |     });
 84 |     test('transforms 111, 122, 533 as expected', () => {
 85 |       expect(stringifyNumber(111)).toBe('# 111');
 86 |       expect(stringifyNumber(122)).toBe('# 122');
 87 |       expect(stringifyNumber(533)).toBe('# 533');
 88 |     });
 89 |   });
 90 | 
 91 |   describe('roundTo', () => {
 92 |     test('basic rounding with defaults', () => {
 93 |       expect(roundTo(1)).toBe(1);
 94 |       expect(roundTo(-1)).toBe(-1);
 95 |       expect(roundTo(1.00005)).toBe(1.0001);
 96 |       expect(roundTo(-1.00005)).toBe(-1.0001);
 97 |     });
 98 |     test('digits can be configured', () => {
 99 |       expect(roundTo(1.00005, 3)).toBe(1);
100 |       expect(roundTo(-1.00005, 3)).toBe(-1);
101 |       expect(roundTo(1.00005, 7)).toBe(1.00005);
102 |       expect(roundTo(-1.00005, 7)).toBe(-1.00005);
103 |     });
104 |   });
105 | 
106 |   describe('formatCoordinate', () => {
107 |     test('when not passed arguments', () => {
108 |       expect(formatCoordinate()).toStrictEqual('[0, 0]');
109 |     });
110 |     test('when either x or y is passed', () => {
111 |       expect(formatCoordinate(1)).toStrictEqual('[1, 0]');
112 |       expect(formatCoordinate(undefined, 1)).toStrictEqual('[0, 1]');
113 |     });
114 |     test('when no digits param is passed, but x and y', () => {
115 |       expect(formatCoordinate(1, 2)).toStrictEqual('[1, 2]');
116 |       expect(formatCoordinate(1.01, 2.02)).toStrictEqual('[1.01, 2.02]');
117 |       expect(formatCoordinate(1.00001, 2.00002)).toStrictEqual('[1, 2]');
118 |     });
119 |     test('handling of negative coords', () => {
120 |       expect(formatCoordinate(-1, -2)).toStrictEqual('[-1, -2]');
121 |       expect(formatCoordinate(-1.00005, -2.00005)).toStrictEqual('[-1.0001, -2.0001]');
122 |       expect(formatCoordinate(-1.00001, -2.00002)).toStrictEqual('[-1, -2]');
123 |     });
124 |     test('decimal precission can be configured', () => {
125 |       expect(formatCoordinate(-1.00001, -2.00002, 5)).toStrictEqual('[-1.00001, -2.00002]');
126 |     });
127 |   });
128 | 
129 |   describe('formatBBOX', () => {
130 |     test('standard formatting', () => {
131 |       let bbox = [1, 2, 3, 4];
132 |       expect(formatBBOX(bbox)).toStrictEqual('[1, 2, 3, 4]');
133 |       bbox = [-1, -2, 3.3, 4.4];
134 |       expect(formatBBOX(bbox)).toStrictEqual('[-1, -2, 3.3, 4.4]');
135 |     });
136 |     test('rounds to 4 decimal places by default', () => {
137 |       let bbox = [1.000000001, -2.55555, 3, 4];
138 |       expect(formatBBOX(bbox)).toStrictEqual('[1, -2.5556, 3, 4]');
139 |     });
140 |     test('number of decimal places is configurable', () => {
141 |       let bbox = [1.000001, -2.55555, 3, 4];
142 |       expect(formatBBOX(bbox, 6)).toStrictEqual('[1.000001, -2.55555, 3, 4]');
143 |     });
144 |   });
145 | 
146 |   describe('rad2deg', () => {
147 |     expect(rad2deg(Math.PI)).toBe(180);
148 |     expect(rad2deg(2 * Math.PI)).toBe(360);
149 |     expect(rad2deg(-Math.PI / 4)).toBe(-45);
150 |     expect(rad2deg(-Math.PI * 1.5)).toBe(-270);
151 |   });
152 | 
153 |   describe('getNameAttribute', () => {
154 |     test('returns empty string when call wo/ feature', () => {
155 |       expect(getNameAttribute()).toStrictEqual('');
156 |     });
157 |     test('finds lower case variants of "name", "nom", "nombre", "naam"', () => {
158 |       let feat;
159 |       feat = makeFeat(10, 'name');
160 |       expect(getNameAttribute(feat)).toStrictEqual('name');
161 |       feat = makeFeat(10, 'nom');
162 |       expect(getNameAttribute(feat)).toStrictEqual('nom');
163 |       feat = makeFeat(10, 'nombre');
164 |       expect(getNameAttribute(feat)).toStrictEqual('nombre');
165 |       feat = makeFeat(10, 'naam');
166 |       expect(getNameAttribute(feat)).toStrictEqual('naam');
167 |     });
168 |     test('finds mixed case variants of "name", "nom", "nombre", "naam"', () => {
169 |       let feat;
170 |       feat = makeFeat(10, 'nAme');
171 |       expect(getNameAttribute(feat)).toStrictEqual('nAme');
172 |       feat = makeFeat(10, 'noM');
173 |       expect(getNameAttribute(feat)).toStrictEqual('noM');
174 |       feat = makeFeat(10, 'Nombre');
175 |       expect(getNameAttribute(feat)).toStrictEqual('Nombre');
176 |       feat = makeFeat(10, 'NAAM');
177 |       expect(getNameAttribute(feat)).toStrictEqual('NAAM');
178 |     });
179 |     test('ignores whitespace around "name", "nom", "nombre", "naam"', () => {
180 |       let feat;
181 |       feat = makeFeat(10, ' name');
182 |       expect(getNameAttribute(feat)).toStrictEqual(' name');
183 |       feat = makeFeat(10, 'noM  ');
184 |       expect(getNameAttribute(feat)).toStrictEqual('noM  ');
185 |       feat = makeFeat(10, '\t\t\tNombre\n\n');
186 |       expect(getNameAttribute(feat)).toStrictEqual('\t\t\tNombre\n\n');
187 |       feat = makeFeat(10, ' NAAM ');
188 |       expect(getNameAttribute(feat)).toStrictEqual(' NAAM ');
189 |     });
190 |   });
191 | 
192 |   describe('simpleStats', () => {
193 |     const rec1 = { foo: 3, bar: 0.1, baz: 'cos', name: 'Humpty' };
194 |     const rec2 = { foo: 2, bar: 0.2, baz: 'sin', name: 'Dumpty' };
195 |     const rec3 = { foo: 1, bar: -0.5, baz: 'tan', name: 'Trumpty' };
196 |     const dataset1 = [rec1, rec2, rec3];
197 |     const got1: any = simpleStats(dataset1, ['foo', 'bar'], 'name');
198 |     test('returns correct min value for "foo"', () => {
199 |       expect(got1.foo.min).toBe(1);
200 |     });
201 |     test('returns correct max value for "foo"', () => {
202 |       expect(got1.foo.max).toBe(3);
203 |     });
204 |     test('returns correct name for the lowest "foo"', () => {
205 |       expect(got1.foo.minName).toBe('Trumpty');
206 |     });
207 |     test('returns correct name for the highest "foo"', () => {
208 |       expect(got1.foo.maxName).toBe('Humpty');
209 |     });
210 |     test('returns correct sum value for "foo"', () => {
211 |       expect(got1.foo.sum).toBe(6);
212 |     });
213 |     test('returns correct avg value for "foo"', () => {
214 |       expect(got1.foo.avg).toBe(6 / 3);
215 |     });
216 | 
217 |     test('returns correct min value for "bar"', () => {
218 |       expect(got1.bar.min).toBe(-0.5);
219 |     });
220 |     test('returns correct max value for "bar"', () => {
221 |       expect(got1.bar.max).toBe(0.2);
222 |     });
223 |     test('returns correct name for the lowest "bar"', () => {
224 |       expect(got1.bar.minName).toBe('Trumpty');
225 |     });
226 |     test('returns correct name for the highest "bar"', () => {
227 |       expect(got1.bar.maxName).toBe('Dumpty');
228 |     });
229 |     test('returns correct sum value for "bar"', () => {
230 |       expect(got1.bar.sum).toBe(0.1 + 0.2 - 0.5);
231 |     });
232 |     test('returns correct avg value for "bar"', () => {
233 |       expect(got1.bar.avg).toBe((0.1 + 0.2 - 0.5) / 3);
234 |     });
235 | 
236 |     describe('with empty datasets, or empty columns we get an empty object', () => {
237 |       expect(simpleStats([], [], '')).toStrictEqual({});
238 |       expect(simpleStats([rec1], [], '')).toStrictEqual({});
239 |       expect(simpleStats([], ['foo'], '')).toStrictEqual({});
240 |     });
241 | 
242 |     describe('no stats for unexpected columns, no names for unexpected name columns', () => {
243 |       const rec4 = { foo: 3 };
244 |       const rec5 = { foo: 2 };
245 |       const rec6 = { foo: 1 };
246 |       const dataset2 = [rec4, rec5, rec6];
247 |       const got2: any = simpleStats(dataset2, ['foo', 'miss'], 'not-there');
248 |       expect('miss' in got2).toBe(false);
249 |       expect(got2.foo.min).toBe(1);
250 |       expect(got2.foo.minName).toBe(undefined);
251 |     });
252 | 
253 |   });
254 | 
255 |   describe('localNum', () => {
256 |     test('should return an empty string when num is undefined', () => {
257 |       const result = localNum(undefined);
258 |       expect(result).toBe('');
259 |     });
260 | 
261 |     test('should localize a positive integer', () => {
262 |       const result = localNum(1234567);
263 |       expect(result).toBe('1,234,567');
264 |     });
265 | 
266 |     test('should localize a negative integer', () => {
267 |       const result = localNum(-9876543);
268 |       expect(result).toBe('-9,876,543');
269 |     });
270 | 
271 |     test('should localize a floating-point number', () => {
272 |       const result = localNum(1234.567);
273 |       expect(result).toBe('1,234.567');
274 |     });
275 | 
276 |     test('should localize zero', () => {
277 |       const result = localNum(0);
278 |       expect(result).toBe('0');
279 |     });
280 | 
281 |     test('should not modify a string input', () => {
282 |       const result = localNum('1234567' as any); // Using 'as any' to force a string input for testing
283 |       expect(result).toBe('1234567');
284 |     });
285 |   });
286 | 
287 |   describe('makePercentInfo', () => {
288 |     expect(makePercentInfo()).toStrictEqual('');
289 |     expect(makePercentInfo(1)).toStrictEqual('');
290 |     expect(makePercentInfo(undefined, 2)).toStrictEqual('');
291 |     expect(makePercentInfo(1, 2)).toStrictEqual(' (50%)');
292 |     expect(makePercentInfo(2, 1)).toStrictEqual(' (200%)');
293 |     expect(makePercentInfo(1589.111, 10000)).toStrictEqual(' (15.89%)');
294 |   });
295 | 
296 | });
297 | 


--------------------------------------------------------------------------------
/src/util.ts:
--------------------------------------------------------------------------------
  1 | import Feature from 'ol/Feature';
  2 | import { ViewDescriberFunc, LayerDescriberFunc } from './types';
  3 | 
  4 | const SPECIAL_NUMS = ['zeroth', 'first', 'second',
  5 |   'third', 'fourth', 'fifth', 'sixth', 'seventh',
  6 |   'eighth', 'ninth', 'tenth', 'eleventh', 'twelfth',
  7 |   'thirteenth', 'fourteenth', 'fifteenth', 'sixteenth',
  8 |   'seventeenth', 'eighteenth', 'nineteenth'];
  9 | const DECA = ['twent', 'thirt', 'fort', 'fift', 'sixt', 'sevent', 'eight', 'ninet'];
 10 | 
 11 | /**
 12 |  * Turns a number into a stringified version, i.e. 1 becomes 'first', 22 becomes
 13 |  * 'twenty-second'. numbers above 100 are returned as '# 175'.
 14 |  *
 15 |  * See  https://stackoverflow.com/a/20426113
 16 |  *
 17 |  * @param n number The number to transform
 18 |  * @returns string A spelled-out number, e.g. 'first', 'twenty-second' or '# 175'.
 19 |  */
 20 | export const stringifyNumber = (n: number): string => {
 21 |   let stringified = '';
 22 |   if (n > 100) {
 23 |     stringified = `# ${n}`;
 24 |   } else if (n < 20) {
 25 |     stringified = SPECIAL_NUMS[n];
 26 |   } else if (n % 10 === 0) {
 27 |     stringified = DECA[Math.floor(n / 10) - 2] + 'ieth';
 28 |   } else {
 29 |     stringified =  DECA[Math.floor(n / 10) - 2] + 'y-' + SPECIAL_NUMS[n % 10];
 30 |   }
 31 |   return stringified;
 32 | };
 33 | 
 34 | /**
 35 |  * Localizes the given number and returns it with thousands seperator and decimal
 36 |  * seperator for the language code en-GB.
 37 |  *
 38 |  * @param num:number|undefined the number to localize
 39 |  * @returns string the localized number.
 40 |  */
 41 | export const localNum = (num: number|undefined): string => {
 42 |   if (num === undefined) {
 43 |     return '';
 44 |   }
 45 |   return num.toLocaleString('en-GB');
 46 | };
 47 | 
 48 | /**
 49 |  * Returns `true` if the string to test starts with 'a', 'o', 'u', 'i' or 'e', ignoring
 50 |  * casing, `false` otherwise.
 51 |  *
 52 |  * @param str string The string to test
 53 |  * @returns boolean True if the string starts with 'a', 'o', 'u', 'i' or 'e',
 54 |  *   case-insensitive, false otherwise.
 55 |  */
 56 | export const startsWithVowel = (str: string): boolean => {
 57 |   let re: RegExp = /^[aouie]{1}/i;
 58 |   return re.test(str);
 59 | };
 60 | 
 61 | /**
 62 |  * See https://stackoverflow.com/a/18358056
 63 |  *
 64 |  * @param num
 65 |  * @param digits
 66 |  * @returns
 67 |  */
 68 | export const roundTo = (num: number, digits: number = 4): number => {
 69 |   if (num < 0) {
 70 |     return -roundTo(-num, digits);
 71 |   }
 72 |   let a = `${num}e+${digits}`;
 73 |   let b = `e-${digits}`;
 74 |   return +(Math.round(+a) + b);
 75 | };
 76 | 
 77 | /**
 78 |  *
 79 |  * @param x
 80 |  * @param y
 81 |  * @param digits
 82 |  * @returns
 83 |  */
 84 | export const formatCoordinate = (x: number = 0, y: number = 0, digits = 4): string => {
 85 |   return `[${roundTo(x, digits)}, ${roundTo(y, digits)}]`;
 86 | };
 87 | 
 88 | /**
 89 |  *
 90 |  * @param coords
 91 |  * @param digits
 92 |  * @returns
 93 |  */
 94 | export const formatBBOX = (coords: number[], digits = 4): string => {
 95 |   return [
 96 |     '[',
 97 |     `${roundTo(coords[0], digits)}, ${roundTo(coords[1], digits)}, `,
 98 |     `${roundTo(coords[2], digits)}, ${roundTo(coords[3], digits)}`,
 99 |     ']'
100 |   ].join('');
101 | };
102 | 
103 | /**
104 |  *
105 |  * @param rad
106 |  * @returns
107 |  */
108 | export const rad2deg = (rad: number = 0): number => rad * (180 / Math.PI);
109 | 
110 | /**
111 |  *
112 |  * @param feature
113 |  * @returns
114 |  */
115 | export const getNameAttribute = (feature: Feature|undefined = undefined): string => {
116 |   if (feature === undefined) {
117 |     return '';
118 |   }
119 |   // https://www.indifferentlanguages.com/words/name
120 |   const candidates = ['name', 'nom', 'nombre', 'naam'];
121 |   const attributes = feature.getProperties();
122 |   const attributeKeys = Object.keys(attributes);
123 |   let nameAttribute: string = '';
124 |   candidates.some((candidate) => {
125 |     if (candidate in attributes) {
126 |       nameAttribute = candidate;
127 |     } else {
128 |       let candidateRegEx = new RegExp(`^\\s*${candidate}\\s*$`, 'i');
129 |       attributeKeys.some((attributeKey) => {
130 |         if (candidateRegEx.test(attributeKey)) {
131 |           nameAttribute = attributeKey;
132 |           return true;
133 |         }
134 |         return false;
135 |       });
136 |     }
137 |     if (nameAttribute !== '') {
138 |       return true;
139 |     }
140 |     return false;
141 |   });
142 |   return nameAttribute;
143 | };
144 | 
145 | 
146 | /**
147 |  *
148 |  * @param data object[]
149 |  * @param keys string[]
150 |  * @param nameAttribute string
151 |  * @returns
152 |  */
153 | export const simpleStats = (data: object[], keys: string[], nameAttribute: string): object => {
154 |   let stats = {};
155 |   if (keys.length < 1 || data.length  < 1) {
156 |     return stats;
157 |   }
158 |   keys.forEach(key => {
159 |     let min = +Infinity;
160 |     let max = -Infinity;
161 |     let avg = NaN;
162 |     let sum = 0;
163 |     let minName = undefined;
164 |     let maxName = undefined;
165 |     data.forEach((record: object) => {
166 |       if (key in record) {
167 |         let val: number = (record as any)[key] as number;
168 |         let name: string = nameAttribute in record ? (record as any)[nameAttribute] : undefined;
169 |         if (val < min) {
170 |           min = val;
171 |           minName = name;
172 |         }
173 |         if (val > max) {
174 |           max = val;
175 |           maxName = name;
176 |         }
177 |         sum += val;
178 |       }
179 |     });
180 |     avg = sum / data.length;
181 |     if (min !== Infinity) {
182 |       (stats as any)[key] = {min, max, avg, sum, minName, maxName };
183 |     }
184 |   });
185 |   return stats;
186 | };
187 | 
188 | /**
189 |  * Calculates, formnats and returns a percentage of the `share` of the `total`.
190 |  *
191 |  * @param share number|undefined The absolute number of the share.
192 |  * @param total number|undefined The toital nomber the share is a part of.
193 |  * @returns string A percentage of the share of the total, wrapped in parentheses, and
194 |  *   with a leading space.
195 |  */
196 | export const makePercentInfo = (share: number|undefined = undefined, total: number|undefined = undefined): string => {
197 |   if (total === 0 || share === undefined || total === undefined || isNaN(total) ) {
198 |     return '';
199 |   }
200 |   return ` (${roundTo(100 * (share / total), 2)}%)`;
201 | };
202 | 
203 | const wmsResponseCache: { [key: string]: string } = {};
204 | export const getWmsResponse = async (url: string, params: object): Promise => {
205 |   let responseTxt = '';
206 |   let cacheKey = `_url:${encodeURIComponent(url)}`;
207 |   for (let [k, v] of Object.entries(params)) {
208 |     cacheKey = `${cacheKey}_${encodeURIComponent(k)}:${encodeURIComponent(v)}`;
209 |   }
210 |   if (!wmsResponseCache[cacheKey]) {
211 |     let wmsUrl = new URL(url);
212 |     for (let [k, v] of Object.entries(params)) {
213 |       wmsUrl.searchParams.set(k, v);
214 |     }
215 |     responseTxt = await fetch(wmsUrl.toString()).then(resp => resp.text());
216 |     wmsResponseCache[cacheKey] = responseTxt;
217 |   }
218 |   return wmsResponseCache[cacheKey];
219 | };
220 | 
221 | export const voidViewDescriber: ViewDescriberFunc = async () => {
222 |   return {};
223 | };
224 | 
225 | export const voidLayersDescriber: LayerDescriberFunc = async () => {
226 |   return {
227 |     type: '',
228 |     source: '',
229 |     details: null
230 |   };
231 | };
232 | 


--------------------------------------------------------------------------------
/src/viewDescriptionToText.test.ts:
--------------------------------------------------------------------------------
 1 | import Map from 'ol/Map';
 2 | import View from 'ol/View';
 3 | 
 4 | import { ViewDescription } from './types';
 5 | import {
 6 |   viewDescriptionToText
 7 | } from './viewDescriptionToText';
 8 | import defaultViewDescriber from './defaultViewDescriber';
 9 | 
10 | let map: Map;
11 | let div: HTMLDivElement;
12 | 
13 | describe('viewDescriptionToText', () => {
14 |   beforeEach(() =>  {
15 |     div = document.createElement('div');
16 |     document.body.appendChild(div);
17 |     map = new Map({
18 |       layers: [],
19 |       view: new View({
20 |         center: [0, 0],
21 |         zoom: 2
22 |       }),
23 |       target: div
24 |     });
25 |   });
26 |   afterEach(() => {
27 |     map.dispose();
28 |     div.parentNode?.removeChild(div);
29 |   });
30 | 
31 |   test('describes rotation when not rotated', async () => {
32 |     let viewDesc: ViewDescription = await defaultViewDescriber(map.getView());
33 |     let got = viewDescriptionToText(viewDesc);
34 |     let text = got.join('');
35 |     expect(text).toMatch(/is not rotated/);
36 |   });
37 | 
38 |   test('describes rotation when rotated clockwise, 90°', async () => {
39 |     map.getView().setRotation(Math.PI/2);
40 |     let viewDesc: ViewDescription = await defaultViewDescriber(map.getView());
41 |     let got = viewDescriptionToText(viewDesc);
42 |     let text = got.join('');
43 |     expect(text).toMatch(/north is at the right-hand side/);
44 |   });
45 | 
46 |   test('describes rotation when rotated counter-clockwise, -90°', async () => {
47 |     map.getView().setRotation(-Math.PI/2);
48 |     let viewDesc: ViewDescription = await defaultViewDescriber(map.getView());
49 |     let got = viewDescriptionToText(viewDesc);
50 |     let text = got.join('');
51 |     expect(text).toMatch(/north is at the left-hand side/);
52 |   });
53 |   test('describes rotation when rotated by 180°', async () => {
54 |     map.getView().setRotation(Math.PI);
55 |     let viewDesc: ViewDescription = await defaultViewDescriber(map.getView());
56 |     let got = viewDescriptionToText(viewDesc);
57 |     let text = got.join('');
58 |     expect(text).toMatch(/north is at the bottom/);
59 |   });
60 |   test('describes rotation when rotated by any other amount', async () => {
61 |     map.getView().setRotation(Math.PI/4); // 45°
62 |     let viewDesc: ViewDescription = await defaultViewDescriber(map.getView());
63 |     let got = viewDescriptionToText(viewDesc);
64 |     let text = got.join('');
65 |     expect(text).toMatch(/roughly 45 degrees/);
66 |   });
67 |   test('it always uses the lowest correct deegree value when higher than 360°', async () => {
68 |     map.getView().setRotation((Math.PI * 2) + Math.PI/4); // 405° => 45°
69 |     let viewDesc: ViewDescription = await defaultViewDescriber(map.getView());
70 |     let got = viewDescriptionToText(viewDesc);
71 |     let text = got.join('');
72 |     expect(text).toMatch(/roughly 45 degrees/);
73 |   });
74 |   test('it always uses the lowest correct deegree value when higher than 360°', async () => {
75 |     map.getView().setRotation(Math.PI + Math.PI/2); // 270° => -90°
76 |     let viewDesc: ViewDescription = await defaultViewDescriber(map.getView());
77 |     let got = viewDescriptionToText(viewDesc);
78 |     let text = got.join('');
79 |     expect(text).toMatch(/north is at the left-hand side/);
80 |   });
81 | });
82 | 


--------------------------------------------------------------------------------
/src/viewDescriptionToText.ts:
--------------------------------------------------------------------------------
  1 | import { ViewDescription } from './types';
  2 | import {
  3 |   formatBBOX,
  4 |   formatCoordinate,
  5 |   rad2deg,
  6 |   roundTo
  7 | } from './util';
  8 | 
  9 | /**
 10 |  * Converts a rotation in radians to a descriptive sentence.
 11 |  *
 12 |  * @param {number} rotation - The rotation angle in radians to describe.
 13 |  * @returns {string} A descriptive sentence based on the rotation angle.
 14 |  */
 15 | const rotationToText = (rotation: number) => {
 16 |   // Constants for circle and direction angles
 17 |   const CIRCLE_DEGREES = 360;
 18 |   const HALF_CIRCLE_DEGREES = 180;
 19 |   const UP_DEGREES = 0;
 20 |   const RIGHT_DEGREES = 90;
 21 |   const DOWN_DEGREES = 180;
 22 |   const LEFT_DEGREES = 270;
 23 | 
 24 |   // reusable text for rotated map
 25 |   const isRotated = 'This map is rotated';
 26 | 
 27 |   // Convert to degrees and ensure it is within [0, 360) range
 28 |   let degrees = rad2deg(rotation) % CIRCLE_DEGREES;
 29 |   if (degrees < 0) {
 30 |     degrees = CIRCLE_DEGREES + degrees;
 31 |   }
 32 | 
 33 |   // Handle special cases for cardinal directions
 34 |   if (degrees === UP_DEGREES) {
 35 |     return 'This map is not rotated, north is at the top. ';
 36 |   } else if (degrees === RIGHT_DEGREES) {
 37 |     return `${isRotated}, north is at the right-hand side. `;
 38 |   } else if (degrees === DOWN_DEGREES) {
 39 |     return `${isRotated}, north is at the bottom. `;
 40 |   } else if (degrees === LEFT_DEGREES) {
 41 |     return `${isRotated}, north is at the left-hand side. `;
 42 |   }
 43 | 
 44 |   // Determine the rotation direction and describe it
 45 |   let direction = 'clockwise';
 46 |   if (degrees > HALF_CIRCLE_DEGREES) {
 47 |     degrees = CIRCLE_DEGREES - degrees;
 48 |     direction = 'counter-clockwise';
 49 |   }
 50 | 
 51 |   // Return the description including the direction and rounded degrees
 52 |   return `${isRotated} ${direction} by roughly ${roundTo(degrees, 2)} degrees. `;
 53 | };
 54 | 
 55 | /**
 56 |  * Converts a description of the view to a bunch of descriptive sentences.
 57 |  *
 58 |  * @param {ViewDescription} viewDesc - an object describing various aspects of the view.
 59 |  * @returns {string[]} A bunch of descriptive sentences based on the passed apects of
 60 |  *   the view.
 61 |  */
 62 | export const viewDescriptionToText = (viewDesc: ViewDescription): string[] => {
 63 |   let parts: string[] = [];
 64 | 
 65 |   const {
 66 |     rotation = 0,
 67 |     viewProjection = '',
 68 |     epsg4326 = {}
 69 |   } = viewDesc;
 70 | 
 71 |   if (epsg4326.center) {
 72 |     parts.push(
 73 |       'This map is currently centered at the following latitude and ' +
 74 |       `longitude coordinate ${formatCoordinate(...epsg4326.center)}. `
 75 |     );
 76 |   }
 77 |   if (viewProjection) {
 78 |     parts.push(`The map projection that is used in the map has the code ${viewProjection}. `);
 79 |   }
 80 | 
 81 |   if (epsg4326.bbox) {
 82 |     parts.push(`The view has an extent of ${formatBBOX(epsg4326.bbox || [])} (latitude/longitude) `);
 83 |     parts.push(`i.e. the lower left point is at ${formatCoordinate(epsg4326.bbox?.[0], epsg4326.bbox?.[1])}`);
 84 |     parts.push(`, the upper right is at ${formatCoordinate(epsg4326.bbox?.[2], epsg4326.bbox?.[3])}. `);
 85 |   }
 86 | 
 87 |   if (viewDesc.rotation !== undefined) {
 88 |     parts.push(rotationToText(rotation));
 89 |   }
 90 | 
 91 |   if (viewDesc.scale) {
 92 |     parts.push(`The map has a scale of roughly 1:${roundTo(viewDesc.scale, 0).toLocaleString('en-GB')}. `);
 93 |   }
 94 |   if (viewDesc.zoom) {
 95 |     parts.push(`Currently the map is zoomed to level ${roundTo(viewDesc.zoom, 2)}.`);
 96 |   }
 97 | 
 98 | 
 99 |   return parts;
100 | };
101 | 


--------------------------------------------------------------------------------
/testdata/capabilites-example.xml:
--------------------------------------------------------------------------------
  1 | 
  2 | 
  6 |   
  7 |     WMS
  8 |     Foo
  9 |     Foo Abstract
 10 |     
 11 |       Service keyword 1
 12 |       Service keyword 2
 13 |     
 14 |     
 16 |     
 17 |       
 18 |         Peter Mustermann
 19 |         Musterfirma
 20 |       
 21 |       Computer Scientist
 22 |       
 23 |         postal
 24 |         
Foo
25 | Musterstadt 26 | NRW 27 | 12345 28 | Germany 29 |
30 | +49 228 123456789 31 | mustermann@example.com 32 |
33 | none 34 | none 35 | 16 36 | 2048 37 | 2048 38 |
39 | 40 | 41 | 42 | text/xml 43 | 44 | 45 | 46 | 49 | 50 | 51 | 54 | 55 | 56 | 57 | 58 | 59 | image/gif 60 | image/png 61 | image/jpeg 62 | 63 | 64 | 65 | 68 | 69 | 70 | 71 | 72 | 73 | text/xml 74 | text/plain 75 | text/html 76 | 77 | 78 | 79 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | XML 89 | INIMAGE 90 | BLANK 91 | 92 | 93 | layer-number-1 94 | The title of layer-number-1 95 | The abstract of layer-number-1 96 | 97 | layer-number-1 keyword 1 98 | layer-number-1 keyword 2 99 | 100 | 101 | text/plain 102 | 105 | 106 | 107 | text/xml 108 | 111 | 112 | 113 | layer-number-1-1 114 | Title of layer-number-1-1 115 | 116 | layer-number-1-1-1 117 | Title of layer-number-1-1-1 118 | 119 | 120 | layer-number-1-1-2 121 | Title of layer-number-1-1-2 122 | The abstract of layer-number-1 123 | 124 | layer-number-1-1-2 keyword 1 125 | layer-number-1-1-2 keyword 2 126 | 127 | 128 | text/plain 129 | 132 | 133 | 134 | text/xml 135 | 138 | 139 | 140 | 141 | 142 | a 143 | Title of a 144 | The abstract of a 145 | 146 | a keyword 1 147 | a keyword 2 148 | 149 | 150 | text/plain 151 | 154 | 155 | 156 | text/xml 157 | 160 | 161 | 162 | 163 | b 164 | Title of b 165 | The abstract of b 166 | 167 | b keyword 1 168 | b keyword 2 169 | 170 | 171 | text/plain 172 | 175 | 176 | 177 | text/xml 178 | 181 | 182 | 183 | 184 | 185 |
186 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": ".", 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "lib": [ 10 | "es7", 11 | "dom" 12 | ], 13 | "module": "commonjs", 14 | "moduleResolution": "node", 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noUnusedLocals": true, 18 | "outDir": "dist", 19 | "resolveJsonModule": true, 20 | "rootDir": ".", 21 | "skipLibCheck": true, 22 | "sourceMap": true, 23 | "target": "es5" 24 | }, 25 | "exclude": [ 26 | "node_modules", 27 | "build", 28 | "browser", 29 | "config", 30 | "dist", 31 | "examples", 32 | "scripts", 33 | "coverage", 34 | "acceptance-tests", 35 | "webpack", 36 | "jest", 37 | "**.config.js", 38 | "**.config.ts", 39 | "**/*.test.ts" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["./src"], 4 | "entryPointStrategy": "expand", 5 | "out": "./dist/doc" 6 | } 7 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | build: { 6 | rollupOptions: { 7 | input: { 8 | main: resolve(__dirname, 'examples/index.html'), 9 | basic: resolve(__dirname, 'examples/basic.html'), 10 | nominatim: resolve(__dirname, 'examples/nominatim.html'), 11 | 'use-geographic': resolve(__dirname, 'examples/use-geographic.html'), 12 | vector: resolve(__dirname, 'examples/vector.html'), 13 | wms: resolve(__dirname, 'examples/wms.html'), 14 | 'wms-verbose': resolve(__dirname, 'examples/wms-verbose.html') 15 | } 16 | } 17 | }, 18 | base: './' 19 | }); 20 | --------------------------------------------------------------------------------