You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

28 KiB

Mapbox 全量替换 maptalks Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 将运行时地图引擎从 maptalks + 天地图全量切到 mapbox-gl + Mapbox 官方样式,并迁移 Monitor / Tasks / Replay 全部地图 API。

Architecture: 保留常驻 MapLayer + commonRefs('map') + mapHelper 薄封装;内部换成 mapboxgl.Map。页面用 GeoJSON Source + Layer;setStyle 后经 style.load 重建业务层。Tasks 继续 DOM replace/restore,内部改 resize()

Tech Stack: Vue 3、Vite 5、mapbox-gl、现有 Pinia devicesStore、prototype.css

Spec: docs/superpowers/specs/2026-08-27-mapbox-migration-design.md


File map

File Responsibility
package.json 依赖:加 mapbox-gl,删 maptalks
.env.example / .env.development / .env.production APP_MAPBOX_TOKEN;去掉天地图必填
src/config/map.js token / center / zoom / MAP_STYLES;删天地图 helper
src/layout/MapLayer.vue 唯一建图入口:mapboxgl.Map
src/core/mapHelper.js pitch/bearing、replace/restore + resize()
src/views/MonitorView/MonitorView.vue 设备点、绑定线、底图切换、fit/fly、比例尺
src/views/ReplayView/ReplayView.vue 航迹 line + 点 + fitBounds
src/views/TasksView/TasksView.vue 搬迁宿主、点击加点、航线/航点 layer
src/styles/prototype.css #map / .mapboxgl-map;删 .maptalks-wrapper
vite.config.js 仅在 worker 加载需要时补配置(优先用官方默认 worker)

Task 1: 依赖与 env / map config

Files:

  • Modify: package.json

  • Modify: .env.example

  • Modify: .env.development

  • Modify: .env.production

  • Modify: src/config/map.js

  • Step 1: 安装 mapbox-gl,移除 maptalks

npm uninstall maptalks
npm install mapbox-gl@^3.9.0

Expected: package.json dependencies 含 mapbox-gl,无 maptalks

  • Step 2: 重写 src/config/map.js

整文件替换为:

const token = import.meta.env.APP_MAPBOX_TOKEN || import.meta.env.VITE_MAPBOX_TOKEN || ''

export const MAP_STYLES = {
  satellite: 'mapbox://styles/mapbox/satellite-streets-v12',
  street: 'mapbox://styles/mapbox/streets-v12',
}

export default {
  token,
  center: [
    Number(import.meta.env.APP_MAP_CENTER_LNG) || 106.62341584179686,
    Number(import.meta.env.APP_MAP_CENTER_LAT) || 26.657826154258505,
  ],
  zoom: Number(import.meta.env.APP_MAP_ZOOM) || 5,
}
  • Step 3: 更新 env 文件

.env.example

# 复制为 .env.development / .env.local 后填写
# Vite 通过 envPrefix: ['APP_','VITE_'] 暴露给前端

# Mapbox token(必填,否则 MapLayer 跳过初始化)
# 申请:https://account.mapbox.com/access-tokens/
APP_MAPBOX_TOKEN=

# 默认地图中心(经度 / 纬度)与缩放级别
APP_MAP_CENTER_LNG=106.62341584179686
APP_MAP_CENTER_LAT=26.657826154258505
APP_MAP_ZOOM=5

.env.development / .env.production:把 APP_TIANDITU_TOKEN=... 换成 APP_MAPBOX_TOKEN=(留空或填真实 token;不要把旧天地图 token 当成 Mapbox token)。保留中心/缩放行。

  • Step 4: Commit
git add package.json package-lock.json src/config/map.js .env.example .env.development .env.production
git commit -m "$(cat <<'EOF'
chore: 引入 mapbox-gl 并替换地图 env/config

删除 maptalks 与天地图配置,改为 APP_MAPBOX_TOKEN + MAP_STYLES。
EOF
)"

Task 2: MapLayer + mapHelper 薄封装

