@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
@@ -1,18 +1,36 @@
1
1
  /**
2
2
  * The OL "last mile" for symbolRegistry's rasterised symbol images: turns the ImageData
3
- * rasteriseSymbolImage() produces into something ol/style/Icon can render (a canvas),
4
- * and caches it by the same content-hash imageId symbolRegistry already computes — so
5
- * repeated resolves of the same style/theme/pixel-ratio combination are free after the
6
- * first. Unlike MapLibre (map.addImage), OL has no map-level image registry of its own,
7
- * so this cache lives at the module level here instead.
3
+ * rasteriseSymbolImage() produces into something OL can render, cached by the same imageId
4
+ * symbolRegistry computes. OL has no map-level image registry like MapLibre's map.addImage,
5
+ * so this cache lives at the module level instead.
6
+ *
7
+ * Two consumers need two different shapes from the same ImageData: highlightFeatures.js builds
8
+ * its own ol/style/Icon directly, so it wants a raw <canvas> (getOrCreateSymbolImage/
9
+ * getCachedSymbolImage); OpenLayersDataset.flatStyle's icon-src needs a data URI string instead.
8
10
  */
9
11
 
12
+ // Fixed oversampling ratio, unrelated to the map's actual pixelRatio — icon-scale genuinely
13
+ // decouples a symbol's on-screen size from its native pixel detail, so one fixed high-detail
14
+ // raster stays crisp at any pixelRatio with no re-rasterisation needed on resize.
15
+ export const SYMBOL_RASTER_PIXEL_RATIO = 3
16
+
10
17
  const imageCache = new Map() // imageId → HTMLCanvasElement
18
+ const dataUriCache = new Map() // imageId → data URI string
19
+ const activeImageMap = new Map() // normalId → activeId
20
+ const selectedImageMap = new Map() // normalId → selectedId
11
21
 
12
22
  /** Synchronous lookup for style functions (which must be synchronous) — undefined until
13
23
  * getOrCreateSymbolImage() has resolved it at least once. */
14
24
  export const getCachedSymbolImage = (imageId) => imageCache.get(imageId)
15
25
 
26
+ /**
27
+ * Reverse-map a symbol's normal (base) imageId to its active/selected variant, mirroring
28
+ * MapLibre's own active/selected image maps. Used by highlightFeatures.js to highlight a
29
+ * dataset symbol point, which (unlike a drawn point) has no active/selected id of its own.
30
+ */
31
+ export const getActiveSymbolImageId = (normalId) => activeImageMap.get(normalId) ?? null
32
+ export const getSelectedSymbolImageId = (normalId) => selectedImageMap.get(normalId) ?? null
33
+
16
34
  /**
17
35
  * Rasterise-once-cache-forever: draws the given ImageData onto a canvas the first time
18
36
  * imageId is seen, and returns the cached canvas on every subsequent call.
@@ -34,7 +52,63 @@ export const getOrCreateSymbolImage = (imageId, imageData) => {
34
52
  return canvas
35
53
  }
36
54
 
55
+ /** Synchronous lookup for OpenLayersDataset.flatStyle (a synchronous getter) — undefined
56
+ * until registerSymbol() has resolved it at least once. */
57
+ export const getCachedSymbolDataUri = (imageId) => dataUriCache.get(imageId)
58
+
59
+ /**
60
+ * Rasterise-once-cache-forever: resolves and caches one symbol's data URI for flatStyle's
61
+ * icon-src, plus its active/selected variants so highlightFeatures.js can highlight a dataset
62
+ * symbol point the same way it already does for a drawn one.
63
+ * @param {Object} style - Dataset style with symbol/symbolSvgContent properties
64
+ * @param {Object} mapStyle - Current map style config
65
+ * @param {Object} symbolRegistry
66
+ * @param {number} pixelRatio - the map's actual current pixelRatio
67
+ * @returns {Promise<void>}
68
+ */
69
+ export const registerSymbol = async (style, mapStyle, symbolRegistry, pixelRatio) => {
70
+ const normalId = symbolRegistry.getSymbolImageId(style, mapStyle, false, pixelRatio)
71
+ if (!normalId) {
72
+ return
73
+ }
74
+ const activeId = symbolRegistry.getSymbolImageId(style, mapStyle, true, pixelRatio)
75
+ if (activeId) {
76
+ activeImageMap.set(normalId, activeId)
77
+ }
78
+
79
+ await Promise.all(['normal', 'active', 'selected'].map(async (variant) => {
80
+ const imageId = variant === 'active' ? activeId : normalId
81
+ if (variant !== 'selected' && (!imageId || imageCache.has(imageId))) {
82
+ return
83
+ }
84
+ const result = await symbolRegistry.rasteriseSymbolImage(style, mapStyle, variant, pixelRatio)
85
+ if (!result) {
86
+ return
87
+ }
88
+ if (variant === 'selected') {
89
+ selectedImageMap.set(normalId, result.imageId)
90
+ }
91
+ if (!imageCache.has(result.imageId)) {
92
+ const canvas = getOrCreateSymbolImage(result.imageId, result.imageData)
93
+ if (variant === 'normal') {
94
+ dataUriCache.set(result.imageId, canvas.toDataURL())
95
+ }
96
+ }
97
+ }))
98
+ }
99
+
100
+ /** Register symbol data URIs for the given pre-resolved style configs, in parallel. */
101
+ export const registerSymbols = async (styleArray, mapStyle, symbolRegistry, pixelRatio) => {
102
+ if (!styleArray.length) {
103
+ return
104
+ }
105
+ await Promise.all(styleArray.map(style => registerSymbol(style, mapStyle, symbolRegistry, pixelRatio)))
106
+ }
107
+
37
108
  /** Mainly for testing — clears every cached image. */
