@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
package/src/uml.test.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import type * as Scene from './scene.ts';
|
|
8
|
+
import { CLASS_DIAGRAM } from './testing.ts';
|
|
9
|
+
import { compile, isClassDiagram, parse } from './uml.ts';
|
|
10
|
+
|
|
11
|
+
const objectsOf = (commands: Scene.Command[]) =>
|
|
12
|
+
commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object] : []));
|
|
13
|
+
|
|
14
|
+
describe('uml', () => {
|
|
15
|
+
test('detects class diagrams', ({ expect }) => {
|
|
16
|
+
expect(isClassDiagram(CLASS_DIAGRAM)).toBe(true);
|
|
17
|
+
expect(isClassDiagram('flowchart TB\nA --> B')).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('parses classes, members and stereotypes', ({ expect }) => {
|
|
21
|
+
const model = parse(CLASS_DIAGRAM);
|
|
22
|
+
|
|
23
|
+
expect(model.direction).toBe('TB');
|
|
24
|
+
expect(model.classes.map((entry) => entry.id).sort()).toEqual([
|
|
25
|
+
'Animal',
|
|
26
|
+
'Bone',
|
|
27
|
+
'Dog',
|
|
28
|
+
'Leg',
|
|
29
|
+
'Owner',
|
|
30
|
+
'Serializable',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const animal = model.classes.find((entry) => entry.id === 'Animal')!;
|
|
34
|
+
expect(animal.stereotype).toBe('abstract');
|
|
35
|
+
expect(animal.attributes).toEqual(['+name: string']);
|
|
36
|
+
expect(animal.methods).toEqual(['+move() void']);
|
|
37
|
+
|
|
38
|
+
// Classes first mentioned in a relation are auto-declared, empty.
|
|
39
|
+
const bone = model.classes.find((entry) => entry.id === 'Bone')!;
|
|
40
|
+
expect(bone.attributes).toEqual([]);
|
|
41
|
+
expect(bone.methods).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('normalizes relations to subtype → supertype and whole → part', ({ expect }) => {
|
|
45
|
+
const { relations } = parse(CLASS_DIAGRAM);
|
|
46
|
+
const byKind = (kind: string) => relations.filter((relation) => relation.kind === kind);
|
|
47
|
+
|
|
48
|
+
// `Animal <|-- Dog`: the arrow renders from the subtype to its parent.
|
|
49
|
+
expect(byKind('inheritance')).toEqual([{ from: 'Dog', to: 'Animal', kind: 'inheritance' }]);
|
|
50
|
+
expect(byKind('realization')).toEqual([{ from: 'Dog', to: 'Serializable', kind: 'realization' }]);
|
|
51
|
+
expect(byKind('composition')).toEqual([{ from: 'Dog', to: 'Leg', kind: 'composition' }]);
|
|
52
|
+
expect(byKind('aggregation')).toEqual([
|
|
53
|
+
{ from: 'Owner', to: 'Dog', kind: 'aggregation', label: 'owns', fromCardinality: '1', toCardinality: '*' },
|
|
54
|
+
]);
|
|
55
|
+
expect(byKind('dependency')).toEqual([{ from: 'Dog', to: 'Bone', kind: 'dependency', label: 'chews' }]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('renders generics from tilde syntax', ({ expect }) => {
|
|
59
|
+
const model = parse(['classDiagram', 'class List~T~', 'List~T~ o-- Item'].join('\n'));
|
|
60
|
+
expect(model.classes.find((entry) => entry.id === 'List~T~')?.label).toBe('List<T>');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('compiles one object per class with compartments, plus an edges object', ({ expect }) => {
|
|
64
|
+
const objects = objectsOf(compile(CLASS_DIAGRAM));
|
|
65
|
+
|
|
66
|
+
expect(objects.map((object) => object.id).sort()).toEqual([
|
|
67
|
+
'Animal',
|
|
68
|
+
'Bone',
|
|
69
|
+
'Dog',
|
|
70
|
+
'Leg',
|
|
71
|
+
'Owner',
|
|
72
|
+
'Serializable',
|
|
73
|
+
'edges',
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const dog = objects.find((object) => object.id === 'Dog')!;
|
|
77
|
+
expect(dog.elements.map((element) => element.id)).toEqual(['title', 'attributes', 'methods']);
|
|
78
|
+
const [title, attributes, methods] = dog.elements as Scene.Box[];
|
|
79
|
+
expect(title.text).toBe('Dog');
|
|
80
|
+
expect(attributes.text).toBe('+breed: string');
|
|
81
|
+
expect(methods.text).toBe('+bark() void');
|
|
82
|
+
// Compartments stack flush: each starts where the previous one ends.
|
|
83
|
+
expect(attributes.y).toBe(title.h);
|
|
84
|
+
expect(methods.y).toBe(title.h + attributes.h);
|
|
85
|
+
expect(new Set([title.w, attributes.w, methods.w]).size).toBe(1);
|
|
86
|
+
|
|
87
|
+
// A memberless class is just its title compartment.
|
|
88
|
+
const leg = objects.find((object) => object.id === 'Leg')!;
|
|
89
|
+
expect(leg.elements.map((element) => element.id)).toEqual(['title']);
|
|
90
|
+
|
|
91
|
+
// Stereotypes render as a guillemet line above the name.
|
|
92
|
+
const serializable = objects.find((object) => object.id === 'Serializable')!;
|
|
93
|
+
expect((serializable.elements[0] as Scene.Box).text).toBe('«interface»\nSerializable');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('layers supertypes and dependency targets above, parts below', ({ expect }) => {
|
|
97
|
+
const objects = objectsOf(compile(CLASS_DIAGRAM));
|
|
98
|
+
const y = (id: string) => objects.find((object) => object.id === id)!.origin!.y;
|
|
99
|
+
|
|
100
|
+
expect(y('Animal')).toBeLessThan(y('Dog'));
|
|
101
|
+
expect(y('Serializable')).toBeLessThan(y('Dog'));
|
|
102
|
+
// Dog's parts sit below it; what it depends on sits above, so the arrow reads upward.
|
|
103
|
+
expect(y('Dog')).toBeLessThan(y('Leg'));
|
|
104
|
+
expect(y('Bone')).toBeLessThan(y('Dog'));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('binds arrows to the compartments facing the peer, with UML styling', ({ expect }) => {
|
|
108
|
+
const edges = objectsOf(compile(CLASS_DIAGRAM)).find((object) => object.id === 'edges')!;
|
|
109
|
+
const arrows = edges.elements as Scene.Arrow[];
|
|
110
|
+
|
|
111
|
+
expect(arrows.every((arrow) => arrow.kind === 'arrow')).toBe(true);
|
|
112
|
+
// Dog sits below Animal: the arrow leaves Dog's top compartment and meets Animal's bottom
|
|
113
|
+
// one, so the visible segment stays in the rank gap instead of crossing Animal's members.
|
|
114
|
+
const inheritance = arrows.find((arrow) => arrow.from === 'Dog/title' && arrow.to === 'Animal/methods')!;
|
|
115
|
+
expect(inheritance.stroke).toBeUndefined();
|
|
116
|
+
const realization = arrows.find((arrow) => arrow.to === 'Serializable/methods')!;
|
|
117
|
+
expect(realization.stroke).toBe('dashed');
|
|
118
|
+
const aggregation = arrows.find((arrow) => arrow.from === 'Owner/title')!;
|
|
119
|
+
expect(aggregation.to).toBe('Dog/title');
|
|
120
|
+
expect(aggregation.text).toBe('1 owns *');
|
|
121
|
+
// Memberless classes below Dog bind to their only compartment; Dog faces them with its last.
|
|
122
|
+
const composition = arrows.find((arrow) => arrow.to === 'Leg/title')!;
|
|
123
|
+
expect(composition.from).toBe('Dog/methods');
|
|
124
|
+
expect(composition.text).toBe('◆');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('wraps long member lines into taller compartments, capped at maxWidth', ({ expect }) => {
|
|
128
|
+
const diagram = (member: string) => ['classDiagram', 'class A {', ` +${member}`, '}'].join('\n');
|
|
129
|
+
const short = objectsOf(compile(diagram('id() string')))[0];
|
|
130
|
+
const long = objectsOf(
|
|
131
|
+
compile(diagram('reallyQuiteLongMethodName(withSomeArguments, andMoreArguments, andEvenMore) ReturnType')),
|
|
132
|
+
)[0];
|
|
133
|
+
|
|
134
|
+
const shortMethods = short.elements.find((element) => element.id === 'methods') as Scene.Box;
|
|
135
|
+
const longMethods = long.elements.find((element) => element.id === 'methods') as Scene.Box;
|
|
136
|
+
expect(longMethods.w).toBeLessThanOrEqual(400);
|
|
137
|
+
expect(longMethods.h).toBeGreaterThan(shortMethods.h);
|
|
138
|
+
|
|
139
|
+
// A tighter maxWidth narrows the box and wraps into even more lines.
|
|
140
|
+
const narrow = objectsOf(
|
|
141
|
+
compile(diagram('reallyQuiteLongMethodName(withSomeArguments, andMoreArguments, andEvenMore) ReturnType'), {
|
|
142
|
+
maxWidth: 240,
|
|
143
|
+
}),
|
|
144
|
+
)[0];
|
|
145
|
+
const narrowMethods = narrow.elements.find((element) => element.id === 'methods') as Scene.Box;
|
|
146
|
+
expect(narrowMethods.w).toBeLessThanOrEqual(240);
|
|
147
|
+
expect(narrowMethods.h).toBeGreaterThan(longMethods.h);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('gap options spread the layout', ({ expect }) => {
|
|
151
|
+
const compact = objectsOf(compile(CLASS_DIAGRAM));
|
|
152
|
+
const spread = objectsOf(compile(CLASS_DIAGRAM, { gapMain: 200, gapCross: 150 }));
|
|
153
|
+
const origin = (objects: Scene.WorldObject[], id: string) => objects.find((object) => object.id === id)!.origin!;
|
|
154
|
+
|
|
155
|
+
expect(origin(spread, 'Dog').y - origin(spread, 'Animal').y).toBeGreaterThan(
|
|
156
|
+
origin(compact, 'Dog').y - origin(compact, 'Animal').y,
|
|
157
|
+
);
|
|
158
|
+
expect(Math.abs(origin(spread, 'Serializable').x - origin(spread, 'Animal').x)).toBeGreaterThan(
|
|
159
|
+
Math.abs(origin(compact, 'Serializable').x - origin(compact, 'Animal').x),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('ignores comments and tolerates unknown syntax', ({ expect }) => {
|
|
164
|
+
const model = parse(
|
|
165
|
+
['classDiagram', '%% a comment', 'class A', 'A <|-- B', 'note for A "irrelevant"', 'style A fill:#f9f'].join(
|
|
166
|
+
'\n',
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
expect(model.classes.map((entry) => entry.id)).toEqual(['A', 'B']);
|
|
170
|
+
expect(model.relations).toEqual([{ from: 'B', to: 'A', kind: 'inheritance' }]);
|
|
171
|
+
});
|
|
172
|
+
});
|
package/src/uml.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// Mermaid class-diagram dialect: parses the subset needed for UML class diagrams (class blocks
|
|
7
|
+
// with members, stereotypes, and the standard relation arrows) and compiles it to scene commands
|
|
8
|
+
// with a layered layout — supertypes above subtypes. Mermaid carries no coordinates, so the
|
|
9
|
+
// dialect owns placement; see `dialect.ts` for the contract.
|
|
10
|
+
//
|
|
11
|
+
|
|
12
|
+
import * as Layout from './layout.ts';
|
|
13
|
+
import type * as Scene from './scene.ts';
|
|
14
|
+
|
|
15
|
+
export type Direction = 'TB' | 'BT' | 'LR' | 'RL';
|
|
16
|
+
|
|
17
|
+
export type UmlClass = {
|
|
18
|
+
id: string;
|
|
19
|
+
label: string;
|
|
20
|
+
/** e.g. "interface", "abstract", "enumeration". */
|
|
21
|
+
stereotype?: string;
|
|
22
|
+
attributes: string[];
|
|
23
|
+
methods: string[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type RelationKind = 'inheritance' | 'realization' | 'composition' | 'aggregation' | 'association' | 'dependency';
|
|
27
|
+
|
|
28
|
+
/** Rendered arrow direction: subtype → supertype, whole → part, source → target. */
|
|
29
|
+
export type UmlRelation = {
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
kind: RelationKind;
|
|
33
|
+
label?: string;
|
|
34
|
+
fromCardinality?: string;
|
|
35
|
+
toCardinality?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type UmlModel = {
|
|
39
|
+
direction: Direction;
|
|
40
|
+
classes: UmlClass[];
|
|
41
|
+
relations: UmlRelation[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const DIRECTIONS: Direction[] = ['TB', 'BT', 'LR', 'RL'];
|
|
45
|
+
|
|
46
|
+
// `class Name`, `class Name["Label"]`, `class Name {`.
|
|
47
|
+
const CLASS = /^class\s+([A-Za-z0-9_~<>-]+)(?:\s*\[\s*"(.*?)"\s*\])?\s*(\{)?\s*$/;
|
|
48
|
+
// `Name : +member` inline member declaration.
|
|
49
|
+
const INLINE_MEMBER = /^([A-Za-z0-9_~<>-]+)\s*:\s*(.+)$/;
|
|
50
|
+
const STEREOTYPE = /^<<\s*(.*?)\s*>>$/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Relation arrows, normalized to (kind, whether the LEFT side is the arrow target).
|
|
54
|
+
* `A <|-- B` reads "B inherits A": the parent is on the flat side of the triangle.
|
|
55
|
+
*/
|
|
56
|
+
const RELATIONS: [token: string, kind: RelationKind, leftIsTarget: boolean][] = [
|
|
57
|
+
['<|--', 'inheritance', true],
|
|
58
|
+
['--|>', 'inheritance', false],
|
|
59
|
+
['<|..', 'realization', true],
|
|
60
|
+
['..|>', 'realization', false],
|
|
61
|
+
['*--', 'composition', false],
|
|
62
|
+
['--*', 'composition', true],
|
|
63
|
+
['o--', 'aggregation', false],
|
|
64
|
+
['--o', 'aggregation', true],
|
|
65
|
+
['-->', 'association', false],
|
|
66
|
+
['<--', 'association', true],
|
|
67
|
+
['..>', 'dependency', false],
|
|
68
|
+
['<..', 'dependency', true],
|
|
69
|
+
['..', 'dependency', false],
|
|
70
|
+
['--', 'association', false],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// `A "1" <|-- "many" B : label` — cardinalities and the trailing label are optional.
|
|
74
|
+
const RELATION = new RegExp(
|
|
75
|
+
'^([A-Za-z0-9_~<>-]+)\\s*(?:"([^"]*)"\\s*)?' +
|
|
76
|
+
`(${RELATIONS.map(([token]) => token.replace(/[|*.>-]/g, '\\$&')).join('|')})` +
|
|
77
|
+
'\\s*(?:"([^"]*)"\\s*)?([A-Za-z0-9_~<>-]+)\\s*(?::\\s*(.+))?$',
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/** Mermaid generics use `~T~`; render them as `<T>`. */
|
|
81
|
+
const genericLabel = (id: string) => id.replace(/~([^~]*)~/g, '<$1>');
|
|
82
|
+
|
|
83
|
+
/** Parse a mermaid class diagram into a neutral model. Unrecognised lines are ignored. */
|
|
84
|
+
export const parse = (source: string): UmlModel => {
|
|
85
|
+
const classes = new Map<string, UmlClass>();
|
|
86
|
+
const relations: UmlRelation[] = [];
|
|
87
|
+
let direction: Direction = 'TB';
|
|
88
|
+
let open: UmlClass | undefined;
|
|
89
|
+
|
|
90
|
+
const declare = (id: string): UmlClass => {
|
|
91
|
+
let entry = classes.get(id);
|
|
92
|
+
if (!entry) {
|
|
93
|
+
entry = { id, label: genericLabel(id), attributes: [], methods: [] };
|
|
94
|
+
classes.set(id, entry);
|
|
95
|
+
}
|
|
96
|
+
return entry;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const addMember = (entry: UmlClass, raw: string) => {
|
|
100
|
+
const member = raw.trim();
|
|
101
|
+
const stereotype = STEREOTYPE.exec(member);
|
|
102
|
+
if (stereotype) {
|
|
103
|
+
entry.stereotype = stereotype[1];
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (member) {
|
|
107
|
+
// A parameter list marks a method; everything else is an attribute.
|
|
108
|
+
(member.includes('(') ? entry.methods : entry.attributes).push(member);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
for (const raw of source.split('\n')) {
|
|
113
|
+
const line = raw.trim();
|
|
114
|
+
if (!line || line.startsWith('%%') || /^classDiagram(-v2)?$/.test(line)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (open) {
|
|
119
|
+
if (line === '}') {
|
|
120
|
+
open = undefined;
|
|
121
|
+
} else {
|
|
122
|
+
addMember(open, line);
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const header = /^direction\s+([A-Za-z]{2})$/.exec(line);
|
|
128
|
+
if (header) {
|
|
129
|
+
const value = header[1].toUpperCase() as Direction;
|
|
130
|
+
direction = DIRECTIONS.includes(value) ? value : 'TB';
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const cls = CLASS.exec(line);
|
|
135
|
+
if (cls) {
|
|
136
|
+
const [, id, label, brace] = cls;
|
|
137
|
+
const entry = declare(id);
|
|
138
|
+
if (label) {
|
|
139
|
+
entry.label = label;
|
|
140
|
+
}
|
|
141
|
+
if (brace) {
|
|
142
|
+
open = entry;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const relation = RELATION.exec(line);
|
|
148
|
+
if (relation) {
|
|
149
|
+
const [, left, leftCardinality, token, rightCardinality, right, label] = relation;
|
|
150
|
+
declare(left);
|
|
151
|
+
declare(right);
|
|
152
|
+
const [, kind, leftIsTarget] = RELATIONS.find(([candidate]) => candidate === token)!;
|
|
153
|
+
const [from, to] = leftIsTarget ? [right, left] : [left, right];
|
|
154
|
+
const [fromCardinality, toCardinality] = leftIsTarget
|
|
155
|
+
? [rightCardinality, leftCardinality]
|
|
156
|
+
: [leftCardinality, rightCardinality];
|
|
157
|
+
relations.push({
|
|
158
|
+
from,
|
|
159
|
+
to,
|
|
160
|
+
kind,
|
|
161
|
+
...(label ? { label: label.trim() } : {}),
|
|
162
|
+
...(fromCardinality ? { fromCardinality } : {}),
|
|
163
|
+
...(toCardinality ? { toCardinality } : {}),
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const inline = INLINE_MEMBER.exec(line);
|
|
169
|
+
if (inline) {
|
|
170
|
+
const [, id, member] = inline;
|
|
171
|
+
addMember(declare(id), member);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { direction, classes: [...classes.values()], relations };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Box geometry in scene units; text sizes come from the shared per-weight metrics table
|
|
180
|
+
* (`Layout.FONT_METRICS`): the title renders at the default weight (m), members at s.
|
|
181
|
+
*/
|
|
182
|
+
const TITLE_FONT = Layout.FONT_METRICS.m;
|
|
183
|
+
const MEMBER_FONT = Layout.FONT_METRICS.s;
|
|
184
|
+
const MIN_W = 160;
|
|
185
|
+
const MAX_W = 400;
|
|
186
|
+
const PAD_X = 24;
|
|
187
|
+
const TITLE_PAD = 10;
|
|
188
|
+
const SECTION_PAD = 14;
|
|
189
|
+
const GAP_MAIN = 90;
|
|
190
|
+
const GAP_CROSS = 60;
|
|
191
|
+
|
|
192
|
+
type ClassBox = {
|
|
193
|
+
entry: UmlClass;
|
|
194
|
+
w: number;
|
|
195
|
+
h: number;
|
|
196
|
+
titleH: number;
|
|
197
|
+
attributesH: number;
|
|
198
|
+
methodsH: number;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** Compartment sizes derived from member counts and line lengths, per compartment font. */
|
|
202
|
+
const measure = (entry: UmlClass, maxWidth: number): ClassBox => {
|
|
203
|
+
const titleW = Math.max(entry.label.length, (entry.stereotype?.length ?? 0) + 2) * TITLE_FONT.charW;
|
|
204
|
+
const memberW =
|
|
205
|
+
Math.max(0, ...[...entry.attributes, ...entry.methods].map((line) => line.length)) * MEMBER_FONT.charW;
|
|
206
|
+
const w = Math.min(maxWidth, Math.max(MIN_W, Math.ceil(Math.max(titleW, memberW)) + PAD_X));
|
|
207
|
+
// Lines wider than the clamped box wrap in the renderer; count the extra lines so the
|
|
208
|
+
// compartment is tall enough and tldraw does not grow it over the one below.
|
|
209
|
+
const lines = (text: string, font: Layout.FontMetrics) =>
|
|
210
|
+
Math.max(1, Math.ceil((text.length * font.charW) / (w - PAD_X)));
|
|
211
|
+
const memberH = (members: string[]) =>
|
|
212
|
+
members.length
|
|
213
|
+
? members.reduce((sum, line) => sum + lines(line, MEMBER_FONT), 0) * MEMBER_FONT.lineH + SECTION_PAD
|
|
214
|
+
: 0;
|
|
215
|
+
const titleH = (lines(entry.label, TITLE_FONT) + (entry.stereotype ? 1 : 0)) * TITLE_FONT.lineH + TITLE_PAD;
|
|
216
|
+
const attributesH = memberH(entry.attributes);
|
|
217
|
+
const methodsH = memberH(entry.methods);
|
|
218
|
+
return { entry, w, h: titleH + attributesH + methodsH, titleH, attributesH, methodsH };
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/** Arrow styling per relation kind; dependency and realization render dashed, as in UML. */
|
|
222
|
+
export const relationStyle = (kind: RelationKind): { stroke?: Scene.Stroke } =>
|
|
223
|
+
kind === 'dependency' || kind === 'realization' ? { stroke: 'dashed' } : {};
|
|
224
|
+
|
|
225
|
+
/** Mid-arrow label: cardinalities and the diamond glyph UML puts at the whole end. */
|
|
226
|
+
export const relationText = (relation: UmlRelation): string | undefined => {
|
|
227
|
+
const glyph = relation.kind === 'composition' ? '◆' : relation.kind === 'aggregation' ? '◇' : undefined;
|
|
228
|
+
const text = [relation.fromCardinality, relation.label ?? glyph, relation.toCardinality].filter(Boolean).join(' ');
|
|
229
|
+
return text || undefined;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Rank so arrows read upward where UML convention points at abstractions — inheritance,
|
|
234
|
+
* realization, and dependency targets sit above their sources; containment and association
|
|
235
|
+
* flow downward (whole/source above part/target).
|
|
236
|
+
*/
|
|
237
|
+
export const relationRanks = (model: UmlModel): Map<string, number> =>
|
|
238
|
+
Layout.rank(
|
|
239
|
+
model.classes.map((entry) => entry.id),
|
|
240
|
+
model.relations.map((relation) =>
|
|
241
|
+
relation.kind === 'inheritance' || relation.kind === 'realization' || relation.kind === 'dependency'
|
|
242
|
+
? { from: relation.to, to: relation.from }
|
|
243
|
+
: { from: relation.from, to: relation.to },
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
export type CompileOptions = {
|
|
248
|
+
/** Canvas position of the diagram's top-left, in canvas px. */
|
|
249
|
+
origin?: Scene.Point;
|
|
250
|
+
/** Canvas px per scene unit. */
|
|
251
|
+
scale?: number;
|
|
252
|
+
/** Gap between ranks along the flow direction, in scene units (default 90). */
|
|
253
|
+
gapMain?: number;
|
|
254
|
+
/** Gap between classes within a rank, in scene units (default 60). */
|
|
255
|
+
gapCross?: number;
|
|
256
|
+
/** Maximum class-box width, in scene units (default 400); longer lines wrap. */
|
|
257
|
+
maxWidth?: number;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Compile a mermaid class diagram into scene commands: one world object per class (compartment
|
|
262
|
+
* rects `title`/`attributes`/`methods`) and an `edges` object holding the relations as bound
|
|
263
|
+
* arrows. Supertypes rank above subtypes, so inheritance reads downward.
|
|
264
|
+
*/
|
|
265
|
+
export const compile = (source: string, options: CompileOptions = {}): Scene.Command[] => {
|
|
266
|
+
const model = parse(source);
|
|
267
|
+
const { origin = { x: 0, y: 0 }, scale = 1, gapMain = GAP_MAIN, gapCross = GAP_CROSS, maxWidth = MAX_W } = options;
|
|
268
|
+
const horizontal = model.direction === 'LR' || model.direction === 'RL';
|
|
269
|
+
|
|
270
|
+
const ranks = relationRanks(model);
|
|
271
|
+
|
|
272
|
+
const boxes = new Map(model.classes.map((entry) => [entry.id, measure(entry, maxWidth)]));
|
|
273
|
+
|
|
274
|
+
// Group classes by rank, preserving declaration order within each lane.
|
|
275
|
+
const lanes = new Map<number, ClassBox[]>();
|
|
276
|
+
for (const entry of model.classes) {
|
|
277
|
+
const value = ranks.get(entry.id) ?? 0;
|
|
278
|
+
lanes.set(value, [...(lanes.get(value) ?? []), boxes.get(entry.id)!]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const cross = (box: ClassBox) => (horizontal ? box.h : box.w);
|
|
282
|
+
const main = (box: ClassBox) => (horizontal ? box.w : box.h);
|
|
283
|
+
const laneSpan = (members: ClassBox[]) =>
|
|
284
|
+
members.reduce((sum, box) => sum + cross(box), 0) + (members.length - 1) * gapCross;
|
|
285
|
+
const widest = Math.max(...[...lanes.values()].map(laneSpan), 0);
|
|
286
|
+
|
|
287
|
+
// Lanes advance along the main axis by their tallest member; members center on the widest lane.
|
|
288
|
+
const positions = new Map<string, Scene.Point>();
|
|
289
|
+
let mainOffset = 0;
|
|
290
|
+
for (const lane of [...lanes.keys()].sort((left, right) => left - right)) {
|
|
291
|
+
const members = lanes.get(lane)!;
|
|
292
|
+
let crossOffset = (widest - laneSpan(members)) / 2;
|
|
293
|
+
for (const box of members) {
|
|
294
|
+
positions.set(box.entry.id, horizontal ? { x: mainOffset, y: crossOffset } : { x: crossOffset, y: mainOffset });
|
|
295
|
+
crossOffset += cross(box) + gapCross;
|
|
296
|
+
}
|
|
297
|
+
mainOffset += Math.max(...members.map(main)) + gapMain;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Arrow terminals clip at the bound compartment, so each endpoint binds to the compartment
|
|
301
|
+
// nearest the peer's center — the arrow meets the box's outer edge instead of crossing the
|
|
302
|
+
// compartments between that edge and the title, and mid-arrow labels land in the rank gap.
|
|
303
|
+
const facing = (id: string, peerId: string): string => {
|
|
304
|
+
const box = boxes.get(id)!;
|
|
305
|
+
const peer = boxes.get(peerId)!;
|
|
306
|
+
const targetY = positions.get(peerId)!.y + peer.h / 2;
|
|
307
|
+
const baseY = positions.get(id)!.y;
|
|
308
|
+
const compartments: [elementId: string, centerY: number][] = [['title', box.titleH / 2]];
|
|
309
|
+
if (box.attributesH) {
|
|
310
|
+
compartments.push(['attributes', box.titleH + box.attributesH / 2]);
|
|
311
|
+
}
|
|
312
|
+
if (box.methodsH) {
|
|
313
|
+
compartments.push(['methods', box.titleH + box.attributesH + box.methodsH / 2]);
|
|
314
|
+
}
|
|
315
|
+
return compartments.reduce((best, candidate) =>
|
|
316
|
+
Math.abs(baseY + candidate[1] - targetY) < Math.abs(baseY + best[1] - targetY) ? candidate : best,
|
|
317
|
+
)[0];
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const commands: Scene.Command[] = [];
|
|
321
|
+
|
|
322
|
+
for (const entry of model.classes) {
|
|
323
|
+
const box = boxes.get(entry.id)!;
|
|
324
|
+
const point = positions.get(entry.id)!;
|
|
325
|
+
const title = entry.stereotype ? `«${entry.stereotype}»\n${entry.label}` : entry.label;
|
|
326
|
+
const elements: Scene.Element[] = [
|
|
327
|
+
{ kind: 'rect', id: 'title', x: 0, y: 0, w: box.w, h: box.titleH, text: title, fill: 'solid' },
|
|
328
|
+
];
|
|
329
|
+
if (box.attributesH) {
|
|
330
|
+
elements.push({
|
|
331
|
+
kind: 'rect',
|
|
332
|
+
id: 'attributes',
|
|
333
|
+
x: 0,
|
|
334
|
+
y: box.titleH,
|
|
335
|
+
w: box.w,
|
|
336
|
+
h: box.attributesH,
|
|
337
|
+
text: entry.attributes.join('\n'),
|
|
338
|
+
weight: 's',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
if (box.methodsH) {
|
|
342
|
+
elements.push({
|
|
343
|
+
kind: 'rect',
|
|
344
|
+
id: 'methods',
|
|
345
|
+
x: 0,
|
|
346
|
+
y: box.titleH + box.attributesH,
|
|
347
|
+
w: box.w,
|
|
348
|
+
h: box.methodsH,
|
|
349
|
+
text: entry.methods.join('\n'),
|
|
350
|
+
weight: 's',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
commands.push({
|
|
354
|
+
op: 'upsert-object',
|
|
355
|
+
object: {
|
|
356
|
+
id: entry.id,
|
|
357
|
+
origin: { x: origin.x + point.x * scale, y: origin.y + point.y * scale },
|
|
358
|
+
scale,
|
|
359
|
+
elements,
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (model.relations.length > 0) {
|
|
365
|
+
commands.push({
|
|
366
|
+
op: 'upsert-object',
|
|
367
|
+
object: {
|
|
368
|
+
id: 'edges',
|
|
369
|
+
origin,
|
|
370
|
+
scale,
|
|
371
|
+
elements: model.relations.map((relation, index) => {
|
|
372
|
+
const text = relationText(relation);
|
|
373
|
+
return {
|
|
374
|
+
kind: 'arrow' as const,
|
|
375
|
+
id: `${relation.from}-${relation.to}-${index}`,
|
|
376
|
+
from: `${relation.from}/${facing(relation.from, relation.to)}`,
|
|
377
|
+
to: `${relation.to}/${facing(relation.to, relation.from)}`,
|
|
378
|
+
...relationStyle(relation.kind),
|
|
379
|
+
...(text ? { text } : {}),
|
|
380
|
+
};
|
|
381
|
+
}),
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return commands;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/** True when the mermaid source declares a class diagram rather than a flowchart. */
|
|
390
|
+
export const isClassDiagram = (source: string): boolean => /^\s*classDiagram(-v2)?\b/m.test(source);
|