@dxos/diagram 0.11.1 → 0.13.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 (171) hide show
  1. package/dist/lib/chunk-parse.mjs +9396 -0
  2. package/dist/lib/chunk-parse.mjs.map +1 -0
  3. package/dist/lib/extension.mjs +204 -0
  4. package/dist/lib/extension.mjs.map +1 -0
  5. package/dist/lib/index.mjs +497 -2846
  6. package/dist/lib/index.mjs.map +1 -1
  7. package/dist/types/src/aesthetics.d.ts +12 -0
  8. package/dist/types/src/aesthetics.d.ts.map +1 -0
  9. package/dist/types/src/aesthetics.test.d.ts +2 -0
  10. package/dist/types/src/aesthetics.test.d.ts.map +1 -0
  11. package/dist/types/src/appeal.d.ts +55 -0
  12. package/dist/types/src/appeal.d.ts.map +1 -0
  13. package/dist/types/src/appeal.test.d.ts +2 -0
  14. package/dist/types/src/appeal.test.d.ts.map +1 -0
  15. package/dist/types/src/architecture.d.ts +86 -0
  16. package/dist/types/src/architecture.d.ts.map +1 -0
  17. package/dist/types/src/architecture.test.d.ts +2 -0
  18. package/dist/types/src/architecture.test.d.ts.map +1 -0
  19. package/dist/types/src/compact.d.ts +39 -0
  20. package/dist/types/src/compact.d.ts.map +1 -0
  21. package/dist/types/src/compact.test.d.ts +2 -0
  22. package/dist/types/src/compact.test.d.ts.map +1 -0
  23. package/dist/types/src/diagnostics.d.ts +70 -3
  24. package/dist/types/src/diagnostics.d.ts.map +1 -1
  25. package/dist/types/src/dsl/cli.d.ts +2 -0
  26. package/dist/types/src/dsl/cli.d.ts.map +1 -0
  27. package/dist/types/src/dsl/compile.d.ts +12 -0
  28. package/dist/types/src/dsl/compile.d.ts.map +1 -0
  29. package/dist/types/src/dsl/convert.d.ts +45 -0
  30. package/dist/types/src/dsl/convert.d.ts.map +1 -0
  31. package/dist/types/src/dsl/convert.test.d.ts +2 -0
  32. package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
  33. package/dist/types/src/dsl/dsl.test.d.ts +2 -0
  34. package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
  35. package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
  36. package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
  37. package/dist/types/src/dsl/index.d.ts +7 -0
  38. package/dist/types/src/dsl/index.d.ts.map +1 -0
  39. package/dist/types/src/dsl/parse.d.ts +49 -0
  40. package/dist/types/src/dsl/parse.d.ts.map +1 -0
  41. package/dist/types/src/dsl/print.d.ts +19 -0
  42. package/dist/types/src/dsl/print.d.ts.map +1 -0
  43. package/dist/types/src/dsl/semantic.test.d.ts +2 -0
  44. package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
  45. package/dist/types/src/dsl/vocabulary.d.ts +61 -0
  46. package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
  47. package/dist/types/src/dx-svg.d.ts +12 -0
  48. package/dist/types/src/dx-svg.d.ts.map +1 -0
  49. package/dist/types/src/dx-svg.test.d.ts +2 -0
  50. package/dist/types/src/dx-svg.test.d.ts.map +1 -0
  51. package/dist/types/src/extension/complete.d.ts +3 -0
  52. package/dist/types/src/extension/complete.d.ts.map +1 -0
  53. package/dist/types/src/extension/extension.test.d.ts +2 -0
  54. package/dist/types/src/extension/extension.test.d.ts.map +1 -0
  55. package/dist/types/src/extension/highlight.d.ts +14 -0
  56. package/dist/types/src/extension/highlight.d.ts.map +1 -0
  57. package/dist/types/src/extension/index.d.ts +6 -0
  58. package/dist/types/src/extension/index.d.ts.map +1 -0
  59. package/dist/types/src/extension/language.d.ts +13 -0
  60. package/dist/types/src/extension/language.d.ts.map +1 -0
  61. package/dist/types/src/extension/lint.d.ts +21 -0
  62. package/dist/types/src/extension/lint.d.ts.map +1 -0
  63. package/dist/types/src/extension/syntax.d.ts +3 -0
  64. package/dist/types/src/extension/syntax.d.ts.map +1 -0
  65. package/dist/types/src/index.d.ts +14 -0
  66. package/dist/types/src/index.d.ts.map +1 -1
  67. package/dist/types/src/mermaid-engine.d.ts +40 -1
  68. package/dist/types/src/mermaid-engine.d.ts.map +1 -1
  69. package/dist/types/src/mermaid.d.ts +3 -3
  70. package/dist/types/src/mermaid.d.ts.map +1 -1
  71. package/dist/types/src/nudge.d.ts +30 -0
  72. package/dist/types/src/nudge.d.ts.map +1 -0
  73. package/dist/types/src/nudge.test.d.ts +2 -0
  74. package/dist/types/src/nudge.test.d.ts.map +1 -0
  75. package/dist/types/src/objective.d.ts +4 -0
  76. package/dist/types/src/objective.d.ts.map +1 -1
  77. package/dist/types/src/ortho-router.d.ts +13 -1
  78. package/dist/types/src/ortho-router.d.ts.map +1 -1
  79. package/dist/types/src/ports.d.ts +40 -0
  80. package/dist/types/src/ports.d.ts.map +1 -0
  81. package/dist/types/src/ports.test.d.ts +2 -0
  82. package/dist/types/src/ports.test.d.ts.map +1 -0
  83. package/dist/types/src/relations.test.d.ts +2 -0
  84. package/dist/types/src/relations.test.d.ts.map +1 -0
  85. package/dist/types/src/rules.d.ts +30 -0
  86. package/dist/types/src/rules.d.ts.map +1 -0
  87. package/dist/types/src/scene.d.ts +99 -65
  88. package/dist/types/src/scene.d.ts.map +1 -1
  89. package/dist/types/src/score.d.ts +54 -0
  90. package/dist/types/src/score.d.ts.map +1 -0
  91. package/dist/types/src/score.test.d.ts +2 -0
  92. package/dist/types/src/score.test.d.ts.map +1 -0
  93. package/dist/types/src/semantic-engine.d.ts +41 -0
  94. package/dist/types/src/semantic-engine.d.ts.map +1 -0
  95. package/dist/types/src/semantic-engine.test.d.ts +2 -0
  96. package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
  97. package/dist/types/src/semantic-place.d.ts +81 -0
  98. package/dist/types/src/semantic-place.d.ts.map +1 -0
  99. package/dist/types/src/semantic-route.d.ts +133 -0
  100. package/dist/types/src/semantic-route.d.ts.map +1 -0
  101. package/dist/types/src/semantic.d.ts +164 -0
  102. package/dist/types/src/semantic.d.ts.map +1 -0
  103. package/dist/types/src/uml-grid.d.ts.map +1 -1
  104. package/dist/types/src/uml.d.ts +3 -5
  105. package/dist/types/src/uml.d.ts.map +1 -1
  106. package/dist/types/src/view.d.ts +53 -0
  107. package/dist/types/src/view.d.ts.map +1 -0
  108. package/dist/types/src/view.test.d.ts +2 -0
  109. package/dist/types/src/view.test.d.ts.map +1 -0
  110. package/dist/types/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +23 -5
  112. package/rules/DIAGRAM.mdl +388 -0
  113. package/src/aesthetics.test.ts +89 -0
  114. package/src/aesthetics.ts +148 -0
  115. package/src/appeal.test.ts +144 -0
  116. package/src/appeal.ts +586 -0
  117. package/src/architecture.test.ts +94 -0
  118. package/src/architecture.ts +215 -0
  119. package/src/compact.test.ts +67 -0
  120. package/src/compact.ts +219 -0
  121. package/src/diagnostics.test.ts +207 -1
  122. package/src/diagnostics.ts +481 -37
  123. package/src/dsl/cli.ts +46 -0
  124. package/src/dsl/compile.ts +28 -0
  125. package/src/dsl/convert.test.ts +51 -0
  126. package/src/dsl/convert.ts +98 -0
  127. package/src/dsl/diagram.grammar +195 -0
  128. package/src/dsl/dsl.test.ts +286 -0
  129. package/src/dsl/gen/diagram.ts +22 -0
  130. package/src/dsl/index.ts +44 -0
  131. package/src/dsl/parse.ts +1054 -0
  132. package/src/dsl/print.ts +198 -0
  133. package/src/dsl/semantic.test.ts +200 -0
  134. package/src/dsl/vocabulary.ts +150 -0
  135. package/src/dx-svg.test.ts +44 -0
  136. package/src/dx-svg.ts +77 -0
  137. package/src/extension/complete.ts +105 -0
  138. package/src/extension/extension.test.ts +135 -0
  139. package/src/extension/highlight.ts +23 -0
  140. package/src/extension/index.ts +9 -0
  141. package/src/extension/language.ts +31 -0
  142. package/src/extension/lint.ts +60 -0
  143. package/src/extension/syntax.ts +62 -0
  144. package/src/index.ts +14 -0
  145. package/src/mermaid-engine.test.ts +6 -6
  146. package/src/mermaid-engine.ts +569 -119
  147. package/src/mermaid.test.ts +17 -1
  148. package/src/mermaid.ts +30 -18
  149. package/src/nudge.test.ts +155 -0
  150. package/src/nudge.ts +385 -0
  151. package/src/objective.test.ts +2 -0
  152. package/src/objective.ts +17 -1
  153. package/src/ortho-router.ts +240 -80
  154. package/src/ports.test.ts +157 -0
  155. package/src/ports.ts +186 -0
  156. package/src/relations.test.ts +98 -0
  157. package/src/rules.ts +117 -0
  158. package/src/scene.ts +64 -3
  159. package/src/score.test.ts +64 -0
  160. package/src/score.ts +130 -0
  161. package/src/semantic-engine.test.ts +635 -0
  162. package/src/semantic-engine.ts +888 -0
  163. package/src/semantic-place.ts +1508 -0
  164. package/src/semantic-route.ts +1136 -0
  165. package/src/semantic.ts +278 -0
  166. package/src/typings.d.ts +8 -0
  167. package/src/uml-grid.ts +6 -1
  168. package/src/uml.test.ts +5 -1
  169. package/src/uml.ts +24 -11
  170. package/src/view.test.ts +139 -0
  171. package/src/view.ts +376 -0
