@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
@@ -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
+ }
@@ -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
+ })
@@ -7,11 +7,11 @@ export const Actions = ({ children }) => {
7
7
  const { openPanels, panelConfig, breakpoint } = useApp()
8
8
 
9
9
  const childArray = React.Children.toArray(children)
10
- const visibleChild = childArray.find(c => c.props?.isHidden === false)
10
+ const visibleChild = childArray.find(c => c.props?.isHidden === false && c.props?.variant !== 'touch')
11
11
 
12
12
  // If a panel exists above we need so css adjustment
13
13
  const isBottomSlotUsed = Object.keys(openPanels).some(panelId => {
14
- return breakpoint === 'mobile' && panelConfig[panelId]?.[breakpoint]?.slot === 'bottom'
14
+ return breakpoint === 'mobile' && panelConfig[panelId]?.[breakpoint]?.slot === 'drawer'
15
15
  })
16
16
 
17
17
  const className = [
@@ -6,19 +6,12 @@
6
6
  padding: var(--panel-margin);
7
7
  max-height: 200px;
8
8
 
9
- @media (prefers-reduced-motion: no-preference) {
10
- transition: max-height var(--duration) ease, opacity var(--duration) ease,
11
- padding-top var(--duration) ease, padding-bottom var(--duration) ease;
12
- }
13
-
14
9
  &--border-top {
15
10
  border-top: 1px solid var(--app-border-color);
16
11
  }
17
12
 
18
13
  &--hidden {
19
14
  max-height: 0;
20
- overflow: hidden;
21
- opacity: 0;
22
15
  padding-top: 0;
23
16
  padding-bottom: 0;
24
17
  border: 0;
@@ -29,7 +29,7 @@ describe('Actions component', () => {
29
29
 
30
30
  it('adds the border class if a bottom slot panel is open', () => {
31
31
  mockUseApp.openPanels = { key: {} }
32
- mockUseApp.panelConfig = { key: { mobile: { slot: 'bottom' } } }
32
+ mockUseApp.panelConfig = { key: { mobile: { slot: 'drawer' } } }
33
33
 
34
34
  render(<Actions slot='actions'>Content</Actions>)
35
35
  const container = screen.getByText('Content').closest('div')
@@ -3,8 +3,8 @@ import { getIconRegistry } from '../../registry/iconRegistry.js'
3
3
 
4
4
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
5
5
  // sonarjs/disable-next-line function-name
6
- export const Icon = ({ id, svgContent }) => {
7
- const icon = getIconRegistry()[id] || svgContent
6
+ export const Icon = ({ id, svgContent, isMenu }) => {
7
+ const icon = isMenu ? getIconRegistry().chevron : (getIconRegistry()[id] || svgContent)
8
8
 
9
9
  return (
10
10
  <svg
@@ -20,6 +20,7 @@ export const Icon = ({ id, svgContent }) => {
20
20
  aria-hidden='true'
21
21
  focusable='false'
22
22
  dangerouslySetInnerHTML={{ __html: icon }}
23
+ className={`im-c-icon${isMenu ? ' im-c-icon--narrow' : ''}`}
23
24
  />
24
25
  )
25
26
  }
@@ -0,0 +1,4 @@
1
+ .im-c-icon--narrow {
2
+ margin-left: -3px;
3
+ margin-right: -3px;
4
+ }
@@ -33,27 +33,66 @@ describe('Icon component', () => {
33
33
  it('renders the SVG from the registry when id is provided', () => {
34
34
  getIconRegistry.mockReturnValue({ close: '<path d="M0 0 L10 10"/>' })
35
35
  const { container } = render(<Icon id='close' />)
36
- expect(container.querySelector('svg').innerHTML).toContain('<path d="M0 0 L10 10"')
36
+ expect(container.querySelector('svg').innerHTML)
37
+ .toContain('<path d="M0 0 L10 10"')
37
38
  })
38
39
 
39
40
  it('falls back to svgContent if id not found in registry', () => {
40
41
  getIconRegistry.mockReturnValue({})
41
42
  const fallbackSVG = '<circle cx="5" cy="5" r="5"/>'
42
43
  const { container } = render(<Icon id='unknown' svgContent={fallbackSVG} />)
43
- expect(container.querySelector('svg').innerHTML).toContain('<circle cx="5" cy="5" r="5"')
44
+ expect(container.querySelector('svg').innerHTML)
45
+ .toContain('<circle cx="5" cy="5" r="5"')
44
46
  })
45
47
 
46
48
  it('renders svgContent directly if no id provided', () => {
47
49
  const fallbackSVG = '<rect x="0" y="0" width="10" height="10"/>'
48
50
  getIconRegistry.mockReturnValue({})
49
51
  const { container } = render(<Icon svgContent={fallbackSVG} />)
50
- expect(container.querySelector('svg').innerHTML).toContain('<rect x="0" y="0" width="10" height="10"')
52
+ expect(container.querySelector('svg').innerHTML)
53
+ .toContain('<rect x="0" y="0" width="10" height="10"')
51
54
  })
52
55
 
53
56
  it('uses registry icon if both id and svgContent provided', () => {
54
57
  getIconRegistry.mockReturnValue({ check: '<path d="M1 1 L5 5"/>' })
55
58
  const fallbackSVG = '<circle cx="5" cy="5" r="5"/>'
56
59
  const { container } = render(<Icon id='check' svgContent={fallbackSVG} />)
57
- expect(container.querySelector('svg').innerHTML).toContain('<path d="M1 1 L5 5"')
60
+ expect(container.querySelector('svg').innerHTML)
61
+ .toContain('<path d="M1 1 L5 5"')
62
+ })
63
+
64
+ it('uses chevron icon when isMenu is true', () => {
65
+ getIconRegistry.mockReturnValue({
66
+ chevron: '<path d="M2 2 L8 8"/>',
67
+ close: '<path d="M0 0 L10 10"/>'
68
+ })
69
+ const { container } = render(<Icon id='close' isMenu />)
70
+ expect(container.querySelector('svg').innerHTML).toContain('<path d="M2 2 L8 8"')
71
+ })
72
+
73
+ it('ignores id and svgContent when isMenu is true', () => {
74
+ getIconRegistry.mockReturnValue({
75
+ chevron: '<path d="M2 2 L8 8"/>'
76
+ })
77
+ const fallbackSVG = '<circle cx="5" cy="5" r="5"/>'
78
+ const { container } = render(
79
+ <Icon id='close' svgContent={fallbackSVG} isMenu />
80
+ )
81
+ expect(container.querySelector('svg').innerHTML).toContain('<path d="M2 2 L8 8"')
82
+ })
83
+
84
+ it('adds narrow class when isMenu is true', () => {
85
+ getIconRegistry.mockReturnValue({
86
+ chevron: '<path d="M2 2 L8 8"/>'
87
+ })
88
+ const { container } = render(<Icon isMenu />)
89
+ const svg = container.querySelector('svg')
90
+ expect(svg).toHaveClass('im-c-icon--narrow')
91
+ })
92
+
93
+ it('renders nothing if isMenu is true and chevron is missing', () => {
94
+ getIconRegistry.mockReturnValue({})
95
+ const { container } = render(<Icon isMenu />)
96
+ expect(container.querySelector('svg').innerHTML).toBe('')
58
97
  })
59
98
  })
@@ -46,6 +46,37 @@ const handleKeyUp = (e) => {
46
46
  }
47
47
  }
48
48
 
49
+ const captureMenuRect = (buttonRefs, buttonId, setMenuRect) => {
50
+ const btn = buttonRefs.current[buttonId]
51
+ if (!btn) {
52
+ return
53
+ }
54
+ setMenuRect(btn.getBoundingClientRect().toJSON())
55
+ }
56
+
57
+ /**
58
+ * Returns a keyup handler for buttons that control a popup menu.
59
+ * ArrowDown opens the menu at the first item; ArrowUp opens at the last.
60
+ * @param {boolean} hasMenu - Whether the button has a popup menu
61
+ * @param {Object} buttonRefs - React ref map of button elements
62
+ * @param {string} buttonId - Unique button identifier
63
+ * @param {Function} setMenuStartPos - State setter for menu start position
64
+ * @param {Function} setMenuRect - State setter for button bounding rect
65
+ * @param {Function} setIsPopupOpen - State setter for popup open state
66
+ * @returns {Function} Keyboard event handler
67
+ */
68
+ const makePopupKeyUpHandler = (hasMenu, buttonRefs, buttonId, setMenuStartPos, setMenuRect, setIsPopupOpen) => (e) => {
69
+ if (hasMenu && ['ArrowDown', 'ArrowUp'].includes(e.key)) {
70
+ e.preventDefault()
71
+ setMenuStartPos(e.key === 'ArrowUp' ? 'last' : 'first')
72
+ captureMenuRect(buttonRefs, buttonId, setMenuRect)
73
+ setIsPopupOpen(true)
74
+ }
75
+ }
76
+
77
+ const getButtonSlot = (panelId, buttonId) =>
78
+ panelId ? `${stringToKebab(buttonId)}-button` : undefined
79
+
49
80
  /**
50
81
  * Determines the controlled element (panel or popup menu) for ARIA attributes.
51
82
  * @param {Object} options - Configuration options
@@ -177,10 +208,13 @@ export const MapButton = ({
177
208
  const { buttonRefs } = useApp()
178
209
  const [isPopupOpen, setIsPopupOpen] = useState(false)
179
210
  const [menuStartPos, setMenuStartPos] = useState(null)
211
+ const [menuRect, setMenuRect] = useState(null)
180
212
  const menuRef = useRef(null)
181
213
 
182
214
  const Element = href ? 'a' : 'button'
183
215
  const hasMenu = menuItems?.length >= 1
216
+ const showIcon = iconId || iconSvgContent || hasMenu
217
+ const buttonSlot = getButtonSlot(panelId, buttonId)
184
218
  const controlledElement = getControlledElement({ idPrefix, panelId, buttonId, hasMenu })
185
219
 
186
220
  /**
@@ -197,6 +231,9 @@ export const MapButton = ({
197
231
  const isKeyboard = e.nativeEvent.pointerType === ''
198
232
  /* istanbul ignore next as pointerType can't be tested in jest */
199
233
  setMenuStartPos(isKeyboard ? 'first' : null)
234
+ if (!isPopupOpen) {
235
+ captureMenuRect(buttonRefs, buttonId, setMenuRect)
236
+ }
200
237
  setIsPopupOpen((prev) => !prev)
201
238
  }
202
239
  if (onClick) {
@@ -204,19 +241,7 @@ export const MapButton = ({
204
241
  }
205
242
  }
206
243
 
207
- /**
208
- * Handles key up events on buttons that control popup menus.
209
- * ArrowDown opens the menu at the first item.
210
- * ArrowUp opens the menu at the last item.
211
- * @param {React.KeyboardEvent} e - The keyboard event
212
- */
213
- const handleButtonKeyUp = e => {
214
- if (hasMenu && ['ArrowDown', 'ArrowUp'].includes(e.key)) {
215
- e.preventDefault()
216
- setMenuStartPos(e.key === 'ArrowUp' ? 'last' : 'first')
217
- setIsPopupOpen(true)
218
- }
219
- }
244
+ const handleButtonKeyUp = makePopupKeyUpHandler(hasMenu, buttonRefs, buttonId, setMenuStartPos, setMenuRect, setIsPopupOpen)
220
245
 
221
246
  const buttonProps = buildButtonProps({
222
247
  appId,
@@ -236,7 +261,7 @@ export const MapButton = ({
236
261
 
237
262
  const buttonEl = (
238
263
  <Element {...buttonProps}>
239
- {(iconId || iconSvgContent) && <Icon id={iconId} svgContent={iconSvgContent} />}
264
+ {showIcon && <Icon id={iconId} svgContent={iconSvgContent} isMenu={hasMenu} />}
240
265
  {showLabel && <span>{label}</span>}
241
266
  </Element>
242
267
  )
@@ -244,12 +269,12 @@ export const MapButton = ({
244
269
  return (
245
270
  <div
246
271
  className={buildWrapperClassNames(buttonId, showLabel)}
247
- data-button-slot={panelId ? `${stringToKebab(buttonId)}-button` : undefined}
272
+ data-button-slot={buttonSlot}
248
273
  style={isHidden ? { display: 'none' } : undefined}
249
274
  >
250
275
  {showLabel ? buttonEl : <Tooltip content={label}>{buttonEl}</Tooltip>}
251
- {panelId && <SlotRenderer slot={`${stringToKebab(buttonId)}-button`} />}
252
- {isPopupOpen && <PopupMenu popupMenuId={controlledElement.id} buttonId={buttonId} startPos={menuStartPos} menuRef={menuRef} items={menuItems} setIsOpen={setIsPopupOpen} />}
276
+ {buttonSlot && <SlotRenderer slot={buttonSlot} />}
277
+ {isPopupOpen && <PopupMenu popupMenuId={controlledElement.id} buttonId={buttonId} startPos={menuStartPos} menuRef={menuRef} items={menuItems} setIsOpen={setIsPopupOpen} buttonRect={menuRect} />}
253
278
  </div>
254
279
  )
255
280
  }
@@ -124,19 +124,6 @@
124
124
  }
125
125
  }
126
126
 
127
- .im-c-map-button--touch {
128
- background-color: var(--map-overlay-foreground-color);
129
- color: var(--map-overlay-halo-color);
130
-
131
- width: var(--touch-button-size);
132
- height: var(--touch-button-size);
133
- border-radius: 100%;
134
-
135
- &::before {
136
- box-shadow: none;
137
- }
138
- }
139
-
140
127
  .im-o-app__right {
141
128
  .im-c-button-group {
142
129
  display: flex;
@@ -185,6 +172,10 @@
185
172
  }
186
173
 
187
174
  // 4. State styles
175
+ .im-c-map-button[aria-haspopup="true"][aria-expanded="true"] svg {
176
+ transform: rotate(180deg);
177
+ }
178
+
188
179
  .im-c-map-button[aria-disabled="true"]:not(.im-c-map-button--primary):not(.im-c-map-button--tertiary) {
189
180
  svg, span {
190
181
  opacity: var(--disabled-button-opacity);