├── .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 | [](https://www.npmjs.com/package/@terrestris/ol-describe-map)
4 | [](https://github.com/terrestris/ol-describe-map/blob/main/LICENSE)
5 | [](https://coveralls.io/github/terrestris/ol-describe-map)
6 | 
7 | 
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 | Read aloud
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 | Read aloud
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 | Read aloud
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 | Read aloud
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 | Read aloud
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(`Displayed layers from WMS "${serviceTitle}" `);
107 | parts.push('');
108 | parts.push('');
109 | columns.forEach(col => {
110 | parts.push(`${col} `);
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(`${valOrFallback(layerName)} `);
121 | parts.push(`${valOrFallback(layerTitle)} `);
122 | parts.push(`${valOrFallback(layerAbstract)} `);
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(`${linkText} `);
129 | }
130 | parts.push(' ');
131 | });
132 | parts.push(' ');
133 | parts.push('
');
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 | Read aloud
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 |
--------------------------------------------------------------------------------