From b847ae246d8e1ae5ceade17164cd185320aea539 Mon Sep 17 00:00:00 2001
From: xiaosi <2652281683@qq.com>
Date: Thu, 27 Aug 2026 11:09:22 +0800
Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20Mapbox=20=E6=9B=BF?=
=?UTF-8?q?=E6=8D=A2=20maptalks=20=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
按共享层→Monitor→Replay→Tasks→清残留验收拆分任务。
---
.../plans/2026-08-27-mapbox-migration.md | 1068 +++++++++++++++++
1 file changed, 1068 insertions(+)
create mode 100644 docs/superpowers/plans/2026-08-27-mapbox-migration.md
diff --git a/docs/superpowers/plans/2026-08-27-mapbox-migration.md b/docs/superpowers/plans/2026-08-27-mapbox-migration.md
new file mode 100644
index 0000000..9c0c69d
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-27-mapbox-migration.md
@@ -0,0 +1,1068 @@
+# 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 计划一致)。