@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/LICENSE +21 -0
- package/README.md +335 -0
- package/dist/index.cjs +6698 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +685 -0
- package/dist/index.d.ts +685 -0
- package/dist/index.js +6611 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/chartjs.ts +784 -0
- package/src/colors.ts +75 -0
- package/src/d3.ts +5021 -0
- package/src/dgmo-mermaid.ts +247 -0
- package/src/dgmo-router.ts +77 -0
- package/src/echarts.ts +1207 -0
- package/src/index.ts +126 -0
- package/src/palettes/bold.ts +59 -0
- package/src/palettes/catppuccin.ts +76 -0
- package/src/palettes/color-utils.ts +191 -0
- package/src/palettes/gruvbox.ts +77 -0
- package/src/palettes/index.ts +35 -0
- package/src/palettes/mermaid-bridge.ts +220 -0
- package/src/palettes/nord.ts +59 -0
- package/src/palettes/one-dark.ts +62 -0
- package/src/palettes/registry.ts +92 -0
- package/src/palettes/rose-pine.ts +76 -0
- package/src/palettes/solarized.ts +69 -0
- package/src/palettes/tokyo-night.ts +78 -0
- package/src/palettes/types.ts +67 -0
- package/src/sequence/parser.ts +531 -0
- package/src/sequence/participant-inference.ts +178 -0
- package/src/sequence/renderer.ts +1487 -0
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
|
+
}
|