@defra/interactive-map 0.0.36-alpha → 0.0.38-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 (248) hide show
  1. package/assets/templates/draw-tools.njk +4 -3
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api.md +32 -0
  8. package/docs/examples/draw-tools.mdx +0 -4
  9. package/docs/plugins/draw.md +482 -0
  10. package/docs/plugins.md +2 -6
  11. package/govuk-prototype-kit.config.json +3 -2
  12. package/package.json +2 -4
  13. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  14. package/plugins/beta/draw-es/src/events.js +1 -1
  15. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  16. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  17. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  18. package/plugins/beta/map-styles/dist/css/index.css +1 -97
  19. package/plugins/datasets/dist/esm/esriLayerAdapter.js +1 -1
  20. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  21. package/plugins/datasets/dist/umd/im-datasets-esri-adapter.js +1 -1
  22. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  23. package/plugins/datasets/src/adapters/esri/esriLayerAdapter.js +13 -0
  24. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -0
  25. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -0
  26. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -0
  27. package/plugins/draw/dist/esm/index.js +1 -0
  28. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -0
  29. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +2 -0
  30. package/plugins/draw/dist/umd/im-draw-ol-adapter.js.LICENSE.txt +1 -0
  31. package/plugins/draw/dist/umd/im-draw-plugin.js +2 -0
  32. package/plugins/draw/dist/umd/im-draw-plugin.js.LICENSE.txt +1 -0
  33. package/plugins/draw/dist/umd/index.js +2 -0
  34. package/plugins/draw/dist/umd/index.js.LICENSE.txt +1 -0
  35. package/plugins/draw/src/DrawInit.jsx +89 -0
  36. package/plugins/draw/src/DrawInit.test.jsx +184 -0
  37. package/plugins/draw/src/adapterEvents.js +127 -0
  38. package/plugins/draw/src/adapterEvents.test.js +20 -0
  39. package/plugins/draw/src/adapters/adapterContract.test.js +48 -0
  40. package/plugins/draw/src/adapters/loadDrawAdapter.js +14 -0
  41. package/plugins/draw/src/adapters/loadDrawAdapter.test.js +53 -0
  42. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +367 -0
  43. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +799 -0
  44. package/plugins/draw/src/adapters/maplibre/defaults.js +1 -0
  45. package/plugins/draw/src/adapters/maplibre/drawEvents.js +35 -0
  46. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +126 -0
  47. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +268 -0
  48. package/plugins/draw/src/adapters/maplibre/mapboxSnap.js +39 -0
  49. package/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js +91 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js +58 -0
  51. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +53 -0
  52. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.js +23 -0
  53. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js +21 -0
  54. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js +15 -0
  55. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js +37 -0
  56. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +115 -0
  57. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +193 -0
  58. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +241 -0
  59. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js +78 -0
  60. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js +75 -0
  61. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +78 -0
  62. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +48 -0
  63. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +103 -0
  64. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +127 -0
  65. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js +59 -0
  66. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js +34 -0
  67. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +193 -0
  68. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +195 -0
  69. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js +16 -0
  70. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js +27 -0
  71. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js +107 -0
  72. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +139 -0
  73. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +46 -0
  74. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js +2 -0
  75. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js +17 -0
  76. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +151 -0
  77. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +123 -0
  78. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +161 -0
  79. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js +98 -0
  80. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +133 -0
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +105 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +166 -0
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +133 -0
  84. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +210 -0
  85. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +167 -0
  86. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +121 -0
  87. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +73 -0
  88. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +249 -0
  89. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +153 -0
  90. package/plugins/draw/src/adapters/maplibre/snap/constants.js +2 -0
  91. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js +39 -0
  92. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js +106 -0
  93. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +148 -0
  94. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +238 -0
  95. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +115 -0
  96. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +151 -0
  97. package/plugins/draw/src/adapters/maplibre/snap/sourceData.js +34 -0
  98. package/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js +62 -0
  99. package/plugins/draw/src/adapters/maplibre/styles.js +207 -0
  100. package/plugins/draw/src/adapters/maplibre/styles.test.js +155 -0
  101. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +202 -0
  102. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +253 -0
  103. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js +57 -0
  104. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js +106 -0
  105. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +117 -0
  106. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +159 -0
  107. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +90 -0
  108. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +178 -0
  109. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +153 -0
  110. package/plugins/draw/src/adapters/openlayers/core/featureStore.js +69 -0
  111. package/plugins/draw/src/adapters/openlayers/core/featureStore.test.js +58 -0
  112. package/plugins/draw/src/adapters/openlayers/core/internalEvents.js +8 -0
  113. package/plugins/draw/src/adapters/openlayers/core/styles.js +155 -0
  114. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +143 -0
  115. package/plugins/draw/src/adapters/openlayers/defaults.js +1 -0
  116. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +272 -0
  117. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +431 -0
  118. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +139 -0
  119. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +140 -0
  120. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +144 -0
  121. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +196 -0
  122. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +385 -0
  123. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +303 -0
  124. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js +55 -0
  125. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js +53 -0
  126. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +157 -0
  127. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +195 -0
  128. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js +57 -0
  129. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js +42 -0
  130. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +83 -0
  131. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +80 -0
  132. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +118 -0
  133. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +165 -0
  134. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js +79 -0
  135. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js +78 -0
  136. package/plugins/draw/src/adapters/openlayers/edit/selectionState.js +116 -0
  137. package/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js +109 -0
  138. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +184 -0
  139. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +133 -0
  140. package/plugins/draw/src/adapters/openlayers/edit/undoOps.js +94 -0
  141. package/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js +64 -0
  142. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js +74 -0
  143. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js +28 -0
  144. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js +49 -0
  145. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js +42 -0
  146. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.js +100 -0
  147. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js +80 -0
  148. package/plugins/draw/src/adapters/openlayers/olDraw.js +40 -0
  149. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +70 -0
  150. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.js +220 -0
  151. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js +232 -0
  152. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js +198 -0
  153. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js +136 -0
  154. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +81 -0
  155. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js +72 -0
  156. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +57 -0
  157. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +68 -0
  158. package/plugins/draw/src/adapters/openlayers/snap/snapManager.js +99 -0
  159. package/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js +101 -0
  160. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js +103 -0
  161. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js +76 -0
  162. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +35 -0
  163. package/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js +22 -0
  164. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js +22 -0
  165. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js +18 -0
  166. package/plugins/draw/src/adapters/openlayers/utils/touchTarget.js +8 -0
  167. package/plugins/draw/src/api/addFeature.js +22 -0
  168. package/plugins/draw/src/api/addFeature.test.js +41 -0
  169. package/plugins/draw/src/api/deleteFeature.js +11 -0
  170. package/plugins/draw/src/api/deleteFeature.test.js +24 -0
  171. package/plugins/draw/src/api/editFeature.js +54 -0
  172. package/plugins/draw/src/api/editFeature.test.js +134 -0
  173. package/plugins/draw/src/api/merge.js +30 -0
  174. package/plugins/draw/src/api/merge.test.js +57 -0
  175. package/plugins/draw/src/api/newLine.js +41 -0
  176. package/plugins/draw/src/api/newLine.test.js +93 -0
  177. package/plugins/draw/src/api/newPolygon.js +41 -0
  178. package/plugins/draw/src/api/newPolygon.test.js +93 -0
  179. package/plugins/draw/src/api/split.js +117 -0
  180. package/plugins/draw/src/api/split.test.js +204 -0
  181. package/plugins/draw/src/defaults.js +52 -0
  182. package/plugins/draw/src/defaults.test.js +18 -0
  183. package/plugins/draw/src/draw.scss +43 -0
  184. package/plugins/draw/src/events.js +237 -0
  185. package/plugins/draw/src/events.test.js +369 -0
  186. package/plugins/draw/src/index.js +10 -0
  187. package/plugins/draw/src/index.test.js +24 -0
  188. package/plugins/draw/src/manifest.js +170 -0
  189. package/plugins/draw/src/manifest.test.js +147 -0
  190. package/plugins/draw/src/reducer.js +69 -0
  191. package/plugins/draw/src/reducer.test.js +85 -0
  192. package/plugins/draw/src/utils/debounce.js +16 -0
  193. package/plugins/draw/src/utils/debounce.test.js +40 -0
  194. package/plugins/draw/src/utils/eventBus.js +36 -0
  195. package/plugins/draw/src/utils/eventBus.test.js +47 -0
  196. package/plugins/draw/src/utils/flattenStyleProperties.js +25 -0
  197. package/plugins/draw/src/utils/flattenStyleProperties.test.js +33 -0
  198. package/plugins/draw/src/utils/getValueForStyle.js +33 -0
  199. package/plugins/draw/src/utils/getValueForStyle.test.js +32 -0
  200. package/plugins/draw/src/utils/resolveColors.js +39 -0
  201. package/plugins/draw/src/utils/resolveColors.test.js +34 -0
  202. package/plugins/draw/src/utils/spatial.js +243 -0
  203. package/plugins/draw/src/utils/spatial.test.js +243 -0
  204. package/plugins/draw/src/utils/touchTarget.js +95 -0
  205. package/plugins/draw/src/utils/touchTarget.test.js +107 -0
  206. package/plugins/draw/src/utils/undoStack.js +31 -0
  207. package/plugins/draw/src/utils/undoStack.test.js +49 -0
  208. package/plugins/draw/src/validation/liveDrawChecks.js +120 -0
  209. package/plugins/draw/src/validation/liveDrawChecks.test.js +159 -0
  210. package/plugins/draw/src/validation/liveStroke.js +85 -0
  211. package/plugins/draw/src/validation/liveStroke.test.js +150 -0
  212. package/plugins/draw/src/validation/rules.areaError.test.js +12 -0
  213. package/plugins/draw/src/validation/rules.js +186 -0
  214. package/plugins/draw/src/validation/rules.test.js +120 -0
  215. package/plugins/draw/src/validation/validateGeometry.js +119 -0
  216. package/plugins/draw/src/validation/validateGeometry.test.js +200 -0
  217. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  218. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  219. package/plugins/interact/src/hooks/useInteractionHandlers.js +1 -1
  220. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +1 -1
  221. package/plugins/interact/src/utils/buildStylesMap.js +1 -0
  222. package/plugins/search/dist/css/index.css +1 -317
  223. package/rollup.esm.mjs +35 -28
  224. package/sonar-project.properties +2 -2
  225. package/src/App/components/Attributions/Attributions.module.scss +1 -0
  226. package/src/App/components/Hints/Hints.module.scss +5 -4
  227. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +1 -1
  228. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +11 -0
  229. package/src/App/components/MoveControl/MoveControl.jsx +73 -9
  230. package/src/App/components/MoveControl/MoveControl.module.scss +12 -2
  231. package/src/App/components/MoveControl/MoveControl.test.jsx +136 -0
  232. package/src/App/hooks/useHintsAPI.js +67 -0
  233. package/src/App/hooks/useHintsAPI.test.js +145 -0
  234. package/src/App/renderer/PluginInits.jsx +4 -0
  235. package/src/App/renderer/PluginInits.test.jsx +7 -1
  236. package/src/InteractiveMap/InteractiveMap.js +20 -0
  237. package/src/InteractiveMap/InteractiveMap.test.js +7 -0
  238. package/src/config/events.js +4 -0
  239. package/src/scss/settings/_dimensions.scss +3 -0
  240. package/src/services/announcer.js +38 -3
  241. package/src/services/announcer.test.js +133 -1
  242. package/src/services/hints.js +3 -0
  243. package/src/services/hints.test.js +19 -0
  244. package/src/types.js +12 -0
  245. package/src/utils/detectInterfaceType.js +5 -1
  246. package/src/utils/isMac.js +15 -0
  247. package/src/utils/isMac.test.js +37 -0
  248. package/webpack.umd.mjs +1 -0
