@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.
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/docs/api/map-style-config.md +7 -0
- package/docs/assets/images/select-feature.jpg +0 -0
- package/govuk-prototype-kit.config.json +1 -0
- package/package.json +1 -1
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-es/src/api/editFeature.js +12 -5
- package/plugins/beta/draw-es/src/api/newPolygon.js +7 -2
- package/plugins/beta/draw-es/src/api/removeSafeZone.js +30 -0
- package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +5 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapEngine.js +10 -5
- package/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js +1 -0
- package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/src/openlayersProvider.js +1 -1
- package/providers/beta/openlayers/src/openlayersProvider.test.js +22 -0
- package/providers/beta/openlayers/src/utils/highlightFeatures.js +23 -11
- package/providers/beta/openlayers/src/utils/highlightFeatures.test.js +2 -0
- package/providers/beta/openlayers/src/utils/hoverCursor.js +6 -5
- package/providers/beta/openlayers/src/utils/hoverCursor.test.js +2 -1
- package/providers/beta/openlayers/src/utils/queryFeatures.js +9 -6
- package/providers/beta/openlayers/src/utils/queryFeatures.test.js +6 -3
- package/providers/beta/openlayers/src/utils/spatial.js +6 -15
- package/providers/beta/openlayers/src/utils/spatial.test.js +3 -9
- package/providers/beta/openlayers/src/utils/tileLayers.js +5 -0
- package/providers/beta/openlayers/src/utils/tileLayers.test.js +1 -1
- package/src/App/components/Attributions/Attributions.jsx +3 -1
- package/src/App/components/Attributions/Attributions.module.scss +7 -3
- package/src/App/components/Attributions/Attributions.test.jsx +8 -0
- package/src/App/hooks/useLayoutMeasurements.js +94 -29
- package/src/App/hooks/useLayoutMeasurements.test.js +55 -10
- package/src/App/layout/Layout.jsx +7 -3
- package/src/App/layout/layout.module.scss +30 -1
- 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:
|
|
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 (
|
|
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
|
-
|
|
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 ||
|
|
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 (
|
|
184
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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, {}]]) //
|
|
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
|
|
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
|
|
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 [
|
|
109
|
+
const [xmin, ymin, xmax, ymax] = getExtentFromGeoJSON(geojson)
|
|
118
110
|
|
|
119
|
-
const corners = [[
|
|
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: [
|
|
70
|
-
expect(getExtentFromGeoJSON(point)).toEqual([
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
//
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
86
|
-
//
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|