anymap-ts 0.9.0 → 0.10.1

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.
@@ -13,21 +13,65 @@ import mapboxgl, {
13
13
  Marker,
14
14
  Popup,
15
15
  } from 'mapbox-gl';
16
+ import MapboxDraw from '@mapbox/mapbox-gl-draw';
17
+ import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
16
18
  import { MapboxOverlay } from '@deck.gl/mapbox';
17
- import { ArcLayer, PointCloudLayer } from '@deck.gl/layers';
19
+ import {
20
+ ArcLayer,
21
+ PointCloudLayer,
22
+ ScatterplotLayer,
23
+ PathLayer,
24
+ PolygonLayer,
25
+ IconLayer,
26
+ TextLayer,
27
+ GeoJsonLayer,
28
+ LineLayer,
29
+ BitmapLayer,
30
+ ColumnLayer,
31
+ GridCellLayer,
32
+ SolidPolygonLayer,
33
+ } from '@deck.gl/layers';
34
+ import {
35
+ HexagonLayer,
36
+ HeatmapLayer,
37
+ GridLayer,
38
+ ContourLayer,
39
+ ScreenGridLayer,
40
+ } from '@deck.gl/aggregation-layers';
41
+ import { TripsLayer } from '@deck.gl/geo-layers';
42
+ import along from '@turf/along';
43
+ import length from '@turf/length';
44
+ import { lineString } from '@turf/helpers';
18
45
  import { COGLayer, proj } from '@developmentseed/deck.gl-geotiff';
19
46
  import { toProj4 } from 'geotiff-geokeys-to-proj4';
20
- import { BaseMapRenderer, MethodHandler } from '../core/BaseMapRenderer';
47
+ import { ZarrLayer } from '@carbonplan/zarr-layer';
48
+ import { BaseMapRenderer } from '../core/BaseMapRenderer';
21
49
  import { StateManager } from '../core/StateManager';
22
50
  import type { MapWidgetModel } from '../types/anywidget';
23
51
  import type {
24
52
  ControlPosition,
25
53
  FlyToOptions,
26
54
  FitBoundsOptions,
55
+ LayerConfig,
56
+ SourceConfig,
27
57
  } from '../types/mapbox';
28
58
  import type { Feature, FeatureCollection } from 'geojson';
29
59
  import { LidarControl } from 'maplibre-gl-lidar';
30
- import type { LidarControlOptions, LidarLayerOptions, LidarColorScheme } from '../types/lidar';
60
+ import type { LidarControlOptions, LidarColorScheme } from '../types/lidar';
61
+ import {
62
+ PMTilesLayerControl,
63
+ CogLayerControl,
64
+ ZarrLayerControl,
65
+ AddVectorControl,
66
+ addControlGrid,
67
+ Colorbar,
68
+ SearchControl,
69
+ MeasureControl,
70
+ PrintControl,
71
+ } from 'maplibre-gl-components';
72
+ import type { ControlGrid } from 'maplibre-gl-components';
73
+ import 'maplibre-gl-components/style.css';
74
+ import { geojson as flatgeobuf } from 'flatgeobuf';
31
75
 
