@defra/interactive-map 0.0.17-alpha → 0.0.18-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 (140) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api/context.md +53 -7
  7. package/docs/api/map-style-config.md +41 -2
  8. package/docs/api/marker-config.md +53 -11
  9. package/docs/api/symbol-config.md +160 -0
  10. package/docs/api/symbol-registry.md +115 -0
  11. package/docs/api.md +22 -19
  12. package/docs/plugins/datasets.md +105 -9
  13. package/docs/plugins/interact.md +68 -43
  14. package/docs/plugins/search.md +15 -3
  15. package/package.json +1 -1
  16. package/plugins/beta/datasets/dist/css/index.css +32 -14
  17. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  18. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  19. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  20. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  21. package/plugins/beta/datasets/src/DatasetsInit.jsx +9 -4
  22. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +57 -11
  23. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +14 -8
  24. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +155 -53
  25. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  26. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  27. package/plugins/beta/datasets/src/api/addDataset.js +1 -1
  28. package/plugins/beta/datasets/src/api/setData.js +4 -2
  29. package/plugins/beta/datasets/src/api/setStyle.js +2 -2
  30. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  31. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  32. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  33. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  34. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  35. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  36. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  37. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  38. package/plugins/beta/datasets/src/datasets.js +13 -4
  39. package/plugins/beta/datasets/src/defaults.js +4 -2
  40. package/plugins/beta/datasets/src/index.js +2 -1
  41. package/plugins/beta/datasets/src/manifest.js +1 -1
  42. package/plugins/beta/datasets/src/panels/Key.jsx +11 -89
  43. package/plugins/beta/datasets/src/panels/Key.module.scss +24 -13
  44. package/plugins/beta/datasets/src/panels/Layers.module.scss +13 -7
  45. package/plugins/beta/datasets/src/reducer.js +6 -0
  46. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  47. package/plugins/beta/datasets/src/utils/mergeSublayer.js +8 -0
  48. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  49. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  50. package/plugins/beta/draw-ml/dist/css/index.css +21 -1
  51. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  52. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  53. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  54. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  55. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  56. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  57. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  58. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  59. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  60. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  61. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  62. package/plugins/interact/dist/umd/index.js +1 -1
  63. package/plugins/interact/src/InteractInit.jsx +14 -5
  64. package/plugins/interact/src/InteractInit.test.js +26 -6
  65. package/plugins/interact/src/api/enable.test.js +7 -7
  66. package/plugins/interact/src/defaults.js +4 -6
  67. package/plugins/interact/src/events.js +9 -6
  68. package/plugins/interact/src/events.test.js +28 -4
  69. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  70. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  71. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  72. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  73. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  74. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  75. package/plugins/interact/src/reducer.js +23 -4
  76. package/plugins/interact/src/reducer.test.js +60 -11
  77. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  78. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  79. package/plugins/interact/src/utils/featureQueries.js +11 -6
  80. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  81. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  82. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  83. package/plugins/search/src/Search.jsx +3 -1
  84. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  85. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  86. package/plugins/search/src/events/formHandlers.js +3 -3
  87. package/plugins/search/src/events/formHandlers.test.js +1 -1
  88. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  89. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  90. package/plugins/search/src/utils/updateMap.js +3 -3
  91. package/plugins/search/src/utils/updateMap.test.js +3 -3
  92. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  93. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  94. package/providers/maplibre/dist/umd/index.js +1 -1
  95. package/providers/maplibre/src/appEvents.js +7 -0
  96. package/providers/maplibre/src/appEvents.test.js +18 -4
  97. package/providers/maplibre/src/maplibreProvider.js +52 -0
  98. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  99. package/providers/maplibre/src/utils/highlightFeatures.js +36 -7
  100. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -96
  101. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  102. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  103. package/providers/maplibre/src/utils/patternImages.js +70 -0
  104. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  105. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  106. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  107. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  108. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  109. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  110. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  111. package/src/App/components/Markers/Markers.jsx +122 -27
  112. package/src/App/components/Markers/Markers.module.scss +0 -10
  113. package/src/App/components/Markers/Markers.test.jsx +246 -0
  114. package/src/App/hooks/useInterfaceAPI.test.js +156 -0
  115. package/src/App/hooks/useMarkersAPI.js +2 -5
  116. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  117. package/src/App/layout/Layout.jsx +2 -2
  118. package/src/App/layout/Layout.test.jsx +4 -2
  119. package/src/App/store/ServiceProvider.jsx +7 -5
  120. package/src/App/store/mapActionsMap.js +4 -6
  121. package/src/App/store/mapActionsMap.test.js +3 -2
  122. package/src/App/store/mapReducer.js +2 -1
  123. package/src/config/appConfig.js +0 -6
  124. package/src/config/appConfig.test.js +1 -2
  125. package/src/config/defaults.js +0 -2
  126. package/src/config/mapTheme.js +56 -0
  127. package/src/config/patternConfig.js +16 -0
  128. package/src/config/symbolConfig.js +80 -0
  129. package/src/scss/settings/_colors.scss +0 -9
  130. package/src/services/patternRegistry.js +40 -0
  131. package/src/services/patternRegistry.test.js +48 -0
  132. package/src/services/symbolRegistry.js +113 -0
  133. package/src/services/symbolRegistry.test.js +262 -0
  134. package/src/types.js +93 -11
  135. package/src/utils/patternUtils.js +94 -0
  136. package/src/utils/patternUtils.test.js +160 -0
  137. package/src/utils/symbolUtils.js +85 -0
  138. package/src/utils/symbolUtils.test.js +156 -0
  139. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +0 -48
  140. package/plugins/beta/datasets/src/styles/patterns.js +0 -157
