transone-chart 0.1.4 → 0.2.1

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
  /** —— 饼图 —— */
@@ -138,6 +168,8 @@ export interface PieChartOption {
138
168
  labelColor?: string;
139
169
  /** 起始角(弧度),默认 -Math.PI / 2(12 点方向),顺时针。 */
140
170
  startAngle?: number;
171
+ /** 悬浮提示配置;默认开启,可 tooltip: { show: false } 关闭。 */
172
+ tooltip?: TooltipOption;
141
173
  backgroundColor?: string;
142
174
  }
143
175
  /** —— 雷达图 —— */
@@ -170,6 +202,8 @@ export interface RadarChartOption {
170
202
  radius?: number;
171
203
  /** 起始角(弧度),默认 -Math.PI / 2(12 点方向),顺时针。 */
172
204
  startAngle?: number;
205
+ /** 悬浮提示配置;默认开启,可 tooltip: { show: false } 关闭。 */
206
+ tooltip?: TooltipOption;
173
207
  backgroundColor?: string;
174
208
  }
175
209
  export type ChartOption = LineChartOption | BarChartOption | PieChartOption | RadarChartOption;
@@ -29,14 +29,14 @@ export interface ResolvedCanvas {
29
29
 
30
30
  /** 通用探测设备像素比(web 与小程序可用 globalThis 访问)。 */
31
31
  export function detectPixelRatio(): number {
32
- const host = getHost();
32
+ const host = getPixelRatioHost();
33
33
  if (typeof host.devicePixelRatio === 'number' && host.devicePixelRatio > 0) {
34
34
  return host.devicePixelRatio;
35
35
  }
36
36
  return 1;
37
37
  }
38
38
 
39
- function getHost(): Record<string, unknown> {
39
+ function getPixelRatioHost(): Record<string, unknown> {
40
40
  if (typeof globalThis !== 'undefined') {
41
41
  return globalThis as unknown as Record<string, unknown>;
42
42
  }
package/lib/charts/bar.ts CHANGED
@@ -12,7 +12,7 @@ import { ChartBase } from '../core/chart';
12
12
  import type { CategoryScale } from '../core/scale';
13
13
  import type { LayoutResult } from '../core/layout';
14
14
  import type { LegendItem } from '../core/legend';
15
- import type { BarChartOption, BarSeries } from '../types';
15
+ import type { BarChartOption, BarSeries, TooltipParams } from '../types';
16
16
 
17
17
  /** 柱体在类目带内的水平偏移与宽度(纵向)或垂直偏移与高度(横向)。 */
18
18
  interface Slot {
@@ -95,6 +95,7 @@ export class BarChart extends ChartBase<BarChartOption> {
95
95
 
96
96
  const stackGroups = groupStacks(option.series);
97
97
  const categoryCount = option.xAxis.labels.length;
98
+ const hoverIndex = this.getHoverCategoryIndex();
98
99
 
99
100
  for (let i = 0; i < categoryCount; i += 1) {
100
101
  // 每个类目带内:无堆叠时按系列分组并排;有堆叠时按 stack 组并排
@@ -104,6 +105,7 @@ export class BarChart extends ChartBase<BarChartOption> {
104
105
  .map((s) => s.barWidth)
105
106
  .find((w) => w !== undefined && w > 0);
106
107
  const slot = this.computeSlot(category, seriesCount, explicitWidth);
108
+ const isHovered = hoverIndex === i;
107
109
 
108
110
  groups.forEach((group, groupIndex) => {
109
111
  const slotOffset = slot.offset + groupIndex * slot.size;
@@ -111,10 +113,11 @@ export class BarChart extends ChartBase<BarChartOption> {
111
113
 
112
114
  group.forEach((series) => {
113
115
  const raw = series.data[i] ?? 0;
114
- const color = this.seriesColor(
116
+ const baseColor = this.seriesColor(
115
117
  option.series.indexOf(series),
116
118
  series.color
117
119
  );
120
+ const color = isHovered ? lightenColor(baseColor, 0.25) : baseColor;
118
121
 
119
122
  if (horizontal) {
120
123
  const x0 = value.scale(0);
@@ -154,6 +157,46 @@ export class BarChart extends ChartBase<BarChartOption> {
154
157
  }
155
158
  }
156
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
+
157
200
  private computeSlot(
158
201
  category: CategoryScale,
159
202
  groupCount: number,
@@ -274,3 +317,41 @@ function roundRectPath(
274
317
  }
275
318
  ctx.closePath();
276
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,
@@ -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;
@@ -45,7 +45,7 @@ export class RadarChart extends ChartBase<RadarChartOption> {
45
45
  }
46
46
 
47
47
  const startAngle = option.startAngle ?? -Math.PI / 2;
48
- const step = TWO_PI / count;
48
+ const step = RADAR_TWO_PI / count;
49
49
  const splitCount = Math.max(1, option.splitCount ?? 5);
50
50
  const gridColor = option.gridColor ?? '#ebedf0';
51
51
  const gridLineWidth = option.gridLineWidth ?? 1;
@@ -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 = RADAR_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) {
@@ -163,4 +214,4 @@ export class RadarChart extends ChartBase<RadarChartOption> {
163
214
  }
164
215
  }
165
216
 
166
- const TWO_PI = Math.PI * 2;
217
+ const RADAR_TWO_PI = Math.PI * 2;
package/lib/component.ts CHANGED
@@ -62,6 +62,8 @@ export class TcChart extends Component<TcChartProps, TcChartState> {
62
62
  private resizeDebounced: Debounced<() => void> | null = null;
63
63
  private resizeObserver: ResizeObserver | null = null;
64
64
  private mpResizeHandler: (() => void) | null = null;
65
+ /** Web 端 hover 事件句柄(mousemove / mouseleave),卸载时移除。 */
66
+ private hoverHandlers: Array<[string, (e: Event) => void]> = [];
65
67
 
66
68
  protected initState(): TcChartState {
67
69
  return { canvasId: CANVAS_ID };
@@ -89,6 +91,7 @@ export class TcChart extends Component<TcChartProps, TcChartState> {
89
91
  protected onMounted(): void {
90
92
  this.attachChart();
91
93
  this.setupAutoResize();
94
+ this.setupHoverEvents();
92
95
  }
93
96
 
94
97
  protected onUpdated(): void {
@@ -101,6 +104,7 @@ export class TcChart extends Component<TcChartProps, TcChartState> {
101
104
 
102
105
  protected onUnmounted(): void {
103
106
  this.teardownAutoResize();
107
+ this.teardownHoverEvents();
104
108
  this.destroyed = true;
105
109
  this.chart?.destroy();
106
110
  this.chart = null;
@@ -129,9 +133,67 @@ export class TcChart extends Component<TcChartProps, TcChartState> {
129
133
  });
130
134
  }
131
135
 
132
- /** 组件根元素(canvas)。Web 端用于解析渲染上下文;小程序端经 SelectorQuery .in(实例) 查询,不依赖此方法。 */
136
+ /**
137
+ * Web 端返回根 canvas;小程序端此方法不会被调用(走 SelectorQuery)。
138
+ * 注意:方法体不能写 super.X() —— transone-cli 编译小程序时会把组件方法拍平为
139
+ * Component options 的普通函数属性,super 在普通函数里非法(JSCore 直接报错)。
140
+ * 基类 el 为 protected,此处直接取用;返回类型收窄在使用点做断言。
141
+ */
133
142
  public getElement(): HTMLCanvasElement | null {
134
- return super.getElement() as HTMLCanvasElement | null;
143
+ return this.el as HTMLCanvasElement | null;
144
+ }
145
+
146
+ /* —— 悬浮提示(tooltip):Web mousemove / mouseleave —— */
147
+
148
+ /** Web 端绑定 hover 事件:鼠标移动命中数据点即显示 tooltip,离开清除。 */
149
+ private setupHoverEvents(): void {
150
+ if (this.destroyed || this.hoverHandlers.length > 0) {
151
+ return;
152
+ }
153
+ // 小程序端暂无鼠标事件,一期仅 Web;后续可在 touchstart/touchmove 接入同一 setHover API
154
+ if (detectMiniProgramGlobal()) {
155
+ return;
156
+ }
157
+ const element = this.getElement() as HTMLCanvasElement | null;
158
+ if (!element || typeof element.addEventListener !== 'function') {
159
+ return;
160
+ }
161
+
162
+ const onMove = (event: Event): void => {
163
+ const chart = this.chart;
164
+ if (!chart) {
165
+ return;
166
+ }
167
+ const mouse = event as MouseEvent;
168
+ const rect = element.getBoundingClientRect();
169
+ const relX = mouse.clientX - rect.left;
170
+ const relY = mouse.clientY - rect.top;
171
+ // 同步图表逻辑尺寸,避免 resize 防抖窗口内坐标系偏移导致命中错位
172
+ const w = rect.width;
173
+ const h = rect.height;
174
+ if (w > 0 && h > 0) {
175
+ chart.resize(w, h);
176
+ }
177
+ chart.setHover(relX, relY);
178
+ };
179
+ const onLeave = (): void => {
180
+ this.chart?.clearHover();
181
+ };
182
+
183
+ element.addEventListener('mousemove', onMove);
184
+ element.addEventListener('mouseleave', onLeave);
185
+ this.hoverHandlers = [
186
+ ['mousemove', onMove],
187
+ ['mouseleave', onLeave],
188
+ ];
189
+ }
190
+
191
+ private teardownHoverEvents(): void {
192
+ const element = this.getElement() as HTMLCanvasElement | null;
193
+ for (const [type, handler] of this.hoverHandlers) {
194
+ element?.removeEventListener(type, handler);
195
+ }
196
+ this.hoverHandlers = [];
135
197
  }
136
198
 
137
199
  /* —— 自动重绘:容器 / 窗口尺寸变化时防抖重绘 —— */
@@ -155,7 +217,7 @@ export class TcChart extends Component<TcChartProps, TcChartState> {
155
217
 
156
218
  // Web:ResizeObserver 观察 canvas 自身(CSS 宽高 100%,容器变化即触发)。
157
219
  // SSR / 构建期无真实元素与 ResizeObserver,跳过。
158
- const element = this.getElement();
220
+ const element = this.getElement() as HTMLCanvasElement | null;
159
221
  if (
160
222
  element &&
161
223
  typeof element.getContext === 'function' &&