@defra/interactive-map 0.0.40-alpha → 0.0.41-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 (351) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api/control-definition.md +9 -1
  7. package/docs/api/slots.md +26 -5
  8. package/docs/assets/images/slot-map.svg +61 -262
  9. package/docs/examples/draw-tools.mdx +3 -3
  10. package/docs/plugins/draw.md +79 -16
  11. package/docs/plugins/search.md +1 -1
  12. package/govuk-prototype-kit.config.json +2 -0
  13. package/jest.setup.js +5 -0
  14. package/package.json +9 -1
  15. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  16. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  17. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  18. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  19. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  20. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  21. package/plugins/datasets/dist/css/index.css +1 -1
  22. package/plugins/datasets/dist/esm/esriLayerAdapter.js +1 -1
  23. package/plugins/datasets/dist/esm/im-datasets-ml-adapter.js +1 -1
  24. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  25. package/plugins/datasets/dist/umd/im-datasets-esri-adapter.js +1 -1
  26. package/plugins/datasets/dist/umd/im-datasets-ml-adapter.js +1 -1
  27. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  28. package/plugins/datasets/dist/umd/index.js +1 -1
  29. package/plugins/datasets/src/adapters/esri/esriLayerAdapter.test.js +175 -0
  30. package/plugins/datasets/src/adapters/esri/registry/esriDataset.test.js +55 -0
  31. package/plugins/datasets/src/adapters/loadLayerAdapter.test.js +61 -0
  32. package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +14 -0
  33. package/plugins/datasets/src/api/setOpacity.test.js +9 -0
  34. package/plugins/datasets/src/components/LayersMenu/LayersMenu.jsx +10 -25
  35. package/plugins/datasets/src/components/LayersMenu/LayersMenu.test.jsx +0 -42
  36. package/plugins/datasets/src/datasets.scss +0 -1
  37. package/plugins/datasets/src/fetch/createDynamicSource.js +135 -129
  38. package/plugins/datasets/src/fetch/createDynamicSource.test.js +318 -0
  39. package/plugins/datasets/src/fetch/fetchGeoJSON.test.js +83 -0
  40. package/plugins/datasets/src/index.test.js +36 -0
  41. package/plugins/datasets/src/initialise/DatasetsInit.jsx +17 -7
  42. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +262 -0
  43. package/plugins/datasets/src/initialise/initialiseDatasets.js +16 -4
  44. package/plugins/datasets/src/initialise/initialiseDatasets.test.js +42 -0
  45. package/plugins/datasets/src/manifest.js +0 -38
  46. package/plugins/datasets/src/manifest.test.js +49 -0
  47. package/plugins/datasets/src/reducers/datasetsToMenu.test.js +54 -1
  48. package/plugins/datasets/src/reducers/pluginState.js +3 -16
  49. package/plugins/datasets/src/reducers/pluginState.test.js +23 -0
  50. package/plugins/datasets/src/registry/dataset.js +13 -1
  51. package/plugins/datasets/src/registry/dataset.test.js +61 -0
  52. package/plugins/datasets/src/registry/datasetRegistry.js +12 -6
  53. package/plugins/datasets/src/registry/datasetRegistry.test.js +16 -4
  54. package/plugins/datasets/src/registry/isVisibleWhen.js +6 -3
  55. package/plugins/datasets/src/registry/isVisibleWhen.test.js +11 -1
  56. package/plugins/datasets/src/utils/bbox.test.js +125 -0
  57. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  58. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  59. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  60. package/plugins/draw/dist/esm/index.js +1 -1
  61. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +2 -1
  62. package/plugins/draw/dist/umd/im-draw-ml-adapter.js.LICENSE.txt +1 -0
  63. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  64. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  65. package/plugins/draw/dist/umd/index.js +1 -1
  66. package/plugins/draw/src/DrawInit.jsx +10 -2
  67. package/plugins/draw/src/DrawInit.test.jsx +51 -10
  68. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +91 -11
  69. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +246 -4
  70. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +109 -14
  71. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +174 -11
  72. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +12 -0
  73. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +9 -1
  74. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +24 -3
  75. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +194 -0
  76. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +457 -0
  77. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/__helpers__/harness.js +102 -0
  78. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +82 -0
  79. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.test.js +83 -0
  80. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +55 -0
  81. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.test.js +62 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointerHandlers.js +138 -0
  83. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointerHandlers.test.js +197 -0
  84. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/touchHandlers.js +92 -0
  85. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/touchHandlers.test.js +101 -0
  86. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/undoHandlers.js +46 -0
  87. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/undoHandlers.test.js +42 -0
  88. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +151 -0
  89. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +156 -0
  90. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +4 -27
  91. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +24 -39
  92. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +4 -5
  93. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +20 -23
  94. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +25 -5
  95. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +6 -36
  96. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -49
  97. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +3 -53
  98. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +8 -33
  99. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +29 -72
  100. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +10 -3
  101. package/plugins/draw/src/adapters/maplibre/pointSymbolImages.js +158 -0
  102. package/plugins/draw/src/adapters/maplibre/pointSymbolImages.test.js +314 -0
  103. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +6 -1
  104. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +21 -0
  105. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +6 -2
  106. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +12 -0
  107. package/plugins/draw/src/adapters/maplibre/styles.js +38 -11
  108. package/plugins/draw/src/adapters/maplibre/styles.test.js +39 -0
  109. package/plugins/draw/src/adapters/maplibre/utils/editModeEvents.js +79 -0
  110. package/plugins/draw/src/adapters/maplibre/utils/editModeEvents.test.js +135 -0
  111. package/plugins/draw/src/adapters/maplibre/utils/geometryValidation.js +42 -0
  112. package/plugins/draw/src/adapters/maplibre/utils/geometryValidation.test.js +85 -0
  113. package/plugins/draw/src/adapters/maplibre/utils/keyboardShortcuts.js +34 -0
  114. package/plugins/draw/src/adapters/maplibre/utils/keyboardShortcuts.test.js +78 -0
  115. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +16 -3
  116. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +21 -3
  117. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +59 -0
  118. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.test.js +73 -0
  119. package/plugins/draw/src/adapters/maplibre/utils/touchDragMath.js +33 -0
  120. package/plugins/draw/src/adapters/maplibre/utils/touchDragMath.test.js +70 -0
  121. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +27 -2
  122. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +93 -0
  123. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +9 -0
  124. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +19 -1
  125. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +34 -1
  126. package/plugins/draw/src/adapters/openlayers/core/styles.js +73 -1
  127. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +122 -3
  128. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +12 -0
  129. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +8 -2
  130. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +31 -0
  131. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +9 -5
  132. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +33 -0
  133. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +5 -3
  134. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +11 -0
  135. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +10 -3
  136. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +24 -1
  137. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +18 -13
  138. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +46 -1
  139. package/plugins/draw/src/adapters/openlayers/olDraw.js +11 -4
  140. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +48 -6
  141. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +156 -0
  142. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +246 -0
  143. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +267 -0
  144. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +327 -0
  145. package/plugins/draw/src/adapters/openlayers/point/pointDragInteraction.js +71 -0
  146. package/plugins/draw/src/adapters/openlayers/point/pointOps.js +34 -0
  147. package/plugins/draw/src/adapters/openlayers/point/pointOps.test.js +32 -0
  148. package/plugins/draw/src/adapters/openlayers/point/pointSelectionState.js +82 -0
  149. package/plugins/draw/src/adapters/openlayers/point/pointSelectionState.test.js +107 -0
  150. package/plugins/draw/src/adapters/openlayers/point/pointSymbolImages.js +83 -0
  151. package/plugins/draw/src/adapters/openlayers/point/pointSymbolImages.test.js +214 -0
  152. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +3 -1
  153. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +6 -9
  154. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +15 -4
  155. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +1 -1
  156. package/plugins/draw/src/api/addFeature.js +16 -1
  157. package/plugins/draw/src/api/addFeature.test.js +62 -5
  158. package/plugins/draw/src/api/editFeature.js +7 -4
  159. package/plugins/draw/src/api/editFeature.test.js +16 -0
  160. package/plugins/draw/src/api/newPoint.js +42 -0
  161. package/plugins/draw/src/api/newPoint.test.js +117 -0
  162. package/plugins/draw/src/api/setStyle.js +43 -0
  163. package/plugins/draw/src/api/setStyle.test.js +75 -0
  164. package/plugins/draw/src/events.js +17 -10
  165. package/plugins/draw/src/events.test.js +14 -0
  166. package/plugins/draw/src/manifest.js +18 -6
  167. package/plugins/draw/src/manifest.test.js +32 -1
  168. package/plugins/draw/src/reducer.test.js +7 -0
  169. package/plugins/draw/src/utils/stripInternalProperties.js +19 -0
  170. package/plugins/draw/src/utils/stripInternalProperties.test.js +50 -0
  171. package/plugins/draw/src/utils/symbolKeys.js +5 -0
  172. package/plugins/draw/src/validation/liveDrawChecks.test.js +54 -0
  173. package/plugins/draw/src/validation/liveStroke.test.js +64 -1
  174. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  175. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  176. package/plugins/interact/dist/umd/index.js +1 -1
  177. package/plugins/interact/src/hooks/useHighlightSync.js +33 -12
  178. package/plugins/interact/src/hooks/useHighlightSync.test.js +96 -25
  179. package/plugins/interact/src/utils/featureQueries.js +18 -0
  180. package/plugins/interact/src/utils/featureQueries.test.js +15 -0
  181. package/plugins/interact/src/utils/spatial.js +9 -3
  182. package/plugins/interact/src/utils/spatial.test.js +12 -1
  183. package/plugins/map-key/dist/css/index.css +62 -0
  184. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -0
  185. package/plugins/map-key/dist/esm/index.js +1 -0
  186. package/plugins/map-key/dist/umd/im-map-key-plugin.js +2 -0
  187. package/plugins/map-key/dist/umd/im-map-key-plugin.js.LICENSE.txt +1 -0
  188. package/plugins/map-key/dist/umd/index.js +2 -0
  189. package/plugins/map-key/dist/umd/index.js.LICENSE.txt +1 -0
  190. package/plugins/map-key/package.json +28 -0
  191. package/plugins/map-key/src/components/Key/EmptyKey.jsx +7 -0
  192. package/plugins/{datasets → map-key}/src/components/Key/EmptyKey.test.jsx +2 -2
  193. package/plugins/map-key/src/components/Key/Key.jsx +41 -0
  194. package/plugins/{datasets → map-key}/src/components/Key/Key.module.scss +15 -14
  195. package/plugins/map-key/src/components/Key/Key.test.jsx +73 -0
  196. package/plugins/map-key/src/components/Key/KeyGroupItem.jsx +16 -0
  197. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +53 -0
  198. package/plugins/map-key/src/components/Key/KeyItem.jsx +29 -0
  199. package/plugins/map-key/src/components/Key/KeyItem.test.jsx +67 -0
  200. package/plugins/map-key/src/components/Key/KeySvg.jsx +42 -0
  201. package/plugins/{datasets → map-key}/src/components/Key/KeySvg.test.jsx +14 -12
  202. package/plugins/map-key/src/components/Key/KeySvgLine.jsx +28 -0
  203. package/plugins/{datasets → map-key}/src/components/Key/KeySvgLine.test.jsx +1 -1
  204. package/plugins/map-key/src/components/Key/KeySvgPattern.jsx +23 -0
  205. package/plugins/{datasets → map-key}/src/components/Key/KeySvgPattern.test.jsx +2 -2
  206. package/plugins/map-key/src/components/Key/KeySvgRect.jsx +33 -0
  207. package/plugins/{datasets → map-key}/src/components/Key/KeySvgRect.test.jsx +1 -1
  208. package/plugins/map-key/src/components/Key/KeySvgSymbol.jsx +27 -0
  209. package/plugins/{datasets → map-key}/src/components/Key/KeySvgSymbol.test.jsx +5 -6
  210. package/plugins/map-key/src/components/Key/MapKey.jsx +50 -0
  211. package/plugins/map-key/src/components/Key/MapKey.test.jsx +130 -0
  212. package/plugins/{datasets → map-key}/src/components/Key/svgProperties.js +2 -2
  213. package/plugins/{datasets → map-key}/src/components/Key/svgProperties.test.js +2 -2
  214. package/plugins/map-key/src/index.js +13 -0
  215. package/plugins/map-key/src/index.test.js +31 -0
  216. package/plugins/map-key/src/initialise/MapKeyInit.jsx +15 -0
  217. package/plugins/map-key/src/initialise/MapKeyInit.test.jsx +39 -0
  218. package/plugins/map-key/src/manifest.js +30 -0
  219. package/plugins/map-key/src/manifest.test.js +63 -0
  220. package/plugins/map-key/src/mapKey.scss +4 -0
  221. package/plugins/map-key/src/registry/getDatasetRegistry.js +6 -0
  222. package/plugins/map-key/src/registry/getDatasetRegistry.test.js +23 -0
  223. package/plugins/map-key/src/registry/index.js +3 -0
  224. package/plugins/map-key/src/registry/index.test.js +15 -0
  225. package/plugins/menu/dist/css/index.css +80 -0
  226. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -0
  227. package/plugins/menu/dist/esm/index.js +1 -0
  228. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -0
  229. package/plugins/menu/dist/umd/index.js +2 -0
  230. package/plugins/menu/dist/umd/index.js.LICENSE.txt +1 -0
  231. package/plugins/menu/package.json +28 -0
  232. package/plugins/menu/src/components/Menu/CheckboxGroupWrapper.jsx +21 -0
  233. package/plugins/menu/src/components/Menu/GroupLegend.jsx +17 -0
  234. package/plugins/menu/src/components/Menu/GroupLegend.test.jsx +68 -0
  235. package/plugins/menu/src/components/Menu/Menu.jsx +19 -0
  236. package/plugins/menu/src/components/Menu/Menu.module.scss +90 -0
  237. package/plugins/menu/src/components/Menu/Menu.test.jsx +161 -0
  238. package/plugins/menu/src/components/Menu/MenuCheckbox.jsx +35 -0
  239. package/plugins/menu/src/components/Menu/MenuCheckbox.test.jsx +93 -0
  240. package/plugins/menu/src/components/Menu/MenuGroup.jsx +11 -0
  241. package/plugins/{datasets/src/components/LayersMenu/LayersMenuRadio.jsx → menu/src/components/Menu/MenuRadio.jsx} +6 -4
  242. package/plugins/{datasets/src/components/LayersMenu/LayersMenuRadio.test.jsx → menu/src/components/Menu/MenuRadio.test.jsx} +18 -18
  243. package/plugins/menu/src/components/Menu/RadioGroupWrapper.jsx +32 -0
  244. package/plugins/{datasets/src/components/LayersMenu/LayersRadioGroupWrapper.test.jsx → menu/src/components/Menu/RadioGroupWrapper.test.jsx} +21 -21
  245. package/plugins/menu/src/components/Menu/useIdPrefix.jsx +4 -0
  246. package/plugins/menu/src/index.js +13 -0
  247. package/plugins/menu/src/index.test.js +31 -0
  248. package/plugins/menu/src/initialise/MenuInit.jsx +24 -0
  249. package/plugins/menu/src/initialise/MenuInit.test.jsx +50 -0
  250. package/plugins/menu/src/initialise/createMenu.js +10 -0
  251. package/plugins/menu/src/initialise/createMenu.test.js +37 -0
  252. package/plugins/menu/src/manifest.js +35 -0
  253. package/plugins/menu/src/manifest.test.js +63 -0
  254. package/plugins/menu/src/menu.scss +4 -0
  255. package/plugins/menu/src/reducers/menuStateReducer.test.js +38 -0
  256. package/plugins/menu/src/reducers/pluginState.js +35 -0
  257. package/plugins/menu/src/reducers/pluginState.test.js +57 -0
  258. package/plugins/menu/src/registry/getDatasetRegistry.js +9 -0
  259. package/plugins/menu/src/registry/getDatasetRegistry.test.js +23 -0
  260. package/plugins/menu/src/registry/index.js +3 -0
  261. package/plugins/menu/src/registry/index.test.js +15 -0
  262. package/plugins/menu/src/registry/isVisibleWhen.js +43 -0
  263. package/plugins/menu/src/registry/isVisibleWhen.test.js +48 -0
  264. package/plugins/search/dist/css/index.css +1 -1
  265. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  266. package/plugins/search/dist/esm/index.js +1 -1
  267. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  268. package/plugins/search/dist/umd/index.js +1 -1
  269. package/plugins/search/src/Search.jsx +2 -3
  270. package/plugins/search/src/components/Form/Form.module.scss +9 -20
  271. package/plugins/search/src/index.js +0 -1
  272. package/plugins/search/src/index.test.js +2 -4
  273. package/plugins/search/src/manifest.js +1 -1
  274. package/plugins/search/src/search.scss +17 -16
  275. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  276. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  277. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  278. package/providers/beta/openlayers/src/openlayersProvider.js +4 -1
  279. package/providers/beta/openlayers/src/openlayersProvider.test.js +18 -0
  280. package/providers/beta/openlayers/src/utils/highlightFeatures.js +52 -5
  281. package/providers/beta/openlayers/src/utils/highlightFeatures.test.js +176 -0
  282. package/providers/beta/openlayers/src/utils/symbolImages.js +40 -0
  283. package/providers/beta/openlayers/src/utils/symbolImages.test.js +58 -0
  284. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  285. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  286. package/providers/maplibre/dist/umd/index.js +1 -1
  287. package/providers/maplibre/src/utils/drawLayerBuckets.js +14 -0
  288. package/providers/maplibre/src/utils/drawLayerBuckets.test.js +18 -0
  289. package/providers/maplibre/src/utils/highlightFeatures.js +80 -55
  290. package/providers/maplibre/src/utils/highlightFeatures.test.js +156 -17
  291. package/providers/maplibre/src/utils/hoverCursor.js +30 -14
  292. package/providers/maplibre/src/utils/hoverCursor.test.js +35 -0
  293. package/providers/maplibre/src/utils/patternImages.js +1 -1
  294. package/providers/maplibre/src/utils/symbolImages.js +41 -3
  295. package/providers/maplibre/src/utils/symbolImages.test.js +40 -6
  296. package/rollup.esm.mjs +11 -1
  297. package/src/App/components/Actions/Actions.test.jsx +47 -0
  298. package/src/App/components/Hints/Hints.test.jsx +13 -0
  299. package/src/App/components/MoveControls/MoveControls.module.scss +41 -10
  300. package/src/App/components/Panel/Panel.jsx +18 -3
  301. package/src/App/components/Panel/Panel.module.scss +18 -8
  302. package/src/App/components/Panel/Panel.test.jsx +34 -0
  303. package/src/App/components/Tooltip/Tooltip.test.jsx +12 -14
  304. package/src/App/hooks/useFeatureItems.js +16 -7
  305. package/src/App/hooks/useFeatureItems.test.js +57 -0
  306. package/src/App/hooks/useLayoutMeasurements.js +95 -84
  307. package/src/App/hooks/useLayoutMeasurements.test.js +61 -1
  308. package/src/App/layout/Layout.jsx +9 -10
  309. package/src/App/layout/Layout.test.jsx +2 -0
  310. package/src/App/layout/layout.module.scss +31 -17
  311. package/src/App/registry/pluginRegistry.js +1 -1
  312. package/src/App/registry/pluginRegistry.test.js +12 -0
  313. package/src/App/renderer/HtmlElementHost.jsx +18 -5
  314. package/src/App/renderer/HtmlElementHost.test.jsx +54 -0
  315. package/src/App/renderer/mapButtons.js +28 -12
  316. package/src/App/renderer/mapButtons.test.js +17 -4
  317. package/src/App/renderer/mapControls.js +3 -1
  318. package/src/App/renderer/mapControls.test.js +16 -0
  319. package/src/App/renderer/mapPanels.js +44 -9
  320. package/src/App/renderer/mapPanels.test.js +51 -2
  321. package/src/App/renderer/orderItems.js +28 -0
  322. package/src/App/renderer/orderItems.test.js +50 -0
  323. package/src/App/renderer/slotAggregator.js +2 -18
  324. package/src/App/renderer/slotHelpers.js +3 -1
  325. package/src/App/renderer/slotHelpers.test.js +5 -0
  326. package/src/App/renderer/slots.js +3 -0
  327. package/src/App/store/AppProvider.jsx +1 -0
  328. package/src/App/store/appDispatchMiddleware.js +1 -1
  329. package/src/App/store/appDispatchMiddleware.test.js +8 -0
  330. package/src/config/events.js +2 -0
  331. package/src/scss/settings/_colors.scss +1 -0
  332. package/src/scss/settings/_dimensions.scss +2 -3
  333. package/src/scss/settings/_transition.scss +1 -1
  334. package/src/services/eventBus.js +44 -0
  335. package/src/services/eventBus.test.js +83 -0
  336. package/src/services/symbolRegistry.js +1 -1
  337. package/src/types.js +11 -1
  338. package/webpack.umd.mjs +2 -0
  339. package/plugins/datasets/src/components/Key/EmptyKey.jsx +0 -7
  340. package/plugins/datasets/src/components/Key/Key.jsx +0 -50
  341. package/plugins/datasets/src/components/Key/KeyGroupItem.jsx +0 -18
  342. package/plugins/datasets/src/components/Key/KeyItem.jsx +0 -23
  343. package/plugins/datasets/src/components/Key/KeySvg.jsx +0 -22
  344. package/plugins/datasets/src/components/Key/KeySvgLine.jsx +0 -19
  345. package/plugins/datasets/src/components/Key/KeySvgPattern.jsx +0 -13
  346. package/plugins/datasets/src/components/Key/KeySvgRect.jsx +0 -22
  347. package/plugins/datasets/src/components/Key/KeySvgSymbol.jsx +0 -16
  348. package/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx +0 -41
  349. /package/plugins/{datasets → menu}/src/reducers/menuStateReducer.js +0 -0
  350. /package/{providers/maplibre/src → src}/utils/rasteriseToImageData.js +0 -0
  351. /package/{providers/maplibre/src → src}/utils/rasteriseToImageData.test.js +0 -0
@@ -2,9 +2,13 @@ import Style from 'ol/style/Style.js'
2
2
  import Fill from 'ol/style/Fill.js'
3
3
  import Stroke from 'ol/style/Stroke.js'
4
4
  import CircleStyle from 'ol/style/Circle.js'
5
+ import Icon from 'ol/style/Icon.js'
5
6
  import MultiPoint from 'ol/geom/MultiPoint.js'
6
7
  import { SIZES } from '../defaults.js'
7
8
  import { getPlacedSketchCoords } from '../utils/sketchHelpers.js'
9
+ import { getCachedSymbolImage } from '../../../../../../providers/beta/openlayers/src/utils/symbolImages.js'
10
+ import { symbolRegistry } from '../../../../../../src/services/symbolRegistry.js'
11
+ import { getSymbolAnchor } from '../../../../../../src/utils/symbolUtils.js'
8
12
 
9
13
  const HALO_RADIUS_OFFSET = 3
10
14
 
@@ -74,12 +78,74 @@ const createSketchLineStyles = (colors) => ({
74
78
  })
75
79
  })
76
80
 
81
+ // Placeholder for a committed point feature that has no symbol config, or whose symbol
82
+ // image hasn't rasterised/cached yet (point/pointSymbolImages.js resolves it asynchronously
83
+ // after the feature is added to the store — this is what renders in the gap, and what
84
+ // permanently renders for a point with no symbol properties at all). Stroke/Fill styles
85
+ // have no effect on a Point geometry, so without this branch a committed point renders
86
+ // invisibly.
87
+ const createPointPlaceholderStyle = (colors) => new Style({
88
+ image: new CircleStyle({
89
+ radius: SIZES.vertexRadius + HALO_RADIUS_OFFSET,
90
+ fill: new Fill({ color: colors.shapeFill }),
91
+ stroke: new Stroke({ color: colors.shapeStroke, width: 2 })
92
+ })
93
+ })
94
+
95
+ // ol/style/Icon defaults (anchorOrigin: 'top-left', anchorXUnits/anchorYUnits: 'fraction')
96
+ // already match symbolAnchor's own [x,y]-fraction-from-top-left convention, so unlike the
97
+ // MapLibre adapter (whose icon-anchor only has 9 discrete positions) no offset compensation
98
+ // is needed here — the raw anchor is usable as-is. Icon style instances are cached per
99
+ // imageId+anchor+pixelRatio so a style function running every render frame doesn't rebuild
100
+ // one each time.
101
+ const createPointStyles = (colors) => {
102
+ const pointStyle = createPointPlaceholderStyle(colors)
103
+ const iconStyleCache = new Map()
104
+
105
+ // imageIdProp lets edit_point's own per-feature style override (point/editPointMode.js)
106
+ // ask for the precomputed "selected" (black ring) variant instead of the normal one — the
107
+ // same variant the interact plugin's own highlight already uses, via the same
108
+ // symbolSelectedImageId point/pointSymbolImages.js resolves for every point.
109
+ const getPointIconStyle = (properties, imageIdProp = 'symbolImageId') => {
110
+ const imageId = properties[imageIdProp]
111
+ const canvas = imageId && getCachedSymbolImage(imageId)
112
+ if (!canvas) {
113
+ return null
114
+ }
115
+ const symbolDef = symbolRegistry.getSymbolDef(properties)
116
+ const [anchorX, anchorY] = getSymbolAnchor(properties, symbolDef)
117
+ // The cached canvas was rasterised at viewBox × pixelRatio device pixels for crispness
118
+ // (point/pointSymbolImages.js) — unlike MapLibre's map.addImage(id, data, {pixelRatio}),
119
+ // which uses the registered pixelRatio to auto-derive the displayed CSS size, ol/style/
120
+ // Icon draws its source at native pixel size by default, so `scale` has to cancel that
121
+ // back out here or the icon renders pixelRatio× too big.
122
+ const pixelRatio = properties.symbolPixelRatio || 1
123
+ const cacheKey = `${imageId}|${anchorX}|${anchorY}|${pixelRatio}`
124
+ let iconStyle = iconStyleCache.get(cacheKey)
125
+ if (!iconStyle) {
126
+ iconStyle = new Style({ image: new Icon({ img: canvas, anchor: [anchorX, anchorY], scale: 1 / pixelRatio }) })
127
+ iconStyleCache.set(cacheKey, iconStyle)
128
+ }
129
+ return iconStyle
130
+ }
131
+
132
+ return {
133
+ pointStyleFor: (feature) => getPointIconStyle(feature.getProperties()) ?? pointStyle,
134
+ // Falls back through selected → normal → placeholder, so edit_point still shows
135
+ // something sensible before the selected variant has resolved.
136
+ selectedPointStyleFor: (feature) => {
137
+ const properties = feature.getProperties()
138
+ return getPointIconStyle(properties, 'symbolSelectedImageId') ?? getPointIconStyle(properties) ?? pointStyle
139
+ }
140
+ }
141
+ }
142
+
77
143
  /**
78
144
  * Create all draw-ol style instances for the given resolved color set.
79
145
  *
80
146
  * @param {object} colors - Output of resolveColors()
81
147
  * @returns {{ vertexStyle, selectedVertexStyle, midpointStyle, selectedMidpointStyle,
82
- * editFeatureStyle, createSketchStyle, createFeatureStyle }}
148
+ * editFeatureStyle, selectedPointStyleFor, createSketchStyle, createFeatureStyle }}
83
149
  */
