1231 lines
43 KiB
Plaintext
1231 lines
43 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 { 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>
|