@@ -0,0 +1,248 @@
1
+ import { anchorToMaplibre, getSymbolImageId, registerSymbols } from './symbolImages.js'
2
+ import { symbolRegistry } from '../../../../src/services/symbolRegistry.js'
3
+
4
+ const STYLE_ID = 'test'
5
+ const mapStyle = { id: STYLE_ID }
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
+ beforeEach(() => {
29
+ symbolRegistry.setDefaults({})
30
+ })
31
+
32
+ // ─── anchorToMaplibre ─────────────────────────────────────────────────────────
33
+
34
+ describe('anchorToMaplibre', () => {
35
+ it('returns center for [0.5, 0.5]', () => {
36
+ expect(anchorToMaplibre([0.5, 0.5])).toBe('center')
37
+ })
38
+
39
+ it('returns top for [0.5, 0]', () => {
40
+ expect(anchorToMaplibre([0.5, 0])).toBe('top')
41
+ })
42
+
43
+ it('returns bottom for [0.5, 1]', () => {
44
+ expect(anchorToMaplibre([0.5, 1])).toBe('bottom')
45
+ })
46
+
47
+ it('returns left for [0, 0.5]', () => {
48
+ expect(anchorToMaplibre([0, 0.5])).toBe('left')
49
+ })
50
+
51
+ it('returns right for [1, 0.5]', () => {
52
+ expect(anchorToMaplibre([1, 0.5])).toBe('right')
53
+ })
54
+
55
+ it('returns top-left for [0, 0]', () => {
56
+ expect(anchorToMaplibre([0, 0])).toBe('top-left')
57
+ })
58
+
59
+ it('returns top-right for [1, 0]', () => {
60
+ expect(anchorToMaplibre([1, 0])).toBe('top-right')
61
+ })
62
+
63
+ it('returns bottom-left for [0, 1]', () => {
64
+ expect(anchorToMaplibre([0, 1])).toBe('bottom-left')
65
+ })
66
+
67
+ it('returns bottom-right for [1, 1]', () => {
68
+ expect(anchorToMaplibre([1, 1])).toBe('bottom-right')
69
+ })
70
+
71
+ it('snaps pin anchor [0.5, 0.9] to bottom', () => {
72
+ expect(anchorToMaplibre([0.5, 0.9])).toBe('bottom') // NOSONAR S109 — deliberate boundary test value
73
+ })
74
+
75
+ it('returns center for values in the middle band', () => {
76
+ expect(anchorToMaplibre([0.5, 0.5])).toBe('center')
77
+ expect(anchorToMaplibre([0.26, 0.26])).toBe('center') // NOSONAR S109 — just inside center band
78
+ expect(anchorToMaplibre([0.74, 0.74])).toBe('center') // NOSONAR S109 — just inside center band
79
+ })
80
+
81
+ it('returns top at boundary value 0.25', () => {
82
+ expect(anchorToMaplibre([0.5, 0.25])).toBe('top')
83
+ })
84
+
85
+ it('returns bottom at boundary value 0.75', () => {
86
+ expect(anchorToMaplibre([0.5, 0.75])).toBe('bottom') // NOSONAR S109 — ANCHOR_HIGH boundary
87
+ })
88
+ })
89
+
90
+ // ─── getSymbolImageId ─────────────────────────────────────────────────────────
91
+
92
+ describe('getSymbolImageId', () => {
93
+ it('returns null when dataset has no symbol', () => {
94
+ expect(getSymbolImageId({}, mapStyle, symbolRegistry)).toBeNull()
95
+ })
96
+
97
+ it('returns null for an unregistered symbol id', () => {
98
+ expect(getSymbolImageId({ symbol: 'does-not-exist' }, mapStyle, symbolRegistry)).toBeNull()
99
+ })
100
+
101
+ it('returns a string prefixed symbol- for normal state', () => {
102
+ const id = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
103
+ expect(typeof id).toBe('string')
104
+ expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
105
+ })
106
+
107
+ it('returns a string prefixed symbol-sel- for selected state', () => {
108
+ const id = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
109
+ expect(typeof id).toBe('string')
110
+ expect(id).toMatch(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/)
111
+ })
112
+
113
+ it('normal and selected ids differ for the same dataset', () => {
114
+ const normalId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, false)
115
+ const selectedId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
116
+ expect(normalId).not.toBe(selectedId)
117
+ })
118
+
119
+ it('same dataset and style always produces the same id', () => {
120
+ const id1 = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
121
+ const id2 = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
122
+ expect(id1).toBe(id2)
123
+ })
124
+
125
+ it('different symbols produce different ids', () => {
126
+ const pinId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
127
+ const circleId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry)
128
+ expect(pinId).not.toBe(circleId)
129
+ })
130
+
131
+ it('different backgrounds produce different ids', () => {
132
+ const redId = getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#ff0000' }, mapStyle, symbolRegistry)
133
+ const blueId = getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#0000ff' }, mapStyle, symbolRegistry)
134
+ expect(redId).not.toBe(blueId)
135
+ })
136
+
137
+ it('resolves inline symbolSvgContent', () => {
138
+ const dataset = {
139
+ symbolSvgContent: '<circle cx="19" cy="19" r="12" fill="{{backgroundColor}}"/>',
140
+ symbolViewBox: '0 0 38 38',
141
+ symbolAnchor: [0.5, 0.5]
142
+ }
143
+ const id = getSymbolImageId(dataset, mapStyle, symbolRegistry)
144
+ expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
145
+ })
146
+ })
147
+
148
+ // ─── registerSymbols ──────────────────────────────────────────────────────────
149
+
150
+ const makeMap = (existingIds = []) => ({
151
+ _symbolImageMap: {},
152
+ hasImage: jest.fn((id) => existingIds.includes(id)),
153
+ addImage: jest.fn()
154
+ })
155
+
156
+ describe('registerSymbols — registration', () => {
157
+ it('returns early and does not touch map for empty configs', async () => {
158
+ const map = makeMap()
159
+ await registerSymbols(map, [], mapStyle, symbolRegistry)
160
+ expect(map.hasImage).not.toHaveBeenCalled()
161
+ expect(map.addImage).not.toHaveBeenCalled()
162
+ })
163
+
164
+ it('resets _symbolImageMap before processing', async () => {
165
+ const map = makeMap()
166
+ map._symbolImageMap = { stale: 'entry' }
167
+ await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
168
+ expect(map._symbolImageMap).not.toHaveProperty('stale')
169
+ })
170
+
171
+ it('calls addImage for normal and selected variants', async () => {
172
+ const map = makeMap()
173
+ await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
174
+ expect(map.addImage).toHaveBeenCalledTimes(2)
175
+ expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
176
+ expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
177
+ })
178
+
179
+ it('populates _symbolImageMap with normal → selected id pairs', async () => {
180
+ const map = makeMap()
181
+ await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
182
+ const normalId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, false)
183
+ const selectedId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
184
+ expect(map._symbolImageMap[normalId]).toBe(selectedId)
185
+ })
186
+
187
+ it('skips addImage when image is already registered', async () => {
188
+ const normalId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry, false)
189
+ const selectedId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry, true)
190
+ const map = makeMap([normalId, selectedId])
191
+ await registerSymbols(map, [{ symbol: 'circle' }], mapStyle, symbolRegistry)
192
+ expect(map.addImage).not.toHaveBeenCalled()
193
+ })
194
+
195
+ it('processes multiple configs independently', async () => {
196
+ const map = makeMap()
197
+ await registerSymbols(map, [{ symbol: 'pin' }, { symbol: 'circle' }], mapStyle, symbolRegistry)
198
+ expect(map.addImage).toHaveBeenCalledTimes(4)
199
+ expect(Object.keys(map._symbolImageMap)).toHaveLength(2)
200
+ })
201
+ })
202
+
203
+ describe('registerSymbols — null results and caching', () => {
204
+ it('does not call addImage when rasteriseSymbolImage returns null', async () => {
205
+ // getSymbolImageId (called twice — normal + selected) needs a real symbolDef to produce imageIds,
206
+ // but rasteriseSymbolImage must get undefined from getSymbolDef so it returns null.
207
+ // The registry.get call order: [1] getSymbolImageId normal, [2] getSymbolImageId selected,
208
+ // [3] rasteriseSymbolImage normal, [4] rasteriseSymbolImage selected.
209
+ const pinDef = symbolRegistry.get('pin')
210
+ const getSpy = jest.spyOn(symbolRegistry, 'get')
211
+ .mockReturnValueOnce(pinDef)
212
+ .mockReturnValueOnce(pinDef)
213
+ .mockReturnValueOnce(undefined)
214
+ .mockReturnValueOnce(undefined)
215
+ const map = makeMap()
216
+ await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
217
+ expect(map.addImage).not.toHaveBeenCalled()
218
+ getSpy.mockRestore()
219
+ })
220
+
221
+ it('skips config when symbolDef cannot be resolved', async () => {
222
+ const map = makeMap()
223
+ await registerSymbols(map, [{ symbol: 'no-such-symbol' }], mapStyle, symbolRegistry)
224
+ expect(map.addImage).not.toHaveBeenCalled()
225
+ expect(map._symbolImageMap).toEqual({})
226
+ })
227
+
228
+ it('reuses cached imageData when called again with the same pixelRatio', async () => {
229
+ // Use an unusual ratio so this test owns its cache entries
230
+ const uniqueRatio = 7
231
+
232
+ const map1 = makeMap()
233
+ const blobCallsBefore = globalThis.URL.createObjectURL.mock.calls.length
234
+ await registerSymbols(map1, [{ symbol: 'pin' }], mapStyle, symbolRegistry, uniqueRatio)
235
+ const blobCallsAfterFirst = globalThis.URL.createObjectURL.mock.calls.length
236
+ // Rasterisation ran — blob was created
237
+ expect(blobCallsAfterFirst).toBeGreaterThan(blobCallsBefore)
238
+
239
+ // Second call with a fresh map (hasImage → false) but same ratio → cache hit
240
+ const map2 = makeMap()
241
+ await registerSymbols(map2, [{ symbol: 'pin' }], mapStyle, symbolRegistry, uniqueRatio)
242
+ const blobCallsAfterSecond = globalThis.URL.createObjectURL.mock.calls.length
243
+ // No new blob created — rasterisation was skipped via cache
244
+ expect(blobCallsAfterSecond).toBe(blobCallsAfterFirst)
245
+ // addImage still called because map2 has no pre-registered images
246
+ expect(map2.addImage).toHaveBeenCalledTimes(2)
247
+ })
248
+ })
@@ -1,47 +1,142 @@
1
- import { markerSvgPaths } from '../../../config/appConfig.js'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
  import { useMarkers } from '../../hooks/useMarkersAPI.js'
