@defra/interactive-map 0.0.16-alpha → 0.0.18-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/assets/images/slot-map.svg +264 -0
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/context.md +53 -7
  8. package/docs/api/map-style-config.md +41 -2
  9. package/docs/api/marker-config.md +53 -11
  10. package/docs/api/slots.md +16 -15
  11. package/docs/api/symbol-config.md +160 -0
  12. package/docs/api/symbol-registry.md +115 -0
  13. package/docs/api.md +25 -22
  14. package/docs/getting-started.md +4 -1
  15. package/docs/plugins/datasets.md +657 -0
  16. package/docs/plugins/interact.md +68 -43
  17. package/docs/plugins/search.md +15 -3
  18. package/docs/plugins.md +1 -1
  19. package/package.json +2 -2
  20. package/plugins/beta/datasets/dist/css/index.css +103 -15
  21. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  23. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  25. package/plugins/beta/datasets/src/DatasetsInit.jsx +29 -9
  26. package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +159 -0
  28. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +75 -0
  29. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +440 -0
  30. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  31. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  32. package/plugins/beta/datasets/src/api/addDataset.js +2 -8
  33. package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
  34. package/plugins/beta/datasets/src/api/getStyle.js +13 -0
  35. package/plugins/beta/datasets/src/api/removeDataset.js +2 -44
  36. package/plugins/beta/datasets/src/api/setData.js +10 -0
  37. package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
  38. package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
  39. package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
  40. package/plugins/beta/datasets/src/api/setStyle.js +22 -0
  41. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  42. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  43. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  44. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  45. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  46. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  47. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  48. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  49. package/plugins/beta/datasets/src/datasets.js +39 -56
  50. package/plugins/beta/datasets/src/defaults.js +44 -8
  51. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +34 -25
  52. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
  53. package/plugins/beta/datasets/src/index.js +2 -1
  54. package/plugins/beta/datasets/src/manifest.js +25 -17
  55. package/plugins/beta/datasets/src/panels/Key.jsx +51 -51
  56. package/plugins/beta/datasets/src/panels/Key.module.scss +59 -9
  57. package/plugins/beta/datasets/src/panels/Layers.jsx +132 -29
  58. package/plugins/beta/datasets/src/panels/Layers.module.scss +56 -8
  59. package/plugins/beta/datasets/src/reducer.js +134 -9
  60. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  61. package/plugins/beta/datasets/src/utils/bbox.js +7 -5
  62. package/plugins/beta/datasets/src/utils/filters.js +5 -2
  63. package/plugins/beta/datasets/src/utils/mergeSublayer.js +86 -0
  64. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  65. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  66. package/plugins/beta/draw-ml/dist/css/index.css +21 -1
  67. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  68. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  69. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  70. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  71. package/plugins/beta/draw-ml/src/draw.scss +0 -7
  72. package/plugins/beta/draw-ml/src/manifest.js +16 -16
  73. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  74. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  75. package/plugins/beta/frame/src/Frame.jsx +5 -5
  76. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  77. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  78. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  79. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  80. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  81. package/plugins/beta/map-styles/src/manifest.js +1 -1
  82. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  83. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
  84. package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
  85. package/plugins/beta/scale-bar/src/index.test.js +3 -3
  86. package/plugins/beta/scale-bar/src/manifest.js +3 -3
  87. package/plugins/beta/scale-bar/src/scaleBar.scss +2 -1
  88. package/plugins/interact/dist/css/index.css +1 -1
  89. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  90. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  91. package/plugins/interact/dist/umd/index.js +1 -1
  92. package/plugins/interact/src/InteractInit.jsx +14 -5
  93. package/plugins/interact/src/InteractInit.test.js +26 -6
  94. package/plugins/interact/src/api/enable.test.js +7 -7
  95. package/plugins/interact/src/defaults.js +4 -6
  96. package/plugins/interact/src/events.js +9 -6
  97. package/plugins/interact/src/events.test.js +28 -4
  98. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  99. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  100. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  101. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  102. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  103. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  104. package/plugins/interact/src/interact.scss +0 -7
  105. package/plugins/interact/src/manifest.js +14 -18
  106. package/plugins/interact/src/manifest.test.js +3 -1
  107. package/plugins/interact/src/reducer.js +23 -4
  108. package/plugins/interact/src/reducer.test.js +60 -11
  109. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  110. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  111. package/plugins/interact/src/utils/featureQueries.js +11 -6
  112. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  113. package/plugins/search/dist/css/index.css +1 -1
  114. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  115. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  116. package/plugins/search/src/Search.jsx +3 -1
  117. package/plugins/search/src/components/Form/Form.module.scss +2 -1
  118. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  119. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  120. package/plugins/search/src/events/formHandlers.js +3 -3
  121. package/plugins/search/src/events/formHandlers.test.js +1 -1
  122. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  123. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  124. package/plugins/search/src/utils/updateMap.js +3 -3
  125. package/plugins/search/src/utils/updateMap.test.js +3 -3
  126. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  127. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  128. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  129. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  130. package/providers/maplibre/dist/umd/index.js +1 -1
  131. package/providers/maplibre/src/appEvents.js +7 -0
  132. package/providers/maplibre/src/appEvents.test.js +18 -4
  133. package/providers/maplibre/src/maplibreProvider.js +52 -0
  134. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  135. package/providers/maplibre/src/utils/highlightFeatures.js +37 -7
  136. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -95
  137. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  138. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  139. package/providers/maplibre/src/utils/patternImages.js +70 -0
  140. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  141. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  142. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  143. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  144. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  145. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  146. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  147. package/src/App/components/Actions/Actions.jsx +2 -2
  148. package/src/App/components/Actions/Actions.module.scss +0 -7
  149. package/src/App/components/Actions/Actions.test.jsx +1 -1
  150. package/src/App/components/Icon/Icon.jsx +3 -2
  151. package/src/App/components/Icon/Icon.module.scss +4 -0
  152. package/src/App/components/Icon/Icon.test.jsx +43 -4
  153. package/src/App/components/MapButton/MapButton.jsx +42 -17
  154. package/src/App/components/MapButton/MapButton.module.scss +4 -13
  155. package/src/App/components/MapButton/MapButton.test.jsx +27 -3
  156. package/src/App/components/Markers/Markers.jsx +122 -27
  157. package/src/App/components/Markers/Markers.module.scss +0 -10
  158. package/src/App/components/Markers/Markers.test.jsx +246 -0
  159. package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
  160. package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
  161. package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
  162. package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
  163. package/src/App/hooks/useButtonStateEvaluator.js +12 -2
  164. package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
  165. package/src/App/hooks/useInterfaceAPI.js +6 -0
  166. package/src/App/hooks/useInterfaceAPI.test.js +156 -0
  167. package/src/App/hooks/useLayoutMeasurements.js +84 -18
  168. package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
  169. package/src/App/hooks/useMarkersAPI.js +2 -5
  170. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  171. package/src/App/layout/Layout.jsx +14 -9
  172. package/src/App/layout/Layout.test.jsx +6 -4
  173. package/src/App/layout/layout.module.scss +67 -29
  174. package/src/App/registry/pluginRegistry.js +1 -1
  175. package/src/App/renderer/HtmlElementHost.jsx +2 -1
  176. package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
  177. package/src/App/renderer/mapButtons.js +1 -1
  178. package/src/App/renderer/mapPanels.test.js +2 -2
  179. package/src/App/renderer/slotHelpers.js +2 -2
  180. package/src/App/renderer/slotHelpers.test.js +5 -5
  181. package/src/App/renderer/slots.js +9 -5
  182. package/src/App/store/AppProvider.jsx +3 -1
  183. package/src/App/store/AppProvider.test.jsx +1 -1
  184. package/src/App/store/ServiceProvider.jsx +8 -4
  185. package/src/App/store/appActionsMap.js +16 -0
  186. package/src/App/store/appActionsMap.test.js +27 -0
  187. package/src/App/store/appDispatchMiddleware.js +1 -1
  188. package/src/App/store/appDispatchMiddleware.test.js +2 -2
  189. package/src/App/store/appReducer.js +2 -0
  190. package/src/App/store/mapActionsMap.js +4 -6
  191. package/src/App/store/mapActionsMap.test.js +3 -2
  192. package/src/App/store/mapReducer.js +2 -1
  193. package/src/InteractiveMap/InteractiveMap.js +4 -0
  194. package/src/config/appConfig.js +5 -8
  195. package/src/config/appConfig.test.js +1 -2
  196. package/src/config/defaults.js +0 -2
  197. package/src/config/events.js +28 -0
  198. package/src/config/mapTheme.js +56 -0
  199. package/src/config/patternConfig.js +16 -0
  200. package/src/config/symbolConfig.js +80 -0
  201. package/src/scss/main.scss +1 -0
  202. package/src/scss/settings/_colors.scss +0 -9
  203. package/src/scss/settings/_dimensions.scss +0 -1
  204. package/src/services/patternRegistry.js +40 -0
  205. package/src/services/patternRegistry.test.js +48 -0
  206. package/src/services/symbolRegistry.js +113 -0
  207. package/src/services/symbolRegistry.test.js +262 -0
  208. package/src/types.js +93 -11
  209. package/src/utils/getSafeZoneInset.js +9 -7
  210. package/src/utils/getSafeZoneInset.test.js +10 -10
  211. package/src/utils/patternUtils.js +94 -0
  212. package/src/utils/patternUtils.test.js +160 -0
  213. package/src/utils/symbolUtils.js +85 -0
  214. package/src/utils/symbolUtils.test.js +156 -0
  215. package/webpack.dev.mjs +1 -1
  216. package/docs/api/slot-map.svg +0 -1
  217. package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
  218. package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
  219. package/plugins/beta/datasets/src/api/showDataset.js +0 -14
  220. package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
  221. package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
  222. package/plugins/beta/datasets/src/mapLayers.js +0 -164
  223. /package/src/{utils → services}/logger.js +0 -0
  224. /package/src/{utils → services}/logger.test.js +0 -0