32
76
  /**
33
77
  * Parse GeoKeys to proj4 definition for COG reprojection.
@@ -52,16 +96,78 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
52
96
  private markersMap: globalThis.Map<string, Marker> = new globalThis.Map();
53
97
  private popupsMap: globalThis.Map<string, Popup> = new globalThis.Map();
54
98
  private controlsMap: globalThis.Map<string, mapboxgl.IControl> = new globalThis.Map();
99
+ private legendsMap: globalThis.Map<string, HTMLElement> = new globalThis.Map();
55
100
  private resizeObserver: ResizeObserver | null = null;
56
101
  private resizeDebounceTimer: number | null = null;
57
102
 
58
103
  // Deck.gl overlay for COG layers
59
- private deckOverlay: MapboxOverlay | null = null;
60
- private deckLayers: globalThis.Map<string, unknown> = new globalThis.Map();
104
+ protected deckOverlay: MapboxOverlay | null = null;
105
+ protected deckLayers: globalThis.Map<string, unknown> = new globalThis.Map();
106
+
107
+ // Zarr layers
108
+ protected zarrLayers: globalThis.Map<string, ZarrLayer> = new globalThis.Map();
109
+
110
+ // Draw control (Mapbox Draw)
111
+ private mapboxDraw: MapboxDraw | null = null;
112
+
113
+ // maplibre-gl-components controls
114
+ protected pmtilesLayerControl: PMTilesLayerControl | null = null;
115
+ protected cogLayerUiControl: CogLayerControl | null = null;
116
+ protected zarrLayerUiControl: ZarrLayerControl | null = null;
117
+ protected addVectorControl: AddVectorControl | null = null;
118
+ protected controlGrid: ControlGrid | null = null;
119
+
120
+ // Colorbar, Search, Measure, Print controls
121
+ protected colorbarsMap: globalThis.Map<string, Colorbar> = new globalThis.Map();
122
+ protected searchControl: SearchControl | null = null;
123
+ protected measureControl: MeasureControl | null = null;
124
+ protected printControl: PrintControl | null = null;
125
+
126
+ // Route animations
127
+ protected animations: globalThis.Map<string, {
128
+ animationId: number;
129
+ marker: Marker;
130
+ isPaused: boolean;
131
+ speed: number;
132
+ startTime: number;
133
+ pausedAt: number;
134
+ duration: number;
135
+ coordinates: [number, number][];
136
+ loop: boolean;
137
+ trailSourceId?: string;
138
+ trailLayerId?: string;
139
+ }> = new globalThis.Map();
140
+
141
+ // Feature hover state tracking
142
+ protected hoveredFeatureId: string | number | null = null;
143
+ protected hoveredLayerId: string | null = null;
144
+
145
+ // Video sources tracking
146
+ protected videoSources: globalThis.Map<string, string> = new globalThis.Map();
147
+
148
+ // Split map state
149
+ private splitMapRight: MapboxMap | null = null;
150
+ private splitMapContainer: HTMLDivElement | null = null;
151
+ private splitSlider: HTMLDivElement | null = null;
152
+ private splitActive: boolean = false;
153
+
154
+ // Tooltip and coordinates
155
+ private tooltipLayerHandlers: Map<string, (e: mapboxgl.MapMouseEvent & { features?: GeoJSON.Feature[] }) => void> = new Map();
156
+ private coordinatesControl: HTMLDivElement | null = null;
157
+ private coordinatesHandler: ((e: mapboxgl.MapMouseEvent) => void) | null = null;
158
+
159
+ // Time slider, opacity slider, style switcher
160
+ private timeSliderContainer: HTMLDivElement | null = null;
161
+ private opacitySliderContainer: Map<string, HTMLDivElement> = new Map();
162
+ private styleSwitcherContainer: HTMLDivElement | null = null;
163
+
164
+ // Swipe map
165
+ private swipeContainer: HTMLDivElement | null = null;
166
+ private swipeHandler: (() => void) | null = null;
61
167
 
62
168
  // LiDAR control
63
- private lidarControl: LidarControl | null = null;
64
- private lidarLayers: globalThis.Map<string, string> = new globalThis.Map();
169
+ protected lidarControl: LidarControl | null = null;
170
+ protected lidarLayers: globalThis.Map<string, string> = new globalThis.Map();
65
171
 
66
172
  constructor(model: MapWidgetModel, el: HTMLElement) {
67
173
  super(model, el);
@@ -99,6 +205,15 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
99
205
  await new Promise<void>((resolve) => {
100
206
  this.map!.on('load', () => {
101
207
  this.isMapReady = true;
208
+ this.restoreState();
209
+ const initProjection = this.model.get('projection') as string;
210
+ if (initProjection && initProjection !== 'mercator') {
211
+ try {
212
+ this.map!.setProjection({ type: initProjection } as unknown as mapboxgl.ProjectionSpecification);
213
+ } catch {
214
+ // Ignore projection errors
215
+ }
216
+ }
102
217
  this.processPendingCalls();
103
218
  setTimeout(() => {
104
219
  if (this.map) {
@@ -247,23 +362,46 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
247
362
 
248
363
  // Raster data
249
364
  this.registerMethod('addTileLayer', this.handleAddTileLayer.bind(this));
365
+ this.registerMethod('addImageLayer', this.handleAddImageLayer.bind(this));
250
366
 
251
367
  // Controls
252
368
  this.registerMethod('addControl', this.handleAddControl.bind(this));
253
369
  this.registerMethod('removeControl', this.handleRemoveControl.bind(this));
370
+ this.registerMethod('addLayerControl', this.handleAddLayerControl.bind(this));
254
371
 
255
- // Markers
372
+ // Draw control
373
+ this.registerMethod('addDrawControl', this.handleAddDrawControl.bind(this));
374
+ this.registerMethod('getDrawData', this.handleGetDrawData.bind(this));
375
+ this.registerMethod('loadDrawData', this.handleLoadDrawData.bind(this));
376
+ this.registerMethod('clearDrawData', this.handleClearDrawData.bind(this));
377
+
378
+ // Markers and popups
256
379
  this.registerMethod('addMarker', this.handleAddMarker.bind(this));
380
+ this.registerMethod('addMarkers', this.handleAddMarkers.bind(this));
257
381
  this.registerMethod('removeMarker', this.handleRemoveMarker.bind(this));
382
+ this.registerMethod('addPopup', this.handleAddPopup.bind(this));
383
+
384
+ // Legend
385
+ this.registerMethod('addLegend', this.handleAddLegend.bind(this));
386
+ this.registerMethod('removeLegend', this.handleRemoveLegend.bind(this));
387
+ this.registerMethod('updateLegend', this.handleUpdateLegend.bind(this));
258
388
 
259
389
  // Terrain (Mapbox-specific)
260
390
  this.registerMethod('addTerrain', this.handleAddTerrain.bind(this));
261
391
  this.registerMethod('removeTerrain', this.handleRemoveTerrain.bind(this));
262
392
 
393
+ // Layer management
394
+ this.registerMethod('moveLayer', this.handleMoveLayer.bind(this));
395
+
263
396
  // COG layers (deck.gl)
264
397
  this.registerMethod('addCOGLayer', this.handleAddCOGLayer.bind(this));
265
398
  this.registerMethod('removeCOGLayer', this.handleRemoveCOGLayer.bind(this));
266
399
 
400
+ // Zarr layers
401
+ this.registerMethod('addZarrLayer', this.handleAddZarrLayer.bind(this));
402
+ this.registerMethod('removeZarrLayer', this.handleRemoveZarrLayer.bind(this));
403
+ this.registerMethod('updateZarrLayer', this.handleUpdateZarrLayer.bind(this));
404
+
267
405
  // Arc layers (deck.gl)
268
406
  this.registerMethod('addArcLayer', this.handleAddArcLayer.bind(this));
269
407
  this.registerMethod('removeArcLayer', this.handleRemoveArcLayer.bind(this));
@@ -272,6 +410,47 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
272
410
  this.registerMethod('addPointCloudLayer', this.handleAddPointCloudLayer.bind(this));
273
411
  this.registerMethod('removePointCloudLayer', this.handleRemovePointCloudLayer.bind(this));
274
412
 
413
+ // Additional deck.gl layers
414
+ this.registerMethod('addScatterplotLayer', this.handleAddScatterplotLayer.bind(this));
415
+ this.registerMethod('addPathLayer', this.handleAddPathLayer.bind(this));
416
+ this.registerMethod('addPolygonLayer', this.handleAddPolygonLayer.bind(this));
417
+ this.registerMethod('addHexagonLayer', this.handleAddHexagonLayer.bind(this));
418
+ this.registerMethod('addHeatmapLayer', this.handleAddHeatmapLayer.bind(this));
419
+ this.registerMethod('addGridLayer', this.handleAddGridLayer.bind(this));
420
+ this.registerMethod('addIconLayer', this.handleAddIconLayer.bind(this));
421
+ this.registerMethod('addTextLayer', this.handleAddTextLayer.bind(this));
422
+ this.registerMethod('addGeoJsonLayer', this.handleAddGeoJsonLayer.bind(this));
423
+ this.registerMethod('addContourLayer', this.handleAddContourLayer.bind(this));
424
+ this.registerMethod('addScreenGridLayer', this.handleAddScreenGridLayer.bind(this));
425
+ this.registerMethod('addTripsLayer', this.handleAddTripsLayer.bind(this));
426
+ this.registerMethod('addLineLayer', this.handleAddLineLayer.bind(this));
427
+ this.registerMethod('addDeckGLLayer', this.handleAddDeckGLLayer.bind(this));
428
+ this.registerMethod('removeDeckLayer', this.handleRemoveDeckLayer.bind(this));
429
+ this.registerMethod('setDeckLayerVisibility', this.handleSetDeckLayerVisibility.bind(this));
430
+ this.registerMethod('addBitmapLayer', this.handleAddBitmapLayer.bind(this));
431
+ this.registerMethod('addColumnLayer', this.handleAddColumnLayer.bind(this));
432
+ this.registerMethod('addGridCellLayer', this.handleAddGridCellLayer.bind(this));
433
+ this.registerMethod('addSolidPolygonLayer', this.handleAddSolidPolygonLayer.bind(this));
434
+
435
+ // Native Mapbox features
436
+ this.registerMethod('setProjection', this.handleSetProjection.bind(this));
437
+ this.registerMethod('updateGeoJSONSource', this.handleUpdateGeoJSONSource.bind(this));
438
+ this.registerMethod('addMapImage', this.handleAddMapImage.bind(this));
439
+ this.registerMethod('addTooltip', this.handleAddTooltip.bind(this));
440
+ this.registerMethod('removeTooltip', this.handleRemoveTooltip.bind(this));
441
+ this.registerMethod('addCoordinatesControl', this.handleAddCoordinatesControl.bind(this));
442
+ this.registerMethod('removeCoordinatesControl', this.handleRemoveCoordinatesControl.bind(this));
443
+ this.registerMethod('addTimeSlider', this.handleAddTimeSlider.bind(this));
444
+ this.registerMethod('removeTimeSlider', this.handleRemoveTimeSlider.bind(this));
445
+ this.registerMethod('addSwipeMap', this.handleAddSwipeMap.bind(this));
446
+ this.registerMethod('removeSwipeMap', this.handleRemoveSwipeMap.bind(this));
447
+ this.registerMethod('addOpacitySlider', this.handleAddOpacitySlider.bind(this));
448
+ this.registerMethod('removeOpacitySlider', this.handleRemoveOpacitySlider.bind(this));
449
+ this.registerMethod('addStyleSwitcher', this.handleAddStyleSwitcher.bind(this));
450
+ this.registerMethod('removeStyleSwitcher', this.handleRemoveStyleSwitcher.bind(this));
451
+ this.registerMethod('getVisibleFeatures', this.handleGetVisibleFeatures.bind(this));
452
+ this.registerMethod('getLayerData', this.handleGetLayerData.bind(this));
453
+
275
454
  // LiDAR layers (maplibre-gl-lidar)
276
455
  this.registerMethod('addLidarControl', this.handleAddLidarControl.bind(this));
277
456
  this.registerMethod('addLidarLayer', this.handleAddLidarLayer.bind(this));
@@ -279,6 +458,66 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
279
458
  this.registerMethod('setLidarColorScheme', this.handleSetLidarColorScheme.bind(this));
280
459
  this.registerMethod('setLidarPointSize', this.handleSetLidarPointSize.bind(this));
281
460
  this.registerMethod('setLidarOpacity', this.handleSetLidarOpacity.bind(this));
461
+
462
+ // PMTiles
463
+ this.registerMethod('addPMTilesLayer', this.handleAddPMTilesLayer.bind(this));
464
+ this.registerMethod('removePMTilesLayer', this.handleRemovePMTilesLayer.bind(this));
465
+ this.registerMethod('addPMTilesControl', this.handleAddPMTilesControl.bind(this));
466
+ this.registerMethod('addCogControl', this.handleAddCogControl.bind(this));
467
+ this.registerMethod('addZarrControl', this.handleAddZarrControl.bind(this));
468
+ this.registerMethod('addVectorControl', this.handleAddVectorControl.bind(this));
469
+ this.registerMethod('addControlGrid', this.handleAddControlGrid.bind(this));
470
+
471
+ // Clustering, Choropleth, 3D Buildings
472
+ this.registerMethod('addClusterLayer', this.handleAddClusterLayer.bind(this));
473
+ this.registerMethod('removeClusterLayer', this.handleRemoveClusterLayer.bind(this));
474
+ this.registerMethod('addChoropleth', this.handleAddChoropleth.bind(this));
475
+ this.registerMethod('add3DBuildings', this.handleAdd3DBuildings.bind(this));
476
+
477
+ // Route Animation
478
+ this.registerMethod('animateAlongRoute', this.handleAnimateAlongRoute.bind(this));
479
+ this.registerMethod('stopAnimation', this.handleStopAnimation.bind(this));
480
+ this.registerMethod('pauseAnimation', this.handlePauseAnimation.bind(this));
481
+ this.registerMethod('resumeAnimation', this.handleResumeAnimation.bind(this));
482
+ this.registerMethod('setAnimationSpeed', this.handleSetAnimationSpeed.bind(this));
483
+
484
+ // Feature Hover
485
+ this.registerMethod('addHoverEffect', this.handleAddHoverEffect.bind(this));
486
+
487
+ // Fog (Mapbox uses setFog, not setSky)
488
+ this.registerMethod('setFog', this.handleSetFog.bind(this));
489
+ this.registerMethod('removeFog', this.handleRemoveFog.bind(this));
490
+
491
+ // Feature Query/Filter
492
+ this.registerMethod('setFilter', this.handleSetFilter.bind(this));
493
+ this.registerMethod('queryRenderedFeatures', this.handleQueryRenderedFeatures.bind(this));
494
+ this.registerMethod('querySourceFeatures', this.handleQuerySourceFeatures.bind(this));
495
+
496
+ // Video Layer
497
+ this.registerMethod('addVideoLayer', this.handleAddVideoLayer.bind(this));
498
+ this.registerMethod('removeVideoLayer', this.handleRemoveVideoLayer.bind(this));
499
+ this.registerMethod('playVideo', this.handlePlayVideo.bind(this));
500
+ this.registerMethod('pauseVideo', this.handlePauseVideo.bind(this));
501
+ this.registerMethod('seekVideo', this.handleSeekVideo.bind(this));
502
+
503
+ // Split Map
504
+ this.registerMethod('addSplitMap', this.handleAddSplitMap.bind(this));
505
+ this.registerMethod('removeSplitMap', this.handleRemoveSplitMap.bind(this));
506
+
507
+ // Colorbar, Search, Measure, Print
508
+ this.registerMethod('addColorbar', this.handleAddColorbar.bind(this));
509
+ this.registerMethod('removeColorbar', this.handleRemoveColorbar.bind(this));
510
+ this.registerMethod('updateColorbar', this.handleUpdateColorbar.bind(this));
511
+ this.registerMethod('addSearchControl', this.handleAddSearchControl.bind(this));
512
+ this.registerMethod('removeSearchControl', this.handleRemoveSearchControl.bind(this));
513
+ this.registerMethod('addMeasureControl', this.handleAddMeasureControl.bind(this));
514
+ this.registerMethod('removeMeasureControl', this.handleRemoveMeasureControl.bind(this));
515
+ this.registerMethod('addPrintControl', this.handleAddPrintControl.bind(this));
516
+ this.registerMethod('removePrintControl', this.handleRemovePrintControl.bind(this));
517
+
518
+ // FlatGeobuf
519
+ this.registerMethod('addFlatGeobuf', this.handleAddFlatGeobuf.bind(this));
520
+ this.registerMethod('removeFlatGeobuf', this.handleRemoveFlatGeobuf.bind(this));
282
521
  }
283
522
 
284
523
  // -------------------------------------------------------------------------
@@ -624,6 +863,38 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
624
863
  }
625
864
  }
626
865
 
866
+ private handleAddImageLayer(args: unknown[], kwargs: Record<string, unknown>): void {
867
+ if (!this.map) return;
868
+ const id = (kwargs.id as string) || `image-${Date.now()}`;
869
+ const url = kwargs.url as string;
870
+ const coordinates = kwargs.coordinates as number[][];
871
+ const opacity = (kwargs.opacity as number) ?? 1.0;
872
+
873
+ if (!url || !coordinates || coordinates.length !== 4) {
874
+ console.error('addImageLayer requires url and 4 corner coordinates');
875
+ return;
876
+ }
877
+
878
+ const sourceId = `${id}-source`;
879
+
880
+ if (!this.map.getSource(sourceId)) {
881
+ this.map.addSource(sourceId, {
882
+ type: 'image',
883
+ url,
884
+ coordinates: coordinates as [[number, number], [number, number], [number, number], [number, number]],
885
+ });
886
+ }
887
+
888
+ if (!this.map.getLayer(id)) {
889
+ this.map.addLayer({
890
+ id,
891
+ type: 'raster',
892
+ source: sourceId,
893
+ paint: { 'raster-opacity': opacity },
894
+ });
895
+ }
896
+ }
897
+
627
898
  // -------------------------------------------------------------------------
628
899
  // Control handlers
629
900
  // -------------------------------------------------------------------------
@@ -684,6 +955,118 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
684
955
  }
685
956
  }
686
957
 
958
+ private handleAddLayerControl(args: unknown[], kwargs: Record<string, unknown>): void {
959
+ if (!this.map) return;
960
+ let layers = kwargs.layers as string[] | undefined;
961
+ if (!layers || layers.length === 0) {
962
+ const modelLayers = this.model.get('_layers') || {};
963
+ layers = Object.keys(modelLayers);
964
+ }
965
+ const position = (kwargs.position as ControlPosition) || 'top-right';
966
+ const collapsed = (kwargs.collapsed as boolean) || false;
967
+
968
+ const container = document.createElement('div');
969
+ container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group anymap-layer-control';
970
+ container.style.cssText = 'padding:8px;background:rgba(255,255,255,0.95);border-radius:4px;max-height:200px;overflow-y:auto;';
971
+
972
+ if (collapsed) {
973
+ container.style.display = 'none';
974
+ const toggle = document.createElement('button');
975
+ toggle.textContent = 'Layers';
976
+ toggle.style.cssText = 'padding:4px 8px;cursor:pointer;border:1px solid #ccc;border-radius:3px;background:#fff;';
977
+ toggle.addEventListener('click', () => {
978
+ container.style.display = container.style.display === 'none' ? 'block' : 'none';
979
+ });
980
+ const wrapper = document.createElement('div');
981
+ wrapper.className = 'mapboxgl-ctrl mapboxgl-ctrl-group';
982
+ wrapper.appendChild(toggle);
983
+ wrapper.appendChild(container);
984
+ this.map.getContainer().querySelector(`.mapboxgl-ctrl-${position}`)?.appendChild(wrapper);
985
+ } else {
986
+ this.map.getContainer().querySelector(`.mapboxgl-ctrl-${position}`)?.appendChild(container);
987
+ }
988
+
989
+ const style = this.map.getStyle();
990
+ const layersList = style?.layers || [];
991
+ for (const layer of layersList) {
992
+ if (layers && layers.length > 0 && !layers.includes(layer.id)) continue;
993
+ if (layer.id.startsWith('mapbox-') || layer.id.startsWith('maplibre-')) continue;
994
+
995
+ const row = document.createElement('div');
996
+ row.style.cssText = 'display:flex;align-items:center;margin-bottom:4px;';
997
+ const cb = document.createElement('input');
998
+ cb.type = 'checkbox';
999
+ cb.checked = (layer.layout as Record<string, unknown>)?.['visibility'] !== 'none';
1000
+ cb.addEventListener('change', () => {
1001
+ this.map?.setLayoutProperty(layer.id, 'visibility', cb.checked ? 'visible' : 'none');
1002
+ });
1003
+ const label = document.createElement('span');
1004
+ label.textContent = layer.id;
1005
+ label.style.marginLeft = '6px';
1006
+ row.appendChild(cb);
1007
+ row.appendChild(label);
1008
+ container.appendChild(row);
1009
+ }
1010
+ }
1011
+
1012
+ private handleAddDrawControl(args: unknown[], kwargs: Record<string, unknown>): void {
1013
+ if (!this.map) return;
1014
+ const position = (kwargs.position as ControlPosition) || 'top-right';
1015
+
1016
+ if (this.mapboxDraw) {
1017
+ this.map.removeControl(this.mapboxDraw as unknown as mapboxgl.IControl);
1018
+ }
1019
+
1020
+ this.mapboxDraw = new MapboxDraw({
1021
+ displayControlsDefault: false,
1022
+ controls: {
1023
+ point: true,
1024
+ line_string: true,
1025
+ polygon: true,
1026
+ trash: true,
1027
+ },
1028
+ });
1029
+ this.map.addControl(this.mapboxDraw as unknown as mapboxgl.IControl, position);
1030
+
1031
+ this.map.on('draw.create', () => this.syncDrawData());
1032
+ this.map.on('draw.update', () => this.syncDrawData());
1033
+ this.map.on('draw.delete', () => this.syncDrawData());
1034
+ }
1035
+
1036
+ private syncDrawData(): void {
1037
+ if (!this.mapboxDraw) return;
1038
+ const data = this.mapboxDraw.getAll();
1039
+ this.model.set('_draw_data', data);
1040
+ this.model.save_changes();
1041
+ }
1042
+
1043
+ private handleGetDrawData(args: unknown[], kwargs: Record<string, unknown>): void {
1044
+ if (!this.mapboxDraw) {
1045
+ this.model.set('_draw_data', { type: 'FeatureCollection', features: [] });
1046
+ this.model.save_changes();
1047
+ return;
1048
+ }
1049
+ const data = this.mapboxDraw.getAll();
1050
+ this.model.set('_draw_data', data);
1051
+ this.model.save_changes();
1052
+ }
1053
+
1054
+ private handleLoadDrawData(args: unknown[], kwargs: Record<string, unknown>): void {
1055
+ if (!this.mapboxDraw) {
1056
+ console.warn('Draw control not initialized');
1057
+ return;
1058
+ }
1059
+ const geojson = args[0] as FeatureCollection;
1060
+ this.mapboxDraw.set(geojson);
1061
+ }
1062
+
1063
+ private handleClearDrawData(args: unknown[], kwargs: Record<string, unknown>): void {
1064
+ if (!this.mapboxDraw) return;
1065
+ this.mapboxDraw.deleteAll();
1066
+ this.model.set('_draw_data', { type: 'FeatureCollection', features: [] });
1067
+ this.model.save_changes();
1068
+ }
1069
+
687
1070
  // -------------------------------------------------------------------------
688
1071
  // Terrain handlers (Mapbox-specific)
689
1072
  // -------------------------------------------------------------------------
@@ -734,6 +1117,22 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
734
1117
  this.markersMap.set(id, marker);
735
1118
  }
736
1119
 
1120
+ private handleAddMarkers(args: unknown[], kwargs: Record<string, unknown>): void {
1121
+ if (!this.map) return;
1122
+ const id = (kwargs.id as string) || `markers-${Date.now()}`;
1123
+ const markers = kwargs.markers as Array<{ lngLat: [number, number]; popup?: string; tooltip?: string }>;
1124
+ const color = (kwargs.color as string) || '#3388ff';
1125
+ if (!markers || !Array.isArray(markers)) return;
1126
+ for (let i = 0; i < markers.length; i++) {
1127
+ const m = markers[i];
1128
+ const markerId = `${id}-${i}`;
1129
+ const marker = new Marker({ color }).setLngLat(m.lngLat);
1130
+ if (m.popup) marker.setPopup(new Popup().setHTML(m.popup));
1131
+ marker.addTo(this.map);
1132
+ this.markersMap.set(markerId, marker);
1133
+ }
1134
+ }
1135
+
737
1136
  private handleRemoveMarker(args: unknown[], kwargs: Record<string, unknown>): void {
738
1137
  const [id] = args as [string];
739
1138
  const marker = this.markersMap.get(id);
@@ -743,6 +1142,111 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
743
1142
  }
744
1143
  }
745
1144
 
1145
+ private handleAddPopup(args: unknown[], kwargs: Record<string, unknown>): void {
1146
+ if (!this.map) return;
1147
+ const layerId = kwargs.layerId as string;
1148
+ const properties = kwargs.properties as string[] | undefined;
1149
+ const template = kwargs.template as string | undefined;
1150
+ if (!layerId) return;
1151
+ this.map.on('click', layerId, (e) => {
1152
+ if (!e.features || e.features.length === 0) return;
1153
+ const feature = e.features[0];
1154
+ const props = feature.properties || {};
1155
+ let content: string;
1156
+ if (template) {
1157
+ content = template.replace(/\{(\w+)\}/g, (_, key) => (props[key] !== undefined ? String(props[key]) : ''));
1158
+ } else if (properties) {
1159
+ content = properties.filter((k) => props[k] !== undefined).map((k) => `${k}: ${props[k]}`).join('<br>');
1160
+ } else {
1161
+ content = Object.entries(props).map(([k, v]) => `${k}: ${v}`).join('<br>');
1162
+ }
1163
+ new Popup().setLngLat(e.lngLat).setHTML(content).addTo(this.map!);
1164
+ });
1165
+ this.map.on('mouseenter', layerId, () => { if (this.map) this.map.getCanvas().style.cursor = 'pointer'; });
1166
+ this.map.on('mouseleave', layerId, () => { if (this.map) this.map.getCanvas().style.cursor = ''; });
1167
+ }
1168
+
1169
+ private handleAddLegend(args: unknown[], kwargs: Record<string, unknown>): void {
1170
+ if (!this.map) return;
1171
+ const legendId = (kwargs.id as string) || `legend-${Date.now()}`;
1172
+ const title = (kwargs.title as string) || 'Legend';
1173
+ const items = (kwargs.items as Array<{ label: string; color: string }>) || [];
1174
+ const position = (kwargs.position as string) || 'bottom-right';
1175
+ if (this.legendsMap.has(legendId)) {
1176
+ const old = this.legendsMap.get(legendId);
1177
+ if (old?.parentNode) old.parentNode.removeChild(old);
1178
+ this.legendsMap.delete(legendId);
1179
+ }
1180
+ const legendDiv = document.createElement('div');
1181
+ legendDiv.id = legendId;
1182
+ legendDiv.className = 'mapboxgl-ctrl legend-control';
1183
+ legendDiv.style.cssText = 'padding:10px 14px;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,0.3);font-size:12px;max-width:200px;background:rgba(255,255,255,0.95);';
1184
+ const titleEl = document.createElement('div');
1185
+ titleEl.style.cssText = 'font-weight:bold;margin-bottom:8px;';
1186
+ titleEl.textContent = title;
1187
+ legendDiv.appendChild(titleEl);
1188
+ for (const item of items) {
1189
+ const row = document.createElement('div');
1190
+ row.style.cssText = 'display:flex;align-items:center;margin-bottom:4px;';
1191
+ const colorBox = document.createElement('span');
1192
+ colorBox.style.cssText = `width:16px;height:16px;background:${item.color};margin-right:8px;border-radius:2px;`;
1193
+ row.appendChild(colorBox);
1194
+ const label = document.createElement('span');
1195
+ label.textContent = item.label;
1196
+ row.appendChild(label);
1197
+ legendDiv.appendChild(row);
1198
+ }
1199
+ this.map.getContainer().querySelector(`.mapboxgl-ctrl-${position}`)?.appendChild(legendDiv);
1200
+ this.legendsMap.set(legendId, legendDiv);
1201
+ }
1202
+
1203
+ private handleRemoveLegend(args: unknown[], kwargs: Record<string, unknown>): void {
1204
+ const legendId = args[0] as string | undefined;
1205
+ if (legendId) {
1206
+ const legendDiv = this.legendsMap.get(legendId);
1207
+ if (legendDiv?.parentNode) legendDiv.parentNode.removeChild(legendDiv);
1208
+ this.legendsMap.delete(legendId);
1209
+ } else {
1210
+ for (const [, div] of this.legendsMap) {
1211
+ if (div.parentNode) div.parentNode.removeChild(div);
1212
+ }
1213
+ this.legendsMap.clear();
1214
+ }
1215
+ }
1216
+
1217
+ private handleUpdateLegend(args: unknown[], kwargs: Record<string, unknown>): void {
1218
+ const legendId = (kwargs.id as string) || (args[0] as string);
1219
+ if (!legendId) return;
1220
+ const legendDiv = this.legendsMap.get(legendId);
1221
+ if (!legendDiv) return;
1222
+ const title = kwargs.title as string | undefined;
1223
+ const items = kwargs.items as Array<{ label: string; color: string }> | undefined;
1224
+ if (title) {
1225
+ const titleEl = legendDiv.querySelector('div');
1226
+ if (titleEl) titleEl.textContent = title;
1227
+ }
1228
+ if (items && items.length > 0) {
1229
+ legendDiv.querySelectorAll('div:not(:first-child)').forEach((r) => r.remove());
1230
+ for (const item of items) {
1231
+ const row = document.createElement('div');
1232
+ row.style.cssText = 'display:flex;align-items:center;margin-bottom:4px;';
1233
+ const colorBox = document.createElement('span');
1234
+ colorBox.style.cssText = `width:16px;height:16px;background:${item.color};margin-right:8px;border-radius:2px;`;
1235
+ row.appendChild(colorBox);
1236
+ const label = document.createElement('span');
1237
+ label.textContent = item.label;
1238
+ row.appendChild(label);
1239
+ legendDiv.appendChild(row);
1240
+ }
1241
+ }
1242
+ }
1243
+
1244
+ private handleMoveLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1245
+ if (!this.map) return;
1246
+ const [layerId, beforeId] = args as [string, string | undefined];
1247
+ if (layerId && this.map.getLayer(layerId)) this.map.moveLayer(layerId, beforeId);
1248
+ }
1249
+
746
1250
  // -------------------------------------------------------------------------
747
1251
  // COG layer handlers (deck.gl)
748
1252
  // -------------------------------------------------------------------------
@@ -809,6 +1313,53 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
809
1313
  this.updateDeckOverlay();
810
1314
  }
811
1315
 
1316
+ // -------------------------------------------------------------------------
1317
+ // Zarr layer handlers
1318
+ // -------------------------------------------------------------------------
1319
+
1320
+ private handleAddZarrLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1321
+ if (!this.map) return;
1322
+ const id = (kwargs.id as string) || `zarr-${Date.now()}`;
1323
+ const source = kwargs.source as string;
1324
+ const variable = kwargs.variable as string;
1325
+ const clim = (kwargs.clim as [number, number]) || [0, 100];
1326
+ const colormap = (kwargs.colormap as string[]) || ['#000000', '#ffffff'];
1327
+ const opacity = (kwargs.opacity as number) ?? 1;
1328
+ const layer = new ZarrLayer({
1329
+ id,
1330
+ source,
1331
+ variable,
1332
+ clim,
1333
+ colormap,
1334
+ selector: (kwargs.selector as Record<string, number>) || {},
1335
+ opacity,
1336
+ minzoom: kwargs.minzoom as number,
1337
+ maxzoom: kwargs.maxzoom as number,
1338
+ fillValue: kwargs.fillValue as number,
1339
+ spatialDimensions: kwargs.spatialDimensions as { lat?: string; lon?: string },
1340
+ zarrVersion: kwargs.zarrVersion as 2 | 3 | undefined,
1341
+ bounds: kwargs.bounds as [number, number, number, number] | undefined,
1342
+ });
1343
+ this.map.addLayer(layer as unknown as mapboxgl.CustomLayerInterface);
1344
+ this.zarrLayers.set(id, layer);
1345
+ }
1346
+
1347
+ private handleRemoveZarrLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1348
+ const [id] = args as [string];
1349
+ if (this.map?.getLayer(id)) this.map.removeLayer(id);
1350
+ this.zarrLayers.delete(id);
1351
+ }
1352
+
1353
+ private handleUpdateZarrLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1354
+ const id = kwargs.id as string;
1355
+ const layer = this.zarrLayers.get(id);
1356
+ if (!layer) return;
1357
+ if (kwargs.selector) layer.setSelector(kwargs.selector as Record<string, number>);
1358
+ if (kwargs.clim) layer.setClim(kwargs.clim as [number, number]);
1359
+ if (kwargs.colormap) layer.setColormap(kwargs.colormap as string[]);
1360
+ if (kwargs.opacity !== undefined) layer.setOpacity(kwargs.opacity as number);
1361
+ }
1362
+
812
1363
  // -------------------------------------------------------------------------
813
1364
  // Arc layer handlers (deck.gl)
814
1365
  // -------------------------------------------------------------------------
@@ -921,86 +1472,689 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
921
1472
  }
922
1473
 
923
1474
  // -------------------------------------------------------------------------
924
- // LiDAR layer handlers (maplibre-gl-lidar)
1475
+ // Additional deck.gl layer handlers
925
1476
  // -------------------------------------------------------------------------
926
1477
 
927
- private handleAddLidarControl(args: unknown[], kwargs: Record<string, unknown>): void {
1478
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1479
+ private makeDeckAccessor(value: unknown, defaultProp: string, fallbackFn?: (d: any) => any): any {
1480
+ if (typeof value === 'string') return (d: any) => d[value];
1481
+ if (typeof value === 'function') return value;
1482
+ if (value !== undefined && value !== null) return value;
1483
+ return fallbackFn || ((d: any) => d[defaultProp]);
1484
+ }
1485
+
1486
+ private handleAddScatterplotLayer(args: unknown[], kwargs: Record<string, unknown>): void {
928
1487
  if (!this.map) return;
1488
+ this.initializeDeckOverlay();
1489
+ const id = (kwargs.id as string) || `scatterplot-${Date.now()}`;
1490
+ const data = kwargs.data as unknown[];
1491
+ const layer = new ScatterplotLayer({
1492
+ id,
1493
+ data,
1494
+ pickable: kwargs.pickable !== false,
1495
+ opacity: (kwargs.opacity as number) ?? 0.8,
1496
+ getPosition: (kwargs.getPosition as (d: unknown) => [number, number]) ?? ((d: any) => d.coordinates || d.position || [d.lng || d.longitude, d.lat || d.latitude]),
1497
+ getRadius: (kwargs.getRadius as number) ?? (kwargs.radius as number) ?? 5,
1498
+ getFillColor: (kwargs.getFillColor as [number, number, number, number]) ?? [51, 136, 255, 200],
1499
+ } as any);
1500
+ this.deckLayers.set(id, layer);
1501
+ this.updateDeckOverlay();
1502
+ }
929
1503
 
930
- if (this.lidarControl) {
931
- console.warn('LiDAR control already exists');
932
- return;
933
- }
1504
+ private handleAddPathLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1505
+ if (!this.map) return;
1506
+ this.initializeDeckOverlay();
1507
+ const id = (kwargs.id as string) || `path-${Date.now()}`;
1508
+ const data = kwargs.data as unknown[];
1509
+ const layer = new PathLayer({
1510
+ id,
1511
+ data,
1512
+ getPath: (kwargs.getPath as (d: unknown) => [number, number][]) ?? ((d: any) => d.path || d.coordinates),
1513
+ getColor: (kwargs.getColor as [number, number, number, number]) ?? [51, 136, 255, 200],
1514
+ getWidth: (kwargs.getWidth as number) ?? 1,
1515
+ } as any);
1516
+ this.deckLayers.set(id, layer);
1517
+ this.updateDeckOverlay();
1518
+ }
934
1519
 
935
- const options = {
936
- position: (kwargs.position as string) || 'top-right',
937
- collapsed: kwargs.collapsed !== false,
938
- title: (kwargs.title as string) || 'LiDAR Viewer',
939
- panelWidth: (kwargs.panelWidth as number) || 365,
940
- panelMaxHeight: (kwargs.panelMaxHeight as number) || 600,
941
- pointSize: (kwargs.pointSize as number) || 2,
942
- opacity: (kwargs.opacity as number) || 1.0,
943
- colorScheme: (kwargs.colorScheme as string) || 'elevation',
944
- usePercentile: kwargs.usePercentile !== false,
945
- pointBudget: (kwargs.pointBudget as number) || 1000000,
946
- pickable: kwargs.pickable === true,
947
- autoZoom: kwargs.autoZoom !== false,
948
- copcLoadingMode: kwargs.copcLoadingMode as 'full' | 'dynamic' | undefined,
949
- streamingPointBudget: (kwargs.streamingPointBudget as number) || 5000000,
950
- };
1520
+ private handleAddPolygonLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1521
+ if (!this.map) return;
1522
+ this.initializeDeckOverlay();
1523
+ const id = (kwargs.id as string) || `polygon-${Date.now()}`;
1524
+ const data = kwargs.data as unknown[];
1525
+ const layer = new PolygonLayer({
1526
+ id,
1527
+ data,
1528
+ getPolygon: (kwargs.getPolygon as (d: unknown) => number[][][]) ?? ((d: any) => d.polygon || d.coordinates),
1529
+ getFillColor: (kwargs.getFillColor as [number, number, number, number]) ?? [51, 136, 255, 128],
1530
+ } as any);
1531
+ this.deckLayers.set(id, layer);
1532
+ this.updateDeckOverlay();
1533
+ }
951
1534
 
952
- // LidarControl works with both MapLibre and Mapbox GL JS
953
- this.lidarControl = new LidarControl(options as LidarControlOptions);
954
- this.map.addControl(
955
- this.lidarControl as unknown as mapboxgl.IControl,
956
- options.position as ControlPosition
957
- );
1535
+ private handleAddHexagonLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1536
+ if (!this.map) return;
1537
+ this.initializeDeckOverlay();
1538
+ const id = (kwargs.id as string) || `hexagon-${Date.now()}`;
1539
+ const data = kwargs.data as unknown[];
1540
+ const layer = new HexagonLayer({
1541
+ id,
1542
+ data,
1543
+ getPosition: (kwargs.getPosition as (d: unknown) => [number, number]) ?? ((d: any) => d.coordinates || [d.lng, d.lat]),
1544
+ radius: (kwargs.radius as number) ?? 1000,
1545
+ elevationScale: (kwargs.elevationScale as number) ?? 4,
1546
+ } as any);
1547
+ this.deckLayers.set(id, layer);
1548
+ this.updateDeckOverlay();
1549
+ }
958
1550
 
959
- this.lidarControl.on('load', (event) => {
960
- const info = event.pointCloud as { id: string; name: string; pointCount: number; source?: string } | undefined;
961
- if (info && 'name' in info) {
962
- this.lidarLayers.set(info.id, info.source || '');
963
- this.sendEvent('lidar:load', { id: info.id, name: info.name, pointCount: info.pointCount });
964
- }
965
- });
1551
+ private handleAddHeatmapLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1552
+ if (!this.map) return;
1553
+ this.initializeDeckOverlay();
1554
+ const id = (kwargs.id as string) || `heatmap-${Date.now()}`;
1555
+ const data = kwargs.data as unknown[];
1556
+ const layer = new HeatmapLayer({
1557
+ id,
1558
+ data,
1559
+ getPosition: (kwargs.getPosition as (d: unknown) => [number, number]) ?? ((d: any) => d.coordinates || [d.lng, d.lat]),
1560
+ getWeight: (kwargs.getWeight as number) ?? 1,
1561
+ } as any);
1562
+ this.deckLayers.set(id, layer);
1563
+ this.updateDeckOverlay();
1564
+ }
966
1565
 
967
- this.lidarControl.on('unload', (event) => {
968
- const pointCloud = event.pointCloud as { id: string } | undefined;
969
- if (pointCloud) {
970
- this.lidarLayers.delete(pointCloud.id);
971
- this.sendEvent('lidar:unload', { id: pointCloud.id });
972
- }
973
- });
1566
+ private handleAddGridLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1567
+ if (!this.map) return;
1568
+ this.initializeDeckOverlay();
1569
+ const id = (kwargs.id as string) || `grid-${Date.now()}`;
1570
+ const data = kwargs.data as unknown[];
1571
+ const layer = new GridLayer({
1572
+ id,
1573
+ data,
1574
+ getPosition: (kwargs.getPosition as (d: unknown) => [number, number]) ?? ((d: any) => d.coordinates || [d.lng, d.lat]),
1575
+ cellSize: (kwargs.cellSize as number) ?? 200,
1576
+ } as any);
1577
+ this.deckLayers.set(id, layer);
1578
+ this.updateDeckOverlay();
974
1579
  }
975
1580
 
976
- private handleAddLidarLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1581
+ private handleAddIconLayer(args: unknown[], kwargs: Record<string, unknown>): void {
977
1582
  if (!this.map) return;
1583
+ this.initializeDeckOverlay();
1584
+ const id = (kwargs.id as string) || `icon-${Date.now()}`;
1585
+ const data = kwargs.data as unknown[];
1586
+ const layer = new IconLayer({
1587
+ id,
1588
+ data,
1589
+ iconAtlas: kwargs.iconAtlas as string,
1590
+ iconMapping: kwargs.iconMapping as Record<string, unknown>,
1591
+ getPosition: (kwargs.getPosition as (d: unknown) => [number, number]) ?? ((d: any) => d.coordinates || [d.lng, d.lat]),
1592
+ getIcon: (kwargs.getIcon as (d: unknown) => string) ?? (() => 'marker'),
1593
+ } as any);
1594
+ this.deckLayers.set(id, layer);
1595
+ this.updateDeckOverlay();
1596
+ }
978
1597
 
979
- const source = kwargs.source as string;
980
- const name = (kwargs.name as string) || `lidar-${Date.now()}`;
981
- const isBase64 = kwargs.isBase64 === true;
1598
+ private handleAddTextLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1599
+ if (!this.map) return;
1600
+ this.initializeDeckOverlay();
1601
+ const id = (kwargs.id as string) || `text-${Date.now()}`;
1602
+ const data = kwargs.data as unknown[];
1603
+ const layer = new TextLayer({
1604
+ id,
1605
+ data,
1606
+ getPosition: (kwargs.getPosition as (d: unknown) => [number, number]) ?? ((d: any) => d.coordinates || [d.lng, d.lat]),
1607
+ getText: (kwargs.getText as (d: unknown) => string) ?? ((d: any) => d.text || d.label || ''),
1608
+ } as any);
1609
+ this.deckLayers.set(id, layer);
1610
+ this.updateDeckOverlay();
1611
+ }
982
1612
 
983
- if (!source) {
984
- console.error('LiDAR layer requires a source URL or base64 data');
985
- return;
986
- }
1613
+ private handleAddGeoJsonLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1614
+ if (!this.map) return;
1615
+ this.initializeDeckOverlay();
1616
+ const id = (kwargs.id as string) || `geojson-${Date.now()}`;
1617
+ const data = kwargs.data as unknown;
1618
+ const layer = new GeoJsonLayer({
1619
+ id,
1620
+ data,
1621
+ getFillColor: (kwargs.getFillColor as [number, number, number, number]) ?? [51, 136, 255, 128],
1622
+ } as any);
1623
+ this.deckLayers.set(id, layer);
1624
+ this.updateDeckOverlay();
1625
+ }
987
1626
 
988
- if (!this.lidarControl) {
989
- this.lidarControl = new LidarControl({
990
- collapsed: true,
991
- position: 'top-right',
992
- pointSize: (kwargs.pointSize as number) || 2,
993
- opacity: (kwargs.opacity as number) || 1.0,
994
- colorScheme: (kwargs.colorScheme as string) || 'elevation',
995
- usePercentile: kwargs.usePercentile !== false,
996
- pointBudget: (kwargs.pointBudget as number) || 1000000,
997
- pickable: kwargs.pickable !== false,
998
- autoZoom: kwargs.autoZoom !== false,
999
- } as LidarControlOptions);
1627
+ private handleAddContourLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1628
+ if (!this.map) return;
1629
+ this.initializeDeckOverlay();
1630
+ const id = (kwargs.id as string) || `contour-${Date.now()}`;
1631
+ const data = kwargs.data as unknown[];
1632
+ const layer = new ContourLayer({
1633
+ id,
1634
+ data,
1635
+ getPosition: (kwargs.getPosition as (d: unknown) => [number, number]) ?? ((d: any) => d.coordinates || [d.lng, d.lat]),
1636
+ } as any);
1637
+ this.deckLayers.set(id, layer);
1638
+ this.updateDeckOverlay();
1639
+ }
1000
1640
 
1001
- this.map.addControl(this.lidarControl as unknown as mapboxgl.IControl, 'top-right');
1641
+ private handleAddScreenGridLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1642
+ if (!this.map) return;
1643
+ this.initializeDeckOverlay();
1644
+ const id = (kwargs.id as string) || `screengrid-${Date.now()}`;
1645
+ const data = kwargs.data as unknown[];
1646
+ const layer = new ScreenGridLayer({
1647
+ id,
1648
+ data,
1649
+ getPosition: (kwargs.getPosition as (d: unknown) => [number, number]) ?? ((d: any) => d.coordinates || [d.lng, d.lat]),
1650
+ } as any);
1651
+ this.deckLayers.set(id, layer);
1652
+ this.updateDeckOverlay();
1653
+ }
1002
1654
 
1003
- this.lidarControl.on('load', (event) => {
1655
+ private handleAddTripsLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1656
+ if (!this.map) return;
1657
+ this.initializeDeckOverlay();
1658
+ const id = (kwargs.id as string) || `trips-${Date.now()}`;
1659
+ const data = kwargs.data as unknown[];
1660
+ const layer = new TripsLayer({
1661
+ id,
1662
+ data,
1663
+ getPath: this.makeDeckAccessor(kwargs.getPath, 'waypoints', (d: any) => d.waypoints || d.path),
1664
+ getTimestamps: this.makeDeckAccessor(kwargs.getTimestamps, 'timestamps', (d: any) => d.timestamps),
1665
+ } as any);
1666
+ this.deckLayers.set(id, layer);
1667
+ this.updateDeckOverlay();
1668
+ }
1669
+
1670
+ private handleAddLineLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1671
+ if (!this.map) return;
1672
+ this.initializeDeckOverlay();
1673
+ const id = (kwargs.id as string) || `line-${Date.now()}`;
1674
+ const data = kwargs.data as unknown[];
1675
+ const layer = new LineLayer({
1676
+ id,
1677
+ data,
1678
+ getSourcePosition: this.makeDeckAccessor(kwargs.getSourcePosition, 'source', (d: any) => d.source || d.from),
1679
+ getTargetPosition: this.makeDeckAccessor(kwargs.getTargetPosition, 'target', (d: any) => d.target || d.to),
1680
+ getColor: this.makeDeckAccessor(kwargs.getColor, 'color', () => [51, 136, 255, 200]),
1681
+ } as any);
1682
+ this.deckLayers.set(id, layer);
1683
+ this.updateDeckOverlay();
1684
+ }
1685
+
1686
+ private handleAddDeckGLLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1687
+ const layerType = kwargs.layerType as string;
1688
+ const handlerMap: Record<string, (a: unknown[], k: Record<string, unknown>) => void> = {
1689
+ ScatterplotLayer: this.handleAddScatterplotLayer.bind(this),
1690
+ ArcLayer: this.handleAddArcLayer.bind(this),
1691
+ PathLayer: this.handleAddPathLayer.bind(this),
1692
+ PolygonLayer: this.handleAddPolygonLayer.bind(this),
1693
+ HexagonLayer: this.handleAddHexagonLayer.bind(this),
1694
+ HeatmapLayer: this.handleAddHeatmapLayer.bind(this),
1695
+ GridLayer: this.handleAddGridLayer.bind(this),
1696
+ IconLayer: this.handleAddIconLayer.bind(this),
1697
+ TextLayer: this.handleAddTextLayer.bind(this),
1698
+ GeoJsonLayer: this.handleAddGeoJsonLayer.bind(this),
1699
+ ContourLayer: this.handleAddContourLayer.bind(this),
1700
+ ScreenGridLayer: this.handleAddScreenGridLayer.bind(this),
1701
+ PointCloudLayer: this.handleAddPointCloudLayer.bind(this),
1702
+ TripsLayer: this.handleAddTripsLayer.bind(this),
1703
+ LineLayer: this.handleAddLineLayer.bind(this),
1704
+ BitmapLayer: this.handleAddBitmapLayer.bind(this),
1705
+ ColumnLayer: this.handleAddColumnLayer.bind(this),
1706
+ GridCellLayer: this.handleAddGridCellLayer.bind(this),
1707
+ SolidPolygonLayer: this.handleAddSolidPolygonLayer.bind(this),
1708
+ };
1709
+ const handler = handlerMap[layerType];
1710
+ if (handler) handler(args, kwargs);
1711
+ }
1712
+
1713
+ private handleRemoveDeckLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1714
+ const id = (args[0] as string) || (kwargs.id as string);
1715
+ if (id) {
1716
+ this.deckLayers.delete(id);
1717
+ this.updateDeckOverlay();
1718
+ }
1719
+ }
1720
+
1721
+ private handleSetDeckLayerVisibility(args: unknown[], kwargs: Record<string, unknown>): void {
1722
+ const id = (args[0] as string) || (kwargs.id as string);
1723
+ const visible = (args[1] as boolean) ?? (kwargs.visible as boolean) ?? true;
1724
+ if (!id) return;
1725
+ const layer = this.deckLayers.get(id) as { clone?: (p: Record<string, unknown>) => unknown } | undefined;
1726
+ if (layer?.clone) {
1727
+ this.deckLayers.set(id, layer.clone({ visible }));
1728
+ this.updateDeckOverlay();
1729
+ }
1730
+ }
1731
+
1732
+ private handleAddBitmapLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1733
+ if (!this.map) return;
1734
+ this.initializeDeckOverlay();
1735
+ const id = (kwargs.id as string) || `bitmap-${Date.now()}`;
1736
+ const layer = new BitmapLayer({
1737
+ id,
1738
+ image: kwargs.image as string,
1739
+ bounds: kwargs.bounds as [number, number, number, number],
1740
+ });
1741
+ this.deckLayers.set(id, layer);
1742
+ this.updateDeckOverlay();
1743
+ }
1744
+
1745
+ private handleAddColumnLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1746
+ if (!this.map) return;
1747
+ this.initializeDeckOverlay();
1748
+ const id = (kwargs.id as string) || `column-${Date.now()}`;
1749
+ const data = kwargs.data as unknown[];
1750
+ const layer = new ColumnLayer({
1751
+ id,
1752
+ data,
1753
+ getPosition: this.makeDeckAccessor(kwargs.getPosition, 'coordinates', (d: any) => d.coordinates || [d.lng, d.lat]),
1754
+ getElevation: this.makeDeckAccessor(kwargs.getElevation, 'elevation', () => 1000),
1755
+ } as any);
1756
+ this.deckLayers.set(id, layer);
1757
+ this.updateDeckOverlay();
1758
+ }
1759
+
1760
+ private handleAddGridCellLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1761
+ if (!this.map) return;
1762
+ this.initializeDeckOverlay();
1763
+ const id = (kwargs.id as string) || `gridcell-${Date.now()}`;
1764
+ const data = kwargs.data as unknown[];
1765
+ const layer = new GridCellLayer({
1766
+ id,
1767
+ data,
1768
+ getPosition: this.makeDeckAccessor(kwargs.getPosition, 'coordinates', (d: any) => d.coordinates || [d.lng, d.lat]),
1769
+ } as any);
1770
+ this.deckLayers.set(id, layer);
1771
+ this.updateDeckOverlay();
1772
+ }
1773
+
1774
+ private handleAddSolidPolygonLayer(args: unknown[], kwargs: Record<string, unknown>): void {
1775
+ if (!this.map) return;
1776
+ this.initializeDeckOverlay();
1777
+ const id = (kwargs.id as string) || `solidpolygon-${Date.now()}`;
1778
+ const data = kwargs.data as unknown[];
1779
+ const layer = new SolidPolygonLayer({
1780
+ id,
1781
+ data,
1782
+ getPolygon: this.makeDeckAccessor(kwargs.getPolygon, 'polygon', (d: any) => d.polygon || d.coordinates),
1783
+ } as any);
1784
+ this.deckLayers.set(id, layer);
1785
+ this.updateDeckOverlay();
1786
+ }
1787
+
1788
+ // -------------------------------------------------------------------------
1789
+ // Native Mapbox feature handlers
1790
+ // -------------------------------------------------------------------------
1791
+
1792
+ private handleSetProjection(args: unknown[], kwargs: Record<string, unknown>): void {
1793
+ if (!this.map) return;
1794
+ const projection = (kwargs.projection as string) || (args[0] as string) || 'mercator';
1795
+ try {
1796
+ this.map.setProjection({ type: projection } as unknown as mapboxgl.ProjectionSpecification);
1797
+ } catch {
1798
+ // Ignore
1799
+ }
1800
+ }
1801
+
1802
+ private handleUpdateGeoJSONSource(args: unknown[], kwargs: Record<string, unknown>): void {
1803
+ if (!this.map) return;
1804
+ const sourceId = (kwargs.sourceId as string) || (args[0] as string);
1805
+ const data = kwargs.data;
1806
+ if (!sourceId) return;
1807
+ let source = this.map.getSource(sourceId) as mapboxgl.GeoJSONSource;
1808
+ if (!source && !sourceId.endsWith('-source')) {
1809
+ source = this.map.getSource(sourceId + '-source') as mapboxgl.GeoJSONSource;
1810
+ }
1811
+ if (source?.setData) source.setData(data as GeoJSON.GeoJSON);
1812
+ }
1813
+
1814
+ private handleAddMapImage(args: unknown[], kwargs: Record<string, unknown>): void {
1815
+ if (!this.map) return;
1816
+ const name = (kwargs.name as string) || (args[0] as string);
1817
+ const url = (kwargs.url as string) || (args[1] as string);
1818
+ if (!name || !url) return;
1819
+ this.map.loadImage(url, (err, image) => {
1820
+ if (err) {
1821
+ console.warn('Failed to load image:', err);
1822
+ return;
1823
+ }
1824
+ if (image && !this.map!.hasImage(name)) {
1825
+ this.map!.addImage(name, image);
1826
+ }
1827
+ });
1828
+ }
1829
+
1830
+ private handleAddTooltip(args: unknown[], kwargs: Record<string, unknown>): void {
1831
+ if (!this.map) return;
1832
+ const layerId = kwargs.layerId as string;
1833
+ const template = (kwargs.template as string) || '';
1834
+ const properties = kwargs.properties as string[] | undefined;
1835
+ if (!layerId) return;
1836
+
1837
+ if (this.tooltipLayerHandlers.has(layerId)) {
1838
+ const old = this.tooltipLayerHandlers.get(layerId)!;
1839
+ this.map.off('mousemove', layerId, old);
1840
+ this.tooltipLayerHandlers.delete(layerId);
1841
+ }
1842
+
1843
+ const popup = new Popup({ closeButton: false, closeOnClick: false });
1844
+ const handler = (e: mapboxgl.MapMouseEvent & { features?: GeoJSON.Feature[] }) => {
1845
+ if (!e.features?.length) {
1846
+ popup.remove();
1847
+ return;
1848
+ }
1849
+ const props = e.features[0].properties || {};
1850
+ let html = template
1851
+ ? template.replace(/\{(\w+)\}/g, (_, k) => (props[k] !== undefined ? String(props[k]) : ''))
1852
+ : Object.entries(props).map(([k, v]) => `<b>${k}:</b> ${v}`).join('<br>');
1853
+ popup.setLngLat(e.lngLat).setHTML(`<div style="font-size:12px">${html}</div>`).addTo(this.map!);
1854
+ };
1855
+ this.map.on('mousemove', layerId, handler);
1856
+ this.map.on('mouseleave', layerId, () => popup.remove());
1857
+ this.tooltipLayerHandlers.set(layerId, handler);
1858
+ }
1859
+
1860
+ private handleRemoveTooltip(args: unknown[], kwargs: Record<string, unknown>): void {
1861
+ const layerId = kwargs.layerId as string;
1862
+ if (layerId && this.tooltipLayerHandlers.has(layerId)) {
1863
+ const handler = this.tooltipLayerHandlers.get(layerId)!;
1864
+ this.map?.off('mousemove', layerId, handler);
1865
+ this.tooltipLayerHandlers.delete(layerId);
1866
+ }
1867
+ }
1868
+
1869
+ private handleAddCoordinatesControl(args: unknown[], kwargs: Record<string, unknown>): void {
1870
+ if (!this.map) return;
1871
+ const position = (kwargs.position as string) || 'bottom-left';
1872
+ if (this.coordinatesControl) {
1873
+ this.coordinatesControl.remove();
1874
+ if (this.coordinatesHandler) this.map.off('mousemove', this.coordinatesHandler);
1875
+ }
1876
+ const div = document.createElement('div');
1877
+ div.className = 'mapboxgl-ctrl mapboxgl-ctrl-group anymap-coordinates';
1878
+ div.style.cssText = 'padding:4px 8px;font-size:11px;font-family:monospace;background:rgba(255,255,255,0.9);';
1879
+ div.textContent = 'Lng: 0.0000, Lat: 0.0000';
1880
+ const precision = (kwargs.precision as number) ?? 4;
1881
+ const handler = (e: mapboxgl.MapMouseEvent) => {
1882
+ div.textContent = `Lng: ${e.lngLat.lng.toFixed(precision)}, Lat: ${e.lngLat.lat.toFixed(precision)}`;
1883
+ };
1884
+ this.map.on('mousemove', handler);
1885
+ this.coordinatesHandler = handler;
1886
+ this.map.getContainer().querySelector(`.mapboxgl-ctrl-${position}`)?.appendChild(div);
1887
+ this.coordinatesControl = div;
1888
+ }
1889
+
1890
+ private handleRemoveCoordinatesControl(args: unknown[], kwargs: Record<string, unknown>): void {
1891
+ if (this.coordinatesControl) {
1892
+ this.coordinatesControl.remove();
1893
+ this.coordinatesControl = null;
1894
+ }
1895
+ if (this.coordinatesHandler) {
1896
+ this.map?.off('mousemove', this.coordinatesHandler);
1897
+ this.coordinatesHandler = null;
1898
+ }
1899
+ }
1900
+
1901
+ private handleAddTimeSlider(args: unknown[], kwargs: Record<string, unknown>): void {
1902
+ if (!this.map) return;
1903
+ const layerId = kwargs.layerId as string;
1904
+ const property = kwargs.property as string;
1905
+ const min = (kwargs.min as number) ?? 0;
1906
+ const max = (kwargs.max as number) ?? 100;
1907
+ const step = (kwargs.step as number) ?? 1;
1908
+ const position = (kwargs.position as string) || 'bottom-left';
1909
+
1910
+ this.handleRemoveTimeSlider([], {});
1911
+
1912
+ const container = document.createElement('div');
1913
+ container.className = 'mapboxgl-ctrl anymap-time-slider';
1914
+ container.style.cssText = 'padding:10px;background:rgba(255,255,255,0.95);min-width:250px;';
1915
+
1916
+ const label = document.createElement('div');
1917
+ label.textContent = `${kwargs.label || 'Time'}: ${min}`;
1918
+
1919
+ const slider = document.createElement('input');
1920
+ slider.type = 'range';
1921
+ slider.min = String(min);
1922
+ slider.max = String(max);
1923
+ slider.value = String(min);
1924
+ slider.addEventListener('input', () => {
1925
+ const val = Number(slider.value);
1926
+ label.textContent = `${kwargs.label || 'Time'}: ${val}`;
1927
+ if (layerId && property) this.map?.setFilter(layerId, ['<=', property, val]);
1928
+ });
1929
+
1930
+ container.appendChild(label);
1931
+ container.appendChild(slider);
1932
+ this.map.getContainer().querySelector(`.mapboxgl-ctrl-${position}`)?.appendChild(container);
1933
+ this.timeSliderContainer = container;
1934
+ }
1935
+
1936
+ private handleRemoveTimeSlider(args: unknown[], kwargs: Record<string, unknown>): void {
1937
+ if (this.timeSliderContainer) {
1938
+ this.timeSliderContainer.remove();
1939
+ this.timeSliderContainer = null;
1940
+ }
1941
+ }
1942
+
1943
+ private handleAddSwipeMap(args: unknown[], kwargs: Record<string, unknown>): void {
1944
+ if (!this.map) return;
1945
+ const leftLayer = kwargs.leftLayer as string;
1946
+ const rightLayer = kwargs.rightLayer as string;
1947
+ if (!leftLayer || !rightLayer) return;
1948
+ this.handleRemoveSwipeMap([], {});
1949
+
1950
+ const container = this.map.getContainer();
1951
+ const slider = document.createElement('div');
1952
+ slider.style.cssText = 'position:absolute;top:0;bottom:0;width:4px;background:#fff;cursor:ew-resize;z-index:10;left:50%;';
1953
+ container.appendChild(slider);
1954
+ this.swipeContainer = slider;
1955
+ }
1956
+
1957
+ private handleRemoveSwipeMap(args: unknown[], kwargs: Record<string, unknown>): void {
1958
+ if (this.swipeContainer) {
1959
+ this.swipeContainer.remove();
1960
+ this.swipeContainer = null;
1961
+ }
1962
+ }
1963
+
1964
+ private handleAddOpacitySlider(args: unknown[], kwargs: Record<string, unknown>): void {
1965
+ if (!this.map) return;
1966
+ const layerId = kwargs.layerId as string;
1967
+ const position = (kwargs.position as string) || 'top-right';
1968
+ if (!layerId) return;
1969
+
1970
+ const container = document.createElement('div');
1971
+ container.className = 'mapboxgl-ctrl anymap-opacity-slider';
1972
+ container.style.cssText = 'padding:8px;background:rgba(255,255,255,0.95);min-width:150px;';
1973
+
1974
+ const label = document.createElement('div');
1975
+ label.textContent = `${kwargs.label || layerId}: 100%`;
1976
+
1977
+ const slider = document.createElement('input');
1978
+ slider.type = 'range';
1979
+ slider.min = '0';
1980
+ slider.max = '100';
1981
+ slider.value = '100';
1982
+ slider.addEventListener('input', () => {
1983
+ const opacity = Number(slider.value) / 100;
1984
+ label.textContent = `${kwargs.label || layerId}: ${slider.value}%`;
1985
+ if (this.map?.getLayer(layerId)) {
1986
+ const layer = this.map.getLayer(layerId);
1987
+ const type = (layer as { type?: string })?.type;
1988
+ const prop = type === 'raster' ? 'raster-opacity' : type === 'fill' ? 'fill-opacity' : type === 'line' ? 'line-opacity' : type === 'circle' ? 'circle-opacity' : null;
1989
+ if (prop) this.map.setPaintProperty(layerId, prop, opacity);
1990
+ }
1991
+ });
1992
+
1993
+ container.appendChild(label);
1994
+ container.appendChild(slider);
1995
+ this.map.getContainer().querySelector(`.mapboxgl-ctrl-${position}`)?.appendChild(container);
1996
+ this.opacitySliderContainer.set(layerId, container);
1997
+ }
1998
+
1999
+ private handleRemoveOpacitySlider(args: unknown[], kwargs: Record<string, unknown>): void {
2000
+ const layerId = kwargs.layerId as string;
2001
+ const el = this.opacitySliderContainer.get(layerId);
2002
+ if (el) {
2003
+ el.remove();
2004
+ this.opacitySliderContainer.delete(layerId);
2005
+ }
2006
+ }
2007
+
2008
+ private handleAddStyleSwitcher(args: unknown[], kwargs: Record<string, unknown>): void {
2009
+ if (!this.map) return;
2010
+ const styles = kwargs.styles as Record<string, string>;
2011
+ const position = (kwargs.position as string) || 'top-right';
2012
+ if (!styles || !Object.keys(styles).length) return;
2013
+ this.handleRemoveStyleSwitcher([], {});
2014
+
2015
+ const container = document.createElement('div');
2016
+ container.className = 'mapboxgl-ctrl anymap-style-switcher';
2017
+ const select = document.createElement('select');
2018
+ for (const [name, url] of Object.entries(styles)) {
2019
+ const opt = document.createElement('option');
2020
+ opt.value = url;
2021
+ opt.textContent = name;
2022
+ select.appendChild(opt);
2023
+ }
2024
+ select.addEventListener('change', () => this.map!.setStyle(select.value));
2025
+ container.appendChild(select);
2026
+ this.map.getContainer().querySelector(`.mapboxgl-ctrl-${position}`)?.appendChild(container);
2027
+ this.styleSwitcherContainer = container;
2028
+ }
2029
+
2030
+ private handleRemoveStyleSwitcher(args: unknown[], kwargs: Record<string, unknown>): void {
2031
+ if (this.styleSwitcherContainer) {
2032
+ this.styleSwitcherContainer.remove();
2033
+ this.styleSwitcherContainer = null;
2034
+ }
2035
+ }
2036
+
2037
+ private handleGetVisibleFeatures(args: unknown[], kwargs: Record<string, unknown>): void {
2038
+ if (!this.map) return;
2039
+ const layers = kwargs.layers as string[] | undefined;
2040
+ const canvas = this.map.getCanvas();
2041
+ const bbox: [mapboxgl.PointLike, mapboxgl.PointLike] = canvas
2042
+ ? [[0, 0], [canvas.width, canvas.height]]
2043
+ : [[0, 0], [256, 256]];
2044
+ const features = layers
2045
+ ? this.map.queryRenderedFeatures(bbox, { layers })
2046
+ : this.map.queryRenderedFeatures(bbox);
2047
+ const geojson: FeatureCollection = {
2048
+ type: 'FeatureCollection',
2049
+ features: features.map((f) => ({ type: 'Feature' as const, geometry: f.geometry, properties: f.properties })),
2050
+ };
2051
+ this.model.set('_queried_features', { type: 'visible_features', data: geojson });
2052
+ this.model.save_changes();
2053
+ }
2054
+
2055
+ private handleGetLayerData(args: unknown[], kwargs: Record<string, unknown>): void {
2056
+ if (!this.map) return;
2057
+ const sourceId = kwargs.sourceId as string;
2058
+ if (!sourceId) return;
2059
+ let features: GeoJSON.Feature[] = [];
2060
+ try {
2061
+ features = this.map.querySourceFeatures(sourceId);
2062
+ } catch {
2063
+ try {
2064
+ features = this.map.querySourceFeatures(sourceId + '-source');
2065
+ } catch {
2066
+ // ignore
2067
+ }
2068
+ }
2069
+ const geojson: FeatureCollection = {
2070
+ type: 'FeatureCollection',
2071
+ features: features.map((f) => ({ type: 'Feature' as const, geometry: f.geometry, properties: f.properties })),
2072
+ };
2073
+ this.model.set('_queried_features', { type: 'layer_data', sourceId, data: geojson });
2074
+ this.model.save_changes();
2075
+ }
2076
+
2077
+ // -------------------------------------------------------------------------
2078
+ // LiDAR layer handlers (maplibre-gl-lidar)
2079
+ // -------------------------------------------------------------------------
2080
+
2081
+ private handleAddLidarControl(args: unknown[], kwargs: Record<string, unknown>): void {
2082
+ if (!this.map) return;
2083
+
2084
+ if (this.lidarControl) {
2085
+ console.warn('LiDAR control already exists');
2086
+ return;
2087
+ }
2088
+
2089
+ const options = {
2090
+ position: (kwargs.position as string) || 'top-right',
2091
+ collapsed: kwargs.collapsed !== false,
2092
+ title: (kwargs.title as string) || 'LiDAR Viewer',
2093
+ panelWidth: (kwargs.panelWidth as number) || 365,
2094
+ panelMaxHeight: (kwargs.panelMaxHeight as number) || 600,
2095
+ pointSize: (kwargs.pointSize as number) || 2,
2096
+ opacity: (kwargs.opacity as number) || 1.0,
2097
+ colorScheme: (kwargs.colorScheme as string) || 'elevation',
2098
+ usePercentile: kwargs.usePercentile !== false,
2099
+ pointBudget: (kwargs.pointBudget as number) || 1000000,
2100
+ pickable: kwargs.pickable === true,
2101
+ autoZoom: kwargs.autoZoom !== false,
2102
+ copcLoadingMode: kwargs.copcLoadingMode as 'full' | 'dynamic' | undefined,
2103
+ streamingPointBudget: (kwargs.streamingPointBudget as number) || 5000000,
2104
+ };
2105
+
2106
+ // LidarControl works with both MapLibre and Mapbox GL JS
2107
+ this.lidarControl = new LidarControl(options as LidarControlOptions);
2108
+ this.map.addControl(
2109
+ this.lidarControl as unknown as mapboxgl.IControl,
2110
+ options.position as ControlPosition
2111
+ );
2112
+
2113
+ this.lidarControl.on('load', (event) => {
2114
+ const info = event.pointCloud as { id: string; name: string; pointCount: number; source?: string } | undefined;
2115
+ if (info && 'name' in info) {
2116
+ this.lidarLayers.set(info.id, info.source || '');
2117
+ this.sendEvent('lidar:load', { id: info.id, name: info.name, pointCount: info.pointCount });
2118
+ }
2119
+ });
2120
+
2121
+ this.lidarControl.on('unload', (event) => {
2122
+ const pointCloud = event.pointCloud as { id: string } | undefined;
2123
+ if (pointCloud) {
2124
+ this.lidarLayers.delete(pointCloud.id);
2125
+ this.sendEvent('lidar:unload', { id: pointCloud.id });
2126
+ }
2127
+ });
2128
+ }
2129
+
2130
+ private handleAddLidarLayer(args: unknown[], kwargs: Record<string, unknown>): void {
2131
+ if (!this.map) return;
2132
+
2133
+ const source = kwargs.source as string;
2134
+ const name = (kwargs.name as string) || `lidar-${Date.now()}`;
2135
+ const isBase64 = kwargs.isBase64 === true;
2136
+
2137
+ if (!source) {
2138
+ console.error('LiDAR layer requires a source URL or base64 data');
2139
+ return;
2140
+ }
2141
+
2142
+ if (!this.lidarControl) {
2143
+ this.lidarControl = new LidarControl({
2144
+ collapsed: true,
2145
+ position: 'top-right',
2146
+ pointSize: (kwargs.pointSize as number) || 2,
2147
+ opacity: (kwargs.opacity as number) || 1.0,
2148
+ colorScheme: (kwargs.colorScheme as string) || 'elevation',
2149
+ usePercentile: kwargs.usePercentile !== false,
2150
+ pointBudget: (kwargs.pointBudget as number) || 1000000,
2151
+ pickable: kwargs.pickable !== false,
2152
+ autoZoom: kwargs.autoZoom !== false,
2153
+ } as LidarControlOptions);
2154
+
2155
+ this.map.addControl(this.lidarControl as unknown as mapboxgl.IControl, 'top-right');
2156
+
2157
+ this.lidarControl.on('load', (event) => {
1004
2158
  const info = event.pointCloud as { id: string; name: string; pointCount: number; source?: string } | undefined;
1005
2159
  if (info && 'name' in info) {
1006
2160
  this.lidarLayers.set(info.id, info.source || '');
@@ -1098,6 +2252,716 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
1098
2252
  }
1099
2253
  }
1100
2254
 
2255
+ // -------------------------------------------------------------------------
2256
+ // PMTiles, maplibre-gl-components, clustering, choropleth, 3D buildings
2257
+ // -------------------------------------------------------------------------
2258
+
2259
+ private handleAddPMTilesLayer(args: unknown[], kwargs: Record<string, unknown>): void {
2260
+ if (!this.map) return;
2261
+ const url = kwargs.url as string;
2262
+ const layerId = (kwargs.id as string) || `pmtiles-${Date.now()}`;
2263
+ const sourceType = (kwargs.sourceType as string) || 'vector';
2264
+ const opacity = (kwargs.opacity as number) ?? 1;
2265
+ const pmtilesUrl = url.startsWith('pmtiles://') ? url : `pmtiles://${url}`;
2266
+ const sourceId = `${layerId}-source`;
2267
+
2268
+ if (!this.map.getSource(sourceId)) {
2269
+ this.map.addSource(sourceId, { type: sourceType as 'vector' | 'raster', url: pmtilesUrl });
2270
+ }
2271
+ if (!this.map.getLayer(layerId)) {
2272
+ const layerConfig: Record<string, unknown> = {
2273
+ id: layerId,
2274
+ type: sourceType === 'vector' ? 'fill' : 'raster',
2275
+ source: sourceId,
2276
+ paint: sourceType === 'vector' ? { 'fill-color': '#3388ff', 'fill-opacity': opacity } : { 'raster-opacity': opacity },
2277
+ };
2278
+ this.map.addLayer(layerConfig as mapboxgl.AnyLayer);
2279
+ }
2280
+ }
2281
+
2282
+ private handleRemovePMTilesLayer(args: unknown[], kwargs: Record<string, unknown>): void {
2283
+ const [layerId] = args as [string];
2284
+ const sourceId = `${layerId}-source`;
2285
+ if (this.map?.getLayer(layerId)) this.map.removeLayer(layerId);
2286
+ if (this.map?.getSource(sourceId)) this.map.removeSource(sourceId);
2287
+ }
2288
+
2289
+ private handleAddPMTilesControl(args: unknown[], kwargs: Record<string, unknown>): void {
2290
+ if (!this.map) return;
2291
+ const position = (kwargs.position as ControlPosition) || 'top-right';
2292
+ this.pmtilesLayerControl = new PMTilesLayerControl({ collapsed: kwargs.collapsed !== false } as any);
2293
+ this.map.addControl(this.pmtilesLayerControl as unknown as mapboxgl.IControl, position);
2294
+ this.controlsMap.set('pmtiles-control', this.pmtilesLayerControl as unknown as mapboxgl.IControl);
2295
+ }
2296
+
2297
+ private handleAddCogControl(args: unknown[], kwargs: Record<string, unknown>): void {
2298
+ if (!this.map) return;
2299
+ const position = (kwargs.position as ControlPosition) || 'top-right';
2300
+ this.cogLayerUiControl = new CogLayerControl({ collapsed: kwargs.collapsed !== false } as any);
2301
+ this.map.addControl(this.cogLayerUiControl as unknown as mapboxgl.IControl, position);
2302
+ this.controlsMap.set('cog-control', this.cogLayerUiControl as unknown as mapboxgl.IControl);
2303
+ }
2304
+
2305
+ private handleAddZarrControl(args: unknown[], kwargs: Record<string, unknown>): void {
2306
+ if (!this.map) return;
2307
+ const position = (kwargs.position as ControlPosition) || 'top-right';
2308
+ this.zarrLayerUiControl = new ZarrLayerControl({ collapsed: kwargs.collapsed !== false } as any);
2309
+ this.map.addControl(this.zarrLayerUiControl as unknown as mapboxgl.IControl, position);
2310
+ this.controlsMap.set('zarr-control', this.zarrLayerUiControl as unknown as mapboxgl.IControl);
2311
+ }
2312
+
2313
+ private handleAddVectorControl(args: unknown[], kwargs: Record<string, unknown>): void {
2314
+ if (!this.map) return;
2315
+ const position = (kwargs.position as ControlPosition) || 'top-right';
2316
+ this.addVectorControl = new AddVectorControl({ collapsed: kwargs.collapsed !== false } as any);
2317
+ this.map.addControl(this.addVectorControl as unknown as mapboxgl.IControl, position);
2318
+ this.controlsMap.set('vector-control', this.addVectorControl as unknown as mapboxgl.IControl);
2319
+ }
2320
+
2321
+ private handleAddControlGrid(args: unknown[], kwargs: Record<string, unknown>): void {
2322
+ if (!this.map) return;
2323
+ const position = (kwargs.position as string) || 'top-right';
2324
+ this.controlGrid = addControlGrid(this.map as any, { position } as any);
2325
+ this.controlsMap.set('control-grid', this.controlGrid as unknown as mapboxgl.IControl);
2326
+ }
2327
+
2328
+ private handleAddClusterLayer(args: unknown[], kwargs: Record<string, unknown>): void {
2329
+ if (!this.map) return;
2330
+ const geojson = kwargs.data as GeoJSON.FeatureCollection;
2331
+ const name = (kwargs.name as string) || `cluster-${Date.now()}`;
2332
+ const sourceId = `${name}-source`;
2333
+
2334
+ if (!this.map.getSource(sourceId)) {
2335
+ this.map.addSource(sourceId, {
2336
+ type: 'geojson',
2337
+ data: geojson,
2338
+ cluster: true,
2339
+ clusterMaxZoom: (kwargs.clusterMaxZoom as number) || 14,
2340
+ clusterRadius: (kwargs.clusterRadius as number) || 50,
2341
+ });
2342
+ }
2343
+ const clusterLayerId = `${name}-clusters`;
2344
+ if (!this.map.getLayer(clusterLayerId)) {
2345
+ this.map.addLayer({
2346
+ id: clusterLayerId,
2347
+ type: 'circle',
2348
+ source: sourceId,
2349
+ filter: ['has', 'point_count'],
2350
+ paint: {
2351
+ 'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1'],
2352
+ 'circle-radius': ['step', ['get', 'point_count'], 15, 100, 20, 750, 25],
2353
+ },
2354
+ });
2355
+ }
2356
+ const unclusteredId = `${name}-unclustered`;
2357
+ if (!this.map.getLayer(unclusteredId)) {
2358
+ this.map.addLayer({
2359
+ id: unclusteredId,
2360
+ type: 'circle',
2361
+ source: sourceId,
2362
+ filter: ['!', ['has', 'point_count']],
2363
+ paint: {
2364
+ 'circle-color': (kwargs.unclusteredColor as string) || '#11b4da',
2365
+ 'circle-radius': (kwargs.unclusteredRadius as number) || 8,
2366
+ },
2367
+ });
2368
+ }
2369
+ }
2370
+
2371
+ private handleRemoveClusterLayer(args: unknown[], kwargs: Record<string, unknown>): void {
2372
+ const [layerId] = args as [string];
2373
+ const sourceId = `${layerId}-source`;
2374
+ for (const id of [`${layerId}-clusters`, `${layerId}-cluster-count`, `${layerId}-unclustered`]) {
2375
+ if (this.map?.getLayer(id)) this.map.removeLayer(id);
2376
+ }
2377
+ if (this.map?.getSource(sourceId)) this.map.removeSource(sourceId);
2378
+ }
2379
+
2380
+ private handleAddChoropleth(args: unknown[], kwargs: Record<string, unknown>): void {
2381
+ if (!this.map) return;
2382
+ const geojson = kwargs.data as GeoJSON.FeatureCollection;
2383
+ const name = (kwargs.name as string) || `choropleth-${Date.now()}`;
2384
+ const sourceId = `${name}-source`;
2385
+ const stepExpression = kwargs.stepExpression as unknown[];
2386
+
2387
+ if (!this.map.getSource(sourceId)) {
2388
+ this.map.addSource(sourceId, { type: 'geojson', data: geojson, generateId: true });
2389
+ }
2390
+ if (!this.map.getLayer(name)) {
2391
+ this.map.addLayer({
2392
+ id: name,
2393
+ type: 'fill',
2394
+ source: sourceId,
2395
+ paint: {
2396
+ 'fill-color': (stepExpression as mapboxgl.ExpressionSpecification) || '#3388ff',
2397
+ 'fill-opacity': (kwargs.fillOpacity as number) ?? 0.7,
2398
+ },
2399
+ } as mapboxgl.AnyLayer);
2400
+ }
2401
+ }
2402
+
2403
+ private handleAdd3DBuildings(args: unknown[], kwargs: Record<string, unknown>): void {
2404
+ if (!this.map) return;
2405
+ const layerId = (kwargs.layerId as string) || '3d-buildings';
2406
+ const style = this.map.getStyle();
2407
+ let sourceId: string | null = null;
2408
+ for (const [id, src] of Object.entries(style.sources || {})) {
2409
+ if ((src as { type?: string }).type === 'vector') {
2410
+ sourceId = id;
2411
+ break;
2412
+ }
2413
+ }
2414
+ if (!sourceId) {
2415
+ sourceId = 'buildings-source';
2416
+ if (!this.map.getSource(sourceId)) {
2417
+ this.map.addSource(sourceId, { type: 'vector', url: 'https://tiles.openfreemap.org/planet' });
2418
+ }
2419
+ }
2420
+ if (!this.map.getLayer(layerId)) {
2421
+ this.map.addLayer({
2422
+ id: layerId,
2423
+ source: sourceId,
2424
+ 'source-layer': 'building',
2425
+ type: 'fill-extrusion',
2426
+ minzoom: (kwargs.minZoom as number) ?? 14,
2427
+ paint: {
2428
+ 'fill-extrusion-color': (kwargs.fillExtrusionColor as string) || '#aaa',
2429
+ 'fill-extrusion-height': ['coalesce', ['get', 'render_height'], ['get', 'height'], 10],
2430
+ 'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0],
2431
+ 'fill-extrusion-opacity': (kwargs.fillExtrusionOpacity as number) ?? 0.6,
2432
+ },
2433
+ } as mapboxgl.AnyLayer);
2434
+ }
2435
+ }
2436
+
2437
+ // -------------------------------------------------------------------------
2438
+ // Route Animation
2439
+ // -------------------------------------------------------------------------
2440
+
2441
+ private handleAnimateAlongRoute(args: unknown[], kwargs: Record<string, unknown>): void {
2442
+ if (!this.map) return;
2443
+ const id = kwargs.id as string;
2444
+ const coordinates = kwargs.coordinates as [number, number][];
2445
+ const duration = (kwargs.duration as number) || 10000;
2446
+ const loop = kwargs.loop !== false;
2447
+ const showTrail = kwargs.showTrail === true;
2448
+
2449
+ const line = lineString(coordinates);
2450
+ const routeLength = length(line, { units: 'kilometers' });
2451
+ const marker = new Marker({ color: (kwargs.markerColor as string) || '#3388ff' })
2452
+ .setLngLat(coordinates[0])
2453
+ .addTo(this.map);
2454
+
2455
+ let trailSourceId: string | undefined;
2456
+ let trailLayerId: string | undefined;
2457
+ if (showTrail) {
2458
+ trailSourceId = `${id}-trail-source`;
2459
+ trailLayerId = `${id}-trail`;
2460
+ this.map.addSource(trailSourceId, {
2461
+ type: 'geojson',
2462
+ data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: [] } },
2463
+ });
2464
+ this.map.addLayer({
2465
+ id: trailLayerId,
2466
+ type: 'line',
2467
+ source: trailSourceId,
2468
+ paint: { 'line-color': '#3388ff', 'line-width': 3 },
2469
+ });
2470
+ }
2471
+
2472
+ const startTime = performance.now();
2473
+ const trailCoords: [number, number][] = [coordinates[0]];
2474
+
2475
+ const animate = (currentTime: number) => {
2476
+ const anim = this.animations.get(id);
2477
+ if (!anim || !this.map) return;
2478
+ if (anim.isPaused) {
2479
+ anim.animationId = requestAnimationFrame(animate);
2480
+ return;
2481
+ }
2482
+ const elapsed = (currentTime - anim.startTime) * anim.speed;
2483
+ const progress = (elapsed % anim.duration) / anim.duration;
2484
+ const distance = progress * routeLength;
2485
+ const point = along(line, distance, { units: 'kilometers' });
2486
+ const pos = point.geometry.coordinates as [number, number];
2487
+ anim.marker.setLngLat(pos);
2488
+ if (showTrail && trailSourceId) {
2489
+ trailCoords.push(pos);
2490
+ (this.map.getSource(trailSourceId) as mapboxgl.GeoJSONSource)?.setData({
2491
+ type: 'Feature',
2492
+ properties: {},
2493
+ geometry: { type: 'LineString', coordinates: trailCoords },
2494
+ });
2495
+ }
2496
+ if (elapsed >= anim.duration && !loop) {
2497
+ this.handleStopAnimation([id], {});
2498
+ return;
2499
+ }
2500
+ anim.animationId = requestAnimationFrame(animate);
2501
+ };
2502
+
2503
+ this.animations.set(id, {
2504
+ animationId: requestAnimationFrame(animate),
2505
+ marker,
2506
+ isPaused: false,
2507
+ speed: 1,
2508
+ startTime,
2509
+ pausedAt: 0,
2510
+ duration,
2511
+ coordinates,
2512
+ loop,
2513
+ trailSourceId,
2514
+ trailLayerId,
2515
+ });
2516
+ }
2517
+
2518
+ private handleStopAnimation(args: unknown[], kwargs: Record<string, unknown>): void {
2519
+ const [id] = args as [string];
2520
+ const anim = this.animations.get(id);
2521
+ if (!anim) return;
2522
+ cancelAnimationFrame(anim.animationId);
2523
+ anim.marker.remove();
2524
+ if (anim.trailLayerId && this.map?.getLayer(anim.trailLayerId)) this.map.removeLayer(anim.trailLayerId);
2525
+ if (anim.trailSourceId && this.map?.getSource(anim.trailSourceId)) this.map.removeSource(anim.trailSourceId);
2526
+ this.animations.delete(id);
2527
+ }
2528
+
2529
+ private handlePauseAnimation(args: unknown[], kwargs: Record<string, unknown>): void {
2530
+ const [id] = args as [string];
2531
+ const anim = this.animations.get(id);
2532
+ if (anim) {
2533
+ anim.isPaused = true;
2534
+ anim.pausedAt = performance.now();
2535
+ }
2536
+ }
2537
+
2538
+ private handleResumeAnimation(args: unknown[], kwargs: Record<string, unknown>): void {
2539
+ const [id] = args as [string];
2540
+ const anim = this.animations.get(id);
2541
+ if (anim?.isPaused) {
2542
+ anim.startTime += performance.now() - anim.pausedAt;
2543
+ anim.isPaused = false;
2544
+ anim.pausedAt = 0;
2545
+ }
2546
+ }
2547
+
2548
+ private handleSetAnimationSpeed(args: unknown[], kwargs: Record<string, unknown>): void {
2549
+ const [id, speed] = args as [string, number];
2550
+ const anim = this.animations.get(id);
2551
+ if (anim) anim.speed = speed;
2552
+ }
2553
+
2554
+ // -------------------------------------------------------------------------
2555
+ // Feature Hover, Fog, Filter, Query
2556
+ // -------------------------------------------------------------------------
2557
+
2558
+ private handleAddHoverEffect(args: unknown[], kwargs: Record<string, unknown>): void {
2559
+ if (!this.map) return;
2560
+ const layerId = kwargs.layerId as string;
2561
+ const layer = this.map.getLayer(layerId);
2562
+ if (!layer) return;
2563
+ const sourceId = (layer as { source?: string }).source;
2564
+ if (!sourceId) return;
2565
+
2566
+ this.map.on('mousemove', layerId, (e) => {
2567
+ if (!this.map || !e.features?.length) return;
2568
+ if (this.hoveredFeatureId !== null) {
2569
+ this.map.setFeatureState({ source: sourceId, id: this.hoveredFeatureId }, { hover: false });
2570
+ }
2571
+ const f = e.features[0];
2572
+ this.hoveredFeatureId = (f as GeoJSON.Feature).id ?? null;
2573
+ this.hoveredLayerId = layerId;
2574
+ if (this.hoveredFeatureId !== null) {
2575
+ this.map.setFeatureState({ source: sourceId, id: this.hoveredFeatureId }, { hover: true });
2576
+ }
2577
+ this.map.getCanvas().style.cursor = 'pointer';
2578
+ });
2579
+ this.map.on('mouseleave', layerId, () => {
2580
+ if (this.map && this.hoveredFeatureId !== null) {
2581
+ this.map.setFeatureState({ source: sourceId, id: this.hoveredFeatureId }, { hover: false });
2582
+ }
2583
+ this.hoveredFeatureId = null;
2584
+ this.hoveredLayerId = null;
2585
+ if (this.map) this.map.getCanvas().style.cursor = '';
2586
+ });
2587
+ }
2588
+
2589
+ private handleSetFog(args: unknown[], kwargs: Record<string, unknown>): void {
2590
+ if (!this.map) return;
2591
+ const fog: mapboxgl.FogSpecification = {
2592
+ range: (kwargs.range as [number, number]) || [0.5, 10],
2593
+ color: (kwargs.color as string) || 'white',
2594
+ 'high-color': (kwargs.highColor as string) || '#245cdf',
2595
+ 'space-color': (kwargs.spaceColor as string) || '#000000',
2596
+ };
2597
+ this.map.setFog(fog);
2598
+ }
2599
+
2600
+ private handleRemoveFog(args: unknown[], kwargs: Record<string, unknown>): void {
2601
+ if (!this.map) return;
2602
+ this.map.setFog(null);
2603
+ }
2604
+
2605
+ private handleSetFilter(args: unknown[], kwargs: Record<string, unknown>): void {
2606
+ if (!this.map) return;
2607
+ const layerId = kwargs.layerId as string;
2608
+ const filter = kwargs.filter as unknown[] | null;
2609
+ if (layerId && this.map.getLayer(layerId)) {
2610
+ this.map.setFilter(layerId, filter as mapboxgl.FilterSpecification | null);
2611
+ this.stateManager.setLayerFilter(layerId, filter);
2612
+ }
2613
+ }
2614
+
2615
+ private handleQueryRenderedFeatures(args: unknown[], kwargs: Record<string, unknown>): void {
2616
+ if (!this.map) return;
2617
+ const geometry = kwargs.geometry as mapboxgl.PointLike | [mapboxgl.PointLike, mapboxgl.PointLike] | undefined;
2618
+ const layers = kwargs.layers as string[] | undefined;
2619
+ const canvas = this.map.getCanvas();
2620
+ const bbox: [mapboxgl.PointLike, mapboxgl.PointLike] = canvas
2621
+ ? [[0, 0], [canvas.width, canvas.height]]
2622
+ : [[0, 0], [256, 256]];
2623
+ const features = geometry
2624
+ ? this.map.queryRenderedFeatures(geometry, { layers })
2625
+ : this.map.queryRenderedFeatures(bbox, { layers });
2626
+ this.model.set('_queried_features', {
2627
+ type: 'FeatureCollection',
2628
+ features: features.map((f) => ({ type: 'Feature' as const, geometry: f.geometry, properties: f.properties, id: f.id })),
2629
+ });
2630
+ this.model.save_changes();
2631
+ }
2632
+
2633
+ private handleQuerySourceFeatures(args: unknown[], kwargs: Record<string, unknown>): void {
2634
+ if (!this.map) return;
2635
+ const sourceId = kwargs.sourceId as string;
2636
+ if (!sourceId) return;
2637
+ const features = this.map.querySourceFeatures(sourceId);
2638
+ this.model.set('_queried_features', {
2639
+ type: 'FeatureCollection',
2640
+ features: features.map((f) => ({ type: 'Feature' as const, geometry: f.geometry, properties: f.properties, id: f.id })),
2641
+ });
2642
+ this.model.save_changes();
2643
+ }
2644
+
2645
+ // -------------------------------------------------------------------------
2646
+ // Video Layer
2647
+ // -------------------------------------------------------------------------
2648
+
2649
+ private handleAddVideoLayer(args: unknown[], kwargs: Record<string, unknown>): void {
2650
+ if (!this.map) return;
2651
+ const id = (kwargs.id as string) || `video-${Date.now()}`;
2652
+ const urls = kwargs.urls as string[];
2653
+ const coordinates = kwargs.coordinates as number[][];
2654
+ if (!urls?.length || !coordinates || coordinates.length !== 4) return;
2655
+
2656
+ const sourceId = `${id}-source`;
2657
+ this.map.addSource(sourceId, {
2658
+ type: 'video',
2659
+ urls,
2660
+ coordinates: coordinates as [[number, number], [number, number], [number, number], [number, number]],
2661
+ });
2662
+ this.map.addLayer({
2663
+ id,
2664
+ type: 'raster',
2665
+ source: sourceId,
2666
+ paint: { 'raster-opacity': (kwargs.opacity as number) ?? 1 },
2667
+ });
2668
+ this.videoSources.set(id, sourceId);
2669
+ }
2670
+
2671
+ private handleRemoveVideoLayer(args: unknown[], kwargs: Record<string, unknown>): void {
2672
+ const id = kwargs.id as string;
2673
+ if (!id) return;
2674
+ const sourceId = this.videoSources.get(id) || `${id}-source`;
2675
+ if (this.map?.getLayer(id)) this.map.removeLayer(id);
2676
+ if (this.map?.getSource(sourceId)) this.map.removeSource(sourceId);
2677
+ this.videoSources.delete(id);
2678
+ }
2679
+
2680
+ private handlePlayVideo(args: unknown[], kwargs: Record<string, unknown>): void {
2681
+ const id = kwargs.id as string;
2682
+ const sourceId = this.videoSources.get(id) || `${id}-source`;
2683
+ const source = this.map?.getSource(sourceId) as { play?: () => void };
2684
+ if (source?.play) source.play();
2685
+ }
2686
+
2687
+ private handlePauseVideo(args: unknown[], kwargs: Record<string, unknown>): void {
2688
+ const id = kwargs.id as string;
2689
+ const sourceId = this.videoSources.get(id) || `${id}-source`;
2690
+ const source = this.map?.getSource(sourceId) as { pause?: () => void };
2691
+ if (source?.pause) source.pause();
2692
+ }
2693
+
2694
+ private handleSeekVideo(args: unknown[], kwargs: Record<string, unknown>): void {
2695
+ const id = kwargs.id as string;
2696
+ const time = kwargs.time as number;
2697
+ const sourceId = this.videoSources.get(id) || `${id}-source`;
2698
+ const source = this.map?.getSource(sourceId) as { getVideo?: () => HTMLVideoElement };
2699
+ const video = source?.getVideo?.();
2700
+ if (video) video.currentTime = time;
2701
+ }
2702
+
2703
+ // -------------------------------------------------------------------------
2704
+ // Split Map
2705
+ // -------------------------------------------------------------------------
2706
+
2707
+ private handleAddSplitMap(args: unknown[], kwargs: Record<string, unknown>): void {
2708
+ if (!this.map || !this.mapContainer) return;
2709
+ const leftLayer = kwargs.leftLayer as string;
2710
+ const rightLayer = kwargs.rightLayer as string;
2711
+ if (!leftLayer || !rightLayer) return;
2712
+ if (this.splitActive) this.handleRemoveSplitMap([], {});
2713
+
2714
+ this.splitActive = true;
2715
+ if (this.map.getLayer(rightLayer)) {
2716
+ this.map.setLayoutProperty(rightLayer, 'visibility', 'none');
2717
+ }
2718
+
2719
+ const rightContainer = document.createElement('div');
2720
+ rightContainer.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;clip-path:inset(0 0 0 50%);pointer-events:none;';
2721
+ this.mapContainer.appendChild(rightContainer);
2722
+ this.splitMapContainer = rightContainer;
2723
+
2724
+ const style = this.model.get('style');
2725
+ this.splitMapRight = new MapboxMap({
2726
+ container: rightContainer,
2727
+ style: typeof style === 'string' ? style : (style as mapboxgl.StyleSpecification),
2728
+ center: this.map.getCenter(),
2729
+ zoom: this.map.getZoom(),
2730
+ bearing: this.map.getBearing(),
2731
+ pitch: this.map.getPitch(),
2732
+ interactive: false,
2733
+ attributionControl: false,
2734
+ });
2735
+
2736
+ const syncMaps = () => {
2737
+ if (this.map && this.splitMapRight) {
2738
+ this.splitMapRight.jumpTo({
2739
+ center: this.map.getCenter(),
2740
+ zoom: this.map.getZoom(),
2741
+ bearing: this.map.getBearing(),
2742
+ pitch: this.map.getPitch(),
2743
+ });
2744
+ }
2745
+ };
2746
+ this.map.on('move', syncMaps);
2747
+
2748
+ const slider = document.createElement('div');
2749
+ slider.style.cssText = 'position:absolute;top:0;left:50%;width:4px;height:100%;background:#333;cursor:ew-resize;z-index:10;';
2750
+ this.mapContainer.appendChild(slider);
2751
+ this.splitSlider = slider;
2752
+
2753
+ let isDragging = false;
2754
+ const onPointerDown = () => { isDragging = true; };
2755
+ const onPointerMove = (e: PointerEvent) => {
2756
+ if (!isDragging || !this.mapContainer || !this.splitMapContainer) return;
2757
+ const rect = this.mapContainer.getBoundingClientRect();
2758
+ const pct = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
2759
+ slider.style.left = `${pct}%`;
2760
+ this.splitMapContainer.style.clipPath = `inset(0 0 0 ${pct}%)`;
2761
+ };
2762
+ const onPointerUp = () => { isDragging = false; };
2763
+ slider.addEventListener('pointerdown', onPointerDown);
2764
+ window.addEventListener('pointermove', onPointerMove);
2765
+ window.addEventListener('pointerup', onPointerUp);
2766
+ (slider as any)._cleanup = () => {
2767
+ slider.removeEventListener('pointerdown', onPointerDown);
2768
+ window.removeEventListener('pointermove', onPointerMove);
2769
+ window.removeEventListener('pointerup', onPointerUp);
2770
+ this.map?.off('move', syncMaps);
2771
+ };
2772
+ }
2773
+
2774
+ private handleRemoveSplitMap(args: unknown[], kwargs: Record<string, unknown>): void {
2775
+ if (!this.splitActive) return;
2776
+ if (this.splitSlider) {
2777
+ (this.splitSlider as any)?._cleanup?.();
2778
+ this.splitSlider.remove();
2779
+ this.splitSlider = null;
2780
+ }
2781
+ if (this.splitMapRight) {
2782
+ this.splitMapRight.remove();
2783
+ this.splitMapRight = null;
2784
+ }
2785
+ if (this.splitMapContainer) {
2786
+ this.splitMapContainer.remove();
2787
+ this.splitMapContainer = null;
2788
+ }
2789
+ this.splitActive = false;
2790
+ }
2791
+
2792
+ // -------------------------------------------------------------------------
2793
+ // Colorbar, Search, Measure, Print
2794
+ // -------------------------------------------------------------------------
2795
+
2796
+ private handleAddColorbar(args: unknown[], kwargs: Record<string, unknown>): void {
2797
+ if (!this.map) return;
2798
+ const colorbarId = (kwargs.colorbarId as string) || `colorbar-${Date.now()}`;
2799
+ const position = (kwargs.position as ControlPosition) || 'bottom-right';
2800
+ const colorbar = new Colorbar({
2801
+ colormap: kwargs.colormap as string[],
2802
+ vmin: kwargs.vmin as number,
2803
+ vmax: kwargs.vmax as number,
2804
+ label: kwargs.label as string,
2805
+ } as any);
2806
+ this.map.addControl(colorbar as unknown as mapboxgl.IControl, position);
2807
+ this.colorbarsMap.set(colorbarId, colorbar);
2808
+ this.controlsMap.set(colorbarId, colorbar as unknown as mapboxgl.IControl);
2809
+ }
2810
+
2811
+ private handleRemoveColorbar(args: unknown[], kwargs: Record<string, unknown>): void {
2812
+ const colorbarId = (kwargs.colorbarId as string) || (args[0] as string);
2813
+ if (colorbarId) {
2814
+ const cb = this.colorbarsMap.get(colorbarId);
2815
+ if (cb && this.map) {
2816
+ this.map.removeControl(cb as unknown as mapboxgl.IControl);
2817
+ this.colorbarsMap.delete(colorbarId);
2818
+ this.controlsMap.delete(colorbarId);
2819
+ }
2820
+ } else {
2821
+ this.colorbarsMap.forEach((cb) => {
2822
+ if (this.map) this.map.removeControl(cb as unknown as mapboxgl.IControl);
2823
+ });
2824
+ this.colorbarsMap.clear();
2825
+ }
2826
+ }
2827
+
2828
+ private handleUpdateColorbar(args: unknown[], kwargs: Record<string, unknown>): void {
2829
+ const colorbarId = (kwargs.colorbarId as string) || 'colorbar-0';
2830
+ const colorbar = this.colorbarsMap.get(colorbarId);
2831
+ if (colorbar?.update) colorbar.update(kwargs as any);
2832
+ }
2833
+
2834
+ private handleAddSearchControl(args: unknown[], kwargs: Record<string, unknown>): void {
2835
+ if (!this.map) return;
2836
+ const position = (kwargs.position as ControlPosition) || 'top-left';
2837
+ if (this.searchControl) {
2838
+ this.map.removeControl(this.searchControl as unknown as mapboxgl.IControl);
2839
+ this.controlsMap.delete('search-control');
2840
+ }
2841
+ this.searchControl = new SearchControl(kwargs as any);
2842
+ this.map.addControl(this.searchControl as unknown as mapboxgl.IControl, position);
2843
+ this.controlsMap.set('search-control', this.searchControl as unknown as mapboxgl.IControl);
2844
+ }
2845
+
2846
+ private handleRemoveSearchControl(args: unknown[], kwargs: Record<string, unknown>): void {
2847
+ if (this.searchControl && this.map) {
2848
+ this.map.removeControl(this.searchControl as unknown as mapboxgl.IControl);
2849
+ this.controlsMap.delete('search-control');
2850
+ this.searchControl = null;
2851
+ }
2852
+ }
2853
+
2854
+ private handleAddMeasureControl(args: unknown[], kwargs: Record<string, unknown>): void {
2855
+ if (!this.map) return;
2856
+ const position = (kwargs.position as ControlPosition) || 'top-right';
2857
+ if (this.measureControl) {
2858
+ this.map.removeControl(this.measureControl as unknown as mapboxgl.IControl);
2859
+ this.controlsMap.delete('measure-control');
2860
+ }
2861
+ this.measureControl = new MeasureControl(kwargs as any);
2862
+ this.map.addControl(this.measureControl as unknown as mapboxgl.IControl, position);
2863
+ this.controlsMap.set('measure-control', this.measureControl as unknown as mapboxgl.IControl);
2864
+ }
2865
+
2866
+ private handleRemoveMeasureControl(args: unknown[], kwargs: Record<string, unknown>): void {
2867
+ if (this.measureControl && this.map) {
2868
+ this.map.removeControl(this.measureControl as unknown as mapboxgl.IControl);
2869
+ this.controlsMap.delete('measure-control');
2870
+ this.measureControl = null;
2871
+ }
2872
+ }
2873
+
2874
+ private handleAddPrintControl(args: unknown[], kwargs: Record<string, unknown>): void {
2875
+ if (!this.map) return;
2876
+ const position = (kwargs.position as ControlPosition) || 'top-right';
2877
+ if (this.printControl) {
2878
+ this.map.removeControl(this.printControl as unknown as mapboxgl.IControl);
2879
+ this.controlsMap.delete('print-control');
2880
+ }
2881
+ this.printControl = new PrintControl(kwargs as any);
2882
+ this.map.addControl(this.printControl as unknown as mapboxgl.IControl, position);
2883
+ this.controlsMap.set('print-control', this.printControl as unknown as mapboxgl.IControl);
2884
+ }
2885
+
2886
+ private handleRemovePrintControl(args: unknown[], kwargs: Record<string, unknown>): void {
2887
+ if (this.printControl && this.map) {
2888
+ this.map.removeControl(this.printControl as unknown as mapboxgl.IControl);
2889
+ this.controlsMap.delete('print-control');
2890
+ this.printControl = null;
2891
+ }
2892
+ }
2893
+
2894
+ // -------------------------------------------------------------------------
2895
+ // FlatGeobuf
2896
+ // -------------------------------------------------------------------------
2897
+
2898
+ private async handleAddFlatGeobuf(args: unknown[], kwargs: Record<string, unknown>): Promise<void> {
2899
+ if (!this.map) return;
2900
+ const url = kwargs.url as string;
2901
+ const name = (kwargs.name as string) || `flatgeobuf-${Date.now()}`;
2902
+ const layerType = kwargs.layerType as string | undefined;
2903
+ const paint = kwargs.paint as Record<string, unknown> | undefined;
2904
+ const fitBounds = kwargs.fitBounds !== false;
2905
+
2906
+ const sourceId = `${name}-source`;
2907
+ const layerId = name;
2908
+
2909
+ try {
2910
+ const response = await fetch(url);
2911
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
2912
+ const features: Feature[] = [];
2913
+ for await (const f of flatgeobuf.deserialize(response.body as ReadableStream)) {
2914
+ features.push(f as Feature);
2915
+ }
2916
+ const fc: FeatureCollection = { type: 'FeatureCollection', features };
2917
+
2918
+ let type = layerType;
2919
+ if (!type && features.length > 0) type = this.inferLayerType(features[0].geometry.type);
2920
+ type = type || 'circle';
2921
+ const layerPaint = paint || this.getDefaultPaint(type);
2922
+
2923
+ if (!this.map.getSource(sourceId)) {
2924
+ this.map.addSource(sourceId, { type: 'geojson', data: fc });
2925
+ }
2926
+ if (!this.map.getLayer(layerId)) {
2927
+ this.map.addLayer({ id: layerId, type: type as any, source: sourceId, paint: layerPaint });
2928
+ }
2929
+
2930
+ if (fitBounds && features.length > 0) {
2931
+ const b = new mapboxgl.LngLatBounds();
2932
+ for (const f of features) {
2933
+ const g = f.geometry;
2934
+ if (g.type === 'Point') b.extend(g.coordinates as [number, number]);
2935
+ else if (g.type === 'LineString' || g.type === 'MultiPoint') {
2936
+ for (const c of g.coordinates) b.extend(c as [number, number]);
2937
+ } else if (g.type === 'Polygon' || g.type === 'MultiLineString') {
2938
+ for (const ring of g.coordinates) {
2939
+ for (const c of ring) b.extend(c as [number, number]);
2940
+ }
2941
+ } else if (g.type === 'MultiPolygon') {
2942
+ for (const poly of g.coordinates) {
2943
+ for (const ring of poly) {
2944
+ for (const c of ring) b.extend(c as [number, number]);
2945
+ }
2946
+ }
2947
+ }
2948
+ }
2949
+ if (!b.isEmpty()) this.map.fitBounds(b, { padding: 50 });
2950
+ }
2951
+ } catch (err) {
2952
+ console.error('Error loading FlatGeobuf:', err);
2953
+ }
2954
+ }
2955
+
2956
+ private handleRemoveFlatGeobuf(args: unknown[], kwargs: Record<string, unknown>): void {
2957
+ const name = (kwargs.name as string) || (args[0] as string);
2958
+ if (!name) return;
2959
+ const layerId = name;
2960
+ const sourceId = `${name}-source`;
2961
+ if (this.map?.getLayer(layerId)) this.map.removeLayer(layerId);
2962
+ if (this.map?.getSource(sourceId)) this.map.removeSource(sourceId);
2963
+ }
2964
+
1101
2965
  // -------------------------------------------------------------------------
1102
2966
  // Trait change handlers
1103
2967
  // -------------------------------------------------------------------------
@@ -1128,26 +2992,92 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
1128
2992
  // -------------------------------------------------------------------------
1129
2993
 
1130
2994
  destroy(): void {
2995
+ if (this.splitActive) this.handleRemoveSplitMap([], {});
1131
2996
  this.removeModelListeners();
1132
2997
 
1133
2998
  if (this.resizeDebounceTimer !== null) {
1134
2999
  window.clearTimeout(this.resizeDebounceTimer);
1135
3000
  this.resizeDebounceTimer = null;
1136
3001
  }
1137
-
1138
3002
  if (this.resizeObserver) {
1139
3003
  this.resizeObserver.disconnect();
1140
3004
  this.resizeObserver = null;
1141
3005
  }
1142
3006
 
1143
- // Remove deck.gl overlay
3007
+ if (this.mapboxDraw && this.map) {
3008
+ this.map.removeControl(this.mapboxDraw as unknown as mapboxgl.IControl);
3009
+ this.mapboxDraw = null;
3010
+ }
3011
+
3012
+ for (const [, div] of this.legendsMap) {
3013
+ if (div.parentNode) div.parentNode.removeChild(div);
3014
+ }
3015
+ this.legendsMap.clear();
3016
+
3017
+ if (this.timeSliderContainer) {
3018
+ this.timeSliderContainer.remove();
3019
+ this.timeSliderContainer = null;
3020
+ }
3021
+ this.opacitySliderContainer.forEach((el) => el.remove());
3022
+ this.opacitySliderContainer.clear();
3023
+ if (this.styleSwitcherContainer) {
3024
+ this.styleSwitcherContainer.remove();
3025
+ this.styleSwitcherContainer = null;
3026
+ }
3027
+ if (this.swipeContainer) {
3028
+ this.swipeContainer.remove();
3029
+ this.swipeContainer = null;
3030
+ }
3031
+ if (this.coordinatesControl) {
3032
+ this.coordinatesControl.remove();
3033
+ this.coordinatesControl = null;
3034
+ }
3035
+ if (this.coordinatesHandler) {
3036
+ this.map?.off('mousemove', this.coordinatesHandler);
3037
+ this.coordinatesHandler = null;
3038
+ }
3039
+
3040
+ this.colorbarsMap.forEach((cb) => {
3041
+ if (this.map) this.map.removeControl(cb as unknown as mapboxgl.IControl);
3042
+ });
3043
+ this.colorbarsMap.clear();
3044
+ if (this.searchControl && this.map) {
3045
+ this.map.removeControl(this.searchControl as unknown as mapboxgl.IControl);
3046
+ this.searchControl = null;
3047
+ }
3048
+ if (this.measureControl && this.map) {
3049
+ this.map.removeControl(this.measureControl as unknown as mapboxgl.IControl);
3050
+ this.measureControl = null;
3051
+ }
3052
+ if (this.printControl && this.map) {
3053
+ this.map.removeControl(this.printControl as unknown as mapboxgl.IControl);
3054
+ this.printControl = null;
3055
+ }
3056
+ if (this.controlGrid && this.map) {
3057
+ this.map.removeControl(this.controlGrid as unknown as mapboxgl.IControl);
3058
+ this.controlGrid = null;
3059
+ }
3060
+
1144
3061
  if (this.deckOverlay && this.map) {
1145
3062
  this.map.removeControl(this.deckOverlay as unknown as mapboxgl.IControl);
1146
3063
  this.deckOverlay = null;
1147
3064
  }
1148
3065
  this.deckLayers.clear();
1149
3066
 
1150
- // Remove LiDAR control
3067
+ this.animations.forEach((anim, id) => {
3068
+ cancelAnimationFrame(anim.animationId);
3069
+ anim.marker.remove();
3070
+ if (anim.trailLayerId && this.map?.getLayer(anim.trailLayerId)) this.map.removeLayer(anim.trailLayerId);
3071
+ if (anim.trailSourceId && this.map?.getSource(anim.trailSourceId)) this.map.removeSource(anim.trailSourceId);
3072
+ });
3073
+ this.animations.clear();
3074
+
3075
+ this.zarrLayers.forEach((_, id) => {
3076
+ if (this.map?.getLayer(id)) this.map.removeLayer(id);
3077
+ });
3078
+ this.zarrLayers.clear();
3079
+ this.videoSources.clear();
3080
+
1151
3081
  if (this.lidarControl && this.map) {
1152
3082
  this.map.removeControl(this.lidarControl as unknown as mapboxgl.IControl);
1153
3083
  this.lidarControl = null;
@@ -1156,14 +3086,11 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
1156
3086
 
1157
3087
  this.markersMap.forEach((marker) => marker.remove());
1158
3088
  this.markersMap.clear();
1159
-
1160
3089
  this.popupsMap.forEach((popup) => popup.remove());
1161
3090
  this.popupsMap.clear();
1162
3091
 
1163
3092
  this.controlsMap.forEach((control) => {
1164
- if (this.map) {
1165
- this.map.removeControl(control);
1166
- }
3093
+ if (this.map) this.map.removeControl(control);
1167
3094
  });
1168
3095
  this.controlsMap.clear();
1169
3096
 
@@ -1171,7 +3098,6 @@ export class MapboxRenderer extends BaseMapRenderer<MapboxMap> {
1171
3098
  this.map.remove();
1172
3099
  this.map = null;
1173
3100
  }
1174
-
1175
3101
  if (this.mapContainer) {
1176
3102
  this.mapContainer.remove();
1177
3103
  this.mapContainer = null;