@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.
- package/dist/lib/chunk-parse.mjs +9396 -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 +99 -65
- 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 +200 -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 +155 -0
- package/src/nudge.ts +385 -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 +64 -3
- 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 +888 -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/diagnostics.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import * as Schema from 'effect/Schema';
|
|
13
13
|
|
|
14
14
|
import * as Layout from './layout.ts';
|
|
15
|
-
import
|
|
15
|
+
import * as Scene from './scene.ts';
|
|
16
16
|
|
|
17
17
|
export const Severity = Schema.Literals(['error', 'warning']);
|
|
18
18
|
export type Severity = Schema.Schema.Type<typeof Severity>;
|
|
@@ -28,6 +28,8 @@ export const Code = Schema.Literals([
|
|
|
28
28
|
'label-overflow',
|
|
29
29
|
'edge-crossing',
|
|
30
30
|
'excessive-bends',
|
|
31
|
+
'text-overlap',
|
|
32
|
+
'edge-overlap',
|
|
31
33
|
]);
|
|
32
34
|
export type Code = Schema.Schema.Type<typeof Code>;
|
|
33
35
|
|
|
@@ -37,6 +39,8 @@ export const SEVERITY: Record<Code, Severity> = {
|
|
|
37
39
|
'label-overflow': 'error',
|
|
38
40
|
'edge-crossing': 'warning',
|
|
39
41
|
'excessive-bends': 'warning',
|
|
42
|
+
'text-overlap': 'warning',
|
|
43
|
+
'edge-overlap': 'warning',
|
|
40
44
|
};
|
|
41
45
|
|
|
42
46
|
/** A schema so operations can return diagnostics to the agent — the repair loop. */
|
|
@@ -53,10 +57,15 @@ export type Diagnostic = Schema.Schema.Type<typeof Diagnostic>;
|
|
|
53
57
|
/** Aggregate counts; the soft ones are the score a layout change is reviewed against. */
|
|
54
58
|
export type Metrics = {
|
|
55
59
|
nodes: number;
|
|
60
|
+
/** Connectors as a reader traces them, source to target: a bus counts once per spoke. */
|
|
56
61
|
connectors: number;
|
|
57
62
|
overlaps: number;
|
|
58
63
|
routesThroughNodes: number;
|
|
59
64
|
labelOverflows: number;
|
|
65
|
+
/** Free text (edge and frame labels) overlapping another label or a box it does not belong to. */
|
|
66
|
+
textOverlaps: number;
|
|
67
|
+
/** Connector pairs that run along the same line for part of their length, so a reader cannot tell them apart. */
|
|
68
|
+
edgeOverlaps: number;
|
|
60
69
|
crossings: number;
|
|
61
70
|
bends: number;
|
|
62
71
|
/** Total connector length in scene units; long edges are hard to follow. */
|
|
@@ -114,36 +123,157 @@ const nodes = (objects: readonly Scene.WorldObject[]): Node[] =>
|
|
|
114
123
|
}),
|
|
115
124
|
);
|
|
116
125
|
|
|
117
|
-
|
|
126
|
+
/** Length two axis-aligned segments share when they lie on the same line; 0 when they only cross or touch. */
|
|
127
|
+
const sharedRun = ([a, b]: Segment, [c, d]: Segment): number => {
|
|
128
|
+
const overlap = (lo1: number, hi1: number, lo2: number, hi2: number) =>
|
|
129
|
+
Math.max(0, Math.min(Math.max(lo1, hi1), Math.max(lo2, hi2)) - Math.max(Math.min(lo1, hi1), Math.min(lo2, hi2)));
|
|
130
|
+
const horizontal = (p: Point, q: Point) => Math.abs(p.y - q.y) < EPSILON;
|
|
131
|
+
const vertical = (p: Point, q: Point) => Math.abs(p.x - q.x) < EPSILON;
|
|
132
|
+
if (horizontal(a, b) && horizontal(c, d) && Math.abs(a.y - c.y) < EPSILON) {
|
|
133
|
+
return overlap(a.x, b.x, c.x, d.x);
|
|
134
|
+
}
|
|
135
|
+
if (vertical(a, b) && vertical(c, d) && Math.abs(a.x - c.x) < EPSILON) {
|
|
136
|
+
return overlap(a.y, b.y, c.y, d.y);
|
|
137
|
+
}
|
|
138
|
+
return 0;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export type Label = { ref: string; rect: Rect };
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Absolute extents of free text elements, estimated from the font metrics as the renderers draw it:
|
|
145
|
+
* `x, y` is the top-left, one line per `\n`.
|
|
146
|
+
*/
|
|
147
|
+
export const labels = (objects: readonly Scene.WorldObject[]): Label[] =>
|
|
148
|
+
objects.flatMap((object) =>
|
|
149
|
+
object.elements.flatMap((element) => {
|
|
150
|
+
if (element.kind !== 'text') {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const font = Layout.FONT_METRICS[element.weight ?? 's'];
|
|
154
|
+
const scale = object.scale ?? 1;
|
|
155
|
+
const lines = element.text.split('\n');
|
|
156
|
+
const origin = place(object, { x: element.x, y: element.y });
|
|
157
|
+
return [
|
|
158
|
+
{
|
|
159
|
+
ref: `${object.id}/${element.id}`,
|
|
160
|
+
rect: {
|
|
161
|
+
x: origin.x,
|
|
162
|
+
y: origin.y,
|
|
163
|
+
w: Math.max(...lines.map((line) => line.length)) * font.charW * scale,
|
|
164
|
+
h: lines.length * font.lineH * scale,
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
/** Absolute rects of every shape an arrow can bind to, keyed `objectId/elementId` as `Scene.resolveRef` spells them. */
|
|
172
|
+
export const bindTargets = (objects: readonly Scene.WorldObject[]): Map<string, Rect> => {
|
|
173
|
+
const targets = new Map<string, Rect>();
|
|
174
|
+
for (const object of objects) {
|
|
175
|
+
const scale = object.scale ?? 1;
|
|
176
|
+
for (const element of object.elements) {
|
|
177
|
+
if (isBox(element) || element.kind === 'portal') {
|
|
178
|
+
const origin = place(object, { x: element.x, y: element.y });
|
|
179
|
+
targets.set(`${object.id}/${element.id}`, {
|
|
180
|
+
x: origin.x,
|
|
181
|
+
y: origin.y,
|
|
182
|
+
w: element.w * scale,
|
|
183
|
+
h: element.h * scale,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return targets;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const centerOf = ({ x, y, w, h }: Rect): Point => ({ x: x + w / 2, y: y + h / 2 });
|
|
192
|
+
|
|
193
|
+
/** Point where the segment from a rect's centre toward `target` crosses its border. */
|
|
194
|
+
const clipToBorder = (rect: Rect, target: Point): Point => {
|
|
195
|
+
const source = centerOf(rect);
|
|
196
|
+
const dx = target.x - source.x;
|
|
197
|
+
const dy = target.y - source.y;
|
|
198
|
+
let along = 1;
|
|
199
|
+
if (dx !== 0) {
|
|
200
|
+
along = Math.min(along, ((dx > 0 ? rect.x + rect.w : rect.x) - source.x) / dx);
|
|
201
|
+
}
|
|
202
|
+
if (dy !== 0) {
|
|
203
|
+
along = Math.min(along, ((dy > 0 ? rect.y + rect.h : rect.y) - source.y) / dy);
|
|
204
|
+
}
|
|
205
|
+
return { x: source.x + dx * along, y: source.y + dy * along };
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** How far a self-loop stands off its shape. */
|
|
209
|
+
const LOOP = 24;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The polyline drawn for an arrow, in absolute coordinates: a bound end sits where the line between the
|
|
213
|
+
* two centres leaves its shape, and an arrow bound to one shape at both ends loops off its top-right
|
|
214
|
+
* corner. Undefined when an end resolves to nothing. Renderers draw through this so the picture and the
|
|
215
|
+
* measurement cannot disagree.
|
|
216
|
+
*/
|
|
217
|
+
export const arrowPoints = (
|
|
218
|
+
object: Scene.WorldObject,
|
|
219
|
+
arrow: Scene.Arrow,
|
|
220
|
+
targets: ReadonlyMap<string, Rect>,
|
|
221
|
+
): Point[] | undefined => {
|
|
222
|
+
const resolve = (ref?: string) => (ref === undefined ? undefined : targets.get(Scene.resolveRef(ref, object.id)));
|
|
223
|
+
const [from, to] = [resolve(arrow.from), resolve(arrow.to)];
|
|
224
|
+
if (from && from === to) {
|
|
225
|
+
const [right, top] = [from.x + from.w, from.y];
|
|
226
|
+
return [
|
|
227
|
+
{ x: from.x + from.w * 0.75, y: top },
|
|
228
|
+
{ x: from.x + from.w * 0.75, y: top - LOOP },
|
|
229
|
+
{ x: right + LOOP, y: top - LOOP },
|
|
230
|
+
{ x: right + LOOP, y: top + from.h * 0.25 },
|
|
231
|
+
{ x: right, y: top + from.h * 0.25 },
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
const explicitStart = arrow.start && place(object, arrow.start);
|
|
235
|
+
const explicitEnd = arrow.end && place(object, arrow.end);
|
|
236
|
+
const start = from
|
|
237
|
+
? clipToBorder(from, to ? centerOf(to) : (explicitEnd ?? place(object, { x: 0, y: 0 })))
|
|
238
|
+
: explicitStart;
|
|
239
|
+
const end = to
|
|
240
|
+
? clipToBorder(to, from ? centerOf(from) : (explicitStart ?? place(object, { x: 0, y: 0 })))
|
|
241
|
+
: explicitEnd;
|
|
242
|
+
return start && end ? [start, end] : undefined;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** A drawn connector piece; `headed` when it ends in an arrow element rather than a bare line. */
|
|
246
|
+
type Connector = { ref: string; points: Point[]; headed: boolean };
|
|
118
247
|
|
|
119
248
|
/**
|
|
120
249
|
* Connector paths, rejoining the `line` prefix and `arrow` head the emitters split a routed edge
|
|
121
|
-
* into (`<id>-path` + `<id>`); a plain arrow is a one-segment path
|
|
250
|
+
* into (`<id>-path` + `<id>`); a plain arrow is a one-segment path, a bound one drawn as `arrowPoints`.
|
|
122
251
|
*/
|
|
123
|
-
const connectors = (objects: readonly Scene.WorldObject[]): Connector[] => {
|
|
252
|
+
const connectors = (objects: readonly Scene.WorldObject[], targets: ReadonlyMap<string, Rect>): Connector[] => {
|
|
124
253
|
const paths = new Map<string, Connector>();
|
|
125
254
|
for (const object of objects) {
|
|
126
255
|
for (const element of object.elements) {
|
|
127
256
|
if (element.kind === 'line') {
|
|
128
257
|
const id = element.id.replace(/-path$/, '');
|
|
129
258
|
const ref = `${object.id}/${id}`;
|
|
130
|
-
paths.set(ref, { ref, points: element.points.map((point) => place(object, point)) });
|
|
259
|
+
paths.set(ref, { ref, points: element.points.map((point) => place(object, point)), headed: false });
|
|
131
260
|
}
|
|
132
261
|
}
|
|
133
262
|
for (const element of object.elements) {
|
|
134
|
-
|
|
263
|
+
const head = element.kind === 'arrow' ? arrowPoints(object, element, targets) : undefined;
|
|
264
|
+
if (head) {
|
|
135
265
|
const ref = `${object.id}/${element.id}`;
|
|
136
|
-
const head = [place(object, element.start), place(object, element.end)];
|
|
137
266
|
const existing = paths.get(ref);
|
|
138
267
|
// The head's start repeats the polyline's last waypoint; keep one copy.
|
|
139
|
-
paths.set(ref, { ref, points: existing ? [...existing.points.slice(0, -1), ...head] : head });
|
|
268
|
+
paths.set(ref, { ref, points: existing ? [...existing.points.slice(0, -1), ...head] : head, headed: true });
|
|
140
269
|
}
|
|
141
270
|
}
|
|
142
271
|
}
|
|
143
|
-
|
|
272
|
+
// An empty path has no ends to bus or trace, and every reader of a connector assumes it has some.
|
|
273
|
+
return [...paths.values()].filter(({ points }) => points.length > 0);
|
|
144
274
|
};
|
|
145
275
|
|
|
146
|
-
const segmentsOf = ({ points }:
|
|
276
|
+
const segmentsOf = ({ points }: { points: readonly Point[] }): Segment[] =>
|
|
147
277
|
points.slice(0, -1).map((point, index): Segment => [point, points[index + 1]]);
|
|
148
278
|
|
|
149
279
|
const orientation = (origin: Point, a: Point, b: Point) =>
|
|
@@ -151,16 +281,188 @@ const orientation = (origin: Point, a: Point, b: Point) =>
|
|
|
151
281
|
|
|
152
282
|
const coincident = (a: Point, b: Point) => Math.abs(a.x - b.x) < EPSILON && Math.abs(a.y - b.y) < EPSILON;
|
|
153
283
|
|
|
154
|
-
/**
|
|
284
|
+
/**
|
|
285
|
+
* Proper crossing: each segment has the other's ends strictly on opposite sides, so a shared port or a
|
|
286
|
+
* T where one connector ends on another is not a crossing.
|
|
287
|
+
*/
|
|
155
288
|
const properlyCrosses = ([a1, a2]: Segment, [b1, b2]: Segment): boolean => {
|
|
156
289
|
if ([a1, a2].some((a) => [b1, b2].some((b) => coincident(a, b)))) {
|
|
157
290
|
return false;
|
|
158
291
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
292
|
+
return orientation(a1, a2, b1) * orientation(a1, a2, b2) < 0 && orientation(b1, b2, a1) * orientation(b1, b2, a2) < 0;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/** Whether a point lies on a segment, within `EPSILON`. */
|
|
296
|
+
const onSegment = (point: Point, [start, end]: Segment): boolean => {
|
|
297
|
+
const [dx, dy] = [end.x - start.x, end.y - start.y];
|
|
298
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
299
|
+
const along =
|
|
300
|
+
lengthSquared === 0
|
|
301
|
+
? 0
|
|
302
|
+
: Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared));
|
|
303
|
+
return Math.hypot(point.x - start.x - along * dx, point.y - start.y - along * dy) < EPSILON;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const onPath = (point: Point, connector: Connector) =>
|
|
307
|
+
segmentsOf(connector).some((segment) => onSegment(point, segment));
|
|
308
|
+
|
|
309
|
+
/** Whether a point lies on a rect's outline: where a connector meets a node at a port. */
|
|
310
|
+
const onBorder = (point: Point, rect: Rect): boolean =>
|
|
311
|
+
point.x > rect.x - EPSILON &&
|
|
312
|
+
point.x < rect.x + rect.w + EPSILON &&
|
|
313
|
+
point.y > rect.y - EPSILON &&
|
|
314
|
+
point.y < rect.y + rect.h + EPSILON &&
|
|
315
|
+
!(
|
|
316
|
+
point.x > rect.x + EPSILON &&
|
|
317
|
+
point.x < rect.x + rect.w - EPSILON &&
|
|
318
|
+
point.y > rect.y + EPSILON &&
|
|
319
|
+
point.y < rect.y + rect.h - EPSILON
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
/** Drops repeated points and the midpoints of straight runs. */
|
|
323
|
+
const simplify = (points: readonly Point[]): Point[] =>
|
|
324
|
+
points
|
|
325
|
+
.filter((point, index) => index === 0 || !coincident(point, points[index - 1]))
|
|
326
|
+
.filter((point, index, kept) => {
|
|
327
|
+
if (index === 0 || index === kept.length - 1) {
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
const [previous, next] = [kept[index - 1], kept[index + 1]];
|
|
331
|
+
return (
|
|
332
|
+
Math.abs(orientation(previous, point, next)) >= EPSILON * Math.hypot(next.x - previous.x, next.y - previous.y)
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Connector pieces joined at a junction in free space — a bus trunk and its spokes — as one connector: a
|
|
338
|
+
* piece ending on another away from any node's border, without running along it, belongs to it.
|
|
339
|
+
*/
|
|
340
|
+
const busesOf = (pieces: readonly Connector[], rects: readonly Rect[]): Connector[][] => {
|
|
341
|
+
const parent = pieces.map((_, index) => index);
|
|
342
|
+
const root = (index: number): number => (parent[index] === index ? index : (parent[index] = root(parent[index])));
|
|
343
|
+
const free = (point: Point) => !rects.some((rect) => onBorder(point, rect));
|
|
344
|
+
pieces.forEach((piece, index) => {
|
|
345
|
+
for (const end of [piece.points[0], piece.points[piece.points.length - 1]]) {
|
|
346
|
+
if (!free(end)) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
pieces.forEach((other, otherIndex) => {
|
|
350
|
+
const alongside = segmentsOf(piece).some((a) => segmentsOf(other).some((b) => sharedRun(a, b) > EPSILON));
|
|
351
|
+
if (otherIndex !== index && onPath(end, other) && !alongside) {
|
|
352
|
+
parent[root(index)] = root(otherIndex);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
const groups = new Map<number, Connector[]>();
|
|
358
|
+
pieces.forEach((piece, index) => groups.set(root(index), [...(groups.get(root(index)) ?? []), piece]));
|
|
359
|
+
return [...groups.values()];
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/** Shortest walk between two points along a bus's pieces. */
|
|
363
|
+
const walk = (pieces: readonly Connector[], from: Point, to: Point): Point[] | undefined => {
|
|
364
|
+
const vertices: Point[] = [];
|
|
365
|
+
const indexOf = (point: Point) => {
|
|
366
|
+
const found = vertices.findIndex((vertex) => coincident(vertex, point));
|
|
367
|
+
return found >= 0 ? found : vertices.push(point) - 1;
|
|
368
|
+
};
|
|
369
|
+
pieces.forEach(({ points }) => points.forEach(indexOf));
|
|
370
|
+
const links = vertices.map((): { to: number; length: number }[] => []);
|
|
371
|
+
for (const segment of pieces.flatMap(segmentsOf)) {
|
|
372
|
+
const [start] = segment;
|
|
373
|
+
const stops = vertices
|
|
374
|
+
.map((vertex, index) => ({ index, distance: Math.hypot(vertex.x - start.x, vertex.y - start.y) }))
|
|
375
|
+
.filter(({ index }) => onSegment(vertices[index], segment))
|
|
376
|
+
.sort((left, right) => left.distance - right.distance);
|
|
377
|
+
stops.slice(1).forEach((stop, index) => {
|
|
378
|
+
const length = stop.distance - stops[index].distance;
|
|
379
|
+
links[stop.index].push({ to: stops[index].index, length });
|
|
380
|
+
links[stops[index].index].push({ to: stop.index, length });
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
const [source, target] = [indexOf(from), indexOf(to)];
|
|
384
|
+
const distance = vertices.map((_, index) => (index === source ? 0 : Infinity));
|
|
385
|
+
const previous = vertices.map((): number | undefined => undefined);
|
|
386
|
+
const open = new Set(vertices.map((_, index) => index));
|
|
387
|
+
while (open.size > 0) {
|
|
388
|
+
const current = [...open].reduce((best, index) => (distance[index] < distance[best] ? index : best));
|
|
389
|
+
open.delete(current);
|
|
390
|
+
if (current === target || distance[current] === Infinity) {
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
for (const link of links[current]) {
|
|
394
|
+
if (distance[current] + link.length < distance[link.to]) {
|
|
395
|
+
distance[link.to] = distance[current] + link.length;
|
|
396
|
+
previous[link.to] = current;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (distance[target] === Infinity) {
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
const path = [target];
|
|
404
|
+
for (let step = previous[target]; step !== undefined; step = previous[step]) {
|
|
405
|
+
path.unshift(step);
|
|
406
|
+
}
|
|
407
|
+
return path.map((index) => vertices[index]);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/** A connector as a reader traces it, from one shape to another; `ref` names the piece at its far end. */
|
|
411
|
+
export type Route = {
|
|
412
|
+
ref: string;
|
|
413
|
+
points: Point[];
|
|
414
|
+
/** Turns a reader follows; a turn where pieces of a bus meet is the bus's shape, not a bend. */
|
|
415
|
+
bends: number;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The routes of one connector. A bus with a single arrowhead (spokes gathering into a headed trunk) or a
|
|
420
|
+
* single bare end (a trunk fanning out to headed spokes) runs hub to spoke; anything else is its pieces.
|
|
421
|
+
*/
|
|
422
|
+
const routesOf = (bus: readonly Connector[]): Route[] => {
|
|
423
|
+
if (bus.length === 1) {
|
|
424
|
+
const points = simplify(bus[0].points);
|
|
425
|
+
return [{ ref: bus[0].ref, points, bends: bendCount({ points }) }];
|
|
426
|
+
}
|
|
427
|
+
const ends = bus.flatMap((piece) => [
|
|
428
|
+
{ piece, point: piece.points[0], headed: false },
|
|
429
|
+
{ piece, point: piece.points[piece.points.length - 1], headed: piece.headed },
|
|
430
|
+
]);
|
|
431
|
+
const meets = (end: (typeof ends)[number]) => bus.some((other) => other !== end.piece && onPath(end.point, other));
|
|
432
|
+
const junctions = ends.filter(meets).map(({ point }) => point);
|
|
433
|
+
const traced = (ref: string, points: Point[]): Route => {
|
|
434
|
+
const corners = simplify(points);
|
|
435
|
+
const atJunction = corners
|
|
436
|
+
.slice(1, -1)
|
|
437
|
+
.filter((corner, index) => bendCount({ points: corners.slice(index, index + 3) }) > 0)
|
|
438
|
+
.filter((corner) => junctions.some((junction) => coincident(junction, corner))).length;
|
|
439
|
+
return { ref, points: corners, bends: bendCount({ points: corners }) - atJunction };
|
|
440
|
+
};
|
|
441
|
+
const terminals = ends.filter((end) => !meets(end));
|
|
442
|
+
const heads = terminals.filter(({ headed }) => headed);
|
|
443
|
+
const tails = terminals.filter(({ headed }) => !headed);
|
|
444
|
+
const [hub, leaves, inward] =
|
|
445
|
+
heads.length === 1
|
|
446
|
+
? [heads[0], tails, true]
|
|
447
|
+
: tails.length === 1
|
|
448
|
+
? [tails[0], heads, false]
|
|
449
|
+
: [undefined, [], false];
|
|
450
|
+
if (!hub) {
|
|
451
|
+
return bus.map(({ ref, points }) => traced(ref, points));
|
|
452
|
+
}
|
|
453
|
+
return leaves.map((leaf) => {
|
|
454
|
+
const points = inward ? walk(bus, leaf.point, hub.point) : walk(bus, hub.point, leaf.point);
|
|
455
|
+
return traced(leaf.piece.ref, points ?? leaf.piece.points);
|
|
456
|
+
});
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/** Every connector's routes, as a reader traces them; a bus contributes one per spoke. */
|
|
460
|
+
export const routes = (objects: readonly Scene.WorldObject[]): Route[] => {
|
|
461
|
+
const targets = bindTargets(objects);
|
|
462
|
+
return busesOf(
|
|
463
|
+
connectors(objects, targets),
|
|
464
|
+
nodes(objects).map(({ rect }) => rect),
|
|
465
|
+
).flatMap(routesOf);
|
|
164
466
|
};
|
|
165
467
|
|
|
166
468
|
/** Overlap area of two rects; 0 when they merely touch. */
|
|
@@ -219,7 +521,7 @@ const interiorRun = ([start, end]: Segment, rect: Rect): number => {
|
|
|
219
521
|
};
|
|
220
522
|
|
|
221
523
|
/** Bends in an orthogonal path: waypoints where the run changes axis. */
|
|
222
|
-
const bendCount = ({ points }:
|
|
524
|
+
const bendCount = ({ points }: { points: readonly Point[] }): number => {
|
|
223
525
|
let bends = 0;
|
|
224
526
|
for (let index = 1; index < points.length - 1; index++) {
|
|
225
527
|
const [previous, point, next] = [points[index - 1], points[index], points[index + 1]];
|
|
@@ -233,21 +535,91 @@ const bendCount = ({ points }: Connector): number => {
|
|
|
233
535
|
return bends;
|
|
234
536
|
};
|
|
235
537
|
|
|
538
|
+
/** Box label type as the SVG renderer sets it, per weight: font size and line advance in scene units. */
|
|
539
|
+
export const LABEL_TYPE: Record<Scene.Weight, { size: number; lineH: number }> = {
|
|
540
|
+
s: { size: 13, lineH: 20 },
|
|
541
|
+
m: { size: 18, lineH: 26 },
|
|
542
|
+
l: { size: 27, lineH: 38 },
|
|
543
|
+
xl: { size: 34, lineH: 48 },
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
/** Average glyph advance over font size for a UI sans, for wrap estimates. */
|
|
547
|
+
export const CHAR_EM = 0.6;
|
|
548
|
+
|
|
549
|
+
/** Room kept between a box label and the box's sides, and its top and bottom. */
|
|
550
|
+
export const LABEL_INSET = { x: 12, y: 4 };
|
|
551
|
+
|
|
552
|
+
/** Font scales a label tries, largest first, before it is ellipsized at the last. */
|
|
553
|
+
const LABEL_SCALES = [1, 0.9, 0.8];
|
|
554
|
+
|
|
555
|
+
/** Breaks inside a word too long for a line: before a capital that follows a lower-case letter or digit, after punctuation. */
|
|
556
|
+
const WORD_BREAK = /(?<=[a-z0-9])(?=[A-Z])|(?<=[-_/.:])/;
|
|
557
|
+
|
|
558
|
+
/** Greedy wrap at spaces; a word longer than a line breaks at camel case or punctuation, then anywhere. */
|
|
559
|
+
const wrapLabel = (text: string, perLine: number): string[] =>
|
|
560
|
+
text.split('\n').flatMap((line) => {
|
|
561
|
+
const tokens = line
|
|
562
|
+
.split(' ')
|
|
563
|
+
.flatMap((word, wordIndex) =>
|
|
564
|
+
(word.length <= perLine ? [word] : word.split(WORD_BREAK))
|
|
565
|
+
.flatMap((part) =>
|
|
566
|
+
part.length <= perLine
|
|
567
|
+
? [part]
|
|
568
|
+
: Array.from({ length: Math.ceil(part.length / perLine) }, (_, index) =>
|
|
569
|
+
part.slice(index * perLine, (index + 1) * perLine),
|
|
570
|
+
),
|
|
571
|
+
)
|
|
572
|
+
.map((part, partIndex) => ({ text: part, glue: wordIndex > 0 && partIndex === 0 ? ' ' : '' })),
|
|
573
|
+
);
|
|
574
|
+
const lines: string[] = [];
|
|
575
|
+
let current = '';
|
|
576
|
+
for (const { text: part, glue } of tokens) {
|
|
577
|
+
const candidate = current ? `${current}${glue}${part}` : part;
|
|
578
|
+
if (candidate.length > perLine && current) {
|
|
579
|
+
lines.push(current);
|
|
580
|
+
current = part;
|
|
581
|
+
} else {
|
|
582
|
+
current = candidate;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return [...lines, current];
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
export type LabelLayout = {
|
|
589
|
+
lines: string[];
|
|
590
|
+
size: number;
|
|
591
|
+
lineH: number;
|
|
592
|
+
/** The label does not show whole: it was ellipsized, or even one line is taller than the box. */
|
|
593
|
+
overflow: boolean;
|
|
594
|
+
};
|
|
595
|
+
|
|
236
596
|
/**
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
* block is `(lines − 1) · lineH + em`.
|
|
597
|
+
* How the SVG renderer sets a box label: wrapped to the box width, shrunk a little if it still does not
|
|
598
|
+
* fit the height, and ellipsized at the smallest size, so text never spills out of its box.
|
|
240
599
|
*/
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
600
|
+
export const layoutLabel = (text: string, weight: Scene.Weight, { w, h }: { w: number; h: number }): LabelLayout => {
|
|
601
|
+
const room = { w: Math.max(0, w - LABEL_INSET.x * 2), h: Math.max(0, h - LABEL_INSET.y * 2) };
|
|
602
|
+
const attempts = LABEL_SCALES.map((scale) => {
|
|
603
|
+
const [size, lineH] = [LABEL_TYPE[weight].size * scale, LABEL_TYPE[weight].lineH * scale];
|
|
604
|
+
const perLine = Math.max(1, Math.floor(room.w / (size * CHAR_EM)));
|
|
605
|
+
const maxLines = Math.max(1, Math.floor((room.h - size) / lineH) + 1);
|
|
606
|
+
return { size, lineH, perLine, maxLines, lines: wrapLabel(text, perLine) };
|
|
607
|
+
});
|
|
608
|
+
const fitting = attempts.find(({ size, lines, maxLines }) => lines.length <= maxLines && size <= room.h + EPSILON);
|
|
609
|
+
if (fitting) {
|
|
610
|
+
return { lines: fitting.lines, size: fitting.size, lineH: fitting.lineH, overflow: false };
|
|
244
611
|
}
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
612
|
+
const { size, lineH, perLine, maxLines, lines } = attempts[attempts.length - 1];
|
|
613
|
+
if (lines.length <= maxLines) {
|
|
614
|
+
return { lines, size, lineH, overflow: true };
|
|
615
|
+
}
|
|
616
|
+
const kept = lines.slice(0, maxLines);
|
|
617
|
+
kept[maxLines - 1] = `${kept[maxLines - 1].slice(0, Math.max(0, perLine - 1)).trimEnd()}…`;
|
|
618
|
+
return { lines: kept, size, lineH, overflow: true };
|
|
249
619
|
};
|
|
250
620
|
|
|
621
|
+
const labelFits = ({ rect, text, weight }: Node): boolean => !text || !layoutLabel(text, weight, rect).overflow;
|
|
622
|
+
|
|
251
623
|
/**
|
|
252
624
|
* Analyzes an emitted scene for layout defects and quality metrics. Pure: the same scene always
|
|
253
625
|
* yields the same report, so the soft metrics can be golden-filed and the hard codes asserted.
|
|
@@ -255,7 +627,12 @@ const labelFits = ({ rect, text, weight }: Node): boolean => {
|
|
|
255
627
|
export const analyze = (objects: readonly Scene.WorldObject[], { maxBends = 3 }: Options = {}): Report => {
|
|
256
628
|
const diagnostics: Diagnostic[] = [];
|
|
257
629
|
const allNodes = nodes(objects);
|
|
258
|
-
const allConnectors = connectors(objects);
|
|
630
|
+
const allConnectors = connectors(objects, bindTargets(objects));
|
|
631
|
+
const buses = busesOf(
|
|
632
|
+
allConnectors,
|
|
633
|
+
allNodes.map(({ rect }) => rect),
|
|
634
|
+
);
|
|
635
|
+
const allRoutes = buses.flatMap(routesOf);
|
|
259
636
|
const ownerOf = (ref: string) => ref.slice(0, ref.indexOf('/'));
|
|
260
637
|
|
|
261
638
|
// A box enclosing another object's box is a container (subgraph or group frame): connectors
|
|
@@ -319,12 +696,22 @@ export const analyze = (objects: readonly Scene.WorldObject[], { maxBends = 3 }:
|
|
|
319
696
|
}
|
|
320
697
|
}
|
|
321
698
|
|
|
699
|
+
// Pieces of one bus meet by design, so crossings and shared runs are counted between buses.
|
|
700
|
+
const firstPair = (
|
|
701
|
+
left: readonly Connector[],
|
|
702
|
+
right: readonly Connector[],
|
|
703
|
+
test: (a: Segment, b: Segment) => boolean,
|
|
704
|
+
) =>
|
|
705
|
+
left
|
|
706
|
+
.flatMap((a) => right.map((b) => [a, b] as const))
|
|
707
|
+
.find(([a, b]) => segmentsOf(a).some((first) => segmentsOf(b).some((second) => test(first, second))));
|
|
708
|
+
|
|
322
709
|
let crossings = 0;
|
|
323
|
-
for (let i = 0; i <
|
|
324
|
-
for (let j = i + 1; j <
|
|
325
|
-
const
|
|
326
|
-
const crossed = segmentsOf(left).some((a) => segmentsOf(right).some((b) => properlyCrosses(a, b)));
|
|
710
|
+
for (let i = 0; i < buses.length; i++) {
|
|
711
|
+
for (let j = i + 1; j < buses.length; j++) {
|
|
712
|
+
const crossed = firstPair(buses[i], buses[j], properlyCrosses);
|
|
327
713
|
if (crossed) {
|
|
714
|
+
const [left, right] = crossed;
|
|
328
715
|
crossings++;
|
|
329
716
|
diagnostics.push({
|
|
330
717
|
code: 'edge-crossing',
|
|
@@ -336,18 +723,73 @@ export const analyze = (objects: readonly Scene.WorldObject[], { maxBends = 3 }:
|
|
|
336
723
|
}
|
|
337
724
|
}
|
|
338
725
|
|
|
726
|
+
let edgeOverlaps = 0;
|
|
727
|
+
for (let i = 0; i < buses.length; i++) {
|
|
728
|
+
for (let j = i + 1; j < buses.length; j++) {
|
|
729
|
+
const pairs = buses[i].flatMap((left) => buses[j].map((right) => [left, right] as const));
|
|
730
|
+
const runs = pairs.map(([left, right]) =>
|
|
731
|
+
segmentsOf(left).reduce(
|
|
732
|
+
(total, a) => total + segmentsOf(right).reduce((sum, b) => sum + sharedRun(a, b), 0),
|
|
733
|
+
0,
|
|
734
|
+
),
|
|
735
|
+
);
|
|
736
|
+
const run = runs.reduce((total, value) => total + value, 0);
|
|
737
|
+
if (run > EPSILON) {
|
|
738
|
+
const [left, right] = pairs[runs.findIndex((value) => value > 0)];
|
|
739
|
+
edgeOverlaps++;
|
|
740
|
+
diagnostics.push({
|
|
741
|
+
code: 'edge-overlap',
|
|
742
|
+
severity: SEVERITY['edge-overlap'],
|
|
743
|
+
message: `Connectors "${left.ref}" and "${right.ref}" run along the same line for ${Math.round(run)} units.`,
|
|
744
|
+
refs: [left.ref, right.ref],
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Text is compared with other text and with boxes outside its own object; a frame's label sits in
|
|
751
|
+
// the frame's band by design, and a container is a backdrop, not an obstacle.
|
|
752
|
+
const allLabels = labels(objects);
|
|
753
|
+
let textOverlaps = 0;
|
|
754
|
+
const overlapText = (ref: string, other: string) => {
|
|
755
|
+
textOverlaps++;
|
|
756
|
+
diagnostics.push({
|
|
757
|
+
code: 'text-overlap',
|
|
758
|
+
severity: SEVERITY['text-overlap'],
|
|
759
|
+
message: `Label "${ref}" overlaps "${other}".`,
|
|
760
|
+
refs: [ref, other],
|
|
761
|
+
});
|
|
762
|
+
};
|
|
763
|
+
for (let i = 0; i < allLabels.length; i++) {
|
|
764
|
+
for (let j = i + 1; j < allLabels.length; j++) {
|
|
765
|
+
if (overlapArea(allLabels[i].rect, allLabels[j].rect) > EPSILON) {
|
|
766
|
+
overlapText(allLabels[i].ref, allLabels[j].ref);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
for (const node of allNodes) {
|
|
770
|
+
if (ownerOf(node.ref) !== ownerOf(allLabels[i].ref) && !containers.has(node.ref)) {
|
|
771
|
+
if (overlapArea(allLabels[i].rect, node.rect) > EPSILON) {
|
|
772
|
+
overlapText(allLabels[i].ref, node.ref);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Each drawn corner counts once; the bend limit applies to a route as a reader follows it, trunk and spoke together.
|
|
339
779
|
let bends = 0;
|
|
340
780
|
let length = 0;
|
|
341
781
|
for (const connector of allConnectors) {
|
|
342
782
|
length += segmentsOf(connector).reduce((total, [a, b]) => total + Math.hypot(b.x - a.x, b.y - a.y), 0);
|
|
343
|
-
|
|
344
|
-
|
|
783
|
+
bends += bendCount(connector);
|
|
784
|
+
}
|
|
785
|
+
for (const route of allRoutes) {
|
|
786
|
+
const count = route.bends;
|
|
345
787
|
if (count > maxBends) {
|
|
346
788
|
diagnostics.push({
|
|
347
789
|
code: 'excessive-bends',
|
|
348
790
|
severity: SEVERITY['excessive-bends'],
|
|
349
|
-
message: `Connector "${
|
|
350
|
-
refs: [
|
|
791
|
+
message: `Connector "${route.ref}" takes ${count} bends.`,
|
|
792
|
+
refs: [route.ref],
|
|
351
793
|
});
|
|
352
794
|
}
|
|
353
795
|
}
|
|
@@ -372,10 +814,12 @@ export const analyze = (objects: readonly Scene.WorldObject[], { maxBends = 3 }:
|
|
|
372
814
|
diagnostics,
|
|
373
815
|
metrics: {
|
|
374
816
|
nodes: allNodes.length,
|
|
375
|
-
connectors:
|
|
817
|
+
connectors: allRoutes.length,
|
|
376
818
|
overlaps: diagnostics.filter(({ code }) => code === 'node-overlap').length,
|
|
377
819
|
routesThroughNodes: diagnostics.filter(({ code }) => code === 'route-through-node').length,
|
|
378
820
|
labelOverflows: diagnostics.filter(({ code }) => code === 'label-overflow').length,
|
|
821
|
+
textOverlaps,
|
|
822
|
+
edgeOverlaps,
|
|
379
823
|
crossings,
|
|
380
824
|
bends,
|
|
381
825
|
length,
|