@defra/interactive-map 0.0.26-alpha → 0.0.27-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 (92) hide show
  1. package/dist/esm/im-core.js +1 -1
  2. package/dist/esm/im-shell.js +1 -1
  3. package/dist/umd/im-core.js +1 -1
  4. package/dist/umd/index.js +1 -1
  5. package/docs/assets/css/docusaurus.scss +4 -0
  6. package/package.json +1 -1
  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/defaults.js +14 -14
  10. package/plugins/beta/draw-ml/src/mapboxDraw.js +5 -0
  11. package/plugins/beta/draw-ml/src/mapboxSnap.js +1 -1
  12. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +10 -2
  13. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +2 -0
  14. package/plugins/beta/draw-ml/src/reducer.js +4 -1
  15. package/plugins/beta/draw-ml/src/styles.js +42 -39
  16. package/plugins/beta/draw-ol/dist/css/index.css +13 -0
  17. package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -0
  18. package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -0
  19. package/plugins/beta/draw-ol/dist/esm/geometryHelpers.js +1 -0
  20. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -0
  21. package/plugins/beta/draw-ol/dist/esm/index.js +1 -0
  22. package/plugins/beta/draw-ol/src/DrawInit.jsx +67 -0
  23. package/plugins/beta/draw-ol/src/api/addFeature.js +29 -0
  24. package/plugins/beta/draw-ol/src/api/deleteFeature.js +11 -0
  25. package/plugins/beta/draw-ol/src/api/editFeature.js +50 -0
  26. package/plugins/beta/draw-ol/src/api/newLine.js +44 -0
  27. package/plugins/beta/draw-ol/src/api/newPolygon.js +44 -0
  28. package/plugins/beta/draw-ol/src/core/OLDrawManager.js +150 -0
  29. package/plugins/beta/draw-ol/src/core/featureStore.js +65 -0
  30. package/plugins/beta/draw-ol/src/core/styles.js +98 -0
  31. package/plugins/beta/draw-ol/src/core/undoStack.js +31 -0
  32. package/plugins/beta/draw-ol/src/defaults.js +16 -0
  33. package/plugins/beta/draw-ol/src/draw/DrawMode.js +113 -0
  34. package/plugins/beta/draw-ol/src/draw/drawInput.js +199 -0
  35. package/plugins/beta/draw-ol/src/draw.scss +17 -0
  36. package/plugins/beta/draw-ol/src/edit/EditMode.js +424 -0
  37. package/plugins/beta/draw-ol/src/edit/keyboardHandler.js +227 -0
  38. package/plugins/beta/draw-ol/src/edit/midpointLayer.js +57 -0
  39. package/plugins/beta/draw-ol/src/edit/touchHandler.js +171 -0
  40. package/plugins/beta/draw-ol/src/edit/undoOps.js +95 -0
  41. package/plugins/beta/draw-ol/src/edit/vertexHitTest.js +72 -0
  42. package/plugins/beta/draw-ol/src/edit/vertexLayer.js +49 -0
  43. package/plugins/beta/draw-ol/src/edit/vertexOps.js +99 -0
  44. package/plugins/beta/draw-ol/src/events.js +114 -0
  45. package/plugins/beta/draw-ol/src/index.js +12 -0
  46. package/plugins/beta/draw-ol/src/manifest.js +121 -0
  47. package/plugins/beta/draw-ol/src/olDraw.js +38 -0
  48. package/plugins/beta/draw-ol/src/reducer.js +41 -0
  49. package/plugins/beta/draw-ol/src/snap/snapEngine.js +109 -0
  50. package/plugins/beta/draw-ol/src/snap/snapGeometry.js +200 -0
  51. package/plugins/beta/draw-ol/src/snap/snapIndicator.js +81 -0
  52. package/plugins/beta/draw-ol/src/snap/snapInteraction.js +56 -0
  53. package/plugins/beta/draw-ol/src/snap/snapManager.js +90 -0
  54. package/plugins/beta/draw-ol/src/utils/flattenStyleProperties.js +47 -0
  55. package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +97 -0
  56. package/plugins/beta/draw-ol/src/utils/olCoords.js +35 -0
  57. package/plugins/beta/draw-ol/src/utils/resolveColors.js +39 -0
  58. package/plugins/beta/draw-ol/src/utils/spatial.js +28 -0
  59. package/plugins/beta/draw-ol/src/utils/touchTarget.js +61 -0
  60. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  61. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  62. package/plugins/interact/src/defaults.js +1 -0
  63. package/plugins/interact/src/hooks/useInteractionHandlers.js +150 -43
  64. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +240 -8
  65. package/plugins/interact/src/reducer.js +7 -0
  66. package/plugins/interact/src/reducer.test.js +16 -0
  67. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  68. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  69. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  70. package/providers/beta/openlayers/src/appEvents.js +9 -13
  71. package/providers/beta/openlayers/src/appEvents.test.js +25 -17
  72. package/providers/beta/openlayers/src/mapEvents.js +32 -16
  73. package/providers/beta/openlayers/src/mapEvents.test.js +43 -10
  74. package/providers/beta/openlayers/src/openlayersProvider.js +45 -26
  75. package/providers/beta/openlayers/src/openlayersProvider.test.js +12 -34
  76. package/providers/beta/openlayers/src/utils/highlightFeatures.js +245 -0
  77. package/providers/beta/openlayers/src/utils/hoverCursor.js +65 -0
  78. package/providers/beta/openlayers/src/utils/hoverCursor.test.js +235 -0
  79. package/providers/beta/openlayers/src/utils/openLayersFixes.js +11 -0
  80. package/providers/beta/openlayers/src/utils/openLayersFixes.test.js +53 -0
  81. package/providers/beta/openlayers/src/utils/queryFeatures.js +77 -0
  82. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +181 -0
  83. package/providers/beta/openlayers/src/utils/tileLayers.js +19 -1
  84. package/providers/beta/openlayers/src/utils/tileLayers.test.js +74 -3
  85. package/providers/beta/openlayers/src/utils/vtTileFragments.js +86 -0
  86. package/rollup.esm.mjs +6 -0
  87. package/src/App/controls/keyboardActions.js +2 -2
  88. package/src/InteractiveMap/InteractiveMap.js +38 -10
  89. package/src/InteractiveMap/InteractiveMap.test.js +282 -356
  90. package/src/InteractiveMap/historyManager.js +7 -0
  91. package/src/InteractiveMap/historyManager.test.js +11 -0
  92. package/webpack.dev.mjs +3 -1
