@defra/interactive-map 0.0.26-alpha → 0.0.28-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 (105) 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/assets/css/docusaurus.scss +4 -0
  7. package/package.json +1 -1
  8. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  9. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  10. package/plugins/beta/draw-ml/src/defaults.js +14 -14
  11. package/plugins/beta/draw-ml/src/mapboxDraw.js +5 -0
  12. package/plugins/beta/draw-ml/src/mapboxSnap.js +1 -1
  13. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +10 -2
  14. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +2 -0
  15. package/plugins/beta/draw-ml/src/reducer.js +4 -1
  16. package/plugins/beta/draw-ml/src/styles.js +42 -39
  17. package/plugins/beta/draw-ol/dist/css/index.css +13 -0
  18. package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -0
  19. package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -0
  20. package/plugins/beta/draw-ol/dist/esm/geometryHelpers.js +1 -0
  21. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -0
  22. package/plugins/beta/draw-ol/dist/esm/index.js +1 -0
  23. package/plugins/beta/draw-ol/src/DrawInit.jsx +67 -0
  24. package/plugins/beta/draw-ol/src/api/addFeature.js +29 -0
  25. package/plugins/beta/draw-ol/src/api/deleteFeature.js +11 -0
  26. package/plugins/beta/draw-ol/src/api/editFeature.js +50 -0
  27. package/plugins/beta/draw-ol/src/api/newLine.js +44 -0
  28. package/plugins/beta/draw-ol/src/api/newPolygon.js +44 -0
  29. package/plugins/beta/draw-ol/src/core/OLDrawManager.js +150 -0
  30. package/plugins/beta/draw-ol/src/core/featureStore.js +65 -0
  31. package/plugins/beta/draw-ol/src/core/styles.js +98 -0
  32. package/plugins/beta/draw-ol/src/core/undoStack.js +31 -0
  33. package/plugins/beta/draw-ol/src/defaults.js +16 -0
  34. package/plugins/beta/draw-ol/src/draw/DrawMode.js +113 -0
  35. package/plugins/beta/draw-ol/src/draw/drawInput.js +199 -0
  36. package/plugins/beta/draw-ol/src/draw.scss +17 -0
  37. package/plugins/beta/draw-ol/src/edit/EditMode.js +424 -0
  38. package/plugins/beta/draw-ol/src/edit/keyboardHandler.js +227 -0
  39. package/plugins/beta/draw-ol/src/edit/midpointLayer.js +57 -0
  40. package/plugins/beta/draw-ol/src/edit/touchHandler.js +171 -0
  41. package/plugins/beta/draw-ol/src/edit/undoOps.js +95 -0
  42. package/plugins/beta/draw-ol/src/edit/vertexHitTest.js +72 -0
  43. package/plugins/beta/draw-ol/src/edit/vertexLayer.js +49 -0
  44. package/plugins/beta/draw-ol/src/edit/vertexOps.js +99 -0
  45. package/plugins/beta/draw-ol/src/events.js +114 -0
  46. package/plugins/beta/draw-ol/src/index.js +12 -0
  47. package/plugins/beta/draw-ol/src/manifest.js +121 -0
  48. package/plugins/beta/draw-ol/src/olDraw.js +38 -0
  49. package/plugins/beta/draw-ol/src/reducer.js +41 -0
  50. package/plugins/beta/draw-ol/src/snap/snapEngine.js +109 -0
  51. package/plugins/beta/draw-ol/src/snap/snapGeometry.js +200 -0
  52. package/plugins/beta/draw-ol/src/snap/snapIndicator.js +81 -0
  53. package/plugins/beta/draw-ol/src/snap/snapInteraction.js +56 -0
  54. package/plugins/beta/draw-ol/src/snap/snapManager.js +90 -0
  55. package/plugins/beta/draw-ol/src/utils/flattenStyleProperties.js +47 -0
  56. package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +97 -0
  57. package/plugins/beta/draw-ol/src/utils/olCoords.js +35 -0
  58. package/plugins/beta/draw-ol/src/utils/resolveColors.js +39 -0
  59. package/plugins/beta/draw-ol/src/utils/spatial.js +28 -0
  60. package/plugins/beta/draw-ol/src/utils/touchTarget.js +61 -0
  61. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  62. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  63. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  64. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  65. package/plugins/interact/src/defaults.js +1 -0
  66. package/plugins/interact/src/hooks/useInteractionHandlers.js +150 -43
  67. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +240 -8
  68. package/plugins/interact/src/reducer.js +7 -0
  69. package/plugins/interact/src/reducer.test.js +16 -0
  70. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  71. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  72. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  73. package/providers/beta/openlayers/src/appEvents.js +9 -13
  74. package/providers/beta/openlayers/src/appEvents.test.js +25 -17
  75. package/providers/beta/openlayers/src/mapEvents.js +32 -16
  76. package/providers/beta/openlayers/src/mapEvents.test.js +43 -10
  77. package/providers/beta/openlayers/src/openlayersProvider.js +45 -26
  78. package/providers/beta/openlayers/src/openlayersProvider.test.js +12 -34
  79. package/providers/beta/openlayers/src/utils/highlightFeatures.js +245 -0
  80. package/providers/beta/openlayers/src/utils/hoverCursor.js +65 -0
  81. package/providers/beta/openlayers/src/utils/hoverCursor.test.js +235 -0
  82. package/providers/beta/openlayers/src/utils/openLayersFixes.js +11 -0
  83. package/providers/beta/openlayers/src/utils/openLayersFixes.test.js +53 -0
  84. package/providers/beta/openlayers/src/utils/queryFeatures.js +77 -0
  85. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +181 -0
  86. package/providers/beta/openlayers/src/utils/tileLayers.js +19 -1
  87. package/providers/beta/openlayers/src/utils/tileLayers.test.js +74 -3
  88. package/providers/beta/openlayers/src/utils/vtTileFragments.js +86 -0
  89. package/rollup.esm.mjs +6 -0
  90. package/src/App/components/Hints/Hints.test.jsx +2 -2
  91. package/src/App/components/PopupMenu/PopupMenu.module.scss +6 -0
  92. package/src/App/controls/keyboardActions.js +2 -2
  93. package/src/App/controls/keyboardMappings.js +1 -2
  94. package/src/App/controls/keyboardShortcuts.js +5 -3
  95. package/src/App/controls/keyboardShortcuts.test.js +24 -0
  96. package/src/InteractiveMap/InteractiveMap.js +38 -10
  97. package/src/InteractiveMap/InteractiveMap.test.js +282 -356
  98. package/src/InteractiveMap/deviceChecker.js +3 -2
  99. package/src/InteractiveMap/deviceChecker.test.js +7 -10
  100. package/src/InteractiveMap/historyManager.js +7 -0
  101. package/src/InteractiveMap/historyManager.test.js +11 -0
  102. package/src/config/defaults.js +1 -1
  103. package/src/utils/htmlToPlainText.js +11 -0
  104. package/src/utils/htmlToPlainText.test.js +5 -0
  105. package/webpack.dev.mjs +3 -1
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Pure geometry helpers for snap candidate testing.
3
+ * No OL imports, no side effects — only coordinate math.
4
+ *
5
+ * All coordinates are [x, y] pairs in map projection units.
6
+ */
7
+
8
+ const dist2 = (a, b) => {
9
+ const dx = a[0] - b[0]
10
+ const dy = a[1] - b[1]
11
+ return dx * dx + dy * dy
12
+ }
13
+
14
+ const closestPointOnSegment = (p, a, b) => {
15
+ const dx = b[0] - a[0]
16
+ const dy = b[1] - a[1]
17
+ const lenSq = dx * dx + dy * dy
18
+ if (lenSq === 0) {
19
+ return [a[0], a[1]]
20
+ }
21
+ const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq))
22
+ return [a[0] + t * dx, a[1] + t * dy]
23
+ }
24
+
25
+ const bestOf = (current, candidate) => better(current, candidate) ? candidate : current
26
+
27
+ const better = (a, b) => {
28
+ if (!a) {
29
+ return !!b
30
+ }
31
+ if (!b) {
32
+ return false
33
+ }
34
+ // Vertex always beats edge — only compare distance within the same type
35
+ if (a.type === 'edge' && b.type === 'vertex') {
36
+ return true
37
+ }
38
+ if (a.type === 'vertex' && b.type === 'edge') {
39
+ return false
40
+ }
41
+ return b.distSq < a.distSq
42
+ }
43
+
44
+ /**
45
+ * Test all vertices and edges of a coordinate ring/line for snap candidates.
46
+ * coords: [[x,y], ...] — OL ring (first === last for closed rings)
47
+ * isClosedRing: if true, OL has duplicated the first coord as last; skip it and wrap edges
48
+ */
49
+ const testCoords = (coords, query, toleranceSq, isClosedRing) => {
50
+ let best = null
51
+ const n = isClosedRing && coords.length > 1 ? coords.length - 1 : coords.length
52
+ const edgeCount = isClosedRing ? n : n - 1
53
+
54
+ for (let i = 0; i < n; i++) {
55
+ const v = coords[i]
56
+ const dSq = dist2(query, v)
57
+ if (dSq <= toleranceSq) {
58
+ best = bestOf(best, { type: 'vertex', coord: [v[0], v[1]], distSq: dSq })
59
+ }
60
+ }
61
+
62
+ for (let i = 0; i < edgeCount; i++) {
63
+ const a = coords[i]
64
+ const b = coords[(i + 1) % n]
65
+ const pt = closestPointOnSegment(query, a, b)
66
+ const dSq = dist2(query, pt)
67
+ if (dSq <= toleranceSq) {
68
+ best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq })
69
+ }
70
+ }
71
+
72
+ return best
73
+ }
74
+
75
+ /**
76
+ * Test flat coordinate array (stride 2) from a VectorTile RenderFeature.
77
+ * flat: number[] — [x0,y0,x1,y1,...]
78
+ * start/end: index range within flat
79
+ * isClosedRing: VTile polygon rings — first coord is NOT duplicated at end (unlike OL Vector)
80
+ * so treat all coords as unique vertices and add a closing edge back to first
81
+ */
82
+ const getBestEdge = (flat, start, numPairs, edgeCount, query, toleranceSq) => {
83
+ let best = null
84
+ for (let i = 0; i < edgeCount; i++) {
85
+ const ai = start + i * 2
86
+ const bi = start + ((i + 1) % numPairs) * 2
87
+ const a = [flat[ai], flat[ai + 1]]
88
+ const b = [flat[bi], flat[bi + 1]]
89
+ const pt = closestPointOnSegment(query, a, b)
90
+ const dSq = dist2(query, pt)
91
+ if (dSq <= toleranceSq) {
92
+ best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq })
93
+ }
94
+ }
95
+ return best
96
+ }
97
+
98
+ const getBestPair = (flat, start, numPairs, edgeCount, query, toleranceSq) => {
99
+ let best = null
100
+ for (let i = 0; i < numPairs; i++) {
101
+ const xi = start + i * 2
102
+ const v = [flat[xi], flat[xi + 1]]
103
+ const dSq = dist2(query, v)
104
+ if (dSq <= toleranceSq) {
105
+ best = bestOf(best, { type: 'vertex', coord: v, distSq: dSq })
106
+ }
107
+ }
108
+ return bestOf(best, getBestEdge(flat, start, numPairs, edgeCount, query, toleranceSq))
109
+ }
110
+
111
+ const testFlatCoords = (flat, start, end, query, toleranceSq, isClosedRing) => {
112
+ const numPairs = (end - start) / 2
113
+ const edgeCount = isClosedRing ? numPairs : numPairs - 1
114
+ return getBestPair(flat, start, numPairs, edgeCount, query, toleranceSq)
115
+ }
116
+
117
+ const olGeomHandlers = {
118
+ Point (geom, query, toleranceSq) {
119
+ const c = geom.getCoordinates()
120
+ const dSq = dist2(query, c)
121
+ return dSq <= toleranceSq ? { type: 'vertex', coord: [c[0], c[1]], distSq: dSq } : null
122
+ },
123
+ LineString (geom, query, toleranceSq) {
124
+ return testCoords(geom.getCoordinates(), query, toleranceSq, false)
125
+ },
126
+ LinearRing (geom, query, toleranceSq) {
127
+ return testCoords(geom.getCoordinates(), query, toleranceSq, true)
128
+ },
129
+ Polygon (geom, query, toleranceSq) {
130
+ let best = null
131
+ for (const ring of geom.getCoordinates()) {
132
+ best = bestOf(best, testCoords(ring, query, toleranceSq, true))
133
+ }
134
+ return best
135
+ },
136
+ MultiLineString (geom, query, toleranceSq) {
137
+ let best = null
138
+ for (const line of geom.getCoordinates()) {
139
+ best = bestOf(best, testCoords(line, query, toleranceSq, false))
140
+ }
141
+ return best
142
+ },
143
+ MultiPolygon (geom, query, toleranceSq) {
144
+ let best = null
145
+ for (const polygon of geom.getCoordinates()) {
146
+ for (const ring of polygon) {
147
+ best = bestOf(best, testCoords(ring, query, toleranceSq, true))
148
+ }
149
+ }
150
+ return best
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Test an OL Feature (from a VectorSource) against query coord.
156
+ * Handles Point, LineString, LinearRing, Polygon, MultiLineString, MultiPolygon.
157
+ *
158
+ * @returns {{ type: 'vertex'|'edge', coord: number[], distSq: number } | null}
159
+ */
160
+ export const testOLFeature = (feature, query, toleranceSq) => {
161
+ const geom = feature.getGeometry()
162
+ if (!geom) {
163
+ return null
164
+ }
165
+ const handler = olGeomHandlers[geom.getType()]
166
+ return handler ? handler(geom, query, toleranceSq) : null
167
+ }
168
+
169
+ /**
170
+ * Test an OL RenderFeature (from a VectorTileSource) against query coord.
171
+ * Handles Point, LineString, Polygon, MultiLineString.
172
+ *
173
+ * @returns {{ type: 'vertex'|'edge', coord: number[], distSq: number } | null}
174
+ */
175
+ export const testRenderFeature = (feature, query, toleranceSq) => {
176
+ const type = feature.getType()
177
+ const flat = feature.getFlatCoordinates()
178
+ let best = null
179
+
180
+ if (type === 'Point') {
181
+ const dSq = dist2(query, flat)
182
+ if (dSq <= toleranceSq) {
183
+ best = bestOf(best, { type: 'vertex', coord: [flat[0], flat[1]], distSq: dSq })
184
+ }
185
+ } else if (type === 'LineString') {
186
+ best = bestOf(best, testFlatCoords(flat, 0, flat.length, query, toleranceSq, false))
187
+ } else if (type === 'Polygon' || type === 'MultiLineString') {
188
+ const ends = feature.getEnds()
189
+ let start = 0
190
+ const isClosedRing = type === 'Polygon'
191
+ for (const end of ends) {
192
+ best = bestOf(best, testFlatCoords(flat, start, end, query, toleranceSq, isClosedRing))
193
+ start = end
194
+ }
195
+ } else {
196
+ // No action
197
+ }
198
+
199
+ return best
200
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Snap indicator — a single OL VectorLayer that shows a circle at the active
3
+ * snap candidate position.
4
+ *
5
+ * Vertex snap → orange semi-transparent circle (snapVertex color)
6
+ * Edge snap → blue semi-transparent circle (snapEdge color)
7
+ *
8
+ * Uses Style.renderer (single canvas call) so the circle renders correctly
9
+ * at fractional CSS scale factors.
10
+ */
11
+
12
+ import VectorLayer from 'ol/layer/Vector.js'
13
+ import VectorSource from 'ol/source/Vector.js'
14
+ import Feature from 'ol/Feature.js'
15
+ import Point from 'ol/geom/Point.js'
16
+ import { Style } from 'ol/style.js'
17
+
18
+ const RADIUS_PX = 10
19
+
20
+ const makeRenderer = (color) => (coords, state) => {
21
+ const ctx = state.context
22
+ const [cx, cy] = coords
23
+ ctx.beginPath()
24
+ ctx.arc(cx, cy, RADIUS_PX * state.pixelRatio, 0, Math.PI * 2)
25
+ ctx.fillStyle = color
26
+ ctx.fill()
27
+ }
28
+
29
+ const makeStyles = (colors) => ({
30
+ vertex: new Style({ renderer: makeRenderer(colors.snapVertex) }),
31
+ edge: new Style({ renderer: makeRenderer(colors.snapEdge) })
32
+ })
33
+
34
+ export const createSnapIndicator = (map, colors) => {
35
+ let styles = makeStyles(colors)
36
+ const source = new VectorSource()
37
+ const layer = new VectorLayer({
38
+ source,
39
+ style: (f) => styles[f.get('snapType')] ?? null,
40
+ zIndex: 200,
41
+ updateWhileAnimating: true,
42
+ updateWhileInteracting: true
43
+ })
44
+ map.addLayer(layer)
45
+
46
+ const feature = new Feature()
47
+ let showing = false
48
+
49
+ return {
50
+ show (coord, type) {
51
+ feature.setGeometry(new Point(coord))
52
+ feature.set('snapType', type, true)
53
+ if (showing) {
54
+ source.changed()
55
+ } else {
56
+ source.addFeature(feature)
57
+ showing = true
58
+ }
59
+ },
60
+
61
+ hide () {
62
+ if (!showing) {
63
+ return
64
+ }
65
+ source.clear()
66
+ showing = false
67
+ },
68
+
69
+ updateColors (newColors) {
70
+ styles = makeStyles(newColors)
71
+ if (showing) {
72
+ source.changed()
73
+ }
74
+ },
75
+
76
+ remove () {
77
+ source.clear()
78
+ map.removeLayer(layer)
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Custom OL Interaction that intercepts pointer events and rewrites
3
+ * mapBrowserEvent.coordinate to the nearest snap candidate before any draw
4
+ * or modify interaction sees it.
5
+ *
6
+ * Coordinate snapping applies to all pointer events (pointermove, pointerdown,
7
+ * pointerup, singleclick) so that both rubberbanding and vertex placement are snapped.
8
+ * The visual indicator is only updated on pointermove.
9
+ *
10
+ * Must be added to the map AFTER the Draw/Modify interaction so it is processed
11
+ * first (OL iterates interactions in reverse-add order).
12
+ * snapManager.reattach() handles this after each mode change.
13
+ */
14
+
15
+ import Interaction from 'ol/interaction/Interaction.js'
16
+
17
+ const SNAP_EVENTS = new Set(['pointermove', 'pointerdrag', 'pointerdown', 'pointerup', 'singleclick', 'click'])
18
+
19
+ export const createSnapInteraction = (engine, indicator, snapRadius) => {
20
+ const interaction = new Interaction({
21
+ handleEvent (mapBrowserEvent) {
22
+ if (!interaction.getActive()) {
23
+ return true
24
+ }
25
+
26
+ const { type } = mapBrowserEvent
27
+
28
+ if (type === 'pointerout' || type === 'pointerleave') {
29
+ indicator.hide()
30
+ return true
31
+ }
32
+
33
+ if (!SNAP_EVENTS.has(type)) {
34
+ return true
35
+ }
36
+
37
+ const result = engine.query(mapBrowserEvent.coordinate, snapRadius)
38
+ if (result) {
39
+ mapBrowserEvent.coordinate = result.coord.slice()
40
+ }
41
+
42
+ // Only show indicator during free mouse movement — hide during drag and clicks
43
+ if (type === 'pointermove') {
44
+ result ? indicator.show(result.coord, result.type) : indicator.hide()
45
+ } else if (type === 'pointerdrag') {
46
+ indicator.hide()
47
+ } else {
48
+ // No action
49
+ }
50
+
51
+ return true
52
+ }
53
+ })
54
+
55
+ return interaction
56
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Snap manager — orchestrates the snap engine, indicator, and OL interaction.
3
+ *
4
+ * Returned as manager.snap; null when no snapLayers are configured.
5
+ *
6
+ * Interface used by draw and edit modes:
7
+ * snap.apply(coord) — query + show/hide indicator; returns snapped coord or original
8
+ * snap.hideIndicator() — explicit hide (e.g. on touch/keyboard commit)
9
+ * snap.setActive(bool) — enable / disable (wired to the UI snap toggle)
10
+ * snap.reattach() — re-add the OL interaction after a mode change so it stays
11
+ * last-added (= first to process pointermove events)
12
+ * snap.destroy() — full cleanup
13
+ */
14
+
15
+ import { createSnapEngine } from './snapEngine.js'
16
+ import { createSnapIndicator } from './snapIndicator.js'
17
+ import { createSnapInteraction } from './snapInteraction.js'
18
+
19
+ export const createSnapManager = (map, snapLayers, colors, snapRadius) => {
20
+ if (!snapLayers?.length) {
21
+ return null
22
+ }
23
+
24
+ const engine = createSnapEngine(map, snapLayers)
25
+ const indicator = createSnapIndicator(map, colors)
26
+ const interaction = createSnapInteraction(engine, indicator, snapRadius)
27
+
28
+ map.addInteraction(interaction)
29
+ interaction.setActive(false) // matches reducer initial state: snap: false
30
+
31
+ let active = false
32
+
33
+ return {
34
+ /**
35
+ * Apply snap at coord. Updates the indicator and returns the snapped
36
+ * coordinate, or the original coordinate when no snap candidate is found.
37
+ * Returns original coord unchanged when snap is disabled.
38
+ */
39
+ snapRadius,
40
+
41
+ apply (coord) {
42
+ if (!active) {
43
+ return coord
44
+ }
45
+ const result = engine.query(coord, snapRadius)
46
+ if (result) {
47
+ indicator.show(result.coord, result.type)
48
+ return result.coord
49
+ }
50
+ indicator.hide()
51
+ return coord
52
+ },
53
+
54
+ hideIndicator () {
55
+ indicator.hide()
56
+ },
57
+
58
+ setActive (value) {
59
+ active = value
60
+ interaction.setActive(value)
61
+ if (!value) {
62
+ indicator.hide()
63
+ }
64
+ },
65
+
66
+ /**
67
+ * Remove and re-add the OL interaction so it sits at the top of the
68
+ * interaction stack (last-added = first to handle pointermove).
69
+ * Call after each changeMode() so the interaction always runs before
70
+ * the newly added Draw or Modify interaction.
71
+ */
72
+ setSnapLayers (layers) {
73
+ engine.setLayers(layers === null || layers === undefined ? (snapLayers ?? []) : layers)
74
+ },
75
+
76
+ reattach () {
77
+ map.removeInteraction(interaction)
78
+ map.addInteraction(interaction)
79
+ },
80
+
81
+ updateColors (newColors) {
82
+ indicator.updateColors(newColors)
83
+ },
84
+
85
+ destroy () {
86
+ map.removeInteraction(interaction)
87
+ indicator.remove()
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,47 @@
1
+ const STYLE_PROPS = ['stroke', 'fill', 'strokeWidth']
2
+
3
+ /**
4
+ * Flatten style properties that may be strings or variant objects
5
+ * keyed by style ID into flat GeoJSON-compatible properties.
6
+ *
7
+ * @param {object} props - Object containing style properties
8
+ * @returns {object} Flattened properties
9
+ *
10
+ * @example
11
+ * flattenStyleProperties({
12
+ * stroke: { outdoor: '#e6c700', dark: '#ffd700' },
13
+ * fill: 'rgba(255, 221, 0, 0.1)',
14
+ * strokeWidth: 3
15
+ * })
16
+ * // Returns:
17
+ * // {
18
+ * // stroke: '#e6c700',
19
+ * // strokeOutdoor: '#e6c700',
20
+ * // strokeDark: '#ffd700',
21
+ * // fill: 'rgba(255, 221, 0, 0.1)',
22
+ * // strokeWidth: 3
23
+ * // }
24
+ */
25
+ export const flattenStyleProperties = (props) => {
26
+ if (!props) {
27
+ return {}
28
+ }
29
+
30
+ const result = {}
31
+
32
+ for (const [key, value] of Object.entries(props)) {
33
+ if (STYLE_PROPS.includes(key) && typeof value === 'object' && value !== null) {
34
+ const entries = Object.entries(value)
35
+ if (entries.length > 0) {
36
+ result[key] = entries[0][1]
37
+ }
38
+ for (const [styleId, styleValue] of entries) {
39
+ result[`${key}${styleId.charAt(0).toUpperCase() + styleId.slice(1)}`] = styleValue
40
+ }
41
+ } else {
42
+ result[key] = value
43
+ }
44
+ }
45
+
46
+ return result
47
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Pure geometry helpers for multi-ring/multi-part geometry support.
3
+ * Handles coordinate transformations between flat arrays and hierarchical GeoJSON.
4
+ * Supports Polygon, MultiPolygon, LineString, MultiLineString.
5
+ */
6
+
7
+ export const getCoords = (geom) => {
8
+ if (!geom?.coordinates) return []
9
+ switch (geom.type) {
10
+ case 'LineString': return geom.coordinates
11
+ case 'Polygon': return geom.coordinates.flatMap(ring => ring.slice(0, -1))
12
+ case 'MultiLineString': return geom.coordinates.flat(1)
13
+ case 'MultiPolygon': return geom.coordinates.flatMap(poly => poly.flatMap(ring => ring.slice(0, -1)))
14
+ default: return []
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Segment metadata for each ring or part.
20
+ * { start, length, path, closed }
21
+ */
22
+ export const getRingSegments = (geom) => {
23
+ if (!geom?.coordinates) return []
24
+ const segments = []
25
+ let start = 0
26
+
27
+ switch (geom.type) {
28
+ case 'LineString':
29
+ segments.push({ start: 0, length: geom.coordinates.length, path: [], closed: false })
30
+ break
31
+ case 'Polygon':
32
+ geom.coordinates.forEach((ring, i) => {
33
+ const len = ring.length - 1
34
+ segments.push({ start, length: len, path: [i], closed: true })
35
+ start += len
36
+ })
37
+ break
38
+ case 'MultiLineString':
39
+ geom.coordinates.forEach((line, i) => {
40
+ segments.push({ start, length: line.length, path: [i], closed: false })
41
+ start += line.length
42
+ })
43
+ break
44
+ case 'MultiPolygon':
45
+ geom.coordinates.forEach((polygon, pi) => {
46
+ polygon.forEach((ring, ri) => {
47
+ const len = ring.length - 1
48
+ segments.push({ start, length: len, path: [pi, ri], closed: true })
49
+ start += len
50
+ })
51
+ })
52
+ break
53
+ default:
54
+ break
55
+ }
56
+
57
+ return segments
58
+ }
59
+
60
+ /** Find which segment a flat vertex index belongs to. */
61
+ export const getSegmentForIndex = (segments, flatIdx) => {
62
+ for (const seg of segments) {
63
+ if (flatIdx >= seg.start && flatIdx < seg.start + seg.length) {
64
+ return { segment: seg, localIdx: flatIdx - seg.start }
65
+ }
66
+ }
67
+ return null
68
+ }
69
+
70
+ /** Return a reference to the coordinate array at a hierarchical path. */
71
+ export const getModifiableCoords = (geojsonGeometry, path) => {
72
+ let coords = geojsonGeometry.coordinates
73
+ for (const idx of path) {
74
+ coords = coords[idx]
75
+ }
76
+ return coords
77
+ }
78
+
79
+ /** Compute midpoints for all segments, respecting open/closed ring boundaries. */
80
+ export const getMidpoints = (geom) => {
81
+ const coords = getCoords(geom)
82
+ const segments = getRingSegments(geom)
83
+ if (!coords.length || !segments.length) return []
84
+
85
+ const midpoints = []
86
+ for (const seg of segments) {
87
+ const count = seg.closed ? seg.length : seg.length - 1
88
+ for (let i = 0; i < count; i++) {
89
+ const idx = seg.start + i
90
+ const nextIdx = seg.start + ((i + 1) % seg.length)
91
+ const [x1, y1] = coords[idx]
92
+ const [x2, y2] = coords[nextIdx]
93
+ midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2])
94
+ }
95
+ }
96
+ return midpoints
97
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Thin helpers bridging OL's array-based pixel/coordinate API
3
+ * and the {x, y} object convention used in touch/keyboard handlers.
4
+ */
5
+
6
+ /** OL coordinate [e, n] → screen pixel { x, y } */
7
+ export const coordToPixel = (map, coord) => {
8
+ const px = map.getPixelFromCoordinate(coord)
9
+ if (!px) return null
10
+ return { x: px[0], y: px[1] }
11
+ }
12
+
13
+ /** Screen pixel { x, y } → OL coordinate [e, n] */
14
+ export const pixelToCoord = (map, pixel) => {
15
+ return map.getCoordinateFromPixel([pixel.x, pixel.y])
16
+ }
17
+
18
+ /** Pixel distance between two { x, y } points */
19
+ export const pixelDist = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
20
+
21
+ /** OL pixel array [x, y] → { x, y } */
22
+ export const arrayToPixel = ([x, y]) => ({ x, y })
23
+
24
+ /** { x, y } → OL pixel array [x, y] */
25
+ export const pixelToArray = ({ x, y }) => [x, y]
26
+
27
+ /**
28
+ * Nudge a coordinate by (dx, dy) screen pixels.
29
+ * Converts pixel offset to map coordinate delta using the current resolution.
30
+ */
31
+ export const nudgeCoord = (map, coord, dx, dy) => {
32
+ const px = map.getPixelFromCoordinate(coord)
33
+ if (!px) return coord
34
+ return map.getCoordinateFromPixel([px[0] + dx, px[1] + dy])
35
+ }
@@ -0,0 +1,39 @@
1
+ import { DEFAULTS } from '../defaults.js'
2
+
3
+ const resolveVariant = (value, scheme, styleId) => {
4
+ if (typeof value !== 'object' || value === null) { return value }
5
+ if (styleId && value[styleId] !== undefined) { return value[styleId] }
6
+ if (value[scheme] !== undefined) { return value[scheme] }
7
+ if (value.light !== undefined) { return value.light }
8
+ return Object.values(value)[0]
9
+ }
10
+
11
+ /**
12
+ * Resolve all draw-ol colors for the given map style and plugin config overrides.
13
+ *
14
+ * Values in pluginConfig may be plain strings or variant objects (e.g. { light: '...', dark: '...' }).
15
+ * Variant resolution order: exact style ID match → color scheme → 'light' fallback → first value.
16
+ *
17
+ * @param {object|null} mapStyle - Current map style object (has .id and .mapColorScheme)
18
+ * @param {object} pluginConfig - Plugin-level user overrides (may override any DEFAULTS key)
19
+ * @returns {object} Flat color values ready for use in createStyles()
20
+ */
21
+ export const resolveColors = (mapStyle, pluginConfig = {}) => {
22
+ const scheme = mapStyle?.mapColorScheme ?? 'light'
23
+ const styleId = mapStyle?.id ?? null
24
+ const r = (key) => resolveVariant(pluginConfig[key] ?? DEFAULTS[key], scheme, styleId)
25
+
26
+ return {
27
+ editStroke: r('editStroke'),
28
+ editVertex: r('editVertex'),
29
+ editMidpoint: r('editMidpoint'),
30
+ editActive: r('editActive'),
31
+ editHalo: r('editHalo'),
32
+ shapeStroke: r('shapeStroke'),
33
+ strokeWidth: pluginConfig.strokeWidth ?? DEFAULTS.strokeWidth,
34
+ shapeFill: r('shapeFill'),
35
+ snapVertex: r('snapVertex'),
36
+ snapEdge: r('snapEdge'),
37
+ mapStyleId: styleId
38
+ }
39
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Navigate spatially from a start pixel toward a direction quadrant.
3
+ * Returns the index of the nearest pixel in that direction.
4
+ *
5
+ * @param {[number, number]} start - Current pixel [x, y]
6
+ * @param {Array<[number, number]>} pixels - All candidate pixels
7
+ * @param {string} direction - ArrowUp | ArrowDown | ArrowLeft | ArrowRight | undefined (nearest)
8
+ * @returns {number} Index into pixels array
9
+ */
10
+ export const spatialNavigate = (start, pixels, direction) => {
11
+ const quadrant = pixels.filter((p) => {
12
+ const dx = Math.abs(p[0] - start[0])
13
+ const dy = Math.abs(p[1] - start[1])
14
+ let inQuadrant = false
15
+ if (direction === 'ArrowUp') inQuadrant = p[1] <= start[1] && dy >= dx
16
+ else if (direction === 'ArrowDown') inQuadrant = p[1] > start[1] && dy >= dx
17
+ else if (direction === 'ArrowLeft') inQuadrant = p[0] <= start[0] && dy < dx
18
+ else if (direction === 'ArrowRight') inQuadrant = p[0] > start[0] && dy < dx
19
+ else inQuadrant = true
20
+ return inQuadrant && JSON.stringify(p) !== JSON.stringify(start)
21
+ })
22
+
23
+ if (!quadrant.length) quadrant.push(start)
24
+
25
+ const dist = (p) => Math.sqrt((start[0] - p[0]) ** 2 + (start[1] - p[1]) ** 2)
26
+ const closest = quadrant.reduce((best, p) => dist(p) < dist(best) ? p : best)
27
+ return pixels.findIndex(p => JSON.stringify(p) === JSON.stringify(closest))
28
+ }