38
109
  export const clearSymbolImageCache = () => {
39
110
  imageCache.clear()
111
+ dataUriCache.clear()
112
+ activeImageMap.clear()
113
+ selectedImageMap.clear()
40
114
  }
@@ -1,7 +1,17 @@
1
- import { getCachedSymbolImage, getOrCreateSymbolImage, clearSymbolImageCache } from './symbolImages.js'
1
+ import { getCachedSymbolImage, getOrCreateSymbolImage, clearSymbolImageCache, registerSymbol, registerSymbols, getCachedSymbolDataUri, getActiveSymbolImageId, getSelectedSymbolImageId } from './symbolImages.js'
2
+
3
+ const MAP_STYLE = { id: 'outdoor' }
2
4
 
3
5
  const imageData = (width, height) => ({ width, height })
4
6
 
7
+ const makeSymbolRegistry = () => ({
8
+ getSymbolImageId: jest.fn((style, mapStyle, active, pixelRatio) => `symbol-${style.symbol}-${pixelRatio}x${active ? '-active' : ''}`),
9
+ rasteriseSymbolImage: jest.fn(async (style, mapStyle, variant, pixelRatio) => ({
10
+ imageId: `symbol-${style.symbol}-${pixelRatio}x${variant === 'active' ? '-active' : variant === 'selected' ? '-selected' : ''}`,
11
+ imageData: imageData(20, 20)
12
+ }))
13
+ })
14
+
5
15
  beforeEach(() => {
6
16
  clearSymbolImageCache()
7
17
  // Memoized per-canvas so `canvas.getContext()` returns the same mock object on repeat
@@ -11,6 +21,9 @@ beforeEach(() => {
11
21
  this._ctx ??= { putImageData: jest.fn() }
12
22
  return this._ctx
13
23
  })
24
+ HTMLCanvasElement.prototype.toDataURL = jest.fn(function () {
25
+ return `data:image/png;base64,mock-${this.width}x${this.height}`
26
+ })
14
27
  })
15
28
 
