@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,4 +1,5 @@
1
- import { registerPatterns } from './patternImages.js'
1
+ import { addPatternsToMap } from './patternImages.js'
2
+ import { patternRegistry } from '../../../../src/services/patternRegistry.js'
2
3
 
3
4
  const OUTDOOR = 'outdoor'
4
5
 
@@ -30,182 +31,159 @@ const makeMap = (existingIds = []) => ({
30
31
  addImage: jest.fn()
31
32
  })
32
33
 
33
- const makePatternRegistry = (id = 'stripes', content = SVG_CONTENT) => ({
34
- get: jest.fn((name) => name === id ? { svgContent: content } : undefined)
35
- })
36
-
37
- // ─── registerPatterns ─────────────────────────────────────────────────────────
38
-
39
- describe('registerPatterns — registration', () => {
40
- it('returns early and does not touch map for empty configs', async () => {
41
- const map = makeMap()
42
- const registry = makePatternRegistry()
43
- await registerPatterns(map, [], OUTDOOR, registry)
44
- expect(map.hasImage).not.toHaveBeenCalled()
45
- expect(map.addImage).not.toHaveBeenCalled()
46
- })
47
-
48
- it('calls addImage for an inline fillPatternSvgContent config', async () => {
49
- const map = makeMap()
50
- const registry = makePatternRegistry()
51
- const config = { fillPatternSvgContent: SVG_CONTENT }
52
- await registerPatterns(map, [config], OUTDOOR, registry)
53
- expect(map.addImage).toHaveBeenCalledTimes(1)
54
- })
55
-
56
- it('skips addImage when image is already registered', async () => {
57
- const registry = makePatternRegistry()
58
- const config = { fillPattern: 'stripes' }
59
- const pixelRatio = 2
60
- const { getPatternImageId } = await import('../../../../src/utils/patternUtils.js')
61
- const existingId = getPatternImageId(config, OUTDOOR, registry, pixelRatio)
62
- const map = makeMap([existingId])
63
- await registerPatterns(map, [config], OUTDOOR, registry, pixelRatio)
64
- expect(map.addImage).not.toHaveBeenCalled()
34
+ // ─── addPatternsToMap ─────────────────────────────────────────────────────────
35
+ describe('addPatternsToMap', () => {
36
+ beforeEach(() => {
37
+ // Clear the registry and re-register a known pattern to ensure consistent test conditions
38
+ patternRegistry.clear()
39
+ patternRegistry.register('stripes', SVG_CONTENT)
65
40
  })
66
-
67
- it('skips config when pattern has no inner content', async () => {
68
- const map = makeMap()
69
- const emptyRegistry = { get: jest.fn(() => undefined) }
70
- await registerPatterns(map, [{ fillPattern: 'unknown' }], OUTDOOR, emptyRegistry)
71
- expect(map.addImage).not.toHaveBeenCalled()
41
+ describe('addPatternsToMap — registration', () => {
42
+ it('returns early and does not touch map for empty configs', async () => {
43
+ const map = makeMap()
44
+ await addPatternsToMap(map, [], OUTDOOR, patternRegistry)
45
+ expect(map.hasImage).not.toHaveBeenCalled()
46
+ expect(map.addImage).not.toHaveBeenCalled()
47
+ })
48
+
49
+ it('calls addImage for an inline fillPatternSvgContent config', async () => {
50
+ const map = makeMap()
51
+ const config = { fillPatternSvgContent: SVG_CONTENT }
52
+ await addPatternsToMap(map, [config], OUTDOOR, patternRegistry)
53
+ expect(map.addImage).toHaveBeenCalledTimes(1)
54
+ })
55
+
56
+ it('skips addImage when image is already registered', async () => {
57
+ const style = { fillPattern: 'stripes' }
58
+ const pixelRatio = 1
59
+ const map = makeMap(['pattern-mpxwil-2x'])
60
+ await addPatternsToMap(map, [style], OUTDOOR, patternRegistry, pixelRatio)
61
+ expect(map.addImage).not.toHaveBeenCalled()
62
+ })
63
+
64
+ it('skips config when pattern has no inner content', async () => {
65
+ const map = makeMap()
66
+ patternRegistry.clear()
67
+ await addPatternsToMap(map, [{ fillPattern: 'unknown' }], OUTDOOR, patternRegistry)
68
+ expect(map.addImage).not.toHaveBeenCalled()
69
+ })
70
+
71
+ it('skips config when neither fillPattern nor fillPatternSvgContent is set', async () => {
72
+ const map = makeMap()
73
+ await addPatternsToMap(map, [{ fillColor: '#ff0000' }], OUTDOOR, patternRegistry)
74
+ expect(map.addImage).not.toHaveBeenCalled()
75
+ })
76
+
77
+ it('processes multiple configs in parallel', async () => {
78
+ const map = makeMap()
79
+ patternRegistry.register('dots', SVG_CONTENT)
80
+ await addPatternsToMap(map, [{ fillPattern: 'stripes' }, { fillPattern: 'dots' }], OUTDOOR, patternRegistry)
81
+ expect(map.addImage).toHaveBeenCalledTimes(2)
82
+ })
72
83
  })
73
84
 
74
- it('skips config when neither fillPattern nor fillPatternSvgContent is set', async () => {
75
- const map = makeMap()
76
- const registry = makePatternRegistry()
77
- await registerPatterns(map, [{ fillColor: '#ff0000' }], OUTDOOR, registry)
78
- expect(map.addImage).not.toHaveBeenCalled()
85
+ describe('addPatternsToMap pixel ratio', () => {
86
+ it('encodes effectiveRatio in the image ID and passes it to addImage', async () => {
87
+ const map = makeMap()
88
+ const config = { fillPattern: 'stripes' }
89
+ await addPatternsToMap(map, [config], OUTDOOR, patternRegistry, 2)
90
+ expect(map.addImage).toHaveBeenCalledTimes(1)
91
+ expect(map.addImage).toHaveBeenCalledWith(
92
+ expect.stringMatching(/^pattern-[a-z0-9]+-4x$/),
93
+ expect.any(Object),
94
+ { pixelRatio: 4 }
95
+ )
96
+ })
97
+
98
+ it('floors effectiveRatio at 2 so low-DPI patterns stay crisp', async () => {
99
+ const map = makeMap()
100
+ const config = { fillPattern: 'stripes' }
101
+ await addPatternsToMap(map, [config], OUTDOOR, patternRegistry, 1)
102
+ expect(map.addImage).toHaveBeenCalledWith(
103
+ expect.stringMatching(/-2x$/),
104
+ expect.any(Object),
105
+ { pixelRatio: 2 }
106
+ )
107
+ })
108
+
109
+ it('produces different image IDs for ratios above the floor', async () => {
110
+ const map1 = makeMap()
111
+ const map2 = makeMap()
112
+ const config = { fillPattern: 'stripes' }
113
+ const hiDpi = 3
114
+ await addPatternsToMap(map1, [config], OUTDOOR, patternRegistry, 2)
115
+ await addPatternsToMap(map2, [config], OUTDOOR, patternRegistry, hiDpi)
116
+ const [id2x] = map1.addImage.mock.calls[0]
117
+ const [id3x] = map2.addImage.mock.calls[0]
118
+ expect(id2x).not.toBe(id3x)
119
+ expect(id2x).toMatch(/-4x$/)
120
+ expect(id3x).toMatch(/-6x$/)
121
+ })
79
122
  })
80
123
 
81
- it('processes multiple configs in parallel', async () => {
82
- const map = makeMap()
83
- const registry = {
84
- get: jest.fn((name) => {
85
- if (name === 'stripes') { return { svgContent: '<path d="M0 0"/>' } }
86
- if (name === 'dots') { return { svgContent: '<circle cx="8" cy="8" r="4"/>' } }
87
- return undefined
88
- })
89
- }
90
- await registerPatterns(map, [{ fillPattern: 'stripes' }, { fillPattern: 'dots' }], OUTDOOR, registry)
91
- expect(map.addImage).toHaveBeenCalledTimes(2)
124
+ describe('addPatternsToMap color resolution and caching', () => {
125
+ it('applies foreground and background colors when resolving the SVG', async () => {
126
+ const map = makeMap()
127
+ const getContextSpy = HTMLCanvasElement.prototype.getContext
128
+ await addPatternsToMap(
129
+ map,
130
+ [{ fillPattern: 'stripes', fillPatternForegroundColor: '#aabbcc', fillPatternBackgroundColor: '#112233' }],
131
+ OUTDOOR,
132
+ patternRegistry
133
+ )
134
+ expect(map.addImage).toHaveBeenCalledTimes(1)
135
+ expect(getContextSpy).toHaveBeenCalled()
136
+ })
137
+
138
+ it('resolves style-keyed foreground color for the given mapStyleId', async () => {
139
+ const map = makeMap()
140
+ const config = {
141
+ fillPattern: 'stripes',
142
+ fillPatternForegroundColor: { outdoor: '#aabbcc', dark: '#112233' }
143
+ }
144
+ await addPatternsToMap(map, [config], OUTDOOR, patternRegistry)
145
+ const map2 = makeMap()
146
+ await addPatternsToMap(map2, [config], 'dark', patternRegistry)
147
+ const [idOutdoor] = map.addImage.mock.calls[0]
148
+ const [idDark] = map2.addImage.mock.calls[0]
149
+ expect(idOutdoor).not.toBe(idDark)
150
+ })
151
+
152
+ it('uses cached ImageData on second call with identical config', async () => {
153
+ const map = makeMap()
154
+ const config = { fillPattern: 'stripes', fillPatternForegroundColor: '#unique2' }
155
+ const pixelRatio = 2
156
+ await addPatternsToMap(map, [config], OUTDOOR, patternRegistry, pixelRatio)
157
+ const imageId = patternRegistry.getPatternImageId(config, OUTDOOR, pixelRatio)
158
+ const map2 = makeMap([imageId])
159
+ await addPatternsToMap(map2, [config], OUTDOOR, patternRegistry, pixelRatio)
160
+ expect(map2.addImage).not.toHaveBeenCalled()
161
+ })
92
162
  })
93
- })
94
163
 
95
- describe('registerPatternspixel ratio', () => {
96
- it('encodes effectiveRatio in the image ID and passes it to addImage', async () => {
97
- const map = makeMap()
98
- const registry = makePatternRegistry()
99
- const config = { fillPattern: 'stripes' }
100
- await registerPatterns(map, [config], OUTDOOR, registry, 2)
101
- expect(map.addImage).toHaveBeenCalledTimes(1)
102
- expect(map.addImage).toHaveBeenCalledWith(
103
- expect.stringMatching(/^pattern-[a-z0-9]+-2x$/),
104
- expect.any(Object),
105
- { pixelRatio: 2 }
106
- )
107
- })
164
+ describe('addPatternsToMapnull results', () => {
165
+ it('does not call addImage when innerContent becomes unavailable inside rasterisePattern', async () => {
166
+ const getPatternInnerContent = jest.spyOn(patternRegistry, 'getPatternInnerContent').mockReturnValue(undefined)
167
+ const getPatternImageId = jest.spyOn(patternRegistry, 'getPatternImageId').mockReturnValue('test-id')
108
168
 
109
- it('floors effectiveRatio at 2 so low-DPI patterns stay crisp', async () => {
110
- const map = makeMap()
111
- const registry = makePatternRegistry()
112
- const config = { fillPattern: 'stripes' }
113
- await registerPatterns(map, [config], OUTDOOR, registry, 1)
114
- expect(map.addImage).toHaveBeenCalledWith(
115
- expect.stringMatching(/-2x$/),
116
- expect.any(Object),
117
- { pixelRatio: 2 }
118
- )
119
- })
169
+ const map = makeMap()
170
+ await addPatternsToMap(map, [{ fillPattern: 'stripes' }], OUTDOOR, patternRegistry)
171
+ expect(map.addImage).not.toHaveBeenCalled()
120
172
 
121
- it('produces different image IDs for ratios above the floor', async () => {
122
- const map1 = makeMap()
123
- const map2 = makeMap()
124
- const registry = makePatternRegistry()
125
- const config = { fillPattern: 'stripes' }
126
- const hiDpi = 3
127
- await registerPatterns(map1, [config], OUTDOOR, registry, 2)
128
- await registerPatterns(map2, [config], OUTDOOR, registry, hiDpi)
129
- const [id2x] = map1.addImage.mock.calls[0]
130
- const [id3x] = map2.addImage.mock.calls[0]
131
- expect(id2x).not.toBe(id3x)
132
- expect(id2x).toMatch(/-2x$/)
133
- expect(id3x).toMatch(/-3x$/)
134
- })
135
- })
173
+ getPatternImageId.mockRestore()
174
+ getPatternInnerContent.mockRestore()
175
+ })
136
176
 
137
- describe('registerPatterns color resolution and caching', () => {
138
- it('applies foreground and background colors when resolving the SVG', async () => {
139
- const map = makeMap()
140
- const registry = makePatternRegistry()
141
- const getContextSpy = HTMLCanvasElement.prototype.getContext
142
- await registerPatterns(
143
- map,
144
- [{ fillPattern: 'stripes', fillPatternForegroundColor: '#aabbcc', fillPatternBackgroundColor: '#112233' }],
145
- OUTDOOR,
146
- registry
147
- )
148
- expect(map.addImage).toHaveBeenCalledTimes(1)
149
- expect(getContextSpy).toHaveBeenCalled()
150
- })
151
-
152
- it('resolves style-keyed foreground color for the given mapStyleId', async () => {
153
- const map = makeMap()
154
- const registry = makePatternRegistry()
155
- const config = {
156
- fillPattern: 'stripes',
157
- fillPatternForegroundColor: { outdoor: '#aabbcc', dark: '#112233' }
158
- }
159
- await registerPatterns(map, [config], OUTDOOR, registry)
160
- const map2 = makeMap()
161
- await registerPatterns(map2, [config], 'dark', registry)
162
- const [idOutdoor] = map.addImage.mock.calls[0]
163
- const [idDark] = map2.addImage.mock.calls[0]
164
- expect(idOutdoor).not.toBe(idDark)
165
- })
166
-
167
- it('uses cached ImageData on second call with identical config', async () => {
168
- const map = makeMap()
169
- const registry = makePatternRegistry()
170
- const config = { fillPattern: 'stripes', fillPatternForegroundColor: '#unique2' }
171
- const pixelRatio = 2
172
- await registerPatterns(map, [config], OUTDOOR, registry, pixelRatio)
173
- const { getPatternImageId } = await import('../../../../src/utils/patternUtils.js')
174
- const imageId = getPatternImageId(config, OUTDOOR, registry, pixelRatio)
175
- const map2 = makeMap([imageId])
176
- await registerPatterns(map2, [config], OUTDOOR, registry, pixelRatio)
177
- expect(map2.addImage).not.toHaveBeenCalled()
178
- })
179
- })
180
-
181
- describe('registerPatterns — null results', () => {
182
- it('does not call addImage when innerContent becomes unavailable inside rasterisePattern', async () => {
183
- // registry.get returns content on the first call (for getPatternImageId in registerPatterns)
184
- // but undefined on the second call (for getPatternInnerContent inside rasterisePattern)
185
- const registry = {
186
- get: jest.fn()
187
- .mockReturnValueOnce({ svgContent: SVG_CONTENT })
188
- .mockReturnValueOnce(undefined)
189
- }
190
- const map = makeMap()
191
- await registerPatterns(map, [{ fillPattern: 'stripes' }], OUTDOOR, registry)
192
- expect(map.addImage).not.toHaveBeenCalled()
193
- })
194
-
195
- it('does not call addImage when imageId becomes unavailable inside rasterisePattern', async () => {
196
- // Three consecutive calls to registry.get:
197
- // 1. getPatternImageId in registerPatterns → returns content → imageId is truthy
198
- // 2. getPatternInnerContent directly in rasterisePattern → returns content → passes innerContent guard
199
- // 3. getPatternInnerContent inside getPatternImageId in rasterisePattern → returns undefined
200
- // → getPatternImageId returns null → hits the imageId null guard
201
- const registry = {
202
- get: jest.fn()
203
- .mockReturnValueOnce({ svgContent: SVG_CONTENT })
204
- .mockReturnValueOnce({ svgContent: SVG_CONTENT })
177
+ it('does not call addImage when imageId becomes unavailable inside rasterisePattern', async () => {
178
+ const getPatternInnerContent = jest.spyOn(patternRegistry, 'getPatternInnerContent').mockReturnValue('CONTENT')
179
+ const getPatternImageId = jest.spyOn(patternRegistry, 'getPatternImageId')
180
+ .mockReturnValueOnce('test-id')
205
181
  .mockReturnValueOnce(undefined)
206
- }
207
- const map = makeMap()
208
- await registerPatterns(map, [{ fillPattern: 'stripes' }], OUTDOOR, registry)
209
- expect(map.addImage).not.toHaveBeenCalled()
182
+ const map = makeMap()
183
+ await addPatternsToMap(map, [{ fillPattern: 'stripes' }], OUTDOOR, patternRegistry)
184
+ expect(map.addImage).not.toHaveBeenCalled()
185
+ getPatternImageId.mockRestore()
186
+ getPatternInnerContent.mockRestore()
187
+ })
210
188
  })
211
189
  })
@@ -10,8 +10,7 @@ const SVG_ERROR_PREVIEW_LENGTH = 80
10
10
  */
11
11
  export const rasteriseToImageData = (svgString, width, height) =>
12
12
  new Promise((resolve, reject) => {
13
- const blob = new Blob([svgString], { type: 'image/svg+xml' })
14
- const url = URL.createObjectURL(blob)
13
+ const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`
15
14
  const img = new Image(width, height)
16
15
  img.onload = () => {
17
16
  const canvas = document.createElement('canvas')
@@ -19,11 +18,9 @@ export const rasteriseToImageData = (svgString, width, height) =>
19
18
  canvas.height = height
20
19
  const ctx = canvas.getContext('2d')
21
20
  ctx.drawImage(img, 0, 0, width, height)
22
- URL.revokeObjectURL(url)
23
21
  resolve(ctx.getImageData(0, 0, width, height))
24
22
  }
25
23
  img.onerror = () => {
26
- URL.revokeObjectURL(url)
27
24
  reject(new Error(`Failed to rasterise SVG: ${svgString.slice(0, SVG_ERROR_PREVIEW_LENGTH)}`))
28
25
  }
29
26
  img.src = url
@@ -9,20 +9,25 @@ const ERROR_PREVIEW_LENGTH = 80
9
9
  // Length chosen to be well over ERROR_PREVIEW_LENGTH so truncation is exercised
10
10
  const LONG_CONTENT_LENGTH = 200
11
11
 
12
- beforeAll(() => {
13
- globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock')
14
- globalThis.URL.revokeObjectURL = jest.fn()
12
+ let imageInstances = []
15
13
 
14
+ beforeAll(() => {
16
15
  HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
17
16
  drawImage: jest.fn(),
18
17
  getImageData: jest.fn((_x, _y, w, h) => ({ width: w, height: h }))
19
18
  }))
19
+ })
20
+
21
+ beforeEach(() => {
22
+ imageInstances = []
23
+ jest.clearAllMocks()
20
24
 
21
25
  globalThis.Image = class {
22
26
  constructor (w, h) {
23
27
  this.width = w
24
28
  this.height = h
25
29
  this._src = ''
30
+ imageInstances.push(this)
26
31
  }
27
32
 
28
33
  get src () { return this._src }
@@ -30,40 +35,34 @@ beforeAll(() => {
30
35
  }
31
36
  })
32
37
 
33
- beforeEach(() => {
34
- jest.clearAllMocks()
35
- globalThis.URL.createObjectURL.mockReturnValue('blob:mock')
36
- })
37
-
38
38
  describe('rasteriseToImageData', () => {
39
- it('resolves with ImageData at the requested dimensions, draws via canvas, and revokes the blob URL', async () => {
39
+ it('resolves with ImageData at the requested dimensions and draws via canvas', async () => {
40
40
  const getContext = HTMLCanvasElement.prototype.getContext
41
41
  const result = await rasteriseToImageData(SVG, WIDTH, HEIGHT)
42
42
  expect(result).toMatchObject({ width: WIDTH, height: HEIGHT })
43
- expect(globalThis.URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
44
- expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock')
45
43
  const { drawImage, getImageData } = getContext.mock.results[0].value
46
44
  expect(drawImage).toHaveBeenCalledWith(expect.any(Object), 0, 0, WIDTH, HEIGHT)
47
45
  expect(getImageData).toHaveBeenCalledWith(0, 0, WIDTH, HEIGHT)
48
46
  })
49
47
 
50
- it('rejects with a truncated SVG preview and revokes the blob URL on error', async () => {
51
- const originalImage = globalThis.Image
48
+ it('sets img.src to a data URI', async () => {
49
+ await rasteriseToImageData(SVG, WIDTH, HEIGHT)
50
+ const src = imageInstances[0]._src
51
+ expect(src).toMatch(/^data:image\/svg\+xml;charset=utf-8,/)
52
+ expect(src).toContain(encodeURIComponent(SVG))
53
+ })
54
+
55
+ it('rejects with a truncated SVG preview on error', async () => {
52
56
  globalThis.Image = class {
53
57
  constructor (w, h) { this.width = w; this.height = h; this._src = '' }
54
58
  get src () { return this._src }
55
59
  set src (val) { this._src = val; this.onerror?.() }
56
60
  }
57
- try {
58
- const longSvg = `<svg>${'x'.repeat(LONG_CONTENT_LENGTH)}</svg>`
59
- const error = await rasteriseToImageData(longSvg, WIDTH, HEIGHT).catch(e => e)
60
- expect(error.message).toMatch('Failed to rasterise SVG')
61
- const preview = error.message.replace('Failed to rasterise SVG: ', '')
62
- expect(preview).toHaveLength(ERROR_PREVIEW_LENGTH)
63
- expect(preview).toBe(longSvg.slice(0, ERROR_PREVIEW_LENGTH))
64
- expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock')
65
- } finally {
66
- globalThis.Image = originalImage
67
- }
61
+ const longSvg = `<svg>${'x'.repeat(LONG_CONTENT_LENGTH)}</svg>`
62
+ const error = await rasteriseToImageData(longSvg, WIDTH, HEIGHT).catch(e => e)
63
+ expect(error.message).toMatch('Failed to rasterise SVG')
64
+ const preview = error.message.replace('Failed to rasterise SVG: ', '')
65
+ expect(preview).toHaveLength(ERROR_PREVIEW_LENGTH)
66
+ expect(preview).toBe(longSvg.slice(0, ERROR_PREVIEW_LENGTH))
68
67
  })
69
68
  })
@@ -1,17 +1,5 @@
1
- import { getSymbolDef, getSymbolStyleColors, getSymbolViewBox } from '../../../../src/utils/symbolUtils.js'
2
- import { rasteriseToImageData } from './rasteriseToImageData.js'
3
-
4
1
  const ANCHOR_LOW = 0.25
5
2
  const ANCHOR_HIGH = 0.75
6
- const HASH_BASE = 36
7
-
8
- const hashString = (str) => {
9
- let hash = 0
10
- for (const ch of str) {
11
- hash = Math.trunc(((hash << 5) - hash) + ch.codePointAt(0))
12
- }
13
- return Math.abs(hash).toString(HASH_BASE)
14
- }
15
3
 
16
4
  // ─── MapLibre-specific anchor conversion ──────────────────────────────────────
17
5
 
@@ -48,99 +36,48 @@ export const anchorToMaplibre = ([ax, ay]) => {
48
36
  return (y + (x && y ? '-' : '') + x) || 'center'
49
37
  }
50
38
 
51
- // ─── Image IDs ────────────────────────────────────────────────────────────────
52
-
53
- /**
54
- * Returns a deterministic image ID for a symbol in normal or selected state.
55
- * Based on the hash of the fully resolved SVG content and the pixel ratio.
56
- *
57
- * @param {Object} dataset
58
- * @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
59
- * @param {Object} symbolRegistry
60
- * @param {boolean} [selected=false]
61
- * @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor
62
- * @returns {string|null}
63
- */
64
- export const getSymbolImageId = (dataset, mapStyle, symbolRegistry, selected = false, pixelRatio = 2) => {
65
- const symbolDef = getSymbolDef(dataset, symbolRegistry)
66
- if (!symbolDef) {
67
- return null
68
- }
69
- const styleColors = getSymbolStyleColors(dataset)
70
- const resolved = selected
71
- ? symbolRegistry.resolveSelected(symbolDef, styleColors, mapStyle)
72
- : symbolRegistry.resolve(symbolDef, styleColors, mapStyle)
73
- return `symbol-${selected ? 'sel-' : ''}${hashString(resolved)}-${pixelRatio}x`
74
- }
75
-
76
- // ─── Rasterisation ────────────────────────────────────────────────────────────
77
-
78
- // Module-level cache: imageId → ImageData. Avoids re-rasterising identical symbols.
79
- const imageDataCache = new Map()
80
-
81
- const rasteriseSymbolImage = async (dataset, mapStyle, symbolRegistry, selected, pixelRatio) => {
82
- const symbolDef = getSymbolDef(dataset, symbolRegistry)
83
- if (!symbolDef) {
84
- return null
85
- }
86
- const styleColors = getSymbolStyleColors(dataset)
87
- const resolvedContent = selected
88
- ? symbolRegistry.resolveSelected(symbolDef, styleColors, mapStyle)
89
- : symbolRegistry.resolve(symbolDef, styleColors, mapStyle)
90
-
91
- const imageId = `symbol-${selected ? 'sel-' : ''}${hashString(resolvedContent)}-${pixelRatio}x`
92
-
93
- let imageData = imageDataCache.get(imageId)
94
- if (!imageData) {
95
- const viewBox = getSymbolViewBox(dataset, symbolDef)
96
- const [,, width, height] = viewBox.split(' ').map(Number)
97
- // Render at pixelRatio× to keep icons crisp at the current device DPI and map size.
98
- // MapLibre receives the matching pixelRatio so the image displays at its original logical size.
99
- const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="${width * pixelRatio}" height="${height * pixelRatio}" viewBox="${viewBox}">${resolvedContent}</svg>`
100
- imageData = await rasteriseToImageData(svgString, width * pixelRatio, height * pixelRatio)
101
- imageDataCache.set(imageId, imageData)
102
- }
103
-
104
- return { imageId, imageData }
105
- }
106
-
107
39
  /**
108
- * Register normal and selected symbol images for the given pre-resolved symbol configs.
40
+ * Register normal, active (both rings) and selected (black ring) symbol images.
109
41
  * Skips images that are already registered (safe to call on style change).
110
- * Updates `map._symbolImageMap` with normal→selected image ID pairs.
42
+ * Updates `map._activeSymbolImageMap` (normal→active) and `map._selectedSymbolImageMap` (normal→selected).
111
43
  *
112
44
  * Callers are responsible for resolving sublayers before calling this function
113
45
  * (see `getSymbolConfigs` in the datasets plugin adapter).
114
46
  *
115
47
  * @param {Object} map - MapLibre map instance
116
- * @param {Object[]} symbolConfigs - Flat list of datasets/merged-sublayers that have a symbol config
48
+ * @param {Object[]} styleArray - Flat list of datasets/merged-sublayers that have a symbol config
117
49
  * @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
118
50
  * @param {Object} symbolRegistry
119
51
  * @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor (computed by caller)
120
52
  * @returns {Promise<void>}
121
53
  */
122
- export const registerSymbols = async (map, symbolConfigs, mapStyle, symbolRegistry, pixelRatio = 2) => {
123
- if (!symbolConfigs.length) {
54
+ export const addSymbolsToMap = async (map, styleArray, mapStyle, symbolRegistry, pixelRatio = 2) => {
55
+ if (!styleArray.length) {
124
56
  return
125
57
  }
126
58
 
127
- // Reset the normal→selected image ID lookup so stale entries don't persist after a style change
128
- map._symbolImageMap = {}
59
+ map._activeSymbolImageMap = {}
60
+ map._selectedSymbolImageMap = {}
129
61
 
130
- await Promise.all(symbolConfigs.flatMap(config => {
131
- const normalId = getSymbolImageId(config, mapStyle, symbolRegistry, false, pixelRatio)
132
- const selectedId = getSymbolImageId(config, mapStyle, symbolRegistry, true, pixelRatio)
133
- if (normalId && selectedId) {
134
- map._symbolImageMap[normalId] = selectedId
62
+ await Promise.all(styleArray.flatMap(config => {
63
+ const normalId = symbolRegistry.getSymbolImageId(config, mapStyle, false, pixelRatio)
64
+ const activeId = symbolRegistry.getSymbolImageId(config, mapStyle, true, pixelRatio)
65
+ if (normalId && activeId) {
66
+ map._activeSymbolImageMap[normalId] = activeId
135
67
  }
136
- return [false, true].map(async (selected) => {
137
- const imageId = selected ? selectedId : normalId
138
- if (!imageId || map.hasImage(imageId)) {
68
+ return ['normal', 'active', 'selected'].map(async (variant) => {
69
+ const imageId = variant === 'active' ? activeId : normalId
70
+ if (variant !== 'selected' && (!imageId || map.hasImage(imageId))) {
139
71
  return
140
72
  }
141
- const result = await rasteriseSymbolImage(config, mapStyle, symbolRegistry, selected, pixelRatio)
142
- if (result && !map.hasImage(result.imageId)) {
143
- map.addImage(result.imageId, result.imageData, { pixelRatio })
73
+ const result = await symbolRegistry.rasteriseSymbolImage(config, mapStyle, variant, pixelRatio)
74
+ if (result) {
75
+ if (variant === 'selected' && normalId) {
76
+ map._selectedSymbolImageMap[normalId] = result.imageId
77
+ }
78
+ if (!map.hasImage(result.imageId)) {
79
+ map.addImage(result.imageId, result.imageData, { pixelRatio })
80
+ }
144
81
  }
145
82
  })
146
83
  }))