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.
- package/README.md +181 -0
- package/dist/adapters/index.d.ts +7 -0
- package/dist/adapters/miniprogram.d.ts +44 -0
- package/dist/adapters/native.d.ts +28 -0
- package/dist/adapters/types.d.ts +27 -0
- package/dist/adapters/web.d.ts +13 -0
- package/dist/charts/bar.d.ts +25 -0
- package/dist/charts/index.d.ts +4 -0
- package/dist/charts/line.d.ts +20 -0
- package/dist/charts/pie.d.ts +14 -0
- package/dist/charts/radar.d.ts +16 -0
- package/dist/component.d.ts +41 -0
- package/dist/core/axis.d.ts +47 -0
- package/dist/core/canvas.d.ts +61 -0
- package/dist/core/chart.d.ts +69 -0
- package/dist/core/layout.d.ts +58 -0
- package/dist/core/legend.d.ts +26 -0
- package/dist/core/scale.d.ts +67 -0
- package/dist/core/text.d.ts +20 -0
- package/dist/factory.d.ts +22 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +49 -0
- package/dist/types.d.ts +180 -0
- package/lib/adapters/index.ts +18 -0
- package/lib/adapters/miniprogram.ts +146 -0
- package/lib/adapters/native.ts +39 -0
- package/lib/adapters/types.ts +44 -0
- package/lib/adapters/web.ts +41 -0
- package/lib/charts/bar.ts +270 -0
- package/lib/charts/index.ts +4 -0
- package/lib/charts/line.ts +153 -0
- package/lib/charts/pie.ts +110 -0
- package/lib/charts/radar.ts +166 -0
- package/lib/component.ts +149 -0
- package/lib/core/axis.ts +174 -0
- package/lib/core/canvas.ts +130 -0
- package/lib/core/chart.ts +322 -0
- package/lib/core/layout.ts +221 -0
- package/lib/core/legend.ts +94 -0
- package/lib/core/scale.ts +183 -0
- package/lib/core/text.ts +70 -0
- package/lib/factory.ts +62 -0
- package/lib/index.ts +69 -0
- package/lib/types.ts +218 -0
- package/package.json +55 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChartBase:所有图表的抽象基类,负责统一渲染管线。
|
|
3
|
+
*
|
|
4
|
+
* 渲染管线(render):
|
|
5
|
+
* save → scale(dpr) → clear → 背景 → 布局计算
|
|
6
|
+
* → 标题 → 图例 → 笛卡尔坐标轴(折线/柱状)
|
|
7
|
+
* → drawSeries(子类策略) → restore
|
|
8
|
+
*
|
|
9
|
+
* 生命周期:setOption 增量更新 → render;resize 改逻辑尺寸 → render;
|
|
10
|
+
* destroy 释放。所有坐标均为逻辑像素(CSS px),DPR 由管线统一缩放。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ICanvas2D } from './canvas';
|
|
14
|
+
import { AxisDrawer } from './axis';
|
|
15
|
+
import {
|
|
16
|
+
computeLayout,
|
|
17
|
+
type LayoutInput,
|
|
18
|
+
type LayoutResult,
|
|
19
|
+
} from './layout';
|
|
20
|
+
import { LegendDrawer, type LegendItem } from './legend';
|
|
21
|
+
import { CategoryScale, LinearScale } from './scale';
|
|
22
|
+
import { applyFont, measureWidth } from './text';
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_PALETTE,
|
|
25
|
+
type CategoryAxisOption,
|
|
26
|
+
type ChartOption,
|
|
27
|
+
type ChartRenderContext,
|
|
28
|
+
type LegendOption,
|
|
29
|
+
type TitleOption,
|
|
30
|
+
type ValueAxisOption,
|
|
31
|
+
} from '../types';
|
|
32
|
+
|
|
33
|
+
export interface CartesianScales {
|
|
34
|
+
value: LinearScale;
|
|
35
|
+
category: CategoryScale;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 子类需要实现的钩子:
|
|
40
|
+
* - drawSeries:绘制数据系列(核心策略)
|
|
41
|
+
* - hasCartesianAxis:是否需要坐标轴(饼图/雷达返回 false)
|
|
42
|
+
* - getValueDomain:数值轴数据域(有轴时)
|
|
43
|
+
* - getCategoryLabels:类目轴标签(有轴时)
|
|
44
|
+
* - isHorizontal:横向布局(横向柱状图)
|
|
45
|
+
*/
|
|
46
|
+
export abstract class ChartBase<T extends ChartOption> {
|
|
47
|
+
protected readonly ctx: ICanvas2D;
|
|
48
|
+
protected option: T;
|
|
49
|
+
protected width: number;
|
|
50
|
+
protected height: number;
|
|
51
|
+
protected dpr: number;
|
|
52
|
+
protected readonly palette: readonly string[];
|
|
53
|
+
|
|
54
|
+
/** 每次 render 构建的笛卡尔比例尺,供 drawSeries 读取。 */
|
|
55
|
+
protected cartesian: CartesianScales | null = null;
|
|
56
|
+
|
|
57
|
+
private destroyed = false;
|
|
58
|
+
|
|
59
|
+
public constructor(context: ChartRenderContext, option: T) {
|
|
60
|
+
this.ctx = context.ctx;
|
|
61
|
+
this.option = option;
|
|
62
|
+
this.width = context.width;
|
|
63
|
+
this.height = context.height;
|
|
64
|
+
this.dpr = context.dpr;
|
|
65
|
+
this.palette = context.palette ?? DEFAULT_PALETTE;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* —— 生命周期 —— */
|
|
69
|
+
|
|
70
|
+
public setOption(option: Partial<T>): this {
|
|
71
|
+
this.option = { ...this.option, ...option };
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public resize(width: number, height: number): this {
|
|
76
|
+
this.width = Math.max(0, width);
|
|
77
|
+
this.height = Math.max(0, height);
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public render(): this {
|
|
82
|
+
if (this.destroyed) {
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
const { ctx, width, height, dpr } = this;
|
|
86
|
+
|
|
87
|
+
ctx.save();
|
|
88
|
+
ctx.scale(dpr, dpr);
|
|
89
|
+
ctx.clearRect(0, 0, width, height);
|
|
90
|
+
|
|
91
|
+
const background = this.getBackgroundColor();
|
|
92
|
+
if (background) {
|
|
93
|
+
ctx.fillStyle = background;
|
|
94
|
+
ctx.fillRect(0, 0, width, height);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const layout = this.computeLayout();
|
|
98
|
+
this.drawTitle(layout);
|
|
99
|
+
this.drawLegend(layout);
|
|
100
|
+
|
|
101
|
+
if (this.hasCartesianAxis()) {
|
|
102
|
+
this.cartesian = this.buildCartesianScales(layout);
|
|
103
|
+
this.drawCartesianAxis(layout);
|
|
104
|
+
} else {
|
|
105
|
+
this.cartesian = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.drawSeries(ctx, layout, this.option);
|
|
109
|
+
|
|
110
|
+
ctx.restore();
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public destroy(): void {
|
|
115
|
+
this.destroyed = true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public isDestroyed(): boolean {
|
|
119
|
+
return this.destroyed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* —— 子类策略钩子 —— */
|
|
123
|
+
|
|
124
|
+
protected abstract drawSeries(
|
|
125
|
+
ctx: ICanvas2D,
|
|
126
|
+
layout: LayoutResult,
|
|
127
|
+
option: T
|
|
128
|
+
): void;
|
|
129
|
+
|
|
130
|
+
protected hasCartesianAxis(): boolean {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
protected getValueDomain(): { min: number; max: number } {
|
|
135
|
+
return { min: 0, max: 1 };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
protected getCategoryLabels(): readonly string[] {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
protected isHorizontal(): boolean {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* —— 可覆盖的配置来源 —— */
|
|
147
|
+
|
|
148
|
+
protected getBackgroundColor(): string | undefined {
|
|
149
|
+
return (this.option as { backgroundColor?: string }).backgroundColor;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
protected getTitle(): TitleOption | undefined {
|
|
153
|
+
return (this.option as { title?: TitleOption }).title;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
protected getLegend(): LegendOption | undefined {
|
|
157
|
+
const legend = (this.option as { legend?: LegendOption }).legend;
|
|
158
|
+
if (legend) {
|
|
159
|
+
return legend;
|
|
160
|
+
}
|
|
161
|
+
// 未显式配置图例但存在命名系列/扇区时,默认在顶部显示
|
|
162
|
+
return this.getLegendItems().length > 0 ? { position: 'top' } : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
protected getLegendItems(): LegendItem[] {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
protected getValueAxis(): ValueAxisOption | undefined {
|
|
170
|
+
return (this.option as { yAxis?: ValueAxisOption }).yAxis;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
protected getCategoryAxis(): CategoryAxisOption | undefined {
|
|
174
|
+
return (this.option as { xAxis?: CategoryAxisOption }).xAxis;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* —— 渲染管线内部 —— */
|
|
178
|
+
|
|
179
|
+
protected computeLayout(): LayoutResult {
|
|
180
|
+
const input = this.buildLayoutInput();
|
|
181
|
+
const measure = (text: string): number =>
|
|
182
|
+
measureWidth(this.ctx, text, 11);
|
|
183
|
+
return computeLayout(input, measure);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
protected buildLayoutInput(): LayoutInput {
|
|
187
|
+
// 轴标签区需要知道刻度标签宽度,而刻度与布局无关(仅依赖数据域),
|
|
188
|
+
// 因此用占位 range 预计算刻度 → 布局 → 最终 scale 两阶段完成。
|
|
189
|
+
const horizontal = this.isHorizontal();
|
|
190
|
+
const hasAxis = this.hasCartesianAxis();
|
|
191
|
+
return {
|
|
192
|
+
width: this.width,
|
|
193
|
+
height: this.height,
|
|
194
|
+
title: this.getTitle(),
|
|
195
|
+
legend: this.getLegend(),
|
|
196
|
+
legendItems: this.getLegendItems().map((item) => item.name),
|
|
197
|
+
valueLabels: hasAxis && !horizontal ? this.computeValueLabels() : undefined,
|
|
198
|
+
categoryLabels: hasAxis ? this.getCategoryLabels() : undefined,
|
|
199
|
+
horizontal,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 用占位 range 预计算数值刻度标签(仅用于测量,不参与最终映射)。 */
|
|
204
|
+
protected computeValueLabels(): string[] {
|
|
205
|
+
const domain = this.getValueDomain();
|
|
206
|
+
const valueAxis = this.getValueAxis();
|
|
207
|
+
const probe = new LinearScale(domain.min, domain.max, 0, 1, {
|
|
208
|
+
min: valueAxis?.min,
|
|
209
|
+
max: valueAxis?.max,
|
|
210
|
+
splitCount: valueAxis?.splitCount,
|
|
211
|
+
});
|
|
212
|
+
return probe.ticks.map((tick) =>
|
|
213
|
+
valueAxis?.format ? valueAxis.format(tick) : String(tick)
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
protected drawTitle(layout: LayoutResult): void {
|
|
218
|
+
const title = this.getTitle();
|
|
219
|
+
if (!title?.text || !layout.titleBox) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const { ctx } = this;
|
|
223
|
+
applyFont(ctx, title.fontSize ?? 16);
|
|
224
|
+
ctx.fillStyle = title.color ?? '#323233';
|
|
225
|
+
ctx.textAlign = 'center';
|
|
226
|
+
ctx.textBaseline = 'top';
|
|
227
|
+
ctx.fillText(title.text, this.width / 2, layout.titleBox.y);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
protected drawLegend(layout: LayoutResult): void {
|
|
231
|
+
const legend = this.getLegend();
|
|
232
|
+
if (legend?.show === false || !layout.legendBox) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const items = this.getLegendItems();
|
|
236
|
+
if (items.length === 0) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
new LegendDrawer(this.ctx).draw({
|
|
240
|
+
box: layout.legendBox,
|
|
241
|
+
items,
|
|
242
|
+
position: legend?.position ?? 'top',
|
|
243
|
+
fontSize: legend?.fontSize,
|
|
244
|
+
color: legend?.color,
|
|
245
|
+
markerSize: legend?.markerSize,
|
|
246
|
+
itemGap: legend?.itemGap,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
protected buildCartesianScales(layout: LayoutResult): CartesianScales {
|
|
251
|
+
const { plot } = layout;
|
|
252
|
+
const horizontal = this.isHorizontal();
|
|
253
|
+
const domain = this.getValueDomain();
|
|
254
|
+
const categories = this.getCategoryLabels();
|
|
255
|
+
const valueAxis = this.getValueAxis();
|
|
256
|
+
|
|
257
|
+
const value = new LinearScale(
|
|
258
|
+
domain.min,
|
|
259
|
+
domain.max,
|
|
260
|
+
horizontal ? plot.x : plot.y + plot.height,
|
|
261
|
+
horizontal ? plot.x + plot.width : plot.y,
|
|
262
|
+
{ min: valueAxis?.min, max: valueAxis?.max, splitCount: valueAxis?.splitCount }
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const category = new CategoryScale(
|
|
266
|
+
categories,
|
|
267
|
+
horizontal ? plot.y : plot.x,
|
|
268
|
+
horizontal ? plot.y + plot.height : plot.x + plot.width
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
return { value, category };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
protected drawCartesianAxis(layout: LayoutResult): void {
|
|
275
|
+
const scales = this.cartesian;
|
|
276
|
+
if (!scales) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const { ctx } = this;
|
|
280
|
+
const { plot } = layout;
|
|
281
|
+
const horizontal = this.isHorizontal();
|
|
282
|
+
const valueAxis = this.getValueAxis();
|
|
283
|
+
const categoryAxis = this.getCategoryAxis();
|
|
284
|
+
const categories = this.getCategoryLabels();
|
|
285
|
+
|
|
286
|
+
const drawer = new AxisDrawer(ctx);
|
|
287
|
+
|
|
288
|
+
const valueLabels = scales.value.ticks.map((tick) =>
|
|
289
|
+
valueAxis?.format ? valueAxis.format(tick) : String(tick)
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
drawer.drawValueAxis({
|
|
293
|
+
plot,
|
|
294
|
+
scale: scales.value,
|
|
295
|
+
labels: valueLabels,
|
|
296
|
+
horizontal,
|
|
297
|
+
labelFontSize: valueAxis?.labelFontSize,
|
|
298
|
+
labelColor: valueAxis?.labelColor,
|
|
299
|
+
gridColor: valueAxis?.gridColor,
|
|
300
|
+
gridLineWidth: valueAxis?.gridLineWidth,
|
|
301
|
+
gridLineDash: valueAxis?.gridLineDash,
|
|
302
|
+
showGrid: valueAxis?.showGrid ?? true,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
drawer.drawCategoryAxis({
|
|
306
|
+
plot,
|
|
307
|
+
scale: scales.category,
|
|
308
|
+
labels: categories,
|
|
309
|
+
horizontal,
|
|
310
|
+
labelFontSize: categoryAxis?.labelFontSize,
|
|
311
|
+
labelColor: categoryAxis?.labelColor,
|
|
312
|
+
gridColor: categoryAxis?.gridColor,
|
|
313
|
+
gridLineWidth: categoryAxis?.gridLineWidth,
|
|
314
|
+
showGrid: categoryAxis?.showGrid ?? false,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 系列取色:显式 color 优先,否则按索引取色板。 */
|
|
319
|
+
protected seriesColor(index: number, color?: string): string {
|
|
320
|
+
return color ?? this.palette[index % this.palette.length];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图表布局计算:在给定画布尺寸内,按 title / legend / 坐标轴标签区
|
|
3
|
+
* 逐层扣除,得到最终绘图区(plot)。
|
|
4
|
+
*
|
|
5
|
+
* 布局只依赖注入的文本测量函数,不接触 Canvas 上下文本身,
|
|
6
|
+
* 保持纯逻辑可测。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { LegendOption, TitleOption } from '../types';
|
|
10
|
+
|
|
11
|
+
export interface Box {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Padding {
|
|
19
|
+
top: number;
|
|
20
|
+
right: number;
|
|
21
|
+
bottom: number;
|
|
22
|
+
left: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LayoutInput {
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
title?: TitleOption;
|
|
29
|
+
/** 图例配置(已解析 show/position)。 */
|
|
30
|
+
legend?: LegendOption;
|
|
31
|
+
/** 图例项名称列表(用于测量总宽度)。 */
|
|
32
|
+
legendItems?: readonly string[];
|
|
33
|
+
/** 数值轴刻度标签(测量最大宽度)。 */
|
|
34
|
+
valueLabels?: readonly string[];
|
|
35
|
+
/** 类目轴标签(测量最大宽度/高度)。 */
|
|
36
|
+
categoryLabels?: readonly string[];
|
|
37
|
+
/** 横向布局(横向柱状图):类目轴占左侧、数值轴占底部。 */
|
|
38
|
+
horizontal?: boolean;
|
|
39
|
+
/** 基础内边距,默认 { top: 12, right: 16, bottom: 12, left: 16 }。 */
|
|
40
|
+
basePadding?: Partial<Padding>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LayoutResult {
|
|
44
|
+
/** 绘图区(含网格与数据系列)。 */
|
|
45
|
+
plot: Box;
|
|
46
|
+
/** 数值轴标签区(横轴图中在左侧,横向图中在底部)。 */
|
|
47
|
+
valueAxisBox: Box;
|
|
48
|
+
/** 类目轴标签区(横轴图中在底部,横向图中在左侧)。 */
|
|
49
|
+
categoryAxisBox: Box;
|
|
50
|
+
/** 标题占用区(若存在)。 */
|
|
51
|
+
titleBox: Box | null;
|
|
52
|
+
/** 图例占用区(若存在)。 */
|
|
53
|
+
legendBox: Box | null;
|
|
54
|
+
padding: Padding;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type MeasureTextFn = (text: string) => number;
|
|
58
|
+
|
|
59
|
+
const DEFAULT_PADDING: Padding = { top: 12, right: 16, bottom: 12, left: 16 };
|
|
60
|
+
const TITLE_PADDING = 16;
|
|
61
|
+
const AXIS_LABEL_PADDING = 8;
|
|
62
|
+
const LEGEND_PADDING = 12;
|
|
63
|
+
|
|
64
|
+
/** 计算图例占用的行高(top/bottom 单行)或列宽(right 纵向)。 */
|
|
65
|
+
function measureLegend(
|
|
66
|
+
input: LayoutInput,
|
|
67
|
+
measure: MeasureTextFn
|
|
68
|
+
): { width: number; height: number } {
|
|
69
|
+
const { legend } = input;
|
|
70
|
+
const items = input.legendItems ?? [];
|
|
71
|
+
const fontSize = legend?.fontSize ?? 12;
|
|
72
|
+
const itemGap = legend?.itemGap ?? 16;
|
|
73
|
+
const markerSize = legend?.markerSize ?? 12;
|
|
74
|
+
|
|
75
|
+
if (legend?.position === 'right') {
|
|
76
|
+
// 纵向:宽度 = 最宽项(色块 + 间距 + 文本),高度 = 项数 × 行高
|
|
77
|
+
let maxWidth = 0;
|
|
78
|
+
for (const item of items) {
|
|
79
|
+
const w = markerSize + 6 + measure(item);
|
|
80
|
+
if (w > maxWidth) {
|
|
81
|
+
maxWidth = w;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
width: maxWidth + LEGEND_PADDING,
|
|
86
|
+
height: Math.max(items.length * (fontSize + 8) + LEGEND_PADDING, 0),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 横向:单行居中,总宽 = 各项宽之和
|
|
91
|
+
let totalWidth = 0;
|
|
92
|
+
for (const item of items) {
|
|
93
|
+
totalWidth += markerSize + 6 + measure(item) + itemGap;
|
|
94
|
+
}
|
|
95
|
+
if (totalWidth > 0) {
|
|
96
|
+
totalWidth -= itemGap;
|
|
97
|
+
}
|
|
98
|
+
return { width: totalWidth, height: fontSize + LEGEND_PADDING + 4 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 计算完整布局。规则:
|
|
103
|
+
* - title 固定顶部
|
|
104
|
+
* - legend:top → title 之下;bottom → 底部;right → 最右侧
|
|
105
|
+
* - 数值轴标签区与类目轴标签区按 horizontal 互换左右/底部
|
|
106
|
+
*/
|
|
107
|
+
export function computeLayout(
|
|
108
|
+
input: LayoutInput,
|
|
109
|
+
measure: MeasureTextFn
|
|
110
|
+
): LayoutResult {
|
|
111
|
+
const padding: Padding = { ...DEFAULT_PADDING, ...input.basePadding };
|
|
112
|
+
|
|
113
|
+
let top = padding.top;
|
|
114
|
+
let bottom = padding.bottom;
|
|
115
|
+
let left = padding.left;
|
|
116
|
+
let right = padding.right;
|
|
117
|
+
|
|
118
|
+
let titleBox: Box | null = null;
|
|
119
|
+
if (input.title?.text) {
|
|
120
|
+
const fontSize = input.title.fontSize ?? 16;
|
|
121
|
+
titleBox = { x: 0, y: 0, width: input.width, height: fontSize };
|
|
122
|
+
top += fontSize + (input.title.padding ?? TITLE_PADDING);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let legendBox: Box | null = null;
|
|
126
|
+
const legend = input.legend;
|
|
127
|
+
if (legend && legend.show !== false && (input.legendItems?.length ?? 0) > 0) {
|
|
128
|
+
const size = measureLegend(input, measure);
|
|
129
|
+
const position = legend.position ?? 'top';
|
|
130
|
+
if (position === 'top') {
|
|
131
|
+
legendBox = {
|
|
132
|
+
x: 0,
|
|
133
|
+
y: titleBox ? titleBox.height : 0,
|
|
134
|
+
width: input.width,
|
|
135
|
+
height: size.height,
|
|
136
|
+
};
|
|
137
|
+
top += size.height;
|
|
138
|
+
} else if (position === 'bottom') {
|
|
139
|
+
legendBox = {
|
|
140
|
+
x: 0,
|
|
141
|
+
y: input.height - size.height,
|
|
142
|
+
width: input.width,
|
|
143
|
+
height: size.height,
|
|
144
|
+
};
|
|
145
|
+
bottom += size.height;
|
|
146
|
+
} else {
|
|
147
|
+
legendBox = {
|
|
148
|
+
x: input.width - size.width,
|
|
149
|
+
y: top,
|
|
150
|
+
width: size.width,
|
|
151
|
+
height: Math.max(0, input.height - top - bottom),
|
|
152
|
+
};
|
|
153
|
+
right += size.width;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 坐标轴标签区
|
|
158
|
+
const labelFontSize = 11;
|
|
159
|
+
let valueAxisWidth = 0;
|
|
160
|
+
let categoryAxisHeight = 0;
|
|
161
|
+
let valueAxisHeight = 0;
|
|
162
|
+
let categoryAxisWidth = 0;
|
|
163
|
+
|
|
164
|
+
const valueLabels = input.valueLabels ?? [];
|
|
165
|
+
const categoryLabels = input.categoryLabels ?? [];
|
|
166
|
+
|
|
167
|
+
if (input.horizontal) {
|
|
168
|
+
// 横向图:类目轴在左侧(列宽),数值轴在底部(行高)
|
|
169
|
+
if (categoryLabels.length > 0) {
|
|
170
|
+
for (const label of categoryLabels) {
|
|
171
|
+
const w = measure(label);
|
|
172
|
+
if (w > categoryAxisWidth) {
|
|
173
|
+
categoryAxisWidth = w;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
categoryAxisWidth += AXIS_LABEL_PADDING * 2;
|
|
177
|
+
left += categoryAxisWidth;
|
|
178
|
+
}
|
|
179
|
+
if (valueLabels.length > 0) {
|
|
180
|
+
valueAxisHeight = labelFontSize + AXIS_LABEL_PADDING * 2;
|
|
181
|
+
bottom += valueAxisHeight;
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
if (valueLabels.length > 0) {
|
|
185
|
+
for (const label of valueLabels) {
|
|
186
|
+
const w = measure(label);
|
|
187
|
+
if (w > valueAxisWidth) {
|
|
188
|
+
valueAxisWidth = w;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
valueAxisWidth += AXIS_LABEL_PADDING * 2;
|
|
192
|
+
left += valueAxisWidth;
|
|
193
|
+
}
|
|
194
|
+
if (categoryLabels.length > 0) {
|
|
195
|
+
categoryAxisHeight = labelFontSize + AXIS_LABEL_PADDING * 2;
|
|
196
|
+
bottom += categoryAxisHeight;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const plotWidth = Math.max(0, input.width - left - right);
|
|
201
|
+
const plotHeight = Math.max(0, input.height - top - bottom);
|
|
202
|
+
|
|
203
|
+
const plot: Box = { x: left, y: top, width: plotWidth, height: plotHeight };
|
|
204
|
+
|
|
205
|
+
const valueAxisBox: Box = input.horizontal
|
|
206
|
+
? { x: plot.x, y: plot.y + plot.height, width: plotWidth, height: valueAxisHeight }
|
|
207
|
+
: { x: plot.x - valueAxisWidth, y: top, width: valueAxisWidth, height: plotHeight };
|
|
208
|
+
|
|
209
|
+
const categoryAxisBox: Box = input.horizontal
|
|
210
|
+
? { x: plot.x - categoryAxisWidth, y: top, width: categoryAxisWidth, height: plotHeight }
|
|
211
|
+
: { x: left, y: plot.y + plotHeight, width: plotWidth, height: categoryAxisHeight };
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
plot,
|
|
215
|
+
valueAxisBox,
|
|
216
|
+
categoryAxisBox,
|
|
217
|
+
titleBox,
|
|
218
|
+
legendBox,
|
|
219
|
+
padding,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图例绘制:色块 + 文本。
|
|
3
|
+
* - top / bottom:横向单行居中排布
|
|
4
|
+
* - right:纵向排布
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ICanvas2D } from './canvas';
|
|
8
|
+
import type { Box } from './layout';
|
|
9
|
+
import { applyFont, measureWidth } from './text';
|
|
10
|
+
import { DEFAULT_TEXT_COLOR } from '../types';
|
|
11
|
+
|
|
12
|
+
export interface LegendItem {
|
|
13
|
+
name: string;
|
|
14
|
+
color: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LegendDrawOptions {
|
|
18
|
+
box: Box;
|
|
19
|
+
items: readonly LegendItem[];
|
|
20
|
+
position?: 'top' | 'bottom' | 'right';
|
|
21
|
+
fontSize?: number;
|
|
22
|
+
color?: string;
|
|
23
|
+
markerSize?: number;
|
|
24
|
+
itemGap?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class LegendDrawer {
|
|
28
|
+
public constructor(private readonly ctx: ICanvas2D) {}
|
|
29
|
+
|
|
30
|
+
public draw(options: LegendDrawOptions): void {
|
|
31
|
+
const {
|
|
32
|
+
box,
|
|
33
|
+
items,
|
|
34
|
+
position = 'top',
|
|
35
|
+
fontSize = 12,
|
|
36
|
+
color = DEFAULT_TEXT_COLOR,
|
|
37
|
+
markerSize = 12,
|
|
38
|
+
itemGap = 16,
|
|
39
|
+
} = options;
|
|
40
|
+
|
|
41
|
+
if (items.length === 0) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { ctx } = this;
|
|
46
|
+
applyFont(ctx, fontSize);
|
|
47
|
+
|
|
48
|
+
if (position === 'right') {
|
|
49
|
+
// 纵向:从顶部开始逐项排列
|
|
50
|
+
let y = box.y + fontSize / 2 + 4;
|
|
51
|
+
for (const item of items) {
|
|
52
|
+
this.drawItem(item, box.x, y, fontSize, markerSize, color);
|
|
53
|
+
y += fontSize + 8;
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 横向:居中排布
|
|
59
|
+
let totalWidth = 0;
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
totalWidth += markerSize + 6 + measureWidth(ctx, item.name, fontSize) + itemGap;
|
|
62
|
+
}
|
|
63
|
+
totalWidth -= itemGap;
|
|
64
|
+
|
|
65
|
+
let x = box.x + (box.width - totalWidth) / 2;
|
|
66
|
+
const y = box.y + box.height / 2;
|
|
67
|
+
|
|
68
|
+
for (const item of items) {
|
|
69
|
+
this.drawItem(item, x, y, fontSize, markerSize, color);
|
|
70
|
+
x += markerSize + 6 + measureWidth(ctx, item.name, fontSize) + itemGap;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private drawItem(
|
|
75
|
+
item: LegendItem,
|
|
76
|
+
x: number,
|
|
77
|
+
centerY: number,
|
|
78
|
+
_fontSize: number,
|
|
79
|
+
markerSize: number,
|
|
80
|
+
color: string
|
|
81
|
+
): void {
|
|
82
|
+
const { ctx } = this;
|
|
83
|
+
// 色块
|
|
84
|
+
ctx.fillStyle = item.color;
|
|
85
|
+
ctx.beginPath();
|
|
86
|
+
ctx.rect(x, centerY - markerSize / 2, markerSize, markerSize);
|
|
87
|
+
ctx.fill();
|
|
88
|
+
// 文本
|
|
89
|
+
ctx.fillStyle = color;
|
|
90
|
+
ctx.textAlign = 'left';
|
|
91
|
+
ctx.textBaseline = 'middle';
|
|
92
|
+
ctx.fillText(item.name, x + markerSize + 6, centerY);
|
|
93
|
+
}
|
|
94
|
+
}
|