12. 渲染:MapLibre GL JS 集成与多源瓦片联动

写在前面:

在 WebGIS 开发中,最让人头秃的往往不是如何加载一张地图,而是如何让成百上千个图层在地图上"和谐共处"。当用户点击一个复选框时,地图是如何在毫秒间完成数据请求、样式计算和 WebGL 绘制的?

今天,我们将深入 light-mvt-server 的前端可视化核心 `MapContainer.vue`,看看我们如何利用 MapLibre GL JS 强大的矢量渲染能力,实现多源 GeoJSON 的动态叠加与实时样式联动。

关键词: #GeoAI #AI #Vibe Coding #MVT #GeoAI-UP

release包下载:一个轻量级、独立的 Mapbox 矢量瓦片(MVT)服务管理系统,用于发布本地 GeoJSON 数据 提供自动扫描、可视化样式配...


一、 架构设计:声明式 UI 与命令式引擎的桥梁

Vue3 是声明式的(我只关心结果),而 MapLibre GL JS 是命令式的(我要一步步告诉你怎么做)。为了弥合这个鸿沟,我们采用了 Composable + Watcher 的设计模式。

1.1 生命周期管理

在 Vue 组件中集成地图,必须处理好"生老病死":

  • onMounted :初始化地图实例,并等待 mapReady 信号。
  • onUnmounted :调用 cleanup() 销毁地图,防止内存泄漏。
  • Watch :监听 props.layers 的变化,自动同步图层状态。

#mermaid-svg-sQj61lChgCzfJgwX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sQj61lChgCzfJgwX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sQj61lChgCzfJgwX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sQj61lChgCzfJgwX .error-icon{fill:#552222;}#mermaid-svg-sQj61lChgCzfJgwX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sQj61lChgCzfJgwX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sQj61lChgCzfJgwX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sQj61lChgCzfJgwX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sQj61lChgCzfJgwX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sQj61lChgCzfJgwX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sQj61lChgCzfJgwX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sQj61lChgCzfJgwX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sQj61lChgCzfJgwX .marker.cross{stroke:#333333;}#mermaid-svg-sQj61lChgCzfJgwX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sQj61lChgCzfJgwX p{margin:0;}#mermaid-svg-sQj61lChgCzfJgwX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sQj61lChgCzfJgwX .cluster-label text{fill:#333;}#mermaid-svg-sQj61lChgCzfJgwX .cluster-label span{color:#333;}#mermaid-svg-sQj61lChgCzfJgwX .cluster-label span p{background-color:transparent;}#mermaid-svg-sQj61lChgCzfJgwX .label text,#mermaid-svg-sQj61lChgCzfJgwX span{fill:#333;color:#333;}#mermaid-svg-sQj61lChgCzfJgwX .node rect,#mermaid-svg-sQj61lChgCzfJgwX .node circle,#mermaid-svg-sQj61lChgCzfJgwX .node ellipse,#mermaid-svg-sQj61lChgCzfJgwX .node polygon,#mermaid-svg-sQj61lChgCzfJgwX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sQj61lChgCzfJgwX .rough-node .label text,#mermaid-svg-sQj61lChgCzfJgwX .node .label text,#mermaid-svg-sQj61lChgCzfJgwX .image-shape .label,#mermaid-svg-sQj61lChgCzfJgwX .icon-shape .label{text-anchor:middle;}#mermaid-svg-sQj61lChgCzfJgwX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sQj61lChgCzfJgwX .rough-node .label,#mermaid-svg-sQj61lChgCzfJgwX .node .label,#mermaid-svg-sQj61lChgCzfJgwX .image-shape .label,#mermaid-svg-sQj61lChgCzfJgwX .icon-shape .label{text-align:center;}#mermaid-svg-sQj61lChgCzfJgwX .node.clickable{cursor:pointer;}#mermaid-svg-sQj61lChgCzfJgwX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sQj61lChgCzfJgwX .arrowheadPath{fill:#333333;}#mermaid-svg-sQj61lChgCzfJgwX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sQj61lChgCzfJgwX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sQj61lChgCzfJgwX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sQj61lChgCzfJgwX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sQj61lChgCzfJgwX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sQj61lChgCzfJgwX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sQj61lChgCzfJgwX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sQj61lChgCzfJgwX .cluster text{fill:#333;}#mermaid-svg-sQj61lChgCzfJgwX .cluster span{color:#333;}#mermaid-svg-sQj61lChgCzfJgwX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sQj61lChgCzfJgwX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sQj61lChgCzfJgwX rect.text{fill:none;stroke-width:0;}#mermaid-svg-sQj61lChgCzfJgwX .icon-shape,#mermaid-svg-sQj61lChgCzfJgwX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sQj61lChgCzfJgwX .icon-shape p,#mermaid-svg-sQj61lChgCzfJgwX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sQj61lChgCzfJgwX .icon-shape .label rect,#mermaid-svg-sQj61lChgCzfJgwX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sQj61lChgCzfJgwX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sQj61lChgCzfJgwX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sQj61lChgCzfJgwX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} New Layer
Style Changed
Removed
Vue Component Mount
Init Map Instance
Wait for mapReady
Add Initial Layers
Layer Props Change
Compare Old vs New
addSource & addLayer
setPaintProperty
removeLayer & removeSource