@@ -0,0 +1,181 @@
1
+ import { queryFeatures } from './queryFeatures.js'
2
+
3
+ import VectorTileLayer from 'ol/layer/VectorTile.js'
4
+ import VectorLayer from 'ol/layer/Vector.js'
5
+ import { renderFeatureToGeoJSON } from './vtTileFragments.js'
6
+
7
+ jest.mock('ol/layer/VectorTile.js', () => ({ __esModule: true, default: class VectorTileLayer {} }))
8
+ jest.mock('ol/layer/Vector.js', () => ({ __esModule: true, default: class VectorLayer {} }))
9
+ jest.mock('ol/format/GeoJSON.js', () => ({
10
+ __esModule: true,
11
+ default: jest.fn(() => ({
12
+ writeGeometryObject: jest.fn(geom => ({ type: 'mock', geom }))
13
+ }))
14
+ }))
15
+ jest.mock('./vtTileFragments.js', () => ({
16
+ renderFeatureToGeoJSON: jest.fn(f => ({ type: 'mock-vt', feature: f }))
17
+ }))
18
+
19
+ const makeMap = (hits = []) => ({
20
+ forEachFeatureAtPixel: jest.fn((pixel, cb, opts) => {
21
+ for (const [feature, layer] of hits) cb(feature, layer)
22
+ })
23
+ })
24
+
25
+ const makeVTLayer = () => Object.assign(new VectorTileLayer(), { get: () => undefined })
26
+
27
+ const makeVTFeature = ({ id = undefined, styleLayerId = 'roads', type = 'fill', props = {} } = {}) => ({
28
+ getId: () => id,
29
+ get: (key) => {
30
+ if (key === 'mapbox-layer') return { id: styleLayerId, type }
31
+ return undefined
32
+ },
33
+ getProperties: () => ({ 'mapbox-layer': { id: styleLayerId }, ...props })
34
+ })
35
+
36
+ const makeVectorLayer = (layerId, isHighlight = false) => Object.assign(new VectorLayer(), {
37
+ get: (key) => {
38
+ if (key === 'layerId') return layerId
39
+ if (key === '_highlight') return isHighlight || undefined
40
+ return undefined
41
+ }
42
+ })
43
+
44
+ const makeVectorFeature = (id = 'f1', geom = { type: 'Point' }, props = {}) => ({
45
+ getId: () => id,
46
+ getGeometry: () => geom,
47
+ getProperties: () => props
48
+ })
49
+
50
+ describe('queryFeatures', () => {
51
+ beforeEach(() => jest.clearAllMocks())
52
+
53
+ /* ------------------------------------------------------------------ */
54
+ /* Guard */
55
+ /* ------------------------------------------------------------------ */
56
+
57
+ it('returns empty array when point is null', () => {
58
+ expect(queryFeatures(makeMap(), null)).toEqual([])
59
+ })
60
+
61
+ it('returns empty array when point is undefined', () => {
62
+ expect(queryFeatures(makeMap())).toEqual([])
63
+ })
64
+
65
+ /* ------------------------------------------------------------------ */
66
+ /* Options */
67
+ /* ------------------------------------------------------------------ */
68
+
69
+ it('passes point.x/y as pixel and radius as hitTolerance', () => {
70
+ const map = makeMap()
71
+ queryFeatures(map, { x: 20, y: 30 }, { radius: 5 })
72
+ expect(map.forEachFeatureAtPixel).toHaveBeenCalledWith(
73
+ [20, 30],
74
+ expect.any(Function),
75
+ { hitTolerance: 5 }
76
+ )
77
+ })
78
+
79
+ it('defaults radius to 10', () => {
80
+ const map = makeMap()
81
+ queryFeatures(map, { x: 0, y: 0 })
82
+ expect(map.forEachFeatureAtPixel).toHaveBeenCalledWith(
83
+ expect.anything(),
84
+ expect.any(Function),
85
+ { hitTolerance: 10 }
86
+ )
87
+ })
88
+
89
+ /* ------------------------------------------------------------------ */
90
+ /* VectorTile layer features */
91
+ /* ------------------------------------------------------------------ */
92
+
93
+ it('returns a result for a VT feature', () => {
94
+ const feature = makeVTFeature({ id: 42, styleLayerId: 'roads' })
95
+ const map = makeMap([[feature, makeVTLayer()]])
96
+ const results = queryFeatures(map, { x: 0, y: 0 })
97
+ expect(results).toHaveLength(1)
98
+ expect(results[0]).toMatchObject({ id: 42, layer: { id: 'roads' } })
99
+ expect(renderFeatureToGeoJSON).toHaveBeenCalledWith(feature)
100
+ })
101
+
102
+ it('skips VT features with no mapbox-layer id', () => {
103
+ const feature = { getId: () => 1, get: () => undefined, getProperties: () => ({}) }
104
+ const map = makeMap([[feature, makeVTLayer()]])
105
+ expect(queryFeatures(map, { x: 0, y: 0 })).toEqual([])
106
+ })
107
+
108
+ it('skips background-type VT features', () => {
109
+ const feature = makeVTFeature({ type: 'background' })
110
+ const map = makeMap([[feature, makeVTLayer()]])
111
+ expect(queryFeatures(map, { x: 0, y: 0 })).toEqual([])
112
+ })
113
+
114
+ it('deduplicates VT features with the same styleLayerId and feature id', () => {
115
+ const feature = makeVTFeature({ id: 1, styleLayerId: 'roads' })
116
+ const map = makeMap([[feature, makeVTLayer()], [feature, makeVTLayer()]])
117
+ expect(queryFeatures(map, { x: 0, y: 0 })).toHaveLength(1)
118
+ })
119
+
120
+ it('uses property hash as id when VT feature has no explicit id', () => {
121
+ const feature = makeVTFeature({ id: undefined, styleLayerId: 'roads', props: { name: 'A' } })
122
+ const map = makeMap([[feature, makeVTLayer()]])
123
+ const results = queryFeatures(map, { x: 0, y: 0 })
124
+ expect(results).toHaveLength(1)
125
+ expect(results[0].id).toBeUndefined()
126
+ })
127
+
128
+ it('deduplicates id-less VT features with identical properties', () => {
129
+ const f1 = makeVTFeature({ id: undefined, styleLayerId: 'roads', props: { name: 'A' } })
130
+ const f2 = makeVTFeature({ id: undefined, styleLayerId: 'roads', props: { name: 'A' } })
131
+ const map = makeMap([[f1, makeVTLayer()], [f2, makeVTLayer()]])
132
+ expect(queryFeatures(map, { x: 0, y: 0 })).toHaveLength(1)
133
+ })
134
+
135
+ /* ------------------------------------------------------------------ */
136
+ /* Vector layer features */
137
+ /* ------------------------------------------------------------------ */
138
+
139
+ it('returns a result for a Vector layer feature', () => {
140
+ const feature = makeVectorFeature('f1', { type: 'Point' })
141
+ const map = makeMap([[feature, makeVectorLayer('draw')]])
142
+ const results = queryFeatures(map, { x: 0, y: 0 })
143
+ expect(results).toHaveLength(1)
144
+ expect(results[0]).toMatchObject({ id: 'f1', layer: { id: 'draw' } })
145
+ })
146
+
147
+ it('skips Vector layer features with no layerId', () => {
148
+ const feature = makeVectorFeature('f1')
149
+ const map = makeMap([[feature, makeVectorLayer(undefined)]])
150
+ expect(queryFeatures(map, { x: 0, y: 0 })).toEqual([])
151
+ })
152
+
153
+ it('skips highlight overlay Vector layers', () => {
154
+ const feature = makeVectorFeature('f1')
155
+ const map = makeMap([[feature, makeVectorLayer('draw', true)]])
156
+ expect(queryFeatures(map, { x: 0, y: 0 })).toEqual([])
157
+ })
158
+
159
+ it('deduplicates Vector layer features with the same layerId and feature id', () => {
160
+ const feature = makeVectorFeature('f1')
161
+ const map = makeMap([[feature, makeVectorLayer('draw')], [feature, makeVectorLayer('draw')]])
162
+ expect(queryFeatures(map, { x: 0, y: 0 })).toHaveLength(1)
163
+ })
164
+
165
+ /* ------------------------------------------------------------------ */
166
+ /* Mixed / other layers */
167
+ /* ------------------------------------------------------------------ */
168
+
169
+ it('skips features from other layer types', () => {
170
+ const feature = makeVectorFeature('f1')
171
+ const map = makeMap([[feature, {}]]) // plain object, not VectorTileLayer or VectorLayer
172
+ expect(queryFeatures(map, { x: 0, y: 0 })).toEqual([])
173
+ })
174
+
175
+ it('returns results from both VT and Vector layers in one call', () => {
176
+ const vtFeature = makeVTFeature({ id: 1, styleLayerId: 'roads' })
177
+ const vecFeature = makeVectorFeature('f1')
178
+ const map = makeMap([[vtFeature, makeVTLayer()], [vecFeature, makeVectorLayer('draw')]])
179
+ expect(queryFeatures(map, { x: 0, y: 0 })).toHaveLength(2)
180
+ })
181
+ })
@@ -1,13 +1,18 @@
1
1
  import XYZ from 'ol/source/XYZ.js'
