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 计划一致)。