You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
560 lines
16 KiB
560 lines
16 KiB
/**
|
|
* wx-chart.js v1.0.0
|
|
* 微信小程序图表库
|
|
* 简化版,支持折线图和饼图
|
|
*/
|
|
class WxChart {
|
|
constructor(options) {
|
|
this.canvasId = options.canvasId;
|
|
this.ctx = wx.createCanvasContext(this.canvasId, options.context);
|
|
this.type = options.type || 'line';
|
|
this.categories = options.categories || [];
|
|
this.series = options.series || [];
|
|
this.labels = options.labels || [];
|
|
this.colors = options.colors || this.getDefaultColors();
|
|
this.width = options.width || 300;
|
|
this.height = options.height || 200;
|
|
this.yAxis = options.yAxis || {};
|
|
this.xAxis = options.xAxis || {};
|
|
this.extra = options.extra || {};
|
|
this.format = options.format || function (val) { return val; };
|
|
|
|
// 初始化画布尺寸
|
|
this.initCanvasSize();
|
|
|
|
// 绘制图表
|
|
this.draw();
|
|
}
|
|
|
|
// 获取默认颜色
|
|
getDefaultColors() {
|
|
return [
|
|
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
|
'#EC4899', '#6366F1', '#14B8A6', '#F97316', '#64748B'
|
|
];
|
|
}
|
|
|
|
// 初始化画布尺寸
|
|
initCanvasSize() {
|
|
const query = wx.createSelectorQuery();
|
|
query.select(`#${this.canvasId}`)
|
|
.boundingClientRect()
|
|
.exec((res) => {
|
|
if (res && res[0]) {
|
|
this.width = res[0].width;
|
|
this.height = res[0].height;
|
|
this.draw();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 更新数据
|
|
updateData(data) {
|
|
if (data.categories) this.categories = data.categories;
|
|
if (data.series) this.series = data.series;
|
|
if (data.labels) this.labels = data.labels;
|
|
if (data.colors) this.colors = data.colors;
|
|
|
|
this.draw();
|
|
}
|
|
|
|
// 绘制图表
|
|
draw() {
|
|
switch (this.type) {
|
|
case 'line':
|
|
this.drawLineChart();
|
|
break;
|
|
case 'pie':
|
|
this.drawPieChart();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 绘制折线图
|
|
drawLineChart() {
|
|
const ctx = this.ctx;
|
|
const { width, height } = this;
|
|
const padding = 40; // 边距
|
|
const innerWidth = width - padding * 2;
|
|
const innerHeight = height - padding * 2;
|
|
|
|
// 清除画布
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// 绘制坐标轴
|
|
this.drawAxis(ctx, padding, innerWidth, innerHeight);
|
|
|
|
// 计算数据范围
|
|
const dataRange = this.calculateDataRange();
|
|
const yStep = innerHeight / (dataRange.max - dataRange.min);
|
|
|
|
// 计算X轴步长
|
|
const xStep = this.categories.length > 1 ? innerWidth / (this.categories.length - 1) : innerWidth;
|
|
|
|
// 绘制网格线
|
|
this.drawGridLines(ctx, padding, innerWidth, innerHeight, xStep, yStep, dataRange);
|
|
|
|
// 绘制数据线和点
|
|
this.series.forEach((series, index) => {
|
|
const color = series.color || this.colors[index % this.colors.length];
|
|
|
|
// 绘制区域填充
|
|
if (this.extra && this.extra.fill) {
|
|
this.drawAreaFill(ctx, padding, innerHeight, xStep, yStep, dataRange, series.data, color);
|
|
}
|
|
|
|
// 绘制线
|
|
this.drawLine(ctx, padding, innerHeight, xStep, yStep, dataRange, series.data, color);
|
|
|
|
// 绘制点
|
|
this.drawPoints(ctx, padding, innerHeight, xStep, yStep, dataRange, series.data, color);
|
|
});
|
|
|
|
// 绘制X轴标签
|
|
this.drawXAxisLabels(ctx, padding, innerWidth, innerHeight, xStep);
|
|
|
|
// 绘制Y轴标签
|
|
this.drawYAxisLabels(ctx, padding, innerHeight, yStep, dataRange);
|
|
|
|
// 绘制Y轴标题
|
|
if (this.yAxis.title) {
|
|
this.drawYAxisTitle(ctx, padding, innerHeight);
|
|
}
|
|
|
|
// 绘制完成
|
|
ctx.draw();
|
|
}
|
|
|
|
// 绘制饼图
|
|
drawPieChart() {
|
|
const ctx = this.ctx;
|
|
const { width, height } = this;
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
const radius = this.extra && this.extra.pie && this.extra.pie.radius || Math.min(width, height) / 3;
|
|
const offsetAngle = this.extra && this.extra.pie && this.extra.pie.offsetAngle || 0;
|
|
|
|
// 清除画布
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// 计算总和
|
|
const total = this.series.reduce((sum, value) => sum + value, 0);
|
|
let startAngle = offsetAngle * Math.PI / 180;
|
|
|
|
// 绘制饼图
|
|
this.series.forEach((value, index) => {
|
|
const percentage = value / total;
|
|
const endAngle = startAngle + percentage * 2 * Math.PI;
|
|
const color = this.colors[index % this.colors.length];
|
|
|
|
// 绘制扇形
|
|
ctx.beginPath();
|
|
ctx.setFillStyle(color);
|
|
ctx.moveTo(centerX, centerY);
|
|
ctx.arc(centerX, centerY, radius, startAngle, endAngle);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
startAngle = endAngle;
|
|
});
|
|
|
|
// 绘制中间空白
|
|
if (this.extra && this.extra.pie && this.extra.pie.holeRadius) {
|
|
const holeRadius = this.extra.pie.holeRadius;
|
|
ctx.beginPath();
|
|
ctx.setFillStyle('#ffffff');
|
|
ctx.arc(centerX, centerY, holeRadius, 0, 2 * Math.PI);
|
|
ctx.fill();
|
|
}
|
|
|
|
// 绘制完成
|
|
ctx.draw();
|
|
}
|
|
|
|
// 计算数据范围
|
|
calculateDataRange() {
|
|
let min = Infinity;
|
|
let max = -Infinity;
|
|
|
|
this.series.forEach(series => {
|
|
series.data.forEach(value => {
|
|
if (value < min) min = value;
|
|
if (value > max) max = value;
|
|
});
|
|
});
|
|
|
|
// 如果设置了Y轴最小值,使用设置的值
|
|
if (this.yAxis.min !== undefined) {
|
|
min = this.yAxis.min;
|
|
} else if (min > 0) {
|
|
min = 0; // 如果最小值大于0,从0开始
|
|
}
|
|
|
|
// 添加一些边距
|
|
const padding = (max - min) * 0.1;
|
|
return {
|
|
min: min - padding,
|
|
max: max + padding
|
|
};
|
|
}
|
|
|
|
// 绘制坐标轴
|
|
drawAxis(ctx, padding, innerWidth, innerHeight) {
|
|
ctx.beginPath();
|
|
ctx.setStrokeStyle('#e5e5e5');
|
|
ctx.setLineWidth(1);
|
|
|
|
// X轴
|
|
ctx.moveTo(padding, padding + innerHeight);
|
|
ctx.lineTo(padding + innerWidth, padding + innerHeight);
|
|
|
|
// Y轴
|
|
ctx.moveTo(padding, padding);
|
|
ctx.lineTo(padding, padding + innerHeight);
|
|
|
|
ctx.stroke();
|
|
}
|
|
|
|
// 绘制网格线
|
|
drawGridLines(ctx, padding, innerWidth, innerHeight, xStep, yStep, dataRange) {
|
|
ctx.beginPath();
|
|
ctx.setStrokeStyle('#f0f0f0');
|
|
ctx.setLineWidth(1);
|
|
|
|
// 垂直网格线
|
|
if (!this.xAxis.disableGrid) {
|
|
this.categories.forEach((category, index) => {
|
|
const x = padding + index * xStep;
|
|
ctx.moveTo(x, padding);
|
|
ctx.lineTo(x, padding + innerHeight);
|
|
});
|
|
}
|
|
|
|
// 水平网格线 (5条)
|
|
for (let i = 0; i <= 5; i++) {
|
|
const y = padding + innerHeight - (i * innerHeight / 5);
|
|
ctx.moveTo(padding, y);
|
|
ctx.lineTo(padding + innerWidth, y);
|
|
}
|
|
|
|
ctx.stroke();
|
|
}
|
|
|
|
// 绘制区域填充
|
|
drawAreaFill(ctx, padding, innerHeight, xStep, yStep, dataRange, data, color) {
|
|
ctx.beginPath();
|
|
ctx.setFillStyle(color.replace('rgb', 'rgba').replace(')', ', 0.1)'));
|
|
|
|
// 起点
|
|
ctx.moveTo(padding, padding + innerHeight);
|
|
|
|
// 绘制数据点
|
|
data.forEach((value, index) => {
|
|
const x = padding + index * xStep;
|
|
const y = padding + innerHeight - (value - dataRange.min) * yStep;
|
|
ctx.lineTo(x, y);
|
|
});
|
|
|
|
// 终点
|
|
ctx.lineTo(padding + (data.length - 1) * xStep, padding + innerHeight);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
|
|
// 绘制线
|
|
drawLine(ctx, padding, innerHeight, xStep, yStep, dataRange, data, color) {
|
|
ctx.beginPath();
|
|
ctx.setStrokeStyle(color);
|
|
ctx.setLineWidth(2);
|
|
|
|
data.forEach((value, index) => {
|
|
const x = padding + index * xStep;
|
|
const y = padding + innerHeight - (value - dataRange.min) * yStep;
|
|
|
|
if (index === 0) {
|
|
ctx.moveTo(x, y);
|
|
} else {
|
|
// 支持曲线
|
|
if (this.extra && this.extra.lineStyle === 'curve' && index > 0 && index < data.length - 1) {
|
|
const nextX = padding + (index + 1) * xStep;
|
|
const nextY = padding + innerHeight - (data[index + 1] - dataRange.min) * yStep;
|
|
ctx.quadraticCurveTo(x, y, (x + nextX) / 2, (y + nextY) / 2);
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
});
|
|
|
|
ctx.stroke();
|
|
}
|
|
|
|
// 绘制点
|
|
drawPoints(ctx, padding, innerHeight, xStep, yStep, dataRange, data, color) {
|
|
data.forEach((value, index) => {
|
|
const x = padding + index * xStep;
|
|
const y = padding + innerHeight - (value - dataRange.min) * yStep;
|
|
|
|
// 绘制外圆
|
|
ctx.beginPath();
|
|
ctx.setFillStyle(color);
|
|
ctx.arc(x, y, 4, 0, 2 * Math.PI);
|
|
ctx.fill();
|
|
|
|
// 绘制内圆
|
|
ctx.beginPath();
|
|
ctx.setFillStyle('#ffffff');
|
|
ctx.arc(x, y, 2, 0, 2 * Math.PI);
|
|
ctx.fill();
|
|
});
|
|
}
|
|
|
|
// 绘制X轴标签
|
|
drawXAxisLabels(ctx, padding, innerWidth, innerHeight, xStep) {
|
|
ctx.setFontSize(12);
|
|
ctx.setFillStyle('#666666');
|
|
ctx.setTextAlign('center');
|
|
ctx.setTextBaseline('top');
|
|
|
|
this.categories.forEach((category, index) => {
|
|
const x = padding + index * xStep;
|
|
const y = padding + innerHeight + 5;
|
|
ctx.fillText(category, x, y);
|
|
});
|
|
}
|
|
|
|
// 绘制Y轴标签
|
|
drawYAxisLabels(ctx, padding, innerHeight, yStep, dataRange) {
|
|
ctx.setFontSize(12);
|
|
ctx.setFillStyle('#666666');
|
|
ctx.setTextAlign('right');
|
|
ctx.setTextBaseline('middle');
|
|
|
|
// 5个标签
|
|
for (let i = 0; i <= 5; i++) {
|
|
const value = dataRange.min + (dataRange.max - dataRange.min) * (i / 5);
|
|
const y = padding + innerHeight - (i * innerHeight / 5);
|
|
const text = this.yAxis.format ? this.yAxis.format(value) : value.toFixed(0);
|
|
|
|
ctx.fillText(text, padding - 5, y);
|
|
}
|
|
}
|
|
|
|
// 绘制Y轴标题
|
|
drawYAxisTitle(ctx, padding, innerHeight) {
|
|
ctx.setFontSize(14);
|
|
ctx.setFillStyle('#333333');
|
|
ctx.setTextAlign('center');
|
|
ctx.setTextBaseline('middle');
|
|
|
|
// 旋转文本
|
|
ctx.save();
|
|
ctx.translate(padding - 30, padding + innerHeight / 2);
|
|
ctx.rotate(-Math.PI / 2);
|
|
ctx.fillText(this.yAxis.title, 0, 0);
|
|
ctx.restore();
|
|
}
|
|
|
|
// 显示工具提示
|
|
showToolTip(e, options) {
|
|
const touch = e.touches[0];
|
|
const canvasRect = wx.createSelectorQuery().select(`#${this.canvasId}`).boundingClientRect();
|
|
|
|
canvasRect.exec((res) => {
|
|
if (!res || !res[0]) return;
|
|
|
|
const rect = res[0];
|
|
const x = touch.x - rect.left;
|
|
const y = touch.y - rect.top;
|
|
|
|
// 根据图表类型处理
|
|
if (this.type === 'line') {
|
|
this.showLineToolTip(x, y, options);
|
|
} else if (this.type === 'pie') {
|
|
this.showPieToolTip(x, y, options);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 显示折线图工具提示
|
|
showLineToolTip(x, y, options) {
|
|
const padding = 40;
|
|
const innerWidth = this.width - padding * 2;
|
|
const innerHeight = this.height - padding * 2;
|
|
const xStep = this.categories.length > 1 ? innerWidth / (this.categories.length - 1) : innerWidth;
|
|
const dataRange = this.calculateDataRange();
|
|
const yStep = innerHeight / (dataRange.max - dataRange.min);
|
|
|
|
// 找到最近的点
|
|
let closestIndex = 0;
|
|
let minDistance = Infinity;
|
|
|
|
this.categories.forEach((category, index) => {
|
|
const pointX = padding + index * xStep;
|
|
const distance = Math.abs(x - pointX);
|
|
|
|
if (distance < minDistance) {
|
|
minDistance = distance;
|
|
closestIndex = index;
|
|
}
|
|
});
|
|
|
|
// 如果距离太远,不显示
|
|
if (minDistance > 30) return;
|
|
|
|
// 准备提示信息
|
|
const category = this.categories[closestIndex];
|
|
const items = this.series.map((series, index) => {
|
|
return {
|
|
name: series.name,
|
|
data: this.format(series.data[closestIndex], series.name),
|
|
color: series.color || this.colors[index % this.colors.length]
|
|
};
|
|
});
|
|
|
|
// 显示提示
|
|
this.drawToolTip(
|
|
padding + closestIndex * xStep,
|
|
padding + innerHeight - (this.series[0].data[closestIndex] - dataRange.min) * yStep,
|
|
category,
|
|
items,
|
|
options
|
|
);
|
|
}
|
|
|
|
// 显示饼图工具提示
|
|
showPieToolTip(x, y, options) {
|
|
const centerX = this.width / 2;
|
|
const centerY = this.height / 2;
|
|
const radius = this.extra && this.extra.pie && this.extra.pie.radius || Math.min(this.width, this.height) / 3;
|
|
|
|
// 计算点击位置到中心的距离
|
|
const dx = x - centerX;
|
|
const dy = y - centerY;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
// 如果不在饼图范围内,不显示
|
|
if (distance > radius) return;
|
|
|
|
// 计算角度
|
|
let angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
|
angle = (angle + 360) % 360; // 转换为0-360度
|
|
|
|
// 计算总和和每个部分的角度
|
|
const total = this.series.reduce((sum, value) => sum + value, 0);
|
|
const offsetAngle = this.extra && this.extra.pie && this.extra.pie.offsetAngle || 0;
|
|
let startAngle = offsetAngle;
|
|
let selectedIndex = -1;
|
|
|
|
this.series.forEach((value, index) => {
|
|
const percentage = value / total;
|
|
const endAngle = startAngle + percentage * 360;
|
|
|
|
if (angle >= startAngle && angle < endAngle) {
|
|
selectedIndex = index;
|
|
}
|
|
|
|
startAngle = endAngle;
|
|
});
|
|
|
|
// 如果没有选中,不显示
|
|
if (selectedIndex === -1) return;
|
|
|
|
// 准备提示信息
|
|
const name = this.labels[selectedIndex];
|
|
const value = this.series[selectedIndex];
|
|
const items = [{
|
|
name: name,
|
|
data: this.format(value, name),
|
|
color: this.colors[selectedIndex % this.colors.length]
|
|
}];
|
|
|
|
// 显示提示
|
|
this.drawToolTip(x, y, '', items, options);
|
|
}
|
|
|
|
// 绘制工具提示
|
|
drawToolTip(x, y, title, items, options) {
|
|
const ctx = this.ctx;
|
|
const padding = 10;
|
|
const itemHeight = 20;
|
|
const titleHeight = title ? 25 : 0;
|
|
const width = 120;
|
|
const height = titleHeight + items.length * itemHeight + padding * 2;
|
|
|
|
// 计算位置(避免超出画布)
|
|
let tipX = x - width / 2;
|
|
let tipY = y - height - 10;
|
|
|
|
if (tipX < 0) tipX = 0;
|
|
if (tipX + width > this.width) tipX = this.width - width;
|
|
if (tipY < 0) tipY = y + 10;
|
|
|
|
// 重绘图表
|
|
this.draw();
|
|
|
|
// 绘制提示框背景
|
|
ctx.setFillStyle('rgba(255, 255, 255, 0.95)');
|
|
ctx.setShadowBlur(5);
|
|
ctx.setShadowColor('rgba(0, 0, 0, 0.1)');
|
|
ctx.fillRoundRect(tipX, tipY, width, height, 5);
|
|
ctx.setShadowBlur(0);
|
|
|
|
// 绘制边框
|
|
ctx.setStrokeStyle('#e5e5e5');
|
|
ctx.setLineWidth(1);
|
|
ctx.strokeRoundRect(tipX, tipY, width, height, 5);
|
|
|
|
// 绘制标题
|
|
if (title) {
|
|
ctx.setFontSize(14);
|
|
ctx.setFillStyle('#333333');
|
|
ctx.setTextAlign('center');
|
|
ctx.setTextBaseline('top');
|
|
ctx.fillText(title, tipX + width / 2, tipY + padding);
|
|
}
|
|
|
|
// 绘制项目
|
|
items.forEach((item, index) => {
|
|
const itemY = tipY + padding + titleHeight + index * itemHeight;
|
|
|
|
// 绘制颜色点
|
|
ctx.setFillStyle(item.color);
|
|
ctx.beginPath();
|
|
ctx.arc(tipX + padding + 5, itemY + 10, 4, 0, 2 * Math.PI);
|
|
ctx.fill();
|
|
|
|
// 绘制文本
|
|
ctx.setFontSize(12);
|
|
ctx.setFillStyle('#666666');
|
|
ctx.setTextAlign('left');
|
|
ctx.setTextBaseline('middle');
|
|
|
|
const text = options && options.format ?
|
|
options.format(item, title) :
|
|
`${item.name}: ${item.data}`;
|
|
|
|
ctx.fillText(text, tipX + padding + 15, itemY + 10);
|
|
});
|
|
|
|
// 绘制连接线
|
|
ctx.beginPath();
|
|
ctx.setStrokeStyle('rgba(255, 255, 255, 0.95)');
|
|
ctx.setLineWidth(2);
|
|
ctx.moveTo(x, y);
|
|
|
|
if (tipY < y - 10) {
|
|
// 提示框在上方
|
|
ctx.lineTo(x, tipY + height);
|
|
} else {
|
|
// 提示框在下方
|
|
ctx.lineTo(x, tipY);
|
|
}
|
|
|
|
ctx.stroke();
|
|
|
|
// 绘制完成
|
|
ctx.draw(true);
|
|
}
|
|
}
|
|
|
|
module.exports = WxChart;
|
|
|