Files:

  • Modify: src/layout/MapLayer.vue

  • Modify: src/core/mapHelper.js

  • Modify: src/styles/prototype.css(Tasks 宿主选择器,可本 task 先改)

  • Step 1: 重写 src/core/mapHelper.js

/**
 * 地图助手(Mapbox)
 * replace/restore 移动 #map 容器后必须 resize()
 */
class MapHelper {
  _map = null
  _originParent = null
  _savedView = null

  setMap(map) {
    this._map = map
  }

  getMap() {
    return this._map
  }

  toggleMapMode({ is2D = false, is3D = false } = {}) {
    if (!this._map) return

    if (is3D) {
      this._map.easeTo({ pitch: 45, bearing: 0, duration: 0 })
      this._map.dragRotate.enable()
      this._map.touchZoomRotate.enableRotation()
      if (this._map.dragPitch) this._map.dragPitch.enable()
    }

    if (is2D) {
      this._map.easeTo({ pitch: 0, bearing: 0, duration: 0 })
      this._map.dragRotate.disable()
      this._map.touchZoomRotate.disableRotation()
      if (this._map.dragPitch) this._map.dragPitch.disable()
    }
  }

  replaceMapContainer(newParent) {
    if (!this._map || !newParent) return

    const el = this._map.getContainer()
    this._originParent = this._originParent || el.parentNode
    const c = this._map.getCenter()
    this._savedView = {
      center: [c.lng, c.lat],
      zoom: this._map.getZoom(),
      pitch: this._map.getPitch(),
      bearing: this._map.getBearing(),
    }
    newParent.appendChild(el)
    this._map.resize()
  }

  restoreMapContainer() {
    if (!this._map || !this._originParent) return

    const el = this._map.getContainer()
    this._originParent.appendChild(el)

    if (this._savedView) {
      this._map.jumpTo({
        center: this._savedView.center,
        zoom: this._savedView.zoom,
        pitch: this._savedView.pitch,
        bearing: this._savedView.bearing,
      })
    }

    this._map.resize()
    this._originParent = null
    this._savedView = null
  }
}

export default new MapHelper()
  • Step 2: 重写 src/layout/MapLayer.vue
<script setup>
import { onMounted, onUnmounted } from 'vue'
import mapboxgl from 'mapbox-gl'
import mapConfig, { MAP_STYLES } from '@/config/map'
import commonRefs from '@/utils/commonRefs'
import mapHelper from '@/core/mapHelper'
import 'mapbox-gl/dist/mapbox-gl.css'

let map = null

function initMap() {
  const tk = mapConfig.token
  if (!tk) {
    console.error('[MapLayer] 缺少 APP_MAPBOX_TOKEN,跳过地图初始化')
    commonRefs.setRef('map', null)
    return
  }

  mapboxgl.accessToken = tk
  map = new mapboxgl.Map({
    container: 'map',
    style: MAP_STYLES.satellite,
    center: mapConfig.center,
    zoom: mapConfig.zoom,
    minZoom: 3,
    attributionControl: false,
  })

  commonRefs.setRef('map', map)
  mapHelper.setMap(map)
}

onMounted(() => {
  setTimeout(() => {
    try {
      initMap()
    } catch (error) {
      console.error('[MapLayer] 地图初始化失败:', error)
      commonRefs.setRef('map', null)
    }
  })
})

onUnmounted(() => {
  if (map) {
    map.remove()
    commonRefs.removeRef('map')
    mapHelper.setMap(null)
    map = null
  }
})
</script>

<template>
  <div id="map" :class="s.root" />
</template>

<style lang="less" module="s">
.root {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  bottom: 0;
  height: 100%;
  z-index: 0;
}
</style>
  • Step 3: CSS 宿主适配

src/styles/prototype.css 把:

.route-planner-map > .maptalks-wrapper,
.route-planner-map > #map { position: absolute !important; inset: 0; width: 100% !important; height: 100% !important; z-index: 0; }

改为:

.route-planner-map > #map,
.route-planner-map > .mapboxgl-map {
  position: absolute !important;
  inset: 0;
  width: 100% !important;
  height: 100% !important;
  z-index: 0;
}

全文件搜索删除其它 maptalks 字样(若有)。

  • Step 4: 冒烟共享层(需有效 token)
  1. 确保 .env.development 已填真实 APP_MAPBOX_TOKEN
  2. npm run dev
  3. 登录后应看到 Mapbox 卫星底图;控制台无 maptalks 报错
  4. 若缺 token:控制台 [MapLayer] 缺少 APP_MAPBOX_TOKEN...,页面不白屏崩
  • Step 5: Commit
git add src/layout/MapLayer.vue src/core/mapHelper.js src/styles/prototype.css
git commit -m "$(cat <<'EOF'
feat: MapLayer/mapHelper 切换到 mapbox-gl

常驻地图改用官方样式;容器搬迁改为 resize。
EOF
)"

Task 3: Monitor 设备点 / 绑定线 / 底图切换

Files:

  • Modify: src/views/MonitorView/MonitorView.vue

约定 ID:

  • source/layer devices: monitor-devices / monitor-devices-circle / monitor-devices-label

  • source/layer binding: monitor-binding / monitor-binding-line

  • Step 1: 替换 import 与图层状态变量

删除:

import * as maptalks from 'maptalks'
import mapConfig, { createTiandituBaseLayer } from '@/config/map'

改为:

import mapConfig, { MAP_STYLES } from '@/config/map'

deviceLayer / bindingLayer / markerById / bindingLine 改为:

const DEVICE_SOURCE = 'monitor-devices'
const DEVICE_CIRCLE = 'monitor-devices-circle'
const DEVICE_LABEL = 'monitor-devices-label'
const BINDING_SOURCE = 'monitor-binding'
const BINDING_LINE = 'monitor-binding-line'

let mapInstance = null
let layersReady = false
let suppressMapClick = false

删除 markerById Map 与 markerSymbol(改由 GeoJSON properties + paint 表达)。

  • Step 2: 实现 ensureMonitorLayers / clearMonitorLayers / devicesFeatureCollection
function statusColor(statusClass) {
  return STATUS_COLORS[statusClass] || STATUS_COLORS.offline
}

function devicesFeatureCollection() {
  return {
    type: 'FeatureCollection',
    features: mapMarkers.value
      .map((asset) => {
        const lon = Number(asset.longitude)
        const lat = Number(asset.latitude)
        if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null
        const selected = selectedId.value === asset.id
        return {
          type: 'Feature',
          id: asset.id, // 供 feature-state;若 id 非数值,改用 properties + setData 全量刷新
          properties: {
            assetId: String(asset.id),
            name: asset.name || '',
            kind: asset.type,
            selected,
            color: statusColor(asset.statusClass),
            radius: asset.type === 'drone' ? (selected ? 10 : 8) : (selected ? 12 : 10),
          },
          geometry: { type: 'Point', coordinates: [lon, lat] },
        }
      })
      .filter(Boolean),
  }
}

function bindingFeatureCollection() {
  const ends = bindingEndpoints()
  if (!ends) {
    return { type: 'FeatureCollection', features: [] }
  }
  return {
    type: 'FeatureCollection',
    features: [
      {
        type: 'Feature',
        properties: { label: '绑定设备' },
        geometry: { type: 'LineString', coordinates: ends },
      },
    ],
  }
}

