Explorar o código

refactor(terrain-marker): 重构地形标记点系统,支持多类型弹窗

- 重新设计标记点样式:六边形底座+垂线+CSS2D标签
- 新增MarkerPopup组件,支持video/html/device三种弹窗类型
- 集成CSS2DRenderer实现3D空间DOM标签渲染
- 更新示例数据,覆盖三种标记点类型
- 优化弹窗跟随与屏幕贴边逻辑
- 兼容原有iframe消息协议
wenhongquan hai 1 mes
pai
achega
16ffadab3f

+ 1 - 0
.gitignore

@@ -6,6 +6,7 @@ bin-release/
 
 
 # Other files and folders
 # Other files and folders
 .settings/
 .settings/
+.superpowers/
 
 
 # Executables
 # Executables
 *.swf
 *.swf

BIN=BIN
4622db6e4a309eca72d06c7e8bbddc91.png


BIN=BIN
5efce9079b9f35554d30a513537fa8c4.png


BIN=BIN
8bf719d7d65f9d1c54a2d9e53bf98c95.png


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
dist/assets/index-BJfDnjH2.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
dist/assets/index-COXYgZo2.css


+ 2 - 2
dist/index.html

@@ -4,8 +4,8 @@
     <meta charset="UTF-8" />
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>3D 大屏 - 倾斜摄影</title>
     <title>3D 大屏 - 倾斜摄影</title>
-    <script type="module" crossorigin src="/assets/index-D5SOz1PB.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-3VZLT-DE.css">
+    <script type="module" crossorigin src="/assets/index-BJfDnjH2.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-COXYgZo2.css">
   </head>
   </head>
   <body>
   <body>
     <div id="app"></div>
     <div id="app"></div>

+ 775 - 0
docs/superpowers/plans/2026-06-13-terrain-marker-redesign.md

