@allternit/viz 0.1.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/README.md +217 -0
- package/dist/charts/canvas-renderer.d.ts +40 -0
- package/dist/charts/canvas-renderer.d.ts.bak +40 -0
- package/dist/charts/canvas-renderer.d.ts.map +1 -0
- package/dist/charts/canvas-renderer.js +580 -0
- package/dist/charts/canvas-renderer.js.bak +580 -0
- package/dist/charts/canvas-renderer.js.map +1 -0
- package/dist/charts/svg-renderer.d.ts +21 -0
- package/dist/charts/svg-renderer.d.ts.bak +21 -0
- package/dist/charts/svg-renderer.d.ts.map +1 -0
- package/dist/charts/svg-renderer.js +285 -0
- package/dist/charts/svg-renderer.js.bak +285 -0
- package/dist/charts/svg-renderer.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.bak +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.bak +35 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +342 -0
- package/dist/types.d.ts.bak +342 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +39 -0
- package/dist/types.js.bak +39 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/data-processor.d.ts +64 -0
- package/dist/utils/data-processor.d.ts.bak +64 -0
- package/dist/utils/data-processor.d.ts.map +1 -0
- package/dist/utils/data-processor.js +326 -0
- package/dist/utils/data-processor.js.bak +326 -0
- package/dist/utils/data-processor.js.map +1 -0
- package/package.json +41 -0
- package/src/charts/canvas-renderer.ts +735 -0
- package/src/charts/canvas-renderer.ts.bak +735 -0
- package/src/charts/svg-renderer.test.ts.bak +837 -0
- package/src/charts/svg-renderer.ts +380 -0
- package/src/charts/svg-renderer.ts.bak +380 -0
- package/src/index.ts +64 -0
- package/src/index.ts.bak +64 -0
- package/src/types.ts +410 -0
- package/src/types.ts.bak +410 -0
- package/src/utils/data-processor.test.ts.bak +720 -0
- package/src/utils/data-processor.ts +420 -0
- package/src/utils/data-processor.ts.bak +420 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Chart Renderer
|
|
3
|
+
*
|
|
4
|
+
* Renders charts to HTML Canvas with animation support.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChartConfig, DataSeries, ChartMetadata, DataPoint } from '../types';
|
|
8
|
+
import { palettes } from '../types';
|
|
9
|
+
|
|
10
|
+
export interface CanvasRenderer {
|
|
11
|
+
/** Render chart to canvas */
|
|
12
|
+
render(config: ChartConfig, series: DataSeries[]): HTMLCanvasElement;
|
|
13
|
+
/** Render with animation */
|
|
14
|
+
renderAnimated(config: ChartConfig, series: DataSeries[], duration?: number): Promise<HTMLCanvasElement>;
|
|
15
|
+
/** Get chart metadata */
|
|
16
|
+
getMetadata(type: string): ChartMetadata;
|
|
17
|
+
/** Export to data URL */
|
|
18
|
+
toDataURL(format?: 'png' | 'jpeg', quality?: number): string | null;
|
|
19
|
+
/** Get canvas element */
|
|
20
|
+
getCanvas(): HTMLCanvasElement | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RenderContext {
|
|
24
|
+
ctx: CanvasRenderingContext2D;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
chartWidth: number;
|
|
28
|
+
chartHeight: number;
|
|
29
|
+
margin: { top: number; right: number; bottom: number; left: number };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create Canvas chart renderer
|
|
34
|
+
*/
|
|
35
|
+
export function createCanvasRenderer(): CanvasRenderer {
|
|
36
|
+
let currentCanvas: HTMLCanvasElement | null = null;
|
|
37
|
+
let currentCtx: CanvasRenderingContext2D | null = null;
|
|
38
|
+
const defaultWidth = 800;
|
|
39
|
+
const defaultHeight = 400;
|
|
40
|
+
const margin = { top: 60, right: 40, bottom: 60, left: 80 };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create and setup canvas
|
|
44
|
+
*/
|
|
45
|
+
function setupCanvas(width: number, height: number): RenderContext {
|
|
46
|
+
if (typeof document === 'undefined') {
|
|
47
|
+
// Node.js environment - create mock canvas
|
|
48
|
+
currentCanvas = {
|
|
49
|
+
width,
|
|
50
|
+
height,
|
|
51
|
+
getContext: () => ({
|
|
52
|
+
fillStyle: '',
|
|
53
|
+
strokeStyle: '',
|
|
54
|
+
lineWidth: 1,
|
|
55
|
+
font: '',
|
|
56
|
+
textAlign: 'left' as CanvasTextAlign,
|
|
57
|
+
textBaseline: 'alphabetic' as CanvasTextBaseline,
|
|
58
|
+
fillRect: () => {},
|
|
59
|
+
strokeRect: () => {},
|
|
60
|
+
clearRect: () => {},
|
|
61
|
+
beginPath: () => {},
|
|
62
|
+
closePath: () => {},
|
|
63
|
+
moveTo: () => {},
|
|
64
|
+
lineTo: () => {},
|
|
65
|
+
arc: () => {},
|
|
66
|
+
fill: () => {},
|
|
67
|
+
stroke: () => {},
|
|
68
|
+
fillText: () => {},
|
|
69
|
+
strokeText: () => {},
|
|
70
|
+
save: () => {},
|
|
71
|
+
restore: () => {},
|
|
72
|
+
translate: () => {},
|
|
73
|
+
scale: () => {},
|
|
74
|
+
rotate: () => {},
|
|
75
|
+
measureText: () => ({ width: 0 }),
|
|
76
|
+
}),
|
|
77
|
+
toDataURL: () => '',
|
|
78
|
+
} as unknown as HTMLCanvasElement;
|
|
79
|
+
currentCtx = currentCanvas.getContext('2d') as CanvasRenderingContext2D;
|
|
80
|
+
} else {
|
|
81
|
+
currentCanvas = document.createElement('canvas');
|
|
82
|
+
currentCanvas.width = width;
|
|
83
|
+
currentCanvas.height = height;
|
|
84
|
+
currentCtx = currentCanvas.getContext('2d');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!currentCtx) {
|
|
88
|
+
throw new Error('Failed to get canvas context');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
ctx: currentCtx,
|
|
93
|
+
width,
|
|
94
|
+
height,
|
|
95
|
+
chartWidth: width - margin.left - margin.right,
|
|
96
|
+
chartHeight: height - margin.top - margin.bottom,
|
|
97
|
+
margin,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Render chart to canvas
|
|
103
|
+
*/
|
|
104
|
+
function render(config: ChartConfig, series: DataSeries[]): HTMLCanvasElement {
|
|
105
|
+
const width = config.width || defaultWidth;
|
|
106
|
+
const height = config.height || defaultHeight;
|
|
107
|
+
const palette = palettes[config.theme || 'default'];
|
|
108
|
+
|
|
109
|
+
const rc = setupCanvas(width, height);
|
|
110
|
+
const { ctx } = rc;
|
|
111
|
+
|
|
112
|
+
// Clear background
|
|
113
|
+
ctx.fillStyle = palette.background[0];
|
|
114
|
+
ctx.fillRect(0, 0, width, height);
|
|
115
|
+
|
|
116
|
+
// Title
|
|
117
|
+
if (config.title) {
|
|
118
|
+
ctx.fillStyle = palette.text.primary;
|
|
119
|
+
ctx.font = 'bold 18px sans-serif';
|
|
120
|
+
ctx.textAlign = 'center';
|
|
121
|
+
ctx.fillText(config.title, width / 2, 30);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Clip to chart area
|
|
125
|
+
ctx.save();
|
|
126
|
+
ctx.translate(margin.left, margin.top);
|
|
127
|
+
|
|
128
|
+
// Render based on chart type
|
|
129
|
+
switch (config.type) {
|
|
130
|
+
case 'bar':
|
|
131
|
+
renderBarChart(rc, series, palette, config);
|
|
132
|
+
break;
|
|
133
|
+
case 'line':
|
|
134
|
+
renderLineChart(rc, series, palette, config);
|
|
135
|
+
break;
|
|
136
|
+
case 'pie':
|
|
137
|
+
case 'donut':
|
|
138
|
+
renderPieChart(rc, series, palette, config);
|
|
139
|
+
break;
|
|
140
|
+
case 'area':
|
|
141
|
+
renderAreaChart(rc, series, palette, config);
|
|
142
|
+
break;
|
|
143
|
+
default:
|
|
144
|
+
renderPlaceholder(rc, config.type, palette);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Axes
|
|
148
|
+
if (['bar', 'line', 'area'].includes(config.type)) {
|
|
149
|
+
renderAxes(rc, series, config, palette);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
ctx.restore();
|
|
153
|
+
|
|
154
|
+
return currentCanvas!;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Render with animation
|
|
159
|
+
*/
|
|
160
|
+
function renderAnimated(
|
|
161
|
+
config: ChartConfig,
|
|
162
|
+
series: DataSeries[],
|
|
163
|
+
duration: number = 1000
|
|
164
|
+
): Promise<HTMLCanvasElement> {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
const startTime = performance.now();
|
|
167
|
+
const width = config.width || defaultWidth;
|
|
168
|
+
const height = config.height || defaultHeight;
|
|
169
|
+
const palette = palettes[config.theme || 'default'];
|
|
170
|
+
|
|
171
|
+
function animate(currentTime: number) {
|
|
172
|
+
const elapsed = currentTime - startTime;
|
|
173
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
174
|
+
const eased = easeOutCubic(progress);
|
|
175
|
+
|
|
176
|
+
// Render with animation progress
|
|
177
|
+
const rc = setupCanvas(width, height);
|
|
178
|
+
const { ctx } = rc;
|
|
179
|
+
|
|
180
|
+
// Clear background
|
|
181
|
+
ctx.fillStyle = palette.background[0];
|
|
182
|
+
ctx.fillRect(0, 0, width, height);
|
|
183
|
+
|
|
184
|
+
// Title
|
|
185
|
+
if (config.title) {
|
|
186
|
+
ctx.fillStyle = palette.text.primary;
|
|
187
|
+
ctx.font = 'bold 18px sans-serif';
|
|
188
|
+
ctx.textAlign = 'center';
|
|
189
|
+
ctx.fillText(config.title, width / 2, 30);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
ctx.save();
|
|
193
|
+
ctx.translate(margin.left, margin.top);
|
|
194
|
+
|
|
195
|
+
// Render with animation progress
|
|
196
|
+
switch (config.type) {
|
|
197
|
+
case 'bar':
|
|
198
|
+
renderBarChartAnimated(rc, series, palette, config, eased);
|
|
199
|
+
break;
|
|
200
|
+
case 'line':
|
|
201
|
+
renderLineChartAnimated(rc, series, palette, config, eased);
|
|
202
|
+
break;
|
|
203
|
+
case 'pie':
|
|
204
|
+
case 'donut':
|
|
205
|
+
renderPieChartAnimated(rc, series, palette, config, eased);
|
|
206
|
+
break;
|
|
207
|
+
case 'area':
|
|
208
|
+
renderAreaChartAnimated(rc, series, palette, config, eased);
|
|
209
|
+
break;
|
|
210
|
+
default:
|
|
211
|
+
renderPlaceholder(rc, config.type, palette);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Axes
|
|
215
|
+
if (['bar', 'line', 'area'].includes(config.type)) {
|
|
216
|
+
renderAxes(rc, series, config, palette);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
ctx.restore();
|
|
220
|
+
|
|
221
|
+
if (progress < 1) {
|
|
222
|
+
requestAnimationFrame(animate);
|
|
223
|
+
} else {
|
|
224
|
+
resolve(currentCanvas!);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
requestAnimationFrame(animate);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Easing function for smooth animations
|
|
234
|
+
*/
|
|
235
|
+
function easeOutCubic(t: number): number {
|
|
236
|
+
return 1 - Math.pow(1 - t, 3);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Render bar chart
|
|
241
|
+
*/
|
|
242
|
+
function renderBarChart(
|
|
243
|
+
rc: RenderContext,
|
|
244
|
+
series: DataSeries[],
|
|
245
|
+
palette: typeof palettes['default'],
|
|
246
|
+
config: ChartConfig
|
|
247
|
+
): void {
|
|
248
|
+
if (series.length === 0) return;
|
|
249
|
+
|
|
250
|
+
const { ctx, chartWidth, chartHeight } = rc;
|
|
251
|
+
const allData = series.flatMap((s) => s.data);
|
|
252
|
+
const maxValue = Math.max(...allData.map((d) => (d.y || d.value || 0) as number));
|
|
253
|
+
const categories = [...new Set(allData.map((d) => d.x || d.name))];
|
|
254
|
+
|
|
255
|
+
const barWidth = (chartWidth / categories.length) * 0.6 / series.length;
|
|
256
|
+
const scaleY = chartHeight / (maxValue * 1.1);
|
|
257
|
+
|
|
258
|
+
series.forEach((s, seriesIndex) => {
|
|
259
|
+
const color = s.color || palette.primary[seriesIndex % palette.primary.length];
|
|
260
|
+
ctx.fillStyle = color;
|
|
261
|
+
|
|
262
|
+
s.data.forEach((point, pointIndex) => {
|
|
263
|
+
const value = (point.y || point.value || 0) as number;
|
|
264
|
+
const barHeight = value * scaleY;
|
|
265
|
+
const x = (pointIndex + 0.2) * (chartWidth / categories.length) + seriesIndex * barWidth;
|
|
266
|
+
const y = chartHeight - barHeight;
|
|
267
|
+
|
|
268
|
+
ctx.fillRect(x, y, barWidth, barHeight);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Render animated bar chart
|
|
275
|
+
*/
|
|
276
|
+
function renderBarChartAnimated(
|
|
277
|
+
rc: RenderContext,
|
|
278
|
+
series: DataSeries[],
|
|
279
|
+
palette: typeof palettes['default'],
|
|
280
|
+
config: ChartConfig,
|
|
281
|
+
progress: number
|
|
282
|
+
): void {
|
|
283
|
+
if (series.length === 0) return;
|
|
284
|
+
|
|
285
|
+
const { ctx, chartWidth, chartHeight } = rc;
|
|
286
|
+
const allData = series.flatMap((s) => s.data);
|
|
287
|
+
const maxValue = Math.max(...allData.map((d) => (d.y || d.value || 0) as number));
|
|
288
|
+
const categories = [...new Set(allData.map((d) => d.x || d.name))];
|
|
289
|
+
|
|
290
|
+
const barWidth = (chartWidth / categories.length) * 0.6 / series.length;
|
|
291
|
+
const scaleY = chartHeight / (maxValue * 1.1);
|
|
292
|
+
|
|
293
|
+
series.forEach((s, seriesIndex) => {
|
|
294
|
+
const color = s.color || palette.primary[seriesIndex % palette.primary.length];
|
|
295
|
+
ctx.fillStyle = color;
|
|
296
|
+
|
|
297
|
+
s.data.forEach((point, pointIndex) => {
|
|
298
|
+
const value = (point.y || point.value || 0) as number;
|
|
299
|
+
const barHeight = value * scaleY * progress;
|
|
300
|
+
const x = (pointIndex + 0.2) * (chartWidth / categories.length) + seriesIndex * barWidth;
|
|
301
|
+
const y = chartHeight - barHeight;
|
|
302
|
+
|
|
303
|
+
ctx.fillRect(x, y, barWidth, barHeight);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Render line chart
|
|
310
|
+
*/
|
|
311
|
+
function renderLineChart(
|
|
312
|
+
rc: RenderContext,
|
|
313
|
+
series: DataSeries[],
|
|
314
|
+
palette: typeof palettes['default'],
|
|
315
|
+
config: ChartConfig
|
|
316
|
+
): void {
|
|
317
|
+
if (series.length === 0) return;
|
|
318
|
+
|
|
319
|
+
const { ctx, chartWidth, chartHeight } = rc;
|
|
320
|
+
const allData = series.flatMap((s) => s.data);
|
|
321
|
+
const maxValue = Math.max(...allData.map((d) => (d.y || 0) as number));
|
|
322
|
+
const scaleX = chartWidth / (allData.length - 1 || 1);
|
|
323
|
+
const scaleY = chartHeight / (maxValue * 1.1);
|
|
324
|
+
|
|
325
|
+
series.forEach((s, seriesIndex) => {
|
|
326
|
+
const color = s.color || palette.primary[seriesIndex % palette.primary.length];
|
|
327
|
+
ctx.strokeStyle = color;
|
|
328
|
+
ctx.lineWidth = 2;
|
|
329
|
+
|
|
330
|
+
ctx.beginPath();
|
|
331
|
+
s.data.forEach((point, index) => {
|
|
332
|
+
const x = index * scaleX;
|
|
333
|
+
const y = chartHeight - ((point.y || 0) as number) * scaleY;
|
|
334
|
+
if (index === 0) {
|
|
335
|
+
ctx.moveTo(x, y);
|
|
336
|
+
} else {
|
|
337
|
+
ctx.lineTo(x, y);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
ctx.stroke();
|
|
341
|
+
|
|
342
|
+
// Data points
|
|
343
|
+
if (config.dataLabels) {
|
|
344
|
+
ctx.fillStyle = color;
|
|
345
|
+
s.data.forEach((point, index) => {
|
|
346
|
+
const x = index * scaleX;
|
|
347
|
+
const y = chartHeight - ((point.y || 0) as number) * scaleY;
|
|
348
|
+
ctx.beginPath();
|
|
349
|
+
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
|
350
|
+
ctx.fill();
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Render animated line chart
|
|
358
|
+
*/
|
|
359
|
+
function renderLineChartAnimated(
|
|
360
|
+
rc: RenderContext,
|
|
361
|
+
series: DataSeries[],
|
|
362
|
+
palette: typeof palettes['default'],
|
|
363
|
+
config: ChartConfig,
|
|
364
|
+
progress: number
|
|
365
|
+
): void {
|
|
366
|
+
if (series.length === 0) return;
|
|
367
|
+
|
|
368
|
+
const { ctx, chartWidth, chartHeight } = rc;
|
|
369
|
+
const allData = series.flatMap((s) => s.data);
|
|
370
|
+
const maxValue = Math.max(...allData.map((d) => (d.y || 0) as number));
|
|
371
|
+
const scaleX = chartWidth / (allData.length - 1 || 1);
|
|
372
|
+
const scaleY = chartHeight / (maxValue * 1.1);
|
|
373
|
+
|
|
374
|
+
series.forEach((s, seriesIndex) => {
|
|
375
|
+
const color = s.color || palette.primary[seriesIndex % palette.primary.length];
|
|
376
|
+
ctx.strokeStyle = color;
|
|
377
|
+
ctx.lineWidth = 2;
|
|
378
|
+
|
|
379
|
+
ctx.beginPath();
|
|
380
|
+
s.data.forEach((point, index) => {
|
|
381
|
+
const x = index * scaleX;
|
|
382
|
+
const y = chartHeight - ((point.y || 0) as number) * scaleY;
|
|
383
|
+
if (index === 0) {
|
|
384
|
+
ctx.moveTo(x, y);
|
|
385
|
+
} else if (index / (s.data.length - 1) <= progress) {
|
|
386
|
+
ctx.lineTo(x, y);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
ctx.stroke();
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Render pie/donut chart
|
|
395
|
+
*/
|
|
396
|
+
function renderPieChart(
|
|
397
|
+
rc: RenderContext,
|
|
398
|
+
series: DataSeries[],
|
|
399
|
+
palette: typeof palettes['default'],
|
|
400
|
+
config: ChartConfig
|
|
401
|
+
): void {
|
|
402
|
+
if (series.length === 0) return;
|
|
403
|
+
|
|
404
|
+
const { ctx, chartWidth, chartHeight } = rc;
|
|
405
|
+
const data = series[0].data;
|
|
406
|
+
const total = data.reduce((sum, d) => sum + ((d.y || d.value || 0) as number), 0);
|
|
407
|
+
|
|
408
|
+
const centerX = chartWidth / 2;
|
|
409
|
+
const centerY = chartHeight / 2;
|
|
410
|
+
const radius = Math.min(chartWidth, chartHeight) / 2 * 0.8;
|
|
411
|
+
const innerRadius = config.type === 'donut' ? radius * 0.5 : 0;
|
|
412
|
+
|
|
413
|
+
let currentAngle = -Math.PI / 2;
|
|
414
|
+
|
|
415
|
+
data.forEach((point, index) => {
|
|
416
|
+
const value = (point.y || point.value || 0) as number;
|
|
417
|
+
const angle = (value / total) * 2 * Math.PI;
|
|
418
|
+
const color = palette.primary[index % palette.primary.length];
|
|
419
|
+
|
|
420
|
+
ctx.fillStyle = color;
|
|
421
|
+
ctx.beginPath();
|
|
422
|
+
|
|
423
|
+
if (innerRadius > 0) {
|
|
424
|
+
// Donut
|
|
425
|
+
ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
|
|
426
|
+
ctx.arc(centerX, centerY, innerRadius, currentAngle + angle, currentAngle, true);
|
|
427
|
+
} else {
|
|
428
|
+
// Pie
|
|
429
|
+
ctx.moveTo(centerX, centerY);
|
|
430
|
+
ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
ctx.closePath();
|
|
434
|
+
ctx.fill();
|
|
435
|
+
|
|
436
|
+
currentAngle += angle;
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Render animated pie/donut chart
|
|
442
|
+
*/
|
|
443
|
+
function renderPieChartAnimated(
|
|
444
|
+
rc: RenderContext,
|
|
445
|
+
series: DataSeries[],
|
|
446
|
+
palette: typeof palettes['default'],
|
|
447
|
+
config: ChartConfig,
|
|
448
|
+
progress: number
|
|
449
|
+
): void {
|
|
450
|
+
if (series.length === 0) return;
|
|
451
|
+
|
|
452
|
+
const { ctx, chartWidth, chartHeight } = rc;
|
|
453
|
+
const data = series[0].data;
|
|
454
|
+
const total = data.reduce((sum, d) => sum + ((d.y || d.value || 0) as number), 0);
|
|
455
|
+
|
|
456
|
+
const centerX = chartWidth / 2;
|
|
457
|
+
const centerY = chartHeight / 2;
|
|
458
|
+
const radius = Math.min(chartWidth, chartHeight) / 2 * 0.8 * progress;
|
|
459
|
+
const innerRadius = config.type === 'donut' ? radius * 0.5 : 0;
|
|
460
|
+
|
|
461
|
+
let currentAngle = -Math.PI / 2;
|
|
462
|
+
|
|
463
|
+
data.forEach((point, index) => {
|
|
464
|
+
const value = (point.y || point.value || 0) as number;
|
|
465
|
+
const angle = (value / total) * 2 * Math.PI;
|
|
466
|
+
const color = palette.primary[index % palette.primary.length];
|
|
467
|
+
|
|
468
|
+
ctx.fillStyle = color;
|
|
469
|
+
ctx.beginPath();
|
|
470
|
+
|
|
471
|
+
if (innerRadius > 0) {
|
|
472
|
+
ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
|
|
473
|
+
ctx.arc(centerX, centerY, innerRadius, currentAngle + angle, currentAngle, true);
|
|
474
|
+
} else {
|
|
475
|
+
ctx.moveTo(centerX, centerY);
|
|
476
|
+
ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
ctx.closePath();
|
|
480
|
+
ctx.fill();
|
|
481
|
+
|
|
482
|
+
currentAngle += angle;
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Render area chart
|
|
488
|
+
*/
|
|
489
|
+
function renderAreaChart(
|
|
490
|
+
rc: RenderContext,
|
|
491
|
+
series: DataSeries[],
|
|
492
|
+
palette: typeof palettes['default'],
|
|
493
|
+
config: ChartConfig
|
|
494
|
+
): void {
|
|
495
|
+
if (series.length === 0) return;
|
|
496
|
+
|
|
497
|
+
const { ctx, chartWidth, chartHeight } = rc;
|
|
498
|
+
const allData = series.flatMap((s) => s.data);
|
|
499
|
+
const maxValue = Math.max(...allData.map((d) => (d.y || 0) as number));
|
|
500
|
+
const scaleX = chartWidth / (allData.length - 1 || 1);
|
|
501
|
+
const scaleY = chartHeight / (maxValue * 1.1);
|
|
502
|
+
|
|
503
|
+
series.forEach((s, seriesIndex) => {
|
|
504
|
+
const color = s.color || palette.primary[seriesIndex % palette.primary.length];
|
|
505
|
+
|
|
506
|
+
ctx.fillStyle = color + '40'; // 25% opacity
|
|
507
|
+
ctx.strokeStyle = color;
|
|
508
|
+
ctx.lineWidth = 2;
|
|
509
|
+
|
|
510
|
+
ctx.beginPath();
|
|
511
|
+
s.data.forEach((point, index) => {
|
|
512
|
+
const x = index * scaleX;
|
|
513
|
+
const y = chartHeight - ((point.y || 0) as number) * scaleY;
|
|
514
|
+
if (index === 0) {
|
|
515
|
+
ctx.moveTo(x, y);
|
|
516
|
+
} else {
|
|
517
|
+
ctx.lineTo(x, y);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
ctx.lineTo(chartWidth, chartHeight);
|
|
521
|
+
ctx.lineTo(0, chartHeight);
|
|
522
|
+
ctx.closePath();
|
|
523
|
+
ctx.fill();
|
|
524
|
+
ctx.stroke();
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Render animated area chart
|
|
530
|
+
*/
|
|
531
|
+
function renderAreaChartAnimated(
|
|
532
|
+
rc: RenderContext,
|
|
533
|
+
series: DataSeries[],
|
|
534
|
+
palette: typeof palettes['default'],
|
|
535
|
+
config: ChartConfig,
|
|
536
|
+
progress: number
|
|
537
|
+
): void {
|
|
538
|
+
if (series.length === 0) return;
|
|
539
|
+
|
|
540
|
+
const { ctx, chartWidth, chartHeight } = rc;
|
|
541
|
+
const allData = series.flatMap((s) => s.data);
|
|
542
|
+
const maxValue = Math.max(...allData.map((d) => (d.y || 0) as number));
|
|
543
|
+
const scaleX = chartWidth / (allData.length - 1 || 1);
|
|
544
|
+
const scaleY = (chartHeight / (maxValue * 1.1)) * progress;
|
|
545
|
+
|
|
546
|
+
series.forEach((s, seriesIndex) => {
|
|
547
|
+
const color = s.color || palette.primary[seriesIndex % palette.primary.length];
|
|
548
|
+
|
|
549
|
+
ctx.fillStyle = color + '40';
|
|
550
|
+
ctx.strokeStyle = color;
|
|
551
|
+
ctx.lineWidth = 2;
|
|
552
|
+
|
|
553
|
+
ctx.beginPath();
|
|
554
|
+
s.data.forEach((point, index) => {
|
|
555
|
+
const x = index * scaleX;
|
|
556
|
+
const y = chartHeight - ((point.y || 0) as number) * scaleY;
|
|
557
|
+
if (index === 0) {
|
|
558
|
+
ctx.moveTo(x, y);
|
|
559
|
+
} else {
|
|
560
|
+
ctx.lineTo(x, y);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
ctx.lineTo(chartWidth, chartHeight);
|
|
564
|
+
ctx.lineTo(0, chartHeight);
|
|
565
|
+
ctx.closePath();
|
|
566
|
+
ctx.fill();
|
|
567
|
+
ctx.stroke();
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Render placeholder for unsupported chart types
|
|
573
|
+
*/
|
|
574
|
+
function renderPlaceholder(
|
|
575
|
+
rc: RenderContext,
|
|
576
|
+
type: string,
|
|
577
|
+
palette: typeof palettes['default']
|
|
578
|
+
): void {
|
|
579
|
+
const { ctx, chartWidth, chartHeight } = rc;
|
|
580
|
+
|
|
581
|
+
ctx.fillStyle = '#e5e7eb';
|
|
582
|
+
ctx.fillRect(chartWidth / 4, chartHeight / 3, chartWidth / 2, chartHeight / 3);
|
|
583
|
+
|
|
584
|
+
ctx.fillStyle = '#6b7280';
|
|
585
|
+
ctx.font = '14px sans-serif';
|
|
586
|
+
ctx.textAlign = 'center';
|
|
587
|
+
ctx.fillText(`${type} chart`, chartWidth / 2, chartHeight / 2 + 5);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Render axes
|
|
592
|
+
*/
|
|
593
|
+
function renderAxes(
|
|
594
|
+
rc: RenderContext,
|
|
595
|
+
series: DataSeries[],
|
|
596
|
+
config: ChartConfig,
|
|
597
|
+
palette: typeof palettes['default']
|
|
598
|
+
): void {
|
|
599
|
+
const { ctx, chartWidth, chartHeight } = rc;
|
|
600
|
+
|
|
601
|
+
ctx.strokeStyle = palette.background[2];
|
|
602
|
+
ctx.lineWidth = 1;
|
|
603
|
+
ctx.fillStyle = palette.text.secondary;
|
|
604
|
+
ctx.font = '12px sans-serif';
|
|
605
|
+
ctx.textAlign = 'right';
|
|
606
|
+
ctx.textBaseline = 'middle';
|
|
607
|
+
|
|
608
|
+
// Y-axis
|
|
609
|
+
ctx.beginPath();
|
|
610
|
+
ctx.moveTo(0, 0);
|
|
611
|
+
ctx.lineTo(0, chartHeight);
|
|
612
|
+
ctx.stroke();
|
|
613
|
+
|
|
614
|
+
// X-axis
|
|
615
|
+
ctx.beginPath();
|
|
616
|
+
ctx.moveTo(0, chartHeight);
|
|
617
|
+
ctx.lineTo(chartWidth, chartHeight);
|
|
618
|
+
ctx.stroke();
|
|
619
|
+
|
|
620
|
+
// Y-axis labels
|
|
621
|
+
const maxValue = Math.max(...series.flatMap((s) => s.data.map((d) => (d.y || 0) as number)));
|
|
622
|
+
const steps = 5;
|
|
623
|
+
|
|
624
|
+
for (let i = 0; i <= steps; i++) {
|
|
625
|
+
const value = (maxValue / steps) * i;
|
|
626
|
+
const y = chartHeight - (chartHeight / steps) * i;
|
|
627
|
+
ctx.fillText(String(Math.round(value)), -10, y);
|
|
628
|
+
|
|
629
|
+
// Grid line
|
|
630
|
+
if (config.xAxis?.grid?.enabled !== false && i > 0) {
|
|
631
|
+
ctx.save();
|
|
632
|
+
ctx.strokeStyle = palette.background[2];
|
|
633
|
+
ctx.setLineDash([4, 4]);
|
|
634
|
+
ctx.beginPath();
|
|
635
|
+
ctx.moveTo(0, y);
|
|
636
|
+
ctx.lineTo(chartWidth, y);
|
|
637
|
+
ctx.stroke();
|
|
638
|
+
ctx.restore();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Axis titles
|
|
643
|
+
if (config.yAxis?.title) {
|
|
644
|
+
ctx.save();
|
|
645
|
+
ctx.translate(-margin.left + 20, chartHeight / 2);
|
|
646
|
+
ctx.rotate(-Math.PI / 2);
|
|
647
|
+
ctx.textAlign = 'center';
|
|
648
|
+
ctx.fillText(config.yAxis.title, 0, 0);
|
|
649
|
+
ctx.restore();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (config.xAxis?.title) {
|
|
653
|
+
ctx.textAlign = 'center';
|
|
654
|
+
ctx.fillText(config.xAxis.title, chartWidth / 2, chartHeight + 40);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Get chart metadata
|
|
660
|
+
*/
|
|
661
|
+
function getMetadata(type: string): ChartMetadata {
|
|
662
|
+
const metadata: Record<string, ChartMetadata> = {
|
|
663
|
+
line: {
|
|
664
|
+
type: 'line',
|
|
665
|
+
name: 'Line Chart',
|
|
666
|
+
description: 'Show trends over time or categories',
|
|
667
|
+
axes: ['x', 'y'],
|
|
668
|
+
dataStructure: 'Array of { x, y } points',
|
|
669
|
+
useCases: ['Time series', 'Trends', 'Progress over time'],
|
|
670
|
+
},
|
|
671
|
+
bar: {
|
|
672
|
+
type: 'bar',
|
|
673
|
+
name: 'Bar Chart',
|
|
674
|
+
description: 'Compare values across categories',
|
|
675
|
+
axes: ['x', 'y'],
|
|
676
|
+
dataStructure: 'Array of { x, y } points',
|
|
677
|
+
useCases: ['Comparisons', 'Rankings', 'Category distribution'],
|
|
678
|
+
},
|
|
679
|
+
pie: {
|
|
680
|
+
type: 'pie',
|
|
681
|
+
name: 'Pie Chart',
|
|
682
|
+
description: 'Show part-to-whole relationships',
|
|
683
|
+
axes: [],
|
|
684
|
+
dataStructure: 'Array of { name, value }',
|
|
685
|
+
useCases: ['Composition', 'Percentage distribution'],
|
|
686
|
+
},
|
|
687
|
+
donut: {
|
|
688
|
+
type: 'donut',
|
|
689
|
+
name: 'Donut Chart',
|
|
690
|
+
description: 'Show part-to-whole with center space',
|
|
691
|
+
axes: [],
|
|
692
|
+
dataStructure: 'Array of { name, value }',
|
|
693
|
+
useCases: ['Composition', 'Percentage distribution'],
|
|
694
|
+
},
|
|
695
|
+
area: {
|
|
696
|
+
type: 'area',
|
|
697
|
+
name: 'Area Chart',
|
|
698
|
+
description: 'Show cumulative totals over time',
|
|
699
|
+
axes: ['x', 'y'],
|
|
700
|
+
dataStructure: 'Array of { x, y } points',
|
|
701
|
+
useCases: ['Volume over time', 'Cumulative data'],
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
return metadata[type] || metadata.line;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Export to data URL
|
|
710
|
+
*/
|
|
711
|
+
function toDataURL(format: 'png' | 'jpeg' = 'png', quality: number = 0.92): string | null {
|
|
712
|
+
if (!currentCanvas) return null;
|
|
713
|
+
return currentCanvas.toDataURL(`image/${format}`, quality);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Get canvas element
|
|
718
|
+
*/
|
|
719
|
+
function getCanvas(): HTMLCanvasElement | null {
|
|
720
|
+
return currentCanvas;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return {
|
|
724
|
+
render,
|
|
725
|
+
renderAnimated,
|
|
726
|
+
getMetadata,
|
|
727
|
+
toDataURL,
|
|
728
|
+
getCanvas,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Global Canvas renderer instance
|
|
734
|
+
*/
|
|
735
|
+
export const globalCanvasRenderer = createCanvasRenderer();
|