@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,156 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useInterfaceAPI } from './useInterfaceAPI.js'
3
+ import { useApp } from '../store/appContext.js'
4
+ import { useService } from '../store/serviceContext.js'
5
+
6
+ jest.mock('../store/appContext.js')
7
+ jest.mock('../store/serviceContext.js')
8
+
9
+ const makeEventBus = () => {
10
+ const handlers = {}
11
+ return {
12
+ on: jest.fn((event, handler) => { handlers[event] = handler }),
13
+ off: jest.fn(),
14
+ emit: (event, payload) => handlers[event]?.(payload),
15
+ _handlers: handlers
16
+ }
17
+ }
18
+
19
+ describe('useInterfaceAPI', () => {
20
+ let mockDispatch, mockEventBus, mockState
21
+
22
+ beforeEach(() => {
23
+ mockDispatch = jest.fn()
24
+ mockEventBus = makeEventBus()
25
+ mockState = {
26
+ hiddenButtons: new Set(),
27
+ disabledButtons: new Set(),
28
+ pressedButtons: new Set(),
29
+ expandedButtons: new Set()
30
+ }
31
+
32
+ useApp.mockReturnValue({ dispatch: mockDispatch, ...mockState })
33
+ useService.mockReturnValue({ eventBus: mockEventBus })
34
+ })
35
+
36
+ it('dispatches ADD_BUTTON on app:addbutton', () => {
37
+ renderHook(() => useInterfaceAPI())
38
+ act(() => mockEventBus.emit('app:addbutton', { id: 'btn1', config: { label: 'Test' } }))
39
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'btn1', config: { label: 'Test' } } })
40
+ })
41
+
42
+ it('also dispatches ADD_BUTTON for each menuItem', () => {
43
+ renderHook(() => useInterfaceAPI())
44
+ act(() => mockEventBus.emit('app:addbutton', {
45
+ id: 'btn1',
46
+ config: {
47
+ label: 'Parent',
48
+ menuItems: [
49
+ { id: 'item1', label: 'Item 1' },
50
+ { id: 'item2', label: 'Item 2' }
51
+ ]
52
+ }
53
+ }))
54
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'btn1', config: expect.objectContaining({ label: 'Parent' }) } })
55
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'item1', config: { id: 'item1', label: 'Item 1', isMenuItem: true } } })
56
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'item2', config: { id: 'item2', label: 'Item 2', isMenuItem: true } } })
57
+ })
58
+
59
+ it('dispatches TOGGLE_APP_VISIBLE true on app:visible', () => {
60
+ renderHook(() => useInterfaceAPI())
61
+ act(() => mockEventBus.emit('app:visible'))
62
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_APP_VISIBLE', payload: true })
63
+ })
64
+
65
+ it('dispatches TOGGLE_APP_VISIBLE false on app:hidden', () => {
66
+ renderHook(() => useInterfaceAPI())
67
+ act(() => mockEventBus.emit('app:hidden'))
68
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_APP_VISIBLE', payload: false })
69
+ })
70
+
71
+ it('dispatches ADD_PANEL on app:addpanel', () => {
72
+ renderHook(() => useInterfaceAPI())
73
+ act(() => mockEventBus.emit('app:addpanel', { id: 'panel1', config: { title: 'My Panel' } }))
74
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_PANEL', payload: { id: 'panel1', config: { title: 'My Panel' } } })
75
+ })
76
+
77
+ it('dispatches REMOVE_PANEL on app:removepanel', () => {
78
+ renderHook(() => useInterfaceAPI())
79
+ act(() => mockEventBus.emit('app:removepanel', 'panel1'))
80
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'REMOVE_PANEL', payload: 'panel1' })
81
+ })
82
+
83
+ it('dispatches OPEN_PANEL on app:showpanel', () => {
84
+ renderHook(() => useInterfaceAPI())
85
+ act(() => mockEventBus.emit('app:showpanel', 'panel1'))
86
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1' } })
87
+ })
88
+
89
+ it('dispatches CLOSE_PANEL on app:hidepanel', () => {
90
+ renderHook(() => useInterfaceAPI())
91
+ act(() => mockEventBus.emit('app:hidepanel', 'panel1'))
92
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'CLOSE_PANEL', payload: 'panel1' })
93
+ })
94
+
95
+ it('dispatches ADD_CONTROL on app:addcontrol', () => {
96
+ renderHook(() => useInterfaceAPI())
97
+ act(() => mockEventBus.emit('app:addcontrol', { id: 'ctrl1', config: { position: 'top-left' } }))
98
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_CONTROL', payload: { id: 'ctrl1', config: { position: 'top-left' } } })
99
+ })
100
+
101
+ describe('handleToggleButtonState', () => {
102
+ it.each([
103
+ ['hidden', 'TOGGLE_BUTTON_HIDDEN', 'isHidden'],
104
+ ['disabled', 'TOGGLE_BUTTON_DISABLED', 'isDisabled'],
105
+ ['pressed', 'TOGGLE_BUTTON_PRESSED', 'isPressed'],
106
+ ['expanded', 'TOGGLE_BUTTON_EXPANDED', 'isExpanded']
107
+ ])('sets %s to explicit boolean value when provided', (prop, actionType, payloadKey) => {
108
+ renderHook(() => useInterfaceAPI())
109
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop, value: true }))
110
+ expect(mockDispatch).toHaveBeenCalledWith({ type: actionType, payload: { id: 'btn1', [payloadKey]: true } })
111
+
112
+ mockDispatch.mockClear()
113
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop, value: false }))
114
+ expect(mockDispatch).toHaveBeenCalledWith({ type: actionType, payload: { id: 'btn1', [payloadKey]: false } })
115
+ })
116
+
117
+ it.each([
118
+ ['hidden', 'TOGGLE_BUTTON_HIDDEN', 'isHidden', 'hiddenButtons'],
119
+ ['disabled', 'TOGGLE_BUTTON_DISABLED', 'isDisabled', 'disabledButtons'],
120
+ ['pressed', 'TOGGLE_BUTTON_PRESSED', 'isPressed', 'pressedButtons'],
121
+ ['expanded', 'TOGGLE_BUTTON_EXPANDED', 'isExpanded', 'expandedButtons']
122
+ ])('toggles %s when no boolean value provided', (prop, actionType, payloadKey, stateKey) => {
123
+ renderHook(() => useInterfaceAPI())
124
+
125
+ // Not in set → toggles to true
126
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop }))
127
+ expect(mockDispatch).toHaveBeenCalledWith({ type: actionType, payload: { id: 'btn1', [payloadKey]: true } })
128
+
129
+ // Already in set → toggles to false
130
+ mockDispatch.mockClear()
131
+ mockState[stateKey].add('btn1')
132
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop }))
133
+ expect(mockDispatch).toHaveBeenCalledWith({ type: actionType, payload: { id: 'btn1', [payloadKey]: false } })
134
+ })
135
+
136
+ it('does nothing for unknown prop', () => {
137
+ renderHook(() => useInterfaceAPI())
138
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop: 'unknown', value: true }))
139
+ expect(mockDispatch).not.toHaveBeenCalled()
140
+ })
141
+ })
142
+
143
+ it('removes all event listeners on unmount', () => {
144
+ const { unmount } = renderHook(() => useInterfaceAPI())
145
+ unmount()
146
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:visible', expect.any(Function))
147
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:hidden', expect.any(Function))
148
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:addbutton', expect.any(Function))
149
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:togglebuttonstate', expect.any(Function))
150
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:addpanel', expect.any(Function))
151
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:removepanel', expect.any(Function))
152
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:showpanel', expect.any(Function))
153
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:hidepanel', expect.any(Function))
154
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:addcontrol', expect.any(Function))
155
+ })
156
+ })
@@ -5,12 +5,9 @@ import { useService } from '../store/serviceContext.js'
5
5
  import { scaleFactor } from '../../config/appConfig.js'
6
6
  import { EVENTS as events } from '../../config/events.js'
7
7
 
8
- // Vertical offset to align the marker tip with the coordinate point
9
- const MARKER_ANCHOR_OFFSET_Y = 19
10
-
11
8
  /**
12
9
  * Projects geographic coordinates to screen pixel position, scaled for the
13
- * current map size and offset so the marker tip aligns with the point.
10
+ * current map size. Anchor alignment is handled in CSS by the Markers component.
14
11
  *
15
12
  * @param {Array<number>} coords - [lng, lat] geographic coordinates
16
13
  * @param {Object} mapProvider - Map provider instance with `mapToScreen` method
@@ -25,7 +22,7 @@ export const projectCoords = (coords, mapProvider, mapSize, isMapReady) => {
25
22
  const { x, y } = mapProvider.mapToScreen(coords)
26
23
  return {
27
24
  x: x * scaleFactor[mapSize],
28
- y: y * scaleFactor[mapSize] - MARKER_ANCHOR_OFFSET_Y
25
+ y: y * scaleFactor[mapSize]
29
26
  }
30
27
  }
31
28
 
@@ -19,7 +19,7 @@ describe('projectCoords', () => {
19
19
  const mockProvider = { mapToScreen: jest.fn(() => ({ x: 100, y: 200 })) }
20
20
 
21
21
  it('returns scaled coordinates when ready', () => {
22
- expect(projectCoords({ lat: 1, lng: 1 }, mockProvider, 'medium', true)).toEqual({ x: 200, y: 381 })
22
+ expect(projectCoords({ lat: 1, lng: 1 }, mockProvider, 'medium', true)).toEqual({ x: 200, y: 400 })
23
23
  })
24
24
 
25
25
  it('returns zero when not ready or no provider', () => {
@@ -102,7 +102,7 @@ describe('useMarkers', () => {
102
102
 
103
103
  const renderCallback = mockEventBus.on.mock.calls.find(call => call[0] === 'map:render')[1]
104
104
  act(() => renderCallback())
105
- expect(mockElement.style.transform).toBe('translate(200px, 381px)')
105
+ expect(mockElement.style.transform).toBe('translate(200px, 400px)')
106
106
  })
107
107
 
108
108
  it('skips map:render when not ready or no provider (line 60)', () => {
@@ -126,7 +126,7 @@ describe('useMarkers', () => {
126
126
  isMapReady: true
127
127
  })
128
128
  rerender()
129
- expect(mockElement.style.transform).toBe('translate(300px, 581px)')
129
+ expect(mockElement.style.transform).toBe('translate(300px, 600px)')
130
130
  })
131
131
 
132
132
  it('handles app:addmarker safely', () => {
@@ -137,7 +137,7 @@ describe('useMarkers', () => {
137
137
  act(() => handleAddMarker(addPayload))
138
138
  expect(mockDispatch).toHaveBeenCalledWith({
139
139
  type: 'UPSERT_LOCATION_MARKER',
140
- payload: { id: 'm1', coords: { lat: 1, lng: 1 }, label: 'Test', x: 200, y: 381, isVisible: true }
140
+ payload: { id: 'm1', coords: { lat: 1, lng: 1 }, label: 'Test', x: 200, y: 400, isVisible: true }
141
141
  })
142
142
  })
143
143
 
@@ -10,6 +10,7 @@ import { Attributions } from '../components/Attributions/Attributions'
10
10
  import { layoutSlots } from '../renderer/slots'
11
11
  import { SlotRenderer } from '../renderer/SlotRenderer'
12
12
  import { HtmlElementHost } from '../renderer/HtmlElementHost'
13
+ import { getMapThemeVars } from '../../config/mapTheme.js'
13
14
 
14
15
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
15
16
  // sonarjs/disable-next-line function-name
@@ -30,10 +31,9 @@ export const Layout = () => {
30
31
  `im-o-app--${interfaceType}`,
31
32
  `im-o-app--${isFullscreen ? 'fullscreen' : 'inline'}`,
32
33
  `im-o-app--${mapStyle?.appColorScheme || preferredColorScheme}-app`,
33
- `im-o-app--${mapStyle?.mapColorScheme || 'light'}-map`,
34
34
  hasExclusiveControl && 'im-o-app--exclusive-control'
35
35
  ].filter(Boolean).join(' ')}
36
- style={{ backgroundColor: mapStyle?.backgroundColor || undefined }}
36
+ style={{ backgroundColor: mapStyle?.backgroundColor || undefined, ...getMapThemeVars(mapStyle) }}
37
37
  ref={layoutRefs.appContainerRef}
38
38
  >
39
39
  <Viewport keyboardHintPortalRef={layoutRefs.topRef} />
@@ -72,9 +72,11 @@ describe('Layout', () => {
72
72
  expect(root.className).toContain('im-o-app--map')
73
73
  expect(root.className).toContain('im-o-app--inline')
74
74
  expect(root.className).toContain('im-o-app--light-app')
75
- expect(root.className).toContain('im-o-app--dark-map')
76
75
  expect(root.className).toContain('im-o-app--exclusive-control')
77
76
  expect(root.style.backgroundColor).toBe('pink')
77
+ expect(root.style.getPropertyValue('--map-overlay-halo-color')).toBe('#0b0c0c')
78
+ expect(root.style.getPropertyValue('--map-overlay-selected-color')).toBe('#ffffff')
79
+ expect(root.style.getPropertyValue('--map-overlay-foreground-color')).toBe('#ffffff')
78
80
 
79
81
  const overlay = root.querySelector('.im-o-app__overlay')
80
82
  expect(overlay.className).not.toContain('not-ready')
@@ -123,7 +125,7 @@ describe('Layout', () => {
123
125
 
124
126
  const root = document.getElementById('myApp-im-app')
125
127
  expect(root.className).toContain('im-o-app--dark-app')
126
- expect(root.className).toContain('im-o-app--light-map') // fallback
128
+ expect(root.className).not.toContain('im-o-app--light-map')
127
129
  expect(root.style.backgroundColor).toBe('')
128
130
  expect(root.className).not.toContain('exclusive-control')
129
131
  })
@@ -1,27 +1,29 @@
1
1
  // src/App/store/ServiceProvider.jsx
2
2
  import React, { createContext, useMemo, useRef } from 'react'
3
- import { EVENTS } from '../../config/events.js'
4
3
  import { createAnnouncer } from '../../services/announcer.js'
5
4
  import { reverseGeocode } from '../../services/reverseGeocode.js'
6
5
  import { useConfig } from '../store/configContext.js'
7
6
  import { closeApp } from '../../services/closeApp.js'
8
- import { logger } from '../../services/logger.js'
7
+ import { symbolRegistry } from '../../services/symbolRegistry.js'
8
+ import { patternRegistry } from '../../services/patternRegistry.js'
9
9
 
10
10
  export const ServiceContext = createContext(null)
11
11
 
12
12
  export const ServiceProvider = ({ eventBus, children }) => {
13
- const { id, handleExitClick } = useConfig()
13
+ const { id, handleExitClick, symbolDefaults: constructorSymbolDefaults } = useConfig()
14
14
  const mapStatusRef = useRef(null)
15
15
  const announce = useMemo(() => createAnnouncer(mapStatusRef), [])
16
16
 
17
+ symbolRegistry.setDefaults(constructorSymbolDefaults || {})
18
+
17
19
  const services = useMemo(() => ({
18
20
  announce,
19
21
  reverseGeocode: (zoom, center) => reverseGeocode(zoom, center),
20
- events: EVENTS,
21
22
  eventBus,
22
23
  mapStatusRef,
23
24
  closeApp: () => closeApp(id, handleExitClick, eventBus),
24
- logger
25
+ symbolRegistry,
26
+ patternRegistry
25
27
  }), [announce])
26
28
 
27
29
  return (
@@ -12,12 +12,10 @@ const setMapReady = (state) => ({
12
12
  isMapReady: true
13
13
  })
14
14
 
15
- const setMapStyle = (state, payload) => {
16
- return {
17
- ...state,
18
- mapStyle: payload
19
- }
20
- }
15
+ const setMapStyle = (state, payload) => ({
16
+ ...state,
17
+ mapStyle: payload
18
+ })
21
19
 
22
20
  const setMapSize = (state, payload) => {
23
21
  return {
@@ -46,8 +46,9 @@ describe('actionsMap', () => {
46
46
  })
47
47
 
48
48
  test('SET_MAP_STYLE sets mapStyle', () => {
49
- const result = actionsMap.SET_MAP_STYLE(state, 'satellite')
50
- expect(result.mapStyle).toBe('satellite')
49
+ const mapStyle = { id: 'satellite' }
50
+ const result = actionsMap.SET_MAP_STYLE(state, mapStyle)
51
+ expect(result.mapStyle).toBe(mapStyle)
51
52
  expect(result.otherProp).toBe(state.otherProp)
52
53
  })
53
54
 
@@ -17,10 +17,11 @@ export const initialState = (config) => {
17
17
  // Does a plugin handle map styles
18
18
  const pluginHandlesMapStyles = !!registeredPlugins?.find(plugin => plugin.config?.handlesMapStyle)
19
19
 
20
+ const initialMapStyle = pluginHandlesMapStyles ? null : mapStyle
20
21
  return {
21
22
  isMapReady: false,
22
23
  mapProvider: null,
23
- mapStyle: pluginHandlesMapStyles ? null : mapStyle,
24
+ mapStyle: initialMapStyle,
24
25
  mapSize,
25
26
  center,
26
27
  zoom,
@@ -159,9 +159,3 @@ export const scaleFactor = {
159
159
  medium: 1.5,
160
160
  large: 2
161
161
  }
162
-
163
- export const markerSvgPaths = [{
164
- shape: 'pin',
165
- backgroundPath: 'M31 16.001c0 7.489-8.308 15.289-11.098 17.698-.533.4-1.271.4-1.803 0C15.309 31.29 7 23.49 7 16.001c0-6.583 5.417-12 12-12s12 5.417 12 12z',
166
- graphicPath: 'M19 11.001c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.241 5-5-2.24-5-5-5z'
167
- }]
@@ -1,5 +1,5 @@
1
1
  import { render } from '@testing-library/react'
2
- import { defaultAppConfig, defaultButtonConfig, scaleFactor, markerSvgPaths } from './appConfig'
2
+ import { defaultAppConfig, defaultButtonConfig, scaleFactor } from './appConfig'
3
3
 
4
4
  describe('defaultAppConfig', () => {
5
5
  const appState = {
@@ -135,6 +135,5 @@ describe('defaultAppConfig', () => {
135
135
  it('exports supplementary configs and constants', () => {
136
136
  expect(defaultButtonConfig.label).toBe('Button')
137
137
  expect(scaleFactor.large).toBe(2)
138
- expect(markerSvgPaths[0].shape).toBe('pin')
139
138
  })
140
139
  })
@@ -27,8 +27,6 @@ const defaults = {
27
27
  mapViewParamKey: 'mv',
28
28
  maxMobileWidth: 640,
29
29
  minDesktopWidth: 835,
30
- markerColor: '#ff0000',
31
- markerShape: 'pin',
32
30
  nudgePanDelta: 5,
33
31
  nudgeZoomDelta: 0.1,
34
32
  panDelta: 100,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Canonical colour values for light and dark map colour schemes.
3
+ * These are the single source of truth for map overlay colours —
4
+ * used both by the symbol SVG renderer (JS canvas) and injected as
5
+ * CSS custom properties onto the app container for CSS-rendered elements.
6
+ *
7
+ * Per-style overrides take precedence: set `haloColor`, `selectedColor`, or
8
+ * `foregroundColor` directly on a `MapStyleConfig` to override these defaults.
9
+ */
10
+ export const SCHEME_COLORS = {
11
+ light: {
12
+ haloColor: '#ffffff',
13
+ selectedColor: '#0b0c0c',
14
+ foregroundColor: '#0b0c0c'
15
+ },
16
+ dark: {
17
+ haloColor: '#0b0c0c',
18
+ selectedColor: '#ffffff',
19
+ foregroundColor: '#ffffff'
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Resolves the three map overlay colours for the given map style,
25
+ * falling back to the appropriate scheme defaults when not explicitly set.
26
+ * Stored in `mapState.mapTheme` so plugins can read resolved values without
27
+ * importing from core.
28
+ *
29
+ * @param {import('../types.js').MapStyleConfig|null} mapStyle
30
+ * @returns {{ haloColor: string, selectedColor: string, foregroundColor: string }}
31
+ */
32
+ export const resolveMapTheme = (mapStyle) => {
33
+ const scheme = SCHEME_COLORS[mapStyle?.mapColorScheme] ?? SCHEME_COLORS.light
34
+ return {
35
+ haloColor: mapStyle?.haloColor ?? scheme.haloColor,
36
+ selectedColor: mapStyle?.selectedColor ?? scheme.selectedColor,
37
+ foregroundColor: mapStyle?.foregroundColor ?? scheme.foregroundColor
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Converts a mapStyle's overlay colours into CSS custom properties,
43
+ * falling back to the appropriate scheme defaults when not explicitly set.
44
+ * Suitable for spreading directly into a React `style` prop.
45
+ *
46
+ * @param {import('../types.js').MapStyleConfig|null} mapStyle
47
+ * @returns {Object} CSS custom property object
48
+ */
49
+ export const getMapThemeVars = (mapStyle) => {
50
+ const { haloColor, selectedColor, foregroundColor } = resolveMapTheme(mapStyle)
51
+ return {
52
+ '--map-overlay-halo-color': haloColor,
53
+ '--map-overlay-selected-color': selectedColor,
54
+ '--map-overlay-foreground-color': foregroundColor
55
+ }
56
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Built-in fill pattern SVG content.
3
+ * Each value is the inner SVG paths only (no wrapper element).
4
+ * Paths are authored in a 16×16 coordinate space and tile seamlessly.
5
+ * Use {{foregroundColor}} and {{backgroundColor}} tokens for colour injection.
6
+ */
7
+ export const BUILT_IN_PATTERNS = {
8
+ '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="{{foregroundColor}}"/>',
9
+ '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="{{foregroundColor}}"/>',
10
+ 'forward-diagonal-hatch': '<path d="M16 8.707V7.293L7.293 16h1.414L16 8.707zm-16 0L8.707 0H7.293L0 7.293v1.414z" fill="{{foregroundColor}}"/>',
11
+ 'backward-diagonal-hatch': '<path d="M0 8.707V7.293L8.707 16H7.293L0 8.707zm16 0L7.293 0h1.414L16 7.293v1.414z" fill="{{foregroundColor}}"/>',
12
+ 'horizontal-hatch': '<path d="M0 4.5V3.499h15.999V4.5H0zm0 7h15.999V12.5H0v-1.001z" fill="{{foregroundColor}}"/>',
13
+ 'vertical-hatch': '<path d="M3.501 16.001V0h1v16.001h-1zm7.998 0V0h1v16.001h-1z" fill="{{foregroundColor}}"/>',
14
+ 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="{{foregroundColor}}"/>',
15
+ 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="{{foregroundColor}}"/>'
16
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Default token values applied to all symbols unless overridden at the constructor,
3
+ * symbol registration, or marker creation level.
4
+ * Colour values may be a plain string or a map-style-keyed object,
5
+ * e.g. { outdoor: '#ffffff', dark: '#0b0c0c' }
6
+ */
7
+ export const symbolDefaults = {
8
+ symbol: 'pin',
9
+ backgroundColor: '#ca3535',
10
+ foregroundColor: '#ffffff',
11
+ haloWidth: '1',
12
+ selectedWidth: '6'
13
+ }
14
+
15
+ /**
16
+ * Built-in graphic path data strings for use with the `graphic` token.
17
+ *
18
+ * Each value is an SVG `d` attribute string in a 16×16 coordinate space,
19
+ * centred at (8, 8). The built-in symbols (`pin`, `circle`, `square`) apply a
20
+ * `translate` transform to position this 16×16 area correctly within their
21
+ * 38×38 viewBox — so graphic path data does not need to account for symbol
22
+ * positioning.
23
+ *
24
+ * @example
25
+ * markers.add('id', coords, { symbol: 'pin', graphic: graphics.dot })
26
+ *
27
+ * @example
28
+ * // Inline path data (16×16 space, centred at 8,8)
29
+ * markers.add('id', coords, { symbol: 'pin', graphic: 'M3 8 L8 3 L13 8 L8 13 Z' })
30
+ */
31
+ export const graphics = {
32
+ /** Small filled circle — the default graphic for built-in symbols */
33
+ dot: 'M8 3c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.241 5-5-2.24-5-5-5z',
34
+
35
+ /** Filled plus / cross shape */
36
+ cross: 'M6 3H10V6H13V10H10V13H6V10H3V6H6Z',
37
+
38
+ /** Filled diamond / rotated square */
39
+ diamond: 'M8 2L14 8L8 14L2 8Z',
40
+
41
+ /** Filled upward-pointing triangle */
42
+ triangle: 'M8 2L14 14H2Z',
43
+
44
+ /** Filled square */
45
+ square: 'M3 3H13V13H3Z'
46
+ }
47
+
48
+ // ─── Built-in symbol definitions ─────────────────────────────────────────────
49
+ // Each symbol uses a 38×38 viewBox. SVG templates use {{token}} placeholders
50
+ // resolved at render time by the symbolRegistry.
51
+
52
+ export const pin = {
53
+ id: 'pin',
54
+ viewBox: '0 0 38 38',
55
+ anchor: [0.5, 0.9], // NOSONAR
56
+ graphic: graphics.dot,
57
+ svg: `<path d="M19 33.499c-5.318-5-12-9.509-12-16.998 0-6.583 5.417-12 12-12s12 5.417 12 12c0 7.489-6.682 11.998-12 16.998z" fill="none" stroke="{{selectedColor}}" stroke-width="{{selectedWidth}}"/>
58
+ <path d="M19 33.499c-5.318-5-12-9.509-12-16.998 0-6.583 5.417-12 12-12s12 5.417 12 12c0 7.489-6.682 11.998-12 16.998z" fill="{{backgroundColor}}" stroke="{{haloColor}}" stroke-width="{{haloWidth}}"/>
59
+ <g transform="translate(19, 16) scale(0.8) translate(-8, -8)"><path d="{{graphic}}" fill="{{foregroundColor}}"/></g>`
60
+ }
61
+
62
+ export const circle = {
63
+ id: 'circle',
64
+ viewBox: '0 0 38 38',
65
+ anchor: [0.5, 0.5],
66
+ graphic: graphics.dot,
67
+ svg: `<path d="M19 7C12.376 7 7 12.376 7 19s5.376 12 12 12a12.01 12.01 0 0 0 12-12A12.01 12.01 0 0 0 19 7z" fill="none" stroke="{{selectedColor}}" stroke-width="{{selectedWidth}}"/>
68
+ <path d="M19 7C12.376 7 7 12.376 7 19s5.376 12 12 12a12.01 12.01 0 0 0 12-12A12.01 12.01 0 0 0 19 7z" fill="{{backgroundColor}}" stroke="{{haloColor}}" stroke-width="{{haloWidth}}"/>
69
+ <g transform="translate(19, 19) scale(0.8) translate(-8, -8)"><path d="{{graphic}}" fill="{{foregroundColor}}"/></g>`
70
+ }
71
+
72
+ export const square = {
73
+ id: 'square',
74
+ viewBox: '0 0 38 38',
75
+ anchor: [0.5, 0.5],
76
+ graphic: graphics.dot,
77
+ svg: `<path d="M28 7a3 3 0 0 1 3 3v18a3 3 0 0 1-3 3H10a3 3 0 0 1-3-3V10a3 3 0 0 1 3-3h18z" fill="none" stroke="{{selectedColor}}" stroke-width="{{selectedWidth}}"/>
78
+ <path d="M28 7a3 3 0 0 1 3 3v18a3 3 0 0 1-3 3H10a3 3 0 0 1-3-3V10a3 3 0 0 1 3-3h18z" fill="{{backgroundColor}}" stroke="{{haloColor}}" stroke-width="{{haloWidth}}"/>
79
+ <g transform="translate(19, 19) scale(0.8) translate(-8, -8)"><path d="{{graphic}}" fill="{{foregroundColor}}"/></g>`
80
+ }
@@ -35,10 +35,6 @@
35
35
  --attributions-foreground-color: #0b0c0c;
36
36
  --attributions-background-color: #ffffff80;
37
37
 
38
- // Map overlays, such as scale bar, target marker etc
39
- --map-overlay-foreground-color: #0b0c0c;
40
- --map-overlay-halo-color: #ffffff;
41
-
42
38
  // Fixed colours (Dont chnage with dark mode)
43
39
  --fixed-foreground-color: #0b0c0c;
44
40
 
@@ -78,8 +74,3 @@
78
74
  --content-link-color: #ffffff;
79
75
  }
80
76
 
81
- // Map color scheme specific overrides
82
- :root .im-o-app--dark-map {
83
- --map-overlay-foreground-color: #ffffff;
84
- --map-overlay-halo-color: #0b0c0c;
85
- }
@@ -0,0 +1,40 @@
1
+ import { BUILT_IN_PATTERNS } from '../config/patternConfig.js'
2
+
3
+ const patterns = new Map()
4
+
5
+ export const patternRegistry = {
6
+ /**
7
+ * Register a named pattern.
8
+ *
9
+ * @param {string} id - Unique pattern name (e.g. 'my-hatch')
10
+ * @param {string} svgContent - Inner SVG path content in a 16×16 coordinate space.
11
+ * Use {{foregroundColor}} and {{backgroundColor}} tokens for colour injection.
12
+ */
13
+ register (id, svgContent) {
14
+ patterns.set(id, { id, svgContent })
15
+ },
16
+
17
+ /**
18
+ * Retrieve a registered pattern by name.
19
+ *
20
+ * @param {string} id
21
+ * @returns {{ id: string, svgContent: string }|undefined}
22
+ */
23
+ get (id) {
24
+ return patterns.get(id)
25
+ },
26
+
27
+ /**
28
+ * Returns all registered patterns.
29
+ *
30
+ * @returns {{ id: string, svgContent: string }[]}
31
+ */
32
+ list () {
33
+ return [...patterns.values()]
34
+ }
35
+ }
36
+
37
+ // Seed built-in patterns
38
+ Object.entries(BUILT_IN_PATTERNS).forEach(([id, svgContent]) => {
39
+ patternRegistry.register(id, svgContent)
40
+ })
@@ -0,0 +1,48 @@
1
+ import { patternRegistry } from './patternRegistry.js'
2
+
3
+ describe('patternRegistry', () => {
4
+ describe('built-in patterns', () => {
5
+ test.each([
6
+ 'cross-hatch',
7
+ 'diagonal-cross-hatch',
8
+ 'forward-diagonal-hatch',
9
+ 'backward-diagonal-hatch',
10
+ 'horizontal-hatch',
11
+ 'vertical-hatch',
12
+ 'dot',
13
+ 'diamond'
14
+ ])('seeds built-in pattern: %s', (id) => {
15
+ const pattern = patternRegistry.get(id)
16
+ expect(pattern).toBeDefined()
17
+ expect(pattern.id).toBe(id)
18
+ expect(typeof pattern.svgContent).toBe('string')
19
+ expect(pattern.svgContent.length).toBeGreaterThan(0)
20
+ })
21
+ })
22
+
23
+ describe('register / get', () => {
24
+ test('registers a custom pattern and retrieves it by id', () => {
25
+ patternRegistry.register('test-hatch', '<path d="M0 0L8 8" stroke="{{foreground}}"/>')
26
+ const result = patternRegistry.get('test-hatch')
27
+ expect(result).toEqual({ id: 'test-hatch', svgContent: '<path d="M0 0L8 8" stroke="{{foreground}}"/>' })
28
+ })
29
+
30
+ test('returns undefined for an unregistered id', () => {
31
+ expect(patternRegistry.get('nonexistent-pattern')).toBeUndefined()
32
+ })
33
+
34
+ test('overwrite an existing pattern by re-registering the same id', () => {
35
+ patternRegistry.register('overwrite-test', '<path d="M0 0"/>')
36
+ patternRegistry.register('overwrite-test', '<path d="M1 1"/>')
37
+ expect(patternRegistry.get('overwrite-test').svgContent).toBe('<path d="M1 1"/>')
38
+ })
39
+ })
40
+
41
+ describe('list', () => {
42
+ test('returns all registered patterns including built-ins', () => {
43
+ const all = patternRegistry.list()
44
+ expect(all.length).toBeGreaterThanOrEqual(8)
45
+ expect(all.every(p => p.id && p.svgContent)).toBe(true)
46
+ })
47
+ })
48
+ })