@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,248 @@
|
|
|
1
|
+
import { anchorToMaplibre, getSymbolImageId, registerSymbols } from './symbolImages.js'
|
|
2
|
+
import { symbolRegistry } from '../../../../src/services/symbolRegistry.js'
|
|
3
|
+
|
|
4
|
+
const STYLE_ID = 'test'
|
|
5
|
+
const mapStyle = { id: STYLE_ID }
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock')
|
|
9
|
+
globalThis.URL.revokeObjectURL = jest.fn()
|
|
10
|
+
|
|
11
|
+
HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
|
|
12
|
+
drawImage: jest.fn(),
|
|
13
|
+
getImageData: jest.fn((_x, _y, w, h) => ({ width: w, height: h }))
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
globalThis.Image = class {
|
|
17
|
+
constructor (w, h) {
|
|
18
|
+
this.width = w
|
|
19
|
+
this.height = h
|
|
20
|
+
this._src = ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get src () { return this._src }
|
|
24
|
+
set src (val) { this._src = val; this.onload?.() }
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
symbolRegistry.setDefaults({})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// ─── anchorToMaplibre ─────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
describe('anchorToMaplibre', () => {
|
|
35
|
+
it('returns center for [0.5, 0.5]', () => {
|
|
36
|
+
expect(anchorToMaplibre([0.5, 0.5])).toBe('center')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns top for [0.5, 0]', () => {
|
|
40
|
+
expect(anchorToMaplibre([0.5, 0])).toBe('top')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns bottom for [0.5, 1]', () => {
|
|
44
|
+
expect(anchorToMaplibre([0.5, 1])).toBe('bottom')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns left for [0, 0.5]', () => {
|
|
48
|
+
expect(anchorToMaplibre([0, 0.5])).toBe('left')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns right for [1, 0.5]', () => {
|
|
52
|
+
expect(anchorToMaplibre([1, 0.5])).toBe('right')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns top-left for [0, 0]', () => {
|
|
56
|
+
expect(anchorToMaplibre([0, 0])).toBe('top-left')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('returns top-right for [1, 0]', () => {
|
|
60
|
+
expect(anchorToMaplibre([1, 0])).toBe('top-right')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns bottom-left for [0, 1]', () => {
|
|
64
|
+
expect(anchorToMaplibre([0, 1])).toBe('bottom-left')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns bottom-right for [1, 1]', () => {
|
|
68
|
+
expect(anchorToMaplibre([1, 1])).toBe('bottom-right')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('snaps pin anchor [0.5, 0.9] to bottom', () => {
|
|
72
|
+
expect(anchorToMaplibre([0.5, 0.9])).toBe('bottom') // NOSONAR S109 — deliberate boundary test value
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns center for values in the middle band', () => {
|
|
76
|
+
expect(anchorToMaplibre([0.5, 0.5])).toBe('center')
|
|
77
|
+
expect(anchorToMaplibre([0.26, 0.26])).toBe('center') // NOSONAR S109 — just inside center band
|
|
78
|
+
expect(anchorToMaplibre([0.74, 0.74])).toBe('center') // NOSONAR S109 — just inside center band
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('returns top at boundary value 0.25', () => {
|
|
82
|
+
expect(anchorToMaplibre([0.5, 0.25])).toBe('top')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('returns bottom at boundary value 0.75', () => {
|
|
86
|
+
expect(anchorToMaplibre([0.5, 0.75])).toBe('bottom') // NOSONAR S109 — ANCHOR_HIGH boundary
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// ─── getSymbolImageId ─────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('getSymbolImageId', () => {
|
|
93
|
+
it('returns null when dataset has no symbol', () => {
|
|
94
|
+
expect(getSymbolImageId({}, mapStyle, symbolRegistry)).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('returns null for an unregistered symbol id', () => {
|
|
98
|
+
expect(getSymbolImageId({ symbol: 'does-not-exist' }, mapStyle, symbolRegistry)).toBeNull()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('returns a string prefixed symbol- for normal state', () => {
|
|
102
|
+
const id = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
|
|
103
|
+
expect(typeof id).toBe('string')
|
|
104
|
+
expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('returns a string prefixed symbol-sel- for selected state', () => {
|
|
108
|
+
const id = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
|
|
109
|
+
expect(typeof id).toBe('string')
|
|
110
|
+
expect(id).toMatch(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('normal and selected ids differ for the same dataset', () => {
|
|
114
|
+
const normalId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, false)
|
|
115
|
+
const selectedId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
|
|
116
|
+
expect(normalId).not.toBe(selectedId)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('same dataset and style always produces the same id', () => {
|
|
120
|
+
const id1 = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
|
|
121
|
+
const id2 = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
|
|
122
|
+
expect(id1).toBe(id2)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('different symbols produce different ids', () => {
|
|
126
|
+
const pinId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
|
|
127
|
+
const circleId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry)
|
|
128
|
+
expect(pinId).not.toBe(circleId)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('different backgrounds produce different ids', () => {
|
|
132
|
+
const redId = getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#ff0000' }, mapStyle, symbolRegistry)
|
|
133
|
+
const blueId = getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#0000ff' }, mapStyle, symbolRegistry)
|
|
134
|
+
expect(redId).not.toBe(blueId)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('resolves inline symbolSvgContent', () => {
|
|
138
|
+
const dataset = {
|
|
139
|
+
symbolSvgContent: '<circle cx="19" cy="19" r="12" fill="{{backgroundColor}}"/>',
|
|
140
|
+
symbolViewBox: '0 0 38 38',
|
|
141
|
+
symbolAnchor: [0.5, 0.5]
|
|
142
|
+
}
|
|
143
|
+
const id = getSymbolImageId(dataset, mapStyle, symbolRegistry)
|
|
144
|
+
expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// ─── registerSymbols ──────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
const makeMap = (existingIds = []) => ({
|
|
151
|
+
_symbolImageMap: {},
|
|
152
|
+
hasImage: jest.fn((id) => existingIds.includes(id)),
|
|
153
|
+
addImage: jest.fn()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('registerSymbols — registration', () => {
|
|
157
|
+
it('returns early and does not touch map for empty configs', async () => {
|
|
158
|
+
const map = makeMap()
|
|
159
|
+
await registerSymbols(map, [], mapStyle, symbolRegistry)
|
|
160
|
+
expect(map.hasImage).not.toHaveBeenCalled()
|
|
161
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('resets _symbolImageMap before processing', async () => {
|
|
165
|
+
const map = makeMap()
|
|
166
|
+
map._symbolImageMap = { stale: 'entry' }
|
|
167
|
+
await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
|
|
168
|
+
expect(map._symbolImageMap).not.toHaveProperty('stale')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('calls addImage for normal and selected variants', async () => {
|
|
172
|
+
const map = makeMap()
|
|
173
|
+
await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
|
|
174
|
+
expect(map.addImage).toHaveBeenCalledTimes(2)
|
|
175
|
+
expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
|
|
176
|
+
expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('populates _symbolImageMap with normal → selected id pairs', async () => {
|
|
180
|
+
const map = makeMap()
|
|
181
|
+
await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
|
|
182
|
+
const normalId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, false)
|
|
183
|
+
const selectedId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
|
|
184
|
+
expect(map._symbolImageMap[normalId]).toBe(selectedId)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('skips addImage when image is already registered', async () => {
|
|
188
|
+
const normalId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry, false)
|
|
189
|
+
const selectedId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry, true)
|
|
190
|
+
const map = makeMap([normalId, selectedId])
|
|
191
|
+
await registerSymbols(map, [{ symbol: 'circle' }], mapStyle, symbolRegistry)
|
|
192
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('processes multiple configs independently', async () => {
|
|
196
|
+
const map = makeMap()
|
|
197
|
+
await registerSymbols(map, [{ symbol: 'pin' }, { symbol: 'circle' }], mapStyle, symbolRegistry)
|
|
198
|
+
expect(map.addImage).toHaveBeenCalledTimes(4)
|
|
199
|
+
expect(Object.keys(map._symbolImageMap)).toHaveLength(2)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('registerSymbols — null results and caching', () => {
|
|
204
|
+
it('does not call addImage when rasteriseSymbolImage returns null', async () => {
|
|
205
|
+
// getSymbolImageId (called twice — normal + selected) needs a real symbolDef to produce imageIds,
|
|
206
|
+
// but rasteriseSymbolImage must get undefined from getSymbolDef so it returns null.
|
|
207
|
+
// The registry.get call order: [1] getSymbolImageId normal, [2] getSymbolImageId selected,
|
|
208
|
+
// [3] rasteriseSymbolImage normal, [4] rasteriseSymbolImage selected.
|
|
209
|
+
const pinDef = symbolRegistry.get('pin')
|
|
210
|
+
const getSpy = jest.spyOn(symbolRegistry, 'get')
|
|
211
|
+
.mockReturnValueOnce(pinDef)
|
|
212
|
+
.mockReturnValueOnce(pinDef)
|
|
213
|
+
.mockReturnValueOnce(undefined)
|
|
214
|
+
.mockReturnValueOnce(undefined)
|
|
215
|
+
const map = makeMap()
|
|
216
|
+
await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
|
|
217
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
218
|
+
getSpy.mockRestore()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('skips config when symbolDef cannot be resolved', async () => {
|
|
222
|
+
const map = makeMap()
|
|
223
|
+
await registerSymbols(map, [{ symbol: 'no-such-symbol' }], mapStyle, symbolRegistry)
|
|
224
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
225
|
+
expect(map._symbolImageMap).toEqual({})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('reuses cached imageData when called again with the same pixelRatio', async () => {
|
|
229
|
+
// Use an unusual ratio so this test owns its cache entries
|
|
230
|
+
const uniqueRatio = 7
|
|
231
|
+
|
|
232
|
+
const map1 = makeMap()
|
|
233
|
+
const blobCallsBefore = globalThis.URL.createObjectURL.mock.calls.length
|
|
234
|
+
await registerSymbols(map1, [{ symbol: 'pin' }], mapStyle, symbolRegistry, uniqueRatio)
|
|
235
|
+
const blobCallsAfterFirst = globalThis.URL.createObjectURL.mock.calls.length
|
|
236
|
+
// Rasterisation ran — blob was created
|
|
237
|
+
expect(blobCallsAfterFirst).toBeGreaterThan(blobCallsBefore)
|
|
238
|
+
|
|
239
|
+
// Second call with a fresh map (hasImage → false) but same ratio → cache hit
|
|
240
|
+
const map2 = makeMap()
|
|
241
|
+
await registerSymbols(map2, [{ symbol: 'pin' }], mapStyle, symbolRegistry, uniqueRatio)
|
|
242
|
+
const blobCallsAfterSecond = globalThis.URL.createObjectURL.mock.calls.length
|
|
243
|
+
// No new blob created — rasterisation was skipped via cache
|
|
244
|
+
expect(blobCallsAfterSecond).toBe(blobCallsAfterFirst)
|
|
245
|
+
// addImage still called because map2 has no pre-registered images
|
|
246
|
+
expect(map2.addImage).toHaveBeenCalledTimes(2)
|
|
247
|
+
})
|
|
248
|
+
})
|
|
@@ -1,47 +1,142 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { useMarkers } from '../../hooks/useMarkersAPI.js'
|
|
3
3
|
import { useConfig } from '../../store/configContext.js'
|
|
4
4
|
import { useMap } from '../../store/mapContext.js'
|
|
5
|
-
import {
|
|
5
|
+
import { useService } from '../../store/serviceContext.js'
|
|
6
6
|
import { stringToKebab } from '../../../utils/stringToKebab.js'
|
|
7
|
+
import { scaleFactor } from '../../../config/appConfig.js'
|
|
8
|
+
|
|
9
|
+
// Marker properties handled internally — excluded from style value resolution
|
|
10
|
+
const INTERNAL_KEYS = new Set(['id', 'coords', 'x', 'y', 'isVisible', 'symbol', 'symbolSvgContent', 'viewBox', 'anchor', 'selectedColor', 'selectedWidth'])
|
|
11
|
+
|
|
12
|
+
const resolveSymbolDef = (marker, defaults, symbolRegistry) => {
|
|
13
|
+
const svgContent = marker.symbolSvgContent || defaults.symbolSvgContent
|
|
14
|
+
// Inline symbolSvgContent takes precedence over a registered symbol,
|
|
15
|
+
// cascading through marker → constructor defaults
|
|
16
|
+
return svgContent
|
|
17
|
+
? { svg: svgContent }
|
|
18
|
+
: symbolRegistry.get(marker.symbol || defaults.symbol)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const resolveViewBox = (marker, defaults, symbolDef) =>
|
|
22
|
+
marker.viewBox || defaults.viewBox || symbolDef?.viewBox || '0 0 38 38'
|
|
23
|
+
|
|
24
|
+
const resolveAnchor = (marker, defaults, symbolDef) =>
|
|
25
|
+
marker.anchor ?? defaults.anchor ?? symbolDef?.anchor ?? [0.5, 0.5]
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* When the interact plugin is active, watch mousemove to set cursor:pointer whenever
|
|
29
|
+
* the mouse is over one of the marker SVG elements (which are pointer-events:none).
|
|
30
|
+
*/
|
|
31
|
+
const useMarkerCursor = (markers, interactActive, viewportRef) => {
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!interactActive) {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
const viewport = viewportRef.current
|
|
37
|
+
if (!viewport) {
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
const onMove = (e) => {
|
|
41
|
+
const hit = markers.items.some(marker => {
|
|
42
|
+
const el = markers.markerRefs?.get(marker.id)
|
|
43
|
+
if (!el) {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
const { left, top, right, bottom } = el.getBoundingClientRect()
|
|
47
|
+
return e.clientX >= left && e.clientX <= right && e.clientY >= top && e.clientY <= bottom
|
|
48
|
+
})
|
|
49
|
+
viewport.style.cursor = hit ? 'pointer' : ''
|
|
50
|
+
}
|
|
51
|
+
viewport.addEventListener('mousemove', onMove)
|
|
52
|
+
return () => {
|
|
53
|
+
viewport.removeEventListener('mousemove', onMove)
|
|
54
|
+
viewport.style.cursor = ''
|
|
55
|
+
}
|
|
56
|
+
}, [markers, interactActive, viewportRef])
|
|
57
|
+
}
|
|
7
58
|
|
|
8
59
|
// eslint-disable-next-line camelcase, react/jsx-pascal-case
|
|
9
60
|
// sonarjs/disable-next-line function-name
|
|
10
61
|
export const Markers = () => {
|
|
11
|
-
const { id
|
|
12
|
-
const { mapStyle } = useMap()
|
|
62
|
+
const { id } = useConfig()
|
|
63
|
+
const { mapStyle, mapSize } = useMap()
|
|
13
64
|
const { markers, markerRef } = useMarkers()
|
|
65
|
+
const { symbolRegistry, eventBus } = useService()
|
|
66
|
+
|
|
67
|
+
const [canSelectMarker, setCanSelectMarker] = useState(false)
|
|
68
|
+
const [selectedMarkers, setSelectedMarkers] = useState([])
|
|
69
|
+
const viewportRef = useRef(null)
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const handleActive = ({ active, interactionModes = [] }) => setCanSelectMarker(active && interactionModes.includes('selectMarker'))
|
|
73
|
+
const handleSelectionChange = ({ selectedMarkers: next = [] }) => setSelectedMarkers(next)
|
|
74
|
+
eventBus.on('interact:active', handleActive)
|
|
75
|
+
eventBus.on('interact:selectionchange', handleSelectionChange)
|
|
76
|
+
return () => {
|
|
77
|
+
eventBus.off('interact:active', handleActive)
|
|
78
|
+
eventBus.off('interact:selectionchange', handleSelectionChange)
|
|
79
|
+
}
|
|
80
|
+
}, [eventBus])
|
|
81
|
+
|
|
82
|
+
// Resolve viewport element once on mount for cursor tracking
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
viewportRef.current = document.querySelector('.im-c-viewport')
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
useMarkerCursor(markers, canSelectMarker, viewportRef)
|
|
14
88
|
|
|
15
89
|
if (!mapStyle) {
|
|
16
90
|
return undefined
|
|
17
91
|
}
|
|
18
92
|
|
|
19
|
-
const
|
|
93
|
+
const defaults = symbolRegistry.getDefaults()
|
|
20
94
|
|
|
21
95
|
return (
|
|
22
96
|
<>
|
|
23
|
-
{markers.items.map(marker =>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
97
|
+
{markers.items.map(marker => {
|
|
98
|
+
const symbolDef = resolveSymbolDef(marker, defaults, symbolRegistry)
|
|
99
|
+
// selectedColor comes from mapStyle — not per-marker; selectedWidth stays in cascade
|
|
100
|
+
const styleValues = Object.fromEntries(
|
|
101
|
+
Object.entries(marker).filter(([k]) => !INTERNAL_KEYS.has(k))
|
|
102
|
+
)
|
|
103
|
+
const isSelected = selectedMarkers.includes(marker.id)
|
|
104
|
+
const resolvedSvg = isSelected
|
|
105
|
+
? symbolRegistry.resolveSelected(symbolDef, styleValues, mapStyle)
|
|
106
|
+
: symbolRegistry.resolve(symbolDef, styleValues, mapStyle)
|
|
107
|
+
|
|
108
|
+
const viewBox = resolveViewBox(marker, defaults, symbolDef)
|
|
109
|
+
const [,, svgWidth, svgHeight] = viewBox.split(' ').map(Number)
|
|
110
|
+
const anchor = resolveAnchor(marker, defaults, symbolDef)
|
|
111
|
+
const shapeId = marker.symbol || defaults.symbol
|
|
112
|
+
const scale = scaleFactor[mapSize] ?? 1
|
|
113
|
+
const scaledWidth = svgWidth * scale
|
|
114
|
+
const scaledHeight = svgHeight * scale
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<svg
|
|
118
|
+
key={marker.id}
|
|
119
|
+
ref={markerRef(marker.id)}
|
|
120
|
+
id={`${id}-marker-${marker.id}`}
|
|
121
|
+
className={[
|
|
122
|
+
'im-c-marker',
|
|
123
|
+
`im-c-marker--${stringToKebab(shapeId)}`,
|
|
124
|
+
isSelected && 'im-c-marker--selected'
|
|
125
|
+
].filter(Boolean).join(' ')}
|
|
126
|
+
width={scaledWidth}
|
|
127
|
+
height={scaledHeight}
|
|
128
|
+
viewBox={viewBox}
|
|
129
|
+
overflow='visible'
|
|
130
|
+
style={{
|
|
131
|
+
display: marker.isVisible ? 'block' : 'none',
|
|
132
|
+
marginLeft: `${-anchor[0] * scaledWidth}px`,
|
|
133
|
+
marginTop: `${-anchor[1] * scaledHeight}px`
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
<g dangerouslySetInnerHTML={{ __html: resolvedSvg }} />
|
|
137
|
+
</svg>
|
|
138
|
+
)
|
|
139
|
+
})}
|
|
45
140
|
</>
|
|
46
141
|
)
|
|
47
142
|
}
|
|
@@ -7,16 +7,6 @@
|
|
|
7
7
|
position: absolute;
|
|
8
8
|
left: 0;
|
|
9
9
|
top: 0;
|
|
10
|
-
margin: -17px 0 0 -19px;
|
|
11
10
|
pointer-events: none;
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
// 2. Elements
|
|
15
|
-
.im-c-marker__graphic {
|
|
16
|
-
fill: var(--map-overlay-halo-color);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// 3. Modifiers
|
|
20
|
-
.im-c-marker--pin .im-c-marker__graphic {
|
|
21
|
-
filter: opacity(50%) brightness(0.25);
|
|
22
|
-
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { render, act } from '@testing-library/react'
|
|
2
|
+
import { Markers } from './Markers.jsx'
|
|
3
|
+
import { useMarkers } from '../../hooks/useMarkersAPI.js'
|
|
4
|
+
import { useConfig } from '../../store/configContext.js'
|
|
5
|
+
import { useMap } from '../../store/mapContext.js'
|
|
6
|
+
import { useService } from '../../store/serviceContext.js'
|
|
7
|
+
|
|
8
|
+
jest.mock('../../hooks/useMarkersAPI.js', () => ({ useMarkers: jest.fn() }))
|
|
9
|
+
jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
|
|
10
|
+
jest.mock('../../store/mapContext.js', () => ({ useMap: jest.fn() }))
|
|
11
|
+
jest.mock('../../store/serviceContext.js', () => ({ useService: jest.fn() }))
|
|
12
|
+
jest.mock('../../../config/appConfig.js', () => ({ scaleFactor: { small: 1, medium: 1.5, large: 2 } }))
|
|
13
|
+
|
|
14
|
+
const makeEventBus = () => {
|
|
15
|
+
const listeners = {}
|
|
16
|
+
return {
|
|
17
|
+
on: jest.fn((e, fn) => { listeners[e] = fn }),
|
|
18
|
+
off: jest.fn(),
|
|
19
|
+
emit: (e, payload) => listeners[e]?.(payload)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const makeSymbolRegistry = (overrides = {}) => ({
|
|
24
|
+
get: jest.fn(() => ({ svg: '<circle/>', viewBox: '0 0 38 38', anchor: [0.5, 1] })),
|
|
25
|
+
getDefaults: jest.fn(() => ({ symbol: 'pin', viewBox: '0 0 38 38', anchor: [0.5, 1] })),
|
|
26
|
+
resolve: jest.fn(() => '<circle/>'),
|
|
27
|
+
resolveSelected: jest.fn(() => '<circle class="selected"/>'),
|
|
28
|
+
...overrides
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const makeMarker = (overrides = {}) => ({
|
|
32
|
+
id: 'marker-1', isVisible: true, symbol: 'pin', ...overrides
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const setup = ({ markers = [], mapSize = 'small', eventBus, symbolRegistry, mapStyle = 'outdoor' } = {}) => {
|
|
36
|
+
const eb = eventBus ?? makeEventBus()
|
|
37
|
+
const sr = symbolRegistry ?? makeSymbolRegistry()
|
|
38
|
+
const markerRefs = new Map()
|
|
39
|
+
useConfig.mockReturnValue({ id: 'test-app' })
|
|
40
|
+
useMap.mockReturnValue({ mapStyle, mapSize })
|
|
41
|
+
useService.mockReturnValue({ symbolRegistry: sr, eventBus: eb })
|
|
42
|
+
useMarkers.mockReturnValue({
|
|
43
|
+
markers: { items: markers, markerRefs },
|
|
44
|
+
markerRef: (id) => (el) => { if (el) markerRefs.set(id, el) }
|
|
45
|
+
})
|
|
46
|
+
return { eb, sr, result: render(<Markers />) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('Markers', () => {
|
|
50
|
+
it('renders nothing when mapStyle is not set', () => {
|
|
51
|
+
expect(setup({ mapStyle: null }).result.container.firstChild).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders nothing when there are no markers', () => {
|
|
55
|
+
expect(setup().result.container.querySelectorAll('svg')).toHaveLength(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('renders one svg per marker with correct id and classes', () => {
|
|
59
|
+
const { result } = setup({ markers: [makeMarker(), makeMarker({ id: 'b', symbol: undefined })] })
|
|
60
|
+
const [svg1, svg2] = result.container.querySelectorAll('svg')
|
|
61
|
+
expect(svg1.getAttribute('id')).toBe('test-app-marker-marker-1')
|
|
62
|
+
expect(svg1).toHaveClass('im-c-marker', 'im-c-marker--pin')
|
|
63
|
+
expect(svg2).toHaveClass('im-c-marker--pin')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it.each([
|
|
67
|
+
[true, 'block'],
|
|
68
|
+
[false, 'none']
|
|
69
|
+
])('display is %s when isVisible=%s', (isVisible, display) => {
|
|
70
|
+
const svg = setup({ markers: [makeMarker({ isVisible })] }).result.container.querySelector('svg')
|
|
71
|
+
expect(svg).toHaveStyle({ display })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('uses inline symbolSvgContent over the symbol registry', () => {
|
|
75
|
+
const sr = makeSymbolRegistry()
|
|
76
|
+
setup({ markers: [makeMarker({ symbolSvgContent: '<rect/>' })], symbolRegistry: sr })
|
|
77
|
+
expect(sr.get).not.toHaveBeenCalled()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('falls back to defaults.symbolSvgContent', () => {
|
|
81
|
+
const sr = makeSymbolRegistry({
|
|
82
|
+
getDefaults: jest.fn(() => ({ symbolSvgContent: '<default-svg/>', viewBox: '0 0 38 38', anchor: [0.5, 1] }))
|
|
83
|
+
})
|
|
84
|
+
setup({ markers: [makeMarker({ symbol: undefined })], symbolRegistry: sr })
|
|
85
|
+
expect(sr.get).not.toHaveBeenCalled()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('uses marker.viewBox when provided', () => {
|
|
89
|
+
const svg = setup({ markers: [makeMarker({ viewBox: '0 0 50 60' })] }).result.container.querySelector('svg')
|
|
90
|
+
expect(svg.getAttribute('viewBox')).toBe('0 0 50 60')
|
|
91
|
+
expect(svg.getAttribute('width')).toBe('50')
|
|
92
|
+
expect(svg.getAttribute('height')).toBe('60')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("falls back to '0 0 38 38' viewBox when none is provided", () => {
|
|
96
|
+
const sr = makeSymbolRegistry({
|
|
97
|
+
get: jest.fn(() => ({ svg: '<circle/>' })),
|
|
98
|
+
getDefaults: jest.fn(() => ({ symbol: 'pin' }))
|
|
99
|
+
})
|
|
100
|
+
expect(setup({ markers: [makeMarker()], symbolRegistry: sr }).result.container.querySelector('svg').getAttribute('viewBox')).toBe('0 0 38 38')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it.each([
|
|
104
|
+
['marker.anchor', makeMarker({ anchor: [0, 0] }), null, '0px', '0px'],
|
|
105
|
+
['symbolDef.anchor', makeMarker(), { get: jest.fn(() => ({ svg: '<circle/>', viewBox: '0 0 38 38', anchor: [0, 0.5] })), getDefaults: jest.fn(() => ({ symbol: 'pin', viewBox: '0 0 38 38' })) }, '0px', '-19px'],
|
|
106
|
+
['[0.5, 0.5] fallback', makeMarker(), { get: jest.fn(() => ({ svg: '<circle/>', viewBox: '0 0 38 38' })), getDefaults: jest.fn(() => ({ symbol: 'pin', viewBox: '0 0 38 38' })) }, '-19px', '-19px']
|
|
107
|
+
])('resolveAnchor uses %s', (_, marker, srOverrides, left, top) => {
|
|
108
|
+
const sr = srOverrides ? makeSymbolRegistry(srOverrides) : undefined
|
|
109
|
+
expect(setup({ markers: [marker], symbolRegistry: sr }).result.container.querySelector('svg')).toHaveStyle({ marginLeft: left, marginTop: top })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it.each([
|
|
113
|
+
['small', '38', '38'],
|
|
114
|
+
['medium', '57', '57'],
|
|
115
|
+
['large', '76', '76'],
|
|
116
|
+
['huge', '38', '38']
|
|
117
|
+
])('scales svg dimensions for mapSize=%s', (mapSize, width, height) => {
|
|
118
|
+
const svg = setup({ markers: [makeMarker()], mapSize }).result.container.querySelector('svg')
|
|
119
|
+
expect(svg.getAttribute('width')).toBe(width)
|
|
120
|
+
expect(svg.getAttribute('height')).toBe(height)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('scales anchor offsets for medium mapSize', () => {
|
|
124
|
+
expect(setup({ markers: [makeMarker()], mapSize: 'medium' }).result.container.querySelector('svg'))
|
|
125
|
+
.toHaveStyle({ marginLeft: '-28.5px', marginTop: '-57px' })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('adds selected class and calls resolveSelected when marker is selected', () => {
|
|
129
|
+
const { eb, sr, result } = setup({ markers: [makeMarker()] })
|
|
130
|
+
act(() => eb.emit('interact:selectionchange', { selectedMarkers: ['marker-1'] }))
|
|
131
|
+
expect(result.container.querySelector('svg')).toHaveClass('im-c-marker--selected')
|
|
132
|
+
expect(sr.resolveSelected).toHaveBeenCalled()
|
|
133
|
+
expect(sr.resolve).not.toHaveBeenCalledAfter?.(sr.resolveSelected)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('uses resolve (not resolveSelected) for unselected markers', () => {
|
|
137
|
+
const { sr } = setup({ markers: [makeMarker()] })
|
|
138
|
+
expect(sr.resolve).toHaveBeenCalled()
|
|
139
|
+
expect(sr.resolveSelected).not.toHaveBeenCalled()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it.each([
|
|
143
|
+
['explicit empty array', { selectedMarkers: [] }],
|
|
144
|
+
['missing selectedMarkers key', {}]
|
|
145
|
+
])('deselects when selectionchange has %s', (_, payload) => {
|
|
146
|
+
const { eb, result } = setup({ markers: [makeMarker()] })
|
|
147
|
+
act(() => eb.emit('interact:selectionchange', { selectedMarkers: ['marker-1'] }))
|
|
148
|
+
act(() => eb.emit('interact:selectionchange', payload))
|
|
149
|
+
expect(result.container.querySelector('svg')).not.toHaveClass('im-c-marker--selected')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('wires interact:active and interact:selectionchange on mount and removes them on unmount', () => {
|
|
153
|
+
const { eb, result } = setup()
|
|
154
|
+
expect(eb.on).toHaveBeenCalledWith('interact:active', expect.any(Function))
|
|
155
|
+
expect(eb.on).toHaveBeenCalledWith('interact:selectionchange', expect.any(Function))
|
|
156
|
+
result.unmount()
|
|
157
|
+
expect(eb.off).toHaveBeenCalledWith('interact:active', expect.any(Function))
|
|
158
|
+
expect(eb.off).toHaveBeenCalledWith('interact:selectionchange', expect.any(Function))
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('useMarkerCursor', () => {
|
|
162
|
+
let viewport
|
|
163
|
+
|
|
164
|
+
beforeEach(() => {
|
|
165
|
+
viewport = document.createElement('div')
|
|
166
|
+
viewport.className = 'im-c-viewport'
|
|
167
|
+
document.body.appendChild(viewport)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
if (viewport.parentNode) document.body.removeChild(viewport)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const activate = (eb) => act(() => eb.emit('interact:active', { active: true, interactionModes: ['selectMarker'] }))
|
|
175
|
+
const deactivate = (eb) => act(() => eb.emit('interact:active', { active: false, interactionModes: ['selectMarker'] }))
|
|
176
|
+
const fireMove = (clientX, clientY) => act(() => {
|
|
177
|
+
viewport.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX, clientY }))
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const setupCursor = (markerBounds) => {
|
|
181
|
+
const eb = makeEventBus()
|
|
182
|
+
const markerRefs = new Map()
|
|
183
|
+
if (markerBounds) markerRefs.set('marker-1', { getBoundingClientRect: () => markerBounds })
|
|
184
|
+
useConfig.mockReturnValue({ id: 'test-app' })
|
|
185
|
+
useMap.mockReturnValue({ mapStyle: 'outdoor', mapSize: 'small' })
|
|
186
|
+
useService.mockReturnValue({ symbolRegistry: makeSymbolRegistry(), eventBus: eb })
|
|
187
|
+
useMarkers.mockReturnValue({ markers: { items: [makeMarker()], markerRefs }, markerRef: () => () => {} })
|
|
188
|
+
render(<Markers />)
|
|
189
|
+
return eb
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
it('does not track mousemove when interact is not active', () => {
|
|
193
|
+
setupCursor({ left: 0, top: 0, right: 50, bottom: 50 })
|
|
194
|
+
fireMove(20, 20)
|
|
195
|
+
expect(viewport.style.cursor).toBe('')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('does not track mousemove when selectMarker is not in interactionModes', () => {
|
|
199
|
+
const eb = setupCursor({ left: 10, top: 10, right: 50, bottom: 50 })
|
|
200
|
+
act(() => eb.emit('interact:active', { active: true, interactionModes: ['selectFeature'] }))
|
|
201
|
+
fireMove(20, 20)
|
|
202
|
+
expect(viewport.style.cursor).toBe('')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('does not track mousemove when interactionModes is absent from payload', () => {
|
|
206
|
+
const eb = setupCursor({ left: 10, top: 10, right: 50, bottom: 50 })
|
|
207
|
+
act(() => eb.emit('interact:active', { active: true }))
|
|
208
|
+
fireMove(20, 20)
|
|
209
|
+
expect(viewport.style.cursor).toBe('')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('does not track mousemove when viewport element is absent', () => {
|
|
213
|
+
document.body.removeChild(viewport)
|
|
214
|
+
const eb = setupCursor({ left: 0, top: 0, right: 50, bottom: 50 })
|
|
215
|
+
activate(eb)
|
|
216
|
+
expect(viewport.style.cursor).toBe('')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('sets cursor to pointer when mousemove lands inside a marker', () => {
|
|
220
|
+
const eb = setupCursor({ left: 10, top: 10, right: 50, bottom: 50 })
|
|
221
|
+
activate(eb)
|
|
222
|
+
fireMove(20, 20)
|
|
223
|
+
expect(viewport.style.cursor).toBe('pointer')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it.each([
|
|
227
|
+
['outside all markers', 100, 100],
|
|
228
|
+
['marker has no ref element', 20, 20]
|
|
229
|
+
])('cursor stays empty when %s', (label, x, y) => {
|
|
230
|
+
const bounds = label.includes('no ref') ? null : { left: 10, top: 10, right: 50, bottom: 50 }
|
|
231
|
+
const eb = setupCursor(bounds)
|
|
232
|
+
activate(eb)
|
|
233
|
+
fireMove(x, y)
|
|
234
|
+
expect(viewport.style.cursor).toBe('')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('clears cursor and stops tracking when interact becomes inactive', () => {
|
|
238
|
+
const eb = setupCursor({ left: 10, top: 10, right: 50, bottom: 50 })
|
|
239
|
+
activate(eb)
|
|
240
|
+
fireMove(20, 20)
|
|
241
|
+
expect(viewport.style.cursor).toBe('pointer')
|
|
242
|
+
deactivate(eb)
|
|
243
|
+
expect(viewport.style.cursor).toBe('')
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
})
|