@defra/interactive-map 0.0.23-alpha → 0.0.25-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 (75) 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/dist/css/index.css +1 -1
  5. package/docs/api/map-style-config.md +39 -2
  6. package/docs/plugins/search.md +70 -0
  7. package/package.json +17 -2
  8. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  9. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  10. package/plugins/beta/draw-ml/src/api/editFeature.js +9 -4
  11. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +11 -3
  12. package/plugins/beta/scale-bar/dist/css/index.css +31 -1
  13. package/plugins/interact/src/hooks/useAttachEvents.test.js +3 -0
  14. package/plugins/search/dist/css/index.css +250 -1
  15. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  16. package/plugins/search/dist/esm/index.js +1 -1
  17. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  18. package/plugins/search/dist/umd/index.js +1 -1
  19. package/plugins/search/src/Search.jsx +2 -1
  20. package/plugins/search/src/components/Form/Form.jsx +11 -7
  21. package/plugins/search/src/components/Form/Form.test.jsx +23 -1
  22. package/plugins/search/src/datasets.js +2 -2
  23. package/plugins/search/src/datasets.test.js +1 -1
  24. package/plugins/search/src/defaults.js +7 -1
  25. package/plugins/search/src/events/fetchSuggestions.js +13 -10
  26. package/plugins/search/src/events/fetchSuggestions.test.js +14 -1
  27. package/plugins/search/src/events/formHandlers.js +2 -4
  28. package/plugins/search/src/events/formHandlers.test.js +3 -3
  29. package/plugins/search/src/events/inputHandlers.js +1 -1
  30. package/plugins/search/src/events/inputHandlers.test.js +1 -1
  31. package/plugins/search/src/events/suggestionHandlers.js +2 -1
  32. package/plugins/search/src/events/suggestionHandlers.test.js +7 -1
  33. package/plugins/search/src/index.js +3 -7
  34. package/plugins/search/src/reducer.js +10 -5
  35. package/plugins/search/src/reducer.test.js +2 -1
  36. package/plugins/search/src/utils/parseOsNamesResults.js +3 -3
  37. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -0
  38. package/providers/beta/openlayers/dist/esm/index.js +1 -0
  39. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +2 -0
  40. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js.LICENSE.txt +1 -0
  41. package/providers/beta/openlayers/dist/umd/index.js +2 -0
  42. package/providers/beta/openlayers/dist/umd/index.js.LICENSE.txt +1 -0
  43. package/providers/beta/openlayers/src/appEvents.js +39 -0
  44. package/providers/beta/openlayers/src/appEvents.test.js +117 -0
  45. package/providers/beta/openlayers/src/defaults.js +36 -0
  46. package/providers/beta/openlayers/src/index.js +21 -0
  47. package/providers/beta/openlayers/src/mapEvents.js +132 -0
  48. package/providers/beta/openlayers/src/mapEvents.test.js +198 -0
  49. package/providers/beta/openlayers/src/openlayersProvider.js +249 -0
  50. package/providers/beta/openlayers/src/openlayersProvider.test.js +386 -0
  51. package/providers/beta/openlayers/src/utils/spatial.js +146 -0
  52. package/providers/beta/openlayers/src/utils/spatial.test.js +119 -0
  53. package/providers/beta/openlayers/src/utils/tileLayers.js +133 -0
  54. package/providers/beta/openlayers/src/utils/tileLayers.test.js +251 -0
  55. package/providers/beta/openlayers/src/utils/zoom.js +26 -0
  56. package/providers/beta/openlayers/src/utils/zoom.test.js +23 -0
  57. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  58. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  59. package/providers/maplibre/src/utils/highlightFeatures.js +17 -7
  60. package/providers/maplibre/src/utils/highlightFeatures.test.js +17 -0
  61. package/providers/maplibre/src/utils/maplibreFixes.js +1 -1
  62. package/providers/maplibre/src/utils/patternImages.js +14 -9
  63. package/providers/maplibre/src/utils/patternImages.test.js +2 -1
  64. package/rollup.esm.mjs +14 -3
  65. package/src/App/hooks/useFeatureItems.test.js +1 -0
  66. package/src/App/hooks/useLayoutMeasurements.test.js +0 -1
  67. package/src/App/hooks/useMarkersAPI.test.js +1 -0
  68. package/src/App/hooks/useModalPanelBehaviour.test.js +2 -1
  69. package/src/App/layout/layout.module.scss +1 -0
  70. package/src/App/store/AppProvider.test.jsx +5 -0
  71. package/src/App/store/MapProvider.test.jsx +8 -1
  72. package/src/services/announcer.test.js +5 -7
  73. package/src/types.js +11 -1
  74. package/webpack.dev.mjs +14 -5
  75. 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,272));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,39 @@
