@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,249 @@
1
+ import OlMap from 'ol/Map.js'
2
+ import View from 'ol/View.js'
3
+ import TileLayer from 'ol/layer/Tile.js'
4
+ import { defaults as defaultInteractions } from 'ol/interaction/defaults.js'
5
+ import proj4 from 'proj4'
6
+ import { register } from 'ol/proj/proj4.js'
7
+ import { supportedShortcuts, DEFAULTS } from './defaults.js'
8
+ import { getViewResolutionConfig, ZOOM_ALIGNMENT } from './utils/zoom.js'
9
+ import { attachMapEvents } from './mapEvents.js'
10
+ import { attachAppEvents, createTileSource, createVectorTileLayer, createOGCVectorTileLayer } from './appEvents.js'
11
+ import { getAreaDimensions, getCardinalMove, getExtentFromGeoJSON, getPaddedExtent, isGeometryObscured } from './utils/spatial.js'
12
+
13
+ const CRS = 'EPSG:27700'
14
+
15
+ // OL view padding is [top, right, bottom, left]; app passes { top, right, bottom, left }
16
+ const toPaddingArray = (padding) => {
17
+ if (!padding) {
18
+ return undefined
19
+ }
20
+ const { top = 0, right = 0, bottom = 0, left = 0 } = padding
21
+ return [top, right, bottom, left]
22
+ }
23
+
24
+ // Register British National Grid with proj4 so OL can use it
25
+ proj4.defs(CRS, '+proj=tmerc +lat_0=49 +lon_0=-2 +k=0.9996012717 +x_0=400000 +y_0=-100000 +ellps=airy +towgs84=446.448,-125.157,542.06,0.15,0.247,0.842,-20.489 +units=m +no_defs')
26
+ register(proj4)
27
+
28
+ export default class OpenLayersProvider {
29
+ constructor ({ mapProviderConfig = {}, events, eventBus }) {
30
+ this.events = events
31
+ this.eventBus = eventBus
32
+ this.capabilities = {
33
+ supportedShortcuts,
34
+ supportsMapSizes: true
35
+ }
36
+ Object.assign(this, mapProviderConfig)
37
+ }
38
+
39
+ async initMap (config) {
40
+ const { container, padding, mapStyle, mapSize, center, zoom, bounds, minZoom, maxZoom, transformRequest, pixelRatio } = config
41
+ this.mapStyleId = mapStyle?.id
42
+ this.mapSize = mapSize
43
+ const { events, eventBus } = this
44
+
45
+ let tileLayer, source
46
+ if (mapStyle.type === 'raster') {
47
+ source = createTileSource(mapStyle.url, transformRequest)
48
+ tileLayer = new TileLayer({ source })
49
+ } else if (mapStyle.type === 'ogc-vt') {
50
+ const vectorTile = await createOGCVectorTileLayer(mapStyle.url, transformRequest)
51
+ tileLayer = vectorTile.layer
52
+ source = vectorTile.source
53
+ } else {
54
+ const vectorTile = await createVectorTileLayer(mapStyle.url, transformRequest)
55
+ tileLayer = vectorTile.layer
56
+ source = vectorTile.source
57
+ }
58
+
59
+ const viewResolutions = getViewResolutionConfig(this.zoomAlignment ?? ZOOM_ALIGNMENT.UK)
60
+
61
+ const view = new View({
62
+ projection: CRS,
63
+ center,
64
+ zoom: zoom ?? viewResolutions.defaultMinZoom,
65
+ minZoom: Math.max(minZoom ?? viewResolutions.defaultMinZoom, viewResolutions.defaultMinZoom),
66
+ maxZoom: maxZoom ?? viewResolutions.maxZoom,
67
+ resolutions: viewResolutions.resolutions,
68
+ constrainResolution: false,
69
+ padding: toPaddingArray(padding)
70
+ })
71
+
72
+ const map = new OlMap({
73
+ target: container,
74
+ layers: [tileLayer],
75
+ view,
76
+ controls: [],
77
+ interactions: defaultInteractions({ doubleClickZoom: false }),
78
+ pixelRatio
79
+ })
80
+
81
+ if (bounds) {
82
+ map.once('rendercomplete', () => {
83
+ view.fit(bounds, { size: map.getSize(), duration: 0 })
84
+ })
85
+ }
86
+
87
+ this.mapEventHandles = attachMapEvents({
88
+ map,
89
+ source,
90
+ events,
91
+ eventBus,
92
+ getZoom: this.getZoom.bind(this),
93
+ getCenter: this.getCenter.bind(this),
94
+ getBounds: this.getBounds.bind(this),
95
+ getResolution: this.getResolution.bind(this)
96
+ })
97
+
98
+ this.appEventHandles = attachAppEvents({
99
+ mapProvider: this,
100
+ layer: tileLayer,
101
+ layerType: mapStyle.type ?? 'vector',
102
+ transformRequest,
103
+ events,
104
+ eventBus,
105
+ map
106
+ }) || []
107
+
108
+ this.map = map
109
+ this.view = view
110
+ this.tileLayer = tileLayer
111
+ this.source = source
112
+
113
+ // MAP_READY is synchronous — OL map is immediately interactive after construction
114
+ eventBus.emit(events.MAP_READY, {
115
+ map: this.map,
116
+ mapStyleId: this.mapStyleId,
117
+ mapSize: this.mapSize,
118
+ crs: this.crs
119
+ })
120
+ }
121
+
122
+ destroyMap () {
123
+ this.mapEventHandles?.remove()
124
+ this.appEventHandles?.remove()
125
+
126
+ this.mapEventHandles = null
127
+ this.appEventHandles = null
128
+
129
+ if (this.map) {
130
+ this.map.setTarget(null)
131
+ this.map = null
132
+ }
133
+
134
+ this.view = null
135
+ this.tileLayer = null
136
+ this.source = null
137
+ }
138
+
139
+ // ==========================
140
+ // Side-effects
141
+ // ==========================
142
+
143
+ setView ({ center, zoom }) {
144
+ this.view.animate({
145
+ center: center ?? this.view.getCenter(),
146
+ zoom: zoom ?? this.view.getZoom(),
147
+ duration: DEFAULTS.animationDuration
148
+ })
149
+ }
150
+
151
+ zoomIn (zoomDelta) {
152
+ this.view.animate({
153
+ zoom: this.view.getZoom() + zoomDelta,
154
+ duration: DEFAULTS.animationDuration
155
+ })
156
+ }
157
+
158
+ zoomOut (zoomDelta) {
159
+ this.view.animate({
160
+ zoom: this.view.getZoom() - zoomDelta,
161
+ duration: DEFAULTS.animationDuration
162
+ })
163
+ }
164
+
165
+ panBy (offset) {
166
+ const center = this.view.getCenter()
167
+ const resolution = this.view.getResolution()
168
+ // Pixel x/y → easting increases right, northing increases up (flip y)
169
+ this.view.animate({
170
+ center: [center[0] + offset[0] * resolution, center[1] - offset[1] * resolution],
171
+ duration: DEFAULTS.animationDuration
172
+ })
173
+ }
174
+
175
+ fitToBounds (bounds) {
176
+ const extent = Array.isArray(bounds) ? bounds : getExtentFromGeoJSON(bounds)
177
+ this.view.fit(extent, { duration: DEFAULTS.animationDuration })
178
+ }
179
+
180
+ setPadding (padding) {
181
+ this.view.padding = toPaddingArray(padding)
182
+ }
183
+
184
+ // ==========================
185
+ // Read-only getters
186
+ // ==========================
187
+
188
+ getCenter () {
189
+ const center = this.view.getCenter()
190
+ const p = DEFAULTS.coordinatePrecision
191
+ return [
192
+ Math.round(center[0] * Math.pow(10, p)) / Math.pow(10, p),
193
+ Math.round(center[1] * Math.pow(10, p)) / Math.pow(10, p)
194
+ ]
195
+ }
196
+
197
+ getZoom () {
198
+ return this.view.getZoom()
199
+ }
200
+
201
+ getBounds () {
202
+ const extent = this.view.calculateExtent(this.map.getSize())
203
+ return extent.map(n => Math.round(n * 100) / 100)
204
+ }
205
+
206
+ getFeaturesAtPoint (_point, _options) {
207
+ // Raster tiles have no queryable features
208
+ return []
209
+ }
210
+
211
+ getVisibleFeatures (_layerIds) {
212
+ return []
213
+ }
214
+
215
+ // ==========================
216
+ // Spatial helpers
217
+ // ==========================
218
+
219
+ getAreaDimensions () {
220
+ return getAreaDimensions(getPaddedExtent(this.map))
221
+ }
222
+
223
+ getCardinalMove (from, to) {
224
+ return getCardinalMove(from, to)
225
+ }
226
+
227
+ getResolution () {
228
+ return this.view.getResolution()
229
+ }
230
+
231
+ mapToScreen (coords) {
232
+ if (!this.map) {
233
+ return { x: 0, y: 0 }
234
+ }
235
+ const pixel = this.map.getPixelFromCoordinate(coords)
236
+ if (!pixel) {
237
+ return { x: 0, y: 0 }
238
+ }
239
+ return { x: pixel[0], y: pixel[1] }
240
+ }
241
+
242
+ screenToMap (point) {
243
+ return this.map.getCoordinateFromPixel([point.x, point.y])
244
+ }
245
+
246
+ isGeometryObscured (geojson, panelRect) {
247
+ return isGeometryObscured(geojson, panelRect, this.map)
248
+ }
249
+ }
@@ -0,0 +1,386 @@
1
+ // Mock-prefixed variables are allowed in jest.mock factories by babel-plugin-jest-hoist
2
+ import OpenLayersProvider from './openlayersProvider.js'
3
+ import { attachMapEvents } from './mapEvents.js'
4
+ import { attachAppEvents, createTileSource, createVectorTileLayer } from './appEvents.js'
5
+ import { getExtentFromGeoJSON, isGeometryObscured } from './utils/spatial.js'
6
+
7
+ const mockAnimate = jest.fn()
8
+ const mockGetCenter = jest.fn(() => [400000.126, 300000.455])
9
+ const mockGetZoom = jest.fn(() => 7)
10
+ const mockGetResolution = jest.fn(() => 56)
11
+ const mockCalculateExtent = jest.fn(() => [1.005, 2.014, 3.994, 4.0])
12
+ const mockViewFit = jest.fn()
13
+ const mockViewInstance = {
14
+ animate: mockAnimate,
15
+ getCenter: mockGetCenter,
16
+ getZoom: mockGetZoom,
17
+ getResolution: mockGetResolution,
18
+ calculateExtent: mockCalculateExtent,
19
+ fit: mockViewFit,
20
+ padding: null
21
+ }
22
+
23
+ const mockMapOnce = jest.fn()
24
+ const mockMapGetSize = jest.fn(() => [800, 600])
25
+ const mockMapSetTarget = jest.fn()
26
+ const mockMapGetPixel = jest.fn(() => [100, 200])
27
+ const mockMapGetCoord = jest.fn(() => [400000, 300000])
28
+ const mockMapInstance = {
29
+ once: mockMapOnce,
30
+ getSize: mockMapGetSize,
31
+ setTarget: mockMapSetTarget,
32
+ getPixelFromCoordinate: mockMapGetPixel,
33
+ getCoordinateFromPixel: mockMapGetCoord,
34
+ getView: jest.fn(() => mockViewInstance)
35
+ }
36
+
37
+ const mockSource = {}
38
+ const mockTileLayer = { setSource: jest.fn() }
39
+ const mockVectorTileLayer = {}
40
+ const mockMapEventHandlesRemove = jest.fn()
41
+ const mockAppEventHandlesRemove = jest.fn()
42
+
43
+ jest.mock('ol/Map.js', () => ({ __esModule: true, default: jest.fn(() => mockMapInstance) }))
44
+ jest.mock('ol/View.js', () => ({ __esModule: true, default: jest.fn(() => mockViewInstance) }))
45
+ jest.mock('ol/layer/Tile.js', () => ({ __esModule: true, default: jest.fn(() => mockTileLayer) }))
46
+ jest.mock('ol/interaction/defaults.js', () => ({ __esModule: true, defaults: jest.fn(() => []) }))
47
+ jest.mock('proj4', () => {
48
+ const fn = jest.fn()
49
+ fn.defs = jest.fn()
50
+ return { __esModule: true, default: fn }
51
+ })
52
+ jest.mock('ol/proj/proj4.js', () => ({ __esModule: true, register: jest.fn() }))
53
+ jest.mock('./mapEvents.js', () => ({
54
+ __esModule: true,
55
+ attachMapEvents: jest.fn(() => ({ remove: mockMapEventHandlesRemove }))
56
+ }))
57
+ jest.mock('./appEvents.js', () => ({
58
+ __esModule: true,
59
+ createTileSource: jest.fn(() => mockSource),
60
+ createVectorTileLayer: jest.fn(async () => ({ layer: mockVectorTileLayer, source: mockSource })),
61
+ attachAppEvents: jest.fn(() => ({ remove: mockAppEventHandlesRemove }))
62
+ }))
63
+ jest.mock('./utils/spatial.js', () => ({
64
+ __esModule: true,
65
+ getAreaDimensions: jest.fn(() => '1 mile by 2 miles'),
66
+ getCardinalMove: jest.fn(() => 'north 100m'),
67
+ getExtentFromGeoJSON: jest.fn(() => [10, 20, 30, 40]),
68
+ getPaddedExtent: jest.fn(() => [0, 0, 800, 600]),
69
+ isGeometryObscured: jest.fn(() => false)
70
+ }))
71
+
72
+ const events = {
73
+ MAP_READY: 'map:ready',
74
+ MAP_SET_STYLE: 'map:setstyle',
75
+ MAP_STYLE_CHANGE: 'map:stylechange'
76
+ }
77
+
78
+ const defaultInitConfig = {
79
+ container: document.createElement('div'),
80
+ padding: null,
81
+ mapStyle: { id: 'myStyle', url: 'https://tiles.example.com/{z}/{x}/{y}' },
82
+ mapSize: null,
83
+ center: [400000, 300000],
84
+ zoom: 7,
85
+ bounds: null,
86
+ minZoom: null,
87
+ maxZoom: null,
88
+ transformRequest: null
89
+ }
90
+
91
+ function makeProvider (mapProviderConfig = {}) {
92
+ const eventBus = { emit: jest.fn(), on: jest.fn(), off: jest.fn() }
93
+ const provider = new OpenLayersProvider({ events, eventBus, mapProviderConfig })
94
+ return { provider, eventBus }
95
+ }
96
+
97
+ describe('OpenLayersProvider', () => {
98
+ beforeEach(() => {
99
+ jest.clearAllMocks()
100
+ mockViewInstance.padding = null
101
+ })
102
+
103
+ describe('constructor', () => {
104
+ it('sets events and eventBus on the instance', () => {
105
+ const eventBus = { emit: jest.fn() }
106
+ const provider = new OpenLayersProvider({ events, eventBus, mapProviderConfig: {} })
107
+ expect(provider.events).toBe(events)
108
+ expect(provider.eventBus).toBe(eventBus)
109
+ })
110
+
111
+ it('sets capabilities with supportsMapSizes true and supportedShortcuts array', () => {
112
+ const { provider } = makeProvider()
113
+ expect(provider.capabilities.supportsMapSizes).toBe(true)
114
+ expect(Array.isArray(provider.capabilities.supportedShortcuts)).toBe(true)
115
+ })
116
+
117
+ it('spreads mapProviderConfig properties onto instance', () => {
118
+ const { provider } = makeProvider({ zoomAlignment: 'world', customProp: 'hello' })
119
+ expect(provider.zoomAlignment).toBe('world')
120
+ expect(provider.customProp).toBe('hello')
121
+ })
122
+ })
123
+
124
+ describe('initMap', () => {
125
+ it('creates vector tile layer, OL objects, and emits MAP_READY by default', async () => {
126
+ const { provider, eventBus } = makeProvider()
127
+ await provider.initMap(defaultInitConfig)
128
+ expect(createVectorTileLayer).toHaveBeenCalledWith(defaultInitConfig.mapStyle.url, null)
129
+ expect(attachMapEvents).toHaveBeenCalled()
130
+ expect(attachAppEvents).toHaveBeenCalled()
131
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_READY, expect.objectContaining({
132
+ map: mockMapInstance,
133
+ mapStyleId: 'myStyle'
134
+ }))
135
+ })
136
+
137
+ it('calls map.once rendercomplete when bounds are provided', async () => {
138
+ const { provider } = makeProvider()
139
+ let renderCallback
140
+ mockMapOnce.mockImplementation((event, cb) => { renderCallback = cb })
141
+ const bounds = [100, 200, 300, 400]
142
+ await provider.initMap({ ...defaultInitConfig, bounds })
143
+ expect(mockMapOnce).toHaveBeenCalledWith('rendercomplete', expect.any(Function))
144
+ renderCallback()
145
+ expect(mockViewFit).toHaveBeenCalledWith(bounds, { size: [800, 600], duration: 0 })
146
+ })
147
+
148
+ it('does not call map.once when no bounds', async () => {
149
+ const { provider } = makeProvider()
150
+ await provider.initMap(defaultInitConfig)
151
+ expect(mockMapOnce).not.toHaveBeenCalled()
152
+ })
153
+
154
+ it('calls createVectorTileLayer and skips createTileSource when mapStyle has no type', async () => {
155
+ const { provider } = makeProvider()
156
+ await provider.initMap(defaultInitConfig)
157
+ expect(createVectorTileLayer).toHaveBeenCalled()
158
+ expect(createTileSource).not.toHaveBeenCalled()
159
+ })
160
+
161
+ it('calls createTileSource and skips createVectorTileLayer when mapStyle.type is "raster"', async () => {
162
+ const { provider, eventBus } = makeProvider()
163
+ const rasterConfig = {
164
+ ...defaultInitConfig,
165
+ mapStyle: { id: 'myStyle', url: 'https://tiles.example.com/{z}/{x}/{y}', type: 'raster' }
166
+ }
167
+ await provider.initMap(rasterConfig)
168
+ expect(createTileSource).toHaveBeenCalledWith('https://tiles.example.com/{z}/{x}/{y}', null)
169
+ expect(createVectorTileLayer).not.toHaveBeenCalled()
170
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_READY, expect.objectContaining({ mapStyleId: 'myStyle' }))
171
+ })
172
+
173
+ it('passes layerType "vector" and map to attachAppEvents by default', async () => {
174
+ const { provider } = makeProvider()
175
+ await provider.initMap(defaultInitConfig)
176
+ expect(attachAppEvents).toHaveBeenCalledWith(expect.objectContaining({
177
+ layerType: 'vector',
178
+ map: mockMapInstance
179
+ }))
180
+ })
181
+
182
+ it('passes layerType "raster" to attachAppEvents when mapStyle.type is "raster"', async () => {
183
+ const { provider } = makeProvider()
184
+ const rasterConfig = {
185
+ ...defaultInitConfig,
186
+ mapStyle: { id: 'myStyle', url: 'https://tiles.example.com/{z}/{x}/{y}', type: 'raster' }
187
+ }
188
+ await provider.initMap(rasterConfig)
189
+ expect(attachAppEvents).toHaveBeenCalledWith(expect.objectContaining({
190
+ layerType: 'raster'
191
+ }))
192
+ })
193
+
194
+ it('passes pixelRatio to OlMap constructor', async () => {
195
+ const OlMap = require('ol/Map.js').default
196
+ const { provider } = makeProvider()
197
+ await provider.initMap({ ...defaultInitConfig, pixelRatio: 2 })
198
+ expect(OlMap).toHaveBeenCalledWith(expect.objectContaining({ pixelRatio: 2 }))
199
+ })
200
+
201
+ it('passes mapProvider to attachAppEvents', async () => {
202
+ const { provider } = makeProvider()
203
+ await provider.initMap(defaultInitConfig)
204
+ expect(attachAppEvents).toHaveBeenCalledWith(expect.objectContaining({ mapProvider: provider }))
205
+ })
206
+ })
207
+
208
+ describe('destroyMap', () => {
209
+ it('removes event handles, nulls map target and all references', async () => {
210
+ const { provider } = makeProvider()
211
+ await provider.initMap(defaultInitConfig)
212
+ jest.clearAllMocks()
213
+
214
+ provider.destroyMap()
215
+
216
+ expect(mockMapEventHandlesRemove).toHaveBeenCalled()
217
+ expect(mockAppEventHandlesRemove).toHaveBeenCalled()
218
+ expect(mockMapSetTarget).toHaveBeenCalledWith(null)
219
+ expect(provider.map).toBeNull()
220
+ expect(provider.view).toBeNull()
221
+ expect(provider.tileLayer).toBeNull()
222
+ expect(provider.source).toBeNull()
223
+ })
224
+
225
+ it('does not throw when called before initMap', () => {
226
+ const { provider } = makeProvider()
227
+ expect(() => provider.destroyMap()).not.toThrow()
228
+ })
229
+ })
230
+
231
+ describe('view control methods', () => {
232
+ let provider
233
+
234
+ beforeEach(async () => {
235
+ ;({ provider } = makeProvider())
236
+ await provider.initMap(defaultInitConfig)
237
+ jest.clearAllMocks()
238
+ mockViewInstance.padding = null
239
+ })
240
+
241
+ it('setView animates to given center and zoom', () => {
242
+ provider.setView({ center: [500000, 400000], zoom: 8 })
243
+ expect(mockAnimate).toHaveBeenCalledWith(expect.objectContaining({ center: [500000, 400000], zoom: 8 }))
244
+ })
245
+
246
+ it('setView falls back to current center and zoom when not supplied', () => {
247
+ mockGetCenter.mockReturnValue([400000, 300000])
248
+ mockGetZoom.mockReturnValue(7)
249
+ provider.setView({})
250
+ expect(mockAnimate).toHaveBeenCalledWith(expect.objectContaining({ center: [400000, 300000], zoom: 7 }))
251
+ })
252
+
253
+ it('zoomIn increments zoom by delta', () => {
254
+ mockGetZoom.mockReturnValue(5)
255
+ provider.zoomIn(2)
256
+ expect(mockAnimate).toHaveBeenCalledWith(expect.objectContaining({ zoom: 7 }))
257
+ })
258
+
259
+ it('zoomOut decrements zoom by delta', () => {
260
+ mockGetZoom.mockReturnValue(9)
261
+ provider.zoomOut(3)
262
+ expect(mockAnimate).toHaveBeenCalledWith(expect.objectContaining({ zoom: 6 }))
263
+ })
264
+
265
+ it('panBy animates center offset scaled by resolution', () => {
266
+ mockGetCenter.mockReturnValue([400000, 300000])
267
+ mockGetResolution.mockReturnValue(56)
268
+ provider.panBy([10, 20])
269
+ expect(mockAnimate).toHaveBeenCalledWith(expect.objectContaining({
270
+ center: [400000 + 10 * 56, 300000 - 20 * 56]
271
+ }))
272
+ })
273
+
274
+ it('fitToBounds passes an array extent directly to view.fit', () => {
275
+ provider.fitToBounds([1, 2, 3, 4])
276
+ expect(getExtentFromGeoJSON).not.toHaveBeenCalled()
277
+ expect(mockViewFit).toHaveBeenCalledWith([1, 2, 3, 4], expect.any(Object))
278
+ })
279
+
280
+ it('fitToBounds converts GeoJSON to extent via getExtentFromGeoJSON', () => {
281
+ const geojson = { type: 'Point', coordinates: [0, 51] }
282
+ provider.fitToBounds(geojson)
283
+ expect(getExtentFromGeoJSON).toHaveBeenCalledWith(geojson)
284
+ expect(mockViewFit).toHaveBeenCalledWith([10, 20, 30, 40], expect.any(Object))
285
+ })
286
+
287
+ it('setPadding converts object to OL [top, right, bottom, left] array', () => {
288
+ provider.setPadding({ top: 10, right: 20, bottom: 30, left: 40 })
289
+ expect(mockViewInstance.padding).toEqual([10, 20, 30, 40])
290
+ })
291
+
292
+ it('setPadding fills missing keys with zero', () => {
293
+ provider.setPadding({ top: 10 })
294
+ expect(mockViewInstance.padding).toEqual([10, 0, 0, 0])
295
+ })
296
+
297
+ it('setPadding with null sets padding to undefined', () => {
298
+ provider.setPadding(null)
299
+ expect(mockViewInstance.padding).toBeUndefined()
300
+ })
301
+ })
302
+
303
+ describe('getter methods', () => {
304
+ let provider
305
+
306
+ beforeEach(async () => {
307
+ ;({ provider } = makeProvider())
308
+ await provider.initMap(defaultInitConfig)
309
+ jest.clearAllMocks()
310
+ })
311
+
312
+ it('getCenter rounds coordinates to 2 decimal places', () => {
313
+ mockGetCenter.mockReturnValue([400000.126, 300000.455])
314
+ expect(provider.getCenter()).toEqual([400000.13, 300000.46])
315
+ })
316
+
317
+ it('getZoom delegates to view.getZoom', () => {
318
+ mockGetZoom.mockReturnValue(9)
319
+ expect(provider.getZoom()).toBe(9)
320
+ })
321
+
322
+ it('getBounds rounds extent values to 2 decimal places', () => {
323
+ mockCalculateExtent.mockReturnValue([1.126, 2.456, 3.994, 4.0])
324
+ expect(provider.getBounds()).toEqual([1.13, 2.46, 3.99, 4])
325
+ })
326
+
327
+ it('getFeaturesAtPoint returns empty array', () => {
328
+ expect(provider.getFeaturesAtPoint()).toEqual([])
329
+ })
330
+
331
+ it('getVisibleFeatures returns empty array', () => {
332
+ expect(provider.getVisibleFeatures()).toEqual([])
333
+ })
334
+
335
+ it('getResolution delegates to view.getResolution', () => {
336
+ mockGetResolution.mockReturnValue(28)
337
+ expect(provider.getResolution()).toBe(28)
338
+ })
339
+ })
340
+
341
+ describe('spatial helper methods', () => {
342
+ let provider
343
+
344
+ beforeEach(async () => {
345
+ ;({ provider } = makeProvider())
346
+ await provider.initMap(defaultInitConfig)
347
+ jest.clearAllMocks()
348
+ })
349
+
350
+ it('getAreaDimensions delegates to spatial utils', () => {
351
+ expect(provider.getAreaDimensions()).toBe('1 mile by 2 miles')
352
+ })
353
+
354
+ it('getCardinalMove delegates to spatial utils', () => {
355
+ expect(provider.getCardinalMove([0, 0], [100, 100])).toBe('north 100m')
356
+ })
357
+
358
+ it('mapToScreen returns pixel as {x, y}', () => {
359
+ mockMapGetPixel.mockReturnValue([150, 250])
360
+ expect(provider.mapToScreen([400000, 300000])).toEqual({ x: 150, y: 250 })
361
+ })
362
+
363
+ it('mapToScreen returns {0, 0} when getPixelFromCoordinate returns null', () => {
364
+ mockMapGetPixel.mockReturnValue(null)
365
+ expect(provider.mapToScreen([400000, 300000])).toEqual({ x: 0, y: 0 })
366
+ })
367
+
368
+ it('mapToScreen returns {0, 0} when map is not set', () => {
369
+ provider.map = null
370
+ expect(provider.mapToScreen([400000, 300000])).toEqual({ x: 0, y: 0 })
371
+ })
372
+
373
+ it('screenToMap delegates to map.getCoordinateFromPixel', () => {
374
+ mockMapGetCoord.mockReturnValue([400000, 300000])
375
+ expect(provider.screenToMap({ x: 100, y: 200 })).toEqual([400000, 300000])
376
+ expect(mockMapGetCoord).toHaveBeenCalledWith([100, 200])
377
+ })
378
+
379
+ it('isGeometryObscured delegates to spatial utils with map instance', () => {
380
+ const geojson = { type: 'Point', coordinates: [0, 51] }
381
+ const panelRect = { left: 0, top: 0, right: 100, bottom: 100 }
382
+ provider.isGeometryObscured(geojson, panelRect)
383
+ expect(isGeometryObscured).toHaveBeenCalledWith(geojson, panelRect, mockMapInstance)
384
+ })
385
+ })
386
+ })