From c81a1ccc340cfa72d8d636bfba894338eb2e46dd Mon Sep 17 00:00:00 2001
From: xiaosi <2652281683@qq.com>
Date: Thu, 27 Aug 2026 16:37:59 +0800
Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=E7=9B=91=E6=8E=A7?=
=?UTF-8?q?=E5=85=A8=E7=90=83=E8=A7=86=E8=A7=92=E4=B8=8E=E6=B0=B4=E6=BB=B4?=
=?UTF-8?q?=E7=82=B9=E4=BD=8D=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
四任务:重画 pin、MapLayer globe、Monitor symbol/overview、冒烟。
---
.../plans/2026-08-25-monitor-globe-markers.md | 563 ++++++++++++++++++
1 file changed, 563 insertions(+)
create mode 100644 docs/superpowers/plans/2026-08-25-monitor-globe-markers.md
diff --git a/docs/superpowers/plans/2026-08-25-monitor-globe-markers.md b/docs/superpowers/plans/2026-08-25-monitor-globe-markers.md
new file mode 100644
index 0000000..613a63b
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-25-monitor-globe-markers.md
@@ -0,0 +1,563 @@
+# Monitor Globe + Markers 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:** `/monitor` 默认地球全景(中国居中),地图点位改为立体水滴机巢/无人机图标。
+
+**Architecture:** `MapLayer` 开启 `projection: 'globe'` 并以 overview 相机初始化;Monitor 用 Mapbox `symbol` 加载按状态区分的 pin 图;进页/清选中/「查看全部」走 `resetOverviewCamera()`,不再 `fitBounds` 设备包围盒。
+
+**Tech Stack:** Vue 3、Mapbox GL、`public/assets/icons/*-pin-*.svg`
+
+**Spec:** `docs/superpowers/specs/2026-08-25-monitor-globe-markers-design.md`
+
+---
+
+## File map
+
+| File | Responsibility |
+|------|----------------|
+| `public/assets/icons/dock-pin-*.svg` | 机巢水滴 pin(online/mission/alarm/offline/pending) |
+| `public/assets/icons/drone-pin-*.svg` | 无人机水滴 pin(同上) |
+| `src/config/map.js` | `MAP_OVERVIEW`、`MAP_VIEW.detailZoom`;保留 env detail center |
+| `src/layout/MapLayer.vue` | `projection: 'globe'`、`minZoom: 1.5`、初始 overview 相机 |
+| `src/views/MonitorView/MonitorView.vue` | pin `loadImage`、symbol 图层、overview 相机行为 |
+
+不改:Tasks/Replay 业务图层语义、侧栏 `dock.svg`/`drone.svg`、绑定虚线语义、独立 `/globe` 路由。
+
+本仓库无地图单测;每任务以 `npm run build` + 必要的浏览器冒烟为准。
+
+---
+
+### Task 1: 重画水滴 pin SVG
+
+**Files:**
+- Modify: `public/assets/icons/dock-pin.svg`
+- Modify: `public/assets/icons/dock-pin-online.svg`
+- Modify: `public/assets/icons/dock-pin-mission.svg`
+- Modify: `public/assets/icons/dock-pin-alarm.svg`
+- Modify: `public/assets/icons/dock-pin-offline.svg`
+- Modify: `public/assets/icons/dock-pin-pending.svg`
+- Modify: `public/assets/icons/drone-pin.svg`
+- Modify: `public/assets/icons/drone-pin-online.svg`
+- Modify: `public/assets/icons/drone-pin-mission.svg`
+- Modify: `public/assets/icons/drone-pin-alarm.svg`
+- Modify: `public/assets/icons/drone-pin-offline.svg`
+- Modify: `public/assets/icons/drone-pin-pending.svg`
+
+- [ ] **Step 1: 统一 viewBox 与造型约定**
+
+所有 pin:`viewBox="0 0 48 64"`;底部椭圆阴影;水滴针身 + 白描边;针内白色剪影。
+
+颜色:
+
+| key | dock fill | drone fill |
+|-----|-----------|------------|
+| online | `#1F6FBF` | `#1F9B6A` |
+| mission | `#2D82DA` | `#2D82DA` |
+| alarm | `#CC3D43` | `#CC3D43` |
+| offline | `#84909B` | `#84909B` |
+| pending | `#C48A2A` | `#C48A2A` |
+| 无后缀 `dock-pin.svg` / `drone-pin.svg` | 同 online | 同 online |
+
+- [ ] **Step 2: 写入机巢 pin 模板(按状态替换 FILL)**
+
+每个 `dock-pin-*.svg` 整文件:
+
+```svg
+
+```
+
+把 `FILL` 换成上表颜色。`dock-pin.svg` = online。
+
+- [ ] **Step 3: 写入无人机 pin 模板(按状态替换 FILL)**
+
+每个 `drone-pin-*.svg` 整文件:
+
+```svg
+
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add public/assets/icons/dock-pin*.svg public/assets/icons/drone-pin*.svg
+git commit -m "$(cat <<'EOF'
+feat: 重画监控地图机巢/无人机水滴 pin
+
+覆盖 dock-pin/drone-pin 各状态 SVG,统一 48x64 立体水滴造型。
+EOF
+)"
+```
+
+---
+
+### Task 2: map config + MapLayer globe overview
+
+**Files:**
+- Modify: `src/config/map.js`
+- Modify: `src/layout/MapLayer.vue`
+
+- [ ] **Step 1: 扩展 `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',
+}
+
+/** 监控全球总览:固定中国居中,不跟 env 贵州中心 */
+export const MAP_OVERVIEW = {
+ center: [104.0, 35.0],
+ zoom: 1.8,
+}
+
+export const MAP_VIEW = {
+ detailZoom: 16,
+ minZoom: 1.5,
+}
+
+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 2: 更新 `MapLayer.vue` 初始化**
+
+`initMap` 内 `new mapboxgl.Map` 改为:
+
+```js
+import mapConfig, { MAP_STYLES, MAP_OVERVIEW, MAP_VIEW } from '@/config/map'
+```
+
+```js
+map = new mapboxgl.Map({
+ container: 'map',
+ style: MAP_STYLES.satellite,
+ center: MAP_OVERVIEW.center,
+ zoom: MAP_OVERVIEW.zoom,
+ minZoom: MAP_VIEW.minZoom,
+ attributionControl: false,
+ projection: 'globe',
+})
+```
+
+保留现有 token 缺失路径、`initTimer` 清理、`onUnmounted` `map.remove()`。
+
+- [ ] **Step 3: Build**
+
+```bash
+npm run build
+```
+
+Expected: exit 0。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/config/map.js src/layout/MapLayer.vue
+git commit -m "$(cat <<'EOF'
+feat: MapLayer 开启 globe 并以中国总览相机初始化
+
+新增 MAP_OVERVIEW / MAP_VIEW,minZoom 1.5,projection globe。
+EOF
+)"
+```
+
+---
+
+### Task 3: Monitor symbol 图层 + overview 相机
+
+**Files:**
+- Modify: `src/views/MonitorView/MonitorView.vue`
+
+- [ ] **Step 1: 常量与 import**
+
+把:
+
+```js
+import mapConfig, { MAP_STYLES } from '@/config/map'
+```
+
+改为:
+
+```js
+import mapConfig, { MAP_STYLES, MAP_OVERVIEW, MAP_VIEW } from '@/config/map'
+```
+
+图层 id:
+
+```js
+const DEVICE_SOURCE = 'monitor-devices'
+const DEVICE_SYMBOL = 'monitor-devices-symbol'
+const DEVICE_HALO = 'monitor-devices-halo'
+const DEVICE_LABEL = 'monitor-devices-label'
+const BINDING_SOURCE = 'monitor-binding'
+const BINDING_LINE = 'monitor-binding-line'
+
+const PIN_KINDS = ['dock', 'drone']
+const PIN_STATUSES = ['online', 'mission', 'alarm', 'offline', 'pending']
+
+function pinImageId(kind, status) {
+ const s = PIN_STATUSES.includes(status) ? status : 'offline'
+ const k = PIN_KINDS.includes(kind) ? kind : 'dock'
+ return `${k}-pin-${s}`
+}
+```
+
+删除旧 `DEVICE_CIRCLE` 引用(全文件替换为新 id)。
+
+- [ ] **Step 2: Feature properties 增加 icon**
+
+`devicesFeatureCollection` 内 properties 改为:
+
+```js
+const selected = selectedId.value === asset.id
+const status = asset.statusClass || 'offline'
+return {
+ type: 'Feature',
+ properties: {
+ assetId: String(asset.id),
+ name: asset.name || '',
+ kind: asset.type,
+ status,
+ selected: selected ? 1 : 0,
+ icon: pinImageId(asset.type, status),
+ },
+ geometry: { type: 'Point', coordinates: [lon, lat] },
+}
+```
+
+可删除仅给 circle 用的 `color` / `radius`(若图例仍用 `STATUS_COLORS` 则保留该常量)。
+
+- [ ] **Step 3: 加载 pin 图**
+
+在 map helpers 区新增:
+
+```js
+async function ensurePinImages(map) {
+ if (!map) return
+ const jobs = []
+ for (const kind of PIN_KINDS) {
+ for (const status of PIN_STATUSES) {
+ const id = pinImageId(kind, status)
+ if (map.hasImage(id)) continue
+ const url = `/assets/icons/${id}.svg`
+ jobs.push(
+ new Promise((resolve) => {
+ map.loadImage(url, (err, image) => {
+ if (!err && image && !map.hasImage(id)) {
+ map.addImage(id, image, { pixelRatio: 2 })
+ }
+ resolve()
+ })
+ }),
+ )
+ }
+ }
+ await Promise.all(jobs)
+}
+```
+
+注意:`setStyle` 后自定义 image 会丢,必须在 `rebuildAfterStyle` / `ensureMonitorLayers` 前重新 `ensurePinImages`。
+
+若当前 mapbox-gl 版本 `loadImage` 返回 Promise,可改用:
+
+```js
+const image = await map.loadImage(url)
+if (!map.hasImage(id)) map.addImage(id, image.data || image, { pixelRatio: 2 })
+```
+
+以实现时实际 API 为准(先 `rg "loadImage" node_modules/mapbox-gl` 或查类型)。优先 Promise 写法。
+
+- [ ] **Step 4: `ensureMonitorLayers` 改为 symbol + 可选 halo**
+
+```js
+async function ensureMonitorLayers() {
+ if (!mapInstance || !mapInstance.isStyleLoaded()) return
+ await ensurePinImages(mapInstance)
+
+ 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_HALO)) {
+ mapInstance.addLayer({
+ id: DEVICE_HALO,
+ type: 'circle',
+ source: DEVICE_SOURCE,
+ filter: ['==', ['get', 'selected'], 1],
+ paint: {
+ 'circle-radius': 22,
+ 'circle-color': 'rgba(45,130,218,0.22)',
+ 'circle-stroke-width': 0,
+ },
+ })
+ }
+
+ if (!mapInstance.getLayer(DEVICE_SYMBOL)) {
+ mapInstance.addLayer({
+ id: DEVICE_SYMBOL,
+ type: 'symbol',
+ source: DEVICE_SOURCE,
+ layout: {
+ 'icon-image': ['get', 'icon'],
+ 'icon-size': [
+ 'case',
+ ['==', ['get', 'selected'], 1],
+ 1.25,
+ 1,
+ ],
+ 'icon-anchor': 'bottom',
+ 'icon-allow-overlap': true,
+ 'icon-ignore-placement': true,
+ },
+ })
+ }
+
+ if (!mapInstance.getLayer(DEVICE_LABEL)) {
+ mapInstance.addLayer({
+ id: DEVICE_LABEL,
+ type: 'symbol',
+ source: DEVICE_SOURCE,
+ minzoom: 4,
+ layout: {
+ 'text-field': ['get', 'name'],
+ 'text-size': 11,
+ 'text-offset': [0, 0.6],
+ '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
+}
+```
+
+`clearMonitorLayers` 删除顺序改为:
+
+```js
+for (const id of [DEVICE_LABEL, DEVICE_SYMBOL, DEVICE_HALO, BINDING_LINE]) {
+ if (mapInstance.getLayer(id)) mapInstance.removeLayer(id)
+}
+```
+
+- [ ] **Step 5: 相机 helper**
+
+新增:
+
+```js
+function resetOverviewCamera() {
+ if (!mapInstance) return
+ mapInstance.easeTo({
+ center: MAP_OVERVIEW.center,
+ zoom: MAP_OVERVIEW.zoom,
+ pitch: 0,
+ bearing: 0,
+ duration: 450,
+ })
+}
+```
+
+改 `fitAll`:
+
+```js
+function fitAll() {
+ if (selectedId.value) {
+ clearSelection()
+ return
+ }
+ resetOverviewCamera()
+}
+```
+
+`flyToSelected` 使用 `MAP_VIEW.detailZoom`:
+
+```js
+mapInstance.easeTo({
+ center: [lon, lat],
+ zoom: Math.max(mapInstance.getZoom(), MAP_VIEW.detailZoom),
+ duration: 450,
+})
+```
+
+可删除 `fitAllMarkers`,或保留但不再被调用。推荐删除并修所有调用点。
+
+- [ ] **Step 6: setup / rebuild / watch 调用点**
+
+`rebuildAfterStyle`:
+
+```js
+async function rebuildAfterStyle() {
+ pendingStyleHandler = null
+ if (!mapInstance) return
+ layersReady = false
+ await ensureMonitorLayers()
+ syncMarkers()
+ updateMapScale()
+}
+```
+
+`setupMap` 的 `onReady`:
+
+```js
+const onReady = async () => {
+ pendingLoadHandler = null
+ if (!mapInstance) return
+ await ensureMonitorLayers()
+ syncMarkers()
+ if (selectedId.value) flyToSelected()
+ else resetOverviewCamera()
+ updateMapScale()
+}
+```
+
+`watch(selectedId)`:
+
+```js
+watch(selectedId, () => {
+ syncMarkers()
+ if (selectedId.value) flyToSelected()
+ else resetOverviewCamera()
+})
+```
+
+全局搜索 `fitAllMarkers`,全部改为 `resetOverviewCamera` 或删除。
+
+`onMapClick` 命中图层改为:
+
+```js
+const layers = [DEVICE_SYMBOL, DEVICE_LABEL].filter((id) => mapInstance.getLayer(id))
+```
+
+可选:symbol 上 `mouseenter`/`mouseleave` 设 `map.getCanvas().style.cursor`。
+
+- [ ] **Step 7: Build**
+
+```bash
+npm run build
+```
+
+Expected: exit 0。
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/views/MonitorView/MonitorView.vue
+git commit -m "$(cat <<'EOF'
+feat: 监控地图改水滴 symbol 并默认全球总览相机
+
+加载状态 pin;清选中/查看全部回中国居中;去掉进页 fitBounds。
+EOF
+)"
+```
+
+---
+
+### Task 4: 浏览器冒烟验收
+
+**Files:** 无代码;验证 Task 1–3。
+
+- [ ] **Step 1: 启动**
+
+```bash
+npm run dev -- --host 0.0.0.0 --port 5173
+```
+
+登录进 `/monitor`(1920×1080)。
+
+- [ ] **Step 2: 验收清单**
+
+1. 进页:地球外形可见,中国大致居中,zoom 全球级;**不**自动 fit 到设备包围盒。
+2. 可见机巢蓝针 / 无人机绿针(有对应状态设备时告警红、离线灰可辨)。
+3. 点击设备:飞近;详情面板打开。
+4. 点空白或关详情:回到全球中国居中。
+5. 点「查看全部」:同样回 overview。
+6. 卫星 ↔ 地图:点位与绑定虚线仍在。
+7. 控制台无 map/style/image 报错。
+
+- [ ] **Step 3: 收尾 commit(仅当冒烟中修了小问题)**
+
+若无代码改动则跳过。有则:
+
+```bash
+git add -A
+git commit -m "$(cat <<'EOF'
+fix: 监控全球视角与 pin 冒烟问题修复
+EOF
+)"
+```
+
+---
+
+## Spec coverage
+
+| Spec 项 | Task |
+|---------|------|
+| `projection: 'globe'` + minZoom 1.5 | 2 |
+| `MAP_OVERVIEW` 中国居中 / zoom 1.8 | 2 |
+| 进页不 fitBounds | 3 |
+| 清选中 / 查看全部 → overview | 3 |
+| 点选 detailZoom | 3 |
+| 重画 pin SVG 覆盖 | 1 |
+| circle → symbol + selected 放大/光晕 | 3 |
+| label minzoom ≥ 4 | 3 |
+| setStyle 后重建 + 重载 image | 3 |
+| 绑定虚线保留 | 3(不改语义) |
+| 不做 /globe、cluster、侧栏图标 | 全任务遵守 |
+| build + 冒烟 | 2/3/4 |
+
+## Placeholder / consistency check
+
+- 无 TBD/TODO。
+- 图层 id 统一 `DEVICE_SYMBOL` / `DEVICE_HALO`。
+- 相机 API 统一 `resetOverviewCamera` / `MAP_OVERVIEW` / `MAP_VIEW.detailZoom`。
+- pin id 统一 `` `${kind}-pin-${status}` `` 对应 `/assets/icons/...svg`。