@@ -0,0 +1,262 @@
1
+ import { symbolRegistry } from './symbolRegistry.js'
2
+ import { symbolDefaults } from '../config/symbolConfig.js'
3
+ import { SCHEME_COLORS } from '../config/mapTheme.js'
4
+ import { getValueForStyle } from '../utils/getValueForStyle.js'
5
+
6
+ const STYLE_ID = 'test'
7
+ const mapStyle = { id: STYLE_ID }
8
+
9
+ beforeEach(() => {
10
+ symbolRegistry.setDefaults({})
11
+ })
12
+
13
+ describe('symbolRegistry — built-in symbols', () => {
14
+ it('registers pin by default', () => {
15
+ const pin = symbolRegistry.get('pin')
16
+ expect(pin).toBeDefined()
17
+ expect(pin.id).toBe('pin')
18
+ expect(pin.anchor).toEqual([0.5, 0.9])
19
+ expect(typeof pin.svg).toBe('string')
20
+ })
21
+
22
+ it('registers circle by default', () => {
23
+ const circle = symbolRegistry.get('circle')
24
+ expect(circle).toBeDefined()
25
+ expect(circle.id).toBe('circle')
26
+ expect(circle.anchor).toEqual([0.5, 0.5])
27
+ })
28
+
29
+ it('lists both built-in symbols', () => {
30
+ const ids = symbolRegistry.list().map(s => s.id)
31
+ expect(ids).toContain('pin')
32
+ expect(ids).toContain('circle')
33
+ })
34
+ })
35
+
36
+ describe('symbolRegistry — register / get', () => {
37
+ it('registers and retrieves a custom symbol', () => {
38
+ const custom = {
39
+ id: 'test-diamond',
40
+ viewBox: '0 0 20 20',
41
+ anchor: [0.5, 0.5],
42
+ svg: '<rect fill="{{backgroundColor}}"/>'
43
+ }
44
+ symbolRegistry.register(custom)
45
+ expect(symbolRegistry.get('test-diamond')).toBe(custom)
46
+ })
47
+
48
+ it('returns undefined for an unregistered id', () => {
49
+ expect(symbolRegistry.get('does-not-exist')).toBeUndefined()
50
+ })
51
+ })
52
+
53
+ describe('symbolRegistry — setDefaults / getDefaults', () => {
54
+ it('getDefaults returns hardcoded defaults when no constructor defaults set', () => {
55
+ const defaults = symbolRegistry.getDefaults()
56
+ expect(defaults.symbol).toBe('pin')
57
+ expect(defaults.backgroundColor).toBe(symbolDefaults.backgroundColor)
58
+ })
59
+
60
+ it('constructor defaults override hardcoded defaults', () => {
61
+ symbolRegistry.setDefaults({ backgroundColor: '#ff0000', symbol: 'circle' })
62
+ const defaults = symbolRegistry.getDefaults()
63
+ expect(defaults.backgroundColor).toBe('#ff0000')
64
+ expect(defaults.symbol).toBe('circle')
65
+ })
66
+
67
+ it('constructor defaults do not affect unset properties', () => {
68
+ symbolRegistry.setDefaults({ backgroundColor: '#ff0000' })
69
+ const defaults = symbolRegistry.getDefaults()
70
+ expect(defaults.foregroundColor).toBe(symbolDefaults.foregroundColor)
71
+ })
72
+
73
+ it('setDefaults with null or undefined resets to hardcoded defaults', () => {
74
+ symbolRegistry.setDefaults({ backgroundColor: '#ff0000' })
75
+ symbolRegistry.setDefaults(null)
76
+ expect(symbolRegistry.getDefaults().backgroundColor).toBe(symbolDefaults.backgroundColor)
77
+ })
78
+ })
79
+
80
+ describe('symbolRegistry — resolve', () => {
81
+ const BACKGROUND_SVG = '<path fill="{{backgroundColor}}"/>'
82
+ const symbolDef = {
83
+ id: 'test',
84
+ svg: '<path fill="{{backgroundColor}}" stroke="{{haloColor}}" stroke-width="{{haloWidth}}"/><path fill="{{foregroundColor}}" stroke="{{selectedColor}}"/>'
85
+ }
86
+
87
+ it('injects default token values when no overrides given', () => {
88
+ const resolved = symbolRegistry.resolve(symbolDef, {}, mapStyle)
89
+ expect(resolved).toContain(`fill="${getValueForStyle(symbolDefaults.backgroundColor, STYLE_ID)}"`)
90
+ expect(resolved).toContain(`fill="${getValueForStyle(symbolDefaults.foregroundColor, STYLE_ID)}"`)
91
+ expect(resolved).toContain(`stroke-width="${symbolDefaults.haloWidth}"`)
92
+ })
93
+
94
+ it('always produces empty selectedColor token — ring is hidden', () => {
95
+ const resolved = symbolRegistry.resolve(symbolDef, {}, mapStyle)
96
+ expect(resolved).toContain('stroke=""')
97
+ })
98
+
99
+ it('uses light scheme haloColor when mapStyle has no haloColor', () => {
100
+ const resolved = symbolRegistry.resolve(symbolDef, {}, mapStyle)
101
+ expect(resolved).toContain(`stroke="${SCHEME_COLORS.light.haloColor}"`)
102
+ })
103
+
104
+ it('uses mapStyle.haloColor when provided', () => {
105
+ const resolved = symbolRegistry.resolve(symbolDef, {}, { id: STYLE_ID, haloColor: '#336699' })
106
+ expect(resolved).toContain('stroke="#336699"')
107
+ })
108
+
109
+ it('overrides default backgroundColor with a plain string', () => {
110
+ const resolved = symbolRegistry.resolve(symbolDef, { backgroundColor: '#ff0000' }, mapStyle)
111
+ expect(resolved).toContain('fill="#ff0000"')
112
+ })
113
+
114
+ it('overrides default with a style-keyed color', () => {
115
+ const resolved = symbolRegistry.resolve(symbolDef, { backgroundColor: { [STYLE_ID]: '#aabbcc', other: '#112233' } }, mapStyle)
116
+ expect(resolved).toContain('fill="#aabbcc"')
117
+ })
118
+
119
+ it('ignores null override values — defaults are preserved', () => {
120
+ const resolved = symbolRegistry.resolve(symbolDef, { backgroundColor: null }, mapStyle)
121
+ expect(resolved).toContain(`fill="${getValueForStyle(symbolDefaults.backgroundColor, STYLE_ID)}"`)
122
+ })
123
+
124
+ it('replaces custom tokens not in defaults', () => {
125
+ const customDef = { id: 'custom', svg: '<path fill="{{accentColor}}"/>' }
126
+ const resolved = symbolRegistry.resolve(customDef, { accentColor: '#123456' }, mapStyle)
127
+ expect(resolved).toContain('fill="#123456"')
128
+ })
129
+
130
+ it('handles null styleColors — uses all defaults', () => {
131
+ const resolved = symbolRegistry.resolve(symbolDef, null, mapStyle)
132
+ expect(resolved).toContain(`fill="${getValueForStyle(symbolDefaults.backgroundColor, STYLE_ID)}"`)
133
+ expect(resolved).toContain(`fill="${getValueForStyle(symbolDefaults.foregroundColor, STYLE_ID)}"`)
134
+ })
135
+
136
+ it('replaces token with empty string when override is an empty string', () => {
137
+ const def = { id: 'es', svg: BACKGROUND_SVG }
138
+ const resolved = symbolRegistry.resolve(def, { backgroundColor: '' }, mapStyle)
139
+ expect(resolved).toContain('fill=""')
140
+ })
141
+
142
+ it('returns empty string for null symbolDef', () => {
143
+ expect(symbolRegistry.resolve(null, {}, mapStyle)).toBe('')
144
+ })
145
+
146
+ it('constructor defaults take precedence over hardcoded defaults', () => {
147
+ symbolRegistry.setDefaults({ backgroundColor: '#abcdef' })
148
+ const resolved = symbolRegistry.resolve(symbolDef, {}, mapStyle)
149
+ expect(resolved).toContain('fill="#abcdef"')
150
+ })
151
+
152
+ it('symbol-level token defaults take precedence over constructor defaults', () => {
153
+ symbolRegistry.setDefaults({ backgroundColor: '#abcdef' })
154
+ const defWithToken = { id: 'td', svg: BACKGROUND_SVG, backgroundColor: '#111111' }
155
+ const resolved = symbolRegistry.resolve(defWithToken, {}, mapStyle)
156
+ expect(resolved).toContain('fill="#111111"')
157
+ })
158
+
159
+ it('marker-level overrides take precedence over symbol-level defaults', () => {
160
+ const defWithToken = { id: 'td2', svg: BACKGROUND_SVG, backgroundColor: '#111111' }
161
+ const resolved = symbolRegistry.resolve(defWithToken, { backgroundColor: '#ffffff' }, mapStyle)
162
+ expect(resolved).toContain('fill="#ffffff"')
163
+ })
164
+ })
165
+
166
+ describe('symbolRegistry — resolveSelected', () => {
167
+ const symbolDef = {
168
+ id: 'test-sel',
169
+ svg: '<path stroke="{{selectedColor}}" stroke-width="{{selectedWidth}}"/><path fill="{{backgroundColor}}"/>'
170
+ }
171
+
172
+ it('uses light scheme selectedColor when mapStyle has no selectedColor', () => {
173
+ const resolved = symbolRegistry.resolveSelected(symbolDef, {}, mapStyle)
174
+ expect(resolved).toContain(`stroke="${SCHEME_COLORS.light.selectedColor}"`)
175
+ })
176
+
177
+ it('uses mapStyle.selectedColor when provided', () => {
178
+ const resolved = symbolRegistry.resolveSelected(symbolDef, {}, { id: STYLE_ID, selectedColor: '#ff0000' })
179
+ expect(resolved).toContain('stroke="#ff0000"')
180
+ })
181
+
182
+ it('uses selectedWidth from symbolDefaults', () => {
183
+ const resolved = symbolRegistry.resolveSelected(symbolDef, {}, mapStyle)
184
+ expect(resolved).toContain(`stroke-width="${symbolDefaults.selectedWidth}"`)
185
+ })
186
+
187
+ it('handles null styleColors — uses cascade defaults', () => {
188
+ const resolved = symbolRegistry.resolveSelected(symbolDef, null, mapStyle)
189
+ expect(resolved).toContain(`stroke="${SCHEME_COLORS.light.selectedColor}"`)
190
+ })
191
+
192
+ it('returns empty string for null symbolDef', () => {
193
+ expect(symbolRegistry.resolveSelected(null, {}, mapStyle)).toBe('')
194
+ })
195
+
196
+ it('symbol-level selectedColor is ignored — mapStyle wins', () => {
197
+ const defWithSelected = { ...symbolDef, selectedColor: '#00ff00' }
198
+ const resolved = symbolRegistry.resolveSelected(defWithSelected, {}, { id: STYLE_ID, selectedColor: '#ff0000' })
199
+ expect(resolved).toContain('stroke="#ff0000"')
200
+ })
201
+
202
+ it('still resolves other tokens correctly', () => {
203
+ const resolved = symbolRegistry.resolveSelected(symbolDef, { backgroundColor: '#d4351c' }, mapStyle)
204
+ expect(resolved).toContain('fill="#d4351c"')
205
+ })
206
+ })
207
+
208
+ describe('symbolRegistry — graphic token', () => {
209
+ const graphicDef = {
210
+ id: 'test-graphic',
211
+ graphic: 'M10 10 L20 20',
212
+ svg: '<path d="{{graphic}}" fill="{{foregroundColor}}"/>'
213
+ }
214
+
215
+ it('substitutes graphic d attribute from symbol-level default', () => {
216
+ const resolved = symbolRegistry.resolve(graphicDef, {}, mapStyle)
217
+ expect(resolved).toContain('d="M10 10 L20 20"')
218
+ })
219
+
220
+ it('resolves named graphic string to built-in path data', () => {
221
+ const resolved = symbolRegistry.resolve(graphicDef, { graphic: 'cross' }, mapStyle)
222
+ expect(resolved).toContain('d="M6 3H10V6H13V10H10V13H6V10H3V6H6Z"')
223
+ })
224
+
225
+ it('overrides symbol-level graphic with marker-level value', () => {
226
+ const resolved = symbolRegistry.resolve(graphicDef, { graphic: 'M0 0 L38 38' }, mapStyle)
227
+ expect(resolved).toContain('d="M0 0 L38 38"')
228
+ })
229
+
230
+ it('overrides graphic via constructor defaults', () => {
231
+ symbolRegistry.setDefaults({ graphic: 'M5 5 L15 15' })
232
+ const defNoGraphic = { id: 'no-graphic', svg: '<path d="{{graphic}}"/>' }
233
+ const resolved = symbolRegistry.resolve(defNoGraphic, {}, mapStyle)
234
+ expect(resolved).toContain('d="M5 5 L15 15"')
235
+ })
236
+
237
+ it('marker-level graphic overrides constructor default', () => {
238
+ symbolRegistry.setDefaults({ graphic: 'M5 5 L15 15' })
239
+ const defNoGraphic = { id: 'no-graphic2', svg: '<path d="{{graphic}}"/>' }
240
+ const resolved = symbolRegistry.resolve(defNoGraphic, { graphic: 'M1 1 L2 2' }, mapStyle)
241
+ expect(resolved).toContain('d="M1 1 L2 2"')
242
+ })
243
+
244
+ it('built-in pin symbol has a graphic default', () => {
245
+ const pin = symbolRegistry.get('pin')
246
+ expect(typeof pin.graphic).toBe('string')
247
+ expect(pin.graphic.length).toBeGreaterThan(0)
248
+ })
249
+
250
+ it('built-in circle symbol has a graphic default', () => {
251
+ const circle = symbolRegistry.get('circle')
252
+ expect(typeof circle.graphic).toBe('string')
253
+ expect(circle.graphic.length).toBeGreaterThan(0)
254
+ })
255
+
256
+ it('pin resolves graphic token into its svg within a g transform', () => {
257
+ const pin = symbolRegistry.get('pin')
258
+ const resolved = symbolRegistry.resolve(pin, {}, mapStyle)
259
+ expect(resolved).toContain(`d="${pin.graphic}"`)
260
+ expect(resolved).toContain('translate(19, 16) scale(0.8) translate(-8, -8)')
261
+ })
262
+ })
package/src/types.js CHANGED
@@ -316,15 +316,67 @@
316
316
  * Alt text for logo.