2
2
  import VectorTileSource from 'ol/source/VectorTile.js'
3
+ import TileLayer from 'ol/layer/Tile.js'
3
4
  import VectorTileLayer from 'ol/layer/VectorTile.js'
4
5
  import OGCVectorTile from 'ol/source/OGCVectorTile.js'
5
6
  import MVT from 'ol/format/MVT.js'
6
7
  import TileGrid from 'ol/tilegrid/TileGrid.js'
7
8
  import TileState from 'ol/TileState.js'
8
- import { stylefunction } from 'ol-mapbox-style'
9
+ import { stylefunction, recordStyleLayer } from 'ol-mapbox-style'
9
10
  import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.js'
10
11
 
12
+ // Enable style-layer name recording so feature.get('mapbox-layer').id returns the
13
+ // style layer name — required for snap layer filtering by style layer name.
14
+ recordStyleLayer(true)
15
+
11
16
  const CRS = 'EPSG:27700'
12
17
 
13
18
  export function fetchWithTransform (url, resourceType, transformRequest) {
@@ -45,6 +50,19 @@ export function createTileSource (url, transformRequest) {
45
50
  })
46
51
  }
47
52
 
53
+ export async function createMapStyleLayer (mapStyle, transformRequest) {
54
+ if (mapStyle.type === 'raster') {
55
+ const source = createTileSource(mapStyle.url, transformRequest)
56
+ return { layer: new TileLayer({ source }), source }
57
+ }
58
+
59
+ if (mapStyle.type === 'ogc-vt') {
60
+ return createOGCVectorTileLayer(mapStyle.url, transformRequest, mapStyle)
61
+ }
62
+
63
+ return createVectorTileLayer(mapStyle.url, transformRequest, mapStyle)
64
+ }
65
+
48
66
  // Insert extension before any query string to match Mapbox GL sprite convention
