@defra/interactive-map 0.0.16-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 (224) hide show
  1. package/assets/images/slot-map.svg +264 -0
  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/slots.md +16 -15
  11. package/docs/api/symbol-config.md +160 -0
  12. package/docs/api/symbol-registry.md +115 -0
  13. package/docs/api.md +25 -22
  14. package/docs/getting-started.md +4 -1
  15. package/docs/plugins/datasets.md +657 -0
  16. package/docs/plugins/interact.md +68 -43
  17. package/docs/plugins/search.md +15 -3
  18. package/docs/plugins.md +1 -1
  19. package/package.json +2 -2
  20. package/plugins/beta/datasets/dist/css/index.css +103 -15
  21. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  23. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  25. package/plugins/beta/datasets/src/DatasetsInit.jsx +29 -9
  26. package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +159 -0
  28. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +75 -0
  29. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +440 -0
  30. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  31. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  32. package/plugins/beta/datasets/src/api/addDataset.js +2 -8
  33. package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
  34. package/plugins/beta/datasets/src/api/getStyle.js +13 -0
  35. package/plugins/beta/datasets/src/api/removeDataset.js +2 -44
  36. package/plugins/beta/datasets/src/api/setData.js +10 -0
  37. package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
  38. package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
  39. package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
  40. package/plugins/beta/datasets/src/api/setStyle.js +22 -0
  41. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  42. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  43. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  44. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  45. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  46. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  47. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  48. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  49. package/plugins/beta/datasets/src/datasets.js +39 -56
  50. package/plugins/beta/datasets/src/defaults.js +44 -8
  51. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +34 -25
  52. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
  53. package/plugins/beta/datasets/src/index.js +2 -1
  54. package/plugins/beta/datasets/src/manifest.js +25 -17
  55. package/plugins/beta/datasets/src/panels/Key.jsx +51 -51
  56. package/plugins/beta/datasets/src/panels/Key.module.scss +59 -9
  57. package/plugins/beta/datasets/src/panels/Layers.jsx +132 -29
  58. package/plugins/beta/datasets/src/panels/Layers.module.scss +56 -8
  59. package/plugins/beta/datasets/src/reducer.js +134 -9
  60. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  61. package/plugins/beta/datasets/src/utils/bbox.js +7 -5
  62. package/plugins/beta/datasets/src/utils/filters.js +5 -2
  63. package/plugins/beta/datasets/src/utils/mergeSublayer.js +86 -0
  64. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  65. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  66. package/plugins/beta/draw-ml/dist/css/index.css +21 -1
  67. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  68. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  69. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  70. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  71. package/plugins/beta/draw-ml/src/draw.scss +0 -7
  72. package/plugins/beta/draw-ml/src/manifest.js +16 -16
  73. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  74. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  75. package/plugins/beta/frame/src/Frame.jsx +5 -5
  76. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  77. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  78. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  79. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  80. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  81. package/plugins/beta/map-styles/src/manifest.js +1 -1
  82. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  83. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
  84. package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
  85. package/plugins/beta/scale-bar/src/index.test.js +3 -3
  86. package/plugins/beta/scale-bar/src/manifest.js +3 -3
  87. package/plugins/beta/scale-bar/src/scaleBar.scss +2 -1
  88. package/plugins/interact/dist/css/index.css +1 -1
  89. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  90. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  91. package/plugins/interact/dist/umd/index.js +1 -1
  92. package/plugins/interact/src/InteractInit.jsx +14 -5
  93. package/plugins/interact/src/InteractInit.test.js +26 -6
  94. package/plugins/interact/src/api/enable.test.js +7 -7
  95. package/plugins/interact/src/defaults.js +4 -6
  96. package/plugins/interact/src/events.js +9 -6
  97. package/plugins/interact/src/events.test.js +28 -4
  98. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  99. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  100. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  101. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  102. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  103. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  104. package/plugins/interact/src/interact.scss +0 -7
  105. package/plugins/interact/src/manifest.js +14 -18
  106. package/plugins/interact/src/manifest.test.js +3 -1
  107. package/plugins/interact/src/reducer.js +23 -4
  108. package/plugins/interact/src/reducer.test.js +60 -11
  109. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  110. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  111. package/plugins/interact/src/utils/featureQueries.js +11 -6
  112. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  113. package/plugins/search/dist/css/index.css +1 -1
  114. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  115. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  116. package/plugins/search/src/Search.jsx +3 -1
  117. package/plugins/search/src/components/Form/Form.module.scss +2 -1
  118. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  119. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  120. package/plugins/search/src/events/formHandlers.js +3 -3
  121. package/plugins/search/src/events/formHandlers.test.js +1 -1
  122. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  123. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  124. package/plugins/search/src/utils/updateMap.js +3 -3
  125. package/plugins/search/src/utils/updateMap.test.js +3 -3
  126. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  127. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  128. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  129. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  130. package/providers/maplibre/dist/umd/index.js +1 -1
  131. package/providers/maplibre/src/appEvents.js +7 -0
  132. package/providers/maplibre/src/appEvents.test.js +18 -4
  133. package/providers/maplibre/src/maplibreProvider.js +52 -0
  134. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  135. package/providers/maplibre/src/utils/highlightFeatures.js +37 -7
  136. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -95
  137. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  138. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  139. package/providers/maplibre/src/utils/patternImages.js +70 -0
  140. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  141. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  142. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  143. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  144. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  145. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  146. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  147. package/src/App/components/Actions/Actions.jsx +2 -2
  148. package/src/App/components/Actions/Actions.module.scss +0 -7
  149. package/src/App/components/Actions/Actions.test.jsx +1 -1
  150. package/src/App/components/Icon/Icon.jsx +3 -2
  151. package/src/App/components/Icon/Icon.module.scss +4 -0
  152. package/src/App/components/Icon/Icon.test.jsx +43 -4
  153. package/src/App/components/MapButton/MapButton.jsx +42 -17
  154. package/src/App/components/MapButton/MapButton.module.scss +4 -13
  155. package/src/App/components/MapButton/MapButton.test.jsx +27 -3
  156. package/src/App/components/Markers/Markers.jsx +122 -27
  157. package/src/App/components/Markers/Markers.module.scss +0 -10
  158. package/src/App/components/Markers/Markers.test.jsx +246 -0
  159. package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
  160. package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
  161. package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
  162. package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
  163. package/src/App/hooks/useButtonStateEvaluator.js +12 -2
  164. package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
  165. package/src/App/hooks/useInterfaceAPI.js +6 -0
  166. package/src/App/hooks/useInterfaceAPI.test.js +156 -0
  167. package/src/App/hooks/useLayoutMeasurements.js +84 -18
  168. package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
  169. package/src/App/hooks/useMarkersAPI.js +2 -5
  170. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  171. package/src/App/layout/Layout.jsx +14 -9
  172. package/src/App/layout/Layout.test.jsx +6 -4
  173. package/src/App/layout/layout.module.scss +67 -29
  174. package/src/App/registry/pluginRegistry.js +1 -1
  175. package/src/App/renderer/HtmlElementHost.jsx +2 -1
  176. package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
  177. package/src/App/renderer/mapButtons.js +1 -1
  178. package/src/App/renderer/mapPanels.test.js +2 -2
  179. package/src/App/renderer/slotHelpers.js +2 -2
  180. package/src/App/renderer/slotHelpers.test.js +5 -5
  181. package/src/App/renderer/slots.js +9 -5
  182. package/src/App/store/AppProvider.jsx +3 -1
  183. package/src/App/store/AppProvider.test.jsx +1 -1
  184. package/src/App/store/ServiceProvider.jsx +8 -4
  185. package/src/App/store/appActionsMap.js +16 -0
  186. package/src/App/store/appActionsMap.test.js +27 -0
  187. package/src/App/store/appDispatchMiddleware.js +1 -1
  188. package/src/App/store/appDispatchMiddleware.test.js +2 -2
  189. package/src/App/store/appReducer.js +2 -0
  190. package/src/App/store/mapActionsMap.js +4 -6
  191. package/src/App/store/mapActionsMap.test.js +3 -2
  192. package/src/App/store/mapReducer.js +2 -1
  193. package/src/InteractiveMap/InteractiveMap.js +4 -0
  194. package/src/config/appConfig.js +5 -8
  195. package/src/config/appConfig.test.js +1 -2
  196. package/src/config/defaults.js +0 -2
  197. package/src/config/events.js +28 -0
  198. package/src/config/mapTheme.js +56 -0
  199. package/src/config/patternConfig.js +16 -0
  200. package/src/config/symbolConfig.js +80 -0
  201. package/src/scss/main.scss +1 -0
  202. package/src/scss/settings/_colors.scss +0 -9
  203. package/src/scss/settings/_dimensions.scss +0 -1
  204. package/src/services/patternRegistry.js +40 -0
  205. package/src/services/patternRegistry.test.js +48 -0
  206. package/src/services/symbolRegistry.js +113 -0
  207. package/src/services/symbolRegistry.test.js +262 -0
  208. package/src/types.js +93 -11
  209. package/src/utils/getSafeZoneInset.js +9 -7
  210. package/src/utils/getSafeZoneInset.test.js +10 -10
  211. package/src/utils/patternUtils.js +94 -0
  212. package/src/utils/patternUtils.test.js +160 -0
  213. package/src/utils/symbolUtils.js +85 -0
  214. package/src/utils/symbolUtils.test.js +156 -0
  215. package/webpack.dev.mjs +1 -1
  216. package/docs/api/slot-map.svg +0 -1
  217. package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
  218. package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
  219. package/plugins/beta/datasets/src/api/showDataset.js +0 -14
  220. package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
  221. package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
  222. package/plugins/beta/datasets/src/mapLayers.js +0 -164
  223. /package/src/{utils → services}/logger.js +0 -0
  224. /package/src/{utils → services}/logger.test.js +0 -0
