@defra/interactive-map 0.0.4-alpha → 0.0.6-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 (203) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/index.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api.md +8 -0
  7. package/package.json +3 -2
  8. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +2 -1
  9. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js.LICENSE.txt +1 -0
  10. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +2 -1
  11. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js.LICENSE.txt +1 -0
  12. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  13. package/plugins/beta/datasets/src/datasets.js +55 -2
  14. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +206 -0
  15. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +38 -0
  16. package/plugins/beta/datasets/src/handleSetMapStyle.js +8 -1
  17. package/plugins/beta/datasets/src/mapLayers.js +41 -3
  18. package/plugins/beta/datasets/src/utils/bbox.js +113 -2
  19. package/plugins/beta/draw-ml/dist/css/index.css +1 -1
  20. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  22. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  23. package/plugins/beta/draw-ml/src/api/addFeature.js +15 -3
  24. package/plugins/beta/draw-ml/src/api/editFeature.js +16 -5
  25. package/plugins/beta/draw-ml/src/api/newLine.js +32 -7
  26. package/plugins/beta/draw-ml/src/api/newPolygon.js +33 -8
  27. package/plugins/beta/draw-ml/src/defaults.js +1 -0
  28. package/plugins/beta/draw-ml/src/draw.scss +23 -0
  29. package/plugins/beta/draw-ml/src/events.js +55 -113
  30. package/plugins/beta/draw-ml/src/manifest.js +64 -38
  31. package/plugins/beta/draw-ml/src/mapboxSnap.js +16 -12
  32. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +4 -3
  33. package/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js +135 -0
  34. package/plugins/beta/draw-ml/src/modes/editVertex/helpers.js +2 -0
  35. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +134 -0
  36. package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +133 -0
  37. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +141 -0
  38. package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +121 -0
  39. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +51 -406
  40. package/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js +49 -0
  41. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  42. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  43. package/plugins/beta/frame/src/Frame.jsx +1 -1
  44. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  45. package/plugins/beta/map-styles/dist/esm/index.js +1 -1
  46. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  47. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  48. package/plugins/beta/map-styles/src/mapStyles.scss +4 -1
  49. package/plugins/interact/dist/css/index.css +1 -1
  50. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  51. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  52. package/plugins/interact/dist/umd/index.js +1 -1
  53. package/plugins/interact/src/InteractInit.jsx +1 -1
  54. package/plugins/interact/src/defaults.js +1 -0
  55. package/plugins/interact/src/events.js +37 -52
  56. package/plugins/interact/src/events.test.js +35 -0
  57. package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
  58. package/plugins/interact/src/hooks/useInteractionHandlers.js +80 -48
  59. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +64 -8
  60. package/plugins/interact/src/interact.scss +6 -0
  61. package/plugins/interact/src/reducer.js +25 -27
  62. package/plugins/interact/src/reducer.test.js +19 -6
  63. package/plugins/interact/src/utils/featureQueries.js +2 -2
  64. package/plugins/interact/src/utils/spatial.js +99 -0
  65. package/plugins/interact/src/utils/spatial.test.js +100 -0
  66. package/plugins/search/dist/css/index.css +1 -1
  67. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  68. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  69. package/plugins/search/dist/umd/index.js +1 -1
  70. package/plugins/search/src/Search.jsx +2 -2
  71. package/plugins/search/src/components/Form/Form.jsx +1 -1
  72. package/plugins/search/src/components/Suggestions/Suggestions.jsx +5 -5
  73. package/plugins/search/src/datasets.js +1 -1
  74. package/plugins/search/src/defaults.js +3 -0
  75. package/plugins/search/src/events/fetchSuggestions.js +48 -53
  76. package/plugins/search/src/events/formHandlers.js +3 -2
  77. package/plugins/search/src/events/index.js +1 -1
  78. package/plugins/search/src/events/inputHandlers.js +3 -1
  79. package/plugins/search/src/events/suggestionHandlers.js +12 -3
  80. package/plugins/search/src/search.scss +4 -1
  81. package/plugins/search/src/utils/parseOsNamesResults.js +12 -12
  82. package/providers/beta/esri/src/esriProvider.js +1 -1
  83. package/providers/beta/esri/src/index.js +1 -1
  84. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  85. package/providers/maplibre/dist/esm/index.js +1 -1
  86. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  87. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  88. package/providers/maplibre/dist/umd/index.js +1 -1
  89. package/providers/maplibre/src/defaults.js +3 -2
  90. package/providers/maplibre/src/index.js +22 -19
  91. package/providers/maplibre/src/maplibreProvider.js +13 -11
  92. package/providers/maplibre/src/utils/detectWebgl.js +1 -2
  93. package/providers/maplibre/src/utils/highlightFeatures.js +78 -67
  94. package/providers/maplibre/src/utils/labels.js +174 -120
  95. package/providers/maplibre/src/utils/maplibreFixes.js +2 -2
  96. package/providers/maplibre/src/utils/queryFeatures.js +146 -0
  97. package/providers/maplibre/src/utils/spatial.js +14 -17
  98. package/sonar-project.properties +39 -2
  99. package/src/App/components/Actions/Actions.jsx +2 -2
  100. package/src/App/components/Actions/Actions.module.scss +2 -2
  101. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +3 -1
  102. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +9 -0
  103. package/src/App/components/Logo/Logo.jsx +1 -1
  104. package/src/App/components/MapButton/MapButton.jsx +217 -41
  105. package/src/App/components/MapButton/MapButton.module.scss +32 -21
  106. package/src/App/components/MapButton/MapButton.test.jsx +128 -84
  107. package/src/App/components/Markers/Markers.jsx +1 -1
  108. package/src/App/components/Markers/Markers.module.scss +0 -5
  109. package/src/App/components/Panel/Panel.jsx +75 -45
  110. package/src/App/components/Panel/Panel.module.scss +18 -24
  111. package/src/App/components/Panel/Panel.test.jsx +18 -0
  112. package/src/App/components/PopupMenu/PopupMenu.jsx +246 -0
  113. package/src/App/components/PopupMenu/PopupMenu.module.scss +70 -0
  114. package/src/App/components/PopupMenu/PopupMenu.test.jsx +304 -0
  115. package/src/App/components/Tooltip/Tooltip.jsx +4 -2
  116. package/src/App/components/Tooltip/getTooltipPosition.js +1 -1
  117. package/src/App/components/Viewport/MapController.jsx +1 -1
  118. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  119. package/src/App/components/Viewport/Viewport.jsx +2 -2
  120. package/src/App/components/Viewport/Viewport.module.scss +2 -2
  121. package/src/App/controls/keyboardActions.js +3 -2
  122. package/src/App/controls/keyboardShortcuts.js +4 -2
  123. package/src/App/hooks/useButtonStateEvaluator.js +58 -45
  124. package/src/App/hooks/useButtonStateEvaluator.test.js +36 -3
  125. package/src/App/hooks/useCrossHairAPI.js +49 -47
  126. package/src/App/hooks/useFocusVisible.js +2 -2
  127. package/src/App/hooks/useInterfaceAPI.js +16 -4
  128. package/src/App/hooks/useKeyboardHint.js +1 -1
  129. package/src/App/hooks/useKeyboardShortcuts.js +4 -2
  130. package/src/App/hooks/useMapAnnouncements.js +34 -32
  131. package/src/App/hooks/useMapEvents.js +12 -1
  132. package/src/App/hooks/useMapProviderOverrides.js +3 -3
  133. package/src/App/hooks/useMapStateSync.js +7 -1
  134. package/src/App/hooks/useMapURLSync.js +6 -1
  135. package/src/App/hooks/useMarkersAPI.js +88 -50
  136. package/src/App/hooks/useMediaQueryDispatch.test.js +48 -0
  137. package/src/App/hooks/useModalPanelBehaviour.js +46 -40
  138. package/src/App/hooks/useResizeObserver.js +2 -2
  139. package/src/App/initialiseApp.js +45 -38
  140. package/src/App/layout/Layout.jsx +6 -4
  141. package/src/App/layout/layout.module.scss +13 -17
  142. package/src/App/registry/keyboardShortcutRegistry.js +12 -2
  143. package/src/App/registry/keyboardShortcutRegistry.test.js +28 -0
  144. package/src/App/registry/mergeManifests.js +1 -1
  145. package/src/App/registry/panelRegistry.js +1 -1
  146. package/src/App/registry/panelRegistry.test.js +30 -1
  147. package/src/App/registry/pluginRegistry.js +14 -51
  148. package/src/App/registry/pluginRegistry.test.js +54 -6
  149. package/src/App/renderer/HtmlElementHost.jsx +186 -0
  150. package/src/App/renderer/HtmlElementHost.test.jsx +231 -0
  151. package/src/App/renderer/mapButtons.js +11 -8
  152. package/src/App/renderer/mapButtons.test.js +6 -4
  153. package/src/App/renderer/mapControls.js +6 -0
  154. package/src/App/renderer/mapControls.test.js +10 -2
  155. package/src/App/renderer/mapPanels.js +39 -26
  156. package/src/App/renderer/mapPanels.test.js +29 -0
  157. package/src/App/renderer/pluginWrapper.test.js +28 -0
  158. package/src/App/renderer/slotHelpers.js +60 -0
  159. package/src/App/renderer/slotHelpers.test.js +82 -0
  160. package/src/App/store/AppProvider.jsx +21 -18
  161. package/src/App/store/AppProvider.test.jsx +40 -0
  162. package/src/App/store/PluginProvider.jsx +7 -5
  163. package/src/App/store/appActionsMap.js +28 -1
  164. package/src/App/store/appActionsMap.test.js +26 -0
  165. package/src/App/store/appReducer.js +1 -0
  166. package/src/App/store/mapActionsMap.js +12 -24
  167. package/src/App/store/mapReducer.js +1 -1
  168. package/src/InteractiveMap/InteractiveMap.js +79 -6
  169. package/src/InteractiveMap/InteractiveMap.test.js +164 -2
  170. package/src/InteractiveMap/behaviourController.js +12 -2
  171. package/src/InteractiveMap/behaviourController.test.js +82 -1
  172. package/src/InteractiveMap/deviceChecker.js +1 -1
  173. package/src/InteractiveMap/deviceChecker.test.js +2 -2
  174. package/src/InteractiveMap/historyManager.js +41 -5
  175. package/src/InteractiveMap/historyManager.test.js +37 -6
  176. package/src/InteractiveMap/polyfills.js +39 -0
  177. package/src/InteractiveMap/polyfills.test.js +127 -0
  178. package/src/config/appConfig.js +5 -5
  179. package/src/config/appConfig.test.js +107 -42
  180. package/src/config/defaults.js +1 -0
  181. package/src/index.umd.js +1 -1
  182. package/src/scss/main.scss +1 -0
  183. package/src/scss/settings/_colors.scss +1 -1
  184. package/src/scss/settings/_dimensions.scss +4 -0
  185. package/src/services/eventBus.test.js +24 -1
  186. package/src/test-utils.js +1 -0
  187. package/src/types.js +5 -0
  188. package/src/utils/detectBreakpoint.js +78 -77
  189. package/src/utils/detectBreakpoint.test.js +50 -4
  190. package/src/utils/detectInterfaceType.js +6 -5
  191. package/src/utils/getIsFullscreen.js +3 -1
  192. package/src/utils/getSafeZoneInset.js +23 -7
  193. package/src/utils/getSafeZoneInset.test.js +18 -0
  194. package/src/utils/mapStateSync.js +3 -3
  195. package/src/utils/queryString.js +1 -1
  196. package/src/utils/stringToKebab.js +1 -1
  197. package/src/utils/toggleInertElements.js +37 -12
  198. package/webpack.dev.mjs +3 -4
  199. package/plugins/interact/src/utils/turfHelpers.js +0 -30
  200. package/plugins/interact/src/utils/turfHelpers.test.js +0 -30
  201. package/plugins/search/src/utils/fetchDataset.js +0 -23
  202. package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +0 -1
  203. package/providers/maplibre/dist/umd/im-maplibre-legacy-framework.js +0 -1