@@ -0,0 +1,775 @@
+# 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: 写入组件完整代码**
+
+```vue
+<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>` 顶部添加:
+
+```js
+import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'
+import MarkerPopup from './MarkerPopup.vue'
+```
+
+- [ ] **Step 2: 添加 CSS2D 渲染器相关变量**
+
+在已有的 `let scene, camera, renderer, controls, animId` 等声明后添加:
+
+```js
+let labelRenderer = null
+let labelGroup = null
+```
+
+- [ ] **Step 3: 在 `onMounted` 中初始化 CSS2DRenderer**
+
+在 `el.appendChild(renderer.domElement)` 之后、创建 `OrbitControls` 之前添加:
+
+```js
+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` 函数:
+
+```js
+function loop() {
+  animId = requestAnimationFrame(loop)
+  controls.update()
+  renderer.render(scene, camera)
+  if (labelRenderer) labelRenderer.render(scene, camera)
+}
+```
+
+- [ ] **Step 5: 在 `onResize` 和 `onUnmounted` 中处理 CSS2DRenderer**
+
+在 `onResize` 中添加:
+
+```js
+if (labelRenderer) {
+  labelRenderer.setSize(container.value.clientWidth, container.value.clientHeight)
+}
+```
+
+在 `onUnmounted` 末尾添加:
+
+```js
+labelRenderer?.domElement?.remove()
+labelRenderer = null
+```
+
+---
+
+## Task 3: 创建新的标记点渲染函数
+
+**Files:**
+- Modify: `src/components/TerrainViewer.vue`
+
+- [ ] **Step 1: 添加类型色映射和标签生成函数**
+
+在 `<script setup>` 中(`createMarkers` 之前)添加:
+
+```js
+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` 函数整体替换为:
+
+```js
+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` 之前添加:
+
+```js
+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` 函数替换为:
+
+```js
+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` 之后添加:
+
+```js
+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` 函数:
+
+```js
+function loop() {
+  animId = requestAnimationFrame(loop)
+  controls.update()
+  renderer.render(scene, camera)
+  if (labelRenderer) labelRenderer.render(scene, camera)
+  updatePopupPosition()
+}
+```
+
+- [ ] **Step 4: 更新 iframe addMarker 消息处理**
+
+在 `handleIframeMessage` 的 `addMarker` 分支中,把新字段透传进去:
+
+```js
+} 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>` 的整块替换为:
+
+```vue
+<!-- 标记点弹窗 -->
+<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>` 末尾添加:
+
+```css
+.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 替换为:
+
+```js
+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 消息协议**
+
+在浏览器控制台执行:
+
+```js
+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。
+- 所有代码片段为可直接写入的内容。
+- 无“适当处理”等模糊描述。

+ 165 - 0
docs/superpowers/specs/2026-06-13-terrain-marker-redesign-design.md

@@ -0,0 +1,165 @@
+# 3D 地形标记点重构设计
+
+## 背景
+
+当前 `TerrainViewer.vue` 中的标记点是简单的球体 + 光晕,点击后仅显示文本弹窗。产品希望:
+
+1. 参考三张数据大屏/矿山监控风格的图片,升级标记点视觉样式。
+2. 点击标记点能够展示视频、HTML 内容或设备数据。
+3. 通过 `type` 字段区分不同类型,例如 `video`、`html`、`device`。
+
+## 目标
+
+- 标记点视觉风格:A 图的标签牌 + 垂线、C 图的六边形图标、B 图的科技/矿山数据大屏配色。
+- 点击标记点弹出内容面板,支持 video、html、device 三种类型。
+- 保持现有外部 iframe 通信协议(`addMarker` / `removeMarker` / `clearMarkers` / `flyTo`)向后兼容。
+- 一次只展开一个弹窗;弹窗跟随标记点移动;超出屏幕自动贴边。
+
+## 参考图风格摘要
+
+| 图片 | 风格关键词 |
+|------|-----------|
+| `5efce9079b9f35554d30a513537fa8c4.png` | 彩色长方形标签牌、细垂线连接到地面、工业区域名称 |
+| `8bf719d7d65f9d1c54a2d9e53bf98c95.png` | 绿色圆形定位图标、黄色人员图标、数据大屏深色背景 |
+| `4622db6e4a309eca72d06c7e8bbddc91.png` | 六边形设备图标、深色信息卡片、字段化展示 |
+
+## 组件拆分
+
+| 组件 | 职责 |
+|------|------|
+| `TerrainViewer.vue` | Three.js 场景、地形、相机、CSS2DRenderer、标记点 Mesh/标签/垂线、弹窗状态管理、事件转发 |
+| `MarkerPopup.vue` | 根据 `marker.type` 渲染 video / html / device 三种内容 |
+| `App.vue` | 传入 markers 数据,接收 `markerClick` 事件 |
+
+## 标记点视觉设计
+
+每个标记点由三部分组成:
+
+1. **地面六边形底座**:`CylinderGeometry(6 segments)`,薄盘,贴于地形表面,按类型着色。
+2. **垂线**:`Line` 从地面中心向上延伸至标签底部,半透明,长度约 50 地形单位。
+3. **标签牌(CSS2DRenderer)**:DOM 元素,始终面向相机,位于底座正上方约 50 地形单位处,包含:
+   - 左侧六边形 SVG 图标
+   - 标题文字
+   - 圆角卡片、半透明深色背景、类型色边框
+
+### 类型配色
+
+| type | 主色 | 用途 |
+|------|------|------|
+| `video` | `#22c55e` 青绿 | 视频监控 |
+| `device` | `#38bdf8` 冰蓝 | 设备数据 |
+| `html` | `#f59e0b` 琥珀黄 | HTML 内容/报表 |
+
+颜色可通过 `marker.color` 覆盖。
+
+### 标签交互
+
+- 悬停:标签轻微放大/高亮。
+- 点击:打开弹窗,触发 `markerClick` 事件,并向父页面 `postMessage`。
+- 标签默认始终显示。
+
+## Marker 数据结构
+
+```js
+{
+  lon: 113.722,
+  lat: 24.575,
+  alt: 0,                 // 可选,海拔偏移
+  title: '监控 01',        // 标签牌与弹窗标题
+  type: 'video',          // 'video' | 'html' | 'device'
+  color: 0x22c55e,        // 可选,覆盖默认类型色
+
+  // video 类型(二选一)
+  videoUrl: 'https://.../stream.m3u8',
+  iframeUrl: '',
+
+  // html 类型(二选一)
+  htmlContent: '<div>...</div>',
+  htmlUrl: '',
+
+  // device 类型
+  status: '正常',
+  metrics: [
+    { label: '温度', value: 24, unit: '°C' },
+    { label: '湿度', value: 62, unit: '%' }
+  ]
+}
+```
+
+## 弹窗设计
+
+弹窗为深色半透明卡片,宽度 `320px`,最大高度限制,超出滚动。
+
+### `type: 'video'`
+
+按优先级:
+1. 存在 `videoUrl` → `<video controls autoplay muted :src="videoUrl" />`
+2. 存在 `iframeUrl` → `<iframe :src="iframeUrl" />`
+3. 都没有 → 显示“未配置视频源”
+
+### `type: 'html'`
+
+按优先级:
+1. 存在 `htmlContent` → `v-html` 渲染
+2. 存在 `htmlUrl` → `<iframe :src="htmlUrl" />`
+3. 都没有 → 显示“未配置 HTML 内容”
+
+### `type: 'device'`
+
+渲染设备卡片:
+- 标题 `title`
+- 状态 `status`(带颜色小圆点)
+  - 正常/在线/良好 → 绿色 `#22c55e`
+  - 警告/注意 → 黄色 `#f59e0b`
+  - 故障/离线/错误 → 红色 `#ef4444`
+  - 其他 → 灰色 `#9ca3af`
+- 指标列表 `metrics: [{ label, value, unit }]`
+
+### 弹窗定位
+
+- 打开时计算标记点屏幕坐标。
+- 相机旋转/缩放时,每帧更新弹窗位置。
+- 弹窗超出屏幕边缘时自动向内偏移。
+
+## 事件与数据流
+
+1. `App.vue` 传入 `markers`。
+2. `TerrainViewer` 监听 `markers` 变化,重建 Mesh 底座、垂线、CSS2D 标签。
+3. 用户点击标记点:
+   - 设置 `activeMarker`
+   - 计算弹窗初始屏幕坐标
+   - `emit('markerClick', marker)`
+   - `postMessage({ type: 'markerClick', ... })` 给父页面
+4. 相机变化时更新弹窗屏幕坐标。
+5. 点击空白处或关闭按钮:`activeMarker = null`。
+
+## 错误与边界处理
+
+- `type` 缺失或非法 → 退化为文本渲染 `title` / `content`。
+- `video` 无有效源 → 显示占位提示。
+- `html` 无内容 → 显示占位提示。
+- `device` 无 `metrics` → 仅显示 title 和 status。
+- 弹窗移出屏幕 → 自动贴边。
+- CSS2D 标签位于 DOM 层,不会被 WebGL 地形深度遮挡。
+
+## 兼容性
+
+- 保留现有 iframe 消息协议:`addMarker`、`removeMarker`、`clearMarkers`、`flyTo`。
+- `addMarker` 支持传入新字段(`type`、`videoUrl`、`metrics` 等)。
+- 旧数据(无 `type`)仍可显示,退化为简单文本弹窗。
+
+## 技术选型
+
+- **方案 1:CSS2DRenderer 标签 + Mesh 六边形标记 + HTML 弹窗(已确认)**
+  - 文字标签清晰、样式灵活、弹窗内嵌视频/HTML 自然。
+  - 需引入 `CSS2DRenderer`。
+
+## 待实现检查项
+
+- [ ] 引入 `CSS2DRenderer` 并集成到渲染循环。
+- [ ] 创建六边形底座 Mesh 和垂线 Line。
+- [ ] 创建 CSS2D 标签组件(图标 + 标题)。
+- [ ] 创建 `MarkerPopup.vue` 支持 video / html / device。
+- [ ] 实现弹窗跟随标记点的屏幕坐标更新。
+- [ ] 更新 `App.vue` 示例数据,覆盖三种类型。
+- [ ] 验证现有 iframe 消息协议向后兼容。