3
3
  import { useConfig } from '../../store/configContext.js'
4
4
  import { useMap } from '../../store/mapContext.js'
5
- import { getValueForStyle } from '../../../utils/getValueForStyle.js'
5
+ import { useService } from '../../store/serviceContext.js'
6
6
  import { stringToKebab } from '../../../utils/stringToKebab.js'
7
+ import { scaleFactor } from '../../../config/appConfig.js'
8
+
9
+ // Marker properties handled internally — excluded from style value resolution
10
+ const INTERNAL_KEYS = new Set(['id', 'coords', 'x', 'y', 'isVisible', 'symbol', 'symbolSvgContent', 'viewBox', 'anchor', 'selectedColor', 'selectedWidth'])
11
+
12
+ const resolveSymbolDef = (marker, defaults, symbolRegistry) => {
13
+ const svgContent = marker.symbolSvgContent || defaults.symbolSvgContent
14
+ // Inline symbolSvgContent takes precedence over a registered symbol,
15
+ // cascading through marker → constructor defaults
16
+ return svgContent
17
+ ? { svg: svgContent }
18
+ : symbolRegistry.get(marker.symbol || defaults.symbol)
19
+ }
20
+
21
+ const resolveViewBox = (marker, defaults, symbolDef) =>
22
+ marker.viewBox || defaults.viewBox || symbolDef?.viewBox || '0 0 38 38'
23
+
24
+ const resolveAnchor = (marker, defaults, symbolDef) =>
25
+ marker.anchor ?? defaults.anchor ?? symbolDef?.anchor ?? [0.5, 0.5]
26
+
27
+ /**
28
+ * When the interact plugin is active, watch mousemove to set cursor:pointer whenever
29
+ * the mouse is over one of the marker SVG elements (which are pointer-events:none).
30
+ */
31
+ const useMarkerCursor = (markers, interactActive, viewportRef) => {
32
+ useEffect(() => {
33
+ if (!interactActive) {
34
+ return undefined
35
+ }
36
+ const viewport = viewportRef.current
37
+ if (!viewport) {
38
+ return undefined
39
+ }
40
+ const onMove = (e) => {
41
+ const hit = markers.items.some(marker => {
42
+ const el = markers.markerRefs?.get(marker.id)
43
+ if (!el) {
44
+ return false
45
+ }
46
+ const { left, top, right, bottom } = el.getBoundingClientRect()
47
+ return e.clientX >= left && e.clientX <= right && e.clientY >= top && e.clientY <= bottom
48
+ })
49
+ viewport.style.cursor = hit ? 'pointer' : ''
50
+ }
51
+ viewport.addEventListener('mousemove', onMove)
52
+ return () => {
53
+ viewport.removeEventListener('mousemove', onMove)
54
+ viewport.style.cursor = ''
55
+ }
56
+ }, [markers, interactActive, viewportRef])
57
+ }
7
58
 
