transone-chart 0.1.2 → 0.2.0

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.
package/dist/types.d.ts CHANGED
@@ -61,6 +61,32 @@ export interface CategoryAxisOption {
61
61
  /** 是否显示类目网格竖线,默认 false。 */
62
62
  showGrid?: boolean;
63
63
  }
64
+ /** —— Tooltip(悬浮提示)—— */
65
+ /** tooltip 中一条数据(一个系列 / 一个扇区)。 */
66
+ export interface TooltipDatum {
67
+ /** 系列名 / 扇区名。 */
68
+ name: string;
69
+ /** 显示值(已格式化)。 */
70
+ value: number | string;
71
+ /** 色块颜色,用于 tooltip 前的小圆点。 */
72
+ color?: string;
73
+ }
74
+ /** tooltip 触发时命中的一组数据。 */
75
+ export interface TooltipParams {
76
+ /** 触发位置(逻辑像素)。 */
77
+ x: number;
78
+ y: number;
79
+ /** 类目名 / 扇区名。 */
80
+ name: string;
81
+ /** 该类目 / 扇区下的所有数据项。 */
82
+ items: readonly TooltipDatum[];
83
+ }
84
+ export interface TooltipOption {
85
+ /** 是否显示 tooltip,默认 true。 */
86
+ show?: boolean;
87
+ /** 自定义展示内容;返回文本行数组。缺省自动生成(首行标题 + 每行一个色块数据项)。 */
88
+ formatter?: (params: TooltipParams) => string | readonly string[];
89
+ }
64
90
  /** —— 折线图 —— */
