简介这是一套面向前端可视化学习者的滚动词云示例合集围绕自定义形状词云、3D球形文字云与画布缩放交互展开适合希望快速掌握词云动效实现思路的初中级开发者参考。压缩包共8个文件约78KB以3个JavaScript脚本、2个HTML页面、1个CSS样式文件为主另附图片与说明文本分别承担动效逻辑、页面演示、样式布局和素材辅助等用途。已有4302人学习下载具备较好的参考热度。学习者可从中获得echarts按图片轮廓生成词云、纯JS实现球体滚动与鼠标悬浮定向滚动、HTML5 Canvas绘制可缩放3D文字球等具体实现便于对照拆解动画参数、交互响应和画布尺寸调整并快速复用到标签云、数据展示或可视化大屏等场景对理解词云动效的渲染流程、交互控制与代码组织方式也有帮助。1. 三种词云一套源码从“贴字”到“滚球”的三个阶段做数据大屏的同行大概都遇到过这种需求需求方发来一张 Logo 或一张地图截图要把关键词按这个形状排进去还要求球体会转。指定形状词云图这几年在数据大屏和活动落地页里出现频率很高3D 球形滚动几乎是它的常见变体——球体自转、词条前后错落、近大远小。这份源码把三条技术路线放在一起ECharts 自定义形状版负责“形状准、配置快”纯 JS 版负责“球体真、旋转顺”Canvas 缩放版负责“能推拉镜头、字不糊”。适合手上有词库、要在大屏或 H5 上出效果、又不想从零推导排布算法的人。下面按先看清结构、再跑起来、最后动手改的顺序展开。2. 工程结构与三版选型三份页面各管一段场景拿到一份前端 Demo 包我习惯先确认三件事入口在哪、哪些文件属于“一次性可替换”的配置、哪些属于“不能乱动”的算法内核。这三点分不清后面改一个字号都可能踩坑。2.1 目录结构与文件职责这份包是按“页面 内核 数据”三层组织的三个 Demo 互不干扰但共用同一个数据源和部分布局函数适合单独使用或组合拆分。wordcloud-shape/ ├── index.html # 导航页三个 Demo 的入口 ├── pages/ │ ├── echarts-shape.html # ECharts 自定义形状版 │ ├── sphere-js.html # 纯 JS 3D 球形滚动版 │ └── sphere-canvas.html # Canvas 缩放 3D 球形版 ├── js/ │ ├── data.js # 统一词库数据 │ ├── wordcloud-config.js # ECharts 配置与遮罩生成 │ ├── sphere-layout.js # 球面撒点 / 旋转 / 投影计算 │ ├── sphere-render.js # Canvas 绘制与动画循环 │ └── zoom-control.js # 滚轮 / 双指缩放控制 ├── css/ │ └── style.css # 容器尺寸、背景、resize 相关样式 ├── assets/ │ ├── mask-star.png # 星形遮罩 │ └── mask-map.png # 地图轮廓遮罩 └── lib/ ├── echarts.min.js └── echarts-wordcloud.min.js可以看到sphere-layout.js只管数学给一组词返回带x/y/z的球面坐标再给旋转角和缩放系数返回屏幕坐标。它不碰 DOM、不碰 Canvas所以在sphere-js.html和sphere-canvas.html里都能直接复用。sphere-render.js才是真正操作ctx的地方。这种拆分的好处是后面想换渲染引擎比如换到 WebGL时只需要重写渲染层。data.js是最该被替换的文件结构保持得极简// js/data.js —— 三套实现共用替换这一处即可换掉全部内容 // name: 展示文本越短越好看value: 权重决定字号与前后层级 window.WORD_DATA [ { name: Canvas, value: 128 }, { name: ECharts, value: 110 }, { name: 可视化, value: 96 }, { name: 3D 球形, value: 88 }, { name: 自定义形状, value: 76 }, { name: 粒子, value: 52 } // ... 建议 60 ~ 120 条太少球面会显得空 ];value不是随便写的装饰字段。在 ECharts 版里它直接映射到sizeRange区间内的字号在 3D 版里我一般用它换算成字号和透明度让“大词”在视觉上更靠前。数据量方面2D 词云 60 到 150 条比较合适3D 球面建议 80 到 200 条低于 60 条球体看起来稀稀拉拉高于 300 条在中低端安卓机上会明显掉帧。2.2 三套实现的边界什么时候用哪一版同一份源码给了三套方案不是为了凑数而是三者解决的问题完全不同。选错了不会报错但会一直在不该纠结的地方纠结。实现依赖形状自由度交互能力典型场景ECharts 自定义形状版echarts 词云扩展高任意 PNG / 路径轮廓依赖 ECharts 事件体系tooltip 开箱即用大屏首页、活动页要求形状贴合 Logo 或地图纯 JS 3D 球形滚动版无只支持球体需自己写 hover 与点击拾取视觉主视觉区球体匀速自转Canvas 缩放 3D 球形版无球体可后期扩展点云滚轮 / 双指缩放可推拉镜头需要“能拉近看清某个词”的交互演示ECharts 版的优势在于省事tooltip、emphasis高亮、点击事件都是现成的你在配置里填一个maskImage就能得到“指定形状”。代价是布局算法是黑盒词与词的间隙、超出边界的裁切规则不完全可控放大到 4K 屏或者容器很窄时容易出现“大词被挤掉”的现象。纯 JS 版和 Canvas 版是同一套数学、两套渲染组织方式。前者用 DOM 元素span或绝对定位的div承载每个词靠transform: translate3d()做位移好处是字体渲染走浏览器原生链路中文边缘不发虚坏处是词一多浏览器的重排成本会上去200 个元素以上在滚轮缩放时能感觉到卡。后者把所有词画在一张 canvas 上性能上限高得多也更容易做“近大远小”的透明度和字号渐变代价是文字抗锯齿、字体加载和事件拾取都要自己处理。提示如果你的场景只是“把词摆成 Logo 形状”别上 3D 版ECharts 版十分钟能出结果如果要求球体持续转动并且要推拉镜头直接看 Canvas 版纯 JS 版当参考实现读。2.3 把项目跑起来起静态服务与确认数据入口三个页面都是纯静态的但不要双击用file://打开。词云扩展在读取遮罩图时会走 canvas 的像素接口file://下 canvas 被标记为跨域污染getImageData直接抛安全错误页面看起来就是“词全堆在左上角”或者干脆空白。这是新手最常见的一类失败原因。# 在项目根目录起一个本地静态服务任选其一 python3 -m http.server 8080 # 或者 npx serve .然后访问http://localhost:8080/index.html。如果你只关心某一个效果直接打开对应页面即可pages/echarts-shape.html、pages/sphere-js.html、pages/sphere-canvas.html。确认跑通的标准有三个词条数量与data.js中一致词云区域不超出容器边框调整浏览器窗口大小时画面跟着重排而不是被拉伸变形。第三条经常被忽略——很多 Demo 在固定尺寸下好看一放到自适应布局的大屏里就露馅原因都写在第 5 章的排查里。替换内容只需要动js/data.js里的数组。注意词面长度控制在 8 个字符以内中文 4 到 6 字最佳超长词在球面布局里会互相压盖因为 3D 版不做碰撞检测它靠的是“点足够均匀 字号随深度衰减”来避免视觉上的重叠。3. ECharts 自定义形状版maskImage、排布参数与遮罩生成ECharts 本身不带词云需要echarts-wordcloud这个扩展系列类型。它是用 canvas 逐字试摆的方式排布的把每个词当成一个矩形实际是带旋转的包围盒从中心向外螺旋试探找到第一个不与已放置词重叠、且落在遮罩内的位置就落子。理解这个机制才知道参数该往哪个方向调。3.1 一份能跑的最小配置先看配置主体这是整个 2D 形状版的全部逻辑没有别的隐藏代码。// js/wordcloud-config.js const chart echarts.init(document.getElementById(cloud)); const option { tooltip: { show: true, formatter: p ${p.name}: ${p.value} }, series: [{ type: wordCloud, // shape 是内置轮廓maskImage 优先级更高两者只留一个即可 shape: circle, maskImage: null, // 稍后由 createMask 异步填入 keepAspect: true, // 保持遮罩图原始宽高比避免 Logo 被拉扁 left: center, top: center, width: 92%, height: 92%, sizeRange: [14, 76], // 最小 / 最大字号直接吃 value 的映射 rotationRange: [-45, 45], // 旋转角度范围0 表示全部水平 rotationStep: 45, // 每次尝试的旋转步进45 意味着 3 种角度 gridSize: 8, // 碰撞检测栅格越小越密、越慢 drawOutOfBound: false, // 超出遮罩的词直接丢弃 shrinkToFit: true, // 放不下时尝试缩小字号而不是直接丢 layoutAnimation: true, // 首次排布的动画大屏上建议留着 textStyle: { fontFamily: PingFang SC, Microsoft YaHei, sans-serif, fontWeight: 600, // 按索引取色比纯随机稳定避免刷新一次配色全变 color: params PALETTE[params.dataIndex % PALETTE.length] }, emphasis: { textStyle: { textShadowBlur: 10, textShadowColor: rgba(0,0,0,.45) } }, data: window.WORD_DATA }] }; // 遮罩是异步资源必须等它 ready 再 setOption否则首帧按 shape 排布 createMask(./assets/mask-star.png).then(mask { option.series[0].maskImage mask; chart.setOption(option); }); window.addEventListener(resize, () chart.resize());几个参数值得单独说。sizeRange是词云观感的第一决定因素[14, 76]意味着权重最高的词占 76px、最低的 14px如果词条数量多而容器小这个上限要往下压否则大词会把整片区域占满小词无处可放。gridSize是性能与密度的权衡旋钮设成 4 时排布精度高、缝隙小但排布耗时大约是 8 的两到三倍设成 16 会明显看到词与词之间留白变大形状边缘变得粗糙。我一般从 8 开始容器宽度超过 1200px 时降到 6。rotationStep: 45配合rotationRange: [-45, 45]实际候选角度是 -45、0、45 三种。角度越多形状边缘贴合得越好但排布失败的次数也越多因为旋转后的包围盒更大。这也是“为什么我的词少了一半”的常见来源第 5 章会细说。shrinkToFit和drawOutOfBound是一对容易搞混的参数前者控制“放不下时缩字号再试”后者控制“缩了还放不下就丢弃还是画到边界外”。形状不规则比如星形、地图时把drawOutOfBound设成true会让词溢出到轮廓外面视觉上立刻散架所以保持false配合shrinkToFit: true更稳。3.2 maskImage 从哪来PNG、SVG 路径与文字轮廓先把遮罩的判定规则说清楚扩展会先把maskImage画到一张离屏 canvas 上然后按像素判断某个位置是否属于“可放置区”。所以对图片的要求是图形实心、背景透明而不是“白底黑图”。很多人拿一张白底 Logo 直接塞进去结果词铺满整个矩形就是这个原因。准备遮罩图有三条路按成本从低到高排第一条直接用设计给的透明 PNG。在 PS 或 Figma 里导出时勾选透明背景图形本身什么颜色都行因为只取透明度通道。第二条没有现成图只有一段 SVG 路径。这种情况我一般用Path2D在离屏 canvas 上描一遍再转成Image完全在浏览器里完成不需要额外资源文件// 把任意 SVG path 转成词云可用的遮罩图 function createMaskFromPath(d, size 512, scale 0.9) { const canvas document.createElement(canvas); canvas.width canvas.height size; const ctx canvas.getContext(2d); ctx.translate(size / 2, size / 2); ctx.scale(scale, scale); // 留一圈内边距边缘的词不容易被切 ctx.fillStyle #fff; // canvas 默认透明底白色填充即可 ctx.fill(new Path2D(d)); // d 为 M...Z 形式的路径字符串 const img new Image(); img.src canvas.toDataURL(image/png); // 同源不会有跨域污染 return img; }scale那个参数是血泪经验路径的包围盒往往顶到画布边缘直接按 1:1 填满时形状最外延的词有一半会被判定越界而丢弃看起来就像“形状缺了个角”。留 5% 到 10% 的内缩量观感立刻正常。toDataURL拿到的是同源 dataURL不会触发 canvas 污染这点比直接引用远程图片省事得多。第三条形状是文字本身比如把词云排成一个汉字或英文单词的轮廓。思路一样把fill(Path2D)换成ctx.font加fillText再把字体调大即可。注意中文字体在不同系统上字形差异较大遮罩和容器用同一套font-family栈更保险。还有一种更“稳”的做法不走maskImage而是自己算一份点集用drawOutOfBound配合外部裁剪。但扩展内部没有开放这个接口硬做就要改源码成本不划算不如直接用上面的离屏 canvas。3.3 排布调优让形状贴边又不拥挤配置能跑通之后剩下的全是调参。这里给一组我常用的起步值和调整方向省得你一个个试。现象优先调方向形状边缘留白太多sizeRange上限、gridSize上限涨 10%~20%gridSize降到 6小词全被丢掉sizeRange下限、shrinkToFit下限降到 10~12保持shrinkToFit: true大词互相压盖gridSize升到 10~12让碰撞盒更保守词太少、区域发空rotationStep、词条数量步进改 30 或 0全水平补数据到 100 条以上排布动画一顿一顿layoutAnimation、gridSize关掉动画gridSize调大layoutAnimation是个容易翻车的选项。它做的是“逐字落位”的动画词条多、gridSize小的时候首帧计算量大在大屏 kiosk 上能看到明显的卡顿。我通常的做法是页面首屏进入时开动画观看者只看到一次之后所有 resize 触发的重排都关掉动画避免用户拖动窗口时反复重算。3.4 交互、自适应与事件绑定ECharts 版最大的性价比就在于事件是现成的。点击某个词跳转详情、hover 显示说明都只写几行chart.on(click, params { // params.name 是词面文本params.data 是原始数据项 console.log(clicked:, params.name, params.data.value); // 这里可以做路由跳转或弹层 }); // 容器尺寸变化时ECharts 需要显式 resize否则画布被 CSS 拉伸 const ro new ResizeObserver(() chart.resize()); ro.observe(document.getElementById(cloud));ResizeObserver比监听window.resize更可靠因为它能捕捉到容器自身尺寸的变化比如侧边栏折叠、栅格重排而窗口尺寸不变时后者根本不触发。这段代码不放上去大屏切换分辨率就会看到词云被拉成椭圆。自适应还有一个隐含问题ECharts 初始化时会缓存容器宽高如果你在 CSS 还没生效、或者容器display: none的状态下调用echarts.init拿到的宽高是 0画出来一片空白。稳妥的写法是先确认容器已经渲染比如放在DOMContentLoaded之后的requestAnimationFrame里再 init。提示容器必须给显式高度。height: 100%在父元素没有确定高度时会算成 0这也是“页面打开了但什么都看不到”的高频原因。4. 球面分布到透视投影3D 球形滚动的坐标怎么算3D 球形滚动词云看起来玄学拆开其实只有四步把词均匀撒在一个球面上、每帧按时间旋转、把三维坐标投影到二维屏幕、按深度排序后绘制。这里不讲概念直接按代码顺序走一遍从sphere-layout.js讲到sphere-render.js。4.1 球面撒点斐波那契分布替代随机经纬度新手第一个坑就是用随机球坐标随机一个经度θ、随机一个纬度φ看起来均匀实际上点会在两极严重堆积球转起来时上下两端糊成一片。原因是球面面积元正比于sin(φ)纬度随机意味着高纬度区域被过度采样。正确做法是斐波那契球黄金角螺旋用黄金角确定经度步进用等间距的y确定纬度这样每个点在球面上的面积近似相等。// js/sphere-layout.js // 斐波那契球n 个点在球面上近似等面积分布避免两极堆积 function fibonacciSphere(count, radius) { const points []; const goldenAngle Math.PI * (3 - Math.sqrt(5)); // 约 2.39996 弧度 for (let i 0; i count; i) { // y 在 [-1, 1] 上等间距保证纬度分布均匀 const y count 1 ? 0 : 1 - (i / (count - 1)) * 2; const r Math.sqrt(Math.max(0, 1 - y * y)); // 当前纬度圈的半径 const theta goldenAngle * i; points.push({ x: Math.cos(theta) * r * radius, y: y * radius, z: Math.sin(theta) * r * radius }); } return points; }count就是词条数量radius决定球体在三维空间里的尺度一般取容器短边的 0.35 到 0.45 倍。y从 1 递减到 -1配合Math.sqrt(1 - y*y)得到对应纬度圈的半径这是单位球的参数方程。经度theta每次加一个黄金角保证点在圆周方向上不重合。这个函数的好处是确定性的同样的count每次得到同样的分布调试时不会因为“刷新一下位置又变了”而怀疑人生。如果你想让词条排布不那么规整可以在goldenAngle上加一点点抖动但别用纯随机否则又回到两极堆积的老路。4.2 旋转矩阵绕 Y 轴自转带一点俯仰球体滚动本质是每帧对所有点的坐标做一次旋转。3D 版通常只绕 Y 轴转就够了但加一点 X 轴俯仰会更立体。// 先绕 Y 轴自转再绕 X 轴轻微俯仰 function rotatePoint(p, angleY, angleX) { const cy Math.cos(angleY), sy Math.sin(angleY); const x1 p.x * cy - p.z * sy; const z1 p.x * sy p.z * cy; const cx Math.cos(angleX), sx Math.sin(angleX); const y2 p.y * cx - z1 * sx; const z2 p.y * sx z1 * cx; return { x: x1, y: y2, z: z2 }; }angleY每帧递增比如 0.004数值只跟帧率相关所以实际项目中应当用时间差计算angleY speed * delta / 16.7让 60Hz 和 120Hz 屏上的转速一致。angleX一般给一个固定的小角度0.1 到 0.25 弧度让球体微微前倾或者用鼠标纵向位置映射做跟随手感。注意这里用的是原地旋转不做平移所以球心始终在原点。真实项目里如果把球心挪到容器中心建议在投影之后再统一加偏移别把偏移量混进三维坐标里否则后面的缩放和拖拽会互相干扰。4.3 透视投影与 z 排序近大远小的像素换算三维点要变成屏幕像素需要一次透视投影。原理是“越远的点同样的三维距离映射出的屏幕距离越小”。// 透视投影focal 越小透视越夸张scale 是整体缩放系数 function project(p, focal, scale) { const k focal / (focal p.z); // z 越大离观察者越远k 越小 return { sx: p.x * k * scale, sy: p.y * k * scale, depth: k // 用于字号和透明度的插值因子 }; }focal取 500 到 900 之间比较自然值越小广角畸变越强球体边缘的词会被拉得很夸张值越大越接近正交投影立体感变弱。scale是给缩放交互留的口子滚轮缩放就是改它不涉及任何坐标重算。投影之后必须按z升序排序再绘制否则会出现“后面的词盖住前面的词”视觉上球体是反的。排序成本是O(n log n)200 个词条一帧也就几微秒可以放心每帧排。拼装起来的渲染循环大概是这样function render() { ctx.clearRect(0, 0, cssWidth, cssHeight); const projected words.map(word { const r rotatePoint(word.pos, angleY, angleX); const p project(r, FOCAL, zoom); return { text: word.name, value: word.value, x: cx p.sx, y: cy p.sy, depth: p.depth, z: r.z }; }); // 远的先画近的后画保证遮挡关系正确 projected.sort((a, b) a.z - b.z); for (const item of projected) { // 深度映射到字号近处大、远处小但不至于完全消失 const fontSize BASE_FONT * item.depth * 0.9; const alpha Math.max(0.25, item.depth - 0.35); // 远处淡化 ctx.font 600 ${fontSize}px PingFang SC, sans-serif; ctx.fillStyle rgba(120, 200, 255, ${alpha}); ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(item.text, item.x, item.y); } angleY 0.004; // 自转步长 requestAnimationFrame(render); }关于alpha那句用一个大于 0 的下界很重要。纯按depth映射的话球体背面的词会淡到看不见球看起来像被挖掉一块给个 0.25 的下限背面保留轮廓立体感更完整。字号同理0.9这个系数是为了让远近差异不过分夸张试的时候可以调。requestAnimationFrame里不要做ctx.save()/restore()的循环包裹也不要在循环内重新setTransform这些都会实打实增加开销第 5 章会讲具体表现。4.4 DPR 与缩放canvas 尺寸、ctx.scale 与 zoom 系数Canvas 版最容易翻车的地方不是算法是像素比。canvas有两个尺寸CSS 尺寸显示大小和位图尺寸canvas.width/height实际像素数。两者不一致时浏览器帮你拉伸字就糊了。// js/sphere-render.js —— 初始化与高清屏适配 const canvas document.getElementById(sphere); const ctx canvas.getContext(2d); let cssWidth 0, cssHeight 0; function resizeCanvas() { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); cssWidth rect.width; cssHeight rect.height; // 位图尺寸 CSS 尺寸 × dpr保证一个 CSS 像素对应 dpr 个物理像素 canvas.width Math.round(cssWidth * dpr); canvas.height Math.round(cssHeight * dpr); // 之后所有绘制都按 CSS 像素坐标系走不用手动乘 dpr ctx.setTransform(dpr, 0, 0, dpr, 0, 0); cx cssWidth / 2; // 球心容器中心 cy cssHeight / 2; }setTransform会覆盖之前的所有变换所以它只能在 resize 时调不能每帧调同时要意识到它会重置translate如果后面还要做局部坐标系偏移得在它之后再调用。cx和cy用 CSS 像素维护投影结果直接加在上面逻辑就干净了。缩放交互就简单了监听滚轮把zoom限制在一个合理区间再给个平滑插值// js/zoom-control.js let zoom 1, targetZoom 1; const MIN_ZOOM 0.6, MAX_ZOOM 2.2; canvas.addEventListener(wheel, e { e.preventDefault(); // deltaY 0 表示向下滚缩小系数 0.001 控制滚动手感 targetZoom clamp(targetZoom - e.deltaY * 0.001, MIN_ZOOM, MAX_ZOOM); }, { passive: false }); // 在渲染循环里做阻尼插值避免缩放一格一顿 function updateZoom() { zoom (targetZoom - zoom) * 0.12; }用targetZoom加阻尼插值是必须的直接把deltaY加到zoom上滚一下跳一大截手感像在拖拽而不是缩放。0.12这个系数决定了跟随速度太大就失去平滑太小会有黏滞感0.1 到 0.15 之间比较合适。MIN_ZOOM和MAX_ZOOM建议不要放开不然用户能把球缩成一个点或者放大到只剩几个字体验会很差。两种渲染方式的差异在这里体现得最清楚DOM 版缩放靠修改每个元素的transform: translate3d(x, y, 0) scale(k)浏览器负责合成字始终清晰但元素多了之后合成层压力大Canvas 版缩放只改一个系数绘制成本恒定缺点是要自己处理 DPR且字体加载完成前画的字会回退成默认字体出现一次“闪变”。常见做法是先用document.fonts.ready等字体就绪再启动动画循环。5. 避坑与排查跑不起来、字叠一起、球体抖动的真实原因这几个问题我自己都完整踩过一遍现象看起来各不相同根因往往就那几类。按“现象 → 原因 → 解决”整理成五条遇到问题时对号入座能省掉大量盲猜。5.1 遮罩不生效词云还是一个方块现象很典型配置里明明加了maskImage渲染出来还是一个填满容器的矩形形状完全看不出来。原因有三个层面。第一图片是白底不透明扩展按透明度判断可放置区整张图全是不透明自然就是矩形。第二图片跨域画到 canvas 后读像素被拦截异常被吞掉回退成了默认形状。第三setOption在图片onload之前就执行了此时maskImage还是null首帧按shape排布之后即便赋值也不会自动重排。解决办法按顺序检查用图片编辑工具把背景删掉导出透明 PNG遮罩图与页面同源或者干脆用离屏 canvas 生成 dataURL把setOption放进图片加载完成的回调里加载失败时给一个兜底形状别让页面直接空白。SVG 路径转遮罩那条路最稳因为不涉及网络请求和跨域。补充一点形状本身太“细”也会导致遮罩无效的错觉。比如一条细线轮廓可放置区只有几个像素宽词根本塞不进去最终结果就是空的。这类形状建议用“描边加粗”或直接换成实心版本。5.2 词少了一半大词不见了现象是词库给了 120 条画面上只有 40 来个权重最高的几个词完全消失。原因通常不是数据问题而是排布失败被丢弃了。drawOutOfBound: false加上不规则的遮罩形状边缘收窄的地方放不下大字号矩形扩展尝试若干次后直接放弃这个词并且不会有任何提示。解决要从三个方向同时压把sizeRange上限降下来大词占的面积小了才塞得进窄区域把gridSize稍微调大碰撞盒更保守成功率高打开shrinkToFit让它先尝试缩字号而不是直接丢弃。另外容器宽高太小也会放大这个问题width/height用92%留了边距再小就没空间了。还有一个隐蔽原因容器高度是 0。ECharts 拿到 0 尺寸后布局空间为零所有词都放不下但画布又是透明的看起来就是“只显示了一部分”。打开开发者工具看一下 canvas 元素的实际尺寸能立刻排除这一类。5.3 球体两极糊成一团3D 版里最直观的失败球中间的词间距正常上下两端密密麻麻挤在一起转起来像粘住了。原因基本只有一个——用随机经纬度撒点。随机纬度在球面上不是等面积分布高纬度圈周长小同样数量的点自然挤。换成斐波那契球分布就解决了y等间距、经度用黄金角步进点数在球面上近似等面积。如果换了之后仍觉得上下略密说明点在视觉上被投影压缩了可以给y加一点非线性的重映射比如y Math.sign(y) * Math.pow(Math.abs(y), 0.92)让纬度略微向赤道方向聚拢。这个指数不要离 1 太远改到 0.8 以下会看到中间明显变稀。顺带一提字号随深度变化的系数也会影响这个观感。字号衰减太强时两端的词变小变淡本来只是密集看起来就像“糊掉了”。把衰减系数从 0.9 调到 1.0 上下密度感会缓解不少。5.4 帧率掉到 30 以下表现是动画明显不顺尤其是在 2K 大屏或者带其他图表的页面上。打开性能面板会看到每帧的脚本耗时占了大部分。常见的三个原因每帧都在调ctx.measureText计算文本宽度每帧都在ctx.save()/restore()或重新设置font每帧都在做 DOM 版的位置更新导致强制同步布局。Canvas 版的优化很直接文本宽度只在数据变化时算一次并缓存到词对象上ctx.font只在字号真的变了才赋值字符串拼接本身也有成本颜色用预生成的rgba字符串而不是每帧模板拼接。DOM 版则要减少style的写入次数把位置写进transform而不是left/top让浏览器走合成层。还有一个经常被忽略的点requestAnimationFrame的回调里包含排序排序本身不慢但map里创建了大量临时对象一帧几百个对象GC 压力会上来。词条固定时可以把对象池化或者直接复用数组元素。5.5 高清屏发虚缩放后更糊现象是文字边缘毛糙放大浏览器缩放比例后更明显截图发出去被人说“图是糊的”。原因是canvas.width/height用了 CSS 尺寸没有乘devicePixelRatio浏览器把低分辨率位图放大到物理像素上。修法就是 4.4 节那段代 码位图尺寸乘 DPR然后用setTransform(dpr, 0, 0, dpr, 0, 0)把坐标系还原成 CSS 像素后续绘制逻辑一行都不用改。注意resizeCanvas之后必须重绘一帧因为修改canvas.width会清空画布内容并重置所有上下文状态包括font、fillStyle。有人把resize写在动画循环外结果窗口一变化画面就白一下再恢复就是漏了这次主动重绘。另外DPR 在双屏拖动时会变化比如从 1 倍屏拖到 2 倍屏现在的主流浏览器会触发resize但个别环境不触发。监听window.matchMedia((resolution: 1dppx))的变化作为补充更稳能覆盖这类边缘情况。6. 二次开发把球换成任意形状、把数据接进接口三份实现的代码量都不大真正的改造空间在“形状”和“数据”两个维度上。给两条我实际用过的路径都是小改动不动核心算法。第一种把 3D 球体换成任意形状的点云。思路是把fibonacciSphere的输出替换成“从一张图片采样得到的点集”把遮罩图画到离屏 canvas按步长遍历像素把不透明的像素坐标映射到三维空间的一个薄层里z给一个小范围随机值制造厚度再用同一套旋转、投影、排序、绘制逻辑跑。因为渲染层只认{x, y, z}换数据源对它完全透明。采样步长控制在 3 到 6 像素之间点太多没有意义词条数远小于像素数。// 把遮罩图采样成三维点云替代 fibonacciSphere 的返回值 function samplePointsFromMask(img, count, thickness 40) { const c document.createElement(canvas); c.width img.width; c.height img.height; const g c.getContext(2d); g.drawImage(img, 0, 0); const data g.getImageData(0, 0, c.width, c.height).data; const filled []; for (let y 0; y c.height; y 4) { for (let x 0; x c.width; x 4) { if (data[(y * c.width x) * 4 3] 128) filled.push({ x, y }); } } // 从实心像素里等间隔取 count 个避免词条全挤在图形边缘 const step Math.max(1, Math.floor(filled.length / count)); return filled.filter((_, i) i % step 0).slice(0, count).map((p, i) ({ x: (p.x - c.width / 2) * 1.2, y: (p.y - c.height / 2) * 1.2, // z 用确定性的伪随机保证每次刷新形状一致 z: ((i * 37) % 100 / 100 - 0.5) * thickness })); }几个参数值得注意采样步长 4 是精度和性能的折中1.2是整体放大系数用来把图形撑到容器大小z用i派生而不是Math.random()这样刷新页面形状不会跳变调试时省心。剪影类图形人物、动物轮廓用这条路径效果最好边缘会有自然的厚度感。第二种数据接实时接口。三个页面都只读window.WORD_DATA所以改造点集中在一处把赋值换成异步请求请求回来后再初始化图表或重建词对象。ECharts 版直接chart.setOption({ series: [{ data }] })就能增量更新3D 版要重建pos数组因为点数是变化的重建后别忘了重新计算words的长度和字号映射。改造点改哪个文件注意事项换词库 / 接接口js/data.js保持name/value字段名其余逻辑不用动换 2D 形状遮罩 PNG 或createMaskFromPath透明背景、留 5%~10% 内边距换 3D 形状sphere-layout.js的撒点函数返回{x, y, z}数组即可长度对齐词条数调转速 / 缩放范围sphere-render.js、zoom-control.js转速用时间差计算别写死每帧增量加点击 / hover两个 3D 页面用投影后的屏幕坐标做距离判定别在三维空间里算最后提一个验证习惯每次改完布局相关的代码我都会把窗口在 1280×720、1920×1080 和手机竖屏三个尺寸下各过一遍并把 DPR 手动切到 2 和 3 看一遍文字清晰度。这套动作花不了两分钟但能拦住绝大部分“在我机器上好好的”问题。3D 球那个z排序我最初就漏了缩放后的重排滚动时远处的词会突然压到近处查了半天才发现是排序用了旋转前的坐标。从那以后我每次改投影相关代码都强制把排序键打出来对一遍。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?