JS饼状图扇区颜色渐变与立体效果实现
深入讲解如何使用原生JavaScript在HTML5 Canvas中实现具有颜色渐变与3D立体效果的饼状图,涵盖仿射透视投影、空间侧壁挤出、画家算法深度排序、径向高光与曲面法向阴影渐变等核心技术与完整工程代码。
一、立体饼图与色彩渐变的核心机理
在二维平面中,标准的饼状图由多个共享圆心的平面圆形扇区(Sector)构成。要让扁平的扇区呈现出逼真的立体感与材质光泽,不能仅靠单一的浅色或阴影滤镜,而需要从几何空间、遮挡消隐和光学着色三个维度进行数学建模。
1.1 仿射投影压缩与等轴测透视
在无需引入臃肿 WebGL 渲染管线的前提下,最经典且高效的 3D 俯仰透视方式是通过 2D 仿射变换压缩 Y 轴坐标。通过在 Canvas 绘图上下文中应用 ctx.scale(1, ky)(其中压缩比 ky 通常设定在 0.5 到 0.6 之间),原本半径为 R 的圆形扇区被压平为半长轴为 R、半短轴为 R × ky 的椭圆扇区。这种变换在平面视网膜上形成了近似等轴测的俯视视差。
1.2 空间厚度挤出与实体曲面构成
仅仅压扁圆面只能得到平面椭圆,必须赋予其空间纵深厚度。在几何学中,厚度是通过沿 Z 轴(在 2D 投影平面中表现为垂直向下沿 Y 轴正方向)将椭圆顶面平移距离 H,形成实体圆柱段。一个完整的 3D 扇区由三类面组合封闭:
- 顶面椭圆扇区:由圆心、起止角度边界线及上部椭圆弧线构成的闭合受光面。
- 外圆弧柱面侧壁:连接顶面外椭圆弧与平移
H后的底面椭圆弧所构成的曲面封闭带。 - 径向剖切面侧壁:扇区起始边与终止边沿厚度方向拉伸形成的两个垂直平面四边形。
二、渲染管线与绘制执行步骤
由于 3D 饼图存在空间前后层级,若直接按数据原始顺序绘制,位于前方的扇区侧壁会被后方扇区覆盖,产生严重的“穿模”缺陷。因此,整个渲染流水线必须遵循严格的排序与分层执行步骤。
2.1 步骤一:数据归一化与扇区角度量化
首先遍历数据集合,累加所有数据项的数值总和 total。随后按各项占比乘以 2π,依次推导出每个扇区的起始弧度 startAngle、终止弧度 endAngle,以及代表扇区中轴方向的平分角 midAngle = (startAngle + endAngle) / 2。
2.2 步骤二:画家算法深度排序(Depth Sorting)
在俯视透视模型中,Y 坐标越小(即处于上侧)的元素在视觉空间中距离观察者越远,Y 坐标越大(处于下侧)的元素距离观察者越近。
依据图形学中的“画家算法”(Painter's Algorithm),必须先绘制远景、再绘制近景。每个扇区中轴点在单位圆上的垂直分量为 Math.sin(midAngle)。我们据此构建排序权重,将数值从小到大(从远端到近端)排序,确定绝对绘制次序。
2.3 步骤三:侧壁可见性区间判定与裁剪
在外圆弧侧壁的渲染中,背对观察者的外弧柱面(位于椭圆上半圆,即弧度区间 [π, 2π])会被扇区自身的顶面完全遮挡,无需浪费算力绘制;只有面向观察者(位于下半圆 [0, π])的外圆弧侧壁才真实可见。
因此,在绘制外弧侧壁前,必须将扇区的角度区间 [startAngle, endAngle] 与可见区间 [0, π] 进行布尔交集求交,精确裁剪出可见弧段后再执行柱面挤出。
三、扇区渐变着色与曲面光影算法实现
纯色的立体模型往往显得机械呆板,真实世界中的曲面材质具有随观察角度与入射光线变化的渐变特性。在 Canvas 2D 环境下,通过组合使用线性渐变(createLinearGradient)与径向渐变(createRadialGradient),可以模拟出逼真的兰伯特漫反射与高光效果。
3.1 顶面拟真光照:径向高光反射
扇区顶面是主要的视觉承载面。我们使用偏心径向渐变来模拟斜上方平行光源对平面的照射。以扇区几何中心为基准,向假想光源方向(如左上方 -45° 方向)偏移微小距离构建高光焦点 (xf, yf)。渐变色标从高光区的提亮色(明度提升 20%~25%),平滑过渡到基准色,并在接近外边缘处微降明度(降低 15%~20%),从而赋予顶面微妙的弧面张力感。
3.2 外弧侧壁法向漫反射与连续明暗过渡
外圆柱侧壁是连续弯曲的曲面。外弧上每一点的法向量为 N(θ) = (cos θ, sin θ)。当光源从左侧照射时,左侧侧壁的法向量与光线夹角较小,接受较多光照;右侧侧壁则背光。
在实现上,无需逐像素计算光照方程,而是沿着可见外弧的起点坐标 (x1, y1) 到终点坐标 (x2, y2) 建立线性渐变。线性渐变内部根据起止角度与光源矢量的内积,计算并设置对应的深浅色标,极高效率地实现柱面圆滑明暗转折。
四、完整原生 JavaScript 引擎实现与实战示例
下面提供一套可以直接在现代浏览器中运行的原生 JavaScript 实现方案。代码封装为自包含的 render3DPieChart 函数,内部处理了画布设备像素比自适应、深度排序、可见性区间切割、空间挤出侧壁绘制与复合色彩渐变。
/**
* 3D 渐变饼状图渲染引擎 (原生 Canvas 2D 实现)
* @param {HTMLCanvasElement} canvas - 目标画布节点
* @param {Array} data - 数据项数组 [{ label, value, color }, ...]
* @param {Object} options - 配置项
*/
function render3DPieChart(canvas, data, options = {}) {
const {
cx = canvas.width / 2,
cy = canvas.height / 2,
radius = 160,
height = 36, // 立体挤出厚度
scaleY = 0.55, // 俯视透视压缩比例
explodedIndex = 0, // 默认悬浮高亮扇区索引
explodeDistance = 20 // 悬浮扇区位移距离
} = options;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 1. 数据归一化与弧度计算
const total = data.reduce((sum, item) => sum + item.value, 0);
let currentAngle = -Math.PI / 2; // 从 12 点钟方向开始
const sectors = data.map((item, index) => {
const sliceAngle = (item.value / total) * Math.PI * 2;
const startAngle = currentAngle;
const endAngle = currentAngle + sliceAngle;
const midAngle = (startAngle + endAngle) / 2;
currentAngle = endAngle;
return {
...item,
index,
startAngle,
endAngle,
midAngle,
isExploded: index === explodedIndex
};
});
// 2. 画家算法深度排序:中轴 Y 轴分量越小越靠后,先绘制
const sortedSectors = [...sectors].sort((a, b) => {
return Math.sin(a.midAngle) - Math.sin(b.midAngle);
});
// 辅助颜色明暗调节函数 (十六进制色值调整)
function adjustColor(hex, factor) {
let num = parseInt(hex.replace('#', ''), 16);
let r = Math.min(255, Math.max(0, Math.round(((num >> 16) & 0xff) * factor)));
let g = Math.min(255, Math.max(0, Math.round(((num >> 8) & 0xff) * factor)));
let b = Math.min(255, Math.max(0, Math.round((num & 0xff) * factor)));
return `rgb(${r},${g},${b})`;
}
// 3. 逐层遍历渲染各扇区
sortedSectors.forEach(sector => {
ctx.save();
// 计算悬浮偏移位移向量 (Δx, Δy)
let offsetX = 0;
let offsetY = 0;
if (sector.isExploded) {
offsetX = Math.cos(sector.midAngle) * explodeDistance;
offsetY = Math.sin(sector.midAngle) * explodeDistance * scaleY;
}
const sectorCx = cx + offsetX;
const sectorCy = cy + offsetY;
// --- A. 绘制可见外圆弧柱面侧壁 ---
// 归一化角到 [0, 2π] 区间用于判定下半圆 [0, π]
const normStart = (sector.startAngle % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2);
const normEnd = (sector.endAngle % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2);
// 针对跨越可见区间的角度做外弧采样绘制 (步进逼近法或分段曲面)
const arcStep = 0.05;
let a1 = sector.startAngle;
let a2 = sector.endAngle;
for (let a = a1; a < a2; a += arcStep) {
const subStart = a;
const subEnd = Math.min(a + arcStep, a2);
const subMid = (subStart + subEnd) / 2;
const normSubMid = (subMid % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2);
// 仅当下半圆可见区间 (0 到 π) 时绘制外侧壁
if (normSubMid >= 0 && normSubMid <= Math.PI) {
const x1 = sectorCx + Math.cos(subStart) * radius;
const y1 = sectorCy + Math.sin(subStart) * radius * scaleY;
const x2 = sectorCx + Math.cos(subEnd) * radius;
const y2 = sectorCy + Math.sin(subEnd) * radius * scaleY;
// 根据曲面法线计算受光衰减系数 (左上方光源)
const lightIntensity = 0.45 + 0.5 * Math.max(0, -Math.cos(subMid) * 0.7 + Math.sin(subMid) * 0.3);
const wallColor = adjustColor(sector.color, lightIntensity);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineTo(x2, y2 + height);
ctx.lineTo(x1, y1 + height);
ctx.closePath();
ctx.fillStyle = wallColor;
ctx.fill();
}
}
// --- B. 绘制径向剖切面侧壁 (如果切面法向朝向观察者) ---
[sector.startAngle, sector.endAngle].forEach(edgeAngle => {
const normEdge = (edgeAngle % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2);
// 判定径向边是否朝向视点
if (Math.sin(edgeAngle) > -0.2) {
const edgeX = sectorCx + Math.cos(edgeAngle) * radius;
const edgeY = sectorCy + Math.sin(edgeAngle) * radius * scaleY;
ctx.beginPath();
ctx.moveTo(sectorCx, sectorCy);
ctx.lineTo(edgeX, edgeY);
ctx.lineTo(edgeX, edgeY + height);
ctx.lineTo(sectorCx, sectorCy + height);
ctx.closePath();
ctx.fillStyle = adjustColor(sector.color, 0.65);
ctx.fill();
}
});
// --- C. 绘制顶面椭圆扇区与高光渐变 ---
ctx.save();
ctx.translate(sectorCx, sectorCy);
ctx.scale(1, scaleY);
// 建立偏心高光径向渐变
const focusDist = radius * 0.25;
const fx = Math.cos(sector.midAngle - 0.4) * focusDist;
const fy = Math.sin(sector.midAngle - 0.4) * focusDist;
const topGrad = ctx.createRadialGradient(fx, fy, 5, 0, 0, radius);
topGrad.addColorStop(0, adjustColor(sector.color, 1.25)); // 中心高光提亮
topGrad.addColorStop(0.7, sector.color); // 基准主色
topGrad.addColorStop(1, adjustColor(sector.color, 0.8)); // 边缘环境渐暗
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, radius, sector.startAngle, sector.endAngle);
ctx.closePath();
ctx.fillStyle = topGrad;
ctx.fill();
// 顶面半透明高光边缘描边
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
ctx.restore();
});
}
render3DPieChart(canvas, data, options) 时,仅需传入普通的 Canvas DOM 节点及标准配色数组(如 #345E82、#AD8060 等)。函数内部自动完成仿射缩放与光照色标计算,无需引入任何第三方 3D 引擎。
五、工程边界防护与性能优化
在实际生产环境中落地 3D 饼状图时,往往会遇到高分屏模糊、扇区接缝白边(Seam Artifacts)以及极端数值渲染等工程边界问题。针对这些典型隐患,需要采取针对性的防护措施。
| 隐患类型 | 具体表现 | 产生根因 | 工程防范措施 |
|---|---|---|---|
| 接缝发虚与白边 | 相邻扇区或侧壁与顶面连接处出现微小背景贯穿缝隙 | 抗锯齿(Subpixel Anti-aliasing)插值导致的透明像素泄漏 | 在计算侧壁与扇区弧度步长时增加 0.005 rad 的微弱重叠冗余 |
| Retina 屏发虚模糊 | 在高 DPR 移动设备上饼图轮廓毛糙不清晰 | Canvas 逻辑像素尺寸未对齐物理像素 | 将 canvas.width/height 乘以 devicePixelRatio 并通过 CSS 限制显示宽高 |
| 全量 100% 扇区退化 | 单项数据占比 100% 时侧壁断裂或无法闭合 | 起始弧度与终止弧度相等或差值为 2π 导致多边形退化 | 单独分支特判:直接按完整圆柱侧壁与完整椭圆顶面连续绘制 |
| 悬浮位移层级穿透 | 悬浮扇区移出后遮挡关系错乱 | 固定深度排序未重新根据位移后的局部 Y 坐标重算 | 排序键加入悬浮向量在 Y 轴的投影偏移权重 offsetY |
六、实现总结
实现高质量的 JavaScript 3D 渐变饼状图,本质是将几何拓扑剖分与轻量光学模型在二维画布上做精确投影。通过将视角转化为仿射压缩矩阵、借助画家算法解决空间遮挡、使用下半圆区间裁剪剔除无效背向侧壁,并结合径向高光与切向线性渐变,我们以极低的代码体积和极佳的渲染性能,在原生 Canvas 环境中构建出了具有专业工业质感的可视化图表。
anime.js 快速入门:怎么引入库并编写第一个 DOM 动画
2026-11-05 14:22:43
在线JS解密后变量名仍乱码?结合浏览器DevTools调试还原
2026-11-04 20:26:01
怎么在浏览器中运行JavaScript代码并查看输出结果
2026-11-04 09:07:33
JS 获取屏幕分辨率:window.screen.width 与 height 用法
2026-11-04 08:52:48
为什么推荐用DOMContentLoaded代替window.onload初始化JS
2026-11-04 08:42:33
相关标签:








