@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/echarts.ts ADDED
@@ -0,0 +1,1207 @@
1
+ import type { EChartsOption } from 'echarts';
2
+
3
+ // ============================================================
4
+ // Types
5
+ // ============================================================
6
+
7
+ export type EChartsChartType =
8
+ | 'sankey'
9
+ | 'chord'
10
+ | 'function'
11
+ | 'scatter'
12
+ | 'heatmap'
13
+ | 'funnel';
14
+
15
+ export interface EChartsDataPoint {
16
+ label: string;
17
+ value: number;
18
+ color?: string;
19
+ lineNumber: number;
20
+ }
21
+
22
+ export interface ParsedSankeyLink {
23
+ source: string;
24
+ target: string;
25
+ value: number;
26
+ lineNumber: number;
27
+ }
28
+
29
+ export interface ParsedFunction {
30
+ name: string;
31
+ expression: string;
32
+ color?: string;
33
+ lineNumber: number;
34
+ }
35
+
36
+ export interface ParsedScatterPoint {
37
+ name: string;
38
+ x: number;
39
+ y: number;
40
+ size?: number;
41
+ color?: string;
42
+ category?: string;
43
+ lineNumber: number;
44
+ }
45
+
46
+ export interface ParsedHeatmapRow {
47
+ label: string;
48
+ values: number[];
49
+ lineNumber: number;
50
+ }
51
+
52
+ export interface ParsedEChart {
53
+ type: EChartsChartType;
54
+ title?: string;
55
+ series?: string;
56
+ seriesNames?: string[];
57
+ seriesNameColors?: (string | undefined)[];
58
+ data: EChartsDataPoint[];
59
+ links?: ParsedSankeyLink[];
60
+ functions?: ParsedFunction[];
61
+ scatterPoints?: ParsedScatterPoint[];
62
+ heatmapRows?: ParsedHeatmapRow[];
63
+ columns?: string[];
64
+ rows?: string[];
65
+ xRange?: { min: number; max: number };
66
+ xlabel?: string;
67
+ ylabel?: string;
68
+ sizelabel?: string;
69
+ showLabels?: boolean;
70
+ categoryColors?: Record<string, string>;
71
+ error?: string;
72
+ }
73
+
74
+ // ============================================================
75
+ // Nord Colors for Charts
76
+ // ============================================================
77
+
78
+ import { resolveColor } from './colors';
79
+ import type { PaletteColors } from './palettes';
80
+ import { getSeriesColors } from './palettes';
81
+
82
+ // ============================================================
83
+ // Parser
84
+ // ============================================================
85
+
86
+ /**
87
+ * Parses the simple echart text format into a structured object.
88
+ *
89
+ * Format:
90
+ * ```
91
+ * chart: bar
92
+ * title: My Chart
93
+ * series: Revenue
94
+ *
95
+ * Jan: 120
96
+ * Feb: 200
97
+ * Mar: 150
98
+ * ```
99
+ */
100
+ export function parseEChart(
101
+ content: string,
102
+ palette?: PaletteColors
103
+ ): ParsedEChart {
104
+ const lines = content.split('\n');
105
+ const result: ParsedEChart = {
106
+ type: 'scatter',
107
+ data: [],
108
+ };
109
+
110
+ // Track current category for grouped scatter charts
111
+ let currentCategory = 'Default';
112
+
113
+ for (let i = 0; i < lines.length; i++) {
114
+ const trimmed = lines[i].trim();
115
+ const lineNumber = i + 1;
116
+
117
+ // Skip empty lines
118
+ if (!trimmed) continue;
119
+
120
+ // Check for markdown-style category header: ## Category Name or ## Category Name(color)
121
+ const mdCategoryMatch = trimmed.match(/^#{2,}\s+(.+)$/);
122
+ if (mdCategoryMatch) {
123
+ let catName = mdCategoryMatch[1].trim();
124
+ const catColorMatch = catName.match(/\(([^)]+)\)\s*$/);
125
+ if (catColorMatch) {
126
+ const resolved = resolveColor(catColorMatch[1].trim(), palette);
127
+ if (!result.categoryColors) result.categoryColors = {};
128
+ catName = catName.substring(0, catColorMatch.index!).trim();
129
+ result.categoryColors[catName] = resolved;
130
+ }
131
+ currentCategory = catName;
132
+ continue;
133
+ }
134
+
135
+ // Skip comments
136
+ if (trimmed.startsWith('#') || trimmed.startsWith('//')) continue;
137
+
138
+ // Check for category header: [Category Name]
139
+ const categoryMatch = trimmed.match(/^\[(.+)\]$/);
140
+ if (categoryMatch) {
141
+ currentCategory = categoryMatch[1].trim();
142
+ continue;
143
+ }
144
+
145
+ // Parse key: value pairs
146
+ const colonIndex = trimmed.indexOf(':');
147
+ if (colonIndex === -1) continue;
148
+
149
+ const key = trimmed.substring(0, colonIndex).trim().toLowerCase();
150
+ const value = trimmed.substring(colonIndex + 1).trim();
151
+
152
+ // Handle metadata
153
+ if (key === 'chart') {
154
+ const chartType = value.toLowerCase();
155
+ if (
156
+ chartType === 'sankey' ||
157
+ chartType === 'chord' ||
158
+ chartType === 'function' ||
159
+ chartType === 'scatter' ||
160
+ chartType === 'heatmap' ||
161
+ chartType === 'funnel'
162
+ ) {
163
+ result.type = chartType;
164
+ } else {
165
+ result.error = `Unsupported chart type: ${value}. Supported types: scatter, sankey, chord, function, heatmap, funnel.`;
166
+ return result;
167
+ }
168
+ continue;
169
+ }
170
+
171
+ if (key === 'title') {
172
+ result.title = value;
173
+ continue;
174
+ }
175
+
176
+ if (key === 'series') {
177
+ result.series = value;
178
+ const rawNames = value
179
+ .split(',')
180
+ .map((s) => s.trim())
181
+ .filter(Boolean);
182
+ const names: string[] = [];
183
+ const nameColors: (string | undefined)[] = [];
184
+ for (const raw of rawNames) {
185
+ const colorMatch = raw.match(/\(([^)]+)\)\s*$/);
186
+ if (colorMatch) {
187
+ nameColors.push(resolveColor(colorMatch[1].trim(), palette));
188
+ names.push(raw.substring(0, colorMatch.index!).trim());
189
+ } else {
190
+ nameColors.push(undefined);
191
+ names.push(raw);
192
+ }
193
+ }
194
+ if (names.length === 1) {
195
+ result.series = names[0];
196
+ }
197
+ if (nameColors.some(Boolean)) result.seriesNameColors = nameColors;
198
+ continue;
199
+ }
200
+
201
+ // Axis labels
202
+ if (key === 'xlabel') {
203
+ result.xlabel = value;
204
+ continue;
205
+ }
206
+
207
+ if (key === 'ylabel') {
208
+ result.ylabel = value;
209
+ continue;
210
+ }
211
+
212
+ if (key === 'sizelabel') {
213
+ result.sizelabel = value;
214
+ continue;
215
+ }
216
+
217
+ if (key === 'labels') {
218
+ result.showLabels =
219
+ value.toLowerCase() === 'on' || value.toLowerCase() === 'true';
220
+ continue;
221
+ }
222
+
223
+ // Heatmap columns and rows headers
224
+ if (key === 'columns') {
225
+ result.columns = value.split(',').map((s) => s.trim());
226
+ continue;
227
+ }
228
+
229
+ if (key === 'rows') {
230
+ result.rows = value.split(',').map((s) => s.trim());
231
+ continue;
232
+ }
233
+
234
+ // Check for x range: "x: min to max"
235
+ if (key === 'x') {
236
+ const rangeMatch = value.match(/^(-?[\d.]+)\s+to\s+(-?[\d.]+)$/);
237
+ if (rangeMatch) {
238
+ result.xRange = {
239
+ min: parseFloat(rangeMatch[1]),
240
+ max: parseFloat(rangeMatch[2]),
241
+ };
242
+ }
243
+ continue;
244
+ }
245
+
246
+ // Check for Sankey arrow syntax: Source -> Target: Value
247
+ const arrowMatch = trimmed.match(/^(.+?)\s*->\s*(.+?):\s*(\d+(?:\.\d+)?)$/);
248
+ if (arrowMatch) {
249
+ const [, source, target, val] = arrowMatch;
250
+ if (!result.links) result.links = [];
251
+ result.links.push({
252
+ source: source.trim(),
253
+ target: target.trim(),
254
+ value: parseFloat(val),
255
+ lineNumber,
256
+ });
257
+ continue;
258
+ }
259
+
260
+ // For function charts, treat non-numeric values as function expressions
261
+ if (result.type === 'function') {
262
+ let fnName = trimmed.substring(0, colonIndex).trim();
263
+ let fnColor: string | undefined;
264
+ const colorMatch = fnName.match(/\(([^)]+)\)\s*$/);
265
+ if (colorMatch) {
266
+ fnColor = resolveColor(colorMatch[1].trim(), palette);
267
+ fnName = fnName.substring(0, colorMatch.index!).trim();
268
+ }
269
+ if (!result.functions) result.functions = [];
270
+ result.functions.push({
271
+ name: fnName,
272
+ expression: value,
273
+ ...(fnColor && { color: fnColor }),
274
+ lineNumber,
275
+ });
276
+ continue;
277
+ }
278
+
279
+ // For scatter charts, parse "Name: x, y" or "Name: x, y, size"
280
+ if (result.type === 'scatter') {
281
+ const scatterMatch = value.match(
282
+ /^(-?[\d.]+)\s*,\s*(-?[\d.]+)(?:\s*,\s*(-?[\d.]+))?$/
283
+ );
284
+ if (scatterMatch) {
285
+ let scatterName = trimmed.substring(0, colonIndex).trim();
286
+ let scatterColor: string | undefined;
287
+ const colorMatch = scatterName.match(/\(([^)]+)\)\s*$/);
288
+ if (colorMatch) {
289
+ scatterColor = resolveColor(colorMatch[1].trim(), palette);
290
+ scatterName = scatterName.substring(0, colorMatch.index!).trim();
291
+ }
292
+ if (!result.scatterPoints) result.scatterPoints = [];
293
+ result.scatterPoints.push({
294
+ name: scatterName,
295
+ x: parseFloat(scatterMatch[1]),
296
+ y: parseFloat(scatterMatch[2]),
297
+ size: scatterMatch[3] ? parseFloat(scatterMatch[3]) : undefined,
298
+ ...(scatterColor && { color: scatterColor }),
299
+ ...(currentCategory !== 'Default' && { category: currentCategory }),
300
+ lineNumber,
301
+ });
302
+ }
303
+ continue;
304
+ }
305
+
306
+ // For heatmap, parse "RowLabel: val1, val2, val3, ..."
307
+ if (result.type === 'heatmap') {
308
+ const values = value.split(',').map((v) => parseFloat(v.trim()));
309
+ if (values.length > 0 && values.every((v) => !isNaN(v))) {
310
+ const originalKey = trimmed.substring(0, colonIndex).trim();
311
+ if (!result.heatmapRows) result.heatmapRows = [];
312
+ result.heatmapRows.push({ label: originalKey, values, lineNumber });
313
+ }
314
+ continue;
315
+ }
316
+
317
+ // Otherwise treat as data point (label: value)
318
+ const numValue = parseFloat(value);
319
+ if (!isNaN(numValue)) {
320
+ // Use the original case for the label (before lowercasing)
321
+ let rawLabel = trimmed.substring(0, colonIndex).trim();
322
+ let pointColor: string | undefined;
323
+ const colorMatch = rawLabel.match(/\(([^)]+)\)\s*$/);
324
+ if (colorMatch) {
325
+ pointColor = resolveColor(colorMatch[1].trim(), palette);
326
+ rawLabel = rawLabel.substring(0, colorMatch.index!).trim();
327
+ }
328
+ result.data.push({
329
+ label: rawLabel,
330
+ value: numValue,
331
+ ...(pointColor && { color: pointColor }),
332
+ lineNumber,
333
+ });
334
+ }
335
+ }
336
+
337
+ if (!result.error) {
338
+ if (result.type === 'sankey') {
339
+ if (!result.links || result.links.length === 0) {
340
+ result.error =
341
+ 'No links found. Add links in format: Source -> Target: 123';
342
+ }
343
+ } else if (result.type === 'chord') {
344
+ if (!result.links || result.links.length === 0) {
345
+ result.error =
346
+ 'No links found. Add links in format: Source -> Target: 123';
347
+ }
348
+ } else if (result.type === 'function') {
349
+ if (!result.functions || result.functions.length === 0) {
350
+ result.error =
351
+ 'No functions found. Add functions in format: Name: expression';
352
+ }
353
+ if (!result.xRange) {
354
+ result.xRange = { min: -10, max: 10 }; // Default range
355
+ }
356
+ } else if (result.type === 'scatter') {
357
+ if (!result.scatterPoints || result.scatterPoints.length === 0) {
358
+ result.error =
359
+ 'No scatter points found. Add points in format: Name: x, y or Name: x, y, size';
360
+ }
361
+ } else if (result.type === 'heatmap') {
362
+ if (!result.heatmapRows || result.heatmapRows.length === 0) {
363
+ result.error =
364
+ 'No heatmap data found. Add data in format: RowLabel: val1, val2, val3';
365
+ }
366
+ if (!result.columns || result.columns.length === 0) {
367
+ result.error =
368
+ 'No columns defined. Add columns in format: columns: Col1, Col2, Col3';
369
+ }
370
+ } else if (result.type === 'funnel') {
371
+ if (result.data.length === 0) {
372
+ result.error = 'No data found. Add data in format: Label: value';
373
+ }
374
+ }
375
+ }
376
+
377
+ return result;
378
+ }
379
+
380
+ // ============================================================
381
+ // ECharts Option Builder
382
+ // ============================================================
383
+
384
+ /**
385
+ * Converts parsed echart data to ECharts option object.
386
+ */
387
+ export function buildEChartsOption(
388
+ parsed: ParsedEChart,
389
+ palette: PaletteColors,
390
+ _isDark: boolean
391
+ ): EChartsOption {
392
+ const textColor = palette.text;
393
+ const axisLineColor = palette.border;
394
+ const colors = getSeriesColors(palette);
395
+
396
+ if (parsed.error) {
397
+ // Return empty option, error will be shown separately
398
+ return {};
399
+ }
400
+
401
+ // Common title configuration
402
+ const titleConfig = parsed.title
403
+ ? {
404
+ text: parsed.title,
405
+ left: 'center' as const,
406
+ textStyle: {
407
+ color: textColor,
408
+ fontSize: 18,
409
+ fontWeight: 'bold' as const,
410
+ fontFamily: 'system-ui, -apple-system, sans-serif',
411
+ },
412
+ }
413
+ : undefined;
414
+
415
+ // Shared tooltip theme so tooltips match light/dark mode
416
+ const tooltipTheme = {
417
+ backgroundColor: palette.surface,
418
+ borderColor: palette.border,
419
+ textStyle: { color: palette.text },
420
+ };
421
+
422
+ // Sankey chart has different structure
423
+ if (parsed.type === 'sankey') {
424
+ return buildSankeyOption(
425
+ parsed,
426
+ textColor,
427
+ colors,
428
+ titleConfig,
429
+ tooltipTheme
430
+ );
431
+ }
432
+
433
+ // Chord diagram
434
+ if (parsed.type === 'chord') {
435
+ return buildChordOption(
436
+ parsed,
437
+ textColor,
438
+ colors,
439
+ titleConfig,
440
+ tooltipTheme
441
+ );
442
+ }
443
+
444
+ // Function plot
445
+ if (parsed.type === 'function') {
446
+ return buildFunctionOption(
447
+ parsed,
448
+ palette,
449
+ textColor,
450
+ axisLineColor,
451
+ colors,
452
+ titleConfig,
453
+ tooltipTheme
454
+ );
455
+ }
456
+
457
+ // Scatter plot
458
+ if (parsed.type === 'scatter') {
459
+ return buildScatterOption(
460
+ parsed,
461
+ palette,
462
+ textColor,
463
+ axisLineColor,
464
+ colors,
465
+ titleConfig,
466
+ tooltipTheme
467
+ );
468
+ }
469
+
470
+ // Funnel chart
471
+ if (parsed.type === 'funnel') {
472
+ return buildFunnelOption(
473
+ parsed,
474
+ textColor,
475
+ colors,
476
+ titleConfig,
477
+ tooltipTheme
478
+ );
479
+ }
480
+
481
+ // Heatmap
482
+ return buildHeatmapOption(
483
+ parsed,
484
+ palette,
485
+ textColor,
486
+ axisLineColor,
487
+ titleConfig,
488
+ tooltipTheme
489
+ );
490
+ }
491
+
492
+ /**
493
+ * Builds ECharts option for sankey diagrams.
494
+ */
495
+ function buildSankeyOption(
496
+ parsed: ParsedEChart,
497
+ textColor: string,
498
+ colors: string[],
499
+ titleConfig: EChartsOption['title'],
500
+ tooltipTheme: Record<string, unknown>
501
+ ): EChartsOption {
502
+ // Extract unique nodes from links
503
+ const nodeSet = new Set<string>();
504
+ if (parsed.links) {
505
+ for (const link of parsed.links) {
506
+ nodeSet.add(link.source);
507
+ nodeSet.add(link.target);
508
+ }
509
+ }
510
+
511
+ const nodes = Array.from(nodeSet).map((name, index) => ({
512
+ name,
513
+ itemStyle: {
514
+ color: colors[index % colors.length],
515
+ },
516
+ }));
517
+
518
+ return {
519
+ backgroundColor: 'transparent',
520
+ animation: false,
521
+ title: titleConfig,
522
+ tooltip: {
523
+ show: false,
524
+ ...tooltipTheme,
525
+ },
526
+ series: [
527
+ {
528
+ type: 'sankey',
529
+ emphasis: {
530
+ focus: 'adjacency',
531
+ },
532
+ nodeAlign: 'left',
533
+ nodeGap: 12,
534
+ nodeWidth: 20,
535
+ data: nodes,
536
+ links: parsed.links ?? [],
537
+ lineStyle: {
538
+ color: 'gradient',
539
+ curveness: 0.5,
540
+ },
541
+ label: {
542
+ color: textColor,
543
+ fontSize: 12,
544
+ },
545
+ },
546
+ ],
547
+ };
548
+ }
549
+
550
+ /**
551
+ * Builds ECharts option for chord diagrams.
552
+ */
553
+ function buildChordOption(
554
+ parsed: ParsedEChart,
555
+ textColor: string,
556
+ colors: string[],
557
+ titleConfig: EChartsOption['title'],
558
+ tooltipTheme: Record<string, unknown>
559
+ ): EChartsOption {
560
+ // Extract unique nodes from links
561
+ const nodeSet = new Set<string>();
562
+ if (parsed.links) {
563
+ for (const link of parsed.links) {
564
+ nodeSet.add(link.source);
565
+ nodeSet.add(link.target);
566
+ }
567
+ }
568
+
569
+ const nodeNames = Array.from(nodeSet);
570
+ const nodeCount = nodeNames.length;
571
+
572
+ // Build adjacency matrix
573
+ const matrix: number[][] = Array(nodeCount)
574
+ .fill(null)
575
+ .map(() => Array(nodeCount).fill(0));
576
+
577
+ if (parsed.links) {
578
+ for (const link of parsed.links) {
579
+ const sourceIndex = nodeNames.indexOf(link.source);
580
+ const targetIndex = nodeNames.indexOf(link.target);
581
+ if (sourceIndex !== -1 && targetIndex !== -1) {
582
+ matrix[sourceIndex][targetIndex] = link.value;
583
+ }
584
+ }
585
+ }
586
+
587
+ // Create category data for nodes with colors
588
+ const categories = nodeNames.map((name, index) => ({
589
+ name,
590
+ itemStyle: {
591
+ color: colors[index % colors.length],
592
+ },
593
+ }));
594
+
595
+ return {
596
+ backgroundColor: 'transparent',
597
+ animation: false,
598
+ title: titleConfig,
599
+ tooltip: {
600
+ trigger: 'item',
601
+ ...tooltipTheme,
602
+ formatter: (params: unknown) => {
603
+ const p = params as {
604
+ data?: { source: string; target: string; value: number };
605
+ };
606
+ if (p.data && p.data.source && p.data.target) {
607
+ return `${p.data.source} → ${p.data.target}: ${p.data.value}`;
608
+ }
609
+ return '';
610
+ },
611
+ },
612
+ legend: {
613
+ data: nodeNames,
614
+ bottom: 10,
615
+ textStyle: {
616
+ color: textColor,
617
+ },
618
+ },
619
+ series: [
620
+ {
621
+ type: 'graph',
622
+ layout: 'circular',
623
+ circular: {
624
+ rotateLabel: true,
625
+ },
626
+ center: ['50%', '55%'],
627
+ width: '60%',
628
+ height: '60%',
629
+ data: categories.map((cat) => ({
630
+ name: cat.name,
631
+ symbolSize: 20,
632
+ itemStyle: cat.itemStyle,
633
+ label: {
634
+ show: true,
635
+ color: textColor,
636
+ },
637
+ })),
638
+ links: (parsed.links ?? []).map((link) => ({
639
+ source: link.source,
640
+ target: link.target,
641
+ value: link.value,
642
+ lineStyle: {
643
+ width: Math.max(1, Math.min(link.value / 20, 10)),
644
+ color: colors[nodeNames.indexOf(link.source) % colors.length],
645
+ curveness: 0.3,
646
+ opacity: 0.6,
647
+ },
648
+ })),
649
+ roam: true,
650
+ label: {
651
+ position: 'right',
652
+ formatter: '{b}',
653
+ },
654
+ emphasis: {
655
+ focus: 'adjacency',
656
+ lineStyle: {
657
+ width: 5,
658
+ opacity: 1,
659
+ },
660
+ },
661
+ },
662
+ ],
663
+ };
664
+ }
665
+
666
+ /**
667
+ * Evaluates a mathematical expression for a given x value.
668
+ * Supports: +, -, *, /, ^, sin, cos, tan, log, ln, exp, sqrt, abs, pi, e
669
+ */
670
+ function evaluateExpression(expr: string, x: number): number {
671
+ try {
672
+ // Replace mathematical constants and functions
673
+ const processed = expr
674
+ .replace(/\bpi\b/gi, String(Math.PI))
675
+ .replace(/\be\b/g, String(Math.E))
676
+ .replace(/\bsin\s*\(/gi, 'Math.sin(')
677
+ .replace(/\bcos\s*\(/gi, 'Math.cos(')
678
+ .replace(/\btan\s*\(/gi, 'Math.tan(')
679
+ .replace(/\bln\s*\(/gi, 'Math.log(')
680
+ .replace(/\blog\s*\(/gi, 'Math.log10(')
681
+ .replace(/\bexp\s*\(/gi, 'Math.exp(')
682
+ .replace(/\bsqrt\s*\(/gi, 'Math.sqrt(')
683
+ .replace(/\babs\s*\(/gi, 'Math.abs(')
684
+ .replace(/\bx\b/gi, `(${x})`)
685
+ .replace(/\^/g, '**');
686
+
687
+ // Evaluate the expression
688
+ const result = new Function(`return ${processed}`)() as unknown;
689
+ return typeof result === 'number' && isFinite(result) ? result : NaN;
690
+ } catch {
691
+ return NaN;
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Builds ECharts option for function plots.
697
+ */
698
+ function buildFunctionOption(
699
+ parsed: ParsedEChart,
700
+ palette: PaletteColors,
701
+ textColor: string,
702
+ axisLineColor: string,
703
+ colors: string[],
704
+ titleConfig: EChartsOption['title'],
705
+ tooltipTheme: Record<string, unknown>
706
+ ): EChartsOption {
707
+ const xRange = parsed.xRange ?? { min: -10, max: 10 };
708
+ const samples = 200;
709
+ const step = (xRange.max - xRange.min) / samples;
710
+
711
+ // Generate x values
712
+ const xValues: number[] = [];
713
+ for (let i = 0; i <= samples; i++) {
714
+ xValues.push(xRange.min + i * step);
715
+ }
716
+
717
+ // Generate series for each function
718
+ const series = (parsed.functions ?? []).map((fn, index) => {
719
+ const data = xValues.map((x) => {
720
+ const y = evaluateExpression(fn.expression, x);
721
+ return [x, y];
722
+ });
723
+
724
+ const fnColor = fn.color ?? colors[index % colors.length];
725
+ return {
726
+ name: fn.name,
727
+ type: 'line' as const,
728
+ showSymbol: false,
729
+ smooth: true,
730
+ data,
731
+ lineStyle: {
732
+ width: 2,
733
+ color: fnColor,
734
+ },
735
+ itemStyle: {
736
+ color: fnColor,
737
+ },
738
+ };
739
+ });
740
+
741
+ return {
742
+ backgroundColor: 'transparent',
743
+ animation: false,
744
+ title: titleConfig,
745
+ tooltip: {
746
+ trigger: 'axis',
747
+ ...tooltipTheme,
748
+ axisPointer: {
749
+ type: 'cross',
750
+ },
751
+ },
752
+ legend: {
753
+ data: (parsed.functions ?? []).map((fn) => fn.name),
754
+ bottom: 10,
755
+ textStyle: {
756
+ color: textColor,
757
+ },
758
+ },
759
+ grid: {
760
+ left: '3%',
761
+ right: '4%',
762
+ bottom: '15%',
763
+ top: parsed.title ? '15%' : '5%',
764
+ containLabel: true,
765
+ },
766
+ xAxis: {
767
+ type: 'value',
768
+ min: xRange.min,
769
+ max: xRange.max,
770
+ axisLine: {
771
+ lineStyle: { color: axisLineColor },
772
+ },
773
+ axisLabel: {
774
+ color: textColor,
775
+ },
776
+ splitLine: {
777
+ lineStyle: {
778
+ color: palette.overlay,
779
+ },
780
+ },
781
+ },
782
+ yAxis: {
783
+ type: 'value',
784
+ axisLine: {
785
+ lineStyle: { color: axisLineColor },
786
+ },
787
+ axisLabel: {
788
+ color: textColor,
789
+ },
790
+ splitLine: {
791
+ lineStyle: {
792
+ color: palette.overlay,
793
+ },
794
+ },
795
+ },
796
+ series,
797
+ };
798
+ }
799
+
800
+ /**
801
+ * Builds ECharts option for scatter plots.
802
+ * Auto-detects categories and size from point data:
803
+ * - hasCategories → multi-series with legend (one per category)
804
+ * - hasSize → dynamic symbol sizing from 3rd value
805
+ */
806
+ function buildScatterOption(
807
+ parsed: ParsedEChart,
808
+ palette: PaletteColors,
809
+ textColor: string,
810
+ axisLineColor: string,
811
+ colors: string[],
812
+ titleConfig: EChartsOption['title'],
813
+ tooltipTheme: Record<string, unknown>
814
+ ): EChartsOption {
815
+ const points = parsed.scatterPoints ?? [];
816
+ const defaultSize = 15;
817
+
818
+ const hasCategories = points.some((p) => p.category !== undefined);
819
+ const hasSize = points.some((p) => p.size !== undefined);
820
+
821
+ const labelConfig = {
822
+ show: parsed.showLabels ?? false,
823
+ formatter: '{b}',
824
+ position: 'top' as const,
825
+ color: textColor,
826
+ fontSize: 11,
827
+ };
828
+
829
+ const emphasisConfig = {
830
+ focus: 'self' as const,
831
+ itemStyle: {
832
+ shadowBlur: 10,
833
+ shadowColor: 'rgba(0, 0, 0, 0.3)',
834
+ },
835
+ };
836
+
837
+ // Build series based on whether categories are present
838
+ let series;
839
+ let legendData: string[] | undefined;
840
+
841
+ if (hasCategories) {
842
+ const categories = [
843
+ ...new Set(points.map((p) => p.category).filter(Boolean)),
844
+ ] as string[];
845
+ legendData = categories;
846
+
847
+ series = categories.map((category, catIndex) => {
848
+ const categoryPoints = points.filter((p) => p.category === category);
849
+ const catColor =
850
+ parsed.categoryColors?.[category] ?? colors[catIndex % colors.length];
851
+
852
+ const data = categoryPoints.map((p) => ({
853
+ name: p.name,
854
+ value: hasSize ? [p.x, p.y, p.size ?? 0] : [p.x, p.y],
855
+ ...(p.color && { itemStyle: { color: p.color } }),
856
+ }));
857
+
858
+ return {
859
+ name: category,
860
+ type: 'scatter' as const,
861
+ data,
862
+ ...(hasSize
863
+ ? { symbolSize: (val: number[]) => val[2] }
864
+ : { symbolSize: defaultSize }),
865
+ itemStyle: { color: catColor },
866
+ label: labelConfig,
867
+ emphasis: emphasisConfig,
868
+ };
869
+ });
870
+ } else {
871
+ // Single series — per-point colors
872
+ const data = points.map((p, index) => ({
873
+ name: p.name,
874
+ value: hasSize ? [p.x, p.y, p.size ?? 0] : [p.x, p.y],
875
+ ...(hasSize
876
+ ? { symbolSize: p.size ?? defaultSize }
877
+ : { symbolSize: defaultSize }),
878
+ itemStyle: {
879
+ color: p.color ?? colors[index % colors.length],
880
+ },
881
+ }));
882
+
883
+ series = [
884
+ {
885
+ type: 'scatter' as const,
886
+ data,
887
+ label: labelConfig,
888
+ emphasis: emphasisConfig,
889
+ },
890
+ ];
891
+ }
892
+
893
+ // Tooltip adapts to available data
894
+ const tooltip = {
895
+ trigger: 'item' as const,
896
+ ...tooltipTheme,
897
+ formatter: (params: unknown) => {
898
+ const p = params as {
899
+ seriesName: string;
900
+ name: string;
901
+ value: number[];
902
+ };
903
+ const xLabel = parsed.xlabel || 'x';
904
+ const yLabel = parsed.ylabel || 'y';
905
+ let html = `<strong>${p.name}</strong>`;
906
+ if (hasCategories) html += `<br/>${p.seriesName}`;
907
+ html += `<br/>${xLabel}: ${p.value[0]}<br/>${yLabel}: ${p.value[1]}`;
908
+ if (hasSize) html += `<br/>${parsed.sizelabel || 'size'}: ${p.value[2]}`;
909
+ return html;
910
+ },
911
+ };
912
+
913
+ return {
914
+ backgroundColor: 'transparent',
915
+ animation: false,
916
+ title: titleConfig,
917
+ tooltip,
918
+ ...(legendData && {
919
+ legend: {
920
+ data: legendData,
921
+ bottom: 10,
922
+ textStyle: { color: textColor },
923
+ },
924
+ }),
925
+ grid: {
926
+ left: '3%',
927
+ right: '4%',
928
+ bottom: hasCategories ? '15%' : '3%',
929
+ top: parsed.title ? '15%' : '5%',
930
+ containLabel: true,
931
+ },
932
+ xAxis: {
933
+ type: 'value',
934
+ name: parsed.xlabel,
935
+ nameLocation: 'middle',
936
+ nameGap: 30,
937
+ nameTextStyle: {
938
+ color: textColor,
939
+ fontSize: 12,
940
+ },
941
+ axisLine: {
942
+ lineStyle: { color: axisLineColor },
943
+ },
944
+ axisLabel: {
945
+ color: textColor,
946
+ },
947
+ splitLine: {
948
+ lineStyle: {
949
+ color: palette.overlay,
950
+ },
951
+ },
952
+ },
953
+ yAxis: {
954
+ type: 'value',
955
+ name: parsed.ylabel,
956
+ nameLocation: 'middle',
957
+ nameGap: 40,
958
+ nameTextStyle: {
959
+ color: textColor,
960
+ fontSize: 12,
961
+ },
962
+ axisLine: {
963
+ lineStyle: { color: axisLineColor },
964
+ },
965
+ axisLabel: {
966
+ color: textColor,
967
+ },
968
+ splitLine: {
969
+ lineStyle: {
970
+ color: palette.overlay,
971
+ },
972
+ },
973
+ },
974
+ series,
975
+ };
976
+ }
977
+
978
+ /**
979
+ * Builds ECharts option for heatmap charts.
980
+ */
981
+ function buildHeatmapOption(
982
+ parsed: ParsedEChart,
983
+ palette: PaletteColors,
984
+ textColor: string,
985
+ axisLineColor: string,
986
+ titleConfig: EChartsOption['title'],
987
+ tooltipTheme: Record<string, unknown>
988
+ ): EChartsOption {
989
+ const heatmapRows = parsed.heatmapRows ?? [];
990
+ const columns = parsed.columns ?? [];
991
+ const rowLabels = heatmapRows.map((r) => r.label);
992
+
993
+ // Convert row data to [colIndex, rowIndex, value] format
994
+ const data: [number, number, number][] = [];
995
+ let minValue = Infinity;
996
+ let maxValue = -Infinity;
997
+
998
+ heatmapRows.forEach((row, rowIndex) => {
999
+ row.values.forEach((value, colIndex) => {
1000
+ data.push([colIndex, rowIndex, value]);
1001
+ minValue = Math.min(minValue, value);
1002
+ maxValue = Math.max(maxValue, value);
1003
+ });
1004
+ });
1005
+
1006
+ return {
1007
+ backgroundColor: 'transparent',
1008
+ animation: false,
1009
+ title: titleConfig,
1010
+ tooltip: {
1011
+ trigger: 'item',
1012
+ ...tooltipTheme,
1013
+ formatter: (params: unknown) => {
1014
+ const p = params as { data: [number, number, number] };
1015
+ const colName = columns[p.data[0]] ?? p.data[0];
1016
+ const rowName = rowLabels[p.data[1]] ?? p.data[1];
1017
+ return `${rowName} / ${colName}: <strong>${p.data[2]}</strong>`;
1018
+ },
1019
+ },
1020
+ grid: {
1021
+ left: '3%',
1022
+ right: '10%',
1023
+ bottom: '3%',
1024
+ top: parsed.title ? '15%' : '5%',
1025
+ containLabel: true,
1026
+ },
1027
+ xAxis: {
1028
+ type: 'category',
1029
+ data: columns,
1030
+ splitArea: {
1031
+ show: true,
1032
+ },
1033
+ axisLine: {
1034
+ lineStyle: { color: axisLineColor },
1035
+ },
1036
+ axisLabel: {
1037
+ color: textColor,
1038
+ },
1039
+ },
1040
+ yAxis: {
1041
+ type: 'category',
1042
+ data: rowLabels,
1043
+ splitArea: {
1044
+ show: true,
1045
+ },
1046
+ axisLine: {
1047
+ lineStyle: { color: axisLineColor },
1048
+ },
1049
+ axisLabel: {
1050
+ color: textColor,
1051
+ },
1052
+ },
1053
+ visualMap: {
1054
+ min: minValue,
1055
+ max: maxValue,
1056
+ calculable: true,
1057
+ orient: 'vertical',
1058
+ right: '2%',
1059
+ top: 'center',
1060
+ inRange: {
1061
+ color: [
1062
+ palette.bg,
1063
+ palette.primary,
1064
+ palette.colors.cyan,
1065
+ palette.colors.yellow,
1066
+ palette.colors.orange,
1067
+ ],
1068
+ },
1069
+ textStyle: {
1070
+ color: textColor,
1071
+ },
1072
+ },
1073
+ series: [
1074
+ {
1075
+ type: 'heatmap',
1076
+ data,
1077
+ label: {
1078
+ show: true,
1079
+ color: textColor,
1080
+ },
1081
+ emphasis: {
1082
+ itemStyle: {
1083
+ shadowBlur: 10,
1084
+ shadowColor: 'rgba(0, 0, 0, 0.5)',
1085
+ },
1086
+ },
1087
+ },
1088
+ ],
1089
+ };
1090
+ }
1091
+
1092
+ /**
1093
+ * Builds ECharts option for funnel charts.
1094
+ */
1095
+ function buildFunnelOption(
1096
+ parsed: ParsedEChart,
1097
+ textColor: string,
1098
+ colors: string[],
1099
+ titleConfig: EChartsOption['title'],
1100
+ tooltipTheme: Record<string, unknown>
1101
+ ): EChartsOption {
1102
+ // Sort data descending by value for funnel ordering
1103
+ const sorted = [...parsed.data].sort((a, b) => b.value - a.value);
1104
+ const topValue = sorted.length > 0 ? sorted[0].value : 1;
1105
+
1106
+ const data = sorted.map((d) => ({
1107
+ name: d.label,
1108
+ value: d.value,
1109
+ itemStyle: {
1110
+ color: d.color ?? colors[parsed.data.indexOf(d) % colors.length],
1111
+ borderWidth: 0,
1112
+ },
1113
+ }));
1114
+
1115
+ // Build lookup for tooltip: previous step value (in sorted order)
1116
+ const prevValueMap = new Map<string, number>();
1117
+ for (let i = 0; i < sorted.length; i++) {
1118
+ prevValueMap.set(
1119
+ sorted[i].label,
1120
+ i > 0 ? sorted[i - 1].value : sorted[i].value
1121
+ );
1122
+ }
1123
+
1124
+ const funnelTop = parsed.title ? 60 : 20;
1125
+ const funnelLayout = {
1126
+ left: '20%',
1127
+ top: funnelTop,
1128
+ bottom: 20,
1129
+ width: '60%',
1130
+ sort: 'descending' as const,
1131
+ gap: 2,
1132
+ minSize: '8%',
1133
+ };
1134
+
1135
+ return {
1136
+ backgroundColor: 'transparent',
1137
+ animation: false,
1138
+ title: titleConfig,
1139
+ tooltip: {
1140
+ trigger: 'item',
1141
+ ...tooltipTheme,
1142
+ formatter: (params: unknown) => {
1143
+ const p = params as { name: string; value: number; dataIndex: number };
1144
+ const val = p.value;
1145
+ const prev = prevValueMap.get(p.name) ?? val;
1146
+ const isFirst = p.dataIndex === 0;
1147
+ let html = `<strong>${p.name}</strong>: ${val}`;
1148
+ if (!isFirst) {
1149
+ const stepDrop = ((1 - val / prev) * 100).toFixed(1);
1150
+ html += `<br/>Step drop-off: ${stepDrop}%`;
1151
+ }
1152
+ if (!isFirst && topValue > 0) {
1153
+ const totalDrop = ((1 - val / topValue) * 100).toFixed(1);
1154
+ html += `<br/>Overall drop-off: ${totalDrop}%`;
1155
+ }
1156
+ return html;
1157
+ },
1158
+ },
1159
+ series: [
1160
+ {
1161
+ type: 'funnel',
1162
+ ...funnelLayout,
1163
+ label: {
1164
+ show: true,
1165
+ position: 'left',
1166
+ formatter: '{b}',
1167
+ color: textColor,
1168
+ fontSize: 13,
1169
+ },
1170
+ labelLine: {
1171
+ show: true,
1172
+ length: 10,
1173
+ lineStyle: { color: textColor, opacity: 0.3 },
1174
+ },
1175
+ emphasis: {
1176
+ label: {
1177
+ fontSize: 15,
1178
+ },
1179
+ },
1180
+ data,
1181
+ },
1182
+ {
1183
+ type: 'funnel',
1184
+ ...funnelLayout,
1185
+ silent: true,
1186
+ itemStyle: { color: 'transparent', borderWidth: 0 },
1187
+ label: {
1188
+ show: true,
1189
+ position: 'right',
1190
+ formatter: '{c}',
1191
+ color: textColor,
1192
+ fontSize: 13,
1193
+ },
1194
+ labelLine: {
1195
+ show: true,
1196
+ length: 10,
1197
+ lineStyle: { color: textColor, opacity: 0.3 },
1198
+ },
1199
+ emphasis: { disabled: true },
1200
+ data: data.map((d) => ({
1201
+ ...d,
1202
+ itemStyle: { color: 'transparent', borderWidth: 0 },
1203
+ })),
1204
+ },
1205
+ ],
1206
+ };
1207
+ }