@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.
Files changed (62) hide show
  1. package/AGENTS.md +15 -20
  2. package/README.md +56 -58
  3. package/dist/cli.cjs +188 -181
  4. package/dist/index.cjs +3529 -1061
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +196 -43
  7. package/dist/index.d.ts +196 -43
  8. package/dist/index.js +3516 -1061
  9. package/dist/index.js.map +1 -1
  10. package/docs/language-reference.md +629 -289
  11. package/package.json +1 -1
  12. package/src/c4/layout.ts +6 -9
  13. package/src/c4/parser.ts +189 -83
  14. package/src/c4/renderer.ts +8 -9
  15. package/src/chart.ts +296 -83
  16. package/src/class/parser.ts +54 -37
  17. package/src/class/renderer.ts +8 -8
  18. package/src/cli.ts +8 -8
  19. package/src/colors.ts +4 -1
  20. package/src/completion.ts +757 -10
  21. package/src/d3.ts +312 -73
  22. package/src/dgmo-router.ts +63 -8
  23. package/src/echarts.ts +726 -231
  24. package/src/er/parser.ts +94 -76
  25. package/src/er/renderer.ts +6 -5
  26. package/src/gantt/parser.ts +144 -69
  27. package/src/gantt/renderer.ts +50 -14
  28. package/src/gantt/types.ts +3 -3
  29. package/src/graph/flowchart-parser.ts +97 -37
  30. package/src/graph/flowchart-renderer.ts +4 -3
  31. package/src/graph/state-parser.ts +50 -31
  32. package/src/graph/state-renderer.ts +4 -3
  33. package/src/index.ts +14 -5
  34. package/src/infra/compute.ts +1 -0
  35. package/src/infra/layout.ts +3 -0
  36. package/src/infra/parser.ts +291 -92
  37. package/src/infra/renderer.ts +172 -30
  38. package/src/infra/types.ts +5 -0
  39. package/src/initiative-status/layout.ts +1 -1
  40. package/src/initiative-status/parser.ts +121 -47
  41. package/src/initiative-status/renderer.ts +82 -31
  42. package/src/initiative-status/types.ts +10 -2
  43. package/src/kanban/parser.ts +60 -37
  44. package/src/kanban/renderer.ts +2 -2
  45. package/src/kanban/types.ts +1 -0
  46. package/src/org/layout.ts +9 -9
  47. package/src/org/parser.ts +39 -40
  48. package/src/org/renderer.ts +5 -6
  49. package/src/org/resolver.ts +26 -19
  50. package/src/render.ts +1 -1
  51. package/src/sequence/parser.ts +304 -95
  52. package/src/sequence/renderer.ts +9 -9
  53. package/src/sitemap/layout.ts +3 -4
  54. package/src/sitemap/parser.ts +57 -49
  55. package/src/sitemap/renderer.ts +6 -7
  56. package/src/utils/arrows.ts +25 -6
  57. package/src/utils/duration.ts +43 -7
  58. package/src/utils/legend-constants.ts +26 -0
  59. package/src/utils/legend-svg.ts +167 -0
  60. package/src/utils/parsing.ts +247 -7
  61. package/src/utils/tag-groups.ts +160 -15
  62. 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
- * chart: bar
114
- * title: My Chart
115
- * series: Revenue
162
+ * scatter My Chart
163
+ * xlabel Weight
116
164
  *