317
317
  *
318
318
  * @property {'light' | 'dark'} [mapColorScheme]
319
- * Map color scheme. Used to determine the style of controls on the map. eg. Halo colours etc
319
+ * Map colour scheme. Sets the default values of `haloColor`, `selectedColor`, and `foregroundColor`
320
+ * when not explicitly provided, and signals to map overlay components which tonal range to use.
321
+ * `'light'` (default): dark overlays on a light basemap. `'dark'`: light overlays on a dark or aerial basemap.
320
322
  *
321
323
  * @property {string} [thumbnail]
322
324
  * URL to thumbnail image.
323
325
  *
326
+ * @property {string} [haloColor]
327
+ * Halo colour for elements rendered on top of the map (e.g. symbol outlines). Provides contrast
328
+ * between overlay elements and the map background. Falls back to `#ffffff` (light) or `#0b0c0c` (dark).
329
+ * Injected as the `--map-overlay-halo-color` CSS custom property.
330
+ *
331
+ * @property {string} [selectedColor]
332
+ * Theme colour for selected state — used by map overlay components to indicate a selected feature.
333
+ * Falls back to `#0b0c0c` (light) or `#ffffff` (dark).
334
+ * Injected as the `--map-overlay-selected-color` CSS custom property.
335
+ *
336
+ * @property {string} [foregroundColor]
337
+ * Foreground colour for elements rendered on top of the map (e.g. text or iconography in overlay components).
338
+ * Falls back to `#0b0c0c` (light) or `#ffffff` (dark).
339
+ * Injected as the `--map-overlay-foreground-color` CSS custom property.
340
+ *
324
341
  * @property {string} url
