@defra/interactive-map 0.0.3-alpha → 0.0.4-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 (268) hide show
  1. package/README.md +16 -57
  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/button-definition.md +176 -0
  7. package/docs/api/context.md +124 -0
  8. package/docs/api/control-definition.md +72 -0
  9. package/docs/api/icon-definition.md +28 -0
  10. package/docs/api/map-style-config.md +76 -0
  11. package/docs/api/marker-config.md +51 -0
  12. package/docs/api/panel-definition.md +117 -0
  13. package/docs/api/slots.md +25 -0
  14. package/docs/api.md +713 -0
  15. package/docs/architecture.md +139 -0
  16. package/docs/building-a-plugin.md +149 -0
  17. package/docs/getting-started.md +46 -0
  18. package/docs/govuk-prototype.md +0 -0
  19. package/docs/plugins/interact.md +205 -0
  20. package/docs/plugins/map-styles.md +84 -0
  21. package/docs/plugins/plugin-context.md +67 -0
  22. package/docs/plugins/plugin-descriptor.md +71 -0
  23. package/docs/plugins/plugin-manifest.md +118 -0
  24. package/docs/plugins/scale-bar.md +62 -0
  25. package/docs/plugins/search.md +162 -0
  26. package/docs/plugins.md +116 -0
  27. package/govuk-prototype-kit.config.json +7 -7
  28. package/package.json +14 -1
  29. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -0
  30. package/plugins/beta/datasets/dist/esm/index.js +2 -0
  31. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -0
  32. package/plugins/beta/datasets/dist/umd/index.js +2 -0
  33. package/plugins/{datasets → beta/datasets}/src/manifest.js +1 -1
  34. package/plugins/{datasets → beta/datasets}/src/mapLayers.js +1 -1
  35. package/plugins/{datasets → beta/datasets}/src/panels/Key.jsx +1 -1
  36. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -0
  37. package/plugins/beta/draw-ml/dist/esm/index.js +2 -0
  38. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -0
  39. package/plugins/beta/draw-ml/dist/umd/index.js +2 -0
  40. package/plugins/{draw-ml → beta/draw-ml}/src/api/editFeature.js +1 -1
  41. package/plugins/beta/draw-ml/src/api/merge.js +9 -0
  42. package/plugins/beta/draw-ml/src/api/newLine.js +43 -0
  43. package/plugins/{draw-ml → beta/draw-ml}/src/api/newPolygon.js +5 -5
  44. package/plugins/beta/draw-ml/src/api/split.js +81 -0
  45. package/plugins/{draw-ml → beta/draw-ml}/src/defaults.js +2 -0
  46. package/plugins/{draw-ml → beta/draw-ml}/src/events.js +105 -3
  47. package/plugins/{draw-ml → beta/draw-ml}/src/manifest.js +17 -11
  48. package/plugins/{draw-ml → beta/draw-ml}/src/mapboxDraw.js +51 -2
  49. package/plugins/{draw-ml → beta/draw-ml}/src/mapboxSnap.js +2 -0
  50. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +496 -0
  51. package/plugins/beta/draw-ml/src/modes/drawLineMode.js +18 -0
  52. package/plugins/beta/draw-ml/src/modes/drawPolygonMode.js +16 -0
  53. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +792 -0
  54. package/plugins/{draw-ml → beta/draw-ml}/src/reducer.js +30 -2
  55. package/plugins/{draw-ml → beta/draw-ml}/src/styles.js +66 -36
  56. package/plugins/beta/draw-ml/src/undoStack.js +50 -0
  57. package/plugins/beta/draw-ml/src/utils/debounce.js +16 -0
  58. package/plugins/beta/draw-ml/src/utils/spatial.js +258 -0
  59. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -0
  60. package/plugins/beta/frame/dist/esm/index.js +2 -0
  61. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -0
  62. package/plugins/beta/frame/dist/umd/index.js +2 -0
  63. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -0
  64. package/plugins/beta/map-styles/dist/esm/index.js +2 -0
  65. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -0
  66. package/plugins/beta/map-styles/dist/umd/index.js +2 -0
  67. package/plugins/{map-styles → beta/map-styles}/src/MapStyles.jsx +1 -1
  68. package/plugins/{map-styles → beta/map-styles}/src/manifest.js +8 -10
  69. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -0
  70. package/plugins/beta/scale-bar/dist/esm/index.js +2 -0
  71. package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -0
  72. package/plugins/beta/scale-bar/dist/umd/index.js +2 -0
  73. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -0
  74. package/plugins/beta/use-location/dist/esm/index.js +2 -0
  75. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -0
  76. package/plugins/beta/use-location/dist/umd/index.js +2 -0
  77. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  78. package/plugins/interact/dist/esm/index.js +1 -1
  79. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  80. package/plugins/interact/dist/umd/index.js +1 -1
  81. package/plugins/interact/src/InteractInit.test.js +97 -0
  82. package/plugins/interact/src/api/clear.test.js +18 -0
  83. package/plugins/interact/src/api/disable.test.js +12 -0
  84. package/plugins/interact/src/api/enable.test.js +48 -0
  85. package/plugins/interact/src/api/selectFeature.test.js +33 -0
  86. package/plugins/interact/src/api/unselectFeature.test.js +33 -0
  87. package/plugins/interact/src/defaults.js +1 -0
  88. package/plugins/interact/src/events.test.js +151 -0
  89. package/plugins/interact/src/hooks/useHighlightSync.test.js +212 -0
  90. package/plugins/interact/src/hooks/useInteractionHandlers.js +14 -7
  91. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +260 -0
  92. package/plugins/interact/src/index.test.js +22 -0
  93. package/plugins/interact/src/manifest.js +1 -1
  94. package/plugins/interact/src/manifest.test.js +94 -0
  95. package/plugins/interact/src/reducer.js +10 -1
  96. package/plugins/interact/src/reducer.test.js +142 -0
  97. package/plugins/interact/src/utils/buildStylesMap.test.js +57 -0
  98. package/plugins/interact/src/utils/featureQueries.test.js +56 -0
  99. package/plugins/interact/src/utils/turfHelpers.js +30 -0
  100. package/plugins/interact/src/utils/turfHelpers.test.js +30 -0
  101. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  102. package/plugins/search/dist/esm/index.js +1 -1
  103. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  104. package/plugins/search/dist/umd/index.js +1 -1
  105. package/plugins/search/src/utils/parseOsNamesResults.js +3 -7
  106. package/providers/{esri → beta/esri}/src/mapEvents.js +2 -2
  107. package/providers/beta/open-names/dist/esm/im-reverse-geocode.js +2 -0
  108. package/providers/beta/open-names/dist/esm/index.js +2 -0
  109. package/providers/beta/open-names/dist/umd/im-reverse-geocode.js +2 -0
  110. package/providers/beta/open-names/dist/umd/index.js +2 -0
  111. package/providers/maplibre/dist/esm/im-maplibre-framework.js +1 -1
  112. package/providers/maplibre/dist/esm/im-maplibre-framework.js.LICENSE.txt +1 -1
  113. package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +1 -1
  114. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  115. package/providers/maplibre/dist/esm/index.js +1 -1
  116. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  117. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  118. package/providers/maplibre/dist/umd/im-maplibre-legacy-framework.js +1 -1
  119. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  120. package/providers/maplibre/dist/umd/index.js +1 -1
  121. package/providers/maplibre/src/index.js +14 -1
  122. package/providers/maplibre/src/maplibreProvider.js +127 -0
  123. package/sonar-project.properties +1 -1
  124. package/src/App/hooks/useInterfaceAPI.js +24 -1
  125. package/src/App/renderer/mapButtons.js +1 -1
  126. package/src/App/store/appActionsMap.js +22 -1
  127. package/src/InteractiveMap/InteractiveMap.js +114 -6
  128. package/src/config/defaults.js +29 -20
  129. package/src/config/events.js +110 -4
  130. package/src/index.js +5 -2
  131. package/src/scss/tools/_border-focus.scss +20 -4
  132. package/src/types.js +585 -0
  133. package/webpack.esm.mjs +8 -8
  134. package/webpack.umd.mjs +8 -8
  135. package/plugins/datasets/dist/esm/im-datasets-plugin.js +0 -1
  136. package/plugins/datasets/dist/esm/index.js +0 -2
  137. package/plugins/datasets/dist/umd/im-datasets-plugin.js +0 -1
  138. package/plugins/datasets/dist/umd/index.js +0 -2
  139. package/plugins/draw-ml/dist/esm/im-draw-ml-plugin.js +0 -1
  140. package/plugins/draw-ml/dist/esm/index.js +0 -2
  141. package/plugins/draw-ml/dist/umd/im-draw-ml-plugin.js +0 -1
  142. package/plugins/draw-ml/dist/umd/index.js +0 -2
  143. package/plugins/draw-ml/src/modes/drawVertexMode.js +0 -275
  144. package/plugins/draw-ml/src/modes/editVertexMode.js +0 -426
  145. package/plugins/draw-ml/src/utils.js +0 -102
  146. package/plugins/frame/dist/esm/im-frame-plugin.js +0 -1
  147. package/plugins/frame/dist/esm/index.js +0 -2
  148. package/plugins/frame/dist/umd/im-frame-plugin.js +0 -1
  149. package/plugins/frame/dist/umd/index.js +0 -2
  150. package/plugins/map-styles/dist/esm/im-map-styles-plugin.js +0 -1
  151. package/plugins/map-styles/dist/esm/index.js +0 -2
  152. package/plugins/map-styles/dist/umd/im-map-styles-plugin.js +0 -1
  153. package/plugins/map-styles/dist/umd/index.js +0 -2
  154. package/plugins/scale-bar/dist/esm/im-scale-bar-plugin.js +0 -1
  155. package/plugins/scale-bar/dist/esm/index.js +0 -2
  156. package/plugins/scale-bar/dist/umd/im-scale-bar-plugin.js +0 -1
  157. package/plugins/scale-bar/dist/umd/index.js +0 -2
  158. package/plugins/use-location/dist/esm/im-use-location-plugin.js +0 -1
  159. package/plugins/use-location/dist/esm/index.js +0 -2
  160. package/plugins/use-location/dist/umd/im-use-location-plugin.js +0 -1
  161. package/plugins/use-location/dist/umd/index.js +0 -2
  162. package/providers/open-names/dist/esm/im-reverse-geocode.js +0 -2
  163. package/providers/open-names/dist/esm/index.js +0 -2
  164. package/providers/open-names/dist/umd/im-reverse-geocode.js +0 -2
  165. package/providers/open-names/dist/umd/index.js +0 -2
  166. /package/docs/architecture/{ARCHITECTURE_DIAGRAMS.md → architecture-diagrams.md} +0 -0
  167. /package/plugins/{datasets → beta/datasets}/dist/css/index.css +0 -0
  168. /package/plugins/{datasets → beta/datasets}/dist/esm/index.js.LICENSE.txt +0 -0
  169. /package/plugins/{datasets → beta/datasets}/dist/umd/index.js.LICENSE.txt +0 -0
  170. /package/plugins/{datasets → beta/datasets}/src/DatasetsInit.jsx +0 -0
  171. /package/plugins/{datasets → beta/datasets}/src/api/addDataset.js +0 -0
  172. /package/plugins/{datasets → beta/datasets}/src/api/hideDataset.js +0 -0
  173. /package/plugins/{datasets → beta/datasets}/src/api/hideFeatures.js +0 -0
  174. /package/plugins/{datasets → beta/datasets}/src/api/removeDataset.js +0 -0
  175. /package/plugins/{datasets → beta/datasets}/src/api/showDataset.js +0 -0
  176. /package/plugins/{datasets → beta/datasets}/src/api/showFeatures.js +0 -0
  177. /package/plugins/{datasets → beta/datasets}/src/datasets.js +0 -0
  178. /package/plugins/{datasets → beta/datasets}/src/datasets.scss +0 -0
  179. /package/plugins/{datasets → beta/datasets}/src/defaults.js +0 -0
  180. /package/plugins/{datasets → beta/datasets}/src/handleSetMapStyle.js +0 -0
  181. /package/plugins/{datasets → beta/datasets}/src/index.js +0 -0
  182. /package/plugins/{datasets → beta/datasets}/src/panels/Key.module.scss +0 -0
  183. /package/plugins/{datasets → beta/datasets}/src/panels/Layers.jsx +0 -0
  184. /package/plugins/{datasets → beta/datasets}/src/panels/Layers.module.scss +0 -0
  185. /package/plugins/{datasets → beta/datasets}/src/reducer.js +0 -0
  186. /package/plugins/{datasets → beta/datasets}/src/utils/bbox.js +0 -0
  187. /package/plugins/{datasets → beta/datasets}/src/utils/debounce.js +0 -0
  188. /package/plugins/{datasets → beta/datasets}/src/utils/filters.js +0 -0
  189. /package/plugins/{draw-es → beta/draw-es}/src/DrawInit.jsx +0 -0
  190. /package/plugins/{draw-es → beta/draw-es}/src/api/addFeature.js +0 -0
  191. /package/plugins/{draw-es → beta/draw-es}/src/api/deleteFeature.js +0 -0
  192. /package/plugins/{draw-es → beta/draw-es}/src/api/editFeature.js +0 -0
  193. /package/plugins/{draw-es → beta/draw-es}/src/api/newPolygon.js +0 -0
  194. /package/plugins/{draw-es → beta/draw-es}/src/events.js +0 -0
  195. /package/plugins/{draw-es → beta/draw-es}/src/graphic.js +0 -0
  196. /package/plugins/{draw-es → beta/draw-es}/src/index.js +0 -0
  197. /package/plugins/{draw-es → beta/draw-es}/src/manifest.js +0 -0
  198. /package/plugins/{draw-es → beta/draw-es}/src/reducer.js +0 -0
  199. /package/plugins/{draw-es → beta/draw-es}/src/sketchViewModel.js +0 -0
  200. /package/plugins/{draw-ml → beta/draw-ml}/dist/css/index.css +0 -0
  201. /package/plugins/{draw-ml → beta/draw-ml}/dist/esm/index.js.LICENSE.txt +0 -0
  202. /package/plugins/{draw-ml → beta/draw-ml}/dist/umd/index.js.LICENSE.txt +0 -0
  203. /package/plugins/{draw-ml → beta/draw-ml}/src/DrawInit.jsx +0 -0
  204. /package/plugins/{draw-ml → beta/draw-ml}/src/api/addFeature.js +0 -0
  205. /package/plugins/{draw-ml → beta/draw-ml}/src/api/deleteFeature.js +0 -0
  206. /package/plugins/{draw-ml → beta/draw-ml}/src/draw.scss +0 -0
  207. /package/plugins/{draw-ml → beta/draw-ml}/src/index.js +0 -0
  208. /package/plugins/{draw-ml → beta/draw-ml}/src/modes/disabledMode.js +0 -0
  209. /package/plugins/{draw-ml/src → beta/draw-ml/src/utils}/snapHelpers.js +0 -0
  210. /package/plugins/{frame → beta/frame}/dist/css/index.css +0 -0
  211. /package/plugins/{frame → beta/frame}/dist/esm/index.js.LICENSE.txt +0 -0
  212. /package/plugins/{frame → beta/frame}/dist/umd/index.js.LICENSE.txt +0 -0
  213. /package/plugins/{frame → beta/frame}/package.json +0 -0
  214. /package/plugins/{frame → beta/frame}/src/Frame.jsx +0 -0
  215. /package/plugins/{frame → beta/frame}/src/FrameInit.jsx +0 -0
  216. /package/plugins/{frame → beta/frame}/src/api/addFrame.js +0 -0
  217. /package/plugins/{frame → beta/frame}/src/api/editFeature.js +0 -0
  218. /package/plugins/{frame → beta/frame}/src/config.js +0 -0
  219. /package/plugins/{frame → beta/frame}/src/frame.scss +0 -0
  220. /package/plugins/{frame → beta/frame}/src/index.js +0 -0
  221. /package/plugins/{frame → beta/frame}/src/manifest.js +0 -0
  222. /package/plugins/{frame → beta/frame}/src/reducer.js +0 -0
  223. /package/plugins/{frame → beta/frame}/src/utils.js +0 -0
  224. /package/plugins/{map-styles → beta/map-styles}/dist/css/index.css +0 -0
  225. /package/plugins/{map-styles → beta/map-styles}/dist/esm/index.js.LICENSE.txt +0 -0
  226. /package/plugins/{map-styles → beta/map-styles}/dist/umd/index.js.LICENSE.txt +0 -0
  227. /package/plugins/{map-styles → beta/map-styles}/src/MapStylesInit.jsx +0 -0
  228. /package/plugins/{map-styles → beta/map-styles}/src/config.js +0 -0
  229. /package/plugins/{map-styles → beta/map-styles}/src/index.js +0 -0
  230. /package/plugins/{map-styles → beta/map-styles}/src/mapStyles.scss +0 -0
  231. /package/plugins/{scale-bar → beta/scale-bar}/dist/css/index.css +0 -0
  232. /package/plugins/{scale-bar → beta/scale-bar}/dist/esm/index.js.LICENSE.txt +0 -0
  233. /package/plugins/{scale-bar → beta/scale-bar}/dist/umd/index.js.LICENSE.txt +0 -0
  234. /package/plugins/{scale-bar → beta/scale-bar}/package.json +0 -0
  235. /package/plugins/{scale-bar → beta/scale-bar}/src/ScaleBar.jsx +0 -0
  236. /package/plugins/{scale-bar → beta/scale-bar}/src/ScaleBar.test.jsx +0 -0
  237. /package/plugins/{scale-bar → beta/scale-bar}/src/index.js +0 -0
  238. /package/plugins/{scale-bar → beta/scale-bar}/src/index.test.js +0 -0
  239. /package/plugins/{scale-bar → beta/scale-bar}/src/manifest.js +0 -0
  240. /package/plugins/{scale-bar → beta/scale-bar}/src/scaleBar.scss +0 -0
  241. /package/plugins/{scale-bar → beta/scale-bar}/src/utils.js +0 -0
  242. /package/plugins/{scale-bar → beta/scale-bar}/src/utils.test.js +0 -0
  243. /package/plugins/{use-location → beta/use-location}/dist/esm/index.js.LICENSE.txt +0 -0
  244. /package/plugins/{use-location → beta/use-location}/dist/umd/index.js.LICENSE.txt +0 -0
  245. /package/plugins/{use-location → beta/use-location}/src/UseLocation.jsx +0 -0
  246. /package/plugins/{use-location → beta/use-location}/src/UseLocationInit.jsx +0 -0
  247. /package/plugins/{use-location → beta/use-location}/src/defaults.js +0 -0
  248. /package/plugins/{use-location → beta/use-location}/src/events.js +0 -0
  249. /package/plugins/{use-location → beta/use-location}/src/index.js +0 -0
  250. /package/plugins/{use-location → beta/use-location}/src/manifest.js +0 -0
  251. /package/plugins/{use-location → beta/use-location}/src/reducer.js +0 -0
  252. /package/providers/{esri → beta/esri}/src/appEvents.js +0 -0
  253. /package/providers/{esri → beta/esri}/src/defaults.js +0 -0
  254. /package/providers/{esri → beta/esri}/src/esriProvider.js +0 -0
  255. /package/providers/{esri → beta/esri}/src/esriProvider.scss +0 -0
  256. /package/providers/{esri → beta/esri}/src/index.js +0 -0
  257. /package/providers/{esri → beta/esri}/src/utils/coords.js +0 -0
  258. /package/providers/{esri → beta/esri}/src/utils/detectWebGL.js +0 -0
  259. /package/providers/{esri → beta/esri}/src/utils/esriFixes.js +0 -0
  260. /package/providers/{esri → beta/esri}/src/utils/query.js +0 -0
  261. /package/providers/{esri → beta/esri}/src/utils/spatial.js +0 -0
  262. /package/providers/{open-names → beta/open-names}/dist/esm/im-reverse-geocode.js.LICENSE.txt +0 -0
  263. /package/providers/{open-names → beta/open-names}/dist/esm/index.js.LICENSE.txt +0 -0
  264. /package/providers/{open-names → beta/open-names}/dist/umd/im-reverse-geocode.js.LICENSE.txt +0 -0
  265. /package/providers/{open-names → beta/open-names}/dist/umd/index.js.LICENSE.txt +0 -0
  266. /package/providers/{open-names → beta/open-names}/src/index.js +0 -0
  267. /package/providers/{open-names → beta/open-names}/src/reverseGeocode.js +0 -0
  268. /package/providers/{open-names → beta/open-names}/src/utils/mapToLocationModel.js +0 -0