16
29
  describe('getCachedSymbolImage', () => {
@@ -55,4 +68,101 @@ describe('clearSymbolImageCache', () => {
55
68
  const second = getOrCreateSymbolImage('symbol-f', imageData(10, 10))
56
69
  expect(second).not.toBe(first)
57
70
  })
71
+
72
+ it('also clears the data URI cache', async () => {
73
+ const symbolRegistry = makeSymbolRegistry()
74
+ await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
75
+ clearSymbolImageCache()
76
+ expect(getCachedSymbolDataUri('symbol-pin-1x')).toBeUndefined()
77
+ })
78
+ })
79
+
80
+ describe('registerSymbol', () => {
81
+ it('caches a data URI built from the rasterised ImageData', async () => {
82
+ const symbolRegistry = makeSymbolRegistry()
83
+ await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
84
+ expect(getCachedSymbolDataUri('symbol-pin-1x')).toBe('data:image/png;base64,mock-20x20')
85
+ })
86
+
87
+ it('rasterises at the given pixelRatio, not a fixed constant', async () => {
88
+ const symbolRegistry = makeSymbolRegistry()
89
+ await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 3)
90
+ expect(symbolRegistry.rasteriseSymbolImage).toHaveBeenCalledWith({ symbol: 'pin' }, MAP_STYLE, 'normal', 3)
91
+ })
92
+
93
+ it('does nothing when rasteriseSymbolImage returns null', async () => {
94
+ const symbolRegistry = { ...makeSymbolRegistry(), rasteriseSymbolImage: jest.fn().mockResolvedValue(null) }
95
+ await registerSymbol({ symbol: 'unknown' }, MAP_STYLE, symbolRegistry, 1)
96
+ expect(getCachedSymbolDataUri('symbol-unknown-1x')).toBeUndefined()
97
+ })
98
+
99
+ it('does nothing when getSymbolImageId returns null', async () => {
100
+ const symbolRegistry = { ...makeSymbolRegistry(), getSymbolImageId: jest.fn(() => null) }
101
+ await registerSymbol({ symbol: 'unknown' }, MAP_STYLE, symbolRegistry, 1)
102
+ expect(symbolRegistry.rasteriseSymbolImage).not.toHaveBeenCalled()
103
+ })
104
+
105
+ it('skips re-rasterising normal/active once cached for the same imageId — selected always re-rasterises', async () => {
106
+ const symbolRegistry = makeSymbolRegistry()
107
+ await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
108
+ expect(symbolRegistry.rasteriseSymbolImage).toHaveBeenCalledTimes(3) // normal + active + selected
109
+ await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
110
+ expect(symbolRegistry.rasteriseSymbolImage).toHaveBeenCalledTimes(4) // + selected only
111
+ })
112
+
113
+ it('reuses the same cached canvas getOrCreateSymbolImage builds', async () => {
114
+ const symbolRegistry = makeSymbolRegistry()
115
+ await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
116
+ expect(getCachedSymbolImage('symbol-pin-1x')).toBeInstanceOf(HTMLCanvasElement)
117
+ })
118
+
119
+ it('registers the active variant, resolvable via getActiveSymbolImageId', async () => {
120
+ const symbolRegistry = makeSymbolRegistry()
121
+ await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
122
+ expect(getActiveSymbolImageId('symbol-pin-1x')).toBe('symbol-pin-1x-active')
123
+ expect(getCachedSymbolImage('symbol-pin-1x-active')).toBeInstanceOf(HTMLCanvasElement)
124
+ })
125
+
126
+ it('registers the selected variant, resolvable via getSelectedSymbolImageId', async () => {
127
+ const symbolRegistry = makeSymbolRegistry()
128
+ await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
129
+ expect(getSelectedSymbolImageId('symbol-pin-1x')).toBe('symbol-pin-1x-selected')
130
+ expect(getCachedSymbolImage('symbol-pin-1x-selected')).toBeInstanceOf(HTMLCanvasElement)
131
+ })
132
+
133
+ it('does not register an active variant when getSymbolImageId returns null for active', async () => {
134
+ const symbolRegistry = makeSymbolRegistry()
135
+ symbolRegistry.getSymbolImageId.mockImplementation((style, mapStyle, active) => (active ? null : 'symbol-pin-1x'))
136
+ await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
137
+ expect(getActiveSymbolImageId('symbol-pin-1x')).toBeNull()
138
+ })
139
+ })
140
+
141
+ describe('getActiveSymbolImageId / getSelectedSymbolImageId', () => {
142
+ it('returns null for an imageId that has never been registered', () => {
143
+ expect(getActiveSymbolImageId('symbol-unseen')).toBeNull()
144
+ expect(getSelectedSymbolImageId('symbol-unseen')).toBeNull()
145
+ })
146
+
147
+ it('is cleared by clearSymbolImageCache', async () => {
148
+ const symbolRegistry = makeSymbolRegistry()
149
+ await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
150
+ clearSymbolImageCache()
151
+ expect(getActiveSymbolImageId('symbol-pin-1x')).toBeNull()
152
+ expect(getSelectedSymbolImageId('symbol-pin-1x')).toBeNull()
153
+ })
154
+ })
155
+
156
+ describe('registerSymbols', () => {
157
+ it('registers every config in parallel', async () => {
158
+ const symbolRegistry = makeSymbolRegistry()
159
+ await registerSymbols([{ symbol: 'pin' }, { symbol: 'flag' }], MAP_STYLE, symbolRegistry, 1)
160
+ expect(symbolRegistry.rasteriseSymbolImage).toHaveBeenCalledTimes(6) // 2 symbols × normal/active/selected
161
+ })
162
+
163
+ it('is a no-op for an empty array', async () => {
164
+ const symbolRegistry = makeSymbolRegistry()
165
+ await registerSymbols([], MAP_STYLE, symbolRegistry, 1)
166
+ expect(symbolRegistry.rasteriseSymbolImage).not.toHaveBeenCalled()
167
+ })
58
168
  })
