@diagrammo/dgmo 0.3.0 → 0.3.2

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.
@@ -44,6 +44,41 @@ Set via CLI: `dgmo diagram.dgmo --palette catppuccin --theme dark`
44
44
 
45
45
  Text fields support: `*italic*`, `**bold**`, `` `code` ``, `[link text](url)`. Bare URLs are auto-linked.
46
46
 
47
+ ### Multi-line Values
48
+
49
+ Properties that accept comma-separated lists (`series`, `columns`, `rows`, `x-axis`, `y-axis`) also accept an indented multi-line format. Leave the value after the colon empty and list each value on its own indented line:
50
+
51
+ ```
52
+ // Single-line (still works)
53
+ series: Rum, Spices, Silk, Gold
54
+
55
+ // Multi-line equivalent
56
+ series:
57
+ Rum
58
+ Spices
59
+ Silk
60
+ Gold
61
+ ```
62
+
63
+ Multi-line blocks support blank lines and `//` comments within the block. Trailing commas on values are stripped for convenience.
64
+
65
+ ```
66
+ series:
67
+ Rum (red)
68
+ Spices (green)
69
+ // gold last
70
+ Gold (yellow)
71
+ ```
72
+
73
+ Works with `columns:` and `rows:` in heatmaps:
74
+
75
+ ```
76
+ columns:
77
+ January
78
+ February
79
+ March
80
+ ```
81
+
47
82
  ---
48
83
 
49
84
  ## Chart Types
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/chart.ts CHANGED
@@ -47,6 +47,7 @@ export interface ParsedChart {
47
47
  import { resolveColor } from './colors';
48
48
  import type { PaletteColors } from './palettes';
49
49
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
50
+ import { collectIndentedValues } from './utils/parsing';
50
51
 
51
52
  // ============================================================
52
53
  // Parser
@@ -181,12 +182,17 @@ export function parseChart(
181
182
  }
182
183
 
183
184
  if (key === 'series') {
184
- result.series = value;
185
- // Parse comma-separated series names for multi-series chart types
186
- const rawNames = value
187
- .split(',')
188
- .map((s) => s.trim())
189
- .filter(Boolean);
185
+ // Parse series names — comma-separated on one line, or indented multi-line
186
+ let rawNames: string[];
187
+ if (value) {
188
+ result.series = value;
189
+ rawNames = value.split(',').map((s) => s.trim()).filter(Boolean);
190
+ } else {
191
+ const collected = collectIndentedValues(lines, i);
192
+ i = collected.newIndex;
193
+ rawNames = collected.values;
194
+ result.series = rawNames.join(', ');
195
+ }
190
196
  const names: string[] = [];
191
197
  const nameColors: (string | undefined)[] = [];
192
198
  for (const raw of rawNames) {
package/src/d3.ts CHANGED
@@ -180,6 +180,7 @@ import type { PaletteColors } from './palettes';
180
180
  import { getSeriesColors } from './palettes';
181
181
  import type { DgmoError } from './diagnostics';
182
182
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
183
+ import { collectIndentedValues } from './utils/parsing';
183
184
 
184
185
  // ============================================================
185
186
  // Timeline Date Helper
@@ -517,10 +518,18 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
517
518
 
518
519
  // Quadrant-specific parsing
519
520
  if (result.type === 'quadrant') {
520
- // x-axis: Low, High
521
- const xAxisMatch = line.match(/^x-axis\s*:\s*(.+)/i);
521
+ // x-axis: Low, High — or indented multi-line
522
+ const xAxisMatch = line.match(/^x-axis\s*:\s*(.*)/i);
522
523
  if (xAxisMatch) {
523
- const parts = xAxisMatch[1].split(',').map((s) => s.trim());
524
+ const val = xAxisMatch[1].trim();
525
+ let parts: string[];
526
+ if (val) {
527
+ parts = val.split(',').map((s) => s.trim());
528
+ } else {
529
+ const collected = collectIndentedValues(lines, i);
530
+ i = collected.newIndex;
531
+ parts = collected.values;
532
+ }
524
533
  if (parts.length >= 2) {
525
534
  result.quadrantXAxis = [parts[0], parts[1]];
526
535
  result.quadrantXAxisLineNumber = lineNumber;
@@ -528,10 +537,18 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
528
537
  continue;
529
538
  }
530
539
 
531
- // y-axis: Low, High
532
- const yAxisMatch = line.match(/^y-axis\s*:\s*(.+)/i);
540
+ // y-axis: Low, High — or indented multi-line
541
+ const yAxisMatch = line.match(/^y-axis\s*:\s*(.*)/i);
533
542
  if (yAxisMatch) {
534
- const parts = yAxisMatch[1].split(',').map((s) => s.trim());
543
+ const val = yAxisMatch[1].trim();
544
+ let parts: string[];
545
+ if (val) {
546
+ parts = val.split(',').map((s) => s.trim());
547
+ } else {
548
+ const collected = collectIndentedValues(lines, i);
549
+ i = collected.newIndex;
550
+ parts = collected.values;
551
+ }
535
552
  if (parts.length >= 2) {
536
553
  result.quadrantYAxis = [parts[0], parts[1]];
537
554
  result.quadrantYAxisLineNumber = lineNumber;
@@ -1057,6 +1074,29 @@ function tokenizeFreeformText(text: string): WordCloudWord[] {
1057
1074
  // Slope Chart Renderer
1058
1075
  // ============================================================
1059
1076
 
1077
+ /**
1078
+ * Resolves vertical label collisions by nudging overlapping items apart.
1079
+ * Takes items with a naturalY (center) and height, returns adjusted center Y positions.
1080
+ */
1081
+ function resolveVerticalCollisions(
1082
+ items: { naturalY: number; height: number }[],
1083
+ minGap: number
1084
+ ): number[] {
1085
+ if (items.length === 0) return [];
1086
+ const sorted = items
1087
+ .map((it, i) => ({ ...it, idx: i }))
1088
+ .sort((a, b) => a.naturalY - b.naturalY);
1089
+ const adjustedY = new Array<number>(items.length);
1090
+ let prevBottom = -Infinity;
1091
+ for (const item of sorted) {
1092
+ const halfH = item.height / 2;
1093
+ const top = Math.max(item.naturalY - halfH, prevBottom + minGap);
1094
+ adjustedY[item.idx] = top + halfH;
1095
+ prevBottom = top + item.height;
1096
+ }
1097
+ return adjustedY;
1098
+ }
1099
+
1060
1100
  const SLOPE_MARGIN = { top: 80, bottom: 40, left: 80 };
1061
1101
  const SLOPE_LABEL_FONT_SIZE = 14;
1062
1102
  const SLOPE_CHAR_WIDTH = 8; // approximate px per character at 14px
@@ -1188,28 +1228,83 @@ export function renderSlopeChart(
1188
1228
  .x((_d, i) => xScale(periods[i])!)
1189
1229
  .y((d) => yScale(d));
1190
1230
 
1191
- // Render each data series
1192
- data.forEach((item, idx) => {
1231
+ // Pre-compute per-series data for label collision resolution
1232
+ const seriesInfo = data.map((item, idx) => {
1193
1233
  const color = item.color ?? colors[idx % colors.length];
1194
-
1195
- // Wrap each series in a group with data-line-number for sync adapter
1196
- const seriesG = g
1197
- .append('g')
1198
- .attr('class', 'slope-series')
1199
- .attr('data-line-number', String(item.lineNumber));
1200
-
1201
- // Tooltip content – overall change for this series
1202
1234
  const firstVal = item.values[0];
1203
1235
  const lastVal = item.values[item.values.length - 1];
1204
1236
  const absChange = lastVal - firstVal;
1205
1237
  const pctChange = firstVal !== 0 ? (absChange / firstVal) * 100 : null;
1206
1238
  const sign = absChange > 0 ? '+' : '';
1207
- const pctPart =
1208
- pctChange !== null ? ` (${sign}${pctChange.toFixed(1)}%)` : '';
1209
- const tipLines = [`${sign}${absChange}`];
1239
+ const tipLines = [`${sign}${parseFloat(absChange.toFixed(2))}`];
1210
1240
  if (pctChange !== null) tipLines.push(`${sign}${pctChange.toFixed(1)}%`);
1211
1241
  const tipHtml = tipLines.join('<br>');
1212
1242
 
1243
+ // Compute right-side label text and wrapping info
1244
+ const lastX = xScale(periods[periods.length - 1])!;
1245
+ const labelText = `${lastVal} — ${item.label}`;
1246
+ const availableWidth = rightMargin - 15;
1247
+ const maxChars = Math.floor(availableWidth / SLOPE_CHAR_WIDTH);
1248
+
1249
+ let labelLineCount = 1;
1250
+ let wrappedLines: string[] | null = null;
1251
+ if (labelText.length > maxChars) {
1252
+ const words = labelText.split(/\s+/);
1253
+ const lines: string[] = [];
1254
+ let current = '';
1255
+ for (const word of words) {
1256
+ const test = current ? `${current} ${word}` : word;
1257
+ if (test.length > maxChars && current) {
1258
+ lines.push(current);
1259
+ current = word;
1260
+ } else {
1261
+ current = test;
1262
+ }
1263
+ }
1264
+ if (current) lines.push(current);
1265
+ labelLineCount = lines.length;
1266
+ wrappedLines = lines;
1267
+ }
1268
+ const lineHeight = SLOPE_LABEL_FONT_SIZE * 1.2;
1269
+ const labelHeight = labelLineCount === 1
1270
+ ? SLOPE_LABEL_FONT_SIZE
1271
+ : labelLineCount * lineHeight;
1272
+
1273
+ return {
1274
+ item, idx, color, firstVal, lastVal, tipHtml,
1275
+ lastX, labelText, maxChars, wrappedLines, labelHeight,
1276
+ };
1277
+ });
1278
+
1279
+ // --- Resolve left-side label collisions per non-last period column ---
1280
+ const leftLabelHeight = 20; // 16px font needs ~20px to avoid glyph overlap
1281
+ const leftLabelCollisions: Map<number, number[]> = new Map();
1282
+ for (let pi = 0; pi < periods.length - 1; pi++) {
1283
+ const entries = data.map((item) => ({
1284
+ naturalY: yScale(item.values[pi]),
1285
+ height: leftLabelHeight,
1286
+ }));
1287
+ leftLabelCollisions.set(pi, resolveVerticalCollisions(entries, 4));
1288
+ }
1289
+
1290
+ // --- Resolve right-side label collisions ---
1291
+ const rightEntries = seriesInfo.map((si) => ({
1292
+ naturalY: yScale(si.lastVal),
1293
+ height: Math.max(si.labelHeight, SLOPE_LABEL_FONT_SIZE * 1.4),
1294
+ }));
1295
+ const rightAdjustedY = resolveVerticalCollisions(rightEntries, 4);
1296
+
1297
+ // Render each data series
1298
+ data.forEach((item, idx) => {
1299
+ const si = seriesInfo[idx];
1300
+ const color = si.color;
1301
+
1302
+ // Wrap each series in a group with data-line-number for sync adapter
1303
+ const seriesG = g
1304
+ .append('g')
1305
+ .attr('class', 'slope-series')
1306
+ .attr('data-line-number', String(item.lineNumber));
1307
+
1213
1308
  // Line
1214
1309
  seriesG.append('path')
1215
1310
  .datum(item.values)
@@ -1227,10 +1322,10 @@ export function renderSlopeChart(
1227
1322
  .attr('d', lineGen)
1228
1323
  .style('cursor', onClickItem ? 'pointer' : 'default')
1229
1324
  .on('mouseenter', (event: MouseEvent) =>
1230
- showTooltip(tooltip, tipHtml, event)
1325
+ showTooltip(tooltip, si.tipHtml, event)
1231
1326
  )
1232
1327
  .on('mousemove', (event: MouseEvent) =>
1233
- showTooltip(tooltip, tipHtml, event)
1328
+ showTooltip(tooltip, si.tipHtml, event)
1234
1329
  )
1235
1330
  .on('mouseleave', () => hideTooltip(tooltip))
1236
1331
  .on('click', () => {
@@ -1252,10 +1347,10 @@ export function renderSlopeChart(
1252
1347
  .attr('stroke-width', 1.5)
1253
1348
  .style('cursor', onClickItem ? 'pointer' : 'default')
1254
1349
  .on('mouseenter', (event: MouseEvent) =>
1255
- showTooltip(tooltip, tipHtml, event)
1350
+ showTooltip(tooltip, si.tipHtml, event)
1256
1351
  )
1257
1352
  .on('mousemove', (event: MouseEvent) =>
1258
- showTooltip(tooltip, tipHtml, event)
1353
+ showTooltip(tooltip, si.tipHtml, event)
1259
1354
  )
1260
1355
  .on('mouseleave', () => hideTooltip(tooltip))
1261
1356
  .on('click', () => {
@@ -1266,59 +1361,41 @@ export function renderSlopeChart(
1266
1361
  const isFirst = i === 0;
1267
1362
  const isLast = i === periods.length - 1;
1268
1363
  if (!isLast) {
1364
+ const adjustedY = leftLabelCollisions.get(i)![idx];
1269
1365
  seriesG.append('text')
1270
1366
  .attr('x', isFirst ? x - 10 : x)
1271
- .attr('y', y)
1367
+ .attr('y', adjustedY)
1272
1368
  .attr('dy', '0.35em')
1273
1369
  .attr('text-anchor', isFirst ? 'end' : 'middle')
1274
- .attr('fill', textColor)
1370
+ .attr('fill', color)
1275
1371
  .attr('font-size', '16px')
1276
1372
  .text(val.toString());
1277
1373
  }
1278
1374
  });
1279
1375
 
1280
1376
  // Series label with value at end of line — wraps if it exceeds available space
1281
- const lastX = xScale(periods[periods.length - 1])!;
1282
- const lastY = yScale(lastVal);
1283
- const labelText = `${lastVal} — ${item.label}`;
1284
- const availableWidth = rightMargin - 15;
1285
- const maxChars = Math.floor(availableWidth / SLOPE_CHAR_WIDTH);
1377
+ const adjustedLastY = rightAdjustedY[idx];
1286
1378
 
1287
1379
  const labelEl = seriesG
1288
1380
  .append('text')
1289
- .attr('x', lastX + 10)
1290
- .attr('y', lastY)
1381
+ .attr('x', si.lastX + 10)
1382
+ .attr('y', adjustedLastY)
1291
1383
  .attr('text-anchor', 'start')
1292
1384
  .attr('fill', color)
1293
1385
  .attr('font-size', `${SLOPE_LABEL_FONT_SIZE}px`)
1294
1386
  .attr('font-weight', '500');
1295
1387
 
1296
- if (labelText.length <= maxChars) {
1297
- labelEl.attr('dy', '0.35em').text(labelText);
1388
+ if (!si.wrappedLines) {
1389
+ labelEl.attr('dy', '0.35em').text(si.labelText);
1298
1390
  } else {
1299
- // Wrap into lines that fit the available width
1300
- const words = labelText.split(/\s+/);
1301
- const lines: string[] = [];
1302
- let current = '';
1303
- for (const word of words) {
1304
- const test = current ? `${current} ${word}` : word;
1305
- if (test.length > maxChars && current) {
1306
- lines.push(current);
1307
- current = word;
1308
- } else {
1309
- current = test;
1310
- }
1311
- }
1312
- if (current) lines.push(current);
1313
-
1314
1391
  const lineHeight = SLOPE_LABEL_FONT_SIZE * 1.2;
1315
- const totalHeight = (lines.length - 1) * lineHeight;
1392
+ const totalHeight = (si.wrappedLines.length - 1) * lineHeight;
1316
1393
  const startDy = -totalHeight / 2;
1317
1394
 
1318
- lines.forEach((line, li) => {
1395
+ si.wrappedLines.forEach((line, li) => {
1319
1396
  labelEl
1320
1397
  .append('tspan')
1321
- .attr('x', lastX + 10)
1398
+ .attr('x', si.lastX + 10)
1322
1399
  .attr(
1323
1400
  'dy',
1324
1401
  li === 0
package/src/echarts.ts CHANGED
@@ -88,6 +88,7 @@ import { getSeriesColors, getSegmentColors } from './palettes';
88
88
  import { parseChart } from './chart';
89
89
  import type { ParsedChart } from './chart';
90
90
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
91
+ import { collectIndentedValues } from './utils/parsing';
91
92
 
92
93
  // ============================================================
93
94
  // Parser
@@ -193,11 +194,16 @@ export function parseEChart(
193
194
  }
194
195
 
195
196
  if (key === 'series') {
196
- result.series = value;
197
- const rawNames = value
198
- .split(',')
199
- .map((s) => s.trim())
200
- .filter(Boolean);
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(', ');
206
+ }
201
207
  const names: string[] = [];
202
208
  const nameColors: (string | undefined)[] = [];
203
209
  for (const raw of rawNames) {
@@ -241,12 +247,24 @@ export function parseEChart(
241
247
 
242
248
  // Heatmap columns and rows headers
243
249
  if (key === 'columns') {
244
- result.columns = value.split(',').map((s) => s.trim());
250
+ if (value) {
251
+ result.columns = value.split(',').map((s) => s.trim());
252
+ } else {
253
+ const collected = collectIndentedValues(lines, i);
254
+ i = collected.newIndex;
255
+ result.columns = collected.values;
256
+ }
245
257
  continue;
246
258
  }
247
259
 
248
260
  if (key === 'rows') {
249
- result.rows = value.split(',').map((s) => s.trim());
261
+ if (value) {
262
+ result.rows = value.split(',').map((s) => s.trim());
263
+ } else {
264
+ const collected = collectIndentedValues(lines, i);
265
+ i = collected.newIndex;
266
+ result.rows = collected.values;
267
+ }
250
268
  continue;
251
269
  }
252
270
 
@@ -1291,21 +1309,45 @@ function makeGridAxis(
1291
1309
  gridOpacity: number,
1292
1310
  label?: string,
1293
1311
  data?: string[],
1294
- nameGapOverride?: number
1312
+ nameGapOverride?: number,
1313
+ chartWidthHint?: number
1295
1314
  ): Record<string, unknown> {
1296
1315
  const defaultGap = type === 'value' ? 75 : 40;
1316
+
1317
+ // Compute category label sizing: font size and width constraint
1318
+ let catFontSize = 16;
1319
+ let catLabelExtras: Record<string, unknown> = {};
1320
+ if (type === 'category' && data && data.length > 0) {
1321
+ const maxLabelLen = Math.max(...data.map((l) => l.length));
1322
+ const count = data.length;
1323
+ // Reduce font size based on density and label length
1324
+ if (count > 10 || maxLabelLen > 20) catFontSize = 10;
1325
+ else if (count > 5 || maxLabelLen > 14) catFontSize = 11;
1326
+ else if (maxLabelLen > 8) catFontSize = 12;
1327
+
1328
+ // Constrain labels to their allotted slot width so ECharts wraps instead of hiding
1329
+ if (chartWidthHint && count > 0) {
1330
+ const availPerLabel = Math.floor((chartWidthHint * 0.85) / count);
1331
+ catLabelExtras = {
1332
+ width: availPerLabel,
1333
+ overflow: 'break',
1334
+ };
1335
+ }
1336
+ }
1337
+
1297
1338
  return {
1298
1339
  type,
1299
1340
  ...(data && { data }),
1300
1341
  axisLine: { lineStyle: { color: axisLineColor } },
1301
1342
  axisLabel: {
1302
1343
  color: textColor,
1303
- fontSize: type === 'category' && data ? (data.length > 10 ? 11 : data.length > 5 ? 12 : 16) : 16,
1344
+ fontSize: type === 'category' && data ? catFontSize : 16,
1304
1345
  fontFamily: FONT_FAMILY,
1305
1346
  ...(type === 'category' && {
1306
1347
  interval: 0,
1307
1348
  formatter: (value: string) =>
1308
- value.replace(/([a-z])([A-Z])/g, '$1\n$2').replace(/ /g, '\n'),
1349
+ value.replace(/([a-z])([A-Z])/g, '$1\n$2'),
1350
+ ...catLabelExtras,
1309
1351
  }),
1310
1352
  },
1311
1353
  splitLine: { lineStyle: { color: splitLineColor, opacity: gridOpacity } },
@@ -1325,7 +1367,8 @@ function makeGridAxis(
1325
1367
  export function buildEChartsOptionFromChart(
1326
1368
  parsed: ParsedChart,
1327
1369
  palette: PaletteColors,
1328
- isDark: boolean
1370
+ isDark: boolean,
1371
+ chartWidth?: number
1329
1372
  ): EChartsOption {
1330
1373
  if (parsed.error) return {};
1331
1374
 
@@ -1357,15 +1400,15 @@ export function buildEChartsOptionFromChart(
1357
1400
 
1358
1401
  switch (parsed.type) {
1359
1402
  case 'bar':
1360
- return buildBarOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme);
1403
+ return buildBarOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme, chartWidth);
1361
1404
  case 'bar-stacked':
1362
- return buildBarStackedOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme);
1405
+ return buildBarStackedOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme, chartWidth);
1363
1406
  case 'line':
1364
1407
  return parsed.seriesNames
1365
- ? buildMultiLineOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme)
1366
- : buildLineOption(parsed, palette, textColor, axisLineColor, splitLineColor, gridOpacity, titleConfig, tooltipTheme);
1408
+ ? buildMultiLineOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme, chartWidth)
1409
+ : buildLineOption(parsed, palette, textColor, axisLineColor, splitLineColor, gridOpacity, titleConfig, tooltipTheme, chartWidth);
1367
1410
  case 'area':
1368
- return buildAreaOption(parsed, palette, textColor, axisLineColor, splitLineColor, gridOpacity, titleConfig, tooltipTheme);
1411
+ return buildAreaOption(parsed, palette, textColor, axisLineColor, splitLineColor, gridOpacity, titleConfig, tooltipTheme, chartWidth);
1369
1412
  case 'pie':
1370
1413
  return buildPieOption(parsed, textColor, getSegmentColors(palette, parsed.data.length), titleConfig, tooltipTheme, false);
1371
1414
  case 'doughnut':
@@ -1387,7 +1430,8 @@ function buildBarOption(
1387
1430
  gridOpacity: number,
1388
1431
  colors: string[],
1389
1432
  titleConfig: EChartsOption['title'],
1390
- tooltipTheme: Record<string, unknown>
1433
+ tooltipTheme: Record<string, unknown>,
1434
+ chartWidth?: number
1391
1435
  ): EChartsOption {
1392
1436
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1393
1437
  const isHorizontal = parsed.orientation === 'horizontal';
@@ -1402,7 +1446,7 @@ function buildBarOption(
1402
1446
  const hCatGap = isHorizontal && yLabel
1403
1447
  ? Math.max(40, Math.max(...labels.map((l) => l.length)) * 8 + 16)
1404
1448
  : undefined;
1405
- const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap);
1449
+ const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap, !isHorizontal ? chartWidth : undefined);
1406
1450
  const valueAxis = makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? xLabel : yLabel);
1407
1451
 
1408
1452
  // xAxis is always the bottom axis, yAxis is always the left axis in ECharts
@@ -1448,7 +1492,8 @@ function buildLineOption(
1448
1492
  splitLineColor: string,
1449
1493
  gridOpacity: number,
1450
1494
  titleConfig: EChartsOption['title'],
1451
- tooltipTheme: Record<string, unknown>
1495
+ tooltipTheme: Record<string, unknown>,
1496
+ chartWidth?: number
1452
1497
  ): EChartsOption {
1453
1498
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1454
1499
  const lineColor = parsed.color ?? parsed.seriesNameColors?.[0] ?? palette.primary;
@@ -1471,7 +1516,7 @@ function buildLineOption(
1471
1516
  top: parsed.title ? '15%' : '5%',
1472
1517
  containLabel: true,
1473
1518
  },
1474
- xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels),
1519
+ xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels, undefined, chartWidth),
1475
1520
  yAxis: makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, yLabel),
1476
1521
  series: [
1477
1522
  {
@@ -1500,7 +1545,8 @@ function buildMultiLineOption(
1500
1545
  gridOpacity: number,
1501
1546
  colors: string[],
1502
1547
  titleConfig: EChartsOption['title'],
1503
- tooltipTheme: Record<string, unknown>
1548
+ tooltipTheme: Record<string, unknown>,
1549
+ chartWidth?: number
1504
1550
  ): EChartsOption {
1505
1551
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1506
1552
  const seriesNames = parsed.seriesNames ?? [];
@@ -1547,7 +1593,7 @@ function buildMultiLineOption(
1547
1593
  top: parsed.title ? '15%' : '5%',
1548
1594
  containLabel: true,
1549
1595
  },
1550
- xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels),
1596
+ xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels, undefined, chartWidth),
1551
1597
  yAxis: makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, yLabel),
1552
1598
  series,
1553
1599
  };
@@ -1563,7 +1609,8 @@ function buildAreaOption(
1563
1609
  splitLineColor: string,
1564
1610
  gridOpacity: number,
1565
1611
  titleConfig: EChartsOption['title'],
1566
- tooltipTheme: Record<string, unknown>
1612
+ tooltipTheme: Record<string, unknown>,
1613
+ chartWidth?: number
1567
1614
  ): EChartsOption {
1568
1615
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1569
1616
  const lineColor = parsed.color ?? parsed.seriesNameColors?.[0] ?? palette.primary;
@@ -1586,7 +1633,7 @@ function buildAreaOption(
1586
1633
  top: parsed.title ? '15%' : '5%',
1587
1634
  containLabel: true,
1588
1635
  },
1589
- xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels),
1636
+ xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels, undefined, chartWidth),
1590
1637
  yAxis: makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, yLabel),
1591
1638
  series: [
1592
1639
  {
@@ -1790,7 +1837,8 @@ function buildBarStackedOption(
1790
1837
  gridOpacity: number,
1791
1838
  colors: string[],
1792
1839
  titleConfig: EChartsOption['title'],
1793
- tooltipTheme: Record<string, unknown>
1840
+ tooltipTheme: Record<string, unknown>,
1841
+ chartWidth?: number
1794
1842
  ): EChartsOption {
1795
1843
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1796
1844
  const isHorizontal = parsed.orientation === 'horizontal';
@@ -1827,8 +1875,11 @@ function buildBarStackedOption(
1827
1875
  const hCatGap = isHorizontal && yLabel
1828
1876
  ? Math.max(40, Math.max(...labels.map((l) => l.length)) * 8 + 16)
1829
1877
  : undefined;
1830
- const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap);
1831
- const valueAxis = makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? xLabel : yLabel);
1878
+ const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap, !isHorizontal ? chartWidth : undefined);
1879
+ // For horizontal bars with a legend, use a smaller nameGap so the xlabel
1880
+ // stays close to the axis ticks rather than drifting toward the legend.
1881
+ const hValueGap = isHorizontal && xLabel ? 40 : undefined;
1882
+ const valueAxis = makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? xLabel : yLabel, undefined, hValueGap);
1832
1883
 
1833
1884
  return {
1834
1885
  backgroundColor: 'transparent',
@@ -1901,7 +1952,7 @@ export async function renderEChartsForExport(
1901
1952
  if (chartType && STANDARD_CHART_TYPES.has(chartType)) {
1902
1953
  const parsed = parseChart(content, effectivePalette);
1903
1954
  if (parsed.error) return '';
1904
- option = buildEChartsOptionFromChart(parsed, effectivePalette, isDark);
1955
+ option = buildEChartsOptionFromChart(parsed, effectivePalette, isDark, ECHART_EXPORT_WIDTH);
1905
1956
  } else {
1906
1957
  const parsed = parseEChart(content, effectivePalette);
1907
1958
  if (parsed.error) return '';
@@ -63,6 +63,8 @@ export interface SequenceMessage {
63
63
  lineNumber: number;
64
64
  async?: boolean;
65
65
  bidirectional?: boolean;
66
+ /** Standalone return — the message itself IS a return (dashed arrow, no call). */
67
+ standaloneReturn?: boolean;
66
68
  }
67
69
 
68
70
  /**
@@ -184,10 +186,20 @@ const NOTE_MULTI = /^note(?:\s+(right|left)\s+of\s+([^\s:]+))?\s*:?\s*$/i;
184
186
  function parseReturnLabel(rawLabel: string): {
185
187
  label: string;
186
188
  returnLabel?: string;
189
+ standaloneReturn?: boolean;
187
190
  } {
188
191
  if (!rawLabel) return { label: '' };
189
192
 
190
- // Check <- syntax first
193
+ // Standalone return: label starts with `<-` (no forward label)
194
+ const standaloneMatch = rawLabel.match(/^<-\s*(.*)$/);
195
+ if (standaloneMatch) {
196
+ return {
197
+ label: standaloneMatch[1].trim(),
198
+ standaloneReturn: true,
199
+ };
200
+ }
201
+
202
+ // Check <- syntax first (separates forward label from return label)
191
203
  const arrowReturn = rawLabel.match(ARROW_RETURN_PATTERN);
192
204
  if (arrowReturn) {
193
205
  return { label: arrowReturn[1].trim(), returnLabel: arrowReturn[2].trim() };
@@ -620,8 +632,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
620
632
  const rawLabel = arrowMatch[3]?.trim() || '';
621
633
 
622
634
  // Extract return label — skip for async messages
623
- const { label, returnLabel } = isAsync
624
- ? { label: rawLabel, returnLabel: undefined }
635
+ const { label, returnLabel, standaloneReturn } = isAsync
636
+ ? { label: rawLabel, returnLabel: undefined, standaloneReturn: undefined }
625
637
  : parseReturnLabel(rawLabel);
626
638
 
627
639
  const msg: SequenceMessage = {
@@ -631,6 +643,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
631
643
  returnLabel,
632
644
  lineNumber,
633
645
  ...(isAsync ? { async: true } : {}),
646
+ ...(standaloneReturn ? { standaloneReturn: true } : {}),
634
647
  };
635
648
  result.messages.push(msg);
636
649
  currentContainer().push(msg);
@@ -571,6 +571,28 @@ export function buildRenderSequence(messages: SequenceMessage[]): RenderStep[] {
571
571
  });
572
572
  }
573
573
 
574
+ // Standalone return: emit as a return step directly (no call, no stack).
575
+ // Also pop the matching pending call from the stack so it doesn't
576
+ // generate a duplicate empty return later.
577
+ if (msg.standaloneReturn) {
578
+ // Find and remove the stack entry this return satisfies
579
+ // (the pending call where from→to matches to→from of this return)
580
+ for (let si = stack.length - 1; si >= 0; si--) {
581
+ if (stack[si].from === msg.to && stack[si].to === msg.from) {
582
+ stack.splice(si, 1);
583
+ break;
584
+ }
585
+ }
586
+ steps.push({
587
+ type: 'return',
588
+ from: msg.from,
589
+ to: msg.to,
590
+ label: msg.label,
591
+ messageIndex: mi,
592
+ });
593
+ continue;
594
+ }
595
+
574
596
  // Emit call
575
597
  steps.push({
576
598
  type: 'call',