@yalehwang/plantuml-js 0.2.2 → 0.3.0

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.
Files changed (64) hide show
  1. package/README.md +20 -0
  2. package/dist/core.d.ts +102 -9
  3. package/dist/core.js +91 -51
  4. package/dist/foundation/layers.d.ts +25 -0
  5. package/dist/foundation/layers.js +101 -0
  6. package/dist/foundation/layout.d.ts +116 -0
  7. package/dist/foundation/layout.js +298 -0
  8. package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
  9. package/dist/foundation/relation-semantics.d.ts +49 -0
  10. package/dist/foundation/relation-semantics.js +112 -0
  11. package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
  12. package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/layout/compound.d.ts +59 -0
  16. package/dist/layout/compound.js +1178 -0
  17. package/dist/layout/elk-graph.d.ts +36 -0
  18. package/dist/layout/elk-graph.js +220 -0
  19. package/dist/layout/elk-result.d.ts +18 -0
  20. package/dist/layout/elk-result.js +127 -0
  21. package/dist/layout/elk.d.ts +63 -33
  22. package/dist/layout/elk.js +215 -321
  23. package/dist/layout/fanout-trunk.d.ts +18 -0
  24. package/dist/layout/fanout-trunk.js +380 -0
  25. package/dist/layout/grid-router.d.ts +69 -0
  26. package/dist/layout/grid-router.js +615 -0
  27. package/dist/layout/lane-spread.d.ts +11 -0
  28. package/dist/layout/lane-spread.js +302 -0
  29. package/dist/layout/layout-metrics.d.ts +183 -0
  30. package/dist/layout/layout-metrics.js +766 -0
  31. package/dist/layout/package-endpoints.d.ts +10 -0
  32. package/dist/layout/package-endpoints.js +54 -0
  33. package/dist/layout/placement-strategy.d.ts +98 -0
  34. package/dist/layout/placement-strategy.js +26 -0
  35. package/dist/layout/strategy.d.ts +27 -0
  36. package/dist/layout/strategy.js +19 -0
  37. package/dist/layout/trunk-lanes.d.ts +29 -0
  38. package/dist/layout/trunk-lanes.js +308 -0
  39. package/dist/layout/viz.d.ts +3 -3
  40. package/dist/layout/viz.js +13 -17
  41. package/dist/parser.d.ts +27 -1
  42. package/dist/parser.js +111 -5
  43. package/dist/render/labels.d.ts +44 -0
  44. package/dist/render/labels.js +231 -0
  45. package/dist/render/legend.d.ts +15 -0
  46. package/dist/render/legend.js +42 -0
  47. package/dist/render/svg.d.ts +60 -3
  48. package/dist/render/svg.js +326 -98
  49. package/dist/render/theme.d.ts +109 -0
  50. package/dist/render/theme.js +99 -0
  51. package/dist/serialize.d.ts +32 -0
  52. package/dist/serialize.js +218 -0
  53. package/dist/transform.d.ts +55 -1
  54. package/dist/transform.js +277 -29
  55. package/package.json +4 -3
  56. package/dist/layout-metrics.d.ts +0 -42
  57. package/dist/layout-metrics.js +0 -241
  58. package/dist/layout.d.ts +0 -75
  59. package/dist/layout.js +0 -51
  60. /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
  61. /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
  62. /package/dist/{model.js → foundation/model.js} +0 -0
  63. /package/dist/{render-dot.d.ts → foundation/render-dot.d.ts} +0 -0
  64. /package/dist/{render-dot.js → foundation/render-dot.js} +0 -0
