@defra/interactive-map 0.0.22-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 (190) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api/button-definition.md +39 -0
  7. package/docs/api/context.md +4 -4
  8. package/docs/api/map-style-config.md +11 -2
  9. package/docs/api/marker-config.md +38 -7
  10. package/docs/api/panel-definition.md +7 -3
  11. package/docs/api/symbol-config.md +10 -26
  12. package/docs/api/symbol-registry.md +28 -13
  13. package/docs/api.md +24 -2
  14. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  15. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  16. package/docs/examples/index.mdx +12 -0
  17. package/docs/examples/toggle-marker-label.mdx +89 -0
  18. package/docs/plugins/interact.md +4 -11
  19. package/docusaurus.config.cjs +2 -1
  20. package/package.json +8 -8
  21. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
  22. package/plugins/beta/datasets/dist/css/index.css +2 -2
  23. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
  25. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
  26. package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  28. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  29. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  30. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  31. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  32. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  33. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  34. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  35. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  36. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  37. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  38. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  39. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  40. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  41. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  42. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  43. package/plugins/beta/use-location/src/manifest.js +1 -0
  44. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  45. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  46. package/plugins/interact/dist/umd/index.js +1 -1
  47. package/plugins/interact/src/InteractInit.jsx +23 -55
  48. package/plugins/interact/src/InteractInit.test.js +58 -80
  49. package/plugins/interact/src/defaults.js +1 -3
  50. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  51. package/plugins/interact/src/hooks/useAttachEvents.test.js +65 -0
  52. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  53. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  54. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  55. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  56. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  57. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  58. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  59. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  60. package/plugins/interact/src/index.js +0 -2
  61. package/plugins/interact/src/index.test.js +0 -2
  62. package/plugins/interact/src/manifest.js +37 -6
  63. package/plugins/interact/src/reducer.js +8 -4
  64. package/plugins/interact/src/reducer.test.js +18 -3
  65. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  66. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  67. package/plugins/interact/src/utils/spatial.js +0 -15
  68. package/plugins/interact/src/utils/spatial.test.js +1 -13
  69. package/plugins/search/dist/css/index.css +1 -1
  70. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  71. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  72. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  73. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  74. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  75. package/plugins/search/src/search.scss +1 -0
  76. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  77. package/providers/beta/esri/src/esriProvider.js +4 -0
  78. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  79. package/providers/maplibre/dist/esm/index.js +1 -1
  80. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  81. package/providers/maplibre/dist/umd/index.js +1 -1
  82. package/providers/maplibre/src/index.js +1 -1
  83. package/providers/maplibre/src/index.test.js +9 -14
  84. package/providers/maplibre/src/mapEvents.js +11 -5
  85. package/providers/maplibre/src/mapEvents.test.js +14 -3
  86. package/providers/maplibre/src/maplibreProvider.js +18 -11
  87. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  88. package/providers/maplibre/src/utils/highlightFeatures.js +102 -57
  89. package/providers/maplibre/src/utils/highlightFeatures.test.js +162 -51
  90. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  91. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  92. package/providers/maplibre/src/utils/patternImages.js +10 -12
  93. package/providers/maplibre/src/utils/patternImages.test.js +145 -167
  94. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  95. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  96. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  97. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  98. package/src/App/components/Actions/Actions.module.scss +1 -0
  99. package/src/App/components/Actions/Actions.test.jsx +17 -4
  100. package/src/App/components/Hints/Hints.jsx +52 -0
  101. package/src/App/components/Hints/Hints.module.scss +37 -0
  102. package/src/App/components/Hints/Hints.test.jsx +104 -0
  103. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  104. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  105. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  106. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  107. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  108. package/src/App/components/Markers/Markers.jsx +44 -75
  109. package/src/App/components/Markers/Markers.module.scss +58 -0
  110. package/src/App/components/Markers/Markers.test.jsx +90 -113
  111. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  112. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  113. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  114. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  115. package/src/App/components/Panel/Panel.jsx +1 -1
  116. package/src/App/components/Panel/Panel.module.scss +1 -0
  117. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  118. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  119. package/src/App/components/Tabs/Tabs.jsx +68 -0
  120. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  121. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  122. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  123. package/src/App/components/Viewport/Features.jsx +35 -0
  124. package/src/App/components/Viewport/Features.module.scss +31 -0
  125. package/src/App/components/Viewport/Features.test.jsx +124 -0
  126. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  127. package/src/App/components/Viewport/Viewport.jsx +35 -29
  128. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  129. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  130. package/src/App/controls/keyboardActions.js +9 -3
  131. package/src/App/controls/keyboardActions.test.js +122 -87
  132. package/src/App/controls/keyboardShortcuts.js +1 -14
  133. package/src/App/hooks/useFeatureFocus.js +199 -0
  134. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  135. package/src/App/hooks/useFeatureItems.js +30 -0
  136. package/src/App/hooks/useFeatureItems.test.js +97 -0
  137. package/src/App/hooks/useFocusVisible.js +4 -7
  138. package/src/App/hooks/useFocusVisible.test.js +6 -6
  139. package/src/App/hooks/useKeyboardHint.js +9 -34
  140. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  141. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  142. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  143. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  144. package/src/App/hooks/useLayoutMeasurements.test.js +30 -0
  145. package/src/App/hooks/useMarkerCursor.js +33 -0
  146. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  147. package/src/App/hooks/useMarkersAPI.js +32 -9
  148. package/src/App/hooks/useMarkersAPI.test.js +162 -94
  149. package/src/App/layout/Layout.jsx +2 -0
  150. package/src/App/layout/Layout.test.jsx +3 -0
  151. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  152. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  153. package/src/App/renderer/mapButtons.js +5 -1
  154. package/src/App/renderer/mapButtons.test.js +37 -1
  155. package/src/App/store/ServiceProvider.jsx +4 -1
  156. package/src/App/store/ServiceProvider.test.jsx +11 -0
  157. package/src/App/store/appActionsMap.js +5 -1
  158. package/src/App/store/appActionsMap.test.js +13 -0
  159. package/src/App/store/appReducer.js +1 -0
  160. package/src/App/store/mapActionsMap.js +2 -1
  161. package/src/InteractiveMap/InteractiveMap.js +12 -0
  162. package/src/InteractiveMap/InteractiveMap.test.js +2 -0
  163. package/src/config/appConfig.js +3 -1
  164. package/src/config/appConfig.test.js +4 -0
  165. package/src/config/defaults.js +1 -1
  166. package/src/config/events.js +8 -0
  167. package/src/config/mapTheme.js +8 -4
  168. package/src/config/symbolConfig.js +23 -16
  169. package/src/scss/main.scss +3 -0
  170. package/src/scss/tools/_border-focus.scss +0 -1
  171. package/src/services/eventBus.js +15 -0
  172. package/src/services/eventBus.test.js +11 -0
  173. package/src/services/hints.js +47 -0
  174. package/src/services/hints.test.js +110 -0
  175. package/src/services/patternRegistry.js +69 -1
  176. package/src/services/patternRegistry.test.js +112 -1
  177. package/src/services/reverseGeocode.js +4 -0
  178. package/src/services/reverseGeocode.test.js +11 -1
  179. package/src/services/symbolRegistry.js +152 -17
  180. package/src/services/symbolRegistry.test.js +141 -31
  181. package/src/types.js +71 -14
  182. package/src/utils/constrainKeyboardFocus.js +5 -5
  183. package/src/utils/detectInterfaceType.js +27 -5
  184. package/src/utils/detectInterfaceType.test.js +29 -3
  185. package/src/utils/patternUtils.js +4 -67
  186. package/src/utils/patternUtils.test.js +1 -123
  187. package/src/utils/symbolUtils.js +11 -13
  188. package/src/utils/symbolUtils.test.js +18 -26
  189. package/plugins/interact/dist/css/index.css +0 -1
  190. package/plugins/interact/src/interact.scss +0 -24
@@ -1,5 +1,6 @@
1
1
  import { BUILT_IN_PATTERNS } from '../config/patternConfig.js'
2
-
2
+ import { getValueForStyle } from '../utils/getValueForStyle.js'
3
+ import { KEY_BORDER_PATH, getEffectivePixelRatio, injectColors, hashString } from '../utils/patternUtils.js'
3
4
  const patterns = new Map()
4
5
 
5
6
  export const patternRegistry = {
@@ -31,6 +32,73 @@ export const patternRegistry = {
31
32
  */
32
33
  list () {
33
34
  return [...patterns.values()]
35
+ },
36
+
37
+ /**
38
+ * Clears all registered patterns (including built-ins). Mainly for testing purposes.
39
+ */
40
+ clear () {
41
+ patterns.clear()
42
+ },
43
+
44
+ /**
45
+ * Returns the raw (un-coloured) inner SVG content for a style's pattern.
46
+ * Precedence: inline fillPatternSvgContent → named fillPattern from registry.
47
+ *
48
+ * @param {Object} style
49
+ * @returns {string|null}
50
+ */
51
+ getPatternInnerContent (style) {
52
+ if (style.fillPatternSvgContent) {
53
+ return style.fillPatternSvgContent
54
+ }
55
+ if (style.fillPattern) {
56
+ return this.get(style.fillPattern)?.svgContent ?? null
57
+ }
58
+ return null
59
+ },
60
+
61
+ /**
62
+ * Returns colour-injected SVG path content for use in Key panel pattern symbols.
63
+ * Returns { border, content } where border is the rounded-rect outline and content
64
+ * is the pattern fill. Returns null if the style has no pattern.
65
+ *
66
+ * @param {Object} style
67
+ * @param {string} mapStyleId
68
+ * @returns {{ border: string, content: string }|null}
69
+ */
70
+ getKeyPatternPaths (style, mapStyleId) {
71
+ const innerContent = this.getPatternInnerContent(style)
72
+ if (!innerContent) {
73
+ return null
74
+ }
75
+ const fg = getValueForStyle(style.fillPatternForegroundColor, mapStyleId) || 'black'
76
+ const bg = getValueForStyle(style.fillPatternBackgroundColor, mapStyleId) || 'transparent'
77
+ const borderStroke = getValueForStyle(style.stroke, mapStyleId) || fg
78
+ const keyPatternPaths = {
79
+ border: injectColors(KEY_BORDER_PATH, borderStroke, bg),
80
+ content: injectColors(innerContent, fg, bg)
81
+ }
82
+ return keyPatternPaths
83
+ },
84
+
85
+ /**
86
+ * Returns a deterministic image ID for a pattern + resolved colour + pixel ratio combination.
87
+ *
88
+ * @param {Object} dataset
89
+ * @param {string} mapStyleId
90
+ * @param {number} [pixelRatio=1]
91
+ * @returns {string|null}
92
+ */
93
+ getPatternImageId (dataset, mapStyleId, pixelRatio = 1) {
94
+ const innerContent = this.getPatternInnerContent(dataset)
95
+ if (!innerContent) {
96
+ return null
97
+ }
98
+ const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black'
99
+ const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
100
+ const effectiveRatio = getEffectivePixelRatio(pixelRatio)
101
+ return `pattern-${hashString(innerContent + fg + bg)}-${effectiveRatio}x`
34
102
  }
35
103
  }
36
104
 
@@ -1,5 +1,5 @@
1
1
  import { patternRegistry } from './patternRegistry.js'
2
-
2
+ import { KEY_BORDER_PATH } from '../utils/patternUtils.js'
3
3
  describe('patternRegistry', () => {
4
4
  describe('built-in patterns', () => {
5
5
  test.each([
@@ -45,4 +45,115 @@ describe('patternRegistry', () => {
45
45
  expect(all.every(p => p.id && p.svgContent)).toBe(true)
46
46
  })
47
47
  })
48
+
49
+ describe('getPatternInnerContent', () => {
50
+ test('returns fillPatternSvgContent when set (inline SVG takes precedence)', () => {
51
+ const style = { fillPatternSvgContent: '<path d="custom"/>', fillPattern: 'dot' }
52
+ expect(patternRegistry.getPatternInnerContent(style)).toBe('<path d="custom"/>')
53
+ })
54
+
55
+ test('returns svgContent from registry for a named fillPattern', () => {
56
+ const style = { fillPattern: 'dot' }
57
+ patternRegistry.register('dot', '<path d="M4 4" fill="{{foregroundColor}}"/>')
58
+ expect(patternRegistry.getPatternInnerContent(style)).toBe('<path d="M4 4" fill="{{foregroundColor}}"/>')
59
+ })
60
+
61
+ test('returns null for an unregistered fillPattern name', () => {
62
+ const style = { fillPattern: 'unknown-pattern' }
63
+ expect(patternRegistry.getPatternInnerContent(style)).toBeNull()
64
+ })
65
+
66
+ test('returns null when no pattern is configured', () => {
67
+ expect(patternRegistry.getPatternInnerContent({})).toBeNull()
68
+ })
69
+ })
70
+
71
+ describe('getKeyPatternPaths', () => {
72
+ test('returns border and content strings with colours injected', () => {
73
+ const dataset = {
74
+ fillPattern: 'dot',
75
+ fillPatternForegroundColor: 'red',
76
+ fillPatternBackgroundColor: 'white',
77
+ stroke: 'black'
78
+ }
79
+ const result = patternRegistry.getKeyPatternPaths(dataset, 'style-a')
80
+ expect(result).not.toBeNull()
81
+ expect(result.border).toContain('black') // stroke colour
82
+ expect(result.border).toContain('white') // background colour
83
+ expect(result.content).toContain('red') // foreground colour
84
+ expect(result.border).not.toContain('{{foregroundColor}}')
85
+ expect(result.content).not.toContain('{{foregroundColor}}')
86
+ })
87
+
88
+ test('returns null when no pattern content is found', () => {
89
+ expect(patternRegistry.getKeyPatternPaths({ fillPattern: 'unknown' }, 'style-a')).toBeNull()
90
+ })
91
+
92
+ test('falls back to "black" fg and "transparent" bg when colour properties are absent', () => {
93
+ const result = patternRegistry.getKeyPatternPaths({ fillPattern: 'dot' }, 'style-a')
94
+ expect(result).not.toBeNull()
95
+ expect(result.content).toContain('black')
96
+ expect(result.border).toContain('transparent')
97
+ })
98
+
99
+ test('border stroke falls back to foreground colour when stroke is absent', () => {
100
+ const result = patternRegistry.getKeyPatternPaths(
101
+ { fillPattern: 'dot', fillPatternForegroundColor: 'green' },
102
+ 'style-a'
103
+ )
104
+ expect(result).not.toBeNull()
105
+ // borderStroke falls back to fg ('green'), so the border uses green for both stroke and background
106
+ expect(result.border).toContain('green')
107
+ })
108
+
109
+ test('KEY_BORDER_PATH contains foregroundColor and backgroundColor tokens', () => {
110
+ expect(KEY_BORDER_PATH).toContain('{{foregroundColor}}')
111
+ expect(KEY_BORDER_PATH).toContain('{{backgroundColor}}')
112
+ })
113
+ })
114
+
115
+ describe('patternRegistry.getPatternImageId', () => {
116
+ test('returns a deterministic string id', () => {
117
+ const dataset = { fillPattern: 'dot', fillPatternForegroundColor: 'red', fillPatternBackgroundColor: 'blue' }
118
+ const id = patternRegistry.getPatternImageId(dataset, 'style-a')
119
+ expect(typeof id).toBe('string')
120
+ expect(id).toMatch(/^pattern-/)
121
+ expect(id).toBe(patternRegistry.getPatternImageId(dataset, 'style-a'))
122
+ })
123
+
124
+ test('returns null when no pattern content is found', () => {
125
+ expect(patternRegistry.getPatternImageId({ fillPattern: 'unknown' }, 'style-a')).toBeNull()
126
+ })
127
+
128
+ test('produces different ids for different colours', () => {
129
+ const base = { fillPattern: 'dot' }
130
+ const idA = patternRegistry.getPatternImageId({ ...base, fillPatternForegroundColor: 'red' }, 'style-a')
131
+ const idB = patternRegistry.getPatternImageId({ ...base, fillPatternForegroundColor: 'blue' }, 'style-a')
132
+ expect(idA).not.toBe(idB)
133
+ })
134
+
135
+ test('floors effective ratio at 2 * so low-DPI ids match 2x', () => {
136
+ const dataset = { fillPattern: 'dot' }
137
+ const id1x = patternRegistry.getPatternImageId(dataset, 'style-a', 1)
138
+ expect(id1x).toMatch(/-2x$/)
139
+ })
140
+
141
+ test('produces different ids for pixelRatios above the floor', () => {
142
+ const dataset = { fillPattern: 'dot' }
143
+ const id2x = patternRegistry.getPatternImageId(dataset, 'style-a', 2)
144
+ const id3x = patternRegistry.getPatternImageId(dataset, 'style-a', 3)
145
+ expect(id2x).not.toBe(id3x)
146
+ expect(id2x).toMatch(/-4x$/)
147
+ expect(id3x).toMatch(/-6x$/)
148
+ })
149
+
150
+ test('falls back to "black" foreground and "transparent" background when colours are absent', () => {
151
+ const id = patternRegistry.getPatternImageId({ fillPattern: 'dot' }, 'style-a')
152
+ const idExplicit = patternRegistry.getPatternImageId(
153
+ { fillPattern: 'dot', fillPatternForegroundColor: 'black', fillPatternBackgroundColor: 'transparent' },
154
+ 'style-a'
155
+ )
156
+ expect(id).toBe(idExplicit)
157
+ })
158
+ })
48
159
  })
@@ -7,6 +7,10 @@ export function createReverseGeocode ({ url, transformRequest, load }, crs) {
7
7
  }
8
8
  }
9
9
 
10
+ export function hasReverseGeocode () {
11
+ return reverseGeocodeFn !== null
12
+ }
13
+
10
14
  export function reverseGeocode (zoom, coord) {
11
15
  if (!reverseGeocodeFn) {
12
16
  throw new Error('ReverseGeocode not initialised')
@@ -1,5 +1,5 @@
1
1
  // reverseGeocode.test.js
2
- import { createReverseGeocode, reverseGeocode } from './reverseGeocode'
2
+ import { createReverseGeocode, reverseGeocode, hasReverseGeocode } from './reverseGeocode'
3
3
 
4
4
  describe('reverseGeocode module', () => {
5
5
  beforeEach(() => {
@@ -11,6 +11,10 @@ describe('reverseGeocode module', () => {
11
11
  expect(() => reverseGeocode(10, [0, 0])).toThrow('ReverseGeocode not initialised')
12
12
  })
13
13
 
14
+ it('hasReverseGeocode returns false before initialisation', () => {
15
+ expect(hasReverseGeocode()).toBe(false)
16
+ })
17
+
14
18
  it('sets reverseGeocodeFn via createReverseGeocode and calls providerFn correctly', async () => {
15
19
  const mockProviderFn = jest.fn().mockResolvedValue('result')
16
20
 
@@ -44,6 +48,12 @@ describe('reverseGeocode module', () => {
44
48
  expect(result).toBe('result')
45
49
  })
46
50
 
51
+ it('hasReverseGeocode returns true after initialisation', () => {
52
+ const config = { url: 'x', transformRequest: jest.fn(), load: jest.fn().mockResolvedValue(jest.fn()) }
53
+ createReverseGeocode(config, 'EPSG:4326')
54
+ expect(hasReverseGeocode()).toBe(true)
55
+ })
56
+
47
57
  it('supports multiple calls after initialisation', async () => {
48
58
  const mockProviderFn = jest.fn().mockResolvedValue('ok')
49
59
 
@@ -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()