anymap-ts 0.10.0 → 0.11.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/anymap_ts/static/cesium.css +1 -0
- package/anymap_ts/static/cesium.js +16543 -1
- package/anymap_ts/static/leaflet.css +1 -1
- package/anymap_ts/static/leaflet.js +1 -1
- package/anymap_ts/static/openlayers.css +1 -1
- package/anymap_ts/static/openlayers.js +602 -7
- package/package.json +3 -2
- package/src/cesium/index.ts +43 -99
- package/src/leaflet/LeafletRenderer.ts +684 -113
- package/src/leaflet/index.ts +23 -0
- package/src/leaflet/leaflet-overrides.css +31 -0
- package/src/leaflet/leaflet-setup.ts +13 -0
- package/src/openlayers/OpenLayersRenderer.ts +1275 -181
- package/src/openlayers/index.ts +1 -0
- package/src/styles/openlayers.css +39 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenLayers renderer implementation.
|
|
2
|
+
* OpenLayers renderer implementation with comprehensive feature support.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import Map from 'ol/Map';
|
|
@@ -8,53 +8,80 @@ import { fromLonLat, toLonLat, transformExtent } from 'ol/proj';
|
|
|
8
8
|
import TileLayer from 'ol/layer/Tile';
|
|
9
9
|
import VectorLayer from 'ol/layer/Vector';
|
|
10
10
|
import ImageLayer from 'ol/layer/Image';
|
|
11
|
+
import HeatmapLayer from 'ol/layer/Heatmap';
|
|
12
|
+
import VectorTileLayer from 'ol/layer/VectorTile';
|
|
13
|
+
import LayerGroup from 'ol/layer/Group';
|
|
11
14
|
import XYZ from 'ol/source/XYZ';
|
|
12
15
|
import OSM from 'ol/source/OSM';
|
|
13
16
|
import TileWMS from 'ol/source/TileWMS';
|
|
14
17
|
import ImageWMS from 'ol/source/ImageWMS';
|
|
18
|
+
import WMTS from 'ol/source/WMTS';
|
|
19
|
+
import WMTSTileGrid from 'ol/tilegrid/WMTS';
|
|
15
20
|
import VectorSource from 'ol/source/Vector';
|
|
21
|
+
import VectorTileSource from 'ol/source/VectorTile';
|
|
22
|
+
import Cluster from 'ol/source/Cluster';
|
|
23
|
+
import ImageStatic from 'ol/source/ImageStatic';
|
|
16
24
|
import GeoJSON from 'ol/format/GeoJSON';
|
|
17
|
-
import
|
|
18
|
-
import
|
|
25
|
+
import MVT from 'ol/format/MVT';
|
|
26
|
+
import Feature from 'ol/Feature';
|
|
27
|
+
import Point from 'ol/geom/Point';
|
|
28
|
+
import LineString from 'ol/geom/LineString';
|
|
29
|
+
import Polygon from 'ol/geom/Polygon';
|
|
30
|
+
import { getArea, getLength } from 'ol/sphere';
|
|
31
|
+
import { Circle as CircleStyle, Fill, Stroke, Style, Icon, Text, RegularShape } from 'ol/style';
|
|
32
|
+
import {
|
|
33
|
+
Zoom, ScaleLine, FullScreen, Attribution, Rotate,
|
|
34
|
+
MousePosition, OverviewMap, ZoomSlider, ZoomToExtent,
|
|
35
|
+
} from 'ol/control';
|
|
19
36
|
import { defaults as defaultControls } from 'ol/control';
|
|
20
|
-
import { defaults as defaultInteractions, DragRotateAndZoom } from 'ol/interaction';
|
|
37
|
+
import { defaults as defaultInteractions, DragRotateAndZoom, Select, Draw, Modify, Snap } from 'ol/interaction';
|
|
21
38
|
import { createStringXY } from 'ol/coordinate';
|
|
39
|
+
import Overlay from 'ol/Overlay';
|
|
40
|
+
import Graticule from 'ol/layer/Graticule';
|
|
41
|
+
import { getTopLeft, getWidth } from 'ol/extent';
|
|
42
|
+
import { get as getProjection } from 'ol/proj';
|
|
43
|
+
import { click, pointerMove } from 'ol/events/condition';
|
|
22
44
|
|
|
23
45
|
import { BaseMapRenderer } from '../core/BaseMapRenderer';
|
|
46
|
+
import { StateManager } from '../core/StateManager';
|
|
24
47
|
import type { MapWidgetModel, JsCall } from '../types/anywidget';
|
|
25
48
|
|
|
26
49
|
import 'ol/ol.css';
|
|
27
50
|
|
|
28
|
-
type MethodHandler = (args: unknown[], kwargs: Record<string, unknown>) => void;
|
|
29
|
-
|
|
30
|
-
// Alias Map to OLMap to avoid conflict with the native Map type
|
|
31
51
|
type OLMap = Map;
|
|
32
52
|
|
|
33
53
|
/**
|
|
34
|
-
* OpenLayers map renderer.
|
|
54
|
+
* OpenLayers map renderer with comprehensive feature support.
|
|
35
55
|
*/
|
|
36
56
|
export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
37
|
-
|
|
38
|
-
private
|
|
39
|
-
private layersMap: globalThis.Map<string, TileLayer<any> | VectorLayer<any> | ImageLayer<any>> = new globalThis.Map();
|
|
57
|
+
private stateManager: StateManager;
|
|
58
|
+
private layersMap: globalThis.Map<string, TileLayer<any> | VectorLayer<any> | ImageLayer<any> | HeatmapLayer | VectorTileLayer | Graticule | LayerGroup> = new globalThis.Map();
|
|
40
59
|
private controlsMap: globalThis.Map<string, any> = new globalThis.Map();
|
|
60
|
+
private markersMap: globalThis.Map<string, VectorLayer<any>> = new globalThis.Map();
|
|
61
|
+
private overlaysMap: globalThis.Map<string, Overlay> = new globalThis.Map();
|
|
62
|
+
private interactionsMap: globalThis.Map<string, any> = new globalThis.Map();
|
|
63
|
+
private resizeObserver: ResizeObserver | null = null;
|
|
64
|
+
private resizeDebounceTimer: number | null = null;
|
|
65
|
+
private isSyncingFromView: boolean = false;
|
|
66
|
+
private popupElement: HTMLDivElement | null = null;
|
|
67
|
+
private popupCloser: HTMLAnchorElement | null = null;
|
|
68
|
+
private popupContent: HTMLDivElement | null = null;
|
|
69
|
+
private popupOverlay: Overlay | null = null;
|
|
70
|
+
private measureTooltipElement: HTMLDivElement | null = null;
|
|
71
|
+
private measureTooltip: Overlay | null = null;
|
|
72
|
+
private drawSource: VectorSource | null = null;
|
|
73
|
+
private drawLayer: VectorLayer<any> | null = null;
|
|
74
|
+
private measureSource: VectorSource | null = null;
|
|
75
|
+
private measureLayer: VectorLayer<any> | null = null;
|
|
76
|
+
private layerControlElement: HTMLDivElement | null = null;
|
|
41
77
|
|
|
42
78
|
constructor(model: MapWidgetModel, el: HTMLElement) {
|
|
43
79
|
super(model, el);
|
|
44
|
-
this.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Register a method handler.
|
|
49
|
-
*/
|
|
50
|
-
protected registerMethod(name: string, handler: MethodHandler): void {
|
|
51
|
-
this.olMethodHandlers[name] = handler;
|
|
80
|
+
this.stateManager = new StateManager(model);
|
|
81
|
+
this.registerMethods();
|
|
52
82
|
}
|
|
53
83
|
|
|
54
|
-
|
|
55
|
-
* Register default method handlers.
|
|
56
|
-
*/
|
|
57
|
-
private registerDefaultMethods(): void {
|
|
84
|
+
private registerMethods(): void {
|
|
58
85
|
// Basemap
|
|
59
86
|
this.registerMethod('addBasemap', this.handleAddBasemap.bind(this));
|
|
60
87
|
|
|
@@ -63,19 +90,41 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
63
90
|
|
|
64
91
|
// Vector data
|
|
65
92
|
this.registerMethod('addGeoJSON', this.handleAddGeoJSON.bind(this));
|
|
93
|
+
this.registerMethod('addGeoJSONFromURL', this.handleAddGeoJSONFromURL.bind(this));
|
|
66
94
|
|
|
67
95
|
// WMS/WMTS
|
|
68
96
|
this.registerMethod('addWMSLayer', this.handleAddWMSLayer.bind(this));
|
|
69
97
|
this.registerMethod('addImageWMSLayer', this.handleAddImageWMSLayer.bind(this));
|
|
98
|
+
this.registerMethod('addWMTSLayer', this.handleAddWMTSLayer.bind(this));
|
|
99
|
+
|
|
100
|
+
// Heatmap
|
|
101
|
+
this.registerMethod('addHeatmap', this.handleAddHeatmap.bind(this));
|
|
102
|
+
|
|
103
|
+
// Clustering
|
|
104
|
+
this.registerMethod('addClusterLayer', this.handleAddClusterLayer.bind(this));
|
|
105
|
+
this.registerMethod('removeClusterLayer', this.handleRemoveClusterLayer.bind(this));
|
|
106
|
+
|
|
107
|
+
// Vector tiles
|
|
108
|
+
this.registerMethod('addVectorTileLayer', this.handleAddVectorTileLayer.bind(this));
|
|
109
|
+
|
|
110
|
+
// Image overlay
|
|
111
|
+
this.registerMethod('addImageLayer', this.handleAddImageLayer.bind(this));
|
|
112
|
+
|
|
113
|
+
// Choropleth
|
|
114
|
+
this.registerMethod('addChoropleth', this.handleAddChoropleth.bind(this));
|
|
70
115
|
|
|
71
116
|
// Layer management
|
|
72
117
|
this.registerMethod('removeLayer', this.handleRemoveLayer.bind(this));
|
|
73
118
|
this.registerMethod('setVisibility', this.handleSetVisibility.bind(this));
|
|
74
119
|
this.registerMethod('setOpacity', this.handleSetOpacity.bind(this));
|
|
120
|
+
this.registerMethod('setLayerStyle', this.handleSetLayerStyle.bind(this));
|
|
121
|
+
this.registerMethod('setLayerZIndex', this.handleSetLayerZIndex.bind(this));
|
|
75
122
|
|
|
76
123
|
// Controls
|
|
77
124
|
this.registerMethod('addControl', this.handleAddControl.bind(this));
|
|
78
125
|
this.registerMethod('removeControl', this.handleRemoveControl.bind(this));
|
|
126
|
+
this.registerMethod('addLayerControl', this.handleAddLayerControl.bind(this));
|
|
127
|
+
this.registerMethod('removeLayerControl', this.handleRemoveLayerControl.bind(this));
|
|
79
128
|
|
|
80
129
|
// Navigation
|
|
81
130
|
this.registerMethod('setCenter', this.handleSetCenter.bind(this));
|
|
@@ -83,166 +132,224 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
83
132
|
this.registerMethod('flyTo', this.handleFlyTo.bind(this));
|
|
84
133
|
this.registerMethod('fitBounds', this.handleFitBounds.bind(this));
|
|
85
134
|
this.registerMethod('fitExtent', this.handleFitExtent.bind(this));
|
|
135
|
+
this.registerMethod('setRotation', this.handleSetRotation.bind(this));
|
|
86
136
|
|
|
87
137
|
// Markers
|
|
88
138
|
this.registerMethod('addMarker', this.handleAddMarker.bind(this));
|
|
139
|
+
this.registerMethod('removeMarker', this.handleRemoveMarker.bind(this));
|
|
140
|
+
|
|
141
|
+
// Popups / Overlays
|
|
142
|
+
this.registerMethod('addPopup', this.handleAddPopup.bind(this));
|
|
143
|
+
this.registerMethod('removePopup', this.handleRemovePopup.bind(this));
|
|
144
|
+
this.registerMethod('showPopup', this.handleShowPopup.bind(this));
|
|
145
|
+
|
|
146
|
+
// Draw interaction
|
|
147
|
+
this.registerMethod('addDrawControl', this.handleAddDrawControl.bind(this));
|
|
148
|
+
this.registerMethod('removeDrawControl', this.handleRemoveDrawControl.bind(this));
|
|
149
|
+
this.registerMethod('clearDrawData', this.handleClearDrawData.bind(this));
|
|
150
|
+
|
|
151
|
+
// Measure
|
|
152
|
+
this.registerMethod('addMeasureControl', this.handleAddMeasureControl.bind(this));
|
|
153
|
+
this.registerMethod('removeMeasureControl', this.handleRemoveMeasureControl.bind(this));
|
|
154
|
+
|
|
155
|
+
// Select interaction
|
|
156
|
+
this.registerMethod('addSelectInteraction', this.handleAddSelectInteraction.bind(this));
|
|
157
|
+
this.registerMethod('removeSelectInteraction', this.handleRemoveSelectInteraction.bind(this));
|
|
158
|
+
|
|
159
|
+
// Graticule
|
|
160
|
+
this.registerMethod('addGraticule', this.handleAddGraticule.bind(this));
|
|
161
|
+
this.registerMethod('removeGraticule', this.handleRemoveGraticule.bind(this));
|
|
89
162
|
}
|
|
90
163
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
164
|
+
// =========================================================================
|
|
165
|
+
// Initialization
|
|
166
|
+
// =========================================================================
|
|
167
|
+
|
|
94
168
|
async initialize(): Promise<void> {
|
|
169
|
+
this.createMapContainer();
|
|
170
|
+
|
|
171
|
+
this.map = this.createMap();
|
|
172
|
+
this.createPopupOverlay();
|
|
173
|
+
|
|
174
|
+
this.setupModelListeners();
|
|
175
|
+
this.setupMapEvents();
|
|
176
|
+
this.setupResizeObserver();
|
|
177
|
+
|
|
178
|
+
this.processJsCalls();
|
|
179
|
+
this.isMapReady = true;
|
|
180
|
+
this.processPendingCalls();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
protected createMap(): OLMap {
|
|
95
184
|
const center = this.model.get('center') as [number, number] || [0, 0];
|
|
96
185
|
const zoom = this.model.get('zoom') as number || 2;
|
|
97
186
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
this.el.style.display = 'block';
|
|
101
|
-
|
|
102
|
-
// Create map container with explicit dimensions
|
|
103
|
-
const container = document.createElement('div');
|
|
104
|
-
container.style.width = this.model.get('width') as string || '100%';
|
|
105
|
-
container.style.height = this.model.get('height') as string || '600px';
|
|
106
|
-
container.style.position = 'relative';
|
|
107
|
-
container.style.minWidth = '200px';
|
|
108
|
-
this.el.appendChild(container);
|
|
109
|
-
|
|
110
|
-
// Create map
|
|
111
|
-
this.map = new Map({
|
|
112
|
-
target: container,
|
|
187
|
+
return new Map({
|
|
188
|
+
target: this.mapContainer!,
|
|
113
189
|
view: new View({
|
|
114
190
|
center: fromLonLat(center),
|
|
115
191
|
zoom: zoom,
|
|
116
192
|
}),
|
|
117
|
-
controls:
|
|
193
|
+
controls: [],
|
|
118
194
|
interactions: defaultInteractions().extend([new DragRotateAndZoom()]),
|
|
119
195
|
});
|
|
196
|
+
}
|
|
120
197
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (jsCalls && jsCalls.length > 0) {
|
|
124
|
-
for (const call of jsCalls) {
|
|
125
|
-
await this.executeOLMethod(call);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Listen for model changes
|
|
130
|
-
this.model.on('change:_js_calls', () => {
|
|
131
|
-
this.handleJsCallsChange();
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
this.model.on('change:center', () => {
|
|
135
|
-
const newCenter = this.model.get('center') as [number, number];
|
|
136
|
-
if (this.map) {
|
|
137
|
-
this.map.getView().setCenter(fromLonLat(newCenter));
|
|
138
|
-
}
|
|
139
|
-
});
|
|
198
|
+
private setupMapEvents(): void {
|
|
199
|
+
if (!this.map) return;
|
|
140
200
|
|
|
141
|
-
this.
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
201
|
+
this.map.on('click', (evt) => {
|
|
202
|
+
const coordinate = toLonLat(evt.coordinate);
|
|
203
|
+
this.model.set('clicked', {
|
|
204
|
+
lng: coordinate[0],
|
|
205
|
+
lat: coordinate[1],
|
|
206
|
+
point: [evt.pixel[0], evt.pixel[1]],
|
|
207
|
+
});
|
|
208
|
+
this.sendEvent('click', {
|
|
209
|
+
lngLat: coordinate,
|
|
210
|
+
point: evt.pixel,
|
|
211
|
+
});
|
|
212
|
+
this.model.save_changes();
|
|
213
|
+
|
|
214
|
+
// Show feature info popup if click is on a feature
|
|
215
|
+
const features: any[] = [];
|
|
216
|
+
this.map!.forEachFeatureAtPixel(evt.pixel, (feature) => {
|
|
217
|
+
features.push(feature);
|
|
218
|
+
});
|
|
219
|
+
if (features.length > 0) {
|
|
220
|
+
const props = features[0].getProperties();
|
|
221
|
+
delete props.geometry;
|
|
222
|
+
this.sendEvent('featureClick', {
|
|
223
|
+
properties: props,
|
|
224
|
+
lngLat: coordinate,
|
|
225
|
+
});
|
|
145
226
|
}
|
|
146
227
|
});
|
|
147
228
|
|
|
148
|
-
// Sync view changes back to model
|
|
149
229
|
this.map.getView().on('change:center', () => {
|
|
150
|
-
if (this.map) {
|
|
230
|
+
if (this.map && !this.isSyncingFromView) {
|
|
231
|
+
this.isSyncingFromView = true;
|
|
151
232
|
const center = toLonLat(this.map.getView().getCenter() || [0, 0]);
|
|
152
233
|
this.model.set('center', center);
|
|
234
|
+
this.model.set('current_center', center);
|
|
153
235
|
this.model.save_changes();
|
|
236
|
+
this.isSyncingFromView = false;
|
|
154
237
|
}
|
|
155
238
|
});
|
|
156
239
|
|
|
157
240
|
this.map.getView().on('change:resolution', () => {
|
|
158
|
-
if (this.map) {
|
|
159
|
-
|
|
241
|
+
if (this.map && !this.isSyncingFromView) {
|
|
242
|
+
this.isSyncingFromView = true;
|
|
243
|
+
const zoom = this.map.getView().getZoom() || 0;
|
|
160
244
|
this.model.set('zoom', zoom);
|
|
245
|
+
this.model.set('current_zoom', zoom);
|
|
161
246
|
this.model.save_changes();
|
|
247
|
+
this.isSyncingFromView = false;
|
|
162
248
|
}
|
|
163
249
|
});
|
|
164
|
-
}
|
|
165
250
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
251
|
+
this.map.on('moveend', () => {
|
|
252
|
+
if (!this.map) return;
|
|
253
|
+
const view = this.map.getView();
|
|
254
|
+
const extent = view.calculateExtent(this.map.getSize());
|
|
255
|
+
const bounds = transformExtent(extent, 'EPSG:3857', 'EPSG:4326');
|
|
256
|
+
this.model.set('current_bounds', [bounds[0], bounds[1], bounds[2], bounds[3]]);
|
|
257
|
+
this.sendEvent('moveend', {
|
|
258
|
+
center: toLonLat(view.getCenter() || [0, 0]),
|
|
259
|
+
zoom: view.getZoom(),
|
|
260
|
+
bounds: bounds,
|
|
261
|
+
});
|
|
262
|
+
this.model.save_changes();
|
|
263
|
+
});
|
|
175
264
|
}
|
|
176
265
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
*/
|
|
180
|
-
private async executeOLMethod(call: JsCall): Promise<void> {
|
|
181
|
-
const { method, args, kwargs } = call;
|
|
182
|
-
const handler = this.olMethodHandlers[method];
|
|
266
|
+
private setupResizeObserver(): void {
|
|
267
|
+
if (!this.mapContainer || !this.map) return;
|
|
183
268
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
} catch (error) {
|
|
188
|
-
console.error(`Error executing method ${method}:`, error);
|
|
269
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
270
|
+
if (this.resizeDebounceTimer !== null) {
|
|
271
|
+
window.clearTimeout(this.resizeDebounceTimer);
|
|
189
272
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
273
|
+
this.resizeDebounceTimer = window.setTimeout(() => {
|
|
274
|
+
if (this.map) {
|
|
275
|
+
this.map.updateSize();
|
|
276
|
+
}
|
|
277
|
+
this.resizeDebounceTimer = null;
|
|
278
|
+
}, 100);
|
|
279
|
+
});
|
|
194
280
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
protected createMap(): OLMap {
|
|
199
|
-
const center = this.model.get('center') as [number, number] || [0, 0];
|
|
200
|
-
const zoom = this.model.get('zoom') as number || 2;
|
|
281
|
+
this.resizeObserver.observe(this.mapContainer);
|
|
282
|
+
this.resizeObserver.observe(this.el);
|
|
283
|
+
}
|
|
201
284
|
|
|
202
|
-
|
|
285
|
+
private createPopupOverlay(): void {
|
|
286
|
+
if (!this.map) return;
|
|
203
287
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
288
|
+
this.popupElement = document.createElement('div');
|
|
289
|
+
this.popupElement.className = 'ol-popup';
|
|
290
|
+
this.popupElement.style.cssText = `
|
|
291
|
+
position: absolute; background-color: white; box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
|
292
|
+
padding: 12px; border-radius: 8px; border: 1px solid #ccc; bottom: 12px;
|
|
293
|
+
left: -50px; min-width: 200px; font-size: 13px; max-width: 300px; color: #333;
|
|
294
|
+
`;
|
|
295
|
+
|
|
296
|
+
this.popupCloser = document.createElement('a');
|
|
297
|
+
this.popupCloser.href = '#';
|
|
298
|
+
this.popupCloser.className = 'ol-popup-closer';
|
|
299
|
+
this.popupCloser.style.cssText = `
|
|
300
|
+
text-decoration: none; position: absolute; top: 4px; right: 8px;
|
|
301
|
+
font-size: 16px; color: #999; cursor: pointer;
|
|
302
|
+
`;
|
|
303
|
+
this.popupCloser.textContent = '✕';
|
|
304
|
+
this.popupElement.appendChild(this.popupCloser);
|
|
305
|
+
|
|
306
|
+
this.popupContent = document.createElement('div');
|
|
307
|
+
this.popupContent.className = 'ol-popup-content';
|
|
308
|
+
this.popupElement.appendChild(this.popupContent);
|
|
309
|
+
|
|
310
|
+
this.popupOverlay = new Overlay({
|
|
311
|
+
element: this.popupElement,
|
|
312
|
+
autoPan: {
|
|
313
|
+
animation: { duration: 250 },
|
|
314
|
+
},
|
|
212
315
|
});
|
|
316
|
+
|
|
317
|
+
this.popupCloser.onclick = () => {
|
|
318
|
+
this.popupOverlay!.setPosition(undefined);
|
|
319
|
+
this.popupCloser!.blur();
|
|
320
|
+
return false;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
this.map.addOverlay(this.popupOverlay);
|
|
213
324
|
}
|
|
214
325
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
326
|
+
// =========================================================================
|
|
327
|
+
// Trait change handlers
|
|
328
|
+
// =========================================================================
|
|
329
|
+
|
|
218
330
|
protected onCenterChange(): void {
|
|
331
|
+
if (this.isSyncingFromView) return;
|
|
219
332
|
const newCenter = this.model.get('center') as [number, number];
|
|
220
333
|
if (this.map) {
|
|
221
334
|
this.map.getView().setCenter(fromLonLat(newCenter));
|
|
222
335
|
}
|
|
223
336
|
}
|
|
224
337
|
|
|
225
|
-
/**
|
|
226
|
-
* Handle changes to the zoom trait.
|
|
227
|
-
*/
|
|
228
338
|
protected onZoomChange(): void {
|
|
339
|
+
if (this.isSyncingFromView) return;
|
|
229
340
|
const newZoom = this.model.get('zoom') as number;
|
|
230
341
|
if (this.map) {
|
|
231
342
|
this.map.getView().setZoom(newZoom);
|
|
232
343
|
}
|
|
233
344
|
}
|
|
234
345
|
|
|
235
|
-
/**
|
|
236
|
-
* Handle changes to the style trait.
|
|
237
|
-
*/
|
|
238
346
|
protected onStyleChange(): void {
|
|
239
|
-
// OpenLayers doesn't have a
|
|
240
|
-
// Style changes would need to be handled differently
|
|
347
|
+
// OpenLayers doesn't have a global style concept
|
|
241
348
|
}
|
|
242
349
|
|
|
243
|
-
//
|
|
244
|
-
// Basemap
|
|
245
|
-
//
|
|
350
|
+
// =========================================================================
|
|
351
|
+
// Basemap
|
|
352
|
+
// =========================================================================
|
|
246
353
|
|
|
247
354
|
private handleAddBasemap(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
248
355
|
if (!this.map) return;
|
|
@@ -251,7 +358,6 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
251
358
|
const name = kwargs.name as string || 'basemap';
|
|
252
359
|
const attribution = kwargs.attribution as string || '';
|
|
253
360
|
|
|
254
|
-
// Remove existing basemap if any
|
|
255
361
|
const existingBasemap = this.layersMap.get('basemap-' + name);
|
|
256
362
|
if (existingBasemap) {
|
|
257
363
|
this.map.removeLayer(existingBasemap);
|
|
@@ -269,9 +375,9 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
269
375
|
this.layersMap.set('basemap-' + name, layer);
|
|
270
376
|
}
|
|
271
377
|
|
|
272
|
-
//
|
|
273
|
-
// Tile Layer
|
|
274
|
-
//
|
|
378
|
+
// =========================================================================
|
|
379
|
+
// Tile Layer
|
|
380
|
+
// =========================================================================
|
|
275
381
|
|
|
276
382
|
private handleAddTileLayer(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
277
383
|
if (!this.map) return;
|
|
@@ -295,11 +401,12 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
295
401
|
|
|
296
402
|
this.map.addLayer(layer);
|
|
297
403
|
this.layersMap.set(name, layer);
|
|
404
|
+
this.updateLayerControl();
|
|
298
405
|
}
|
|
299
406
|
|
|
300
|
-
//
|
|
301
|
-
// Vector
|
|
302
|
-
//
|
|
407
|
+
// =========================================================================
|
|
408
|
+
// Vector / GeoJSON
|
|
409
|
+
// =========================================================================
|
|
303
410
|
|
|
304
411
|
private handleAddGeoJSON(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
305
412
|
if (!this.map) return;
|
|
@@ -308,6 +415,8 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
308
415
|
const name = kwargs.name as string || `geojson-${this.layersMap.size}`;
|
|
309
416
|
const fitBounds = kwargs.fitBounds !== false;
|
|
310
417
|
const style = kwargs.style as Record<string, unknown> || {};
|
|
418
|
+
const popup = kwargs.popup as string | undefined;
|
|
419
|
+
const popupProperties = kwargs.popupProperties as string[] | undefined;
|
|
311
420
|
|
|
312
421
|
const vectorSource = new VectorSource({
|
|
313
422
|
features: new GeoJSON().readFeatures(data, {
|
|
@@ -322,6 +431,11 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
322
431
|
|
|
323
432
|
this.map.addLayer(vectorLayer);
|
|
324
433
|
this.layersMap.set(name, vectorLayer);
|
|
434
|
+
this.updateLayerControl();
|
|
435
|
+
|
|
436
|
+
if (popup || popupProperties) {
|
|
437
|
+
this.setupFeaturePopup(name, popup, popupProperties);
|
|
438
|
+
}
|
|
325
439
|
|
|
326
440
|
if (fitBounds) {
|
|
327
441
|
const extent = vectorSource.getExtent();
|
|
@@ -334,7 +448,90 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
334
448
|
}
|
|
335
449
|
}
|
|
336
450
|
|
|
337
|
-
private
|
|
451
|
+
private handleAddGeoJSONFromURL(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
452
|
+
if (!this.map) return;
|
|
453
|
+
|
|
454
|
+
const url = kwargs.url as string;
|
|
455
|
+
const name = kwargs.name as string || `geojson-url-${this.layersMap.size}`;
|
|
456
|
+
const fitBounds = kwargs.fitBounds !== false;
|
|
457
|
+
const style = kwargs.style as Record<string, unknown> || {};
|
|
458
|
+
|
|
459
|
+
const vectorSource = new VectorSource({
|
|
460
|
+
url: url,
|
|
461
|
+
format: new GeoJSON(),
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const vectorLayer = new VectorLayer({
|
|
465
|
+
source: vectorSource,
|
|
466
|
+
style: this.createVectorStyle(style),
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
this.map.addLayer(vectorLayer);
|
|
470
|
+
this.layersMap.set(name, vectorLayer);
|
|
471
|
+
this.updateLayerControl();
|
|
472
|
+
|
|
473
|
+
if (fitBounds) {
|
|
474
|
+
vectorSource.once('change', () => {
|
|
475
|
+
if (vectorSource.getState() === 'ready') {
|
|
476
|
+
const extent = vectorSource.getExtent();
|
|
477
|
+
if (extent && extent.every(v => isFinite(v)) && this.map) {
|
|
478
|
+
this.map.getView().fit(extent, {
|
|
479
|
+
padding: [50, 50, 50, 50],
|
|
480
|
+
duration: 500,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private setupFeaturePopup(layerName: string, popup?: string, popupProperties?: string[]): void {
|
|
489
|
+
if (!this.map) return;
|
|
490
|
+
|
|
491
|
+
this.map.on('click', (evt) => {
|
|
492
|
+
let featureFound = false;
|
|
493
|
+
this.map!.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
|
|
494
|
+
if (featureFound) return;
|
|
495
|
+
const olLayer = this.layersMap.get(layerName);
|
|
496
|
+
if (layer === olLayer) {
|
|
497
|
+
featureFound = true;
|
|
498
|
+
const props = feature.getProperties();
|
|
499
|
+
delete props.geometry;
|
|
500
|
+
|
|
501
|
+
let content = '';
|
|
502
|
+
if (popup) {
|
|
503
|
+
content = popup;
|
|
504
|
+
for (const [key, value] of Object.entries(props)) {
|
|
505
|
+
content = content.replace(`{${key}}`, String(value));
|
|
506
|
+
}
|
|
507
|
+
} else if (popupProperties) {
|
|
508
|
+
content = '<table style="border-collapse:collapse;">';
|
|
509
|
+
for (const key of popupProperties) {
|
|
510
|
+
if (props[key] !== undefined) {
|
|
511
|
+
content += `<tr><td style="padding:2px 8px;font-weight:bold;">${key}</td><td style="padding:2px 8px;">${props[key]}</td></tr>`;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
content += '</table>';
|
|
515
|
+
} else {
|
|
516
|
+
content = '<table style="border-collapse:collapse;">';
|
|
517
|
+
for (const [key, value] of Object.entries(props)) {
|
|
518
|
+
content += `<tr><td style="padding:2px 8px;font-weight:bold;">${key}</td><td style="padding:2px 8px;">${value}</td></tr>`;
|
|
519
|
+
}
|
|
520
|
+
content += '</table>';
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
this.popupContent!.innerHTML = content;
|
|
524
|
+
this.popupOverlay!.setPosition(evt.coordinate);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private createVectorStyle(styleConfig: Record<string, unknown>): Style | ((feature: any) => Style) {
|
|
531
|
+
if (styleConfig.styleFunction) {
|
|
532
|
+
return styleConfig.styleFunction as (feature: any) => Style;
|
|
533
|
+
}
|
|
534
|
+
|
|
338
535
|
const fill = new Fill({
|
|
339
536
|
color: styleConfig.fillColor as string || 'rgba(51, 136, 255, 0.5)',
|
|
340
537
|
});
|
|
@@ -342,6 +539,7 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
342
539
|
const stroke = new Stroke({
|
|
343
540
|
color: styleConfig.strokeColor as string || '#3388ff',
|
|
344
541
|
width: styleConfig.strokeWidth as number ?? 2,
|
|
542
|
+
lineDash: styleConfig.lineDash as number[] || undefined,
|
|
345
543
|
});
|
|
346
544
|
|
|
347
545
|
const circle = new CircleStyle({
|
|
@@ -350,16 +548,162 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
350
548
|
stroke: stroke,
|
|
351
549
|
});
|
|
352
550
|
|
|
353
|
-
|
|
551
|
+
const options: any = {
|
|
354
552
|
fill: fill,
|
|
355
553
|
stroke: stroke,
|
|
356
554
|
image: circle,
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
if (styleConfig.text) {
|
|
558
|
+
options.text = new Text({
|
|
559
|
+
text: styleConfig.text as string,
|
|
560
|
+
fill: new Fill({ color: styleConfig.textColor as string || '#000' }),
|
|
561
|
+
stroke: new Stroke({ color: '#fff', width: 3 }),
|
|
562
|
+
font: styleConfig.font as string || '12px sans-serif',
|
|
563
|
+
offsetY: styleConfig.textOffsetY as number || -15,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return new Style(options);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// =========================================================================
|
|
571
|
+
// Heatmap
|
|
572
|
+
// =========================================================================
|
|
573
|
+
|
|
574
|
+
private handleAddHeatmap(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
575
|
+
if (!this.map) return;
|
|
576
|
+
|
|
577
|
+
const data = kwargs.data as object;
|
|
578
|
+
const name = kwargs.name as string || `heatmap-${this.layersMap.size}`;
|
|
579
|
+
const blur = kwargs.blur as number ?? 15;
|
|
580
|
+
const radius = kwargs.radius as number ?? 8;
|
|
581
|
+
const weight = kwargs.weight as string || undefined;
|
|
582
|
+
const gradient = kwargs.gradient as string[] || undefined;
|
|
583
|
+
const opacity = kwargs.opacity as number ?? 0.8;
|
|
584
|
+
const fitBounds = kwargs.fitBounds !== false;
|
|
585
|
+
|
|
586
|
+
const vectorSource = new VectorSource({
|
|
587
|
+
features: new GeoJSON().readFeatures(data, {
|
|
588
|
+
featureProjection: 'EPSG:3857',
|
|
589
|
+
}),
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const heatmapLayer = new HeatmapLayer({
|
|
593
|
+
source: vectorSource,
|
|
594
|
+
blur: blur,
|
|
595
|
+
radius: radius,
|
|
596
|
+
opacity: opacity,
|
|
597
|
+
weight: weight ? (feature: any) => {
|
|
598
|
+
const val = feature.get(weight);
|
|
599
|
+
return val !== undefined ? Number(val) : 1;
|
|
600
|
+
} : undefined,
|
|
601
|
+
gradient: gradient || undefined,
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
this.map.addLayer(heatmapLayer);
|
|
605
|
+
this.layersMap.set(name, heatmapLayer);
|
|
606
|
+
this.updateLayerControl();
|
|
607
|
+
|
|
608
|
+
if (fitBounds) {
|
|
609
|
+
const extent = vectorSource.getExtent();
|
|
610
|
+
if (extent && extent.every(v => isFinite(v))) {
|
|
611
|
+
this.map.getView().fit(extent, {
|
|
612
|
+
padding: [50, 50, 50, 50],
|
|
613
|
+
duration: 500,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// =========================================================================
|
|
620
|
+
// Clustering
|
|
621
|
+
// =========================================================================
|
|
622
|
+
|
|
623
|
+
private handleAddClusterLayer(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
624
|
+
if (!this.map) return;
|
|
625
|
+
|
|
626
|
+
const data = kwargs.data as object;
|
|
627
|
+
const name = kwargs.name as string || `cluster-${this.layersMap.size}`;
|
|
628
|
+
const distance = kwargs.distance as number ?? 40;
|
|
629
|
+
const minDistance = kwargs.minDistance as number ?? 20;
|
|
630
|
+
const fitBounds = kwargs.fitBounds !== false;
|
|
631
|
+
const clusterColor = kwargs.clusterColor as string || 'rgba(51, 136, 255, 0.7)';
|
|
632
|
+
const pointColor = kwargs.pointColor as string || 'rgba(51, 136, 255, 0.9)';
|
|
633
|
+
const textColor = kwargs.textColor as string || '#fff';
|
|
634
|
+
|
|
635
|
+
const features = new GeoJSON().readFeatures(data, {
|
|
636
|
+
featureProjection: 'EPSG:3857',
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
const source = new VectorSource({ features });
|
|
640
|
+
|
|
641
|
+
const clusterSource = new Cluster({
|
|
642
|
+
distance: distance,
|
|
643
|
+
minDistance: minDistance,
|
|
644
|
+
source: source,
|
|
357
645
|
});
|
|
646
|
+
|
|
647
|
+
const styleCache: Record<number, Style> = {};
|
|
648
|
+
const clusterLayer = new VectorLayer({
|
|
649
|
+
source: clusterSource,
|
|
650
|
+
style: (feature) => {
|
|
651
|
+
const clusterFeatures = feature.get('features');
|
|
652
|
+
const size = clusterFeatures.length;
|
|
653
|
+
let style = styleCache[size];
|
|
654
|
+
if (!style) {
|
|
655
|
+
if (size === 1) {
|
|
656
|
+
style = new Style({
|
|
657
|
+
image: new CircleStyle({
|
|
658
|
+
radius: 8,
|
|
659
|
+
fill: new Fill({ color: pointColor }),
|
|
660
|
+
stroke: new Stroke({ color: '#fff', width: 2 }),
|
|
661
|
+
}),
|
|
662
|
+
});
|
|
663
|
+
} else {
|
|
664
|
+
const radius = Math.min(8 + Math.sqrt(size) * 3, 30);
|
|
665
|
+
style = new Style({
|
|
666
|
+
image: new CircleStyle({
|
|
667
|
+
radius: radius,
|
|
668
|
+
fill: new Fill({ color: clusterColor }),
|
|
669
|
+
stroke: new Stroke({ color: '#fff', width: 2 }),
|
|
670
|
+
}),
|
|
671
|
+
text: new Text({
|
|
672
|
+
text: size.toString(),
|
|
673
|
+
fill: new Fill({ color: textColor }),
|
|
674
|
+
font: 'bold 12px sans-serif',
|
|
675
|
+
}),
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
styleCache[size] = style;
|
|
679
|
+
}
|
|
680
|
+
return style;
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
this.map.addLayer(clusterLayer);
|
|
685
|
+
this.layersMap.set(name, clusterLayer);
|
|
686
|
+
this.updateLayerControl();
|
|
687
|
+
|
|
688
|
+
if (fitBounds) {
|
|
689
|
+
const extent = source.getExtent();
|
|
690
|
+
if (extent && extent.every(v => isFinite(v))) {
|
|
691
|
+
this.map.getView().fit(extent, {
|
|
692
|
+
padding: [50, 50, 50, 50],
|
|
693
|
+
duration: 500,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private handleRemoveClusterLayer(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
700
|
+
const [name] = args as [string];
|
|
701
|
+
this.handleRemoveLayer([name], kwargs);
|
|
358
702
|
}
|
|
359
703
|
|
|
360
|
-
//
|
|
361
|
-
// WMS
|
|
362
|
-
//
|
|
704
|
+
// =========================================================================
|
|
705
|
+
// WMS / WMTS
|
|
706
|
+
// =========================================================================
|
|
363
707
|
|
|
364
708
|
private handleAddWMSLayer(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
365
709
|
if (!this.map) return;
|
|
@@ -386,6 +730,7 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
386
730
|
|
|
387
731
|
this.map.addLayer(layer);
|
|
388
732
|
this.layersMap.set(name, layer);
|
|
733
|
+
this.updateLayerControl();
|
|
389
734
|
}
|
|
390
735
|
|
|
391
736
|
private handleAddImageWMSLayer(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
@@ -413,11 +758,211 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
413
758
|
|
|
414
759
|
this.map.addLayer(layer);
|
|
415
760
|
this.layersMap.set(name, layer);
|
|
761
|
+
this.updateLayerControl();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private handleAddWMTSLayer(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
765
|
+
if (!this.map) return;
|
|
766
|
+
|
|
767
|
+
const url = kwargs.url as string;
|
|
768
|
+
const name = kwargs.name as string || `wmts-${this.layersMap.size}`;
|
|
769
|
+
const layerName = kwargs.layer as string;
|
|
770
|
+
const matrixSet = kwargs.matrixSet as string || 'EPSG:3857';
|
|
771
|
+
const format = kwargs.format as string || 'image/png';
|
|
772
|
+
const attribution = kwargs.attribution as string || '';
|
|
773
|
+
const style = kwargs.style as string || 'default';
|
|
774
|
+
const opacity = kwargs.opacity as number ?? 1;
|
|
775
|
+
|
|
776
|
+
const projection = getProjection(matrixSet);
|
|
777
|
+
const projectionExtent = projection!.getExtent();
|
|
778
|
+
const size = getWidth(projectionExtent) / 256;
|
|
779
|
+
const resolutions = new Array(19);
|
|
780
|
+
const matrixIds = new Array(19);
|
|
781
|
+
for (let z = 0; z < 19; ++z) {
|
|
782
|
+
resolutions[z] = size / Math.pow(2, z);
|
|
783
|
+
matrixIds[z] = z.toString();
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const layer = new TileLayer({
|
|
787
|
+
opacity: opacity,
|
|
788
|
+
source: new WMTS({
|
|
789
|
+
url: url,
|
|
790
|
+
layer: layerName,
|
|
791
|
+
matrixSet: matrixSet,
|
|
792
|
+
format: format,
|
|
793
|
+
style: style,
|
|
794
|
+
attributions: attribution ? [attribution] : undefined,
|
|
795
|
+
tileGrid: new WMTSTileGrid({
|
|
796
|
+
origin: getTopLeft(projectionExtent),
|
|
797
|
+
resolutions: resolutions,
|
|
798
|
+
matrixIds: matrixIds,
|
|
799
|
+
}),
|
|
800
|
+
}),
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
this.map.addLayer(layer);
|
|
804
|
+
this.layersMap.set(name, layer);
|
|
805
|
+
this.updateLayerControl();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// =========================================================================
|
|
809
|
+
// Vector Tiles
|
|
810
|
+
// =========================================================================
|
|
811
|
+
|
|
812
|
+
private handleAddVectorTileLayer(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
813
|
+
if (!this.map) return;
|
|
814
|
+
|
|
815
|
+
const url = kwargs.url as string;
|
|
816
|
+
const name = kwargs.name as string || `vectortile-${this.layersMap.size}`;
|
|
817
|
+
const style = kwargs.style as Record<string, unknown> || {};
|
|
818
|
+
const attribution = kwargs.attribution as string || '';
|
|
819
|
+
const minZoom = kwargs.minZoom as number ?? 0;
|
|
820
|
+
const maxZoom = kwargs.maxZoom as number ?? 22;
|
|
821
|
+
|
|
822
|
+
const layer = new VectorTileLayer({
|
|
823
|
+
source: new VectorTileSource({
|
|
824
|
+
format: new MVT(),
|
|
825
|
+
url: url,
|
|
826
|
+
attributions: attribution ? [attribution] : undefined,
|
|
827
|
+
minZoom: minZoom,
|
|
828
|
+
maxZoom: maxZoom,
|
|
829
|
+
}),
|
|
830
|
+
style: Object.keys(style).length > 0 ? this.createVectorStyle(style) as Style : undefined,
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
this.map.addLayer(layer);
|
|
834
|
+
this.layersMap.set(name, layer);
|
|
835
|
+
this.updateLayerControl();
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// =========================================================================
|
|
839
|
+
// Image Overlay
|
|
840
|
+
// =========================================================================
|
|
841
|
+
|
|
842
|
+
private handleAddImageLayer(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
843
|
+
if (!this.map) return;
|
|
844
|
+
|
|
845
|
+
const url = kwargs.url as string;
|
|
846
|
+
const name = kwargs.name as string || `image-${this.layersMap.size}`;
|
|
847
|
+
const bounds = kwargs.bounds as [number, number, number, number];
|
|
848
|
+
const opacity = kwargs.opacity as number ?? 1;
|
|
849
|
+
|
|
850
|
+
const extent = transformExtent(
|
|
851
|
+
[bounds[0], bounds[1], bounds[2], bounds[3]],
|
|
852
|
+
'EPSG:4326',
|
|
853
|
+
'EPSG:3857'
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
const layer = new ImageLayer({
|
|
857
|
+
source: new ImageStatic({
|
|
858
|
+
url: url,
|
|
859
|
+
imageExtent: extent,
|
|
860
|
+
}),
|
|
861
|
+
opacity: opacity,
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
this.map.addLayer(layer);
|
|
865
|
+
this.layersMap.set(name, layer);
|
|
866
|
+
this.updateLayerControl();
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// =========================================================================
|
|
870
|
+
// Choropleth
|
|
871
|
+
// =========================================================================
|
|
872
|
+
|
|
873
|
+
private handleAddChoropleth(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
874
|
+
if (!this.map) return;
|
|
875
|
+
|
|
876
|
+
const data = kwargs.data as object;
|
|
877
|
+
const name = kwargs.name as string || `choropleth-${this.layersMap.size}`;
|
|
878
|
+
const column = kwargs.column as string;
|
|
879
|
+
const breaks = kwargs.breaks as number[];
|
|
880
|
+
const colors = kwargs.colors as string[];
|
|
881
|
+
const strokeColor = kwargs.strokeColor as string || '#333';
|
|
882
|
+
const strokeWidth = kwargs.strokeWidth as number ?? 1;
|
|
883
|
+
const opacity = kwargs.opacity as number ?? 0.7;
|
|
884
|
+
const fitBounds = kwargs.fitBounds !== false;
|
|
885
|
+
const legend = kwargs.legend as boolean ?? true;
|
|
886
|
+
|
|
887
|
+
const vectorSource = new VectorSource({
|
|
888
|
+
features: new GeoJSON().readFeatures(data, {
|
|
889
|
+
featureProjection: 'EPSG:3857',
|
|
890
|
+
}),
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const getColorForValue = (value: number): string => {
|
|
894
|
+
for (let i = 0; i < breaks.length - 1; i++) {
|
|
895
|
+
if (value <= breaks[i + 1]) {
|
|
896
|
+
return colors[Math.min(i, colors.length - 1)];
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return colors[colors.length - 1];
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
const vectorLayer = new VectorLayer({
|
|
903
|
+
source: vectorSource,
|
|
904
|
+
style: (feature) => {
|
|
905
|
+
const value = feature.get(column);
|
|
906
|
+
const color = value !== undefined ? getColorForValue(Number(value)) : colors[0];
|
|
907
|
+
return new Style({
|
|
908
|
+
fill: new Fill({ color: color }),
|
|
909
|
+
stroke: new Stroke({
|
|
910
|
+
color: strokeColor,
|
|
911
|
+
width: strokeWidth,
|
|
912
|
+
}),
|
|
913
|
+
});
|
|
914
|
+
},
|
|
915
|
+
opacity: opacity,
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
this.map.addLayer(vectorLayer);
|
|
919
|
+
this.layersMap.set(name, vectorLayer);
|
|
920
|
+
this.updateLayerControl();
|
|
921
|
+
|
|
922
|
+
if (legend) {
|
|
923
|
+
this.addChoroplethLegend(name, column, breaks, colors);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (fitBounds) {
|
|
927
|
+
const extent = vectorSource.getExtent();
|
|
928
|
+
if (extent && extent.every(v => isFinite(v))) {
|
|
929
|
+
this.map.getView().fit(extent, {
|
|
930
|
+
padding: [50, 50, 50, 50],
|
|
931
|
+
duration: 500,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
}
|
|
416
935
|
}
|
|
417
936
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
937
|
+
private addChoroplethLegend(name: string, column: string, breaks: number[], colors: string[]): void {
|
|
938
|
+
if (!this.mapContainer) return;
|
|
939
|
+
|
|
940
|
+
const legendDiv = document.createElement('div');
|
|
941
|
+
legendDiv.className = `ol-legend ol-legend-${name}`;
|
|
942
|
+
legendDiv.style.cssText = `
|
|
943
|
+
position: absolute; bottom: 30px; right: 10px; background: rgba(255,255,255,0.9);
|
|
944
|
+
padding: 10px 14px; border-radius: 6px; font-size: 12px; z-index: 1000;
|
|
945
|
+
box-shadow: 0 1px 4px rgba(0,0,0,0.15); max-height: 300px; overflow-y: auto;
|
|
946
|
+
color: #333;
|
|
947
|
+
`;
|
|
948
|
+
|
|
949
|
+
let html = `<div style="font-weight:bold;margin-bottom:6px;color:#333;">${column}</div>`;
|
|
950
|
+
for (let i = 0; i < colors.length; i++) {
|
|
951
|
+
const low = breaks[i] !== undefined ? breaks[i].toFixed(1) : '';
|
|
952
|
+
const high = breaks[i + 1] !== undefined ? breaks[i + 1].toFixed(1) : '';
|
|
953
|
+
html += `
|
|
954
|
+
<div style="display:flex;align-items:center;margin:2px 0;">
|
|
955
|
+
<div style="width:18px;height:18px;background:${colors[i]};border:1px solid #ccc;margin-right:6px;border-radius:2px;"></div>
|
|
956
|
+
<span style="color:#333;">${low} – ${high}</span>
|
|
957
|
+
</div>`;
|
|
958
|
+
}
|
|
959
|
+
legendDiv.innerHTML = html;
|
|
960
|
+
this.mapContainer.appendChild(legendDiv);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// =========================================================================
|
|
964
|
+
// Layer Management
|
|
965
|
+
// =========================================================================
|
|
421
966
|
|
|
422
967
|
private handleRemoveLayer(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
423
968
|
if (!this.map) return;
|
|
@@ -428,13 +973,16 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
428
973
|
if (layer) {
|
|
429
974
|
this.map.removeLayer(layer);
|
|
430
975
|
this.layersMap.delete(layerId);
|
|
976
|
+
this.updateLayerControl();
|
|
431
977
|
}
|
|
978
|
+
|
|
979
|
+
const legendEl = this.mapContainer?.querySelector(`.ol-legend-${layerId}`);
|
|
980
|
+
if (legendEl) legendEl.remove();
|
|
432
981
|
}
|
|
433
982
|
|
|
434
983
|
private handleSetVisibility(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
435
984
|
const [layerId, visible] = args as [string, boolean];
|
|
436
985
|
const layer = this.layersMap.get(layerId);
|
|
437
|
-
|
|
438
986
|
if (layer) {
|
|
439
987
|
layer.setVisible(visible);
|
|
440
988
|
}
|
|
@@ -443,22 +991,36 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
443
991
|
private handleSetOpacity(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
444
992
|
const [layerId, opacity] = args as [string, number];
|
|
445
993
|
const layer = this.layersMap.get(layerId);
|
|
446
|
-
|
|
447
994
|
if (layer) {
|
|
448
995
|
layer.setOpacity(opacity);
|
|
449
996
|
}
|
|
450
997
|
}
|
|
451
998
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
999
|
+
private handleSetLayerStyle(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1000
|
+
const [layerId] = args as [string];
|
|
1001
|
+
const style = kwargs.style as Record<string, unknown>;
|
|
1002
|
+
const layer = this.layersMap.get(layerId);
|
|
1003
|
+
if (layer && layer instanceof VectorLayer) {
|
|
1004
|
+
(layer as VectorLayer<any>).setStyle(this.createVectorStyle(style) as Style);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
private handleSetLayerZIndex(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1009
|
+
const [layerId, zIndex] = args as [string, number];
|
|
1010
|
+
const layer = this.layersMap.get(layerId);
|
|
1011
|
+
if (layer) {
|
|
1012
|
+
layer.setZIndex(zIndex);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// =========================================================================
|
|
1017
|
+
// Controls
|
|
1018
|
+
// =========================================================================
|
|
455
1019
|
|
|
456
1020
|
private handleAddControl(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
457
1021
|
if (!this.map) return;
|
|
458
1022
|
|
|
459
1023
|
const controlType = args[0] as string;
|
|
460
|
-
const position = kwargs.position as string || 'top-right';
|
|
461
|
-
|
|
462
1024
|
let control: any;
|
|
463
1025
|
|
|
464
1026
|
switch (controlType) {
|
|
@@ -486,10 +1048,24 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
486
1048
|
break;
|
|
487
1049
|
case 'mousePosition':
|
|
488
1050
|
control = new MousePosition({
|
|
489
|
-
coordinateFormat: createStringXY(4),
|
|
1051
|
+
coordinateFormat: createStringXY(kwargs.precision as number ?? 4),
|
|
490
1052
|
projection: 'EPSG:4326',
|
|
491
1053
|
});
|
|
492
1054
|
break;
|
|
1055
|
+
case 'overviewMap':
|
|
1056
|
+
control = new OverviewMap({
|
|
1057
|
+
collapsed: kwargs.collapsed !== false,
|
|
1058
|
+
layers: [new TileLayer({ source: new OSM() })],
|
|
1059
|
+
});
|
|
1060
|
+
break;
|
|
1061
|
+
case 'zoomSlider':
|
|
1062
|
+
control = new ZoomSlider();
|
|
1063
|
+
break;
|
|
1064
|
+
case 'zoomToExtent':
|
|
1065
|
+
control = new ZoomToExtent({
|
|
1066
|
+
extent: kwargs.extent as [number, number, number, number] || undefined,
|
|
1067
|
+
});
|
|
1068
|
+
break;
|
|
493
1069
|
}
|
|
494
1070
|
|
|
495
1071
|
if (control) {
|
|
@@ -510,27 +1086,105 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
510
1086
|
}
|
|
511
1087
|
}
|
|
512
1088
|
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
1089
|
+
// =========================================================================
|
|
1090
|
+
// Layer Control
|
|
1091
|
+
// =========================================================================
|
|
1092
|
+
|
|
1093
|
+
private handleAddLayerControl(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1094
|
+
if (!this.mapContainer) return;
|
|
1095
|
+
|
|
1096
|
+
this.handleRemoveLayerControl([], {});
|
|
1097
|
+
|
|
1098
|
+
const collapsed = kwargs.collapsed as boolean ?? true;
|
|
1099
|
+
|
|
1100
|
+
const container = document.createElement('div');
|
|
1101
|
+
container.className = 'ol-layer-control';
|
|
1102
|
+
container.style.cssText = `
|
|
1103
|
+
position: absolute; top: 10px; right: 10px; background: rgba(255,255,255,0.95);
|
|
1104
|
+
padding: 8px 12px; border-radius: 6px; font-size: 13px; z-index: 1000;
|
|
1105
|
+
box-shadow: 0 1px 4px rgba(0,0,0,0.15); max-height: 400px; overflow-y: auto;
|
|
1106
|
+
min-width: 150px; color: #333;
|
|
1107
|
+
`;
|
|
1108
|
+
|
|
1109
|
+
const header = document.createElement('div');
|
|
1110
|
+
header.style.cssText = 'font-weight:bold;margin-bottom:6px;cursor:pointer;user-select:none;';
|
|
1111
|
+
header.textContent = '☰ Layers';
|
|
1112
|
+
container.appendChild(header);
|
|
1113
|
+
|
|
1114
|
+
const list = document.createElement('div');
|
|
1115
|
+
list.className = 'ol-layer-list';
|
|
1116
|
+
if (collapsed) {
|
|
1117
|
+
list.style.display = 'none';
|
|
1118
|
+
}
|
|
1119
|
+
container.appendChild(list);
|
|
1120
|
+
|
|
1121
|
+
header.onclick = () => {
|
|
1122
|
+
list.style.display = list.style.display === 'none' ? 'block' : 'none';
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
this.layerControlElement = container;
|
|
1126
|
+
this.mapContainer.appendChild(container);
|
|
1127
|
+
|
|
1128
|
+
this.updateLayerControl();
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
private handleRemoveLayerControl(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1132
|
+
if (this.layerControlElement) {
|
|
1133
|
+
this.layerControlElement.remove();
|
|
1134
|
+
this.layerControlElement = null;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
private updateLayerControl(): void {
|
|
1139
|
+
if (!this.layerControlElement) return;
|
|
1140
|
+
|
|
1141
|
+
const list = this.layerControlElement.querySelector('.ol-layer-list');
|
|
1142
|
+
if (!list) return;
|
|
1143
|
+
|
|
1144
|
+
list.innerHTML = '';
|
|
1145
|
+
|
|
1146
|
+
this.layersMap.forEach((layer, name) => {
|
|
1147
|
+
if (name.startsWith('basemap-')) return;
|
|
1148
|
+
|
|
1149
|
+
const item = document.createElement('div');
|
|
1150
|
+
item.style.cssText = 'display:flex;align-items:center;margin:3px 0;';
|
|
1151
|
+
|
|
1152
|
+
const checkbox = document.createElement('input');
|
|
1153
|
+
checkbox.type = 'checkbox';
|
|
1154
|
+
checkbox.checked = layer.getVisible();
|
|
1155
|
+
checkbox.style.marginRight = '6px';
|
|
1156
|
+
checkbox.onchange = () => {
|
|
1157
|
+
layer.setVisible(checkbox.checked);
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
const label = document.createElement('span');
|
|
1161
|
+
label.textContent = name;
|
|
1162
|
+
label.style.cssText = 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
|
|
1163
|
+
|
|
1164
|
+
item.appendChild(checkbox);
|
|
1165
|
+
item.appendChild(label);
|
|
1166
|
+
list.appendChild(item);
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// =========================================================================
|
|
1171
|
+
// Navigation
|
|
1172
|
+
// =========================================================================
|
|
516
1173
|
|
|
517
1174
|
private handleSetCenter(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
518
1175
|
if (!this.map) return;
|
|
519
|
-
|
|
520
1176
|
const [lng, lat] = args as [number, number];
|
|
521
1177
|
this.map.getView().setCenter(fromLonLat([lng, lat]));
|
|
522
1178
|
}
|
|
523
1179
|
|
|
524
1180
|
private handleSetZoom(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
525
1181
|
if (!this.map) return;
|
|
526
|
-
|
|
527
1182
|
const [zoom] = args as [number];
|
|
528
1183
|
this.map.getView().setZoom(zoom);
|
|
529
1184
|
}
|
|
530
1185
|
|
|
531
1186
|
private handleFlyTo(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
532
1187
|
if (!this.map) return;
|
|
533
|
-
|
|
534
1188
|
const [lng, lat] = args as [number, number];
|
|
535
1189
|
const zoom = kwargs.zoom as number;
|
|
536
1190
|
const duration = kwargs.duration as number || 2000;
|
|
@@ -544,12 +1198,10 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
544
1198
|
|
|
545
1199
|
private handleFitBounds(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
546
1200
|
if (!this.map) return;
|
|
547
|
-
|
|
548
1201
|
const bounds = args[0] as [number, number, number, number];
|
|
549
1202
|
const padding = kwargs.padding as number || 50;
|
|
550
1203
|
const duration = kwargs.duration as number || 1000;
|
|
551
1204
|
|
|
552
|
-
// bounds: [minLng, minLat, maxLng, maxLat]
|
|
553
1205
|
const extent = transformExtent(
|
|
554
1206
|
[bounds[0], bounds[1], bounds[2], bounds[3]],
|
|
555
1207
|
'EPSG:4326',
|
|
@@ -564,7 +1216,6 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
564
1216
|
|
|
565
1217
|
private handleFitExtent(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
566
1218
|
if (!this.map) return;
|
|
567
|
-
|
|
568
1219
|
const extent = args[0] as [number, number, number, number];
|
|
569
1220
|
const padding = kwargs.padding as number || 50;
|
|
570
1221
|
const duration = kwargs.duration as number || 1000;
|
|
@@ -575,40 +1226,37 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
575
1226
|
});
|
|
576
1227
|
}
|
|
577
1228
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
1229
|
+
private handleSetRotation(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1230
|
+
if (!this.map) return;
|
|
1231
|
+
const [rotation] = args as [number];
|
|
1232
|
+
this.map.getView().setRotation(rotation);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// =========================================================================
|
|
1236
|
+
// Markers
|
|
1237
|
+
// =========================================================================
|
|
581
1238
|
|
|
582
1239
|
private handleAddMarker(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
583
1240
|
if (!this.map) return;
|
|
584
1241
|
|
|
585
1242
|
const [lng, lat] = args as [number, number];
|
|
586
|
-
const name = kwargs.id as string || kwargs.name as string || `marker-${this.
|
|
1243
|
+
const name = kwargs.id as string || kwargs.name as string || `marker-${this.markersMap.size}`;
|
|
587
1244
|
const popup = kwargs.popup as string;
|
|
588
1245
|
const color = kwargs.color as string || '#3388ff';
|
|
1246
|
+
const radius = kwargs.radius as number ?? 8;
|
|
1247
|
+
const draggable = kwargs.draggable as boolean ?? false;
|
|
589
1248
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
type: 'Point',
|
|
595
|
-
coordinates: [lng, lat],
|
|
596
|
-
},
|
|
597
|
-
properties: {
|
|
598
|
-
popup: popup,
|
|
599
|
-
},
|
|
600
|
-
}, {
|
|
601
|
-
featureProjection: 'EPSG:3857',
|
|
1249
|
+
const feature = new Feature({
|
|
1250
|
+
geometry: new Point(fromLonLat([lng, lat])),
|
|
1251
|
+
popup: popup,
|
|
1252
|
+
name: name,
|
|
602
1253
|
});
|
|
603
1254
|
|
|
604
|
-
const
|
|
605
|
-
const vectorSource = new VectorSource({
|
|
606
|
-
features,
|
|
607
|
-
});
|
|
1255
|
+
const vectorSource = new VectorSource({ features: [feature] });
|
|
608
1256
|
|
|
609
1257
|
const markerStyle = new Style({
|
|
610
1258
|
image: new CircleStyle({
|
|
611
|
-
radius:
|
|
1259
|
+
radius: radius,
|
|
612
1260
|
fill: new Fill({ color: color }),
|
|
613
1261
|
stroke: new Stroke({ color: '#ffffff', width: 2 }),
|
|
614
1262
|
}),
|
|
@@ -620,20 +1268,466 @@ export class OpenLayersRenderer extends BaseMapRenderer<OLMap> {
|
|
|
620
1268
|
});
|
|
621
1269
|
|
|
622
1270
|
this.map.addLayer(vectorLayer);
|
|
623
|
-
this.
|
|
1271
|
+
this.markersMap.set(name, vectorLayer);
|
|
1272
|
+
|
|
1273
|
+
if (popup) {
|
|
1274
|
+
this.map.on('click', (evt) => {
|
|
1275
|
+
this.map!.forEachFeatureAtPixel(evt.pixel, (f) => {
|
|
1276
|
+
if (f === feature && popup) {
|
|
1277
|
+
this.popupContent!.innerHTML = popup;
|
|
1278
|
+
this.popupOverlay!.setPosition(evt.coordinate);
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
if (draggable) {
|
|
1285
|
+
const modify = new Modify({ source: vectorSource });
|
|
1286
|
+
modify.on('modifyend', () => {
|
|
1287
|
+
const coords = (feature.getGeometry() as Point).getCoordinates();
|
|
1288
|
+
const lonLat = toLonLat(coords);
|
|
1289
|
+
this.sendEvent('markerDrag', { id: name, lngLat: lonLat });
|
|
1290
|
+
});
|
|
1291
|
+
this.map.addInteraction(modify);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private handleRemoveMarker(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1296
|
+
if (!this.map) return;
|
|
1297
|
+
const [name] = args as [string];
|
|
1298
|
+
|
|
1299
|
+
const layer = this.markersMap.get(name);
|
|
1300
|
+
if (layer) {
|
|
1301
|
+
this.map.removeLayer(layer);
|
|
1302
|
+
this.markersMap.delete(name);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// =========================================================================
|
|
1307
|
+
// Popups / Overlays
|
|
1308
|
+
// =========================================================================
|
|
1309
|
+
|
|
1310
|
+
private handleAddPopup(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1311
|
+
if (!this.map) return;
|
|
1312
|
+
|
|
1313
|
+
const name = kwargs.name as string || `popup-${this.overlaysMap.size}`;
|
|
1314
|
+
const content = kwargs.content as string || '';
|
|
1315
|
+
const lng = kwargs.lng as number;
|
|
1316
|
+
const lat = kwargs.lat as number;
|
|
1317
|
+
|
|
1318
|
+
this.popupContent!.innerHTML = content;
|
|
1319
|
+
this.popupOverlay!.setPosition(fromLonLat([lng, lat]));
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
private handleRemovePopup(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1323
|
+
if (this.popupOverlay) {
|
|
1324
|
+
this.popupOverlay.setPosition(undefined);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
private handleShowPopup(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1329
|
+
if (!this.map) return;
|
|
1330
|
+
|
|
1331
|
+
const content = kwargs.content as string || '';
|
|
1332
|
+
const lng = kwargs.lng as number;
|
|
1333
|
+
const lat = kwargs.lat as number;
|
|
1334
|
+
|
|
1335
|
+
this.popupContent!.innerHTML = content;
|
|
1336
|
+
this.popupOverlay!.setPosition(fromLonLat([lng, lat]));
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// =========================================================================
|
|
1340
|
+
// Draw Interaction
|
|
1341
|
+
// =========================================================================
|
|
1342
|
+
|
|
1343
|
+
private handleAddDrawControl(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1344
|
+
if (!this.map) return;
|
|
1345
|
+
|
|
1346
|
+
this.handleRemoveDrawControl([], {});
|
|
1347
|
+
|
|
1348
|
+
const drawType = kwargs.type as string || 'Polygon';
|
|
1349
|
+
|
|
1350
|
+
this.drawSource = new VectorSource();
|
|
1351
|
+
this.drawLayer = new VectorLayer({
|
|
1352
|
+
source: this.drawSource,
|
|
1353
|
+
style: new Style({
|
|
1354
|
+
fill: new Fill({ color: 'rgba(255, 255, 255, 0.3)' }),
|
|
1355
|
+
stroke: new Stroke({ color: '#ffcc33', width: 2 }),
|
|
1356
|
+
image: new CircleStyle({
|
|
1357
|
+
radius: 7,
|
|
1358
|
+
fill: new Fill({ color: '#ffcc33' }),
|
|
1359
|
+
}),
|
|
1360
|
+
}),
|
|
1361
|
+
});
|
|
1362
|
+
this.map.addLayer(this.drawLayer);
|
|
1363
|
+
|
|
1364
|
+
const draw = new Draw({
|
|
1365
|
+
source: this.drawSource,
|
|
1366
|
+
type: drawType as any,
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
const modify = new Modify({ source: this.drawSource });
|
|
1370
|
+
const snap = new Snap({ source: this.drawSource });
|
|
1371
|
+
|
|
1372
|
+
draw.on('drawend', () => {
|
|
1373
|
+
setTimeout(() => this.syncDrawData(), 100);
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
modify.on('modifyend', () => {
|
|
1377
|
+
this.syncDrawData();
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
this.map.addInteraction(draw);
|
|
1381
|
+
this.map.addInteraction(modify);
|
|
1382
|
+
this.map.addInteraction(snap);
|
|
1383
|
+
|
|
1384
|
+
this.interactionsMap.set('draw', draw);
|
|
1385
|
+
this.interactionsMap.set('draw-modify', modify);
|
|
1386
|
+
this.interactionsMap.set('draw-snap', snap);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
private handleRemoveDrawControl(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1390
|
+
if (!this.map) return;
|
|
1391
|
+
|
|
1392
|
+
['draw', 'draw-modify', 'draw-snap'].forEach(key => {
|
|
1393
|
+
const interaction = this.interactionsMap.get(key);
|
|
1394
|
+
if (interaction) {
|
|
1395
|
+
this.map!.removeInteraction(interaction);
|
|
1396
|
+
this.interactionsMap.delete(key);
|
|
1397
|
+
}
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
if (this.drawLayer) {
|
|
1401
|
+
this.map.removeLayer(this.drawLayer);
|
|
1402
|
+
this.drawLayer = null;
|
|
1403
|
+
this.drawSource = null;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
private handleClearDrawData(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1408
|
+
if (this.drawSource) {
|
|
1409
|
+
this.drawSource.clear();
|
|
1410
|
+
this.syncDrawData();
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
private syncDrawData(): void {
|
|
1415
|
+
if (!this.drawSource) return;
|
|
1416
|
+
|
|
1417
|
+
const features = this.drawSource.getFeatures();
|
|
1418
|
+
const geojson = new GeoJSON().writeFeaturesObject(features, {
|
|
1419
|
+
featureProjection: 'EPSG:3857',
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
this.model.set('_draw_data', geojson);
|
|
1423
|
+
this.model.save_changes();
|
|
1424
|
+
this.sendEvent('drawChange', geojson);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// =========================================================================
|
|
1428
|
+
// Measure
|
|
1429
|
+
// =========================================================================
|
|
1430
|
+
|
|
1431
|
+
private handleAddMeasureControl(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1432
|
+
if (!this.map) return;
|
|
1433
|
+
|
|
1434
|
+
this.handleRemoveMeasureControl([], {});
|
|
1435
|
+
|
|
1436
|
+
const measureType = kwargs.type as string || 'LineString';
|
|
1437
|
+
|
|
1438
|
+
this.measureSource = new VectorSource();
|
|
1439
|
+
this.measureLayer = new VectorLayer({
|
|
1440
|
+
source: this.measureSource,
|
|
1441
|
+
style: new Style({
|
|
1442
|
+
fill: new Fill({ color: 'rgba(255, 255, 255, 0.2)' }),
|
|
1443
|
+
stroke: new Stroke({
|
|
1444
|
+
color: '#e74c3c',
|
|
1445
|
+
width: 3,
|
|
1446
|
+
lineDash: [10, 10],
|
|
1447
|
+
}),
|
|
1448
|
+
image: new CircleStyle({
|
|
1449
|
+
radius: 5,
|
|
1450
|
+
fill: new Fill({ color: '#e74c3c' }),
|
|
1451
|
+
stroke: new Stroke({ color: '#fff', width: 2 }),
|
|
1452
|
+
}),
|
|
1453
|
+
}),
|
|
1454
|
+
});
|
|
1455
|
+
this.map.addLayer(this.measureLayer);
|
|
1456
|
+
|
|
1457
|
+
this.measureTooltipElement = document.createElement('div');
|
|
1458
|
+
this.measureTooltipElement.className = 'ol-measure-tooltip';
|
|
1459
|
+
this.measureTooltipElement.style.cssText = `
|
|
1460
|
+
position: relative; background: rgba(0,0,0,0.7); color: #fff;
|
|
1461
|
+
border-radius: 4px; padding: 4px 8px; font-size: 12px; white-space: nowrap;
|
|
1462
|
+
`;
|
|
1463
|
+
|
|
1464
|
+
this.measureTooltip = new Overlay({
|
|
1465
|
+
element: this.measureTooltipElement,
|
|
1466
|
+
offset: [0, -15],
|
|
1467
|
+
positioning: 'bottom-center',
|
|
1468
|
+
});
|
|
1469
|
+
this.map.addOverlay(this.measureTooltip);
|
|
1470
|
+
|
|
1471
|
+
const draw = new Draw({
|
|
1472
|
+
source: this.measureSource,
|
|
1473
|
+
type: measureType as any,
|
|
1474
|
+
style: new Style({
|
|
1475
|
+
fill: new Fill({ color: 'rgba(255, 255, 255, 0.2)' }),
|
|
1476
|
+
stroke: new Stroke({
|
|
1477
|
+
color: 'rgba(231, 76, 60, 0.5)',
|
|
1478
|
+
width: 2,
|
|
1479
|
+
lineDash: [10, 10],
|
|
1480
|
+
}),
|
|
1481
|
+
image: new CircleStyle({
|
|
1482
|
+
radius: 5,
|
|
1483
|
+
stroke: new Stroke({ color: 'rgba(231, 76, 60, 0.7)' }),
|
|
1484
|
+
fill: new Fill({ color: 'rgba(255, 255, 255, 0.2)' }),
|
|
1485
|
+
}),
|
|
1486
|
+
}),
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
draw.on('drawstart', (evt) => {
|
|
1490
|
+
const sketch = evt.feature;
|
|
1491
|
+
sketch.getGeometry()!.on('change', (geomEvt) => {
|
|
1492
|
+
const geom = geomEvt.target;
|
|
1493
|
+
let output = '';
|
|
1494
|
+
let tooltipCoord: any;
|
|
1495
|
+
if (geom instanceof Polygon) {
|
|
1496
|
+
const area = getArea(geom);
|
|
1497
|
+
output = area > 10000
|
|
1498
|
+
? `${(area / 1000000).toFixed(2)} km²`
|
|
1499
|
+
: `${area.toFixed(2)} m²`;
|
|
1500
|
+
tooltipCoord = geom.getInteriorPoint().getCoordinates();
|
|
1501
|
+
} else if (geom instanceof LineString) {
|
|
1502
|
+
const length = getLength(geom);
|
|
1503
|
+
output = length > 1000
|
|
1504
|
+
? `${(length / 1000).toFixed(2)} km`
|
|
1505
|
+
: `${length.toFixed(2)} m`;
|
|
1506
|
+
tooltipCoord = geom.getLastCoordinate();
|
|
1507
|
+
}
|
|
1508
|
+
if (this.measureTooltipElement) {
|
|
1509
|
+
this.measureTooltipElement.textContent = output;
|
|
1510
|
+
}
|
|
1511
|
+
if (this.measureTooltip) {
|
|
1512
|
+
this.measureTooltip.setPosition(tooltipCoord);
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
draw.on('drawend', (evt) => {
|
|
1518
|
+
const geom = evt.feature.getGeometry();
|
|
1519
|
+
let result: Record<string, unknown> = {};
|
|
1520
|
+
if (geom instanceof Polygon) {
|
|
1521
|
+
result = { type: 'area', value: getArea(geom), unit: 'm²' };
|
|
1522
|
+
} else if (geom instanceof LineString) {
|
|
1523
|
+
result = { type: 'distance', value: getLength(geom), unit: 'm' };
|
|
1524
|
+
}
|
|
1525
|
+
this.sendEvent('measureResult', result);
|
|
1526
|
+
|
|
1527
|
+
const tooltip = document.createElement('div');
|
|
1528
|
+
tooltip.style.cssText = `
|
|
1529
|
+
background: rgba(0,0,0,0.7); color: #fff; border-radius: 4px;
|
|
1530
|
+
padding: 3px 6px; font-size: 11px; white-space: nowrap;
|
|
1531
|
+
`;
|
|
1532
|
+
tooltip.textContent = this.measureTooltipElement?.textContent || '';
|
|
1533
|
+
const overlay = new Overlay({
|
|
1534
|
+
element: tooltip,
|
|
1535
|
+
offset: [0, -10],
|
|
1536
|
+
positioning: 'bottom-center',
|
|
1537
|
+
position: this.measureTooltip?.getPosition(),
|
|
1538
|
+
});
|
|
1539
|
+
this.map!.addOverlay(overlay);
|
|
1540
|
+
|
|
1541
|
+
this.measureTooltipElement = document.createElement('div');
|
|
1542
|
+
this.measureTooltipElement.className = 'ol-measure-tooltip';
|
|
1543
|
+
this.measureTooltipElement.style.cssText = `
|
|
1544
|
+
position: relative; background: rgba(0,0,0,0.7); color: #fff;
|
|
1545
|
+
border-radius: 4px; padding: 4px 8px; font-size: 12px; white-space: nowrap;
|
|
1546
|
+
`;
|
|
1547
|
+
this.measureTooltip!.setElement(this.measureTooltipElement);
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
this.map.addInteraction(draw);
|
|
1551
|
+
this.interactionsMap.set('measure', draw);
|
|
624
1552
|
}
|
|
625
1553
|
|
|
626
|
-
|
|
1554
|
+
private handleRemoveMeasureControl(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1555
|
+
if (!this.map) return;
|
|
1556
|
+
|
|
1557
|
+
const interaction = this.interactionsMap.get('measure');
|
|
1558
|
+
if (interaction) {
|
|
1559
|
+
this.map.removeInteraction(interaction);
|
|
1560
|
+
this.interactionsMap.delete('measure');
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
if (this.measureLayer) {
|
|
1564
|
+
this.map.removeLayer(this.measureLayer);
|
|
1565
|
+
this.measureLayer = null;
|
|
1566
|
+
this.measureSource = null;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
if (this.measureTooltip) {
|
|
1570
|
+
this.map.removeOverlay(this.measureTooltip);
|
|
1571
|
+
this.measureTooltip = null;
|
|
1572
|
+
this.measureTooltipElement = null;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// =========================================================================
|
|
1577
|
+
// Select Interaction
|
|
1578
|
+
// =========================================================================
|
|
1579
|
+
|
|
1580
|
+
private handleAddSelectInteraction(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1581
|
+
if (!this.map) return;
|
|
1582
|
+
|
|
1583
|
+
this.handleRemoveSelectInteraction([], {});
|
|
1584
|
+
|
|
1585
|
+
const multi = kwargs.multi as boolean ?? false;
|
|
1586
|
+
|
|
1587
|
+
const highlightStyle = new Style({
|
|
1588
|
+
fill: new Fill({ color: 'rgba(255, 200, 0, 0.4)' }),
|
|
1589
|
+
stroke: new Stroke({ color: '#ff8800', width: 3 }),
|
|
1590
|
+
image: new CircleStyle({
|
|
1591
|
+
radius: 8,
|
|
1592
|
+
fill: new Fill({ color: 'rgba(255, 200, 0, 0.6)' }),
|
|
1593
|
+
stroke: new Stroke({ color: '#ff8800', width: 2 }),
|
|
1594
|
+
}),
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
const select = new Select({
|
|
1598
|
+
condition: click,
|
|
1599
|
+
multi: multi,
|
|
1600
|
+
style: highlightStyle,
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
select.on('select', (evt) => {
|
|
1604
|
+
const selected = evt.selected.map((feature: any) => {
|
|
1605
|
+
const props = feature.getProperties();
|
|
1606
|
+
delete props.geometry;
|
|
1607
|
+
return props;
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
this.sendEvent('featureSelect', { selected });
|
|
1611
|
+
this.model.set('_queried_features', { selected });
|
|
1612
|
+
this.model.save_changes();
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
this.map.addInteraction(select);
|
|
1616
|
+
this.interactionsMap.set('select', select);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
private handleRemoveSelectInteraction(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1620
|
+
if (!this.map) return;
|
|
1621
|
+
|
|
1622
|
+
const interaction = this.interactionsMap.get('select');
|
|
1623
|
+
if (interaction) {
|
|
1624
|
+
this.map.removeInteraction(interaction);
|
|
1625
|
+
this.interactionsMap.delete('select');
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// =========================================================================
|
|
1630
|
+
// Graticule
|
|
1631
|
+
// =========================================================================
|
|
1632
|
+
|
|
1633
|
+
private handleAddGraticule(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1634
|
+
if (!this.map) return;
|
|
1635
|
+
|
|
1636
|
+
this.handleRemoveGraticule([], {});
|
|
1637
|
+
|
|
1638
|
+
const strokeColor = kwargs.strokeColor as string || 'rgba(0, 0, 0, 0.2)';
|
|
1639
|
+
const strokeWidth = kwargs.strokeWidth as number ?? 1;
|
|
1640
|
+
const showLabels = kwargs.showLabels !== false;
|
|
1641
|
+
|
|
1642
|
+
const graticule = new Graticule({
|
|
1643
|
+
strokeStyle: new Stroke({
|
|
1644
|
+
color: strokeColor,
|
|
1645
|
+
width: strokeWidth,
|
|
1646
|
+
lineDash: [4, 4],
|
|
1647
|
+
}),
|
|
1648
|
+
showLabels: showLabels,
|
|
1649
|
+
wrapX: false,
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
this.map.addLayer(graticule);
|
|
1653
|
+
this.layersMap.set('graticule', graticule);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
private handleRemoveGraticule(args: unknown[], kwargs: Record<string, unknown>): void {
|
|
1657
|
+
if (!this.map) return;
|
|
1658
|
+
|
|
1659
|
+
const graticule = this.layersMap.get('graticule');
|
|
1660
|
+
if (graticule) {
|
|
1661
|
+
this.map.removeLayer(graticule);
|
|
1662
|
+
this.layersMap.delete('graticule');
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// =========================================================================
|
|
627
1667
|
// Cleanup
|
|
628
|
-
//
|
|
1668
|
+
// =========================================================================
|
|
629
1669
|
|
|
630
1670
|
destroy(): void {
|
|
631
1671
|
this.removeModelListeners();
|
|
1672
|
+
|
|
1673
|
+
if (this.resizeDebounceTimer !== null) {
|
|
1674
|
+
window.clearTimeout(this.resizeDebounceTimer);
|
|
1675
|
+
this.resizeDebounceTimer = null;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
if (this.resizeObserver) {
|
|
1679
|
+
this.resizeObserver.disconnect();
|
|
1680
|
+
this.resizeObserver = null;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
this.interactionsMap.forEach((interaction) => {
|
|
1684
|
+
if (this.map) {
|
|
1685
|
+
this.map.removeInteraction(interaction);
|
|
1686
|
+
}
|
|
1687
|
+
});
|
|
1688
|
+
this.interactionsMap.clear();
|
|
1689
|
+
|
|
1690
|
+
this.overlaysMap.forEach((overlay) => {
|
|
1691
|
+
if (this.map) {
|
|
1692
|
+
this.map.removeOverlay(overlay);
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
this.overlaysMap.clear();
|
|
1696
|
+
|
|
1697
|
+
this.markersMap.forEach((layer) => {
|
|
1698
|
+
if (this.map) {
|
|
1699
|
+
this.map.removeLayer(layer);
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
this.markersMap.clear();
|
|
1703
|
+
|
|
1704
|
+
this.layersMap.forEach((layer) => {
|
|
1705
|
+
if (this.map) {
|
|
1706
|
+
this.map.removeLayer(layer);
|
|
1707
|
+
}
|
|
1708
|
+
});
|
|
1709
|
+
this.layersMap.clear();
|
|
1710
|
+
|
|
1711
|
+
this.controlsMap.forEach((control) => {
|
|
1712
|
+
if (this.map) {
|
|
1713
|
+
this.map.removeControl(control);
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
this.controlsMap.clear();
|
|
1717
|
+
|
|
1718
|
+
if (this.layerControlElement) {
|
|
1719
|
+
this.layerControlElement.remove();
|
|
1720
|
+
this.layerControlElement = null;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
632
1723
|
if (this.map) {
|
|
633
1724
|
this.map.setTarget(undefined);
|
|
634
1725
|
this.map = null;
|
|
635
1726
|
}
|
|
636
|
-
|
|
637
|
-
this.
|
|
1727
|
+
|
|
1728
|
+
if (this.mapContainer) {
|
|
1729
|
+
this.mapContainer.remove();
|
|
1730
|
+
this.mapContainer = null;
|
|
1731
|
+
}
|
|
638
1732
|
}
|
|
639
1733
|
}
|