VJMAP logoVJMAP
CAD · WebGIS
中

AI Chat SDK

Updated 2026-03-07~ 727 min read
.md

Click Export Project Template in the AI chat toolbar Click to Download Project Code. See the full codebase in src\chat\sdk.ts.

import vjmap, { Map }  from "vjmap";
import vjcommon from "vjcommon";
export class VjChatSDK {
    map: Map;
    context: Record<string, any>;
    constructor(map: Map, context: Record<string, any>) {
        this.map = map;
		this.context = context;
    }

        /**
     * Get user custom data keys by prefix from server
     * @param {string} prefix Key name prefix
     * @returns {Promise<string[]>} Returns array of retrieved keys
     * @example
      * // Get keys by prefix from server, prefix test
      * let keys = await this.getCustomDataKeysByPrefixServer("test");
      * console.log(keys);
     */
    async getCustomDataKeysByPrefixServer(prefix)  {
            let svc = this.map.getService();
            let res = await svc.getCustomDataKeysByPrefix(prefix);
            return res?.keys || [];
        }
    

    /**
     * Create point marker data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {string} options.text Text to display, optional, default "index ${props.index}"
     * @param {number} options.minZoom Min zoom to display, optional, default 0
     * @param {number} options.maxZoom Max zoom to display, optional, default 24
     * @param {boolean} options.isDrawLinePoint Include line points when drawing, optional, default false
     * @param {string} options.color Marker color, optional, default'#3FB1CE'
     * @param {number[]} options.offset XY pixel offset [0,0], negative = left/up
     * @param {number} options.rotation Marker rotation, clockwise, default 0
     * @param {string} options.anchor Marker anchor:center、top、bottom、left、right、top-left、top-right、bottom-left、bottom-right, default center
     * @param {boolean} options.draggable Draggable, optional, default false
     * @param {string} options.animationType Animation: MAP_ANIMATION_NONE, MAP_ANIMATION_BOUNCE, MAP_ANIMATION_DROP
     * @param {string} options.rotationAlignment Rotation alignment: auto, map, viewport, default auto
     * @param {string} options.pitchAlignment Pitch alignment: auto, map, viewport, default auto
     * @param {number} options.scale Scale, default 1, 41px x 27px
     * @param {number} options.scaleMaxZoom Scale max zoom (0-24), auto-scale below
     * @param {number} options.height Elevation, CAD units
     * @param {boolean} options.removeWhenNoInMapView Auto show/hide in view
     * @param {number} options.removeWhenNoInMapViewPadding View padding for auto show/hide, default 500
     * @param {number} options.clickTolerance Click tolerance, inherits map default
     * @param {string} options.customImage Custom image URL/base64/svg
     * @param {number} options.customImageWidth Custom image width
     * @param {number} options.customImageHeight Custom image height
     * @param {string} options.customHtml Custom HTML element JS code
     * @param {string} options.popupHtml Popup HTML content
     * @param {[number,number]} options.popupOffset Popup XY offset
     * @param {string} options.popupAnchor Popup anchor, same as anchor
     * @param {boolean} options.closeButton Show close button
     * @param {boolean} options.closeOnClick Close popup on map click
     * @param {boolean} options.closeOnMove Close popup on map move
     * @param {boolean} options.focusAfterOpen Focus first focusable in popup
     * @param {string} options.className CSS class for popup
     * @param {string} options.maxWidth Popup max width, default'240px'
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Create marker layer layer19 from random GeoJSON
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createMarkerDataLayer(geojson, {
      * layerName: 'layer19'
      * });
      * console.log(layer);
     */
    async createMarkerDataLayer(data, options)  {
            options = options || {};
            let type = "marker";
        
            if (options.height) {
              options.height = this.map.geoLengthToHeight(options.height);
            }
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                color: '#3FB1CE',
                closeButton: true,
                closeOnClick: true,
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createMarkerDataLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Remove a function button
     * @param {string} name Button name
     * @example
      * // Remove function button with name "Zoom in one level"
      * this.removeButtonItem("Zoom in one level")
     */
    removeButtonItem(name, func)  {
             this.context.ui.removeButtonItem(name, func);   
             // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.addButtonItem(name, func);
            });
          }
    

    /**
     * Capture rectangle coordinate
     * @param {boolean} isSnap Enable snap, default false
     * @returns {Promise<number[] | undefined>} Returns [x1,y1,x2,y2] or undefined if cancelled
     * @example
      * // Capture rectangle coordinate
      * const rect = await this.actionDrawRectangle();
      * if (!rect) return;
      * // Rectangle bottom-left
      * const leftBottomX = rect[0];
      * const leftBottomY = rect[1];
      * // Rectangle top-right
      * const rightTopX = rect[2];
      * const rightTopY = rect[3];
      * console.log(leftBottomX, leftBottomY, rightTopX, rightTopY);
     */
    async actionDrawRectangle(isSnap)  {
            this.context.tools.logInfo("Please draw a rectangle on the map")
            let snapObj = {};
            if (isSnap) {
                // Snap to DWG points
                vjcommon.getMapSnapPoints(this.map, snapObj);
            }
            let drawRect = await vjmap.Draw.actionDrawRectangle(this.map, isSnap ? {
                api: {
                    getSnapFeatures: snapObj
                }
            } : {});
            if (drawRect.cancel) {
                return; // Cancelled
            }
            let co = drawRect.features[0].geometry.coordinates[0];
            co = this.map.fromLngLat(co);
            return [co[0].x, co[0].y, co[2].x, co[1].y];
        }
    

    /**
     * Get current map zoom
     * @returns {number} Current map zoom level
     */
    getZoom() {  this.map.getZoom() }
    

    /**
     * Create rotating halo data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {string} options.text Text to display, optional, default "index ${props.index}"
     * @param {number} options.minZoom Min zoom to display, optional, default 0
     * @param {number} options.maxZoom Max zoom to display, optional, default 24
     * @param {boolean} options.isDrawLinePoint Include line points when drawing, optional, default false
     * @param {number} options.width Pixel width, optional, default 24
     * @param {number} options.markerHeight Pixel height, optional
     * @param {string} options.color1 Color value 1, optional
     * @param {string} options.color2 Color value 2, optional
     * @param {number} options.textFontSize Font size, optional
     * @param {string} options.textColor Text color, optional
     * @param {boolean} options.draggable Draggable, optional, default false
     * @param {string} options.animationType Animation type, optional: MAP_ANIMATION_NONE, MAP_ANIMATION_BOUNCE, MAP_ANIMATION_DROP
     * @param {number} options.rotation Rotation angle, optional
     * @param {string} options.rotationAlignment Rotation alignment, optional: auto, map, viewport
     * @param {string} options.pitchAlignment Pitch alignment, optional: auto, map, viewport
     * @param {number} options.height Elevation value, optional
     * @param {number} options.scaleMaxZoom Max zoom for scaling, optional
     * @param {boolean} options.removeWhenNoInMapView Auto show/hide in view, optional
     * @param {number} options.removeWhenNoInMapViewPadding Padding for auto show/hide, optional, default 500
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Create rotating halo layer from random GeoJSON in map bounds, layer name layer23
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createRotatingMarkerLayer(geojson, {
      * layerName: 'layer23'
      * });
      * console.log(layer);
     */
    async createRotatingMarkerLayer(data, options)  {
            options = options || {};
            let type = "rotatingMarker";
        
    
            if (options.height) {
              options.height = this.map.geoLengthToHeight(options.height);
            }
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                text: "ID:${props.index}",
                width: 80,
                markerHeight: 80,
                color1: "#ff0000",
                color2: "#ffff00",
                textFontSize: 15,
                textColor: "#00ffff",
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createRotatingMarkerLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Get map metadata
     * @param {object | object[]} options Parameters, optional
     * @param {string} [options.mapid] Map ID. Optional, empty means current opened map
     * @param {string} [options.version] Map version, uses current when empty.
     * @returns {Promise<Object>} Returns map metadata, undefined on failure. Fields:
     * @example
      * // Get metadata for map sys_hello
      * let res = await this.metadata({
      * mapid: "sys_hello"
      * });
     */
    async metadata(options)  {
            let svc = this.map.getService();
            options = options || {};
            let res =  await svc.metadata(options.mapid, options.version);
            if (res) {
                res.bounds = vjmap.GeoBounds.fromString(res.bounds).toArray();
                res.dbBounds = vjmap.GeoBounds.fromString(res.dbBounds).toArray();
                res.drawBounds = vjmap.GeoBounds.fromString(res.drawBounds).toArray();
                res.initBounds = vjmap.GeoBounds.fromString(res.initBounds).toArray();
                if (res.clipbounds) {
                    res.clipbounds = vjmap.GeoBounds.fromString(res.clipbounds).toArray();
                } else {
                    res.clipbounds = null;
                }
                res.layouts = res.layouts.split(", ");
            }
            return res;
         }
    

    /**
     * Create temporary map from fileid
     * @param {string} fileid File ID
     * @param {object} options Other params (optional):
     * @param {boolean} [options.GeomRender] Geometry render mode. false=memory, true=geometry render. Default false
     * @param {number} [options.expireTime] Auto-delete after idle minutes. Default 5
     * @param {boolean} [options.isVisible] Visible. If true, listable via ListMaps. Default false
     * @param {boolean} options.lightTheme Use light theme. Empty = dark theme
     * @returns {string} Created map ID
     * @example
      * // Create temporary map from fileid "c0febd8e49c7"
      * let mapid = await this.createTempMap("c0febd8e49c7");
      * console.log(mapid);
     */
    async createTempMap(fileid, options)  {
            options = options || {};
            let svc = this.map.getService().clone();
            let mapid = vjmap.getTempMapId(options.expireTime || 5, options.isVisible);
            // JS code
            let res = await svc.updateMap({
                mapid: mapid,
                fileid: fileid,
                mapopenway: options.GeomRender === true ? vjmap.MapOpenWay.GeomRender : vjmap.MapOpenWay.Memory,
                style:  options.lightTheme ? vjmap.openMapLightStyle() : vjmap.openMapDarkStyle(),
            })
            return res.mapid;
        }
    

    /**
     * Create line data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {string} options.lineColor Line color, optional, default '#ff0000'
     * @param {number} options.lineWidth Line width, optional, default 1
     * @param {number} options.lineOpacity Line opacity, optional, 0-1
     * @param {number} options.lineGapWidth Line gap width, optional, default 1, pixels
     * @param {number} options.lineOffset Line offset, optional. Positive=right/inward, negative=left/outward
     * @param {number} options.lineBlur Line blur, optional, must be less than line width
     * @param {[number,number]} options.lineDasharray Dash pattern, optional, e.g. [1,1]
     * @param {[number,number]} options.lineTranslate Translate [x,y], optional, negative=left/up, pixels
     * @param {string} options.lineCap Line cap: 'butt', 'round', 'square'
     * @param {string} options.lineJoin Line join: 'bevel', 'round', 'miter'
     * @param {string} options.linePattern Line pattern, optional, must match preloaded image, width power of 2
     * @param {string} options.lineGradient Line gradient, optional
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Create line layer from random GeoJSON in map bounds, layer name layer17
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createLineLayer(geojson, {
      * layerName: 'layer17'
      * });
      * console.log(layer);
     */
    async createLineLayer(data, options)  {
            options = options || {};
            let type = "line";
        
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                lineColor: '#ff0000',
                lineWidth: 1,
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createLineLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Measure area
     * @example
      * // Measure area
      * await this.measureArea();
     */
    measureArea()  {
                vjcommon.runMeasureCmd(this.map, "measureArea");
            }
    
    

    /**
     * Set map theme: dark, light, or high-tech
     * @param {string} theme Theme: "dark", "light", "high-tech"
     * @example
      * // Example 1
      * // Set dark theme
      * await this.setMapTheme("dark");
      * // Example 2
      * // Set light theme
      * await this.setMapTheme("light");
      * // Example 3
      * // Set high-tech theme
      * await this.setMapTheme("high-tech");
     */
    async setMapTheme(theme)  {
            if (!this.context.global.updateMapStyleObj) {
                this.context.global.updateMapStyleObj = vjcommon.createUpdateMapStyleObj(this.map);
            }
            const updateMapStyleObj = this.context.global.updateMapStyleObj;
            let style = {
                backcolor: theme === "light" ? 0xffffff : 0,
            };
            if (theme === "high-tech") {
                style.expression = "var color := gFilterCustomTheme(gInColorRed, gInColorGreen, gInColorBlue, 200, 200, 0.1);gOutColorRed[0] := gRed(color);gOutColorGreen[0] := gGreen(color);gOutColorBlue[0] := gBlue(color);";
            }
            const curStyle = {...updateMapStyleObj.getCurStyle()};
            await updateMapStyleObj.updateStyle(style)
            // Add undo record
            this.context.ui.addUndoRecord(async () => {
                await updateMapStyleObj.updateStyle(curStyle)
            });
        }
    

    /**
     * Set map max/min zoom level
     * @param {object} options Object with maxZoom and minZoom
     * @param {number} options.maxZoom Max zoom to set (optional)
     * @param {number} options.minZoom Min zoom to set (optional)
     * @example
      * this.setMaxMinZoom({maxZoom: 10}); // Set max zoom to 10
     */
    setMaxMinZoom(options)  {
            options = options || {};
            if (options.maxZoom !== undefined) {
                let oldMaxZoom = this.map.getMaxZoom(); // Get current max zoom
                this.map.setMaxZoom(options.maxZoom); // Set new max zoom
                // Add undo record
                this.context.ui.addUndoRecord(() => {
                    this.map.setMaxZoom(oldMaxZoom);
                });
            }
            if (options.minZoom !== undefined) {
                let oldMinZoom = this.map.getMinZoom(); // Get current min zoom
                this.map.setMinZoom(options.minZoom); // Set new min zoom
                // Add undo record
                this.context.ui.addUndoRecord(() => {
                    this.map.setMinZoom(oldMinZoom);
                });
            }
        }
    

    /**
     * Get custom data from server
     * @param {string | string[]} key Key name(s), array for batch.
     * @param {object} options retDataType: empty=data+props, "value"=data only, "prop"=props only. contentType: empty=json, "image"=image
     * @returns {Promise<object>} Returns {data, prop, status}
     * @example
      * // Get custom data for key testKey
      * let data = await this.getCustomDataServer("testKey");
      * console.log(data);
      * // Batch get custom data for keys testKey1, testKey2
      * let dataArray = await this.getCustomDataServer(["testKey1", "testKey2"]);
      * console.log(dataArray);
     */
    async getCustomDataServer(key, options)  {
            let svc = this.map.getService();
            return await svc.getCustomData(key, options);
        }
    

    /**
     * Set whether map shows line width
     * @param {boolean} isShowLineWidth Whether to show line width
     * @example
      * // Show line width
      * await this.setMapShowLineWidth(true);
      * // Hide line width
      * await this.setMapShowLineWidth(false);
     */
    async setMapShowLineWidth(isShowLineWidth)  {
            if (!this.context.global.updateMapStyleObj) {
                this.context.global.updateMapStyleObj = vjcommon.createUpdateMapStyleObj(this.map);
            }
            const updateMapStyleObj = this.context.global.updateMapStyleObj;
            const oldLineweight = updateMapStyleObj.getCurStyle()?.lineweight;
            await updateMapStyleObj.updateStyle({
                lineweight: isShowLineWidth ? 1 : 0
            })
            // Add undo record
            this.context.ui.addUndoRecord(async () => {
                await updateMapStyleObj.updateStyle({
                    lineweight: oldLineweight
                })
            });
        }
    

    /**
     * Delete map
     * @param {object} options Parameters
     * @param {string} options.mapid Map ID
     * @param {string} options.version Version. Use "*" to delete all versions
     * @param {number} [options.retainVersionMaxCount] When deleting all, max latest versions to retain. Optional
     * @returns {Promise<boolean>} true=success, false=failure
     * @example
      * // Example 1
      * // Delete all versions of map hello
      * let res1 = await this.deleteMap({
      * mapid: "hello",
      * version: "*"
      * });
      * console.log(res1);
      * // Example 2
      * // Delete v1 version of map hello
      * let res2 = await this.deleteMap({
      * mapid: "hello",
      * version: "v1"
      * });
      * console.log(res2);
     */
    async deleteMap(options)  {
           let svc = this.map.getService();
           let res = await svc.cmdDeleteMap(options.mapid, options.version, options.retainVersionMaxCount);
           return res && res.status;
       }
    

    /**
     * Create breathing aperture point data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {string} options.text Text to display, optional, default "index ${props.index}"
     * @param {number} options.minZoom Min zoom to display, optional, default 0
     * @param {number} options.maxZoom Max zoom to display, optional, default 24
     * @param {boolean} options.isDrawLinePoint Include line points when drawing, optional, default false
     * @param {number} options.width Pixel width, optional, default 24
     * @param {number} options.markerHeight Pixel height, optional
     * @param {string} options.color1 Color value 1, optional
     * @param {string} options.color2 Color value 2, optional
     * @param {number} options.textFontSize Font size, optional
     * @param {string} options.textColor Text color, optional
     * @param {boolean} options.draggable Draggable, optional, default false
     * @param {string} options.animationType Animation type, optional: MAP_ANIMATION_NONE, MAP_ANIMATION_BOUNCE, MAP_ANIMATION_DROP
     * @param {number} options.rotation Rotation angle, optional
     * @param {string} options.rotationAlignment Rotation alignment, optional: auto, map, viewport
     * @param {string} options.pitchAlignment Pitch alignment, optional: auto, map, viewport
     * @param {number} options.height Elevation value, optional
     * @param {number} options.scaleMaxZoom Max zoom for scaling, optional
     * @param {boolean} options.removeWhenNoInMapView Auto show/hide in view, optional
     * @param {number} options.removeWhenNoInMapViewPadding Padding for auto show/hide, optional, default 500
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Create breathing aperture layer from random GeoJSON, layer name layer6
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createBreathingMarkerLayer(geojson, {
      * layerName: 'layer6',
      * text: 'Index: ${props.index}'
      * });
      * console.log(layer);
     */
    async createBreathingMarkerLayer(data, options)  {
            options = options || {};
            let type = "breathingMarker";
        
            if (options.height) {
              options.height = this.map.geoLengthToHeight(options.height);
            }
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                "width": 80,
                "markerHeight": 80,
                "color1": "#ff0000",
                "color2": "#ffff00",
                "textFontSize": 15,
                "textColor": "#00ffff",
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createBreathingMarkerLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Capture a point coordinate
     * @param {boolean} isSnap Enable snap, default false
     * @returns {Promise<[number,number] | undefined>} Returns [x,y] or undefined if cancelled
     * @example
      * const pt = await this.actionDrawPoint(true); // Capture point with snap
      * if (!pt) return;
      * // Point coordinates
      * const x = pt[0];
      * const y = pt[1];
      * console.log(x, y);
     */
    async actionDrawPoint(isSnap)  {
            this.context.tools.logInfo("Please draw a point on the map")
            let snapObj = {};
            if (isSnap) {
                // Snap to DWG points
                vjcommon.getMapSnapPoints(this.map, snapObj);
            }
            let drawPoint = await vjmap.Draw.actionDrawPoint(this.map, isSnap ? {
                api: {
                    getSnapFeatures: snapObj
                }
            } : {});
            if (drawPoint.cancel) {
                return; // Cancelled
            }
            let co = drawPoint.features[0].geometry.coordinates;
            co = this.map.fromLngLat(co);
            return [co.x, co.y];
        }
    

    /**
     * Update data layer by layer name and data content
     * @param {string} layerName Layer name, required
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @example
      * // Update layer1 data with random GeoJSON
      * let layerName = 'layer1';
      * let geojson = this.randomGeojsonInMapBounds();
      * this.updateDataLayerSource(layerName, geojson);
     */
    updateDataLayerSource(layerName, data)  {
            let item = this.context.global.layers.find(layer => layer.layerName == layerName);
            if(item) {
                item.data = data;
                let oldData = {...item.data};
    
                let layerId = item.layerId;
                let sourceId = item.sourceId;
                if (item.layer && typeof(item.layer.getLayerId) == "function") {
                    layerId = item.layer.getLayerId();
                    let style = this.map.getStyle();
                    let layer = style.layers.find(layer => layer.id == layerId);
                    if (layer && layer.source) {
                        sourceId = layer.source;
                    }
                }
    
                this.map.setData(sourceId, this.map.toLngLat(data));
                // Add undo record
                this.context.ui.addUndoRecord(() => {
                    this.updateDataLayerSource(layerName, oldData);
                });
            }
        }
    

    /**
     * Transform GeoJSON by four parameters
     * @param {Object} data GeoJSON to transform, required
     * @param {Object} transform Four params: {dx, dy}, {scale}, {rotate} (radians), required
     * @param {boolean} isLngLatCoord If input is lng/lat. false=CAD coords, true=map lng/lat. Optional
     * @returns {Object} Transformed GeoJSON
     * @example
      * // Transform GeoJSON: translate [100,200], scale 0.5, rotate 0.5 rad
      * let newGeojson = this.geojsonTransform(geojson, {dx: 100, dy: 200, scale: 0.5, rotate: 0.5});
      * console.log(newGeojson);
     */
    geojsonTransform(data, transform, isLngLatCoord)  {
            return vjmap.transform.convert(data, (pt) => {
                let point = isLngLatCoord === true ? this.map.fromLngLat(vjmap.geoPoint(pt)) : vjmap.geoPoint(pt);
                let newPoint  = vjmap.coordTransfromByFourParamter(point, {
                    dx: transform.dx || 0,
                    dy: transform.dy || 0,
                    scale: transform.scale || 1,
                    rotate: transform.rotate || 0
                });
                return isLngLatCoord === true ? this.map.toLngLat(newPoint) : [newPoint.x, newPoint.y];
            });
        }
    

    /**
     * Capture 3D polyline
     * @param {object} options Parameters
     * @param {number} options.searchRadiusRatio Text search radius multiplier, default 2
     * @param {boolean} isSnap Enable snap for drawn points, default true
     * @param {boolean} isSnapMap Enable snap to map points, default false
     * @example
      * // Capture 3D polyline
      * await this.pick3dPolylines();
     */
    async pick3dPolylines(options)  {
            // Capture 3D line - auto-search text for elevation
            // Coords as [x,z,y], use for 3D pipe generation
            // https://vjmap.com/map3d/demo/#/demo/this.map/builtin/objects/03builtinobjtubepath
            options = options || {};
            let svc = this.map.getService();
            let mapAllTexts;
            let textMeanHeight = 0; // Average text height in drawing
            const getAllTexts = async ()=> {
                if (mapAllTexts) return mapAllTexts; // If already fetched
                // Entity type ID to name mapping
                const { entTypeIdMap } = await svc.getConstData();
                const getTypeNameById = name => Object.keys(entTypeIdMap).find(e => entTypeIdMap[e] === name);
    
                let queryTextEntTypeId = getTypeNameById("AcDbText"); // Single-line text
                let queryMTextEntTypeId = getTypeNameById("AcDbMText"); // Multi-line text
                let queryAttDefEntTypeId = getTypeNameById("AcDbAttributeDefinition"); // Attribute definition
                let queryAttEntTypeId = getTypeNameById("AcDbAttribute"); // Attribute text
                let query = await svc.conditionQueryFeature({
                    condition: `name='${queryTextEntTypeId}' or name='${queryMTextEntTypeId}' or name='${queryAttDefEntTypeId}' or name='${queryAttEntTypeId}'`, // WHERE clause, see "Server condition and expression query" docs
                    fields: "objectid,envelop,text",
                    geom: true,
                    limit: 100000 // Large limit to fetch all. Default 100 if omitted
                })
                if (query.error) {
                    this.map.logInfo(query.error, "error");
                    return;
                } else {
                    mapAllTexts = []
                    textMeanHeight = 0
                    if (query.recordCount > 0) {
                        for(let i = 0; i < query.recordCount; i++) {
                            let bounds = this.map.getEnvelopBounds(query.result[i].envelop);
                            mapAllTexts.push({
                                position: bounds.center(),
                                text: query.result[i].text
                            });
                            textMeanHeight += bounds.height()
                        }
                        textMeanHeight = textMeanHeight / query.recordCount
                    }
                }
                return mapAllTexts
            }
        
            function isNumeric(value) {
                const regex = /^[0-9]*\.?[0-9]+$/;  // Match number with optional decimal
                return regex.test(value);
            }
            
            let snapObj = {};
            const collect3Dlines = async () => {
                // Send ESC to map to cancel. Cancel previous capture if any
                this.map.fire("keyup",{keyCode:27})
                this.map.logInfo("Draw line on map, press Enter or ConfirmEnd in right-click menu")
                let elevations = []
                if (options.isSnapMap && !snapObj.features) {
                    vjcommon.getMapSnapPoints(this.map, snapObj);
                }
                let line = await vjmap.Draw.actionDrawLineSting(this.map, {
                    snap: options.isSnap === false ? false : true,
                    api: {
                        getSnapFeatures: snapObj
                    },
                    updatecoordinate: async (e) => {
                        if (!e.lnglat) return;
                        if (e.isClick) {
                            let curIdx = e.feature.coordinates.length - 1
                            const co = this.map.fromLngLat(e.feature.coordinates[curIdx]);
                            // Get all text near current point
                            let allTexts = await getAllTexts()
                            let radius = textMeanHeight * (options.searchRadiusRatio || 2); // Search radius = 2x text height, adjust as needed
                            // Filter by is-numeric first, then by distance
                            let texts = allTexts.filter(item => isNumeric(item.text)).filter(item => vjmap.geoPoint(co).distanceTo(item.position) <= radius)
                            texts = texts.map(t => t.text); // Get content only
                            // Show input dialog and get user input
                            let elevation = await this.context.ui.selectSuggestionValue(texts, {mustNumber: true, title: "Select or enter elevation"})
                            if (elevation === undefined) {
                                // Cancel capture
                                this.map.fire("keyup",{keyCode:27})
                                return;
                            }
                            elevations.push(elevation)
                        }
    
                    }, contextMenu: (e) => {
                        new vjmap.ContextMenu({
                            event: e.event.originalEvent,
                            theme: "dark", //light
                            width: "250px",
                            items: [
                                {
                                    label: 'Confirm',
                                    onClick: () => {
                                        // Send Enter to map
                                        this.map.fire("keyup", { keyCode: 13 })
                                    }
                                },
                                {
                                    label: 'Cancel',
                                    onClick: () => {
                                        // Send ESC to map to cancel
                                        this.map.fire("keyup", { keyCode: 27 })
                                    }
                                }, {
                                    label: 'Delete last point',
                                    onClick: () => {
                                        // Send Backspace to delete last point
                                        this.map.fire("keyup", { keyCode: 8 })
                                        elevations.pop(); // Remove last element
                                    }
                                }, {
                                    label: 'End',
                                    onClick: () => {
                                        // Send ESC to map to cancel
                                        this.map.fire("keyup", { keyCode: 27 })
                                    }
                                }
                            ]
                        });
    
                    }
                })
                if (line.cancel) {
                    return // Cancelled
                }
    
                let feature = line.features[0]
                feature.properties.elevations = elevations;
                feature.properties.typename = "3dpolyline"
                feature.properties.line_width = 12;
                feature.properties.color = vjmap.randomColor()
                this.map.getDrawLayer().add(feature) 
    
                if (options.isSnap !== false) {
                    // Add drawn line to snap
                    snapObj.features = snapObj.features || [];
                    snapObj.features.push({
                        type: "Feature",
                        geometry: {
                            type: "LineString",
                            coordinates: feature.geometry.coordinates
                        }
                    });
                }
            }
    
            this.addButtonItem("Start collection", async () => {
                await collect3Dlines()
            })
    
            this.addButtonItem("Get 3D line data", async () => {
                let data = this.map.getDrawLayer().getAll();
                data = this.map.fromLngLat(data);
                let features = data.features.filter(item => item.properties.typename === "3dpolyline") || [];
                let result = [];
                for(let i = 0; i < features.length; i++) {
                    let coords = features[i].geometry.coordinates;
                    let elevations = features[i].properties.elevations;
                    let data = coords.map((co, idx) => [co[0], co[1], elevations[idx]]);
                    result.push({
                        paths: data,
                        name: i
                    })
                }
                await this.showDataInfoDialog(result, false, "3D line data")
            })
    
            this.addButtonItem("Exit collection", async () => {
                // Cancel capture
                this.map.fire("keyup",{keyCode:27})
                let data = this.map.getDrawLayer().getAll();
                data.features = data.features.filter(item => item.properties.typename !== "3dpolyline")
                this.map.getDrawLayer().set(data);
                this.removeButtonItem("Start collection")
                this.removeButtonItem("Get 3D line data")
                this.removeButtonItem("Exit collection")
            })
    
        }
    

    /**
     * Generate QR code image from content
     * @param {string} content QR code content
     * @param {object} options Options
     * @param {number} options.size QR size, default 256
     * @param {boolean} options.disableOpen Return URL only, don't open image, default false
     * @returns {Promise<string>} QR code image URL
     * @example
      * // Generate QR code for "https://www.vjmap.com"
      * let url = await this.qrcodeUrl("https://www.vjmap.com");
      * console.log(url);
     */
    async qrcodeUrl(content, options)  {
            options = options || {};
            let svc = this.map.getService();
            let res = await svc.qrcodeUrl(content, options.size);
            if (options.disableOpen) return res;
            window.open(res);
            return res;
        }
    

    /**
     * Delete a point marker
     * @param {string} id Marker ID
     * @example
      * this.deleteMarker("marker_1"); // Delete marker with id marker_1
     */
    deleteMarker(id)  {
           let markers = this.map.getMarkers();
           let marker = markers.find(m => m.id == id);
           if (marker) {
            let point = this.map.fromLngLat(marker.getLngLat());
            
            let oldOptions = {};
            oldOptions.color = marker._color;
            oldOptions.data = marker.data;
            oldOptions.draggable = marker.isDraggable();
            oldOptions.rotation = marker.getRotation();
    
            if (marker.getPopup()) {
                marker.getPopup().remove();
            }
            marker.remove()
    
           
             // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.addMarker(point, oldOptions);
            });
           }
          
        }
    

    /**
     * Convert CAD geometry coords to map lng/lat
     * @returns {[number, number]} [lng, lat]
     * @example
      * // Convert CAD coords [1000, 2000] to lng/lat
      * let point = this.geoPointToLngLat([1000, 2000]);
      * let lng = point[0];
      * let lat = point[1];
      * console.log(lng, lat);
     */
    geoPointToLngLat(pt)  {
            let lngLat = this.map.toLngLat(pt);
            let lng = lngLat[0];
            let lat = lngLat[1];
            return [lng, lat];
        }
    

    /**
     * Add fullscreen control
     * @param {string} options.container Fullscreen container, optional
     * @param {'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'} options.position Control position, optional, default 'top-right'
     * @example
      * // Add fullscreen control, top-left position
      * this.addMapFullscreenControl({ position: 'top-left' });
     */
    addMapFullscreenControl(options)  {
            options = options || {};
            this.context.global.controls = this.context.global.controls || {};
            if (this.context.global.controls.fullscreenControl) {
                this.map.removeControl(this.context.global.controls.fullscreenControl.control);
            }
            if (options.container) {
                options.container = document.querySelector(options.container);
            }
            let fullscreenControl = new vjmap.FullscreenControl(options);
            this.map.addControl(fullscreenControl, options.position);
            this.context.global.controls.fullscreenControl = {
                control: fullscreenControl,
                position: options.position,
                options: options
            };
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.removeControl(fullscreenControl);
                delete this.context.global.controls.fullscreenControl;
            }); 
        }
    

    /**
     * Manually draw a polygon interactively
     * @param {object} options Config params, optional
     * @param {boolean} options.isSnap Enable snap, optional, default false
     * @param {string} options.color Color #RRGGBB, optional
     * @param {boolean} options.isFill Fill, default true, optional
     * @param {number} options.opacity Opacity, default 1, optional
     * @param {number} options.extrusionHeight Extrusion height, optional
     * @param {number} options.data Data value, optional
     * @returns {Promise<Object | undefined>} Drawn entity or undefined if cancelled. Object includes:
     * @example
      * // Manually draw a polygon
      * const polygon = await this.drawPolygon({
      * isSnap: true,
      * color: "#FF0000",
      * isFill: true,
      * opacity: 0.8
      * });
      * if (!polygon) return;
      * console.log("Polygon ID:", polygon.id);
      * console.log("Polygon points:", polygon.points);
     */
    async drawPolygon(options)  {
            let points = await this.actionDrawPolygon(options.isSnap);
            if (!points) return;
            return this.createPolygon(points, options);
        }
    

    /**
     * Create point symbol cluster data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @param {string} options.iconImage Icon name for image background
     * @param {number} options.iconRotate Icon rotation angle, clockwise, degrees, default 0
     * @param {[number,number]} options.iconOffset Icon offset from anchor, [x,y], positive = right/down
     * @param {string} options.iconAnchor Icon anchor position, values: "center", "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right"
     * @param {string} options.iconRotationAlignment Icon rotation alignment, values: "map", "viewport", "auto"
     * @param {string} options.iconPitchAlignment Icon pitch alignment, values: "map", "viewport", "auto"
     * @param {boolean} options.iconAllowOverlap Allow icon overlap, default false
     * @param {number} options.iconOpacity Icon opacity 0-1, default 1
     * @param {boolean} options.iconIgnorePlacement Ignore collision detection, default false
     * @param {boolean} options.iconOptional Icon overlap option, default false
     * @param {number} options.iconSize Icon scale, default 1
     * @param {string} options.iconTextFit Icon text fit, values: "none", "width", "height", "both"
     * @param {[number,number,number,number]} options.iconTextFitPadding Icon text fit padding [top, right, bottom, left]
     * @param {number} options.iconPadding Icon padding, default 1px
     * @param {boolean} options.iconKeepUpright Keep icon upright, default false
     * @param {string} options.iconColor SDF icon color, default"#000000"
     * @param {string} options.iconHaloColor SDF icon halo color, default"rgba(0, 0, 0, 0)"
     * @param {number} options.iconHaloWidth Icon halo width, default 0
     * @param {number} options.iconHaloBlur Icon halo blur, default 0
     * @param {[number,number]} options.iconTranslate Icon translate [x,y]
     * @param {string} options.iconTranslateAnchor Icon translate anchor, values: "map", "viewport"
     * @param {string|Expression} options.textField Text label content
     * @param {string[]} options.textFont Text font, default["Open Sans Regular","Arial Unicode MS Regular"]
     * @param {number} options.textSize Text size, default 16
     * @param {boolean} options.textAllowOverlap Allow text overlap, default false
     * @param {string} options.textColor Text color, default"#000000"
     * @param {number} options.textOpacity Text opacity 0-1, default 1
     * @param {[number,number]} options.textOffset Text offset [x,y]
     * @param {number} options.textRotate Text rotation angle
     * @param {number} options.textPadding Text padding, default 2
     * @param {boolean} options.textKeepUpright Keep text upright, default true
     * @param {number} options.textMaxWidth Text max width, default 10em
     * @param {number} options.textLineHeight Text line height, default 1.2
     * @param {number} options.textLetterSpacing Letter spacing, default 0
     * @param {string} options.textJustify Text justify, values: "auto", "left", "center", "right"
     * @param {string} options.textAnchor Text anchor position
     * @param {string} options.textRotationAlignment Text rotation alignment
     * @param {string} options.textPitchAlignment Text pitch alignment
     * @param {number} options.textMaxAngle Max angle between chars, default 45°
     * @param {string[]} options.textWritingMode Text writing mode
     * @param {string} options.textTransform Text transform, values: "none", "uppercase", "lowercase"
     * @param {number} options.textRadialOffset Text radial offset
     * @param {string[]} options.textVariableAnchor Text variable anchor array
     * @param {boolean} options.textIgnorePlacement Ignore text placement
     * @param {boolean} options.textOptional Text optional
     * @param {string} options.textHaloColor Text halo color
     * @param {number} options.textHaloWidth Text halo width
     * @param {number} options.textHaloBlur Text halo blur
     * @param {[number,number]} options.textTranslate Text translate
     * @param {string} options.textTranslateAnchor Text translate anchor
     * @param {string} options.symbolPlacement Symbol placement, values: "point", "line", "line-center"
     * @param {number} options.symbolSpacing Symbol spacing, default 250
     * @param {boolean} options.symbolAvoidEdges Avoid edge collision
     * @param {string} options.symbolZOrder Symbol z-order, values: "auto", "viewport-y", "source"
     * @param {number} options.clusterMaxZoom Cluster max zoom, default 10
     * @param {number} options.clusterRadius Cluster radius in pixels, default 60
     * @param {Array<[number, string]>} options.outerColors Outer circle value/color [[value,color],...], default[[1000, 'rgba(253, 156, 115, 0.6)'], [100, 'rgba(241, 211, 87, 0.6)'], [0, 'rgba(181, 226, 140, 0.6)']]
     * @param {number} options.outerCircleRadius Outer circle radius px, default 20
     * @param {Array<[number, string]>} options.innerColors Inner circle value/color [[value,color],...], default[[1000, 'rgba(241, 128, 23, 0.6)'], [100, 'rgba(240, 194, 12, 0.6)'], [0, 'rgba(110, 204, 57, 0.6)']]
     * @param {number} options.innerCircleRadius Inner circle radius px, default 15
     * @param {number} options.clusterTextSize Cluster text size px, default 12
     * @param {string} options.color Marker color, optional, default'#3FB1CE'
     * @param {number[]} options.offset XY pixel offset [0,0], negative = left/up
     * @param {number} options.rotation Marker rotation, clockwise, default 0
     * @param {string} options.anchor Marker anchor:center、top、bottom、left、right、top-left、top-right、bottom-left、bottom-right, default center
     * @param {boolean} options.draggable Draggable, optional, default false
     * @param {string} options.animationType Animation: MAP_ANIMATION_NONE, MAP_ANIMATION_BOUNCE, MAP_ANIMATION_DROP
     * @param {string} options.rotationAlignment Rotation alignment: auto, map, viewport, default auto
     * @param {string} options.pitchAlignment Pitch alignment: auto, map, viewport, default auto
     * @param {number} options.scale Scale, default 1, 41px x 27px
     * @param {number} options.scaleMaxZoom Scale max zoom (0-24), auto-scale below
     * @param {number} options.height Elevation, CAD units
     * @param {boolean} options.removeWhenNoInMapView Auto show/hide in view
     * @param {number} options.removeWhenNoInMapViewPadding View padding for auto show/hide, default 500
     * @param {number} options.clickTolerance Click tolerance, inherits map default
     * @param {string} options.customImage Custom image URL/base64/svg
     * @param {number} options.customImageWidth Custom image width
     * @param {number} options.customImageHeight Custom image height
     * @param {string} options.customHtml Custom HTML element JS code
     * @param {string} options.popupHtml Popup HTML content
     * @param {[number,number]} options.popupOffset Popup XY offset
     * @param {string} options.popupAnchor Popup anchor, same as anchor
     * @param {boolean} options.closeButton Show close button
     * @param {boolean} options.closeOnClick Close popup on map click
     * @param {boolean} options.closeOnMove Close popup on map move
     * @param {boolean} options.focusAfterOpen Focus first focusable in popup
     * @param {string} options.className CSS class for popup
     * @param {string} options.maxWidth Popup max width, default'240px'
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Create symbol cluster layer layer25 with icon _markerIcon_
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createSymbolClusterDataLayer(geojson, {
      * layerName: 'layer25',
      * iconImage: '_markerIcon_'
      * });
      * console.log(layer);
     */
    async createSymbolClusterDataLayer(data, options)  {
            options = options || {};
            let type = "symbolCluster";
        
            if (options.height) {
              options.height = this.map.geoLengthToHeight(options.height);
            }
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                clusterMaxZoom: 10,
                clusterRadius: 60,
                outerColors: [[1000, 'rgba(253, 156, 115, 0.6)'], [100, 'rgba(241, 211, 87, 0.6)'], [0, 'rgba(181, 226, 140, 0.6)']],
                outerCircleRadius: 20,
                innerColors: [[1000, 'rgba(241, 128, 23, 0.6)'], [100, 'rgba(240, 194, 12, 0.6)'], [0, 'rgba(110, 204, 57, 0.6)']],
                innerCircleRadius: 15,
                clusterTextSize: 12,
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createSymbolClusterDataLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Get map center coordinates
     * @returns {[number, number]} Map center coords as [x,y]
     */
    getCenter()  {
            let center = this.map.fromLngLat(this.map.getCenter());
            return [center.x, center.y];
        }
    
    

    /**
     * Auto-extract table from map
     * @returns {Promise<GeoJSON>} Returns extracted table GeoJSON, undefined on failure
     * @example
      * // Example 1
      * // Auto-extract table from map
      * let geojson = await this.extractTale();
      * if (!geojson) {
      * console.log("Table extraction failed");
      * } else {
      * console.log("Table extracted, GeoJSON:", geojson);
      * }
      * // Example 2
      * // Manually select range for auto-extract table from map
      * let res2 = await this.extractTale({
      * isSelectBounds: true
      * });
      * console.log(res2);
     */
    async extractTale(options)  {
           options = options || {};
           let svc = this.map.getService();
           options = {
            mapid: options.mapid || svc.currentMapParam().mapid,
            bounds: "",
            condition: "",
            debugInfo: [],
            digit: 4,
            tol: 0,
            tableEdgeMinPoint: 12,
            tableTextMinCount: 4,
            cellMaxArea: 90,
            cellEmptyRatio: 90,
            unLinkLineRatio: 20,
            tableMaxCellCount: 100000,
            noHvLineSegCount: 0,
            seachTableMost: false,
            version:  svc.currentMapParam()?.version,
            layer: svc.currentMapParam()?.layer,
            // Use expression query for memory, spatial query for geometry render
            geom: svc.currentMapParam()?.mapopenway != vjmap.MapOpenWay.Memory,
            ...options,
           };
           if (options.isSelectBounds) {
                // For manual mode, select area first
                if (this.map.logInfo) this.map.logInfo("Please select rectangular area covering the table", "success", 10000);
                 // Draw rectangle on map as clip region
                let snapObj = {}; // Snap entities
                vjcommon.getMapSnapPoints(this.map, snapObj);
                let res = await vjmap.Draw.actionDrawRectangle(this.map, {
                    api: {
                        getSnapFeatures: snapObj // Snap features assigned via features property
                    },
                    contextMenu: (e) => {
                        // Send Enter to map
                        this.map.fire("keyup", {keyCode:13})
                    }
                });
                if (res.cancel) {
                    return // Cancelled
                }
                // Get drawn coordinates
                let rectCoord = this.map.fromLngLat(res.features[0].geometry.coordinates[0]);
                // Get bottom-left and bottom-right points
                let  bounds = [rectCoord[0].x, rectCoord[0].y, rectCoord[2].x, rectCoord[2].y];
                options.bounds = bounds.join(',');
           }
           if (Array.isArray(options.bounds)) {
            options.bounds = JSON.stringify(options.bounds);
           }
           if (options.showSettingForm !== false) {
            options = await this.showDataModifyDlg({
                data: options,
                title: "Auto-extract table settings",
                showForm: true
            })
            if (!options) return;
           }
           let result = await svc.cmdExtractTale(options);
           if (result && result.error) {
                if (this.map.logInfo) this.map.logInfo(result.error, "error", 10000);
                return;
           }
           let tables = result.tables || [];
           let geoDatas = {
                "type": "FeatureCollection",
                "features": []
            }
            for(let t = 0; t < tables.length; t++) {
                let rect = vjmap.GeoBounds.fromString(tables[t].rect);
                let pts = rect.toPointArray();
                pts.push(pts[0]); // Close
                geoDatas.features.push(
                    {
                        "id": t,
                        "type": "Feature",
                        "properties": {
                            color: vjmap.randomColor(),
                            index: t,
                            data: tables[t],
                            tableDatas: tables[t].datas
                        },
                        "geometry": {
                            "coordinates": [pts],
                            "type": "Polygon"
                        }
                    })
            }
           return geoDatas;
       }
    

    /**
     * Show message
     * @param {string} message Message content, required
     * @param {string} type Message type: warning, error, info, success, optional, default error
     * @example
      * // Example 1
      * // Show error message
      * this.showMessage("Error occurred", "error");
      * // Example 2
      * // Show success message
      * this.showMessage("Completed", "success");
     */
    async showMessage(message, type)  {
            return await this.context.ui.showMessage(message, type || "error");
        }
    

    /**
     * Delete user custom data from server
     * @param {string | string[]} key Key name(s), array for batch
     * @param {boolean} isPrefix Delete all keys with prefix, default false
     * @returns {Promise<boolean>} Returns true on success, false on failure
     * @example
      * // Delete user custom data key drawData from server
      * await this.deleteCustomDataServer("drawData");
     */
    async deleteCustomDataServer(key, isPrefix = false)  {
            let svc = this.map.getService();
            let res = await svc.deleteCustomData(key, isPrefix);
            return res?.status === "ok";
        }
    

    /**
     * Create raster tile layer
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {number} options.rasterOpacity Image opacity 0-1, default 1
     * @param {number} options.rasterHueRotate Hue rotation degrees, default 0
     * @param {number} options.rasterBrightnessMin Min brightness 0-1, default 0
     * @param {number} options.rasterBrightnessMax Max brightness 0-1, default 1
     * @param {number} options.rasterSaturation Saturation -1 to 1, default 0
     * @param {number} options.rasterContrast Contrast -1 to 1, default 0
     * @param {number} options.rasterFadeDuration Tile fade duration ms, default 300
     * @param {string} options.rasterResampling Resampling: linear, nearest, default linear
     * @param {string} options.tileMapId Tile mapid, optional
     * @param {string} options.tileMapVersion Tile version, optional
     * @param {string} options.tileMapLayer Tile style layer name, optional
     * @param {string|Array<string>} options.tiles Tile URL, supports variables {x},{y},{z},{serviceUrl},{accessToken}
     * @param {number} options.tileSize Tile size, default 512
     * @param {number[]} options.bounds Tile bounds [minx,miny,maxx,maxy], optional
     * @param {string} options.scheme Tile scheme: xyz (Y from top), tms (Y from bottom), default xyz
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Create raster tile layer, tile mapid is sys_hello
      * let layer = await this.createRasterLayer({
      * tileMapId: "sys_hello",
      * });
      * console.log(layer);
     */
    async createRasterLayer(options)  {
            options = options || {};
            let type = "raster";
    
            let randomId = vjmap.RandomID();
            let sourceId = `source_${type}_${randomId}`;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                sourceId,
                layerId,
                ...options,
                type: type
            }
    
            if (options.tileMapId && !options.tiles) {
                let layer = options.tileMapLayer;
                if (!layer) {
                    let style = await this.map.getService().createStyle({
                        backcolor: this.map.getService().currentMapParam().darkMode ? 0x000000 : 0xFFFFFF
                    }, options.tileMapId, options.tileMapVersion);
                    layer = style.stylename
                }
                let tileUrl = this.map.getService().rasterTileUrl({
                    mapid: options.tileMapId,
                    version: options.tileMapVersion,
                    layer: layer,
                })
                options.tiles = tileUrl;
            }
            if (options.bounds) {
                const p1 = this.map.toLngLat([options.bounds[0], options.bounds[1]]);
                const p2 = this.map.toLngLat([options.bounds[2], options.bounds[3]]);
                options.bounds = [p1[0], p1[1], p2[0], p2[1]];
            }
            let sourceData = {
                type: "raster",
                tiles: Array.isArray(options.tiles) ? options.tiles : [options.tiles],
                tileSize: options.tileSize || 256
            }
            if (options.bounds) {
                sourceData.bounds = options.bounds;
            }
            if (options.scheme) {
                sourceData.scheme = options.scheme;
            }
            this.map.addRasterSource(sourceId, sourceData);
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data: sourceData, options, layer, visible: true, create: this.createRasterLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Show data panel with content
     * @param {any} data Data content, required
     * @param {object} options Data panel config, optional
     * @param {string} options.title Data panel title, optional
     * @param {number} options.width Data panel width, optional
     * @param {number} options.height Data panel height, optional
     * @param {number} options.left Data panel left, optional
     * @param {number} options.top Data panel top, optional
     * @param {string} options.position Data panel position, default top-left
     * @param {boolean} options.showForm Show as form, default false
     * @example
      * // Show data panel with content, data is {"a":1,"b":2}, data panel title is "Data View", width 300, height 200, left 50, top 100, position top-left, shown as form
      * this.showDataPanel({a:1,b:2}, {title: "Data View", width: 300, height: 200, left: 50, top: 100, position: "top-left", showForm: true})
     */
    showDataPanel(data, options)  {
            options = options || {};
            options.title = options.title || "Data View";
            this.context.ui.setDataPanelContent(data, options);   
         }
    

    /**
     * Set map center coordinates
     * @param {[number, number]} point Center point [x,y]
     * @example
      * this.setCenter([100, 20]); // Set center to x=100, y=20
     */
    setCenter(point)  {
            let oldCenter = this.map.fromLngLat(this.map.getCenter()) ; // Get current center
            this.map.setCenter(this.map.toLngLat(point)); // Set new center
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.setCenter(this.map.toLngLat(oldCenter));
            });
        }
    

    /**
     * Clear map data
     * @param {object} options Options
     * @param {boolean} [options.clearMarkers] Clear all markers
     * @param {boolean} [options.clearDraw] Clear all draw layers
     * @param {boolean} [options.clearOtherLayerData] Clear all data except base map
     * @param {boolean} [options.clearAllData] Clear all map data
     * @example
      * // Example 1
      * // Clear all markers
      * this.clearMapData({clearMarkers: true});
      * // Example 2
      * // Clear all draw layers
      * this.clearMapData({clearDraw: true});
      * // Example 3
      * // Clear all data except base map
      * this.clearMapData({clearOtherLayerData: true});
      * // Example 4
      * // Clear all map data
      * this.clearMapData({clearAllData: true});
     */
    clearMapData(options)  {
            options = options || {};
            if (options.clearMarkers === true) {
                this.map.removeMarkers();
                this.map.removePopups();
            }
            if (options.clearDraw === true) {
                let drawLayer = this.map.getDrawLayer();
                drawLayer.deleteAll();
            }
            if (options.clearOtherLayerData === true) {
                this.map.removeMarkers();
                this.map.removePopups();
                // Remove all draw layers
                // @ts-ignore
                const controls = this.map._controls;
                for(let k = controls.length - 1; k >= 0; k--) {
                    if (controls[k] instanceof vjmap.Draw.Tool) {
                        this.map.removeControl(controls[k])
                    }
                }
                let sources = this.map.getStyle().sources;
                
                for(let item in sources) {
                    if (item != "highlight-source" && sources[item].type == "geojson")
                    {
                        // Remove geojson only, keep click-highlight layers
                        this.map.removeSourceEx(item);
                    }
                }
            } 
            if (options.clearAllData === true) {
                if (this.map.hasVectorLayer()) {
                    this.map.disableVectorLayerHoverHighlight()
                } else {
                    this.map.disableLayerClickHighlight();
                }
                // Remove all draw layers
                // @ts-ignore
                const controls = this.map._controls;
                for(let k = controls.length - 1; k >= 0; k--) {
                    if (controls[k] instanceof vjmap.Draw.Tool) {
                        this.map.removeControl(controls[k])
                    }
                }
                let sources = this.map.getStyle().sources;
                for(let item in sources) {
                    // @ts-ignore
                   this.map.removeSourceEx(item);
                }
                this.map.removeMarkers();
                this.map.removePopups();
            }
        }
    

    /**
     * Move layer
     * @param {string} layerName Layer name, required
     * @param {object} options Other params, optional
     * @param {string} options.beforeLayerName Move before this layer, optional
     * @param {string} options.topMost Move to top, optional
     * @param {string} options.bottomMost Move to bottom, optional
     * @example
      * // Example 1
      * // Move layer1 to top
      * let layerName = 'layer1';
      * this.moveLayer(layerName, {
      * topMost: true
      * });
      * // Example 2
      * // Move layer1 to bottom
      * this.moveLayer(layerName, {
      * bottomMost: true
      * });
      * // Example 3
      * // Move layer1 before layer2
      * this.moveLayer(layerName, {
      * beforeLayerName: 'layer2'
      * });
     */
    moveLayer(layerName, options)  {
            let item = this.context.global.layers.find(layer => layer.layerName == layerName);
            if(item) {
                item.visible = true;
                let layerId = item.layerId;
                if (item.layer && typeof(item.layer.getLayerId) == "function") {
                    layerId = item.layer.getLayerId();
                }
                let layers = this.map.getStyle().layers;
                let oldBeforeLayerId;
                let index = layers.findIndex(item => item.id == layerId);
                if (index < layers.length - 1) {
                    oldBeforeLayerId = layers[index + 1].id;
                }
                
                if (options.topMost) {
                    this.map.moveLayer(layerId);
                } else if (options.bottomMost) {
                    this.map.moveLayer(layerId, layers[0].id);
                } else if (options.beforeLayerName) {
                    let itemBefore = this.context.global.layers.find(layer => layer.layerName == options.beforeLayerName);
                    let beforeLayerId;
                    if (itemBefore) {
                        beforeLayerId = itemBefore.layerId;
                        if (itemBefore.layer && typeof(itemBefore.layer.getLayerId) == "function") {
                            beforeLayerId = itemBefore.layer.getLayerId();
                        }
                    }
                    this.map.moveLayer(beforeLayerId);
                }
    
    
                // Add undo record
                this.context.ui.addUndoRecord(() => {
                    if (oldBeforeLayerId) {
                        this.map.moveLayer(layerId, oldBeforeLayerId);
                    }
                });
            }
        }
    

    /**
     * Manually draw a line interactively
     * @param {object} options Config params, optional
     * @param {boolean} options.isSnap Enable snap, optional, default false
     * @param {string} options.color Color #RRGGBB, optional
     * @param {number} options.width Line width, default 1, optional
     * @param {number} options.data Data value, optional
     * @returns {Promise<Object | undefined>} Drawn entity or undefined if cancelled. Object includes:
     * @example
      * // Manually draw a line
      * const line = await this.drawLineString({
      * isSnap: true,
      * color: "#FF0000",
      * width: 2
      * });
      * if (!line) return;
      * console.log("Line ID:", line.id);
      * console.log("Line points:", line.points);
     */
    async drawLineString(options)  {
            let points = await this.actionDrawLineString(options.isSnap);
            if (!points) return;
            return this.createLineString(points, options);
        }
    

    /**
     * Get map max/min zoom
     * @returns {number} Current max zoom
     */
    getMaxZoom() {  this.map.getMaxZoom() }
    

    /**
     * Remove map fullscreen control
     * @example
      * // Remove map fullscreen control
      * this.removeMapFullscreenControl();
     */
    removeMapFullscreenControl()  {
            this.context.global.controls = this.context.global.controls || {};
            let options;
            if (this.context.global.controls.fullscreenControl) {
                options = {...this.context.global.controls.fullscreenControl.options}
                this.map.removeControl(this.context.global.controls.fullscreenControl.control);
                delete this.context.global.controls.fullscreenControl;
            }
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                if (options) {
                    this.addMapFullscreenControl(options);
                }
            }); 
        }
    
    

    /**
     * Show confirm dialog
     * @param {string} message Dialog content, optional
     * @param {string} title Dialog title, optional
     * @param {string} type Dialog type: warning, error, info, success
     * @returns {boolean} Returns true for OK, false for Cancel
     * @example
      * // Show confirm dialog with message "Delete?"
      * let res = await this.messageBoxConfirm("Delete?", "Prompt", "warning");
      * if (res) {
      * console.log("Clicked OK")
      * } else {
      * console.log("Clicked Cancel")
      * }
     */
    async messageBoxConfirm(message, title, type)  {
            return await this.context.ui.confirm(message || "Confirm?", title || "Prompt", type || "warning");
        }
    

    /**
     * Get map rotation angle
     * @returns {number} Current map rotation
     */
    getBearing() {  this.map.getBearing() }
    

    /**
     * Get map service version and env info
     * @example
      * // Get map service version and env info, show returned data in data info dialog, title is map service version info, shown as form
      * let res = await this.getMapServerInfo();
      * if (res) {
      * await this.showDataInfoDialog(res, true, "Map service version info");
      * }
     */
    async getMapServerInfo()  {
            let svc = this.map.getService();
            const _url = svc.baseUrl() + "version";
            let res = await svc._get(_url, {});
            if (res && "data" in res) {
                let data =  res["data"];
                return {
                    version: data.version,
                    os: data.os,
                    arch: data.arch
                };
            }
         }
    
    

    /**
     * Set map raster base style
     * @param {object} options Other params, optional
     * @param {number} options.rasterOpacity Image opacity 0-1, default 1
     * @param {number} options.rasterHueRotate Hue rotation degrees, default 0
     * @param {number} options.rasterBrightnessMin Min brightness 0-1, default 0
     * @param {number} options.rasterBrightnessMax Max brightness 0-1, default 1
     * @param {number} options.rasterSaturation Saturation -1 to 1, default 0
     * @param {number} options.rasterContrast Contrast -1 to 1, default 0
     * @param {boolean} options.showSettingForm Show settings dialog, default true
     * @example
      * // Set map raster base style
      * this.setMapRasterLayerStyle();
     */
    async setMapRasterLayerStyle(options)  {
            options = options || {};
            let showSettingForm = options.showSettingForm === undefined ? true : options.showSettingForm;
            let oldStyle = {...this.map.getLayerStyle(this.map.getService().rasterLayerId())?.paint};
            // Convert style to camelCase
            let formattedOldStyle = {};
            for (let key in oldStyle) {
                let camelCaseKey = key.replace(/-([a-z])/g, (match, p1) => p1.toUpperCase());
                formattedOldStyle[camelCaseKey] = oldStyle[key];
            }
            oldStyle = formattedOldStyle;
            delete options.showSettingForm;
            options = {
                rasterOpacity: oldStyle.rasterOpacity || 1,
                rasterHueRotate: oldStyle.rasterHueRotate || 0,
                rasterBrightnessMin: oldStyle.rasterBrightnessMin || 0,
                rasterBrightnessMax: oldStyle.rasterBrightnessMax || 1,
                rasterSaturation: oldStyle.rasterSaturation || 0,
                rasterContrast: oldStyle.rasterContrast || 0,
                ...options,
            }
            let svc = this.map.getService();
            let layerId = svc.rasterLayerId();
            const setRasterStyle = (data) => {
                this.map.setRasterOpacity(layerId, data.rasterOpacity || 1);
                this.map.setRasterHueRotate(layerId, data.rasterHueRotate || 0);
                this.map.setRasterBrightnessMin(layerId, data.rasterBrightnessMin || 0);
                this.map.setRasterBrightnessMax(layerId, data.rasterBrightnessMax || 1);
                this.map.setRasterSaturation(layerId, data.rasterSaturation || 0);
                this.map.setRasterContrast(layerId, data.rasterContrast || 0);
            }
            if (showSettingForm) {
                let dataOptions = {
                    rasterOpacity: {
                        type:"slider",
                        field:"rasterOpacity",
                        title:"Image opacity",
                        value:options.rasterOpacity,
                        props:{
                            min: 0,
                            max: 1,
                            step: 0.01
                        }
                    },
                    rasterHueRotate: {
                        type:"slider",
                        field:"rasterHueRotate",
                        title:"Hue rotation",
                        value: options.rasterHueRotate,
                        props:{
                            min: 0,
                            max: 360
                        }
                    },
                    rasterBrightnessMin: {
                        type:"slider",
                        field:"rasterBrightnessMin",
                        title:"Min brightness",
                        value: options.rasterBrightnessMin,
                        props:{
                            min: 0,
                            max: 1,
                            step: 0.01
                        }
                    },
                    rasterBrightnessMax: {
                        type:"slider",
                        field:"rasterBrightnessMax",
                        title:"Max brightness",
                        value: options.rasterBrightnessMax,
                        props:{
                            min: 0,
                            max: 1,
                            step: 0.01
                        }
                    },
                    rasterSaturation: {
                        type:"slider",
                        field:"rasterSaturation",
                        title:"Saturation",
                        value: options.rasterSaturation,
                        props:{
                            min: -1,
                            max: 1,
                            step: 0.01
                        }
                    },
                    rasterContrast: {
                        type:"slider",
                        field:"rasterContrast",
                        title:"Contrast",
                        value: options.rasterContrast,
                        props:{
                            min: -1,
                            max: 1,
                            step: 0.01
                        }
                    },
                }
                options = await this.showDataModifyDlg({
                    data: dataOptions,
                    title: "Raster base style settings",
                    showForm: true,
                    applyCallback: (data) => {
                        setRasterStyle(data);
                    }
                })
                if (!options) {
                    // Restore style if user cancels
                    setRasterStyle(oldStyle);
                    return
                }
            }
            setRasterStyle(options);
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                setRasterStyle(oldStyle);
            });
        }
    

    /**
     * Set map zoom level
     * @param {number} zoom Zoom level to set
     * @example
      * this.setZoom(10); // Set zoom to 10
      * // Zoom in one level
      * let zoom = this.getZoom(); // Get original zoom
      * zoom = zoom + 1; // Zoom in one level
      * this.setZoom(zoom);
     */
    setZoom(zoom)  {
            let oldZoom = this.map.getZoom(); // Get current zoom
            this.map.setZoom(zoom) // Set new zoom
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.setZoom(oldZoom);
            });
        }
    

    /**
     * Measure coordinates
     * @example
      * // Measure coordinates
      * await this.measureCoordinate();
     */
    measureCoordinate()  {
                vjcommon.runMeasureCmd(this.map, "measureCoordinate");
            }
    
    

    /**
     * Create contour lines from data
     * @param {Object} options Config params
     * @param {number[]} options.breaks Contour breaks
     * @param {string} [options.model] Interpolation model: 'exponential', 'gaussian', 'spherical', default 'exponential',
     * @param {number} [options.sigma2] Variance param, default 0
     * @param {number} [options.alpha] Alpha prior, default 100
     * @param {string[]} [options.colors] Contour fill colors
     * @param {number} [options.width] Contour width, default 500
     * @param {number} [options.contoursSize] Contour intervals, default 20
     * @param {[number, number, number, number]} [options.extent] Extent to draw, auto from data
     * @param {number} [options.maxHeight] Max extrusion height, default 100
     * @param {[number, number]} [options.clipBounds] Clip bounds, optional
     * @param {boolean} [options.drawSymbols] Draw original points, default false
     * @param {boolean} [options.drawPolyline] Draw contour lines, default true
     * @param {boolean} [options.drawPolygon] Draw contour polygon, default false
     * @param {boolean} [options.drawFillExtrusion] Draw fill extrusion, default false
     * @returns {Object} Returns object with source ID array and layer ID array {sourceIds: string[], layerIds: string[]}
     * @example
      * // Randomly generate 50 points within map bounds, assign random value 10-150 to each, draw contour lines from these points
      * let points = this.randomPointInMapBounds(50);
      * let data = points.map(p => ({x: p[0], y: p[1], value: Math.floor(Math.random() * 90) + 10}));
      * let contour = await this.vectorContour({data});
      * console.log(contour);
     */
    async vectorContour(options)  {
            options = options || {};
            options.data = options.data || [];
            let dataset = {
                "type" : "FeatureCollection",
                "features" : []
            };
            let dataMinValue = 0;
            let dataMaxValue = 0;
            options.data = options.data.map(item => {
                return Array.isArray(item) ? {
                    x: item[0],
                    y: item[1],
                    value: item[2]
                } : item;
            });
            let minx, miny, maxx, maxy;
            for (let i = 0; i < options.data.length; i++) {
                if (i == 0) {
                    dataMinValue = options.data[i].value;
                    dataMaxValue = options.data[i].value;
                    minx = options.data[i].x;
                    miny = options.data[i].y;
                    maxx = options.data[i].x;
                    maxy = options.data[i].y;
                } else {
                    if (options.data[i].value < dataMinValue) dataMinValue = options.data[i].value;
                    if (options.data[i].value > dataMaxValue) dataMaxValue = options.data[i].value;
                    if (options.data[i].x < minx) minx = options.data[i].x;
                    if (options.data[i].y < miny) miny = options.data[i].y;
                    if (options.data[i].x > maxx) maxx = options.data[i].x;
                    if (options.data[i].y > maxy) maxy = options.data[i].y;
                }
                let feature={
                    "type" : "Feature",
                    "properties" : {
                        "value" : options.data[i].value
                    },
                    "geometry" : {
                        "type" : "Point",
                        "coordinates" : this.map.toLngLat([options.data[i].x,options.data[i].y])
                    }
                };
                dataset.features.push(feature);
            }
            let extent = options.extent || vjmap.GeoBounds.fromArray([minx, miny, maxx, maxy]).scale(1.2).toArray();
            let contoursSize = options.contoursSize || 20; // Contour interval count
            let model = options.model || 'exponential'; // Model name
            let alpha = options.alpha || 100; // Alpha value
            let colors = options.colors || ["#006837", "#1a9850", "#66bd63", "#a6d96a", "#d9ef8b", "#ffffbf","#fee08b",
                "#fdae61", "#f46d43", "#d73027", "#a50026"];
            let propField = "value";
            let maxHeight = options.maxHeight || this.map.pixelToHeight(100, this.map.getZoom()) ; // Set max extrusion height
            let contours = [];
            let isClipContour = false;
            if (options.clipBounds) {
                isClipContour = true;
                options.clipBounds = vjmap.GeoBounds.fromArray(options.clipBounds);
            }
    
            for(let i = 0; i < contoursSize; i++) {
                contours.push(dataMinValue + (dataMaxValue - dataMinValue) * i /  (contoursSize - 1));
            }
            if (dataMinValue >= 10 && dataMaxValue > 100) {
                // Round contour values by 10
                for(let i = 0; i < contours.length; i++) {
                    contours[i] = (parseInt(contours[i]/10) * 10);
                }
            }
    
            let interpolateInput = [], interpolateOutput = [];
            for(let i = 0; i < colors.length; i++) {
                interpolateInput.push(i / (colors.length - 1)); // Interpolate input 0-1
                interpolateOutput.push(colors[i]) // Interpolate output colors
            }
    
            // Start web worker
            let createContourWorker = vjmap.WorkerProxy(vjmap.vectorContour);
            let { grid, contour, variogram } =  await createContourWorker(this.map.fromLngLat(dataset), propField, contours, {
                model: model || 'exponential', // 'exponential','gaussian','spherical', choose one, default exponential
                sigma2: options.sigma2 || 0, // sigma2 variance param, z-distance of this data set; σ² likelihood reflects Gaussian process error, set manually; usually 0, other values may produce blank output
                alpha: alpha, // Alpha prior value, may control diffusion range: smaller = larger range, noticeable with few points; with many evenly distributed points, changing has little effect; default 100
                extent: extent, // Extent auto from data if omitted
                width: options.width || 500 // Contour width in pixels
            }, []);
            contour = this.map.toLngLat(contour);
           
            // Interpolate color by ratio
            const mapProgressToValues = value => vjmap.interpolate(
                interpolateInput,
                interpolateOutput,
                { ease: vjmap.linear }
            )(value)
    
            // Set original data color for marker
            dataset.features.forEach(f => f.properties.color = mapProgressToValues((f.properties.value - dataMinValue) / (dataMaxValue - dataMinValue)))
    
            let h = maxHeight; // Set max extrusion height
            for(let i = 0; i < contour.features.length; i++) {
                let prop = contour.features[i].properties;
                let r = (prop.value - dataMinValue) / (dataMaxValue - dataMinValue);
                prop.color = mapProgressToValues(r); // Interpolate color
                prop.height = h * r; // Interpolate extrusion height
            }
    
            contour.features.forEach(f => {
                f.properties.formatValue = Math.round(f.properties.value) // Add formatted label value
            })
    
            
            let markers = null;
            let markerLayerId = "marker_contour_" + vjmap.RandomID();
            const addMarkers = ()=> {
                if (markers) return;
                markers = dataset.features.map(f => {
                    if (isClipContour && !clipBounds.contains(this.map.fromLngLat(f.geometry.coordinates))) {
                        // Skip markers outside clip bounds
                        return null;
                    }
                    // Random styles
                    let _marker = new vjmap.DiffusedApertureMarker({
                        lngLat: f.geometry.coordinates,
                        text: f.properties.value.toFixed(0)
                    }, {
                        // Set props: width, color, font
                        width: 10,
                        colors: [f.properties.color, vjmap.randomColor()],
                        textFontSize: 14,
                        textColor: f.properties.color
                    }).createMarker();
                    _marker.layerId = markerLayerId
                    _marker.addTo(this.map)
                    return _marker
                })
            }
            const removeMarkers = ()=> {
                if (!markers) return;
                for(let i = markers.length - 1; i >= 0; i--) {
                    if (markers[i]) markers[i].remove();
                }
                markers = null;
            }
    
            let symbols = null;
            const addSymbols = async ()=> {
                if (symbols) return;
                let contourData = contour;
                if (isClipContour) contourData = clipContours(contour, clipBounds);
    
                // Add text bg image
                await this.map.addImageData("imgMaskText", `<svg viewBox="0 0 1024 1024" width="1024" height="1024" xmlns="http://www.w3.org/2000/svg">
                            <path d="M0 0L0 1024 1024 1024 1024 0z"  fill="#022B4F"></path>
                        </svg>`, 30, 15);
                symbols = new vjmap.Symbol({
                    data: contourData,
                    iconImage: "imgMaskText",
                    symbolSpacing: 250, // Symbol spacing 250px
                    textField:  [
                        'format',
                        ["to-string", ['round', ['get', 'value']]], // Round value for display; for decimals use ['number-format',['get', 'value'], { 'min-fraction-digits': 1, 'max-fraction-digits': 1 }],
                        {}
                    ],
                    textFont: ['Arial Unicode MS Regular'],
                    textSize: 14,
                    textColor: '#00ffff',
                    symbolPlacement: "line",
                    textAnchor: 'center',
                    textRotationAlignment: "this.map",
                    iconAllowOverlap: false,
                    textAllowOverlap: false
                });
                symbols.addTo(this.map);
            }
    
            const removeSymbols = ()=> {
                if (!symbols) return;
                symbols.remove();
                symbols = null;
            }
    
            let polyline = null;
            const addPolyline = ()=> {
                if (polyline) return;
                let contourData = contour;
                if (isClipContour) contourData = clipContours(contour, clipBounds);
                polyline = new vjmap.Polyline({
                    data: contourData,
                    lineColor: ['case', ['to-boolean', ['feature-state', 'hover']], '#00ffff', ['get', 'color']],
                    isHoverPointer: true,
                    isHoverFeatureState: true
                });
                polyline.addTo(this.map);
                polyline.clickPopup(f => `<h3>Value: ${f.properties.value.toFixed(2)}</h3>Color: ${f.properties.color}`, { anchor: 'bottom' });
    
            }
            const removePolyline = ()=> {
                if (!polyline) return;
                polyline.remove();
                polyline = null;
                removeSymbols();
            }
            const drawPolyline = async () => {
                addPolyline();
                removeSymbols();
                await addSymbols();
            }
    
            let polygon = null;
            const addPolygon = ()=> {
                if (polygon) return;
                let contourData = contour;
                if (isClipContour) contourData = clipContoursPolygon(contour, clipBounds);
                polygon = new vjmap.Polygon({
                    data: contourData,
                    fillColor: ['case', ['to-boolean', ['feature-state', 'hover']], '#00ffff', ['get', 'color']],
                    fillOpacity: 0.9,
                    isHoverPointer: true,
                    isHoverFeatureState: true
                });
                polygon.addTo(this.map);
                polygon.clickPopup(f => `<h3>Value: ${f.properties.value.toFixed(2)}</h3>Color: ${f.properties.color}`, { anchor: 'bottom' });
            }
            const removePolygon = ()=> {
                if (!polygon) return;
                polygon.remove();
                polygon = null;
                removeSymbols();
            }
    
            const drawPolygon = async () => {
                addPolygon();
                removeSymbols();
                await addSymbols();
            }
    
            let fillExtrusions = null;
            const addFillExtrusion = ()=> {
                if (fillExtrusions) return;
                fillExtrusions = new vjmap.FillExtrusion({
                    data: contour,
                    fillExtrusionColor: ['case', ['to-boolean', ['feature-state', 'hover']], '#00ffff', ['get', 'color']],
                    fillExtrusionOpacity: 0.9,
                    fillExtrusionHeight: ['get', 'height'],
                    fillExtrusionBase:0,
                    isHoverPointer: true,
                    isHoverFeatureState: true
                });
                fillExtrusions.addTo(this.map);
                fillExtrusions.clickPopup(f => `<h3>Value: ${f.properties.value.toFixed(2)}</h3>Color: ${f.properties.color}`, { anchor: 'bottom' });
            }
            const removeFillExtrusion = ()=> {
                if (!fillExtrusions) return;
                fillExtrusions.remove();
                fillExtrusions = null;
                removeSymbols();
            }
    
            const drawFillExtrusion = async ()=> {
                addFillExtrusion();
                removeSymbols();
                await addSymbols();
            }
    
            let clipPolyline = null;
            const addClipPolyline = () => {
                if (clipPolyline) return;
                let points = clipBounds.toPointArray();
                points.push(points[0]);
                clipPolyline = new vjmap.Polyline({
                    data: this.map.toLngLat(points),
                    lineWidth: 2,
                    lineColor: 'red'
                });
                clipPolyline.addTo(this.map);
            }
            const removeClipPolyline = ()=> {
                if (!clipPolyline) return;
                clipPolyline.remove();
                clipPolyline = null;
            }
    
            
            // Polylines and clip bounds
            const clipContours = (polylineData, clipBounds) => {
                let data = {
                    type: 'FeatureCollection',
                    features: []
                }
                for(let k = 0; k < polylineData.features.length; k++) {
                    // Iterate sub-polygons
                    for(let n = 0; n < polylineData.features[k].geometry.coordinates.length; n++) {
                        let coordinates = this.map.fromLngLat(polylineData.features[k].geometry.coordinates[n]);
                        // Clip segment by segment
                        let clipCoordinates = [];
                        let clipSubCoordinates = [];
                        for(let j = 1; j < coordinates.length; j++) {
                            let p1 = vjmap.geoPoint(coordinates[j - 1]);
                            let p2 = vjmap.geoPoint(coordinates[j]);
                            let res = vjmap.clipSegment(p1, p2, clipBounds);
                            if (res === false) {
                                // Split at bounds
                                if (clipSubCoordinates.length > 0) {
                                    clipCoordinates.push(this.map.toLngLat(clipSubCoordinates));
                                    clipSubCoordinates = [];
                                }
                                continue;
                            }
                            let pt1 = [res[0].x, res[0].y];
                            let pt2 = [res[1].x, res[1].y];
                            clipSubCoordinates.push(pt1, pt2);
                        }
                        if (clipSubCoordinates.length > 0) {
                            clipCoordinates.push(this.map.toLngLat(clipSubCoordinates));
                        }
                        if (clipCoordinates.length > 0) {
                            for(let i = 0; i < clipCoordinates.length; i++) {
                                let feature = vjmap.cloneDeep(polylineData.features[k]);
                                feature.geometry.type = "LineString" ;// Polygon to LineString
                                feature.geometry.coordinates = clipCoordinates[i];
                                data.features.push(feature);
                            }
                        }
                    }
                }
                return data
            }
    
            // Polygon and clip bounds
            const clipContoursPolygon = (polygonData, clipBounds) => {
                let data = {
                    type: 'FeatureCollection',
                    features: []
                }
                for (let k = 0; k < polygonData.features.length; k++) {
                    // Iterate sub-polygons
                    for (let n = 0; n < polygonData.features[k].geometry.coordinates.length; n++) {
                        let feature = vjmap.cloneDeep(polygonData.features[k]);
                        feature.geometry.coordinates = []
                        let coordinates = this.map.fromLngLat(polygonData.features[k].geometry.coordinates[n]);
                        let clipCoordinates = vjmap.clipPolygon(coordinates, clipBounds)
                        if (clipCoordinates.length > 2) {
                            clipCoordinates.push(clipCoordinates[0]); // Close
                            feature.geometry.coordinates.push(this.map.toLngLat(clipCoordinates));
                            feature.properties.area = vjmap.calcPolygonArea(clipCoordinates)
                            data.features.push(feature)
                        }
                    }
                }
                // Sort by area
                data.features = data.features.sort((a, b) => b.properties.area - a.properties.area)
                return data
            }
    
            if (options.drawSymbols) {
                addMarkers();
            }
            if (options.drawPolyline !== false) {
                await drawPolyline();
            }
            if (options.drawPolygon) {
                await drawPolygon();
            }
            if (options.drawFillExtrusion) {
                await drawFillExtrusion();
            }
            if (isClipContour) {
                addClipPolyline();
            }
    
            let sourceIds = [];
            let layerIds = [];
            if (markers) {
                sourceIds.push(markerLayerId);
                layerIds.push(markerLayerId);
            }
            if (symbols) {
                sourceIds.push(symbols.sourceId);
                layerIds.push(symbols.layerId);
            }
            if (polyline) {
                sourceIds.push(polyline.sourceId);
                layerIds.push(polyline.layerId);
            }
            if (polygon) {
                sourceIds.push(polygon.sourceId);
                layerIds.push(polygon.layerId);
            }
            if (fillExtrusions) {
                sourceIds.push(fillExtrusions.sourceId);
                layerIds.push(fillExtrusions.layerId);
            }
            if (clipPolyline) {
                sourceIds.push(clipPolyline.sourceId);
                layerIds.push(clipPolyline.layerId);
            }
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
               for(let i = 0; i < sourceIds.length; i++) {
                if (sourceIds[i].indexOf("marker_contour_")  == 0) {
                    // Handle marker separately
                    let markers = this.map.getMarkers();
                    // Delete in reverse order
                    for(let j = markers.length - 1; j >= 0; j--) {
                        if (markers[j].layerId == sourceIds[i]) {
                            markers[j].remove();
                        }
                    }
                } else {
                    this.map.removeSourceEx(sourceIds[i]);
                }
               }
            });
    
            return { sourceIds, layerIds }
        }
    

    /**
     * Get WMS tile URLs
     * @param {object} options WMS config options
     * @param {string} [options.mapid] Map ID
     * @param {string} [options.version] Map version, uses current when empty
     * @param {string} [options.layer] Layer style name, default when empty
     * @param {string} [options.epsg] CAD EPSG code
     * @param {"beijing54"|"xian80"|"cgs2000"} [options.crs] CAD CRS: beijing54, xian80, cgs2000
     * @param {string} [options.webMapTiles] Web map tile URL when overlay
     * @param {boolean} [options.overMapImage] Whether overlay is imagery
     * @param {boolean} [options.isSetRotateZero] Allow rotate for control points
     * @param {boolean} [options.baseMapWgs84] Base map WGS84
     * @returns {Promise<string[]>} Returns WMS tile URL array
     * @example
      * // Overlay mapid sys_hello, get WMS tile URLs, create raster tile layer from these URLs
      * let wmsTileUrl = await this.getWmsTileUrl({
      * mapid: "sys_hello"
      * });
      * let layer = await this.createRasterLayer({
      * tiles: wmsTileUrl,
      * });
      * console.log(layer);
     */
    async getWmsTileUrl(options)  {
            options = options || {}
            let svc = this.map.getService();
            // Check if base is web map
            let isWebMap = this.map.getMapProjection() instanceof vjmap.LnglatProjection;
            let baseMapType = "CAD"
            if (isWebMap) {
                if (options.baseMapWgs84 === undefined) {
                    // Auto fetch
                    baseMapType = "GCJ02";
                    let sources = this.map.getStyle().sources;
                    for (let name in sources) {
                        let source = sources[name];
                        if (source.type === 'raster') {
                            let tiles = source.tiles;
                            if (tiles && tiles.length > 0) {
                                let tile = tiles[0];
                                if (tile.includes('tianditu') || tile.includes('openstreetmap')) {
                                    baseMapType = "WGS84";
                                    break;
                                }
                            }
                        }
                    }
                } else {
                    baseMapType = options.baseMapWgs84 === true ? "WGS84" : "GCJ02";
                }
               
            }
            let mapParam  = svc.currentMapParam() || {}
            let baseMapParam = {
                // Base map type 
                baseMapType: baseMapType,
                // Map ID
                mapid: mapParam.mapid,
                // Map version
                version: mapParam.version,
                // Map bounds
                mapbounds: JSON.stringify(this.map.getGeoBounds(1.0).toArray())
            }
    
            let overlayType = options.overlayType;
            if (overlayType !== "param") {
                if (options.epsg !== undefined) {
                    overlayType = "auto";
                } else {
                    let epsg = await this.getCadEpsgCode(options);
                    if (epsg) {
                        options.epsg = epsg;
                        overlayType = "auto";
                    }
                }
            }
           
            if (overlayType === undefined && options.coordinates !== undefined) {
                overlayType = "param";
            }
    
            let overMapType = options.overMapType || "CAD";
            
            if (options.webMapTiles === undefined) {
                let isImageUrl =  options.overMapImage === true; // Is imagery map
                if (overMapType === "WGS84") {
                    options.webMapTiles = vjcommon.tiandituProviderTiles(isImageUrl);
                } else if (overMapType === "GCJ02") {
                    options.webMapTiles = vjcommon.gaodeProviderTiles(isImageUrl);
                }
            }
            if (overlayType === "auto" && !options.epsg) {
                this.showMessage("Please set CAD EPSG"); 
                return;
            }
            let overMapParam = {
                // Map ID (uses current mapid when empty)
                mapid: options.mapid,
                // Map version, uses current when empty
                version: options.version ,
                // Layer style name
                layer: options.layer,
                // CAD CRS
                crs: options.epsg,
                // Overlay type
                baseMapType: overMapType,
                // Web tile URL
                webMapTiles: Array.isArray(options.webMapTiles) ? options.webMapTiles : [options.webMapTiles]  ,
                isSetRotateZero: options.isSetRotateZero, // Allow rotate for control points
                coordinates: options.coordinates // Control point coords
            };
           
            return await vjcommon.getWmsTileUrl(svc, baseMapParam, overlayType || "direct", {maps: overMapParam})
        }
    

    /**
     * Export as new CAD drawing
     */
    async exportCAD()  {
            this.map.logInfo("Exporting DWG, please wait...")
            const res = await vjcommon.exportDwg(this.map, this.map.getDrawLayer());
            if (res.error) {
                this.map.logInfo(res.error, "error");
                return;
            }
            const isCloud = this.map.getService().serverUrl.indexOf("vjmap.com") >= 0;
            const host = isCloud ? "https://vjmap.com/app/cloud" : (window.location.origin + "/_cloud");
            const url = host + `/#/this.map/${res.mapid}?mapopenway=Memory&version=${res.version}`;
            window.open(url);
          }
    
    

    /**
     * Create path animation data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @param {string} options.lineColor Line color, default #FF9900
     * @param {number} options.lineWidth Line width, default 10
     * @param {number} options.lineOpacity Line opacity 0-1
     * @param {number} options.lineGapWidth Line gap width
     * @param {number} options.lineOffset Line offset
     * @param {number} options.lineBlur Line blur
     * @param {string} options.strokeStyle Stroke: solid or dashed
     * @param {[number,number]} options.lineDasharray Dash array [1,1]
     * @param {[number,number]} options.lineTranslate Translate [x,y]
     * @param {string} options.lineCap Line cap: butt, round, square
     * @param {string} options.lineJoin Line join: bevel, round, miter
     * @param {string} options.strokeImage Line pattern image name
     * @param {string} options.lineGradient Line gradient
     * @param {boolean} options.showBorder Show border, default true
     * @param {string} options.borderColor Border color, default #f00
     * @param {number} options.borderWidth Border width
     * @param {number} options.borderOpacity Border opacity 0-1
     * @param {number} options.borderGapWidth Border gap width
     * @param {number} options.borderOffset Border offset
     * @param {number} options.borderBlur Border blur
     * @param {string} options.borderGradient Border gradient
     * @param {boolean} options.showDir Show arrow, default true
     * @param {number} options.dirSize Arrow size
     * @param {number} options.dirSpacing Arrow spacing
     * @param {number} options.dirImageWidth Arrow image width
     * @param {number} options.dirImageHeight Arrow image height
     * @param {string} options.dirImageColor Arrow image color
     * @param {string} options.dirIconColor Arrow icon color
     * @param {string} options.dirLayout Arrow layout
     * @param {string} options.cursor Cursor shape
     * @param {string} options.iconImage Icon name for image background
     * @param {number} options.iconRotate Icon rotation angle, clockwise, degrees, default 0
     * @param {[number,number]} options.iconOffset Icon offset from anchor, [x,y], positive = right/down
     * @param {string} options.iconAnchor Icon anchor position, values: "center", "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right"
     * @param {string} options.iconRotationAlignment Icon rotation alignment, values: "map", "viewport", "auto"
     * @param {string} options.iconPitchAlignment Icon pitch alignment, values: "map", "viewport", "auto"
     * @param {boolean} options.iconAllowOverlap Allow icon overlap, default false
     * @param {number} options.iconOpacity Icon opacity 0-1, default 1
     * @param {boolean} options.iconIgnorePlacement Ignore collision detection, default false
     * @param {boolean} options.iconOptional Icon overlap option, default false
     * @param {number} options.iconSize Icon scale, default 1
     * @param {string} options.iconTextFit Icon text fit, values: "none", "width", "height", "both"
     * @param {[number,number,number,number]} options.iconTextFitPadding Icon text fit padding [top, right, bottom, left]
     * @param {number} options.iconPadding Icon padding, default 1px
     * @param {boolean} options.iconKeepUpright Keep icon upright, default false
     * @param {string} options.iconColor SDF icon color, default"#000000"
     * @param {string} options.iconHaloColor SDF icon halo color, default"rgba(0, 0, 0, 0)"
     * @param {number} options.iconHaloWidth Icon halo width, default 0
     * @param {number} options.iconHaloBlur Icon halo blur, default 0
     * @param {[number,number]} options.iconTranslate Icon translate [x,y]
     * @param {string} options.iconTranslateAnchor Icon translate anchor, values: "map", "viewport"
     * @param {string|Expression} options.textField Text label content
     * @param {string[]} options.textFont Text font, default["Open Sans Regular","Arial Unicode MS Regular"]
     * @param {number} options.textSize Text size, default 16
     * @param {boolean} options.textAllowOverlap Allow text overlap, default false
     * @param {string} options.textColor Text color, default"#000000"
     * @param {number} options.textOpacity Text opacity 0-1, default 1
     * @param {[number,number]} options.textOffset Text offset [x,y]
     * @param {number} options.textRotate Text rotation angle
     * @param {number} options.textPadding Text padding, default 2
     * @param {boolean} options.textKeepUpright Keep text upright, default true
     * @param {number} options.textMaxWidth Text max width, default 10em
     * @param {number} options.textLineHeight Text line height, default 1.2
     * @param {number} options.textLetterSpacing Letter spacing, default 0
     * @param {string} options.textJustify Text justify, values: "auto", "left", "center", "right"
     * @param {string} options.textAnchor Text anchor position
     * @param {string} options.textRotationAlignment Text rotation alignment
     * @param {string} options.textPitchAlignment Text pitch alignment
     * @param {number} options.textMaxAngle Max angle between chars, default 45°
     * @param {string[]} options.textWritingMode Text writing mode
     * @param {string} options.textTransform Text transform, values: "none", "uppercase", "lowercase"
     * @param {number} options.textRadialOffset Text radial offset
     * @param {string[]} options.textVariableAnchor Text variable anchor array
     * @param {boolean} options.textIgnorePlacement Ignore text placement
     * @param {boolean} options.textOptional Text optional
     * @param {string} options.textHaloColor Text halo color
     * @param {number} options.textHaloWidth Text halo width
     * @param {number} options.textHaloBlur Text halo blur
     * @param {[number,number]} options.textTranslate Text translate
     * @param {string} options.textTranslateAnchor Text translate anchor
     * @param {string} options.symbolPlacement Symbol placement, values: "point", "line", "line-center"
     * @param {number} options.symbolSpacing Symbol spacing, default 250
     * @param {boolean} options.symbolAvoidEdges Avoid edge collision
     * @param {string} options.symbolZOrder Symbol z-order, values: "auto", "viewport-y", "source"
     * @param {number} options.fps Animation frame rate, frames per second, default 10; line split into 100 segments, segments per second; range 1-100
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Randomly generate GeoJSON in map bounds, create path animation layer named layer21
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createPathAnimateLayer(geojson, {
      * layerName: 'layer21',
      * iconImage: 'icon1'
      * });
      * console.log(layer);
     */
    async createPathAnimateLayer(data, options)  {
            options = options || {};
            let type = "pathAnimate";
        
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                lineWidth: 10,
                showDir: true,
                showBorder: true,
                borderColor: "#f00",
                lineColor: '#FF9900',
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createPathAnimateLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Create rotating text border data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {string} options.text Text to display, optional, default "index ${props.index}"
     * @param {number} options.minZoom Min zoom to display, optional, default 0
     * @param {number} options.maxZoom Max zoom to display, optional, default 24
     * @param {boolean} options.isDrawLinePoint Include line points when drawing, optional, default false
     * @param {number} options.width Pixel width, optional, default 24
     * @param {number} options.markerHeight Pixel height, optional
     * @param {string} options.color1 Color value 1, optional
     * @param {string} options.color2 Color value 2, optional
     * @param {number} options.textFontSize Font size, optional
     * @param {string} options.textColor Text color, optional
     * @param {boolean} options.draggable Draggable, optional, default false
     * @param {string} options.animationType Animation type, optional: MAP_ANIMATION_NONE, MAP_ANIMATION_BOUNCE, MAP_ANIMATION_DROP
     * @param {number} options.rotation Rotation angle, optional
     * @param {string} options.rotationAlignment Rotation alignment, optional: auto, map, viewport
     * @param {string} options.pitchAlignment Pitch alignment, optional: auto, map, viewport
     * @param {number} options.height Elevation value, optional
     * @param {number} options.scaleMaxZoom Max zoom for scaling, optional
     * @param {boolean} options.removeWhenNoInMapView Auto show/hide in view, optional
     * @param {number} options.removeWhenNoInMapViewPadding Padding for auto show/hide, optional, default 500
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Randomly generate GeoJSON in map bounds, create rotating text border layer named layer28
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createTextBorderMarkerLayer(geojson, {
      * layerName: 'layer28'
      * });
      * console.log(layer);
     */
    async createTextBorderMarkerLayer(data, options)  {
            options = options || {};
            let type = "textBorderMarker";
        
    
            if (options.height) {
              options.height = this.map.geoLengthToHeight(options.height);
            }
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                text: "ID:${props.index}",
                width: 40,
                markerHeight: 20,
                color1: "#ff0000",
                color2: "#ffff00",
                textFontSize: 15,
                textColor: "#00ffff",
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createTextBorderMarkerLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Create symbol path animation data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @param {string} options.iconImage Icon name for image background
     * @param {number} options.iconRotate Icon rotation angle, clockwise, degrees, default 0
     * @param {[number,number]} options.iconOffset Icon offset from anchor, [x,y], positive = right/down
     * @param {string} options.iconAnchor Icon anchor position, values: "center", "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right"
     * @param {string} options.iconRotationAlignment Icon rotation alignment, values: "map", "viewport", "auto"
     * @param {string} options.iconPitchAlignment Icon pitch alignment, values: "map", "viewport", "auto"
     * @param {boolean} options.iconAllowOverlap Allow icon overlap, default false
     * @param {number} options.iconOpacity Icon opacity 0-1, default 1
     * @param {boolean} options.iconIgnorePlacement Ignore collision detection, default false
     * @param {boolean} options.iconOptional Icon overlap option, default false
     * @param {number} options.iconSize Icon scale, default 1
     * @param {string} options.iconTextFit Icon text fit, values: "none", "width", "height", "both"
     * @param {[number,number,number,number]} options.iconTextFitPadding Icon text fit padding [top, right, bottom, left]
     * @param {number} options.iconPadding Icon padding, default 1px
     * @param {boolean} options.iconKeepUpright Keep icon upright, default false
     * @param {string} options.iconColor SDF icon color, default"#000000"
     * @param {string} options.iconHaloColor SDF icon halo color, default"rgba(0, 0, 0, 0)"
     * @param {number} options.iconHaloWidth Icon halo width, default 0
     * @param {number} options.iconHaloBlur Icon halo blur, default 0
     * @param {[number,number]} options.iconTranslate Icon translate [x,y]
     * @param {string} options.iconTranslateAnchor Icon translate anchor, values: "map", "viewport"
     * @param {string|Expression} options.textField Text label content
     * @param {string[]} options.textFont Text font, default["Open Sans Regular","Arial Unicode MS Regular"]
     * @param {number} options.textSize Text size, default 16
     * @param {boolean} options.textAllowOverlap Allow text overlap, default false
     * @param {string} options.textColor Text color, default"#000000"
     * @param {number} options.textOpacity Text opacity 0-1, default 1
     * @param {[number,number]} options.textOffset Text offset [x,y]
     * @param {number} options.textRotate Text rotation angle
     * @param {number} options.textPadding Text padding, default 2
     * @param {boolean} options.textKeepUpright Keep text upright, default true
     * @param {number} options.textMaxWidth Text max width, default 10em
     * @param {number} options.textLineHeight Text line height, default 1.2
     * @param {number} options.textLetterSpacing Letter spacing, default 0
     * @param {string} options.textJustify Text justify, values: "auto", "left", "center", "right"
     * @param {string} options.textAnchor Text anchor position
     * @param {string} options.textRotationAlignment Text rotation alignment
     * @param {string} options.textPitchAlignment Text pitch alignment
     * @param {number} options.textMaxAngle Max angle between chars, default 45°
     * @param {string[]} options.textWritingMode Text writing mode
     * @param {string} options.textTransform Text transform, values: "none", "uppercase", "lowercase"
     * @param {number} options.textRadialOffset Text radial offset
     * @param {string[]} options.textVariableAnchor Text variable anchor array
     * @param {boolean} options.textIgnorePlacement Ignore text placement
     * @param {boolean} options.textOptional Text optional
     * @param {string} options.textHaloColor Text halo color
     * @param {number} options.textHaloWidth Text halo width
     * @param {number} options.textHaloBlur Text halo blur
     * @param {[number,number]} options.textTranslate Text translate
     * @param {string} options.textTranslateAnchor Text translate anchor
     * @param {string} options.symbolPlacement Symbol placement, values: "point", "line", "line-center"
     * @param {number} options.symbolSpacing Symbol spacing, default 250
     * @param {boolean} options.symbolAvoidEdges Avoid edge collision
     * @param {string} options.symbolZOrder Symbol z-order, values: "auto", "viewport-y", "source"
     * @param {string} options.lineColor Line color, optional, default '#ff0000'
     * @param {number} options.lineWidth Line width, optional, default 1
     * @param {number} options.lineOpacity Line opacity, optional, 0-1
     * @param {number} options.lineGapWidth Line gap width, optional, default 1, pixels
     * @param {number} options.lineOffset Line offset, optional
     * @param {number} options.lineBlur Line blur, optional
     * @param {[number,number]} options.lineDasharray Dash pattern, optional
     * @param {[number,number]} options.lineTranslate Translate [x,y], optional
     * @param {string} options.lineCap Line cap: 'butt', 'round', 'square'
     * @param {string} options.lineJoin Line join: 'bevel', 'round', 'miter'
     * @param {string} options.linePattern Line pattern, optional
     * @param {string} options.lineGradient Line gradient, optional
     * @param {boolean} options.drawPath Draw path line, true/false
     * @param {number} options.anmiDuration Animation duration in seconds, default 5. 0=no animation
     * @param {number} options.anmiRepeat Animation repeat count, default 0. 0=infinite
     * @param {string} options.anmiRepeatType Repeat type: "reverse", "loop", "mirror"
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Randomly generate GeoJSON in map bounds, create symbol path animation layer named layer26, icon name _markerIcon_
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createSymbolPathAnimateLayer(geojson, {
      * layerName: 'layer26',
      * iconImage: '_markerIcon_'
      * });
      * console.log(layer);
     */
    async createSymbolPathAnimateLayer(data, options)  {
            options = options || {};
            let type = "symbolPathAnimate";
        
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                drawPath: true,
                anmiDuration: 5,
                lineColor: '#75E7EF',
                lineOpacity: 0.4,
                lineWidth: 20,
                iconAllowOverlap: true,
                textAllowOverlap: true,
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createSymbolPathAnimateLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Query all block entities
     * @param {string | string[]} blockName Block name or block name array to query, optional, default empty, query all blocks when empty
     * @returns {Object} Returns standard GeoJSON FeatureCollection with features, each feature has geometry and properties
     * @example
      * // Query all block entities
      * let geoJson = await this.queryMapAllBlock();
      * // Get result
      * console.log(geoJson)
     */
    async queryMapAllBlock(blockName)  {
            let svc = this.map.getService();
            let query = await svc.exprQueryFeature({
                // Use expression query for all block entities first; with condition query blocks become discrete spatial data and cannot be queried as whole, so query block info via expression first
                /// To query all blocks. For a specific block name use: expr: `gOutReturn := if((gInFeatureType == 'AcDbBlockReference' and gInAttrs4 == 'block_name_to_query'), 1, 0);`,
                expr: `gOutReturn := if((gInFeatureType == 'AcDbBlockReference'), 1, 0);`,
                fields: "objectid,blockname,layername,positon,rotate,scaleX,scaleY,scaleZ,bounds", // empty means all fields
                geom: false, // memory mode
                useCache: true,
                limit: 1000000 // large value to find all
            })
            let blockNameSet = new Set();
            if (Array.isArray(blockName)) {
                blockName.forEach(name => {
                    blockNameSet.add(name)
                })
            } else if (typeof blockName === "string") {
                blockNameSet.add(blockName)
            }
            let geoJson = {
                type: "FeatureCollection",
                features: []
            }
            if (query.error) {
                this.map.logInfo(query.error, "error")
                return geoJson
            }
            for(let i = 0; i < query.result.length; i++) {
                if (blockNameSet.size > 0 && !blockNameSet.has(query.result[i].blockname)) {
                    continue
                }
                query.result[i].bounds = vjmap.GeoBounds.fromString(query.result[i].bounds).toArray();
                let feature = {
                    type: "Feature",
                    geometry: {
                        type: "Point",
                        coordinates: vjmap.GeoPoint.fromString(query.result[i].positon).toArray()
                    },
                    properties: query.result[i]
                }
                geoJson.features.push(feature)
            }
            return geoJson
        }
    

    /**
     * Get map bounds
     * @param {number} boundsType Bounds type (0 draw bounds, 1 actual bounds, 2 full bounds), default 0, optional
     * @returns {[number, number, number, number]} Returns array [x1,y1,x2,y2] for bottom-left and top-right coordinates
     * @example
      * // Get map bounds
      * let rect = this.getMapGeoBounds();
      * let minx = rect[0];
      * let miny = rect[1];
      * let maxx = rect[2];
      * let maxy = rect[3];
      * let width = maxx - minx; // Map bounds width
      * let height = maxy - miny; // Map bounds height
      * console.log(minx, miny, maxx, maxy, width, height);
      * // Get map actual bounds
      * let rect2 = this.getMapGeoBounds(1);
      * console.log(rect2);
     */
    getMapGeoBounds(boundsType)  {
            boundsType = boundsType || 0;
            let geoBounds;
            if (boundsType == 1) {
                // CAD actual bounds (0.4 of full map) this.map.getGeoBounds(0.4) // square
                geoBounds = this.map.getGeoBounds(0.4) 
            } else if (boundsType == 2) {
                // Full map bounds this.map.getGeoBounds(1.0) // square
                geoBounds = this.map.getGeoBounds(1)
            } else {
                // Map draw bounds
                geoBounds = this.map.getService().currentMapParam().drawBounds || this.map.getGeoBounds(0.4) 
            }
            return geoBounds.toArray()
        }
    

    /**
     * Create text data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {string} options.text Text to display, optional, default "index ${props.index}"
     * @param {Object} options.style Text style settings, optional
     * @param {number} options.minZoom Min zoom to display, optional, default 0
     * @param {number} options.maxZoom Max zoom to display, optional, default 24
     * @param {boolean} options.isDrawLinePoint Include line points when drawing, optional, default false
     * @param {string} options.color Marker color, optional, default'#3FB1CE'
     * @param {number[]} options.offset XY pixel offset [0,0], negative = left/up
     * @param {number} options.rotation Marker rotation, clockwise, default 0
     * @param {string} options.anchor Marker anchor:center、top、bottom、left、right、top-left、top-right、bottom-left、bottom-right, default center
     * @param {boolean} options.draggable Draggable, optional, default false
     * @param {string} options.animationType Animation: MAP_ANIMATION_NONE, MAP_ANIMATION_BOUNCE, MAP_ANIMATION_DROP
     * @param {string} options.rotationAlignment Rotation alignment: auto, map, viewport, default auto
     * @param {string} options.pitchAlignment Pitch alignment: auto, map, viewport, default auto
     * @param {number} options.scale Scale, default 1, 41px x 27px
     * @param {number} options.scaleMaxZoom Scale max zoom (0-24), auto-scale below
     * @param {number} options.height Elevation, CAD units
     * @param {boolean} options.removeWhenNoInMapView Auto show/hide in view
     * @param {number} options.removeWhenNoInMapViewPadding View padding for auto show/hide, default 500
     * @param {number} options.clickTolerance Click tolerance, inherits map default
     * @param {string} options.customImage Custom image URL/base64/svg
     * @param {number} options.customImageWidth Custom image width
     * @param {number} options.customImageHeight Custom image height
     * @param {string} options.customHtml Custom HTML element JS code
     * @param {string} options.popupHtml Popup HTML content
     * @param {[number,number]} options.popupOffset Popup XY offset
     * @param {string} options.popupAnchor Popup anchor, same as anchor
     * @param {boolean} options.closeButton Show close button
     * @param {boolean} options.closeOnClick Close popup on map click
     * @param {boolean} options.closeOnMove Close popup on map move
     * @param {boolean} options.focusAfterOpen Focus first focusable in popup
     * @param {string} options.className CSS class for popup
     * @param {string} options.maxWidth Popup max width, default'240px'
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Randomly generate GeoJSON in map bounds, create text data layer named layer27
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createTextDataLayer(geojson, {
      * layerName: 'layer27'
      * });
      * console.log(layer);
     */
    async createTextDataLayer(data, options)  {
            options = options || {};
            let type = "text";
        
            if (options.height) {
              options.height = this.map.geoLengthToHeight(options.height);
            }
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                text: "ID:${props.index}",
                style: {
                    'cursor': 'pointer',
                    'opacity': 1,
                    'padding': '12px',
                    'border-radius': '4px',
                    'background-color': '#0ff',
                    'border-width': 0,
                    'box-shadow': '0px 2px 6px 0px rgba(97,113,166,0.2)',
                    'text-align': 'center',
                    'font-size': '14px',
                    'color': '#F33'
                },
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createTextDataLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Create curve data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {string} options.lineColor Line color, optional, default '#ff0000'
     * @param {number} options.lineWidth Line width, optional, default 1
     * @param {number} options.lineOpacity Line opacity, optional, 0-1
     * @param {number} options.lineGapWidth Line gap width, optional, default 1, pixels
     * @param {number} options.lineOffset Line offset, optional. Positive=right/inward, negative=left/outward
     * @param {number} options.lineBlur Line blur, optional, must be less than line width
     * @param {[number,number]} options.lineDasharray Dash pattern, optional, e.g. [1,1]
     * @param {[number,number]} options.lineTranslate Translate [x,y], optional, negative=left/up, pixels
     * @param {string} options.lineCap Line cap: 'butt', 'round', 'square'
     * @param {string} options.lineJoin Line join: 'bevel', 'round', 'miter'
     * @param {string} options.linePattern Line pattern, optional, must match preloaded image, width power of 2
     * @param {string} options.lineGradient Line gradient, optional
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Randomly generate GeoJSON in map bounds, create curve data layer named layer8
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createCurveLayer(geojson, {
      * layerName: 'layer8'
      * });
      * console.log(layer);
     */
    async createCurveLayer(data, options)  {
            options = options || {};
            let type = "curve";
        
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                lineColor: '#00ffff',
                lineWidth: 3,
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createCurveLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Measure angle
     * @example
      * // Measure angle
      * await this.measureAngle();
     */
    measureAngle()  {
                vjcommon.runMeasureCmd(this.map, "measureAngle");
            }
    
    

    /**
     * Rename map
     * @param {string} oldMapID Old map ID
     * @param {string} newMapID New map ID
     * @returns {Promise<boolean>} Returns true on success, false on failure
     * @example
      * // Rename map ID from old_hello to new_hello
      * let ok = await this.renameMap("old_hello", "new_hello");
      * console.log(ok);
     */
    async renameMap(oldMapID, newMapID)  {
           let svc = this.map.getService();
           let res = await svc.cmdRenameMap(oldMapID, newMapID);
           return res && res.status;
       }
    

    /**
     * Overlay web map
     * @param {object} options Overlay config options
     * @param {string} [options.epsg] CAD EPSG code
     * @param {"beijing54"|"xian80"|"cgs2000"|""} [options.crs] CAD CRS: beijing54, xian80, cgs2000 or empty
     * @param {number} [options.webmaptype] Web map type: 1 Amap, 2 Amap imagery, 3 Tianditu, 4 Tianditu imagery
     * @returns {Promise<string>} Returns overlay layer name
     * @example
      * // Example 1
      * // Overlay web map, CAD CRS is cgs2000
      * let layerName = await this.overlayWebMap({
      * crs: "cgs2000"
      * });
      * console.log("layerName", layerName);
      * // Example 2
      * // Overlay Tianditu imagery, CAD CRS is xian80
      * let layerName2 = await this.overlayWebMap({
      * crs: "xian80",
      * webmaptype: 4
      * });
      * console.log("layerName", layerName2);
      * // Example 3
      * // Overlay web map
      * let layerName3 = await this.overlayWebMap();
      * console.log("layerName", layerName3);
     */
    async overlayWebMap(options)  {
            options = {...options}
            // Check if base is web map
            let isWebMap = this.map.getMapProjection() instanceof vjmap.LnglatProjection;
            if (isWebMap) {
                this.showMessage("Current map is already a web map, cannot overlay");
                return;
            }
            let epsg = options.epsg;
            if (!epsg) {
                epsg = await this.getCadEpsgCode(options);
            }
            let webmaptype = options.webmaptype ?? 1;
            let fp;
            if (!epsg) {
               // Four-parameter overlay, left is default map, right is web map
               fp = await this.context.ui.getFourParamter({}, {webmaptype: webmaptype});
               if (!fp) return;
                if (fp.webmaptype == 1 || fp.webmaptype == 2) {
                    // Amap
                    options.overMapType = "GCJ02";
                    if (fp.webmaptype == 2) {
                        // Imagery
                        options.overMapImage = true;
                    }
                } else if (fp.webmaptype == 3 || fp.webmaptype == 4) {
                    // Tianditu
                    options.overMapType = "WGS84";
                    if (fp.webmaptype == 4) {
                        // Imagery
                        options.overMapImage = true;
                    }
                }
                options.isSetRotateZero = !fp.allowsRotation;
                options.coordinates = [];
                for(let n = 0; n < fp.leftMarkers.length; n++) {
                    let leftMarker = fp.leftMarkers[n];
                    let rightMarker = fp.rightMarkers[n];
                    options.coordinates.push({
                        x1: rightMarker.x,
                        y1: rightMarker.y,
                        x2: leftMarker.x,
                        y2: leftMarker.y
                    });
                }
                options.overlayType = "param";
            } else {
                options.overlayType = "auto";
                if (webmaptype == 1 || webmaptype == 2) {
                    // Amap
                    options.overMapType = "GCJ02";
                    if (webmaptype == 2) {
                        // Imagery
                        options.overMapImage = true;
                    }
                } else if (webmaptype == 3 || webmaptype == 4) {
                    // Tianditu
                    options.overMapType = "WGS84";
                    if (webmaptype == 4) {
                        // Imagery
                        options.overMapImage = true;
                    }
                }
            }
            let wmsTileUrl;
            if (options.overlayType == "param") {
                // Four-parameter overlay
                let b = this.map.getMapExtent();
                if (options.overMapType == "WGS84") {
                    // Tianditu
                    let webMapTiles = vjcommon.tiandituProviderTiles(options.overMapImage);   
                    const tiles = webMapTiles.map(t => vjcommon.getTileShards(t)) || [];
                    wmsTileUrl = this.map.getService().webMapUrl({
                        tileCrs: "wgs84",
                        tileUrl: tiles?.map(t => t.tileUrl) ?? '',
                        tileShards: (tiles.length >= 0) ? tiles[0].tileShards : '',
                        tileToken: "",
                        tileSize: 256,
                        tileRetina: 1,
                        tileMaxZoom: 18,
                        tileFlipY: false,
                        // @ts-ignore
                        mapbounds: JSON.stringify([b.min.x,b.min.y,b.max.x,b.max.y]),
                        srs:  "EPSG:3857",
                        fourParameterBefore: fp.fourParamter
                    })
                } else {
                    // Amap
                    let webMapTiles = vjcommon.gaodeProviderTiles(options.overMapImage);
                    const tiles = webMapTiles.map(t => vjcommon.getTileShards(t)) || [];
                    wmsTileUrl = this.map.getService().webMapUrl({
                        tileCrs: "gcj02",
                        tileUrl: tiles?.map(t => t.tileUrl) ?? '',
                        tileShards: (tiles.length >= 0) ? tiles[0].tileShards : '',
                        tileToken: "",
                        tileSize: 256,
                        tileRetina: 1,
                        // @ts-ignore
                        tileMaxZoom: 18,
                        tileFlipY: false,
                        mapbounds: JSON.stringify([b.min.x,b.min.y,b.max.x,b.max.y]),
                        srs:  "EPSG:3857",
                        fourParameterBefore: fp.fourParamter
                    })
                }
            } else {
                // Auto overlay
                wmsTileUrl = await this.getWmsTileUrl({
                    epsg: epsg,
                    overMapType: options.overMapType,
                    overMapImage: options.overMapImage,
                    isSetRotateZero: options.isSetRotateZero,
                    coordinates: options.coordinates,
                    overlayType: options.overlayType
                });
            }
            
            if (!wmsTileUrl) return;
            // Create raster tile layer
            let layer = await this.createRasterLayer({
                tiles: wmsTileUrl,
            });
            // Move layer to bottom
            this.moveLayer(layer.layerName, {
                bottomMost: true
            });
            return layer
         }
    

    /**
     * Set CAD entity visibility
     * @param {Array<string>} objectids Entity ID array
     * @param {boolean} visible Visible or not, true=visible, false=hidden
     */
    async setCadEntityVisible(objectids, visible)  {
          if (!this.context.global.updateMapStyleObj) {
            this.context.global.updateMapStyleObj = vjcommon.createUpdateMapStyleObj(this.map);
          }
          const updateMapStyleObj = this.context.global.updateMapStyleObj;
          if (visible) {
            await updateMapStyleObj.removeHideObjectIds(new Set(objectids));
          } else {
            await updateMapStyleObj.addHideObjectIds(new Set(objectids));
          }
          // Add undo record
          this.context.ui.addUndoRecord(async () => {
              if (!visible) {
                await updateMapStyleObj.removeHideObjectIds(new Set(objectids));
              } else {
                await updateMapStyleObj.addHideObjectIds(new Set(objectids));
              }
          });
        }
    

    /**
     * Switch drawing workspace
     * @param {string} workspaceName Workspace name, optional, select workspace when empty
     * @example
      * // Switch drawing workspace
      * this.switchWorkspace();
     */
    async switchWorkspace(workspaceName)  {
            // Get default layer first
            let svc = this.map.getService();
            let oldWorkspaceName = svc.getCurWorkspaceName();
            if (!workspaceName) {
                let workspaces = await svc.getWorkspaces() || [];
                let workspaceNames = workspaces.map(wp => wp.name + (wp.alias ? "[" + wp.alias + "]" : ""));
                let oldWorkspace = workspaces.find(wp => wp.name === oldWorkspaceName);
                if (oldWorkspace) {
                    oldWorkspaceName = oldWorkspace.name + (oldWorkspace.alias ? "[" + oldWorkspace.alias + "]" : "");
                }
                workspaceNames.unshift("[Default Workspace]");
                workspaceName = await this.selectOptionsValue(workspaceNames, {
                    isMultiSelect: false,
                    title: "Select Workspace",
                    initValue: oldWorkspaceName || "[Default Workspace]"
                });
                if (!workspaceName) {
                    return;
                }
                if (workspaceName) {
                    // Take the name part before bracket
                    workspaceName = workspaceName.split("[")[0];
                }
            }
            
            svc.switchWorkspace(workspaceName);
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                svc.switchWorkspace(oldWorkspaceName);
            });
        }
    

    /**
     * Transform coordinates
     * @param {string} srs Source CRS name, e.g. EPSG:4326
     * @param {string} crs Target CRS name, e.g. EPSG:3857
     * @param {[number, number] | [number, number][]} points Coordinates to transform
     * @param {string | string[]} [fourParameter] Four parameters (x offset, y offset, scale, rotation rad), optional, final correction
     * @param {boolean} [isInverseFourParamter] Whether inverse of above four-parameter transform
     * @returns {Promise<[number, number] | [number, number][]>} Returns transformed coordinates
     * @example
      * // Transform coordinates [120.0, 30.0] from EPSG:4326 to EPSG:3857
      * let transformedPoints = await this.coordTransform("EPSG:4326", "EPSG:3857", [120.0, 30.0]);
      * console.log(transformedPoints);
     */
    async coordTransform(srs, crs, points, fourParameter, isInverseFourParamter)  {
           let svc = this.map.getService();
           let isOnePoint = false;
           if (Array.isArray(points)) {
            if (points.length > 0 && !Array.isArray(points[0])) {
                isOnePoint = true;
                points = vjmap.geoPoint(points);
            } else {
                points = points.map((point) => vjmap.geoPoint(point));
            }
           }
           let result = await svc.cmdTransform(srs, crs, points, fourParameter, isInverseFourParamter);
           if (result && result.error) {
               if (this.map.logInfo) this.map.logInfo(result.error, "error", 10000);
               return;
           }
           if (isOnePoint) {
            return result[0];
           }
           return result;
       }
    

    /**
     * Get all drawn features
     * @returns {<GeoJSON | undefined>} Returns selected GeoJSON object
     * @example
      * // Get feature IDs of all drawn features
      * const geojson = this.getDrawAll();
      * if (!geojson) return;
      * let ids = [];
      * for(let i = 0; i < geojson.features.length; i++) {
      * let feature = geojson.features[i];
      * ids.push(feature.id);
      * }
      * console.log(ids);
     */
    getDrawAll()  {
            let draw = this.map.getDrawLayer();
            return this.map.fromLngLat(draw.getAll());
        }
    
    

    /**
     * Randomly generate GeoJSON data within map bounds
     * @param {Record<string, any>} options Parameters, optional
     * @param {number} boundsType Bounds type (0 draw, 1 actual, 2 full), default 0, optional
     * @param {string} options.type Type: 'point', 'line', 'polygon', 'all', default 'all'
     * @param {number} options.pointCount Max point count, default 10
     * @param {number} options.lineCount Max line count, default 10
     * @param {number} options.polygonCount Max polygon count, default 10
     * @param {number} options.linePointCount Max points per line, default 3
     * @param {number} options.polygonPointCount Max points per polygon, default 3
     * @returns {Object} Returns GeoJSON with features, each has geometry and properties including index
     * @example
      * // Randomly generate GeoJSON in map bounds
      * let geojson = this.randomGeojsonInMapBounds();
      * console.log(geojson);
      * // Randomly generate GeoJSON, type point, max 50 points
      * let ptGeoJson = this.randomGeojsonInMapBounds({
      * type: 'point',
      * pointCount: 50
      * });
      * console.log(ptGeoJson);
     */
    randomGeojsonInMapBounds(options)  {
            options = options || {};
            options.type = options.type || 'all';
            let rect = this.getMapGeoBounds(options.boundsType || 0);
            let geoBounds = vjmap.GeoBounds.fromArray(rect);
            let FeatureCollection = {
                type: "FeatureCollection",
                features: []
            };
        
            if (options.type == 'point' || options.type == 'all') {
                let coll = geoBounds.randomGeoJsonPointCollection(Math.max(options.pointCount || 10, 2));
                if (options.pointCount == 1) {
                    // @ts-ignore
                    FeatureCollection.features.push(coll.features[0]);
                } else {
                    // @ts-ignore
                    FeatureCollection.features.push(...coll.features);
                }
        
            }
            if (options.type == 'line' || options.type == 'all') {
                let coll = geoBounds.randomGeoJsonLineCollection(options.lineCount || 10, options.linePointCount || 3);
                // @ts-ignore
                FeatureCollection.features.push(...coll.features);
            }
            if (options.type == 'polygon' || options.type == 'all') {
                let coll = geoBounds.randomGeoJsonPolygonCollection(options.polygonCount || 10, options.polygonPointCount || 3);
                // @ts-ignore
                FeatureCollection.features.push(...coll.features);
            }
            for(let i = 0; i < FeatureCollection.features.length; i++) {
                let feature = FeatureCollection.features[i];
                feature.id = i;
                feature.properties = {
                    index: i
                }
            }
            return FeatureCollection;
        }
    

    /**
     * Measure distance
     * @example
      * // Measure distance
      * await this.measureDistance();
     */
    measureDistance()  {
                vjcommon.runMeasureCmd(this.map, "measureDist");
            }
    
    

    /**
     * Create animate symbol data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @param {string} options.animateImagesType Animation type: "arrow", "antpath", "customImage", "imageSprites"
     * @param {string} options.animateImages_arrowFillColor Arrow fill color when animateImagesType is "arrow", default "#22B14C"
     * @param {string} options.animateImages_arrowStrokeColor Arrow stroke color when animateImagesType is "arrow", default "#fff"
     * @param {number} options.animateImages_arrowStrokeWidth Arrow stroke width when animateImagesType is "arrow"
     * @param {number} options.animateImages_arrowWidth Arrow size when animateImagesType is "arrow"
     * @param {string} options.animateImages_fillColor1 Antpath fill color 1 when animateImagesType is "antpath"
     * @param {string} options.animateImages_fillColor2 Antpath fill color 2 when animateImagesType is "antpath"
     * @param {string} options.customImageUrl Custom image URL or base64 when animateImagesType is "customImage"
     * @param {string} options.imageSpriteUrl Image sprite URL or base64 when animateImagesType is "imageSprites"
     * @param {number} options.animateImages_spriteWidth Sprite width in source image when animateImagesType is "imageSprites"
     * @param {number} options.animateImages_spriteHeight Sprite height in source image when animateImagesType is "imageSprites"
     * @param {number} options.animateImages_canvasWidth Canvas width, common for all animate types
     * @param {number} options.animateImages_canvasHeight Canvas height, common for all animate types
     * @param {number} options.animateImages_frameCount Frame count, common for all animate types
     * @param {boolean} options.animateImages_directionReverse Reverse animation direction, common for all animate types
     * @param {number} options.speed Animation speed, step 0.1
     * @param {boolean} options.startAutoAnimation Auto-play on start
     * @param {string} options.symbolPlacement Symbol placement: "point", "line", "line-center"
     * @param {number} options.symbolSpacing Distance between symbol centers in pixels, default 250
     * @param {boolean} options.symbolAvoidEdges Avoid symbol collision, default false
     * @param {string} options.symbolZOrder Symbol z-order: "auto", "viewport-y", "source"
     * @param {string} options.iconImage Icon name for image background
     * @param {number} options.iconRotate Icon rotation angle, default 0
     * @param {[number, number]} options.iconOffset Icon offset from anchor, default [0,0]
     * @param {string} options.iconAnchor Icon anchor: "center", "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right"
     * @param {string} options.iconRotationAlignment Icon rotation alignment: "map", "viewport", "auto"
     * @param {string} options.iconPitchAlignment Icon direction when map tilted: "map", "viewport", "auto"
     * @param {boolean} options.iconAllowOverlap Allow icon overlap, default false
     * @param {number} options.iconOpacity Icon opacity 0-1, default 1
     * @param {boolean} options.iconIgnorePlacement Ignore icon collision, default false
     * @param {boolean} options.iconOptional Icon overlap option, default false
     * @param {number} options.iconSize Icon scale, default 1
     * @param {string} options.iconTextFit Icon text fit: "none", "width", "height", "both"
     * @param {[number,number,number,number]} options.iconTextFitPadding Icon text fit padding, default [0,0,0,0]
     * @param {number} options.iconPadding Icon padding, default 1
     * @param {boolean} options.iconKeepUpright Keep icon upright, default false
     * @param {string} options.iconColor SDF icon color, default"#000000"
     * @param {string} options.iconHaloColor SDF icon halo color, default "rgba(0,0,0,0)"
     * @param {number} options.iconHaloWidth Halo distance to icon outline, default 0
     * @param {number} options.iconHaloBlur Icon halo blur width, default 0
     * @param {[number,number]} options.iconTranslate Icon translate, default [0,0]
     * @param {string} options.iconTranslateAnchor Icon translate anchor: "map", "viewport"
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Randomly generate GeoJSON in map bounds, create animate symbol layer named layer4
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createAnimateSymbolLayer(geojson, {
      * layerName: 'layer4'
      * });
      * console.log(layer);
     */
    async createAnimateSymbolLayer(data, options)  {
            options = options || {};
            let type = "animateSymbol";
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                animateImagesType: "arrow",
                animateImages_arrowFillColor: "#22B14C",
                animateImages_arrowStrokeColor: "#fff",
                animateImages_arrowStrokeWidth: 6,
                animateImages_canvasWidth: 128,
                animateImages_canvasHeight: 32,
                animateImages_arrowWidth: 16,
                animateImages_frameCount: 4,
                animateImages_fillColor1: "#f0fb",
                animateImages_fillColor2: "#0ffb",
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createAnimateSymbolLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Create info window (popup) data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {string} options.text Text to display, optional, default "index ${props.index}"
     * @param {number} options.minZoom Min zoom to display, optional, default 0
     * @param {number} options.maxZoom Max zoom to display, optional, default 24
     * @param {boolean} options.isDrawLinePoint Include line points when drawing, optional, default false
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @param {string} options.html Popup HTML content, supports props variables
     * @param {[number, number]} options.offset Offset [x,y] in pixels from element center, negative=left/up, default [0,0]
     * @param {string} options.anchor Popup anchor: 'center', 'top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right', default 'center'
     * @param {boolean} options.closeButton Show close button, optional
     * @param {boolean} options.closeOnClick Close popup on map click, optional
     * @param {boolean} options.closeOnMove Close popup on map move, optional
     * @param {boolean} options.focusAfterOpen Focus first focusable element in popup, optional
     * @param {string} options.className Space-separated CSS classes for popup container, optional
     * @param {string} options.maxWidth Popup max width CSS string e.g. '300px', 'none' for auto, default '240px'
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Randomly generate GeoJSON in map bounds, create info window layer named layer22
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createPopupDataLayer(geojson, {
      * layerName: 'layer22'
      * });
      * console.log(layer);
     */
    async createPopupDataLayer(data, options)  {
            options = options || {};
            let type = "popup";
        
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                color: '#3FB1CE',
                html: "<span style='color:#0000ff'>index:${props.index}</span>",
                closeButton: true,
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createPopupDataLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Add mouse position control
     * @param {number} options.digits Decimal places, optional
     * @param {boolean} options.trackCenter Track center position, optional
     * @param {boolean} options.showLatLng Show lat/lng, optional
     * @param {boolean} options.showZoom Show zoom level, optional
     * @param {boolean} options.showBearing Show bearing, optional
     * @param {boolean} options.showPitch Show pitch, optional
     * @param {'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'} options.position Control position, optional, default 'top-right'
     * @example
      * // Add mouse position control at bottom-right
      * this.addMousePositionControl({ position: 'bottom-right' });
     */
    addMousePositionControl(options)  {
            this.context.global.controls = this.context.global.controls || {};
            if (this.context.global.controls.mousePositionControl) {
                this.map.removeControl(this.context.global.controls.mousePositionControl.control);
            }
            let mousePositionControl = new vjmap.MousePositionControl(options);
            this.map.addControl(mousePositionControl, options.position);
            this.context.global.controls.mousePositionControl = {
                control: mousePositionControl,
                position: options.position,
                options: options
            };
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.removeControl(mousePositionControl);
                delete this.context.global.controls.mousePositionControl;
            }); 
        }
    

    /**
     * Show list selection dialog
     * @param {Array<string>} values Option list content, required
     * @param {Object} options List config, optional
     * @param {string|string[]} options.initValue Initial value, optional, default empty
     * @param {boolean} options.isMultiSelect Multi-select, optional, default false
     * @param {string} options.title Dialog title, optional
     * @param {Function} options.applyCallback Callback when Apply clicked, optional
     * @returns {string|string[]|undefined} Returns selected value(s), undefined if Cancel
     * @example
      * // Example 1
      * // Show list selection, options ["a","b","c"]
      * let res = await this.selectOptionsValue(["a","b","c"]);
      * if (res) {
      * // Single select returns string
      * console.log("Selected string value:", res)
      * } else {
      * console.log("Clicked Cancel")
      * }
      * // Example 2
      * // Show list selection, options ["a","b","c","d"], multi-select, title "Select", init ["a","b"]
      * let arr = await this.selectOptionsValue(["a","b","c","d"], {
      * isMultiSelect: true,
      * title: "Select",
      * initValue: ["a","b"]
      * });
      * if (arr) {
      * // Multi-select returns array
      * console.log("Selected array value:", arr)
      * } else {
      * console.log("Clicked Cancel")
      * }
     */
    selectOptionsValue(values, options)  {
            options = options || {};
            return this.context.ui.selectOptionsValue(values, {
                isMultiSelect: options.isMultiSelect || false,
                initValue: options.initValue || [],
                title: options.title || "Select",
                applyCallback: options.applyCallback || undefined
            });   
         }
    

    /**
     * Manually draw a point symbol
     * @param {object} options Config params, optional
     * @param {boolean} options.isSnap Enable snap, optional, default false
     * @param {string} options.icon Symbol icon name, default _markerIcon_, optional
     * @param {number} options.iconSize Symbol icon scale, default 1, optional
     * @param {number} options.iconRotate Symbol icon rotation angle, default 0, optional
     * @param {string} options.text Symbol text content, default empty, optional
     * @param {string} options.textColor Symbol text color, default empty, optional
     * @param {number} options.textSize Symbol text size, default 22, optional
     * @param {number} options.textRotate Symbol text rotation angle, default 0, optional
     * @param {number} options.textOpacity Symbol text opacity, default 0, optional
     * @param {number} options.data Data value, optional
     * @returns {Promise<Object | undefined>} Drawn entity or undefined if cancelled. Object includes:
     * @example
      * // Example 1
      * // Manually draw a point symbol
      * const pt = await this.drawSymbol(); // Enable snap to draw a point symbol
      * if (!pt) return;
      * // Point coordinates
      * let point = pt.point;
      * const x = point[0];
      * const y = point[1];
      * console.log(x, y);
      * // Feature ID
      * let id = pt.id;
      * console.log(id);
      * // Example 2
      * // Manually draw point symbol with icon _pulsingDot_
      * const pt2 = await this.drawSymbol({
      * icon: "_pulsingDot_"
      * });
      * console.log(pt2);
      * // Example 3
      * // Manually draw point symbol, icon empty, text "Hello", text rotation 45
      * const pt3 = await this.drawSymbol({
      * text: "Hello",
      * textRotate: 45
      * });
      * console.log(pt3);
     */
    async drawSymbol(options)  {
            options = options || {};
            let point = await this.actionDrawPoint(options.isSnap);
            if (!point) return;
            return this.createSymbol(point, options)
        }
    

    /**
     * Manually draw an oblique rectangle
     * @param {object} options Config params, optional
     * @param {boolean} options.isSnap Enable snap, optional, default false
     * @param {string} options.color Color #RRGGBB, optional
     * @param {boolean} options.isFill Fill or not, default false, optional
     * @param {number} options.opacity Opacity, default 1, optional
     * @param {number} options.data Data value, optional
     * @returns {Promise<Object | undefined>} Drawn entity or undefined if cancelled. Object includes:
     * @example
      * // Manually draw an oblique rectangle
      * const rect = await this.drawSlantRectangle({
      * isSnap: true,
      * color: "#FF0000",
      * isFill: true,
      * opacity: 0.8
      * });
      * if (!rect) return;
      * console.log("Oblique rectangle ID:", rect.id);
      * console.log("Oblique rectangle point coordinates:", rect.points);
     */
    async drawSlantRectangle(options)  {
            let points = await this.actionDrawSlantRectangle(options.isSnap);
            if (!points) return;
            return this.createPolygon(points, options);
        }
    

    /**
     * Add minimap (overview) control
     * @param {'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'} options.position Control position, optional, default 'top-right'
     * @example
      * // Add minimap control at bottom-left
      * this.addMiniMapControl({ position: 'bottom-left' });
     */
    addMiniMapControl(options)  {
            this.context.global.controls = this.context.global.controls || {};
            if (this.context.global.controls.miniMapControl) {
                this.map.removeControl(this.context.global.controls.miniMapControl.control);
            }
            let transformOrigin = "100% 0%";
            if (options.position === 'top-left') {
                transformOrigin = "0% 0%";
            } else if (options.position === 'bottom-right') {
                transformOrigin = "100% 100%";
            } else if (options.position === 'bottom-left') {
                transformOrigin = "0% 100%";
            }
            let miniMapControl = new vjmap.MiniMapControl({
                containerStyle: {
                    background: "#31346a85",
                    transform: "scale(0.6)",
                    transformOrigin: transformOrigin
                },
                ...options
            });
            this.map.addControl(miniMapControl, options.position);
            this.context.global.controls.miniMapControl = {
                control: miniMapControl,
                position: options.position,
                options: options
            };
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.removeControl(miniMapControl);
                delete this.context.global.controls.miniMapControl;
            }); 
        }
    

    /**
     * Set map rotation angle
     * @param {number} bearing Rotation angle to set
     * @example
      * this.setBearing(45); // Set rotation to 45 degrees
     */
    setBearing(bearing)  {
            let oldBearing = this.map.getBearing(); // Get current rotation
            this.map.setBearing(bearing); // Set new rotation
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.setBearing(oldBearing);
            });
        }
    

    /**
     * Draw a point symbol at given coordinates
     * @param {[number, number]} point Point coordinates [x, y], required
     * @param {object} options Config params, optional
     * @param {boolean} options.isSnap Enable snap, optional, default false
     * @param {string} options.color Color #RRGGBB, optional
     * @param {number} options.size Point size, optional
     * @param {number} options.data Data value, optional
     * @returns {Promise<Object | undefined>} Drawn entity or undefined if cancelled. Object includes:
     * @example
      * // Example 1
      * // Draw a point at [10, 20]
      * const pt = this.createPoint([10, 20]); // Enable snap to draw point at coordinates
      * if (!pt) return;
      * // Point coordinates
      * let point = pt.point;
      * const x = point[0];
      * const y = point[1];
      * console.log(x, y);
      * // Feature ID
      * let id = pt.id;
      * console.log(id);
      * // Example 2
      * // Randomly generate 1 point in map bounds, draw point there with color red, size 10, data 123
      * let points = this.randomPointInMapBounds(1);
      * const pt2 = this.createPoint(points[0], {
      * color: "#FF0000",
      * size: 10,
      * data: 123
      * });
      * console.log(pt2);
     */
    createPoint(point, options)  {
            options = options || {};
            let draw = this.map.getDrawLayer();
            let id = vjmap.RandomID(10)
            draw.add({
                id,
                type: 'Feature',
                geometry: {
                    type: 'Point',
                    coordinates: this.map.toLngLat(point)
                },
                properties: {
                    "color_static": options.color,
                    "radius_static": options.size,
                    "data": options.data
                }
            });
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.getDrawLayer().delete(id);
            });
            return {
                id,
                point
            };
        }
    

    /**
     * Set popup properties
     * @param {string} id Popup id
     * @param {Record<string, any>} options Other params, optional
     * @param {[number, number]} options.position Coordinates [x, y], optional, default undefined
     * @param {any} options.content Popup content, optional, empty means no popup
     * @param {boolean} options.showInForm Show content as form, optional, default false
     * @param {number} options.elevation Elevation value, optional, default 0
     * @param {boolean} options.visible Visible or not, optional, default true
     * @param {any} options.data Data content, optional, default empty
     * @example
      * // Set popup popup_1: position [10, 20], content {name: "Sensor", value: 100}, show as form
      * this.setPopupProperty("popup_1", {
      * position: [10, 20],
      * content: {name: "Sensor", value: 100},
      * showInForm: true
      * });
     */
    setPopupProperty(id, options)  {
            let popups = this.context.global.popups || [];
            let item = popups.find(m => m.id == id);
            if (!item) return;
            let popup = item.popup;
            let oldOptions = {...item.options, ...options};
            if (options.position) {
                popup.setLngLat(this.map.toLngLat(options.position));
            }
            if (options.data !== undefined) {
                popup.data = options.data;
            }
            if (options.content !== undefined || options.showInForm !== undefined) {
                const dom = this.context.ui.createElementByJsonData(options.content || oldOptions.content, options.showInForm || oldOptions.showInForm)
                popup.setDOMContent(dom.element)
            }
    
            if (options.elevation !== undefined) {
                popup.setHeight(options.elevation);
            }
           
            if (options.data !== undefined) {
                popup.data = options.data;
            }
            if (options.visible !== undefined) {
                if (options.visible) {
                    if (!popup._map) {
                        popup.addTo(this.map);
                    }
                    
                } else {
                    if (popup._map) {
                        popup.remove();
                    }
                }
            }
            
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.setPopupProperty(id, oldOptions);
            });
            
         }
    

    /**
     * Clear all selected entities
     * @example
      * // Clear all selected entities
      * this.clearSelectedFeatures();
     */
    clearSelectedFeatures()  {
           vjcommon.clearHighlight(this.map);
        }
    
    

    /**
     * Set data layer click to show popup
     * @param {string} layerName Layer name, required
     * @param {Record<string, any>} options Other params, optional
     * @param {any} options.popupContent Popup content, default current data properties
     * @param {boolean} options.popupShowInForm Show popup as form, optional, default false
     * @param {[number, number]} options.popupOffset Popup offset, optional, default [0, -40]
     * @param {number} options.popupWidth Popup width, optional
     * @param {number} options.popupHeight Popup height, optional
     * @param {boolean} options.noShowPopup Do not show popup, optional, default false
     * @param {Function} options.clickCallback Click callback, optional, default null (first param is popup content)
     * @example
      * // Example 1
      * // Set layer layer1 click to show popup as form
      * this.setDataLayerClickPopupContent('layer1', {
      * popupShowInForm: true
      * });
      * // Example 2
      * // Set layer layer2 click to show popup, content "ID:${props.index}"
      * this.setDataLayerClickPopupContent('layer2', {
      * popupContent: "ID:${props.index}",
      * });
     */
    setDataLayerClickPopupContent(layerName, options)  {
            let item = this.context.global.layers.find(layer => layer.layerName == layerName);
            if(!item) return;
            options = options || {};
            options.isHoverPointer = options.isHoverPointer === undefined ? true : options.isHoverPointer;
            let markers = item.layer.markers;
            if (Array.isArray(markers)) {
                // If Marker-related layer
                for(let m = 0; m < markers.length; m++) {
                    let marker = markers[m];
                
                    marker.on('click', () => {
                        let popup = this.context.global.popup;
                        if (!popup && options.noShowPopup !== true) {
                            popup = new vjmap.Popup({
                                offset: options.popupOffset || [0, -40],
                                closeOnClick: false, 
                                closeButton: true, 
                                anchor: "bottom" 
                            })
                            this.context.global.popup = popup;
                        }
                        if (options.noShowPopup !== true && options.popupOffset) {
                            popup.setOffset(options.popupOffset);
                        }
                        let content = options.popupContent;
                        if (item.data && item.data.features && item.data.features[m] && item.data.features[m].properties && typeof content == "string" && !(content in item.data.features[m].properties)) {
                            content = item.layer.evalValue("return `" + content + "`", item.data.features[m].properties, this.map);
                            if (options.noShowPopup !== true) {
                                popup.setHTML(content);
                            }
                            if (options.clickCallback) {
                                options.clickCallback(content);
                            }
                        } else {
                            if (!content) {
                                content = item.data.features[m].properties;
                            } else {
                                // If content is object property, get property value
                                if (item.data.features[m].properties && (content in item.data.features[m].properties)) {
                                    content = item.data.features[m].properties[content];
                                    try {
                                        // If content is JSON string, try to parse
                                        content = JSON.parse(content);
                                    } catch (e) {}
                                }
                            }
                            if (options.noShowPopup !== true) {
                                const dom = this.context.ui.createElementByJsonData(content, options.popupShowInForm, {
                                    width: options.popupWidth,
                                    height: options.popupHeight
                                })
                                popup.setDOMContent(dom.element)
                            }
                            if (options.clickCallback) {
                                options.clickCallback(content);
                            }
    
                        }
                        if (options.noShowPopup !== true) {
                            popup.setLngLat(marker.getLngLat());
                            popup.addTo(this.map);
                        }
                    });
                   
                }
            } else {
                // If other layer type
                let layerId = item.layerId;
                if (item.layer && typeof(item.layer.getLayerId) == "function") {
                    layerId = item.layer.getLayerId();
                }
                this.map.on("click", layerId, (ev) => {
                    if (!ev || !ev.features || !ev.features[0]) return;
                   
                    let pt = ev.lngLat;
                    let popup = this.context.global.popup;
                    if (!popup && options.noShowPopup !== true) {
                        popup = new vjmap.Popup({
                            offset: options.popupOffset || [0, -40]
                        })
                        this.context.global.popup = popup;
                    }
                    if (options.noShowPopup !== true && options.popupOffset) {
                        popup.setOffset(options.popupOffset);
                    }
                    let content = options.popupContent;
                    if (ev.features[0].properties && typeof content == "string" && !(content in ev.features[0].properties)) {
                        content = item.layer.evalValue("return `" + content + "`", ev.features[0].properties, this.map);
                        if (options.noShowPopup !== true) {
                            popup.setHTML(content);
                        }
                        if (options.clickCallback) {
                            options.clickCallback(content);
                        }
                    } else {
                        ev.features[0].properties = ev.features[0].properties || {}
                        if (!content) {
                            content = ev.features[0].properties;
                        } else {
                                // If content is object property, get property value
                            if (content in ev.features[0].properties) {
                                content = ev.features[0].properties[content];
                                try {
                                    // If content is JSON string, try to parse
                                    content = JSON.parse(content);
                                } catch (e) {}
                            }
                        }
                        if (options.noShowPopup !== true) {
                            const dom = this.context.ui.createElementByJsonData(content, options.popupShowInForm, {
                                width: options.popupWidth,
                                height: options.popupHeight
                            })
                            popup.setDOMContent(dom.element)
                        }
                        if (options.clickCallback) {
                            options.clickCallback(content);
                        }
                    }
                    if (options.noShowPopup !== true) {
                        popup.setLngLat(pt);
                        popup.addTo(this.map);
                    }
                });
            }
        }
    

    /**
     * Get four parameters by manually specifying common control points
     * @param {Object} [leftMapOptions] Left map options, optional, default current map
     * @param {string} [leftMapOptions.mapid] Map ID
     * @param {string} [leftMapOptions.version] Map version, uses current when empty
     * @param {Object} [rightMapOptions] Right map options, optional, Amap
     * @param {string} [rightMapOptions.mapid] Map ID
     * @param {string} [rightMapOptions.version] Map version, uses current when empty
     * @param {number} [leftMapOptions.webmaptype] Map type: 1 Amap, 2 Amap imagery, 3 Tianditu, 4 Tianditu imagery
     * @returns {Promise<Object>} Returns four-parameter object
     * @example
      * // Example 1
      * // Get four parameters for current map and Amap via common control points
      * let fp = await this.getFourParamter();
      * if (fp) {
      * console.log("Four parameters", fp.fourParamter);
      * }
      * // Example 2
      * // Get four parameters for current map and CAD map sys_world via common control points
      * let fp2 = await this.getFourParamter({}, {
      * mapid: "sys_world"
      * });
      * if (fp2) {
      * console.log("Four parameters", fp2.fourParamter);
      * }
     */
    async getFourParamter(leftMapOptions, rightMapOptions)  {
            return await this.context.ui.getFourParamter(leftMapOptions, rightMapOptions);
          }
    

    /**
     * Switch to web map as base
     * @param {number} webmaptype Map type: 1 Amap, 2 Amap imagery, 3 Tianditu, 4 Tianditu imagery, optional, default 1
     * @returns {Promise<Object>} Returns result object
     * @example
      * // Example 1
      * // Switch to Amap as base
      * let result = await this.switchWebMap(1);
      * console.log("result", result);
      * // Example 2
      * // Switch to Tianditu imagery as base
      * let result2 = await this.switchWebMap(4);
      * console.log("result2", result2);
     */
    switchWebMap(webmaptype)  {
            if (!webmaptype) webmaptype = 1;
            this.clearMapData({clearAllData: true}); 
            let prj = new vjmap.LnglatProjection();
            let webMapTiles = [];
            if (webmaptype == 1 || webmaptype == 2) {
                webMapTiles = vjcommon.gaodeProviderTiles(webmaptype == 2);
            } else if (webmaptype == 3 || webmaptype == 4) {
                webMapTiles = vjcommon.tiandituProviderTiles(webmaptype == 4);
            }
            let tiles = webMapTiles.map(t => vjcommon.getShardsTileUrl(t, this.map))
            let sourceIds = [];
            let layerIds = [];
            for(let t of tiles) {
                let id = vjmap.RandomID();
                let sourceId = "web_source_" + webmaptype + "_" + id;
                let layerId = "web_layer_" + webmaptype + "_" + id;
                this.map.addRasterSource(sourceId, {
                    type: "raster",
                    tiles: t,
                    tileSize: 256
                });
                this.map.addRasterLayer(layerId, sourceId);
                this.map.setMapProjection(prj);
                sourceIds.push(sourceId);
                layerIds.push(layerId);
            }
            
            return {
                sourceIds: sourceIds,
                layerIds: layerIds
            }
          }
    

    /**
     * Get data layer property
     * @param {string} layerName Layer name, required
     * @returns {object | undefined} Returns layer property object, undefined if not found
     * @example
      * // Get data layer property for layer1
      * let layerName = 'layer1';
      * let property = this.getDataLayerProperty(layerName);
      * console.log("Layer property:", property);
     */
    getDataLayerProperty(layerName)  {
            let item = this.context.global.layers.find(layer => layer.layerName == layerName);
            if(item) {
               return item.layer.mapLayer
            }
        }
    

    /**
     * Get map thumbnail
     * @param {object} options Parameters, optional
     * @param {string} [options.mapid] Map ID, optional, current map when empty
     * @param {string} [options.version] Version, optional, current version when empty
     * @param {number} [options.width] Thumbnail width, optional
     * @param {number} [options.height] Thumbnail height, optional
     * @param {boolean} [options.darkTheme] Dark theme or not, optional
     * @returns {string} Returns thumbnail URL
     * @example
      * // Example 1
      * // Get thumbnail for map sys_hello
      * let url1 = this.thumbnailUrl({
      * mapid: "sys_hello"
      * });
      * console.log(url1);
      * // Example 2
      * // Get thumbnail for map sys_hello, version v1, 200x200, dark theme
      * let url2 = this.thumbnailUrl({
      * mapid: "sys_hello",
      * version: "v1",
      * width: 200,
      * height: 200,
      * darkTheme: true
      * });
      * console.log(url2);
     */
    thumbnailUrl(options)  {
           let svc = this.map.getService();
           return svc.thumbnailUrl(options.mapid, options.version, options.width, options.height, options.darkTheme);
       }
    

    /**
     * Create sky layer
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {string} options.skyType Sky type: 'gradient' or 'atmosphere', default 'atmosphere'
     * @param {string[]} options.skyGradient Gradient colors, when skyType is 'gradient'
     * @param {[number, number]} options.skyGradientCenter Gradient center [lng, lat]
     * @param {number} options.skyGradientRadius Gradient radius in degrees, 0-180, default 90
     * @param {number} options.skyOpacity Sky opacity 0-1, default 1
     * @param {string} options.skyAtmosphereColor Atmosphere color for scattering, default white
     * @param {string} options.skyAtmosphereHaloColor Atmosphere halo color, default white
     * @param {number} options.skyAtmosphereSunIntensity Sun intensity 0-100, default 10
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Create sky layer
      * let layer = await this.createSkyLayer();
      * console.log(layer);
     */
    async createSkyLayer(options)  {
            options = options || {};
            let type = "sky";
    
            let randomId = vjmap.RandomID();
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                skyType: 'gradient',
                skyGradient: [
                    'interpolate',
                    ['linear'],
                    ['sky-radial-progress'],
                    0.8,
                    'rgba(135, 206, 235, 1.0)',
                    1,
                    'rgba(0,0,0,0.1)'
                ],
                skyGradientCenter: [0, 0],
                skyGradientRadius: 90,
                skyOpacity: [
                    'interpolate',
                    ['exponential', 0.1],
                    ['zoom'],
                    0,
                    0.3,
                    22,
                    1
                ],
                ...options,
                type: type
            }
           
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId: undefined, layerName, data: undefined, options, layer, visible: true, create: this.createSkyLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId: undefined,
                layerId,
                layerName
            };
        }
    

    /**
     * Select drawn features
     * @param {boolean} selectSingle Single select only
     * @returns {Promise<GeoJSON | undefined>} Returns selected GeoJSON, undefined if cancelled
     * @example
      * const geojson = await this.actionSelect(true);
      * if (!geojson) return;
      * // Iterate each feature in geojson
      * for(let i = 0; i < geojson.features.length; i++) {
      * let feature = geojson.features[i];
      * console.log(feature);
      * }
     */
    async actionSelect(selectSingle)  {
            this.context.tools.logInfo("Please select drawn features")
            let draw = this.map.getDrawLayer();
            let selection = await vjmap.Draw.actionSelect(this.map, draw, {
                selectSingle
            });
            if (selection.cancel) {
                return;
            }
            return this.map.fromLngLat(selection);
        }
    

    /**
     * Delete a popup
     * @param {string} id Popup id
     * @example
      * this.deletePopup("popup_1"); // Delete popup popup_1
     */
    deletePopup(id)  {
           let popups = this.context.global.popups || [];
           let item = popups.find(m => m.id == id);
           if (item) {
            item.popup.remove()
            let pt = this.map.fromLngLat(item.popup.getLngLat());
             // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.addPopup(pt, item.options);
            });
           }
          
        }
    

    /**
     * Show data info dialog from JSON
     * @param {object|Array} json JSON data, object or array, required
     * @param {boolean} showForm Show as form, optional, default false
     * @param {string} title Dialog title, optional
     * @example
      * // Example 1
      * // Get map center X,Y and zoom, create JSON, show data info dialog, title "Map Info", as form
      * let center = this.getCenter();
      * const mapInfo = {
      * centerX: center[0],
      * centerY: center[1],
      * zoom: map.getZoom()
      * }
      * await this.showDataInfoDialog(mapInfo, true, "Map Info");
      * // Example 2
      * // Rect select entities, show data info dialog with selected data, title "Entity Info", clear selection on close
      * const geojson1 = await this.selectFeatures({
      * isPointSel: true
      * });
      * await this.showFormInputDialog(geojson1, false, "Entity Info");
      * // Clear all selected entities
      * this.clearSelectedFeatures();
     */
    async showDataInfoDialog(json, showForm, title)  {
            return await this.context.ui.showInfoDlg(json, showForm, title)
        }
    

    /**
     * Create diffused point data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {string} options.text Text to display, optional, default "index ${props.index}"
     * @param {number} options.minZoom Min zoom to display, optional, default 0
     * @param {number} options.maxZoom Max zoom to display, optional, default 24
     * @param {boolean} options.isDrawLinePoint Include line points when drawing, optional, default false
     * @param {number} options.width Pixel width, optional, default 24
     * @param {number} options.markerHeight Pixel height, optional
     * @param {string} options.color1 Color value 1, optional
     * @param {string} options.color2 Color value 2, optional
     * @param {number} options.textFontSize Font size, optional
     * @param {string} options.textColor Text color, optional
     * @param {boolean} options.draggable Draggable, optional, default false
     * @param {string} options.animationType Animation type, optional: MAP_ANIMATION_NONE, MAP_ANIMATION_BOUNCE, MAP_ANIMATION_DROP
     * @param {number} options.rotation Rotation angle, optional
     * @param {string} options.rotationAlignment Rotation alignment, optional: auto, map, viewport
     * @param {string} options.pitchAlignment Pitch alignment, optional: auto, map, viewport
     * @param {number} options.height Elevation value, optional
     * @param {number} options.scaleMaxZoom Max zoom for scaling, optional
     * @param {boolean} options.removeWhenNoInMapView Auto show/hide in view, optional
     * @param {number} options.removeWhenNoInMapViewPadding Padding for auto show/hide, optional, default 500
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Randomly generate GeoJSON in map bounds, create diffused point layer named layer9
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createDiffusedMarkerLayer(geojson, {
      * layerName: 'layer9'
      * });
      * console.log(layer);
     */
    async createDiffusedMarkerLayer(data, options)  {
            options = options || {};
            let type = "diffusedMarker";
        
    
            if (options.height) {
              options.height = this.map.geoLengthToHeight(options.height);
            }
            
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                text: "ID:${props.index}",
                width: 30,
                markerHeight: 30,
                color1: "#ff0000",
                color2: "#ffff00",
                textFontSize: 15,
                textColor: "#00ffff",
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createDiffusedMarkerLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Draw a line from point coordinate array
     * @param {[number, number][]} points Line coordinates [[x1,y1],[x2,y2],...], required
     * @param {object} options Config params, optional
     * @param {string} options.color Color #RRGGBB, optional
     * @param {number} options.width Line width, default 1, optional
     * @param {number} options.data Data value, optional
     * @returns {Object} Returns drawn feature object with properties:
     * @example
      * // Create line from 3 random map points, width 2-5, color red
      * let points = this.randomPointInMapBounds(3); // 3 points per line
      * const line = this.createLineString(points, {
      * color: vjmap.randomColor(),
      * width: 2 + Math.random() * 3
      * });
      * console.log(`Line ID:`, line.id);
      * console.log(`Line points:`, line.points);
     */
    createLineString(points, options)  {
            let draw = this.map.getDrawLayer();
            let id = vjmap.RandomID(10);
            draw.add({
                id,
                type: 'Feature',
                geometry: {
                    type: 'LineString',
                    coordinates: points.map(pt => this.map.toLngLat(pt))
                },
                properties: {
                    color: options.color,
                    line_width: options.width,
                    data: options.data
                }
            });
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.getDrawLayer().delete(id);
            });
            return {
                id,
                points
            };
        }
    

    /**
     * Remove map scale control
     * @example
      * // Remove map scale control
      * this.removeMapScaleControl();
     */
    removeMapScaleControl()  {
            this.context.global.controls = this.context.global.controls || {};
            let options;
            if (this.context.global.controls.scaleControl) {
                options = {...this.context.global.controls.scaleControl.options}
                this.map.removeControl(this.context.global.controls.scaleControl.control);
                delete this.context.global.controls.scaleControl;
            }
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                if (options) {
                    this.addMapScaleControl(options);
                }
            });
        }
    
    

    /**
     * Add map navigation control
     * @param {boolean} options.showCompass Show compass, optional
     * @param {boolean} options.showZoom Show zoom buttons, optional
     * @param {boolean} options.visualizePitch Show pitch button, optional
     * @param {'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'} options.position Control position, optional, default 'top-right'
     * @example
      * // Add map navigation control at top-left
      * this.addMapNavigationControl('top-left');
     */
    addMapNavigationControl(options)  {
            options = options || {};
            this.context.global.controls = this.context.global.controls || {};
            if (this.context.global.controls.navigationControl) {
                this.map.removeControl(this.context.global.controls.navigationControl.control);
            }
            let navigationControl = new vjmap.NavigationControl(options);
            this.map.addControl(navigationControl, options.position);
            this.context.global.controls.navigationControl = {
                control: navigationControl,
                position: options.position,
                options: options
            };
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.removeControl(navigationControl);
                delete this.context.global.controls.navigationControl;
            }); 
        }
    

    /**
     * Unlisten map event
     * @param {string} eventName Event name, required
     * @param {function | undefined} callback Event callback, optional, unlisten all when empty
     * @example
      * // Unlisten map click event
      * this.cancelListenMapEvent("click");
     */
    cancelListenMapEvent(eventName, callback)  {
            if (eventName === "clickEntity") {
                this.map.disableLayerClickHighlight();
                if (this.context.global._delegatedListeners) delete this.context.global._delegatedListeners[eventName];
                return;
            }
            if (callback) {
                this.map.off(eventName, callback);
    
                if (this.context.global._delegatedListeners && this.context.global._delegatedListeners[eventName]) {
                    let index = this.context.global._delegatedListeners[eventName].indexOf(callback);
                    if (index !== -1) {
                        this.context.global._delegatedListeners[eventName].splice(index, 1);
                    }
                }
                // Add undo record
                this.context.ui.addUndoRecord(() => {
                    this.map.on(eventName, callback);
    
                    this.context.global._delegatedListeners = this.context.global._delegatedListeners || {};
                    this.context.global._delegatedListeners[eventName] = this.context.global._delegatedListeners[eventName] || [];
                    this.context.global._delegatedListeners[eventName].push(callback);
                });
            } else {
                if (!this.context.global._delegatedListeners) return;
                if (!this.context.global._delegatedListeners[eventName]) return;
                const listener = this.context.global._delegatedListeners[eventName];
                if (listener) {
                    let saveListener = [...listener]; 
                    listener.forEach(func => {
                        this.map.off(eventName, func);
    
                        if (this.context.global._delegatedListeners && this.context.global._delegatedListeners[eventName]) {
                            let index = this.context.global._delegatedListeners[eventName].indexOf(func);
                            if (index !== -1) {
                                this.context.global._delegatedListeners[eventName].splice(index, 1);
                            }
                        }
                    });
                    // Add undo record
                    this.context.ui.addUndoRecord(() => {
                        saveListener.forEach(func => {
                            this.map.on(eventName, func);
                            
                            this.context.global._delegatedListeners = this.context.global._delegatedListeners || {};
                            this.context.global._delegatedListeners[eventName] = this.context.global._delegatedListeners[eventName] || [];
                            this.context.global._delegatedListeners[eventName].push(func);
                        });
                    });
                }
            }
            
        }
    

    /**
     * Create heatmap data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @param {Array|number} options.heatmapWeight Heatmap weight, point contribution, optional, default [0,1]
     * @param {Array|number} options.heatmapRadius Point radius in pixels, optional, default 30
     * @param {Array|string} options.heatmapColor Heatmap color config, optional
     * @param {Array|number} options.heatmapIntensity Heatmap intensity, optional, 1-3 by zoom
     * @param {Array|number} options.heatmapOpacity Heatmap opacity 0-1, optional, varies by zoom
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Randomly generate GeoJSON in map bounds, create heatmap layer named layer16
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createHeatmapLayer(geojson, {
      * layerName: 'layer16'
      * });
      * console.log(layer);
     */
    async createHeatmapLayer(data, options)  {
            options = options || {};
            let type = "heatmap";
        
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                // heatmapWeight: point contribution to heatmap weight, higher = more visible
                heatmapWeight:  [
                  'interpolate',
                  ['linear'],
                  ['get', 'value'],
                  0, // Normalize min/max 0,100 to 0,1 range
                  0,
                  100,
                  1
                ],
                // heatmapRadius: point radius in pixels for weight calc, default 30
                heatmapRadius: [
                    'interpolate',
                    ['linear'],
                    ['zoom'],
                    0, // zoom 0
                    10, // radius 10
                    9, // zoom 9 (linear interpolate for others)
                    50 // radius 50
                ],
                // heatmapColor: color at each heatmap value
                heatmapColor: [
                    'interpolate',
                    ['linear'],
                    ['heatmap-density'],
                    0,
                    'rgba(33,102,172,0)',
                    0.2,
                    'rgb(103,169,207)',
                    0.4,
                    'rgb(209,229,240)',
                    0.6,
                    'rgb(253,219,199)',
                    0.8,
                    'rgb(239,138,98)',
                    1,
                    'rgb(178,24,43)'
                ],
                // heatmapIntensity: overall heatmap intensity, similar to heatmapWeight but for whole
                heatmapIntensity: [
                    'interpolate',
                    ['linear'],
                    ['zoom'],
                    0,
                    1,
                    9,
                    3
                ],
                // heatmapOpacity: heatmap transparency
                heatmapOpacity: [
                    'interpolate',
                    ['linear'],
                    ['zoom'],
                    7,
                    1,
                    9,
                    0
                ],
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createHeatmapLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Generate shortest path from route, start and end points
     * @param {Object} options Config params
     * @param {[number, number]} options.startPoint Start [lng, lat]
     * @param {[number, number]} options.endPoint End [lng, lat]
     * @param {number} [options.precision] Precision, decimal places, default 0
     * @param {boolean} [options.hasDirection] Consider direction, default false
     * @param {boolean} [options.disableDrawPath] Disable auto draw path, default false
     * @param {string} [options.drawPathColor] Path color, default "#ff0000"
     * @param {number} [options.drawPathWidth] Path width, default 5
     * @returns {Object | null} Shortest path info: start, end, path, layer ids; null if not found
     * @example
      * // Generate shortest path from route, start [116.397428, 39.90923], end [116.405285, 39.90923]
      * let result = this.findShortestPath({
      * startPoint: [116.397428, 39.90923],
      * endPoint: [116.405285, 39.90923],
      * lines: [
      * {points: [[116.397428, 39.90923], [116.405285, 39.90923]]},
      * {points: [[116.405285, 39.90923], [116.425285, 39.91923]]},
      * ]
      * });
      * console.log(result);
     */
    findShortestPath(options)  {
            options = options || {};
            options.lines = options.lines || [];
            options.lines = options.lines.map(line => {
                return {
                    points: line.points.map(point => vjmap.geoPoint(point)),
                    id: line.id,
                    weight: line.weight
                }
            });
            let result = vjmap.findShortestPath(vjmap.geoPoint(options.startPoint), vjmap.geoPoint(options.endPoint), options.lines, options.precision, options.hasDirection);
        
            if (result.error) {
                this.map.logInfo(result.error.message, "error");
                return null;
            } 
            if (options.disableDrawPath !== true) {
               let polyline = new vjmap.Polyline({
                    data: this.map.toLngLat(result.route),
                    lineColor: options.drawPathColor || "#ff0000",
                    lineWidth: options.drawPathWidth || 5,
                });
                polyline.addTo(this.map);
                result.lineLayerId = polyline.layerId;
                result.lineSourceId = polyline.sourceId;
            }
            
            let sourceId = result.lineSourceId;
            if (sourceId) {
                // Add undo record
                this.context.ui.addUndoRecord(() => {
                    this.map.removeSourceEx(sourceId);
                });
            }
            return result;
        }
    

    /**
     * Get map container size
     * @returns {object} Object with width and height
     */
    getContainerSize() {  this.map.getContainer().getBoundingClientRect() }
    

    /**
     * Create new map from JSON data
     * @param {array} drawJson JSON array of draw entity definitions, Cartesian coords, entities extend DbEntity
     * @param {object} options Other params (optional)
     * @example
      * // Example 1
      * // Create circle center [10,10], radius 5, color red
      * let drawJson = [{
      * "typename": "DbCircle",
      * "center": [10, 10, 0],
      * "radius": 5,
      * "color": "#FF0000"
      * }]
      * // Create polyline with points [10,10] and [20,20], color green, width 2
      * drawJson.push({
      * "typename": "DbPolyline",
      * "points": [[10, 10], [20, 20]],
      * "color": "#00FF00",
      * "lineWidth": 2
      * })
      * // Create map from drawJson above
      * let mapid = await this.createMapData(drawJson);
      * console.log(mapid)
      * // Example 2
      * // Based on sys_hello/v1, select TEXT layer, create text at [10108,10556] height 2000 "Hello, World!" center aligned, create filled triangle [[10,10],[20,20],[10,20]] fill #00FF00, layer "ly1"
      * await this.createMapData([{
      * "typename": "DbText",
      * "position": [10108, 10556, 0],
      * "height": 2000,
      * "text": "Hello, World!",
      * "horizontalMode": 1,
      * "verticalMode": 2
      * }, {
      * "typename": "DbHatch",
      * "points": [[10, 10], [20, 20], [10, 20]],
      * "color": "#00FF00",
      * "layer": "ly1"
      * }], {
      * "from": "sys_hello/v1",
      * "pickLayers": ["TEXT"]
      * });
      * // Example 3
      * // Create block refs at [10,10], [5000,0], [0,6000] from sys_symbols clone id 25B; 2nd rotate 45, 3rd scale 3x; add to current map
      * await this.createMapData([{
      * "typename": "DbBlockReference",
      * "position": [10, 10],
      * "cloneFromDb": "sys_symbols",
      * "cloneObjectId": "25B"
      * }, {
      * "position": [5000, 0],
      * "cloneFromDb": "sys_symbols",
      * "cloneObjectId": "25B",
      * "rotateMat": 45
      * }, {
      * "position": [0, 6000],
      * "cloneFromDb": "sys_symbols",
      * "cloneObjectId": "25B",
      * "scaleMat": 3
      * }], {
      * "openway": 1
      * });
     */
    async createMapData(drawJson, options)  {
            return await this.context.tools.createMapData(this.context, drawJson, options)
        }
    

    /**
     * Cancel measurement
     * @example
      * // Cancel measurement
      * await this.cancelMeasure();
     */
    cancelMeasure()  {
                vjcommon.runMeasureCmd(this.map, "measureCancel");
            }
    

    /**
     * Listen to map event
     * @param {string} eventName Event name, required
     * @param {function} callback Event callback, required
     * @param {boolean} isOnce Fire once only, default false, optional
     * @example
      * // Example 1
      * // Listen to map double-click
      * this.listenMapEvent("dblclick", ev => {
      * console.log(ev);
      * });
      * // Example 2
      * // Listen to map click once, add Marker at click position
      * this.listenMapEvent("click", ev => {
      * if (ev && ev.point) {
      * // Get map pixel coords, convert to CAD geo coords
      * const point =  this.pixelPointToGeoPoint(ev.point);
      * // Add Marker at CAD geo coords on map
      * this.addMarker(point);
      * }
      * }, true);
      * // Example 3
      * // Listen to entity click, show data panel, title "Entity Data", height 500, as form
      * this.listenMapEvent("clickEntity", ev => {
      * if (ev) {
      * this.showDataPanel(ev, { title: "Entity Data", height: 500, showForm: true });
      * } else {
      * this.closeDataPanel();
      * }
      * });
     */
    listenMapEvent(eventName, callback, isOnce)  {
            // callback has map pixel coords, use this.pixelPointToGeoPoint(ev.point) for CAD geo coords
            
            // Add listener to global for unlisten
            this.context.global._delegatedListeners = this.context.global._delegatedListeners || {};
            this.context.global._delegatedListeners[eventName] = this.context.global._delegatedListeners[eventName] || [];
            this.context.global._delegatedListeners[eventName].push(callback);
    
            if (eventName === "clickEntity") {
                if (this.context.global._delegatedListeners[eventName].length === 1) {
                    // First listen
                    let oldEventValue;
                    this.map.enableLayerClickHighlight(this.map.getService(), e => {
                        if (e !== oldEventValue) {
                            this.context.global._delegatedListeners[eventName].forEach(func => {
                                func(e);
                            });
                            oldEventValue = e;
                        }
                    })
                }
                return;
            } 
    
            if (isOnce) {
                this.map.once(eventName, callback);
            } else {
                this.map.on(eventName, callback);
            }
    
            if (!this.context.global.__listenKeyEvent) {
                // Listen to keyboard for Shift/Ctrl/Alt state
                this.context.global.__listenKeyEvent = vjcommon.listenKeyEvent()
            }
            
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.off(eventName, callback);
                if (this.context.global._delegatedListeners && this.context.global._delegatedListeners[eventName]) {
                    let index = this.context.global._delegatedListeners[eventName].indexOf(callback);
                    if (index !== -1) {
                        this.context.global._delegatedListeners[eventName].splice(index, 1);
                    }
                }
            });
        }
    

    /**
     * Open map by mapid or manual selection
     * @param {object} options Parameters, optional
     * @param {string} options.mapid Map id, optional, manual select when empty
     * @param {string} options.version Version, optional, latest when empty
     * @param {"Memory" | "GeomRaster" | "GeomVector"} options.openway Open mode: Memory, GeomRaster, GeomVector, default GeomRaster
     * @param {boolean} options.lightTheme Light theme, optional, dark when empty
     * @param {boolean} options.isFullExtent Full extent, optional, default false
     * @returns {Object | undefined} Returns {mapid, version}, undefined if cancelled
     * @example
      * // Example 1
      * // Open map
      * this.openCadMap();
      * // Example 2
      * // Open mapsys_world
      * this.openCadMap({
      * mapid: "sys_world"
      * });
      * // Example 3
      * // Open map sys_hello, version v1, vector mode, light theme
      * this.openCadMap({
      * mapid: "sys_hello",
      * version: "v1",
      * openway: "GeomVector",
      * lightTheme: true
      * });
     */
    async openCadMap(options)  {
            options = options || {};
            let mapid = options.mapid || "";
            let version = options.version || "";
            let mapopenway = vjmap.MapOpenWay.GeomRender;
            let isVectorStyle = false;
            let lightTheme = options.lightTheme ?? (this.map.getService().currentMapParam()?.darkMode === true ? false : true)
            if (!mapid) {
                let res = await this.context.ui.selectCadMap()
                if (!res) return;
                mapid = res.mapid;
                version = res.version;
                mapopenway = res.openway;
                isVectorStyle = res.isVectorStyle;
            } else {
                mapopenway = options.openway === "Memory" ? vjmap.MapOpenWay.Memory : vjmap.MapOpenWay.GeomRender;
                isVectorStyle = options.openway === "GeomVector" ? true : false;
            }
            // Open map
            let res = await this.map.switchMap({
                mapid: mapid,
                version: version,
                mapopenway: mapopenway,
                style: lightTheme ? vjmap.openMapLightStyle() : vjmap.openMapDarkStyle(),
                // If password-protected map, password callback; system prompt if no callback
                cbInputPassword: (param) => {
                    return new Promise((resolve) => {
                        if (param.tryPasswordCount > 3) {
                            resolve('');// If password retry exceeds 3 times
                        } else {
                            this.messageBoxPrompt("Enter password or AccessKey", `Map ${param.mapid} requires password`, "", true)
                            .then((value) => {
                                resolve(value) ;
                            })
                            .catch(() => {
                                resolve('') ;
                            })
                        }
                    })
                }
            }, false, isVectorStyle, true, true);
            if (options.isFullExtent !== true) {
                // Use default center and zoom
                let svc = this.map.getService();
                let param = svc.currentMapParam();
                if (param && param.view) {
                    if (param.view.center) {
                        this.map.setCenter(param.view.center);
                    }
                    if (param.view.zoom) {
                        this.map.setZoom(param.view.zoom);
                    }
                    if (param.view.bearing) {
                        this.map.setBearing(param.view.bearing);
                    }
                }
            }
            if (res && res.error) {
                this.showMessage(res.error)
                return;
            }
            // New map opened, clear history
            this.context.ui.clearUndoRecord();
            return {
                mapid: mapid,
                version: version
            };
    
        }
    

    /**
     * Create blank map from bounds
     * @param {number| [number,number,number,number] | GeoJSON} boundData Bounds: number (width/height), array [x1,y1,x2,y2], or GeoJSON (auto calc); empty = 10000x10000 square
     * @example
      * // Example 1
      * // Create blank map
      * this.createBlankMap();
      * // Example 2
      * // Create blank map from bounds [100,100,200,200]
      * this.createBlankMap([100,100,200,200]);
     */
    createBlankMap(boundData)  {
            let dataExtent;
            boundData = boundData || 10000;
            if (typeof boundData === "number") {
                dataExtent = [-boundData/2, -boundData/2, boundData/2, boundData/2 ];
            } else if (Array.isArray(boundData) && boundData.length === 4 && typeof boundData[0] === "number") {
                dataExtent = vjmap.GeoBounds.fromArray(boundData);
            } else {
                dataExtent = vjmap.GeoBounds.fromDataExtent(boundData);
            }
            dataExtent = dataExtent.square(); // Map bounds must be square
            let mapBounds = dataExtent.scale(4); // Scale bounds, points should stay within
    
            // Clear map
            this.clearMapData({
                clearAllData: true
            });
            // Build geo projection from map bounds
            let prj = new vjmap.GeoProjection(mapBounds);
    
            let blankStyle = this.map.getService().rasterBlankStyle(0, 24)
            let sourceId = Object.keys(blankStyle.sources)[0];
            this.map.addRasterSource(sourceId, blankStyle[sourceId]);
            this.map.addLayer(blankStyle.layers[0]);
            this.map.setMapProjection(prj);
            // New map opened, clear history
            this.context.ui.clearUndoRecord();
        }
    

    /**
     * Convert map pixel to CAD geo coordinates
     * @returns {[number, number]} Returns [x,y] CAD geo coordinates
     * @example
      * // Convert map pixel [5, 10] to CAD geo coordinates
      * let point = this.pixelPointToGeoPoint([5, 10]);
      * let x = point[0];
      * let y = point[1];
      * console.log(x, y);
     */
    pixelPointToGeoPoint(pt)  {
            let lnglat = this.map.unproject(pt);
            let geoPoint = this.map.fromLngLat(lnglat);
            return [geoPoint.x, geoPoint.y];
        }
    

    /**
     * Remove map navigation control
     * @example
      * // Remove map navigation control
      * this.removeMapNavigationControl();
     */
    removeMapNavigationControl()  {
            this.context.global.controls = this.context.global.controls || {};
            let options;
            if (this.context.global.controls.navigationControl) {
                options = {...this.context.global.controls.navigationControl.options}
                this.map.removeControl(this.context.global.controls.navigationControl.control);
                delete this.context.global.controls.navigationControl;
            }
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                if (options) {
                    this.addMapNavigationControl(options);
                }
            }); 
        }
    
    

    /**
     * Fly to target position
     * @param {Object} options Fly options
     * @param {[number,number]} options.center Target coordinates
     * @param {number} [options.zoom] Target zoom, optional, keep current if omitted
     * @param {number} [options.bearing] Bearing in degrees, 0=north, optional
     * @param {number} [options.pitch] Pitch in degrees, 0=vertical, optional
     * @param {number} [options.speed] Speed multiplier, default 1.2, optional
     * @param {number} [options.curve] Path curvature, default 1.42, optional
     * @param {boolean} [options.essential] Whether essential animation,如果为true,则不会受到用户设置的“减少运动”选项的影响。可选,默认值为true
     * @param {boolean} [options.useAsync] Use async animation, default false
     * @param {boolean} [options.easeTo] Use ease animation, default false
     * @example
      * // Example 1
      * // Fly to [100,20]
      * this.flyTo({center: [100,20]});
      * // Example 2
      * // Fly to [100,20], zoom 10, bearing 45, pitch 60, ease animation
      * this.flyTo({center: [100,20], zoom: 10, bearing: 45, pitch: 60, easeTo: true});
     */
    async flyTo(options)  {
            options = options || {};
            if (options.center) {
                options.center = this.map.toLngLat(options.center);
            }
            options.essential = options.essential === undefined ? true : options.essential;
            let oldCenter = this.map.getCenter();
            let oldZoom = this.map.getZoom();
            let oldBearing = this.map.getBearing();
            let oldPitch = this.map.getPitch();
            let method = options.easeTo === true ? "easeTo" : "flyTo";
            if (options.useAsync === true) {
                function animateAsync(options) {
                    return new Promise((resolve) => {
                        // Listen to map moveend
                        this.map.once('moveend', ({ originalEvent }) => {
                            if (!originalEvent) {
                                resolve()
                            }
                        });
                        this.map[method](options);
                    });
                }
                await animateAsync(options);
            } else {
                this.map[method](options)
            }
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.setCenter(oldCenter);
                this.map.setZoom(oldZoom);
                this.map.setBearing(oldBearing);
                this.map.setPitch(oldPitch);
            });
         }
    

    /**
     * Create background layer
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {string} options.backgroundColor Background color, hex, default "#572782"
     * @param {number} options.backgroundOpacity Background opacity 0-1, default 0.6
     * @param {string} options.backgroundPattern Background pattern, image name, width must be power of 2
     * @param {boolean} options.visible Layer visible, optional, default true
     * @param {string} options.before Draw before specified layer, optional
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Create background layer, color #572782, opacity 0.6
      * let layer = await this.createBackgroundLayer({
      * backgroundColor: "#572782",
      * backgroundOpacity: 0.6,
      * });
      * console.log(layer);
     */
    async createBackgroundLayer(options)  {
            options = options || {};
            let type = "background";
    
            let randomId = vjmap.RandomID();
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                backgroundColor: "#572782",
                backgroundOpacity: 0.6,
                ...options,
                type: type
            }
           
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId: undefined, layerName, data: undefined, options, layer, visible: true, create: this.createBackgroundLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId: undefined,
                layerId,
                layerName
            };
        }
    

    /**
     * Set draw feature properties
     * @param {string} id Feature ID, required
     * @param {object} options Config params, optional
     * @param {string} options.color Color #RRGGBB, optional
     * @param {string} options.borderColor Border color #RRGGBB, optional
     * @param {number} options.opacity Opacity, optional
     * @param {number} options.borderOpacity Border opacity, optional
     * @param {number} options.width Line width, optional
     * @param {number} options.extrusionHeight Extrusion height, polygon only, optional
     * @param {boolean} options.isOff Hidden: false=show, true=hide, optional
     * @param {boolean} options.isLocked Locked: false=unlock, true=lock, optional
     * @param {string} options.text Text content, symbol text only, optional
     * @param {[number, number][] | [number, number]} options.coordinates Coordinate array, optional
     * @param {number} options.data Data value, optional
     * @returns {Promise<Object | undefined>} Drawn entity or undefined if cancelled. Object includes:
     * @example
      * // Example 1
      * // Select drawn feature, set color to red
      * let geojson = await this.actionSelect();
      * for(let feature of geojson.features) {
      * this.setFeatureProperty(feature.id, {
      * color: "#FF0000",
      * });
      * };
      * // Example 2
      * // Get second drawn feature, set color yellow, opacity 0.6, width 2, border red
      * let geojson2 =  this.getDrawAll();
      * let features = geojson2.features;
      * let index = 2; // Second entity
      * if (index >= features.length) {
      * return;
      * }
      * let feature = features[index];
      * this.setFeatureProperty(feature.id, {
      * color: "#FFFF00",
      * opacity: 0.6,
      * width: 2,
      * borderColor: "#FF0000",
      * });
     */
    setFeatureProperty(id, options)  {
            options = options || {};
            let draw = this.map.getDrawLayer();
            let attr = {
                "color": options.color,// color
                "opacity": options.opacity, // opacity
                "outlineColor": options.borderColor,// border color
                "line_width": options.width, // line width
                "line_opacity": options.borderOpacity, // opacity
                "data": options.data,
                "coordinates": options.coordinates,
                "isOff": options.isOff,
                "isLocked": options.isLocked,
                "text": options.text,
                "extrusionHeight": options.extrusionHeight,
            };
    
            for(let key in attr) {
                if (attr[key] !== undefined && attr[key] !== null) {
                    draw.setFeatureProperty(
                        id,
                        key,
                        attr[key]
                    );
                }
            }
            draw.forceRefresh();
        }
    

    /**
     * Save temporary data
     * @param {string} name Data name
     * @param {any} data Data content
     * @example
      * // Save "123" to temp data test
      * this.saveTempData("test", "123");
     */
    saveTempData(name, data)  {
            this.context.resultData[name] = data
        }
    

    /**
     * Delete data layer
     * @param {string} layerName Layer name, required
     * @example
      * // Delete data layer named layer1
      * let layerName = 'layer1';
      * this.deleteDataLayer(layerName);
     */
    deleteDataLayer(layerName)  {
            let item = this.context.global.layers.find(layer => layer.layerName == layerName);
            if(item) {
                let layerId = item.layerId;
                let sourceId = item.sourceId;
                if (item.layer && typeof(item.layer.getLayerId) == "function") {
                    layerId = item.layer.getLayerId();
                    let style = this.map.getStyle();
                    let layer = style.layers.find(layer => layer.id == layerId);
                    if (layer && layer.source) {
                        sourceId = layer.source;
                    }
                }
                item.layer.removeLayer(this.map, layerId);
                if (sourceId) {
                    this.map.removeSourceEx(sourceId);
                }
                this.context.global.layers = this.context.global.layers.filter(layer => layer.layerId != item.layerId);
    
                // Add undo record
                this.context.ui.addUndoRecord(() => {
                    if (item.create) {
                        item.create(item.data, item.options);
                    }
                });
            }
        }
    

    /**
     * Set data layer hover to show popup
     * @param {string} layerName Layer name, required
     * @param {Record<string, any>} options Other params, optional
     * @param {any} options.popupContent Popup content, default current data properties
     * @param {boolean} options.popupShowInForm Whether popup content is displayed as form. Optional, default false
     * @param {[number, number]} options.popupOffset Popup offset, optional, default [0, -40]
     * @param {number} options.popupWidth Popup width, optional
     * @param {number} options.popupHeight Popup height, optional
     * @param {boolean} options.isHoverPointer Enable hover events, optional, default true
     * @param {boolean} options.noShowPopup Do not show popup, optional, default false
     * @param {Function} options.hoverCallback Hover callback, optional, first param is popup content
     * @example
      * // Example 1
      * // Set data layer with name layer1 to show popup on hover
      * this.setDataLayerHoverPopupContent('layer1');
      * // Example 2
      * // Set layer layer1 hover popup, content "ID:${props.index}"
      * this.setDataLayerHoverPopupContent('layer1', {
      * popupContent: "ID:${props.index}",
      * });
     */
    setDataLayerHoverPopupContent(layerName, options)  {
            let item = this.context.global.layers.find(layer => layer.layerName == layerName);
            if(!item) return;
            options = options || {};
            options.isHoverPointer = options.isHoverPointer === undefined ? true : options.isHoverPointer;
            let markers = item.layer.markers;
            if (Array.isArray(markers)) {
                // If Marker-related layer
                for(let m = 0; m < markers.length; m++) {
                    let marker = markers[m];
                    let oldCursor;
                    marker.on('mouseover', () => {
                        if (options.isHoverPointer) {
                            oldCursor = this.map.getCanvas().style.cursor;
                            this.map.getCanvas().style.cursor = "pointer";
                        }
                        let popup = this.context.global.popup;
                        if (!popup && options.noShowPopup !== true) {
                            popup = new vjmap.Popup({
                                offset: options.popupOffset || [0, -40],
                                closeOnClick: false, 
                                closeButton: true, 
                                anchor: "bottom" 
                            })
                            this.context.global.popup = popup;
                        }
                        if (options.noShowPopup !== true &&options.popupOffset) {
                            popup.setOffset(options.popupOffset);
                        }
                        let content = options.popupContent;
                        if (item.data && item.data.features && item.data.features[m] && item.data.features[m].properties && typeof content == "string" && !(content in item.data.features[m].properties)) {
                            content = item.layer.evalValue("return `" + content + "`", item.data.features[m].properties, this.map);
                            if (options.noShowPopup !== true) {
                                popup.setHTML(content);
                            }
                            if (options.hoverCallback) {
                                options.hoverCallback(content);
                            }
                        } else {
                            if (!content) {
                                content = item.data.features[m].properties;
                            } else {
                                // If content is object property, get property value
                                if (item.data.features[m].properties && (content in item.data.features[m].properties)) {
                                    content = item.data.features[m].properties[content];
                                    try {
                                        // If content is JSON string, try to parse
                                        content = JSON.parse(content);
                                    } catch (e) {}
                                }
                            }
                            if (options.noShowPopup !== true) {
                                const dom = this.context.ui.createElementByJsonData(content, options.popupShowInForm, {
                                    width: options.popupWidth,
                                    height: options.popupHeight
                                })
                                popup.setDOMContent(dom.element)
                            }
                            if (options.hoverCallback) {
                                options.hoverCallback(content);
                            }
                        }
                        if (options.noShowPopup !== true) {
                            popup.setLngLat(marker.getLngLat());
                            popup.addTo(this.map);
                        }
                    });
                    marker.on('mouseleave', () => {
                        if (options.isHoverPointer && oldCursor) {
                            this.map.getCanvas().style.cursor = oldCursor;
                            oldCursor = null;
                        }
                        if (options.noShowPopup !== true) {
                            let popup = this.context.global.popup;
                            if (popup) {
                                popup.remove();
                                this.context.global.popup = null;
                            }
                        }
                        if (options.hoverCallback) {
                            options.hoverCallback(null);
                        }
                    });
                }
            } else {
                // If other layer type
                let oldCursor;
                let oldFeatureId;
                let layerId = item.layerId;
                let sourceId = item.sourceId;
                if (item.layer && typeof(item.layer.getLayerId) == "function") {
                    layerId = item.layer.getLayerId();
                    let style = this.map.getStyle();
                    let layer = style.layers.find(layer => layer.id == layerId);
                    if (layer && layer.source) {
                        sourceId = layer.source;
                    }
                }
                this.map.on("mouseover", layerId, (ev) => {
                    if (!ev || !ev.features || !ev.features[0]) return;
                    if (options.isHoverPointer) {
                        oldCursor = this.map.getCanvas().style.cursor;
                        this.map.getCanvas().style.cursor = "pointer";
                    }
                    let pt = ev.lngLat;
                    let popup = this.context.global.popup;
                    if (!popup && options.noShowPopup !== true) {
                        popup = new vjmap.Popup({
                            offset: options.popupOffset || [0, -40],
                            closeOnClick: false, 
                            closeButton: true, 
                            anchor: "bottom" 
                        })
                        this.context.global.popup = popup;
                    }
                    if (options.noShowPopup !== true && options.popupOffset) {
                        popup.setOffset(options.popupOffset);
                    }
                    let content = options.popupContent;
                    if (ev.features[0].properties && typeof content == "string" && !(content in ev.features[0].properties)) {
                        content = item.layer.evalValue("return `" + content + "`", ev.features[0].properties, this.map);
                        if (options.noShowPopup !== true) {
                            popup.setHTML(content);
                        }
                        if (options.hoverCallback) {
                            options.hoverCallback(content);
                        }
                    } else {
                        if (!content ) {
                            content = ev.features[0].properties;
                        } else {    
                                // If content is object property, get property value
                            if (ev.features[0].properties && (content in ev.features[0].properties)) {
                                content = ev.features[0].properties[content];
                                try {
                                    // If content is JSON string, try to parse
                                    content = JSON.parse(content);
                                } catch (e) {}
                            }
                        }
                        if (options.noShowPopup !== true) {
                            const dom = this.context.ui.createElementByJsonData(content, options.popupShowInForm, {
                                width: options.popupWidth,
                                height: options.popupHeight
                            })
                            popup.setDOMContent(dom.element)
                        }
                        if (options.hoverCallback) {
                            options.hoverCallback(content);
                        }
                    }
                    if (options.noShowPopup !== true) {
                        popup.setLngLat(pt);
                        popup.addTo(this.map);
                    }
                    oldFeatureId = ev.features[0].id;
                    this.map.setFeatureState({ source: sourceId, sourceLayer: layerId, id: ev.features[0].id }, { hover: true });
                });
                this.map.on("mouseleave", layerId, () => {
                    if (options.isHoverPointer && oldCursor) {
                        this.map.getCanvas().style.cursor = oldCursor;
                        oldCursor = null;
                    }   
                    if (options.noShowPopup !== true) {
                        let popup = this.context.global.popup;
                        if (popup) {
                            popup.remove();
                            this.context.global.popup = null;
                        }
                    }
                    if (options.hoverCallback) {
                        options.hoverCallback(null);
                    }
                    if (oldFeatureId) {
                        this.map.setFeatureState({ source: sourceId, sourceLayer: layerId, id: oldFeatureId }, { hover: false });
                        oldFeatureId = null;
                    }
                });
            }
        }
    

    /**
     * Draw polygon from point coordinate array
     * @param {[number, number][]} points Polygon coordinates [[x1,y1],[x2,y2],...], required
     * @param {object} options Config params, optional
     * @param {string} options.color Color #RRGGBB, optional
     * @param {boolean} options.isFill Fill, default true, optional
     * @param {number} options.opacity Opacity, default 1, optional
     * @param {number} options.extrusionHeight Extrusion height, optional
     * @param {number} options.data Data value, optional
     * @returns {Object} Returns drawn feature object with properties:
     * @example
      * // Example 1
      * // Create polygon from 3 random map points
      * let points = this.randomPointInMapBounds(3); // 3 points per polygon
      * points.push(points[0]); // Close polygon
      * const polygon = this.createPolygon(points, {
      * color: vjmap.randomColor(),
      * isFill: true,
      * opacity: 0.5
      * });
      * console.log(`Polygon ID:`, polygon.id);
      * console.log(`Polygon points:`, polygon.points);
      * // Example 2
      * // Create polygon from 3 random points, extrusion height 1/5 of map width
      * let points2 = this.randomPointInMapBounds(3); // 3 points per polygon
      * points2.push(points2[0]); // Close polygon
      * const polygon2 = this.createPolygon(points2, {
      * color: vjmap.randomColor(),
      * isFill: true,
      * extrusionHeight: map.getGeoBounds().width() / 5
      * });
      * console.log(`Extrusion polygon ID:`, polygon2.id);
      * console.log(`Extrusion polygon points:`, polygon2.points);
     */
    createPolygon(points, options)  {
            let draw = this.map.getDrawLayer();
            let id = vjmap.RandomID(10);
            draw.add({
                id,
                type: 'Feature',
                geometry: {
                    type: options.isFill !== false ? 'Polygon' : 'LineString',
                    coordinates: options.isFill !== false ? [points.map(pt => this.map.toLngLat(pt))] : points.map(pt => this.map.toLngLat(pt))
                },
                properties: {
                    color: options.color,
                    opacity: options.opacity,
                    data: options.data,
                    extrusionHeight: options.extrusionHeight ? this.map.geoLengthToHeight(options.extrusionHeight) : undefined
                }
            });
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.getDrawLayer().delete(id);
            });
            return {
                id,
                points
            };
        }
    

    /**
     * Set map pitch angle
     * @param {number} pitch Pitch angle to set
     * @example
      * this.setPitch(60); // Set pitch to 60 degrees
     */
    setPitch(pitch)  {
            let oldPitch = this.map.getPitch(); // Get current pitch
            this.map.setPitch(pitch); // Set new pitch
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.setPitch(oldPitch);
            });
        }
    

    /**
     * Manually draw a point
     * @param {object} options Config params, optional
     * @param {boolean} options.isSnap Enable snap, optional, default false
     * @param {string} options.color Color #RRGGBB, optional
     * @param {number} options.size Point size, optional
     * @param {number} options.data Data value, optional
     * @returns {Promise<Object | undefined>} Drawn entity or undefined if cancelled. Object includes:
     * @example
      * // Manually draw a point
      * const pt = await this.drawPoint(); // Enable snap to draw point
      * if (!pt) return;
      * // Point coordinates
      * let point = pt.point;
      * const x = point[0];
      * const y = point[1];
      * console.log(x, y);
      * // Feature ID
      * let id = pt.id;
      * console.log(id);
      * // Manually draw point, color red, size 10, data 123, with snap
      * const pt2 = await this.drawPoint({
      * isSnap: true,
      * color: "#FF0000",
      * size: 10,
      * data: 123
      * });
      * console.log(pt2);
     */
    async drawPoint(options)  {
            options = options || {};
            let point = await this.actionDrawPoint(options.isSnap);
            if (!point) return;
            return this.createPoint(point, options);
        }
    

    /**
     * Add a popup
     * @param {[number, number]} pt Coordinates [x, y]
     * @param {Record<string, any>} options Other params, optional
     * @param {any} options.content Popup content, optional, empty=no popup
     * @param {[number, number]} options.offset Popup offset, optional, default [0, -40]
     * @param {boolean} options.showInForm Show as form, optional, default false
     * @param {boolean} options.isOpen Popup open, optional, default false
     * @param {string} options.anchor Anchor: center, top, bottom, left, right, etc., optional
     * @param {string} options.maxWidth Popup max width, optional, default 'auto'
     * @param {string} options.width Popup width, optional
     * @param {string} options.height Popup height, optional
     * @param {string} options.className Popup CSS class, optional
     * @param {boolean} options.closeButton Show close button, optional, default true
     * @param {boolean} options.closeOnClick Close on outside click, optional, default false
     * @param {boolean} options.closeOnMove Close on map move, optional, default false
     * @param {boolean} options.focusAfterOpen Focus after open, optional, default true
     * @param {number} options.elevation Elevation value, optional, default 0
     * @param {boolean} options.visible Visible, optional, default true
     * @param {any} options.data Data content, optional
     * @returns {string} Returns popup id
     * @example
      * // Add popup at [30,50], content "Hello,World"
      * let popupId = this.addPopup([30, 50], {
      * content: "Hello,World"
      * });
      * console.log(popupId);
     */
    addPopup(pt, options)  {
            options = options || {};
            let popup = new vjmap.Popup({
                offset: options.offset, 
                anchor: options.anchor,
                maxWidth: options.maxWidth,
                className: options.className,
                closeButton: options.closeButton === undefined ? true : options.closeButton,
                closeOnClick: options.closeOnClick,
                closeOnMove: options.closeOnMove,
                focusAfterOpen: options.focusAfterOpen
            })
            const dom = this.context.ui.createElementByJsonData(options.content, options.showInForm, {
                width: options.width,
                height: options.height
            })
            popup.setDOMContent(dom.element)
          
            popup.id = "popup_" + vjmap.RandomID()
            if (options.data) popup.data = options.data;
            if (pt) {
                popup.setLngLat(this.map.toLngLat(pt));
            }
            if (options.visible !== false || options.isOpen) {
                popup.addTo(this.map);
            }
    
            if (options.elevation) {
                popup.setHeight(options.elevation);
            }
    
            this.context.global.popups = this.context.global.popups || [];
            this.context.global.popups.push({id: popup.id, popup: popup, options: options});
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deletePopup(popup.id);
            });
            return popup.id
        }
    

    /**
     * Switch to raster tile style
     * @example
      * // Switch to raster tile style
      * this.switchVectorToRasterStyle();
     */
    switchVectorToRasterStyle()  {
            // Skip if map already uses raster tile style
            if (!this.map.hasVectorLayer()) {
                return
            }
            this.map.switchVectorToRasterStyle(this.map.getService().rasterTileUrl());
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.switchRasterToVectorStyle(this.map.getService().vectorTileUrl());
            });
        }
    
    

    /**
     * Create point symbol data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @param {string} options.iconImage Icon name for image background
     * @param {number} options.iconRotate Icon rotation angle, clockwise, degrees, default 0
     * @param {[number,number]} options.iconOffset Icon offset from anchor, [x,y], positive = right/down
     * @param {string} options.iconAnchor Icon anchor position, values: "center", "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right"
     * @param {string} options.iconRotationAlignment Icon rotation alignment, values: "map", "viewport", "auto"
     * @param {string} options.iconPitchAlignment Icon pitch alignment, values: "map", "viewport", "auto"
     * @param {boolean} options.iconAllowOverlap Allow icon overlap, default false
     * @param {number} options.iconOpacity Icon opacity 0-1, default 1
     * @param {boolean} options.iconIgnorePlacement Ignore collision detection, default false
     * @param {boolean} options.iconOptional Icon overlap option, default false
     * @param {number} options.iconSize Icon scale, default 1
     * @param {string} options.iconTextFit Icon text fit, values: "none", "width", "height", "both"
     * @param {[number,number,number,number]} options.iconTextFitPadding Icon text fit padding [top, right, bottom, left]
     * @param {number} options.iconPadding Icon padding, default 1px
     * @param {boolean} options.iconKeepUpright Keep icon upright, default false
     * @param {string} options.iconColor SDF icon color, default"#000000"
     * @param {string} options.iconHaloColor SDF icon halo color, default"rgba(0, 0, 0, 0)"
     * @param {number} options.iconHaloWidth Icon halo width, default 0
     * @param {number} options.iconHaloBlur Icon halo blur, default 0
     * @param {[number,number]} options.iconTranslate Icon translate [x,y]
     * @param {string} options.iconTranslateAnchor Icon translate anchor, values: "map", "viewport"
     * @param {string} options.textField Text content
     * @param {string[]} options.textFont Text font, default["Open Sans Regular","Arial Unicode MS Regular"]
     * @param {number} options.textSize Text size, default 16
     * @param {boolean} options.textAllowOverlap Allow text overlap, default false
     * @param {string} options.textColor Text color, default"#000000"
     * @param {number} options.textOpacity Text opacity 0-1, default 1
     * @param {[number,number]} options.textOffset Text offset [x,y]
     * @param {number} options.textRotate Text rotation angle
     * @param {number} options.textPadding Text padding, default 2
     * @param {boolean} options.textKeepUpright Keep text upright, default true
     * @param {number} options.textMaxWidth Text max width, default 10em
     * @param {number} options.textLineHeight Text line height, default 1.2
     * @param {number} options.textLetterSpacing Letter spacing, default 0
     * @param {string} options.textJustify Text justify, values: "auto", "left", "center", "right"
     * @param {string} options.textAnchor Text anchor position
     * @param {string} options.textRotationAlignment Text rotation alignment
     * @param {string} options.textPitchAlignment Text pitch alignment
     * @param {number} options.textMaxAngle Max angle between chars, default 45°
     * @param {string[]} options.textWritingMode Text writing mode
     * @param {string} options.textTransform Text transform, values: "none", "uppercase", "lowercase"
     * @param {number} options.textRadialOffset Text radial offset
     * @param {string[]} options.textVariableAnchor Text variable anchor array
     * @param {boolean} options.textIgnorePlacement Ignore text placement
     * @param {boolean} options.textOptional Text optional
     * @param {string} options.textHaloColor Text halo color
     * @param {number} options.textHaloWidth Text halo width
     * @param {number} options.textHaloBlur Text halo blur
     * @param {[number,number]} options.textTranslate Text translate
     * @param {string} options.textTranslateAnchor Text translate anchor
     * @param {string} options.symbolPlacement Symbol placement, values: "point", "line", "line-center"
     * @param {number} options.symbolSpacing Symbol spacing, default 250
     * @param {boolean} options.symbolAvoidEdges Avoid edge collision
     * @param {string} options.symbolZOrder Symbol z-order, values: "auto", "viewport-y", "source"
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Randomly generate GeoJSON in map bounds, create point symbol layer layer24, icon _markerIcon_
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createSymbolDataLayer(geojson, {
      * layerName: 'layer24',
      * iconImage: '_markerIcon_'
      * });
      * console.log(layer);
     */
    async createSymbolDataLayer(data, options)  {
            options = options || {};
            let type = "symbol";
        
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
    
            if (options.textField && data.features.length > 0 && data.features[0].properties && (options.textField in data.features[0].properties)) {
                // If content is a GeoJSON property, get its value
                options.textField = "['get', '" + options.textField + "']";
            }
            let layerOptions = {
                layerId,
                sourceId,
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createSymbolDataLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Draw rectangle at given coordinates
     * @param {[number, number, number, number]} points Rect [x1,y1,x2,y2] bottom-left and top-right, required
     * @param {object} options Config params, optional
     * @param {string} options.color Color #RRGGBB, optional
     * @param {boolean} options.isFill Whether to fill. Optional, default false
     * @param {number} options.opacity Opacity. Optional, default 1
     * @param {number} options.data Data value, optional
     * @returns {Object} Returns drawn feature object with properties:
     * @example
      * // Create 3 rects at random map positions, size 1/20 of map width, random color, fill, opacity 0.5, 2nd unfilled
      * let points = this.randomPointInMapBounds(3);
      * let rect = this.getMapGeoBounds(); // Get map bounds
      * let width = (rect[2] - rect[0]) / 20; // Rect width 1/20 of map
      * for(let i = 0; i < points.length; i++) {
      * const rect = this.createRectangle([
      * points[i][0], points[i][1],
      * points[i][0] + width, points[i][1] + width
      * ], {
      * color: vjmap.randomColor(),
      * isFill: i == 1 ? false : true,
      * opacity: 0.5,
      * data: i
      * });
      * console.log(`Rect ${i+1} ID:`, rect.id);
      * console.log(`Rect ${i+1} coords:`, rect.points);
      * }
     */
    createRectangle(points, options)  {
            let bounds = vjmap.GeoBounds.fromArray(points);
            let pts = bounds.toPointArray();
            pts.push(pts[0]);
            let draw = this.map.getDrawLayer();
            let id = vjmap.RandomID(10);
            draw.add({
                id,
                type: 'Feature',
                geometry: {
                    type: options.isFill ? 'Polygon' : 'LineString',
                    coordinates: options.isFill ? [pts.map(pt => this.map.toLngLat(pt))] : pts.map(pt => this.map.toLngLat(pt))
                },
                properties: {
                    color: options.color,
                    opacity: options.opacity,
                    data: options.data
                }
            });
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.getDrawLayer().delete(id);
            });
            return {
                id,
                points
            };
        }
    

    /**
     * Save user custom data to server
     * @param {any} value Key value
     * @param {any} prop Property value
     * @param {number} ttl TTL in seconds, default permanent
     * @returns {Promise<boolean>} Returns true on success, false on failure
     * @example
      * // Save custom data to server, key testKey, value testValue, prop { prop1: "value1" }
      * await this.saveCustomDataServer("testKey", "testValue", { prop1: "value1" });
      * // Batch save: testKey1=testValue1 prop1 3600s, testKey2=testValue2 prop2 7200s
      * await this.saveCustomDataServer([
      * { key: "testKey1", value: "testValue1", prop: { prop1: "value1" }, ttl: 3600 },
      * { key: "testKey2", value: "testValue2", prop: { prop2: "value2" }, ttl: 7200 }
      * ]);
     */
    async saveCustomDataServer(key, value, prop, ttl)  {
            let svc = this.map.getService();
            let res = await svc.saveCustomData(key, value, prop, ttl);
            return res?.status === "ok";
        }
    

    /**
     * Add map scale control
     * @param {number} options.maxWidth Scale max width, optional
     * @param {string} options.unit Scale unit, optional
     * @param {'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'} options.position Control position, optional, default 'bottom-left'
     * @example
      * // Add scale control at bottom-right
      * this.addMapScaleControl({ position: 'bottom-right' });
     */
    addMapScaleControl(options)  {
            options = options || {};
            this.context.global.controls = this.context.global.controls || {};
            if (this.context.global.controls.scaleControl) {
                this.map.removeControl(this.context.global.controls.scaleControl.control);
            }
            let scaleControl = new vjmap.ScaleControl(options);
            this.map.addControl(scaleControl, options.position || 'bottom-left');
            this.context.global.controls.scaleControl = {
                control: scaleControl,
                position: options.position,
                options: options
            };
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.removeControl(scaleControl);
                delete this.context.global.controls.scaleControl;
            }); 
        }
    

    /**
     * Add a function button
     * @param {string} name Button name, required
     * @param {Function} func Function to execute, required
     * @param {string} tooltip Button tooltip, optional
     * @example
      * // Add button "Zoom In", zoom map in by one level
      * this.addButtonItem("Zoom In", () => {
      * let zoom = map.getZoom();
      * map.setZoom(zoom + 1);
      * })
     */
    addButtonItem(name, func, tooltip)  {
            this.context.ui.addButtonItem(name, func, tooltip);   
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                 this.removeButtonItem(name);
             });
         }
    

    /**
     * Delete drawn feature(s)
     * @param {string | string[]} id Feature ID or ID array, required
     * @example
      * // Delete all drawn features
      * const geojson = this.getDrawAll();
      * if (!geojson) return;
      * let ids = [];
      * for(let i = 0; i < geojson.features.length; i++) {
      * let feature = geojson.features[i];
      * ids.push(feature.id);
      * }
      * this.deleteDrawFeature(ids);
     */
    deleteDrawFeature(id)  {
            let draw = this.map.getDrawLayer();
            draw.delete(id);
        }
    

    /**
     * Create line extrusion layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {string} options.fillExtrusionColor Extrusion color, optional, default "#00ffff"
     * @param {number} options.fillExtrusionOpacity Opacity 0-1, optional
     * @param {number} options.fillExtrusionHeight Extrusion height in CAD units, optional, default 500000
     * @param {number} options.fillExtrusionBase Base height in CAD units, optional, default 0
     * @param {string} options.fillExtrusionPattern Fill pattern, sprite image name, width power of 2
     * @param {[number,number]} options.fillExtrusionTranslate Translate [x,y], negative=left/up, default [0,0]
     * @param {string} options.fillExtrusionTranslateAnchor Translate anchor: "map" or "viewport"
     * @param {boolean} options.fillExtrusionVerticalGradient Vertical gradient, optional, true=darker at bottom
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @param {number} options.offsetLine Line offset size, optional, default 10
     * @param {boolean} options.isBreakLine Break into segments, optional
     * @param {number} options.anmiDuration Animation duration in seconds, optional, 0=no animation
     * @param {number} options.anmiRepeat Animation repeat count, optional, 0=infinite
     * @param {string} options.anmiRepeatType Repeat type: "reverse", "loop", "mirror"
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Randomly generate GeoJSON in map bounds, create line extrusion layer layer18
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createLineExtrusionsLayer(geojson, {
      * layerName: 'layer18'
      * });
      * console.log(layer);
     */
    async createLineExtrusionsLayer(data, options)  {
            options = options || {};
            let type = "lineExtrusions";
        
            if(options.fillExtrusionHeight) {
              options.fillExtrusionHeight = this.map.geoLengthToHeight(options.fillExtrusionHeight);
            }
            if(options.fillExtrusionBase) {
              options.fillExtrusionBase = this.map.geoLengthToHeight(options.fillExtrusionBase);
            }
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                fillExtrusionColor: "#00ffff",
                fillExtrusionHeight: this.map.geoLengthToHeight(this.map.getGeoBounds().width() / 6),
                anmiDuration: 0,
                offsetLine: 10,
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createLineExtrusionsLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Overlay CAD map
     * @param {string} [options.mapid] Map ID
     * @param {string} [options.version] Map version, uses current when empty
     * @param {string} [options.layer] Layer style name, default when empty
     * @param {string} [options.epsg] CAD EPSG code
     * @param {"beijing54"|"xian80"|"cgs2000"} [options.crs] CAD CRS: beijing54, xian80, cgs2000
     * @param {string} [options.webMapTiles] Web map tile URL when overlay
     * @param {boolean} [options.overMapImage] Whether overlay is imagery
     * @param {boolean} [options.isSetRotateZero] Allow rotate for control points
     * @param {boolean} [options.baseMapWgs84] Base map WGS84
     * @returns {Promise<string>} Returns the overlaid layer name
     * @example
      * // Example 1
      * // Overlay CAD map, mapid is sys_world
      * let layerName = await this.overlayCadMap({
      * mapid: "sys_world"
      * });
      * console.log("layerName", layerName);
      * // Example 2
      * // Auto overlay CAD map, mapid is sys_cad2000
      * let layerName2 = await this.overlayCadMap({
      * mapid: "sys_cad2000",
      * overlayType: "auto"
      * });
      * console.log("layerName2", layerName2);
      * // Example 3
      * // Direct overlay CAD map, mapid is sys_world
      * let layerName3 = await this.overlayCadMap({
      * mapid: "sys_world",
      * overlayType: "direct"
      * });
      * console.log("layerName3", layerName3);
     */
    async overlayCadMap(options)  {
            options = {...options}
            // Check if base is web map
            let isWebMap = this.map.getMapProjection() instanceof vjmap.LnglatProjection;
            let wmsTileUrl;
            if (!options.mapid) {
                this.showMessage("Please set the mapid of the CAD map to overlay");
                return;
            }
            if (isWebMap) {
               // Base map is web map; web map overlays CAD only via auto or four-parameter
                let epsg = options.epsg;
                if (!epsg) {
                    epsg = await this.getCadEpsgCode(options);
                }
                if (options.overlayType !== "param" && epsg) {
                    options.epsg = epsg;
                   // Auto overlay
                   wmsTileUrl = await this.getWmsTileUrl(options);
                   let svc = this.map.getService();
                   // Convert CAD coords to web map coords and move map to CAD center
                   const cadToWebCoordinate = async point => {
                        let result = await svc.cmdTransform(epsg, "EPSG:4326", vjmap.geoPoint(point)); // Or use proj4 on frontend if not calling service
                        return result[0];// First result
                   }
                   let metadata = await svc.metadata(options.mapid, options.version);
                   let mapExtent = vjmap.GeoBounds.fromString(metadata.bounds);
                   let p1 = await cadToWebCoordinate(mapExtent.min);
                   let p2 = await cadToWebCoordinate(mapExtent.max);
                   this.fitMapBounds([p1[0],p1[1],p2[0],p2[1]], {padding: 0}); 
                } else {
                   // Four-parameter overlay
                    // Four-parameter overlay: left=default map, right=CAD to overlay
                    let fp = await this.context.ui.getFourParamter({
                        mapid: options.mapid,
                        version: options.version,
                        layer: options.layer
                    });
                    if (!fp) return;
                    options.coordinates = [];
                    for(let n = 0; n < fp.leftMarkers.length; n++) {
                        let leftMarker = fp.leftMarkers[n];
                        let rightMarker = fp.rightMarkers[n];
                        options.coordinates.push({
                            x1: rightMarker.x,
                            y1: rightMarker.y,
                            x2: leftMarker.x,
                            y2: leftMarker.y
                        });
                    }
                    options.overlayType = "param";
                    options.isSetRotateZero = !fp.allowsRotation;
                    wmsTileUrl = await this.getWmsTileUrl(options);
                    let svc = this.map.getService();
                    // Convert CAD coords to web map coords and move map to CAD center
                    const cadToWebCoordinate =  point => {
                        let p = vjmap.coordTransfromByFourParamter(point,{
                            dx: fp.fourParamter[0], 
                            dy: fp.fourParamter[1], 
                            scale: fp.fourParamter[2], 
                            rotate: fp.fourParamter[3],
                        });
                        let wgs84 = vjmap.Projection.mercator2LngLat(p); // Mercator 3857 to WGS84 lng/lat
                        return wgs84;
                    }
                    let metadata = await svc.metadata(options.mapid, options.version);
                    let mapExtent = vjmap.GeoBounds.fromString(metadata.bounds);
                    let p1 =  cadToWebCoordinate(mapExtent.min);
                    let p2 =  cadToWebCoordinate(mapExtent.max);
                    this.fitMapBounds([p1[0],p1[1],p2[0],p2[1]], {padding: 0}); 
                }
            } else {
               // Base map is CAD; CAD overlays CAD only via direct coords or four-parameter
               if (options.overlayType == "direct") {
                // Direct overlay by coordinates
                // Auto overlay
                wmsTileUrl = await this.getWmsTileUrl(options);
               } else {
                // Four-parameter overlay
                // Four-parameter overlay: left=default map, right=CAD to overlay
                if (!options.mapid) {
                    this.showMessage("Please set the mapid of the CAD map to overlay");
                    return;
                }
                let fp = await this.context.ui.getFourParamter({}, {
                    mapid: options.mapid,
                    version: options.version,
                    layer: options.layer
                });
                if (!fp) return;
                options.coordinates = [];
                for(let n = 0; n < fp.leftMarkers.length; n++) {
                    let leftMarker = fp.leftMarkers[n];
                    let rightMarker = fp.rightMarkers[n];
                    options.coordinates.push({
                        x1: leftMarker.x,
                        y1: leftMarker.y,
                        x2: rightMarker.x,
                        y2: rightMarker.y
                    });
                }
                options.overlayType = "param";
                options.overMapType = "CAD";
                options.isSetRotateZero = !fp.allowsRotation;
                wmsTileUrl = await this.getWmsTileUrl(options);
               }
            }
            if (!wmsTileUrl) return;
            // Create raster tile layer
            let layer = await this.createRasterLayer({
                tiles: wmsTileUrl,
            });
            return layer
          }
    

    /**
     * Convert CAD geometry coordinates to map pixel coordinates
     * @returns {[number, number]} Returns [x,y] array for map pixel coordinates
     * @example
      * // Convert CAD coords [1000, 2000] to map pixel coords
      * let pixel = this.geoPointToPixelPoint([1000, 2000]);
      * let x = pixel[0];
      * let y = pixel[1];
      * console.log(x, y);
     */
    geoPointToPixelPoint(pt)  {
            let lngLat = this.map.toLngLat(pt);
            let pixelPoint = this.map.project(lngLat);
            return [pixelPoint.x, pixelPoint.y];
        }
    

    /**
     * Switch to vector tile style
     * @example
      * // Switch to vector tile style
      * this.switchRasterToVectorStyle();
     */
    switchRasterToVectorStyle()  {
            // If map already uses vector tile style, skip switch
            if (this.map.hasVectorLayer()) {
                return
            }
            this.map.switchRasterToVectorStyle(this.map.getService().vectorTileUrl());
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.switchVectorToRasterStyle(this.map.getService().rasterTileUrl());
            });
        }
    
    

    /**
     * Get all Popup info windows
     * @param {string} id Popup id
     * @param {[number, number]} position Popup position [x, y]
     * @param {string} data Popup data
     * @returns {Array<Object>} Returns all Popups, each containing:
     * @example
      * // Get all Popups
      * let popups = this.getAllPopups();
      * for(let i = 0; i < popups.length; i++) {
      * let popup = popups[i];
      * let x = popup.position[0];
      * let y = popup.position[1];
      * console.log(popup.id, x, y, popup.data);
      * }
     */
    getAllPopups()  {
            let popups = this.context.global.popups || [];
            popups = popups.filter(m => m.id !== undefined);
            return popups.map(m => {
                let popup = m.popup;
                let point = this.map.fromLngLat(popup.getLngLat());
                return {
                    id: m.id,
                    position: [point.x, point.y],
                    data: popup.data
                }
            });
         }
    

    /**
     * Compare map differences
     * @param {Object} options Params
     * @param {string} options.mapid1 Map 1 ID. Optional, use current map if empty
     * @param {string} [options.version1] Map 1 version. Uses current map version if empty
     * @param {string} [options.layer1] Map 1 style layer. Optional, uses current map style if empty
     * @param {string} options.mapid2 Map 2 ID. Optional, uses current map if empty
     * @param {string} [options.version2] Map 2 version. Uses current map version if empty
     * @param {string} [options.layer2] Map 2 style layer. Optional, uses current map style if empty
     * @param {boolean} [options.noCompareNew] Do not compare added parts
     * @param {boolean} [options.noCompareDelete] Do not compare deleted parts
     * @param {number} [options.size] Compare size. Default 4096
     * @param {number} [options.cellsize] Cell size. Default 10
     * @param {number} [options.diffMinPixel] Min different pixels. Default 0
     * @param {number} [options.diffMinAlpha] Min different alpha. Default 25
     * @param {number} [options.diffMinColor] Min different color. Default 6
     * @returns {Promise<Object>} Returns compare result, undefined on failure, object with fields on success
     * @example
      * // Example
      * // Compare current map with sys_hello
      * let res = await this.findMapDiff({
      * mapid2: "world"
      * });
      * console.log(res);
     */
    async findMapDiff(options)  {
           options = options || {};
           let svc = this.map.getService();
           options.mapid1 = options.mapid1 || svc.currentMapParam().mapid;
           options.mapid2 = options.mapid2 || svc.currentMapParam().mapid;
           let res = await svc.cmdMapDiff(options);
           if (res && res.status) {
            const toGeoJSON = (points) => {
                let geojson = {
                    type: "FeatureCollection",
                    features: []
                };
                if (!points || points.length === 0) return geojson;
                points.forEach(p => {
                    if (p.length > 0) {
                        p.push(p[0]);
                    }
                    geojson.features.push({
                        type: "Feature",
                        geometry: {
                            type: "LineString",
                            coordinates: p
                        }
                    });
                }) ;// Close
                
                
                return geojson;
            }
            let result = {
                new: toGeoJSON(res.new),
                modify: toGeoJSON(res.modify),
                del: toGeoJSON(res.del),
                diff: toGeoJSON([...res.new, ...res.modify, ...res.del])
            };   
            return result;
           }
           return undefined;
       }
    

    /**
     * Set data layer properties
     * @param {string} layerName Layer name, required
     * @param {object} property Layer property object, required
     * @example
      * // Set data layer layer1 properties to {"minZoom":1,"maxZoom":5}
      * let layerName = 'layer1';
      * let property = {"minZoom":1,"maxZoom":5};
      * this.setDataLayerProperty(layerName, property);
     */
    setDataLayerProperty(layerName, property)  {
            let item = this.context.global.layers.find(layer => layer.layerName == layerName);
            if(item) {
               let oldProp = {...vjcommon.toMapLayer(item.layer, item.layer.mapLayer)};
               let layerId = item.layerId;
               if (item.layer && typeof(item.layer.getLayerId) == "function") {
                    layerId = item.layer.getLayerId();
               }
               item.layer.setLayerStyle(this.map, layerId, property, oldProp);
               // Add undo record
               this.context.ui.addUndoRecord(() => {
                   this.setDataLayerProperty(item.layerName, oldProp);
               });
            }
        }
    

    /**
     * Select entities in map
     * @param {object} options Entity selection options, optional
     * @param {boolean} options.useGeomCoord Use geometry coords or position coords. false=position, true=geometry. Optional, default true
     * @param {boolean} options.includeWholeEntity Select whole entity. true=whole, false=partial. Optional, default false
     * @param {boolean} options.isPointSel Point or box select. false=box, true=point. Optional, default false
     * @param {boolean} options.disableSnap Disable snap. Optional, default false
     * @param {number} options.maxSelectCount Max selection count. Optional, no limit by default
     * @returns {Object} GeoJSON FeatureCollection with features (geometry, properties)
     * @example
      * // Example 1
      * // Click to select entities on map
      * const geojson1 = await this.selectFeatures({
      * isPointSel: true
      * });
      * // Output selected entity objectids
      * let selectedObjectids = [];
      * for (let feature of geojson1.features) {
      * selectedObjectids.push(feature.properties.objectid);
      * }
      * console.log(selectedObjectids);
      * // Example 2
      * // Box select entities, use position coords, select whole entity
      * const geojson2 = await this.selectFeatures({
      * useGeomCoord: false,
      * includeWholeEntity: true,
      * isPointSel: false
      * });
      * // Output selection count
      * console.log(geojson2.features.length);
      * // Example 3
      * // Click select entities, whole entity, max 1
      * const geojson3 = await this.selectFeatures({
      * isPointSel: true,
      * includeWholeEntity: true,
      * maxSelectCount: 1
      * });
      * console.log(geojson3);
     */
    async selectFeatures(options)  {
            options = options || {};
            let features = await vjcommon.selectFeatures(this.map, options.useGeomCoord !== false, options.includeWholeEntity, options.isPointSel, options.disableSnap, undefined, options.maxSelectCount);
            return {
              features: features || [],
              type: "FeatureCollection"
            }
          }
    

    /**
     * Split sub-maps
     * @returns {Promise<string[]>} Returns fileid per sub-map (same order as clipBounds), undefined on failure
     * @example
      * // Auto extract sub-map bounds, split sub-maps
      * let res = await this.findChildMapRects();
      * if (!res) return;
      * let fileids = await this.splitChildMaps({
      * clipBounds: res.childMapBounds
      * });
      * console.log(fileids);
     */
    async splitChildMaps(options)  {
           options = options || {};
           let svc = this.map.getService();
           if (!options.clipBounds || (options.clipBounds && options.clipBounds.length == 0)) {
                if (this.map.logInfo) this.map.logInfo('Clip bounds is empty', "error", 10000);
                return;
           }
           for(let n = 0; n < options.clipBounds.length; n++) {
            if (Array.isArray(options.clipBounds[n]) && options.clipBounds[n].length == 4) {
                options.clipBounds[n] = options.clipBounds[n].join(',');
            }
           }
           let result = await svc.cmdSplitChildMaps({
            // cloneObjects: deep clone entities, fastest (default). cloneDb: clone DB, faster. cloneMap: clone map, slower but most accurate
            method: "cloneObjects",
            // Use new process (doesn't block main, slower start). Default true
            startNewProcess: true,
            // Whether split result is full map. Default false
            isFullExtent: true,
            ...options
           });
           if (result && result.error) {
               if (this.map.logInfo) this.map.logInfo(result.error, "error", 10000);
               return;
           }
           let fileids = result.fileids;
           if (fileids && fileids.length > 0) {
            for(let n = 0; n < fileids.length; n++) {
                // Remove file extension
                fileids[n] = fileids[n].split(".")[0];
            }
           }
           return fileids;
       }
    

    /**
     * Random points within map bounds
     * @param {number} count Point count, default 1
     * @param {number} boundsType Bounds type: 0=draw, 1=actual, 2=whole. Default 0, optional
     * @returns {[number, number][]} Returns [[x1,y1],[x2,y2],...]
     * @example
      * // Example 1
      * // Random 1 point in map bounds
      * let points = this.randomPointInMapBounds(1);
      * let x = points[0][0];
      * let y = points[0][1];
      * console.log(x, y);
      * // Example 2
      * // Random 10 points in actual map bounds
      * let points2 = this.randomPointInMapBounds(10, 1);
      * for (let i = 0; i < points2.length; i++) {
      * let x = points2[i][0];
      * let y = points2[i][1];
      * console.log(x, y);
      * }
     */
    randomPointInMapBounds(count, boundsType)  {
            let rect = this.getMapGeoBounds(boundsType);
            let geoBounds = vjmap.GeoBounds.fromArray(rect);
            let points = geoBounds.randomPoints(count, count);
            return points.map(p => [p.x, p.y]);
        }
    

    /**
     * Capture rotated rectangle coordinates
     * @param {boolean} isSnap Enable snap, default false
     * @returns {Promise<number[][] | undefined>} Returns [[x1,y1],[x2,y2],[x3,y3],[x4,y4]], undefined if cancelled
     * @example
      * const rect = await this.actionDrawSlantRectangle();
      * if (!rect) return;
      * console.log(rect); // Four points of rotated rectangle
     */
    async actionDrawSlantRectangle(isSnap)  {
            this.context.tools.logInfo("Please draw a rotated rectangle on the map")
            let snapObj = {};
            if (isSnap) {
                vjcommon.getMapSnapPoints(this.map, snapObj);
            }
            let drawRect = await vjmap.Draw.actionDrawSlantRectangle(this.map, isSnap ? {
                api: {
                    getSnapFeatures: snapObj
                }
            } : {});
            if (drawRect.cancel) {
                return;
            }
            let coords = drawRect.features[0].geometry.coordinates[0];
            return coords.map(c => {
                let p = this.map.fromLngLat(c);
                return [p.x, p.y];
            });
        }
    

    /**
     * Create arrow line data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @param {string} options.lineColor Line color, default #FF9900
     * @param {number} options.lineWidth Line width, default 10
     * @param {number} options.lineOpacity Line opacity 0-1
     * @param {number} options.lineGapWidth Line gap width
     * @param {number} options.lineOffset Line offset
     * @param {number} options.lineBlur Line blur
     * @param {string} options.strokeStyle Stroke: solid or dashed
     * @param {[number,number]} options.lineDasharray Dash array [1,1]
     * @param {[number,number]} options.lineTranslate Translate [x,y]
     * @param {string} options.lineCap Line cap: butt, round, square
     * @param {string} options.lineJoin Line join: bevel, round, miter
     * @param {string} options.strokeImage Line pattern image name
     * @param {string} options.lineGradient Line gradient
     * @param {boolean} options.showBorder Show border, default true
     * @param {string} options.borderColor Border color, default #f00
     * @param {number} options.borderWidth Border width
     * @param {number} options.borderOpacity Border opacity 0-1
     * @param {number} options.borderGapWidth Border gap width
     * @param {number} options.borderOffset Border offset
     * @param {number} options.borderBlur Border blur
     * @param {string} options.borderGradient Border gradient
     * @param {boolean} options.showDir Show arrow, default true
     * @param {number} options.dirSize Arrow size
     * @param {number} options.dirSpacing Arrow spacing
     * @param {number} options.dirImageWidth Arrow image width
     * @param {number} options.dirImageHeight Arrow image height
     * @param {string} options.dirImageColor Arrow image color
     * @param {string} options.dirIconColor Arrow icon color
     * @param {string} options.dirLayout Arrow layout
     * @param {string} options.cursor Cursor shape
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Random GeoJSON in map bounds, create arrow line layer layer5
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createArrowLineLayer(geojson, {
      * layerName: 'layer5'
      * });
      * console.log(layer);
     */
    async createArrowLineLayer(data, options)  {
            options = options || {};
            let type = "arrowLine";
        
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                lineWidth: 10,
                showDir: true,
                showBorder: true,
                borderColor: "#f00",
                lineColor: '#FF9900',
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createArrowLineLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Create polygon extrusion animation data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {string} options.fillExtrusionColor Extrusion color, optional, default "#00ffff"
     * @param {number} options.fillExtrusionOpacity Opacity, optional, 0-1
     * @param {number} options.fillExtrusionHeight Extrusion height, CAD units, optional, default 500000
     * @param {number} options.fillExtrusionBase Extrusion base height, CAD units, optional, default 0
     * @param {string} options.fillExtrusionPattern Extrusion pattern, sprite image name, width must be power of 2
     * @param {[number,number]} options.fillExtrusionTranslate Translate [x,y], negative=left/up, pixels, default [0,0]
     * @param {string} options.fillExtrusionTranslateAnchor "map" or "viewport"
     * @param {boolean} options.fillExtrusionVerticalGradient Apply vertical gradient, optional
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @param {number} options.anmiDuration Animation duration (sec), optional, default 2, 0=no animation
     * @param {number} options.anmiRepeat Animation repeat count, optional, 0=infinite
     * @param {string} options.anmiRepeatType "reverse", "loop", or "mirror"
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Random GeoJSON in map bounds, create polygon extrusion layer layer13
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createFillExtrusionsAnimateLayer(geojson, {
      * layerName: 'layer13'
      * });
      * console.log(layer);
     */
    async createFillExtrusionsAnimateLayer(data, options)  {
            options = options || {};
            let type = "fillExtrusionsAnimate";
        
            if(options.fillExtrusionHeight) {
              options.fillExtrusionHeight = this.map.geoLengthToHeight(options.fillExtrusionHeight);
            }
            if(options.fillExtrusionBase) {
              options.fillExtrusionBase = this.map.geoLengthToHeight(options.fillExtrusionBase);
            }
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                fillExtrusionColor: "#00ffff",
                fillExtrusionHeight: this.map.geoLengthToHeight(this.map.getGeoBounds().width() / 6),
                anmiDuration: 2,
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createFillExtrusionsAnimateLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Capture polygon coordinates
     * @param {boolean} isSnap Enable snap, default false
     * @returns {Promise<number[][] | undefined>} Returns [[x1,y1],[x2,y2],...], undefined if cancelled
     * @example
      * const polygon = await this.actionDrawPolygon();
      * if (!polygon) return;
      * // Output all polygon point coords
      * for (let i = 0; i < polygon.length; i++) {
      * let x = polygon[i][0];
      * let y = polygon[i][1];
      * console.log(x, y);
      * }
     */
    async actionDrawPolygon(isSnap)  {
            this.context.tools.logInfo("Please draw a polygon on the map")
            let snapObj = {};
            if (isSnap) {
                vjcommon.getMapSnapPoints(this.map, snapObj);
            }
            let drawPolygon = await vjmap.Draw.actionDrawPolygon(this.map, isSnap ? {
                api: {
                    getSnapFeatures: snapObj
                }
            } : {});
            if (drawPolygon.cancel) {
                return;
            }
            let coords = drawPolygon.features[0].geometry.coordinates[0];
            return coords.map(c => {
                let p = this.map.fromLngLat(c);
                return [p.x, p.y];
            });
        }
    

    /**
     * Set Marker properties
     * @param {string} id Marker id
     * @param {Record<string, any>} options Other params, optional
     * @param {[number, number]} options.position Coords [x, y], optional
     * @param {string} options.color Color, optional, default #3FB1CE
     * @param {any} options.popupContent Popup content, optional, empty=no popup
     * @param {boolean} options.popupShowInForm Whether popup content is displayed as form. Optional, default false
     * @param {number} options.popupWidth Popup width, optional
     * @param {number} options.popupHeight Popup height, optional
     * @param {boolean} options.popupOpen Whether popup is open, optional, default false
     * @param {boolean} options.draggable Draggable, optional, default false
     * @param {number} options.rotation Rotation (degrees), optional, default 0
     * @param {string} options.icon Custom icon name, optional
     * @param {any} options.data Data, optional
     * @param {number} options.elevation Elevation, optional, default 0
     * @param {boolean} options.visible Visible, optional, default true
     * @example
      * // Set marker_1: position [10,20], red, popup "Hello, World!", icon sensor, draggable, rotation 45
      * this.setMarkerProperty("marker_1", {
      * position: [10, 20],
      * color: "#FF0000",
      * popupContent: "Hello, World!",
      * icon: "sensor",
      * draggable: true,
      * rotation: 45,
      * data: {name: "Sensor", value: 100}
      * });
     */
    setMarkerProperty(id, options)  {
            let markers = this.map.getMarkers();
            let marker = markers.find(m => m.id == id);
            let oldOptions = {};
            if (marker) {
                if (options.position) {
                    let position = this.map.fromLngLat(marker.getLngLat());
                    oldOptions.position = [position.x, position.y];
                    marker.setLngLat(this.map.toLngLat(options.position));
                }
                if (options.color) {
                    oldOptions.color = marker._color;
                    marker.setColor(options.color);
                }
                if (options.data !== undefined) {
                    oldOptions.data = marker.data;
                    marker.data = options.data;
                }
                if (options.elevation) {
                    oldOptions.elevation = marker.getHeight();
                    marker.setHeight(options.elevation);
                }
                if (options.popupContent) {
                    let popup = marker.getPopup();
                    if (!popup) {
                        popup = new vjmap.Popup({
                            offset: options.popupOffset || [0, -40]
                        })
                        marker.setPopup(popup)
                    }
                    const dom = this.context.ui.createElementByJsonData(options.popupContent, options.popupShowInForm, {
                        width: options.popupWidth,
                        height: options.popupHeight
                    })
                    popup.setDOMContent(dom.element)
                }
                if (options.popupOpen !== undefined) {
                    let popup = marker.getPopup();
                    if (popup) {
                        if (popup.isOpen() && !options.popupOpen) {
                            marker.togglePopup()
                        } else if (!popup.isOpen() && options.popupOpen) {
                            marker.togglePopup()
                        }
                    }
                }
                if (options.icon) { 
                    // Manage custom icons in toolbar "Map Image Management"
                    let image = this.context.ui.getCustomImages(options.icon);
                    if (image) {
                        let el = marker.getElement();
                        if (el) {
                            el.className = 'marker';
                            let imgValue = image.value;
                            if (imgValue.toLowerCase().indexOf("<svg") == 0) {
                                // svg or base64 png
                                imgValue =  "data:image/svg+xml;base64," + window.btoa(unescape(encodeURIComponent(imgValue))) ;
                            }
                            el.style.backgroundImage =
                            `url("${imgValue}")`;
                            el.style.width = (image.width ?? 40) + 'px';
                            el.style.height = (image.height ?? 40) + 'px';
                            el.style.backgroundSize = '100%';
                        }
                    }
                }
                if (options.draggable) {
                    oldOptions.draggable = marker.isDraggable();
                    marker.setDraggable(options.draggable);
                }
                if (options.rotation) {
                    oldOptions.rotation = marker.getRotation();
                    marker.setRotation(options.rotation);
                }
                if (options.visible !== undefined) {
                    oldOptions.visible = marker.getElement().style.display !== 'none';
                    marker.setVisible(options.visible, true);
                }
               
            }
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.setMarkerProperty(id, oldOptions);
            });
            
         }
    

    /**
     * Get all Markers
     * @param {string} id Marker id
     * @param {[number, number]} position Marker position [x, y]
     * @param {string} data Marker data
     * @returns {Array<Object>} All Markers, each containing:
     * @example
      * // Get all Markers
      * let markers = this.getAllMarkers();
      * for(let i = 0; i < markers.length; i++) {
      * let marker = markers[i];
      * let x = marker.position[0];
      * let y = marker.position[1];
      * console.log(marker.id, x, y, marker.data);
      * }
     */
    getAllMarkers()  {
            let markers = this.map.getMarkers();
            markers = markers.filter(m => m.id !== undefined);
            return markers.map(m => {
                let point = this.map.fromLngLat(m.getLngLat());
                return {
                    id: m.id,
                    position: [point.x, point.y],
                    data: m.data
                }
            });
         }
    

    /**
     * Fit map to bounds
     * @param {Object} [options] Fit options
     * @param {boolean} [options.linear] true=Map#easeTo, false=Map#flyTo
     * @param {Function} [options.easing] Easing function
     * @param {Array<number>} [options.offset] Offset in pixels (default [0,0])
     * @param {number} [options.maxZoom] Max zoom when fitting
     * @param {boolean} [options.fitFillClip] true=fill and clip overflow
     * @param {Object} [eventData] Extra properties for triggered event object
     * @param {boolean} [options.isFlash] Use flash animation, default false
     * @example
      * // Example 1
      * // Fit to [0,0,10,10] with 10px padding
      * this.fitMapBounds([0,0,10,10], {padding: 10});
      * // Example 2
      * // Fit to full map
      * this.fitMapBounds();
     */
    fitMapBounds(bounds, options, eventData)  {
            options = options || {}
            if (Array.isArray(bounds)) {
                bounds = vjmap.GeoBounds.fromArray(bounds);
            }
            let oldBounds = this.map.fromLngLat(this.map.getBounds()); // Get current bounds
            this.map.fitMapBounds(bounds, options, eventData) // Fit to bounds
            if (options.isFlash === true) {
                __flashBounds(typeof bounds === 'number' ? this.map.getGeoBounds(bounds) : bounds);
            }
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.fitMapBounds(oldBounds);
            });
         }
    

    /**
     * Get user data
     * @returns {Object} GeoJSON FeatureCollection (Point, LineString, Polygon, etc.)
     * @example
      * // Get user data and output to session
      * let data = this.getUserData();
      * if (data) this.chatLog(JSON.stringify(data, null, 4));
     */
    getUserData()  {
            return this.context.userData;
        }
    
    

    /**
     * Switch CAD layer
     * @example
      * // Switch CAD layer
      * this.switchCadLayers();
     */
    async switchCadLayers()  {
            // Get default layer first
            let layers = this.map.getService().getMapLayers();
            let layerNames = layers.map(layer => layer.name);
            let openLayers = layers.filter(layer => layer.isOff === false).map(layer => layer.name);
            const getSelectLayers = (selectNames) => {
                let selectLayers = [];
                for (let layerName of layerNames) {
                    let isOpen = selectNames.includes(layerName);
                    if (isOpen) {
                        selectLayers.push({name: layerName, isOff: false});
                    } else {
                        selectLayers.push({name: layerName, isOff: true});
                    }
                }
                return selectLayers;
            }
            let isClickApply = false;
            let selectLayerNames = await this.selectOptionsValue(layerNames, {
                isMultiSelect: true,
                title: "Select Layer",
                initValue: openLayers,
                applyCallback: async (selectNames) => {
                    isClickApply = true;
                    // Switch layer on apply
                    await vjcommon.switchCadLayers(this.map, getSelectLayers(selectNames), this.map.hasVectorLayer());
                }
            });
            if (!selectLayerNames) {
                if (isClickApply) {
                    // On cancel after apply, revert to previous layer
                    await vjcommon.switchCadLayers(this.map, getSelectLayers(openLayers), this.map.hasVectorLayer());
                }
                return;
            }
            // Switch layer
            await vjcommon.switchCadLayers(this.map, getSelectLayers(selectLayerNames), this.map.hasVectorLayer());
            // Add undo record
            let oldLayers = getSelectLayers(openLayers);
            let isVector = this.map.hasVectorLayer();
            this.context.ui.addUndoRecord(() => {
                vjcommon.switchCadLayers(this.map, oldLayers, isVector);
            });
        }
    
    

    /**
     * Add a Marker
     * @param {[number, number]} pt Position [x, y]
     * @param {Record<string, any>} options Other params, optional
     * @param {string} options.color Color, optional, default #3FB1CE
     * @param {any} options.popupContent Popup content, optional, empty=no popup
     * @param {[number, number]} options.popupOffset Popup offset, optional, default [0, -40]
     * @param {boolean} options.popupShowInForm Popup as form. Optional, default false
     * @param {boolean} options.popupOpen Whether popup is open, optional, default false
     * @param {number} options.popupWidth Popup width, optional
     * @param {number} options.popupHeight Popup height, optional
     * @param {string} options.anchor 'center','top','bottom','left','right','top-left','top-right','bottom-left','bottom-right'. Default 'center'
     * @param {boolean} options.draggable Draggable, optional, default false
     * @param {number} options.rotation Rotation (degrees), optional, default 0
     * @param {string} options.icon Custom icon name, optional
     * @param {number} options.elevation Elevation, optional, default 0
     * @param {any} options.data Data, optional
     * @param {boolean} options.visible Visible, optional, default true
     * @returns {string} Returns id
     * @example
      * // Example 1
      * // Add red marker at [10,20]
      * let markerId = this.addMarker([10, 20], {
      * color: "#FF0000"
      * });
      * console.log(markerId);
      * // Example 2
      * // Add markers at point data positions, popup shows property values
      * let data = this.getUserData();
      * let features = data.features.filter(f => f.geometry.type == "Point");
      * features.forEach(f => {
      * this.addMarker(f.geometry.coordinates, {
      * popupContent: f.properties
      * });
      * })
      * // Example 3
      * // Random point in map, create draggable marker with sensor icon
      * let points = this.randomPointInMapBounds(1);
      * let x = points[0][0];
      * let y = points[0][1];
      * this.addMarker([x, y], {
      * draggable: true,
      * icon: "sensor"
      * });
     */
    addMarker(pt, options)  {
            options = options || {};
            let element;
            if (options.icon) { 
                // Manage custom icons in toolbar "Map Image Management"
                let image = this.context.ui.getCustomImages(options.icon);
                if (image) {
                    let el = document.createElement('div');
                    el.className = 'marker';
                    let imgValue = image.value;
                    if (imgValue.toLowerCase().indexOf("<svg") == 0) {
                        // svg or base64 png
                        imgValue =  "data:image/svg+xml;base64," + window.btoa(unescape(encodeURIComponent(imgValue))) ;
                    }
                    el.style.backgroundImage =
                    `url("${imgValue}")`;
                    el.style.width = (image.width ?? 40) + 'px';
                    el.style.height = (image.height ?? 40) + 'px';
                    el.style.backgroundSize = '100%';
                    element = el;
                }
            }
            let marker = new vjmap.Marker({
                color: options.color,
                anchor: options.anchor,
                draggable: options.draggable,
                rotation: options.rotation,
                element: element
            })
            marker.setLngLat(this.map.toLngLat(pt));
            marker.addTo(this.map);
            marker.id = "marker_" + vjmap.RandomID()
            if (options.data) marker.data = options.data;
            if (options.elevation) marker.setHeight(options.elevation);
    
            if (options.popupContent) {
                let popup = new vjmap.Popup({
                    offset: options.popupOffset || [0, -40]
                })
                const dom = this.context.ui.createElementByJsonData(options.popupContent, options.popupShowInForm, {
                    width: options.popupWidth,
                    height: options.popupHeight
                })
                popup.setDOMContent(dom.element)
                marker.setPopup(popup)
                if (options.popupOpen) {
                    marker.togglePopup()
                }
            }
            if (options.visible !== undefined) {
                marker.setVisible(options.visible, true);
            }
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteMarker(marker.id);
            });
            return marker.id
        }
    

    /**
     * Draw point symbol at coordinates
     * @param {[number, number]} point Symbol position [x, y], required
     * @param {object} options Config params, optional
     * @param {string} options.icon Icon name, default _markerIcon_
     * @param {number} options.iconSize Icon scale, default 1
     * @param {number} options.iconRotate Icon rotation (deg), default 0
     * @param {string} options.text Text content, default empty
     * @param {string} options.textColor Text color, optional
     * @param {number} options.textSize Text size, default 22
     * @param {number} options.textRotate Text rotation, default 0
     * @param {number} options.textOpacity Text opacity, default 0
     * @param {number} options.data Data value, optional
     * @returns {Promise<Object | undefined>} Drawn entity or undefined if cancelled. Object includes:
     * @example
      * // Example 1
      * // Draw point symbol at [10, 20]
      * const pt = this.createSymbol([10, 20]); // Draw point symbol with snap
      * if (!pt) return;
      * // Point coordinates
      * let point = pt.point;
      * const x = point[0];
      * const y = point[1];
      * console.log(x, y);
      * // Feature ID
      * let id = pt.id;
      * console.log(id);
      * // Example 2
      * // Random 1 point, draw symbol with _pulsingDot_ icon
      * let points = this.randomPointInMapBounds(1);
      * const pt2 = this.createSymbol(points[0], {
      * icon: "_pulsingDot_"
      * });
      * console.log(pt2);
      * // Example 3
      * // Random 10 points, draw symbols, text=coords, red
      * let points2 = this.randomPointInMapBounds(10);
      * for (let i = 0; i < points2.length; i++) {
      * let coord = points2[i];
      * this.createSymbol(coord, {
      * icon: "",
      * text: `[${coord[0].toFixed(2)}, ${coord[1].toFixed(2)}]`,
      * textColor: "#FF0000"
      * });
      * }
     */
    createSymbol(point, options)  {
            options = options || {};
            if (!point) return;
            let draw = this.map.getDrawLayer();
            let id = vjmap.RandomID(10)
            let icon = options.icon;
            if (icon === undefined) icon = "_markerIcon_";
            let onlyText = options.text && !icon;
            let symbolAttr = icon ? {
                "icon_image": icon, 
                "icon_size": options.iconSize || 1,
                "icon_rotate": options.iconRotate || 0,
            } : undefined;
    
            let textAttr = options.text ? {
                "text": options.text, 
                "text_size": options.textSize,
                "text_rotate": options.textRotate,
                "text_opacity": options.textOpacity,
                "color": options.textColor
            } : undefined
            draw.add({
                id,
                type: 'Feature',
                geometry: {
                    type: 'Point',
                    coordinates: this.map.toLngLat(point)
                },
                properties: {
                    "symbol": true,
                    "onlyText": onlyText,
                    "data": options.data,
                    ...symbolAttr, 
                    ...textAttr,
                }
            });
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.getDrawLayer().delete(id);
            });
            return {
                id,
                point
            };
        }
    

    /**
     * Show form input dialog
     * @param {object} formJsonSchema Form JSON Schema config (fields, types, validation)
     * @returns {object} User input object keyed by property names
     * @example
      * // Student form: name, age (default 18), gender
      * let formJsonSchema = {
      * type: "object",
      * title: "Student Info",
      * properties: {
      * name: { type: "string", title: "Name"},
      * age: { type: "number", title: "Age", default: 18},
      * gender: { type: "string", title: "Gender", enum: ["Male", "Female"]}
      * },
      * required: ["name", "age", "gender"]
      * }
      * let res = await this.showFormInputDialog(formJsonSchema);
      * let name = res.name;
      * let age = res.age;
      * let gender = res.gender;
      * console.log(name, age, gender)
     */
    async showFormInputDialog(formJsonSchema)  {
            return await this.context.ui.showFormInputDlg(formJsonSchema)
        }
    

    /**
     * Create glowing point data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {string} options.text Text to display, optional, default "index ${props.index}"
     * @param {number} options.minZoom Min zoom to display, optional, default 0
     * @param {number} options.maxZoom Max zoom to display, optional, default 24
     * @param {boolean} options.isDrawLinePoint Include line points when drawing, optional, default false
     * @param {number} options.width Pixel width, optional, default 24
     * @param {number} options.markerHeight Pixel height, optional
     * @param {string} options.color1 Color value 1, optional
     * @param {string} options.color2 Color value 2, optional
     * @param {number} options.textFontSize Font size, optional
     * @param {string} options.textColor Text color, optional
     * @param {boolean} options.draggable Draggable, optional, default false
     * @param {string} options.animationType Animation type, optional: MAP_ANIMATION_NONE, MAP_ANIMATION_BOUNCE, MAP_ANIMATION_DROP
     * @param {number} options.rotation Rotation angle, optional
     * @param {string} options.rotationAlignment Rotation alignment, optional: auto, map, viewport
     * @param {string} options.pitchAlignment Pitch alignment, optional: auto, map, viewport
     * @param {number} options.height Elevation value, optional
     * @param {number} options.scaleMaxZoom Max zoom for scaling, optional
     * @param {boolean} options.removeWhenNoInMapView Auto show/hide in view, optional
     * @param {number} options.removeWhenNoInMapViewPadding Padding for auto show/hide, optional, default 500
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Random GeoJSON in map bounds, create glowing point layer layer14
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createFluorescenceMarkerLayer(geojson, {
      * layerName: 'layer14'
      * });
      * console.log(layer);
     */
    async createFluorescenceMarkerLayer(data, options)  {
            options = options || {};
            let type = "fluorescenceMarker";
        
    
            if (options.height) {
              options.height = this.map.geoLengthToHeight(options.height);
            }
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                text: "ID:${props.index}",
                width: 20,
                markerHeight: 20,
                color1: "#ff0000",
                color2: "#ffff00",
                textFontSize: 15,
                textColor: "#00ffff",
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createFluorescenceMarkerLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Create glowing halo data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {string} options.text Text to display, optional, default "index ${props.index}"
     * @param {number} options.minZoom Min zoom to display, optional, default 0
     * @param {number} options.maxZoom Max zoom to display, optional, default 24
     * @param {boolean} options.isDrawLinePoint Include line points when drawing, optional, default false
     * @param {number} options.width Pixel width, optional, default 24
     * @param {number} options.markerHeight Pixel height, optional
     * @param {string} options.color1 Color value 1, optional
     * @param {string} options.color2 Color value 2, optional
     * @param {number} options.textFontSize Font size, optional
     * @param {string} options.textColor Text color, optional
     * @param {boolean} options.draggable Draggable, optional, default false
     * @param {string} options.animationType Animation type, optional: MAP_ANIMATION_NONE, MAP_ANIMATION_BOUNCE, MAP_ANIMATION_DROP
     * @param {number} options.rotation Rotation angle, optional
     * @param {string} options.rotationAlignment Rotation alignment, optional: auto, map, viewport
     * @param {string} options.pitchAlignment Pitch alignment, optional: auto, map, viewport
     * @param {number} options.height Elevation value, optional
     * @param {number} options.scaleMaxZoom Max zoom for scaling, optional
     * @param {boolean} options.removeWhenNoInMapView Auto show/hide in view, optional
     * @param {number} options.removeWhenNoInMapViewPadding Padding for auto show/hide, optional, default 500
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Random GeoJSON in map bounds, create glowing halo layer layer15
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createHaloRingMarkerLayer(geojson, {
      * layerName: 'layer15'
      * });
      * console.log(layer);
     */
    async createHaloRingMarkerLayer(data, options)  {
            options = options || {};
            let type = "haloRingMarker";
        
    
            if (options.height) {
              options.height = this.map.geoLengthToHeight(options.height);
            }
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                text: "ID:${props.index}",
                width: 40,
                markerHeight: 40,
                color1: "#ff0000",
                color2: "#ffff00",
                textFontSize: 15,
                textColor: "#00ffff",
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createHaloRingMarkerLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Get all data layers
     * @returns {Array<Object>} All data layers, each containing
     * @example
      * // Get all data layer names
      * let layers = this.getAllDataLayers();
      * let layerNames = layers.map(layer => layer.layerName);
      * console.log("All data layer names:", layerNames);
     */
    getAllDataLayers()  {
            let layers = this.context.global.layers;
            if(layers) {
                
                return layers.map(item => {
                    let layerId = item.layerId;
                    let sourceId = item.sourceId;
                    if (item.layer && typeof(item.layer.getLayerId) == "function") {
                        layerId = item.layer.getLayerId();
                        let style = this.map.getStyle();
                        let layer = style.layers.find(layer => layer.id == layerId);
                        if (layer && layer.source) {
                            sourceId = layer.source;
                        }
                    }
                    return {
                        layerId: layerId,
                        sourceId: sourceId,
                        visible: item.visible,
                        type: item.type,
                        layer: item.layer,
                        name: item.name
                    }
                });
            } else {
                return [];
            }
        }
    
    

    /**
     * Export to PDF
     * @param {Object} options PDF export options, optional
     * @param {string} options.mapid Map id, default current map
     * @param {string} options.version Map version, default current
     * @param {boolean} options.exportDrawLayer Export draw layer, default false
     * @param {boolean} options.bIncludeOffLayers Include off layers, default false
     * @param {boolean} options.bSearchableSHX Searchable SHX text, default false
     * @param {boolean} options.bSearchableTTF Searchable TTF text, default false
     * @param {number} options.pageWidth Page width (mm), default 210 (A4)
     * @param {number} options.pageHeight Page height (mm), default 297 (A4)
     * @param {number} options.pageLeftMargin Left margin (mm), default 0
     * @param {number} options.pageRightMargin Right margin (mm), default 0
     * @param {number} options.pageTopMargin Top margin (mm), default 0
     * @param {number} options.pageBottomMargin Bottom margin (mm), default 0
     * @param {number} options.geomDPI Vector DPI, default 600
     * @param {number} options.colorImagesDPI Image DPI, default 400
     * @param {boolean} options.isBlackWhiteMode Black/white mode, default false
     * @param {boolean} options.isGrayMode Gray mode, default false
     * @param {boolean} options.disableOpen Return URL only, don't open, default false
     * @example
      * // Example 1
      * // Export to PDF
      * await this.exportPdf();
      * // Example 2
      * // Export to PDF including draw layer
      * await this.exportPdf({exportDrawLayer: true});
      * // Example 3
      * // Export to PDF, mapid sys_world, gray mode
      * await this.exportPdf({mapid: "sys_world", isGrayMode: true});
     */
    async exportPdf(options)  {
            this.showMessage("Exporting PDF, please wait...", "info" );
            options = options || {};
            let mapid = options.mapid || this.map.getService().currentMapParam()?.mapid;
            let version = options.version || this.map.getService().currentMapParam()?.version;
            if (!options.mapid &&options.exportDrawLayer === true) {
              const res = await vjcommon.exportDwg(this.map, this.map.getDrawLayer());
              if (res.error) {
                  this.showMessage(res.error, "error" );
                  return;
              }
              mapid = res.mapid;
              version = res.version;
            }
    
            let param = options; // Use defaults if omitted
            /*
            param = {
                bIncludeOffLayers: false, // Include off layers
                bSearchableSHX: false, // Searchable SHX text
                bSearchableTTF: false, // Searchable TTF text
                pageWidth: 210, // Width (mm)
                pageHeight: 297, // Height (mm)
                pageLeftMargin: 0, // Left margin (mm)
                pageRightMargin: 0, // Right margin (mm)
                pageTopMargin: 0, // Top margin (mm)
                pageBottomMargin: 0, // Bottom margin (mm)
                geomDPI: 600, // Vector DPI
                colorImagesDPI: 400, // Image DPI
                isBlackWhiteMode: false, // Black/white mode
                isGrayMode: false, // Gray mode
            }*/
            let svc = this.map.getService();
            const result = await svc.execCommand("exportPdf", param, mapid, version, true);
            if (result.error) {
                this.map.logInfo(result.error, "error");
            } else {
                let pdfUrl = svc.baseUrl() + result.path + "?token=" + svc.accessToken;
                if (!options.disableOpen) {
                    window.open(pdfUrl )
                }
                return pdfUrl;
            }
          }
    

    /**
     * Show data create/edit dialog
     * @param {object} options Data panel config, optional
     * @param {any} options.data Data to edit, optional, empty=create
     * @param {string} options.title Data panel title, optional
     * @param {number} options.width Data panel width, optional
     * @param {number} options.height Data panel height, optional
     * @param {boolean} options.showForm Show as form, default false
     * @param {Function} options.applyCallback Callback on apply, optional
     * @returns {any} User input data, undefined if cancelled
     * @example
      * // Show create dialog, output data to console
      * let res = await this.showDataModifyDlg();
      * if (res) {
      * console.log(res)
      * }
      * // Get layer1 props, show edit dialog, set layer props on apply
      * let layerName = 'layer1';
      * let property = this.getDataLayerProperty(layerName);
      * let value = await this.showDataModifyDlg({
      * data: property,
      * title: "Layer Properties",
      * showForm: true,
      * applyCallback: (val) => {
      * this.setDataLayerProperty(layerName, val);
      * }
      * });
      * if (value) {
      * this.setDataLayerProperty(layerName, value);
      * }
     */
    async showDataModifyDlg(options)  {
            options = options || {};
            if (!options.data) {
                // For create, show JSON input
                options.showForm = false;
            }
            return await this.context.ui.showDataModifyDlg(options.data, options.showForm, options);   
         }
    

    /**
     * Capture circle coordinates
     * @param {boolean} isSnap Enable snap, default false
     * @returns {Promise<Object | undefined>} Center and radius, undefined if cancelled
     * @example
      * const circle = await this.actionDrawCircle();
      * if (!circle) return;
      * // Center coords
      * const centerX = circle.center[0];
      * const centerY = circle.center[1];
      * // Radius
      * const radius = circle.radius;
      * console.log(centerX, centerY, radius);
     */
    async actionDrawCircle(isSnap)  {
            this.context.tools.logInfo("Please draw a circle on the map")
            let snapObj = {};
            if (isSnap) {
                vjcommon.getMapSnapPoints(this.map, snapObj);
            }
            let drawCircle = await vjmap.Draw.actionDrawCircle(this.map, isSnap ? {
                api: {
                    getSnapFeatures: snapObj
                }
            } : {});
            if (drawCircle.cancel) {
                return;
            }
            let center = this.map.fromLngLat(drawCircle.features[0].properties.center);
            let circlePoint = this.map.fromLngLat(drawCircle.features[0].geometry.coordinates[0][0]);
            let radius = center.distanceTo(circlePoint);
            return {
                center: [center.x, center.y],
                radius
            };
        }
    

    /**
     * Process geometry to create new map
     * @param {object | object[]} options Options, object or array of objects
     * @param {string} [options.mapid] Map ID, optional, uses current if empty
     * @param {string} [options.version] Map version, uses current when empty. Optional
     * @param {[number, number, number, number]} [options.clipbounds] Clip bounds [x1,y1,x2,y2], optional, no clip if empty
     * @param {boolean} [options.selByCrossing] false=contain, true=intersect. Optional
     * @param {[number, number, number, number]} [options.fourParameter] Four params (dx, dy, scale, rotate rad). Optional
     * @param {boolean} [options.isInverseFourParamter] Inverse four params, default false
     * @param {string[]} [options.layers] Layer names to show, empty=all
     * @param {boolean} [options.purge] Purge after to reduce dwg size
     * @param {boolean} [options.isZoomExtents] Zoom to extent after create, default false
     * @param {string | "*" | "cad2000" | "cad2004" | "cad2007" | "cad2010" | "cad2013" | "cad2018"} [options.cadVersion] CAD version, * = current
     * @returns {Promise<string|undefined>} Generated fileid, undefined on failure
     * @example
      * // Example 1
      * // Create new map from geometry, layers ["0"]
      * let res = await this.composeNewMap({
      * layers: ["0"]
      * });
      * if (res) {
      * this.showMessage("Map created, fileid: " + res, "success");
      * }
      * // Example 2
      * // Combine sys_hello and sys_world maps
      * let res2 = await this.composeNewMap([{
      * mapid: "sys_hello"
      * }, {
      * mapid: "sys_world"
      * }]);
      * if (res2) {
      * this.showMessage("Map created, fileid: " + res2, "success");
      * }
     */
    async composeNewMap(options)  {
            let svc = this.map.getService();
            options = options || {};
            let currentMap = svc.currentMapParam() || {};
            if (Array.isArray(options)) {
               for(let opt of options) {
                if (!opt.mapid) {
                    opt.mapid = currentMap.mapid;
                }
               }
            } else {
                if (!options.mapid) {
                    options.mapid = currentMap.mapid;
                }
            }
            let res = await svc.composeNewMap(options);
            if (res && res.error) {
                this.showMessage(res.error)
                return;
            }
            return res.fileid;
        }
    

    /**
     * Get temp data
     * @param {string} name Data name
     * @returns {any} Data content
     * @example
      * // Get temp data "test"
      * this.getTempData("test");
     */
    getTempData(name)  {
            return this.context.resultData[name]    
        }
    

    /**
     * Create animate line data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @param {string} options.lineColor Line color
     * @param {number} options.lineWidth Line width
     * @param {number} options.lineOpacity Line opacity, 0-1
     * @param {number} options.lineGapWidth Line gap width (px), default 1
     * @param {number} options.lineOffset Line offset (px), + right/inward, - left/outward
     * @param {number} options.lineBlur Line blur, should be less than width
     * @param {[number,number]} options.lineDasharray Dash array, e.g. [1,1]
     * @param {[number,number]} options.lineTranslate Translate [x,y], negative=left/up, default [0,0]
     * @param {string} options.lineCap Line cap: "butt", "round", "square"
     * @param {string} options.lineJoin Line join: "bevel", "round", "miter"
     * @param {string} options.linePattern Line pattern image, must match loaded image, width power of 2
     * @param {string} options.lineGradient Line gradient
     * @param {string} options.animateImagesType Animation type: "arrow", "antpath", "customImage", "imageSprites"
     * @param {string} options.animateImages_arrowFillColor Arrow fill color when animateImagesType is "arrow", default "#22B14C"
     * @param {string} options.animateImages_arrowStrokeColor Arrow stroke color when animateImagesType is "arrow", default "#fff"
     * @param {number} options.animateImages_arrowStrokeWidth Arrow stroke width when animateImagesType is "arrow"
     * @param {number} options.animateImages_arrowWidth Arrow size when animateImagesType is "arrow"
     * @param {string} options.animateImages_fillColor1 Ant path fill color 1, for "antpath"
     * @param {string} options.animateImages_fillColor2 Ant path fill color 2, for "antpath"
     * @param {string} options.customImageUrl Custom image URL/base64, for "customImage"
     * @param {string} options.imageSpriteUrl Image sprite URL/base64, for "imageSprites"
     * @param {number} options.animateImages_spriteWidth Sprite width in source, for "imageSprites"
     * @param {number} options.animateImages_spriteHeight Sprite height in source, for "imageSprites"
     * @param {number} options.animateImages_canvasWidth Canvas width
     * @param {number} options.animateImages_canvasHeight Canvas height
     * @param {number} options.animateImages_frameCount Frame count
     * @param {boolean} options.animateImages_directionReverse Reverse animation direction
     * @param {number} options.speed Animation speed, step 0.1
     * @param {boolean} options.startAutoAnimation Auto-play on start
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Random GeoJSON, create animate line layer layer3
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createAnimateLineLayer(geojson, {
      * layerName: 'layer3'
      * });
      * console.log(layer);
     */
    async createAnimateLineLayer(data, options)  {
            options = options || {};
            let type = "animateLine";
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                lineColor: '#ff0000',
                lineWidth: 10,
                animateImagesType: "arrow",
                animateImages_arrowFillColor: "#22B14C",
                animateImages_arrowStrokeColor: "#fff",
                animateImages_arrowStrokeWidth: 6,
                animateImages_canvasWidth: 128,
                animateImages_canvasHeight: 32,
                animateImages_arrowWidth: 16,
                animateImages_frameCount: 4,
                animateImages_fillColor1: "#f0fb",
                animateImages_fillColor2: "#0ffb",
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createAnimateLineLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Create polygon extrusion data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {string} options.fillExtrusionColor Extrusion color, optional, default "#00ffff"
     * @param {number} options.fillExtrusionOpacity Opacity, optional, 0-1
     * @param {number} options.fillExtrusionHeight Extrusion height (CAD units), optional, default 0
     * @param {number} options.fillExtrusionBase Extrusion base height, CAD units, optional, default 0
     * @param {string} options.fillExtrusionPattern Extrusion pattern, sprite image name, width must be power of 2
     * @param {[number,number]} options.fillExtrusionTranslate Translate [x,y], negative=left/up, pixels, default [0,0]
     * @param {string} options.fillExtrusionTranslateAnchor "map" or "viewport"
     * @param {boolean} options.fillExtrusionVerticalGradient Apply vertical gradient, optional
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Random GeoJSON, create polygon extrusion layer layer12
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createFillExtrusionLayer(geojson, {
      * layerName: 'layer12'
      * });
      * console.log(layer);
     */
    async createFillExtrusionLayer(data, options)  {
            options = options || {};
            let type = "fillExtrusion";
        
    
            if(options.fillExtrusionHeight) {
              options.fillExtrusionHeight = this.map.geoLengthToHeight(options.fillExtrusionHeight);
            }
            if(options.fillExtrusionBase) {
              options.fillExtrusionBase = this.map.geoLengthToHeight(options.fillExtrusionBase);
            }
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                fillExtrusionColor: "#00ffff",
                fillExtrusionHeight: this.map.geoLengthToHeight(this.map.getGeoBounds().width() / 6),
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createFillExtrusionLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Output content to session window
     * @param {string} content Content, supports markdown
     * @example
      * // Output "123" to session window
      * this.chatLog("123");
     */
    chatLog(content)  {
            return this.context.ui.chatLog(content) 
        }
    

    /**
     * Create draw data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {boolean} options.entColorToHtmlColor Convert color to HTML color, optional
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Random GeoJSON in map bounds, create draw data layer layer10
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createDrawDataLayer(geojson, {
      * layerName: 'layer10',
      * });
      * console.log(layer);
     */
    async createDrawDataLayer(data, options)  {
            options = options || {};
            let type = "draw";
      
            if  (options.entColorToHtmlColor === undefined) {
              for(let f of data.features) {
                if (f.properties && typeof f.properties.color == "number") {
                    if (f.properties.color < 0) {
                        options.entColorToHtmlColor = true;
                        break;
                      }
                  }
              }
            }
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createDrawDataLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Export layout space
     * @param {object} options Options
     * @param {string} options.mapid Map ID, optional, uses current if empty
     * @param {string} [options.version] Map version, uses current when empty
     * @param {string} [options.layoutIndex] Layout index (layoutIndex or layoutName, index preferred). Empty=current layout
     * @param {string} [options.layoutName] Layout name (layoutIndex or layoutName, index preferred). Empty=current layout
     * @returns {Promise<string>} Exported fileid, empty string on failure
     * @example
      * // Export current layout, layout index 0
      * let fileid = await this.exportLayout({
      * layoutIndex: "0"
      * });
      * console.log(fileid);
     */
    async exportLayout(options)  {
           let svc = this.map.getService();
           options = options || {};
           options.mapid = options.mapid || svc.currentMapParam().mapid;
           let res = await svc.cmdExportLayout(options);
           if (res && res.fileid) {
               return res.fileid;
           }
           return "";
       }
    

    /**
     * Add a menu item and handler
     * @param {string} name Menu name
     * @param {Function} func Handler function
     * @example
      * // Add menu "Fit to full map", zoom to full map
      * this.addMenuItem("Full Map", () => {
      * // Fit to full map
      * this.fitMapBounds();
      * })
     */
    addMenuItem(name, func)  {
           // Added menu items show in map context menu by default
           this.context.ui.addMenuItem(name, func);   
           // Add undo record
           this.context.ui.addUndoRecord(() => {
                this.removeMenuItem(name);
            });
        }
    

    /**
     * Query map data
     * @param {string} sql SQL, table name geom, structure as below
     * @returns {Object} GeoJSON FeatureCollection with features (geometry, properties)
     * @example
      * // Example 1
      * // Query line type, layer index 2
      * let result1 = await  this.queryMapData("SELECT * FROM geom WHERE name = 'AcDbLine' AND layerIndex = 2");
      * if (result1.features.length > 0) {
      * let feature = result1.features[0];
      * // Get line width
      * console.log(feature.properties.lineWidth)
      * // Get all Point coords, draw Marker at each
      * const addMarker = (co) => {
      * // co format [x, y]
      * this.addMarker(co)
      * }
      * const processGeoJSONFeature = (geometry) => {
      * if (geometry.type === "Point") {
      * let co = geometry.coordinates;
      * addMarker(co)
      * } else if (geometry.type === "MultiPoint") {
      * geometry.coordinates.forEach(co => addMarker(co))
      * } else if (geometry.type === "LineString") {
      * geometry.coordinates.forEach(co => addMarker(co))
      * } else if (geometry.type === "MultiLineString") {
      * geometry.coordinates.forEach(line => {
      * line.forEach(co => addMarker(co))
      * })
      * } else if (geometry.type === "Polygon") {
      * geometry.coordinates.forEach(line => {
      * line.forEach(co => addMarker(co))
      * })
      * } else if (geometry.type === "MultiPolygon") {
      * geometry.coordinates.forEach(polygon => {
      * polygon.forEach(line => {
      * line.forEach(co => addMarker(co))
      * })
      * })
      * }
      * }
      * if (feature.geometry.type === "GeometryCollection") {
      * feature.geometry.geometries.forEach(ft => {
      * processGeoJSONFeature(ft)
      * })
      * } else {
      * processGeoJSONFeature(feature.geometry)
      * }
      * }
      * // Example 2
      * // Query all text, geometry coords
      * let result2 = await this.queryMapData("SELECT * FROM geom WHERE (name = 'AcDbText' or name = 'AcDbMText' or name = 'AcDbAttributeDefinition' or name = 'AcDbAttribute')  and coordType = true");
      * if (result2.features.length > 0) {
      * result2.features.forEach(feature => {
      * // Get text content
      * console.log(feature.properties.content)
      * });
      * }
      * // Query lines in rect [10,10,20,20], position coords
      * let result3 = await this.queryMapData("SELECT * FROM geom WHERE  (name = 'AcDbLine' or name = 'AcDbPolyline' or name = 'AcDb2dPolyline' or name = 'AcDb3dPolyline') and bounds ='[10, 10, 20, 20]' and isContains = true  and coordType = false");
      * // Get total record count
      * console.log(result3.features.length)
     */
    queryMapData(sql)  {
            return this.context.tools.queryMapData(this.context, sql)
        }
    

    /**
     * Auto extract sub-map bounds
     * @returns {Promise<Object>} undefined on failure, else GeoJSON and bounds array
     * @example
      * // Auto extract sub-map bounds
      * let res = await this.findChildMapRects();
      * console.log(res);
     */
    async findChildMapRects(options)  {
            options = options || {};
           let svc = this.map.getService();
           options = {
            mapid: options.mapid || svc.currentMapParam().mapid,
            bounds: "",
            condition: "",
            debugInfo: [],
            digit: 4,
            tol: 0,
            tableEdgeMinPoint: 12,
            tableTextMinCount: 4,
            cellMaxArea: 90,
            cellEmptyRatio: 90,
            unLinkLineRatio: 20,
            tableMaxCellCount: 100000,
            noHvLineSegCount: 0,
            seachTableMost: false,
            version:  svc.currentMapParam()?.version,
            layer: svc.currentMapParam()?.layer,
            // Use expression query for memory, spatial query for geometry render
            geom: svc.currentMapParam()?.mapopenway != vjmap.MapOpenWay.Memory,
            ...options,
           };
           if (options.isSelectBounds) {
                // For manual mode, select area first
                if (this.map.logInfo) this.map.logInfo("Please select rectangular area covering the table", "success", 10000);
                 // Draw rectangle on map as clip region
                let snapObj = {}; // Snap entities
                vjcommon.getMapSnapPoints(this.map, snapObj);
                let res = await vjmap.Draw.actionDrawRectangle(this.map, {
                    api: {
                        getSnapFeatures: snapObj // Snap features assigned via features property
                    },
                    contextMenu: (e) => {
                        // Send Enter to map
                        this.map.fire("keyup", {keyCode:13})
                    }
                });
                if (res.cancel) {
                    return // Cancelled
                }
                // Get drawn coordinates
                let rectCoord = this.map.fromLngLat(res.features[0].geometry.coordinates[0]);
                // Get bottom-left and bottom-right points
                let  bounds = [rectCoord[0].x, rectCoord[0].y, rectCoord[2].x, rectCoord[2].y];
                options.bounds = bounds.join(',');
           }
           if (Array.isArray(options.bounds)) {
            options.bounds = JSON.stringify(options.bounds);
           }
           if (options.showSettingForm === true) {
            options = await this.showDataModifyDlg({
                data: options,
                title: "Auto-extract table settings",
                showForm: true
            })
            if (!options) return;
           }
           // Get sub-map bounds only
           options.findChildMapRects = true;
           let result = await svc.cmdExtractTale(options);
           if (result && result.error) {
                if (this.map.logInfo) this.map.logInfo(result.error, "error", 10000);
                return;
           }
           if (!result.childMapRects) return;
           let geoDatas = {
                "type": "FeatureCollection",
                "features": []
            }
           let childMapBounds = [];
           for (let i = 0; i < result.childMapRects.length; i++) {
               let bounds = vjmap.GeoBounds.fromArray(result.childMapRects[i].bounds);
               childMapBounds.push(bounds.toString())
               let points = bounds.toPointArray();
               points.push(points[0]); // Close
               geoDatas.features.push({
                   type: "Feature",
                   geometry: {
                       type: "Polygon",
                       coordinates: [points]
                   },
                   properties: {
                       color: vjmap.randomColor(),
                       index: i,
                       data: result.childMapRects[i]
                   }
               });
           }
           return {
            geojson: geoDatas,
            childMapBounds: childMapBounds
           }
    
        }
    

    /**
     * Draw circle by center and radius
     * @param {[number, number]} center Center [x,y], required
     * @param {number} radius Radius, required
     * @param {object} options Config params, optional
     * @param {string} options.color Color #RRGGBB, optional
     * @param {boolean} options.isFill Whether to fill. Optional, default false
     * @param {number} options.opacity Opacity. Optional, default 1
     * @param {number} options.data Data value, optional
     * @returns {Object} Returns drawn feature object with properties:
     * @example
      * // Random circle in map, radius 1/40 map width, random color, fill, opacity 0.5
      * let points = this.randomPointInMapBounds(1);
      * let rect = this.getMapGeoBounds(); // Get map bounds
      * let width = (rect[2] - rect[0]) / 40; // Width 1/40 of map
      * const circle = this.createCircle(points[0], width, {
      * color: vjmap.randomColor(),
      * isFill: true,
      * opacity: 0.5
      * });
      * console.log(`Circle ID:`, circle.id);
      * console.log(`Circle center:`, circle.center);
      * console.log(`Circle radius:`, circle.radius);
     */
    createCircle(center, radius, options)  {
            let draw = this.map.getDrawLayer();
            let coords = vjmap.getCirclePolygonCoordinates(center, radius);
            coords = coords.map(c => this.map.toLngLat(c));
            let id = vjmap.RandomID(10);
            draw.add({
                id,
                type: 'Feature',
                geometry: {
                    type: options.isFill ? 'Polygon' : 'LineString',
                    coordinates: options.isFill ? [coords] : coords
                },
                properties: {
                    color: options.color,
                    opacity: options.opacity,
                    data: options.data
                }
            });
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.map.getDrawLayer().delete(id);
            });
            return {
                id,
                center,
                radius
            };
        }
    

    /**
     * Convert map lng/lat to CAD geometry coordinates
     * @returns {[number, number]} [x,y] CAD geometry coordinates
     * @example
      * // Convert lng/lat [10, 20] to CAD geometry coords
      * let point = this.lngLatToGeoPoint([10, 20]);
      * let x = point[0];
      * let y = point[1];
      * console.log(x, y);
     */
    lngLatToGeoPoint(lngLat)  {
            let geoPoint = this.map.fromLngLat(lngLat);
            return [geoPoint.x, geoPoint.y];
        }
    

    /**
     * Flash by bounds
     * @param {[number,number,number,number]} bounds Bounds
     * @param {Object} options Config params, optional
     * @param {string} options.color Color #RRGGBB, default #FFFF00, optional
     * @param {number} options.width Line width, default 3, optional
     * @param {number} options.duration Animation duration (ms), default 1000, optional
     * @example
      * this.flashBounds([0,0,10,10]);
     */
    flashBounds(bounds, options)  {
            options = options || {};
            return new Promise((resolve => {
               if (Array.isArray(bounds) && bounds.length === 4 && typeof bounds[0] === "number") {
                    bounds = vjmap.GeoBounds.fromArray(bounds);
                } else {
                    bounds = vjmap.GeoBounds.fromDataExtent(bounds);
                }
                const routePath = [];
                routePath.push(bounds.min);
                routePath.push(vjmap.geoPoint([bounds.max.x, bounds.min.y]));
                routePath.push(bounds.max);
                routePath.push(vjmap.geoPoint([bounds.min.x, bounds.max.y]));
                routePath.push(bounds.min);
                let geoLineDatas = [];
                geoLineDatas.push({
                    points: this.map.toLngLat(routePath),
                    properties: {
                        opacity: 1.0
                    }
                })
    
                let polylines = new vjmap.Polyline({
                    data: geoLineDatas,
                    lineColor: options.color || 'yellow',
                    lineWidth: options.width || 3,
                    lineOpacity: ['get', 'opacity'],
                    isHoverPointer: false,
                    isHoverFeatureState: false
                });
                polylines.addTo(this.map);
    
                vjmap.createAnimation({
                    from: 1,
                    to: 10,
                    duration: options.duration || 1000,
                    onUpdate: (e) => {
                        const data = polylines.getData();
                        data.features[0].properties.opacity = parseInt(e) % 2 ? 1.0 : 0 ;
                        polylines.setData(data);
                    },
                    onStop: () => {
                        polylines.remove()
                        resolve({})
                    },
                    onComplete: () => {
                        polylines.remove()
                        resolve({})
                    }
                })
            }))
        }
    

    /**
     * /**
     * @returns {Object} Object with Shift, Ctrl, Alt key pressed (boolean)
     * @example
      * // Example 1
      * // Is Shift pressed
      * let key = this.isShiftCtrlAltKeyPressed();
      * if (key.shift) {
      * console.log("Shift pressed");
      * }
      * // Example 2
      * // On map click, if Ctrl pressed get CAD coords and show data dialog "Click position"
      * this.listenMapEvent("click", async ev => {
      * // Check if Ctrl pressed
      * let key = this.isShiftCtrlAltKeyPressed();
      * if (key.ctrl) {
      * if (ev && ev.point) {
      * // Get map pixel coords, convert to CAD geo coords
      * const point =  this.pixelPointToGeoPoint(ev.point);
      * // Show data dialog at position, title "Click position", form display
      * await this.showDataInfoDialog(point, true, "Click position");
      * }
      * }
      * }, true);
     */
    isShiftCtrlAltKeyPressed()  {
            let shift = false, ctrl = false, alt = false;
            if (this.context.global.__listenKeyEvent) {
                shift = this.context.global.__listenKeyEvent.isShiftKey();
                ctrl = this.context.global.__listenKeyEvent.isCtrlKey();
                alt = this.context.global.__listenKeyEvent.isAltKey();
            }
            return {
                shift: shift,
                ctrl: ctrl,
                alt: alt,
            };
        }
    

    /**
     * Enable or disable map rotation, pitch, wheel zoom, pan, double-click zoom
     * @param {boolean} options.rotation Allow rotation, optional
     * @param {boolean} options.pitch Allow pitch, optional
     * @param {boolean} options.zoom Allow wheel zoom, optional
     * @param {boolean} options.pan Allow pan, optional
     * @param {boolean} options.dblclickZoom Allow double-click zoom, optional
     * @example
      * // Disable rotation, pitch, dblclick zoom; allow wheel zoom
      * this.setMapInteraction({rotation: false, pitch: false, zoom: true, dblclickZoom: false});
     */
    setMapInteraction(options)  {
            options = options || {};
            if (options.rotation === true) {
                this.map.dragRotate.enable();
                this.map.touchZoomRotate.enable(); 
                this.map.keyboard.enableRotation();
            } else if (options.rotation === false) {
                this.map.dragRotate.disable();
                this.map.touchZoomRotate.disable();
                this.map.keyboard.disable();
            }
    
            if (options.pitch === true) {
                this.map.setMinPitch(this.map.getPitch());
                this.map.setMaxPitch(this.map.getPitch());
            } else if (options.pitch === false) {
                this.map.setMinPitch(0);
                this.map.setMaxPitch(90);
            }
    
            if (options.zoom === true) {
                this.map.scrollZoom.enable();
                this.map.touchZoomRotate.enable();
            } else if (options.rotation === false) {
                this.map.scrollZoom.disable();
                this.map.touchZoomRotate.disable();
            }
    
            if (options.dblclickZoom === true) {
                this.map.doubleClickZoom.enable();
            } else if (options.dblclickZoom === false) {
                this.map.doubleClickZoom.disable();
            }
    
            if (options.pan === true) {
                this.map.dragPan.enable();
                this.map.touchZoomRotate.enable();
            } else if (options.pan === false) {
                this.map.dragPan.disable();
                this.map.touchZoomRotate.disable();
            }
        }
    

    /**
     * Create div overlay layer
     * @param {[number,number,number,number] | [[number,number],[number,number]] | [[number,number],[number,number],[number,number],[number,number]]} data Bounds: [x1,y1,x2,y2], [[x1,y1],[x2,y2]], or 4 corners
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {number} options.width Div width (px)
     * @param {number} options.height Div height (px)
     * @param {string} options.customImage Image name
     * @param {string} options.customHtml JS code for custom HTML, returns DOM element
     * @param {number} options.minZoom Min zoom 0-24
     * @param {number} options.maxZoom Max zoom 0-24
     * @param {number} options.maxPitch Max pitch 0-90
     * @param {boolean} options.updateDivSize Auto update div size, useful for SVG zoom
     * @param {number} options.maxDivSize Max div size (px), beyond uses pixel scale
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Capture rect, create div overlay layer30, image picture1
      * const rect = await this.actionDrawRectangle();
      * if (!rect) return;
      * let layer = await this.createDivOverlayLayer(rect,{
      * layerName: 'layer30',
      * customImage: 'picture1'
      * });
      * console.log(layer);
     */
    async createDivOverlayLayer(data, options)  {
            options = options || {};
            let type = "divOverlay";
    
            let randomId = vjmap.RandomID();
            let layerId =  `layer_${type}_${randomId}`;
    
            options.bounds = data;
            if (options.customImage) {
                let image = this.context.ui.getCustomImages(options.customImage);
                if (image) {
                    options.customImage = image.value;
                    options.width = image.width;
                    options.height = image.height;
                }
            }
            if (Array.isArray(options.bounds) && options.bounds.length == 4 && typeof options.bounds[0] == 'number') {
                options.bounds = vjmap.GeoBounds.fromArray(options.bounds).toPointArray();
            }
            let layerOptions = {
                layerId,
                ...options,
                type: type
            }
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId: undefined, layerName, data: undefined, options, layer, visible: true, create: this.createDivOverlayLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId: undefined,
                layerId,
                layerName
            };
        }
    

    /**
     * Manually draw a circle
     * @param {object} options Config params, optional
     * @param {boolean} options.isSnap Enable snap, optional, default false
     * @param {string} options.color Color #RRGGBB, optional
     * @param {boolean} options.isFill Whether to fill. Optional, default false
     * @param {number} options.opacity Opacity. Optional, default 1
     * @param {number} options.data Data value, optional
     * @returns {Promise<Object | undefined>} Drawn entity or undefined if cancelled. Object includes:
     * @example
      * // Draw a circle manually
      * const circle = await this.drawCircle({
      * isSnap: true,
      * color: "#FF0000",
      * isFill: true,
      * opacity: 0.8
      * });
      * if (!circle) return;
      * console.log("Circle ID:", circle.id);
      * console.log("Circle center:", circle.center);
      * console.log("Circle radius:", circle.radius);
     */
    async drawCircle(options)  {
            let circle = await this.actionDrawCircle(options.isSnap);
            if (!circle) return;
            return this.createCircle(circle.center, circle.radius, options);
        }
    

    /**
     * Export image for given bounds
     * @param {[number, number, number, number]} bounds [minx, miny, maxx, maxy]
     * @param {object} options Export options, optional
     * @param {number} options.pictureWidth Image width (px), default 1024
     * @param {number} options.scale Scale, default 1.01
     * @param {boolean} options.transparent Transparent background, default false
     * @param {string} options.backgroundColor Background color, default 'rgb(0,0,0)'
     * @param {boolean} options.disableOpen Return URL only, don't open, default false
     * @example
      * // Export map bounds [100,200,300,400] as PNG
      * this.exportPng([100, 200, 300, 400]);
     */
    exportPng(bounds, options)  {
            options = options || {};
            if (Array.isArray(bounds) && bounds.length === 4 && typeof bounds[0] === 'number') {
                bounds = vjmap.GeoBounds.fromArray(bounds);
            } else {
               bounds = vjmap.GeoBounds.fromDataExtent(bounds);
            }
            bounds = bounds.scale(options.scale || 1.01); // Slightly larger so border is visible
            let pictureWidth = options.pictureWidth || 1024;
            let svc = this.map.getService();
            let wmsUrl = svc.wmsTileUrl({
                width: pictureWidth,
                height: Math.round(pictureWidth * bounds.height() / bounds.width()),
                srs: "",
                bbox: bounds.toString(),
                transparent: options.transparent || false,
                backgroundColor: options.backgroundColor || 'rgb(0,0,0)'
            });
            if (!options.disableOpen) {
              window.open(wmsUrl);
            }
            return wmsUrl;
        }
    

    /**
     * Remove minimap (overview) control
     * @example
      * // Remove minimap control
      * this.removeMiniMapControl();
     */
    removeMiniMapControl()  {
            this.context.global.controls = this.context.global.controls || {};
            let options;
            if (this.context.global.controls.miniMapControl) {
                options = {...this.context.global.controls.miniMapControl.options}
                this.map.removeControl(this.context.global.controls.miniMapControl.control);
                delete this.context.global.controls.miniMapControl;
            }
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                if (options) {
                    this.addMiniMapControl(options);
                }
            }); 
        }
    

    /**
     * Remove all menu items
     * @example
      * // Remove all menu items
      * this.deleteMenuItems()
     */
    deleteMenuItems()  {
            this.context.ui.deleteMenuItems();   
         }
    
    

    /**
     * Get map pitch
     * @returns {number} Current map pitch
     */
    getPitch() {  this.map.getPitch() }
    

    /**
     * Create or update map by fileid, URL, or local file selection
     * @param {object} options Options, optional
     * @param {string} options.fileid File id; empty=select local file; can be URL or file id
     * @param {string} options.mapid Map id, optional, auto-generated if empty
     * @param {"Memory" | "GeomRaster" | "GeomVector"} options.openway Open mode: Memory, GeomRaster, GeomVector. Default GeomRaster
     * @param {boolean} options.lightTheme Light theme, optional, default dark
     * @param {boolean} options.isFullExtent Full extent, optional, default false
     * @returns {Object | undefined} {mapid, version, fileid}, undefined if cancelled
     * @example
      * // Select local file to create map
      * await this.createOrUpdateMap();
      * // Create map from URL gym.dwg, mapid newgym, vector, dark theme
      * await this.createOrUpdateMap({
      * fileid: "https://vjmap.com/static/assets/data/gym.dwg",
      * mapid: "newgym",
      * openway: "GeomVector",
      * lightTheme: false
      * });
     */
    async createOrUpdateMap(options)  {
            options = options || {};
            let fileid = options.fileid || "";
            let svc = this.map.getService();
            let uploadname;
            if (!fileid) {
                let formats = await svc.cmdGetSupportFormat();
                let acceptFormat = '';
                if (formats.cad) {
                    acceptFormat = formats.cad.split(";").map(f => '.' + f).join(",")
                }
                if (formats.image) {
                    acceptFormat += "," + formats.image.split(";").map(f => '.' + f).join(",")
                }
                const selectFile = () => {
                    return new Promise((resolve, reject) => {
                        // Create hidden file input
                        const input = document.createElement('input');
                        input.type = 'file';
                    
                        // Set allowed file types
                        input.accept = acceptFormat;
                    
                        // Add listener for file selection
                        input.addEventListener('change', () => {
                        if (input.files && input.files.length > 0) {
                            resolve(input.files[0]); // Return first selected file
                        } else {
                            reject(new Error('No file selected'));
                        }
                        });
                    
                        // Trigger file picker
                        input.click();
                    });
                }
                try {
                    // Select file
                    let file = await selectFile();
                    // Upload file
                    let res = await svc.uploadMap(file);
                    if (res && res.error) {
                        this.showMessage(res.error)
                        return;
                    }
                    fileid = res.fileid;
                    uploadname = res.uploadname;
                }
                catch(err) {
                    this.showMessage(err)
                    return;
                }
            }
            let mapid = options.mapid || "";
            if (!mapid) {
                mapid = fileid;
                // If URL
                if (fileid.toLowerCase().endsWith('.dwg')) {
                    mapid = svc.strMd5(fileid).substring(0, 16);
                }
            }
            let mapopenway =  options.openway === "Memory" ? vjmap.MapOpenWay.Memory : vjmap.MapOpenWay.GeomRender;
            let isVectorStyle = options.openway === "GeomVector" ? true : false;
            let lightTheme = options.lightTheme ?? (this.map.getService().currentMapParam()?.darkMode === true ? false : true)
          
            this.showMessage("Opening map, please wait...", "success")
            // Open map
            let result = await svc.updateMap({
                mapid: mapid,
                fileid: fileid,
                mapopenway: mapopenway,
                uploadname: uploadname,
                style: lightTheme ? vjmap.openMapLightStyle() : vjmap.openMapDarkStyle(),
            });
    
            // Switch to new map
            let res = await this.map.switchMap({
                mapid: result.mapid,
                version: result.version,
                mapopenway: mapopenway,
                style: lightTheme ? vjmap.openMapLightStyle() : vjmap.openMapDarkStyle()
            }, false, isVectorStyle, true, true);
            if (options.isFullExtent !== true) {
                // Use default center and zoom
                let svc = this.map.getService();
                let param = svc.currentMapParam();
                if (param && param.view) {
                    if (param.view.center) {
                        this.map.setCenter(param.view.center);
                    }
                    if (param.view.zoom) {
                        this.map.setZoom(param.view.zoom);
                    }
                    if (param.view.bearing) {
                        this.map.setBearing(param.view.bearing);
                    }
                }
            }
            if (res && res.error) {
                this.showMessage(res.error)
                return;
            }
            // New map opened, clear history
            this.context.ui.clearUndoRecord();
            return {
                mapid: result.mapid,
                fileid: fileid,
                version: result.version
            };
    
        }
    

    /**
     * Delete CAD entities
     * @param {boolean} rectSelect false=point select, true=rect select. Optional, default false
     * @example
      * // Example 1
      * // Point select to delete CAD entities
      * this.deleteCadEntity(false);
      * // Example 2
      * // Rect select to delete CAD entities
      * this.deleteCadEntity(true);
     */
    async deleteCadEntity(rectSelect)  {
            let draw = this.map.getDrawLayer();
            if (!this.context.global.updateMapStyleObj) {
                this.context.global.updateMapStyleObj = vjcommon.createUpdateMapStyleObj(this.map);
            }
            const updateMapStyleObj = this.context.global.updateMapStyleObj;
            // Save old data for undo
            let oldHideObjectIds = new Set(updateMapStyleObj.hideObjectIds);
            const deleteEntitys = vjcommon.getTrackFeatureProperty(draw, "deleteEntitys") || [];
            const hideEntityObjectIds =
              vjcommon.getTrackFeatureProperty(draw, "hideEntityObjectIds") || [];
            const hideEntityIds = vjcommon.getTrackFeatureProperty(draw, "hideEntityIds") || [];
    
            await vjcommon.deleteCadEntity(this.map, draw, updateMapStyleObj,  this.map.logInfo.bind(this.map), async (content) => {
                return await this.context.ui.confirm(content, "Prompt", "warning");
              }, rectSelect);
    
            let newHideObjectIds = new Set(updateMapStyleObj.hideObjectIds);
            let diffObjectIds = newHideObjectIds.difference(oldHideObjectIds);
            // Add undo record
            this.context.ui.addUndoRecord(async () => {
                await updateMapStyleObj.removeHideObjectIds(diffObjectIds);
                vjcommon.setTrackFeatureProperty(draw, {
                  deleteEntitys: Array.from(new Set(deleteEntitys)),
                  hideEntityObjectIds: Array.from(new Set(hideEntityObjectIds)),
                  hideEntityIds: Array.from(new Set(hideEntityIds)),
                });
            });
        }
    

    /**
     * Copy CAD entities
     * @param {boolean} rectSelect false=point select, true=rect select. Optional, default false
     * @example
      * // Example 1
      * // Point select to copy CAD entities
      * await this.copyCadEntity(false);
      * // Example 2
      * // Rect select to copy CAD entities
      * await this.copyCadEntity(true);
     */
    async copyCadEntity(rectSelect)  {
            let draw = this.map.getDrawLayer();
            if (!this.context.global.updateMapStyleObj) {
                this.context.global.updateMapStyleObj = vjcommon.createUpdateMapStyleObj(this.map);
            }
            const updateMapStyleObj = this.context.global.updateMapStyleObj;
            // Save old data for undo
            let features = draw.getAll().features;
            let oldFeatureLength = features ? features.length : 0;
    
            await vjcommon.copyCadEntity(this.map, draw, updateMapStyleObj,  this.map.logInfo.bind(this.map), async (content) => {
                return await this.context.ui.confirm(content, "Prompt", "warning");
              }, rectSelect, (content) => {
                return this.context.ui.prompt("Please enter content", "Prompt", content);
              });
    
           
            let addFeatureIds = [];
            let newFeatures = draw.getAll().features;
            let newFeatureLength = newFeatures ? newFeatures.length : 0;
            for (let i = oldFeatureLength; i < newFeatureLength; i++) {
                addFeatureIds.push(newFeatures[i].id);
            }
            // Add undo record
            this.context.ui.addUndoRecord(async () => {
                this.map.getDrawLayer().delete(addFeatureIds);
            });
        }
    

    /**
     * Capture line coordinates
     * @param {boolean} isSnap Enable snap, default false
     * @returns {Promise<number[][] | undefined>} Returns [[x1,y1],[x2,y2],...], undefined if cancelled
     * @example
      * const line = await this.actionDrawLineString();
      * if (!line) return;
      * // Output all line point coords
      * for (let i = 0; i < line.length; i++) {
      * let x = line[i][0];
      * let y = line[i][1];
      * console.log(x, y);
      * }
     */
    async actionDrawLineString(isSnap)  {
            this.context.tools.logInfo("Please draw a line on the map")
            let snapObj = {};
            if (isSnap) {
                vjcommon.getMapSnapPoints(this.map, snapObj);
            }
            let drawLine = await vjmap.Draw.actionDrawLineSting(this.map, isSnap ? {
                api: {
                    getSnapFeatures: snapObj
                }
            } : {});
            if (drawLine.cancel) {
                return;
            }
            let coords = drawLine.features[0].geometry.coordinates;
            return coords.map(c => {
                let p = this.map.fromLngLat(c);
                return [p.x, p.y];
            });
        }
    

    /**
     * Manually draw ellipse or ellipse arc
     * @param {object} options Config params, optional
     * @param {boolean} options.isSnap Enable snap, optional, default false
     * @param {string} options.color Color #RRGGBB, optional
     * @param {string} options.borderColor Border color #RRGGBB, optional
     * @param {boolean} options.arc Draw ellipse arc, default false, optional
     * @param {boolean} options.isFill Whether to fill. Optional, default false
     * @param {number} options.width Line width, default 1, optional
     * @param {number} options.opacity Opacity. Optional, default 1
     * @param {number} options.borderOpacity Border opacity, default 1, optional
     * @param {number} options.data Data value, optional
     * @returns {Promise<Object | undefined>} Drawn entity or undefined if cancelled. Object includes:
     * @example
      * // Example 1
      * // Draw filled ellipse, snap, red, opacity 0.6, width 2, yellow border 0.8
      * const ellipseFill = await this.drawEllipse({
      * isSnap: true,
      * color: "#FF0000",
      * isFill: true,
      * opacity: 0.6,
      * width: 2,
      * borderColor: "#FFFF00",
      * borderOpacity: 0.8
      * });
      * if (ellipseFill && ellipseFill.id) {
      * console.log("Ellipse ID:", ellipseFill.id);
      * }
      * // Example 2
      * // Draw ellipse, red, width 3
      * const ellipse = await this.drawEllipse({
      * color: "#FF0000",
      * isFill: false,
      * width: 3,
      * });
      * if (ellipse && ellipse.id) {
      * console.log("Ellipse ID:", ellipse.id);
      * }
      * // Example 3
      * // Draw ellipse arc, yellow, width 2
      * const ellipseArc = await this.drawEllipse({
      * color: "#FFFF00",
      * isFill: false,
      * arc: true,
      * width: 2,
      * });
      * if (ellipseArc && ellipseArc.id) {
      * console.log("Ellipse arc ID:", ellipseArc.id);
      * }
     */
    async drawEllipse(options)  {
            options = options || {};
            let draw = this.map.getDrawLayer();
            let snapObj = {};
            if (options.isSnap) {
                vjcommon.getMapSnapPoints(this.map, snapObj);
            }
            let drawOptions = options.isSnap ? {
                api: {
                    getSnapFeatures: snapObj
                }
            } : {};
            let featureId; 
            if (options.isFill) {
                if (options.arc) {
                    featureId = await vjcommon.drawEllipseFillArc(this.map, draw, drawOptions, {
                        "color": options.color,// color
                        "opacity": options.opacity, // opacity
                        "outlineColor": options.borderColor || options.color,// border color
                        "line_width": options.width, // line width
                        "line_opacity": options.borderOpacity, // opacity
                        "data": options.data
                    }, () => this.map.logInfo);
                } else {
                    featureId = await vjcommon.drawEllipseFill(this.map, draw, drawOptions, {
                        "color": options.color,// color
                        "opacity": options.opacity, // opacity
                        "outlineColor": options.borderColor || options.color,// border color
                        "line_width": options.width, // line width
                        "line_opacity": options.borderOpacity, // opacity
                        "data": options.data
                    }, () => this.map.logInfo);
                }
            } else {
                if (options.arc) {
                    featureId = await vjcommon.drawEllipseArc(this.map, draw, drawOptions, {
                        "color": options.borderColor || options.color,// color
                        "line_width": options.width, // line width
                        "data": options.data
                    }, () => this.map.logInfo);
                } else {
                    featureId = await vjcommon.drawEllipseEdge(this.map, draw, drawOptions, {
                        "color": options.borderColor || options.color,// color
                        "line_width": options.width, // line width
                        "data": options.data
                    }, () => this.map.logInfo);
                }
            }
            if (featureId) {
                // Add undo record
                this.context.ui.addUndoRecord(() => {
                    this.map.getDrawLayer().delete(featureId);
                });
                return { id: featureId };
            }
        }
    

    /**
     * Set data layer visibility
     * @param {string} layerName Layer name, required
     * @param {boolean} visible Visible, required
     * @example
      * // Hide data layer layer1
      * let layerName = 'layer1';
      * this.setDataLayerVisible(layerName, false);
     */
    setDataLayerVisible(layerName, visible)  {
            let item = this.context.global.layers.find(layer => layer.layerName == layerName);
            if(item && item.visible != visible) {
                item.visible = visible;
                let layerId = item.layerId;
                if (item.layer && typeof(item.layer.getLayerId) == "function") {
                    layerId = item.layer.getLayerId();
                }
                item.layer.setVisible(this.map, layerId, !visible);
    
                // Add undo record
                this.context.ui.addUndoRecord(() => {
                    this.setDataLayerVisible(item.layerName, visible);
                });
            }
        }
    

    /**
     * Create debug tile layer
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {number} options.rasterOpacity Image opacity 0-1, default 1
     * @param {number} options.rasterHueRotate Hue rotation degrees, default 0
     * @param {number} options.rasterBrightnessMin Min brightness 0-1, default 0
     * @param {number} options.rasterBrightnessMax Max brightness 0-1, default 1
     * @param {number} options.rasterSaturation Saturation -1 to 1, default 0
     * @param {number} options.rasterContrast Contrast -1 to 1, default 0
     * @param {number} options.rasterFadeDuration Tile fade duration ms, default 300
     * @param {string} options.rasterResampling Resampling: linear, nearest, default linear
     * @param {string} options.color Color #RRGGBB
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Create debug tile layer
      * let layer = await this.createDebugLayer();
      * console.log(layer);
     */
    async createDebugLayer(options)  {
            options = options || {};
            let type = "raster";
    
            let randomId = vjmap.RandomID();
            let sourceId = `source_${type}_${randomId}`;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                sourceId,
                layerId,
                ...options,
                type: type
            }
    
            let isVector = this.map.hasVectorLayer() ? "&vector=true" : ''
            let r = 255, g = 255, b = 0, a = 255;
           
            if (options.color && options.color.startsWith('#') && options.color.length === 7) {
                r = parseInt(options.color.slice(1, 3), 16);
                g = parseInt(options.color.slice(3, 5), 16);
                b = parseInt(options.color.slice(5, 7), 16);
            }
            let svc = this.map.getService();
            let tiles = svc.serviceUrl('this.map/') + `tile/debug/{z}/{x}/{y}?r=${r}&g=${g}&b=${b}&a=${a}${isVector}&token=${svc.accessToken}`;
            let sourceData = {
                type: "raster",
                tiles: [tiles],
                tileSize: this.map.hasVectorLayer() ? 512 : 256
            }
           
            this.map.addRasterSource(sourceId, sourceData);
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data: sourceData, options, layer, visible: true, create: this.createDebugLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Get CAD EPSG code
     * @param {object} options Config options
     * @param {string} [options.mapid] Map ID, uses current if empty
     * @param {string} [options.version] Map version, uses current when empty
     * @param {"beijing54"|"xian80"|"cgs2000"} [options.crs] CAD CRS: beijing54, xian80, cgs2000
     * @returns {Promise<string|undefined>} CAD EPSG code, undefined if not available
     * @example
      * // Example 1
      * // Get current map EPSG, output to session
      * let epsg = await this.getCadEpsgCode();
      * if (epsg) {
      * this.chatLog(epsg);
      * } else {
      * this.chatLog("Failed to get CAD EPSG code");
      * }
      * // Example 2
      * // Get EPSG for map ca422a931980, Xi'an 80
      * let epsg2 = await this.getCadEpsgCode({
      * mapid: "ca422a931980",
      * crs: "xian80"
      * });
      * console.log(epsg2);
     */
    async getCadEpsgCode(options)  {
            options = options || {}
            let svc = this.map.getService();
            // Get center; for web map overlay X 8 digits, Y 7 (or X 6 without zone)
            let center = this.map.fromLngLat(this.map.getCenter());
            if (options.mapid) {
                let metadata = await svc.metadata(options.mapid, options.version);
                let mapExtent = vjmap.GeoBounds.fromString(metadata.bounds);
                center = mapExtent.center();
            }
            let strX = parseInt(center.x) + "";
            let xLen = strX.length;
            let yLen = (parseInt(center.y) + "").length;
            if (yLen == 7) {
                let crs = vjmap.transform.EpsgCrsTypes.CGCS2000;
                if (options.crs == "beijing54") {
                    crs = vjmap.transform.EpsgCrsTypes.Beijing54;
                } else if (options.crs == "xian80") {
                    crs = vjmap.transform.EpsgCrsTypes.Xian80;
                }
                if (xLen == 8) {
                    let epsg = vjmap.transform.getEpsgParam(crs, +strX.substring(0, 2))?.epsg;
                    if (epsg) {
                        return epsg;
                    }
                } else if (xLen == 6) {
                    // If 6-digit X without zone, pick approximate lng/lat on web map
                    let lngLat = await this.context.ui.selectWebMapLngLat({
                        isGaode: true,
                        isRoadway: true
                    })
                    if (lngLat) {
                        let res = vjmap.transform.getEpsgCode([lngLat.lng, lngLat.lat]);
                        let curEpsg;
                        let curDist;
                        // Get nearest EPSG
                        for(let item of res) {
                           if (crs == vjmap.transform.EpsgCrsTypes.CGCS2000 && item.crs.indexOf('2000') == -1) {
                              continue;
                            } else if (crs == vjmap.transform.EpsgCrsTypes.Beijing54 && item.crs.indexOf('54') == -1) {
                              continue;
                           } else if (crs == vjmap.transform.EpsgCrsTypes.Xian80 && item.crs.indexOf('80') == -1) {
                              continue;
                           }
                           let epsg = item.epsg;
                           let result = await svc.cmdTransform("EPSG:4326", epsg, vjmap.geoPoint([lngLat.lng, lngLat.lat])); // Or use proj4 on frontend
                           let pt = result[0];// First result
                           let dist = vjmap.geoPoint(pt).distanceTo(vjmap.geoPoint(center))
                           if (curDist === undefined) {
                            curDist = dist;
                            curEpsg = epsg;
                           } else if (dist < curDist) {
                            curDist = dist;
                            curEpsg = epsg;
                           }
                        }
                        if (curEpsg) {
                            return curEpsg;
                        }
                    }
                }
            }
            return undefined;
        }
    

    /**
     * Modify CAD entities
     * @param {boolean} rectSelect false=point select, true=rect select. Optional, default false
     * @example
      * // Example 1
      * // Point select to modify CAD entities
      * this.modifyCadEntity(false);
      * // Example 2
      * // Rect select to modify CAD entities
      * this.modifyCadEntity(true);
     */
    async modifyCadEntity(rectSelect)  {
            let draw = this.map.getDrawLayer();
            if (!this.context.global.updateMapStyleObj) {
                this.context.global.updateMapStyleObj = vjcommon.createUpdateMapStyleObj(this.map);
            }
            const updateMapStyleObj = this.context.global.updateMapStyleObj;
            // Save old data for undo
            let oldHideObjectIds = new Set(updateMapStyleObj.hideObjectIds);
            const deleteEntitys = vjcommon.getTrackFeatureProperty(draw, "deleteEntitys") || [];
            const hideEntityObjectIds =
              vjcommon.getTrackFeatureProperty(draw, "hideEntityObjectIds") || [];
            const hideEntityIds = vjcommon.getTrackFeatureProperty(draw, "hideEntityIds") || [];
            let features = draw.getAll().features;
            let oldFeatureLength = features ? features.length : 0;
    
            await vjcommon.modifyCadEntity(this.map, draw, updateMapStyleObj,  this.map.logInfo.bind(this.map), async (content) => {
                return await this.context.ui.confirm(content, "Prompt", "warning");
              }, rectSelect, (content) => {
                return this.context.ui.prompt("Please enter content", "Prompt", content);
              });
              
            let newHideObjectIds = new Set(updateMapStyleObj.hideObjectIds);
            let diffObjectIds = newHideObjectIds.difference(oldHideObjectIds);
            let addFeatureIds = [];
            let newFeatures = draw.getAll().features;
            let newFeatureLength = newFeatures ? newFeatures.length : 0;
            for (let i = oldFeatureLength; i < newFeatureLength; i++) {
                addFeatureIds.push(newFeatures[i].id);
            }
            // Add undo record
            this.context.ui.addUndoRecord(async () => {
                await updateMapStyleObj.removeHideObjectIds(diffObjectIds);
                this.map.getDrawLayer().delete(addFeatureIds);
                vjcommon.setTrackFeatureProperty(draw, {
                  deleteEntitys: Array.from(new Set(deleteEntitys)),
                  hideEntityObjectIds: Array.from(new Set(hideEntityObjectIds)),
                  hideEntityIds: Array.from(new Set(hideEntityIds)),
                });
            });
        }
    

    /**
     * Get nearest point from point to multiple lines
     * @param {[number, number]} point Point [x,y]
     * @param {[number, number][][] | geojson} lines Lines array or GeoJSON
     * @returns {object} Object with nearest point, distance, index, prevDist
     * @example
      * // Nearest point from [130,140] to lines [[[100,100],[200,200]],[[100,200],[200,100]]]
      * let lines = [
      * [[100, 100], [200, 200]],
      * [[100, 200], [200, 100]]
      * ];
      * let point = [130, 140];
      * let res = this.closestPointOnPolylines(point, lines);
      * console.log(res);
     */
    closestPointOnPolylines(point, lines)  {
            if (lines && lines.type === "FeatureCollection" && lines.features.length > 0) {
                let geojsonLines = [];
                for(let i = 0; i < lines.features.length; i++) {
                    if (lines.features[i].geometry.type === "LineString") {
                        geojsonLines.push(lines.features[i].geometry.coordinates);
                    } else if (lines.features[i].geometry.type === "MultiLineString") {
                        lines.features[i].geometry.coordinates.forEach(ln => geojsonLines.push(ln));
                    }
                }
                lines = geojsonLines;
            }
            if (Array.isArray(lines)) {
                lines = lines.map(line => line.map(co => vjmap.geoPoint(co)));
            }
            // Compute nearest to all lines
            const res = vjmap.closestPointOnPolylines(vjmap.geoPoint(point), lines);
            if (res && res.closestPoint) {
                res.closestPoint = [res.closestPoint.x, res.closestPoint.y];
            }
            return res;
        }
    

    /**
     * Get data bounds from data
     * @param {Object} geojson GeoJSON data for bounds, required
     * @returns {[number, number, number, number]} [x1,y1,x2,y2] bounds (bottom-left, top-right)
     * @example
      * // Get GeoJSON bounds
      * let geojson = {type: "FeatureCollection", features: [{type: "Feature", geometry: {type: "Point", coordinates: [10, 20]}}]};
      * let bounds = this.getDataGeoBounds(geojson);
      * console.log(bounds);
     */
    getDataGeoBounds(geojson)  {
            let dataExtent = vjmap.GeoBounds.fromDataExtent(geojson);
            return dataExtent.toArray();
        }
    

    /**
     * Manually draw a rectangle
     * @param {object} options Config params, optional
     * @param {boolean} options.isSnap Enable snap, optional, default false
     * @param {string} options.color Color #RRGGBB, optional
     * @param {boolean} options.isFill Whether to fill. Optional, default false
     * @param {number} options.opacity Opacity. Optional, default 1
     * @param {number} options.data Data value, optional
     * @returns {Promise<Object | undefined>} Drawn entity or undefined if cancelled. Object includes:
     * @example
      * // Draw a rectangle manually
      * const rect = await this.drawRectangle({
      * isSnap: true,
      * color: "#FF0000",
      * isFill: true,
      * opacity: 0.8
      * });
      * if (!rect) return;
      * console.log("Rect ID:", rect.id);
      * console.log("Rect coords:", rect.points);
     */
    async drawRectangle(options)  {
            let points = await this.actionDrawRectangle(options.isSnap);
            if (!points) return;
            return this.createRectangle(points, options);
        }
    

    /**
     * Remove a menu item
     * @param {string} name Menu name
     * @example
      * // Remove "Full Map" menu item
      * this.removeMenuItem("Full Map")
     */
    removeMenuItem(name, func)  {
            this.context.ui.removeMenuItem(name, func);   
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.addMenuItem(name, func);
            });
         }
    

    /**
     * Select map, returns mapid and version
     * @returns {Object | undefined} {mapid, version}, undefined if cancelled
     * @example
      * // Select map, get mapid and version
      * let res = await this.selectCadMap();
      * if (res) {
      * this.chatLog("Selected mapid: " + res.mapid + ", version: " + res.version);
      * }
     */
    async selectCadMap()  {
            let res = await this.context.ui.selectCadMap()
            if (!res) return;
            return {
                mapid: res.mapid,
                version: res.version
            };
        }
    
    

    /**
     * Create animate polygon data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @param {string} options.fillColor 填充颜色
     * @param {string} options.fillOutlineColor 边框颜色
     * @param {number} options.fillOpacity 填充透明度,取值范围0-1
     * @param {string} options.fillPattern 填充图案,必须与地图已加载的图片名称对应,图像宽度必须是2的系数(2、4、8、...、512)
     * @param {[number,number]} options.fillTranslate 几何体的平移量[x,y],负数分别表示左和上,单位像素,默认[0,0]
     * @param {string} options.fillTranslateAnchor 平移参考系,"map"表示相对于地图平移,"viewport"表示相对于视窗平移
     * @param {boolean} options.fillAntialias 是否启用填充反锯齿
     * @param {string} options.animateImagesType Animation type: "arrow", "antpath", "customImage", "imageSprites"
     * @param {string} options.animateImages_arrowFillColor Arrow fill color when animateImagesType is "arrow", default "#22B14C"
     * @param {string} options.animateImages_arrowStrokeColor Arrow stroke color when animateImagesType is "arrow", default "#fff"
     * @param {number} options.animateImages_arrowStrokeWidth Arrow stroke width when animateImagesType is "arrow"
     * @param {number} options.animateImages_arrowWidth Arrow size when animateImagesType is "arrow"
     * @param {string} options.animateImages_fillColor1 Ant path fill color 1, for "antpath"
     * @param {string} options.animateImages_fillColor2 Ant path fill color 2, for "antpath"
     * @param {string} options.customImageUrl Custom image URL/base64, for "customImage"
     * @param {string} options.imageSpriteUrl Image sprite URL/base64, for "imageSprites"
     * @param {number} options.animateImages_spriteWidth Sprite width in source, for "imageSprites"
     * @param {number} options.animateImages_spriteHeight Sprite height in source, for "imageSprites"
     * @param {number} options.animateImages_canvasWidth Canvas width
     * @param {number} options.animateImages_canvasHeight Canvas height
     * @param {number} options.animateImages_frameCount Frame count
     * @param {boolean} options.animateImages_directionReverse Reverse animation direction
     * @param {number} options.speed Animation speed, step 0.1
     * @param {boolean} options.startAutoAnimation Auto-play on start
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Random GeoJSON, create animate polygon layer layer2
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createAnimateFillLayer(geojson, {
      * layerName: 'layer2'
      * });
      * console.log(layer);
     */
    async createAnimateFillLayer(data, options)  {
            options = options || {};
            let type = "animateFill";
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                fillColor: "#00ffff",
                fillOutlineColor: "#00ff00",
                animateImagesType: "arrow",
                animateImages_arrowFillColor: "#22B14C",
                animateImages_arrowStrokeColor: "#fff",
                animateImages_arrowStrokeWidth: 6,
                animateImages_canvasWidth: 128,
                animateImages_canvasHeight: 32,
                animateImages_arrowWidth: 16,
                animateImages_frameCount: 4,
                animateImages_fillColor1: "#f0fb",
                animateImages_fillColor2: "#0ffb",
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createSymbolCircleLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Get CAD layer list
     * @param {string} mapid Map id, optional, current if empty
     * @param {string} version Version, optional, current if empty
     * @returns {Promise<Object[]>} CAD layer list, each with properties below
     * @example
      * // Example 1
      * // Get CAD layer list
      * let res = await this.getCadLayers();
      * if (res) {
      * this.chatLog("Layer names: " + res.map(layer => layer.name).join(","));
      * }
      * // Example 2
      * // Get CAD map sys_world v1 layer list
      * let resp = await this.getCadLayers("sys_world", "v1");
      * if (resp) {
      * console.log(resp);
      * }
     */
    async getCadLayers(mapid, version)  {
            let svc = this.map.getService();
            if (!mapid) {
                return svc.getMapLayers();
            } else {
                // Get metadata first
                let meta = await svc.metadata(mapid, version);
                if (meta && meta.layers) {
                    return meta.layers;
                }
            }
        }
    

    /**
     * Remove all button items
     * @example
      * // Remove all button items
      * this.deleteButtonItems()
     */
    deleteButtonItems()  {
             this.context.ui.deleteButtonItems();   
          }
    
    

    /**
     * Set draw layer feature data
     * @param {<GeoJSON | undefined>} data Feature data
     */
    setDrawLayerData(data)  {
            let draw = this.map.getDrawLayer();
            draw.set(this.map.toLngLat(data));
        }
    

    /**
     * Create circle data layer
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {number} options.circleRadius 圆半径,单位像素,参数可选
     * @param {string} options.circleColor 圆填充颜色,支持十六进制颜色值,参数可选
     * @param {number} options.circleOpacity 圆填充透明度,取值范围0-1,参数可选
     * @param {string} options.circleStrokeColor 圆边框颜色,支持十六进制颜色值,参数可选
     * @param {number} options.circleStrokeWidth 圆Border width,单位像素,参数可选
     * @param {number} options.circleStrokeOpacity 圆边框透明度,取值范围0-1,参数可选
     * @param {number} options.circleBlur 圆模糊度,参数可选
     * @param {[number]} options.circleTranslate Translate [x,y], default [0,0], optional
     * @param {string} options.circleTranslateAnchor 平移参考系,'map'表示相对地图平移,'viewport'表示相对视窗平移,参数可选
     * @param {string} options.circlePitchScale 倾斜时缩放对齐方式,'map'相对地图,'viewport'相对视窗,参数可选
     * @param {string} options.circlePitchAlignment 倾斜时对齐方式,'map'相对地图,'viewport'相对视窗,参数可选
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // Random GeoJSON, create circle layer layer7, radius 5, red
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createSymbolCircleLayer(geojson, {
      * layerName: 'layer7',
      * circleRadius: 5,
      * circleColor: "#ff0000"
      * });
      * console.log(layer);
     */
    async createSymbolCircleLayer(data, options)  {
            options = options || {};
            let type = "circle";
        
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                "circleRadius": 5,
                "circleColor": "#ffff00",
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createSymbolCircleLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * Get current map preview URL
     * @returns {Promise<string>} Current map preview URL
     * @example
      * // Get current map preview URL
      * let url = await this.curMapPreviewUrl();
      * console.log(url);
     */
    curMapPreviewUrl()  {
            let svc = this.map.getService();
            let mapParam = svc.currentMapParam();
            let url = svc.baseUrl() + "_mapviewer?";
            let param = {};
            param["render"] = 1; // Default geom raster
            if (mapParam?.mapopenway == vjmap.MapOpenWay.Memory) {
                param["render"] = 0; // Memory open
            } else if (mapParam?.mapopenway == vjmap.MapOpenWay.GeomRender && window.location.href.indexOf("vector=true") > 0) {
                param["render"] = 2; // Default geom vector
            }
    
            param["mapid"] = mapParam?.mapid;
            param["version"] = mapParam?.version;
            param["layer"] = mapParam?.layer;
    
            param["centerX"] = this.map.getCenter().lng;
            param["centerY"] = this.map.getCenter().lat;
            param["zoom"] = this.map.getZoom();
            param["pitch"] = this.map.getPitch();
            param["bearing"] = this.map.getBearing();
            param["light"] = mapParam?.darkMode !== false;
    
            param["title"] = `VJMap-${mapParam?.mapid}-${mapParam?.version}-${mapParam?.layer}`;
    
            param["mobile"] = true;
            param['token'] = svc.accessToken;
            if (svc.getCurWorkspaceName()) {
                param['workspace'] = svc.getCurWorkspaceName();
            }
            url = url + Object.keys(param).map(item => item + "=" + param[item]).join("&");
            return url;
        }
    

    /**
     * Target matching
     * @param {object} options Options
     * @param {string} [options.mapid] Map ID
     * @param {string} [options.version] Map version, uses current when empty
     * @param {number} [options.selectWay] 0=box, 1=point, 2=no select. Default 0
     * @param {string} [options.layer] Style layer name, empty=from selected entities
     * @param {string} [options.mapBounds] Match map bounds, default full data bounds
     * @param {string} [options.objectIds] Entity objectids, separated by ||
     * @param {string} [options.objectBounds] Entity bounds
     * @param {string} [options.layeron] Map layers on
     * @param {number} [options.size] Image size for match, default 10000
     * @param {string} [options.method] Method, default matchPattern
     * @param {number} [options.maxCount] Max results, default 200
     * @param {number} [options.score] Min score (0-1), default 0.6
     * @param {number} [options.minReduceArea] Pyramid size 64-2048, default 256, for matchPattern
     * @param {boolean} [options.canOverlap] Allow overlap, default false
     * @param {number} [options.maxOverlap] Overlap ratio 0-0.8, default 0.3
     * @param {number} [options.toleranceAngle] Angle tolerance (-180,180), default 180, for matchPattern
     * @param {boolean} [options.useToleranceRange] Use angle range, default false, for matchPattern
     * @param {number} [options.tolerance1] Angle range 1 start, for matchPattern
     * @param {number} [options.tolerance2] Angle range 1 end, for matchPattern
     * @param {number} [options.tolerance3] Angle range 2 start, for matchPattern
     * @param {number} [options.tolerance4] Angle range 2 end, for matchPattern
     * @param {boolean} [options.showSettingForm] Show settings dialog, default true
     * @returns {Promise<GeoJSON>} Match result GeoJSON, undefined on failure
     * @example
      * // Point select entities for target matching
      * let res = await this.matchObject({
      * selectWay: 1
      * });
      * console.log(res);
     */
    async matchObject(options)  {
           options = options || {};
           let svc = this.map.getService();
           options = {
            mapid: options.mapid || svc.currentMapParam().mapid,
            objectIds: '',
            objectBounds: '',
            method: "matchPattern",
            maxCount: 200,
            score: 0.6,
            size: 10000,
            canOverlap: false,
            maxOverlap: 0.3,
            toleranceAngle: 180,
            layer: "",
            layeron: "",
            ...options,
           }
           if (options.selectWay === 0) {
             let isPointSel = options.selectWay === 1;
             let param = {};
             let res = await vjcommon.selectFeatures(this.map, true, isPointSel, isPointSel, true);
             vjcommon.clearHighlight(this.map);
             if (!res || res.length == 0) return;
             const objectIds = res.map(f => f.properties.objectid);
             const layeron = `(${Array.from(new Set(res.map(f => f.properties.layerindex))).join(",")})`;
             const features = {
                type: "FeatureCollection",
                features: res
             }
             let data = this.map.fromLngLat(features);
             let bounds = vjcommon.getGeoJsonBounds(data).scale(1.01); // Slightly larger
             param.objectIds = objectIds.join("||");
             param.objectBounds = `[${bounds.toString()}]`;
             param.layeron = layeron;
             options = {
                ...options,
                ...param
             }
           }
           if (options.showSettingForm !== false) {
            options = await this.showDataModifyDlg({
                data: options,
                title: "Target Match Settings",
                showForm: true
            })
            if (!options) return;
           }
           
           let result = await svc.cmdMatchObject(options);
           if (result && result.error) {
                if (this.map.logInfo) this.map.logInfo(result.error, "error", 10000);
                return;
            }
           if (!result || !result.match) return;
           let match = result.match.split(";");
           let geojson = {
            type: "FeatureCollection",
            features: []
           };
            for(let i = 0; i < match.length; i++) {
                if (match[i].indexOf(",") <= 0) continue;
                const items = match[i].split(",");
                const pts = [[+items[0], +items[1]], [+items[2], +items[3]], [+items[4], +items[5]], [+items[6], +items[7]]];
                pts.push(pts[0])
    
                geojson.features.push({
                    type: "Feature",
                    geometry: {
                        type: "LineString",
                        coordinates: pts
                    }
                })
            }
            return geojson;
       }
    

    /**
     * Show prompt input
     * @param {string} message Dialog content, optional
     * @param {string} title Dialog title, optional
     * @param {string} inputValue Initial value, optional
     * @param {boolean} isPassword Password input, optional, default false
     * @returns {string|undefined} User input, undefined if cancelled
     * @example
      * // Show prompt "Please enter content", initial empty
      * let res = await this.messageBoxPrompt("Please enter content", "Prompt", "");
      * if (res !== undefined) {
      * console.log("Input:", res)
      * } else {
      * console.log("Clicked Cancel")
      * }
     */
    async messageBoxPrompt(message, title, inputValue, isPassword)  {
            return await this.context.ui.prompt(message || "Please enter content", title || "Prompt", inputValue || "", isPassword || false);
        }
    

    /**
     * Get all map data or by mapid
     * @param {string} mapid Map ID, optional, all if empty
     * @param {string} version Version, optional, latest if empty, * = all
     * @returns {Promise<Array<Object>>} Map metadata array, undefined on failure
     * @example
      * // Example 1
      * // Get all map data
      * let res = await this.listMaps();
      * console.log(res);
      * // Example 2
      * // Get all versions of map sys_hello
      * let res2 = await this.listMaps("sys_hello", "*");
      * console.log(res2);
     */
    async listMaps(mapid, version)  {
            let svc = this.map.getService();
            let res =  await svc.listMaps(mapid, version);
            let result = [];
            if (mapid) {
                if (res && typeof res["version"] === "string") {
                    result.push({
                        mapid: mapid,
                        ...res 
                    });
                    return result;
                }
            } 
            for(let id in res) {
                let data = res[id];
                result.push({
                    mapid: id,
                    ...data
                });
            }
            return result;
         }
    

    /**
     * Convert projection PRJ WKT to proj4 string
     * @param {string} wkt PRJ WKT string
     * @param {string} [from] String source, empty=WKT
     * @returns {Promise<string>} proj4 string
     * @example
      * // 将WKT字符串`PROJCS["CGCS2000 / 3-degree Gauss-Kruger CM 111E",GEOGCS["China Geodetic Coordinate System 2000",DATUM["China_2000",SPHEROID["CGCS2000",6378300,300,AUTHORITY["EPSG","1024"]],AUTHORITY["EPSG","1043"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.018,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4490"]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",112.1],PARAMETER["scale_factor",1],PARAMETER["false_easting",500000],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AUTHORITY["EPSG","4546"]]`转换为proj4字符串
      * let proj4String = await this.prjWktToPrj4(`PROJCS["CGCS2000 / 3-degree Gauss-Kruger CM 111E",GEOGCS["China Geodetic Coordinate System 2000",DATUM["China_2000",SPHEROID["CGCS2000",6378300,300,AUTHORITY["EPSG","1024"]],AUTHORITY["EPSG","1043"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.018,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4490"]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",112.1],PARAMETER["scale_factor",1],PARAMETER["false_easting",500000],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AUTHORITY["EPSG","4546"]]`);
      * console.log(proj4String);
     */
    async prjWktToPrj4(wkt, from)  {
           let svc = this.map.getService();
           let res = await svc.cmdPrjWktToPrj4(wkt, from);
           return res?.proj4;
       }
    

    /**
     * 移除鼠标位置控件
     * @example
      * // 移除鼠标位置控件
      * this.removeMousePositionControl();
     */
    removeMousePositionControl()  {
            this.context.global.controls = this.context.global.controls || {};
            let options;
            if (this.context.global.controls.mousePositionControl) {
                options = {...this.context.global.controls.mousePositionControl.options}
                this.map.removeControl(this.context.global.controls.mousePositionControl.control);
                delete this.context.global.controls.mousePositionControl;
            }
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                if (options) {
                    this.addMousePositionControl(options);
                }
            }); 
        }
    
    

    /**
     * 关闭数据显示面板
     * @example
      * // 关闭数据显示面板
      * this.closeDataPanel()
     */
    closeDataPanel()  {
            this.context.ui.setDataPanelContent();   
         }
    
    

    /**
     * 创建点标记聚合数据图层
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {string} options.text Text to display, optional, default "index ${props.index}"
     * @param {number} options.minZoom Min zoom to display, optional, default 0
     * @param {number} options.maxZoom Max zoom to display, optional, default 24
     * @param {boolean} options.isDrawLinePoint Include line points when drawing, optional, default false
     * @param {string} options.color Marker color, optional, default'#3FB1CE'
     * @param {number[]} options.offset XY pixel offset [0,0], negative = left/up
     * @param {number} options.rotation Marker rotation, clockwise, default 0
     * @param {string} options.anchor Marker anchor:center、top、bottom、left、right、top-left、top-right、bottom-left、bottom-right, default center
     * @param {boolean} options.draggable Draggable, optional, default false
     * @param {string} options.animationType Animation: MAP_ANIMATION_NONE, MAP_ANIMATION_BOUNCE, MAP_ANIMATION_DROP
     * @param {string} options.rotationAlignment Rotation alignment: auto, map, viewport, default auto
     * @param {string} options.pitchAlignment Pitch alignment: auto, map, viewport, default auto
     * @param {number} options.scale Scale, default 1, 41px x 27px
     * @param {number} options.scaleMaxZoom Scale max zoom (0-24), auto-scale below
     * @param {number} options.height Elevation, CAD units
     * @param {boolean} options.removeWhenNoInMapView Auto show/hide in view
     * @param {number} options.removeWhenNoInMapViewPadding View padding for auto show/hide, default 500
     * @param {number} options.clickTolerance Click tolerance, inherits map default
     * @param {string} options.customImage Custom image URL/base64/svg
     * @param {number} options.customImageWidth Custom image width
     * @param {number} options.customImageHeight Custom image height
     * @param {string} options.customHtml Custom HTML element JS code
     * @param {string} options.popupHtml Popup HTML content
     * @param {[number,number]} options.popupOffset Popup XY offset
     * @param {string} options.popupAnchor Popup anchor, same as anchor
     * @param {boolean} options.closeButton Show close button
     * @param {boolean} options.closeOnClick Close popup on map click
     * @param {boolean} options.closeOnMove Close popup on map move
     * @param {boolean} options.focusAfterOpen Focus first focusable in popup
     * @param {string} options.className CSS class for popup
     * @param {string} options.maxWidth Popup max width, default'240px'
     * @param {boolean} options.allowOverlap 是否允许标记重叠
     * @param {number} options.allowOverlapMaxZoom 允许重叠的最大缩放级别,小于或等于此级别才会处理重叠,超过此级时会全部显示,默认4级
     * @param {number} options.markerWidth marker div的像素宽,用于计算重叠时需要,默认40
     * @param {number} options.markerHeight marker div的像素高,用于计算重叠时需要,默认40
     * @param {Object} options.textStyle 聚合文本的CSS样式对象,如{cursor:"pointer", opacity:0.8等}
     * @param {Array<Array>} options.textColors 聚合文本颜色配置,格式为[[值1,文本颜色,背景色], [值2,文本颜色,背景色], ...]
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // 在地图范围内随机生成GeoJson数据,创建图层名称为layer20的点标记聚合数据图层
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createMarkerClusterLayer(geojson, {
      * layerName: 'layer20'
      * });
      * console.log(layer);
     */
    async createMarkerClusterLayer(data, options)  {
            options = options || {};
            let type = "markerCluster";
        
    
            if (options.height) {
              options.height = this.map.geoLengthToHeight(options.height);
            }
            
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                textStyle: {
                  cursor: "pointer",
                  opacity: 0.8,
                  padding: "6px",
                  "border-radius": "12px",
                  "border-width": 0,
                  "box-shadow": "0px 2px 6px 0px rgba(97,113,166,0.2)",
                  "text-align": "center",
                  "font-size": "14px",
                },
                textColors: [
                    [0, "#00FFFF", "#00FFFF"],
                    [5, "#F0F", "#80FF00"],
                    [10, "#00F", "#FFFF00"],
                    [1000, "#FFF", "#FF3D3D"]
                ],
                popupHtml: "<h3 style='color:red'>index ${props.index}</h3>",
                popupOffset: [0, -28],
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createMarkerClusterLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * 创建矢量瓦片图层
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {string} options.tileMapId Tile mapid, optional
     * @param {string} options.tileMapVersion Tile version, optional
     * @param {string} options.tileMapLayer Tile style layer name, optional
     * @param {string|Array<string>} options.tiles Tile URL, supports variables {x},{y},{z},{serviceUrl},{accessToken}
     * @param {number} options.tileSize Tile size, default 512
     * @param {number[]} options.bounds Tile bounds [minx,miny,maxx,maxy], optional
     * @param {string} options.scheme Tile scheme: xyz (Y from top), tms (Y from bottom), default xyz
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // 创建矢量瓦片图层, 瓦片的mapid为sys_hello
      * let layer = await this.createVectorLayer({
      * tileMapId: "sys_hello",
      * });
      * console.log(layer);
     */
    async createVectorLayer(options)  {
            options = options || {};
            let type = "vector";
    
            let randomId = vjmap.RandomID();
            let sourceId = `source_${type}_${randomId}`;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                sourceId,
                layerId,
                ...options,
                type: type
            }
    
            if (options.tileMapId && !options.tiles) {
                let layer = options.tileMapLayer;
                if (!layer) {
                    let style = await this.map.getService().createStyle({
                        backcolor: this.map.getService().currentMapParam().darkMode ? 0x000000 : 0xFFFFFF
                    }, options.tileMapId, options.tileMapVersion);
                    layer = style.stylename
                }
                let tileUrl = this.map.getService().vectorTileUrl({
                    mapid: options.tileMapId,
                    version: options.tileMapVersion,
                    layer: layer,
                })
                options.tiles = tileUrl;
            }
            if (options.bounds) {
                const p1 = this.map.toLngLat([options.bounds[0], options.bounds[1]]);
                const p2 = this.map.toLngLat([options.bounds[2], options.bounds[3]]);
                options.bounds = [p1[0], p1[1], p2[0], p2[1]];
            }
            let sourceData = {
                type: "vector",
                tiles: Array.isArray(options.tiles) ? options.tiles : [options.tiles],
                tileSize: options.tileSize || 512
            }
            if (options.bounds) {
                sourceData.bounds = options.bounds;
            }
            if (options.scheme) {
                sourceData.scheme = options.scheme;
            }
            this.map.addVectorSource(sourceId, sourceData);
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data: sourceData, options, layer, visible: true, create: this.createVectorLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * 创建多边形数据图层
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {string} options.fillColor 填充颜色
     * @param {string} options.fillOutlineColor 边框颜色
     * @param {number} options.fillOpacity 填充透明度,取值范围0-1
     * @param {string} options.fillPattern 填充图案,必须与地图已加载的图片名称对应,图像宽度必须是2的系数(2、4、8、...、512)
     * @param {[number,number]} options.fillTranslate 几何体的平移量[x,y],负数分别表示左和上,单位像素,默认[0,0]
     * @param {string} options.fillTranslateAnchor 平移参考系,"map"表示相对于地图平移,"viewport"表示相对于视窗平移
     * @param {boolean} options.fillAntialias 是否启用填充反锯齿
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // 在地图范围内随机生成GeoJson数据,创建图层名称为layer11的多边形数据图层
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createFillLayer(geojson, {
      * layerName: 'layer11'
      * });
      * console.log(layer);
     */
    async createFillLayer(data, options)  {
            options = options || {};
            let type = "fill";
        
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                fillColor: "#00ffff",
                fillOutlineColor: "#00ff00",
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createFillLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    

    /**
     * 创建Geojson点面线图层
     * @param {GeoJson} data Data content, standard GeoJSON format, required
     * @param {Record<string, any>} options Layer parameter options, optional
     * @param {string} options.layerName Layer name, optional, default empty, uses random id if empty
     * @param {number} options.minzoom Min zoom to display, optional, default 0
     * @param {number} options.maxzoom Max zoom to display, optional, default 24
     * @param {string} options.fillColor 填充颜色
     * @param {string} options.fillOutlineColor 边框颜色
     * @param {number} options.fillOpacity 填充透明度,取值范围0-1
     * @param {string} options.fillPattern 填充图案,必须与地图已加载的图片名称对应,图像宽度必须是2的系数(2、4、8、...、512)
     * @param {[number,number]} options.fillTranslate 几何体的平移量[x,y],负数分别表示左和上,单位像素,默认[0,0]
     * @param {string} options.fillTranslateAnchor 平移参考系,"map"表示相对于地图平移,"viewport"表示相对于视窗平移
     * @param {boolean} options.fillAntialias 是否启用填充反锯齿
     * @param {string} options.lineColor Line color, optional, default '#ff0000'
     * @param {number} options.lineWidth Line width, optional, default 1
     * @param {number} options.lineOpacity Line opacity, optional, 0-1
     * @param {number} options.lineGapWidth Line gap width, optional, default 1, pixels
     * @param {number} options.lineOffset Line offset, optional
     * @param {number} options.lineBlur Line blur, optional
     * @param {[number,number]} options.lineDasharray Dash pattern, optional
     * @param {[number,number]} options.lineTranslate Translate [x,y], optional
     * @param {string} options.lineCap Line cap: 'butt', 'round', 'square'
     * @param {string} options.lineJoin Line join: 'bevel', 'round', 'miter'
     * @param {string} options.linePattern Line pattern, optional
     * @param {string} options.lineGradient Line gradient, optional
     * @param {number} options.circleRadius 圆半径,单位像素,参数可选
     * @param {string} options.circleColor 圆填充颜色,支持十六进制颜色值,参数可选
     * @param {number} options.circleOpacity 圆填充透明度,取值范围0-1,参数可选
     * @param {string} options.circleStrokeColor 圆边框颜色,支持十六进制颜色值,参数可选
     * @param {number} options.circleStrokeWidth 圆Border width,单位像素,参数可选
     * @param {number} options.circleStrokeOpacity 圆边框透明度,取值范围0-1,参数可选
     * @param {number} options.circleBlur 圆模糊度,参数可选
     * @param {[number,number]} options.circleTranslate 平移量,格式[x,y],负数表示向左和向上平移,单位像素,默认[0,0],参数可选
     * @param {string} options.circleTranslateAnchor 平移参考系,'map'表示相对地图平移,'viewport'表示相对视窗平移,参数可选
     * @param {string} options.circlePitchScale 倾斜时缩放对齐方式,'map'相对地图,'viewport'相对视窗,参数可选
     * @param {string} options.circlePitchAlignment 倾斜时对齐方式,'map'相对地图,'viewport'相对视窗,参数可选
     * @param {boolean} options.isHoverPointer Enable hover events, optional
     * @param {boolean} options.isHoverFeatureState Enable hover feature state, optional
     * @returns {Object} Returns object with {sourceId: string, layerId: string, layerName: string}
     * @example
      * // 在地图范围内随机生成GeoJson数据,创建图层名称为layer29的Geojson点面线图层
      * let geojson = this.randomGeojsonInMapBounds();
      * let layer = await this.createGeojsonDataLayer(geojson, {
      * layerName: 'layer29',
      * });
      * console.log(layer);
     */
    async createGeojsonDataLayer(data, options)  {
            options = options || {};
            let type = "geojson";
      
            if  (options.entColorToHtmlColor === undefined) {
              for(let f of data.features) {
                if (f.properties && typeof f.properties.color == "number") {
                    if (f.properties.color < 0) {
                        options.entColorToHtmlColor = true;
                        break;
                      }
                  }
              }
            }
    
            let randomId = vjmap.RandomID();
            let sourceId = "source_geojson_" + randomId;
            let layerId =  `layer_${type}_${randomId}`;
            let layerOptions = {
                layerId,
                sourceId,
                fillColor: "#00ffff",
                fillOutlineColor: "#00ff00",
                lineColor: '#ff0000',
                lineWidth: 1,
                circleRadius: 5,
                circleColor: "#ffff00",
                ...options,
                type: type
            }
            // Add data source
            this.map.addGeoJSONSource(sourceId, this.map.toLngLat(data));
    
            // Add layer
            let layer = new vjcommon.providerLayers[layerOptions.type];
            await layer.addLayer(this.map, layerOptions);
    
            let layerName = options.layerName || layerId;
    
            this.context.global.layers = this.context.global.layers || [];
            this.context.global.layers.push({layerId, sourceId, layerName, data, options, layer, visible: true, create: this.createGeojsonDataLayer});
    
            // Add undo record
            this.context.ui.addUndoRecord(() => {
                this.deleteDataLayer(layerName);
            });
            return {
                sourceId,
                layerId,
                layerName
            };
        }
    


}
Was this page helpful?