@@ -0,0 +1,215 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Architecture rules for a diagram's content: the software-engineering review rules the team applies
7
+ // to code (`.agents/projects/architecture-rules`), restated for what a diagram shows — components,
8
+ // the groups they belong to and who depends on whom. A decision model judges every rule in one call,
9
+ // so grading a diagram costs one request however many rules there are.
10
+ //
11
+
12
+ import * as Decision from 'effect/ai/Decision';
13
+ import * as DecisionModel from 'effect/ai/DecisionModel';
14
+ import * as Effect from 'effect/Effect';
15
+ import * as Schema from 'effect/Schema';
16
+
17
+ import type * as Mermaid from './mermaid.ts';
18
+ import type * as Score from './score.ts';
19
+
20
+ /** What a diagram says, plus optionally how it is drawn: the input every rule is judged against. */
21
+ export const Content = Schema.Struct({
22
+ title: Schema.optional(Schema.String),
23
+ layout: Schema.optional(Schema.String).annotate({
24
+ description: 'A text rendering of the drawn page (`View`), so placement can be judged as a reader sees it.',
25
+ }),
26
+ groups: Schema.Array(Schema.Struct({ id: Schema.String, label: Schema.String })),
27
+ nodes: Schema.Array(
28
+ Schema.Struct({ id: Schema.String, label: Schema.String, group: Schema.optional(Schema.String) }),
29
+ ),
30
+ edges: Schema.Array(
31
+ Schema.Struct({
32
+ from: Schema.String,
33
+ to: Schema.String,
34
+ label: Schema.optional(Schema.String),
35
+ kind: Schema.optional(Schema.String).annotate({
36
+ description:
37
+ 'Absent for a plain dependency; otherwise `inheritance`, `implements`, `hasMany`, `contains` (from owns ' +
38
+ 'to) or `creates`.',
39
+ }),
40
+ }).annotate({ description: '`from` depends on, calls or owns `to`.' }),
41
+ ),
42
+ });
43
+ export type Content = Schema.Schema.Type<typeof Content>;
44
+
45
+ /** The content of a parsed mermaid graph, with an optional caption and text rendering of its layout. */
46
+ export const contentOf = (
47
+ graph: Mermaid.MermaidGraph,
48
+ { title, layout }: { title?: string; layout?: string } = {},
49
+ ): Content => ({
50
+ ...(title ? { title } : {}),
51
+ ...(layout ? { layout } : {}),
52
+ groups: graph.groups.map(({ id, label }) => ({ id, label })),
53
+ nodes: graph.nodes.map(({ id, label, group }) => ({ id, label, ...(group ? { group } : {}) })),
54
+ edges: graph.edges.map(({ from, to, label, kind }) => ({
55
+ from,
56
+ to,
57
+ ...(label ? { label } : {}),
58
+ ...(kind !== 'reference' ? { kind } : {}),
59
+ })),
60
+ });
61
+
62
+ export type Rule = {
63
+ /** Kebab-case, as the review rule it adapts is named. */
64
+ id: string;
65
+ /** Decision key: System One names questions by identifier. */
66
+ key: string;
67
+ description: string;
68
+ /** The question, phrased so `true` means the diagram follows the rule. */
69
+ instructions: string;
70
+ criteria: { false: string; true: string };
71
+ };
72
+
73
+ const HOW_TO_READ =
74
+ 'The input is an architecture diagram: nodes are components, groups are layers or packages, and an edge ' +
75
+ '`from → to` means `from` depends on, calls or owns `to`. An `inheritance` or `implements` edge points from ' +
76
+ 'the implementation to its abstraction, which by convention sits above it, so it is never a back-dependency. ' +
77
+ 'When `layout` is present it shows the page as drawn.';
78
+
79
+ const rule = (id: string, key: string, description: string, question: string, criteria: Rule['criteria']): Rule => ({
80
+ id,
81
+ key,
82
+ description,
83
+ instructions: `${HOW_TO_READ} ${question}`,
84
+ criteria,
85
+ });
86
+
87
+ /**
88
+ * The first batch of `architecture-rules`' recommended rules that a diagram can show, plus the
89
+ * bounded-state pair `non-negotiables.mdl` already enforces on code.
90
+ */
91
+ export const RULES: readonly Rule[] = [
92
+ rule(
93
+ 'dependency-direction',
94
+ 'dependencyDirection',
95
+ 'Dependencies point from higher-level layers to foundational ones, never back up.',
96
+ 'Do all dependencies point one way, from higher-level or UI components towards foundational ones, with no ' +
97
+ 'foundational component depending on a higher-level one and no cycle between groups?',
98
+ {
99
+ true: 'Every edge points downward in the layering; groups form a one-way stack.',
100
+ false: 'Some foundational component depends on a higher-level one, or two groups depend on each other.',
101
+ },
102
+ ),
103
+ rule(
104
+ 'state-owned-once',
105
+ 'stateOwnedOnce',
106
+ 'Every piece of state has exactly one owner; others derive or subscribe.',
107
+ 'Does the diagram show a single owner for each piece of state (store, table, cache, registry), with other ' +
108
+ 'components reaching it through that owner rather than keeping mirrored copies or writing it directly?',
109
+ {
110
+ true: 'Each store has one owning component and everyone else goes through it.',
111
+ false: 'A store is written by several components, or state is mirrored in parallel copies.',
112
+ },
113
+ ),
114
+ rule(
115
+ 'one-mechanism-per-concern',
116
+ 'oneMechanismPerConcern',
117
+ 'One mechanism serves each concern; no parallel components doing the same job.',
118
+ 'Is each concern (transport, storage, scheduling, lookup, sync) served by one component, rather than two ' +
119
+ 'parallel components that do the same job side by side?',
120
+ {
121
+ true: 'Every concern has one mechanism.',
122
+ false: 'Two components serve the same concern in parallel.',
123
+ },
124
+ ),
125
+ rule(
126
+ 'no-pointless-indirection',
127
+ 'noPointlessIndirection',
128
+ 'Every node earns its place; no pass-through layer that only forwards to one other.',
129
+ 'Does every component do real work of its own, rather than being a pass-through with one incoming and one ' +
130
+ 'outgoing edge that only forwards calls?',
131
+ {
132
+ true: 'No node is a mere relay; each adds behavior, state or a boundary.',
133
+ false: 'At least one node only forwards to a single other node and could be removed.',
134
+ },
135
+ ),
136
+ rule(
137
+ 'public-surface-only',
138
+ 'publicSurfaceOnly',
139
+ "Consumers depend on a group's public entry point, not on its internals.",
140
+ 'When an edge crosses from one group into another, does it land on the entry point or service that group ' +
141
+ 'exposes, rather than reaching past it into an internal component?',
142
+ {
143
+ true: 'Cross-group edges land on each group’s entry point.',
144
+ false: 'Some edge from outside a group reaches an internal component directly.',
145
+ },
146
+ ),
147
+ rule(
148
+ 'same-granularity',
149
+ 'sameGranularity',
150
+ 'Every box is a component at one level of abstraction.',
151
+ 'Is every node a component at the same level of abstraction — services, modules or processes of comparable ' +
152
+ 'weight — rather than a mix of components with their fields, caches, limits or data types drawn as boxes?',
153
+ {
154
+ true: 'All nodes are peers of comparable importance.',
155
+ false:
156
+ 'Some nodes are internals of another node (a field, cache, limit or type) drawn as if they were components.',
157
+ },
158
+ ),
159
+ rule(
160
+ 'bounded-live-state',
161
+ 'boundedLiveState',
162
+ 'Long-lived collections show who bounds them and who evicts finished entries.',
163
+ 'For every long-lived collection the diagram shows (processes, invocations, queues, caches, indexes, records ' +
164
+ 'of finished work), does it also show the component that owns it and so can bound it and evict finished ' +
165
+ 'entries?',
166
+ {
167
+ true: 'Each long-lived collection has an owning component responsible for its limit and eviction.',
168
+ false: 'A collection grows with no owner shown that could bound it or evict from it.',
169
+ },
170
+ ),
171
+ ];
172
+
173
+ /** The decision definition for a set of rules: one probability per rule, answered in one call. */
174
+ export const definition = (rules: readonly Rule[] = RULES) =>
175
+ Decision.make({
176
+ input: Content,
177
+ decisions: Object.fromEntries(
178
+ rules.map(({ key, instructions, criteria }) => [key, Decision.probability({ instructions, criteria })]),
179
+ ),
180
+ });
181
+
182
+ /**
183
+ * What a judge is shown: the diagram's content, and optionally the rendered page as images for a
184
+ * model that reads them (one that does not fails the call, which scores every rule as an error).
185
+ */
186
+ export type Subject = { readonly content: Content; readonly images?: readonly DecisionModel.Image[] };
187
+
188
+ /**
189
+ * Every rule as a score, from one `DecisionModel` call: the probability that the diagram follows the
190
+ * rule. A failed call scores every rule as an error rather than as 0, so an outage never reads as a
191
+ * bad diagram. `kind` labels the scores, so another rule set (e.g. `Aesthetics`) can share the judge.
192
+ */
193
+ export const judge = (
194
+ rules: readonly Rule[] = RULES,
195
+ kind: Score.Kind = 'architecture',
196
+ ): Score.Batch<Subject, DecisionModel.DecisionModel> => {
197
+ const decisions = definition(rules);
198
+ return {
199
+ entries: rules.map(({ id, description }) => ({ id, kind, description })),
200
+ evaluate: ({ content, images }) =>
201
+ DecisionModel.decide(decisions, { input: content, ...(images?.length ? { images } : {}) }).pipe(
202
+ Effect.map(({ answers }) =>
203
+ rules.map(({ key }) => {
204
+ const answer = answers[key];
205
+ return answer ? { score: answer.probability } : { score: 0, error: 'No answer.' };
206
+ }),
207
+ ),
208
+ Effect.catch((error) =>
209
+ Effect.succeed(
210
+ rules.map(() => ({ score: 0, error: error instanceof Error ? error.message : String(error) })),
211
+ ),
212
+ ),
213
+ ),
214
+ };
215
+ };
@@ -0,0 +1,67 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { compact } from './compact.ts';
8
+ import type * as Scene from './scene.ts';
9
+
10
+ const GEOMETRY = {
11
+ cell: { w: 192, h: 96 },
12
+ pitch: { x: 384, y: 224 },
13
+ framePad: 32,
14
+ frameLabel: 32,
15
+ frameGap: 32,
16
+ };
17
+
18
+ const at = (col: number, row: number): Scene.Point => ({ x: col * GEOMETRY.pitch.x, y: row * GEOMETRY.pitch.y });
19
+
20
+ describe('compact', () => {
21
+ test('pulls a detached package up to the package it connects to', ({ expect }) => {
22
+ const graph = {
23
+ nodes: ['A', 'B', 'C', 'D'],
24
+ groups: [
25
+ { id: 'top', children: ['A', 'B'] },
26
+ { id: 'bottom', children: ['C', 'D'] },
27
+ ],
28
+ edges: [
29
+ { from: 'A', to: 'C' },
30
+ { from: 'C', to: 'D' },
31
+ ],
32
+ };
33
+ const positions = new Map([
34
+ ['A', at(0, 0)],
35
+ ['B', at(1, 0)],
36
+ ['C', at(0, 4)],
37
+ ['D', at(1, 4)],
38
+ ]);
39
+ const result = compact(graph, positions, GEOMETRY);
40
+
41
+ // One row of clearance holds both frames; the edge still runs down.
42
+ expect(result.get('A')).toEqual(at(0, 0));
43
+ expect(result.get('C')).toEqual(at(0, 1));
44
+ expect(result.get('D')).toEqual(at(1, 1));
45
+ });
46
+
47
+ test('never reverses an edge or stacks two boxes', ({ expect }) => {
48
+ const graph = {
49
+ nodes: ['A', 'B', 'C'],
50
+ groups: [],
51
+ edges: [
52
+ { from: 'A', to: 'B' },
53
+ { from: 'B', to: 'C' },
54
+ ],
55
+ };
56
+ const positions = new Map([
57
+ ['A', at(0, 0)],
58
+ ['B', at(0, 3)],
59
+ ['C', at(0, 6)],
60
+ ]);
61
+ const result = compact(graph, positions, GEOMETRY);
62
+ const rows = ['A', 'B', 'C'].map((id) => result.get(id)?.y ?? Number.NaN);
63
+
64
+ expect(rows).toEqual([at(0, 0).y, at(0, 1).y, at(0, 2).y]);
65
+ expect(new Set([...result.values()].map(({ x, y }) => `${x}:${y}`)).size).toBe(3);
66
+ });
67
+ });
package/src/compact.ts ADDED
@@ -0,0 +1,219 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Post-placement compaction on the lattice. ELK spaces compound nodes by their padded extents and
7
+ // quantization rounds every gap up, so a placement often carries empty lanes and packages parked
8
+ // far from the package they talk to. A local search slides whole packages and single boxes by whole
9
+ // lattice pitches toward their partners — never reversing an edge's direction on either axis, never
10
+ // stacking two boxes and never bringing frames closer than their clearance — while the drawing's
11
+ // connector length and extent fall.
12
+ //
13
+
14
+ import type * as Scene from './scene.ts';
15
+ import { type Rect } from './uml-grid.ts';
16
+
17
+ export type Geometry = {
18
+ /** Box size, shared by every node. */
19
+ cell: { w: number; h: number };
20
+ /** Lattice pitch; every move is a whole number of pitches. */
21
+ pitch: { x: number; y: number };
22
+ /** Inset of a frame around its members, and the label band above them. */
23
+ framePad: number;
24
+ frameLabel: number;
25
+ /** Clear space a frame keeps from other frames and from boxes outside it. */
26
+ frameGap: number;
27
+ };
28
+
29
+ export type Graph = {
30
+ nodes: readonly string[];
31
+ groups: readonly { id: string; children: readonly string[] }[];
32
+ edges: readonly { from: string; to: string }[];
33
+ };
34
+
35
+ /** Upper bound on improvement sweeps; each sweep tries every unit once. */
36
+ const MAX_SWEEPS = 12;
37
+ /** Farthest a unit moves in one step, in pitches per axis. */
38
+ const MAX_REACH = 8;
39
+ /** Cost of one connector whose ends share neither a row nor a column (it must bend). */
40
+ const BEND_COST = 1;
41
+ /** Cost of a lattice unit of drawing width or height, against one unit of connector length. */
42
+ const EXTENT_COST = 1;
43
+
44
+ type Points = Map<string, Scene.Point>;
45
+
46
+ const sign = (value: number) => (value > 0 ? 1 : value < 0 ? -1 : 0);
47
+
48
+ const orientation = (a: Scene.Point, b: Scene.Point, c: Scene.Point) =>
49
+ sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x));
50
+
51
+ /** Whether two segments properly cross; touching or collinear runs do not count. */
52
+ const intersect = (a: Scene.Point, b: Scene.Point, c: Scene.Point, d: Scene.Point) => {
53
+ const [o1, o2, o3, o4] = [orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)];
54
+ return o1 * o2 < 0 && o3 * o4 < 0;
55
+ };
56
+
57
+ const apart = (a: Rect, b: Rect, gap: number) =>
58
+ a.x + a.w + gap <= b.x || b.x + b.w + gap <= a.x || a.y + a.h + gap <= b.y || b.y + b.h + gap <= a.y;
59
+
60
+ /**
61
+ * Compacts a lattice placement. Units are every package (moved rigidly) and every box (moved
62
+ * within or outside its package); a move is kept only when it lowers the cost — connector length
63
+ * plus bends plus the drawing's width and height, all in lattice units — and introduces no
64
+ * violation of the placement's invariants.
65
+ */
66
+ export const compact = (graph: Graph, positions: ReadonlyMap<string, Scene.Point>, geometry: Geometry): Points => {
67
+ const { cell, pitch, framePad, frameLabel, frameGap } = geometry;
68
+ const edges = graph.edges.filter(
69
+ (edge) => positions.has(edge.from) && positions.has(edge.to) && edge.from !== edge.to,
70
+ );
71
+ const groups = graph.groups
72
+ .map((group) => ({ id: group.id, members: group.children.filter((id) => positions.has(id)) }))
73
+ .filter((group) => group.members.length > 0);
74
+ const grouped = new Set(groups.flatMap((group) => group.members));
75
+ const loose = [...positions.keys()].filter((id) => !grouped.has(id));
76
+ // Direction of every edge on each axis, which no move may reverse or flatten.
77
+ const signs = edges.map((edge) => {
78
+ const [from, to] = [positions.get(edge.from), positions.get(edge.to)];
79
+ return from && to ? { x: sign(to.x - from.x), y: sign(to.y - from.y) } : { x: 0, y: 0 };
80
+ });
81
+
82
+ const frameOf = (members: readonly string[], state: Points): Rect => {
83
+ const points = members.flatMap((id) => state.get(id) ?? []);
84
+ const x = Math.min(...points.map((point) => point.x)) - framePad;
85
+ const y = Math.min(...points.map((point) => point.y)) - framePad - frameLabel;
86
+ const right = Math.max(...points.map((point) => point.x)) + cell.w + framePad;
87
+ const bottom = Math.max(...points.map((point) => point.y)) + cell.h + framePad;
88
+ return { x, y, w: right - x, h: bottom - y };
89
+ };
90
+
91
+ /** Invariant breaches: stacked boxes, reversed edges, frames too close to frames or outside boxes. */
92
+ const violations = (state: Points) => {
93
+ let count = 0;
94
+ const taken = new Set<string>();
95
+ for (const point of state.values()) {
96
+ const key = `${point.x}:${point.y}`;
97
+ count += taken.has(key) ? 1 : 0;
98
+ taken.add(key);
99
+ }
100
+ edges.forEach((edge, index) => {
101
+ const [from, to] = [state.get(edge.from), state.get(edge.to)];
102
+ if (from && to && (sign(to.x - from.x) !== signs[index].x || sign(to.y - from.y) !== signs[index].y)) {
103
+ count++;
104
+ }
105
+ });
106
+ const frames = groups.map((group) => frameOf(group.members, state));
107
+ for (let index = 0; index < frames.length; index++) {
108
+ for (let other = index + 1; other < frames.length; other++) {
109
+ count += apart(frames[index], frames[other], frameGap) ? 0 : 1;
110
+ }
111
+ for (const id of loose) {
112
+ const point = state.get(id);
113
+ count += point && !apart(frames[index], { ...point, ...cell }, frameGap) ? 1 : 0;
114
+ }
115
+ }
116
+ return count;
117
+ };
118
+
119
+ // Straight centre-to-centre lines stand in for the routes, which do not exist yet; a move may not add crossings among them.
120
+ const crossings = (state: Points) => {
121
+ const center = (id: string) => {
122
+ const point = state.get(id);
123
+ return point ? { x: point.x + cell.w / 2, y: point.y + cell.h / 2 } : undefined;
124
+ };
125
+ const lines = edges.flatMap((edge) => {
126
+ const [from, to] = [center(edge.from), center(edge.to)];
127
+ return from && to ? [{ edge, from, to }] : [];
128
+ });
129
+ let count = 0;
130
+ for (let index = 0; index < lines.length; index++) {
131
+ for (let other = index + 1; other < lines.length; other++) {
132
+ const [a, b] = [lines[index], lines[other]];
133
+ const shared = [a.edge.from, a.edge.to].some((id) => id === b.edge.from || id === b.edge.to);
134
+ count += !shared && intersect(a.from, a.to, b.from, b.to) ? 1 : 0;
135
+ }
136
+ }
137
+ return count;
138
+ };
139
+
140
+ const cost = (state: Points) => {
141
+ let total = 0;
142
+ for (const edge of edges) {
143
+ const [from, to] = [state.get(edge.from), state.get(edge.to)];
144
+ if (from && to) {
145
+ const [dx, dy] = [Math.abs(to.x - from.x) / pitch.x, Math.abs(to.y - from.y) / pitch.y];
146
+ total += dx + dy + (dx > 0 && dy > 0 ? BEND_COST : 0);
147
+ }
148
+ }
149
+ const rects = [
150
+ ...groups.map((group) => frameOf(group.members, state)),
151
+ ...loose.flatMap((id) => {
152
+ const point = state.get(id);
153
+ return point ? [{ ...point, ...cell }] : [];
154
+ }),
155
+ ];
156
+ const width = Math.max(...rects.map((rect) => rect.x + rect.w)) - Math.min(...rects.map((rect) => rect.x));
157
+ const height = Math.max(...rects.map((rect) => rect.y + rect.h)) - Math.min(...rects.map((rect) => rect.y));
158
+ return total + EXTENT_COST * (width / pitch.x + height / pitch.y);
159
+ };
160
+
161
+ const units: (readonly string[])[] = [
162
+ ...groups.filter((group) => group.members.length > 1).map((group) => group.members),
163
+ ...[...positions.keys()].map((id) => [id]),
164
+ ];
165
+ const offsets = Array.from({ length: MAX_REACH * 2 + 1 }, (_, index) => index - MAX_REACH);
166
+ const moves = offsets
167
+ .flatMap((dx) => offsets.map((dy) => ({ dx, dy })))
168
+ .filter(({ dx, dy }) => dx !== 0 || dy !== 0)
169
+ .sort((left, right) => Math.abs(left.dx) + Math.abs(left.dy) - Math.abs(right.dx) - Math.abs(right.dy));
170
+
171
+ let state: Points = new Map(positions);
172
+ let current = cost(state);
173
+ let broken = violations(state);
174
+ let crossed = crossings(state);
175
+ for (let sweep = 0; sweep < MAX_SWEEPS; sweep++) {
176
+ let improved = false;
177
+ for (const unit of units) {
178
+ let best: { state: Points; cost: number; violations: number; crossings: number } | undefined;
179
+ for (const { dx, dy } of moves) {
180
+ const next = new Map(state);
181
+ for (const id of unit) {
182
+ const point = state.get(id);
183
+ if (point) {
184
+ next.set(id, { x: point.x + dx * pitch.x, y: point.y + dy * pitch.y });
185
+ }
186
+ }
187
+ const value = cost(next);
188
+ // Strictly cheaper by a margin, so float noise never shuffles equal layouts.
189
+ if (value < (best?.cost ?? current) - 1e-6) {
190
+ const breaches = violations(next);
191
+ const crossing = breaches <= broken ? crossings(next) : Infinity;
192
+ if (crossing <= crossed) {
193
+ best = { state: next, cost: value, violations: breaches, crossings: crossing };
194
+ }
195
+ }
196
+ }
197
+ if (best) {
198
+ state = best.state;
199
+ current = best.cost;
200
+ broken = best.violations;
201
+ crossed = best.crossings;
202
+ improved = true;
203
+ }
204
+ }
205
+ if (!improved) {
206
+ break;
207
+ }
208
+ }
209
+
210
+ // Keep the placement's top-left where it was, so a move of the first package does not shift the drawing.
211
+ const corner = (points: Iterable<Scene.Point>) => {
212
+ const list = [...points];
213
+ return { x: Math.min(...list.map((point) => point.x)), y: Math.min(...list.map((point) => point.y)) };
214
+ };
215
+ const [before, after] = [corner(positions.values()), corner(state.values())];
216
+ return new Map(
217
+ [...state].map(([id, point]) => [id, { x: point.x - after.x + before.x, y: point.y - after.y + before.y }]),
218
+ );
219
+ };