@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,234 @@
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 } 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: false
35
+ }
36
+ Object.assign(this, mapProviderConfig)
37
+ }
38
+
39
+ async initMap (config) {
40
+ const { container, padding, mapStyle, mapSize, center, zoom, bounds, minZoom, maxZoom, transformRequest } = config
41
+ this.mapStyleId = mapStyle?.id
42
+ this.mapSize = mapSize
43
+ const { events, eventBus } = this
44
+
45
+ const source = createTileSource(mapStyle.url, transformRequest)
46
+ const tileLayer = new TileLayer({ source })
47
+
48
+ const viewResolutions = getViewResolutionConfig(this.zoomAlignment ?? ZOOM_ALIGNMENT.UK)
49
+
50
+ const view = new View({
51
+ projection: CRS,
52
+ center,
53
+ zoom: zoom ?? viewResolutions.defaultMinZoom,
54
+ minZoom: Math.max(minZoom ?? viewResolutions.defaultMinZoom, viewResolutions.defaultMinZoom),
55
+ maxZoom: maxZoom ?? viewResolutions.maxZoom,
56
+ resolutions: viewResolutions.resolutions,
57
+ constrainResolution: false,
58
+ padding: toPaddingArray(padding)
59
+ })
60
+
61
+ const map = new OlMap({
62
+ target: container,
63
+ layers: [tileLayer],
64
+ view,
65
+ controls: [],
66
+ interactions: defaultInteractions({ doubleClickZoom: false })
67
+ })
68
+
69
+ if (bounds) {
70
+ map.once('rendercomplete', () => {
71
+ view.fit(bounds, { size: map.getSize(), duration: 0 })
72
+ })
73
+ }
74
+
75
+ this.mapEventHandles = attachMapEvents({
76
+ map,
77
+ source,
78
+ events,
79
+ eventBus,
80
+ getZoom: this.getZoom.bind(this),
81
+ getCenter: this.getCenter.bind(this),
82
+ getBounds: this.getBounds.bind(this),
83
+ getResolution: this.getResolution.bind(this)
84
+ })
85
+
86
+ this.appEventHandles = attachAppEvents({
87
+ tileLayer,
88
+ transformRequest,
89
+ events,
90
+ eventBus
91
+ }) || []
92
+
93
+ this.map = map
94
+ this.view = view
95
+ this.tileLayer = tileLayer
96
+ this.source = source
97
+
98
+ // MAP_READY is synchronous — OL map is immediately interactive after construction
99
+ eventBus.emit(events.MAP_READY, {
100
+ map: this.map,
101
+ mapStyleId: this.mapStyleId,
102
+ mapSize: this.mapSize,
103
+ crs: this.crs
104
+ })
105
+ }
106
+
107
+ destroyMap () {
108
+ this.mapEventHandles?.remove()
109
+ this.appEventHandles?.remove()
110
+
111
+ this.mapEventHandles = null
112
+ this.appEventHandles = null
113
+
114
+ if (this.map) {
115
+ this.map.setTarget(null)
116
+ this.map = null
117
+ }
118
+
119
+ this.view = null
120
+ this.tileLayer = null
121
+ this.source = null
122
+ }
123
+
124
+ // ==========================
125
+ // Side-effects
126
+ // ==========================
127
+
128
+ setView ({ center, zoom }) {
129
+ this.view.animate({
130
+ center: center ?? this.view.getCenter(),
131
+ zoom: zoom ?? this.view.getZoom(),
132
+ duration: DEFAULTS.animationDuration
133
+ })
134
+ }
135
+
136
+ zoomIn (zoomDelta) {
137
+ this.view.animate({
138
+ zoom: this.view.getZoom() + zoomDelta,
139
+ duration: DEFAULTS.animationDuration
140
+ })
141
+ }
142
+
143
+ zoomOut (zoomDelta) {
144
+ this.view.animate({
145
+ zoom: this.view.getZoom() - zoomDelta,
146
+ duration: DEFAULTS.animationDuration
147
+ })
148
+ }
149
+
150
+ panBy (offset) {
151
+ const center = this.view.getCenter()
152
+ const resolution = this.view.getResolution()
153
+ // Pixel x/y → easting increases right, northing increases up (flip y)
154
+ this.view.animate({
155
+ center: [center[0] + offset[0] * resolution, center[1] - offset[1] * resolution],
156
+ duration: DEFAULTS.animationDuration
157
+ })
158
+ }
159
+
160
+ fitToBounds (bounds) {
161
+ const extent = Array.isArray(bounds) ? bounds : getExtentFromGeoJSON(bounds)
162
+ this.view.fit(extent, { duration: DEFAULTS.animationDuration })
163
+ }
164
+
165
+ setPadding (padding) {
166
+ this.view.padding = toPaddingArray(padding)
167
+ }
168
+
169
+ // ==========================
170
+ // Read-only getters
171
+ // ==========================
172
+
173
+ getCenter () {
174
+ const center = this.view.getCenter()
175
+ const p = DEFAULTS.coordinatePrecision
176
+ return [
177
+ Math.round(center[0] * Math.pow(10, p)) / Math.pow(10, p),
178
+ Math.round(center[1] * Math.pow(10, p)) / Math.pow(10, p)
179
+ ]
180
+ }
181
+
182
+ getZoom () {
183
+ return this.view.getZoom()
184
+ }
185
+
186
+ getBounds () {
187
+ const extent = this.view.calculateExtent(this.map.getSize())
188
+ return extent.map(n => Math.round(n * 100) / 100)
189
+ }
190
+
191
+ getFeaturesAtPoint (_point, _options) {
192
+ // Raster tiles have no queryable features
193
+ return []
194
+ }
195
+
196
+ getVisibleFeatures (_layerIds) {
197
+ return []
198
+ }
199
+
200
+ // ==========================
201
+ // Spatial helpers
202
+ // ==========================
203
+
204
+ getAreaDimensions () {
205
+ return getAreaDimensions(getPaddedExtent(this.map))
206
+ }
207
+
208
+ getCardinalMove (from, to) {
209
+ return getCardinalMove(from, to)
210
+ }
211
+
212
+ getResolution () {
213
+ return this.view.getResolution()
214
+ }
215
+
216
+ mapToScreen (coords) {
217
+ if (!this.map) {
218
+ return { x: 0, y: 0 }
219
+ }
220
+ const pixel = this.map.getPixelFromCoordinate(coords)
221
+ if (!pixel) {
222
+ return { x: 0, y: 0 }
223
+ }
224
+ return { x: pixel[0], y: pixel[1] }
225
+ }
226
+
227
+ screenToMap (point) {
228
+ return this.map.getCoordinateFromPixel([point.x, point.y])
229
+ }
230
+
231
+ isGeometryObscured (geojson, panelRect) {
232
+ return isGeometryObscured(geojson, panelRect, this.map)
233
+ }
234
+ }
@@ -0,0 +1,331 @@
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 } 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 mockMapEventHandlesRemove = jest.fn()
40
+ const mockAppEventHandlesRemove = jest.fn()
41
+
42
+ jest.mock('ol/Map.js', () => ({ __esModule: true, default: jest.fn(() => mockMapInstance) }))
43
+ jest.mock('ol/View.js', () => ({ __esModule: true, default: jest.fn(() => mockViewInstance) }))
44
+ jest.mock('ol/layer/Tile.js', () => ({ __esModule: true, default: jest.fn(() => mockTileLayer) }))
45
+ jest.mock('ol/interaction/defaults.js', () => ({ __esModule: true, defaults: jest.fn(() => []) }))
46
+ jest.mock('proj4', () => {
47
+ const fn = jest.fn()
48
+ fn.defs = jest.fn()
49
+ return { __esModule: true, default: fn }
50
+ })
51
+ jest.mock('ol/proj/proj4.js', () => ({ __esModule: true, register: jest.fn() }))
52
+ jest.mock('./mapEvents.js', () => ({
53
+ __esModule: true,
54
+ attachMapEvents: jest.fn(() => ({ remove: mockMapEventHandlesRemove }))
55
+ }))
56
+ jest.mock('./appEvents.js', () => ({
57
+ __esModule: true,
58
+ createTileSource: jest.fn(() => mockSource),
59
+ attachAppEvents: jest.fn(() => ({ remove: mockAppEventHandlesRemove }))
60
+ }))
61
+ jest.mock('./utils/spatial.js', () => ({
62
+ __esModule: true,
63
+ getAreaDimensions: jest.fn(() => '1 mile by 2 miles'),
64
+ getCardinalMove: jest.fn(() => 'north 100m'),
65
+ getExtentFromGeoJSON: jest.fn(() => [10, 20, 30, 40]),
66
+ getPaddedExtent: jest.fn(() => [0, 0, 800, 600]),
67
+ isGeometryObscured: jest.fn(() => false)
68
+ }))
69
+
70
+ const events = {
71
+ MAP_READY: 'map:ready',
72
+ MAP_SET_STYLE: 'map:setstyle',
73
+ MAP_STYLE_CHANGE: 'map:stylechange'
74
+ }
75
+
76
+ const defaultInitConfig = {
77
+ container: document.createElement('div'),
78
+ padding: null,
79
+ mapStyle: { id: 'myStyle', url: 'https://tiles.example.com/{z}/{x}/{y}' },
80
+ mapSize: null,
81
+ center: [400000, 300000],
82
+ zoom: 7,
83
+ bounds: null,
84
+ minZoom: null,
85
+ maxZoom: null,
86
+ transformRequest: null
87
+ }
88
+
89
+ function makeProvider (mapProviderConfig = {}) {
90
+ const eventBus = { emit: jest.fn(), on: jest.fn(), off: jest.fn() }
91
+ const provider = new OpenLayersProvider({ events, eventBus, mapProviderConfig })
92
+ return { provider, eventBus }
93
+ }
94
+
95
+ describe('OpenLayersProvider', () => {
96
+ beforeEach(() => {
97
+ jest.clearAllMocks()
98
+ mockViewInstance.padding = null
99
+ })
100
+
101
+ describe('constructor', () => {
102
+ it('sets events and eventBus on the instance', () => {
103
+ const eventBus = { emit: jest.fn() }
104
+ const provider = new OpenLayersProvider({ events, eventBus, mapProviderConfig: {} })
105
+ expect(provider.events).toBe(events)
106
+ expect(provider.eventBus).toBe(eventBus)
107
+ })
108
+
109
+ it('sets capabilities with supportsMapSizes false and supportedShortcuts array', () => {
110
+ const { provider } = makeProvider()
111
+ expect(provider.capabilities.supportsMapSizes).toBe(false)
112
+ expect(Array.isArray(provider.capabilities.supportedShortcuts)).toBe(true)
113
+ })
114
+
115
+ it('spreads mapProviderConfig properties onto instance', () => {
116
+ const { provider } = makeProvider({ zoomAlignment: 'world', customProp: 'hello' })
117
+ expect(provider.zoomAlignment).toBe('world')
118
+ expect(provider.customProp).toBe('hello')
119
+ })
120
+ })
121
+
122
+ describe('initMap', () => {
123
+ it('creates tile source, OL objects, and emits MAP_READY', async () => {
124
+ const { provider, eventBus } = makeProvider()
125
+ await provider.initMap(defaultInitConfig)
126
+ expect(createTileSource).toHaveBeenCalledWith(defaultInitConfig.mapStyle.url, null)
127
+ expect(attachMapEvents).toHaveBeenCalled()
128
+ expect(attachAppEvents).toHaveBeenCalled()
129
+ expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_READY, expect.objectContaining({
130
+ map: mockMapInstance,
131
+ mapStyleId: 'myStyle'
132
+ }))
133
+ })
134
+
135
+ it('calls map.once rendercomplete when bounds are provided', async () => {
136
+ const { provider } = makeProvider()
137
+ let renderCallback
138
+ mockMapOnce.mockImplementation((event, cb) => { renderCallback = cb })
139
+ const bounds = [100, 200, 300, 400]
140
+ await provider.initMap({ ...defaultInitConfig, bounds })
141
+ expect(mockMapOnce).toHaveBeenCalledWith('rendercomplete', expect.any(Function))
142
+ renderCallback()
143
+ expect(mockViewFit).toHaveBeenCalledWith(bounds, { size: [800, 600], duration: 0 })
144
+ })
145
+
146
+ it('does not call map.once when no bounds', async () => {
147
+ const { provider } = makeProvider()
148
+ await provider.initMap(defaultInitConfig)
149
+ expect(mockMapOnce).not.toHaveBeenCalled()
150
+ })
151
+ })
152
+
153
+ describe('destroyMap', () => {
154
+ it('removes event handles, nulls map target and all references', async () => {
155
+ const { provider } = makeProvider()
156
+ await provider.initMap(defaultInitConfig)
157
+ jest.clearAllMocks()
158
+
159
+ provider.destroyMap()
160
+
161
+ expect(mockMapEventHandlesRemove).toHaveBeenCalled()
162
+ expect(mockAppEventHandlesRemove).toHaveBeenCalled()
163
+ expect(mockMapSetTarget).toHaveBeenCalledWith(null)
164
+ expect(provider.map).toBeNull()
165
+ expect(provider.view).toBeNull()
166
+ expect(provider.tileLayer).toBeNull()
167
+ expect(provider.source).toBeNull()
168
+ })
169
+
170
+ it('does not throw when called before initMap', () => {
171
+ const { provider } = makeProvider()
172
+ expect(() => provider.destroyMap()).not.toThrow()
173
+ })
174
+ })
175
+
176
+ describe('view control methods', () => {
177
+ let provider
178
+
179
+ beforeEach(async () => {
180
+ ;({ provider } = makeProvider())
181
+ await provider.initMap(defaultInitConfig)
182
+ jest.clearAllMocks()
183
+ mockViewInstance.padding = null
184
+ })
185
+
186
+ it('setView animates to given center and zoom', () => {
187
+ provider.setView({ center: [500000, 400000], zoom: 8 })
188
+ expect(mockAnimate).toHaveBeenCalledWith(expect.objectContaining({ center: [500000, 400000], zoom: 8 }))
189
+ })
190
+
191
+ it('setView falls back to current center and zoom when not supplied', () => {
192
+ mockGetCenter.mockReturnValue([400000, 300000])
193
+ mockGetZoom.mockReturnValue(7)
194
+ provider.setView({})
195
+ expect(mockAnimate).toHaveBeenCalledWith(expect.objectContaining({ center: [400000, 300000], zoom: 7 }))
196
+ })
197
+
198
+ it('zoomIn increments zoom by delta', () => {
199
+ mockGetZoom.mockReturnValue(5)
200
+ provider.zoomIn(2)
201
+ expect(mockAnimate).toHaveBeenCalledWith(expect.objectContaining({ zoom: 7 }))
202
+ })
203
+
204
+ it('zoomOut decrements zoom by delta', () => {
205
+ mockGetZoom.mockReturnValue(9)
206
+ provider.zoomOut(3)
207
+ expect(mockAnimate).toHaveBeenCalledWith(expect.objectContaining({ zoom: 6 }))
208
+ })
209
+
210
+ it('panBy animates center offset scaled by resolution', () => {
211
+ mockGetCenter.mockReturnValue([400000, 300000])
212
+ mockGetResolution.mockReturnValue(56)
213
+ provider.panBy([10, 20])
214
+ expect(mockAnimate).toHaveBeenCalledWith(expect.objectContaining({
215
+ center: [400000 + 10 * 56, 300000 - 20 * 56]
216
+ }))
217
+ })
218
+
219
+ it('fitToBounds passes an array extent directly to view.fit', () => {
220
+ provider.fitToBounds([1, 2, 3, 4])
221
+ expect(getExtentFromGeoJSON).not.toHaveBeenCalled()
222
+ expect(mockViewFit).toHaveBeenCalledWith([1, 2, 3, 4], expect.any(Object))
223
+ })
224
+
225
+ it('fitToBounds converts GeoJSON to extent via getExtentFromGeoJSON', () => {
226
+ const geojson = { type: 'Point', coordinates: [0, 51] }
227
+ provider.fitToBounds(geojson)
228
+ expect(getExtentFromGeoJSON).toHaveBeenCalledWith(geojson)
229
+ expect(mockViewFit).toHaveBeenCalledWith([10, 20, 30, 40], expect.any(Object))
230
+ })
231
+
232
+ it('setPadding converts object to OL [top, right, bottom, left] array', () => {
233
+ provider.setPadding({ top: 10, right: 20, bottom: 30, left: 40 })
234
+ expect(mockViewInstance.padding).toEqual([10, 20, 30, 40])
235
+ })
236
+
237
+ it('setPadding fills missing keys with zero', () => {
238
+ provider.setPadding({ top: 10 })
239
+ expect(mockViewInstance.padding).toEqual([10, 0, 0, 0])
240
+ })
241
+
242
+ it('setPadding with null sets padding to undefined', () => {
243
+ provider.setPadding(null)
244
+ expect(mockViewInstance.padding).toBeUndefined()
245
+ })
246
+ })
247
+
248
+ describe('getter methods', () => {
249
+ let provider
250
+
251
+ beforeEach(async () => {
252
+ ;({ provider } = makeProvider())
253
+ await provider.initMap(defaultInitConfig)
254
+ jest.clearAllMocks()
255
+ })
256
+
257
+ it('getCenter rounds coordinates to 2 decimal places', () => {
258
+ mockGetCenter.mockReturnValue([400000.126, 300000.455])
259
+ expect(provider.getCenter()).toEqual([400000.13, 300000.46])
260
+ })
261
+
262
+ it('getZoom delegates to view.getZoom', () => {
263
+ mockGetZoom.mockReturnValue(9)
264
+ expect(provider.getZoom()).toBe(9)
265
+ })
266
+
267
+ it('getBounds rounds extent values to 2 decimal places', () => {
268
+ mockCalculateExtent.mockReturnValue([1.126, 2.456, 3.994, 4.0])
269
+ expect(provider.getBounds()).toEqual([1.13, 2.46, 3.99, 4])
270
+ })
271
+
272
+ it('getFeaturesAtPoint returns empty array', () => {
273
+ expect(provider.getFeaturesAtPoint()).toEqual([])
274
+ })
275
+
276
+ it('getVisibleFeatures returns empty array', () => {
277
+ expect(provider.getVisibleFeatures()).toEqual([])
278
+ })
279
+
280
+ it('getResolution delegates to view.getResolution', () => {
281
+ mockGetResolution.mockReturnValue(28)
282
+ expect(provider.getResolution()).toBe(28)
283
+ })
284
+ })
285
+
286
+ describe('spatial helper methods', () => {
287
+ let provider
288
+
289
+ beforeEach(async () => {
290
+ ;({ provider } = makeProvider())
291
+ await provider.initMap(defaultInitConfig)
292
+ jest.clearAllMocks()
293
+ })
294
+
295
+ it('getAreaDimensions delegates to spatial utils', () => {
296
+ expect(provider.getAreaDimensions()).toBe('1 mile by 2 miles')
297
+ })
298
+
299
+ it('getCardinalMove delegates to spatial utils', () => {
300
+ expect(provider.getCardinalMove([0, 0], [100, 100])).toBe('north 100m')
301
+ })
302
+
303
+ it('mapToScreen returns pixel as {x, y}', () => {
304
+ mockMapGetPixel.mockReturnValue([150, 250])
305
+ expect(provider.mapToScreen([400000, 300000])).toEqual({ x: 150, y: 250 })
306
+ })
307
+
308
+ it('mapToScreen returns {0, 0} when getPixelFromCoordinate returns null', () => {
309
+ mockMapGetPixel.mockReturnValue(null)
310
+ expect(provider.mapToScreen([400000, 300000])).toEqual({ x: 0, y: 0 })
311
+ })
312
+
313
+ it('mapToScreen returns {0, 0} when map is not set', () => {
314
+ provider.map = null
315
+ expect(provider.mapToScreen([400000, 300000])).toEqual({ x: 0, y: 0 })
316
+ })
317
+
318
+ it('screenToMap delegates to map.getCoordinateFromPixel', () => {
319
+ mockMapGetCoord.mockReturnValue([400000, 300000])
320
+ expect(provider.screenToMap({ x: 100, y: 200 })).toEqual([400000, 300000])
321
+ expect(mockMapGetCoord).toHaveBeenCalledWith([100, 200])
322
+ })
323
+
324
+ it('isGeometryObscured delegates to spatial utils with map instance', () => {
325
+ const geojson = { type: 'Point', coordinates: [0, 51] }
326
+ const panelRect = { left: 0, top: 0, right: 100, bottom: 100 }
327
+ provider.isGeometryObscured(geojson, panelRect)
328
+ expect(isGeometryObscured).toHaveBeenCalledWith(geojson, panelRect, mockMapInstance)
329
+ })
330
+ })
331
+ })
@@ -0,0 +1,142 @@
1
+ import turfBbox from '@turf/bbox'
2
+ import { transformExtent } from 'ol/proj.js'
3
+
4
+ // In EPSG:27700 coordinates are projected meters — distances are Pythagorean, no geodesy needed
5
+
6
+ const formatDimension = (meters) => {
7
+ const WHOLE_MILE_THRESHOLD = 10
8
+ const MILE_THRESHOLD = 0.5
9
+ const METERS_PER_MILE = 1609.344
10
+
11
+ const miles = meters / METERS_PER_MILE
12
+
13
+ if (miles < MILE_THRESHOLD) {
14
+ return `${Math.round(meters)}m`
15
+ }
16
+
17
+ if (miles < WHOLE_MILE_THRESHOLD) {
18
+ const value = Number.parseFloat(miles.toFixed(1))
19
+ const units = value === 1 ? 'mile' : 'miles'
20
+ return `${value} ${units}`
21
+ }
22
+
23
+ const rounded = Math.round(miles)
24
+ return `${rounded} miles`
25
+ }
26
+
27
+ /**
28
+ * Returns "400m by 1.4 miles" for the visible (padded) map area.
29
+ * extent: [xmin, ymin, xmax, ymax] in EPSG:27700 meters
30
+ */
31
+ const getAreaDimensions = (extent) => {
32
+ if (!extent) {
33
+ return ''
34
+ }
35
+ const [xmin, ymin, xmax, ymax] = extent
36
+ const widthMeters = xmax - xmin
37
+ const heightMeters = ymax - ymin
38
+ return `${formatDimension(heightMeters)} by ${formatDimension(widthMeters)}`
39
+ }
40
+
41
+ /**
42
+ * Returns "north 400m, east 750m" for moves between two EPSG:27700 [easting, northing] coords.
43
+ */
44
+ const getCardinalMove = (from, to) => {
45
+ const THRESHOLD_METERS = 1
46
+
47
+ const dEasting = to[0] - from[0]
48
+ const dNorthing = to[1] - from[1]
49
+
50
+ const moves = []
51
+
52
+ if (Math.abs(dNorthing) > THRESHOLD_METERS) {
53
+ moves.push(`${dNorthing > 0 ? 'north' : 'south'} ${formatDimension(Math.abs(dNorthing))}`)
54
+ }
55
+
56
+ if (Math.abs(dEasting) > THRESHOLD_METERS) {
57
+ moves.push(`${dEasting > 0 ? 'east' : 'west'} ${formatDimension(Math.abs(dEasting))}`)
58
+ }
59
+
60
+ return moves.join(', ')
61
+ }
62
+
63
+ /**
64
+ * Get a flat bbox [west, south, east, north] in WGS84 from any GeoJSON object.
65
+ */
66
+ const getBboxFromGeoJSON = (geojson) => turfBbox(geojson)
67
+
68
+ /**
69
+ * Get a flat extent [xmin, ymin, xmax, ymax] in EPSG:27700 from any GeoJSON object.
70
+ * GeoJSON is always WGS84, so this transforms the bbox.
71
+ */
72
+ const getExtentFromGeoJSON = (geojson) => {
73
+ const wgs84Bbox = turfBbox(geojson)
74
+ return transformExtent(wgs84Bbox, 'EPSG:4326', 'EPSG:27700')
75
+ }
76
+
77
+ /**
78
+ * Returns the visible (padded) extent [xmin, ymin, xmax, ymax] in EPSG:27700.
79
+ * Accounts for view padding by converting padded pixel corners to map coordinates.
80
+ */
81
+ const getPaddedExtent = (map) => {
82
+ const size = map.getSize()
83
+ const view = map.getView()
84
+ const padding = view.padding || [0, 0, 0, 0] // [top, right, bottom, left]
85
+ const [top, right, bottom, left] = padding
86
+
87
+ const swPixel = [left, size[1] - bottom]
88
+ const nePixel = [size[0] - right, top]
89
+
90
+ const sw = map.getCoordinateFromPixel(swPixel)
91
+ const ne = map.getCoordinateFromPixel(nePixel)
92
+
93
+ if (!sw || !ne) {
94
+ return null
95
+ }
96
+
97
+ return [sw[0], sw[1], ne[0], ne[1]]
98
+ }
99
+
100
+ /**
101
+ * Returns true if the geometry's screen bounding box overlaps the given panel rectangle.
102
+ * GeoJSON coords are expected in WGS84; they are transformed to EPSG:27700 for screen projection.
103
+ */
104
+ const isGeometryObscured = (geojson, panelRect, map) => {
105
+ const containerRect = map.getTargetElement().getBoundingClientRect()
106
+ const [west, south, east, north] = getBboxFromGeoJSON(geojson)
107
+
108
+ const corners = [[west, south], [west, north], [east, south], [east, north]].map(coord => {
109
+ return map.getPixelFromCoordinate(coord)
110
+ }).filter(Boolean)
111
+
112
+ if (!corners.length) {
113
+ return false
114
+ }
115
+
116
+ const screenMinX = Math.min(...corners.map(c => c[0]))
117
+ const screenMaxX = Math.max(...corners.map(c => c[0]))
118
+ const screenMinY = Math.min(...corners.map(c => c[1]))
119
+ const screenMaxY = Math.max(...corners.map(c => c[1]))
120
+
121
+ const panelLeft = panelRect.left - containerRect.left
122
+ const panelTop = panelRect.top - containerRect.top
123
+ const panelRight = panelRect.right - containerRect.left
124
+ const panelBottom = panelRect.bottom - containerRect.top
125
+
126
+ return (
127
+ screenMinX < panelRight &&
128
+ screenMaxX > panelLeft &&
129
+ screenMinY < panelBottom &&
130
+ screenMaxY > panelTop
131
+ )
132
+ }
133
+
134
+ export {
135
+ getAreaDimensions,
136
+ getCardinalMove,
137
+ getBboxFromGeoJSON,
138
+ getExtentFromGeoJSON,
139
+ getPaddedExtent,
140
+ isGeometryObscured,
141
+ formatDimension
142
+ }