二、 多源图层组(Layer Group)的实现逻辑

本项目的一个核心特性是:一个图层可以包含多个 GeoJSON 文件。在前端,我们通过"单 Source 多 Layer"的策略来实现。

2.1 统一的数据源入口

对于一个图层组,我们只向 MapLibre 注册一个 vector 类型的 Source,其 URL 指向后端的图层切片接口:

/api/tiles/{layerId}/{z}/{x}/{y}.pbf

2.2 动态子图层拆分

虽然只有一个 Source,但 PBF 内部包含了多个 source-layer(对应不同的 GeoJSON 文件)。我们需要遍历这些文件,为每一个创建一个对应的 MapLibre Layer。

typescript 复制代码
// web/src/components/map/MapContainer.vue
function addLayerToMap(layer: LayerWithSources) {
  // 1. 注册统一的矢量瓦片源
  const groupId = `group-${layer.id}`;
  const tileUrl = `${window.location.origin}/api/tiles/${layer.id}/{z}/{x}/{y}.pbf`;
  
  if (!mapInstance.value.getSource(groupId)) {
    addSource(groupId, tileUrl);
  }
  
  // 2. 遍历该图层下的所有数据源文件
  layer.sources.forEach((source, index) => {
    // 从文件名提取 source-layer 名称(与后端 vt-pbf 编码保持一致)
    const sourceLayerName = source.file.filename.replace(/\.[^/.]+$/, "");
    
    // 推断几何类型并获取样式配置
    const geometryType = inferGeometryType(source);
    const styleConfig = source.styleConfig || getDefaultStyle(geometryType);
    
    // 3. 为每个文件创建独立的渲染层
    const subLayerId = `${layer.id}-${sourceLayerName}`;
    addLayer(subLayerId, geometryType, styleConfig, groupId, sourceLayerName);
  });
}

这种设计的精妙之处在于,它既保持了前端图层管理的灵活性(每个文件可以有独立样式),又利用了 MVT 的多层打包特性,减少了 HTTP 请求次数。


三、 样式联动的"热更新"

当用户在右侧面板调整颜色或线宽时,地图必须立即响应。我们不重新加载瓦片,而是直接修改 WebGL 的绘制属性。

3.1 深度对比策略

为了避免不必要的性能开销,我们在 watch 中使用了 JSON 字符串化对比。只有当 styleConfig 真正发生变化时,才触发更新。

typescript 复制代码
// 只有当样式配置发生改变时才执行更新
const oldStyleStr = JSON.stringify(oldSource.styleConfig);
const newStyleStr = JSON.stringify(newSource.styleConfig);

if (oldStyleStr !== newStyleStr) {
  updateLayerStyle(subLayerId, geometryType, newStyleConfig);
}

3.2 样式注入实战

根据几何类型的不同,我们调用 MapLibre 不同的 API:

  • Point : 设置 circle-color, circle-radius
  • LineString : 设置 line-color, line-width
  • Polygon : 设置 fill-color, fill-opacity

四、 交互体验:弹窗与底图切换