8
59
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
9
60
  // sonarjs/disable-next-line function-name
10
61
  export const Markers = () => {
11
- const { id, markerShape, markerColor } = useConfig()
12
- const { mapStyle } = useMap()
62
+ const { id } = useConfig()
63
+ const { mapStyle, mapSize } = useMap()
13
64
  const { markers, markerRef } = useMarkers()
65
+ const { symbolRegistry, eventBus } = useService()
66
+
67
+ const [canSelectMarker, setCanSelectMarker] = useState(false)
68
+ const [selectedMarkers, setSelectedMarkers] = useState([])
69
+ const viewportRef = useRef(null)
70
+
71
+ useEffect(() => {
72
+ const handleActive = ({ active, interactionModes = [] }) => setCanSelectMarker(active && interactionModes.includes('selectMarker'))
73
+ const handleSelectionChange = ({ selectedMarkers: next = [] }) => setSelectedMarkers(next)
74
+ eventBus.on('interact:active', handleActive)
75
+ eventBus.on('interact:selectionchange', handleSelectionChange)
76
+ return () => {
77
+ eventBus.off('interact:active', handleActive)
78
+ eventBus.off('interact:selectionchange', handleSelectionChange)
79
+ }
80
+ }, [eventBus])
81
+
82
+ // Resolve viewport element once on mount for cursor tracking
83
+ useEffect(() => {
84
+ viewportRef.current = document.querySelector('.im-c-viewport')
85
+ }, [])
86
+
87
+ useMarkerCursor(markers, canSelectMarker, viewportRef)
14
88
 
15
89
  if (!mapStyle) {
16
90
  return undefined
17
91
  }
18
92
 
19
- const defaultSvgPaths = markerSvgPaths.find(m => m.shape === markerShape)
93
+ const defaults = symbolRegistry.getDefaults()
20
94
 
21
95
  return (
22
96
  <>
23
- {markers.items.map(marker => (
24
- <svg
25
- key={marker.id}
26
- ref={markerRef(marker.id)} // Single callback ref, just like useCrossHair
27
- id={`${id}-marker-${marker.id}`}
28
- className={`im-c-marker im-c-marker--${marker.markerShape || stringToKebab(markerShape)}`}
29
- width='38'
30
- height='38'
31
- viewBox='0 0 38 38'
32
- style={{ display: marker.isVisible ? 'block' : 'none' }}
33
- >
34
- <path
35
- className='im-c-marker__background'
36
- d={defaultSvgPaths.backgroundPath}
37
- fill={getValueForStyle(marker.color || markerColor, mapStyle.id)}
38
- />
39
- <path
40
- className='im-c-marker__graphic'
41
- d={defaultSvgPaths.graphicPath}
42
- />
43
- </svg>
44
- ))}
97
+ {markers.items.map(marker => {
98
+ const symbolDef = resolveSymbolDef(marker, defaults, symbolRegistry)
99
+ // selectedColor comes from mapStyle — not per-marker; selectedWidth stays in cascade
100
+ const styleValues = Object.fromEntries(
101
+ Object.entries(marker).filter(([k]) => !INTERNAL_KEYS.has(k))
102
+ )
103
+ const isSelected = selectedMarkers.includes(marker.id)
104
+ const resolvedSvg = isSelected
105
+ ? symbolRegistry.resolveSelected(symbolDef, styleValues, mapStyle)
106
+ : symbolRegistry.resolve(symbolDef, styleValues, mapStyle)
107
+
108
+ const viewBox = resolveViewBox(marker, defaults, symbolDef)
109
+ const [,, svgWidth, svgHeight] = viewBox.split(' ').map(Number)
110
+ const anchor = resolveAnchor(marker, defaults, symbolDef)
111
+ const shapeId = marker.symbol || defaults.symbol
112
+ const scale = scaleFactor[mapSize] ?? 1
113
+ const scaledWidth = svgWidth * scale
114
+ const scaledHeight = svgHeight * scale
115
+
116
+ return (
117
+ <svg
118
+ key={marker.id}
119
+ ref={markerRef(marker.id)}
120
+ id={`${id}-marker-${marker.id}`}
121
+ className={[
122
+ 'im-c-marker',
123
+ `im-c-marker--${stringToKebab(shapeId)}`,
124
+ isSelected && 'im-c-marker--selected'
125
+ ].filter(Boolean).join(' ')}
126
+ width={scaledWidth}
127
+ height={scaledHeight}
128
+ viewBox={viewBox}
129
+ overflow='visible'
130
+ style={{
131
+ display: marker.isVisible ? 'block' : 'none',
132
+ marginLeft: `${-anchor[0] * scaledWidth}px`,
133
+ marginTop: `${-anchor[1] * scaledHeight}px`
134
+ }}
135
+ >
136
+ <g dangerouslySetInnerHTML={{ __html: resolvedSvg }} />
137
+ </svg>
138
+ )
139
+ })}
45
140
  </>
46
141
  )
47
142
  }
