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
| 文件 | 职责 |
|---|---|
src/components/MarkerPopup.vue |
根据 marker type 渲染 video / html / device 弹窗内容 |
src/components/TerrainViewer.vue |
集成 CSS2DRenderer、创建六边形底座/垂线/标签、管理弹窗状态 |
src/App.vue |
更新示例 markers,覆盖三种 type |
src/components/MarkerPopup.vueFiles:
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 模板语法错误。
TerrainViewer.vue 中引入 CSS2DRendererFiles:
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'
在已有的 let scene, camera, renderer, controls, animId 等声明后添加:
let labelRenderer = null
let labelGroup = null
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)
修改 loop 函数:
function loop() {
animId = requestAnimationFrame(loop)
controls.update()
renderer.render(scene, camera)
if (labelRenderer) labelRenderer.render(scene, camera)
}
onResize 和 onUnmounted 中处理 CSS2DRenderer在 onResize 中添加:
if (labelRenderer) {
labelRenderer.setSize(container.value.clientWidth, container.value.clientHeight)
}
在 onUnmounted 末尾添加:
labelRenderer?.domElement?.remove()
labelRenderer = null
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
}
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)
}
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)
}
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
}
}
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 }
}
修改 loop 函数:
function loop() {
animId = requestAnimationFrame(loop)
controls.update()
renderer.render(scene, camera)
if (labelRenderer) labelRenderer.render(scene, camera)
updatePopupPosition()
}
在 handleIframeMessage 的 addMarker 分支中,把新字段透传进去:
} 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)
}
MarkerPopupFiles:
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>
在 <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;
}
将旧的 .marker-popup、.popup-header、.popup-title、.popup-close、.popup-content 等样式删除,避免冲突。
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>'
}
])
Files:
N/A
[ ] Step 1: 本地运行开发服务器
Run: npm run dev
Expected: 无启动报错,打开 http://localhost:5173(或 Vite 提示的端口)能看到 3D 地形。
Expected:
悬停标签有放大效果。
[ ] Step 3: 手动检查弹窗
Expected:
点击空白处或关闭按钮弹窗消失。
[ ] Step 4: 生产构建检查
Run: npm run build
Expected: 构建成功,dist/ 目录生成,无 TypeScript/Vite 报错。
在浏览器控制台执行:
window.postMessage({ type: 'addMarker', lon: 113.724, lat: 24.570, title: '测试点', type: 'device', status: '正常', metrics: [{ label: '测试', value: 1 }] }, '*')
Expected: 新标记点出现在地图上,点击后弹出设备卡片。
| 设计需求 | 对应任务 |
|---|---|
| 六边形图标 | 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 验证 |