transone-chart 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +181 -0
  2. package/dist/adapters/index.d.ts +7 -0
  3. package/dist/adapters/miniprogram.d.ts +44 -0
  4. package/dist/adapters/native.d.ts +28 -0
  5. package/dist/adapters/types.d.ts +27 -0
  6. package/dist/adapters/web.d.ts +13 -0
  7. package/dist/charts/bar.d.ts +25 -0
  8. package/dist/charts/index.d.ts +4 -0
  9. package/dist/charts/line.d.ts +20 -0
  10. package/dist/charts/pie.d.ts +14 -0
  11. package/dist/charts/radar.d.ts +16 -0
  12. package/dist/component.d.ts +41 -0
  13. package/dist/core/axis.d.ts +47 -0
  14. package/dist/core/canvas.d.ts +61 -0
  15. package/dist/core/chart.d.ts +69 -0
  16. package/dist/core/layout.d.ts +58 -0
  17. package/dist/core/legend.d.ts +26 -0
  18. package/dist/core/scale.d.ts +67 -0
  19. package/dist/core/text.d.ts +20 -0
  20. package/dist/factory.d.ts +22 -0
  21. package/dist/index.d.ts +29 -0
  22. package/dist/index.js +17 -0
  23. package/dist/index.js.map +49 -0
  24. package/dist/types.d.ts +180 -0
  25. package/lib/adapters/index.ts +18 -0
  26. package/lib/adapters/miniprogram.ts +146 -0
  27. package/lib/adapters/native.ts +39 -0
  28. package/lib/adapters/types.ts +44 -0
  29. package/lib/adapters/web.ts +41 -0
  30. package/lib/charts/bar.ts +270 -0
  31. package/lib/charts/index.ts +4 -0
  32. package/lib/charts/line.ts +153 -0
  33. package/lib/charts/pie.ts +110 -0
  34. package/lib/charts/radar.ts +166 -0
  35. package/lib/component.ts +149 -0
  36. package/lib/core/axis.ts +174 -0
  37. package/lib/core/canvas.ts +130 -0
  38. package/lib/core/chart.ts +322 -0
  39. package/lib/core/layout.ts +221 -0
  40. package/lib/core/legend.ts +94 -0
  41. package/lib/core/scale.ts +183 -0
  42. package/lib/core/text.ts +70 -0
  43. package/lib/factory.ts +62 -0
  44. package/lib/index.ts +69 -0
  45. package/lib/types.ts +218 -0
  46. package/package.json +55 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * 柱状图:纵向 / 横向,支持多系列分组与 stack 堆叠。