@@ -7,16 +7,6 @@
7
7
  position: absolute;
8
8
  left: 0;
9
9
  top: 0;
10
- margin: -17px 0 0 -19px;
11
10
  pointer-events: none;
12
11
  }
13
12
 
14
- // 2. Elements
15
- .im-c-marker__graphic {
16
- fill: var(--map-overlay-halo-color);
17
- }
18
-
19
- // 3. Modifiers
20
- .im-c-marker--pin .im-c-marker__graphic {
21
- filter: opacity(50%) brightness(0.25);
22
- }
@@ -0,0 +1,246 @@
1
+ import { render, act } from '@testing-library/react'
2
+ import { Markers } from './Markers.jsx'
3
+ import { useMarkers } from '../../hooks/useMarkersAPI.js'
4
+ import { useConfig } from '../../store/configContext.js'
5
+ import { useMap } from '../../store/mapContext.js'
6
+ import { useService } from '../../store/serviceContext.js'
7
+
8
+ jest.mock('../../hooks/useMarkersAPI.js', () => ({ useMarkers: jest.fn() }))
9
+ jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
10
+ jest.mock('../../store/mapContext.js', () => ({ useMap: jest.fn() }))
11
+ jest.mock('../../store/serviceContext.js', () => ({ useService: jest.fn() }))
12
+ jest.mock('../../../config/appConfig.js', () => ({ scaleFactor: { small: 1, medium: 1.5, large: 2 } }))
13
+
14
+ const makeEventBus = () => {
15
+ const listeners = {}
16
+ return {
17
+ on: jest.fn((e, fn) => { listeners[e] = fn }),
18
+ off: jest.fn(),
19
+ emit: (e, payload) => listeners[e]?.(payload)
20
+ }
21
+ }
22
+
23
+ const makeSymbolRegistry = (overrides = {}) => ({
24
+ get: jest.fn(() => ({ svg: '<circle/>', viewBox: '0 0 38 38', anchor: [0.5, 1] })),
25
+ getDefaults: jest.fn(() => ({ symbol: 'pin', viewBox: '0 0 38 38', anchor: [0.5, 1] })),
26
+ resolve: jest.fn(() => '<circle/>'),
27
+ resolveSelected: jest.fn(() => '<circle class="selected"/>'),
28
+ ...overrides
29
+ })
30
+
31
+ const makeMarker = (overrides = {}) => ({
32
+ id: 'marker-1', isVisible: true, symbol: 'pin', ...overrides
33
+ })
34
+
35
+ const setup = ({ markers = [], mapSize = 'small', eventBus, symbolRegistry, mapStyle = 'outdoor' } = {}) => {
36
+ const eb = eventBus ?? makeEventBus()
37
+ const sr = symbolRegistry ?? makeSymbolRegistry()
38
+ const markerRefs = new Map()
39
+ useConfig.mockReturnValue({ id: 'test-app' })
40
+ useMap.mockReturnValue({ mapStyle, mapSize })
41
+ useService.mockReturnValue({ symbolRegistry: sr, eventBus: eb })
42
+ useMarkers.mockReturnValue({
43
+ markers: { items: markers, markerRefs },
44
+ markerRef: (id) => (el) => { if (el) markerRefs.set(id, el) }
45
+ })
46
+ return { eb, sr, result: render(<Markers />) }
47
+ }
48
+
49
+ describe('Markers', () => {
50
+ it('renders nothing when mapStyle is not set', () => {
51
+ expect(setup({ mapStyle: null }).result.container.firstChild).toBeNull()
52
+ })
53
+
54
+ it('renders nothing when there are no markers', () => {
55
+ expect(setup().result.container.querySelectorAll('svg')).toHaveLength(0)
56
+ })
57
+
58
+ it('renders one svg per marker with correct id and classes', () => {
59
+ const { result } = setup({ markers: [makeMarker(), makeMarker({ id: 'b', symbol: undefined })] })
60
+ const [svg1, svg2] = result.container.querySelectorAll('svg')
61
+ expect(svg1.getAttribute('id')).toBe('test-app-marker-marker-1')
62
+ expect(svg1).toHaveClass('im-c-marker', 'im-c-marker--pin')
63
+ expect(svg2).toHaveClass('im-c-marker--pin')
64
+ })
65
+
66
+ it.each([
67
+ [true, 'block'],
68
+ [false, 'none']
69
+ ])('display is %s when isVisible=%s', (isVisible, display) => {
70
+ const svg = setup({ markers: [makeMarker({ isVisible })] }).result.container.querySelector('svg')
71
+ expect(svg).toHaveStyle({ display })
72
+ })
73
+
74
+ it('uses inline symbolSvgContent over the symbol registry', () => {
75
+ const sr = makeSymbolRegistry()
76
+ setup({ markers: [makeMarker({ symbolSvgContent: '<rect/>' })], symbolRegistry: sr })
77
+ expect(sr.get).not.toHaveBeenCalled()
78
+ })
79
+
80
+ it('falls back to defaults.symbolSvgContent', () => {
81
+ const sr = makeSymbolRegistry({
82
+ getDefaults: jest.fn(() => ({ symbolSvgContent: '<default-svg/>', viewBox: '0 0 38 38', anchor: [0.5, 1] }))
83
+ })
84
+ setup({ markers: [makeMarker({ symbol: undefined })], symbolRegistry: sr })
85
+ expect(sr.get).not.toHaveBeenCalled()
86
+ })
87
+
88
+ it('uses marker.viewBox when provided', () => {
89
+ const svg = setup({ markers: [makeMarker({ viewBox: '0 0 50 60' })] }).result.container.querySelector('svg')
90
+ expect(svg.getAttribute('viewBox')).toBe('0 0 50 60')
91
+ expect(svg.getAttribute('width')).toBe('50')
92
+ expect(svg.getAttribute('height')).toBe('60')
93
+ })
94
+
95
+ it("falls back to '0 0 38 38' viewBox when none is provided", () => {
96
+ const sr = makeSymbolRegistry({
97
+ get: jest.fn(() => ({ svg: '<circle/>' })),
98
+ getDefaults: jest.fn(() => ({ symbol: 'pin' }))
99
+ })
100
+ expect(setup({ markers: [makeMarker()], symbolRegistry: sr }).result.container.querySelector('svg').getAttribute('viewBox')).toBe('0 0 38 38')
101
+ })
102
+
103
+ it.each([
104
+ ['marker.anchor', makeMarker({ anchor: [0, 0] }), null, '0px', '0px'],
105
+ ['symbolDef.anchor', makeMarker(), { get: jest.fn(() => ({ svg: '<circle/>', viewBox: '0 0 38 38', anchor: [0, 0.5] })), getDefaults: jest.fn(() => ({ symbol: 'pin', viewBox: '0 0 38 38' })) }, '0px', '-19px'],
106
+ ['[0.5, 0.5] fallback', makeMarker(), { get: jest.fn(() => ({ svg: '<circle/>', viewBox: '0 0 38 38' })), getDefaults: jest.fn(() => ({ symbol: 'pin', viewBox: '0 0 38 38' })) }, '-19px', '-19px']
107
+ ])('resolveAnchor uses %s', (_, marker, srOverrides, left, top) => {
108
+ const sr = srOverrides ? makeSymbolRegistry(srOverrides) : undefined
109
+ expect(setup({ markers: [marker], symbolRegistry: sr }).result.container.querySelector('svg')).toHaveStyle({ marginLeft: left, marginTop: top })
110
+ })
111
+
112
+ it.each([
113
+ ['small', '38', '38'],
114
+ ['medium', '57', '57'],
115
+ ['large', '76', '76'],
116
+ ['huge', '38', '38']
117
+ ])('scales svg dimensions for mapSize=%s', (mapSize, width, height) => {
118
+ const svg = setup({ markers: [makeMarker()], mapSize }).result.container.querySelector('svg')
119
+ expect(svg.getAttribute('width')).toBe(width)
120
+ expect(svg.getAttribute('height')).toBe(height)
121
+ })
122
+
123
+ it('scales anchor offsets for medium mapSize', () => {
124
+ expect(setup({ markers: [makeMarker()], mapSize: 'medium' }).result.container.querySelector('svg'))
125
+ .toHaveStyle({ marginLeft: '-28.5px', marginTop: '-57px' })
126
+ })
127
+
128
+ it('adds selected class and calls resolveSelected when marker is selected', () => {
129
+ const { eb, sr, result } = setup({ markers: [makeMarker()] })
130
+ act(() => eb.emit('interact:selectionchange', { selectedMarkers: ['marker-1'] }))
131
+ expect(result.container.querySelector('svg')).toHaveClass('im-c-marker--selected')
132
+ expect(sr.resolveSelected).toHaveBeenCalled()
133
+ expect(sr.resolve).not.toHaveBeenCalledAfter?.(sr.resolveSelected)
134
+ })
135
+
136
+ it('uses resolve (not resolveSelected) for unselected markers', () => {
137
+ const { sr } = setup({ markers: [makeMarker()] })
138
+ expect(sr.resolve).toHaveBeenCalled()
139
+ expect(sr.resolveSelected).not.toHaveBeenCalled()
140
+ })
141
+
142
+ it.each([
143
+ ['explicit empty array', { selectedMarkers: [] }],
144
+ ['missing selectedMarkers key', {}]
145
+ ])('deselects when selectionchange has %s', (_, payload) => {
146
+ const { eb, result } = setup({ markers: [makeMarker()] })
147
+ act(() => eb.emit('interact:selectionchange', { selectedMarkers: ['marker-1'] }))
148
+ act(() => eb.emit('interact:selectionchange', payload))
149
+ expect(result.container.querySelector('svg')).not.toHaveClass('im-c-marker--selected')
150
+ })
151
+
152
+ it('wires interact:active and interact:selectionchange on mount and removes them on unmount', () => {
153
+ const { eb, result } = setup()
154
+ expect(eb.on).toHaveBeenCalledWith('interact:active', expect.any(Function))
155
+ expect(eb.on).toHaveBeenCalledWith('interact:selectionchange', expect.any(Function))
156
+ result.unmount()
157
+ expect(eb.off).toHaveBeenCalledWith('interact:active', expect.any(Function))
158
+ expect(eb.off).toHaveBeenCalledWith('interact:selectionchange', expect.any(Function))
159
+ })
160
+
161
+ describe('useMarkerCursor', () => {
162
+ let viewport
163
+
164
+ beforeEach(() => {
165
+ viewport = document.createElement('div')
166
+ viewport.className = 'im-c-viewport'
167
+ document.body.appendChild(viewport)
168
+ })
169
+
170
+ afterEach(() => {
171
+ if (viewport.parentNode) document.body.removeChild(viewport)
172
+ })
173
+
174
+ const activate = (eb) => act(() => eb.emit('interact:active', { active: true, interactionModes: ['selectMarker'] }))
175
+ const deactivate = (eb) => act(() => eb.emit('interact:active', { active: false, interactionModes: ['selectMarker'] }))
176
+ const fireMove = (clientX, clientY) => act(() => {
177
+ viewport.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX, clientY }))
178
+ })
179
+
180
+ const setupCursor = (markerBounds) => {
181
+ const eb = makeEventBus()
182
+ const markerRefs = new Map()
183
+ if (markerBounds) markerRefs.set('marker-1', { getBoundingClientRect: () => markerBounds })
184
+ useConfig.mockReturnValue({ id: 'test-app' })
185
+ useMap.mockReturnValue({ mapStyle: 'outdoor', mapSize: 'small' })
186
+ useService.mockReturnValue({ symbolRegistry: makeSymbolRegistry(), eventBus: eb })
187
+ useMarkers.mockReturnValue({ markers: { items: [makeMarker()], markerRefs }, markerRef: () => () => {} })
188
+ render(<Markers />)
189
+ return eb
190
+ }
191
+
192
+ it('does not track mousemove when interact is not active', () => {
193
+ setupCursor({ left: 0, top: 0, right: 50, bottom: 50 })
194
+ fireMove(20, 20)
195
+ expect(viewport.style.cursor).toBe('')
196
+ })
197
+
198
+ it('does not track mousemove when selectMarker is not in interactionModes', () => {
199
+ const eb = setupCursor({ left: 10, top: 10, right: 50, bottom: 50 })
200
+ act(() => eb.emit('interact:active', { active: true, interactionModes: ['selectFeature'] }))
201
+ fireMove(20, 20)
202
+ expect(viewport.style.cursor).toBe('')
203
+ })
204
+
205
+ it('does not track mousemove when interactionModes is absent from payload', () => {
206
+ const eb = setupCursor({ left: 10, top: 10, right: 50, bottom: 50 })
207
+ act(() => eb.emit('interact:active', { active: true }))
208
+ fireMove(20, 20)
209
+ expect(viewport.style.cursor).toBe('')
210
+ })
211
+
212
+ it('does not track mousemove when viewport element is absent', () => {
213
+ document.body.removeChild(viewport)
214
+ const eb = setupCursor({ left: 0, top: 0, right: 50, bottom: 50 })
215
+ activate(eb)
216
+ expect(viewport.style.cursor).toBe('')
217
+ })
218
+
219
+ it('sets cursor to pointer when mousemove lands inside a marker', () => {
220
+ const eb = setupCursor({ left: 10, top: 10, right: 50, bottom: 50 })
221
+ activate(eb)
222
+ fireMove(20, 20)
223
+ expect(viewport.style.cursor).toBe('pointer')
224
+ })
225
+
226
+ it.each([
227
+ ['outside all markers', 100, 100],
228
+ ['marker has no ref element', 20, 20]
229
+ ])('cursor stays empty when %s', (label, x, y) => {
230
+ const bounds = label.includes('no ref') ? null : { left: 10, top: 10, right: 50, bottom: 50 }
231
+ const eb = setupCursor(bounds)
232
+ activate(eb)
233
+ fireMove(x, y)
234
+ expect(viewport.style.cursor).toBe('')
235
+ })
236
+
237
+ it('clears cursor and stops tracking when interact becomes inactive', () => {
238
+ const eb = setupCursor({ left: 10, top: 10, right: 50, bottom: 50 })
239
+ activate(eb)
240
+ fireMove(20, 20)
241
+ expect(viewport.style.cursor).toBe('pointer')
242
+ deactivate(eb)
243
+ expect(viewport.style.cursor).toBe('')
244
+ })
245
+ })
246
+ })