@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.
Files changed (76) hide show
  1. package/dist/esm/im-core.js +1 -1
  2. package/dist/umd/im-core.js +1 -1
  3. package/docs/api.md +19 -0
  4. package/docs/architecture.md +14 -0
  5. package/docs/getting-started.md +10 -0
  6. package/docs/plugins/datasets.md +9 -3
  7. package/jest.setup.js +8 -0
  8. package/package.json +8 -3
  9. package/plugins/datasets/dist/esm/im-datasets-ml-adapter.js +1 -1
  10. package/plugins/datasets/dist/esm/im-datasets-ol-adapter.js +1 -0
  11. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  12. package/plugins/datasets/dist/umd/im-datasets-ml-adapter.js +1 -1
  13. package/plugins/datasets/dist/umd/im-datasets-ol-adapter.js +2 -0
  14. package/plugins/datasets/dist/umd/im-datasets-ol-adapter.js.LICENSE.txt +1 -0
  15. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  16. package/plugins/datasets/dist/umd/index.js +1 -1
  17. package/plugins/datasets/src/adapters/loadLayerAdapter.js +7 -3
  18. package/plugins/datasets/src/adapters/loadLayerAdapter.test.js +17 -0
  19. package/plugins/datasets/src/adapters/mapboxStyleLayerAdapter.js +74 -0
  20. package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +14 -79
  21. package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +4 -0
  22. package/plugins/datasets/src/adapters/maplibre/registry/mapLibreDataset.js +3 -89
  23. package/plugins/datasets/src/adapters/openlayers/canvasPatternStyle.js +111 -0
  24. package/plugins/datasets/src/adapters/openlayers/canvasPatternStyle.test.js +198 -0
  25. package/plugins/datasets/src/adapters/openlayers/layerBuilders.js +205 -0
  26. package/plugins/datasets/src/adapters/openlayers/layerBuilders.test.js +233 -0
  27. package/plugins/datasets/src/adapters/openlayers/openlayersLayerAdapter.js +276 -0
  28. package/plugins/datasets/src/adapters/openlayers/openlayersLayerAdapter.test.js +431 -0
  29. package/plugins/datasets/src/adapters/openlayers/registry/openLayersDataset.js +172 -0
  30. package/plugins/datasets/src/adapters/openlayers/registry/openLayersDataset.test.js +262 -0
  31. package/plugins/datasets/src/fetch/createDynamicSource.js +12 -8
  32. package/plugins/datasets/src/fetch/createDynamicSource.test.js +61 -52
  33. package/plugins/datasets/src/initialise/DatasetsInit.jsx +13 -1
  34. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +20 -0
  35. package/plugins/datasets/src/initialise/initialiseDatasets.js +1 -1
  36. package/plugins/datasets/src/initialise/initialiseDatasets.test.js +1 -1
  37. package/plugins/datasets/src/registry/mapboxStyleDataset.js +108 -0
  38. package/plugins/datasets/src/registry/mapboxStyleDataset.test.js +240 -0
  39. package/plugins/datasets/src/utils/bbox.js +5 -7
  40. package/plugins/datasets/src/utils/bbox.test.js +3 -10
  41. package/plugins/draw/dist/css/index.css +1 -0
  42. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  43. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  44. package/plugins/draw/src/index.js +2 -0
  45. package/plugins/draw/src/index.test.js +1 -0
  46. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  47. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  48. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  49. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  50. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  51. package/providers/beta/openlayers/src/index.js +16 -0
  52. package/providers/beta/openlayers/src/openlayersProvider.js +160 -7
  53. package/providers/beta/openlayers/src/openlayersProvider.test.js +13 -0
  54. package/providers/beta/openlayers/src/utils/bngProjection.js +9 -0
  55. package/providers/beta/openlayers/src/utils/filterEvaluator.js +32 -0
  56. package/providers/beta/openlayers/src/utils/filterEvaluator.test.js +36 -0
  57. package/providers/beta/openlayers/src/utils/highlightFeatures.js +38 -23
  58. package/providers/beta/openlayers/src/utils/highlightFeatures.test.js +153 -8
  59. package/providers/beta/openlayers/src/utils/hoverCursor.js +5 -1
  60. package/providers/beta/openlayers/src/utils/hoverCursor.test.js +40 -0
  61. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -54
  62. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +143 -1
  63. package/providers/beta/openlayers/src/utils/symbolImages.js +79 -5
  64. package/providers/beta/openlayers/src/utils/symbolImages.test.js +111 -1
  65. package/providers/beta/openlayers/src/utils/vtTileFragments.js +20 -8
  66. package/providers/beta/openlayers/src/utils/vtTileFragments.test.js +162 -0
  67. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  68. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  69. package/providers/maplibre/dist/umd/index.js +1 -1
  70. package/providers/maplibre/src/utils/patternImages.js +4 -44
  71. package/rollup.esm.mjs +2 -0
  72. package/src/services/patternRegistry.js +38 -0
  73. package/src/services/patternRegistry.test.js +61 -0
  74. package/src/types.js +11 -0
  75. package/webpack.dev.mjs +1 -0
  76. /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
+ })