function ensureMonitorLayers() {
  if (!mapInstance || !mapInstance.isStyleLoaded()) return
  if (!mapInstance.getSource(DEVICE_SOURCE)) {
    mapInstance.addSource(DEVICE_SOURCE, { type: 'geojson', data: devicesFeatureCollection() })
  }
  if (!mapInstance.getSource(BINDING_SOURCE)) {
    mapInstance.addSource(BINDING_SOURCE, { type: 'geojson', data: bindingFeatureCollection() })
  }
  if (!mapInstance.getLayer(BINDING_LINE)) {
    mapInstance.addLayer({
      id: BINDING_LINE,
      type: 'line',
      source: BINDING_SOURCE,
      paint: {
        'line-color': 'rgba(117,191,246,0.95)',
        'line-width': 2,
        'line-dasharray': [2, 1.5],
        'line-opacity': 0.95,
      },
    })
  }
  if (!mapInstance.getLayer(DEVICE_CIRCLE)) {
    mapInstance.addLayer({
      id: DEVICE_CIRCLE,
      type: 'circle',
      source: DEVICE_SOURCE,
      paint: {
        'circle-radius': ['get', 'radius'],
        'circle-color': ['get', 'color'],
        'circle-stroke-color': '#fff',
        'circle-stroke-width': [
          'case',
          ['==', ['get', 'selected'], true],
          3,
          2,
        ],
      },
    })
  }
  if (!mapInstance.getLayer(DEVICE_LABEL)) {
    mapInstance.addLayer({
      id: DEVICE_LABEL,
      type: 'symbol',
      source: DEVICE_SOURCE,
      layout: {
        'text-field': ['get', 'name'],
        'text-size': 11,
        'text-offset': [0, 1.4],
        'text-anchor': 'top',
        'text-allow-overlap': false,
      },
      paint: {
        'text-color': '#fff',
        'text-halo-color': 'rgba(24,35,45,.86)',
        'text-halo-width': 1.2,
      },
    })
  }
  layersReady = true
}

function clearMonitorLayers() {
  if (!mapInstance) return
  for (const id of [DEVICE_LABEL, DEVICE_CIRCLE, BINDING_LINE]) {
    if (mapInstance.getLayer(id)) mapInstance.removeLayer(id)
  }
  for (const id of [DEVICE_SOURCE, BINDING_SOURCE]) {
    if (mapInstance.getSource(id)) mapInstance.removeSource(id)
  }
  layersReady = false
}

保留现有 bindingEndpoints() 逻辑不变。

  • Step 3: 重写 syncMarkers / syncBindingLine / setMapMode / fit / fly
function syncMarkers() {
  if (!mapInstance || !layersReady) return
  const src = mapInstance.getSource(DEVICE_SOURCE)
  if (src) src.setData(devicesFeatureCollection())
  syncBindingLine()
}

function syncBindingLine() {
  if (!mapInstance || !layersReady) return
  const src = mapInstance.getSource(BINDING_SOURCE)
  if (src) src.setData(bindingFeatureCollection())
}

function rebuildAfterStyle() {
  layersReady = false
  ensureMonitorLayers()
  syncMarkers()
  updateMapScale()
}

function setMapMode(mode) {
  if (!mapInstance || mapMode.value === mode) {
    mapMode.value = mode
    return
  }
  if (!mapConfig.token) {
    ui.toast('缺少 Mapbox token,无法切换底图')
    return
  }
  mapMode.value = mode
  mapInstance.setStyle(MAP_STYLES[mode] || MAP_STYLES.satellite)
  mapInstance.once('style.load', rebuildAfterStyle)
}

function fitAllMarkers() {
  if (!mapInstance) return
  const coords = mapMarkers.value
    .map((a) => [Number(a.longitude), Number(a.latitude)])
    .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat))

  if (!coords.length) {
    mapInstance.jumpTo({ center: mapConfig.center, zoom: mapConfig.zoom })
    return
  }
  if (coords.length === 1) {
    mapInstance.easeTo({ center: coords[0], zoom: 16, duration: 450 })
    return
  }
  const bounds = coords.reduce(
    (b, c) => b.extend(c),
    new mapboxgl.LngLatBounds(coords[0], coords[0]),
  )
  mapInstance.fitBounds(bounds, { padding: 60, duration: 450 })
}