325
342
  * URL to the style.json (Mapbox Style Specification).
326
343
  */
327
344
 
345
+ /**
346
+ * App-wide symbol appearance defaults. All properties are optional.
347
+ * Color values may be a plain string or an object keyed by map style ID.
348
+ *
349
+ * @typedef {Object} SymbolDefaults
350
+ *
351
+ * @property {string} [symbol='pin']
352
+ * Default symbol ID. Built-in values: `'pin'`, `'circle'`.
353
+ *
354
+ * @property {string} [symbolSvgContent]
355
+ * Default inner SVG path content. When set, overrides `symbol`.
356
+ *
357
+ * @property {string} [viewBox='0 0 38 38']
358
+ * Default SVG viewBox.
359
+ *
360
+ * @property {[number, number]} [anchor=[0.5, 0.5]]
361
+ * Default anchor point as a normalised [x, y] pair.
362
+ *
363
+ * @property {string | Record<string, string>} [backgroundColor='#ca3535']
364
+ * Default background fill colour.
365
+ *
366
+ * @property {string | Record<string, string>} [foregroundColor='#ffffff']
367
+ * Default foreground fill colour.
368
+ *
369
+ * @property {string} [haloWidth='1']
370
+ * Default halo stroke width in SVG units.
371
+ *
372
+ * @property {string} [graphic]
373
+ * Default SVG `d` attribute value for the foreground graphic path. Each built-in symbol sets
374
+ * its own default (a small dot). Override to swap the graphic across all markers globally.
375
+ *
376
+ * @property {string} [selectedWidth='6']
377
+ * Selection ring stroke width in SVG units. App-wide only — ignored at symbol registration and marker creation level.
378
+ */
379
+
328
380
  /**
329
381
  * Configuration for a map marker.
330
382
  *
@@ -341,15 +393,47 @@
341
393
  */