+ 7 - 1
node_modules/.vite/deps/_metadata.json

@@ -2,7 +2,7 @@
   "hash": "3477c0ce",
   "hash": "3477c0ce",
   "configHash": "cb1bb023",
   "configHash": "cb1bb023",
   "lockfileHash": "d56dfa6b",
   "lockfileHash": "d56dfa6b",
-  "browserHash": "d3ca7b81",
+  "browserHash": "039e3439",
   "optimized": {
   "optimized": {
     "element-plus": {
     "element-plus": {
       "src": "../../element-plus/es/index.mjs",
       "src": "../../element-plus/es/index.mjs",
@@ -22,6 +22,12 @@
       "fileHash": "bc938bb8",
       "fileHash": "bc938bb8",
       "needsInterop": false
       "needsInterop": false
     },
     },
+    "three/addons/renderers/CSS2DRenderer.js": {
+      "src": "../../three/examples/jsm/renderers/CSS2DRenderer.js",
+      "file": "three_addons_renderers_CSS2DRenderer__js.js",
+      "fileHash": "55bb6a6d",
+      "needsInterop": false
+    },
     "vue": {
     "vue": {
       "src": "../../vue/dist/vue.runtime.esm-bundler.js",
       "src": "../../vue/dist/vue.runtime.esm-bundler.js",
       "file": "vue.js",
       "file": "vue.js",

+ 192 - 0
node_modules/.vite/deps/three_addons_renderers_CSS2DRenderer__js.js

@@ -0,0 +1,192 @@
+import { Bs as Vector3, Vi as Object3D, ni as Matrix4, zs as Vector2 } from "./three.module-Dhi4sXJn.js";
+//#region node_modules/three/examples/jsm/renderers/CSS2DRenderer.js
+/**
+* The only type of 3D object that is supported by {@link CSS2DRenderer}.
+*
+* @augments Object3D
+* @three_import import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
+*/
+var CSS2DObject = class extends Object3D {
+	/**
+	* Constructs a new CSS2D object.
+	*
+	* @param {HTMLElement} [element] - The DOM element.
+	*/
+	constructor(element = document.createElement("div")) {
+		super();
+		/**
+		* This flag can be used for type testing.
+		*
+		* @type {boolean}
+		* @readonly
+		* @default true
+		*/
+		this.isCSS2DObject = true;
+		/**
+		* The DOM element which defines the appearance of this 3D object.
+		*
+		* @type {HTMLElement}
+		* @readonly
+		* @default true
+		*/
+		this.element = element;
+		this.element.style.position = "absolute";
+		this.element.style.userSelect = "none";
+		this.element.setAttribute("draggable", false);
+		/**
+		* The 3D objects center point.
+		* `( 0, 0 )` is the lower left, `( 1, 1 )` is the top right.
+		*
+		* @type {Vector2}
+		* @default (0.5,0.5)
+		*/
+		this.center = new Vector2(.5, .5);
+		this.addEventListener("removed", function() {
+			this.traverse(function(object) {
+				if (object.element && object.element instanceof object.element.ownerDocument.defaultView.Element && object.element.parentNode !== null) object.element.remove();
+			});
+		});
+	}
+	copy(source, recursive) {
+		super.copy(source, recursive);
+		this.element = source.element.cloneNode(true);
+		this.center = source.center;
+		return this;
+	}
+};
+var _vector = new Vector3();
+var _viewMatrix = new Matrix4();
+var _viewProjectionMatrix = new Matrix4();
+var _a = new Vector3();
+var _b = new Vector3();
+/**
+* This renderer is a simplified version of {@link CSS3DRenderer}. The only transformation that is
+* supported is translation.
+*
+* The renderer is very useful if you want to combine HTML based labels with 3D objects. Here too,
+* the respective DOM elements are wrapped into an instance of {@link CSS2DObject} and added to the
+* scene graph. All other types of renderable 3D objects (like meshes or point clouds) are ignored.
+*
+* `CSS2DRenderer` only supports 100% browser and display zoom.
+*
+* @three_import import { CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js';
+*/
+var CSS2DRenderer = class {
+	/**
+	* Constructs a new CSS2D renderer.
+	*
+	* @param {CSS2DRenderer~Parameters} [parameters] - The parameters.
+	*/
+	constructor(parameters = {}) {
+		const _this = this;
+		let _width, _height;
+		let _widthHalf, _heightHalf;
+		const cache = { objects: /* @__PURE__ */ new WeakMap() };
+		const domElement = parameters.element !== void 0 ? parameters.element : document.createElement("div");
+		domElement.style.overflow = "hidden";
+		/**
+		* The DOM where the renderer appends its child-elements.
+		*
+		* @type {HTMLElement}
+		*/
+		this.domElement = domElement;
+		/**
+		* Controls whether the renderer assigns `z-index` values to CSS2DObject DOM elements.
+		* If set to `true`, z-index values are assigned first based on the `renderOrder`
+		* and secondly - the distance to the camera. If set to `false`, no z-index values are assigned.
+		*
+		* @type {boolean}
+		* @default true
+		*/
+		this.sortObjects = true;
+		/**
+		* Returns an object containing the width and height of the renderer.
+		*
+		* @return {{width:number,height:number}} The size of the renderer.
+		*/
+		this.getSize = function() {
+			return {
+				width: _width,
+				height: _height
+			};
+		};
+		/**
+		* Renders the given scene using the given camera.
+		*
+		* @param {Object3D} scene - A scene or any other type of 3D object.
+		* @param {Camera} camera - The camera.
+		*/
+		this.render = function(scene, camera) {
+			if (scene.matrixWorldAutoUpdate === true) scene.updateMatrixWorld();
+			if (camera.parent === null && camera.matrixWorldAutoUpdate === true) camera.updateMatrixWorld();
+			_viewMatrix.copy(camera.matrixWorldInverse);
+			_viewProjectionMatrix.multiplyMatrices(camera.projectionMatrix, _viewMatrix);
+			renderObject(scene, scene, camera);
+			if (this.sortObjects) zOrder(scene);
+		};
+		/**
+		* Resizes the renderer to the given width and height.
+		*
+		* @param {number} width - The width of the renderer.
+		* @param {number} height - The height of the renderer.
+		*/
+		this.setSize = function(width, height) {
+			_width = width;
+			_height = height;
+			_widthHalf = _width / 2;
+			_heightHalf = _height / 2;
+			domElement.style.width = width + "px";
+			domElement.style.height = height + "px";
+		};
+		function hideObject(object) {
+			if (object.isCSS2DObject) object.element.style.display = "none";
+			for (let i = 0, l = object.children.length; i < l; i++) hideObject(object.children[i]);
+		}
+		function renderObject(object, scene, camera) {
+			if (object.visible === false) {
+				hideObject(object);
+				return;
+			}
+			if (object.isCSS2DObject) {
+				_vector.setFromMatrixPosition(object.matrixWorld);
+				_vector.applyMatrix4(_viewProjectionMatrix);
+				const visible = _vector.z >= -1 && _vector.z <= 1 && object.layers.test(camera.layers) === true;
+				const element = object.element;
+				element.style.display = visible === true ? "" : "none";
+				if (visible === true) {
+					object.onBeforeRender(_this, scene, camera);
+					element.style.transform = "translate(" + -100 * object.center.x + "%," + -100 * object.center.y + "%)translate(" + (_vector.x * _widthHalf + _widthHalf) + "px," + (-_vector.y * _heightHalf + _heightHalf) + "px)";
+					if (element.parentNode !== domElement) domElement.appendChild(element);
+					object.onAfterRender(_this, scene, camera);
+				}
+				const objectData = { distanceToCameraSquared: getDistanceToSquared(camera, object) };
+				cache.objects.set(object, objectData);
+			}
+			for (let i = 0, l = object.children.length; i < l; i++) renderObject(object.children[i], scene, camera);
+		}
+		function getDistanceToSquared(object1, object2) {
+			_a.setFromMatrixPosition(object1.matrixWorld);
+			_b.setFromMatrixPosition(object2.matrixWorld);
+			return _a.distanceToSquared(_b);
+		}
+		function filterAndFlatten(scene) {
+			const result = [];
+			scene.traverseVisible(function(object) {
+				if (object.isCSS2DObject) result.push(object);
+			});
+			return result;
+		}
+		function zOrder(scene) {
+			const sorted = filterAndFlatten(scene).sort(function(a, b) {
+				if (a.renderOrder !== b.renderOrder) return b.renderOrder - a.renderOrder;
+				return cache.objects.get(a).distanceToCameraSquared - cache.objects.get(b).distanceToCameraSquared;
+			});
+			const zMax = sorted.length;
+			for (let i = 0, l = sorted.length; i < l; i++) sorted[i].element.style.zIndex = zMax - i;
+		}
+	}
+};
+//#endregion
+export { CSS2DObject, CSS2DRenderer };
+
+//# sourceMappingURL=three_addons_renderers_CSS2DRenderer__js.js.map

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
node_modules/.vite/deps/three_addons_renderers_CSS2DRenderer__js.js.map


+ 26 - 3
src/App.vue

@@ -3,9 +3,32 @@ import { ref } from 'vue'
 import TerrainViewer from './components/TerrainViewer.vue'
 import TerrainViewer from './components/TerrainViewer.vue'
 
 
 const markers = ref([
 const markers = ref([
-  { lon: 113.722, lat: 24.575, title: '监测点 A', content: '位于山顶区域,海拔 847m,植被覆盖率 85%。', color: 0x44ff88 },
-  { lon: 113.730, lat: 24.568, title: '监测点 B', content: '位于山谷低点,海拔 512m,为主要汇水区域。', color: 0x4488ff },
-  { lon: 113.725, lat: 24.572, title: '监测点 C', content: '位于山坡中部,海拔 678m,土壤含水量适中。', color: 0xffaa44 }
+  {
+    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>'
+  }
 ])
 ])
 
 
 function onMarkerUpdate(newMarkers) {
 function onMarkerUpdate(newMarkers) {

+ 209 - 0
src/components/MarkerPopup.vue

@@ -0,0 +1,209 @@
+<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'
+})
+</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>

+ 189 - 101
src/components/TerrainViewer.vue

@@ -2,6 +2,8 @@
 import { ref, onMounted, onUnmounted, watch } from 'vue'
 import { ref, onMounted, onUnmounted, watch } from 'vue'
 import * as THREE from 'three'
 import * as THREE from 'three'
 import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
 import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
+import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'
+import MarkerPopup from './MarkerPopup.vue'
 
 
 const props = defineProps({
 const props = defineProps({
   metaUrl: { type: String, default: '/terrain-meta.json' },
   metaUrl: { type: String, default: '/terrain-meta.json' },
@@ -44,7 +46,14 @@ function handleIframeMessage(event) {
       alt: data.alt || 0,
       alt: data.alt || 0,
       title: data.title || '标注点',
       title: data.title || '标注点',
       content: data.content || '',
       content: data.content || '',
-      color: data.color ? parseInt(data.color.replace('#', '0x'), 16) : 0xff4444
+      type: data.markerType || data.markerTypeName || '',
+      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 更新父组件
     // 通过 emit 更新父组件
     emit('update:markers', newMarkers)
     emit('update:markers', newMarkers)
@@ -107,6 +116,8 @@ let elevations = null
 let colorTex = null
 let colorTex = null
 let meta = null
 let meta = null
 let markerGroup = null
 let markerGroup = null
+let labelRenderer = null
+let labelGroup = null
 
 
 function disposeMesh() {
 function disposeMesh() {
   if (currentMesh) {
   if (currentMesh) {
@@ -205,6 +216,59 @@ function resetCamera() {
   controls.update()
   controls.update()
 }
 }
 
 
+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
+}
+
 // 创建标记点
 // 创建标记点
 function createMarkers() {
 function createMarkers() {
   if (!scene || !meta || !elevations) return
   if (!scene || !meta || !elevations) return
@@ -217,8 +281,13 @@ function createMarkers() {
       if (obj.material) obj.material.dispose()
       if (obj.material) obj.material.dispose()
     })
     })
   }
   }
+  if (labelGroup) {
+    scene.remove(labelGroup)
+    labelGroup = null
+  }
 
 
   markerGroup = new THREE.Group()
   markerGroup = new THREE.Group()
+  labelGroup = new THREE.Group()
 
 
   // 计算经纬度范围
   // 计算经纬度范围
   const lonMin = meta.dsmLonMin
   const lonMin = meta.dsmLonMin
@@ -246,38 +315,67 @@ function createMarkers() {
     const gridZ = Math.round(clampedZ * (meta.gridHeight - 1))
     const gridZ = Math.round(clampedZ * (meta.gridHeight - 1))
     const elevIndex = gridZ * meta.gridWidth + gridX
     const elevIndex = gridZ * meta.gridWidth + gridX
     const elev = elevations[elevIndex] || meta.elevationMin
     const elev = elevations[elevIndex] || meta.elevationMin
-    const y = (elev - meta.elevationMin) * veModel.value + 15
-
-    // 创建标记点(球体)
-    const markerGeo = new THREE.SphereGeometry(12, 16, 16)
-    const markerMat = new THREE.MeshBasicMaterial({
-      color: marker.color || 0xff4444,
+    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,
       transparent: true,
-      opacity: 0.9
+      opacity: 0.85
     })
     })
-    const markerMesh = new THREE.Mesh(markerGeo, markerMat)
-    markerMesh.position.set(x, y, z)
-    markerMesh.userData = { markerData: marker, index, worldPos: { x, y, z } }
-
-    // 创建光晕效果
-    const glowGeo = new THREE.SphereGeometry(18, 16, 16)
-    const glowMat = new THREE.MeshBasicMaterial({
-      color: marker.color || 0xff4444,
+    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,
       transparent: true,
-      opacity: 0.3
+      opacity: 0.6
     })
     })
-    const glowMesh = new THREE.Mesh(glowGeo, glowMat)
-    markerMesh.add(glowMesh)
-
-    markerGroup.add(markerMesh)
+    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(markerGroup)
+  scene.add(labelGroup)
+}
+
+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,
+    markerType: marker.type
+  })
+  emit('markerClick', marker)
 }
 }
 
 
 // 点击标记点
 // 点击标记点
 function onMarkerClick(e) {
 function onMarkerClick(e) {
-  if (!markerGroup || loading.value) return
+  if (loading.value) return
 
 
   const rect = renderer.domElement.getBoundingClientRect()
   const rect = renderer.domElement.getBoundingClientRect()
   const mouse = new THREE.Vector2(
   const mouse = new THREE.Vector2(
@@ -288,39 +386,48 @@ function onMarkerClick(e) {
   const ray = new THREE.Raycaster()
   const ray = new THREE.Raycaster()
   ray.setFromCamera(mouse, camera)
   ray.setFromCamera(mouse, camera)
 
 
-  // 只检测标记点
-  const intersects = ray.intersectObjects(markerGroup.children, false)
+  // 检测底座和垂线
+  let intersects = []
+  if (markerGroup) {
+    intersects = ray.intersectObjects(markerGroup.children, false)
+  }
 
 
   if (intersects.length > 0) {
   if (intersects.length > 0) {
-    const clickedMarker = intersects[0].object
-    const markerData = clickedMarker.userData.markerData
-    const worldPos = clickedMarker.userData.worldPos
-
-    // 将 3D 坐标转换为屏幕坐标
-    const vec = new THREE.Vector3(worldPos.x, worldPos.y + 30, worldPos.z) // 弹窗在标记点上方30单位
-    vec.project(camera)
-
-    const screenX = (vec.x * 0.5 + 0.5) * rect.width + rect.left
-    const screenY = (-(vec.y * 0.5) + 0.5) * rect.height + rect.top
-
-    markerPopupPosition.value = { x: screenX, y: screenY }
-    activeMarker.value = markerData
-
-    // 发送点击事件给外部系统
-    sendToParent({
-      type: 'markerClick',
-      lon: markerData.lon,
-      lat: markerData.lat,
-      alt: markerData.alt,
-      title: markerData.title,
-      content: markerData.content
-    })
-    emit('markerClick', markerData)
+    const obj = intersects[0].object
+    const markerData = obj.userData.markerData
+    if (markerData) openMarkerPopup(markerData)
   } else {
   } else {
     activeMarker.value = null
     activeMarker.value = null
   }
   }
 }
 }
 
 
+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 }
+}
+
 async function loadAll() {
 async function loadAll() {
   if (!scene) return
   if (!scene) return
   loading.value = true
   loading.value = true
@@ -386,6 +493,15 @@ onMounted(() => {
   renderer.toneMappingExposure = 1.0
   renderer.toneMappingExposure = 1.0
   el.appendChild(renderer.domElement)
   el.appendChild(renderer.domElement)
 
 
+  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)
+
   controls = new OrbitControls(camera, renderer.domElement)
   controls = new OrbitControls(camera, renderer.domElement)
   controls.enableDamping = true
   controls.enableDamping = true
   controls.dampingFactor = 0.06
   controls.dampingFactor = 0.06
@@ -439,6 +555,8 @@ onMounted(() => {
     animId = requestAnimationFrame(loop)
     animId = requestAnimationFrame(loop)
     controls.update()
     controls.update()
     renderer.render(scene, camera)
     renderer.render(scene, camera)
+    if (labelRenderer) labelRenderer.render(scene, camera)
+    updatePopupPosition()
   }
   }
   loop()
   loop()
 })
 })
@@ -451,6 +569,9 @@ function onResize() {
   camera.aspect = container.value.clientWidth / container.value.clientHeight
   camera.aspect = container.value.clientWidth / container.value.clientHeight
   camera.updateProjectionMatrix()
   camera.updateProjectionMatrix()
   renderer.setSize(container.value.clientWidth, container.value.clientHeight)
   renderer.setSize(container.value.clientWidth, container.value.clientHeight)
+  if (labelRenderer) {
+    labelRenderer.setSize(container.value.clientWidth, container.value.clientHeight)
+  }
 }
 }
 window.addEventListener('resize', onResize)
 window.addEventListener('resize', onResize)
 
 
@@ -461,6 +582,8 @@ onUnmounted(() => {
   renderer?.domElement?.removeEventListener('dblclick', onDblClick)
   renderer?.domElement?.removeEventListener('dblclick', onDblClick)
   renderer?.domElement?.removeEventListener('click', onMarkerClick)
   renderer?.domElement?.removeEventListener('click', onMarkerClick)
   renderer?.dispose()
   renderer?.dispose()
+  labelRenderer?.domElement?.remove()
+  labelRenderer = null
 })
 })
 </script>
 </script>
 
 
@@ -486,15 +609,12 @@ onUnmounted(() => {
     <!-- 标记点弹窗 -->
     <!-- 标记点弹窗 -->
     <div
     <div
       v-if="activeMarker"
       v-if="activeMarker"
-      class="marker-popup"
+      class="marker-popup-wrapper"
       :style="{ left: markerPopupPosition.x + 'px', top: markerPopupPosition.y + 'px' }"
       :style="{ left: markerPopupPosition.x + 'px', top: markerPopupPosition.y + 'px' }"
       @click.stop
       @click.stop
     >
     >
-      <div class="popup-header">
-        <span class="popup-title">{{ activeMarker.title }}</span>
-        <button class="popup-close" @click="activeMarker = null">×</button>
-      </div>
-      <div class="popup-content">{{ activeMarker.content }}</div>
+      <button class="popup-close" @click="activeMarker = null">×</button>
+      <MarkerPopup :marker="activeMarker" />
     </div>
     </div>
   </div>
   </div>
 </template>
 </template>
@@ -514,64 +634,32 @@ onUnmounted(() => {
 .sp { width:40px; height:40px; border:3px solid #ddd; border-top-color:#409eff; border-radius:50%; animation:sp 0.8s linear infinite; }
 .sp { width:40px; height:40px; border:3px solid #ddd; border-top-color:#409eff; border-radius:50%; animation:sp 0.8s linear infinite; }
 @keyframes sp { to { transform:rotate(360deg) } }
 @keyframes sp { to { transform:rotate(360deg) } }
 
 
-/* 标记点弹窗样式 */
-.marker-popup {
+/* 标记点弹窗 */
+.marker-popup-wrapper {
   position: absolute;
   position: absolute;
   z-index: 100;
   z-index: 100;
-  background: rgba(20, 20, 25, 0.95);
-  backdrop-filter: blur(16px);
-  border-radius: 12px;
-  padding: 0;
-  min-width: 240px;
-  max-width: 320px;
-  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.08);
-  animation: popupIn 0.2s ease-out;
-}
-
-@keyframes popupIn {
-  from {
-    opacity: 0;
-    transform: translateY(10px) scale(0.95);
-  }
-  to {
-    opacity: 1;
-    transform: translateY(0) scale(1);
-  }
 }
 }
 
 
-.popup-header {
+.popup-close {
+  position: absolute;
+  top: 8px;
+  right: 8px;
+  width: 22px;
+  height: 22px;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
-  justify-content: space-between;
-  padding: 14px 16px;
-  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
-}
-
-.popup-title {
-  font-size: 15px;
-  font-weight: 600;
-  color: #fff;
-}
-
-.popup-close {
-  background: none;
+  justify-content: center;
+  background: rgba(255, 255, 255, 0.1);
   border: none;
   border: none;
-  color: rgba(255, 255, 255, 0.5);
-  font-size: 20px;
+  border-radius: 50%;
+  color: rgba(255, 255, 255, 0.7);
+  font-size: 16px;
   cursor: pointer;
   cursor: pointer;
-  padding: 0;
-  line-height: 1;
-  transition: color 0.15s;
+  z-index: 10;
 }
 }
 
 
 .popup-close:hover {
 .popup-close:hover {
+  background: rgba(255, 255, 255, 0.2);
   color: #fff;
   color: #fff;
 }
 }
-
-.popup-content {
-  padding: 14px 16px;
-  font-size: 13px;
-  color: rgba(255, 255, 255, 0.75);
-  line-height: 1.6;
-}
 </style>
 </style>

+ 107 - 7
test-marker.html

@@ -199,9 +199,57 @@
           <input type="text" id="title" value="测试点">
           <input type="text" id="title" value="测试点">
         </div>
         </div>
         <div class="form-group">
         <div class="form-group">
+          <label>类型</label>
+          <select id="markerType" onchange="onTypeChange()" style="width: 100%; padding: 8px 12px; border: 1px solid #0f3460; border-radius: 4px; background: #0f3460; color: #eee; font-size: 13px;">
+            <option value="">普通标记</option>
+            <option value="video">视频 (video)</option>
+            <option value="html">HTML (html)</option>
+            <option value="device">设备 (device)</option>
+          </select>
+        </div>
+
+        <!-- 通用字段 -->
+        <div class="form-group">
           <label>内容</label>
           <label>内容</label>
           <input type="text" id="content" value="这是一个测试标记点">
           <input type="text" id="content" value="这是一个测试标记点">
         </div>
         </div>
+
+        <!-- video 字段 -->
+        <div class="type-fields" id="videoFields" style="display:none">
+          <div class="form-group">
+            <label>视频 URL (videoUrl)</label>
+            <input type="text" id="videoUrl" value="https://www.w3schools.com/html/mov_bbb.mp4">
+          </div>
+          <div class="form-group">
+            <label>iframe URL (iframeUrl,优先级低于 videoUrl)</label>
+            <input type="text" id="iframeUrl" value="">
+          </div>
+        </div>
+
+        <!-- html 字段 -->
+        <div class="type-fields" id="htmlFields" style="display:none">
+          <div class="form-group">
+            <label>HTML 内容 (htmlContent)</label>
+            <input type="text" id="htmlContent" value="<div style='color:#22c55e'>自定义 HTML</div>">
+          </div>
+          <div class="form-group">
+            <label>HTML 页面 URL (htmlUrl,优先级低于 htmlContent)</label>
+            <input type="text" id="htmlUrl" value="">
+          </div>
+        </div>
+
+        <!-- device 字段 -->
+        <div class="type-fields" id="deviceFields" style="display:none">
+          <div class="form-group">
+            <label>状态 (status)</label>
+            <input type="text" id="status" value="正常">
+          </div>
+          <div class="form-group">
+            <label>指标 (metrics JSON)</label>
+            <input type="text" id="metrics" value='[{"label":"温度","value":24,"unit":"°C"},{"label":"湿度","value":62,"unit":"%"}]'>
+          </div>
+        </div>
+
         <div class="form-group">
         <div class="form-group">
           <label>颜色</label>
           <label>颜色</label>
           <div class="color-picker">
           <div class="color-picker">
@@ -248,8 +296,16 @@
       })
       })
     })
     })
 
 