function flyToSelected() {
  if (!mapInstance || !selectedId.value) return
  const asset = devices.asset(selectedId.value)
  if (!asset?.hasCoordinates) return
  const lon = Number(asset.longitude)
  const lat = Number(asset.latitude)
  if (!Number.isFinite(lon) || !Number.isFinite(lat)) return
  mapInstance.easeTo({
    center: [lon, lat],
    zoom: Math.max(mapInstance.getZoom(), 16),
    duration: 450,
  })
}

在文件顶部 script 增加:

import mapboxgl from 'mapbox-gl'

(仅用于 LngLatBounds;若不想顶层 import,可用 mapInstance.fitBounds 前动态 import('mapbox-gl'),但本项目直接静态 import 即可。)

  • Step 4: 点击选中与 setup/teardown
function onMapClick(e) {
  if (suppressMapClick) return
  const feats = mapInstance.queryRenderedFeatures(e.point, {
    layers: [DEVICE_CIRCLE, DEVICE_LABEL].filter((id) => mapInstance.getLayer(id)),
  })
  const id = feats[0]?.properties?.assetId
  if (id) {
    suppressMapClick = true
    select(String(id))
    setTimeout(() => { suppressMapClick = false }, 0)
    return
  }
  clearSelection()
}

async function setupMap() {
  const map = await commonRefs.getRef('map')
  if (!map) {
    ui.toast('地图未初始化(缺少 Mapbox token)')
    return
  }
  mapInstance = map
  mapHelper.toggleMapMode({ is2D: true })

  const onReady = () => {
    ensureMonitorLayers()
    syncMarkers()
    if (selectedId.value) flyToSelected()
    else fitAllMarkers()
    updateMapScale()
  }

  if (map.isStyleLoaded()) onReady()
  else map.once('load', onReady)

  map.on('click', onMapClick)
  map.on('zoom', updateMapScale)
  map.on('move', updateMapScale)
}

function teardownMap() {
  if (mapInstance) {
    mapInstance.off('click', onMapClick)
    mapInstance.off('zoom', updateMapScale)
    mapInstance.off('move', updateMapScale)
    clearMonitorLayers()
  }
  mapInstance = null
  commonRefs.clearPendingList('map')
}

删除旧的 onMapBlankClickmap.getLayer maptalks 路径、zoomIn/zoomOut 若仍调用 maptalks API 则改为:

function zoomIn() {
  if (!mapInstance) return
  mapInstance.zoomTo(Math.min(mapInstance.getMaxZoom(), mapInstance.getZoom() + 1), { duration: 200 })
}
function zoomOut() {
  if (!mapInstance) return
  mapInstance.zoomTo(Math.max(mapInstance.getMinZoom(), mapInstance.getZoom() - 1), { duration: 200 })
}

updateMapScale 继续用 mapInstance.getZoom()(Mapbox 同样有)。

  • Step 5: 浏览器冒烟 Monitor
  1. 登录 /monitor,见卫星底图与设备圆点/文字
  2. 点设备 → 右侧详情 + 飞向;飞行无人机出现绑定虚线
  3. 切「地图」→ 街道样式;点/线在 style.load 后仍在
  4. fitAll 清选中并框住全部点
  5. 源码检索:rg "maptalks|createTianditu|setBaseLayer" src/views/MonitorView 应无匹配
  • Step 6: Commit
git add src/views/MonitorView/MonitorView.vue
git commit -m "$(cat <<'EOF'
feat: Monitor 地图改为 Mapbox GeoJSON 图层

设备点、绑定虚线、样式切换与 fit/fly 全量替换 maptalks。
EOF
)"

Task 4: Replay 航迹

Files:

  • Modify: src/views/ReplayView/ReplayView.vue

  • Step 1: 替换 import 与 drawTrajectory

删除 import * as maptalks from 'maptalks'

import mapboxgl from 'mapbox-gl'

const LAYER_ID = 'replay-traj'
const SOURCE_ID = 'replay-traj'
const LINE_LAYER = 'replay-traj-line'
const POINT_LAYER = 'replay-traj-points'

