pin_drop当前位置:知识文库 ❯ 图文
数据可视化:面积图与雷达图 - 从入门到实践详解
教程简介
面积图和雷达图是两种重要的数据可视化图表类型。面积图通过折线下方的填充区域强调数据的量级和趋势变化,堆叠面积图还能展示各部分的贡献和总量变化。雷达图则通过多轴放射状布局展示多维数据的对比,特别适合评估多个对象在多个指标上的综合表现。本教程将全面讲解面积图与雷达图的实现,包括面积图实现、堆叠面积图、雷达图实现、多维度对比、填充透明度和交互效果。
核心概念
面积图
面积图是折线图的变体,在折线下方填充颜色区域:
类型:
基础面积图:单系列面积填充,强调趋势量级
堆叠面积图:多系列堆叠,展示总量和各部分贡献
百分比堆叠面积图:每列总和为100%,展示各部分占比变化
适用场景:
展示数据量级随时间的变化
比较多个系列的贡献大小
展示各部分占整体的比例变化
注意事项:
系列数量不宜超过5个,否则难以区分
堆叠面积图中系列顺序影响可读性
面积填充使用半透明颜色,避免遮挡
雷达图
雷达图(又称蜘蛛图)使用放射状的多轴坐标系展示多维数据:
构成要素:
多个从中心向外辐射的轴线,每个轴代表一个维度
数据点在各轴上标记,连接成多边形
多个数据系列可叠加在同一雷达图上
适用场景:
多维性能评估(如产品评分、员工能力评估)
多对象多维对比
展示数据在各维度上的均衡性
注意事项:
维度数量建议5-8个,过少无意义,过多难阅读
维度顺序影响图形形状,需合理排列
数值范围应统一,否则大值维度会主导图形
语法与用法
面积图绘制核心逻辑
代码示例
function drawAreaChart(ctx, data, config) {
const { margin, plotWidth, plotHeight } = config;
const maxVal = Math.max(...data.values);
const xStep = plotWidth / (data.values.length - 1);
ctx.save();
ctx.translate(margin.left, margin.top);
// 计算数据点
const points = data.values.map((v, i) => ({
x: i * xStep,
y: plotHeight - (v / maxVal) * plotHeight
}));
// 绘制面积
ctx.beginPath();
ctx.moveTo(points[0].x, plotHeight);
points.forEach(p => ctx.lineTo(p.x, p.y));
ctx.lineTo(points[points.length - 1].x, plotHeight);
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, plotHeight);
grad.addColorStop(0, data.color + '40');
grad.addColorStop(1, data.color + '05');
ctx.fillStyle = grad;
ctx.fill();
// 绘制折线
ctx.beginPath();
points.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.strokeStyle = data.color;
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}雷达图绘制核心逻辑
代码示例
function drawRadarChart(ctx, data, config) {
const { cx, cy, radius, dimensions } = config;
const angleStep = (Math.PI * 2) / dimensions.length;
const maxVal = 100; // 假设0-100范围
// 绘制网格
[0.2, 0.4, 0.6, 0.8, 1.0].forEach(scale => {
ctx.beginPath();
dimensions.forEach((_, i) => {
const angle = -Math.PI / 2 + i * angleStep;
const x = cx + Math.cos(angle) * radius * scale;
const y = cy + Math.sin(angle) * radius * scale;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.closePath();
ctx.strokeStyle = '#eee';
ctx.stroke();
});
// 绘制轴线
dimensions.forEach((dim, i) => {
const angle = -Math.PI / 2 + i * angleStep;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius);
ctx.strokeStyle = '#ddd';
ctx.stroke();
});
// 绘制数据多边形
data.forEach(series => {
ctx.beginPath();
series.values.forEach((v, i) => {
const angle = -Math.PI / 2 + i * angleStep;
const r = (v / maxVal) * radius;
const x = cx + Math.cos(angle) * r;
const y = cy + Math.sin(angle) * r;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.closePath();
ctx.fillStyle = series.color + '30';
ctx.fill();
ctx.strokeStyle = series.color;
ctx.lineWidth = 2;
ctx.stroke();
});
}代码示例
示例1:堆叠面积图
以下示例实现堆叠面积图,展示多系列数据的堆叠效果和交互。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>堆叠面积图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
display: flex;
justify-content: center;
padding: 30px 20px;
}
.chart-container {
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
width: 100%;
max-width: 950px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 10px;
}
.chart-title { color: #2c3e50; font-size: 20px; }
.toggle-group { display: flex; gap: 4px; }
.toggle-btn {
padding: 5px 14px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
color: #666;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.toggle-btn.active { background: #4e79a7; color: #fff; border-color: #4e79a7; }
canvas { width: 100%; cursor: crosshair; }
.legend {
display: flex;
gap: 18px;
margin-top: 15px;
justify-content: center;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #666;
cursor: pointer;
}
.legend-item.disabled { opacity: 0.3; }
.legend-color { width: 14px; height: 14px; border-radius: 3px; }
</style>
</head>
<body>
<div class="chart-container">
<div class="chart-header">
<span class="chart-title">网站流量来源分析</span>
<div class="toggle-group">
<button class="toggle-btn active" onclick="setMode('stacked')">堆叠</button>
<button class="toggle-btn" onclick="setMode('percent')">百分比</button>
</div>
</div>
<canvas id="areaChart"></canvas>
<div class="legend" id="legend"></div>
</div>
<script>
const trafficData = {
labels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
series: [
{ name: '自然搜索', color: '#4e79a7', data: [320,350,380,420,450,480,520,550,530,560,590,620], visible: true },
{ name: '社交媒体', color: '#f28e2b', data: [180,200,220,250,280,310,340,360,350,370,400,420], visible: true },
{ name: '直接访问', color: '#e15759', data: [150,160,170,180,190,200,210,220,215,225,240,250], visible: true },
{ name: '付费广告', color: '#59a14f', data: [100,120,140,160,180,200,220,240,230,250,270,290], visible: true },
{ name: '邮件营销', color: '#76b7b2', data: [50,55,60,65,70,75,80,85,82,88,92,98], visible: true }
]
};
let chartMode = 'stacked';
let mouseX = -1;
let animProgress = 0;
const canvas = document.getElementById('areaChart');
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.parentElement.clientWidth - 60;
const displayH = Math.min(420, displayW * 0.48);
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
canvas.style.width = displayW + 'px';
canvas.style.height = displayH + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const margin = { top: 25, right: 25, bottom: 45, left: 55 };
const pw = displayW - margin.left - margin.right;
const ph = displayH - margin.top - margin.bottom;
function renderLegend() {
document.getElementById('legend').innerHTML = trafficData.series.map((s, i) =>
`<div class="legend-item ${s.visible ? '' : 'disabled'}" onclick="toggleSeries(${i})">
<div class="legend-color" style="background:${s.color}"></div>${s.name}
</div>`
).join('');
}
function toggleSeries(index) {
trafficData.series[index].visible = !trafficData.series[index].visible;
renderLegend();
drawChart();
}
function setMode(mode) {
chartMode = mode;
document.querySelectorAll('.toggle-btn').forEach((btn, i) => {
btn.classList.toggle('active', (i === 0 && mode === 'stacked') || (i === 1 && mode === 'percent'));
});
animateChart();
}
function getStackedData() {
const visible = trafficData.series.filter(s => s.visible);
const len = trafficData.labels.length;
const stacks = [];
for (let i = 0; i < len; i++) {
let cumValue = 0;
const row = [];
visible.forEach(s => {
const raw = s.data[i];
cumValue += raw;
row.push({ bottom: cumValue - raw, top: cumValue, raw });
});
stacks.push(row);
}
if (chartMode === 'percent') {
const totals = stacks.map(row => row[row.length - 1].top);
stacks.forEach((row, i) => {
const total = totals[i] || 1;
let cumPct = 0;
row.forEach(item => {
const bottomPct = cumPct;
const topPct = cumPct + (item.raw / total) * 100;
item.bottom = bottomPct;
item.top = topPct;
cumPct = topPct;
});
});
}
return stacks;
}
function drawChart() {
const visible = trafficData.series.filter(s => s.visible);
if (visible.length === 0) return;
const stacks = getStackedData();
const maxVal = chartMode === 'percent' ? 100 : Math.max(...stacks.map(row => row[row.length - 1].top));
const xStep = pw / (trafficData.labels.length - 1);
ctx.clearRect(0, 0, displayW, displayH);
ctx.save();
ctx.translate(margin.left, margin.top);
// 网格
for (let i = 0; i <= 5; i++) {
const v = maxVal * i / 5;
const y = ph - (v / maxVal) * ph;
ctx.strokeStyle = '#f0f0f0'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke();
ctx.fillStyle = '#999'; ctx.font = '11px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(chartMode === 'percent' ? v + '%' : v, -8, y);
}
// X轴标签
trafficData.labels.forEach((label, i) => {
ctx.fillStyle = '#888'; ctx.font = '11px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(label, i * xStep, ph + 10);
});
// 动画裁剪
ctx.save();
ctx.beginPath();
ctx.rect(0, -5, pw * animProgress + 5, ph + 10);
ctx.clip();
// 绘制面积(从上到下)
for (let si = visible.length - 1; si >= 0; si--) {
const series = visible[si];
const seriesIdx = trafficData.series.indexOf(series);
// 上边界
ctx.beginPath();
for (let i = 0; i < trafficData.labels.length; i++) {
const x = i * xStep;
const y = ph - (stacks[i][si].top / maxVal) * ph;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
// 下边界(反向)
for (let i = trafficData.labels.length - 1; i >= 0; i--) {
const x = i * xStep;
const y = ph - (stacks[i][si].bottom / maxVal) * ph;
ctx.lineTo(x, y);
}
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, ph);
grad.addColorStop(0, series.color + '80');
grad.addColorStop(1, series.color + '30');
ctx.fillStyle = grad;
ctx.fill();
// 上边界线
ctx.beginPath();
for (let i = 0; i < trafficData.labels.length; i++) {
const x = i * xStep;
const y = ph - (stacks[i][si].top / maxVal) * ph;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.strokeStyle = series.color;
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore(); // 恢复裁剪
// 十字准线和tooltip
const plotLeft = margin.left;
const plotRight = margin.left + pw;
if (mouseX >= plotLeft && mouseX <= plotRight) {
const relX = mouseX - margin.left;
const idx = Math.round(relX / xStep);
if (idx >= 0 && idx < trafficData.labels.length) {
const snapX = idx * xStep;
ctx.strokeStyle = 'rgba(0,0,0,0.1)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(snapX, 0); ctx.lineTo(snapX, ph); ctx.stroke();
ctx.setLineDash([]);
// Tooltip
const lines = [trafficData.labels[idx]];
visible.forEach((s, si) => {
const val = s.data[idx];
const pct = chartMode === 'percent'
? ` (${stacks[idx][si].top.toFixed(1) - stacks[idx][si].bottom.toFixed(1)}%)`
: '';
lines.push(`${s.name}: ${val}${pct}`);
});
ctx.font = '12px sans-serif';
const maxW = Math.max(...lines.map(l => ctx.measureText(l).width));
const tw = maxW + 20;
const th = lines.length * 20 + 12;
let tx = snapX + 15;
if (tx + tw > pw) tx = snapX - tw - 15;
ctx.fillStyle = 'rgba(44,62,80,0.92)';
ctx.beginPath(); ctx.roundRect(tx, 5, tw, th, 6); ctx.fill();
lines.forEach((line, li) => {
ctx.fillStyle = li === 0 ? '#fff' : visible[li - 1].color;
ctx.font = li === 0 ? 'bold 12px sans-serif' : '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(line, tx + 10, 19 + li * 20);
});
}
}
// 坐标轴
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, ph); ctx.lineTo(pw, ph); ctx.stroke();
ctx.restore();
}
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
mouseX = (e.clientX - rect.left) * (displayW / rect.width);
drawChart();
});
canvas.addEventListener('mouseleave', () => {
mouseX = -1;
drawChart();
});
function animateChart() {
const start = performance.now();
function step(now) {
animProgress = Math.min(1, (now - start) / 1000);
animProgress = 1 - Math.pow(1 - animProgress, 3);
drawChart();
if (animProgress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
renderLegend();
animateChart();
</script>
</body>
</html>示例2:雷达图多维对比
以下示例实现雷达图,支持多系列对比、悬停交互和动画效果。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>雷达图多维对比</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
display: flex;
justify-content: center;
padding: 30px 20px;
}
.chart-container {
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
width: 100%;
max-width: 700px;
}
.chart-title { text-align: center; color: #2c3e50; font-size: 20px; margin-bottom: 15px; }
canvas { width: 100%; cursor: pointer; }
.legend {
display: flex;
gap: 20px;
margin-top: 15px;
justify-content: center;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #666;
cursor: pointer;
}
.legend-item.disabled { opacity: 0.3; }
.legend-line { width: 20px; height: 3px; border-radius: 2px; }
</style>
</head>
<body>
<div class="chart-container">
<h2 class="chart-title">产品能力雷达图</h2>
<canvas id="radarChart"></canvas>
<div class="legend" id="radarLegend"></div>
</div>
<script>
const radarData = {
dimensions: ['性能', '易用性', '安全性', '可扩展', '稳定性', '文档'],
series: [
{ name: '产品A', color: '#4e79a7', data: [85, 70, 90, 75, 88, 65], visible: true },
{ name: '产品B', color: '#e15759', data: [70, 85, 75, 90, 72, 80], visible: true },
{ name: '产品C', color: '#59a14f', data: [60, 90, 65, 80, 85, 92], visible: true }
]
};
let hoveredSeries = -1;
let animProgress = 0;
const canvas = document.getElementById('radarChart');
const dpr = window.devicePixelRatio || 1;
const size = Math.min(550, canvas.parentElement.clientWidth - 60);
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const cx = size / 2;
const cy = size / 2;
const radius = size * 0.36;
const dimCount = radarData.dimensions.length;
const angleStep = (Math.PI * 2) / dimCount;
const maxVal = 100;
function renderLegend() {
document.getElementById('radarLegend').innerHTML = radarData.series.map((s, i) =>
`<div class="legend-item ${s.visible ? '' : 'disabled'}" onclick="toggleRadarSeries(${i})">
<div class="legend-line" style="background:${s.color}"></div>${s.name}
</div>`
).join('');
}
function toggleRadarSeries(index) {
radarData.series[index].visible = !radarData.series[index].visible;
renderLegend();
drawChart();
}
function drawChart() {
ctx.clearRect(0, 0, size, size);
const visible = radarData.series.filter(s => s.visible);
// 网格多边形
[0.2, 0.4, 0.6, 0.8, 1.0].forEach(scale => {
ctx.beginPath();
for (let i = 0; i < dimCount; i++) {
const angle = -Math.PI / 2 + i * angleStep;
const x = cx + Math.cos(angle) * radius * scale;
const y = cy + Math.sin(angle) * radius * scale;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
ctx.strokeStyle = '#eee';
ctx.lineWidth = 1;
ctx.stroke();
// 刻度标签
if (scale === 1 || scale === 0.5) {
const angle = -Math.PI / 2;
const lx = cx + Math.cos(angle) * radius * scale - 15;
const ly = cy + Math.sin(angle) * radius * scale - 5;
ctx.fillStyle = '#bbb';
ctx.font = '10px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(Math.round(maxVal * scale), lx, ly);
}
});
// 轴线和标签
for (let i = 0; i < dimCount; i++) {
const angle = -Math.PI / 2 + i * angleStep;
const ex = cx + Math.cos(angle) * radius;
const ey = cy + Math.sin(angle) * radius;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(ex, ey);
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1;
ctx.stroke();
// 维度标签
const labelR = radius + 20;
const lx = cx + Math.cos(angle) * labelR;
const ly = cy + Math.sin(angle) * labelR;
ctx.fillStyle = '#555';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 调整标签位置避免重叠
if (Math.abs(Math.cos(angle)) > 0.8) {
ctx.textAlign = Math.cos(angle) > 0 ? 'left' : 'right';
}
ctx.fillText(radarData.dimensions[i], lx, ly);
}
// 数据多边形
visible.forEach((series, si) => {
const seriesIdx = radarData.series.indexOf(series);
const isHovered = hoveredSeries === seriesIdx;
const opacity = isHovered ? '50' : '25';
ctx.beginPath();
series.data.forEach((v, i) => {
const angle = -Math.PI / 2 + i * angleStep;
const r = (v / maxVal) * radius * animProgress;
const x = cx + Math.cos(angle) * r;
const y = cy + Math.sin(angle) * r;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.closePath();
ctx.fillStyle = series.color + opacity;
ctx.fill();
ctx.strokeStyle = series.color;
ctx.lineWidth = isHovered ? 3 : 2;
ctx.stroke();
// 数据点
series.data.forEach((v, i) => {
const angle = -Math.PI / 2 + i * angleStep;
const r = (v / maxVal) * radius * animProgress;
const x = cx + Math.cos(angle) * r;
const y = cy + Math.sin(angle) * r;
ctx.beginPath();
ctx.arc(x, y, isHovered ? 5 : 3.5, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = series.color;
ctx.lineWidth = 2;
ctx.stroke();
// 悬停时显示数值
if (isHovered) {
ctx.fillStyle = series.color;
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(v, x, y - 12);
}
});
});
}
// 交互
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (size / rect.width) - cx;
const my = (e.clientY - rect.top) * (size / rect.height) - cy;
const dist = Math.sqrt(mx * mx + my * my);
if (dist > radius + 20) {
if (hoveredSeries !== -1) { hoveredSeries = -1; drawChart(); }
return;
}
// 找到最近的系列
let found = -1;
let minDist = 30;
radarData.series.forEach((series, si) => {
if (!series.visible) return;
series.data.forEach((v, i) => {
const angle = -Math.PI / 2 + i * angleStep;
const r = (v / maxVal) * radius;
const px = Math.cos(angle) * r;
const py = Math.sin(angle) * r;
const d = Math.sqrt((mx - px) ** 2 + (my - py) ** 2);
if (d < minDist) { minDist = d; found = si; }
});
});
if (found !== hoveredSeries) {
hoveredSeries = found;
drawChart();
}
});
canvas.addEventListener('mouseleave', () => {
hoveredSeries = -1;
drawChart();
});
// 动画
const startTime = performance.now();
function animate(now) {
animProgress = Math.min(1, (now - startTime) / 800);
animProgress = 1 - Math.pow(1 - animProgress, 3);
drawChart();
if (animProgress < 1) requestAnimationFrame(animate);
}
renderLegend();
requestAnimationFrame(animate);
</script>
</body>
</html>示例3:面积图与雷达图组合面板
以下示例将面积图和雷达图组合在一个面板中,展示综合数据分析视图。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>面积图与雷达图组合面板</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
padding: 20px;
}
h1 { text-align: center; color: #2c3e50; margin-bottom: 20px; }
.panel {
display: flex;
gap: 20px;
max-width: 1100px;
margin: 0 auto;
flex-wrap: wrap;
}
.card {
background: #fff;
border-radius: 16px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
flex: 1;
min-width: 400px;
}
.card h2 { text-align: center; color: #34495e; margin-bottom: 15px; font-size: 16px; }
canvas { width: 100%; }
</style>
</head>
<body>
<h1>数据分析面板</h1>
<div class="panel">
<div class="card">
<h2>月度收入趋势(面积图)</h2>
<canvas id="miniArea"></canvas>
</div>
<div class="card">
<h2>能力评估(雷达图)</h2>
<canvas id="miniRadar"></canvas>
</div>
</div>
<script>
// 迷你面积图
(function() {
const canvas = document.getElementById('miniArea');
const dpr = window.devicePixelRatio || 1;
const w = canvas.parentElement.clientWidth - 50;
const h = 250;
canvas.width = w * dpr; canvas.height = h * dpr;
canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr);
const data = [42, 55, 48, 62, 58, 72, 68, 85, 78, 92, 88, 95];
const labels = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
const m = { top: 15, right: 15, bottom: 30, left: 40 };
const pw = w - m.left - m.right;
const ph = h - m.top - m.bottom;
const maxV = 100;
const xStep = pw / (data.length - 1);
ctx.save(); ctx.translate(m.left, m.top);
// 网格
for (let i = 0; i <= 4; i++) {
const y = ph - ph * i / 4;
ctx.strokeStyle = '#f0f0f0'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke();
ctx.fillStyle = '#bbb'; ctx.font = '10px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(maxV * i / 4, -5, y);
}
// 面积
const points = data.map((v, i) => ({ x: i * xStep, y: ph - (v / maxV) * ph }));
ctx.beginPath();
ctx.moveTo(points[0].x, ph);
points.forEach(p => ctx.lineTo(p.x, p.y));
ctx.lineTo(points[points.length - 1].x, ph);
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, ph);
grad.addColorStop(0, '#4e79a740');
grad.addColorStop(1, '#4e79a708');
ctx.fillStyle = grad; ctx.fill();
// 折线
ctx.beginPath();
points.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.strokeStyle = '#4e79a7'; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.stroke();
// 数据点
points.forEach(p => {
ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
ctx.fillStyle = '#fff'; ctx.fill();
ctx.strokeStyle = '#4e79a7'; ctx.lineWidth = 1.5; ctx.stroke();
});
// X标签
labels.forEach((l, i) => {
ctx.fillStyle = '#bbb'; ctx.font = '10px sans-serif';
ctx.textAlign = 'center'; ctx.fillText(l, i * xStep, ph + 15);
});
ctx.restore();
})();
// 迷你雷达图
(function() {
const canvas = document.getElementById('miniRadar');
const dpr = window.devicePixelRatio || 1;
const s = Math.min(400, canvas.parentElement.clientWidth - 50);
canvas.width = s * dpr; canvas.height = s * dpr;
canvas.style.width = s + 'px'; canvas.style.height = s + 'px';
const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr);
const cx = s / 2, cy = s / 2, r = s * 0.34;
const dims = ['技术', '沟通', '领导力', '创新', '执行', '协作'];
const series = [
{ name: '当前', color: '#4e79a7', data: [85, 70, 60, 80, 90, 75] },
{ name: '目标', color: '#e15759', data: [90, 85, 80, 90, 95, 90] }
];
const n = dims.length;
const step = Math.PI * 2 / n;
// 网格
[0.25, 0.5, 0.75, 1].forEach(sc => {
ctx.beginPath();
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + i * step;
const x = cx + Math.cos(a) * r * sc;
const y = cy + Math.sin(a) * r * sc;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath(); ctx.strokeStyle = '#eee'; ctx.lineWidth = 1; ctx.stroke();
});
// 轴线和标签
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + i * step;
ctx.beginPath(); ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1; ctx.stroke();
const lr = r + 18;
ctx.fillStyle = '#555'; ctx.font = '12px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
if (Math.abs(Math.cos(a)) > 0.8) ctx.textAlign = Math.cos(a) > 0 ? 'left' : 'right';
ctx.fillText(dims[i], cx + Math.cos(a) * lr, cy + Math.sin(a) * lr);
}
// 数据
series.forEach(s => {
ctx.beginPath();
s.data.forEach((v, i) => {
const a = -Math.PI / 2 + i * step;
const x = cx + Math.cos(a) * r * v / 100;
const y = cy + Math.sin(a) * r * v / 100;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.closePath();
ctx.fillStyle = s.color + '25'; ctx.fill();
ctx.strokeStyle = s.color; ctx.lineWidth = 2; ctx.stroke();
s.data.forEach((v, i) => {
const a = -Math.PI / 2 + i * step;
const x = cx + Math.cos(a) * r * v / 100;
const y = cy + Math.sin(a) * r * v / 100;
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = '#fff'; ctx.fill();
ctx.strokeStyle = s.color; ctx.lineWidth = 1.5; ctx.stroke();
});
});
// 图例
series.forEach((s, i) => {
const lx = cx - 60 + i * 80;
ctx.strokeStyle = s.color; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(lx, s * 0 + 15); ctx.lineTo(lx + 15, 15); ctx.stroke();
ctx.fillStyle = '#666'; ctx.font = '11px sans-serif';
ctx.textAlign = 'left'; ctx.fillText(s.name, lx + 20, 19);
});
})();
</script>
</body>
</html>浏览器兼容性
注意事项与最佳实践
面积图系列数量:堆叠面积图系列不超过5个,否则难以区分和阅读。
填充透明度:面积填充使用15%-40%透明度,确保重叠区域可辨识。
堆叠顺序:将变化最大的系列放在底部,便于观察趋势。
雷达图维度数:5-8个维度效果最佳,过少缺乏意义,过多难以阅读。
雷达图数值范围:所有维度使用相同的数值范围(如0-100),避免大值维度主导图形。
雷达图维度排列:将相关维度相邻排列,便于观察关联性。
百分比堆叠:当关注占比变化而非绝对值时,使用百分比堆叠面积图。
基线选择:面积图Y轴从零开始,否则填充面积会产生误导。
代码规范示例
代码示例
// 好的做法:面积图配置
const areaChartConfig = {
margin: { top: 25, right: 25, bottom: 45, left: 55 },
area: {
fillOpacity: 0.3,
strokeWidth: 2,
gradient: true,
gradientStops: [
{ offset: 0, opacity: 0.4 },
{ offset: 1, opacity: 0.05 }
]
},
stack: {
mode: 'stacked', // 'stacked' | 'percent' | 'none'
order: 'reverse' // 'reverse' | 'original' | 'ascending' | 'descending'
},
radar: {
gridLevels: 5,
gridOpacity: 0.15,
axisOpacity: 0.2,
fillOpacity: 0.2,
strokeWidth: 2,
pointRadius: 4
}
};常见问题与解决方案
问题1:堆叠面积图系列顺序影响可读性
解决方案:将最稳定(变化最小)的系列放在底部,变化最大的放在顶部,便于观察趋势。
问题2:雷达图形状受维度顺序影响
解决方案:将相关维度相邻排列;提供多种排列方式供用户选择;使用平均值作为参考线。
问题3:面积图Y轴不从零导致误导
解决方案:面积图必须从零开始填充。如果只需展示趋势不需展示量级,使用折线图而非面积图。
问题4:雷达图维度量纲不同
解决方案:对所有维度进行归一化处理(如0-100分),确保各维度在图形上的权重一致。
总结
本教程全面讲解了面积图与雷达图的各类实现:
面积图:基础面积填充、渐变效果、折线叠加
堆叠面积图:多系列堆叠、总量展示、各部分贡献
百分比堆叠:占比变化展示、消除总量差异
雷达图:多维数据展示、多边形绘制、网格和轴线
多维度对比:多系列叠加、颜色区分、交互高亮
填充透明度:半透明填充、渐变效果、重叠可辨识
交互效果:十字准线、tooltip、悬停高亮
面积图和雷达图是展示趋势量级和多维对比的重要工具,掌握其实现对于构建综合数据分析应用至关重要。
常见问题
什么是面积图与雷达图?
面积图与雷达图是HTML5开发中的重要技术,本教程详细介绍了其核心概念和实践方法。
如何学习面积图与雷达图的实际应用?
教程中提供了完整的代码示例和实践指导,建议结合示例代码动手练习。
面积图与雷达图有哪些注意事项?
常见注意事项包括兼容性、性能优化等,教程的注意事项与最佳实践部分有详细说明。
面积图与雷达图适合初学者吗?
本教程从基础概念讲起,循序渐进,适合有一定HTML和JavaScript基础的初学者。
面积图与雷达图的核心要点是什么?
核心要点包括教程简介等内容,建议按教程顺序逐步学习。
本文由小确幸生活整理发布,转载请注明出处
本文涉及AI创作
内容由AI创作,请仔细甄别