+    // 类型切换
+    function onTypeChange() {
+      const type = document.getElementById('markerType').value
+      document.querySelectorAll('.type-fields').forEach(el => el.style.display = 'none')
+      if (type) document.getElementById(type + 'Fields').style.display = 'block'
+    }
+
     // 添加标记点
     // 添加标记点
     function addMarker() {
     function addMarker() {
+      const markerType = document.getElementById('markerType').value
       const data = {
       const data = {
         type: 'addMarker',
         type: 'addMarker',
         lon: parseFloat(document.getElementById('lon').value),
         lon: parseFloat(document.getElementById('lon').value),
@@ -257,10 +313,33 @@
         alt: parseFloat(document.getElementById('alt').value),
         alt: parseFloat(document.getElementById('alt').value),
         title: document.getElementById('title').value,
         title: document.getElementById('title').value,
         content: document.getElementById('content').value,
         content: document.getElementById('content').value,
-        color: selectedColor
+        color: selectedColor,
+        markerType: markerType
+      }
+
+      if (markerType === 'video') {
+        const videoUrl = document.getElementById('videoUrl').value.trim()
+        const iframeUrl = document.getElementById('iframeUrl').value.trim()
+        if (videoUrl) data.videoUrl = videoUrl
+        if (iframeUrl) data.iframeUrl = iframeUrl
+      } else if (markerType === 'html') {
+        const htmlContent = document.getElementById('htmlContent').value.trim()
+        const htmlUrl = document.getElementById('htmlUrl').value.trim()
+        if (htmlContent) data.htmlContent = htmlContent
+        if (htmlUrl) data.htmlUrl = htmlUrl
+      } else if (markerType === 'device') {
+        data.status = document.getElementById('status').value
+        try {
+          const metricsStr = document.getElementById('metrics').value.trim()
+          if (metricsStr) data.metrics = JSON.parse(metricsStr)
+        } catch (e) {
+          log('error', '指标 JSON 解析失败: ' + e.message)
+          return
+        }
       }
       }
+
       iframe.contentWindow.postMessage(data, '*')
       iframe.contentWindow.postMessage(data, '*')
-      log('send', `添加标记: ${data.title} (${data.lon}, ${data.lat})`)
+      log('send', `添加标记 [${markerType || '默认'}]: ${data.title} (${data.lon}, ${data.lat})`)
     }
     }
 
 
     // 添加随机标记
     // 添加随机标记
