记账微信小程序
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.

561 lines
16 KiB

1 week ago
/**
* 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;