@defra/interactive-map 0.0.8-alpha → 0.0.10-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.
Files changed (104) hide show
  1. package/DOCS_README.md +39 -0
  2. package/dist/esm/im-core.js +1 -0
  3. package/dist/esm/im-shell.js +1 -0
  4. package/dist/esm/index.js +1 -2
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/button-definition.md +104 -3
  8. package/docs/api.md +22 -2
  9. package/docs/architecture/architecture-diagrams.md +1 -3
  10. package/docs/architecture/diagrams-viewer.mdx +12 -0
  11. package/docs/getting-started.md +78 -8
  12. package/docs/govuk-prototype.md +23 -0
  13. package/docs/index.md +23 -0
  14. package/docusaurus.config.cjs +106 -0
  15. package/mise.toml +2 -0
  16. package/package.json +51 -27
  17. package/plugins/beta/datasets/dist/css/index.css +50 -1
  18. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -0
  19. package/plugins/beta/datasets/dist/esm/index.js +1 -2
  20. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -0
  21. package/plugins/beta/draw-es/dist/esm/index.js +1 -2
  22. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -3
  23. package/plugins/beta/draw-es/src/events.js +2 -2
  24. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -0
  25. package/plugins/beta/draw-ml/dist/esm/index.js +1 -2
  26. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  27. package/plugins/beta/draw-ml/src/api/newLine.js +2 -2
  28. package/plugins/beta/draw-ml/src/api/newPolygon.js +2 -2
  29. package/plugins/beta/draw-ml/src/events.js +18 -10
  30. package/plugins/beta/frame/dist/css/index.css +11 -1
  31. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -0
  32. package/plugins/beta/frame/dist/esm/index.js +1 -2
  33. package/plugins/beta/map-styles/dist/css/index.css +79 -1
  34. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -0
  35. package/plugins/beta/map-styles/dist/esm/index.js +1 -2
  36. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -0
  37. package/plugins/beta/scale-bar/dist/esm/index.js +1 -2
  38. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -0
  39. package/plugins/beta/use-location/dist/esm/index.js +1 -2
  40. package/plugins/beta/use-location/dist/umd/index.js +1 -1
  41. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -0
  42. package/plugins/interact/dist/esm/index.js +1 -2
  43. package/plugins/search/dist/esm/im-search-plugin.js +1 -0
  44. package/plugins/search/dist/esm/index.js +1 -2
  45. package/plugins/search/src/Search.test.jsx +170 -0
  46. package/plugins/search/src/components/CloseButton/CloseButton.test.jsx +67 -0
  47. package/plugins/search/src/components/Form/Form.test.jsx +158 -0
  48. package/plugins/search/src/components/OpenButton/OpenButton.test.jsx +47 -0
  49. package/plugins/search/src/components/Suggestions/Suggestions.module.scss +1 -1
  50. package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +79 -0
  51. package/plugins/search/src/datasets.test.js +46 -0
  52. package/plugins/search/src/events/fetchSuggestions.test.js +212 -0
  53. package/plugins/search/src/events/formHandlers.test.js +232 -0
  54. package/plugins/search/src/events/index.test.js +118 -0
  55. package/plugins/search/src/events/inputHandlers.test.js +104 -0
  56. package/plugins/search/src/events/suggestionHandlers.test.js +166 -0
  57. package/plugins/search/src/index.test.js +47 -0
  58. package/plugins/search/src/reducer.test.js +80 -0
  59. package/plugins/search/src/search.scss +1 -1
  60. package/plugins/search/src/utils/parseOsNamesResults.js +2 -1
  61. package/plugins/search/src/utils/parseOsNamesResults.test.js +140 -0
  62. package/plugins/search/src/utils/updateMap.test.js +52 -0
  63. package/providers/beta/esri/dist/css/index.css +30 -1
  64. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -0
  65. package/providers/beta/esri/dist/esm/index.js +1 -2
  66. package/providers/beta/open-names/dist/esm/im-reverse-geocode.js +1 -0
  67. package/providers/beta/open-names/dist/esm/index.js +1 -2
  68. package/providers/beta/open-names/src/utils/mapToLocationModel.test.js +61 -0
  69. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -0
  70. package/providers/maplibre/dist/esm/index.js +1 -2
  71. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  72. package/providers/maplibre/src/appEvents.test.js +44 -0
  73. package/providers/maplibre/src/index.test.js +60 -0
  74. package/providers/maplibre/src/mapEvents.test.js +115 -0
  75. package/providers/maplibre/src/maplibreProvider.test.js +205 -0
  76. package/providers/maplibre/src/utils/calculateLinearTextSize.test.js +31 -0
  77. package/providers/maplibre/src/utils/detectWebgl.test.js +63 -0
  78. package/providers/maplibre/src/utils/highlightFeatures.test.js +126 -0
  79. package/providers/maplibre/src/utils/labels.js +1 -3
  80. package/providers/maplibre/src/utils/labels.test.js +231 -0
  81. package/providers/maplibre/src/utils/maplibreFixes.test.js +66 -0
  82. package/providers/maplibre/src/utils/queryFeatures.test.js +60 -0
  83. package/providers/maplibre/src/utils/spatial.js +5 -4
  84. package/providers/maplibre/src/utils/spatial.test.js +96 -0
  85. package/rollup.esm.mjs +288 -0
  86. package/src/App/store/appActionsMap.js +1 -1
  87. package/src/InteractiveMap/InteractiveMap.js +3 -2
  88. package/webpack.dev.mjs +9 -1
  89. package/webpack.prod.mjs +8 -1
  90. package/webpack.umd.mjs +1 -2
  91. package/dist/esm/index.js.LICENSE.txt +0 -1
  92. package/plugins/beta/datasets/dist/esm/index.js.LICENSE.txt +0 -1
  93. package/plugins/beta/draw-es/dist/esm/index.js.LICENSE.txt +0 -1
  94. package/plugins/beta/draw-ml/dist/esm/index.js.LICENSE.txt +0 -1
  95. package/plugins/beta/frame/dist/esm/index.js.LICENSE.txt +0 -1
  96. package/plugins/beta/map-styles/dist/esm/index.js.LICENSE.txt +0 -1
  97. package/plugins/beta/scale-bar/dist/esm/index.js.LICENSE.txt +0 -1
  98. package/plugins/beta/use-location/dist/esm/index.js.LICENSE.txt +0 -1
  99. package/plugins/interact/dist/esm/index.js.LICENSE.txt +0 -1
  100. package/plugins/search/dist/esm/index.js.LICENSE.txt +0 -1
  101. package/providers/beta/esri/dist/esm/index.js.LICENSE.txt +0 -1
  102. package/providers/beta/open-names/dist/esm/index.js.LICENSE.txt +0 -1
  103. package/providers/maplibre/dist/esm/index.js.LICENSE.txt +0 -6
  104. package/webpack.esm.mjs +0 -154
