@defra/interactive-map 0.0.46-alpha → 0.0.46-fmp-2

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 (39) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/umd/im-core.js +1 -1
  4. package/docs/api/map-style-config.md +7 -0
  5. package/docs/assets/images/select-feature.jpg +0 -0
  6. package/govuk-prototype-kit.config.json +1 -0
  7. package/package.json +1 -1
  8. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  9. package/plugins/beta/draw-es/src/api/editFeature.js +12 -5
  10. package/plugins/beta/draw-es/src/api/newPolygon.js +7 -2
  11. package/plugins/beta/draw-es/src/api/removeSafeZone.js +30 -0
  12. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  13. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  14. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  15. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +5 -0
  16. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.js +10 -5
  17. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js +1 -0
  18. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  19. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  20. package/providers/beta/openlayers/src/openlayersProvider.js +1 -1
  21. package/providers/beta/openlayers/src/openlayersProvider.test.js +22 -0
  22. package/providers/beta/openlayers/src/utils/highlightFeatures.js +23 -11
  23. package/providers/beta/openlayers/src/utils/highlightFeatures.test.js +2 -0
  24. package/providers/beta/openlayers/src/utils/hoverCursor.js +6 -5
  25. package/providers/beta/openlayers/src/utils/hoverCursor.test.js +2 -1
  26. package/providers/beta/openlayers/src/utils/queryFeatures.js +9 -6
  27. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +6 -3
  28. package/providers/beta/openlayers/src/utils/spatial.js +6 -15
  29. package/providers/beta/openlayers/src/utils/spatial.test.js +3 -9
  30. package/providers/beta/openlayers/src/utils/tileLayers.js +5 -0
  31. package/providers/beta/openlayers/src/utils/tileLayers.test.js +1 -1
  32. package/src/App/components/Attributions/Attributions.jsx +3 -1
  33. package/src/App/components/Attributions/Attributions.module.scss +7 -3
  34. package/src/App/components/Attributions/Attributions.test.jsx +8 -0
  35. package/src/App/hooks/useLayoutMeasurements.js +94 -29
  36. package/src/App/hooks/useLayoutMeasurements.test.js +55 -10
  37. package/src/App/layout/Layout.jsx +7 -3
  38. package/src/App/layout/layout.module.scss +30 -1
  39. package/src/types.js +5 -0
