@defra/interactive-map 0.0.7-alpha → 0.0.9-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/templates/map.njk +2 -2
- package/dist/esm/im-core.js +1 -2
- package/dist/esm/im-shell.js +1 -0
- package/dist/esm/index.js +1 -2
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/button-definition.md +104 -3
- package/docs/api.md +21 -1
- package/docs/getting-started.md +78 -8
- package/package.json +31 -24
- package/plugins/beta/datasets/dist/css/index.css +50 -1
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -2
- package/plugins/beta/datasets/dist/esm/index.js +1 -2
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -2
- package/plugins/beta/draw-es/dist/esm/index.js +1 -2
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/esm/index.js +1 -2
- package/plugins/beta/frame/dist/css/index.css +11 -1
- package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/dist/esm/index.js +1 -2
- package/plugins/beta/map-styles/dist/css/index.css +79 -1
- package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/esm/index.js +1 -2
- package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
- package/plugins/beta/scale-bar/dist/esm/index.js +1 -2
- package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/dist/esm/index.js +1 -2
- package/plugins/beta/use-location/dist/umd/index.js +1 -1
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/esm/index.js +1 -2
- package/plugins/search/dist/esm/im-search-plugin.js +1 -2
- package/plugins/search/dist/esm/index.js +1 -2
- package/plugins/search/src/components/Suggestions/Suggestions.module.scss +1 -1
- package/plugins/search/src/search.scss +1 -1
- package/providers/beta/esri/dist/css/index.css +30 -0
- package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -2
- package/providers/beta/esri/dist/esm/index.js +1 -2
- package/providers/beta/open-names/dist/esm/im-reverse-geocode.js +1 -2
- package/providers/beta/open-names/dist/esm/index.js +1 -2
- package/providers/beta/open-names/src/utils/mapToLocationModel.test.js +61 -0
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -2
- package/providers/maplibre/dist/esm/index.js +1 -2
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/src/appEvents.test.js +44 -0
- package/providers/maplibre/src/index.test.js +60 -0
- package/providers/maplibre/src/mapEvents.test.js +115 -0
- package/providers/maplibre/src/maplibreProvider.test.js +205 -0
- package/providers/maplibre/src/utils/calculateLinearTextSize.test.js +31 -0
- package/providers/maplibre/src/utils/detectWebgl.test.js +63 -0
- package/providers/maplibre/src/utils/highlightFeatures.test.js +126 -0
- package/providers/maplibre/src/utils/labels.js +1 -3
- package/providers/maplibre/src/utils/labels.test.js +231 -0
- package/providers/maplibre/src/utils/maplibreFixes.test.js +66 -0
- package/providers/maplibre/src/utils/queryFeatures.test.js +60 -0
- package/providers/maplibre/src/utils/spatial.js +5 -4
- package/providers/maplibre/src/utils/spatial.test.js +96 -0
- package/rollup.esm.mjs +288 -0
- package/src/App/store/appActionsMap.js +1 -1
- package/src/InteractiveMap/InteractiveMap.js +3 -2
- package/webpack.dev.mjs +9 -1
- package/webpack.prod.mjs +8 -1
- package/webpack.umd.mjs +1 -3
- package/dist/esm/im-core.js.LICENSE.txt +0 -1
- package/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js.LICENSE.txt +0 -1
- package/plugins/beta/datasets/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js.LICENSE.txt +0 -1
- package/plugins/beta/draw-es/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/draw-ml/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/frame/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/map-styles/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/scale-bar/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/use-location/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/interact/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/search/dist/esm/im-search-plugin.js.LICENSE.txt +0 -1
- package/plugins/search/dist/esm/index.js.LICENSE.txt +0 -1
- package/providers/beta/esri/dist/css/im-esri-provider.css +0 -1
- package/providers/beta/esri/dist/esm/im-esri-provider.js.LICENSE.txt +0 -1
- package/providers/beta/esri/dist/esm/index.js.LICENSE.txt +0 -1
- package/providers/beta/open-names/dist/esm/im-reverse-geocode.js.LICENSE.txt +0 -1
- package/providers/beta/open-names/dist/esm/index.js.LICENSE.txt +0 -1
- package/providers/maplibre/dist/esm/im-maplibre-framework.js +0 -2
- package/providers/maplibre/dist/esm/im-maplibre-framework.js.LICENSE.txt +0 -4
- package/providers/maplibre/dist/esm/im-maplibre-provider.js.LICENSE.txt +0 -1
- package/providers/maplibre/dist/esm/index.js.LICENSE.txt +0 -1
- package/webpack.esm.mjs +0 -153
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getGeometryCenter, evalInterpolate, getHighlightColors, extractTextPropertyName,
|
|
3
|
+
buildLabelFromFeature, buildLabelsFromLayers, findClosestLabel,
|
|
4
|
+
createHighlightLayerConfig, removeHighlightLayer, applyHighlight,
|
|
5
|
+
navigateToNextLabel, createMapLabelNavigator
|
|
6
|
+
} from './labels.js'
|
|
7
|
+
|
|
8
|
+
jest.mock('./spatial.js', () => ({ spatialNavigate: jest.fn() }))
|
|
9
|
+
jest.mock('./calculateLinearTextSize.js', () => ({ calculateLinearTextSize: jest.fn(() => 12) }))
|
|
10
|
+
|
|
11
|
+
import { spatialNavigate } from './spatial.js'
|
|
12
|
+
import { calculateLinearTextSize } from './calculateLinearTextSize.js'
|
|
13
|
+
|
|
14
|
+
describe('labels utils', () => {
|
|
15
|
+
|
|
16
|
+
test('getGeometryCenter all geometry types', () => {
|
|
17
|
+
expect(getGeometryCenter({ type: 'Point', coordinates: [1, 2] })).toEqual([1, 2])
|
|
18
|
+
expect(getGeometryCenter({ type: 'MultiPoint', coordinates: [[3, 4]] })).toEqual([3, 4])
|
|
19
|
+
expect(getGeometryCenter({ type: 'LineString', coordinates: [[0, 0], [4, 4]] })).toEqual([2, 2])
|
|
20
|
+
expect(getGeometryCenter({ type: 'MultiLineString', coordinates: [[[0, 0], [4, 4]]] })).toEqual([2, 2])
|
|
21
|
+
expect(getGeometryCenter({ type: 'Polygon', coordinates: [[[0, 0], [2, 0], [2, 2], [0, 2]]] })).toEqual([1, 1])
|
|
22
|
+
expect(getGeometryCenter({ type: 'MultiPolygon', coordinates: [[[[0, 0], [2, 0], [2, 2], [0, 2]]]] })).toEqual([1, 1])
|
|
23
|
+
expect(getGeometryCenter({ type: 'GeometryCollection', coordinates: [] })).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('evalInterpolate all branches', () => {
|
|
27
|
+
expect(evalInterpolate(14, 10)).toBe(14)
|
|
28
|
+
expect(evalInterpolate('label', 10)).toBe(12)
|
|
29
|
+
expect(calculateLinearTextSize).toHaveBeenCalledWith('label', 10)
|
|
30
|
+
expect(evalInterpolate(['literal', 'x'], 10)).toBe(12)
|
|
31
|
+
expect(() => evalInterpolate(['interpolate', ['linear'], ['get', 'p'], 5, 10], 10)).toThrow()
|
|
32
|
+
const expr = ['interpolate', ['linear'], ['zoom'], 5, 10, 10, 20]
|
|
33
|
+
expect(evalInterpolate(expr, 3)).toBe(10) // zoom <= z0
|
|
34
|
+
expect(evalInterpolate(expr, 7.5)).toBe(15) // interpolated
|
|
35
|
+
expect(evalInterpolate(expr, 15)).toBe(20) // beyond last stop
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('getHighlightColors', () => {
|
|
39
|
+
expect(getHighlightColors(true)).toEqual({ text: '#ffffff', halo: '#000000' })
|
|
40
|
+
expect(getHighlightColors(false)).toEqual({ text: '#000000', halo: '#ffffff' })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('extractTextPropertyName all branches', () => {
|
|
44
|
+
expect(extractTextPropertyName('{name}')).toBe('name')
|
|
45
|
+
expect(extractTextPropertyName('plain')).toBeUndefined()
|
|
46
|
+
expect(extractTextPropertyName(['label', ['get', 'title']])).toBe('title')
|
|
47
|
+
expect(extractTextPropertyName(['label'])).toBeUndefined()
|
|
48
|
+
expect(extractTextPropertyName(null)).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('buildLabelFromFeature: null center returns null; valid returns label', () => {
|
|
52
|
+
const map = { project: jest.fn(({ lng, lat }) => ({ x: lng, y: lat })) }
|
|
53
|
+
expect(buildLabelFromFeature(
|
|
54
|
+
{ geometry: { type: 'Unknown', coordinates: [] }, properties: {} }, {}, 'n', map
|
|
55
|
+
)).toBeNull()
|
|
56
|
+
const result = buildLabelFromFeature(
|
|
57
|
+
{ geometry: { type: 'Point', coordinates: [1, 2] }, properties: { n: 'A' } },
|
|
58
|
+
{ id: 'l1' }, 'n', map
|
|
59
|
+
)
|
|
60
|
+
expect(result).toMatchObject({ text: 'A', x: 1, y: 2 })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('buildLabelsFromLayers: skips no-propName layer; filters null-center labels', () => {
|
|
64
|
+
const map = { project: jest.fn(({ lng, lat }) => ({ x: lng, y: lat })) }
|
|
65
|
+
const layers = [
|
|
66
|
+
{ id: 'l1', layout: {} },
|
|
67
|
+
{ id: 'l2', layout: { 'text-field': '{name}' } }
|
|
68
|
+
]
|
|
69
|
+
const features = [
|
|
70
|
+
{ layer: { id: 'l2' }, properties: { name: 'Town' }, geometry: { type: 'Point', coordinates: [1, 2] } },
|
|
71
|
+
{ layer: { id: 'l2' }, properties: { name: 'X' }, geometry: { type: 'Unknown', coordinates: [] } }
|
|
72
|
+
]
|
|
73
|
+
const result = buildLabelsFromLayers(map, layers, features)
|
|
74
|
+
expect(result).toHaveLength(1)
|
|
75
|
+
expect(result[0].text).toBe('Town')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('findClosestLabel: empty → undefined; returns closest; skips farther', () => {
|
|
79
|
+
expect(findClosestLabel([], { x: 0, y: 0 })).toBeUndefined()
|
|
80
|
+
const labels = [{ x: 2, y: 0 }, { x: 10, y: 0 }] // closer first → second hits false branch
|
|
81
|
+
expect(findClosestLabel(labels, { x: 0, y: 0 })).toBe(labels[0])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('createHighlightLayerConfig returns merged config', () => {
|
|
85
|
+
const config = createHighlightLayerConfig(
|
|
86
|
+
{ id: 'sl', type: 'symbol', layout: { 'text-font': ['Open Sans'] }, paint: {} },
|
|
87
|
+
18, { text: '#fff', halo: '#000' }
|
|
88
|
+
)
|
|
89
|
+
expect(config.id).toBe('highlight-sl')
|
|
90
|
+
expect(config.layout['text-size']).toBe(18)
|
|
91
|
+
expect(config.layout['text-allow-overlap']).toBe(true)
|
|
92
|
+
expect(config.paint['text-color']).toBe('#fff')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('removeHighlightLayer: skips when no id or layer absent; removes when present', () => {
|
|
96
|
+
const map = { getLayer: jest.fn(), removeLayer: jest.fn() }
|
|
97
|
+
const state = { highlightLayerId: null, highlightedExpr: 'x' }
|
|
98
|
+
removeHighlightLayer(map, state)
|
|
99
|
+
expect(map.removeLayer).not.toHaveBeenCalled()
|
|
100
|
+
state.highlightLayerId = 'h1'
|
|
101
|
+
map.getLayer.mockReturnValue(null)
|
|
102
|
+
removeHighlightLayer(map, state)
|
|
103
|
+
expect(map.removeLayer).not.toHaveBeenCalled()
|
|
104
|
+
map.getLayer.mockReturnValue(true)
|
|
105
|
+
removeHighlightLayer(map, state)
|
|
106
|
+
expect(map.removeLayer).toHaveBeenCalledWith('h1')
|
|
107
|
+
expect(state.highlightLayerId).toBeNull()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('applyHighlight: early returns without feature.layer; applies otherwise', () => {
|
|
111
|
+
const map = {
|
|
112
|
+
getLayer: jest.fn(), removeLayer: jest.fn(),
|
|
113
|
+
getSource: jest.fn(() => ({ setData: jest.fn() })),
|
|
114
|
+
getZoom: jest.fn(() => 10), addLayer: jest.fn(), moveLayer: jest.fn()
|
|
115
|
+
}
|
|
116
|
+
const state = { highlightLayerId: null, highlightedExpr: null, isDarkStyle: false }
|
|
117
|
+
applyHighlight(map, null, state)
|
|
118
|
+
applyHighlight(map, { feature: null }, state)
|
|
119
|
+
applyHighlight(map, { feature: {} }, state)
|
|
120
|
+
expect(map.addLayer).not.toHaveBeenCalled()
|
|
121
|
+
applyHighlight(map, {
|
|
122
|
+
feature: { id: 1, type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [0, 0] }, layer: { id: 'l1' } },
|
|
123
|
+
layer: { id: 'l1', layout: { 'text-size': 12 }, paint: {} }
|
|
124
|
+
}, state)
|
|
125
|
+
expect(map.addLayer).toHaveBeenCalled()
|
|
126
|
+
expect(map.moveLayer).toHaveBeenCalledWith('highlight-l1')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('navigateToNextLabel all branches', () => {
|
|
130
|
+
expect(navigateToNextLabel('ArrowRight', { currentPixel: null })).toBeNull()
|
|
131
|
+
expect(navigateToNextLabel('ArrowRight', {
|
|
132
|
+
currentPixel: { x: 1, y: 1 }, labels: [{ x: 1, y: 1 }]
|
|
133
|
+
})).toBeNull()
|
|
134
|
+
const state = { currentPixel: { x: 0, y: 0 }, labels: [{ x: 0, y: 0 }, { x: 5, y: 5 }] }
|
|
135
|
+
spatialNavigate.mockReturnValue(-1) // out of range → use 0
|
|
136
|
+
expect(navigateToNextLabel('ArrowRight', state)).toBe(state.labels[1])
|
|
137
|
+
spatialNavigate.mockReturnValue(0) // valid index
|
|
138
|
+
expect(navigateToNextLabel('ArrowRight', state)).toBe(state.labels[1])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('createMapLabelNavigator', () => {
|
|
142
|
+
let map, layers
|
|
143
|
+
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
layers = [
|
|
146
|
+
{ id: 's1', type: 'symbol', layout: { 'symbol-placement': 'line', 'text-field': '{name}', 'text-size': 12 }, paint: {} },
|
|
147
|
+
{ id: 's2', type: 'symbol', layout: { 'text-field': '{name}', 'text-size': 14 }, paint: {} },
|
|
148
|
+
{ id: 'fill', type: 'fill', layout: {} }
|
|
149
|
+
]
|
|
150
|
+
map = {
|
|
151
|
+
getStyle: jest.fn(() => ({ layers })),
|
|
152
|
+
setLayoutProperty: jest.fn(),
|
|
153
|
+
setPaintProperty: jest.fn(),
|
|
154
|
+
getSource: jest.fn().mockReturnValueOnce(null).mockReturnValue({ setData: jest.fn() }),
|
|
155
|
+
addSource: jest.fn(),
|
|
156
|
+
getLayer: jest.fn(() => null),
|
|
157
|
+
removeLayer: jest.fn(),
|
|
158
|
+
addLayer: jest.fn(),
|
|
159
|
+
moveLayer: jest.fn(),
|
|
160
|
+
on: jest.fn(),
|
|
161
|
+
once: jest.fn(),
|
|
162
|
+
queryRenderedFeatures: jest.fn(() => []),
|
|
163
|
+
project: jest.fn(c => ({ x: c.lng ?? 0, y: c.lat ?? 0 })),
|
|
164
|
+
getCenter: jest.fn(() => ({ lng: 0, lat: 0 })),
|
|
165
|
+
getZoom: jest.fn(() => 10)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('init, full highlight lifecycle, and zoom handler branches', () => {
|
|
170
|
+
const eventBus = { on: jest.fn() }
|
|
171
|
+
const nav = createMapLabelNavigator(map, 'dark', { MAP_SET_STYLE: 'set-style' }, eventBus)
|
|
172
|
+
|
|
173
|
+
// Init: line-center placement, addSource, eventBus registration
|
|
174
|
+
expect(map.setLayoutProperty).toHaveBeenCalledWith('s1', 'symbol-placement', 'line-center')
|
|
175
|
+
expect(map.setLayoutProperty).not.toHaveBeenCalledWith('s2', 'symbol-placement', expect.anything())
|
|
176
|
+
expect(map.addSource).toHaveBeenCalled()
|
|
177
|
+
expect(eventBus.on).toHaveBeenCalledWith('set-style', expect.any(Function))
|
|
178
|
+
|
|
179
|
+
// No labels → null for both highlight functions
|
|
180
|
+
expect(nav.highlightLabelAtCenter()).toBeNull()
|
|
181
|
+
expect(nav.highlightNextLabel('ArrowRight')).toBeNull()
|
|
182
|
+
|
|
183
|
+
// Zoom handler: no active highlight → no-op
|
|
184
|
+
const zoomHandler = map.on.mock.calls.find(([e]) => e === 'zoom')[1]
|
|
185
|
+
map.setLayoutProperty.mockClear()
|
|
186
|
+
zoomHandler()
|
|
187
|
+
expect(map.setLayoutProperty).not.toHaveBeenCalled()
|
|
188
|
+
|
|
189
|
+
// One feat: highlightNext without currentPixel falls back to highlightCenter (lines 249-251)
|
|
190
|
+
const feat1 = { layer: { id: 's2' }, properties: { name: 'City1' }, geometry: { type: 'Point', coordinates: [1, 2] } }
|
|
191
|
+
map.queryRenderedFeatures.mockReturnValue([feat1])
|
|
192
|
+
expect(nav.highlightNextLabel('ArrowRight')).toContain('City1')
|
|
193
|
+
|
|
194
|
+
// Single label at currentPixel → navigateToNextLabel → null (lines 253-255)
|
|
195
|
+
expect(nav.highlightNextLabel('ArrowRight')).toBeNull()
|
|
196
|
+
|
|
197
|
+
// Zoom handler: active highlight → updates text-size
|
|
198
|
+
map.setLayoutProperty.mockClear()
|
|
199
|
+
zoomHandler()
|
|
200
|
+
expect(map.setLayoutProperty).toHaveBeenCalledWith('highlight-s2', 'text-size', expect.any(Number))
|
|
201
|
+
|
|
202
|
+
// Two feats: navigation path sets currentPixel + applies highlight (lines 256-258)
|
|
203
|
+
const feat2 = { layer: { id: 's2' }, properties: { name: 'City2' }, geometry: { type: 'Point', coordinates: [3, 4] } }
|
|
204
|
+
map.queryRenderedFeatures.mockReturnValue([feat1, feat2])
|
|
205
|
+
spatialNavigate.mockReturnValue(0)
|
|
206
|
+
expect(nav.highlightNextLabel('ArrowRight')).toContain('City2')
|
|
207
|
+
|
|
208
|
+
// clearHighlightedLabel removes layer
|
|
209
|
+
map.getLayer.mockReturnValue(true)
|
|
210
|
+
nav.clearHighlightedLabel()
|
|
211
|
+
expect(map.removeLayer).toHaveBeenCalledWith('highlight-s2')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('initLabelSource skips addSource when source exists; MAP_SET_STYLE triggers re-init', () => {
|
|
215
|
+
map.getSource.mockReset().mockReturnValue({ setData: jest.fn() }) // source always exists
|
|
216
|
+
const eventBus = { on: jest.fn() }
|
|
217
|
+
createMapLabelNavigator(map, 'light', { MAP_SET_STYLE: 'set-style' }, eventBus)
|
|
218
|
+
expect(map.addSource).not.toHaveBeenCalled()
|
|
219
|
+
|
|
220
|
+
// Fire MAP_SET_STYLE → styledata → idle → setLineCenterPlacement + initLabelSource
|
|
221
|
+
const styleHandler = eventBus.on.mock.calls[0][1]
|
|
222
|
+
styleHandler({ mapColorScheme: 'dark' })
|
|
223
|
+
const styleDataHandler = map.once.mock.calls.find(([e]) => e === 'styledata')[1]
|
|
224
|
+
styleDataHandler()
|
|
225
|
+
map.setLayoutProperty.mockClear()
|
|
226
|
+
const idleHandler = map.once.mock.calls.find(([e]) => e === 'idle')[1]
|
|
227
|
+
idleHandler()
|
|
228
|
+
expect(map.setLayoutProperty).toHaveBeenCalledWith('s1', 'symbol-placement', 'line-center')
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { cleanCanvas, applyPreventDefaultFix } from './maplibreFixes.js'
|
|
2
|
+
|
|
3
|
+
describe('cleanCanvas', () => {
|
|
4
|
+
it('removes and sets correct attributes on the canvas', () => {
|
|
5
|
+
const canvas = document.createElement('canvas')
|
|
6
|
+
canvas.setAttribute('role', 'presentation')
|
|
7
|
+
canvas.setAttribute('aria-label', 'map')
|
|
8
|
+
canvas.style.display = 'none'
|
|
9
|
+
|
|
10
|
+
const map = { getCanvas: () => canvas }
|
|
11
|
+
|
|
12
|
+
cleanCanvas(map)
|
|
13
|
+
|
|
14
|
+
expect(canvas.hasAttribute('role')).toBe(false)
|
|
15
|
+
expect(canvas.getAttribute('tabindex')).toBe('-1')
|
|
16
|
+
expect(canvas.hasAttribute('aria-label')).toBe(false)
|
|
17
|
+
expect(canvas.style.display).toBe('block')
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('applyPreventDefaultFix', () => {
|
|
22
|
+
let map, canvas, spy
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
canvas = document.createElement('div')
|
|
26
|
+
map = { getCanvas: () => canvas }
|
|
27
|
+
spy = jest.spyOn(Event.prototype, 'preventDefault')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
spy.mockRestore()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('skips preventDefault for non-cancelable touch events on the map', () => {
|
|
35
|
+
applyPreventDefaultFix(map)
|
|
36
|
+
const e = new Event('touchmove', { cancelable: false })
|
|
37
|
+
Object.defineProperty(e, 'target', { value: canvas })
|
|
38
|
+
e.preventDefault()
|
|
39
|
+
expect(spy).not.toHaveBeenCalled()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('calls original preventDefault for events outside the map', () => {
|
|
43
|
+
applyPreventDefaultFix(map)
|
|
44
|
+
const e = new Event('touchstart', { cancelable: false })
|
|
45
|
+
const outside = document.createElement('div')
|
|
46
|
+
Object.defineProperty(e, 'target', { value: outside })
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
expect(spy).toHaveBeenCalled()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('calls original preventDefault for cancelable touch events on the map', () => {
|
|
52
|
+
applyPreventDefaultFix(map)
|
|
53
|
+
const e = new Event('touchmove', { cancelable: true }) // cancelable true
|
|
54
|
+
Object.defineProperty(e, 'target', { value: canvas })
|
|
55
|
+
e.preventDefault()
|
|
56
|
+
expect(spy).toHaveBeenCalled()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('calls original preventDefault for non-touch events', () => {
|
|
60
|
+
applyPreventDefaultFix(map)
|
|
61
|
+
const e = new Event('mousedown', { cancelable: false }) // not touch
|
|
62
|
+
Object.defineProperty(e, 'target', { value: canvas })
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
expect(spy).toHaveBeenCalled()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { queryFeatures } from './queryFeatures.js'
|
|
2
|
+
|
|
3
|
+
const mockMap = {
|
|
4
|
+
project: (l) => ({ x: l[0], y: l[1] }),
|
|
5
|
+
unproject: (p) => ({ lng: p.x, lat: p.y }),
|
|
6
|
+
queryRenderedFeatures: () => []
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('queryFeatures coverage', () => {
|
|
10
|
+
test('all branches and sorting', () => {
|
|
11
|
+
// 1. Test empty case
|
|
12
|
+
expect(queryFeatures(mockMap, { x: 0, y: 0 })).toEqual([])
|
|
13
|
+
|
|
14
|
+
// 2. Data-driven loop for all geometry types and distance edge cases
|
|
15
|
+
const cases = [
|
|
16
|
+
{ type: 'Point', coords: [0, 0], p: { x: 3, y: 4 } },
|
|
17
|
+
{ type: 'LineString', coords: [[0, 0], [10, 0]], p: { x: 5, y: 5 } }, // t=0.5
|
|
18
|
+
{ type: 'LineString', coords: [[0, 0], [0, 0]], p: { x: 1, y: 1 } }, // l2=0
|
|
19
|
+
{ type: 'LineString', coords: [[0, 0], [10, 0]], p: { x: -5, y: 0 } }, // t<0
|
|
20
|
+
{ type: 'MultiPoint', coords: [[0, 0], [10, 10]], p: { x: 1, y: 0 } },
|
|
21
|
+
{ type: 'MultiLineString', coords: [[[0, 0], [10, 0]]], p: { x: 5, y: 1 } },
|
|
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: 20, y: 20 } }, // Outside
|
|
24
|
+
{ type: 'Unknown', coords: [], p: { x: 0, y: 0 } }
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
cases.forEach(({ type, coords, p }) => {
|
|
28
|
+
const feat = { id: type, layer: { id: 'L1' }, geometry: { type, coordinates: coords } }
|
|
29
|
+
const map = { ...mockMap, queryRenderedFeatures: () => [feat, feat] } // Hits deduplication
|
|
30
|
+
expect(queryFeatures(map, p).length).toBe(1)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// 3. Hits Line 144 (.sort) and property-based ID fallback
|
|
34
|
+
const f1 = {
|
|
35
|
+
properties: { key: 'a' },
|
|
36
|
+
layer: { id: 'layer-A' },
|
|
37
|
+
geometry: { type: 'Point', coordinates: [10, 10] }
|
|
38
|
+
}
|
|
39
|
+
const f2 = {
|
|
40
|
+
id: 'b',
|
|
41
|
+
layer: { id: 'layer-B' },
|
|
42
|
+
geometry: { type: 'Point', coordinates: [0, 0] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// map.queryRenderedFeatures returns multiple items to trigger .sort()
|
|
46
|
+
const sortMap = { ...mockMap, queryRenderedFeatures: () => [f1, f2] }
|
|
47
|
+
const result = queryFeatures(sortMap, { x: 0, y: 0 })
|
|
48
|
+
|
|
49
|
+
expect(result.length).toBe(2)
|
|
50
|
+
expect(result[0].layer.id).toBe('layer-A') // Sorted by layerStack index
|
|
51
|
+
|
|
52
|
+
// 4. Hit ray-casting intersect logic (Line 42 branch)
|
|
53
|
+
const polyFeat = {
|
|
54
|
+
layer: { id: 'L' },
|
|
55
|
+
geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 10], [0, 10], [0, 0]]] }
|
|
56
|
+
}
|
|
57
|
+
const rayMap = { ...mockMap, queryRenderedFeatures: () => [polyFeat] }
|
|
58
|
+
expect(queryFeatures(rayMap, { x: -1, y: 5 }).length).toBe(1)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -28,7 +28,8 @@ const formatDimension = (meters) => {
|
|
|
28
28
|
|
|
29
29
|
const miles = meters / METERS_PER_MILE
|
|
30
30
|
|
|
31
|
-
if
|
|
31
|
+
// Check if we are under the half-mile threshold
|
|
32
|
+
if (miles < MILE_THRESHOLD) {
|
|
32
33
|
return `${Math.round(meters)}m`
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -39,8 +40,7 @@ const formatDimension = (meters) => {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
const rounded = Math.round(miles)
|
|
42
|
-
|
|
43
|
-
return `${rounded} ${unit}`
|
|
43
|
+
return `${rounded} miles`
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// -----------------------------------------------------------------------------
|
|
@@ -191,5 +191,6 @@ export {
|
|
|
191
191
|
getCardinalMove,
|
|
192
192
|
spatialNavigate,
|
|
193
193
|
getResolution,
|
|
194
|
-
getPaddedBounds
|
|
194
|
+
getPaddedBounds,
|
|
195
|
+
formatDimension
|
|
195
196
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// tests/utils/spatial.test.js
|
|
2
|
+
import * as spatial from '../../src/utils/spatial.js'
|
|
3
|
+
|
|
4
|
+
jest.mock('geodesy/latlon-spherical.js', () =>
|
|
5
|
+
jest.fn().mockImplementation((lat, lng) => ({
|
|
6
|
+
distanceTo: jest.fn((other) => Math.abs(lat - (other.lat || 0)) * 1609 + Math.abs(lng - (other.lon || 0)) * 1609)
|
|
7
|
+
}))
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
describe('spatial utils', () => {
|
|
11
|
+
|
|
12
|
+
test('formatDimension hits all branches', () => {
|
|
13
|
+
// < 0.5 miles
|
|
14
|
+
expect(spatial.formatDimension(500)).toMatch(/m$/)
|
|
15
|
+
|
|
16
|
+
// Singular mile (exactly 1.0) - hits value === 1 branch
|
|
17
|
+
expect(spatial.formatDimension(1609.344)).toBe('1 mile')
|
|
18
|
+
|
|
19
|
+
// 5 miles
|
|
20
|
+
const metersSmallMiles = 5 * 1609.344
|
|
21
|
+
expect(spatial.formatDimension(metersSmallMiles)).toMatch(/^5\s*miles$/)
|
|
22
|
+
|
|
23
|
+
// >= WHOLE_MILE_THRESHOLD
|
|
24
|
+
const metersLarge = 15 * 1609.344
|
|
25
|
+
expect(spatial.formatDimension(metersLarge)).toBe('15 miles')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('array bounds triggers all branches', () => {
|
|
29
|
+
const dims = spatial.getAreaDimensions([[0, 0], [0.01, 0.02]])
|
|
30
|
+
expect(dims).toMatch(/by/)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('LngLatBounds object triggers getWest/getSouth branch', () => {
|
|
34
|
+
const fakeBounds = { getWest: () => 0, getSouth: () => 0, getEast: () => 0.01, getNorth: () => 0.02 }
|
|
35
|
+
expect(spatial.getAreaDimensions(fakeBounds)).toMatch(/by/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('invalid bounds returns empty string', () => {
|
|
39
|
+
expect(spatial.getAreaDimensions(null)).toBe('')
|
|
40
|
+
expect(spatial.getAreaDimensions([])).toBe('')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('north/south/east/west moves', () => {
|
|
44
|
+
expect(spatial.getCardinalMove([0,0],[0,0.5])).toMatch(/north/)
|
|
45
|
+
expect(spatial.getCardinalMove([0,0],[0,-0.5])).toMatch(/south/)
|
|
46
|
+
expect(spatial.getCardinalMove([0,0],[0.5,0])).toMatch(/east/)
|
|
47
|
+
expect(spatial.getCardinalMove([0,0],[-0.5,0])).toMatch(/west/)
|
|
48
|
+
expect(spatial.getCardinalMove([0,0],[0.5,0.5])).toMatch(/north.*east|east.*north/)
|
|
49
|
+
expect(spatial.getCardinalMove([0,0],[0.00001,0.00001])).toBe('')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('spatialNavigate all directions and fallback', () => {
|
|
53
|
+
const pixels = [[0,0],[0,-1],[1,0],[0,1],[-1,0]]
|
|
54
|
+
expect(spatial.spatialNavigate('ArrowUp',[0,0],pixels)).toBe(1)
|
|
55
|
+
expect(spatial.spatialNavigate('ArrowDown',[0,0],pixels)).toBe(3)
|
|
56
|
+
expect(spatial.spatialNavigate('ArrowLeft',[0,0],pixels)).toBe(4)
|
|
57
|
+
expect(spatial.spatialNavigate('ArrowRight',[0,0],pixels)).toBe(2)
|
|
58
|
+
expect(spatial.spatialNavigate('InvalidDir',[0,0],pixels)).toBe(0)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('spatialNavigate finds closer candidates (hits dist < minDist)', () => {
|
|
62
|
+
const start = [0,0]
|
|
63
|
+
const pixels = [[0,0],[10,0],[2,0]]
|
|
64
|
+
expect(spatial.spatialNavigate('ArrowRight', start, pixels)).toBe(2)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('spatialNavigate skips farther candidate (dist >= minDist false branch)', () => {
|
|
68
|
+
// Closer candidate first → second candidate fails dist < minDist
|
|
69
|
+
const pixels = [[0,0],[2,0],[10,0]]
|
|
70
|
+
expect(spatial.spatialNavigate('ArrowRight', [0,0], pixels)).toBe(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('spatialNavigate diagonal with dx>dy', () => {
|
|
74
|
+
const start = [0,0]
|
|
75
|
+
const pixels = [[0,0],[3,1],[1,0]] // dx>dy
|
|
76
|
+
expect(spatial.spatialNavigate('ArrowRight', start, pixels)).toBe(2)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('getResolution returns positive value', () => {
|
|
80
|
+
expect(spatial.getResolution({lat:0},1)).toBeGreaterThan(0)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('getPaddedBounds returns bounds', () => {
|
|
84
|
+
const map = {
|
|
85
|
+
getContainer: () => ({ getBoundingClientRect: () => ({ width:100,height:200 }) }),
|
|
86
|
+
getPadding: () => ({ top:1,right:2,bottom:3,left:4 }),
|
|
87
|
+
unproject: p => ({ x:p[0], y:p[1] })
|
|
88
|
+
}
|
|
89
|
+
const LngLatBounds = function(sw,ne){
|
|
90
|
+
return {sw,ne}
|
|
91
|
+
}
|
|
92
|
+
const bounds = spatial.getPaddedBounds(LngLatBounds,map)
|
|
93
|
+
expect(bounds.sw).toBeDefined()
|
|
94
|
+
expect(bounds.ne).toBeDefined()
|
|
95
|
+
})
|
|
96
|
+
})
|