342
394
 
343
395
  /**
344
- * Options for customizing marker appearance.
396
+ * Options for customizing marker appearance. Any key corresponds to a token in the symbol's SVG template.
397
+ * Color values may be a plain string or an object keyed by map style ID e.g. `{ outdoor: '#fff', dark: '#000' }`.
345
398
  *
346
399
  * @typedef {Object} MarkerOptions
347
400
  *
348
- * @property {string | Record<string, string>} [color]
349
- * Marker color or object with colors keyed by style ID.
401
+ * @property {string} [symbol]
402
+ * Symbol id to use for this marker (e.g. 'pin', 'circle'). Overrides the default `symbolDefaults.symbol` option.
403
+ *
404
+ * @property {string} [symbolSvgContent]
405
+ * Inner SVG path content (no `<svg>` wrapper) to use instead of a registered symbol.
406
+ * Use `{{token}}` placeholders for colours — e.g. `fill="{{backgroundColor}}"`.
407
+ * When set, `symbol` is ignored.
408
+ *
409
+ * @property {string} [viewBox]
410
+ * SVG viewBox attribute for the symbol, e.g. `'0 0 38 38'`.
411
+ * Defaults to the registered symbol's viewBox, or `'0 0 38 38'`.
412
+ *
413
+ * @property {[number, number]} [anchor]
414
+ * Anchor point as a normalised [x, y] pair where [0, 0] is top-left and [1, 1] is bottom-right.
415
+ * Determines which point on the symbol aligns with the geographic coordinate.
416
+ * Defaults to the registered symbol's anchor, or `[0.5, 0.5]` (centre).
417
+ *
418
+ * @property {string | Record<string, string>} [backgroundColor]
419
+ * Background fill colour of the symbol.
420
+ *
421
+ * @property {string | Record<string, string>} [foregroundColor]
422
+ * Foreground fill colour of the symbol (e.g. the inner dot on a pin).
423
+ *
424
+ * @property {string} [haloWidth]
425
+ * Stroke width of the halo in SVG units. Defaults to `'1'`.
426
+ *
427
+ * @property {string} [graphic]
428
+ * SVG `d` attribute value for the foreground graphic path. Replaces the foreground shape of the
429
+ * symbol while keeping its background, halo and selection ring intact. Each built-in symbol
430
+ * (`pin`, `circle`) provides a default dot; pass a different `d` string to swap it.
431
+ * Use named values from `graphics.js` or supply your own path data.
432
+ *
433
+ * @example
434
+ * import { graphics } from './config/symbolConfig.js'
435
+ * markers.add('id', coords, { symbol: 'pin', graphic: graphics.cross })
350
436
  *
351
- * @property {string} [shape]
352
- * Marker shape (e.g., 'pin').
353
437
  */
