@allternit/viz 0.1.0 → 0.1.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.
@@ -1,735 +0,0 @@
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();