/** * 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;