@defra/interactive-map 0.0.17-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 (140) 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/context.md +53 -7
  7. package/docs/api/map-style-config.md +41 -2
  8. package/docs/api/marker-config.md +53 -11
  9. package/docs/api/symbol-config.md +160 -0
  10. package/docs/api/symbol-registry.md +115 -0
  11. package/docs/api.md +22 -19
  12. package/docs/plugins/datasets.md +105 -9
  13. package/docs/plugins/interact.md +68 -43
  14. package/docs/plugins/search.md +15 -3
  15. package/package.json +1 -1
  16. package/plugins/beta/datasets/dist/css/index.css +32 -14
  17. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  18. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  19. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  20. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  21. package/plugins/beta/datasets/src/DatasetsInit.jsx +9 -4
  22. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +57 -11
  23. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +14 -8
  24. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +155 -53
  25. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  26. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  27. package/plugins/beta/datasets/src/api/addDataset.js +1 -1
  28. package/plugins/beta/datasets/src/api/setData.js +4 -2
  29. package/plugins/beta/datasets/src/api/setStyle.js +2 -2
  30. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  31. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  32. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  33. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  34. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  35. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  36. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  37. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  38. package/plugins/beta/datasets/src/datasets.js +13 -4
  39. package/plugins/beta/datasets/src/defaults.js +4 -2
  40. package/plugins/beta/datasets/src/index.js +2 -1
  41. package/plugins/beta/datasets/src/manifest.js +1 -1
  42. package/plugins/beta/datasets/src/panels/Key.jsx +11 -89
  43. package/plugins/beta/datasets/src/panels/Key.module.scss +24 -13
  44. package/plugins/beta/datasets/src/panels/Layers.module.scss +13 -7
  45. package/plugins/beta/datasets/src/reducer.js +6 -0
  46. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  47. package/plugins/beta/datasets/src/utils/mergeSublayer.js +8 -0
  48. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  49. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  50. package/plugins/beta/draw-ml/dist/css/index.css +21 -1
  51. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  52. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  53. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  54. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  55. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  56. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  57. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  58. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  59. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  60. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  61. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  62. package/plugins/interact/dist/umd/index.js +1 -1
  63. package/plugins/interact/src/InteractInit.jsx +14 -5
  64. package/plugins/interact/src/InteractInit.test.js +26 -6
  65. package/plugins/interact/src/api/enable.test.js +7 -7
  66. package/plugins/interact/src/defaults.js +4 -6
  67. package/plugins/interact/src/events.js +9 -6
  68. package/plugins/interact/src/events.test.js +28 -4
  69. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  70. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  71. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  72. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  73. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  74. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  75. package/plugins/interact/src/reducer.js +23 -4
  76. package/plugins/interact/src/reducer.test.js +60 -11
  77. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  78. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  79. package/plugins/interact/src/utils/featureQueries.js +11 -6
  80. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  81. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  82. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  83. package/plugins/search/src/Search.jsx +3 -1
  84. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  85. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  86. package/plugins/search/src/events/formHandlers.js +3 -3
  87. package/plugins/search/src/events/formHandlers.test.js +1 -1
  88. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  89. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  90. package/plugins/search/src/utils/updateMap.js +3 -3
  91. package/plugins/search/src/utils/updateMap.test.js +3 -3
  92. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  93. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  94. package/providers/maplibre/dist/umd/index.js +1 -1
  95. package/providers/maplibre/src/appEvents.js +7 -0
  96. package/providers/maplibre/src/appEvents.test.js +18 -4
  97. package/providers/maplibre/src/maplibreProvider.js +52 -0
  98. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  99. package/providers/maplibre/src/utils/highlightFeatures.js +36 -7
  100. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -96
  101. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  102. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  103. package/providers/maplibre/src/utils/patternImages.js +70 -0
  104. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  105. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  106. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  107. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  108. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  109. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  110. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  111. package/src/App/components/Markers/Markers.jsx +122 -27
  112. package/src/App/components/Markers/Markers.module.scss +0 -10
  113. package/src/App/components/Markers/Markers.test.jsx +246 -0
  114. package/src/App/hooks/useInterfaceAPI.test.js +156 -0
  115. package/src/App/hooks/useMarkersAPI.js +2 -5
  116. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  117. package/src/App/layout/Layout.jsx +2 -2
  118. package/src/App/layout/Layout.test.jsx +4 -2
  119. package/src/App/store/ServiceProvider.jsx +7 -5
  120. package/src/App/store/mapActionsMap.js +4 -6
  121. package/src/App/store/mapActionsMap.test.js +3 -2
  122. package/src/App/store/mapReducer.js +2 -1
  123. package/src/config/appConfig.js +0 -6
  124. package/src/config/appConfig.test.js +1 -2
  125. package/src/config/defaults.js +0 -2
  126. package/src/config/mapTheme.js +56 -0
  127. package/src/config/patternConfig.js +16 -0
  128. package/src/config/symbolConfig.js +80 -0
  129. package/src/scss/settings/_colors.scss +0 -9
  130. package/src/services/patternRegistry.js +40 -0
  131. package/src/services/patternRegistry.test.js +48 -0
  132. package/src/services/symbolRegistry.js +113 -0
  133. package/src/services/symbolRegistry.test.js +262 -0
  134. package/src/types.js +93 -11
  135. package/src/utils/patternUtils.js +94 -0
  136. package/src/utils/patternUtils.test.js +160 -0
  137. package/src/utils/symbolUtils.js +85 -0
  138. package/src/utils/symbolUtils.test.js +156 -0
  139. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +0 -48
  140. package/plugins/beta/datasets/src/styles/patterns.js +0 -157