function clearReplayLayers(map) {
  if (!map) return
  for (const id of [POINT_LAYER, LINE_LAYER]) {
    if (map.getLayer(id)) map.removeLayer(id)
  }
  if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID)
}

function drawTrajectory(map, list) {
  clearReplayLayers(map)
  const coords = toCoords(list)
  const lineFeatures = coords.length >= 2
    ? [{
        type: 'Feature',
        geometry: { type: 'LineString', coordinates: coords },
        properties: {},
      }]
    : []
  const pointFeatures = coords.map((c, i) => ({
    type: 'Feature',
    properties: {
      role: i === 0 ? 'start' : i === coords.length - 1 ? 'end' : 'mid',
      color: i === 0 ? '#21a06a' : i === coords.length - 1 ? '#c56c28' : '#2b79c8',
      radius: i === 0 || i === coords.length - 1 ? 6 : 3.5,
    },
    geometry: { type: 'Point', coordinates: c },
  }))

  map.addSource(SOURCE_ID, {
    type: 'geojson',
    data: {
      type: 'FeatureCollection',
      features: [...lineFeatures, ...pointFeatures],
    },
  })

  if (coords.length >= 2) {
    map.addLayer({
      id: LINE_LAYER,
      type: 'line',
      source: SOURCE_ID,
      filter: ['==', ['geometry-type'], 'LineString'],
      paint: {
        'line-color': '#2b79c8',
        'line-width': 3,
        'line-dasharray': [2, 1.5],
        'line-opacity': 0.95,
      },
    })
  }

  map.addLayer({
    id: POINT_LAYER,
    type: 'circle',
    source: SOURCE_ID,
    filter: ['==', ['geometry-type'], 'Point'],
    paint: {
      'circle-radius': ['get', 'radius'],
      'circle-color': ['get', 'color'],
      'circle-stroke-color': '#fff',
      'circle-stroke-width': 2,
    },
  })

  if (!coords.length) return
  if (coords.length === 1) {
    map.easeTo({ center: coords[0], zoom: Math.max(map.getZoom(), 16), duration: 400 })
    return
  }
  const bounds = coords.reduce(
    (b, c) => b.extend(c),
    new mapboxgl.LngLatBounds(coords[0], coords[0]),
  )
  map.fitBounds(bounds, { padding: 80, duration: 400 })
}
  • Step 2: setup/teardown 文案与清理
async function setupMap() {
  const map = await commonRefs.getRef('map')
  if (!map) {
    ui.toast('地图未初始化(缺少 Mapbox token)')
    return
  }
  mapInstance = map
  mapHelper.toggleMapMode({ is2D: true })
  const run = () => drawTrajectory(map, points.value)
  if (map.isStyleLoaded()) run()
  else map.once('load', run)
}

function teardownMap() {
  clearReplayLayers(mapInstance)
  trajLayer = null
  mapInstance = null
  commonRefs.clearPendingList('map')
}

删除对 trajLayer maptalks 实例的依赖(可删变量或仅作标志)。

  • Step 3: 冒烟

打开任一回放页:航迹虚线 + 起终点色点 + 自动 fit;离开页无残留 layer(再进 Monitor 不应看到回放线)。

  • Step 4: Commit
git add src/views/ReplayView/ReplayView.vue
git commit -m "$(cat <<'EOF'
feat: Replay 航迹改为 Mapbox GeoJSON 图层
EOF
)"

Task 5: Tasks 航线编辑地图

Files:

  • Modify: src/views/TasksView/TasksView.vue

约定 ID: task-route source;task-route-linetask-waypoints source;task-waypoints-circletask-waypoints-label

  • Step 1: 替换 import,去掉 maptalks
import mapboxgl from 'mapbox-gl'
  • Step 2: 重写 syncRouteLayer / 点击 / attach / teardown
const ROUTE_SOURCE = 'task-route'
const ROUTE_LINE = 'task-route-line'
const WP_SOURCE = 'task-waypoints'
const WP_CIRCLE = 'task-waypoints-circle'
const WP_LABEL = 'task-waypoints-label'

