@defra/interactive-map 0.0.23-alpha → 0.0.24-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/images/light-raster-thumb.jpg +0 -0
- package/assets/images/outdoor-raster-thumb.jpg +0 -0
- package/assets/images/road-raster-thumb.jpg +0 -0
- package/docs/plugins/search.md +70 -0
- package/package.json +16 -2
- package/plugins/beta/draw-ml/dist/css/index.css +1 -21
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/src/api/editFeature.js +9 -4
- package/plugins/beta/draw-ml/src/modes/createDrawMode.js +11 -3
- package/plugins/interact/src/hooks/useAttachEvents.test.js +3 -0
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/esm/index.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/index.js +1 -1
- package/plugins/search/src/Search.jsx +2 -1
- package/plugins/search/src/components/Form/Form.jsx +11 -7
- package/plugins/search/src/components/Form/Form.test.jsx +23 -1
- package/plugins/search/src/datasets.js +2 -2
- package/plugins/search/src/datasets.test.js +1 -1
- package/plugins/search/src/defaults.js +7 -1
- package/plugins/search/src/events/fetchSuggestions.js +13 -10
- package/plugins/search/src/events/fetchSuggestions.test.js +14 -1
- package/plugins/search/src/events/formHandlers.js +2 -4
- package/plugins/search/src/events/formHandlers.test.js +3 -3
- package/plugins/search/src/events/inputHandlers.js +1 -1
- package/plugins/search/src/events/inputHandlers.test.js +1 -1
- package/plugins/search/src/events/suggestionHandlers.js +2 -1
- package/plugins/search/src/events/suggestionHandlers.test.js +7 -1
- package/plugins/search/src/index.js +3 -7
- package/plugins/search/src/reducer.js +10 -5
- package/plugins/search/src/reducer.test.js +2 -1
- package/plugins/search/src/utils/parseOsNamesResults.js +3 -3
- package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -0
- package/providers/beta/openlayers/dist/esm/index.js +1 -0
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +2 -0
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js.LICENSE.txt +1 -0
- package/providers/beta/openlayers/dist/umd/index.js +2 -0
- package/providers/beta/openlayers/dist/umd/index.js.LICENSE.txt +1 -0
- package/providers/beta/openlayers/src/appEvents.js +50 -0
- package/providers/beta/openlayers/src/appEvents.test.js +140 -0
- package/providers/beta/openlayers/src/defaults.js +36 -0
- package/providers/beta/openlayers/src/index.js +21 -0
- package/providers/beta/openlayers/src/mapEvents.js +132 -0
- package/providers/beta/openlayers/src/mapEvents.test.js +197 -0
- package/providers/beta/openlayers/src/openlayersProvider.js +234 -0
- package/providers/beta/openlayers/src/openlayersProvider.test.js +331 -0
- package/providers/beta/openlayers/src/utils/spatial.js +142 -0
- package/providers/beta/openlayers/src/utils/spatial.test.js +117 -0
- package/providers/beta/openlayers/src/utils/zoom.js +26 -0
- package/providers/beta/openlayers/src/utils/zoom.test.js +23 -0
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/src/utils/highlightFeatures.js +17 -7
- package/providers/maplibre/src/utils/highlightFeatures.test.js +17 -0
- package/providers/maplibre/src/utils/patternImages.js +14 -9
- package/providers/maplibre/src/utils/patternImages.test.js +2 -1
- package/rollup.esm.mjs +14 -3
- package/src/App/hooks/useFeatureItems.test.js +1 -0
- package/src/App/hooks/useLayoutMeasurements.test.js +0 -1
- package/src/App/hooks/useMarkersAPI.test.js +1 -0
- package/src/App/hooks/useModalPanelBehaviour.test.js +2 -1
- package/src/App/store/AppProvider.test.jsx +5 -0
- package/src/App/store/MapProvider.test.jsx +8 -1
- package/src/services/announcer.test.js +5 -7
- package/webpack.dev.mjs +6 -1
- package/webpack.umd.mjs +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/*! For license information please see index.js.LICENSE.txt */
|
|
2
|
+
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define("defra",[],t):"object"==typeof exports?exports.defra=t():(e.defra=e.defra||{},e.defra.openLayersProvider=t())}(this,()=>(()=>{"use strict";var e,t,r={},n={};function o(e){var t=n[e];if(void 0!==t)return t.exports;var i=n[e]={exports:{}};return r[e](i,i.exports,o),i.exports}o.m=r,o.d=(e,t)=>{for(var r in t)o.o(t,r)&&!o.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o.f={},o.e=e=>Promise.all(Object.keys(o.f).reduce((t,r)=>(o.f[r](e,t),t),[])),o.u=e=>"im-openlayers-provider.js",o.miniCssF=e=>{},o.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),e={},t="defra.openLayersProvider:",o.l=(r,n,i,a)=>{if(e[r])e[r].push(n);else{var u,c;if(void 0!==i)for(var f=document.getElementsByTagName("script"),p=0;p<f.length;p++){var s=f[p];if(s.getAttribute("src")==r||s.getAttribute("data-webpack")==t+i){u=s;break}}u||(c=!0,(u=document.createElement("script")).charset="utf-8",o.nc&&u.setAttribute("nonce",o.nc),u.setAttribute("data-webpack",t+i),u.src=r),e[r]=[n];var l=(t,n)=>{u.onerror=u.onload=null,clearTimeout(d);var o=e[r];if(delete e[r],u.parentNode&&u.parentNode.removeChild(u),o&&o.forEach(e=>e(n)),t)return t(n)},d=setTimeout(l.bind(null,void 0,{type:"timeout",target:u}),12e4);u.onerror=l.bind(null,u.onerror),u.onload=l.bind(null,u.onload),c&&document.head.appendChild(u)}},o.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;o.g.importScripts&&(e=o.g.location+"");var t=o.g.document;if(!e&&t&&(t.currentScript&&"SCRIPT"===t.currentScript.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){var r=t.getElementsByTagName("script");if(r.length)for(var n=r.length-1;n>-1&&(!e||!/^http(s?):/.test(e));)e=r[n--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),o.p=e})(),(()=>{var e={57:0};o.f.j=(t,r)=>{var n=o.o(e,t)?e[t]:void 0;if(0!==n)if(n)r.push(n[2]);else{var i=new Promise((r,o)=>n=e[t]=[r,o]);r.push(n[2]=i);var a=o.p+o.u(t),u=new Error;o.l(a,r=>{if(o.o(e,t)&&(0!==(n=e[t])&&(e[t]=void 0),n)){var i=r&&("load"===r.type?"missing":r.type),a=r&&r.target&&r.target.src;u.message="Loading chunk "+t+" failed.\n("+i+": "+a+")",u.name="ChunkLoadError",u.type=i,u.request=a,n[1](u)}},"chunk-"+t,t)}};var t=(t,r)=>{var n,i,[a,u,c]=r,f=0;if(a.some(t=>0!==e[t])){for(n in u)o.o(u,n)&&(o.m[n]=u[n]);c&&c(o)}for(t&&t(r);f<a.length;f++)i=a[f],o.o(e,i)&&e[i]&&e[i][0](),e[i]=0},r=this.webpackChunkdefra_DefraMap=this.webpackChunkdefra_DefraMap||[];r.forEach(t.bind(null,0)),r.push=t.bind(null,r.push.bind(r))})();var i={};function a(){var e,t,r="function"==typeof Symbol?Symbol:{},n=r.iterator||"@@iterator",o=r.toStringTag||"@@toStringTag";function i(r,n,o,i){var a=n&&n.prototype instanceof f?n:f,p=Object.create(a.prototype);return u(p,"_invoke",function(r,n,o){var i,a,u,f=0,p=o||[],s=!1,l={p:0,n:0,v:e,a:d,f:d.bind(e,4),d:function(t,r){return i=t,a=0,u=e,l.n=r,c}};function d(r,n){for(a=r,u=n,t=0;!s&&f&&!o&&t<p.length;t++){var o,i=p[t],d=l.p,v=i[2];r>3?(o=v===n)&&(u=i[(a=i[4])?5:(a=3,3)],i[4]=i[5]=e):i[0]<=d&&((o=r<2&&d<i[1])?(a=0,l.v=n,l.n=i[1]):d<v&&(o=r<3||i[0]>n||n>v)&&(i[4]=r,i[5]=n,l.n=v,a=0))}if(o||r>1)return c;throw s=!0,n}return function(o,p,v){if(f>1)throw TypeError("Generator is already running");for(s&&1===p&&d(p,v),a=p,u=v;(t=a<2?e:u)||!s;){i||(a?a<3?(a>1&&(l.n=-1),d(a,u)):l.n=u:l.v=u);try{if(f=2,i){if(a||(o="next"),t=i[o]){if(!(t=t.call(i,u)))throw TypeError("iterator result is not an object");if(!t.done)return t;u=t.value,a<2&&(a=0)}else 1===a&&(t=i.return)&&t.call(i),a<2&&(u=TypeError("The iterator does not provide a '"+o+"' method"),a=1);i=e}else if((t=(s=l.n<0)?u:r.call(n,l))!==c)break}catch(t){i=e,a=1,u=t}finally{f=1}}return{value:t,done:s}}}(r,o,i),!0),p}var c={};function f(){}function p(){}function s(){}t=Object.getPrototypeOf;var l=[][n]?t(t([][n]())):(u(t={},n,function(){return this}),t),d=s.prototype=f.prototype=Object.create(l);function v(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,s):(e.__proto__=s,u(e,o,"GeneratorFunction")),e.prototype=Object.create(d),e}return p.prototype=s,u(d,"constructor",s),u(s,"constructor",p),p.displayName="GeneratorFunction",u(s,o,"GeneratorFunction"),u(d),u(d,o,"Generator"),u(d,n,function(){return this}),u(d,"toString",function(){return"[object Generator]"}),(a=function(){return{w:i,m:v}})()}function u(e,t,r,n){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}u=function(e,t,r,n){function i(t,r){u(e,t,function(e){return this._invoke(t,r,e)})}t?o?o(e,t,{value:r,enumerable:!n,configurable:!n,writable:!n}):e[t]=r:(i("next",0),i("throw",1),i("return",2))},u(e,t,r,n)}function c(e,t,r,n,o,i,a){try{var u=e[i](a),c=u.value}catch(e){return void r(e)}u.done?t(c):Promise.resolve(c).then(n,o)}function f(){var e,t,r=(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}).zoomAlignment;return{checkDeviceCapabilities:function(){return{isSupported:!!document.createElement("canvas").getContext,error:"Canvas is not supported in this browser"}},load:(e=a().m(function e(){var t,n;return a().w(function(e){for(;;)switch(e.n){case 0:return e.n=1,o.e(142).then(o.bind(o,107));case 1:return t=e.v.default,n={zoomAlignment:r,crs:"EPSG:27700"},e.a(2,{MapProvider:t,mapProviderConfig:n})}},e)}),t=function(){var t=this,r=arguments;return new Promise(function(n,o){var i=e.apply(t,r);function a(e){c(i,n,o,a,u,"next",e)}function u(e){c(i,n,o,a,u,"throw",e)}a(void 0)})},function(){return t.apply(this,arguments)})}}return o.d(i,{default:()=>f}),i.default})());
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import XYZ from 'ol/source/XYZ.js'
|
|
2
|
+
import TileGrid from 'ol/tilegrid/TileGrid.js'
|
|
3
|
+
import TileState from 'ol/TileState.js'
|
|
4
|
+
import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from './defaults.js'
|
|
5
|
+
|
|
6
|
+
const createTileLoadFunction = (transformRequest) => (tile, src) => {
|
|
7
|
+
const result = transformRequest(src, 'Tile') || {}
|
|
8
|
+
const url = result.url || src
|
|
9
|
+
const headers = result.headers || {}
|
|
10
|
+
fetch(url, { headers })
|
|
11
|
+
.then(r => r.blob())
|
|
12
|
+
.then(blob => { tile.getImage().src = URL.createObjectURL(blob) })
|
|
13
|
+
.catch(() => tile.setState(TileState.ERROR))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createTileSource (url, transformRequest) {
|
|
17
|
+
const tileGrid = new TileGrid({
|
|
18
|
+
resolutions: TILE_GRID_RESOLUTIONS,
|
|
19
|
+
origin: TILE_GRID_ORIGIN,
|
|
20
|
+
tileSize: TILE_SIZE
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const tileUrlFunction = ([z, x, y]) => url
|
|
24
|
+
.replace('{z}', z)
|
|
25
|
+
.replace('{x}', x)
|
|
26
|
+
.replace('{y}', y)
|
|
27
|
+
|
|
28
|
+
return new XYZ({
|
|
29
|
+
projection: 'EPSG:27700',
|
|
30
|
+
tileGrid,
|
|
31
|
+
tileUrlFunction,
|
|
32
|
+
tileLoadFunction: transformRequest ? createTileLoadFunction(transformRequest) : undefined
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function attachAppEvents ({ tileLayer, transformRequest, events, eventBus }) {
|
|
37
|
+
const handleSetMapStyle = (mapStyle) => {
|
|
38
|
+
const source = createTileSource(mapStyle.url, transformRequest)
|
|
39
|
+
tileLayer.setSource(source)
|
|
40
|
+
eventBus.emit(events.MAP_STYLE_CHANGE, { mapStyleId: mapStyle.id })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle)
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
remove () {
|
|
47
|
+
eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import XYZ from 'ol/source/XYZ.js'
|
|
2
|
+
import TileGrid from 'ol/tilegrid/TileGrid.js'
|
|
3
|
+
import { createTileSource, attachAppEvents } from './appEvents.js'
|
|
4
|
+
import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from './defaults.js'
|
|
5
|
+
|
|
6
|
+
const mockTileGridInstance = {}
|
|
7
|
+
const mockSourceInstance = {}
|
|
8
|
+
|
|
9
|
+
jest.mock('ol/source/XYZ.js', () => ({ __esModule: true, default: jest.fn(() => mockSourceInstance) }))
|
|
10
|
+
jest.mock('ol/tilegrid/TileGrid.js', () => ({ __esModule: true, default: jest.fn(() => mockTileGridInstance) }))
|
|
11
|
+
jest.mock('ol/TileState.js', () => ({ __esModule: true, default: { ERROR: 'error' } }))
|
|
12
|
+
|
|
13
|
+
const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0))
|
|
14
|
+
|
|
15
|
+
describe('createTileSource', () => {
|
|
16
|
+
beforeEach(() => jest.clearAllMocks())
|
|
17
|
+
|
|
18
|
+
it('creates TileGrid with correct OS tile grid config', () => {
|
|
19
|
+
createTileSource('https://tiles.example.com/{z}/{x}/{y}', null)
|
|
20
|
+
expect(TileGrid).toHaveBeenCalledWith({
|
|
21
|
+
resolutions: TILE_GRID_RESOLUTIONS,
|
|
22
|
+
origin: TILE_GRID_ORIGIN,
|
|
23
|
+
tileSize: TILE_SIZE
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('creates XYZ source with EPSG:27700 projection', () => {
|
|
28
|
+
createTileSource('https://tiles.example.com/{z}/{x}/{y}', null)
|
|
29
|
+
expect(XYZ).toHaveBeenCalledWith(expect.objectContaining({ projection: 'EPSG:27700' }))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('tileUrlFunction substitutes z, x, y into url template', () => {
|
|
33
|
+
createTileSource('https://tiles.example.com/{z}/{x}/{y}', null)
|
|
34
|
+
const { tileUrlFunction } = XYZ.mock.calls[0][0]
|
|
35
|
+
expect(tileUrlFunction([7, 3, 5])).toBe('https://tiles.example.com/7/3/5')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('does not set tileLoadFunction when transformRequest is null', () => {
|
|
39
|
+
createTileSource('https://tiles.example.com/{z}/{x}/{y}', null)
|
|
40
|
+
const { tileLoadFunction } = XYZ.mock.calls[0][0]
|
|
41
|
+
expect(tileLoadFunction).toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('sets tileLoadFunction when transformRequest is provided', () => {
|
|
45
|
+
createTileSource('https://tiles.example.com/{z}/{x}/{y}', jest.fn())
|
|
46
|
+
const { tileLoadFunction } = XYZ.mock.calls[0][0]
|
|
47
|
+
expect(typeof tileLoadFunction).toBe('function')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('tileLoadFunction (via transformRequest)', () => {
|
|
52
|
+
const url = 'https://tiles.example.com/7/3/5'
|
|
53
|
+
const mockImg = { src: null }
|
|
54
|
+
const mockTile = { getImage: () => mockImg, setState: jest.fn() }
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
jest.clearAllMocks()
|
|
58
|
+
mockImg.src = null
|
|
59
|
+
global.URL.createObjectURL = jest.fn(() => 'blob:test')
|
|
60
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
61
|
+
blob: () => Promise.resolve(new Blob())
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
function getTileLoadFn (transformRequest) {
|
|
66
|
+
createTileSource('https://tiles.example.com/{z}/{x}/{y}', transformRequest)
|
|
67
|
+
return XYZ.mock.calls[0][0].tileLoadFunction
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
it('calls transformRequest with src and "Tile" resource type', async () => {
|
|
71
|
+
const transformRequest = jest.fn(() => null)
|
|
72
|
+
const fn = getTileLoadFn(transformRequest)
|
|
73
|
+
fn(mockTile, url)
|
|
74
|
+
expect(transformRequest).toHaveBeenCalledWith(url, 'Tile')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('uses url and headers from transformRequest result', async () => {
|
|
78
|
+
const transformRequest = jest.fn(() => ({ url: 'https://proxied.example.com/tile', headers: { Authorization: 'Bearer abc' } }))
|
|
79
|
+
const fn = getTileLoadFn(transformRequest)
|
|
80
|
+
fn(mockTile, url)
|
|
81
|
+
await flushPromises()
|
|
82
|
+
expect(fetch).toHaveBeenCalledWith('https://proxied.example.com/tile', { headers: { Authorization: 'Bearer abc' } })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('falls back to original src and empty headers when transformRequest returns null', async () => {
|
|
86
|
+
const fn = getTileLoadFn(() => null)
|
|
87
|
+
fn(mockTile, url)
|
|
88
|
+
await flushPromises()
|
|
89
|
+
expect(fetch).toHaveBeenCalledWith(url, { headers: {} })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('sets tile image src via createObjectURL on success', async () => {
|
|
93
|
+
const fn = getTileLoadFn(() => null)
|
|
94
|
+
fn(mockTile, url)
|
|
95
|
+
await flushPromises()
|
|
96
|
+
expect(mockImg.src).toBe('blob:test')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('sets tile state to ERROR on fetch failure', async () => {
|
|
100
|
+
global.fetch = jest.fn().mockRejectedValue(new Error('network error'))
|
|
101
|
+
const fn = getTileLoadFn(() => null)
|
|
102
|
+
fn(mockTile, url)
|
|
103
|
+
await flushPromises()
|
|
104
|
+
expect(mockTile.setState).toHaveBeenCalledWith('error')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('attachAppEvents', () => {
|
|
109
|
+
const events = {
|
|
110
|
+
MAP_SET_STYLE: 'map:setstyle',
|
|
111
|
+
MAP_STYLE_CHANGE: 'map:stylechange'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function makeSetup () {
|
|
115
|
+
jest.clearAllMocks()
|
|
116
|
+
const tileLayer = { setSource: jest.fn() }
|
|
117
|
+
const eventBus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
|
|
118
|
+
const handles = attachAppEvents({ tileLayer, transformRequest: null, events, eventBus })
|
|
119
|
+
const styleHandler = eventBus.on.mock.calls[0][1]
|
|
120
|
+
return { tileLayer, eventBus, handles, styleHandler }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
it('subscribes to MAP_SET_STYLE on the event bus', () => {
|
|
124
|
+
const { eventBus } = makeSetup()
|
|
125
|
+
expect(eventBus.on).toHaveBeenCalledWith(events.MAP_SET_STYLE, expect.any(Function))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('on MAP_SET_STYLE: updates tile layer source and emits MAP_STYLE_CHANGE', () => {
|
|
129
|
+
const { tileLayer, eventBus, styleHandler } = makeSetup()
|
|
130
|
+
styleHandler({ url: 'https://new.tiles.com/{z}/{x}/{y}', id: 'newStyle' })
|
|
131
|
+
expect(tileLayer.setSource).toHaveBeenCalledWith(mockSourceInstance)
|
|
132
|
+
expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_STYLE_CHANGE, { mapStyleId: 'newStyle' })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('remove() unsubscribes from MAP_SET_STYLE', () => {
|
|
136
|
+
const { eventBus, handles, styleHandler } = makeSetup()
|
|
137
|
+
handles.remove()
|
|
138
|
+
expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SET_STYLE, styleHandler)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// OS Maps API WMTS tile grid for EPSG:27700 — used by the tile source only
|
|
2
|
+
export const TILE_GRID_RESOLUTIONS = [896, 448, 224, 112, 56, 28, 14, 7, 3.5, 1.75, 0.875, 0.4375, 0.21875, 0.109375]
|
|
3
|
+
|
|
4
|
+
// World (ESRI-derived) LOD resolutions for EPSG:27700. When used in the OL View,
|
|
5
|
+
// view.zoom matches ESRI SDK zoom levels exactly. OL maps between view resolution
|
|
6
|
+
// and the nearest OS tile grid level automatically when loading tiles.
|
|
7
|
+
export const WORLD_LOD_RESOLUTIONS = [
|
|
8
|
+
156543.03392800014, 78271.51696400007, 39135.758482000034, 19567.879241000017,
|
|
9
|
+
9783.939620500008, 4891.969810250004, 2445.984905125002, 1222.992452562501,
|
|
10
|
+
611.4962262812505, 305.74811314062526, 152.87405657031263, 76.43702828515632,
|
|
11
|
+
38.21851414257816, 19.10925707128908, 9.55462853564454, 4.77731426782227,
|
|
12
|
+
2.388657133911135, 1.1943285669555674, 0.5971642834777837, 0.29858214173889186,
|
|
13
|
+
0.14929107086944593, 0.07464553543472296, 0.03732276771736148, 0.01866138385868074
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
// ESRI LOD 6 (~2446 m/px) matches the ESRI SDK's minimum zoom for EPSG:27700
|
|
17
|
+
export const WORLD_DEFAULT_MIN_ZOOM = 6
|
|
18
|
+
|
|
19
|
+
// ESRI LOD 20 (~0.149 m/px) is the last level that maps to a unique OS tile (zoom 13).
|
|
20
|
+
// Past this point OL upscales the same zoom-13 tiles with no additional detail.
|
|
21
|
+
export const WORLD_DEFAULT_MAX_ZOOM = 20
|
|
22
|
+
|
|
23
|
+
// Top-left corner of the OS National Grid extent
|
|
24
|
+
export const TILE_GRID_ORIGIN = [-238375.0, 1376256.0]
|
|
25
|
+
|
|
26
|
+
export const TILE_SIZE = 256
|
|
27
|
+
|
|
28
|
+
export const DEFAULTS = {
|
|
29
|
+
animationDuration: 300,
|
|
30
|
+
coordinatePrecision: 2
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Zoom level tolerance for min/max checks (fractional zoom)
|
|
34
|
+
export const ZOOM_TOLERANCE = 0.01
|
|
35
|
+
|
|
36
|
+
export const supportedShortcuts = ['showKeyboardHelp', 'selectControl', 'moveLarge', 'nudgeMap', 'zoomLarge', 'nudgeZoom']
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export default function createOpenLayersProvider ({ zoomAlignment } = {}) {
|
|
2
|
+
return {
|
|
3
|
+
checkDeviceCapabilities: () => ({
|
|
4
|
+
isSupported: !!document.createElement('canvas').getContext,
|
|
5
|
+
error: 'Canvas is not supported in this browser'
|
|
6
|
+
}),
|
|
7
|
+
load: async () => {
|
|
8
|
+
const MapProvider = (await import(/* webpackChunkName: "im-openlayers-provider" */ './openlayersProvider.js')).default
|
|
9
|
+
|
|
10
|
+
const mapProviderConfig = {
|
|
11
|
+
zoomAlignment,
|
|
12
|
+
crs: 'EPSG:27700'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
MapProvider,
|
|
17
|
+
mapProviderConfig
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { debounce } from '../../../../src/utils/debounce.js'
|
|
2
|
+
import { throttle } from '../../../../src/utils/throttle.js'
|
|
3
|
+
import { ZOOM_TOLERANCE } from './defaults.js'
|
|
4
|
+
|
|
5
|
+
const DEBOUNCE_IDLE_TIME = 500
|
|
6
|
+
const MOVE_THROTTLE_TIME = 10
|
|
7
|
+
const DRAG_TOLERANCE = 6
|
|
8
|
+
|
|
9
|
+
function attachLoadEvents ({ source, map, emit, eventBus, events, on }) {
|
|
10
|
+
let loadedEmitted = false
|
|
11
|
+
on(source, 'tileloadend', () => {
|
|
12
|
+
if (!loadedEmitted) {
|
|
13
|
+
loadedEmitted = true
|
|
14
|
+
eventBus.emit(events.MAP_LOADED)
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
map.once('rendercomplete', () => emit(events.MAP_FIRST_IDLE))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function attachMoveEvents ({ map, view, emit, eventBus, events, on, debouncers }) {
|
|
21
|
+
let moving = false
|
|
22
|
+
|
|
23
|
+
const emitMoveEnd = debounce(() => {
|
|
24
|
+
moving = false
|
|
25
|
+
emit(events.MAP_MOVE_END)
|
|
26
|
+
}, DEBOUNCE_IDLE_TIME)
|
|
27
|
+
debouncers.push(emitMoveEnd)
|
|
28
|
+
|
|
29
|
+
const emitMove = throttle(() => emit(events.MAP_MOVE), MOVE_THROTTLE_TIME)
|
|
30
|
+
|
|
31
|
+
on(view, 'change', () => {
|
|
32
|
+
if (!moving) {
|
|
33
|
+
moving = true
|
|
34
|
+
eventBus.emit(events.MAP_MOVE_START)
|
|
35
|
+
}
|
|
36
|
+
emitMoveEnd()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Drive MAP_MOVE from postrender so resolution reads the actual animated value each frame,
|
|
40
|
+
// matching MapLibre/ESRI smooth scale bar behaviour
|
|
41
|
+
on(map, 'postrender', () => {
|
|
42
|
+
if (moving) {
|
|
43
|
+
emitMove()
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function attachClickEvents ({ map, eventBus, events, on }) {
|
|
49
|
+
let pointerDownPixel = null
|
|
50
|
+
|
|
51
|
+
on(map, 'pointerdown', (e) => {
|
|
52
|
+
pointerDownPixel = e.pixel
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
on(map, 'click', (e) => {
|
|
56
|
+
if (pointerDownPixel) {
|
|
57
|
+
const dx = e.pixel[0] - pointerDownPixel[0]
|
|
58
|
+
const dy = e.pixel[1] - pointerDownPixel[1]
|
|
59
|
+
if (Math.hypot(dx, dy) > DRAG_TOLERANCE) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
eventBus.emit(events.MAP_CLICK, {
|
|
64
|
+
point: { x: e.pixel[0], y: e.pixel[1] },
|
|
65
|
+
coords: e.coordinate
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function attachMapEvents ({
|
|
71
|
+
map,
|
|
72
|
+
source,
|
|
73
|
+
events,
|
|
74
|
+
eventBus,
|
|
75
|
+
getZoom,
|
|
76
|
+
getCenter,
|
|
77
|
+
getBounds,
|
|
78
|
+
getResolution
|
|
79
|
+
}) {
|
|
80
|
+
let destroyed = false
|
|
81
|
+
const listeners = []
|
|
82
|
+
const debouncers = []
|
|
83
|
+
|
|
84
|
+
const view = map.getView()
|
|
85
|
+
const viewMinZoom = view.getMinZoom()
|
|
86
|
+
const viewMaxZoom = view.getMaxZoom()
|
|
87
|
+
|
|
88
|
+
const getMapState = () => {
|
|
89
|
+
if (destroyed) {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
const zoom = getZoom()
|
|
93
|
+
return {
|
|
94
|
+
center: getCenter(),
|
|
95
|
+
bounds: getBounds(),
|
|
96
|
+
resolution: getResolution(),
|
|
97
|
+
zoom,
|
|
98
|
+
isAtMaxZoom: zoom + ZOOM_TOLERANCE >= viewMaxZoom,
|
|
99
|
+
isAtMinZoom: zoom - ZOOM_TOLERANCE <= viewMinZoom
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const emit = (name) => {
|
|
104
|
+
const state = getMapState()
|
|
105
|
+
if (state) {
|
|
106
|
+
eventBus.emit(name, state)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const on = (target, type, handler) => {
|
|
111
|
+
target.on(type, handler)
|
|
112
|
+
listeners.push([target, type, handler])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
attachLoadEvents({ source, map, emit, eventBus, events, on })
|
|
116
|
+
attachMoveEvents({ map, view: map.getView(), emit, eventBus, events, on, debouncers })
|
|
117
|
+
attachClickEvents({ map, eventBus, events, on })
|
|
118
|
+
|
|
119
|
+
on(map, 'postrender', () => eventBus.emit(events.MAP_RENDER))
|
|
120
|
+
|
|
121
|
+
const emitDataChange = debounce(() => emit(events.MAP_DATA_CHANGE), DEBOUNCE_IDLE_TIME)
|
|
122
|
+
debouncers.push(emitDataChange)
|
|
123
|
+
on(source, 'tileloadend', emitDataChange)
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
remove () {
|
|
127
|
+
destroyed = true
|
|
128
|
+
debouncers.forEach(d => d.cancel())
|
|
129
|
+
listeners.forEach(([target, type, handler]) => target.un(type, handler))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Non-passthrough debounce: captures fn without calling it so tests can control when it fires
|
|
2
|
+
import { attachMapEvents } from './mapEvents.js'
|
|
3
|
+
|
|
4
|
+
const mockDebounceFns = []
|
|
5
|
+
|
|
6
|
+
jest.mock('../../../../src/utils/debounce.js', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
debounce: (fn) => {
|
|
9
|
+
const wrapper = jest.fn()
|
|
10
|
+
wrapper.cancel = jest.fn()
|
|
11
|
+
mockDebounceFns.push({ fn, wrapper })
|
|
12
|
+
return wrapper
|
|
13
|
+
}
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
// Passthrough throttle: calls fn immediately (no cancel needed, not added to debouncers)
|
|
17
|
+
jest.mock('../../../../src/utils/throttle.js', () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
throttle: (fn) => jest.fn(fn)
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
const events = {
|
|
23
|
+
MAP_LOADED: 'map:loaded',
|
|
24
|
+
MAP_FIRST_IDLE: 'map:firstidle',
|
|
25
|
+
MAP_MOVE_START: 'map:movestart',
|
|
26
|
+
MAP_MOVE: 'map:move',
|
|
27
|
+
MAP_MOVE_END: 'map:moveend',
|
|
28
|
+
MAP_CLICK: 'map:click',
|
|
29
|
+
MAP_RENDER: 'map:render',
|
|
30
|
+
MAP_DATA_CHANGE: 'map:datachange'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeTarget () {
|
|
34
|
+
const handlers = {}
|
|
35
|
+
return {
|
|
36
|
+
on: jest.fn((type, handler) => { (handlers[type] = handlers[type] || []).push(handler) }),
|
|
37
|
+
once: jest.fn((type, handler) => { (handlers[type] = handlers[type] || []).push(handler) }),
|
|
38
|
+
un: jest.fn(),
|
|
39
|
+
trigger: (type, event = {}) => (handlers[type] || []).forEach(h => h(event))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setup () {
|
|
44
|
+
mockDebounceFns.length = 0
|
|
45
|
+
|
|
46
|
+
const view = makeTarget()
|
|
47
|
+
view.getMinZoom = jest.fn(() => 0)
|
|
48
|
+
view.getMaxZoom = jest.fn(() => 13)
|
|
49
|
+
|
|
50
|
+
const map = makeTarget()
|
|
51
|
+
map.getView = jest.fn(() => view)
|
|
52
|
+
|
|
53
|
+
const source = makeTarget()
|
|
54
|
+
const eventBus = { emit: jest.fn() }
|
|
55
|
+
|
|
56
|
+
const getZoom = jest.fn(() => 7)
|
|
57
|
+
const getCenter = jest.fn(() => [400000, 300000])
|
|
58
|
+
const getBounds = jest.fn(() => [0, 0, 800, 600])
|
|
59
|
+
const getResolution = jest.fn(() => 56)
|
|
60
|
+
|
|
61
|
+
const handles = attachMapEvents({ map, source, events, eventBus, getZoom, getCenter, getBounds, getResolution })
|
|
62
|
+
|
|
63
|
+
return { map, view, source, eventBus, handles }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('attachMapEvents — load events', () => {
|
|
67
|
+
it('emits MAP_LOADED on first tileloadend', () => {
|
|
68
|
+
const { source, eventBus } = setup()
|
|
69
|
+
source.trigger('tileloadend')
|
|
70
|
+
expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_LOADED)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does not re-emit MAP_LOADED on subsequent tileloadend', () => {
|
|
74
|
+
const { source, eventBus } = setup()
|
|
75
|
+
source.trigger('tileloadend')
|
|
76
|
+
eventBus.emit.mockClear()
|
|
77
|
+
source.trigger('tileloadend')
|
|
78
|
+
expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_LOADED)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('emits MAP_FIRST_IDLE on rendercomplete', () => {
|
|
82
|
+
const { map, eventBus } = setup()
|
|
83
|
+
map.trigger('rendercomplete')
|
|
84
|
+
expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_FIRST_IDLE, expect.any(Object))
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('attachMapEvents — move events', () => {
|
|
89
|
+
it('emits MAP_MOVE_START on first view change', () => {
|
|
90
|
+
const { view, eventBus } = setup()
|
|
91
|
+
view.trigger('change')
|
|
92
|
+
expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE_START)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('does not re-emit MAP_MOVE_START while already moving', () => {
|
|
96
|
+
const { view, eventBus } = setup()
|
|
97
|
+
view.trigger('change')
|
|
98
|
+
eventBus.emit.mockClear()
|
|
99
|
+
view.trigger('change')
|
|
100
|
+
expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_MOVE_START)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('emits MAP_MOVE_END (with state) when debounced callback fires', () => {
|
|
104
|
+
const { view, eventBus } = setup()
|
|
105
|
+
view.trigger('change')
|
|
106
|
+
mockDebounceFns[0].fn() // emitMoveEnd's inner callback
|
|
107
|
+
expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE_END, expect.any(Object))
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('resets moving flag after MAP_MOVE_END so MAP_MOVE_START can fire again', () => {
|
|
111
|
+
const { view, eventBus } = setup()
|
|
112
|
+
view.trigger('change')
|
|
113
|
+
mockDebounceFns[0].fn() // resets moving = false
|
|
114
|
+
eventBus.emit.mockClear()
|
|
115
|
+
view.trigger('change')
|
|
116
|
+
expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE_START)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('emits MAP_MOVE on postrender when moving', () => {
|
|
120
|
+
const { view, map, eventBus } = setup()
|
|
121
|
+
view.trigger('change') // sets moving = true (debounce wrapper does not reset it)
|
|
122
|
+
map.trigger('postrender')
|
|
123
|
+
expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE, expect.any(Object))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('does not emit MAP_MOVE on postrender when not moving', () => {
|
|
127
|
+
const { map, eventBus } = setup()
|
|
128
|
+
map.trigger('postrender')
|
|
129
|
+
expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_MOVE, expect.anything())
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('attachMapEvents — click events', () => {
|
|
134
|
+
it('emits MAP_CLICK with point and coords on click with no prior pointerdown', () => {
|
|
135
|
+
const { map, eventBus } = setup()
|
|
136
|
+
map.trigger('click', { pixel: [100, 200], coordinate: [400000, 300000] })
|
|
137
|
+
expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_CLICK, {
|
|
138
|
+
point: { x: 100, y: 200 },
|
|
139
|
+
coords: [400000, 300000]
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('emits MAP_CLICK when drag distance is within tolerance', () => {
|
|
144
|
+
const { map, eventBus } = setup()
|
|
145
|
+
// Math.hypot(4, 3) = 5 < DRAG_TOLERANCE(6)
|
|
146
|
+
map.trigger('pointerdown', { pixel: [100, 200] })
|
|
147
|
+
map.trigger('click', { pixel: [104, 203], coordinate: [400000, 300000] })
|
|
148
|
+
expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_CLICK, expect.any(Object))
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('suppresses MAP_CLICK when drag distance exceeds tolerance', () => {
|
|
152
|
+
const { map, eventBus } = setup()
|
|
153
|
+
// Math.hypot(7, 0) = 7 > DRAG_TOLERANCE(6)
|
|
154
|
+
map.trigger('pointerdown', { pixel: [100, 200] })
|
|
155
|
+
map.trigger('click', { pixel: [107, 200], coordinate: [400000, 300000] })
|
|
156
|
+
expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_CLICK, expect.anything())
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('attachMapEvents — render and data events', () => {
|
|
161
|
+
it('emits MAP_RENDER on every postrender', () => {
|
|
162
|
+
const { map, eventBus } = setup()
|
|
163
|
+
map.trigger('postrender')
|
|
164
|
+
expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_RENDER)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('emits MAP_DATA_CHANGE (with state) when debounced tileloadend callback fires', () => {
|
|
168
|
+
const { source, eventBus } = setup()
|
|
169
|
+
source.trigger('tileloadend')
|
|
170
|
+
mockDebounceFns[1].fn() // emitDataChange's inner callback
|
|
171
|
+
expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_DATA_CHANGE, expect.any(Object))
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('attachMapEvents — remove', () => {
|
|
176
|
+
it('calls cancel on all debouncers', () => {
|
|
177
|
+
const { handles } = setup()
|
|
178
|
+
handles.remove()
|
|
179
|
+
expect(mockDebounceFns[0].wrapper.cancel).toHaveBeenCalled()
|
|
180
|
+
expect(mockDebounceFns[1].wrapper.cancel).toHaveBeenCalled()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('calls un on all registered event targets', () => {
|
|
184
|
+
const { handles, map, view, source } = setup()
|
|
185
|
+
handles.remove()
|
|
186
|
+
expect(view.un).toHaveBeenCalled()
|
|
187
|
+
expect(map.un).toHaveBeenCalled()
|
|
188
|
+
expect(source.un).toHaveBeenCalled()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('prevents state-bearing events from emitting after destroy', () => {
|
|
192
|
+
const { eventBus, handles } = setup()
|
|
193
|
+
handles.remove()
|
|
194
|
+
mockDebounceFns[0].fn() // emitMoveEnd fires after destroy → getMapState returns null
|
|
195
|
+
expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_MOVE_END, expect.anything())
|
|
196
|
+
})
|
|
197
|
+
})
|