84
150
  export const createStyles = (colors) => {
85
151
  const { vertexImage, vertexStyle, selectedVertexStyle } = createVertexStyles(colors)
@@ -130,7 +196,12 @@ export const createStyles = (colors) => {
130
196
  return type === geometryType ? [lineStyle, sketchVertexStyle] : [lineStyle]
131
197
  }
132
198
 
199
+ const { pointStyleFor, selectedPointStyleFor } = createPointStyles(colors)
200
+
133
201
  const createFeatureStyle = () => (feature) => {
202
+ if (feature.getGeometry().getType() === 'Point') {
203
+ return [pointStyleFor(feature)]
204
+ }
134
205
  const p = feature.getProperties()
135
206
  const id = colors.mapStyleId
136
207
  const stroke = (id && p[`stroke${capitalize(id)}`]) || p.stroke || colors.shapeStroke
@@ -149,6 +220,7 @@ export const createStyles = (colors) => {
149
220
  selectedMidpointStyle,
150
221
  editFeatureStyle,
151
222
  editFeatureStyleInvalid,
223
+ selectedPointStyleFor,
152
224
  createSketchStyle,
153
225
  createFeatureStyle
154
226
  }
@@ -1,9 +1,11 @@
1
1
  import Point from 'ol/geom/Point.js'
2
2
  import LineString from 'ol/geom/LineString.js'
3
3
  import Feature from 'ol/Feature.js'
4
+ import Icon from 'ol/style/Icon.js'
4
5
  import { createStyles } from './styles.js'
5
6
  import { SIZES } from '../defaults.js'
6
- import { polygonFeature, lineFeature } from '../__helpers__/harness.js'
7
+ import { polygonFeature, lineFeature, pointFeature } from '../__helpers__/harness.js'
8
+ import { getOrCreateSymbolImage, clearSymbolImageCache } from '../../../../../../providers/beta/openlayers/src/utils/symbolImages.js'
7
9
 
8
10
  const colors = {
9
11
  editStroke: '#e5',
@@ -117,7 +119,11 @@ describe('sketch styles while drawing', () => {
117
119
  })
118
120
 
119
121
  describe('createFeatureStyle (inactive shapes)', () => {
120
- const styleFor = (properties) => styles.createFeatureStyle()({ getProperties: () => properties })[0]
122
+ const styleFor = (properties) => {
123
+ const feature = polygonFeature([[0, 0], [1, 0], [1, 1], [0, 0]])
124
+ feature.setProperties(properties)
125
+ return styles.createFeatureStyle()(feature)[0]
126
+ }
121
127
 
122
128
  test('falls back to the configured shape colours', () => {
123
129
  const style = styleFor({})
@@ -137,7 +143,120 @@ describe('createFeatureStyle (inactive shapes)', () => {
137
143
 
138
144
  test('without a map style id only the generic properties apply', () => {
139
145
  const plain = createStyles({ ...colors, mapStyleId: null })
140
- const style = plain.createFeatureStyle()({ getProperties: () => ({ strokeRoad: '#r1' }) })[0]
146
+ const feature = polygonFeature([[0, 0], [1, 0], [1, 1], [0, 0]])
147
+ feature.setProperties({ strokeRoad: '#r1' })
148
+ const style = plain.createFeatureStyle()(feature)[0]
141
149
  expect(style.getStroke().getColor()).toBe(colors.shapeStroke)
142
150
  })
151
+
152
+ test('a committed Point feature gets the placeholder circle style, not Stroke/Fill', () => {
153
+ const style = styles.createFeatureStyle()(pointFeature([5, 5]))[0]
154
+ expect(style.getImage()).toBeTruthy()
155
+ expect(style.getStroke()).toBeFalsy()
156
+ expect(style.getFill()).toBeFalsy()
157
+ })
158
+
159
+ test('a Point feature with a resolved+cached symbolImageId renders a real Icon, not the placeholder', () => {
160
+ HTMLCanvasElement.prototype.getContext = jest.fn(function () {
161
+ this._ctx ??= { putImageData: jest.fn() }
162
+ return this._ctx
163
+ })
164
+ clearSymbolImageCache()
165
+ const canvas = getOrCreateSymbolImage('symbol-pin-1x', { width: 10, height: 10 })
166
+
167
+ const feature = pointFeature([5, 5])
168
+ feature.setProperties({ symbol: 'pin', symbolImageId: 'symbol-pin-1x' })
169
+
170
+ const style = styles.createFeatureStyle()(feature)[0]
171
+ expect(style.getImage()).toBeInstanceOf(Icon)
172
+ expect(style.getImage().getImage(1)).toBe(canvas)
173
+ })
174
+
175
+ test('reuses the same Icon Style instance across renders for the same imageId/anchor/pixelRatio', () => {
176
+ HTMLCanvasElement.prototype.getContext = jest.fn(function () {
177
+ this._ctx ??= { putImageData: jest.fn() }
178
+ return this._ctx
179
+ })
180
+ clearSymbolImageCache()
181
+ getOrCreateSymbolImage('symbol-pin-cache', { width: 10, height: 10 })
182
+
183
+ const feature = pointFeature([5, 5])
184
+ feature.setProperties({ symbol: 'pin', symbolImageId: 'symbol-pin-cache' })
185
+
186
+ const styleFn = styles.createFeatureStyle()
187
+ const first = styleFn(feature)[0]
188
+ const second = styleFn(feature)[0] // a style function runs every render frame
189
+ expect(second).toBe(first)
190
+ })
191
+
192
+ test('scales the Icon down by the rasterisation pixelRatio, so a high-DPI raster still displays at its intended CSS size', () => {
193
+ HTMLCanvasElement.prototype.getContext = jest.fn(function () {
194
+ this._ctx ??= { putImageData: jest.fn() }
195
+ return this._ctx
196
+ })
197
+ clearSymbolImageCache()
198
+ getOrCreateSymbolImage('symbol-pin-4x', { width: 176, height: 176 }) // 44 viewBox × pixelRatio 4
199
+
200
+ const feature = pointFeature([5, 5])
201
+ feature.setProperties({ symbol: 'pin', symbolImageId: 'symbol-pin-4x', symbolPixelRatio: 4 })
202
+
203
+ const style = styles.createFeatureStyle()(feature)[0]
204
+ expect(style.getImage().getScale()).toBe(0.25)
205
+ })
206
+
207
+ test('defaults to scale 1 when symbolPixelRatio is missing', () => {
208
+ HTMLCanvasElement.prototype.getContext = jest.fn(function () {
209
+ this._ctx ??= { putImageData: jest.fn() }
210
+ return this._ctx
211
+ })
212
+ clearSymbolImageCache()
213
+ getOrCreateSymbolImage('symbol-pin-no-ratio', { width: 44, height: 44 })
214
+
215
+ const feature = pointFeature([5, 5])
216
+ feature.setProperties({ symbol: 'pin', symbolImageId: 'symbol-pin-no-ratio' })
217
+
218
+ const style = styles.createFeatureStyle()(feature)[0]
219
+ expect(style.getImage().getScale()).toBe(1)
220
+ })
221
+
222
+ // edit_point's per-feature style override (point/editPointMode.js) uses this to keep
223
+ // showing the same "selected" (black ring) look the interact plugin's own highlight
224
+ // already uses for the whole edit session — see selectedPointStyleFor's own comment.
225
+ test('selectedPointStyleFor renders the selected variant, falling back to normal then the placeholder', () => {
226
+ HTMLCanvasElement.prototype.getContext = jest.fn(function () {
227
+ this._ctx ??= { putImageData: jest.fn() }
228
+ return this._ctx
229
+ })
230
+ clearSymbolImageCache()
231
+ const selectedCanvas = getOrCreateSymbolImage('symbol-pin-selected', { width: 10, height: 10 })
232
+
233
+ const selected = pointFeature([5, 5])
234
+ selected.setProperties({ symbol: 'pin', symbolImageId: 'symbol-pin-normal', symbolSelectedImageId: 'symbol-pin-selected' })
235
+ const selectedStyle = styles.selectedPointStyleFor(selected)
236
+ expect(selectedStyle.getImage()).toBeInstanceOf(Icon)
237
+ expect(selectedStyle.getImage().getImage(1)).toBe(selectedCanvas)
238
+
239
+ // No selected variant resolved yet → falls back to the normal icon
240
+ const normalCanvas = getOrCreateSymbolImage('symbol-pin-normal-only', { width: 10, height: 10 })
241
+ const normalOnly = pointFeature([5, 5])
242
+ normalOnly.setProperties({ symbol: 'pin', symbolImageId: 'symbol-pin-normal-only' })
243
+ const fallbackStyle = styles.selectedPointStyleFor(normalOnly)
244
+ expect(fallbackStyle.getImage().getImage(1)).toBe(normalCanvas)
245
+
246
+ // Neither resolved → the placeholder circle
247
+ const placeholderStyle = styles.selectedPointStyleFor(pointFeature([5, 5]))
248
+ expect(placeholderStyle.getStroke()).toBeFalsy()
249
+ expect(placeholderStyle.getImage()).not.toBeInstanceOf(Icon)
250
+ })
251
+
252
+ test('a Point feature whose symbolImageId has not been rasterised/cached yet falls back to the placeholder', () => {
253
+ clearSymbolImageCache()
254
+ const feature = pointFeature([5, 5])
255
+ feature.setProperties({ symbol: 'pin', symbolImageId: 'symbol-not-cached' })
256
+
257
+ const style = styles.createFeatureStyle()(feature)[0]
258
+ expect(style.getImage()).not.toBeInstanceOf(Icon)
259
+ expect(style.getStroke()).toBeFalsy()
260
+ expect(style.getFill()).toBeFalsy()
261
+ })
143
262
  })
@@ -4,6 +4,8 @@ import { createFeatureStore } from '../core/featureStore.js'
4
4
  import { ADAPTER_EVENTS } from '../../../adapterEvents.js'
5
5
  import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js'
6
6
  import { createFakeMap, createFakeManager, polygonFeature, lineFeature } from '../__helpers__/harness.js'
7
+ import Feature from 'ol/Feature.js'
8
+ import Polygon from 'ol/geom/Polygon.js'
7
9
 
8
10
  jest.mock('./drawInput.js', () => ({
9
11
  createDrawInput: jest.fn(() => ({
@@ -174,6 +176,16 @@ describe('drawing lifecycle', () => {
174
176
  expect(manager.styles.createSketchStyle).not.toHaveBeenCalledWith('Polygon', true)
175
177
  })
176
178
 
179
+ test('a Polygon sketch with no ring yet (freshly started, before the first coordinate) never throws or goes dashed', () => {
180
+ const { manager, interaction } = setup('Polygon')
181
+ const sketch = new Feature(new Polygon([]))
182
+ sketch.setId('shape-1')
183
+ interaction.dispatchEvent({ type: 'drawstart', feature: sketch })
184
+ // coords[0] is undefined with zero rings — displayedSketch's `?? []` fallback covers it.
185
+ expect(() => sketch.getGeometry().setCoordinates([])).not.toThrow()
186
+ expect(manager.styles.createSketchStyle).not.toHaveBeenCalledWith('Polygon', true)
187
+ })
188
+
177
189
  test('2 placed vertices + cursor never go dashed (below the 4-point threshold)', () => {
178
190
  const { manager, interaction } = setup('Polygon')
179
191
  const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]])
@@ -3,7 +3,8 @@ import { createVertexPlacement } from './vertexPlacement.js'
3
3
  const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'])
4
4
 
5
5
  // Mirrors DrawInit.jsx's fixAtCenter()/hide() gate, but reasserted on every interface-type transition this module detects — matching MapLibre's own onSetup/onInterfaceTypeChange redundancy.
6
- const applyCrossHairVisibility = (crossHair, type) => {
6
+ // Exported alongside wireInputEvents for the same reason — point/drawPointMode.js reuses it directly.
7
+ export const applyCrossHairVisibility = (crossHair, type) => {
7
8
  if (!crossHair) { return }
8
9
  if (['touch', 'keyboard'].includes(type)) {
9
10
  crossHair.fixAtCenter()
@@ -12,7 +13,12 @@ const applyCrossHairVisibility = (crossHair, type) => {
12
13
  }
13
14
  }
14
15
 
15
- const wireInputEvents = ({
16
+ // Exported so point/drawPointMode.js can reuse this DOM-wiring layer directly — it's
17
+ // already generic (crosshair/interface-type tracking, add-vertex-button/Enter/undo
18
+ // listeners), parametrised entirely by the callbacks passed in, with no ring-shaped
19
+ // assumptions baked in. draw_point supplies point-shaped versions of those callbacks
20
+ // (no rubber band, no undo) rather than this file growing a second copy of the wiring.
21
+ export const wireInputEvents = ({
16
22
  container, addVertexButtonId, olView, onUndo,
17
23
  getInterfaceType, setInterfaceType, clearLastCoord,
18
24
  updateRubberbanding, placeVertex
@@ -71,6 +71,13 @@ test('a mid-drag crossing turns the stroke dashed live, and back when it clears'
71
71
  expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyle)
72
72
  })
73
73
 
74
+ test('a Polygon with no ring yet (transiently empty mid-drag) never throws computing numVertices', () => {
75
+ const { olFeature } = setup()
76
+ // OL's own Polygon geometry supports zero rings natively — coordinates[0] is undefined,
77
+ // exercising updateLiveValidity's `?? 1` fallback rather than a real drag scenario.
78
+ expect(() => olFeature.getGeometry().setCoordinates([])).not.toThrow()
79
+ })
80
+
74
81
  test('a validity flip repaints the map without touching the feature — Modify tracks its own drag via feature #change, which setStyle() would trip', () => {
75
82
  const { map, olFeature } = setup()
76
83
  const renderCallsBefore = map.render.mock.calls.length
@@ -88,6 +95,30 @@ test('validity flips while editing also gate the Done button', () => {
88
95
  expect.objectContaining({ valid: true }))
89
96
  })
90
97
 
98
+ test('a LineString edit computes numVertices directly from coordinates.length (no ring -1 adjustment)', () => {
99
+ const map = createFakeMap()
100
+ const manager = createFakeManager()
101
+ manager.store = createFeatureStore()
102
+ manager.store.add({ type: 'Feature', id: 'l1', properties: {}, geometry: { type: 'LineString', coordinates: [[0, 0], [100, 0], [100, 100]] } })
103
+ const container = createContainer()
104
+ map.getViewport().appendChild(container)
105
+ const mode = createEditMode({
106
+ map,
107
+ manager,
108
+ options: { featureId: 'l1', container, interfaceType: 'mouse', deleteVertexButtonId: 'del-v', snap: null }
109
+ })
110
+ liveModes.push(mode)
111
+ const olFeature = manager.store.getOL('l1')
112
+
113
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: 3 })
114
+
115
+ // LIVE_RULES (self-intersect/area) only ever apply to Polygon (see rules.js's getPolygon
116
+ // guard) — a self-crossing LineString still drives updateLiveValidity, but never flips
117
+ // the stroke invalid, unlike the Polygon case above.
118
+ olFeature.getGeometry().setCoordinates([[0, 0], [100, 100], [100, 0], [0, 100]])
119
+ expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyle)
120
+ })
121
+
91
122
  test('the user callback runs throttled during an edit drag', () => {
92
123
  jest.useFakeTimers()
93
124
  const { manager, olFeature } = setup()
@@ -104,12 +104,13 @@ const buildKeydownHandler = ({ map, getState, setState, nudge, keyMove, onUndo,
104
104
  }
105
105
  }
106
106
 
107
- const buildKeyupHandler = ({ snap, keyMove, onVertexMoved, onDeleted, isFocused }) => (e) => {
107
+ const buildKeyupHandler = ({ keyMove, onVertexMoved, onDeleted, isFocused }) => (e) => {
108
108
  if (isFocused()) {
109
109
  return
110
110
  }
111
111
  if (ARROW_KEYS.has(e.key) && keyMove.start && keyMove.index != null) {
112
- snap?.hideIndicator()
112
+ // Not hiding the snap indicator here — nudge.js's own snap.apply() already left it showing
113
+ // correctly, and it should stay that way after the key is released.
113
114
  onVertexMoved({ vertexIndex: keyMove.index, previousCoord: keyMove.start })
114
115
  keyMove.start = null
115
116
  keyMove.index = null
@@ -133,16 +134,19 @@ const buildKeyupHandler = ({ snap, keyMove, onVertexMoved, onDeleted, isFocused
133
134
  * convert it. Only pressing a plain/Shift arrow converts it.
134
135
  *
135
136
  * @param {{ map, getState, setState, snap, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive }} options
137
+ * @param {(olFeature, index: number, coord: number[]) => void} [options.moveCoord] - coordinate
138
+ * writer forwarded to wireNudge, defaulting there to moveVertex; point/editPointMode.js
139
+ * injects point/pointOps.js's movePoint instead.
136
140
  * @returns {{ nudgeByDelta: (dx: number, dy: number, isLargeStep: boolean) => void, destroy: () => void }}
137
141
  */
138
142
  export const createKeyboardHandler = (options) => {
139
- const { map, snap, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive } = options
140
- const { nudge, keyMove, nudgeByDelta } = wireNudge({ map, snap, getState, setState, onInserted, onVertexMoved })
143
+ const { map, snap, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive, moveCoord } = options
144
+ const { nudge, keyMove, nudgeByDelta } = wireNudge({ map, snap, getState, setState, onInserted, onVertexMoved, moveCoord })
141
145
  const appViewport = map.getViewport().closest('[role="application"]') ?? map.getViewport()
142
146
  const isFocused = () => isInteractiveElementFocused(appViewport)
143
147
 
144
148
  const onKeydown = buildKeydownHandler({ map, getState, setState, nudge, keyMove, onUndo, onKeyboardActive, isFocused })
145
- const onKeyup = buildKeyupHandler({ snap, keyMove, onVertexMoved, onDeleted, isFocused })
149
+ const onKeyup = buildKeyupHandler({ keyMove, onVertexMoved, onDeleted, isFocused })
146
150
 
147
151
  globalThis.addEventListener('keydown', onKeydown, { capture: true })
148
152
  globalThis.addEventListener('keyup', onKeyup, { capture: true })
@@ -115,6 +115,39 @@ test('a plain arrow nudges the selected vertex, and keyup commits a single move
115
115
  expect(onVertexMoved).toHaveBeenCalledTimes(1)
116
116
  })
117
117
 
118
+ // Regression: keyup used to force-hide the snap indicator unconditionally, so it vanished the
119
+ // instant the key was released even when the vertex landed exactly on a snap target — making it
120
+ // impossible to tell whether the nudge had actually snapped. nudge.js's own snap.apply() already
121
+ // leaves the indicator correctly reflecting reality; keyup must leave it alone (mirrors the ML
122
+ // adapter, which never hides its own indicator on keyup either).
123
+ test('keyup does not hide the snap indicator', () => {
124
+ const snap = { apply: jest.fn((c) => c), hideIndicator: jest.fn(), snapRadius: 12 }
125
+ const map = createFakeMap({ center: [98, 98] })
126
+ const state = {
127
+ olFeature: polygonFeature(RING),
128
+ selectedVertexIndex: 1,
129
+ selectedVertexType: 'vertex',
130
+ vertices: [[0, 0], [100, 0], [100, 100]],
131
+ midpoints: [[50, 0], [100, 50], [50, 50]]
132
+ }
133
+ const setState = jest.fn((updates) => Object.assign(state, updates))
134
+ const handler = createKeyboardHandler({
135
+ map,
136
+ getState: () => state,
137
+ setState,
138
+ snap,
139
+ onVertexMoved: jest.fn(),
140
+ onInserted: jest.fn(),
141
+ onDeleted: jest.fn(),
142
+ onUndo: jest.fn(),
143
+ onKeyboardActive: jest.fn()
144
+ })
145
+ liveHandlers.push(handler)
146
+ key('keydown', { key: 'ArrowRight' })
147
+ key('keyup', { key: 'ArrowRight' })
148
+ expect(snap.hideIndicator).not.toHaveBeenCalled()
149
+ })
150
+
118
151
  test('arrows without a selection do nothing', () => {
119
152
  const { setState } = setup()
120
153
  key('keydown', { key: 'ArrowRight' })
@@ -44,16 +44,18 @@ export const buildModifyCondition = ({ map, getState }) => (mapBrowserEvent) =>
44
44
  * each completed drag via `onModifyEnd(prevCoords)` with the vertices
45
45
  * snapshotted at drag start.
46
46
  *
47
+ * @param {(mapBrowserEvent) => boolean} [condition] - overrides buildModifyCondition's
48
+ * fixed-radius vertex hit-test.
47
49
  * @returns {{ destroy: () => void }}
48
50
  */
49
- export const createModifyInteraction = ({ map, olFeature, getState, onModifyEnd }) => {
50
- const condition = buildModifyCondition({ map, getState })
51
+ export const createModifyInteraction = ({ map, olFeature, getState, onModifyEnd, condition }) => {
52
+ const modifyCondition = condition ?? buildModifyCondition({ map, getState })
51
53
 
52
54
  const modifyInteraction = new Modify({
53
55
  features: new Collection([olFeature]),
54
56
  style: () => [], // vertex circles rendered by vertexLayer instead
55
57
  pixelTolerance: PIXEL_TOLERANCE,
56
- condition
58
+ condition: modifyCondition
57
59
  })
58
60
  map.addInteraction(modifyInteraction)
59
61
 
@@ -77,4 +77,15 @@ describe('createModifyInteraction', () => {
77
77
  modify.destroy()
78
78
  expect(map.removeInteraction).toHaveBeenCalledWith(interaction)
79
79
  })
80
+
81
+ // point/editPointMode.js injects its own condition (built around map.forEachFeatureAtPixel)
82
+ // in place of buildModifyCondition's fixed-radius hit-test, since a symbol icon can render
83
+ // far bigger than PIXEL_TOLERANCE covers.
84
+ test('an injected condition overrides buildModifyCondition\'s own default', () => {
85
+ const map = createFakeMap()
86
+ const state = { interfaceType: 'mouse', vertices: [[0, 0]], midpoints: [] }
87
+ const customCondition = jest.fn(() => true)
88
+ createModifyInteraction({ map, olFeature: polygonFeature(RING), getState: () => state, onModifyEnd: jest.fn(), condition: customCondition })
89
+ expect(map.interactions[0].condition_).toBe(customCondition)
90
+ })
80
91
  })
@@ -30,9 +30,12 @@ export const resolveSnappedCoord = (snap, map, current, nudgedCoord, snappedCoor
30
30
  * `keyMove` tracks the coordinate at the start of a nudge sequence so a single
31
31
  * move_vertex undo op can be pushed on keyup (see keyboardHandler).
32
32
  *
33
+ * @param {(olFeature, index: number, coord: number[]) => void} [options.moveCoord] - coordinate
34
+ * writer, defaulting to moveVertex; point/editPointMode.js injects point/pointOps.js's
35
+ * movePoint instead, since a Point has no ring index to address.
33
36
  * @returns {{ nudge: (e: KeyboardEvent) => void, keyMove: { start, index }, nudgeByDelta: (dx: number, dy: number, isLargeStep: boolean) => void }}
34
37
  */
35
- export const wireNudge = ({ map, snap, getState, setState, onInserted, onVertexMoved }) => {
38
+ export const wireNudge = ({ map, snap, getState, setState, onInserted, onVertexMoved, moveCoord = moveVertex }) => {
36
39
  const keyMove = { start: null, index: null }
37
40
 
38
41
  // Shared core: moves the selected vertex by an already pixel-scaled delta,
@@ -46,9 +49,13 @@ export const wireNudge = ({ map, snap, getState, setState, onInserted, onVertexM
46
49
  const current = vertices[selectedVertexIndex]
47
50
  const nudgedCoord = nudgeCoord(map, current, dx, dy)
48
51
  const snappedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord
49
- snap?.hideIndicator()
50
52
  const newCoord = resolveSnappedCoord(snap, map, current, nudgedCoord, snappedCoord, dx, dy)
51
- moveVertex(olFeature, selectedVertexIndex, newCoord)
53
+ // resolveSnappedCoord can override snappedCoord with an escape jump clear of the snap
54
+ // radius — when it does, the indicator snap.apply() just showed is now stale, so correct it.
55
+ if (snap && newCoord !== snappedCoord) {
56
+ snap.hideIndicator()
57
+ }
58
+ moveCoord(olFeature, selectedVertexIndex, newCoord)
52
59
  setState({ vertices: vertices.map((c, i) => i === selectedVertexIndex ? newCoord : c) })
53
60
  return { previousCoord: current, vertexIndex: selectedVertexIndex }
54
61
  }
@@ -134,7 +134,17 @@ describe('snapping while nudging', () => {
134
134
  Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
135
135
  nudge({ key: 'ArrowRight' })
136
136
  expect(ring(olFeature)[1]).toEqual([16, 0])
137
- expect(snap.hideIndicator).toHaveBeenCalled()
137
+ })
138
+
139
+ // Regression: a stray hideIndicator() right after snap.apply() undid its own show on every
140
+ // single nudge, so the indicator could never actually be seen. keyboardHandler.js's own keyup
141
+ // no longer hides it either — see its own comment — so once shown here, it stays shown.
142
+ test('does not hide the indicator mid-sequence — snap.apply() already shows/hides it', () => {
143
+ const snap = snapReturning((c) => [c[0] + 1, c[1]])
144
+ const { state, nudge } = setup({ snap })
145
+ Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
146
+ nudge({ key: 'ArrowRight' })
147
+ expect(snap.hideIndicator).not.toHaveBeenCalled()
138
148
  })
139
149
 
140
150
  test('escapes the snap radius when snapping blocks the nudge', () => {
@@ -144,6 +154,19 @@ describe('snapping while nudging', () => {
144
154
  nudge({ key: 'ArrowRight' })
145
155
  expect(ring(olFeature)[1]).toEqual([23, 0]) // snapRadius + 1 past the original position
146
156
  })
157
+
158
+ // Regression: nudging away from a target you're sitting on escapes the snap radius (above),
159
+ // but snap.apply() had already shown the indicator at the target it's about to flee — left
160
+ // uncorrected, the indicator stayed pinned there for one extra press even though the vertex
161
+ // had already jumped clear, only catching up on the next press once querying from the
162
+ // escaped position genuinely found nothing.
163
+ test('hides the indicator when a nudge escapes the snap radius', () => {
164
+ const snap = snapReturning(() => [10, 0]) // snap holds the vertex in place
165
+ const { state, nudge } = setup({ snap })
166
+ Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
167
+ nudge({ key: 'ArrowRight' })
168
+ expect(snap.hideIndicator).toHaveBeenCalled()
169
+ })
147
170
  })
148
171
 
149
172
  describe('resolveSnappedCoord', () => {
@@ -30,21 +30,22 @@ const handleTouchStart = (e, { map, targetEl, cssToOl, getState, drag }) => {
30
30
  e.preventDefault()
31
31
  }
32
32
 
33
- const handleTouchMove = (e, { map, targetEl, olToCSS, getState, setState, snap, drag }) => {
33
+ const handleTouchMove = (e, { map, targetEl, olToCSS, getState, setState, snap, drag, moveCoord }) => {
34
34
  if (!isOnTouchTarget(e.target) || drag.dragStartIndex == null) {
35
35
  return
36
36
  }
37
37
  e.preventDefault()
38
38
  const tOl = map.getEventPixel({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY })
39
39
  const rawCoord = pixelToCoord(map, { x: tOl[0] - drag.vertexTouchDelta.x, y: tOl[1] - drag.vertexTouchDelta.y })
40
+ // snap.apply() already shows/hides the indicator based on whether a candidate was found.
40
41
  const newCoord = snap ? snap.apply(rawCoord) : rawCoord
41
- snap?.hideIndicator()
42
42
  const { olFeature, vertices } = getState()
43
43
  if (!olFeature) {
44
44
  return
45
45
  }
46
- moveVertex(olFeature, drag.dragStartIndex, newCoord)
46
+ moveCoord(olFeature, drag.dragStartIndex, newCoord)
47
47
  setState({ vertices: vertices.map((c, i) => i === drag.dragStartIndex ? newCoord : c) })
48
+ // Tracks the raw touch point, not newCoord — the vertex is what jumps to the snap candidate.
48
49
  showTouchTarget(targetEl, olToCSS({ x: tOl[0] - drag.targetTouchDelta.x, y: tOl[1] - drag.targetTouchDelta.y }))
49
50
  }
50
51
 
@@ -63,7 +64,7 @@ const handleTap = (e, { map, getState, onTap, drag }) => {
63
64
  }
64
65
 
65
66
  const handleTouchEnd = (e, ctx) => {
66
- const { getState, onVertexMoved, snap, drag } = ctx
67
+ const { getState, onVertexMoved, drag, updateTargetPosition } = ctx
67
68
  if (drag.dragStartIndex == null) {
68
69
  handleTap(e, ctx)
69
70
  drag.tapStart = null
@@ -74,8 +75,9 @@ const handleTouchEnd = (e, ctx) => {
74
75
  if (vertices[drag.dragStartIndex] && drag.dragStartCoord) {
75
76
  onVertexMoved({ vertexIndex: drag.dragStartIndex, previousCoord: drag.dragStartCoord })
76
77
  }
77
- snap?.hideIndicator()
78
+ // Not hiding the indicator here — it should stay showing after the drag ends.
78
79
  drag.dragStartCoord = null; drag.dragStartIndex = null; drag.vertexTouchDelta = null; drag.targetTouchDelta = null
80
+ updateTargetPosition() // re-sync the target to the vertex's final position
79
81
  e.preventDefault()
80
82
  }
81
83
 
@@ -97,6 +99,8 @@ const wireTouchEvents = (deps) => {
97
99
  container.addEventListener('touchstart', onTouchstart, { passive: false })
98
100
  container.addEventListener('touchmove', onTouchmove, { passive: false })
99
101
  container.addEventListener('touchend', onTouchend, { passive: false })
102
+ // touchcancel fires instead of touchend when the browser itself interrupts the gesture.
103
+ container.addEventListener('touchcancel', onTouchend, { passive: false })
100
104
 
101
105
  return {
102
106
  isDragging: () => drag.dragStartIndex != null,
@@ -104,6 +108,7 @@ const wireTouchEvents = (deps) => {
104
108
  container.removeEventListener('touchstart', onTouchstart)
105
109
  container.removeEventListener('touchmove', onTouchmove)
106
110
  container.removeEventListener('touchend', onTouchend)
111
+ container.removeEventListener('touchcancel', onTouchend)
107
112
  }
108
113
  }
109
114
  }
@@ -114,15 +119,16 @@ const wireTouchEvents = (deps) => {
114
119
  * without finger occlusion. Tap on a vertex or midpoint selects it via onTap.
115
120
  *
116
121
  * @param {{ map, container, getState, setState, onVertexMoved, onTap, colors }} options
122
+ * @param {(olFeature, index: number, coord: number[]) => void} [options.moveCoord] - coordinate
123
+ * writer, defaulting to moveVertex; point/editPointMode.js injects point/pointOps.js's
124
+ * movePoint instead, since a Point has no ring index to address.
117
125
  * @returns {{ updateTargetPosition, updateColors, hide, destroy }}
118
126
  */
119
- export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved, onTap, colors, snap }) => {
127
+ export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved, onTap, colors, snap, moveCoord = moveVertex }) => {
120
128
  const targetEl = createTouchTarget(container)
121
129
  applyTouchTargetColors(targetEl, colors)
122
130
 
123
- // OL pixel space is relative to ol-viewport at its pre-scale CSS size.
124
- // Container CSS space is larger when a CSS transform scales up ol-viewport
125
- // (e.g. scale(1.5) at medium map size makes OL pixels 1.5× smaller than CSS pixels).
131
+ // Converts between OL pixel space and CSS space, which differ when a CSS transform scales up ol-viewport.
126
132
  const cssTx = { scale: 1, ox: 0, oy: 0 }
127
133
  const olToCSS = (p) => ({ x: p.x * cssTx.scale + cssTx.ox, y: p.y * cssTx.scale + cssTx.oy })
128
134
  const cssToOl = (p) => ({ x: (p.x - cssTx.ox) / cssTx.scale, y: (p.y - cssTx.oy) / cssTx.scale })
@@ -140,8 +146,6 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte
140
146
  })
141
147
  }
142
148
 
143
- const touchEvents = wireTouchEvents({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap })
144
-
145
149
  const updateTargetPosition = () => {
146
150
  const { selectedVertexIndex, vertices, interfaceType } = getState()
147
151
  if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex] || interfaceType !== 'touch') {
@@ -156,8 +160,9 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte
156
160
  showTouchTarget(targetEl, olToCSS(px))
157
161
  }
158
162
 
159
- // Reposition on every render keeps target anchored during pinch-zoom and pan.
160
- // Skipped during drag since touchmove handles position directly.
163
+ const touchEvents = wireTouchEvents({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap, moveCoord, updateTargetPosition })
164
+
165
+ // Reposition on every render (skipped mid-drag, which handles position directly) to stay anchored during pinch-zoom and pan.
161
166
  const onPostrender = () => {
162
167
  const { selectedVertexIndex, interfaceType } = getState()
163
168
  if (selectedVertexIndex >= 0 && !touchEvents.isDragging() && interfaceType === 'touch') {