Files
akmon/uni_modules/ak-charts/components/ak-charts.uvue
2026-01-20 08:04:15 +08:00

1231 lines
43 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="ak-charts-container">
<canvas :id="canvasId" :canvas-id="canvasId" class="ak-charts-canvas"></canvas>
</view>
</template>
<script lang="uts">
import type { ChartOption,ChartType,Margin } from '../interface.uts';
export default {
name: 'AkCharts',
props: {
option: {
type: UTSJSONObject, // 修正为 Object避免类型错误
required: true
},
canvasId: {
type: String,
default: 'ak-charts-canvas'
}
},
data() {
return {
canvas: null as UniCanvasElement | null,
canvasContext: null as CanvasContext | null,
renderingContext: null as CanvasRenderingContext2D | null,
dpr: 1,
ctxReady: false,
canvasWidth: 0,
canvasHeight: 0
}
},
watch: {
option: {
handler() {
// 类型检查,确保 option 是对象且有 type/data 字段
if (
this.ctxReady ) {
let aopt:ChartOption = this.toChartOption(this.option)
this.drawChart(aopt);
}
},
deep: true,
immediate: true
}
},
mounted() {
console.log('ak charts')
this.dpr = typeof uni.getWindowInfo === "function" ? uni.getWindowInfo().pixelRatio : 1;
this.$nextTick(() => {
console.log(this.option)
let aopt:ChartOption = this.toChartOption(this.option)
uni.createCanvasContextAsync({
id: this.canvasId,
component: this,
success: (context: CanvasContext) => {
this.canvasContext = context;
this.renderingContext = context.getContext('2d');
this.canvas = this.renderingContext!.canvas;
this.hidpi(this.canvas!);
this.canvasWidth = this.canvas!.width;
this.canvasHeight = this.canvas!.height;
this.ctxReady = true;
this.drawChart(aopt);
}
});
});
uni.$on('ak-charts-render', (payload: any) => {
let option: ChartOption | null = null;
let canvasId: string = '';
let isValid = false;
// 尝试将 payload 转为 UTSJSONObject 处理
let utsPayload: UTSJSONObject | null = null;
try {
// UTSJSONObject 通常有 get 方法
if (payload != null ) {
utsPayload = payload as UTSJSONObject;
}
} catch (e) {
utsPayload = null;
}
if (utsPayload!=null) {
const opt = utsPayload.get('option');
const cid = utsPayload.get('canvasId');
if (opt != null && cid != null) {
option = opt as ChartOption;
canvasId = cid as string;
isValid = true;
}
}
if (isValid && canvasId === this.canvasId && this.ctxReady) {
this.drawChart(option!);
}
});
},
methods: {
hidpi(canvas: UniCanvasElement) {
const context = canvas.getContext("2d")!;
const dpr = this.dpr;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
context.scale(dpr, dpr);
},
toChartOption(obj: UTSJSONObject): ChartOption {
// 确保必需的 type 属性存在且有效
let chartType: ChartType = 'line' // 默认值
const typeValue = obj.get('type')
if (typeValue != null && typeof typeValue === 'string') {
chartType = typeValue as ChartType
}
// 支持 multi 类型或 data 为数组的数组
const rawData = obj.get('data')
let data: number[] | number[][] = [] as number[]
const isMulti = rawData != null && Array.isArray(rawData) && rawData.length > 0 && Array.isArray(rawData[0])
if (isMulti) {
data = rawData as number[][]
} else if (rawData != null && Array.isArray(rawData)) {
data = rawData as number[]
}
// 处理 labels
let labels: string[] = [] as string[]
const labelsValue = obj.get('labels')
if (labelsValue != null && Array.isArray(labelsValue)) {
labels = labelsValue as string[]
}
// 处理 color
let color: string | string[] = ''
const colorValue = obj.get('color')
if (colorValue != null) {
if (typeof colorValue === 'string') {
color = colorValue as string
} else if (Array.isArray(colorValue)) {
color = (colorValue as any[]).map((c) => typeof c === 'string' ? c : `${c}`)
}
}
// 处理多系列元数据
let seriesNames: string[] | null = null
const namesValue = obj.get('seriesNames')
if (namesValue != null && Array.isArray(namesValue)) {
seriesNames = (namesValue as any[]).map((name) => typeof name === 'string' ? name : `${name}`)
}
let seriesAxis: string[] | null = null
const axisValue = obj.get('seriesAxis')
if (axisValue != null && Array.isArray(axisValue)) {
seriesAxis = (axisValue as any[]).map((axis) => typeof axis === 'string' ? axis : `${axis}`)
}
function normalizeValue(value: any): number {
if (typeof value === 'number') return value
if (typeof value === 'string') {
const parsed = parseFloat(value as string)
return isNaN(parsed) ? 0 : parsed
}
return 0
}
let normalizedData: number[] | number[][] = [] as number[]
if (isMulti) {
const seriesArray = Array.isArray(data) ? (data as any[]) : []
normalizedData = seriesArray.map((seriesItem) => {
if (Array.isArray(seriesItem)) {
return (seriesItem as any[]).map((num) => normalizeValue(num))
}
return [] as number[]
})
} else {
const singleArray = Array.isArray(data) ? (data as any[]) : []
normalizedData = singleArray.map((num) => normalizeValue(num))
}
let result: ChartOption = {
type: isMulti ? 'multi' : chartType,
data: normalizedData,
labels: labels,
color: color,
seriesNames: seriesNames as string[] | null,
seriesAxis: seriesAxis as string[] | null
}
return result
},
drawChart(option: ChartOption) {
console.log('now drawing')
if (this.ctxReady != true || this.renderingContext == null) return;
if (option == null || option.data == null) {
return;
}
const ctx = this.renderingContext!;
ctx.clearRect(0, 0, this.canvasWidth / this.dpr, this.canvasHeight / this.dpr);
if (option.type === 'multi') {
this.drawMulti(ctx, option);
} else if (option.type === 'bar') {
this.drawBar(ctx, option);
} else if (option.type === 'line') {
this.drawLine(ctx, option);
} else if (option.type === 'pie') {
this.drawPie(ctx, option);
} else if (option.type === 'doughnut') {
this.drawDoughnut(ctx, option);
} else if (option.type === 'radar') {
this.drawRadar(ctx, option); } else if (option.type === 'area') {
this.drawArea(ctx, option);
} else if (option.type === 'horizontalBar') {
this.drawHorizontalBar(ctx, option);
}
},
normalizeSeriesData(series: any): number[] {
if (!Array.isArray(series)) return [];
const result: number[] = [];
for (let i = 0; i < series.length; i++) {
const value = series[i];
let num = 0;
if (typeof value === 'number') {
num = value;
} else if (typeof value === 'string') {
const parsed = parseFloat(value);
num = isNaN(parsed) ? 0 : parsed;
}
result.push(num);
}
return result;
},
getSingleSeries(option: ChartOption): number[] {
const raw = option.data;
if (raw != null) {
const rawArray = raw as any[];
if (rawArray.length > 0 && Array.isArray(rawArray[0])) {
return this.normalizeSeriesData(rawArray[0] as number[]);
}
return this.normalizeSeriesData(rawArray as number[]);
}
return [];
},
getMultiSeries(option: ChartOption): number[][] {
const raw = option.data;
const result: number[][] = [];
if (raw != null) {
const rawArray = raw as any[];
for (let i = 0; i < rawArray.length; i++) {
const item = rawArray[i];
if (Array.isArray(item)) {
result.push(this.normalizeSeriesData(item as number[]));
}
}
}
return result;
},
drawBar(ctx: CanvasRenderingContext2D, option: ChartOption) {
const data = this.getSingleSeries(option);
const colorValue = option.color != null ? option.color : '#2196f3';
const color: string = typeof colorValue === 'string' ? colorValue : '#2196f3';
const labels = option.labels != null ? option.labels : ([] as string[]);
if (data.length === 0) return;
// 使用实际画布尺寸而不是固定值
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
const margin: Margin = {
top: 20,
right: 20,
bottom: 40,
left: 40
};
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// 找出最大值,确保图表高度合适
let max = 1;
for (let i = 0; i < data.length; i++) {
const value = data[i];
if (!isNaN(value) && value > max) {
max = value;
}
}
// 调整条形宽度,确保条形之间有合适的间距
const barWidth = (chartWidth / data.length) * 0.7; // 条形宽度占用70%的可用空间
const spacing = (chartWidth / data.length) * 0.3; // 剩余30%用作间距
ctx.save();
// 绘制Y轴
ctx.beginPath();
ctx.strokeStyle = '#ccc';
ctx.moveTo(margin.left, margin.top);
ctx.lineTo(margin.left, height - margin.bottom);
ctx.stroke();
// 绘制X轴
ctx.beginPath();
ctx.moveTo(margin.left, height - margin.bottom);
ctx.lineTo(width - margin.right, height - margin.bottom);
ctx.stroke();
// 绘制数据条形
for (let i = 0; i < data.length; i++) {
const v = data[i] as number;
// 计算条形位置
const x = margin.left + (chartWidth / data.length) * i + spacing / 2;
const valueHeight = (v / max) * chartHeight;
const y = height - margin.bottom - valueHeight;
// 使用指定颜色填充条形 - 修复类型错误
ctx.fillStyle = color as any;
ctx.fillRect(x, y, barWidth, valueHeight);
// 在条形上方显示数值
ctx.font = '12px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'center';
ctx.fillText(v.toString(), x + barWidth / 2, y - 5);
// 显示标签
if (labels != null && labels.length > i && labels[i] != null && labels[i] != '') {
ctx.font = '14px sans-serif';
ctx.fillStyle = '#666' as any;
ctx.textAlign = 'center';
ctx.fillText(labels[i] as string, x + barWidth / 2, height - margin.bottom + 20);
}
};
ctx.restore();
},
drawLine(ctx: CanvasRenderingContext2D, option: ChartOption) {
const data = this.getSingleSeries(option);
const colorValue = option.color != null ? option.color : '#2196f3';
const color: string = typeof colorValue === 'string' ? colorValue : '#2196f3';
const labels = option.labels != null ? option.labels : ([] as string[]);
if (data.length === 0) return;
// 使用实际画布尺寸而不是固定值
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
const margin: Margin = {
top: 20,
right: 20,
bottom: 40,
left: 40
};
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// 找出最大值,确保图表高度合适
let max = 1;
for (let i = 0; i < data.length; i++) {
const value = data[i];
if (!isNaN(value) && value > max) {
max = value;
}
}
ctx.save();
// 绘制Y轴
ctx.beginPath();
ctx.strokeStyle = '#ccc' as any;
ctx.moveTo(margin.left, margin.top);
ctx.lineTo(margin.left, height - margin.bottom);
ctx.stroke();
// 绘制X轴
ctx.beginPath();
ctx.strokeStyle = '#ccc' as any;
ctx.moveTo(margin.left, height - margin.bottom);
ctx.lineTo(width - margin.right, height - margin.bottom);
ctx.stroke();
// 绘制线图主体 - 先完成折线的绘制
ctx.beginPath();
ctx.strokeStyle = color as any;
ctx.lineWidth = 2;
for (let i = 0; i < data.length; i++) {
const v = data[i];
// 计算点的位置
const divisor = data.length > 1 ? data.length - 1 : 1;
const x = margin.left + (chartWidth / divisor) * i;
const y = height - margin.bottom - (v / max) * chartHeight;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
// 先把线画出来
ctx.stroke();
// 然后再单独绘制数据点和标签
for (let i = 0; i < data.length; i++) {
const v = data[i];
const divisor = data.length > 1 ? data.length - 1 : 1;
const x = margin.left + (chartWidth / divisor) * i;
const y = height - margin.bottom - (v / max) * chartHeight;
// 在数据点上绘制圆点
ctx.beginPath();
ctx.fillStyle = color as any;
ctx.arc(x, y, 4, 0, 2 * Math.PI);
ctx.fill();
// 在点上方显示数值
ctx.font = '12px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'center';
ctx.fillText(v.toString(), x, y - 10);
// 显示标签
if (labels != null && labels.length > i && labels[i] != null && labels[i] != '') {
ctx.font = '14px sans-serif';
ctx.fillStyle = '#666' as any;
ctx.textAlign = 'center';
ctx.fillText(labels[i] as string, x, height - margin.bottom + 20);
}
}
ctx.restore();
},
drawMulti(ctx: CanvasRenderingContext2D, option: ChartOption) {
const seriesArr = this.getMultiSeries(option);
if (seriesArr.length === 0) return;
const labels = option.labels != null ? option.labels : ([] as string[])
const colors = Array.isArray(option.color) ? (option.color as string[]) : ([] as string[])
// 使用实际画布尺寸
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
const margin: Margin = { top: 40, right: 20, bottom: 50, left: 60 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// 计算左右轴的最大值(忽略 null/undefined支持按 seriesAxis 指定轴
const seriesAxis: string[] = Array.isArray(option['seriesAxis']) ? option['seriesAxis'] as string[] : []
let leftMax = 1
let rightMax = 1
for (let s = 0; s < seriesArr.length; s++) {
const arr = seriesArr[s]
const axis = seriesAxis[s] === 'right' ? 'right' : 'left'
for (let i = 0; i < arr.length; i++) {
const v = (arr[i] as number)
if (isNaN(v)) continue
if (axis === 'right') rightMax = Math.max(rightMax, v)
else leftMax = Math.max(leftMax, v)
}
}
ctx.save();
// 绘制坐标轴与网格线
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1;
// y axis line
ctx.beginPath(); ctx.moveTo(margin.left, margin.top); ctx.lineTo(margin.left, height - margin.bottom); ctx.stroke();
// x axis line
ctx.beginPath(); ctx.moveTo(margin.left, height - margin.bottom); ctx.lineTo(width - margin.right, height - margin.bottom); ctx.stroke();
// 绘制 y 轴刻度和水平网格线 (5 ticks)
const ticks = 5
ctx.font = '12px sans-serif'; ctx.fillStyle = '#666' as any; ctx.textAlign = 'right';
for (let t = 0; t <= ticks; t++) {
// left axis
const vL = (leftMax * (t / ticks))
const yL = height - margin.bottom - (vL / leftMax) * chartHeight
// grid line (shared)
ctx.beginPath(); ctx.strokeStyle = '#eee'; ctx.moveTo(margin.left, yL); ctx.lineTo(width - margin.right, yL); ctx.stroke();
// left tick label
const labelL = Math.round(vL).toString()
ctx.fillText(labelL, margin.left - 8, yL + 4)
// right axis labels drawn later after determining right axis position
}
const series0Length = seriesArr[0] != null ? seriesArr[0].length : 0
const n = Math.max(series0Length, labels.length)
const divisor = n - 1 > 0 ? n - 1 : 1
// 为每个 series 绘制折线
for (let s = 0; s < seriesArr.length; s++) {
const data = seriesArr[s] != null ? seriesArr[s] : ([] as number[])
const color = colors.length > s ? colors[s] : (typeof option.color === 'string' ? (option.color as string) : '#000')
const axis = seriesAxis[s] === 'right' ? 'right' : 'left'
const axisMax = axis === 'right' ? rightMax : leftMax
ctx.beginPath(); ctx.strokeStyle = color as any; ctx.lineWidth = 2;
for (let i = 0; i < data.length; i++) {
const v = (data[i] as number)
if (isNaN(v)) continue
const x = margin.left + (chartWidth / divisor) * i
const y = height - margin.bottom - (v / axisMax) * chartHeight
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y)
}
ctx.stroke()
// 绘制点
for (let i = 0; i < data.length; i++) {
const v = data[i] as number
if (isNaN(v)) continue
const x = margin.left + (chartWidth / divisor) * i
const axis = seriesAxis[s] === 'right' ? 'right' : 'left'
const axisMax = axis === 'right' ? rightMax : leftMax
const y = height - margin.bottom - (v / axisMax) * chartHeight
ctx.beginPath(); ctx.fillStyle = color as any; ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
}
}
// 绘制 labels共享
for (let i = 0; i < labels.length; i++) {
const label = labels[i]
if (label == null || label == '') continue
const x = margin.left + (chartWidth / divisor) * i
ctx.font = '12px sans-serif'; ctx.fillStyle = '#666' as any; ctx.textAlign = 'center';
ctx.fillText(label as string, x, height - margin.bottom + 20)
}
// 绘制右侧 Y 轴刻度(如果存在 rightMax
if (rightMax > 1) {
ctx.textAlign = 'left'; ctx.fillStyle = '#666' as any; ctx.font = '12px sans-serif';
for (let t = 0; t <= ticks; t++) {
const vR = Math.round(rightMax * (t / ticks))
const yR = height - margin.bottom - ( (rightMax * (t / ticks)) / rightMax) * chartHeight
ctx.fillText(vR.toString(), width - margin.right + 8, yR + 4)
}
}
// 绘制图例(在上方 margin 区域)
const seriesNames = option['seriesNames'] != null && Array.isArray(option['seriesNames']) ? (option['seriesNames'] as string[]) : []
const legendX = margin.left
let legendY = 16 // 在顶部区域
const legendGap = 12
ctx.textAlign = 'left'; ctx.font = '12px sans-serif'; ctx.fillStyle = '#333' as any;
for (let s = 0; s < seriesArr.length; s++) {
const name = seriesNames[s] != null ? (seriesNames[s] as string) : (`Series ${s+1}` as string)
const color = colors.length > s ? colors[s] : (typeof option.color === 'string' ? (option.color as string) : '#000')
// color box
ctx.fillStyle = color as any; ctx.fillRect(legendX + s * 110, legendY - 8, 12, 12)
// name
ctx.fillStyle = '#333' as any; ctx.fillText(name as string, legendX + s * 110 + 18, legendY + 2)
}
ctx.restore();
},
drawPie(ctx: CanvasRenderingContext2D, option: ChartOption) {
const data = this.getSingleSeries(option);
const labels = option.labels != null ? option.labels : ([] as string[]);
if (data.length === 0) return;
// 使用实际画布尺寸
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
// 计算饼图中心和半径
let centerXValue: number | null = option.centerX;
const centerX: number = centerXValue != null ? centerXValue : width / 2;
let centerYValue: number | null = option.centerY;
const centerY: number = centerYValue != null ? centerYValue : height / 2;
let radiusValue: number | null = option.radius;
const radius: number = radiusValue != null ? radiusValue : Math.min(width, height) / 2 - 40;
// 计算数据总和,用于计算每个扇区的角度
let sum = 0;
for (let i = 0; i < data.length; i++) {
sum += data[i];
}
if (sum <= 0) sum = 1;
// 默认颜色数组,如果没有提供足够的颜色
const defaultColors = [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
'#FF9F40', '#8AC249', '#EA526F', '#49A6B7', '#C5D86D'
];
ctx.save();
// 绘制饼图
let startAngle: Double = (-Math.PI / 2) as Double; // 从12点钟方向开始
for (let i = 0; i < data.length; i++) {
const value = data[i];
const sliceAngle = 2 * Math.PI * (value / sum);
// 确定扇区颜色
let color: string = '';
if (Array.isArray(option.color)) {
color = option.color.length > i ? option.color[i] : defaultColors[i % defaultColors.length];
} else if (typeof option.color === 'string') {
color = i === 0 ? option.color : this.shiftHue(option.color as string, i * 30);
} else {
color = defaultColors[i % defaultColors.length];
}
// 绘制扇区
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = color as any;
ctx.fill();
// 绘制扇区边框
ctx.strokeStyle = '#fff' as any;
ctx.lineWidth = 2;
ctx.stroke();
// 计算标签位置 - 在扇区中间
const labelAngle = startAngle + sliceAngle / 2;
const labelRadius = radius * 0.7;
const labelX = centerX + Math.cos(labelAngle) * labelRadius;
const labelY = centerY + Math.sin(labelAngle) * labelRadius;
// 显示数值
const percentage = Math.round((value / sum) * 100);
ctx.font = 'bold 14px sans-serif';
ctx.fillStyle = '#fff' as any;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${percentage}%`, labelX, labelY);
startAngle += sliceAngle as Double;
}
// 绘制图例
if (labels != null && labels.length > 0) {
const legendX = centerX - radius - 10;
const legendY = centerY + radius + 20;
for (let i = 0; i < data.length; i++) {
const value = data[i];
if (labels != null && i < labels.length && labels[i] != null && labels[i] != '') {
let color: string = '';
if (Array.isArray(option.color)) {
color = option.color.length > i ? option.color[i] : defaultColors[i % defaultColors.length];
} else if (typeof option.color === 'string') {
color = i === 0 ? option.color : this.shiftHue(option.color as string, i * 30);
} else {
color = defaultColors[i % defaultColors.length];
}
ctx.fillStyle = color as any;
ctx.fillRect(legendX, legendY + i * 25, 15, 15);
const percentage = Math.round((value / sum) * 100);
ctx.font = '14px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`${labels[i]} (${percentage}%)`, legendX + 25, legendY + i * 25 + 7.5);
}
}
}
ctx.restore();
},
drawDoughnut(ctx: CanvasRenderingContext2D, option: ChartOption) {
const data = this.getSingleSeries(option);
const labels = option.labels != null ? option.labels : ([] as string[]);
if (data.length === 0) return;
// 使用实际画布尺寸
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
// 计算环形图中心和半径
let centerXValue: number | null = option.centerX;
const centerX: number = centerXValue != null ? centerXValue : width / 2;
let centerYValue: number | null = option.centerY;
const centerY: number = centerYValue != null ? centerYValue : height / 2;
let radiusValue: number | null = option.radius;
const outerRadius: number = radiusValue != null ? radiusValue : Math.min(width, height) / 2 - 40;
let innerRadiusValue: number | null = option.innerRadius;
const innerRadius: number = innerRadiusValue != null ? innerRadiusValue : outerRadius * 0.6;
// 计算数据总和
let sum = 0;
for (let i = 0; i < data.length; i++) {
sum += data[i];
}
if (sum <= 0) sum = 1;
// 默认颜色数组
const defaultColors = [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
'#FF9F40', '#8AC249', '#EA526F', '#49A6B7', '#C5D86D'
];
ctx.save();
let startAngle: Double = (-Math.PI / 2) as Double;
for (let i = 0; i < data.length; i++) {
const value = data[i];
const sliceAngle = 2 * Math.PI * (value / sum);
let color: string = '';
if (Array.isArray(option.color)) {
color = option.color.length > i ? option.color[i] : defaultColors[i % defaultColors.length];
} else if (typeof option.color === 'string') {
color = i === 0 ? option.color : this.shiftHue(option.color as string, i * 30);
} else {
color = defaultColors[i % defaultColors.length];
}
ctx.beginPath();
ctx.arc(centerX, centerY, outerRadius, startAngle, startAngle + sliceAngle);
ctx.arc(centerX, centerY, innerRadius, startAngle + sliceAngle, startAngle, true);
ctx.closePath();
ctx.fillStyle = color as any;
ctx.fill();
ctx.strokeStyle = '#fff' as any;
ctx.lineWidth = 2;
ctx.stroke();
const labelAngle = startAngle + sliceAngle / 2;
const labelRadius = (outerRadius + innerRadius) / 2;
const labelX = centerX + Math.cos(labelAngle) * labelRadius;
const labelY = centerY + Math.sin(labelAngle) * labelRadius;
const percentage = Math.round((value / sum) * 100);
ctx.font = 'bold 14px sans-serif';
ctx.fillStyle = '#fff' as any;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${percentage}%`, labelX, labelY);
startAngle += sliceAngle as Double;
}
if (labels != null && labels.length > 0) {
const legendX = centerX - outerRadius - 10;
const legendY = centerY + outerRadius + 20;
for (let i = 0; i < data.length; i++) {
const value = data[i];
if (labels != null && i < labels.length && labels[i] != null && labels[i] != '') {
let color: string = '';
if (Array.isArray(option.color)) {
color = option.color.length > i ? option.color[i] : defaultColors[i % defaultColors.length];
} else if (typeof option.color === 'string') {
color = i === 0 ? option.color : this.shiftHue(option.color as string, i * 30);
} else {
color = defaultColors[i % defaultColors.length];
}
ctx.fillStyle = color as any;
ctx.fillRect(legendX, legendY + i * 25, 15, 15);
const percentage = Math.round((value / sum) * 100);
ctx.font = '14px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`${labels[i]} (${percentage}%)`, legendX + 25, legendY + i * 25 + 7.5);
}
}
}
ctx.restore();
},
drawRadar(ctx: CanvasRenderingContext2D, option: ChartOption) {
const data = this.getSingleSeries(option);
const labels = option.labels != null ? option.labels : ([] as string[]);
if (data.length === 0 || (labels != null && labels.length === 0)) return;
// 使用实际画布尺寸
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
// 计算雷达图中心和半径
let centerXValue: number | null = option.centerX;
const centerX: number = centerXValue != null ? centerXValue : width / 2;
let centerYValue: number | null = option.centerY;
const centerY: number = centerYValue != null ? centerYValue : height / 2;
let radiusValue: number | null = option.radius;
const radius: number = radiusValue != null ? radiusValue : Math.min(width, height) / 2 - 60;
// 计算数据点数量即多边形的边数至少为3
const count = Math.max(data.length, 3);
// 找出最大值,用于归一化数据
let max = 1;
for (let i = 0; i < data.length; i++) {
const value = data[i];
if (!isNaN(value) && value > max) {
max = value;
}
}
// 计算每个角的角度
const angleStep = (2 * Math.PI) / count;
// 确定颜色
let color: string = '';
if (typeof option.color === 'string') {
color = option.color;
} else {
color = '#36A2EB';
}
ctx.save();
// 绘制雷达图网格和轴线
for (let level = 1; level <= 5; level++) {
const levelRadius = radius * (level / 5);
// 绘制多边形轮廓
ctx.beginPath();
for (let i = 0; i < count; i++) {
const angle = -Math.PI / 2 + i * angleStep;
const x = centerX + levelRadius * Math.cos(angle);
const y = centerY + levelRadius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.strokeStyle = '#ddd' as any;
ctx.lineWidth = 1;
ctx.stroke();
}
// 绘制轴线
for (let i = 0; i < count; i++) {
const angle = -Math.PI / 2 + i * angleStep;
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.lineTo(x, y);
ctx.strokeStyle = '#ddd' as any;
ctx.lineWidth = 1;
ctx.stroke();
// 绘制标签
const labelRadius = radius + 15;
const labelX = centerX + labelRadius * Math.cos(angle);
const labelY = centerY + labelRadius * Math.sin(angle);
ctx.font = '14px sans-serif';
ctx.fillStyle = '#666' as any;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 根据标签位置调整对齐方式,使标签不超出画布
if (angle === -Math.PI / 2) { // 顶部
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
} else if (angle === Math.PI / 2) { // 底部
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
} else if (angle === 0) { // 右侧
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
} else if (Math.abs(angle) === Math.PI) { // 左侧
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
} else if (angle > -Math.PI / 2 && angle < 0) { // 右上
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
} else if (angle > 0 && angle < Math.PI / 2) { // 右下
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
} else if (angle > Math.PI / 2 && angle < Math.PI) { // 左下
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
} else if (angle > -Math.PI && angle < -Math.PI / 2) { // 左上
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
}
if (labels != null && i < labels.length && labels[i] != null) {
ctx.fillText(labels[i] as string, labelX, labelY);
}
}
// 绘制数据区域
ctx.beginPath();
for (let i = 0; i < count; i++) {
const value = i < data.length ? data[i] : 0;
const valueRadius = (value / max) * radius;
const angle = -Math.PI / 2 + i * angleStep;
const x = centerX + valueRadius * Math.cos(angle);
const y = centerY + valueRadius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
// 绘制数据点
ctx.fillStyle = color as any;
ctx.beginPath();
ctx.arc(x, y, 4, 0, 2 * Math.PI);
ctx.fill();
// 显示数值
const textRadius = valueRadius + 15;
const textX = centerX + valueRadius * Math.cos(angle);
const textY = centerY + valueRadius * Math.sin(angle);
ctx.font = '12px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(value.toString(), textX, textY - 10);
}
// 闭合并填充数据区域
ctx.closePath();
ctx.fillStyle = `${color}33` as any; // 添加透明度
ctx.fill();
// 描边
ctx.strokeStyle = color as any;
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
},
drawArea(ctx: CanvasRenderingContext2D, option: ChartOption) {
const data = this.getSingleSeries(option);
const colorValue = option.color != null ? option.color : '#6366F1';
const color: string = typeof colorValue === 'string' ? colorValue : '#6366F1';
const labels = option.labels != null ? option.labels : ([] as string[]);
if (data.length === 0) return;
// 使用实际画布尺寸
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
const margin: Margin = {
top: 20,
right: 20,
bottom: 40,
left: 40
};
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// 找出最大值,确保图表高度合适
let max = 1;
for (let i = 0; i < data.length; i++) {
const value = data[i];
if (!isNaN(value) && value > max) {
max = value;
}
}
ctx.save();
// 绘制Y轴
ctx.beginPath();
ctx.strokeStyle = '#ddd' as any;
ctx.moveTo(margin.left, margin.top);
ctx.lineTo(margin.left, height - margin.bottom);
ctx.stroke();
// 绘制X轴
ctx.beginPath();
ctx.strokeStyle = '#ddd' as any;
ctx.moveTo(margin.left, height - margin.bottom);
ctx.lineTo(width - margin.right, height - margin.bottom);
ctx.stroke();
// 绘制网格线
for (let i = 0; i <= 4; i++) {
const y = margin.top + (chartHeight / 4) * i;
ctx.beginPath();
ctx.strokeStyle = '#f0f0f0' as any;
ctx.lineWidth = 1;
ctx.moveTo(margin.left, y);
ctx.lineTo(width - margin.right, y);
ctx.stroke();
// Y轴标签
const value = (max * (4 - i) / 4).toFixed(0);
ctx.font = '12px sans-serif';
ctx.fillStyle = '#666' as any;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(value, margin.left - 5, y);
}
// 创建面积图路径
ctx.beginPath();
// 从底部开始绘制
const firstX = margin.left;
const bottomY = height - margin.bottom;
ctx.moveTo(firstX, bottomY);
// 绘制到第一个数据点
const firstDataY = height - margin.bottom - (data[0] / max) * chartHeight;
ctx.lineTo(firstX, firstDataY);
const divisor = data.length > 1 ? data.length - 1 : 1;
for (let i = 1; i < data.length; i++) {
const v = data[i];
const x = margin.left + (chartWidth / divisor) * i;
const y = height - margin.bottom - (v / max) * chartHeight;
ctx.lineTo(x, y);
}
// 回到底部闭合路径
const lastX = margin.left + chartWidth;
ctx.lineTo(lastX, bottomY);
ctx.closePath();
// 填充面积
const gradient = ctx.createLinearGradient(0, margin.top, 0, height - margin.bottom);
gradient.addColorStop(0, `${color}80`); // 50% 透明度在顶部
gradient.addColorStop(1, `${color}20`); // 12% 透明度在底部
ctx.fillStyle = gradient as any;
ctx.fill();
// 绘制边界线
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const v = data[i];
const divisorLine = data.length > 1 ? data.length - 1 : 1;
const x = margin.left + (chartWidth / divisorLine) * i;
const y = height - margin.bottom - (v / max) * chartHeight;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.strokeStyle = color as any;
ctx.lineWidth = 2;
ctx.stroke();
// 绘制数据点
for (let i = 0; i < data.length; i++) {
const v = data[i];
const divisorPoints = data.length > 1 ? data.length - 1 : 1;
const x = margin.left + (chartWidth / divisorPoints) * i;
const y = height - margin.bottom - (v / max) * chartHeight;
// 数据点
ctx.beginPath();
ctx.fillStyle = color as any;
ctx.arc(x, y, 4, 0, 2 * Math.PI);
ctx.fill();
// 数据点外圈
ctx.beginPath();
ctx.strokeStyle = '#fff' as any;
ctx.lineWidth = 2;
ctx.arc(x, y, 4, 0, 2 * Math.PI);
ctx.stroke();
// 在数据点上方显示数值
ctx.font = 'bold 12px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(v.toString() + '%', x, y - 8);
// 显示标签
if (labels != null && labels.length > i && labels[i] != null && labels[i] != '') {
ctx.font = '12px sans-serif';
ctx.fillStyle = '#666' as any;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(labels[i] as string, x, height - margin.bottom + 5);
}
}
ctx.restore();
},
drawHorizontalBar(ctx: CanvasRenderingContext2D, option: ChartOption) {
const data = this.getSingleSeries(option);
const colorValue = option.color != null ? option.color : '#10B981';
const color: string = typeof colorValue === 'string' ? colorValue : '#10B981';
const labels = option.labels != null ? option.labels : ([] as string[]);
if (data.length === 0) return;
// 使用实际画布尺寸
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
const margin: Margin = {
top: 20,
right: 40,
bottom: 20,
left: 80 // 左边距增大以容纳标签
};
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// 找出最大值,确保图表宽度合适
let max = 1;
for (let i = 0; i < data.length; i++) {
const value = data[i];
if (!isNaN(value) && value > max) {
max = value;
}
}
// 调整条形高度,确保条形之间有合适的间距
const barHeight = (chartHeight / data.length) * 0.7; // 条形高度占用70%的可用空间
const spacing = (chartHeight / data.length) * 0.3; // 剩余30%用作间距
ctx.save();
// 绘制Y轴
ctx.beginPath();
ctx.strokeStyle = '#ddd' as any;
ctx.moveTo(margin.left, margin.top);
ctx.lineTo(margin.left, height - margin.bottom);
ctx.stroke();
// 绘制X轴
ctx.beginPath();
ctx.strokeStyle = '#ddd' as any;
ctx.moveTo(margin.left, height - margin.bottom);
ctx.lineTo(width - margin.right, height - margin.bottom);
ctx.stroke();
// 绘制网格线和X轴标签
for (let i = 0; i <= 4; i++) {
const x = margin.left + (chartWidth / 4) * i;
ctx.beginPath();
ctx.strokeStyle = '#f0f0f0' as any;
ctx.lineWidth = 1;
ctx.moveTo(x, margin.top);
ctx.lineTo(x, height - margin.bottom);
ctx.stroke();
// X轴标签
const value = (max * i / 4).toFixed(0);
ctx.font = '12px sans-serif';
ctx.fillStyle = '#666' as any;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(value, x, height - margin.bottom + 5);
}
// 绘制数据条形
for (let i = 0; i < data.length; i++) {
const v = data[i];
// 计算条形位置
const y = margin.top + (chartHeight / data.length) * i + spacing / 2;
const valueWidth = (v / max) * chartWidth;
const x = margin.left;
// 渐变色条形
const gradient = ctx.createLinearGradient(x, 0, x + valueWidth, 0);
gradient.addColorStop(0, color);
gradient.addColorStop(1, `${color}CC`); // 80% 透明度
// 使用渐变填充条形
ctx.fillStyle = gradient as any;
ctx.fillRect(x, y, valueWidth, barHeight);
// 条形边框
ctx.strokeStyle = color as any;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, valueWidth, barHeight);
// 在条形右侧显示数值
ctx.font = 'bold 12px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(v.toString(), x + valueWidth + 5, y + barHeight / 2);
// 显示标签
if (labels != null && labels.length > i && labels[i] != null && labels[i] != '') {
ctx.font = 'bold 12px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(labels[i] as string, margin.left - 10, y + barHeight / 2);
}
}
},
// 辅助函数:根据基础颜色和色调偏移生成新颜色
shiftHue(color: string, degree: number): string {
// 简单实现:返回默认颜色数组中的一个
const defaultColors = [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
'#FF9F40', '#8AC249', '#EA526F', '#49A6B7', '#C5D86D'
];
return defaultColors[Math.abs(degree) % defaultColors.length];
}
}
}
</script>
<style scoped>
.ak-charts-container {
width: 100%;
display: flex;
justify-content: flex-start;
align-items: center;
/* overflow properties removed as they may not be supported in uni-app x */
}
.ak-charts-canvas {
width: 1200rpx;
height: 500rpx;
background: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
}
</style>