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.
@@ -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 { Circle as CircleStyle, Fill, Stroke, Style, Icon, Text } from 'ol/style';
18
- import { Zoom, ScaleLine, FullScreen, Attribution, Rotate, MousePosition } from 'ol/control';
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
- protected map: OLMap | null = null;
38
- private olMethodHandlers: Record<string, MethodHandler> = {};
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.registerDefaultMethods();
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
- * Initialize the OpenLayers map.
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
- // Set up parent element
99
- this.el.style.width = '100%';
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: defaultControls({ attribution: false }),
193
+ controls: [],
118
194
  interactions: defaultInteractions().extend([new DragRotateAndZoom()]),
119
195
  });
196
+ }
120
197
 
121
- // Process any pending JS calls
122
- const jsCalls = this.model.get('_js_calls') as JsCall[];
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.model.on('change:zoom', () => {
142
- const newZoom = this.model.get('zoom') as number;
143
- if (this.map) {
144
- this.map.getView().setZoom(newZoom);
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
- const zoom = this.map.getView().getZoom();
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
- * Handle JS calls change.
168
- */
169
- private handleJsCallsChange(): void {
170
- const jsCalls = this.model.get('_js_calls') as JsCall[];
171
- if (jsCalls && jsCalls.length > 0) {
172
- const lastCall = jsCalls[jsCalls.length - 1];
173
- this.executeOLMethod(lastCall);
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
- * Execute a method from Python.
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
- if (handler) {
185
- try {
186
- handler(args, kwargs);
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
- } else {
191
- console.warn(`Unknown method: ${method}`);
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
- * Create the map instance.
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
- const container = this.createMapContainer();
285
+ private createPopupOverlay(): void {
286
+ if (!this.map) return;
203
287
 
204
- return new Map({
205
- target: container,
206
- view: new View({
207
- center: fromLonLat(center),
208
- zoom: zoom,
209
- }),
210
- controls: defaultControls({ attribution: false }),
211
- interactions: defaultInteractions().extend([new DragRotateAndZoom()]),
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
- * Handle changes to the center trait.
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 built-in style concept like MapLibre
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 Handlers
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 Handlers
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 Layer Handlers
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 createVectorStyle(styleConfig: Record<string, unknown>): Style {
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
- return new Style({
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 Layer Handlers
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
- // Layer Management Handlers
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
- // Control Handlers
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
- // Navigation Handlers
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
- // Marker Handler
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.layersMap.size}`;
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
- // Create a vector source with a point feature
591
- const featureResult = new GeoJSON().readFeature({
592
- type: 'Feature',
593
- geometry: {
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 features = Array.isArray(featureResult) ? featureResult : [featureResult];
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: 8,
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.layersMap.set(name, vectorLayer);
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
- this.layersMap.clear();
637
- this.controlsMap.clear();
1727
+
1728
+ if (this.mapContainer) {
1729
+ this.mapContainer.remove();
1730
+ this.mapContainer = null;
1731
+ }
638
1732
  }
639
1733
  }