Vue3+OpenStreetMap实现地理围栏

实现效果

代码

安装

javascript 复制代码
npm install leaflet leaflet-draw
javascript 复制代码
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import L from 'leaflet'
import { ElMessage, ElMessageBox } from 'element-plus'
import 'leaflet/dist/leaflet.css';
import 'leaflet-draw';
import 'leaflet-draw/dist/leaflet.draw.css';
// 围栏数据
interface Geofence {
  id: number
  name: string
  coordinates: [number, number][]
  color: string
}

// 状态
const mapContainer = ref<HTMLElement>()
const map = ref<L.Map | null>(null)
const mapLoading = ref(true)
const mapError = ref('')
const geofences = ref<Geofence[]>([])
const currentGeofence = ref<Geofence | null>(null)
const isDrawing = ref(false)
const isEditing = ref(false)
const drawingPoints = ref<[number, number][]>([])
const tempPolyline = ref<L.Polyline | null>(null)
const tempPolygon = ref<L.Polygon | null>(null)
const polygonLayers = ref<Map<number, L.Polygon>>(new Map())
const drawingMarkers = ref<L.Marker[]>([])
const nextId = ref(1)

// 颜色列表
const colors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#8B5CF6']

// 闭合阈值(米):当新点与第一个点距离小于此值时自动完成绘制
const CLOSE_THRESHOLD = 30

// 计算两点之间的距离(米)
const calculateDistance = (p1: [number, number], p2: [number, number]): number => {
  const R = 6371000 // 地球半径(米)
  const lat1 = p1[0] * Math.PI / 180
  const lat2 = p2[0] * Math.PI / 180
  const deltaLat = (p2[0] - p1[0]) * Math.PI / 180
  const deltaLng = (p2[1] - p1[1]) * Math.PI / 180

  const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
    Math.cos(lat1) * Math.cos(lat2) *
    Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2)
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))

  return R * c
}

// 生成单个围栏的 GeoJSON
const generateGeoJSON = (geofence: Geofence) => {
  // GeoJSON Polygon 坐标格式:[lng, lat],且首尾闭合
  const coordinates = geofence.coordinates.map(p => [p[1], p[0]])
  // 确保多边形闭合
  if (coordinates.length > 0) {
    const first = coordinates[0]
    const last = coordinates[coordinates.length - 1]
    if (first[0] !== last[0] || first[1] !== last[1]) {
      coordinates.push([...first])
    }
  }

  return {
    type: 'Feature',
    geometry: {
      type: 'Polygon',
      coordinates: [coordinates]
    },
    properties: {
      id: geofence.id,
      name: geofence.name,
      color: geofence.color
    }
  }
}

// 保存所有围栏(输出 GeoJSON)
const saveAllGeofences = () => {
  if (geofences.value.length === 0) {
    ElMessage.warning('没有可保存的围栏')
    return
  }

  const geojson = {
    type: 'FeatureCollection',
    features: geofences.value.map(generateGeoJSON)
  }

  console.log('=== GeoJSON 数据 ===')
  console.log(JSON.stringify(geojson, null, 2))

  ElMessage.success(`已保存 ${geofences.value.length} 个围栏,GeoJSON 已输出到控制台`)
}

// 初始化地图
const initMap = async () => {
  await nextTick()

  // 延迟确保 DOM 完全渲染
  setTimeout(() => {
    if (!mapContainer.value) {
      mapError.value = '地图容器不存在'
      mapLoading.value = false
      console.error('地图容器不存在')
      return
    }

    // 确保容器有高度
    const container = mapContainer.value
    console.log('地图容器尺寸:', container.clientWidth, 'x', container.clientHeight)

    // 强制设置容器尺寸
    container.style.width = '100%'
    container.style.height = '100%'
    container.style.minHeight = '600px'
    container.style.backgroundColor = '#f0f2f5'

    if (container.clientHeight === 0) {
      container.style.height = '600px'
      console.log('容器高度为0,已设置为600px')
    }

    // 深圳坐标
    const SHENZHEN_LAT = 22.5431
    const SHENZHEN_LNG = 114.0579

    try {
      console.log('开始初始化地图...')
      map.value = L.map(container, {
        center: [SHENZHEN_LAT, SHENZHEN_LNG],
        zoom: 12,
        zoomControl: true,
        attributionControl: true
      })
      console.log('地图对象创建成功')

      // 使用高德地图瓦片(开源数据,国内可访问)
      const tileLayer = L.tileLayer(
        'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}',
        {
          attribution: '&copy; 高德地图',
          subdomains: ['1', '2', '3', '4'],
          maxZoom: 18
        }
      )

      // const tileLayer = L.tileLayer('http://t{s}.tianditu.gov.cn/vec_w/wmts?service=wmts&request=GetTile&version=1.0.0&LAYER=vec&tileMatrixSet=w&TileMatrix={z}&TileRow={y}&TileCol={x}&style=default&format=tiles&tk=YOUR_TDT_KEY', {
      //   subdomains: ['0', '1', '2', '3', '4', '5', '6', '7'],
      //   attribution: '&copy; <a href="http://www.tianditu.gov.cn/">天地图</a>'
      // }).addTo(map.value);
      // L.tileLayer('http://t{s}.tianditu.gov.cn/cva_w/wmts?service=wmts&request=GetTile&version=1.0.0&LAYER=cva&tileMatrixSet=w&TileMatrix={z}&TileRow={y}&TileCol={x}&style=default&format=tiles&tk=YOUR_TDT_KEY', {
      //   subdomains: ['0', '1', '2', '3', '4', '5', '6', '7']
      // }).addTo(map.value);

      tileLayer.on('tileerror', (error) => {
        console.warn('瓦片加载失败:', error)
      })

      tileLayer.addTo(map.value)

      // 添加深圳中心点标记
      const customIcon = L.divIcon({
        className: 'custom-marker',
        html: '<div style="background-color: #409EFF; width: 20px; height: 20px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 6px rgba(0,0,0,0.3);"></div>',
        iconSize: [20, 20],
        iconAnchor: [10, 10]
      })

      L.marker([SHENZHEN_LAT, SHENZHEN_LNG], { icon: customIcon })
        .addTo(map.value)
        .bindPopup('<b>深圳市</b><br>地理围栏中心点')
        .openPopup()

      // 绑定地图点击事件
      map.value.on('click', handleMapClick)

      // 监听地图缩放,更新绘制点大小
      map.value.on('zoomend', () => {
        if (isDrawing.value) {
          updateAllMarkerSizes()
        }
      })

      // 监听地图加载完成
      map.value.whenReady(() => {
        mapLoading.value = false
        console.log('地图加载完成,使用高德瓦片')
      })
    } catch (error) {
      mapError.value = '地图初始化失败: ' + (error as Error).message
      mapLoading.value = false
      console.error('地图初始化失败:', error)
    }
  }, 100)
}

// 更新所有标记位置
const updateMarkersPosition = () => {
  drawingMarkers.value.forEach((marker, index) => {
    if (drawingPoints.value[index]) {
      marker.setLatLng(drawingPoints.value[index])
    }
  })
}

// 获取当前缩放级别下的标记大小
const getMarkerSize = (): number => {
  if (!map.value) return 16
  const zoom = map.value.getZoom()
  // 基础大小 16px,在 zoom 12 时;缩放时按比例变化
  return Math.max(8, Math.min(32, Math.round(16 * Math.pow(1.2, zoom - 12))))
}

// 创建可拖拽标记
const createDraggableMarker = (latlng: [number, number], index: number) => {
  if (!map.value) return null

  const size = getMarkerSize()

  const marker = L.marker(latlng, {
    draggable: true,
    icon: L.divIcon({
      className: 'draggable-point',
      html: `<div class="draw-point" style="
        width: ${size}px;
        height: ${size}px;
        background-color: #409EFF;
        border: 3px solid white;
        border-radius: 50%;
        box-shadow: 0 2px 6px rgba(0,0,0,0.3);
        cursor: move;
      "></div>`,
      iconSize: [size, size],
      iconAnchor: [size / 2, size / 2]
    })
  }).addTo(map.value)

  // 拖拽开始
  marker.on('dragstart', () => {
    marker.setZIndexOffset(1000)
  })

  // 拖拽中
  marker.on('drag', (e: L.LeafletMouseEvent) => {
    const newLatLng = e.latlng
    drawingPoints.value[index] = [newLatLng.lat, newLatLng.lng]
    updateTempShape(false)
  })

  // 拖拽结束
  marker.on('dragend', (e: L.LeafletMouseEvent) => {
    marker.setZIndexOffset(0)
    drawingPoints.value[index] = [e.latlng.lat, e.latlng.lng]
    updateTempShape(true)
  })

  return marker
}

// 更新所有标记大小(缩放时调用)
const updateAllMarkerSizes = () => {
  const size = getMarkerSize()
  drawingMarkers.value.forEach(marker => {
    marker.setIcon(L.divIcon({
      className: 'draggable-point',
      html: `<div class="draw-point" style="
        width: ${size}px;
        height: ${size}px;
        background-color: #409EFF;
        border: 3px solid white;
        border-radius: 50%;
        box-shadow: 0 2px 6px rgba(0,0,0,0.3);
        cursor: move;
      "></div>`,
      iconSize: [size, size],
      iconAnchor: [size / 2, size / 2]
    }))
  })
}

// 地图点击 - 添加绘制点
const handleMapClick = (e: L.LeafletMouseEvent) => {
  if (!isDrawing.value || !map.value) return

  const latlng: [number, number] = [e.latlng.lat, e.latlng.lng]

  // 检查是否与第一个点重合(自动闭合)
  if (drawingPoints.value.length >= 3) {
    const firstPoint = drawingPoints.value[0]
    const distance = calculateDistance(latlng, firstPoint)

    if (distance < CLOSE_THRESHOLD) {
      // 自动闭合:不添加新点,直接完成绘制
      finishDrawing()
      return
    }
  }

  const index = drawingPoints.value.length
  drawingPoints.value.push(latlng)

  // 创建可拖拽标记
  const marker = createDraggableMarker(latlng, index)
  if (marker) {
    drawingMarkers.value.push(marker)
  }

  // 更新临时线条
  updateTempShape(true)
}

// 更新临时形状
const updateTempShape = (updateMarkers: boolean = true) => {
  if (!map.value) return

  // 清除旧的
  if (tempPolyline.value) {
    tempPolyline.value.remove()
  }
  if (tempPolygon.value) {
    tempPolygon.value.remove()
  }

  if (updateMarkers) {
    updateMarkersPosition()
  }

  if (drawingPoints.value.length < 2) return

  if (drawingPoints.value.length === 2) {
    tempPolyline.value = L.polyline(drawingPoints.value, {
      color: '#409EFF',
      weight: 3,
      dashArray: '5, 5'
    }).addTo(map.value)
  } else {
    tempPolygon.value = L.polygon(drawingPoints.value, {
      color: '#409EFF',
      weight: 2,
      fillColor: '#409EFF',
      fillOpacity: 0.2,
      dashArray: '5, 5'
    }).addTo(map.value)
  }
}

// 开始新建围栏
const startNewGeofence = () => {
  if (isDrawing.value) return

  isDrawing.value = true
  drawingPoints.value = []
  currentGeofence.value = null

  ElMessage.info('点击地图绘制围栏,首尾点靠近自动闭合')
}

