@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.
Files changed (67) hide show
  1. package/assets/images/light-raster-thumb.jpg +0 -0
  2. package/assets/images/outdoor-raster-thumb.jpg +0 -0
  3. package/assets/images/road-raster-thumb.jpg +0 -0
  4. package/docs/plugins/search.md +70 -0
  5. package/package.json +16 -2
  6. package/plugins/beta/draw-ml/dist/css/index.css +1 -21
  7. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  8. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  9. package/plugins/beta/draw-ml/src/api/editFeature.js +9 -4
  10. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +11 -3
  11. package/plugins/interact/src/hooks/useAttachEvents.test.js +3 -0
  12. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  13. package/plugins/search/dist/esm/index.js +1 -1
  14. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  15. package/plugins/search/dist/umd/index.js +1 -1
  16. package/plugins/search/src/Search.jsx +2 -1
  17. package/plugins/search/src/components/Form/Form.jsx +11 -7
  18. package/plugins/search/src/components/Form/Form.test.jsx +23 -1
  19. package/plugins/search/src/datasets.js +2 -2
  20. package/plugins/search/src/datasets.test.js +1 -1
  21. package/plugins/search/src/defaults.js +7 -1
  22. package/plugins/search/src/events/fetchSuggestions.js +13 -10
  23. package/plugins/search/src/events/fetchSuggestions.test.js +14 -1
  24. package/plugins/search/src/events/formHandlers.js +2 -4
  25. package/plugins/search/src/events/formHandlers.test.js +3 -3
  26. package/plugins/search/src/events/inputHandlers.js +1 -1
  27. package/plugins/search/src/events/inputHandlers.test.js +1 -1
  28. package/plugins/search/src/events/suggestionHandlers.js +2 -1
  29. package/plugins/search/src/events/suggestionHandlers.test.js +7 -1
  30. package/plugins/search/src/index.js +3 -7
  31. package/plugins/search/src/reducer.js +10 -5
  32. package/plugins/search/src/reducer.test.js +2 -1
  33. package/plugins/search/src/utils/parseOsNamesResults.js +3 -3
  34. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -0
  35. package/providers/beta/openlayers/dist/esm/index.js +1 -0
  36. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +2 -0
  37. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js.LICENSE.txt +1 -0
  38. package/providers/beta/openlayers/dist/umd/index.js +2 -0
  39. package/providers/beta/openlayers/dist/umd/index.js.LICENSE.txt +1 -0
  40. package/providers/beta/openlayers/src/appEvents.js +50 -0
  41. package/providers/beta/openlayers/src/appEvents.test.js +140 -0
  42. package/providers/beta/openlayers/src/defaults.js +36 -0
  43. package/providers/beta/openlayers/src/index.js +21 -0
  44. package/providers/beta/openlayers/src/mapEvents.js +132 -0
  45. package/providers/beta/openlayers/src/mapEvents.test.js +197 -0
  46. package/providers/beta/openlayers/src/openlayersProvider.js +234 -0
  47. package/providers/beta/openlayers/src/openlayersProvider.test.js +331 -0
  48. package/providers/beta/openlayers/src/utils/spatial.js +142 -0
  49. package/providers/beta/openlayers/src/utils/spatial.test.js +117 -0
  50. package/providers/beta/openlayers/src/utils/zoom.js +26 -0
  51. package/providers/beta/openlayers/src/utils/zoom.test.js +23 -0
  52. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  53. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  54. package/providers/maplibre/src/utils/highlightFeatures.js +17 -7
  55. package/providers/maplibre/src/utils/highlightFeatures.test.js +17 -0
  56. package/providers/maplibre/src/utils/patternImages.js +14 -9
  57. package/providers/maplibre/src/utils/patternImages.test.js +2 -1
  58. package/rollup.esm.mjs +14 -3
  59. package/src/App/hooks/useFeatureItems.test.js +1 -0
  60. package/src/App/hooks/useLayoutMeasurements.test.js +0 -1
  61. package/src/App/hooks/useMarkersAPI.test.js +1 -0
  62. package/src/App/hooks/useModalPanelBehaviour.test.js +2 -1
  63. package/src/App/store/AppProvider.test.jsx +5 -0
  64. package/src/App/store/MapProvider.test.jsx +8 -1
  65. package/src/services/announcer.test.js +5 -7
  66. package/webpack.dev.mjs +6 -1
  67. 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
+ })