内网环境下 Web 离线地图的实现方案与合规性探讨

在政企内网、涉密园区、封闭办公区等无法访问外网的场景中,基于高德、百度等商业地图 API 的 Web 地图方案往往无法落地 ------ 这类 API 的 Key 校验、瓦片加载等核心环节均依赖外网访问。本文将详细介绍基于开源瓦片数据 + Leaflet 的 Web 离线地图实现方案,并重点探讨该方案的法律风险与使用注意事项。

一、技术实现方案

1. 核心思路

离线地图的核心是摆脱对商业地图 API 外网服务的依赖,通过以下步骤实现:

  1. 下载开源地图瓦片数据(卫星图、标注层);
  2. 将瓦片数据部署到内网存储(本地项目 / MinIO / 内网服务器);
  3. 基于 Leaflet 加载内网瓦片,实现地图展示、打点、画线等核心功能。

2. 瓦片数据准备

(1)数据下载工具

推荐使用「全能地图下载器」或「水经注地图下载器」,这类工具支持:

  • 选择地图层级(建议 1-10 级,层级越高数据量越大);
  • 框选下载区域(如全国、某省份);
  • 导出瓦片为{z}/{x}/{y}.jpg/png格式(符合 Leaflet 瓦片加载规范)。

(2)数据存储方式

  • 轻量场景 :层级≤5、区域较小的瓦片,可直接放入前端项目的public目录;
  • 中大型场景:层级高、区域大的瓦片,建议部署到 MinIO(内网对象存储)或内网 HTTP 服务器,通过内网地址访问。

3. 前端核心实现(Vue+Leaflet)

以下是完整的离线地图实现代码,包含地图初始化、多图层切换、点位标记、弹窗交互等核心功能 vue

xml 复制代码
<template>
  <div class="offline-map-container">
    <!-- 地图容器 -->
    <div ref="mapContainer" class="map-container" />
    <!-- 图层切换控件 -->
    <div class="controls">
      <button @click="switchLayer('satellite')">卫星图</button>
      <button @click="switchLayer('hybrid')">混合模式</button>
      <button @click="switchLayer('overlay')">仅标注</button>
    </div>
  </div>
</template>

<script>
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
// 自定义标记图标(需自行准备)
import blueIcon from '@/assets/map/marker-icon.png'
import redIcon from '@/assets/map/marker-icon-red.png'
import shadowIcon from '@/assets/map/marker-shadow.png'

export default {
  name: 'OfflineMap',
  data() {
    return {
      map: null,
      satelliteLayer: null, // 卫星图图层
      overlayLayer: null,   // 标注图层
      currentLayerType: 'hybrid', // 默认混合图层
      chinaCenter: [35.8617, 104.1954], // 中国地理中心点
      defaultZoom: 6, // 默认缩放级别
      // 模拟点位数据(实际可替换为业务数据)
      markerDataList: [
        { id: 1, name: '北京', lat: 39.9042, lng: 116.4074, desc: '中国首都,政治文化中心' },
        { id: 2, name: '上海', lat: 31.2304, lng: 121.4737, desc: '国际化大都市,经济中心' },
        { id: 3, name: '广州', lat: 23.1291, lng: 113.2644, desc: '华南门户,商贸中心' },
        { id: 4, name: '深圳', lat: 22.5431, lng: 114.0579, desc: '科技创新之都,经济特区' },
        { id: 5, name: '成都', lat: 30.5728, lng: 104.0668, desc: '西南重镇,天府之国' },
        { id: 6, name: '重庆', lat: 29.5628, lng: 106.5516, desc: '直辖市,山城雾都' },
        { id: 7, name: '西安', lat: 34.3416, lng: 108.9398, desc: '十三朝古都,历史文化名城' },
        { id: 8, name: '武汉', lat: 30.5928, lng: 114.3055, desc: '九省通衢,中部核心城市' },
        { id: 9, name: '杭州', lat: 30.2741, lng: 120.1551, desc: '电商之都,风景旅游城市' },
        { id: 10, name: '南京', lat: 32.0603, lng: 118.7969, desc: '六朝古都,华东重要城市' }
      ],
      markers: [], // 存储标记实例
      activeMarkerId: null // 当前激活的点位ID
    }
  },
  mounted() {
    this.initMap()
    this.initDefaultMarkers()
  },
  beforeUnmount() {
    // 组件销毁时清理地图资源,避免内存泄漏
    if (this.map) {
      this.markers.forEach(item => this.map.removeLayer(item.marker))
      this.map.remove()
      this.map = null
    }
  },
  methods: {
    // 初始化地图
    initMap() {
      if (!this.$refs.mapContainer) {
        console.error('地图容器不存在!')
        return
      }

      // 创建地图实例并定位到中国中心
      this.map = L.map(this.$refs.mapContainer).setView(this.chinaCenter, this.defaultZoom)

      // 加载内网卫星图瓦片(替换为你的内网瓦片地址)
      this.satelliteLayer = L.tileLayer('#######/juicefs-vol-001/public/demo/map/satellite/{z}/{x}/{y}.jpg', {
        minZoom: 1,
        maxZoom: 10,
        tileSize: 256,
        noWrap: true, // 禁止地图循环显示
        attribution: '离线地图数据'
      })

      // 加载内网标注瓦片
      this.overlayLayer = L.tileLayer('#######/juicefs-vol-001/public/demo/map/overlay/{z}/{x}/{y}.png', {
        minZoom: 1,
        maxZoom: 5,
        tileSize: 256,
        noWrap: true,
        pane: 'overlayPane', // 标注层独立面板
        opacity: 0.8, // 标注透明度
        attribution: ''
      })

      // 默认显示混合图层(卫星图+标注)
      this.satelliteLayer.addTo(this.map)
      this.overlayLayer.addTo(this.map)

      // 添加比例尺控件
      L.control.scale().addTo(this.map)

      // 瓦片加载失败处理
      this.satelliteLayer.on('tileerror', (error) => {
        console.warn('卫星图瓦片加载失败:', error)
      })
      this.overlayLayer.on('tileerror', (error) => {
        console.warn('标注瓦片加载失败:', error)
      })
    },

    // 初始化点位标记
    initDefaultMarkers() {
      // 定义默认图标和激活态图标
      const defaultIcon = L.icon({
        iconUrl: blueIcon,
        shadowUrl: shadowIcon,
        iconSize: [25, 41],
        iconAnchor: [12, 41],
        popupAnchor: [1, -34]
      })

      const activeRedIcon = L.icon({
        iconUrl: redIcon,
        shadowUrl: shadowIcon,
        iconSize: [25, 41],
        iconAnchor: [12, 41],
        popupAnchor: [1, -34]
      })

      // 遍历创建点位标记
      this.markerDataList.forEach(markerData => {
        const marker = L.marker([markerData.lat, markerData.lng], {
          icon: defaultIcon,
          draggable: false // 禁止拖拽,如需开启可改为true
        }).addTo(this.map)

        // 点位点击事件:切换激活态+显示弹窗
        marker.on('click', () => {
          // 恢复上一个激活点位的默认样式
          if (this.activeMarkerId !== null) {
            const prevActiveMarker = this.markers.find(item => item.id === this.activeMarkerId)
            if (prevActiveMarker) {
              prevActiveMarker.marker.setIcon(defaultIcon)
            }
          }

          // 设置当前点位为激活态
          marker.setIcon(activeRedIcon)
          this.activeMarkerId = markerData.id

          // 绑定并打开自定义弹窗
          marker.bindPopup(`
            <div class="marker-popup">
              <h3 style="margin:0 0 8px 0; color:#e53935;">${markerData.name}</h3>
              <p style="margin:4px 0;">经纬度:${markerData.lat.toFixed(4)}, ${markerData.lng.toFixed(4)}</p>
              <p style="margin:4px 0;">详情:${markerData.desc}</p>
            </div>
          `).openPopup()
        })

        // 存储点位实例,方便后续管理
        this.markers.push({
          id: markerData.id,
          marker: marker,
          data: markerData
        })
      })
    },

    // 切换地图图层
    switchLayer(type) {
      if (this.currentLayerType === type) return

      // 移除所有瓦片图层
      this.map.eachLayer((layer) => {
        if (layer instanceof L.TileLayer) {
          this.map.removeLayer(layer)
        }
      })

      // 根据类型添加对应图层
      switch (type) {
        case 'satellite':
          this.satelliteLayer.addTo(this.map)
          break
        case 'overlay':
          this.overlayLayer.addTo(this.map)
          break
        case 'hybrid':
          this.satelliteLayer.addTo(this.map)
          this.overlayLayer.addTo(this.map)
          break
      }

      this.currentLayerType = type
    }
  }
}
</script>

<style scoped>
.offline-map-container {
  width: 100%;
  height: 100vh;
  position: relative;
}

