@defra/interactive-map 0.0.21-alpha → 0.0.23-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 (218) 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/esm/index.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/button-definition.md +39 -0
  8. package/docs/api/context.md +4 -4
  9. package/docs/api/map-style-config.md +11 -2
  10. package/docs/api/marker-config.md +38 -7
  11. package/docs/api/panel-definition.md +7 -3
  12. package/docs/api/symbol-config.md +10 -26
  13. package/docs/api/symbol-registry.md +28 -13
  14. package/docs/api.md +65 -4
  15. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  16. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  17. package/docs/examples/index.mdx +12 -0
  18. package/docs/examples/toggle-marker-label.mdx +89 -0
  19. package/docs/plugins/datasets.md +39 -1
  20. package/docs/plugins/interact.md +4 -11
  21. package/docusaurus.config.cjs +2 -1
  22. package/package.json +10 -12
  23. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -0
  24. package/plugins/beta/datasets/dist/css/index.css +2 -2
  25. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  26. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +2 -0
  27. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +2 -0
  28. package/plugins/beta/datasets/dist/umd/maplibre/index.js +2 -0
  29. package/plugins/beta/datasets/src/adapters/maplibre/index.js +1 -1
  30. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  31. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  32. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  33. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  34. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  35. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  36. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  37. package/plugins/beta/datasets/src/index.maplibre.umd.js +23 -0
  38. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  39. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  40. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  41. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  42. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  43. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  44. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  45. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  46. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  47. package/plugins/beta/use-location/src/manifest.js +1 -0
  48. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  49. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  50. package/plugins/interact/dist/umd/index.js +1 -1
  51. package/plugins/interact/src/InteractInit.jsx +23 -55
  52. package/plugins/interact/src/InteractInit.test.js +58 -80
  53. package/plugins/interact/src/defaults.js +1 -3
  54. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  55. package/plugins/interact/src/hooks/useAttachEvents.test.js +65 -0
  56. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  57. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  58. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  59. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  60. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  61. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  62. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  63. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  64. package/plugins/interact/src/index.js +0 -2
  65. package/plugins/interact/src/index.test.js +0 -2
  66. package/plugins/interact/src/manifest.js +37 -6
  67. package/plugins/interact/src/reducer.js +8 -4
  68. package/plugins/interact/src/reducer.test.js +18 -3
  69. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  70. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  71. package/plugins/interact/src/utils/spatial.js +0 -15
  72. package/plugins/interact/src/utils/spatial.test.js +1 -13
  73. package/plugins/search/dist/css/index.css +1 -1
  74. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  75. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  76. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  77. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  78. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  79. package/plugins/search/src/search.scss +1 -0
  80. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  81. package/providers/beta/esri/src/esriProvider.js +4 -0
  82. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  83. package/providers/maplibre/dist/esm/index.js +1 -1
  84. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  85. package/providers/maplibre/dist/umd/index.js +1 -1
  86. package/providers/maplibre/src/index.js +7 -1
  87. package/providers/maplibre/src/index.test.js +15 -2
  88. package/providers/maplibre/src/mapEvents.js +11 -5
  89. package/providers/maplibre/src/mapEvents.test.js +14 -3
  90. package/providers/maplibre/src/maplibreProvider.js +18 -11
  91. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  92. package/providers/maplibre/src/utils/highlightFeatures.js +102 -57
  93. package/providers/maplibre/src/utils/highlightFeatures.test.js +162 -51
  94. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  95. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  96. package/providers/maplibre/src/utils/patternImages.js +10 -12
  97. package/providers/maplibre/src/utils/patternImages.test.js +145 -167
  98. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  99. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  100. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  101. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  102. package/rollup.esm.mjs +1 -1
  103. package/src/App/components/Actions/Actions.module.scss +1 -0
  104. package/src/App/components/Actions/Actions.test.jsx +17 -4
  105. package/src/App/components/Hints/Hints.jsx +52 -0
  106. package/src/App/components/Hints/Hints.module.scss +37 -0
  107. package/src/App/components/Hints/Hints.test.jsx +104 -0
  108. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  109. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  110. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  111. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  112. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  113. package/src/App/components/Markers/Markers.jsx +44 -75
  114. package/src/App/components/Markers/Markers.module.scss +58 -0
  115. package/src/App/components/Markers/Markers.test.jsx +90 -113
  116. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  117. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  118. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  119. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  120. package/src/App/components/Panel/Panel.jsx +1 -1
  121. package/src/App/components/Panel/Panel.module.scss +1 -0
  122. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  123. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  124. package/src/App/components/Tabs/Tabs.jsx +68 -0
  125. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  126. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  127. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  128. package/src/App/components/Viewport/Features.jsx +35 -0
  129. package/src/App/components/Viewport/Features.module.scss +31 -0
  130. package/src/App/components/Viewport/Features.test.jsx +124 -0
  131. package/src/App/components/Viewport/MapController.jsx +1 -1
  132. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  133. package/src/App/components/Viewport/Viewport.jsx +35 -29
  134. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  135. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  136. package/src/App/controls/keyboardActions.js +9 -3
  137. package/src/App/controls/keyboardActions.test.js +122 -87
  138. package/src/App/controls/keyboardShortcuts.js +1 -14
  139. package/src/App/hooks/useFeatureFocus.js +199 -0
  140. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  141. package/src/App/hooks/useFeatureItems.js +30 -0
  142. package/src/App/hooks/useFeatureItems.test.js +97 -0
  143. package/src/App/hooks/useFocusVisible.js +4 -7
  144. package/src/App/hooks/useFocusVisible.test.js +6 -6
  145. package/src/App/hooks/useKeyboardHint.js +9 -34
  146. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  147. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  148. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  149. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  150. package/src/App/hooks/useLayoutMeasurements.test.js +30 -0
  151. package/src/App/hooks/useMapURLSync.js +3 -3
  152. package/src/App/hooks/useMapURLSync.test.js +20 -3
  153. package/src/App/hooks/useMarkerCursor.js +33 -0
  154. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  155. package/src/App/hooks/useMarkersAPI.js +32 -9
  156. package/src/App/hooks/useMarkersAPI.test.js +162 -94
  157. package/src/App/layout/Layout.jsx +2 -0
  158. package/src/App/layout/Layout.test.jsx +3 -0
  159. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  160. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  161. package/src/App/renderer/mapButtons.js +5 -1
  162. package/src/App/renderer/mapButtons.test.js +37 -1
  163. package/src/App/store/ServiceProvider.jsx +4 -1
  164. package/src/App/store/ServiceProvider.test.jsx +11 -0
  165. package/src/App/store/appActionsMap.js +5 -1
  166. package/src/App/store/appActionsMap.test.js +13 -0
  167. package/src/App/store/appReducer.js +1 -0
  168. package/src/App/store/mapActionsMap.js +2 -1
  169. package/src/InteractiveMap/InteractiveMap.js +13 -1
  170. package/src/InteractiveMap/InteractiveMap.test.js +6 -4
  171. package/src/InteractiveMap/behaviourController.js +1 -2
  172. package/src/InteractiveMap/buttonManager.js +1 -3
  173. package/src/InteractiveMap/buttonManager.test.js +16 -8
  174. package/src/InteractiveMap/domStateManager.js +2 -3
  175. package/src/InteractiveMap/historyManager.js +4 -3
  176. package/src/config/appConfig.js +3 -1
  177. package/src/config/appConfig.test.js +4 -0
  178. package/src/config/defaults.js +3 -2
  179. package/src/config/events.js +8 -0
  180. package/src/config/mapTheme.js +8 -4
  181. package/src/config/mergeConfig.js +8 -2
  182. package/src/config/mergeConfig.test.js +19 -0
  183. package/src/config/symbolConfig.js +23 -16
  184. package/src/scss/main.scss +3 -0
  185. package/src/scss/tools/_border-focus.scss +0 -1
  186. package/src/services/eventBus.js +15 -0
  187. package/src/services/eventBus.test.js +11 -0
  188. package/src/services/hints.js +47 -0
  189. package/src/services/hints.test.js +110 -0
  190. package/src/services/patternRegistry.js +69 -1
  191. package/src/services/patternRegistry.test.js +112 -1
  192. package/src/services/reverseGeocode.js +4 -0
  193. package/src/services/reverseGeocode.test.js +11 -1
  194. package/src/services/symbolRegistry.js +152 -17
  195. package/src/services/symbolRegistry.test.js +141 -31
  196. package/src/types.js +93 -16
  197. package/src/utils/constrainKeyboardFocus.js +5 -5
  198. package/src/utils/detectInterfaceType.js +27 -5
  199. package/src/utils/detectInterfaceType.test.js +29 -3
  200. package/src/utils/mapStateSync.js +2 -2
  201. package/src/utils/mapStateSync.test.js +18 -0
  202. package/src/utils/patternUtils.js +4 -67
  203. package/src/utils/patternUtils.test.js +1 -123
  204. package/src/utils/symbolUtils.js +11 -13
  205. package/src/utils/symbolUtils.test.js +18 -26
  206. package/webpack.dev.mjs +4 -0
  207. package/webpack.umd.mjs +9 -6
  208. package/plugins/beta/datasets/adapters/maplibre/dist/esm/im-datasets-ml-adapter.js +0 -1
  209. package/plugins/beta/datasets/adapters/maplibre/dist/umd/im-datasets-ml-adapter.js +0 -2
  210. package/plugins/beta/datasets/adapters/maplibre/dist/umd/index.js +0 -1
  211. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +0 -2
  212. package/plugins/beta/datasets/dist/umd/index.js +0 -2
  213. package/plugins/interact/dist/css/index.css +0 -1
  214. package/plugins/interact/src/interact.scss +0 -24
  215. /package/plugins/beta/datasets/{adapters/maplibre/dist → dist/adapters/maplibre}/esm/index.js +0 -0
  216. /package/plugins/beta/datasets/{adapters/maplibre/dist/umd/im-datasets-ml-adapter.js.LICENSE.txt → dist/umd/maplibre/im-datasets-maplibre-adapter.js.LICENSE.txt} +0 -0
  217. /package/plugins/beta/datasets/dist/umd/{im-datasets-plugin.js.LICENSE.txt → maplibre/im-datasets-plugin.js.LICENSE.txt} +0 -0
  218. /package/plugins/beta/datasets/dist/umd/{index.js.LICENSE.txt → maplibre/index.js.LICENSE.txt} +0 -0
