2026-06-13-terrain-marker-redesign.md 19 KB

Terrain Marker Redesign 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: 将 3D 地形 viewer 的标记点升级为六边形图标 + 标签牌 + 垂线风格,并支持点击弹出 video / html / device 三种内容面板。

Architecture: 使用 CSS2DRenderer 在 3D 空间中渲染 DOM 标签牌,Three.js Mesh 渲染地面六边形底座和垂线;点击后由 Vue 浮层弹窗根据 marker.type 渲染对应内容,并在相机变化时实时跟随标记点。

Tech Stack: Vue 3, Vite, Three.js r184, Element Plus


File Map

文件 职责
src/components/MarkerPopup.vue 根据 marker type 渲染 video / html / device 弹窗内容
src/components/TerrainViewer.vue 集成 CSS2DRenderer、创建六边形底座/垂线/标签、管理弹窗状态
src/App.vue 更新示例 markers,覆盖三种 type

Task 1: 创建 src/components/MarkerPopup.vue

Files:

  • Create: src/components/MarkerPopup.vue

  • [ ] Step 1: 写入组件完整代码

    <script setup>
    import { computed } from 'vue'
    
    const props = defineProps({
    marker: { type: Object, required: true }
    })
    
    const statusColor = computed(() => {
    const s = String(props.marker.status || '').toLowerCase()
    if (['正常', '在线', '良好', '运行中', 'ok'].some(k => s.includes(k))) return '#22c55e'
    if (['警告', '注意', 'warning'].some(k => s.includes(k))) return '#f59e0b'
    if (['故障', '离线', '错误', 'error', 'danger'].some(k => s.includes(k))) return '#ef4444'
    return '#9ca3af'
    })
    
    const hasVideo = computed(() => props.marker.videoUrl || props.marker.iframeUrl)
    const hasHtml = computed(() => props.marker.htmlContent || props.marker.htmlUrl)
    </script>
    
    <template>
    <div class="marker-popup">
    <div class="popup-header">
      <span class="popup-title">{{ marker.title || '未命名' }}</span>
    </div>
    <div class="popup-body">
      <!-- video -->
      <template v-if="marker.type === 'video'">
        <video
          v-if="marker.videoUrl"
          class="popup-media"
          :src="marker.videoUrl"
          controls
          autoplay
          muted
          playsinline
        />
        <iframe
          v-else-if="marker.iframeUrl"
          class="popup-media"
          :src="marker.iframeUrl"
          frameborder="0"
          allowfullscreen
        />
        <div v-else class="popup-empty">未配置视频源</div>
      </template>
    
      <!-- html -->
      <template v-else-if="marker.type === 'html'">
        <div
          v-if="marker.htmlContent"
          class="popup-html"
          v-html="marker.htmlContent"
        />
        <iframe
          v-else-if="marker.htmlUrl"
          class="popup-media"
          :src="marker.htmlUrl"
          frameborder="0"
        />
        <div v-else class="popup-empty">未配置 HTML 内容</div>
      </template>
    
      <!-- device -->
      <template v-else-if="marker.type === 'device'">
        <div class="device-status">
          <span class="status-dot" :style="{ background: statusColor }"></span>
          <span class="status-text">{{ marker.status || '未知' }}</span>
        </div>
        <div v-if="marker.metrics?.length" class="device-metrics">
          <div
            v-for="(m, i) in marker.metrics"
            :key="i"
            class="metric-item"
          >
            <span class="metric-label">{{ m.label }}</span>
            <span class="metric-value">
              {{ m.value }}
              <span v-if="m.unit" class="metric-unit">{{ m.unit }}</span>
            </span>
          </div>
        </div>
        <div v-else class="popup-empty">暂无指标数据</div>
      </template>
    
      <!-- fallback -->
      <template v-else>
        <div class="popup-text">{{ marker.content || marker.title || '无内容' }}</div>
      </template>
    </div>
    </div>
    </template>
    
    <style scoped>
    .marker-popup {
    width: 320px;
    max-height: 420px;
    background: rgba(2, 6, 23, 0.95);
    border: 1px solid rgba(255, 255, 255, 0.12);
    border-radius: 12px;
    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
    overflow: hidden;
    display: flex;
    flex-direction: column;
    animation: popupIn 0.2s ease-out;
    }
    
    @keyframes popupIn {
    from { opacity: 0; transform: translateY(10px) scale(0.96); }
    to { opacity: 1; transform: translateY(0) scale(1); }
    }
    
    .popup-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 14px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
    }
    
    .popup-title {
    font-size: 14px;
    font-weight: 600;
    color: #fff;
    }
    
    .popup-body {
    padding: 12px 14px;
    overflow-y: auto;
    }
    
    .popup-media {
    width: 100%;
    aspect-ratio: 16 / 10;
    border-radius: 8px;
    background: #000;
    display: block;
    }
    
    .popup-html {
    font-size: 12px;
    color: rgba(255, 255, 255, 0.8);
    line-height: 1.6;
    }
    
    .popup-empty {
    padding: 24px 0;
    text-align: center;
    font-size: 12px;
    color: rgba(255, 255, 255, 0.4);
    }
    
    .popup-text {
    font-size: 13px;
    color: rgba(255, 255, 255, 0.75);
    line-height: 1.6;
    }
    
    .device-status {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 12px;
    padding: 8px 10px;
    background: rgba(255, 255, 255, 0.05);
    border-radius: 8px;
    }
    
    .status-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.08);
    }
    
    .status-text {
    font-size: 13px;
    color: #fff;
    font-weight: 500;
    }
    
    .device-metrics {
    display: flex;
    flex-direction: column;
    gap: 8px;
    }
    
    .metric-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 8px 10px;
    background: rgba(255, 255, 255, 0.04);
    border-radius: 6px;
    }
    
    .metric-label {
    font-size: 12px;
    color: rgba(255, 255, 255, 0.55);
    }
    
    .metric-value {
    font-size: 13px;
    color: #fff;
    font-weight: 500;
    }
    
    .metric-unit {
    font-size: 11px;
    color: rgba(255, 255, 255, 0.5);
    margin-left: 2px;
    }
    </style>
    
  • [ ] Step 2: 运行构建检查语法