49
67
  function resolveSprite (spriteBase, transformRequest) {
50
68
  const queryIdx = spriteBase.indexOf('?')
@@ -1,24 +1,29 @@
1
1
  import XYZ from 'ol/source/XYZ.js'
2
2
  import TileGrid from 'ol/tilegrid/TileGrid.js'
3
+ import TileLayer from 'ol/layer/Tile.js'
3
4
  import VectorTileSource from 'ol/source/VectorTile.js'
4
5
  import VectorTileLayer from 'ol/layer/VectorTile.js'
5
6
  import { stylefunction } from 'ol-mapbox-style'
6
- import { createTileSource, createVectorTileLayer } from './tileLayers.js'
7
+ import { createTileSource, createMapStyleLayer, createVectorTileLayer } from './tileLayers.js'
7
8
  import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.js'
8
9
 
9
10
  const mockTileGridInstance = {}
10
11
  const mockSourceInstance = {}
12
+ const mockTileLayerInstance = {}
11
13
  const mockVectorTileSourceInstance = {}
14
+ const mockOGCVectorTileSourceInstance = { supportedMediaTypes: [] }
12
15
  const mockVectorTileLayerInstance = {}
13
- const mockMVTInstance = {}
16
+ const mockMVTInstance = { supportedMediaTypes: [] }
14
17
 
15
18
  jest.mock('ol/source/XYZ.js', () => ({ __esModule: true, default: jest.fn(() => mockSourceInstance) }))
19
+ jest.mock('ol/layer/Tile.js', () => ({ __esModule: true, default: jest.fn(() => mockTileLayerInstance) }))
16
20
  jest.mock('ol/tilegrid/TileGrid.js', () => ({ __esModule: true, default: jest.fn(() => mockTileGridInstance) }))
17
21
  jest.mock('ol/TileState.js', () => ({ __esModule: true, default: { ERROR: 'error' } }))
18
22
  jest.mock('ol/source/VectorTile.js', () => ({ __esModule: true, default: jest.fn(() => mockVectorTileSourceInstance) }))
23
+ jest.mock('ol/source/OGCVectorTile.js', () => ({ __esModule: true, default: jest.fn(() => mockOGCVectorTileSourceInstance) }))
19
24
  jest.mock('ol/layer/VectorTile.js', () => ({ __esModule: true, default: jest.fn(() => mockVectorTileLayerInstance) }))
20
25
  jest.mock('ol/format/MVT.js', () => ({ __esModule: true, default: jest.fn(() => mockMVTInstance) }))
21
- jest.mock('ol-mapbox-style', () => ({ __esModule: true, stylefunction: jest.fn() }))
26
+ jest.mock('ol-mapbox-style', () => ({ __esModule: true, stylefunction: jest.fn(), recordStyleLayer: jest.fn() }))
22
27
 
23
28
  const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0))