@@ -0,0 +1,31 @@
1
+ import { calculateLinearTextSize } from './calculateLinearTextSize.js'
2
+
3
+ describe('calculateLinearTextSize', () => {
4
+ it('returns 0 if stops is empty', () => {
5
+ expect(calculateLinearTextSize({ stops: [] }, 5)).toBe(0)
6
+ })
7
+
8
+ it('returns single stop value if stops has one entry', () => {
9
+ expect(calculateLinearTextSize({ stops: [[3, 10]] }, 5)).toBe(10)
10
+ })
11
+
12
+ it('returns lower stop if zoom below first stop', () => {
13
+ expect(calculateLinearTextSize({ stops: [[3, 10], [6, 20]] }, 2)).toBe(10)
14
+ })
15
+
16
+ it('returns upper stop if zoom above last stop', () => {
17
+ expect(calculateLinearTextSize({ stops: [[3, 10], [6, 20]] }, 7)).toBe(20)
18
+ })
19
+
20
+ it('interpolates between stops for zoom in range', () => {
21
+ expect(calculateLinearTextSize({ stops: [[3, 10], [6, 20]] }, 4.5)).toBe(15)
22
+ })
23
+
24
+ it('works with multiple stops', () => {
25
+ const expr = { stops: [[0, 5], [5, 15], [10, 25]] }
26
+ expect(calculateLinearTextSize(expr, -1)).toBe(5) // below first
27
+ expect(calculateLinearTextSize(expr, 12)).toBe(25) // above last
28
+ expect(calculateLinearTextSize(expr, 2.5)).toBe(10) // between first two
29
+ expect(calculateLinearTextSize(expr, 7.5)).toBe(20) // between last two
30
+ })
31
+ })
@@ -0,0 +1,63 @@
1
+ import { getWebGL } from './detectWebgl.js'
2
+
3
+ describe('getWebGL', () => {
4
+ const originalWebGL = window.WebGLRenderingContext
5
+ const originalCreateElement = document.createElement
6
+
7
+ afterEach(() => {
8
+ window.WebGLRenderingContext = originalWebGL
9
+ document.createElement = originalCreateElement
10
+ jest.restoreAllMocks()
11
+ })
12
+
13
+ it('returns disabled if WebGL is not supported', () => {
14
+ window.WebGLRenderingContext = undefined
15
+ expect(getWebGL(['webgl'])).toEqual({
16
+ isEnabled: false,
17
+ error: 'WebGL is not supported'
18
+ })
19
+ })
20
+
21
+ it('returns enabled if WebGL context is created successfully', () => {
22
+ window.WebGLRenderingContext = class {}
23
+ const fakeContext = { getParameter: () => true }
24
+ document.createElement = jest.fn(() => ({
25
+ getContext: jest.fn(() => fakeContext)
26
+ }))
27
+ expect(getWebGL(['webgl'])).toEqual({ isEnabled: true })
28
+ })
29
+
30
+ it('returns disabled if WebGL is supported but context fails', () => {
31
+ window.WebGLRenderingContext = class {}
32
+ document.createElement = jest.fn(() => ({
33
+ getContext: jest.fn(() => null)
34
+ }))
35
+ expect(getWebGL(['webgl'])).toEqual({
36
+ isEnabled: false,
37
+ error: 'WebGL is supported, but disabled'
38
+ })
39
+ })
40
+
41
+ it('tries multiple context names and succeeds on second', () => {
42
+ window.WebGLRenderingContext = class {}
43
+ let call = 0
44
+ document.createElement = jest.fn(() => ({
45
+ getContext: jest.fn(() => {
46
+ call++
47
+ return call === 2 ? { getParameter: () => true } : null
48
+ })
49
+ }))
50
+ expect(getWebGL(['webgl1', 'webgl2'])).toEqual({ isEnabled: true })
51
+ })
52
+
53
+ it('catches errors from getContext and continues', () => {
54
+ window.WebGLRenderingContext = class {}
55
+ document.createElement = jest.fn(() => ({
56
+ getContext: jest.fn(() => { throw new Error('fail') })
57
+ }))
58
+ expect(getWebGL(['webgl', 'webgl2'])).toEqual({
59
+ isEnabled: false,
60
+ error: 'WebGL is supported, but disabled'
61
+ })
62
+ })
63
+ })
@@ -0,0 +1,126 @@
1
+ import { updateHighlightedFeatures } from './highlightFeatures.js'
2
+
3
+ describe('Highlighting Utils', () => {
4
+ let map
5
+ const LngLatBounds = function() {
6
+ this.coords = []
7
+ this.extend = (c) => this.coords.push(c)
8
+ this.getWest = () => Math.min(...this.coords.map(c => c[0]))
9
+ this.getSouth = () => Math.min(...this.coords.map(c => c[1]))
10
+ this.getEast = () => Math.max(...this.coords.map(c => c[0]))
11
+ this.getNorth = () => Math.max(...this.coords.map(c => c[1]))
12
+ }
13
+
14
+ beforeEach(() => {
15
+ map = {
16
+ _highlightedSources: new Set(['stale']),
17
+ getLayer: jest.fn(),
18
+ addLayer: jest.fn(),
19
+ setFilter: jest.fn(),
20
+ setPaintProperty: jest.fn(),
21
+ queryRenderedFeatures: jest.fn()
22
+ }
23
+ })
24
+
25
+ test('All branches', () => {
26
+ // Coverage for Line 93: Null map check
27
+ expect(updateHighlightedFeatures({ map: null })).toBeNull()
28
+
29
+ map.getLayer.mockImplementation((id) => {
30
+ if (id.includes('stale')) return true // Coverage for Line 49
31
+ if (id === 'l1') return { source: 's1', type: 'fill' }
32
+ if (id === 'l2') return { source: 's2', type: 'line' }
33
+ if (id === 'highlight-s2-fill') return true // Coverage for Line 124
34
+ return null // Coverage for Line 13
35
+ })
36
+
37
+ const selectedFeatures = [
38
+ // Coverage for Lines 37-40: Polygon & MultiPolygon checks
39
+ { featureId: 1, layerId: 'l1', geometry: { type: 'Polygon' } },
40
+ { featureId: 2, layerId: 'l1', geometry: { type: 'MultiPolygon' } },
41
+ // Coverage for Line 13: Invalid layer
42
+ { featureId: 3, layerId: 'invalid' },
43
+ // Coverage for Line 116: idProperty exists
44
+ { featureId: 4, layerId: 'l2', idProperty: 'customId', geometry: { type: 'Point' } }
45
+ ]
46
+
47
+ const stylesMap = {
48
+ l1: { stroke: 'red', fill: 'blue' },
49
+ l2: { stroke: 'green' }
50
+ }
51
+
52
+ // Coverage for Lines 78-80: Recursive coordinate handling (numbers vs arrays)
53
+ map.queryRenderedFeatures.mockReturnValue([
54
+ { id: 1, geometry: { coordinates: [10, 10] } }, // Simple point
55
+ { id: 2, properties: { customId: 4 }, geometry: { coordinates: [[0, 0], [5, 5]] } } // Nested
56
+ ])
57
+
58
+ const bounds = updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, stylesMap })
59
+
60
+ // Line 13 verify: map.getLayer returned null and function returned early
61
+ // Line 49-50 verify: Stale sources filtered out
62
+ expect(map.setFilter).toHaveBeenCalledWith('highlight-stale-fill', ['==', 'id', ''])
63
+
64
+ // Line 124 verify: Clear fill highlight when switching to line geometry
65
+ expect(map.setFilter).toHaveBeenCalledWith('highlight-s2-fill', ['==', 'id', ''])
66
+
67
+ // Line 116 verify: Using ['get', idProperty]
68
+ expect(map.setFilter).toHaveBeenCalledWith('highlight-s2-line', expect.arrayContaining([['get', 'customId']]))
69
+
70
+ // Line 80-82 verify: Recursive LngLatBounds logic
71
+ expect(bounds).toEqual([0, 0, 10, 10])
72
+ })
73
+
74
+ test('undefined _highlightedSources falls back to empty set; line geom skips absent fill layer', () => {
75
+ // line 93: || new Set() fallback; line 124 false: no pre-existing fill to clear
76
+ map._highlightedSources = undefined
77
+ map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null)
78
+ map.queryRenderedFeatures.mockReturnValue([])
79
+ updateHighlightedFeatures({ LngLatBounds, map,
80
+ selectedFeatures: [{ featureId: 1, layerId: 'l1' }],
81
+ stylesMap: { l1: { stroke: 'red' } }
82
+ })
83
+ expect(map.setFilter).not.toHaveBeenCalledWith('highlight-s1-fill', expect.anything())
84
+ })
85
+
86
+ test('persistent source skips cleanup; missing stale layers skip setFilter', () => {
87
+ // line 37 false: src IS in currentSources; line 41 false: getLayer returns null for stale layers
88
+ map._highlightedSources = new Set(['stale', 's1'])
89
+ map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null)
90
+ map.queryRenderedFeatures.mockReturnValue([])
91
+ updateHighlightedFeatures({ LngLatBounds, map,
92
+ selectedFeatures: [{ featureId: 1, layerId: 'l1' }],
93
+ stylesMap: { l1: { stroke: 'red' } }
94
+ })
95
+ expect(map.setFilter).not.toHaveBeenCalledWith(expect.stringContaining('stale'), expect.anything())
96
+ })
97
+
98
+ test('reuses existing highlight layer; new layer spreads sourceLayer', () => {
99
+ // line 50 false: getLayer truthy → skip addLayer for s1
100
+ // line 55: srcLayer truthy → 'source-layer' spread in addLayer for s2
101
+ map.getLayer.mockImplementation(id => {
102
+ if (id === 'l1') return { source: 's1', type: 'line' }
103
+ if (id === 'l2') return { source: 's2', type: 'line', sourceLayer: 'tiles' }
104
+ if (id === 'highlight-s1-line') return true
105
+ return null
106
+ })
107
+ map.queryRenderedFeatures.mockReturnValue([])
108
+ updateHighlightedFeatures({ LngLatBounds, map,
109
+ selectedFeatures: [
110
+ { featureId: 1, layerId: 'l1' },
111
+ { featureId: 2, layerId: 'l2' }
112
+ ],
113
+ stylesMap: { l1: { stroke: 'blue' }, l2: { stroke: 'green' } }
114
+ })
115
+ expect(map.addLayer).toHaveBeenCalledTimes(1)
116
+ expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ 'source-layer': 'tiles' }))
117
+ })
118
+
119
+ test('Empty features coverage', () => {
120
+ // Coverage for Line 72: empty renderedFeatures
121
+ map.getLayer.mockReturnValue({ source: 's1', type: 'line' })
122
+ map.queryRenderedFeatures.mockReturnValue([])
123
+ const res = updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures: [], stylesMap: {} })
124
+ expect(res).toBeNull()
125
+ })
126
+ })
@@ -234,9 +234,7 @@ export function createMapLabelNavigator(map, mapColorScheme, events, eventBus) {
234
234
  }
235
235
  const centerPoint = map.project(map.getCenter())
236
236
  const closest = findClosestLabel(state.labels, centerPoint)
237
- if (closest) {
238
- state.currentPixel = { x: closest.x, y: closest.y }
239
- }
237
+ state.currentPixel = { x: closest.x, y: closest.y }
240
238
  applyHighlight(map, closest, state)
241
239
  return `${closest.text} (${closest.layer.id})`
242
240
  }
@@ -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 (miles < MILE_THRESHOLD / METERS_PER_MILE) {
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
- const unit = rounded === 1 ? 'mile' : 'miles'
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
  }