.map-container {
  width: 100%;
  height: 100%;
}

.controls {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 1000;
  background: white;
  padding: 10px;
  border-radius: 5px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}

.controls button {
  margin: 0 5px;
  padding: 5px 10px;
  cursor: pointer;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #fff;
  transition: all 0.2s;
}

.controls button:hover {
  background: #f5f5f5;
  border-color: #999;
}

/* 美化Leaflet弹窗样式 */
:deep(.leaflet-popup-content-wrapper) {
  border-radius: 8px;
  box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
}

:deep(.leaflet-popup-content) {
  margin: 12px 16px;
  width: 220px !important;
}
</style>

效果如下

当然也支持 地图打点自定义组件 和 画线

js 复制代码
<template>
  <div class="offline-map-container">
    <div ref="mapContainer" class="map-container" />

    <div class="controls">
      <button @click="switchLayer('satellite')">卫星图</button>
      <button @click="switchLayer('hybrid')">混合模式</button>
      <button @click="switchLayer('overlay')">仅标注</button>
    </div>
  </div>
</template>

<script>
import Vue from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import MapMarker from './MapMarker.vue' // 确保路径正确

export default {
  name: 'OfflineMap',
  data() {
    return {
      map: null,
      satelliteLayer: null,
      overlayLayer: null,
      currentLayerType: 'hybrid',
      chinaCenter: [35.8617, 104.1954],
      defaultZoom: 5,
      markerDataList: [
        { id: 1, name: '北京', lat: 39.9042, lng: 116.4074, desc: '中国首都' },
        { id: 2, name: '上海', lat: 31.2304, lng: 121.4737, desc: '经济中心' },
        { id: 3, name: '广州', lat: 23.1291, lng: 113.2644, desc: '华南门户' },
        { id: 4, name: '深圳', lat: 22.5431, lng: 114.0579, desc: '科技创新之都' },
        { id: 5, name: '成都', lat: 30.5728, lng: 104.0668, desc: '天府之国' },
        { id: 6, name: '重庆', lat: 29.5628, lng: 106.5516, desc: '山城雾都' },
        { id: 7, name: '西安', lat: 34.3416, lng: 108.9398, desc: '十三朝古都' },
        { id: 8, name: '武汉', lat: 30.5928, lng: 114.3055, desc: '九省通衢' },
        { id: 9, name: '杭州', lat: 30.2741, lng: 120.1551, desc: '电商之都' },
        { id: 10, name: '南京', lat: 32.0603, lng: 118.7969, desc: '六朝古都' }
      ],
      markers: [],
      lines: [], // 存储线段实例
      activeMarkerId: null
    }
  },
  mounted() {
    this.initMap()
    this.initDefaultMarkers()
    this.initPolylines() // 初始化各种样式的线
  },
  beforeUnmount() {
    if (this.map) {
      // 销毁 Vue 实例,防止内存泄漏
      this.markers.forEach(item => {
        if (item.vueInstance) item.vueInstance.$destroy()
      })
      this.map.remove()
      this.map = null
    }
  },
  methods: {
    initMap() {
      if (!this.$refs.mapContainer) return
      this.map = L.map(this.$refs.mapContainer).setView(this.chinaCenter, this.defaultZoom)

      // 离线瓦片图层
      this.satelliteLayer = L.tileLayer('##########/juicefs-vol-001/public/demo/map/satellite/{z}/{x}/{y}.jpg', {
        minZoom: 1, maxZoom: 10, tileSize: 256, noWrap: true
      })
      this.overlayLayer = L.tileLayer('##########/juicefs-vol-001/public/demo/map/overlay/{z}/{x}/{y}.png', {
        minZoom: 1, maxZoom: 5, tileSize: 256, noWrap: true, pane: 'overlayPane'
      })

      this.satelliteLayer.addTo(this.map)
      this.overlayLayer.addTo(this.map)
      L.control.scale().addTo(this.map)
    },

    initDefaultMarkers() {
      const MarkerConstructor = Vue.extend(MapMarker)

      this.markerDataList.forEach(markerData => {
        // 创建并挂载 Vue 组件
        const instance = new MarkerConstructor({
          propsData: { name: markerData.name, active: false }
        }).$mount()

        // 使用 L.divIcon 承载 Vue 组件的 DOM
        const icon = L.divIcon({
          html: instance.$el,
          className: 'custom-leaflet-icon', // 必须在 CSS 中重置样式
          iconSize: [30, 30],
          iconAnchor: [15, 15]
        })

        const marker = L.marker([markerData.lat, markerData.lng], { icon }).addTo(this.map)

        marker.on('click', () => {
          // 切换状态逻辑
          if (this.activeMarkerId !== null) {
            const prev = this.markers.find(m => m.id === this.activeMarkerId)
            if (prev) prev.vueInstance.active = false
          }
          instance.active = true
          this.activeMarkerId = markerData.id

          marker.bindPopup(`<b>${markerData.name}</b><br>${markerData.desc}`).openPopup()
        })

        this.markers.push({ id: markerData.id, marker, vueInstance: instance })
      })
    },

    initPolylines() {
      const getPos = (id) => {
        const d = this.markerDataList.find(m => m.id === id)
        return [d.lat, d.lng]
      }

      // 1. 普通白色实线 (北京 - 南京)
      L.polyline([getPos(1), getPos(10)], {
        color: 'white', weight: 3, opacity: 0.6
      }).addTo(this.map)

      // 2. 红色虚线 (上海 - 杭州)
      L.polyline([getPos(2), getPos(9)], {
        color: '#ff5252', weight: 3, dashArray: '10, 10'
      }).addTo(this.map)

      // 3. 蓝色流光动画线 (西安 - 武汉 - 广州)
      L.polyline([getPos(7), getPos(8), getPos(3)], {
        color: '#00e5ff',
        weight: 4,
        dashArray: '15, 15',
        className: 'polyline-path-flow' // 对应 CSS 动画
      }).addTo(this.map)

      // 4. 黄色宽线 (成都 - 重庆)
      L.polyline([getPos(5), getPos(6)], {
        color: '#ffeb3b', weight: 8, opacity: 0.3
      }).addTo(this.map)
    },

    switchLayer(type) {
      if (this.currentLayerType === type) return
      this.map.eachLayer(layer => {
        if (layer instanceof L.TileLayer) this.map.removeLayer(layer)
      })
      if (type === 'satellite' || type === 'hybrid') this.satelliteLayer.addTo(this.map)
      if (type === 'overlay' || type === 'hybrid') this.overlayLayer.addTo(this.map)
      this.currentLayerType = type
    }
  }
}
</script>

