|
|
@@ -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。
|
|
|
+- 所有代码片段为可直接写入的内容。
|
|
|
+- 无“适当处理”等模糊描述。
|