@@ -0,0 +1,160 @@
1
+ import {
2
+ hashString,
3
+ injectColors,
4
+ hasPattern,
5
+ getPatternInnerContent,
6
+ getPatternImageId,
7
+ getKeyPatternPaths,
8
+ KEY_BORDER_PATH
9
+ } from './patternUtils.js'
10
+
11
+ const mockRegistry = {
12
+ get: (id) => id === 'dot' ? { id: 'dot', svgContent: '<path d="M4 4" fill="{{foregroundColor}}"/>' } : undefined
13
+ }
14
+
15
+ describe('hashString', () => {
16
+ test('returns a non-empty string', () => {
17
+ expect(typeof hashString('hello')).toBe('string')
18
+ expect(hashString('hello').length).toBeGreaterThan(0)
19
+ })
20
+
21
+ test('is deterministic', () => {
22
+ expect(hashString('hello')).toBe(hashString('hello'))
23
+ })
24
+
25
+ test('produces different values for different inputs', () => {
26
+ expect(hashString('a')).not.toBe(hashString('b'))
27
+ })
28
+ })
29
+
30
+ describe('injectColors', () => {
31
+ test('replaces {{foregroundColor}} and {{backgroundColor}} tokens', () => {
32
+ const result = injectColors('fill="{{foregroundColor}}" bg="{{backgroundColor}}"', 'red', 'blue')
33
+ expect(result).toBe('fill="red" bg="blue"')
34
+ })
35
+
36
+ test('replaces all occurrences', () => {
37
+ const result = injectColors('{{foregroundColor}} {{foregroundColor}}', 'red', 'blue')
38
+ expect(result).toBe('red red')
39
+ })
40
+
41
+ test('uses fallback "black" when foregroundColor is falsy', () => {
42
+ expect(injectColors('{{foregroundColor}}', '', 'blue')).toBe('black')
43
+ })
44
+
45
+ test('uses fallback "transparent" when backgroundColor is falsy', () => {
46
+ expect(injectColors('{{backgroundColor}}', 'red', '')).toBe('transparent')
47
+ })
48
+ })
49
+
50
+ describe('hasPattern', () => {
51
+ test('returns true when fillPattern is set', () => {
52
+ expect(hasPattern({ fillPattern: 'dot' })).toBe(true)
53
+ })
54
+
55
+ test('returns true when fillPatternSvgContent is set', () => {
56
+ expect(hasPattern({ fillPatternSvgContent: '<path/>' })).toBe(true)
57
+ })
58
+
59
+ test('returns false when neither is set', () => {
60
+ expect(hasPattern({})).toBe(false)
61
+ expect(hasPattern({ fill: 'red' })).toBe(false)
62
+ })
63
+ })
64
+
65
+ describe('getPatternInnerContent', () => {
66
+ test('returns fillPatternSvgContent when set (inline SVG takes precedence)', () => {
67
+ const dataset = { fillPatternSvgContent: '<path d="custom"/>', fillPattern: 'dot' }
68
+ expect(getPatternInnerContent(dataset, mockRegistry)).toBe('<path d="custom"/>')
69
+ })
70
+
71
+ test('returns svgContent from registry for a named fillPattern', () => {
72
+ const dataset = { fillPattern: 'dot' }
73
+ expect(getPatternInnerContent(dataset, mockRegistry)).toBe('<path d="M4 4" fill="{{foregroundColor}}"/>')
74
+ })
75
+
76
+ test('returns null for an unregistered fillPattern name', () => {
77
+ const dataset = { fillPattern: 'unknown-pattern' }
78
+ expect(getPatternInnerContent(dataset, mockRegistry)).toBeNull()
79
+ })
80
+
81
+ test('returns null when no pattern is configured', () => {
82
+ expect(getPatternInnerContent({}, mockRegistry)).toBeNull()
83
+ })
84
+ })
85
+
86
+ describe('getPatternImageId', () => {
87
+ test('returns a deterministic string id', () => {
88
+ const dataset = { fillPattern: 'dot', fillPatternForegroundColor: 'red', fillPatternBackgroundColor: 'blue' }
89
+ const id = getPatternImageId(dataset, 'style-a', mockRegistry)
90
+ expect(typeof id).toBe('string')
91
+ expect(id).toMatch(/^pattern-/)
92
+ expect(id).toBe(getPatternImageId(dataset, 'style-a', mockRegistry))
93
+ })
94
+
95
+ test('returns null when no pattern content is found', () => {
96
+ expect(getPatternImageId({ fillPattern: 'unknown' }, 'style-a', mockRegistry)).toBeNull()
97
+ })
98
+
99
+ test('produces different ids for different colours', () => {
100
+ const base = { fillPattern: 'dot' }
101
+ const idA = getPatternImageId({ ...base, fillPatternForegroundColor: 'red' }, 'style-a', mockRegistry)
102
+ const idB = getPatternImageId({ ...base, fillPatternForegroundColor: 'blue' }, 'style-a', mockRegistry)
103
+ expect(idA).not.toBe(idB)
104
+ })
105
+
106
+ test('falls back to "black" foreground and "transparent" background when colours are absent', () => {
107
+ const id = getPatternImageId({ fillPattern: 'dot' }, 'style-a', mockRegistry)
108
+ const idExplicit = getPatternImageId(
109
+ { fillPattern: 'dot', fillPatternForegroundColor: 'black', fillPatternBackgroundColor: 'transparent' },
110
+ 'style-a',
111
+ mockRegistry
112
+ )
113
+ expect(id).toBe(idExplicit)
114
+ })
115
+ })
116
+
117
+ describe('getKeyPatternPaths', () => {
118
+ test('returns border and content strings with colours injected', () => {
119
+ const dataset = {
120
+ fillPattern: 'dot',
121
+ fillPatternForegroundColor: 'red',
122
+ fillPatternBackgroundColor: 'white',
123
+ stroke: 'black'
124
+ }
125
+ const result = getKeyPatternPaths(dataset, 'style-a', mockRegistry)
126
+ expect(result).not.toBeNull()
127
+ expect(result.border).toContain('black') // stroke colour
128
+ expect(result.border).toContain('white') // background colour
129
+ expect(result.content).toContain('red') // foreground colour
130
+ expect(result.border).not.toContain('{{foregroundColor}}')
131
+ expect(result.content).not.toContain('{{foregroundColor}}')
132
+ })
133
+
134
+ test('returns null when no pattern content is found', () => {
135
+ expect(getKeyPatternPaths({ fillPattern: 'unknown' }, 'style-a', mockRegistry)).toBeNull()
136
+ })
137
+
138
+ test('falls back to "black" fg and "transparent" bg when colour properties are absent', () => {
139
+ const result = getKeyPatternPaths({ fillPattern: 'dot' }, 'style-a', mockRegistry)
140
+ expect(result).not.toBeNull()
141
+ expect(result.content).toContain('black')
142
+ expect(result.border).toContain('transparent')
143
+ })
144
+
145
+ test('border stroke falls back to foreground colour when stroke is absent', () => {
146
+ const result = getKeyPatternPaths(
147
+ { fillPattern: 'dot', fillPatternForegroundColor: 'green' },
148
+ 'style-a',
149
+ mockRegistry
150
+ )
151
+ expect(result).not.toBeNull()
152
+ // borderStroke falls back to fg ('green'), so the border uses green for both stroke and background
153
+ expect(result.border).toContain('green')
154
+ })
155
+
156
+ test('KEY_BORDER_PATH contains foregroundColor and backgroundColor tokens', () => {
157
+ expect(KEY_BORDER_PATH).toContain('{{foregroundColor}}')
158
+ expect(KEY_BORDER_PATH).toContain('{{backgroundColor}}')
159
+ })
160
+ })
@@ -0,0 +1,85 @@
1
+ // Symbol style props in dataset style that carry token values.
2
+ // These use the 'symbol' prefix to distinguish them from fill/stroke props at the same level.
3
+ // The prefix is stripped before passing tokens to the registry (e.g. symbolBackgroundColor → backgroundColor).
4
+ const SYMBOL_STYLE_PROPS = new Set([
5
+ 'symbolBackgroundColor', 'symbolForegroundColor',
6
+ 'symbolHaloWidth', 'symbolGraphic'
7
+ ])
8
+
9
+ /**
10
+ * Returns true if this dataset should be rendered as a symbol (point) layer.
11
+ * @param {Object} dataset
12
+ * @returns {boolean}
13
+ */
14
+ export const hasSymbol = (dataset) => !!(dataset.symbol || dataset.symbolSvgContent)
15
+
16
+ /**
17
+ * Resolves the symbolDef for a dataset's symbol config.
18
+ *
19
+ * dataset.symbol is a string symbol ID (e.g. 'pin').
20
+ * dataset.symbolSvgContent is inline SVG content for a custom symbol.
21
+ *
22
+ * @param {Object} dataset
23
+ * @param {Object} symbolRegistry
24
+ * @returns {Object|undefined}
25
+ */
26
+ export const getSymbolDef = (dataset, symbolRegistry) => {
27
+ if (dataset.symbolSvgContent) {
28
+ return { svg: dataset.symbolSvgContent }
29
+ }
30
+ if (dataset.symbol) {
31
+ return symbolRegistry.get(dataset.symbol)
32
+ }
33
+ return undefined
34
+ }
35
+
36
+ /**
37
+ * Extracts token overrides from a dataset's flat symbol style props.
38
+ * Strips the 'symbol' prefix to produce internal token names (e.g. symbolBackgroundColor → backgroundColor).
39
+ * Returns an empty object when no symbol is configured.
40
+ *
41
+ * @param {Object} dataset
42
+ * @returns {Object}
43
+ */
44
+ export const getSymbolStyleColors = (dataset) => {
45
+ if (!hasSymbol(dataset)) { return {} }
46
+ const tokens = {}
47
+ SYMBOL_STYLE_PROPS.forEach(key => {
48
+ if (dataset[key] != null) {
49
+ // Strip 'symbol' prefix: symbolBackgroundColor → backgroundColor
50
+ const tokenKey = key.charAt(6).toLowerCase() + key.slice(7) // NOSONAR
51
+ tokens[tokenKey] = dataset[key]
52
+ }
53
+ })
54
+ return tokens
55
+ }
56
+
57
+ /**
58
+ * Returns the viewBox string for a dataset's symbol.
59
+ * Precedence: dataset.symbolViewBox → symbolDef viewBox → default.
60
+ *
61
+ * @param {Object} dataset
62
+ * @param {Object|undefined} symbolDef
63
+ * @returns {string}
64
+ */
65
+ export const getSymbolViewBox = (dataset, symbolDef) => {
66
+ if (dataset.symbolViewBox) {
67
+ return dataset.symbolViewBox
68
+ }
69
+ return symbolDef?.viewBox ?? '0 0 38 38'
70
+ }
71
+
72
+ /**
73
+ * Returns the anchor for a dataset's symbol as [x, y] in 0–1 space.
74
+ * Precedence: dataset.symbolAnchor → symbolDef anchor → [0.5, 0.5].
75
+ *
76
+ * @param {Object} dataset
77
+ * @param {Object|undefined} symbolDef
78
+ * @returns {number[]}
79
+ */
80
+ export const getSymbolAnchor = (dataset, symbolDef) => {
81
+ if (dataset.symbolAnchor) {
82
+ return dataset.symbolAnchor
83
+ }
84
+ return symbolDef?.anchor ?? [0.5, 0.5]
85
+ }
@@ -0,0 +1,156 @@
1
+ import {
2
+ hasSymbol,
3
+ getSymbolDef,
4
+ getSymbolStyleColors,
5
+ getSymbolViewBox,
6
+ getSymbolAnchor
7
+ } from './symbolUtils.js'
8
+
9
+ const mockRegistry = (defs = {}) => ({
10
+ get: jest.fn((id) => defs[id])
11
+ })
12
+
13
+ // ─── hasSymbol ────────────────────────────────────────────────────────────────
14
+
15
+ describe('hasSymbol', () => {
16
+ it('returns true when dataset has a symbol string', () => {
17
+ expect(hasSymbol({ symbol: 'pin' })).toBe(true)
18
+ })
19
+
20
+ it('returns true when dataset has symbolSvgContent', () => {
21
+ expect(hasSymbol({ symbolSvgContent: '<circle/>' })).toBe(true)
22
+ })
23
+
24
+ it('returns false when symbol is absent', () => {
25
+ expect(hasSymbol({})).toBe(false)
26
+ })
27
+
28
+ it('returns false when symbol is null', () => {
29
+ expect(hasSymbol({ symbol: null })).toBe(false)
30
+ })
31
+ })
32
+
33
+ // ─── getSymbolDef ─────────────────────────────────────────────────────────────
34
+
35
+ describe('getSymbolDef', () => {
36
+ it('returns undefined when dataset has no symbol', () => {
37
+ expect(getSymbolDef({}, mockRegistry())).toBeUndefined()
38
+ })
39
+
40
+ it('looks up string symbol id in the registry', () => {
41
+ const pinDef = { id: 'pin', svg: '<g/>' }
42
+ const registry = mockRegistry({ pin: pinDef })
43
+ expect(getSymbolDef({ symbol: 'pin' }, registry)).toBe(pinDef)
44
+ })
45
+
46
+ it('returns undefined for an unregistered string symbol', () => {
47
+ expect(getSymbolDef({ symbol: 'missing' }, mockRegistry())).toBeUndefined()
48
+ })
49
+
50
+ it('returns inline def from symbolSvgContent with svg key', () => {
51
+ const dataset = { symbolSvgContent: '<circle/>', symbolViewBox: '0 0 10 10' }
52
+ const result = getSymbolDef(dataset, mockRegistry())
53
+ expect(result.svg).toBe('<circle/>')
54
+ })
55
+
56
+ it('symbolSvgContent takes precedence over symbol id', () => {
57
+ const pinDef = { id: 'pin', svg: '<g/>' }
58
+ const registry = mockRegistry({ pin: pinDef })
59
+ const result = getSymbolDef({ symbol: 'pin', symbolSvgContent: '<circle/>' }, registry)
60
+ expect(result.svg).toBe('<circle/>')
61
+ })
62
+ })
63
+
64
+ // ─── getSymbolStyleColors ─────────────────────────────────────────────────────
65
+
66
+ describe('getSymbolStyleColors', () => {
67
+ it('returns empty object when dataset has no symbol', () => {
68
+ expect(getSymbolStyleColors({})).toEqual({})
69
+ })
70
+
71
+ it('returns empty object for string symbol with no token props', () => {
72
+ expect(getSymbolStyleColors({ symbol: 'pin' })).toEqual({})
73
+ })
74
+
75
+ it('strips symbol prefix from token props', () => {
76
+ const dataset = {
77
+ symbol: 'pin',
78
+ symbolBackgroundColor: '#ff0000',
79
+ symbolForegroundColor: '#ffffff',
80
+ symbolHaloWidth: '2',
81
+ symbolGraphic: 'cross'
82
+ }
83
+ expect(getSymbolStyleColors(dataset)).toEqual({
84
+ backgroundColor: '#ff0000',
85
+ foregroundColor: '#ffffff',
86
+ haloWidth: '2',
87
+ graphic: 'cross'
88
+ })
89
+ })
90
+
91
+ it('works with symbolSvgContent instead of symbol id', () => {
92
+ const dataset = { symbolSvgContent: '<circle/>', symbolBackgroundColor: '#0000ff' }
93
+ expect(getSymbolStyleColors(dataset)).toEqual({ backgroundColor: '#0000ff' })
94
+ })
95
+
96
+ it('omits token props that are null or undefined', () => {
97
+ const dataset = { symbol: 'pin', symbolBackgroundColor: '#ff0000', symbolForegroundColor: null }
98
+ const result = getSymbolStyleColors(dataset)
99
+ expect(result).toEqual({ backgroundColor: '#ff0000' })
100
+ expect(result).not.toHaveProperty('foregroundColor')
101
+ })
102
+
103
+ it('supports style-keyed colour objects', () => {
104
+ const dataset = {
105
+ symbol: 'pin',
106
+ symbolBackgroundColor: { outdoor: '#1d70b8', dark: '#5694ca' }
107
+ }
108
+ expect(getSymbolStyleColors(dataset)).toEqual({
109
+ backgroundColor: { outdoor: '#1d70b8', dark: '#5694ca' }
110
+ })
111
+ })
112
+ })
113
+
114
+ // ─── getSymbolViewBox ─────────────────────────────────────────────────────────
115
+
116
+ describe('getSymbolViewBox', () => {
117
+ it('returns symbolViewBox from dataset', () => {
118
+ const dataset = { symbol: 'custom', symbolViewBox: '0 0 24 24' }
119
+ expect(getSymbolViewBox(dataset, undefined)).toBe('0 0 24 24')
120
+ })
121
+
122
+ it('falls back to symbolDef viewBox', () => {
123
+ const symbolDef = { id: 'pin', viewBox: '0 0 38 38' }
124
+ expect(getSymbolViewBox({ symbol: 'pin' }, symbolDef)).toBe('0 0 38 38')
125
+ })
126
+
127
+ it('returns default viewBox when neither source has one', () => {
128
+ expect(getSymbolViewBox({ symbol: 'pin' }, {})).toBe('0 0 38 38')
129
+ })
130
+
131
+ it('returns default viewBox when symbolDef is undefined', () => {
132
+ expect(getSymbolViewBox({ symbol: 'pin' }, undefined)).toBe('0 0 38 38')
133
+ })
134
+ })
135
+
136
+ // ─── getSymbolAnchor ──────────────────────────────────────────────────────────
137
+
138
+ describe('getSymbolAnchor', () => {
139
+ it('returns symbolAnchor from dataset', () => {
140
+ const dataset = { symbol: 'custom', symbolAnchor: [0.5, 0.9] }
141
+ expect(getSymbolAnchor(dataset, undefined)).toEqual([0.5, 0.9])
142
+ })
143
+
144
+ it('falls back to symbolDef anchor', () => {
145
+ const symbolDef = { id: 'pin', anchor: [0.5, 0.9] }
146
+ expect(getSymbolAnchor({ symbol: 'pin' }, symbolDef)).toEqual([0.5, 0.9])
147
+ })
148
+
149
+ it('returns default [0.5, 0.5] when neither source has an anchor', () => {
150
+ expect(getSymbolAnchor({ symbol: 'pin' }, {})).toEqual([0.5, 0.5])
151
+ })
152
+
153
+ it('returns default [0.5, 0.5] when symbolDef is undefined', () => {
154
+ expect(getSymbolAnchor({ symbol: 'pin' }, undefined)).toEqual([0.5, 0.5])
155
+ })
156
+ })
@@ -1,48 +0,0 @@
1
- import { hasPattern, getPatternImageId, rasterisePattern } from '../../styles/patterns.js'
2
- import { mergeSublayer } from '../../utils/mergeSublayer.js'
3
-
4
- /**
5
- * Collect all style configs that require a pattern image: top-level datasets
6
- * and any sublayers whose merged style has a pattern.
7
- * @param {Object[]} datasets
8
- * @returns {Object[]}
9
- */
10
- const getPatternConfigs = (datasets) =>
11
- datasets.flatMap(dataset => {
12
- const configs = hasPattern(dataset) ? [dataset] : []
13
- if (dataset.sublayers?.length) {
14
- dataset.sublayers.forEach(sublayer => {
15
- const merged = mergeSublayer(dataset, sublayer)
16
- if (hasPattern(merged)) {
17
- configs.push(merged)
18
- }
19
- })
20
- }
21
- return configs
22
- })
23
-
24
- /**
25
- * Register all required pattern images with the map.
26
- * Skips images that are already registered (safe to call on style change).
27
- * @param {Object} map - MapLibre map instance
28
- * @param {Object[]} datasets
29
- * @param {string} mapStyleId
30
- * @returns {Promise<void>}
31
- */
32
- export const registerPatterns = async (map, datasets, mapStyleId) => {
33
- const patternConfigs = getPatternConfigs(datasets)
34
- if (!patternConfigs.length) {
35
- return
36
- }
37
-
38
- await Promise.all(patternConfigs.map(async (config) => {
39
- const imageId = getPatternImageId(config, mapStyleId)
40
- if (!imageId || map.hasImage(imageId)) {
41
- return
42
- }
43
- const result = await rasterisePattern(config, mapStyleId)
44
- if (result) {
45
- map.addImage(result.imageId, result.imageData, { pixelRatio: 2 })
46
- }
47
- }))
48
- }
@@ -1,157 +0,0 @@
1
- import { getValueForStyle } from '../../../../../src/utils/getValueForStyle.js'
2
-
3
- // ─── Built-in pattern library ────────────────────────────────────────────────
4
- // Each value is the inner SVG content (paths only, no wrapper).
5
- // Paths are authored in a 16×16 coordinate space (power-of-two, tiles seamlessly).
6
- // Use {{foreground}} and {{background}} tokens for colours.
7
-
8
- const BUILT_IN_PATTERNS = {
9
- 'cross-hatch': '<path d="M0 4.486V3.485h3.5V.001h1v3.484h7.002V.001h1v3.484h3.5v1.001h-3.5v7h3.5v.999h-3.5v3.516h-1v-3.516H4.499v3.516h-1v-3.516H0v-.999h3.5v-7H0zm11.501 0H4.499v7h7.002v-7z" fill="{{foreground}}"/>',
10
- 'diagonal-cross-hatch': '<path d="M0 8.707V7.293L7.293 0h1.414L16 7.293v1.414L8.707 16H7.293L0 8.707zM.707 8L8 15.293 15.293 8 8 .707.707 8z" fill="{{foreground}}"/>',
11
- 'forward-diagonal-hatch': '<path d="M16 8.707V7.293L7.293 16h1.414L16 8.707zm-16 0L8.707 0H7.293L0 7.293v1.414z" fill="{{foreground}}"/>',
12
- 'backward-diagonal-hatch': '<path d="M0 8.707V7.293L8.707 16H7.293L0 8.707zm16 0L7.293 0h1.414L16 7.293v1.414z" fill="{{foreground}}"/>',
13
- 'horizontal-hatch': '<path d="M0 4.5V3.499h15.999V4.5H0zm0 7h15.999V12.5H0v-1.001z" fill="{{foreground}}"/>',
14
- 'vertical-hatch': '<path d="M3.501 16.001V0h1v16.001h-1zm7.998 0V0h1v16.001h-1z" fill="{{foreground}}"/>',
15
- dot: '<path d="M3.999 2A2 2 0 0 1 6 3.999C6 5.103 5.103 6 3.999 6a2 2 0 0 1-1.999-2.001A2 2 0 0 1 3.999 2zm0 7.999C5.103 10 6 10.897 6 12.001A2 2 0 0 1 3.999 14a2 2 0 0 1-1.999-1.999A2 2 0 0 1 3.999 10zM11.999 2A2 2 0 0 1 14 3.999C14 5.103 13.103 6 11.999 6S10 5.103 10 3.999A2 2 0 0 1 11.999 2zm0 7.999c1.104 0 2.001.897 2.001 2.001A2 2 0 0 1 11.999 14 2 2 0 0 1 10 12.001c0-1.104.897-2.001 1.999-2.001z" fill="{{foreground}}"/>',
16
- diamond: '<path d="M4 .465L7.535 4 4 7.535.465 4 4 .465zm0 7.999l3.535 3.535L4 15.535.465 11.999 4 8.464zm8-8l3.535 3.535-3.536 3.536L8.464 4 12 .464zm0 8.001L15.536 12 12 15.536 8.465 12 12 8.465z" fill="{{foreground}}"/>'
17
- }
18
-
19
- // Plugin-controlled border path used in the key symbol (20×20 coordinate space).
20
- // This is always rendered as the first element, before the user-supplied content.
21
- 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="{{background}}" stroke="{{foreground}}" stroke-width="2"/>'
22
-
23
- // ─── Helpers ─────────────────────────────────────────────────────────────────
24
-
25
- export const hashString = (str) => {
26
- let hash = 0
27
- for (const ch of str) {
28
- hash = ((hash << 5) - hash) + ch.codePointAt(0)
29
- hash = hash & hash
30
- }
31
- return Math.abs(hash).toString(36) // NOSONAR: base36 encoding for compact alphanumeric hash string
32
- }
33
-
34
- export const injectColors = (content, foreground, background) =>
35
- content
36
- .replace(/\{\{foreground\}\}/g, foreground || 'black')
37
- .replace(/\{\{background\}\}/g, background || 'transparent')
38
-
39
- // ─── Public API ───────────────────────────────────────────────────────────────
40
-
41
- /**
42
- * Returns true if a dataset has a fill pattern configured.
43
- * @param {Object} dataset
44
- * @returns {boolean}
45
- */
46
- export const hasPattern = (dataset) => !!(dataset.fillPattern || dataset.fillPatternSvgContent)
47
-
48
- /**
49
- * Returns the raw (un-coloured) inner SVG content for a dataset's pattern.
50
- * Custom fillPatternSvgContent takes precedence over built-in fillPattern ids.
51
- * @param {Object} dataset
52
- * @returns {string|null}
53
- */
54
- export const getPatternInnerContent = (dataset) => {
55
- if (dataset.fillPatternSvgContent) {
56
- return dataset.fillPatternSvgContent
57
- }
58
- if (dataset.fillPattern && BUILT_IN_PATTERNS[dataset.fillPattern]) {
59
- return BUILT_IN_PATTERNS[dataset.fillPattern]
60
- }
61
- return null
62
- }
63
-
64
- /**
65
- * Returns a deterministic image ID for a pattern + resolved colour combination.
66
- * @param {Object} dataset
67
- * @param {string} mapStyleId
68
- * @returns {string|null}
69
- */
70
- export const getPatternImageId = (dataset, mapStyleId) => {
71
- const innerContent = getPatternInnerContent(dataset)
72
- if (!innerContent) {
73
- return null
74
- }
75
- const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black'
76
- const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
77
- return `pattern-${hashString(innerContent + fg + bg)}`
78
- }
79
-
80
- /**
81
- * Returns colour-injected inner SVG path content for use in the Key symbol.
82
- * The caller is responsible for wrapping this in the SVG element and border path.
83
- * @param {Object} dataset
84
- * @param {string} mapStyleId
85
- * @returns {{ border: string, content: string }|null}
86
- */
87
- export const getKeyPatternPaths = (dataset, mapStyleId) => {
88
- const innerContent = getPatternInnerContent(dataset)
89
- if (!innerContent) {
90
- return null
91
- }
92
- const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black'
93
- const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
94
- const borderStroke = getValueForStyle(dataset.stroke, mapStyleId) || fg
95
- return {
96
- border: injectColors(KEY_BORDER_PATH, borderStroke, bg),
97
- content: injectColors(innerContent, fg, bg)
98
- }
99
- }
100
-
101
- // ─── Rasterisation ────────────────────────────────────────────────────────────
102
-
103
- // Module-level cache: imageId → ImageData. Avoids re-rasterising identical patterns.
104
- const imageDataCache = new Map()
105
-
106
- const rasteriseToImageData = (svgString, width, height) =>
107
- new Promise((resolve, reject) => {
108
- const blob = new Blob([svgString], { type: 'image/svg+xml' })
109
- const url = URL.createObjectURL(blob)
110
- const img = new Image(width, height)
111
- img.onload = () => {
112
- const canvas = document.createElement('canvas')
113
- canvas.width = width
114
- canvas.height = height
115
- const ctx = canvas.getContext('2d')
116
- ctx.drawImage(img, 0, 0, width, height)
117
- URL.revokeObjectURL(url)
118
- resolve(ctx.getImageData(0, 0, width, height))
119
- }
120
- img.onerror = () => {
121
- URL.revokeObjectURL(url)
122
- reject(new Error(`Failed to rasterise pattern SVG: ${svgString.slice(0, 80)}`))
123
- }
124
- img.src = url
125
- })
126
-
127
- /**
128
- * Rasterises a dataset's pattern SVG to ImageData, using an in-memory cache
129
- * to avoid re-rasterising identical patterns. Framework-agnostic — callers
130
- * are responsible for registering the result with their map framework.
131
- *
132
- * @param {Object} dataset
133
- * @param {string} mapStyleId
134
- * @returns {Promise<{ imageId: string, imageData: ImageData }|null>}
135
- */
136
- export const rasterisePattern = async (dataset, mapStyleId) => {
137
- const innerContent = getPatternInnerContent(dataset)
138
- if (!innerContent) {
139
- return null
140
- }
141
-
142
- const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black'
143
- const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
144
- const imageId = `pattern-${hashString(innerContent + fg + bg)}`
145
-
146
- let imageData = imageDataCache.get(imageId)
147
- if (!imageData) {
148
- const colored = injectColors(innerContent, fg, bg)
149
- const bgRect = `<rect width="16" height="16" fill="${bg}"/>`
150
- // pixelRatio: 2 means the map treats this as an 8×8 logical tile — crisp on retina screens.
151
- const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">${bgRect}${colored}</svg>`
152
- imageData = await rasteriseToImageData(svgString, 16, 16)
153
- imageDataCache.set(imageId, imageData)
154
- }
155
-
156
- return { imageId, imageData }
157
- }