@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,244 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { trim } from '@dxos/util';
|
|
8
|
+
|
|
9
|
+
import { analyze, errors } from './diagnostics.ts';
|
|
10
|
+
import { compile, layout } from './mermaid-engine.ts';
|
|
11
|
+
import { toStandard } from './mermaid.ts';
|
|
12
|
+
import type * as Scene from './scene.ts';
|
|
13
|
+
import { BASIC } from './testing.ts';
|
|
14
|
+
import { GRID } from './uml-grid.ts';
|
|
15
|
+
|
|
16
|
+
const objectsOf = (commands: readonly Scene.Command[]) =>
|
|
17
|
+
commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object] : []));
|
|
18
|
+
|
|
19
|
+
/** Two subgraphs, cross-group edges, a fan-in, and a back edge. */
|
|
20
|
+
const FLOWCHART = trim`
|
|
21
|
+
flowchart TB
|
|
22
|
+
subgraph client [Client]
|
|
23
|
+
App[Composer]
|
|
24
|
+
Framework[App Framework]
|
|
25
|
+
end
|
|
26
|
+
subgraph core [Core]
|
|
27
|
+
Echo[ECHO]
|
|
28
|
+
Halo[HALO]
|
|
29
|
+
Mesh[MESH]
|
|
30
|
+
end
|
|
31
|
+
Edge[EDGE]
|
|
32
|
+
App --> Framework
|
|
33
|
+
Framework --> Echo
|
|
34
|
+
Framework --> Halo
|
|
35
|
+
Echo --> Mesh
|
|
36
|
+
Halo --> Mesh
|
|
37
|
+
Mesh -->|sync| Edge
|
|
38
|
+
Edge --> Echo
|
|
39
|
+
%% ref Echo packages/core/echo
|
|
40
|
+
%% ref Edge https://github.com/dxos/edge
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
/** Runs a sweep once for the file: a candidate sweep is seconds of ELK and routing, and the tests only read its result. */
|
|
44
|
+
const once = <T>(compute: () => Promise<T>) => {
|
|
45
|
+
let promise: Promise<T> | undefined;
|
|
46
|
+
return () => (promise ??= compute());
|
|
47
|
+
};
|
|
48
|
+
const basicLayout = once(() => layout(BASIC));
|
|
49
|
+
const flowchartObjects = once(async () => objectsOf(await compile(FLOWCHART)));
|
|
50
|
+
|
|
51
|
+
// The shared sweeps land on whichever test awaits them first, on a CI runner several times slower than a laptop.
|
|
52
|
+
describe('mermaid-engine', { timeout: 120_000 }, () => {
|
|
53
|
+
test('ref directives land on the world objects they name', async ({ expect }) => {
|
|
54
|
+
const objects = await flowchartObjects();
|
|
55
|
+
|
|
56
|
+
expect(objects.find(({ id }) => id === 'Echo')?.ref).toBe('packages/core/echo');
|
|
57
|
+
expect(objects.find(({ id }) => id === 'Edge')?.ref).toBe('https://github.com/dxos/edge');
|
|
58
|
+
expect(objects.find(({ id }) => id === 'Mesh')?.ref).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('places nodes on the grid and frames enclose their members', async ({ expect }) => {
|
|
62
|
+
const objects = await flowchartObjects();
|
|
63
|
+
const nodes = objects.filter((object) => !['edges', 'client', 'core'].includes(object.id));
|
|
64
|
+
|
|
65
|
+
expect(nodes).toHaveLength(6);
|
|
66
|
+
for (const node of nodes) {
|
|
67
|
+
expect(node.origin!.x % GRID).toBe(0);
|
|
68
|
+
expect(node.origin!.y % GRID).toBe(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const frame = (id: string) => {
|
|
72
|
+
const object = objects.find((entry) => entry.id === id)!;
|
|
73
|
+
const rect = object.elements.find((element) => element.id === 'frame') as Scene.Box;
|
|
74
|
+
return { x: object.origin!.x, y: object.origin!.y, w: rect.w, h: rect.h };
|
|
75
|
+
};
|
|
76
|
+
const inside = (nodeId: string, frameId: string) => {
|
|
77
|
+
const node = objects.find((entry) => entry.id === nodeId)!;
|
|
78
|
+
const box = node.elements[0] as Scene.Box;
|
|
79
|
+
const outer = frame(frameId);
|
|
80
|
+
return (
|
|
81
|
+
node.origin!.x >= outer.x &&
|
|
82
|
+
node.origin!.y >= outer.y &&
|
|
83
|
+
node.origin!.x + box.w <= outer.x + outer.w &&
|
|
84
|
+
node.origin!.y + box.h <= outer.y + outer.h
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
expect(inside('App', 'client')).toBe(true);
|
|
88
|
+
expect(inside('Framework', 'client')).toBe(true);
|
|
89
|
+
expect(inside('Echo', 'core')).toBe(true);
|
|
90
|
+
expect(inside('Mesh', 'core')).toBe(true);
|
|
91
|
+
expect(inside('Edge', 'core')).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('layout ranks every candidate and the chosen one has the lowest feasible cost', async ({ expect }) => {
|
|
95
|
+
const result = await basicLayout();
|
|
96
|
+
|
|
97
|
+
// lattice × order × arrangement × layering × alignment × bus, less the knob settings that reach
|
|
98
|
+
// a placement already routed (three groups, so both arrangements are tried).
|
|
99
|
+
expect(result.ranked.length).toBeGreaterThanOrEqual(16);
|
|
100
|
+
const feasible = result.ranked.filter(({ evaluation }) => evaluation.violations.length === 0);
|
|
101
|
+
expect(feasible.length).toBeGreaterThan(0);
|
|
102
|
+
expect(result.chosen).toBe(result.ranked[0]);
|
|
103
|
+
expect(result.chosen.evaluation.cost).toBe(Math.min(...feasible.map(({ evaluation }) => evaluation.cost)));
|
|
104
|
+
expect(result.chosen.candidate.bus).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a labelled inheritance edge keeps its own connector and label beside the bus', async ({ expect }) => {
|
|
108
|
+
const objects = objectsOf(
|
|
109
|
+
await compile(trim`
|
|
110
|
+
flowchart TB
|
|
111
|
+
B --|> A
|
|
112
|
+
C --|> A
|
|
113
|
+
D --|>|via mixin| A
|
|
114
|
+
`),
|
|
115
|
+
);
|
|
116
|
+
const edges = objects.find(({ id }) => id === 'edges')!.elements;
|
|
117
|
+
|
|
118
|
+
expect(edges.find(({ id }) => id === 'A-bus')?.kind).toBe('line');
|
|
119
|
+
expect(edges.filter(({ id }) => /-stub-\d$/.test(id))).toHaveLength(2);
|
|
120
|
+
const labelled = edges.filter((element) => element.kind === 'text' && element.text === 'via mixin');
|
|
121
|
+
expect(labelled).toHaveLength(1);
|
|
122
|
+
expect(edges.filter((element) => element.kind === 'arrow' && element.head === 'triangle')).toHaveLength(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('toStandard rewrites the UML tokens into mermaid-legal labelled arrows', ({ expect }) => {
|
|
126
|
+
expect(toStandard(BASIC)).not.toMatch(/--\|>|--\{|o-->/);
|
|
127
|
+
expect(toStandard(' B --|> A')).toBe(' B -->|extends| A');
|
|
128
|
+
expect(toStandard('X --{ Y')).toBe('X -->|has many| Y');
|
|
129
|
+
expect(toStandard('P o--> Q')).toBe('P -->|contains| Q');
|
|
130
|
+
// A label of the edge's own survives in place of the kind's.
|
|
131
|
+
expect(toStandard('B --|>|is a| A')).toBe('B -->|is a| A');
|
|
132
|
+
expect(toStandard('X --{ |owns| Y')).toBe('X -->|owns| Y');
|
|
133
|
+
expect(toStandard('P o--> |holds| Q')).toBe('P -->|holds| Q');
|
|
134
|
+
// Plain arrows and labelled edges pass through untouched.
|
|
135
|
+
expect(toStandard('Y -->|sync| Z')).toBe('Y -->|sync| Z');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// In columns the root level is laid across the flow, so an edge between two ungrouped nodes runs
|
|
139
|
+
// across it: a straight connector, with no polyline, on the across axis.
|
|
140
|
+
for (const [direction, axis] of [
|
|
141
|
+
['TB', 'y'],
|
|
142
|
+
['LR', 'x'],
|
|
143
|
+
] as const) {
|
|
144
|
+
test(`columns: an edge between ungrouped nodes runs across the ${direction} flow`, async ({ expect }) => {
|
|
145
|
+
const { chosen } = await layout(
|
|
146
|
+
trim`
|
|
147
|
+
flowchart ${direction}
|
|
148
|
+
subgraph g1 [G1]
|
|
149
|
+
A
|
|
150
|
+
end
|
|
151
|
+
subgraph g2 [G2]
|
|
152
|
+
B
|
|
153
|
+
end
|
|
154
|
+
M --> N
|
|
155
|
+
A --> B
|
|
156
|
+
`,
|
|
157
|
+
{ arrangement: ['columns'] },
|
|
158
|
+
);
|
|
159
|
+
const edges = chosen.candidate.layout.objects.find(({ id }) => id === 'edges')!.elements;
|
|
160
|
+
const arrow = edges.find((element) => element.kind === 'arrow' && /^M-N-\d+$/.test(element.id));
|
|
161
|
+
const terminals = arrow?.kind === 'arrow' ? [arrow.start?.[axis], arrow.end?.[axis]] : [];
|
|
162
|
+
|
|
163
|
+
expect(edges.some(({ id }) => /^M-N-\d+-path$/.test(id))).toBe(false);
|
|
164
|
+
expect(terminals[0]).toBeDefined();
|
|
165
|
+
expect(terminals[0]).toBe(terminals[1]);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
test('edge tokens carry the UML end markers', async ({ expect }) => {
|
|
170
|
+
const objects = objectsOf(
|
|
171
|
+
await compile(trim`
|
|
172
|
+
flowchart TB
|
|
173
|
+
B --|> A
|
|
174
|
+
X --{ Y
|
|
175
|
+
P o--> Q
|
|
176
|
+
R --> S
|
|
177
|
+
`),
|
|
178
|
+
);
|
|
179
|
+
const arrows = objects
|
|
180
|
+
.find(({ id }) => id === 'edges')!
|
|
181
|
+
.elements.flatMap((element) => (element.kind === 'arrow' ? [element] : []));
|
|
182
|
+
const byId = (prefix: string) => arrows.find(({ id }) => id.startsWith(prefix))!;
|
|
183
|
+
|
|
184
|
+
expect(byId('B-A')).toMatchObject({ head: 'triangle' });
|
|
185
|
+
expect(byId('X-Y')).toMatchObject({ head: 'crowsfoot' });
|
|
186
|
+
expect(byId('P-Q')).toMatchObject({ tail: 'circle' });
|
|
187
|
+
expect(byId('R-S').head).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('basic: no hard defects, every connector straight or one bend', async ({ expect }) => {
|
|
191
|
+
const objects = objectsOf((await basicLayout()).commands);
|
|
192
|
+
const report = analyze(objects);
|
|
193
|
+
|
|
194
|
+
expect(errors(report).map(({ message }) => message)).toEqual([]);
|
|
195
|
+
expect(report.metrics.crossings).toBe(0);
|
|
196
|
+
// Which arrangement wins is a close call on this fixture (columns by connector length); the
|
|
197
|
+
// snapshot records it so a weight change shows up as a diff rather than a surprise.
|
|
198
|
+
const { chosen } = await basicLayout();
|
|
199
|
+
expect({
|
|
200
|
+
arrangement: chosen.candidate.arrangement,
|
|
201
|
+
cost: Number(chosen.evaluation.cost.toFixed(2)),
|
|
202
|
+
}).toMatchSnapshot();
|
|
203
|
+
// The inheritance bus wins: B and C stub up to one bus and a single triangle-headed trunk.
|
|
204
|
+
const edges = objects.find(({ id }) => id === 'edges')!.elements;
|
|
205
|
+
expect(edges.find(({ id }) => id === 'A-bus')?.kind).toBe('line');
|
|
206
|
+
expect(edges.filter(({ id }) => id.endsWith('-stub-0') || id.endsWith('-stub-1'))).toHaveLength(2);
|
|
207
|
+
expect(edges.filter((element) => element.kind === 'arrow' && element.head === 'triangle')).toHaveLength(1);
|
|
208
|
+
// Nothing else needs a second bend, and the three frames sit at equal gaps.
|
|
209
|
+
expect(report.metrics.frameGapSpread).toBe(0);
|
|
210
|
+
|
|
211
|
+
// Inheritance reads upward: the base type sits above its subtypes.
|
|
212
|
+
const originY = (id: string) => objects.find((object) => object.id === id)!.origin!.y;
|
|
213
|
+
expect(originY('A')).toBeLessThan(originY('B'));
|
|
214
|
+
expect(originY('A')).toBeLessThan(originY('C'));
|
|
215
|
+
// Ownership reads downward: the owner above what it has many of.
|
|
216
|
+
expect(originY('X')).toBeLessThan(originY('Y'));
|
|
217
|
+
expect({ crossings: report.metrics.crossings, bends: report.metrics.bends }).toMatchSnapshot();
|
|
218
|
+
|
|
219
|
+
// Packages read as separate: every pair of frames is at least a grid unit apart on some axis.
|
|
220
|
+
const frames = objects
|
|
221
|
+
.filter((object) => object.id.startsWith('pkg'))
|
|
222
|
+
.map((object) => {
|
|
223
|
+
const rect = object.elements.find((element) => element.id === 'frame') as Scene.Box;
|
|
224
|
+
return { id: object.id, x: object.origin!.x, y: object.origin!.y, w: rect.w, h: rect.h };
|
|
225
|
+
});
|
|
226
|
+
expect(frames).toHaveLength(3);
|
|
227
|
+
for (let i = 0; i < frames.length; i++) {
|
|
228
|
+
for (let j = i + 1; j < frames.length; j++) {
|
|
229
|
+
const [a, b] = [frames[i], frames[j]];
|
|
230
|
+
const gapX = Math.max(a.x - (b.x + b.w), b.x - (a.x + a.w));
|
|
231
|
+
const gapY = Math.max(a.y - (b.y + b.h), b.y - (a.y + a.h));
|
|
232
|
+
expect(Math.max(gapX, gapY), `${a.id} vs ${b.id}`).toBeGreaterThanOrEqual(GRID);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('has no hard defects and records its soft metrics', async ({ expect }) => {
|
|
238
|
+
const report = analyze(await flowchartObjects());
|
|
239
|
+
|
|
240
|
+
expect(errors(report).map(({ message }) => message)).toEqual([]);
|
|
241
|
+
expect(report.metrics.connectors).toBe(7);
|
|
242
|
+
expect({ crossings: report.metrics.crossings, bends: report.metrics.bends }).toMatchSnapshot();
|
|
243
|
+
});
|
|
244
|
+
});
|