@@ -268,18 +347,39 @@
       const lon = 113.720 + Math.random() * 0.015
       const lon = 113.720 + Math.random() * 0.015
       const lat = 24.565 + Math.random() * 0.015
       const lat = 24.565 + Math.random() * 0.015
       const colors = ['#ff4444', '#44ff88', '#4488ff', '#ffaa44', '#aa44ff']
       const colors = ['#ff4444', '#44ff88', '#4488ff', '#ffaa44', '#aa44ff']
-      const titles = ['监测点A', '监测点B', '监测点C', '预警点', '异常点']
+      const types = ['video', 'device', 'html', '']
+      const type = types[Math.floor(Math.random() * types.length)]
+      const titles = {
+        video: '视频监控',
+        device: '设备监测',
+        html: 'HTML面板',
+        '': '普通标记'
+      }
       const data = {
       const data = {
         type: 'addMarker',
         type: 'addMarker',
         lon: lon,
         lon: lon,
         lat: lat,
         lat: lat,
         alt: Math.floor(Math.random() * 200),
         alt: Math.floor(Math.random() * 200),
-        title: titles[Math.floor(Math.random() * titles.length)],
+        title: titles[type] + ' #' + Math.floor(Math.random() * 100),
         content: `随机标记点 #${Math.floor(Math.random() * 1000)}`,
         content: `随机标记点 #${Math.floor(Math.random() * 1000)}`,
-        color: colors[Math.floor(Math.random() * colors.length)]
+        color: colors[Math.floor(Math.random() * colors.length)],
+        markerType: type
       }
       }