@@ -0,0 +1,298 @@
1
+ /**
2
+ * LayoutEngine — the pluggable layout contract. A LayoutEngine is a pure
3
+ * function from PlantUmlModel to concrete positions; the parser never talks
4
+ * to a layout engine and the renderer never talks to the parser, so swapping
5
+ * engines (elkjs, @viz-js/viz, a custom tuner) is an injection, not a rewrite.
6
+ */
7
+ import { measureTextWidth, MONOSPACE_FONT_SIZE } from './text-measure.js';
8
+ /**
9
+ * Shared drawing metrics. The renderer reads these so entity sizing and the
10
+ * emitted SVG stay in lockstep: the font family and size are bound to the same
11
+ * monospace advance [[../text-measure]] measures with.
12
+ */
13
+ export const METRICS = {
14
+ fontFamily: 'monospace',
15
+ fontSize: MONOSPACE_FONT_SIZE,
16
+ lineHeight: 18,
17
+ paddingX: 10,
18
+ paddingY: 8,
19
+ minWidth: 60,
20
+ };
21
+ /**
22
+ * Package label-tab geometry, shared by the renderer and the layout-metrics
23
+ * overlap counter so a drawn tab and a measured tab are the same rectangle.
24
+ */
25
+ export const PACKAGE_LABEL = {
26
+ tabHeight: 20,
27
+ fontSize: 12,
28
+ tabPadding: 8,
29
+ };
30
+ /** Spacing grid (px). Entity box sizes snap up to a multiple of it. */
31
+ export const GRID = 8;
32
+ export function snapUp(n, grid = GRID) {
33
+ return Math.ceil(n / grid) * grid;
34
+ }
35
+ /** Reconstruct the name drawn in the title compartment, generics expanded. */
36
+ export function displayName(entity) {
37
+ const generics = entity.generics;
38
+ if (!generics || generics.length === 0)
39
+ return entity.name;
40
+ return `${entity.name}<${generics.join(', ')}>`;
41
+ }
42
+ /**
43
+ * Approximate the on-canvas size of an entity box. The title plus every member
44
+ * line contributes one row; the width is the longest rendered line measured
45
+ * through the text table plus padding, never below MIN_WIDTH.
46
+ */
47
+ export function estimateEntitySize(entity) {
48
+ const lines = [
49
+ displayName(entity),
50
+ ...entity.members.map((m) => m.raw),
51
+ ];
52
+ const longest = lines.reduce((max, line) => Math.max(max, measureTextWidth(line, METRICS.fontSize)), 0);
53
+ const width = snapUp(Math.max(METRICS.minWidth, longest + METRICS.paddingX * 2));
54
+ const height = snapUp(lines.length * METRICS.lineHeight + METRICS.paddingY * 2);
55
+ return { width, height };
56
+ }
57
+ /** Entity count up to which `full` is chosen automatically. */
58
+ export const DETAIL_FULL_MAX_ENTITIES = 40;
59
+ /** Entity count up to which `compact` is chosen automatically (beyond: `names`). */
60
+ export const DETAIL_COMPACT_MAX_ENTITIES = 150;
61
+ /** Member rows kept per entity in `compact` mode. */
62
+ export const COMPACT_MEMBER_ROWS = 4;
63
+ export function autoDetail(entityCount) {
64
+ if (entityCount <= DETAIL_FULL_MAX_ENTITIES)
65
+ return 'full';
66
+ if (entityCount <= DETAIL_COMPACT_MAX_ENTITIES)
67
+ return 'compact';
68
+ return 'names';
69
+ }
70
+ /**
71
+ * Reduce member rows to the requested detail level *before* layout, so boxes
72
+ * are sized for what is actually drawn. `undefined` picks by entity count.
73
+ */
74
+ export function applyDetail(model, detail) {
75
+ const level = detail ?? autoDetail(model.entities.length);
76
+ if (level === 'full')
77
+ return model;
78
+ return {
79
+ ...model,
80
+ entities: model.entities.map((entity) => {
81
+ const total = entity.members.length;
82
+ if (level === 'names')
83
+ return total === 0 ? entity : { ...entity, members: [] };
84
+ if (total <= COMPACT_MEMBER_ROWS)
85
+ return entity;
86
+ const kept = entity.members.slice(0, COMPACT_MEMBER_ROWS);
87
+ const more = {
88
+ visibility: 'public',
89
+ name: '…',
90
+ kind: 'field',
91
+ modifiers: [],
92
+ raw: `… +${total - COMPACT_MEMBER_ROWS} more (${total} members)`,
93
+ };
94
+ return { ...entity, members: [...kept, more] };
95
+ }),
96
+ };
97
+ }
98
+ /**
99
+ * Group nodes into rows: within one package (or the top level), nodes whose
100
+ * vertical extents overlap belong to the same layered-layout rank. Returns
101
+ * arrays of node ids, each sorted top-to-bottom.
102
+ */
103
+ export function layoutRows(model, layout) {
104
+ const pkgOf = new Map(model.entities.map((e) => [e.id, e.packageId ?? '']));
105
+ const groups = new Map();
106
+ for (const node of layout.nodes) {
107
+ const key = pkgOf.get(node.id) ?? '';
108
+ const list = groups.get(key);
109
+ if (list)
110
+ list.push(node);
111
+ else
112
+ groups.set(key, [node]);
113
+ }
114
+ const rows = [];
115
+ for (const list of groups.values()) {
116
+ const sorted = list.slice().sort((a, b) => a.y - b.y || a.x - b.x);
117
+ let row = [];
118
+ let bottom = -Infinity;
119
+ for (const node of sorted) {
120
+ if (row.length > 0 && node.y >= bottom) {
121
+ rows.push(row);
122
+ row = [];
123
+ bottom = -Infinity;
124
+ }
125
+ row.push(node);
126
+ bottom = Math.max(bottom, node.y + node.height);
127
+ }
128
+ if (row.length > 0)
129
+ rows.push(row);
130
+ }
131
+ return rows;
132
+ }
133
+ /**
134
+ * Top-align the nodes of every row (never moving a node down or out of its
135
+ * package) and keep edges attached: an endpoint on a moved node shifts with it,
136
+ * dragging along the next bend when the first segment is horizontal, so
137
+ * orthogonal routes stay orthogonal. Mutates and returns `layout`.
138
+ */
139
+ export function alignRows(model, layout) {
140
+ if (model.entities.length > ALIGN_MAX_ENTITIES)
141
+ return layout;
142
+ const edges = layout.edges ?? [];
143
+ for (const row of layoutRows(model, layout)) {
144
+ if (row.length < 2)
145
+ continue;
146
+ const top = Math.min(...row.map((n) => n.y));
147
+ for (const node of row) {
148
+ const dy = top - node.y;
149
+ if (dy === 0)
150
+ continue;
151
+ // Moving up must not run the box into another node or a foreign edge.
152
+ const next = { x: node.x, y: top, width: node.width, height: node.height };
153
+ if (layout.nodes.some((o) => o !== node && boxesOverlap(next, o)))
154
+ continue;
155
+ if (edges.some((e) => e.source !== node.id && e.target !== node.id && polylineHitsBox(e.points, next))) {
156
+ continue;
157
+ }
158
+ // Re-attach the node's own edges; refuse the move if any orthogonal
159
+ // edge would turn diagonal.
160
+ const patched = new Map();
161
+ let ok = true;
162
+ for (const edge of edges) {
163
+ if (edge.source !== node.id && edge.target !== node.id)
164
+ continue;
165
+ const pts = edge.points.map((p) => ({ ...p }));
166
+ if (pts.length < 2)
167
+ continue;
168
+ const wasOrthogonal = isOrthogonal(edge.points);
169
+ const ends = [
170
+ [edge.source, 0, 1],
171
+ [edge.target, pts.length - 1, pts.length - 2],
172
+ ];
173
+ for (const [id, i, nxt] of ends) {
174
+ if (id !== node.id)
175
+ continue;
176
+ const horizontal = Math.abs(pts[i].y - pts[nxt].y) < 0.01;
177
+ if (horizontal) {
178
+ // A side port still inside the moved box's span needs no change.
179
+ if (pts[i].y > top && pts[i].y < top + node.height)
180
+ continue;
181
+ pts[i].y += dy;
182
+ if (pts.length > 2)
183
+ pts[nxt].y += dy;
184
+ }
185
+ else {
186
+ pts[i].y += dy;
187
+ }
188
+ }
189
+ if (wasOrthogonal && !isOrthogonal(pts)) {
190
+ ok = false;
191
+ break;
192
+ }
193
+ patched.set(edge, pts);
194
+ }
195
+ if (!ok)
196
+ continue;
197
+ node.y = top;
198
+ for (const [edge, pts] of patched)
199
+ edge.points = pts;
200
+ }
201
+ }
202
+ return layout;
203
+ }
204
+ /**
205
+ * Top-align an isolated package frame (no edge touches any entity inside it)
206
+ * with the topmost side-by-side neighbour frame, so a lone package sits level
207
+ * with its neighbour instead of floating at its own offset. Only moves up, and
208
+ * only when the frame (with its contents) stays clear of everything else.
209
+ * Mutates and returns `layout`.
210
+ */
211
+ export function alignIsolatedFrames(model, layout) {
212
+ const frames = layout.packages ?? [];
213
+ const roots = model.packages.filter((p) => p.parentId === undefined);
214
+ if (roots.length < 2 || frames.length === 0)
215
+ return layout;
216
+ const parentOf = new Map(model.packages.map((p) => [p.id, p.parentId]));
217
+ const rootOf = (id) => {
218
+ let cur = id;
219
+ while (cur !== undefined && parentOf.get(cur) !== undefined)
220
+ cur = parentOf.get(cur);
221
+ return cur;
222
+ };
223
+ const entityRoot = new Map(model.entities.map((e) => [e.id, rootOf(e.packageId)]));
224
+ const touched = new Set();
225
+ for (const r of model.relations) {
226
+ for (const id of [r.source, r.target]) {
227
+ const root = entityRoot.get(id);
228
+ if (root !== undefined)
229
+ touched.add(root);
230
+ }
231
+ if (r.sourcePackageId)
232
+ touched.add(rootOf(r.sourcePackageId));
233
+ if (r.targetPackageId)
234
+ touched.add(rootOf(r.targetPackageId));
235
+ }
236
+ const byId = new Map(frames.map((f) => [f.id, f]));
237
+ const rootFrames = roots.map((r) => byId.get(r.id)).filter((f) => f !== undefined);
238
+ for (const frame of rootFrames) {
239
+ if (touched.has(frame.id))
240
+ continue;
241
+ const neighbours = rootFrames.filter((g) => g !== frame && g.y < frame.y && g.y + g.height > frame.y &&
242
+ (g.x + g.width <= frame.x || frame.x + frame.width <= g.x));
243
+ if (neighbours.length === 0)
244
+ continue;
245
+ const top = Math.min(...neighbours.map((g) => g.y));
246
+ const dy = top - frame.y;
247
+ const inFrame = (pid) => rootOf(pid) === frame.id;
248
+ const memberNodes = layout.nodes.filter((n) => inFrame(model.entities.find((e) => e.id === n.id)?.packageId));
249
+ const memberFrames = frames.filter((f) => inFrame(f.id));
250
+ const moved = { x: frame.x, y: top, width: frame.width, height: frame.height };
251
+ const others = [
252
+ ...rootFrames.filter((g) => g !== frame),
253
+ ...layout.nodes.filter((n) => !memberNodes.includes(n)),
254
+ ];
255
+ if (others.some((o) => boxesOverlap(moved, o)))
256
+ continue;
257
+ if ((layout.edges ?? []).some((e) => polylineHitsBox(e.points, moved)))
258
+ continue;
259
+ for (const n of memberNodes)
260
+ n.y += dy;
261
+ for (const f of memberFrames)
262
+ f.y += dy;
263
+ }
264
+ return layout;
265
+ }
266
+ function isOrthogonal(points) {
267
+ for (let i = 0; i + 1 < points.length; i++) {
268
+ if (Math.abs(points[i].x - points[i + 1].x) > 0.01 && Math.abs(points[i].y - points[i + 1].y) > 0.01) {
269
+ return false;
270
+ }
271
+ }
272
+ return true;
273
+ }
274
+ /** Row alignment is skipped above this size (its blocking checks are O(n·m)). */
275
+ const ALIGN_MAX_ENTITIES = 150;
276
+ function boxesOverlap(a, b) {
277
+ return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
278
+ }
279
+ /** True when any segment's bounding box crosses the box interior. */
280
+ function polylineHitsBox(points, box) {
281
+ for (let i = 0; i + 1 < points.length; i++) {
282
+ const a = points[i];
283
+ const b = points[i + 1];
284
+ const seg = {
285
+ x: Math.min(a.x, b.x),
286
+ y: Math.min(a.y, b.y),
287
+ width: Math.abs(a.x - b.x),
288
+ height: Math.abs(a.y - b.y),
289
+ };
290
+ if (seg.x < box.x + box.width && box.x < seg.x + seg.width &&
291
+ seg.y <= box.y + box.height && box.y <= seg.y + seg.height &&
292
+ !(seg.width === 0 && (seg.x <= box.x || seg.x >= box.x + box.width)) &&
293
+ !(seg.height === 0 && (seg.y <= box.y || seg.y >= box.y + box.height))) {
294
+ return true;
295
+ }
296
+ }
297
+ return false;
298
+ }
@@ -43,6 +43,14 @@ export interface PlantUmlEntity {
43
43
  * no members and are drawn with a distinct frame.
44
44
  */
45
45
  stub?: boolean;
46
+ /** Names of the stubs merged into a group stub; listed in its `<title>`. */
47
+ groupMembers?: string[];
48
+ /**
49
+ * True when the entity was never declared and only exists because a relation
50
+ * named it (an implicit/external type such as `Error`). Drawn with a weaker
51
+ * style and grouped under a synthetic External package.
52
+ */
53
+ external?: boolean;
46
54
  }