354
438
 
355
439
  /**
@@ -540,14 +624,12 @@
540
624
  * @property {string} [mapViewParamKey='mv']
541
625
  * URL query parameter key used to control map view state.
542
626
  *
543
- * @property {string | Record<string, string>} [markerColor='#ff0000']
544
- * Colour used for map markers. May be a single colour value or an object keyed by map style ID.
545
- *
546
627
  * @property {MarkerConfig[]} [markers]
547
628
  * Initial markers to display on the map.
548
629
  *
549
- * @property {string} [markerShape='pin']
550
- * Shape used for map markers.
630
+ * @property {Partial<SymbolDefaults>} [symbolDefaults]
631
+ * App-wide defaults for symbol appearance. Merged onto the hardcoded defaults in symbolDefaults.js.
632
+ * Values cascade: symbolDefaults.js → constructor symbolDefaults → symbol registration → marker creation.
551
633
  *
552
634
  * @property {[number, number, number, number]} [maxExtent]
553
635
  * Maximum viewable extent [west, south, east, north].
@@ -27,7 +27,7 @@
27
27
  * @param {React.RefObject} refs.leftRef - Left button column.
28
28
  * @param {React.RefObject} refs.rightRef - Right button column.
29
29
  * @param {React.RefObject} refs.actionsRef - Bottom action bar.
30
- * @param {React.RefObject} refs.footerRef - Footer (logo, copyright, etc).
30
+ * @param {React.RefObject} refs.bottomRef - Bottom row (logo, copyright, etc).
31
31
  * @param {React.RefObject} [refs.leftTopRef] - Top-left panel slot.
32
32
  * @param {React.RefObject} [refs.leftBottomRef] - Bottom-left panel slot.
33
33
  * @param {React.RefObject} [refs.rightTopRef] - Top-right panel slot.
@@ -107,15 +107,15 @@ const computeRow = (leftW, rightW, leftH, rightH, wThreshold, baseInset, gap) =>
107
107
  leftW + rightW > wThreshold ? baseInset + Math.max(leftH, rightH) + gap : 0
108
108
 
109
109
  export const getSafeZoneInset = ({
110
- mainRef, leftRef, rightRef, actionsRef, footerRef,
110
+ mainRef, leftRef, rightRef, actionsRef, bottomRef,
111
111
  leftTopRef, leftBottomRef, rightTopRef, rightBottomRef
112
112
  }) => {
113
- if ([mainRef, leftRef, rightRef, actionsRef, footerRef].some(ref => !ref?.current)) {
113
+ if ([mainRef, leftRef, rightRef, actionsRef, bottomRef].some(ref => !ref?.current)) {
114
114
  return undefined
115
115
  }
116
116
 
117
117
  const main = mainRef.current; const left = leftRef.current
118
- const actions = actionsRef.current; const footer = footerRef.current
118
+ const actions = actionsRef.current; const bottom = bottomRef.current
119
119
 
120
120
  const gap = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--divider-gap'), 10)
121
121
 
@@ -127,8 +127,10 @@ export const getSafeZoneInset = ({
127
127
  const baseLeft = main.offsetLeft + left.offsetLeft + colWidth + gap
128
128
  const baseRight = left.offsetLeft + colWidth + gap
129
129
  const baseTop = left.offsetTop
130
- const footerInset = main.offsetHeight - footer.offsetTop + gap // mirrors --left/right-offset-bottom CSS var
131
- const baseBottom = Math.max(main.offsetHeight - actions.offsetTop + gap, footerInset)
130
+ const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
131
+ // Minimum: primary-gap above the bottom edge. Normally: divider-gap above the top of the bottom container.
132
+ const bottomInset = Math.max(bottomContainerPad, main.offsetHeight - bottom.offsetTop + gap)
133
+ const baseBottom = Math.max(main.offsetHeight - actions.offsetTop + gap, bottomInset)
132
134
 
133
135
  const availableH = main.offsetHeight - baseTop - baseBottom
134
136
  const availableW = main.offsetWidth - (baseLeft - main.offsetLeft) - baseRight
@@ -139,7 +141,7 @@ export const getSafeZoneInset = ({
139
141
  const leftPanelInset = leftCol.panelInset
140
142
  const rightPanelInset = rightCol.panelInset
141
143
  const topPanelInset = computeRow(leftCol.rowA.w, rightCol.rowA.w, leftCol.rowA.h, rightCol.rowA.h, availableW / RATIO, baseTop, gap)
142
- const bottomPanelInset = computeRow(leftCol.rowB.w, rightCol.rowB.w, leftCol.rowB.h, rightCol.rowB.h, availableW / RATIO, footerInset, gap)
144
+ const bottomPanelInset = computeRow(leftCol.rowB.w, rightCol.rowB.w, leftCol.rowB.h, rightCol.rowB.h, availableW / RATIO, bottomInset, gap)
143
145
 
144
146
  const usableW = main.offsetWidth - 2 * gap
145
147
  const usableH = main.offsetHeight - 2 * gap
@@ -7,7 +7,7 @@ const LEFT_WIDTH = 40
7
7
  const LEFT_TOP = 60
8
8
  const RIGHT_WIDTH = 40
9
9
  const ACTIONS_TOP = 540
10
- const FOOTER_TOP = 560
10
+ const BOTTOM_TOP = 560
11
11
  const EARLY_ACTIONS_TOP = 500
12
12
  const GAP = 8
13
13
 
@@ -31,7 +31,7 @@ const PANEL_H_TALL = 150
31
31
  const PANEL_H_SHORT = 100
32
32
 
33
33
  const ABOVE_CAP_TOP = 330 // 60+330+8=398 > CAP_HEIGHT ≈ 389.3 → capped
34
- const FOOTER_INSET = MAIN_HEIGHT - FOOTER_TOP + GAP // 48
34
+ const BOTTOM_INSET = MAIN_HEIGHT - BOTTOM_TOP + GAP // 48: divider-gap above top of bottom container
35
35
  const ABOVE_CAP_BOTTOM = 342 // 48+342+8=398 > CAP_HEIGHT → capped
36
36
 
37
37
  const PANEL_W_STANDARD = 200
@@ -49,7 +49,7 @@ const CAP_HEIGHT = (MAIN_HEIGHT - 2 * GAP) * (MAX_RATIO - 1) / MAX_RATIO
49
49
 
50
50
  // ─── Setup ──────────────────────────────────────────────────────────────────
51
51
 
52
- let mainRef, leftRef, rightRef, actionsRef, footerRef
52
+ let mainRef, leftRef, rightRef, actionsRef, bottomRef
53
53
 
54
54
  beforeAll(() => {
55
55
  globalThis.getComputedStyle = jest.fn().mockReturnValue({ getPropertyValue: () => String(GAP) })
@@ -72,10 +72,10 @@ beforeEach(() => {
72
72
  leftRef = colRef(LEFT_WIDTH, LEFT_LEFT, LEFT_TOP)
73
73
  rightRef = colRef(RIGHT_WIDTH)
74
74
  actionsRef = { current: { offsetTop: ACTIONS_TOP } }
75
- footerRef = { current: { offsetTop: FOOTER_TOP } }
75
+ bottomRef = { current: { offsetTop: BOTTOM_TOP, offsetHeight: 0 } }
76
76
  })
77
77
 
78
- const base = () => ({ mainRef, leftRef, rightRef, actionsRef, footerRef })
78
+ const base = () => ({ mainRef, leftRef, rightRef, actionsRef, bottomRef })
79
79
 
80
80
  // Slot container where an .im-c-panel is the first element child.
81
81
  const panel = (offsetWidth, offsetHeight) => {
@@ -96,7 +96,7 @@ describe('getSafeZoneInset — missing refs', () => {
96
96
  expect(getSafeZoneInset({ ...base(), leftRef: { current: null } })).toBeUndefined()
97
97
  })
98
98
  it('returns undefined when actionsRef is undefined', () => {
99
- expect(getSafeZoneInset({ mainRef, leftRef, rightRef, footerRef })).toBeUndefined()
99
+ expect(getSafeZoneInset({ mainRef, leftRef, rightRef, bottomRef })).toBeUndefined()
100
100
  })
101
101
  })
102
102
 
@@ -120,7 +120,7 @@ describe('getSafeZoneInset — base structural insets', () => {
120
120
  it('uses zero button width when column ref has no button group', () => {
121
121
  const noGroupLeft = { current: { offsetWidth: 0, offsetLeft: LEFT_LEFT, offsetTop: LEFT_TOP, querySelector: () => null } }
122
122
  const noGroupRight = { current: { offsetWidth: 0, offsetLeft: 0, offsetTop: 0, querySelector: () => null } }
123
- expect(getSafeZoneInset({ mainRef, leftRef: noGroupLeft, rightRef: noGroupRight, actionsRef, footerRef })).toEqual({
123
+ expect(getSafeZoneInset({ mainRef, leftRef: noGroupLeft, rightRef: noGroupRight, actionsRef, bottomRef })).toEqual({
124
124
  left: LEFT_LEFT + GAP, right: LEFT_LEFT + GAP, top: LEFT_TOP, bottom: BASE_BOTTOM
125
125
  })
126
126
  })
@@ -133,7 +133,7 @@ describe('getSafeZoneInset — base structural insets', () => {
133
133
  rightBottomRef: panel(PANEL_W_STANDARD, 0)
134
134
  })).toEqual({ left: BASE_LEFT, right: BASE_RIGHT, top: BASE_TOP, bottom: BASE_BOTTOM })
135
135
  })
136
- it('uses max of actions and footer for base bottom', () => {
136
+ it('uses max of actions and bottom for base bottom', () => {
137
137
  actionsRef.current.offsetTop = EARLY_ACTIONS_TOP
138
138
  expect(getSafeZoneInset(base()).bottom).toBe(MAIN_HEIGHT - EARLY_ACTIONS_TOP + GAP)
139
139
  })
@@ -244,7 +244,7 @@ describe('getSafeZoneInset — bottom edge', () => {
244
244
  })
245
245
  it('triggers when a bottom panel width exceeds threshold', () => {
246
246
  expect(getSafeZoneInset({ ...base(), leftBottomRef: panel(ABOVE_W_THRESHOLD, ABOVE_THRESHOLD) }).bottom)
247
- .toBe(Math.min(FOOTER_INSET + ABOVE_THRESHOLD + GAP, CAP_HEIGHT))
247
+ .toBe(Math.min(BOTTOM_INSET + ABOVE_THRESHOLD + GAP, CAP_HEIGHT))
248
248
  })
249
249
  it('column-primary wide-and-tall bottom panel triggers left inset, not bottom', () => {
250
250
  // panel(400,342): h/availableH≈0.724 > w/availableW≈0.510 → column-primary
@@ -257,7 +257,7 @@ describe('getSafeZoneInset — bottom edge', () => {
257
257
  ...base(),
258
258
  leftBottomRef: panel(COMBINED_ABOVE_W, PANEL_H_TALL),
259
259
  rightBottomRef: panel(COMBINED_ABOVE_W, PANEL_H_SHORT)
260
- }).bottom).toBe(Math.min(FOOTER_INSET + PANEL_H_TALL + GAP, CAP_HEIGHT))
260
+ }).bottom).toBe(Math.min(BOTTOM_INSET + PANEL_H_TALL + GAP, CAP_HEIGHT))
261
261
  })
262
262
  it('does not trigger when both bottom panels are below combined width threshold', () => {
263
263
  expect(getSafeZoneInset({
@@ -0,0 +1,94 @@
1
+ import { getValueForStyle } from '../utils/getValueForStyle.js'
2
+
3
+ // Border path rendered behind the pattern content in Key panel symbols (20×20 coordinate space).
4
+ export const KEY_BORDER_PATH = '<path d="M19 2.862v14.275c0 1.028-.835 1.862-1.862 1.862H2.863c-1.028 0-1.862-.835-1.862-1.862V2.862C1.001 1.834 1.836 1 2.863 1h14.275C18.166 1 19 1.835 19 2.862z" fill="{{backgroundColor}}" stroke="{{foregroundColor}}" stroke-width="2"/>'
5
+
6
+ export const hashString = (str) => {
7
+ let hash = 0
8
+ for (const ch of str) {
9
+ hash = ((hash << 5) - hash) + ch.codePointAt(0)
10
+ hash = hash & hash
11
+ }
12
+ return Math.abs(hash).toString(36) // NOSONAR: base36 encoding for compact alphanumeric hash string
13
+ }
14
+
15
+ /**
16
+ * Replaces {{foregroundColor}} and {{backgroundColor}} tokens in SVG content with resolved colour values.
17
+ *
18
+ * @param {string} content - SVG path string with colour tokens
19
+ * @param {string} foregroundColor
20
+ * @param {string} backgroundColor
21
+ * @returns {string}
22
+ */
23
+ export const injectColors = (content, foregroundColor, backgroundColor) =>
24
+ content
25
+ .replace(/\{\{foregroundColor\}\}/g, foregroundColor || 'black')
26
+ .replace(/\{\{backgroundColor\}\}/g, backgroundColor || 'transparent')
27
+
28
+ /**
29
+ * Returns true if a dataset/config has a fill pattern configured.
30
+ *
31
+ * @param {Object} dataset
32
+ * @returns {boolean}
33
+ */
34
+ export const hasPattern = (dataset) => !!(dataset.fillPattern || dataset.fillPatternSvgContent)
35
+
36
+ /**
37
+ * Returns the raw (un-coloured) inner SVG content for a dataset's pattern.
38
+ * Precedence: inline fillPatternSvgContent → named fillPattern from registry.
39
+ *
40
+ * @param {Object} dataset
41
+ * @param {Object} patternRegistry
42
+ * @returns {string|null}
43
+ */
44
+ export const getPatternInnerContent = (dataset, patternRegistry) => {
45
+ if (dataset.fillPatternSvgContent) {
46
+ return dataset.fillPatternSvgContent
47
+ }
48
+ if (dataset.fillPattern) {
49
+ return patternRegistry?.get(dataset.fillPattern)?.svgContent ?? null
50
+ }
51
+ return null
52
+ }
53
+
54
+ /**
55
+ * Returns a deterministic image ID for a pattern + resolved colour combination.
56
+ *
57
+ * @param {Object} dataset
58
+ * @param {string} mapStyleId
59
+ * @param {Object} patternRegistry
60
+ * @returns {string|null}
61
+ */
62
+ export const getPatternImageId = (dataset, mapStyleId, patternRegistry) => {
63
+ const innerContent = getPatternInnerContent(dataset, patternRegistry)
64
+ if (!innerContent) {
65
+ return null
66
+ }
67
+ const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black'
68
+ const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
69
+ return `pattern-${hashString(innerContent + fg + bg)}`
70
+ }
71
+
72
+ /**
73
+ * Returns colour-injected SVG path content for use in Key panel pattern symbols.
74
+ * Returns { border, content } where border is the rounded-rect outline and content
75
+ * is the pattern fill. Returns null if the dataset has no pattern.
76
+ *
77
+ * @param {Object} dataset
78
+ * @param {string} mapStyleId
79
+ * @param {Object} patternRegistry
80
+ * @returns {{ border: string, content: string }|null}
81
+ */
82
+ export const getKeyPatternPaths = (dataset, mapStyleId, patternRegistry) => {
83
+ const innerContent = getPatternInnerContent(dataset, patternRegistry)
84
+ if (!innerContent) {
85
+ return null
86
+ }
87
+ const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black'
88
+ const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
89
+ const borderStroke = getValueForStyle(dataset.stroke, mapStyleId) || fg
90
+ return {
91
+ border: injectColors(KEY_BORDER_PATH, borderStroke, bg),
92
+ content: injectColors(innerContent, fg, bg)
93
+ }
94
+ }