65
91
  export interface LineSeries {
66
92
  name?: string;
@@ -83,6 +109,8 @@ export interface LineChartOption {
83
109
  series: readonly LineSeries[];
84
110
  /** 数值轴是否从 0 开始(数据全为正时强制含 0),默认 false(按数据范围紧凑显示)。 */
85
111
  startFromZero?: boolean;
112
+ /** 悬浮提示配置;默认开启,可 tooltip: { show: false } 关闭。 */
113
+ tooltip?: TooltipOption;
86
114
  /** 背景色(如 'rgba(22,119,255,0.06)'),默认无。 */
87
115
  backgroundColor?: string;
88
116
  }
@@ -107,6 +135,8 @@ export interface BarChartOption {
107
135
  series: readonly BarSeries[];
108
136
  /** 横向柱状图(类目轴转纵向、数值轴转横向),默认 false。 */
109
137
  horizontal?: boolean;
138
+ /** 悬浮提示配置;默认开启,可 tooltip: { show: false } 关闭。 */
139
+ tooltip?: TooltipOption;
110
140
  backgroundColor?: string;
111
141
  }
112
142
  /** —— 饼图 —— */
@@ -126,10 +156,20 @@ export interface PieChartOption {
126
156
  innerRadius?: number | string;
127
157
  /** 是否显示扇区标签(名称 + 百分比),默认 true。 */
128
158
  showLabel?: boolean;
159
+ /** 标签位置:'inside' 画在扇区内(默认,白字);'outside' 画在扇区外并带引线(适合小扇区 / 长名称)。 */
160
+ labelPosition?: 'inside' | 'outside';
161
+ /** 外部标签引线沿平分线的长度(px),仅 labelPosition: 'outside' 生效,默认 14。 */
162
+ labelLineLength?: number;
163
+ /** 外部标签与引线端点的水平间距(px),仅 labelPosition: 'outside' 生效,默认 6。 */
164
+ labelGap?: number;
165
+ /** 外部标签引线颜色,默认 '#c0c4cc'。 */
166
+ labelLineColor?: string;
129
167
  labelFontSize?: number;
130
168
  labelColor?: string;
131
169
  /** 起始角(弧度),默认 -Math.PI / 2(12 点方向),顺时针。 */
132
170
  startAngle?: number;
171
+ /** 悬浮提示配置;默认开启,可 tooltip: { show: false } 关闭。 */
172
+ tooltip?: TooltipOption;
133
173
  backgroundColor?: string;
134
174
  }
135
175
  /** —— 雷达图 —— */
@@ -162,6 +202,8 @@ export interface RadarChartOption {
162
202
  radius?: number;
163
203
  /** 起始角(弧度),默认 -Math.PI / 2(12 点方向),顺时针。 */
164
204
  startAngle?: number;
205
+ /** 悬浮提示配置;默认开启,可 tooltip: { show: false } 关闭。 */
206
+ tooltip?: TooltipOption;
165
207
  backgroundColor?: string;
166
208
  }
167
209
  export type ChartOption = LineChartOption | BarChartOption | PieChartOption | RadarChartOption;
package/lib/charts/bar.ts CHANGED
@@ -9,9 +9,10 @@
9
9
 
10
10
  import type { ICanvas2D } from '../core/canvas';
11
11
  import { ChartBase } from '../core/chart';
12
+ import type { CategoryScale } from '../core/scale';
12
13
  import type { LayoutResult } from '../core/layout';
13
14
  import type { LegendItem } from '../core/legend';
14
- import type { BarChartOption, BarSeries } from '../types';
15
+ import type { BarChartOption, BarSeries, TooltipParams } from '../types';
15
16
 
16
17
  /** 柱体在类目带内的水平偏移与宽度(纵向)或垂直偏移与高度(横向)。 */
17
18
  interface Slot {
@@ -94,6 +95,7 @@ export class BarChart extends ChartBase<BarChartOption> {
94
95
 
95
96
  const stackGroups = groupStacks(option.series);
96
97
  const categoryCount = option.xAxis.labels.length;
98
+ const hoverIndex = this.getHoverCategoryIndex();
97
99
 
98
100
  for (let i = 0; i < categoryCount; i += 1) {
99
101
  // 每个类目带内:无堆叠时按系列分组并排;有堆叠时按 stack 组并排
@@ -103,6 +105,7 @@ export class BarChart extends ChartBase<BarChartOption> {
103
105
  .map((s) => s.barWidth)
104
106
  .find((w) => w !== undefined && w > 0);
105
107
  const slot = this.computeSlot(category, seriesCount, explicitWidth);
108
+ const isHovered = hoverIndex === i;
106
109
 
107
110
  groups.forEach((group, groupIndex) => {
108
111
  const slotOffset = slot.offset + groupIndex * slot.size;
@@ -110,10 +113,11 @@ export class BarChart extends ChartBase<BarChartOption> {
110
113
 
111
114
  group.forEach((series) => {
112
115
  const raw = series.data[i] ?? 0;
113
- const color = this.seriesColor(
116
+ const baseColor = this.seriesColor(
114
117
  option.series.indexOf(series),
115
118
  series.color
116
119
  );
120
+ const color = isHovered ? lightenColor(baseColor, 0.25) : baseColor;
117
121
 
118
122
  if (horizontal) {
119
123
  const x0 = value.scale(0);
@@ -153,21 +157,66 @@ export class BarChart extends ChartBase<BarChartOption> {
153
157
  }
154
158
  }
155
159
 
160
+ /** 命中检测:点落在某类目的柱体带内即显示该类目所有系列值。 */
161
+ protected hitTestSeries(x: number, y: number): TooltipParams | null {
162
+ const scales = this.cartesian;
163
+ if (!scales) {
164
+ return null;
165
+ }
166
+ const horizontal = this.option.horizontal ?? false;
167
+ const labels = this.getCategoryLabels();
168
+
169
+ // 命中整个类目带(含柱间间隙):鼠标在类目带内即命中,避免间隙处 tooltip 消失
170
+ for (let i = 0; i < labels.length; i += 1) {
171
+ const start = scales.category.bandStart(i);
172
+ const band = scales.category.bandWidth;
173
+ const hit = horizontal
174
+ ? y >= start && y <= start + band
175
+ : x >= start && x <= start + band;
176
+ if (!hit) {
177
+ continue;
178
+ }
179
+ // 该类目下所有系列(堆叠时为各段值)
180
+ const items = this.option.series.map((s, si) => ({
181
+ name: s.name ?? `系列${si + 1}`,
182
+ value: s.data[i] ?? 0,
183
+ color: this.seriesColor(si, s.color),
184
+ }));
185
+ return { x, y, name: labels[i], items };
186
+ }
187
+ return null;
188
+ }
189
+
190
+ /** 从 hover 状态解析命中的类目索引,未命中返回 -1。 */
191
+ private getHoverCategoryIndex(): number {
192
+ const hover = this.hover;
193
+ if (!hover) {
194
+ return -1;
195
+ }
196
+ const labels = this.getCategoryLabels();
197
+ return labels.indexOf(hover.name);
198
+ }
199
+
156
200
  private computeSlot(
157
- category: { bandStart(index: number): number; innerWidth(): number },
201
+ category: CategoryScale,
158
202
  groupCount: number,
159
203
  explicitWidth?: number
160
204
  ): Slot {
205
+ const band = category.bandWidth;
206
+ const inner = category.innerWidth();
161
207
  if (explicitWidth !== undefined) {
162
- // 显式柱宽:组内居中
208
+ // 显式柱宽:整组在类目带内居中
163
209
  return {
164
- offset: (category.innerWidth() - explicitWidth) / 2,
210
+ offset: (band - explicitWidth * groupCount) / 2,
165
211
  size: explicitWidth,
166
212
  };
167
213
  }
168
- const size = category.innerWidth() / Math.max(1, groupCount) * 0.8;
169
- const gap = (category.innerWidth() - size * groupCount) / 2;
170
- return { offset: gap, size };
214
+ const size = (inner / Math.max(1, groupCount)) * 0.8;
215
+ // 柱区(size × groupCount)先在 inner 内居中,再随 inner 在类目带内居中,
216
+ // 保证整组柱中心落在类目中心(与轴标签 center(i) 对齐)。
217
+ const bandSide = (band - inner) / 2;
218
+ const innerSide = (inner - size * groupCount) / 2;
219
+ return { offset: bandSide + innerSide, size };
171
220
  }
172
221
 
173
222
  private drawBar(
@@ -268,3 +317,41 @@ function roundRectPath(
268
317
  }
269
318
  ctx.closePath();
270
319
  }
320
+
321
+ /** 将颜色向白色方向混合 amount(0~1),支持 #rrggbb 与 rgba()。 */
322
+ function lightenColor(color: string, amount: number): string {
323
+ const a = Math.max(0, Math.min(1, amount));
324
+
325
+ // #rrggbb / #rgb
326
+ const hex = color.match(/^#([0-9a-f]{3,8})$/i);
327
+ if (hex) {
328
+ let r: number;
329
+ let g: number;
330
+ let b: number;
331
+ const h = hex[1];
332
+ if (h.length === 3) {
333
+ r = parseInt(h[0] + h[0], 16);
334
+ g = parseInt(h[1] + h[1], 16);
335
+ b = parseInt(h[2] + h[2], 16);
336
+ } else {
337
+ r = parseInt(h.slice(0, 2), 16);
338
+ g = parseInt(h.slice(2, 4), 16);
339
+ b = parseInt(h.slice(4, 6), 16);
340
+ }
341
+ r = Math.round(r + (255 - r) * a);
342
+ g = Math.round(g + (255 - g) * a);
343
+ b = Math.round(b + (255 - b) * a);
344
+ return `rgb(${r}, ${g}, ${b})`;
345
+ }
346
+
347
+ // rgba(r, g, b, a?) / rgb(r, g, b)
348
+ const rgba = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
349
+ if (rgba) {
350
+ const r = Math.round(parseInt(rgba[1]) + (255 - parseInt(rgba[1])) * a);
351
+ const g = Math.round(parseInt(rgba[2]) + (255 - parseInt(rgba[2])) * a);
352
+ const b = Math.round(parseInt(rgba[3]) + (255 - parseInt(rgba[3])) * a);
353
+ return `rgb(${r}, ${g}, ${b})`;
354
+ }
355
+
356
+ return color;
357
+ }
@@ -7,7 +7,7 @@ import type { ICanvas2D } from '../core/canvas';
7
7
  import { ChartBase } from '../core/chart';
8
8
  import type { LayoutResult } from '../core/layout';
9
9
  import type { LegendItem } from '../core/legend';
10
- import type { LineChartOption } from '../types';
10
+ import type { LineChartOption, TooltipParams } from '../types';
11
11
 
12
12
  interface Point {
13
13
  x: number;
@@ -68,6 +68,7 @@ export class LineChart extends ChartBase<LineChartOption> {
68
68
  }
69
69
  const { plot } = layout;
70
70
  const { value, category } = scales;
71
+ const hoverIndex = this.getHoverCategoryIndex();
71
72
 
72
73
  const pointsPerSeries: Point[][] = option.series.map((series) =>
73
74
  series.data.map((d, i) => ({
@@ -76,6 +77,20 @@ export class LineChart extends ChartBase<LineChartOption> {
76
77
  }))
77
78
  );
78
79
 
80
+ // 悬浮参考线:在命中类目中心画一条纵向虚线
81
+ if (hoverIndex >= 0) {
82
+ const hx = category.center(hoverIndex);
83
+ ctx.save();
84
+ ctx.strokeStyle = '#c8c9cc';
85
+ ctx.lineWidth = 1;
86
+ ctx.setLineDash([4, 3]);
87
+ ctx.beginPath();
88
+ ctx.moveTo(hx, plot.y);
89
+ ctx.lineTo(hx, plot.y + plot.height);
90
+ ctx.stroke();
91
+ ctx.restore();
92
+ }
93
+
79
94
  option.series.forEach((series, index) => {
80
95
  const points = pointsPerSeries[index];
81
96
  if (points.length === 0) {
@@ -111,11 +126,14 @@ export class LineChart extends ChartBase<LineChartOption> {
111
126
 
112
127
  // 数据点标记
113
128
  if (series.showSymbol !== false) {
129
+ const baseR = Math.max(2.5, lineWidth + 0.5);
114
130
  ctx.save();
115
131
  ctx.fillStyle = color;
116
- for (const p of points) {
132
+ for (let i = 0; i < points.length; i += 1) {
133
+ const p = points[i];
134
+ const r = i === hoverIndex ? baseR * 1.6 : baseR;
117
135
  ctx.beginPath();
118
- ctx.arc(p.x, p.y, Math.max(2.5, lineWidth + 0.5), 0, Math.PI * 2);
136
+ ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
119
137
  ctx.fill();
120
138
  }
121
139
  ctx.restore();
@@ -123,6 +141,47 @@ export class LineChart extends ChartBase<LineChartOption> {
123
141
  });
124
142
  }
125
143
 
144
+ /** 命中检测:x 落在某类目带内即显示该类目所有系列值(y 在绘图区内即可)。 */
145
+ protected hitTestSeries(x: number, y: number): TooltipParams | null {
146
+ const scales = this.cartesian;
147
+ const plot = this.plot;
148
+ if (!scales || !plot) {
149
+ return null;
150
+ }
151
+ if (y < plot.y || y > plot.y + plot.height) {
152
+ return null;
153
+ }
154
+ const labels = this.option.xAxis.labels;
155
+ let best = -1;
156
+ let bestDist = Infinity;
157
+ for (let i = 0; i < labels.length; i += 1) {
158
+ const d = Math.abs(scales.category.center(i) - x);
159
+ if (d < bestDist) {
160
+ bestDist = d;
161
+ best = i;
162
+ }
163
+ }
164
+ if (best < 0 || bestDist > scales.category.bandWidth / 2) {
165
+ return null;
166
+ }
167
+ const items = this.option.series.map((s, i) => ({
168
+ name: s.name ?? `系列${i + 1}`,
169
+ value: s.data[best] ?? 0,
170
+ color: this.seriesColor(i, s.color),
171
+ }));
172
+ return { x, y, name: labels[best], items };
173
+ }
174
+
175
+ /** 从 hover 状态解析命中的类目索引,未命中返回 -1。 */
176
+ private getHoverCategoryIndex(): number {
177
+ const hover = this.hover;
178
+ if (!hover) {
179
+ return -1;
180
+ }
181
+ const labels = this.option.xAxis.labels;
182
+ return labels.indexOf(hover.name);
183
+ }
184
+
126
185
  /** 折线路径:直线或 Catmull-Rom 平滑插值(三次贝塞尔)。 */
127
186
  private tracePath(ctx: ICanvas2D, points: readonly Point[], smooth: boolean): void {
128
187
  if (points.length === 0) {
package/lib/charts/pie.ts CHANGED
@@ -8,7 +8,7 @@ import { ChartBase } from '../core/chart';
8
8
  import type { LayoutResult } from '../core/layout';
9
9
  import type { LegendItem } from '../core/legend';
10
10
  import { applyFont } from '../core/text';
11
- import type { PieChartOption } from '../types';
11
+ import type { PieChartOption, TooltipParams } from '../types';
12
12
 
13
13
  const TWO_PI = Math.PI * 2;
14
14
 
@@ -24,6 +24,61 @@ export class PieChart extends ChartBase<PieChartOption> {
24
24
  }));
25
25
  }
26
26
 
27
+ /** 命中检测:点落在某扇区(环形环带内)即显示该扇区名与数值占比。 */
28
+ protected hitTestSeries(x: number, y: number): TooltipParams | null {
29
+ const plot = this.plot;
30
+ if (!plot) {
31
+ return null;
32
+ }
33
+ const centerX = plot.x + plot.width / 2;
34
+ const centerY = plot.y + plot.height / 2;
35
+ const maxRadius = Math.min(plot.width, plot.height) / 2;
36
+ const outerRadius = resolveRadius(this.option.radius ?? '60%', maxRadius);
37
+ const innerRadius = resolveRadius(this.option.innerRadius ?? 0, maxRadius);
38
+ const startAngle = this.option.startAngle ?? -Math.PI / 2;
39
+
40
+ const dx = x - centerX;
41
+ const dy = y - centerY;
42
+ const dist = Math.hypot(dx, dy);
43
+ if (dist < innerRadius || dist > outerRadius) {
44
+ return null;
45
+ }
46
+
47
+ const data = this.option.data.filter((d) => d.value > 0);
48
+ const total = data.reduce((sum, d) => sum + d.value, 0);
49
+ if (total <= 0) {
50
+ return null;
51
+ }
52
+
53
+ // 命中角(canvas 系 y 向下,与绘制时角度递增方向一致)
54
+ let a = Math.atan2(dy, dx);
55
+ while (a < startAngle) {
56
+ a += TWO_PI;
57
+ }
58
+ let cur = startAngle;
59
+ for (let i = 0; i < data.length; i += 1) {
60
+ const d = data[i]!;
61
+ const sweep = (d.value / total) * TWO_PI;
62
+ if (a >= cur && a <= cur + sweep) {
63
+ const percent = Math.round((d.value / total) * 100);
64
+ return {
65
+ x,
66
+ y,
67
+ name: d.name,
68
+ items: [
69
+ {
70
+ name: d.name,
71
+ value: `${percent}%`,
72
+ color: d.color ?? this.seriesColor(i),
73
+ },
74
+ ],
75
+ };
76
+ }
77
+ cur += sweep;
78
+ }
79
+ return null;
80
+ }
81
+
27
82
  protected drawSeries(
28
83
  ctx: ICanvas2D,
29
84
  layout: LayoutResult,
@@ -77,19 +132,56 @@ export class PieChart extends ChartBase<PieChartOption> {
77
132
  ctx.stroke();
78
133
  ctx.restore();
79
134
 
80
- // 扇区标签:名称 + 百分比,画在扇区角平分线上
135
+ // 扇区标签:名称 + 百分比
136
+ // - inside(默认):画在扇区角平分线上(白字)
137
+ // - outside:引线把文字引到扇区外(右半区左对齐、左半区右对齐,避免文字跨越中线)
81
138
  if (option.showLabel !== false) {
82
139
  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
140
  const percent = total > 0 ? Math.round((d.value / total) * 100) : 0;
141
+ const text = `${d.name} ${percent}%`;
142
+
143
+ if (option.labelPosition === 'outside') {
144
+ const lineLength = option.labelLineLength ?? 14;
145
+ const gap = option.labelGap ?? 6;
146
+ const dirX = Math.cos(midAngle);
147
+ const dirY = Math.sin(midAngle);
148
+ // 引线第一段:扇区边缘沿平分线向外
149
+ const edgeX = centerX + dirX * outerRadius;
150
+ const edgeY = centerY + dirY * outerRadius;
151
+ const bendX = centerX + dirX * (outerRadius + lineLength);
152
+ const bendY = centerY + dirY * (outerRadius + lineLength);
153
+ // 第二段:水平延伸到文字锚点(右半向右、左半向左)
154
+ const horizontal = dirX >= 0 ? 1 : -1;
155
+ const textX = bendX + horizontal * gap;
156
+ const textY = bendY;
157
+
158
+ ctx.save();
159
+ ctx.strokeStyle = option.labelLineColor ?? '#c0c4cc';
160
+ ctx.lineWidth = 1;
161
+ ctx.beginPath();
162
+ ctx.moveTo(edgeX, edgeY);
163
+ ctx.lineTo(bendX, bendY);
164
+ ctx.lineTo(textX, textY);
165
+ ctx.stroke();
166
+ ctx.restore();
167
+
168
+ applyFont(ctx, option.labelFontSize ?? 10);
169
+ ctx.fillStyle = option.labelColor ?? '#323233';
170
+ ctx.textAlign = horizontal > 0 ? 'left' : 'right';
171
+ ctx.textBaseline = 'middle';
172
+ ctx.fillText(text, textX, textY);
173
+ } else {
174
+ const labelRadius =
175
+ ((outerRadius + (innerRadius > 0 ? innerRadius : 0)) / 2) * 0.85;
176
+ const labelX = centerX + Math.cos(midAngle) * labelRadius;
177
+ const labelY = centerY + Math.sin(midAngle) * labelRadius;
87
178
 
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);
179
+ applyFont(ctx, option.labelFontSize ?? 10);
180
+ ctx.fillStyle = option.labelColor ?? '#ffffff';
181
+ ctx.textAlign = 'center';
182
+ ctx.textBaseline = 'middle';
183
+ ctx.fillText(text, labelX, labelY);
184
+ }
93
185
  }
94
186
 
95
187
  angle += sweep;
@@ -8,7 +8,7 @@ import { ChartBase } from '../core/chart';
8
8
  import type { LayoutResult } from '../core/layout';
9
9
  import type { LegendItem } from '../core/legend';
10
10
  import { applyFont } from '../core/text';
11
- import type { RadarChartOption } from '../types';
11
+ import type { RadarChartOption, TooltipParams } from '../types';
12
12
 
13
13
  interface PolarPoint {
14
14
  x: number;
@@ -141,6 +141,57 @@ export class RadarChart extends ChartBase<RadarChartOption> {
141
141
  });
142
142
  }
143
143
 
144
+ /** 命中检测:靠近任一数据顶点(阈值 24px)即显示该指标下所有系列值。 */
145
+ protected hitTestSeries(x: number, y: number): TooltipParams | null {
146
+ const plot = this.plot;
147
+ if (!plot) {
148
+ return null;
149
+ }
150
+ const centerX = plot.x + plot.width / 2;
151
+ const centerY = plot.y + plot.height / 2;
152
+ const radius =
153
+ this.option.radius ?? (Math.min(plot.width, plot.height) / 2) * 0.6;
154
+ const count = this.option.indicators.length;
155
+ if (count === 0) {
156
+ return null;
157
+ }
158
+ const startAngle = this.option.startAngle ?? -Math.PI / 2;
159
+ const step = TWO_PI / count;
160
+
161
+ let best = -1;
162
+ let bestDist = 24; // 命中阈值(px)
163
+ for (let i = 0; i < count; i += 1) {
164
+ const max =
165
+ this.option.indicators[i].max ?? this.indicatorMax(this.option, i);
166
+ const angle = startAngle + i * step;
167
+ for (let si = 0; si < this.option.series.length; si += 1) {
168
+ const value = this.option.series[si].data[i] ?? 0;
169
+ const ratio = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
170
+ const px = centerX + Math.cos(angle) * radius * ratio;
171
+ const py = centerY + Math.sin(angle) * radius * ratio;
172
+ const d = Math.hypot(x - px, y - py);
173
+ if (d < bestDist) {
174
+ bestDist = d;
175
+ best = i;
176
+ }
177
+ }
178
+ }
179
+ if (best < 0) {
180
+ return null;
181
+ }
182
+ const items = this.option.series.map((s, si) => ({
183
+ name: s.name ?? `系列${si + 1}`,
184
+ value: s.data[best] ?? 0,
185
+ color: this.seriesColor(si, s.color),
186
+ }));
187
+ return {
188
+ x,
189
+ y,
190
+ name: this.option.indicators[best]!.name,
191
+ items,
192
+ };
193
+ }
194
+
144
195
  private indicatorMax(option: RadarChartOption, index: number): number {
145
196
  let max = 0;
146
197
  for (const series of option.series) {