function clearTaskLayers(map) {
  if (!map) return
  for (const id of [WP_LABEL, WP_CIRCLE, ROUTE_LINE]) {
    if (map.getLayer(id)) map.removeLayer(id)
  }
  for (const id of [WP_SOURCE, ROUTE_SOURCE]) {
    if (map.getSource(id)) map.removeSource(id)
  }
}

function ensureTaskLayers(map) {
  if (!map.getSource(ROUTE_SOURCE)) {
    map.addSource(ROUTE_SOURCE, {
      type: 'geojson',
      data: { type: 'FeatureCollection', features: [] },
    })
  }
  if (!map.getSource(WP_SOURCE)) {
    map.addSource(WP_SOURCE, {
      type: 'geojson',
      data: { type: 'FeatureCollection', features: [] },
    })
  }
  if (!map.getLayer(ROUTE_LINE)) {
    map.addLayer({
      id: ROUTE_LINE,
      type: 'line',
      source: ROUTE_SOURCE,
      paint: {
        'line-color': '#2d82da',
        'line-width': 3,
        'line-opacity': 0.95,
      },
    })
  }
  if (!map.getLayer(WP_CIRCLE)) {
    map.addLayer({
      id: WP_CIRCLE,
      type: 'circle',
      source: WP_SOURCE,
      paint: {
        'circle-radius': 9,
        'circle-color': ['get', 'color'],
        'circle-stroke-color': '#fff',
        'circle-stroke-width': 2,
      },
    })
  }
  if (!map.getLayer(WP_LABEL)) {
    map.addLayer({
      id: WP_LABEL,
      type: 'symbol',
      source: WP_SOURCE,
      layout: {
        'text-field': ['get', 'indexLabel'],
        'text-size': 11,
        'text-allow-overlap': true,
      },
      paint: { 'text-color': '#fff' },
    })
  }
}

function syncRouteLayer() {
  if (!routeMapInstance) return
  ensureTaskLayers(routeMapInstance)
  const coords = routePoints.value
    .map((p) => [Number(p.longitude), Number(p.latitude)])
    .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat))

  const lineData = {
    type: 'FeatureCollection',
    features: coords.length >= 2
      ? [{ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} }]
      : [],
  }
  const wpData = {
    type: 'FeatureCollection',
    features: coords.map((c, i) => {
      const isStart = i === 0
      const isEnd = i === coords.length - 1 && coords.length > 1
      return {
        type: 'Feature',
        properties: {
          index: i,
          indexLabel: String(i + 1),
          color: isStart ? '#35a66a' : isEnd ? '#c56c28' : '#2d82da',
        },
        geometry: { type: 'Point', coordinates: c },
      }
    }),
  }
  routeMapInstance.getSource(ROUTE_SOURCE)?.setData(lineData)
  routeMapInstance.getSource(WP_SOURCE)?.setData(wpData)
}

function onRouteMapClick(e) {
  if (!routeMapAttached || !routeMapInstance) return
  const hit = routeMapInstance.queryRenderedFeatures(e.point, {
    layers: [WP_CIRCLE, WP_LABEL].filter((id) => routeMapInstance.getLayer(id)),
  })
  if (hit[0]?.properties?.index != null) {
    openWaypointEditor(Number(hit[0].properties.index))
    return
  }
  if (!validRouteDefaults()) return
  const { lng, lat } = e.lngLat
  routePoints.value.push({
    longitude: lng,
    latitude: lat,
    altitude: Number(routeForm.altitude),
    speed: Number(routeForm.speed),
    turnMode: routeForm.turnMode,
  })
  routeCursor.value = formatCoord(lng, lat)
  routeFormError.value = ''
  syncRouteLayer()
}

function onRouteMapMove(e) {
  if (!routeMapAttached) return
  routeCursor.value = formatCoord(e.lngLat.lng, e.lngLat.lat)
}

