@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,180 @@
|
|
|
1
|
+
import { registerPatterns } from './patternImages.js'
|
|
2
|
+
|
|
3
|
+
const OUTDOOR = 'outdoor'
|
|
4
|
+
|
|
5
|
+
const SVG_CONTENT = '<path d="M0 0 L8 8"/>'
|
|
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
|
+
const makeMap = (existingIds = []) => ({
|
|
29
|
+
hasImage: jest.fn((id) => existingIds.includes(id)),
|
|
30
|
+
addImage: jest.fn()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const makePatternRegistry = (id = 'stripes', content = SVG_CONTENT) => ({
|
|
34
|
+
get: jest.fn((name) => name === id ? { svgContent: content } : undefined)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// ─── registerPatterns ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe('registerPatterns — registration', () => {
|
|
40
|
+
it('returns early and does not touch map for empty configs', async () => {
|
|
41
|
+
const map = makeMap()
|
|
42
|
+
const registry = makePatternRegistry()
|
|
43
|
+
await registerPatterns(map, [], OUTDOOR, registry)
|
|
44
|
+
expect(map.hasImage).not.toHaveBeenCalled()
|
|
45
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('calls addImage with pixelRatio 2 for a named pattern', async () => {
|
|
49
|
+
const map = makeMap()
|
|
50
|
+
const registry = makePatternRegistry()
|
|
51
|
+
const config = { fillPattern: 'stripes' }
|
|
52
|
+
await registerPatterns(map, [config], OUTDOOR, registry)
|
|
53
|
+
expect(map.addImage).toHaveBeenCalledTimes(1)
|
|
54
|
+
expect(map.addImage).toHaveBeenCalledWith(
|
|
55
|
+
expect.stringMatching(/^pattern-[a-z0-9]+$/),
|
|
56
|
+
expect.any(Object),
|
|
57
|
+
{ pixelRatio: 2 }
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('calls addImage for an inline fillPatternSvgContent config', async () => {
|
|
62
|
+
const map = makeMap()
|
|
63
|
+
const registry = makePatternRegistry()
|
|
64
|
+
const config = { fillPatternSvgContent: SVG_CONTENT }
|
|
65
|
+
await registerPatterns(map, [config], OUTDOOR, registry)
|
|
66
|
+
expect(map.addImage).toHaveBeenCalledTimes(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('skips addImage when image is already registered', async () => {
|
|
70
|
+
const registry = makePatternRegistry()
|
|
71
|
+
const config = { fillPattern: 'stripes' }
|
|
72
|
+
const { getPatternImageId } = await import('../../../../src/utils/patternUtils.js')
|
|
73
|
+
const existingId = getPatternImageId(config, OUTDOOR, registry)
|
|
74
|
+
const map = makeMap([existingId])
|
|
75
|
+
await registerPatterns(map, [config], OUTDOOR, registry)
|
|
76
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('skips config when pattern has no inner content', async () => {
|
|
80
|
+
const map = makeMap()
|
|
81
|
+
const emptyRegistry = { get: jest.fn(() => undefined) }
|
|
82
|
+
await registerPatterns(map, [{ fillPattern: 'unknown' }], OUTDOOR, emptyRegistry)
|
|
83
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('skips config when neither fillPattern nor fillPatternSvgContent is set', async () => {
|
|
87
|
+
const map = makeMap()
|
|
88
|
+
const registry = makePatternRegistry()
|
|
89
|
+
await registerPatterns(map, [{ fillColor: '#ff0000' }], OUTDOOR, registry)
|
|
90
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('processes multiple configs in parallel', async () => {
|
|
94
|
+
const map = makeMap()
|
|
95
|
+
const registry = {
|
|
96
|
+
get: jest.fn((name) => {
|
|
97
|
+
if (name === 'stripes') { return { svgContent: '<path d="M0 0"/>' } }
|
|
98
|
+
if (name === 'dots') { return { svgContent: '<circle cx="8" cy="8" r="4"/>' } }
|
|
99
|
+
return undefined
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
await registerPatterns(map, [{ fillPattern: 'stripes' }, { fillPattern: 'dots' }], OUTDOOR, registry)
|
|
103
|
+
expect(map.addImage).toHaveBeenCalledTimes(2)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('registerPatterns — color resolution and caching', () => {
|
|
108
|
+
it('applies foreground and background colors when resolving the SVG', async () => {
|
|
109
|
+
const map = makeMap()
|
|
110
|
+
const registry = makePatternRegistry()
|
|
111
|
+
const getContextSpy = HTMLCanvasElement.prototype.getContext
|
|
112
|
+
await registerPatterns(
|
|
113
|
+
map,
|
|
114
|
+
[{ fillPattern: 'stripes', fillPatternForegroundColor: '#aabbcc', fillPatternBackgroundColor: '#112233' }],
|
|
115
|
+
OUTDOOR,
|
|
116
|
+
registry
|
|
117
|
+
)
|
|
118
|
+
expect(map.addImage).toHaveBeenCalledTimes(1)
|
|
119
|
+
expect(getContextSpy).toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('resolves style-keyed foreground color for the given mapStyleId', async () => {
|
|
123
|
+
const map = makeMap()
|
|
124
|
+
const registry = makePatternRegistry()
|
|
125
|
+
const config = {
|
|
126
|
+
fillPattern: 'stripes',
|
|
127
|
+
fillPatternForegroundColor: { outdoor: '#aabbcc', dark: '#112233' }
|
|
128
|
+
}
|
|
129
|
+
await registerPatterns(map, [config], OUTDOOR, registry)
|
|
130
|
+
const map2 = makeMap()
|
|
131
|
+
await registerPatterns(map2, [config], 'dark', registry)
|
|
132
|
+
const [idOutdoor] = map.addImage.mock.calls[0]
|
|
133
|
+
const [idDark] = map2.addImage.mock.calls[0]
|
|
134
|
+
expect(idOutdoor).not.toBe(idDark)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('uses cached ImageData on second call with identical config', async () => {
|
|
138
|
+
const map = makeMap()
|
|
139
|
+
const registry = makePatternRegistry()
|
|
140
|
+
const config = { fillPattern: 'stripes', fillPatternForegroundColor: '#unique1' }
|
|
141
|
+
await registerPatterns(map, [config], OUTDOOR, registry)
|
|
142
|
+
const { getPatternImageId } = await import('../../../../src/utils/patternUtils.js')
|
|
143
|
+
const imageId = getPatternImageId(config, OUTDOOR, registry)
|
|
144
|
+
const map2 = makeMap([imageId])
|
|
145
|
+
await registerPatterns(map2, [config], OUTDOOR, registry)
|
|
146
|
+
expect(map2.addImage).not.toHaveBeenCalled()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('registerPatterns — null results', () => {
|
|
151
|
+
it('does not call addImage when innerContent becomes unavailable inside rasterisePattern', async () => {
|
|
152
|
+
// registry.get returns content on the first call (for getPatternImageId in registerPatterns)
|
|
153
|
+
// but undefined on the second call (for getPatternInnerContent inside rasterisePattern)
|
|
154
|
+
const registry = {
|
|
155
|
+
get: jest.fn()
|
|
156
|
+
.mockReturnValueOnce({ svgContent: SVG_CONTENT })
|
|
157
|
+
.mockReturnValueOnce(undefined)
|
|
158
|
+
}
|
|
159
|
+
const map = makeMap()
|
|
160
|
+
await registerPatterns(map, [{ fillPattern: 'stripes' }], OUTDOOR, registry)
|
|
161
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('does not call addImage when imageId becomes unavailable inside rasterisePattern', async () => {
|
|
165
|
+
// Three consecutive calls to registry.get:
|
|
166
|
+
// 1. getPatternImageId in registerPatterns → returns content → imageId is truthy
|
|
167
|
+
// 2. getPatternInnerContent directly in rasterisePattern → returns content → passes innerContent guard
|
|
168
|
+
// 3. getPatternInnerContent inside getPatternImageId in rasterisePattern → returns undefined
|
|
169
|
+
// → getPatternImageId returns null → hits the imageId null guard
|
|
170
|
+
const registry = {
|
|
171
|
+
get: jest.fn()
|
|
172
|
+
.mockReturnValueOnce({ svgContent: SVG_CONTENT })
|
|
173
|
+
.mockReturnValueOnce({ svgContent: SVG_CONTENT })
|
|
174
|
+
.mockReturnValueOnce(undefined)
|
|
175
|
+
}
|
|
176
|
+
const map = makeMap()
|
|
177
|
+
await registerPatterns(map, [{ fillPattern: 'stripes' }], OUTDOOR, registry)
|
|
178
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -83,12 +83,23 @@ const getMinDistToGeometry = (map, point, geometry) => {
|
|
|
83
83
|
*/
|
|
84
84
|
export const queryFeatures = (map, point, options = {}) => {
|
|
85
85
|
const { radius = 10 } = options
|
|
86
|
-
|
|
87
|
-
const
|
|
86
|
+
|
|
87
|
+
const bbox = [[point.x - radius, point.y - radius], [point.x + radius, point.y + radius]]
|
|
88
|
+
const rawFeatures = map.queryRenderedFeatures(bbox)
|
|
88
89
|
if (rawFeatures.length === 0) {
|
|
89
90
|
return []
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
// For symbol/point features, tolerance must not apply — selection should only
|
|
94
|
+
// fire when the click lands within the rendered icon bounds. An exact point
|
|
95
|
+
// query uses MapLibre's own icon hit-testing, mirroring the hover cursor behaviour.
|
|
96
|
+
const exactFeatureKeys = new Set(
|
|
97
|
+
map.queryRenderedFeatures([point.x, point.y]).map(f => {
|
|
98
|
+
const rawId = f.id === undefined ? JSON.stringify(f.properties) : f.id
|
|
99
|
+
return `${f.layer?.source}:${rawId}`
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
|
|
92
103
|
// Identify layer visual hierarchy
|
|
93
104
|
const layerStack = []
|
|
94
105
|
rawFeatures.forEach(f => {
|
|
@@ -97,12 +108,15 @@ export const queryFeatures = (map, point, options = {}) => {
|
|
|
97
108
|
}
|
|
98
109
|
})
|
|
99
110
|
|
|
100
|
-
// Deduplicate Bottom-Up to favor data layers over highlight layers
|
|
111
|
+
// Deduplicate Bottom-Up to favor data layers over highlight layers.
|
|
112
|
+
// Key includes source ID to prevent collisions between features from different
|
|
113
|
+
// sources that share the same numeric ID (e.g. generateId: true resets per source).
|
|
101
114
|
const seenIds = new Set()
|
|
102
115
|
const uniqueFeatures = []
|
|
103
116
|
for (let i = rawFeatures.length - 1; i >= 0; i--) {
|
|
104
117
|
const f = rawFeatures[i]
|
|
105
|
-
const
|
|
118
|
+
const rawId = f.id === undefined ? JSON.stringify(f.properties) : f.id
|
|
119
|
+
const featureId = `${f.layer?.source}:${rawId}`
|
|
106
120
|
if (seenIds.has(featureId) === false) {
|
|
107
121
|
seenIds.add(featureId)
|
|
108
122
|
uniqueFeatures.push(f)
|
|
@@ -112,7 +126,24 @@ export const queryFeatures = (map, point, options = {}) => {
|
|
|
112
126
|
const clickLngLat = map.unproject(point)
|
|
113
127
|
const clickPt = [clickLngLat.lng, clickLngLat.lat]
|
|
114
128
|
|
|
115
|
-
|
|
129
|
+
// Discard features where tolerance should not apply:
|
|
130
|
+
// - Polygons: only include if click is geometrically inside
|
|
131
|
+
// - Points/symbols: only include if under the exact click point (respects icon bounds)
|
|
132
|
+
// - Lines: allowed through — tolerance bbox is intentional for them
|
|
133
|
+
const candidates = uniqueFeatures.filter((f) => {
|
|
134
|
+
const type = f.geometry.type
|
|
135
|
+
if (type.includes('Polygon')) {
|
|
136
|
+
const polys = type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates
|
|
137
|
+
return polys.some((ring) => isPointInPolygon(clickPt, ring[0]))
|
|
138
|
+
}
|
|
139
|
+
if (type === 'Point' || type === 'MultiPoint') {
|
|
140
|
+
const rawId = f.id === undefined ? JSON.stringify(f.properties) : f.id
|
|
141
|
+
return exactFeatureKeys.has(`${f.layer?.source}:${rawId}`)
|
|
142
|
+
}
|
|
143
|
+
return true
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
return candidates
|
|
116
147
|
.map((f) => {
|
|
117
148
|
let score = 0
|
|
118
149
|
const type = f.geometry.type
|
|
@@ -122,18 +153,9 @@ export const queryFeatures = (map, point, options = {}) => {
|
|
|
122
153
|
const layerRank = layerStack.indexOf(f.layer.id)
|
|
123
154
|
score += (layerRank * 1000000)
|
|
124
155
|
|
|
125
|
-
// PRIORITY 2:
|
|
156
|
+
// PRIORITY 2: POLYGON BOOST (already filtered to inside-only)
|
|
126
157
|
if (type.includes('Polygon')) {
|
|
127
|
-
|
|
128
|
-
const isInside = polys.some((ring) => isPointInPolygon(clickPt, ring[0]))
|
|
129
|
-
|
|
130
|
-
if (isInside === true) {
|
|
131
|
-
// Massive boost for polygons if we are actually inside them
|
|
132
|
-
score -= 500000 // NOSONAR - tolerance used only here
|
|
133
|
-
} else {
|
|
134
|
-
// If we are outside a polygon, it loses significantly to anything we ARE inside
|
|
135
|
-
score += 100000 // NOSONAR - tolerance used only here
|
|
136
|
-
}
|
|
158
|
+
score -= 500000 // NOSONAR
|
|
137
159
|
}
|
|
138
160
|
|
|
139
161
|
// PRIORITY 3: DISTANCE (Final Tie-breaker)
|
|
@@ -20,7 +20,7 @@ describe('queryFeatures coverage', () => {
|
|
|
20
20
|
{ type: 'MultiPoint', coords: [[0, 0], [10, 10]], p: { x: 1, y: 0 } },
|
|
21
21
|
{ type: 'MultiLineString', coords: [[[0, 0], [10, 0]]], p: { x: 5, y: 1 } },
|
|
22
22
|
{ type: 'Polygon', coords: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], p: { x: 5, y: 5 } }, // Inside
|
|
23
|
-
{ type: 'MultiPolygon', coords: [[[[0, 0], [10, 0], [10, 10], [0, 0]]]], p: { x:
|
|
23
|
+
{ type: 'MultiPolygon', coords: [[[[0, 0], [10, 0], [10, 10], [0, 0]]]], p: { x: 5, y: 3 } }, // Inside
|
|
24
24
|
{ type: 'Unknown', coords: [], p: { x: 0, y: 0 } }
|
|
25
25
|
]
|
|
26
26
|
|
|
@@ -49,12 +49,29 @@ describe('queryFeatures coverage', () => {
|
|
|
49
49
|
expect(result.length).toBe(2)
|
|
50
50
|
expect(result[0].layer.id).toBe('layer-A') // Sorted by layerStack index
|
|
51
51
|
|
|
52
|
-
// 4. Hit ray-casting intersect logic
|
|
52
|
+
// 4. Hit ray-casting intersect logic — point inside the polygon
|
|
53
53
|
const polyFeat = {
|
|
54
54
|
layer: { id: 'L' },
|
|
55
55
|
geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 10], [0, 10], [0, 0]]] }
|
|
56
56
|
}
|
|
57
57
|
const rayMap = { ...mockMap, queryRenderedFeatures: () => [polyFeat] }
|
|
58
|
-
expect(queryFeatures(rayMap, { x:
|
|
58
|
+
expect(queryFeatures(rayMap, { x: 2, y: 8 }).length).toBe(1)
|
|
59
|
+
|
|
60
|
+
// 5. Outside polygon is filtered out (tolerance only applies to lines)
|
|
61
|
+
const outsideMap = { ...mockMap, queryRenderedFeatures: () => [polyFeat] }
|
|
62
|
+
expect(queryFeatures(outsideMap, { x: -1, y: 5 }).length).toBe(0)
|
|
63
|
+
|
|
64
|
+
// 6. Symbol under exact click point is included
|
|
65
|
+
const symbolFeat = { id: 'sym', layer: { id: 'S', source: 'src' }, geometry: { type: 'Point', coordinates: [0, 0] } }
|
|
66
|
+
const symbolMap = { ...mockMap, queryRenderedFeatures: () => [symbolFeat] } // both calls return it
|
|
67
|
+
expect(queryFeatures(symbolMap, { x: 5, y: 5 }).length).toBe(1)
|
|
68
|
+
|
|
69
|
+
// 7. Symbol NOT under exact click point is filtered out
|
|
70
|
+
let call = 0
|
|
71
|
+
const symbolMissMap = {
|
|
72
|
+
...mockMap,
|
|
73
|
+
queryRenderedFeatures: () => call++ === 0 ? [symbolFeat] : [] // bbox returns it, exact does not
|
|
74
|
+
}
|
|
75
|
+
expect(queryFeatures(symbolMissMap, { x: 5, y: 5 }).length).toBe(0)
|
|
59
76
|
})
|
|
60
77
|
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const SVG_ERROR_PREVIEW_LENGTH = 80
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rasterises an SVG string to an ImageData object via a canvas.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} svgString - Full SVG markup to render
|
|
7
|
+
* @param {number} width - Canvas width in pixels
|
|
8
|
+
* @param {number} height - Canvas height in pixels
|
|
9
|
+
* @returns {Promise<ImageData>}
|
|
10
|
+
*/
|
|
11
|
+
export const rasteriseToImageData = (svgString, width, height) =>
|
|
12
|
+
new Promise((resolve, reject) => {
|
|
13
|
+
const blob = new Blob([svgString], { type: 'image/svg+xml' })
|
|
14
|
+
const url = URL.createObjectURL(blob)
|
|
15
|
+
const img = new Image(width, height)
|
|
16
|
+
img.onload = () => {
|
|
17
|
+
const canvas = document.createElement('canvas')
|
|
18
|
+
canvas.width = width
|
|
19
|
+
canvas.height = height
|
|
20
|
+
const ctx = canvas.getContext('2d')
|
|
21
|
+
ctx.drawImage(img, 0, 0, width, height)
|
|
22
|
+
URL.revokeObjectURL(url)
|
|
23
|
+
resolve(ctx.getImageData(0, 0, width, height))
|
|
24
|
+
}
|
|
25
|
+
img.onerror = () => {
|
|
26
|
+
URL.revokeObjectURL(url)
|
|
27
|
+
reject(new Error(`Failed to rasterise SVG: ${svgString.slice(0, SVG_ERROR_PREVIEW_LENGTH)}`))
|
|
28
|
+
}
|
|
29
|
+
img.src = url
|
|
30
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { rasteriseToImageData } from './rasteriseToImageData.js'
|
|
2
|
+
|
|
3
|
+
const SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="16" cy="16" r="8"/></svg>'
|
|
4
|
+
const WIDTH = 32
|
|
5
|
+
const HEIGHT = 32
|
|
6
|
+
|
|
7
|
+
// Mirrors SVG_ERROR_PREVIEW_LENGTH in rasteriseToImageData.js
|
|
8
|
+
const ERROR_PREVIEW_LENGTH = 80
|
|
9
|
+
// Length chosen to be well over ERROR_PREVIEW_LENGTH so truncation is exercised
|
|
10
|
+
const LONG_CONTENT_LENGTH = 200
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock')
|
|
14
|
+
globalThis.URL.revokeObjectURL = jest.fn()
|
|
15
|
+
|
|
16
|
+
HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
|
|
17
|
+
drawImage: jest.fn(),
|
|
18
|
+
getImageData: jest.fn((_x, _y, w, h) => ({ width: w, height: h }))
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
globalThis.Image = class {
|
|
22
|
+
constructor (w, h) {
|
|
23
|
+
this.width = w
|
|
24
|
+
this.height = h
|
|
25
|
+
this._src = ''
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get src () { return this._src }
|
|
29
|
+
set src (val) { this._src = val; this.onload?.() }
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
jest.clearAllMocks()
|
|
35
|
+
globalThis.URL.createObjectURL.mockReturnValue('blob:mock')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('rasteriseToImageData', () => {
|
|
39
|
+
it('resolves with ImageData at the requested dimensions, draws via canvas, and revokes the blob URL', async () => {
|
|
40
|
+
const getContext = HTMLCanvasElement.prototype.getContext
|
|
41
|
+
const result = await rasteriseToImageData(SVG, WIDTH, HEIGHT)
|
|
42
|
+
expect(result).toMatchObject({ width: WIDTH, height: HEIGHT })
|
|
43
|
+
expect(globalThis.URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
|
44
|
+
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock')
|
|
45
|
+
const { drawImage, getImageData } = getContext.mock.results[0].value
|
|
46
|
+
expect(drawImage).toHaveBeenCalledWith(expect.any(Object), 0, 0, WIDTH, HEIGHT)
|
|
47
|
+
expect(getImageData).toHaveBeenCalledWith(0, 0, WIDTH, HEIGHT)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rejects with a truncated SVG preview and revokes the blob URL on error', async () => {
|
|
51
|
+
const originalImage = globalThis.Image
|
|
52
|
+
globalThis.Image = class {
|
|
53
|
+
constructor (w, h) { this.width = w; this.height = h; this._src = '' }
|
|
54
|
+
get src () { return this._src }
|
|
55
|
+
set src (val) { this._src = val; this.onerror?.() }
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const longSvg = `<svg>${'x'.repeat(LONG_CONTENT_LENGTH)}</svg>`
|
|
59
|
+
const error = await rasteriseToImageData(longSvg, WIDTH, HEIGHT).catch(e => e)
|
|
60
|
+
expect(error.message).toMatch('Failed to rasterise SVG')
|
|
61
|
+
const preview = error.message.replace('Failed to rasterise SVG: ', '')
|
|
62
|
+
expect(preview).toHaveLength(ERROR_PREVIEW_LENGTH)
|
|
63
|
+
expect(preview).toBe(longSvg.slice(0, ERROR_PREVIEW_LENGTH))
|
|
64
|
+
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock')
|
|
65
|
+
} finally {
|
|
66
|
+
globalThis.Image = originalImage
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { getSymbolDef, getSymbolStyleColors, getSymbolViewBox } from '../../../../src/utils/symbolUtils.js'
|
|
2
|
+
import { rasteriseToImageData } from './rasteriseToImageData.js'
|
|
3
|
+
|
|
4
|
+
const ANCHOR_LOW = 0.25
|
|
5
|
+
const ANCHOR_HIGH = 0.75
|
|
6
|
+
const HASH_BASE = 36
|
|
7
|
+
|
|
8
|
+
const hashString = (str) => {
|
|
9
|
+
let hash = 0
|
|
10
|
+
for (const ch of str) {
|
|
11
|
+
hash = Math.trunc(((hash << 5) - hash) + ch.codePointAt(0))
|
|
12
|
+
}
|
|
13
|
+
return Math.abs(hash).toString(HASH_BASE)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── MapLibre-specific anchor conversion ──────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Converts a fractional [ax, ay] anchor to a MapLibre icon-anchor string.
|
|
20
|
+
* Snaps to the nearest of the 9 standard positions.
|
|
21
|
+
*
|
|
22
|
+
* @param {number[]} anchor - [x, y] in 0–1 space
|
|
23
|
+
* @returns {string} MapLibre icon-anchor value
|
|
24
|
+
*/
|
|
25
|
+
const xAnchor = (ax) => {
|
|
26
|
+
if (ax <= ANCHOR_LOW) {
|
|
27
|
+
return 'left'
|
|
28
|
+
}
|
|
29
|
+
if (ax >= ANCHOR_HIGH) {
|
|
30
|
+
return 'right'
|
|
31
|
+
}
|
|
32
|
+
return ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const yAnchor = (ay) => {
|
|
36
|
+
if (ay <= ANCHOR_LOW) {
|
|
37
|
+
return 'top'
|
|
38
|
+
}
|
|
39
|
+
if (ay >= ANCHOR_HIGH) {
|
|
40
|
+
return 'bottom'
|
|
41
|
+
}
|
|
42
|
+
return ''
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const anchorToMaplibre = ([ax, ay]) => {
|
|
46
|
+
const x = xAnchor(ax)
|
|
47
|
+
const y = yAnchor(ay)
|
|
48
|
+
return (y + (x && y ? '-' : '') + x) || 'center'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Image IDs ────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns a deterministic image ID for a symbol in normal or selected state.
|
|
55
|
+
* Based on the hash of the fully resolved SVG content and the pixel ratio.
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} dataset
|
|
58
|
+
* @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
|
|
59
|
+
* @param {Object} symbolRegistry
|
|
60
|
+
* @param {boolean} [selected=false]
|
|
61
|
+
* @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor
|
|
62
|
+
* @returns {string|null}
|
|
63
|
+
*/
|
|
64
|
+
export const getSymbolImageId = (dataset, mapStyle, symbolRegistry, selected = false, pixelRatio = 2) => {
|
|
65
|
+
const symbolDef = getSymbolDef(dataset, symbolRegistry)
|
|
66
|
+
if (!symbolDef) {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
const styleColors = getSymbolStyleColors(dataset)
|
|
70
|
+
const resolved = selected
|
|
71
|
+
? symbolRegistry.resolveSelected(symbolDef, styleColors, mapStyle)
|
|
72
|
+
: symbolRegistry.resolve(symbolDef, styleColors, mapStyle)
|
|
73
|
+
return `symbol-${selected ? 'sel-' : ''}${hashString(resolved)}-${pixelRatio}x`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Rasterisation ────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
// Module-level cache: imageId → ImageData. Avoids re-rasterising identical symbols.
|
|
79
|
+
const imageDataCache = new Map()
|
|
80
|
+
|
|
81
|
+
const rasteriseSymbolImage = async (dataset, mapStyle, symbolRegistry, selected, pixelRatio) => {
|
|
82
|
+
const symbolDef = getSymbolDef(dataset, symbolRegistry)
|
|
83
|
+
if (!symbolDef) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
const styleColors = getSymbolStyleColors(dataset)
|
|
87
|
+
const resolvedContent = selected
|
|
88
|
+
? symbolRegistry.resolveSelected(symbolDef, styleColors, mapStyle)
|
|
89
|
+
: symbolRegistry.resolve(symbolDef, styleColors, mapStyle)
|
|
90
|
+
|
|
91
|
+
const imageId = `symbol-${selected ? 'sel-' : ''}${hashString(resolvedContent)}-${pixelRatio}x`
|
|
92
|
+
|
|
93
|
+
let imageData = imageDataCache.get(imageId)
|
|
94
|
+
if (!imageData) {
|
|
95
|
+
const viewBox = getSymbolViewBox(dataset, symbolDef)
|
|
96
|
+
const [,, width, height] = viewBox.split(' ').map(Number)
|
|
97
|
+
// Render at pixelRatio× to keep icons crisp at the current device DPI and map size.
|
|
98
|
+
// MapLibre receives the matching pixelRatio so the image displays at its original logical size.
|
|
99
|
+
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="${width * pixelRatio}" height="${height * pixelRatio}" viewBox="${viewBox}">${resolvedContent}</svg>`
|
|
100
|
+
imageData = await rasteriseToImageData(svgString, width * pixelRatio, height * pixelRatio)
|
|
101
|
+
imageDataCache.set(imageId, imageData)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { imageId, imageData }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Register normal and selected symbol images for the given pre-resolved symbol configs.
|
|
109
|
+
* Skips images that are already registered (safe to call on style change).
|
|
110
|
+
* Updates `map._symbolImageMap` with normal→selected image ID pairs.
|
|
111
|
+
*
|
|
112
|
+
* Callers are responsible for resolving sublayers before calling this function
|
|
113
|
+
* (see `getSymbolConfigs` in the datasets plugin adapter).
|
|
114
|
+
*
|
|
115
|
+
* @param {Object} map - MapLibre map instance
|
|
116
|
+
* @param {Object[]} symbolConfigs - Flat list of datasets/merged-sublayers that have a symbol config
|
|
117
|
+
* @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
|
|
118
|
+
* @param {Object} symbolRegistry
|
|
119
|
+
* @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor (computed by caller)
|
|
120
|
+
* @returns {Promise<void>}
|
|
121
|
+
*/
|
|
122
|
+
export const registerSymbols = async (map, symbolConfigs, mapStyle, symbolRegistry, pixelRatio = 2) => {
|
|
123
|
+
if (!symbolConfigs.length) {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Reset the normal→selected image ID lookup so stale entries don't persist after a style change
|
|
128
|
+
map._symbolImageMap = {}
|
|
129
|
+
|
|
130
|
+
await Promise.all(symbolConfigs.flatMap(config => {
|
|
131
|
+
const normalId = getSymbolImageId(config, mapStyle, symbolRegistry, false, pixelRatio)
|
|
132
|
+
const selectedId = getSymbolImageId(config, mapStyle, symbolRegistry, true, pixelRatio)
|
|
133
|
+
if (normalId && selectedId) {
|
|
134
|
+
map._symbolImageMap[normalId] = selectedId
|
|
135
|
+
}
|
|
136
|
+
return [false, true].map(async (selected) => {
|
|
137
|
+
const imageId = selected ? selectedId : normalId
|
|
138
|
+
if (!imageId || map.hasImage(imageId)) {
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
const result = await rasteriseSymbolImage(config, mapStyle, symbolRegistry, selected, pixelRatio)
|
|
142
|
+
if (result && !map.hasImage(result.imageId)) {
|
|
143
|
+
map.addImage(result.imageId, result.imageData, { pixelRatio })
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
}))
|
|
147
|
+
}
|