@@ -1,5 +1,9 @@
1
- import VectorTileLayer from 'ol/layer/VectorTile.js'
2
1
  import TileState from 'ol/TileState.js'
2
+ import GeoJSON from 'ol/format/GeoJSON.js'
3
+
4
+ const CRS = 'EPSG:27700'
5
+ const geoJsonFormat = new GeoJSON({ dataProjection: CRS, featureProjection: CRS })
6
+
3
7
  const toPairs = (flat, start, end) => {
4
8
  const coords = []
5
9
  for (let i = start; i < end; i += 2) {
@@ -47,17 +51,24 @@ export const renderFeatureToGeoJSON = (feature) => {
47
51
  return { type, coordinates: toPairs(flat, 0, flat.length) }
48
52
  }
49
53
 
54
+ // draw-ol's basemap MVT tiles are RenderFeatures (no getGeometry()); tiles-backed datasets use
55
+ // real ol/Feature instances instead — branch on which shape this actually is.
56
+ const fragmentToGeoJSON = (feature) =>
57
+ feature.getGeometry ? geoJsonFormat.writeGeometryObject(feature.getGeometry()) : renderFeatureToGeoJSON(feature)
58
+
50
59
  /**
51
- * Collects all loaded VT tile fragments for a feature across all VectorTileLayers.
52
- * OL clips VT features at tile boundaries, so a single logical feature appears as
53
- * multiple RenderFeature instances (one per tile). This gathers them all so callers
54
- * can work with the full feature geometry rather than a single clipped fragment.
60
+ * Collects all loaded VT tile fragments for a feature across all VectorTile/WebGLVectorTile
61
+ * layers. OL clips VT features at tile boundaries, so a single logical feature appears as
62
+ * multiple feature instances (one per tile). This gathers them all so callers can work with
63
+ * the full feature geometry rather than a single clipped fragment.
55
64
  */
56
65
  export const collectTileFragments = (map, layerId, featureId, idProperty) => {
57
66
  const fragments = []
58
67
 
59
68
  map.getLayers().forEach(mapLayer => {
60
- if (!(mapLayer instanceof VectorTileLayer)) {
69
+ // Tagged with a 'layerType' property rather than checked via `instanceof VectorTileLayer`,
70
+ // since a UMD consumer's independently-bundled ol copy would never match this one's class.
71
+ if (mapLayer.get('layerType') !== 'vectorTile') {
61
72
  return
62
73
  }
63
74
  const source = mapLayer.getSource()
@@ -70,14 +81,15 @@ export const collectTileFragments = (map, layerId, featureId, idProperty) => {
70
81
  return
71
82
  }
72
83
  tile.getFeatures().forEach(feature => {
73
- if (feature.get('mapbox-layer')?.id !== layerId) {
84
+ const styleLayerId = feature.get('mapbox-layer')?.id ?? mapLayer.get('layerId')
85
+ if (styleLayerId !== layerId) {
74
86
  return
75
87
  }
76
88
  const fid = idProperty ? feature.get(idProperty) : feature.getId()
77
89
  if (String(fid) !== String(featureId)) {
78
90
  return
79
91
  }
80
- fragments.push(renderFeatureToGeoJSON(feature))
92
+ fragments.push(fragmentToGeoJSON(feature))
81
93
  })
82
94
  })
83
95
  })
@@ -0,0 +1,162 @@
1
+ import TileState from 'ol/TileState.js'
2
+ import { renderFeatureToGeoJSON, collectTileFragments } from './vtTileFragments.js'
3
+
4
+ jest.mock('ol/TileState.js', () => ({ __esModule: true, default: { LOADED: 'loaded', LOADING: 'loading' } }))
5
+ jest.mock('ol/format/GeoJSON.js', () => ({
6
+ __esModule: true,
7
+ default: jest.fn(() => ({
8
+ writeGeometryObject: jest.fn(geom => ({ type: 'mock-real-feature', geom }))
9
+ }))
10
+ }))
11
+
12
+ describe('renderFeatureToGeoJSON', () => {
13
+ const makeRenderFeature = (type, flat, extra = {}) => ({
14
+ getType: () => type,
15
+ getFlatCoordinates: () => flat,
16
+ ...extra
17
+ })
18
+
19
+ it('converts a Point', () => {
20
+ const feature = makeRenderFeature('Point', [1, 2])
21
+ expect(renderFeatureToGeoJSON(feature)).toEqual({ type: 'Point', coordinates: [1, 2] })
22
+ })
23
+
24
+ it('converts a LineString', () => {
25
+ const feature = makeRenderFeature('LineString', [0, 0, 1, 1, 2, 2])
26
+ expect(renderFeatureToGeoJSON(feature)).toEqual({ type: 'LineString', coordinates: [[0, 0], [1, 1], [2, 2]] })
27
+ })
28
+
29
+ it('converts a Polygon using getEnds for ring boundaries', () => {
30
+ const flat = [0, 0, 1, 0, 1, 1, 0, 0]
31
+ const feature = makeRenderFeature('Polygon', flat, { getEnds: () => [8] })
32
+ expect(renderFeatureToGeoJSON(feature)).toEqual({
33
+ type: 'Polygon',
34
+ coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]]
35
+ })
36
+ })
37
+
38
+ it('converts a Polygon with a hole (two rings)', () => {
39
+ const outer = [0, 0, 4, 0, 4, 4, 0, 0]
40
+ const hole = [1, 1, 2, 1, 2, 2, 1, 1]
41
+ const flat = [...outer, ...hole]
42
+ const feature = makeRenderFeature('Polygon', flat, { getEnds: () => [8, 16] })
43
+ const result = renderFeatureToGeoJSON(feature)
44
+ expect(result.coordinates).toHaveLength(2)
45
+ expect(result.coordinates[1]).toEqual([[1, 1], [2, 1], [2, 2], [1, 1]])
46
+ })
47
+
48
+ it('converts a MultiPolygon using getEndss for polygon/ring boundaries', () => {
49
+ const poly1 = [0, 0, 1, 0, 1, 1, 0, 0]
50
+ const poly2 = [2, 2, 3, 2, 3, 3, 2, 2]
51
+ const flat = [...poly1, ...poly2]
52
+ const feature = makeRenderFeature('MultiPolygon', flat, { getEndss: () => [[8], [16]] })
53
+ const result = renderFeatureToGeoJSON(feature)
54
+ expect(result.coordinates).toEqual([
55
+ [[[0, 0], [1, 0], [1, 1], [0, 0]]],
56
+ [[[2, 2], [3, 2], [3, 3], [2, 2]]]
57
+ ])
58
+ })
59
+
60
+ it('falls back to a flat pairs conversion for MultiPoint/MultiLineString', () => {
61
+ const feature = makeRenderFeature('MultiPoint', [0, 0, 1, 1])
62
+ expect(renderFeatureToGeoJSON(feature)).toEqual({ type: 'MultiPoint', coordinates: [[0, 0], [1, 1]] })
63
+ })
64
+ })
65
+
66
+ describe('collectTileFragments', () => {
67
+ const makeTile = (features, state = TileState.LOADED) => ({
68
+ getState: () => state,
69
+ getFeatures: () => features
70
+ })
71
+
72
+ const makeVtLayer = (layerId, sourceTiles) => ({
73
+ get: (key) => {
74
+ if (key === 'layerType') return 'vectorTile'
75
+ if (key === 'layerId') return layerId
76
+ return undefined
77
+ },
78
+ getSource: () => ({ sourceTiles_: sourceTiles })
79
+ })
80
+
81
+ const makeMap = (layers) => ({ getLayers: () => ({ forEach: cb => layers.forEach(cb) }) })
82
+
83
+ // draw-ol's basemap MVT convention: a RenderFeature with a 'mapbox-layer' object.
84
+ const makeMapboxFeature = (styleLayerId, id, flat = [1, 2]) => ({
85
+ get: (key) => key === 'mapbox-layer' ? { id: styleLayerId } : undefined,
86
+ getId: () => id,
87
+ getType: () => 'Point',
88
+ getFlatCoordinates: () => flat
89
+ })
90
+
91
+ // datasets-plugin tiles-backed convention: a real ol/Feature, no 'mapbox-layer', identified
92
+ // purely by which OL *layer* it came from (mapLayer.get('layerId')).
93
+ const makeDatasetFeature = (id) => ({
94
+ get: () => undefined,
95
+ getId: () => id,
96
+ getGeometry: () => ({ type: 'Point', coordinates: [3, 4] })
97
+ })
98
+
99
+ it('returns fragments for a matching mapbox-layer (basemap MVT) feature', () => {
100
+ const feature = makeMapboxFeature('roads', 1)
101
+ const layer = makeVtLayer(undefined, { a: makeTile([feature]) })
102
+ const result = collectTileFragments(makeMap([layer]), 'roads', 1)
103
+ expect(result).toEqual([{ type: 'Point', coordinates: [1, 2] }])
104
+ })
105
+
106
+ it('returns fragments for a matching datasets-plugin tiles-backed feature, via the layer\'s own layerId', () => {
107
+ const feature = makeDatasetFeature('f1')
108
+ const layer = makeVtLayer('existing-fields', { a: makeTile([feature]) })
109
+ const result = collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')
110
+ expect(result).toEqual([{ type: 'mock-real-feature', geom: { type: 'Point', coordinates: [3, 4] } }])
111
+ })
112
+
113
+ it('does not call the RenderFeature-only conversion for a real ol/Feature', () => {
114
+ // A real ol/Feature has no getFlatCoordinates/getType — calling renderFeatureToGeoJSON on
115
+ // it would throw. This proves collectTileFragments takes the getGeometry() branch instead.
116
+ const feature = makeDatasetFeature('f1')
117
+ const layer = makeVtLayer('existing-fields', { a: makeTile([feature]) })
118
+ expect(() => collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')).not.toThrow()
119
+ })
120
+
121
+ it('matches a dataset feature using idProperty when given', () => {
122
+ const feature = { get: (k) => k === 'ref' ? 'abc' : undefined, getGeometry: () => ({ type: 'Point', coordinates: [0, 0] }) }
123
+ const layer = makeVtLayer('existing-fields', { a: makeTile([feature]) })
124
+ const result = collectTileFragments(makeMap([layer]), 'existing-fields', 'abc', 'ref')
125
+ expect(result).toHaveLength(1)
126
+ })
127
+
128
+ it('skips a feature whose styleLayerId does not match', () => {
129
+ const feature = makeDatasetFeature('f1')
130
+ const layer = makeVtLayer('hedge-control', { a: makeTile([feature]) })
131
+ expect(collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')).toEqual([])
132
+ })
133
+
134
+ it('skips a feature whose id does not match', () => {
135
+ const feature = makeDatasetFeature('other-id')
136
+ const layer = makeVtLayer('existing-fields', { a: makeTile([feature]) })
137
+ expect(collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')).toEqual([])
138
+ })
139
+
140
+ it('skips tiles that are not loaded', () => {
141
+ const feature = makeDatasetFeature('f1')
142
+ const layer = makeVtLayer('existing-fields', { a: makeTile([feature], TileState.LOADING) })
143
+ expect(collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')).toEqual([])
144
+ })
145
+
146
+ it('skips layers with no loaded tiles yet', () => {
147
+ const layer = makeVtLayer('existing-fields', undefined)
148
+ expect(collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')).toEqual([])
149
+ })
150
+
151
+ it('skips layers that are not vectorTile-tagged', () => {
152
+ const otherLayer = { get: () => 'vector', getSource: () => ({}) }
153
+ expect(collectTileFragments(makeMap([otherLayer]), 'existing-fields', 'f1')).toEqual([])
154
+ })
155
+
156
+ it('collects fragments split across multiple loaded tiles', () => {
157
+ const feature1 = makeDatasetFeature('f1')
158
+ const feature2 = makeDatasetFeature('f1')
159
+ const layer = makeVtLayer('existing-fields', { a: makeTile([feature1]), b: makeTile([feature2]) })
160
+ expect(collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')).toHaveLength(2)
161
+ })
162
+ })