@@ -23,7 +23,7 @@ jest.mock('../../renderer/SlotRenderer', () => ({
23
23
  }))
24
24
 
25
25
  jest.mock('../PopupMenu/PopupMenu', () => ({
26
- PopupMenu: ({ startPos, items }) => {
26
+ PopupMenu: ({ startPos, items, buttonRect }) => {
27
27
  let selectedIndex = -1
28
28
  if (startPos === 'first' && items?.length > 0) {
29
29
  selectedIndex = 0
@@ -31,7 +31,7 @@ jest.mock('../PopupMenu/PopupMenu', () => ({
31
31
  if (startPos === 'last' && items?.length > 0) {
32
32
  selectedIndex = items.length - 1
33
33
  }
34
- return <div data-testid='popup-menu' data-start-pos={String(startPos)} data-selected-index={String(selectedIndex)}>{items?.map((item, i) => <div key={i} data-testid={`menu-item-${i}`}>{item.label}</div>)}</div>
34
+ return <div data-testid='popup-menu' data-start-pos={String(startPos)} data-selected-index={String(selectedIndex)} data-has-rect={String(!!buttonRect)}>{items?.map((item, i) => <div key={i} data-testid={`menu-item-${i}`}>{item.label}</div>)}</div>
35
35
  }
36
36
  }))
37
37
 
@@ -41,7 +41,12 @@ const mockButtonRefs = { current: {} }
41
41
  jest.mock('../../store/appContext', () => ({ useApp: () => ({ buttonRefs: mockButtonRefs }) }))
42
42
 
43
43
  describe('MapButton', () => {
44
- beforeEach(() => { mockButtonRefs.current = {} })
44
+ beforeEach(() => {
45
+ mockButtonRefs.current = {}
46
+ Element.prototype.getBoundingClientRect = jest.fn(() => ({
47
+ toJSON: () => ({ top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 })
48
+ }))
49
+ })
45
50
 
46
51
  const renderButton = (props = {}) => render(<MapButton buttonId='Test' iconId='icon' label='Label' {...props} />)
47
52
  const getButton = () => screen.getByRole('button')
@@ -148,6 +153,19 @@ describe('MapButton', () => {
148
153
  expect(menu).toHaveAttribute('data-selected-index', String(expectedIndex))
149
154
  })
150
155
 
156
+ it('passes buttonRect to popup menu on open', () => {
157
+ renderButton({ menuItems: [{ label: 'Item' }] })
158
+ fireEvent.click(getButton())
159
+ expect(screen.getByTestId('popup-menu')).toHaveAttribute('data-has-rect', 'true')
160
+ })
161
+
162
+ it('captureMenuRect returns early when buttonRef is not stored', () => {
163
+ renderButton({ menuItems: [{ label: 'Item' }] })
164
+ mockButtonRefs.current = {}
165
+ fireEvent.click(getButton())
166
+ expect(screen.getByTestId('popup-menu')).toHaveAttribute('data-has-rect', 'false')
167
+ })
168
+
151
169
  it('does nothing for arrow keys when no menu', () => {
152
170
  renderButton()
153
171
  fireEvent.keyUp(getButton(), { key: 'ArrowDown' })
@@ -188,4 +206,10 @@ describe('MapButton', () => {
188
206
  fireEvent.keyUp(el, { key: 'Enter' })
189
207
  expect(spy).not.toHaveBeenCalled()
190
208
  })
209
+
210
+ it('renders no Icon when iconId, iconSvgContent and menuItems are all absent', () => {
211
+ render(<MapButton buttonId='Test' label='Label' />)
212
+ expect(screen.queryByRole('img', { hidden: true })).toBeNull()
213
+ expect(screen.queryByTestId('icon')).toBeNull()
214
+ })
191
215
  })
@@ -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
+ })