三种词云形态一套源码:ECharts自定义形状与3D球形滚动缩放实现
简介这是一份面向前端开发者与数据可视化爱好者的词云图实现合集聚焦于突破常规矩形排布的视觉限制解决词云形状单一、缺乏动态交互的问题。资源包含三种实现路径基于 ECharts 按自定义图片轮廓生成形状词云纯 JavaScript 打造的 3D 球体滚动词云并支持鼠标悬浮定向滚动以及借助 HTML5 Canvas 绘制的可自由缩放 3D 球形动态词云。压缩包共 8 个文件约 78KB以 3 个 js 脚本、2 个 html 页面、1 个 css 样式、1 张示例图片和 1 个说明文本组成分别承担核心逻辑、演示入口、样式控制与素材参考。目前已有 4299 人学习下载。读者可直接运行示例理解自定义形状裁剪、球面坐标映射与滚动交互的实现思路并在此基础上替换数据与图片快速应用到数据大屏、标签云展示等场景。1. 三种词云形态一套源码全打通前阵子接了个数据大屏的活甲方指着设计稿说中间这块词云要按中国地图形状排布旁边再来个能自己转的 3D 球鼠标滚轮还能缩放。我当时第一反应是找现成库翻了一圈发现要么只支持矩形排布要么 3D 效果是贴图假转真正能同时满足「自定义形状 3D 球形滚动 滚轮缩放」的轮子几乎没有。后来干脆自己拆了一套实现出来也就是这份源码包里的三个独立版本ECharts 自定义形状版、纯 JS 3D 球形滚动版、HTML Canvas 缩放 3D 球形版。三个版本共用同一套词条数据格式你可以按场景挑一个用也可以三个都跑起来对比效果。适合正在做大屏可视化、数据看板、品牌词频展示的开发者尤其是被「形状约束」和「3D 交互」卡住的那批人。2. 项目结构与技术选型三个版本怎么分工2.1 目录组织与文件职责这份源码没有用构建工具打包属于开箱即跑的静态资源结构常见做法是每个版本一个独立文件夹互不干扰。我拿到手后按功能重新梳理了一遍大致是这样wordcloud-shape-3d/ ├── echart-shape/ # ECharts 自定义形状版 │ ├── index.html # 页面入口挂载容器 │ ├── shape-wordcloud.js # 形状遮罩 词云布局核心 │ └── data.js # 词条数据text / weight ├── js-3d-ball/ # 纯 JS 3D 球形滚动版 │ ├── index.html │ ├── ball-scroll.js # 球面坐标计算 旋转动画 │ └── data.js ├── canvas-3d-zoom/ # Canvas 缩放 3D 球形版 │ ├── index.html │ ├── canvas-ball.js # 透视投影 滚轮缩放 │ └── data.js └── assets/ └── shape-mask.png # 形状遮罩图可选三个版本的数据文件格式统一都是{ text, weight }的数组这样你换数据时只改一处逻辑。ECharts 版依赖echarts.min.js和echarts-wordcloud插件另外两个版本零依赖纯原生 JS Canvas丢到浏览器就能跑。2.2 三种实现路线的取舍选型上我踩过坑这里直接说结论。ECharts 自定义形状版适合「形状固定、词条数量中等100300 个、需要快速上线」的场景它靠maskImage把词云限制在指定形状内布局是 2D 的性能稳。纯 JS 3D 球形版适合「强调空间感和旋转交互」的场景词条均匀分布在球面上靠requestAnimationFrame驱动旋转视觉冲击强但词条多了会掉帧。Canvas 缩放版是在球形基础上加了透视投影和滚轮缩放交互最完整代码也最复杂适合做核心展示位。提示如果你的大屏同时要形状约束和 3D 旋转别硬塞进一个版本用两个容器并排展示反而更稳这也是我最后交付的方案。3. ECharts 自定义形状版遮罩布局与词条渲染3.1 形状遮罩的实现逻辑这个版本的核心思路是先用一张形状图比如地图轮廓、LOGO 剪影作为遮罩ECharts 词云插件在布局每个词时会判断该词的位置是否落在遮罩的有效区域内落在外面就重新找位置。关键配置在series里// echart-shape/shape-wordcloud.js const chart echarts.init(document.getElementById(cloud)); const option { series: [{ type: wordCloud, shape: circle, // 内置形状自定义时用 maskImage 覆盖 maskImage: maskImg, // 形状遮罩图决定词的分布边界 left: center, top: center, width: 90%, height: 90%, sizeRange: [12, 60], // 字号范围映射 weight rotationRange: [0, 0], // 形状版一般不做旋转避免破坏轮廓 rotationStep: 0, gridSize: 8, // 词与词的间距越小越密 drawOutOfBound: false, // 超出遮罩的词直接丢弃 textStyle: { fontFamily: sans-serif, fontWeight: bold, color: () { // 按权重给渐变色权重越高越亮 return hsl(${Math.random() * 360}, 70%, 60%); } }, data: wordData // [{ name, value }] 格式 }] }; chart.setOption(option); window.addEventListener(resize, () chart.resize());逻辑说明maskImage接收一个 Image 对象插件会读取它的像素 alpha 通道来判断边界。drawOutOfBound: false是形状版的关键设为 true 的话词会溢出形状轮廓就废了。gridSize控制密度形状复杂时调大到 1012否则词挤在一起看不清。sizeRange的上下限对应数据里 weight 的最大最小值映射是线性的。参数上最容易翻车的是maskImage的加载时机。图片没加载完就setOption遮罩是空的词会铺满整个画布。我一般这么处理const maskImg new Image(); maskImg.src ./assets/shape-mask.png; maskImg.onload () { chart.setOption(option); // 确保图片加载完成后再渲染 };3.2 数据格式与权重映射数据这块三个版本通用格式如下// data.js const wordData [ { name: 数据可视化, value: 980 }, { name: 词云图, value: 860 }, { name: 自定义形状, value: 720 }, { name: 3D球形, value: 650 }, { name: 滚动交互, value: 540 } // ... 更多词条 ];value决定字号ECharts 内部会做归一化。如果你的数据里最大最小差距太大比如 10000 和 10小词会小到看不见常见做法是先做一次对数平滑Math.log(value 1)再传给value。这个细节原文没提但实际项目里几乎必做否则形状边缘的小词全糊成一团。4. 纯 JS 3D 球形滚动版球面坐标与旋转动画4.1 球面均匀分布算法3D 球形词云的核心是让词条均匀分布在球面上不能全挤在赤道。常见做法是斐波那契球面分布用黄金角递推避免极点堆积// js-3d-ball/ball-scroll.js function fibonacciSphere(n) { const points []; const goldenAngle Math.PI * (3 - Math.sqrt(5)); // 黄金角约 2.399 for (let i 0; i n; i) { const y 1 - (i / (n - 1)) * 2; // y 从 1 到 -1 const radius Math.sqrt(1 - y * y); // 当前纬度的圆半径 const theta goldenAngle * i; // 经度递推 points.push({ x: Math.cos(theta) * radius, y: y, z: Math.sin(theta) * radius }); } return points; }逻辑说明y线性递减保证纬度均匀radius是勾股定理算出的该纬度圆半径theta用黄金角递推让经度不重复。这样 n 个点会均匀铺在单位球面上不会出现两极扎堆。参数上n就是词条数量超过 500 个时建议抽稀否则 DOM 节点太多浏览器扛不住。4.2 旋转与投影渲染拿到球面坐标后每帧根据旋转角度更新每个词的屏幕位置再按 z 值调整透明度和字号制造景深let angleX 0, angleY 0; function render() { angleY 0.005; // 每帧绕 Y 轴转一点 const cosY Math.cos(angleY), sinY Math.sin(angleY); const cosX Math.cos(angleX), sinX Math.sin(angleX); words.forEach((word, i) { const p spherePoints[i]; // 绕 Y 轴旋转 let x p.x * cosY - p.z * sinY; let z p.x * sinY p.z * cosY; // 绕 X 轴旋转 let y p.y * cosX - z * sinX; z p.y * sinX z * cosX; // 透视投影z 越大越远缩放越小 const scale 300 / (300 z * 200); const screenX centerX x * radius * scale; const screenY centerY y * radius * scale; word.el.style.transform translate(${screenX}px, ${screenY}px) scale(${scale}); word.el.style.opacity scale; // 远处淡出 word.el.style.zIndex Math.floor(scale * 100); }); requestAnimationFrame(render); }逻辑说明angleY每帧自增实现自动旋转scale用透视公式300 / (300 z * 200)计算z 为正远时 scale 小于 1词变小变淡。zIndex保证近处的词盖住远处的。参数上0.005是旋转速度调大到 0.02 转得快但容易晕调小到 0.001 几乎静止。radius是球在屏幕上的基准半径一般取容器宽度的 0.35 倍。注意这个版本每个词是一个绝对定位的 DOM 节点词条超过 300 个时transform重排会明显卡顿。我一般超过这个量就换 Canvas 版或者把不重要的词降频渲染。5. Canvas 缩放 3D 球形版透视投影与滚轮交互5.1 Canvas 绘制与透视投影Canvas 版把 DOM 换成了画布绘制性能上限高很多代价是所有交互要自己算。核心是把球面点投影到 2D 画布并按 z 排序后绘制// canvas-3d-zoom/canvas-ball.js function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); const projected []; words.forEach((word, i) { const p rotate(spherePoints[i], angleX, angleY); const scale focalLength / (focalLength p.z * radius); projected.push({ x: centerX p.x * radius * scale, y: centerY p.y * radius * scale, scale, word }); }); // 按 scale 升序绘制远的先画近的覆盖 projected.sort((a, b) a.scale - b.scale); projected.forEach(item { ctx.font ${item.word.value * item.scale * 0.1}px sans-serif; ctx.fillStyle rgba(80, 180, 255, ${item.scale}); ctx.fillText(item.word.name, item.x, item.y); }); requestAnimationFrame(draw); }逻辑说明focalLength是焦距控制透视强度值越小透视越夸张近大远小越明显一般取 600800。sort按 scale 升序保证绘制顺序正确这是 Canvas 版必须手动做的DOM 版靠 zIndex 自动处理。字号用value * scale * 0.1动态算权重高的词在近处会明显更大。5.2 滚轮缩放与拖拽旋转交互是这个版本的卖点滚轮改radius拖拽改angleX / angleYlet radius 200; const MIN_RADIUS 80, MAX_RADIUS 500; canvas.addEventListener(wheel, (e) { e.preventDefault(); // 向下滚 deltaY 为正缩小向上滚放大 radius e.deltaY 0 ? -20 : 20; radius Math.max(MIN_RADIUS, Math.min(MAX_RADIUS, radius)); }, { passive: false }); let isDragging false, lastX 0, lastY 0; canvas.addEventListener(mousedown, (e) { isDragging true; lastX e.clientX; lastY e.clientY; }); canvas.addEventListener(mousemove, (e) { if (!isDragging) return; angleY (e.clientX - lastX) * 0.01; angleX (e.clientY - lastY) * 0.01; lastX e.clientX; lastY e.clientY; }); canvas.addEventListener(mouseup, () isDragging false);逻辑说明wheel里必须preventDefault并声明passive: false否则页面会跟着滚。radius做上下限钳制防止缩到看不见或撑爆画布。拖拽的0.01是灵敏度系数调大到 0.02 转得快调小到 0.005 更精细。angleX建议也做钳制限制在-Math.PI/2到Math.PI/2否则球会翻过头导致文字倒置。6. 避坑与常见问题排查6.1 形状遮罩不生效词铺满画布现象ECharts 版设置了maskImage但词还是铺满整个容器形状轮廓完全没体现。原因九成是图片没加载完就调用了setOption插件读到的是空遮罩。解决把setOption放进img.onload回调里或者用Promise包一层确保图片就绪。另外确认遮罩图是带透明通道的 PNGJPG 没有 alpha 通道插件识别不了边界。6.2 3D 球旋转时文字倒置现象拖拽旋转到某个角度后部分文字上下颠倒看着很别扭。原因是angleX没有做范围限制球绕 X 轴翻转超过 90 度后背面的文字自然就倒了。解决在mousemove里对angleX做钳制angleX Math.max(-Math.PI/2, Math.min(Math.PI/2, angleX))限制在正负 90 度内。如果非要全角度旋转就得在绘制时判断文字朝向翻转时额外做一次ctx.rotate(Math.PI)代码量翻倍一般大屏场景没必要。6.3 词条多了掉帧严重现象词条超过 300 个后旋转动画明显卡顿风扇狂转。DOM 版的原因是每个词一个节点每帧改transform触发大量重排Canvas 版的原因通常是每帧clearRect全画布重绘加排序。解决DOM 版抽稀到 200 个以内或者改用 Canvas 版Canvas 版把sort从每帧改成每 3 帧一次视觉上几乎看不出差别性能能提三成。另外requestAnimationFrame里别做console.log这个坑我踩过日志本身就能拖慢一半帧率。6.4 滚轮缩放导致页面跟着滚动现象在 Canvas 上滚轮缩放时整个页面也跟着上下滚体验很差。原因是wheel事件默认行为没被阻止。解决监听时加{ passive: false }并在回调里e.preventDefault()。注意passive必须显式设为 false现代浏览器对wheel默认是 passive 的不设的话preventDefault会被忽略并报警告。6.5 数据权重差距过大导致小词消失现象数据里最大权重 10000最小 10渲染出来小词小到看不见形状边缘全是空白。原因是字号线性映射差距被放大。解决传数据前做对数平滑value: Math.log(rawValue 1) * 100把差距压缩到合理区间。这个处理放在data.js里做一次就行三个版本通用。7. 二次开发换形状、接数据、调性能拿到这套源码后最常见的改造就三件事换形状、接真实数据、调性能。换形状最直接ECharts 版把assets/shape-mask.png替换成你的轮廓图就行注意用黑白对比明显的图透明区域就是词的分布区。如果是动态形状比如按用户上传的图生成可以在onload里用canvas读像素重新生成遮罩我一般会加一层缓存同一张图不重复处理。接真实数据时三个版本的data.js格式统一你只需要把接口返回的数据映射成{ name, value }。我习惯在数据层加一个适配函数function adaptData(rawList) { const max Math.max(...rawList.map(d d.count)); return rawList.map(d ({ name: d.keyword, value: Math.log(d.count 1) / Math.log(max 1) * 100 // 归一化到 0-100 })); }这样无论后端给的是点击量、搜索量还是词频都能压到统一量纲换数据源不用改渲染逻辑。性能调优上DOM 版优先减词条数Canvas 版优先降排序频率ECharts 版优先调大gridSize减少布局计算。三个版本的旋转速度、缩放范围这些参数我都抽成了顶部的常量对象改一处全局生效别散落在代码各处不然调起来是场灾难。最后说个血泪经验这套代码我最初交付时没做移动端适配结果甲方在平板上演示滚轮缩放变成了双指手势完全没反应。后来补了touchstart / touchmove的手势映射把双指距离变化映射到radius上才过关。从那以后我每次交付 3D 交互组件都强制在触屏设备上走一遍手势验证不管需求方有没有提移动端。希望帮到你。本文还有配套的精品资源点击获取