47
55
  export type RelationType = 'inheritance' | 'implementation' | 'dependency' | 'association' | 'aggregation' | 'composition';
48
56
  export interface PlantUmlRelation {
@@ -63,6 +71,18 @@ export interface PlantUmlRelation {
63
71
  weight?: number;
64
72
  /** True when this edge is a cycle feedback arc, drawn in a distinct color. */
65
73
  feedback?: boolean;
74
+ /**
75
+ * One line per original relation folded into an inter-package corridor edge
76
+ * (`A --> B`); rendered as the edge's `<title>`.
77
+ */
78
+ descriptions?: string[];
79
+ /**
80
+ * Set when the authored endpoint named a package rather than an entity: the
81
+ * edge is laid out against a representative entity inside it, and the
82
+ * layout clips its end to the package frame boundary.
83
+ */
84
+ sourcePackageId?: string;
85
+ targetPackageId?: string;
66
86
  }
67
87
  export interface PlantUmlPackage {
68
88
  id: string;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * RelationType semantics — the single place that knows what each relation kind
3
+ * *means*.
4
+ *
5
+ * `RelationType` is a closed enum, but its behaviour used to be decided
6
+ * independently by every consumer: layout reversal in `elk-graph`/`compound`,
7
+ * line style and arrowheads in `svg`/`viz`, bundling and reduction eligibility
8
+ * in `svg`/`transform`. The copies drifted — the viz DOT said `implementation`
9
+ * was dashed while the SVG renderer drew it solid. Consumers now read this
10
+ * table and only translate the facts into their own output format (SVG DOM,
11
+ * DOT attributes, ELK endpoints).
12
+ *
13
+ * It lives in `foundation/` (the shared lowest layer) because layout/, render/
14
+ * and transform/ all consume it: a module at the `src/` root would be imported
15
+ * by subdirectories that the root itself imports, creating a package cycle.
16
+ */
17
+ import type { RelationType } from './model.js';
18
+ /** Shape a relation type puts at the arrow end (`none`: no head). */
19
+ export type RelationArrowKind = 'triangle' | 'chevron' | 'diamond' | 'none';
20
+ /**
21
+ * The behavioural facts of one relation type, exposed as **methods**. Each
22
+ * fact is a decision, not a rendering instruction: `isDashed()` is "this line
23
+ * is dashed", `svg.ts` decides *which* dash token that maps to.
24
+ *
25
+ * The methods are the whole public surface — consumers ask the value object
26
+ * what to do instead of reading how it remembers the answer. That keeps the
27
+ * representation private: a fact that later becomes a computation (rather than
28
+ * a stored boolean) changes only this module, not every call site.
29
+ */
30
+ export interface RelationSemantics {
31
+ /** Layout direction is reversed: the arrow target sits above the source. */
32
+ isReversed(): boolean;
33
+ /** The connection line is dashed. */
34
+ isDashed(): boolean;
35
+ /** Shape of the arrowhead. */
36
+ arrowKind(): RelationArrowKind;
37
+ /** A diamond head is filled (composition) rather than hollow (aggregation). */
38
+ isFilled(): boolean;
39
+ /** A weak relation, drawn in the lighter edge colour. */
40
+ isWeak(): boolean;
41
+ /** Eligible for edge bundling and automatic transitive reduction. */
42
+ isBundlable(): boolean;
43
+ }
44
+ /** Every relation type and the behaviour it implies. */
45
+ export declare const RELATION_SEMANTICS: Readonly<Record<RelationType, RelationSemantics>>;
46
+ /** The behavioural facts of one relation type. */
47
+ export declare function relationSemantics(type: RelationType): RelationSemantics;
48
+ /** Every relation type, in declaration order (for consumers that iterate the closed enum). */
49
+ export declare const RELATION_TYPES: readonly RelationType[];
@@ -0,0 +1,112 @@
1
+ /**
2
+ * RelationType semantics — the single place that knows what each relation kind
3
+ * *means*.
4
+ *
5
+ * `RelationType` is a closed enum, but its behaviour used to be decided
6
+ * independently by every consumer: layout reversal in `elk-graph`/`compound`,
7
+ * line style and arrowheads in `svg`/`viz`, bundling and reduction eligibility
8
+ * in `svg`/`transform`. The copies drifted — the viz DOT said `implementation`
9
+ * was dashed while the SVG renderer drew it solid. Consumers now read this
10
+ * table and only translate the facts into their own output format (SVG DOM,
11
+ * DOT attributes, ELK endpoints).
12
+ *
13
+ * It lives in `foundation/` (the shared lowest layer) because layout/, render/
14
+ * and transform/ all consume it: a module at the `src/` root would be imported
15
+ * by subdirectories that the root itself imports, creating a package cycle.
16
+ */
17
+ /** Build the methods-only public view over one private fact set. */
18
+ function semantics(facts) {
19
+ return Object.freeze({
20
+ isReversed: () => facts.reversed,
21
+ isDashed: () => facts.dashed,
22
+ arrowKind: () => facts.arrowKind,
23
+ isFilled: () => facts.filled,
24
+ isWeak: () => facts.weak,
25
+ isBundlable: () => facts.bundlable,
26
+ });
27
+ }
28
+ /**
29
+ * Relation types whose layout direction is reversed so the arrow target sits
30
+ * above the source. The one and only definition of this fact.
31
+ */
32
+ const REVERSED_RELATION_TYPES = new Set(['inheritance', 'implementation']);
33
+ /** Every relation type and the behaviour it implies. */
34
+ export const RELATION_SEMANTICS = {
35
+ inheritance: semantics({
36
+ reversed: REVERSED_RELATION_TYPES.has('inheritance'),
37
+ dashed: false,
38
+ arrowKind: 'triangle',
39
+ filled: false,
40
+ weak: false,
41
+ bundlable: false,
42
+ }),
43
+ implementation: semantics({
44
+ reversed: REVERSED_RELATION_TYPES.has('implementation'),
45
+ // PlantUML `..|>` — dashed, but not a "weak" (lighter) edge.
46
+ dashed: true,
47
+ arrowKind: 'triangle',
48
+ filled: false,
49
+ weak: false,
50
+ bundlable: false,
51
+ }),
52
+ dependency: semantics({
53
+ reversed: false,
54
+ dashed: true,
55
+ arrowKind: 'chevron',
56
+ filled: false,
57
+ weak: true,
58
+ bundlable: true,
59
+ }),
60
+ association: semantics({
61
+ reversed: false,
62
+ dashed: false,
63
+ arrowKind: 'chevron',
64
+ filled: false,
65
+ weak: false,
66
+ bundlable: true,
67
+ }),
68
+ aggregation: semantics({
69
+ reversed: false,
70
+ dashed: false,
71
+ arrowKind: 'diamond',
72
+ filled: false,
73
+ weak: false,
74
+ bundlable: false,
75
+ }),
76
+ composition: semantics({
77
+ reversed: false,
78
+ dashed: false,
79
+ arrowKind: 'diamond',
80
+ filled: true,
81
+ weak: false,
82
+ bundlable: false,
83
+ }),
84
+ };
85
+ /**
86
+ * Facts for a relation type outside the closed `RelationType` domain. External
87
+ * input (JSON fixtures, hosts) can carry such a type; every consumer used to
88
+ * treat it as an anonymous line via `Set.has()` / `===` checks — no reversal,
89
+ * no dash, no arrowhead, not weak, not bundlable. Keep exactly that instead of
90
+ * failing the lookup.
91
+ */
92
+ const UNKNOWN_RELATION_SEMANTICS = semantics({
93
+ reversed: false,
94
+ dashed: false,
95
+ arrowKind: 'none',
96
+ filled: false,
97
+ weak: false,
98
+ bundlable: false,
99
+ });
100
+ /** The behavioural facts of one relation type. */
101
+ export function relationSemantics(type) {
102
+ return RELATION_SEMANTICS[type] ?? UNKNOWN_RELATION_SEMANTICS;
103
+ }
104
+ /** Every relation type, in declaration order (for consumers that iterate the closed enum). */
105
+ export const RELATION_TYPES = [
106
+ 'inheritance',
107
+ 'implementation',
108
+ 'dependency',
109
+ 'association',
110
+ 'aggregation',
111
+ 'composition',
112
+ ];
@@ -21,6 +21,8 @@ export declare const MONOSPACE_ADVANCE_EM = 0.61;
21
21
  * its shape, contributes the same advance.
22
22
  */
23
23
  export declare function measureTextWidth(text: string, fontSize?: number): number;
24
+ /** Width of `text` in monospace cells: CJK / fullwidth code points count 2, the rest 1. */
25
+ export declare function textUnits(text: string): number;
24
26
  export interface TextMeasurer {
25
27
  width(text: string, fontSize?: number): number;
26
28
  }
@@ -21,7 +21,26 @@ export const MONOSPACE_ADVANCE_EM = 0.61;
21
21
  * its shape, contributes the same advance.
22
22
  */
23
23
  export function measureTextWidth(text, fontSize = MONOSPACE_FONT_SIZE) {
24
- return Math.ceil(text.length * MONOSPACE_ADVANCE_EM * fontSize * 100) / 100;
24
+ return Math.ceil(textUnits(text) * MONOSPACE_ADVANCE_EM * fontSize * 100) / 100;
25
+ }
26
+ /** East Asian wide / fullwidth ranges: these glyphs advance two monospace cells. */
27
+ function isWide(cp) {
28
+ return ((cp >= 0x1100 && cp <= 0x115f) ||
29
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
30
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
31
+ (cp >= 0xf900 && cp <= 0xfaff) ||
32
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
33
+ (cp >= 0xff00 && cp <= 0xff60) ||
34
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
35
+ (cp >= 0x1f300 && cp <= 0x1faff) ||
36
+ (cp >= 0x20000 && cp <= 0x3fffd));
37
+ }
38
+ /** Width of `text` in monospace cells: CJK / fullwidth code points count 2, the rest 1. */
39
+ export function textUnits(text) {
40
+ let units = 0;
41
+ for (const ch of text)
42
+ units += isWide(ch.codePointAt(0)) ? 2 : 1;
43
+ return units;
25
44
  }
26
45
  export function createTextMeasurer() {
27
46
  return { width: (text, fontSize) => measureTextWidth(text, fontSize) };
package/dist/index.d.ts CHANGED
@@ -6,10 +6,10 @@
6
6
  * bundle stays free of the Graphviz WASM.
7
7
  */
8
8
  import { VizLayoutEngine } from './layout/viz.js';
9
- import { renderDot } from './render-dot.js';
9
+ import { renderDot } from './foundation/render-dot.js';
10
10
  import { Renderer, RendererOptions } from './core.js';
11
11
  export * from './core.js';
12
- export type { GraphvizEngine } from './render-dot.js';
12
+ export type { GraphvizEngine } from './foundation/render-dot.js';
13
13
  export { renderDot, VizLayoutEngine };
14
14
  /**
15
15
  * Build a renderer. `layout` is `'elk'` (default), `'viz'` (experimental), or a
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * bundle stays free of the Graphviz WASM.
7
7
  */
8
8
  import { VizLayoutEngine } from './layout/viz.js';
9
- import { renderDot } from './render-dot.js';
9
+ import { renderDot } from './foundation/render-dot.js';
10
10
  import { createRenderer as createCoreRenderer, } from './core.js';
11
11
  export * from './core.js';
12
12
  export { renderDot, VizLayoutEngine };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Compound layout — lay packages out as independent blocks, place the blocks,
3
+ * then route the edges that cross block boundaries.
4
+ *
5
+ * A single hierarchical layered run (`INCLUDE_CHILDREN`) routes every edge but
6
+ * stacks all layers of every package into one tall strip: the 660-entity
7
+ * archguard `all-classes` graph came out 13455×79369. Here each top-level
8
+ * package is laid out on its own (packed to a balanced rectangle), the blocks
9
+ * are arranged as plain nodes — layered for small block counts, rectpacking for
10
+ * large ones — and [[routeOrthogonal]] draws the cross-block edges around the
11
+ * leaf boxes along the channels between blocks.
12
+ */
13
+ import type { ELK as ElkInstance, ElkNode } from 'elkjs';
14
+ import type { PlantUmlModel } from '../foundation/model.js';
15
+ import { type LayoutResult } from '../foundation/layout.js';
16
+ import { type BoxPlacementContext, type PlacementStrategy, type PlacementStrategyOptions, type StubOrbit } from './placement-strategy.js';
17
+ export type { StubOrbit } from './placement-strategy.js';
18
+ export interface CompoundOptions extends PlacementStrategyOptions {
19
+ edgeRouting?: string;
20
+ /** Rip-up passes of the cross-block router; its default when omitted. */
21
+ ripupPasses?: number;
22
+ /**
23
+ * Extra block-placement strategies tried, in order, before the built-in
24
+ * chain; the first to return a placement wins (see [[./placement-strategy]]).
25
+ */
26
+ placementStrategies?: PlacementStrategy[];
27
+ }
28
+ export declare const DEFAULT_STUB_ORBIT: StubOrbit;
29
+ export declare function compoundLayout(elk: ElkInstance, model: PlantUmlModel, options: CompoundOptions): Promise<LayoutResult>;
30
+ /**
31
+ * Equal-column grid for unconnected boxes: the column width is the widest box,
32
+ * rows are as tall as their tallest box and boxes are top-left aligned, so
33
+ * columns and rows line up. The column count is the one whose block lands
34
+ * closest to `aspect`; the last row alone may be short. `tight` sizes each column
35
+ * to its own widest box and hands the narrowest boxes to the first columns; without
36
+ * it every column is as wide as the widest box overall and the flow order is kept.
37
+ */
38
+ export declare function gridPack(children: ElkNode[], order: string[], aspect: number, gap: number, rowGap?: number, tight?: boolean): ElkNode;
39
+ /** Box placement strategies: the equal-column grid and the ordered shelf pack. */
40
+ export declare const gridPackPlacementStrategy: PlacementStrategy<BoxPlacementContext>;
41
+ export declare const orderedShelfPackPlacementStrategy: PlacementStrategy<BoxPlacementContext>;
42
+ /** Stubs orbit the placed main blocks instead of taking part in their placement. */
43
+ export declare const stubOrbitPlacementStrategy: PlacementStrategy;
44
+ /** Semantic layers as stacked bands of blocks. */
45
+ export declare const layerRowsPlacementStrategy: PlacementStrategy;
46
+ /**
47
+ * Height-sorted shelf packing of the blocks; [[packedBlocksPlacementStrategy]]
48
+ * keeps it as one candidate beside ELK's own rect-packing runs.
49
+ */
50
+ export declare const shelfPackBlocksPlacementStrategy: PlacementStrategy;
51
+ /**
52
+ * Rect packing of the blocks: height-sorted shelves are tried beside ELK's own
53
+ * rectpacking at a few ratios, and the tightest canvas near the target wins.
54
+ */
55
+ export declare const packedBlocksPlacementStrategy: PlacementStrategy;
56
+ /** Blocks placed by ELK's layered algorithm (no partitions, few enough to layer). */
57
+ export declare const elkLayeredPlacementStrategy: PlacementStrategy;
58
+ /** Built-in block placement chain, tried after any injected strategies. */
59
+ export declare const DEFAULT_PLACEMENT_STRATEGIES: readonly PlacementStrategy[];