<style scoped>
.offline-map-container {
  width: 100%;
  height: 100vh;
  position: relative;
  background: #001529;
  /* 深色底色让线条更明显 */
}

.map-container {
  width: 100%;
  height: 100%;
}

.controls {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 1000;
  background: rgba(255, 255, 255, 0.9);
  padding: 10px;
  border-radius: 4px;
}

/* 关键:重置 Leaflet divIcon 的默认样式 */
:deep(.custom-leaflet-icon) {
  background: transparent !important;
  border: none !important;
}

/* 线条流光动画 */
:deep(.polyline-path-flow) {
  stroke-dasharray: 20, 20;
  animation: line-flow 10s linear infinite;
}

@keyframes line-flow {
  from {
    stroke-dashoffset: 400;
  }

  to {
    stroke-dashoffset: 0;
  }
}
</style>
js 复制代码
<template>
    <div class="custom-marker" :class="{ 'is-active': active }">
        <div class="marker-container">
            <div class="marker-icon">
                <span>{{ name[0] }}</span>
            </div>
            <div class="marker-shadow"></div>
        </div>
    </div>
</template>

<script>
export default {
    props: ['name', 'active']
}
</script>

<style scoped>
.custom-marker {
    width: 30px;
    height: 30px;
    cursor: pointer;
}

.marker-container {
    position: relative;
    display: flex;
    justify-content: center;
}

.marker-icon {
    width: 30px;
    height: 30px;
    background: #2196f3;
    border-radius: 50% 50% 50% 0;
    transform: rotate(-45deg);
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    border: 2px solid white;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
    transition: all 0.3s;
}

.marker-icon span {
    transform: rotate(45deg);
    font-size: 12px;
    font-weight: bold;
}

/* 激活状态样式 */
.is-active .marker-icon {
    background: #e53935;
    transform: rotate(-45deg) scale(1.2);
}

/* 呼吸动画 */
.is-active .marker-shadow {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 20px;
    height: 20px;
    margin-left: -10px;
    margin-top: -10px;
    background: rgba(229, 57, 53, 0.4);
    border-radius: 50%;
    animation: pulse 1.5s infinite;
}