@@ -1,21 +1,33 @@
1
1
  import { getValueForStyle } from '../utils/getValueForStyle.js'
2
2
  import { symbolDefaults, pin, circle, square, graphics } from '../config/symbolConfig.js'
3
- import { SCHEME_COLORS } from '../config/mapTheme.js'
3
+ import { getSymbolStyleColors, getSymbolViewBox } from '../utils/symbolUtils.js'
4
+ import { THEME_COLORS } from '../config/mapTheme.js'
5
+ import { rasteriseToImageData } from '../../providers/maplibre/src/utils/rasteriseToImageData.js'
4
6
 
5
7
  const symbols = new Map()
8
+ // Module-level cache: imageId → ImageData. Avoids re-rasterising identical symbols.
9
+ const imageDataCache = new Map()
6
10
  let _constructorDefaults = {}
7
11
 
12
+ const HASH_BASE = 36
13
+
14
+ const hashString = (str) => {
15
+ let hash = 0
16
+ for (const ch of str) {
17
+ hash = Math.trunc(((hash << 5) - hash) + ch.codePointAt(0))
18
+ }
19
+ return Math.abs(hash).toString(HASH_BASE)
20
+ }
21
+
8
22
  // Keys that are structural — not token values for SVG substitution
9
23
  const STRUCTURAL = new Set(['id', 'svg', 'viewBox', 'anchor', 'symbol', 'symbolSvgContent'])