3
+ *
4
+ * 几何规则:
5
+ * - 分组:同一类目内多个系列并排,子柱宽 = 类目带宽 × (1 - gap) / 系列数 × 0.8
6
+ * - 堆叠:同名 stack 系列共用一根柱,后一个系列从前一个的顶端继续累加
7
+ * - 数值轴强制包含 0(柱形高度必须从零线起算才有意义)
8
+ */
9
+
10
+ import type { ICanvas2D } from '../core/canvas';
11
+ import { ChartBase } from '../core/chart';
12
+ import type { LayoutResult } from '../core/layout';
13
+ import type { LegendItem } from '../core/legend';
14
+ import type { BarChartOption, BarSeries } from '../types';
15
+
16
+ /** 柱体在类目带内的水平偏移与宽度(纵向)或垂直偏移与高度(横向)。 */
17
+ interface Slot {
18
+ offset: number;
19
+ size: number;
20
+ }
21
+
22
+ export class BarChart extends ChartBase<BarChartOption> {
23
+ protected isHorizontal(): boolean {
24
+ return this.option.horizontal ?? false;
25
+ }
26
+
27
+ protected getValueDomain(): { min: number; max: number } {
28
+ const { series, yAxis } = this.option;
29
+ let max = 0;
30
+ let min = 0;
31
+
32
+ // 堆叠:按 stack 组在类目上累加正负
33
+ const stackGroups = groupStacks(series);
34
+ const categoryCount = Math.max(
35
+ 0,
36
+ ...series.map((s) => s.data.length)
37
+ );
38
+
39
+ for (let i = 0; i < categoryCount; i += 1) {
40
+ for (const group of stackGroups) {
41
+ let positive = 0;
42
+ let negative = 0;
43
+ for (const s of group) {
44
+ const v = s.data[i] ?? 0;
45
+ if (v >= 0) {
46
+ positive += v;
47
+ } else {
48
+ negative += v;
49
+ }
50
+ }
51
+ if (positive > max) {
52
+ max = positive;
53
+ }
54
+ if (negative < min) {
55
+ min = negative;
56
+ }
57
+ }
58
+ }
59
+
60
+ if (yAxis?.min !== undefined) {
61
+ min = Math.min(min, yAxis.min);
62
+ }
63
+ if (yAxis?.max !== undefined) {
64
+ max = Math.max(max, yAxis.max);
65
+ }
66
+ return { min, max };
67
+ }
68
+
69
+ protected getCategoryLabels(): readonly string[] {
70
+ return this.option.xAxis.labels;
71
+ }
72
+
73
+ protected getLegendItems(): LegendItem[] {
74
+ return this.option.series
75
+ .filter((s) => s.name)
76
+ .map((s, i) => ({
77
+ name: s.name as string,
78
+ color: this.seriesColor(i, s.color),
79
+ }));
80
+ }
81
+
82
+ protected drawSeries(
83
+ ctx: ICanvas2D,
84
+ layout: LayoutResult,
85
+ option: BarChartOption
86
+ ): void {
87
+ const scales = this.cartesian;
88
+ if (!scales) {
89
+ return;
90
+ }
91
+ void layout; // 柱体几何全部由比例尺绝对坐标决定,无需 plot 引用
92
+ const { value, category } = scales;
93
+ const horizontal = option.horizontal ?? false;
94
+
95
+ const stackGroups = groupStacks(option.series);
96
+ const categoryCount = option.xAxis.labels.length;
97
+
98
+ for (let i = 0; i < categoryCount; i += 1) {
99
+ // 每个类目带内:无堆叠时按系列分组并排;有堆叠时按 stack 组并排
100
+ const groups = stackGroups;
101
+ const seriesCount = groups.length;
102
+ const explicitWidth = option.series
103
+ .map((s) => s.barWidth)
104
+ .find((w) => w !== undefined && w > 0);
105
+ const slot = this.computeSlot(category, seriesCount, explicitWidth);
106
+
107
+ groups.forEach((group, groupIndex) => {
108
+ const slotOffset = slot.offset + groupIndex * slot.size;
109
+ let cursor = 0; // 堆叠游标(绝对值累加)
110
+
111
+ group.forEach((series) => {
112
+ const raw = series.data[i] ?? 0;
113
+ const color = this.seriesColor(
114
+ option.series.indexOf(series),
115
+ series.color
116
+ );
117
+
118
+ if (horizontal) {
119
+ const x0 = value.scale(0);
120
+ const x1 = value.scale(cursor + raw);
121
+ const base = Math.min(x0, x1);
122
+ const width = Math.abs(x1 - x0);
123
+ const y = category.bandStart(i) + slotOffset;
124
+ this.drawBar(ctx, {
125
+ x: base,
126
+ y,
127
+ width,
128
+ height: slot.size,
129
+ radius: series.borderRadius ?? 0,
130
+ color,
131
+ roundSide: raw >= 0 ? 'right' : 'left',
132
+ });
133
+ } else {
134
+ const y0 = value.scale(cursor);
135
+ const y1 = value.scale(cursor + raw);
136
+ const top = Math.min(y0, y1);
137
+ const height = Math.abs(y1 - y0);
138
+ const x = category.bandStart(i) + slotOffset;
139
+ this.drawBar(ctx, {
140
+ x,
141
+ y: top,
142
+ width: slot.size,
143
+ height,
144
+ radius: series.borderRadius ?? 0,
145
+ color,
146
+ roundSide: raw >= 0 ? 'top' : 'bottom',
147
+ });
148
+ }
149
+
150
+ cursor += raw;
151
+ });
152
+ });
153
+ }
154
+ }
155
+
156
+ private computeSlot(
157
+ category: { bandStart(index: number): number; innerWidth(): number },
158
+ groupCount: number,
159
+ explicitWidth?: number
160
+ ): Slot {
161
+ if (explicitWidth !== undefined) {
162
+ // 显式柱宽:组内居中
163
+ return {
164
+ offset: (category.innerWidth() - explicitWidth) / 2,
165
+ size: explicitWidth,
166
+ };
167
+ }
168
+ const size = category.innerWidth() / Math.max(1, groupCount) * 0.8;
169
+ const gap = (category.innerWidth() - size * groupCount) / 2;
170
+ return { offset: gap, size };
171
+ }
172
+
173
+ private drawBar(
174
+ ctx: ICanvas2D,
175
+ bar: {
176
+ x: number;
177
+ y: number;
178
+ width: number;
179
+ height: number;
180
+ radius: number;
181
+ color: string;
182
+ roundSide: 'top' | 'bottom' | 'right' | 'left';
183
+ }
184
+ ): void {
185
+ if (bar.width <= 0 || bar.height <= 0) {
186
+ return;
187
+ }
188
+ ctx.save();
189
+ ctx.fillStyle = bar.color;
190
+ ctx.beginPath();
191
+ roundRectPath(ctx, bar, bar.radius, bar.roundSide);
192
+ ctx.fill();
193
+ ctx.restore();
194
+ }
195
+ }
196
+
197
+ /** 按 stack 分组:无 stack 的系列各自成组;同名 stack 合并为一组。 */
198
+ function groupStacks(series: readonly BarSeries[]): BarSeries[][] {
199
+ const groups: BarSeries[][] = [];
200
+ const stackMap = new Map<string, BarSeries[]>();
201
+
202
+ for (const s of series) {
203
+ if (s.stack) {
204
+ let group = stackMap.get(s.stack);
205
+ if (!group) {
206
+ group = [];
207
+ stackMap.set(s.stack, group);
208
+ groups.push(group);
209
+ }
210
+ group.push(s);
211
+ } else {
212
+ groups.push([s]);
213
+ }
214
+ }
215
+ return groups;
216
+ }
217
+
218
+ /**
219
+ * 圆角柱体路径:仅 roundSide 指示的一侧(两个角)圆角。
220
+ * - 纵向柱:'top' 顶部两角 / 'bottom' 底部两角
221
+ * - 横向柱:'right' 右侧两角 / 'left' 左侧两角
222
+ */
223
+ function roundRectPath(
224
+ ctx: ICanvas2D,
225
+ bar: { x: number; y: number; width: number; height: number },
226
+ radius: number,
227
+ roundSide: 'top' | 'bottom' | 'right' | 'left'
228
+ ): void {
229
+ const { x, y, width, height } = bar;
230
+ const r = Math.min(radius, width / 2, height / 2);
231
+ if (r <= 0) {
232
+ ctx.rect(x, y, width, height);
233
+ return;
234
+ }
235
+
236
+ const roundTopLeft = roundSide === 'top' || roundSide === 'left';
237
+ const roundTopRight = roundSide === 'top' || roundSide === 'right';
238
+ const roundBottomLeft = roundSide === 'bottom' || roundSide === 'left';
239
+ const roundBottomRight = roundSide === 'bottom' || roundSide === 'right';
240
+
241
+ ctx.moveTo(x + (roundTopLeft ? r : 0), y);
242
+ ctx.lineTo(x + width - (roundTopRight ? r : 0), y);
243
+ if (roundTopRight) {
244
+ ctx.arcTo(x + width, y, x + width, y + r, r);
245
+ } else {
246
+ ctx.lineTo(x + width, y);
247
+ }
248
+
249
+ ctx.lineTo(x + width, y + height - (roundBottomRight ? r : 0));
250
+ if (roundBottomRight) {
251
+ ctx.arcTo(x + width, y + height, x + width - r, y + height, r);
252
+ } else {
253
+ ctx.lineTo(x + width, y + height);
254
+ }
255
+
256
+ ctx.lineTo(x + (roundBottomLeft ? r : 0), y + height);
257
+ if (roundBottomLeft) {
258
+ ctx.arcTo(x, y + height, x, y + height - r, r);
259
+ } else {
260
+ ctx.lineTo(x, y + height);
261
+ }
262
+
263
+ ctx.lineTo(x, y + (roundTopLeft ? r : 0));
264
+ if (roundTopLeft) {
265
+ ctx.arcTo(x, y, x + r, y, r);
266
+ } else {
267
+ ctx.lineTo(x, y);
268
+ }
269
+ ctx.closePath();
270
+ }
@@ -0,0 +1,4 @@
1
+ export { LineChart } from './line';
2
+ export { BarChart } from './bar';
3
+ export { PieChart } from './pie';
4
+ export { RadarChart } from './radar';
@@ -0,0 +1,153 @@
1
+ /**
2
+ * 折线图:多系列 / 平滑曲线 / 面积填充 / 数据点标记。
3
+ * 类目 x 轴 + 数值 y 轴,复用 ChartBase 的笛卡尔管线。
4
+ */
5
+
6
+ import type { ICanvas2D } from '../core/canvas';
7
+ import { ChartBase } from '../core/chart';
8
+ import type { LayoutResult } from '../core/layout';
9
+ import type { LegendItem } from '../core/legend';
10
+ import type { LineChartOption } from '../types';
11
+
12
+ interface Point {
13
+ x: number;
14
+ y: number;
15
+ }
16
+
17
+ export class LineChart extends ChartBase<LineChartOption> {
18
+ protected getValueDomain(): { min: number; max: number } {
19
+ const { series, yAxis, startFromZero = false } = this.option;
20
+ let min = Infinity;
21
+ let max = -Infinity;
22
+ for (const s of series) {
23
+ for (const value of s.data) {
24
+ if (value < min) {
25
+ min = value;
26
+ }
27
+ if (value > max) {
28
+ max = value;
29
+ }
30
+ }
31
+ }
32
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
33
+ return { min: 0, max: 1 };
34
+ }
35
+ if (startFromZero && min > 0) {
36
+ min = 0;
37
+ }
38
+ if (yAxis?.min !== undefined) {
39
+ min = Math.min(min, yAxis.min);
40
+ }
41
+ if (yAxis?.max !== undefined) {
42
+ max = Math.max(max, yAxis.max);
43
+ }
44
+ return { min, max };
45
+ }
46
+
47
+ protected getCategoryLabels(): readonly string[] {
48
+ return this.option.xAxis.labels;
49
+ }
50
+
51
+ protected getLegendItems(): LegendItem[] {
52
+ return this.option.series
53
+ .filter((s) => s.name)
54
+ .map((s, i) => ({
55
+ name: s.name as string,
56
+ color: this.seriesColor(i, s.color),
57
+ }));
58
+ }
59
+
60
+ protected drawSeries(
61
+ ctx: ICanvas2D,
62
+ layout: LayoutResult,
63
+ option: LineChartOption
64
+ ): void {
65
+ const scales = this.cartesian;
66
+ if (!scales) {
67
+ return;
68
+ }
69
+ const { plot } = layout;
70
+ const { value, category } = scales;
71
+
72
+ const pointsPerSeries: Point[][] = option.series.map((series) =>
73
+ series.data.map((d, i) => ({
74
+ x: category.center(i),
75
+ y: value.scale(d),
76
+ }))
77
+ );
78
+
79
+ option.series.forEach((series, index) => {
80
+ const points = pointsPerSeries[index];
81
+ if (points.length === 0) {
82
+ return;
83
+ }
84
+ const color = this.seriesColor(index, series.color);
85
+ const lineWidth = series.lineWidth ?? 2;
86
+
87
+ // 面积填充(先画,保证在折线之下)
88
+ if (series.area) {
89
+ ctx.save();
90
+ ctx.fillStyle = color;
91
+ ctx.globalAlpha = 0.15;
92
+ ctx.beginPath();
93
+ this.tracePath(ctx, points, series.smooth ?? false);
94
+ ctx.lineTo(points[points.length - 1].x, plot.y + plot.height);
95
+ ctx.lineTo(points[0].x, plot.y + plot.height);
96
+ ctx.closePath();
97
+ ctx.fill();
98
+ ctx.restore();
99
+ }
100
+
101
+ // 折线
102
+ ctx.save();
103
+ ctx.strokeStyle = color;
104
+ ctx.lineWidth = lineWidth;
105
+ ctx.lineJoin = 'round';
106
+ ctx.lineCap = 'round';
107
+ ctx.beginPath();
108
+ this.tracePath(ctx, points, series.smooth ?? false);
109
+ ctx.stroke();
110
+ ctx.restore();
111
+
112
+ // 数据点标记
113
+ if (series.showSymbol !== false) {
114
+ ctx.save();
115
+ ctx.fillStyle = color;
116
+ for (const p of points) {
117
+ ctx.beginPath();
118
+ ctx.arc(p.x, p.y, Math.max(2.5, lineWidth + 0.5), 0, Math.PI * 2);
119
+ ctx.fill();
120
+ }
121
+ ctx.restore();
122
+ }
123
+ });
124
+ }
125
+
126
+ /** 折线路径:直线或 Catmull-Rom 平滑插值(三次贝塞尔)。 */
127
+ private tracePath(ctx: ICanvas2D, points: readonly Point[], smooth: boolean): void {
128
+ if (points.length === 0) {
129
+ return;
130
+ }
131
+ ctx.moveTo(points[0].x, points[0].y);
132
+ if (!smooth || points.length < 3) {
133
+ for (let i = 1; i < points.length; i += 1) {
134
+ ctx.lineTo(points[i].x, points[i].y);
135
+ }
136
+ return;
137
+ }
138
+
139
+ for (let i = 0; i < points.length - 1; i += 1) {
140
+ const p0 = points[Math.max(0, i - 1)];
141
+ const p1 = points[i];
142
+ const p2 = points[i + 1];
143
+ const p3 = points[Math.min(points.length - 1, i + 2)];
144
+
145
+ const cp1x = p1.x + (p2.x - p0.x) / 6;
146
+ const cp1y = p1.y + (p2.y - p0.y) / 6;
147
+ const cp2x = p2.x - (p3.x - p1.x) / 6;
148
+ const cp2y = p2.y - (p3.y - p1.y) / 6;
149
+
150
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
151
+ }
152
+ }
153
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 饼图 / 环形图:扇区绘制、百分比标签、图例。
3
+ * 无笛卡尔坐标轴,全部几何在绘图区内按极坐标计算。
4
+ */
5
+
6
+ import type { ICanvas2D } from '../core/canvas';
7
+ import { ChartBase } from '../core/chart';
8
+ import type { LayoutResult } from '../core/layout';
9
+ import type { LegendItem } from '../core/legend';
10
+ import { applyFont } from '../core/text';
11
+ import type { PieChartOption } from '../types';
12
+
13
+ const TWO_PI = Math.PI * 2;
14
+
15
+ export class PieChart extends ChartBase<PieChartOption> {
16
+ protected hasCartesianAxis(): boolean {
17
+ return false;
18
+ }
19
+
20
+ protected getLegendItems(): LegendItem[] {
21
+ return this.option.data.map((d, i) => ({
22
+ name: d.name,
23
+ color: d.color ?? this.seriesColor(i),
24
+ }));
25
+ }
26
+
27
+ protected drawSeries(
28
+ ctx: ICanvas2D,
29
+ layout: LayoutResult,
30
+ option: PieChartOption
31
+ ): void {
32
+ const { plot } = layout;
33
+ const centerX = plot.x + plot.width / 2;
34
+ const centerY = plot.y + plot.height / 2;
35
+
36
+ const maxRadius =
37
+ Math.min(plot.width, plot.height) / 2;
38
+ const outerRadius = resolveRadius(option.radius ?? '60%', maxRadius);
39
+ const innerRadius = resolveRadius(option.innerRadius ?? 0, maxRadius);
40
+ const startAngle = option.startAngle ?? -Math.PI / 2;
41
+
42
+ const data = option.data.filter((d) => d.value > 0);
43
+ const total = data.reduce((sum, d) => sum + d.value, 0);
44
+ if (total <= 0 || outerRadius <= 0) {
45
+ return;
46
+ }
47
+
48
+ let angle = startAngle;
49
+
50
+ data.forEach((d, index) => {
51
+ const sweep = (d.value / total) * TWO_PI;
52
+ const color = d.color ?? this.seriesColor(index);
53
+
54
+ ctx.save();
55
+ ctx.fillStyle = color;
56
+ ctx.beginPath();
57
+ if (innerRadius > 0) {
58
+ // 环形:外弧 → 内弧(反向)闭合
59
+ ctx.arc(centerX, centerY, outerRadius, angle, angle + sweep);
60
+ ctx.arc(
61
+ centerX,
62
+ centerY,
63
+ innerRadius,
64
+ angle + sweep,
65
+ angle,
66
+ true
67
+ );
68
+ } else {
69
+ ctx.arc(centerX, centerY, outerRadius, angle, angle + sweep);
70
+ ctx.lineTo(centerX, centerY);
71
+ }
72
+ ctx.closePath();
73
+ ctx.fill();
74
+ // 扇区间分隔线
75
+ ctx.strokeStyle = '#ffffff';
76
+ ctx.lineWidth = 1;
77
+ ctx.stroke();
78
+ ctx.restore();
79
+
80
+ // 扇区标签:名称 + 百分比,画在扇区角平分线上
81
+ if (option.showLabel !== false) {
82
+ const midAngle = angle + sweep / 2;
83
+ const labelRadius = (outerRadius + (innerRadius > 0 ? innerRadius : 0)) / 2 * 0.85;
84
+ const labelX = centerX + Math.cos(midAngle) * labelRadius;
85
+ const labelY = centerY + Math.sin(midAngle) * labelRadius;
86
+ const percent = total > 0 ? Math.round((d.value / total) * 100) : 0;
87
+
88
+ applyFont(ctx, option.labelFontSize ?? 10);
89
+ ctx.fillStyle = option.labelColor ?? '#ffffff';
90
+ ctx.textAlign = 'center';
91
+ ctx.textBaseline = 'middle';
92
+ ctx.fillText(`${d.name} ${percent}%`, labelX, labelY);
93
+ }
94
+
95
+ angle += sweep;
96
+ });
97
+ }
98
+ }
99
+
100
+ /** 解析半径:数字为像素;'60%' 形式按最大半径百分比。 */
101
+ function resolveRadius(value: number | string, maxRadius: number): number {
102
+ if (typeof value === 'number') {
103
+ return Math.max(0, value);
104
+ }
105
+ const match = /^(\d+(?:\.\d+)?)%$/.exec(value);
106
+ if (!match) {
107
+ return maxRadius;
108
+ }
109
+ return (Number(match[1]) / 100) * maxRadius;
110
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 雷达图:多边形网格 + 指标轴 + 多系列区域/折线。
3
+ * 指标刻度按各 indicator.max(缺省取所有系列该指标最大值)归一化。
4
+ */
5
+
6
+ import type { ICanvas2D } from '../core/canvas';
7
+ import { ChartBase } from '../core/chart';
8
+ import type { LayoutResult } from '../core/layout';
9
+ import type { LegendItem } from '../core/legend';
10
+ import { applyFont } from '../core/text';
11
+ import type { RadarChartOption } from '../types';
12
+
13
+ interface PolarPoint {
14
+ x: number;
15
+ y: number;
16
+ }
17
+
18
+ export class RadarChart extends ChartBase<RadarChartOption> {
19
+ protected hasCartesianAxis(): boolean {
20
+ return false;
21
+ }
22
+
23
+ protected getLegendItems(): LegendItem[] {
24
+ return this.option.series
25
+ .filter((s) => s.name)
26
+ .map((s, i) => ({
27
+ name: s.name as string,
28
+ color: this.seriesColor(i, s.color),
29
+ }));
30
+ }
31
+
32
+ protected drawSeries(
33
+ ctx: ICanvas2D,
34
+ layout: LayoutResult,
35
+ option: RadarChartOption
36
+ ): void {
37
+ const { plot } = layout;
38
+ const centerX = plot.x + plot.width / 2;
39
+ const centerY = plot.y + plot.height / 2;
40
+ const radius =
41
+ option.radius ?? Math.min(plot.width, plot.height) / 2 * 0.6;
42
+ const count = option.indicators.length;
43
+ if (count === 0 || radius <= 0) {
44
+ return;
45
+ }
46
+
47
+ const startAngle = option.startAngle ?? -Math.PI / 2;
48
+ const step = TWO_PI / count;
49
+ const splitCount = Math.max(1, option.splitCount ?? 5);
50
+ const gridColor = option.gridColor ?? '#ebedf0';
51
+ const gridLineWidth = option.gridLineWidth ?? 1;
52
+
53
+ const vertexAngle = (index: number): number =>
54
+ startAngle + index * step;
55
+
56
+ const polarPoint = (angle: number, r: number): PolarPoint => ({
57
+ x: centerX + Math.cos(angle) * r,
58
+ y: centerY + Math.sin(angle) * r,
59
+ });
60
+
61
+ // 网格层:同心多边形
62
+ for (let layer = 1; layer <= splitCount; layer += 1) {
63
+ const r = (radius * layer) / splitCount;
64
+ ctx.save();
65
+ ctx.strokeStyle = gridColor;
66
+ ctx.lineWidth = gridLineWidth;
67
+ ctx.beginPath();
68
+ for (let i = 0; i < count; i += 1) {
69
+ const p = polarPoint(vertexAngle(i), r);
70
+ if (i === 0) {
71
+ ctx.moveTo(p.x, p.y);
72
+ } else {
73
+ ctx.lineTo(p.x, p.y);
74
+ }
75
+ }
76
+ ctx.closePath();
77
+ ctx.stroke();
78
+ ctx.restore();
79
+ }
80
+
81
+ // 指标轴线
82
+ ctx.save();
83
+ ctx.strokeStyle = gridColor;
84
+ ctx.lineWidth = gridLineWidth;
85
+ ctx.beginPath();
86
+ for (let i = 0; i < count; i += 1) {
87
+ const p = polarPoint(vertexAngle(i), radius);
88
+ ctx.moveTo(centerX, centerY);
89
+ ctx.lineTo(p.x, p.y);
90
+ }
91
+ ctx.stroke();
92
+ ctx.restore();
93
+
94
+ // 指标标签(顶点外侧)
95
+ const labelFontSize = option.labelFontSize ?? 11;
96
+ applyFont(ctx, labelFontSize);
97
+ ctx.fillStyle = option.labelColor ?? '#323233';
98
+ for (let i = 0; i < count; i += 1) {
99
+ const p = polarPoint(vertexAngle(i), radius + 12);
100
+ ctx.textAlign = Math.abs(p.x - centerX) < 1 ? 'center' : p.x > centerX ? 'left' : 'right';
101
+ ctx.textBaseline =
102
+ Math.abs(p.y - centerY) < 1
103
+ ? 'middle'
104
+ : p.y > centerY
105
+ ? 'top'
106
+ : 'bottom';
107
+ ctx.fillText(option.indicators[i].name, p.x, p.y);
108
+ }
109
+
110
+ // 系列区域 / 折线
111
+ option.series.forEach((series, seriesIndex) => {
112
+ const color = this.seriesColor(seriesIndex, series.color);
113
+ const points: PolarPoint[] = option.indicators.map((indicator, i) => {
114
+ const max =
115
+ indicator.max ?? this.indicatorMax(option, i);
116
+ const value = series.data[i] ?? 0;
117
+ const ratio = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
118
+ return polarPoint(vertexAngle(i), radius * ratio);
119
+ });
120
+
121
+ if (series.area !== false) {
122
+ ctx.save();
123
+ ctx.fillStyle = color;
124
+ ctx.globalAlpha = 0.2;
125
+ ctx.beginPath();
126
+ this.tracePolygon(ctx, points);
127
+ ctx.closePath();
128
+ ctx.fill();
129
+ ctx.restore();
130
+ }
131
+
132
+ ctx.save();
133
+ ctx.strokeStyle = color;
134
+ ctx.lineWidth = series.lineWidth ?? 2;
135
+ ctx.lineJoin = 'round';
136
+ ctx.beginPath();
137
+ this.tracePolygon(ctx, points);
138
+ ctx.closePath();
139
+ ctx.stroke();
140
+ ctx.restore();
141
+ });
142
+ }
143
+
144
+ private indicatorMax(option: RadarChartOption, index: number): number {
145
+ let max = 0;
146
+ for (const series of option.series) {
147
+ const v = series.data[index] ?? 0;
148
+ if (v > max) {
149
+ max = v;
150
+ }
151
+ }
152
+ return max;
153
+ }
154
+
155
+ private tracePolygon(ctx: ICanvas2D, points: readonly PolarPoint[]): void {
156
+ if (points.length === 0) {
157
+ return;
158
+ }
159
+ ctx.moveTo(points[0].x, points[0].y);
160
+ for (let i = 1; i < points.length; i += 1) {
161
+ ctx.lineTo(points[i].x, points[i].y);
162
+ }
163
+ }
164
+ }
165
+
166
+ const TWO_PI = Math.PI * 2;