@@ -0,0 +1,243 @@
1
+ import polygonSplitter from 'polygon-splitter'
2
+ import turfUnion from '@turf/union'
3
+ import {
4
+ featureCollection as turfFeatureCollection,
5
+ polygon as turfPolygon,
6
+ multiPolygon as turfMultiPolygon,
7
+ lineString as turfLineString,
8
+ multiLineString as turfMultiLineString,
9
+ point as turfPoint,
10
+ multiPoint as turfMultiPoint
11
+ } from '@turf/helpers'
12
+
13
+ /**
14
+ * @typedef {import('geojson').Feature<import('geojson').Polygon>} Polygon
15
+ * @typedef {import('geojson').Feature<import('geojson').LineString>} Line
16
+ * @typedef {import('geojson').Feature<import('geojson').Feature>} Feature
17
+ * @typedef {import('geojson').FeatureCollection<import('geojson').FeatureCollection>} FeatureCollection
18
+ */
19
+
20
+ /**
21
+ * Extend a LineString at its endpoints, along their own direction, by a small
22
+ * fraction of the adjacent segment's length. Pure planar vector math — no
23
+ * geodesic assumption, so it works for any coordinate system (lon/lat degrees,
24
+ * projected meters like British National Grid, etc.). Turf's bearing/distance
25
+ * functions assume WGS84 lon/lat input; feeding them projected coordinates
26
+ * silently produces nonsense (large eastings/northings get treated as
27
+ * out-of-range degrees and wrapped), which is why this doesn't use them.
28
+ *
29
+ * @param {Feature<LineString>} line
30
+ * @param {number} fraction - portion of the adjacent segment's length to extend by
31
+ */
32
+ function extendLine (line, fraction = 0.01) {
33
+ const coords = line.geometry.coordinates.map(c => [...c])
34
+ const last = coords.length - 1
35
+
36
+ const extend = (from, towards) => [
37
+ from[0] + (from[0] - towards[0]) * fraction,
38
+ from[1] + (from[1] - towards[1]) * fraction
39
+ ]
40
+
41
+ // Compute both from the original coordinates before assigning either — for a
42
+ // 2-point line, coords[last - 1] is coords[0], so writing coords[0] first
43
+ // would corrupt the reference point the end extension reads.
44
+ const newStart = extend(coords[0], coords[1])
45
+ const newEnd = extend(coords[last], coords[last - 1])
46
+ coords[0] = newStart
47
+ coords[last] = newEnd
48
+
49
+ return turfLineString(coords)
50
+ }
51
+
52
+ /**
53
+ * Split a polygon using a line.
54
+ * Only accepts splits that result in exactly two polygons.
55
+ *
56
+ * @param {Feature<Polygon>} polygon
57
+ * @param {Feature<LineString>} line
58
+ * @returns {FeatureCollection<Polygon>|null}
59
+ */
60
+ const splitPolygon = (polygon, line) => {
61
+ // Extend only start and end vertices
62
+ const extended = extendLine(line) // assume extendLine only touches start/end now
63
+
64
+ let result
65
+ try {
66
+ result = polygonSplitter(polygon, extended)
67
+ } catch {
68
+ return null
69
+ }
70
+
71
+ // Must result in exactly 2 polygons
72
+ let polygons = []
73
+ if (result.geometry.type === 'MultiPolygon') {
74
+ if (result.geometry.coordinates.length !== 2) {
75
+ return null
76
+ }
77
+ polygons = result.geometry.coordinates.map(coords => turfPolygon(coords, polygon.properties))
78
+ } else {
79
+ return null
80
+ }
81
+
82
+ // Assign IDs & properties
83
+ const baseId = polygon.id ?? polygon.properties?.id ?? 'poly'
84
+ const features = polygons.map((poly, i) =>
85
+ turfPolygon(
86
+ poly.geometry.coordinates,
87
+ { ...polygon.properties, id: baseId },
88
+ { id: `${baseId}-${i + 1}` }
89
+ )
90
+ )
91
+
92
+ return turfFeatureCollection(features)
93
+ }
94
+
95
+ /**
96
+ * Merge multiple contiguous polygons into a single polygon.
97
+ * Only accepts a merge that results in exactly one polygon — a gap between two
98
+ * of the inputs would make Turf return a MultiPolygon instead, which is rejected.
99
+ *
100
+ * @param {Polygon[]} polygons
101
+ * @returns {Polygon|null}
102
+ */
103
+ const mergePolygons = (polygons) => {
104
+ let result
105
+ try {
106
+ result = turfUnion(turfFeatureCollection(polygons))
107
+ } catch {
108
+ return null
109
+ }
110
+
111
+ if (result?.geometry?.type !== 'Polygon') {
112
+ return null
113
+ }
114
+
115
+ // Assign ID & properties from the first feature, matching splitPolygon's convention.
116
+ const baseId = polygons[0].id ?? polygons[0].properties?.id ?? 'poly'
117
+ return turfPolygon(result.geometry.coordinates, { ...polygons[0].properties, id: baseId }, { id: baseId })
118
+ }
119
+
120
+ /**
121
+ * Convert a GeoJSON Feature or geometry-like object into a Turf geometry.
122
+ *
123
+ * @param {Object} featureOrGeom - Either a Feature with a `.geometry` property or a raw GeoJSON geometry object.
124
+ * @returns {Object} Turf geometry (Polygon, LineString, Point, etc.)
125
+ *
126
+ * @throws Will throw if the geometry type is not supported.
127
+ */
128
+ const toTurfGeometry = (featureOrGeom) => {
129
+ const geom = featureOrGeom.geometry || featureOrGeom
130
+
131
+ switch (geom.type) {
132
+ case 'Polygon':
133
+ return turfPolygon(geom.coordinates)
134
+ case 'MultiPolygon':
135
+ return turfMultiPolygon(geom.coordinates)
136
+ case 'LineString':
137
+ return turfLineString(geom.coordinates)
138
+ case 'MultiLineString':
139
+ return turfMultiLineString(geom.coordinates)
140
+ case 'Point':
141
+ return turfPoint(geom.coordinates)
142
+ case 'MultiPoint':
143
+ return turfMultiPoint(geom.coordinates)
144
+ default:
145
+ throw new Error(`Unsupported geometry type: ${geom.type}`)
146
+ }
147
+ }
148
+
149
+ const DEGREES_PER_HALF_TURN = 180
150
+
151
+ const haversine = ([lon1, lat1], [lon2, lat2]) => {
152
+ const toRad = deg => deg * Math.PI / DEGREES_PER_HALF_TURN
153
+ const R = 6371000 // meters
154
+ const dLat = toRad(lat2 - lat1)
155
+ const dLon = toRad(lon2 - lon1)
156
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2
157
+ return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
158
+ }
159
+
160
+ const SMALL_RING_LENGTH = 3
161
+
162
+ // True if any two points in the ring are within `tolerance` of each other
163
+ const hasDuplicatePair = (ring, tolerance) => {
164
+ for (let i = 0; i < ring.length; i++) {
165
+ for (let j = i + 1; j < ring.length; j++) {
166
+ if (haversine(ring[i], ring[j]) < tolerance) {
167
+ return true
168
+ }
169
+ }
170
+ }
171
+ return false
172
+ }
173
+
174
+ const isNewCoordinate = (coords, tolerance = 0.01) => {
175
+ const ring = coords[0]
176
+ // First coord is always new
177
+ if (ring.length <= 1) {
178
+ return true
179
+ }
180
+ // For small rings, reject if any two points coincide
181
+ if (ring.length <= SMALL_RING_LENGTH && hasDuplicatePair(ring, tolerance)) {
182
+ return false
183
+ }
184
+ return true
185
+ }
186
+
187
+ const isValidLineClick = (coords, tolerance = 0.01) => {
188
+ // First coord is always valid
189
+ if (coords.length <= 1) {
190
+ return true
191
+ }
192
+ // The new coordinate must differ from the previous one
193
+ const last = coords[coords.length - 1]
194
+ const secondLast = coords[coords.length - 2]
195
+ return haversine(last, secondLast) >= tolerance
196
+ }
197
+
198
+ const isValidClick = (coords) => {
199
+ // Valid when it's the very first point, or a genuinely new (non-duplicate) coordinate.
200
+ // Callers only pass single-ring polygon coordinates while drawing, so no ring-closure /
201
+ // self-intersection checks are needed here.
202
+ return coords[0].length <= 1 || isNewCoordinate(coords)
203
+ }
204
+
205
+ const spatialNavigate = (start, pixels, direction) => {
206
+ const quadrant = pixels.filter((p) => {
207
+ const offsetX = Math.abs(p[0] - start[0])
208
+ const offsetY = Math.abs(p[1] - start[1])
209
+ let isQuadrant = false
210
+ if (direction === 'ArrowUp') {
211
+ isQuadrant = p[1] <= start[1] && offsetY >= offsetX
212
+ } else if (direction === 'ArrowDown') {
213
+ isQuadrant = p[1] > start[1] && offsetY >= offsetX
214
+ } else if (direction === 'ArrowLeft') {
215
+ isQuadrant = p[0] <= start[0] && offsetY < offsetX
216
+ } else if (direction === 'ArrowRight') {
217
+ isQuadrant = p[0] > start[0] && offsetY < offsetX
218
+ } else {
219
+ isQuadrant = true
220
+ }
221
+ return isQuadrant && (JSON.stringify(p) !== JSON.stringify(start))
222
+ })
223
+
224
+ if (!quadrant.length) {
225
+ quadrant.push(start)
226
+ }
227
+
228
+ const pythagorean = (a, b) => Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2))
229
+ const distances = quadrant.map(p => pythagorean(Math.abs(start[0] - p[0]), Math.abs(start[1] - p[1])))
230
+ const closest = quadrant[distances.indexOf(Math.min(...distances))]
231
+ return pixels.findIndex(i => JSON.stringify(i) === JSON.stringify(closest))
232
+ }
233
+
234
+ export {
235
+ toTurfGeometry,
236
+ splitPolygon,
237
+ mergePolygons,
238
+ extendLine,
239
+ isNewCoordinate,
240
+ isValidClick,
241
+ isValidLineClick,
242
+ spatialNavigate
243
+ }
@@ -0,0 +1,243 @@
1
+ import polygonSplitter from 'polygon-splitter'
2
+ import turfUnion from '@turf/union'
3
+ import {
4
+ toTurfGeometry,
5
+ splitPolygon,
6
+ mergePolygons,
7
+ extendLine,
8
+ isNewCoordinate,
9
+ isValidClick,
10
+ isValidLineClick,
11
+ spatialNavigate
12
+ } from './spatial.js'
13
+
14
+ jest.mock('polygon-splitter', () => jest.fn())
15
+ jest.mock('@turf/union', () => jest.fn())
16
+
17
+ beforeEach(() => jest.clearAllMocks())
18
+
19
+ describe('toTurfGeometry', () => {
20
+ test.each([
21
+ ['Polygon', { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }],
22
+ ['MultiPolygon', { type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 0], [1, 1], [0, 0]]]] }],
23
+ ['LineString', { type: 'LineString', coordinates: [[0, 0], [1, 1]] }],
24
+ ['MultiLineString', { type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]]] }],
25
+ ['Point', { type: 'Point', coordinates: [0, 0] }],
26
+ ['MultiPoint', { type: 'MultiPoint', coordinates: [[0, 0], [1, 1]] }]
27
+ ])('converts a raw %s geometry', (type, geom) => {
28
+ expect(toTurfGeometry(geom).geometry.type).toBe(type)
29
+ })
30
+
31
+ test('unwraps a Feature via its .geometry property', () => {
32
+ const feature = { geometry: { type: 'Point', coordinates: [0, 0] } }
33
+ expect(toTurfGeometry(feature).geometry.type).toBe('Point')
34
+ })
35
+
36
+ test('throws for unsupported geometry types', () => {
37
+ expect(() => toTurfGeometry({ type: 'GeometryCollection' }))
38
+ .toThrow('Unsupported geometry type: GeometryCollection')
39
+ })
40
+ })
41
+
42
+ describe('extendLine', () => {
43
+ test('extends a two-point line at both ends, along each segment\'s own direction', () => {
44
+ const line = { geometry: { coordinates: [[0, 0], [0, 10]] } }
45
+ const result = extendLine(line, 0.1)
46
+ expect(result.geometry.type).toBe('LineString')
47
+ // Start moves further away from the second point (backward); end moves
48
+ // further away from the second-to-last point (forward).
49
+ expect(result.geometry.coordinates).toEqual([[0, -1], [0, 11]])
50
+ })
51
+
52
+ test('extends only the endpoints of a multi-point line', () => {
53
+ const line = { geometry: { coordinates: [[0, 0], [0, 1], [0, 2]] } }
54
+ const result = extendLine(line, 0.1)
55
+ expect(result.geometry.coordinates).toHaveLength(3)
56
+ expect(result.geometry.coordinates[1]).toEqual([0, 1]) // middle vertex untouched
57
+ })
58
+
59
+ test('is pure planar math — correct at British National Grid scale (large eastings/northings)', () => {
60
+ // Geodesic bearing/destination would misread these as out-of-range lon/lat
61
+ // degrees and wrap them; plain vector extension has no such assumption.
62
+ const line = { geometry: { coordinates: [[337600, 504600], [337650, 504650]] } }
63
+ const result = extendLine(line, 0.1)
64
+ expect(result.geometry.coordinates[0]).toEqual([337595, 504595])
65
+ expect(result.geometry.coordinates[1]).toEqual([337655, 504655])
66
+ })
67
+ })
68
+
69
+ describe('splitPolygon', () => {
70
+ const polygon = {
71
+ id: 'sq',
72
+ properties: { id: 'sq', name: 'field' },
73
+ geometry: { type: 'Polygon', coordinates: [[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]] }
74
+ }
75
+ const line = { geometry: { type: 'LineString', coordinates: [[2, -1], [2, 5]] } }
76
+
77
+ const twoPolygonResult = {
78
+ geometry: {
79
+ type: 'MultiPolygon',
80
+ coordinates: [
81
+ [[[0, 0], [2, 0], [2, 4], [0, 4], [0, 0]]],
82
+ [[[2, 0], [4, 0], [4, 4], [2, 4], [2, 0]]]
83
+ ]
84
+ }
85
+ }
86
+
87
+ test('returns a two-feature collection for a valid split', () => {
88
+ polygonSplitter.mockReturnValue(twoPolygonResult)
89
+
90
+ const result = splitPolygon(polygon, line)
91
+
92
+ expect(result.type).toBe('FeatureCollection')
93
+ expect(result.features).toHaveLength(2)
94
+ expect(result.features[0].id).toBe('sq-1')
95
+ expect(result.features[1].id).toBe('sq-2')
96
+ expect(result.features[0].properties).toMatchObject({ id: 'sq', name: 'field' })
97
+ })
98
+
99
+ test('returns null when the splitter throws', () => {
100
+ polygonSplitter.mockImplementation(() => { throw new Error('bad geometry') })
101
+ expect(splitPolygon(polygon, line)).toBeNull()
102
+ })
103
+
104
+ test('returns null when the result is not a MultiPolygon', () => {
105
+ polygonSplitter.mockReturnValue({ geometry: { type: 'Polygon', coordinates: [] } })
106
+ expect(splitPolygon(polygon, line)).toBeNull()
107
+ })
108
+
109
+ test('returns null when the split does not produce exactly two polygons', () => {
110
+ polygonSplitter.mockReturnValue({ geometry: { type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 0], [1, 1], [0, 0]]]] } })
111
+ expect(splitPolygon(polygon, line)).toBeNull()
112
+ })
113
+
114
+ test('derives the base id from properties.id then a default', () => {
115
+ polygonSplitter.mockReturnValue(twoPolygonResult)
116
+
117
+ const noTopLevelId = { properties: { id: 'pid' }, geometry: polygon.geometry }
118
+ expect(splitPolygon(noTopLevelId, line).features[0].id).toBe('pid-1')
119
+
120
+ const anonymous = { properties: {}, geometry: polygon.geometry }
121
+ expect(splitPolygon(anonymous, line).features[0].id).toBe('poly-1')
122
+ })
123
+ })
124
+
125
+ describe('mergePolygons', () => {
126
+ const squareA = {
127
+ id: 'sq-a',
128
+ properties: { id: 'sq-a', name: 'field' },
129
+ geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] }
130
+ }
131
+ const squareB = {
132
+ id: 'sq-b',
133
+ properties: { id: 'sq-b' },
134
+ geometry: { type: 'Polygon', coordinates: [[[1, 0], [2, 0], [2, 1], [1, 1], [1, 0]]] }
135
+ }
136
+
137
+ const mergedResult = {
138
+ geometry: { type: 'Polygon', coordinates: [[[0, 0], [2, 0], [2, 1], [0, 1], [0, 0]]] }
139
+ }
140
+
141
+ test('returns a single merged polygon for a valid union', () => {
142
+ turfUnion.mockReturnValue(mergedResult)
143
+
144
+ const result = mergePolygons([squareA, squareB])
145
+
146
+ expect(result.geometry.type).toBe('Polygon')
147
+ expect(result.id).toBe('sq-a')
148
+ expect(result.properties).toMatchObject({ id: 'sq-a', name: 'field' })
149
+ })
150
+
151
+ test('returns null when union throws', () => {
152
+ turfUnion.mockImplementation(() => { throw new Error('bad geometry') })
153
+ expect(mergePolygons([squareA, squareB])).toBeNull()
154
+ })
155
+
156
+ test('returns null when the inputs are not contiguous (MultiPolygon result)', () => {
157
+ turfUnion.mockReturnValue({ geometry: { type: 'MultiPolygon', coordinates: [] } })
158
+ expect(mergePolygons([squareA, squareB])).toBeNull()
159
+ })
160
+
161
+ test('returns null when union returns null', () => {
162
+ turfUnion.mockReturnValue(null)
163
+ expect(mergePolygons([squareA, squareB])).toBeNull()
164
+ })
165
+
166
+ test('derives the base id from properties.id then a default', () => {
167
+ turfUnion.mockReturnValue(mergedResult)
168
+
169
+ const noTopLevelId = { properties: { id: 'pid' }, geometry: squareA.geometry }
170
+ expect(mergePolygons([noTopLevelId, squareB]).id).toBe('pid')
171
+
172
+ const anonymous = { properties: {}, geometry: squareA.geometry }
173
+ expect(mergePolygons([anonymous, squareB]).id).toBe('poly')
174
+ })
175
+ })
176
+
177
+ describe('isNewCoordinate', () => {
178
+ test('is true for a ring with a single point', () => {
179
+ expect(isNewCoordinate([[[0, 0]]])).toBe(true)
180
+ })
181
+
182
+ test('is false when a small ring has coincident points', () => {
183
+ expect(isNewCoordinate([[[0, 0], [0, 0]]])).toBe(false)
184
+ })
185
+
186
+ test('is true when a small ring has distinct points', () => {
187
+ expect(isNewCoordinate([[[0, 0], [10, 10]]])).toBe(true)
188
+ })
189
+
190
+ test('skips the duplicate check for larger rings', () => {
191
+ expect(isNewCoordinate([[[0, 0], [0, 0], [1, 1], [2, 2], [3, 3]]])).toBe(true)
192
+ })
193
+ })
194
+
195
+ describe('isValidClick', () => {
196
+ test('is true when the ring has a single point', () => {
197
+ expect(isValidClick([[[0, 0]]])).toBe(true)
198
+ })
199
+
200
+ test('is true for a new (non-duplicate) coordinate', () => {
201
+ expect(isValidClick([[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]])).toBe(true)
202
+ })
203
+
204
+ test('is false when a small ring repeats a coordinate', () => {
205
+ expect(isValidClick([[[0, 0], [0, 0]]])).toBe(false)
206
+ })
207
+ })
208
+
209
+ describe('isValidLineClick', () => {
210
+ test('is true for the first coordinate', () => {
211
+ expect(isValidLineClick([[0, 0]])).toBe(true)
212
+ })
213
+
214
+ test('is true when the last two coordinates differ', () => {
215
+ expect(isValidLineClick([[0, 0], [10, 10]])).toBe(true)
216
+ })
217
+
218
+ test('is false when the last two coordinates coincide', () => {
219
+ expect(isValidLineClick([[0, 0], [0, 0]])).toBe(false)
220
+ })
221
+ })
222
+
223
+ describe('spatialNavigate', () => {
224
+ const start = [0, 0]
225
+ const pixels = [[0, 0], [0, -10], [0, 10], [-10, 0], [10, 0]]
226
+
227
+ test.each([
228
+ ['ArrowUp', 1],
229
+ ['ArrowDown', 2],
230
+ ['ArrowLeft', 3],
231
+ ['ArrowRight', 4]
232
+ ])('finds the nearest pixel for %s', (direction, expectedIndex) => {
233
+ expect(spatialNavigate(start, pixels, direction)).toBe(expectedIndex)
234
+ })
235
+
236
+ test('considers all pixels for an unrecognised direction', () => {
237
+ expect(spatialNavigate(start, pixels, 'Tab')).toBe(1)
238
+ })
239
+
240
+ test('falls back to the start point when no pixel is in the quadrant', () => {
241
+ expect(spatialNavigate(start, [[0, 0]], 'ArrowUp')).toBe(0)
242
+ })
243
+ })
@@ -0,0 +1,95 @@
1
+ /**
2
+ * SVG offset vertex target for touch edit mode
3
+ * Shown below the finger so users can accurately reposition vertices without finger occlusion.
4
+ *
5
+ * Colors update dynamically based on current color scheme via CSS custom properties:
6
+ * --draw-halo outer ring (editActive color)
7
+ * --draw-bg inner background (editHalo color)
8
+ * --draw-primary arrow icons and centre dot (editVertex color)
9
+ */
10
+
11
+ import { SIZES } from '../defaults.js'
12
+
13
+ const HALF_SIZE = SIZES.touchTargetSize / 2
14
+
15
+ /**
16
+ * Generate SVG touch target HTML string based on configured size
17
+ * @returns {string} SVG HTML
18
+ */
19
+ function generateSvgHtml () {
20
+ const size = SIZES.touchTargetSize
21
+ const half = HALF_SIZE
22
+ return `
23
+ <svg width='${size}' height='${size}' viewBox='0 0 48 48' fill-rule='evenodd'
24
+ style='display:none;position:absolute;top:0;left:0;margin:${half}px 0 0 -${half}px;cursor:grab'
25
+ class='im-draw-touch-target' data-im-draw-touch-target>
26
+ <circle cx='24' cy='24' r='24' fill='var(--draw-halo, #000)'/>
27
+ <path d="M37.543 25H34a1 1 0 1 1 0-2h3.629l-.836-.837a1 1 0 0 1 1.414-1.414l2.5 2.501A1 1 0 0 1 41 24a1 1 0 0 1-.487.858l-2.306 2.306a1 1 0 0 1-1.414-1.414l.75-.75zM23 10.414l-.793.793a1 1 0 0 1-1.414-1.414l2.5-2.5C23.481 7.105 23.734 7 24 7s.519.105.707.293l2.5 2.5a1 1 0 0 1-1.414 1.414L25 10.414V14a1 1 0 1 1-2 0v-3.586zM7 24a1 1 0 0 1 .293-.75l2.5-2.501a1 1 0 0 1 1.414 1.414l-.836.837H14a1 1 0 1 1 0 2h-3.543l.75.75a1 1 0 0 1-1.414 1.414l-2.306-2.306A1 1 0 0 1 7 24zm16.293 16.707l-2.5-2.5a1 1 0 0 1 1.414-1.414l.793.793V34a1 1 0 1 1 2 0v3.586l.793-.793a1 1 0 0 1 1.414 1.414l-2.5 2.5c-.188.188-.441.293-.707.293s-.519-.105-.707-.293zM24 20c2.208 0 4 1.792 4 4s-1.792 4-4 4-4-1.792-4-4 1.792-4 4-4z" fill='var(--draw-bg, #fff)'/>
28
+ </svg>
29
+ `
30
+ }
31
+
32
+ /**
33
+ * Create or retrieve the touch target SVG element
34
+ * @param {HTMLElement} container - Parent container element
35
+ * @returns {SVGElement} The touch target element
36
+ */
37
+ export const createTouchTarget = (container) => {
38
+ let el = container.querySelector('[data-im-draw-touch-target]')
39
+ if (!el) {
40
+ container.insertAdjacentHTML('beforeend', generateSvgHtml())
41
+ el = container.querySelector('[data-im-draw-touch-target]')
42
+ }
43
+ return el
44
+ }
45
+
46
+ /**
47
+ * Apply color scheme to touch target SVG
48
+ * @param {SVGElement} el - Touch target element
49
+ * @param {object} colors - Color object with editActive, editHalo, editVertex properties
50
+ */
51
+ export const applyTouchTargetColors = (el, colors) => {
52
+ if (!el) {
53
+ return
54
+ }
55
+ el.style.setProperty('--draw-halo', colors.editActive)
56
+ el.style.setProperty('--draw-bg', colors.editHalo)
57
+ el.style.setProperty('--draw-primary', colors.editVertex)
58
+ }
59
+
60
+ /**
61
+ * Show touch target at specified pixel position
62
+ * @param {SVGElement} el - Touch target element
63
+ * @param {object} pixel - Position object with x, y properties
64
+ */
65
+ export const showTouchTarget = (el, pixel) => {
66
+ if (!pixel || !el) {
67
+ return
68
+ }
69
+ el.style.left = `${pixel.x}px`
70
+ el.style.top = `${pixel.y}px`
71
+ el.style.display = 'block'
72
+ }
73
+
74
+ /**
75
+ * Hide touch target
76
+ * @param {SVGElement} el - Touch target element
77
+ */
78
+ export const hideTouchTarget = (el) => {
79
+ if (el) {
80
+ el.style.display = 'none'
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Check if event target is part of the touch target SVG
86
+ * @param {HTMLElement} el - Element to check
87
+ * @returns {boolean} True if element is part of touch target
88
+ */
89
+ export const isOnTouchTarget = (el) => {
90
+ if (!el) {
91
+ return false
92
+ }
93
+ const parent = el.parentNode
94
+ return (parent instanceof globalThis.SVGElement) || (parent?.ownerSVGElement != null)
95
+ }
@@ -0,0 +1,107 @@
1
+ import {
2
+ createTouchTarget,
3
+ applyTouchTargetColors,
4
+ showTouchTarget,
5
+ hideTouchTarget,
6
+ isOnTouchTarget
7
+ } from './touchTarget.js'
8
+
9
+ const SVG_NS = 'http://www.w3.org/2000/svg'
10
+
11
+ describe('createTouchTarget', () => {
12
+ test('inserts the SVG target once and returns it', () => {
13
+ const container = document.createElement('div')
14
+
15
+ const el = createTouchTarget(container)
16
+
17
+ expect(el).not.toBeNull()
18
+ expect(el.hasAttribute('data-im-draw-touch-target')).toBe(true)
19
+ expect(container.querySelectorAll('[data-im-draw-touch-target]')).toHaveLength(1)
20
+ })
21
+
22
+ test('returns the existing target without inserting a second one', () => {
23
+ const container = document.createElement('div')
24
+ const first = createTouchTarget(container)
25
+ const second = createTouchTarget(container)
26
+
27
+ expect(second).toBe(first)
28
+ expect(container.querySelectorAll('[data-im-draw-touch-target]')).toHaveLength(1)
29
+ })
30
+ })
31
+
32
+ describe('applyTouchTargetColors', () => {
33
+ test('sets the CSS custom properties from the colours', () => {
34
+ const el = document.createElement('div')
35
+
36
+ applyTouchTargetColors(el, { editActive: 'a', editHalo: 'h', editVertex: 'v' })
37
+
38
+ expect(el.style.getPropertyValue('--draw-halo')).toBe('a')
39
+ expect(el.style.getPropertyValue('--draw-bg')).toBe('h')
40
+ expect(el.style.getPropertyValue('--draw-primary')).toBe('v')
41
+ })
42
+
43
+ test('does nothing for a null element', () => {
44
+ expect(() => applyTouchTargetColors(null, {})).not.toThrow()
45
+ })
46
+ })
47
+
48
+ describe('showTouchTarget', () => {
49
+ test('positions and shows the element', () => {
50
+ const el = document.createElement('div')
51
+
52
+ showTouchTarget(el, { x: 10, y: 20 })
53
+
54
+ expect(el.style.left).toBe('10px')
55
+ expect(el.style.top).toBe('20px')
56
+ expect(el.style.display).toBe('block')
57
+ })
58
+
59
+ test('does nothing without a pixel or an element', () => {
60
+ const el = document.createElement('div')
61
+ showTouchTarget(el, null)
62
+ expect(el.style.display).toBe('')
63
+
64
+ expect(() => showTouchTarget(null, { x: 0, y: 0 })).not.toThrow()
65
+ })
66
+ })
67
+
68
+ describe('hideTouchTarget', () => {
69
+ test('hides the element', () => {
70
+ const el = document.createElement('div')
71
+ el.style.display = 'block'
72
+
73
+ hideTouchTarget(el)
74
+
75
+ expect(el.style.display).toBe('none')
76
+ })
77
+
78
+ test('does nothing for a null element', () => {
79
+ expect(() => hideTouchTarget(null)).not.toThrow()
80
+ })
81
+ })
82
+
83
+ describe('isOnTouchTarget', () => {
84
+ test('returns false for a null element', () => {
85
+ expect(isOnTouchTarget(null)).toBe(false)
86
+ })
87
+
88
+ test('returns false for an element without a parent', () => {
89
+ expect(isOnTouchTarget(document.createElement('div'))).toBe(false)
90
+ })
91
+
92
+ test('returns true when the parent is an SVG element', () => {
93
+ const svg = document.createElementNS(SVG_NS, 'svg')
94
+ const circle = document.createElementNS(SVG_NS, 'circle')
95
+ svg.appendChild(circle)
96
+
97
+ expect(isOnTouchTarget(circle)).toBe(true)
98
+ })
99
+
100
+ test('returns false when the parent is a plain HTML element', () => {
101
+ const div = document.createElement('div')
102
+ const span = document.createElement('span')
103
+ div.appendChild(span)
104
+
105
+ expect(isOnTouchTarget(span)).toBe(false)
106
+ })
107
+ })