@defra/interactive-map 0.0.25-alpha → 0.0.27-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.
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/map-style-config.md +30 -0
- package/docs/assets/css/docusaurus.scss +4 -0
- package/package.json +1 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/src/defaults.js +14 -14
- package/plugins/beta/draw-ml/src/mapboxDraw.js +5 -0
- package/plugins/beta/draw-ml/src/mapboxSnap.js +1 -1
- package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +10 -2
- package/plugins/beta/draw-ml/src/modes/editVertexMode.js +2 -0
- package/plugins/beta/draw-ml/src/reducer.js +4 -1
- package/plugins/beta/draw-ml/src/styles.js +42 -39
- package/plugins/beta/draw-ol/dist/css/index.css +13 -0
- package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/geometryHelpers.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/index.js +1 -0
- package/plugins/beta/draw-ol/src/DrawInit.jsx +67 -0
- package/plugins/beta/draw-ol/src/api/addFeature.js +29 -0
- package/plugins/beta/draw-ol/src/api/deleteFeature.js +11 -0
- package/plugins/beta/draw-ol/src/api/editFeature.js +50 -0
- package/plugins/beta/draw-ol/src/api/newLine.js +44 -0
- package/plugins/beta/draw-ol/src/api/newPolygon.js +44 -0
- package/plugins/beta/draw-ol/src/core/OLDrawManager.js +150 -0
- package/plugins/beta/draw-ol/src/core/featureStore.js +65 -0
- package/plugins/beta/draw-ol/src/core/styles.js +98 -0
- package/plugins/beta/draw-ol/src/core/undoStack.js +31 -0
- package/plugins/beta/draw-ol/src/defaults.js +16 -0
- package/plugins/beta/draw-ol/src/draw/DrawMode.js +113 -0
- package/plugins/beta/draw-ol/src/draw/drawInput.js +199 -0
- package/plugins/beta/draw-ol/src/draw.scss +17 -0
- package/plugins/beta/draw-ol/src/edit/EditMode.js +424 -0
- package/plugins/beta/draw-ol/src/edit/keyboardHandler.js +227 -0
- package/plugins/beta/draw-ol/src/edit/midpointLayer.js +57 -0
- package/plugins/beta/draw-ol/src/edit/touchHandler.js +171 -0
- package/plugins/beta/draw-ol/src/edit/undoOps.js +95 -0
- package/plugins/beta/draw-ol/src/edit/vertexHitTest.js +72 -0
- package/plugins/beta/draw-ol/src/edit/vertexLayer.js +49 -0
- package/plugins/beta/draw-ol/src/edit/vertexOps.js +99 -0
- package/plugins/beta/draw-ol/src/events.js +114 -0
- package/plugins/beta/draw-ol/src/index.js +12 -0
- package/plugins/beta/draw-ol/src/manifest.js +121 -0
- package/plugins/beta/draw-ol/src/olDraw.js +38 -0
- package/plugins/beta/draw-ol/src/reducer.js +41 -0
- package/plugins/beta/draw-ol/src/snap/snapEngine.js +109 -0
- package/plugins/beta/draw-ol/src/snap/snapGeometry.js +200 -0
- package/plugins/beta/draw-ol/src/snap/snapIndicator.js +81 -0
- package/plugins/beta/draw-ol/src/snap/snapInteraction.js +56 -0
- package/plugins/beta/draw-ol/src/snap/snapManager.js +90 -0
- package/plugins/beta/draw-ol/src/utils/flattenStyleProperties.js +47 -0
- package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +97 -0
- package/plugins/beta/draw-ol/src/utils/olCoords.js +35 -0
- package/plugins/beta/draw-ol/src/utils/resolveColors.js +39 -0
- package/plugins/beta/draw-ol/src/utils/spatial.js +28 -0
- package/plugins/beta/draw-ol/src/utils/touchTarget.js +61 -0
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/src/defaults.js +1 -0
- package/plugins/interact/src/hooks/useInteractionHandlers.js +150 -43
- package/plugins/interact/src/hooks/useInteractionHandlers.test.js +240 -8
- package/plugins/interact/src/reducer.js +7 -0
- package/plugins/interact/src/reducer.test.js +16 -0
- package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/index.js +1 -1
- package/providers/beta/openlayers/src/appEvents.js +9 -13
- package/providers/beta/openlayers/src/appEvents.test.js +27 -18
- package/providers/beta/openlayers/src/mapEvents.js +34 -20
- package/providers/beta/openlayers/src/mapEvents.test.js +65 -6
- package/providers/beta/openlayers/src/openlayersProvider.js +45 -26
- package/providers/beta/openlayers/src/openlayersProvider.test.js +12 -34
- package/providers/beta/openlayers/src/utils/highlightFeatures.js +245 -0
- package/providers/beta/openlayers/src/utils/hoverCursor.js +65 -0
- package/providers/beta/openlayers/src/utils/hoverCursor.test.js +235 -0
- package/providers/beta/openlayers/src/utils/openLayersFixes.js +11 -0
- package/providers/beta/openlayers/src/utils/openLayersFixes.test.js +53 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.js +77 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.test.js +181 -0
- package/providers/beta/openlayers/src/utils/tileLayers.js +23 -5
- package/providers/beta/openlayers/src/utils/tileLayers.test.js +81 -5
- package/providers/beta/openlayers/src/utils/vtTileFragments.js +86 -0
- package/rollup.esm.mjs +6 -0
- package/src/App/controls/keyboardActions.js +2 -2
- package/src/InteractiveMap/InteractiveMap.js +38 -10
- package/src/InteractiveMap/InteractiveMap.test.js +282 -356
- package/src/InteractiveMap/historyManager.js +7 -0
- package/src/InteractiveMap/historyManager.test.js +11 -0
- 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
|
+
}
|