Initial commit
This commit is contained in:
803
uni_modules/ak-charts/components/ak-charts.uvue
Normal file
803
uni_modules/ak-charts/components/ak-charts.uvue
Normal file
@@ -0,0 +1,803 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user