@@ -2,8 +2,9 @@ import { spatialNavigate } from './spatial.js'
2
2
  import { calculateLinearTextSize } from './calculateLinearTextSize.js'
3
3
 
4
4
  const HIGHLIGHT_SCALE_FACTOR = 1.5
5
+ const HIGHLIGHT_LABEL_SOURCE = 'highlighted-label'
5
6
 
6
- function getGeometryCenter(geometry) {
7
+ export function getGeometryCenter(geometry) {
7
8
  const { type, coordinates } = geometry
8
9
  if (type === 'Point') {
9
10
  return coordinates
@@ -23,13 +24,12 @@ function getGeometryCenter(geometry) {
23
24
  return null
24
25
  }
25
26
 
26
- function evalInterpolate(expr, zoom) {
27
+ export function evalInterpolate(expr, zoom) {
27
28
  if (typeof expr === 'number') {
28
29
  return expr
29
30
  }
30
31
  if (!Array.isArray(expr) || expr[0] !== 'interpolate') {
31
32
  return calculateLinearTextSize(expr, zoom)
32
- // throw new Error('Only interpolate expressions supported')
33
33
  }
34
34
  const [, , input, ...stops] = expr
35
35
  if (input[0] !== 'zoom') {
@@ -39,7 +39,7 @@ function evalInterpolate(expr, zoom) {
39
39
  const z0 = stops[i]
40
40
  const v0 = stops[i + 1]
41
41
  const z1 = stops[i + 2]
42
- const v1 = stops[i + 3]
42
+ const v1 = stops[i + 3] // NOSONAR: array index offset for interpolation pairs
43
43
  if (zoom <= z0) {
44
44
  return v0
45
45
  }
@@ -50,166 +50,220 @@ function evalInterpolate(expr, zoom) {
50
50
  return stops[stops.length - 1]
51
51
  }
52
52
 
53
- export function createMapLabelNavigator(map, mapColorScheme, events, eventBus) {
54
- let isDarkStyle = mapColorScheme === 'dark'
55
- let labels = []
56
- let currentPixel = null
57
- let highlightLayerId = null
58
- let highlightedExpr = null
59
- let highlightedFeature = null
60
-
61
- const colors = {
62
- get current() {
63
- if (isDarkStyle) {
64
- return { text: '#ffffff', halo: '#000000' }
65
- }
66
- return { text: '#000000', halo: '#ffffff' }
67
- }
53
+ export function getHighlightColors(isDarkStyle) {
54
+ if (isDarkStyle) {
55
+ return { text: '#ffffff', halo: '#000000' }
68
56
  }
57
+ return { text: '#000000', halo: '#ffffff' }
58
+ }
69
59
 
70
- const initLabelSource = () => {
71
- if (!map.getSource('highlighted-label')) {
72
- map.addSource('highlighted-label', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
73
- }
60
+ export function extractTextPropertyName(textField) {
61
+ if (typeof textField === 'string') {
62
+ return /^{(.+)}$/.exec(textField)?.[1]
74
63
  }
64
+ if (Array.isArray(textField)) {
65
+ return textField.find(e => Array.isArray(e) && e[0] === 'get')?.[1]
66
+ }
67
+ return null
68
+ }
75
69
 
76
- map.getStyle().layers.filter(l => l.layout?.['symbol-placement'] === 'line').forEach(l => {
77
- map.setLayoutProperty(l.id, 'symbol-placement', 'line-center')
78
- })
79
- initLabelSource()
70
+ export function buildLabelFromFeature(feature, layer, propName, map) {
71
+ const center = getGeometryCenter(feature.geometry)
72
+ if (!center) {
73
+ return null
74
+ }
75
+ const projected = map.project({ lng: center[0], lat: center[1] })
76
+ return { text: feature.properties[propName], x: projected.x, y: projected.y, feature, layer }
77
+ }
80
78
 
81
- eventBus?.on(events.MAP_SET_STYLE, style => {
82
- map.once('styledata', () => map.once('idle', () => {
83
- map.getStyle().layers.filter(l => l.layout?.['symbol-placement'] === 'line').forEach(l => {
84
- map.setLayoutProperty(l.id, 'symbol-placement', 'line-center')
85
- })
86
- initLabelSource()
87
- isDarkStyle = style?.mapColorScheme === 'dark'
88
- }))
79
+ export function buildLabelsFromLayers(map, symbolLayers, features) {
80
+ return symbolLayers.flatMap(layer => {
81
+ const textField = layer.layout?.['text-field']
82
+ const propName = extractTextPropertyName(textField)
83
+ if (!propName) {
84
+ return []
85
+ }
86
+ return features
87
+ .filter(f => f.layer.id === layer.id && f.properties?.[propName])
88
+ .map(f => buildLabelFromFeature(f, layer, propName, map))
89
+ .filter(Boolean)
89
90
  })
91
+ }
90
92
 
91
- function refreshLabels() {
92
- const symbolLayers = map.getStyle().layers.filter(l => l.type === 'symbol')
93
- const features = map.queryRenderedFeatures({ layers: symbolLayers.map(l => l.id) })
94
- labels = symbolLayers.flatMap(layer => {
95
- const textField = layer.layout?.['text-field']
96
- const propName = typeof textField === 'string'
97
- ? textField.match(/^{(.+)}$/)?.[1]
98
- : Array.isArray(textField)
99
- ? textField.find(e => Array.isArray(e) && e[0] === 'get')?.[1]
100
- : null
101
- if (!propName) {
102
- return []
103
- }
104
- return features.filter(f => f.layer.id === layer.id && f.properties?.[propName]).map(f => {
105
- const center = getGeometryCenter(f.geometry)
106
- if (!center) {
107
- return null
108
- }
109
- const projected = map.project({ lng: center[0], lat: center[1] })
110
- return { text: f.properties[propName], x: projected.x, y: projected.y, feature: f, layer }
111
- }).filter(Boolean)
112
- })
113
- }
93
+ export function findClosestLabel(labels, centerPoint) {
94
+ return labels.reduce((best, label) => {
95
+ const dist = (label.x - centerPoint.x) ** 2 + (label.y - centerPoint.y) ** 2
96
+ if (!best || dist < best.dist) {
97
+ return { label, dist }
98
+ }
99
+ return best
100
+ }, null)?.label
101
+ }
114
102
 
115
- function removeHighlight() {
116
- if (highlightLayerId && map.getLayer(highlightLayerId)) {
117
- try {
118
- map.removeLayer(highlightLayerId)
119
- }
120
- catch {}
121
- highlightLayerId = null
122
- highlightedExpr = null
123
- highlightedFeature = null
103
+ export function createHighlightLayerConfig(sourceLayer, highlightSize, colors) {
104
+ return {
105
+ id: `highlight-${sourceLayer.id}`,
106
+ type: sourceLayer.type,
107
+ source: HIGHLIGHT_LABEL_SOURCE,
108
+ layout: {
109
+ ...sourceLayer.layout,
110
+ 'text-size': highlightSize,
111
+ 'text-allow-overlap': true,
112
+ 'text-ignore-placement': true,
113
+ 'text-max-angle': 90
114
+ },
115
+ paint: {
116
+ ...sourceLayer.paint,
117
+ 'text-color': colors.text,
118
+ 'text-halo-color': colors.halo,
119
+ 'text-halo-width': 3,
120
+ 'text-halo-blur': 1,
121
+ 'text-opacity': 1
124
122
  }
125
123
  }
124
+ }
126
125
 
127
- function highlight(labelData) {
128
- if (!labelData?.feature?.layer) {
129
- return
126
+ export function removeHighlightLayer(map, state) {
127
+ if (state.highlightLayerId && map.getLayer(state.highlightLayerId)) {
128
+ try {
129
+ map.removeLayer(state.highlightLayerId)
130
130
  }
131
- removeHighlight()
132
- const { feature, layer } = labelData
133
- highlightLayerId = `highlight-${layer.id}`
134
-
135
- const { id, type, properties, geometry } = feature
136
- highlightedFeature = { id, type, properties, geometry }
137
- map.getSource('highlighted-label').setData(highlightedFeature)
138
- highlightedExpr = layer.layout['text-size']
139
- const zoom = map.getZoom()
140
- const baseSize = evalInterpolate(highlightedExpr, zoom)
141
- const highlightSize = baseSize * HIGHLIGHT_SCALE_FACTOR
142
- map.addLayer({
143
- id: highlightLayerId,
144
- type: layer.type,
145
- source: 'highlighted-label',
146
- layout: { ...layer.layout, 'text-size': highlightSize, 'text-allow-overlap': true, 'text-ignore-placement': true, 'text-max-angle': 90 },
147
- paint: { ...layer.paint, 'text-color': colors.current.text, 'text-halo-color': colors.current.halo, 'text-halo-width': 3, 'text-halo-blur': 1, 'text-opacity': 1 }
131
+ catch {}
132
+ state.highlightLayerId = null
133
+ state.highlightedExpr = null
134
+ }
135
+ }
136
+
137
+ export function applyHighlight(map, labelData, state) {
138
+ if (!labelData?.feature?.layer) {
139
+ return
140
+ }
141
+ removeHighlightLayer(map, state)
142
+ const { feature, layer } = labelData
143
+ state.highlightLayerId = `highlight-${layer.id}`
144
+
145
+ const { id, type, properties, geometry } = feature
146
+ map.getSource(HIGHLIGHT_LABEL_SOURCE).setData({ id, type, properties, geometry })
147
+ state.highlightedExpr = layer.layout['text-size']
148
+
149
+ const zoom = map.getZoom()
150
+ const baseSize = evalInterpolate(state.highlightedExpr, zoom)
151
+ const highlightSize = baseSize * HIGHLIGHT_SCALE_FACTOR
152
+ const colors = getHighlightColors(state.isDarkStyle)
153
+ const layerConfig = createHighlightLayerConfig(layer, highlightSize, colors)
154
+
155
+ map.addLayer(layerConfig)
156
+ map.moveLayer(state.highlightLayerId)
157
+ }
158
+
159
+ export function navigateToNextLabel(direction, state) {
160
+ if (!state.currentPixel) {
161
+ return null
162
+ }
163
+ const filtered = state.labels
164
+ .map((l, i) => ({ pixel: [l.x, l.y], index: i }))
165
+ .filter(l => l.pixel[0] !== state.currentPixel.x || l.pixel[1] !== state.currentPixel.y)
166
+ if (!filtered.length) {
167
+ return null
168
+ }
169
+ const pixelArray = filtered.map(l => l.pixel)
170
+ let nextFilteredIndex = spatialNavigate(direction, [state.currentPixel.x, state.currentPixel.y], pixelArray)
171
+ if (nextFilteredIndex == null || nextFilteredIndex < 0 || nextFilteredIndex >= filtered.length) {
172
+ nextFilteredIndex = 0
173
+ }
174
+ return state.labels[filtered[nextFilteredIndex].index]
175
+ }
176
+
177
+ function initLabelSource(map) {
178
+ if (!map.getSource(HIGHLIGHT_LABEL_SOURCE)) {
179
+ map.addSource(HIGHLIGHT_LABEL_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
180
+ }
181
+ }
182
+
183
+ function setLineCenterPlacement(map) {
184
+ map.getStyle().layers
185
+ .filter(l => l.layout?.['symbol-placement'] === 'line')
186
+ .forEach(l => map.setLayoutProperty(l.id, 'symbol-placement', 'line-center'))
187
+ }
188
+
189
+ function setSymbolTextOpacity(map) {
190
+ map.getStyle().layers
191
+ .filter(l => l.type === 'symbol')
192
+ .forEach(layer => {
193
+ map.setPaintProperty(layer.id, 'text-opacity', ['case', ['boolean', ['feature-state', 'highlighted'], false], 0, 1])
148
194
  })
149
- map.moveLayer(highlightLayerId)
195
+ }
196
+
197
+ export function createMapLabelNavigator(map, mapColorScheme, events, eventBus) {
198
+ const state = {
199
+ isDarkStyle: mapColorScheme === 'dark',
200
+ labels: [],
201
+ currentPixel: null,
202
+ highlightLayerId: null,
203
+ highlightedExpr: null
150
204
  }
151
205
 
206
+ setLineCenterPlacement(map)
207
+ initLabelSource(map)
208
+
209
+ eventBus?.on(events.MAP_SET_STYLE, style => {
210
+ map.once('styledata', () => map.once('idle', () => {
211
+ setLineCenterPlacement(map)
212
+ initLabelSource(map)
213
+ state.isDarkStyle = style?.mapColorScheme === 'dark'
214
+ }))
215
+ })
216
+
152
217
  map.on('zoom', () => {
153
- if (highlightLayerId && highlightedExpr) {
154
- const zoom = map.getZoom()
155
- const baseSize = evalInterpolate(highlightedExpr, zoom)
156
- map.setLayoutProperty(highlightLayerId, 'text-size', baseSize * HIGHLIGHT_SCALE_FACTOR)
218
+ if (state.highlightLayerId && state.highlightedExpr) {
219
+ const baseSize = evalInterpolate(state.highlightedExpr, map.getZoom())
220
+ map.setLayoutProperty(state.highlightLayerId, 'text-size', baseSize * HIGHLIGHT_SCALE_FACTOR)
157
221
  }
158
222
  })
159
223
 
224
+ function refreshLabels() {
225
+ const symbolLayers = map.getStyle().layers.filter(l => l.type === 'symbol')
226
+ const features = map.queryRenderedFeatures({ layers: symbolLayers.map(l => l.id) })
227
+ state.labels = buildLabelsFromLayers(map, symbolLayers, features)
228
+ }
229
+
160
230
  function highlightCenter() {
161
231
  refreshLabels()
162
- if (!labels.length) {
232
+ if (!state.labels.length) {
163
233
  return null
164
234
  }
165
235
  const centerPoint = map.project(map.getCenter())
166
- const closest = labels.reduce((best, label) => {
167
- const dist = (label.x - centerPoint.x) ** 2 + (label.y - centerPoint.y) ** 2
168
- if (!best || dist < best.dist) {
169
- return { label, dist }
170
- }
171
- return best
172
- }, null)?.label
236
+ const closest = findClosestLabel(state.labels, centerPoint)
173
237
  if (closest) {
174
- currentPixel = { x: closest.x, y: closest.y }
238
+ state.currentPixel = { x: closest.x, y: closest.y }
175
239
  }
176
- highlight(closest)
240
+ applyHighlight(map, closest, state)
177
241
  return `${closest.text} (${closest.layer.id})`
178
242
  }
179
243
 
180
244
  function highlightNext(direction) {
181
245
  refreshLabels()
182
- if (!labels.length) {
246
+ if (!state.labels.length) {
183
247
  return null
184
248
  }
185
- if (!currentPixel) {
249
+ if (!state.currentPixel) {
186
250
  return highlightCenter()
187
251
  }
188
- const filtered = labels
189
- .map((l, i) => ({ pixel: [l.x, l.y], index: i }))
190
- .filter(l => l.pixel[0] !== currentPixel.x || l.pixel[1] !== currentPixel.y)
191
- if (!filtered.length) {
252
+ const labelData = navigateToNextLabel(direction, state)
253
+ if (!labelData) {
192
254
  return null
193
255
  }
194
- const pixelArray = filtered.map(l => l.pixel)
195
- let nextFilteredIndex = spatialNavigate(direction, [currentPixel.x, currentPixel.y], pixelArray)
196
- if (nextFilteredIndex == null || nextFilteredIndex < 0 || nextFilteredIndex >= filtered.length) {
197
- nextFilteredIndex = 0
198
- }
199
- const labelData = labels[filtered[nextFilteredIndex].index]
200
- currentPixel = { x: labelData.x, y: labelData.y }
201
- highlight(labelData)
256
+ state.currentPixel = { x: labelData.x, y: labelData.y }
257
+ applyHighlight(map, labelData, state)
202
258
  return `${labelData.text} (${labelData.layer.id})`
203
259
  }
204
260
 
205
- map.getStyle().layers.filter(l => l.type === 'symbol').forEach(layer => {
206
- map.setPaintProperty(layer.id, 'text-opacity', ['case', ['boolean', ['feature-state', 'highlighted'], false], 0, 1])
207
- })
261
+ setSymbolTextOpacity(map)
208
262
 
209
263
  return {
210
264
  refreshLabels,
211
265
  highlightNextLabel: highlightNext,
212
266
  highlightLabelAtCenter: highlightCenter,
213
- clearHighlightedLabel: removeHighlight
267
+ clearHighlightedLabel: () => removeHighlightLayer(map, state)
214
268
  }
215
269
  }
@@ -13,7 +13,7 @@ export function applyPreventDefaultFix (map) {
13
13
  const originalPreventDefault = Event.prototype.preventDefault
14
14
 
15
15
  // Override preventDefault only for events targeting our map
16
- Event.prototype.preventDefault = function () {
16
+ Event.prototype.preventDefault = function () { // NOSONAR: intentional monkey-patch to fix MapLibre touch event bug
17
17
  if ((this.type === 'touchmove' || this.type === 'touchstart') && !this.cancelable) {
18
18
  // Check if the event target is within our map container
19
19
  const canvas = map.getCanvas()
@@ -21,6 +21,6 @@ export function applyPreventDefaultFix (map) {
21
21
  return
22
22
  }
23
23
  }
24
- return originalPreventDefault.call(this)
24
+ originalPreventDefault.call(this)
25
25
  }
26
26
  }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Calculates the squared distance from a point (p) to a line segment (v to w).
3
+ */
4
+ const distToSegmentSquared = (p, v, w) => {
5
+ const l2 = (v.x - w.x) ** 2 + (v.y - w.y) ** 2
6
+ if (l2 === 0) {
7
+ return (p.x - v.x) ** 2 + (p.y - v.y) ** 2
8
+ }
9
+ let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2
10
+ t = Math.max(0, Math.min(1, t))
11
+ return (p.x - (v.x + t * (w.x - v.x))) ** 2 + (p.y - (v.y + t * (w.y - v.y))) ** 2
12
+ }
13
+
14
+ /**
15
+ * Ray-casting algorithm to determine if a point is inside a polygon.
16
+ */
17
+ const isPointInPolygon = (point, ring) => {
18
+ const [px, py] = point
19
+ let inside = false
20
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i++) {
21
+ const [xi, yi] = ring[i]
22
+ const [xj, yj] = ring[j]
23
+ const isAboveI = yi > py
24
+ const isAboveJ = yj > py
25
+
26
+ if (isAboveI === isAboveJ) {
27
+ continue
28
+ }
29
+ // 2. Calculate horizontal intersection
30
+ const intersectX = ((xj - xi) * (py - yi)) / (yj - yi) + xi
31
+
32
+ if (px < intersectX) {
33
+ inside = !inside;
34
+ }
35
+ }
36
+ return inside
37
+ }
38
+
39
+ /**
40
+ * Calculates minimum squared pixel distance to the geometry.
41
+ */
42
+ const getMinDistToGeometry = (map, point, geometry) => {
43
+ const { coordinates: coords, type } = geometry
44
+ let minSqDist = Infinity
45
+ const getScreenPt = (lngLat) => map.project(lngLat)
46
+
47
+ const processLine = (lineCoords) => {
48
+ for (let i = 0; i < lineCoords.length - 1; i++) {
49
+ const d2 = distToSegmentSquared(point, getScreenPt(lineCoords[i]), getScreenPt(lineCoords[i + 1]))
50
+ if (d2 < minSqDist) {
51
+ minSqDist = d2
52
+ }
53
+ }
54
+ }
55
+
56
+ if (type === 'Point') {
57
+ const p = getScreenPt(coords)
58
+ minSqDist = (point.x - p.x) ** 2 + (point.y - p.y) ** 2
59
+ } else if (type === 'LineString' || type === 'MultiPoint') {
60
+ if (type === 'LineString') {
61
+ processLine(coords)
62
+ } else {
63
+ coords.forEach((pt) => {
64
+ const p = getScreenPt(pt)
65
+ const d2 = (point.x - p.x) ** 2 + (point.y - p.y) ** 2
66
+ if (d2 < minSqDist) {
67
+ minSqDist = d2
68
+ }
69
+ })
70
+ }
71
+ } else if (type === 'Polygon' || type === 'MultiLineString') {
72
+ coords.forEach(processLine)
73
+ } else if (type === 'MultiPolygon') {
74
+ coords.forEach((poly) => poly.forEach(processLine))
75
+ } else {
76
+ // No action
77
+ }
78
+ return minSqDist
79
+ }
80
+
81
+ /**
82
+ * Query features prioritizing Layer Order, then Containment for Polygons.
83
+ */
84
+ export const queryFeatures = (map, point, options = {}) => {
85
+ const { radius = 10 } = options
86
+ const queryArea = [[point.x - radius, point.y - radius], [point.x + radius, point.y + radius]]
87
+ const rawFeatures = map.queryRenderedFeatures(queryArea)
88
+ if (rawFeatures.length === 0) {
89
+ return []
90
+ }
91
+
92
+ // Identify layer visual hierarchy
93
+ const layerStack = []
94
+ rawFeatures.forEach(f => {
95
+ if (layerStack.includes(f.layer.id) === false) {
96
+ layerStack.push(f.layer.id)
97
+ }
98
+ })
99
+
100
+ // Deduplicate Bottom-Up to favor data layers over highlight layers
101
+ const seenIds = new Set()
102
+ const uniqueFeatures = []
103
+ for (let i = rawFeatures.length - 1; i >= 0; i--) {
104
+ const f = rawFeatures[i]
105
+ const featureId = f.id === undefined ? JSON.stringify(f.properties) : f.id
106
+ if (seenIds.has(featureId) === false) {
107
+ seenIds.add(featureId)
108
+ uniqueFeatures.push(f)
109
+ }
110
+ }
111
+
112
+ const clickLngLat = map.unproject(point)
113
+ const clickPt = [clickLngLat.lng, clickLngLat.lat]
114
+
115
+ return uniqueFeatures
116
+ .map((f) => {
117
+ let score = 0
118
+ const type = f.geometry.type
119
+ const pixelDistSq = getMinDistToGeometry(map, point, f.geometry)
120
+
121
+ // PRIORITY 1: LAYER ORDER
122
+ const layerRank = layerStack.indexOf(f.layer.id)
123
+ score += (layerRank * 1000000)
124
+
125
+ // PRIORITY 2: CONTAINMENT (Polygon Special Treatment)
126
+ if (type.includes('Polygon')) {
127
+ const polys = type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates
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
+ }
137
+ }
138
+
139
+ // PRIORITY 3: DISTANCE (Final Tie-breaker)
140
+ score += pixelDistSq
141
+
142
+ return { f, score }
143
+ })
144
+ .sort((a, b) => a.score - b.score)
145
+ .map(({ f }) => f)
146
+ }
@@ -123,26 +123,23 @@ const getCardinalMove = (from, to) => {
123
123
  * @param {Array<[number, number]>} pixels - Array of pixel coordinates.
124
124
  * @returns {number} Index of the closest pixel in the given direction.
125
125
  */
126
+ const isInDirection = (direction, dx, dy) => {
127
+ switch (direction) {
128
+ case 'ArrowUp': return dy < 0 && Math.abs(dy) >= Math.abs(dx)
129
+ case 'ArrowDown': return dy > 0 && Math.abs(dy) >= Math.abs(dx)
130
+ case 'ArrowLeft': return dx < 0 && Math.abs(dx) > Math.abs(dy)
131
+ case 'ArrowRight': return dx > 0 && Math.abs(dx) > Math.abs(dy)
132
+ default: return false
133
+ }
134
+ }
135
+
126
136
  const spatialNavigate = (direction, start, pixels) => {
127
137
  const [sx, sy] = start
128
138
 
129
139
  // Direction filters
130
- const candidates = pixels.filter(([x, y]) => {
131
- if (x === sx && y === sy) {
132
- return false
133
- }
134
-
135
- const dx = x - sx
136
- const dy = y - sy
137
-
138
- switch (direction) {
139
- case 'ArrowUp': return dy < 0 && Math.abs(dy) >= Math.abs(dx)
140
- case 'ArrowDown': return dy > 0 && Math.abs(dy) >= Math.abs(dx)
141
- case 'ArrowLeft': return dx < 0 && Math.abs(dx) > Math.abs(dy)
142
- case 'ArrowRight': return dx > 0 && Math.abs(dx) > Math.abs(dy)
143
- default: return false
144
- }
145
- })
140
+ const candidates = pixels.filter(([x, y]) =>
141
+ (x !== sx || y !== sy) && isInDirection(direction, x - sx, y - sy)
142
+ )
146
143
 
147
144
  if (!candidates.length) {
148
145
  return pixels.findIndex(p => p[0] === sx && p[1] === sy)
@@ -169,7 +166,7 @@ const getResolution = (center, zoom) => {
169
166
  const TILE_SIZE = 512
170
167
  const lat = center.lat
171
168
  const scale = Math.pow(2, zoom)
172
- const resolution = (EARTH_CIRCUMFERENCE * Math.cos((lat * Math.PI) / 180)) / (scale * TILE_SIZE)
169
+ const resolution = (EARTH_CIRCUMFERENCE * Math.cos((lat * Math.PI) / 180)) / (scale * TILE_SIZE) // NOSONAR - 180 is degrees-to-radians conversion
173
170
  return resolution
174
171
  }
175
172
 
@@ -16,8 +16,45 @@ sonar.tests=src,plugins,providers
16
16
  sonar.test.inclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/**
17
17
  sonar.cpd.exclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/**
18
18
 
19
- sonar.issue.ignore.multicriteria=reactPropsJs,reactPropsJsx
19
+ # Ignored rules
20
+ sonar.issue.ignore.multicriteria=reactPropsJs,reactPropsJsx,preferGlobalThisJs,preferGlobalThisJsx,preferAtJs,preferAtJsx,replaceAllJs,replaceAllJsx,stringReplaceAllJs,stringReplaceAllJsx,pascalCaseFunctionsJs,pascalCaseFunctionsJsx,pascalCaseFunctionsTs,pascalCaseFunctionsTsx
21
+
22
+ # S6774: React props validation - using TypeScript/JSDoc for prop types instead
20
23
  sonar.issue.ignore.multicriteria.reactPropsJs.ruleKey=javascript:S6774
21
24
  sonar.issue.ignore.multicriteria.reactPropsJs.resourceKey=**/*.js
22
25
  sonar.issue.ignore.multicriteria.reactPropsJsx.ruleKey=javascript:S6774
23
- sonar.issue.ignore.multicriteria.reactPropsJsx.resourceKey=**/*.jsx
26
+ sonar.issue.ignore.multicriteria.reactPropsJsx.resourceKey=**/*.jsx
27
+
28
+ # S7764: Prefer globalThis over window
29
+ sonar.issue.ignore.multicriteria.preferGlobalThisJs.ruleKey=javascript:S7764
30
+ sonar.issue.ignore.multicriteria.preferGlobalThisJs.resourceKey=**/*.js
31
+ sonar.issue.ignore.multicriteria.preferGlobalThisJsx.ruleKey=javascript:S7764
32
+ sonar.issue.ignore.multicriteria.preferGlobalThisJsx.resourceKey=**/*.jsx
33
+
34
+ # S7755: Prefer .at() over [index]
35
+ sonar.issue.ignore.multicriteria.preferAtJs.ruleKey=javascript:S7755
36
+ sonar.issue.ignore.multicriteria.preferAtJs.resourceKey=**/*.js
37
+ sonar.issue.ignore.multicriteria.preferAtJsx.ruleKey=javascript:S7755
38
+ sonar.issue.ignore.multicriteria.preferAtJsx.resourceKey=**/*.jsx
39
+
40
+ # S6316: Array.prototype.replaceAll may not be supported in all browsers
41
+ sonar.issue.ignore.multicriteria.replaceAllJs.ruleKey=javascript:S6316
42
+ sonar.issue.ignore.multicriteria.replaceAllJs.resourceKey=**/*.js
43
+ sonar.issue.ignore.multicriteria.replaceAllJsx.ruleKey=javascript:S6316
44
+ sonar.issue.ignore.multicriteria.replaceAllJsx.resourceKey=**/*.jsx
45
+
46
+ # S7781: Prefer .replaceAll() over .replace() with global regex
47
+ sonar.issue.ignore.multicriteria.stringReplaceAllJs.ruleKey=javascript:S7781
48
+ sonar.issue.ignore.multicriteria.stringReplaceAllJs.resourceKey=**/*.js
49
+ sonar.issue.ignore.multicriteria.stringReplaceAllJsx.ruleKey=javascript:S7781
50
+ sonar.issue.ignore.multicriteria.stringReplaceAllJsx.resourceKey=**/*.jsx
51
+
52
+ # S100: Function names should comply with a naming convention (PascalCase for React/Inits) - mustn't pics up React functions
53
+ sonar.issue.ignore.multicriteria.pascalCaseFunctionsJs.ruleKey=javascript:S100
54
+ sonar.issue.ignore.multicriteria.pascalCaseFunctionsJs.resourceKey=**/*.js
55
+ sonar.issue.ignore.multicriteria.pascalCaseFunctionsJsx.ruleKey=javascript:S100
56
+ sonar.issue.ignore.multicriteria.pascalCaseFunctionsJsx.resourceKey=**/*.jsx
57
+ sonar.issue.ignore.multicriteria.pascalCaseFunctionsTs.ruleKey=javascript:S100
58
+ sonar.issue.ignore.multicriteria.pascalCaseFunctionsTs.resourceKey=**/*.ts
59
+ sonar.issue.ignore.multicriteria.pascalCaseFunctionsTsx.ruleKey=javascript:S100
60
+ sonar.issue.ignore.multicriteria.pascalCaseFunctionsTsx.resourceKey=**/*.tsx
@@ -3,7 +3,7 @@ import { useApp } from '../../store/appContext'
3
3
 
4
4
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
5
5
  // sonarjs/disable-next-line function-name
6
- export const Actions = ({ slot, children }) => {
6
+ export const Actions = ({ children }) => {
7
7
  const { openPanels, panelConfig, breakpoint } = useApp()
8
8
 
9
9
  const childArray = React.Children.toArray(children)
@@ -21,7 +21,7 @@ export const Actions = ({ slot, children }) => {
21
21
  ].filter(Boolean).join(' ')
22
22
 
23
23
  return (
24
- <div className={className} style={!visibleChild ? { display: 'none' } : undefined}>
24
+ <div className={className} style={visibleChild ? undefined : { display: 'none' }}>
25
25
  {children}
26
26
  </div>
27
27
  )
@@ -2,7 +2,7 @@
2
2
  display: flex;
3
3
  flex-direction: row;
4
4
  justify-content: center;
5
- gap: var(--divider-gap);
5
+ gap: calc(var(--divider-gap) / 2);
6
6
  padding: var(--panel-margin);
7
7
 
8
8
  .im-c-button-wrapper--wide {
@@ -22,6 +22,6 @@
22
22
 
23
23
  .im-o-app--tablet, .im-o-app--desktop {
24
24
  .im-c-actions {
25
- min-width: 280px;
25
+ min-width: 0;
26
26
  }
27
27
  }