@rural-environmental-registry/map_component 1.0.0 → 1.1.0
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/README.md +444 -0
- package/dist/constants/coordinatePanel.d.ts +1 -1
- package/dist/handlers/drawingControl.d.ts +0 -1
- package/dist/handlers/mapHandler.d.ts +8 -3
- package/dist/handlers/mapToolsHandler.d.ts +39 -0
- package/dist/handlers/measureToolsHandler.d.ts +51 -0
- package/dist/handlers/memorialButtonControl.d.ts +19 -0
- package/dist/handlers/toolsConstants.d.ts +3 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.mjs +25452 -18225
- package/dist/types/index.d.ts +68 -0
- package/dist/utils/areaCalculator.d.ts +3 -0
- package/dist/utils/drawingPathOptions.d.ts +8 -0
- package/dist/utils/geometryCalculator.d.ts +13 -0
- package/dist/utils/memorialLayer.d.ts +3 -0
- package/dist/utils/menuHistory.d.ts +8 -0
- package/dist/utils/parseShapefileZip.d.ts +9 -0
- package/dist/utils/stableMarker.d.ts +15 -0
- package/dist/utils/validateShapefileGeometry.d.ts +9 -0
- package/package.json +21 -4
- package/src/assets/icons/file-import-regular-full-gray.svg +1 -0
- package/src/assets/icons/file-lines-regular-full-gray.svg +1 -0
- package/src/assets/icons/layer-icon.svg +3 -0
- package/src/assets/icons/loading.gif +0 -0
- package/src/assets/icons/marker-icon.svg +1 -0
- package/src/assets/icons/measure-icon.svg +5 -0
- package/src/assets/layers/mapLayers.json +16 -0
- package/src/assets/main.css +187 -0
- package/src/assets/styles/element-plus.css +25 -0
- package/src/assets/styles/layers-menu.css +95 -0
- package/src/assets/styles/leaflet.css +361 -0
- package/src/components/MapDPG.vue +302 -0
- package/src/components/coordinate/CoordinatePanel.vue +1183 -0
- package/src/components/fa-icon/FontAwesomeIcon.vue +44 -0
- package/src/components/loading/Loading.vue +25 -0
- package/src/components/map/LeafletMap.vue +183 -0
- package/src/components/menu/ChildMenu.vue +112 -0
- package/src/components/menu/LayerMenu.vue +294 -0
- package/src/components/menu/ParentMenu.vue +132 -0
- package/src/constants/coordinatePanel.ts +67 -0
- package/src/handlers/constants.ts +35 -0
- package/src/handlers/drawingControl.ts +143 -0
- package/src/handlers/mapHandler.ts +124 -0
- package/src/handlers/mapToolsHandler.ts +281 -0
- package/src/handlers/measureToolsHandler.ts +424 -0
- package/src/handlers/memorialButtonControl.ts +171 -0
- package/src/handlers/toolsConstants.ts +69 -0
- package/src/index.ts +6 -0
- package/src/shims-vue.d.ts +5 -0
- package/src/types/index.ts +259 -0
- package/src/types/json.d.ts +4 -0
- package/src/types/shpjs.d.ts +7 -0
- package/src/types/svg.d.ts +4 -0
- package/src/utils/CoordinateConverter.ts +43 -0
- package/src/utils/areaCalculator.ts +9 -0
- package/src/utils/drawingPathOptions.ts +57 -0
- package/src/utils/geometryCalculator.ts +62 -0
- package/src/utils/memorialLayer.ts +7 -0
- package/src/utils/menuHistory.ts +71 -0
- package/src/utils/parseShapefileZip.ts +56 -0
- package/src/utils/stableMarker.ts +52 -0
- package/src/utils/validateShapefileGeometry.ts +54 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import L, { FeatureGroup, LatLngBounds, Map } from 'leaflet'
|
|
2
|
+
|
|
3
|
+
import MeasureToolsHandler from './measureToolsHandler'
|
|
4
|
+
import { resolveMapToolsConfig } from './toolsConstants'
|
|
5
|
+
import { MapToolsConfig, MeasureCompleteEvent } from '../types'
|
|
6
|
+
|
|
7
|
+
type MapToolsCallbacks = {
|
|
8
|
+
onFullscreenChange?: (active: boolean) => void
|
|
9
|
+
onMeasureComplete?: (data: MeasureCompleteEvent) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type InitialView = {
|
|
13
|
+
center: L.LatLngExpression
|
|
14
|
+
zoom: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const FULLSCREEN_ICON =
|
|
18
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>'
|
|
19
|
+
|
|
20
|
+
const EXIT_FULLSCREEN_ICON =
|
|
21
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/></svg>'
|
|
22
|
+
|
|
23
|
+
const CENTER_ICON =
|
|
24
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/></svg>'
|
|
25
|
+
|
|
26
|
+
export default class MapToolsHandler {
|
|
27
|
+
private readonly _map: Map
|
|
28
|
+
private readonly _config: MapToolsConfig
|
|
29
|
+
private readonly _initialView: InitialView
|
|
30
|
+
private readonly _callbacks: MapToolsCallbacks
|
|
31
|
+
private readonly _fullscreenContainer: HTMLElement
|
|
32
|
+
private _drawItemsGroup: FeatureGroup | null
|
|
33
|
+
private _control: L.Control | null = null
|
|
34
|
+
private _measureTools: MeasureToolsHandler | null = null
|
|
35
|
+
private _fullscreenBtn: HTMLButtonElement | null = null
|
|
36
|
+
private _boundFullscreenChange: () => void
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
map: Map,
|
|
40
|
+
toolsConfig: MapToolsConfig | undefined,
|
|
41
|
+
initialView: InitialView,
|
|
42
|
+
drawItemsGroup: FeatureGroup | null,
|
|
43
|
+
fullscreenContainer: HTMLElement,
|
|
44
|
+
callbacks: MapToolsCallbacks = {}
|
|
45
|
+
) {
|
|
46
|
+
const resolved = resolveMapToolsConfig(toolsConfig)
|
|
47
|
+
if (!resolved) {
|
|
48
|
+
throw new Error('MapToolsHandler requires tools.show to be true')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this._map = map
|
|
52
|
+
this._config = resolved
|
|
53
|
+
this._initialView = initialView
|
|
54
|
+
this._drawItemsGroup = drawItemsGroup
|
|
55
|
+
this._callbacks = callbacks
|
|
56
|
+
this._fullscreenContainer = fullscreenContainer
|
|
57
|
+
|
|
58
|
+
this._boundFullscreenChange = () => this.handleFullscreenChange()
|
|
59
|
+
|
|
60
|
+
this.addControl()
|
|
61
|
+
|
|
62
|
+
if (this._config.measureArea?.show !== false) {
|
|
63
|
+
this._measureTools = new MeasureToolsHandler(this._map, this._config, {
|
|
64
|
+
onMeasureComplete: (data) => this._callbacks.onMeasureComplete?.(data)
|
|
65
|
+
})
|
|
66
|
+
this.alignTopRightControls()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.bindFullscreenEvents()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public setDrawItemsGroup(group: FeatureGroup | null): void {
|
|
73
|
+
this._drawItemsGroup = group
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public centerMap(): void {
|
|
77
|
+
const padding = this._config.center?.padding ?? [20, 20]
|
|
78
|
+
const target = this._config.center?.target ?? 'drawn'
|
|
79
|
+
|
|
80
|
+
if (target === 'drawn' && this._drawItemsGroup && this._drawItemsGroup.getLayers().length > 0) {
|
|
81
|
+
const bounds = this._drawItemsGroup.getBounds()
|
|
82
|
+
if (bounds.isValid()) {
|
|
83
|
+
this._map.fitBounds(bounds, { padding })
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this._map.setView(this._initialView.center, this._initialView.zoom)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public enterFullscreen(): void {
|
|
92
|
+
if (!document.fullscreenElement) {
|
|
93
|
+
void this._fullscreenContainer.requestFullscreen?.()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public exitFullscreen(): void {
|
|
98
|
+
if (document.fullscreenElement) {
|
|
99
|
+
void document.exitFullscreen?.()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public toggleFullscreen(): void {
|
|
104
|
+
if (document.fullscreenElement) {
|
|
105
|
+
this.exitFullscreen()
|
|
106
|
+
} else {
|
|
107
|
+
this.enterFullscreen()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public toggleMeasureArea(): void {
|
|
112
|
+
if (this._measureTools?.isActive()) {
|
|
113
|
+
this._measureTools.cancel()
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this._measureTools?.startLine()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public destroy(): void {
|
|
121
|
+
document.removeEventListener('fullscreenchange', this._boundFullscreenChange)
|
|
122
|
+
|
|
123
|
+
this._measureTools?.destroy()
|
|
124
|
+
this._measureTools = null
|
|
125
|
+
|
|
126
|
+
if (this._control) {
|
|
127
|
+
this._map.removeControl(this._control)
|
|
128
|
+
this._control = null
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private addControl(): void {
|
|
133
|
+
const position = this._config.position ?? 'topright'
|
|
134
|
+
const config = this._config
|
|
135
|
+
const handler = this
|
|
136
|
+
|
|
137
|
+
const MapToolsControl = L.Control.extend({
|
|
138
|
+
options: { position },
|
|
139
|
+
onAdd() {
|
|
140
|
+
const container = L.DomUtil.create('div', 'leaflet-control leaflet-control-map-tools')
|
|
141
|
+
|
|
142
|
+
if (config.zoom?.show !== false) {
|
|
143
|
+
handler.createButton(
|
|
144
|
+
container,
|
|
145
|
+
'map-tools-btn map-tools-btn--zoom-in',
|
|
146
|
+
config.zoom?.titleIn ?? 'Zoom in',
|
|
147
|
+
'+',
|
|
148
|
+
(e) => {
|
|
149
|
+
L.DomEvent.stop(e)
|
|
150
|
+
handler._map.zoomIn()
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
handler.createButton(
|
|
155
|
+
container,
|
|
156
|
+
'map-tools-btn map-tools-btn--zoom-out',
|
|
157
|
+
config.zoom?.titleOut ?? 'Zoom out',
|
|
158
|
+
'−',
|
|
159
|
+
(e) => {
|
|
160
|
+
L.DomEvent.stop(e)
|
|
161
|
+
handler._map.zoomOut()
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (config.center?.show !== false) {
|
|
167
|
+
handler.createButton(
|
|
168
|
+
container,
|
|
169
|
+
'map-tools-btn map-tools-btn--center',
|
|
170
|
+
config.center?.title ?? 'Center map',
|
|
171
|
+
CENTER_ICON,
|
|
172
|
+
(e) => {
|
|
173
|
+
L.DomEvent.stop(e)
|
|
174
|
+
handler.centerMap()
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (config.fullscreen?.show !== false) {
|
|
180
|
+
handler._fullscreenBtn = handler.createButton(
|
|
181
|
+
container,
|
|
182
|
+
'map-tools-btn map-tools-btn--fullscreen',
|
|
183
|
+
config.fullscreen?.title ?? 'Fullscreen',
|
|
184
|
+
FULLSCREEN_ICON,
|
|
185
|
+
(e) => {
|
|
186
|
+
L.DomEvent.stop(e)
|
|
187
|
+
handler.toggleFullscreen()
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
L.DomEvent.disableClickPropagation(container)
|
|
193
|
+
L.DomEvent.disableScrollPropagation(container)
|
|
194
|
+
|
|
195
|
+
return container
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
this._control = new MapToolsControl()
|
|
200
|
+
this._map.addControl(this._control)
|
|
201
|
+
this.removeNativeZoomControl()
|
|
202
|
+
this.alignTopRightControls()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private removeNativeZoomControl(): void {
|
|
206
|
+
const zoomControl = this._map.getContainer().querySelector('.leaflet-control-zoom')
|
|
207
|
+
if (!zoomControl) return
|
|
208
|
+
|
|
209
|
+
const control = this._map.zoomControl
|
|
210
|
+
if (control) {
|
|
211
|
+
this._map.removeControl(control)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
public alignTopRightControls(): void {
|
|
216
|
+
const corner = this._map.getContainer().querySelector('.leaflet-top.leaflet-right')
|
|
217
|
+
if (!corner) return
|
|
218
|
+
|
|
219
|
+
const prioritySelectors = [
|
|
220
|
+
'.leaflet-control-map-tools',
|
|
221
|
+
'.leaflet-control-layers',
|
|
222
|
+
'.leaflet-control-measure-tools',
|
|
223
|
+
'.leaflet-control-measure'
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
const controls = Array.from(corner.querySelectorAll(':scope > .leaflet-control'))
|
|
227
|
+
const sorted = [
|
|
228
|
+
...prioritySelectors.flatMap((selector) => controls.filter((control) => control.matches(selector))),
|
|
229
|
+
...controls.filter((control) => !prioritySelectors.some((selector) => control.matches(selector)))
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
sorted.forEach((control) => corner.appendChild(control))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private createButton(
|
|
236
|
+
container: HTMLElement,
|
|
237
|
+
className: string,
|
|
238
|
+
title: string,
|
|
239
|
+
iconHtml: string,
|
|
240
|
+
onClick: (e: Event) => void
|
|
241
|
+
): HTMLButtonElement {
|
|
242
|
+
const btn = L.DomUtil.create('button', className, container) as HTMLButtonElement
|
|
243
|
+
btn.type = 'button'
|
|
244
|
+
btn.title = title
|
|
245
|
+
btn.innerHTML = iconHtml
|
|
246
|
+
|
|
247
|
+
L.DomEvent.on(btn, 'click', onClick)
|
|
248
|
+
|
|
249
|
+
return btn
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private bindFullscreenEvents(): void {
|
|
253
|
+
document.addEventListener('fullscreenchange', this._boundFullscreenChange)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private handleFullscreenChange(): void {
|
|
257
|
+
const isActive = document.fullscreenElement === this._fullscreenContainer
|
|
258
|
+
this._fullscreenContainer.classList.toggle('map-container--fullscreen', isActive)
|
|
259
|
+
|
|
260
|
+
if (this._fullscreenBtn) {
|
|
261
|
+
this._fullscreenBtn.innerHTML = isActive ? EXIT_FULLSCREEN_ICON : FULLSCREEN_ICON
|
|
262
|
+
this._fullscreenBtn.title = isActive ? 'Exit fullscreen' : (this._config.fullscreen?.title ?? 'Fullscreen')
|
|
263
|
+
this._fullscreenBtn.classList.toggle('map-tools-btn--active', isActive)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
setTimeout(() => {
|
|
267
|
+
this._map.invalidateSize()
|
|
268
|
+
}, 100)
|
|
269
|
+
this._callbacks.onFullscreenChange?.(isActive)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function isMeasureModeActive(map: Map): boolean {
|
|
274
|
+
return !!(map as Map & { _leafletMeasureActive?: boolean })._leafletMeasureActive
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function getDrawnBounds(drawItemsGroup: FeatureGroup | null): LatLngBounds | null {
|
|
278
|
+
if (!drawItemsGroup || drawItemsGroup.getLayers().length === 0) return null
|
|
279
|
+
const bounds = drawItemsGroup.getBounds()
|
|
280
|
+
return bounds.isValid() ? bounds : null
|
|
281
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import L, { FeatureGroup, Layer, Map, Path } from 'leaflet'
|
|
2
|
+
import '@geoman-io/leaflet-geoman-free'
|
|
3
|
+
|
|
4
|
+
import { MapToolsConfig, MeasureCompleteEvent, GeomanDrawingEvent } from '../types'
|
|
5
|
+
import {
|
|
6
|
+
calculateLayerArea,
|
|
7
|
+
calculateLayerLength,
|
|
8
|
+
formatAreaHa,
|
|
9
|
+
formatLengthKm
|
|
10
|
+
} from '../utils/geometryCalculator'
|
|
11
|
+
|
|
12
|
+
type MeasureToolsCallbacks = {
|
|
13
|
+
onMeasureComplete?: (data: MeasureCompleteEvent) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type MeasureMode = 'line' | 'polygon'
|
|
17
|
+
|
|
18
|
+
const LINE_ICON =
|
|
19
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20L20 4"/><circle cx="6" cy="18" r="1.5" fill="currentColor" stroke="none"/><circle cx="18" cy="6" r="1.5" fill="currentColor" stroke="none"/></svg>'
|
|
20
|
+
|
|
21
|
+
const POLYGON_ICON =
|
|
22
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l8 5v10l-8 5-8-5V7z"/></svg>'
|
|
23
|
+
|
|
24
|
+
export default class MeasureToolsHandler {
|
|
25
|
+
private readonly _map: Map
|
|
26
|
+
private readonly _mapContainer: HTMLElement
|
|
27
|
+
private readonly _outerContainer: HTMLElement | null
|
|
28
|
+
private readonly _config: MapToolsConfig
|
|
29
|
+
private readonly _callbacks: MeasureToolsCallbacks
|
|
30
|
+
private readonly _measureGroup: FeatureGroup
|
|
31
|
+
private readonly _measureSvgRenderer: L.SVG
|
|
32
|
+
private readonly _boundMeasureCreate: (evt: GeomanDrawingEvent) => void
|
|
33
|
+
private readonly _boundMeasureCancel: () => void
|
|
34
|
+
private readonly _boundEscapeKey: (event: KeyboardEvent) => void
|
|
35
|
+
private _control: L.Control | null = null
|
|
36
|
+
private _lineBtn: HTMLButtonElement | null = null
|
|
37
|
+
private _polygonBtn: HTMLButtonElement | null = null
|
|
38
|
+
private _interactionPanel: HTMLElement | null = null
|
|
39
|
+
private _resultsEl: HTMLElement | null = null
|
|
40
|
+
private _helpEl: HTMLElement | null = null
|
|
41
|
+
private _activeMode: MeasureMode | null = null
|
|
42
|
+
private _suppressMeasureCancel = false
|
|
43
|
+
private _restoreZoomAnimation = true
|
|
44
|
+
private _measureCreateTimeoutId: number | null = null
|
|
45
|
+
|
|
46
|
+
constructor(map: Map, config: MapToolsConfig, callbacks: MeasureToolsCallbacks = {}) {
|
|
47
|
+
this._map = map
|
|
48
|
+
this._config = config
|
|
49
|
+
this._callbacks = callbacks
|
|
50
|
+
this._mapContainer = map.getContainer()
|
|
51
|
+
this._outerContainer = this._mapContainer.closest('.map-container')
|
|
52
|
+
this._measureGroup = new FeatureGroup()
|
|
53
|
+
this._measureGroup.addTo(map)
|
|
54
|
+
this._measureSvgRenderer = L.svg({ pane: 'overlayPane' })
|
|
55
|
+
|
|
56
|
+
this._boundMeasureCreate = (evt) => this.handleMeasureCreate(evt)
|
|
57
|
+
this._boundMeasureCancel = () => this.handleMeasureCancel()
|
|
58
|
+
this._boundEscapeKey = (event) => {
|
|
59
|
+
if (event.key === 'Escape' && this.isActive()) {
|
|
60
|
+
this.cancel()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.addControl()
|
|
65
|
+
this._map.on('pm:create', this._boundMeasureCreate)
|
|
66
|
+
this._map.on('pm:drawcancel', this._boundMeasureCancel)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public destroy(): void {
|
|
70
|
+
this.clearMeasureCreateTimeout()
|
|
71
|
+
this.stopMode()
|
|
72
|
+
document.removeEventListener('keydown', this._boundEscapeKey)
|
|
73
|
+
this._map.off('pm:create', this._boundMeasureCreate)
|
|
74
|
+
this._map.off('pm:drawcancel', this._boundMeasureCancel)
|
|
75
|
+
this._measureGroup.clearLayers()
|
|
76
|
+
this._map.removeLayer(this._measureGroup)
|
|
77
|
+
|
|
78
|
+
if (this._control) {
|
|
79
|
+
this._map.removeControl(this._control)
|
|
80
|
+
this._control = null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public cancel(): void {
|
|
85
|
+
if (!this.isActive()) return
|
|
86
|
+
|
|
87
|
+
this._suppressMeasureCancel = true
|
|
88
|
+
this._map.pm.disableDraw()
|
|
89
|
+
this._suppressMeasureCancel = false
|
|
90
|
+
this.stopMode()
|
|
91
|
+
this._measureGroup.clearLayers()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public isActive(): boolean {
|
|
95
|
+
return this._activeMode !== null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public startLine(): void {
|
|
99
|
+
this.startMode('line')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public startPolygon(): void {
|
|
103
|
+
this.startMode('polygon')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private addControl(): void {
|
|
107
|
+
const position = this._config.position ?? 'topright'
|
|
108
|
+
const config = this._config
|
|
109
|
+
const handler = this
|
|
110
|
+
|
|
111
|
+
const MeasureToolsControl = L.Control.extend({
|
|
112
|
+
options: { position },
|
|
113
|
+
onAdd() {
|
|
114
|
+
const container = L.DomUtil.create('div', 'leaflet-control leaflet-control-measure-tools')
|
|
115
|
+
|
|
116
|
+
const buttons = L.DomUtil.create('div', 'measure-tools-buttons', container)
|
|
117
|
+
|
|
118
|
+
if (config.measureLine?.show !== false) {
|
|
119
|
+
handler._lineBtn = handler.createToolButton(
|
|
120
|
+
buttons,
|
|
121
|
+
'measure-tools-btn measure-tools-btn--line',
|
|
122
|
+
config.measureLine?.title ?? config.texts?.measureLineTitle ?? 'Measure line',
|
|
123
|
+
LINE_ICON,
|
|
124
|
+
() => handler.toggleMode('line')
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (config.measurePolygon?.show !== false) {
|
|
129
|
+
handler._polygonBtn = handler.createToolButton(
|
|
130
|
+
buttons,
|
|
131
|
+
'measure-tools-btn measure-tools-btn--polygon',
|
|
132
|
+
config.measurePolygon?.title ?? config.texts?.measurePolygonTitle ?? 'Measure polygon',
|
|
133
|
+
POLYGON_ICON,
|
|
134
|
+
() => handler.toggleMode('polygon')
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
handler._interactionPanel = L.DomUtil.create('div', 'measure-tools-interaction js-measure-interaction', container)
|
|
139
|
+
handler._interactionPanel.style.display = 'none'
|
|
140
|
+
handler._interactionPanel.innerHTML = handler.buildInteractionHtml(config)
|
|
141
|
+
|
|
142
|
+
handler._resultsEl = handler._interactionPanel.querySelector('.js-results')
|
|
143
|
+
handler._helpEl = handler._interactionPanel.querySelector('.js-measure-help')
|
|
144
|
+
|
|
145
|
+
const cancelBtn = handler._interactionPanel.querySelector('.js-cancel')
|
|
146
|
+
const finishBtn = handler._interactionPanel.querySelector('.js-finish')
|
|
147
|
+
|
|
148
|
+
if (cancelBtn instanceof HTMLElement) {
|
|
149
|
+
L.DomEvent.on(cancelBtn, 'click', (e) => {
|
|
150
|
+
L.DomEvent.stop(e)
|
|
151
|
+
handler.cancel()
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (finishBtn instanceof HTMLElement) {
|
|
156
|
+
L.DomEvent.on(finishBtn, 'click', (e) => {
|
|
157
|
+
L.DomEvent.stop(e)
|
|
158
|
+
handler.finishDrawing()
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
L.DomEvent.disableClickPropagation(container)
|
|
163
|
+
L.DomEvent.disableScrollPropagation(container)
|
|
164
|
+
|
|
165
|
+
return container
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
this._control = new MeasureToolsControl()
|
|
170
|
+
this._control.addTo(this._map)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private buildInteractionHtml(config: MapToolsConfig): string {
|
|
174
|
+
const title = config.texts?.measurePanelTitle ?? 'Measure distances and areas'
|
|
175
|
+
const cancel = config.texts?.measureCancel ?? 'Cancel'
|
|
176
|
+
const finish = config.texts?.measureFinish ?? 'Finish measurement'
|
|
177
|
+
|
|
178
|
+
return `
|
|
179
|
+
<div class="js-measuringprompt">
|
|
180
|
+
<h3>${title}</h3>
|
|
181
|
+
<p class="js-measure-help"></p>
|
|
182
|
+
<div class="js-results results"></div>
|
|
183
|
+
<ul class="js-measuretasks tasks">
|
|
184
|
+
<li><a href="#" class="js-cancel cancel">${cancel}</a></li>
|
|
185
|
+
<li><a href="#" class="js-finish finish">${finish}</a></li>
|
|
186
|
+
</ul>
|
|
187
|
+
</div>
|
|
188
|
+
`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private createToolButton(
|
|
192
|
+
container: HTMLElement,
|
|
193
|
+
className: string,
|
|
194
|
+
title: string,
|
|
195
|
+
iconHtml: string,
|
|
196
|
+
onClick: () => void
|
|
197
|
+
): HTMLButtonElement {
|
|
198
|
+
const btn = L.DomUtil.create('button', className, container) as HTMLButtonElement
|
|
199
|
+
btn.type = 'button'
|
|
200
|
+
btn.title = title
|
|
201
|
+
btn.setAttribute('aria-label', title)
|
|
202
|
+
btn.innerHTML = iconHtml
|
|
203
|
+
L.DomEvent.on(btn, 'click', (e) => {
|
|
204
|
+
L.DomEvent.stop(e)
|
|
205
|
+
onClick()
|
|
206
|
+
})
|
|
207
|
+
return btn
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private toggleMode(mode: MeasureMode): void {
|
|
211
|
+
if (this._activeMode === mode) {
|
|
212
|
+
this.cancel()
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.startMode(mode)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private startMode(mode: MeasureMode): void {
|
|
220
|
+
if (this.isActive()) {
|
|
221
|
+
this._suppressMeasureCancel = true
|
|
222
|
+
this._map.pm.disableDraw()
|
|
223
|
+
this._suppressMeasureCancel = false
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this._measureGroup.clearLayers()
|
|
227
|
+
this._activeMode = mode
|
|
228
|
+
this.setMeasureActive(true)
|
|
229
|
+
this.updateButtons()
|
|
230
|
+
this.showInteractionPanel(mode)
|
|
231
|
+
|
|
232
|
+
if (this._map.doubleClickZoom.enabled()) {
|
|
233
|
+
this._map.doubleClickZoom.disable()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this._restoreZoomAnimation = this._map.options.zoomAnimation !== false
|
|
237
|
+
this._map.options.zoomAnimation = false
|
|
238
|
+
|
|
239
|
+
const shapeOptions = this._config.measureArea?.shapeOptions ?? {
|
|
240
|
+
color: '#e67e22',
|
|
241
|
+
fillColor: '#e67e22',
|
|
242
|
+
fillOpacity: 0.2,
|
|
243
|
+
weight: 2
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const drawOptions = {
|
|
247
|
+
snappable: true,
|
|
248
|
+
templineStyle: shapeOptions,
|
|
249
|
+
hintlineStyle: shapeOptions,
|
|
250
|
+
pathOptions: shapeOptions
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (mode === 'line') {
|
|
254
|
+
this._map.pm.enableDraw('Line', {
|
|
255
|
+
...drawOptions,
|
|
256
|
+
finishOn: 'dblclick'
|
|
257
|
+
} as unknown as L.PM.DrawModeOptions)
|
|
258
|
+
} else {
|
|
259
|
+
this._map.pm.enableDraw('Polygon', {
|
|
260
|
+
...drawOptions,
|
|
261
|
+
finishOn: null,
|
|
262
|
+
allowSelfIntersection: true
|
|
263
|
+
} as unknown as L.PM.DrawModeOptions)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private stopMode(): void {
|
|
268
|
+
this._activeMode = null
|
|
269
|
+
this.setMeasureActive(false)
|
|
270
|
+
this._map.pm.disableDraw()
|
|
271
|
+
this._map.options.zoomAnimation = this._restoreZoomAnimation
|
|
272
|
+
this.updateButtons()
|
|
273
|
+
this.hideInteractionPanel()
|
|
274
|
+
|
|
275
|
+
if (!this._map.doubleClickZoom.enabled()) {
|
|
276
|
+
this._map.doubleClickZoom.enable()
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private finishDrawing(): void {
|
|
281
|
+
const draw = (this._map as Map & { pm?: { Draw?: { finishDraw?: () => void } } }).pm?.Draw
|
|
282
|
+
draw?.finishDraw?.()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private handleMeasureCreate(evt: GeomanDrawingEvent): void {
|
|
286
|
+
if (!this.isActive()) return
|
|
287
|
+
|
|
288
|
+
const shape = String(evt.shape).toLowerCase()
|
|
289
|
+
const isLine = shape === 'line' || shape === 'polyline'
|
|
290
|
+
const isPolygon = shape === 'polygon' || shape === 'rectangle'
|
|
291
|
+
|
|
292
|
+
if (this._activeMode === 'line' && !isLine) return
|
|
293
|
+
if (this._activeMode === 'polygon' && !isPolygon) return
|
|
294
|
+
|
|
295
|
+
const { layer } = evt
|
|
296
|
+
const mode = this._activeMode
|
|
297
|
+
|
|
298
|
+
this.stopMode()
|
|
299
|
+
|
|
300
|
+
this.clearMeasureCreateTimeout()
|
|
301
|
+
this._measureCreateTimeoutId = window.setTimeout(() => {
|
|
302
|
+
this._measureCreateTimeoutId = null
|
|
303
|
+
|
|
304
|
+
if (!this._map?.hasLayer(this._measureGroup)) return
|
|
305
|
+
|
|
306
|
+
this.applyMeasureLayerOptions(layer)
|
|
307
|
+
|
|
308
|
+
if (this._map.hasLayer(layer)) {
|
|
309
|
+
this._map.removeLayer(layer)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this._measureGroup.clearLayers()
|
|
313
|
+
this._measureGroup.addLayer(layer)
|
|
314
|
+
|
|
315
|
+
const { html, event } = this.buildMeasurePopup(layer, mode!)
|
|
316
|
+
layer.bindPopup(html, { closeOnClick: false, autoClose: false, autoPan: false }).openPopup()
|
|
317
|
+
|
|
318
|
+
layer.on('popupclose', () => {
|
|
319
|
+
this._measureGroup.removeLayer(layer)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
this._callbacks.onMeasureComplete?.(event)
|
|
323
|
+
}, 0)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private clearMeasureCreateTimeout(): void {
|
|
327
|
+
if (this._measureCreateTimeoutId !== null) {
|
|
328
|
+
window.clearTimeout(this._measureCreateTimeoutId)
|
|
329
|
+
this._measureCreateTimeoutId = null
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private handleMeasureCancel(): void {
|
|
334
|
+
if (this._suppressMeasureCancel || !this.isActive()) return
|
|
335
|
+
this.stopMode()
|
|
336
|
+
this._measureGroup.clearLayers()
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private buildMeasurePopup(
|
|
340
|
+
layer: Layer,
|
|
341
|
+
mode: MeasureMode
|
|
342
|
+
): { html: string; event: MeasureCompleteEvent } {
|
|
343
|
+
const label = this._config.texts?.measureResult ?? 'Measurement'
|
|
344
|
+
|
|
345
|
+
if (mode === 'polygon') {
|
|
346
|
+
const areas = calculateLayerArea(layer)
|
|
347
|
+
const areaLine = `${this._config.texts?.measureArea ?? 'Area'}: ${formatAreaHa(areas.ha)}`
|
|
348
|
+
return {
|
|
349
|
+
html: `<div class="leaflet-measure-resultpopup"><h3>${label}</h3><p>${areaLine}</p></div>`,
|
|
350
|
+
event: {
|
|
351
|
+
m2: areas.m2,
|
|
352
|
+
ha: areas.ha,
|
|
353
|
+
geojson: (layer as L.Polygon).toGeoJSON() as MeasureCompleteEvent['geojson']
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const lengths = calculateLayerLength(layer)
|
|
359
|
+
const distanceLine = `${this._config.texts?.measureLength ?? 'Distance'}: ${formatLengthKm(lengths.km)}`
|
|
360
|
+
return {
|
|
361
|
+
html: `<div class="leaflet-measure-resultpopup"><h3>${label}</h3><p>${distanceLine}</p></div>`,
|
|
362
|
+
event: {
|
|
363
|
+
lengthM: lengths.m,
|
|
364
|
+
lengthKm: lengths.km,
|
|
365
|
+
geojson: (layer as L.Polyline).toGeoJSON() as MeasureCompleteEvent['geojson']
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private applyMeasureLayerOptions(layer: Layer): void {
|
|
371
|
+
if (layer instanceof Path) {
|
|
372
|
+
layer.options.renderer = this._measureSvgRenderer
|
|
373
|
+
if (typeof layer.redraw === 'function') {
|
|
374
|
+
layer.redraw()
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private showInteractionPanel(mode: MeasureMode): void {
|
|
380
|
+
if (!this._interactionPanel || !this._helpEl) return
|
|
381
|
+
|
|
382
|
+
this._interactionPanel.style.removeProperty('display')
|
|
383
|
+
this._interactionPanel.closest('.leaflet-control-measure-tools')?.classList.add('measure-tools-expanded')
|
|
384
|
+
|
|
385
|
+
if (mode === 'line') {
|
|
386
|
+
this._helpEl.textContent =
|
|
387
|
+
this._config.texts?.measureLineHelp ??
|
|
388
|
+
'Click two points on the map. Double-click to finish the line.'
|
|
389
|
+
} else {
|
|
390
|
+
this._helpEl.textContent =
|
|
391
|
+
this._config.texts?.measurePolygonHelp ??
|
|
392
|
+
'Click to add vertices. Finish on the first point, use Finish, or double-click.'
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (this._resultsEl) {
|
|
396
|
+
this._resultsEl.innerHTML = ''
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
document.addEventListener('keydown', this._boundEscapeKey)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private hideInteractionPanel(): void {
|
|
403
|
+
if (!this._interactionPanel) return
|
|
404
|
+
|
|
405
|
+
this._interactionPanel.style.display = 'none'
|
|
406
|
+
this._interactionPanel.closest('.leaflet-control-measure-tools')?.classList.remove('measure-tools-expanded')
|
|
407
|
+
document.removeEventListener('keydown', this._boundEscapeKey)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private updateButtons(): void {
|
|
411
|
+
const active = this._activeMode
|
|
412
|
+
this._lineBtn?.classList.toggle('measure-tools-btn--active', active === 'line')
|
|
413
|
+
this._polygonBtn?.classList.toggle('measure-tools-btn--active', active === 'polygon')
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private setMeasureActive(active: boolean): void {
|
|
417
|
+
;(this._map as Map & { _leafletMeasureActive?: boolean })._leafletMeasureActive = active
|
|
418
|
+
this._mapContainer.classList.toggle('map-container--measuring', active)
|
|
419
|
+
|
|
420
|
+
if (this._outerContainer instanceof HTMLElement) {
|
|
421
|
+
this._outerContainer.classList.toggle('map-container--measuring', active)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|