dsh-archify-projects 0.0.0-stage → 0.1.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/README.md +204 -2
- package/cordis.patch.yml +99 -0
- package/dist/client.js +2261 -0
- package/dist/client.js.map +1 -0
- package/docs/CONTAINER.md +197 -0
- package/docs/DESIGN.md +905 -0
- package/docs/HANDOVER.md +391 -0
- package/docs/PUBLISH.md +107 -0
- package/icon.svg +12 -0
- package/lib/host/archify-skill.js +116 -0
- package/lib/host/archify-skill.js.map +1 -0
- package/lib/host/config.js +95 -0
- package/lib/host/config.js.map +1 -0
- package/lib/host/context.js +28 -0
- package/lib/host/context.js.map +1 -0
- package/lib/host/git.js +185 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/http.js +184 -0
- package/lib/host/http.js.map +1 -0
- package/lib/host/index.js +90 -0
- package/lib/host/index.js.map +1 -0
- package/lib/host/policy.js +124 -0
- package/lib/host/policy.js.map +1 -0
- package/lib/host/routes.js +667 -0
- package/lib/host/routes.js.map +1 -0
- package/lib/host/runner.js +913 -0
- package/lib/host/runner.js.map +1 -0
- package/lib/host/store.js +370 -0
- package/lib/host/store.js.map +1 -0
- package/lib/host/workspace.js +65 -0
- package/lib/host/workspace.js.map +1 -0
- package/lib/shared/format.js +138 -0
- package/lib/shared/format.js.map +1 -0
- package/lib/shared/prompt.js +257 -0
- package/lib/shared/prompt.js.map +1 -0
- package/lib/shared/protocol.js +31 -0
- package/lib/shared/protocol.js.map +1 -0
- package/lib/shared/types.js +173 -0
- package/lib/shared/types.js.map +1 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +98 -4
- package/skills/archify/LICENSE +22 -0
- package/skills/archify/SKILL.md +109 -0
- package/skills/archify/THIRD_PARTY_NOTICES.md +69 -0
- package/skills/archify/assets/JetBrainsMono-OFL.txt +93 -0
- package/skills/archify/assets/template.html +13709 -0
- package/skills/archify/bin/archify.mjs +6936 -0
- package/skills/archify/bin/delivery-update.mjs +91 -0
- package/skills/archify/bin/finalize.mjs +895 -0
- package/skills/archify/bin/open-artifact.mjs +146 -0
- package/skills/archify/bin/preview.mjs +969 -0
- package/skills/archify/bin/recover-output.mjs +41 -0
- package/skills/archify/bin/visual-check.mjs +2667 -0
- package/skills/archify/brand-marks/README.md +31 -0
- package/skills/archify/brand-marks/catalog.json +131 -0
- package/skills/archify/delta/architecture-delta.mjs +1313 -0
- package/skills/archify/examples/agent-run.lifecycle.json +55 -0
- package/skills/archify/examples/agent-tool-call.workflow.json +74 -0
- package/skills/archify/examples/async-job-roundtrip.sequence.json +56 -0
- package/skills/archify/examples/brand-aware-delivery.architecture.json +34 -0
- package/skills/archify/examples/cache-miss-request.sequence.json +77 -0
- package/skills/archify/examples/checkout-platform.base.architecture.json +32 -0
- package/skills/archify/examples/checkout-platform.head.architecture.json +32 -0
- package/skills/archify/examples/dataflow-product-analytics.html +13820 -0
- package/skills/archify/examples/deployment-release.lifecycle.json +48 -0
- package/skills/archify/examples/event-stream.dataflow.json +52 -0
- package/skills/archify/examples/holiday-planning.workflow.json +143 -0
- package/skills/archify/examples/incident-response.workflow.json +59 -0
- package/skills/archify/examples/lifecycle-agent-run.html +13765 -0
- package/skills/archify/examples/locales/de.json +425 -0
- package/skills/archify/examples/locales/es.json +440 -0
- package/skills/archify/examples/locales/fr.json +425 -0
- package/skills/archify/examples/locales/fr.partial.json +22 -0
- package/skills/archify/examples/locales/it.json +425 -0
- package/skills/archify/examples/locales/ja.json +425 -0
- package/skills/archify/examples/locales/ko.json +440 -0
- package/skills/archify/examples/locales/pt.json +425 -0
- package/skills/archify/examples/locales/ru.json +425 -0
- package/skills/archify/examples/product-analytics.dataflow.json +71 -0
- package/skills/archify/examples/production-deployment.architecture.json +51 -0
- package/skills/archify/examples/release-delivery.workflow.json +57 -0
- package/skills/archify/examples/sequence-cache-miss-request.html +13835 -0
- package/skills/archify/examples/web-app-rendered.html +13808 -0
- package/skills/archify/examples/web-app.architecture.json +41 -0
- package/skills/archify/examples/workflow-agent-tool-call-rendered.html +13826 -0
- package/skills/archify/migrations/workflow-v2.mjs +280 -0
- package/skills/archify/package.json +17 -0
- package/skills/archify/recipes/scenarios.mjs +430 -0
- package/skills/archify/references/architecture-layout-repair.md +18 -0
- package/skills/archify/references/authoring-contract.md +419 -0
- package/skills/archify/references/authoring-defaults.md +41 -0
- package/skills/archify/references/brand-marks.md +82 -0
- package/skills/archify/references/delivery-contract.md +588 -0
- package/skills/archify/references/repository-authoring.md +105 -0
- package/skills/archify/references/update-awareness.md +12 -0
- package/skills/archify/references/viewer-runtime.md +35 -0
- package/skills/archify/renderers/architecture/grid.mjs +62 -0
- package/skills/archify/renderers/architecture/labels.mjs +215 -0
- package/skills/archify/renderers/architecture/render-architecture.mjs +1001 -0
- package/skills/archify/renderers/architecture/routing.mjs +1157 -0
- package/skills/archify/renderers/dataflow/README.md +105 -0
- package/skills/archify/renderers/dataflow/render-dataflow.mjs +544 -0
- package/skills/archify/renderers/lifecycle/README.md +171 -0
- package/skills/archify/renderers/lifecycle/grid-routing.mjs +429 -0
- package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +938 -0
- package/skills/archify/renderers/sequence/README.md +129 -0
- package/skills/archify/renderers/sequence/render-sequence.mjs +526 -0
- package/skills/archify/renderers/shared/atomic-output.mjs +1982 -0
- package/skills/archify/renderers/shared/brand-marks.mjs +670 -0
- package/skills/archify/renderers/shared/cli.mjs +451 -0
- package/skills/archify/renderers/shared/desktop-readability.mjs +132 -0
- package/skills/archify/renderers/shared/diagnostics.mjs +173 -0
- package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/skills/archify/renderers/shared/generated-validators.mjs +134 -0
- package/skills/archify/renderers/shared/geometry.mjs +1941 -0
- package/skills/archify/renderers/shared/i18n.mjs +603 -0
- package/skills/archify/renderers/shared/layout-report.mjs +42 -0
- package/skills/archify/renderers/shared/legend.mjs +217 -0
- package/skills/archify/renderers/shared/output-path.mjs +440 -0
- package/skills/archify/renderers/shared/path-semantics.mjs +1017 -0
- package/skills/archify/renderers/shared/portable-path.mjs +408 -0
- package/skills/archify/renderers/shared/repository-evidence.mjs +352 -0
- package/skills/archify/renderers/shared/repository-location.mjs +59 -0
- package/skills/archify/renderers/shared/route-quality.mjs +614 -0
- package/skills/archify/renderers/shared/sidecar-path.mjs +38 -0
- package/skills/archify/renderers/shared/spatial-grid.mjs +66 -0
- package/skills/archify/renderers/shared/text-fit.mjs +105 -0
- package/skills/archify/renderers/shared/utils.mjs +254 -0
- package/skills/archify/renderers/shared/validator.mjs +86 -0
- package/skills/archify/renderers/workflow/README.md +301 -0
- package/skills/archify/renderers/workflow/render-workflow.mjs +37 -0
- package/skills/archify/renderers/workflow/workflow-compiler.mjs +4913 -0
- package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- package/skills/archify/schemas/README.md +256 -0
- package/skills/archify/schemas/architecture.schema.json +152 -0
- package/skills/archify/schemas/common.schema.json +181 -0
- package/skills/archify/schemas/dataflow.schema.json +254 -0
- package/skills/archify/schemas/lifecycle.schema.json +280 -0
- package/skills/archify/schemas/sequence.schema.json +234 -0
- package/skills/archify/schemas/workflow.schema.json +439 -0
- package/skills/archify/scripts/check-render-output.mjs +1388 -0
- package/skills/archify/scripts/check-update.mjs +1710 -0
- package/skills/archify/scripts/delivery-update-child.mjs +23 -0
- package/skills/archify/scripts/render-examples.mjs +26 -0
- package/skills/archify/scripts/update-contract.mjs +182 -0
- package/skills/archify/skill-release.json +10 -0
|
@@ -0,0 +1,1941 @@
|
|
|
1
|
+
// Geometry helpers shared by all typed renderers. Every function here is
|
|
2
|
+
// pure; renderers own their layout tables and pass measured rects
|
|
3
|
+
// ({x, y, width, height, cx, cy}) in.
|
|
4
|
+
|
|
5
|
+
import { recordDiagnostic } from './diagnostics.mjs';
|
|
6
|
+
|
|
7
|
+
// In degraded mode (no ajv) a type-wrong top-level field reaches the renderer.
|
|
8
|
+
// Coerce non-arrays to [] so the module-level Maps build without throwing and
|
|
9
|
+
// the friendly validator checks (which run later) report the real problem.
|
|
10
|
+
export function asArray(value) {
|
|
11
|
+
return Array.isArray(value) ? value : [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A computed coordinate must be a finite number; NaN/undefined would silently
|
|
15
|
+
// write `<rect x="NaN">` into the output. Used by the validators as a backstop.
|
|
16
|
+
export function isFinitePoint(...coords) {
|
|
17
|
+
return coords.every((c) => Number.isFinite(c));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Separation the layout solver guarantees and separation this check re-derives
|
|
21
|
+
// travel through different arithmetic, so a pair the solver placed at exactly
|
|
22
|
+
// its minimum can land a few ulps short of this comparison. In #583 the CLI's
|
|
23
|
+
// own receipt has the pair `793.6 + 160 + 8` read as 2**-43 past a5's left edge
|
|
24
|
+
// at 961.5999999999999 — one ulp at that magnitude. 0.0001 is the numeric
|
|
25
|
+
// tolerance the route and layout geometry in this file already compares with.
|
|
26
|
+
const NUMERIC_CLEARANCE_PX = 0.0001;
|
|
27
|
+
|
|
28
|
+
export function rectsOverlap(a, b, gap = 0) {
|
|
29
|
+
// Non-finite geometry means "unknown", not "overlapping". Every comparison
|
|
30
|
+
// below is false for NaN, so without this guard the negation reports a
|
|
31
|
+
// collision for every pair. Callers surface non-finite pos/size through their
|
|
32
|
+
// own diagnostic; reporting it again as an overlap buries that message under
|
|
33
|
+
// one bogus separation hint per pair.
|
|
34
|
+
if (!isFinitePoint(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return !(
|
|
38
|
+
a.x + a.width + gap <= b.x + NUMERIC_CLEARANCE_PX ||
|
|
39
|
+
b.x + b.width + gap <= a.x + NUMERIC_CLEARANCE_PX ||
|
|
40
|
+
a.y + a.height + gap <= b.y + NUMERIC_CLEARANCE_PX ||
|
|
41
|
+
b.y + b.height + gap <= a.y + NUMERIC_CLEARANCE_PX
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function segmentIntersectsRect(segment, rect, gap = 0) {
|
|
46
|
+
const box = {
|
|
47
|
+
x1: rect.x - gap,
|
|
48
|
+
y1: rect.y - gap,
|
|
49
|
+
x2: rect.x + rect.width + gap,
|
|
50
|
+
y2: rect.y + rect.height + gap
|
|
51
|
+
};
|
|
52
|
+
const [a, b] = [segment.start, segment.end];
|
|
53
|
+
if (pointInBox(a, box) || pointInBox(b, box)) return true;
|
|
54
|
+
return (
|
|
55
|
+
segmentsIntersect(a, b, [box.x1, box.y1], [box.x2, box.y1]) ||
|
|
56
|
+
segmentsIntersect(a, b, [box.x2, box.y1], [box.x2, box.y2]) ||
|
|
57
|
+
segmentsIntersect(a, b, [box.x2, box.y2], [box.x1, box.y2]) ||
|
|
58
|
+
segmentsIntersect(a, b, [box.x1, box.y2], [box.x1, box.y1])
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function segmentRectClearance(segment, rect) {
|
|
63
|
+
if (!segment || !rect) return null;
|
|
64
|
+
const { start, end } = segment;
|
|
65
|
+
if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
|
|
66
|
+
if (!isFinitePoint(start[0], start[1], end[0], end[1], rect.x, rect.y, rect.width, rect.height)) return null;
|
|
67
|
+
if (rect.width < 0 || rect.height < 0) return null;
|
|
68
|
+
if (segmentIntersectsRect(segment, rect)) return 0;
|
|
69
|
+
|
|
70
|
+
const right = rect.x + rect.width;
|
|
71
|
+
const bottom = rect.y + rect.height;
|
|
72
|
+
let clearance = Math.min(pointRectDistance(start, rect), pointRectDistance(end, rect));
|
|
73
|
+
const corners = [rect.x, rect.y, right, rect.y, right, bottom, rect.x, bottom];
|
|
74
|
+
for (let index = 0; index < 8; index += 2) {
|
|
75
|
+
const distance = pointSegmentDistanceXY(corners[index], corners[index + 1], start, end);
|
|
76
|
+
if (distance < clearance) clearance = distance;
|
|
77
|
+
}
|
|
78
|
+
return clearance;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The same clearance, but the caller already knows the threshold it compares
|
|
82
|
+
// against: when an axis gap alone proves the distance cannot be smaller, the
|
|
83
|
+
// exact distance is never computed and Infinity is returned.
|
|
84
|
+
export function segmentRectClearanceWithin(segment, rect, limit) {
|
|
85
|
+
if (!segment || !rect) return segmentRectClearance(segment, rect);
|
|
86
|
+
const { start, end } = segment;
|
|
87
|
+
if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
|
|
88
|
+
if (!isFinitePoint(start[0], start[1], end[0], end[1], rect.x, rect.y, rect.width, rect.height)) return null;
|
|
89
|
+
if (rect.width < 0 || rect.height < 0) return null;
|
|
90
|
+
if (segmentIntersectsRect(segment, rect)) return 0;
|
|
91
|
+
const minX = Math.min(start[0], end[0]);
|
|
92
|
+
const maxX = Math.max(start[0], end[0]);
|
|
93
|
+
const minY = Math.min(start[1], end[1]);
|
|
94
|
+
const maxY = Math.max(start[1], end[1]);
|
|
95
|
+
const gapX = Math.max(rect.x - maxX, minX - (rect.x + rect.width));
|
|
96
|
+
const gapY = Math.max(rect.y - maxY, minY - (rect.y + rect.height));
|
|
97
|
+
if (Math.max(gapX, gapY) > limit) return Number.POSITIVE_INFINITY;
|
|
98
|
+
return segmentRectClearance(segment, rect);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function segmentRectIntersectionLength(segment, rect) {
|
|
102
|
+
if (!segment || !rect) return null;
|
|
103
|
+
const { start, end } = segment;
|
|
104
|
+
if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
|
|
105
|
+
if (!isFinitePoint(...start, ...end, rect.x, rect.y, rect.width, rect.height)) return null;
|
|
106
|
+
if (rect.width < 0 || rect.height < 0) return null;
|
|
107
|
+
|
|
108
|
+
const dx = end[0] - start[0];
|
|
109
|
+
const dy = end[1] - start[1];
|
|
110
|
+
const length = Math.hypot(dx, dy);
|
|
111
|
+
if (length <= 0.0000001) return 0;
|
|
112
|
+
const bounds = [
|
|
113
|
+
[-dx, start[0] - rect.x],
|
|
114
|
+
[dx, rect.x + rect.width - start[0]],
|
|
115
|
+
[-dy, start[1] - rect.y],
|
|
116
|
+
[dy, rect.y + rect.height - start[1]],
|
|
117
|
+
];
|
|
118
|
+
let enter = 0;
|
|
119
|
+
let leave = 1;
|
|
120
|
+
for (const [direction, distance] of bounds) {
|
|
121
|
+
if (Math.abs(direction) <= 0.0000001) {
|
|
122
|
+
if (distance < -0.0000001) return 0;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const ratio = distance / direction;
|
|
126
|
+
if (direction < 0) enter = Math.max(enter, ratio);
|
|
127
|
+
else leave = Math.min(leave, ratio);
|
|
128
|
+
if (enter > leave + 0.0000001) return 0;
|
|
129
|
+
}
|
|
130
|
+
return length * Math.max(0, leave - enter);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ROUTE_INDEX_CELL_SIZE = 128;
|
|
134
|
+
const ROUTE_INDEX_MAX_CELLS_PER_ROUTE = 256;
|
|
135
|
+
|
|
136
|
+
function routeBounds(route) {
|
|
137
|
+
let x1 = Infinity;
|
|
138
|
+
let y1 = Infinity;
|
|
139
|
+
let x2 = -Infinity;
|
|
140
|
+
let y2 = -Infinity;
|
|
141
|
+
for (const [x, y] of route.points) {
|
|
142
|
+
x1 = Math.min(x1, x);
|
|
143
|
+
y1 = Math.min(y1, y);
|
|
144
|
+
x2 = Math.max(x2, x);
|
|
145
|
+
y2 = Math.max(y2, y);
|
|
146
|
+
}
|
|
147
|
+
return { x1, y1, x2, y2 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function createRouteCandidateIndex(routes) {
|
|
151
|
+
const cells = new Map();
|
|
152
|
+
const globalRouteIndexes = [];
|
|
153
|
+
|
|
154
|
+
routes.forEach((route, routeIndex) => {
|
|
155
|
+
const bounds = routeBounds(route);
|
|
156
|
+
const x1 = Math.floor(bounds.x1 / ROUTE_INDEX_CELL_SIZE);
|
|
157
|
+
const y1 = Math.floor(bounds.y1 / ROUTE_INDEX_CELL_SIZE);
|
|
158
|
+
const x2 = Math.floor(bounds.x2 / ROUTE_INDEX_CELL_SIZE);
|
|
159
|
+
const y2 = Math.floor(bounds.y2 / ROUTE_INDEX_CELL_SIZE);
|
|
160
|
+
if (![x1, y1, x2, y2].every(Number.isSafeInteger)) {
|
|
161
|
+
globalRouteIndexes.push(routeIndex);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const cellCount = (x2 - x1 + 1) * (y2 - y1 + 1);
|
|
165
|
+
|
|
166
|
+
// Very long routes are cheaper to test once per label than to duplicate
|
|
167
|
+
// across a large part of the index.
|
|
168
|
+
if (!Number.isSafeInteger(cellCount) || cellCount > ROUTE_INDEX_MAX_CELLS_PER_ROUTE) {
|
|
169
|
+
globalRouteIndexes.push(routeIndex);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
for (let y = y1; y <= y2; y += 1) {
|
|
173
|
+
for (let x = x1; x <= x2; x += 1) {
|
|
174
|
+
const key = `${x}:${y}`;
|
|
175
|
+
const bucket = cells.get(key);
|
|
176
|
+
if (bucket) bucket.push(routeIndex);
|
|
177
|
+
else cells.set(key, [routeIndex]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return function candidates(rect, threshold) {
|
|
183
|
+
const x1 = Math.floor((rect.x - threshold) / ROUTE_INDEX_CELL_SIZE);
|
|
184
|
+
const y1 = Math.floor((rect.y - threshold) / ROUTE_INDEX_CELL_SIZE);
|
|
185
|
+
const x2 = Math.floor((rect.x + rect.width + threshold) / ROUTE_INDEX_CELL_SIZE);
|
|
186
|
+
const y2 = Math.floor((rect.y + rect.height + threshold) / ROUTE_INDEX_CELL_SIZE);
|
|
187
|
+
const cellCount = (x2 - x1 + 1) * (y2 - y1 + 1);
|
|
188
|
+
|
|
189
|
+
// A huge threshold offers no selectivity and can overflow cell iteration.
|
|
190
|
+
if (![x1, y1, x2, y2].every(Number.isSafeInteger)
|
|
191
|
+
|| !Number.isSafeInteger(cellCount)
|
|
192
|
+
|| cellCount > Math.max(64, cells.size * 4)) return routes;
|
|
193
|
+
|
|
194
|
+
const routeIndexes = new Set(globalRouteIndexes);
|
|
195
|
+
for (let y = y1; y <= y2; y += 1) {
|
|
196
|
+
for (let x = x1; x <= x2; x += 1) {
|
|
197
|
+
for (const routeIndex of cells.get(`${x}:${y}`) || []) routeIndexes.add(routeIndex);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return [...routeIndexes]
|
|
201
|
+
.sort((left, right) => left - right)
|
|
202
|
+
.map((routeIndex) => routes[routeIndex]);
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function collectLabelRouteClearance({ labels, routedRelations, threshold }) {
|
|
207
|
+
if (!Number.isFinite(threshold) || threshold < 0) return [];
|
|
208
|
+
if (threshold === 0) return [];
|
|
209
|
+
const routeCandidates = asArray(routedRelations).map((entry, fallbackIndex) => {
|
|
210
|
+
const relation = entry?.relation || entry;
|
|
211
|
+
const points = normalizeRoutePoints(entry?.points || relation?.routePoints);
|
|
212
|
+
if (!relation || points.length < 2) return null;
|
|
213
|
+
return {
|
|
214
|
+
relation,
|
|
215
|
+
relationIndex: Number.isInteger(entry?.relationIndex) ? entry.relationIndex : fallbackIndex,
|
|
216
|
+
points,
|
|
217
|
+
};
|
|
218
|
+
}).filter(Boolean);
|
|
219
|
+
const seenRoutes = new Set();
|
|
220
|
+
const routes = routeCandidates.filter((route) => {
|
|
221
|
+
const identity = relationshipIdentity(route.relation, route.relationIndex);
|
|
222
|
+
if (seenRoutes.has(identity)) return false;
|
|
223
|
+
seenRoutes.add(identity);
|
|
224
|
+
return true;
|
|
225
|
+
});
|
|
226
|
+
const hits = [];
|
|
227
|
+
const seenLabels = new Set();
|
|
228
|
+
const candidateRoutes = createRouteCandidateIndex(routes);
|
|
229
|
+
|
|
230
|
+
for (const [fallbackIndex, label] of asArray(labels).entries()) {
|
|
231
|
+
const rect = label?.rect || label;
|
|
232
|
+
if (!rect || !isFinitePoint(rect.x, rect.y, rect.width, rect.height) || rect.width < 0 || rect.height < 0) continue;
|
|
233
|
+
const relationIndex = Number.isInteger(label?.relationIndex) ? label.relationIndex : fallbackIndex;
|
|
234
|
+
const labelIdentity = relationshipIdentity(label?.relation, relationIndex);
|
|
235
|
+
if (seenLabels.has(labelIdentity)) continue;
|
|
236
|
+
seenLabels.add(labelIdentity);
|
|
237
|
+
for (const route of candidateRoutes(rect, threshold)) {
|
|
238
|
+
if (relationIndex === route.relationIndex || sameRelationship(label?.relation, route.relation)) continue;
|
|
239
|
+
let nearest = null;
|
|
240
|
+
for (let segmentIndex = 0; segmentIndex < route.points.length - 1; segmentIndex += 1) {
|
|
241
|
+
const start = route.points[segmentIndex];
|
|
242
|
+
const end = route.points[segmentIndex + 1];
|
|
243
|
+
const clearance = segmentRectClearance({ start, end }, rect);
|
|
244
|
+
if (clearance == null) continue;
|
|
245
|
+
if (!nearest || clearance < nearest.clearance) {
|
|
246
|
+
nearest = {
|
|
247
|
+
clearance,
|
|
248
|
+
intersectionLength: segmentRectIntersectionLength({ start, end }, rect),
|
|
249
|
+
segmentIndex,
|
|
250
|
+
start,
|
|
251
|
+
end,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!nearest || nearest.clearance + 0.0001 >= threshold) continue;
|
|
256
|
+
hits.push({
|
|
257
|
+
label,
|
|
258
|
+
labelRelation: label?.relation,
|
|
259
|
+
labelRelationIndex: relationIndex,
|
|
260
|
+
otherRelation: route.relation,
|
|
261
|
+
otherRelationIndex: route.relationIndex,
|
|
262
|
+
rect,
|
|
263
|
+
...nearest,
|
|
264
|
+
threshold,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return hits;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function minimumLabelRouteClearance(measurements) {
|
|
272
|
+
if (!asArray(measurements).length) return null;
|
|
273
|
+
const minimum = measurements.reduce(
|
|
274
|
+
(value, hit) => Math.min(value, hit.clearance),
|
|
275
|
+
Infinity,
|
|
276
|
+
);
|
|
277
|
+
return Math.round(minimum * 10) / 10;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function relationshipIdentity(relation, relationIndex) {
|
|
281
|
+
if (relation?.key !== undefined) return `key:${relation.key}`;
|
|
282
|
+
if (relation?.id) return `id:${relation.from || ''}\u0000${relation.to || ''}\u0000${relation.id}`;
|
|
283
|
+
return `index:${relationIndex}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function sameRelationship(left, right) {
|
|
287
|
+
if (!left || !right) return false;
|
|
288
|
+
if (left === right) return true;
|
|
289
|
+
if (left.key !== undefined && right.key !== undefined) return left.key === right.key;
|
|
290
|
+
return Boolean(left.id && right.id && left.id === right.id && left.from === right.from && left.to === right.to);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function relationshipSubject(diagramType, relationCollection, relationIndex, relation) {
|
|
294
|
+
return {
|
|
295
|
+
diagramType,
|
|
296
|
+
collection: relationCollection,
|
|
297
|
+
index: relationIndex,
|
|
298
|
+
...(relation?.id ? { id: relation.id } : {}),
|
|
299
|
+
...(relation?.from ? { from: relation.from } : {}),
|
|
300
|
+
...(relation?.to ? { to: relation.to } : {}),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const AUTHORED_PLAN_KEYS = ['route', 'via', 'channelX', 'channelY', 'fromSide', 'toSide', 'bias', 'labelAt', 'labelDx', 'labelDy', 'labelSegment'];
|
|
305
|
+
|
|
306
|
+
// Re-planning advice only applies when the failing relation actually carries
|
|
307
|
+
// authored controls; telling an author to remove controls that do not exist
|
|
308
|
+
// just sends them hunting for a field that was never written.
|
|
309
|
+
function rePlanHint(relations, fallback) {
|
|
310
|
+
const controls = new Set();
|
|
311
|
+
for (const relation of asArray(relations)) {
|
|
312
|
+
for (const key of AUTHORED_PLAN_KEYS) if (relation?.[key] !== undefined) controls.add(key);
|
|
313
|
+
}
|
|
314
|
+
if (!controls.size) return fallback;
|
|
315
|
+
return `if the authored ${[...controls].join('/')} are not required by the user, remove them so the renderer can re-plan; otherwise preserve that intent and ${fallback}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const ENDPOINT_SIDE_RULES = {
|
|
319
|
+
left: {
|
|
320
|
+
axis: 'horizontal',
|
|
321
|
+
sourceSign: -1,
|
|
322
|
+
targetSign: 1,
|
|
323
|
+
sourceDirection: 'leftward',
|
|
324
|
+
targetDirection: 'rightward from the left',
|
|
325
|
+
},
|
|
326
|
+
right: {
|
|
327
|
+
axis: 'horizontal',
|
|
328
|
+
sourceSign: 1,
|
|
329
|
+
targetSign: -1,
|
|
330
|
+
sourceDirection: 'rightward',
|
|
331
|
+
targetDirection: 'leftward from the right',
|
|
332
|
+
},
|
|
333
|
+
top: {
|
|
334
|
+
axis: 'vertical',
|
|
335
|
+
sourceSign: -1,
|
|
336
|
+
targetSign: 1,
|
|
337
|
+
sourceDirection: 'upward',
|
|
338
|
+
targetDirection: 'downward from above',
|
|
339
|
+
},
|
|
340
|
+
bottom: {
|
|
341
|
+
axis: 'vertical',
|
|
342
|
+
sourceSign: 1,
|
|
343
|
+
targetSign: -1,
|
|
344
|
+
sourceDirection: 'downward',
|
|
345
|
+
targetDirection: 'upward from below',
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
function endpointSideIssue(points, endpoint, side) {
|
|
350
|
+
const rule = ENDPOINT_SIDE_RULES[side];
|
|
351
|
+
if (!rule) return null;
|
|
352
|
+
const normalized = normalizeRoutePoints(points);
|
|
353
|
+
if (normalized.length < 2) return null;
|
|
354
|
+
const segmentIndex = endpoint === 'source' ? 0 : normalized.length - 2;
|
|
355
|
+
const start = normalized[segmentIndex];
|
|
356
|
+
const end = normalized[segmentIndex + 1];
|
|
357
|
+
const dx = end[0] - start[0];
|
|
358
|
+
const dy = end[1] - start[1];
|
|
359
|
+
const along = rule.axis === 'horizontal' ? dx : dy;
|
|
360
|
+
const across = rule.axis === 'horizontal' ? dy : dx;
|
|
361
|
+
const expectedSign = endpoint === 'source' ? rule.sourceSign : rule.targetSign;
|
|
362
|
+
if (Math.abs(across) <= 0.0001 && along * expectedSign > 0.0001) return null;
|
|
363
|
+
return {
|
|
364
|
+
endpoint,
|
|
365
|
+
side,
|
|
366
|
+
segmentIndex,
|
|
367
|
+
start,
|
|
368
|
+
end,
|
|
369
|
+
expectedAxis: rule.axis,
|
|
370
|
+
expectedDirection: endpoint === 'source' ? rule.sourceDirection : rule.targetDirection,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// A side is a direction contract, not just a point on a box border. This pure
|
|
375
|
+
// predicate lets automatic routers prefer a dogleg whose first and final
|
|
376
|
+
// segments leave/enter the chosen sides perpendicularly.
|
|
377
|
+
export function routeHonorsEndpointSides(points, fromSide, toSide) {
|
|
378
|
+
return !endpointSideIssue(points, 'source', fromSide)
|
|
379
|
+
&& !endpointSideIssue(points, 'target', toSide);
|
|
380
|
+
}
|
|
381
|
+
// Explicit fromSide/toSide are authored geometry, so a tangent or backwards
|
|
382
|
+
// endpoint segment changes their meaning. Fail this universally instead of
|
|
383
|
+
// leaving a malformed arrow for visual review to discover. Named routes and
|
|
384
|
+
// authored via points already carry their own geometry semantics: when they
|
|
385
|
+
// omit endpoint sides, do not invent a relative-position side and then reject
|
|
386
|
+
// the route for disagreeing with that invention. Pure automatic routes may
|
|
387
|
+
// still be checked against renderer-inferred sides.
|
|
388
|
+
export function cleanEndpointSideProblems({
|
|
389
|
+
relations,
|
|
390
|
+
endpointIds,
|
|
391
|
+
pathFor,
|
|
392
|
+
diagramType,
|
|
393
|
+
relationCollection,
|
|
394
|
+
fromSideFor,
|
|
395
|
+
toSideFor,
|
|
396
|
+
shouldCheckRelation = () => true,
|
|
397
|
+
routeHint = 'align the first/final via segment with fromSide/toSide, change the side, or remove explicit routing so auto can choose a perpendicular approach',
|
|
398
|
+
}) {
|
|
399
|
+
const problems = [];
|
|
400
|
+
for (const [relationIndex, relation] of asArray(relations).entries()) {
|
|
401
|
+
if (!relation || !endpointIds?.has(relation.from) || !endpointIds?.has(relation.to)) continue;
|
|
402
|
+
if (!shouldCheckRelation(relation, relationIndex)) continue;
|
|
403
|
+
const points = pathFor(relation)?.points;
|
|
404
|
+
if (!Array.isArray(points) || points.length < 2) continue;
|
|
405
|
+
const authoredFromSide = relation.fromSide && relation.fromSide !== 'auto' ? relation.fromSide : null;
|
|
406
|
+
const authoredToSide = relation.toSide && relation.toSide !== 'auto' ? relation.toSide : null;
|
|
407
|
+
const hasAuthoredRouteGeometry = Boolean(
|
|
408
|
+
(relation.route && relation.route !== 'auto') || Array.isArray(relation.via),
|
|
409
|
+
);
|
|
410
|
+
const inferredFromSide = !hasAuthoredRouteGeometry && typeof fromSideFor === 'function'
|
|
411
|
+
? fromSideFor(relation)
|
|
412
|
+
: null;
|
|
413
|
+
const inferredToSide = !hasAuthoredRouteGeometry && typeof toSideFor === 'function'
|
|
414
|
+
? toSideFor(relation)
|
|
415
|
+
: null;
|
|
416
|
+
const fromSide = authoredFromSide ?? inferredFromSide;
|
|
417
|
+
const toSide = authoredToSide ?? inferredToSide;
|
|
418
|
+
const checks = [
|
|
419
|
+
fromSide
|
|
420
|
+
? { ...endpointSideIssue(points, 'source', fromSide), sideOrigin: authoredFromSide ? 'authored' : 'inferred' }
|
|
421
|
+
: null,
|
|
422
|
+
toSide
|
|
423
|
+
? { ...endpointSideIssue(points, 'target', toSide), sideOrigin: authoredToSide ? 'authored' : 'inferred' }
|
|
424
|
+
: null,
|
|
425
|
+
].filter((issue) => issue?.endpoint);
|
|
426
|
+
for (const issue of checks) {
|
|
427
|
+
const relationId = relation.id ? ` id "${relation.id}"` : '';
|
|
428
|
+
const authoredField = issue.endpoint === 'source' ? 'fromSide' : 'toSide';
|
|
429
|
+
const sideField = issue.sideOrigin === 'inferred' ? `inferred ${authoredField}` : authoredField;
|
|
430
|
+
const segmentRole = issue.endpoint === 'source' ? 'first' : 'final';
|
|
431
|
+
const from = issue.start.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
432
|
+
const to = issue.end.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
433
|
+
const message = `[clean-flow/endpoint-side-direction] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" ${segmentRole} segment ${issue.segmentIndex} [${from}] -> [${to}] does not honor ${sideField} "${issue.side}" — it must run ${issue.expectedAxis} ${issue.expectedDirection}; ${routeHint}.`;
|
|
434
|
+
recordDiagnostic({
|
|
435
|
+
code: 'clean-flow/endpoint-side-direction',
|
|
436
|
+
severity: 'error',
|
|
437
|
+
message,
|
|
438
|
+
subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation),
|
|
439
|
+
evidence: {
|
|
440
|
+
endpoint: issue.endpoint,
|
|
441
|
+
authoredField,
|
|
442
|
+
sideOrigin: issue.sideOrigin,
|
|
443
|
+
side: issue.side,
|
|
444
|
+
segmentIndex: issue.segmentIndex,
|
|
445
|
+
from: issue.start,
|
|
446
|
+
to: issue.end,
|
|
447
|
+
expectedAxis: issue.expectedAxis,
|
|
448
|
+
expectedDirection: issue.expectedDirection,
|
|
449
|
+
},
|
|
450
|
+
supportedFixes: [routeHint],
|
|
451
|
+
});
|
|
452
|
+
problems.push(message);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return problems;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// One mechanical quality gate for every renderer-owned relationship path.
|
|
459
|
+
// A renderer supplies its semantic obstacle set; source/target boxes are
|
|
460
|
+
// always exempt because paths are expected to terminate on their boundaries.
|
|
461
|
+
// Containers, lifelines, and other intentionally pass-through geometry should
|
|
462
|
+
// simply not be supplied as obstacles.
|
|
463
|
+
export function cleanFlowProblems({
|
|
464
|
+
relations,
|
|
465
|
+
obstacles,
|
|
466
|
+
pathFor,
|
|
467
|
+
diagramType,
|
|
468
|
+
relationCollection,
|
|
469
|
+
obstacleKind,
|
|
470
|
+
clearance = 2,
|
|
471
|
+
routeHint = 'adjust fromSide/toSide, set route/via or channel coordinates, or move the obstacle'
|
|
472
|
+
}) {
|
|
473
|
+
// A relationship hidden behind an unrelated opaque node changes the
|
|
474
|
+
// diagram's meaning, so this is a correctness invariant rather than an
|
|
475
|
+
// opt-in composition preference. Keep it active even when the author omits
|
|
476
|
+
// quality_profile; standard/showcase still control stricter visual budgets.
|
|
477
|
+
const problems = [];
|
|
478
|
+
const obstacleList = [...obstacles];
|
|
479
|
+
const obstacleIds = new Set(obstacleList.map((obstacle) => obstacle?.id));
|
|
480
|
+
for (const [relationIndex, relation] of asArray(relations).entries()) {
|
|
481
|
+
if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') continue;
|
|
482
|
+
if (!obstacleIds.has(relation.from) || !obstacleIds.has(relation.to)) continue;
|
|
483
|
+
const points = pathFor(relation)?.points;
|
|
484
|
+
if (!Array.isArray(points) || points.length < 2) continue;
|
|
485
|
+
if (!points.every((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))) continue;
|
|
486
|
+
|
|
487
|
+
const endpointIds = new Set([relation.from, relation.to]);
|
|
488
|
+
for (const obstacle of obstacleList) {
|
|
489
|
+
if (!obstacle || endpointIds.has(obstacle.id)) continue;
|
|
490
|
+
if (!isFinitePoint(obstacle.x, obstacle.y, obstacle.width, obstacle.height)) continue;
|
|
491
|
+
let hitSegment = -1;
|
|
492
|
+
for (let segmentIndex = 0; segmentIndex < points.length - 1; segmentIndex += 1) {
|
|
493
|
+
if (segmentIntersectsRect({ start: points[segmentIndex], end: points[segmentIndex + 1] }, obstacle, clearance)) {
|
|
494
|
+
hitSegment = segmentIndex;
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (hitSegment === -1) continue;
|
|
499
|
+
const from = points[hitSegment].map(Math.round).join(', ');
|
|
500
|
+
const to = points[hitSegment + 1].map(Math.round).join(', ');
|
|
501
|
+
const relationId = relation.id ? ` id "${relation.id}"` : '';
|
|
502
|
+
const hint = rePlanHint([relation], routeHint);
|
|
503
|
+
const message = `[clean-flow/edge-through-node] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" crosses ${obstacleKind} "${obstacle.id}" (unrelated to this relationship) on segment ${hitSegment} [${from}] -> [${to}] (${clearance}px clearance) — ${hint}.`;
|
|
504
|
+
recordDiagnostic({
|
|
505
|
+
code: 'clean-flow/edge-through-node',
|
|
506
|
+
severity: 'error',
|
|
507
|
+
message,
|
|
508
|
+
subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation),
|
|
509
|
+
evidence: {
|
|
510
|
+
obstacleKind,
|
|
511
|
+
obstacleId: obstacle.id,
|
|
512
|
+
segmentIndex: hitSegment,
|
|
513
|
+
from: points[hitSegment],
|
|
514
|
+
to: points[hitSegment + 1],
|
|
515
|
+
clearancePx: clearance,
|
|
516
|
+
},
|
|
517
|
+
supportedFixes: [hint],
|
|
518
|
+
});
|
|
519
|
+
problems.push(message);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return problems;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Build a read-only analysis copy of a polyline with straight-through
|
|
526
|
+
// waypoints removed. A waypoint on a forward-collinear run is not a visual
|
|
527
|
+
// endpoint, so treating it as one would hide a proper X that lands exactly on
|
|
528
|
+
// that waypoint. Reversals and real bends stay split: their shared point can
|
|
529
|
+
// still be an authored touch rather than a crossing. The source points are
|
|
530
|
+
// retained on every merged segment so diagnostics can name the authored
|
|
531
|
+
// segment that contains a hit without changing rendered/receipt geometry.
|
|
532
|
+
export function forwardCollinearAnalysisSegments(points) {
|
|
533
|
+
const segments = [];
|
|
534
|
+
for (let segmentIndex = 0; segmentIndex < asArray(points).length - 1; segmentIndex += 1) {
|
|
535
|
+
const authoredStart = points[segmentIndex];
|
|
536
|
+
const authoredEnd = points[segmentIndex + 1];
|
|
537
|
+
const start = Array.isArray(authoredStart) ? [...authoredStart] : authoredStart;
|
|
538
|
+
const end = Array.isArray(authoredEnd) ? [...authoredEnd] : authoredEnd;
|
|
539
|
+
const sourceSegment = { start, end, segmentIndex };
|
|
540
|
+
const previous = segments.at(-1);
|
|
541
|
+
if (previous && segmentsContinueForward(previous.start, previous.end, start, end)) {
|
|
542
|
+
previous.end = end;
|
|
543
|
+
previous.sourceSegments.push(sourceSegment);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
segments.push({
|
|
547
|
+
start,
|
|
548
|
+
end,
|
|
549
|
+
segmentIndex,
|
|
550
|
+
sourceSegments: [sourceSegment],
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return segments;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function sourceSegmentIndexAtPoint(segment, point) {
|
|
557
|
+
const source = asArray(segment?.sourceSegments).find(({ start, end }) => (
|
|
558
|
+
pointLiesOnSegment(point, start, end)
|
|
559
|
+
));
|
|
560
|
+
return source?.segmentIndex ?? segment?.segmentIndex ?? 0;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function authoredAnalysisSegments(points) {
|
|
564
|
+
return asArray(points).slice(0, -1).map((start, segmentIndex) => ({
|
|
565
|
+
start,
|
|
566
|
+
end: points[segmentIndex + 1],
|
|
567
|
+
segmentIndex,
|
|
568
|
+
sourceSegments: [{ start, end: points[segmentIndex + 1], segmentIndex }],
|
|
569
|
+
}));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function segmentsContinueForward(firstStart, firstEnd, secondStart, secondEnd) {
|
|
573
|
+
if (![firstStart, firstEnd, secondStart, secondEnd].every((point) => (
|
|
574
|
+
Array.isArray(point) && point.length === 2 && isFinitePoint(...point)
|
|
575
|
+
))) return false;
|
|
576
|
+
const epsilon = 0.0001;
|
|
577
|
+
if (Math.abs(firstEnd[0] - secondStart[0]) > epsilon
|
|
578
|
+
|| Math.abs(firstEnd[1] - secondStart[1]) > epsilon) return false;
|
|
579
|
+
const firstVector = [firstEnd[0] - firstStart[0], firstEnd[1] - firstStart[1]];
|
|
580
|
+
const secondVector = [secondEnd[0] - secondStart[0], secondEnd[1] - secondStart[1]];
|
|
581
|
+
const firstLength = Math.hypot(...firstVector);
|
|
582
|
+
const secondLength = Math.hypot(...secondVector);
|
|
583
|
+
if (firstLength <= epsilon || secondLength <= epsilon) return false;
|
|
584
|
+
const cross = firstVector[0] * secondVector[1] - firstVector[1] * secondVector[0];
|
|
585
|
+
if (Math.abs(cross) > epsilon) return false;
|
|
586
|
+
const dot = firstVector[0] * secondVector[0] + firstVector[1] * secondVector[1];
|
|
587
|
+
return dot > epsilon;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function pointLiesOnSegment(point, start, end) {
|
|
591
|
+
if (![point, start, end].every((candidate) => (
|
|
592
|
+
Array.isArray(candidate) && candidate.length === 2 && isFinitePoint(...candidate)
|
|
593
|
+
))) return false;
|
|
594
|
+
const epsilon = 0.0001;
|
|
595
|
+
const length = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
|
596
|
+
if (length <= epsilon) return Math.hypot(point[0] - start[0], point[1] - start[1]) <= epsilon;
|
|
597
|
+
if (Math.abs(crossProduct(start, end, point)) > epsilon * length) return false;
|
|
598
|
+
return point[0] >= Math.min(start[0], end[0]) - epsilon
|
|
599
|
+
&& point[0] <= Math.max(start[0], end[0]) + epsilon
|
|
600
|
+
&& point[1] >= Math.min(start[1], end[1]) - epsilon
|
|
601
|
+
&& point[1] <= Math.max(start[1], end[1]) + epsilon;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Authored shared endpoints retain their junction interpretation by default.
|
|
605
|
+
// Renderers can opt their automatic routes into proper interior X checks;
|
|
606
|
+
// endpoint touches and collinear trunks still are not proper crossings.
|
|
607
|
+
export function cleanCrossingProblems({
|
|
608
|
+
relations,
|
|
609
|
+
endpointIds,
|
|
610
|
+
pathFor,
|
|
611
|
+
diagramType,
|
|
612
|
+
relationCollection,
|
|
613
|
+
profile = 'standard',
|
|
614
|
+
profileIsAuthoritative = false,
|
|
615
|
+
mergeForwardCollinearWaypoints = false,
|
|
616
|
+
includeSharedEndpoints = () => false,
|
|
617
|
+
crossingResolved = () => false,
|
|
618
|
+
warnInStandard = false,
|
|
619
|
+
onDiagnostic = recordDiagnostic,
|
|
620
|
+
routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors'
|
|
621
|
+
}) {
|
|
622
|
+
const severity = qualityProfileForGate(profile, profileIsAuthoritative) === 'showcase' ? 'error' : 'warning';
|
|
623
|
+
if (severity === 'warning' && !warnInStandard) return [];
|
|
624
|
+
const routed = asArray(relations).map((relation, index) => {
|
|
625
|
+
if (!relation || !endpointIds.has(relation.from) || !endpointIds.has(relation.to)) return null;
|
|
626
|
+
const points = pathFor(relation)?.points;
|
|
627
|
+
if (!Array.isArray(points) || points.length < 2) return null;
|
|
628
|
+
if (!points.every((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))) return null;
|
|
629
|
+
return {
|
|
630
|
+
relation,
|
|
631
|
+
index,
|
|
632
|
+
points,
|
|
633
|
+
analysisSegments: mergeForwardCollinearWaypoints
|
|
634
|
+
? forwardCollinearAnalysisSegments(points)
|
|
635
|
+
: authoredAnalysisSegments(points),
|
|
636
|
+
};
|
|
637
|
+
}).filter(Boolean);
|
|
638
|
+
const problems = [];
|
|
639
|
+
|
|
640
|
+
for (let leftIndex = 0; leftIndex < routed.length; leftIndex += 1) {
|
|
641
|
+
const left = routed[leftIndex];
|
|
642
|
+
for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) {
|
|
643
|
+
const right = routed[rightIndex];
|
|
644
|
+
if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)
|
|
645
|
+
&& !includeSharedEndpoints(left.relation, right.relation)) continue;
|
|
646
|
+
|
|
647
|
+
let hit = null;
|
|
648
|
+
for (const leftSegment of left.analysisSegments) {
|
|
649
|
+
if (hit) break;
|
|
650
|
+
for (const rightSegment of right.analysisSegments) {
|
|
651
|
+
const point = properSegmentIntersection(
|
|
652
|
+
leftSegment.start,
|
|
653
|
+
leftSegment.end,
|
|
654
|
+
rightSegment.start,
|
|
655
|
+
rightSegment.end
|
|
656
|
+
);
|
|
657
|
+
if (point) {
|
|
658
|
+
hit = {
|
|
659
|
+
point,
|
|
660
|
+
leftSegment: sourceSegmentIndexAtPoint(leftSegment, point),
|
|
661
|
+
rightSegment: sourceSegmentIndexAtPoint(rightSegment, point),
|
|
662
|
+
};
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (!hit) continue;
|
|
668
|
+
if (crossingResolved(left.relation, right.relation, hit)) continue;
|
|
669
|
+
|
|
670
|
+
const describe = ({ relation, index }) => {
|
|
671
|
+
const id = relation.id ? ` id "${relation.id}"` : '';
|
|
672
|
+
return `${relationCollection}[${index}]${id} "${relation.from}" -> "${relation.to}"`;
|
|
673
|
+
};
|
|
674
|
+
const point = hit.point.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
675
|
+
const hint = rePlanHint([left.relation, right.relation], routeHint);
|
|
676
|
+
const message = `[composition/proper-crossing] ${severity === 'error' ? 'showcase' : 'standard'} ${diagramType} ${describe(left)} crosses ${describe(right)} at [${point}] (segments ${hit.leftSegment} and ${hit.rightSegment}) — ${hint}.`;
|
|
677
|
+
onDiagnostic({
|
|
678
|
+
code: 'composition/proper-crossing',
|
|
679
|
+
severity,
|
|
680
|
+
message,
|
|
681
|
+
subject: relationshipSubject(diagramType, relationCollection, left.index, left.relation),
|
|
682
|
+
evidence: {
|
|
683
|
+
otherRelationship: relationshipSubject(diagramType, relationCollection, right.index, right.relation),
|
|
684
|
+
point: hit.point,
|
|
685
|
+
segmentIndex: hit.leftSegment,
|
|
686
|
+
otherSegmentIndex: hit.rightSegment,
|
|
687
|
+
},
|
|
688
|
+
supportedFixes: [hint],
|
|
689
|
+
});
|
|
690
|
+
if (severity === 'error') problems.push(message);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return problems;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Two unrelated relationships that occupy the same visible corridor can read
|
|
697
|
+
// as one authored branch or merge even when neither relationship crosses a
|
|
698
|
+
// node or forms a proper X. Keep authored shared endpoints exempt by default;
|
|
699
|
+
// automatic architecture routes opt in because they promise separate ports.
|
|
700
|
+
// Workflow v2 opts into shared-endpoint checks with a bounded terminal-trunk
|
|
701
|
+
// exception. Other callers keep their existing authored-junction contract.
|
|
702
|
+
// The counterflow-only opt-in serves older workflow exports without a root
|
|
703
|
+
// readable-v2 contract; full shared-endpoint checking takes precedence.
|
|
704
|
+
// Tiny overlaps below the route rhythm
|
|
705
|
+
// floor are ignored to avoid turning sub-pixel rounding into a quality debt.
|
|
706
|
+
export function collectAmbiguousCorridors({
|
|
707
|
+
routedRelations,
|
|
708
|
+
minOverlapPx = 8,
|
|
709
|
+
includeSharedEndpoints = () => false,
|
|
710
|
+
includeSharedEndpointCounterflow = () => false,
|
|
711
|
+
allowShortWorkflowTrunks = false,
|
|
712
|
+
}) {
|
|
713
|
+
const routed = asArray(routedRelations).map((entry, fallbackIndex) => {
|
|
714
|
+
const relation = entry?.relation;
|
|
715
|
+
if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
|
|
716
|
+
const points = normalizeRoutePoints(entry?.points);
|
|
717
|
+
if (points.length < 2) return null;
|
|
718
|
+
return {
|
|
719
|
+
relation,
|
|
720
|
+
relationIndex: Number.isInteger(entry.relationIndex) ? entry.relationIndex : fallbackIndex,
|
|
721
|
+
points,
|
|
722
|
+
};
|
|
723
|
+
}).filter(Boolean);
|
|
724
|
+
const hits = [];
|
|
725
|
+
|
|
726
|
+
for (let leftIndex = 0; leftIndex < routed.length; leftIndex += 1) {
|
|
727
|
+
const left = routed[leftIndex];
|
|
728
|
+
for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) {
|
|
729
|
+
const right = routed[rightIndex];
|
|
730
|
+
const sharedEndpoint = [left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to);
|
|
731
|
+
const counterflowOnly = sharedEndpoint && !includeSharedEndpoints(left.relation, right.relation);
|
|
732
|
+
if (counterflowOnly && !includeSharedEndpointCounterflow(left.relation, right.relation)) continue;
|
|
733
|
+
|
|
734
|
+
let longest = null;
|
|
735
|
+
for (let leftSegment = 0; leftSegment < left.points.length - 1; leftSegment += 1) {
|
|
736
|
+
for (let rightSegment = 0; rightSegment < right.points.length - 1; rightSegment += 1) {
|
|
737
|
+
const overlap = collinearAxisOverlap(
|
|
738
|
+
left.points[leftSegment],
|
|
739
|
+
left.points[leftSegment + 1],
|
|
740
|
+
right.points[rightSegment],
|
|
741
|
+
right.points[rightSegment + 1],
|
|
742
|
+
);
|
|
743
|
+
if (!overlap || overlap.length + 0.0001 < minOverlapPx) continue;
|
|
744
|
+
if (allowShortWorkflowTrunks && shortWorkflowTrunk(left, right, leftSegment, rightSegment, overlap.length)) continue;
|
|
745
|
+
if (counterflowOnly) {
|
|
746
|
+
const leftDelta = left.points[leftSegment + 1].map((value, axis) => value - left.points[leftSegment][axis]);
|
|
747
|
+
const rightDelta = right.points[rightSegment + 1].map((value, axis) => value - right.points[rightSegment][axis]);
|
|
748
|
+
if (leftDelta[0] * rightDelta[0] + leftDelta[1] * rightDelta[1] >= 0) continue;
|
|
749
|
+
}
|
|
750
|
+
if (!longest || overlap.length > longest.overlapLength + 0.0001) {
|
|
751
|
+
longest = {
|
|
752
|
+
left,
|
|
753
|
+
right,
|
|
754
|
+
leftSegment,
|
|
755
|
+
rightSegment,
|
|
756
|
+
overlapLength: overlap.length,
|
|
757
|
+
overlapStart: overlap.start,
|
|
758
|
+
overlapEnd: overlap.end,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (longest) hits.push(longest);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return hits;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function shortWorkflowTrunk(left, right, leftSegment, rightSegment, length) {
|
|
770
|
+
if (length > 24 + 0.0001) return false;
|
|
771
|
+
const a = left.relation, b = right.relation;
|
|
772
|
+
const variant = (edge) => edge.variant || 'default';
|
|
773
|
+
const width = (edge) => edge.width || (variant(edge) === 'emphasis' ? 1.8 : 1.4);
|
|
774
|
+
if (variant(a) !== variant(b) || width(a) !== width(b) || (a.role || '') !== (b.role || '')) return false;
|
|
775
|
+
const same = (p, q) => Math.abs(p[0] - q[0]) < 0.0001 && Math.abs(p[1] - q[1]) < 0.0001;
|
|
776
|
+
const source = a.from === b.from && leftSegment === 0 && rightSegment === 0
|
|
777
|
+
&& same(left.points[0], right.points[0]);
|
|
778
|
+
const target = a.to === b.to && leftSegment === left.points.length - 2 && rightSegment === right.points.length - 2
|
|
779
|
+
&& same(left.points.at(-1), right.points.at(-1));
|
|
780
|
+
if (!source && !target) return false;
|
|
781
|
+
const p = left.points[leftSegment], q = left.points[leftSegment + 1];
|
|
782
|
+
const r = right.points[rightSegment], s = right.points[rightSegment + 1];
|
|
783
|
+
return (q[0] - p[0]) * (s[0] - r[0]) + (q[1] - p[1]) * (s[1] - r[1]) > 0;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Bundled arrow markers are 7 stroke-widths across the direction of travel.
|
|
787
|
+
// Callers select the automatic routes they own; explicit junctions are preserved.
|
|
788
|
+
export function collectArrowheadCollisions({ routedRelations, allowShortWorkflowTrunks = false }) {
|
|
789
|
+
const incoming = new Map();
|
|
790
|
+
const hits = [];
|
|
791
|
+
for (const entry of asArray(routedRelations)) {
|
|
792
|
+
const points = normalizeRoutePoints(entry.points);
|
|
793
|
+
if (points.length < 2 || !entry.relation?.to) continue;
|
|
794
|
+
const tip = points.at(-1);
|
|
795
|
+
const previous = points.at(-2);
|
|
796
|
+
const vertical = Math.abs(tip[0] - previous[0]) < 0.0001;
|
|
797
|
+
const axis = vertical ? 0 : 1;
|
|
798
|
+
const direction = Math.sign(tip[1 - axis] - previous[1 - axis]);
|
|
799
|
+
const halfWidth = 3.5 * (entry.relation.width || (entry.relation.variant === 'emphasis' ? 1.8 : 1.5));
|
|
800
|
+
const key = `${entry.relation.to}\u0000${axis}\u0000${direction}`;
|
|
801
|
+
const siblings = incoming.get(key) || [];
|
|
802
|
+
const current = { ...entry, tip, halfWidth };
|
|
803
|
+
for (const sibling of siblings) {
|
|
804
|
+
if (Math.abs(tip[1 - axis] - sibling.tip[1 - axis]) > 0.0001) continue;
|
|
805
|
+
const distance = Math.abs(tip[axis] - sibling.tip[axis]);
|
|
806
|
+
const minimum = halfWidth + sibling.halfWidth;
|
|
807
|
+
if (allowShortWorkflowTrunks) {
|
|
808
|
+
const left = { ...sibling, points: normalizeRoutePoints(sibling.points) };
|
|
809
|
+
const right = { ...entry, points };
|
|
810
|
+
const overlap = collinearAxisOverlap(left.points.at(-2), left.points.at(-1), points.at(-2), tip);
|
|
811
|
+
if (overlap && shortWorkflowTrunk(left, right, left.points.length - 2, points.length - 2, overlap.length)) continue;
|
|
812
|
+
}
|
|
813
|
+
if (distance < minimum - 0.0001) hits.push({ left: sibling, right: current, distance, minimum });
|
|
814
|
+
}
|
|
815
|
+
siblings.push(current);
|
|
816
|
+
incoming.set(key, siblings);
|
|
817
|
+
}
|
|
818
|
+
return hits;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
export function cleanAmbiguousCorridorProblems({
|
|
822
|
+
relations,
|
|
823
|
+
endpointIds,
|
|
824
|
+
pathFor,
|
|
825
|
+
diagramType,
|
|
826
|
+
relationCollection,
|
|
827
|
+
profile = 'standard',
|
|
828
|
+
profileIsAuthoritative = false,
|
|
829
|
+
routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors',
|
|
830
|
+
minOverlapPx = 8,
|
|
831
|
+
includeSharedEndpoints = () => false,
|
|
832
|
+
includeSharedEndpointCounterflow = () => false,
|
|
833
|
+
allowShortWorkflowTrunks = false,
|
|
834
|
+
onDiagnostic = recordDiagnostic,
|
|
835
|
+
}) {
|
|
836
|
+
const severity = qualityProfileForGate(profile, profileIsAuthoritative) === 'showcase' ? 'error' : 'warning';
|
|
837
|
+
if (severity === 'warning' && !allowShortWorkflowTrunks) return [];
|
|
838
|
+
const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
|
|
839
|
+
|
|
840
|
+
return collectAmbiguousCorridors({ routedRelations, minOverlapPx, includeSharedEndpoints, includeSharedEndpointCounterflow, allowShortWorkflowTrunks }).map((hit) => {
|
|
841
|
+
const describe = ({ relation, relationIndex }) => {
|
|
842
|
+
const id = relation.id ? ` id "${relation.id}"` : '';
|
|
843
|
+
return `${relationCollection}[${relationIndex}]${id} "${relation.from}" -> "${relation.to}"`;
|
|
844
|
+
};
|
|
845
|
+
const length = Math.round(hit.overlapLength * 10) / 10;
|
|
846
|
+
const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
847
|
+
const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
848
|
+
const hint = rePlanHint([hit.left.relation, hit.right.relation], routeHint);
|
|
849
|
+
const message = `[composition/ambiguous-corridor] ${severity === 'error' ? 'showcase' : 'standard'} ${diagramType} ${describe(hit.left)} shares a ${length}px corridor with ${describe(hit.right)} at [${from}] -> [${to}] (segments ${hit.leftSegment} and ${hit.rightSegment}; minimum ${minOverlapPx}px) — ${hint}.`;
|
|
850
|
+
onDiagnostic({
|
|
851
|
+
code: 'composition/ambiguous-corridor',
|
|
852
|
+
severity,
|
|
853
|
+
message,
|
|
854
|
+
subject: relationshipSubject(diagramType, relationCollection, hit.left.relationIndex, hit.left.relation),
|
|
855
|
+
evidence: {
|
|
856
|
+
otherRelationship: relationshipSubject(diagramType, relationCollection, hit.right.relationIndex, hit.right.relation),
|
|
857
|
+
overlapLengthPx: length,
|
|
858
|
+
minimumPx: minOverlapPx,
|
|
859
|
+
from: hit.overlapStart,
|
|
860
|
+
to: hit.overlapEnd,
|
|
861
|
+
segmentIndex: hit.leftSegment,
|
|
862
|
+
otherSegmentIndex: hit.rightSegment,
|
|
863
|
+
},
|
|
864
|
+
supportedFixes: [hint],
|
|
865
|
+
});
|
|
866
|
+
return severity === 'error' ? message : null;
|
|
867
|
+
}).filter(Boolean);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Relationship paths may cross a structural frame, but they must not borrow a
|
|
871
|
+
// frame side as a routing corridor. Rounded rectangle corners are trimmed from
|
|
872
|
+
// the modeled straight sides so a short corner touch is not mistaken for a
|
|
873
|
+
// border run. Any positive straight overlap beyond the numeric epsilon is a
|
|
874
|
+
// hard failure in every quality profile; 16px belongs only to the separate,
|
|
875
|
+
// neutral short-segment metric and is not a corridor exemption.
|
|
876
|
+
export function collectBorderRuns({ routedRelations, frames }) {
|
|
877
|
+
const hits = [];
|
|
878
|
+
for (const routed of asArray(routedRelations)) {
|
|
879
|
+
const routeSegments = Array.isArray(routed?.segments)
|
|
880
|
+
? routed.segments
|
|
881
|
+
: asArray(routed?.points).slice(0, -1).map((start, index) => ({ start, end: routed.points[index + 1] }));
|
|
882
|
+
if (!routeSegments.length) continue;
|
|
883
|
+
if (!routeSegments.every((segment) => (
|
|
884
|
+
Array.isArray(segment?.start) && segment.start.length === 2 && isFinitePoint(...segment.start)
|
|
885
|
+
&& Array.isArray(segment?.end) && segment.end.length === 2 && isFinitePoint(...segment.end)
|
|
886
|
+
))) continue;
|
|
887
|
+
for (const [frameIndex, frame] of asArray(frames).entries()) {
|
|
888
|
+
for (const border of frameBorderSegments(frame)) {
|
|
889
|
+
const overlaps = [];
|
|
890
|
+
for (let segmentIndex = 0; segmentIndex < routeSegments.length; segmentIndex += 1) {
|
|
891
|
+
const segment = routeSegments[segmentIndex];
|
|
892
|
+
const overlap = collinearAxisOverlap(
|
|
893
|
+
segment.start,
|
|
894
|
+
segment.end,
|
|
895
|
+
border.start,
|
|
896
|
+
border.end,
|
|
897
|
+
);
|
|
898
|
+
if (!overlap || overlap.length <= 0.0001) continue;
|
|
899
|
+
overlaps.push({ ...overlap, segmentIndex });
|
|
900
|
+
}
|
|
901
|
+
if (!overlaps.length) continue;
|
|
902
|
+
const merged = mergeBorderOverlaps(overlaps, border);
|
|
903
|
+
const longest = [...merged].sort((left, right) => right.length - left.length || left.low - right.low)[0];
|
|
904
|
+
hits.push({
|
|
905
|
+
...routed,
|
|
906
|
+
frame,
|
|
907
|
+
frameIndex,
|
|
908
|
+
side: border.side,
|
|
909
|
+
segmentIndex: overlaps.reduce((min, overlap) => Math.min(min, overlap.segmentIndex), Infinity),
|
|
910
|
+
overlapLength: merged.reduce((total, overlap) => total + overlap.length, 0),
|
|
911
|
+
overlapStart: longest.start,
|
|
912
|
+
overlapEnd: longest.end,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return hits;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
export function cleanBorderRunProblems({
|
|
921
|
+
relations,
|
|
922
|
+
endpointIds,
|
|
923
|
+
frames,
|
|
924
|
+
pathFor,
|
|
925
|
+
diagramType,
|
|
926
|
+
relationCollection,
|
|
927
|
+
profile,
|
|
928
|
+
profileIsAuthoritative = false,
|
|
929
|
+
routeHint = 'adjust route/via or channel coordinates so the relationship crosses the frame perpendicularly through a clear opening'
|
|
930
|
+
}) {
|
|
931
|
+
if (!qualityProfileForGate(profile, profileIsAuthoritative)) return [];
|
|
932
|
+
const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
|
|
933
|
+
return collectBorderRuns({ routedRelations, frames }).map((hit) => {
|
|
934
|
+
const relation = hit.relation || {};
|
|
935
|
+
const relationId = relation.id ? ` id "${relation.id}"` : '';
|
|
936
|
+
const frameKind = hit.frame?.kind || hit.frame?.shape || 'frame';
|
|
937
|
+
const frameIdentity = hit.frame?.label || hit.frame?.id || hit.frameIndex;
|
|
938
|
+
const length = Math.round(hit.overlapLength * 10) / 10;
|
|
939
|
+
const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
940
|
+
const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
941
|
+
const hint = rePlanHint([relation], routeHint);
|
|
942
|
+
const message = `[composition/container-border-run] ${diagramType} ${relationCollection}[${hit.relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" follows ${frameKind} "${frameIdentity}" ${hit.side} border for ${length}px on segment ${hit.segmentIndex} [${from}] -> [${to}] — ${hint}.`;
|
|
943
|
+
recordDiagnostic({
|
|
944
|
+
code: 'composition/container-border-run',
|
|
945
|
+
severity: 'error',
|
|
946
|
+
message,
|
|
947
|
+
subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation),
|
|
948
|
+
evidence: {
|
|
949
|
+
frameKind,
|
|
950
|
+
frameId: hit.frame?.id,
|
|
951
|
+
frameLabel: hit.frame?.label,
|
|
952
|
+
side: hit.side,
|
|
953
|
+
segmentIndex: hit.segmentIndex,
|
|
954
|
+
overlapLengthPx: length,
|
|
955
|
+
from: hit.overlapStart,
|
|
956
|
+
to: hit.overlapEnd,
|
|
957
|
+
},
|
|
958
|
+
supportedFixes: [hint],
|
|
959
|
+
});
|
|
960
|
+
return message;
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
export function routeBudgetMetrics({
|
|
965
|
+
routedRelations,
|
|
966
|
+
bendsPerRelationship = 2,
|
|
967
|
+
stretch = 1.35,
|
|
968
|
+
segmentPx = 16,
|
|
969
|
+
microSegmentPx = 8,
|
|
970
|
+
}) {
|
|
971
|
+
let maxBends = 0;
|
|
972
|
+
let routesOverSuggestedBends = 0;
|
|
973
|
+
let maxStretch = null;
|
|
974
|
+
let routesOverSuggestedStretch = 0;
|
|
975
|
+
let minSegmentPx = null;
|
|
976
|
+
let minInteriorSegmentPx = null;
|
|
977
|
+
let shortSegmentCount = 0;
|
|
978
|
+
let shortEndpointSegmentCount = 0;
|
|
979
|
+
let shortInteriorSegmentCount = 0;
|
|
980
|
+
let microSegmentCount = 0;
|
|
981
|
+
|
|
982
|
+
for (const routed of asArray(routedRelations)) {
|
|
983
|
+
const points = normalizeRoutePoints(routed?.points);
|
|
984
|
+
if (points.length < 2) continue;
|
|
985
|
+
const bends = Math.max(0, points.length - 2);
|
|
986
|
+
maxBends = Math.max(maxBends, bends);
|
|
987
|
+
if (bends > bendsPerRelationship) routesOverSuggestedBends += 1;
|
|
988
|
+
|
|
989
|
+
let routeLength = 0;
|
|
990
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
991
|
+
const length = Math.abs(points[index + 1][0] - points[index][0]) + Math.abs(points[index + 1][1] - points[index][1]);
|
|
992
|
+
if (length <= 0.0001) continue;
|
|
993
|
+
const position = segmentPosition(index, points.length - 1);
|
|
994
|
+
routeLength += length;
|
|
995
|
+
minSegmentPx = minSegmentPx == null ? length : Math.min(minSegmentPx, length);
|
|
996
|
+
if (position === 'interior') {
|
|
997
|
+
minInteriorSegmentPx = minInteriorSegmentPx == null ? length : Math.min(minInteriorSegmentPx, length);
|
|
998
|
+
}
|
|
999
|
+
if (length < segmentPx) {
|
|
1000
|
+
shortSegmentCount += 1;
|
|
1001
|
+
if (position === 'interior') shortInteriorSegmentCount += 1;
|
|
1002
|
+
else shortEndpointSegmentCount += 1;
|
|
1003
|
+
}
|
|
1004
|
+
if (length < microSegmentPx) microSegmentCount += 1;
|
|
1005
|
+
}
|
|
1006
|
+
const direct = Math.abs(points.at(-1)[0] - points[0][0]) + Math.abs(points.at(-1)[1] - points[0][1]);
|
|
1007
|
+
if (direct > 0.0001) {
|
|
1008
|
+
const routeStretch = routeLength / direct;
|
|
1009
|
+
maxStretch = maxStretch == null ? routeStretch : Math.max(maxStretch, routeStretch);
|
|
1010
|
+
if (routeStretch > stretch + 0.0001) routesOverSuggestedStretch += 1;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
return {
|
|
1015
|
+
maxBends,
|
|
1016
|
+
routesOverSuggestedBends,
|
|
1017
|
+
maxStretch,
|
|
1018
|
+
routesOverSuggestedStretch,
|
|
1019
|
+
minSegmentPx,
|
|
1020
|
+
minInteriorSegmentPx,
|
|
1021
|
+
shortSegmentCount,
|
|
1022
|
+
shortEndpointSegmentCount,
|
|
1023
|
+
shortInteriorSegmentCount,
|
|
1024
|
+
microSegmentCount,
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
export function collectRouteRhythmIssues({
|
|
1029
|
+
routedRelations,
|
|
1030
|
+
interiorSegmentPx = 16,
|
|
1031
|
+
microSegmentPx = 8,
|
|
1032
|
+
}) {
|
|
1033
|
+
const issues = [];
|
|
1034
|
+
for (const [fallbackIndex, routed] of asArray(routedRelations).entries()) {
|
|
1035
|
+
const points = normalizeRoutePoints(routed?.points);
|
|
1036
|
+
if (points.length < 2) continue;
|
|
1037
|
+
for (let segmentIndex = 0; segmentIndex < points.length - 1; segmentIndex += 1) {
|
|
1038
|
+
const start = points[segmentIndex];
|
|
1039
|
+
const end = points[segmentIndex + 1];
|
|
1040
|
+
const length = Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]);
|
|
1041
|
+
if (length <= 0.0001) continue;
|
|
1042
|
+
const position = segmentPosition(segmentIndex, points.length - 1);
|
|
1043
|
+
const code = length < microSegmentPx - 0.0001
|
|
1044
|
+
? 'composition/micro-segment'
|
|
1045
|
+
: position === 'interior' && length < interiorSegmentPx - 0.0001
|
|
1046
|
+
? 'composition/short-interior-segment'
|
|
1047
|
+
: null;
|
|
1048
|
+
if (!code) continue;
|
|
1049
|
+
issues.push({
|
|
1050
|
+
code,
|
|
1051
|
+
relation: routed.relation,
|
|
1052
|
+
relationIndex: Number.isInteger(routed.relationIndex) ? routed.relationIndex : fallbackIndex,
|
|
1053
|
+
segmentIndex,
|
|
1054
|
+
position,
|
|
1055
|
+
length,
|
|
1056
|
+
start,
|
|
1057
|
+
end,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return issues;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
export function cleanRouteRhythmProblems({
|
|
1065
|
+
relations,
|
|
1066
|
+
endpointIds,
|
|
1067
|
+
pathFor,
|
|
1068
|
+
diagramType,
|
|
1069
|
+
relationCollection,
|
|
1070
|
+
profile,
|
|
1071
|
+
profileIsAuthoritative = false,
|
|
1072
|
+
routeHint = 'move the channel/via point to remove the cramped turn or give the route more corridor space',
|
|
1073
|
+
interiorSegmentPx = 16,
|
|
1074
|
+
microSegmentPx = 8,
|
|
1075
|
+
}) {
|
|
1076
|
+
if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
|
|
1077
|
+
const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
|
|
1078
|
+
return collectRouteRhythmIssues({ routedRelations, interiorSegmentPx, microSegmentPx }).map((hit) => {
|
|
1079
|
+
const relation = hit.relation || {};
|
|
1080
|
+
const relationId = relation.id ? ` id "${relation.id}"` : '';
|
|
1081
|
+
const length = Math.round(hit.length * 10) / 10;
|
|
1082
|
+
const from = hit.start.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
1083
|
+
const to = hit.end.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
1084
|
+
const rule = hit.code === 'composition/micro-segment'
|
|
1085
|
+
? `is below the ${microSegmentPx}px micro-segment floor`
|
|
1086
|
+
: `is below the ${interiorSegmentPx}px interior-segment floor`;
|
|
1087
|
+
const hint = rePlanHint([relation], routeHint);
|
|
1088
|
+
const message = `[${hit.code}] showcase ${diagramType} ${relationCollection}[${hit.relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" has a ${length}px ${hit.position} segment ${hit.segmentIndex} [${from}] -> [${to}] that ${rule} — ${hint}.`;
|
|
1089
|
+
recordDiagnostic({
|
|
1090
|
+
code: hit.code,
|
|
1091
|
+
severity: 'error',
|
|
1092
|
+
message,
|
|
1093
|
+
subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation),
|
|
1094
|
+
evidence: {
|
|
1095
|
+
segmentIndex: hit.segmentIndex,
|
|
1096
|
+
position: hit.position,
|
|
1097
|
+
lengthPx: length,
|
|
1098
|
+
minimumPx: hit.code === 'composition/micro-segment' ? microSegmentPx : interiorSegmentPx,
|
|
1099
|
+
from: hit.start,
|
|
1100
|
+
to: hit.end,
|
|
1101
|
+
},
|
|
1102
|
+
supportedFixes: [hint],
|
|
1103
|
+
});
|
|
1104
|
+
return message;
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
export function cleanLabelRouteClearanceProblems({
|
|
1109
|
+
relations,
|
|
1110
|
+
labels,
|
|
1111
|
+
endpointIds,
|
|
1112
|
+
pathFor,
|
|
1113
|
+
diagramType,
|
|
1114
|
+
relationCollection,
|
|
1115
|
+
profile,
|
|
1116
|
+
profileIsAuthoritative = false,
|
|
1117
|
+
threshold = 4,
|
|
1118
|
+
routeHint = 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other relationship route/via/channel',
|
|
1119
|
+
}) {
|
|
1120
|
+
if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
|
|
1121
|
+
const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
|
|
1122
|
+
return collectLabelRouteClearance({ labels, routedRelations, threshold }).map((hit) => {
|
|
1123
|
+
const describe = (relation, relationIndex) => {
|
|
1124
|
+
const relationId = relation?.id ? ` id "${relation.id}"` : '';
|
|
1125
|
+
const relationLabel = relation?.label ? ` label "${relation.label}"` : '';
|
|
1126
|
+
return `${relationCollection}[${relationIndex}]${relationId} "${relation?.from}" -> "${relation?.to}"${relationLabel}`;
|
|
1127
|
+
};
|
|
1128
|
+
const clearance = Math.round(hit.clearance * 10) / 10;
|
|
1129
|
+
const from = hit.start.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
1130
|
+
const to = hit.end.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
1131
|
+
const hint = rePlanHint([hit.labelRelation, hit.otherRelation], routeHint);
|
|
1132
|
+
const message = `[composition/label-route-clearance] showcase ${diagramType} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${describe(hit.labelRelation, hit.labelRelationIndex)} is ${clearance}px from ${describe(hit.otherRelation, hit.otherRelationIndex)} segment ${hit.segmentIndex} [${from}] -> [${to}] (label rect ${formatRect(hit.rect)}; minimum ${threshold}px) — ${hint}.`;
|
|
1133
|
+
recordDiagnostic({
|
|
1134
|
+
code: 'composition/label-route-clearance',
|
|
1135
|
+
severity: 'error',
|
|
1136
|
+
message,
|
|
1137
|
+
subject: relationshipSubject(diagramType, relationCollection, hit.labelRelationIndex, hit.labelRelation),
|
|
1138
|
+
evidence: {
|
|
1139
|
+
label: hit.label?.label || hit.labelRelation?.label || '',
|
|
1140
|
+
otherRelationship: relationshipSubject(diagramType, relationCollection, hit.otherRelationIndex, hit.otherRelation),
|
|
1141
|
+
segmentIndex: hit.segmentIndex,
|
|
1142
|
+
clearancePx: clearance,
|
|
1143
|
+
minimumPx: threshold,
|
|
1144
|
+
labelRect: hit.rect,
|
|
1145
|
+
from: hit.start,
|
|
1146
|
+
to: hit.end,
|
|
1147
|
+
},
|
|
1148
|
+
supportedFixes: [hint],
|
|
1149
|
+
});
|
|
1150
|
+
return message;
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// How far [start, start + size) leaves [0, extent), per end of one axis. This
|
|
1155
|
+
// is the single definition of "inside the canvas" that both the containment
|
|
1156
|
+
// rule and the repair hints measure against; an unknown extent bounds nothing.
|
|
1157
|
+
function axisCanvasOverflow(start, size, extent, origin = 0) {
|
|
1158
|
+
if (!Number.isFinite(extent)) return { start: 0, end: 0 };
|
|
1159
|
+
return { start: origin - start, end: start + size - (origin + extent) };
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// THE CANVAS CONTAINMENT RATIONALE (referenced from the other call sites).
|
|
1163
|
+
//
|
|
1164
|
+
// The SVG canvas clips whatever leaves the viewBox, so an edge label that
|
|
1165
|
+
// overhangs an edge ships as truncated text while every post-render check on
|
|
1166
|
+
// the emitted markup still passes: clipped text is still well-formed markup.
|
|
1167
|
+
// Renderers with a fixed canvas already bound their nodes, lanes, and legends
|
|
1168
|
+
// against it; edge label rects were the exception.
|
|
1169
|
+
//
|
|
1170
|
+
// Not applied to readable-v2 workflows: that compiler grows its canvas around
|
|
1171
|
+
// pinned label rects and rejects an authored viewBox that cannot hold the
|
|
1172
|
+
// result, so the only gap growth cannot close is the origin its own rule
|
|
1173
|
+
// already guards. Architecture's auto canvas grows the same way, which leaves
|
|
1174
|
+
// this rule reporting authored viewBoxes and the origin side.
|
|
1175
|
+
//
|
|
1176
|
+
// Showcase only: a standard document authored before the rule exists may
|
|
1177
|
+
// overhang by a few pixels, and failing it there would break compatibility
|
|
1178
|
+
// instead of repairing a diagram.
|
|
1179
|
+
export function collectLabelCanvasOverflow({ labels, viewBox, tolerance = 0.5 }) {
|
|
1180
|
+
// Renderers author origin-zero canvases (meta.viewBox is [width, height] by
|
|
1181
|
+
// schema), but `check` re-measures foreign artifacts whose SVG viewBox may
|
|
1182
|
+
// carry a legal non-zero min-x/min-y; those pass all four numbers.
|
|
1183
|
+
const box = asArray(viewBox);
|
|
1184
|
+
const [originX, originY, canvasWidth, canvasHeight] = box.length === 4 ? box : [0, 0, box[0], box[1]];
|
|
1185
|
+
if (!isFinitePoint(originX, originY, canvasWidth, canvasHeight)) return [];
|
|
1186
|
+
const hits = [];
|
|
1187
|
+
for (const [fallbackIndex, label] of asArray(labels).entries()) {
|
|
1188
|
+
const rect = label?.rect || label;
|
|
1189
|
+
if (!rect || !isFinitePoint(rect.x, rect.y, rect.width, rect.height)) continue;
|
|
1190
|
+
// `check` parses foreign markup, so a malformed negative-size rect is
|
|
1191
|
+
// skipped like the other label collectors do — its flipped interval would
|
|
1192
|
+
// otherwise read as contained.
|
|
1193
|
+
if (rect.width < 0 || rect.height < 0) continue;
|
|
1194
|
+
const horizontal = axisCanvasOverflow(rect.x, rect.width, canvasWidth, originX);
|
|
1195
|
+
const vertical = axisCanvasOverflow(rect.y, rect.height, canvasHeight, originY);
|
|
1196
|
+
const overflow = {
|
|
1197
|
+
left: horizontal.start,
|
|
1198
|
+
right: horizontal.end,
|
|
1199
|
+
top: vertical.start,
|
|
1200
|
+
bottom: vertical.end,
|
|
1201
|
+
};
|
|
1202
|
+
const sides = Object.keys(overflow).filter((side) => overflow[side] > tolerance);
|
|
1203
|
+
if (!sides.length) continue;
|
|
1204
|
+
hits.push({
|
|
1205
|
+
label,
|
|
1206
|
+
relation: label?.relation,
|
|
1207
|
+
relationIndex: Number.isInteger(label?.relationIndex) ? label.relationIndex : fallbackIndex,
|
|
1208
|
+
rect,
|
|
1209
|
+
viewBox: [canvasWidth, canvasHeight],
|
|
1210
|
+
viewBoxOrigin: [originX, originY],
|
|
1211
|
+
sides,
|
|
1212
|
+
overflowPx: Object.fromEntries(sides.map((side) => [side, Math.round(overflow[side] * 10) / 10])),
|
|
1213
|
+
tolerance,
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
return hits;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
export function describeLabelCanvasOverflow(hit) {
|
|
1220
|
+
return hit.sides.map((side) => `${side} edge by ${hit.overflowPx[side]}px`).join(' and ');
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
export function cleanLabelCanvasContainmentProblems({
|
|
1224
|
+
labels,
|
|
1225
|
+
viewBox,
|
|
1226
|
+
diagramType,
|
|
1227
|
+
relationCollection,
|
|
1228
|
+
profile,
|
|
1229
|
+
profileIsAuthoritative = false,
|
|
1230
|
+
routeHint = 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise enlarge meta.viewBox',
|
|
1231
|
+
// Label widths are estimated from the text, not measured, so sub-pixel
|
|
1232
|
+
// overhang is rounding noise rather than a visible truncation.
|
|
1233
|
+
tolerance = 0.5,
|
|
1234
|
+
}) {
|
|
1235
|
+
if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
|
|
1236
|
+
return collectLabelCanvasOverflow({ labels, viewBox, tolerance }).map((hit) => {
|
|
1237
|
+
const relation = hit.relation;
|
|
1238
|
+
const relationId = relation?.id ? ` id "${relation.id}"` : '';
|
|
1239
|
+
const labelText = hit.label?.label || relation?.label || '';
|
|
1240
|
+
const hint = rePlanHint([relation], routeHint);
|
|
1241
|
+
const message = `[composition/label-canvas-containment] showcase ${diagramType} label "${labelText}" on ${relationCollection}[${hit.relationIndex}]${relationId} "${relation?.from}" -> "${relation?.to}" extends past the ${describeLabelCanvasOverflow(hit)} (label rect ${formatRect(hit.rect)}; viewBox ${hit.viewBox[0]}x${hit.viewBox[1]}) — ${hint}.`;
|
|
1242
|
+
recordDiagnostic({
|
|
1243
|
+
code: 'composition/label-canvas-containment',
|
|
1244
|
+
severity: 'error',
|
|
1245
|
+
message,
|
|
1246
|
+
subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation),
|
|
1247
|
+
evidence: {
|
|
1248
|
+
label: labelText,
|
|
1249
|
+
labelRect: { x: hit.rect.x, y: hit.rect.y, width: hit.rect.width, height: hit.rect.height },
|
|
1250
|
+
viewBox: hit.viewBox,
|
|
1251
|
+
overflowPx: hit.overflowPx,
|
|
1252
|
+
tolerancePx: hit.tolerance,
|
|
1253
|
+
},
|
|
1254
|
+
supportedFixes: [hint],
|
|
1255
|
+
});
|
|
1256
|
+
return message;
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function qualityProfileForGate(profile, profileIsAuthoritative) {
|
|
1261
|
+
return profileIsAuthoritative
|
|
1262
|
+
? profile
|
|
1263
|
+
: process.env.ARCHIFY_QUALITY_PROFILE || profile;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function collectEligibleRoutedRelations({ relations, endpointIds, pathFor }) {
|
|
1267
|
+
return asArray(relations).map((relation, relationIndex) => {
|
|
1268
|
+
if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
|
|
1269
|
+
if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
|
|
1270
|
+
return { relation, relationIndex, points: pathFor(relation)?.points };
|
|
1271
|
+
}).filter(Boolean);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function segmentPosition(index, segmentCount) {
|
|
1275
|
+
if (index === 0) return 'source-stub';
|
|
1276
|
+
if (index === segmentCount - 1) return 'target-stub';
|
|
1277
|
+
return 'interior';
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Normalizing a route is pure, and the same route array is normalized again by
|
|
1281
|
+
// every predicate that inspects it. Keyed by the array itself, so a rebuilt
|
|
1282
|
+
// route simply gets a new entry. The result's outer array is frozen. Callers
|
|
1283
|
+
// must also treat input arrays and their coordinate pairs as immutable, since
|
|
1284
|
+
// edits in place cannot invalidate this identity-based cache.
|
|
1285
|
+
const NORMALIZED_ROUTE_POINTS = new WeakMap();
|
|
1286
|
+
|
|
1287
|
+
// Assembling a route from an anchor, corridor points and an anchor is the same
|
|
1288
|
+
// normalization without the intermediate array, and the result is seeded in the
|
|
1289
|
+
// cache so later predicates do not normalize it again.
|
|
1290
|
+
export function joinRoutePoints(start, via, end) {
|
|
1291
|
+
const normalized = [];
|
|
1292
|
+
const accept = (point) => {
|
|
1293
|
+
if (!Array.isArray(point) || point.length !== 2 || !isFinitePoint(point[0], point[1])) return;
|
|
1294
|
+
const previous = normalized.at(-1);
|
|
1295
|
+
if (previous
|
|
1296
|
+
&& Math.abs(point[0] - previous[0]) <= 0.0001
|
|
1297
|
+
&& Math.abs(point[1] - previous[1]) <= 0.0001) return;
|
|
1298
|
+
while (normalized.length >= 2 && collinearForward(normalized.at(-2), normalized.at(-1), point)) normalized.pop();
|
|
1299
|
+
normalized.push(point);
|
|
1300
|
+
};
|
|
1301
|
+
accept(start);
|
|
1302
|
+
for (const point of Array.isArray(via) ? via : []) accept(point);
|
|
1303
|
+
accept(end);
|
|
1304
|
+
Object.freeze(normalized);
|
|
1305
|
+
NORMALIZED_ROUTE_POINTS.set(normalized, normalized);
|
|
1306
|
+
return normalized;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
export function normalizeRoutePoints(points) {
|
|
1310
|
+
if (Array.isArray(points)) {
|
|
1311
|
+
const cached = NORMALIZED_ROUTE_POINTS.get(points);
|
|
1312
|
+
if (cached) return cached;
|
|
1313
|
+
}
|
|
1314
|
+
const normalized = [];
|
|
1315
|
+
for (const point of Array.isArray(points) ? points : []) {
|
|
1316
|
+
if (!Array.isArray(point) || point.length !== 2 || !isFinitePoint(point[0], point[1])) continue;
|
|
1317
|
+
const previous = normalized.at(-1);
|
|
1318
|
+
if (previous
|
|
1319
|
+
&& Math.abs(point[0] - previous[0]) <= 0.0001
|
|
1320
|
+
&& Math.abs(point[1] - previous[1]) <= 0.0001) continue;
|
|
1321
|
+
while (normalized.length >= 2 && collinearForward(normalized.at(-2), normalized.at(-1), point)) normalized.pop();
|
|
1322
|
+
normalized.push(point);
|
|
1323
|
+
}
|
|
1324
|
+
if (Array.isArray(points)) {
|
|
1325
|
+
Object.freeze(normalized);
|
|
1326
|
+
NORMALIZED_ROUTE_POINTS.set(points, normalized);
|
|
1327
|
+
}
|
|
1328
|
+
return normalized;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function pointRectDistance(point, rect) {
|
|
1332
|
+
const dx = Math.max(rect.x - point[0], 0, point[0] - (rect.x + rect.width));
|
|
1333
|
+
const dy = Math.max(rect.y - point[1], 0, point[1] - (rect.y + rect.height));
|
|
1334
|
+
return Math.hypot(dx, dy);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function pointSegmentDistanceXY(px, py, start, end) {
|
|
1338
|
+
const dx = end[0] - start[0];
|
|
1339
|
+
const dy = end[1] - start[1];
|
|
1340
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
1341
|
+
if (lengthSquared <= 0.0000001) return Math.hypot(px - start[0], py - start[1]);
|
|
1342
|
+
const t = Math.max(0, Math.min(1, ((px - start[0]) * dx + (py - start[1]) * dy) / lengthSquared));
|
|
1343
|
+
return Math.hypot(px - (start[0] + t * dx), py - (start[1] + t * dy));
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function pointSegmentDistance(point, start, end) {
|
|
1347
|
+
return pointSegmentDistanceXY(point[0], point[1], start, end);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function collinearForward(a, b, c) {
|
|
1351
|
+
if (Math.abs(crossProduct(a, b, c)) > 0.0001) return false;
|
|
1352
|
+
return (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) >= -0.0001;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
export function frameBorderSegments(frame) {
|
|
1356
|
+
if (!frame || typeof frame !== 'object') return [];
|
|
1357
|
+
if (frame.shape === 'line') {
|
|
1358
|
+
const start = frame.start || [frame.x1, frame.y1];
|
|
1359
|
+
const end = frame.end || [frame.x2, frame.y2];
|
|
1360
|
+
return isFinitePoint(...start, ...end) ? [{ side: 'line', start, end }] : [];
|
|
1361
|
+
}
|
|
1362
|
+
if (!isFinitePoint(frame.x, frame.y, frame.width, frame.height) || frame.width <= 0 || frame.height <= 0) return [];
|
|
1363
|
+
const radius = Math.max(0, Math.min(Number(frame.radius) || 0, frame.width / 2, frame.height / 2));
|
|
1364
|
+
const left = frame.x;
|
|
1365
|
+
const right = frame.x + frame.width;
|
|
1366
|
+
const top = frame.y;
|
|
1367
|
+
const bottom = frame.y + frame.height;
|
|
1368
|
+
return [
|
|
1369
|
+
{ side: 'top', start: [left + radius, top], end: [right - radius, top] },
|
|
1370
|
+
{ side: 'right', start: [right, top + radius], end: [right, bottom - radius] },
|
|
1371
|
+
{ side: 'bottom', start: [right - radius, bottom], end: [left + radius, bottom] },
|
|
1372
|
+
{ side: 'left', start: [left, bottom - radius], end: [left, top + radius] },
|
|
1373
|
+
].filter(({ start, end }) => Math.hypot(end[0] - start[0], end[1] - start[1]) > 0.0001);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function mergeBorderOverlaps(overlaps, border) {
|
|
1377
|
+
const horizontal = Math.abs(border.start[1] - border.end[1]) <= 0.0001;
|
|
1378
|
+
const axis = horizontal ? 0 : 1;
|
|
1379
|
+
const fixed = horizontal ? border.start[1] : border.start[0];
|
|
1380
|
+
const sorted = overlaps.map((overlap) => ({
|
|
1381
|
+
low: Math.min(overlap.start[axis], overlap.end[axis]),
|
|
1382
|
+
high: Math.max(overlap.start[axis], overlap.end[axis]),
|
|
1383
|
+
})).sort((left, right) => left.low - right.low || left.high - right.high);
|
|
1384
|
+
const merged = [];
|
|
1385
|
+
for (const interval of sorted) {
|
|
1386
|
+
const previous = merged.at(-1);
|
|
1387
|
+
if (previous && interval.low <= previous.high + 0.0001) previous.high = Math.max(previous.high, interval.high);
|
|
1388
|
+
else merged.push({ ...interval });
|
|
1389
|
+
}
|
|
1390
|
+
return merged.map((interval) => ({
|
|
1391
|
+
...interval,
|
|
1392
|
+
length: interval.high - interval.low,
|
|
1393
|
+
start: horizontal ? [interval.low, fixed] : [fixed, interval.low],
|
|
1394
|
+
end: horizontal ? [interval.high, fixed] : [fixed, interval.high],
|
|
1395
|
+
}));
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function collinearAxisOverlap(a, b, c, d) {
|
|
1399
|
+
const epsilon = 0.0001;
|
|
1400
|
+
const horizontal = Math.abs(a[1] - b[1]) <= epsilon
|
|
1401
|
+
&& Math.abs(c[1] - d[1]) <= epsilon
|
|
1402
|
+
&& Math.abs(a[1] - c[1]) <= epsilon;
|
|
1403
|
+
const vertical = Math.abs(a[0] - b[0]) <= epsilon
|
|
1404
|
+
&& Math.abs(c[0] - d[0]) <= epsilon
|
|
1405
|
+
&& Math.abs(a[0] - c[0]) <= epsilon;
|
|
1406
|
+
if (!horizontal && !vertical) return null;
|
|
1407
|
+
const axis = horizontal ? 0 : 1;
|
|
1408
|
+
const low = Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis]));
|
|
1409
|
+
const high = Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis]));
|
|
1410
|
+
if (high - low <= epsilon) return null;
|
|
1411
|
+
const fixed = horizontal ? a[1] : a[0];
|
|
1412
|
+
return {
|
|
1413
|
+
length: high - low,
|
|
1414
|
+
start: horizontal ? [low, fixed] : [fixed, low],
|
|
1415
|
+
end: horizontal ? [high, fixed] : [fixed, high],
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
export function properSegmentIntersection(a, b, c, d) {
|
|
1420
|
+
const abC = crossProduct(a, b, c);
|
|
1421
|
+
const abD = crossProduct(a, b, d);
|
|
1422
|
+
const cdA = crossProduct(c, d, a);
|
|
1423
|
+
const cdB = crossProduct(c, d, b);
|
|
1424
|
+
const epsilon = 0.0001;
|
|
1425
|
+
const opposite = (left, right) => (left > epsilon && right < -epsilon) || (left < -epsilon && right > epsilon);
|
|
1426
|
+
if (!opposite(abC, abD) || !opposite(cdA, cdB)) return null;
|
|
1427
|
+
|
|
1428
|
+
const denominator = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
|
|
1429
|
+
if (Math.abs(denominator) < epsilon) return null;
|
|
1430
|
+
const ab = a[0] * b[1] - a[1] * b[0];
|
|
1431
|
+
const cd = c[0] * d[1] - c[1] * d[0];
|
|
1432
|
+
return [
|
|
1433
|
+
(ab * (c[0] - d[0]) - (a[0] - b[0]) * cd) / denominator,
|
|
1434
|
+
(ab * (c[1] - d[1]) - (a[1] - b[1]) * cd) / denominator
|
|
1435
|
+
];
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
function crossProduct(a, b, c) {
|
|
1439
|
+
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function pointInBox(point, box) {
|
|
1443
|
+
return point[0] >= box.x1 && point[0] <= box.x2 && point[1] >= box.y1 && point[1] <= box.y2;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function segmentsIntersect(a, b, c, d) {
|
|
1447
|
+
const o1 = orientation(a, b, c);
|
|
1448
|
+
const o2 = orientation(a, b, d);
|
|
1449
|
+
const o3 = orientation(c, d, a);
|
|
1450
|
+
const o4 = orientation(c, d, b);
|
|
1451
|
+
|
|
1452
|
+
if (o1 === 0 && onSegment(a, c, b)) return true;
|
|
1453
|
+
if (o2 === 0 && onSegment(a, d, b)) return true;
|
|
1454
|
+
if (o3 === 0 && onSegment(c, a, d)) return true;
|
|
1455
|
+
if (o4 === 0 && onSegment(c, b, d)) return true;
|
|
1456
|
+
|
|
1457
|
+
return o1 !== o2 && o3 !== o4;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function orientation(a, b, c) {
|
|
1461
|
+
const value = (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]);
|
|
1462
|
+
if (Math.abs(value) < 0.0001) return 0;
|
|
1463
|
+
return value > 0 ? 1 : 2;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function onSegment(a, b, c) {
|
|
1467
|
+
return (
|
|
1468
|
+
b[0] <= Math.max(a[0], c[0]) &&
|
|
1469
|
+
b[0] >= Math.min(a[0], c[0]) &&
|
|
1470
|
+
b[1] <= Math.max(a[1], c[1]) &&
|
|
1471
|
+
b[1] >= Math.min(a[1], c[1])
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
export function anchor(rect, side) {
|
|
1476
|
+
switch (side) {
|
|
1477
|
+
case 'left': return [rect.x, rect.cy];
|
|
1478
|
+
case 'right': return [rect.x + rect.width, rect.cy];
|
|
1479
|
+
case 'top': return [rect.cx, rect.y];
|
|
1480
|
+
case 'bottom': return [rect.cx, rect.y + rect.height];
|
|
1481
|
+
default:
|
|
1482
|
+
return [rect.x + rect.width, rect.cy];
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const PORT_OUTWARD_VECTOR = {
|
|
1487
|
+
left: [-1, 0],
|
|
1488
|
+
right: [1, 0],
|
|
1489
|
+
top: [0, -1],
|
|
1490
|
+
bottom: [0, 1],
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
// Automatic port spreading can put otherwise parallel anchors only a few
|
|
1494
|
+
// pixels apart. A conventional midpoint dogleg then violates the renderer's
|
|
1495
|
+
// own 8px/16px route-rhythm floors. Return a full outside-channel route when
|
|
1496
|
+
// that happens, or null when the normal automatic route remains appropriate.
|
|
1497
|
+
export function automaticPortRhythmBridge(
|
|
1498
|
+
start,
|
|
1499
|
+
end,
|
|
1500
|
+
fromSide,
|
|
1501
|
+
toSide,
|
|
1502
|
+
{ endpointStubPx = 24, interiorSegmentPx = 16, accept } = {},
|
|
1503
|
+
) {
|
|
1504
|
+
if (!Array.isArray(start) || !Array.isArray(end)
|
|
1505
|
+
|| start.length !== 2 || end.length !== 2
|
|
1506
|
+
|| !isFinitePoint(...start, ...end)) return null;
|
|
1507
|
+
const fromVector = PORT_OUTWARD_VECTOR[fromSide];
|
|
1508
|
+
const toVector = PORT_OUTWARD_VECTOR[toSide];
|
|
1509
|
+
if (!fromVector || !toVector) return null;
|
|
1510
|
+
|
|
1511
|
+
const opposedFacingGap = (
|
|
1512
|
+
(fromSide === 'right' && toSide === 'left' && end[0] > start[0])
|
|
1513
|
+
|| (fromSide === 'left' && toSide === 'right' && start[0] > end[0])
|
|
1514
|
+
) ? Math.abs(end[0] - start[0]) : (
|
|
1515
|
+
(fromSide === 'bottom' && toSide === 'top' && end[1] > start[1])
|
|
1516
|
+
|| (fromSide === 'top' && toSide === 'bottom' && start[1] > end[1])
|
|
1517
|
+
) ? Math.abs(end[1] - start[1]) : null;
|
|
1518
|
+
const boundedStubPx = opposedFacingGap != null
|
|
1519
|
+
&& opposedFacingGap - endpointStubPx * 2 < interiorSegmentPx
|
|
1520
|
+
? Math.max(8, Math.min(endpointStubPx, (opposedFacingGap - interiorSegmentPx) / 2))
|
|
1521
|
+
: null;
|
|
1522
|
+
const stubDistances = [endpointStubPx];
|
|
1523
|
+
if (boundedStubPx != null && boundedStubPx >= 8 && boundedStubPx !== endpointStubPx) {
|
|
1524
|
+
stubDistances.push(boundedStubPx);
|
|
1525
|
+
}
|
|
1526
|
+
const candidates = [];
|
|
1527
|
+
const verticalSides = new Set(['top', 'bottom']);
|
|
1528
|
+
const horizontalSides = new Set(['left', 'right']);
|
|
1529
|
+
|
|
1530
|
+
for (const stubPx of stubDistances) {
|
|
1531
|
+
const startStub = [
|
|
1532
|
+
start[0] + fromVector[0] * stubPx,
|
|
1533
|
+
start[1] + fromVector[1] * stubPx,
|
|
1534
|
+
];
|
|
1535
|
+
const endStub = [
|
|
1536
|
+
end[0] + toVector[0] * stubPx,
|
|
1537
|
+
end[1] + toVector[1] * stubPx,
|
|
1538
|
+
];
|
|
1539
|
+
if (verticalSides.has(fromSide) && verticalSides.has(toSide)
|
|
1540
|
+
&& Math.abs(start[0] - end[0]) < interiorSegmentPx) {
|
|
1541
|
+
for (const channelX of [
|
|
1542
|
+
Math.max(start[0], end[0]) + interiorSegmentPx,
|
|
1543
|
+
Math.min(start[0], end[0]) - interiorSegmentPx,
|
|
1544
|
+
]) {
|
|
1545
|
+
candidates.push([
|
|
1546
|
+
start,
|
|
1547
|
+
startStub,
|
|
1548
|
+
[channelX, startStub[1]],
|
|
1549
|
+
[channelX, endStub[1]],
|
|
1550
|
+
endStub,
|
|
1551
|
+
end,
|
|
1552
|
+
]);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
|
|
1556
|
+
&& Math.abs(start[1] - end[1]) < interiorSegmentPx) {
|
|
1557
|
+
for (const channelY of [
|
|
1558
|
+
Math.max(start[1], end[1]) + interiorSegmentPx,
|
|
1559
|
+
Math.min(start[1], end[1]) - interiorSegmentPx,
|
|
1560
|
+
]) {
|
|
1561
|
+
candidates.push([
|
|
1562
|
+
start,
|
|
1563
|
+
startStub,
|
|
1564
|
+
[startStub[0], channelY],
|
|
1565
|
+
[endStub[0], channelY],
|
|
1566
|
+
endStub,
|
|
1567
|
+
end,
|
|
1568
|
+
]);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
return candidates
|
|
1574
|
+
.map((points) => normalizeRoutePoints(points))
|
|
1575
|
+
.find((points) => (
|
|
1576
|
+
routeHonorsEndpointSides(points, fromSide, toSide)
|
|
1577
|
+
&& collectRouteRhythmIssues({ routedRelations: [{ points }], interiorSegmentPx }).length === 0
|
|
1578
|
+
&& (typeof accept !== 'function' || accept(points))
|
|
1579
|
+
)) || null;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// Keep conservative auto-routed fan-out/fan-in relationships visually
|
|
1583
|
+
// distinct without changing authored route controls. The returned map only
|
|
1584
|
+
// contains endpoints that belong to a shared automatic midpoint anchor.
|
|
1585
|
+
export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing = 14, sideFor, spacingFor } = {}) {
|
|
1586
|
+
const groups = new Map();
|
|
1587
|
+
const spread = new Map();
|
|
1588
|
+
|
|
1589
|
+
const add = (relation, endpoint, rect, side, counterpart) => {
|
|
1590
|
+
const key = `${rect.id}\u0000${side}`;
|
|
1591
|
+
const items = groups.get(key) || [];
|
|
1592
|
+
items.push({ relation, endpoint, rect, side, counterpart });
|
|
1593
|
+
groups.set(key, items);
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
for (const relation of asArray(relations)) {
|
|
1597
|
+
if (!relation || (relation.route && relation.route !== 'auto')) continue;
|
|
1598
|
+
if (relation.via || relation.channelX !== undefined || relation.channelY !== undefined || relation.labelAt) continue;
|
|
1599
|
+
const from = boxes.get(relation.from);
|
|
1600
|
+
const to = boxes.get(relation.to);
|
|
1601
|
+
if (!from || !to) continue;
|
|
1602
|
+
const fromSide = chosenSide(
|
|
1603
|
+
relation.fromSide,
|
|
1604
|
+
sideFor?.(relation, 'source') || defaultFromSide(from, to),
|
|
1605
|
+
);
|
|
1606
|
+
const toSide = chosenSide(
|
|
1607
|
+
relation.toSide,
|
|
1608
|
+
sideFor?.(relation, 'target') || defaultToSide(from, to),
|
|
1609
|
+
);
|
|
1610
|
+
add(relation, 'from', from, fromSide, to);
|
|
1611
|
+
add(relation, 'to', to, toSide, from);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
for (const items of groups.values()) {
|
|
1615
|
+
if (items.length < 2) continue;
|
|
1616
|
+
const verticalSide = items[0].side === 'left' || items[0].side === 'right';
|
|
1617
|
+
items.sort((a, b) => {
|
|
1618
|
+
const aCoordinate = verticalSide ? a.counterpart.cy : a.counterpart.cx;
|
|
1619
|
+
const bCoordinate = verticalSide ? b.counterpart.cy : b.counterpart.cx;
|
|
1620
|
+
if (aCoordinate !== bCoordinate) return aCoordinate - bCoordinate;
|
|
1621
|
+
const aKey = `${a.relation.id || ''}\u0000${a.relation.from}\u0000${a.relation.to}\u0000${a.relation.label || ''}`;
|
|
1622
|
+
const bKey = `${b.relation.id || ''}\u0000${b.relation.from}\u0000${b.relation.to}\u0000${b.relation.label || ''}`;
|
|
1623
|
+
return aKey < bKey ? -1 : aKey > bKey ? 1 : 0;
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
const extent = verticalSide ? items[0].rect.height : items[0].rect.width;
|
|
1627
|
+
const usable = Math.max(0, extent - gutter * 2);
|
|
1628
|
+
const spacing = Math.min(maxSpacing, usable / (items.length - 1));
|
|
1629
|
+
if (!(spacing > 0)) continue;
|
|
1630
|
+
|
|
1631
|
+
// Width-aware callers reserve the whole group together. Moving a single
|
|
1632
|
+
// port around the legacy 14px slots can wrongly report a full side while
|
|
1633
|
+
// its still-unrouted neighbours could have fitted farther apart.
|
|
1634
|
+
let offsets;
|
|
1635
|
+
if (spacingFor) {
|
|
1636
|
+
const gaps = items.slice(1).map((item, index) => Math.max(maxSpacing,
|
|
1637
|
+
spacingFor(items[index].relation, item.relation)));
|
|
1638
|
+
const span = gaps.reduce((sum, gap) => sum + gap, 0);
|
|
1639
|
+
if (span <= usable && gaps.some((gap) => gap > maxSpacing)) {
|
|
1640
|
+
let offset = -span / 2;
|
|
1641
|
+
offsets = [offset, ...gaps.map((gap) => (offset += gap))];
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
for (const [index, item] of items.entries()) {
|
|
1646
|
+
const offset = offsets?.[index] ?? (index - (items.length - 1) / 2) * spacing;
|
|
1647
|
+
const point = anchor(item.rect, item.side);
|
|
1648
|
+
if (verticalSide) point[1] += offset;
|
|
1649
|
+
else point[0] += offset;
|
|
1650
|
+
const endpoints = spread.get(item.relation) || {};
|
|
1651
|
+
endpoints[item.endpoint] = point;
|
|
1652
|
+
spread.set(item.relation, endpoints);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
return spread;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// A centre delta on the horizontal axis does not by itself mean the route
|
|
1660
|
+
// leaves sideways: a hub above an offset spoke has both a horizontal and a
|
|
1661
|
+
// larger vertical delta, and the router draws the vertical dogleg. Compare the
|
|
1662
|
+
// deltas so the inferred side matches the axis the route actually uses. Equal
|
|
1663
|
+
// deltas select horizontal sides; dx === 0 preserves the previous vertical
|
|
1664
|
+
// result, while a vertical-dominant nonzero dx selects vertical sides.
|
|
1665
|
+
export function defaultFromSide(from, to) {
|
|
1666
|
+
const dx = to.cx - from.cx;
|
|
1667
|
+
const dy = to.cy - from.cy;
|
|
1668
|
+
if (dx !== 0 && Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? 'left' : 'right';
|
|
1669
|
+
return dy > 0 ? 'bottom' : 'top';
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
export function defaultToSide(from, to) {
|
|
1673
|
+
const dx = to.cx - from.cx;
|
|
1674
|
+
const dy = to.cy - from.cy;
|
|
1675
|
+
if (dx !== 0 && Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? 'right' : 'left';
|
|
1676
|
+
return dy > 0 ? 'top' : 'bottom';
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// Non-architecture renderers retain their established horizontal-first
|
|
1680
|
+
// endpoint contract. Architecture opts into dominant-axis inference above so
|
|
1681
|
+
// a vertical hub-and-spoke route can use perpendicular automatic ports.
|
|
1682
|
+
export function legacyDefaultFromSide(from, to) {
|
|
1683
|
+
if (to.cx < from.cx) return 'left';
|
|
1684
|
+
if (to.cx > from.cx) return 'right';
|
|
1685
|
+
if (to.cy > from.cy) return 'bottom';
|
|
1686
|
+
return 'top';
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
export function legacyDefaultToSide(from, to) {
|
|
1690
|
+
if (to.cx < from.cx) return 'right';
|
|
1691
|
+
if (to.cx > from.cx) return 'left';
|
|
1692
|
+
if (to.cy > from.cy) return 'top';
|
|
1693
|
+
return 'bottom';
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
export function chosenSide(side, fallback) {
|
|
1697
|
+
return side && side !== 'auto' ? side : fallback;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
export function polylinePath(points) {
|
|
1701
|
+
return points.map(([x, y], index) => `${index === 0 ? 'M' : 'L'} ${x} ${y}`).join(' ');
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
export function routePointsValue(points) {
|
|
1705
|
+
return asArray(points)
|
|
1706
|
+
.filter((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))
|
|
1707
|
+
.map(([x, y]) => `${x},${y}`)
|
|
1708
|
+
.join(';');
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// Only a direct, explicitly authored diagonal needs an artifact-check exception.
|
|
1712
|
+
// Nonempty via takes precedence; empty via adds no intermediate geometry.
|
|
1713
|
+
export function authoredStraightRouteAttrs(relation, points) {
|
|
1714
|
+
if (relation.route !== 'straight' || relation.via?.length || points.length !== 2) return '';
|
|
1715
|
+
const [start, end] = points;
|
|
1716
|
+
return Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01
|
|
1717
|
+
? ' data-composition-route="straight"'
|
|
1718
|
+
: '';
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
export function roundedPath(points, radius) {
|
|
1722
|
+
if (points.length < 3 || radius <= 0) {
|
|
1723
|
+
return polylinePath(points);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
const commands = [`M ${points[0][0]} ${points[0][1]}`];
|
|
1727
|
+
for (let i = 1; i < points.length - 1; i += 1) {
|
|
1728
|
+
const [px, py] = points[i - 1];
|
|
1729
|
+
const [cx, cy] = points[i];
|
|
1730
|
+
const [nx, ny] = points[i + 1];
|
|
1731
|
+
const prevLen = Math.hypot(cx - px, cy - py);
|
|
1732
|
+
const nextLen = Math.hypot(nx - cx, ny - cy);
|
|
1733
|
+
const r = Math.min(radius, prevLen / 2, nextLen / 2);
|
|
1734
|
+
if (r < 1) {
|
|
1735
|
+
commands.push(`L ${cx} ${cy}`);
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
const before = [cx - ((cx - px) / prevLen) * r, cy - ((cy - py) / prevLen) * r];
|
|
1739
|
+
const after = [cx + ((nx - cx) / nextLen) * r, cy + ((ny - cy) / nextLen) * r];
|
|
1740
|
+
commands.push(`L ${before[0]} ${before[1]}`);
|
|
1741
|
+
commands.push(`Q ${cx} ${cy} ${after[0]} ${after[1]}`);
|
|
1742
|
+
}
|
|
1743
|
+
const [endX, endY] = points[points.length - 1];
|
|
1744
|
+
commands.push(`L ${endX} ${endY}`);
|
|
1745
|
+
return commands.join(' ');
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// Shared by edges/flows/transitions: all carry the same optional
|
|
1749
|
+
// labelAt/labelDx/labelDy/labelSegment knobs.
|
|
1750
|
+
export function labelPoint(item, points) {
|
|
1751
|
+
if (item.labelAt) return item.labelAt;
|
|
1752
|
+
if (points.length === 2) {
|
|
1753
|
+
return [
|
|
1754
|
+
(points[0][0] + points[1][0]) / 2 + (item.labelDx || 0),
|
|
1755
|
+
points[0][1] - 10 + (item.labelDy || 0)
|
|
1756
|
+
];
|
|
1757
|
+
}
|
|
1758
|
+
const segmentIndex = Math.min(points.length - 2, Math.max(0, item.labelSegment ?? 1));
|
|
1759
|
+
const a = points[segmentIndex];
|
|
1760
|
+
const b = points[segmentIndex + 1];
|
|
1761
|
+
return [(a[0] + b[0]) / 2 + (item.labelDx || 0), (a[1] + b[1]) / 2 - 10 + (item.labelDy || 0)];
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
export const componentFill = {
|
|
1765
|
+
frontend: 'c-frontend',
|
|
1766
|
+
backend: 'c-backend',
|
|
1767
|
+
database: 'c-database',
|
|
1768
|
+
cloud: 'c-cloud',
|
|
1769
|
+
security: 'c-security',
|
|
1770
|
+
messagebus: 'c-messagebus',
|
|
1771
|
+
external: 'c-external'
|
|
1772
|
+
};
|
|
1773
|
+
|
|
1774
|
+
export const componentText = {
|
|
1775
|
+
frontend: 't-frontend',
|
|
1776
|
+
backend: 't-backend',
|
|
1777
|
+
database: 't-database',
|
|
1778
|
+
cloud: 't-cloud',
|
|
1779
|
+
security: 't-security',
|
|
1780
|
+
messagebus: 't-messagebus',
|
|
1781
|
+
external: 't-external'
|
|
1782
|
+
};
|
|
1783
|
+
|
|
1784
|
+
export const arrowClassMap = {
|
|
1785
|
+
default: ['a-default', 'arrowhead'],
|
|
1786
|
+
emphasis: ['a-emphasis', 'arrowhead-emphasis'],
|
|
1787
|
+
security: ['a-security', 'arrowhead-security'],
|
|
1788
|
+
dashed: ['a-dashed', 'arrowhead-dashed']
|
|
1789
|
+
};
|
|
1790
|
+
|
|
1791
|
+
// Structural phase/group accents retain their existing semantic colors.
|
|
1792
|
+
export function variantAccent(variant) {
|
|
1793
|
+
return variant === 'security' ? 't-security'
|
|
1794
|
+
: variant === 'emphasis' ? 't-backend'
|
|
1795
|
+
: variant === 'dashed' ? 't-messagebus' : 't-muted';
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// Relationship labels use the same theme token as their path. Keep this map
|
|
1799
|
+
// edge-specific: node-kind text colors only coincide with some path colors in
|
|
1800
|
+
// the classic preset and must not define the relationship's visual meaning.
|
|
1801
|
+
export function edgeLabelAccent(variant) {
|
|
1802
|
+
return variant === 'security'
|
|
1803
|
+
? 't-edge-security'
|
|
1804
|
+
: variant === 'emphasis'
|
|
1805
|
+
? 't-edge-emphasis'
|
|
1806
|
+
: variant === 'dashed'
|
|
1807
|
+
? 't-edge-dashed'
|
|
1808
|
+
: 't-edge-default';
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
export function formatRect(r) {
|
|
1812
|
+
return `[${Math.round(r.x)}, ${Math.round(r.y)}, ${Math.round(r.width)}, ${Math.round(r.height)}]`;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
function formatValue(n) {
|
|
1816
|
+
return String(Math.round(n));
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// A label anchor is the text origin, so keeping a suggestion on the canvas
|
|
1820
|
+
// means moving the whole rect: offset is `rect - anchor` on that axis.
|
|
1821
|
+
function anchorFitsCanvas(anchorValue, offset, size, extent) {
|
|
1822
|
+
const overflow = axisCanvasOverflow(anchorValue + offset, size, extent);
|
|
1823
|
+
return overflow.start <= 0 && overflow.end <= 0;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// Suggestions are emitted as integers, so the real-valued bounds are tightened
|
|
1827
|
+
// inward with ceil/floor first: every integer between them keeps the whole rect
|
|
1828
|
+
// on the canvas, which a bound like 616.7 would not. Returns null when the rect
|
|
1829
|
+
// is wider than the canvas and no anchor can contain it.
|
|
1830
|
+
function clampAnchorToCanvas(anchorValue, offset, size, extent) {
|
|
1831
|
+
const rounded = Math.round(anchorValue);
|
|
1832
|
+
if (!Number.isFinite(extent)) return rounded;
|
|
1833
|
+
const min = Math.ceil(-offset);
|
|
1834
|
+
const max = Math.floor(extent - size - offset);
|
|
1835
|
+
return max < min ? null : Math.min(Math.max(rounded, min), max);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
/**
|
|
1839
|
+
* Actionable hint when an edge label rect hits a node/component box (#7).
|
|
1840
|
+
* Every suggested value is a replacement for the authored field, not an
|
|
1841
|
+
* increment, and has to survive being applied to the document:
|
|
1842
|
+
* - the absolute form is nudged along x so the rect stays on the canvas, and a
|
|
1843
|
+
* vertical placement that cannot fit is dropped rather than clamped, since
|
|
1844
|
+
* clamping it back would push the label onto the obstacle it must clear;
|
|
1845
|
+
* - the relative form is offered only when labelDx/labelDy actually move this
|
|
1846
|
+
* label (labelPoint returns an authored labelAt as-is) and only when the
|
|
1847
|
+
* replacements, computed from the document's own labelDx/labelDy, land the
|
|
1848
|
+
* rect inside the canvas.
|
|
1849
|
+
* The authored values therefore come from `labelRect.relation`, the authored
|
|
1850
|
+
* relationship the renderer already attaches to every label record.
|
|
1851
|
+
* `obstacles` is every box the callers' own overlap loop tests (defaults to
|
|
1852
|
+
* the named obstacle alone): a placement that merely traded the named obstacle
|
|
1853
|
+
* for its neighbor would fail the same rule again when applied.
|
|
1854
|
+
*/
|
|
1855
|
+
export function suggestLabelObstacleFix(labelRect, lx, ly, obstacle, obstacleKind = 'component', viewBox, obstacles) {
|
|
1856
|
+
// Renderer canvases are origin-zero [width, height] by schema, but read a
|
|
1857
|
+
// 4-number [min-x, min-y, width, height] the same way the containment
|
|
1858
|
+
// collector does — degraded no-ajv runs must not clamp against the origin
|
|
1859
|
+
// pair as if it were the canvas size.
|
|
1860
|
+
const box = asArray(viewBox);
|
|
1861
|
+
const [canvasWidth, canvasHeight] = box.length === 4 ? box.slice(2) : box;
|
|
1862
|
+
const xOffset = labelRect.x - lx;
|
|
1863
|
+
const yOffset = labelRect.y - ly;
|
|
1864
|
+
const anchorX = clampAnchorToCanvas(lx, xOffset, labelRect.width, canvasWidth);
|
|
1865
|
+
// The placement anchors are derived from the label's own rect, not from the
|
|
1866
|
+
// obstacle alone: an obstacle-only "above" anchor assumes the 14px
|
|
1867
|
+
// single-line rect and lands a 27px two-line rect (dataflow classification,
|
|
1868
|
+
// lifecycle note) back on the obstacle it must clear. The overlap filter
|
|
1869
|
+
// uses the callers' own detection call, so a surviving hint cannot re-raise
|
|
1870
|
+
// the problem it repairs.
|
|
1871
|
+
const blockers = obstacles ? [...obstacles] : [obstacle];
|
|
1872
|
+
const placements = [
|
|
1873
|
+
{ name: 'below', y: Math.round(obstacle.y + obstacle.height + 4 - yOffset) },
|
|
1874
|
+
{ name: 'above', y: Math.round(obstacle.y - 4 - labelRect.height - yOffset) },
|
|
1875
|
+
].filter(({ y }) => anchorX !== null
|
|
1876
|
+
&& anchorFitsCanvas(y, yOffset, labelRect.height, canvasHeight)
|
|
1877
|
+
&& blockers.every((blocker) => !rectsOverlap(
|
|
1878
|
+
{ x: anchorX + xOffset, y: y + yOffset, width: labelRect.width, height: labelRect.height },
|
|
1879
|
+
blocker,
|
|
1880
|
+
-2,
|
|
1881
|
+
)));
|
|
1882
|
+
const lines = [
|
|
1883
|
+
` label rect: ${formatRect(labelRect)}`,
|
|
1884
|
+
` ${obstacleKind} "${obstacle.id}" rect: ${formatRect(obstacle)}`,
|
|
1885
|
+
];
|
|
1886
|
+
if (!placements.length) {
|
|
1887
|
+
// Only the vertical slots beside the named obstacle were tried, so an
|
|
1888
|
+
// unclaimed spot elsewhere may still exist — never assert that none does.
|
|
1889
|
+
lines.push(anchorX === null
|
|
1890
|
+
? ` Suggested fix: the ${Math.round(labelRect.width)}px label rect cannot fit the ${canvasWidth}x${canvasHeight} viewBox at any anchor — shorten the label or enlarge meta.viewBox`
|
|
1891
|
+
: ` Suggested fix: no placement directly above or below "${obstacle.id}" stays clear inside the ${canvasWidth}x${canvasHeight} viewBox — move the label to an open area with labelAt, shorten the label, move the ${obstacleKind}, or enlarge meta.viewBox`);
|
|
1892
|
+
return lines.join('\n');
|
|
1893
|
+
}
|
|
1894
|
+
const authored = labelRect.relation || {};
|
|
1895
|
+
const authoredDx = Number.isFinite(authored.labelDx) ? authored.labelDx : 0;
|
|
1896
|
+
const authoredDy = Number.isFinite(authored.labelDy) ? authored.labelDy : 0;
|
|
1897
|
+
const relativeHint = (y) => {
|
|
1898
|
+
if (Array.isArray(authored.labelAt)) return null;
|
|
1899
|
+
// Both replacements are measured from the automatic label point that the
|
|
1900
|
+
// authored offsets are applied to, and against the unrounded anchor: a
|
|
1901
|
+
// rounded base would shift the applied label by up to a pixel.
|
|
1902
|
+
const dx = Math.round(authoredDx + anchorX - lx);
|
|
1903
|
+
const dy = Math.round(authoredDy + y - ly);
|
|
1904
|
+
// labelDx is authored as a number, so an authored fraction must not read as
|
|
1905
|
+
// a needed nudge once the replacement is rounded to an integer.
|
|
1906
|
+
const movesX = Math.abs(dx - authoredDx) >= 0.5;
|
|
1907
|
+
const landsAtX = lx - authoredDx + (movesX ? dx : authoredDx);
|
|
1908
|
+
const landsAtY = ly - authoredDy + dy;
|
|
1909
|
+
if (!anchorFitsCanvas(landsAtX, xOffset, labelRect.width, canvasWidth)) return null;
|
|
1910
|
+
if (!anchorFitsCanvas(landsAtY, yOffset, labelRect.height, canvasHeight)) return null;
|
|
1911
|
+
return movesX
|
|
1912
|
+
? `set labelDx ${formatValue(dx)} with labelDy ${formatValue(dy)}`
|
|
1913
|
+
: `set labelDy ${formatValue(dy)}`;
|
|
1914
|
+
};
|
|
1915
|
+
const hint = placements
|
|
1916
|
+
.map(({ name, y }) => {
|
|
1917
|
+
const relative = relativeHint(y);
|
|
1918
|
+
return `set labelAt [${anchorX}, ${y}]${relative ? ` or ${relative}` : ''} (${name})`;
|
|
1919
|
+
})
|
|
1920
|
+
.join('; or ');
|
|
1921
|
+
lines.push(` Suggested fix: ${hint}`);
|
|
1922
|
+
return lines.join('\n');
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/** Hint when two edge labels collide. */
|
|
1926
|
+
export function suggestLabelPairFix(a, b) {
|
|
1927
|
+
return [
|
|
1928
|
+
` "${a.label}" ${formatRect(a)}; "${b.label}" ${formatRect(b)}`,
|
|
1929
|
+
' Suggested fix: adjust labelDx/labelDy/labelSegment, or route one relationship through a separate corridor',
|
|
1930
|
+
].join('\n');
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/** Hint when two components/nodes are too close. */
|
|
1934
|
+
export function suggestComponentSeparation(a, b, minGap = 8) {
|
|
1935
|
+
const rightX = Math.round(a.x + a.width + minGap);
|
|
1936
|
+
const belowY = Math.round(a.y + a.height + minGap);
|
|
1937
|
+
return [
|
|
1938
|
+
` "${a.id}" ${formatRect(a)}; "${b.id}" ${formatRect(b)}`,
|
|
1939
|
+
` Suggested fix: move "${b.id}" pos to [${rightX}, ${Math.round(b.y)}] (right of "${a.id}") or [${Math.round(b.x)}, ${belowY}] (below)`,
|
|
1940
|
+
].join('\n');
|
|
1941
|
+
}
|