先给大家上预览:




这是我改善了两版之后的结果。
最开始曾尝试在原版label上改属性、以及修改材质,两种方式都出现了不同程度的问题,最终我尝试使用div来创建label,并时刻计算对应的屏幕坐标到广告牌上方,完成了一版不错的效果。
下面给出封装方法。
vue3+ts:
1. useDroneNestHtmlLabels.ts
这是核心逻辑文件,负责做两件事:
- 把 Cesium 世界坐标转换成屏幕坐标
- 输出模板可直接渲染的
labelOverlays
对外只需要关心两个方法: start(viewer, getSources):开始监听 CesiumpostRender,持续刷新标签位置stop():停止监听并清空标签
javascript
import * as Cesium from 'cesium'
import { ref } from 'vue'
export interface DroneNestHtmlLabelOverlay {
id: string
text: string
left: number
top: number
visible: boolean
}
export interface DroneNestHtmlLabelSource {
id: string
text: string
anchorCartesian: Cesium.Cartesian3
billboardHeight: number
scaleByDistance?: Cesium.NearFarScalar
}
interface UseDroneNestHtmlLabelsOptions {
labelGapPx?: number
viewportPaddingPx?: number
}
export function useDroneNestHtmlLabels(options: UseDroneNestHtmlLabelsOptions = {}) {
const {
labelGapPx = 8,
viewportPaddingPx = 40,
} = options
const labelOverlays = ref<DroneNestHtmlLabelOverlay[]>([])
let removeRenderListener: (() => void) | null = null
const getNearFarScaleValue = (scalar: Cesium.NearFarScalar | undefined, distance: number) => {
if (!scalar) return 1
if (distance <= scalar.near) return scalar.nearValue
if (distance >= scalar.far) return scalar.farValue
const ratio = (distance - scalar.near) / (scalar.far - scalar.near)
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * ratio
}
const buildOverlay = (
viewer: Cesium.Viewer,
occluder: { isPointVisible: (position: Cesium.Cartesian3) => boolean },
source: DroneNestHtmlLabelSource,
): DroneNestHtmlLabelOverlay => {
const canvas = viewer.scene.canvas
if (!source.anchorCartesian || !occluder.isPointVisible(source.anchorCartesian)) {
return {
id: source.id,
text: source.text,
left: 0,
top: 0,
visible: false,
}
}
const windowPosition = Cesium.SceneTransforms.wgs84ToWindowCoordinates(
viewer.scene,
source.anchorCartesian,
)
if (!windowPosition || !Number.isFinite(windowPosition.x) || !Number.isFinite(windowPosition.y)) {
return {
id: source.id,
text: source.text,
left: 0,
top: 0,
visible: false,
}
}
const distance = Cesium.Cartesian3.distance(viewer.camera.positionWC, source.anchorCartesian)
const scale = getNearFarScaleValue(source.scaleByDistance, distance)
const labelOffsetPx = source.billboardHeight * scale + labelGapPx
const isInViewport = windowPosition.x >= -viewportPaddingPx
&& windowPosition.x <= canvas.clientWidth + viewportPaddingPx
&& windowPosition.y >= -viewportPaddingPx
&& windowPosition.y <= canvas.clientHeight + viewportPaddingPx
return {
id: source.id,
text: source.text,
left: windowPosition.x,
top: windowPosition.y - labelOffsetPx,
visible: isInViewport,
}
}
const sync = (viewer: Cesium.Viewer, sources: DroneNestHtmlLabelSource[]) => {
const occluder = new (Cesium as any).EllipsoidalOccluder(
viewer.scene.globe.ellipsoid,
viewer.camera.positionWC,
)
labelOverlays.value = sources.map((source) => buildOverlay(viewer, occluder, source))
}
const start = (viewer: Cesium.Viewer, getSources: () => DroneNestHtmlLabelSource[]) => {
removeRenderListener?.()
const render = () => {
sync(viewer, getSources())
}
viewer.scene.postRender.addEventListener(render)
removeRenderListener = () => {
viewer.scene.postRender.removeEventListener(render)
removeRenderListener = null
}
render()
}
const stop = () => {
removeRenderListener?.()
labelOverlays.value = []
}
return {
labelOverlays,
start,
stop,
}
}
2. DroneNestHtmlLabelOverlay.vue
这是纯展示组件,只负责把 labelOverlays 渲染成 HTML div 标签。
javascript
<script lang="ts">
export default {
name: 'DroneNestHtmlLabelOverlay',
}
</script>
<script setup lang="ts">
import type { DroneNestHtmlLabelOverlay } from './useDroneNestHtmlLabels'
defineProps<{
overlays: DroneNestHtmlLabelOverlay[]
}>()
</script>
<template>
<div class="drone-nest-html-label-overlay">
<div
v-for="item in overlays"
v-show="item.visible"
:key="item.id"
class="drone-nest-html-label-overlay__item"
:style="{
left: `${item.left}px`,
top: `${item.top}px`,
}"
>
{{ item.text }}
</div>
</div>
</template>
<style scoped lang="scss">
.drone-nest-html-label-overlay {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.drone-nest-html-label-overlay__item {
position: absolute;
padding: 6px 10px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
font-size: 14px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
transform: translate(-50%, -100%);
text-shadow:
0 1px 1px rgba(0, 0, 0, 0.8),
0 0 2px rgba(0, 0, 0, 0.9);
}
</style>
3. DroneNestHtmlLabelDemo.vue
这是最小调用示例,保留的是可以直接抄到业务页里的完整链路:
- 初始化 Cesium
- 批量采样地形高度
- 添加机巢广告牌
- 组装 label source
- 启动 HTML label 跟随
javascript
<script lang="ts">
export default {
name: 'DroneNestHtmlLabelDemo',
}
</script>
<script setup lang="ts">
import * as Cesium from 'cesium'
import { onBeforeUnmount, onMounted } from 'vue'
import cesiumUtils from '../../utils/cesium/cesiumUtils'
import TestDroneNestHtmlLabelOverlay from './TestDroneNestHtmlLabelOverlay.vue'
import {
useDroneNestHtmlLabels,
type DroneNestHtmlLabelSource,
} from './useDroneNestHtmlLabels'
type DroneNestStatus = 'idle' | 'work'
interface DroneNestDemoItem {
id: string
name: string
lng: number
lat: number
sn: string
status: DroneNestStatus
}
const CONTAINER_ID = 'test-drone-nest-html-label-map'
const DEFAULT_CAMERA_ALTITUDE = 42000
const DRONE_NEST_POSITION_HEIGHT = 36
const DRONE_NEST_BILLBOARD_SIZE = 30
const DRONE_NEST_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(2000, 1, 120000, 0.48)
const droneNestList: DroneNestDemoItem[] = [
{
id: 'demo-drone-nest-01',
name: '机场-01',
lng: 109.94873764813244,
lat: 39.78098592227097,
sn: 'dx-01',
status: 'idle',
},
{
id: 'demo-drone-nest-02',
name: '机场-02',
lng: 109.992,
lat: 39.811,
sn: 'dx-02',
status: 'work',
},
]
let droneNestDataSource: Cesium.CustomDataSource | null = null
const droneNestLabelSources = new Map<string, DroneNestHtmlLabelSource>()
const { labelOverlays, start, stop } = useDroneNestHtmlLabels()
async function addDroneNestBatch() {
const viewer = cesiumUtils.getViewer()
if (!viewer) return
droneNestDataSource = new Cesium.CustomDataSource('test-drone-nest-html-label-demo')
viewer.dataSources.add(droneNestDataSource)
droneNestLabelSources.clear()
for (const item of droneNestList) {
const terrainHeight = await cesiumUtils.getTerrainHeight(item.lng, item.lat)
const anchorCartesian = Cesium.Cartesian3.fromDegrees(
item.lng,
item.lat,
terrainHeight + DRONE_NEST_POSITION_HEIGHT,
)
cesiumUtils.addDroneNestOverlay({
entities: droneNestDataSource.entities,
idPrefix: item.id,
lng: item.lng,
lat: item.lat,
sn: item.sn,
status: () => item.status,
anchorCartesian,
positionHeight: DRONE_NEST_POSITION_HEIGHT,
billboardWidth: DRONE_NEST_BILLBOARD_SIZE,
billboardHeight: DRONE_NEST_BILLBOARD_SIZE,
})
droneNestLabelSources.set(item.id, {
id: item.id,
text: item.name,
anchorCartesian,
billboardHeight: DRONE_NEST_BILLBOARD_SIZE,
scaleByDistance: DRONE_NEST_SCALE_BY_DISTANCE,
})
}
start(viewer, () => Array.from(droneNestLabelSources.values()))
}
function clearDroneNestBatch() {
const viewer = cesiumUtils.getViewer()
stop()
droneNestLabelSources.clear()
if (viewer && droneNestDataSource) {
viewer.dataSources.remove(droneNestDataSource, true)
}
droneNestDataSource = null
}
onMounted(() => {
clearDroneNestBatch()
cesiumUtils.destroy()
const viewer = cesiumUtils.initViewer(CONTAINER_ID, {
terrain: true,
dayNightEffect: true,
})
void addDroneNestBatch()
viewer?.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
droneNestList[0].lng,
droneNestList[0].lat,
DEFAULT_CAMERA_ALTITUDE,
),
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-55),
roll: 0,
},
duration: 1.6,
})
})
onBeforeUnmount(() => {
clearDroneNestBatch()
cesiumUtils.destroy()
})
</script>
<template>
<div class="test-drone-nest-html-label-demo">
<div :id="CONTAINER_ID" class="test-drone-nest-html-label-demo__map" />
<TestDroneNestHtmlLabelOverlay :overlays="labelOverlays" />
</div>
</template>
<style scoped lang="scss">
.test-drone-nest-html-label-demo {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
background: #020814;
}
.test-drone-nest-html-label-demo__map {
position: absolute;
inset: 0;
}
.test-drone-nest-html-label-demo__map:deep(.cesium-viewer),
.test-drone-nest-html-label-demo__map:deep(.cesium-viewer-cesiumWidgetContainer),
.test-drone-nest-html-label-demo__map:deep(.cesium-widget),
.test-drone-nest-html-label-demo__map:deep(canvas) {
width: 100%;
height: 100%;
}
</style>
如果你觉得上面太长,我还提供了一份最小可抄代码,你可以直接看懂我demo的骨架,从而在自己的项目上修改:
ts
const { labelOverlays, start, stop } = useDroneNestHtmlLabels()
const labelSources = new Map()
for (const item of droneNestList) {
const terrainHeight = await cesiumUtils.getTerrainHeight(item.lng, item.lat)
const anchorCartesian = Cesium.Cartesian3.fromDegrees(
item.lng,
item.lat,
terrainHeight + 36,
)
cesiumUtils.addDroneNestOverlay({
entities: dataSource.entities,
idPrefix: item.id,
lng: item.lng,
lat: item.lat,
sn: item.sn,
status: () => item.status,
anchorCartesian,
positionHeight: 36,
billboardWidth: 30,
billboardHeight: 30,
})
labelSources.set(item.id, {
id: item.id,
text: item.name,
anchorCartesian,
billboardHeight: 30,
scaleByDistance: new Cesium.NearFarScalar(2000, 1, 120000, 0.48),
})
}
start(viewer, () => Array.from(labelSources.values()))
下面是v2的封装。
vue2+js:
javascript
<template>
<div id="init-viewer-wrapper">
<div
v-for="item in labelOverlays"
v-show="item.visible"
:key="item.id"
class="drone-label"
:style="{
left: item.left + 'px',
top: item.top + 'px'
}"
>
{{ item.text }}
</div>
</div>
</template>
<script>
import cesiumUtils from '@/utils/cesium/cesiumUtils.js'
export default {
name: 'depthDetection',
components: {},
data() {
return {
points: [],
droneNestDataSource: null,
labelOverlays: [],
droneNestLabelSources: [],
removeDroneNestLabelRenderListener: null,
droneNestList: [
{
id: 'demo-drone-nest-01',
name: '机场-01',
lng: 109.94873764813244,
lat: 39.78098592227097
},
{
id: 'demo-drone-nest-02',
name: '机场-02',
lng: 109.992,
lat: 39.811
}
]
}
},
mounted() {
this.initViewer()
},
beforeDestroy() {
this.clearDroneNestBatch()
cesiumUtils.destroy();
},
methods: {
async initViewer() {
this.clearDroneNestBatch()
cesiumUtils.destroy()
const viewer = cesiumUtils.initViewer("init-viewer-wrapper", { terrain: true });
await this.addDroneNestBatch()
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(this.droneNestList[0].lng, this.droneNestList[0].lat, 42000),
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-55),
roll: 0
},
duration: 1.6
})
},
async addDroneNestBatch() {
const viewer = cesiumUtils.getViewer()
if (!viewer) return
this.clearDroneNestBatch()
this.droneNestDataSource = new Cesium.CustomDataSource('drone-nest-html-label-test-demo')
viewer.dataSources.add(this.droneNestDataSource)
this.droneNestLabelSources = []
for (let i = 0; i < this.droneNestList.length; i += 1) {
const item = this.droneNestList[i]
const terrainHeight = await cesiumUtils.getTerrainHeight(item.lng, item.lat)
const anchorCartesian = Cesium.Cartesian3.fromDegrees(item.lng, item.lat, terrainHeight + 36)
this.droneNestDataSource.entities.add({
id: item.id + '-billboard',
position: anchorCartesian,
billboard: {
image: require('../../assets/images/droneNest_idle.png'),
width: 30,
height: 30,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
scaleByDistance: new Cesium.NearFarScalar(2000, 1, 120000, 0.48)
}
})
this.droneNestLabelSources.push({
id: item.id,
text: item.name,
anchorCartesian: anchorCartesian,
billboardHeight: 30,
scaleByDistance: new Cesium.NearFarScalar(2000, 1, 120000, 0.48)
})
}
this.startDroneNestLabelSync()
},
clearDroneNestBatch() {
const viewer = cesiumUtils.getViewer()
this.stopDroneNestLabelSync()
this.droneNestLabelSources = []
this.labelOverlays = []
if (viewer && this.droneNestDataSource) {
viewer.dataSources.remove(this.droneNestDataSource, true)
}
this.droneNestDataSource = null
},
startDroneNestLabelSync() {
const viewer = cesiumUtils.getViewer()
if (!viewer) return
this.stopDroneNestLabelSync()
const render = () => {
this.syncDroneNestLabels()
}
viewer.scene.postRender.addEventListener(render)
this.removeDroneNestLabelRenderListener = () => {
viewer.scene.postRender.removeEventListener(render)
this.removeDroneNestLabelRenderListener = null
}
this.syncDroneNestLabels()
},
stopDroneNestLabelSync() {
if (this.removeDroneNestLabelRenderListener) {
this.removeDroneNestLabelRenderListener()
}
},
getNearFarScaleValue(scalar, distance) {
if (!scalar) return 1
if (distance <= scalar.near) return scalar.nearValue
if (distance >= scalar.far) return scalar.farValue
const ratio = (distance - scalar.near) / (scalar.far - scalar.near)
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * ratio
},
getLabelOffsetPx(source, viewer) {
const distance = Cesium.Cartesian3.distance(viewer.camera.positionWC, source.anchorCartesian)
const scale = this.getNearFarScaleValue(source.scaleByDistance, distance)
return source.billboardHeight * scale + 8
},
syncDroneNestLabels() {
const viewer = cesiumUtils.getViewer()
if (!viewer) {
this.labelOverlays = []
return
}
const canvas = viewer.scene.canvas
const occluder = new Cesium.EllipsoidalOccluder(viewer.scene.globe.ellipsoid, viewer.camera.positionWC)
this.labelOverlays = this.droneNestLabelSources.map((source) => {
if (!source.anchorCartesian || !occluder.isPointVisible(source.anchorCartesian)) {
return { id: source.id, text: source.text, left: 0, top: 0, visible: false }
}
const windowPosition = Cesium.SceneTransforms.wgs84ToWindowCoordinates(viewer.scene, source.anchorCartesian)
if (!windowPosition || !isFinite(windowPosition.x) || !isFinite(windowPosition.y)) {
return { id: source.id, text: source.text, left: 0, top: 0, visible: false }
}
const labelOffsetPx = this.getLabelOffsetPx(source, viewer)
const isInViewport =
windowPosition.x >= -40 &&
windowPosition.x <= canvas.clientWidth + 40 &&
windowPosition.y >= -40 &&
windowPosition.y <= canvas.clientHeight + 40
return {
id: source.id,
text: source.text,
left: windowPosition.x,
top: windowPosition.y - labelOffsetPx,
visible: isInViewport
}
})
}
}
}
</script>
<style scoped>
#init-viewer-wrapper {
width: 100%;
height: 100%;
position: relative;
}
.drone-label {
position: absolute;
z-index: 2;
pointer-events: none;
padding: 6px 10px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.72);
color: #ffffff;
font-size: 14px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
transform: translate(-50%, -100%);
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.8), 0 0 2px rgba(0, 0, 0, 0.9);
}
</style>
怎么样,很棒吧?