@meonode/canvas 1.0.0 → 1.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.
@@ -0,0 +1,578 @@
1
+ import { BoxNode, Row } from './layout.canvas.util.js';
2
+ import { Style } from '../constant/common.const.js';
3
+ import { TextNode } from './text.canvas.util.js';
4
+
5
+ class ChartNode extends BoxNode {
6
+ chartData;
7
+ chartType;
8
+ chartOptions;
9
+ constructor(props) {
10
+ // Set default intrinsic size if not provided
11
+ const defaultWidth = props.width ?? 400;
12
+ const defaultHeight = props.height ?? 300;
13
+ super({
14
+ ...props,
15
+ width: defaultWidth,
16
+ height: defaultHeight,
17
+ name: 'Chart',
18
+ });
19
+ this.chartData = props.data;
20
+ this.chartType = props.type;
21
+ this.chartOptions = {
22
+ showLabels: true,
23
+ showLegend: true,
24
+ labelFontSize: 12,
25
+ legendPosition: 'bottom',
26
+ ...props.options,
27
+ };
28
+ this.validateProps();
29
+ }
30
+ validateProps() {
31
+ if (this.chartType === 'bar' || this.chartType === 'line') {
32
+ const data = this.chartData;
33
+ if (!data.labels || !data.datasets) {
34
+ console.warn(`[ChartNode] Warning: Cartesian chart (${this.chartType}) is missing 'labels' or 'datasets' in its data prop.`);
35
+ }
36
+ data.datasets?.forEach((dataset, i) => {
37
+ if (dataset.data.length !== data.labels.length) {
38
+ console.warn(`[ChartNode] Warning: In dataset ${i} ("${dataset.label}"), the number of data points (${dataset.data.length}) does not match the number of labels (${data.labels.length}).`);
39
+ }
40
+ });
41
+ }
42
+ else if (this.chartType === 'pie' || this.chartType === 'doughnut') {
43
+ const data = this.chartData;
44
+ if (!Array.isArray(data)) {
45
+ console.warn(`[ChartNode] Warning: ${this.chartType} chart expects an array of PieChartDataPoint, but received a different type.`);
46
+ }
47
+ }
48
+ }
49
+ _renderContent(ctx, x, y, width, height) {
50
+ // First render background/borders from parent
51
+ super._renderContent(ctx, x, y, width, height);
52
+ // Then render chart-specific content
53
+ const paddingLeft = this.node.getComputedPadding(Style.Edge.Left);
54
+ const paddingRight = this.node.getComputedPadding(Style.Edge.Right);
55
+ const paddingTop = this.node.getComputedPadding(Style.Edge.Top);
56
+ const paddingBottom = this.node.getComputedPadding(Style.Edge.Bottom);
57
+ const contentX = x + paddingLeft;
58
+ const contentY = y + paddingTop;
59
+ const contentWidth = width - paddingLeft - paddingRight;
60
+ const contentHeight = height - paddingTop - paddingBottom;
61
+ switch (this.chartType) {
62
+ case 'bar':
63
+ this.renderBarChart(ctx, contentX, contentY, contentWidth, contentHeight);
64
+ break;
65
+ case 'line':
66
+ this.renderLineChart(ctx, contentX, contentY, contentWidth, contentHeight);
67
+ break;
68
+ case 'pie':
69
+ this.renderPieChart(ctx, contentX, contentY, contentWidth, contentHeight);
70
+ break;
71
+ case 'doughnut':
72
+ this.renderDoughnutChart(ctx, contentX, contentY, contentWidth, contentHeight);
73
+ break;
74
+ }
75
+ }
76
+ getLegendLayout(ctx, totalWidth, totalHeight) {
77
+ if (!this.chartOptions?.showLegend) {
78
+ return { x: 0, y: 0, width: 0, height: 0, chartWidth: totalWidth, chartHeight: totalHeight, chartX: 0, chartY: 0 };
79
+ }
80
+ const legendItems = 'datasets' in this.chartData ? this.chartData.datasets : this.chartData;
81
+ if (legendItems.length === 0) {
82
+ return { x: 0, y: 0, width: 0, height: 0, chartWidth: totalWidth, chartHeight: totalHeight, chartX: 0, chartY: 0 };
83
+ }
84
+ const fontSize = this.chartOptions?.labelFontSize || 12;
85
+ ctx.font = `${fontSize}px ${this.props.fontFamily || 'sans-serif'}`;
86
+ const metrics = ctx.measureText('Mg');
87
+ const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
88
+ const itemHeight = Math.ceil(textHeight + 8);
89
+ const position = this.chartOptions.legendPosition;
90
+ const boxSize = Math.min(15, itemHeight - 2);
91
+ const legendItemLabels = 'datasets' in this.chartData ? this.chartData.datasets.map(d => d.label) : this.chartData.map(p => `${p.label} (${p.value})`);
92
+ let calculatedLegendHeight;
93
+ let calculatedLegendWidth;
94
+ if (position === 'top' || position === 'bottom') {
95
+ let currentX = 0;
96
+ let numRows = 1;
97
+ const itemPadding = 20;
98
+ legendItemLabels.forEach(label => {
99
+ const labelWidth = ctx.measureText(label).width;
100
+ const itemWidth = boxSize + 5 + labelWidth + itemPadding;
101
+ if (currentX > 0 && currentX + itemWidth > totalWidth) {
102
+ numRows++;
103
+ currentX = 0;
104
+ }
105
+ currentX += itemWidth;
106
+ });
107
+ calculatedLegendHeight = numRows * itemHeight + 10;
108
+ calculatedLegendWidth = totalWidth;
109
+ }
110
+ else {
111
+ // 'left' or 'right'
112
+ const maxLabelWidth = Math.max(...legendItemLabels.map(label => ctx.measureText(label).width));
113
+ calculatedLegendWidth = maxLabelWidth + boxSize + 25; // padding + box + padding + text
114
+ calculatedLegendHeight = totalHeight;
115
+ }
116
+ let effectiveChartWidth = totalWidth;
117
+ let effectiveChartHeight = totalHeight;
118
+ let legendAreaX;
119
+ let legendAreaY;
120
+ let chartAreaX;
121
+ let chartAreaY;
122
+ let legendAreaWidth;
123
+ let legendAreaHeight;
124
+ if (position === 'top' || position === 'bottom') {
125
+ effectiveChartHeight -= calculatedLegendHeight;
126
+ legendAreaHeight = calculatedLegendHeight;
127
+ legendAreaWidth = totalWidth;
128
+ legendAreaX = 0;
129
+ chartAreaX = 0;
130
+ if (position === 'top') {
131
+ chartAreaY = calculatedLegendHeight;
132
+ legendAreaY = 0;
133
+ }
134
+ else {
135
+ // bottom
136
+ legendAreaY = effectiveChartHeight;
137
+ chartAreaY = 0;
138
+ }
139
+ }
140
+ else {
141
+ // 'left' or 'right'
142
+ effectiveChartWidth -= calculatedLegendWidth;
143
+ legendAreaWidth = calculatedLegendWidth;
144
+ legendAreaHeight = totalHeight;
145
+ legendAreaY = 0;
146
+ chartAreaY = 0;
147
+ if (position === 'left') {
148
+ chartAreaX = calculatedLegendWidth;
149
+ legendAreaX = 0;
150
+ }
151
+ else {
152
+ // right
153
+ legendAreaX = effectiveChartWidth;
154
+ chartAreaX = 0;
155
+ }
156
+ }
157
+ return {
158
+ x: legendAreaX,
159
+ y: legendAreaY,
160
+ width: legendAreaWidth,
161
+ height: legendAreaHeight,
162
+ chartWidth: effectiveChartWidth,
163
+ chartHeight: effectiveChartHeight,
164
+ chartX: chartAreaX,
165
+ chartY: chartAreaY,
166
+ };
167
+ }
168
+ renderBarChart(ctx, x, y, width, height) {
169
+ if (this.chartType !== 'bar')
170
+ return;
171
+ const chartData = this.chartData;
172
+ const chartOptions = this.chartOptions;
173
+ const legendLayout = this.getLegendLayout(ctx, width, height);
174
+ const chartX = x + legendLayout.chartX;
175
+ const chartY = y + legendLayout.chartY;
176
+ const chartWidth = legendLayout.chartWidth;
177
+ const chartHeight = legendLayout.chartHeight;
178
+ const { labels, datasets } = chartData;
179
+ const maxValue = Math.max(...datasets.flatMap(d => d.data));
180
+ let labelHeight = 0;
181
+ if (chartOptions?.showLabels) {
182
+ const fontSize = chartOptions.labelFontSize || 12;
183
+ ctx.font = `${fontSize}px ${this.props.fontFamily || 'sans-serif'}`;
184
+ const metrics = ctx.measureText('Mg');
185
+ labelHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent + 10; // with padding
186
+ }
187
+ const finalChartHeight = chartHeight - labelHeight;
188
+ const groupWidth = chartWidth / labels.length;
189
+ const barSpacing = groupWidth * 0.2;
190
+ const barWidth = (groupWidth - barSpacing) / datasets.length;
191
+ // Render grid
192
+ if (chartOptions?.grid?.show) {
193
+ ctx.strokeStyle = chartOptions.grid.color || '#e0e0e0';
194
+ ctx.lineWidth = 1;
195
+ if (chartOptions.grid.style === 'dashed') {
196
+ ctx.setLineDash([5, 5]);
197
+ }
198
+ else if (chartOptions.grid.style === 'dotted') {
199
+ ctx.setLineDash([2, 2]);
200
+ }
201
+ for (let i = 0; i <= 5; i++) {
202
+ const gridY = chartY + (finalChartHeight / 5) * i;
203
+ ctx.beginPath();
204
+ ctx.moveTo(chartX, gridY);
205
+ ctx.lineTo(chartX + chartWidth, gridY);
206
+ ctx.stroke();
207
+ }
208
+ ctx.setLineDash([]);
209
+ }
210
+ // Render bars
211
+ labels.forEach((label, index) => {
212
+ const groupX = chartX + index * groupWidth + barSpacing / 2;
213
+ datasets.forEach((dataset, datasetIndex) => {
214
+ const barHeight = (dataset.data[index] / maxValue) * finalChartHeight;
215
+ const barX = groupX + datasetIndex * barWidth;
216
+ const barY = chartY + finalChartHeight - barHeight;
217
+ ctx.fillStyle = dataset.color || this.generateColor(datasetIndex);
218
+ ctx.fillRect(barX, barY, barWidth, barHeight);
219
+ });
220
+ // Render labels
221
+ if (chartOptions?.showLabels) {
222
+ const { renderLabelItem } = chartOptions;
223
+ if (renderLabelItem) {
224
+ const labelNode = renderLabelItem({ item: label, index });
225
+ if (labelNode) {
226
+ labelNode.processInitialChildren();
227
+ labelNode.node.calculateLayout(undefined, undefined, Style.Direction.LTR);
228
+ const layout = labelNode.node.getComputedLayout();
229
+ labelNode.render(ctx, groupX + (groupWidth - barSpacing) / 2 - layout.width / 2, chartY + finalChartHeight + labelHeight / 2 - layout.height / 2);
230
+ }
231
+ }
232
+ else {
233
+ TextNode.renderSimpleText(ctx, label, groupX + (groupWidth - barSpacing) / 2, chartY + finalChartHeight + labelHeight / 2, {
234
+ color: chartOptions.labelColor || chartOptions.axisColor,
235
+ fontSize: chartOptions.labelFontSize,
236
+ fontFamily: this.props.fontFamily,
237
+ textAlign: 'center',
238
+ textBaseline: 'middle',
239
+ });
240
+ }
241
+ }
242
+ });
243
+ // Render legend
244
+ if (chartOptions?.showLegend) {
245
+ this.renderLegend(ctx, x + legendLayout.x, y + legendLayout.y, legendLayout.width, legendLayout.height);
246
+ }
247
+ }
248
+ renderLineChart(ctx, x, y, width, height) {
249
+ if (this.chartType !== 'line')
250
+ return;
251
+ const chartData = this.chartData;
252
+ const chartOptions = this.chartOptions;
253
+ const legendLayout = this.getLegendLayout(ctx, width, height);
254
+ const chartX = x + legendLayout.chartX;
255
+ const chartY = y + legendLayout.chartY;
256
+ const chartWidth = legendLayout.chartWidth;
257
+ const chartHeight = legendLayout.chartHeight;
258
+ const { labels, datasets } = chartData;
259
+ const maxValue = Math.max(...datasets.flatMap(d => d.data));
260
+ let labelHeight = 0;
261
+ if (chartOptions?.showLabels) {
262
+ const fontSize = chartOptions.labelFontSize || 12;
263
+ ctx.font = `${fontSize}px ${this.props.fontFamily || 'sans-serif'}`;
264
+ const metrics = ctx.measureText('Mg');
265
+ labelHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent + 10; // with padding
266
+ }
267
+ const finalChartHeight = chartHeight - labelHeight;
268
+ const pointSpacing = chartWidth / (labels.length > 1 ? labels.length - 1 : 1);
269
+ // Render grid
270
+ if (chartOptions?.grid?.show) {
271
+ ctx.strokeStyle = chartOptions.grid.color || '#e0e0e0';
272
+ ctx.lineWidth = 1;
273
+ if (chartOptions.grid.style === 'dashed') {
274
+ ctx.setLineDash([5, 5]);
275
+ }
276
+ else if (chartOptions.grid.style === 'dotted') {
277
+ ctx.setLineDash([2, 2]);
278
+ }
279
+ for (let i = 0; i <= 5; i++) {
280
+ const gridY = chartY + (finalChartHeight / 5) * i;
281
+ ctx.beginPath();
282
+ ctx.moveTo(chartX, gridY);
283
+ ctx.lineTo(chartX + chartWidth, gridY);
284
+ ctx.stroke();
285
+ }
286
+ ctx.setLineDash([]);
287
+ }
288
+ // Render lines and points
289
+ datasets.forEach((dataset, datasetIndex) => {
290
+ ctx.strokeStyle = dataset.color || this.generateColor(datasetIndex);
291
+ ctx.lineWidth = 2;
292
+ ctx.beginPath();
293
+ dataset.data.forEach((value, index) => {
294
+ const pointX = chartX + index * pointSpacing;
295
+ const pointY = chartY + finalChartHeight - (value / maxValue) * finalChartHeight;
296
+ if (index === 0) {
297
+ ctx.moveTo(pointX, pointY);
298
+ }
299
+ else {
300
+ ctx.lineTo(pointX, pointY);
301
+ }
302
+ });
303
+ ctx.stroke();
304
+ // Render points
305
+ dataset.data.forEach((value, index) => {
306
+ const pointX = chartX + index * pointSpacing;
307
+ const pointY = chartY + finalChartHeight - (value / maxValue) * finalChartHeight;
308
+ ctx.fillStyle = dataset.color || this.generateColor(datasetIndex);
309
+ ctx.beginPath();
310
+ ctx.arc(pointX, pointY, 4, 0, Math.PI * 2);
311
+ ctx.fill();
312
+ });
313
+ });
314
+ // Render labels
315
+ if (chartOptions?.showLabels) {
316
+ const { renderLabelItem } = chartOptions;
317
+ labels.forEach((label, index) => {
318
+ const pointX = chartX + index * pointSpacing;
319
+ if (renderLabelItem) {
320
+ const labelNode = renderLabelItem({ item: label, index });
321
+ if (labelNode) {
322
+ labelNode.processInitialChildren();
323
+ labelNode.node.calculateLayout(undefined, undefined, Style.Direction.LTR);
324
+ const layout = labelNode.node.getComputedLayout();
325
+ labelNode.render(ctx, pointX - layout.width / 2, chartY + finalChartHeight + labelHeight / 2 - layout.height / 2);
326
+ }
327
+ }
328
+ else {
329
+ TextNode.renderSimpleText(ctx, label, pointX, chartY + finalChartHeight + labelHeight / 2, {
330
+ color: chartOptions.labelColor || chartOptions.axisColor,
331
+ fontSize: chartOptions.labelFontSize,
332
+ fontFamily: this.props.fontFamily,
333
+ textAlign: 'center',
334
+ textBaseline: 'middle',
335
+ });
336
+ }
337
+ });
338
+ }
339
+ if (chartOptions?.showLegend) {
340
+ this.renderLegend(ctx, x + legendLayout.x, y + legendLayout.y, legendLayout.width, legendLayout.height);
341
+ }
342
+ }
343
+ renderPieChart(ctx, x, y, width, height) {
344
+ if (this.chartType !== 'pie')
345
+ return;
346
+ const data = this.chartData;
347
+ const chartOptions = this.chartOptions;
348
+ const legendLayout = this.getLegendLayout(ctx, width, height);
349
+ const chartX = x + legendLayout.chartX;
350
+ const chartY = y + legendLayout.chartY;
351
+ const chartWidth = legendLayout.chartWidth;
352
+ const chartHeight = legendLayout.chartHeight;
353
+ const centerX = chartX + chartWidth / 2;
354
+ const centerY = chartY + chartHeight / 2;
355
+ const radius = Math.min(chartWidth, chartHeight) / 2 - 10;
356
+ const total = data.reduce((sum, point) => sum + point.value, 0);
357
+ let currentAngle = -Math.PI / 2; // Start at top
358
+ data.forEach((point, index) => {
359
+ const sliceAngle = (point.value / total) * Math.PI * 2;
360
+ const startAngle = currentAngle;
361
+ const endAngle = currentAngle + sliceAngle;
362
+ ctx.fillStyle = point.color || this.generateColor(index);
363
+ ctx.beginPath();
364
+ ctx.moveTo(centerX, centerY);
365
+ ctx.arc(centerX, centerY, radius, startAngle, endAngle);
366
+ ctx.closePath();
367
+ ctx.fill();
368
+ // Draw slice border
369
+ ctx.strokeStyle = '#fff';
370
+ ctx.lineWidth = 2;
371
+ ctx.stroke();
372
+ // Render labels
373
+ if (chartOptions?.showLabels) {
374
+ const { renderLabelItem } = chartOptions;
375
+ const labelAngle = startAngle + sliceAngle / 2;
376
+ const labelRadius = radius * 0.7;
377
+ const labelX = centerX + Math.cos(labelAngle) * labelRadius;
378
+ const labelY = centerY + Math.sin(labelAngle) * labelRadius;
379
+ if (renderLabelItem) {
380
+ const labelNode = renderLabelItem({ item: point, index });
381
+ if (labelNode) {
382
+ labelNode.processInitialChildren();
383
+ labelNode.node.calculateLayout(undefined, undefined, Style.Direction.LTR);
384
+ const layout = labelNode.node.getComputedLayout();
385
+ labelNode.render(ctx, labelX - layout.width / 2, labelY - layout.height / 2);
386
+ }
387
+ }
388
+ else {
389
+ TextNode.renderSimpleText(ctx, point.label, labelX, labelY, {
390
+ color: chartOptions.labelColor,
391
+ fontSize: chartOptions.labelFontSize,
392
+ fontFamily: this.props.fontFamily,
393
+ textAlign: 'center',
394
+ textBaseline: 'middle',
395
+ });
396
+ }
397
+ }
398
+ currentAngle = endAngle;
399
+ });
400
+ if (chartOptions?.showLegend) {
401
+ this.renderLegend(ctx, x + legendLayout.x, y + legendLayout.y, legendLayout.width, legendLayout.height);
402
+ }
403
+ }
404
+ renderDoughnutChart(ctx, x, y, width, height) {
405
+ if (this.chartType !== 'doughnut')
406
+ return;
407
+ const data = this.chartData;
408
+ const chartOptions = this.chartOptions;
409
+ const legendLayout = this.getLegendLayout(ctx, width, height);
410
+ const chartX = x + legendLayout.chartX;
411
+ const chartY = y + legendLayout.chartY;
412
+ const chartWidth = legendLayout.chartWidth;
413
+ const chartHeight = legendLayout.chartHeight;
414
+ const centerX = chartX + chartWidth / 2;
415
+ const centerY = chartY + chartHeight / 2;
416
+ const outerRadius = Math.min(chartWidth, chartHeight) / 2 - 10;
417
+ const innerRadius = outerRadius * (chartOptions?.innerRadius ?? 0.6);
418
+ const total = data.reduce((sum, point) => sum + point.value, 0);
419
+ let currentAngle = -Math.PI / 2;
420
+ data.forEach((point, index) => {
421
+ const sliceAngle = (point.value / total) * Math.PI * 2;
422
+ const startAngle = currentAngle;
423
+ const endAngle = currentAngle + sliceAngle;
424
+ ctx.fillStyle = point.color || this.generateColor(index);
425
+ ctx.beginPath();
426
+ ctx.arc(centerX, centerY, outerRadius, startAngle, endAngle);
427
+ ctx.arc(centerX, centerY, innerRadius, endAngle, startAngle, true);
428
+ ctx.closePath();
429
+ ctx.fill();
430
+ ctx.strokeStyle = '#fff';
431
+ ctx.lineWidth = 2;
432
+ ctx.stroke();
433
+ // Render labels
434
+ if (chartOptions?.showLabels) {
435
+ const { renderLabelItem } = chartOptions;
436
+ const labelAngle = startAngle + sliceAngle / 2;
437
+ const labelRadius = innerRadius + (outerRadius - innerRadius) / 2;
438
+ const labelX = centerX + Math.cos(labelAngle) * labelRadius;
439
+ const labelY = centerY + Math.sin(labelAngle) * labelRadius;
440
+ if (renderLabelItem) {
441
+ const labelNode = renderLabelItem({ item: point, index });
442
+ if (labelNode) {
443
+ labelNode.processInitialChildren();
444
+ labelNode.node.calculateLayout(undefined, undefined, Style.Direction.LTR);
445
+ const layout = labelNode.node.getComputedLayout();
446
+ labelNode.render(ctx, labelX - layout.width / 2, labelY - layout.height / 2);
447
+ }
448
+ }
449
+ else {
450
+ TextNode.renderSimpleText(ctx, point.label, labelX, labelY, {
451
+ color: chartOptions.labelColor,
452
+ fontSize: chartOptions.labelFontSize,
453
+ fontFamily: this.props.fontFamily,
454
+ textAlign: 'center',
455
+ textBaseline: 'middle',
456
+ });
457
+ }
458
+ }
459
+ currentAngle = endAngle;
460
+ });
461
+ if (chartOptions?.showLegend) {
462
+ this.renderLegend(ctx, x + legendLayout.x, y + legendLayout.y, legendLayout.width, legendLayout.height);
463
+ }
464
+ }
465
+ renderLegend(ctx, x, y, width, height) {
466
+ const { renderLegendItem } = this.chartOptions;
467
+ if (renderLegendItem) {
468
+ let legendNodes;
469
+ if (this.chartType === 'bar' || this.chartType === 'line') {
470
+ const items = this.chartData.datasets;
471
+ const render = renderLegendItem;
472
+ legendNodes = items.map((item, index) => {
473
+ const color = item.color || this.generateColor(index);
474
+ return render({ item, index, color });
475
+ });
476
+ }
477
+ else {
478
+ const items = this.chartData;
479
+ const render = renderLegendItem;
480
+ legendNodes = items.map((item, index) => {
481
+ const color = item.color || this.generateColor(index);
482
+ return render({ item, index, color });
483
+ });
484
+ }
485
+ const finalNodes = legendNodes.filter((node) => !!node);
486
+ if (finalNodes.length > 0) {
487
+ const legendContainer = Row({
488
+ children: finalNodes,
489
+ width,
490
+ height,
491
+ justifyContent: Style.Justify.Center,
492
+ alignItems: Style.Align.Center,
493
+ flexWrap: Style.Wrap.Wrap,
494
+ gap: 10,
495
+ });
496
+ legendContainer.processInitialChildren();
497
+ legendContainer.node.calculateLayout(width, height, Style.Direction.LTR);
498
+ legendContainer.render(ctx, x, y);
499
+ }
500
+ return;
501
+ }
502
+ // Fallback to default rendering if renderLegendItem is not provided
503
+ const legendItems = 'datasets' in this.chartData
504
+ ? this.chartData.datasets.map(d => ({ label: d.label, value: d.data.reduce((a, b) => a + b, 0) }))
505
+ : this.chartData;
506
+ const fontSize = this.chartOptions?.labelFontSize || 12;
507
+ ctx.font = `${fontSize}px ${this.props.fontFamily || 'sans-serif'}`;
508
+ const metrics = ctx.measureText('Mg');
509
+ const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
510
+ const itemHeight = Math.ceil(textHeight + 8);
511
+ const boxSize = Math.min(15, itemHeight - 2);
512
+ const position = this.chartOptions.legendPosition;
513
+ if (position === 'top' || position === 'bottom') {
514
+ const itemPadding = 20; // horizontal padding between items
515
+ const rows = [];
516
+ let currentRow = { items: [], width: 0 };
517
+ legendItems.forEach((point, index) => {
518
+ const color = ('datasets' in this.chartData ? this.chartData.datasets[index].color : point.color) || this.generateColor(index);
519
+ const label = 'datasets' in this.chartData ? point.label : `${point.label} (${point.value})`;
520
+ const labelWidth = ctx.measureText(label).width;
521
+ const itemWidth = boxSize + 5 + labelWidth;
522
+ if (currentRow.items.length > 0 && currentRow.width + itemPadding + itemWidth > width) {
523
+ rows.push(currentRow);
524
+ currentRow = { items: [], width: 0 };
525
+ }
526
+ currentRow.items.push({ label, color, width: itemWidth });
527
+ currentRow.width += itemWidth + (currentRow.items.length > 1 ? itemPadding : 0);
528
+ });
529
+ rows.push(currentRow);
530
+ let currentY = y + 5;
531
+ rows.forEach(row => {
532
+ let currentX = x + (width - row.width) / 2;
533
+ row.items.forEach(item => {
534
+ const boxY = currentY + (itemHeight - boxSize) / 2;
535
+ ctx.fillStyle = item.color;
536
+ ctx.fillRect(currentX, boxY, boxSize, boxSize);
537
+ TextNode.renderSimpleText(ctx, item.label, currentX + boxSize + 5, currentY + itemHeight / 2, {
538
+ color: this.chartOptions?.labelColor,
539
+ fontSize,
540
+ fontFamily: this.props.fontFamily,
541
+ textAlign: 'left',
542
+ textBaseline: 'middle',
543
+ });
544
+ currentX += item.width + itemPadding;
545
+ });
546
+ currentY += itemHeight;
547
+ });
548
+ }
549
+ else {
550
+ // 'left' or 'right'
551
+ const totalHeight = legendItems.length * itemHeight;
552
+ const startY = y + (height - totalHeight) / 2;
553
+ legendItems.forEach((point, index) => {
554
+ const itemX = x + 10;
555
+ const itemY = startY + index * itemHeight;
556
+ const boxY = itemY + (itemHeight - boxSize) / 2;
557
+ ctx.fillStyle =
558
+ ('datasets' in this.chartData ? this.chartData.datasets[index].color : point.color) || this.generateColor(index);
559
+ ctx.fillRect(itemX, boxY, boxSize, boxSize);
560
+ const label = 'datasets' in this.chartData ? point.label : `${point.label} (${point.value})`;
561
+ TextNode.renderSimpleText(ctx, label, itemX + boxSize + 5, itemY + itemHeight / 2, {
562
+ color: this.chartOptions?.labelColor,
563
+ fontSize,
564
+ fontFamily: this.props.fontFamily,
565
+ textAlign: 'left',
566
+ textBaseline: 'middle',
567
+ });
568
+ });
569
+ }
570
+ }
571
+ generateColor(index) {
572
+ const colors = ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', '#C9CBCF'];
573
+ return colors[index % colors.length];
574
+ }
575
+ }
576
+ const Chart = (props) => new ChartNode(props);
577
+
578
+ export { Chart, ChartNode };
@@ -17,6 +17,24 @@ export declare class TextNode extends BoxNode {
17
17
  lineGap: number;
18
18
  };
