@defra/interactive-map 0.0.23-alpha → 0.0.25-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/images/light-raster-thumb.jpg +0 -0
- package/assets/images/outdoor-raster-thumb.jpg +0 -0
- package/assets/images/road-raster-thumb.jpg +0 -0
- package/dist/css/index.css +1 -1
- package/docs/api/map-style-config.md +39 -2
- package/docs/plugins/search.md +70 -0
- package/package.json +17 -2
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/src/api/editFeature.js +9 -4
- package/plugins/beta/draw-ml/src/modes/createDrawMode.js +11 -3
- package/plugins/beta/scale-bar/dist/css/index.css +31 -1
- package/plugins/interact/src/hooks/useAttachEvents.test.js +3 -0
- package/plugins/search/dist/css/index.css +250 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/esm/index.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/index.js +1 -1
- package/plugins/search/src/Search.jsx +2 -1
- package/plugins/search/src/components/Form/Form.jsx +11 -7
- package/plugins/search/src/components/Form/Form.test.jsx +23 -1
- package/plugins/search/src/datasets.js +2 -2
- package/plugins/search/src/datasets.test.js +1 -1
- package/plugins/search/src/defaults.js +7 -1
- package/plugins/search/src/events/fetchSuggestions.js +13 -10
- package/plugins/search/src/events/fetchSuggestions.test.js +14 -1
- package/plugins/search/src/events/formHandlers.js +2 -4
- package/plugins/search/src/events/formHandlers.test.js +3 -3
- package/plugins/search/src/events/inputHandlers.js +1 -1
- package/plugins/search/src/events/inputHandlers.test.js +1 -1
- package/plugins/search/src/events/suggestionHandlers.js +2 -1
- package/plugins/search/src/events/suggestionHandlers.test.js +7 -1
- package/plugins/search/src/index.js +3 -7
- package/plugins/search/src/reducer.js +10 -5
- package/plugins/search/src/reducer.test.js +2 -1
- package/plugins/search/src/utils/parseOsNamesResults.js +3 -3
- package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -0
- package/providers/beta/openlayers/dist/esm/index.js +1 -0
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +2 -0
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js.LICENSE.txt +1 -0
- package/providers/beta/openlayers/dist/umd/index.js +2 -0
- package/providers/beta/openlayers/dist/umd/index.js.LICENSE.txt +1 -0
- package/providers/beta/openlayers/src/appEvents.js +39 -0
- package/providers/beta/openlayers/src/appEvents.test.js +117 -0
- package/providers/beta/openlayers/src/defaults.js +36 -0
- package/providers/beta/openlayers/src/index.js +21 -0
- package/providers/beta/openlayers/src/mapEvents.js +132 -0
- package/providers/beta/openlayers/src/mapEvents.test.js +198 -0
- package/providers/beta/openlayers/src/openlayersProvider.js +249 -0
- package/providers/beta/openlayers/src/openlayersProvider.test.js +386 -0
- package/providers/beta/openlayers/src/utils/spatial.js +146 -0
- package/providers/beta/openlayers/src/utils/spatial.test.js +119 -0
- package/providers/beta/openlayers/src/utils/tileLayers.js +133 -0
- package/providers/beta/openlayers/src/utils/tileLayers.test.js +251 -0
- package/providers/beta/openlayers/src/utils/zoom.js +26 -0
- package/providers/beta/openlayers/src/utils/zoom.test.js +23 -0
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/src/utils/highlightFeatures.js +17 -7
- package/providers/maplibre/src/utils/highlightFeatures.test.js +17 -0
- package/providers/maplibre/src/utils/maplibreFixes.js +1 -1
- package/providers/maplibre/src/utils/patternImages.js +14 -9
- package/providers/maplibre/src/utils/patternImages.test.js +2 -1
- package/rollup.esm.mjs +14 -3
- package/src/App/hooks/useFeatureItems.test.js +1 -0
- package/src/App/hooks/useLayoutMeasurements.test.js +0 -1
- package/src/App/hooks/useMarkersAPI.test.js +1 -0
- package/src/App/hooks/useModalPanelBehaviour.test.js +2 -1
- package/src/App/layout/layout.module.scss +1 -0
- package/src/App/store/AppProvider.test.jsx +5 -0
- package/src/App/store/MapProvider.test.jsx +8 -1
- package/src/services/announcer.test.js +5 -7
- package/src/types.js +11 -1
- package/webpack.dev.mjs +14 -5
- package/webpack.umd.mjs +1 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import turfBbox from '@turf/bbox'
|
|
2
|
+
import { transformExtent } from 'ol/proj.js'
|
|
3
|
+
|
|
4
|
+
// In EPSG:27700 coordinates are projected meters — distances are Pythagorean, no geodesy needed
|
|
5
|
+
|
|
6
|
+
const formatDimension = (meters) => {
|
|
7
|
+
const WHOLE_MILE_THRESHOLD = 10
|
|
8
|
+
const MILE_THRESHOLD = 0.5
|
|
9
|
+
const METERS_PER_MILE = 1609.344
|
|
10
|
+
|
|
11
|
+
const miles = meters / METERS_PER_MILE
|
|
12
|
+
|
|
13
|
+
if (miles < MILE_THRESHOLD) {
|
|
14
|
+
return `${Math.round(meters)}m`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (miles < WHOLE_MILE_THRESHOLD) {
|
|
18
|
+
const value = Number.parseFloat(miles.toFixed(1))
|
|
19
|
+
const units = value === 1 ? 'mile' : 'miles'
|
|
20
|
+
return `${value} ${units}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const rounded = Math.round(miles)
|
|
24
|
+
return `${rounded} miles`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns "400m by 1.4 miles" for the visible (padded) map area.
|
|
29
|
+
* extent: [xmin, ymin, xmax, ymax] in EPSG:27700 meters
|
|
30
|
+
*/
|
|
31
|
+
const getAreaDimensions = (extent) => {
|
|
32
|
+
if (!extent) {
|
|
33
|
+
return ''
|
|
34
|
+
}
|
|
35
|
+
const [xmin, ymin, xmax, ymax] = extent
|
|
36
|
+
const widthMeters = xmax - xmin
|
|
37
|
+
const heightMeters = ymax - ymin
|
|
38
|
+
return `${formatDimension(heightMeters)} by ${formatDimension(widthMeters)}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns "north 400m, east 750m" for moves between two EPSG:27700 [easting, northing] coords.
|
|
43
|
+
*/
|
|
44
|
+
const getCardinalMove = (from, to) => {
|
|
45
|
+
const THRESHOLD_METERS = 1
|
|
46
|
+
|
|
47
|
+
const dEasting = to[0] - from[0]
|
|
48
|
+
const dNorthing = to[1] - from[1]
|
|
49
|
+
|
|
50
|
+
const moves = []
|
|
51
|
+
|
|
52
|
+
if (Math.abs(dNorthing) > THRESHOLD_METERS) {
|
|
53
|
+
moves.push(`${dNorthing > 0 ? 'north' : 'south'} ${formatDimension(Math.abs(dNorthing))}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (Math.abs(dEasting) > THRESHOLD_METERS) {
|
|
57
|
+
moves.push(`${dEasting > 0 ? 'east' : 'west'} ${formatDimension(Math.abs(dEasting))}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return moves.join(', ')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get a flat bbox [west, south, east, north] in WGS84 from any GeoJSON object.
|
|
65
|
+
*/
|
|
66
|
+
const getBboxFromGeoJSON = (geojson) => turfBbox(geojson)
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get a flat extent [xmin, ymin, xmax, ymax] in EPSG:27700 from any GeoJSON object.
|
|
70
|
+
* GeoJSON is always WGS84, so this transforms the bbox.
|
|
71
|
+
*/
|
|
72
|
+
const getExtentFromGeoJSON = (geojson) => {
|
|
73
|
+
const wgs84Bbox = turfBbox(geojson)
|
|
74
|
+
return transformExtent(wgs84Bbox, 'EPSG:4326', 'EPSG:27700')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Returns the visible (padded) extent [xmin, ymin, xmax, ymax] in EPSG:27700.
|
|
79
|
+
* Accounts for view padding by converting padded pixel corners to map coordinates.
|
|
80
|
+
*/
|
|
81
|
+
const getPaddedExtent = (map) => {
|
|
82
|
+
const size = map.getSize()
|
|
83
|
+
const view = map.getView()
|
|
84
|
+
const padding = view.padding || [0, 0, 0, 0] // [top, right, bottom, left]
|
|
85
|
+
const [top, right, bottom, left] = padding
|
|
86
|
+
|
|
87
|
+
const swPixel = [left, size[1] - bottom]
|
|
88
|
+
const nePixel = [size[0] - right, top]
|
|
89
|
+
|
|
90
|
+
const sw = map.getCoordinateFromPixel(swPixel)
|
|
91
|
+
const ne = map.getCoordinateFromPixel(nePixel)
|
|
92
|
+
|
|
93
|
+
if (!sw || !ne) {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return [sw[0], sw[1], ne[0], ne[1]]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Returns true if the geometry's screen bounding box overlaps the given panel rectangle.
|
|
102
|
+
* getPixelFromCoordinate returns layout-space pixels (unscaled canvas), so we convert them
|
|
103
|
+
* to screen space using the ratio between the viewport's visual size and the canvas size.
|
|
104
|
+
* This accounts for the CSS scale transform applied on medium/large mapSize.
|
|
105
|
+
*/
|
|
106
|
+
const isGeometryObscured = (geojson, panelRect, map) => {
|
|
107
|
+
const containerRect = map.getTargetElement().getBoundingClientRect()
|
|
108
|
+
if (!containerRect.width || !containerRect.height) {
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
const viewportRect = map.getViewport().getBoundingClientRect()
|
|
112
|
+
const scaleX = viewportRect.width / containerRect.width
|
|
113
|
+
const scaleY = viewportRect.height / containerRect.height
|
|
114
|
+
|
|
115
|
+
const [west, south, east, north] = getBboxFromGeoJSON(geojson)
|
|
116
|
+
|
|
117
|
+
const corners = [[west, south], [west, north], [east, south], [east, north]].map(coord => {
|
|
118
|
+
return map.getPixelFromCoordinate(coord)
|
|
119
|
+
}).filter(Boolean)
|
|
120
|
+
|
|
121
|
+
if (!corners.length) {
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const screenMinX = containerRect.left + Math.min(...corners.map(c => c[0])) * scaleX
|
|
126
|
+
const screenMaxX = containerRect.left + Math.max(...corners.map(c => c[0])) * scaleX
|
|
127
|
+
const screenMinY = containerRect.top + Math.min(...corners.map(c => c[1])) * scaleY
|
|
128
|
+
const screenMaxY = containerRect.top + Math.max(...corners.map(c => c[1])) * scaleY
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
screenMinX < panelRect.right &&
|
|
132
|
+
screenMaxX > panelRect.left &&
|
|
133
|
+
screenMinY < panelRect.bottom &&
|
|
134
|
+
screenMaxY > panelRect.top
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export {
|
|
139
|
+
getAreaDimensions,
|
|
140
|
+
getCardinalMove,
|
|
141
|
+
getBboxFromGeoJSON,
|
|
142
|
+
getExtentFromGeoJSON,
|
|
143
|
+
getPaddedExtent,
|
|
144
|
+
isGeometryObscured,
|
|
145
|
+
formatDimension
|
|
146
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatDimension,
|
|
3
|
+
getAreaDimensions,
|
|
4
|
+
getCardinalMove,
|
|
5
|
+
getExtentFromGeoJSON,
|
|
6
|
+
getPaddedExtent,
|
|
7
|
+
isGeometryObscured
|
|
8
|
+
} from './spatial.js'
|
|
9
|
+
|
|
10
|
+
jest.mock('ol/proj.js', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
transform: (coord) => coord,
|
|
13
|
+
transformExtent: (extent) => extent
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
describe('formatDimension', () => {
|
|
17
|
+
it('formats sub-mile distances in meters', () => {
|
|
18
|
+
expect(formatDimension(400)).toBe('400m')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('formats exactly 1 mile', () => {
|
|
22
|
+
expect(formatDimension(1609.344)).toBe('1 mile')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('formats decimal miles with plural', () => {
|
|
26
|
+
expect(formatDimension(2414.016)).toBe('1.5 miles')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('formats 10+ miles as whole number', () => {
|
|
30
|
+
expect(formatDimension(20000)).toBe('12 miles')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('getAreaDimensions', () => {
|
|
35
|
+
it('returns empty string for null extent', () => {
|
|
36
|
+
expect(getAreaDimensions(null)).toBe('')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns height by width as formatted strings', () => {
|
|
40
|
+
// 1609m height (~1 mile), 804m width (sub-mile)
|
|
41
|
+
expect(getAreaDimensions([0, 0, 804, 1609])).toBe('1 mile by 804m')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('getCardinalMove', () => {
|
|
46
|
+
it('returns empty string for sub-threshold move', () => {
|
|
47
|
+
expect(getCardinalMove([0, 0], [0.5, 0.5])).toBe('')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('describes north and east movement', () => {
|
|
51
|
+
expect(getCardinalMove([0, 0], [100, 100])).toBe('north 100m, east 100m')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('describes south and west movement', () => {
|
|
55
|
+
expect(getCardinalMove([100, 100], [0, 0])).toBe('south 100m, west 100m')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('describes single-axis movement', () => {
|
|
59
|
+
expect(getCardinalMove([0, 0], [0, 100])).toBe('north 100m')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('getExtentFromGeoJSON', () => {
|
|
64
|
+
it('returns a 4-element extent from a GeoJSON point', () => {
|
|
65
|
+
const point = { type: 'Feature', geometry: { type: 'Point', coordinates: [0, 51] } }
|
|
66
|
+
expect(getExtentFromGeoJSON(point)).toEqual([0, 51, 0, 51])
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('getPaddedExtent', () => {
|
|
71
|
+
const makeMap = (padding) => ({
|
|
72
|
+
getSize: () => [800, 600],
|
|
73
|
+
getView: () => ({ padding }),
|
|
74
|
+
getCoordinateFromPixel: (pixel) => pixel
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('applies padding to sw and ne corner pixels', () => {
|
|
78
|
+
// padding [top=10, right=20, bottom=30, left=40]
|
|
79
|
+
// sw pixel: [left=40, height-bottom=570] → ne pixel: [width-right=780, top=10]
|
|
80
|
+
expect(getPaddedExtent(makeMap([10, 20, 30, 40]))).toEqual([40, 570, 780, 10])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('uses zero padding when view.padding is null', () => {
|
|
84
|
+
expect(getPaddedExtent(makeMap(null))).toEqual([0, 600, 800, 0])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns null when coordinate projection fails', () => {
|
|
88
|
+
const map = {
|
|
89
|
+
getSize: () => [800, 600],
|
|
90
|
+
getView: () => ({ padding: null }),
|
|
91
|
+
getCoordinateFromPixel: () => null
|
|
92
|
+
}
|
|
93
|
+
expect(getPaddedExtent(map)).toBeNull()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('isGeometryObscured', () => {
|
|
98
|
+
const point = { type: 'Feature', geometry: { type: 'Point', coordinates: [-1, 51] } }
|
|
99
|
+
const panel = { left: 0, top: 0, right: 100, bottom: 100 }
|
|
100
|
+
|
|
101
|
+
const containerRect = { left: 0, top: 0, width: 1000, height: 1000 }
|
|
102
|
+
const makeMap = (pixelFn, viewportRect = containerRect) => ({
|
|
103
|
+
getTargetElement: () => ({ getBoundingClientRect: () => containerRect }),
|
|
104
|
+
getViewport: () => ({ getBoundingClientRect: () => viewportRect }),
|
|
105
|
+
getPixelFromCoordinate: pixelFn
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('returns false when no corners project to screen pixels', () => {
|
|
109
|
+
expect(isGeometryObscured(point, panel, makeMap(() => null))).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('returns true when geometry overlaps panel', () => {
|
|
113
|
+
expect(isGeometryObscured(point, panel, makeMap(() => [50, 50]))).toBe(true)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('returns false when geometry is outside panel', () => {
|
|
117
|
+
expect(isGeometryObscured(point, panel, makeMap(() => [200, 200]))).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import XYZ from 'ol/source/XYZ.js'
|
|
2
|
+
import VectorTileSource from 'ol/source/VectorTile.js'
|
|
3
|
+
import VectorTileLayer from 'ol/layer/VectorTile.js'
|
|
4
|
+
import OGCVectorTile from 'ol/source/OGCVectorTile.js'
|
|
5
|
+
import MVT from 'ol/format/MVT.js'
|
|
6
|
+
import TileGrid from 'ol/tilegrid/TileGrid.js'
|
|
7
|
+
import TileState from 'ol/TileState.js'
|
|
8
|
+
import { stylefunction } from 'ol-mapbox-style'
|
|
9
|
+
import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.js'
|
|
10
|
+
|
|
11
|
+
const CRS = 'EPSG:27700'
|
|
12
|
+
|
|
13
|
+
export function fetchWithTransform (url, resourceType, transformRequest) {
|
|
14
|
+
const result = transformRequest ? (transformRequest(url, resourceType) || {}) : {}
|
|
15
|
+
return fetch(result.url || url, { headers: result.headers || {} })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const createTileLoadFunction = (transformRequest) => (tile, src) => {
|
|
19
|
+
const result = transformRequest(src, 'Tile') || {}
|
|
20
|
+
const url = result.url || src
|
|
21
|
+
const headers = result.headers || {}
|
|
22
|
+
fetch(url, { headers })
|
|
23
|
+
.then(r => r.blob())
|
|
24
|
+
.then(blob => { tile.getImage().src = URL.createObjectURL(blob) })
|
|
25
|
+
.catch(() => tile.setState(TileState.ERROR))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createTileSource (url, transformRequest) {
|
|
29
|
+
const tileGrid = new TileGrid({
|
|
30
|
+
resolutions: TILE_GRID_RESOLUTIONS,
|
|
31
|
+
origin: TILE_GRID_ORIGIN,
|
|
32
|
+
tileSize: TILE_SIZE
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const tileUrlFunction = ([z, x, y]) => url
|
|
36
|
+
.replace('{z}', z)
|
|
37
|
+
.replace('{x}', x)
|
|
38
|
+
.replace('{y}', y)
|
|
39
|
+
|
|
40
|
+
return new XYZ({
|
|
41
|
+
projection: CRS,
|
|
42
|
+
tileGrid,
|
|
43
|
+
tileUrlFunction,
|
|
44
|
+
tileLoadFunction: transformRequest ? createTileLoadFunction(transformRequest) : undefined
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Insert extension before any query string to match Mapbox GL sprite convention
|
|
49
|
+
function resolveSprite (spriteBase, transformRequest) {
|
|
50
|
+
const queryIdx = spriteBase.indexOf('?')
|
|
51
|
+
const [spritePath, spriteQuery] = queryIdx >= 0 ? [spriteBase.slice(0, queryIdx), spriteBase.slice(queryIdx)] : [spriteBase, '']
|
|
52
|
+
const jsonUrl = `${spritePath}.json${spriteQuery}`
|
|
53
|
+
const pngUrl = `${spritePath}.png${spriteQuery}`
|
|
54
|
+
return { jsonUrl, pngUrl, fetch: () => fetchWithTransform(jsonUrl, 'SpriteJSON', transformRequest).then(r => r.json()) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The OS VTS styles are designed to work with the limited ArcGIS/ESRI SDK, which does not support
|
|
58
|
+
// icon-color for sprite tinting. OS works around this by setting icon-color alpha to 0 (a no-op
|
|
59
|
+
// in the ESRI renderer) so the sprite renders as-is. ol-mapbox-style interprets the colour
|
|
60
|
+
// literally though, making icons invisible. Fix by setting alpha to 1 (opaque tint).
|
|
61
|
+
function fixIconOpacity (styleJson) {
|
|
62
|
+
styleJson.layers.forEach(styleLayer => {
|
|
63
|
+
if (styleLayer.paint?.['icon-color']) {
|
|
64
|
+
styleLayer.paint['icon-color'] = styleLayer.paint['icon-color'].replace(',0)', ',1)')
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function createVectorTileLayer (url, transformRequest) {
|
|
70
|
+
const styleJson = await fetchWithTransform(url, 'Style', transformRequest).then(r => r.json())
|
|
71
|
+
|
|
72
|
+
const sourceId = Object.keys(styleJson.sources)[0]
|
|
73
|
+
const capabilitiesUrl = styleJson.sources[sourceId].url
|
|
74
|
+
const serviceJson = await fetchWithTransform(capabilitiesUrl, 'Source', transformRequest).then(r => r.json())
|
|
75
|
+
|
|
76
|
+
const extent = [serviceJson.fullExtent.xmin, serviceJson.fullExtent.ymin, serviceJson.fullExtent.xmax, serviceJson.fullExtent.ymax]
|
|
77
|
+
const origin = [serviceJson.tileInfo.origin.x, serviceJson.tileInfo.origin.y]
|
|
78
|
+
const resolutions = serviceJson.tileInfo.lods.map(l => l.resolution).slice(0, 16)
|
|
79
|
+
const tileSize = serviceJson.tileInfo.rows
|
|
80
|
+
const tileUrl = serviceJson.tiles[0]
|
|
81
|
+
|
|
82
|
+
const sprite = resolveSprite(styleJson.sprite, transformRequest)
|
|
83
|
+
const spritesJson = await sprite.fetch()
|
|
84
|
+
|
|
85
|
+
fixIconOpacity(styleJson)
|
|
86
|
+
|
|
87
|
+
const tileGrid = new TileGrid({ extent, origin, resolutions, tileSize })
|
|
88
|
+
|
|
89
|
+
// Tile URL from capabilities already includes the API key — no custom tileLoadFunction needed
|
|
90
|
+
const source = new VectorTileSource({
|
|
91
|
+
format: new MVT(),
|
|
92
|
+
url: tileUrl,
|
|
93
|
+
projection: CRS,
|
|
94
|
+
tileGrid
|
|
95
|
+
})
|
|
96
|
+
const layer = new VectorTileLayer({ source, declutter: true, renderMode: 'vector' })
|
|
97
|
+
|
|
98
|
+
stylefunction(layer, styleJson, sourceId, resolutions, spritesJson, sprite.pngUrl)
|
|
99
|
+
|
|
100
|
+
return { layer, source }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function createOGCVectorTileLayer (url, transformRequest) {
|
|
104
|
+
const styleJson = await fetchWithTransform(url, 'Style', transformRequest).then(r => r.json())
|
|
105
|
+
|
|
106
|
+
const sourceId = Object.keys(styleJson.sources)[0]
|
|
107
|
+
const tilesUrl = styleJson.sources[sourceId].url
|
|
108
|
+
|
|
109
|
+
// Fetch tileset descriptor to get the tile matrix set URL (includes API key), in parallel with sprites
|
|
110
|
+
const sprite = resolveSprite(styleJson.sprite, transformRequest)
|
|
111
|
+
const tilesetJson = await fetchWithTransform(tilesUrl, 'Source', transformRequest).then(r => r.json())
|
|
112
|
+
const tmsLink = tilesetJson.links?.find(l => l.rel === 'http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme')
|
|
113
|
+
const [tmsJson, spritesJson] = await Promise.all([
|
|
114
|
+
fetchWithTransform(tmsLink.href, 'Source', transformRequest).then(r => r.json()),
|
|
115
|
+
sprite.fetch()
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
const resolutions = tmsJson.tileMatrices.map(m => m.cellSize)
|
|
119
|
+
const origin = tmsJson.tileMatrices[0].pointOfOrigin
|
|
120
|
+
const tileSize = [tmsJson.tileMatrices[0].tileHeight, tmsJson.tileMatrices[0].tileWidth]
|
|
121
|
+
|
|
122
|
+
// OS NGD returns tiles as 'application/octet-stream' rather than the standard MVT media type
|
|
123
|
+
const format = new MVT()
|
|
124
|
+
format.supportedMediaTypes.push('application/octet-stream')
|
|
125
|
+
|
|
126
|
+
const tileGrid = new TileGrid({ resolutions, origin, tileSize })
|
|
127
|
+
const source = new OGCVectorTile({ url: tilesUrl, format, tileGrid, projection: CRS })
|
|
128
|
+
const layer = new VectorTileLayer({ source, declutter: true, renderMode: 'vector' })
|
|
129
|
+
|
|
130
|
+
stylefunction(layer, styleJson, sourceId, resolutions, spritesJson, sprite.pngUrl)
|
|
131
|
+
|
|
132
|
+
return { layer, source }
|
|
133
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import XYZ from 'ol/source/XYZ.js'
|
|
2
|
+
import TileGrid from 'ol/tilegrid/TileGrid.js'
|
|
3
|
+
import VectorTileSource from 'ol/source/VectorTile.js'
|
|
4
|
+
import VectorTileLayer from 'ol/layer/VectorTile.js'
|
|
5
|
+
import { stylefunction } from 'ol-mapbox-style'
|
|
6
|
+
import { createTileSource, createVectorTileLayer } from './tileLayers.js'
|
|
7
|
+
import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.js'
|
|
8
|
+
|
|
9
|
+
const mockTileGridInstance = {}
|
|
10
|
+
const mockSourceInstance = {}
|
|
11
|
+
const mockVectorTileSourceInstance = {}
|
|
12
|
+
const mockVectorTileLayerInstance = {}
|
|
13
|
+
const mockMVTInstance = {}
|
|
14
|
+
|
|
15
|
+
jest.mock('ol/source/XYZ.js', () => ({ __esModule: true, default: jest.fn(() => mockSourceInstance) }))
|
|
16
|
+
jest.mock('ol/tilegrid/TileGrid.js', () => ({ __esModule: true, default: jest.fn(() => mockTileGridInstance) }))
|
|
17
|
+
jest.mock('ol/TileState.js', () => ({ __esModule: true, default: { ERROR: 'error' } }))
|
|
18
|
+
jest.mock('ol/source/VectorTile.js', () => ({ __esModule: true, default: jest.fn(() => mockVectorTileSourceInstance) }))
|
|
19
|
+
jest.mock('ol/layer/VectorTile.js', () => ({ __esModule: true, default: jest.fn(() => mockVectorTileLayerInstance) }))
|
|
20
|
+
jest.mock('ol/format/MVT.js', () => ({ __esModule: true, default: jest.fn(() => mockMVTInstance) }))
|
|
21
|
+
jest.mock('ol-mapbox-style', () => ({ __esModule: true, stylefunction: jest.fn() }))
|
|
22
|
+
|
|
23
|
+
const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0))
|
|
24
|
+
|
|
25
|
+
const mockStyleJson = {
|
|
26
|
+
sources: { esri: { url: 'https://example.com/caps.json' } },
|
|
27
|
+
sprite: 'https://example.com/sprites/sprite',
|
|
28
|
+
layers: []
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const mockServiceJson = {
|
|
32
|
+
fullExtent: { xmin: -238375, ymin: 0, xmax: 700000, ymax: 1300000 },
|
|
33
|
+
tileInfo: {
|
|
34
|
+
origin: { x: -238375, y: 1376256 },
|
|
35
|
+
lods: Array.from({ length: 20 }, (_, i) => ({ resolution: 896 / Math.pow(2, i) })),
|
|
36
|
+
rows: 256,
|
|
37
|
+
spatialReference: { latestWkid: 27700 }
|
|
38
|
+
},
|
|
39
|
+
tiles: ['https://example.com/tiles/{z}/{x}/{y}.pbf']
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const mockSpritesJson = { myIcon: { x: 0, y: 0, width: 16, height: 16, pixelRatio: 1 } }
|
|
43
|
+
|
|
44
|
+
function makeVectorFetchMock (styleJson = mockStyleJson) {
|
|
45
|
+
return jest.fn().mockImplementation(url => {
|
|
46
|
+
if (url === styleJson.sources[Object.keys(styleJson.sources)[0]].url) {
|
|
47
|
+
return Promise.resolve({ json: () => Promise.resolve(mockServiceJson) })
|
|
48
|
+
}
|
|
49
|
+
if (url.endsWith('.json') || url.includes('.json?')) {
|
|
50
|
+
return Promise.resolve({ json: () => Promise.resolve(mockSpritesJson) })
|
|
51
|
+
}
|
|
52
|
+
return Promise.resolve({ json: () => Promise.resolve(styleJson) })
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('createTileSource', () => {
|
|
57
|
+
beforeEach(() => jest.clearAllMocks())
|
|
58
|
+
|
|
59
|
+
it('creates TileGrid with correct OS tile grid config', () => {
|
|
60
|
+
createTileSource('https://tiles.example.com/{z}/{x}/{y}', null)
|
|
61
|
+
expect(TileGrid).toHaveBeenCalledWith({
|
|
62
|
+
resolutions: TILE_GRID_RESOLUTIONS,
|
|
63
|
+
origin: TILE_GRID_ORIGIN,
|
|
64
|
+
tileSize: TILE_SIZE
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('creates XYZ source with EPSG:27700 projection', () => {
|
|
69
|
+
createTileSource('https://tiles.example.com/{z}/{x}/{y}', null)
|
|
70
|
+
expect(XYZ).toHaveBeenCalledWith(expect.objectContaining({ projection: 'EPSG:27700' }))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('tileUrlFunction substitutes z, x, y into url template', () => {
|
|
74
|
+
createTileSource('https://tiles.example.com/{z}/{x}/{y}', null)
|
|
75
|
+
const { tileUrlFunction } = XYZ.mock.calls[0][0]
|
|
76
|
+
expect(tileUrlFunction([7, 3, 5])).toBe('https://tiles.example.com/7/3/5')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('does not set tileLoadFunction when transformRequest is null', () => {
|
|
80
|
+
createTileSource('https://tiles.example.com/{z}/{x}/{y}', null)
|
|
81
|
+
const { tileLoadFunction } = XYZ.mock.calls[0][0]
|
|
82
|
+
expect(tileLoadFunction).toBeUndefined()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('sets tileLoadFunction when transformRequest is provided', () => {
|
|
86
|
+
createTileSource('https://tiles.example.com/{z}/{x}/{y}', jest.fn())
|
|
87
|
+
const { tileLoadFunction } = XYZ.mock.calls[0][0]
|
|
88
|
+
expect(typeof tileLoadFunction).toBe('function')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('tileLoadFunction (via transformRequest)', () => {
|
|
93
|
+
const url = 'https://tiles.example.com/7/3/5'
|
|
94
|
+
const mockImg = { src: null }
|
|
95
|
+
const mockTile = { getImage: () => mockImg, setState: jest.fn() }
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
jest.clearAllMocks()
|
|
99
|
+
mockImg.src = null
|
|
100
|
+
global.URL.createObjectURL = jest.fn(() => 'blob:test')
|
|
101
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
102
|
+
blob: () => Promise.resolve(new Blob())
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
function getTileLoadFn (transformRequest) {
|
|
107
|
+
createTileSource('https://tiles.example.com/{z}/{x}/{y}', transformRequest)
|
|
108
|
+
return XYZ.mock.calls[0][0].tileLoadFunction
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
it('calls transformRequest with src and "Tile" resource type', async () => {
|
|
112
|
+
const transformRequest = jest.fn(() => null)
|
|
113
|
+
const fn = getTileLoadFn(transformRequest)
|
|
114
|
+
fn(mockTile, url)
|
|
115
|
+
expect(transformRequest).toHaveBeenCalledWith(url, 'Tile')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('uses url and headers from transformRequest result', async () => {
|
|
119
|
+
const transformRequest = jest.fn(() => ({ url: 'https://proxied.example.com/tile', headers: { Authorization: 'Bearer abc' } }))
|
|
120
|
+
const fn = getTileLoadFn(transformRequest)
|
|
121
|
+
fn(mockTile, url)
|
|
122
|
+
await flushPromises()
|
|
123
|
+
expect(fetch).toHaveBeenCalledWith('https://proxied.example.com/tile', { headers: { Authorization: 'Bearer abc' } })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('falls back to original src and empty headers when transformRequest returns null', async () => {
|
|
127
|
+
const fn = getTileLoadFn(() => null)
|
|
128
|
+
fn(mockTile, url)
|
|
129
|
+
await flushPromises()
|
|
130
|
+
expect(fetch).toHaveBeenCalledWith(url, { headers: {} })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('sets tile image src via createObjectURL on success', async () => {
|
|
134
|
+
const fn = getTileLoadFn(() => null)
|
|
135
|
+
fn(mockTile, url)
|
|
136
|
+
await flushPromises()
|
|
137
|
+
expect(mockImg.src).toBe('blob:test')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('sets tile state to ERROR on fetch failure', async () => {
|
|
141
|
+
global.fetch = jest.fn().mockRejectedValue(new Error('network error'))
|
|
142
|
+
const fn = getTileLoadFn(() => null)
|
|
143
|
+
fn(mockTile, url)
|
|
144
|
+
await flushPromises()
|
|
145
|
+
expect(mockTile.setState).toHaveBeenCalledWith('error')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('createVectorTileLayer', () => {
|
|
150
|
+
const styleUrl = 'https://example.com/styles'
|
|
151
|
+
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
jest.clearAllMocks()
|
|
154
|
+
global.fetch = makeVectorFetchMock()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('fetches style JSON from url', async () => {
|
|
158
|
+
await createVectorTileLayer(styleUrl, null)
|
|
159
|
+
expect(fetch).toHaveBeenCalledWith(styleUrl, { headers: {} })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('fetches capabilities from first source url in style JSON', async () => {
|
|
163
|
+
await createVectorTileLayer(styleUrl, null)
|
|
164
|
+
expect(fetch).toHaveBeenCalledWith('https://example.com/caps.json', { headers: {} })
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('fetches sprite JSON from sprite base + .json', async () => {
|
|
168
|
+
await createVectorTileLayer(styleUrl, null)
|
|
169
|
+
expect(fetch).toHaveBeenCalledWith('https://example.com/sprites/sprite.json', { headers: {} })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('creates TileGrid from capabilities tileInfo', async () => {
|
|
173
|
+
await createVectorTileLayer(styleUrl, null)
|
|
174
|
+
expect(TileGrid).toHaveBeenCalledWith({
|
|
175
|
+
extent: [-238375, 0, 700000, 1300000],
|
|
176
|
+
origin: [-238375, 1376256],
|
|
177
|
+
resolutions: expect.any(Array),
|
|
178
|
+
tileSize: 256
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('slices capabilities lods to max 16 resolutions', async () => {
|
|
183
|
+
await createVectorTileLayer(styleUrl, null)
|
|
184
|
+
const { resolutions } = TileGrid.mock.calls[0][0]
|
|
185
|
+
expect(resolutions.length).toBe(16)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('creates VectorTileSource with MVT format and 27700 projection', async () => {
|
|
189
|
+
await createVectorTileLayer(styleUrl, null)
|
|
190
|
+
expect(VectorTileSource).toHaveBeenCalledWith(expect.objectContaining({
|
|
191
|
+
format: mockMVTInstance,
|
|
192
|
+
url: 'https://example.com/tiles/{z}/{x}/{y}.pbf',
|
|
193
|
+
projection: 'EPSG:27700',
|
|
194
|
+
tileGrid: mockTileGridInstance
|
|
195
|
+
}))
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('does not set tileLoadFunction on VectorTileSource', async () => {
|
|
199
|
+
await createVectorTileLayer(styleUrl, null)
|
|
200
|
+
const { tileLoadFunction } = VectorTileSource.mock.calls[0][0]
|
|
201
|
+
expect(tileLoadFunction).toBeUndefined()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('creates VectorTileLayer with source, declutter true, and vector renderMode', async () => {
|
|
205
|
+
await createVectorTileLayer(styleUrl, null)
|
|
206
|
+
expect(VectorTileLayer).toHaveBeenCalledWith({ source: mockVectorTileSourceInstance, declutter: true, renderMode: 'vector' })
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('applies stylefunction with layer, styleJson, sourceId, resolutions, spritesJson, spritesPngUrl', async () => {
|
|
210
|
+
await createVectorTileLayer(styleUrl, null)
|
|
211
|
+
expect(stylefunction).toHaveBeenCalledWith(
|
|
212
|
+
mockVectorTileLayerInstance,
|
|
213
|
+
mockStyleJson,
|
|
214
|
+
'esri',
|
|
215
|
+
expect.any(Array),
|
|
216
|
+
mockSpritesJson,
|
|
217
|
+
'https://example.com/sprites/sprite.png'
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('returns the constructed layer and source', async () => {
|
|
222
|
+
const result = await createVectorTileLayer(styleUrl, null)
|
|
223
|
+
expect(result.layer).toBe(mockVectorTileLayerInstance)
|
|
224
|
+
expect(result.source).toBe(mockVectorTileSourceInstance)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('inserts sprite extension before query string when sprite URL has one', async () => {
|
|
228
|
+
const styleWithQuery = { ...mockStyleJson, sprite: 'https://example.com/sprites/sprite?key=abc123' }
|
|
229
|
+
global.fetch = makeVectorFetchMock(styleWithQuery)
|
|
230
|
+
await createVectorTileLayer(styleUrl, null)
|
|
231
|
+
expect(fetch).toHaveBeenCalledWith('https://example.com/sprites/sprite.json?key=abc123', { headers: {} })
|
|
232
|
+
expect(stylefunction).toHaveBeenCalledWith(
|
|
233
|
+
expect.anything(), expect.anything(), expect.anything(), expect.anything(), expect.anything(),
|
|
234
|
+
'https://example.com/sprites/sprite.png?key=abc123'
|
|
235
|
+
)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('passes transformRequest result url and headers to each fetch', async () => {
|
|
239
|
+
const transformRequest = jest.fn((url) => ({
|
|
240
|
+
url: url + '&auth=1',
|
|
241
|
+
headers: { Authorization: 'Bearer token' }
|
|
242
|
+
}))
|
|
243
|
+
global.fetch = jest.fn().mockImplementation(url => {
|
|
244
|
+
if (url.includes('caps.json')) { return Promise.resolve({ json: () => Promise.resolve(mockServiceJson) }) }
|
|
245
|
+
if (url.includes('.json')) { return Promise.resolve({ json: () => Promise.resolve(mockSpritesJson) }) }
|
|
246
|
+
return Promise.resolve({ json: () => Promise.resolve(mockStyleJson) })
|
|
247
|
+
})
|
|
248
|
+
await createVectorTileLayer(styleUrl, transformRequest)
|
|
249
|
+
expect(fetch).toHaveBeenCalledWith(styleUrl + '&auth=1', { headers: { Authorization: 'Bearer token' } })
|
|
250
|
+
})
|
|
251
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { TILE_GRID_RESOLUTIONS, WORLD_LOD_RESOLUTIONS, WORLD_DEFAULT_MIN_ZOOM, WORLD_DEFAULT_MAX_ZOOM } from '../defaults.js'
|
|
2
|
+
|
|
3
|
+
export const ZOOM_ALIGNMENT = {
|
|
4
|
+
UK: 'uk',
|
|
5
|
+
WORLD: 'world'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns OL View resolution config for the requested zoom alignment mode.
|
|
10
|
+
* 'uk' — OS tile grid zoom levels (0–13); zoom 0 shows all of Great Britain (default)
|
|
11
|
+
* 'world' — ESRI LOD sequence; zoom levels match ESRI SDK, full UK visible around zoom 7
|
|
12
|
+
*/
|
|
13
|
+
export function getViewResolutionConfig (zoomAlignment) {
|
|
14
|
+
if (zoomAlignment === ZOOM_ALIGNMENT.WORLD) {
|
|
15
|
+
return {
|
|
16
|
+
resolutions: WORLD_LOD_RESOLUTIONS,
|
|
17
|
+
defaultMinZoom: WORLD_DEFAULT_MIN_ZOOM,
|
|
18
|
+
maxZoom: WORLD_DEFAULT_MAX_ZOOM
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
resolutions: TILE_GRID_RESOLUTIONS,
|
|
23
|
+
defaultMinZoom: 0,
|
|
24
|
+
maxZoom: TILE_GRID_RESOLUTIONS.length - 1
|
|
25
|
+
}
|
|
26
|
+
}
|