pin_drop当前位置:知识文库 ❯ 图文
数据可视化:散点图与气泡图 - 从入门到实践详解
教程简介
散点图是展示两个变量之间关系的经典图表类型,每个数据点在二维平面上的位置由其X和Y值决定。气泡图是散点图的扩展,通过第三个维度(气泡大小)编码额外的数据变量,实现三维数据的可视化。本教程将全面讲解散点图与气泡图的实现,包括散点图绘制、气泡图(三维度数据)、趋势线、聚类展示、大数据量优化和交互选择,帮助你掌握这类图表的完整实现能力。
核心概念
散点图的用途
相关性分析:观察两个变量之间是否存在相关关系(正相关、负相关、无相关)
异常值检测:识别偏离主体分布的数据点
聚类发现:观察数据是否自然形成分组
分布观察:了解数据在二维空间中的分布特征
散点图与气泡图的区别
趋势线
趋势线(回归线)用于量化两个变量之间的关系:
线性回归: y = ax + b
代码示例
function linearRegression(points) {
const n = points.length;
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
points.forEach(p => {
sumX += p.x; sumY += p.y;
sumXY += p.x * p.y; sumX2 += p.x * p.x;
});
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
return { slope, intercept };
}大数据量优化
当数据点超过数千个时,需要采用优化策略:
数据采样:随机采样或分层采样减少渲染点数
像素合并:将重叠的像素区域合并为一个点
WebGL渲染:使用GPU加速渲染
分层渲染:缩放时逐步加载详细数据
四叉树索引:加速空间查询和碰撞检测
语法与用法
散点图绘制核心逻辑
代码示例
function drawScatterPlot(ctx, data, config) {
const { margin, plotWidth, plotHeight, xScale, yScale } = config;
ctx.save();
ctx.translate(margin.left, margin.top);
// 绘制网格和坐标轴
drawGrid(ctx, plotWidth, plotHeight);
// 绘制数据点
data.forEach(d => {
const x = xScale(d.x);
const y = plotHeight - yScale(d.y);
ctx.beginPath();
ctx.arc(x, y, config.pointRadius, 0, Math.PI * 2);
ctx.fillStyle = d.color || config.defaultColor;
ctx.fill();
});
ctx.restore();
}气泡图绘制核心逻辑
代码示例
function drawBubbleChart(ctx, data, config) {
const { margin, plotWidth, plotHeight, xScale, yScale, sizeScale } = config;
// 按大小排序,大的先绘制(在底层)
const sorted = [...data].sort((a, b) => b.size - a.size);
sorted.forEach(d => {
const x = xScale(d.x);
const y = plotHeight - yScale(d.y);
const r = sizeScale(d.size);
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = d.color + '80'; // 半透明
ctx.fill();
ctx.strokeStyle = d.color;
ctx.lineWidth = 1.5;
ctx.stroke();
});
}代码示例
示例1:散点图与趋势线
以下示例实现散点图,包含线性回归趋势线、R平方值和交互提示。
代码示例
<!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: 900px;
}
.chart-title { text-align: center; color: #2c3e50; font-size: 20px; margin-bottom: 5px; }
.chart-subtitle { text-align: center; color: #999; font-size: 13px; margin-bottom: 15px; }
canvas { width: 100%; cursor: crosshair; }
.stats-bar {
display: flex;
justify-content: center;
gap: 30px;
margin-top: 15px;
flex-wrap: wrap;
}
.stat-item { font-size: 13px; color: #666; }
.stat-value { font-weight: 700; color: #2c3e50; }
</style>
</head>
<body>
<div class="chart-container">
<h2 class="chart-title">学习时间与考试成绩关系</h2>
<p class="chart-subtitle">散点图 + 线性回归趋势线</p>
<canvas id="scatterChart"></canvas>
<div class="stats-bar" id="statsBar"></div>
</div>
<script>
// 生成模拟数据
const scatterData = [];
for (let i = 0; i < 80; i++) {
const hours = Math.random() * 10 + 0.5;
const score = 30 + hours * 6 + (Math.random() - 0.5) * 20;
scatterData.push({
x: Math.round(hours * 10) / 10,
y: Math.round(Math.max(0, Math.min(100, score))),
group: score > 70 ? '优秀' : score > 50 ? '良好' : '待提升'
});
}
const groupColors = { '优秀': '#4e79a7', '良好': '#f28e2b', '待提升': '#e15759' };
const canvas = document.getElementById('scatterChart');
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.parentElement.clientWidth - 60;
const displayH = Math.min(450, displayW * 0.55);
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: 50, left: 55 };
const pw = displayW - margin.left - margin.right;
const ph = displayH - margin.top - margin.bottom;
const xMin = 0, xMax = 12, yMin = 0, yMax = 100;
const xScale = v => (v - xMin) / (xMax - xMin) * pw;
const yScale = v => (v - yMin) / (yMax - yMin) * ph;
let hoveredPoint = null;
// 线性回归
function linearReg(data) {
const n = data.length;
let sx = 0, sy = 0, sxy = 0, sx2 = 0, sy2 = 0;
data.forEach(d => { sx += d.x; sy += d.y; sxy += d.x * d.y; sx2 += d.x * d.x; sy2 += d.y * d.y; });
const slope = (n * sxy - sx * sy) / (n * sx2 - sx * sx);
const intercept = (sy - slope * sx) / n;
const r = (n * sxy - sx * sy) / Math.sqrt((n * sx2 - sx * sx) * (n * sy2 - sy * sy));
return { slope, intercept, r, rSquared: r * r };
}
const reg = linearReg(scatterData);
function drawChart() {
ctx.clearRect(0, 0, displayW, displayH);
ctx.save();
ctx.translate(margin.left, margin.top);
// 网格
for (let i = 0; i <= 6; i++) {
const v = yMax * i / 6;
const y = ph - yScale(v);
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(v, -8, y);
}
for (let i = 0; i <= 6; i++) {
const v = xMax * i / 6;
const x = xScale(v);
ctx.fillStyle = '#999'; ctx.font = '11px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(v + 'h', x, ph + 10);
}
// 趋势线
const trendY1 = reg.slope * xMin + reg.intercept;
const trendY2 = reg.slope * xMax + reg.intercept;
ctx.beginPath();
ctx.moveTo(xScale(xMin), ph - yScale(trendY1));
ctx.lineTo(xScale(xMax), ph - yScale(trendY2));
ctx.strokeStyle = '#e1575980';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.stroke();
ctx.setLineDash([]);
// 置信区间(简化)
ctx.beginPath();
const band = 8;
ctx.moveTo(xScale(xMin), ph - yScale(trendY1) - band);
ctx.lineTo(xScale(xMax), ph - yScale(trendY2) - band);
ctx.lineTo(xScale(xMax), ph - yScale(trendY2) + band);
ctx.lineTo(xScale(xMin), ph - yScale(trendY1) + band);
ctx.closePath();
ctx.fillStyle = '#e1575910';
ctx.fill();
// 数据点
scatterData.forEach((d, i) => {
const x = xScale(d.x);
const y = ph - yScale(d.y);
const isHovered = hoveredPoint === i;
const r = isHovered ? 7 : 5;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = isHovered ? groupColors[d.group] : groupColors[d.group] + 'aa';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = isHovered ? 2 : 1;
ctx.stroke();
});
// Tooltip
if (hoveredPoint !== null) {
const d = scatterData[hoveredPoint];
const x = xScale(d.x);
const y = ph - yScale(d.y);
const text = `学习: ${d.x}h 成绩: ${d.y}分 ${d.group}`;
ctx.font = '12px sans-serif';
const tw = ctx.measureText(text).width + 16;
let tx = x - tw / 2;
tx = Math.max(0, Math.min(pw - tw, tx));
const ty = y - 35;
ctx.fillStyle = 'rgba(44,62,80,0.9)';
ctx.beginPath();
ctx.roundRect(tx, ty, tw, 24, 4);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, tx + tw / 2, ty + 12);
}
// 坐标轴
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, ph); ctx.lineTo(pw, ph); ctx.stroke();
// 轴标题
ctx.fillStyle = '#666'; ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('每日学习时间(小时)', pw / 2, ph + 35);
ctx.save();
ctx.translate(-40, ph / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('考试成绩(分)', 0, 0);
ctx.restore();
// 图例
Object.entries(groupColors).forEach(([name, color], i) => {
const lx = pw - 150 + i * 55;
ctx.beginPath(); ctx.arc(lx, -10, 4, 0, Math.PI * 2);
ctx.fillStyle = color + 'aa'; ctx.fill();
ctx.fillStyle = '#666'; ctx.font = '11px sans-serif';
ctx.textAlign = 'left'; ctx.fillText(name, lx + 8, -6);
});
ctx.restore();
}
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (displayW / rect.width) - margin.left;
const my = (e.clientY - rect.top) * (displayH / rect.height) - margin.top;
let found = null;
let minDist = 15;
scatterData.forEach((d, i) => {
const dx = xScale(d.x) - mx;
const dy = (ph - yScale(d.y)) - my;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < minDist) { minDist = dist; found = i; }
});
if (found !== hoveredPoint) {
hoveredPoint = found;
drawChart();
}
});
canvas.addEventListener('mouseleave', () => {
hoveredPoint = null;
drawChart();
});
// 统计信息
document.getElementById('statsBar').innerHTML = `
<div class="stat-item">样本数: <span class="stat-value">${scatterData.length}</span></div>
<div class="stat-item">回归方程: <span class="stat-value">y = ${reg.slope.toFixed(2)}x + ${reg.intercept.toFixed(2)}</span></div>
<div class="stat-item">R² = <span class="stat-value">${reg.rSquared.toFixed(4)}</span></div>
<div class="stat-item">相关系数 r = <span class="stat-value">${reg.r.toFixed(4)}</span></div>
`;
drawChart();
</script>
</body>
</html>示例2:气泡图(三维度数据)
以下示例实现气泡图,用X轴、Y轴和气泡大小分别编码三个维度的数据。
代码示例
<!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-title { text-align: center; color: #2c3e50; font-size: 20px; margin-bottom: 5px; }
.chart-subtitle { text-align: center; color: #999; font-size: 13px; margin-bottom: 15px; }
canvas { width: 100%; cursor: pointer; }
.size-legend {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
margin-top: 15px;
font-size: 12px;
color: #888;
}
.size-circle {
border-radius: 50%;
border: 1px solid #ccc;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<div class="chart-container">
<h2 class="chart-title">各国经济数据气泡图</h2>
<p class="chart-subtitle">X轴: 人均GDP | Y轴: 预期寿命 | 气泡大小: 人口</p>
<canvas id="bubbleChart"></canvas>
<div class="size-legend">
人口:
<div class="size-circle" style="width:12px;height:12px;"> </div> 1亿
<div class="size-circle" style="width:22px;height:22px;"> </div> 5亿
<div class="size-circle" style="width:34px;height:34px;"> </div> 14亿
</div>
</div>
<script>
const countryData = [
{ name: '中国', gdp: 12500, life: 78.2, pop: 1412, color: '#e15759', continent: '亚洲' },
{ name: '美国', gdp: 76300, life: 77.2, pop: 332, color: '#4e79a7', continent: '北美' },
{ name: '日本', gdp: 33800, life: 84.8, pop: 125, color: '#f28e2b', continent: '亚洲' },
{ name: '德国', gdp: 51300, life: 81.7, pop: 84, color: '#59a14f', continent: '欧洲' },
{ name: '英国', gdp: 46300, life: 81.8, pop: 67, color: '#76b7b2', continent: '欧洲' },
{ name: '印度', gdp: 2400, life: 70.8, pop: 1408, color: '#edc948', continent: '亚洲' },
{ name: '巴西', gdp: 8900, life: 75.9, pop: 214, color: '#b07aa1', continent: '南美' },
{ name: '俄罗斯', gdp: 12100, life: 73.2, pop: 144, color: '#ff9da7', continent: '欧洲' },
{ name: '澳大利亚', gdp: 64400, life: 83.5, pop: 26, color: '#9c755f', continent: '大洋洲' },
{ name: '韩国', gdp: 32200, life: 83.7, pop: 52, color: '#bab0ac', continent: '亚洲' },
{ name: '法国', gdp: 42300, life: 82.5, pop: 68, color: '#4e79a7', continent: '欧洲' },
{ name: '加拿大', gdp: 52000, life: 82.4, pop: 38, color: '#f28e2b', continent: '北美' },
{ name: '尼日利亚', gdp: 2100, life: 55.4, pop: 218, color: '#e15759', continent: '非洲' },
{ name: '印尼', gdp: 4600, life: 72.3, pop: 274, color: '#59a14f', continent: '亚洲' }
];
const canvas = document.getElementById('bubbleChart');
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.parentElement.clientWidth - 60;
const displayH = Math.min(480, displayW * 0.55);
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: 55, left: 60 };
const pw = displayW - margin.left - margin.right;
const ph = displayH - margin.top - margin.bottom;
const xMin = 0, xMax = 80000;
const yMin = 50, yMax = 90;
const xScale = v => (v - xMin) / (xMax - xMin) * pw;
const yScale = v => (v - yMin) / (yMax - yMin) * ph;
const maxPop = Math.max(...countryData.map(d => d.pop));
const sizeScale = v => Math.sqrt(v / maxPop) * 35 + 5;
let hoveredBubble = null;
function drawChart() {
ctx.clearRect(0, 0, displayW, displayH);
ctx.save();
ctx.translate(margin.left, margin.top);
// 网格
for (let i = 0; i <= 8; i++) {
const v = yMin + (yMax - yMin) * i / 8;
const y = ph - yScale(v);
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(v, -8, y);
}
[0, 20000, 40000, 60000, 80000].forEach(v => {
const x = xScale(v);
ctx.fillStyle = '#999'; ctx.font = '11px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText((v / 1000) + 'K', x, ph + 10);
});
// 按大小排序(大的先画)
const sorted = [...countryData].sort((a, b) => b.pop - a.pop);
sorted.forEach((d, i) => {
const x = xScale(d.gdp);
const y = ph - yScale(d.life);
const r = sizeScale(d.pop);
const isHovered = hoveredBubble === countryData.indexOf(d);
if (isHovered) {
ctx.shadowColor = 'rgba(0,0,0,0.2)';
ctx.shadowBlur = 10;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = isHovered ? d.color + 'cc' : d.color + '55';
ctx.fill();
ctx.strokeStyle = d.color;
ctx.lineWidth = isHovered ? 2.5 : 1.5;
ctx.stroke();
ctx.shadowColor = 'transparent';
// 国家名标签
if (r > 15 || isHovered) {
ctx.fillStyle = isHovered ? '#333' : '#666';
ctx.font = (isHovered ? 'bold ' : '') + '10px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(d.name, x, y);
}
});
// Tooltip
if (hoveredBubble !== null) {
const d = countryData[hoveredBubble];
const x = xScale(d.gdp);
const y = ph - yScale(d.life);
const lines = [
d.name,
`人均GDP: $${d.gdp.toLocaleString()}`,
`预期寿命: ${d.life}岁`,
`人口: ${d.pop > 100 ? (d.pop / 100).toFixed(1) + '亿' : d.pop + '百万'}`
];
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 = x + sizeScale(d.pop) + 10;
if (tx + tw > pw) tx = x - sizeScale(d.pop) - tw - 10;
const ty = Math.max(5, y - th / 2);
ctx.fillStyle = 'rgba(44,62,80,0.92)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 6); ctx.fill();
lines.forEach((line, li) => {
ctx.fillStyle = li === 0 ? '#fff' : '#ccc';
ctx.font = li === 0 ? 'bold 12px sans-serif' : '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(line, tx + 10, ty + 14 + 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.fillStyle = '#666'; ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('人均GDP(美元)', pw / 2, ph + 38);
ctx.save();
ctx.translate(-45, ph / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('预期寿命(岁)', 0, 0);
ctx.restore();
ctx.restore();
}
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (displayW / rect.width) - margin.left;
const my = (e.clientY - rect.top) * (displayH / rect.height) - margin.top;
let found = null;
// 从小到大检测(小的在上层)
const sorted = [...countryData].sort((a, b) => a.pop - b.pop);
for (const d of sorted) {
const x = xScale(d.gdp);
const y = ph - yScale(d.life);
const r = sizeScale(d.pop);
const dx = mx - x, dy = my - y;
if (dx * dx + dy * dy <= r * r) {
found = countryData.indexOf(d);
}
}
if (found !== hoveredBubble) {
hoveredBubble = found;
drawChart();
}
});
canvas.addEventListener('mouseleave', () => {
hoveredBubble = null;
drawChart();
});
drawChart();
</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: 10px; }
.controls {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.ctrl-btn {
padding: 8px 18px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
color: #666;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.ctrl-btn.active { background: #4e79a7; color: #fff; border-color: #4e79a7; }
.chart-container {
background: #fff;
border-radius: 16px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
max-width: 900px;
margin: 0 auto;
}
canvas { width: 100%; cursor: crosshair; }
.perf-info {
text-align: center;
margin-top: 10px;
font-size: 13px;
color: #888;
}
</style>
</head>
<body>
<h1>大数据量散点图优化</h1>
<div class="controls">
<button class="ctrl-btn" onclick="setDataCount(1000)">1,000点</button>
<button class="ctrl-btn active" onclick="setDataCount(5000)">5,000点</button>
<button class="ctrl-btn" onclick="setDataCount(20000)">20,000点</button>
<button class="ctrl-btn" onclick="setDataCount(50000)">50,000点</button>
</div>
<div class="chart-container">
<canvas id="bigScatter"></canvas>
<div class="perf-info" id="perfInfo"></div>
</div>
<script>
let dataCount = 5000;
let pointData = [];
let renderMode = 'dots'; // 'dots' | 'density'
function generateData(count) {
const data = [];
// 生成3个聚类
const clusters = [
{ cx: 25, cy: 60, spread: 12 },
{ cx: 65, cy: 35, spread: 15 },
{ cx: 50, cy: 75, spread: 10 }
];
for (let i = 0; i < count; i++) {
const cluster = clusters[Math.floor(Math.random() * 3)];
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * cluster.spread;
data.push({
x: cluster.cx + Math.cos(angle) * dist + (Math.random() - 0.5) * 5,
y: cluster.cy + Math.sin(angle) * dist + (Math.random() - 0.5) * 5,
cluster: clusters.indexOf(cluster)
});
}
return data;
}
const clusterColors = ['#4e79a7', '#e15759', '#59a14f'];
const canvas = document.getElementById('bigScatter');
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.parentElement.clientWidth - 50;
const displayH = Math.min(450, displayW * 0.55);
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: 20, right: 20, bottom: 40, left: 50 };
const pw = displayW - margin.left - margin.right;
const ph = displayH - margin.top - margin.bottom;
function drawChart() {
const startTime = performance.now();
ctx.clearRect(0, 0, displayW, displayH);
ctx.save();
ctx.translate(margin.left, margin.top);
// 网格
for (let i = 0; i <= 5; i++) {
const y = ph * i / 5;
ctx.strokeStyle = '#f0f0f0'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke();
ctx.fillStyle = '#999'; ctx.font = '10px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(100 - 20 * i, -6, y);
}
// 根据数据量选择渲染策略
if (pointData.length <= 10000) {
// 直接绘制
pointData.forEach(d => {
const x = (d.x / 100) * pw;
const y = (1 - d.y / 100) * ph;
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, Math.PI * 2);
ctx.fillStyle = clusterColors[d.cluster] + '99';
ctx.fill();
});
} else {
// 像素合并:使用离屏Canvas降采样
const offCanvas = document.createElement('canvas');
offCanvas.width = pw;
offCanvas.height = ph;
const offCtx = offCanvas.getContext('2d');
// 按聚类分批绘制
clusterColors.forEach((color, ci) => {
offCtx.beginPath();
pointData.filter(d => d.cluster === ci).forEach(d => {
const x = (d.x / 100) * pw;
const y = (1 - d.y / 100) * ph;
offCtx.moveTo(x + 2, y);
offCtx.arc(x, y, 2, 0, Math.PI * 2);
});
offCtx.fillStyle = color + '88';
offCtx.fill();
});
ctx.drawImage(offCanvas, 0, 0);
}
// 坐标轴
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, ph); ctx.lineTo(pw, ph); ctx.stroke();
ctx.restore();
const elapsed = performance.now() - startTime;
document.getElementById('perfInfo').textContent =
`数据量: ${pointData.length.toLocaleString()} | 渲染耗时: ${elapsed.toFixed(1)}ms | 模式: ${pointData.length <= 10000 ? '直接绘制' : '批量路径'}`;
}
function setDataCount(count) {
dataCount = count;
pointData = generateData(count);
document.querySelectorAll('.ctrl-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
drawChart();
}
pointData = generateData(dataCount);
drawChart();
</script>
</body>
</html>浏览器兼容性
注意事项与最佳实践
气泡大小映射:使用面积(而非半径)与数据值成正比,确保视觉感知准确。
重叠处理:气泡图数据点容易重叠,使用半透明填充和边框帮助区分。
绘制顺序:大气泡先绘制(底层),小气泡后绘制(上层),确保小气泡可见。
趋势线选择:根据数据特征选择线性或非线性回归,不要盲目使用线性趋势线。
大数据量优化:超过1万个点时考虑批量路径绘制或像素合并。
交互优化:大数据量时使用空间索引(四叉树)加速命中检测。
轴起始值:散点图轴可以不从零开始,根据数据范围调整。
颜色编码:使用颜色编码第四维度时,确保色阶可区分。
代码规范示例
代码示例
// 好的做法:散点图配置
const scatterConfig = {
point: {
radius: 4,
hoverRadius: 7,
opacity: 0.7,
hoverOpacity: 1.0
},
bubble: {
minRadius: 5,
maxRadius: 40,
sizeEncoding: 'area', // 'area' 或 'radius'
opacity: 0.5
},
trendLine: {
enabled: true,
type: 'linear', // 'linear' | 'polynomial' | 'exponential'
showConfidence: true,
confidenceLevel: 0.95
},
performance: {
batchSize: 10000, // 超过此数量启用批量渲染
useQuadTree: true // 启用四叉树加速交互
}
};
// 好的做法:气泡大小映射(面积正比)
function mapBubbleSize(value, minVal, maxVal, minR, maxR) {
const area = (value - minVal) / (maxVal - minVal) * (Math.PI * maxR * maxR - Math.PI * minR * minR) + Math.PI * minR * minR;
return Math.sqrt(area / Math.PI);
}常见问题与解决方案
问题1:气泡图重叠严重
解决方案:使用半透明填充、调整气泡间距、提供缩放功能、或使用力导向布局避免重叠。
问题2:大数据量散点图卡顿
解决方案:使用批量路径绘制(单个beginPath + 多个arc + 单次fill)、离屏Canvas、WebGL渲染。
问题3:趋势线不准确
原因:数据存在异常值或非线性关系。
解决方案:使用鲁棒回归(如RANSAC)处理异常值,或使用多项式/局部加权回归处理非线性关系。
问题4:命中检测慢
解决方案:构建四叉树空间索引,只检测鼠标附近的点。
总结
本教程全面讲解了散点图与气泡图的各类实现:
散点图绘制:数据点渲染、颜色编码、交互提示
气泡图:三维度数据编码、大小映射、重叠处理
趋势线:线性回归、置信区间、R平方值
聚类展示:颜色区分不同聚类
大数据量优化:批量路径绘制、离屏Canvas、像素合并
交互选择:悬停高亮、tooltip、命中检测
散点图和气泡图是探索性数据分析的核心工具,掌握其实现对于构建数据分析和可视化应用至关重要。
常见问题
什么是散点图与气泡图?
散点图与气泡图是HTML5开发中的重要技术,本教程详细介绍了其核心概念和实践方法。
如何学习散点图与气泡图的实际应用?
教程中提供了完整的代码示例和实践指导,建议结合示例代码动手练习。
散点图与气泡图有哪些注意事项?
常见注意事项包括兼容性、性能优化等,教程的注意事项与最佳实践部分有详细说明。
散点图与气泡图适合初学者吗?
本教程从基础概念讲起,循序渐进,适合有一定HTML和JavaScript基础的初学者。
散点图与气泡图的核心要点是什么?
核心要点包括教程简介等内容,建议按教程顺序逐步学习。
本文由小确幸生活整理发布,转载请注明出处
本文涉及AI创作
内容由AI创作,请仔细甄别