1
+ import { createTileSource, createVectorTileLayer, createOGCVectorTileLayer } from './utils/tileLayers.js'
2
+
3
+ export { createTileSource, createVectorTileLayer, createOGCVectorTileLayer }
4
+
5
+ export function attachAppEvents ({ mapProvider, layer, layerType, transformRequest, events, eventBus, map }) {
6
+ const handleSetMapStyle = async (mapStyle) => {
7
+ if (layerType === 'raster') {
8
+ const source = createTileSource(mapStyle.url, transformRequest)
9
+ layer.setSource(source)
10
+ } else if (mapStyle.type === 'ogc-vt') {
11
+ const { layer: newLayer } = await createOGCVectorTileLayer(mapStyle.url, transformRequest)
12
+ map.getLayers().setAt(0, newLayer)
13
+ } else {
14
+ const { layer: newLayer } = await createVectorTileLayer(mapStyle.url, transformRequest)
15
+ map.getLayers().setAt(0, newLayer)
16
+ }
17
+ eventBus.emit(events.MAP_STYLE_CHANGE, { mapStyleId: mapStyle.id })
18
+ }
19
+
20
+ const handleSetPixelRatio = (pixelRatio) => {
21
+ map.setPixelRatio(pixelRatio)
22
+ }
23
+
24
+ const handleSizeChange = ({ mapSize }) => {
25
+ mapProvider.mapSize = mapSize
26
+ }
27
+
28
+ eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle)
29
+ eventBus.on(events.MAP_SET_PIXEL_RATIO, handleSetPixelRatio)
30
+ eventBus.on(events.MAP_SIZE_CHANGE, handleSizeChange)
31
+
32
+ return {
33
+ remove () {
34
+ eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle)
35
+ eventBus.off(events.MAP_SET_PIXEL_RATIO, handleSetPixelRatio)
36
+ eventBus.off(events.MAP_SIZE_CHANGE, handleSizeChange)
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,117 @@
1
+ import { attachAppEvents } from './appEvents.js'
2
+
3
+ import { createTileSource, createVectorTileLayer } from './utils/tileLayers.js'
4
+
5
+ const mockSourceInstance = {}
6
+ const mockVectorTileLayerInstance = {}
7
+
8
+ jest.mock('./utils/tileLayers.js', () => ({
9
+ __esModule: true,
10
+ createTileSource: jest.fn(() => mockSourceInstance),
11
+ createVectorTileLayer: jest.fn(async () => ({ layer: mockVectorTileLayerInstance, source: {} }))
12
+ }))
13
+
14
+ const events = {
15
+ MAP_SET_STYLE: 'map:setstyle',
16
+ MAP_STYLE_CHANGE: 'map:stylechange',
17
+ MAP_SET_PIXEL_RATIO: 'map:setpixelratio',
18
+ MAP_SIZE_CHANGE: 'map:sizechange'
19
+ }
20
+
21
+ function makeMap () {
22
+ return { getLayers: jest.fn(() => ({ setAt: jest.fn() })), setPixelRatio: jest.fn() }
23
+ }
24
+
25
+ function makeProvider () {
26
+ return { mapSize: 'small' }
27
+ }
28
+
29
+ describe('attachAppEvents', () => {
30
+ describe('raster', () => {
31
+ function makeSetup () {
32
+ jest.clearAllMocks()
33
+ const layer = { setSource: jest.fn() }
34
+ const eventBus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
35
+ const mapProvider = makeProvider()
36
+ const handles = attachAppEvents({ mapProvider, layer, layerType: 'raster', transformRequest: null, events, eventBus, map: makeMap() })
37
+ const handlerFor = (event) => eventBus.on.mock.calls.find(c => c[0] === event)[1]
38
+ return { layer, eventBus, handles, handlerFor, mapProvider }
39
+ }
40
+
41
+ it('subscribes to MAP_SET_STYLE on the event bus', () => {
42
+ const { eventBus } = makeSetup()
43
+ expect(eventBus.on).toHaveBeenCalledWith(events.MAP_SET_STYLE, expect.any(Function))
44
+ })
45
+
46
+ it('on MAP_SET_STYLE: creates a tile source and sets it on the layer', async () => {
47
+ const { layer, handlerFor } = makeSetup()
48
+ await handlerFor(events.MAP_SET_STYLE)({ url: 'https://new.tiles.com/{z}/{x}/{y}', id: 'newStyle' })
49
+ expect(createTileSource).toHaveBeenCalledWith('https://new.tiles.com/{z}/{x}/{y}', null)
50
+ expect(layer.setSource).toHaveBeenCalledWith(mockSourceInstance)
51
+ })
52
+
53
+ it('on MAP_SET_STYLE: emits MAP_STYLE_CHANGE with the new style id', async () => {
54
+ const { eventBus, handlerFor } = makeSetup()
55
+ await handlerFor(events.MAP_SET_STYLE)({ url: 'https://new.tiles.com/{z}/{x}/{y}', id: 'newStyle' })
56
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_STYLE_CHANGE, { mapStyleId: 'newStyle' })
57
+ })
58
+
59
+ it('remove() unsubscribes all handlers', () => {
60
+ const { eventBus, handles, handlerFor } = makeSetup()
61
+ handles.remove()
62
+ expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SET_STYLE, handlerFor(events.MAP_SET_STYLE))
63
+ expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SET_PIXEL_RATIO, handlerFor(events.MAP_SET_PIXEL_RATIO))
64
+ expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SIZE_CHANGE, handlerFor(events.MAP_SIZE_CHANGE))
65
+ })
66
+ })
67
+
68
+ describe('vector', () => {
69
+ function makeSetup () {
70
+ jest.clearAllMocks()
71
+ const setAt = jest.fn()
72
+ const map = { getLayers: jest.fn(() => ({ setAt })), setPixelRatio: jest.fn() }
73
+ const eventBus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
74
+ const mapProvider = makeProvider()
75
+ const handles = attachAppEvents({ mapProvider, layer: {}, layerType: 'vector', transformRequest: null, events, eventBus, map })
76
+ const handlerFor = (event) => eventBus.on.mock.calls.find(c => c[0] === event)[1]
77
+ return { map, setAt, eventBus, handles, handlerFor, mapProvider }
78
+ }
79
+
80
+ it('on MAP_SET_STYLE: creates a vector tile layer and places it at index 0', async () => {
81
+ const { setAt, handlerFor } = makeSetup()
82
+ await handlerFor(events.MAP_SET_STYLE)({ url: 'https://example.com/styles', id: 'newVts' })
83
+ expect(createVectorTileLayer).toHaveBeenCalledWith('https://example.com/styles', null)
84
+ expect(setAt).toHaveBeenCalledWith(0, mockVectorTileLayerInstance)
85
+ })
86
+
87
+ it('on MAP_SET_STYLE: emits MAP_STYLE_CHANGE with the new style id', async () => {
88
+ const { eventBus, handlerFor } = makeSetup()
89
+ await handlerFor(events.MAP_SET_STYLE)({ url: 'https://example.com/styles', id: 'newVts' })
90
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_STYLE_CHANGE, { mapStyleId: 'newVts' })
91
+ })
92
+ })
93
+
94
+ describe('pixel ratio and map size', () => {
95
+ function makeSetup () {
96
+ jest.clearAllMocks()
97
+ const map = makeMap()
98
+ const eventBus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
99
+ const mapProvider = makeProvider()
100
+ attachAppEvents({ mapProvider, layer: {}, layerType: 'vector', transformRequest: null, events, eventBus, map })
101
+ const handlerFor = (event) => eventBus.on.mock.calls.find(c => c[0] === event)[1]
102
+ return { map, mapProvider, handlerFor }
103
+ }
104
+
105
+ it('on MAP_SET_PIXEL_RATIO: calls map.setPixelRatio with the new ratio', () => {
106
+ const { map, handlerFor } = makeSetup()
107
+ handlerFor(events.MAP_SET_PIXEL_RATIO)(3)
108
+ expect(map.setPixelRatio).toHaveBeenCalledWith(3)
109
+ })
110
+
111
+ it('on MAP_SIZE_CHANGE: updates mapProvider.mapSize', () => {
112
+ const { mapProvider, handlerFor } = makeSetup()
113
+ handlerFor(events.MAP_SIZE_CHANGE)({ mapSize: 'large' })
114
+ expect(mapProvider.mapSize).toBe('large')
115
+ })
116
+ })
117
+ })
@@ -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,198 @@
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
+ source.changed = jest.fn()
55
+ const eventBus = { emit: jest.fn() }
56
+
57
+ const getZoom = jest.fn(() => 7)
58
+ const getCenter = jest.fn(() => [400000, 300000])
59
+ const getBounds = jest.fn(() => [0, 0, 800, 600])
60
+ const getResolution = jest.fn(() => 56)
61
+
62
+ const handles = attachMapEvents({ map, source, events, eventBus, getZoom, getCenter, getBounds, getResolution })
63
+
64
+ return { map, view, source, eventBus, handles }
65
+ }
66
+
67
+ describe('attachMapEvents — load events', () => {
68
+ it('emits MAP_LOADED on first tileloadend', () => {
69
+ const { source, eventBus } = setup()
70
+ source.trigger('tileloadend')
71
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_LOADED)
72
+ })
73
+
74
+ it('does not re-emit MAP_LOADED on subsequent tileloadend', () => {
75
+ const { source, eventBus } = setup()
76
+ source.trigger('tileloadend')
77
+ eventBus.emit.mockClear()
78
+ source.trigger('tileloadend')
79
+ expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_LOADED)
80
+ })
81
+
82
+ it('emits MAP_FIRST_IDLE on rendercomplete', () => {
83
+ const { map, eventBus } = setup()
84
+ map.trigger('rendercomplete')
85
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_FIRST_IDLE, expect.any(Object))
86
+ })
87
+ })
88
+
89
+ describe('attachMapEvents — move events', () => {
90
+ it('emits MAP_MOVE_START on first view change', () => {
91
+ const { view, eventBus } = setup()
92
+ view.trigger('change')
93
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE_START)
94
+ })
95
+
96
+ it('does not re-emit MAP_MOVE_START while already moving', () => {
97
+ const { view, eventBus } = setup()
98
+ view.trigger('change')
99
+ eventBus.emit.mockClear()
100
+ view.trigger('change')
101
+ expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_MOVE_START)
102
+ })
103
+
104
+ it('emits MAP_MOVE_END (with state) when debounced callback fires', () => {
105
+ const { view, eventBus } = setup()
106
+ view.trigger('change')
107
+ mockDebounceFns[0].fn() // emitMoveEnd's inner callback
108
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE_END, expect.any(Object))
109
+ })
110
+
111
+ it('resets moving flag after MAP_MOVE_END so MAP_MOVE_START can fire again', () => {
112
+ const { view, eventBus } = setup()
113
+ view.trigger('change')
114
+ mockDebounceFns[0].fn() // resets moving = false
115
+ eventBus.emit.mockClear()
116
+ view.trigger('change')
117
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE_START)
118
+ })
119
+
120
+ it('emits MAP_MOVE on postrender when moving', () => {
121
+ const { view, map, eventBus } = setup()
122
+ view.trigger('change') // sets moving = true (debounce wrapper does not reset it)
123
+ map.trigger('postrender')
124
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE, expect.any(Object))
125
+ })
126
+
127
+ it('does not emit MAP_MOVE on postrender when not moving', () => {
128
+ const { map, eventBus } = setup()
129
+ map.trigger('postrender')
130
+ expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_MOVE, expect.anything())
131
+ })
132
+ })
133
+
134
+ describe('attachMapEvents — click events', () => {
135
+ it('emits MAP_CLICK with point and coords on click with no prior pointerdown', () => {
136
+ const { map, eventBus } = setup()
137
+ map.trigger('click', { pixel: [100, 200], coordinate: [400000, 300000] })
138
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_CLICK, {
139
+ point: { x: 100, y: 200 },
140
+ coords: [400000, 300000]
141
+ })
142
+ })
143
+
144
+ it('emits MAP_CLICK when drag distance is within tolerance', () => {
145
+ const { map, eventBus } = setup()
146
+ // Math.hypot(4, 3) = 5 < DRAG_TOLERANCE(6)
147
+ map.trigger('pointerdown', { pixel: [100, 200] })
148
+ map.trigger('click', { pixel: [104, 203], coordinate: [400000, 300000] })
149
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_CLICK, expect.any(Object))
150
+ })
151
+
152
+ it('suppresses MAP_CLICK when drag distance exceeds tolerance', () => {
153
+ const { map, eventBus } = setup()
154
+ // Math.hypot(7, 0) = 7 > DRAG_TOLERANCE(6)
155
+ map.trigger('pointerdown', { pixel: [100, 200] })
156
+ map.trigger('click', { pixel: [107, 200], coordinate: [400000, 300000] })
157
+ expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_CLICK, expect.anything())
158
+ })
159
+ })
160
+
161
+ describe('attachMapEvents — render and data events', () => {
162
+ it('emits MAP_RENDER on every postrender', () => {
163
+ const { map, eventBus } = setup()
164
+ map.trigger('postrender')
165
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_RENDER)
166
+ })
167
+
168
+ it('emits MAP_DATA_CHANGE (with state) when debounced tileloadend callback fires', () => {
169
+ const { source, eventBus } = setup()
170
+ source.trigger('tileloadend')
171
+ mockDebounceFns[1].fn() // emitDataChange's inner callback
172
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_DATA_CHANGE, expect.any(Object))
173
+ })
174
+ })
175
+
176
+ describe('attachMapEvents — remove', () => {
177
+ it('calls cancel on all debouncers', () => {
178
+ const { handles } = setup()
179
+ handles.remove()
180
+ expect(mockDebounceFns[0].wrapper.cancel).toHaveBeenCalled()
181
+ expect(mockDebounceFns[1].wrapper.cancel).toHaveBeenCalled()
182
+ })
183
+
184
+ it('calls un on all registered event targets', () => {
185
+ const { handles, map, view, source } = setup()
186
+ handles.remove()
187
+ expect(view.un).toHaveBeenCalled()
188
+ expect(map.un).toHaveBeenCalled()
189
+ expect(source.un).toHaveBeenCalled()
190
+ })
191
+
192
+ it('prevents state-bearing events from emitting after destroy', () => {
193
+ const { eventBus, handles } = setup()
194
+ handles.remove()
195
+ mockDebounceFns[0].fn() // emitMoveEnd fires after destroy → getMapState returns null
196
+ expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_MOVE_END, expect.anything())
197
+ })
198
+ })