# 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**
```bash
npm uninstall maptalks
npm install mapbox-gl@^3.9.0
```
Expected: `package.json` dependencies 含 `mapbox-gl`,无 `maptalks`。
- [ ] **Step 2: 重写 `src/config/map.js`**
整文件替换为:
```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
# 复制为 .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**
```bash
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`**
```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`**
```vue
```
- [ ] **Step 3: CSS 宿主适配**
在 `src/styles/prototype.css` 把:
```css
.route-planner-map > .maptalks-wrapper,
.route-planner-map > #map { position: absolute !important; inset: 0; width: 100% !important; height: 100% !important; z-index: 0; }
```
改为:
```css
.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**
```bash
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 与图层状态变量**
删除:
```js
import * as maptalks from 'maptalks'
import mapConfig, { createTiandituBaseLayer } from '@/config/map'
```
改为:
```js
import mapConfig, { MAP_STYLES } from '@/config/map'
```
将 `deviceLayer` / `bindingLayer` / `markerById` / `bindingLine` 改为:
```js
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**
```js
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**
```js
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 增加:
```js
import mapboxgl from 'mapbox-gl'
```
(仅用于 `LngLatBounds`;若不想顶层 import,可用 `mapInstance.fitBounds` 前动态 `import('mapbox-gl')`,但本项目直接静态 import 即可。)
- [ ] **Step 4: 点击选中与 setup/teardown**
```js
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')
}
```
删除旧的 `onMapBlankClick`、`map.getLayer` maptalks 路径、`zoomIn`/`zoomOut` 若仍调用 maptalks API 则改为:
```js
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**
```bash
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'`。
```js
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 文案与清理**
```js
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**
```bash
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-line`;`task-waypoints` source;`task-waypoints-circle`;`task-waypoints-label`
- [ ] **Step 1: 替换 import,去掉 maptalks**
```js
import mapboxgl from 'mapbox-gl'
```
- [ ] **Step 2: 重写 syncRouteLayer / 点击 / attach / teardown**
```js
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**
```bash
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: 全局清扫**
```bash
rg -n "maptalks|createTianditu|TIANDITU|setBaseLayer|checkSize|fitExtent|VectorLayer" src package.json
```
Expected: `src/` 与 `package.json` **无** `maptalks` / 天地图运行时引用(docs 历史文件可留)。
若 `vite` 构建报 mapbox worker 错,在 `MapLayer.vue` init 前加:
```js
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: 构建**
```bash
npm run build
```
Expected: 成功;产物无 maptalks。
- [ ] **Step 3: 1920×1080 浏览器冒烟清单**
- [ ] 有效 token:登录见卫星底图
- [ ] 缺 token(临时清空):不白屏,有 toast/日志
- [ ] Monitor:点选、绑定线、卫星↔街道、fitAll、比例尺
- [ ] Replay:航迹 + fit
- [ ] Tasks:搬迁编辑、加点、关闭恢复
- [ ] **Step 4: 最终 commit(若有清扫改动)**
```bash
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 计划一致)。