@@ -64,7 +64,7 @@ export default class OpenLayersProvider extends MapProvider {
64
64
  projection: CRS,
65
65
  center: center ?? (bounds ? getExtentCenter(bounds) : undefined),
66
66
  zoom: zoom ?? viewResolutions.defaultMinZoom,
67
- minZoom: Math.max(minZoom ?? viewResolutions.defaultMinZoom, viewResolutions.defaultMinZoom),
67
+ minZoom: minZoom ?? viewResolutions.defaultMinZoom,
68
68
  maxZoom: maxZoom ?? viewResolutions.maxZoom,
69
69
  resolutions: viewResolutions.resolutions,
70
70
  constrainResolution: false,
@@ -174,6 +174,28 @@ describe('OpenLayersProvider', () => {
174
174
  expect(View).toHaveBeenCalledWith(expect.objectContaining({ center: [400000, 300000] }))
175
175
  })
176
176
 
177
+ it('passes minZoom and maxZoom through to the View unchanged', async () => {
178
+ const { provider } = makeProvider({ zoomAlignment: 'world' })
179
+ await provider.initMap({ ...defaultInitConfig, minZoom: 8, maxZoom: 12 })
180
+ expect(View).toHaveBeenCalledWith(expect.objectContaining({ minZoom: 8, maxZoom: 12 }))
181
+ })
182
+
183
+ // Regression: minZoom used to be floored at the zoom alignment's default via Math.max(),
184
+ // silently overriding any caller-supplied minZoom that was more permissive (lower) than
185
+ // that default — e.g. a consumer trying to let people zoom out further than the 'world'
186
+ // alignment's default minZoom of 6 had their config ignored.
187
+ it('does not clamp a minZoom below the zoom alignment default', async () => {
188
+ const { provider } = makeProvider({ zoomAlignment: 'world' })
189
+ await provider.initMap({ ...defaultInitConfig, minZoom: 0 })
190
+ expect(View).toHaveBeenCalledWith(expect.objectContaining({ minZoom: 0 }))
191
+ })
192
+
193
+ it('falls back to the zoom alignment defaults when minZoom/maxZoom are not provided', async () => {
194
+ const { provider } = makeProvider({ zoomAlignment: 'world' })
195
+ await provider.initMap({ ...defaultInitConfig, minZoom: null, maxZoom: null })
196
+ expect(View).toHaveBeenCalledWith(expect.objectContaining({ minZoom: 6, maxZoom: 20 }))
197
+ })
198
+
177
199
  it('creates the initial layer from the map style', async () => {
178
200
  const { provider } = makeProvider()
179
201
  await provider.initMap(defaultInitConfig)
@@ -1,4 +1,3 @@
1
- import VectorTileLayer from 'ol/layer/VectorTile.js'
2
1
  import VectorLayer from 'ol/layer/Vector.js'
3
2
  import VectorSource from 'ol/source/Vector.js'
4
3
  import Feature from 'ol/Feature.js'
@@ -18,6 +17,11 @@ const geoJsonFormat = new GeoJSON({ dataProjection: CRS, featureProjection: CRS
18
17
  const HIGHLIGHT_MARKER = '_highlight'
19
18
  const HIGHLIGHT_Z = 999
20
19
 
20
+ // Layers are classified by a `layerType` tag ('vector' | 'vectorTile') set at creation,
21
+ // not `instanceof VectorLayer`/`VectorTileLayer` — a UMD consumer loads this provider and
22
+ // other plugins (e.g. draw) as independently-bundled scripts, each with its own copy of
23
+ // ol, so a class reference from this bundle never matches an instance built by another.
24
+
21
25
  const buildHighlightStyles = (styleEntry, isActive) => {
22
26
  if (!styleEntry) {
23
27
  return []
@@ -40,6 +44,13 @@ const buildHighlightStyles = (styleEntry, isActive) => {
40
44
 
41
45
  const hasSymbolStyle = (properties) => !!(properties?.symbol || properties?.symbolSvgContent)
42
46
 
47
+ const toStyleArray = (style) => {
48
+ if (!style) {
49
+ return []
50
+ }
51
+ return Array.isArray(style) ? style : [style]
52
+ }
53
+
43
54
  // A drawn point renders as a real symbol icon, not Stroke/Fill, so its selected/active ring is
44
55
  // the active/selected variant of that same icon instead. Returns null (not []) for a
45
56
  // non-symbol feature so the caller falls through to buildHighlightStyles.
@@ -83,7 +94,7 @@ const buildFeatureKeyIndex = (features) => {
83
94
 
84
95
  const wrapVtLayers = (map, selectedKeys, activeKeys, idPropsMap, stylesMap) => {
85
96
  map.getLayers().forEach(layer => {
86
- if (!(layer instanceof VectorTileLayer)) {
97
+ if (layer.get('layerType') !== 'vectorTile') {
87
98
  return
88
99
  }
89
100
 
@@ -124,8 +135,7 @@ const wrapVtLayers = (map, selectedKeys, activeKeys, idPropsMap, stylesMap) => {
124
135
  return base
125
136
  }
126
137
 
127
- const baseArr = base ? (Array.isArray(base) ? base : [base]) : []
128
- return [...baseArr, ...highlightStyles]
138
+ return [...toStyleArray(base), ...highlightStyles]
129
139
  })
130
140
  // setStyle() calls layer.changed() internally — no source.changed() needed
131
141
  // (source.changed() works but causes a visible flicker on selection)
@@ -148,6 +158,7 @@ const getOrCreateHighlightLayer = (map) => {
148
158
  if (!layer) {
149
159
  layer = new VectorLayer({ source: new VectorSource(), zIndex: HIGHLIGHT_Z + 2 })
150
160
  layer.set(HIGHLIGHT_MARKER, true)
161
+ layer.set('layerType', 'vector')
151
162
  map.addLayer(layer)
152
163
  }
153
164
  return layer
@@ -162,7 +173,7 @@ const getLiveProperties = (map, layerId, featureId) => {
162
173
  }
163
174
  let properties
164
175
  map.getLayers().forEach(l => {
165
- if (properties || !(l instanceof VectorLayer) || l.get(HIGHLIGHT_MARKER) || l.get('layerId') !== layerId) {
176
+ if (properties || l.get('layerType') !== 'vector' || l.get(HIGHLIGHT_MARKER) || l.get('layerId') !== layerId) {
166
177
  return
167
178
  }
168
179
  const feature = l.getSource()?.getFeatureById(String(featureId))
@@ -180,12 +191,11 @@ const addVectorHighlights = (map, source, features, isActive, stylesMap) => {
180
191
  }
181
192
  const liveProperties = getLiveProperties(map, layerId, featureId)
182
193
  const styles = buildSymbolHighlightStyle(liveProperties, isActive) ?? buildHighlightStyles(stylesMap?.[layerId], isActive)
183
- if (!styles.length) {
184
- continue
194
+ if (styles.length) {
195
+ const olFeature = new Feature({ geometry: geoJsonFormat.readGeometry(geometry) })
196
+ olFeature.setStyle(styles)
197
+ source.addFeature(olFeature)
185
198
  }
186
- const olFeature = new Feature({ geometry: geoJsonFormat.readGeometry(geometry) })
187
- olFeature.setStyle(styles)
188
- source.addFeature(olFeature)
189
199
  }
190
200
  }
191
201
 
@@ -206,6 +216,8 @@ const expandBoundsFromGeometry = (geometry, cb) => {
206
216
  coordinates.forEach(visitRing)
207
217
  } else if (type === 'MultiPolygon') {
208
218
  coordinates.forEach(poly => poly.forEach(visitRing))
219
+ } else {
220
+ // unsupported/unrecognised geometry type — nothing to expand bounds by
209
221
  }
210
222
  }
211
223
 
@@ -260,7 +272,7 @@ export const updateHighlightedFeatures = (map, selectedFeatures, activeFeatures,
260
272
  // Determine which layerIds belong to plain VectorLayers vs VT layers
261
273
  const vectorLayerIds = new Set()
262
274
  map.getLayers().forEach(l => {
263
- if (l instanceof VectorLayer && !l.get(HIGHLIGHT_MARKER)) {
275
+ if (l.get('layerType') === 'vector' && !l.get(HIGHLIGHT_MARKER)) {
264
276
  const id = l.get('layerId')
265
277
  if (id) {
266
278
  vectorLayerIds.add(id)
@@ -38,6 +38,7 @@ const drawLayer = (features = []) => {
38
38
  })
39
39
  const layer = new VectorLayer({ source })
40
40
  layer.set('layerId', 'draw')
41
+ layer.set('layerType', 'vector') // mirrors OLDrawManager.js's own tagging
41
42
  return layer
42
43
  }
43
44
 
@@ -170,6 +171,7 @@ describe('updateHighlightedFeatures', () => {
170
171
  describe('VectorTileLayer style-wrap (smoke test — pre-existing, undocumented path)', () => {
171
172
  test('does not throw when a VT layer is present alongside a draw VectorLayer', () => {
172
173
  const vt = new VectorTileLayer({})
174
+ vt.set('layerType', 'vectorTile')
173
175
  const map = createFakeMap([vt, drawLayer()])
174
176
  expect(() => updateHighlightedFeatures(map, [], [], {})).not.toThrow()
175
177
  })
@@ -1,15 +1,16 @@
1
- import VectorTileLayer from 'ol/layer/VectorTile.js'
2
- import VectorLayer from 'ol/layer/Vector.js'
3
-
4
1
  const HIGHLIGHT_MARKER = '_highlight'
5
2
  const HIT_TOLERANCE = 8
6
3
 
4
+ // Layers are classified by a `layerType` tag ('vector' | 'vectorTile') set at creation,
5
+ // not `instanceof VectorLayer`/`VectorTileLayer` — a UMD consumer loads this provider and
6
+ // other plugins (e.g. draw) as independently-bundled scripts, each with its own copy of
7
+ // ol, so a class reference from this bundle never matches an instance built by another.
7
8
  const isInteractiveFeature = (feature, layer, layerSet) => {
8
- if (layer instanceof VectorTileLayer) {
9
+ if (layer.get('layerType') === 'vectorTile') {
9
10
  const styleLayerId = feature.get('mapbox-layer')?.id
10
11
  return Boolean(styleLayerId && layerSet.has(styleLayerId))
11
12
  }
12
- if (layer instanceof VectorLayer && !layer.get(HIGHLIGHT_MARKER)) {
13
+ if (layer.get('layerType') === 'vector' && !layer.get(HIGHLIGHT_MARKER)) {
13
14
  const layerId = layer.get('layerId')
14
15
  return Boolean(layerId && layerSet.has(layerId))
15
16
  }
@@ -26,13 +26,14 @@ const makeVectorFeature = () => ({ get: () => undefined })
26
26
 
27
27
  const makeVTLayer = () => {
28
28
  const layer = new VectorTileLayer()
29
- layer.get = () => undefined
29
+ layer.get = (key) => key === 'layerType' ? 'vectorTile' : undefined
30
30
  return layer
31
31
  }
32
32
 
33
33
  const makeVectorLayer = (layerId, isHighlight = false) => {
34
34
  const layer = new VectorLayer()
35
35
  layer.get = (key) => {
36
+ if (key === 'layerType') return 'vector'
36
37
  if (key === '_highlight') return isHighlight ? true : undefined
37
38
  if (key === 'layerId') return layerId
38
39
  return undefined
@@ -1,5 +1,3 @@
1
- import VectorTileLayer from 'ol/layer/VectorTile.js'
2
- import VectorLayer from 'ol/layer/Vector.js'
3
1
  import GeoJSON from 'ol/format/GeoJSON.js'
4
2
  import TileState from 'ol/TileState.js'
5
3
  import { renderFeatureToGeoJSON } from './vtTileFragments.js'
@@ -8,6 +6,11 @@ const CRS = 'EPSG:27700'
8
6
 
9
7
  const geoJsonFormat = new GeoJSON({ dataProjection: CRS, featureProjection: CRS })
10
8
 
9
+ // Layers are classified by a `layerType` tag ('vector' | 'vectorTile') set at creation,
10
+ // not `instanceof VectorLayer`/`VectorTileLayer` — a UMD consumer loads this provider and
11
+ // other plugins (e.g. draw) as independently-bundled scripts, each with its own copy of
12
+ // ol, so a class reference from this bundle never matches an instance built by another.
13
+
11
14
  // Mirror MapLibre's fallback: use property hash when feature has no explicit MVT ID.
12
15
  // This deduplicates tile-split fragments that share the same properties.
13
16
  const getVtFeatureId = (feature) => {
@@ -32,7 +35,7 @@ export const queryFeatures = (map, point, options = {}) => {
32
35
  map.forEachFeatureAtPixel(
33
36
  pixel,
34
37
  (feature, layer) => {
35
- if (layer instanceof VectorTileLayer) {
38
+ if (layer.get('layerType') === 'vectorTile') {
36
39
  const mapboxLayer = feature.get('mapbox-layer')
37
40
  const styleLayerId = mapboxLayer?.id
38
41
  // background-type layers have no features in MapLibre — skip to match behaviour
@@ -50,7 +53,7 @@ export const queryFeatures = (map, point, options = {}) => {
50
53
  geometry: renderFeatureToGeoJSON(feature),
51
54
  properties: feature.getProperties()
52
55
  })
53
- } else if (layer instanceof VectorLayer) {
56
+ } else if (layer.get('layerType') === 'vector') {
54
57
  const layerId = layer.get('layerId')
55
58
  if (!layerId || layer.get('_highlight')) {
56
59
  return
@@ -98,7 +101,7 @@ export const getVisibleFeatures = (map, layerIds) => {
98
101
  const extent = map.getView().calculateExtent(map.getSize())
99
102
 
100
103
  map.getLayers().forEach(mapLayer => {
101
- if (mapLayer instanceof VectorTileLayer) {
104
+ if (mapLayer.get('layerType') === 'vectorTile') {
102
105
  const sourceTiles = mapLayer.getSource()?.sourceTiles_
103
106
  if (!sourceTiles) {
104
107
  return
@@ -127,7 +130,7 @@ export const getVisibleFeatures = (map, layerIds) => {
127
130
  })
128
131
  })
129
132
  })
130
- } else if (mapLayer instanceof VectorLayer) {
133
+ } else if (mapLayer.get('layerType') === 'vector') {
131
134
  const layerId = mapLayer.get('layerId')
132
135
  if (!layerId || !wanted.has(layerId) || mapLayer.get('_highlight')) {
133
136
  return
@@ -24,7 +24,7 @@ const makeMap = (hits = []) => ({
24
24
  })
25
25
  })
26
26
 
27
- const makeVTLayer = () => Object.assign(new VectorTileLayer(), { get: () => undefined })
27
+ const makeVTLayer = () => Object.assign(new VectorTileLayer(), { get: (key) => key === 'layerType' ? 'vectorTile' : undefined })
28
28
 
29
29
  const makeVTFeature = ({ id = undefined, styleLayerId = 'roads', type = 'fill', props = {} } = {}) => ({
30
30
  getId: () => id,
@@ -37,6 +37,7 @@ const makeVTFeature = ({ id = undefined, styleLayerId = 'roads', type = 'fill',
37
37
 
38
38
  const makeVectorLayer = (layerId, isHighlight = false) => Object.assign(new VectorLayer(), {
39
39
  get: (key) => {
40
+ if (key === 'layerType') return 'vector'
40
41
  if (key === 'layerId') return layerId
41
42
  if (key === '_highlight') return isHighlight || undefined
42
43
  return undefined
@@ -170,7 +171,7 @@ describe('queryFeatures', () => {
170
171
 
171
172
  it('skips features from other layer types', () => {
172
173
  const feature = makeVectorFeature('f1')
173
- const map = makeMap([[feature, {}]]) // plain object, not VectorTileLayer or VectorLayer
174
+ const map = makeMap([[feature, { get: () => undefined }]]) // untagged, not a vector/vectorTile layer
174
175
  expect(queryFeatures(map, { x: 0, y: 0 })).toEqual([])
175
176
  })
176
177
 
@@ -199,6 +200,7 @@ describe('getVisibleFeatures', () => {
199
200
  })
200
201
 
201
202
  const makeVTLayerWithTiles = (tiles) => Object.assign(new VectorTileLayer(), {
203
+ get: (key) => key === 'layerType' ? 'vectorTile' : undefined,
202
204
  getSource: () => ({ sourceTiles_: tiles })
203
205
  })
204
206
 
@@ -206,6 +208,7 @@ describe('getVisibleFeatures', () => {
206
208
  const source = { getFeaturesInExtent: jest.fn(() => features) }
207
209
  return Object.assign(new VectorLayer(), {
208
210
  get: (key) => {
211
+ if (key === 'layerType') return 'vector'
209
212
  if (key === 'layerId') return layerId
210
213
  if (key === '_highlight') return isHighlight || undefined
211
214
  return undefined
@@ -290,7 +293,7 @@ describe('getVisibleFeatures', () => {
290
293
  /* ------------------------------------------------------------------ */
291
294
 
292
295
  it('skips layers that are neither VectorTileLayer nor VectorLayer', () => {
293
- expect(getVisibleFeatures(makeExtentMap([{}]), ['draw'])).toEqual([])
296
+ expect(getVisibleFeatures(makeExtentMap([{ get: () => undefined }]), ['draw'])).toEqual([])
294
297
  })
295
298
 
296
299
  it('returns results from both VT and Vector layers in one call', () => {
@@ -1,5 +1,4 @@
1
1
  import turfBbox from '@turf/bbox'
2
- import { transformExtent } from 'ol/proj.js'
3
2
 
4
3
  // In EPSG:27700 coordinates are projected metres — distances are Pythagorean, no geodesy needed
5
4
 
@@ -63,18 +62,11 @@ const getCardinalMove = (from, to) => {
63
62
  }
64
63
 
65
64
  /**
66
- * Get a flat bbox [west, south, east, north] in WGS84 from any GeoJSON object.
65
+ * Get a flat extent [xmin, ymin, xmax, ymax] from any GeoJSON object.
66
+ * GeoJSON fed into the OL provider is already in EPSG:27700 (the view's native
67
+ * CRS), not WGS84, so this is a straight bbox — no reprojection.
67
68
  */
68
- const getBboxFromGeoJSON = (geojson) => turfBbox(geojson)
69
-
70
- /**
71
- * Get a flat extent [xmin, ymin, xmax, ymax] in EPSG:27700 from any GeoJSON object.
72
- * GeoJSON is always WGS84, so this transforms the bbox.
73
- */
74
- const getExtentFromGeoJSON = (geojson) => {
75
- const wgs84Bbox = turfBbox(geojson)
76
- return transformExtent(wgs84Bbox, 'EPSG:4326', 'EPSG:27700')
77
- }
69
+ const getExtentFromGeoJSON = (geojson) => turfBbox(geojson)
78
70
 
79
71
  /**
80
72
  * Returns the visible (padded) extent [xmin, ymin, xmax, ymax] in EPSG:27700.
@@ -114,9 +106,9 @@ const isGeometryObscured = (geojson, panelRect, map) => {
114
106
  const scaleX = viewportRect.width / containerRect.width
115
107
  const scaleY = viewportRect.height / containerRect.height
116
108
 
117
- const [west, south, east, north] = getBboxFromGeoJSON(geojson)
109
+ const [xmin, ymin, xmax, ymax] = getExtentFromGeoJSON(geojson)
118
110
 
119
- const corners = [[west, south], [west, north], [east, south], [east, north]].map(coord => {
111
+ const corners = [[xmin, ymin], [xmin, ymax], [xmax, ymin], [xmax, ymax]].map(coord => {
120
112
  return map.getPixelFromCoordinate(coord)
121
113
  }).filter(Boolean)
122
114
 
@@ -140,7 +132,6 @@ const isGeometryObscured = (geojson, panelRect, map) => {
140
132
  export {
141
133
  getAreaDimensions,
142
134
  getCardinalMove,
143
- getBboxFromGeoJSON,
144
135
  getExtentFromGeoJSON,
145
136
  getPaddedExtent,
146
137
  isGeometryObscured,
@@ -7,12 +7,6 @@ import {
7
7
  isGeometryObscured
8
8
  } from './spatial.js'
9
9
 
10
- jest.mock('ol/proj.js', () => ({
11
- __esModule: true,
12
- transform: (coord) => coord,
13
- transformExtent: (extent) => extent
14
- }))
15
-
16
10
  describe('formatDimension', () => {
17
11
  it('formats sub-mile distances in metres', () => {
18
12
  expect(formatDimension(400)).toBe('400 metres')
@@ -65,9 +59,9 @@ describe('getCardinalMove', () => {
65
59
  })
66
60
 
67
61
  describe('getExtentFromGeoJSON', () => {
68
- it('returns a 4-element extent from a GeoJSON point', () => {
69
- const point = { type: 'Feature', geometry: { type: 'Point', coordinates: [0, 51] } }
70
- expect(getExtentFromGeoJSON(point)).toEqual([0, 51, 0, 51])
62
+ it('returns a 4-element extent from a GeoJSON point, unchanged (no reprojection)', () => {
63
+ const point = { type: 'Feature', geometry: { type: 'Point', coordinates: [432500, 250000] } }
64
+ expect(getExtentFromGeoJSON(point)).toEqual([432500, 250000, 432500, 250000])
71
65
  })
72
66
  })
73
67
 
@@ -139,6 +139,10 @@ export async function createVectorTileLayer (url, transformRequest, { renderMode
139
139
  tileGrid
140
140
  })
141
141
  const layer = new VectorTileLayer({ source, declutter: true, ...(renderMode && { renderMode }) })
142
+ // Tagged rather than left to `instanceof VectorTileLayer` — a UMD consumer loads this
143
+ // provider and other plugins as independently-bundled scripts, each with its own copy
144
+ // of ol, so a class reference from one bundle never matches an instance from another.
145
+ layer.set('layerType', 'vectorTile')
142
146
 
143
147
  stylefunction(layer, styleJson, sourceId, resolutions, spritesJson, sprite.pngUrl)
144
148
 
@@ -171,6 +175,7 @@ export async function createOGCVectorTileLayer (url, transformRequest, { renderM
171
175
  const tileGrid = new TileGrid({ resolutions, origin, tileSize })
172
176
  const source = new OGCVectorTile({ url: tilesUrl, format, tileGrid, projection: CRS })
173
177
  const layer = new VectorTileLayer({ source, declutter: true, ...(renderMode && { renderMode }) })
178
+ layer.set('layerType', 'vectorTile')
174
179
 
175
180
  stylefunction(layer, styleJson, sourceId, resolutions, spritesJson, sprite.pngUrl)
176
181
 
@@ -14,7 +14,7 @@ const mockWMSSourceInstance = {}
14
14
  const mockTileLayerInstance = {}
15
15
  const mockVectorTileSourceInstance = {}
16
16
  const mockOGCVectorTileSourceInstance = { supportedMediaTypes: [] }
17
- const mockVectorTileLayerInstance = {}
17
+ const mockVectorTileLayerInstance = { set: jest.fn() }
18
18
  const mockMVTInstance = { supportedMediaTypes: [] }
19
19
 
20
20
  jest.mock('ol/source/XYZ.js', () => ({ __esModule: true, default: jest.fn(() => mockSourceInstance) }))
@@ -12,8 +12,10 @@ export const Attributions = () => {
12
12
  return null
13
13
  }
14
14
 
15
+ const isMobile = breakpoint === 'mobile'
16
+
15
17
  return (
16
- breakpoint !== 'mobile' && (
18
+ (!isMobile || mapStyle.showAttributionOnMobile) && (
17
19
  <div className='im-c-attributions' dangerouslySetInnerHTML={{ __html: mapStyle.attribution }} />
18
20
  )
19
21
  )
@@ -5,9 +5,13 @@
5
5
  background-color: var(--attributions-background-color);
6
6
  padding: var(--attributions-padding);
7
7
  font-size: var(--attributions-font-size);
8
- white-space: nowrap;
9
- overflow: hidden;
10
- text-overflow: ellipsis;
8
+ // Wraps rather than truncating so the text stays fully readable, capped to whatever
9
+ // width .im-o-app__attributions (layout.module.scss) currently gives it — the space
10
+ // beside the logo when docked, or the full row (--attributions--stacked) when it doesn't fit there.
11
+ max-width: 100%;
12
+ white-space: normal;
13
+ overflow-wrap: break-word;
14
+ text-align: right;
11
15
  pointer-events: auto;
12
16
  }
13
17
 
@@ -42,4 +42,12 @@ describe('Attributions', () => {
42
42
  const { container } = render(<Attributions />)
43
43
  expect(container.firstChild).toBeNull()
44
44
  })
45
+
46
+ it('renders on mobile when the style opts in via showAttributionOnMobile', () => {
47
+ useApp.mockReturnValue({ breakpoint: 'mobile' })
48
+ useMap.mockReturnValue({ mapStyle: { attribution: '<span>© Test</span>', showAttributionOnMobile: true } })
49
+
50
+ render(<Attributions />)
51
+ expect(screen.getByText('© Test')).toBeInTheDocument()
52
+ })
45
53
  })
@@ -6,6 +6,7 @@ import { getSafeZoneInset } from '../../utils/getSafeZoneInset.js'
6
6
 
7
7
  const BANNER_DOCKED_CLASS = 'im-o-app__banner--docked'
8
8
  const BANNER_PANEL_SELECTOR = '.im-c-panel--banner'
9
+ const ATTRIBUTIONS_STACKED_CLASS = 'im-o-app__attributions--stacked'
9
10
 
10
11
  const buttonHeight = (ref) => ref?.current?.offsetHeight ?? 0
11
12
  const buttonWidth = (ref) => ref?.current?.offsetWidth ?? 0
@@ -19,12 +20,16 @@ const subSlotMaxHeight = (columnHeight, siblingButtons, gap) => columnHeight - (
19
20
  const rightOffsetBottom = (containerPad, bottomRightHeight, attributionsHeight, gap) =>
20
21
  containerPad + (bottomRightHeight > 0 ? bottomRightHeight + gap : attributionsHeight)
21
22
 
22
- // Mobile's actions bar sits in flow (already reflected in baseBottom); tablet/desktop's floats, so this clears it too.
23
+ // Clears the bottom row's own TOP edge (not just its trailing gap below), so the hint
24
+ // never overlaps the logo/attribution row itself, plus a gap above it. That trivially
25
+ // also clears anything after the row in flow — mobile's in-flow actions bar included.
26
+ // Tablet/desktop's floating actions bar can sit independently higher than the row, so
27
+ // still needs its own explicit clearance, hence the Math.max with actionsOffset below.
23
28
  const hintBottom = (main, bottom, actionsEl, gap) => {
24
- const baseBottom = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
29
+ const clearsBottomRow = main.offsetHeight - bottom.offsetTop + gap
25
30
  const actionsHeight = actionsEl?.offsetHeight ?? 0
26
31
  const actionsOffset = actionsHeight > 0 ? main.offsetHeight - actionsEl.offsetTop : 0
27
- return Math.max(baseBottom, actionsOffset + gap)
32
+ return Math.max(clearsBottomRow, actionsOffset + gap)
28
33
  }
29
34
 
30
35
  // Space between .im-o-app__left/.im-o-app__right for the banner to dock in.
@@ -49,6 +54,81 @@ const clearBannerPanelWidths = (bannerEl) => {
49
54
  const bannerInset = (isDocked, primaryGap, sideColWidth, gap) =>
50
55
  isDocked ? primaryGap + sideColWidth + gap : primaryGap
51
56
 
57
+ // Natural (unwrapped) width of the attribution text, measured by forcing nowrap just long
58
+ // enough to read scrollWidth, then restoring — same "mutate, measure, restore" approach as
59
+ // clearBannerPanelWidths/bannerConfiguredWidth above. Needed because once wrapping is
60
+ // allowed, scrollWidth alone can't tell us how wide the text *would* be on one line.
61
+ const attributionsNaturalWidth = (attributionsEl) => {
62
+ const textEl = attributionsEl?.firstElementChild
63
+ if (!textEl) {
64
+ return 0
65
+ }
66
+ const previousWhiteSpace = textEl.style.whiteSpace
67
+ textEl.style.whiteSpace = 'nowrap'
68
+ const width = textEl.scrollWidth
69
+ textEl.style.whiteSpace = previousWhiteSpace
70
+ return width
71
+ }
72
+
73
+ // Stacks (drops to its own full-width row below the logo) once the attribution text no
74
+ // longer fits, at its natural width, in the space beside the logo column.
75
+ const isAttributionsStacked = (naturalWidth, availableWidth) => naturalWidth > availableWidth
76
+
77
+ // Docked, attributions bleeds primaryGap below the row's bottom edge then grows upward from
78
+ // there by its own height — once that height exceeds primaryGap, it creeps back up into the
79
+ // row itself, where .im-o-app__bottom-right shares the same horizontal space. This is how far
80
+ // that needs pushing up to stay clear. Stacked already has its own clearance (the stacked
81
+ // rule's margin-top), so none is needed there.
82
+ const attributionsBottomRightClearance = (isStacked, attributionsHeight, dividerGap, primaryGap) =>
83
+ isStacked ? 0 : Math.max(0, attributionsHeight + dividerGap - primaryGap)
84
+
85
+ // Docks centred between the side columns when there's room, otherwise stacks full-width
86
+ // (mobile always stacks). Sets the banner's own CSS vars and returns what the side-column
87
+ // offset calc below needs.
88
+ function applyBannerLayout ({ appContainer, root, top, bannerRef, leftRef, rightRef, isMobile, dividerGap, primaryGap }) {
89
+ const banner = bannerRef?.current
90
+ if (isMobile) {
91
+ clearBannerPanelWidths(banner)
92
+ }
93
+ const bannerHeight = buttonHeight(bannerRef)
94
+ const hasBanner = bannerHeight > 0
95
+ const bannerSideColWidth = symmetricWidth(buttonWidth(leftRef), buttonWidth(rightRef))
96
+ const defaultPreferredWidth = Number.parseInt(getComputedStyle(root).getPropertyValue('--banner-preferred-width'), 10)
97
+ const preferredWidth = bannerConfiguredWidth(banner) ?? defaultPreferredWidth
98
+ appContainer.style.setProperty('--banner-preferred-width', `${preferredWidth}px`)
99
+ const gutterWidth = bannerGutterWidth(top.offsetWidth, bannerSideColWidth, dividerGap)
100
+ const isDocked = !isMobile && isBannerDocked(gutterWidth, preferredWidth)
101
+ banner?.classList.toggle(BANNER_DOCKED_CLASS, isDocked)
102
+
103
+ const bannerSideInset = `${bannerInset(isDocked, primaryGap, bannerSideColWidth, dividerGap)}px`
104
+ appContainer.style.setProperty('--banner-left', bannerSideInset)
105
+ appContainer.style.setProperty('--banner-right', bannerSideInset)
106
+
107
+ // Sits at the top row's bottom edge; top.offsetHeight already includes a trailing gap.
108
+ const isBannerStacked = hasBanner && !isDocked
109
+ const bannerTop = hasBanner ? top.offsetTop + top.offsetHeight : 0
110
+ appContainer.style.setProperty('--banner-top', `${bannerTop}px`)
111
+
112
+ return { isBannerStacked, bannerTop, bannerHeight }
113
+ }
114
+
115
+ // Docks attributions beside the logo when its natural width fits there, otherwise stacks it
116
+ // onto its own full-width row below (im-o-app__attributions--stacked, in layout.module.scss),
117
+ // genuinely growing .im-o-app__bottom and pushing the logo and bottom-right buttons up. Must
118
+ // run before the offsets below, since toggling the stacked class changes
119
+ // bottom.offsetTop/offsetHeight that they read.
120
+ function applyAttributionsLayout ({ appContainer, bottom, attributions, dividerGap, primaryGap }) {
121
+ const attributionsCol = bottom.children[1] // the bottom-right column, a DOM sibling of attributions
122
+ const isStacked = isAttributionsStacked(attributionsNaturalWidth(attributions), attributionsCol?.offsetWidth ?? 0)
123
+ attributions.classList.toggle(ATTRIBUTIONS_STACKED_CLASS, isStacked)
124
+ // getBoundingClientRect, not offset math, because .im-o-app__bottom's `justify-content:
125
+ // space-between` gap between the two columns isn't a fixed value (unlike `gap`) to reconstruct.
126
+ const left = attributionsCol ? Math.round(attributionsCol.getBoundingClientRect().left - bottom.getBoundingClientRect().left) : 0
127
+ appContainer.style.setProperty('--attributions-left', `${left}px`)
128
+ const clearance = attributionsBottomRightClearance(isStacked, attributions.offsetHeight, dividerGap, primaryGap)
129
+ appContainer.style.setProperty('--bottom-right-clearance', `${clearance}px`)
130
+ }
131
+
52
132
  /**
53
133
  * Computes layout CSS vars for the map overlay and dispatches the safe zone inset used
54
134
  * for `fitBounds`/`setView`. Waits for `arePluginsEvaluated` so the inset reflects final
@@ -68,7 +148,6 @@ function calculateLayout (layoutRefs, breakpoint) {
68
148
  const topRightCol = topRightColRef.current
69
149
  const bottom = bottomRef.current
70
150
  const attributions = attributionsRef.current
71
- const banner = bannerRef?.current
72
151
 
73
152
  if ([main, top, bottom].some(r => !r)) {
74
153
  return
@@ -82,30 +161,12 @@ function calculateLayout (layoutRefs, breakpoint) {
82
161
  const topColWidthPx = symmetricWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)
83
162
  appContainer.style.setProperty('--top-col-width', `${topColWidthPx}px`)
84
163
 
85
- // === Banner: docks centred between the side columns when there's room, otherwise
86
- // stacks full-width. Mobile always stacks. ===
164
+ // Banner: docks centred between the side columns when there's room, otherwise stacks
165
+ // full-width. Mobile always stacks.
87
166
  const isMobile = breakpoint === 'mobile'
88
- if (isMobile) {
89
- clearBannerPanelWidths(banner)
90
- }
91
- const bannerHeight = buttonHeight(bannerRef)
92
- const hasBanner = bannerHeight > 0
93
- const bannerSideColWidth = symmetricWidth(buttonWidth(leftRef), buttonWidth(rightRef))
94
- const defaultPreferredWidth = Number.parseInt(getComputedStyle(root).getPropertyValue('--banner-preferred-width'), 10)
95
- const preferredWidth = bannerConfiguredWidth(banner) ?? defaultPreferredWidth
96
- appContainer.style.setProperty('--banner-preferred-width', `${preferredWidth}px`)
97
- const gutterWidth = bannerGutterWidth(top.offsetWidth, bannerSideColWidth, dividerGap)
98
- const isDocked = !isMobile && isBannerDocked(gutterWidth, preferredWidth)
99
- banner?.classList.toggle(BANNER_DOCKED_CLASS, isDocked)
100
-
101
- const bannerSideInset = `${bannerInset(isDocked, primaryGap, bannerSideColWidth, dividerGap)}px`
102
- appContainer.style.setProperty('--banner-left', bannerSideInset)
103
- appContainer.style.setProperty('--banner-right', bannerSideInset)
104
-
105
- // Sits at the top row's bottom edge; top.offsetHeight already includes a trailing gap.
106
- const isBannerStacked = hasBanner && !isDocked
107
- const bannerTop = hasBanner ? top.offsetTop + top.offsetHeight : 0
108
- appContainer.style.setProperty('--banner-top', `${bannerTop}px`)
167
+ const { isBannerStacked, bannerTop, bannerHeight } = applyBannerLayout({
168
+ appContainer, root, top, bannerRef, leftRef, rightRef, isMobile, dividerGap, primaryGap
169
+ })
109
170
 
110
171
  // Stacked pushes the side columns below the banner plus a trailing gap — added here, not
111
172
  // via a CSS last-child margin, since closed consumer HTML panels stay in the DOM (display:none).
@@ -113,6 +174,8 @@ function calculateLayout (layoutRefs, breakpoint) {
113
174
  ? bannerTop + bannerHeight + dividerGap
114
175
  : colHeight + top.offsetTop
115
176
 
177
+ applyAttributionsLayout({ appContainer, bottom, attributions, dividerGap, primaryGap })
178
+
116
179
  // === Left container offsets ===
117
180
  const leftOffsetTop = sideOffsetTop(topLeftCol.offsetHeight)
118
181
  const leftColumnHeight = bottom.offsetTop - leftOffsetTop - dividerGap
@@ -144,7 +207,7 @@ export function useLayoutMeasurements () {
144
207
  const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp()
145
208
  const { mapSize, isMapReady } = useMap()
146
209
 
147
- const { bannerRef, mainRef, headerRef, topRef, topLeftColRef, topRightColRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef, actionsRef, leftRef, rightRef } = layoutRefs
210
+ const { bannerRef, mainRef, headerRef, topRef, topLeftColRef, topRightColRef, bottomRef, bottomRightRef, attributionsRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef, actionsRef, leftRef, rightRef } = layoutRefs
148
211
 
149
212
  // 1. Clear the evaluated flag on structural changes, gating the safe zone until re-evaluated.
150
213
  useLayoutEffect(() => {
@@ -168,7 +231,9 @@ export function useLayoutMeasurements () {
168
231
  // 3. Recalculate CSS vars on resize; safe zone dispatch stays Effect 2's job.
169
232
  // Memoized so useResizeObserver doesn't re-run (and cancel its RAF) on every render.
170
233
  const observedRefs = useMemo(
171
- () => [bannerRef, mainRef, headerRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef, leftRef, rightRef],
234
+ // attributionsRef included so its height (now variable, since attribution text can wrap
235
+ // onto multiple lines) recalculates --right-offset-bottom when the wrap changes.
236
+ () => [bannerRef, mainRef, headerRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, attributionsRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef, leftRef, rightRef],
172
237
  []
173
238
  )
174
239