简介:面向前端开发与数据可视化学习者的词云图实现合集,聚焦三类常见需求:按自定义图片轮廓生成异形词云、用纯 JavaScript 构建可定向滚动的 3D 球状词云,以及基于 HTML5 Canvas 绘制并支持自由缩放的 3D 球形标签云。压缩包共 8 个文件、约 78KB,以 JavaScript 脚本、HTML 演示页面和 CSS 样式为主,辅以示例图片与说明文本,分别承担交互逻辑、页面展示、视觉样式和参考素材等用途,体量轻便,适合直接运行与二次改造。已有 4302 人学习下载,具备一定参考基础。读者可从中获取 ECharts 自定义形状词云的实现思路、鼠标悬浮位置驱动滚动方向的控制方式、Canvas 球体动态混动与画布缩放等关键做法,也可对照演示页面理解 3D 标签云的结构组织与参数调节,适用于大屏展示、标签聚合页或可视化课程练习中的功能验证与效果复用。
1. 三种词云摆在一起:选型比写代码更先要命
需求方一句话就能把人送走:“要那个能转的、还得是指定形状的球。”我手上这份资源正好把三种做法拆成三个独立 Demo —— echart 自定义形状版词云图、JS 版 3D 球形滚动、HTML Canvas 缩放 3D 球形滚动。三者不是同一个东西换皮:第一种是 2D 平面填充,靠一张掩码图约束文字边界;后两种是 3D 球面坐标加透视投影,区别只在渲染层拿 DOM 还是拿 Canvas 干。适合谁?手上有词表、被要求“形状得像 logo”或者“得转起来”的前端;也适合已经把球转起来了、但一到缩放和移动端就翻车、想看看到底哪一步算错了的人。下面按“能跑起来”的顺序拆。
2. 目录与技术栈:三条渲染路线凭什么拆成三个页面
三个 Demo 各自独立,但共用一份词表和一套归一化工具。这个结构不是为了好看,是因为三条路线的依赖完全不一样:ECharts 那条要引两个 JS 文件,DOM 那条只要一个 CSS,Canvas 那条反而最“裸”,连库都不需要。混在一个页面里改配置,改到第三天你自己都分不清哪个参数属于哪套。
2.1 目录结构:一份词表,三套渲染
wordcloud-kit/ ├── index.html // 入口导航,三个 Demo 各一个链接 ├── shared/ │ ├── words.json // 共用词表,统一 {name, value} 结构 │ └── util.js // 归一化、配色、节流 ├── echarts-shape/ │ ├── index.html │ ├── option.js // 词云 series 配置 │ └── mask.js // 掩码生成:Canvas 现画 -> PNG ├── js-sphere/ │ ├── index.html │ ├── sphere.js // 球面分布 + 旋转投影 + DOM 写入 │ └── sphere.css // 绝对定位、will-change、transform-origin └── canvas-zoom/ ├── index.html ├── cloud.js // Canvas 渲染 + 滚轮缩放 + 拖拽惯性 └── assets/ └── mask.png // 可选的静态掩码图shared/words.json是唯一的数据源,三个页面都用fetch拉它。这么做的直接好处是:你调字号、调旋转、调配色,改的是渲染层;你加词、删词、换权重,改的是同一份 JSON,不会出现“改了 A 页面 B 页面没变”的低级问题。util.js里只放两件事:把原始值归一化到统一区间、把 HSL 颜色按索引稳定生成(别用随机色,重排一次颜色全跳,肉眼很炸)。
echarts-shape/mask.js单独拆出来是有原因的。掩码生成这段逻辑会越写越长——一开始是圆形、后来变心形、再后来业务丢过来一个 SVG path。放在option.js里,配置和绘制混在一坨,改起来要人命。
2.2 三条渲染路线的技术栈与取舍
| 维度 | ECharts 自定义形状 | DOM 3D 球 | Canvas 3D 球 |
|---|---|---|---|
| 渲染层 | Canvas(扩展内部) | 每个词一个 span | 单个 canvas 2D |
| 形状控制 | 掩码图约束边界 | 只能球形 | 只能球形 |
| 文字清晰度 | 高(按设备像素比) | 高(浏览器渲染文字) | 需手动处理 DPR |
| 可承载词量 | 100~300 较好 | 60~150 开始掉帧 | 300~600 仍可 |
| 交互成本 | 低(事件现成) | 中(zIndex 排序) | 高(命中检测自己写) |
| 移动端表现 | 好 | 一般(重排多) | 好(但要touch-action) |
| 改造难度 | 低(改配置) | 中 | 高(改数学) |
这张表是我踩完之后总结的,不是查来的。一句话选型:要形状就走 ECharts,要转起来且词不多就走 DOM,词多还要缩放就上 Canvas。反过来的组合都会难受——比如用 ECharts 做 3D 球,你得自己在textStyle回调里算透视,每个词的字号都不一样,扩展内部还会做碰撞检测,性能直接崩;又比如用 DOM 做 600 个词的球,每帧 600 次样式写入,手机上基本是幻灯片。
2.3 统一数据格式与归一化
三个 Demo 吃同一份数据,格式必须是{ name, value }。但真实业务给的往往是{ keyword, count }或者干脆是二维数组,所以入口处加一层适配。
// shared/util.js // 把原始词表映射到统一区间,避免某个词权重 10 万把字号撑爆 export function normalize(list, min = 1, max = 100) { const values = list.map(i => Number(i.value) || 0); const lo = Math.min(...values); const hi = Math.max(...values); const span = hi - lo || 1; // 全部相等时防止除零 return list.map(i => ({ name: i.name, value: Math.round(min + ((Number(i.value) - lo) / span) * (max - min)) })); } // 按索引稳定生成颜色,重排时同一词颜色不变 export function colorAt(i, sat = 72, light = 56) { return `hsl(${(i * 47) % 360}, ${sat}%, ${light}%)`; }normalize里那个|| 1是关键。真实词表经常出现所有 value 相同的情况(比如后端返回的全是 1),不兜底就是除以 0,得到的全是NaN,最后渲染出来一片空白,你还得怀疑是不是库没加载。
colorAt用i * 47 % 360而不是i * 137 % 360,纯个人口味。47 和 360 互质,前 360 个颜色不会撞;137 是黄金角,撞得更晚但相邻色差太大,看着像打翻了调色盘。这类细节没人写进文档,但直接影响需求方第一眼评价。
3. ECharts 自定义形状词云:从 maskImage 到位图掩码的完整链路
ECharts 本体没有wordCloud这个 series 类型,它来自echarts-wordcloud扩展。这一点很多人第一次用会懵:直接series: [{ type: 'wordCloud' }]报 “series.type is not exists”,不是配置写错了,是脚本没引。
3.1 最小可跑配置:先让圆形出词
先把最朴素的跑通,再谈形状。
<div id="cloud" style="width: 640px; height: 640px;"></div> <script src="./vendor/echarts.min.js"></script> <script src="./vendor/echarts-wordcloud.min.js"></script>// echarts-shape/option.js const chart = echarts.init(document.getElementById('cloud')); const words = [ { name: '词云图', value: 100 }, { name: '自定义形状', value: 86 }, { name: '3D球形', value: 78 }, { name: '滚动', value: 64 }, { name: 'Canvas', value: 52 } ]; chart.setOption({ series: [{ type: 'wordCloud', shape: 'circle', // 无掩码时的内置形状 left: 'center', top: 'center', width: '92%', height: '92%', sizeRange: [14, 64], // 字号区间,按 value 线性映射 rotationRange: [-45, 45], // 旋转角度区间 rotationStep: 45, // 每 45 度取一档,避免任意角度导致排版碎片 gridSize: 8, // 碰撞网格,越小越密也越慢 drawOutOfBound: false, // 超出画布的词直接丢弃 shrinkToFit: true, // 整体缩放塞进容器 layoutAnimation: true, textStyle: { fontFamily: 'PingFang SC, Microsoft YaHei, sans-serif', fontWeight: 'bold', color: () => `hsl(${Math.floor(Math.random() * 360)}, 72%, 56%)` }, emphasis: { textStyle: { shadowBlur: 12, shadowColor: 'rgba(0,0,0,.28)' } }, data: words }] });跑通之后你会看到一堆词挤在圆里。sizeRange决定最大词和最小词的字号,gridSize决定“挤”的程度——它不是间距,是碰撞检测的网格粒度,值是 8 意味着布局器把画布切成 8×8 的小格来判断重叠。调到 4 会更紧但慢一倍,调到 16 会松且词被丢弃得更多。
rotationStep这个参数容易被忽略。设置rotationRange: [-90, 90]而不设 step,词会以任意小数角度旋转,排版器需要为每个角度算包围盒,词一多就明显卡。设成 45 之后,实际只有 -90、-45、0、45、90 五档,布局速度快很多。
3.2 自定义形状的两种做法:图片掩码与 Canvas 现画
maskImage是自定义形状的唯一入口,传一个 Image 对象或图片 URL。扩展内部会读这张图的像素 alpha 通道,alpha 为 0 的位置不放词,非 0 的位置才算有效区域。
第一种做法:静态图片。把设计给的 PNG 丢进assets/,注意必须是带透明通道的 PNG。JPEG 没有 alpha,整张图全不透明,掩码等于一个矩形,你会以为是配置没生效。
第二种做法,我更常用:用 Canvas 现画。
// echarts-shape/mask.js // 画一个“杯子”剪影当掩码,画完转成 Image,避免额外图片请求和跨域麻烦 export function createMaskImage(size = 480) { const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; const ctx = canvas.getContext('2d'); // 关键:底必须是全透明的,掩码只认 alpha,不看颜色 ctx.clearRect(0, 0, size, size); ctx.fillStyle = '#ffffff'; // 杯口 ctx.beginPath(); ctx.ellipse(size * 0.5, size * 0.3, size * 0.22, size * 0.07, 0, 0, Math.PI * 2); ctx.fill(); // 杯身 ctx.beginPath(); ctx.moveTo(size * 0.28, size * 0.3); ctx.lineTo(size * 0.34, size * 0.86); ctx.lineTo(size * 0.66, size * 0.86); ctx.lineTo(size * 0.72, size * 0.3); ctx.closePath(); ctx.fill(); const img = new Image(); img.src = canvas.toDataURL('image/png'); return img; }createMaskImage返回的 Image 还没加载完,直接丢给setOption是无效的。这是第一个高频坑,后面排查章节会展开。正确的接法:
const mask = createMaskImage(); mask.onload = () => { chart.setOption({ series: [{ maskImage: mask, shape: undefined, // 有 maskImage 时把内置 shape 置空,避免优先级打架 drawOutOfBound: false, shrinkToFit: true }] }, { replaceMerge: ['series'] }); };为什么用 Canvas 现画而不是让设计给图?两个原因。一是形状经常要微调,设计师改一版你换一次文件,来回三五轮就烦了;二是外部图片放在 CDN 上,一旦跨域又没有Access-Control-Allow-Origin,扩展内部读getImageData会直接抛安全错误,控制台报Tainted canvases may not be exported,新手看到这行基本就卡死了。用 Canvas 自己画,同源、可控、还能参数化——比如按数据动态改杯子的高度。
如果业务给的是 SVG path,路线是Path2D接进 canvas:
const path = new Path2D('M20,20 L180,20 L160,180 L40,180 Z'); ctx.fill(path);Path2D构造器吃 SVG path 字符串,现代浏览器都支持。别直接把 SVG 文件当maskImage传,扩展内部走的是 image 解码那条路,SVG 在部分环境里解不出来,形状就变成空的。绕一圈转成 PNG 最稳。
3.3 布局参数逐个说清:gridSize、sizeRange、drawOutOfBound
这几个参数互相咬合,单独调一个经常没效果,得一起看。
sizeRange是字号区间,[14, 64]意味着最大词 64px、最小词 14px,中间按 value 线性插值。但注意它只是“期望值”,shrinkToFit: true时整体会被等比缩小塞进形状,实际渲染字号会小于配置值。所以你调大sizeRange想突出大词,结果发现整体变小了,这不是 bug,是shrinkToFit在起作用。
drawOutOfBound: false表示超出掩码区域的词直接丢弃,不画。这是词云“漏词”的主因。词表 200 个只显示 60 个,八成是这条加gridSize太大。想让词尽量全显示,就把drawOutOfBound设成true,代价是会溢出形状边界,形状就不是那个形状了。
我的常用组合是:形状类需求固定drawOutOfBound: false+shrinkToFit: true+gridSize: 6,宁可少显示几个词也要形状完整;纯装饰不要求形状时用drawOutOfBound: true+gridSize: 10,出词多、速度快。
layoutAnimation: true打开后,数据更新时词会平滑移动。视觉上舒服,但每次重排都是全量计算,词数超过 150 时动画期间会明显掉帧。如果做定时滚动,这个开关要不要开得看场景。
3.4 让它滚起来:定时轮换与过渡不闪屏
“滚动”这个词在需求里至少有三种意思:词在画面里平移、词云整体旋转、词按时间轮换出现。ECharts 这套能做的是第三种——定时换数据。
// 每轮只扰动一部分词的权重,避免整图重新洗牌导致画面乱跳 let round = 0; const base = words.slice(); setInterval(() => { round++; const next = base.map((w, i) => { // 每 7 个词里扰动 1 个,其余保持原权重,位置基本稳定 if ((i + round) % 7 !== 0) return w; return { name: w.name, value: Math.max(6, Math.round(w.value * (0.6 + Math.random() * 0.9))) }; }); chart.setOption({ series: [{ data: next }] }); }, 4000);这里的核心思路是“少动”。如果每轮把所有 value 都重新随机,ECharts 会认为整个布局全变了,所有词重新找位置,画面像洗牌一样闪,很廉价。只扰动一部分,其余保持稳定,视觉上就是几个词在慢慢变大变小、挪位置。
间隔设 4000ms 是折中。低于 2000ms,重排还没结束下一轮就来了,尤其在低端安卓上会积压;高于 8000ms,观察者会觉得它“没在动”。如果只是想要“有东西在动”的观感,成本最低的方案其实是给整个容器加 CSS 动画做轻微上下浮动,一行@keyframes搞定,一点 JS 都不烧。
提示:
setOption更新 data 时,series是整体替换还是合并,取决于是否传了replaceMerge。定时轮换场景保持默认合并即可,词的颜色、字号配置不会丢;但换掩码形状时一定要replaceMerge: ['series'],否则旧系列的maskImage会残留。
4. JS 版 3D 球形滚动:球面坐标分布与透视投影
这一套是纯手写的,没有库。核心就三件事:把词均匀铺在球面上、每帧旋转、按深度做投影和排序。听起来玄学,实际算下来不到 80 行。
4.1 球面均匀分布:为什么不能用 random 生成坐标
最直觉的写法是生成随机经纬度:
// 反例:直接随机经纬度,两极必堆词 const phi = Math.random() * Math.PI; // 纬度 const theta = Math.random() * Math.PI * 2; // 经度 const x = R * Math.sin(phi) * Math.cos(theta); const y = R * Math.sin(phi) * Math.sin(theta); const z = R * Math.cos(phi);跑起来你会发现球的北极和南极全是字,挤成一坨,赤道反而稀。原因是球面面积元是dS = R² sinφ dφ dθ,当 φ 均匀采样时,靠近两极的sinφ接近 0,同样大小的 φ 区间对应的实际球面面积更小,所以密度更高。这是几何问题,不是随机种子的问题。
正确做法是让cosφ均匀,也就是对 φ 取反余弦:
// js-sphere/sphere.js // Fibonacci 球面分布:cos(phi) 均匀,等价于球面上面积均匀 function fibonacciSphere(n, radius) { const points = []; for (let i = 0; i < n; i++) { const phi = Math.acos(-1 + (2 * i + 1) / n); // 极角 [0, PI],cos 均匀 const theta = Math.sqrt(n * Math.PI) * phi; // 方位角,乘 sqrt(n*PI) 让螺旋匀 points.push({ x: radius * Math.cos(theta) * Math.sin(phi), y: radius * Math.sin(theta) * Math.sin(phi), z: radius * Math.cos(phi) }); } return points; }(2 * i + 1) / n把 i 映射到 (-1, 1) 区间,再Math.acos转成极角,这样cosφ是等距的,投影到球面上就是等面积。theta那个Math.sqrt(n * Math.PI)系数是螺旋臂的疏密控制,乘上去之后相邻点不会在某个经度上排成一条直线。
如果你的词数固定,这个函数算一次就够,把结果缓存在模块级变量里。每帧重算一百次三角函数,性能白扔。
4.2 每帧旋转与透视投影的数学
旋转只需要两个角度:绕 X 轴(上下)和绕 Y 轴(左右)。注意顺序,先 X 后 Y 和先 Y 后 X 结果不一样,视觉上手感也不同。我用先 X 后 Y,因为鼠标垂直拖动影响 X、水平拖动影响 Y,符合直觉。
// 把预计算的 sin/cos 传进来,避免在循环里重复算 function rotatePoint(p, sinX, cosX, sinY, cosY) { // 绕 X 轴 const y1 = p.y * cosX - p.z * sinX; const z1 = p.y * sinX + p.z * cosX; // 再绕 Y 轴 const x2 = p.x * cosY + z1 * sinY; const z2 = -p.x * sinY + z1 * cosY; return { x: x2, y: y1, z: z2 }; }透视投影用一个很土但够用的公式:s = camZ / (camZ + z)。z 负表示靠近镜头,s 大于 1(放大);z 正表示远离,s 小于 1(缩小)。camZ取半径的 3 倍是比较舒服的观感,取太小会像鱼眼,取太大接近正交投影,就没什么 3D 感了。
const RADIUS = 220; const CAM_Z = RADIUS * 3; // 660 function project(p, cx, cy) { const s = CAM_Z / (CAM_Z + p.z); // z ∈ [-R, R] -> s ∈ [0.75, 1.5] return { x: cx + p.x * s, y: cy + p.y * s, s }; }s 不会出现负数,因为CAM_Z + p.z的最小值是660 - 220 = 440,始终为正。如果哪天你把CAM_Z调到比半径还小,就会出现“词翻到背面后坐标反转”的鬼畜现象,这是血泪经验,别问。
4.3 DOM 版渲染:transform 与 zIndex 的排序
DOM 版每个词是一个span,绝对定位在容器里。初始化的重点是宽高只量一次。
const stage = document.getElementById('stage'); const points = fibonacciSphere(words.length, RADIUS); const items = words.map((w, i) => { const el = document.createElement('span'); el.className = 'wc-item'; el.textContent = w.name; el.style.fontSize = (12 + w.value * 0.32) + 'px'; // 字号跟权重挂钩 stage.appendChild(el); return { el, name: w.name, x: points[i].x, y: points[i].y, z: points[i].z, w: el.offsetWidth, // 关键:只量一次 h: el.offsetHeight }; });offsetWidth会强制浏览器做同步布局(layout thrashing),放进每帧循环里,200 个词就是每帧 400 次强制重排,帧率直接从 60 掉到十几。初始化时量一次存起来,后面直接读。
渲染函数:
function render() { const sinX = Math.sin(angleX), cosX = Math.cos(angleX); const sinY = Math.sin(angleY), cosY = Math.cos(angleY); const cx = stage.clientWidth / 2; const cy = stage.clientHeight / 2; // 第一遍:算位置,不做任何 DOM 写入 const frame = items.map(it => { const p = rotatePoint(it, sinX, cosX, sinY, cosY); const s = CAM_Z / (CAM_Z + p.z); return { it, p, s }; }); // 按 z 从大到小排,远的先画 frame.sort((a, b) => b.p.z - a.p.z); // 第二遍:统一写入,避免读写交错 for (let i = 0; i < frame.length; i++) { const { it, p, s } = frame[i]; const left = cx + p.x * s - (it.w * s) / 2; const top = cy + p.y * s - (it.h * s) / 2; it.el.style.transform = `translate3d(${left.toFixed(1)}px, ${top.toFixed(1)}px, 0) scale(${s.toFixed(3)})`; it.el.style.opacity = (0.22 + 0.78 * (s - 0.75) / 0.75).toFixed(3); it.el.style.zIndex = i; } }两个要点。一是left内部已经减掉了w * s / 2,所以 CSS 里不需要再加translate(-50%, -50%)—— 混用会导致缩放中心偏移,词绕着左上角转,看着像坏掉了。二是整个流程分成“先算完、再统一写”两遍,避免在循环里交替读clientWidth和写style,这是 DOM 动画的基本功。
will-change: transform加在.wc-item上,让浏览器提前把每个词提成合成层。加多了会有内存开销,150 个以内可以放心加。
4.4 鼠标与自动旋转的阻尼
不做交互的球是死的。最省事的做法是按鼠标位置给角速度,而不是直接给角度——直接给角度会让球“跳”。
let angleX = 0, angleY = 0; let speedX = 0.002, speedY = 0.003; // 当前角速度 let targetX = 0.002, targetY = 0.003; // 目标角速度 let mouseNX = 0, mouseNY = 0; // 归一化鼠标位置 [-1, 1] stage.addEventListener('pointermove', (e) => { const r = stage.getBoundingClientRect(); mouseNX = ((e.clientX - r.left) / r.width - 0.5) * 2; mouseNY = ((e.clientY - r.top) / r.height - 0.5) * 2; }); function loop() { // 鼠标越靠右,绕 Y 轴转得越快;上下同理,系数自己调 targetY = 0.003 + mouseNX * 0.016; targetX = 0.002 + mouseNY * 0.010; // 缓动逼近,避免鼠标一抖球就抽 speedX += (targetX - speedX) * 0.06; speedY += (targetY - speedY) * 0.06; angleX += speedX; angleY += speedY; render(); requestAnimationFrame(loop); }阻尼系数
本文还有配套的精品资源,点击获取