// 完成绘制
const finishDrawing = () => {
  if (!map.value || drawingPoints.value.length < 3) return

  // 如果是编辑模式,保留原来的名称和颜色;否则使用新值
  const isEditMode = isEditing.value
  const originalName = currentGeofence.value?.name || `围栏${geofences.value.length + 1}`
  const originalColor = currentGeofence.value?.color || colors[geofences.value.length % colors.length]

  const color = isEditMode ? originalColor : colors[geofences.value.length % colors.length]
  const name = isEditMode ? originalName : `围栏${geofences.value.length + 1}`

  const newGeofence: Geofence = {
    id: nextId.value++,
    name,
    coordinates: [...drawingPoints.value],
    color
  }

  geofences.value.push(newGeofence)
  drawGeofenceOnMap(newGeofence)

  // 如果是编辑模式,更新当前选中的围栏为新围栏
  if (isEditMode) {
    currentGeofence.value = newGeofence
  }

  // 清除临时图形
  clearTempShapes()
  isDrawing.value = false
  drawingPoints.value = []

  ElMessage.success('围栏绘制完成')
}

// 在地图上绘制围栏
const drawGeofenceOnMap = (geofence: Geofence) => {
  if (!map.value) return

  const polygon = L.polygon(geofence.coordinates, {
    color: geofence.color,
    weight: 2,
    fillColor: geofence.color,
    fillOpacity: 0.3
  }).addTo(map.value)

  // 绑定点击事件
  polygon.on('click', () => {
    if (!isDrawing.value) {
      selectGeofence(geofence)
    }
  })

  polygonLayers.value.set(geofence.id, polygon)
}

// 清除临时图形
const clearTempShapes = () => {
  if (tempPolyline.value) {
    tempPolyline.value.remove()
    tempPolyline.value = null
  }
  if (tempPolygon.value) {
    tempPolygon.value.remove()
    tempPolygon.value = null
  }

  // 清除所有标记
  drawingMarkers.value.forEach(marker => {
    marker.remove()
  })
  drawingMarkers.value = []
}

// 选择围栏
const selectGeofence = (geofence: Geofence) => {
  if (isDrawing.value || isEditing.value) return

  currentGeofence.value = geofence

  // 高亮显示
  const layer = polygonLayers.value.get(geofence.id)
  if (layer && map.value) {
    map.value.fitBounds(layer.getBounds(), { padding: [50, 50] })
  }
}

// 修改围栏
const editGeofence = () => {
  if (!currentGeofence.value) {
    ElMessage.warning('请先选择一个围栏')
    return
  }

  isEditing.value = true
  isDrawing.value = true
  drawingPoints.value = [...currentGeofence.value.coordinates]

  // 删除旧的
  const oldLayer = polygonLayers.value.get(currentGeofence.value.id)
  if (oldLayer) {
    oldLayer.remove()
    polygonLayers.value.delete(currentGeofence.value.id)
  }

  // 移除数据,但不重置 currentGeofence,以便 finishDrawing 中恢复
  const editingGeofence = currentGeofence.value
  geofences.value = geofences.value.filter(g => g.id !== editingGeofence.id)

  // 为已有的点创建可拖拽标记
  drawingPoints.value.forEach((latlng, index) => {
    const marker = createDraggableMarker(latlng, index)
    if (marker) {
      drawingMarkers.value.push(marker)
    }
  })

  updateTempShape(true)
  ElMessage.info('重新绘制围栏,首尾点靠近自动闭合')
}

// 保存编辑
const saveEdit = () => {
  if (isDrawing.value && drawingPoints.value.length >= 3) {
    finishDrawing()
    isEditing.value = false
  } else if (isDrawing.value) {
    ElMessage.warning('请完成围栏绘制')
  } else {
    ElMessage.success('保存成功')
  }
}

// 取消编辑
const cancelEdit = () => {
  if (isEditing.value && currentGeofence.value) {
    // 恢复原来的围栏
    geofences.value.push(currentGeofence.value)
    drawGeofenceOnMap(currentGeofence.value)
  }

  clearTempShapes()
  isDrawing.value = false
  isEditing.value = false
  drawingPoints.value = []
  currentGeofence.value = null
}

