transone-chart 0.1.2 → 0.1.4

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
@@ -126,6 +126,14 @@ export interface PieChartOption {
126
126
  innerRadius?: number | string;
127
127
  /** 是否显示扇区标签(名称 + 百分比),默认 true。 */
128
128
  showLabel?: boolean;
129
+ /** 标签位置:'inside' 画在扇区内(默认,白字);'outside' 画在扇区外并带引线(适合小扇区 / 长名称)。 */
130
+ labelPosition?: 'inside' | 'outside';
131
+ /** 外部标签引线沿平分线的长度(px),仅 labelPosition: 'outside' 生效,默认 14。 */
132
+ labelLineLength?: number;
133
+ /** 外部标签与引线端点的水平间距(px),仅 labelPosition: 'outside' 生效,默认 6。 */
134
+ labelGap?: number;
135
+ /** 外部标签引线颜色,默认 '#c0c4cc'。 */
136
+ labelLineColor?: string;
129
137
  labelFontSize?: number;
130
138
  labelColor?: string;
131
139
  /** 起始角(弧度),默认 -Math.PI / 2(12 点方向),顺时针。 */
package/lib/charts/bar.ts CHANGED
@@ -9,6 +9,7 @@
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
15
  import type { BarChartOption, BarSeries } from '../types';
@@ -154,20 +155,25 @@ export class BarChart extends ChartBase<BarChartOption> {
154
155
  }
155
156
 
156
157
  private computeSlot(
157
- category: { bandStart(index: number): number; innerWidth(): number },
158
+ category: CategoryScale,
158
159
  groupCount: number,
159
160
  explicitWidth?: number
160
161
  ): Slot {
162
+ const band = category.bandWidth;
163
+ const inner = category.innerWidth();
161
164
  if (explicitWidth !== undefined) {
162
- // 显式柱宽:组内居中
165
+ // 显式柱宽:整组在类目带内居中
163
166
  return {
164
- offset: (category.innerWidth() - explicitWidth) / 2,
167
+ offset: (band - explicitWidth * groupCount) / 2,
165
168
  size: explicitWidth,
166
169
  };
167
170
  }
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
+ const size = (inner / Math.max(1, groupCount)) * 0.8;
172
+ // 柱区(size × groupCount)先在 inner 内居中,再随 inner 在类目带内居中,
173
+ // 保证整组柱中心落在类目中心(与轴标签 center(i) 对齐)。
174
+ const bandSide = (band - inner) / 2;
175
+ const innerSide = (inner - size * groupCount) / 2;
176
+ return { offset: bandSide + innerSide, size };
171
177
  }
172
178
 
173
179
  private drawBar(
package/lib/charts/pie.ts CHANGED
@@ -77,19 +77,56 @@ export class PieChart extends ChartBase<PieChartOption> {
77
77
  ctx.stroke();
78
78
  ctx.restore();
79
79
 
80
- // 扇区标签:名称 + 百分比,画在扇区角平分线上
80
+ // 扇区标签:名称 + 百分比
81
+ // - inside(默认):画在扇区角平分线上(白字)
82
+ // - outside:引线把文字引到扇区外(右半区左对齐、左半区右对齐,避免文字跨越中线)
81
83
  if (option.showLabel !== false) {
82
84
  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
85
  const percent = total > 0 ? Math.round((d.value / total) * 100) : 0;
86
+ const text = `${d.name} ${percent}%`;
87
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);
88
+ if (option.labelPosition === 'outside') {
89
+ const lineLength = option.labelLineLength ?? 14;
90
+ const gap = option.labelGap ?? 6;
91
+ const dirX = Math.cos(midAngle);
92
+ const dirY = Math.sin(midAngle);
93
+ // 引线第一段:扇区边缘沿平分线向外
94
+ const edgeX = centerX + dirX * outerRadius;
95
+ const edgeY = centerY + dirY * outerRadius;
96
+ const bendX = centerX + dirX * (outerRadius + lineLength);
97
+ const bendY = centerY + dirY * (outerRadius + lineLength);
98
+ // 第二段:水平延伸到文字锚点(右半向右、左半向左)
99
+ const horizontal = dirX >= 0 ? 1 : -1;
100
+ const textX = bendX + horizontal * gap;
101
+ const textY = bendY;
102
+
103
+ ctx.save();
104
+ ctx.strokeStyle = option.labelLineColor ?? '#c0c4cc';
105
+ ctx.lineWidth = 1;
106
+ ctx.beginPath();
107
+ ctx.moveTo(edgeX, edgeY);
108
+ ctx.lineTo(bendX, bendY);
109
+ ctx.lineTo(textX, textY);
110
+ ctx.stroke();
111
+ ctx.restore();
112
+
113
+ applyFont(ctx, option.labelFontSize ?? 10);
114
+ ctx.fillStyle = option.labelColor ?? '#323233';
115
+ ctx.textAlign = horizontal > 0 ? 'left' : 'right';
116
+ ctx.textBaseline = 'middle';
117
+ ctx.fillText(text, textX, textY);
118
+ } else {
119
+ const labelRadius =
120
+ ((outerRadius + (innerRadius > 0 ? innerRadius : 0)) / 2) * 0.85;
121
+ const labelX = centerX + Math.cos(midAngle) * labelRadius;
122
+ const labelY = centerY + Math.sin(midAngle) * labelRadius;
123
+
124
+ applyFont(ctx, option.labelFontSize ?? 10);
125
+ ctx.fillStyle = option.labelColor ?? '#ffffff';
126
+ ctx.textAlign = 'center';
127
+ ctx.textBaseline = 'middle';
128
+ ctx.fillText(text, labelX, labelY);
129
+ }
93
130
  }
94
131
 
95
132
  angle += sweep;
package/lib/component.ts CHANGED
@@ -7,7 +7,10 @@
7
7
  * - 未来原生:通过 resolve 注入自定义解析器
8
8
  *
9
9
  * 组件本身是薄壳:状态由父级以 option 传入(完全受控),
10
- * 数据变化时自动 setOption + render
10
+ * 数据变化时自动 setOption + render
11
+ * 容器 / 窗口尺寸变化时防抖(150ms)自动重绘:
12
+ * - Web:ResizeObserver 观察 canvas,重测 clientWidth/Height 后增量 resize + render
13
+ * - 小程序:平台窗口尺寸回调触发后重建图表(SelectorQuery 取最新节点尺寸)
11
14
  */
12
15
 
13
16
  import {
@@ -17,12 +20,15 @@ import {
17
20
  } from 'transone';
18
21
  import type { ChartRenderContext, ChartOption } from './types';
19
22
  import type { ChartBase } from './core/chart';
23
+ import { debounce, type Debounced } from './core/debounce';
20
24
  import { createChart } from './factory';
21
25
  import {
22
26
  detectMiniProgramGlobal,
23
27
  getMiniProgramCanvasNode,
28
+ getMiniProgramGlobal,
24
29
  resolveMiniProgramCanvas,
25
30
  } from './adapters/miniprogram';
31
+ import { detectPixelRatio } from './adapters/types';
26
32
  import { resolveWebCanvas } from './adapters/web';
27
33
 
28
34
  export interface TcChartProps {
@@ -42,9 +48,20 @@ interface TcChartState {
42
48
  /** canvas 元素 id:Web 端仅作 DOM 属性;小程序端配合 SelectorQuery .in(实例) 隔离查询,多实例共存不冲突。 */
43
49
  const CANVAS_ID = 'tc-chart-canvas';
44
50
 
51
+ /** 尺寸变化自动重绘的防抖窗口(毫秒)。连续 resize 事件只在此窗口后重绘一次。 */
52
+ const RESIZE_DEBOUNCE_MS = 150;
53
+
54
+ interface MpWindowResizeApi {
55
+ onWindowResize?: (callback: () => void) => unknown;
56
+ offWindowResize?: (callback: () => void) => unknown;
57
+ }
58
+
45
59
  export class TcChart extends Component<TcChartProps, TcChartState> {
46
60
  private chart: ChartBase<ChartOption> | null = null;
47
61
  private destroyed = false;
62
+ private resizeDebounced: Debounced<() => void> | null = null;
63
+ private resizeObserver: ResizeObserver | null = null;
64
+ private mpResizeHandler: (() => void) | null = null;
48
65
 
49
66
  protected initState(): TcChartState {
50
67
  return { canvasId: CANVAS_ID };
@@ -71,6 +88,7 @@ export class TcChart extends Component<TcChartProps, TcChartState> {
71
88
 
72
89
  protected onMounted(): void {
73
90
  this.attachChart();
91
+ this.setupAutoResize();
74
92
  }
75
93
 
76
94
  protected onUpdated(): void {
@@ -82,6 +100,7 @@ export class TcChart extends Component<TcChartProps, TcChartState> {
82
100
  }
83
101
 
84
102
  protected onUnmounted(): void {
103
+ this.teardownAutoResize();
85
104
  this.destroyed = true;
86
105
  this.chart?.destroy();
87
106
  this.chart = null;
@@ -98,7 +117,8 @@ export class TcChart extends Component<TcChartProps, TcChartState> {
98
117
  }
99
118
  this.resolveContext()
100
119
  .then((context) => {
101
- if (this.destroyed) {
120
+ if (this.destroyed || !context) {
121
+ // 非浏览器环境(SSR / 构建期静态渲染)或显式跳过:渲染交给客户端水合阶段。
102
122
  return;
103
123
  }
104
124
  this.chart = createChart(context, this.props.option);
@@ -114,7 +134,88 @@ export class TcChart extends Component<TcChartProps, TcChartState> {
114
134
  return super.getElement() as HTMLCanvasElement | null;
115
135
  }
116
136
 
117
- private resolveContext(): Promise<ChartRenderContext> {
137
+ /* —— 自动重绘:容器 / 窗口尺寸变化时防抖重绘 —— */
138
+
139
+ private setupAutoResize(): void {
140
+ if (this.destroyed || this.resizeDebounced) {
141
+ return;
142
+ }
143
+ this.resizeDebounced = debounce(() => this.handleResize(), RESIZE_DEBOUNCE_MS);
144
+
145
+ const platform = detectMiniProgramGlobal();
146
+ if (platform) {
147
+ // 小程序:各平台均有窗口尺寸回调(横竖屏切换 / 分屏等场景)
148
+ const mp = getMiniProgramGlobal(platform) as MpWindowResizeApi | null;
149
+ if (mp && typeof mp.onWindowResize === 'function') {
150
+ this.mpResizeHandler = () => this.resizeDebounced!.run();
151
+ mp.onWindowResize(this.mpResizeHandler);
152
+ }
153
+ return;
154
+ }
155
+
156
+ // Web:ResizeObserver 观察 canvas 自身(CSS 宽高 100%,容器变化即触发)。
157
+ // SSR / 构建期无真实元素与 ResizeObserver,跳过。
158
+ const element = this.getElement();
159
+ if (
160
+ element &&
161
+ typeof element.getContext === 'function' &&
162
+ typeof ResizeObserver === 'function'
163
+ ) {
164
+ this.resizeObserver = new ResizeObserver(() => this.resizeDebounced!.run());
165
+ this.resizeObserver.observe(element);
166
+ }
167
+ }
168
+
169
+ private teardownAutoResize(): void {
170
+ this.resizeObserver?.disconnect();
171
+ this.resizeObserver = null;
172
+ this.resizeDebounced?.cancel();
173
+ this.resizeDebounced = null;
174
+ if (this.mpResizeHandler) {
175
+ const platform = detectMiniProgramGlobal();
176
+ const mp = platform
177
+ ? (getMiniProgramGlobal(platform) as MpWindowResizeApi | null)
178
+ : null;
179
+ if (mp && typeof mp.offWindowResize === 'function') {
180
+ mp.offWindowResize(this.mpResizeHandler);
181
+ }
182
+ this.mpResizeHandler = null;
183
+ }
184
+ }
185
+
186
+ private handleResize(): void {
187
+ if (this.destroyed) {
188
+ return;
189
+ }
190
+ if (detectMiniProgramGlobal()) {
191
+ // 小程序:窗口尺寸变化后 SelectorQuery 取到的节点尺寸已更新,
192
+ // 重建图表即可拿到最新尺寸(上下文持有节点实测宽高与 pixelRatio)。
193
+ this.chart?.destroy();
194
+ this.chart = null;
195
+ this.attachChart();
196
+ return;
197
+ }
198
+ const element = this.getElement() as HTMLCanvasElement | null;
199
+ if (!element || typeof element.getContext !== 'function') {
200
+ return;
201
+ }
202
+ const width = element.clientWidth || 0;
203
+ const height = element.clientHeight || 0;
204
+ if (width <= 0 || height <= 0) {
205
+ return;
206
+ }
207
+ if (!this.chart) {
208
+ this.attachChart();
209
+ return;
210
+ }
211
+ // 更新物理像素缓冲(逻辑尺寸 × DPR),并增量重绘(保留图表状态)
212
+ const dpr = detectPixelRatio();
213
+ element.width = Math.round(width * dpr);
214
+ element.height = Math.round(height * dpr);
215
+ this.chart.resize(width, height, dpr).render();
216
+ }
217
+
218
+ private resolveContext(): Promise<ChartRenderContext | null> {
118
219
  if (this.props.resolve) {
119
220
  return Promise.resolve(this.props.resolve(this.getElement()));
120
221
  }
@@ -141,9 +242,15 @@ export class TcChart extends Component<TcChartProps, TcChartState> {
141
242
  return attempt(5);
142
243
  }
143
244
 
144
- // Web:canvas 元素即 HTMLCanvasElement
245
+ // Web:canvas 元素即 HTMLCanvasElement。SSR / 构建期静态渲染环境下
246
+ // 元素没有真实 2d context(如 projectDom),返回 null 跳过渲染,
247
+ // 由客户端水合阶段(真实浏览器)完成绘制。
248
+ const element = this.getElement() as HTMLCanvasElement | null;
249
+ if (!element || typeof element.getContext !== 'function') {
250
+ return Promise.resolve(null);
251
+ }
145
252
  return Promise.resolve(
146
- resolveWebCanvas(this.getElement() as unknown as HTMLCanvasElement)
253
+ resolveWebCanvas(element)
147
254
  );
148
255
  }
149
256
  }
package/lib/core/chart.ts CHANGED
@@ -72,9 +72,13 @@ export abstract class ChartBase<T extends ChartOption> {
72
72
  return this;
73
73
  }
74
74
 
75
- public resize(width: number, height: number): this {
75
+ public resize(width: number, height: number, dpr?: number): this {
76
76
  this.width = Math.max(0, width);
77
77
  this.height = Math.max(0, height);
78
+ // dpr 变化(如窗口跨屏移动)时同步更新,绘制管线按新 dpr 缩放
79
+ if (dpr !== undefined && Number.isFinite(dpr) && dpr > 0) {
80
+ this.dpr = dpr;
81
+ }
78
82
  return this;
79
83
  }
80
84
 
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 零依赖防抖工具。
3
+ *
4
+ * 用于高频触发场景(如窗口 / 容器尺寸变化)的合并调度:
5
+ * 连续调用只会在停止触发 delay 毫秒后执行一次。
6
+ */
7
+
8
+ export interface Debounced<T extends (...args: never[]) => unknown> {
9
+ /** 触发一次;若已有待执行调用则重置计时器(合并连续触发)。 */
10
+ run: (...args: Parameters<T>) => void;
11
+ /** 取消尚未执行的调用。 */
12
+ cancel: () => void;
13
+ /** 是否已有待执行的调用。 */
14
+ pending: () => boolean;
15
+ }
16
+
17
+ type AnyFunction = (...args: any[]) => unknown;
18
+
19
+ export function debounce<T extends AnyFunction>(
20
+ fn: T,
21
+ delay: number
22
+ ): Debounced<T> {
23
+ let timer: ReturnType<typeof setTimeout> | null = null;
24
+ let lastArgs: Parameters<T> | null = null;
25
+
26
+ return {
27
+ run(...args: Parameters<T>): void {
28
+ lastArgs = args;
29
+ if (timer !== null) {
30
+ clearTimeout(timer);
31
+ }
32
+ timer = setTimeout(() => {
33
+ timer = null;
34
+ if (lastArgs !== null) {
35
+ const runArgs = lastArgs;
36
+ lastArgs = null;
37
+ fn(...runArgs);
38
+ }
39
+ }, delay);
40
+ },
41
+ cancel(): void {
42
+ if (timer !== null) {
43
+ clearTimeout(timer);
44
+ timer = null;
45
+ }
46
+ lastArgs = null;
47
+ },
48
+ pending(): boolean {
49
+ return timer !== null;
50
+ },
51
+ };
52
+ }
package/lib/index.ts CHANGED
@@ -30,6 +30,10 @@ export type { LinearScaleOptions, ScaleResult } from './core/scale';
30
30
  export { computeLayout } from './core/layout';
31
31
  export type { Box, LayoutInput, LayoutResult, Padding } from './core/layout';
32
32
 
33
+ // 工具:防抖(自动重绘等高频触发场景)
34
+ export { debounce } from './core/debounce';
35
+ export type { Debounced } from './core/debounce';
36
+
33
37
  // 图表基类
34
38
  export { ChartBase } from './core/chart';
35
39
  export type { CartesianScales } from './core/chart';
package/lib/types.ts CHANGED
@@ -154,6 +154,14 @@ export interface PieChartOption {
154
154
  innerRadius?: number | string;
155
155
  /** 是否显示扇区标签(名称 + 百分比),默认 true。 */
156
156
  showLabel?: boolean;
157
+ /** 标签位置:'inside' 画在扇区内(默认,白字);'outside' 画在扇区外并带引线(适合小扇区 / 长名称)。 */
158
+ labelPosition?: 'inside' | 'outside';
159
+ /** 外部标签引线沿平分线的长度(px),仅 labelPosition: 'outside' 生效,默认 14。 */
160
+ labelLineLength?: number;
161
+ /** 外部标签与引线端点的水平间距(px),仅 labelPosition: 'outside' 生效,默认 6。 */
162
+ labelGap?: number;
163
+ /** 外部标签引线颜色,默认 '#c0c4cc'。 */
164
+ labelLineColor?: string;
157
165
  labelFontSize?: number;
158
166
  labelColor?: string;
159
167
  /** 起始角(弧度),默认 -Math.PI / 2(12 点方向),顺时针。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transone-chart",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "TransOne 跨端图表库:一份 TypeScript 源码,基于 Canvas 2D 渲染,兼容 Web 与多端小程序,预留原生 App(iOS/Android/鸿蒙)扩展",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",