@defra/interactive-map 0.0.50-alpha → 0.0.51-apha
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/esm/im-core.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/docs/api.md +19 -0
- package/docs/architecture.md +14 -0
- package/docs/getting-started.md +10 -0
- package/docs/plugins/datasets.md +9 -3
- package/jest.setup.js +8 -0
- package/package.json +8 -3
- package/plugins/datasets/dist/esm/im-datasets-ml-adapter.js +1 -1
- package/plugins/datasets/dist/esm/im-datasets-ol-adapter.js +1 -0
- package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-ml-adapter.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-ol-adapter.js +2 -0
- package/plugins/datasets/dist/umd/im-datasets-ol-adapter.js.LICENSE.txt +1 -0
- package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/datasets/dist/umd/index.js +1 -1
- package/plugins/datasets/src/adapters/loadLayerAdapter.js +7 -3
- package/plugins/datasets/src/adapters/loadLayerAdapter.test.js +17 -0
- package/plugins/datasets/src/adapters/mapboxStyleLayerAdapter.js +74 -0
- package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +14 -79
- package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +4 -0
- package/plugins/datasets/src/adapters/maplibre/registry/mapLibreDataset.js +3 -89
- package/plugins/datasets/src/adapters/openlayers/canvasPatternStyle.js +111 -0
- package/plugins/datasets/src/adapters/openlayers/canvasPatternStyle.test.js +198 -0
- package/plugins/datasets/src/adapters/openlayers/layerBuilders.js +205 -0
- package/plugins/datasets/src/adapters/openlayers/layerBuilders.test.js +233 -0
- package/plugins/datasets/src/adapters/openlayers/openlayersLayerAdapter.js +276 -0
- package/plugins/datasets/src/adapters/openlayers/openlayersLayerAdapter.test.js +431 -0
- package/plugins/datasets/src/adapters/openlayers/registry/openLayersDataset.js +172 -0
- package/plugins/datasets/src/adapters/openlayers/registry/openLayersDataset.test.js +262 -0
- package/plugins/datasets/src/fetch/createDynamicSource.js +12 -8
- package/plugins/datasets/src/fetch/createDynamicSource.test.js +61 -52
- package/plugins/datasets/src/initialise/DatasetsInit.jsx +13 -1
- package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +20 -0
- package/plugins/datasets/src/initialise/initialiseDatasets.js +1 -1
- package/plugins/datasets/src/initialise/initialiseDatasets.test.js +1 -1
- package/plugins/datasets/src/registry/mapboxStyleDataset.js +108 -0
- package/plugins/datasets/src/registry/mapboxStyleDataset.test.js +240 -0
- package/plugins/datasets/src/utils/bbox.js +5 -7
- package/plugins/datasets/src/utils/bbox.test.js +3 -10
- package/plugins/draw/dist/css/index.css +1 -0
- 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/index.js +2 -0
- package/plugins/draw/src/index.test.js +1 -0
- package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
- 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/dist/umd/index.js +1 -1
- package/providers/beta/openlayers/src/index.js +16 -0
- package/providers/beta/openlayers/src/openlayersProvider.js +160 -7
- package/providers/beta/openlayers/src/openlayersProvider.test.js +13 -0
- package/providers/beta/openlayers/src/utils/bngProjection.js +9 -0
- package/providers/beta/openlayers/src/utils/filterEvaluator.js +32 -0
- package/providers/beta/openlayers/src/utils/filterEvaluator.test.js +36 -0
- package/providers/beta/openlayers/src/utils/highlightFeatures.js +38 -23
- package/providers/beta/openlayers/src/utils/highlightFeatures.test.js +153 -8
- package/providers/beta/openlayers/src/utils/hoverCursor.js +5 -1
- package/providers/beta/openlayers/src/utils/hoverCursor.test.js +40 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -54
- package/providers/beta/openlayers/src/utils/queryFeatures.test.js +143 -1
- package/providers/beta/openlayers/src/utils/symbolImages.js +79 -5
- package/providers/beta/openlayers/src/utils/symbolImages.test.js +111 -1
- package/providers/beta/openlayers/src/utils/vtTileFragments.js +20 -8
- package/providers/beta/openlayers/src/utils/vtTileFragments.test.js +162 -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/dist/umd/index.js +1 -1
- package/providers/maplibre/src/utils/patternImages.js +4 -44
- package/rollup.esm.mjs +2 -0
- package/src/services/patternRegistry.js +38 -0
- package/src/services/patternRegistry.test.js +61 -0
- package/src/types.js +11 -0
- package/webpack.dev.mjs +1 -0
- /package/plugins/datasets/dist/esm/{esriLayerAdapter.js → im-datasets-esri-adapter.js} +0 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import VectorSource from 'ol/source/Vector.js'
|
|
2
|
+
import VectorLayer from 'ol/layer/Vector.js'
|
|
3
|
+
import VectorTileSource from 'ol/source/VectorTile.js'
|
|
4
|
+
import VectorTileLayer from 'ol/layer/VectorTile.js'
|
|
5
|
+
import GeoJSON from 'ol/format/GeoJSON.js'
|
|
6
|
+
import MVT from 'ol/format/MVT.js'
|
|
7
|
+
import Feature from 'ol/Feature.js'
|
|
8
|
+
import TileGrid from 'ol/tilegrid/TileGrid.js'
|
|
9
|
+
import { logger } from '../../../../../src/services/logger.js'
|
|
10
|
+
import { BNG_CRS } from '../../../../../providers/beta/openlayers/src/utils/bngProjection.js'
|
|
11
|
+
import { buildCanvasPatternStyle } from './canvasPatternStyle.js'
|
|
12
|
+
|
|
13
|
+
// Data and map both use BNG, so no coordinate transformation is needed. Importing bngProjection.js
|
|
14
|
+
// (rather than just the string) also registers EPSG:27700 with proj4/OL, needed for
|
|
15
|
+
// VectorTileSource's projection option below to resolve to a real projection.
|
|
16
|
+
const PROJECTION = BNG_CRS
|
|
17
|
+
const format = new GeoJSON({ dataProjection: PROJECTION, featureProjection: PROJECTION })
|
|
18
|
+
|
|
19
|
+
// The Mapbox style spec's minzoom/maxzoom are inclusive-min, exclusive-max; OL's own semantics
|
|
20
|
+
// are the exact opposite (exclusive-min, inclusive-max — see ol/layer/Layer.js's visibility
|
|
21
|
+
// check). Passing registryDataset.minZoom/maxZoom straight through was a real bug: every dataset
|
|
22
|
+
// at the shared default minZoom:6 vanished exactly at the map's own minZoom:6. Shifting both
|
|
23
|
+
// bounds down by a tiny epsilon reproduces the Mapbox-style semantics using OL's opposite sense.
|
|
24
|
+
const ZOOM_EPSILON = 1e-6
|
|
25
|
+
const toOlMinZoom = (minZoom) => (minZoom === undefined ? undefined : minZoom - ZOOM_EPSILON)
|
|
26
|
+
const toOlMaxZoom = (maxZoom) => (maxZoom === undefined ? undefined : maxZoom - ZOOM_EPSILON)
|
|
27
|
+
|
|
28
|
+
// Symbol (icon) layers always render above fill/stroke layers, regardless of add/remove order —
|
|
29
|
+
// mirrors MapLibreLayerAdapter's _maintainSymbolOrdering, but OL's zIndex sorting achieves the
|
|
30
|
+
// same guarantee with no imperative re-ordering step needed.
|
|
31
|
+
const SYMBOL_Z_INDEX = 1
|
|
32
|
+
const DEFAULT_Z_INDEX = 0
|
|
33
|
+
|
|
34
|
+
// Must stay in sync with the ST_MakeEnvelope(0, 0, 1300000, 1300000, 27700) bounds hardcoded in
|
|
35
|
+
// the farming-tiles server's own vector tile sources — a wrong tile grid silently misplaces or
|
|
36
|
+
// fails to load every tile. Independent of the ArcGIS-hosted OSGB36 tiles used elsewhere, which
|
|
37
|
+
// use a different origin/resolution/tileSize despite sharing the same EPSG:27700 projection.
|
|
38
|
+
const BNG_TILE_GRID_SIZE = 1300000
|
|
39
|
+
const BNG_TILE_GRID_EXTENT = [0, 0, BNG_TILE_GRID_SIZE, BNG_TILE_GRID_SIZE]
|
|
40
|
+
const BNG_TILE_SIZE = 256
|
|
41
|
+
const BNG_TILE_GRID = new TileGrid({
|
|
42
|
+
extent: BNG_TILE_GRID_EXTENT,
|
|
43
|
+
origin: [BNG_TILE_GRID_EXTENT[0], BNG_TILE_GRID_EXTENT[3]], // NOSONAR: bbox [west, south, east, north] coords
|
|
44
|
+
resolutions: Array.from({ length: 25 }, (_, zoom) => (BNG_TILE_GRID_EXTENT[2] - BNG_TILE_GRID_EXTENT[0]) / (BNG_TILE_SIZE * 2 ** zoom)), // NOSONAR: bbox coords
|
|
45
|
+
tileSize: BNG_TILE_SIZE
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reads GeoJSON into OL Features, promoting idProperty onto each feature's id first —
|
|
50
|
+
* ol/format/GeoJSON has no "promoteId" source option like MapLibre's, so a property can only
|
|
51
|
+
* become the feature's id by rewriting the raw GeoJSON before it's parsed.
|
|
52
|
+
* @param {Object} geojson - a GeoJSON FeatureCollection
|
|
53
|
+
* @param {string|null} idProperty
|
|
54
|
+
* @returns {import('ol/Feature.js').default[]}
|
|
55
|
+
*/
|
|
56
|
+
export const readGeoJSONFeatures = (geojson, idProperty) => {
|
|
57
|
+
if (!idProperty) {
|
|
58
|
+
return format.readFeatures(geojson)
|
|
59
|
+
}
|
|
60
|
+
const withPromotedIds = {
|
|
61
|
+
...geojson,
|
|
62
|
+
features: geojson.features.map(feature => ({ ...feature, id: feature.properties?.[idProperty] ?? feature.id }))
|
|
63
|
+
}
|
|
64
|
+
return format.readFeatures(withPromotedIds)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fetch a geojson URL once and populate the given source — the OL equivalent of MapLibre's
|
|
69
|
+
* native one-time fetch-on-load for a geojson source whose `data` is a URL string.
|
|
70
|
+
* Fire-and-forget: the source is returned empty and populated when the fetch resolves, the
|
|
71
|
+
* same "empty now, filled in later" shape dynamic sources already use via setData.
|
|
72
|
+
* @param {import('ol/source/Vector.js').default} olSource
|
|
73
|
+
* @param {string} url
|
|
74
|
+
* @param {string|null} idProperty
|
|
75
|
+
* @param {string} datasetId - for the warning message only
|
|
76
|
+
*/
|
|
77
|
+
const populateFromUrl = (olSource, url, idProperty, datasetId) => {
|
|
78
|
+
fetch(url)
|
|
79
|
+
.then(response => {
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw new Error(`${response.status} ${response.statusText}`)
|
|
82
|
+
}
|
|
83
|
+
return response.json()
|
|
84
|
+
})
|
|
85
|
+
.then(geojson => olSource.addFeatures(readGeoJSONFeatures(geojson, idProperty)))
|
|
86
|
+
.catch(error => logger.warn(`OpenLayers datasets adapter: failed to fetch geojson for dataset "${datasetId}" — ${error.message}`))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the OL source for a dataset — a plain VectorSource for geojson/dynamicGeoJSON, or a
|
|
91
|
+
* VectorTileSource (BNG-native tile grid, see BNG_TILE_GRID) for a tiles-backed dataset. Several
|
|
92
|
+
* top-level datasets can point at the same tiles URL with different sourceLayer values (mirroring
|
|
93
|
+
* MapLibre's own multiple-paint-layers-per-vector-source model, e.g. existing-fields/hedge-control
|
|
94
|
+
* both reading field_parcels_with_hedges_*) — registryDataset.sourceId is already hashed from the
|
|
95
|
+
* tiles URL (see Dataset.sourceId), so the adapter's source cache naturally shares one
|
|
96
|
+
* VectorTileSource between them; only the per-layer style/filter (see OpenLayersDataset.filter)
|
|
97
|
+
* distinguishes which sourceLayer each one actually renders.
|
|
98
|
+
* @param {Object} registryDataset - an OpenLayersDataset
|
|
99
|
+
* @returns {import('ol/source/Vector.js').default|import('ol/source/VectorTile.js').default|null}
|
|
100
|
+
*/
|
|
101
|
+
export const createDatasetSource = (registryDataset) => {
|
|
102
|
+
const { source, id, idStrategy } = registryDataset
|
|
103
|
+
if (!source) {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
if (source.type === 'geojson') {
|
|
107
|
+
const olSource = new VectorSource()
|
|
108
|
+
if (typeof source.data === 'string') {
|
|
109
|
+
populateFromUrl(olSource, source.data, idStrategy, id)
|
|
110
|
+
} else if (source.data?.features?.length) {
|
|
111
|
+
olSource.addFeatures(readGeoJSONFeatures(source.data, idStrategy))
|
|
112
|
+
} else {
|
|
113
|
+
// dynamicGeoJSON sources start empty and are populated later via setData
|
|
114
|
+
}
|
|
115
|
+
return olSource
|
|
116
|
+
}
|
|
117
|
+
if (source.type === 'vector') {
|
|
118
|
+
// tiles is accepted as either a single URL string or an array (MapLibre-style) elsewhere in
|
|
119
|
+
// this codebase — see Dataset.sourceId's own Array.isArray check — but VectorTileSource's
|
|
120
|
+
// urls option requires a real array.
|
|
121
|
+
const urls = Array.isArray(source.tiles) ? source.tiles : [source.tiles]
|
|
122
|
+
return new VectorTileSource({
|
|
123
|
+
// A real ol/Feature (not MVT's default RenderFeature) — needed so canvasPatternStyle.js's
|
|
124
|
+
// filter evaluator can call feature.getGeometry()/getProperties() the same way it does for
|
|
125
|
+
// GeoJSON-backed features, and so getFeaturesInExtent-style consumers work uniformly.
|
|
126
|
+
format: new MVT({ featureClass: Feature, idProperty: idStrategy || undefined }),
|
|
127
|
+
urls,
|
|
128
|
+
projection: PROJECTION,
|
|
129
|
+
tileGrid: BNG_TILE_GRID
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
logger.warn(`OpenLayers datasets adapter: unsupported source type "${source.type}" (dataset "${id}")`)
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The style value to hand to layer.setStyle()/the layer constructor: registryDataset's own
|
|
138
|
+
* flatStyle, except for a pattern fill, which needs the genuinely crisp, CanvasPattern-based
|
|
139
|
+
* style function from canvasPatternStyle.js instead — see that module's doc for why flat-style's
|
|
140
|
+
* fill-pattern-src can't achieve this.
|
|
141
|
+
* @param {Object} registryDataset - an OpenLayersDataset
|
|
142
|
+
* @param {{ mapStyleId: string, pixelRatio: number, patternRegistry: Object }} context
|
|
143
|
+
* @returns {Object|Array<Object>|import('ol/style/Style.js').StyleFunction}
|
|
144
|
+
*/
|
|
145
|
+
export const resolveLayerStyle = (registryDataset, { mapStyleId, pixelRatio, patternRegistry } = {}) => {
|
|
146
|
+
if (registryDataset.hasPattern) {
|
|
147
|
+
return buildCanvasPatternStyle(registryDataset, mapStyleId, pixelRatio, patternRegistry)
|
|
148
|
+
}
|
|
149
|
+
return registryDataset.flatStyle
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build the OL layer for a dataset, wired to the given source — a Canvas VectorTileLayer for a
|
|
154
|
+
* tiles-backed dataset, or a Canvas VectorLayer otherwise. For canvas patterns, also picks the
|
|
155
|
+
* style pipeline (see resolveLayerStyle).
|
|
156
|
+
* @param {Object} registryDataset - an OpenLayersDataset
|
|
157
|
+
* @param {import('ol/source/Vector.js').default} olSource
|
|
158
|
+
* @param {{ mapStyleId: string, pixelRatio: number, patternRegistry: Object }} context
|
|
159
|
+
* @returns {import('ol/layer/Layer.js').default}
|
|
160
|
+
*/
|
|
161
|
+
export const createDatasetLayer = (registryDataset, olSource, context) => {
|
|
162
|
+
const isTileSource = olSource instanceof VectorTileSource
|
|
163
|
+
const LayerClass = isTileSource ? VectorTileLayer : VectorLayer
|
|
164
|
+
const layer = new LayerClass({
|
|
165
|
+
source: olSource,
|
|
166
|
+
style: resolveLayerStyle(registryDataset, context),
|
|
167
|
+
opacity: registryDataset.opacity,
|
|
168
|
+
visible: registryDataset.visibility === 'visible',
|
|
169
|
+
// See toOlMinZoom/toOlMaxZoom's comment: compensates OL's opposite inclusive/exclusive
|
|
170
|
+
// sense at each boundary, to match MapLibre's minzoom/maxzoom (which registryDataset.
|
|
171
|
+
// minZoom/maxZoom mirror) exactly.
|
|
172
|
+
minZoom: toOlMinZoom(registryDataset.minZoom),
|
|
173
|
+
maxZoom: toOlMaxZoom(registryDataset.maxZoom),
|
|
174
|
+
zIndex: registryDataset.hasSymbol ? SYMBOL_Z_INDEX : DEFAULT_Z_INDEX,
|
|
175
|
+
// ol/layer/VectorTile defaults to 'hybrid' (fills/strokes rasterised to an image tile,
|
|
176
|
+
// redrawn only on tile load — cheaper, but visibly stale/blurry while panning/zooming
|
|
177
|
+
// between loads). 'vector' re-renders true vector geometry every frame instead, matching
|
|
178
|
+
// MapLibre's own always-vector tile rendering.
|
|
179
|
+
...(isTileSource ? { renderMode: 'vector' } : {})
|
|
180
|
+
})
|
|
181
|
+
// Required for the OL provider's queryFeatures/getVisibleFeatures (click-to-select, hover,
|
|
182
|
+
// the interact plugin's Features list) to recognise this layer at all — untagged layers are
|
|
183
|
+
// silently skipped there. 'layerId' must equal the (sub)dataset id so it matches what
|
|
184
|
+
// interactPlugin's own layerId config is written against, same convention as MapLibre.
|
|
185
|
+
// 'vectorTile' here is deliberately the *same* tag draw-ol's basemap MVT tiles use, but with a
|
|
186
|
+
// different feature shape (a real ol/Feature carrying this dataset's own sourceLayer/id, not a
|
|
187
|
+
// RenderFeature carrying a MapLibre-style 'mapbox-layer' object) — see queryFeatures.js, which
|
|
188
|
+
// branches on which shape it's actually looking at.
|
|
189
|
+
layer.set('layerType', isTileSource ? 'vectorTile' : 'vector')
|
|
190
|
+
layer.set('layerId', registryDataset.id)
|
|
191
|
+
// Sibling sublayers of the same parent share one OL source (see createDatasetSource's doc) —
|
|
192
|
+
// the *style* only actually paints/hit-tests each sublayer's own filtered subset, but
|
|
193
|
+
// queryFeatures.js's getVisibleFeatures reads straight off the shared source/tiles with no
|
|
194
|
+
// rendering step involved, so it needs this same filter to know which features are actually
|
|
195
|
+
// this sublayer's own, not just every feature the source happens to hold.
|
|
196
|
+
if (registryDataset.filter) {
|
|
197
|
+
layer.set('filter', registryDataset.filter)
|
|
198
|
+
}
|
|
199
|
+
// Lets highlightFeatures.js build a select/active highlight for a dataset symbol point —
|
|
200
|
+
// see OpenLayersDataset.symbolMeta's doc.
|
|
201
|
+
if (registryDataset.hasSymbol) {
|
|
202
|
+
layer.set('symbolMeta', registryDataset.symbolMeta)
|
|
203
|
+
}
|
|
204
|
+
return layer
|
|
205
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import VectorSource from 'ol/source/Vector.js'
|
|
2
|
+
import VectorLayer from 'ol/layer/Vector.js'
|
|
3
|
+
import VectorTileSource from 'ol/source/VectorTile.js'
|
|
4
|
+
import VectorTileLayer from 'ol/layer/VectorTile.js'
|
|
5
|
+
import { createDatasetSource, createDatasetLayer, readGeoJSONFeatures } from './layerBuilders.js'
|
|
6
|
+
import { logger } from '../../../../../src/services/logger.js'
|
|
7
|
+
|
|
8
|
+
jest.mock('../../../../../src/services/logger.js', () => ({ logger: { warn: jest.fn() } }))
|
|
9
|
+
|
|
10
|
+
const point = (id, coords, properties = {}) => ({
|
|
11
|
+
type: 'Feature', id, properties, geometry: { type: 'Point', coordinates: coords }
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('readGeoJSONFeatures', () => {
|
|
15
|
+
it('reads features using their native id when no idProperty is given', () => {
|
|
16
|
+
const geojson = { type: 'FeatureCollection', features: [point(1, [0, 0])] }
|
|
17
|
+
const [feature] = readGeoJSONFeatures(geojson, null)
|
|
18
|
+
expect(feature.getId()).toBe(1)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('promotes idProperty onto the feature id when given', () => {
|
|
22
|
+
const geojson = { type: 'FeatureCollection', features: [point('native-1', [0, 0], { ref: 'abc' })] }
|
|
23
|
+
const [feature] = readGeoJSONFeatures(geojson, 'ref')
|
|
24
|
+
expect(feature.getId()).toBe('abc')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('createDatasetSource', () => {
|
|
29
|
+
it('returns null when the dataset has no source', () => {
|
|
30
|
+
expect(createDatasetSource({ id: 'ds', source: null })).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('builds a populated VectorSource for a geojson dataset', () => {
|
|
34
|
+
const registryDataset = {
|
|
35
|
+
id: 'ds',
|
|
36
|
+
idStrategy: null,
|
|
37
|
+
source: { type: 'geojson', data: { type: 'FeatureCollection', features: [point(1, [0, 0])] } }
|
|
38
|
+
}
|
|
39
|
+
const source = createDatasetSource(registryDataset)
|
|
40
|
+
expect(source).toBeInstanceOf(VectorSource)
|
|
41
|
+
expect(source.getFeatures()).toHaveLength(1)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('builds an empty VectorSource for a geojson dataset with no features', () => {
|
|
45
|
+
const registryDataset = { id: 'ds', idStrategy: null, source: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } }
|
|
46
|
+
const source = createDatasetSource(registryDataset)
|
|
47
|
+
expect(source.getFeatures()).toHaveLength(0)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('vector tile source', () => {
|
|
51
|
+
it('builds a VectorTileSource for a tiles array', () => {
|
|
52
|
+
const registryDataset = { id: 'ds-tiles', idStrategy: null, source: { type: 'vector', tiles: ['https://example.com/{z}/{x}/{y}'] } }
|
|
53
|
+
const source = createDatasetSource(registryDataset)
|
|
54
|
+
expect(source).toBeInstanceOf(VectorTileSource)
|
|
55
|
+
expect(source.getUrls()).toEqual(['https://example.com/{z}/{x}/{y}'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('normalises a single tiles URL string into an array', () => {
|
|
59
|
+
const registryDataset = { id: 'ds-tiles', idStrategy: null, source: { type: 'vector', tiles: 'https://example.com/{z}/{x}/{y}' } }
|
|
60
|
+
const source = createDatasetSource(registryDataset)
|
|
61
|
+
expect(source.getUrls()).toEqual(['https://example.com/{z}/{x}/{y}'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('uses the fixed BNG tile grid, not the default Web Mercator one', () => {
|
|
65
|
+
const registryDataset = { id: 'ds-tiles', idStrategy: null, source: { type: 'vector', tiles: ['https://example.com/{z}/{x}/{y}'] } }
|
|
66
|
+
const source = createDatasetSource(registryDataset)
|
|
67
|
+
const tileGrid = source.getTileGrid()
|
|
68
|
+
expect(tileGrid.getExtent()).toEqual([0, 0, 1300000, 1300000])
|
|
69
|
+
expect(tileGrid.getOrigin(0)).toEqual([0, 1300000])
|
|
70
|
+
expect(tileGrid.getTileSize(0)).toBe(256)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('warns and returns null for an unsupported source type', () => {
|
|
75
|
+
const registryDataset = { id: 'ds-unsupported', source: { type: 'raster' } }
|
|
76
|
+
expect(createDatasetSource(registryDataset)).toBeNull()
|
|
77
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('ds-unsupported'))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('geojson as a URL string', () => {
|
|
81
|
+
const flushMicrotasks = () => Promise.resolve().then(() => Promise.resolve())
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
global.fetch = jest.fn()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns an empty source immediately, then populates it once the fetch resolves', async () => {
|
|
88
|
+
global.fetch.mockResolvedValue({
|
|
89
|
+
ok: true,
|
|
90
|
+
json: () => Promise.resolve({ type: 'FeatureCollection', features: [point(1, [0, 0])] })
|
|
91
|
+
})
|
|
92
|
+
const registryDataset = { id: 'ds', idStrategy: null, source: { type: 'geojson', data: 'https://example.com/data.geojson' } }
|
|
93
|
+
|
|
94
|
+
const source = createDatasetSource(registryDataset)
|
|
95
|
+
expect(source.getFeatures()).toHaveLength(0)
|
|
96
|
+
|
|
97
|
+
await flushMicrotasks()
|
|
98
|
+
expect(global.fetch).toHaveBeenCalledWith('https://example.com/data.geojson')
|
|
99
|
+
expect(source.getFeatures()).toHaveLength(1)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('promotes idStrategy onto fetched features', async () => {
|
|
103
|
+
global.fetch.mockResolvedValue({
|
|
104
|
+
ok: true,
|
|
105
|
+
json: () => Promise.resolve({ type: 'FeatureCollection', features: [point('native', [0, 0], { ref: 'abc' })] })
|
|
106
|
+
})
|
|
107
|
+
const registryDataset = { id: 'ds', idStrategy: 'ref', source: { type: 'geojson', data: 'https://example.com/data.geojson' } }
|
|
108
|
+
|
|
109
|
+
const source = createDatasetSource(registryDataset)
|
|
110
|
+
await flushMicrotasks()
|
|
111
|
+
|
|
112
|
+
expect(source.getFeatures()[0].getId()).toBe('abc')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('warns and leaves the source empty when the fetch response is not ok', async () => {
|
|
116
|
+
global.fetch.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' })
|
|
117
|
+
const registryDataset = { id: 'ds-404', idStrategy: null, source: { type: 'geojson', data: 'https://example.com/missing.geojson' } }
|
|
118
|
+
|
|
119
|
+
const source = createDatasetSource(registryDataset)
|
|
120
|
+
await flushMicrotasks()
|
|
121
|
+
|
|
122
|
+
expect(source.getFeatures()).toHaveLength(0)
|
|
123
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('ds-404'))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('warns when the fetch itself rejects', async () => {
|
|
127
|
+
global.fetch.mockRejectedValue(new Error('network down'))
|
|
128
|
+
const registryDataset = { id: 'ds-fail', idStrategy: null, source: { type: 'geojson', data: 'https://example.com/data.geojson' } }
|
|
129
|
+
|
|
130
|
+
createDatasetSource(registryDataset)
|
|
131
|
+
await flushMicrotasks()
|
|
132
|
+
|
|
133
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('ds-fail'))
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('createDatasetLayer', () => {
|
|
139
|
+
// Hex, not a named colour ('blue') — ol/style/flat resolves named CSS colours via a real
|
|
140
|
+
// canvas 2D context, which jsdom returns null for without the `canvas` npm package. Hex
|
|
141
|
+
// colours take OL's fast lane and skip that path entirely (see ol/color.js#parseRgba).
|
|
142
|
+
// Named colours work fine in a real browser; this is a test-environment gap only.
|
|
143
|
+
const registryDataset = { flatStyle: { 'fill-color': '#0000ff' }, opacity: 0.5, visibility: 'visible', minZoom: 1, maxZoom: 10, hasPattern: false }
|
|
144
|
+
|
|
145
|
+
it('builds a Canvas VectorLayer', () => {
|
|
146
|
+
const layer = createDatasetLayer(registryDataset, new VectorSource())
|
|
147
|
+
expect(layer).toBeInstanceOf(VectorLayer)
|
|
148
|
+
expect(layer.getOpacity()).toBe(0.5)
|
|
149
|
+
expect(layer.getVisible()).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('sets visible to false when the dataset visibility is "none"', () => {
|
|
153
|
+
const layer = createDatasetLayer({ ...registryDataset, visibility: 'none' }, new VectorSource())
|
|
154
|
+
expect(layer.getVisible()).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('tags the layer with layerType and layerId so the OL provider\'s queryFeatures recognises it', () => {
|
|
158
|
+
const layer = createDatasetLayer({ ...registryDataset, id: 'my-dataset' }, new VectorSource())
|
|
159
|
+
expect(layer.get('layerType')).toBe('vector')
|
|
160
|
+
expect(layer.get('layerId')).toBe('my-dataset')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('minZoom/maxZoom semantics', () => {
|
|
164
|
+
// OL's own visibility check (ol/layer/Layer.js) is `zoom > minZoom && zoom <= maxZoom` —
|
|
165
|
+
// exclusive-min, inclusive-max. MapLibre/the Mapbox style spec is the opposite:
|
|
166
|
+
// inclusive-min, exclusive-max (`zoom >= minzoom && zoom < maxzoom`). Without compensating,
|
|
167
|
+
// a dataset's minZoom:6 (matching this app's own shared default) would incorrectly hide at
|
|
168
|
+
// exactly zoom 6 in OL, when MapLibre would still show it there.
|
|
169
|
+
const isVisibleAtZoom = (layer, zoom) => zoom > layer.getMinZoom() && zoom <= layer.getMaxZoom()
|
|
170
|
+
|
|
171
|
+
it('shows the layer AT its minZoom, matching MapLibre\'s inclusive-min (not OL\'s native exclusive-min)', () => {
|
|
172
|
+
const layer = createDatasetLayer({ ...registryDataset, minZoom: 6, maxZoom: 20 }, new VectorSource())
|
|
173
|
+
expect(isVisibleAtZoom(layer, 6)).toBe(true)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('hides the layer AT its maxZoom, matching MapLibre\'s exclusive-max (not OL\'s native inclusive-max)', () => {
|
|
177
|
+
const layer = createDatasetLayer({ ...registryDataset, minZoom: 6, maxZoom: 20 }, new VectorSource())
|
|
178
|
+
expect(isVisibleAtZoom(layer, 20)).toBe(false)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('still shows the layer comfortably within its zoom range', () => {
|
|
182
|
+
const layer = createDatasetLayer({ ...registryDataset, minZoom: 6, maxZoom: 20 }, new VectorSource())
|
|
183
|
+
expect(isVisibleAtZoom(layer, 12)).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('still hides the layer comfortably outside its zoom range', () => {
|
|
187
|
+
const layer = createDatasetLayer({ ...registryDataset, minZoom: 6, maxZoom: 20 }, new VectorSource())
|
|
188
|
+
expect(isVisibleAtZoom(layer, 5)).toBe(false)
|
|
189
|
+
expect(isVisibleAtZoom(layer, 21)).toBe(false)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('leaves unrestricted (undefined) zoom bounds alone', () => {
|
|
193
|
+
const layer = createDatasetLayer({ ...registryDataset, minZoom: undefined, maxZoom: undefined }, new VectorSource())
|
|
194
|
+
expect(layer.getMinZoom()).toBe(-Infinity)
|
|
195
|
+
expect(layer.getMaxZoom()).toBe(Infinity)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('z-ordering', () => {
|
|
200
|
+
it('gives a fill/stroke dataset the default zIndex', () => {
|
|
201
|
+
const layer = createDatasetLayer(registryDataset, new VectorSource())
|
|
202
|
+
expect(layer.getZIndex()).toBe(0)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('gives a symbol dataset a higher zIndex, so it always renders above fill/stroke layers', () => {
|
|
206
|
+
const layer = createDatasetLayer({ ...registryDataset, hasSymbol: true }, new VectorSource())
|
|
207
|
+
expect(layer.getZIndex()).toBeGreaterThan(0)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('tile-backed dataset', () => {
|
|
212
|
+
const tileRegistryDataset = { flatStyle: { 'stroke-color': '#0000ff' }, opacity: 1, visibility: 'visible', hasPattern: false, id: 'existing-fields' }
|
|
213
|
+
// A real VectorTileSource, built the same way createDatasetSource does — a bare
|
|
214
|
+
// `new VectorTileSource()` throws without a resolved projection.
|
|
215
|
+
const tileSource = () => createDatasetSource({ id: 'existing-fields', idStrategy: null, source: { type: 'vector', tiles: ['https://example.com/{z}/{x}/{y}'] } })
|
|
216
|
+
|
|
217
|
+
it('builds a Canvas VectorTileLayer for a VectorTileSource', () => {
|
|
218
|
+
const layer = createDatasetLayer(tileRegistryDataset, tileSource())
|
|
219
|
+
expect(layer).toBeInstanceOf(VectorTileLayer)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('renders true vector geometry every frame, not the hybrid raster-tile default', () => {
|
|
223
|
+
const layer = createDatasetLayer(tileRegistryDataset, tileSource())
|
|
224
|
+
expect(layer.getRenderMode()).toBe('vector')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('tags a tile-backed layer as layerType "vectorTile", not "vector"', () => {
|
|
228
|
+
const layer = createDatasetLayer(tileRegistryDataset, tileSource())
|
|
229
|
+
expect(layer.get('layerType')).toBe('vectorTile')
|
|
230
|
+
expect(layer.get('layerId')).toBe('existing-fields')
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
})
|