// 删除围栏
const deleteGeofence = async (geofence: Geofence) => {
  try {
    await ElMessageBox.confirm('确定要删除该围栏吗?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })

    const layer = polygonLayers.value.get(geofence.id)
    if (layer) {
      layer.remove()
      polygonLayers.value.delete(geofence.id)
    }

    geofences.value = geofences.value.filter(g => g.id !== geofence.id)

    if (currentGeofence.value?.id === geofence.id) {
      currentGeofence.value = null
    }

    ElMessage.success('删除成功')
  } catch {
    // 取消删除
  }
}

// 定位围栏
const locateGeofence = (geofence: Geofence) => {
  selectGeofence(geofence)
}

onMounted(() => {
  initMap()
})

onUnmounted(() => {
  if (map.value) {
    map.value.remove()
    map.value = null
  }
})
</script>

<template>
  <div class="geofence-page">
    <div class="geofence-container">
      <!-- 地图区域 -->
      <div class="map-wrapper">
        <div v-if="mapLoading" class="map-loading">
          <Icon icon="ep:loading" class="loading-icon" />
          <span>地图加载中...</span>
        </div>
        <div v-if="mapError" class="map-error">
          <Icon icon="ep:warning-filled" class="error-icon" />
          <span>{{ mapError }}</span>
        </div>
        <div ref="mapContainer" class="map-container" ></div>
      </div>

      <!-- 侧边栏 -->
      <div class="sidebar">
        <div class="sidebar-header">
          <h3 class="sidebar-title">地理围栏编辑器</h3>
          <p class="sidebar-subtitle">点击地图可添加围栏,拖拽可调整区域</p>
        </div>

        <div class="sidebar-actions">
          <el-button
            type="primary"
            :disabled="isDrawing"
            @click="startNewGeofence"
          >
            <Icon icon="ep:plus" class="mr-5px" /> 新建围栏
          </el-button>
          <el-button
            :disabled="!currentGeofence || isDrawing"
            @click="editGeofence"
          >
            <Icon icon="ep:edit" class="mr-5px" /> 修改
          </el-button>
        </div>

        <div class="sidebar-actions" v-if="isDrawing">
          <el-button type="success" @click="saveEdit">
            <Icon icon="ep:check" class="mr-5px" /> 保存编辑
          </el-button>
          <el-button @click="cancelEdit">
            <Icon icon="ep:close" class="mr-5px" /> 取消编辑
          </el-button>
        </div>

        <div class="sidebar-actions">
          <el-button
            type="success"
            :disabled="geofences.length === 0"
            @click="saveAllGeofences"
          >
            <Icon icon="ep:download" class="mr-5px" /> 保存
          </el-button>
        </div>

        <div class="geofence-list">
          <div v-if="geofences.length === 0" class="empty-state">
            <Icon icon="ep:map-location" :size="48" class="empty-icon" />
            <p class="empty-text">暂无围栏</p>
            <p class="empty-tip">点击「新建围栏」开始绘制</p>
          </div>

          <div v-else class="list-content">
            <div
              v-for="item in geofences"
              :key="item.id"
              class="geofence-item"
              :class="{ active: currentGeofence?.id === item.id }"
              @click="locateGeofence(item)"
            >
              <div class="geofence-info">
                <div class="geofence-color" :style="{ backgroundColor: item.color }" ></div>
                <span class="geofence-name">{{ item.name }}</span>
              </div>
              <div class="geofence-actions">
                <el-button
                  link
                  type="danger"
                  size="small"
                  @click.stop="deleteGeofence(item)"
                >
                  <Icon icon="ep:delete" />
                </el-button>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped lang="scss">
.geofence-page {
  height: calc(100vh - 84px);
  padding: 0;
  margin: 0;
}

.geofence-container {
  display: flex;
  height: 100%;
  background-color: #fff;
}

.map-wrapper {
  flex: 1;
  position: relative;
  height: 100%;
  min-height: 600px;
}

.map-loading,
.map-error {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  z-index: 10;
  padding: 20px;
  background: rgba(255, 255, 255, 0.9);
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}

.loading-icon {
  font-size: 32px;
  color: #409eff;
  animation: rotate 1s linear infinite;
}

.error-icon {
  font-size: 32px;
  color: #f56c6c;
}

@keyframes rotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

/* 确保地图容器高度 */
.map-container {
  width: 100%;
  height: 100%;
  min-height: 600px;
  position: relative;
  z-index: 1;
}

.sidebar {
  width: 320px;
  border-left: 1px solid #e4e7ed;
  background-color: #fff;
  display: flex;
  flex-direction: column;
}

.sidebar-header {
  padding: 20px;
  border-bottom: 1px solid #e4e7ed;
}

.sidebar-title {
  margin: 0 0 8px;
  font-size: 16px;
  font-weight: 600;
  color: #303133;
}

.sidebar-subtitle {
  margin: 0;
  font-size: 12px;
  color: #909399;
}

.sidebar-actions {
  padding: 16px 20px;
  display: flex;
  gap: 10px;
  border-bottom: 1px solid #e4e7ed;

  .el-button {
    flex: 1;
  }
}

.geofence-list {
  flex: 1;
  overflow-y: auto;
  padding: 16px 20px;
}

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 60px 0;
  color: #909399;
}