@@ -0,0 +1,260 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useInteractionHandlers } from './useInteractionHandlers.js'
3
+ import * as featureQueries from '../utils/featureQueries.js'
4
+ import booleanDisjoint from '@turf/boolean-disjoint'
5
+
6
+ /* ------------------------------------------------------------------ */
7
+ /* Mocks */
8
+ /* ------------------------------------------------------------------ */
9
+
10
+ jest.mock('@turf/boolean-disjoint', () => jest.fn())
11
+ jest.mock('../utils/turfHelpers.js', () => ({
12
+ toTurfGeometry: jest.fn(g => g)
13
+ }))
14
+ jest.mock('../utils/featureQueries.js', () => ({
15
+ getFeaturesAtPoint: jest.fn(),
16
+ findMatchingFeature: jest.fn(),
17
+ buildLayerConfigMap: jest.fn(() => ({}))
18
+ }))
19
+
20
+ /* ------------------------------------------------------------------ */
21
+ /* Helpers */
22
+ /* ------------------------------------------------------------------ */
23
+
24
+ const click = result =>
25
+ act(() =>
26
+ result.current.handleInteraction({
27
+ point: { x: 10, y: 20 },
28
+ coords: [1, 2]
29
+ })
30
+ )
31
+
32
+ const setup = (pluginOverrides = {}) => {
33
+ const deps = {
34
+ mapState: {
35
+ markers: { add: jest.fn(), remove: jest.fn() }
36
+ },
37
+ pluginState: {
38
+ dispatch: jest.fn(),
39
+ dataLayers: [{ layerId: 'parcels', idProperty: 'parcelId' }],
40
+ interactionMode: 'select',
41
+ multiSelect: false,
42
+ contiguous: false,
43
+ markerColor: 'red',
44
+ selectedFeatures: [],
45
+ selectionBounds: null,
46
+ ...pluginOverrides
47
+ },
48
+ services: {
49
+ eventBus: { emit: jest.fn() }
50
+ },
51
+ mapProvider: {}
52
+ }
53
+
54
+ const utils = renderHook(() => useInteractionHandlers(deps))
55
+ return { ...utils, deps }
56
+ }
57
+
58
+ const baseFeature = {
59
+ properties: { parcelId: 'P1' },
60
+ geometry: { type: 'Polygon' },
61
+ layer: { id: 'parcels' }
62
+ }
63
+
64
+ beforeEach(() => {
65
+ jest.clearAllMocks()
66
+
67
+ featureQueries.getFeaturesAtPoint.mockReturnValue([baseFeature])
68
+ featureQueries.findMatchingFeature.mockReturnValue({
69
+ feature: baseFeature,
70
+ config: { layerId: 'parcels', idProperty: 'parcelId' }
71
+ })
72
+ })
73
+
74
+ /* ------------------------------------------------------------------ */
75
+ /* Marker mode */
76
+ /* ------------------------------------------------------------------ */
77
+
78
+ describe('marker mode', () => {
79
+ it('places marker, clears selection, and emits event', () => {
80
+ const { result, deps } = setup({ interactionMode: 'marker' })
81
+
82
+ click(result)
83
+
84
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith({
85
+ type: 'CLEAR_SELECTED_FEATURES'
86
+ })
87
+ expect(deps.mapState.markers.add).toHaveBeenCalledWith(
88
+ 'location',
89
+ [1, 2],
90
+ { color: 'red' }
91
+ )
92
+ expect(deps.services.eventBus.emit).toHaveBeenCalledWith(
93
+ 'interact:markerchange',
94
+ { coords: [1, 2] }
95
+ )
96
+ })
97
+ })
98
+
99
+ /* ------------------------------------------------------------------ */
100
+ /* Select & Auto modes */
101
+ /* ------------------------------------------------------------------ */
102
+
103
+ describe.each(['select', 'auto'])('%s mode feature selection', mode => {
104
+ it('dispatches selection for matching feature', () => {
105
+ const { result, deps } = setup({ interactionMode: mode })
106
+
107
+ click(result)
108
+
109
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
110
+ expect.objectContaining({
111
+ type: 'TOGGLE_SELECTED_FEATURES',
112
+ payload: expect.objectContaining({
113
+ featureId: 'P1',
114
+ layerId: 'parcels'
115
+ })
116
+ })
117
+ )
118
+ })
119
+ })
120
+
121
+ it('falls back to marker in auto mode when no feature found', () => {
122
+ featureQueries.getFeaturesAtPoint.mockReturnValue([])
123
+ featureQueries.findMatchingFeature.mockReturnValue(null)
124
+
125
+ const { result, deps } = setup({ interactionMode: 'auto' })
126
+
127
+ click(result)
128
+
129
+ expect(deps.mapState.markers.add).toHaveBeenCalled()
130
+ })
131
+
132
+ /* ------------------------------------------------------------------ */
133
+ /* featureId guard (FULL COVERAGE) */
134
+ /* ------------------------------------------------------------------ */
135
+
136
+ it('skips dispatch when idProperty exists but featureId is falsy', () => {
137
+ featureQueries.findMatchingFeature.mockReturnValue({
138
+ feature: {
139
+ properties: { parcelId: undefined },
140
+ geometry: { type: 'Point' },
141
+ layer: { id: 'parcels' }
142
+ },
143
+ config: { layerId: 'parcels', idProperty: 'parcelId' }
144
+ })
145
+
146
+ const { result, deps } = setup()
147
+
148
+ click(result)
149
+
150
+ expect(deps.pluginState.dispatch).not.toHaveBeenCalled()
151
+ })
152
+
153
+ /* ------------------------------------------------------------------ */
154
+ /* Multi-select */
155
+ /* ------------------------------------------------------------------ */
156
+
157
+ it('passes multiSelect flag through to dispatch', () => {
158
+ const { result, deps } = setup({ multiSelect: true })
159
+
160
+ click(result)
161
+
162
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
163
+ expect.objectContaining({
164
+ payload: expect.objectContaining({ multiSelect: true })
165
+ })
166
+ )
167
+ })
168
+
169
+ /* ------------------------------------------------------------------ */
170
+ /* Contiguous selection (FULL COVERAGE) */
171
+ /* ------------------------------------------------------------------ */
172
+
173
+ describe('contiguous selection', () => {
174
+ it('does NOT replace selection when feature is contiguous', () => {
175
+ booleanDisjoint.mockReturnValue(false) // overlapping
176
+
177
+ const { result, deps } = setup({
178
+ contiguous: true,
179
+ selectedFeatures: [{ geometry: { type: 'Polygon' } }]
180
+ })
181
+
182
+ click(result)
183
+
184
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
185
+ expect.objectContaining({
186
+ payload: expect.objectContaining({
187
+ replaceAll: false
188
+ })
189
+ })
190
+ )
191
+ })
192
+
193
+ it('replaces selection when feature is NOT contiguous', () => {
194
+ booleanDisjoint.mockReturnValue(true) // disjoint
195
+
196
+ const { result, deps } = setup({
197
+ contiguous: true,
198
+ selectedFeatures: [{ geometry: { type: 'Polygon' } }]
199
+ })
200
+
201
+ click(result)
202
+
203
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
204
+ expect.objectContaining({
205
+ payload: expect.objectContaining({
206
+ replaceAll: true
207
+ })
208
+ })
209
+ )
210
+ })
211
+
212
+ it('does not compute contiguity when contiguous is false', () => {
213
+ const { result } = setup({ contiguous: false })
214
+
215
+ click(result)
216
+
217
+ expect(booleanDisjoint).not.toHaveBeenCalled()
218
+ })
219
+ })
220
+
221
+ /* ------------------------------------------------------------------ */
222
+ /* Marker condition guard (FULL COVERAGE) */
223
+ /* ------------------------------------------------------------------ */
224
+
225
+ it('does NOT place marker in auto mode when no dataLayers exist', () => {
226
+ featureQueries.getFeaturesAtPoint.mockReturnValue([])
227
+ featureQueries.findMatchingFeature.mockReturnValue(null)
228
+
229
+ const { result, deps } = setup({
230
+ interactionMode: 'auto',
231
+ dataLayers: []
232
+ })
233
+
234
+ click(result)
235
+
236
+ expect(deps.mapState.markers.add).not.toHaveBeenCalled()
237
+ })
238
+
239
+ /* ------------------------------------------------------------------ */
240
+ /* Selection change event */
241
+ /* ------------------------------------------------------------------ */
242
+
243
+ it('emits selectionchange once when bounds exist', () => {
244
+ const deps = {
245
+ mapState: { markers: { add: jest.fn(), remove: jest.fn() } },
246
+ pluginState: {
247
+ selectedFeatures: [{ featureId: 'F1' }],
248
+ selectionBounds: { sw: [0, 0], ne: [1, 1] }
249
+ },
250
+ services: { eventBus: { emit: jest.fn() } },
251
+ mapProvider: {}
252
+ }
253
+
254
+ renderHook(() => useInteractionHandlers(deps))
255
+
256
+ expect(deps.services.eventBus.emit).toHaveBeenCalledWith(
257
+ 'interact:selectionchange',
258
+ deps.pluginState
259
+ )
260
+ })
@@ -0,0 +1,22 @@
1
+ import createPlugin from './index.js'
2
+
3
+ jest.mock('./interact.scss', () => ({}))
4
+
5
+ describe('createPlugin', () => {
6
+ it('returns plugin with fixed id and load function', () => {
7
+ const plugin = createPlugin({ foo: 'bar' })
8
+ expect(plugin.id).toBe('interact')
9
+ expect(typeof plugin.load).toBe('function')
10
+ expect(plugin.foo).toBe('bar') // merged option
11
+ })
12
+
13
+ it('load() returns manifest', async () => {
14
+ const plugin = createPlugin()
15
+ const manifest = await plugin.load()
16
+ expect(manifest).toBeDefined()
17
+ expect(manifest.InitComponent).toBeDefined()
18
+ expect(manifest.reducer).toBeDefined()
19
+ expect(Array.isArray(manifest.buttons)).toBe(true)
20
+ expect(manifest.api).toBeDefined()
21
+ })
22
+ })
@@ -20,7 +20,7 @@ export const manifest = {
20
20
  label: 'Done',
21
21
  variant: 'primary',
22
22
  excludeWhen: ({ appState, pluginState }) => !pluginState.enabled || !appState.isFullscreen,
23
- enableWhen: ({ mapState, pluginState }) => !!mapState.markers.items.find(m => m.id === 'location') || !!pluginState.selectionBounds,
23
+ enableWhen: ({ mapState, pluginState }) => !!mapState.markers.items.some(m => m.id === 'location') || !!pluginState.selectionBounds,
24
24
  mobile: {
25
25
  slot: 'actions',
26
26
  showLabel: true
@@ -0,0 +1,94 @@
1
+ import { manifest } from './manifest.js'
2
+ import { initialState, actions } from './reducer.js'
3
+
4
+ jest.mock('./api/enable.js', () => ({ enable: jest.fn() }))
5
+ jest.mock('./api/disable.js', () => ({ disable: jest.fn() }))
6
+ jest.mock('./api/clear.js', () => ({ clear: jest.fn() }))
7
+ jest.mock('./api/selectFeature.js', () => ({ selectFeature: jest.fn() }))
8
+ jest.mock('./api/unselectFeature.js', () => ({ unselectFeature: jest.fn() }))
9
+
10
+ describe('manifest', () => {
11
+ it('has InitComponent', () => {
12
+ expect(manifest.InitComponent).toBeDefined()
13
+ })
14
+
15
+ it('has reducer with initialState and actions', () => {
16
+ expect(manifest.reducer.initialState).toBeDefined()
17
+ expect(manifest.reducer.actions).toBeDefined()
18
+ })
19
+
20
+ it('defines buttons with correct ids', () => {
21
+ const ids = manifest.buttons.map(b => b.id)
22
+ expect(ids).toEqual(expect.arrayContaining(['selectDone', 'selectAtTarget', 'selectCancel']))
23
+ })
24
+
25
+ it('buttons have slots and showLabel', () => {
26
+ manifest.buttons.forEach(b => {
27
+ ['mobile','tablet','desktop'].forEach(dev => {
28
+ expect(b[dev].slot).toBe('actions')
29
+ expect(b[dev].showLabel).toBe(true)
30
+ })
31
+ })
32
+ })
33
+
34
+ it('button logic functions cover all branches', () => {
35
+ const done = manifest.buttons.find(b => b.id === 'selectDone')
36
+ const atTarget = manifest.buttons.find(b => b.id === 'selectAtTarget')
37
+ const cancel = manifest.buttons.find(b => b.id === 'selectCancel')
38
+
39
+ // selectDone.excludeWhen
40
+ expect(done.excludeWhen({ appState: { isFullscreen: false }, pluginState: { enabled: true } })).toBe(true)
41
+ expect(done.excludeWhen({ appState: { isFullscreen: true }, pluginState: { enabled: true } })).toBe(false)
42
+
43
+ // selectDone.enableWhen
44
+ expect(done.enableWhen({
45
+ mapState: { markers: { items: [{ id: 'location' }] } },
46
+ pluginState: { selectionBounds: null }
47
+ })).toBe(true)
48
+ expect(done.enableWhen({
49
+ mapState: { markers: { items: [] } },
50
+ pluginState: { selectionBounds: null }
51
+ })).toBe(false)
52
+ expect(done.enableWhen({
53
+ mapState: { markers: { items: [] } },
54
+ pluginState: { selectionBounds: { sw:[0,0], ne:[1,1] } }
55
+ })).toBe(true)
56
+
57
+ // selectAtTarget.hiddenWhen
58
+ expect(atTarget.hiddenWhen({ appState: { interfaceType: 'pointer' }, pluginState: { enabled: true } })).toBe(true)
59
+ expect(atTarget.hiddenWhen({ appState: { interfaceType: 'touch' }, pluginState: { enabled: true } })).toBe(false)
60
+ expect(atTarget.hiddenWhen({ appState: { interfaceType: 'touch' }, pluginState: { enabled: false } })).toBe(true)
61
+
62
+ // selectCancel.hiddenWhen
63
+ expect(cancel.hiddenWhen({
64
+ appConfig: { behaviour: 'always' },
65
+ appState: { isFullscreen: true },
66
+ pluginState: { enabled: true }
67
+ })).toBe(true)
68
+ expect(cancel.hiddenWhen({
69
+ appConfig: { behaviour: 'hybrid' },
70
+ appState: { isFullscreen: true },
71
+ pluginState: { enabled: true }
72
+ })).toBe(false)
73
+ expect(cancel.hiddenWhen({
74
+ appConfig: { behaviour: 'hybrid' },
75
+ appState: { isFullscreen: false },
76
+ pluginState: { enabled: true }
77
+ })).toBe(true)
78
+ expect(cancel.hiddenWhen({
79
+ appConfig: { behaviour: 'hybrid' },
80
+ appState: { isFullscreen: true },
81
+ pluginState: { enabled: false }
82
+ })).toBe(true)
83
+ })
84
+
85
+ it('keyboardShortcuts array exists', () => {
86
+ expect(Array.isArray(manifest.keyboardShortcuts)).toBe(true)
87
+ })
88
+
89
+ it('api exposes expected methods', () => {
90
+ ['enable','disable','clear','selectFeature','unselectFeature'].forEach(fn => {
91
+ expect(typeof manifest.api[fn]).toBe('function')
92
+ })
93
+ })
94
+ })
@@ -4,6 +4,7 @@ const initialState = {
4
4
  markerColor: null,
5
5
  interactionMode: null,
6
6
  multiSelect: false,
7
+ contiguous: false,
7
8
  selectedFeatures: [],
8
9
  selectionBounds: null,
9
10
  closeOnAction: true // Done or Cancel
@@ -29,7 +30,7 @@ const disable = (state) => {
29
30
  * Structure of items in Set: { featureId: string, layerId: string, idProperty: string }
30
31
  */
31
32
  const toggleSelectedFeatures = (state, payload) => {
32
- const { featureId, multiSelect, layerId, idProperty, addToExisting = true, properties, geometry } = payload
33
+ const { featureId, multiSelect, layerId, idProperty, addToExisting = true, replaceAll = false, properties, geometry } = payload
33
34
  const selected = Array.isArray(state.selectedFeatures) ? [...state.selectedFeatures] : []
34
35
 
35
36
  const existingIndex = selected.findIndex(
@@ -44,6 +45,14 @@ const toggleSelectedFeatures = (state, payload) => {
44
45
  return { ...state, selectedFeatures: selected }
45
46
  }
46
47
 
48
+ // Replace all selected features if flag is true
49
+ if (replaceAll) {
50
+ return {
51
+ ...state,
52
+ selectedFeatures: [{ featureId, layerId, idProperty, properties, geometry }]
53
+ }
54
+ }
55
+
47
56
  // Multi-select mode (add to selection)
48
57
  if (multiSelect) {
49
58
  if (existingIndex === -1) {
@@ -0,0 +1,142 @@
1
+ import { initialState, actions } from './reducer.js'
2
+
3
+ describe('initialState', () => {
4
+ it('has correct defaults', () => {
5
+ expect(initialState).toEqual({
6
+ enabled: false,
7
+ dataLayers: [],
8
+ markerColor: null,
9
+ interactionMode: null,
10
+ multiSelect: false,
11
+ contiguous: false,
12
+ selectedFeatures: [],
13
+ selectionBounds: null,
14
+ closeOnAction: true
15
+ })
16
+ })
17
+ })
18
+
19
+ describe('ENABLE/DISABLE actions', () => {
20
+ it('ENABLE sets enabled and merges payload', () => {
21
+ const state = { ...initialState, enabled: false }
22
+ const payload = { dataLayers: [1], markerColor: 'red' }
23
+ const result = actions.ENABLE(state, payload)
24
+
25
+ expect(result.enabled).toBe(true)
26
+ expect(result.dataLayers).toEqual([1])
27
+ expect(result.markerColor).toBe('red')
28
+ expect(result).not.toBe(state)
29
+ })
30
+
31
+ it('DISABLE sets enabled to false but preserves other state', () => {
32
+ const state = { ...initialState, enabled: true, dataLayers: [1], markerColor: 'red' }
33
+ const result = actions.DISABLE(state)
34
+
35
+ expect(result.enabled).toBe(false)
36
+ expect(result.dataLayers).toEqual([1])
37
+ expect(result.markerColor).toBe('red')
38
+ expect(result).not.toBe(state)
39
+ })
40
+ })
41
+
42
+
43
+ describe('TOGGLE_SELECTED_FEATURES action', () => {
44
+ const createFeature = (id, layerId = 'layer1') => ({
45
+ featureId: id,
46
+ layerId,
47
+ idProperty: 'id',
48
+ properties: { name: `Feature ${id}` },
49
+ geometry: { type: 'Point', coordinates: [0, 0] }
50
+ })
51
+
52
+ it('handles single-select, multi-select, add/remove, and replaceAll', () => {
53
+ let state = { ...initialState }
54
+
55
+ // Single-select: add
56
+ state = actions.TOGGLE_SELECTED_FEATURES(state, createFeature('f1'))
57
+ expect(state.selectedFeatures).toHaveLength(1)
58
+
59
+ // Single-select: replace
60
+ state = actions.TOGGLE_SELECTED_FEATURES(state, createFeature('f2'))
61
+ expect(state.selectedFeatures[0].featureId).toBe('f2')
62
+
63
+ // Toggle off same
64
+ state = actions.TOGGLE_SELECTED_FEATURES(state, createFeature('f2'))
65
+ expect(state.selectedFeatures).toHaveLength(0)
66
+
67
+ // Multi-select: add multiple
68
+ state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f1'), multiSelect: true })
69
+ state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f2'), multiSelect: true })
70
+ expect(state.selectedFeatures.map(f => f.featureId)).toEqual(['f1', 'f2'])
71
+
72
+ // Multi-select: remove
73
+ state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f1'), multiSelect: true })
74
+ expect(state.selectedFeatures.map(f => f.featureId)).toEqual(['f2'])
75
+
76
+ // addToExisting false removes only the feature
77
+ state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f2'), addToExisting: false })
78
+ expect(state.selectedFeatures).toHaveLength(0)
79
+
80
+ // replaceAll replaces everything
81
+ state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f3'), replaceAll: true })
82
+ expect(state.selectedFeatures).toHaveLength(1)
83
+ expect(state.selectedFeatures[0].featureId).toBe('f3')
84
+ })
85
+
86
+ it('handles null or empty selectedFeatures gracefully', () => {
87
+ let state = { ...initialState, selectedFeatures: null }
88
+ state = actions.TOGGLE_SELECTED_FEATURES(state, createFeature('f1'))
89
+ expect(state.selectedFeatures).toHaveLength(1)
90
+
91
+ state = { ...initialState, selectedFeatures: [] }
92
+ state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f2'), addToExisting: false })
93
+ expect(state.selectedFeatures).toEqual([])
94
+ })
95
+
96
+ it('matches by both featureId and layerId', () => {
97
+ const state = { ...initialState, selectedFeatures: [createFeature('f1', 'layer1')] }
98
+ const payload = createFeature('f1', 'layer2')
99
+ const result = actions.TOGGLE_SELECTED_FEATURES(state, payload)
100
+ expect(result.selectedFeatures[0].layerId).toBe('layer2')
101
+ })
102
+ })
103
+
104
+ describe('UPDATE_SELECTED_BOUNDS action', () => {
105
+ it('updates selectionBounds correctly', () => {
106
+ const state = { ...initialState, selectionBounds: { sw: [0, 0], ne: [1, 1] } }
107
+ const newBounds = { sw: [0, 0], ne: [2, 2] }
108
+ const result = actions.UPDATE_SELECTED_BOUNDS(state, newBounds)
109
+ expect(result.selectionBounds).toEqual(newBounds)
110
+
111
+ // unchanged bounds returns same state
112
+ const result2 = actions.UPDATE_SELECTED_BOUNDS(state, { sw: [0, 0], ne: [1, 1] })
113
+ expect(result2).toBe(state)
114
+ })
115
+ })
116
+
117
+ describe('CLEAR_SELECTED_FEATURES action', () => {
118
+ it('resets selection and bounds', () => {
119
+ const state = {
120
+ ...initialState,
121
+ selectedFeatures: [1],
122
+ selectionBounds: { sw: [0, 0], ne: [1, 1] }
123
+ }
124
+ const result = actions.CLEAR_SELECTED_FEATURES(state)
125
+ expect(result.selectedFeatures).toEqual([])
126
+ expect(result.selectionBounds).toBeNull()
127
+ expect(result).not.toBe(state)
128
+ })
129
+ })
130
+
131
+ describe('actions object', () => {
132
+ it('exports all action handlers as functions', () => {
133
+ expect(Object.keys(actions)).toEqual([
134
+ 'ENABLE',
135
+ 'DISABLE',
136
+ 'TOGGLE_SELECTED_FEATURES',
137
+ 'UPDATE_SELECTED_BOUNDS',
138
+ 'CLEAR_SELECTED_FEATURES'
139
+ ])
140
+ Object.values(actions).forEach(fn => expect(typeof fn).toBe('function'))
141
+ })
142
+ })
@@ -0,0 +1,57 @@
1
+ import { buildStylesMap } from './buildStylesMap.js'
2
+ import { DEFAULTS } from '../defaults.js'
3
+ import { getValueForStyle } from '../../../../src/utils/getValueForStyle.js'
4
+
5
+ jest.mock('../../../../src/utils/getValueForStyle.js', () => ({
6
+ getValueForStyle: jest.fn((value) => value)
7
+ }))
8
+
9
+ describe('buildStylesMap', () => {
10
+ beforeEach(() => {
11
+ jest.clearAllMocks()
12
+ })
13
+
14
+ it('returns empty object when mapStyle is falsy or dataLayers empty', () => {
15
+ expect(buildStylesMap([{ layerId: 'test' }], null)).toEqual({})
16
+ expect(buildStylesMap([{ layerId: 'test' }], undefined)).toEqual({})
17
+ expect(buildStylesMap([], { id: 'default' })).toEqual({})
18
+ })
19
+
20
+ it('builds correct stylesMap with defaults and layer overrides', () => {
21
+ const dataLayers = [
22
+ { layerId: 'custom1', selectedStroke: 'red', selectedFill: 'blue', selectedStrokeWidth: 5 },
23
+ { layerId: 'custom2' } // uses defaults
24
+ ]
25
+ const mapStyle = { id: 'default-style' }
26
+
27
+ const result = buildStylesMap(dataLayers, mapStyle)
28
+
29
+ // Layer-specific values
30
+ expect(result.custom1).toEqual({
31
+ stroke: 'red',
32
+ fill: 'blue',
33
+ strokeWidth: 5
34
+ })
35
+
36
+ // Default fallback values
37
+ expect(result.custom2.stroke).toBe(DEFAULTS.selectedStroke)
38
+ expect(result.custom2.fill).toBe(DEFAULTS.selectedFill)
39
+ expect(result.custom2.strokeWidth).toBe(DEFAULTS.selectedStrokeWidth)
40
+ })
41
+
42
+ it('calls getValueForStyle for stroke and fill with mapStyle.id', () => {
43
+ const dataLayers = [
44
+ { layerId: 'layer1', selectedStroke: 'strokeVal', selectedFill: 'fillVal' }
45
+ ]
46
+ const mapStyle = { id: 'mapStyleId' }
47
+
48
+ buildStylesMap(dataLayers, mapStyle)
49
+
50
+ expect(getValueForStyle).toHaveBeenCalledWith('strokeVal', 'mapStyleId')
51
+ expect(getValueForStyle).toHaveBeenCalledWith('fillVal', 'mapStyleId')
52
+
53
+ // Ensure strokeWidth is not passed to getValueForStyle
54
+ const numberCalls = getValueForStyle.mock.calls.filter(call => typeof call[0] === 'number')
55
+ expect(numberCalls).toHaveLength(0)
56
+ })
57
+ })
@@ -0,0 +1,56 @@
1
+ import { buildLayerConfigMap, getFeaturesAtPoint, findMatchingFeature } from './featureQueries.js'
2
+
3
+ describe('buildLayerConfigMap', () => {
4
+ it('builds map keyed by layerId, handles empty array', () => {
5
+ const dataLayers = [
6
+ { layerId: 'a', value: 1 },
7
+ { layerId: 'b', value: 2 }
8
+ ]
9
+
10
+ const result = buildLayerConfigMap(dataLayers)
11
+ expect(result).toEqual({
12
+ a: { layerId: 'a', value: 1 },
13
+ b: { layerId: 'b', value: 2 }
14
+ })
15
+
16
+ expect(buildLayerConfigMap([])).toEqual({})
17
+ })
18
+ })
19
+
20
+ describe('getFeaturesAtPoint', () => {
21
+ it('returns features or empty array, handles errors', () => {
22
+ const features = [{ id: 'f1' }]
23
+ const mockProvider = {
24
+ getFeaturesAtPoint: jest.fn(() => features)
25
+ }
26
+
27
+ expect(getFeaturesAtPoint(mockProvider, { x: 0, y: 0 })).toBe(features)
28
+ expect(getFeaturesAtPoint(null, { x: 0, y: 0 })).toEqual([])
29
+ expect(getFeaturesAtPoint(undefined, { x: 0, y: 0 })).toEqual([])
30
+
31
+ // error handling
32
+ const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
33
+ const errorProvider = { getFeaturesAtPoint: jest.fn(() => { throw new Error('fail') }) }
34
+ expect(getFeaturesAtPoint(errorProvider, { x: 0, y: 0 })).toEqual([])
35
+ expect(consoleSpy).toHaveBeenCalledWith('Feature query failed:', expect.any(Error))
36
+ consoleSpy.mockRestore()
37
+ })
38
+ })
39
+
40
+ describe('findMatchingFeature', () => {
41
+ const layerConfigMap = { layer1: { layerId: 'layer1' } }
42
+
43
+ it('returns first feature matching config, null otherwise', () => {
44
+ const features = [
45
+ { id: 'f1', layer: { id: 'unknown' } },
46
+ { id: 'f2', layer: { id: 'layer1' } }
47
+ ]
48
+ const result = findMatchingFeature(features, layerConfigMap)
49
+ expect(result).toEqual({ feature: features[1], config: layerConfigMap.layer1 })
50
+
51
+ expect(findMatchingFeature([], layerConfigMap)).toBeNull()
52
+ expect(findMatchingFeature([{ id: 'f3', layer: {} }], layerConfigMap)).toBeNull()
53
+ expect(findMatchingFeature([{ id: 'f4' }], layerConfigMap)).toBeNull()
54
+ expect(findMatchingFeature([{ id: 'f5', layer: { id: 'other' } }], {})).toBeNull()
55
+ })
56
+ })