@defra/interactive-map 0.0.17-alpha → 0.0.19-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.
Files changed (185) hide show
  1. package/assets/css/docusaurus.css +58 -34
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/context.md +53 -7
  8. package/docs/api/map-style-config.md +41 -2
  9. package/docs/api/marker-config.md +53 -11
  10. package/docs/api/panel-definition.md +16 -0
  11. package/docs/api/symbol-config.md +160 -0
  12. package/docs/api/symbol-registry.md +115 -0
  13. package/docs/api.md +50 -23
  14. package/docs/assets/basic-map.jpg +0 -0
  15. package/docs/assets/button-first.jpg +0 -0
  16. package/docs/assets/maker-panel.jpg +0 -0
  17. package/docs/examples/add-marker-with-panel.mdx +59 -0
  18. package/docs/examples/basic-map.mdx +24 -0
  19. package/docs/examples/button-map.mdx +24 -0
  20. package/docs/examples/index.mdx +49 -0
  21. package/docs/index.mdx +1 -1
  22. package/docs/plugins/datasets.md +105 -9
  23. package/docs/plugins/interact.md +100 -44
  24. package/docs/plugins/search.md +15 -3
  25. package/docs/plugins.md +1 -1
  26. package/docusaurus.config.cjs +9 -1
  27. package/package.json +1 -1
  28. package/plugins/beta/datasets/dist/css/index.css +32 -14
  29. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  31. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  32. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  33. package/plugins/beta/datasets/src/DatasetsInit.jsx +9 -4
  34. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +57 -11
  35. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +14 -8
  36. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +155 -53
  37. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  38. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  39. package/plugins/beta/datasets/src/api/addDataset.js +1 -1
  40. package/plugins/beta/datasets/src/api/setData.js +4 -2
  41. package/plugins/beta/datasets/src/api/setStyle.js +2 -2
  42. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  43. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  44. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  45. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  46. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  47. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  48. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  49. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  50. package/plugins/beta/datasets/src/datasets.js +13 -4
  51. package/plugins/beta/datasets/src/defaults.js +4 -2
  52. package/plugins/beta/datasets/src/index.js +2 -1
  53. package/plugins/beta/datasets/src/manifest.js +1 -1
  54. package/plugins/beta/datasets/src/panels/Key.jsx +11 -89
  55. package/plugins/beta/datasets/src/panels/Key.module.scss +24 -13
  56. package/plugins/beta/datasets/src/panels/Layers.module.scss +13 -7
  57. package/plugins/beta/datasets/src/reducer.js +6 -0
  58. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  59. package/plugins/beta/datasets/src/utils/mergeSublayer.js +8 -0
  60. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  61. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  62. package/plugins/beta/draw-ml/dist/css/index.css +3 -0
  63. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  64. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  65. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  66. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  67. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  68. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  69. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  70. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  71. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  72. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  73. package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
  74. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  75. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  76. package/plugins/interact/dist/umd/index.js +1 -1
  77. package/plugins/interact/src/InteractInit.jsx +19 -8
  78. package/plugins/interact/src/InteractInit.test.js +26 -6
  79. package/plugins/interact/src/api/clear.js +1 -1
  80. package/plugins/interact/src/api/enable.test.js +7 -7
  81. package/plugins/interact/src/api/selectMarker.js +14 -0
  82. package/plugins/interact/src/api/selectMarker.test.js +25 -0
  83. package/plugins/interact/src/api/unselectMarker.js +14 -0
  84. package/plugins/interact/src/api/unselectMarker.test.js +14 -0
  85. package/plugins/interact/src/defaults.js +4 -6
  86. package/plugins/interact/src/events.js +27 -36
  87. package/plugins/interact/src/events.test.js +119 -90
  88. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  89. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  90. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  91. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  92. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  93. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  94. package/plugins/interact/src/manifest.js +10 -2
  95. package/plugins/interact/src/reducer.js +59 -5
  96. package/plugins/interact/src/reducer.test.js +100 -12
  97. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  98. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  99. package/plugins/interact/src/utils/featureQueries.js +11 -6
  100. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  101. package/plugins/interact/src/utils/interactionModes.js +12 -0
  102. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  103. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  104. package/plugins/search/src/Search.jsx +3 -1
  105. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  106. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  107. package/plugins/search/src/events/formHandlers.js +3 -3
  108. package/plugins/search/src/events/formHandlers.test.js +1 -1
  109. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  110. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  111. package/plugins/search/src/utils/updateMap.js +3 -3
  112. package/plugins/search/src/utils/updateMap.test.js +3 -3
  113. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  114. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  115. package/providers/maplibre/dist/umd/index.js +1 -1
  116. package/providers/maplibre/src/appEvents.js +7 -0
  117. package/providers/maplibre/src/appEvents.test.js +18 -4
  118. package/providers/maplibre/src/maplibreProvider.js +52 -0
  119. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  120. package/providers/maplibre/src/utils/highlightFeatures.js +36 -7
  121. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -96
  122. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  123. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  124. package/providers/maplibre/src/utils/patternImages.js +70 -0
  125. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  126. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  127. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  128. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  129. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  130. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  131. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  132. package/src/App/components/Markers/Markers.jsx +122 -27
  133. package/src/App/components/Markers/Markers.module.scss +0 -10
  134. package/src/App/components/Markers/Markers.test.jsx +246 -0
  135. package/src/App/components/Panel/Panel.jsx +6 -6
  136. package/src/App/components/Panel/Panel.test.jsx +37 -0
  137. package/src/App/components/Viewport/Viewport.jsx +5 -15
  138. package/src/App/components/Viewport/Viewport.module.scss +2 -0
  139. package/src/App/components/Viewport/Viewport.test.jsx +16 -33
  140. package/src/App/hooks/useInterfaceAPI.js +7 -7
  141. package/src/App/hooks/useInterfaceAPI.test.js +162 -0
  142. package/src/App/hooks/useLayoutMeasurements.js +64 -72
  143. package/src/App/hooks/useMarkersAPI.js +2 -5
  144. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  145. package/src/App/layout/Layout.jsx +3 -3
  146. package/src/App/layout/Layout.test.jsx +4 -2
  147. package/src/App/layout/layout.module.scss +1 -8
  148. package/src/App/renderer/HtmlElementHost.jsx +10 -5
  149. package/src/App/renderer/mapPanels.js +2 -1
  150. package/src/App/store/ServiceProvider.jsx +7 -5
  151. package/src/App/store/appActionsMap.js +4 -4
  152. package/src/App/store/appActionsMap.test.js +10 -0
  153. package/src/App/store/mapActionsMap.js +4 -6
  154. package/src/App/store/mapActionsMap.test.js +3 -2
  155. package/src/App/store/mapReducer.js +2 -1
  156. package/src/InteractiveMap/InteractiveMap.js +59 -11
  157. package/src/InteractiveMap/InteractiveMap.test.js +126 -4
  158. package/src/InteractiveMap/domStateManager.js +18 -6
  159. package/src/InteractiveMap/domStateManager.test.js +21 -0
  160. package/src/InteractiveMap/historyManager.js +28 -16
  161. package/src/InteractiveMap/historyManager.test.js +17 -0
  162. package/src/config/appConfig.js +2 -7
  163. package/src/config/appConfig.test.js +4 -15
  164. package/src/config/defaults.js +2 -3
  165. package/src/config/events.js +20 -21
  166. package/src/config/mapTheme.js +56 -0
  167. package/src/config/patternConfig.js +16 -0
  168. package/src/config/symbolConfig.js +80 -0
  169. package/src/scss/settings/_colors.scss +0 -9
  170. package/src/services/closeApp.js +1 -10
  171. package/src/services/closeApp.test.js +3 -43
  172. package/src/services/patternRegistry.js +40 -0
  173. package/src/services/patternRegistry.test.js +48 -0
  174. package/src/services/symbolRegistry.js +113 -0
  175. package/src/services/symbolRegistry.test.js +262 -0
  176. package/src/types.js +99 -12
  177. package/src/utils/mapStateSync.js +48 -10
  178. package/src/utils/mapStateSync.test.js +29 -9
  179. package/src/utils/patternUtils.js +94 -0
  180. package/src/utils/patternUtils.test.js +160 -0
  181. package/src/utils/symbolUtils.js +85 -0
  182. package/src/utils/symbolUtils.test.js +156 -0
  183. package/docs/examples.mdx +0 -70
  184. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +0 -48
  185. package/plugins/beta/datasets/src/styles/patterns.js +0 -157
