@dxos/diagram 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/README.md +10 -0
- package/dist/lib/index.mjs +3975 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/testing.mjs +68 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/content.d.ts +60 -0
- package/dist/types/src/content.d.ts.map +1 -0
- package/dist/types/src/diagnostics.d.ts +56 -0
- package/dist/types/src/diagnostics.d.ts.map +1 -0
- package/dist/types/src/diagnostics.test.d.ts +2 -0
- package/dist/types/src/diagnostics.test.d.ts.map +1 -0
- package/dist/types/src/dialect.d.ts +19 -0
- package/dist/types/src/dialect.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +16 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/layout.d.ts +26 -0
- package/dist/types/src/layout.d.ts.map +1 -0
- package/dist/types/src/mermaid-engine.d.ts +83 -0
- package/dist/types/src/mermaid-engine.d.ts.map +1 -0
- package/dist/types/src/mermaid-engine.test.d.ts +2 -0
- package/dist/types/src/mermaid-engine.test.d.ts.map +1 -0
- package/dist/types/src/mermaid.d.ts +56 -0
- package/dist/types/src/mermaid.d.ts.map +1 -0
- package/dist/types/src/mermaid.test.d.ts +3 -0
- package/dist/types/src/mermaid.test.d.ts.map +1 -0
- package/dist/types/src/objective.d.ts +75 -0
- package/dist/types/src/objective.d.ts.map +1 -0
- package/dist/types/src/objective.test.d.ts +2 -0
- package/dist/types/src/objective.test.d.ts.map +1 -0
- package/dist/types/src/ortho-router.d.ts +13 -0
- package/dist/types/src/ortho-router.d.ts.map +1 -0
- package/dist/types/src/scene.d.ts +1006 -0
- package/dist/types/src/scene.d.ts.map +1 -0
- package/dist/types/src/scene.test.d.ts +2 -0
- package/dist/types/src/scene.test.d.ts.map +1 -0
- package/dist/types/src/svg-handler.d.ts +5 -0
- package/dist/types/src/svg-handler.d.ts.map +1 -0
- package/dist/types/src/svg-handler.test.d.ts +2 -0
- package/dist/types/src/svg-handler.test.d.ts.map +1 -0
- package/dist/types/src/testing.d.ts +11 -0
- package/dist/types/src/testing.d.ts.map +1 -0
- package/dist/types/src/ui.d.ts +67 -0
- package/dist/types/src/ui.d.ts.map +1 -0
- package/dist/types/src/ui.test.d.ts +2 -0
- package/dist/types/src/ui.test.d.ts.map +1 -0
- package/dist/types/src/uml-engine.d.ts +14 -0
- package/dist/types/src/uml-engine.d.ts.map +1 -0
- package/dist/types/src/uml-engine.test.d.ts +2 -0
- package/dist/types/src/uml-engine.test.d.ts.map +1 -0
- package/dist/types/src/uml-grid.d.ts +99 -0
- package/dist/types/src/uml-grid.d.ts.map +1 -0
- package/dist/types/src/uml-grid.test.d.ts +2 -0
- package/dist/types/src/uml-grid.test.d.ts.map +1 -0
- package/dist/types/src/uml-rules.d.ts +56 -0
- package/dist/types/src/uml-rules.d.ts.map +1 -0
- package/dist/types/src/uml-rules.test.d.ts +2 -0
- package/dist/types/src/uml-rules.test.d.ts.map +1 -0
- package/dist/types/src/uml-search.d.ts +26 -0
- package/dist/types/src/uml-search.d.ts.map +1 -0
- package/dist/types/src/uml-search.test.d.ts +2 -0
- package/dist/types/src/uml-search.test.d.ts.map +1 -0
- package/dist/types/src/uml.d.ts +60 -0
- package/dist/types/src/uml.d.ts.map +1 -0
- package/dist/types/src/uml.test.d.ts +2 -0
- package/dist/types/src/uml.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -0
- package/src/__snapshots__/diagnostics.test.ts.snap +36 -0
- package/src/__snapshots__/mermaid-engine.test.ts.snap +22 -0
- package/src/content.ts +180 -0
- package/src/diagnostics.test.ts +173 -0
- package/src/diagnostics.ts +393 -0
- package/src/dialect.ts +53 -0
- package/src/index.ts +19 -0
- package/src/layout.ts +99 -0
- package/src/mermaid-engine.test.ts +244 -0
- package/src/mermaid-engine.ts +863 -0
- package/src/mermaid.test.ts +138 -0
- package/src/mermaid.ts +326 -0
- package/src/objective.test.ts +82 -0
- package/src/objective.ts +157 -0
- package/src/ortho-router.ts +372 -0
- package/src/scene.test.ts +36 -0
- package/src/scene.ts +281 -0
- package/src/svg-handler.test.ts +75 -0
- package/src/svg-handler.ts +119 -0
- package/src/testing.ts +68 -0
- package/src/ui.test.ts +183 -0
- package/src/ui.ts +398 -0
- package/src/uml-engine.test.ts +63 -0
- package/src/uml-engine.ts +146 -0
- package/src/uml-grid.test.ts +175 -0
- package/src/uml-grid.ts +559 -0
- package/src/uml-rules.test.ts +109 -0
- package/src/uml-rules.ts +319 -0
- package/src/uml-search.test.ts +108 -0
- package/src/uml-search.ts +266 -0
- package/src/uml.test.ts +172 -0
- package/src/uml.ts +390 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { compile, parse } from './mermaid.ts';
|
|
8
|
+
import type * as Scene from './scene.ts';
|
|
9
|
+
|
|
10
|
+
/** Exercises node declarations, a labelled-blank subgraph, fan-out, and a `C <-> Y` cycle. */
|
|
11
|
+
export const FLOWCHART = `
|
|
12
|
+
flowchart TB
|
|
13
|
+
X[X]
|
|
14
|
+
|
|
15
|
+
subgraph CORE[" "]
|
|
16
|
+
A[A]
|
|
17
|
+
B[B]
|
|
18
|
+
C[C]
|
|
19
|
+
|
|
20
|
+
A --> B
|
|
21
|
+
A --> C
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Y[Y]
|
|
25
|
+
|
|
26
|
+
X --> A
|
|
27
|
+
X --> B
|
|
28
|
+
X --> C
|
|
29
|
+
C --> Y
|
|
30
|
+
Y --> C
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const objectsOf = (commands: Scene.Command[]) =>
|
|
34
|
+
commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object] : []));
|
|
35
|
+
|
|
36
|
+
describe('mermaid', () => {
|
|
37
|
+
test('parses nodes, subgraph membership and edges', ({ expect }) => {
|
|
38
|
+
const graph = parse(FLOWCHART);
|
|
39
|
+
|
|
40
|
+
expect(graph.direction).toBe('TB');
|
|
41
|
+
expect(graph.nodes.map((node) => node.id).sort()).toEqual(['A', 'B', 'C', 'X', 'Y']);
|
|
42
|
+
expect(graph.groups.map((group) => group.id)).toEqual(['CORE']);
|
|
43
|
+
expect(graph.groups[0].children.sort()).toEqual(['A', 'B', 'C']);
|
|
44
|
+
// Nodes declared outside the subgraph carry no group.
|
|
45
|
+
expect(graph.nodes.find((node) => node.id === 'X')?.group).toBeUndefined();
|
|
46
|
+
expect(graph.nodes.find((node) => node.id === 'A')?.group).toBe('CORE');
|
|
47
|
+
expect(graph.edges.map((edge) => `${edge.from}->${edge.to}`)).toEqual([
|
|
48
|
+
'A->B',
|
|
49
|
+
'A->C',
|
|
50
|
+
'X->A',
|
|
51
|
+
'X->B',
|
|
52
|
+
'X->C',
|
|
53
|
+
'C->Y',
|
|
54
|
+
'Y->C',
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('compiles to one object per node plus a frame and an edge object', ({ expect }) => {
|
|
59
|
+
const objects = objectsOf(compile(FLOWCHART));
|
|
60
|
+
|
|
61
|
+
expect(objects.map((object) => object.id)).toEqual(['CORE', 'X', 'A', 'B', 'C', 'Y', 'edges']);
|
|
62
|
+
// Every node is a single labelled box; the label is the mermaid text, not the id.
|
|
63
|
+
for (const id of ['X', 'A', 'B', 'C', 'Y']) {
|
|
64
|
+
const [element] = objects.find((object) => object.id === id)!.elements;
|
|
65
|
+
expect(element).toMatchObject({ kind: 'rect', id: 'box', text: id });
|
|
66
|
+
}
|
|
67
|
+
// The blank subgraph label yields a frame with no text.
|
|
68
|
+
const frame = objects.find((object) => object.id === 'CORE')!.elements[0];
|
|
69
|
+
expect(frame).toMatchObject({ kind: 'rect', id: 'frame', stroke: 'dashed' });
|
|
70
|
+
expect(frame).not.toHaveProperty('text');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('ranks nodes by longest path and breaks the C/Y cycle', ({ expect }) => {
|
|
74
|
+
const objects = objectsOf(compile(FLOWCHART));
|
|
75
|
+
const y = (id: string) => objects.find((object) => object.id === id)!.origin!.y;
|
|
76
|
+
|
|
77
|
+
// X feeds everything, so it leads; B and C sit past A because the longest path wins over the
|
|
78
|
+
// direct X edge. `Y --> C` is a back edge and must not push C below Y.
|
|
79
|
+
expect(y('X')).toBeLessThan(y('A'));
|
|
80
|
+
expect(y('A')).toBeLessThan(y('B'));
|
|
81
|
+
expect(y('A')).toBeLessThan(y('C'));
|
|
82
|
+
expect(y('B')).toBe(y('C'));
|
|
83
|
+
expect(y('C')).toBeLessThan(y('Y'));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('binds arrows to node boxes, including both directions of the cycle', ({ expect }) => {
|
|
87
|
+
const edges = objectsOf(compile(FLOWCHART)).find((object) => object.id === 'edges')!;
|
|
88
|
+
const refs = edges.elements.map((element) => (element.kind === 'arrow' ? `${element.from}->${element.to}` : ''));
|
|
89
|
+
|
|
90
|
+
expect(refs).toContain('C/box->Y/box');
|
|
91
|
+
expect(refs).toContain('Y/box->C/box');
|
|
92
|
+
expect(edges.elements.every((element) => element.kind === 'arrow')).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('frame encloses its members', ({ expect }) => {
|
|
96
|
+
const objects = objectsOf(compile(FLOWCHART));
|
|
97
|
+
const core = objects.find((object) => object.id === 'CORE')!;
|
|
98
|
+
const frame = core.elements[0] as Scene.Box;
|
|
99
|
+
|
|
100
|
+
for (const id of ['A', 'B', 'C']) {
|
|
101
|
+
const node = objects.find((object) => object.id === id)!;
|
|
102
|
+
expect(node.origin!.x).toBeGreaterThanOrEqual(core.origin!.x);
|
|
103
|
+
expect(node.origin!.y).toBeGreaterThanOrEqual(core.origin!.y);
|
|
104
|
+
expect(node.origin!.x).toBeLessThanOrEqual(core.origin!.x + frame.w);
|
|
105
|
+
expect(node.origin!.y).toBeLessThanOrEqual(core.origin!.y + frame.h);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('gap options spread the layout', ({ expect }) => {
|
|
110
|
+
const compact = objectsOf(compile(FLOWCHART));
|
|
111
|
+
const spread = objectsOf(compile(FLOWCHART, { gapMain: 200, gapCross: 120 }));
|
|
112
|
+
|
|
113
|
+
expect(origin(spread, 'A').y - origin(spread, 'X').y).toBeGreaterThan(
|
|
114
|
+
origin(compact, 'A').y - origin(compact, 'X').y,
|
|
115
|
+
);
|
|
116
|
+
expect(Math.abs(origin(spread, 'C').x - origin(spread, 'B').x)).toBeGreaterThan(
|
|
117
|
+
Math.abs(origin(compact, 'C').x - origin(compact, 'B').x),
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('LR flowcharts advance the main axis by node width', ({ expect }) => {
|
|
122
|
+
const objects = objectsOf(compile(['flowchart LR', 'A[A]', 'B[B]', 'A --> B'].join('\n')));
|
|
123
|
+
|
|
124
|
+
// Main-axis step = NODE_W (100) + gapMain (60); lanes share the cross axis.
|
|
125
|
+
expect(origin(objects, 'B').x - origin(objects, 'A').x).toBe(160);
|
|
126
|
+
expect(origin(objects, 'B').y).toBe(origin(objects, 'A').y);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('ignores comments and tolerates unknown syntax', ({ expect }) => {
|
|
130
|
+
const graph = parse(['flowchart LR', '%% a comment', 'A[A]', 'A --> B', 'click A callback'].join('\n'));
|
|
131
|
+
|
|
132
|
+
expect(graph.direction).toBe('LR');
|
|
133
|
+
expect(graph.nodes.map((node) => node.id)).toEqual(['A', 'B']);
|
|
134
|
+
expect(graph.edges).toEqual([{ from: 'A', to: 'B', kind: 'reference' }]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const origin = (objects: Scene.WorldObject[], id: string) => objects.find((object) => object.id === id)!.origin!;
|
package/src/mermaid.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// Mermaid flowchart dialect: parses the subset needed for block diagrams (node declarations,
|
|
7
|
+
// subgraphs, directed edges) and compiles it to scene commands with a layered layout. Mermaid
|
|
8
|
+
// carries no coordinates, so the dialect owns placement — see `dialect.ts` for the contract.
|
|
9
|
+
//
|
|
10
|
+
|
|
11
|
+
import * as Layout from './layout.ts';
|
|
12
|
+
import type * as Scene from './scene.ts';
|
|
13
|
+
|
|
14
|
+
export type Direction = 'TB' | 'BT' | 'LR' | 'RL';
|
|
15
|
+
|
|
16
|
+
export type MermaidNode = {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
/** Enclosing subgraph id, when declared inside one. */
|
|
20
|
+
group?: string;
|
|
21
|
+
/** What the node depicts (DXN or URI), from a `%% ref <id> <target>` directive. */
|
|
22
|
+
ref?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type MermaidGroup = {
|
|
26
|
+
id: string;
|
|
27
|
+
label: string;
|
|
28
|
+
children: string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Relationship kind, from the edge token; drawn with the UML end markers in `markers`. */
|
|
32
|
+
export type RelationKind = 'reference' | 'inheritance' | 'hasMany' | 'contains';
|
|
33
|
+
|
|
34
|
+
export type MermaidEdge = {
|
|
35
|
+
from: string;
|
|
36
|
+
to: string;
|
|
37
|
+
kind: RelationKind;
|
|
38
|
+
label?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Edge tokens: mermaid's own arrows read as references; the classDiagram-style `--|>` (hollow
|
|
43
|
+
* triangle) and ER-style `--{` (crow's foot) and `o-->` (circle at the source) extend the flowchart
|
|
44
|
+
* grammar with the UML kinds, at the cost of mermaid.js rejecting those lines.
|
|
45
|
+
*/
|
|
46
|
+
const EDGE_KINDS: Record<string, RelationKind> = {
|
|
47
|
+
'-->': 'reference',
|
|
48
|
+
'---': 'reference',
|
|
49
|
+
'-.->': 'reference',
|
|
50
|
+
'==>': 'reference',
|
|
51
|
+
'--|>': 'inheritance',
|
|
52
|
+
'--{': 'hasMany',
|
|
53
|
+
'o-->': 'contains',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Reference-render labels for the UML kinds, used when the edge carries none of its own. */
|
|
57
|
+
const UML_LABELS: Record<string, string> = { '--|>': 'extends', '--{': 'has many', 'o-->': 'contains' };
|
|
58
|
+
const UML_EDGE = /^(\s*\S+)\s*(o-->|--\|>|--\{)\s*(?:\|(.*?)\|\s*)?(\S+)\s*$/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Rewrite the UML edge tokens into mermaid-legal labelled arrows, for rendering the source with
|
|
62
|
+
* mermaid.js itself (as a reference beside our layout). Lossy by design: mermaid flowcharts have no
|
|
63
|
+
* triangle or crow's foot, so the kind becomes the label unless the edge already has one; `%% ref`
|
|
64
|
+
* lines are already comments.
|
|
65
|
+
*/
|
|
66
|
+
export const toStandard = (source: string): string =>
|
|
67
|
+
source
|
|
68
|
+
.split('\n')
|
|
69
|
+
.map((line) => {
|
|
70
|
+
const match = UML_EDGE.exec(line);
|
|
71
|
+
return match ? `${match[1]} -->|${match[3] ?? UML_LABELS[match[2]]}| ${match[4]}` : line;
|
|
72
|
+
})
|
|
73
|
+
.join('\n');
|
|
74
|
+
|
|
75
|
+
/** Scene arrow markers for a relationship kind. */
|
|
76
|
+
export const markers = (kind: RelationKind): Pick<Scene.Arrow, 'head' | 'tail'> => {
|
|
77
|
+
switch (kind) {
|
|
78
|
+
case 'inheritance':
|
|
79
|
+
return { head: 'triangle' };
|
|
80
|
+
case 'hasMany':
|
|
81
|
+
return { head: 'crowsfoot' };
|
|
82
|
+
case 'contains':
|
|
83
|
+
return { tail: 'circle' };
|
|
84
|
+
default:
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type MermaidGraph = {
|
|
90
|
+
direction: Direction;
|
|
91
|
+
nodes: MermaidNode[];
|
|
92
|
+
groups: MermaidGroup[];
|
|
93
|
+
edges: MermaidEdge[];
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const DIRECTIONS: Direction[] = ['TB', 'BT', 'LR', 'RL'];
|
|
97
|
+
|
|
98
|
+
// `A[Label]`, `A(Label)`, `A{Label}` or a bare `A`.
|
|
99
|
+
const NODE = /^([A-Za-z0-9_-]+)(?:\[(.*?)\]|\((.*?)\)|\{(.*?)\})?$/;
|
|
100
|
+
// `A --> B`, `A-->|label|B`, `A --- B`, plus the UML kinds `B --|> A`, `X --{ Y`, `A o--> B`.
|
|
101
|
+
const EDGE = /^(.+?)\s*(o-->|--\|>|--\{|-->|---|-\.->|==>)\s*(?:\|(.*?)\|\s*)?(.+)$/;
|
|
102
|
+
const SUBGRAPH = /^subgraph\s+([A-Za-z0-9_-]+)(?:\s*\[(.*?)\])?\s*$/;
|
|
103
|
+
|
|
104
|
+
// `%% ref A packages/core/echo` — a comment to mermaid proper, so sources stay portable.
|
|
105
|
+
const REF = /^%%\s*ref\s+([A-Za-z0-9_-]+)\s+(\S+)\s*$/;
|
|
106
|
+
|
|
107
|
+
const unquote = (value: string) => value.trim().replace(/^"(.*)"$/, '$1');
|
|
108
|
+
|
|
109
|
+
/** Parse a mermaid flowchart into a neutral graph. Unrecognised lines are ignored. */
|
|
110
|
+
export const parse = (source: string): MermaidGraph => {
|
|
111
|
+
const nodes = new Map<string, MermaidNode>();
|
|
112
|
+
const groups: MermaidGroup[] = [];
|
|
113
|
+
const edges: MermaidEdge[] = [];
|
|
114
|
+
let direction: Direction = 'TB';
|
|
115
|
+
const stack: string[] = [];
|
|
116
|
+
|
|
117
|
+
const declare = (token: string): string | undefined => {
|
|
118
|
+
const match = NODE.exec(token.trim());
|
|
119
|
+
if (!match) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const [, id, square, round, curly] = match;
|
|
123
|
+
const label = unquote(square ?? round ?? curly ?? id);
|
|
124
|
+
const group = stack[stack.length - 1];
|
|
125
|
+
const existing = nodes.get(id);
|
|
126
|
+
if (existing) {
|
|
127
|
+
// A later declaration with an explicit label wins; the first mention fixes the group.
|
|
128
|
+
if (square ?? round ?? curly) {
|
|
129
|
+
existing.label = label;
|
|
130
|
+
}
|
|
131
|
+
return id;
|
|
132
|
+
}
|
|
133
|
+
nodes.set(id, { id, label, ...(group ? { group } : {}) });
|
|
134
|
+
if (group) {
|
|
135
|
+
groups.find((entry) => entry.id === group)?.children.push(id);
|
|
136
|
+
}
|
|
137
|
+
return id;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const refs = new Map<string, string>();
|
|
141
|
+
for (const raw of source.split('\n')) {
|
|
142
|
+
const line = raw.trim();
|
|
143
|
+
const ref = REF.exec(line);
|
|
144
|
+
if (ref) {
|
|
145
|
+
refs.set(ref[1], ref[2]);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!line || line.startsWith('%%')) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const header = /^(?:flowchart|graph)\s+([A-Za-z]{2})$/.exec(line);
|
|
153
|
+
if (header) {
|
|
154
|
+
const value = header[1].toUpperCase() as Direction;
|
|
155
|
+
direction = DIRECTIONS.includes(value) ? value : 'TB';
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const subgraph = SUBGRAPH.exec(line);
|
|
160
|
+
if (subgraph) {
|
|
161
|
+
const [, id, label] = subgraph;
|
|
162
|
+
groups.push({ id, label: unquote(label ?? id), children: [] });
|
|
163
|
+
stack.push(id);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (line === 'end') {
|
|
168
|
+
stack.pop();
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const edge = EDGE.exec(line);
|
|
173
|
+
if (edge) {
|
|
174
|
+
const [, from, token, label, to] = edge;
|
|
175
|
+
const fromId = declare(from);
|
|
176
|
+
const toId = declare(to);
|
|
177
|
+
if (fromId && toId) {
|
|
178
|
+
edges.push({
|
|
179
|
+
from: fromId,
|
|
180
|
+
to: toId,
|
|
181
|
+
kind: EDGE_KINDS[token] ?? 'reference',
|
|
182
|
+
...(label ? { label: unquote(label) } : {}),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
declare(line);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Directives may precede or follow the node they name.
|
|
192
|
+
return {
|
|
193
|
+
direction,
|
|
194
|
+
nodes: [...nodes.values()].map((node) => (refs.has(node.id) ? { ...node, ref: refs.get(node.id) } : node)),
|
|
195
|
+
groups,
|
|
196
|
+
edges,
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** Layout constants in scene units. */
|
|
201
|
+
const NODE_W = 100;
|
|
202
|
+
const NODE_H = 50;
|
|
203
|
+
const GAP_MAIN = 60;
|
|
204
|
+
const GAP_CROSS = 40;
|
|
205
|
+
const GROUP_PAD = 24;
|
|
206
|
+
|
|
207
|
+
/** Assign each node a rank one past its deepest forward predecessor. */
|
|
208
|
+
const rank = (graph: MermaidGraph): Map<string, number> =>
|
|
209
|
+
Layout.rank(
|
|
210
|
+
graph.nodes.map((node) => node.id),
|
|
211
|
+
graph.edges,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
export type CompileOptions = {
|
|
215
|
+
/** Canvas position of the diagram's top-left, in canvas px. */
|
|
216
|
+
origin?: Scene.Point;
|
|
217
|
+
/** Canvas px per scene unit. */
|
|
218
|
+
scale?: number;
|
|
219
|
+
/** Gap between ranks along the flow direction, in scene units (default 60). */
|
|
220
|
+
gapMain?: number;
|
|
221
|
+
/** Gap between nodes within a rank, in scene units (default 40). */
|
|
222
|
+
gapCross?: number;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Compile a mermaid flowchart into scene commands: one world object per node (element `box`),
|
|
227
|
+
* one per subgraph frame, and an `edges` object holding the connectors as bound arrows.
|
|
228
|
+
*/
|
|
229
|
+
export const compile = (source: string, options: CompileOptions = {}): Scene.Command[] => {
|
|
230
|
+
const graph = parse(source);
|
|
231
|
+
const { origin = { x: 0, y: 0 }, scale = 1, gapMain = GAP_MAIN, gapCross = GAP_CROSS } = options;
|
|
232
|
+
const ranks = rank(graph);
|
|
233
|
+
const horizontal = graph.direction === 'LR' || graph.direction === 'RL';
|
|
234
|
+
|
|
235
|
+
// Group nodes by rank, preserving declaration order within each.
|
|
236
|
+
const lanes = new Map<number, MermaidNode[]>();
|
|
237
|
+
for (const node of graph.nodes) {
|
|
238
|
+
const value = ranks.get(node.id) ?? 0;
|
|
239
|
+
lanes.set(value, [...(lanes.get(value) ?? []), node]);
|
|
240
|
+
}
|
|
241
|
+
const widest = Math.max(...[...lanes.values()].map((lane) => lane.length), 1);
|
|
242
|
+
|
|
243
|
+
// Axis-specific node sizes: for LR/RL the main axis advances by width and lanes stack by height.
|
|
244
|
+
const mainSize = horizontal ? NODE_W : NODE_H;
|
|
245
|
+
const crossSize = horizontal ? NODE_H : NODE_W;
|
|
246
|
+
const positions = new Map<string, Scene.Point>();
|
|
247
|
+
for (const [lane, members] of lanes) {
|
|
248
|
+
const span = members.length * crossSize + (members.length - 1) * gapCross;
|
|
249
|
+
const offset = (widest * crossSize + (widest - 1) * gapCross - span) / 2;
|
|
250
|
+
members.forEach((node, index) => {
|
|
251
|
+
const cross = offset + index * (crossSize + gapCross);
|
|
252
|
+
const main = lane * (mainSize + gapMain);
|
|
253
|
+
positions.set(node.id, horizontal ? { x: main, y: cross } : { x: cross, y: main });
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const commands: Scene.Command[] = [];
|
|
258
|
+
|
|
259
|
+
// Frames first so nodes paint above them.
|
|
260
|
+
for (const group of graph.groups) {
|
|
261
|
+
const members = group.children.map((id) => positions.get(id)).filter((point): point is Scene.Point => !!point);
|
|
262
|
+
if (members.length === 0) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const minX = Math.min(...members.map((point) => point.x)) - GROUP_PAD;
|
|
266
|
+
const minY = Math.min(...members.map((point) => point.y)) - GROUP_PAD;
|
|
267
|
+
const maxX = Math.max(...members.map((point) => point.x)) + NODE_W + GROUP_PAD;
|
|
268
|
+
const maxY = Math.max(...members.map((point) => point.y)) + NODE_H + GROUP_PAD;
|
|
269
|
+
commands.push({
|
|
270
|
+
op: 'upsert-object',
|
|
271
|
+
object: {
|
|
272
|
+
id: group.id,
|
|
273
|
+
origin: { x: origin.x + minX * scale, y: origin.y + minY * scale },
|
|
274
|
+
scale,
|
|
275
|
+
elements: [
|
|
276
|
+
{
|
|
277
|
+
kind: 'rect',
|
|
278
|
+
id: 'frame',
|
|
279
|
+
x: 0,
|
|
280
|
+
y: 0,
|
|
281
|
+
w: maxX - minX,
|
|
282
|
+
h: maxY - minY,
|
|
283
|
+
stroke: 'dashed',
|
|
284
|
+
color: 'grey',
|
|
285
|
+
...(group.label.trim() ? { text: group.label } : {}),
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const node of graph.nodes) {
|
|
293
|
+
const point = positions.get(node.id)!;
|
|
294
|
+
commands.push({
|
|
295
|
+
op: 'upsert-object',
|
|
296
|
+
object: {
|
|
297
|
+
id: node.id,
|
|
298
|
+
origin: { x: origin.x + point.x * scale, y: origin.y + point.y * scale },
|
|
299
|
+
scale,
|
|
300
|
+
...(node.ref ? { ref: node.ref } : {}),
|
|
301
|
+
elements: [{ kind: 'rect', id: 'box', x: 0, y: 0, w: NODE_W, h: NODE_H, text: node.label }],
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (graph.edges.length > 0) {
|
|
307
|
+
commands.push({
|
|
308
|
+
op: 'upsert-object',
|
|
309
|
+
object: {
|
|
310
|
+
id: 'edges',
|
|
311
|
+
origin,
|
|
312
|
+
scale,
|
|
313
|
+
elements: graph.edges.map((edge) => ({
|
|
314
|
+
kind: 'arrow' as const,
|
|
315
|
+
id: `${edge.from}-${edge.to}`,
|
|
316
|
+
from: `${edge.from}/box`,
|
|
317
|
+
to: `${edge.to}/box`,
|
|
318
|
+
...markers(edge.kind),
|
|
319
|
+
...(edge.label ? { text: edge.label } : {}),
|
|
320
|
+
})),
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return commands;
|
|
326
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { analyze } from './diagnostics.ts';
|
|
8
|
+
import { DEFAULT, type Layout, evaluate, framesApart, select } from './objective.ts';
|
|
9
|
+
import type * as Scene from './scene.ts';
|
|
10
|
+
import { GRID } from './uml-grid.ts';
|
|
11
|
+
|
|
12
|
+
const box = (id: string, x: number, y: number, w = 64, h = 64): Scene.WorldObject => ({
|
|
13
|
+
id,
|
|
14
|
+
origin: { x, y },
|
|
15
|
+
elements: [{ kind: 'rect', id: 'frame', x: 0, y: 0, w, h }],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const layoutOf = (objects: Scene.WorldObject[]): Layout => ({ objects, report: analyze(objects) });
|
|
19
|
+
|
|
20
|
+
describe('objective', () => {
|
|
21
|
+
test('a clean layout has no violations and a cost from the soft terms only', ({ expect }) => {
|
|
22
|
+
const layout = layoutOf([
|
|
23
|
+
box('a', 0, 0),
|
|
24
|
+
box('b', 0, 200),
|
|
25
|
+
{ id: 'edges', elements: [{ kind: 'arrow', id: 'a-b', start: { x: 32, y: 64 }, end: { x: 32, y: 200 } }] },
|
|
26
|
+
]);
|
|
27
|
+
const evaluation = evaluate(DEFAULT, layout);
|
|
28
|
+
|
|
29
|
+
expect(evaluation.violations).toEqual([]);
|
|
30
|
+
expect(evaluation.terms.map(({ id }) => id)).toEqual([
|
|
31
|
+
'crossings',
|
|
32
|
+
'bends',
|
|
33
|
+
'connector-length',
|
|
34
|
+
'uneven-frame-gaps',
|
|
35
|
+
'compactness',
|
|
36
|
+
]);
|
|
37
|
+
expect(evaluation.cost).toBeGreaterThan(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('overlapping nodes violate the hard-defect constraint', ({ expect }) => {
|
|
41
|
+
const evaluation = evaluate(DEFAULT, layoutOf([box('a', 0, 0), box('b', 32, 32)]));
|
|
42
|
+
|
|
43
|
+
expect(evaluation.violations.map(({ constraint }) => constraint)).toEqual(['no-hard-defects']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('frames closer than the gap violate frames-apart; uneven gaps cost', ({ expect }) => {
|
|
47
|
+
// Three frames in a row, each enclosing a node so they count as containers.
|
|
48
|
+
const frames = (gap1: number, gap2: number) => {
|
|
49
|
+
const x1 = 0;
|
|
50
|
+
const x2 = x1 + 128 + gap1;
|
|
51
|
+
const x3 = x2 + 128 + gap2;
|
|
52
|
+
return layoutOf([
|
|
53
|
+
box('f1', x1, 0, 128, 128),
|
|
54
|
+
box('n1', x1 + 32, 32, 32, 32),
|
|
55
|
+
box('f2', x2, 0, 128, 128),
|
|
56
|
+
box('n2', x2 + 32, 32, 32, 32),
|
|
57
|
+
box('f3', x3, 0, 128, 128),
|
|
58
|
+
box('n3', x3 + 32, 32, 32, 32),
|
|
59
|
+
]);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
expect(framesApart(GRID).violations(frames(GRID / 2, GRID))).toHaveLength(1);
|
|
63
|
+
expect(framesApart(GRID).violations(frames(GRID, GRID))).toEqual([]);
|
|
64
|
+
|
|
65
|
+
const even = evaluate(DEFAULT, frames(GRID, GRID));
|
|
66
|
+
const uneven = evaluate(DEFAULT, frames(GRID, GRID * 3));
|
|
67
|
+
expect(even.terms.find(({ id }) => id === 'uneven-frame-gaps')?.value).toBe(0);
|
|
68
|
+
expect(uneven.terms.find(({ id }) => id === 'uneven-frame-gaps')?.value).toBe(2);
|
|
69
|
+
expect(uneven.cost).toBeGreaterThan(even.cost);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('select prefers feasibility over cost, then lowest cost', ({ expect }) => {
|
|
73
|
+
const feasibleCostly = { name: 'feasible-costly', layout: layoutOf([box('a', 0, 0), box('b', 0, 2000)]) };
|
|
74
|
+
const feasibleTight = { name: 'feasible-tight', layout: layoutOf([box('a', 0, 0), box('b', 0, 200)]) };
|
|
75
|
+
const infeasible = { name: 'infeasible', layout: layoutOf([box('a', 0, 0), box('b', 16, 16)]) };
|
|
76
|
+
|
|
77
|
+
const { chosen, ranked } = select(DEFAULT, [infeasible, feasibleCostly, feasibleTight]);
|
|
78
|
+
|
|
79
|
+
expect(chosen.candidate.name).toBe('feasible-tight');
|
|
80
|
+
expect(ranked.map(({ candidate }) => candidate.name)).toEqual(['feasible-tight', 'feasible-costly', 'infeasible']);
|
|
81
|
+
});
|
|
82
|
+
});
|