117
- * Jan: 120
118
- * Feb: 200
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
- // [Category] container header with optional color: [Category Name] or [Category Name](color)
158
- const categoryMatch = trimmed.match(/^\[(.+?)\](?:\s*\(([^)]+)\))?\s*$/);
159
- if (categoryMatch) {
160
- const catName = categoryMatch[1].trim();
161
- const catColor = categoryMatch[2] ? resolveColor(categoryMatch[2].trim(), palette) : null;
162
- if (catColor) {
163
- if (!result.categoryColors) result.categoryColors = {};
164
- result.categoryColors[catName] = catColor;
165
- }
166
- currentCategory = catName;
167
- continue;
168
- }
169
-
170
- // Parse key: value pairs
171
- const colonIndex = trimmed.indexOf(':');
172
-
173
- // Sankey: bare label (no colon) at any indent = source node for indented children
174
- if (result.type === 'sankey' && colonIndex === -1) {
175
- const indent = measureIndent(lines[i]);
176
- while (sankeyStack.length && sankeyStack.at(-1)!.indent >= indent) {
177
- sankeyStack.pop();
178
- }
179
- const { label: nodeName, color: nodeColor } = extractColor(trimmed, palette);
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
- sankeyStack.push({ name: nodeName, indent });
185
- continue;
186
- }
187
-
188
- if (colonIndex === -1) continue;
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
- continue;
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
- // Check for x range: "x: min to max"
281
- if (key === 'x') {
282
- const rangeMatch = value.match(/^(-?[\d.]+)\s+to\s+(-?[\d.]+)$/);
283
- if (rangeMatch) {
284
- result.xRange = {
285
- min: parseFloat(rangeMatch[1]),
286
- max: parseFloat(rangeMatch[2]),
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
- // Check for Sankey arrow syntax: Source (color) -> Target (color): Value (color)
293
- const arrowMatch = trimmed.match(/^(.+?)\s*->\s*(.+?):\s*(\d+(?:\.\d+)?)\s*(?:\(([^)]+)\))?\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: indented "Target: Value" under a source node on the indent stack
316
- if (result.type === 'sankey' && sankeyStack.length > 0) {
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
- if (indent > 0) {
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
- const source = sankeyStack.at(-1)!.name;
325
- const { label: target, color: targetColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
326
- if (targetColor) {
327
- if (!result.nodeColors) result.nodeColors = {};
328
- result.nodeColors[target] = targetColor;
329
- }
330
- // Parse value with optional trailing (color) for link color
331
- const valColorMatch = value.match(/^(\d+(?:\.\d+)?)\s*(?:\(([^)]+)\))?\s*$/);
332
- const val = valColorMatch ? parseFloat(valColorMatch[1]) : NaN;
333
- const linkColor = valColorMatch?.[2] ? resolveColor(valColorMatch[2].trim(), palette) : undefined;
334
- if (!isNaN(val)) {
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: val, ...(linkColor && { color: linkColor }), lineNumber });
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
- // For function charts, treat non-numeric values as function expressions
346
- if (result.type === 'function') {
347
- const { label: fnName, color: fnColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
348
- if (!result.functions) result.functions = [];
349
- result.functions.push({
350
- name: fnName,
351
- expression: value,
352
- ...(fnColor && { color: fnColor }),
353
- lineNumber,
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
- // For scatter charts, parse "Name: x, y" or "Name: x, y, size"
359
- if (result.type === 'scatter') {
360
- const scatterMatch = value.match(
361
- /^(-?[\d.]+)\s*,\s*(-?[\d.]+)(?:\s*,\s*(-?[\d.]+))?$/
362
- );
363
- if (scatterMatch) {
364
- const { label: scatterName, color: scatterColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
365
- if (!result.scatterPoints) result.scatterPoints = [];
366
- result.scatterPoints.push({
367
- name: scatterName,
368
- x: parseFloat(scatterMatch[1]),
369
- y: parseFloat(scatterMatch[2]),
370
- size: scatterMatch[3] ? parseFloat(scatterMatch[3]) : undefined,
371
- ...(scatterColor && { color: scatterColor }),
372
- ...(currentCategory !== 'Default' && { category: currentCategory }),
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
- // For heatmap, parse "RowLabel: val1, val2, val3, ..."
475
+ // Heatmap data row: "RowLabel val1, val2, val3, ..."
380
476
  if (result.type === 'heatmap') {
381
- const values = value.split(',').map((v) => parseFloat(v.trim()));
382
- if (values.length > 0 && values.every((v) => !isNaN(v))) {
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: originalKey, values, lineNumber });
480
+ result.heatmapRows.push({ label: dataRow.label, values: dataRow.values, lineNumber });
481
+ continue;
386
482
  }
387
- continue;
388
483
  }
389
484
 
390
- // Otherwise treat as data point (label: value)
391
- const numValue = parseFloat(value);
392
- if (!isNaN(numValue)) {
393
- const { label: rawLabel, color: pointColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
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: numValue,
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
- legend: {
685
- data: nodeNames,
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: (parsed.links ?? []).map((link) => ({
711
- source: link.source,
712
- target: link.target,
713
- value: link.value,
714
- lineStyle: {
715
- width: Math.max(1, Math.min(link.value / 20, 10)),
716
- color: colors[nodeNames.indexOf(link.source) % colors.length],
717
- curveness: 0.3,
718
- opacity: 0.6,
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: parsed.showLabels ?? false,
1295
+ show: false,
902
1296
  formatter: '{b}',
903
1297
  position: 'top' as const,
904
1298
  color: textColor,
905
- fontSize: 11,
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: parsed.ylabel ? '12%' : '3%',
1018
- right: '4%',
1019
- bottom: hasCategories ? '15%' : parsed.xlabel ? '10%' : '3%',
1020
- top: parsed.title ? '15%' : '5%',
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: Math.floor(xMin - xPad),
1033
- max: Math.ceil(xMax + xPad),
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: Math.floor(yMin - yPad),
1058
- max: Math.ceil(yMax + yPad),
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 <= 6) return 0; // show all
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 >= 3,
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: EMPHASIS_SELF,
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: EMPHASIS_SELF,
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: EMPHASIS_SELF,
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
- const chartLine = content.match(/^chart\s*:\s*(.+)/im);
1993
- const chartType = chartLine?.[1]?.trim().toLowerCase();
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
- if (chartType && STANDARD_CHART_TYPES.has(chartType)) {
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);