19
19
  constructor(text?: number | string, props?: TextProps);
20
+ /**
21
+ * Renders a simple, single-line text string without complex layout calculations.
22
+ * A lightweight, static utility for drawing text where layout is handled externally.
23
+ * @param ctx The canvas rendering context.
24
+ * @param text The string to render.
25
+ * @param x The x-coordinate for rendering.
26
+ * @param y The y-coordinate for rendering.
27
+ * @param props Basic text styling properties.
28
+ */
29
+ static renderSimpleText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, props?: {
30
+ fontFamily?: string;
31
+ fontSize?: number;
32
+ fontWeight?: TextProps['fontWeight'];
33
+ fontStyle?: TextProps['fontStyle'];
34
+ color?: string;
35
+ textAlign?: CanvasRenderingContext2D['textAlign'];
36
+ textBaseline?: CanvasRenderingContext2D['textBaseline'];
37
+ }): void;
20
38
  protected applyDefaults(): void;
21
39
  /**
22
40
  * Processes Unix-like escape sequences in text strings.
@@ -1 +1 @@
1
- {"version":3,"file":"text.canvas.util.d.ts","sourceRoot":"","sources":["../../../src/canvas/text.canvas.util.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,yBAAyB,CAAA;AACrE,OAAO,EAAU,KAAK,wBAAwB,EAA2B,MAAM,aAAa,CAAA;AAC5F,OAAO,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAA;AAGxD;;;GAGG;AACH,qBAAa,QAAS,SAAQ,OAAO;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoB;IAC7C,OAAO,CAAC,KAAK,CAAsB;IACnC,OAAO,CAAC,MAAM,CAAC,kBAAkB,CAAwC;IACzE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAU;IACxC,OAAO,CAAC,WAAW,CAAe;IAClC,OAAO,CAAC,WAAW,CAAe;IAClC,OAAO,CAAC,kBAAkB,CAAe;IAEjC,KAAK,EAAE,SAAS,GAAG;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;gBAElC,IAAI,GAAE,MAAM,GAAG,MAAW,EAAE,KAAK,GAAE,SAAc;cAuB1C,aAAa,IAAI,IAAI;IAoDxC;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,sBAAsB;IA8B9B;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,aAAa;IA+ErB,OAAO,CAAC,aAAa;IAKrB,OAAO,CAAC,gBAAgB;IAyBxB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,aAAa;IAiCrB;;OAEG;IACH,OAAO,CAAC,yBAAyB;IAQjC;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,WAAW;IA8NnB;;;;;;;;;OASG;IACH,OAAO,CAAC,YAAY;IAuKpB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;IAmErB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAQzB;;;;;;;;;;;;;;;;OAgBG;cACgB,cAAc,CAAC,GAAG,EAAE,wBAAwB,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CA8UrH;AAED;;GAEG;AACH,eAAO,MAAM,IAAI,GAAI,MAAM,MAAM,GAAG,MAAM,EAAE,QAAQ,SAAS,aAA8B,CAAA"}
1
+ {"version":3,"file":"text.canvas.util.d.ts","sourceRoot":"","sources":["../../../src/canvas/text.canvas.util.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,yBAAyB,CAAA;AACrE,OAAO,EAAU,KAAK,wBAAwB,EAA2B,MAAM,aAAa,CAAA;AAC5F,OAAO,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAA;AAGxD;;;GAGG;AACH,qBAAa,QAAS,SAAQ,OAAO;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoB;IAC7C,OAAO,CAAC,KAAK,CAAsB;IACnC,OAAO,CAAC,MAAM,CAAC,kBAAkB,CAAwC;IACzE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAU;IACxC,OAAO,CAAC,WAAW,CAAe;IAClC,OAAO,CAAC,WAAW,CAAe;IAClC,OAAO,CAAC,kBAAkB,CAAe;IAEjC,KAAK,EAAE,SAAS,GAAG;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;gBAElC,IAAI,GAAE,MAAM,GAAG,MAAW,EAAE,KAAK,GAAE,SAAc;IAuB7D;;;;;;;;OAQG;WACW,gBAAgB,CAC5B,GAAG,EAAE,wBAAwB,EAC7B,IAAI,EAAE,MAAM,EACZ,CAAC,EAAE,MAAM,EACT,CAAC,EAAE,MAAM,EACT,KAAK,GAAE;QACL,UAAU,CAAC,EAAE,MAAM,CAAA;QACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,UAAU,CAAC,EAAE,SAAS,CAAC,YAAY,CAAC,CAAA;QACpC,SAAS,CAAC,EAAE,SAAS,CAAC,WAAW,CAAC,CAAA;QAClC,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,SAAS,CAAC,EAAE,wBAAwB,CAAC,WAAW,CAAC,CAAA;QACjD,YAAY,CAAC,EAAE,wBAAwB,CAAC,cAAc,CAAC,CAAA;KACnD;cAwBW,aAAa,IAAI,IAAI;IAoDxC;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,sBAAsB;IA8B9B;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,aAAa;IA+ErB,OAAO,CAAC,aAAa;IAKrB,OAAO,CAAC,gBAAgB;IAyBxB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,aAAa;IAiCrB;;OAEG;IACH,OAAO,CAAC,yBAAyB;IAQjC;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,WAAW;IA8NnB;;;;;;;;;OASG;IACH,OAAO,CAAC,YAAY;IAuKpB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;IAmErB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAQzB;;;;;;;;;;;;;;;;OAgBG;cACgB,cAAc,CAAC,GAAG,EAAE,wBAAwB,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CA8UrH;AAED;;GAEG;AACH,eAAO,MAAM,IAAI,GAAI,MAAM,MAAM,GAAG,MAAM,EAAE,QAAQ,SAAS,aAA8B,CAAA"}
@@ -37,6 +37,25 @@ class TextNode extends BoxNode {
37
37
  this.node.setMeasureFunc(this.measureText.bind(this));
38
38
  this.applyDefaults();
39
39
  }
40
+ /**
41
+ * Renders a simple, single-line text string without complex layout calculations.
42
+ * A lightweight, static utility for drawing text where layout is handled externally.
43
+ * @param ctx The canvas rendering context.
44
+ * @param text The string to render.
45
+ * @param x The x-coordinate for rendering.
46
+ * @param y The y-coordinate for rendering.
47
+ * @param props Basic text styling properties.
48
+ */
49
+ static renderSimpleText(ctx, text, x, y, props = {}) {
50
+ ctx.save();
51
+ const { fontFamily = 'sans-serif', fontSize = 12, fontWeight = 'normal', fontStyle = 'normal', color = '#333', textAlign = 'left', textBaseline = 'alphabetic', } = props;
52
+ ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
53
+ ctx.fillStyle = color;
54
+ ctx.textAlign = textAlign;
55
+ ctx.textBaseline = textBaseline;
56
+ ctx.fillText(text, x, y);
57
+ ctx.restore();
58
+ }
40
59
  applyDefaults() {
41
60
  const textDefaults = {
42
61
  fontSize: 16,
@@ -5,4 +5,5 @@ export { Image } from './canvas/image.canvas.util.js';
5
5
  export { Text } from './canvas/text.canvas.util.js';
6
6
  export { Root } from './canvas/root.canvas.util.js';
7
7
  export { Grid } from './canvas/grid.canvas.util.js';
8
+ export { Chart } from './canvas/chart.canvas.util.js';
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,4BAA4B,CAAA;AAC1C,cAAc,yBAAyB,CAAA;AACvC,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,OAAO,EAAE,MAAM,gCAAgC,CAAA;AAC/E,OAAO,EAAE,KAAK,EAAE,MAAM,+BAA+B,CAAA;AACrD,OAAO,EAAE,IAAI,EAAE,MAAM,8BAA8B,CAAA;AACnD,OAAO,EAAE,IAAI,EAAE,MAAM,8BAA8B,CAAA;AACnD,OAAO,EAAE,IAAI,EAAE,MAAM,8BAA8B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,4BAA4B,CAAA;AAC1C,cAAc,yBAAyB,CAAA;AACvC,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,OAAO,EAAE,MAAM,gCAAgC,CAAA;AAC/E,OAAO,EAAE,KAAK,EAAE,MAAM,+BAA+B,CAAA;AACrD,OAAO,EAAE,IAAI,EAAE,MAAM,8BAA8B,CAAA;AACnD,OAAO,EAAE,IAAI,EAAE,MAAM,8BAA8B,CAAA;AACnD,OAAO,EAAE,IAAI,EAAE,MAAM,8BAA8B,CAAA;AACnD,OAAO,EAAE,KAAK,EAAE,MAAM,+BAA+B,CAAA"}
package/dist/esm/index.js CHANGED
@@ -4,4 +4,5 @@ export { Image } from './canvas/image.canvas.util.js';
4
4
  export { Text } from './canvas/text.canvas.util.js';
5
5
  export { Root } from './canvas/root.canvas.util.js';
6
6
  export { Grid } from './canvas/grid.canvas.util.js';
7
+ export { Chart } from './canvas/chart.canvas.util.js';
7
8
  export * from 'yoga-layout';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meonode/canvas",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A declarative, component-based library for generating high-quality images on a canvas, inspired by the MeoNode UI library for React. It leverages skia-canvas for rendering and yoga-layout for flexible, CSS-like layouts.",
5
5
  "keywords": [
6
6
  "canvas",