Run: npm run build Expected: 此时 build 可能因未引用该组件而通过或报错,确保无 Vue 模板语法错误。


Task 2: 在 TerrainViewer.vue 中引入 CSS2DRenderer

Files:

  • Modify: src/components/TerrainViewer.vue

  • [ ] Step 1: 导入 CSS2DRenderer 和 MarkerPopup

<script setup> 顶部添加:

import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'
import MarkerPopup from './MarkerPopup.vue'
  • Step 2: 添加 CSS2D 渲染器相关变量

在已有的 let scene, camera, renderer, controls, animId 等声明后添加:

let labelRenderer = null
let labelGroup = null
  • Step 3: 在 onMounted 中初始化 CSS2DRenderer

el.appendChild(renderer.domElement) 之后、创建 OrbitControls 之前添加:

labelRenderer = new CSS2DRenderer()
labelRenderer.setSize(el.clientWidth, el.clientHeight)
labelRenderer.domElement.style.position = 'absolute'
labelRenderer.domElement.style.top = '0'
labelRenderer.domElement.style.left = '0'
labelRenderer.domElement.style.pointerEvents = 'none'
labelRenderer.domElement.style.zIndex = '5'
el.appendChild(labelRenderer.domElement)
  • Step 4: 在渲染循环中渲染 CSS2D

修改 loop 函数:

function loop() {
  animId = requestAnimationFrame(loop)
  controls.update()
  renderer.render(scene, camera)
  if (labelRenderer) labelRenderer.render(scene, camera)
}
  • Step 5: 在 onResizeonUnmounted 中处理 CSS2DRenderer

onResize 中添加:

if (labelRenderer) {
  labelRenderer.setSize(container.value.clientWidth, container.value.clientHeight)
}

onUnmounted 末尾添加:

labelRenderer?.domElement?.remove()
labelRenderer = null

Task 3: 创建新的标记点渲染函数

Files:

  • Modify: src/components/TerrainViewer.vue

  • [ ] Step 1: 添加类型色映射和标签生成函数

<script setup> 中(createMarkers 之前)添加:

const TYPE_COLORS = {
  video: 0x22c55e,
  device: 0x38bdf8,
  html: 0xf59e0b
}

function getMarkerColor(marker) {
  if (marker.color) return marker.color
  return TYPE_COLORS[marker.type] || 0xff4444
}

function createMarkerLabelElement(marker) {
  const color = '#' + getMarkerColor(marker).toString(16).padStart(6, '0')
  const div = document.createElement('div')
  div.className = 'marker-label'
  div.style.cssText = `
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 5px 10px;
    background: rgba(2, 6, 23, 0.9);
    border: 1px solid ${color};
    border-radius: 8px;
    box-shadow: 0 4px 16px ${color}33;
    color: #fff;
    font-size: 12px;
    font-weight: 500;
    white-space: nowrap;
    pointer-events: auto;
    cursor: pointer;
    transition: transform 0.15s, box-shadow 0.15s;
  `
  div.innerHTML = `
    <svg width="14" height="16" viewBox="0 0 24 28" style="fill:${color};display:block">
      <path d="M12 0l10.4 6v12L12 24 1.6 18V6z"/>
    </svg>
    <span>${marker.title || '未命名'}</span>
  `
  div.addEventListener('mouseenter', () => {
    div.style.transform = 'scale(1.05)'
    div.style.boxShadow = `0 6px 20px ${color}55`
  })
  div.addEventListener('mouseleave', () => {
    div.style.transform = 'scale(1)'
    div.style.boxShadow = `0 4px 16px ${color}33`
  })
  div.addEventListener('click', (e) => {
    e.stopPropagation()
    openMarkerPopup(marker)
  })
  return div
}
  • Step 2: 替换 createMarkers 函数

将原有 createMarkers 函数整体替换为:

function createMarkers() {
  if (!scene || !meta || !elevations) return

  // 移除旧标记
  if (markerGroup) {
    scene.remove(markerGroup)
    markerGroup.traverse((obj) => {
      if (obj.geometry) obj.geometry.dispose()
      if (obj.material) obj.material.dispose()
    })
  }
  if (labelGroup) {
    scene.remove(labelGroup)
    labelGroup = null
  }

  markerGroup = new THREE.Group()
  labelGroup = new THREE.Group()

  const lonMin = meta.dsmLonMin
  const latMax = meta.dsmLatMax
  const lonRange = (meta.terrainWidthMeters / 111320) / Math.cos(latMax * Math.PI / 180)
  const latRange = meta.terrainDepthMeters / 111320
  const lonMax = lonMin + lonRange
  const latMin = latMax - latRange

  props.markers.forEach((marker, index) => {
    const normX = (marker.lon - lonMin) / (lonMax - lonMin)
    const normZ = (latMax - marker.lat) / (latMax - latMin)
    const clampedX = Math.max(0, Math.min(1, normX))
    const clampedZ = Math.max(0, Math.min(1, normZ))

    const x = (clampedX - 0.5) * meta.terrainWidthMeters
    const z = (clampedZ - 0.5) * meta.terrainDepthMeters

    const gridX = Math.round(clampedX * (meta.gridWidth - 1))
    const gridZ = Math.round(clampedZ * (meta.gridHeight - 1))
    const elevIndex = gridZ * meta.gridWidth + gridX
    const elev = elevations[elevIndex] || meta.elevationMin
    const surfaceY = (elev - meta.elevationMin) * veModel.value + (marker.alt || 0)
    const color = getMarkerColor(marker)
    const labelHeight = 50

    // 六边形底座
    const baseGeo = new THREE.CylinderGeometry(8, 8, 2, 6)
    baseGeo.rotateX(Math.PI / 2)
    const baseMat = new THREE.MeshBasicMaterial({
      color,
      transparent: true,
      opacity: 0.85
    })
    const baseMesh = new THREE.Mesh(baseGeo, baseMat)
    baseMesh.position.set(x, surfaceY + 1, z)
    baseMesh.userData = { markerData: marker, index, worldPos: { x, y: surfaceY, z } }
    markerGroup.add(baseMesh)

    // 垂线
    const lineGeo = new THREE.BufferGeometry().setFromPoints([
      new THREE.Vector3(x, surfaceY + 2, z),
      new THREE.Vector3(x, surfaceY + labelHeight, z)
    ])
    const lineMat = new THREE.LineBasicMaterial({
      color,
      transparent: true,
      opacity: 0.6
    })
    const line = new THREE.Line(lineGeo, lineMat)
    markerGroup.add(line)

    // CSS2D 标签
    const labelEl = createMarkerLabelElement(marker)
    const label = new CSS2DObject(labelEl)
    label.position.set(x, surfaceY + labelHeight, z)
    label.userData = { markerData: marker, index, worldPos: { x, y: surfaceY + labelHeight, z } }
    labelGroup.add(label)
  })

  scene.add(markerGroup)
  scene.add(labelGroup)
}
  • Step 3: 添加 openMarkerPopup 辅助函数