@@ -1,7 +1,10 @@
1
1
  import { applyExclusionFilter } from '../../utils/filters.js'
2
2
  import { getSourceId, getLayerIds, getSublayerLayerIds, getAllLayerIds } from './layerIds.js'
3
3
  import { addDatasetLayers, addSublayerLayers } from './layerBuilders.js'
4
- import { registerPatterns } from './patternRegistry.js'
4
+ import { getPatternConfigs } from './patternImages.js'
5
+ import { getSymbolConfigs, getSymbolImageId } from './symbolImages.js'
6
+ import { mergeSublayer } from '../../utils/mergeSublayer.js'
7
+ import { scaleFactor } from '../../../../../../src/config/appConfig.js'
5
8
 
6
9
  /**
7
10
  * MapLibre GL JS implementation of the LayerAdapter interface for the datasets plugin.
@@ -11,12 +14,25 @@ import { registerPatterns } from './patternRegistry.js'
11
14
  * - Pattern image registration (delegated to patternRegistry)
12
15
  * - Visibility toggling, feature filtering, style changes
13
16
  * - Style-change recovery (re-adding layers after basemap swap)
17
+ *
18
+ * Symbol image rasterisation is delegated to the map provider via
19
+ * `mapProvider.registerSymbols()`, keeping this adapter free of provider internals.
14
20
  */