.empty-icon {
  margin-bottom: 16px;
  color: #c0c4cc;
}

.empty-text {
  margin: 0 0 8px;
  font-size: 14px;
  color: #606266;
}

.empty-tip {
  margin: 0;
  font-size: 12px;
  color: #909399;
}

.list-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.geofence-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px;
  border-radius: 4px;
  border: 1px solid #e4e7ed;
  cursor: pointer;
  transition: all 0.3s;

  &:hover {
    border-color: #409eff;
    background-color: #f5f7fa;
  }

  &.active {
    border-color: #409eff;
    background-color: #ecf5ff;
  }
}

.geofence-info {
  display: flex;
  align-items: center;
  gap: 8px;
}

.geofence-color {
  width: 12px;
  height: 12px;
  border-radius: 2px;
}

.geofence-name {
  font-size: 14px;
  color: #606266;
}

.geofence-actions {
  display: flex;
  gap: 4px;
}

:deep(.leaflet-container) {
  font-family: inherit;
  width: 100%;
  height: 100%;
}
</style>

注意:代码中使用的高德地图瓦片链接,存在较高的收费和法律合规风险,建议商业授权

​​​​​​​也可以自己部署瓦片服务,资源下载地址https://download.geofabrik.de/

相关推荐
KaMeidebaby1 小时前
卡梅德生物技术快报|Fab 抗体文库构建标准化实验流程与数据复盘
服务器·前端·数据库·人工智能·算法
暗冰ཏོ1 小时前
React超详细学习指南
前端·react.js·前端框架
IT_陈寒1 小时前
Python多线程居然不加速?这个坑我踩得明明白白
前端·人工智能·后端
布局呆星1 小时前
Pinia 综合笔记:介绍、两种 API、实例方法与持久化
前端·javascript·vue.js
fxshy1 小时前
Vue 项目中 vis-network 点击节点不生效的问题排查:外层 transform 缩放导致坐标偏移
前端·javascript·vue.js
Maimai108082 小时前
Redux Toolkit 项目落地:从 slice、thunk 到可维护的前端状态管理
前端·javascript·react.js·前端框架·reactjs
ZC跨境爬虫2 小时前
模块化烹饪小程序开发日记 Day3:(Flask后端初始化、数据库配置与自定义日志系统搭建)
前端·javascript·数据库·后端·python·flask
ZC跨境爬虫2 小时前
跟着 MDN 学 HTML day_64:从 object 到 iframe 的嵌入技术全面解析
开发语言·前端·javascript·ui·html·音视频
暗冰ཏོ2 小时前
《前端动画超详细教程:CSS、JS 动画原理、实战与性能优化》
前端·javascript·css·动画