@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.
- package/.claude/skills/dgmo-chart/SKILL.md +6 -0
- package/README.md +5 -0
- package/dist/cli.cjs +139 -140
- package/dist/index.cjs +267 -113
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +267 -113
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +35 -0
- package/package.json +1 -1
- package/src/chart.ts +12 -6
- package/src/d3.ts +129 -52
- package/src/echarts.ts +79 -28
- package/src/sequence/parser.ts +16 -3
- package/src/sequence/renderer.ts +22 -0
- package/src/utils/parsing.ts +31 -0
|
@@ -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
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
.
|
|
188
|
-
.map((s) => s.trim())
|
|
189
|
-
|
|
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*(
|
|
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
|
|
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*(
|
|
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
|
|
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
|
-
//
|
|
1192
|
-
data.
|
|
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
|
|
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',
|
|
1367
|
+
.attr('y', adjustedY)
|
|
1272
1368
|
.attr('dy', '0.35em')
|
|
1273
1369
|
.attr('text-anchor', isFirst ? 'end' : 'middle')
|
|
1274
|
-
.attr('fill',
|
|
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
|
|
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',
|
|
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 (
|
|
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 = (
|
|
1392
|
+
const totalHeight = (si.wrappedLines.length - 1) * lineHeight;
|
|
1316
1393
|
const startDy = -totalHeight / 2;
|
|
1317
1394
|
|
|
1318
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
.
|
|
199
|
-
.map((s) => s.trim())
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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')
|
|
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
|
-
|
|
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 '';
|
package/src/sequence/parser.ts
CHANGED
|
@@ -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
|
-
//
|
|
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);
|
package/src/sequence/renderer.ts
CHANGED
|
@@ -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',
|