@diagrammo/dgmo 0.0.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.
package/src/chartjs.ts ADDED
@@ -0,0 +1,784 @@
1
+ import type { ChartConfiguration } from 'chart.js';
2
+ import 'chartjs-plugin-datalabels';
3
+
4
+ // ============================================================
5
+ // Types
6
+ // ============================================================
7
+
8
+ export type ChartJsChartType =
9
+ | 'bar'
10
+ | 'line'
11
+ | 'pie'
12
+ | 'doughnut'
13
+ | 'area'
14
+ | 'polar-area'
15
+ | 'radar'
16
+ | 'bar-stacked';
17
+
18
+ export interface ChartJsDataPoint {
19
+ label: string;
20
+ value: number;
21
+ extraValues?: number[];
22
+ color?: string;
23
+ lineNumber: number;
24
+ }
25
+
26
+ export interface ParsedChartJs {
27
+ type: ChartJsChartType;
28
+ title?: string;
29
+ series?: string;
30
+ xlabel?: string;
31
+ ylabel?: string;
32
+ seriesNames?: string[];
33
+ seriesNameColors?: (string | undefined)[];
34
+ orientation?: 'horizontal' | 'vertical';
35
+ color?: string;
36
+ label?: string;
37
+ data: ChartJsDataPoint[];
38
+ error?: string;
39
+ }
40
+
41
+ // ============================================================
42
+ // Nord Colors for Charts
43
+ // ============================================================
44
+
45
+ import { resolveColor } from './colors';
46
+ import type { PaletteColors } from './palettes';
47
+ import { getSeriesColors } from './palettes';
48
+
49
+ // ============================================================
50
+ // Parser
51
+ // ============================================================
52
+
53
+ const VALID_TYPES = new Set<ChartJsChartType>([
54
+ 'bar',
55
+ 'line',
56
+ 'pie',
57
+ 'doughnut',
58
+ 'area',
59
+ 'polar-area',
60
+ 'radar',
61
+ 'bar-stacked',
62
+ ]);
63
+
64
+ const TYPE_ALIASES: Record<string, ChartJsChartType> = {
65
+ 'multi-line': 'line',
66
+ };
67
+
68
+ /**
69
+ * Parses the simple chartjs text format into a structured object.
70
+ *
71
+ * Format:
72
+ * ```
73
+ * chart: bar
74
+ * title: My Chart
75
+ * series: Revenue
76
+ *
77
+ * Jan: 120
78
+ * Feb: 200
79
+ * Mar: 150
80
+ * ```
81
+ */
82
+ export function parseChartJs(
83
+ content: string,
84
+ palette?: PaletteColors
85
+ ): ParsedChartJs {
86
+ const lines = content.split('\n');
87
+ const result: ParsedChartJs = {
88
+ type: 'bar',
89
+ data: [],
90
+ };
91
+
92
+ for (let i = 0; i < lines.length; i++) {
93
+ const trimmed = lines[i].trim();
94
+ const lineNumber = i + 1;
95
+
96
+ // Skip empty lines
97
+ if (!trimmed) continue;
98
+
99
+ // Recognize ## section headers (skip, but don't treat as comments)
100
+ if (/^#{2,}\s+/.test(trimmed)) continue;
101
+
102
+ // Skip comments
103
+ if (trimmed.startsWith('#') || trimmed.startsWith('//')) continue;
104
+
105
+ // Parse key: value pairs
106
+ const colonIndex = trimmed.indexOf(':');
107
+ if (colonIndex === -1) continue;
108
+
109
+ const key = trimmed.substring(0, colonIndex).trim().toLowerCase();
110
+ const value = trimmed.substring(colonIndex + 1).trim();
111
+
112
+ // Handle metadata
113
+ if (key === 'chart') {
114
+ const raw = value.toLowerCase();
115
+ const chartType = (TYPE_ALIASES[raw] ?? raw) as ChartJsChartType;
116
+ if (VALID_TYPES.has(chartType)) {
117
+ result.type = chartType;
118
+ } else {
119
+ result.error = `Unsupported chart type: ${value}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
120
+ return result;
121
+ }
122
+ continue;
123
+ }
124
+
125
+ if (key === 'title') {
126
+ result.title = value;
127
+ continue;
128
+ }
129
+
130
+ if (key === 'xlabel') {
131
+ result.xlabel = value;
132
+ continue;
133
+ }
134
+
135
+ if (key === 'ylabel') {
136
+ result.ylabel = value;
137
+ continue;
138
+ }
139
+
140
+ if (key === 'label') {
141
+ result.label = value;
142
+ continue;
143
+ }
144
+
145
+ if (key === 'orientation') {
146
+ const v = value.toLowerCase();
147
+ if (v === 'horizontal' || v === 'vertical') {
148
+ result.orientation = v;
149
+ }
150
+ continue;
151
+ }
152
+
153
+ if (key === 'color') {
154
+ result.color = resolveColor(value.trim(), palette);
155
+ continue;
156
+ }
157
+
158
+ if (key === 'series') {
159
+ result.series = value;
160
+ // Parse comma-separated series names for multi-series chart types
161
+ const rawNames = value
162
+ .split(',')
163
+ .map((s) => s.trim())
164
+ .filter(Boolean);
165
+ const names: string[] = [];
166
+ const nameColors: (string | undefined)[] = [];
167
+ for (const raw of rawNames) {
168
+ const colorMatch = raw.match(/\(([^)]+)\)\s*$/);
169
+ if (colorMatch) {
170
+ const resolved = resolveColor(colorMatch[1].trim(), palette);
171
+ nameColors.push(resolved);
172
+ names.push(raw.substring(0, colorMatch.index!).trim());
173
+ } else {
174
+ nameColors.push(undefined);
175
+ names.push(raw);
176
+ }
177
+ }
178
+ if (names.length === 1) {
179
+ result.series = names[0];
180
+ }
181
+ if (names.length > 1) {
182
+ result.seriesNames = names;
183
+ }
184
+ if (nameColors.some(Boolean)) result.seriesNameColors = nameColors;
185
+ continue;
186
+ }
187
+
188
+ // Data point: Label: value or Label: v1, v2, ...
189
+ const parts = value.split(',').map((s) => s.trim());
190
+ const numValue = parseFloat(parts[0]);
191
+ if (!isNaN(numValue)) {
192
+ let rawLabel = trimmed.substring(0, colonIndex).trim();
193
+ let pointColor: string | undefined;
194
+ const colorMatch = rawLabel.match(/\(([^)]+)\)\s*$/);
195
+ if (colorMatch) {
196
+ const resolved = resolveColor(colorMatch[1].trim(), palette);
197
+ pointColor = resolved;
198
+ rawLabel = rawLabel.substring(0, colorMatch.index!).trim();
199
+ }
200
+ const extra = parts
201
+ .slice(1)
202
+ .map((s) => parseFloat(s))
203
+ .filter((n) => !isNaN(n));
204
+ result.data.push({
205
+ label: rawLabel,
206
+ value: numValue,
207
+ ...(extra.length > 0 && { extraValues: extra }),
208
+ ...(pointColor && { color: pointColor }),
209
+ lineNumber,
210
+ });
211
+ }
212
+ }
213
+
214
+ // Validation
215
+ if (!result.error && result.data.length === 0) {
216
+ result.error = 'No data points found. Add data in format: Label: 123';
217
+ }
218
+
219
+ if (!result.error && result.type === 'bar-stacked' && !result.seriesNames) {
220
+ result.error = `Chart type "bar-stacked" requires multiple series names. Use: series: Name1, Name2, Name3`;
221
+ }
222
+
223
+ if (!result.error && result.seriesNames) {
224
+ const expectedCount = result.seriesNames.length;
225
+ for (const dp of result.data) {
226
+ const actualCount = 1 + (dp.extraValues?.length ?? 0);
227
+ if (actualCount !== expectedCount) {
228
+ result.error = `Data point "${dp.label}" has ${actualCount} value(s), but ${expectedCount} series defined. Each row must have ${expectedCount} comma-separated values.`;
229
+ break;
230
+ }
231
+ }
232
+ }
233
+
234
+ return result;
235
+ }
236
+
237
+ // ============================================================
238
+ // Chart.js Config Builder
239
+ // ============================================================
240
+
241
+ /**
242
+ * Converts parsed chartjs data to a Chart.js configuration object.
243
+ */
244
+ export function buildChartJsConfig(
245
+ parsed: ParsedChartJs,
246
+ palette: PaletteColors,
247
+ _isDark: boolean
248
+ ): ChartConfiguration {
249
+ const textColor = palette.text;
250
+ const gridColor = palette.border + '80';
251
+ const crosshairColor = palette.border + '60';
252
+ const colors = getSeriesColors(palette);
253
+
254
+ // Plugin: draws a vertical line at the hovered x-position on line/area charts
255
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
256
+ const verticalCrosshairPlugin: any = {
257
+ id: 'verticalCrosshair',
258
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
259
+ afterDraw(chart: any) {
260
+ const tooltip = chart.tooltip;
261
+ if (!tooltip || !tooltip.getActiveElements().length) return;
262
+ const { ctx, chartArea } = chart;
263
+ const x = tooltip.caretX;
264
+ ctx.save();
265
+ ctx.beginPath();
266
+ ctx.moveTo(x, chartArea.top);
267
+ ctx.lineTo(x, chartArea.bottom);
268
+ ctx.lineWidth = 1;
269
+ ctx.strokeStyle = crosshairColor;
270
+ ctx.stroke();
271
+ ctx.restore();
272
+ },
273
+ };
274
+
275
+ const labels = parsed.data.map((d) => d.label);
276
+ const values = parsed.data.map((d) => d.value);
277
+ const perPointColors = parsed.data.map(
278
+ (d, i) => d.color ?? colors[i % colors.length]
279
+ );
280
+
281
+ const titlePlugin = parsed.title
282
+ ? {
283
+ display: true as const,
284
+ text: parsed.title,
285
+ color: textColor,
286
+ font: { size: 18, weight: 'bold' as const },
287
+ padding: { bottom: 16 },
288
+ }
289
+ : { display: false as const };
290
+
291
+ const tooltipConfig = {
292
+ backgroundColor: palette.surface,
293
+ titleColor: palette.text,
294
+ bodyColor: palette.text,
295
+ borderColor: palette.border,
296
+ borderWidth: 1,
297
+ };
298
+
299
+ // Resolve `label:` to the value axis (Y for vertical, X for horizontal)
300
+ const isHorizontalChart = parsed.orientation === 'horizontal';
301
+ const resolvedXLabel =
302
+ parsed.xlabel ?? (isHorizontalChart ? parsed.label : undefined);
303
+ const resolvedYLabel =
304
+ parsed.ylabel ?? (isHorizontalChart ? undefined : parsed.label);
305
+
306
+ // Axis title configs (used by chart types with x/y scales)
307
+ const xAxisTitle = resolvedXLabel
308
+ ? { display: true, text: resolvedXLabel, color: textColor }
309
+ : undefined;
310
+ const yAxisTitle = resolvedYLabel
311
+ ? { display: true, text: resolvedYLabel, color: textColor }
312
+ : undefined;
313
+
314
+ // Radar chart
315
+ if (parsed.type === 'radar') {
316
+ const radarColor =
317
+ parsed.color ?? parsed.seriesNameColors?.[0] ?? palette.primary;
318
+ // Subtle grid color for concentric reference lines drawn on top
319
+ const radarGridColor = palette.border + '60';
320
+
321
+ // Plugin: draws concentric polygon grid lines ON TOP of the data area.
322
+ // This makes the reference shapes visible even with solid fill.
323
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
+ const radarGridOverlayPlugin: any = {
325
+ id: 'radarGridOverlay',
326
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
327
+ afterDatasetsDraw(chart: any) {
328
+ const scale = chart.scales.r;
329
+ if (!scale) return;
330
+ const ticks = scale.ticks as { value: number }[];
331
+ if (!ticks || ticks.length < 1) return;
332
+
333
+ const { ctx } = chart;
334
+ const pointCount = chart.data.labels?.length ?? 0;
335
+ if (pointCount < 3) return;
336
+
337
+ ctx.save();
338
+ ctx.strokeStyle = radarGridColor;
339
+ ctx.lineWidth = 1;
340
+
341
+ // Draw concentric polygon lines for each tick
342
+ for (let i = 0; i < ticks.length; i++) {
343
+ const dist = scale.getDistanceFromCenterForValue(
344
+ ticks[i].value
345
+ ) as number;
346
+ if (dist <= 0) continue;
347
+
348
+ ctx.beginPath();
349
+ for (let p = 0; p < pointCount; p++) {
350
+ const pos = scale.getPointPosition(p, dist);
351
+ if (p === 0) ctx.moveTo(pos.x, pos.y);
352
+ else ctx.lineTo(pos.x, pos.y);
353
+ }
354
+ ctx.closePath();
355
+ ctx.stroke();
356
+ }
357
+
358
+ // Draw angle lines from center to each point
359
+ const outerDist = scale.getDistanceFromCenterForValue(
360
+ ticks[ticks.length - 1].value
361
+ ) as number;
362
+ for (let p = 0; p < pointCount; p++) {
363
+ const pos = scale.getPointPosition(p, outerDist);
364
+ ctx.beginPath();
365
+ ctx.moveTo(scale.xCenter, scale.yCenter);
366
+ ctx.lineTo(pos.x, pos.y);
367
+ ctx.stroke();
368
+ }
369
+
370
+ ctx.restore();
371
+ },
372
+ };
373
+
374
+ return {
375
+ type: 'radar',
376
+ data: {
377
+ labels,
378
+ datasets: [
379
+ {
380
+ label: parsed.series ?? 'Value',
381
+ data: values,
382
+ backgroundColor: radarColor,
383
+ borderColor: 'transparent',
384
+ borderWidth: 0,
385
+ pointBackgroundColor: radarColor,
386
+ pointRadius: 5,
387
+ },
388
+ ],
389
+ },
390
+ options: {
391
+ responsive: true,
392
+ maintainAspectRatio: false,
393
+ animation: false,
394
+ plugins: {
395
+ legend: { display: false },
396
+ title: titlePlugin,
397
+ tooltip: tooltipConfig,
398
+ datalabels: {
399
+ display: true,
400
+ color: textColor,
401
+ backgroundColor: palette.bg + 'cc',
402
+ borderRadius: 3,
403
+ padding: { top: 2, bottom: 2, left: 4, right: 4 },
404
+ font: { size: 11, weight: 'bold' as const },
405
+ anchor: 'center' as const,
406
+ align: 'center' as const,
407
+ formatter: (value: number) => value.toString(),
408
+ },
409
+ },
410
+ scales: {
411
+ r: {
412
+ beginAtZero: true,
413
+ ticks: {
414
+ // Hide tick labels - we show actual values on data points instead
415
+ display: false,
416
+ },
417
+ grid: {
418
+ // Hide default grid - we draw it on top via plugin
419
+ display: false,
420
+ },
421
+ angleLines: {
422
+ // Hide default angle lines - we draw them on top via plugin
423
+ display: false,
424
+ },
425
+ pointLabels: {
426
+ color: textColor,
427
+ font: { size: 12, weight: 'bold' as const },
428
+ },
429
+ },
430
+ },
431
+ },
432
+ plugins: [radarGridOverlayPlugin],
433
+ } as ChartConfiguration;
434
+ }
435
+
436
+ // Polar Area chart (styled like pie/doughnut: outer labels, no legend)
437
+ if (parsed.type === 'polar-area') {
438
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
439
+ const polarConnectorPlugin: any = {
440
+ id: 'polarConnectorLines',
441
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
442
+ afterDatasetsDraw(chart: any) {
443
+ const meta = chart.getDatasetMeta(0);
444
+ if (!meta?.data?.length) return;
445
+
446
+ const { ctx } = chart;
447
+ ctx.save();
448
+ ctx.strokeStyle = textColor;
449
+ ctx.lineWidth = 1;
450
+
451
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
452
+ meta.data.forEach((arc: any) => {
453
+ const {
454
+ startAngle,
455
+ endAngle,
456
+ outerRadius,
457
+ x: cx,
458
+ y: cy,
459
+ } = arc.getProps(['startAngle', 'endAngle', 'outerRadius', 'x', 'y']);
460
+ const midAngle = (startAngle + endAngle) / 2;
461
+ const r1 = outerRadius + 2;
462
+ const r2 = outerRadius + 14;
463
+
464
+ ctx.beginPath();
465
+ ctx.moveTo(
466
+ cx + Math.cos(midAngle) * r1,
467
+ cy + Math.sin(midAngle) * r1
468
+ );
469
+ ctx.lineTo(
470
+ cx + Math.cos(midAngle) * r2,
471
+ cy + Math.sin(midAngle) * r2
472
+ );
473
+ ctx.stroke();
474
+ });
475
+
476
+ ctx.restore();
477
+ },
478
+ };
479
+
480
+ const polarTitlePlugin = parsed.title
481
+ ? {
482
+ display: true as const,
483
+ text: parsed.title,
484
+ color: textColor,
485
+ font: { size: 18, weight: 'bold' as const },
486
+ padding: { bottom: 24 },
487
+ }
488
+ : { display: false as const };
489
+
490
+ return {
491
+ type: 'polarArea',
492
+ data: {
493
+ labels,
494
+ datasets: [
495
+ {
496
+ label: parsed.series ?? 'Value',
497
+ data: values,
498
+ backgroundColor: perPointColors,
499
+ borderWidth: 0,
500
+ },
501
+ ],
502
+ },
503
+ options: {
504
+ responsive: true,
505
+ maintainAspectRatio: false,
506
+ animation: false,
507
+ layout: { padding: { top: 10, bottom: 40, left: 60, right: 60 } },
508
+ plugins: {
509
+ legend: { display: false },
510
+ title: polarTitlePlugin,
511
+ tooltip: tooltipConfig,
512
+ datalabels: {
513
+ display: true,
514
+ color: textColor,
515
+ font: { weight: 'bold' as const },
516
+ formatter: (_value: number, ctx: { dataIndex: number }) =>
517
+ labels[ctx.dataIndex] ?? '',
518
+ anchor: 'end' as const,
519
+ align: 'end' as const,
520
+ offset: 16,
521
+ },
522
+ },
523
+ scales: {
524
+ r: {
525
+ ticks: { display: false },
526
+ grid: { color: gridColor },
527
+ },
528
+ },
529
+ },
530
+ plugins: [polarConnectorPlugin],
531
+ } as ChartConfiguration;
532
+ }
533
+
534
+ // Pie / Doughnut chart
535
+ if (parsed.type === 'pie' || parsed.type === 'doughnut') {
536
+ // Inline plugin to draw connector lines from each slice to its outer label
537
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
538
+ const pieConnectorPlugin: any = {
539
+ id: 'pieConnectorLines',
540
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
541
+ afterDatasetsDraw(chart: any) {
542
+ const meta = chart.getDatasetMeta(0);
543
+ if (!meta?.data?.length) return;
544
+
545
+ const { ctx } = chart;
546
+ ctx.save();
547
+ ctx.strokeStyle = textColor;
548
+ ctx.lineWidth = 1;
549
+
550
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
551
+ meta.data.forEach((arc: any) => {
552
+ const {
553
+ startAngle,
554
+ endAngle,
555
+ outerRadius,
556
+ x: cx,
557
+ y: cy,
558
+ } = arc.getProps(['startAngle', 'endAngle', 'outerRadius', 'x', 'y']);
559
+ const midAngle = (startAngle + endAngle) / 2;
560
+ const r1 = outerRadius + 2;
561
+ const r2 = outerRadius + 14;
562
+
563
+ ctx.beginPath();
564
+ ctx.moveTo(
565
+ cx + Math.cos(midAngle) * r1,
566
+ cy + Math.sin(midAngle) * r1
567
+ );
568
+ ctx.lineTo(
569
+ cx + Math.cos(midAngle) * r2,
570
+ cy + Math.sin(midAngle) * r2
571
+ );
572
+ ctx.stroke();
573
+ });
574
+
575
+ ctx.restore();
576
+ },
577
+ };
578
+
579
+ const pieTitlePlugin = parsed.title
580
+ ? {
581
+ display: true as const,
582
+ text: parsed.title,
583
+ color: textColor,
584
+ font: { size: 18, weight: 'bold' as const },
585
+ padding: { bottom: 24 },
586
+ }
587
+ : { display: false as const };
588
+
589
+ return {
590
+ type: parsed.type,
591
+ data: {
592
+ labels,
593
+ datasets: [
594
+ {
595
+ label: parsed.series ?? 'Value',
596
+ data: values,
597
+ backgroundColor: perPointColors,
598
+ borderWidth: 0,
599
+ },
600
+ ],
601
+ },
602
+ options: {
603
+ responsive: true,
604
+ maintainAspectRatio: false,
605
+ animation: false,
606
+ // radius is valid for pie/doughnut at runtime but not in the strict type
607
+ radius: '70%',
608
+ layout: { padding: { top: 10, bottom: 40, left: 60, right: 60 } },
609
+ plugins: {
610
+ legend: { display: false },
611
+ title: pieTitlePlugin,
612
+ tooltip: tooltipConfig,
613
+ datalabels: {
614
+ display: true,
615
+ color: textColor,
616
+ font: { weight: 'bold' as const },
617
+ formatter: (_value: number, ctx: { dataIndex: number }) =>
618
+ labels[ctx.dataIndex] ?? '',
619
+ anchor: 'end' as const,
620
+ align: 'end' as const,
621
+ offset: 16,
622
+ },
623
+ },
624
+ },
625
+ plugins: [pieConnectorPlugin],
626
+ } as ChartConfiguration;
627
+ }
628
+
629
+ // Multi-series: bar-stacked, or line with multiple series
630
+ const isMultiSeries =
631
+ parsed.type === 'bar-stacked' ||
632
+ (parsed.type === 'line' && parsed.seriesNames);
633
+ if (isMultiSeries) {
634
+ const seriesNames = parsed.seriesNames ?? ['Value'];
635
+ const isHorizontal = parsed.orientation === 'horizontal';
636
+ const isMultiLine = parsed.type === 'line';
637
+
638
+ // Transpose row-based data into per-series datasets
639
+ const datasets = seriesNames.map((name, seriesIdx) => {
640
+ const data = parsed.data.map((dp) => {
641
+ if (seriesIdx === 0) return dp.value;
642
+ return dp.extraValues?.[seriesIdx - 1] ?? 0;
643
+ });
644
+
645
+ const color =
646
+ parsed.seriesNameColors?.[seriesIdx] ??
647
+ colors[seriesIdx % colors.length];
648
+
649
+ if (isMultiLine) {
650
+ return {
651
+ label: name,
652
+ data,
653
+ borderColor: color,
654
+ backgroundColor: color + '40',
655
+ borderWidth: 3,
656
+ pointBackgroundColor: color,
657
+ pointRadius: 4,
658
+ tension: 0,
659
+ fill: false,
660
+ };
661
+ }
662
+
663
+ return {
664
+ label: name,
665
+ data,
666
+ backgroundColor: color,
667
+ borderColor: color,
668
+ borderWidth: 1,
669
+ };
670
+ });
671
+
672
+ const scaleOptions = isMultiLine
673
+ ? {
674
+ x: {
675
+ grid: { color: gridColor },
676
+ ticks: { color: textColor },
677
+ ...(xAxisTitle && { title: xAxisTitle }),
678
+ },
679
+ y: {
680
+ grid: { color: gridColor },
681
+ ticks: { color: textColor },
682
+ ...(yAxisTitle && { title: yAxisTitle }),
683
+ },
684
+ }
685
+ : {
686
+ x: {
687
+ stacked: true as const,
688
+ grid: { color: gridColor },
689
+ ticks: { color: textColor },
690
+ ...(xAxisTitle && { title: xAxisTitle }),
691
+ },
692
+ y: {
693
+ stacked: true as const,
694
+ grid: { color: gridColor },
695
+ ticks: { color: textColor },
696
+ ...(yAxisTitle && { title: yAxisTitle }),
697
+ },
698
+ };
699
+
700
+ return {
701
+ type: isMultiLine ? 'line' : 'bar',
702
+ data: {
703
+ labels,
704
+ datasets,
705
+ },
706
+ options: {
707
+ indexAxis: isHorizontal ? 'y' : 'x',
708
+ responsive: true,
709
+ maintainAspectRatio: false,
710
+ animation: false,
711
+ plugins: {
712
+ legend: { position: 'top' as const, labels: { color: textColor } },
713
+ title: titlePlugin,
714
+ tooltip: tooltipConfig,
715
+ datalabels: { display: false },
716
+ },
717
+ ...(isMultiLine
718
+ ? { interaction: { mode: 'index' as const, intersect: false } }
719
+ : {}),
720
+ scales: scaleOptions,
721
+ },
722
+ ...(isMultiLine ? { plugins: [verticalCrosshairPlugin] } : {}),
723
+ } as ChartConfiguration;
724
+ }
725
+
726
+ // Bar, line, area
727
+ const isHorizontal = parsed.orientation === 'horizontal';
728
+ const isLine = parsed.type === 'line' || parsed.type === 'area';
729
+ const isArea = parsed.type === 'area';
730
+ const lineColor =
731
+ parsed.color ?? parsed.seriesNameColors?.[0] ?? palette.primary;
732
+
733
+ return {
734
+ type: isLine ? 'line' : 'bar',
735
+ data: {
736
+ labels,
737
+ datasets: [
738
+ {
739
+ label: parsed.series ?? 'Value',
740
+ data: values,
741
+ backgroundColor: isLine
742
+ ? isArea
743
+ ? lineColor + '40'
744
+ : lineColor
745
+ : perPointColors,
746
+ borderColor: isLine ? lineColor : undefined,
747
+ borderWidth: isLine ? 3 : 0,
748
+ pointBackgroundColor: isLine ? lineColor : undefined,
749
+ pointRadius: isLine ? 4 : undefined,
750
+ tension: isLine ? 0 : undefined,
751
+ fill: isArea ? true : undefined,
752
+ },
753
+ ],
754
+ },
755
+ options: {
756
+ indexAxis: isHorizontal ? 'y' : 'x',
757
+ responsive: true,
758
+ maintainAspectRatio: false,
759
+ animation: false,
760
+ ...(isLine && !isArea
761
+ ? { interaction: { mode: 'index' as const, intersect: false } }
762
+ : {}),
763
+ plugins: {
764
+ legend: { display: false },
765
+ title: titlePlugin,
766
+ tooltip: tooltipConfig,
767
+ datalabels: { display: false },
768
+ },
769
+ scales: {
770
+ x: {
771
+ grid: { color: gridColor },
772
+ ticks: { color: textColor },
773
+ ...(xAxisTitle && { title: xAxisTitle }),
774
+ },
775
+ y: {
776
+ grid: { color: gridColor },
777
+ ticks: { color: textColor },
778
+ ...(yAxisTitle && { title: yAxisTitle }),
779
+ },
780
+ },
781
+ },
782
+ ...(isLine && !isArea ? { plugins: [verticalCrosshairPlugin] } : {}),
783
+ };
784
+ }