Cesium实现可拖拽图标及连接线

在 Cesium 三维地图应用中,灵活的用户交互功能对于提升用户体验至关重要。本文将详细介绍如何在 Cesium 中实现可拖拽的信息标签功能,包括标签与目标实体之间的动态连接线,以及如何优雅地处理视角变化时的位置更新问题。这种交互方式特别适合展示动态目标(如车辆、飞机等)的实时信息。

需求背景

在展示如车辆轨迹、飞行路径等动态场景时,我们通常需要同时显示目标的详细信息。直接将信息附加在目标上往往会造成视觉混乱,而允许用户自由拖动信息标签则可以:

  1. 避免信息遮挡重要视图内容
  2. 允许用户根据自己的喜好整理界面
  3. 在多目标场景下提高信息的可读性
  1. 保持目标与信息之间的视觉关联

实现原理

实现可拖拽标签的核心是利用 Cesium 的事件系统捕获用户交互,并实时更新标签的屏幕坐标偏移量。主要步骤包括:

  1. 为目标实体创建关联的信息标签实体
  2. 标记实体为可拖拽(通过自定义属性)
  3. 设置屏幕空间事件处理器捕获拖拽事件
  4. 在拖拽过程中更新标签的偏移量
  5. 创建并维护目标与标签之间的连接线

详细代码解析

1. 创建信息标签

首先,我们需要为目标实体创建一个信息标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
const createHTMLLable = (entities, id) => {
const entity = entities.getById(id);
const entity2 = new Cesium.Entity({
id: id + "box",
drag: true, // 自定义属性,标记为可拖拽
position: entity.position,
});
entity2.proId = id; // 存储关联的目标ID

// 设置标签背景图片
entity2.billboard = {
width: 245,
height: 86,
image: "/img/box.png",
horizontalOrigin: Cesium.HorizontalOrigin.LEFT,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
eyeOffset: new Cesium.Cartesian3(0, 0, 1),
pixelOffset: new Cesium.Cartesian2(0, -120),
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(20, 100000),
};

// 设置标签文本内容,这里使用回调属性来动态更新坐标信息
entity2.label = {
text: new Cesium.CallbackProperty(function () {
const time = viewer.clock.currentTime;
const position = entity.position.getValue(time);
const cartographic = Cesium.Cartographic.fromCartesian(position);
const longitude = Cesium.Math.toDegrees(cartographic.longitude);
const latitude = Cesium.Math.toDegrees(cartographic.latitude);
return `\t\t${longitude.toFixed(4)}\t\t\n\t\t${latitude.toFixed(5)}\t\t`;
}, false),
font: "19pt/1.5 微软雅黑",
showBackground: true,
backgroundColor: new Cesium.Color(0.165, 0.165, 0.165, 0.8),
horizontalOrigin: Cesium.HorizontalOrigin.LEFT,
verticalOrigin: Cesium.VerticalOrigin.TOP,
pixelOffset: new Cesium.Cartesian2(85, -198),
show: true,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(20, 100000),
};

// 创建连接线并更新
let linePositions;
function updateLine() {
const currentTime = viewer.clock.currentTime;
const position = entity.position.getValue(currentTime);
const screenPosition = Cesium.SceneTransforms.worldToDrawingBufferCoordinates(
viewer.scene,
position
);
const carBillboardOffset = entity2.billboard.pixelOffset.getValue();

if (screenPosition) {
const offsetScreenPosition = new Cesium.Cartesian2(
screenPosition.x + carBillboardOffset.x + 85,
screenPosition.y + carBillboardOffset.y
);
const offsetCartesian = viewer.camera.pickEllipsoid(
offsetScreenPosition,
viewer.scene.globe.ellipsoid
);

if (!offsetCartesian) return;
const lineId = id + "line";
let lineEntity = entities.getById(lineId);
if (!lineEntity) {
linePositions = [offsetCartesian, position];
lineEntity = entities.add({
id: lineId,
polyline: {
positions: new Cesium.CallbackProperty(function () {
return linePositions;
}, false),
width: 2,
material: new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.RED,
dashLength: 16.0,
}),
},
});
} else {
linePositions = [offsetCartesian, position];
}
}
}

