@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.
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/context.md +53 -7
- package/docs/api/map-style-config.md +41 -2
- package/docs/api/marker-config.md +53 -11
- package/docs/api/symbol-config.md +160 -0
- package/docs/api/symbol-registry.md +115 -0
- package/docs/api.md +22 -19
- package/docs/plugins/datasets.md +105 -9
- package/docs/plugins/interact.md +68 -43
- package/docs/plugins/search.md +15 -3
- package/package.json +1 -1
- package/plugins/beta/datasets/dist/css/index.css +32 -14
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/esm/index.js +1 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/index.js +1 -1
- package/plugins/beta/datasets/src/DatasetsInit.jsx +9 -4
- package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +57 -11
- package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +14 -8
- package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +155 -53
- package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
- package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
- package/plugins/beta/datasets/src/api/addDataset.js +1 -1
- package/plugins/beta/datasets/src/api/setData.js +4 -2
- package/plugins/beta/datasets/src/api/setStyle.js +2 -2
- package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
- package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
- package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
- package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
- package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
- package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
- package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
- package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
- package/plugins/beta/datasets/src/datasets.js +13 -4
- package/plugins/beta/datasets/src/defaults.js +4 -2
- package/plugins/beta/datasets/src/index.js +2 -1
- package/plugins/beta/datasets/src/manifest.js +1 -1
- package/plugins/beta/datasets/src/panels/Key.jsx +11 -89
- package/plugins/beta/datasets/src/panels/Key.module.scss +24 -13
- package/plugins/beta/datasets/src/panels/Layers.module.scss +13 -7
- package/plugins/beta/datasets/src/reducer.js +6 -0
- package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
- package/plugins/beta/datasets/src/utils/mergeSublayer.js +8 -0
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
- package/plugins/beta/draw-ml/dist/css/index.css +21 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
- package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
- package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +1 -1
- package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
- package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/index.js +1 -1
- package/plugins/interact/src/InteractInit.jsx +14 -5
- package/plugins/interact/src/InteractInit.test.js +26 -6
- package/plugins/interact/src/api/enable.test.js +7 -7
- package/plugins/interact/src/defaults.js +4 -6
- package/plugins/interact/src/events.js +9 -6
- package/plugins/interact/src/events.test.js +28 -4
- package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
- package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
- package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
- package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
- package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
- package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
- package/plugins/interact/src/reducer.js +23 -4
- package/plugins/interact/src/reducer.test.js +60 -11
- package/plugins/interact/src/utils/buildStylesMap.js +17 -4
- package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
- package/plugins/interact/src/utils/featureQueries.js +11 -6
- package/plugins/interact/src/utils/featureQueries.test.js +8 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/src/Search.jsx +3 -1
- package/plugins/search/src/events/fetchSuggestions.js +6 -4
- package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
- package/plugins/search/src/events/formHandlers.js +3 -3
- package/plugins/search/src/events/formHandlers.test.js +1 -1
- package/plugins/search/src/events/suggestionHandlers.js +2 -2
- package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
- package/plugins/search/src/utils/updateMap.js +3 -3
- package/plugins/search/src/utils/updateMap.test.js +3 -3
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/index.js +1 -1
- package/providers/maplibre/src/appEvents.js +7 -0
- package/providers/maplibre/src/appEvents.test.js +18 -4
- package/providers/maplibre/src/maplibreProvider.js +52 -0
- package/providers/maplibre/src/maplibreProvider.test.js +105 -1
- package/providers/maplibre/src/utils/highlightFeatures.js +36 -7
- package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -96
- package/providers/maplibre/src/utils/hoverCursor.js +61 -0
- package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
- package/providers/maplibre/src/utils/patternImages.js +70 -0
- package/providers/maplibre/src/utils/patternImages.test.js +180 -0
- package/providers/maplibre/src/utils/queryFeatures.js +38 -16
- package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
- package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
- package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
- package/providers/maplibre/src/utils/symbolImages.js +147 -0
- package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
- package/src/App/components/Markers/Markers.jsx +122 -27
- package/src/App/components/Markers/Markers.module.scss +0 -10
- package/src/App/components/Markers/Markers.test.jsx +246 -0
- package/src/App/hooks/useInterfaceAPI.test.js +156 -0
- package/src/App/hooks/useMarkersAPI.js +2 -5
- package/src/App/hooks/useMarkersAPI.test.js +4 -4
- package/src/App/layout/Layout.jsx +2 -2
- package/src/App/layout/Layout.test.jsx +4 -2
- package/src/App/store/ServiceProvider.jsx +7 -5
- package/src/App/store/mapActionsMap.js +4 -6
- package/src/App/store/mapActionsMap.test.js +3 -2
- package/src/App/store/mapReducer.js +2 -1
- package/src/config/appConfig.js +0 -6
- package/src/config/appConfig.test.js +1 -2
- package/src/config/defaults.js +0 -2
- package/src/config/mapTheme.js +56 -0
- package/src/config/patternConfig.js +16 -0
- package/src/config/symbolConfig.js +80 -0
- package/src/scss/settings/_colors.scss +0 -9
- package/src/services/patternRegistry.js +40 -0
- package/src/services/patternRegistry.test.js +48 -0
- package/src/services/symbolRegistry.js +113 -0
- package/src/services/symbolRegistry.test.js +262 -0
- package/src/types.js +93 -11
- package/src/utils/patternUtils.js +94 -0
- package/src/utils/patternUtils.test.js +160 -0
- package/src/utils/symbolUtils.js +85 -0
- package/src/utils/symbolUtils.test.js +156 -0
- package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +0 -48
- 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
|
|
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]
|
|
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:
|
|
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,
|
|
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,
|
|
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:
|
|
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')
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
50
|
-
|
|
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:
|
|
24
|
+
mapStyle: initialMapStyle,
|
|
24
25
|
mapSize,
|
|
25
26
|
center,
|
|
26
27
|
zoom,
|
package/src/config/appConfig.js
CHANGED
|
@@ -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
|
|
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
|
})
|
package/src/config/defaults.js
CHANGED
|
@@ -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
|
+
})
|