如何在 Three.js 地球飞线中间生成卡片

在可视化项目中,我们经常需要在球体表面绘制飞线,并在飞线的中间展示一些信息卡片(也可以称为 Label、弹窗等)。比如:攻击事件监控、地理位置说明、某些数据统计信息等。这篇文章就是一步步 讲解如何在飞线的中点生成一个卡片,并在需要时进行可视化展示。

1. 实现思路

  1. 先计算飞线的中点:在球面上,给定起点(经度/纬度)和终点(经度/纬度),通过数学公式将它们转换为三维坐标,再在三维空间里计算出"中点"。
  2. 把卡片做成 Sprite
    • 在 Three.js 中,常用 Sprite(精灵)来做类似标注、卡片的东西。因为 Sprite 永远面向摄像机,比较适合做平面贴图。
    • 也可以用 PlaneGeometry+Mesh,如果需要更多自定义操作。
  3. 生成卡片纹理
    • 我们可能想把一些文字或 HTML 元素做成"图片纹理",再贴到 Sprite 上。
    • 为了在 Three.js 中显示文字或复杂的 DOM 样式,这里可以借助 html2canvas 来把指定的 DOM 生成一张 png,再用 TextureLoader 把这张图片加载为贴图。
  4. 将卡片放置在飞线中点上
    • 将上一步得到的 Sprite,设置 sprite.position.set(...) 到刚才计算好的中点位置。
  5. 显示 / 隐藏逻辑
    • 如果你想在鼠标悬停在飞线时显示卡片,可以先默认 sprite.visible = false,在鼠标检测到与飞线相交时再显示出来。
    • 如果想一直显示,直接 sprite.visible = true 即可。
  6. 避免卡片被销毁
    • 有时我们会在一段时间后清除掉飞线和卡片,但如果你的业务想长期留着这条飞线,就不需要删除它,只要隐藏或设置 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,现在我们要做的就是:

  1. 计算中点 midpoint
  2. 生成卡片 Sprite。
  3. 设置位置 & 加到场景或飞线 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. 鼠标交互:让卡片只在悬停时显示

如果你希望卡片默认隐藏 ,并在鼠标悬停飞线时才显示,就需要做以下几点:

  1. 给飞线存下它对应的卡片 :比如在 arcline.userData['labelSprite'] = sprite;
  2. 使用 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. 总结

  1. 数学公式 :利用 lon2xyz() 将经纬度转换到三维坐标,再结合简单的 (start+end)/2 求出中点,最后归一化到球面半径,从而得到"球面中点"。
  2. 使用 Sprite 做卡片
    • html2canvas 把 DOM 转成截图;
    • TextureLoader + SpriteMaterial + Sprite
    • 设置 sprite.position
  3. 可选的交互
    • 可以一直显示 卡片,也可以悬停时显示
    • 如果要做更多复杂的交互,可以配合事件库或手动管理 Raycaster

上述就是在球面飞线中点生成卡片的思路与完整步骤。新手可以直接复制示例代码并替换自己的数据和文本,就能快速跑出一个带有三维地球飞线与卡片标注的场景。希望对你有所帮助,Happy Coding!

相关推荐
别致的影分身11 分钟前
Linux 线程池
java·开发语言·jvm
山山而川粤2 小时前
母婴用品系统|Java|SSM|JSP|
java·开发语言·后端·学习·mysql
迷失蒲公英3 小时前
XML与Go结构互转实现(序列化及反序列化)
xml·开发语言·golang
测试盐4 小时前
c++编译过程初识
开发语言·c++
高兴蛋炒饭4 小时前
RouYi-Vue框架,环境搭建以及使用
前端·javascript·vue.js
赖赖赖先生4 小时前
fastadmin 框架 生成qr code 二维码图片,PHP 7.4版本
开发语言·php
ᥬ 小月亮4 小时前
Vue中接入萤石等直播视频(更新中ing)
前端·javascript·vue.js
玉红7775 小时前
R语言的数据类型
开发语言·后端·golang
夜斗(dou)5 小时前
node.js文件压缩包解析,反馈解析进度,解析后的文件字节正常
开发语言·javascript·node.js
觅远5 小时前
python+PyMuPDF库:(一)创建pdf文件及内容读取和写入
开发语言·python·pdf