在可视化项目中,我们经常需要在球体表面绘制飞线,并在飞线的中间展示一些信息卡片(也可以称为 Label、弹窗等)。比如:攻击事件监控、地理位置说明、某些数据统计信息等。这篇文章就是一步步 讲解如何在飞线的中点生成一个卡片,并在需要时进行可视化展示。
1. 实现思路
- 先计算飞线的中点:在球面上,给定起点(经度/纬度)和终点(经度/纬度),通过数学公式将它们转换为三维坐标,再在三维空间里计算出"中点"。
- 把卡片做成 Sprite :
- 在 Three.js 中,常用
Sprite
(精灵)来做类似标注、卡片的东西。因为Sprite
永远面向摄像机,比较适合做平面贴图。 - 也可以用 PlaneGeometry+Mesh,如果需要更多自定义操作。
- 在 Three.js 中,常用
- 生成卡片纹理 :
- 我们可能想把一些文字或 HTML 元素做成"图片纹理",再贴到 Sprite 上。
- 为了在 Three.js 中显示文字或复杂的 DOM 样式,这里可以借助 html2canvas 来把指定的 DOM 生成一张 png,再用
TextureLoader
把这张图片加载为贴图。
- 将卡片放置在飞线中点上 :
- 将上一步得到的 Sprite,设置
sprite.position.set(...)
到刚才计算好的中点位置。
- 将上一步得到的 Sprite,设置
- 显示 / 隐藏逻辑 :
- 如果你想在鼠标悬停在飞线时显示卡片,可以先默认
sprite.visible = false
,在鼠标检测到与飞线相交时再显示出来。 - 如果想一直显示,直接
sprite.visible = true
即可。
- 如果你想在鼠标悬停在飞线时显示卡片,可以先默认
- 避免卡片被销毁 :
- 有时我们会在一段时间后清除掉飞线和卡片,但如果你的业务想长期留着这条飞线,就不需要删除它,只要隐藏或设置
visible = false
即可。
- 有时我们会在一段时间后清除掉飞线和卡片,但如果你的业务想长期留着这条飞线,就不需要删除它,只要隐藏或设置
下面就详细说一下具体步骤和示例代码。
2. 计算球面两点的中点
Three.js 中通常会用到 lon2xyz()
这个工具函数,将经纬度转换为三维坐标(X、Y、Z)。我们可以这样写一个简易函数(根据地球半径 R,和经纬度 lon、lat 计算):
typescript
import { Vector3, MathUtils } from 'three';
/**
* 将经纬度转换为 Three.js 空间坐标
* @param radius 球体半径
* @param lon 经度
* @param lat 纬度
* @returns {Vector3} 球面上的三维坐标
*/
export function lon2xyz(radius: number, lon: number, lat: number): Vector3 {
const phi = MathUtils.degToRad(90 - lat); // 纬度转到球坐标系
const theta = MathUtils.degToRad(lon + 180); // 经度转到球坐标系
const x = -radius * Math.sin(phi) * Math.cos(theta);
const z = radius * Math.sin(phi) * Math.sin(theta);
const y = radius * Math.cos(phi);
return new Vector3(x, y, z);
}
有了这个函数,就可以很容易获取到起点 和终点在三维空间的坐标,然后再计算中点:
typescript
/**
* 计算球面弧线大约中点
* @param startE 起点经度
* @param startN 起点纬度
* @param endE 终点经度
* @param endN 终点纬度
* @param radius 球半径
* @returns 球面大约中点
*/
export function calculateMidpoint(
startE: number,
startN: number,
endE: number,
endN: number,
radius: number
): Vector3 {
// 先把经纬度转成三维坐标
const startP = lon2xyz(radius, startE, startN);
const endP = lon2xyz(radius, endE, endN);
// 简单的三维空间中点
const midpoint = new Vector3(
(startP.x + endP.x) / 2,
(startP.y + endP.y) / 2,
(startP.z + endP.z) / 2
);
// 把这个中点"归一化"回到球面上(稍微放大一些,让它悬浮在球面之上)
midpoint.normalize().multiplyScalar(radius * 1.1);
return midpoint;
}
这样就能得到一个中点坐标,用来放我们的"卡片"。
3. 创建卡片:HTML → Canvas → Texture → Sprite
3.1 使用 html2canvas
生成图片
我们先写一个 HTML 片段,一般可以放在一个隐藏或全局的容器里(例如 <div id="html2canvas" style="display: none;"></div>
),当我们想生成卡片的时候,往里塞一下 HTML,然后通过 html2canvas
转换成图片 base64,再把它贴到 Sprite 上。
示例:
html
<!-- 在你的 HTML 中预留一个容器 -->
<div id="html2canvas" style="display: none;"></div>
然后在 JavaScript/TypeScript 里:
typescript
import html2canvas from 'html2canvas';
import { Sprite, SpriteMaterial, TextureLoader } from 'three';
/**
* 生成一个 Sprite,以卡片方式显示
* @param cardHTML 需要在卡片上显示的 HTML 字符串
* @returns Promise<Sprite>
*/
export async function createCardSprite(cardHTML: string): Promise<Sprite> {
// 1) 拿到 html2canvas 的容器
const shareContent = document.getElementById('html2canvas');
if (!shareContent) throw new Error('html2canvas container not found');
// 2) 写入 HTML
shareContent.innerHTML = cardHTML;
// 3) 用 html2canvas 生成截图
const canvas = await html2canvas(shareContent, {
backgroundColor: 'transparent',
scale: 2,
dpi: window.devicePixelRatio,
});
// 4) 把生成的 canvas 转成 base64,再用 TextureLoader 变成贴图
const dataURL = canvas.toDataURL('image/png');
const map = new TextureLoader().load(dataURL);
// 5) 用这个贴图创建 SpriteMaterial 和 Sprite
const material = new SpriteMaterial({
map: map,
transparent: true,
});
const sprite = new Sprite(material);
// 6) 设置卡片的大小,后面会继续调整
sprite.scale.set(15, 10, 1);
return sprite;
}
现在,我们已经有一个 createCardSprite()
函数,只要给它一段 HTML,它就能返回一个写着那段 HTML 的 Sprite。
3.2 将卡片放置在飞线中点
假设你已经创建了飞线 arcline
,现在我们要做的就是:
- 计算中点
midpoint
。 - 生成卡片 Sprite。
- 设置位置 & 加到场景或飞线 Group 里。
例如(结合你的需求):
typescript
import { Group } from 'three';
// 在你的 Earth 类中
createFlyLineLabel(
startE: number, startN: number,
endE: number, endN: number,
radius: number,
text: any,
flyLineArcGroup: Group
) {
// 1) 先拿到中点
const midpoint = calculateMidpoint(startE, startN, endE, endN, radius);
// 2) 准备 HTML 片段,比如警告信息
const cardHTML = `
<div class="flyline-card">
<div style="font-weight: bold; font-size: 16px;">
${text.cardInfo.alarmType || '无'}
<span style="color: red; margin-left: 10px;">${text.cardInfo.hazardRating || '无'}</span>
</div>
<div>攻击时间:${text.cardInfo.attackTime || '无'}</div>
<div>攻击来源:${text.cardInfo.attackAddr || '无'}</div>
<div>被攻击IP:${text.cardInfo.attackDip || '无'}</div>
<div>攻击IP:${text.cardInfo.attackSip || '无'}</div>
</div>
`;
// 3) 生成 Sprite
createCardSprite(cardHTML).then((sprite) => {
// 4) 设置 sprite 的位置
sprite.position.set(midpoint.x, midpoint.y + 2, midpoint.z);
// 5) 把 sprite 添加到 flyLineArcGroup 或其他场景
flyLineArcGroup.add(sprite);
// 如果你只想在鼠标移到飞线上才显示:
sprite.visible = false; // 默认隐藏
// 下面可以把 sprite 存到飞线对象的 userData 里,或者自己维护
});
}
这样,当你在创建每条飞线时,只要调用 createFlyLineLabel(...)
,就能在"中间"自动生成一个相应的卡片。
4. 鼠标交互:让卡片只在悬停时显示
如果你希望卡片默认隐藏 ,并在鼠标悬停飞线时才显示,就需要做以下几点:
- 给飞线存下它对应的卡片 :比如在
arcline.userData['labelSprite'] = sprite;
- 使用 Raycaster 来检测鼠标是否悬停在飞线上:
- 在
mousemove
事件里记录鼠标坐标; - 在每帧
render
时,对飞线进行intersectObjects
; - 如果相交,设置
arcline.userData['labelSprite'].visible = true
,否则设为 false。
- 在
这个部分比较常见,代码示例略简要:
typescript
// 在 constructor 或 init 时,定义
this.raycaster = new Raycaster();
this.mouseVector = new Vector2();
// 监听 DOM 的 mousemove
this.options.dom.addEventListener('mousemove', (event) => {
const rect = this.options.dom.getBoundingClientRect();
this.mouseVector.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.mouseVector.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}, false);
// 在 render() 中检测相交
render() {
// 1) 先设置射线
this.raycaster.setFromCamera(this.mouseVector, this.options.camera);
// 2) 用 flyLineArcGroup.children 作为检测对象
const intersects = this.raycaster.intersectObjects(this.flyLineArcGroup.children, true);
// 3) 先让所有飞线卡片隐藏
this.flyLineArcGroup.children.forEach((child: any) => {
if (child.userData && child.userData.labelSprite) {
child.userData.labelSprite.visible = false;
}
});
// 4) 如果相交到某个 child,就显示其 labelSprite
if (intersects.length > 0) {
const first = intersects[0].object;
const arcRoot = this.findArcLineRoot(first);
if (arcRoot && arcRoot.userData && arcRoot.userData.labelSprite) {
arcRoot.userData.labelSprite.visible = true;
}
}
// ... 其他动画逻辑 ...
}
// 辅助函数:找飞线最外层(如果你的结构是 Group 嵌套的话)
findArcLineRoot(obj) {
while (obj.parent && obj.parent !== this.flyLineArcGroup) {
obj = obj.parent;
}
return obj;
}
这样当鼠标移动到飞线上,就会自动显示它对应的卡片。移动走后,再次隐藏。
5. 总结
- 数学公式 :利用
lon2xyz()
将经纬度转换到三维坐标,再结合简单的(start+end)/2
求出中点,最后归一化到球面半径,从而得到"球面中点"。 - 使用 Sprite 做卡片 :
- html2canvas 把 DOM 转成截图;
TextureLoader
+SpriteMaterial
+Sprite
;- 设置
sprite.position
。
- 可选的交互 :
- 可以一直显示 卡片,也可以悬停时显示;
- 如果要做更多复杂的交互,可以配合事件库或手动管理
Raycaster
。
上述就是在球面飞线中点生成卡片的思路与完整步骤。新手可以直接复制示例代码并替换自己的数据和文本,就能快速跑出一个带有三维地球飞线与卡片标注的场景。希望对你有所帮助,Happy Coding!