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,1157 @@
|
|
|
1
|
+
// Internal architecture router shared by rendering and geometry inspection.
|
|
2
|
+
// Create a new router when measured boxes or connections change: port spreading
|
|
3
|
+
// is computed once and route results are cached for this scene.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
segmentIntersectsRect,
|
|
7
|
+
anchor,
|
|
8
|
+
automaticPortSpread,
|
|
9
|
+
automaticPortRhythmBridge,
|
|
10
|
+
defaultFromSide,
|
|
11
|
+
defaultToSide,
|
|
12
|
+
chosenSide,
|
|
13
|
+
properSegmentIntersection,
|
|
14
|
+
routeHonorsEndpointSides,
|
|
15
|
+
normalizeRoutePoints,
|
|
16
|
+
rectsOverlap,
|
|
17
|
+
roundedPath,
|
|
18
|
+
collectBorderRuns,
|
|
19
|
+
collectRouteRhythmIssues,
|
|
20
|
+
frameBorderSegments,
|
|
21
|
+
} from '../shared/geometry.mjs';
|
|
22
|
+
import { shortestOrthogonalGridRoute } from '../shared/route-quality.mjs';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Router bound to one set of measured component boxes.
|
|
26
|
+
*
|
|
27
|
+
* @param {Map<string, {x,y,width,height,cx,cy}>} components measured boxes by id
|
|
28
|
+
* @param {Array<object>} connections the connection list to spread ports across
|
|
29
|
+
* @param {object} [options]
|
|
30
|
+
* @param {Array<{x,y,width,height,radius?}>} [options.frames] structural frames
|
|
31
|
+
* (boundaries) whose borders an automatic route may cross but never follow
|
|
32
|
+
* @param {number} [options.interiorSegmentPx] showcase floor for interior segments
|
|
33
|
+
* @param {number} [options.microSegmentPx] floor for any segment
|
|
34
|
+
* @param {(conn: object, points: number[][], context: {routes: number[][][], labels: object[]}) => object|null} [options.labelRectFor]
|
|
35
|
+
* default label rect of a routed relationship given the routes and label
|
|
36
|
+
* rects resolved so far; later automatic routes keep clear of it so a dense
|
|
37
|
+
* fan-out does not leave the label nowhere to go
|
|
38
|
+
*/
|
|
39
|
+
export function createRouter(components, connections = [], {
|
|
40
|
+
frames = [],
|
|
41
|
+
interiorSegmentPx = 16,
|
|
42
|
+
microSegmentPx = 8,
|
|
43
|
+
labelRectFor = null,
|
|
44
|
+
distinctAutomaticPorts = false,
|
|
45
|
+
preferReadableRoutes = false,
|
|
46
|
+
} = {}) {
|
|
47
|
+
const frameBorders = frames.flatMap((frame) => frameBorderSegments(frame));
|
|
48
|
+
const LABEL_CLEARANCE = 4;
|
|
49
|
+
// Labels of already-routed relationships, reserved while planning the rest.
|
|
50
|
+
let reservedLabels = [];
|
|
51
|
+
let honourReservedLabels = true;
|
|
52
|
+
let allowGridSearch = true;
|
|
53
|
+
|
|
54
|
+
function routeClearsReservedLabels(conn, points) {
|
|
55
|
+
if (!honourReservedLabels) return true;
|
|
56
|
+
for (const entry of reservedLabels) {
|
|
57
|
+
if (entry.conn === conn) continue;
|
|
58
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
59
|
+
if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, entry.rect, LABEL_CLEARANCE)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The grid search adds its own 2px component clearance; pre-expand so a
|
|
68
|
+
// reserved label keeps the same 4px clearance the placement pass demands.
|
|
69
|
+
function reservedLabelObstacles(gridClearance) {
|
|
70
|
+
if (!honourReservedLabels) return [];
|
|
71
|
+
const grow = LABEL_CLEARANCE - gridClearance;
|
|
72
|
+
return reservedLabels.map(({ rect }) => ({
|
|
73
|
+
x: rect.x - grow,
|
|
74
|
+
y: rect.y - grow,
|
|
75
|
+
width: rect.width + grow * 2,
|
|
76
|
+
height: rect.height + grow * 2,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Automatic routes are held to the same composition floors the showcase
|
|
81
|
+
// gate enforces afterwards. Accepting a route here that the gate rejects
|
|
82
|
+
// only hands the author a hand-routing repair the planner could have made.
|
|
83
|
+
function routeMeetsCompositionFloors(points) {
|
|
84
|
+
if (collectRouteRhythmIssues({ routedRelations: [{ points }], interiorSegmentPx, microSegmentPx }).length) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return !frames.length || collectBorderRuns({ routedRelations: [{ points }], frames }).length === 0;
|
|
88
|
+
}
|
|
89
|
+
const planningMetrics = {
|
|
90
|
+
routeCount: 0,
|
|
91
|
+
explicitRouteCount: 0,
|
|
92
|
+
automaticRouteCount: 0,
|
|
93
|
+
gridSearchCount: 0,
|
|
94
|
+
gridRoutedCount: 0,
|
|
95
|
+
gridCandidateNodeCount: 0,
|
|
96
|
+
gridUsableNodeCount: 0,
|
|
97
|
+
gridEdgeCount: 0,
|
|
98
|
+
gridVisitedNodeCount: 0,
|
|
99
|
+
avoidedSegmentCount: 0,
|
|
100
|
+
conflictFallbackCount: 0,
|
|
101
|
+
maximumGridSearchCount: 64,
|
|
102
|
+
gridBudgetExhaustedCount: 0,
|
|
103
|
+
crossoverRoutedCount: 0,
|
|
104
|
+
readabilityCandidateCount: 0,
|
|
105
|
+
readabilityImprovedCount: 0,
|
|
106
|
+
reciprocalCandidateCount: 0,
|
|
107
|
+
reciprocalImprovedCount: 0,
|
|
108
|
+
gridAttempts: [],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ---- Connection routing ------------------------------------------------------
|
|
112
|
+
function routeClearsComponents(conn, points, clearance = 2) {
|
|
113
|
+
const endpointIds = new Set([conn.from, conn.to]);
|
|
114
|
+
for (const component of components.values()) {
|
|
115
|
+
if (endpointIds.has(component.id)) continue;
|
|
116
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
117
|
+
if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, component, clearance)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return routeClearsReservedLabels(conn, points);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function routeClearsEndpointComponents(points, from, to) {
|
|
126
|
+
const lastSegment = points.length - 2;
|
|
127
|
+
for (let index = 0; index <= lastSegment; index += 1) {
|
|
128
|
+
const segment = { start: points[index], end: points[index + 1] };
|
|
129
|
+
if (index > 0 && segmentIntersectsRect(segment, from)) return false;
|
|
130
|
+
if (index < lastSegment && segmentIntersectsRect(segment, to)) return false;
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function relationshipsShareEndpoint(left, right) {
|
|
136
|
+
return left.from === right.from
|
|
137
|
+
|| left.from === right.to
|
|
138
|
+
|| left.to === right.from
|
|
139
|
+
|| left.to === right.to;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function collinearOverlapLength(leftStart, leftEnd, rightStart, rightEnd) {
|
|
143
|
+
const epsilon = 0.0001;
|
|
144
|
+
if (Math.abs(leftStart[0] - leftEnd[0]) <= epsilon
|
|
145
|
+
&& Math.abs(rightStart[0] - rightEnd[0]) <= epsilon
|
|
146
|
+
&& Math.abs(leftStart[0] - rightStart[0]) <= epsilon) {
|
|
147
|
+
return Math.max(0,
|
|
148
|
+
Math.min(Math.max(leftStart[1], leftEnd[1]), Math.max(rightStart[1], rightEnd[1]))
|
|
149
|
+
- Math.max(Math.min(leftStart[1], leftEnd[1]), Math.min(rightStart[1], rightEnd[1])));
|
|
150
|
+
}
|
|
151
|
+
if (Math.abs(leftStart[1] - leftEnd[1]) <= epsilon
|
|
152
|
+
&& Math.abs(rightStart[1] - rightEnd[1]) <= epsilon
|
|
153
|
+
&& Math.abs(leftStart[1] - rightStart[1]) <= epsilon) {
|
|
154
|
+
return Math.max(0,
|
|
155
|
+
Math.min(Math.max(leftStart[0], leftEnd[0]), Math.max(rightStart[0], rightEnd[0]))
|
|
156
|
+
- Math.max(Math.min(leftStart[0], leftEnd[0]), Math.min(rightStart[0], rightEnd[0])));
|
|
157
|
+
}
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function orthogonalTouchOnResolvedInterior(start, end, resolvedStart, resolvedEnd) {
|
|
162
|
+
const epsilon = 0.0001;
|
|
163
|
+
const candidateHorizontal = Math.abs(start[1] - end[1]) <= epsilon;
|
|
164
|
+
const candidateVertical = Math.abs(start[0] - end[0]) <= epsilon;
|
|
165
|
+
const resolvedHorizontal = Math.abs(resolvedStart[1] - resolvedEnd[1]) <= epsilon;
|
|
166
|
+
const resolvedVertical = Math.abs(resolvedStart[0] - resolvedEnd[0]) <= epsilon;
|
|
167
|
+
if (candidateHorizontal && resolvedVertical) {
|
|
168
|
+
const x = resolvedStart[0];
|
|
169
|
+
const y = start[1];
|
|
170
|
+
return x >= Math.min(start[0], end[0]) - epsilon
|
|
171
|
+
&& x <= Math.max(start[0], end[0]) + epsilon
|
|
172
|
+
&& y > Math.min(resolvedStart[1], resolvedEnd[1]) + epsilon
|
|
173
|
+
&& y < Math.max(resolvedStart[1], resolvedEnd[1]) - epsilon;
|
|
174
|
+
}
|
|
175
|
+
if (candidateVertical && resolvedHorizontal) {
|
|
176
|
+
const x = start[0];
|
|
177
|
+
const y = resolvedStart[1];
|
|
178
|
+
return y >= Math.min(start[1], end[1]) - epsilon
|
|
179
|
+
&& y <= Math.max(start[1], end[1]) + epsilon
|
|
180
|
+
&& x > Math.min(resolvedStart[0], resolvedEnd[0]) + epsilon
|
|
181
|
+
&& x < Math.max(resolvedStart[0], resolvedEnd[0]) - epsilon;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function unrelatedResolvedRoutes(conn, resolvedRoutes) {
|
|
187
|
+
return resolvedRoutes.filter((entry) => !relationshipsShareEndpoint(conn, entry.conn));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function routeConflictsWithResolved(conn, points, resolvedRoutes) {
|
|
191
|
+
const unrelated = unrelatedResolvedRoutes(conn, resolvedRoutes);
|
|
192
|
+
for (const entry of unrelated) {
|
|
193
|
+
for (let left = 0; left < points.length - 1; left += 1) {
|
|
194
|
+
for (let right = 0; right < entry.points.length - 1; right += 1) {
|
|
195
|
+
if (properSegmentIntersection(
|
|
196
|
+
points[left],
|
|
197
|
+
points[left + 1],
|
|
198
|
+
entry.points[right],
|
|
199
|
+
entry.points[right + 1],
|
|
200
|
+
)) return true;
|
|
201
|
+
if (orthogonalTouchOnResolvedInterior(
|
|
202
|
+
points[left],
|
|
203
|
+
points[left + 1],
|
|
204
|
+
entry.points[right],
|
|
205
|
+
entry.points[right + 1],
|
|
206
|
+
)) return true;
|
|
207
|
+
if (collinearOverlapLength(
|
|
208
|
+
points[left],
|
|
209
|
+
points[left + 1],
|
|
210
|
+
entry.points[right],
|
|
211
|
+
entry.points[right + 1],
|
|
212
|
+
) >= 8) return true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function routeOverlapsResolved(conn, points, resolvedRoutes) {
|
|
220
|
+
// A common destination does not make two independently labelled routes a
|
|
221
|
+
// bus. Keep their corridors distinct too; explicit routes bypass planning.
|
|
222
|
+
for (const entry of resolvedRoutes) {
|
|
223
|
+
if (relationshipsShareEndpoint(conn, entry.conn)
|
|
224
|
+
&& (!distinctAutomaticPorts || hasAuthoredRouteGeometry(entry.conn) || entry.conn.labelAt || conn.labelAt)) continue;
|
|
225
|
+
for (let left = 0; left < points.length - 1; left += 1) {
|
|
226
|
+
for (let right = 0; right < entry.points.length - 1; right += 1) {
|
|
227
|
+
if (collinearOverlapLength(
|
|
228
|
+
points[left],
|
|
229
|
+
points[left + 1],
|
|
230
|
+
entry.points[right],
|
|
231
|
+
entry.points[right + 1],
|
|
232
|
+
) >= 8) return true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const OUTWARD_SIDE_VECTOR = {
|
|
240
|
+
left: [-1, 0],
|
|
241
|
+
right: [1, 0],
|
|
242
|
+
top: [0, -1],
|
|
243
|
+
bottom: [0, 1],
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
function outwardStub(point, side, distance = 24) {
|
|
247
|
+
const [dx, dy] = OUTWARD_SIDE_VECTOR[side] || [0, 0];
|
|
248
|
+
return [point[0] + dx * distance, point[1] + dy * distance];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function collinearBacktrack(a, b, c) {
|
|
252
|
+
const first = [b[0] - a[0], b[1] - a[1]];
|
|
253
|
+
const second = [c[0] - b[0], c[1] - b[1]];
|
|
254
|
+
const cross = first[0] * second[1] - first[1] * second[0];
|
|
255
|
+
const dot = first[0] * second[0] + first[1] * second[1];
|
|
256
|
+
return Math.abs(cross) <= 0.0001 && dot < -0.0001;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function sideAwareBridgeCandidates(start, end, fromSide, toSide) {
|
|
260
|
+
const startStub = outwardStub(start, fromSide);
|
|
261
|
+
const endStub = outwardStub(end, toSide);
|
|
262
|
+
const rawCandidates = [];
|
|
263
|
+
const minimumBridge = 16;
|
|
264
|
+
const verticalSides = new Set(['top', 'bottom']);
|
|
265
|
+
const horizontalSides = new Set(['left', 'right']);
|
|
266
|
+
|
|
267
|
+
// Port spreading can leave parallel-side anchors only a few pixels apart.
|
|
268
|
+
// Route through a bounded outside channel so we keep both endpoint normals
|
|
269
|
+
// without introducing a tiny, noisy connector between the two stubs.
|
|
270
|
+
if (verticalSides.has(fromSide) && verticalSides.has(toSide)
|
|
271
|
+
&& Math.abs(start[0] - end[0]) < minimumBridge) {
|
|
272
|
+
for (const channelX of [
|
|
273
|
+
Math.max(start[0], end[0]) + minimumBridge,
|
|
274
|
+
Math.min(start[0], end[0]) - minimumBridge,
|
|
275
|
+
]) {
|
|
276
|
+
rawCandidates.push([
|
|
277
|
+
startStub,
|
|
278
|
+
[channelX, startStub[1]],
|
|
279
|
+
[channelX, endStub[1]],
|
|
280
|
+
endStub,
|
|
281
|
+
]);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
|
|
285
|
+
&& Math.abs(start[1] - end[1]) < minimumBridge) {
|
|
286
|
+
for (const channelY of [
|
|
287
|
+
Math.max(start[1], end[1]) + minimumBridge,
|
|
288
|
+
Math.min(start[1], end[1]) - minimumBridge,
|
|
289
|
+
]) {
|
|
290
|
+
rawCandidates.push([
|
|
291
|
+
startStub,
|
|
292
|
+
[startStub[0], channelY],
|
|
293
|
+
[endStub[0], channelY],
|
|
294
|
+
endStub,
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
rawCandidates.push(
|
|
300
|
+
[startStub, [endStub[0], startStub[1]], endStub],
|
|
301
|
+
[startStub, [startStub[0], endStub[1]], endStub],
|
|
302
|
+
);
|
|
303
|
+
return rawCandidates.map((candidate) => normalizeRoutePoints([start, ...candidate, end]))
|
|
304
|
+
.filter((points) => points.length >= 2)
|
|
305
|
+
.filter((points) => !collinearBacktrack(points[0], points[1], points[2] || points[1]))
|
|
306
|
+
.filter((points) => !collinearBacktrack(points.at(-3) || points.at(-2), points.at(-2), points.at(-1)))
|
|
307
|
+
.filter((points) => routeHonorsEndpointSides(points, fromSide, toSide))
|
|
308
|
+
.map((points) => points.slice(1, -1));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const AUTOMATIC_PORT_CORNER_GUTTER = 16;
|
|
312
|
+
const AUTOMATIC_PORT_ALIGNMENT_DELTA = 16;
|
|
313
|
+
|
|
314
|
+
function portHasCornerClearance(rect, side, point) {
|
|
315
|
+
if (side === 'left' || side === 'right') {
|
|
316
|
+
const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.height / 2);
|
|
317
|
+
return point[1] >= rect.y + inset && point[1] <= rect.y + rect.height - inset;
|
|
318
|
+
}
|
|
319
|
+
if (side === 'top' || side === 'bottom') {
|
|
320
|
+
const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.width / 2);
|
|
321
|
+
return point[0] >= rect.x + inset && point[0] <= rect.x + rect.width - inset;
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function alignFacingPorts(conn, from, to, start, end, fromSide, toSide, ports) {
|
|
327
|
+
const hasExplicitGeometry = (
|
|
328
|
+
conn.via
|
|
329
|
+
|| (conn.route && conn.route !== 'auto')
|
|
330
|
+
|| conn.channelX !== undefined
|
|
331
|
+
|| conn.channelY !== undefined
|
|
332
|
+
|| conn.labelAt
|
|
333
|
+
);
|
|
334
|
+
const horizontallyFacing = (
|
|
335
|
+
(fromSide === 'right' && toSide === 'left')
|
|
336
|
+
|| (fromSide === 'left' && toSide === 'right')
|
|
337
|
+
);
|
|
338
|
+
const verticallyFacing = (
|
|
339
|
+
(fromSide === 'bottom' && toSide === 'top')
|
|
340
|
+
|| (fromSide === 'top' && toSide === 'bottom')
|
|
341
|
+
);
|
|
342
|
+
if (hasExplicitGeometry || (!horizontallyFacing && !verticallyFacing)) return { start, end };
|
|
343
|
+
|
|
344
|
+
const fromSpread = Boolean(ports?.from);
|
|
345
|
+
const toSpread = Boolean(ports?.to);
|
|
346
|
+
if (fromSpread && toSpread) return { start, end };
|
|
347
|
+
const hasExplicitSides = (
|
|
348
|
+
(conn.fromSide && conn.fromSide !== 'auto')
|
|
349
|
+
|| (conn.toSide && conn.toSide !== 'auto')
|
|
350
|
+
);
|
|
351
|
+
if (!fromSpread && !toSpread && hasExplicitSides) return { start, end };
|
|
352
|
+
|
|
353
|
+
const alignmentDelta = horizontallyFacing
|
|
354
|
+
? Math.abs(start[1] - end[1])
|
|
355
|
+
: Math.abs(start[0] - end[0]);
|
|
356
|
+
if (alignmentDelta >= AUTOMATIC_PORT_ALIGNMENT_DELTA) return { start, end };
|
|
357
|
+
|
|
358
|
+
// Keep the shared endpoint's distinct spread slot and move only the
|
|
359
|
+
// relationship's unshared endpoint onto that axis. With no spread endpoint,
|
|
360
|
+
// retain the existing least-movement choice between the two facing sides.
|
|
361
|
+
// If both endpoints are shared, preserve the outside bridge so no competing
|
|
362
|
+
// port is silently collapsed.
|
|
363
|
+
const alignEndToStart = horizontallyFacing
|
|
364
|
+
? { start, end: [end[0], start[1]] }
|
|
365
|
+
: { start, end: [start[0], end[1]] };
|
|
366
|
+
const alignStartToEnd = horizontallyFacing
|
|
367
|
+
? { start: [start[0], end[1]], end }
|
|
368
|
+
: { start: [end[0], start[1]], end };
|
|
369
|
+
const candidates = fromSpread
|
|
370
|
+
? [alignEndToStart]
|
|
371
|
+
: toSpread
|
|
372
|
+
? [alignStartToEnd]
|
|
373
|
+
: [alignEndToStart, alignStartToEnd];
|
|
374
|
+
for (const candidate of candidates) {
|
|
375
|
+
const points = [candidate.start, candidate.end];
|
|
376
|
+
if (portHasCornerClearance(from, fromSide, candidate.start)
|
|
377
|
+
&& portHasCornerClearance(to, toSide, candidate.end)
|
|
378
|
+
&& routeHonorsEndpointSides(points, fromSide, toSide)
|
|
379
|
+
&& routeClearsEndpointComponents(points, from, to)
|
|
380
|
+
&& routeClearsComponents(conn, points)) {
|
|
381
|
+
return candidate;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return { start, end };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// A reciprocal pair is repaired jointly after planning, so crossings that
|
|
388
|
+
// involve it are not final yet; keep the established first choice there.
|
|
389
|
+
const reciprocal = (conn) => connections.some((other) => other.from === conn.to && other.to === conn.from);
|
|
390
|
+
function siblingCrossings(conn, points, resolvedRoutes) {
|
|
391
|
+
if (reciprocal(conn)) return 0;
|
|
392
|
+
return crossingCount(conn, points, resolvedRoutes.filter((entry) => !reciprocal(entry.conn)));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function routeVia(conn, from, to, start, end, fromSide, toSide, resolvedRoutes = []) {
|
|
396
|
+
if (conn.via) return conn.via;
|
|
397
|
+
switch (conn.route || 'auto') {
|
|
398
|
+
case 'straight':
|
|
399
|
+
return [];
|
|
400
|
+
case 'orthogonal-h': {
|
|
401
|
+
const midX = (start[0] + end[0]) / 2;
|
|
402
|
+
return [[midX, start[1]], [midX, end[1]]];
|
|
403
|
+
}
|
|
404
|
+
case 'orthogonal-v': {
|
|
405
|
+
const midY = (start[1] + end[1]) / 2;
|
|
406
|
+
return [[start[0], midY], [end[0], midY]];
|
|
407
|
+
}
|
|
408
|
+
case 'auto':
|
|
409
|
+
default: {
|
|
410
|
+
// Direct line unless the anchors are clearly orthogonal-friendly.
|
|
411
|
+
const deltaX = Math.abs(start[0] - end[0]);
|
|
412
|
+
const deltaY = Math.abs(start[1] - end[1]);
|
|
413
|
+
if (deltaX < 4 || deltaY < 4) {
|
|
414
|
+
const direct = [start, end];
|
|
415
|
+
if (routeHonorsEndpointSides(direct, fromSide, toSide)
|
|
416
|
+
&& routeClearsEndpointComponents(direct, from, to)
|
|
417
|
+
&& routeClearsComponents(conn, direct)
|
|
418
|
+
&& routeMeetsCompositionFloors(direct)
|
|
419
|
+
&& !routeConflictsWithResolved(conn, direct, resolvedRoutes)) return [];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const rhythmBridge = automaticPortRhythmBridge(start, end, fromSide, toSide, {
|
|
423
|
+
accept: (points) => (
|
|
424
|
+
routeClearsEndpointComponents(points, from, to)
|
|
425
|
+
&& routeClearsComponents(conn, points)
|
|
426
|
+
&& routeMeetsCompositionFloors(points)
|
|
427
|
+
&& !routeConflictsWithResolved(conn, points, resolvedRoutes)
|
|
428
|
+
),
|
|
429
|
+
});
|
|
430
|
+
if (rhythmBridge) return rhythmBridge.slice(1, -1);
|
|
431
|
+
|
|
432
|
+
// Automatic port spreading can leave otherwise aligned endpoints only a
|
|
433
|
+
// few pixels apart. A midpoint route would split that tiny difference
|
|
434
|
+
// into two unreadable endpoint stubs, so take a bounded outside channel
|
|
435
|
+
// when both anchors sit on parallel component sides.
|
|
436
|
+
const minimumStub = 8;
|
|
437
|
+
const fromVerticalSide = start[1] === from.y || start[1] === from.y + from.height;
|
|
438
|
+
const toVerticalSide = end[1] === to.y || end[1] === to.y + to.height;
|
|
439
|
+
if (fromVerticalSide && toVerticalSide && deltaX < minimumStub * 2) {
|
|
440
|
+
const outsideChannels = [
|
|
441
|
+
Math.max(start[0], end[0]) + minimumStub * 2,
|
|
442
|
+
Math.min(start[0], end[0]) - minimumStub * 2,
|
|
443
|
+
];
|
|
444
|
+
for (const channelX of outsideChannels) {
|
|
445
|
+
const candidate = [[channelX, start[1]], [channelX, end[1]]];
|
|
446
|
+
const points = [start, ...candidate, end];
|
|
447
|
+
if (routeHonorsEndpointSides(points, fromSide, toSide)
|
|
448
|
+
&& routeClearsComponents(conn, points)
|
|
449
|
+
&& routeMeetsCompositionFloors(points)
|
|
450
|
+
&& !routeConflictsWithResolved(conn, points, resolvedRoutes)) return candidate;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const fromHorizontalSide = start[0] === from.x || start[0] === from.x + from.width;
|
|
455
|
+
const toHorizontalSide = end[0] === to.x || end[0] === to.x + to.width;
|
|
456
|
+
if (fromHorizontalSide && toHorizontalSide && deltaY < minimumStub * 2) {
|
|
457
|
+
const outsideChannels = [
|
|
458
|
+
Math.max(start[1], end[1]) + minimumStub * 2,
|
|
459
|
+
Math.min(start[1], end[1]) - minimumStub * 2,
|
|
460
|
+
];
|
|
461
|
+
for (const channelY of outsideChannels) {
|
|
462
|
+
const candidate = [[start[0], channelY], [end[0], channelY]];
|
|
463
|
+
const points = [start, ...candidate, end];
|
|
464
|
+
if (routeHonorsEndpointSides(points, fromSide, toSide)
|
|
465
|
+
&& routeClearsComponents(conn, points)
|
|
466
|
+
&& routeMeetsCompositionFloors(points)
|
|
467
|
+
&& !routeConflictsWithResolved(conn, points, resolvedRoutes)) return candidate;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const midX = (start[0] + end[0]) / 2;
|
|
472
|
+
const horizontalFirst = [[midX, start[1]], [midX, end[1]]];
|
|
473
|
+
const midY = (start[1] + end[1]) / 2;
|
|
474
|
+
const verticalFirst = [[start[0], midY], [end[0], midY]];
|
|
475
|
+
const candidates = [horizontalFirst, verticalFirst];
|
|
476
|
+
const sideSafe = candidates.filter((candidate) => (
|
|
477
|
+
routeHonorsEndpointSides([start, ...candidate, end], fromSide, toSide)
|
|
478
|
+
));
|
|
479
|
+
const sideAware = sideAwareBridgeCandidates(start, end, fromSide, toSide);
|
|
480
|
+
const nearParallelPorts = (
|
|
481
|
+
((fromSide === 'top' || fromSide === 'bottom')
|
|
482
|
+
&& (toSide === 'top' || toSide === 'bottom')
|
|
483
|
+
&& deltaX < minimumStub * 2)
|
|
484
|
+
|| ((fromSide === 'left' || fromSide === 'right')
|
|
485
|
+
&& (toSide === 'left' || toSide === 'right')
|
|
486
|
+
&& deltaY < minimumStub * 2)
|
|
487
|
+
);
|
|
488
|
+
const ordered = [
|
|
489
|
+
...(nearParallelPorts ? sideAware : sideSafe),
|
|
490
|
+
...(nearParallelPorts ? sideSafe : sideAware),
|
|
491
|
+
];
|
|
492
|
+
for (const candidate of ordered) {
|
|
493
|
+
const points = [start, ...candidate, end];
|
|
494
|
+
if (routeClearsEndpointComponents(points, from, to)
|
|
495
|
+
&& routeClearsComponents(conn, points)
|
|
496
|
+
&& routeMeetsCompositionFloors(points)
|
|
497
|
+
&& !routeConflictsWithResolved(conn, points, resolvedRoutes)
|
|
498
|
+
&& (!distinctAutomaticPorts || (!routeOverlapsResolved(conn, points, resolvedRoutes)
|
|
499
|
+
&& !siblingCrossings(conn, points, resolvedRoutes)))) return candidate;
|
|
500
|
+
}
|
|
501
|
+
// Siblings fanning out from one side all want the same midpoint
|
|
502
|
+
// channel. Step outward through the corridor to a free parallel
|
|
503
|
+
// channel before searching.
|
|
504
|
+
if (distinctAutomaticPorts) {
|
|
505
|
+
const channels = (from, to, mid) => {
|
|
506
|
+
const [low, high] = [Math.min(from, to) + 24, Math.max(from, to) - 24];
|
|
507
|
+
const values = [];
|
|
508
|
+
for (let offset = 16; mid - offset >= low || mid + offset <= high; offset += 16) {
|
|
509
|
+
values.push(...[mid + offset, mid - offset].filter((value) => value >= low && value <= high));
|
|
510
|
+
}
|
|
511
|
+
return values;
|
|
512
|
+
};
|
|
513
|
+
for (const candidate of [
|
|
514
|
+
...channels(start[0], end[0], midX).map((x) => [[x, start[1]], [x, end[1]]]),
|
|
515
|
+
...channels(start[1], end[1], midY).map((y) => [[start[0], y], [end[0], y]]),
|
|
516
|
+
]) {
|
|
517
|
+
const points = [start, ...candidate, end];
|
|
518
|
+
if (routeHonorsEndpointSides(points, fromSide, toSide)
|
|
519
|
+
&& routeClearsEndpointComponents(points, from, to)
|
|
520
|
+
&& routeClearsComponents(conn, points)
|
|
521
|
+
&& routeMeetsCompositionFloors(points)
|
|
522
|
+
&& !routeConflictsWithResolved(conn, points, resolvedRoutes)
|
|
523
|
+
&& !routeOverlapsResolved(conn, points, resolvedRoutes)
|
|
524
|
+
&& !siblingCrossings(conn, points, resolvedRoutes)) return candidate;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Two-bend doglegs are deliberately cheap, but a real architecture can
|
|
529
|
+
// place adjacent components on both of those corridors. Search a
|
|
530
|
+
// bounded obstacle grid before falling back to a route that the Clean
|
|
531
|
+
// Flow gate already knows violates the inferred endpoint directions.
|
|
532
|
+
// This keeps ordinary multi-bend avoidance renderer-owned instead of
|
|
533
|
+
// forcing the author to hand-place via points.
|
|
534
|
+
// Alternative endpoint pairs are cheap probes, not another grid-search
|
|
535
|
+
// budget. A legal short bridge often beats the first grid detour.
|
|
536
|
+
if (!allowGridSearch) return sideSafe[0] || sideAware[0] || horizontalFirst;
|
|
537
|
+
if (planningMetrics.gridSearchCount >= planningMetrics.maximumGridSearchCount) {
|
|
538
|
+
planningMetrics.gridBudgetExhaustedCount += 1;
|
|
539
|
+
planningMetrics.conflictFallbackCount += 1;
|
|
540
|
+
return sideSafe[0] || sideAware[0] || horizontalFirst;
|
|
541
|
+
}
|
|
542
|
+
// First-draft architecture graphs are not always planar at their
|
|
543
|
+
// authored node positions. The renderer gives automatic crossings a
|
|
544
|
+
// visible halo, so the grid owns opaque-node avoidance and rejects
|
|
545
|
+
// ambiguous shared corridors without forcing the model to hand-route
|
|
546
|
+
// a sprawling perimeter detour. Cheap candidates above still prefer a
|
|
547
|
+
// genuinely crossing-free path whenever one is available.
|
|
548
|
+
const avoidedSegments = resolvedRoutes
|
|
549
|
+
.filter((entry) => distinctAutomaticPorts && relationshipsShareEndpoint(conn, entry.conn)
|
|
550
|
+
&& !hasAuthoredRouteGeometry(entry.conn) && !entry.conn.labelAt && !conn.labelAt)
|
|
551
|
+
.flatMap((entry) => entry.points.slice(1).map((end, index) => ({
|
|
552
|
+
start: entry.points[index], end,
|
|
553
|
+
})));
|
|
554
|
+
const gridMetrics = {};
|
|
555
|
+
planningMetrics.gridSearchCount += 1;
|
|
556
|
+
planningMetrics.avoidedSegmentCount += avoidedSegments.length;
|
|
557
|
+
const searched = shortestOrthogonalGridRoute({
|
|
558
|
+
start,
|
|
559
|
+
end,
|
|
560
|
+
points: [start, end],
|
|
561
|
+
obstacles: [...components.values(), ...reservedLabelObstacles(2)],
|
|
562
|
+
fromSide,
|
|
563
|
+
toSide,
|
|
564
|
+
clearance: 2,
|
|
565
|
+
maximumObstacleCount: 80,
|
|
566
|
+
endpointStubPx: 24,
|
|
567
|
+
maximumGridNodes: 4096,
|
|
568
|
+
avoidedSegments,
|
|
569
|
+
allowAvoidedCrossings: true,
|
|
570
|
+
minimumAvoidedOverlapPx: 8,
|
|
571
|
+
routeSeparationPx: 8,
|
|
572
|
+
minimumSegmentPx: interiorSegmentPx,
|
|
573
|
+
borderSegments: frameBorders,
|
|
574
|
+
bendPenaltyPx: 48,
|
|
575
|
+
metrics: gridMetrics,
|
|
576
|
+
});
|
|
577
|
+
planningMetrics.gridCandidateNodeCount += gridMetrics.candidateNodeCount || 0;
|
|
578
|
+
planningMetrics.gridUsableNodeCount += gridMetrics.usableNodeCount || 0;
|
|
579
|
+
planningMetrics.gridEdgeCount += gridMetrics.graphEdgeCount || 0;
|
|
580
|
+
planningMetrics.gridVisitedNodeCount += gridMetrics.visitedNodeCount || 0;
|
|
581
|
+
const clearsEndpoints = searched
|
|
582
|
+
? routeClearsEndpointComponents(searched.points, from, to) : false;
|
|
583
|
+
const clearsComponents = searched
|
|
584
|
+
? routeClearsComponents(conn, searched.points) : false;
|
|
585
|
+
const clearsRelationships = searched
|
|
586
|
+
? !routeConflictsWithResolved(conn, searched.points, resolvedRoutes) : false;
|
|
587
|
+
const clearsSharedCorridors = searched
|
|
588
|
+
? !routeOverlapsResolved(conn, searched.points, resolvedRoutes) : false;
|
|
589
|
+
const meetsFloors = searched ? routeMeetsCompositionFloors(searched.points) : false;
|
|
590
|
+
const accepted = Boolean(
|
|
591
|
+
searched && clearsEndpoints && clearsComponents && clearsSharedCorridors && meetsFloors,
|
|
592
|
+
);
|
|
593
|
+
planningMetrics.gridAttempts.push({
|
|
594
|
+
relationship: conn.id || `${conn.from}->${conn.to}`,
|
|
595
|
+
fromSide,
|
|
596
|
+
toSide,
|
|
597
|
+
inputAvoidedSegmentCount: avoidedSegments.length,
|
|
598
|
+
...gridMetrics,
|
|
599
|
+
accepted,
|
|
600
|
+
...(!accepted && searched ? {
|
|
601
|
+
rejectedBy: [
|
|
602
|
+
...(!clearsEndpoints ? ['endpoint-components'] : []),
|
|
603
|
+
...(!clearsComponents ? ['components'] : []),
|
|
604
|
+
...(!clearsSharedCorridors ? ['shared-corridor'] : []),
|
|
605
|
+
...(!meetsFloors ? ['composition-floors'] : []),
|
|
606
|
+
],
|
|
607
|
+
candidatePoints: searched.points,
|
|
608
|
+
} : {}),
|
|
609
|
+
});
|
|
610
|
+
if (accepted) {
|
|
611
|
+
planningMetrics.gridRoutedCount += 1;
|
|
612
|
+
if (!clearsRelationships) planningMetrics.crossoverRoutedCount += 1;
|
|
613
|
+
return searched.points.slice(1, -1);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Both bounded doglegs are blocked. Keep the best endpoint-safe route
|
|
617
|
+
// when one exists so the universal Clean Flow gate reports the actual
|
|
618
|
+
// obstacle; otherwise preserve the historical deterministic fallback
|
|
619
|
+
// and let the endpoint-direction gate explain the side mismatch.
|
|
620
|
+
planningMetrics.conflictFallbackCount += 1;
|
|
621
|
+
return sideSafe[0] || sideAware[0] || horizontalFirst;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// A node's neighbours laid out as one row below (or above) it read as a
|
|
627
|
+
// fan-out: reach them all through the same vertical side. Center-based
|
|
628
|
+
// inference alone sends the outer ones sideways around their siblings.
|
|
629
|
+
const neighbourRects = new Map();
|
|
630
|
+
for (const conn of connections) {
|
|
631
|
+
const from = components.get(conn.from);
|
|
632
|
+
const to = components.get(conn.to);
|
|
633
|
+
if (!from || !to || from === to) continue;
|
|
634
|
+
for (const [node, other] of [[from, to], [to, from]]) {
|
|
635
|
+
neighbourRects.set(node.id, [...(neighbourRects.get(node.id) || []), other]);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function rowFanOutSides(from, to) {
|
|
639
|
+
if (!preferReadableRoutes) return null;
|
|
640
|
+
const verticalSides = (node, other) => {
|
|
641
|
+
const below = other.y >= node.y + node.height;
|
|
642
|
+
if (!below && other.y + other.height > node.y) return null;
|
|
643
|
+
if (['top', 'bottom'].includes(defaultFromSide(node, other))) return null;
|
|
644
|
+
const rowSibling = (neighbourRects.get(node.id) || []).some((sibling) => sibling !== other
|
|
645
|
+
&& Math.abs(sibling.cy - other.cy) < 1
|
|
646
|
+
&& defaultFromSide(node, sibling) === (below ? 'bottom' : 'top'));
|
|
647
|
+
// Only when a node of that row blocks a sideways route to one of the
|
|
648
|
+
// neighbours on this side; otherwise side exits stay clear and keep the
|
|
649
|
+
// vertical side free. Decide per side so a row never mixes both styles.
|
|
650
|
+
const blocked = (target) => {
|
|
651
|
+
const [gapStart, gapEnd] = target.cx < node.cx
|
|
652
|
+
? [target.x + target.width, node.x] : [node.x + node.width, target.x];
|
|
653
|
+
return [...components.values()].some((rect) => rect !== node && rect !== target
|
|
654
|
+
&& rect.y < target.y + target.height && rect.y + rect.height > target.y
|
|
655
|
+
&& rect.x < gapEnd && rect.x + rect.width > gapStart);
|
|
656
|
+
};
|
|
657
|
+
const sameSide = (neighbourRects.get(node.id) || []).filter((sibling) => Math.abs(sibling.cy - other.cy) < 1
|
|
658
|
+
&& defaultFromSide(node, sibling) === defaultFromSide(node, other));
|
|
659
|
+
return rowSibling && sameSide.some(blocked) ? (below ? 'bottom' : 'top') : null;
|
|
660
|
+
};
|
|
661
|
+
const opposite = { top: 'bottom', bottom: 'top' };
|
|
662
|
+
const fromSide = verticalSides(from, to);
|
|
663
|
+
if (fromSide) return { fromSide, toSide: opposite[fromSide] };
|
|
664
|
+
const toSide = verticalSides(to, from);
|
|
665
|
+
return toSide ? { fromSide: opposite[toSide], toSide } : null;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const pathCache = new Map();
|
|
669
|
+
const selectedSides = new Map();
|
|
670
|
+
const stroke = (relation) => relation.width || (relation.variant === 'emphasis' ? 1.8 : 1.5);
|
|
671
|
+
const markerSpacing = (left, right) => 3.5 * (stroke(left) + stroke(right));
|
|
672
|
+
const portSpacing = (left, right) => Math.max(14, markerSpacing(left, right) + 3.5);
|
|
673
|
+
const automaticPorts = automaticPortSpread(connections, components, {
|
|
674
|
+
sideFor: (relation, endpoint) => rowFanOutSides(components.get(relation.from), components.get(relation.to))
|
|
675
|
+
?.[endpoint === 'source' ? 'fromSide' : 'toSide'],
|
|
676
|
+
// Preserve the established initial placement for ordinary markers; only
|
|
677
|
+
// widen groups whose arrowheads cannot fit the legacy 14px slots.
|
|
678
|
+
...(distinctAutomaticPorts ? { spacingFor: (left, right) =>
|
|
679
|
+
markerSpacing(left, right) > 14 ? portSpacing(left, right) : 14 } : {}),
|
|
680
|
+
});
|
|
681
|
+
const incidentEndpoints = new Map();
|
|
682
|
+
for (const conn of connections) {
|
|
683
|
+
if (!components.has(conn.from) || !components.has(conn.to)) continue;
|
|
684
|
+
for (const [field, sideField] of [['from', 'fromSide'], ['to', 'toSide']]) {
|
|
685
|
+
const entries = incidentEndpoints.get(conn[field]) || [];
|
|
686
|
+
entries.push({ conn, field, sideField });
|
|
687
|
+
incidentEndpoints.set(conn[field], entries);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
function inferredConnectionSides(conn) {
|
|
691
|
+
const from = components.get(conn.from);
|
|
692
|
+
const to = components.get(conn.to);
|
|
693
|
+
const fanOut = rowFanOutSides(from, to);
|
|
694
|
+
return {
|
|
695
|
+
fromSide: chosenSide(conn.fromSide, fanOut?.fromSide || defaultFromSide(from, to)),
|
|
696
|
+
toSide: chosenSide(conn.toSide, fanOut?.toSide || defaultToSide(from, to)),
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function connectionSides(conn) {
|
|
701
|
+
if (!routesPlanned && !routesPlanning) planRoutes();
|
|
702
|
+
return selectedSides.get(conn) || inferredConnectionSides(conn);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function connectionEndpointSide(conn, endpoint) {
|
|
706
|
+
const field = endpoint === 'source' ? 'fromSide' : 'toSide';
|
|
707
|
+
if (conn[field] && conn[field] !== 'auto') return conn[field];
|
|
708
|
+
return connectionSides(conn)[field];
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function hasAuthoredRouteGeometry(conn) {
|
|
712
|
+
return Boolean(
|
|
713
|
+
conn?.via
|
|
714
|
+
|| (conn?.route && conn.route !== 'auto')
|
|
715
|
+
|| conn?.channelX !== undefined
|
|
716
|
+
|| conn?.channelY !== undefined
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function hasAuthoredLabelPlacement(conn) {
|
|
721
|
+
return ['labelAt', 'labelDx', 'labelDy', 'labelSegment']
|
|
722
|
+
.some((field) => conn?.[field] !== undefined);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// A route can change sides after the initial port spread. Reserve slots on
|
|
726
|
+
// the final side as well: falling back to its midpoint can put an arrow
|
|
727
|
+
// between two existing slots, or directly on another incoming arrow.
|
|
728
|
+
function automaticEndpoint(conn, endpoint, rect, side, inferredSide) {
|
|
729
|
+
const initial = side === inferredSide ? automaticPorts.get(conn)?.[endpoint] : null;
|
|
730
|
+
const preferred = initial || anchor(rect, side);
|
|
731
|
+
if (hasAuthoredRouteGeometry(conn) || conn.labelAt) return { point: preferred, spread: Boolean(initial) };
|
|
732
|
+
const axis = side === 'left' || side === 'right' ? 1 : 0;
|
|
733
|
+
const occupied = [];
|
|
734
|
+
for (const { conn: other, field, sideField } of incidentEndpoints.get(rect.id) || []) {
|
|
735
|
+
if (other === conn || hasAuthoredRouteGeometry(other) || other.labelAt) continue;
|
|
736
|
+
const routed = pathCache.get(other);
|
|
737
|
+
const sides = selectedSides.get(other) || inferredConnectionSides(other);
|
|
738
|
+
if (sides[sideField] !== side) continue;
|
|
739
|
+
const point = routed
|
|
740
|
+
? (field === 'from' ? routed.points[0] : routed.points.at(-1))
|
|
741
|
+
: automaticPorts.get(other)?.[field] || anchor(rect, side);
|
|
742
|
+
occupied.push({ value: point[axis], spacing: portSpacing(conn, other) });
|
|
743
|
+
}
|
|
744
|
+
if (!occupied.length) return { point: preferred, spread: Boolean(initial) };
|
|
745
|
+
const candidates = [preferred[axis], ...occupied.flatMap(({ value, spacing }) => [value - spacing, value + spacing])]
|
|
746
|
+
.sort((a, b) => Math.abs(a - preferred[axis]) - Math.abs(b - preferred[axis]) || a - b);
|
|
747
|
+
for (const value of candidates) {
|
|
748
|
+
const point = [...preferred];
|
|
749
|
+
point[axis] = value;
|
|
750
|
+
if (portHasCornerClearance(rect, side, point)
|
|
751
|
+
&& occupied.every((other) => Math.abs(value - other.value) >= other.spacing - 0.0001)) {
|
|
752
|
+
return { point, spread: true };
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return { point: preferred, spread: true, crowded: true };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function connectionGeometry(conn, sides = inferredConnectionSides(conn)) {
|
|
759
|
+
const from = components.get(conn.from);
|
|
760
|
+
const to = components.get(conn.to);
|
|
761
|
+
const inferred = inferredConnectionSides(conn);
|
|
762
|
+
const { fromSide, toSide } = sides;
|
|
763
|
+
const legacyPorts = fromSide === inferred.fromSide && toSide === inferred.toSide ? automaticPorts.get(conn) : null;
|
|
764
|
+
const source = distinctAutomaticPorts
|
|
765
|
+
? automaticEndpoint(conn, 'from', from, fromSide, inferred.fromSide)
|
|
766
|
+
: { point: legacyPorts?.from || anchor(from, fromSide), spread: Boolean(legacyPorts?.from) };
|
|
767
|
+
const target = distinctAutomaticPorts
|
|
768
|
+
? automaticEndpoint(conn, 'to', to, toSide, inferred.toSide)
|
|
769
|
+
: { point: legacyPorts?.to || anchor(to, toSide), spread: Boolean(legacyPorts?.to) };
|
|
770
|
+
const ports = { from: source.spread, to: target.spread };
|
|
771
|
+
const { start, end } = alignFacingPorts(
|
|
772
|
+
conn,
|
|
773
|
+
from,
|
|
774
|
+
to,
|
|
775
|
+
source.point,
|
|
776
|
+
target.point,
|
|
777
|
+
fromSide,
|
|
778
|
+
toSide,
|
|
779
|
+
ports,
|
|
780
|
+
);
|
|
781
|
+
return { from, to, start, end, fromSide, toSide, crowded: source.crowded || target.crowded };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function routedForGeometry(conn, resolvedRoutes, geometry) {
|
|
785
|
+
const { from, to, start, end, fromSide, toSide } = geometry;
|
|
786
|
+
const authoredPoints = [
|
|
787
|
+
start,
|
|
788
|
+
...routeVia(conn, from, to, start, end, fromSide, toSide, resolvedRoutes),
|
|
789
|
+
end,
|
|
790
|
+
];
|
|
791
|
+
// Explicit waypoints are author-owned geometry. Keep even a collinear
|
|
792
|
+
// waypoint: it can intentionally split a route at a semantic touch point,
|
|
793
|
+
// and preserving it is part of the backwards-compatible authoring contract.
|
|
794
|
+
// Automatic routes remain normalized so the renderer does not emit noisy
|
|
795
|
+
// duplicate turns or zero-length segments.
|
|
796
|
+
const points = hasAuthoredRouteGeometry(conn)
|
|
797
|
+
? authoredPoints
|
|
798
|
+
: normalizeRoutePoints(authoredPoints);
|
|
799
|
+
return { d: roundedPath(points, 8), points };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function cachePath(conn, routed, sides) {
|
|
803
|
+
pathCache.set(conn, routed);
|
|
804
|
+
selectedSides.set(conn, { fromSide: sides.fromSide, toSide: sides.toSide });
|
|
805
|
+
return routed;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const SIDE_ORDER = ['right', 'bottom', 'left', 'top'];
|
|
809
|
+
function candidateSidePairs(conn) {
|
|
810
|
+
const inferred = inferredConnectionSides(conn);
|
|
811
|
+
const authoredFrom = conn.fromSide && conn.fromSide !== 'auto' ? conn.fromSide : null;
|
|
812
|
+
const authoredTo = conn.toSide && conn.toSide !== 'auto' ? conn.toSide : null;
|
|
813
|
+
const fromOptions = authoredFrom
|
|
814
|
+
? [authoredFrom]
|
|
815
|
+
: [inferred.fromSide, ...SIDE_ORDER.filter((side) => side !== inferred.fromSide)];
|
|
816
|
+
const toOptions = authoredTo
|
|
817
|
+
? [authoredTo]
|
|
818
|
+
: [inferred.toSide, ...SIDE_ORDER.filter((side) => side !== inferred.toSide)];
|
|
819
|
+
return fromOptions.flatMap((fromSide) => toOptions.map((toSide) => ({
|
|
820
|
+
fromSide,
|
|
821
|
+
toSide,
|
|
822
|
+
deviationCount: Number(fromSide !== inferred.fromSide) + Number(toSide !== inferred.toSide),
|
|
823
|
+
}))).sort((left, right) => {
|
|
824
|
+
if (left.deviationCount !== right.deviationCount) {
|
|
825
|
+
return left.deviationCount - right.deviationCount;
|
|
826
|
+
}
|
|
827
|
+
const leftGeometry = connectionGeometry(conn, left);
|
|
828
|
+
const rightGeometry = connectionGeometry(conn, right);
|
|
829
|
+
const leftDistance = Math.abs(leftGeometry.end[0] - leftGeometry.start[0])
|
|
830
|
+
+ Math.abs(leftGeometry.end[1] - leftGeometry.start[1]);
|
|
831
|
+
const rightDistance = Math.abs(rightGeometry.end[0] - rightGeometry.start[0])
|
|
832
|
+
+ Math.abs(rightGeometry.end[1] - rightGeometry.start[1]);
|
|
833
|
+
return leftDistance - rightDistance;
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function routeIsClear(conn, routed, geometry, resolvedRoutes) {
|
|
838
|
+
return !geometry.crowded && routed.points.length >= 2
|
|
839
|
+
&& routeHonorsEndpointSides(routed.points, geometry.fromSide, geometry.toSide)
|
|
840
|
+
&& routeClearsEndpointComponents(routed.points, geometry.from, geometry.to)
|
|
841
|
+
&& routeClearsComponents(conn, routed.points)
|
|
842
|
+
&& routeMeetsCompositionFloors(routed.points)
|
|
843
|
+
&& !routeOverlapsResolved(conn, routed.points, resolvedRoutes);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function crossingCount(conn, points, resolvedRoutes) {
|
|
847
|
+
return resolvedRoutes.reduce((total, entry) => total + Number(
|
|
848
|
+
!(relationshipsShareEndpoint(conn, entry.conn)
|
|
849
|
+
&& (!distinctAutomaticPorts || hasAuthoredRouteGeometry(entry.conn) || entry.conn.labelAt || conn.labelAt))
|
|
850
|
+
&& points.slice(1).some((end, index) => entry.points.slice(1).some((otherEnd, otherIndex) =>
|
|
851
|
+
properSegmentIntersection(points[index], end, entry.points[otherIndex], otherEnd))),
|
|
852
|
+
), 0);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function readabilityCost(conn, routed, resolvedRoutes) {
|
|
856
|
+
const points = routed.points;
|
|
857
|
+
const length = points.slice(1).reduce((total, point, index) => total
|
|
858
|
+
+ Math.abs(point[0] - points[index][0]) + Math.abs(point[1] - points[index][1]), 0);
|
|
859
|
+
// A crossover is a reading cost even with a halo. Keep it finite: avoiding
|
|
860
|
+
// one crossing must not justify an arbitrarily long perimeter excursion.
|
|
861
|
+
return length + Math.max(0, points.length - 2) * 48 + crossingCount(conn, points, resolvedRoutes) * 160;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function computePath(conn, resolvedRoutes) {
|
|
865
|
+
if (hasAuthoredRouteGeometry(conn)) {
|
|
866
|
+
const sides = inferredConnectionSides(conn);
|
|
867
|
+
return cachePath(conn, routedForGeometry(
|
|
868
|
+
conn,
|
|
869
|
+
resolvedRoutes,
|
|
870
|
+
connectionGeometry(conn, sides),
|
|
871
|
+
), sides);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
let firstFallback = null;
|
|
875
|
+
const clearRoute = () => {
|
|
876
|
+
let firstClear = null;
|
|
877
|
+
for (const sides of candidateSidePairs(conn)) {
|
|
878
|
+
const geometry = connectionGeometry(conn, sides);
|
|
879
|
+
const routed = routedForGeometry(conn, resolvedRoutes, geometry);
|
|
880
|
+
if (!firstFallback) firstFallback = { routed, sides };
|
|
881
|
+
if (routeIsClear(conn, routed, geometry, resolvedRoutes)) {
|
|
882
|
+
firstClear = { routed, sides };
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return firstClear;
|
|
887
|
+
};
|
|
888
|
+
const routeLength = ({ routed }) => routed.points.slice(1)
|
|
889
|
+
.reduce((total, point, index) => total + Math.abs(point[0] - routed.points[index][0]) + Math.abs(point[1] - routed.points[index][1]), 0);
|
|
890
|
+
|
|
891
|
+
// Reserved labels are a preference, not an obstacle. A route that has no
|
|
892
|
+
// corridor around them, or that would have to detour far around them,
|
|
893
|
+
// takes the plain route instead and the label placement pass moves the
|
|
894
|
+
// label; only a modest extra length is worth keeping a label in place.
|
|
895
|
+
honourReservedLabels = true;
|
|
896
|
+
let chosen = clearRoute();
|
|
897
|
+
if (reservedLabels.length) {
|
|
898
|
+
honourReservedLabels = false;
|
|
899
|
+
if (!chosen) chosen = clearRoute();
|
|
900
|
+
else {
|
|
901
|
+
const direct = Math.abs(chosen.routed.points.at(-1)[0] - chosen.routed.points[0][0])
|
|
902
|
+
+ Math.abs(chosen.routed.points.at(-1)[1] - chosen.routed.points[0][1]);
|
|
903
|
+
if (routeLength(chosen) > direct * 1.25 + 64) {
|
|
904
|
+
const plain = clearRoute();
|
|
905
|
+
if (plain && routeLength(plain) * 1.25 + 64 < routeLength(chosen)) chosen = plain;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
honourReservedLabels = true;
|
|
909
|
+
}
|
|
910
|
+
if (chosen) return cachePath(conn, chosen.routed, chosen.sides);
|
|
911
|
+
return cachePath(conn, firstFallback.routed, firstFallback.sides);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
let routesPlanned = false;
|
|
915
|
+
let routesPlanning = false;
|
|
916
|
+
function planRoutes() {
|
|
917
|
+
if (routesPlanned || routesPlanning) return;
|
|
918
|
+
routesPlanning = true;
|
|
919
|
+
const indexed = connections
|
|
920
|
+
.map((conn, index) => ({ conn, index }))
|
|
921
|
+
.filter(({ conn }) => components.has(conn.from) && components.has(conn.to));
|
|
922
|
+
const explicit = indexed.filter(({ conn }) => hasAuthoredRouteGeometry(conn));
|
|
923
|
+
const automatic = indexed.filter(({ conn }) => !hasAuthoredRouteGeometry(conn))
|
|
924
|
+
.sort((left, right) => {
|
|
925
|
+
const leftFrom = components.get(left.conn.from);
|
|
926
|
+
const leftTo = components.get(left.conn.to);
|
|
927
|
+
const rightFrom = components.get(right.conn.from);
|
|
928
|
+
const rightTo = components.get(right.conn.to);
|
|
929
|
+
const leftDistance = Math.abs(leftTo.cx - leftFrom.cx) + Math.abs(leftTo.cy - leftFrom.cy);
|
|
930
|
+
const rightDistance = Math.abs(rightTo.cx - rightFrom.cx) + Math.abs(rightTo.cy - rightFrom.cy);
|
|
931
|
+
return leftDistance - rightDistance || left.index - right.index;
|
|
932
|
+
});
|
|
933
|
+
const resolvedRoutes = [];
|
|
934
|
+
reservedLabels = [];
|
|
935
|
+
for (const { conn } of [...explicit, ...automatic]) {
|
|
936
|
+
const routed = computePath(conn, resolvedRoutes);
|
|
937
|
+
resolvedRoutes.push({ conn, points: routed.points });
|
|
938
|
+
const rect = labelRectFor?.(conn, routed.points, {
|
|
939
|
+
routes: resolvedRoutes.map((entry) => entry.points),
|
|
940
|
+
labels: reservedLabels.map((entry) => entry.rect),
|
|
941
|
+
});
|
|
942
|
+
if (rect) reservedLabels.push({ conn, rect });
|
|
943
|
+
}
|
|
944
|
+
// Improve against the complete set of routes, not only earlier edges.
|
|
945
|
+
// A greedy side change during the initial pass can steal a later edge's
|
|
946
|
+
// corridor. This bounded sweep only changes one route at a time, keeping
|
|
947
|
+
// every other route and label as an obstacle/reference.
|
|
948
|
+
if (preferReadableRoutes) {
|
|
949
|
+
const scenePoints = resolvedRoutes.flatMap((entry) => entry.points);
|
|
950
|
+
for (const rect of [...components.values(), ...frames, ...reservedLabels.map((entry) => entry.rect)]) {
|
|
951
|
+
scenePoints.push([rect.x, rect.y], [rect.x + rect.width, rect.y + rect.height]);
|
|
952
|
+
}
|
|
953
|
+
const bounds = {
|
|
954
|
+
left: Math.min(...scenePoints.map(([x]) => x)), right: Math.max(...scenePoints.map(([x]) => x)),
|
|
955
|
+
top: Math.min(...scenePoints.map(([, y]) => y)), bottom: Math.max(...scenePoints.map(([, y]) => y)),
|
|
956
|
+
};
|
|
957
|
+
const withinScene = ([x, y]) => x >= bounds.left && x <= bounds.right && y >= bounds.top && y <= bounds.bottom;
|
|
958
|
+
const jointlyImproved = new Set();
|
|
959
|
+
function improveReciprocalPairs() {
|
|
960
|
+
// A one-route sweep cannot repair reciprocal facing edges whose initial
|
|
961
|
+
// spread groups differ. Try both direct lanes together after final side
|
|
962
|
+
// geometry is known, leaving every other route and authored port in place.
|
|
963
|
+
const paired = new Set();
|
|
964
|
+
for (const first of resolvedRoutes) {
|
|
965
|
+
const conn = first.conn;
|
|
966
|
+
if (paired.has(conn) || hasAuthoredRouteGeometry(conn) || hasAuthoredLabelPlacement(conn)
|
|
967
|
+
|| (conn.fromSide && conn.fromSide !== 'auto')
|
|
968
|
+
|| (conn.toSide && conn.toSide !== 'auto')) continue;
|
|
969
|
+
const second = resolvedRoutes.find((entry) => entry !== first
|
|
970
|
+
&& entry.conn.from === conn.to && entry.conn.to === conn.from
|
|
971
|
+
&& !hasAuthoredRouteGeometry(entry.conn) && !hasAuthoredLabelPlacement(entry.conn)
|
|
972
|
+
&& (!entry.conn.fromSide || entry.conn.fromSide === 'auto')
|
|
973
|
+
&& (!entry.conn.toSide || entry.conn.toSide === 'auto'));
|
|
974
|
+
if (!second) continue;
|
|
975
|
+
paired.add(conn);
|
|
976
|
+
paired.add(second.conn);
|
|
977
|
+
const firstSides = inferredConnectionSides(conn);
|
|
978
|
+
const secondSides = inferredConnectionSides(second.conn);
|
|
979
|
+
const horizontal = firstSides.fromSide === 'right' && firstSides.toSide === 'left'
|
|
980
|
+
&& secondSides.fromSide === 'left' && secondSides.toSide === 'right';
|
|
981
|
+
const horizontalReverse = firstSides.fromSide === 'left' && firstSides.toSide === 'right'
|
|
982
|
+
&& secondSides.fromSide === 'right' && secondSides.toSide === 'left';
|
|
983
|
+
const vertical = firstSides.fromSide === 'bottom' && firstSides.toSide === 'top'
|
|
984
|
+
&& secondSides.fromSide === 'top' && secondSides.toSide === 'bottom';
|
|
985
|
+
const verticalReverse = firstSides.fromSide === 'top' && firstSides.toSide === 'bottom'
|
|
986
|
+
&& secondSides.fromSide === 'bottom' && secondSides.toSide === 'top';
|
|
987
|
+
if (!horizontal && !horizontalReverse && !vertical && !verticalReverse) continue;
|
|
988
|
+
if (first.points.length <= 2 && second.points.length <= 2) continue;
|
|
989
|
+
const axis = horizontal || horizontalReverse ? 1 : 0;
|
|
990
|
+
const others = resolvedRoutes.filter((entry) => entry !== first && entry !== second);
|
|
991
|
+
const firstGeometry = connectionGeometry(conn, firstSides);
|
|
992
|
+
const secondGeometry = connectionGeometry(second.conn, secondSides);
|
|
993
|
+
if (firstGeometry.crowded || secondGeometry.crowded) continue;
|
|
994
|
+
const previousLabels = reservedLabels;
|
|
995
|
+
let improved = false;
|
|
996
|
+
reservedLabels = reservedLabels.filter((entry) => entry.conn !== conn && entry.conn !== second.conn);
|
|
997
|
+
try {
|
|
998
|
+
const lanes = (geometry) => [...new Set([geometry.start[axis], geometry.end[axis]])];
|
|
999
|
+
const direct = (geometry, lane) => {
|
|
1000
|
+
const start = [...geometry.start];
|
|
1001
|
+
const end = [...geometry.end];
|
|
1002
|
+
start[axis] = lane;
|
|
1003
|
+
end[axis] = lane;
|
|
1004
|
+
return { points: [start, end], d: roundedPath([start, end], 8) };
|
|
1005
|
+
};
|
|
1006
|
+
const endpointSlotsClear = (candidateRoutes) => {
|
|
1007
|
+
const entries = [...others, ...candidateRoutes];
|
|
1008
|
+
for (const candidate of candidateRoutes) {
|
|
1009
|
+
const sides = candidate.conn === conn ? firstSides : secondSides;
|
|
1010
|
+
for (const [componentId, side, point] of [
|
|
1011
|
+
[candidate.conn.from, sides.fromSide, candidate.points[0]],
|
|
1012
|
+
[candidate.conn.to, sides.toSide, candidate.points.at(-1)],
|
|
1013
|
+
]) {
|
|
1014
|
+
const coordinate = side === 'left' || side === 'right' ? 1 : 0;
|
|
1015
|
+
for (const other of entries) {
|
|
1016
|
+
if (other.conn === candidate.conn) continue;
|
|
1017
|
+
const otherSides = other.conn === conn ? firstSides
|
|
1018
|
+
: other.conn === second.conn ? secondSides : selectedSides.get(other.conn);
|
|
1019
|
+
for (const [otherId, otherSide, otherPoint] of [
|
|
1020
|
+
[other.conn.from, otherSides.fromSide, other.points[0]],
|
|
1021
|
+
[other.conn.to, otherSides.toSide, other.points.at(-1)],
|
|
1022
|
+
]) {
|
|
1023
|
+
if (componentId === otherId && side === otherSide
|
|
1024
|
+
&& Math.abs(point[coordinate] - otherPoint[coordinate])
|
|
1025
|
+
< portSpacing(candidate.conn, other.conn) - 0.0001) return false;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return true;
|
|
1031
|
+
};
|
|
1032
|
+
const labelClears = (rect, owner, entries, labels) => {
|
|
1033
|
+
if (!rect) return !owner.label;
|
|
1034
|
+
if (!withinScene([rect.x, rect.y])
|
|
1035
|
+
|| !withinScene([rect.x + rect.width, rect.y + rect.height])
|
|
1036
|
+
|| labels.some((other) => rectsOverlap(rect, other.rect, 2))) return false;
|
|
1037
|
+
return entries.every((entry) => entry.conn === owner || entry.points.slice(1).every((end, index) =>
|
|
1038
|
+
!segmentIntersectsRect({ start: entry.points[index], end }, rect, LABEL_CLEARANCE)));
|
|
1039
|
+
};
|
|
1040
|
+
let best = null;
|
|
1041
|
+
let bestCost = readabilityCost(conn, first, [...others, second])
|
|
1042
|
+
+ readabilityCost(second.conn, second, [...others, first]);
|
|
1043
|
+
for (const firstLane of lanes(firstGeometry)) {
|
|
1044
|
+
for (const secondLane of lanes(secondGeometry)) {
|
|
1045
|
+
planningMetrics.reciprocalCandidateCount += 1;
|
|
1046
|
+
const firstRoute = { conn, ...direct(firstGeometry, firstLane) };
|
|
1047
|
+
const secondRoute = { conn: second.conn, ...direct(secondGeometry, secondLane) };
|
|
1048
|
+
const candidates = [firstRoute, secondRoute];
|
|
1049
|
+
if (!candidates.every((entry) => entry.points.every(withinScene))) continue;
|
|
1050
|
+
if (!endpointSlotsClear(candidates)) continue;
|
|
1051
|
+
if (!candidates.every((entry, index) => {
|
|
1052
|
+
const geometry = entry.conn === conn ? firstGeometry : secondGeometry;
|
|
1053
|
+
if (!portHasCornerClearance(geometry.from, geometry.fromSide, entry.points[0])
|
|
1054
|
+
|| !portHasCornerClearance(geometry.to, geometry.toSide, entry.points.at(-1))) return false;
|
|
1055
|
+
return routeIsClear(entry.conn, entry, geometry, [...others, candidates[1 - index]]);
|
|
1056
|
+
})) continue;
|
|
1057
|
+
const firstRect = labelRectFor?.(conn, firstRoute.points, {
|
|
1058
|
+
routes: [...others.map((entry) => entry.points), ...candidates.map((entry) => entry.points)],
|
|
1059
|
+
labels: reservedLabels.map((entry) => entry.rect),
|
|
1060
|
+
});
|
|
1061
|
+
if (!labelClears(firstRect, conn, [...others, secondRoute], reservedLabels)) continue;
|
|
1062
|
+
const secondRect = labelRectFor?.(second.conn, secondRoute.points, {
|
|
1063
|
+
routes: [...others.map((entry) => entry.points), ...candidates.map((entry) => entry.points)],
|
|
1064
|
+
labels: [...reservedLabels.map((entry) => entry.rect), ...(firstRect ? [firstRect] : [])],
|
|
1065
|
+
});
|
|
1066
|
+
if (!labelClears(secondRect, second.conn, [...others, firstRoute], [
|
|
1067
|
+
...reservedLabels, ...(firstRect ? [{ conn, rect: firstRect }] : []),
|
|
1068
|
+
])) continue;
|
|
1069
|
+
const cost = readabilityCost(conn, firstRoute, [...others, secondRoute])
|
|
1070
|
+
+ readabilityCost(second.conn, secondRoute, [...others, firstRoute]);
|
|
1071
|
+
if (cost < bestCost) {
|
|
1072
|
+
best = { firstRoute, secondRoute, firstRect, secondRect };
|
|
1073
|
+
bestCost = cost;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (!best) continue;
|
|
1078
|
+
cachePath(conn, best.firstRoute, firstSides);
|
|
1079
|
+
cachePath(second.conn, best.secondRoute, secondSides);
|
|
1080
|
+
first.points = best.firstRoute.points;
|
|
1081
|
+
second.points = best.secondRoute.points;
|
|
1082
|
+
planningMetrics.reciprocalImprovedCount += 1;
|
|
1083
|
+
jointlyImproved.add(conn);
|
|
1084
|
+
jointlyImproved.add(second.conn);
|
|
1085
|
+
improved = true;
|
|
1086
|
+
reservedLabels = [
|
|
1087
|
+
...reservedLabels,
|
|
1088
|
+
...(best.firstRect ? [{ conn, rect: best.firstRect }] : []),
|
|
1089
|
+
...(best.secondRect ? [{ conn: second.conn, rect: best.secondRect }] : []),
|
|
1090
|
+
];
|
|
1091
|
+
} finally {
|
|
1092
|
+
if (!improved) reservedLabels = previousLabels;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
allowGridSearch = false;
|
|
1097
|
+
try {
|
|
1098
|
+
improveReciprocalPairs();
|
|
1099
|
+
for (const entry of resolvedRoutes) {
|
|
1100
|
+
const { conn } = entry;
|
|
1101
|
+
if (jointlyImproved.has(conn) || hasAuthoredRouteGeometry(conn) || conn.labelAt) continue;
|
|
1102
|
+
const others = resolvedRoutes.filter((other) => other !== entry);
|
|
1103
|
+
// Preserve uncomplicated routes and their established inferred sides.
|
|
1104
|
+
// Spend the comparison budget where the complete scene has a reading
|
|
1105
|
+
// cost: a crossover or more than two bends.
|
|
1106
|
+
if (entry.points.length <= 4 && crossingCount(conn, entry.points, others) === 0) continue;
|
|
1107
|
+
let best = null;
|
|
1108
|
+
let bestCost = readabilityCost(conn, { points: entry.points }, others);
|
|
1109
|
+
for (const sides of candidateSidePairs(conn)) {
|
|
1110
|
+
const geometry = connectionGeometry(conn, sides);
|
|
1111
|
+
const routed = routedForGeometry(conn, others, geometry);
|
|
1112
|
+
planningMetrics.readabilityCandidateCount += 1;
|
|
1113
|
+
if (!routed.points.every(withinScene) || !routeIsClear(conn, routed, geometry, others)) continue;
|
|
1114
|
+
const cost = readabilityCost(conn, routed, others);
|
|
1115
|
+
if (cost < bestCost) {
|
|
1116
|
+
const rect = labelRectFor?.(conn, routed.points, {
|
|
1117
|
+
routes: [...others.map((other) => other.points), routed.points],
|
|
1118
|
+
labels: reservedLabels.filter((label) => label.conn !== conn).map((label) => label.rect),
|
|
1119
|
+
});
|
|
1120
|
+
// A shorter route must not expand the canvas and shrink every
|
|
1121
|
+
// label at the default viewport. Include its reserved label too.
|
|
1122
|
+
if (rect && (!withinScene([rect.x, rect.y]) || !withinScene([rect.x + rect.width, rect.y + rect.height]))) continue;
|
|
1123
|
+
best = { routed, sides, rect };
|
|
1124
|
+
bestCost = cost;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (!best) continue;
|
|
1128
|
+
cachePath(conn, best.routed, best.sides);
|
|
1129
|
+
entry.points = best.routed.points;
|
|
1130
|
+
planningMetrics.readabilityImprovedCount += 1;
|
|
1131
|
+
reservedLabels = reservedLabels.filter((label) => label.conn !== conn);
|
|
1132
|
+
if (best.rect) reservedLabels.push({ conn, rect: best.rect });
|
|
1133
|
+
}
|
|
1134
|
+
} finally {
|
|
1135
|
+
allowGridSearch = true;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
planningMetrics.routeCount = resolvedRoutes.length;
|
|
1139
|
+
planningMetrics.explicitRouteCount = explicit.length;
|
|
1140
|
+
planningMetrics.automaticRouteCount = automatic.length;
|
|
1141
|
+
routesPlanning = false;
|
|
1142
|
+
routesPlanned = true;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function pathFor(conn) {
|
|
1146
|
+
planRoutes();
|
|
1147
|
+
if (pathCache.has(conn)) return pathCache.get(conn);
|
|
1148
|
+
return computePath(conn, []);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function routingMetrics() {
|
|
1152
|
+
planRoutes();
|
|
1153
|
+
return { ...planningMetrics };
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
return { pathFor, connectionSides, connectionEndpointSide, routingMetrics };
|
|
1157
|
+
}
|