updateLine();
viewer.scene.camera.changed.addEventListener(updateLine);
entities.add(entity2);
};

这段代码的关键点:

  1. 使用drag: true标记实体为可拖拽
  2. 使用proId存储关联的目标实体 ID,方便后续引用
  3. 通过CallbackProperty动态更新标签文本内容,显示实时坐标
  4. 创建虚线连接目标和标签
  5. 监听相机变化事件,实时更新连接线位置

2. 实现拖拽功能

拖拽功能的核心是设置屏幕空间事件处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
const setupDraggingHandler = () => {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
let selectedEntity;
let isDragging = false;
let startDragPosition = null;
let startOffset = null;
let startLabelOffset = null;

// 更新连接线位置的函数
const updateLinePosition = () => {
if (!selectedEntity) return;
const currentTime = viewer.clock.currentTime;
const position = selectedEntity.position.getValue(currentTime);
const screenPosition = Cesium.SceneTransforms.worldToDrawingBufferCoordinates(
viewer.scene,
position
);
const carBillboardOffset = selectedEntity.billboard.pixelOffset.getValue();

if (screenPosition) {
const offsetScreenPosition = new Cesium.Cartesian2(
screenPosition.x + carBillboardOffset.x + 85,
screenPosition.y + carBillboardOffset.y
);
const offsetCartesian = viewer.camera.pickEllipsoid(
offsetScreenPosition,
viewer.scene.globe.ellipsoid
);

if (offsetCartesian) {
const lineId = selectedEntity.proId + "line";
const lineEntity = entitiesCol.getById(lineId);
if (lineEntity) {
lineEntity.polyline.positions = new Cesium.CallbackProperty(function () {
return [offsetCartesian, position];
}, false);
}
}
}
};

// 处理鼠标按下事件
handler.setInputAction(function (click) {
const pickedObject = viewer.scene.pick(click.position);
if (Cesium.defined(pickedObject) && Cesium.defined(pickedObject.id) && pickedObject.id.drag) {
// 选中标记为可拖拽的实体
selectedEntity = pickedObject.id;
isDragging = true;
startDragPosition = click.position;

// 获取当前偏移量
startOffset = selectedEntity.billboard.pixelOffset.getValue();
startLabelOffset = selectedEntity.label.pixelOffset.getValue();

// 禁用相机旋转,防止拖动时意外旋转视图
viewer.scene.screenSpaceCameraController.enableRotate = false;
}
}, Cesium.ScreenSpaceEventType.LEFT_DOWN);

// 处理鼠标移动事件
handler.setInputAction(function (movement) {
if (isDragging && selectedEntity) {
const dragPosition = movement.endPosition;

// 计算拖动的距离
const offsetX = dragPosition.x - startDragPosition.x;
const offsetY = dragPosition.y - startDragPosition.y;

// 更新偏移量
const newPixelOffset = new Cesium.Cartesian2(
startOffset.x + offsetX,
startOffset.y + offsetY
);
const newLabelOffset = new Cesium.Cartesian2(
startLabelOffset.x + offsetX,
startLabelOffset.y + offsetY
);

// 应用新的偏移量
selectedEntity.billboard.pixelOffset = newPixelOffset;
selectedEntity.label.pixelOffset = newLabelOffset;

// 更新连接线
updateLinePosition();
}
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);

// 处理鼠标释放事件
handler.setInputAction(function () {
isDragging = false;
startDragPosition = null;
startOffset = null;

// 重新启用相机旋转
viewer.scene.screenSpaceCameraController.enableRotate = true;

// 添加相机变化监听,以更新连接线
viewer.scene.camera.changed.addEventListener(updateLinePosition);
}, Cesium.ScreenSpaceEventType.LEFT_UP);
};

这段代码的关键点:

  1. 创建ScreenSpaceEventHandler用于捕获鼠标事件
  2. 在 LEFT_DOWN 事件中,检查点击的实体是否标记为可拖拽
  3. 记录拖拽起始位置和初始偏移量
  4. 在 MOUSE_MOVE 事件中,计算鼠标移动距离并更新标签偏移量
  5. 在 LEFT_UP 事件中,重置拖拽状态并恢复正常操作

3. 连接线实现

连接线的实现涉及两个关键步骤:

  1. 计算标签在屏幕上的位置
  2. 将屏幕坐标转换回世界坐标,作为连接线的一端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 更新连接线的关键代码
const screenPosition = Cesium.SceneTransforms.worldToDrawingBufferCoordinates(
viewer.scene,
position
);
const carBillboardOffset = entity2.billboard.pixelOffset.getValue();

if (screenPosition) {
// 计算标签位置在屏幕上的坐标
const offsetScreenPosition = new Cesium.Cartesian2(
screenPosition.x + carBillboardOffset.x + 85,
screenPosition.y + carBillboardOffset.y
);

// 将屏幕坐标转换回世界坐标
const offsetCartesian = viewer.camera.pickEllipsoid(
offsetScreenPosition,
viewer.scene.globe.ellipsoid
);

if (offsetCartesian) {
// 创建或更新连接线
linePositions = [offsetCartesian, position];
}
}

这段代码关键的 API 使用:

  1. worldToDrawingBufferCoordinates:将世界坐标转换为屏幕坐标
  2. pickEllipsoid:将屏幕坐标转换回世界坐标
  3. CallbackProperty:使连接线能够实时更新位置

重要技术点解析

1. 坐标系转换

实现可拖拽标签的一个关键技术点是在世界坐标系和屏幕坐标系之间进行转换:

  • 世界坐标系:Cesium 中的 Cartesian3 坐标,表示三维空间中的位置
  • 屏幕坐标系:二维像素坐标,表示元素在屏幕上的位置

拖拽交互发生在屏幕坐标系中,但连接线需要在世界坐标系中创建。代码中使用了两个关键方法实现这种转换:

1
2
3
4
5
6
7
8
9
10
11
// 世界坐标 -> 屏幕坐标
const screenPosition = Cesium.SceneTransforms.worldToDrawingBufferCoordinates(
viewer.scene,
position
);

// 屏幕坐标 -> 世界坐标
const offsetCartesian = viewer.camera.pickEllipsoid(
offsetScreenPosition,
viewer.scene.globe.ellipsoid
);

2. 处理视角变化

当相机视角改变时,标签与目标之间的相对位置需要保持一致。代码通过监听相机变化事件来解决这个问题:

1
2
// 监听相机变化事件
viewer.scene.camera.changed.addEventListener(updateLine);

这确保了无论用户如何旋转、平移或缩放视图,连接线都能正确更新。

3. 像素偏移量

使用像素偏移量(pixelOffset)而非直接修改实体位置,是实现拖拽的关键:

1
2
entity2.billboard.pixelOffset = new Cesium.Cartesian2(0, -120);
entity2.label.pixelOffset = new Cesium.Cartesian2(85, -198);

这样做的优势是:

  • 标签实际位置仍然与目标位置绑定,保持关联
  • 只有屏幕上的显示位置发生变化,便于计算连接线
  • 当目标移动时标签会自动跟随,无需额外处理

总结

通过 Cesium 的事件系统和坐标转换功能,我们成功实现了可拖拽的信息标签系统。这种交互方式大大提升了信息展示的灵活性和用户体验,特别适合于展示动态目标的详细信息。

实现可拖拽标签的核心技术包括:

  1. 使用自定义属性标记可拖拽实体
  2. 使用 ScreenSpaceEventHandler 捕获拖拽事件
  3. 使用像素偏移量调整标签位置
  4. 在世界坐标系和屏幕坐标系之间进行转换
  5. 创建动态连接线维持视觉关联
作者

wangyawei

发布于

2025-03-21

更新于

2025-03-21

许可协议