@diagrammo/dgmo 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/echarts.ts CHANGED
@@ -82,13 +82,19 @@ export interface ParsedEChart {
82
82
  // Nord Colors for Charts
83
83
  // ============================================================
84
84
 
85
- import { resolveColor } from './colors';
86
85
  import type { PaletteColors } from './palettes';
87
86
  import { getSeriesColors, getSegmentColors } from './palettes';
88
87
  import { parseChart } from './chart';
89
88
  import type { ParsedChart } from './chart';
90
89
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
91
- import { collectIndentedValues } from './utils/parsing';
90
+ import { collectIndentedValues, extractColor, parseSeriesNames } from './utils/parsing';
91
+
92
+ // ============================================================
93
+ // Shared Constants
94
+ // ============================================================
95
+
96
+ const EMPHASIS_SELF = { focus: 'self' as const, blurScope: 'global' as const };
97
+ const CHART_BASE: Pick<EChartsOption, 'backgroundColor' | 'animation'> = { backgroundColor: 'transparent', animation: false };
92
98
 
93
99
  // ============================================================
94
100
  // Parser
@@ -133,13 +139,10 @@ export function parseEChart(
133
139
  // Check for markdown-style category header: ## Category Name or ## Category Name(color)
134
140
  const mdCategoryMatch = trimmed.match(/^#{2,}\s+(.+)$/);
135
141
  if (mdCategoryMatch) {
136
- let catName = mdCategoryMatch[1].trim();
137
- const catColorMatch = catName.match(/\(([^)]+)\)\s*$/);
138
- if (catColorMatch) {
139
- const resolved = resolveColor(catColorMatch[1].trim(), palette);
142
+ const { label: catName, color: catColor } = extractColor(mdCategoryMatch[1].trim(), palette);
143
+ if (catColor) {
140
144
  if (!result.categoryColors) result.categoryColors = {};
141
- catName = catName.substring(0, catColorMatch.index!).trim();
142
- result.categoryColors[catName] = resolved;
145
+ result.categoryColors[catName] = catColor;
143
146
  }
144
147
  currentCategory = catName;
145
148
  continue;
@@ -194,32 +197,13 @@ export function parseEChart(
194
197
  }
195
198
 
196
199
  if (key === 'series') {
197
- let rawNames: string[];
198
- if (value) {
199
- result.series = value;
200
- rawNames = value.split(',').map((s) => s.trim()).filter(Boolean);
201
- } else {
202
- const collected = collectIndentedValues(lines, i);
203
- i = collected.newIndex;
204
- rawNames = collected.values;
205
- result.series = rawNames.join(', ');
200
+ const parsed = parseSeriesNames(value, lines, i, palette);
201
+ i = parsed.newIndex;
202
+ result.series = parsed.series;
203
+ if (parsed.names.length > 1) {
204
+ result.seriesNames = parsed.names;
206
205
  }
207
- const names: string[] = [];
208
- const nameColors: (string | undefined)[] = [];
209
- for (const raw of rawNames) {
210
- const colorMatch = raw.match(/\(([^)]+)\)\s*$/);
211
- if (colorMatch) {
212
- nameColors.push(resolveColor(colorMatch[1].trim(), palette));
213
- names.push(raw.substring(0, colorMatch.index!).trim());
214
- } else {
215
- nameColors.push(undefined);
216
- names.push(raw);
217
- }
218
- }
219
- if (names.length === 1) {
220
- result.series = names[0];
221
- }
222
- if (nameColors.some(Boolean)) result.seriesNameColors = nameColors;
206
+ if (parsed.nameColors.some(Boolean)) result.seriesNameColors = parsed.nameColors;
223
207
  continue;
224
208
  }
225
209
 
@@ -296,13 +280,7 @@ export function parseEChart(
296
280
 
297
281
  // For function charts, treat non-numeric values as function expressions
298
282
  if (result.type === 'function') {
299
- let fnName = trimmed.substring(0, colonIndex).trim();
300
- let fnColor: string | undefined;
301
- const colorMatch = fnName.match(/\(([^)]+)\)\s*$/);
302
- if (colorMatch) {
303
- fnColor = resolveColor(colorMatch[1].trim(), palette);
304
- fnName = fnName.substring(0, colorMatch.index!).trim();
305
- }
283
+ const { label: fnName, color: fnColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
306
284
  if (!result.functions) result.functions = [];
307
285
  result.functions.push({
308
286
  name: fnName,
@@ -319,13 +297,7 @@ export function parseEChart(
319
297
  /^(-?[\d.]+)\s*,\s*(-?[\d.]+)(?:\s*,\s*(-?[\d.]+))?$/
320
298
  );
321
299
  if (scatterMatch) {
322
- let scatterName = trimmed.substring(0, colonIndex).trim();
323
- let scatterColor: string | undefined;
324
- const colorMatch = scatterName.match(/\(([^)]+)\)\s*$/);
325
- if (colorMatch) {
326
- scatterColor = resolveColor(colorMatch[1].trim(), palette);
327
- scatterName = scatterName.substring(0, colorMatch.index!).trim();
328
- }
300
+ const { label: scatterName, color: scatterColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
329
301
  if (!result.scatterPoints) result.scatterPoints = [];
330
302
  result.scatterPoints.push({
331
303
  name: scatterName,
@@ -354,14 +326,7 @@ export function parseEChart(
354
326
  // Otherwise treat as data point (label: value)
355
327
  const numValue = parseFloat(value);
356
328
  if (!isNaN(numValue)) {
357
- // Use the original case for the label (before lowercasing)
358
- let rawLabel = trimmed.substring(0, colonIndex).trim();
359
- let pointColor: string | undefined;
360
- const colorMatch = rawLabel.match(/\(([^)]+)\)\s*$/);
361
- if (colorMatch) {
362
- pointColor = resolveColor(colorMatch[1].trim(), palette);
363
- rawLabel = rawLabel.substring(0, colorMatch.index!).trim();
364
- }
329
+ const { label: rawLabel, color: pointColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
365
330
  result.data.push({
366
331
  label: rawLabel,
367
332
  value: numValue,
@@ -416,6 +381,20 @@ export function parseEChart(
416
381
  // ECharts Option Builder
417
382
  // ============================================================
418
383
 
384
+ /**
385
+ * Computes the shared set of theme-derived variables used by all chart option builders.
386
+ */
387
+ function buildChartCommons(parsed: { title?: string; error?: string | null }, palette: PaletteColors, isDark: boolean) {
388
+ const textColor = palette.text;
389
+ const axisLineColor = palette.border;
390
+ const splitLineColor = palette.border;
391
+ const gridOpacity = isDark ? 0.7 : 0.55;
392
+ const colors = getSeriesColors(palette);
393
+ const titleConfig = parsed.title ? { text: parsed.title, left: 'center' as const, top: 8, textStyle: { color: textColor, fontSize: 20, fontWeight: 'bold' as const, fontFamily: FONT_FAMILY } } : undefined;
394
+ const tooltipTheme = { backgroundColor: palette.surface, borderColor: palette.border, textStyle: { color: palette.text } };
395
+ return { textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme };
396
+ }
397
+
419
398
  /**
420
399
  * Converts parsed echart data to ECharts option object.
421
400
  */
@@ -424,37 +403,12 @@ export function buildEChartsOption(
424
403
  palette: PaletteColors,
425
404
  isDark: boolean
426
405
  ): EChartsOption {
427
- const textColor = palette.text;
428
- const axisLineColor = palette.border;
429
- const gridOpacity = isDark ? 0.7 : 0.55;
430
- const colors = getSeriesColors(palette);
431
-
432
406
  if (parsed.error) {
433
407
  // Return empty option, error will be shown separately
434
408
  return {};
435
409
  }
436
410
 
437
- // Common title configuration
438
- const titleConfig = parsed.title
439
- ? {
440
- text: parsed.title,
441
- left: 'center' as const,
442
- top: 8,
443
- textStyle: {
444
- color: textColor,
445
- fontSize: 20,
446
- fontWeight: 'bold' as const,
447
- fontFamily: FONT_FAMILY,
448
- },
449
- }
450
- : undefined;
451
-
452
- // Shared tooltip theme so tooltips match light/dark mode
453
- const tooltipTheme = {
454
- backgroundColor: palette.surface,
455
- borderColor: palette.border,
456
- textStyle: { color: palette.text },
457
- };
411
+ const { textColor, axisLineColor, gridOpacity, colors, titleConfig, tooltipTheme } = buildChartCommons(parsed, palette, isDark);
458
412
 
459
413
  // Sankey chart has different structure
460
414
  if (parsed.type === 'sankey') {
@@ -555,8 +509,7 @@ function buildSankeyOption(
555
509
  }));
556
510
 
557
511
  return {
558
- backgroundColor: 'transparent',
559
- animation: false,
512
+ ...CHART_BASE,
560
513
  title: titleConfig,
561
514
  tooltip: {
562
515
  show: false,
@@ -633,8 +586,7 @@ function buildChordOption(
633
586
  }));
634
587
 
635
588
  return {
636
- backgroundColor: 'transparent',
637
- animation: false,
589
+ ...CHART_BASE,
638
590
  title: titleConfig,
639
591
  tooltip: {
640
592
  trigger: 'item',
@@ -776,16 +728,12 @@ function buildFunctionOption(
776
728
  itemStyle: {
777
729
  color: fnColor,
778
730
  },
779
- emphasis: {
780
- focus: 'self' as const,
781
- blurScope: 'global' as const,
782
- },
731
+ emphasis: EMPHASIS_SELF,
783
732
  };
784
733
  });
785
734
 
786
735
  return {
787
- backgroundColor: 'transparent',
788
- animation: false,
736
+ ...CHART_BASE,
789
737
  title: titleConfig,
790
738
  tooltip: {
791
739
  trigger: 'axis',
@@ -971,8 +919,7 @@ function buildScatterOption(
971
919
  const yPad = (yMax - yMin) * 0.1 || 1;
972
920
 
973
921
  return {
974
- backgroundColor: 'transparent',
975
- animation: false,
922
+ ...CHART_BASE,
976
923
  title: titleConfig,
977
924
  tooltip,
978
925
  ...(legendData && {
@@ -1072,8 +1019,7 @@ function buildHeatmapOption(
1072
1019
  });
1073
1020
 
1074
1021
  return {
1075
- backgroundColor: 'transparent',
1076
- animation: false,
1022
+ ...CHART_BASE,
1077
1023
  title: titleConfig,
1078
1024
  tooltip: {
1079
1025
  trigger: 'item',
@@ -1150,8 +1096,7 @@ function buildHeatmapOption(
1150
1096
  fontWeight: 'bold' as const,
1151
1097
  },
1152
1098
  emphasis: {
1153
- focus: 'self' as const,
1154
- blurScope: 'global' as const,
1099
+ ...EMPHASIS_SELF,
1155
1100
  itemStyle: {
1156
1101
  shadowBlur: 10,
1157
1102
  shadowColor: 'rgba(0, 0, 0, 0.5)',
@@ -1206,8 +1151,7 @@ function buildFunnelOption(
1206
1151
  };
1207
1152
 
1208
1153
  return {
1209
- backgroundColor: 'transparent',
1210
- animation: false,
1154
+ ...CHART_BASE,
1211
1155
  title: titleConfig,
1212
1156
  tooltip: {
1213
1157
  trigger: 'item',
@@ -1245,8 +1189,7 @@ function buildFunnelOption(
1245
1189
  lineStyle: { color: textColor, opacity: 0.3 },
1246
1190
  },
1247
1191
  emphasis: {
1248
- focus: 'self' as const,
1249
- blurScope: 'global' as const,
1192
+ ...EMPHASIS_SELF,
1250
1193
  label: {
1251
1194
  fontSize: 15,
1252
1195
  },
@@ -1309,21 +1252,45 @@ function makeGridAxis(
1309
1252
  gridOpacity: number,
1310
1253
  label?: string,
1311
1254
  data?: string[],
1312
- nameGapOverride?: number
1255
+ nameGapOverride?: number,
1256
+ chartWidthHint?: number
1313
1257
  ): Record<string, unknown> {
1314
1258
  const defaultGap = type === 'value' ? 75 : 40;
1259
+
1260
+ // Compute category label sizing: font size and width constraint
1261
+ let catFontSize = 16;
1262
+ let catLabelExtras: Record<string, unknown> = {};
1263
+ if (type === 'category' && data && data.length > 0) {
1264
+ const maxLabelLen = Math.max(...data.map((l) => l.length));
1265
+ const count = data.length;
1266
+ // Reduce font size based on density and label length
1267
+ if (count > 10 || maxLabelLen > 20) catFontSize = 10;
1268
+ else if (count > 5 || maxLabelLen > 14) catFontSize = 11;
1269
+ else if (maxLabelLen > 8) catFontSize = 12;
1270
+
1271
+ // Constrain labels to their allotted slot width so ECharts wraps instead of hiding
1272
+ if (chartWidthHint && count > 0) {
1273
+ const availPerLabel = Math.floor((chartWidthHint * 0.85) / count);
1274
+ catLabelExtras = {
1275
+ width: availPerLabel,
1276
+ overflow: 'break',
1277
+ };
1278
+ }
1279
+ }
1280
+
1315
1281
  return {
1316
1282
  type,
1317
1283
  ...(data && { data }),
1318
1284
  axisLine: { lineStyle: { color: axisLineColor } },
1319
1285
  axisLabel: {
1320
1286
  color: textColor,
1321
- fontSize: type === 'category' && data ? (data.length > 10 ? 11 : data.length > 5 ? 12 : 16) : 16,
1287
+ fontSize: type === 'category' && data ? catFontSize : 16,
1322
1288
  fontFamily: FONT_FAMILY,
1323
1289
  ...(type === 'category' && {
1324
1290
  interval: 0,
1325
1291
  formatter: (value: string) =>
1326
- value.replace(/([a-z])([A-Z])/g, '$1\n$2').replace(/ /g, '\n'),
1292
+ value.replace(/([a-z])([A-Z])/g, '$1\n$2'),
1293
+ ...catLabelExtras,
1327
1294
  }),
1328
1295
  },
1329
1296
  splitLine: { lineStyle: { color: splitLineColor, opacity: gridOpacity } },
@@ -1343,47 +1310,24 @@ function makeGridAxis(
1343
1310
  export function buildEChartsOptionFromChart(
1344
1311
  parsed: ParsedChart,
1345
1312
  palette: PaletteColors,
1346
- isDark: boolean
1313
+ isDark: boolean,
1314
+ chartWidth?: number
1347
1315
  ): EChartsOption {
1348
1316
  if (parsed.error) return {};
1349
1317
 
1350
- const textColor = palette.text;
1351
- const axisLineColor = palette.border;
1352
- const splitLineColor = palette.border;
1353
- const gridOpacity = isDark ? 0.7 : 0.55;
1354
- const colors = getSeriesColors(palette);
1355
-
1356
- const titleConfig = parsed.title
1357
- ? {
1358
- text: parsed.title,
1359
- left: 'center' as const,
1360
- top: 8,
1361
- textStyle: {
1362
- color: textColor,
1363
- fontSize: 20,
1364
- fontWeight: 'bold' as const,
1365
- fontFamily: FONT_FAMILY,
1366
- },
1367
- }
1368
- : undefined;
1369
-
1370
- const tooltipTheme = {
1371
- backgroundColor: palette.surface,
1372
- borderColor: palette.border,
1373
- textStyle: { color: palette.text },
1374
- };
1318
+ const { textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme } = buildChartCommons(parsed, palette, isDark);
1375
1319
 
1376
1320
  switch (parsed.type) {
1377
1321
  case 'bar':
1378
- return buildBarOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme);
1322
+ return buildBarOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme, chartWidth);
1379
1323
  case 'bar-stacked':
1380
- return buildBarStackedOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme);
1324
+ return buildBarStackedOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme, chartWidth);
1381
1325
  case 'line':
1382
1326
  return parsed.seriesNames
1383
- ? buildMultiLineOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme)
1384
- : buildLineOption(parsed, palette, textColor, axisLineColor, splitLineColor, gridOpacity, titleConfig, tooltipTheme);
1327
+ ? buildMultiLineOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme, chartWidth)
1328
+ : buildLineOption(parsed, palette, textColor, axisLineColor, splitLineColor, gridOpacity, titleConfig, tooltipTheme, chartWidth);
1385
1329
  case 'area':
1386
- return buildAreaOption(parsed, palette, textColor, axisLineColor, splitLineColor, gridOpacity, titleConfig, tooltipTheme);
1330
+ return buildAreaOption(parsed, palette, textColor, axisLineColor, splitLineColor, gridOpacity, titleConfig, tooltipTheme, chartWidth);
1387
1331
  case 'pie':
1388
1332
  return buildPieOption(parsed, textColor, getSegmentColors(palette, parsed.data.length), titleConfig, tooltipTheme, false);
1389
1333
  case 'doughnut':
@@ -1395,6 +1339,19 @@ export function buildEChartsOptionFromChart(
1395
1339
  }
1396
1340
  }
1397
1341
 
1342
+ /**
1343
+ * Builds a standard chart grid object with consistent spacing rules.
1344
+ */
1345
+ function makeChartGrid(options: { xLabel?: string; yLabel?: string; hasTitle: boolean; hasLegend?: boolean }): Record<string, unknown> {
1346
+ return {
1347
+ left: options.yLabel ? '12%' : '3%',
1348
+ right: '4%',
1349
+ bottom: options.hasLegend ? '15%' : options.xLabel ? '10%' : '3%',
1350
+ top: options.hasTitle ? '15%' : '5%',
1351
+ containLabel: true,
1352
+ };
1353
+ }
1354
+
1398
1355
  // ── Bar ──────────────────────────────────────────────────────
1399
1356
 
1400
1357
  function buildBarOption(
@@ -1405,7 +1362,8 @@ function buildBarOption(
1405
1362
  gridOpacity: number,
1406
1363
  colors: string[],
1407
1364
  titleConfig: EChartsOption['title'],
1408
- tooltipTheme: Record<string, unknown>
1365
+ tooltipTheme: Record<string, unknown>,
1366
+ chartWidth?: number
1409
1367
  ): EChartsOption {
1410
1368
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1411
1369
  const isHorizontal = parsed.orientation === 'horizontal';
@@ -1420,37 +1378,27 @@ function buildBarOption(
1420
1378
  const hCatGap = isHorizontal && yLabel
1421
1379
  ? Math.max(40, Math.max(...labels.map((l) => l.length)) * 8 + 16)
1422
1380
  : undefined;
1423
- const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap);
1381
+ const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap, !isHorizontal ? chartWidth : undefined);
1424
1382
  const valueAxis = makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? xLabel : yLabel);
1425
1383
 
1426
1384
  // xAxis is always the bottom axis, yAxis is always the left axis in ECharts
1427
1385
 
1428
1386
  return {
1429
- backgroundColor: 'transparent',
1430
- animation: false,
1387
+ ...CHART_BASE,
1431
1388
  title: titleConfig,
1432
1389
  tooltip: {
1433
1390
  trigger: 'axis',
1434
1391
  ...tooltipTheme,
1435
1392
  axisPointer: { type: 'shadow' },
1436
1393
  },
1437
- grid: {
1438
- left: yLabel ? '12%' : '3%',
1439
- right: '4%',
1440
- bottom: xLabel ? '10%' : '3%',
1441
- top: parsed.title ? '15%' : '5%',
1442
- containLabel: true,
1443
- },
1394
+ grid: makeChartGrid({ xLabel, yLabel, hasTitle: !!parsed.title }),
1444
1395
  xAxis: isHorizontal ? valueAxis : categoryAxis,
1445
1396
  yAxis: isHorizontal ? categoryAxis : valueAxis,
1446
1397
  series: [
1447
1398
  {
1448
1399
  type: 'bar',
1449
1400
  data,
1450
- emphasis: {
1451
- focus: 'self' as const,
1452
- blurScope: 'global' as const,
1453
- },
1401
+ emphasis: EMPHASIS_SELF,
1454
1402
  },
1455
1403
  ],
1456
1404
  };
@@ -1466,7 +1414,8 @@ function buildLineOption(
1466
1414
  splitLineColor: string,
1467
1415
  gridOpacity: number,
1468
1416
  titleConfig: EChartsOption['title'],
1469
- tooltipTheme: Record<string, unknown>
1417
+ tooltipTheme: Record<string, unknown>,
1418
+ chartWidth?: number
1470
1419
  ): EChartsOption {
1471
1420
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1472
1421
  const lineColor = parsed.color ?? parsed.seriesNameColors?.[0] ?? palette.primary;
@@ -1474,22 +1423,15 @@ function buildLineOption(
1474
1423
  const values = parsed.data.map((d) => d.value);
1475
1424
 
1476
1425
  return {
1477
- backgroundColor: 'transparent',
1478
- animation: false,
1426
+ ...CHART_BASE,
1479
1427
  title: titleConfig,
1480
1428
  tooltip: {
1481
1429
  trigger: 'axis',
1482
1430
  ...tooltipTheme,
1483
1431
  axisPointer: { type: 'line' },
1484
1432
  },
1485
- grid: {
1486
- left: yLabel ? '12%' : '3%',
1487
- right: '4%',
1488
- bottom: xLabel ? '10%' : '3%',
1489
- top: parsed.title ? '15%' : '5%',
1490
- containLabel: true,
1491
- },
1492
- xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels),
1433
+ grid: makeChartGrid({ xLabel, yLabel, hasTitle: !!parsed.title }),
1434
+ xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels, undefined, chartWidth),
1493
1435
  yAxis: makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, yLabel),
1494
1436
  series: [
1495
1437
  {
@@ -1499,10 +1441,7 @@ function buildLineOption(
1499
1441
  symbolSize: 8,
1500
1442
  lineStyle: { color: lineColor, width: 3 },
1501
1443
  itemStyle: { color: lineColor },
1502
- emphasis: {
1503
- focus: 'self' as const,
1504
- blurScope: 'global' as const,
1505
- },
1444
+ emphasis: EMPHASIS_SELF,
1506
1445
  },
1507
1446
  ],
1508
1447
  };
@@ -1518,7 +1457,8 @@ function buildMultiLineOption(
1518
1457
  gridOpacity: number,
1519
1458
  colors: string[],
1520
1459
  titleConfig: EChartsOption['title'],
1521
- tooltipTheme: Record<string, unknown>
1460
+ tooltipTheme: Record<string, unknown>,
1461
+ chartWidth?: number
1522
1462
  ): EChartsOption {
1523
1463
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1524
1464
  const seriesNames = parsed.seriesNames ?? [];
@@ -1537,16 +1477,12 @@ function buildMultiLineOption(
1537
1477
  symbolSize: 8,
1538
1478
  lineStyle: { color, width: 3 },
1539
1479
  itemStyle: { color },
1540
- emphasis: {
1541
- focus: 'self' as const,
1542
- blurScope: 'global' as const,
1543
- },
1480
+ emphasis: EMPHASIS_SELF,
1544
1481
  };
1545
1482
  });
1546
1483
 
1547
1484
  return {
1548
- backgroundColor: 'transparent',
1549
- animation: false,
1485
+ ...CHART_BASE,
1550
1486
  title: titleConfig,
1551
1487
  tooltip: {
1552
1488
  trigger: 'axis',
@@ -1558,14 +1494,8 @@ function buildMultiLineOption(
1558
1494
  bottom: 10,
1559
1495
  textStyle: { color: textColor },
1560
1496
  },
1561
- grid: {
1562
- left: yLabel ? '12%' : '3%',
1563
- right: '4%',
1564
- bottom: '15%',
1565
- top: parsed.title ? '15%' : '5%',
1566
- containLabel: true,
1567
- },
1568
- xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels),
1497
+ grid: makeChartGrid({ xLabel, yLabel, hasTitle: !!parsed.title, hasLegend: true }),
1498
+ xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels, undefined, chartWidth),
1569
1499
  yAxis: makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, yLabel),
1570
1500
  series,
1571
1501
  };
@@ -1581,7 +1511,8 @@ function buildAreaOption(
1581
1511
  splitLineColor: string,
1582
1512
  gridOpacity: number,
1583
1513
  titleConfig: EChartsOption['title'],
1584
- tooltipTheme: Record<string, unknown>
1514
+ tooltipTheme: Record<string, unknown>,
1515
+ chartWidth?: number
1585
1516
  ): EChartsOption {
1586
1517
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1587
1518
  const lineColor = parsed.color ?? parsed.seriesNameColors?.[0] ?? palette.primary;
@@ -1589,22 +1520,15 @@ function buildAreaOption(
1589
1520
  const values = parsed.data.map((d) => d.value);
1590
1521
 
1591
1522
  return {
1592
- backgroundColor: 'transparent',
1593
- animation: false,
1523
+ ...CHART_BASE,
1594
1524
  title: titleConfig,
1595
1525
  tooltip: {
1596
1526
  trigger: 'axis',
1597
1527
  ...tooltipTheme,
1598
1528
  axisPointer: { type: 'line' },
1599
1529
  },
1600
- grid: {
1601
- left: yLabel ? '12%' : '3%',
1602
- right: '4%',
1603
- bottom: xLabel ? '10%' : '3%',
1604
- top: parsed.title ? '15%' : '5%',
1605
- containLabel: true,
1606
- },
1607
- xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels),
1530
+ grid: makeChartGrid({ xLabel, yLabel, hasTitle: !!parsed.title }),
1531
+ xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels, undefined, chartWidth),
1608
1532
  yAxis: makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, yLabel),
1609
1533
  series: [
1610
1534
  {
@@ -1615,10 +1539,7 @@ function buildAreaOption(
1615
1539
  lineStyle: { color: lineColor, width: 3 },
1616
1540
  itemStyle: { color: lineColor },
1617
1541
  areaStyle: { opacity: 0.25 },
1618
- emphasis: {
1619
- focus: 'self' as const,
1620
- blurScope: 'global' as const,
1621
- },
1542
+ emphasis: EMPHASIS_SELF,
1622
1543
  },
1623
1544
  ],
1624
1545
  };
@@ -1652,8 +1573,7 @@ function buildPieOption(
1652
1573
  }));
1653
1574
 
1654
1575
  return {
1655
- backgroundColor: 'transparent',
1656
- animation: false,
1576
+ ...CHART_BASE,
1657
1577
  title: titleConfig,
1658
1578
  tooltip: {
1659
1579
  trigger: 'item',
@@ -1671,10 +1591,7 @@ function buildPieOption(
1671
1591
  fontFamily: FONT_FAMILY,
1672
1592
  },
1673
1593
  labelLine: { show: true },
1674
- emphasis: {
1675
- focus: 'self' as const,
1676
- blurScope: 'global' as const,
1677
- },
1594
+ emphasis: EMPHASIS_SELF,
1678
1595
  },
1679
1596
  ],
1680
1597
  };
@@ -1701,8 +1618,7 @@ function buildRadarOption(
1701
1618
  }));
1702
1619
 
1703
1620
  return {
1704
- backgroundColor: 'transparent',
1705
- animation: false,
1621
+ ...CHART_BASE,
1706
1622
  title: titleConfig,
1707
1623
  tooltip: {
1708
1624
  trigger: 'item',
@@ -1744,10 +1660,7 @@ function buildRadarOption(
1744
1660
  },
1745
1661
  },
1746
1662
  ],
1747
- emphasis: {
1748
- focus: 'self' as const,
1749
- blurScope: 'global' as const,
1750
- },
1663
+ emphasis: EMPHASIS_SELF,
1751
1664
  },
1752
1665
  ],
1753
1666
  };
@@ -1769,8 +1682,7 @@ function buildPolarAreaOption(
1769
1682
  }));
1770
1683
 
1771
1684
  return {
1772
- backgroundColor: 'transparent',
1773
- animation: false,
1685
+ ...CHART_BASE,
1774
1686
  title: titleConfig,
1775
1687
  tooltip: {
1776
1688
  trigger: 'item',
@@ -1789,10 +1701,7 @@ function buildPolarAreaOption(
1789
1701
  fontFamily: FONT_FAMILY,
1790
1702
  },
1791
1703
  labelLine: { show: true },
1792
- emphasis: {
1793
- focus: 'self' as const,
1794
- blurScope: 'global' as const,
1795
- },
1704
+ emphasis: EMPHASIS_SELF,
1796
1705
  },
1797
1706
  ],
1798
1707
  };
@@ -1808,7 +1717,8 @@ function buildBarStackedOption(
1808
1717
  gridOpacity: number,
1809
1718
  colors: string[],
1810
1719
  titleConfig: EChartsOption['title'],
1811
- tooltipTheme: Record<string, unknown>
1720
+ tooltipTheme: Record<string, unknown>,
1721
+ chartWidth?: number
1812
1722
  ): EChartsOption {
1813
1723
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1814
1724
  const isHorizontal = parsed.orientation === 'horizontal';
@@ -1835,22 +1745,21 @@ function buildBarStackedOption(
1835
1745
  fontWeight: 'bold' as const,
1836
1746
  fontFamily: FONT_FAMILY,
1837
1747
  },
1838
- emphasis: {
1839
- focus: 'self' as const,
1840
- blurScope: 'global' as const,
1841
- },
1748
+ emphasis: EMPHASIS_SELF,
1842
1749
  };
1843
1750
  });
1844
1751
 
1845
1752
  const hCatGap = isHorizontal && yLabel
1846
1753
  ? Math.max(40, Math.max(...labels.map((l) => l.length)) * 8 + 16)
1847
1754
  : undefined;
1848
- const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap);
1849
- const valueAxis = makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? xLabel : yLabel);
1755
+ const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap, !isHorizontal ? chartWidth : undefined);
1756
+ // For horizontal bars with a legend, use a smaller nameGap so the xlabel
1757
+ // stays close to the axis ticks rather than drifting toward the legend.
1758
+ const hValueGap = isHorizontal && xLabel ? 40 : undefined;
1759
+ const valueAxis = makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? xLabel : yLabel, undefined, hValueGap);
1850
1760
 
1851
1761
  return {
1852
- backgroundColor: 'transparent',
1853
- animation: false,
1762
+ ...CHART_BASE,
1854
1763
  title: titleConfig,
1855
1764
  tooltip: {
1856
1765
  trigger: 'axis',
@@ -1862,13 +1771,7 @@ function buildBarStackedOption(
1862
1771
  bottom: 10,
1863
1772
  textStyle: { color: textColor },
1864
1773
  },
1865
- grid: {
1866
- left: yLabel ? '12%' : '3%',
1867
- right: '4%',
1868
- bottom: '15%',
1869
- top: parsed.title ? '15%' : '5%',
1870
- containLabel: true,
1871
- },
1774
+ grid: makeChartGrid({ xLabel, yLabel, hasTitle: !!parsed.title, hasLegend: true }),
1872
1775
  xAxis: isHorizontal ? valueAxis : categoryAxis,
1873
1776
  yAxis: isHorizontal ? categoryAxis : valueAxis,
1874
1777
  series,
@@ -1882,17 +1785,7 @@ function buildBarStackedOption(
1882
1785
  const ECHART_EXPORT_WIDTH = 1200;
1883
1786
  const ECHART_EXPORT_HEIGHT = 800;
1884
1787
 
1885
- const STANDARD_CHART_TYPES = new Set([
1886
- 'bar',
1887
- 'line',
1888
- 'multi-line',
1889
- 'area',
1890
- 'pie',
1891
- 'doughnut',
1892
- 'radar',
1893
- 'polar-area',
1894
- 'bar-stacked',
1895
- ]);
1788
+ import { STANDARD_CHART_TYPES } from './dgmo-router';
1896
1789
 
1897
1790
  /**
1898
1791
  * Renders an ECharts diagram to SVG using server-side rendering.
@@ -1919,7 +1812,7 @@ export async function renderEChartsForExport(
1919
1812
  if (chartType && STANDARD_CHART_TYPES.has(chartType)) {
1920
1813
  const parsed = parseChart(content, effectivePalette);
1921
1814
  if (parsed.error) return '';
1922
- option = buildEChartsOptionFromChart(parsed, effectivePalette, isDark);
1815
+ option = buildEChartsOptionFromChart(parsed, effectivePalette, isDark, ECHART_EXPORT_WIDTH);
1923
1816
  } else {
1924
1817
  const parsed = parseEChart(content, effectivePalette);
1925
1818
  if (parsed.error) return '';