@keyframes pulse {
    0% {
        transform: scale(1);
        opacity: 1;
    }

    100% {
        transform: scale(3);
        opacity: 0;
    }
}
</style>

效果如下

4. 核心功能说明

  • 多图层切换:支持卫星图、标注层、混合模式三种图层切换;
  • 点位标记:自定义图标样式,点击点位切换激活态并显示详情弹窗;
  • 资源清理:组件销毁时清理地图实例和标记,避免内存泄漏;
  • 异常处理:添加瓦片加载失败监听,便于排查内网瓦片访问问题。

二、法律风险与使用注意事项

离线地图方案虽能解决内网使用需求,但地图数据涉及测绘、地理信息安全等敏感领域,切勿 "拿来主义" ,需重点关注以下合规要求:

1. 核心法律风险

(1)测绘资质合规风险

根据《中华人民共和国测绘法》,地图数据属于测绘成果,未经许可下载、使用、传播测绘成果可能涉嫌违法:

  • 禁止使用无合法来源的地图瓦片数据;
  • 企业若自行采集、处理地理信息数据,需取得对应的测绘资质;
  • 严禁下载、使用涉密地理信息(如军事禁区、重要设施坐标)。

(2)数据来源合规风险

  • 避免使用盗版下载工具获取地图数据;
  • 拒绝使用未经授权的商业地图瓦片(如高德、百度未授权的离线瓦片),否则可能涉及著作权侵权;
  • 优先选择有合法授权的开源地图数据(如 OpenStreetMap),并遵守其使用协议。

(3)数据使用范围风险

  • 离线地图数据仅限内网自用,禁止对外提供、传播或用于商业盈利;
  • 若涉及国境线、行政区域划分等敏感内容,需确保数据符合国家测绘标准,避免出现 "问题地图"。

2. 合规使用建议

  1. 数据来源审核

    • 确认地图瓦片数据的合法授权,保留授权文件;
    • 优先使用国家测绘局认可的地图数据,避免使用境外未经审核的地图数据。
  2. 使用范围管控

    • 在内网部署时设置访问权限,仅限授权人员使用;
    • 明确数据使用目的,仅用于企业内部业务,不扩散、不商用。
  3. 合规审查

    • 涉及敏感区域(如边境、军事区)的地图数据,需提交测绘主管部门审查;
    • 企业建立地图数据使用台账,记录数据来源、使用范围、责任人。
  4. 替代方案

    • 若有条件,优先采购有内网部署授权的商业地图服务(如高德 / 百度的内网地图解决方案),降低合规风险;
    • 对于非核心场景,可使用开源地图框架(如 Leaflet+OpenStreetMap)的合规瓦片,避免法律风险。

三、总结

内网 Web 离线地图的实现核心是 "开源瓦片 + Leaflet",该方案能有效解决外网依赖问题,但法律合规是前提。总结核心要点:

  1. 技术层面:通过下载瓦片、内网部署、Leaflet 加载,可快速实现离线地图的展示、打点、画线等功能;
  2. 合规层面:严禁无授权使用地图数据,避免 "问题地图",仅限内网自用,不扩散、不商用;
  3. 风险管控:优先选择合法授权的数据来源,涉及敏感内容需提交审查,降低法律风险。

在实际落地中,需平衡技术实现与合规要求,切勿因追求功能而忽视地理信息安全与法律规定,确保离线地图的使用符合国家法律法规和企业合规要求。

相关推荐
2501_941807261 天前
在迪拜智能机场场景中构建行李实时调度与高并发航班数据分析平台的工程设计实践经验分享
java·前端·数据库
一 乐1 天前
餐厅点餐|基于springboot + vue餐厅点餐系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·后端
踢球的打工仔1 天前
typescript-var和let作用域
前端·javascript·typescript
手握风云-1 天前
JavaEE 进阶第八期:Spring MVC - Web开发的“交通枢纽”(二)
前端·spring·java-ee
海云前端11 天前
前端组件封装封神指南:16条实战原则,面试、项目双加分
前端
C_心欲无痕1 天前
网络相关 - XSS跨站脚本攻击与防御
前端·网络·xss
2501_941875281 天前
从日志语义到可观测性的互联网工程表达升级与多语言实践分享随笔
java·前端·python
钰fly1 天前
DataGridView 与 DataTable 与csv 序列
前端·c#
龙在天1 天前
Nuxtjs中,举例子一篇文章讲清楚:水合sop
前端·nuxt.js