@diagrammo/dgmo 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -20
- package/README.md +56 -58
- package/dist/cli.cjs +188 -181
- package/dist/index.cjs +3529 -1061
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +196 -43
- package/dist/index.d.ts +196 -43
- package/dist/index.js +3516 -1061
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +629 -289
- package/package.json +1 -1
- package/src/c4/layout.ts +6 -9
- package/src/c4/parser.ts +189 -83
- package/src/c4/renderer.ts +8 -9
- package/src/chart.ts +296 -83
- package/src/class/parser.ts +54 -37
- package/src/class/renderer.ts +8 -8
- package/src/cli.ts +8 -8
- package/src/colors.ts +4 -1
- package/src/completion.ts +757 -10
- package/src/d3.ts +312 -73
- package/src/dgmo-router.ts +63 -8
- package/src/echarts.ts +726 -231
- package/src/er/parser.ts +94 -76
- package/src/er/renderer.ts +6 -5
- package/src/gantt/parser.ts +144 -69
- package/src/gantt/renderer.ts +50 -14
- package/src/gantt/types.ts +3 -3
- package/src/graph/flowchart-parser.ts +97 -37
- package/src/graph/flowchart-renderer.ts +4 -3
- package/src/graph/state-parser.ts +50 -31
- package/src/graph/state-renderer.ts +4 -3
- package/src/index.ts +14 -5
- package/src/infra/compute.ts +1 -0
- package/src/infra/layout.ts +3 -0
- package/src/infra/parser.ts +291 -92
- package/src/infra/renderer.ts +172 -30
- package/src/infra/types.ts +5 -0
- package/src/initiative-status/layout.ts +1 -1
- package/src/initiative-status/parser.ts +121 -47
- package/src/initiative-status/renderer.ts +82 -31
- package/src/initiative-status/types.ts +10 -2
- package/src/kanban/parser.ts +60 -37
- package/src/kanban/renderer.ts +2 -2
- package/src/kanban/types.ts +1 -0
- package/src/org/layout.ts +9 -9
- package/src/org/parser.ts +39 -40
- package/src/org/renderer.ts +5 -6
- package/src/org/resolver.ts +26 -19
- package/src/render.ts +1 -1
- package/src/sequence/parser.ts +304 -95
- package/src/sequence/renderer.ts +9 -9
- package/src/sitemap/layout.ts +3 -4
- package/src/sitemap/parser.ts +57 -49
- package/src/sitemap/renderer.ts +6 -7
- package/src/utils/arrows.ts +25 -6
- package/src/utils/duration.ts +43 -7
- package/src/utils/legend-constants.ts +26 -0
- package/src/utils/legend-svg.ts +167 -0
- package/src/utils/parsing.ts +247 -7
- package/src/utils/tag-groups.ts +160 -15
- package/src/utils/title-constants.ts +9 -0
package/src/echarts.ts
CHANGED
|
@@ -2,6 +2,9 @@ import * as echarts from 'echarts';
|
|
|
2
2
|
import type { EChartsOption } from 'echarts';
|
|
3
3
|
import { FONT_FAMILY } from './fonts';
|
|
4
4
|
import { injectBranding } from './branding';
|
|
5
|
+
import { renderLegendSvg } from './utils/legend-svg';
|
|
6
|
+
import type { LegendGroupData } from './utils/legend-svg';
|
|
7
|
+
import { LEGEND_HEIGHT } from './utils/legend-constants';
|
|
5
8
|
|
|
6
9
|
// ============================================================
|
|
7
10
|
// Types
|
|
@@ -27,6 +30,7 @@ export interface ParsedSankeyLink {
|
|
|
27
30
|
target: string;
|
|
28
31
|
value: number;
|
|
29
32
|
color?: string;
|
|
33
|
+
directed?: boolean;
|
|
30
34
|
lineNumber: number;
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -60,7 +64,9 @@ export interface ParsedExtendedChart {
|
|
|
60
64
|
title?: string;
|
|
61
65
|
titleLineNumber?: number;
|
|
62
66
|
series?: string;
|
|
67
|
+
seriesLineNumber?: number;
|
|
63
68
|
seriesNames?: string[];
|
|
69
|
+
seriesNameLineNumbers?: number[];
|
|
64
70
|
seriesNameColors?: (string | undefined)[];
|
|
65
71
|
data: ExtendedChartDataPoint[];
|
|
66
72
|
links?: ParsedSankeyLink[];
|
|
@@ -71,10 +77,13 @@ export interface ParsedExtendedChart {
|
|
|
71
77
|
rows?: string[];
|
|
72
78
|
xRange?: { min: number; max: number };
|
|
73
79
|
xlabel?: string;
|
|
80
|
+
xlabelLineNumber?: number;
|
|
74
81
|
ylabel?: string;
|
|
82
|
+
ylabelLineNumber?: number;
|
|
75
83
|
sizelabel?: string;
|
|
76
84
|
showLabels?: boolean;
|
|
77
85
|
categoryColors?: Record<string, string>;
|
|
86
|
+
categoryLineNumbers?: Record<string, number>;
|
|
78
87
|
nodeColors?: Record<string, string>;
|
|
79
88
|
diagnostics: DgmoError[];
|
|
80
89
|
error: string | null;
|
|
@@ -91,13 +100,19 @@ import { parseChart } from './chart';
|
|
|
91
100
|
import type { ParsedChart, ChartEra } from './chart';
|
|
92
101
|
import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
|
|
93
102
|
import { resolveColor } from './colors';
|
|
94
|
-
import { collectIndentedValues, extractColor, measureIndent, parseSeriesNames } from './utils/parsing';
|
|
103
|
+
import { collectIndentedValues, extractColor, measureIndent, normalizeGroupedNumber, parseFirstLine, parseSeriesNames } from './utils/parsing';
|
|
104
|
+
import { parseDataRowValues } from './chart';
|
|
95
105
|
|
|
96
106
|
// ============================================================
|
|
97
107
|
// Shared Constants
|
|
98
108
|
// ============================================================
|
|
99
109
|
|
|
100
110
|
const EMPHASIS_SELF = { focus: 'self' as const, blurScope: 'global' as const };
|
|
111
|
+
const EMPHASIS_LINE = {
|
|
112
|
+
...EMPHASIS_SELF,
|
|
113
|
+
scale: 2.5,
|
|
114
|
+
itemStyle: { borderWidth: 2, borderColor: '#fff', shadowBlur: 8, shadowColor: 'rgba(0,0,0,0.4)' },
|
|
115
|
+
};
|
|
101
116
|
const CHART_BASE: Pick<EChartsOption, 'backgroundColor' | 'animation'> = { backgroundColor: 'transparent', animation: false };
|
|
102
117
|
const CHART_BORDER_WIDTH = 2;
|
|
103
118
|
|
|
@@ -105,18 +120,50 @@ const CHART_BORDER_WIDTH = 2;
|
|
|
105
120
|
// Parser
|
|
106
121
|
// ============================================================
|
|
107
122
|
|
|
123
|
+
const VALID_EXTENDED_TYPES = new Set<ExtendedChartType>([
|
|
124
|
+
'sankey', 'chord', 'function', 'scatter', 'heatmap', 'funnel',
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/** Known option keywords for the extended chart parser. */
|
|
128
|
+
const KNOWN_EXTENDED_OPTIONS = new Set([
|
|
129
|
+
'chart', 'title', 'series', 'xlabel', 'ylabel', 'sizelabel', 'labels',
|
|
130
|
+
'columns', 'rows', 'x',
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse a scatter data row: "Name x, y[, size]" or "Name(color) x, y[, size]"
|
|
135
|
+
* Returns a ParsedScatterPoint or null if the line doesn't match.
|
|
136
|
+
*/
|
|
137
|
+
function parseScatterRow(
|
|
138
|
+
line: string,
|
|
139
|
+
palette: PaletteColors | undefined,
|
|
140
|
+
currentCategory: string,
|
|
141
|
+
lineNumber: number,
|
|
142
|
+
): ParsedScatterPoint | null {
|
|
143
|
+
const dataRow = parseDataRowValues(line);
|
|
144
|
+
if (!dataRow || dataRow.values.length < 2) return null;
|
|
145
|
+
const { label: rawLabel, color: pointColor } = extractColor(dataRow.label, palette);
|
|
146
|
+
return {
|
|
147
|
+
name: rawLabel,
|
|
148
|
+
x: dataRow.values[0],
|
|
149
|
+
y: dataRow.values[1],
|
|
150
|
+
size: dataRow.values[2] !== undefined ? dataRow.values[2] : undefined,
|
|
151
|
+
...(pointColor && { color: pointColor }),
|
|
152
|
+
...(currentCategory !== 'Default' && { category: currentCategory }),
|
|
153
|
+
lineNumber,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
108
157
|
/**
|
|
109
158
|
* Parses extended chart content into a structured object.
|
|
110
159
|
*
|
|
111
|
-
* Format:
|
|
160
|
+
* Format (colon-free):
|
|
112
161
|
* ```
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* series: Revenue
|
|
162
|
+
* scatter My Chart
|
|
163
|
+
* xlabel Weight
|
|
116
164
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* Mar: 150
|
|
165
|
+
* Alice 165, 60
|
|
166
|
+
* Bob 180, 85
|
|
120
167
|
* ```
|
|
121
168
|
*/
|
|
122
169
|
export function parseExtendedChart(
|
|
@@ -136,6 +183,7 @@ export function parseExtendedChart(
|
|
|
136
183
|
|
|
137
184
|
// Sankey indentation state: stack of source nodes by indent level
|
|
138
185
|
const sankeyStack: { name: string; indent: number }[] = [];
|
|
186
|
+
let firstLineParsed = false;
|
|
139
187
|
|
|
140
188
|
for (let i = 0; i < lines.length; i++) {
|
|
141
189
|
const trimmed = lines[i].trim();
|
|
@@ -154,145 +202,64 @@ export function parseExtendedChart(
|
|
|
154
202
|
// Skip comments
|
|
155
203
|
if (trimmed.startsWith('//')) continue;
|
|
156
204
|
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (nodeColor) {
|
|
181
|
-
if (!result.nodeColors) result.nodeColors = {};
|
|
182
|
-
result.nodeColors[nodeName] = nodeColor;
|
|
205
|
+
// First non-empty, non-comment line: chart type + optional title
|
|
206
|
+
if (!firstLineParsed) {
|
|
207
|
+
firstLineParsed = true;
|
|
208
|
+
const firstLine = parseFirstLine(trimmed);
|
|
209
|
+
if (firstLine) {
|
|
210
|
+
const chartType = firstLine.chartType.toLowerCase() as ExtendedChartType;
|
|
211
|
+
if (VALID_EXTENDED_TYPES.has(chartType)) {
|
|
212
|
+
result.type = chartType;
|
|
213
|
+
if (firstLine.title) {
|
|
214
|
+
result.title = firstLine.title;
|
|
215
|
+
result.titleLineNumber = lineNumber;
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
} else {
|
|
219
|
+
const validTypes = [...VALID_EXTENDED_TYPES];
|
|
220
|
+
let msg = `Unsupported chart type: ${firstLine.chartType}. Supported types: ${validTypes.join(', ')}.`;
|
|
221
|
+
const hint = suggest(chartType, validTypes);
|
|
222
|
+
if (hint) msg += ` ${hint}`;
|
|
223
|
+
const diag = makeDgmoError(lineNumber, msg);
|
|
224
|
+
result.diagnostics.push(diag);
|
|
225
|
+
result.error = formatDgmoError(diag);
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
183
228
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const key = trimmed.substring(0, colonIndex).trim().toLowerCase();
|
|
191
|
-
const value = trimmed.substring(colonIndex + 1).trim();
|
|
192
|
-
|
|
193
|
-
// Handle metadata
|
|
194
|
-
if (key === 'chart') {
|
|
195
|
-
const chartType = value.toLowerCase();
|
|
196
|
-
if (
|
|
197
|
-
chartType === 'sankey' ||
|
|
198
|
-
chartType === 'chord' ||
|
|
199
|
-
chartType === 'function' ||
|
|
200
|
-
chartType === 'scatter' ||
|
|
201
|
-
chartType === 'heatmap' ||
|
|
202
|
-
chartType === 'funnel'
|
|
203
|
-
) {
|
|
204
|
-
result.type = chartType;
|
|
205
|
-
} else {
|
|
206
|
-
const validTypes = ['scatter', 'sankey', 'chord', 'function', 'heatmap', 'funnel'];
|
|
207
|
-
let msg = `Unsupported chart type: ${value}. Supported types: ${validTypes.join(', ')}.`;
|
|
208
|
-
const hint = suggest(chartType, validTypes);
|
|
229
|
+
// If the first line is a single word (no spaces, no colon, no numbers),
|
|
230
|
+
// treat it as an unrecognized chart type rather than falling through
|
|
231
|
+
if (!trimmed.includes(' ') && !trimmed.includes(':') && !/\d/.test(trimmed)) {
|
|
232
|
+
const validTypes = [...VALID_EXTENDED_TYPES];
|
|
233
|
+
let msg = `Unsupported chart type: ${trimmed}. Supported types: ${validTypes.join(', ')}.`;
|
|
234
|
+
const hint = suggest(trimmed.toLowerCase(), validTypes);
|
|
209
235
|
if (hint) msg += ` ${hint}`;
|
|
210
236
|
const diag = makeDgmoError(lineNumber, msg);
|
|
211
237
|
result.diagnostics.push(diag);
|
|
212
238
|
result.error = formatDgmoError(diag);
|
|
213
239
|
return result;
|
|
214
240
|
}
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (key === 'title') {
|
|
219
|
-
result.title = value;
|
|
220
|
-
result.titleLineNumber = lineNumber;
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (key === 'series') {
|
|
225
|
-
const parsed = parseSeriesNames(value, lines, i, palette);
|
|
226
|
-
i = parsed.newIndex;
|
|
227
|
-
result.series = parsed.series;
|
|
228
|
-
if (parsed.names.length > 1) {
|
|
229
|
-
result.seriesNames = parsed.names;
|
|
230
|
-
}
|
|
231
|
-
if (parsed.nameColors.some(Boolean)) result.seriesNameColors = parsed.nameColors;
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Axis labels
|
|
236
|
-
if (key === 'xlabel') {
|
|
237
|
-
result.xlabel = value;
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (key === 'ylabel') {
|
|
242
|
-
result.ylabel = value;
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (key === 'sizelabel') {
|
|
247
|
-
result.sizelabel = value;
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
if (key === 'labels') {
|
|
252
|
-
result.showLabels =
|
|
253
|
-
value.toLowerCase() === 'on' || value.toLowerCase() === 'true';
|
|
254
|
-
continue;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Heatmap columns and rows headers
|
|
258
|
-
if (key === 'columns') {
|
|
259
|
-
if (value) {
|
|
260
|
-
result.columns = value.split(',').map((s) => s.trim());
|
|
261
|
-
} else {
|
|
262
|
-
const collected = collectIndentedValues(lines, i);
|
|
263
|
-
i = collected.newIndex;
|
|
264
|
-
result.columns = collected.values;
|
|
265
|
-
}
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (key === 'rows') {
|
|
270
|
-
if (value) {
|
|
271
|
-
result.rows = value.split(',').map((s) => s.trim());
|
|
272
|
-
} else {
|
|
273
|
-
const collected = collectIndentedValues(lines, i);
|
|
274
|
-
i = collected.newIndex;
|
|
275
|
-
result.rows = collected.values;
|
|
276
|
-
}
|
|
277
|
-
continue;
|
|
241
|
+
// Fall through — first line might be a data row or option
|
|
278
242
|
}
|
|
279
243
|
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
244
|
+
// [Category] container header with optional color: [Category Name] or [Category Name](color)
|
|
245
|
+
const categoryMatch = trimmed.match(/^\[(.+?)\](?:\s*\(([^)]+)\))?\s*$/);
|
|
246
|
+
if (categoryMatch) {
|
|
247
|
+
const catName = categoryMatch[1].trim();
|
|
248
|
+
const catColor = categoryMatch[2] ? resolveColor(categoryMatch[2].trim(), palette) : null;
|
|
249
|
+
if (catColor) {
|
|
250
|
+
if (!result.categoryColors) result.categoryColors = {};
|
|
251
|
+
result.categoryColors[catName] = catColor;
|
|
288
252
|
}
|
|
253
|
+
if (!result.categoryLineNumbers) result.categoryLineNumbers = {};
|
|
254
|
+
result.categoryLineNumbers[catName] = lineNumber;
|
|
255
|
+
currentCategory = catName;
|
|
289
256
|
continue;
|
|
290
257
|
}
|
|
291
258
|
|
|
292
|
-
//
|
|
293
|
-
const arrowMatch = trimmed.match(/^(.+?)\s
|
|
259
|
+
// Sankey/chord link syntax: Source -> Target Value (directed) or Source -- Target Value (undirected)
|
|
260
|
+
const arrowMatch = trimmed.match(/^(.+?)\s*(->|--)\s*(.+?)\s+(\d+(?:\.\d+)?)\s*(?:\(([^)]+)\))?\s*$/);
|
|
294
261
|
if (arrowMatch) {
|
|
295
|
-
const [, rawSource, rawTarget, val, rawLinkColor] = arrowMatch;
|
|
262
|
+
const [, rawSource, arrow, rawTarget, val, rawLinkColor] = arrowMatch;
|
|
296
263
|
const { label: source, color: sourceColor } = extractColor(rawSource.trim(), palette);
|
|
297
264
|
const { label: target, color: targetColor } = extractColor(rawTarget.trim(), palette);
|
|
298
265
|
if (sourceColor || targetColor) {
|
|
@@ -307,93 +274,221 @@ export function parseExtendedChart(
|
|
|
307
274
|
target,
|
|
308
275
|
value: parseFloat(val),
|
|
309
276
|
...(linkColor && { color: linkColor }),
|
|
277
|
+
directed: arrow === '->',
|
|
310
278
|
lineNumber,
|
|
311
279
|
});
|
|
312
280
|
continue;
|
|
313
281
|
}
|
|
314
282
|
|
|
315
|
-
// Sankey:
|
|
316
|
-
if (result.type === 'sankey'
|
|
283
|
+
// Sankey: bare label (no numeric value) at any indent = source node for indented children
|
|
284
|
+
if (result.type === 'sankey') {
|
|
317
285
|
const indent = measureIndent(lines[i]);
|
|
318
|
-
|
|
286
|
+
// Sankey indented child: " Target value (color)" under a source on the stack
|
|
287
|
+
if (indent > 0 && sankeyStack.length > 0) {
|
|
319
288
|
// Pop entries at same or deeper indent to find the parent
|
|
320
289
|
while (sankeyStack.length && sankeyStack.at(-1)!.indent >= indent) {
|
|
321
290
|
sankeyStack.pop();
|
|
322
291
|
}
|
|
323
292
|
if (sankeyStack.length > 0) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
293
|
+
// Parse "TargetName value (linkColor)" or "TargetName(nodeColor) value (linkColor)"
|
|
294
|
+
// Strip trailing (color) annotation before parseDataRowValues — it can't handle it
|
|
295
|
+
const valColorMatch = trimmed.match(/(\d+(?:\.\d+)?)\s*\(([^)]+)\)\s*$/);
|
|
296
|
+
const strippedLine = valColorMatch ? trimmed.replace(/\s*\([^)]+\)\s*$/, '') : trimmed;
|
|
297
|
+
const dataRow = parseDataRowValues(strippedLine);
|
|
298
|
+
if (dataRow && dataRow.values.length === 1) {
|
|
299
|
+
const source = sankeyStack.at(-1)!.name;
|
|
300
|
+
const linkColor = valColorMatch?.[2] ? resolveColor(valColorMatch[2].trim(), palette) : undefined;
|
|
301
|
+
const { label: target, color: targetColor } = extractColor(dataRow.label, palette);
|
|
302
|
+
if (targetColor) {
|
|
303
|
+
if (!result.nodeColors) result.nodeColors = {};
|
|
304
|
+
result.nodeColors[target] = targetColor;
|
|
305
|
+
}
|
|
335
306
|
if (!result.links) result.links = [];
|
|
336
|
-
result.links.push({ source, target, value:
|
|
337
|
-
// Push target as potential source for deeper nesting
|
|
307
|
+
result.links.push({ source, target, value: dataRow.values[0], ...(linkColor && { color: linkColor }), lineNumber });
|
|
338
308
|
sankeyStack.push({ name: target, indent });
|
|
339
309
|
continue;
|
|
340
310
|
}
|
|
341
311
|
}
|
|
342
312
|
}
|
|
313
|
+
|
|
314
|
+
// Bare label at indent 0 (or any indent without a value) = new source node
|
|
315
|
+
const spaceIdx = trimmed.indexOf(' ');
|
|
316
|
+
const hasNumericSuffix = spaceIdx >= 0 && !isNaN(parseFloat(trimmed.substring(trimmed.lastIndexOf(' ') + 1)));
|
|
317
|
+
if (!hasNumericSuffix) {
|
|
318
|
+
while (sankeyStack.length && sankeyStack.at(-1)!.indent >= indent) {
|
|
319
|
+
sankeyStack.pop();
|
|
320
|
+
}
|
|
321
|
+
const { label: nodeName, color: nodeColor } = extractColor(trimmed, palette);
|
|
322
|
+
if (nodeColor) {
|
|
323
|
+
if (!result.nodeColors) result.nodeColors = {};
|
|
324
|
+
result.nodeColors[nodeName] = nodeColor;
|
|
325
|
+
}
|
|
326
|
+
sankeyStack.push({ name: nodeName, indent });
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
343
329
|
}
|
|
344
330
|
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
331
|
+
// Extract first token to check for known options
|
|
332
|
+
const spaceIdx = trimmed.indexOf(' ');
|
|
333
|
+
const firstToken = (spaceIdx >= 0 ? trimmed.substring(0, spaceIdx) : trimmed).toLowerCase();
|
|
334
|
+
|
|
335
|
+
// Known option with a value
|
|
336
|
+
if (KNOWN_EXTENDED_OPTIONS.has(firstToken) && spaceIdx >= 0) {
|
|
337
|
+
const value = trimmed.substring(spaceIdx + 1).trim();
|
|
338
|
+
|
|
339
|
+
if (firstToken === 'chart') {
|
|
340
|
+
const chartType = value.toLowerCase() as ExtendedChartType;
|
|
341
|
+
if (VALID_EXTENDED_TYPES.has(chartType)) {
|
|
342
|
+
result.type = chartType;
|
|
343
|
+
} else {
|
|
344
|
+
const validTypes = [...VALID_EXTENDED_TYPES];
|
|
345
|
+
let msg = `Unsupported chart type: ${value}. Supported types: ${validTypes.join(', ')}.`;
|
|
346
|
+
const hint = suggest(chartType, validTypes);
|
|
347
|
+
if (hint) msg += ` ${hint}`;
|
|
348
|
+
const diag = makeDgmoError(lineNumber, msg);
|
|
349
|
+
result.diagnostics.push(diag);
|
|
350
|
+
result.error = formatDgmoError(diag);
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (firstToken === 'title') {
|
|
357
|
+
result.title = value;
|
|
358
|
+
result.titleLineNumber = lineNumber;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (firstToken === 'series') {
|
|
363
|
+
const parsed = parseSeriesNames(value, lines, i, palette);
|
|
364
|
+
i = parsed.newIndex;
|
|
365
|
+
result.series = parsed.series;
|
|
366
|
+
result.seriesLineNumber = lineNumber;
|
|
367
|
+
if (parsed.names.length > 1) {
|
|
368
|
+
result.seriesNames = parsed.names;
|
|
369
|
+
result.seriesNameLineNumbers = parsed.nameLineNumbers;
|
|
370
|
+
}
|
|
371
|
+
if (parsed.nameColors.some(Boolean)) result.seriesNameColors = parsed.nameColors;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (firstToken === 'xlabel') { result.xlabel = value; result.xlabelLineNumber = lineNumber; continue; }
|
|
376
|
+
if (firstToken === 'ylabel') { result.ylabel = value; result.ylabelLineNumber = lineNumber; continue; }
|
|
377
|
+
if (firstToken === 'sizelabel') { result.sizelabel = value; continue; }
|
|
378
|
+
|
|
379
|
+
if (firstToken === 'labels') {
|
|
380
|
+
result.showLabels = value.toLowerCase() === 'on' || value.toLowerCase() === 'true';
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (firstToken === 'columns') {
|
|
385
|
+
if (value) {
|
|
386
|
+
result.columns = value.split(',').map((s) => s.trim());
|
|
387
|
+
} else {
|
|
388
|
+
const collected = collectIndentedValues(lines, i);
|
|
389
|
+
i = collected.newIndex;
|
|
390
|
+
result.columns = collected.values;
|
|
391
|
+
}
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (firstToken === 'rows') {
|
|
396
|
+
if (value) {
|
|
397
|
+
result.rows = value.split(',').map((s) => s.trim());
|
|
398
|
+
} else {
|
|
399
|
+
const collected = collectIndentedValues(lines, i);
|
|
400
|
+
i = collected.newIndex;
|
|
401
|
+
result.rows = collected.values;
|
|
402
|
+
}
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (firstToken === 'x') {
|
|
407
|
+
const rangeMatch = value.match(/^(-?[\d.]+)\s+to\s+(-?[\d.]+)$/);
|
|
408
|
+
if (rangeMatch) {
|
|
409
|
+
result.xRange = {
|
|
410
|
+
min: parseFloat(rangeMatch[1]),
|
|
411
|
+
max: parseFloat(rangeMatch[2]),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Bare keyword options (no value)
|
|
419
|
+
if (firstToken === 'series' && spaceIdx === -1) {
|
|
420
|
+
const parsed = parseSeriesNames('', lines, i, palette);
|
|
421
|
+
i = parsed.newIndex;
|
|
422
|
+
result.series = parsed.series;
|
|
423
|
+
result.seriesLineNumber = lineNumber;
|
|
424
|
+
if (parsed.names.length > 1) {
|
|
425
|
+
result.seriesNames = parsed.names;
|
|
426
|
+
result.seriesNameLineNumbers = parsed.nameLineNumbers;
|
|
427
|
+
}
|
|
428
|
+
if (parsed.nameColors.some(Boolean)) result.seriesNameColors = parsed.nameColors;
|
|
355
429
|
continue;
|
|
356
430
|
}
|
|
357
431
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
432
|
+
if (firstToken === 'columns' && spaceIdx === -1) {
|
|
433
|
+
const collected = collectIndentedValues(lines, i);
|
|
434
|
+
i = collected.newIndex;
|
|
435
|
+
result.columns = collected.values;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (firstToken === 'rows' && spaceIdx === -1) {
|
|
440
|
+
const collected = collectIndentedValues(lines, i);
|
|
441
|
+
i = collected.newIndex;
|
|
442
|
+
result.rows = collected.values;
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Function chart: "name expression" where name may contain parens like f(x)
|
|
447
|
+
// Must use colon to separate name from expression since both can contain spaces
|
|
448
|
+
if (result.type === 'function') {
|
|
449
|
+
const colonIndex = trimmed.indexOf(':');
|
|
450
|
+
if (colonIndex >= 0) {
|
|
451
|
+
const { label: fnName, color: fnColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
|
|
452
|
+
const fnValue = trimmed.substring(colonIndex + 1).trim();
|
|
453
|
+
if (!result.functions) result.functions = [];
|
|
454
|
+
result.functions.push({
|
|
455
|
+
name: fnName,
|
|
456
|
+
expression: fnValue,
|
|
457
|
+
...(fnColor && { color: fnColor }),
|
|
373
458
|
lineNumber,
|
|
374
459
|
});
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Scatter chart: "Name x, y" or "Name x, y, size"
|
|
465
|
+
if (result.type === 'scatter') {
|
|
466
|
+
// Parse from right: trailing comma-separated numbers are x, y [, size]
|
|
467
|
+
const scatterData = parseScatterRow(trimmed, palette, currentCategory, lineNumber);
|
|
468
|
+
if (scatterData) {
|
|
469
|
+
if (!result.scatterPoints) result.scatterPoints = [];
|
|
470
|
+
result.scatterPoints.push(scatterData);
|
|
471
|
+
continue;
|
|
375
472
|
}
|
|
376
|
-
continue;
|
|
377
473
|
}
|
|
378
474
|
|
|
379
|
-
//
|
|
475
|
+
// Heatmap data row: "RowLabel val1, val2, val3, ..."
|
|
380
476
|
if (result.type === 'heatmap') {
|
|
381
|
-
const
|
|
382
|
-
if (values.length > 0
|
|
383
|
-
const originalKey = trimmed.substring(0, colonIndex).trim();
|
|
477
|
+
const dataRow = parseDataRowValues(trimmed);
|
|
478
|
+
if (dataRow && dataRow.values.length > 0) {
|
|
384
479
|
if (!result.heatmapRows) result.heatmapRows = [];
|
|
385
|
-
result.heatmapRows.push({ label:
|
|
480
|
+
result.heatmapRows.push({ label: dataRow.label, values: dataRow.values, lineNumber });
|
|
481
|
+
continue;
|
|
386
482
|
}
|
|
387
|
-
continue;
|
|
388
483
|
}
|
|
389
484
|
|
|
390
|
-
//
|
|
391
|
-
const
|
|
392
|
-
if (
|
|
393
|
-
const { label: rawLabel, color: pointColor } = extractColor(
|
|
485
|
+
// Funnel / generic data point: "Label value"
|
|
486
|
+
const dataRow = parseDataRowValues(trimmed);
|
|
487
|
+
if (dataRow && dataRow.values.length === 1) {
|
|
488
|
+
const { label: rawLabel, color: pointColor } = extractColor(dataRow.label, palette);
|
|
394
489
|
result.data.push({
|
|
395
490
|
label: rawLabel,
|
|
396
|
-
value:
|
|
491
|
+
value: dataRow.values[0],
|
|
397
492
|
...(pointColor && { color: pointColor }),
|
|
398
493
|
lineNumber,
|
|
399
494
|
});
|
|
@@ -584,6 +679,8 @@ function buildSankeyOption(
|
|
|
584
679
|
return {
|
|
585
680
|
...CHART_BASE,
|
|
586
681
|
title: titleConfig,
|
|
682
|
+
xAxis: { show: false },
|
|
683
|
+
yAxis: { show: false },
|
|
587
684
|
tooltip: {
|
|
588
685
|
show: false,
|
|
589
686
|
...tooltipTheme,
|
|
@@ -681,13 +778,8 @@ function buildChordOption(
|
|
|
681
778
|
return '';
|
|
682
779
|
},
|
|
683
780
|
},
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
bottom: 10,
|
|
687
|
-
textStyle: {
|
|
688
|
-
color: textColor,
|
|
689
|
-
},
|
|
690
|
-
},
|
|
781
|
+
xAxis: { show: false },
|
|
782
|
+
yAxis: { show: false },
|
|
691
783
|
series: [
|
|
692
784
|
{
|
|
693
785
|
type: 'graph',
|
|
@@ -707,17 +799,35 @@ function buildChordOption(
|
|
|
707
799
|
color: textColor,
|
|
708
800
|
},
|
|
709
801
|
})),
|
|
710
|
-
links: (
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
802
|
+
links: (() => {
|
|
803
|
+
const allLinks = parsed.links ?? [];
|
|
804
|
+
// Detect opposing link pairs to offset curvatures
|
|
805
|
+
const pairKeys = new Set<string>();
|
|
806
|
+
for (const l of allLinks) {
|
|
807
|
+
const rev = allLinks.find((r) => r.source === l.target && r.target === l.source && r !== l);
|
|
808
|
+
if (rev) pairKeys.add(`${l.source}\0${l.target}`);
|
|
809
|
+
}
|
|
810
|
+
return allLinks.map((link) => {
|
|
811
|
+
const hasOpposite = pairKeys.has(`${link.source}\0${link.target}`);
|
|
812
|
+
// Offset curvature for opposing pairs: one curves more, the other less
|
|
813
|
+
const baseCurve = 0.3;
|
|
814
|
+
const curveness = hasOpposite
|
|
815
|
+
? (link.source < link.target ? baseCurve + 0.15 : baseCurve - 0.15)
|
|
816
|
+
: baseCurve;
|
|
817
|
+
return {
|
|
818
|
+
source: link.source,
|
|
819
|
+
target: link.target,
|
|
820
|
+
value: link.value,
|
|
821
|
+
...(link.directed && { symbol: ['none', 'arrow'], symbolSize: [0, 10] }),
|
|
822
|
+
lineStyle: {
|
|
823
|
+
width: Math.max(1, Math.min(link.value / 20, 10)),
|
|
824
|
+
color: colors[nodeNames.indexOf(link.source) % colors.length],
|
|
825
|
+
curveness,
|
|
826
|
+
opacity: 0.6,
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
});
|
|
830
|
+
})(),
|
|
721
831
|
roam: true,
|
|
722
832
|
label: {
|
|
723
833
|
position: 'right',
|
|
@@ -874,6 +984,286 @@ function buildFunctionOption(
|
|
|
874
984
|
};
|
|
875
985
|
}
|
|
876
986
|
|
|
987
|
+
/**
|
|
988
|
+
* Extracts legend group data from standard chart types (multi-line, bar-stacked).
|
|
989
|
+
* Returns empty array if chart has no multi-series legend.
|
|
990
|
+
*/
|
|
991
|
+
export function getSimpleChartLegendGroups(
|
|
992
|
+
parsed: ParsedChart,
|
|
993
|
+
colors: string[],
|
|
994
|
+
): LegendGroupData[] {
|
|
995
|
+
if (!parsed.seriesNames || parsed.seriesNames.length <= 1) return [];
|
|
996
|
+
return [{
|
|
997
|
+
name: 'Series',
|
|
998
|
+
entries: parsed.seriesNames.map((name, i) => ({
|
|
999
|
+
value: name,
|
|
1000
|
+
color: parsed.seriesNameColors?.[i] ?? colors[i % colors.length],
|
|
1001
|
+
})),
|
|
1002
|
+
}];
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Extracts legend group data from extended chart types.
|
|
1007
|
+
* Supports scatter (categories), chord (nodes), and function (series).
|
|
1008
|
+
*/
|
|
1009
|
+
export function getExtendedChartLegendGroups(
|
|
1010
|
+
parsed: ParsedExtendedChart,
|
|
1011
|
+
colors: string[],
|
|
1012
|
+
): LegendGroupData[] {
|
|
1013
|
+
if (parsed.type === 'scatter') {
|
|
1014
|
+
const points = parsed.scatterPoints ?? [];
|
|
1015
|
+
const categories = [...new Set(points.map((p) => p.category).filter(Boolean))] as string[];
|
|
1016
|
+
if (categories.length === 0) return [];
|
|
1017
|
+
return [{
|
|
1018
|
+
name: 'Group',
|
|
1019
|
+
entries: categories.map((cat, i) => ({
|
|
1020
|
+
value: cat,
|
|
1021
|
+
color: parsed.categoryColors?.[cat] ?? colors[i % colors.length],
|
|
1022
|
+
})),
|
|
1023
|
+
}];
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (parsed.type === 'function') {
|
|
1027
|
+
const fns = parsed.functions ?? [];
|
|
1028
|
+
if (fns.length === 0) return [];
|
|
1029
|
+
return [{
|
|
1030
|
+
name: 'Function',
|
|
1031
|
+
entries: fns.map((fn, i) => ({
|
|
1032
|
+
value: fn.name,
|
|
1033
|
+
color: fn.color ?? colors[i % colors.length],
|
|
1034
|
+
})),
|
|
1035
|
+
}];
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
return [];
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// ---------------------------------------------------------------------------
|
|
1042
|
+
// Scatter label collision avoidance — greedy placement algorithm
|
|
1043
|
+
// ---------------------------------------------------------------------------
|
|
1044
|
+
|
|
1045
|
+
interface LabelRect { x: number; y: number; w: number; h: number }
|
|
1046
|
+
interface PointCircle { cx: number; cy: number; r: number }
|
|
1047
|
+
|
|
1048
|
+
/** Axis-aligned bounding box overlap test. @internal exported for testing */
|
|
1049
|
+
export function rectsOverlap(a: LabelRect, b: LabelRect): boolean {
|
|
1050
|
+
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** Rect vs circle overlap using nearest-point-on-rect distance check. @internal exported for testing */
|
|
1054
|
+
export function rectCircleOverlap(rect: LabelRect, circle: PointCircle): boolean {
|
|
1055
|
+
const nearestX = Math.max(rect.x, Math.min(circle.cx, rect.x + rect.w));
|
|
1056
|
+
const nearestY = Math.max(rect.y, Math.min(circle.cy, rect.y + rect.h));
|
|
1057
|
+
const dx = nearestX - circle.cx;
|
|
1058
|
+
const dy = nearestY - circle.cy;
|
|
1059
|
+
return dx * dx + dy * dy < circle.r * circle.r;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
export interface ScatterLabelPoint {
|
|
1063
|
+
name: string;
|
|
1064
|
+
px: number;
|
|
1065
|
+
py: number;
|
|
1066
|
+
color: string;
|
|
1067
|
+
size?: number; // per-point symbol size (for bubble charts)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Greedy label placement for scatter charts.
|
|
1072
|
+
* Returns ECharts `graphic` elements (text + background rects + optional connector lines).
|
|
1073
|
+
* Pure function — no ECharts instance dependency.
|
|
1074
|
+
*
|
|
1075
|
+
* @param bg - chart background color, used for label background rects that mask connector lines
|
|
1076
|
+
*/
|
|
1077
|
+
export function computeScatterLabelGraphics(
|
|
1078
|
+
points: ScatterLabelPoint[],
|
|
1079
|
+
chartBounds: { top: number; bottom: number },
|
|
1080
|
+
fontSize: number,
|
|
1081
|
+
symbolSize: number,
|
|
1082
|
+
bg?: string
|
|
1083
|
+
): Record<string, unknown>[] {
|
|
1084
|
+
const labelHeight = fontSize + 4;
|
|
1085
|
+
const stepSize = labelHeight + 2;
|
|
1086
|
+
|
|
1087
|
+
// Build collision circles for ALL points (per-point size for bubble charts)
|
|
1088
|
+
const pointCircles: PointCircle[] = points.map((p) => ({
|
|
1089
|
+
cx: p.px,
|
|
1090
|
+
cy: p.py,
|
|
1091
|
+
r: (p.size ?? symbolSize) / 2,
|
|
1092
|
+
}));
|
|
1093
|
+
|
|
1094
|
+
const placedLabels: LabelRect[] = [];
|
|
1095
|
+
const elements: Record<string, unknown>[] = [];
|
|
1096
|
+
|
|
1097
|
+
for (let i = 0; i < points.length; i++) {
|
|
1098
|
+
const pt = points[i];
|
|
1099
|
+
const ptSize = pt.size ?? symbolSize;
|
|
1100
|
+
const minGap = ptSize / 2 + 4;
|
|
1101
|
+
const labelWidth = pt.name.length * fontSize * 0.6 + 8;
|
|
1102
|
+
const labelX = pt.px - labelWidth / 2; // centered horizontally
|
|
1103
|
+
|
|
1104
|
+
// Try both directions, pick whichever keeps the label closest to the point
|
|
1105
|
+
let bestLabelY = 0;
|
|
1106
|
+
let bestOffset = Infinity;
|
|
1107
|
+
let placed = false;
|
|
1108
|
+
|
|
1109
|
+
for (const dir of [-1, 1]) {
|
|
1110
|
+
for (let offset = minGap; ; offset += stepSize) {
|
|
1111
|
+
const labelY =
|
|
1112
|
+
dir === -1
|
|
1113
|
+
? pt.py - offset - labelHeight // above: label bottom edge is offset above point center
|
|
1114
|
+
: pt.py + offset; // below: label top edge is offset below point center
|
|
1115
|
+
|
|
1116
|
+
// Check chart bounds
|
|
1117
|
+
if (labelY < chartBounds.top || labelY + labelHeight > chartBounds.bottom) break;
|
|
1118
|
+
|
|
1119
|
+
const candidate: LabelRect = { x: labelX, y: labelY, w: labelWidth, h: labelHeight };
|
|
1120
|
+
|
|
1121
|
+
// Check collisions with all placed labels
|
|
1122
|
+
let collision = false;
|
|
1123
|
+
for (const pl of placedLabels) {
|
|
1124
|
+
if (rectsOverlap(candidate, pl)) {
|
|
1125
|
+
collision = true;
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Check collisions with all point circles
|
|
1131
|
+
if (!collision) {
|
|
1132
|
+
for (const circle of pointCircles) {
|
|
1133
|
+
if (rectCircleOverlap(candidate, circle)) {
|
|
1134
|
+
collision = true;
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (!collision) {
|
|
1141
|
+
// Found closest slot in this direction — keep if it beats the other
|
|
1142
|
+
if (offset < bestOffset) {
|
|
1143
|
+
bestOffset = offset;
|
|
1144
|
+
bestLabelY = labelY;
|
|
1145
|
+
}
|
|
1146
|
+
placed = true;
|
|
1147
|
+
break; // best for this direction found, try the other
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Fallback: try above first, then below — prefer whichever is within bounds
|
|
1153
|
+
if (!placed) {
|
|
1154
|
+
const aboveY = pt.py - minGap - labelHeight;
|
|
1155
|
+
const belowY = pt.py + minGap;
|
|
1156
|
+
if (aboveY >= chartBounds.top) {
|
|
1157
|
+
bestLabelY = aboveY;
|
|
1158
|
+
} else if (belowY + labelHeight <= chartBounds.bottom) {
|
|
1159
|
+
bestLabelY = belowY;
|
|
1160
|
+
} else {
|
|
1161
|
+
bestLabelY = aboveY; // last resort — may clip
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const labelRect: LabelRect = { x: labelX, y: bestLabelY, w: labelWidth, h: labelHeight };
|
|
1166
|
+
placedLabels.push(labelRect);
|
|
1167
|
+
|
|
1168
|
+
const textY = bestLabelY + labelHeight / 2;
|
|
1169
|
+
|
|
1170
|
+
// Connector line (z=1, rendered below labels)
|
|
1171
|
+
const isAbove = bestLabelY + labelHeight <= pt.py;
|
|
1172
|
+
const pointEdge = isAbove ? pt.py - ptSize / 2 : pt.py + ptSize / 2;
|
|
1173
|
+
const labelEdge = isAbove ? bestLabelY + labelHeight : bestLabelY;
|
|
1174
|
+
const gap = Math.abs(pointEdge - labelEdge);
|
|
1175
|
+
|
|
1176
|
+
if (gap > 4) {
|
|
1177
|
+
elements.push({
|
|
1178
|
+
type: 'line',
|
|
1179
|
+
id: `scatter-line-${i}`,
|
|
1180
|
+
z: 1,
|
|
1181
|
+
shape: {
|
|
1182
|
+
x1: pt.px,
|
|
1183
|
+
y1: pointEdge,
|
|
1184
|
+
x2: pt.px,
|
|
1185
|
+
y2: labelEdge,
|
|
1186
|
+
},
|
|
1187
|
+
style: {
|
|
1188
|
+
stroke: pt.color,
|
|
1189
|
+
lineWidth: 1,
|
|
1190
|
+
},
|
|
1191
|
+
silent: true,
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Background rect (z=2, masks connector lines behind label text)
|
|
1196
|
+
if (bg) {
|
|
1197
|
+
const bgPad = 2;
|
|
1198
|
+
elements.push({
|
|
1199
|
+
type: 'rect',
|
|
1200
|
+
id: `scatter-bg-${i}`,
|
|
1201
|
+
z: 2,
|
|
1202
|
+
shape: {
|
|
1203
|
+
x: labelX - bgPad,
|
|
1204
|
+
y: bestLabelY - bgPad,
|
|
1205
|
+
width: labelWidth + bgPad * 2,
|
|
1206
|
+
height: labelHeight + bgPad * 2,
|
|
1207
|
+
},
|
|
1208
|
+
style: { fill: bg },
|
|
1209
|
+
silent: true,
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Text element (z=3, rendered on top)
|
|
1214
|
+
elements.push({
|
|
1215
|
+
type: 'text',
|
|
1216
|
+
id: `scatter-label-${i}`,
|
|
1217
|
+
z: 3,
|
|
1218
|
+
x: pt.px,
|
|
1219
|
+
y: textY,
|
|
1220
|
+
style: {
|
|
1221
|
+
text: pt.name,
|
|
1222
|
+
fill: pt.color,
|
|
1223
|
+
fontSize,
|
|
1224
|
+
fontFamily: FONT_FAMILY,
|
|
1225
|
+
textAlign: 'center',
|
|
1226
|
+
textVerticalAlign: 'middle',
|
|
1227
|
+
},
|
|
1228
|
+
silent: true,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
return elements;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Convert data coordinates to pixel coordinates using linear interpolation.
|
|
1237
|
+
* For SSR path where chart instance is not available for convertToPixel.
|
|
1238
|
+
*/
|
|
1239
|
+
function dataToPixel(
|
|
1240
|
+
dataX: number,
|
|
1241
|
+
dataY: number,
|
|
1242
|
+
xMin: number,
|
|
1243
|
+
xMax: number,
|
|
1244
|
+
yMin: number,
|
|
1245
|
+
yMax: number,
|
|
1246
|
+
gridLeftPct: number,
|
|
1247
|
+
gridRightPct: number,
|
|
1248
|
+
gridTopPct: number,
|
|
1249
|
+
gridBottomPct: number,
|
|
1250
|
+
chartWidth: number,
|
|
1251
|
+
chartHeight: number
|
|
1252
|
+
): { px: number; py: number } {
|
|
1253
|
+
// containLabel: true shrinks the plot area — apply conservative 30px inset
|
|
1254
|
+
const inset = 30;
|
|
1255
|
+
const gridLeftPx = gridLeftPct * chartWidth / 100 + inset;
|
|
1256
|
+
const gridRightPx = chartWidth - gridRightPct * chartWidth / 100 - inset;
|
|
1257
|
+
const gridTopPx = gridTopPct * chartHeight / 100 + inset;
|
|
1258
|
+
const gridBottomPx = chartHeight - gridBottomPct * chartHeight / 100 - inset;
|
|
1259
|
+
const plotWidth = gridRightPx - gridLeftPx;
|
|
1260
|
+
const plotHeight = gridBottomPx - gridTopPx;
|
|
1261
|
+
|
|
1262
|
+
const px = gridLeftPx + ((dataX - xMin) / (xMax - xMin)) * plotWidth;
|
|
1263
|
+
const py = gridTopPx + ((yMax - dataY) / (yMax - yMin)) * plotHeight;
|
|
1264
|
+
return { px, py };
|
|
1265
|
+
}
|
|
1266
|
+
|
|
877
1267
|
/**
|
|
878
1268
|
* Builds ECharts option for scatter plots.
|
|
879
1269
|
* Auto-detects categories and size from point data:
|
|
@@ -897,12 +1287,16 @@ function buildScatterOption(
|
|
|
897
1287
|
const hasCategories = points.some((p) => p.category !== undefined);
|
|
898
1288
|
const hasSize = points.some((p) => p.size !== undefined);
|
|
899
1289
|
|
|
1290
|
+
const showLabels = parsed.showLabels ?? false;
|
|
1291
|
+
const labelFontSize = 11;
|
|
1292
|
+
|
|
1293
|
+
// When showLabels is on, we render labels ourselves via graphic — disable ECharts labels
|
|
900
1294
|
const labelConfig = {
|
|
901
|
-
show:
|
|
1295
|
+
show: false,
|
|
902
1296
|
formatter: '{b}',
|
|
903
1297
|
position: 'top' as const,
|
|
904
1298
|
color: textColor,
|
|
905
|
-
fontSize:
|
|
1299
|
+
fontSize: labelFontSize,
|
|
906
1300
|
};
|
|
907
1301
|
|
|
908
1302
|
const emphasisConfig = {
|
|
@@ -915,13 +1309,11 @@ function buildScatterOption(
|
|
|
915
1309
|
|
|
916
1310
|
// Build series based on whether categories are present
|
|
917
1311
|
let series;
|
|
918
|
-
let legendData: string[] | undefined;
|
|
919
1312
|
|
|
920
1313
|
if (hasCategories) {
|
|
921
1314
|
const categories = [
|
|
922
1315
|
...new Set(points.map((p) => p.category).filter(Boolean)),
|
|
923
1316
|
] as string[];
|
|
924
|
-
legendData = categories;
|
|
925
1317
|
|
|
926
1318
|
series = categories.map((category, catIndex) => {
|
|
927
1319
|
const categoryPoints = points.filter((p) => p.category === category);
|
|
@@ -1002,22 +1394,71 @@ function buildScatterOption(
|
|
|
1002
1394
|
const xPad = (xMax - xMin) * 0.1 || 1;
|
|
1003
1395
|
const yPad = (yMax - yMin) * 0.1 || 1;
|
|
1004
1396
|
|
|
1397
|
+
const axisXMin = Math.floor(xMin - xPad);
|
|
1398
|
+
const axisXMax = Math.ceil(xMax + xPad);
|
|
1399
|
+
const axisYMin = Math.floor(yMin - yPad);
|
|
1400
|
+
const axisYMax = Math.ceil(yMax + yPad);
|
|
1401
|
+
|
|
1402
|
+
const gridLeft = parsed.ylabel ? 12 : 3;
|
|
1403
|
+
const gridRight = 4;
|
|
1404
|
+
const gridBottom = parsed.xlabel ? 10 : 3;
|
|
1405
|
+
const gridTop = parsed.title ? 15 : 5;
|
|
1406
|
+
|
|
1407
|
+
// Compute custom label graphics for SSR when labels are enabled
|
|
1408
|
+
let graphic: Record<string, unknown>[] | undefined;
|
|
1409
|
+
if (showLabels && points.length > 0) {
|
|
1410
|
+
// Collect label points with resolved colors
|
|
1411
|
+
const labelPoints: ScatterLabelPoint[] = [];
|
|
1412
|
+
if (hasCategories) {
|
|
1413
|
+
const categories = [
|
|
1414
|
+
...new Set(points.map((p) => p.category).filter(Boolean)),
|
|
1415
|
+
] as string[];
|
|
1416
|
+
for (let idx = 0; idx < points.length; idx++) {
|
|
1417
|
+
const pt = points[idx];
|
|
1418
|
+
const catIndex = pt.category ? categories.indexOf(pt.category) : -1;
|
|
1419
|
+
const catColor = pt.category
|
|
1420
|
+
? (parsed.categoryColors?.[pt.category] ?? colors[catIndex % colors.length])
|
|
1421
|
+
: colors[idx % colors.length];
|
|
1422
|
+
const color = pt.color ?? catColor;
|
|
1423
|
+
const { px, py } = dataToPixel(
|
|
1424
|
+
pt.x, pt.y, axisXMin, axisXMax, axisYMin, axisYMax,
|
|
1425
|
+
gridLeft, gridRight, gridTop, gridBottom,
|
|
1426
|
+
ECHART_EXPORT_WIDTH, ECHART_EXPORT_HEIGHT
|
|
1427
|
+
);
|
|
1428
|
+
labelPoints.push({ name: pt.name, px, py, color, size: pt.size });
|
|
1429
|
+
}
|
|
1430
|
+
} else {
|
|
1431
|
+
points.forEach((pt, index) => {
|
|
1432
|
+
const color = pt.color ?? colors[index % colors.length];
|
|
1433
|
+
const { px, py } = dataToPixel(
|
|
1434
|
+
pt.x, pt.y, axisXMin, axisXMax, axisYMin, axisYMax,
|
|
1435
|
+
gridLeft, gridRight, gridTop, gridBottom,
|
|
1436
|
+
ECHART_EXPORT_WIDTH, ECHART_EXPORT_HEIGHT
|
|
1437
|
+
);
|
|
1438
|
+
labelPoints.push({ name: pt.name, px, py, color, size: pt.size });
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const chartBoundsTop = gridTop * ECHART_EXPORT_HEIGHT / 100;
|
|
1443
|
+
const chartBoundsBottom = ECHART_EXPORT_HEIGHT - gridBottom * ECHART_EXPORT_HEIGHT / 100;
|
|
1444
|
+
graphic = computeScatterLabelGraphics(
|
|
1445
|
+
labelPoints,
|
|
1446
|
+
{ top: chartBoundsTop, bottom: chartBoundsBottom },
|
|
1447
|
+
labelFontSize,
|
|
1448
|
+
defaultSize,
|
|
1449
|
+
bg
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1005
1453
|
return {
|
|
1006
1454
|
...CHART_BASE,
|
|
1007
1455
|
title: titleConfig,
|
|
1008
1456
|
tooltip,
|
|
1009
|
-
...(legendData && {
|
|
1010
|
-
legend: {
|
|
1011
|
-
data: legendData,
|
|
1012
|
-
bottom: 10,
|
|
1013
|
-
textStyle: { color: textColor },
|
|
1014
|
-
},
|
|
1015
|
-
}),
|
|
1016
1457
|
grid: {
|
|
1017
|
-
left:
|
|
1018
|
-
right:
|
|
1019
|
-
bottom:
|
|
1020
|
-
top:
|
|
1458
|
+
left: `${gridLeft}%`,
|
|
1459
|
+
right: `${gridRight}%`,
|
|
1460
|
+
bottom: `${gridBottom}%`,
|
|
1461
|
+
top: `${gridTop}%`,
|
|
1021
1462
|
containLabel: true,
|
|
1022
1463
|
},
|
|
1023
1464
|
xAxis: {
|
|
@@ -1029,8 +1470,8 @@ function buildScatterOption(
|
|
|
1029
1470
|
color: textColor,
|
|
1030
1471
|
fontSize: 18,
|
|
1031
1472
|
},
|
|
1032
|
-
min:
|
|
1033
|
-
max:
|
|
1473
|
+
min: axisXMin,
|
|
1474
|
+
max: axisXMax,
|
|
1034
1475
|
axisLine: {
|
|
1035
1476
|
lineStyle: { color: axisLineColor },
|
|
1036
1477
|
},
|
|
@@ -1054,8 +1495,8 @@ function buildScatterOption(
|
|
|
1054
1495
|
color: textColor,
|
|
1055
1496
|
fontSize: 18,
|
|
1056
1497
|
},
|
|
1057
|
-
min:
|
|
1058
|
-
max:
|
|
1498
|
+
min: axisYMin,
|
|
1499
|
+
max: axisYMax,
|
|
1059
1500
|
axisLine: {
|
|
1060
1501
|
lineStyle: { color: axisLineColor },
|
|
1061
1502
|
},
|
|
@@ -1071,6 +1512,7 @@ function buildScatterOption(
|
|
|
1071
1512
|
},
|
|
1072
1513
|
},
|
|
1073
1514
|
series,
|
|
1515
|
+
...(graphic && { graphic }),
|
|
1074
1516
|
};
|
|
1075
1517
|
}
|
|
1076
1518
|
|
|
@@ -1248,6 +1690,8 @@ function buildFunnelOption(
|
|
|
1248
1690
|
return {
|
|
1249
1691
|
...CHART_BASE,
|
|
1250
1692
|
title: titleConfig,
|
|
1693
|
+
xAxis: { show: false },
|
|
1694
|
+
yAxis: { show: false },
|
|
1251
1695
|
tooltip: {
|
|
1252
1696
|
trigger: 'item',
|
|
1253
1697
|
...tooltipTheme,
|
|
@@ -1521,7 +1965,7 @@ function buildBarOption(
|
|
|
1521
1965
|
// Targets ~5 visible labels — conservative enough to prevent ECharts stagger.
|
|
1522
1966
|
function buildIntervalStep(labels: string[]): number {
|
|
1523
1967
|
const count = labels.length;
|
|
1524
|
-
if (count <=
|
|
1968
|
+
if (count <= 12) return 0; // show all
|
|
1525
1969
|
const snapSteps = [1, 2, 5, 10, 25, 50, 100];
|
|
1526
1970
|
const raw = Math.ceil(count / 5); // target ~5 visible labels
|
|
1527
1971
|
const N = [...snapSteps].reverse().find((s) => s <= raw) ?? 1; // snap down
|
|
@@ -1549,7 +1993,7 @@ function buildMarkArea(
|
|
|
1549
1993
|
xAxis: era.start,
|
|
1550
1994
|
itemStyle: { color, opacity: 0.15 },
|
|
1551
1995
|
label: {
|
|
1552
|
-
show: bandSlots >=
|
|
1996
|
+
show: bandSlots >= 2,
|
|
1553
1997
|
position: 'insideTop',
|
|
1554
1998
|
fontSize: 11,
|
|
1555
1999
|
color: textColor,
|
|
@@ -1601,7 +2045,7 @@ function buildLineOption(
|
|
|
1601
2045
|
symbolSize: 8,
|
|
1602
2046
|
lineStyle: { color: lineColor, width: 3 },
|
|
1603
2047
|
itemStyle: { color: lineColor },
|
|
1604
|
-
emphasis:
|
|
2048
|
+
emphasis: EMPHASIS_LINE,
|
|
1605
2049
|
...(markArea && { markArea }),
|
|
1606
2050
|
},
|
|
1607
2051
|
],
|
|
@@ -1642,7 +2086,7 @@ function buildMultiLineOption(
|
|
|
1642
2086
|
symbolSize: 8,
|
|
1643
2087
|
lineStyle: { color, width: 3 },
|
|
1644
2088
|
itemStyle: { color },
|
|
1645
|
-
emphasis:
|
|
2089
|
+
emphasis: EMPHASIS_LINE,
|
|
1646
2090
|
...(idx === 0 && markArea && { markArea }),
|
|
1647
2091
|
};
|
|
1648
2092
|
});
|
|
@@ -1708,7 +2152,7 @@ function buildAreaOption(
|
|
|
1708
2152
|
lineStyle: { color: lineColor, width: 3 },
|
|
1709
2153
|
itemStyle: { color: lineColor },
|
|
1710
2154
|
areaStyle: { opacity: 0.25 },
|
|
1711
|
-
emphasis:
|
|
2155
|
+
emphasis: EMPHASIS_LINE,
|
|
1712
2156
|
...(markArea && { markArea }),
|
|
1713
2157
|
},
|
|
1714
2158
|
],
|
|
@@ -1737,6 +2181,7 @@ function buildPieOption(
|
|
|
1737
2181
|
tooltipTheme: Record<string, unknown>,
|
|
1738
2182
|
isDoughnut: boolean
|
|
1739
2183
|
): EChartsOption {
|
|
2184
|
+
const HIDE_AXES = { xAxis: { show: false }, yAxis: { show: false } };
|
|
1740
2185
|
const data = parsed.data.map((d, i) => {
|
|
1741
2186
|
const stroke = d.color ?? colors[i % colors.length];
|
|
1742
2187
|
return {
|
|
@@ -1748,6 +2193,7 @@ function buildPieOption(
|
|
|
1748
2193
|
|
|
1749
2194
|
return {
|
|
1750
2195
|
...CHART_BASE,
|
|
2196
|
+
...HIDE_AXES,
|
|
1751
2197
|
title: titleConfig,
|
|
1752
2198
|
tooltip: {
|
|
1753
2199
|
trigger: 'item',
|
|
@@ -1795,6 +2241,8 @@ function buildRadarOption(
|
|
|
1795
2241
|
return {
|
|
1796
2242
|
...CHART_BASE,
|
|
1797
2243
|
title: titleConfig,
|
|
2244
|
+
xAxis: { show: false },
|
|
2245
|
+
yAxis: { show: false },
|
|
1798
2246
|
tooltip: {
|
|
1799
2247
|
trigger: 'item',
|
|
1800
2248
|
...tooltipTheme,
|
|
@@ -1863,6 +2311,8 @@ function buildPolarAreaOption(
|
|
|
1863
2311
|
return {
|
|
1864
2312
|
...CHART_BASE,
|
|
1865
2313
|
title: titleConfig,
|
|
2314
|
+
xAxis: { show: false },
|
|
2315
|
+
yAxis: { show: false },
|
|
1866
2316
|
tooltip: {
|
|
1867
2317
|
trigger: 'item',
|
|
1868
2318
|
...tooltipTheme,
|
|
@@ -1989,21 +2439,41 @@ export async function renderExtendedChartForExport(
|
|
|
1989
2439
|
palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
|
|
1990
2440
|
|
|
1991
2441
|
// Detect chart type to dispatch to the right parser/builder
|
|
1992
|
-
|
|
1993
|
-
|
|
2442
|
+
// Find first non-empty, non-comment line and use parseFirstLine for new-style detection
|
|
2443
|
+
let chartType: string | undefined;
|
|
2444
|
+
for (const rawLine of content.split('\n')) {
|
|
2445
|
+
const t = rawLine.trim();
|
|
2446
|
+
if (!t || t.startsWith('//')) continue;
|
|
2447
|
+
const fl = parseFirstLine(t);
|
|
2448
|
+
if (fl) chartType = fl.chartType.toLowerCase();
|
|
2449
|
+
break;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
// No recognised chart type on the first line → nothing to render
|
|
2453
|
+
if (!chartType) return '';
|
|
1994
2454
|
|
|
1995
2455
|
let option: EChartsOption;
|
|
1996
|
-
|
|
2456
|
+
let legendGroups: LegendGroupData[] = [];
|
|
2457
|
+
const colors = getSeriesColors(effectivePalette);
|
|
2458
|
+
|
|
2459
|
+
if (STANDARD_CHART_TYPES.has(chartType)) {
|
|
1997
2460
|
const parsed = parseChart(content, effectivePalette);
|
|
1998
2461
|
if (parsed.error) return '';
|
|
1999
2462
|
option = buildSimpleChartOption(parsed, effectivePalette, isDark, ECHART_EXPORT_WIDTH);
|
|
2463
|
+
legendGroups = getSimpleChartLegendGroups(parsed, colors);
|
|
2000
2464
|
} else {
|
|
2001
2465
|
const parsed = parseExtendedChart(content, effectivePalette);
|
|
2002
2466
|
if (parsed.error) return '';
|
|
2003
2467
|
option = buildExtendedChartOption(parsed, effectivePalette, isDark);
|
|
2468
|
+
legendGroups = getExtendedChartLegendGroups(parsed, colors);
|
|
2004
2469
|
}
|
|
2005
2470
|
if (!option || Object.keys(option).length === 0) return '';
|
|
2006
2471
|
|
|
2472
|
+
// When using custom legend, strip ECharts' built-in legend
|
|
2473
|
+
if (legendGroups.length > 0) {
|
|
2474
|
+
option = { ...option, legend: undefined };
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2007
2477
|
const chart = echarts.init(null, null, {
|
|
2008
2478
|
renderer: 'svg',
|
|
2009
2479
|
ssr: true,
|
|
@@ -2024,6 +2494,31 @@ export async function renderExtendedChartForExport(
|
|
|
2024
2494
|
`<svg style="${bgStyle}font-family: ${FONT_FAMILY}" `
|
|
2025
2495
|
);
|
|
2026
2496
|
|
|
2497
|
+
// Inject custom legend SVG when present
|
|
2498
|
+
if (legendGroups.length > 0) {
|
|
2499
|
+
const titleHeight = option.title && (option.title as { text?: string }).text ? 40 : 0;
|
|
2500
|
+
const legendY = 8 + titleHeight;
|
|
2501
|
+
// In static export, expand the first group so entries are visible
|
|
2502
|
+
// Extract grid offsets for plot-area-centered legend
|
|
2503
|
+
const grid = option.grid as Record<string, unknown> | undefined;
|
|
2504
|
+
const gridLeftPct = grid?.left ? parseFloat(String(grid.left)) : undefined;
|
|
2505
|
+
const gridRightPct = grid?.right ? parseFloat(String(grid.right)) : undefined;
|
|
2506
|
+
const { svg: legendSvgStr } = renderLegendSvg(legendGroups, {
|
|
2507
|
+
palette: effectivePalette,
|
|
2508
|
+
isDark,
|
|
2509
|
+
containerWidth: ECHART_EXPORT_WIDTH,
|
|
2510
|
+
gridLeftPct,
|
|
2511
|
+
gridRightPct,
|
|
2512
|
+
activeGroup: legendGroups[0].name,
|
|
2513
|
+
className: 'chart-legend',
|
|
2514
|
+
});
|
|
2515
|
+
// Insert legend group right after the opening <svg ...> tag
|
|
2516
|
+
result = result.replace(
|
|
2517
|
+
/(<svg[^>]*>)/,
|
|
2518
|
+
`$1<g transform="translate(0,${legendY})">${legendSvgStr}</g>`,
|
|
2519
|
+
);
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2027
2522
|
if (options?.branding !== false) {
|
|
2028
2523
|
const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
|
|
2029
2524
|
result = injectBranding(result, brandColor);
|