24
29
 
@@ -41,6 +46,38 @@ const mockServiceJson = {
41
46
 
42
47
  const mockSpritesJson = { myIcon: { x: 0, y: 0, width: 16, height: 16, pixelRatio: 1 } }
43
48
 
49
+ const mockOGCStyleJson = {
50
+ sources: { ngd: { url: 'https://example.com/ogc/tiles' } },
51
+ sprite: 'https://example.com/sprites/sprite',
52
+ layers: []
53
+ }
54
+
55
+ const mockTilesetJson = {
56
+ links: [{ rel: 'http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme', href: 'https://example.com/ogc/tms' }]
57
+ }
58
+
59
+ const mockTMSJson = {
60
+ tileMatrices: [
61
+ { cellSize: 896, pointOfOrigin: [-238375, 1376256], tileHeight: 256, tileWidth: 256 },
62
+ { cellSize: 448, pointOfOrigin: [-238375, 1376256], tileHeight: 256, tileWidth: 256 }
63
+ ]
64
+ }
65
+
66
+ function makeOGCFetchMock (styleJson = mockOGCStyleJson) {
67
+ return jest.fn().mockImplementation(url => {
68
+ if (url === 'https://example.com/ogc/tiles') {
69
+ return Promise.resolve({ json: () => Promise.resolve(mockTilesetJson) })
70
+ }
71
+ if (url === 'https://example.com/ogc/tms') {
72
+ return Promise.resolve({ json: () => Promise.resolve(mockTMSJson) })
73
+ }
74
+ if (url.endsWith('.json') || url.includes('.json?')) {
75
+ return Promise.resolve({ json: () => Promise.resolve(mockSpritesJson) })
76
+ }
77
+ return Promise.resolve({ json: () => Promise.resolve(styleJson) })
78
+ })
79
+ }
80
+
44
81
  function makeVectorFetchMock (styleJson = mockStyleJson) {
45
82
  return jest.fn().mockImplementation(url => {
46
83
  if (url === styleJson.sources[Object.keys(styleJson.sources)[0]].url) {
@@ -146,6 +183,40 @@ describe('tileLoadFunction (via transformRequest)', () => {
146
183
  })
147
184
  })
148
185
 
186
+ describe('createMapStyleLayer', () => {
187
+ const styleUrl = 'https://example.com/styles'
188
+
189
+ beforeEach(() => {
190
+ jest.clearAllMocks()
191
+ global.fetch = makeVectorFetchMock()
192
+ })
193
+
194
+ it('creates a raster tile layer and source when mapStyle.type is raster', async () => {
195
+ const result = await createMapStyleLayer({ url: 'https://tiles.example.com/{z}/{x}/{y}', type: 'raster' }, null)
196
+ expect(TileLayer).toHaveBeenCalledWith({ source: mockSourceInstance })
197
+ expect(result).toEqual({ layer: mockTileLayerInstance, source: mockSourceInstance })
198
+ })
199
+
200
+ it('creates an OGC vector tile layer and source when mapStyle.type is ogc-vt', async () => {
201
+ global.fetch = makeOGCFetchMock()
202
+ const result = await createMapStyleLayer({ url: 'https://example.com/ogc-styles', type: 'ogc-vt' }, null)
203
+ expect(VectorTileLayer).toHaveBeenCalledWith({ source: mockOGCVectorTileSourceInstance, declutter: true })
204
+ expect(result).toEqual({ layer: mockVectorTileLayerInstance, source: mockOGCVectorTileSourceInstance })
205
+ })
206
+
207
+ it('creates a vector tile layer and source when mapStyle.type is vector', async () => {
208
+ const result = await createMapStyleLayer({ url: styleUrl, type: 'vector' }, null)
209
+ expect(VectorTileLayer).toHaveBeenCalledWith({ source: mockVectorTileSourceInstance, declutter: true })
210
+ expect(result).toEqual({ layer: mockVectorTileLayerInstance, source: mockVectorTileSourceInstance })
211
+ })
212
+
213
+ it('creates a vector tile layer and source when mapStyle.type is missing', async () => {
214
+ const result = await createMapStyleLayer({ url: styleUrl }, null)
215
+ expect(VectorTileLayer).toHaveBeenCalledWith({ source: mockVectorTileSourceInstance, declutter: true })
216
+ expect(result).toEqual({ layer: mockVectorTileLayerInstance, source: mockVectorTileSourceInstance })
217
+ })
218
+ })
219
+
149
220
  describe('createVectorTileLayer', () => {
150
221
  const styleUrl = 'https://example.com/styles'
151
222
 
@@ -0,0 +1,86 @@
1
+ import VectorTileLayer from 'ol/layer/VectorTile.js'
2
+ import TileState from 'ol/TileState.js'
3
+ const toPairs = (flat, start, end) => {
4
+ const coords = []
5
+ for (let i = start; i < end; i += 2) {
6
+ coords.push([flat[i], flat[i + 1]])
7
+ }
8
+ return coords
9
+ }
10
+
11
+ export const renderFeatureToGeoJSON = (feature) => {
12
+ const type = feature.getType()
13
+ const flat = feature.getFlatCoordinates()
14
+
15
+ if (type === 'Point') {
16
+ return { type: 'Point', coordinates: [flat[0], flat[1]] }
17
+ }
18
+ if (type === 'LineString') {
19
+ return { type: 'LineString', coordinates: toPairs(flat, 0, flat.length) }
20
+ }
21
+ if (type === 'Polygon') {
22
+ const ends = feature.getEnds()
23
+ let prev = 0
24
+ const rings = ends.map(end => {
25
+ const ring = toPairs(flat, prev, end)
26
+ prev = end
27
+ return ring
28
+ })
29
+ return { type: 'Polygon', coordinates: rings }
30
+ }
31
+ if (type === 'MultiPolygon') {
32
+ const endss = feature.getEndss()
33
+ let offset = 0
34
+ const polys = endss.map(ends => {
35
+ let prev = offset
36
+ const rings = ends.map(end => {
37
+ const ring = toPairs(flat, prev, end)
38
+ prev = end
39
+ return ring
40
+ })
41
+ offset = ends[ends.length - 1]
42
+ return rings
43
+ })
44
+ return { type: 'MultiPolygon', coordinates: polys }
45
+ }
46
+ // MultiLineString / MultiPoint fallback
47
+ return { type, coordinates: toPairs(flat, 0, flat.length) }
48
+ }
49
+
50
+ /**
51
+ * Collects all loaded VT tile fragments for a feature across all VectorTileLayers.
52
+ * OL clips VT features at tile boundaries, so a single logical feature appears as
53
+ * multiple RenderFeature instances (one per tile). This gathers them all so callers
54
+ * can work with the full feature geometry rather than a single clipped fragment.
55
+ */
56
+ export const collectTileFragments = (map, layerId, featureId, idProperty) => {
57
+ const fragments = []
58
+
59
+ map.getLayers().forEach(mapLayer => {
60
+ if (!(mapLayer instanceof VectorTileLayer)) {
61
+ return
62
+ }
63
+ const source = mapLayer.getSource()
64
+ const sourceTiles = source?.sourceTiles_
65
+ if (!sourceTiles) {
66
+ return
67
+ }
68
+ Object.values(sourceTiles).forEach(tile => {
69
+ if (tile.getState() !== TileState.LOADED) {
70
+ return
71
+ }
72
+ tile.getFeatures().forEach(feature => {
73
+ if (feature.get('mapbox-layer')?.id !== layerId) {
74
+ return
75
+ }
76
+ const fid = idProperty ? feature.get(idProperty) : feature.getId()
77
+ if (String(fid) !== String(featureId)) {
78
+ return
79
+ }
80
+ fragments.push(renderFeatureToGeoJSON(feature))
81
+ })
82
+ })
83
+ })
84
+
85
+ return fragments
86
+ }
package/rollup.esm.mjs CHANGED
@@ -290,6 +290,12 @@ const ALL_BUILDS = [
290
290
  outDir: 'plugins/beta/draw-es/dist/esm',
291
291
  manualChunks: (id) => { if (id.includes('/manifest')) return 'im-draw-es-plugin' }
292
292
  },
293
+ {
294
+ entryPath: './plugins/beta/draw-ol/src/index.js',
295
+ outDir: 'plugins/beta/draw-ol/dist/esm',
296
+ extraExternals: [/^ol\//],
297
+ manualChunks: (id) => { if (id.includes('/manifest')) return 'im-draw-ol-plugin' }
298
+ },
293
299
  {
294
300
  entryPath: './plugins/beta/frame/src/index.js',
295
301
  outDir: 'plugins/beta/frame/dist/esm',
@@ -50,7 +50,7 @@ export const createKeyboardActions = (mapProvider, announce, {
50
50
  },
51
51
 
52
52
  highlightNextLabel: (e) => {
53
- if (!readMapText && mapProvider.highlightNextLabel) {
53
+ if (!readMapText || !mapProvider.highlightNextLabel) {
54
54
  return
55
55
  }
56
56
  const label = mapProvider.highlightNextLabel(e.key)
@@ -58,7 +58,7 @@ export const createKeyboardActions = (mapProvider, announce, {
58
58
  },
59
59
 
60
60
  highlightLabelAtCenter: () => {
61
- if (!readMapText && mapProvider.highlightNextLabel) {
61
+ if (!readMapText || !mapProvider.highlightLabelAtCenter) {
62
62
  return
63
63
  }
64
64
  const label = mapProvider.highlightLabelAtCenter()
@@ -38,6 +38,8 @@ export default class InteractiveMap {
38
38
  _interfaceDetectorCleanup = null
39
39
  _hybridBehaviourCleanup = null
40
40
  _isHidden = false // tracks if map is hidden but preserved (hybrid mode)
41
+ _isClosingViaBack = false // prevents double history.back() while popstate is pending
42
+ _isLoading = false // prevents concurrent loadApp() calls
41
43
 
42
44
  /**
43
45
  * Create a new InteractiveMap instance.
@@ -103,12 +105,17 @@ export default class InteractiveMap {
103
105
  }
104
106
 
105
107
  _handleButtonClick (e) {
106
- if (this.config.manageHistoryState) {
107
- history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href'))
108
- }
109
108
  if (this._isHidden) {
109
+ if (this.config.manageHistoryState) {
110
+ history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href'))
111
+ }
110
112
  this.showApp()
113
+ } else if (this._root) {
114
+ // app already open — no-op, no push
111
115
  } else {
116
+ if (this.config.manageHistoryState) {
117
+ history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href'))
118
+ }
112
119
  this.loadApp()
113
120
  }
114
121
  }
@@ -128,22 +135,35 @@ export default class InteractiveMap {
128
135
  }
129
136
 
130
137
  _handleExitClick () {
131
- if (this.config.preserveStateOnClose) {
132
- this.hideApp()
133
- } else {
134
- this.removeApp()
135
- }
136
-
137
138
  if (!this.config.manageHistoryState) {
139
+ if (this.config.preserveStateOnClose) {
140
+ this.hideApp()
141
+ } else {
142
+ this.removeApp()
143
+ }
138
144
  return
139
145
  }
140
146
 
141
147
  // If this history entry was pushed by the map's open button, go back so the
142
148
  // ?mv= entry is preserved as a forward entry (browser forward re-opens the map).
143
- // Otherwise (direct URL / bookmark), just strip the param in place.
149
+ // Don't close synchronously here let the popstate handler (syncMapInstance →
150
+ // closeMap) do it. This prevents a race where the button becomes clickable before
151
+ // history.back() resolves, which would push a ghost ?mv= entry into history.
144
152
  if (history.state?.isBack) {
153
+ if (this._isClosingViaBack) {
154
+ return
155
+ }
156
+ this._isClosingViaBack = true
145
157
  history.back()
158
+ // Safety net: clear flag if popstate never fires (e.g. nothing to go back to)
159
+ setTimeout(() => { this._isClosingViaBack = false }, 1000)
146
160
  } else {
161
+ // Direct URL / bookmark: no popstate coming, so close immediately.
162
+ if (this.config.preserveStateOnClose) {
163
+ this.hideApp()
164
+ } else {
165
+ this.removeApp()
166
+ }
147
167
  const key = this.config.mapViewQueryParam
148
168
  const newUrl = this._removeMapParamFromUrl(location.href, key)
149
169
  history.replaceState(history.state, '', newUrl)
@@ -157,6 +177,11 @@ export default class InteractiveMap {
157
177
  * @returns {Promise<void>}
158
178
  */
159
179
  async loadApp () {
180
+ if (this._root || this._isLoading) {
181
+ return
182
+ }
183
+ this._isLoading = true
184
+
160
185
  if (this._openButton) {
161
186
  this._openButton.style.display = 'none'
162
187
  }
@@ -203,9 +228,12 @@ export default class InteractiveMap {
203
228
  updateDOMState(this)
204
229
  this.eventBus.emit(events.APP_OPENED, { statePreserved: false })
205
230
  } catch (err) {
231
+ this._openButton?.removeAttribute('style')
206
232
  renderError(this.rootEl, this.config.genericErrorText)
207
233
  console.error(err)
208
234
  throw err
235
+ } finally {
236
+ this._isLoading = false
209
237
  }
210
238
  }
211
239