@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
@@ -17,6 +17,7 @@
17
17
 
18
18
  // Components: specific UI components. Using 'CSS in Component' pattern
19
19
  @use '../App/components/KeyboardHelp/KeyboardHelp.module';
20
+ @use '../App/components/Tabs/Tabs.module';
20
21
  @use '../App/components/MapButton/MapButton.module';
21
22
  @use '../App/components/Panel/Panel.module';
22
23
  @use '../App/components/Icon/Icon.module';
@@ -25,8 +26,10 @@
25
26
  @use '../App/components/PopupMenu/PopupMenu.module';
26
27
  @use '../App/components/Attributions/Attributions.module';
27
28
  @use '../App/components/Logo/Logo.module';
29
+ @use '../App/components/Hints/Hints.module';
28
30
  @use '../App/components/CrossHair/CrossHair.module';
29
31
  @use '../App/components/Markers/Markers.module';
32
+ @use '../App/components/Viewport/Features.module';
30
33
  @use '../App/components/Actions/Actions.module';
31
34
 
32
35
  // Components that dont have a corresponding React Component
@@ -11,7 +11,6 @@
11
11
  /// @include border-focus-base($offset: -1px, $border: panel, $box-shadow: panel);
12
12
  /// ```
13
13
  @mixin border-focus-base($type: null, $is-inset: null) {
14
- position: relative;
15
14
  border: 0;
16
15
 
17
16
  // Border radius
@@ -31,6 +31,21 @@ class EventBus {
31
31
  return this
32
32
  }
33
33
 
34
+ /**
35
+ * Register a one-time handler that removes itself after the first call.
36
+ *
37
+ * @param {string} eventName
38
+ * @param {Function} handler
39
+ * @returns {this}
40
+ */
41
+ once (eventName, handler) {
42
+ const wrapper = (...args) => {
43
+ this.off(eventName, wrapper)
44
+ handler(...args)
45
+ }
46
+ return this.on(eventName, wrapper)
47
+ }
48
+
34
49
  /**
35
50
  * Remove a handler for an event. Omit `handler` to remove all handlers.
36
51
  *
@@ -82,6 +82,17 @@ describe('EventBus singleton', () => {
82
82
  console.error.mockRestore()
83
83
  })
84
84
 
85
+ it('once fires the handler exactly once', () => {
86
+ const handler = jest.fn()
87
+ eventBus.once('single', handler)
88
+
89
+ eventBus.emit('single', 'a')
90
+ eventBus.emit('single', 'b')
91
+
92
+ expect(handler).toHaveBeenCalledTimes(1)
93
+ expect(handler).toHaveBeenCalledWith('a')
94
+ })
95
+
85
96
  it('destroys all events', () => {
86
97
  const handler = jest.fn()
87
98
  eventBus.on('destroyMe', handler)
@@ -0,0 +1,47 @@
1
+ const stripHtml = (html) => html.replace(/<[^<>]*>/g, '')
2
+
3
+ /**
4
+ * Creates the hints service. Manages a single active toast hint shown in the
5
+ * Hints container. Calling show() replaces any current hint and
6
+ * restarts the dismiss timer. Internally calls announce() so screen readers
7
+ * receive the message through the live region without callers needing to pair
8
+ * the two calls manually.
9
+ */
10
+ export function createHints (announce) {
11
+ const subscribers = new Set()
12
+ let current = null
13
+ let timer = null
14
+
15
+ const notify = () => subscribers.forEach(fn => fn(current))
16
+
17
+ const clearTimer = () => {
18
+ if (timer) {
19
+ clearTimeout(timer)
20
+ timer = null
21
+ }
22
+ }
23
+
24
+ const dismiss = () => {
25
+ clearTimer()
26
+ current = null
27
+ notify()
28
+ }
29
+
30
+ const show = (html, options = {}) => {
31
+ const { duration = 4000, announce: announceText } = options
32
+ clearTimer()
33
+ current = { html }
34
+ notify()
35
+ announce(announceText ?? stripHtml(html), 'plugin')
36
+ if (duration > 0) {
37
+ timer = setTimeout(dismiss, duration)
38
+ }
39
+ }
40
+
41
+ const subscribe = (fn) => {
42
+ subscribers.add(fn)
43
+ return () => subscribers.delete(fn)
44
+ }
45
+
46
+ return { show, dismiss, subscribe }
47
+ }
@@ -0,0 +1,110 @@
1
+ import { createHints } from './hints.js'
2
+
3
+ let announce
4
+ let hints
5
+
6
+ beforeEach(() => {
7
+ jest.useFakeTimers()
8
+ announce = jest.fn()
9
+ hints = createHints(announce)
10
+ })
11
+
12
+ afterEach(() => {
13
+ jest.useRealTimers()
14
+ })
15
+
16
+ // ─── show() ──────────────────────────────────────────────────────────────────
17
+
18
+ describe('show', () => {
19
+ it('notifies subscribers with the html payload', () => {
20
+ const sub = jest.fn()
21
+ hints.subscribe(sub)
22
+ hints.show('<kbd>Enter</kbd> to select')
23
+ expect(sub).toHaveBeenCalledWith({ html: '<kbd>Enter</kbd> to select' })
24
+ })
25
+
26
+ it('calls announce with stripped html', () => {
27
+ hints.show('<kbd>Enter</kbd> to select')
28
+ expect(announce).toHaveBeenCalledWith('Enter to select', 'plugin')
29
+ })
30
+
31
+ it('calls announce with custom text when announce option is provided', () => {
32
+ hints.show('<kbd>Alt+K</kbd> help', { announce: 'Press Alt+K for keyboard controls' })
33
+ expect(announce).toHaveBeenCalledWith('Press Alt+K for keyboard controls', 'plugin')
34
+ })
35
+
36
+ it('replaces an existing hint and resets the timer', () => {
37
+ const sub = jest.fn()
38
+ hints.subscribe(sub)
39
+ hints.show('first', { duration: 2000 })
40
+ hints.show('second', { duration: 2000 })
41
+ jest.advanceTimersByTime(2000)
42
+ expect(sub).toHaveBeenLastCalledWith(null)
43
+ // Only one dismiss fired (the second hint's timer)
44
+ expect(sub).toHaveBeenCalledTimes(3) // show1, show2, dismiss
45
+ })
46
+
47
+ it('auto-dismisses after duration', () => {
48
+ const sub = jest.fn()
49
+ hints.subscribe(sub)
50
+ hints.show('hello', { duration: 3000 })
51
+ jest.advanceTimersByTime(3000)
52
+ expect(sub).toHaveBeenLastCalledWith(null)
53
+ })
54
+
55
+ it('does not auto-dismiss when duration is 0', () => {
56
+ const sub = jest.fn()
57
+ hints.subscribe(sub)
58
+ hints.show('persistent', { duration: 0 })
59
+ jest.advanceTimersByTime(60000)
60
+ expect(sub).not.toHaveBeenCalledWith(null)
61
+ })
62
+ })
63
+
64
+ // ─── dismiss() ───────────────────────────────────────────────────────────────
65
+
66
+ describe('dismiss', () => {
67
+ it('clears the active hint and notifies', () => {
68
+ const sub = jest.fn()
69
+ hints.subscribe(sub)
70
+ hints.show('hello')
71
+ hints.dismiss()
72
+ expect(sub).toHaveBeenLastCalledWith(null)
73
+ })
74
+
75
+ it('cancels the auto-dismiss timer', () => {
76
+ const sub = jest.fn()
77
+ hints.subscribe(sub)
78
+ hints.show('hello', { duration: 3000 })
79
+ hints.dismiss()
80
+ sub.mockClear()
81
+ jest.advanceTimersByTime(3000)
82
+ expect(sub).not.toHaveBeenCalled()
83
+ })
84
+
85
+ it('is safe to call when no hint is active', () => {
86
+ expect(() => hints.dismiss()).not.toThrow()
87
+ })
88
+ })
89
+
90
+ // ─── subscribe / unsubscribe ──────────────────────────────────────────────────
91
+
92
+ describe('subscribe', () => {
93
+ it('returns an unsubscribe function', () => {
94
+ const sub = jest.fn()
95
+ const unsub = hints.subscribe(sub)
96
+ unsub()
97
+ hints.show('hello')
98
+ expect(sub).not.toHaveBeenCalled()
99
+ })
100
+
101
+ it('notifies multiple subscribers', () => {
102
+ const sub1 = jest.fn()
103
+ const sub2 = jest.fn()
104
+ hints.subscribe(sub1)
105
+ hints.subscribe(sub2)
106
+ hints.show('hello')
107
+ expect(sub1).toHaveBeenCalled()
108
+ expect(sub2).toHaveBeenCalled()
109
+ })
110
+ })
@@ -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