@diagrammo/dgmo 0.4.2 → 0.4.4
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 +28 -0
- package/.claude/skills/dgmo-generate/SKILL.md +1 -0
- package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
- package/.cursorrules +27 -2
- package/.github/copilot-instructions.md +36 -3
- package/.windsurfrules +27 -2
- package/README.md +12 -3
- package/dist/cli.cjs +197 -154
- package/dist/index.cjs +8647 -3447
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +503 -58
- package/dist/index.d.ts +503 -58
- package/dist/index.js +8379 -3200
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +1 -1
- package/docs/language-reference.md +336 -17
- package/docs/migration-sequence-color-to-tags.md +98 -0
- package/package.json +1 -1
- package/src/c4/renderer.ts +1 -20
- package/src/class/renderer.ts +1 -11
- package/src/cli.ts +40 -0
- package/src/d3.ts +92 -2
- package/src/dgmo-router.ts +11 -0
- package/src/echarts.ts +74 -8
- package/src/er/parser.ts +29 -3
- package/src/er/renderer.ts +1 -15
- package/src/graph/flowchart-parser.ts +7 -30
- package/src/graph/flowchart-renderer.ts +62 -69
- package/src/graph/layout.ts +5 -0
- package/src/graph/state-parser.ts +388 -0
- package/src/graph/state-renderer.ts +496 -0
- package/src/graph/types.ts +4 -2
- package/src/index.ts +42 -1
- package/src/infra/compute.ts +1113 -0
- package/src/infra/layout.ts +578 -0
- package/src/infra/parser.ts +559 -0
- package/src/infra/renderer.ts +1553 -0
- package/src/infra/roles.ts +60 -0
- package/src/infra/serialize.ts +67 -0
- package/src/infra/types.ts +221 -0
- package/src/infra/validation.ts +192 -0
- package/src/initiative-status/layout.ts +56 -61
- package/src/initiative-status/renderer.ts +13 -13
- package/src/kanban/renderer.ts +1 -24
- package/src/org/layout.ts +28 -37
- package/src/org/parser.ts +16 -1
- package/src/org/renderer.ts +159 -121
- package/src/org/resolver.ts +90 -23
- package/src/palettes/color-utils.ts +30 -0
- package/src/render.ts +2 -0
- package/src/sequence/parser.ts +202 -42
- package/src/sequence/renderer.ts +576 -113
- package/src/sequence/tag-resolution.ts +163 -0
- package/src/sharing.ts +8 -0
- package/src/sitemap/collapse.ts +187 -0
- package/src/sitemap/layout.ts +738 -0
- package/src/sitemap/parser.ts +489 -0
- package/src/sitemap/renderer.ts +774 -0
- package/src/sitemap/types.ts +42 -0
- package/src/utils/tag-groups.ts +119 -0
package/src/d3.ts
CHANGED
|
@@ -5159,7 +5159,7 @@ export async function renderD3ForExport(
|
|
|
5159
5159
|
activeTagGroup?: string | null;
|
|
5160
5160
|
hiddenAttributes?: Set<string>;
|
|
5161
5161
|
},
|
|
5162
|
-
options?: { branding?: boolean; c4Level?: 'context' | 'containers' | 'components' | 'deployment'; c4System?: string; c4Container?: string }
|
|
5162
|
+
options?: { branding?: boolean; c4Level?: 'context' | 'containers' | 'components' | 'deployment'; c4System?: string; c4Container?: string; scenario?: string }
|
|
5163
5163
|
): Promise<string> {
|
|
5164
5164
|
// Flowchart and org chart use their own parser pipelines — intercept before parseD3()
|
|
5165
5165
|
const { parseDgmoChartType } = await import('./dgmo-router');
|
|
@@ -5191,7 +5191,8 @@ export async function renderD3ForExport(
|
|
|
5191
5191
|
effectiveParsed,
|
|
5192
5192
|
hiddenCounts.size > 0 ? hiddenCounts : undefined,
|
|
5193
5193
|
activeTagGroup,
|
|
5194
|
-
hiddenAttributes
|
|
5194
|
+
hiddenAttributes,
|
|
5195
|
+
true // expandAllLegend — show all tag groups expanded in export
|
|
5195
5196
|
);
|
|
5196
5197
|
|
|
5197
5198
|
const PADDING = 20;
|
|
@@ -5204,6 +5205,46 @@ export async function renderD3ForExport(
|
|
|
5204
5205
|
return finalizeSvgExport(container, theme, effectivePalette, options);
|
|
5205
5206
|
}
|
|
5206
5207
|
|
|
5208
|
+
if (detectedType === 'sitemap') {
|
|
5209
|
+
const { parseSitemap } = await import('./sitemap/parser');
|
|
5210
|
+
const { layoutSitemap } = await import('./sitemap/layout');
|
|
5211
|
+
const { collapseSitemapTree } = await import('./sitemap/collapse');
|
|
5212
|
+
const { renderSitemap } = await import('./sitemap/renderer');
|
|
5213
|
+
|
|
5214
|
+
const isDark = theme === 'dark';
|
|
5215
|
+
const effectivePalette = await resolveExportPalette(theme, palette);
|
|
5216
|
+
|
|
5217
|
+
const sitemapParsed = parseSitemap(content, effectivePalette);
|
|
5218
|
+
if (sitemapParsed.error || sitemapParsed.roots.length === 0) return '';
|
|
5219
|
+
|
|
5220
|
+
// Apply interactive collapse state when provided
|
|
5221
|
+
const collapsedNodes = orgExportState?.collapsedNodes;
|
|
5222
|
+
const activeTagGroup = orgExportState?.activeTagGroup ?? null;
|
|
5223
|
+
const hiddenAttributes = orgExportState?.hiddenAttributes;
|
|
5224
|
+
|
|
5225
|
+
const { parsed: effectiveParsed, hiddenCounts } =
|
|
5226
|
+
collapsedNodes && collapsedNodes.size > 0
|
|
5227
|
+
? collapseSitemapTree(sitemapParsed, collapsedNodes)
|
|
5228
|
+
: { parsed: sitemapParsed, hiddenCounts: new Map<string, number>() };
|
|
5229
|
+
|
|
5230
|
+
const sitemapLayout = layoutSitemap(
|
|
5231
|
+
effectiveParsed,
|
|
5232
|
+
hiddenCounts.size > 0 ? hiddenCounts : undefined,
|
|
5233
|
+
activeTagGroup,
|
|
5234
|
+
hiddenAttributes,
|
|
5235
|
+
true,
|
|
5236
|
+
);
|
|
5237
|
+
|
|
5238
|
+
const PADDING = 20;
|
|
5239
|
+
const titleOffset = effectiveParsed.title ? 30 : 0;
|
|
5240
|
+
const exportWidth = sitemapLayout.width + PADDING * 2;
|
|
5241
|
+
const exportHeight = sitemapLayout.height + PADDING * 2 + titleOffset;
|
|
5242
|
+
const container = createExportContainer(exportWidth, exportHeight);
|
|
5243
|
+
|
|
5244
|
+
renderSitemap(container, effectiveParsed, sitemapLayout, effectivePalette, isDark, undefined, { width: exportWidth, height: exportHeight }, activeTagGroup, hiddenAttributes);
|
|
5245
|
+
return finalizeSvgExport(container, theme, effectivePalette, options);
|
|
5246
|
+
}
|
|
5247
|
+
|
|
5207
5248
|
if (detectedType === 'kanban') {
|
|
5208
5249
|
const { parseKanban } = await import('./kanban/parser');
|
|
5209
5250
|
const { renderKanban } = await import('./kanban/renderer');
|
|
@@ -5336,6 +5377,55 @@ export async function renderD3ForExport(
|
|
|
5336
5377
|
return finalizeSvgExport(container, theme, effectivePalette, options);
|
|
5337
5378
|
}
|
|
5338
5379
|
|
|
5380
|
+
if (detectedType === 'infra') {
|
|
5381
|
+
const { parseInfra } = await import('./infra/parser');
|
|
5382
|
+
const { computeInfra } = await import('./infra/compute');
|
|
5383
|
+
const { layoutInfra } = await import('./infra/layout');
|
|
5384
|
+
const { renderInfra, computeInfraLegendGroups } = await import('./infra/renderer');
|
|
5385
|
+
|
|
5386
|
+
const effectivePalette = await resolveExportPalette(theme, palette);
|
|
5387
|
+
const infraParsed = parseInfra(content);
|
|
5388
|
+
if (infraParsed.error || infraParsed.nodes.length === 0) return '';
|
|
5389
|
+
|
|
5390
|
+
const selectedScenario = options?.scenario
|
|
5391
|
+
? infraParsed.scenarios.find((s) => s.name.toLowerCase() === options.scenario!.toLowerCase()) ?? null
|
|
5392
|
+
: null;
|
|
5393
|
+
const infraComputed = computeInfra(infraParsed, selectedScenario ? { scenario: selectedScenario } : {});
|
|
5394
|
+
const infraLayout = layoutInfra(infraComputed);
|
|
5395
|
+
|
|
5396
|
+
const titleOffset = infraParsed.title ? 40 : 0;
|
|
5397
|
+
const legendGroups = computeInfraLegendGroups(infraLayout.nodes, infraParsed.tagGroups, effectivePalette);
|
|
5398
|
+
const legendOffset = legendGroups.length > 0 ? 28 : 0;
|
|
5399
|
+
const exportWidth = infraLayout.width;
|
|
5400
|
+
const exportHeight = infraLayout.height + titleOffset + legendOffset;
|
|
5401
|
+
const container = createExportContainer(exportWidth, exportHeight);
|
|
5402
|
+
|
|
5403
|
+
renderInfra(container, infraLayout, effectivePalette, theme === 'dark', infraParsed.title, infraParsed.titleLineNumber, infraParsed.tagGroups, null, false, null, null, true);
|
|
5404
|
+
// Restore explicit pixel dimensions for resvg (renderer uses 100%/viewBox for app scaling)
|
|
5405
|
+
const infraSvg = container.querySelector('svg');
|
|
5406
|
+
if (infraSvg) {
|
|
5407
|
+
infraSvg.setAttribute('width', String(exportWidth));
|
|
5408
|
+
infraSvg.setAttribute('height', String(exportHeight));
|
|
5409
|
+
}
|
|
5410
|
+
return finalizeSvgExport(container, theme, effectivePalette, options);
|
|
5411
|
+
}
|
|
5412
|
+
|
|
5413
|
+
if (detectedType === 'state') {
|
|
5414
|
+
const { parseState } = await import('./graph/state-parser');
|
|
5415
|
+
const { layoutGraph } = await import('./graph/layout');
|
|
5416
|
+
const { renderState } = await import('./graph/state-renderer');
|
|
5417
|
+
|
|
5418
|
+
const effectivePalette = await resolveExportPalette(theme, palette);
|
|
5419
|
+
const stateParsed = parseState(content, effectivePalette);
|
|
5420
|
+
if (stateParsed.error || stateParsed.nodes.length === 0) return '';
|
|
5421
|
+
|
|
5422
|
+
const layout = layoutGraph(stateParsed);
|
|
5423
|
+
const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);
|
|
5424
|
+
|
|
5425
|
+
renderState(container, stateParsed, layout, effectivePalette, theme === 'dark', undefined, { width: EXPORT_WIDTH, height: EXPORT_HEIGHT });
|
|
5426
|
+
return finalizeSvgExport(container, theme, effectivePalette, options);
|
|
5427
|
+
}
|
|
5428
|
+
|
|
5339
5429
|
const parsed = parseD3(content, palette);
|
|
5340
5430
|
// Allow sequence diagrams through even if parseD3 errors —
|
|
5341
5431
|
// sequence is parsed by its own dedicated parser (parseSequenceDgmo)
|
package/src/dgmo-router.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { looksLikeSequence, parseSequenceDgmo } from './sequence/parser';
|
|
6
6
|
import { looksLikeFlowchart, parseFlowchart } from './graph/flowchart-parser';
|
|
7
|
+
import { looksLikeState, parseState } from './graph/state-parser';
|
|
7
8
|
import { looksLikeClassDiagram, parseClassDiagram } from './class/parser';
|
|
8
9
|
import { looksLikeERDiagram, parseERDiagram } from './er/parser';
|
|
9
10
|
import { parseChart } from './chart';
|
|
@@ -13,6 +14,8 @@ import { parseOrg, looksLikeOrg } from './org/parser';
|
|
|
13
14
|
import { parseKanban } from './kanban/parser';
|
|
14
15
|
import { parseC4 } from './c4/parser';
|
|
15
16
|
import { looksLikeInitiativeStatus, parseInitiativeStatus } from './initiative-status/parser';
|
|
17
|
+
import { looksLikeSitemap, parseSitemap } from './sitemap/parser';
|
|
18
|
+
import { parseInfra } from './infra/parser';
|
|
16
19
|
import type { DgmoError } from './diagnostics';
|
|
17
20
|
|
|
18
21
|
/**
|
|
@@ -62,6 +65,9 @@ export const DGMO_CHART_TYPE_MAP: Record<string, DgmoFramework> = {
|
|
|
62
65
|
kanban: 'd3',
|
|
63
66
|
c4: 'd3',
|
|
64
67
|
'initiative-status': 'd3',
|
|
68
|
+
state: 'd3',
|
|
69
|
+
sitemap: 'd3',
|
|
70
|
+
infra: 'd3',
|
|
65
71
|
};
|
|
66
72
|
|
|
67
73
|
/**
|
|
@@ -94,6 +100,8 @@ export function parseDgmoChartType(content: string): string | null {
|
|
|
94
100
|
if (looksLikeClassDiagram(content)) return 'class';
|
|
95
101
|
if (looksLikeERDiagram(content)) return 'er';
|
|
96
102
|
if (looksLikeInitiativeStatus(content)) return 'initiative-status';
|
|
103
|
+
if (looksLikeState(content)) return 'state';
|
|
104
|
+
if (looksLikeSitemap(content)) return 'sitemap';
|
|
97
105
|
if (looksLikeOrg(content)) return 'org';
|
|
98
106
|
|
|
99
107
|
return null;
|
|
@@ -120,6 +128,9 @@ const PARSE_DISPATCH = new Map<string, (content: string) => { diagnostics: DgmoE
|
|
|
120
128
|
['kanban', (c) => parseKanban(c)],
|
|
121
129
|
['c4', (c) => parseC4(c)],
|
|
122
130
|
['initiative-status', (c) => parseInitiativeStatus(c)],
|
|
131
|
+
['state', (c) => parseState(c)],
|
|
132
|
+
['sitemap', (c) => parseSitemap(c)],
|
|
133
|
+
['infra', (c) => parseInfra(c)],
|
|
123
134
|
]);
|
|
124
135
|
|
|
125
136
|
/**
|
package/src/echarts.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface ParsedSankeyLink {
|
|
|
26
26
|
source: string;
|
|
27
27
|
target: string;
|
|
28
28
|
value: number;
|
|
29
|
+
color?: string;
|
|
29
30
|
lineNumber: number;
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -74,6 +75,7 @@ export interface ParsedEChart {
|
|
|
74
75
|
sizelabel?: string;
|
|
75
76
|
showLabels?: boolean;
|
|
76
77
|
categoryColors?: Record<string, string>;
|
|
78
|
+
nodeColors?: Record<string, string>;
|
|
77
79
|
diagnostics: DgmoError[];
|
|
78
80
|
error: string | null;
|
|
79
81
|
}
|
|
@@ -87,7 +89,8 @@ import { getSeriesColors, getSegmentColors } from './palettes';
|
|
|
87
89
|
import { parseChart } from './chart';
|
|
88
90
|
import type { ParsedChart } from './chart';
|
|
89
91
|
import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
|
|
90
|
-
import {
|
|
92
|
+
import { resolveColor } from './colors';
|
|
93
|
+
import { collectIndentedValues, extractColor, measureIndent, parseSeriesNames } from './utils/parsing';
|
|
91
94
|
|
|
92
95
|
// ============================================================
|
|
93
96
|
// Shared Constants
|
|
@@ -129,6 +132,9 @@ export function parseEChart(
|
|
|
129
132
|
// Track current category for grouped scatter charts
|
|
130
133
|
let currentCategory = 'Default';
|
|
131
134
|
|
|
135
|
+
// Sankey indentation state: stack of source nodes by indent level
|
|
136
|
+
const sankeyStack: { name: string; indent: number }[] = [];
|
|
137
|
+
|
|
132
138
|
for (let i = 0; i < lines.length; i++) {
|
|
133
139
|
const trimmed = lines[i].trim();
|
|
134
140
|
const lineNumber = i + 1;
|
|
@@ -160,6 +166,22 @@ export function parseEChart(
|
|
|
160
166
|
|
|
161
167
|
// Parse key: value pairs
|
|
162
168
|
const colonIndex = trimmed.indexOf(':');
|
|
169
|
+
|
|
170
|
+
// Sankey: bare label (no colon) at any indent = source node for indented children
|
|
171
|
+
if (result.type === 'sankey' && colonIndex === -1) {
|
|
172
|
+
const indent = measureIndent(lines[i]);
|
|
173
|
+
while (sankeyStack.length && sankeyStack.at(-1)!.indent >= indent) {
|
|
174
|
+
sankeyStack.pop();
|
|
175
|
+
}
|
|
176
|
+
const { label: nodeName, color: nodeColor } = extractColor(trimmed, palette);
|
|
177
|
+
if (nodeColor) {
|
|
178
|
+
if (!result.nodeColors) result.nodeColors = {};
|
|
179
|
+
result.nodeColors[nodeName] = nodeColor;
|
|
180
|
+
}
|
|
181
|
+
sankeyStack.push({ name: nodeName, indent });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
163
185
|
if (colonIndex === -1) continue;
|
|
164
186
|
|
|
165
187
|
const key = trimmed.substring(0, colonIndex).trim().toLowerCase();
|
|
@@ -264,20 +286,59 @@ export function parseEChart(
|
|
|
264
286
|
continue;
|
|
265
287
|
}
|
|
266
288
|
|
|
267
|
-
// Check for Sankey arrow syntax: Source -> Target: Value
|
|
268
|
-
const arrowMatch = trimmed.match(/^(.+?)\s*->\s*(.+?):\s*(\d+(?:\.\d+)?)
|
|
289
|
+
// Check for Sankey arrow syntax: Source (color) -> Target (color): Value (color)
|
|
290
|
+
const arrowMatch = trimmed.match(/^(.+?)\s*->\s*(.+?):\s*(\d+(?:\.\d+)?)\s*(?:\(([^)]+)\))?\s*$/);
|
|
269
291
|
if (arrowMatch) {
|
|
270
|
-
const [,
|
|
292
|
+
const [, rawSource, rawTarget, val, rawLinkColor] = arrowMatch;
|
|
293
|
+
const { label: source, color: sourceColor } = extractColor(rawSource.trim(), palette);
|
|
294
|
+
const { label: target, color: targetColor } = extractColor(rawTarget.trim(), palette);
|
|
295
|
+
if (sourceColor || targetColor) {
|
|
296
|
+
if (!result.nodeColors) result.nodeColors = {};
|
|
297
|
+
if (sourceColor) result.nodeColors[source] = sourceColor;
|
|
298
|
+
if (targetColor) result.nodeColors[target] = targetColor;
|
|
299
|
+
}
|
|
300
|
+
const linkColor = rawLinkColor ? resolveColor(rawLinkColor.trim(), palette) : undefined;
|
|
271
301
|
if (!result.links) result.links = [];
|
|
272
302
|
result.links.push({
|
|
273
|
-
source
|
|
274
|
-
target
|
|
303
|
+
source,
|
|
304
|
+
target,
|
|
275
305
|
value: parseFloat(val),
|
|
306
|
+
...(linkColor && { color: linkColor }),
|
|
276
307
|
lineNumber,
|
|
277
308
|
});
|
|
278
309
|
continue;
|
|
279
310
|
}
|
|
280
311
|
|
|
312
|
+
// Sankey: indented "Target: Value" under a source node on the indent stack
|
|
313
|
+
if (result.type === 'sankey' && sankeyStack.length > 0) {
|
|
314
|
+
const indent = measureIndent(lines[i]);
|
|
315
|
+
if (indent > 0) {
|
|
316
|
+
// Pop entries at same or deeper indent to find the parent
|
|
317
|
+
while (sankeyStack.length && sankeyStack.at(-1)!.indent >= indent) {
|
|
318
|
+
sankeyStack.pop();
|
|
319
|
+
}
|
|
320
|
+
if (sankeyStack.length > 0) {
|
|
321
|
+
const source = sankeyStack.at(-1)!.name;
|
|
322
|
+
const { label: target, color: targetColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
|
|
323
|
+
if (targetColor) {
|
|
324
|
+
if (!result.nodeColors) result.nodeColors = {};
|
|
325
|
+
result.nodeColors[target] = targetColor;
|
|
326
|
+
}
|
|
327
|
+
// Parse value with optional trailing (color) for link color
|
|
328
|
+
const valColorMatch = value.match(/^(\d+(?:\.\d+)?)\s*(?:\(([^)]+)\))?\s*$/);
|
|
329
|
+
const val = valColorMatch ? parseFloat(valColorMatch[1]) : NaN;
|
|
330
|
+
const linkColor = valColorMatch?.[2] ? resolveColor(valColorMatch[2].trim(), palette) : undefined;
|
|
331
|
+
if (!isNaN(val)) {
|
|
332
|
+
if (!result.links) result.links = [];
|
|
333
|
+
result.links.push({ source, target, value: val, ...(linkColor && { color: linkColor }), lineNumber });
|
|
334
|
+
// Push target as potential source for deeper nesting
|
|
335
|
+
sankeyStack.push({ name: target, indent });
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
281
342
|
// For function charts, treat non-numeric values as function expressions
|
|
282
343
|
if (result.type === 'function') {
|
|
283
344
|
const { label: fnName, color: fnColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
|
|
@@ -504,7 +565,7 @@ function buildSankeyOption(
|
|
|
504
565
|
const nodes = Array.from(nodeSet).map((name, index) => ({
|
|
505
566
|
name,
|
|
506
567
|
itemStyle: {
|
|
507
|
-
color: colors[index % colors.length],
|
|
568
|
+
color: parsed.nodeColors?.[name] ?? colors[index % colors.length],
|
|
508
569
|
},
|
|
509
570
|
}));
|
|
510
571
|
|
|
@@ -526,7 +587,12 @@ function buildSankeyOption(
|
|
|
526
587
|
nodeGap: 12,
|
|
527
588
|
nodeWidth: 20,
|
|
528
589
|
data: nodes,
|
|
529
|
-
links: parsed.links ?? []
|
|
590
|
+
links: (parsed.links ?? []).map(link => ({
|
|
591
|
+
source: link.source,
|
|
592
|
+
target: link.target,
|
|
593
|
+
value: link.value,
|
|
594
|
+
...(link.color && { lineStyle: { color: link.color } }),
|
|
595
|
+
})),
|
|
530
596
|
lineStyle: {
|
|
531
597
|
color: 'gradient',
|
|
532
598
|
curveness: 0.5,
|
package/src/er/parser.ts
CHANGED
|
@@ -29,6 +29,9 @@ const TABLE_DECL_RE = /^([a-zA-Z_]\w*)(?:\s+\(([^)]+)\))?\s*$/;
|
|
|
29
29
|
// Column: name: type [constraints] or name [constraints] or name: type or name
|
|
30
30
|
const COLUMN_RE = /^(\w+)(?:\s*:\s*(\w[\w()]*(?:\s*\[\])?))?(?:\s+\[([^\]]+)\])?\s*$/;
|
|
31
31
|
|
|
32
|
+
// Indented relationship: 1-* target or 1-label-* target
|
|
33
|
+
const INDENT_REL_RE = /^([1*?])-(?:(.+)-)?([1*?])\s+([a-zA-Z_]\w*)\s*$/;
|
|
34
|
+
|
|
32
35
|
// Constraint keywords
|
|
33
36
|
const CONSTRAINT_MAP: Record<string, ERConstraint> = {
|
|
34
37
|
pk: 'pk',
|
|
@@ -224,8 +227,27 @@ export function parseERDiagram(
|
|
|
224
227
|
if (!/\s/.test(key)) continue;
|
|
225
228
|
}
|
|
226
229
|
|
|
227
|
-
// Indented lines = columns of current table
|
|
230
|
+
// Indented lines = columns or relationships of current table
|
|
228
231
|
if (indent > 0 && currentTable) {
|
|
232
|
+
// Try indented relationship first: 1-* target or 1-label-* target
|
|
233
|
+
const indentRel = trimmed.match(INDENT_REL_RE);
|
|
234
|
+
if (indentRel) {
|
|
235
|
+
const fromCard = parseCardSide(indentRel[1]);
|
|
236
|
+
const toCard = parseCardSide(indentRel[3]);
|
|
237
|
+
if (fromCard && toCard) {
|
|
238
|
+
const targetName = indentRel[4];
|
|
239
|
+
getOrCreateTable(targetName, lineNumber);
|
|
240
|
+
result.relationships.push({
|
|
241
|
+
source: currentTable.id,
|
|
242
|
+
target: tableId(targetName),
|
|
243
|
+
cardinality: { from: fromCard, to: toCard },
|
|
244
|
+
...(indentRel[2]?.trim() && { label: indentRel[2].trim() }),
|
|
245
|
+
lineNumber,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
229
251
|
const colMatch = trimmed.match(COLUMN_RE);
|
|
230
252
|
if (colMatch) {
|
|
231
253
|
const colName = colMatch[1];
|
|
@@ -332,6 +354,10 @@ export function looksLikeERDiagram(content: string): boolean {
|
|
|
332
354
|
if (/\[(pk|fk)\]/i.test(trimmed)) {
|
|
333
355
|
hasConstraint = true;
|
|
334
356
|
}
|
|
357
|
+
// Indented relationship is a strong ER signal
|
|
358
|
+
if (INDENT_REL_RE.test(trimmed)) {
|
|
359
|
+
hasRelationship = true;
|
|
360
|
+
}
|
|
335
361
|
} else {
|
|
336
362
|
// Check for table-like declaration
|
|
337
363
|
if (TABLE_DECL_RE.test(trimmed)) {
|
|
@@ -347,8 +373,8 @@ export function looksLikeERDiagram(content: string): boolean {
|
|
|
347
373
|
// [pk]/[fk] constraint is a strong enough signal
|
|
348
374
|
if (hasConstraint && hasTableDecl) return true;
|
|
349
375
|
|
|
350
|
-
// Relationship with table declarations
|
|
351
|
-
if (hasRelationship && hasTableDecl
|
|
376
|
+
// Relationship with table declarations is sufficient
|
|
377
|
+
if (hasRelationship && hasTableDecl) return true;
|
|
352
378
|
|
|
353
379
|
return false;
|
|
354
380
|
}
|
package/src/er/renderer.ts
CHANGED
|
@@ -6,6 +6,7 @@ import * as d3Selection from 'd3-selection';
|
|
|
6
6
|
import * as d3Shape from 'd3-shape';
|
|
7
7
|
import { FONT_FAMILY } from '../fonts';
|
|
8
8
|
import type { PaletteColors } from '../palettes';
|
|
9
|
+
import { mix } from '../palettes/color-utils';
|
|
9
10
|
import { getSeriesColors } from '../palettes';
|
|
10
11
|
import type { ParsedERDiagram, ERConstraint } from './types';
|
|
11
12
|
import type { ERLayoutResult, ERLayoutNode, ERLayoutEdge } from './layout';
|
|
@@ -27,21 +28,6 @@ const MEMBER_LINE_HEIGHT = 18;
|
|
|
27
28
|
const COMPARTMENT_PADDING_Y = 8;
|
|
28
29
|
const MEMBER_PADDING_X = 10;
|
|
29
30
|
|
|
30
|
-
// ============================================================
|
|
31
|
-
// Color helpers
|
|
32
|
-
// ============================================================
|
|
33
|
-
|
|
34
|
-
function mix(a: string, b: string, pct: number): string {
|
|
35
|
-
const parse = (h: string) => {
|
|
36
|
-
const r = h.replace('#', '');
|
|
37
|
-
const f = r.length === 3 ? r[0]+r[0]+r[1]+r[1]+r[2]+r[2] : r;
|
|
38
|
-
return [parseInt(f.substring(0,2),16), parseInt(f.substring(2,4),16), parseInt(f.substring(4,6),16)];
|
|
39
|
-
};
|
|
40
|
-
const [ar,ag,ab] = parse(a), [br,bg,bb] = parse(b), t = pct/100;
|
|
41
|
-
const c = (x: number, y: number) => Math.round(x*t + y*(1-t)).toString(16).padStart(2,'0');
|
|
42
|
-
return `#${c(ar,br)}${c(ag,bg)}${c(ab,bb)}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
31
|
// ============================================================
|
|
46
32
|
// Constraint icons (text glyphs for resvg compat)
|
|
47
33
|
// ============================================================
|
|
@@ -6,7 +6,6 @@ import type {
|
|
|
6
6
|
ParsedGraph,
|
|
7
7
|
GraphNode,
|
|
8
8
|
GraphEdge,
|
|
9
|
-
GraphGroup,
|
|
10
9
|
GraphShape,
|
|
11
10
|
GraphDirection,
|
|
12
11
|
} from './types';
|
|
@@ -195,9 +194,9 @@ function parseArrowToken(token: string, palette?: PaletteColors): ArrowInfo {
|
|
|
195
194
|
}
|
|
196
195
|
|
|
197
196
|
// ============================================================
|
|
198
|
-
//
|
|
197
|
+
// Legacy group heading (deprecated — emit error)
|
|
199
198
|
// ============================================================
|
|
200
|
-
const
|
|
199
|
+
const LEGACY_GROUP_RE = /^##\s+/;
|
|
201
200
|
|
|
202
201
|
// ============================================================
|
|
203
202
|
// Main parser
|
|
@@ -227,8 +226,6 @@ export function parseFlowchart(
|
|
|
227
226
|
|
|
228
227
|
const nodeMap = new Map<string, GraphNode>();
|
|
229
228
|
const indentStack: { nodeId: string; indent: number }[] = [];
|
|
230
|
-
let currentGroup: GraphGroup | null = null;
|
|
231
|
-
const groups: GraphGroup[] = [];
|
|
232
229
|
let contentStarted = false;
|
|
233
230
|
|
|
234
231
|
function getOrCreateNode(ref: NodeRef, lineNumber: number): GraphNode {
|
|
@@ -241,16 +238,10 @@ export function parseFlowchart(
|
|
|
241
238
|
shape: ref.shape,
|
|
242
239
|
lineNumber,
|
|
243
240
|
...(ref.color && { color: ref.color }),
|
|
244
|
-
...(currentGroup && { group: currentGroup.id }),
|
|
245
241
|
};
|
|
246
242
|
nodeMap.set(ref.id, node);
|
|
247
243
|
result.nodes.push(node);
|
|
248
244
|
|
|
249
|
-
// Add to current group
|
|
250
|
-
if (currentGroup && !currentGroup.nodeIds.includes(ref.id)) {
|
|
251
|
-
currentGroup.nodeIds.push(ref.id);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
245
|
return node;
|
|
255
246
|
}
|
|
256
247
|
|
|
@@ -387,23 +378,11 @@ export function parseFlowchart(
|
|
|
387
378
|
// Skip comments
|
|
388
379
|
if (trimmed.startsWith('//')) continue;
|
|
389
380
|
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const groupColor = groupColorName
|
|
396
|
-
? resolveColor(groupColorName, palette)
|
|
397
|
-
: undefined;
|
|
398
|
-
|
|
399
|
-
currentGroup = {
|
|
400
|
-
id: `group:${groupLabel.toLowerCase()}`,
|
|
401
|
-
label: groupLabel,
|
|
402
|
-
nodeIds: [],
|
|
403
|
-
lineNumber,
|
|
404
|
-
...(groupColor && { color: groupColor }),
|
|
405
|
-
};
|
|
406
|
-
groups.push(currentGroup);
|
|
381
|
+
// Legacy ## group headings — no longer supported
|
|
382
|
+
if (LEGACY_GROUP_RE.test(trimmed)) {
|
|
383
|
+
result.diagnostics.push(
|
|
384
|
+
makeDgmoError(lineNumber, '## group syntax is not supported in flowcharts. Remove the ## line.', 'error')
|
|
385
|
+
);
|
|
407
386
|
continue;
|
|
408
387
|
}
|
|
409
388
|
|
|
@@ -447,8 +426,6 @@ export function parseFlowchart(
|
|
|
447
426
|
processContentLine(trimmed, lineNumber, indent);
|
|
448
427
|
}
|
|
449
428
|
|
|
450
|
-
if (groups.length > 0) result.groups = groups;
|
|
451
|
-
|
|
452
429
|
// Validation: no nodes found
|
|
453
430
|
if (result.nodes.length === 0 && !result.error) {
|
|
454
431
|
const diag = makeDgmoError(1, 'No nodes found. Add flowchart content with shape syntax like [Process] or (Start).');
|
|
@@ -6,8 +6,9 @@ import * as d3Selection from 'd3-selection';
|
|
|
6
6
|
import * as d3Shape from 'd3-shape';
|
|
7
7
|
import { FONT_FAMILY } from '../fonts';
|
|
8
8
|
import type { PaletteColors } from '../palettes';
|
|
9
|
+
import { mix } from '../palettes/color-utils';
|
|
9
10
|
import type { ParsedGraph, GraphShape } from './types';
|
|
10
|
-
import type { LayoutResult, LayoutNode, LayoutEdge
|
|
11
|
+
import type { LayoutResult, LayoutNode, LayoutEdge } from './layout';
|
|
11
12
|
import { parseFlowchart } from './flowchart-parser';
|
|
12
13
|
import { layoutGraph } from './layout';
|
|
13
14
|
|
|
@@ -19,7 +20,6 @@ const DIAGRAM_PADDING = 20;
|
|
|
19
20
|
const MAX_SCALE = 3;
|
|
20
21
|
const NODE_FONT_SIZE = 13;
|
|
21
22
|
const EDGE_LABEL_FONT_SIZE = 11;
|
|
22
|
-
const GROUP_LABEL_FONT_SIZE = 11;
|
|
23
23
|
const EDGE_STROKE_WIDTH = 1.5;
|
|
24
24
|
const NODE_STROKE_WIDTH = 1.5;
|
|
25
25
|
const ARROWHEAD_W = 10;
|
|
@@ -27,23 +27,11 @@ const ARROWHEAD_H = 7;
|
|
|
27
27
|
const IO_SKEW = 15;
|
|
28
28
|
const SUBROUTINE_INSET = 8;
|
|
29
29
|
const DOC_WAVE_HEIGHT = 10;
|
|
30
|
-
const GROUP_EXTRA_PADDING = 12;
|
|
31
30
|
|
|
32
31
|
// ============================================================
|
|
33
|
-
// Color helpers
|
|
32
|
+
// Color helpers
|
|
34
33
|
// ============================================================
|
|
35
34
|
|
|
36
|
-
function mix(a: string, b: string, pct: number): string {
|
|
37
|
-
const parse = (h: string) => {
|
|
38
|
-
const r = h.replace('#', '');
|
|
39
|
-
const f = r.length === 3 ? r[0]+r[0]+r[1]+r[1]+r[2]+r[2] : r;
|
|
40
|
-
return [parseInt(f.substring(0,2),16), parseInt(f.substring(2,4),16), parseInt(f.substring(4,6),16)];
|
|
41
|
-
};
|
|
42
|
-
const [ar,ag,ab] = parse(a), [br,bg,bb] = parse(b), t = pct/100;
|
|
43
|
-
const c = (x: number, y: number) => Math.round(x*t + y*(1-t)).toString(16).padStart(2,'0');
|
|
44
|
-
return `#${c(ar,br)}${c(ag,bg)}${c(ab,bb)}`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
35
|
function shapeDefaultColor(shape: GraphShape, palette: PaletteColors, isEndTerminal?: boolean, colorOff?: boolean): string {
|
|
48
36
|
if (colorOff) return palette.textMuted;
|
|
49
37
|
switch (shape) {
|
|
@@ -53,6 +41,7 @@ function shapeDefaultColor(shape: GraphShape, palette: PaletteColors, isEndTermi
|
|
|
53
41
|
case 'io': return palette.colors.purple;
|
|
54
42
|
case 'subroutine': return palette.colors.teal;
|
|
55
43
|
case 'document': return palette.colors.orange;
|
|
44
|
+
default: return palette.colors.blue;
|
|
56
45
|
}
|
|
57
46
|
}
|
|
58
47
|
|
|
@@ -337,48 +326,58 @@ export function renderFlowchart(
|
|
|
337
326
|
.append('g')
|
|
338
327
|
.attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
|
|
339
328
|
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
329
|
+
// Compute edge label positions with perpendicular offset to hug their path,
|
|
330
|
+
// then resolve remaining collisions.
|
|
331
|
+
const LABEL_CHAR_W = 7;
|
|
332
|
+
const LABEL_PAD = 8;
|
|
333
|
+
const LABEL_H = 16;
|
|
334
|
+
const PERP_OFFSET = 10;
|
|
335
|
+
|
|
336
|
+
interface LabelPos { x: number; y: number; w: number; h: number; edgeIdx: number }
|
|
337
|
+
const labelPositions: LabelPos[] = [];
|
|
338
|
+
|
|
339
|
+
for (let ei = 0; ei < layout.edges.length; ei++) {
|
|
340
|
+
const edge = layout.edges[ei];
|
|
341
|
+
if (!edge.label || edge.points.length < 2) continue;
|
|
342
|
+
const midIdx = Math.floor(edge.points.length / 2);
|
|
343
|
+
const midPt = edge.points[midIdx];
|
|
344
|
+
const bgW = edge.label.length * LABEL_CHAR_W + LABEL_PAD;
|
|
345
|
+
|
|
346
|
+
const prev = edge.points[Math.max(0, midIdx - 1)];
|
|
347
|
+
const next = edge.points[Math.min(edge.points.length - 1, midIdx + 1)];
|
|
348
|
+
const dx = next.x - prev.x;
|
|
349
|
+
const dy = next.y - prev.y;
|
|
350
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
351
|
+
let lx = midPt.x;
|
|
352
|
+
let ly = midPt.y;
|
|
353
|
+
if (len > 0) {
|
|
354
|
+
lx += (-dy / len) * PERP_OFFSET;
|
|
355
|
+
ly += (dx / len) * PERP_OFFSET;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
labelPositions.push({ x: lx, y: ly, w: bgW, h: LABEL_H, edgeIdx: ei });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Resolve remaining label collisions.
|
|
362
|
+
labelPositions.sort((a, b) => a.y - b.y);
|
|
363
|
+
for (let i = 0; i < labelPositions.length; i++) {
|
|
364
|
+
for (let j = i + 1; j < labelPositions.length; j++) {
|
|
365
|
+
const a = labelPositions[i];
|
|
366
|
+
const b = labelPositions[j];
|
|
367
|
+
const overlapX = Math.abs(a.x - b.x) < (a.w + b.w) / 2;
|
|
368
|
+
const overlapY = Math.abs(a.y - b.y) < (a.h + b.h) / 2;
|
|
369
|
+
if (overlapX && overlapY) {
|
|
370
|
+
b.y = a.y + (a.h + b.h) / 2 + 2;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
378
373
|
}
|
|
379
374
|
|
|
375
|
+
const labelPosMap = new Map<number, LabelPos>();
|
|
376
|
+
for (const lp of labelPositions) labelPosMap.set(lp.edgeIdx, lp);
|
|
377
|
+
|
|
380
378
|
// Render edges (middle layer)
|
|
381
|
-
for (
|
|
379
|
+
for (let ei = 0; ei < layout.edges.length; ei++) {
|
|
380
|
+
const edge = layout.edges[ei];
|
|
382
381
|
if (edge.points.length < 2) continue;
|
|
383
382
|
const edgeG = contentG
|
|
384
383
|
.append('g')
|
|
@@ -402,21 +401,15 @@ export function renderFlowchart(
|
|
|
402
401
|
.attr('class', 'fc-edge');
|
|
403
402
|
}
|
|
404
403
|
|
|
405
|
-
// Edge label
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const midPt = edge.points[midIdx];
|
|
409
|
-
|
|
410
|
-
// Background rect for legibility
|
|
411
|
-
const labelLen = edge.label.length;
|
|
412
|
-
const bgW = labelLen * 7 + 8;
|
|
413
|
-
const bgH = 16;
|
|
404
|
+
// Edge label with collision-resolved position
|
|
405
|
+
const lp = labelPosMap.get(ei);
|
|
406
|
+
if (edge.label && lp) {
|
|
414
407
|
edgeG
|
|
415
408
|
.append('rect')
|
|
416
|
-
.attr('x',
|
|
417
|
-
.attr('y',
|
|
418
|
-
.attr('width',
|
|
419
|
-
.attr('height',
|
|
409
|
+
.attr('x', lp.x - lp.w / 2)
|
|
410
|
+
.attr('y', lp.y - lp.h / 2 - 1)
|
|
411
|
+
.attr('width', lp.w)
|
|
412
|
+
.attr('height', lp.h)
|
|
420
413
|
.attr('rx', 3)
|
|
421
414
|
.attr('fill', palette.bg)
|
|
422
415
|
.attr('opacity', 0.85)
|
|
@@ -424,8 +417,8 @@ export function renderFlowchart(
|
|
|
424
417
|
|
|
425
418
|
edgeG
|
|
426
419
|
.append('text')
|
|
427
|
-
.attr('x',
|
|
428
|
-
.attr('y',
|
|
420
|
+
.attr('x', lp.x)
|
|
421
|
+
.attr('y', lp.y + 4)
|
|
429
422
|
.attr('text-anchor', 'middle')
|
|
430
423
|
.attr('fill', edgeColor)
|
|
431
424
|
.attr('font-size', EDGE_LABEL_FONT_SIZE)
|