anymap-ts 0.10.1 → 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.
@@ -6,11 +6,8 @@
6
6
  * src/leaflet directory due to tsconfig baseUrl resolution.
7
7
  */
8
8
 
9
- // Import all Leaflet exports as namespace L
10
- // @ts-ignore - Import from explicit ESM path to avoid baseUrl conflict
11
- import * as L from 'leaflet/dist/leaflet-src.esm.js';
9
+ import { L } from './leaflet-setup';
12
10
 
13
- // Re-export types for convenience
14
11
  type LeafletMap = L.Map;
15
12
  type TileLayer = L.TileLayer;
16
13
  type Marker = L.Marker;
@@ -33,6 +30,7 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
33
30
  private markersMap: globalThis.Map<string, Marker> = new globalThis.Map();
34
31
  private popupsMap: globalThis.Map<string, Popup> = new globalThis.Map();
35
32
  private controlsMap: globalThis.Map<string, Control> = new globalThis.Map();
33
+ private layerControl: L.Control.Layers | null = null;
36
34
  private resizeObserver: ResizeObserver | null = null;
37
35
  private resizeDebounceTimer: number | null = null;
38
36
 
@@ -42,40 +40,22 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
42
40
  this.registerMethods();
43
41
  }
44
42
 
45
- /**
46
- * Initialize the Leaflet map.
47
- */
48
43
  async initialize(): Promise<void> {
49
- // Create container
50
44
  this.createMapContainer();
51
-
52
- // Create map
53
45
  this.map = this.createMap();
54
-
55
- // Set up listeners
56
46
  this.setupModelListeners();
57
47
  this.setupMapEvents();
58
-
59
- // Set up resize observer
60
48
  this.setupResizeObserver();
61
-
62
- // Process any JS calls that were made before listeners were set up
63
49
  this.processJsCalls();
64
-
65
- // Mark as ready (Leaflet doesn't have a load event like MapLibre)
66
50
  this.isMapReady = true;
67
51
  this.processPendingCalls();
68
52
  }
69
53
 
70
- /**
71
- * Set up resize observer.
72
- */
73
54
  private setupResizeObserver(): void {
74
55
  if (!this.mapContainer || !this.map) return;
75
56
 
76
57
  this.resizeObserver = new ResizeObserver(() => {
77
58
  if (this.map) {
78
- // Debounce resize to prevent flickering during window resize
79
59
  if (this.resizeDebounceTimer !== null) {
80
60
  window.clearTimeout(this.resizeDebounceTimer);
81
61
  }
@@ -92,31 +72,23 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
92
72
  this.resizeObserver.observe(this.el);
93
73
  }
94
74
 
95
- /**
96
- * Create the Leaflet map instance.
97
- */
98
75
  protected createMap(): LeafletMap {
99
- // Leaflet uses [lat, lng] but we receive [lng, lat] from Python
100
76
  const center = this.model.get('center') as [number, number];
101
77
  const zoom = this.model.get('zoom');
102
78
 
103
79
  const map = L.map(this.mapContainer!, {
104
- center: [center[1], center[0]], // Convert [lng, lat] to [lat, lng]
80
+ center: [center[1], center[0]],
105
81
  zoom,
106
- zoomControl: false, // We'll add controls manually
82
+ zoomControl: false,
107
83
  attributionControl: false,
108
84
  });
109
85
 
110
86
  return map;
111
87
  }
112
88
 
113
- /**
114
- * Set up map event listeners.
115
- */
116
89
  private setupMapEvents(): void {
117
90
  if (!this.map) return;
118
91
 
119
- // Click event
120
92
  this.map.on('click', (e: L.LeafletMouseEvent) => {
121
93
  this.model.set('clicked', {
122
94
  lng: e.latlng.lng,
@@ -130,7 +102,6 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
130
102
  this.model.save_changes();
131
103
  });
132
104
 
133
- // Move end event
134
105
  this.map.on('moveend', () => {
135
106
  if (!this.map) return;
136
107
  const center = this.map.getCenter();
@@ -159,16 +130,12 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
159
130
  });
160
131
  });
161
132
 
162
- // Zoom end event
163
133
  this.map.on('zoomend', () => {
164
134
  if (!this.map) return;
165
135
  this.sendEvent('zoomend', { zoom: this.map.getZoom() });
166
136
  });
167
137
  }
168
138
 
169
- /**
170
- * Register all method handlers.
171
- */
172
139
  private registerMethods(): void {
173
140
  // Map navigation
174
141
  this.registerMethod('setCenter', this.handleSetCenter.bind(this));
@@ -180,6 +147,10 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
180
147
  this.registerMethod('addTileLayer', this.handleAddTileLayer.bind(this));
181
148
  this.registerMethod('removeTileLayer', this.handleRemoveTileLayer.bind(this));
182
149
 
150
+ // WMS layers
151
+ this.registerMethod('addWMSLayer', this.handleAddWMSLayer.bind(this));
152
+ this.registerMethod('removeWMSLayer', this.handleRemoveWMSLayer.bind(this));
153
+
183
154
  // GeoJSON
184
155
  this.registerMethod('addGeoJSON', this.handleAddGeoJSON.bind(this));
185
156
  this.registerMethod('removeGeoJSON', this.handleRemoveGeoJSON.bind(this));
@@ -198,7 +169,34 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
198
169
 
199
170
  // Markers
200
171
  this.registerMethod('addMarker', this.handleAddMarker.bind(this));
172
+ this.registerMethod('addMarkers', this.handleAddMarkers.bind(this));
201
173
  this.registerMethod('removeMarker', this.handleRemoveMarker.bind(this));
174
+
175
+ // Shapes
176
+ this.registerMethod('addCircleMarker', this.handleAddCircleMarker.bind(this));
177
+ this.registerMethod('addCircle', this.handleAddCircle.bind(this));
178
+ this.registerMethod('addPolyline', this.handleAddPolyline.bind(this));
179
+ this.registerMethod('addPolygon', this.handleAddPolygon.bind(this));
180
+ this.registerMethod('addRectangle', this.handleAddRectangle.bind(this));
181
+
182
+ // Overlays
183
+ this.registerMethod('addImageOverlay', this.handleAddImageOverlay.bind(this));
184
+ this.registerMethod('addVideoOverlay', this.handleAddVideoOverlay.bind(this));
185
+
186
+ // Heatmap
187
+ this.registerMethod('addHeatmap', this.handleAddHeatmap.bind(this));
188
+ this.registerMethod('removeHeatmap', this.handleRemoveHeatmap.bind(this));
189
+
190
+ // Choropleth
191
+ this.registerMethod('addChoropleth', this.handleAddChoropleth.bind(this));
192
+
193
+ // Popups & Tooltips
194
+ this.registerMethod('addPopup', this.handleAddPopup.bind(this));
195
+ this.registerMethod('removePopup', this.handleRemovePopup.bind(this));
196
+
197
+ // Legend
198
+ this.registerMethod('addLegend', this.handleAddLegend.bind(this));
199
+ this.registerMethod('removeLegend', this.handleRemoveLegend.bind(this));
202
200
  }
203
201
 
204
202
  // -------------------------------------------------------------------------
@@ -223,9 +221,7 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
223
221
  const zoom = kwargs.zoom as number | undefined;
224
222
  const duration = (kwargs.duration as number) || 2000;
225
223
 
226
- const options = {
227
- duration: duration / 1000, // Leaflet uses seconds
228
- };
224
+ const options = { duration: duration / 1000 };
229
225
 
230
226
  if (zoom !== undefined) {
231
227
  this.map.flyTo([lat, lng], zoom, options);
@@ -241,8 +237,8 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
241
237
  const duration = (kwargs.duration as number) || 1000;
242
238
 
243
239
  const leafletBounds = L.latLngBounds(
244
- [bounds[1], bounds[0]], // Southwest: [lat, lng]
245
- [bounds[3], bounds[2]] // Northeast: [lat, lng]
240
+ [bounds[1], bounds[0]],
241
+ [bounds[3], bounds[2]]
246
242
  );
247
243
 
248
244
  this.map.fitBounds(leafletBounds, {
@@ -263,23 +259,20 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
263
259
  const attribution = (kwargs.attribution as string) || '';
264
260
  const minZoom = (kwargs.minZoom as number) || 0;
265
261
  const maxZoom = (kwargs.maxZoom as number) || 22;
266
- const opacity = (kwargs.opacity as number) || 1;
267
-
268
- const tileLayer = L.tileLayer(url, {
269
- attribution,
270
- minZoom,
271
- maxZoom,
272
- opacity,
273
- });
262
+ const opacity = (kwargs.opacity as number) ?? 1;
274
263
 
264
+ const tileLayer = L.tileLayer(url, { attribution, minZoom, maxZoom, opacity });
275
265
  tileLayer.addTo(this.map);
276
266
  this.layersMap.set(name, tileLayer);
267
+
268
+ if (this.layerControl) {
269
+ this.layerControl.addOverlay(tileLayer, name);
270
+ }
277
271
  }
278
272
 
279
273
  private handleRemoveTileLayer(args: unknown[], kwargs: Record<string, unknown>): void {
280
274
  if (!this.map) return;
281
275
  const [name] = args as [string];
282
-
283
276
  const layer = this.layersMap.get(name);
284
277
  if (layer) {
285
278
  this.map.removeLayer(layer);
@@ -287,6 +280,59 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
287
280
  }
288
281
  }
289
282
 
283
+ // -------------------------------------------------------------------------
284
+ // WMS layer handlers
285
+ // -------------------------------------------------------------------------
286
+
287
+ private handleAddWMSLayer(args: unknown[], kwargs: Record<string, unknown>): void {
288
+ if (!this.map) return;
289
+ const [url] = args as [string];
290
+ const name = (kwargs.name as string) || `wms-${Date.now()}`;
291
+ const layers = (kwargs.layers as string) || '';
292
+ const format = (kwargs.format as string) || 'image/png';
293
+ const transparent = kwargs.transparent !== false;
294
+ const attribution = (kwargs.attribution as string) || '';
295
+ const opacity = (kwargs.opacity as number) ?? 1;
296
+ const crs = kwargs.crs as string | undefined;
297
+ const styles = (kwargs.styles as string) || '';
298
+ const version = (kwargs.version as string) || '1.1.1';
299
+ const uppercase = kwargs.uppercase !== false;
300
+
301
+ const wmsOptions: Record<string, unknown> = {
302
+ layers,
303
+ format,
304
+ transparent,
305
+ attribution,
306
+ opacity,
307
+ styles,
308
+ version,
309
+ uppercase,
310
+ };
311
+
312
+ if (crs) {
313
+ const crsMap: Record<string, L.CRS> = {
314
+ 'EPSG:3857': L.CRS.EPSG3857,
315
+ 'EPSG:4326': L.CRS.EPSG4326,
316
+ 'EPSG:3395': L.CRS.EPSG3395,
317
+ };
318
+ if (crsMap[crs]) {
319
+ wmsOptions.crs = crsMap[crs];
320
+ }
321
+ }
322
+
323
+ const wmsLayer = L.tileLayer.wms(url, wmsOptions as any);
324
+ wmsLayer.addTo(this.map);
325
+ this.layersMap.set(name, wmsLayer);
326
+
327
+ if (this.layerControl) {
328
+ this.layerControl.addOverlay(wmsLayer, name);
329
+ }
330
+ }
331
+
332
+ private handleRemoveWMSLayer(args: unknown[], kwargs: Record<string, unknown>): void {
333
+ this.handleRemoveLayer(args, kwargs);
334
+ }
335
+
290
336
  // -------------------------------------------------------------------------
291
337
  // Basemap handlers
292
338
  // -------------------------------------------------------------------------
@@ -297,21 +343,19 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
297
343
  const name = (kwargs.name as string) || 'basemap';
298
344
  const attribution = (kwargs.attribution as string) || '';
299
345
 
300
- // Remove existing basemap with same name
301
346
  const existingLayer = this.layersMap.get(`basemap-${name}`);
302
347
  if (existingLayer) {
303
348
  this.map.removeLayer(existingLayer);
304
349
  }
305
350
 
306
- const tileLayer = L.tileLayer(url, {
307
- attribution,
308
- maxZoom: 22,
309
- });
310
-
311
- // Add to bottom of layer stack
351
+ const tileLayer = L.tileLayer(url, { attribution, maxZoom: 22 });
312
352
  tileLayer.addTo(this.map);
313
353
  tileLayer.bringToBack();
314
354
  this.layersMap.set(`basemap-${name}`, tileLayer);
355
+
356
+ if (this.layerControl) {
357
+ this.layerControl.addBaseLayer(tileLayer, name);
358
+ }
315
359
  }
316
360
 
317
361
  // -------------------------------------------------------------------------
@@ -325,14 +369,12 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
325
369
  const name = kwargs.name as string;
326
370
  const style = kwargs.style as Record<string, unknown> | undefined;
327
371
  const fitBounds = kwargs.fitBounds !== false;
372
+ const popupProperties = kwargs.popupProperties as string[] | boolean | undefined;
373
+ const tooltipProperty = kwargs.tooltipProperty as string | undefined;
328
374
 
329
- // Create GeoJSON layer
330
375
  const geoJsonLayer = L.geoJSON(geojson as any, {
331
376
  style: (feature: GeoJSON.Feature | undefined) => {
332
- if (style) {
333
- return style;
334
- }
335
- // Default styles based on geometry type
377
+ if (style) return style;
336
378
  const geomType = feature?.geometry?.type || 'Point';
337
379
  return this.getDefaultStyle(geomType);
338
380
  },
@@ -340,12 +382,34 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
340
382
  const s = style || this.getDefaultStyle('Point');
341
383
  return L.circleMarker(latlng, s as any);
342
384
  },
385
+ onEachFeature: (feature: GeoJSON.Feature, layer: L.Layer) => {
386
+ const props = feature.properties || {};
387
+
388
+ if (popupProperties) {
389
+ let html = '<div class="anymap-popup">';
390
+ const keys = popupProperties === true ? Object.keys(props) : popupProperties;
391
+ for (const key of keys) {
392
+ if (props[key] !== undefined && props[key] !== null) {
393
+ html += `<b>${key}:</b> ${props[key]}<br>`;
394
+ }
395
+ }
396
+ html += '</div>';
397
+ layer.bindPopup(html);
398
+ }
399
+
400
+ if (tooltipProperty && props[tooltipProperty] !== undefined) {
401
+ layer.bindTooltip(String(props[tooltipProperty]), { sticky: true });
402
+ }
403
+ },
343
404
  });
344
405
 
345
406
  geoJsonLayer.addTo(this.map);
346
407
  this.layersMap.set(name, geoJsonLayer);
347
408
 
348
- // Fit bounds
409
+ if (this.layerControl) {
410
+ this.layerControl.addOverlay(geoJsonLayer, name);
411
+ }
412
+
349
413
  if (fitBounds && kwargs.bounds) {
350
414
  const bounds = kwargs.bounds as [number, number, number, number];
351
415
  const leafletBounds = L.latLngBounds(
@@ -364,7 +428,6 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
364
428
  private handleRemoveGeoJSON(args: unknown[], kwargs: Record<string, unknown>): void {
365
429
  if (!this.map) return;
366
430
  const [name] = args as [string];
367
-
368
431
  const layer = this.layersMap.get(name);
369
432
  if (layer) {
370
433
  this.map.removeLayer(layer);
@@ -374,46 +437,12 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
374
437
 
375
438
  private getDefaultStyle(geometryType: string): Record<string, unknown> {
376
439
  const defaults: Record<string, Record<string, unknown>> = {
377
- Point: {
378
- radius: 8,
379
- fillColor: '#3388ff',
380
- color: '#ffffff',
381
- weight: 2,
382
- opacity: 1,
383
- fillOpacity: 0.8,
384
- },
385
- MultiPoint: {
386
- radius: 8,
387
- fillColor: '#3388ff',
388
- color: '#ffffff',
389
- weight: 2,
390
- opacity: 1,
391
- fillOpacity: 0.8,
392
- },
393
- LineString: {
394
- color: '#3388ff',
395
- weight: 3,
396
- opacity: 0.8,
397
- },
398
- MultiLineString: {
399
- color: '#3388ff',
400
- weight: 3,
401
- opacity: 0.8,
402
- },
403
- Polygon: {
404
- fillColor: '#3388ff',
405
- color: '#0000ff',
406
- weight: 2,
407
- opacity: 1,
408
- fillOpacity: 0.5,
409
- },
410
- MultiPolygon: {
411
- fillColor: '#3388ff',
412
- color: '#0000ff',
413
- weight: 2,
414
- opacity: 1,
415
- fillOpacity: 0.5,
416
- },
440
+ Point: { radius: 8, fillColor: '#3388ff', color: '#ffffff', weight: 2, opacity: 1, fillOpacity: 0.8 },
441
+ MultiPoint: { radius: 8, fillColor: '#3388ff', color: '#ffffff', weight: 2, opacity: 1, fillOpacity: 0.8 },
442
+ LineString: { color: '#3388ff', weight: 3, opacity: 0.8 },
443
+ MultiLineString: { color: '#3388ff', weight: 3, opacity: 0.8 },
444
+ Polygon: { fillColor: '#3388ff', color: '#0000ff', weight: 2, opacity: 1, fillOpacity: 0.5 },
445
+ MultiPolygon: { fillColor: '#3388ff', color: '#0000ff', weight: 2, opacity: 1, fillOpacity: 0.5 },
417
446
  };
418
447
  return defaults[geometryType] || defaults.Point;
419
448
  }
@@ -428,6 +457,9 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
428
457
 
429
458
  const layer = this.layersMap.get(layerId);
430
459
  if (layer) {
460
+ if (this.layerControl) {
461
+ this.layerControl.removeLayer(layer);
462
+ }
431
463
  this.map.removeLayer(layer);
432
464
  this.layersMap.delete(layerId);
433
465
  }
@@ -482,8 +514,7 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
482
514
  case 'attribution':
483
515
  control = L.control.attribution({ position });
484
516
  break;
485
- case 'layers':
486
- // Layer control needs baseLayers and overlays
517
+ case 'layers': {
487
518
  const baseLayers: Record<string, TileLayer> = {};
488
519
  const overlays: Record<string, L.Layer> = {};
489
520
  this.layersMap.forEach((layer, name) => {
@@ -493,8 +524,13 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
493
524
  overlays[name] = layer;
494
525
  }
495
526
  });
496
- control = L.control.layers(baseLayers, overlays, { position, collapsed: kwargs.collapsed !== false });
527
+ const layersControl = L.control.layers(baseLayers, overlays, {
528
+ position, collapsed: kwargs.collapsed !== false,
529
+ });
530
+ this.layerControl = layersControl;
531
+ control = layersControl;
497
532
  break;
533
+ }
498
534
  }
499
535
 
500
536
  if (control) {
@@ -513,6 +549,9 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
513
549
  this.map.removeControl(control);
514
550
  this.controlsMap.delete(controlType);
515
551
  this.stateManager.removeControl(controlType);
552
+ if (controlType === 'layers') {
553
+ this.layerControl = null;
554
+ }
516
555
  }
517
556
  }
518
557
 
@@ -537,19 +576,84 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
537
576
  private handleAddMarker(args: unknown[], kwargs: Record<string, unknown>): void {
538
577
  if (!this.map) return;
539
578
  const [lng, lat] = args as [number, number];
540
- const id = (kwargs.id as string) || `marker-${Date.now()}`;
579
+ const id = (kwargs.id as string) || `marker-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
541
580
  const popup = kwargs.popup as string | undefined;
581
+ const tooltip = kwargs.tooltip as string | undefined;
582
+ const draggable = kwargs.draggable as boolean | undefined;
583
+ const opacity = kwargs.opacity as number | undefined;
584
+ const iconUrl = kwargs.iconUrl as string | undefined;
585
+ const iconSize = kwargs.iconSize as [number, number] | undefined;
586
+ const iconAnchor = kwargs.iconAnchor as [number, number] | undefined;
587
+
588
+ const markerOptions: L.MarkerOptions = {};
589
+
590
+ if (draggable) markerOptions.draggable = true;
591
+ if (opacity !== undefined) markerOptions.opacity = opacity;
592
+
593
+ if (iconUrl) {
594
+ markerOptions.icon = L.icon({
595
+ iconUrl,
596
+ iconSize: iconSize || [25, 41],
597
+ iconAnchor: iconAnchor || [12, 41],
598
+ popupAnchor: [1, -34],
599
+ });
600
+ }
542
601
 
543
- const marker = L.marker([lat, lng]);
602
+ const marker = L.marker([lat, lng], markerOptions);
544
603
 
545
- if (popup) {
546
- marker.bindPopup(popup);
604
+ if (popup) marker.bindPopup(popup);
605
+ if (tooltip) marker.bindTooltip(tooltip);
606
+
607
+ if (draggable) {
608
+ marker.on('dragend', () => {
609
+ const pos = marker.getLatLng();
610
+ this.sendEvent('marker_dragend', { id, lngLat: [pos.lng, pos.lat] });
611
+ });
547
612
  }
548
613
 
549
614
  marker.addTo(this.map);
550
615
  this.markersMap.set(id, marker);
551
616
  }
552
617
 
618
+ private handleAddMarkers(args: unknown[], kwargs: Record<string, unknown>): void {
619
+ if (!this.map) return;
620
+ const data = kwargs.data as Array<Record<string, unknown>>;
621
+ const name = (kwargs.name as string) || `markers-${Date.now()}`;
622
+
623
+ const markerGroup = L.layerGroup();
624
+
625
+ for (const item of data) {
626
+ const lng = item.lng as number;
627
+ const lat = item.lat as number;
628
+ const popup = item.popup as string | undefined;
629
+ const tooltip = item.tooltip as string | undefined;
630
+ const iconUrl = item.iconUrl as string | undefined;
631
+ const iconSize = item.iconSize as [number, number] | undefined;
632
+
633
+ const opts: L.MarkerOptions = {};
634
+ if (iconUrl) {
635
+ opts.icon = L.icon({
636
+ iconUrl,
637
+ iconSize: iconSize || [25, 41],
638
+ iconAnchor: [12, 41],
639
+ popupAnchor: [1, -34],
640
+ });
641
+ }
642
+
643
+ const marker = L.marker([lat, lng], opts);
644
+ if (popup) marker.bindPopup(popup);
645
+ if (tooltip) marker.bindTooltip(tooltip);
646
+ markerGroup.addLayer(marker);
647
+ }
648
+
649
+ markerGroup.addTo(this.map);
650
+ this.layersMap.set(name, markerGroup);
651
+
652
+ if (this.layerControl) {
653
+ this.layerControl.addOverlay(markerGroup, name);
654
+ }
655
+ }
656
+
553
657
  private handleRemoveMarker(args: unknown[], kwargs: Record<string, unknown>): void {
554
658
  if (!this.map) return;
555
659
  const [id] = args as [string];
@@ -561,6 +665,473 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
561
665
  }
562
666
  }
563
667
 
668
+ // -------------------------------------------------------------------------
669
+ // Shape handlers (circles, polylines, polygons, rectangles)
670
+ // -------------------------------------------------------------------------
671
+
672
+ private handleAddCircleMarker(args: unknown[], kwargs: Record<string, unknown>): void {
673
+ if (!this.map) return;
674
+ const [lng, lat] = args as [number, number];
675
+ const name = (kwargs.name as string) || `circle-marker-${Date.now()}`;
676
+ const radius = (kwargs.radius as number) || 10;
677
+ const color = (kwargs.color as string) || '#3388ff';
678
+ const fillColor = (kwargs.fillColor as string) || color;
679
+ const fillOpacity = (kwargs.fillOpacity as number) ?? 0.5;
680
+ const weight = (kwargs.weight as number) || 2;
681
+ const opacity = (kwargs.opacity as number) ?? 1;
682
+ const popup = kwargs.popup as string | undefined;
683
+ const tooltip = kwargs.tooltip as string | undefined;
684
+
685
+ const cm = L.circleMarker([lat, lng], {
686
+ radius, color, fillColor, fillOpacity, weight, opacity,
687
+ });
688
+
689
+ if (popup) cm.bindPopup(popup);
690
+ if (tooltip) cm.bindTooltip(tooltip);
691
+
692
+ cm.addTo(this.map);
693
+ this.layersMap.set(name, cm);
694
+
695
+ if (this.layerControl) {
696
+ this.layerControl.addOverlay(cm, name);
697
+ }
698
+ }
699
+
700
+ private handleAddCircle(args: unknown[], kwargs: Record<string, unknown>): void {
701
+ if (!this.map) return;
702
+ const [lng, lat] = args as [number, number];
703
+ const name = (kwargs.name as string) || `circle-${Date.now()}`;
704
+ const radius = (kwargs.radius as number) || 1000;
705
+ const color = (kwargs.color as string) || '#3388ff';
706
+ const fillColor = (kwargs.fillColor as string) || color;
707
+ const fillOpacity = (kwargs.fillOpacity as number) ?? 0.2;
708
+ const weight = (kwargs.weight as number) || 2;
709
+ const opacity = (kwargs.opacity as number) ?? 1;
710
+ const popup = kwargs.popup as string | undefined;
711
+ const tooltip = kwargs.tooltip as string | undefined;
712
+
713
+ const circle = L.circle([lat, lng], {
714
+ radius, color, fillColor, fillOpacity, weight, opacity,
715
+ });
716
+
717
+ if (popup) circle.bindPopup(popup);
718
+ if (tooltip) circle.bindTooltip(tooltip);
719
+
720
+ circle.addTo(this.map);
721
+ this.layersMap.set(name, circle);
722
+
723
+ if (this.layerControl) {
724
+ this.layerControl.addOverlay(circle, name);
725
+ }
726
+ }
727
+
728
+ private handleAddPolyline(args: unknown[], kwargs: Record<string, unknown>): void {
729
+ if (!this.map) return;
730
+ const coords = kwargs.coordinates as number[][];
731
+ const name = (kwargs.name as string) || `polyline-${Date.now()}`;
732
+ const color = (kwargs.color as string) || '#3388ff';
733
+ const weight = (kwargs.weight as number) || 3;
734
+ const opacity = (kwargs.opacity as number) ?? 1;
735
+ const dashArray = kwargs.dashArray as string | undefined;
736
+ const popup = kwargs.popup as string | undefined;
737
+ const tooltip = kwargs.tooltip as string | undefined;
738
+ const fitBounds = kwargs.fitBounds as boolean | undefined;
739
+
740
+ const latLngs = coords.map(([lng, lat]) => [lat, lng] as [number, number]);
741
+ const opts: L.PolylineOptions = { color, weight, opacity };
742
+ if (dashArray) opts.dashArray = dashArray;
743
+
744
+ const polyline = L.polyline(latLngs, opts);
745
+
746
+ if (popup) polyline.bindPopup(popup);
747
+ if (tooltip) polyline.bindTooltip(tooltip);
748
+
749
+ polyline.addTo(this.map);
750
+ this.layersMap.set(name, polyline);
751
+
752
+ if (fitBounds) {
753
+ this.map.fitBounds(polyline.getBounds(), { padding: [50, 50] });
754
+ }
755
+
756
+ if (this.layerControl) {
757
+ this.layerControl.addOverlay(polyline, name);
758
+ }
759
+ }
760
+
761
+ private handleAddPolygon(args: unknown[], kwargs: Record<string, unknown>): void {
762
+ if (!this.map) return;
763
+ const coords = kwargs.coordinates as number[][];
764
+ const name = (kwargs.name as string) || `polygon-${Date.now()}`;
765
+ const color = (kwargs.color as string) || '#3388ff';
766
+ const fillColor = (kwargs.fillColor as string) || color;
767
+ const fillOpacity = (kwargs.fillOpacity as number) ?? 0.5;
768
+ const weight = (kwargs.weight as number) || 2;
769
+ const opacity = (kwargs.opacity as number) ?? 1;
770
+ const popup = kwargs.popup as string | undefined;
771
+ const tooltip = kwargs.tooltip as string | undefined;
772
+ const fitBounds = kwargs.fitBounds as boolean | undefined;
773
+
774
+ const latLngs = coords.map(([lng, lat]) => [lat, lng] as [number, number]);
775
+
776
+ const polygon = L.polygon(latLngs, { color, fillColor, fillOpacity, weight, opacity });
777
+
778
+ if (popup) polygon.bindPopup(popup);
779
+ if (tooltip) polygon.bindTooltip(tooltip);
780
+
781
+ polygon.addTo(this.map);
782
+ this.layersMap.set(name, polygon);
783
+
784
+ if (fitBounds) {
785
+ this.map.fitBounds(polygon.getBounds(), { padding: [50, 50] });
786
+ }
787
+
788
+ if (this.layerControl) {
789
+ this.layerControl.addOverlay(polygon, name);
790
+ }
791
+ }
792
+
793
+ private handleAddRectangle(args: unknown[], kwargs: Record<string, unknown>): void {
794
+ if (!this.map) return;
795
+ const bounds = kwargs.bounds as [number, number, number, number];
796
+ const name = (kwargs.name as string) || `rectangle-${Date.now()}`;
797
+ const color = (kwargs.color as string) || '#3388ff';
798
+ const fillColor = (kwargs.fillColor as string) || color;
799
+ const fillOpacity = (kwargs.fillOpacity as number) ?? 0.2;
800
+ const weight = (kwargs.weight as number) || 2;
801
+ const opacity = (kwargs.opacity as number) ?? 1;
802
+ const popup = kwargs.popup as string | undefined;
803
+ const tooltip = kwargs.tooltip as string | undefined;
804
+
805
+ const leafletBounds = L.latLngBounds(
806
+ [bounds[1], bounds[0]],
807
+ [bounds[3], bounds[2]]
808
+ );
809
+
810
+ const rect = L.rectangle(leafletBounds, { color, fillColor, fillOpacity, weight, opacity });
811
+
812
+ if (popup) rect.bindPopup(popup);
813
+ if (tooltip) rect.bindTooltip(tooltip);
814
+
815
+ rect.addTo(this.map);
816
+ this.layersMap.set(name, rect);
817
+
818
+ if (this.layerControl) {
819
+ this.layerControl.addOverlay(rect, name);
820
+ }
821
+ }
822
+
823
+ // -------------------------------------------------------------------------
824
+ // Image & Video overlay handlers
825
+ // -------------------------------------------------------------------------
826
+
827
+ private handleAddImageOverlay(args: unknown[], kwargs: Record<string, unknown>): void {
828
+ if (!this.map) return;
829
+ const [url] = args as [string];
830
+ const bounds = kwargs.bounds as [number, number, number, number];
831
+ const name = (kwargs.name as string) || `image-${Date.now()}`;
832
+ const opacity = (kwargs.opacity as number) ?? 1;
833
+ const interactive = kwargs.interactive as boolean | undefined;
834
+
835
+ const leafletBounds = L.latLngBounds(
836
+ [bounds[1], bounds[0]],
837
+ [bounds[3], bounds[2]]
838
+ );
839
+
840
+ const imageOverlay = L.imageOverlay(url, leafletBounds, {
841
+ opacity,
842
+ interactive: interactive || false,
843
+ });
844
+
845
+ imageOverlay.addTo(this.map);
846
+ this.layersMap.set(name, imageOverlay);
847
+
848
+ if (this.layerControl) {
849
+ this.layerControl.addOverlay(imageOverlay, name);
850
+ }
851
+ }
852
+
853
+ private handleAddVideoOverlay(args: unknown[], kwargs: Record<string, unknown>): void {
854
+ if (!this.map) return;
855
+ const urls = kwargs.url as string | string[];
856
+ const bounds = kwargs.bounds as [number, number, number, number];
857
+ const name = (kwargs.name as string) || `video-${Date.now()}`;
858
+ const opacity = (kwargs.opacity as number) ?? 1;
859
+ const autoplay = kwargs.autoplay !== false;
860
+ const loop = kwargs.loop !== false;
861
+ const muted = kwargs.muted !== false;
862
+
863
+ const leafletBounds = L.latLngBounds(
864
+ [bounds[1], bounds[0]],
865
+ [bounds[3], bounds[2]]
866
+ );
867
+
868
+ const videoOverlay = L.videoOverlay(urls as string, leafletBounds, {
869
+ opacity,
870
+ autoplay,
871
+ loop,
872
+ muted,
873
+ });
874
+
875
+ videoOverlay.addTo(this.map);
876
+ this.layersMap.set(name, videoOverlay);
877
+
878
+ if (this.layerControl) {
879
+ this.layerControl.addOverlay(videoOverlay, name);
880
+ }
881
+ }
882
+
883
+ // -------------------------------------------------------------------------
884
+ // Heatmap handlers (via leaflet.heat)
885
+ // -------------------------------------------------------------------------
886
+
887
+ private handleAddHeatmap(args: unknown[], kwargs: Record<string, unknown>): void {
888
+ if (!this.map) return;
889
+ const data = kwargs.data as number[][];
890
+ const name = (kwargs.name as string) || `heatmap-${Date.now()}`;
891
+ const radius = (kwargs.radius as number) || 25;
892
+ const blur = (kwargs.blur as number) || 15;
893
+ const maxZoom = (kwargs.maxZoom as number) || 18;
894
+ const max = (kwargs.max as number) || 1.0;
895
+ const minOpacity = (kwargs.minOpacity as number) || 0.05;
896
+ const gradient = kwargs.gradient as Record<string, string> | undefined;
897
+
898
+ // leaflet.heat expects [lat, lng, intensity] arrays
899
+ const heatData = data.map((point) => {
900
+ if (point.length >= 3) {
901
+ return [point[1], point[0], point[2]];
902
+ }
903
+ return [point[1], point[0]];
904
+ });
905
+
906
+ const options: Record<string, unknown> = {
907
+ radius,
908
+ blur,
909
+ maxZoom,
910
+ max,
911
+ minOpacity,
912
+ };
913
+ if (gradient) options.gradient = gradient;
914
+
915
+ // @ts-ignore - L.heatLayer is added by leaflet.heat plugin
916
+ const heatLayer = (L as any).heatLayer(heatData, options);
917
+ heatLayer.addTo(this.map);
918
+ this.layersMap.set(name, heatLayer);
919
+
920
+ if (this.layerControl) {
921
+ this.layerControl.addOverlay(heatLayer, name);
922
+ }
923
+ }
924
+
925
+ private handleRemoveHeatmap(args: unknown[], kwargs: Record<string, unknown>): void {
926
+ this.handleRemoveLayer(args, kwargs);
927
+ }
928
+
929
+ // -------------------------------------------------------------------------
930
+ // Choropleth handler
931
+ // -------------------------------------------------------------------------
932
+
933
+ private handleAddChoropleth(args: unknown[], kwargs: Record<string, unknown>): void {
934
+ if (!this.map) return;
935
+
936
+ const geojson = kwargs.data as FeatureCollection;
937
+ const name = (kwargs.name as string) || `choropleth-${Date.now()}`;
938
+ const valueProperty = kwargs.valueProperty as string;
939
+ const colors = kwargs.colors as string[];
940
+ const thresholds = kwargs.thresholds as number[];
941
+ const fillOpacity = (kwargs.fillOpacity as number) ?? 0.7;
942
+ const lineColor = (kwargs.lineColor as string) || '#ffffff';
943
+ const lineWeight = (kwargs.lineWeight as number) || 2;
944
+ const lineOpacity = (kwargs.lineOpacity as number) ?? 1;
945
+ const popupProperties = kwargs.popupProperties as string[] | boolean | undefined;
946
+ const tooltipProperty = kwargs.tooltipProperty as string | undefined;
947
+ const fitBounds = kwargs.fitBounds !== false;
948
+ const legendTitle = kwargs.legendTitle as string | undefined;
949
+ const legendPosition = kwargs.legendPosition as string | undefined;
950
+
951
+ const getColor = (value: number): string => {
952
+ for (let i = thresholds.length - 1; i >= 0; i--) {
953
+ if (value >= thresholds[i]) return colors[i + 1] || colors[colors.length - 1];
954
+ }
955
+ return colors[0];
956
+ };
957
+
958
+ const choroplethLayer = L.geoJSON(geojson as any, {
959
+ style: (feature: any) => {
960
+ const value = feature?.properties?.[valueProperty] ?? 0;
961
+ return {
962
+ fillColor: getColor(value),
963
+ weight: lineWeight,
964
+ opacity: lineOpacity,
965
+ color: lineColor,
966
+ fillOpacity,
967
+ };
968
+ },
969
+ onEachFeature: (feature: any, layer: L.Layer) => {
970
+ const props = feature.properties || {};
971
+
972
+ if (popupProperties) {
973
+ let html = '<div class="anymap-popup">';
974
+ const keys = popupProperties === true ? Object.keys(props) : popupProperties;
975
+ for (const key of keys) {
976
+ if (props[key] !== undefined && props[key] !== null) {
977
+ html += `<b>${key}:</b> ${props[key]}<br>`;
978
+ }
979
+ }
980
+ html += '</div>';
981
+ layer.bindPopup(html);
982
+ }
983
+
984
+ if (tooltipProperty && props[tooltipProperty] !== undefined) {
985
+ layer.bindTooltip(String(props[tooltipProperty]), { sticky: true });
986
+ }
987
+
988
+ // Highlight on hover
989
+ (layer as any).on({
990
+ mouseover: (e: any) => {
991
+ const target = e.target;
992
+ target.setStyle({
993
+ weight: lineWeight + 2,
994
+ fillOpacity: Math.min(fillOpacity + 0.2, 1),
995
+ });
996
+ target.bringToFront();
997
+ },
998
+ mouseout: (e: any) => {
999
+ choroplethLayer.resetStyle(e.target);
1000
+ },
1001
+ });
1002
+ },
1003
+ });
1004
+
1005
+ choroplethLayer.addTo(this.map);
1006
+ this.layersMap.set(name, choroplethLayer);
1007
+
1008
+ if (fitBounds) {
1009
+ const layerBounds = choroplethLayer.getBounds();
1010
+ if (layerBounds.isValid()) {
1011
+ this.map.fitBounds(layerBounds, { padding: [50, 50] });
1012
+ }
1013
+ }
1014
+
1015
+ if (this.layerControl) {
1016
+ this.layerControl.addOverlay(choroplethLayer, name);
1017
+ }
1018
+
1019
+ if (legendTitle && colors && thresholds) {
1020
+ this.createChoroplethLegend(name, legendTitle, colors, thresholds, legendPosition || 'bottomright');
1021
+ }
1022
+ }
1023
+
1024
+ private createChoroplethLegend(
1025
+ name: string,
1026
+ title: string,
1027
+ colors: string[],
1028
+ thresholds: number[],
1029
+ position: string,
1030
+ ): void {
1031
+ if (!this.map) return;
1032
+
1033
+ const LegendControl = L.Control.extend({
1034
+ options: { position: this.convertPosition(position) },
1035
+ onAdd: () => {
1036
+ const div = L.DomUtil.create('div', 'anymap-legend');
1037
+
1038
+ let html = `<div style="font-weight:bold;margin-bottom:6px">${title}</div>`;
1039
+ const numBins = thresholds.length + 1;
1040
+ for (let i = 0; i < numBins; i++) {
1041
+ const color = colors[i] || colors[colors.length - 1];
1042
+ const label = i === 0
1043
+ ? `< ${thresholds[0]}`
1044
+ : i < thresholds.length
1045
+ ? `${thresholds[i - 1]} – ${thresholds[i]}`
1046
+ : `≥ ${thresholds[thresholds.length - 1]}`;
1047
+ html += `<div style="display:flex;align-items:center;margin:2px 0"><span style="width:18px;height:12px;display:inline-block;background:${color};margin-right:6px;border-radius:2px"></span>${label}</div>`;
1048
+ }
1049
+ div.innerHTML = html;
1050
+ return div;
1051
+ },
1052
+ });
1053
+
1054
+ const legend = new LegendControl();
1055
+ legend.addTo(this.map);
1056
+ this.controlsMap.set(`legend-${name}`, legend);
1057
+ }
1058
+
1059
+ // -------------------------------------------------------------------------
1060
+ // Popup handlers
1061
+ // -------------------------------------------------------------------------
1062
+
1063
+ private handleAddPopup(args: unknown[], kwargs: Record<string, unknown>): void {
1064
+ if (!this.map) return;
1065
+ const [lng, lat] = args as [number, number];
1066
+ const content = kwargs.content as string;
1067
+ const id = (kwargs.id as string) || `popup-${Date.now()}`;
1068
+ const maxWidth = (kwargs.maxWidth as number) || 300;
1069
+ const closeButton = kwargs.closeButton !== false;
1070
+
1071
+ const popup = L.popup({
1072
+ maxWidth,
1073
+ closeButton,
1074
+ })
1075
+ .setLatLng([lat, lng])
1076
+ .setContent(content)
1077
+ .openOn(this.map);
1078
+
1079
+ this.popupsMap.set(id, popup);
1080
+ }
1081
+
1082
+ private handleRemovePopup(args: unknown[], kwargs: Record<string, unknown>): void {
1083
+ if (!this.map) return;
1084
+ const [id] = args as [string];
1085
+
1086
+ const popup = this.popupsMap.get(id);
1087
+ if (popup) {
1088
+ this.map.closePopup(popup);
1089
+ this.popupsMap.delete(id);
1090
+ }
1091
+ }
1092
+
1093
+ // -------------------------------------------------------------------------
1094
+ // Legend handlers
1095
+ // -------------------------------------------------------------------------
1096
+
1097
+ private handleAddLegend(args: unknown[], kwargs: Record<string, unknown>): void {
1098
+ if (!this.map) return;
1099
+ const name = (kwargs.name as string) || 'legend';
1100
+ const title = kwargs.title as string;
1101
+ const items = kwargs.items as Array<{ color: string; label: string }>;
1102
+ const position = (kwargs.position as string) || 'bottomright';
1103
+
1104
+ const LegendControl = L.Control.extend({
1105
+ options: { position: this.convertPosition(position) },
1106
+ onAdd: () => {
1107
+ const div = L.DomUtil.create('div', 'anymap-legend');
1108
+
1109
+ let html = title ? `<div style="font-weight:bold;margin-bottom:6px">${title}</div>` : '';
1110
+ for (const item of items) {
1111
+ html += `<div style="display:flex;align-items:center;margin:2px 0"><span style="width:18px;height:12px;display:inline-block;background:${item.color};margin-right:6px;border-radius:2px"></span>${item.label}</div>`;
1112
+ }
1113
+ div.innerHTML = html;
1114
+ return div;
1115
+ },
1116
+ });
1117
+
1118
+ const legend = new LegendControl();
1119
+ legend.addTo(this.map);
1120
+ this.controlsMap.set(`legend-${name}`, legend);
1121
+ }
1122
+
1123
+ private handleRemoveLegend(args: unknown[], kwargs: Record<string, unknown>): void {
1124
+ if (!this.map) return;
1125
+ const [name] = args as [string];
1126
+ const key = `legend-${name}`;
1127
+
1128
+ const control = this.controlsMap.get(key);
1129
+ if (control) {
1130
+ this.map.removeControl(control);
1131
+ this.controlsMap.delete(key);
1132
+ }
1133
+ }
1134
+
564
1135
  // -------------------------------------------------------------------------
565
1136
  // Trait change handlers
566
1137
  // -------------------------------------------------------------------------
@@ -581,7 +1152,6 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
581
1152
 
582
1153
  protected onStyleChange(): void {
583
1154
  // Leaflet doesn't have a style concept like MapLibre/Mapbox
584
- // Style changes would need to be handled per-layer
585
1155
  }
586
1156
 
587
1157
  // -------------------------------------------------------------------------
@@ -620,6 +1190,7 @@ export class LeafletRenderer extends BaseMapRenderer<LeafletMap> {
620
1190
  }
621
1191
  });
622
1192
  this.controlsMap.clear();
1193
+ this.layerControl = null;
623
1194
 
624
1195
  if (this.map) {
625
1196
  this.map.remove();