10
24
 
11
- // selectedWidth is app-widenot overridable at symbol registration level.
12
- // selectedColor is a map style concern — always injected from mapStyle, never from cascade.
13
- const REGISTRY_EXCLUDED = new Set([...STRUCTURAL, 'selectedWidth'])
25
+ // selectedColor and activeColor are map style concerns always injected from mapStyle, never from cascade.
14
26
 
15
27
  function resolveValues (symbolDef, markerValues, mapStyle) {
16
28
  const mapStyleId = mapStyle?.id
17
29
  const symbolTokens = Object.fromEntries(
18
- Object.entries(symbolDef || {}).filter(([k]) => !REGISTRY_EXCLUDED.has(k))
30
+ Object.entries(symbolDef || {}).filter(([k]) => !STRUCTURAL.has(k))
19
31
  )
20
32
  const constructorTokens = Object.fromEntries(
21
33
  Object.entries(_constructorDefaults).filter(([k]) => !STRUCTURAL.has(k))
@@ -24,10 +36,11 @@ function resolveValues (symbolDef, markerValues, mapStyle) {
24
36
  Object.entries(markerValues).filter(([, v]) => v != null)
25
37
  )
26
38
  const merged = { ...symbolDefaults, ...constructorTokens, ...symbolTokens, ...defined }
27
- // haloColor and selectedColor are map style concerns — always injected from mapStyle, never from the cascade
28
- const scheme = SCHEME_COLORS[mapStyle?.mapColorScheme] ?? SCHEME_COLORS.light
39
+ // haloColor, selectedColor and activeColor are map style concerns — always injected from mapStyle, never from the cascade
40
+ const scheme = THEME_COLORS[mapStyle?.mapColorScheme] ?? THEME_COLORS.light
29
41
  merged.haloColor = mapStyle?.haloColor ?? scheme.haloColor
30
42
  merged.selectedColor = mapStyle?.selectedColor ?? scheme.selectedColor
43
+ merged.activeColor = mapStyle?.activeColor ?? scheme.activeColor
31
44
  if (typeof merged.graphic === 'string' && graphics[merged.graphic]) {
32
45
  merged.graphic = graphics[merged.graphic]
33
46
  }
@@ -77,37 +90,159 @@ export const symbolRegistry = {
77
90
  },
78
91
 
79
92
  /**
80
- * Resolve a symbol's SVG string for normal (unselected) rendering.
81
- * The selected ring is always hidden regardless of cascade values.
93
+ * Clears all registered symbols (including built-ins). Mainly for testing purposes.
94
+ */
95
+ clear () {
96
+ symbols.clear()
97
+ },
98
+
99
+ /**
100
+ * Clears all registered symbols (including built-ins). Mainly for testing purposes.
101
+ */
102
+ initialise () {
103
+ this.register(pin)
104
+ this.register(circle)
105
+ this.register(square)
106
+ },
107
+
108
+ /**
109
+ * Resolve a symbol's SVG string for normal (unselected, inactive) rendering.
110
+ * Both selectedColor and activeColor are set to 'none' — all rings hidden.
82
111
  *
83
112
  * @param {Object} symbolDef - Symbol definition
84
113
  * @param {Object} styleColors - Token overrides
85
- * @param {Object} mapStyle - Current map style config (provides selectedColor, haloColor)
114
+ * @param {Object} mapStyle - Current map style config (provides selectedColor, activeColor, haloColor)
86
115
  * @returns {string} Resolved SVG string
87
116
  */
88
117
  resolve (symbolDef, styleColors, mapStyle) {
89
118
  const colors = resolveValues(symbolDef, styleColors || {}, mapStyle)
90
119
  if (!symbolDef) { return '' }
91
- colors.selectedColor = ''
120
+ colors.selectedColor = 'none'
121
+ colors.activeColor = 'none'
92
122
  return resolveLayer(symbolDef.svg, colors)
93
123
  },
94
124
 
95
125
  /**
96
- * Resolve a symbol's SVG string for selected rendering.
97
- * selectedColor comes from mapStyle.selectedColor (or the hardcoded fallback).
126
+ * Resolve a symbol's SVG string for active (keyboard cursor) rendering.
127
+ * Both selectedColor (committed ring) and activeColor (focus ring) are shown simultaneously,
128
+ * so an item that is active always displays both rings regardless of selection state.
98
129
  *
99
130
  * @param {Object} symbolDef - Symbol definition
100
131
  * @param {Object} styleColors - Token overrides
101
- * @param {Object} mapStyle - Current map style config (provides selectedColor, haloColor)
132
+ * @param {Object} mapStyle - Current map style config (provides selectedColor, activeColor, haloColor)
133
+ * @returns {string} Resolved SVG string
134
+ */
135
+ resolveActive (symbolDef, styleColors, mapStyle) {
136
+ const colors = resolveValues(symbolDef, styleColors || {}, mapStyle)
137
+ if (!symbolDef) { return '' }
138
+ return resolveLayer(symbolDef.svg, colors)
139
+ },
140
+
141
+ /**
142
+ * Resolve a symbol's SVG string for committed-selection rendering.
143
+ * selectedColor (committed ring) is shown; activeColor is set to 'none'.
144
+ *
145
+ * @param {Object} symbolDef - Symbol definition
146
+ * @param {Object} styleColors - Token overrides
147
+ * @param {Object} mapStyle - Current map style config (provides selectedColor, activeColor, haloColor)
102
148
  * @returns {string} Resolved SVG string
103
149
  */
104
150
  resolveSelected (symbolDef, styleColors, mapStyle) {
105
151
  const colors = resolveValues(symbolDef, styleColors || {}, mapStyle)
106
152
  if (!symbolDef) { return '' }
153
+ colors.activeColor = 'none'
107
154
  return resolveLayer(symbolDef.svg, colors)
155
+ },
156
+
157
+ // ─── Image IDs ────────────────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Returns a deterministic image ID for a symbol in normal or selected state.
161
+ * Based on the hash of the fully resolved SVG content and the pixel ratio.
162
+ *
163
+ * @param {Object} style
164
+ * @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
165
+ * @param {boolean} [selected=false]
166
+ * @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor
167
+ * @returns {string|null}
168
+ */
169
+ getSymbolImageId (style, mapStyle, active = false, pixelRatio = 2) {
170
+ const symbolDef = this.getSymbolDef(style)
171
+ if (!symbolDef) {
172
+ return null
173
+ }
174
+ const styleColors = getSymbolStyleColors(style)
175
+ const resolved = active
176
+ ? this.resolveActive(symbolDef, styleColors, mapStyle)
177
+ : this.resolve(symbolDef, styleColors, mapStyle)
178
+ return `symbol-${active ? 'act-' : ''}${hashString(resolved)}-${pixelRatio}x`
179
+ },
180
+
181
+ /**
182
+ * Resolves the symbolDef for a style's symbol config.
183
+ *
184
+ * style.symbol is a string symbol ID (e.g. 'pin').
185
+ * style.symbolSvgContent is inline SVG content for a custom symbol.
186
+ *
187
+ * @param {Object} style
188
+ * @returns {Object|undefined}
189
+ */
190
+ getSymbolDef (style) {
191
+ if (style.symbolSvgContent) {
192
+ return { svg: style.symbolSvgContent }
193
+ }
194
+ if (style.symbol) {
195
+ return this.get(style.symbol)
196
+ }
197
+ return undefined
198
+ },
199
+
200
+ /**
201
+ * Rasterise one variant of a symbol to ImageData for use as a MapLibre image.
202
+ * Results are cached by imageId so identical symbols are only rendered once.
203
+ *
204
+ * @param {Object} style - Dataset or marker config with symbol properties
205
+ * @param {Object} mapStyle - Current map style config
206
+ * @param {'normal'|'active'|'selected'} variant
207
+ * - `'normal'` — no rings (default display)
208
+ * - `'active'` — both rings (keyboard cursor, yellow + black)
209
+ * - `'selected'` — selected ring only (black)
210
+ * @param {number} pixelRatio - Device pixel ratio × map size scale factor
211
+ * @returns {Promise<{imageId: string, imageData: ImageData}|null>}
212
+ */
213
+ async rasteriseSymbolImage (style, mapStyle, variant, pixelRatio) {
214
+ const symbolDef = this.getSymbolDef(style)
215
+ if (!symbolDef) {
216
+ return null
217
+ }
218
+ const styleColors = getSymbolStyleColors(style)
219
+ let resolvedContent, prefix
220
+ if (variant === 'active') {
221
+ resolvedContent = this.resolveActive(symbolDef, styleColors, mapStyle)
222
+ prefix = 'act-'
223
+ } else if (variant === 'selected') {
224
+ resolvedContent = this.resolveSelected(symbolDef, styleColors, mapStyle)
225
+ prefix = 'sel-'
226
+ } else {
227
+ resolvedContent = this.resolve(symbolDef, styleColors, mapStyle)
228
+ prefix = ''
229
+ }
230
+
231
+ const imageId = `symbol-${prefix}${hashString(resolvedContent)}-${pixelRatio}x`
232
+
233
+ let imageData = imageDataCache.get(imageId)
234
+ if (!imageData) {
235
+ const viewBox = getSymbolViewBox(style, symbolDef)
236
+ const [,, width, height] = viewBox.split(' ').map(Number)
237
+ // Render at pixelRatio× to keep icons crisp at the current device DPI and map size.
238
+ // MapLibre receives the matching pixelRatio so the image displays at its original logical size.
239
+ const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="${width * pixelRatio}" height="${height * pixelRatio}" viewBox="${viewBox}">${resolvedContent}</svg>`
240
+ imageData = await rasteriseToImageData(svgString, width * pixelRatio, height * pixelRatio)
241
+ imageDataCache.set(imageId, imageData)
242
+ }
243
+
244
+ return { imageId, imageData }
108
245
  }
109
246
  }
110
247
 
111
- symbolRegistry.register(pin)
112
- symbolRegistry.register(circle)
113
- symbolRegistry.register(square)
248
+ symbolRegistry.initialise()
@@ -1,12 +1,16 @@
1
1
  import { symbolRegistry } from './symbolRegistry.js'
2
- import { symbolDefaults } from '../config/symbolConfig.js'
3
- import { SCHEME_COLORS } from '../config/mapTheme.js'
2
+ import { symbolDefaults, pin } from '../config/symbolConfig.js'
3
+ import { THEME_COLORS } from '../config/mapTheme.js'
4
4
  import { getValueForStyle } from '../utils/getValueForStyle.js'
5
5
 
6
6
  const STYLE_ID = 'test'
7
7
  const mapStyle = { id: STYLE_ID }
8
+ const COLOR_OVERRIDE = '#ff0000'
9
+ const FILL_OVERRIDE = `fill="${COLOR_OVERRIDE}"`
8
10
 
9
11
  beforeEach(() => {
12
+ symbolRegistry.clear()
13
+ symbolRegistry.initialise()
10
14
  symbolRegistry.setDefaults({})
11
15
  })
12
16
 
@@ -58,20 +62,20 @@ describe('symbolRegistry — setDefaults / getDefaults', () => {
58
62
  })
59
63
 
60
64
  it('constructor defaults override hardcoded defaults', () => {
61
- symbolRegistry.setDefaults({ backgroundColor: '#ff0000', symbol: 'circle' })
65
+ symbolRegistry.setDefaults({ backgroundColor: COLOR_OVERRIDE, symbol: 'circle' })
62
66
  const defaults = symbolRegistry.getDefaults()
63
- expect(defaults.backgroundColor).toBe('#ff0000')
67
+ expect(defaults.backgroundColor).toBe(COLOR_OVERRIDE)
64
68
  expect(defaults.symbol).toBe('circle')
65
69
  })
66
70
 
67
71
  it('constructor defaults do not affect unset properties', () => {
68
- symbolRegistry.setDefaults({ backgroundColor: '#ff0000' })
72
+ symbolRegistry.setDefaults({ backgroundColor: COLOR_OVERRIDE })
69
73
  const defaults = symbolRegistry.getDefaults()
70
74
  expect(defaults.foregroundColor).toBe(symbolDefaults.foregroundColor)
71
75
  })
72
76
 
73
77
  it('setDefaults with null or undefined resets to hardcoded defaults', () => {
74
- symbolRegistry.setDefaults({ backgroundColor: '#ff0000' })
78
+ symbolRegistry.setDefaults({ backgroundColor: COLOR_OVERRIDE })
75
79
  symbolRegistry.setDefaults(null)
76
80
  expect(symbolRegistry.getDefaults().backgroundColor).toBe(symbolDefaults.backgroundColor)
77
81
  })
@@ -81,24 +85,23 @@ describe('symbolRegistry — resolve', () => {
81
85
  const BACKGROUND_SVG = '<path fill="{{backgroundColor}}"/>'
82
86
  const symbolDef = {
83
87
  id: 'test',
84
- svg: '<path fill="{{backgroundColor}}" stroke="{{haloColor}}" stroke-width="{{haloWidth}}"/><path fill="{{foregroundColor}}" stroke="{{selectedColor}}"/>'
88
+ svg: '<path fill="{{backgroundColor}}" stroke="{{haloColor}}"/><path fill="{{foregroundColor}}" stroke="{{selectedColor}}"/><path stroke="{{activeColor}}"/>'
85
89
  }
86
90
 
87
91
  it('injects default token values when no overrides given', () => {
88
92
  const resolved = symbolRegistry.resolve(symbolDef, {}, mapStyle)
89
93
  expect(resolved).toContain(`fill="${getValueForStyle(symbolDefaults.backgroundColor, STYLE_ID)}"`)
90
94
  expect(resolved).toContain(`fill="${getValueForStyle(symbolDefaults.foregroundColor, STYLE_ID)}"`)
91
- expect(resolved).toContain(`stroke-width="${symbolDefaults.haloWidth}"`)
92
95
  })
93
96
 
94
- it('always produces empty selectedColor tokenring is hidden', () => {
97
+ it('always produces empty selectedColor and activeColor tokens rings are hidden', () => {
95
98
  const resolved = symbolRegistry.resolve(symbolDef, {}, mapStyle)
96
- expect(resolved).toContain('stroke=""')
99
+ expect(resolved).toContain('stroke="none"')
97
100
  })
98
101
 
99
102
  it('uses light scheme haloColor when mapStyle has no haloColor', () => {
100
103
  const resolved = symbolRegistry.resolve(symbolDef, {}, mapStyle)
101
- expect(resolved).toContain(`stroke="${SCHEME_COLORS.light.haloColor}"`)
104
+ expect(resolved).toContain(`stroke="${THEME_COLORS.light.haloColor}"`)
102
105
  })
103
106
 
104
107
  it('uses mapStyle.haloColor when provided', () => {
@@ -107,8 +110,8 @@ describe('symbolRegistry — resolve', () => {
107
110
  })
108
111
 
109
112
  it('overrides default backgroundColor with a plain string', () => {
110
- const resolved = symbolRegistry.resolve(symbolDef, { backgroundColor: '#ff0000' }, mapStyle)
111
- expect(resolved).toContain('fill="#ff0000"')
113
+ const resolved = symbolRegistry.resolve(symbolDef, { backgroundColor: COLOR_OVERRIDE }, mapStyle)
114
+ expect(resolved).toContain(FILL_OVERRIDE)
112
115
  })
113
116
 
114
117
  it('overrides default with a style-keyed color', () => {
@@ -163,48 +166,70 @@ describe('symbolRegistry — resolve', () => {
163
166
  })
164
167
  })
165
168
 
166
- describe('symbolRegistry — resolveSelected', () => {
169
+ describe('symbolRegistry — resolveActive (keyboard cursor state)', () => {
167
170
  const symbolDef = {
168
- id: 'test-sel',
169
- svg: '<path stroke="{{selectedColor}}" stroke-width="{{selectedWidth}}"/><path fill="{{backgroundColor}}"/>'
171
+ id: 'test-active',
172
+ svg: '<path fill="{{selectedColor}}" stroke="{{activeColor}}" stroke-width="6"/><path fill="{{backgroundColor}}"/>'
170
173
  }
171
174
 
172
- it('uses light scheme selectedColor when mapStyle has no selectedColor', () => {
173
- const resolved = symbolRegistry.resolveSelected(symbolDef, {}, mapStyle)
174
- expect(resolved).toContain(`stroke="${SCHEME_COLORS.light.selectedColor}"`)
175
+ it('renders selectedColor from scheme when mapStyle has no selectedColor', () => {
176
+ const resolved = symbolRegistry.resolveActive(symbolDef, {}, mapStyle)
177
+ expect(resolved).toContain(`fill="${THEME_COLORS.light.selectedColor}"`)
178
+ })
179
+
180
+ it('renders activeColor from scheme when mapStyle has no activeColor', () => {
181
+ const resolved = symbolRegistry.resolveActive(symbolDef, {}, mapStyle)
182
+ expect(resolved).toContain(`stroke="${THEME_COLORS.light.activeColor}"`)
175
183
  })
176
184
 
177
185
  it('uses mapStyle.selectedColor when provided', () => {
178
- const resolved = symbolRegistry.resolveSelected(symbolDef, {}, { id: STYLE_ID, selectedColor: '#ff0000' })
179
- expect(resolved).toContain('stroke="#ff0000"')
186
+ const resolved = symbolRegistry.resolveActive(symbolDef, {}, { id: STYLE_ID, selectedColor: COLOR_OVERRIDE })
187
+ expect(resolved).toContain(FILL_OVERRIDE)
180
188
  })
181
189
 
182
- it('uses selectedWidth from symbolDefaults', () => {
183
- const resolved = symbolRegistry.resolveSelected(symbolDef, {}, mapStyle)
184
- expect(resolved).toContain(`stroke-width="${symbolDefaults.selectedWidth}"`)
190
+ it('uses mapStyle.activeColor when provided', () => {
191
+ const resolved = symbolRegistry.resolveActive(symbolDef, {}, { id: STYLE_ID, activeColor: '#00ff00' })
192
+ expect(resolved).toContain('stroke="#00ff00"')
185
193
  })
186
194
 
187
195
  it('handles null styleColors — uses cascade defaults', () => {
188
- const resolved = symbolRegistry.resolveSelected(symbolDef, null, mapStyle)
189
- expect(resolved).toContain(`stroke="${SCHEME_COLORS.light.selectedColor}"`)
196
+ const resolved = symbolRegistry.resolveActive(symbolDef, null, mapStyle)
197
+ expect(resolved).toContain(`fill="${THEME_COLORS.light.selectedColor}"`)
190
198
  })
191
199
 
192
200
  it('returns empty string for null symbolDef', () => {
193
- expect(symbolRegistry.resolveSelected(null, {}, mapStyle)).toBe('')
201
+ expect(symbolRegistry.resolveActive(null, {}, mapStyle)).toBe('')
194
202
  })
195
203
 
196
204
  it('symbol-level selectedColor is ignored — mapStyle wins', () => {
197
205
  const defWithSelected = { ...symbolDef, selectedColor: '#00ff00' }
198
- const resolved = symbolRegistry.resolveSelected(defWithSelected, {}, { id: STYLE_ID, selectedColor: '#ff0000' })
199
- expect(resolved).toContain('stroke="#ff0000"')
206
+ const resolved = symbolRegistry.resolveActive(defWithSelected, {}, { id: STYLE_ID, selectedColor: COLOR_OVERRIDE })
207
+ expect(resolved).toContain(FILL_OVERRIDE)
200
208
  })
201
209
 
202
210
  it('still resolves other tokens correctly', () => {
203
- const resolved = symbolRegistry.resolveSelected(symbolDef, { backgroundColor: '#d4351c' }, mapStyle)
211
+ const resolved = symbolRegistry.resolveActive(symbolDef, { backgroundColor: '#d4351c' }, mapStyle)
204
212
  expect(resolved).toContain('fill="#d4351c"')
205
213
  })
206
214
  })
207
215
 
216
+ describe('symbolRegistry — resolveSelected (committed selection state)', () => {
217
+ const symbolDef = {
218
+ id: 'test-selected',
219
+ svg: '<path fill="{{selectedColor}}"/>'
220
+ }
221
+
222
+ it('returns empty string for null symbolDef', () => {
223
+ expect(symbolRegistry.resolveSelected(null, {}, mapStyle)).toBe('')
224
+ })
225
+
226
+ it('handles null styleColors — uses cascade defaults', () => {
227
+ const resolved = symbolRegistry.resolveSelected(symbolDef, null, mapStyle)
228
+ expect(typeof resolved).toBe('string')
229
+ expect(resolved.length).toBeGreaterThan(0)
230
+ })
231
+ })
232
+
208
233
  describe('symbolRegistry — graphic token', () => {
209
234
  const graphicDef = {
210
235
  id: 'test-graphic',
@@ -257,6 +282,91 @@ describe('symbolRegistry — graphic token', () => {
257
282
  const pin = symbolRegistry.get('pin')
258
283
  const resolved = symbolRegistry.resolve(pin, {}, mapStyle)
259
284
  expect(resolved).toContain(`d="${pin.graphic}"`)
260
- expect(resolved).toContain('translate(19, 16) scale(0.8) translate(-8, -8)')
285
+ expect(resolved).toContain('translate(22, 19) scale(0.8) translate(-8, -8)')
286
+ })
287
+ })
288
+
289
+ // ─── getSymbolDef ─────────────────────────────────────────────────────────────
290
+
291
+ describe('getSymbolDef', () => {
292
+ it('returns undefined when dataset has no symbol', () => {
293
+ expect(symbolRegistry.getSymbolDef({})).toBeUndefined()
294
+ })
295
+
296
+ it('looks up string symbol id in the registry', () => {
297
+ expect(symbolRegistry.getSymbolDef({ symbol: 'pin' })).toBe(pin)
298
+ })
299
+
300
+ it('returns undefined for an unregistered string symbol', () => {
301
+ expect(symbolRegistry.getSymbolDef({ symbol: 'missing' })).toBeUndefined()
302
+ })
303
+
304
+ it('returns inline def from symbolSvgContent with svg key', () => {
305
+ const dataset = { symbolSvgContent: '<circle/>', symbolViewBox: '0 0 10 10' }
306
+ const result = symbolRegistry.getSymbolDef(dataset)
307
+ expect(result.svg).toBe('<circle/>')
308
+ })
309
+
310
+ it('symbolSvgContent takes precedence over symbol id', () => {
311
+ const result = symbolRegistry.getSymbolDef({ symbol: 'pin', symbolSvgContent: '<circle/>' })
312
+ expect(result.svg).toBe('<circle/>')
313
+ })
314
+ })
315
+
316
+ // ─── getSymbolImageId ─────────────────────────────────────────────────────────
317
+
318
+ describe('getSymbolImageId', () => {
319
+ it('returns null when dataset has no symbol', () => {
320
+ expect(symbolRegistry.getSymbolImageId({}, mapStyle)).toBeNull()
321
+ })
322
+
323
+ it('returns null for an unregistered symbol id', () => {
324
+ expect(symbolRegistry.getSymbolImageId({ symbol: 'does-not-exist' }, mapStyle)).toBeNull()
325
+ })
326
+
327
+ it('returns a string prefixed symbol- for normal state', () => {
328
+ const id = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle)
329
+ expect(typeof id).toBe('string')
330
+ expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
331
+ })
332
+
333
+ it('returns a string prefixed symbol-act- for active state', () => {
334
+ const id = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, true)
335
+ expect(typeof id).toBe('string')
336
+ expect(id).toMatch(/^symbol-act-[a-z0-9]+-\d+(\.\d+)?x$/)
337
+ })
338
+
339
+ it('normal and active ids differ for the same dataset', () => {
340
+ const normalId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, false)
341
+ const activeId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, true)
342
+ expect(normalId).not.toBe(activeId)
343
+ })
344
+
345
+ it('same dataset and style always produces the same id', () => {
346
+ const id1 = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle)
347
+ const id2 = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle)
348
+ expect(id1).toBe(id2)
349
+ })
350
+
351
+ it('different symbols produce different ids', () => {
352
+ const pinId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle)
353
+ const circleId = symbolRegistry.getSymbolImageId({ symbol: 'circle' }, mapStyle)
354
+ expect(pinId).not.toBe(circleId)
355
+ })
356
+
357
+ it('different backgrounds produce different ids', () => {
358
+ const redId = symbolRegistry.getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#ff0000' }, mapStyle)
359
+ const blueId = symbolRegistry.getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#0000ff' }, mapStyle)
360
+ expect(redId).not.toBe(blueId)
361
+ })
362
+
363
+ it('resolves inline symbolSvgContent', () => {
364
+ const dataset = {
365
+ symbolSvgContent: '<circle cx="19" cy="19" r="12" fill="{{backgroundColor}}"/>',
366
+ symbolViewBox: '0 0 38 38',
367
+ symbolAnchor: [0.5, 0.5]
368
+ }
369
+ const id = symbolRegistry.getSymbolImageId(dataset, mapStyle)
370
+ expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
261
371
  })
262
372
  })