@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
@@ -0,0 +1,180 @@
1
+ import { registerPatterns } from './patternImages.js'
2
+
3
+ const OUTDOOR = 'outdoor'
4
+
5
+ const SVG_CONTENT = '<path d="M0 0 L8 8"/>'
6
+
7
+ beforeAll(() => {
8
+ globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock')
9
+ globalThis.URL.revokeObjectURL = jest.fn()
10
+
11
+ HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
12
+ drawImage: jest.fn(),
13
+ getImageData: jest.fn((_x, _y, w, h) => ({ width: w, height: h }))
14
+ }))
15
+
16
+ globalThis.Image = class {
17
+ constructor (w, h) {
18
+ this.width = w
19
+ this.height = h
20
+ this._src = ''
21
+ }
22
+
23
+ get src () { return this._src }
24
+ set src (val) { this._src = val; this.onload?.() }
25
+ }
26
+ })
27
+
28
+ const makeMap = (existingIds = []) => ({
29
+ hasImage: jest.fn((id) => existingIds.includes(id)),
30
+ addImage: jest.fn()
31
+ })
32
+
33
+ const makePatternRegistry = (id = 'stripes', content = SVG_CONTENT) => ({
34
+ get: jest.fn((name) => name === id ? { svgContent: content } : undefined)
35
+ })
36
+
37
+ // ─── registerPatterns ─────────────────────────────────────────────────────────
38
+
39
+ describe('registerPatterns — registration', () => {
40
+ it('returns early and does not touch map for empty configs', async () => {
41
+ const map = makeMap()
42
+ const registry = makePatternRegistry()
43
+ await registerPatterns(map, [], OUTDOOR, registry)
44
+ expect(map.hasImage).not.toHaveBeenCalled()
45
+ expect(map.addImage).not.toHaveBeenCalled()
46
+ })
47
+
48
+ it('calls addImage with pixelRatio 2 for a named pattern', async () => {
49
+ const map = makeMap()
50
+ const registry = makePatternRegistry()
51
+ const config = { fillPattern: 'stripes' }
52
+ await registerPatterns(map, [config], OUTDOOR, registry)
53
+ expect(map.addImage).toHaveBeenCalledTimes(1)
54
+ expect(map.addImage).toHaveBeenCalledWith(
55
+ expect.stringMatching(/^pattern-[a-z0-9]+$/),
56
+ expect.any(Object),
57
+ { pixelRatio: 2 }
58
+ )
59
+ })
60
+
61
+ it('calls addImage for an inline fillPatternSvgContent config', async () => {
62
+ const map = makeMap()
63
+ const registry = makePatternRegistry()
64
+ const config = { fillPatternSvgContent: SVG_CONTENT }
65
+ await registerPatterns(map, [config], OUTDOOR, registry)
66
+ expect(map.addImage).toHaveBeenCalledTimes(1)
67
+ })
68
+
69
+ it('skips addImage when image is already registered', async () => {
70
+ const registry = makePatternRegistry()
71
+ const config = { fillPattern: 'stripes' }
72
+ const { getPatternImageId } = await import('../../../../src/utils/patternUtils.js')
73
+ const existingId = getPatternImageId(config, OUTDOOR, registry)
74
+ const map = makeMap([existingId])
75
+ await registerPatterns(map, [config], OUTDOOR, registry)
76
+ expect(map.addImage).not.toHaveBeenCalled()
77
+ })
78
+
79
+ it('skips config when pattern has no inner content', async () => {
80
+ const map = makeMap()
81
+ const emptyRegistry = { get: jest.fn(() => undefined) }
82
+ await registerPatterns(map, [{ fillPattern: 'unknown' }], OUTDOOR, emptyRegistry)
83
+ expect(map.addImage).not.toHaveBeenCalled()
84
+ })
85
+
86
+ it('skips config when neither fillPattern nor fillPatternSvgContent is set', async () => {
87
+ const map = makeMap()
88
+ const registry = makePatternRegistry()
89
+ await registerPatterns(map, [{ fillColor: '#ff0000' }], OUTDOOR, registry)
90
+ expect(map.addImage).not.toHaveBeenCalled()
91
+ })
92
+
93
+ it('processes multiple configs in parallel', async () => {
94
+ const map = makeMap()
95
+ const registry = {
96
+ get: jest.fn((name) => {
97
+ if (name === 'stripes') { return { svgContent: '<path d="M0 0"/>' } }
98
+ if (name === 'dots') { return { svgContent: '<circle cx="8" cy="8" r="4"/>' } }
99
+ return undefined
100
+ })
101
+ }
102
+ await registerPatterns(map, [{ fillPattern: 'stripes' }, { fillPattern: 'dots' }], OUTDOOR, registry)
103
+ expect(map.addImage).toHaveBeenCalledTimes(2)
104
+ })
105
+ })
106
+
107
+ describe('registerPatterns — color resolution and caching', () => {
108
+ it('applies foreground and background colors when resolving the SVG', async () => {
109
+ const map = makeMap()
110
+ const registry = makePatternRegistry()
111
+ const getContextSpy = HTMLCanvasElement.prototype.getContext
112
+ await registerPatterns(
113
+ map,
114
+ [{ fillPattern: 'stripes', fillPatternForegroundColor: '#aabbcc', fillPatternBackgroundColor: '#112233' }],
115
+ OUTDOOR,
116
+ registry
117
+ )
118
+ expect(map.addImage).toHaveBeenCalledTimes(1)
119
+ expect(getContextSpy).toHaveBeenCalled()
120
+ })
121
+
122
+ it('resolves style-keyed foreground color for the given mapStyleId', async () => {
123
+ const map = makeMap()
124
+ const registry = makePatternRegistry()
125
+ const config = {
126
+ fillPattern: 'stripes',
127
+ fillPatternForegroundColor: { outdoor: '#aabbcc', dark: '#112233' }
128
+ }
129
+ await registerPatterns(map, [config], OUTDOOR, registry)
130
+ const map2 = makeMap()
131
+ await registerPatterns(map2, [config], 'dark', registry)
132
+ const [idOutdoor] = map.addImage.mock.calls[0]
133
+ const [idDark] = map2.addImage.mock.calls[0]
134
+ expect(idOutdoor).not.toBe(idDark)
135
+ })
136
+
137
+ it('uses cached ImageData on second call with identical config', async () => {
138
+ const map = makeMap()
139
+ const registry = makePatternRegistry()
140
+ const config = { fillPattern: 'stripes', fillPatternForegroundColor: '#unique1' }
141
+ await registerPatterns(map, [config], OUTDOOR, registry)
142
+ const { getPatternImageId } = await import('../../../../src/utils/patternUtils.js')
143
+ const imageId = getPatternImageId(config, OUTDOOR, registry)
144
+ const map2 = makeMap([imageId])
145
+ await registerPatterns(map2, [config], OUTDOOR, registry)
146
+ expect(map2.addImage).not.toHaveBeenCalled()
147
+ })
148
+ })
149
+
150
+ describe('registerPatterns — null results', () => {
151
+ it('does not call addImage when innerContent becomes unavailable inside rasterisePattern', async () => {
152
+ // registry.get returns content on the first call (for getPatternImageId in registerPatterns)
153
+ // but undefined on the second call (for getPatternInnerContent inside rasterisePattern)
154
+ const registry = {
155
+ get: jest.fn()
156
+ .mockReturnValueOnce({ svgContent: SVG_CONTENT })
157
+ .mockReturnValueOnce(undefined)
158
+ }
159
+ const map = makeMap()
160
+ await registerPatterns(map, [{ fillPattern: 'stripes' }], OUTDOOR, registry)
161
+ expect(map.addImage).not.toHaveBeenCalled()
162
+ })
163
+
164
+ it('does not call addImage when imageId becomes unavailable inside rasterisePattern', async () => {
165
+ // Three consecutive calls to registry.get:
166
+ // 1. getPatternImageId in registerPatterns → returns content → imageId is truthy
167
+ // 2. getPatternInnerContent directly in rasterisePattern → returns content → passes innerContent guard
168
+ // 3. getPatternInnerContent inside getPatternImageId in rasterisePattern → returns undefined
169
+ // → getPatternImageId returns null → hits the imageId null guard
170
+ const registry = {
171
+ get: jest.fn()
172
+ .mockReturnValueOnce({ svgContent: SVG_CONTENT })
173
+ .mockReturnValueOnce({ svgContent: SVG_CONTENT })
174
+ .mockReturnValueOnce(undefined)
175
+ }
176
+ const map = makeMap()
177
+ await registerPatterns(map, [{ fillPattern: 'stripes' }], OUTDOOR, registry)
178
+ expect(map.addImage).not.toHaveBeenCalled()
179
+ })
180
+ })
@@ -83,12 +83,23 @@ const getMinDistToGeometry = (map, point, geometry) => {
83
83
  */
84
84
  export const queryFeatures = (map, point, options = {}) => {
85
85
  const { radius = 10 } = options
86
- const queryArea = [[point.x - radius, point.y - radius], [point.x + radius, point.y + radius]]
87
- const rawFeatures = map.queryRenderedFeatures(queryArea)
86
+
87
+ const bbox = [[point.x - radius, point.y - radius], [point.x + radius, point.y + radius]]
88
+ const rawFeatures = map.queryRenderedFeatures(bbox)
88
89
  if (rawFeatures.length === 0) {
89
90
  return []
90
91
  }
91
92
 
93
+ // For symbol/point features, tolerance must not apply — selection should only
94
+ // fire when the click lands within the rendered icon bounds. An exact point
95
+ // query uses MapLibre's own icon hit-testing, mirroring the hover cursor behaviour.
96
+ const exactFeatureKeys = new Set(
97
+ map.queryRenderedFeatures([point.x, point.y]).map(f => {
98
+ const rawId = f.id === undefined ? JSON.stringify(f.properties) : f.id
99
+ return `${f.layer?.source}:${rawId}`
100
+ })
101
+ )
102
+
92
103
  // Identify layer visual hierarchy
93
104
  const layerStack = []
94
105
  rawFeatures.forEach(f => {
@@ -97,12 +108,15 @@ export const queryFeatures = (map, point, options = {}) => {
97
108
  }
98
109
  })
99
110
 
100
- // Deduplicate Bottom-Up to favor data layers over highlight layers
111
+ // Deduplicate Bottom-Up to favor data layers over highlight layers.
112
+ // Key includes source ID to prevent collisions between features from different
113
+ // sources that share the same numeric ID (e.g. generateId: true resets per source).
101
114
  const seenIds = new Set()
102
115
  const uniqueFeatures = []
103
116
  for (let i = rawFeatures.length - 1; i >= 0; i--) {
104
117
  const f = rawFeatures[i]
105
- const featureId = f.id === undefined ? JSON.stringify(f.properties) : f.id
118
+ const rawId = f.id === undefined ? JSON.stringify(f.properties) : f.id
119
+ const featureId = `${f.layer?.source}:${rawId}`
106
120
  if (seenIds.has(featureId) === false) {
107
121
  seenIds.add(featureId)
108
122
  uniqueFeatures.push(f)
@@ -112,7 +126,24 @@ export const queryFeatures = (map, point, options = {}) => {
112
126
  const clickLngLat = map.unproject(point)
113
127
  const clickPt = [clickLngLat.lng, clickLngLat.lat]
114
128
 
115
- return uniqueFeatures
129
+ // Discard features where tolerance should not apply:
130
+ // - Polygons: only include if click is geometrically inside
131
+ // - Points/symbols: only include if under the exact click point (respects icon bounds)
132
+ // - Lines: allowed through — tolerance bbox is intentional for them
133
+ const candidates = uniqueFeatures.filter((f) => {
134
+ const type = f.geometry.type
135
+ if (type.includes('Polygon')) {
136
+ const polys = type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates
137
+ return polys.some((ring) => isPointInPolygon(clickPt, ring[0]))
138
+ }
139
+ if (type === 'Point' || type === 'MultiPoint') {
140
+ const rawId = f.id === undefined ? JSON.stringify(f.properties) : f.id
141
+ return exactFeatureKeys.has(`${f.layer?.source}:${rawId}`)
142
+ }
143
+ return true
144
+ })
145
+
146
+ return candidates
116
147
  .map((f) => {
117
148
  let score = 0
118
149
  const type = f.geometry.type
@@ -122,18 +153,9 @@ export const queryFeatures = (map, point, options = {}) => {
122
153
  const layerRank = layerStack.indexOf(f.layer.id)
123
154
  score += (layerRank * 1000000)
124
155
 
125
- // PRIORITY 2: CONTAINMENT (Polygon Special Treatment)
156
+ // PRIORITY 2: POLYGON BOOST (already filtered to inside-only)
126
157
  if (type.includes('Polygon')) {
127
- const polys = type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates
128
- const isInside = polys.some((ring) => isPointInPolygon(clickPt, ring[0]))
129
-
130
- if (isInside === true) {
131
- // Massive boost for polygons if we are actually inside them
132
- score -= 500000 // NOSONAR - tolerance used only here
133
- } else {
134
- // If we are outside a polygon, it loses significantly to anything we ARE inside
135
- score += 100000 // NOSONAR - tolerance used only here
136
- }
158
+ score -= 500000 // NOSONAR
137
159
  }
138
160
 
139
161
  // PRIORITY 3: DISTANCE (Final Tie-breaker)
@@ -20,7 +20,7 @@ describe('queryFeatures coverage', () => {
20
20
  { type: 'MultiPoint', coords: [[0, 0], [10, 10]], p: { x: 1, y: 0 } },
21
21
  { type: 'MultiLineString', coords: [[[0, 0], [10, 0]]], p: { x: 5, y: 1 } },
22
22
  { type: 'Polygon', coords: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], p: { x: 5, y: 5 } }, // Inside
23
- { type: 'MultiPolygon', coords: [[[[0, 0], [10, 0], [10, 10], [0, 0]]]], p: { x: 20, y: 20 } }, // Outside
23
+ { type: 'MultiPolygon', coords: [[[[0, 0], [10, 0], [10, 10], [0, 0]]]], p: { x: 5, y: 3 } }, // Inside
24
24
  { type: 'Unknown', coords: [], p: { x: 0, y: 0 } }
25
25
  ]
26
26
 
@@ -49,12 +49,29 @@ describe('queryFeatures coverage', () => {
49
49
  expect(result.length).toBe(2)
50
50
  expect(result[0].layer.id).toBe('layer-A') // Sorted by layerStack index
51
51
 
52
- // 4. Hit ray-casting intersect logic (Line 42 branch)
52
+ // 4. Hit ray-casting intersect logic point inside the polygon
53
53
  const polyFeat = {
54
54
  layer: { id: 'L' },
55
55
  geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 10], [0, 10], [0, 0]]] }
56
56
  }
57
57
  const rayMap = { ...mockMap, queryRenderedFeatures: () => [polyFeat] }
58
- expect(queryFeatures(rayMap, { x: -1, y: 5 }).length).toBe(1)
58
+ expect(queryFeatures(rayMap, { x: 2, y: 8 }).length).toBe(1)
59
+
60
+ // 5. Outside polygon is filtered out (tolerance only applies to lines)
61
+ const outsideMap = { ...mockMap, queryRenderedFeatures: () => [polyFeat] }
62
+ expect(queryFeatures(outsideMap, { x: -1, y: 5 }).length).toBe(0)
63
+
64
+ // 6. Symbol under exact click point is included
65
+ const symbolFeat = { id: 'sym', layer: { id: 'S', source: 'src' }, geometry: { type: 'Point', coordinates: [0, 0] } }
66
+ const symbolMap = { ...mockMap, queryRenderedFeatures: () => [symbolFeat] } // both calls return it
67
+ expect(queryFeatures(symbolMap, { x: 5, y: 5 }).length).toBe(1)
68
+
69
+ // 7. Symbol NOT under exact click point is filtered out
70
+ let call = 0
71
+ const symbolMissMap = {
72
+ ...mockMap,
73
+ queryRenderedFeatures: () => call++ === 0 ? [symbolFeat] : [] // bbox returns it, exact does not
74
+ }
75
+ expect(queryFeatures(symbolMissMap, { x: 5, y: 5 }).length).toBe(0)
59
76
  })
60
77
  })
@@ -0,0 +1,30 @@
1
+ const SVG_ERROR_PREVIEW_LENGTH = 80
2
+
3
+ /**
4
+ * Rasterises an SVG string to an ImageData object via a canvas.
5
+ *
6
+ * @param {string} svgString - Full SVG markup to render
7
+ * @param {number} width - Canvas width in pixels
8
+ * @param {number} height - Canvas height in pixels
9
+ * @returns {Promise<ImageData>}
10
+ */
11
+ export const rasteriseToImageData = (svgString, width, height) =>
12
+ new Promise((resolve, reject) => {
13
+ const blob = new Blob([svgString], { type: 'image/svg+xml' })
14
+ const url = URL.createObjectURL(blob)
15
+ const img = new Image(width, height)
16
+ img.onload = () => {
17
+ const canvas = document.createElement('canvas')
18
+ canvas.width = width
19
+ canvas.height = height
20
+ const ctx = canvas.getContext('2d')
21
+ ctx.drawImage(img, 0, 0, width, height)
22
+ URL.revokeObjectURL(url)
23
+ resolve(ctx.getImageData(0, 0, width, height))
24
+ }
25
+ img.onerror = () => {
26
+ URL.revokeObjectURL(url)
27
+ reject(new Error(`Failed to rasterise SVG: ${svgString.slice(0, SVG_ERROR_PREVIEW_LENGTH)}`))
28
+ }
29
+ img.src = url
30
+ })
@@ -0,0 +1,69 @@
1
+ import { rasteriseToImageData } from './rasteriseToImageData.js'
2
+
3
+ const SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="16" cy="16" r="8"/></svg>'
4
+ const WIDTH = 32
5
+ const HEIGHT = 32
6
+
7
+ // Mirrors SVG_ERROR_PREVIEW_LENGTH in rasteriseToImageData.js
8
+ const ERROR_PREVIEW_LENGTH = 80
9
+ // Length chosen to be well over ERROR_PREVIEW_LENGTH so truncation is exercised
10
+ const LONG_CONTENT_LENGTH = 200
11
+
12
+ beforeAll(() => {
13
+ globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock')
14
+ globalThis.URL.revokeObjectURL = jest.fn()
15
+
16
+ HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
17
+ drawImage: jest.fn(),
18
+ getImageData: jest.fn((_x, _y, w, h) => ({ width: w, height: h }))
19
+ }))
20
+
21
+ globalThis.Image = class {
22
+ constructor (w, h) {
23
+ this.width = w
24
+ this.height = h
25
+ this._src = ''
26
+ }
27
+
28
+ get src () { return this._src }
29
+ set src (val) { this._src = val; this.onload?.() }
30
+ }
31
+ })
32
+
33
+ beforeEach(() => {
34
+ jest.clearAllMocks()
35
+ globalThis.URL.createObjectURL.mockReturnValue('blob:mock')
36
+ })
37
+
38
+ describe('rasteriseToImageData', () => {
39
+ it('resolves with ImageData at the requested dimensions, draws via canvas, and revokes the blob URL', async () => {
40
+ const getContext = HTMLCanvasElement.prototype.getContext
41
+ const result = await rasteriseToImageData(SVG, WIDTH, HEIGHT)
42
+ expect(result).toMatchObject({ width: WIDTH, height: HEIGHT })
43
+ expect(globalThis.URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
44
+ expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock')
45
+ const { drawImage, getImageData } = getContext.mock.results[0].value
46
+ expect(drawImage).toHaveBeenCalledWith(expect.any(Object), 0, 0, WIDTH, HEIGHT)
47
+ expect(getImageData).toHaveBeenCalledWith(0, 0, WIDTH, HEIGHT)
48
+ })
49
+
50
+ it('rejects with a truncated SVG preview and revokes the blob URL on error', async () => {
51
+ const originalImage = globalThis.Image
52
+ globalThis.Image = class {
53
+ constructor (w, h) { this.width = w; this.height = h; this._src = '' }
54
+ get src () { return this._src }
55
+ set src (val) { this._src = val; this.onerror?.() }
56
+ }
57
+ try {
58
+ const longSvg = `<svg>${'x'.repeat(LONG_CONTENT_LENGTH)}</svg>`
59
+ const error = await rasteriseToImageData(longSvg, WIDTH, HEIGHT).catch(e => e)
60
+ expect(error.message).toMatch('Failed to rasterise SVG')
61
+ const preview = error.message.replace('Failed to rasterise SVG: ', '')
62
+ expect(preview).toHaveLength(ERROR_PREVIEW_LENGTH)
63
+ expect(preview).toBe(longSvg.slice(0, ERROR_PREVIEW_LENGTH))
64
+ expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock')
65
+ } finally {
66
+ globalThis.Image = originalImage
67
+ }
68
+ })
69
+ })
@@ -0,0 +1,147 @@
1
+ import { getSymbolDef, getSymbolStyleColors, getSymbolViewBox } from '../../../../src/utils/symbolUtils.js'
2
+ import { rasteriseToImageData } from './rasteriseToImageData.js'
3
+
4
+ const ANCHOR_LOW = 0.25
5
+ const ANCHOR_HIGH = 0.75
6
+ const HASH_BASE = 36
7
+
8
+ const hashString = (str) => {
9
+ let hash = 0
10
+ for (const ch of str) {
11
+ hash = Math.trunc(((hash << 5) - hash) + ch.codePointAt(0))
12
+ }
13
+ return Math.abs(hash).toString(HASH_BASE)
14
+ }
15
+
16
+ // ─── MapLibre-specific anchor conversion ──────────────────────────────────────
17
+
18
+ /**
19
+ * Converts a fractional [ax, ay] anchor to a MapLibre icon-anchor string.
20
+ * Snaps to the nearest of the 9 standard positions.
21
+ *
22
+ * @param {number[]} anchor - [x, y] in 0–1 space
23
+ * @returns {string} MapLibre icon-anchor value
24
+ */
25
+ const xAnchor = (ax) => {
26
+ if (ax <= ANCHOR_LOW) {
27
+ return 'left'
28
+ }
29
+ if (ax >= ANCHOR_HIGH) {
30
+ return 'right'
31
+ }
32
+ return ''
33
+ }
34
+
35
+ const yAnchor = (ay) => {
36
+ if (ay <= ANCHOR_LOW) {
37
+ return 'top'
38
+ }
39
+ if (ay >= ANCHOR_HIGH) {
40
+ return 'bottom'
41
+ }
42
+ return ''
43
+ }
44
+
45
+ export const anchorToMaplibre = ([ax, ay]) => {
46
+ const x = xAnchor(ax)
47
+ const y = yAnchor(ay)
48
+ return (y + (x && y ? '-' : '') + x) || 'center'
49
+ }
50
+
51
+ // ─── Image IDs ────────────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Returns a deterministic image ID for a symbol in normal or selected state.
55
+ * Based on the hash of the fully resolved SVG content and the pixel ratio.
56
+ *
57
+ * @param {Object} dataset
58
+ * @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
59
+ * @param {Object} symbolRegistry
60
+ * @param {boolean} [selected=false]
61
+ * @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor
62
+ * @returns {string|null}
63
+ */
64
+ export const getSymbolImageId = (dataset, mapStyle, symbolRegistry, selected = false, pixelRatio = 2) => {
65
+ const symbolDef = getSymbolDef(dataset, symbolRegistry)
66
+ if (!symbolDef) {
67
+ return null
68
+ }
69
+ const styleColors = getSymbolStyleColors(dataset)
70
+ const resolved = selected
71
+ ? symbolRegistry.resolveSelected(symbolDef, styleColors, mapStyle)
72
+ : symbolRegistry.resolve(symbolDef, styleColors, mapStyle)
73
+ return `symbol-${selected ? 'sel-' : ''}${hashString(resolved)}-${pixelRatio}x`
74
+ }
75
+
76
+ // ─── Rasterisation ────────────────────────────────────────────────────────────
77
+
78
+ // Module-level cache: imageId → ImageData. Avoids re-rasterising identical symbols.
79
+ const imageDataCache = new Map()
80
+
81
+ const rasteriseSymbolImage = async (dataset, mapStyle, symbolRegistry, selected, pixelRatio) => {
82
+ const symbolDef = getSymbolDef(dataset, symbolRegistry)
83
+ if (!symbolDef) {
84
+ return null
85
+ }
86
+ const styleColors = getSymbolStyleColors(dataset)
87
+ const resolvedContent = selected
88
+ ? symbolRegistry.resolveSelected(symbolDef, styleColors, mapStyle)
89
+ : symbolRegistry.resolve(symbolDef, styleColors, mapStyle)
90
+
91
+ const imageId = `symbol-${selected ? 'sel-' : ''}${hashString(resolvedContent)}-${pixelRatio}x`
92
+
93
+ let imageData = imageDataCache.get(imageId)
94
+ if (!imageData) {
95
+ const viewBox = getSymbolViewBox(dataset, symbolDef)
96
+ const [,, width, height] = viewBox.split(' ').map(Number)
97
+ // Render at pixelRatio× to keep icons crisp at the current device DPI and map size.
98
+ // MapLibre receives the matching pixelRatio so the image displays at its original logical size.
99
+ const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="${width * pixelRatio}" height="${height * pixelRatio}" viewBox="${viewBox}">${resolvedContent}</svg>`
100
+ imageData = await rasteriseToImageData(svgString, width * pixelRatio, height * pixelRatio)
101
+ imageDataCache.set(imageId, imageData)
102
+ }
103
+
104
+ return { imageId, imageData }
105
+ }
106
+
107
+ /**
108
+ * Register normal and selected symbol images for the given pre-resolved symbol configs.
109
+ * Skips images that are already registered (safe to call on style change).
110
+ * Updates `map._symbolImageMap` with normal→selected image ID pairs.
111
+ *
112
+ * Callers are responsible for resolving sublayers before calling this function
113
+ * (see `getSymbolConfigs` in the datasets plugin adapter).
114
+ *
115
+ * @param {Object} map - MapLibre map instance
116
+ * @param {Object[]} symbolConfigs - Flat list of datasets/merged-sublayers that have a symbol config
117
+ * @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
118
+ * @param {Object} symbolRegistry
119
+ * @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor (computed by caller)
120
+ * @returns {Promise<void>}
121
+ */
122
+ export const registerSymbols = async (map, symbolConfigs, mapStyle, symbolRegistry, pixelRatio = 2) => {
123
+ if (!symbolConfigs.length) {
124
+ return
125
+ }
126
+
127
+ // Reset the normal→selected image ID lookup so stale entries don't persist after a style change
128
+ map._symbolImageMap = {}
129
+
130
+ await Promise.all(symbolConfigs.flatMap(config => {
131
+ const normalId = getSymbolImageId(config, mapStyle, symbolRegistry, false, pixelRatio)
132
+ const selectedId = getSymbolImageId(config, mapStyle, symbolRegistry, true, pixelRatio)
133
+ if (normalId && selectedId) {
134
+ map._symbolImageMap[normalId] = selectedId
135
+ }
136
+ return [false, true].map(async (selected) => {
137
+ const imageId = selected ? selectedId : normalId
138
+ if (!imageId || map.hasImage(imageId)) {
139
+ return
140
+ }
141
+ const result = await rasteriseSymbolImage(config, mapStyle, symbolRegistry, selected, pixelRatio)
142
+ if (result && !map.hasImage(result.imageId)) {
143
+ map.addImage(result.imageId, result.imageData, { pixelRatio })
144
+ }
145
+ })
146
+ }))
147
+ }