15
21
  export default class MaplibreLayerAdapter {
16
- constructor (map) {
17
- this._map = map
22
+ /**
23
+ * @param {Object} mapProvider - Map provider instance (e.g. MapLibreProvider)
24
+ * @param {Object} symbolRegistry
25
+ * @param {Object} patternRegistry
26
+ */
27
+ constructor (mapProvider, symbolRegistry, patternRegistry) {
28
+ this._mapProvider = mapProvider
29
+ this._map = mapProvider.map
30
+ this._symbolRegistry = symbolRegistry
31
+ this._patternRegistry = patternRegistry
18
32
  // datasetId → sourceId, used by setData to update the correct source
19
33
  this._datasetSourceMap = new Map()
34
+ // Tracks all active symbol-type layer IDs so non-symbol layers can be kept below them
35
+ this._symbolLayerIds = new Set()
20
36
  }
21
37
 
22
38
  // ─── Lifecycle ──────────────────────────────────────────────────────────────
@@ -24,12 +40,18 @@ export default class MaplibreLayerAdapter {
24
40
  /**
25
41
  * Initialise all datasets: register patterns, add layers, then wait for idle.
26
42
  * @param {Object[]} datasets
27
- * @param {string} mapStyleId
43
+ * @param {Object} mapStyle
28
44
  * @returns {Promise<void>} Resolves once the map has processed all layers.
29
45
  */
30
- async init (datasets, mapStyleId) {
31
- await registerPatterns(this._map, datasets, mapStyleId)
32
- datasets.forEach(dataset => this._addLayers(dataset, mapStyleId))
46
+ async init (datasets, mapStyle) {
47
+ const mapStyleId = mapStyle.id
48
+ const pixelRatio = this._pixelRatio
49
+ await Promise.all([
50
+ this._mapProvider.registerPatterns(getPatternConfigs(datasets, this._patternRegistry), mapStyleId, this._patternRegistry),
51
+ this._mapProvider.registerSymbols(getSymbolConfigs(datasets), mapStyle, this._symbolRegistry)
52
+ ])
53
+ this._symbolLayerIds.clear()
54
+ datasets.forEach(dataset => this._addLayers(dataset, mapStyle, pixelRatio))
33
55
  await new Promise(resolve => this._map.once('idle', resolve))
34
56
  }
35
57
 
@@ -58,17 +80,23 @@ export default class MaplibreLayerAdapter {
58
80
  * Re-register patterns and re-add all layers after a basemap style change,
59
81
  * then reapply cached dynamic source data and hidden-feature filters.
60
82
  * @param {Object[]} datasets
61
- * @param {string} newStyleId
83
+ * @param {Object} newMapStyle
62
84
  * @param {Object} hiddenFeatures - pluginState.hiddenFeatures
63
85
  * @param {Map} dynamicSources - datasetId → dynamic source instance
64
86
  * @returns {Promise<void>}
65
87
  */
66
- async onStyleChange (datasets, newStyleId, hiddenFeatures, dynamicSources) {
88
+ async onStyleChange (datasets, newMapStyle, hiddenFeatures, dynamicSources) {
67
89
  // MapLibre wipes all sources/layers on style change — must wait for idle first
68
90
  await new Promise(resolve => this._map.once('idle', resolve))
69
91
 
70
- await registerPatterns(this._map, datasets, newStyleId)
71
- datasets.forEach(dataset => this._addLayers(dataset, newStyleId))
92
+ const newStyleId = newMapStyle.id
93
+ const pixelRatio = this._pixelRatio
94
+ await Promise.all([
95
+ this._mapProvider.registerPatterns(getPatternConfigs(datasets, this._patternRegistry), newStyleId, this._patternRegistry),
96
+ this._mapProvider.registerSymbols(getSymbolConfigs(datasets), newMapStyle, this._symbolRegistry)
97
+ ])
98
+ this._symbolLayerIds.clear()
99
+ datasets.forEach(dataset => this._addLayers(dataset, newMapStyle, pixelRatio))
72
100
 
73
101
  // Re-push cached data for dynamic sources
74
102
  dynamicSources.forEach(source => source.reapply())
@@ -83,15 +111,45 @@ export default class MaplibreLayerAdapter {
83
111
  })
84
112
  }
85
113
 
114
+ /**
115
+ * Re-register symbols at the new pixel ratio and update icon-image on all symbol layers.
116
+ * Called when the map size changes so symbols are rasterised at the correct resolution.
117
+ * @param {Object[]} datasets
118
+ * @param {Object} mapStyle
119
+ * @returns {Promise<void>}
120
+ */
121
+ async onSizeChange (datasets, mapStyle) {
122
+ await this._mapProvider.registerSymbols(getSymbolConfigs(datasets), mapStyle, this._symbolRegistry)
123
+ const pixelRatio = this._pixelRatio
124
+ datasets.forEach(dataset => {
125
+ getAllLayerIds(dataset).forEach(layerId => {
126
+ if (!this._symbolLayerIds.has(layerId) || !this._map.getLayer(layerId)) { return }
127
+ const imageId = getSymbolImageId(dataset, mapStyle, this._symbolRegistry, false, pixelRatio)
128
+ if (imageId) {
129
+ this._map.setLayoutProperty(layerId, 'icon-image', imageId)
130
+ }
131
+ })
132
+ dataset.sublayers?.forEach(sublayer => {
133
+ const { symbolLayerId } = getSublayerLayerIds(dataset.id, sublayer.id)
134
+ if (!this._map.getLayer(symbolLayerId)) { return }
135
+ const merged = mergeSublayer(dataset, sublayer)
136
+ const imageId = getSymbolImageId(merged, mapStyle, this._symbolRegistry, false, pixelRatio)
137
+ if (imageId) {
138
+ this._map.setLayoutProperty(symbolLayerId, 'icon-image', imageId)
139
+ }
140
+ })
141
+ })
142
+ }
143
+
86
144
  // ─── Dataset operations ─────────────────────────────────────────────────────
87
145
 
88
146
  /**
89
147
  * Add a single dataset's source and layers to the map.
90
148
  * @param {Object} dataset
91
- * @param {string} mapStyleId
149
+ * @param {Object} mapStyle
92
150
  */
93
- addDataset (dataset, mapStyleId) {
94
- this._addLayers(dataset, mapStyleId)
151
+ addDataset (dataset, mapStyle) {
152
+ this._addLayers(dataset, mapStyle, this._pixelRatio)
95
153
  }
96
154
 
97
155
  /**
@@ -108,6 +166,7 @@ export default class MaplibreLayerAdapter {
108
166
  if (this._map.getLayer(layerId)) {
109
167
  this._map.removeLayer(layerId)
110
168
  }
169
+ this._symbolLayerIds.delete(layerId)
111
170
  })
112
171
 
113
172
  const sourceIsShared = allDatasets.some(d => d.id !== dataset.id && getSourceId(d) === sourceId)
@@ -141,13 +200,12 @@ export default class MaplibreLayerAdapter {
141
200
  * @param {string} sublayerId
142
201
  */
143
202
  showSublayer (datasetId, sublayerId) {
144
- const { fillLayerId, strokeLayerId } = getSublayerLayerIds(datasetId, sublayerId)
145
- if (this._map.getLayer(fillLayerId)) {
146
- this._map.setLayoutProperty(fillLayerId, 'visibility', 'visible')
147
- }
148
- if (this._map.getLayer(strokeLayerId)) {
149
- this._map.setLayoutProperty(strokeLayerId, 'visibility', 'visible')
150
- }
203
+ const { fillLayerId, strokeLayerId, symbolLayerId } = getSublayerLayerIds(datasetId, sublayerId)
204
+ ;[fillLayerId, strokeLayerId, symbolLayerId].forEach(layerId => {
205
+ if (this._map.getLayer(layerId)) {
206
+ this._map.setLayoutProperty(layerId, 'visibility', 'visible')
207
+ }
208
+ })
151
209
  }
152
210
 
153
211
  /**
@@ -156,13 +214,12 @@ export default class MaplibreLayerAdapter {
156
214
  * @param {string} sublayerId
157
215
  */
158
216
  hideSublayer (datasetId, sublayerId) {
159
- const { fillLayerId, strokeLayerId } = getSublayerLayerIds(datasetId, sublayerId)
160
- if (this._map.getLayer(fillLayerId)) {
161
- this._map.setLayoutProperty(fillLayerId, 'visibility', 'none')
162
- }
163
- if (this._map.getLayer(strokeLayerId)) {
164
- this._map.setLayoutProperty(strokeLayerId, 'visibility', 'none')
165
- }
217
+ const { fillLayerId, strokeLayerId, symbolLayerId } = getSublayerLayerIds(datasetId, sublayerId)
218
+ ;[fillLayerId, strokeLayerId, symbolLayerId].forEach(layerId => {
219
+ if (this._map.getLayer(layerId)) {
220
+ this._map.setLayoutProperty(layerId, 'visibility', 'none')
221
+ }
222
+ })
166
223
  }
167
224
 
168
225
  // ─── Feature operations ─────────────────────────────────────────────────────
@@ -190,42 +247,54 @@ export default class MaplibreLayerAdapter {
190
247
  /**
191
248
  * Update a dataset's style and re-render all its layers.
192
249
  * @param {Object} dataset - Updated dataset (style changes already merged in)
193
- * @param {string} mapStyleId
250
+ * @param {Object} mapStyle
194
251
  * @returns {Promise<void>}
195
252
  */
196
- async setStyle (dataset, mapStyleId) {
253
+ async setStyle (dataset, mapStyle) {
254
+ const mapStyleId = mapStyle.id
255
+ const pixelRatio = this._pixelRatio
197
256
  getAllLayerIds(dataset).forEach(layerId => {
198
257
  if (this._map.getLayer(layerId)) {
199
258
  this._map.removeLayer(layerId)
200
259
  }
260
+ this._symbolLayerIds.delete(layerId)
201
261
  })
202
- await registerPatterns(this._map, [dataset], mapStyleId)
203
- this._addLayers(dataset, mapStyleId)
262
+ await Promise.all([
263
+ this._mapProvider.registerPatterns(getPatternConfigs([dataset], this._patternRegistry), mapStyleId, this._patternRegistry),
264
+ this._mapProvider.registerSymbols(getSymbolConfigs([dataset]), mapStyle, this._symbolRegistry)
265
+ ])
266
+ this._addLayers(dataset, mapStyle, pixelRatio)
204
267
  }
205
268
 
206
269
  /**
207
270
  * Update a single sublayer's style and re-render its layers.
208
271
  * @param {Object} dataset - Updated dataset (sublayer style changes already merged in)
209
272
  * @param {string} sublayerId
210
- * @param {string} mapStyleId
273
+ * @param {Object} mapStyle
211
274
  * @returns {Promise<void>}
212
275
  */
213
- async setSublayerStyle (dataset, sublayerId, mapStyleId) {
214
- const { fillLayerId, strokeLayerId } = getSublayerLayerIds(dataset.id, sublayerId)
215
- if (this._map.getLayer(fillLayerId)) {
216
- this._map.removeLayer(fillLayerId)
217
- }
218
- if (this._map.getLayer(strokeLayerId)) {
219
- this._map.removeLayer(strokeLayerId)
220
- }
276
+ async setSublayerStyle (dataset, sublayerId, mapStyle) {
277
+ const mapStyleId = mapStyle.id
278
+ const pixelRatio = this._pixelRatio
279
+ const { fillLayerId, strokeLayerId, symbolLayerId } = getSublayerLayerIds(dataset.id, sublayerId)
280
+ ;[fillLayerId, strokeLayerId, symbolLayerId].forEach(layerId => {
281
+ if (this._map.getLayer(layerId)) {
282
+ this._map.removeLayer(layerId)
283
+ }
284
+ this._symbolLayerIds.delete(layerId)
285
+ })
221
286
  const sublayer = dataset.sublayers?.find(s => s.id === sublayerId)
222
287
  if (!sublayer) {
223
288
  return
224
289
  }
225
- await registerPatterns(this._map, [dataset], mapStyleId)
290
+ await Promise.all([
291
+ this._mapProvider.registerPatterns(getPatternConfigs([dataset], this._patternRegistry), mapStyleId, this._patternRegistry),
292
+ this._mapProvider.registerSymbols(getSymbolConfigs([dataset]), mapStyle, this._symbolRegistry)
293
+ ])
226
294
  const sourceId = this._datasetSourceMap.get(dataset.id)
227
295
  const sourceLayer = dataset.tiles?.length ? dataset.sourceLayer : undefined
228
- addSublayerLayers(this._map, dataset, sublayer, sourceId, sourceLayer, mapStyleId)
296
+ addSublayerLayers(this._map, dataset, sublayer, sourceId, sourceLayer, { mapStyle, symbolRegistry: this._symbolRegistry, patternRegistry: this._patternRegistry, pixelRatio })
297
+ this._maintainSymbolOrdering(dataset)
229
298
  }
230
299
 
231
300
  /**
@@ -275,9 +344,43 @@ export default class MaplibreLayerAdapter {
275
344
 
276
345
  // ─── Private ─────────────────────────────────────────────────────────────────
277
346
 
278
- _addLayers (dataset, mapStyleId) {
279
- const sourceId = addDatasetLayers(this._map, dataset, mapStyleId)
347
+ get _pixelRatio () {
348
+ return this._mapProvider.map.getPixelRatio() * (scaleFactor[this._mapProvider.mapSize] || 1)
349
+ }
350
+
351
+ _addLayers (dataset, mapStyle, pixelRatio) {
352
+ const sourceId = addDatasetLayers(this._map, dataset, mapStyle, this._symbolRegistry, this._patternRegistry, pixelRatio)
280
353
  this._datasetSourceMap.set(dataset.id, sourceId)
354
+ this._maintainSymbolOrdering(dataset)
355
+ }
356
+
357
+ _getFirstSymbolLayerId () {
358
+ const style = this._map.getStyle()
359
+ if (!style?.layers) {
360
+ return null
361
+ }
362
+ const layer = style.layers.find(l => this._symbolLayerIds.has(l.id))
363
+ return layer?.id ?? null
364
+ }
365
+
366
+ _maintainSymbolOrdering (dataset) {
367
+ const layerIds = getAllLayerIds(dataset).filter(id => id && this._map.getLayer(id))
368
+ layerIds.forEach(id => {
369
+ if (this._map.getLayer(id)?.type === 'symbol') {
370
+ this._symbolLayerIds.add(id)
371
+ } else {
372
+ this._symbolLayerIds.delete(id)
373
+ }
374
+ })
375
+ const firstSymbolId = this._getFirstSymbolLayerId()
376
+ if (!firstSymbolId) {
377
+ return
378
+ }
379
+ layerIds.forEach(id => {
380
+ if (!this._symbolLayerIds.has(id)) {
381
+ this._map.moveLayer(id, firstSymbolId)
382
+ }
383
+ })
281
384
  }
282
385
 
283
386
  _setDatasetVisibility (datasetId, visibility) {
@@ -307,14 +410,12 @@ export default class MaplibreLayerAdapter {
307
410
  })
308
411
  return
309
412
  }
310
- const { fillLayerId, strokeLayerId } = getLayerIds(dataset)
413
+ const { fillLayerId, strokeLayerId, symbolLayerId } = getLayerIds(dataset)
311
414
  const originalFilter = dataset.filter || null
312
- if (fillLayerId) {
313
- applyExclusionFilter(this._map, fillLayerId, originalFilter, idProperty, excludeIds)
314
- }
315
- if (strokeLayerId) {
316
- applyExclusionFilter(this._map, strokeLayerId, originalFilter, idProperty, excludeIds)
317
- }
415
+ const layerIds = [fillLayerId, strokeLayerId, symbolLayerId].filter(Boolean)
416
+ layerIds.forEach(layerId => {
417
+ applyExclusionFilter(this._map, layerId, originalFilter, idProperty, excludeIds)
418
+ })
318
419
  }
319
420
 
320
421
  _setPaintOpacity (layerId, opacity) {
@@ -322,7 +423,8 @@ export default class MaplibreLayerAdapter {
322
423
  if (!layer) {
323
424
  return
324
425
  }
325
- const prop = layer.type === 'line' ? 'line-opacity' : 'fill-opacity'
426
+ const opacityProps = { line: 'line-opacity', symbol: 'icon-opacity' }
427
+ const prop = opacityProps[layer.type] || 'fill-opacity'
326
428
  this._map.setPaintProperty(layerId, prop, opacity)
327
429
  }
328
430
 
@@ -0,0 +1,27 @@
1
+ // Re-export pure pattern utilities from core — no map engine dependency.
2
+ import { hasPattern } from '../../../../../../src/utils/patternUtils.js'
3
+ import { mergeSublayer } from '../../utils/mergeSublayer.js'
4
+
5
+ export { hasPattern, getPatternInnerContent, getPatternImageId, getKeyPatternPaths, injectColors } from '../../../../../../src/utils/patternUtils.js'
6
+
7
+ /**
8
+ * Returns a flat list of datasets and merged sublayers that require pattern images.
9
+ * Handles sublayer merging so callers (e.g. mapProvider.registerPatterns) receive ready-to-use configs.
10
+ *
11
+ * @param {Object[]} datasets
12
+ * @param {Object} patternRegistry
13
+ * @returns {Object[]}
14
+ */
15
+ export const getPatternConfigs = (datasets, patternRegistry) =>
16
+ datasets.flatMap(dataset => {
17
+ const configs = hasPattern(dataset) ? [dataset] : []
18
+ if (dataset.sublayers?.length) {
19
+ dataset.sublayers.forEach(sublayer => {
20
+ const merged = mergeSublayer(dataset, sublayer)
21
+ if (hasPattern(merged)) {
22
+ configs.push(merged)
23
+ }
24
+ })
25
+ }
26
+ return configs
27
+ })
@@ -0,0 +1,31 @@
1
+ // Re-export pure symbol resolution utilities from core — no map engine dependency.
2
+ import { hasSymbol } from '../../../../../../src/utils/symbolUtils.js'
3
+ import { mergeSublayer } from '../../utils/mergeSublayer.js'
4
+
5
+ export { hasSymbol, getSymbolDef, getSymbolStyleColors, getSymbolViewBox, getSymbolAnchor } from '../../../../../../src/utils/symbolUtils.js'
6
+
7
+ // Re-export MapLibre-specific symbol utilities from the provider.
8
+ // This is the single cross-boundary import in the adapter; in a separate-package
9
+ // setup this would be: '@interactive-map/maplibre-provider/utils/symbolImages'
10
+ export { anchorToMaplibre, getSymbolImageId } from '../../../../../../providers/maplibre/src/utils/symbolImages.js'
11
+
12
+ /**
13
+ * Returns a flat list of datasets and merged sublayers that require symbol images.
14
+ * Handles sublayer merging so callers (e.g. mapProvider.registerSymbols) receive ready-to-use configs.
15
+ *
16
+ * @param {Object[]} datasets
17
+ * @returns {Object[]}
18
+ */
19
+ export const getSymbolConfigs = (datasets) =>
20
+ datasets.flatMap(dataset => {
21
+ const configs = hasSymbol(dataset) ? [dataset] : []
22
+ if (dataset.sublayers?.length) {
23
+ dataset.sublayers.forEach(sublayer => {
24
+ const merged = mergeSublayer(dataset, sublayer)
25
+ if (hasSymbol(merged)) {
26
+ configs.push(merged)
27
+ }
28
+ })
29
+ }
30
+ return configs
31
+ })
@@ -1,6 +1,6 @@
1
1
  import { datasetDefaults } from '../defaults.js'
2
2
 
3
3
  export const addDataset = ({ pluginState, mapState }, dataset) => {
4
- pluginState.layerAdapter?.addDataset({ ...datasetDefaults, ...dataset }, mapState.mapStyle.id)
4
+ pluginState.layerAdapter?.addDataset({ ...datasetDefaults, ...dataset }, mapState.mapStyle)
5
5
  pluginState.dispatch({ type: 'ADD_DATASET', payload: { dataset, datasetDefaults } })
6
6
  }
@@ -1,7 +1,9 @@
1
- export const setData = ({ pluginState, services }, geojson, { datasetId }) => {
1
+ import { logger } from '../../../../../src/services/logger.js'
2
+
3
+ export const setData = ({ pluginState }, geojson, { datasetId }) => {
2
4
  const dataset = pluginState.datasets?.find(d => d.id === datasetId)
3
5
  if (dataset?.tiles) {
4
- services.logger.warn(`setData called on vector tile dataset "${datasetId}" — has no effect`)
6
+ logger.warn(`setData called on vector tile dataset "${datasetId}" — has no effect`)
5
7
  return
6
8
  }
7
9
  pluginState.layerAdapter?.setData(datasetId, geojson)
@@ -12,11 +12,11 @@ export const setStyle = ({ pluginState, mapState }, style, { datasetId, sublayer
12
12
  sublayer.id === sublayerId ? { ...sublayer, style: { ...sublayer.style, ...style } } : sublayer
13
13
  )
14
14
  }
15
- pluginState.layerAdapter?.setSublayerStyle(updatedSublayerDataset, sublayerId, mapState.mapStyle.id)
15
+ pluginState.layerAdapter?.setSublayerStyle(updatedSublayerDataset, sublayerId, mapState.mapStyle)
16
16
  return
17
17
  }
18
18
 
19
19
  pluginState.dispatch({ type: 'SET_DATASET_STYLE', payload: { datasetId, styleChanges: style } })
20
20
  const updatedDataset = { ...dataset, ...style }
21
- pluginState.layerAdapter?.setStyle(updatedDataset, mapState.mapStyle.id)
21
+ pluginState.layerAdapter?.setStyle(updatedDataset, mapState.mapStyle)
22
22
  }
@@ -0,0 +1,7 @@
1
+ export const EmptyKey = ({ text }) => {
2
+ return (
3
+ <div className='im-c-datasets-key'>
4
+ <p className='im-c-datasets-key__empty-message'>{text}</p>
5
+ </div>
6
+ )
7
+ }
@@ -0,0 +1,21 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { EmptyKey } from './EmptyKey'
3
+
4
+ describe('EmptyKey', () => {
5
+ const text = 'No features available'
6
+
7
+ it('renders the wrapper div with the correct class', async () => {
8
+ const { container } = render(<EmptyKey text={text} />)
9
+ expect(container.querySelector('.im-c-datasets-key')).toBeTruthy()
10
+ })
11
+
12
+ it('renders the empty message paragraph with the correct class', async () => {
13
+ const { container } = render(<EmptyKey text={text} />)
14
+ expect(container.querySelector('.im-c-datasets-key__empty-message')).toBeTruthy()
15
+ })
16
+
17
+ it('renders the provided text', async () => {
18
+ render(<EmptyKey text={text} />)
19
+ expect(screen.getByText(text)).toBeTruthy()
20
+ })
21
+ })
@@ -0,0 +1,24 @@
1
+ import { hasSymbol, getSymbolDef } from '../../../../../src/utils/symbolUtils.js'
2
+ import { hasPattern } from '../../../../../src/utils/patternUtils.js'
3
+ import { KeySvgPattern } from './KeySvgPattern.jsx'
4
+ import { KeySvgSymbol } from './KeySvgSymbol.jsx'
5
+ import { KeySvgLine } from './KeySvgLine.jsx'
6
+ import { KeySvgRect } from './KeySvgRect.jsx'
7
+
8
+ export const KeySvg = (props) => {
9
+ const { symbolRegistry } = props
10
+ const symbolDef = hasSymbol(props) && getSymbolDef(props, symbolRegistry)
11
+ if (symbolDef) {
12
+ return <KeySvgSymbol {...props} symbolDef={symbolDef} />
13
+ }
14
+
15
+ if (hasPattern(props)) {
16
+ return <KeySvgPattern {...props} />
17
+ }
18
+
19
+ if (props.keySymbolShape === 'line') {
20
+ return <KeySvgLine {...props} />
21
+ }
22
+
23
+ return <KeySvgRect {...props} />
24
+ }
@@ -0,0 +1,19 @@
1
+ import { svgProps, SVG_SIZE, SVG_CENTER } from './svgProperties.js'
2
+ import { getValueForStyle } from '../../../../../src/utils/getValueForStyle'
3
+
4
+ export const KeySvgLine = (props) => {
5
+ const { mapStyle } = props
6
+ return (
7
+ <svg {...svgProps}>
8
+ <line
9
+ x1={props.strokeWidth / 2}
10
+ y1={SVG_CENTER}
11
+ x2={SVG_SIZE - props.strokeWidth / 2}
12
+ y2={SVG_CENTER}
13
+ stroke={getValueForStyle(props.stroke, mapStyle.id)}
14
+ strokeWidth={props.strokeWidth}
15
+ strokeLinecap='round'
16
+ />
17
+ </svg>
18
+ )
19
+ }
@@ -0,0 +1,15 @@
1
+ import { getKeyPatternPaths } from '../../../../../src/utils/patternUtils.js'
2
+ import { svgProps } from './svgProperties.js'
3
+ const PATTERN_INSET = 2
4
+
5
+ export const KeySvgPattern = (props) => {
6
+ const { patternRegistry, mapStyle } = props
7
+
8
+ const paths = getKeyPatternPaths(props, mapStyle.id, patternRegistry)
9
+ return (
10
+ <svg {...svgProps}>
11
+ <g dangerouslySetInnerHTML={{ __html: paths.border }} />
12
+ <g transform={`translate(${PATTERN_INSET}, ${PATTERN_INSET})`} dangerouslySetInnerHTML={{ __html: paths.content }} />
13
+ </svg>
14
+ )
15
+ }
@@ -0,0 +1,22 @@
1
+ import { getValueForStyle } from '../../../../../src/utils/getValueForStyle'
2
+ import { svgProps, SVG_SIZE } from './svgProperties.js'
3
+
4
+ export const KeySvgRect = (props) => {
5
+ const { mapStyle } = props
6
+ return (
7
+ <svg {...svgProps}>
8
+ <rect
9
+ x={props.strokeWidth / 2}
10
+ y={props.strokeWidth / 2}
11
+ width={SVG_SIZE - props.strokeWidth}
12
+ height={SVG_SIZE - props.strokeWidth}
13
+ rx={props.strokeWidth}
14
+ ry={props.strokeWidth}
15
+ fill={getValueForStyle(props.fill, mapStyle.id)}
16
+ stroke={getValueForStyle(props.stroke, mapStyle.id)}
17
+ strokeWidth={props.strokeWidth}
18
+ strokeLinejoin='round'
19
+ />
20
+ </svg>
21
+ )
22
+ }
@@ -0,0 +1,16 @@
1
+ import { getSymbolStyleColors, getSymbolViewBox } from '../../../../../src/utils/symbolUtils.js'
2
+ import { svgSymbolProps } from './svgProperties.js'
3
+
4
+ export const KeySvgSymbol = (props) => {
5
+ const { symbolRegistry, mapStyle, symbolDef } = props
6
+ const mapColorScheme = mapStyle?.appColorScheme ?? 'light'
7
+ const keyMapStyle = { ...mapStyle, mapColorScheme }
8
+
9
+ const resolvedSvg = symbolRegistry.resolve(symbolDef, getSymbolStyleColors(props), keyMapStyle)
10
+ const viewBox = getSymbolViewBox(props, symbolDef)
11
+ return (
12
+ <svg {...svgSymbolProps} viewBox={viewBox}>
13
+ <g dangerouslySetInnerHTML={{ __html: resolvedSvg }} />
14
+ </svg>
15
+ )
16
+ }
@@ -0,0 +1,20 @@
1
+ export const SVG_SIZE = 20
2
+ export const SVG_CENTER = SVG_SIZE / 2
3
+ const SVG_SYMBOL_SIZE = 38
4
+
5
+ export const svgProps = {
6
+ xmlns: 'http://www.w3.org/2000/svg',
7
+ width: SVG_SIZE,
8
+ height: SVG_SIZE,
9
+ viewBox: `0 0 ${SVG_SIZE} ${SVG_SIZE}`,
10
+ className: 'am-c-datasets-key-symbol',
11
+ 'aria-hidden': 'true',
12
+ focusable: 'false'
13
+ }
14
+
15
+ export const svgSymbolProps = {
16
+ ...svgProps,
17
+ width: SVG_SYMBOL_SIZE,
18
+ height: SVG_SYMBOL_SIZE,
19
+ className: 'am-c-datasets-key-symbol am-c-datasets-key-symbol--point'
20
+ }
@@ -11,7 +11,7 @@ export const createDatasets = ({
11
11
  adapter,
12
12
  pluginConfig,
13
13
  pluginStateRef,
14
- mapStyleId,
14
+ mapStyle,
15
15
  mapProvider,
16
16
  events,
17
17
  eventBus
@@ -25,7 +25,7 @@ export const createDatasets = ({
25
25
 
26
26
  // Initialise all datasets via the adapter, then set up dynamic sources
27
27
  const processedDatasets = datasets.map(d => applyDatasetDefaults(d, datasetDefaults))
28
- adapter.init(processedDatasets, mapStyleId).then(() => {
28
+ adapter.init(processedDatasets, mapStyle).then(() => {
29
29
  processedDatasets.forEach(dataset => {
30
30
  if (!isDynamicSource(dataset)) {
31
31
  return
@@ -42,16 +42,25 @@ export const createDatasets = ({
42
42
  eventBus.emit('datasets:ready')
43
43
  })
44
44
 
45
+ let currentMapStyle = mapStyle
46
+
45
47
  // Handle basemap style changes — delegate entirely to the adapter
46
- const onSetStyle = (e) => {
47
- adapter.onStyleChange(getDatasets(), e.id, getHiddenFeatures(), dynamicSources)
48
+ const onSetStyle = (newMapStyle) => {
49
+ currentMapStyle = newMapStyle
50
+ adapter.onStyleChange(getDatasets(), newMapStyle, getHiddenFeatures(), dynamicSources)
51
+ }
52
+
53
+ const onSizeChange = () => {
54
+ adapter.onSizeChange(getDatasets(), currentMapStyle)
48
55
  }
49
56
 
50
57
  eventBus.on(events.MAP_SET_STYLE, onSetStyle)
58
+ eventBus.on(events.MAP_SIZE_CHANGE, onSizeChange)
51
59
 
52
60
  return {
53
61
  remove () {
54
62
  eventBus.off(events.MAP_SET_STYLE, onSetStyle)
63
+ eventBus.off(events.MAP_SIZE_CHANGE, onSizeChange)
55
64
 
56
65
  // Clean up dynamic sources
57
66
  dynamicSources.forEach(source => source.destroy())
@@ -16,12 +16,14 @@ const datasetDefaults = {
16
16
  const STYLE_PROPS = [
17
17
  'stroke', 'strokeWidth', 'strokeDashArray',
18
18
  'fill', 'fillPattern', 'fillPatternSvgContent', 'fillPatternForegroundColor', 'fillPatternBackgroundColor',
19
- 'opacity', 'symbolDescription', 'keySymbolShape'
19
+ 'opacity', 'symbolDescription', 'keySymbolShape',
20
+ 'symbol', 'symbolSvgContent', 'symbolViewBox', 'symbolAnchor',
21
+ 'symbolBackgroundColor', 'symbolForegroundColor', 'symbolHaloWidth', 'symbolGraphic'
20
22
  ]
21
23
 
22
24
  // Props whose presence in a style object indicates a custom visual style.
23
25
  // When any are set, the default symbolDescription is not appropriate.
24
- const VISUAL_STYLE_PROPS = ['stroke', 'fill', 'fillPattern', 'fillPatternSvgContent']
26
+ const VISUAL_STYLE_PROPS = ['stroke', 'fill', 'fillPattern', 'fillPatternSvgContent', 'symbol', 'symbolSvgContent']
25
27
 
26
28
  const hasCustomVisualStyle = (style) =>
27
29
  VISUAL_STYLE_PROPS.some(prop => prop in style)
@@ -1,8 +1,9 @@
1
1
  // /plugins/datasets/index.js
2
2
  import './datasets.scss'
3
3
 
4
- export default function createPlugin (options = {}) {
4
+ export default function createPlugin (options = { }) {
5
5
  const plugin = {
6
+ noKeyItemText: 'No features displayed',
6
7
  ...options,
7
8
  id: 'datasets',
8
9
  load: async () => {