804 lines
28 KiB
Plaintext
804 lines
28 KiB
Plaintext
<template>
|
||
<view class="ak-charts-container">
|
||
<canvas :id="canvasId" :canvas-id="canvasId" class="ak-charts-canvas"></canvas>
|
||
</view>
|
||
</template>
|
||
|
||
<script lang="uts">
|
||
import type { ChartSeries,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 方法
|
||
toChartOption(obj: UTSJSONObject): ChartOption {
|
||
// 支持 series
|
||
if (Array.isArray(obj.series)) {
|
||
return {
|
||
type: obj.type as ChartType,
|
||
series: Array.isArray(obj.series) ? (obj.series as ChartSeries[]) : null,
|
||
labels: Array.isArray(obj.labels) ? (obj.labels as string[]) : []
|
||
}
|
||
}
|
||
return {
|
||
type: obj.type as ChartType,
|
||
data: Array.isArray(obj.data) ? (obj.data as number[]) : [],
|
||
labels: Array.isArray(obj.labels) ? (obj.labels as string[]) : [],
|
||
color: typeof obj.color === 'string' ? obj.color as string : ''
|
||
}
|
||
},
|
||
drawChart(option: ChartOption) {
|
||
console.log('now drawing',option)
|
||
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 === '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);
|
||
}
|
||
},
|
||
drawBar(ctx: CanvasRenderingContext2D, option: ChartOption) {
|
||
const data = option.data != null ? option.data : ([] as number[]);
|
||
const color = option.color != null ? option.color : '#2196f3';
|
||
const labels = option.labels != null ? option.labels : ([] as string[]);
|
||
if (data == null || data.length == null || 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;
|
||
|
||
// 找出最大值,确保图表高度合适
|
||
const max = Math.max(...(data as number[]), 1);
|
||
|
||
// 调整条形宽度,确保条形之间有合适的间距
|
||
const barWidth = (chartWidth / (data.length as number)) * 0.7; // 条形宽度占用70%的可用空间
|
||
const spacing = (chartWidth / (data.length as number)) * 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();
|
||
|
||
// 绘制数据条形
|
||
data.forEach((v, i) => {
|
||
// 计算条形位置
|
||
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) {
|
||
// 支持 series
|
||
if (option.series != null) {
|
||
const labels = option.labels != null ? option.labels : ([] as string[]);
|
||
// 计算画布尺寸、margin、chartWidth/chartHeight、max
|
||
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;
|
||
option.series?.forEach(s => {
|
||
if (s.data != null && s.data.length > 0) {
|
||
const m = Math.max(...(s.data as number[]));
|
||
if (m > max) max = m;
|
||
}
|
||
});
|
||
// 定义默认颜色
|
||
const defaultColors = [
|
||
'#4caf50', '#f44336', '#2196f3', '#ff9800', '#9c27b0', '#009688'
|
||
];
|
||
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();
|
||
// 绘制每条折线
|
||
option.series?.forEach((s, si) => {
|
||
const color = s.color != null ? s.color : defaultColors[si % defaultColors.length];
|
||
ctx.beginPath();
|
||
ctx.strokeStyle = color as any;
|
||
ctx.lineWidth = 2;
|
||
if (s.data != null && s.data.length > 0) {
|
||
const len = s.data.length;
|
||
s.data.forEach((v, i) => {
|
||
const denom = (len - 1) !== 0 ? (len - 1) : 1;
|
||
const x = margin.left + (chartWidth / denom) * i;
|
||
const y = height - margin.bottom - (v / max) * chartHeight;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
});
|
||
}
|
||
ctx.stroke();
|
||
// 绘制点
|
||
if (s.data != null && s.data.length > 0) {
|
||
const len = s.data.length;
|
||
s.data.forEach((v, i) => {
|
||
const denom = (len - 1) !== 0 ? (len - 1) : 1;
|
||
const x = margin.left + (chartWidth / denom) * 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.restore();
|
||
return;
|
||
}
|
||
// ...原有单组数据逻辑
|
||
const data = option.data != null ? option.data : ([] as number[]);
|
||
const color = option.color != null ? option.color : '#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;
|
||
|
||
// 找出最大值,确保图表高度合适
|
||
const max = Math.max(...data!!, 1);
|
||
|
||
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;
|
||
|
||
data.forEach((v, i) => {
|
||
// 计算点的位置
|
||
const x = margin.left + (chartWidth / (data.length - 1 | 1)) * i;
|
||
const y = height - margin.bottom - (v / max) * chartHeight;
|
||
|
||
if (i === 0) {
|
||
ctx.moveTo(x, y);
|
||
} else {
|
||
ctx.lineTo(x, y);
|
||
}
|
||
});
|
||
|
||
// 先把线画出来
|
||
ctx.stroke();
|
||
|
||
// 然后再单独绘制数据点和标签
|
||
data.forEach((v, i) => {
|
||
const x = margin.left + (chartWidth / (data.length - 1 | 1)) * 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();
|
||
},
|
||
drawPie(ctx: CanvasRenderingContext2D, option: ChartOption) {
|
||
const data = option.data != null ? option.data : ([] as number[]);
|
||
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];
|
||
}
|
||
|
||
// 默认颜色数组,如果没有提供足够的颜色
|
||
const defaultColors = [
|
||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
|
||
'#FF9F40', '#8AC249', '#EA526F', '#49A6B7', '#C5D86D'
|
||
];
|
||
|
||
ctx.save();
|
||
|
||
// 绘制饼图
|
||
let startAngle: Double = (-Math.PI / 2) as Double; // 从12点钟方向开始
|
||
|
||
data!!.forEach((value, 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; // 标签位于半径的70%处
|
||
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;
|
||
|
||
data.forEach((value, 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 = option.data != null ? option.data : ([] as number[]);
|
||
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;
|
||
|
||
// 环形图特有的内圆半径,默认为外圆半径的60%
|
||
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];
|
||
}
|
||
|
||
// 默认颜色数组
|
||
const defaultColors = [
|
||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
|
||
'#FF9F40', '#8AC249', '#EA526F', '#49A6B7', '#C5D86D'
|
||
];
|
||
|
||
ctx.save();
|
||
|
||
// 绘制环形图
|
||
let startAngle: Double = (-Math.PI / 2) as Double; // 从12点钟方向开始
|
||
|
||
data!!.forEach((value, 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;
|
||
|
||
data.forEach((value, 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 = option.data != null ? option.data : ([] as number[]);
|
||
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);
|
||
|
||
// 找出最大值,用于归一化数据
|
||
const max = Math.max(...data, 1);
|
||
|
||
// 计算每个角的角度
|
||
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();
|
||
},
|
||
|
||
// 辅助函数:根据基础颜色和色调偏移生成新颜色
|
||
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: center;
|
||
align-items: center;
|
||
}
|
||
.ak-charts-canvas {
|
||
width: 600rpx;
|
||
height: 400rpx;
|
||
background: #ffff7f;
|
||
border-radius: 12rpx;
|
||
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
|
||
}
|
||
</style>
|