async function attachRouteMap() {
  await nextTick()
  const host = routeMapEl.value
  if (!host) return

  const map = await commonRefs.getRef('map')
  if (!map) {
    routeMapReady.value = false
    ui.toast('地图未初始化(缺少 Mapbox token)')
    return
  }

  routeMapInstance = map
  mapHelper.replaceMapContainer(host)
  mapHelper.toggleMapMode({ is2D: true })
  routeMapAttached = true
  routeMapReady.value = true

  clearTaskLayers(map)
  ensureTaskLayers(map)

  map.on('click', onRouteMapClick)
  map.on('mousemove', onRouteMapMove)

  const c = map.getCenter()
  routeCursor.value = formatCoord(c.lng, c.lat)

  requestAnimationFrame(() => {
    map.resize()
    syncRouteLayer()
  })
}

function teardownRouteMap() {
  if (routeMapInstance) {
    routeMapInstance.off('click', onRouteMapClick)
    routeMapInstance.off('mousemove', onRouteMapMove)
    clearTaskLayers(routeMapInstance)
  }
  if (routeMapAttached) {
    mapHelper.restoreMapContainer()
    routeMapAttached = false
  }
  routeMapInstance = null
  routeMapReady.value = false
}

删除 routeLayer maptalks 变量及所有 new maptalks.*

  • Step 3: 冒烟 Tasks
  1. 打开航线编辑器:地图出现在编辑面板内(非主壳空白)
  2. 点击加点 → 序号圆点 + 连线;点已有点打开编辑
  3. 关闭编辑器:地图回到主壳;再进 Monitor 无航线残留
  • Step 4: Commit
git add src/views/TasksView/TasksView.vue
git commit -m "$(cat <<'EOF'
feat: Tasks 航线编辑改为 Mapbox 图层与点击交互

保留 replace/restore 搬迁语义,容器刷新改 resize。
EOF
)"

Task 6: 清残留 + 全量验收

Files:

  • Modify: any remaining maptalks / TIANDITU references in src/

  • Verify: package.json, build, browser

  • Step 1: 全局清扫

rg -n "maptalks|createTianditu|TIANDITU|setBaseLayer|checkSize|fitExtent|VectorLayer" src package.json

Expected: src/package.json maptalks / 天地图运行时引用(docs 历史文件可留)。

vite 构建报 mapbox worker 错,在 MapLayer.vue init 前加:

mapboxgl.workerUrl = new URL('mapbox-gl/dist/mapbox-gl-csp-worker.js', import.meta.url).toString()

或按报错补 vite.config.js optimizeDeps.include: ['mapbox-gl']

  • Step 2: 构建
npm run build

Expected: 成功;产物无 maptalks。

  • Step 3: 1920×1080 浏览器冒烟清单

  • 有效 token:登录见卫星底图

  • 缺 token(临时清空):不白屏,有 toast/日志

  • Monitor:点选、绑定线、卫星↔街道、fitAll、比例尺

  • Replay:航迹 + fit

  • Tasks:搬迁编辑、加点、关闭恢复

  • Step 4: 最终 commit(若有清扫改动)

git add -A
git status
git commit -m "$(cat <<'EOF'
chore: 清除 maptalks/天地图残留并完成 Mapbox 迁移验收
EOF
)"

Spec coverage checklist

Spec 项 Task
mapbox-gl 依赖 / 删 maptalks 1, 6
APP_MAPBOX_TOKEN + MAP_STYLES 1
MapLayer 初始化 / 缺 token 降级 2
mapHelper toggle + replace/restore + resize 2, 5
Monitor GeoJSON 点/线 / setStyle+style.load 3
Replay 航迹 4
Tasks 搬迁 + 点击加点 5
CSS 去 maptalks-wrapper 2
验收清单 6

执行前注意

  • 必须先准备真实 Mapbox token 写入 .env.development,否则 Task 2 起无法目视验收底图。
  • 本仓库无地图单测;验收以 npm run build + 浏览器冒烟为准(与既有 Monitor 计划一致)。