+
+      if (type === 'video') {
+        data.videoUrl = 'https://www.w3schools.com/html/mov_bbb.mp4'
+      } else if (type === 'html') {
+        data.htmlContent = '<div style="color:#22c55e;font-weight:600">随机 HTML 内容</div>'
+      } else if (type === 'device') {
+        data.status = Math.random() > 0.5 ? '正常' : '警告'
+        data.metrics = [
+          { label: '温度', value: Math.floor(Math.random() * 40), unit: '°C' },
+          { label: '湿度', value: Math.floor(Math.random() * 100), unit: '%' }
+        ]
+      }
+
       iframe.contentWindow.postMessage(data, '*')
       iframe.contentWindow.postMessage(data, '*')
-      log('send', `添加随机标记: ${data.title} (${data.lon.toFixed(4)}, ${data.lat.toFixed(4)})`)
+      log('send', `添加随机标记 [${type || '默认'}]: ${data.title} (${data.lon.toFixed(4)}, ${data.lat.toFixed(4)})`)
     }
     }
 
 
     // 飞行到标记点
     // 飞行到标记点
@@ -318,7 +418,7 @@
     window.addEventListener('message', (event) => {
     window.addEventListener('message', (event) => {
       const data = event.data
       const data = event.data
       if (data.type === 'markerClick') {
       if (data.type === 'markerClick') {
-        log('click', `点击标记: ${data.title} (${data.lon}, ${data.lat})`)
+        log('click', `点击标记 [${data.markerType || '默认'}]: ${data.title} (${data.lon}, ${data.lat})`)
       }
       }
     })
     })
   </script>
   </script>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio