@defra/interactive-map 0.0.17-alpha → 0.0.19-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/assets/css/docusaurus.css +58 -34
- 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/panel-definition.md +16 -0
- package/docs/api/symbol-config.md +160 -0
- package/docs/api/symbol-registry.md +115 -0
- package/docs/api.md +50 -23
- package/docs/assets/basic-map.jpg +0 -0
- package/docs/assets/button-first.jpg +0 -0
- package/docs/assets/maker-panel.jpg +0 -0
- package/docs/examples/add-marker-with-panel.mdx +59 -0
- package/docs/examples/basic-map.mdx +24 -0
- package/docs/examples/button-map.mdx +24 -0
- package/docs/examples/index.mdx +49 -0
- package/docs/index.mdx +1 -1
- package/docs/plugins/datasets.md +105 -9
- package/docs/plugins/interact.md +100 -44
- package/docs/plugins/search.md +15 -3
- package/docs/plugins.md +1 -1
- package/docusaurus.config.cjs +9 -1
- 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 +3 -0
- 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/beta/scale-bar/dist/css/index.css +1 -1
- package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
- 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 +19 -8
- package/plugins/interact/src/InteractInit.test.js +26 -6
- package/plugins/interact/src/api/clear.js +1 -1
- package/plugins/interact/src/api/enable.test.js +7 -7
- package/plugins/interact/src/api/selectMarker.js +14 -0
- package/plugins/interact/src/api/selectMarker.test.js +25 -0
- package/plugins/interact/src/api/unselectMarker.js +14 -0
- package/plugins/interact/src/api/unselectMarker.test.js +14 -0
- package/plugins/interact/src/defaults.js +4 -6
- package/plugins/interact/src/events.js +27 -36
- package/plugins/interact/src/events.test.js +119 -90
- 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/manifest.js +10 -2
- package/plugins/interact/src/reducer.js +59 -5
- package/plugins/interact/src/reducer.test.js +100 -12
- 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/interact/src/utils/interactionModes.js +12 -0
- 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/components/Panel/Panel.jsx +6 -6
- package/src/App/components/Panel/Panel.test.jsx +37 -0
- package/src/App/components/Viewport/Viewport.jsx +5 -15
- package/src/App/components/Viewport/Viewport.module.scss +2 -0
- package/src/App/components/Viewport/Viewport.test.jsx +16 -33
- package/src/App/hooks/useInterfaceAPI.js +7 -7
- package/src/App/hooks/useInterfaceAPI.test.js +162 -0
- package/src/App/hooks/useLayoutMeasurements.js +64 -72
- package/src/App/hooks/useMarkersAPI.js +2 -5
- package/src/App/hooks/useMarkersAPI.test.js +4 -4
- package/src/App/layout/Layout.jsx +3 -3
- package/src/App/layout/Layout.test.jsx +4 -2
- package/src/App/layout/layout.module.scss +1 -8
- package/src/App/renderer/HtmlElementHost.jsx +10 -5
- package/src/App/renderer/mapPanels.js +2 -1
- package/src/App/store/ServiceProvider.jsx +7 -5
- package/src/App/store/appActionsMap.js +4 -4
- package/src/App/store/appActionsMap.test.js +10 -0
- 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/InteractiveMap/InteractiveMap.js +59 -11
- package/src/InteractiveMap/InteractiveMap.test.js +126 -4
- package/src/InteractiveMap/domStateManager.js +18 -6
- package/src/InteractiveMap/domStateManager.test.js +21 -0
- package/src/InteractiveMap/historyManager.js +28 -16
- package/src/InteractiveMap/historyManager.test.js +17 -0
- package/src/config/appConfig.js +2 -7
- package/src/config/appConfig.test.js +4 -15
- package/src/config/defaults.js +2 -3
- package/src/config/events.js +20 -21
- 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/closeApp.js +1 -10
- package/src/services/closeApp.test.js +3 -43
- 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 +99 -12
- package/src/utils/mapStateSync.js +48 -10
- package/src/utils/mapStateSync.test.js +29 -9
- 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/docs/examples.mdx +0 -70
- package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +0 -48
- package/plugins/beta/datasets/src/styles/patterns.js +0 -157
|
@@ -1,133 +1,190 @@
|
|
|
1
1
|
import { updateHighlightedFeatures } from './highlightFeatures.js'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
function lngLatBounds () {
|
|
4
|
+
this.coords = []
|
|
5
|
+
this.extend = (c) => this.coords.push(c)
|
|
6
|
+
this.getWest = () => Math.min(...this.coords.map(c => c[0]))
|
|
7
|
+
this.getSouth = () => Math.min(...this.coords.map(c => c[1]))
|
|
8
|
+
this.getEast = () => Math.max(...this.coords.map(c => c[0]))
|
|
9
|
+
this.getNorth = () => Math.max(...this.coords.map(c => c[1]))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const EMPTY_FILTER = ['==', 'id', '']
|
|
13
|
+
const STALE_SYMBOL_LAYER = 'highlight-stale-symbol'
|
|
14
|
+
|
|
15
|
+
const makeMap = (overrides = {}) => ({
|
|
16
|
+
_highlightedSources: new Set(),
|
|
17
|
+
getLayer: jest.fn(),
|
|
18
|
+
addLayer: jest.fn(),
|
|
19
|
+
moveLayer: jest.fn(),
|
|
20
|
+
setFilter: jest.fn(),
|
|
21
|
+
setPaintProperty: jest.fn(),
|
|
22
|
+
setLayoutProperty: jest.fn(),
|
|
23
|
+
getLayoutProperty: jest.fn(),
|
|
24
|
+
queryRenderedFeatures: jest.fn().mockReturnValue([]),
|
|
25
|
+
...overrides
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('Highlighting Utils — fill and line', () => {
|
|
4
29
|
let map
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
30
|
+
|
|
31
|
+
const ALL_BRANCHES_FEATURES = [
|
|
32
|
+
{ featureId: 1, layerId: 'l1', geometry: { type: 'Polygon' } },
|
|
33
|
+
{ featureId: 2, layerId: 'l1', geometry: { type: 'MultiPolygon' } },
|
|
34
|
+
{ featureId: 3, layerId: 'invalid' },
|
|
35
|
+
{ featureId: 4, layerId: 'l2', idProperty: 'customId', geometry: { type: 'Point' } }
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const ALL_BRANCHES_STYLES = { l1: { stroke: 'red', fill: 'blue' }, l2: { stroke: 'green' } }
|
|
13
39
|
|
|
14
40
|
beforeEach(() => {
|
|
15
|
-
map = {
|
|
16
|
-
_highlightedSources: new Set(['stale']),
|
|
17
|
-
getLayer: jest.fn(),
|
|
18
|
-
addLayer: jest.fn(),
|
|
19
|
-
moveLayer: jest.fn(),
|
|
20
|
-
setFilter: jest.fn(),
|
|
21
|
-
setPaintProperty: jest.fn(),
|
|
22
|
-
queryRenderedFeatures: jest.fn()
|
|
23
|
-
}
|
|
41
|
+
map = makeMap({ _highlightedSources: new Set(['stale']) })
|
|
24
42
|
})
|
|
25
43
|
|
|
26
44
|
test('All branches', () => {
|
|
27
|
-
// Coverage for Line 93: Null map check
|
|
28
45
|
expect(updateHighlightedFeatures({ map: null })).toBeNull()
|
|
29
46
|
|
|
30
|
-
map.getLayer.mockImplementation((id) => {
|
|
31
|
-
if (id.includes('stale')) return
|
|
32
|
-
if (id === 'l1') return { source: 's1', type: 'fill' }
|
|
33
|
-
if (id === 'l2') return { source: 's2', type: 'line' }
|
|
34
|
-
if (id === 'highlight-s2-fill') return
|
|
35
|
-
return null
|
|
47
|
+
map.getLayer.mockImplementation((id) => { // NOSONAR
|
|
48
|
+
if (id.includes('stale')) { return {} }
|
|
49
|
+
if (id === 'l1') { return { source: 's1', type: 'fill' } }
|
|
50
|
+
if (id === 'l2') { return { source: 's2', type: 'line' } }
|
|
51
|
+
if (id === 'highlight-s2-fill') { return {} }
|
|
52
|
+
return null
|
|
36
53
|
})
|
|
37
54
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
{ featureId: 1, layerId: 'l1', geometry: { type: 'Polygon' } },
|
|
41
|
-
{ featureId: 2, layerId: 'l1', geometry: { type: 'MultiPolygon' } },
|
|
42
|
-
// Coverage for Line 13: Invalid layer
|
|
43
|
-
{ featureId: 3, layerId: 'invalid' },
|
|
44
|
-
// Coverage for Line 116: idProperty exists
|
|
45
|
-
{ featureId: 4, layerId: 'l2', idProperty: 'customId', geometry: { type: 'Point' } }
|
|
46
|
-
]
|
|
47
|
-
|
|
48
|
-
const stylesMap = {
|
|
49
|
-
l1: { stroke: 'red', fill: 'blue' },
|
|
50
|
-
l2: { stroke: 'green' }
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Coverage for Lines 78-80: Recursive coordinate handling (numbers vs arrays)
|
|
55
|
+
const coordMax = 10
|
|
56
|
+
const coordMid = 5
|
|
54
57
|
map.queryRenderedFeatures.mockReturnValue([
|
|
55
|
-
{ id: 1, geometry: { coordinates: [
|
|
56
|
-
{ id: 2, properties: { customId: 4 }, geometry: { coordinates: [[0, 0], [
|
|
58
|
+
{ id: 1, geometry: { coordinates: [coordMax, coordMax] } },
|
|
59
|
+
{ id: 2, properties: { customId: 4 }, geometry: { coordinates: [[0, 0], [coordMid, coordMid]] } }
|
|
57
60
|
])
|
|
58
61
|
|
|
59
|
-
const bounds = updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, stylesMap })
|
|
60
|
-
|
|
61
|
-
// Line 13 verify: map.getLayer returned null and function returned early
|
|
62
|
-
// Line 49-50 verify: Stale sources filtered out
|
|
63
|
-
expect(map.setFilter).toHaveBeenCalledWith('highlight-stale-fill', ['==', 'id', ''])
|
|
62
|
+
const bounds = updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: ALL_BRANCHES_FEATURES, stylesMap: ALL_BRANCHES_STYLES })
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
expect(map.setFilter).toHaveBeenCalledWith(
|
|
67
|
-
|
|
68
|
-
// Line 116 verify: Using ['get', idProperty]
|
|
64
|
+
expect(map.setFilter).toHaveBeenCalledWith('highlight-stale-fill', EMPTY_FILTER)
|
|
65
|
+
expect(map.setFilter).toHaveBeenCalledWith(STALE_SYMBOL_LAYER, EMPTY_FILTER)
|
|
66
|
+
expect(map.setFilter).toHaveBeenCalledWith('highlight-s2-fill', EMPTY_FILTER)
|
|
69
67
|
expect(map.setFilter).toHaveBeenCalledWith('highlight-s2-line', expect.arrayContaining([['get', 'customId']]))
|
|
70
|
-
|
|
71
|
-
// Line 80-82 verify: Recursive LngLatBounds logic
|
|
72
68
|
expect(bounds).toEqual([0, 0, 10, 10])
|
|
73
69
|
})
|
|
74
70
|
|
|
75
|
-
test('
|
|
76
|
-
|
|
77
|
-
map.
|
|
78
|
-
|
|
79
|
-
map.queryRenderedFeatures.mockReturnValue([])
|
|
80
|
-
updateHighlightedFeatures({
|
|
81
|
-
LngLatBounds,
|
|
82
|
-
map,
|
|
83
|
-
selectedFeatures: [{ featureId: 1, layerId: 'l1' }],
|
|
84
|
-
stylesMap: { l1: { stroke: 'red' } }
|
|
85
|
-
})
|
|
71
|
+
test('null _highlightedSources falls back to empty set; line geom skips absent fill layer', () => {
|
|
72
|
+
map._highlightedSources = null
|
|
73
|
+
map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null) // NOSONAR
|
|
74
|
+
updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: [{ featureId: 1, layerId: 'l1' }], stylesMap: { l1: { stroke: 'red' } } })
|
|
86
75
|
expect(map.setFilter).not.toHaveBeenCalledWith('highlight-s1-fill', expect.anything())
|
|
87
76
|
})
|
|
88
77
|
|
|
89
78
|
test('persistent source skips cleanup; missing stale layers skip setFilter', () => {
|
|
90
|
-
// line 37 false: src IS in currentSources; line 41 false: getLayer returns null for stale layers
|
|
91
79
|
map._highlightedSources = new Set(['stale', 's1'])
|
|
92
|
-
map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null)
|
|
93
|
-
map
|
|
94
|
-
updateHighlightedFeatures({
|
|
95
|
-
LngLatBounds,
|
|
96
|
-
map,
|
|
97
|
-
selectedFeatures: [{ featureId: 1, layerId: 'l1' }],
|
|
98
|
-
stylesMap: { l1: { stroke: 'red' } }
|
|
99
|
-
})
|
|
80
|
+
map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null) // NOSONAR
|
|
81
|
+
updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: [{ featureId: 1, layerId: 'l1' }], stylesMap: { l1: { stroke: 'red' } } })
|
|
100
82
|
expect(map.setFilter).not.toHaveBeenCalledWith(expect.stringContaining('stale'), expect.anything())
|
|
101
83
|
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('Highlighting Utils — layer management', () => {
|
|
87
|
+
let map
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
map = makeMap({ _highlightedSources: new Set(['stale']) })
|
|
91
|
+
})
|
|
102
92
|
|
|
103
93
|
test('reuses existing highlight layer; new layer spreads sourceLayer', () => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (id === '
|
|
108
|
-
if (id === 'l2') return { source: 's2', type: 'line', sourceLayer: 'tiles' }
|
|
109
|
-
if (id === 'highlight-s1-line') return true
|
|
94
|
+
map.getLayer.mockImplementation(id => { // NOSONAR
|
|
95
|
+
if (id === 'l1') { return { source: 's1', type: 'line' } }
|
|
96
|
+
if (id === 'l2') { return { source: 's2', type: 'line', sourceLayer: 'tiles' } }
|
|
97
|
+
if (id === 'highlight-s1-line') { return {} }
|
|
110
98
|
return null
|
|
111
99
|
})
|
|
112
|
-
map
|
|
113
|
-
updateHighlightedFeatures({
|
|
114
|
-
LngLatBounds,
|
|
115
|
-
map,
|
|
116
|
-
selectedFeatures: [
|
|
117
|
-
{ featureId: 1, layerId: 'l1' },
|
|
118
|
-
{ featureId: 2, layerId: 'l2' }
|
|
119
|
-
],
|
|
120
|
-
stylesMap: { l1: { stroke: 'blue' }, l2: { stroke: 'green' } }
|
|
121
|
-
})
|
|
100
|
+
updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: [{ featureId: 1, layerId: 'l1' }, { featureId: 2, layerId: 'l2' }], stylesMap: { l1: { stroke: 'blue' }, l2: { stroke: 'green' } } })
|
|
122
101
|
expect(map.addLayer).toHaveBeenCalledTimes(1)
|
|
123
102
|
expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ 'source-layer': 'tiles' }))
|
|
124
103
|
})
|
|
125
104
|
|
|
126
|
-
test('
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
105
|
+
test('returns null when no rendered features match', () => {
|
|
106
|
+
expect(updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: [], stylesMap: {} })).toBeNull()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('Highlighting Utils — symbol layers', () => {
|
|
111
|
+
const SYMBOL_IMAGE = 'symbol-abc123'
|
|
112
|
+
const SELECTED_IMAGE = 'symbol-sel-abc123'
|
|
113
|
+
const HIGHLIGHT_LAYER = 'highlight-s1-symbol'
|
|
114
|
+
const ICON_IMAGE = 'icon-image'
|
|
115
|
+
const ICON_ANCHOR = 'icon-anchor'
|
|
116
|
+
const POINT_FEATURE = { featureId: 1, layerId: 'l1', geometry: { type: 'Point' } }
|
|
117
|
+
|
|
118
|
+
let map
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
map = makeMap()
|
|
122
|
+
map._symbolImageMap = { [SYMBOL_IMAGE]: SELECTED_IMAGE }
|
|
123
|
+
map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'symbol' } : null) // NOSONAR
|
|
124
|
+
map.getLayoutProperty.mockReturnValue(SYMBOL_IMAGE)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const run = (selectedFeatures = [POINT_FEATURE]) =>
|
|
128
|
+
updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures, stylesMap: { l1: {} } })
|
|
129
|
+
|
|
130
|
+
test('creates symbol highlight layer with selected image variant', () => {
|
|
131
|
+
run()
|
|
132
|
+
expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
|
|
133
|
+
id: HIGHLIGHT_LAYER,
|
|
134
|
+
type: 'symbol',
|
|
135
|
+
layout: expect.objectContaining({ [ICON_IMAGE]: SELECTED_IMAGE })
|
|
136
|
+
}))
|
|
137
|
+
expect(map.setLayoutProperty).toHaveBeenCalledWith(HIGHLIGHT_LAYER, ICON_IMAGE, SELECTED_IMAGE)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('reads icon-anchor from original layer', () => {
|
|
141
|
+
map.getLayoutProperty.mockImplementation((_id, prop) => { // NOSONAR
|
|
142
|
+
if (prop === ICON_IMAGE) { return SYMBOL_IMAGE }
|
|
143
|
+
if (prop === ICON_ANCHOR) { return 'bottom' }
|
|
144
|
+
return null
|
|
145
|
+
})
|
|
146
|
+
run()
|
|
147
|
+
expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
|
|
148
|
+
layout: expect.objectContaining({ [ICON_ANCHOR]: 'bottom' })
|
|
149
|
+
}))
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('falls back to center anchor when icon-anchor is not set on original layer', () => {
|
|
153
|
+
map.getLayoutProperty.mockImplementation((_id, prop) => prop === ICON_IMAGE ? SYMBOL_IMAGE : null) // NOSONAR
|
|
154
|
+
run()
|
|
155
|
+
expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
|
|
156
|
+
layout: expect.objectContaining({ [ICON_ANCHOR]: 'center' })
|
|
157
|
+
}))
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('spreads source-layer into symbol highlight layer for vector tile source', () => {
|
|
161
|
+
map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'symbol', sourceLayer: 'points' } : null) // NOSONAR
|
|
162
|
+
run()
|
|
163
|
+
expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ 'source-layer': 'points' }))
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('reuses existing symbol highlight layer without re-adding', () => {
|
|
167
|
+
map.getLayer.mockImplementation(id => { // NOSONAR
|
|
168
|
+
if (id === 'l1') { return { source: 's1', type: 'symbol' } }
|
|
169
|
+
if (id === HIGHLIGHT_LAYER) { return { source: 's1', type: 'symbol' } }
|
|
170
|
+
return null
|
|
171
|
+
})
|
|
172
|
+
run()
|
|
173
|
+
expect(map.addLayer).not.toHaveBeenCalled()
|
|
174
|
+
expect(map.setLayoutProperty).toHaveBeenCalledWith(HIGHLIGHT_LAYER, ICON_IMAGE, SELECTED_IMAGE)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('skips highlight when icon-image has no entry in _symbolImageMap', () => {
|
|
178
|
+
map.getLayoutProperty.mockReturnValue('symbol-abc123')
|
|
179
|
+
map._symbolImageMap = {} // no mapping registered
|
|
180
|
+
run()
|
|
181
|
+
expect(map.addLayer).not.toHaveBeenCalled()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('cleans up stale symbol highlight layer', () => {
|
|
185
|
+
map._highlightedSources = new Set(['stale'])
|
|
186
|
+
map.getLayer.mockImplementation(id => id === STALE_SYMBOL_LAYER ? { type: 'symbol' } : null) // NOSONAR
|
|
187
|
+
run([])
|
|
188
|
+
expect(map.setFilter).toHaveBeenCalledWith(STALE_SYMBOL_LAYER, EMPTY_FILTER)
|
|
132
189
|
})
|
|
133
190
|
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attaches a mousemove listener that changes the map cursor to a pointer when
|
|
3
|
+
* hovering over any of the specified layers.
|
|
4
|
+
*
|
|
5
|
+
* Line layers use a 10px tolerance bbox. Stroke layers that have a companion
|
|
6
|
+
* fill layer are skipped — the fill handles hover. Fill and symbol layers use
|
|
7
|
+
* exact point hit-testing.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} map - MapLibre map instance
|
|
10
|
+
* @param {string[]} layerIds - Layer IDs to watch
|
|
11
|
+
* @param {Function|null} prevHandler - Previous mousemove handler to remove
|
|
12
|
+
* @returns {Function|null} The new handler, or null if layerIds is empty
|
|
13
|
+
*/
|
|
14
|
+
const splitLayers = (map, layerIds) => {
|
|
15
|
+
const lineLayers = []
|
|
16
|
+
const otherLayers = []
|
|
17
|
+
for (const id of layerIds) {
|
|
18
|
+
const type = map.getLayer(id).type
|
|
19
|
+
if (type === 'line') {
|
|
20
|
+
const fillId = id.endsWith('-stroke') ? id.slice(0, -7) : null // NOSONAR
|
|
21
|
+
const hasFillCompanion = fillId !== null && layerIds.includes(fillId)
|
|
22
|
+
if (!hasFillCompanion) {
|
|
23
|
+
lineLayers.push(id)
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
otherLayers.push(id)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { lineLayers, otherLayers }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const setupHoverCursor = (map, layerIds, prevHandler) => {
|
|
33
|
+
const canvas = map.getCanvas()
|
|
34
|
+
|
|
35
|
+
if (prevHandler) {
|
|
36
|
+
map.off('mousemove', prevHandler)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!layerIds?.length) {
|
|
40
|
+
canvas.style.cursor = ''
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const handler = (e) => {
|
|
45
|
+
const existingLayers = layerIds.filter(id => map.getLayer(id))
|
|
46
|
+
if (existingLayers.length === 0) {
|
|
47
|
+
canvas.style.cursor = ''
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { lineLayers, otherLayers } = splitLayers(map, existingLayers)
|
|
52
|
+
const { x, y } = e.point
|
|
53
|
+
const bbox = [[x - 10, y - 10], [x + 10, y + 10]]
|
|
54
|
+
const lineHit = lineLayers.length > 0 && map.queryRenderedFeatures(bbox, { layers: lineLayers }).length > 0
|
|
55
|
+
const otherHit = otherLayers.length > 0 && map.queryRenderedFeatures(e.point, { layers: otherLayers }).length > 0
|
|
56
|
+
canvas.style.cursor = (lineHit || otherHit) ? 'pointer' : ''
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
map.on('mousemove', handler)
|
|
60
|
+
return handler
|
|
61
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { setupHoverCursor } from './hoverCursor.js'
|
|
2
|
+
|
|
3
|
+
const makeMap = (layerTypes = {}, queryResults = []) => ({
|
|
4
|
+
getCanvas: () => ({ style: { cursor: '' } }),
|
|
5
|
+
getLayer: (id) => layerTypes[id] ? { type: layerTypes[id] } : undefined,
|
|
6
|
+
queryRenderedFeatures: jest.fn(() => queryResults),
|
|
7
|
+
on: jest.fn(),
|
|
8
|
+
off: jest.fn()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const move = (handler, x = 10, y = 10) =>
|
|
12
|
+
handler({ point: { x, y } })
|
|
13
|
+
|
|
14
|
+
describe('setupHoverCursor', () => {
|
|
15
|
+
/* ------------------------------------------------------------------ */
|
|
16
|
+
/* Setup / teardown */
|
|
17
|
+
/* ------------------------------------------------------------------ */
|
|
18
|
+
|
|
19
|
+
it('returns null and clears cursor when layerIds is empty', () => {
|
|
20
|
+
const map = makeMap()
|
|
21
|
+
const result = setupHoverCursor(map, [], null)
|
|
22
|
+
expect(result).toBeNull()
|
|
23
|
+
expect(map.getCanvas().style.cursor).toBe('')
|
|
24
|
+
expect(map.on).not.toHaveBeenCalled()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns null and clears cursor when layerIds is null', () => {
|
|
28
|
+
const map = makeMap()
|
|
29
|
+
const result = setupHoverCursor(map, null, null)
|
|
30
|
+
expect(result).toBeNull()
|
|
31
|
+
expect(map.getCanvas().style.cursor).toBe('')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('removes previous handler before attaching a new one', () => {
|
|
35
|
+
const map = makeMap({ 'layer-a': 'fill' })
|
|
36
|
+
const prev = jest.fn()
|
|
37
|
+
setupHoverCursor(map, ['layer-a'], prev)
|
|
38
|
+
expect(map.off).toHaveBeenCalledWith('mousemove', prev)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('removes previous handler when clearing layers', () => {
|
|
42
|
+
const map = makeMap()
|
|
43
|
+
const prev = jest.fn()
|
|
44
|
+
setupHoverCursor(map, [], prev)
|
|
45
|
+
expect(map.off).toHaveBeenCalledWith('mousemove', prev)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('attaches a mousemove listener and returns the handler', () => {
|
|
49
|
+
const map = makeMap({ 'layer-a': 'fill' })
|
|
50
|
+
const handler = setupHoverCursor(map, ['layer-a'], null)
|
|
51
|
+
expect(typeof handler).toBe('function')
|
|
52
|
+
expect(map.on).toHaveBeenCalledWith('mousemove', handler)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
/* Mousemove — cursor state */
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
|
|
59
|
+
it('sets pointer cursor when a fill layer is hit', () => {
|
|
60
|
+
const canvas = { style: { cursor: '' } }
|
|
61
|
+
const map = { ...makeMap({ 'fill-layer': 'fill' }, [{ id: 'f1' }]), getCanvas: () => canvas }
|
|
62
|
+
const handler = setupHoverCursor(map, ['fill-layer'], null)
|
|
63
|
+
move(handler)
|
|
64
|
+
expect(canvas.style.cursor).toBe('pointer')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('clears cursor when no layers are hit', () => {
|
|
68
|
+
const canvas = { style: { cursor: 'pointer' } }
|
|
69
|
+
const map = { ...makeMap({ 'fill-layer': 'fill' }, []), getCanvas: () => canvas }
|
|
70
|
+
const handler = setupHoverCursor(map, ['fill-layer'], null)
|
|
71
|
+
move(handler)
|
|
72
|
+
expect(canvas.style.cursor).toBe('')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('clears cursor when no registered layers exist on the map', () => {
|
|
76
|
+
const canvas = { style: { cursor: 'pointer' } }
|
|
77
|
+
const map = { ...makeMap({}, []), getCanvas: () => canvas }
|
|
78
|
+
const handler = setupHoverCursor(map, ['missing-layer'], null)
|
|
79
|
+
move(handler)
|
|
80
|
+
expect(canvas.style.cursor).toBe('')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/* ------------------------------------------------------------------ */
|
|
84
|
+
/* Line layer tolerance */
|
|
85
|
+
/* ------------------------------------------------------------------ */
|
|
86
|
+
|
|
87
|
+
it('uses bbox query for a pure line layer', () => {
|
|
88
|
+
const map = makeMap({ hedge: 'line' }, [{ id: 'f1' }])
|
|
89
|
+
const handler = setupHoverCursor(map, ['hedge'], null)
|
|
90
|
+
move(handler, 50, 50)
|
|
91
|
+
expect(map.queryRenderedFeatures).toHaveBeenCalledWith(
|
|
92
|
+
[[40, 40], [60, 60]],
|
|
93
|
+
{ layers: ['hedge'] }
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('sets pointer when line layer is hit via bbox', () => {
|
|
98
|
+
const canvas = { style: { cursor: '' } }
|
|
99
|
+
const map = { ...makeMap({ hedge: 'line' }, [{ id: 'f1' }]), getCanvas: () => canvas }
|
|
100
|
+
const handler = setupHoverCursor(map, ['hedge'], null)
|
|
101
|
+
move(handler)
|
|
102
|
+
expect(canvas.style.cursor).toBe('pointer')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
/* ------------------------------------------------------------------ */
|
|
106
|
+
/* Stroke + fill companion */
|
|
107
|
+
/* ------------------------------------------------------------------ */
|
|
108
|
+
|
|
109
|
+
it('skips stroke layer when a companion fill layer exists', () => {
|
|
110
|
+
const map = makeMap({ poly: 'fill', 'poly-stroke': 'line' }, [])
|
|
111
|
+
const handler = setupHoverCursor(map, ['poly', 'poly-stroke'], null)
|
|
112
|
+
move(handler)
|
|
113
|
+
// Only the fill layer should be queried (exact point), not the stroke
|
|
114
|
+
expect(map.queryRenderedFeatures).toHaveBeenCalledTimes(1)
|
|
115
|
+
expect(map.queryRenderedFeatures).toHaveBeenCalledWith(
|
|
116
|
+
expect.objectContaining({ x: 10, y: 10 }),
|
|
117
|
+
{ layers: ['poly'] }
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('does not skip a stroke layer that has no companion fill', () => {
|
|
122
|
+
const map = makeMap({ 'hedge-stroke': 'line' }, [])
|
|
123
|
+
const handler = setupHoverCursor(map, ['hedge-stroke'], null)
|
|
124
|
+
move(handler)
|
|
125
|
+
expect(map.queryRenderedFeatures).toHaveBeenCalledWith(
|
|
126
|
+
expect.any(Array),
|
|
127
|
+
{ layers: ['hedge-stroke'] }
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getPatternInnerContent, getPatternImageId, injectColors } from '../../../../src/utils/patternUtils.js'
|
|
2
|
+
import { getValueForStyle } from '../../../../src/utils/getValueForStyle.js'
|
|
3
|
+
import { rasteriseToImageData } from './rasteriseToImageData.js'
|
|
4
|
+
|
|
5
|
+
// Module-level cache: imageId → ImageData. Avoids re-rasterising identical patterns.
|
|
6
|
+
const imageDataCache = new Map()
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rasterises a dataset's pattern SVG to ImageData, using an in-memory cache
|
|
10
|
+
* to avoid re-rasterising identical patterns.
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} dataset
|
|
13
|
+
* @param {string} mapStyleId
|
|
14
|
+
* @param {Object} patternRegistry
|
|
15
|
+
* @returns {Promise<{ imageId: string, imageData: ImageData }|null>}
|
|
16
|
+
*/
|
|
17
|
+
const rasterisePattern = async (dataset, mapStyleId, patternRegistry) => {
|
|
18
|
+
const innerContent = getPatternInnerContent(dataset, patternRegistry)
|
|
19
|
+
if (!innerContent) {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const imageId = getPatternImageId(dataset, mapStyleId, patternRegistry)
|
|
24
|
+
if (!imageId) {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let imageData = imageDataCache.get(imageId)
|
|
29
|
+
if (!imageData) {
|
|
30
|
+
const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black'
|
|
31
|
+
const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
|
|
32
|
+
const colored = injectColors(innerContent, fg, bg)
|
|
33
|
+
const bgRect = `<rect width="16" height="16" fill="${bg}"/>`
|
|
34
|
+
// pixelRatio: 2 means the map treats this as an 8×8 logical tile — crisp on retina screens.
|
|
35
|
+
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">${bgRect}${colored}</svg>`
|
|
36
|
+
imageData = await rasteriseToImageData(svgString, 16, 16)
|
|
37
|
+
imageDataCache.set(imageId, imageData)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { imageId, imageData }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register pattern images for the given pre-resolved pattern configs.
|
|
45
|
+
* Skips images that are already registered (safe to call on style change).
|
|
46
|
+
* Callers are responsible for sublayer merging before passing configs here
|
|
47
|
+
* (see `getPatternConfigs` in the datasets plugin adapter).
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} map - MapLibre map instance
|
|
50
|
+
* @param {Object[]} patternConfigs - Flat list of datasets/merged-sublayers with a pattern config
|
|
51
|
+
* @param {string} mapStyleId
|
|
52
|
+
* @param {Object} patternRegistry
|
|
53
|
+
* @returns {Promise<void>}
|
|
54
|
+
*/
|
|
55
|
+
export const registerPatterns = async (map, patternConfigs, mapStyleId, patternRegistry) => {
|
|
56
|
+
if (!patternConfigs.length) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await Promise.all(patternConfigs.map(async (config) => {
|
|
61
|
+
const imageId = getPatternImageId(config, mapStyleId, patternRegistry)
|
|
62
|
+
if (!imageId || map.hasImage(imageId)) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const result = await rasterisePattern(config, mapStyleId, patternRegistry)
|
|
66
|
+
if (result) {
|
|
67
|
+
map.addImage(result.imageId, result.imageData, { pixelRatio: 2 })
|
|
68
|
+
}
|
|
69
|
+
}))
|
|
70
|
+
}
|