createMarkers 之后、原 onMarkerClick 之前添加:

function openMarkerPopup(marker) {
  activeMarker.value = marker
  updatePopupPosition()

  sendToParent({
    type: 'markerClick',
    lon: marker.lon,
    lat: marker.lat,
    alt: marker.alt,
    title: marker.title,
    content: marker.content,
    type: marker.type
  })
  emit('markerClick', marker)
}

Task 4: 更新点击处理与弹窗跟随

Files:

  • Modify: src/components/TerrainViewer.vue

  • [ ] Step 1: 重写 onMarkerClick 为射线检测 + 空白处关闭

将原 onMarkerClick 函数替换为:

function onMarkerClick(e) {
  if (loading.value) return

  const rect = renderer.domElement.getBoundingClientRect()
  const mouse = new THREE.Vector2(
    ((e.clientX - rect.left) / rect.width) * 2 - 1,
    -((e.clientY - rect.top) / rect.height) * 2 + 1
  )

  const ray = new THREE.Raycaster()
  ray.setFromCamera(mouse, camera)

  // 检测标签(CSS2DObject 射线不可直接用,先检测底座和线)
  let intersects = []
  if (markerGroup) {
    intersects = ray.intersectObjects(markerGroup.children, false)
  }

  if (intersects.length > 0) {
    const obj = intersects[0].object
    const markerData = obj.userData.markerData
    if (markerData) openMarkerPopup(markerData)
  } else {
    // 点击空白关闭弹窗
    activeMarker.value = null
  }
}
  • Step 2: 添加 updatePopupPosition 函数

openMarkerPopup 之后添加:

function updatePopupPosition() {
  if (!activeMarker.value || !camera || !renderer) return

  const target = markerGroup?.children.find(
    (c) => c.userData.markerData === activeMarker.value
  )
  if (!target) return

  const pos = target.userData.worldPos
  const vec = new THREE.Vector3(pos.x, pos.y + 60, pos.z)
  vec.project(camera)

  const rect = renderer.domElement.getBoundingClientRect()
  let screenX = (vec.x * 0.5 + 0.5) * rect.width + rect.left
  let screenY = (-(vec.y * 0.5) + 0.5) * rect.height + rect.top

  // 简单贴边处理
  const popupWidth = 320
  const popupHeight = 360
  if (screenX + popupWidth > rect.right) screenX = rect.right - popupWidth - 16
  if (screenX < rect.left + 8) screenX = rect.left + 8
  if (screenY + popupHeight > rect.bottom) screenY = screenY - popupHeight - 40
  if (screenY < rect.top + 8) screenY = rect.top + 8

  markerPopupPosition.value = { x: screenX, y: screenY }
}
  • Step 3: 在渲染循环中更新弹窗位置

修改 loop 函数:

function loop() {
  animId = requestAnimationFrame(loop)
  controls.update()
  renderer.render(scene, camera)
  if (labelRenderer) labelRenderer.render(scene, camera)
  updatePopupPosition()
}
  • Step 4: 更新 iframe addMarker 消息处理

handleIframeMessageaddMarker 分支中,把新字段透传进去:

} else if (data.type === 'addMarker') {
  const newMarkers = [...props.markers, {
    lon: data.lon,
    lat: data.lat,
    alt: data.alt || 0,
    title: data.title || '标注点',
    content: data.content || '',
    type: data.type || '',
    videoUrl: data.videoUrl,
    iframeUrl: data.iframeUrl,
    htmlContent: data.htmlContent,
    htmlUrl: data.htmlUrl,
    status: data.status,
    metrics: data.metrics,
    color: data.color ? parseInt(data.color.replace('#', '0x'), 16) : undefined
  }]
  emit('update:markers', newMarkers)
}

Task 5: 在模板中接入 MarkerPopup

