@diagrammo/dgmo 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -20
- package/README.md +56 -58
- package/dist/cli.cjs +188 -181
- package/dist/index.cjs +3522 -1072
- 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 +3509 -1072
- 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 +324 -78
- package/src/dgmo-router.ts +63 -8
- package/src/echarts.ts +735 -241
- 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 +42 -23
- 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;
|
|
241
|
+
// Fall through — first line might be a data row or option
|
|
267
242
|
}
|
|
268
243
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
result.
|
|
276
|
-
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
|
|
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
|
-
//
|
|
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;
|
|
375
461
|
}
|
|
376
|
-
continue;
|
|
377
462
|
}
|
|
378
463
|
|
|
379
|
-
//
|
|
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;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
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,80 @@ 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 = hasCategories ? 15 : 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
|
+
|
|
1453
|
+
// Build legend for categorized scatter charts
|
|
1454
|
+
const categories = hasCategories
|
|
1455
|
+
? [...new Set(points.map((p) => p.category).filter(Boolean))] as string[]
|
|
1456
|
+
: [];
|
|
1457
|
+
const legendConfig = categories.length > 0
|
|
1458
|
+
? { data: categories, bottom: 10, textStyle: { color: textColor } }
|
|
1459
|
+
: undefined;
|
|
1460
|
+
|
|
1005
1461
|
return {
|
|
1006
1462
|
...CHART_BASE,
|
|
1007
1463
|
title: titleConfig,
|
|
1464
|
+
...(legendConfig && { legend: legendConfig }),
|
|
1008
1465
|
tooltip,
|
|
1009
|
-
...(legendData && {
|
|
1010
|
-
legend: {
|
|
1011
|
-
data: legendData,
|
|
1012
|
-
bottom: 10,
|
|
1013
|
-
textStyle: { color: textColor },
|
|
1014
|
-
},
|
|
1015
|
-
}),
|
|
1016
1466
|
grid: {
|
|
1017
|
-
left:
|
|
1018
|
-
right:
|
|
1019
|
-
bottom:
|
|
1020
|
-
top:
|
|
1467
|
+
left: `${gridLeft}%`,
|
|
1468
|
+
right: `${gridRight}%`,
|
|
1469
|
+
bottom: `${gridBottom}%`,
|
|
1470
|
+
top: `${gridTop}%`,
|
|
1021
1471
|
containLabel: true,
|
|
1022
1472
|
},
|
|
1023
1473
|
xAxis: {
|
|
@@ -1029,8 +1479,8 @@ function buildScatterOption(
|
|
|
1029
1479
|
color: textColor,
|
|
1030
1480
|
fontSize: 18,
|
|
1031
1481
|
},
|
|
1032
|
-
min:
|
|
1033
|
-
max:
|
|
1482
|
+
min: axisXMin,
|
|
1483
|
+
max: axisXMax,
|
|
1034
1484
|
axisLine: {
|
|
1035
1485
|
lineStyle: { color: axisLineColor },
|
|
1036
1486
|
},
|
|
@@ -1054,8 +1504,8 @@ function buildScatterOption(
|
|
|
1054
1504
|
color: textColor,
|
|
1055
1505
|
fontSize: 18,
|
|
1056
1506
|
},
|
|
1057
|
-
min:
|
|
1058
|
-
max:
|
|
1507
|
+
min: axisYMin,
|
|
1508
|
+
max: axisYMax,
|
|
1059
1509
|
axisLine: {
|
|
1060
1510
|
lineStyle: { color: axisLineColor },
|
|
1061
1511
|
},
|
|
@@ -1071,6 +1521,7 @@ function buildScatterOption(
|
|
|
1071
1521
|
},
|
|
1072
1522
|
},
|
|
1073
1523
|
series,
|
|
1524
|
+
...(graphic && { graphic }),
|
|
1074
1525
|
};
|
|
1075
1526
|
}
|
|
1076
1527
|
|
|
@@ -1248,6 +1699,8 @@ function buildFunnelOption(
|
|
|
1248
1699
|
return {
|
|
1249
1700
|
...CHART_BASE,
|
|
1250
1701
|
title: titleConfig,
|
|
1702
|
+
xAxis: { show: false },
|
|
1703
|
+
yAxis: { show: false },
|
|
1251
1704
|
tooltip: {
|
|
1252
1705
|
trigger: 'item',
|
|
1253
1706
|
...tooltipTheme,
|
|
@@ -1495,11 +1948,6 @@ function buildBarOption(
|
|
|
1495
1948
|
return {
|
|
1496
1949
|
...CHART_BASE,
|
|
1497
1950
|
title: titleConfig,
|
|
1498
|
-
tooltip: {
|
|
1499
|
-
trigger: 'axis',
|
|
1500
|
-
...tooltipTheme,
|
|
1501
|
-
axisPointer: { type: 'shadow' },
|
|
1502
|
-
},
|
|
1503
1951
|
grid: makeChartGrid({ xLabel, yLabel, hasTitle: !!parsed.title }),
|
|
1504
1952
|
xAxis: isHorizontal ? valueAxis : categoryAxis,
|
|
1505
1953
|
yAxis: isHorizontal ? categoryAxis : valueAxis,
|
|
@@ -1521,7 +1969,7 @@ function buildBarOption(
|
|
|
1521
1969
|
// Targets ~5 visible labels — conservative enough to prevent ECharts stagger.
|
|
1522
1970
|
function buildIntervalStep(labels: string[]): number {
|
|
1523
1971
|
const count = labels.length;
|
|
1524
|
-
if (count <=
|
|
1972
|
+
if (count <= 12) return 0; // show all
|
|
1525
1973
|
const snapSteps = [1, 2, 5, 10, 25, 50, 100];
|
|
1526
1974
|
const raw = Math.ceil(count / 5); // target ~5 visible labels
|
|
1527
1975
|
const N = [...snapSteps].reverse().find((s) => s <= raw) ?? 1; // snap down
|
|
@@ -1549,7 +1997,7 @@ function buildMarkArea(
|
|
|
1549
1997
|
xAxis: era.start,
|
|
1550
1998
|
itemStyle: { color, opacity: 0.15 },
|
|
1551
1999
|
label: {
|
|
1552
|
-
show: bandSlots >=
|
|
2000
|
+
show: bandSlots >= 2,
|
|
1553
2001
|
position: 'insideTop',
|
|
1554
2002
|
fontSize: 11,
|
|
1555
2003
|
color: textColor,
|
|
@@ -1601,7 +2049,7 @@ function buildLineOption(
|
|
|
1601
2049
|
symbolSize: 8,
|
|
1602
2050
|
lineStyle: { color: lineColor, width: 3 },
|
|
1603
2051
|
itemStyle: { color: lineColor },
|
|
1604
|
-
emphasis:
|
|
2052
|
+
emphasis: EMPHASIS_LINE,
|
|
1605
2053
|
...(markArea && { markArea }),
|
|
1606
2054
|
},
|
|
1607
2055
|
],
|
|
@@ -1642,7 +2090,7 @@ function buildMultiLineOption(
|
|
|
1642
2090
|
symbolSize: 8,
|
|
1643
2091
|
lineStyle: { color, width: 3 },
|
|
1644
2092
|
itemStyle: { color },
|
|
1645
|
-
emphasis:
|
|
2093
|
+
emphasis: EMPHASIS_LINE,
|
|
1646
2094
|
...(idx === 0 && markArea && { markArea }),
|
|
1647
2095
|
};
|
|
1648
2096
|
});
|
|
@@ -1708,7 +2156,7 @@ function buildAreaOption(
|
|
|
1708
2156
|
lineStyle: { color: lineColor, width: 3 },
|
|
1709
2157
|
itemStyle: { color: lineColor },
|
|
1710
2158
|
areaStyle: { opacity: 0.25 },
|
|
1711
|
-
emphasis:
|
|
2159
|
+
emphasis: EMPHASIS_LINE,
|
|
1712
2160
|
...(markArea && { markArea }),
|
|
1713
2161
|
},
|
|
1714
2162
|
],
|
|
@@ -1737,6 +2185,7 @@ function buildPieOption(
|
|
|
1737
2185
|
tooltipTheme: Record<string, unknown>,
|
|
1738
2186
|
isDoughnut: boolean
|
|
1739
2187
|
): EChartsOption {
|
|
2188
|
+
const HIDE_AXES = { xAxis: { show: false }, yAxis: { show: false } };
|
|
1740
2189
|
const data = parsed.data.map((d, i) => {
|
|
1741
2190
|
const stroke = d.color ?? colors[i % colors.length];
|
|
1742
2191
|
return {
|
|
@@ -1748,6 +2197,7 @@ function buildPieOption(
|
|
|
1748
2197
|
|
|
1749
2198
|
return {
|
|
1750
2199
|
...CHART_BASE,
|
|
2200
|
+
...HIDE_AXES,
|
|
1751
2201
|
title: titleConfig,
|
|
1752
2202
|
tooltip: {
|
|
1753
2203
|
trigger: 'item',
|
|
@@ -1795,6 +2245,8 @@ function buildRadarOption(
|
|
|
1795
2245
|
return {
|
|
1796
2246
|
...CHART_BASE,
|
|
1797
2247
|
title: titleConfig,
|
|
2248
|
+
xAxis: { show: false },
|
|
2249
|
+
yAxis: { show: false },
|
|
1798
2250
|
tooltip: {
|
|
1799
2251
|
trigger: 'item',
|
|
1800
2252
|
...tooltipTheme,
|
|
@@ -1863,6 +2315,8 @@ function buildPolarAreaOption(
|
|
|
1863
2315
|
return {
|
|
1864
2316
|
...CHART_BASE,
|
|
1865
2317
|
title: titleConfig,
|
|
2318
|
+
xAxis: { show: false },
|
|
2319
|
+
yAxis: { show: false },
|
|
1866
2320
|
tooltip: {
|
|
1867
2321
|
trigger: 'item',
|
|
1868
2322
|
...tooltipTheme,
|
|
@@ -1941,11 +2395,6 @@ function buildBarStackedOption(
|
|
|
1941
2395
|
return {
|
|
1942
2396
|
...CHART_BASE,
|
|
1943
2397
|
title: titleConfig,
|
|
1944
|
-
tooltip: {
|
|
1945
|
-
trigger: 'axis',
|
|
1946
|
-
...tooltipTheme,
|
|
1947
|
-
axisPointer: { type: 'shadow' },
|
|
1948
|
-
},
|
|
1949
2398
|
legend: {
|
|
1950
2399
|
data: seriesNames,
|
|
1951
2400
|
bottom: 10,
|
|
@@ -1989,21 +2438,41 @@ export async function renderExtendedChartForExport(
|
|
|
1989
2438
|
palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
|
|
1990
2439
|
|
|
1991
2440
|
// Detect chart type to dispatch to the right parser/builder
|
|
1992
|
-
|
|
1993
|
-
|
|
2441
|
+
// Find first non-empty, non-comment line and use parseFirstLine for new-style detection
|
|
2442
|
+
let chartType: string | undefined;
|
|
2443
|
+
for (const rawLine of content.split('\n')) {
|
|
2444
|
+
const t = rawLine.trim();
|
|
2445
|
+
if (!t || t.startsWith('//')) continue;
|
|
2446
|
+
const fl = parseFirstLine(t);
|
|
2447
|
+
if (fl) chartType = fl.chartType.toLowerCase();
|
|
2448
|
+
break;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
// No recognised chart type on the first line → nothing to render
|
|
2452
|
+
if (!chartType) return '';
|
|
1994
2453
|
|
|
1995
2454
|
let option: EChartsOption;
|
|
1996
|
-
|
|
2455
|
+
let legendGroups: LegendGroupData[] = [];
|
|
2456
|
+
const colors = getSeriesColors(effectivePalette);
|
|
2457
|
+
|
|
2458
|
+
if (STANDARD_CHART_TYPES.has(chartType)) {
|
|
1997
2459
|
const parsed = parseChart(content, effectivePalette);
|
|
1998
2460
|
if (parsed.error) return '';
|
|
1999
2461
|
option = buildSimpleChartOption(parsed, effectivePalette, isDark, ECHART_EXPORT_WIDTH);
|
|
2462
|
+
legendGroups = getSimpleChartLegendGroups(parsed, colors);
|
|
2000
2463
|
} else {
|
|
2001
2464
|
const parsed = parseExtendedChart(content, effectivePalette);
|
|
2002
2465
|
if (parsed.error) return '';
|
|
2003
2466
|
option = buildExtendedChartOption(parsed, effectivePalette, isDark);
|
|
2467
|
+
legendGroups = getExtendedChartLegendGroups(parsed, colors);
|
|
2004
2468
|
}
|
|
2005
2469
|
if (!option || Object.keys(option).length === 0) return '';
|
|
2006
2470
|
|
|
2471
|
+
// When using custom legend, strip ECharts' built-in legend
|
|
2472
|
+
if (legendGroups.length > 0) {
|
|
2473
|
+
option = { ...option, legend: undefined };
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2007
2476
|
const chart = echarts.init(null, null, {
|
|
2008
2477
|
renderer: 'svg',
|
|
2009
2478
|
ssr: true,
|
|
@@ -2024,6 +2493,31 @@ export async function renderExtendedChartForExport(
|
|
|
2024
2493
|
`<svg style="${bgStyle}font-family: ${FONT_FAMILY}" `
|
|
2025
2494
|
);
|
|
2026
2495
|
|
|
2496
|
+
// Inject custom legend SVG when present
|
|
2497
|
+
if (legendGroups.length > 0) {
|
|
2498
|
+
const titleHeight = option.title && (option.title as { text?: string }).text ? 40 : 0;
|
|
2499
|
+
const legendY = 8 + titleHeight;
|
|
2500
|
+
// In static export, expand the first group so entries are visible
|
|
2501
|
+
// Extract grid offsets for plot-area-centered legend
|
|
2502
|
+
const grid = option.grid as Record<string, unknown> | undefined;
|
|
2503
|
+
const gridLeftPct = grid?.left ? parseFloat(String(grid.left)) : undefined;
|
|
2504
|
+
const gridRightPct = grid?.right ? parseFloat(String(grid.right)) : undefined;
|
|
2505
|
+
const { svg: legendSvgStr } = renderLegendSvg(legendGroups, {
|
|
2506
|
+
palette: effectivePalette,
|
|
2507
|
+
isDark,
|
|
2508
|
+
containerWidth: ECHART_EXPORT_WIDTH,
|
|
2509
|
+
gridLeftPct,
|
|
2510
|
+
gridRightPct,
|
|
2511
|
+
activeGroup: legendGroups[0].name,
|
|
2512
|
+
className: 'chart-legend',
|
|
2513
|
+
});
|
|
2514
|
+
// Insert legend group right after the opening <svg ...> tag
|
|
2515
|
+
result = result.replace(
|
|
2516
|
+
/(<svg[^>]*>)/,
|
|
2517
|
+
`$1<g transform="translate(0,${legendY})">${legendSvgStr}</g>`,
|
|
2518
|
+
);
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2027
2521
|
if (options?.branding !== false) {
|
|
2028
2522
|
const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
|
|
2029
2523
|
result = injectBranding(result, brandColor);
|