@dxos/diagram 0.11.1 → 0.14.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.
- package/dist/lib/chunk-parse.mjs +9406 -0
- package/dist/lib/chunk-parse.mjs.map +1 -0
- package/dist/lib/extension.mjs +204 -0
- package/dist/lib/extension.mjs.map +1 -0
- package/dist/lib/index.mjs +497 -2846
- package/dist/lib/index.mjs.map +1 -1
- package/dist/types/src/aesthetics.d.ts +12 -0
- package/dist/types/src/aesthetics.d.ts.map +1 -0
- package/dist/types/src/aesthetics.test.d.ts +2 -0
- package/dist/types/src/aesthetics.test.d.ts.map +1 -0
- package/dist/types/src/appeal.d.ts +55 -0
- package/dist/types/src/appeal.d.ts.map +1 -0
- package/dist/types/src/appeal.test.d.ts +2 -0
- package/dist/types/src/appeal.test.d.ts.map +1 -0
- package/dist/types/src/architecture.d.ts +86 -0
- package/dist/types/src/architecture.d.ts.map +1 -0
- package/dist/types/src/architecture.test.d.ts +2 -0
- package/dist/types/src/architecture.test.d.ts.map +1 -0
- package/dist/types/src/compact.d.ts +39 -0
- package/dist/types/src/compact.d.ts.map +1 -0
- package/dist/types/src/compact.test.d.ts +2 -0
- package/dist/types/src/compact.test.d.ts.map +1 -0
- package/dist/types/src/diagnostics.d.ts +70 -3
- package/dist/types/src/diagnostics.d.ts.map +1 -1
- package/dist/types/src/dsl/cli.d.ts +2 -0
- package/dist/types/src/dsl/cli.d.ts.map +1 -0
- package/dist/types/src/dsl/compile.d.ts +12 -0
- package/dist/types/src/dsl/compile.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.d.ts +45 -0
- package/dist/types/src/dsl/convert.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.test.d.ts +2 -0
- package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
- package/dist/types/src/dsl/dsl.test.d.ts +2 -0
- package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
- package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
- package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
- package/dist/types/src/dsl/index.d.ts +7 -0
- package/dist/types/src/dsl/index.d.ts.map +1 -0
- package/dist/types/src/dsl/parse.d.ts +49 -0
- package/dist/types/src/dsl/parse.d.ts.map +1 -0
- package/dist/types/src/dsl/print.d.ts +19 -0
- package/dist/types/src/dsl/print.d.ts.map +1 -0
- package/dist/types/src/dsl/semantic.test.d.ts +2 -0
- package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
- package/dist/types/src/dsl/vocabulary.d.ts +61 -0
- package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
- package/dist/types/src/dx-svg.d.ts +12 -0
- package/dist/types/src/dx-svg.d.ts.map +1 -0
- package/dist/types/src/dx-svg.test.d.ts +2 -0
- package/dist/types/src/dx-svg.test.d.ts.map +1 -0
- package/dist/types/src/extension/complete.d.ts +3 -0
- package/dist/types/src/extension/complete.d.ts.map +1 -0
- package/dist/types/src/extension/extension.test.d.ts +2 -0
- package/dist/types/src/extension/extension.test.d.ts.map +1 -0
- package/dist/types/src/extension/highlight.d.ts +14 -0
- package/dist/types/src/extension/highlight.d.ts.map +1 -0
- package/dist/types/src/extension/index.d.ts +6 -0
- package/dist/types/src/extension/index.d.ts.map +1 -0
- package/dist/types/src/extension/language.d.ts +13 -0
- package/dist/types/src/extension/language.d.ts.map +1 -0
- package/dist/types/src/extension/lint.d.ts +21 -0
- package/dist/types/src/extension/lint.d.ts.map +1 -0
- package/dist/types/src/extension/syntax.d.ts +3 -0
- package/dist/types/src/extension/syntax.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +14 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/mermaid-engine.d.ts +40 -1
- package/dist/types/src/mermaid-engine.d.ts.map +1 -1
- package/dist/types/src/mermaid.d.ts +3 -3
- package/dist/types/src/mermaid.d.ts.map +1 -1
- package/dist/types/src/nudge.d.ts +30 -0
- package/dist/types/src/nudge.d.ts.map +1 -0
- package/dist/types/src/nudge.test.d.ts +2 -0
- package/dist/types/src/nudge.test.d.ts.map +1 -0
- package/dist/types/src/objective.d.ts +4 -0
- package/dist/types/src/objective.d.ts.map +1 -1
- package/dist/types/src/ortho-router.d.ts +13 -1
- package/dist/types/src/ortho-router.d.ts.map +1 -1
- package/dist/types/src/ports.d.ts +40 -0
- package/dist/types/src/ports.d.ts.map +1 -0
- package/dist/types/src/ports.test.d.ts +2 -0
- package/dist/types/src/ports.test.d.ts.map +1 -0
- package/dist/types/src/relations.test.d.ts +2 -0
- package/dist/types/src/relations.test.d.ts.map +1 -0
- package/dist/types/src/rules.d.ts +30 -0
- package/dist/types/src/rules.d.ts.map +1 -0
- package/dist/types/src/scene.d.ts +108 -74
- package/dist/types/src/scene.d.ts.map +1 -1
- package/dist/types/src/score.d.ts +54 -0
- package/dist/types/src/score.d.ts.map +1 -0
- package/dist/types/src/score.test.d.ts +2 -0
- package/dist/types/src/score.test.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.d.ts +41 -0
- package/dist/types/src/semantic-engine.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.test.d.ts +2 -0
- package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
- package/dist/types/src/semantic-place.d.ts +81 -0
- package/dist/types/src/semantic-place.d.ts.map +1 -0
- package/dist/types/src/semantic-route.d.ts +133 -0
- package/dist/types/src/semantic-route.d.ts.map +1 -0
- package/dist/types/src/semantic.d.ts +164 -0
- package/dist/types/src/semantic.d.ts.map +1 -0
- package/dist/types/src/uml-grid.d.ts.map +1 -1
- package/dist/types/src/uml.d.ts +3 -5
- package/dist/types/src/uml.d.ts.map +1 -1
- package/dist/types/src/view.d.ts +53 -0
- package/dist/types/src/view.d.ts.map +1 -0
- package/dist/types/src/view.test.d.ts +2 -0
- package/dist/types/src/view.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -5
- package/rules/DIAGRAM.mdl +388 -0
- package/src/aesthetics.test.ts +89 -0
- package/src/aesthetics.ts +148 -0
- package/src/appeal.test.ts +144 -0
- package/src/appeal.ts +586 -0
- package/src/architecture.test.ts +94 -0
- package/src/architecture.ts +215 -0
- package/src/compact.test.ts +67 -0
- package/src/compact.ts +219 -0
- package/src/diagnostics.test.ts +207 -1
- package/src/diagnostics.ts +481 -37
- package/src/dsl/cli.ts +46 -0
- package/src/dsl/compile.ts +28 -0
- package/src/dsl/convert.test.ts +51 -0
- package/src/dsl/convert.ts +98 -0
- package/src/dsl/diagram.grammar +195 -0
- package/src/dsl/dsl.test.ts +286 -0
- package/src/dsl/gen/diagram.ts +22 -0
- package/src/dsl/index.ts +44 -0
- package/src/dsl/parse.ts +1054 -0
- package/src/dsl/print.ts +198 -0
- package/src/dsl/semantic.test.ts +216 -0
- package/src/dsl/vocabulary.ts +150 -0
- package/src/dx-svg.test.ts +44 -0
- package/src/dx-svg.ts +77 -0
- package/src/extension/complete.ts +105 -0
- package/src/extension/extension.test.ts +135 -0
- package/src/extension/highlight.ts +23 -0
- package/src/extension/index.ts +9 -0
- package/src/extension/language.ts +31 -0
- package/src/extension/lint.ts +60 -0
- package/src/extension/syntax.ts +62 -0
- package/src/index.ts +14 -0
- package/src/mermaid-engine.test.ts +6 -6
- package/src/mermaid-engine.ts +569 -119
- package/src/mermaid.test.ts +17 -1
- package/src/mermaid.ts +30 -18
- package/src/nudge.test.ts +186 -0
- package/src/nudge.ts +387 -0
- package/src/objective.test.ts +2 -0
- package/src/objective.ts +17 -1
- package/src/ortho-router.ts +240 -80
- package/src/ports.test.ts +157 -0
- package/src/ports.ts +186 -0
- package/src/relations.test.ts +98 -0
- package/src/rules.ts +117 -0
- package/src/scene.ts +66 -5
- package/src/score.test.ts +64 -0
- package/src/score.ts +130 -0
- package/src/semantic-engine.test.ts +635 -0
- package/src/semantic-engine.ts +891 -0
- package/src/semantic-place.ts +1508 -0
- package/src/semantic-route.ts +1136 -0
- package/src/semantic.ts +278 -0
- package/src/typings.d.ts +8 -0
- package/src/uml-grid.ts +6 -1
- package/src/uml.test.ts +5 -1
- package/src/uml.ts +24 -11
- package/src/view.test.ts +139 -0
- package/src/view.ts +376 -0
package/src/ports.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// Port ordering: the classic heuristic that the order of connectors along a box side should match
|
|
7
|
+
// the order in which they head off, so they fan out instead of swapping over next to the box. Each
|
|
8
|
+
// terminal is keyed by where its route turns after leaving the side, and the side's free terminals
|
|
9
|
+
// take lattice slots in key order around the ones that stay put.
|
|
10
|
+
//
|
|
11
|
+
|
|
12
|
+
import type * as Scene from './scene.ts';
|
|
13
|
+
import { type Rect } from './uml-grid.ts';
|
|
14
|
+
|
|
15
|
+
type Point = Scene.Point;
|
|
16
|
+
|
|
17
|
+
export type Side = 'top' | 'bottom' | 'left' | 'right';
|
|
18
|
+
|
|
19
|
+
/** A routed connector with the boxes its ends sit on. */
|
|
20
|
+
export type PortPath = {
|
|
21
|
+
points: readonly Point[];
|
|
22
|
+
source: Rect;
|
|
23
|
+
target: Rect;
|
|
24
|
+
/** Ends whose coordinate must not move (a straightened edge, or a face the router picked itself). */
|
|
25
|
+
fixed?: { start?: boolean; end?: boolean };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** New coordinates along the side for the ends that moved; an absent end keeps its place. */
|
|
29
|
+
export type Assigned = { start?: number; end?: number };
|
|
30
|
+
|
|
31
|
+
export type AssignOptions = {
|
|
32
|
+
/** Slot spacing and minimum distance from a box corner. */
|
|
33
|
+
step: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const EPSILON = 0.5;
|
|
37
|
+
|
|
38
|
+
/** Longer than any first segment, so a route that never turns sorts between the two turning fans. */
|
|
39
|
+
const STRAIGHT = 1e6;
|
|
40
|
+
|
|
41
|
+
/** The side of `rect` that `point` sits on, if any. */
|
|
42
|
+
export const sideOf = (point: Point, rect: Rect): Side | undefined => {
|
|
43
|
+
const withinX = point.x >= rect.x - EPSILON && point.x <= rect.x + rect.w + EPSILON;
|
|
44
|
+
const withinY = point.y >= rect.y - EPSILON && point.y <= rect.y + rect.h + EPSILON;
|
|
45
|
+
if (withinX && Math.abs(point.y - rect.y) < EPSILON) {
|
|
46
|
+
return 'top';
|
|
47
|
+
}
|
|
48
|
+
if (withinX && Math.abs(point.y - (rect.y + rect.h)) < EPSILON) {
|
|
49
|
+
return 'bottom';
|
|
50
|
+
}
|
|
51
|
+
if (withinY && Math.abs(point.x - rect.x) < EPSILON) {
|
|
52
|
+
return 'left';
|
|
53
|
+
}
|
|
54
|
+
if (withinY && Math.abs(point.x - (rect.x + rect.w)) < EPSILON) {
|
|
55
|
+
return 'right';
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const axisOf = (side: Side): 'x' | 'y' => (side === 'top' || side === 'bottom' ? 'x' : 'y');
|
|
61
|
+
const normalOf = (side: Side): 'x' | 'y' => (axisOf(side) === 'x' ? 'y' : 'x');
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Sort key of a terminal along its side, from its route read outward: a route turning toward the
|
|
65
|
+
* side's high end belongs at the high end, and of two turning the same way the one that runs out
|
|
66
|
+
* farther before turning sits nearer the low end, so neither turn-off cuts the other's stub.
|
|
67
|
+
*/
|
|
68
|
+
export const keyOf = (outward: readonly Point[], side: Side): number => {
|
|
69
|
+
const axis = axisOf(side);
|
|
70
|
+
const normal = normalOf(side);
|
|
71
|
+
if (outward.length < 3) {
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
const [terminal, bend, next] = outward;
|
|
75
|
+
const run = Math.abs(bend[normal] - terminal[normal]);
|
|
76
|
+
const turn = Math.sign(next[axis] - bend[axis]);
|
|
77
|
+
return turn === 0 ? 0 : turn * (STRAIGHT - run);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type Terminal = { path: number; end: 'start' | 'end'; coord: number; key: number; fixed: boolean };
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Lattice slots for `free` (already in key order) strictly between `low` and `high`, each as near
|
|
84
|
+
* its current coordinate as order and spacing allow; undefined when they do not fit.
|
|
85
|
+
*/
|
|
86
|
+
const fit = (free: readonly Terminal[], low: number, high: number, step: number): number[] | undefined => {
|
|
87
|
+
const snap = (value: number) => Math.round(value / step) * step;
|
|
88
|
+
const first = Math.ceil((low + step - EPSILON) / step) * step;
|
|
89
|
+
const last = Math.floor((high - step + EPSILON) / step) * step;
|
|
90
|
+
if (free.length === 0) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
if ((last - first) / step + 1 < free.length) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const slots: number[] = [];
|
|
97
|
+
for (const [index, terminal] of free.entries()) {
|
|
98
|
+
const floor = index === 0 ? first : slots[index - 1] + step;
|
|
99
|
+
slots.push(Math.max(floor, snap(terminal.coord)));
|
|
100
|
+
}
|
|
101
|
+
for (let index = free.length - 1; index >= 0; index--) {
|
|
102
|
+
const ceiling = index === free.length - 1 ? last : slots[index + 1] - step;
|
|
103
|
+
slots[index] = Math.min(slots[index], ceiling);
|
|
104
|
+
}
|
|
105
|
+
return slots;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Reorders the ports on every box side so they follow their routes' key order, moving only the
|
|
110
|
+
* sides whose current order disagrees with it. Fixed terminals stay where they are and the free
|
|
111
|
+
* ones take slots on the `step` lattice in the gaps between them, at least `step` from a corner.
|
|
112
|
+
*/
|
|
113
|
+
export const assign = (paths: readonly PortPath[], { step }: AssignOptions): Assigned[] => {
|
|
114
|
+
const sides = new Map<string, { rect: Rect; side: Side; terminals: Terminal[] }>();
|
|
115
|
+
const rectIds = new Map<Rect, number>();
|
|
116
|
+
const idOf = (rect: Rect) => rectIds.get(rect) ?? rectIds.set(rect, rectIds.size).get(rect) ?? 0;
|
|
117
|
+
paths.forEach((path, index) => {
|
|
118
|
+
const ends = [
|
|
119
|
+
{ end: 'start' as const, rect: path.source, outward: path.points },
|
|
120
|
+
{ end: 'end' as const, rect: path.target, outward: [...path.points].reverse() },
|
|
121
|
+
];
|
|
122
|
+
for (const { end, rect, outward } of ends) {
|
|
123
|
+
const side = outward.length >= 2 ? sideOf(outward[0], rect) : undefined;
|
|
124
|
+
if (!side) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const leaves = Math.abs(outward[1][axisOf(side)] - outward[0][axisOf(side)]) < EPSILON;
|
|
128
|
+
const id = `${idOf(rect)}:${side}`;
|
|
129
|
+
const entry = sides.get(id) ?? { rect, side, terminals: [] };
|
|
130
|
+
entry.terminals.push({
|
|
131
|
+
path: index,
|
|
132
|
+
end,
|
|
133
|
+
coord: outward[0][axisOf(side)],
|
|
134
|
+
key: keyOf(outward, side),
|
|
135
|
+
// A route that runs along its side before leaving it gives no direction to order by.
|
|
136
|
+
fixed: Boolean(path.fixed?.[end]) || !leaves,
|
|
137
|
+
});
|
|
138
|
+
sides.set(id, entry);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const result: Assigned[] = paths.map(() => ({}));
|
|
143
|
+
for (const { rect, side, terminals } of sides.values()) {
|
|
144
|
+
if (terminals.length < 2 || terminals.every((terminal) => terminal.fixed)) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const sorted = [...terminals].sort(
|
|
148
|
+
(left, right) => left.key - right.key || left.coord - right.coord || left.path - right.path,
|
|
149
|
+
);
|
|
150
|
+
// Equal coordinates are a shared trunk, which the nudge splits; only distinct ones out of order cross.
|
|
151
|
+
const inverted = sorted.some((terminal, index) => index > 0 && terminal.coord < sorted[index - 1].coord - EPSILON);
|
|
152
|
+
if (!inverted) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const axis = axisOf(side);
|
|
156
|
+
const [lowEdge, highEdge] = axis === 'x' ? [rect.x, rect.x + rect.w] : [rect.y, rect.y + rect.h];
|
|
157
|
+
const fixed = sorted.filter((terminal) => terminal.fixed);
|
|
158
|
+
// Fixed terminals must already be in key order for the free ones to fit around them.
|
|
159
|
+
if (fixed.some((terminal, index) => index > 0 && terminal.coord < fixed[index - 1].coord - EPSILON)) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const bounds = [lowEdge, ...fixed.map((terminal) => terminal.coord), highEdge];
|
|
163
|
+
const groups: Terminal[][] = bounds.slice(1).map(() => []);
|
|
164
|
+
let gap = 0;
|
|
165
|
+
for (const terminal of sorted) {
|
|
166
|
+
if (terminal.fixed) {
|
|
167
|
+
gap++;
|
|
168
|
+
} else {
|
|
169
|
+
groups[gap].push(terminal);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const placed = groups.map((group, index) => fit(group, bounds[index], bounds[index + 1], step));
|
|
173
|
+
if (placed.some((slots) => !slots)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
groups.forEach((group, index) =>
|
|
177
|
+
group.forEach((terminal, position) => {
|
|
178
|
+
const coord = placed[index]?.[position];
|
|
179
|
+
if (coord !== undefined) {
|
|
180
|
+
result[terminal.path][terminal.end] = coord;
|
|
181
|
+
}
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { parse as parseDsl, printCommands, read } from './dsl/index.ts';
|
|
8
|
+
import { markers, parse as parseMermaid } from './mermaid.ts';
|
|
9
|
+
import * as Scene from './scene.ts';
|
|
10
|
+
import { relationStyle } from './uml.ts';
|
|
11
|
+
|
|
12
|
+
describe('relations', () => {
|
|
13
|
+
test('each relation derives its markers, and head/tail still override', ({ expect }) => {
|
|
14
|
+
expect(Scene.markersOf({ relation: 'inheritance' })).toEqual({ end: 'triangle', dashed: false });
|
|
15
|
+
expect(Scene.markersOf({ relation: 'implementation' })).toEqual({ end: 'triangle', dashed: true });
|
|
16
|
+
expect(Scene.markersOf({ relation: 'composition' })).toEqual({ start: 'diamond-filled', dashed: false });
|
|
17
|
+
expect(Scene.markersOf({ relation: 'aggregation' })).toEqual({ start: 'diamond', dashed: false });
|
|
18
|
+
expect(Scene.markersOf({ relation: 'one-to-many' })).toEqual({ start: 'one', end: 'crowsfoot', dashed: false });
|
|
19
|
+
expect(Scene.markersOf({ relation: 'many-to-many' })).toEqual({
|
|
20
|
+
start: 'crowsfoot',
|
|
21
|
+
end: 'crowsfoot',
|
|
22
|
+
dashed: false,
|
|
23
|
+
});
|
|
24
|
+
expect(Scene.markersOf({ relation: 'dependency' })).toEqual({ end: 'open', dashed: true });
|
|
25
|
+
// Documents from before relations keep drawing as they did.
|
|
26
|
+
expect(Scene.markersOf({})).toEqual({ end: 'arrow', dashed: false });
|
|
27
|
+
expect(Scene.markersOf({ head: 'triangle', tail: 'circle' })).toEqual({
|
|
28
|
+
start: 'circle',
|
|
29
|
+
end: 'triangle',
|
|
30
|
+
dashed: false,
|
|
31
|
+
});
|
|
32
|
+
expect(Scene.markersOf({ relation: 'inheritance', head: 'none' })).toEqual({ dashed: false });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('the DSL relationship words', ({ expect }) => {
|
|
36
|
+
const { diagram, problems } = read(`
|
|
37
|
+
node Animal node Dog node Shape node Square node Order node Line node Team node Member
|
|
38
|
+
node Customer node Student node Course node App node Log
|
|
39
|
+
edge Dog extends Animal
|
|
40
|
+
edge Square implements Shape
|
|
41
|
+
edge Order composes Line
|
|
42
|
+
edge Team owns Member
|
|
43
|
+
edge Customer one-to-many Order
|
|
44
|
+
edge Student many-to-many Course
|
|
45
|
+
edge App depends-on Log
|
|
46
|
+
edge App -> Log
|
|
47
|
+
`);
|
|
48
|
+
expect(problems).toEqual([]);
|
|
49
|
+
expect(diagram?.edges.map((edge) => `${edge.from.node}:${edge.relation ?? '-'}:${edge.to.node}`)).toEqual([
|
|
50
|
+
'Dog:inheritance:Animal',
|
|
51
|
+
'Square:implementation:Shape',
|
|
52
|
+
'Order:composition:Line',
|
|
53
|
+
'Team:aggregation:Member',
|
|
54
|
+
'Customer:one-to-many:Order',
|
|
55
|
+
'Student:many-to-many:Course',
|
|
56
|
+
'App:dependency:Log',
|
|
57
|
+
'App:-:Log',
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('an unknown relationship word is an error on the word', ({ expect }) => {
|
|
62
|
+
const text = 'node A\nnode B\nedge A loves B';
|
|
63
|
+
const [problem] = parseDsl(text).problems;
|
|
64
|
+
expect(text.slice(problem.from, problem.to)).toBe('loves');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('laid-out edges carry their relation, and the scene round-trips through print and parse', ({ expect }) => {
|
|
68
|
+
const { commands } = parseDsl(
|
|
69
|
+
'node Animal\nnode Dog below Animal\nedge Dog extends Animal\nnode Leg below Dog\nedge Dog composes Leg',
|
|
70
|
+
);
|
|
71
|
+
const edges = commands.flatMap((command) =>
|
|
72
|
+
command.op === 'upsert-object' && command.object.id === 'edges' ? command.object.elements : [],
|
|
73
|
+
);
|
|
74
|
+
const relations = edges.flatMap((element) => (element.kind === 'arrow' ? [element.relation] : []));
|
|
75
|
+
expect(relations).toEqual(['inheritance', 'composition']);
|
|
76
|
+
const printed = printCommands(commands);
|
|
77
|
+
expect(printed).toContain('relation=inheritance');
|
|
78
|
+
expect(printCommands(parseDsl(printed).commands)).toBe(printed);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('mermaid tokens, UML class relations and DSL words agree', ({ expect }) => {
|
|
82
|
+
const graph = parseMermaid(
|
|
83
|
+
['flowchart TB', ' B --|> A', ' C ..|> A', ' E o--> F', ' G --{ H', ' I -.-> J'].join('\n'),
|
|
84
|
+
);
|
|
85
|
+
expect(graph.edges.map(({ kind }) => markers(kind).relation)).toEqual([
|
|
86
|
+
'inheritance',
|
|
87
|
+
'implementation',
|
|
88
|
+
'aggregation',
|
|
89
|
+
'one-to-many',
|
|
90
|
+
'dependency',
|
|
91
|
+
]);
|
|
92
|
+
expect(
|
|
93
|
+
(['inheritance', 'realization', 'composition', 'aggregation', 'dependency', 'association'] as const).map(
|
|
94
|
+
(kind) => relationStyle(kind).relation,
|
|
95
|
+
),
|
|
96
|
+
).toEqual(['inheritance', 'implementation', 'composition', 'aggregation', 'dependency', undefined]);
|
|
97
|
+
});
|
|
98
|
+
});
|
package/src/rules.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// The diagram rule library: what makes a drawing read well to a person, as `diagram-rule` blocks in
|
|
7
|
+
// `rules/DIAGRAM.mdl` (a term of its own, so the code-review harness, which reads `rule` blocks, never
|
|
8
|
+
// picks these up). Each rule names how it is judged: `code` rules are measured from the scene's
|
|
9
|
+
// geometry by `Appeal`, `vision` rules are asked of a decision model shown the rendered image, and
|
|
10
|
+
// `both` rules have a geometric measure that the image judge can be checked against.
|
|
11
|
+
//
|
|
12
|
+
|
|
13
|
+
import text from '../rules/DIAGRAM.mdl?raw';
|
|
14
|
+
|
|
15
|
+
export type Evaluator = 'code' | 'vision' | 'both';
|
|
16
|
+
|
|
17
|
+
export type Category = 'crossings' | 'flow' | 'alignment' | 'space' | 'groups' | 'labels' | 'edges' | 'hierarchy';
|
|
18
|
+
|
|
19
|
+
export type DiagramRule = {
|
|
20
|
+
/** Kebab-case id; `Appeal`'s evaluators and the image judge's decision keys are derived from it. */
|
|
21
|
+
readonly id: string;
|
|
22
|
+
/** What a good diagram does, in one line. */
|
|
23
|
+
readonly title: string;
|
|
24
|
+
readonly category: Category;
|
|
25
|
+
readonly evaluator: Evaluator;
|
|
26
|
+
/** Relative importance in the overall appeal score; the research ranks crossings far above the rest. */
|
|
27
|
+
readonly weight: number;
|
|
28
|
+
/** The yes/no question a vision judge answers; `yes` means the drawing follows the rule. */
|
|
29
|
+
readonly question?: string;
|
|
30
|
+
readonly criteria?: { readonly true: string; readonly false: string };
|
|
31
|
+
/** Rationale, the evidence behind the rule, and what does not count against it. */
|
|
32
|
+
readonly prose: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const EVALUATORS: readonly Evaluator[] = ['code', 'vision', 'both'];
|
|
36
|
+
const CATEGORIES: readonly Category[] = [
|
|
37
|
+
'crossings',
|
|
38
|
+
'flow',
|
|
39
|
+
'alignment',
|
|
40
|
+
'space',
|
|
41
|
+
'groups',
|
|
42
|
+
'labels',
|
|
43
|
+
'edges',
|
|
44
|
+
'hierarchy',
|
|
45
|
+
];
|
|
46
|
+
const FIELDS = new Set(['category', 'evaluator', 'weight', 'question', 'yes', 'no']);
|
|
47
|
+
|
|
48
|
+
const isEvaluator = (value: string): value is Evaluator => (EVALUATORS as readonly string[]).includes(value);
|
|
49
|
+
const isCategory = (value: string): value is Category => (CATEGORIES as readonly string[]).includes(value);
|
|
50
|
+
|
|
51
|
+
/** The line arrays of every ```mdl fence in a document. */
|
|
52
|
+
const fences = (document: string): string[][] => {
|
|
53
|
+
const blocks: string[][] = [];
|
|
54
|
+
let block: string[] | undefined;
|
|
55
|
+
for (const line of document.split(/\r?\n/)) {
|
|
56
|
+
if (!block) {
|
|
57
|
+
if (/^\s*```mdl\s*$/.test(line)) {
|
|
58
|
+
block = [];
|
|
59
|
+
}
|
|
60
|
+
} else if (/^\s*```\s*$/.test(line)) {
|
|
61
|
+
blocks.push(block);
|
|
62
|
+
block = undefined;
|
|
63
|
+
} else {
|
|
64
|
+
block.push(line);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return blocks;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parses the `diagram-rule` blocks of an `.mdl` document, ignoring every other block. A malformed rule
|
|
72
|
+
* throws, naming it, since a rule that silently drops out would leave its evaluator unbound.
|
|
73
|
+
*/
|
|
74
|
+
export const parse = (document: string): DiagramRule[] =>
|
|
75
|
+
fences(document).flatMap((lines) => {
|
|
76
|
+
const start = lines.findIndex((line) => line.trim() !== '');
|
|
77
|
+
const header = start >= 0 ? /^diagram-rule\s+([a-z0-9-]+)\s*:\s*(.+)$/.exec(lines[start].trim()) : null;
|
|
78
|
+
if (!header) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const [, id, title] = header;
|
|
82
|
+
const fields: Record<string, string> = {};
|
|
83
|
+
const prose: string[] = [];
|
|
84
|
+
for (const line of lines.slice(start + 1)) {
|
|
85
|
+
const field = /^\s*([a-z]+)\s*:\s*(.*)$/.exec(line);
|
|
86
|
+
if (field && FIELDS.has(field[1])) {
|
|
87
|
+
fields[field[1]] = field[2].trim();
|
|
88
|
+
} else {
|
|
89
|
+
prose.push(line.trim());
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const { category = '', evaluator = '', weight = '1', question, yes, no } = fields;
|
|
93
|
+
if (!isCategory(category) || !isEvaluator(evaluator) || !Number.isFinite(Number(weight))) {
|
|
94
|
+
throw new Error(`diagram-rule ${id}: needs category (${CATEGORIES.join('|')}), evaluator and a numeric weight.`);
|
|
95
|
+
}
|
|
96
|
+
if (evaluator !== 'code' && !question) {
|
|
97
|
+
throw new Error(`diagram-rule ${id}: a ${evaluator} rule needs a question for the image judge.`);
|
|
98
|
+
}
|
|
99
|
+
return [
|
|
100
|
+
{
|
|
101
|
+
id,
|
|
102
|
+
title,
|
|
103
|
+
category,
|
|
104
|
+
evaluator,
|
|
105
|
+
weight: Number(weight),
|
|
106
|
+
...(question ? { question } : {}),
|
|
107
|
+
...(yes && no ? { criteria: { true: yes, false: no } } : {}),
|
|
108
|
+
prose: prose.join('\n').trim(),
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** Every rule in `rules/DIAGRAM.mdl`. */
|
|
114
|
+
export const RULES: readonly DiagramRule[] = parse(text);
|
|
115
|
+
|
|
116
|
+
/** The camelCase decision key a judge answers a rule under. */
|
|
117
|
+
export const keyOf = (id: string): string => id.replace(/-([a-z0-9])/g, (_, letter: string) => letter.toUpperCase());
|
package/src/scene.ts
CHANGED
|
@@ -28,7 +28,8 @@ export const Color = Schema.Literals([
|
|
|
28
28
|
]);
|
|
29
29
|
export type Color = Schema.Schema.Type<typeof Color>;
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/** `tint` is a light wash of the element's color, for backdrops such as group frames that text sits on. */
|
|
32
|
+
export const Fill = Schema.Literals(['none', 'solid', 'pattern', 'tint']);
|
|
32
33
|
export type Fill = Schema.Schema.Type<typeof Fill>;
|
|
33
34
|
|
|
34
35
|
export const Stroke = Schema.Literals(['sketchy', 'solid', 'dashed', 'dotted']);
|
|
@@ -55,9 +56,17 @@ const id = Schema.String.annotate({
|
|
|
55
56
|
description: 'Stable element id, unique within the object (e.g. "left-eye"). Used to edit or delete the element.',
|
|
56
57
|
});
|
|
57
58
|
|
|
59
|
+
/** The closed-shape kinds {@link Box} covers. */
|
|
60
|
+
export const BoxKind = Schema.Literals(['rect', 'ellipse', 'diamond', 'triangle']);
|
|
61
|
+
export type BoxKind = Schema.Schema.Type<typeof BoxKind>;
|
|
62
|
+
|
|
63
|
+
/** Which corners a rect rounds. */
|
|
64
|
+
export const Corners = Schema.Literals(['top', 'bottom', 'none']);
|
|
65
|
+
export type Corners = Schema.Schema.Type<typeof Corners>;
|
|
66
|
+
|
|
58
67
|
/** Closed shape drawn inside a local bounding box. */
|
|
59
68
|
export const Box = Schema.Struct({
|
|
60
|
-
kind:
|
|
69
|
+
kind: BoxKind,
|
|
61
70
|
id,
|
|
62
71
|
x: Schema.Number.annotate({ description: 'Left edge (object-local units).' }),
|
|
63
72
|
y: Schema.Number.annotate({ description: 'Top edge (object-local units).' }),
|
|
@@ -65,7 +74,7 @@ export const Box = Schema.Struct({
|
|
|
65
74
|
h: Schema.Number,
|
|
66
75
|
rotation: Schema.optional(Schema.Number).annotate({ description: 'Clockwise rotation in degrees.' }),
|
|
67
76
|
text: Schema.optional(Schema.String).annotate({ description: 'Centered label.' }),
|
|
68
|
-
corners: Schema.optional(
|
|
77
|
+
corners: Schema.optional(Corners).annotate({
|
|
69
78
|
description: 'Rects only: round just these corners (default all); backends without corner control ignore it.',
|
|
70
79
|
}),
|
|
71
80
|
...styleFields,
|
|
@@ -135,9 +144,57 @@ export type Text = Schema.Schema.Type<typeof Text>;
|
|
|
135
144
|
/** UML-style end markers: the head sits at the target, the tail at the source. */
|
|
136
145
|
export const ArrowHead = Schema.Literals(['arrow', 'triangle', 'crowsfoot', 'none']);
|
|
137
146
|
export type ArrowHead = Schema.Schema.Type<typeof ArrowHead>;
|
|
138
|
-
export const ArrowTail = Schema.Literals(['none', 'circle']);
|
|
147
|
+
export const ArrowTail = Schema.Literals(['none', 'circle', 'arrow']);
|
|
139
148
|
export type ArrowTail = Schema.Schema.Type<typeof ArrowTail>;
|
|
140
149
|
|
|
150
|
+
/**
|
|
151
|
+
* What a connector means, in UML and ER terms; renderers derive its end markers and dash from it.
|
|
152
|
+
* The source end is the subtype (inheritance, implementation), the whole (composition), the owner
|
|
153
|
+
* (aggregation), or the one side (one-to-many); the target end is the other.
|
|
154
|
+
*/
|
|
155
|
+
export const Relation = Schema.Literals([
|
|
156
|
+
'association',
|
|
157
|
+
'dependency',
|
|
158
|
+
'inheritance',
|
|
159
|
+
'implementation',
|
|
160
|
+
'composition',
|
|
161
|
+
'aggregation',
|
|
162
|
+
'one-to-many',
|
|
163
|
+
'many-to-many',
|
|
164
|
+
]);
|
|
165
|
+
export type Relation = Schema.Schema.Type<typeof Relation>;
|
|
166
|
+
|
|
167
|
+
/** A marker a renderer draws at a connector end. */
|
|
168
|
+
export type Marker = 'arrow' | 'open' | 'triangle' | 'crowsfoot' | 'one' | 'diamond' | 'diamond-filled' | 'circle';
|
|
169
|
+
|
|
170
|
+
export type RelationMarkers = { start?: Marker; end?: Marker; dashed: boolean };
|
|
171
|
+
|
|
172
|
+
const RELATION_MARKERS: Record<Relation, RelationMarkers> = {
|
|
173
|
+
'association': { end: 'arrow', dashed: false },
|
|
174
|
+
'dependency': { end: 'open', dashed: true },
|
|
175
|
+
'inheritance': { end: 'triangle', dashed: false },
|
|
176
|
+
'implementation': { end: 'triangle', dashed: true },
|
|
177
|
+
'composition': { start: 'diamond-filled', dashed: false },
|
|
178
|
+
'aggregation': { start: 'diamond', dashed: false },
|
|
179
|
+
'one-to-many': { start: 'one', end: 'crowsfoot', dashed: false },
|
|
180
|
+
'many-to-many': { start: 'crowsfoot', end: 'crowsfoot', dashed: false },
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The markers an arrow draws: its relation's, unless `head` or `tail` says otherwise, which keeps
|
|
185
|
+
* documents written before relations existed drawing as they did.
|
|
186
|
+
*/
|
|
187
|
+
export const markersOf = (arrow: Pick<Arrow, 'relation' | 'head' | 'tail' | 'stroke'>): RelationMarkers => {
|
|
188
|
+
const derived = arrow.relation ? RELATION_MARKERS[arrow.relation] : { end: 'arrow' as const, dashed: false };
|
|
189
|
+
const end = arrow.head === undefined ? derived.end : arrow.head === 'none' ? undefined : arrow.head;
|
|
190
|
+
const start = arrow.tail === undefined ? derived.start : arrow.tail === 'none' ? undefined : arrow.tail;
|
|
191
|
+
return {
|
|
192
|
+
...(start ? { start } : {}),
|
|
193
|
+
...(end ? { end } : {}),
|
|
194
|
+
dashed: arrow.stroke === undefined ? derived.dashed : arrow.stroke === 'dashed',
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
|
|
141
198
|
/**
|
|
142
199
|
* Connector. Endpoints are element refs — `"<elementId>"` within the same object or
|
|
143
200
|
* `"<objectId>/<elementId>"` across objects, either with an optional `#<port>` naming an
|
|
@@ -156,7 +213,11 @@ export const Arrow = Schema.Struct({
|
|
|
156
213
|
description: 'Marker at the target end (default arrow): triangle for inheritance, crowsfoot for has-many.',
|
|
157
214
|
}),
|
|
158
215
|
tail: Schema.optional(ArrowTail).annotate({
|
|
159
|
-
description: 'Marker at the source end (default none): circle for containment.',
|
|
216
|
+
description: 'Marker at the source end (default none): circle for containment, arrow for a two-way connector.',
|
|
217
|
+
}),
|
|
218
|
+
relation: Schema.optional(Relation).annotate({
|
|
219
|
+
description:
|
|
220
|
+
'What the connector means; sets its markers: inheritance and implementation (hollow triangle at the target), composition and aggregation (filled or hollow diamond at the source, the whole), one-to-many (bar at the source, crow’s foot at the target), many-to-many, dependency (dashed, open arrow), association.',
|
|
160
221
|
}),
|
|
161
222
|
...styleFields,
|
|
162
223
|
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import { describe, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { analyze } from './diagnostics.ts';
|
|
9
|
+
import { DEFAULT, type Layout } from './objective.ts';
|
|
10
|
+
import type * as Scene from './scene.ts';
|
|
11
|
+
import { HALF_COST, evaluate, fromCost, fromObjective, overall } from './score.ts';
|
|
12
|
+
|
|
13
|
+
const box = (id: string, x: number, y: number): Scene.WorldObject => ({
|
|
14
|
+
id,
|
|
15
|
+
origin: { x, y },
|
|
16
|
+
elements: [{ kind: 'rect', id: 'frame', x: 0, y: 0, w: 64, h: 64 }],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const layoutOf = (objects: Scene.WorldObject[]): Layout => ({ objects, report: analyze(objects) });
|
|
20
|
+
|
|
21
|
+
describe('score', () => {
|
|
22
|
+
test('cost maps to 1 at zero, 0.5 at half, towards 0 beyond', ({ expect }) => {
|
|
23
|
+
expect(fromCost(0)).toBe(1);
|
|
24
|
+
expect(fromCost(HALF_COST)).toBe(0.5);
|
|
25
|
+
expect(fromCost(HALF_COST * 99)).toBeCloseTo(0.01);
|
|
26
|
+
expect(fromCost(-1)).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('objective scorers are constraints then costs, all in [0, 1]', ({ expect }) => {
|
|
30
|
+
const scores = Effect.runSync(evaluate(fromObjective(DEFAULT), layoutOf([box('a', 0, 0), box('b', 256, 0)])));
|
|
31
|
+
expect(scores.map(({ kind }) => kind)).toEqual([
|
|
32
|
+
...DEFAULT.constraints.map(() => 'constraint'),
|
|
33
|
+
...DEFAULT.costs.map(() => 'cost'),
|
|
34
|
+
]);
|
|
35
|
+
for (const { score } of scores) {
|
|
36
|
+
expect(score).toBeGreaterThanOrEqual(0);
|
|
37
|
+
expect(score).toBeLessThanOrEqual(1);
|
|
38
|
+
}
|
|
39
|
+
expect(overall(scores)).toBeGreaterThan(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a broken constraint scores 0 and gates the overall score', ({ expect }) => {
|
|
43
|
+
const scores = Effect.runSync(evaluate(fromObjective(DEFAULT), layoutOf([box('a', 0, 0), box('b', 32, 32)])));
|
|
44
|
+
const hard = scores.find(({ id }) => id === 'no-hard-defects');
|
|
45
|
+
expect(hard?.score).toBe(0);
|
|
46
|
+
expect(hard?.detail).toMatch(/overlap/);
|
|
47
|
+
expect(overall(scores)).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('nothing judged is no score, not a perfect one', ({ expect }) => {
|
|
51
|
+
expect(overall([])).toBeUndefined();
|
|
52
|
+
expect(overall([{ kind: 'architecture', score: 0, error: 'down' }])).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('other kinds join the mean', ({ expect }) => {
|
|
56
|
+
expect(
|
|
57
|
+
overall([
|
|
58
|
+
{ kind: 'constraint', score: 1 },
|
|
59
|
+
{ kind: 'cost', score: 0.5 },
|
|
60
|
+
{ kind: 'judge', score: 1 },
|
|
61
|
+
]),
|
|
62
|
+
).toBe(0.75);
|
|
63
|
+
});
|
|
64
|
+
});
|