Files:

  • Modify: src/components/TerrainViewer.vue

  • [ ] Step 1: 替换模板中的弹窗部分

将原模板中 <!-- 标记点弹窗 --></div> 的整块替换为:

<!-- 标记点弹窗 -->
<div
  v-if="activeMarker"
  class="marker-popup-wrapper"
  :style="{ left: markerPopupPosition.x + 'px', top: markerPopupPosition.y + 'px' }"
  @click.stop
>
  <button class="popup-close" @click="activeMarker = null">×</button>
  <MarkerPopup :marker="activeMarker" />
</div>
  • Step 2: 添加弹窗外层样式

<style scoped> 末尾添加:

.marker-popup-wrapper {
  position: absolute;
  z-index: 100;
}

.popup-close {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 22px;
  height: 22px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.1);
  border: none;
  border-radius: 50%;
  color: rgba(255, 255, 255, 0.7);
  font-size: 16px;
  cursor: pointer;
  z-index: 10;
}

.popup-close:hover {
  background: rgba(255, 255, 255, 0.2);
  color: #fff;
}
  • Step 3: 移除或注释旧弹窗样式

将旧的 .marker-popup.popup-header.popup-title.popup-close.popup-content 等样式删除,避免冲突。


Task 6: 更新 src/App.vue 示例数据

Files:

  • Modify: src/App.vue

  • [ ] Step 1: 替换 markers 示例

markers ref 替换为:

const markers = ref([
  {
    lon: 113.722,
    lat: 24.575,
    title: '监控点 A',
    type: 'video',
    videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4'
  },
  {
    lon: 113.730,
    lat: 24.568,
    title: '设备 B',
    type: 'device',
    status: '正常',
    metrics: [
      { label: '温度', value: 24, unit: '°C' },
      { label: '湿度', value: 62, unit: '%' },
      { label: '风速', value: 3.2, unit: 'm/s' }
    ]
  },
  {
    lon: 113.725,
    lat: 24.572,
    title: '报表 C',
    type: 'html',
    htmlContent: '<div style="color:#22c55e;font-weight:600">今日产量:1,240 吨</div><div style="margin-top:8px;color:rgba(255,255,255,0.6)">环比昨日 +5.3%</div>'
  }
])

Task 7: 验证构建与运行

Files:

  • N/A

  • [ ] Step 1: 本地运行开发服务器

Run: npm run dev Expected: 无启动报错,打开 http://localhost:5173(或 Vite 提示的端口)能看到 3D 地形。

  • Step 2: 手动检查标记点

Expected:

  • 三个标记点显示为六边形底座 + 垂线 + 标签牌。
  • 标签颜色:video 青绿、device 冰蓝、html 琥珀黄。
  • 悬停标签有放大效果。

  • [ ] Step 3: 手动检查弹窗

Expected:

  • 点击“监控点 A”弹出视频播放器,能播放。
  • 点击“设备 B”弹出设备卡片,显示状态和指标。
  • 点击“报表 C”弹出 HTML 内容。
  • 旋转/缩放相机时弹窗跟随标记点。
  • 点击空白处或关闭按钮弹窗消失。

  • [ ] Step 4: 生产构建检查

Run: npm run build Expected: 构建成功,dist/ 目录生成,无 TypeScript/Vite 报错。

  • Step 5: 验证 iframe 消息协议

在浏览器控制台执行:

window.postMessage({ type: 'addMarker', lon: 113.724, lat: 24.570, title: '测试点', type: 'device', status: '正常', metrics: [{ label: '测试', value: 1 }] }, '*')

Expected: 新标记点出现在地图上,点击后弹出设备卡片。


Spec Coverage Check

设计需求 对应任务
六边形图标 Task 3 底座 + 标签 SVG
标签牌 + 垂线 Task 3 标签牌 + Line
类型配色 Task 3 TYPE_COLORS
video 弹窗 Task 1 / Task 5
html 弹窗 Task 1 / Task 5
device 弹窗 Task 1 / Task 5
弹窗跟随 Task 4 updatePopupPosition
一次一个弹窗 Task 4 activeMarker 单状态
iframe 协议兼容 Task 3 userData 保留原字段 + Task 7 验证

Placeholder Scan

  • 无 TBD / TODO。
  • 所有代码片段为可直接写入的内容。
  • 无“适当处理”等模糊描述。