我们没有使用 MapLibre 默认的简陋弹窗,而是封装了一个 Vue 组件 FeaturePopup

  • 定位 :通过 map.project() 将地理坐标转换为屏幕像素坐标。
  • 数据 :点击要素时,从 e.features[0].properties 中提取属性并传递给 Vue 组件。

4.2 底图无缝切换

利用 setBaseMap 方法,我们实现了在 Esri、OSM、CartoDB 等底图之间的秒级切换。这本质上是替换了 MapLibre 的 style 对象中的 sources 部分。


五、 总结与展望

通过这一章,我们完成了 GIS 系统的"最后一公里":

  • 组件化封装让地图逻辑变得可复用、易测试。
  • 多源联动机制解决了复杂业务场景下的图层管理难题。
  • 样式热更新提供了丝滑的用户交互体验。

下一步预告:

地图已经动起来了,但如何让它更"智能"?在第 13 篇文章中,我们将探讨样式编辑器的设计,如何通过可视化的方式生成复杂的 MapLibre Style JSON,让非专业用户也能轻松定制地图。

互动时间:

你在处理大量矢量图层时,有没有遇到过浏览器卡顿的情况?你是通过简化几何还是分层加载来解决的?欢迎在评论区分享你的优化心得。


六、附录:MapLibre GL JS 集成核心源码

以下是本项目中地图管理 Composable (web/src/composables/useMap.ts)) 的完整实现。它展示了如何将 MapLibre GL JS 的命令式 API 封装为 Vue3 的响应式逻辑。

typescript 复制代码
/**
 * @author rzcgis@foxmail.com
 * @blog https://blog.csdn.net/eqmaster
 * MapLibre GL的地图管理组件
 */

import { ref } from 'vue';
import { DEFAULT_MAP_CONFIG, BASE_MAP_STYLES, type BaseMapId } from '@/utils/constants';
import type { StyleConfig, GeometryType } from '@/types';
import { generateMaplibreLayer } from '@/utils/style';
import { logger } from '@/utils';
import { iconService } from '@/services';

export function useMap(containerId: string = 'map-container') {
  const mapInstance = ref<any>(null);
  const mapReady = ref(false);
  const currentZoom = ref(DEFAULT_MAP_CONFIG.zoom);
  const center = ref<[number, number]>(DEFAULT_MAP_CONFIG.center);
  const currentBaseMap = ref<BaseMapId>('esriStreet');
  const popupVisible = ref(false);
  const popupProperties = ref<Record<string, any>>({});
  const popupPosition = ref<{ x: number; y: number } | null>(null);
  
  // Track loaded icons to avoid duplicate loading
  const loadedIcons = new Set<string>();

  /**
   * 将icon图标加载到MapLibre
   */
  async function loadIcon(iconName: string) {
    if (!mapInstance.value || !mapReady.value) {
      logger.warn('Map not ready, cannot load icon');
      return;
    }
    
    // 如果已经加载过久跳过
    if (loadedIcons.has(iconName)) {
      logger.info(`Icon already loaded: ${iconName}`);
      return;
    }
    
    try {
      const iconUrl = iconService.getIconUrl(iconName);
      logger.info(`Loading icon: ${iconName}, URL: ${iconUrl}`);
      
      // 检测是否已经存在
      if (mapInstance.value.hasImage(iconName)) {
        logger.info(`Icon already exists in map style: ${iconName}`);
        loadedIcons.add(iconName);
        return;
      }
      
      // 对于SVG文件,使用Image对象,而不是createImageBitmap
      const isSvg = iconName.toLowerCase().endsWith('.svg');
      
      let image: HTMLImageElement | ImageBitmap;
      
      if (isSvg) {
        // SVG: 使用Image对象
        image = await new Promise((resolve, reject) => {
          const img = new Image();
          img.onload = () => resolve(img);
          img.onerror = (error) => reject(new Error(`Failed to load SVG: ${error}`));
          img.src = iconUrl;
        });
      } else {
        // PNG/JPG: 用fetch + createImageBitmap
        const response = await fetch(iconUrl);
        logger.info(`Icon fetch response status: ${response.status}`);
        
        if (!response.ok) {
          throw new Error(`Failed to fetch icon: ${iconName} (status: ${response.status})`);
        }
        
        const blob = await response.blob();
        logger.info(`Icon blob size: ${blob.size} bytes, type: ${blob.type}`);
        
        if (blob.size === 0) {
          throw new Error(`Icon file is empty: ${iconName}`);
        }
        
        image = await createImageBitmap(blob);
      }
          
      // 检查 icon 是否已经存在
      if (mapInstance.value.hasImage(iconName)) {
        logger.info(`Icon already loaded: ${iconName}`);
        return;
      }
          
      logger.info(`Icon loaded, adding to map: ${iconName}`);
      mapInstance.value.addImage(iconName, image);
      loadedIcons.add(iconName);
      
      logger.info(`Icon loaded successfully: ${iconName}`);
    } catch (error) {
      logger.error(`Failed to load icon ${iconName}`, error);
      throw error;
    }
  }

  /**
   * 初始化 MapLibre map
   */
  async function initMap() {
    try {
      // 动态导入以避免服务器端渲染(SSR)问题
      const maplibregl = await import('maplibre-gl');
      
      // 创建默认设置为Esri Street的底图
      const baseMapStyle = createBaseMapStyle(currentBaseMap.value);
      
      mapInstance.value = new maplibregl.Map({
        container: containerId,
        style: baseMapStyle,
        center: center.value,
        zoom: currentZoom.value,
        minZoom: DEFAULT_MAP_CONFIG.minZoom,
        maxZoom: DEFAULT_MAP_CONFIG.maxZoom,
      });

      mapInstance.value.on('load', () => {
        mapReady.value = true;
      });

      mapInstance.value.on('zoom', () => {
        currentZoom.value = mapInstance.value.getZoom();
      });

      mapInstance.value.on('move', () => {
        const lngLat = mapInstance.value.getCenter();
        center.value = [lngLat.lng, lngLat.lat];
      });

      // 为功能添加点击事件处理程序
      mapInstance.value.on('click', (e: any) => {
        handleFeatureClick(e);
      });
    } catch (error) {
      logger.error('Failed to initialize map', error);
    }
  }

  /**
   * 处理功能点击事件
   */
  function handleFeatureClick(e: any) {
    if (!mapInstance.value || !mapReady.value) return;

    // 点击时候查询要素
    const features = mapInstance.value.queryRenderedFeatures(e.point);
    
    if (features && features.length > 0) {
      // Get properties from the first feature
      const feature = features[0];
      if (feature.properties) {
        popupProperties.value = feature.properties;
        popupPosition.value = { x: e.point.x, y: e.point.y };
        popupVisible.value = true;
      }
    } else {
      // 如果没有选中要素,关闭气泡
      closePopup();
    }
  }

  /**
   * 关闭前气泡
   */
  function closePopup() {
    popupVisible.value = false;
    popupProperties.value = {};
    popupPosition.value = null;
  }

  /**
   * 创建底图
   */
  function createBaseMapStyle(baseMapId: BaseMapId) {
    const baseMap = BASE_MAP_STYLES[baseMapId];
    return {
      version: 8 as const,
      sources: {
        'base-map': {
          type: baseMap.type,
          tiles: [...baseMap.tiles], 
          tileSize: baseMap.tileSize,
          attribution: baseMap.attribution,
        },
      },
      layers: [
        {
          id: 'base-map-layer',
          type: 'raster' as const,
          source: 'base-map',
          paint: {
            'raster-opacity': 1,
          },
        },
      ],
    };
  }

  /**
   * 底图切换
   */
  function setBaseMap(baseMapId: BaseMapId) {
    if (!mapInstance.value || !mapReady.value) {
      logger.warn('Map not ready');
      return;
    }

    const baseMap = BASE_MAP_STYLES[baseMapId];
    const baseMapSource = mapInstance.value.getSource('base-map');
    
    if (baseMapSource) {
      // 更新底图
      baseMapSource.setTiles([...baseMap.tiles]);
      currentBaseMap.value = baseMapId;
    }
  }

  /**
   * 将图层添加到地图
   */
  async function addLayer(
    layerId: string,
    geometryType: GeometryType,
    style: StyleConfig,
    sourceId: string,
    sourceLayerName?: string
  ) {
    if (!mapInstance.value || !mapReady.value) {
      return;
    }
  
    // 如果使用图标,确保先加载
    if (geometryType === 'Point' || geometryType === 'MultiPoint') {
      if (style.point?.iconImage) {
        await loadIcon(style.point.iconImage);
      }
    }
  
    const layers = generateMaplibreLayer(layerId, geometryType, style, sourceId, sourceLayerName);
  
    // 处理单图层和图层组
    const layerArray = Array.isArray(layers) ? layers : [layers];
  
    layerArray.forEach((layer: any) => {
      if (!mapInstance.value.getLayer(layer.id)) {
        mapInstance.value.addLayer(layer);
      }
    });
  }

  /**
   * 移除图层
   */
  function removeLayer(layerId: string) {
    if (!mapInstance.value) return;

    // 移除主图层
    if (mapInstance.value.getLayer(layerId)) {
      mapInstance.value.removeLayer(layerId);
    }

    // 移除outline图层
    const outlineId = `${layerId}-outline`;
    if (mapInstance.value.getLayer(outlineId)) {
      mapInstance.value.removeLayer(outlineId);
    }
  }

  /**
   * 更新图层样式 (paint/layout 属性)
   */
  async function updateLayerStyle(
    layerId: string,
    geometryType: GeometryType,
    style: StyleConfig
  ) {
    if (!mapInstance.value || !mapReady.value) {
      return;
    }

    // 优先加载icon图标
    if (geometryType === 'Point' || geometryType === 'MultiPoint') {
      if (style.point?.iconImage) {
        await loadIcon(style.point.iconImage);
      }
    }

    // 使用更新后的样式生成新的图层规格
    const layers = generateMaplibreLayer(layerId, geometryType, style, '', '');
    const layerArray = Array.isArray(layers) ? layers : [layers];

    layerArray.forEach((layer: any) => {
      if (mapInstance.value.getLayer(layer.id)) {
        // 更新 paint 属性
        if (layer.paint) {
          Object.keys(layer.paint).forEach((key) => {
            try {
              mapInstance.value.setPaintProperty(layer.id, key, layer.paint[key]);
            } catch (error) {
              logger.warn(`Failed to set paint property ${key} for layer ${layer.id}`, error);
            }
          });
        }
        // 更新 layout 属性
        if (layer.layout) {
          Object.keys(layer.layout).forEach((key) => {
            try {
              const currentValue = mapInstance.value.getLayoutProperty(layer.id, key);
              if (currentValue !== undefined || layer.layout[key] !== undefined) {
                mapInstance.value.setLayoutProperty(layer.id, key, layer.layout[key]);
              }
            } catch (error) {
              logger.warn(`Failed to set layout property ${key} for layer ${layer.id}`, error);
            }
          });
        }
      }
    });
  }

  /**
   * 增加 vector source
   */
  function addSource(sourceId: string, url: string) {
    if (!mapInstance.value || !mapReady.value) {
      logger.warn('Map not ready, cannot add source');
      return;
    }

    if (!mapInstance.value.getSource(sourceId)) {
      mapInstance.value.addSource(sourceId, {
        type: 'vector',
        tiles: [url],
        minzoom: 0,
        maxzoom: 18,
      });
    }
  }

  /**
   * 移除 source
   */
  function removeSource(sourceId: string) {
    if (!mapInstance.value) return;

    if (mapInstance.value.getSource(sourceId)) {
      mapInstance.value.removeSource(sourceId);
    }
  }

  /**
   * Fit map to bounds
   */
  function fitBounds(bounds: [number, number, number, number]) {
    if (!mapInstance.value) return;

    mapInstance.value.fitBounds([
      [bounds[0], bounds[1]],
      [bounds[2], bounds[3]],
    ], {
      padding: 50,
      duration: 1000,
    });
  }

  /**
   * Fit map to bbox string (JSON format)
   * @param bboxString - JSON string of [minX, minY, maxX, maxY]
   */
  function fitBoundsToBbox(bboxString: string | null) {
    if (!bboxString) {
      logger.warn('No bbox available');
      return;
    }

    try {
      const bbox = JSON.parse(bboxString) as [number, number, number, number];
      if (bbox && bbox.length === 4) {
        fitBounds(bbox);
      } else {
        logger.error('Invalid bbox format:', bbox);
      }
    } catch (error) {
      logger.error('Failed to parse bbox:', error);
    }
  }

  /**
   * Fly to location
   */
  function flyTo(lng: number, lat: number, zoom?: number) {
    if (!mapInstance.value) return;

    mapInstance.value.flyTo({
      center: [lng, lat],
      zoom: zoom || currentZoom.value,
      duration: 1500,
    });
  }

  /**
   * Zoom in
   */
  function zoomIn() {
    if (!mapInstance.value || !mapReady.value) return;
    mapInstance.value.zoomIn();
  }

  /**
   * Zoom out
   */
  function zoomOut() {
    if (!mapInstance.value || !mapReady.value) return;
    mapInstance.value.zoomOut();
  }

  /**
   * Locate user (geolocation)
   */
  function locate() {
    if (!mapInstance.value || !mapReady.value) return;
    
    if ('geolocation' in navigator) {
      navigator.geolocation.getCurrentPosition(
        (position) => {
          const { latitude, longitude } = position.coords;
          flyTo(longitude, latitude, 15);
          logger.info('Located user at:', { latitude, longitude });
        },
        (error) => {
          logger.error('Geolocation error:', error);
        }
      );
    } else {
      logger.warn('Geolocation not supported');
    }
  }

  /**
   * Reset view to default
   */
  function resetView() {
    if (!mapInstance.value || !mapReady.value) return;
    
    mapInstance.value.flyTo({
      center: DEFAULT_MAP_CONFIG.center,
      zoom: DEFAULT_MAP_CONFIG.zoom,
      duration: 1000,
    });
  }

  /**
   * Get map instance
   */
  function getMap() {
    return mapInstance.value;
  }

  /**
   * Cleanup on unmount
   */
  function cleanup() {
    if (mapInstance.value) {
      mapInstance.value.remove();
      mapInstance.value = null;
      mapReady.value = false;
    }
  }

  return {
    // State
    mapInstance,
    mapReady,
    currentZoom,
    center,
    currentBaseMap,
    popupVisible,
    popupProperties,
    popupPosition,

    // Actions
    initMap,
    addLayer,
    removeLayer,
    updateLayerStyle,
    addSource,
    removeSource,
    fitBounds,
    fitBoundsToBbox,
    flyTo,
    setBaseMap,
    getMap,
    cleanup,
    closePopup,
    zoomIn,
    zoomOut,
    locate,
    resetView,
  };
}

