简介:一套覆盖三种词云可视化方案的代码资源包:基于 echarts 的自定义图片形状词云、纯 JavaScript 实现的 3D 球体滚动词云,以及利用 html5 canvas 实现的 3D 球体动态滚动效果。资源支持鼠标悬浮定向滚动,并可自由缩放词云画布大小,交互方式丰富。压缩包内共计 8 个文件,包括 3 个 JavaScript 脚本、2 个 HTML 演示页面、1 张形状参考 JPG 图片、1 个 CSS 样式文件和 1 个说明文本,总大小约 78KB,体积轻巧,便于下载和迁移。该资源已有 4295 人学习浏览,适合前端开发者和数据可视化爱好者用来学习词云生成与 3D 滚动交互。通过 demo 页面能直接查看三种效果,JS 版本不依赖复杂框架即可运行,ECharts 自定义形状方案附带 demo 代码,可帮助理解图片遮罩词云的生成原理,并能按需调整文字大小、滚动方向与交互逻辑,是快速掌握词云可视化技巧的实用参考。
1. 从平面到球体:为什么“指定形状滚动词云图”值得自己动手做
如果你只是想要一张能看的词云图,ECharts 官方 wordCloud 已经够用,但你一旦提出“把词云做成球形并且能滚动”这个需求,现成方案立刻见底。原因很简单:ECharts 的词云扩展基于 canvas 2D,文字布局算法是矩形边界框的变体——它能画圆形轮廓,但做不到真正的 3D 球面投影,更别提鼠标拖拽时的滚动惯性。市面上的“3D 词云”要么是静态贴图,要么是拿 Three.js 把文字当精灵贴到球面上,交互手感生硬,放大缩小全靠相机,没有原生 canvas 绘制的锐利度。这篇文章要拆的,是把“指定形状词云图、JS 版 3D 球形滚动、Canvas 缩放 3D 球形滚动”三件事串起来的最小可行方案:先用 ECharts 自定义形状跑通平面词云,再把文字作为粒子映射到球面上实现滚动,最后用纯 canvas 2D 上下文做缩放和投影。按这套路径做完,你能得到一份不依赖重型 3D 引擎、支持鼠标拖拽和滚轮缩放、且文字清晰度可调的滚动词云实现。这套东西适合的可视化工程师、前端切图老手,以及被产品经理“要一个能转的球”逼到墙角的人。
2. 自定义形状词云:ECharts 只负责画,形状得靠文字缠绕函数
2.1 为什么 ECharts 原生词云只有矩形和圆形,问题出在布局算法
ECharts 的词云扩展echarts-wordcloud默认只支持两种形状:矩形(矩形边界内随机放置)和圆形(通过shape: 'circle'触发)。你传image或者maskImage参数,本质上是把一张图片的 alpha 通道作为遮罩,但布局算法仍然在矩形画布内做碰撞检测,只不过把落在透明区域的词抛弃。这意味着两件事:第一,词的放置顺序和大小仍然受矩形边界影响,靠近图片边缘的大词经常被挤到角落;第二,性能开销很大,因为每次尝试放置一个词都要逐像素检查 alpha 值。
真正能实现“指定形状”的关键在于 ECharts wordCloud 的maskImage配合shape参数使用时的细节:shape的优先级高于maskImage,你设置了shape: 'circle'再传maskImage,形状会被强制改成圆形。这就是很多人在网上照着抄代码却始终得到圆形词云的原因。
我一般会这样绕过原生限制:不依赖maskImage的 alpha 检测,而是把形状本身转化成一个二维坐标集合,自己控制词的生成位置。这个方案的逻辑是把图片读入 canvas,用getImageData提取形状区域的坐标,然后在词云布局时按坐标集合决定词的中心点范围。ECharts 无法直接消费这个坐标集合,所以要走一个中间层——改写echarts-wordcloud内部prepareLayout函数,把原来矩形范围判断替换成自定义坐标判断。
2.2 把任意图片转成遮罩:canvas 灰度图与 alpha 通道提取
先把一张形状图(比如品牌 Logo、动物轮廓)转成词云可以用的遮罩。整个过程分三步:第一步离屏 canvas 绘制图片并缩放到目标尺寸(建议 512×512,太大逐像素判断会卡);第二步用getImageData取像素数据;第三步把 alpha 值大于 8 的像素坐标存入数组。这里的关键参数是 alpha 阈值,我一般取 8 而不是 0,因为 PNG 图片边缘常有半透明抗锯齿像素,阈值取 0 会导致边缘词重叠。
下面这段代码将图片转成有效坐标数组:
function imageToMask(imageSrc, size = 512) { return new Promise((resolve) => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); // 绘制时用白色填充背景,避免透明区域被误判 ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, size, size); ctx.drawImage(img, 0, 0, size, size); const imageData = ctx.getImageData(0, 0, size, size).data; const points = []; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const alpha = imageData[(y * size + x) * 4 + 3]; if (alpha > 8) { points.push([x, y]); } } } resolve({ points, size }); }; img.src = imageSrc; }); }这段代码只在加载完成后才执行遍历,用fillRect预先填充白色背景是为了避免透明 PNG 在绘制时产生意外的 alpha 混合。遍历时跳过 alpha 小于等于 8 的像素,既能过滤锯齿边缘,又不会丢失形状细节。得到的points数组就是后续布局时的候选坐标池。
2.3 改写布局函数:坐标池替换矩形边界的三个关键改动
ECharts wordCloud 的布局核心在wordcloud.js里的CloudLayout.prototype,其中getPos函数返回[x, y]候选位置,默认实现会在矩形或圆形区域里随机取点。我可以替换这个函数,让每个词从坐标池里取点。具体做法是先把points数组按字典序排序并归一化到词云画布尺寸(通常是 600×600),然后在getPos内部随机取一个点加上一个随机偏移量。
改动有三个地方:第一,shape参数直接忽略,不再交给原生分支;第二,getPos函数改成从坐标池取值;第三,碰撞检测时仍然用词的宽高做矩形判断,但坐标池本身保证了词中心落在形状内部。注意,词中心落在形状内部不代表整个词都在形状内,大词会溢出形状边界。所以我在坐标池中加入了边缘收缩逻辑——取点时如果词宽超过阈值,就偏向坐标池中心位置取点。
// 替换 echarts-wordcloud 内部布局函数的关键片段 const originalGetPos = CloudLayout.prototype.getPos; CloudLayout.prototype.getPos = function () { const shape = this.shape; // 自定义遮罩坐标池 if (this.maskPoints && this.maskPoints.length > 0) { // 大词偏向中心,小词随机分布 const weight = this.fontSize > 32 ? 0.3 : 0.8; const idx = Math.floor(Math.random() * this.maskPoints.length * weight); const point = this.maskPoints[idx]; // 加上微小随机偏移,避免所有词堆在同一个像素点 const offset = (Math.random() - 0.5) * this.fontSize * 0.25; return [point[0] + offset, point[1] + offset]; } return originalGetPos.call(this); };fontSize > 32时取坐标池前 30% 的点,这些点是排序后偏中心位置的点,能有效减少大词溢出形状轮廓的概率。偏移量取0.25 * fontSize是因为超过这个范围会明显破坏形状边缘的整齐度。注意maskPoints需要是实例属性而不是原型属性,否则多个 ECharts 实例会共享同一个点集,产生串扰。
2.4 自定义形状落地清单:从图片到 ECharts 配置的完整链路
整个平面自定义形状链路包括四步。第一步,准备一张黑白分明的 PNG,比如黑底白字的 Logo 或纯黑色动物剪影,背景必须干净,否则坐标池会混入噪声点。第二步,执行imageToMask得到maskPoints和尺寸,然后把这个数组改写成{ points, size }结构。第三步,在 ECharts 配置里把 wordCloud 系列的shape设为'自定义',width和height设为与 mask 尺寸一致,sizeRange控制在 12 到 48 之间,这个范围之外的词要么小到看不清形状,要么大到淹没整体。第四步,把maskPoints传入改写后的布局函数。
这里有一个参数值得专门提出来:gridSize默认值是 8,控制词云的最小间隙。做自定义形状时我建议调到 10 到 14 之间,因为坐标池本身已经有半像素偏移,栅格大小太小会导致碰撞检测过于频繁,布局耗时从 200ms 级骤增到 2s 级。改完这些后,你会看到词云严格贴合形状轮廓,但大词边缘有少量溢出——这是预期行为,后续做球形滚动时反而可以利用溢出的词制造景深。
3. JS 版 3D 球形滚动词云:Three.js 球面贴词的两种可行路径
3.1 为什么不用 CSS 3D transform,而选 Three.js 球体几何体
网上流传的“3D 球形词云”很多是用 CSS 的transform: rotateX() rotateY()把一个平面词云容器做成旋转效果,但这不是真正的球面投影——文字始终在一个平面上,旋转时会出现明显的透视变形而且边缘文字不会朝向球心。真正的 JS 3D 球形滚动需要用 WebGL 或 Canvas 3D 渲染文字精灵到球面坐标上。Three.js 里的做法是创建一个SphereGeometry,然后把每个词的 DOM 节点(或 canvas 纹理)作为 Sprite 精灵绑定到球面顶点上。
选择 Three.js 而不是原生 WebGL,核心原因是矩阵运算和深度排序不用自己造轮子。原生 WebGL 接文字渲染需要把每个词单独绘制到纹理再贴到四边形上,至少几百行代码;Three.js 的Sprite对象天然支持position和scale设置,配合OrbitControls就能获得滚轮缩放和鼠标拖拽旋转。
但是注意:Three.js 的 Sprite 是面向相机的(billboard),词云滚动时所有文字永远正对屏幕,这其实不符合“球形滚动”的视觉效果——真实 3D 球上的文字应该根据球面法线方向旋转。如果你能接受 billboard 模式,那是最省事的路径;如果要求文字随球面法线旋转,需要改用TextGeometry或每个词独立贴图到一个PlaneGeometry并手动设置 lookAt。后者性能开销大,一般图表场景不需要。
3.2 球面坐标映射:从平面词云到 3D 点集的数学桥
把第 2 章的平面词云结果映射到球面,数学上用的是经纬度到直角坐标的标准公式。设词的权重为weight,对应球面半径R = maxRadius * sqrt(weight / maxWeight),这是把权重映射到半径的常见做法,让高权重词分布在更外层,滚动时看起来更有层次。然后取经度theta = random * 2 * PI,纬度phi = acos(2 * random - 1),这是球面均匀采样的关键——直接random * PI会导致两极区域词密度过高。
function wordVec3(word, maxRadius, maxWeight) { const theta = Math.random() * Math.PI * 2; // acos 保证球面分布均匀,避免两极聚集 const phi = Math.acos(2 * Math.random() - 1); const radius = maxRadius * Math.sqrt(word.weight / maxWeight); return new THREE.Vector3( radius * Math.sin(phi) * Math.cos(theta), radius * Math.sin(phi) * Math.sin(theta), radius * Math.cos(phi) ); }acos(2 * random - 1)是球面均匀采样的标准写法,它把纬度按余弦分布而不是线性分布,这样单位球面上每个面积单元落入的词数量相等。sqrt(weight / maxWeight)把权重映射到半径时压缩了大词和小词的差距,避免最大词半径是最大词的两三倍导致球面出现空洞。映射完成后,每个词就是一个带坐标的 Sprite 对象,滚动时OrbitControls负责旋转相机,词云本身不动。
3.3 两种滚动交互实现对比:相机旋转与物体旋转
同样是“滚动”,实现姿势完全不同。方案 A 是三目相机不动,旋转词云物体本身。方案 B 是词云物体不动,用OrbitControls控制相机围绕球体旋转。我实际测试下来的体验差异明显:方案 A 适合球体本身是主体、相机固定角度的场景(比如大屏展示),拖拽时直接sphere.rotation.y += deltaX,每帧更新;方案 B 适合用户自由探索,但OrbitControls默认会限制俯仰角,需要设置controls.minPolarAngle = 0和controls.maxPolarAngle = Math.PI才能让用户看到南北极。
// 方案 B 的关键配置:关闭 OrbitControls 的默认视角限制 const controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.rotateSpeed = 0.5; controls.minDistance = maxRadius * 0.8; controls.maxDistance = maxRadius * 3; // 允许看到球体背面,而不是卡在赤道区域 controls.minPolarAngle = 0; controls.maxPolarAngle = Math.PI;dampingFactor取 0.08 的原因:小于 0.04 时惯性太强,拖拽松手后球体会继续转好几秒,让人头晕;大于 0.15 时拖拽极其跟手但缺少顺滑感。minDistance设为maxRadius * 0.8是为了防止相机穿入球体内部——一旦穿进去,词云的背面词汇会挡住视线,视觉上变成一团乱码。rotateSpeed0.5 是经验值,给用户操控感但不至于一碰就飞。
3.4 词云文字清晰度:Sprite 缩放与 canvas 纹理分辨率的关系
Three.js 的 Sprite 加载文字有两种路径:直接传 canvas 元素作为纹理,或先把文字画到离屏 canvas 再转Texture。直接传 canvas 的坑在于:canvas 自身的分辨率决定了纹理清晰度,但 Sprite 的scale会改变最终渲染大小。如果你的 canvas 是 100×100,Sprite 的 scale 设为 10,字会被拉伸到模糊。我踩过这个坑后,统一用 256×256 的离屏 canvas 绘制文字,字体大小占 canvas 高度的 60%,再通过scale.set(radius * 0.15, radius * 0.15, 1)控制显示大小。
字体渲染时要设置ctx.font = 'bold ' + fontSize + 'px "Microsoft YaHei", sans-serif',并且文字必须写在 canvas 中心——textAlign: 'center'和textBaseline: 'middle'缺一不可,否则在球面上旋转时文字位置会有明显偏移。纹理生成后记得执行texture.needsUpdate = true,不然第一次渲染时文字是空白,必须刷新事件,这是 Three.js 最常见的“黑匣子”问题——不报错,但不显示。
4. HTML Canvas 缩放 3D 球形滚动:不依赖 Three.js 的轻量实现
4.1 纯 Canvas 2D 的 3D 投影数学:正交投影与透视投影的选择
如果你不想引入 Three.js,纯 Canvas 2D 也能实现 3D 球形词云,只是核心变成你手写投影矩阵——本质上是把三维坐标映射到二维屏幕坐标。常见的投影有两种:正交投影和透视投影。正交投影没有近大远小,适合词云这种每个词权重不同、不追求真实视觉深度的场景;透视投影有近大远小,球体转动时边缘词会缩小,更像真实 3D。我倾向于透视投影,因为词云滚动最重要的视觉反馈就是“越靠近边缘越远”,正交投影没有这个深度暗示。
透视投影的公式核心是scale = perspective / (perspective + z),其中perspective是人眼到投影面的距离,一般取 800 到 1200。z是词相对相机平面的深度。球体旋转时,每个词的z值随旋转变化,scale也随帧更新。
function projectTo2D(vec3, camera, perspective = 1000) { // 旋转后的坐标减相机位置得到相对坐标 const dx = vec3.x - camera.x; const dy = vec3.y - camera.y; const dz = vec3.z - camera.z; // 透视收缩因子:z 越大(越远)缩放越小 const scale = perspective / (perspective + dz); return { x: canvasWidth / 2 + dx * scale, y: canvasHeight / 2 - dy * scale, scale: scale, }; }注意dy前面是减号,因为 canvas 的 y 轴向下,而 3D 坐标的 y 轴通常向上。scale值小于 1 表示词在屏幕后方,大于 1 表示在前方。实际绘制时,词越小越透明——透明度alpha = Math.max(0, Math.min(1, scale - 0.5)),这样背面的词不会突兀地显示完整文字。
4.2 缩放 3D 球形滚动:滚轮事件与球体半径的自适应调整
滚轮缩放对应的不是改变词的尺寸,而是改变球体半径——这是保持词相对大小不变、让“球”本身变大变小的关键。实现方式:监听wheel事件,计算deltaY,然后radius *= deltaY > 0 ? 1.05 : 0.95。但这里有个坑:缩放球体半径会影响所有词的投影坐标,因为投影公式里vec3已经包含半径信息。
我建议半径缩放范围限制在初始半径的 50% 到 200% 之间,并且缩放中心默认为画布中心,而不是鼠标位置。如果你想让鼠标位置成为缩放锚点(类似地图缩放),需要额外计算鼠标相对球心的偏移量并在投影时补偿,这个计算量翻倍但对用户体验提升明显。
canvas.addEventListener('wheel', (e) => { e.preventDefault(); const zoomFactor = e.deltaY > 0 ? 0.95 : 1.05; sphereRadius = Math.min( maxRadius * 2, Math.max(maxRadius * 0.5, sphereRadius * zoomFactor) ); // 半径变化后重新映射所有词的坐标 updateWordPositions(sphereRadius); }, { passive: false });passive: false很关键,必须显式设置,否则浏览器会把wheel事件当成滚动行为,导致页面也跟着滚。updateWordPositions内部遍历所有词,用新半径重新计算球面坐标。注意这里不要重新随机经纬度,否则每次缩放词的位置都会跳动——重新计算的只有radius对应的那部分乘法系数。
4.3 鼠标拖拽旋转:四元数或欧拉角,哪种更适合词云球
拖拽旋转是实现“滚动”的核心。我用的是欧拉角叠加,每次拖拽更新rotationX和rotationY,然后按 ZYX 顺序旋转每个词坐标。欧拉角的坑在于万向锁,如果你允许用户把球转到极点(y 轴旋转 90 度),旋转会变得混乱。实际项目中我发现用户很少把词云转到正极点,所以欧拉角够用且性能好。
四元数方案虽然能避免万向锁,但每次拖拽都要做quaternion.multiply和normalize,对于几百个词的词云来说微不足道,但代码复杂度更高,调试时不容易看出问题。我给出欧拉角的完整实现,因为整体思路更线性:
function rotatePoint(point, rotX, rotY) { const { x, y, z } = point; // 绕 Y 轴旋转:左右拖拽 const cosY = Math.cos(rotY), sinY = Math.sin(rotY); const x1 = cosY * x + sinY * z; const z1 = -sinY * x + cosY * z; // 绕 X 轴旋转:上下拖拽 const cosX = Math.cos(rotX), sinX = Math.sin(rotX); const y1 = cosX * y - sinX * z1; const z2 = sinX * y + cosX * z1; return { x: x1, y: y1, z: z2 }; }旋转顺序是先 Y 后 X,符合直觉——左右拖拽转水平方向,上下拖拽转垂直方向。当你同时拖拽斜角时,两次旋转叠加,视觉上依然平滑。Math.cos和Math.sin每次拖拽都要全量计算所有词,词量超过 500 个时可能出现卡顿,优化手段是把旋转矩阵计算一次,然后对每个词只做矩阵乘法,而不是每个词重新调用三角函数。
4.4 Canvas 3D 词云的渲染管线:排序、裁剪与重绘
纯 Canvas 2D 没有深度缓冲,必须手动处理两个问题:绘制顺序和遮挡关系。绘制顺序必须按z值从大到小排——离相机最远的词先画,最近的词最后画,这样近处的词会盖住远处的词。排序逻辑简单,对每个词的z值做降序排序,如果没有排序,词的前后关系会错乱,视觉上球体不再有立体感。
裁剪是另一个坑:投影后scale小于 0.3 的词应该直接跳过绘制,否则它们会以极小字体输出,产生闪烁噪点。我见过有人用全局透明度解决这个问题,但效果不理想——半透明的小字会叠加成乱码。裁剪阈值取 0.3 到 0.5 之间,具体看你的词量:词量大时取 0.5 减少绘制次数,词量少时取 0.3 保留更多词。
function drawFrame() { ctx.clearRect(0, 0, canvasWidth, canvasHeight); const projected = words.map(w => { const p = rotatePoint(w.pos, rotX, rotY); const screen = projectTo2D(p, perspective); return { ...screen, word: w }; }); // 深度排序:z 值小的(近的)后绘制 projected.sort((a, b) => b.pos.z - a.pos.z); projected.forEach(item => { if (item.scale < 0.35) return; ctx.globalAlpha = Math.max(0, Math.min(1, item.scale - 0.4)); const fontSize = item.word.fontSize * item.scale; ctx.font = `bold ${fontSize}px "Microsoft YaHei", sans-serif`; ctx.fillStyle = item.word.color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(item.word.text, item.x, item.y); }); requestAnimationFrame(drawFrame); }ctx.globalAlpha的取值逻辑scale - 0.4意味着scale等于 0.4 时透明度为 0,等于 1.0 时全清晰,这形成一个平滑的淡入淡出效果。requestAnimationFrame保证绘制与屏幕刷新率同步,不会掉帧。注意textAlign和textBaseline每次绘制都要重新设置,因为 canvas 的上下文状态是全局的,其他地方改过会影响这次绘制。
5. 避坑手册:词云 3D 化最容易翻车的五个细节
5.1 词云空白区域:maskImage和shape同时设置导致形状失效
现象是自定义形状词云输出空白,或者只画出矩形词云,完全没有形状轮廓。我排查时第一反应是图片没加载成功,后来发现是shape参数没删掉。ECharts wordCloud 的shape字段优先级高于maskImage,文档没写清楚,源码里判断顺序是先查shape再查maskImage。解决方法是彻底删掉shape字段,只保留maskImage;如果你直接传imageURL,记得在series配置里同时设maskImage为图片对象。
另一个隐藏原因是maskImage必须是 Image 对象而不是 URL,ECharts 内部有document.createElement('canvas')的逻辑,直接传 URL 不会触发 canvas 绘制。我习惯在外部先把图片new Image()加载完再传给配置项。
5.2 3D 球体背后文字穿透:深度排序失效的根因在透明度
Pure canvas 词云球体旋转时,背面的词会穿透正面出现在屏幕上,视觉上像词在球内部乱跳。这不是绘制顺序的问题,而是透明度累加的问题——背面词的globalAlpha不为 0 时会被看到。我测试时发现背面词的scale在 0.4 到 0.6 之间时仍然清晰,因为大词的字体基数大,乘以 0.5 仍然可读。
解决方法是把隐藏阈值从 0.35 提高到 0.5,并且对前景词的透明度做钳制:正面词的 alpha 最低 0.8,背面词一律低于 0.2。改完后的效果是背面词隐约可见但不干扰,球体有玻璃质感。如果你要完全剔除背面词,可以在旋转矩阵中判断z值正负,负值直接跳过绘制。
5.3 ECharts 实例复用导致内存泄漏:词云更新时旧布局未销毁
同一个页面里切换不同形状的词云,第二次渲染时会出现旧词残留。根因是 ECharts 实例的setOption不会清理 wordCloud 内部创建的 canvas 上下文,旧的布局数据还挂在原型链上。我的做法是每次切换形状前先chart.dispose()再echarts.init(),并且把之前手动修补的CloudLayout.prototype.getPos函数恢复原样——否则第二次运行时新旧逻辑混在一起,词的位置会异常。
更蹊跷的是dispose()之后,如果maskPoints数组引用还在全局变量里,那下次请求时由于数组被修改过,坐标池不再完整。我现在统一用函数工厂创建独立的遮罩坐标池,而不是模块级变量。
5.4 字体加载时机:词云布局时字体未就绪导致文字错位
词云首次加载时,字体文件还没下载完,布局引擎拿到的ctx.measureText返回值是回退字体的宽度,等真正字体加载完成后,文字宽度变化,但词的位置已经固定,结果就是大词之间出现叠字。这个问题在中文场景特别明显,中文是方框字,加载前回退字体宽度完全不同。
解决方法是在布局前用document.fonts.load强制加载目标字体并等待document.fonts.ready。
await document.fonts.load('bold 24px "Microsoft YaHei"'); await document.fonts.ready; // 字体就绪后再初始化 echarts 并 setOption如果不做这一步,你会发现同一个词云在不同网络环境下的词布局不同——因为字体加载快慢不同。document.fonts.ready是全局 Promise,在多个词云实例同时创建时,放一个await就能统一拦截。
5.5 Canvas 缩放后文字模糊:分辨率与 CSS 像素不成比例
很多人做纯 canvas 3D 词云时直接设置canvas.width = window.innerWidth,然后在 CSS 里用width: 100%拉伸。如果浏览器的设备像素比是 2(Retina 屏),canvas 实际渲染分辨率只有物理像素的一半,词云文字会明显发虚。
我通常在初始化时做分辨率适配:
const dpr = window.devicePixelRatio || 1; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; ctx.scale(dpr, dpr); canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px';注意ctx.scale(dpr, dpr)必须在任何绘制之前调用,并且后续投影计算里的canvasWidth用cssWidth而不是canvas.width,否则坐标会错位。这是 Canvas 最常见的“看着模糊但代码没报错”的坑,我的血泪经验就是永远先查 DPI。
6. 验证与优化:把词云球调到“能上线”的三个量化手段
6.1 布局质量验证:用覆盖率与重叠率判断形状贴合度
自定义形状词云做完后,怎么判断形状像不像?用主观感觉容易扯皮,我用两个量化指标:覆盖率和重叠率。覆盖率衡量形状区域内被词覆盖的像素比例,做法是渲染词云到 canvas,然后逐像素比较“词云 canvas”与“遮罩 canvas”,统计词云像素落在形状区域内的百分比。覆盖率应超过 70%,低于这个值说明大量词堆在了形状外或空白处。
重叠率衡量词与词之间碰撞检测是否正常,做法是遍历所有词对,检测矩形相交面积百分比。正常情况下重叠率应低于 5%,如果超过 10%,说明词间距参数太小或者坐标池里点太密。我在 2.3 节提出的offset = fontSize * 0.25就能把重叠率控制在这个范围,只需在改动参数后重跑这两项验证。
6.2 性能预算:拖拽旋转与滚轮缩放的帧率目标
交互流畅度的验证标准是帧率不低于 50fps。我在词量 300 时用requestAnimationFrame自测纯 canvas 方案,拖拽与旋转两个动作都能稳定在 55fps 以上;词量到 1000 时帧率掉到 35fps,优化方向是每帧不重新绘制全量词,而只绘制屏幕可见区域——即投影后scale在 0.3 到 1.5 之间的词。这个方法能把有效绘制量降到原来的一半,但需要维护一个“活跃词索引”,每帧更新一次。
性能排查时先看排序算法耗时——sort是每帧最高的 CPU 消耗点,然后是fillText的调用次数。fillText无法减少,但排序可以用“分桶排序”代替全量sort:按 z 值划分 20 个桶,每桶内部直接插入,省掉比较开销。
6.3 自动回归测试:用位移采样断言拖拽逻辑不退化
ECharts 改过布局函数、Three.js 调过相机参数之后,肉眼看着没问题,但下次改参数可能就翻车。我维护了一张简单的回归测试表——自动拖拽模拟:模拟鼠标从 (0,0) 拖到 (100,50),记录旋转前后指定词的屏幕坐标位移,断言位移方向是顺时针而不是逆时针。这类测试不需要引入测试框架,写一个纯 Node 脚本,把旋转和投影函数导出,用固定的输入验证输出。
// 回归断言示例:rotY 增加时,位于赤道的词应向右移动 const point = { x: 1, y: 0, z: 0 }; const rotated = rotatePoint(point, 0, Math.PI / 4); const movedRight = rotated.x > point.x; if (!movedRight) { throw new Error('旋转方向错误:词云向左滚动而非向右'); }这种断言能捕获两类问题:一是把sin和cos的符号写反,导致滚动方向与预期相反;二是把坐标系搞反,导致上下拖动变成左右旋转。每次调整旋转逻辑后跑一遍断言,就不会在联调时被产品经理反馈“词云方向不对”而抓瞎。
6.4 视觉验收:慢动作录屏与逐帧比对
视觉上的“球形流畅感”很难用单元测试度量,我最后常用的验收手段是慢动作录屏——用浏览器的开发者工具录制 20 秒交互过程(拖拽加缩放),然后逐帧播放,检查三类问题:第一,大词周围是否有毛刺(说明字体渲染抗锯齿参数不对);第二,拖拽停止后是否有回弹(说明dampingFactor与惯性系数冲突);第三,缩放时词与词之间是否出现瞬间重叠(说明排序更新滞后一帧)。逐帧比对能发现requestAnimationFrame回调里状态更新与绘制不同步的情况——通常是先绘制后更新状态导致的,把状态更新挪到绘制前即可。
6.5 参数表:三套方案核心参数速查
| 方案 | 核心参数 | 经验值 | 调整方向 |
|---|---|---|---|
| ECharts 自定义形状 | alpha 阈值 | 8 | 边缘毛刺多时调大到 16 |
| ECharts 自定义形状 | gridSize | 10-14 | 布局耗时过高时调大 |
| Three.js 球形 | rotateSpeed | 0.5 | 需要更灵敏时调大到 0.8 |
| Three.js 球形 | dampingFactor | 0.08 | 惯性太强时调小到 0.04 |
| Canvas 2D 球形 | 隐藏阈值 | 0.35-0.5 | 背面穿透时调大 |
| Canvas 2D 球形 | perspective | 1000 | 视觉扁平时调大到 1200 |
这套参数我现在做任何词云项目都会先按表格里的值初始化,再根据实际效果微调。参数调优的关键是每次只改一个变量,同时跑 6.1 的覆盖率验证和 6.2 的帧率验证,不要凭感觉同时改三个。词云 3D 化这件事,做到最后拼的不是炫技,而是把每个变量的边界摸清楚——这跟做其他前端可视化一样:黑匣子只是暂时的,自己验证过的才敢上线。希望帮到你。
本文还有配套的精品资源,点击获取