代码亮点解析:

  • SVG 图标支持 :在 loadIcon 中,我们针对 SVG 和位图(PNG/JPG)采用了不同的加载策略。SVG 使用 Image 对象直接加载,而位图则利用 createImageBitmap 提升性能。
  • 样式热更新updateLayerStyle 方法通过遍历 paintlayout 属性,实现了在不重载瓦片的情况下实时更新地图视觉效果。
  • 底图无缝切换setBaseMap 仅修改 Source 的 tiles 属性,避免了重新初始化整个 Map 实例,保证了自定义业务图层的稳定性。
相关推荐
橘子星1 小时前
别再懵圈!JS 执行机制的 “千层套路” 全揭秘
前端·javascript
拾年2751 小时前
__proto__ vs prototype:90% 的人分不清的 JavaScript 核心
前端·javascript·面试
提子拌饭1332 小时前
个人月事记录表应用 - 鸿蒙PC Electron框架完整实现指南
前端·javascript·华为·electron·前端框架·开源·鸿蒙系统
超人气王2 小时前
新手学前端JS浅拷贝和深拷贝:对象复制竟然是个“替身文学”?
javascript·面试
YHL2 小时前
📚 JS执行机制(执行上下文 + 调用栈 + 编译流程)
前端·javascript
不简说2 小时前
这次真香!sv-print 可视化打印设计器更新:插件脚手架、Excel 导出、弹窗 API 三连发
前端·javascript·前端框架
无聊的老谢2 小时前
Web GIS 最佳实践:Vue 集成 Leaflet/OpenLayers 实现基站海量点位渲染
前端·javascript·vue.js
东风破_2 小时前
V8 如何执行你的代码——编译、上下文与调用栈
javascript
Aphasia3112 小时前
从内存模型看深浅拷贝
前端·javascript·面试