astro-archify 0.3.4 → 0.3.6
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 +70 -4
- package/package.json +6 -3
- package/vendor/archify/NOTICE.md +8 -5
- package/vendor/archify/assets/template.html +1 -1
- package/vendor/archify/renderers/architecture/render-architecture.mjs +1 -12
- package/vendor/archify/renderers/dataflow/render-dataflow.mjs +16 -15
- package/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +1 -10
- package/vendor/archify/renderers/sequence/render-sequence.mjs +1 -16
- package/vendor/archify/renderers/shared/cli.mjs +1 -3
- package/vendor/archify/renderers/shared/diagnostics.mjs +17 -6
- package/vendor/archify/renderers/shared/generated-validators.mjs +2 -2
- package/vendor/archify/renderers/shared/geometry.mjs +129 -40
- package/vendor/archify/renderers/workflow/render-workflow.mjs +21 -735
- package/vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- package/vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
|
@@ -0,0 +1,4400 @@
|
|
|
1
|
+
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
|
2
|
+
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
|
3
|
+
import {
|
|
4
|
+
throwDiagnosticError,
|
|
5
|
+
throwDiagnosticProblems,
|
|
6
|
+
withDiagnosticRecordingSuppressed,
|
|
7
|
+
} from '../shared/diagnostics.mjs';
|
|
8
|
+
import { validateSchema } from '../shared/validator.mjs';
|
|
9
|
+
import {
|
|
10
|
+
legendFootprint,
|
|
11
|
+
measureLegend,
|
|
12
|
+
relationshipLegendObstacles,
|
|
13
|
+
resolveLegend,
|
|
14
|
+
renderLegend as renderResolvedLegend,
|
|
15
|
+
} from '../shared/legend.mjs';
|
|
16
|
+
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
|
|
17
|
+
import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
|
18
|
+
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
|
19
|
+
import {
|
|
20
|
+
createMappedWorkflowCandidate,
|
|
21
|
+
intrinsicWorkflow,
|
|
22
|
+
planningWorkflow,
|
|
23
|
+
} from './workflow-migration-geometry.mjs';
|
|
24
|
+
import {
|
|
25
|
+
asArray,
|
|
26
|
+
isFinitePoint,
|
|
27
|
+
rectsOverlap,
|
|
28
|
+
segmentIntersectsRect,
|
|
29
|
+
segmentRectClearance,
|
|
30
|
+
cleanEndpointSideProblems,
|
|
31
|
+
cleanFlowProblems,
|
|
32
|
+
cleanCrossingProblems,
|
|
33
|
+
cleanAmbiguousCorridorProblems,
|
|
34
|
+
cleanBorderRunProblems,
|
|
35
|
+
cleanRouteRhythmProblems,
|
|
36
|
+
cleanLabelRouteClearanceProblems,
|
|
37
|
+
collectAmbiguousCorridors,
|
|
38
|
+
collectLabelRouteClearance,
|
|
39
|
+
collectBorderRuns,
|
|
40
|
+
forwardCollinearAnalysisSegments,
|
|
41
|
+
sourceSegmentIndexAtPoint,
|
|
42
|
+
suggestLabelObstacleFix,
|
|
43
|
+
suggestLabelPairFix,
|
|
44
|
+
anchor,
|
|
45
|
+
automaticPortSpread,
|
|
46
|
+
defaultFromSide,
|
|
47
|
+
defaultToSide,
|
|
48
|
+
chosenSide,
|
|
49
|
+
normalizeRoutePoints,
|
|
50
|
+
routeHonorsEndpointSides,
|
|
51
|
+
polylinePath,
|
|
52
|
+
routePointsValue,
|
|
53
|
+
labelPoint,
|
|
54
|
+
componentFill,
|
|
55
|
+
componentText,
|
|
56
|
+
arrowClassMap,
|
|
57
|
+
variantAccent
|
|
58
|
+
} from '../shared/geometry.mjs';
|
|
59
|
+
|
|
60
|
+
const LEGACY_COLUMN_CENTERS = Object.freeze([88, 220, 300, 430, 500, 625]);
|
|
61
|
+
const READABLE_CANDIDATE_COST_PRIORITY = Object.freeze([
|
|
62
|
+
'automaticForwardReversePx',
|
|
63
|
+
'properCrossingCount',
|
|
64
|
+
'sharedCorridorPx',
|
|
65
|
+
'labelRouteClearanceDeficit',
|
|
66
|
+
'interiorPreferred28Deficit',
|
|
67
|
+
'bendCount',
|
|
68
|
+
'stretchMilli',
|
|
69
|
+
'canvasGrowthPx',
|
|
70
|
+
'portDisplacementMilli',
|
|
71
|
+
'legacyCoordinateDisplacement',
|
|
72
|
+
'stableCandidateOrdinal',
|
|
73
|
+
]);
|
|
74
|
+
const MAX_READABLE_LAYOUT_FEEDBACK_ROUNDS = 3;
|
|
75
|
+
const GROUP_FRAME_TOP_INSET = 8;
|
|
76
|
+
const GROUP_FRAME_BOTTOM_INSET = 4;
|
|
77
|
+
const GROUP_LABEL_BASELINE_OFFSET = -2;
|
|
78
|
+
const GROUP_LABEL_MASK_ASCENT = 10;
|
|
79
|
+
const GROUP_LABEL_MASK_H = 14;
|
|
80
|
+
const GROUP_NODE_INSET = 4;
|
|
81
|
+
|
|
82
|
+
class WorkflowLayoutFeedback extends Error {
|
|
83
|
+
constructor(request) {
|
|
84
|
+
super(`Workflow layout requires ${request.kind} feedback.`);
|
|
85
|
+
this.name = 'WorkflowLayoutFeedback';
|
|
86
|
+
this.request = request;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function createLegacyLayout() {
|
|
91
|
+
return {
|
|
92
|
+
contract: 'fixed-v1',
|
|
93
|
+
laneX: 40,
|
|
94
|
+
laneY: 52,
|
|
95
|
+
laneW: 640,
|
|
96
|
+
laneH: 104,
|
|
97
|
+
laneGap: 20,
|
|
98
|
+
laneTitleH: 30,
|
|
99
|
+
colXs: [...LEGACY_COLUMN_CENTERS],
|
|
100
|
+
nodeW: 92,
|
|
101
|
+
nodeH: 52,
|
|
102
|
+
defaultViewBoxWidth: 720,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function authoredNodeWidth(node) {
|
|
107
|
+
return Number.isFinite(node?.width) ? node.width : 92;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function nodeWidthContributor(node) {
|
|
111
|
+
return `node ${node.id} width ${authoredNodeWidth(node)}px`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function authoredNodeHeight(node) {
|
|
115
|
+
if (Number.isFinite(node?.height)) return node.height;
|
|
116
|
+
return node?.tag ? 68 : 52;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function workflowLabelWidth(label) {
|
|
120
|
+
return Math.max(30, textUnits(label) * 4.8 + 10);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readableGroupBounds(workflow, group, colXs) {
|
|
124
|
+
if (!Number.isInteger(group.fromCol) || !Number.isInteger(group.toCol)
|
|
125
|
+
|| group.fromCol < 0 || group.fromCol > group.toCol || group.toCol >= colXs.length) {
|
|
126
|
+
return { x: 0, width: 0, cx: 0 };
|
|
127
|
+
}
|
|
128
|
+
const start = colXs[group.fromCol] - 50;
|
|
129
|
+
const end = colXs[group.toCol] + 50;
|
|
130
|
+
const naturalWidth = end - start;
|
|
131
|
+
const minimumWidth = textUnits(group.label) * 5.6 + 20;
|
|
132
|
+
let width = Math.max(naturalWidth, minimumWidth);
|
|
133
|
+
let left = group.fromCol === group.toCol && width > naturalWidth
|
|
134
|
+
? start
|
|
135
|
+
: (start + end - width) / 2;
|
|
136
|
+
let right = left + width;
|
|
137
|
+
for (const node of asArray(workflow.nodes)) {
|
|
138
|
+
if (node.lane !== group.lane
|
|
139
|
+
|| !Number.isInteger(node.col)
|
|
140
|
+
|| node.col < group.fromCol
|
|
141
|
+
|| node.col > group.toCol
|
|
142
|
+
|| node.col < 0
|
|
143
|
+
|| node.col >= colXs.length) continue;
|
|
144
|
+
const halfWidth = authoredNodeWidth(node) / 2;
|
|
145
|
+
left = Math.min(left, colXs[node.col] - halfWidth - GROUP_NODE_INSET);
|
|
146
|
+
right = Math.max(right, colXs[node.col] + halfWidth + GROUP_NODE_INSET);
|
|
147
|
+
}
|
|
148
|
+
width = right - left;
|
|
149
|
+
return { x: left, width, cx: left + width / 2 };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function verticalIntervalsOverlap(a, b, clearance = 0) {
|
|
153
|
+
const aCenter = Number(a?.yOffset) || 0;
|
|
154
|
+
const bCenter = Number(b?.yOffset) || 0;
|
|
155
|
+
return Math.abs(aCenter - bCenter)
|
|
156
|
+
< authoredNodeHeight(a) / 2 + authoredNodeHeight(b) / 2 + clearance;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function createReadableLayout(workflow, layoutFeedback = {}) {
|
|
160
|
+
const columnCount = 6;
|
|
161
|
+
const baselinePitch = 120;
|
|
162
|
+
const columnStart = 94;
|
|
163
|
+
const maxLayoutIterations = 3;
|
|
164
|
+
const channelDetourBudgetPx = 4 * 28;
|
|
165
|
+
const constraints = [];
|
|
166
|
+
const feedbackConstraints = [];
|
|
167
|
+
const channelLabelEdgeKeys = new Set();
|
|
168
|
+
const widthContributors = new Set();
|
|
169
|
+
const heightContributors = new Set();
|
|
170
|
+
const nodes = asArray(workflow.nodes);
|
|
171
|
+
const nodesById = new Map(nodes.map((node) => [node.id, node]));
|
|
172
|
+
|
|
173
|
+
for (let col = 0; col < columnCount - 1; col += 1) {
|
|
174
|
+
constraints.push({ from: col, to: col + 1, minimum: baselinePitch });
|
|
175
|
+
}
|
|
176
|
+
for (const [key, minimum] of Object.entries(layoutFeedback.rankGapMinimums || {}).sort()) {
|
|
177
|
+
const [from, to] = key.split(':').map(Number);
|
|
178
|
+
constraints.push({
|
|
179
|
+
from,
|
|
180
|
+
to,
|
|
181
|
+
minimum,
|
|
182
|
+
contributors: layoutFeedback.rankGapContributors?.[key]
|
|
183
|
+
|| [`rank ${from}→${to} route clearance`],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
|
|
188
|
+
for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) {
|
|
189
|
+
const leftNode = nodes[leftIndex];
|
|
190
|
+
const rightNode = nodes[rightIndex];
|
|
191
|
+
if (leftNode.lane !== rightNode.lane || leftNode.col === rightNode.col) continue;
|
|
192
|
+
if (!verticalIntervalsOverlap(leftNode, rightNode, 8)) continue;
|
|
193
|
+
const fromNode = leftNode.col < rightNode.col ? leftNode : rightNode;
|
|
194
|
+
const toNode = fromNode === leftNode ? rightNode : leftNode;
|
|
195
|
+
constraints.push({
|
|
196
|
+
from: fromNode.col,
|
|
197
|
+
to: toNode.col,
|
|
198
|
+
minimum: authoredNodeWidth(fromNode) / 2 + 8 + authoredNodeWidth(toNode) / 2,
|
|
199
|
+
contributors: [
|
|
200
|
+
`rank ${fromNode.col}→${toNode.col} node width clearance`,
|
|
201
|
+
nodeWidthContributor(fromNode),
|
|
202
|
+
nodeWidthContributor(toNode),
|
|
203
|
+
],
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const edge of asArray(workflow.edges)) {
|
|
209
|
+
const fromNode = nodesById.get(edge.from);
|
|
210
|
+
const toNode = nodesById.get(edge.to);
|
|
211
|
+
if (!fromNode || !toNode || fromNode.lane !== toNode.lane || fromNode.col === toNode.col) continue;
|
|
212
|
+
if (edge.via || edge.channelX !== undefined || edge.channelY !== undefined
|
|
213
|
+
|| !['auto', 'straight'].includes(edge.route || 'auto')) continue;
|
|
214
|
+
if ((Number(fromNode.yOffset) || 0) !== (Number(toNode.yOffset) || 0)) continue;
|
|
215
|
+
const earlier = fromNode.col < toNode.col ? fromNode : toNode;
|
|
216
|
+
const later = earlier === fromNode ? toNode : fromNode;
|
|
217
|
+
const labeledDirectClearance = edge.label && !edge.labelAt
|
|
218
|
+
? Math.max(28, workflowLabelWidth(edge.label) + 8)
|
|
219
|
+
: 28;
|
|
220
|
+
const directLabelExpansionCost = Math.max(0, labeledDirectClearance - 28);
|
|
221
|
+
const canUseAutomaticLabelChannel = edge.label
|
|
222
|
+
&& !edge.labelAt
|
|
223
|
+
&& (edge.route || 'auto') === 'auto'
|
|
224
|
+
&& !edge.fromSide
|
|
225
|
+
&& !edge.toSide
|
|
226
|
+
&& edge.channelX === undefined
|
|
227
|
+
&& edge.channelY === undefined;
|
|
228
|
+
const preferLabelChannel = canUseAutomaticLabelChannel
|
|
229
|
+
&& directLabelExpansionCost > channelDetourBudgetPx;
|
|
230
|
+
if (preferLabelChannel) channelLabelEdgeKeys.add(stableValueKey(edge));
|
|
231
|
+
constraints.push({
|
|
232
|
+
from: earlier.col,
|
|
233
|
+
to: later.col,
|
|
234
|
+
minimum: authoredNodeWidth(earlier) / 2 + 28 + authoredNodeWidth(later) / 2,
|
|
235
|
+
contributors: [
|
|
236
|
+
`rank ${earlier.col}→${later.col} direct clearance`,
|
|
237
|
+
`rank ${earlier.col}→${later.col} node width clearance`,
|
|
238
|
+
nodeWidthContributor(earlier),
|
|
239
|
+
nodeWidthContributor(later),
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
if (!preferLabelChannel && labeledDirectClearance > 28) {
|
|
243
|
+
const labelConstraintMinimum = authoredNodeWidth(earlier) / 2
|
|
244
|
+
+ labeledDirectClearance
|
|
245
|
+
+ authoredNodeWidth(later) / 2;
|
|
246
|
+
feedbackConstraints.push({
|
|
247
|
+
from: earlier.col,
|
|
248
|
+
to: later.col,
|
|
249
|
+
minimum: labelConstraintMinimum,
|
|
250
|
+
contributors: [
|
|
251
|
+
`rank ${earlier.col}→${later.col} direct clearance`,
|
|
252
|
+
`edge ${workflowEdgeName(edge)} label mask`,
|
|
253
|
+
nodeWidthContributor(earlier),
|
|
254
|
+
nodeWidthContributor(later),
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const phase of asArray(workflow.phases)) {
|
|
261
|
+
if (!Number.isInteger(phase.fromCol) || !Number.isInteger(phase.toCol)
|
|
262
|
+
|| phase.fromCol < 0 || phase.fromCol > phase.toCol || phase.toCol >= columnCount) continue;
|
|
263
|
+
const minimumWidth = textUnits(phase.label) * 5.6 + 8;
|
|
264
|
+
if (phase.fromCol === phase.toCol) {
|
|
265
|
+
if (phase.toCol < columnCount - 1) {
|
|
266
|
+
constraints.push({
|
|
267
|
+
from: phase.toCol,
|
|
268
|
+
to: phase.toCol + 1,
|
|
269
|
+
minimum: baselinePitch + Math.max(0, minimumWidth - 92),
|
|
270
|
+
contributors: [`phase ${phase.id || phase.label} label span`],
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
constraints.push({
|
|
276
|
+
from: phase.fromCol,
|
|
277
|
+
to: phase.toCol,
|
|
278
|
+
minimum: Math.max(0, minimumWidth - 92),
|
|
279
|
+
contributors: [`phase ${phase.id || phase.label} label span`],
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const group of asArray(workflow.groups)) {
|
|
284
|
+
if (!Number.isInteger(group.fromCol) || !Number.isInteger(group.toCol)
|
|
285
|
+
|| group.fromCol < 0 || group.fromCol > group.toCol || group.toCol >= columnCount) continue;
|
|
286
|
+
const minimumWidth = textUnits(group.label) * 5.6 + 20;
|
|
287
|
+
if (group.fromCol === group.toCol) {
|
|
288
|
+
if (group.toCol < columnCount - 1) {
|
|
289
|
+
constraints.push({
|
|
290
|
+
from: group.toCol,
|
|
291
|
+
to: group.toCol + 1,
|
|
292
|
+
minimum: baselinePitch + Math.max(0, minimumWidth - 100),
|
|
293
|
+
contributors: [`group ${group.id || group.label} label span`],
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
constraints.push({
|
|
299
|
+
from: group.fromCol,
|
|
300
|
+
to: group.toCol,
|
|
301
|
+
minimum: Math.max(0, minimumWidth - 100),
|
|
302
|
+
contributors: [`group ${group.id || group.label} label span`],
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let activeConstraints = [...constraints];
|
|
307
|
+
let colXs;
|
|
308
|
+
let colProvenance;
|
|
309
|
+
for (let iteration = 0; iteration < maxLayoutIterations; iteration += 1) {
|
|
310
|
+
colXs = Array.from({ length: columnCount }, (_, col) => columnStart + col * baselinePitch);
|
|
311
|
+
colProvenance = Array.from({ length: columnCount }, () => new Set());
|
|
312
|
+
const orderedConstraints = activeConstraints
|
|
313
|
+
.filter(({ from, to, minimum }) => (
|
|
314
|
+
Number.isInteger(from) && Number.isInteger(to)
|
|
315
|
+
&& from >= 0 && from < to && to < columnCount
|
|
316
|
+
&& Number.isFinite(minimum)
|
|
317
|
+
))
|
|
318
|
+
.sort((a, b) => a.to - b.to || a.from - b.from || a.minimum - b.minimum);
|
|
319
|
+
for (let to = 1; to < columnCount; to += 1) {
|
|
320
|
+
for (const constraint of orderedConstraints) {
|
|
321
|
+
if (constraint.to !== to) continue;
|
|
322
|
+
const candidate = colXs[constraint.from] + constraint.minimum;
|
|
323
|
+
const candidateProvenance = new Set([
|
|
324
|
+
...colProvenance[constraint.from],
|
|
325
|
+
...asArray(constraint.contributors),
|
|
326
|
+
]);
|
|
327
|
+
if (candidate > colXs[to] + 0.0001) {
|
|
328
|
+
colXs[to] = candidate;
|
|
329
|
+
colProvenance[to] = candidateProvenance;
|
|
330
|
+
} else if (Math.abs(candidate - colXs[to]) <= 0.0001
|
|
331
|
+
&& candidate > columnStart + to * baselinePitch + 0.0001) {
|
|
332
|
+
for (const contributor of candidateProvenance) colProvenance[to].add(contributor);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (iteration > 0 || !feedbackConstraints.length) break;
|
|
337
|
+
activeConstraints = [...activeConstraints, ...feedbackConstraints];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const firstRankNodes = nodes.filter((node) => node.col === 0);
|
|
341
|
+
const firstExtent = firstRankNodes.reduce(
|
|
342
|
+
(maximum, node) => Math.max(maximum, authoredNodeWidth(node) / 2),
|
|
343
|
+
46,
|
|
344
|
+
);
|
|
345
|
+
const leftInset = 8;
|
|
346
|
+
const leftShift = Math.max(0, 40 + leftInset + firstExtent - colXs[0]);
|
|
347
|
+
if (leftShift) {
|
|
348
|
+
for (let col = 0; col < colXs.length; col += 1) colXs[col] += leftShift;
|
|
349
|
+
for (const node of firstRankNodes) {
|
|
350
|
+
if (Math.abs(authoredNodeWidth(node) / 2 - firstExtent) > 0.0001) continue;
|
|
351
|
+
for (const provenance of colProvenance) provenance.add(nodeWidthContributor(node));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const unpinnedTopEndpointIds = new Set();
|
|
356
|
+
for (const edge of asArray(workflow.edges)) {
|
|
357
|
+
const preservesHorizontalPins = Array.isArray(edge.via) || edge.channelX !== undefined;
|
|
358
|
+
if (preservesHorizontalPins) continue;
|
|
359
|
+
if (edge.fromSide === 'top') unpinnedTopEndpointIds.add(edge.from);
|
|
360
|
+
if (edge.toSide === 'top') unpinnedTopEndpointIds.add(edge.to);
|
|
361
|
+
}
|
|
362
|
+
const laneOrder = new Map(asArray(workflow.lanes).map((lane, index) => [lane.id, index]));
|
|
363
|
+
let laneHeaderShift = 0;
|
|
364
|
+
const laneHeaderShiftContributors = new Set();
|
|
365
|
+
for (const nodeId of unpinnedTopEndpointIds) {
|
|
366
|
+
const node = nodesById.get(nodeId);
|
|
367
|
+
if (!node || !Number.isInteger(node.col) || node.col < 0 || node.col >= columnCount) continue;
|
|
368
|
+
const lanePosition = laneOrder.get(node.lane);
|
|
369
|
+
const lane = asArray(workflow.lanes)[lanePosition];
|
|
370
|
+
if (!lane) continue;
|
|
371
|
+
const prefix = lane.variant === 'exception'
|
|
372
|
+
? 'EX'
|
|
373
|
+
: String(lanePosition + 1).padStart(2, '0');
|
|
374
|
+
const laneHeaderRight = 40 + 14 + textUnits(`${prefix} / ${lane.label}`) * 6.2;
|
|
375
|
+
const requiredShift = laneHeaderRight + 2 - colXs[node.col];
|
|
376
|
+
if (requiredShift > laneHeaderShift + 0.0001) {
|
|
377
|
+
laneHeaderShift = requiredShift;
|
|
378
|
+
laneHeaderShiftContributors.clear();
|
|
379
|
+
laneHeaderShiftContributors.add(`lane ${lane.id} label width`);
|
|
380
|
+
} else if (requiredShift > 0 && Math.abs(requiredShift - laneHeaderShift) <= 0.0001) {
|
|
381
|
+
laneHeaderShiftContributors.add(`lane ${lane.id} label width`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (laneHeaderShift > 0) {
|
|
385
|
+
for (let col = 0; col < colXs.length; col += 1) colXs[col] += laneHeaderShift;
|
|
386
|
+
for (const provenance of colProvenance) {
|
|
387
|
+
for (const contributor of laneHeaderShiftContributors) provenance.add(contributor);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let measuredContentLeftShift = asArray(workflow.edges).reduce((maximum, edge) => {
|
|
392
|
+
if (!channelLabelEdgeKeys.has(stableValueKey(edge))) return maximum;
|
|
393
|
+
const fromNode = nodesById.get(edge.from);
|
|
394
|
+
const toNode = nodesById.get(edge.to);
|
|
395
|
+
if (!fromNode || !toNode) return maximum;
|
|
396
|
+
const labelCenter = (colXs[fromNode.col] + colXs[toNode.col]) / 2;
|
|
397
|
+
const labelLeft = labelCenter - workflowLabelWidth(edge.label) / 2;
|
|
398
|
+
return Math.max(maximum, 16 - labelLeft);
|
|
399
|
+
}, 0);
|
|
400
|
+
for (const phase of asArray(workflow.phases)) {
|
|
401
|
+
if (!Number.isInteger(phase.fromCol) || !Number.isInteger(phase.toCol)) continue;
|
|
402
|
+
const width = Math.max(
|
|
403
|
+
colXs[phase.toCol] - colXs[phase.fromCol] + 92,
|
|
404
|
+
textUnits(phase.label) * 5.6 + 8,
|
|
405
|
+
);
|
|
406
|
+
const left = phase.fromCol === phase.toCol
|
|
407
|
+
? colXs[phase.fromCol] - 46
|
|
408
|
+
: (colXs[phase.fromCol] + colXs[phase.toCol] - width) / 2;
|
|
409
|
+
measuredContentLeftShift = Math.max(measuredContentLeftShift, 16 - left);
|
|
410
|
+
}
|
|
411
|
+
for (const group of asArray(workflow.groups)) {
|
|
412
|
+
if (!Number.isInteger(group.fromCol) || !Number.isInteger(group.toCol)) continue;
|
|
413
|
+
const bounds = readableGroupBounds(workflow, group, colXs);
|
|
414
|
+
measuredContentLeftShift = Math.max(measuredContentLeftShift, 44 - bounds.x);
|
|
415
|
+
}
|
|
416
|
+
if (measuredContentLeftShift > 0) {
|
|
417
|
+
for (let col = 0; col < colXs.length; col += 1) colXs[col] += measuredContentLeftShift;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let rightmost = colXs.at(-1) + 50;
|
|
421
|
+
let rightmostContributors = new Set(colProvenance.at(-1));
|
|
422
|
+
for (const node of nodes) {
|
|
423
|
+
if (!Number.isInteger(node.col) || node.col < 0 || node.col >= columnCount) continue;
|
|
424
|
+
const nodeRight = colXs[node.col] + authoredNodeWidth(node) / 2;
|
|
425
|
+
const nodeContributors = new Set([
|
|
426
|
+
...colProvenance[node.col],
|
|
427
|
+
nodeWidthContributor(node),
|
|
428
|
+
]);
|
|
429
|
+
if (nodeRight > rightmost + 0.0001) {
|
|
430
|
+
rightmost = nodeRight;
|
|
431
|
+
rightmostContributors = nodeContributors;
|
|
432
|
+
} else if (Math.abs(nodeRight - rightmost) <= 0.0001) {
|
|
433
|
+
for (const contributor of nodeContributors) rightmostContributors.add(contributor);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
for (const group of asArray(workflow.groups)) {
|
|
437
|
+
if (!Number.isInteger(group.fromCol) || !Number.isInteger(group.toCol)) continue;
|
|
438
|
+
const bounds = readableGroupBounds(workflow, group, colXs);
|
|
439
|
+
const groupRight = bounds.x + bounds.width;
|
|
440
|
+
const groupContributors = new Set([
|
|
441
|
+
...colProvenance[group.fromCol],
|
|
442
|
+
...colProvenance[group.toCol],
|
|
443
|
+
`group ${group.id || group.label} label span`,
|
|
444
|
+
...nodes
|
|
445
|
+
.filter((node) => node.lane === group.lane
|
|
446
|
+
&& node.col >= group.fromCol && node.col <= group.toCol)
|
|
447
|
+
.map(nodeWidthContributor),
|
|
448
|
+
]);
|
|
449
|
+
if (groupRight > rightmost + 0.0001) {
|
|
450
|
+
rightmost = groupRight;
|
|
451
|
+
rightmostContributors = groupContributors;
|
|
452
|
+
} else if (Math.abs(groupRight - rightmost) <= 0.0001) {
|
|
453
|
+
for (const contributor of groupContributors) rightmostContributors.add(contributor);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const widestLaneLabel = asArray(workflow.lanes).reduce((widest, lane, index) => {
|
|
457
|
+
const width = textUnits(`${String(index + 1).padStart(2, '0')} / ${lane.label}`) * 6.2 + 30;
|
|
458
|
+
return width > widest.width ? { width, lane } : widest;
|
|
459
|
+
}, { width: 0, lane: null });
|
|
460
|
+
const laneLabelWidth = widestLaneLabel.width;
|
|
461
|
+
const rightmostLaneWidth = Math.ceil(rightmost - 40 + 8);
|
|
462
|
+
const laneW = Math.max(
|
|
463
|
+
640,
|
|
464
|
+
rightmostLaneWidth,
|
|
465
|
+
Math.ceil(laneLabelWidth),
|
|
466
|
+
);
|
|
467
|
+
if (laneW > 640) {
|
|
468
|
+
if (rightmostLaneWidth === laneW) {
|
|
469
|
+
for (const contributor of rightmostContributors) widthContributors.add(contributor);
|
|
470
|
+
}
|
|
471
|
+
if (Math.ceil(laneLabelWidth) === laneW && widestLaneLabel.lane) {
|
|
472
|
+
widthContributors.add(`lane ${widestLaneLabel.lane.id || widestLaneLabel.lane.label} label width`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
let maxVerticalExtent = 0;
|
|
476
|
+
const verticalExtentContributors = new Set();
|
|
477
|
+
for (const node of nodes) {
|
|
478
|
+
const yOffset = Number(node.yOffset) || 0;
|
|
479
|
+
const extent = authoredNodeHeight(node) / 2 + Math.abs(yOffset);
|
|
480
|
+
const contributor = `node ${node.id} height ${authoredNodeHeight(node)}px${yOffset ? ` with yOffset ${yOffset}px` : ''}`;
|
|
481
|
+
if (extent > maxVerticalExtent + 0.0001) {
|
|
482
|
+
maxVerticalExtent = extent;
|
|
483
|
+
verticalExtentContributors.clear();
|
|
484
|
+
verticalExtentContributors.add(contributor);
|
|
485
|
+
} else if (Math.abs(extent - maxVerticalExtent) <= 0.0001) {
|
|
486
|
+
verticalExtentContributors.add(contributor);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const baseContentH = Math.max(74, Math.ceil(maxVerticalExtent * 2 + 8));
|
|
490
|
+
const laneH = 30 + baseContentH;
|
|
491
|
+
const groupsByLane = new Map();
|
|
492
|
+
for (const group of asArray(workflow.groups)) {
|
|
493
|
+
groupsByLane.set(group.lane, [...(groupsByLane.get(group.lane) || []), group]);
|
|
494
|
+
}
|
|
495
|
+
const groupLaneReserves = asArray(workflow.lanes).map((lane) => {
|
|
496
|
+
let header = 0;
|
|
497
|
+
let footer = 0;
|
|
498
|
+
for (const group of groupsByLane.get(lane.id) || []) {
|
|
499
|
+
const bounds = readableGroupBounds(workflow, group, colXs);
|
|
500
|
+
const labelLeft = bounds.x + 10;
|
|
501
|
+
const labelRight = labelLeft + textUnits(group.label) * 5.6;
|
|
502
|
+
for (const node of nodes) {
|
|
503
|
+
if (node.lane !== group.lane
|
|
504
|
+
|| !Number.isInteger(node.col)
|
|
505
|
+
|| node.col < group.fromCol
|
|
506
|
+
|| node.col > group.toCol
|
|
507
|
+
|| node.col < 0
|
|
508
|
+
|| node.col >= colXs.length) continue;
|
|
509
|
+
const halfWidth = authoredNodeWidth(node) / 2;
|
|
510
|
+
const nodeLeft = colXs[node.col] - halfWidth;
|
|
511
|
+
const nodeRight = colXs[node.col] + halfWidth;
|
|
512
|
+
const overlapsLabel = nodeRight > labelLeft && nodeLeft < labelRight;
|
|
513
|
+
const topOffset = (baseContentH - authoredNodeHeight(node)) / 2
|
|
514
|
+
+ (Number(node.yOffset) || 0);
|
|
515
|
+
const minimumTopOffset = overlapsLabel ? 11 : 9;
|
|
516
|
+
header = Math.max(header, Math.ceil(minimumTopOffset - topOffset));
|
|
517
|
+
const bottomMargin = baseContentH - GROUP_FRAME_BOTTOM_INSET
|
|
518
|
+
- topOffset - authoredNodeHeight(node);
|
|
519
|
+
footer = Math.max(footer, Math.ceil(1 - bottomMargin));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return { header: Math.max(0, header), footer: Math.max(0, footer) };
|
|
523
|
+
});
|
|
524
|
+
const groupHeaderHeights = groupLaneReserves.map(({ header }) => header);
|
|
525
|
+
const groupFooterHeights = groupLaneReserves.map(({ footer }) => footer);
|
|
526
|
+
const laneHeights = groupLaneReserves.map(({ header, footer }) => laneH + header + footer);
|
|
527
|
+
const laneGap = Math.max(20, Math.ceil(layoutFeedback.laneGapMin || 0));
|
|
528
|
+
for (const [index, reserve] of groupHeaderHeights.entries()) {
|
|
529
|
+
if (!reserve) continue;
|
|
530
|
+
const lane = asArray(workflow.lanes)[index];
|
|
531
|
+
heightContributors.add(`lane ${lane.id || lane.label} group label clearance ${reserve}px`);
|
|
532
|
+
}
|
|
533
|
+
for (const [index, reserve] of groupFooterHeights.entries()) {
|
|
534
|
+
if (!reserve) continue;
|
|
535
|
+
const lane = asArray(workflow.lanes)[index];
|
|
536
|
+
heightContributors.add(`lane ${lane.id || lane.label} group frame containment ${reserve}px`);
|
|
537
|
+
}
|
|
538
|
+
if (laneH > 104) {
|
|
539
|
+
for (const contributor of verticalExtentContributors) heightContributors.add(contributor);
|
|
540
|
+
}
|
|
541
|
+
if (laneGap > 20) {
|
|
542
|
+
for (const contributor of asArray(layoutFeedback.laneGapContributors)) {
|
|
543
|
+
heightContributors.add(contributor);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const requiredWidth = 40 + laneW + 16;
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
contract: 'readable-v2',
|
|
550
|
+
laneX: 40,
|
|
551
|
+
laneY: 52,
|
|
552
|
+
laneW,
|
|
553
|
+
laneH,
|
|
554
|
+
laneHeights,
|
|
555
|
+
laneGap,
|
|
556
|
+
laneTitleH: 30,
|
|
557
|
+
groupHeaderHeights,
|
|
558
|
+
groupFooterHeights,
|
|
559
|
+
colXs,
|
|
560
|
+
nodeW: 92,
|
|
561
|
+
nodeH: 52,
|
|
562
|
+
defaultViewBoxWidth: requiredWidth,
|
|
563
|
+
channelLabelEdgeKeys,
|
|
564
|
+
widthContributors: [...widthContributors].sort(stableCompare),
|
|
565
|
+
heightContributors: [...heightContributors].sort(stableCompare),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function compilerFailure(contract, diagnostics, error = diagnostics.map(({ message }) => message).join('\n')) {
|
|
570
|
+
return {
|
|
571
|
+
ok: false,
|
|
572
|
+
error,
|
|
573
|
+
diagnostics,
|
|
574
|
+
receipt: { contract, diagnostics },
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function workflowEdgeName(edge) {
|
|
579
|
+
return edge.id || `${edge.from}->${edge.to}`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function stableText(value) {
|
|
583
|
+
return value == null ? '' : String(value);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function stableCompare(left, right) {
|
|
587
|
+
const a = stableText(left);
|
|
588
|
+
const b = stableText(right);
|
|
589
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function stableValueKey(value) {
|
|
593
|
+
if (Array.isArray(value)) return `[${value.map(stableValueKey).join(',')}]`;
|
|
594
|
+
if (value && typeof value === 'object') {
|
|
595
|
+
return `{${Object.keys(value).sort(stableCompare).map((key) => `${JSON.stringify(key)}:${stableValueKey(value[key])}`).join(',')}}`;
|
|
596
|
+
}
|
|
597
|
+
return JSON.stringify(value);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function cloneWorkflow(value) {
|
|
601
|
+
return JSON.parse(JSON.stringify(value));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function canonicalReadableWorkflow(workflow) {
|
|
605
|
+
if (workflow.schema_version !== 2) return workflow;
|
|
606
|
+
const laneOrder = new Map(asArray(workflow.lanes).map((lane, index) => [lane.id, index]));
|
|
607
|
+
const nodes = [...asArray(workflow.nodes)].sort((left, right) => (
|
|
608
|
+
(laneOrder.get(left.lane) ?? Number.MAX_SAFE_INTEGER) - (laneOrder.get(right.lane) ?? Number.MAX_SAFE_INTEGER)
|
|
609
|
+
|| left.col - right.col
|
|
610
|
+
|| stableCompare(left.id, right.id)
|
|
611
|
+
));
|
|
612
|
+
const edges = [...asArray(workflow.edges)].sort((left, right) => (
|
|
613
|
+
stableCompare(left.id, right.id)
|
|
614
|
+
|| stableCompare(left.from, right.from)
|
|
615
|
+
|| stableCompare(left.to, right.to)
|
|
616
|
+
|| stableCompare(left.label, right.label)
|
|
617
|
+
|| stableCompare(left.route, right.route)
|
|
618
|
+
|| stableCompare(stableValueKey(left), stableValueKey(right))
|
|
619
|
+
));
|
|
620
|
+
const phases = workflow.phases === undefined ? undefined : [...asArray(workflow.phases)].sort((left, right) => (
|
|
621
|
+
left.fromCol - right.fromCol || left.toCol - right.toCol
|
|
622
|
+
|| stableCompare(left.id, right.id)
|
|
623
|
+
));
|
|
624
|
+
const groups = workflow.groups === undefined ? undefined : [...asArray(workflow.groups)].sort((left, right) => (
|
|
625
|
+
(laneOrder.get(left.lane) ?? Number.MAX_SAFE_INTEGER) - (laneOrder.get(right.lane) ?? Number.MAX_SAFE_INTEGER)
|
|
626
|
+
|| left.fromCol - right.fromCol || left.toCol - right.toCol
|
|
627
|
+
|| stableCompare(left.id, right.id)
|
|
628
|
+
));
|
|
629
|
+
return {
|
|
630
|
+
...workflow,
|
|
631
|
+
nodes,
|
|
632
|
+
edges,
|
|
633
|
+
...(phases ? { phases } : {}),
|
|
634
|
+
...(groups ? { groups } : {}),
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function semanticContractDiagnostics(workflow) {
|
|
639
|
+
const checks = workflow.semanticChecks;
|
|
640
|
+
if (!checks) return [];
|
|
641
|
+
|
|
642
|
+
const nodeIds = new Set(asArray(workflow.nodes).map((node) => node.id));
|
|
643
|
+
const incoming = new Map([...nodeIds].map((id) => [id, 0]));
|
|
644
|
+
const outgoing = new Map([...nodeIds].map((id) => [id, 0]));
|
|
645
|
+
const adjacency = new Map([...nodeIds].map((id) => [id, new Set()]));
|
|
646
|
+
for (const edge of asArray(workflow.edges)) {
|
|
647
|
+
if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) continue;
|
|
648
|
+
outgoing.set(edge.from, outgoing.get(edge.from) + 1);
|
|
649
|
+
incoming.set(edge.to, incoming.get(edge.to) + 1);
|
|
650
|
+
adjacency.get(edge.from).add(edge.to);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const diagnostics = [];
|
|
654
|
+
const diagnostic = (code, message, subject, evidence, supportedFixes) => ({
|
|
655
|
+
code,
|
|
656
|
+
severity: 'error',
|
|
657
|
+
message,
|
|
658
|
+
subject: { diagramType: 'workflow', ...subject },
|
|
659
|
+
evidence,
|
|
660
|
+
supportedFixes,
|
|
661
|
+
});
|
|
662
|
+
const referencedNodes = [
|
|
663
|
+
...asArray(checks.allowedRoots).map((id, index) => ({ id, path: `/semanticChecks/allowedRoots/${index}` })),
|
|
664
|
+
...asArray(checks.allowedTerminals).map((id, index) => ({ id, path: `/semanticChecks/allowedTerminals/${index}` })),
|
|
665
|
+
...asArray(checks.requiredEdges).flatMap((relation, index) => [
|
|
666
|
+
{ id: relation.from, path: `/semanticChecks/requiredEdges/${index}/from` },
|
|
667
|
+
{ id: relation.to, path: `/semanticChecks/requiredEdges/${index}/to` },
|
|
668
|
+
]),
|
|
669
|
+
...asArray(checks.requiredPaths).flatMap((relation, index) => [
|
|
670
|
+
{ id: relation.from, path: `/semanticChecks/requiredPaths/${index}/from` },
|
|
671
|
+
{ id: relation.to, path: `/semanticChecks/requiredPaths/${index}/to` },
|
|
672
|
+
]),
|
|
673
|
+
];
|
|
674
|
+
for (const { id, path } of referencedNodes) {
|
|
675
|
+
if (nodeIds.has(id)) continue;
|
|
676
|
+
diagnostics.push(diagnostic(
|
|
677
|
+
'workflow/semantic-node-reference',
|
|
678
|
+
`Workflow semantic contract references unknown node "${id}" at ${path}.`,
|
|
679
|
+
{ node: id, path },
|
|
680
|
+
{ knownNodes: [...nodeIds] },
|
|
681
|
+
[`replace "${id}" with an existing node id`, 'add the missing node before compiling'],
|
|
682
|
+
));
|
|
683
|
+
}
|
|
684
|
+
if (diagnostics.length) return diagnostics;
|
|
685
|
+
|
|
686
|
+
if (checks.allowedRoots !== undefined) {
|
|
687
|
+
const allowed = new Set(checks.allowedRoots);
|
|
688
|
+
for (const [node, count] of incoming) {
|
|
689
|
+
if (count > 0 || allowed.has(node)) continue;
|
|
690
|
+
diagnostics.push(diagnostic(
|
|
691
|
+
'workflow/unexpected-root',
|
|
692
|
+
`Workflow node "${node}" has no incoming edge and is not declared in semanticChecks.allowedRoots.`,
|
|
693
|
+
{ node, path: '/semanticChecks/allowedRoots' },
|
|
694
|
+
{ incomingEdges: 0, allowedRoots: [...allowed] },
|
|
695
|
+
[`add the missing incoming edge to "${node}"`, `declare "${node}" in semanticChecks.allowedRoots if it is an intentional source`],
|
|
696
|
+
));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (checks.allowedTerminals !== undefined) {
|
|
701
|
+
const allowed = new Set(checks.allowedTerminals);
|
|
702
|
+
for (const [node, count] of outgoing) {
|
|
703
|
+
if (count > 0 || allowed.has(node)) continue;
|
|
704
|
+
diagnostics.push(diagnostic(
|
|
705
|
+
'workflow/unexpected-terminal',
|
|
706
|
+
`Workflow node "${node}" has no outgoing edge and is not declared in semanticChecks.allowedTerminals.`,
|
|
707
|
+
{ node, path: '/semanticChecks/allowedTerminals' },
|
|
708
|
+
{ outgoingEdges: 0, allowedTerminals: [...allowed] },
|
|
709
|
+
[`add the missing outgoing edge from "${node}"`, `declare "${node}" in semanticChecks.allowedTerminals if it is an intentional sink`],
|
|
710
|
+
));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const authoredEdges = new Set(asArray(workflow.edges).map((edge) => `${edge.from}\u0000${edge.to}`));
|
|
715
|
+
for (const [index, relation] of asArray(checks.requiredEdges).entries()) {
|
|
716
|
+
if (authoredEdges.has(`${relation.from}\u0000${relation.to}`)) continue;
|
|
717
|
+
diagnostics.push(diagnostic(
|
|
718
|
+
'workflow/required-edge',
|
|
719
|
+
`Workflow semantic contract requires edge "${relation.from}" -> "${relation.to}", but no authored edge matches it.`,
|
|
720
|
+
{ from: relation.from, to: relation.to, path: `/semanticChecks/requiredEdges/${index}` },
|
|
721
|
+
{ authoredEdgeCount: asArray(workflow.edges).length },
|
|
722
|
+
[`add an edge from "${relation.from}" to "${relation.to}" without deleting the semantic requirement`],
|
|
723
|
+
));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function reachable(from, to) {
|
|
727
|
+
const visited = new Set([from]);
|
|
728
|
+
const pending = [from];
|
|
729
|
+
while (pending.length) {
|
|
730
|
+
const current = pending.shift();
|
|
731
|
+
if (current === to) return true;
|
|
732
|
+
for (const next of adjacency.get(current) || []) {
|
|
733
|
+
if (visited.has(next)) continue;
|
|
734
|
+
visited.add(next);
|
|
735
|
+
pending.push(next);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
for (const [index, relation] of asArray(checks.requiredPaths).entries()) {
|
|
742
|
+
if (reachable(relation.from, relation.to)) continue;
|
|
743
|
+
diagnostics.push(diagnostic(
|
|
744
|
+
'workflow/required-path',
|
|
745
|
+
`Workflow semantic contract requires a directed path from "${relation.from}" to "${relation.to}", but none exists.`,
|
|
746
|
+
{ from: relation.from, to: relation.to, path: `/semanticChecks/requiredPaths/${index}` },
|
|
747
|
+
{ reachableNodes: [...new Set([relation.from, ...(adjacency.get(relation.from) || [])])] },
|
|
748
|
+
[`restore a directed path from "${relation.from}" to "${relation.to}" without weakening the semantic requirement`],
|
|
749
|
+
));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return diagnostics;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function compileWorkflowInternal({
|
|
756
|
+
workflow: inputWorkflow,
|
|
757
|
+
qualityProfile,
|
|
758
|
+
discoverFixes = true,
|
|
759
|
+
layoutFeedback = {},
|
|
760
|
+
} = {}) {
|
|
761
|
+
if (!inputWorkflow || typeof inputWorkflow !== 'object' || Array.isArray(inputWorkflow)) {
|
|
762
|
+
const diagnostics = [{
|
|
763
|
+
code: 'workflow/input-contract',
|
|
764
|
+
severity: 'error',
|
|
765
|
+
message: 'compileWorkflow requires one parsed workflow document object.',
|
|
766
|
+
subject: { diagramType: 'workflow', path: '/' },
|
|
767
|
+
evidence: {},
|
|
768
|
+
supportedFixes: [],
|
|
769
|
+
}];
|
|
770
|
+
return compilerFailure('fixed-v1', diagnostics, diagnostics[0].message);
|
|
771
|
+
}
|
|
772
|
+
const resolvedQualityProfile = qualityProfile || inputWorkflow.meta?.quality_profile;
|
|
773
|
+
const authoredQualityProfile = inputWorkflow.meta?.quality_profile;
|
|
774
|
+
const qualityResolvedWorkflow = resolvedQualityProfile && resolvedQualityProfile !== inputWorkflow.meta?.quality_profile
|
|
775
|
+
? { ...inputWorkflow, meta: { ...inputWorkflow.meta, quality_profile: resolvedQualityProfile } }
|
|
776
|
+
: inputWorkflow;
|
|
777
|
+
let inputDiagnostics = [];
|
|
778
|
+
try {
|
|
779
|
+
validateSchema('workflow', qualityResolvedWorkflow);
|
|
780
|
+
} catch (error) {
|
|
781
|
+
inputDiagnostics = Array.isArray(error?.archifyDiagnostics)
|
|
782
|
+
? error.archifyDiagnostics.map((diagnostic) => ({
|
|
783
|
+
...diagnostic,
|
|
784
|
+
supportedFixes: [],
|
|
785
|
+
}))
|
|
786
|
+
: [{
|
|
787
|
+
code: 'workflow/input-contract',
|
|
788
|
+
severity: 'error',
|
|
789
|
+
message: 'Workflow schema validation failed unexpectedly.',
|
|
790
|
+
subject: { diagramType: 'workflow', path: '/' },
|
|
791
|
+
evidence: { reason: error?.message || String(error) },
|
|
792
|
+
supportedFixes: [],
|
|
793
|
+
}];
|
|
794
|
+
}
|
|
795
|
+
if (inputDiagnostics.length) {
|
|
796
|
+
return compilerFailure(
|
|
797
|
+
inputWorkflow.schema_version === 2 ? 'readable-v2' : 'fixed-v1',
|
|
798
|
+
inputDiagnostics,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
const workflow = canonicalReadableWorkflow(qualityResolvedWorkflow);
|
|
802
|
+
const semanticDiagnostics = semanticContractDiagnostics(workflow);
|
|
803
|
+
if (semanticDiagnostics.length) {
|
|
804
|
+
return compilerFailure(
|
|
805
|
+
workflow.schema_version === 2 ? 'readable-v2' : 'fixed-v1',
|
|
806
|
+
semanticDiagnostics,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
const sourceIndexes = {
|
|
810
|
+
lanes: new Map(asArray(qualityResolvedWorkflow.lanes).map((lane, index) => [lane, index])),
|
|
811
|
+
nodes: new Map(asArray(qualityResolvedWorkflow.nodes).map((node, index) => [node, index])),
|
|
812
|
+
edges: new Map(asArray(qualityResolvedWorkflow.edges).map((edge, index) => [edge, index])),
|
|
813
|
+
};
|
|
814
|
+
const layout = workflow.schema_version === 2
|
|
815
|
+
? createReadableLayout(workflow, layoutFeedback)
|
|
816
|
+
: createLegacyLayout();
|
|
817
|
+
|
|
818
|
+
const LEGEND_CATALOG = [
|
|
819
|
+
'frontend',
|
|
820
|
+
'backend',
|
|
821
|
+
'security',
|
|
822
|
+
'messagebus',
|
|
823
|
+
'database',
|
|
824
|
+
'cloud',
|
|
825
|
+
'external',
|
|
826
|
+
].map((kind) => ({ kind, label: i18nText(workflow.meta.locale, `legend.workflow.${kind}`) }));
|
|
827
|
+
const presentLegendKinds = new Set(asArray(workflow.nodes).map((node) => node.type));
|
|
828
|
+
const workflowLegendEntries = resolveLegend(
|
|
829
|
+
workflow.meta?.legend,
|
|
830
|
+
LEGEND_CATALOG,
|
|
831
|
+
presentLegendKinds,
|
|
832
|
+
);
|
|
833
|
+
const legendFootprintOptions = { fontSize: 7, itemGap: 7 };
|
|
834
|
+
const oneRowLegendFootprint = legendFootprint(workflowLegendEntries, {
|
|
835
|
+
...legendFootprintOptions,
|
|
836
|
+
width: Number.MAX_SAFE_INTEGER,
|
|
837
|
+
});
|
|
838
|
+
const minimumCanvasWidth = workflow.schema_version === 2
|
|
839
|
+
? Math.max(layout.defaultViewBoxWidth, oneRowLegendFootprint.minWidth + 40)
|
|
840
|
+
: layout.defaultViewBoxWidth;
|
|
841
|
+
const legendPackingWidth = Math.max(
|
|
842
|
+
1,
|
|
843
|
+
(workflow.schema_version === 2
|
|
844
|
+
? minimumCanvasWidth
|
|
845
|
+
: (workflow.meta?.viewBox?.[0] ?? minimumCanvasWidth)) - 40,
|
|
846
|
+
);
|
|
847
|
+
const packedLegendFootprint = legendFootprint(workflowLegendEntries, {
|
|
848
|
+
...legendFootprintOptions,
|
|
849
|
+
width: legendPackingWidth,
|
|
850
|
+
});
|
|
851
|
+
const legendExtraHeight = workflow.schema_version === 2
|
|
852
|
+
? packedLegendFootprint.extraHeight
|
|
853
|
+
: 0;
|
|
854
|
+
|
|
855
|
+
// Content is 680px wide (laneX + laneW); auto height fits the lanes plus legend.
|
|
856
|
+
const autoHeight = layout.laneY
|
|
857
|
+
+ (layout.laneHeights?.reduce((total, height) => total + height, 0)
|
|
858
|
+
?? (workflow.lanes?.length || 1) * layout.laneH)
|
|
859
|
+
+ ((workflow.lanes?.length || 1) - 1) * layout.laneGap
|
|
860
|
+
+ 124
|
|
861
|
+
+ legendExtraHeight;
|
|
862
|
+
let viewBox = workflow.meta?.viewBox || [minimumCanvasWidth, autoHeight];
|
|
863
|
+
let requiredViewBox = [...viewBox];
|
|
864
|
+
|
|
865
|
+
const laneIndex = new Map(asArray(workflow.lanes).map((lane, index) => [lane.id, index]));
|
|
866
|
+
const laneLabels = new Map(asArray(workflow.lanes).map((lane) => [lane.id, lane.label]));
|
|
867
|
+
|
|
868
|
+
function nodeContext(node) {
|
|
869
|
+
const group = asArray(workflow.groups).find((candidate) => (
|
|
870
|
+
candidate.lane === node.lane && node.col >= candidate.fromCol && node.col <= candidate.toCol
|
|
871
|
+
));
|
|
872
|
+
const phase = asArray(workflow.phases).find((candidate) => (
|
|
873
|
+
node.col >= candidate.fromCol && node.col <= candidate.toCol
|
|
874
|
+
));
|
|
875
|
+
return [laneLabels.get(node.lane), group?.label, phase?.label].filter(Boolean).join(' › ')
|
|
876
|
+
|| i18nText(workflow.meta.locale, 'node.context.workflow');
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function laneHeight(idOrIndex) {
|
|
880
|
+
const index = typeof idOrIndex === 'number' ? idOrIndex : laneIndex.get(idOrIndex);
|
|
881
|
+
return layout.laneHeights?.[index] ?? layout.laneH;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function laneGroupHeaderH(idOrIndex) {
|
|
885
|
+
const index = typeof idOrIndex === 'number' ? idOrIndex : laneIndex.get(idOrIndex);
|
|
886
|
+
return layout.groupHeaderHeights?.[index] ?? 0;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function laneGroupFooterH(idOrIndex) {
|
|
890
|
+
const index = typeof idOrIndex === 'number' ? idOrIndex : laneIndex.get(idOrIndex);
|
|
891
|
+
return layout.groupFooterHeights?.[index] ?? 0;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function laneTop(id) {
|
|
895
|
+
const index = laneIndex.get(id);
|
|
896
|
+
const precedingHeight = asArray(workflow.lanes).slice(0, index)
|
|
897
|
+
.reduce((total, _lane, lanePosition) => total + laneHeight(lanePosition), 0);
|
|
898
|
+
return layout.laneY + precedingHeight + index * layout.laneGap;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function lastLaneBottom() {
|
|
902
|
+
return layout.laneY
|
|
903
|
+
+ asArray(workflow.lanes).reduce((total, _lane, index) => total + laneHeight(index), 0)
|
|
904
|
+
+ (workflow.lanes.length - 1) * layout.laneGap;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function legendY() {
|
|
908
|
+
return lastLaneBottom() + 44 + legendExtraHeight;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function workflowLegendLayout(obstacles = []) {
|
|
912
|
+
return {
|
|
913
|
+
x: 20,
|
|
914
|
+
baselineY: legendY(),
|
|
915
|
+
width: workflow.schema_version === 2 ? legendPackingWidth : viewBox[0] - 40,
|
|
916
|
+
fontSize: 7,
|
|
917
|
+
itemGap: 7,
|
|
918
|
+
minTitleY: lastLaneBottom() + 8,
|
|
919
|
+
obstacles,
|
|
920
|
+
unfit: workflow.meta?.legend === undefined ? 'hide' : 'error',
|
|
921
|
+
diagramType: 'workflow',
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function workflowLegendRects() {
|
|
926
|
+
if (!workflowLegendEntries.length) return [];
|
|
927
|
+
const measured = measureLegend(workflowLegendEntries, workflowLegendLayout());
|
|
928
|
+
if (!measured) return [];
|
|
929
|
+
return [
|
|
930
|
+
{ kind: 'title', x: 20, y: measured.titleY - 10, width: 48, height: 14 },
|
|
931
|
+
...measured.entries.map((entry) => ({
|
|
932
|
+
kind: entry.kind,
|
|
933
|
+
x: entry.x,
|
|
934
|
+
y: entry.baseline - 10,
|
|
935
|
+
width: entry.width,
|
|
936
|
+
height: 14,
|
|
937
|
+
})),
|
|
938
|
+
];
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function measureNode(node) {
|
|
942
|
+
const width = node.width || layout.nodeW;
|
|
943
|
+
const height = node.height || (node.tag ? 68 : layout.nodeH);
|
|
944
|
+
const cx = layout.colXs[node.col];
|
|
945
|
+
const groupHeaderH = laneGroupHeaderH(node.lane);
|
|
946
|
+
const contentH = laneHeight(node.lane) - layout.laneTitleH
|
|
947
|
+
- groupHeaderH - laneGroupFooterH(node.lane);
|
|
948
|
+
const y = laneTop(node.lane) + layout.laneTitleH + groupHeaderH
|
|
949
|
+
+ (contentH - height) / 2 + (node.yOffset || 0);
|
|
950
|
+
return {
|
|
951
|
+
...node,
|
|
952
|
+
width,
|
|
953
|
+
height,
|
|
954
|
+
x: cx - width / 2,
|
|
955
|
+
y,
|
|
956
|
+
cx,
|
|
957
|
+
cy: y + height / 2
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Font sizes for this renderer's node text; the fitting geometry is shared.
|
|
962
|
+
const nodeTextFit = {
|
|
963
|
+
labelPreferred: 11,
|
|
964
|
+
labelMinimum: 9,
|
|
965
|
+
sublabelPreferred: 8,
|
|
966
|
+
sublabelMinimum: 6,
|
|
967
|
+
tagPreferred: 7,
|
|
968
|
+
tagMinimum: 6,
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
const nodes = new Map(asArray(workflow.nodes).map((node) => [node.id, measureNode(node)]));
|
|
972
|
+
|
|
973
|
+
function workflowCompositionFrames() {
|
|
974
|
+
const frames = [];
|
|
975
|
+
for (const [index, lane] of asArray(workflow.lanes).entries()) {
|
|
976
|
+
const y = laneTop(lane.id);
|
|
977
|
+
const height = laneHeight(index);
|
|
978
|
+
frames.push({ id: `lane-${index}`, label: lane.label, kind: 'lane', x: layout.laneX, y, width: layout.laneW, height, radius: 10 });
|
|
979
|
+
if (lane.variant === 'exception') {
|
|
980
|
+
frames.push({ id: `lane-${index}-exception`, label: `${lane.label} exception`, kind: 'exception-lane', x: layout.laneX + 6, y: y + 6, width: layout.laneW - 12, height: height - 12, radius: 8 });
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
for (const [index, group] of asArray(workflow.groups).entries()) {
|
|
984
|
+
const span = groupSpan(group);
|
|
985
|
+
frames.push({
|
|
986
|
+
id: `group-${index}`,
|
|
987
|
+
label: group.label,
|
|
988
|
+
kind: 'group',
|
|
989
|
+
x: span.x,
|
|
990
|
+
y: laneTop(group.lane) + layout.laneTitleH + GROUP_FRAME_TOP_INSET,
|
|
991
|
+
width: span.width,
|
|
992
|
+
height: workflow.schema_version === 2
|
|
993
|
+
? laneHeight(group.lane) - layout.laneTitleH
|
|
994
|
+
- GROUP_FRAME_TOP_INSET - GROUP_FRAME_BOTTOM_INSET
|
|
995
|
+
: layout.laneH - layout.laneTitleH - 16,
|
|
996
|
+
radius: 9,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
return frames;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function workflowSceneLabelObstacles() {
|
|
1003
|
+
const obstacles = [];
|
|
1004
|
+
for (const [index, lane] of asArray(workflow.lanes).entries()) {
|
|
1005
|
+
const prefix = lane.variant === 'exception' ? 'EX' : String(index + 1).padStart(2, '0');
|
|
1006
|
+
const label = `${prefix} / ${lane.label}`;
|
|
1007
|
+
obstacles.push({
|
|
1008
|
+
kind: 'lane-header',
|
|
1009
|
+
id: lane.id,
|
|
1010
|
+
x: layout.laneX + 14,
|
|
1011
|
+
y: laneTop(lane.id) + 12,
|
|
1012
|
+
width: textUnits(label) * 6.2,
|
|
1013
|
+
height: 14,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
for (const phase of asArray(workflow.phases)) {
|
|
1017
|
+
if (!Number.isInteger(phase.fromCol) || !Number.isInteger(phase.toCol)
|
|
1018
|
+
|| phase.fromCol < 0 || phase.toCol >= layout.colXs.length || phase.fromCol > phase.toCol) continue;
|
|
1019
|
+
const span = phaseSpan(phase);
|
|
1020
|
+
obstacles.push({
|
|
1021
|
+
kind: 'phase-header',
|
|
1022
|
+
id: phase.id ?? null,
|
|
1023
|
+
x: span.x,
|
|
1024
|
+
y: 27,
|
|
1025
|
+
width: span.width,
|
|
1026
|
+
height: 16,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
for (const group of asArray(workflow.groups)) {
|
|
1030
|
+
if (!laneIndex.has(group.lane)
|
|
1031
|
+
|| !Number.isInteger(group.fromCol) || !Number.isInteger(group.toCol)
|
|
1032
|
+
|| group.fromCol < 0 || group.toCol >= layout.colXs.length || group.fromCol > group.toCol) continue;
|
|
1033
|
+
const span = groupSpan(group);
|
|
1034
|
+
const frameY = laneTop(group.lane) + layout.laneTitleH + GROUP_FRAME_TOP_INSET;
|
|
1035
|
+
const labelBaseline = frameY + GROUP_LABEL_BASELINE_OFFSET;
|
|
1036
|
+
obstacles.push({
|
|
1037
|
+
kind: 'group-label',
|
|
1038
|
+
id: group.id ?? null,
|
|
1039
|
+
x: span.x + 10,
|
|
1040
|
+
y: labelBaseline - GROUP_LABEL_MASK_ASCENT,
|
|
1041
|
+
width: textUnits(group.label) * 5.6,
|
|
1042
|
+
height: GROUP_LABEL_MASK_H,
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
return obstacles;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const mainPathSteps = new Map(asArray(workflow.mainPath).map((id, index) => [id, index]));
|
|
1049
|
+
const edgeSteps = new Map(asArray(workflow.edges).map((edge, index) => {
|
|
1050
|
+
const fromStep = mainPathSteps.get(edge.from);
|
|
1051
|
+
const toStep = mainPathSteps.get(edge.to);
|
|
1052
|
+
const mainStep = Number.isInteger(fromStep) && toStep === fromStep + 1 ? fromStep : null;
|
|
1053
|
+
return [edge, mainStep ?? asArray(workflow.mainPath).length + index];
|
|
1054
|
+
}));
|
|
1055
|
+
|
|
1056
|
+
function nodeStep(node) {
|
|
1057
|
+
return mainPathSteps.get(node.id) ?? asArray(workflow.mainPath).length + asArray(workflow.nodes).findIndex((item) => item.id === node.id);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function acceptsFix(mutator) {
|
|
1061
|
+
if (!discoverFixes) return false;
|
|
1062
|
+
const candidate = cloneWorkflow(workflow);
|
|
1063
|
+
mutator(candidate);
|
|
1064
|
+
return withDiagnosticRecordingSuppressed(() => compileWorkflowWithFeedback({
|
|
1065
|
+
workflow: candidate,
|
|
1066
|
+
qualityProfile: resolvedQualityProfile,
|
|
1067
|
+
discoverFixes: false,
|
|
1068
|
+
}).ok);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function verifiedLegacyAlternative(edge, from, to, requiredClearance) {
|
|
1072
|
+
const occupied = [...nodes.values()].filter((node) => node.lane === to.lane && node.id !== to.id);
|
|
1073
|
+
const candidates = layout.colXs.map((center, col) => ({ center, col }))
|
|
1074
|
+
.filter(({ col }) => col !== to.col)
|
|
1075
|
+
.sort((a, b) => Math.abs(a.col - to.col) - Math.abs(b.col - to.col) || a.col - b.col);
|
|
1076
|
+
for (const candidate of candidates) {
|
|
1077
|
+
const candidateRect = { ...to, col: candidate.col, cx: candidate.center, x: candidate.center - to.width / 2 };
|
|
1078
|
+
if (occupied.some((node) => rectsOverlap(candidateRect, node, 8))) continue;
|
|
1079
|
+
const centerDistance = Math.abs(candidate.center - from.cx);
|
|
1080
|
+
const signedClearance = centerDistance - from.width / 2 - to.width / 2;
|
|
1081
|
+
if (signedClearance < requiredClearance) continue;
|
|
1082
|
+
if (acceptsFix((document) => {
|
|
1083
|
+
document.nodes.find((node) => node.id === to.id).col = candidate.col;
|
|
1084
|
+
})) return candidate.col;
|
|
1085
|
+
}
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function readableMigrationProvidesCapacity(from, to, requiredClearance) {
|
|
1090
|
+
const readable = createReadableLayout({ ...workflow, schema_version: 2 });
|
|
1091
|
+
const centerDistance = Math.abs(readable.colXs[to.col] - readable.colXs[from.col]);
|
|
1092
|
+
if (centerDistance - from.width / 2 - to.width / 2 < requiredClearance) return false;
|
|
1093
|
+
if (!discoverFixes) return false;
|
|
1094
|
+
|
|
1095
|
+
return withDiagnosticRecordingSuppressed(() => {
|
|
1096
|
+
const migrationQualityProfile = authoredQualityProfile;
|
|
1097
|
+
let planned = compileWorkflowWithFeedback({
|
|
1098
|
+
workflow: intrinsicWorkflow(workflow),
|
|
1099
|
+
qualityProfile: migrationQualityProfile,
|
|
1100
|
+
discoverFixes: false,
|
|
1101
|
+
});
|
|
1102
|
+
if (!planned.ok) {
|
|
1103
|
+
planned = compileWorkflowWithFeedback({
|
|
1104
|
+
workflow: planningWorkflow(workflow),
|
|
1105
|
+
qualityProfile: migrationQualityProfile,
|
|
1106
|
+
discoverFixes: false,
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
if (!planned.ok || !Array.isArray(planned.receipt?.columns)) return false;
|
|
1110
|
+
|
|
1111
|
+
let candidate;
|
|
1112
|
+
try {
|
|
1113
|
+
candidate = createMappedWorkflowCandidate(
|
|
1114
|
+
workflow,
|
|
1115
|
+
LEGACY_COLUMN_CENTERS,
|
|
1116
|
+
planned.receipt.columns,
|
|
1117
|
+
).document;
|
|
1118
|
+
} catch {
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
let compiled = compileWorkflowWithFeedback({
|
|
1122
|
+
workflow: candidate,
|
|
1123
|
+
qualityProfile: migrationQualityProfile,
|
|
1124
|
+
discoverFixes: false,
|
|
1125
|
+
});
|
|
1126
|
+
const requiredViewBox = compiled.diagnostics?.length
|
|
1127
|
+
&& compiled.diagnostics.every(({ code }) => code === 'workflow/viewbox-capacity')
|
|
1128
|
+
? compiled.diagnostics.find(({ evidence }) => Array.isArray(evidence?.requiredViewBox))
|
|
1129
|
+
?.evidence.requiredViewBox
|
|
1130
|
+
: null;
|
|
1131
|
+
if (!compiled.ok && Array.isArray(candidate.meta?.viewBox) && requiredViewBox) {
|
|
1132
|
+
candidate.meta.viewBox = [
|
|
1133
|
+
Math.max(candidate.meta.viewBox[0], requiredViewBox[0]),
|
|
1134
|
+
Math.max(candidate.meta.viewBox[1], requiredViewBox[1]),
|
|
1135
|
+
];
|
|
1136
|
+
compiled = compileWorkflowWithFeedback({
|
|
1137
|
+
workflow: candidate,
|
|
1138
|
+
qualityProfile: migrationQualityProfile,
|
|
1139
|
+
discoverFixes: false,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
return compiled.ok;
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function verifiedReducedWidths(from, to, requiredClearance) {
|
|
1147
|
+
const widthBudget = 2 * (Math.abs(to.cx - from.cx) - requiredClearance);
|
|
1148
|
+
if (widthBudget < 64) return null;
|
|
1149
|
+
const widths = [from.width, to.width];
|
|
1150
|
+
let excess = widths[0] + widths[1] - widthBudget;
|
|
1151
|
+
for (const index of widths[0] >= widths[1] ? [0, 1] : [1, 0]) {
|
|
1152
|
+
const reduction = Math.min(excess, widths[index] - 32);
|
|
1153
|
+
widths[index] -= reduction;
|
|
1154
|
+
excess -= reduction;
|
|
1155
|
+
}
|
|
1156
|
+
if (excess > 0.0001) return null;
|
|
1157
|
+
const candidates = [from, to];
|
|
1158
|
+
const labelsFit = candidates.every((node, index) => (
|
|
1159
|
+
textUnits(node.label) * 6.8 <= widths[index] + 6
|
|
1160
|
+
&& (!node.sublabel || minimumNodeTextWidth(node.sublabel, nodeTextFit.sublabelMinimum) <= availableNodeTextWidth(widths[index]))
|
|
1161
|
+
&& (!node.tag || minimumNodeTextWidth(node.tag, nodeTextFit.tagMinimum) <= availableNodeTextWidth(widths[index]))
|
|
1162
|
+
));
|
|
1163
|
+
if (!labelsFit) return null;
|
|
1164
|
+
const serializedWidths = widths.map((width) => Math.floor((width + 1e-9) * 100) / 100);
|
|
1165
|
+
const signedClearance = Math.abs(to.cx - from.cx)
|
|
1166
|
+
- serializedWidths[0] / 2 - serializedWidths[1] / 2;
|
|
1167
|
+
if (signedClearance + 0.0001 < requiredClearance) return null;
|
|
1168
|
+
const accepted = acceptsFix((document) => {
|
|
1169
|
+
document.nodes.find((node) => node.id === from.id).width = serializedWidths[0];
|
|
1170
|
+
document.nodes.find((node) => node.id === to.id).width = serializedWidths[1];
|
|
1171
|
+
});
|
|
1172
|
+
return accepted ? serializedWidths : null;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function enforceLegacyColumnCapacity() {
|
|
1176
|
+
if (workflow.schema_version !== 1) return;
|
|
1177
|
+
for (const edge of workflow.edges) {
|
|
1178
|
+
const from = nodes.get(edge.from);
|
|
1179
|
+
const to = nodes.get(edge.to);
|
|
1180
|
+
if (!from || !to || from.lane !== to.lane || from.col === to.col) continue;
|
|
1181
|
+
if (!verticalIntervalsOverlap(from, to, 8)) continue;
|
|
1182
|
+
const centerDistance = Math.abs(to.cx - from.cx);
|
|
1183
|
+
const actualSignedClearance = centerDistance - from.width / 2 - to.width / 2;
|
|
1184
|
+
const direct = !edge.via && ['auto', 'straight'].includes(edge.route || 'auto')
|
|
1185
|
+
&& Math.abs(from.cy - to.cy) < 0.0001;
|
|
1186
|
+
const requiredDirectClearance = direct ? 28 : 8;
|
|
1187
|
+
if (actualSignedClearance >= requiredDirectClearance) continue;
|
|
1188
|
+
const alternative = verifiedLegacyAlternative(edge, from, to, requiredDirectClearance);
|
|
1189
|
+
const reducedWidths = verifiedReducedWidths(from, to, requiredDirectClearance);
|
|
1190
|
+
const capacity = actualSignedClearance < 0
|
|
1191
|
+
? `overlap by ${Math.abs(Math.round(actualSignedClearance))}px`
|
|
1192
|
+
: `leave only ${Math.round(actualSignedClearance)}px of direct clearance`;
|
|
1193
|
+
const message = `Workflow columns ${from.col}→${to.col} place nodes "${from.id}" and "${to.id}" so they ${capacity} under the fixed-v1 layout.`;
|
|
1194
|
+
const supportedFixes = [];
|
|
1195
|
+
if (readableMigrationProvidesCapacity(from, to, requiredDirectClearance)) {
|
|
1196
|
+
supportedFixes.push('migrate this workflow to schema_version 2');
|
|
1197
|
+
}
|
|
1198
|
+
if (alternative !== null) supportedFixes.push(`move node "${to.id}" to verified free column ${alternative}`);
|
|
1199
|
+
if (reducedWidths) {
|
|
1200
|
+
supportedFixes.push(`set node widths "${from.id}"=${Math.round(reducedWidths[0] * 100) / 100}px and "${to.id}"=${Math.round(reducedWidths[1] * 100) / 100}px`);
|
|
1201
|
+
}
|
|
1202
|
+
throwDiagnosticError(message, [{
|
|
1203
|
+
code: 'workflow/column-capacity',
|
|
1204
|
+
severity: 'error',
|
|
1205
|
+
message,
|
|
1206
|
+
subject: {
|
|
1207
|
+
diagramType: 'workflow',
|
|
1208
|
+
edge: edge.id ?? null,
|
|
1209
|
+
from: edge.from,
|
|
1210
|
+
to: edge.to,
|
|
1211
|
+
fromCol: from.col,
|
|
1212
|
+
toCol: to.col,
|
|
1213
|
+
},
|
|
1214
|
+
evidence: {
|
|
1215
|
+
centerDistancePx: centerDistance,
|
|
1216
|
+
nodeWidthsPx: [from.width, to.width],
|
|
1217
|
+
actualSignedClearancePx: actualSignedClearance,
|
|
1218
|
+
requiredDirectClearancePx: requiredDirectClearance,
|
|
1219
|
+
},
|
|
1220
|
+
supportedFixes,
|
|
1221
|
+
suppresses: [
|
|
1222
|
+
'workflow/short-edge',
|
|
1223
|
+
'clean-flow/endpoint-side-direction',
|
|
1224
|
+
'workflow/label-node-overlap',
|
|
1225
|
+
],
|
|
1226
|
+
}]);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function verifiedEdgeFix(edge, message, mutator) {
|
|
1231
|
+
const edgeIndex = workflow.edges.indexOf(edge);
|
|
1232
|
+
if (edgeIndex < 0) return null;
|
|
1233
|
+
const accepted = acceptsFix((document) => mutator(document.edges[edgeIndex], document));
|
|
1234
|
+
return accepted ? message : null;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function verifiedAutomaticRouteFix(edge, { clearSides = false } = {}) {
|
|
1238
|
+
const edgeName = workflowEdgeName(edge);
|
|
1239
|
+
return verifiedEdgeFix(
|
|
1240
|
+
edge,
|
|
1241
|
+
clearSides
|
|
1242
|
+
? `remove explicit route geometry and endpoint sides from edge "${edgeName}" so readable-v2 can use its verified automatic candidate`
|
|
1243
|
+
: `remove explicit route geometry from edge "${edgeName}" so readable-v2 can use its verified automatic candidate`,
|
|
1244
|
+
(candidate) => {
|
|
1245
|
+
delete candidate.via;
|
|
1246
|
+
delete candidate.channelX;
|
|
1247
|
+
delete candidate.channelY;
|
|
1248
|
+
delete candidate.route;
|
|
1249
|
+
if (clearSides) {
|
|
1250
|
+
delete candidate.fromSide;
|
|
1251
|
+
delete candidate.toSide;
|
|
1252
|
+
}
|
|
1253
|
+
},
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function authoredPinEvidence(edge, field) {
|
|
1258
|
+
const authoredEdgeIndex = sourceIndexes.edges.get(edge);
|
|
1259
|
+
const value = Array.isArray(edge[field])
|
|
1260
|
+
? edge[field].map((item) => (Array.isArray(item) ? [...item] : item))
|
|
1261
|
+
: edge[field];
|
|
1262
|
+
return {
|
|
1263
|
+
edge: workflowEdgeName(edge),
|
|
1264
|
+
field,
|
|
1265
|
+
...(Number.isInteger(authoredEdgeIndex) ? { path: `/edges/${authoredEdgeIndex}/${field}` } : {}),
|
|
1266
|
+
value,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function combinations(values, size, start = 0, prefix = [], output = []) {
|
|
1271
|
+
if (prefix.length === size) {
|
|
1272
|
+
output.push([...prefix]);
|
|
1273
|
+
return output;
|
|
1274
|
+
}
|
|
1275
|
+
for (let index = start; index <= values.length - (size - prefix.length); index += 1) {
|
|
1276
|
+
prefix.push(values[index]);
|
|
1277
|
+
combinations(values, size, index + 1, prefix, output);
|
|
1278
|
+
prefix.pop();
|
|
1279
|
+
}
|
|
1280
|
+
return output;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function verifiedPinRemovalAlternatives(edge, fields, reason) {
|
|
1284
|
+
if (!discoverFixes) return { removalSets: [], supportedFixes: [] };
|
|
1285
|
+
const edgeIndex = workflow.edges.indexOf(edge);
|
|
1286
|
+
if (edgeIndex < 0) return { removalSets: [], supportedFixes: [] };
|
|
1287
|
+
const uniqueFields = [...new Set(fields.filter((field) => edge[field] !== undefined))];
|
|
1288
|
+
for (let size = 1; size <= uniqueFields.length; size += 1) {
|
|
1289
|
+
const removalSets = combinations(uniqueFields, size).filter((fieldSet) => (
|
|
1290
|
+
acceptsFix((document) => {
|
|
1291
|
+
for (const field of fieldSet) delete document.edges[edgeIndex][field];
|
|
1292
|
+
})
|
|
1293
|
+
));
|
|
1294
|
+
if (!removalSets.length) continue;
|
|
1295
|
+
const edgeName = workflowEdgeName(edge);
|
|
1296
|
+
return {
|
|
1297
|
+
removalSets,
|
|
1298
|
+
supportedFixes: removalSets.map((fieldSet) => (
|
|
1299
|
+
`remove ${fieldSet.join(' and ')} from edge "${edgeName}" ${reason}`
|
|
1300
|
+
)),
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
return { removalSets: [], supportedFixes: [] };
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function conflictPinsFromRemovalSets(edge, removalSets, fallbackFields = []) {
|
|
1307
|
+
const fields = removalSets.length
|
|
1308
|
+
? [...new Set(removalSets.flat())]
|
|
1309
|
+
: [...new Set(fallbackFields)];
|
|
1310
|
+
return fields.map((field) => authoredPinEvidence(edge, field));
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function authoredRouteAssertionFields(edge) {
|
|
1314
|
+
return [
|
|
1315
|
+
...(Array.isArray(edge?.via) ? ['via'] : []),
|
|
1316
|
+
...(edge?.channelX !== undefined ? ['channelX'] : []),
|
|
1317
|
+
...(edge?.channelY !== undefined ? ['channelY'] : []),
|
|
1318
|
+
...(edge?.route && edge.route !== 'auto' ? ['route'] : []),
|
|
1319
|
+
...(edge?.fromSide && edge.fromSide !== 'auto' ? ['fromSide'] : []),
|
|
1320
|
+
...(edge?.toSide && edge.toSide !== 'auto' ? ['toSide'] : []),
|
|
1321
|
+
];
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function hasAuthoredRouteAssertions(edge) {
|
|
1325
|
+
return authoredRouteAssertionFields(edge).length > 0;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function verifiedPinReferenceAlternatives(candidateRefs, reason) {
|
|
1329
|
+
const seenRefs = new Set();
|
|
1330
|
+
const refs = candidateRefs.filter(({ edge, edgeIndex, field }) => {
|
|
1331
|
+
if (edgeIndex < 0 || edge?.[field] === undefined) return false;
|
|
1332
|
+
const key = `${edgeIndex}:${field}`;
|
|
1333
|
+
if (seenRefs.has(key)) return false;
|
|
1334
|
+
seenRefs.add(key);
|
|
1335
|
+
return true;
|
|
1336
|
+
});
|
|
1337
|
+
const fallbackPins = refs.map(({ edge, field }) => authoredPinEvidence(edge, field));
|
|
1338
|
+
if (!discoverFixes) {
|
|
1339
|
+
return {
|
|
1340
|
+
removalSets: [], conflictingRefs: refs, conflictingPins: fallbackPins, repairs: [], supportedFixes: [],
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
for (let size = 1; size <= refs.length; size += 1) {
|
|
1345
|
+
const removalSets = combinations(refs, size).filter((removalSet) => (
|
|
1346
|
+
acceptsFix((document) => {
|
|
1347
|
+
for (const { edgeIndex, field } of removalSet) delete document.edges[edgeIndex][field];
|
|
1348
|
+
})
|
|
1349
|
+
));
|
|
1350
|
+
if (!removalSets.length) continue;
|
|
1351
|
+
const conflictingRefs = [];
|
|
1352
|
+
const conflictingPins = [];
|
|
1353
|
+
const seenPins = new Set();
|
|
1354
|
+
for (const removalSet of removalSets) {
|
|
1355
|
+
for (const { edge, field } of removalSet) {
|
|
1356
|
+
const key = `${workflow.edges.indexOf(edge)}:${field}`;
|
|
1357
|
+
if (seenPins.has(key)) continue;
|
|
1358
|
+
seenPins.add(key);
|
|
1359
|
+
conflictingRefs.push({ edge, edgeIndex: workflow.edges.indexOf(edge), field });
|
|
1360
|
+
conflictingPins.push(authoredPinEvidence(edge, field));
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
const repairs = removalSets.map((removalSet) => {
|
|
1364
|
+
const grouped = [];
|
|
1365
|
+
for (const ref of removalSet) {
|
|
1366
|
+
let group = grouped.find(({ edge }) => edge === ref.edge);
|
|
1367
|
+
if (!group) {
|
|
1368
|
+
group = { edge: ref.edge, fields: [] };
|
|
1369
|
+
grouped.push(group);
|
|
1370
|
+
}
|
|
1371
|
+
group.fields.push(ref.field);
|
|
1372
|
+
}
|
|
1373
|
+
const removals = grouped.map(({ edge, fields }) => (
|
|
1374
|
+
`remove ${fields.join(' and ')} from edge "${workflowEdgeName(edge)}"`
|
|
1375
|
+
));
|
|
1376
|
+
return { removalSet, message: `${removals.join(' and ')} ${reason}` };
|
|
1377
|
+
});
|
|
1378
|
+
return {
|
|
1379
|
+
removalSets,
|
|
1380
|
+
conflictingRefs,
|
|
1381
|
+
conflictingPins,
|
|
1382
|
+
repairs,
|
|
1383
|
+
supportedFixes: repairs.map(({ message }) => message),
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
return {
|
|
1387
|
+
removalSets: [], conflictingRefs: refs, conflictingPins: fallbackPins, repairs: [], supportedFixes: [],
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function verifiedRoutePairPinAlternatives(leftEdge, rightEdge, reason) {
|
|
1392
|
+
const refs = [leftEdge, rightEdge].flatMap((edge) => {
|
|
1393
|
+
const edgeIndex = workflow.edges.indexOf(edge);
|
|
1394
|
+
return authoredRouteAssertionFields(edge).map((field) => ({ edge, edgeIndex, field }));
|
|
1395
|
+
});
|
|
1396
|
+
return verifiedPinReferenceAlternatives(refs, reason);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function verifiedLabelRoutePinAlternatives(labelEdge, routeEdge) {
|
|
1400
|
+
const refs = [];
|
|
1401
|
+
const labelEdgeIndex = workflow.edges.indexOf(labelEdge);
|
|
1402
|
+
if (Array.isArray(labelEdge?.labelAt)) {
|
|
1403
|
+
refs.push({ edge: labelEdge, edgeIndex: labelEdgeIndex, field: 'labelAt' });
|
|
1404
|
+
}
|
|
1405
|
+
const routeEdgeIndex = workflow.edges.indexOf(routeEdge);
|
|
1406
|
+
for (const field of authoredRouteAssertionFields(routeEdge)) {
|
|
1407
|
+
refs.push({ edge: routeEdge, edgeIndex: routeEdgeIndex, field });
|
|
1408
|
+
}
|
|
1409
|
+
return verifiedPinReferenceAlternatives(
|
|
1410
|
+
refs,
|
|
1411
|
+
Array.isArray(labelEdge?.labelAt)
|
|
1412
|
+
? 'so readable-v2 can replan the remaining authored label-route pins'
|
|
1413
|
+
: 'so readable-v2 can replan the remaining authored route assertions',
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function verifiedLabelPairPinAlternatives(leftEdge, rightEdge) {
|
|
1418
|
+
return verifiedPinReferenceAlternatives(
|
|
1419
|
+
[leftEdge, rightEdge].flatMap((edge) => (
|
|
1420
|
+
Array.isArray(edge?.labelAt)
|
|
1421
|
+
? [{ edge, edgeIndex: workflow.edges.indexOf(edge), field: 'labelAt' }]
|
|
1422
|
+
: []
|
|
1423
|
+
)),
|
|
1424
|
+
'so readable-v2 can replan the remaining authored label pins',
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function verifiedRepairsWithLabelNudges(alternatives) {
|
|
1429
|
+
return alternatives.repairs.flatMap(({ removalSet, message }) => {
|
|
1430
|
+
if (removalSet.length !== 1 || removalSet[0].field !== 'labelAt') return [message];
|
|
1431
|
+
const nudges = verifiedLabelAtAlternatives(removalSet[0].edge);
|
|
1432
|
+
return nudges.length ? nudges : [message];
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function throwExplicitPinConflict(edge, invariant, evidence, supportedFixes = []) {
|
|
1437
|
+
const message = `Workflow edge "${workflowEdgeName(edge)}" has explicit geometry that violates ${invariant}.`;
|
|
1438
|
+
const [onlyPin] = asArray(evidence?.conflictingPins);
|
|
1439
|
+
const authoredEdgeIndex = sourceIndexes.edges.get(edge);
|
|
1440
|
+
const pinPath = asArray(evidence?.conflictingPins).length === 1
|
|
1441
|
+
&& Number.isInteger(authoredEdgeIndex)
|
|
1442
|
+
&& onlyPin?.field
|
|
1443
|
+
? onlyPin.path || `/edges/${authoredEdgeIndex}/${onlyPin.field}`
|
|
1444
|
+
: null;
|
|
1445
|
+
throwDiagnosticError(message, [{
|
|
1446
|
+
code: 'workflow/explicit-pin-conflict',
|
|
1447
|
+
severity: 'error',
|
|
1448
|
+
message,
|
|
1449
|
+
subject: {
|
|
1450
|
+
diagramType: 'workflow',
|
|
1451
|
+
edge: edge.id ?? null,
|
|
1452
|
+
from: edge.from,
|
|
1453
|
+
to: edge.to,
|
|
1454
|
+
...(pinPath ? { path: pinPath } : {}),
|
|
1455
|
+
},
|
|
1456
|
+
evidence: { invariant, ...evidence },
|
|
1457
|
+
supportedFixes: supportedFixes.filter(Boolean),
|
|
1458
|
+
}]);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function hasAbsoluteRoutePins(edge) {
|
|
1462
|
+
return Array.isArray(edge?.via)
|
|
1463
|
+
|| edge?.channelX !== undefined
|
|
1464
|
+
|| edge?.channelY !== undefined;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function presentRouteGeometryFields(edge) {
|
|
1468
|
+
return [
|
|
1469
|
+
...(Array.isArray(edge?.via) ? ['via'] : []),
|
|
1470
|
+
...(edge?.channelX !== undefined ? ['channelX'] : []),
|
|
1471
|
+
...(edge?.channelY !== undefined ? ['channelY'] : []),
|
|
1472
|
+
];
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function verifiedRouteGeometryPinAlternatives(
|
|
1476
|
+
edge,
|
|
1477
|
+
reason = 'so readable-v2 can replan the remaining explicit route assertions',
|
|
1478
|
+
) {
|
|
1479
|
+
const edgeIndex = workflow.edges.indexOf(edge);
|
|
1480
|
+
return verifiedPinReferenceAlternatives(
|
|
1481
|
+
authoredRouteAssertionFields(edge).map((field) => ({ edge, edgeIndex, field })),
|
|
1482
|
+
reason,
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function properOrthogonalIntersection(leftStart, leftEnd, rightStart, rightEnd) {
|
|
1487
|
+
const leftOrientation = segmentOrientation(leftStart, leftEnd);
|
|
1488
|
+
const rightOrientation = segmentOrientation(rightStart, rightEnd);
|
|
1489
|
+
if (leftOrientation === rightOrientation
|
|
1490
|
+
|| leftOrientation === 'diagonal'
|
|
1491
|
+
|| rightOrientation === 'diagonal') return null;
|
|
1492
|
+
const horizontalStart = leftOrientation === 'horizontal' ? leftStart : rightStart;
|
|
1493
|
+
const horizontalEnd = leftOrientation === 'horizontal' ? leftEnd : rightEnd;
|
|
1494
|
+
const verticalStart = leftOrientation === 'vertical' ? leftStart : rightStart;
|
|
1495
|
+
const verticalEnd = leftOrientation === 'vertical' ? leftEnd : rightEnd;
|
|
1496
|
+
const point = [verticalStart[0], horizontalStart[1]];
|
|
1497
|
+
const epsilon = 0.0001;
|
|
1498
|
+
const insideHorizontal = point[0] > Math.min(horizontalStart[0], horizontalEnd[0]) + epsilon
|
|
1499
|
+
&& point[0] < Math.max(horizontalStart[0], horizontalEnd[0]) - epsilon;
|
|
1500
|
+
const insideVertical = point[1] > Math.min(verticalStart[1], verticalEnd[1]) + epsilon
|
|
1501
|
+
&& point[1] < Math.max(verticalStart[1], verticalEnd[1]) - epsilon;
|
|
1502
|
+
return insideHorizontal && insideVertical ? point : null;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function verifiedLabelAtAlternatives(edge) {
|
|
1506
|
+
if (!Array.isArray(edge.labelAt)) return [];
|
|
1507
|
+
const [x, y] = edge.labelAt;
|
|
1508
|
+
return [
|
|
1509
|
+
[0, 24], [0, -24], [24, 0], [-24, 0],
|
|
1510
|
+
[0, 48], [0, -48], [48, 0], [-48, 0],
|
|
1511
|
+
].map(([dx, dy]) => {
|
|
1512
|
+
const next = [x + dx, y + dy];
|
|
1513
|
+
return verifiedEdgeFix(
|
|
1514
|
+
edge,
|
|
1515
|
+
`set labelAt on edge "${workflowEdgeName(edge)}" to [${next[0]}, ${next[1]}]`,
|
|
1516
|
+
(candidate) => { candidate.labelAt = next; },
|
|
1517
|
+
);
|
|
1518
|
+
}).filter(Boolean);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function verifiedLabelAtNudge(edge) {
|
|
1522
|
+
const [alternative] = verifiedLabelAtAlternatives(edge);
|
|
1523
|
+
if (alternative) return alternative;
|
|
1524
|
+
return verifiedEdgeFix(
|
|
1525
|
+
edge,
|
|
1526
|
+
`remove labelAt from edge "${workflowEdgeName(edge)}" so readable-v2 can use verified automatic label placement`,
|
|
1527
|
+
(candidate) => { delete candidate.labelAt; },
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function throwReadableLabelRoutePinConflict(hit, routePoints = null) {
|
|
1532
|
+
const labelEdge = hit.labelRelation;
|
|
1533
|
+
const routeEdge = hit.otherRelation;
|
|
1534
|
+
const labelPinned = Array.isArray(labelEdge?.labelAt);
|
|
1535
|
+
const routePinned = hasAuthoredRouteAssertions(routeEdge);
|
|
1536
|
+
if (!labelPinned && !routePinned) return false;
|
|
1537
|
+
const alternatives = verifiedLabelRoutePinAlternatives(labelEdge, routeEdge);
|
|
1538
|
+
const actualRoutePoints = routePoints
|
|
1539
|
+
|| pathCache.get(routeEdge)?.points
|
|
1540
|
+
|| pathFor(routeEdge).points;
|
|
1541
|
+
const diagnosticEdge = alternatives.conflictingRefs[0]?.edge
|
|
1542
|
+
|| (labelPinned ? labelEdge : routeEdge);
|
|
1543
|
+
throwExplicitPinConflict(diagnosticEdge, 'explicit label-route clearance', {
|
|
1544
|
+
conflictingPins: alternatives.conflictingPins,
|
|
1545
|
+
...(labelPinned ? { labelAt: [...labelEdge.labelAt] } : {}),
|
|
1546
|
+
labelRect: {
|
|
1547
|
+
x: hit.rect.x,
|
|
1548
|
+
y: hit.rect.y,
|
|
1549
|
+
width: hit.rect.width,
|
|
1550
|
+
height: hit.rect.height,
|
|
1551
|
+
},
|
|
1552
|
+
collidedRoute: {
|
|
1553
|
+
edge: routeEdge.id || `${routeEdge.from}->${routeEdge.to}`,
|
|
1554
|
+
from: routeEdge.from,
|
|
1555
|
+
to: routeEdge.to,
|
|
1556
|
+
points: actualRoutePoints.map((point) => [...point]),
|
|
1557
|
+
},
|
|
1558
|
+
routeSegmentIndex: hit.segmentIndex,
|
|
1559
|
+
routeSegment: { from: [...hit.start], to: [...hit.end] },
|
|
1560
|
+
clearancePx: Math.round(hit.clearance * 10) / 10,
|
|
1561
|
+
minimumPx: hit.threshold,
|
|
1562
|
+
}, [
|
|
1563
|
+
...verifiedRepairsWithLabelNudges(alternatives),
|
|
1564
|
+
]);
|
|
1565
|
+
return true;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function throwReadableLabelLabelPinConflict(left, right) {
|
|
1569
|
+
const leftEdge = left.relation;
|
|
1570
|
+
const rightEdge = right.relation;
|
|
1571
|
+
const pinnedEdges = [leftEdge, rightEdge].filter((edge) => Array.isArray(edge?.labelAt));
|
|
1572
|
+
if (!pinnedEdges.length) return false;
|
|
1573
|
+
const alternatives = verifiedLabelPairPinAlternatives(leftEdge, rightEdge);
|
|
1574
|
+
const causalLabelEdges = [...new Set(alternatives.conflictingRefs.map(({ edge }) => edge))];
|
|
1575
|
+
const diagnosticEdge = causalLabelEdges[0] || pinnedEdges[0];
|
|
1576
|
+
throwExplicitPinConflict(diagnosticEdge, 'explicit label-label clearance', {
|
|
1577
|
+
conflictingPins: alternatives.conflictingPins,
|
|
1578
|
+
labelRects: [left, right].map((rect) => ({
|
|
1579
|
+
edge: rect.relation.id ?? null,
|
|
1580
|
+
x: rect.x,
|
|
1581
|
+
y: rect.y,
|
|
1582
|
+
width: rect.width,
|
|
1583
|
+
height: rect.height,
|
|
1584
|
+
})),
|
|
1585
|
+
minimumGapPx: -2,
|
|
1586
|
+
}, verifiedRepairsWithLabelNudges(alternatives));
|
|
1587
|
+
return true;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function classifyFailedAutomaticCandidatePins(edge, rawCandidates) {
|
|
1591
|
+
const relationIndex = workflow.edges.indexOf(edge);
|
|
1592
|
+
const priorRoutes = [...pathCache.entries()]
|
|
1593
|
+
.filter(([otherEdge]) => otherEdge !== edge)
|
|
1594
|
+
.map(([relation, routed]) => ({
|
|
1595
|
+
relation,
|
|
1596
|
+
relationIndex: workflow.edges.indexOf(relation),
|
|
1597
|
+
points: routed.points,
|
|
1598
|
+
}));
|
|
1599
|
+
if (!priorRoutes.length) return;
|
|
1600
|
+
const priorLabels = priorRoutes.map(({ relation, relationIndex }) => (
|
|
1601
|
+
labelRectFor(relation, relationIndex)
|
|
1602
|
+
)).filter(Boolean);
|
|
1603
|
+
|
|
1604
|
+
for (const { points } of rawCandidates) {
|
|
1605
|
+
const candidateRect = candidateLabelRect(edge, points);
|
|
1606
|
+
const candidateLabel = candidateRect
|
|
1607
|
+
? { ...candidateRect, relation: edge, relationIndex, label: edge.label }
|
|
1608
|
+
: null;
|
|
1609
|
+
if (candidateLabel) {
|
|
1610
|
+
const priorLabel = priorLabels.find((otherLabel) => (
|
|
1611
|
+
rectsOverlap(candidateLabel, otherLabel, -2)
|
|
1612
|
+
&& (Array.isArray(edge.labelAt) || Array.isArray(otherLabel.relation?.labelAt))
|
|
1613
|
+
));
|
|
1614
|
+
if (priorLabel) throwReadableLabelLabelPinConflict(candidateLabel, priorLabel);
|
|
1615
|
+
|
|
1616
|
+
const labelRouteHit = collectLabelRouteClearance({
|
|
1617
|
+
labels: [candidateLabel],
|
|
1618
|
+
routedRelations: priorRoutes,
|
|
1619
|
+
threshold: 4,
|
|
1620
|
+
}).find((hit) => (
|
|
1621
|
+
Array.isArray(edge.labelAt) || hasAbsoluteRoutePins(hit.otherRelation)
|
|
1622
|
+
));
|
|
1623
|
+
if (labelRouteHit) {
|
|
1624
|
+
const collidedRoute = priorRoutes.find(({ relation }) => relation === labelRouteHit.otherRelation);
|
|
1625
|
+
throwReadableLabelRoutePinConflict(labelRouteHit, collidedRoute?.points);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const reverseHit = collectLabelRouteClearance({
|
|
1630
|
+
labels: priorLabels,
|
|
1631
|
+
routedRelations: [{ relation: edge, relationIndex, points }],
|
|
1632
|
+
threshold: 4,
|
|
1633
|
+
}).find((hit) => Array.isArray(hit.labelRelation?.labelAt));
|
|
1634
|
+
if (reverseHit) throwReadableLabelRoutePinConflict(reverseHit, points);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function validateReadablePairwisePinConflicts() {
|
|
1639
|
+
const labels = workflow.edges.map((edge, relationIndex) => (
|
|
1640
|
+
labelRectFor(edge, relationIndex)
|
|
1641
|
+
)).filter(Boolean);
|
|
1642
|
+
const routedRelations = workflow.edges.map((edge, relationIndex) => (
|
|
1643
|
+
nodes.has(edge.from) && nodes.has(edge.to)
|
|
1644
|
+
? { relation: edge, relationIndex, points: pathFor(edge).points }
|
|
1645
|
+
: null
|
|
1646
|
+
)).filter(Boolean);
|
|
1647
|
+
|
|
1648
|
+
const labelRouteHit = collectLabelRouteClearance({
|
|
1649
|
+
labels,
|
|
1650
|
+
routedRelations,
|
|
1651
|
+
threshold: 4,
|
|
1652
|
+
}).find((hit) => (
|
|
1653
|
+
Array.isArray(hit.labelRelation?.labelAt) || hasAuthoredRouteAssertions(hit.otherRelation)
|
|
1654
|
+
));
|
|
1655
|
+
if (labelRouteHit) throwReadableLabelRoutePinConflict(labelRouteHit);
|
|
1656
|
+
|
|
1657
|
+
for (let leftIndex = 0; leftIndex < labels.length; leftIndex += 1) {
|
|
1658
|
+
for (let rightIndex = leftIndex + 1; rightIndex < labels.length; rightIndex += 1) {
|
|
1659
|
+
const left = labels[leftIndex];
|
|
1660
|
+
const right = labels[rightIndex];
|
|
1661
|
+
if (!rectsOverlap(left, right, -2)) continue;
|
|
1662
|
+
throwReadableLabelLabelPinConflict(left, right);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const requestedProfile = workflow.meta?.quality_profile;
|
|
1667
|
+
if (requestedProfile !== 'showcase') return;
|
|
1668
|
+
for (let leftIndex = 0; leftIndex < routedRelations.length; leftIndex += 1) {
|
|
1669
|
+
const left = routedRelations[leftIndex];
|
|
1670
|
+
for (let rightIndex = leftIndex + 1; rightIndex < routedRelations.length; rightIndex += 1) {
|
|
1671
|
+
const right = routedRelations[rightIndex];
|
|
1672
|
+
const leftPinned = hasAuthoredRouteAssertions(left.relation);
|
|
1673
|
+
const rightPinned = hasAuthoredRouteAssertions(right.relation);
|
|
1674
|
+
if (!leftPinned && !rightPinned) continue;
|
|
1675
|
+
if ([left.relation.from, left.relation.to].some((id) => (
|
|
1676
|
+
id === right.relation.from || id === right.relation.to
|
|
1677
|
+
))) continue;
|
|
1678
|
+
const leftAnalysis = forwardCollinearAnalysisSegments(left.points);
|
|
1679
|
+
const rightAnalysis = forwardCollinearAnalysisSegments(right.points);
|
|
1680
|
+
for (const leftSegment of leftAnalysis) {
|
|
1681
|
+
for (const rightSegment of rightAnalysis) {
|
|
1682
|
+
const point = properOrthogonalIntersection(
|
|
1683
|
+
leftSegment.start,
|
|
1684
|
+
leftSegment.end,
|
|
1685
|
+
rightSegment.start,
|
|
1686
|
+
rightSegment.end,
|
|
1687
|
+
);
|
|
1688
|
+
if (!point) continue;
|
|
1689
|
+
const leftSourceIndex = sourceSegmentIndexAtPoint(leftSegment, point);
|
|
1690
|
+
const rightSourceIndex = sourceSegmentIndexAtPoint(rightSegment, point);
|
|
1691
|
+
const leftSource = {
|
|
1692
|
+
from: left.points[leftSourceIndex],
|
|
1693
|
+
to: left.points[leftSourceIndex + 1],
|
|
1694
|
+
};
|
|
1695
|
+
const rightSource = {
|
|
1696
|
+
from: right.points[rightSourceIndex],
|
|
1697
|
+
to: right.points[rightSourceIndex + 1],
|
|
1698
|
+
};
|
|
1699
|
+
const alternatives = verifiedRoutePairPinAlternatives(
|
|
1700
|
+
left.relation,
|
|
1701
|
+
right.relation,
|
|
1702
|
+
'so readable-v2 can replan the remaining authored route assertions',
|
|
1703
|
+
);
|
|
1704
|
+
const diagnosticEdge = leftPinned ? left.relation : right.relation;
|
|
1705
|
+
throwExplicitPinConflict(diagnosticEdge, 'explicit route-route crossing', {
|
|
1706
|
+
conflictingPins: alternatives.conflictingPins,
|
|
1707
|
+
point,
|
|
1708
|
+
segmentIndex: leftSourceIndex,
|
|
1709
|
+
otherSegmentIndex: rightSourceIndex,
|
|
1710
|
+
routeSegments: [
|
|
1711
|
+
{ edge: left.relation.id ?? null, from: [...leftSegment.start], to: [...leftSegment.end] },
|
|
1712
|
+
{ edge: right.relation.id ?? null, from: [...rightSegment.start], to: [...rightSegment.end] },
|
|
1713
|
+
],
|
|
1714
|
+
sourceRouteSegments: [
|
|
1715
|
+
{ edge: left.relation.id ?? null, from: [...leftSource.from], to: [...leftSource.to] },
|
|
1716
|
+
{ edge: right.relation.id ?? null, from: [...rightSource.from], to: [...rightSource.to] },
|
|
1717
|
+
],
|
|
1718
|
+
}, alternatives.supportedFixes);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
const corridorHit = collectAmbiguousCorridors({
|
|
1725
|
+
routedRelations,
|
|
1726
|
+
minOverlapPx: 8,
|
|
1727
|
+
}).find((hit) => (
|
|
1728
|
+
hasAuthoredRouteAssertions(hit.left.relation)
|
|
1729
|
+
|| hasAuthoredRouteAssertions(hit.right.relation)
|
|
1730
|
+
));
|
|
1731
|
+
if (corridorHit) {
|
|
1732
|
+
const leftSegment = {
|
|
1733
|
+
from: corridorHit.left.points[corridorHit.leftSegment],
|
|
1734
|
+
to: corridorHit.left.points[corridorHit.leftSegment + 1],
|
|
1735
|
+
};
|
|
1736
|
+
const rightSegment = {
|
|
1737
|
+
from: corridorHit.right.points[corridorHit.rightSegment],
|
|
1738
|
+
to: corridorHit.right.points[corridorHit.rightSegment + 1],
|
|
1739
|
+
};
|
|
1740
|
+
const leftPinned = hasAuthoredRouteAssertions(corridorHit.left.relation);
|
|
1741
|
+
const alternatives = verifiedRoutePairPinAlternatives(
|
|
1742
|
+
corridorHit.left.relation,
|
|
1743
|
+
corridorHit.right.relation,
|
|
1744
|
+
'so readable-v2 can replan the remaining authored route assertions',
|
|
1745
|
+
);
|
|
1746
|
+
const diagnosticEdge = leftPinned ? corridorHit.left.relation : corridorHit.right.relation;
|
|
1747
|
+
throwExplicitPinConflict(diagnosticEdge, 'explicit route-route corridor clearance', {
|
|
1748
|
+
conflictingPins: alternatives.conflictingPins,
|
|
1749
|
+
segmentIndex: corridorHit.leftSegment,
|
|
1750
|
+
otherSegmentIndex: corridorHit.rightSegment,
|
|
1751
|
+
routeSegments: [
|
|
1752
|
+
{
|
|
1753
|
+
edge: corridorHit.left.relation.id ?? null,
|
|
1754
|
+
from: [...leftSegment.from],
|
|
1755
|
+
to: [...leftSegment.to],
|
|
1756
|
+
},
|
|
1757
|
+
{
|
|
1758
|
+
edge: corridorHit.right.relation.id ?? null,
|
|
1759
|
+
from: [...rightSegment.from],
|
|
1760
|
+
to: [...rightSegment.to],
|
|
1761
|
+
},
|
|
1762
|
+
],
|
|
1763
|
+
overlapStart: [...corridorHit.overlapStart],
|
|
1764
|
+
overlapEnd: [...corridorHit.overlapEnd],
|
|
1765
|
+
overlapLengthPx: corridorHit.overlapLength,
|
|
1766
|
+
minimumClearancePx: 8,
|
|
1767
|
+
}, alternatives.supportedFixes);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
const READABLE_PRESET_PIN_FIELDS = Object.freeze({
|
|
1772
|
+
straight: [],
|
|
1773
|
+
drop: ['channelY'],
|
|
1774
|
+
'outside-right': ['channelX'],
|
|
1775
|
+
'return-left': ['channelX'],
|
|
1776
|
+
'bottom-channel': ['channelY'],
|
|
1777
|
+
'up-channel': ['channelY'],
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
function presentChannelPins(edge) {
|
|
1781
|
+
return ['channelX', 'channelY'].filter((field) => edge[field] !== undefined);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function validateReadableRouteControls(edge) {
|
|
1785
|
+
const channelPins = presentChannelPins(edge);
|
|
1786
|
+
const preset = edge.route || 'auto';
|
|
1787
|
+
if (preset === 'auto') return;
|
|
1788
|
+
const allowedPins = new Set(READABLE_PRESET_PIN_FIELDS[preset] || []);
|
|
1789
|
+
const conflictingPins = channelPins.filter((field) => !allowedPins.has(field));
|
|
1790
|
+
if (!conflictingPins.length) return;
|
|
1791
|
+
const edgeIndex = workflow.edges.indexOf(edge);
|
|
1792
|
+
const alternatives = verifiedPinReferenceAlternatives([
|
|
1793
|
+
{ edge, edgeIndex, field: 'route' },
|
|
1794
|
+
...conflictingPins.map((field) => ({ edge, edgeIndex, field })),
|
|
1795
|
+
], 'and keep the remaining verified route assertions');
|
|
1796
|
+
throwExplicitPinConflict(edge, 'route preset compatibility', {
|
|
1797
|
+
route: preset,
|
|
1798
|
+
allowedPins: [...allowedPins],
|
|
1799
|
+
conflictingPins: alternatives.conflictingPins,
|
|
1800
|
+
}, alternatives.supportedFixes);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function segmentOrientation(start, end) {
|
|
1804
|
+
if (Math.abs(start[0] - end[0]) <= 0.0001) return 'vertical';
|
|
1805
|
+
if (Math.abs(start[1] - end[1]) <= 0.0001) return 'horizontal';
|
|
1806
|
+
return 'diagonal';
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function routeSegments(points) {
|
|
1810
|
+
return points.slice(0, -1).map((start, index) => ({
|
|
1811
|
+
start,
|
|
1812
|
+
end: points[index + 1],
|
|
1813
|
+
orientation: segmentOrientation(start, points[index + 1]),
|
|
1814
|
+
}));
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
function endpointSideIsHonored(points, side, endpoint) {
|
|
1818
|
+
if (!side || side === 'auto' || points.length < 2) return true;
|
|
1819
|
+
const source = endpoint === 'source';
|
|
1820
|
+
const from = source ? points[0] : points.at(-2);
|
|
1821
|
+
const to = source ? points[1] : points.at(-1);
|
|
1822
|
+
const dx = to[0] - from[0];
|
|
1823
|
+
const dy = to[1] - from[1];
|
|
1824
|
+
if (source) {
|
|
1825
|
+
if (side === 'right') return dx > 0 && Math.abs(dy) <= 0.0001;
|
|
1826
|
+
if (side === 'left') return dx < 0 && Math.abs(dy) <= 0.0001;
|
|
1827
|
+
if (side === 'bottom') return dy > 0 && Math.abs(dx) <= 0.0001;
|
|
1828
|
+
if (side === 'top') return dy < 0 && Math.abs(dx) <= 0.0001;
|
|
1829
|
+
return false;
|
|
1830
|
+
}
|
|
1831
|
+
if (side === 'right') return dx < 0 && Math.abs(dy) <= 0.0001;
|
|
1832
|
+
if (side === 'left') return dx > 0 && Math.abs(dy) <= 0.0001;
|
|
1833
|
+
if (side === 'bottom') return dy < 0 && Math.abs(dx) <= 0.0001;
|
|
1834
|
+
if (side === 'top') return dy > 0 && Math.abs(dx) <= 0.0001;
|
|
1835
|
+
return false;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function corridorTopologyMatches(points, axis, coordinate) {
|
|
1839
|
+
const collapsed = normalizeRoutePoints(points.map((point) => [...point]));
|
|
1840
|
+
const start = collapsed[0];
|
|
1841
|
+
const end = collapsed.at(-1);
|
|
1842
|
+
const via = axis === 'x'
|
|
1843
|
+
? [[coordinate, start[1]], [coordinate, end[1]]]
|
|
1844
|
+
: [[start[0], coordinate], [end[0], coordinate]];
|
|
1845
|
+
const expected = normalizeRoutePoints([start, ...via, end]);
|
|
1846
|
+
const actualPattern = routeSegments(collapsed).map(({ orientation }) => orientation);
|
|
1847
|
+
const expectedPattern = routeSegments(expected).map(({ orientation }) => orientation);
|
|
1848
|
+
return actualPattern.length === expectedPattern.length
|
|
1849
|
+
&& actualPattern.every((orientation, index) => orientation === expectedPattern[index])
|
|
1850
|
+
&& routeContainsChannelPin(
|
|
1851
|
+
collapsed,
|
|
1852
|
+
axis === 'x' ? 'channelX' : 'channelY',
|
|
1853
|
+
coordinate,
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function routeMatchesPresetFamily(preset, points, from, to) {
|
|
1858
|
+
const collapsed = normalizeRoutePoints(points.map((point) => [...point]));
|
|
1859
|
+
const segments = routeSegments(collapsed);
|
|
1860
|
+
if (preset === 'straight') return collapsed.length === 2;
|
|
1861
|
+
if (preset === 'drop') {
|
|
1862
|
+
if (from.lane === to.lane) return false;
|
|
1863
|
+
if (collapsed.length === 2 && segments[0]?.orientation === 'vertical') return true;
|
|
1864
|
+
const upper = from.cy <= to.cy ? from : to;
|
|
1865
|
+
const lower = upper === from ? to : from;
|
|
1866
|
+
return segments.some(({ start, orientation }) => (
|
|
1867
|
+
orientation === 'horizontal'
|
|
1868
|
+
&& start[1] >= upper.y + upper.height - 0.0001
|
|
1869
|
+
&& start[1] <= lower.y + 0.0001
|
|
1870
|
+
&& corridorTopologyMatches(points, 'y', start[1])
|
|
1871
|
+
));
|
|
1872
|
+
}
|
|
1873
|
+
if (preset === 'outside-right' || preset === 'return-left') {
|
|
1874
|
+
const boundary = preset === 'outside-right'
|
|
1875
|
+
? Math.max(from.x + from.width, to.x + to.width)
|
|
1876
|
+
: Math.min(from.x, to.x);
|
|
1877
|
+
return segments.some(({ start, orientation }) => (
|
|
1878
|
+
orientation === 'vertical'
|
|
1879
|
+
&& (preset === 'outside-right'
|
|
1880
|
+
? start[0] > boundary + 0.0001
|
|
1881
|
+
: start[0] < boundary - 0.0001)
|
|
1882
|
+
&& corridorTopologyMatches(points, 'x', start[0])
|
|
1883
|
+
));
|
|
1884
|
+
}
|
|
1885
|
+
if (preset === 'bottom-channel' || preset === 'up-channel') {
|
|
1886
|
+
const boundary = preset === 'bottom-channel'
|
|
1887
|
+
? Math.max(from.y + from.height, to.y + to.height)
|
|
1888
|
+
: Math.min(from.y, to.y);
|
|
1889
|
+
return segments.some(({ start, orientation }) => (
|
|
1890
|
+
orientation === 'horizontal'
|
|
1891
|
+
&& (preset === 'bottom-channel'
|
|
1892
|
+
? start[1] > boundary + 0.0001
|
|
1893
|
+
: start[1] < boundary - 0.0001)
|
|
1894
|
+
&& corridorTopologyMatches(points, 'y', start[1])
|
|
1895
|
+
));
|
|
1896
|
+
}
|
|
1897
|
+
return false;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function routeContainsChannelPin(points, field, value) {
|
|
1901
|
+
return points.slice(0, -1).some((start, index) => {
|
|
1902
|
+
const end = points[index + 1];
|
|
1903
|
+
if (field === 'channelX') {
|
|
1904
|
+
return start[0] === value
|
|
1905
|
+
&& end[0] === value
|
|
1906
|
+
&& Math.abs(end[1] - start[1]) > 0.0001;
|
|
1907
|
+
}
|
|
1908
|
+
return start[1] === value
|
|
1909
|
+
&& end[1] === value
|
|
1910
|
+
&& Math.abs(end[0] - start[0]) > 0.0001;
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function validateReadablePinnedGeometry() {
|
|
1915
|
+
if (workflow.schema_version !== 2) return;
|
|
1916
|
+
for (const edge of workflow.edges) {
|
|
1917
|
+
if (!nodes.has(edge.from) || !nodes.has(edge.to)) continue;
|
|
1918
|
+
validateReadableRouteControls(edge);
|
|
1919
|
+
const edgeName = workflowEdgeName(edge);
|
|
1920
|
+
const edgeIndex = sourceIndexes.edges.get(edge);
|
|
1921
|
+
if (Array.isArray(edge.labelAt)) {
|
|
1922
|
+
const rect = labelRectFor(edge, workflow.edges.indexOf(edge));
|
|
1923
|
+
if (rect && (rect.x < 0 || rect.y < 0)) {
|
|
1924
|
+
throwExplicitPinConflict(edge, 'viewBox-origin containment', {
|
|
1925
|
+
conflictingPins: [{
|
|
1926
|
+
edge: edgeName,
|
|
1927
|
+
field: 'labelAt',
|
|
1928
|
+
path: `/edges/${edgeIndex}/labelAt`,
|
|
1929
|
+
value: [...edge.labelAt],
|
|
1930
|
+
}],
|
|
1931
|
+
offendingRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
1932
|
+
minimumCoordinate: 0,
|
|
1933
|
+
}, [verifiedLabelAtNudge(edge)]);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
const negativeViaIndex = asArray(edge.via).findIndex(([x, y]) => x < 0 || y < 0);
|
|
1937
|
+
const negativeRoutePin = negativeViaIndex >= 0
|
|
1938
|
+
? {
|
|
1939
|
+
field: 'via',
|
|
1940
|
+
path: `/edges/${edgeIndex}/via/${negativeViaIndex}`,
|
|
1941
|
+
value: [...edge.via[negativeViaIndex]],
|
|
1942
|
+
}
|
|
1943
|
+
: edge.channelX < 0
|
|
1944
|
+
? { field: 'channelX', path: `/edges/${edgeIndex}/channelX`, value: edge.channelX }
|
|
1945
|
+
: edge.channelY < 0
|
|
1946
|
+
? { field: 'channelY', path: `/edges/${edgeIndex}/channelY`, value: edge.channelY }
|
|
1947
|
+
: null;
|
|
1948
|
+
if (negativeRoutePin) {
|
|
1949
|
+
throwExplicitPinConflict(edge, 'viewBox-origin containment', {
|
|
1950
|
+
conflictingPins: [{ edge: edgeName, ...negativeRoutePin }],
|
|
1951
|
+
minimumCoordinate: 0,
|
|
1952
|
+
}, [
|
|
1953
|
+
verifiedAutomaticRouteFix(edge),
|
|
1954
|
+
verifiedAutomaticRouteFix(edge, { clearSides: true }),
|
|
1955
|
+
]);
|
|
1956
|
+
}
|
|
1957
|
+
const hasPinnedRoute = Array.isArray(edge.via)
|
|
1958
|
+
|| edge.channelX !== undefined
|
|
1959
|
+
|| edge.channelY !== undefined;
|
|
1960
|
+
const points = pathFor(edge).points;
|
|
1961
|
+
if (hasPinnedRoute) {
|
|
1962
|
+
const invalidPointIndex = points.findIndex((point) => (
|
|
1963
|
+
!Array.isArray(point) || point.length !== 2 || !isFinitePoint(...point)
|
|
1964
|
+
));
|
|
1965
|
+
if (invalidPointIndex !== -1) {
|
|
1966
|
+
const alternatives = verifiedRouteGeometryPinAlternatives(edge);
|
|
1967
|
+
throwExplicitPinConflict(edge, 'finite route coordinates', {
|
|
1968
|
+
conflictingPins: alternatives.conflictingPins,
|
|
1969
|
+
pointIndex: invalidPointIndex,
|
|
1970
|
+
point: points[invalidPointIndex],
|
|
1971
|
+
}, alternatives.supportedFixes);
|
|
1972
|
+
}
|
|
1973
|
+
for (let segmentIndex = 0; segmentIndex < points.length - 1; segmentIndex += 1) {
|
|
1974
|
+
const start = points[segmentIndex];
|
|
1975
|
+
const end = points[segmentIndex + 1];
|
|
1976
|
+
const dx = Math.abs(end[0] - start[0]);
|
|
1977
|
+
const dy = Math.abs(end[1] - start[1]);
|
|
1978
|
+
if (dx <= 0.0001 && dy <= 0.0001) {
|
|
1979
|
+
const duplicateFix = Array.isArray(edge.via) && edge.via.length
|
|
1980
|
+
? verifiedEdgeFix(
|
|
1981
|
+
edge,
|
|
1982
|
+
`remove duplicate via[${Math.min(segmentIndex, edge.via.length - 1)}] and keep the remaining authored pins unchanged`,
|
|
1983
|
+
(candidate) => candidate.via.splice(Math.min(segmentIndex, candidate.via.length - 1), 1),
|
|
1984
|
+
)
|
|
1985
|
+
: verifiedAutomaticRouteFix(edge);
|
|
1986
|
+
const alternatives = Array.isArray(edge.via)
|
|
1987
|
+
? null
|
|
1988
|
+
: verifiedRouteGeometryPinAlternatives(edge);
|
|
1989
|
+
throwExplicitPinConflict(edge, 'non-zero route segments', {
|
|
1990
|
+
conflictingPins: alternatives?.conflictingPins
|
|
1991
|
+
|| [authoredPinEvidence(edge, 'via')],
|
|
1992
|
+
segmentIndex,
|
|
1993
|
+
from: start,
|
|
1994
|
+
to: end,
|
|
1995
|
+
}, alternatives?.supportedFixes || [duplicateFix]);
|
|
1996
|
+
}
|
|
1997
|
+
if (dx > 0.0001 && dy > 0.0001) {
|
|
1998
|
+
const alternatives = verifiedRouteGeometryPinAlternatives(edge);
|
|
1999
|
+
throwExplicitPinConflict(edge, 'orthogonal route segments', {
|
|
2000
|
+
conflictingPins: alternatives.conflictingPins,
|
|
2001
|
+
segmentIndex,
|
|
2002
|
+
from: start,
|
|
2003
|
+
to: end,
|
|
2004
|
+
}, alternatives.supportedFixes);
|
|
2005
|
+
}
|
|
2006
|
+
const endpoint = segmentIndex === 0 || segmentIndex === points.length - 2;
|
|
2007
|
+
const minimumPx = points.length === 2 ? 28 : endpoint ? 8 : 16;
|
|
2008
|
+
const lengthPx = dx + dy;
|
|
2009
|
+
if (lengthPx + 0.0001 < minimumPx) {
|
|
2010
|
+
const alternatives = verifiedRouteGeometryPinAlternatives(edge);
|
|
2011
|
+
throwExplicitPinConflict(edge, endpoint ? '8px endpoint stub clearance' : '16px interior turn clearance', {
|
|
2012
|
+
conflictingPins: alternatives.conflictingPins,
|
|
2013
|
+
segmentIndex,
|
|
2014
|
+
position: segmentIndex === 0 ? 'source-stub' : segmentIndex === points.length - 2 ? 'target-stub' : 'interior',
|
|
2015
|
+
from: start,
|
|
2016
|
+
to: end,
|
|
2017
|
+
lengthPx,
|
|
2018
|
+
minimumPx,
|
|
2019
|
+
}, alternatives.supportedFixes);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
const { fromSide, toSide } = edgeSides(edge);
|
|
2023
|
+
if (Array.isArray(edge.via)) {
|
|
2024
|
+
const missingChannelPins = presentChannelPins(edge).filter((field) => (
|
|
2025
|
+
!routeContainsChannelPin(points, field, edge[field])
|
|
2026
|
+
));
|
|
2027
|
+
if (missingChannelPins.length) {
|
|
2028
|
+
const candidateFields = ['via', ...missingChannelPins];
|
|
2029
|
+
const alternatives = verifiedPinRemovalAlternatives(
|
|
2030
|
+
edge,
|
|
2031
|
+
candidateFields,
|
|
2032
|
+
'and replan the remaining explicit route assertions',
|
|
2033
|
+
);
|
|
2034
|
+
throwExplicitPinConflict(edge, 'channel pin preservation', {
|
|
2035
|
+
route: edge.route || 'auto',
|
|
2036
|
+
conflictingPins: conflictPinsFromRemovalSets(
|
|
2037
|
+
edge,
|
|
2038
|
+
alternatives.removalSets,
|
|
2039
|
+
candidateFields,
|
|
2040
|
+
),
|
|
2041
|
+
points: points.map((point) => [...point]),
|
|
2042
|
+
}, alternatives.supportedFixes);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
if (edge.route
|
|
2046
|
+
&& edge.route !== 'auto'
|
|
2047
|
+
&& !routeMatchesPresetFamily(
|
|
2048
|
+
edge.route,
|
|
2049
|
+
points,
|
|
2050
|
+
nodes.get(edge.from),
|
|
2051
|
+
nodes.get(edge.to),
|
|
2052
|
+
)) {
|
|
2053
|
+
const authoredEdgeIndex = workflow.edges.indexOf(edge);
|
|
2054
|
+
const alternatives = verifiedPinReferenceAlternatives([
|
|
2055
|
+
{ edge, edgeIndex: authoredEdgeIndex, field: 'route' },
|
|
2056
|
+
...presentRouteGeometryFields(edge)
|
|
2057
|
+
.map((field) => ({ edge, edgeIndex: authoredEdgeIndex, field })),
|
|
2058
|
+
], 'and keep the remaining verified route assertions');
|
|
2059
|
+
throwExplicitPinConflict(edge, 'route preset compatibility', {
|
|
2060
|
+
route: edge.route,
|
|
2061
|
+
conflictingPins: alternatives.conflictingPins,
|
|
2062
|
+
points: points.map((point) => [...point]),
|
|
2063
|
+
}, alternatives.supportedFixes);
|
|
2064
|
+
}
|
|
2065
|
+
if (!routeHonorsEndpointSides(points, fromSide, toSide)) {
|
|
2066
|
+
const mismatchedSideFields = [
|
|
2067
|
+
...(edge.fromSide && edge.fromSide !== 'auto'
|
|
2068
|
+
&& !endpointSideIsHonored(points, fromSide, 'source') ? ['fromSide'] : []),
|
|
2069
|
+
...(edge.toSide && edge.toSide !== 'auto'
|
|
2070
|
+
&& !endpointSideIsHonored(points, toSide, 'target') ? ['toSide'] : []),
|
|
2071
|
+
];
|
|
2072
|
+
const candidateFields = [
|
|
2073
|
+
...mismatchedSideFields,
|
|
2074
|
+
...presentRouteGeometryFields(edge),
|
|
2075
|
+
];
|
|
2076
|
+
const alternatives = verifiedPinRemovalAlternatives(
|
|
2077
|
+
edge,
|
|
2078
|
+
candidateFields,
|
|
2079
|
+
'and replan the remaining explicit pins',
|
|
2080
|
+
);
|
|
2081
|
+
throwExplicitPinConflict(edge, 'perpendicular endpoint-side direction', {
|
|
2082
|
+
conflictingPins: conflictPinsFromRemovalSets(
|
|
2083
|
+
edge,
|
|
2084
|
+
alternatives.removalSets,
|
|
2085
|
+
candidateFields,
|
|
2086
|
+
),
|
|
2087
|
+
points: points.map((point) => [...point]),
|
|
2088
|
+
fromSide,
|
|
2089
|
+
toSide,
|
|
2090
|
+
}, alternatives.supportedFixes);
|
|
2091
|
+
}
|
|
2092
|
+
const nodeCollision = firstRouteNodeCollision(edge, points);
|
|
2093
|
+
if (nodeCollision) {
|
|
2094
|
+
const alternatives = verifiedRouteGeometryPinAlternatives(edge);
|
|
2095
|
+
throwExplicitPinConflict(edge, 'node clearance', {
|
|
2096
|
+
conflictingPins: alternatives.conflictingPins,
|
|
2097
|
+
...nodeCollision,
|
|
2098
|
+
}, alternatives.supportedFixes);
|
|
2099
|
+
}
|
|
2100
|
+
const legendObstacle = workflowLegendRects().find((rect) => points.slice(0, -1).some((point, index) => (
|
|
2101
|
+
segmentIntersectsRect({ start: point, end: points[index + 1] }, rect)
|
|
2102
|
+
)));
|
|
2103
|
+
if (legendObstacle) {
|
|
2104
|
+
const alternatives = verifiedRouteGeometryPinAlternatives(edge);
|
|
2105
|
+
throwExplicitPinConflict(edge, 'legend clearance', {
|
|
2106
|
+
conflictingPins: alternatives.conflictingPins,
|
|
2107
|
+
points: points.map((point) => [...point]),
|
|
2108
|
+
legendObstacle,
|
|
2109
|
+
}, alternatives.supportedFixes);
|
|
2110
|
+
}
|
|
2111
|
+
const compositionObstacle = workflowSceneLabelObstacles().find((rect) => (
|
|
2112
|
+
points.slice(0, -1).some((point, index) => (
|
|
2113
|
+
segmentIntersectsRect({ start: point, end: points[index + 1] }, rect)
|
|
2114
|
+
))
|
|
2115
|
+
));
|
|
2116
|
+
if (compositionObstacle) {
|
|
2117
|
+
const alternatives = verifiedRouteGeometryPinAlternatives(edge);
|
|
2118
|
+
throwExplicitPinConflict(edge, 'lane/phase/group label clearance', {
|
|
2119
|
+
conflictingPins: alternatives.conflictingPins,
|
|
2120
|
+
points: points.map((point) => [...point]),
|
|
2121
|
+
compositionObstacle,
|
|
2122
|
+
}, alternatives.supportedFixes);
|
|
2123
|
+
}
|
|
2124
|
+
const [frameRun] = collectBorderRuns({
|
|
2125
|
+
routedRelations: [{ points }],
|
|
2126
|
+
frames: workflowCompositionFrames(),
|
|
2127
|
+
});
|
|
2128
|
+
if (frameRun) {
|
|
2129
|
+
const alternatives = verifiedRouteGeometryPinAlternatives(edge);
|
|
2130
|
+
throwExplicitPinConflict(edge, 'structural-frame border clearance', {
|
|
2131
|
+
conflictingPins: alternatives.conflictingPins,
|
|
2132
|
+
points: points.map((point) => [...point]),
|
|
2133
|
+
frame: frameRun.frame?.id ?? frameRun.frameIndex,
|
|
2134
|
+
side: frameRun.side,
|
|
2135
|
+
overlapLengthPx: frameRun.overlapLength,
|
|
2136
|
+
}, alternatives.supportedFixes);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
if (edge.labelAt) {
|
|
2141
|
+
const rect = labelRectFor(edge, workflow.edges.indexOf(edge));
|
|
2142
|
+
const obstacle = rect && [...nodes.values()].find((node) => rectsOverlap(rect, node, -2));
|
|
2143
|
+
if (obstacle) {
|
|
2144
|
+
throwExplicitPinConflict(edge, 'edge-label node clearance', {
|
|
2145
|
+
conflictingPins: [authoredPinEvidence(edge, 'labelAt')],
|
|
2146
|
+
labelAt: [...edge.labelAt],
|
|
2147
|
+
labelRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
2148
|
+
obstacleNode: obstacle.id,
|
|
2149
|
+
}, [verifiedEdgeFix(
|
|
2150
|
+
edge,
|
|
2151
|
+
'remove labelAt so readable-v2 can use its verified automatic label placement',
|
|
2152
|
+
(candidate) => { delete candidate.labelAt; },
|
|
2153
|
+
)]);
|
|
2154
|
+
}
|
|
2155
|
+
const legendObstacle = rect && workflowLegendRects().find((legendRect) => (
|
|
2156
|
+
rectsOverlap(rect, legendRect)
|
|
2157
|
+
));
|
|
2158
|
+
if (legendObstacle) {
|
|
2159
|
+
throwExplicitPinConflict(edge, 'edge-label legend clearance', {
|
|
2160
|
+
conflictingPins: [authoredPinEvidence(edge, 'labelAt')],
|
|
2161
|
+
labelAt: [...edge.labelAt],
|
|
2162
|
+
labelRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
2163
|
+
legendObstacle,
|
|
2164
|
+
}, [verifiedEdgeFix(
|
|
2165
|
+
edge,
|
|
2166
|
+
'remove labelAt so readable-v2 can use its verified automatic label placement',
|
|
2167
|
+
(candidate) => { delete candidate.labelAt; },
|
|
2168
|
+
)]);
|
|
2169
|
+
}
|
|
2170
|
+
const compositionObstacle = rect && workflowSceneLabelObstacles().find((candidate) => (
|
|
2171
|
+
rectsOverlap(rect, candidate)
|
|
2172
|
+
));
|
|
2173
|
+
if (compositionObstacle) {
|
|
2174
|
+
throwExplicitPinConflict(edge, 'edge-label lane/phase/group clearance', {
|
|
2175
|
+
conflictingPins: [authoredPinEvidence(edge, 'labelAt')],
|
|
2176
|
+
labelAt: [...edge.labelAt],
|
|
2177
|
+
labelRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
2178
|
+
compositionObstacle,
|
|
2179
|
+
}, [verifiedEdgeFix(
|
|
2180
|
+
edge,
|
|
2181
|
+
`remove labelAt from edge "${workflowEdgeName(edge)}" so readable-v2 can use its verified automatic label placement`,
|
|
2182
|
+
(candidate) => { delete candidate.labelAt; },
|
|
2183
|
+
)]);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
validateReadablePairwisePinConflicts();
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
function validateWorkflow() {
|
|
2191
|
+
const problems = [];
|
|
2192
|
+
if (workflow.schema_version !== 1 && workflow.schema_version !== 2) {
|
|
2193
|
+
problems.push('Workflow files must set "schema_version" to 1 or 2.');
|
|
2194
|
+
}
|
|
2195
|
+
if (workflow.diagram_type !== 'workflow') {
|
|
2196
|
+
problems.push(`Unsupported diagram_type "${workflow.diagram_type}". Expected "workflow".`);
|
|
2197
|
+
}
|
|
2198
|
+
if (!workflow.meta || !workflow.meta.title) {
|
|
2199
|
+
problems.push('Workflow files must include meta.title.');
|
|
2200
|
+
}
|
|
2201
|
+
if (!Array.isArray(workflow.lanes) || !workflow.lanes.length) {
|
|
2202
|
+
problems.push('Workflow files must include at least one lane.');
|
|
2203
|
+
}
|
|
2204
|
+
if (!Array.isArray(workflow.nodes)) {
|
|
2205
|
+
problems.push('Workflow files must include a nodes array.');
|
|
2206
|
+
}
|
|
2207
|
+
if (!Array.isArray(workflow.edges)) {
|
|
2208
|
+
problems.push('Workflow files must include an edges array.');
|
|
2209
|
+
}
|
|
2210
|
+
if (workflow.phases !== undefined && !Array.isArray(workflow.phases)) {
|
|
2211
|
+
problems.push('Workflow "phases" must be an array.');
|
|
2212
|
+
}
|
|
2213
|
+
if (workflow.groups !== undefined && !Array.isArray(workflow.groups)) {
|
|
2214
|
+
problems.push('Workflow "groups" must be an array.');
|
|
2215
|
+
}
|
|
2216
|
+
if (workflow.mainPath !== undefined && !Array.isArray(workflow.mainPath)) {
|
|
2217
|
+
problems.push('Workflow "mainPath" must be an array of node ids.');
|
|
2218
|
+
}
|
|
2219
|
+
if (workflow.cards !== undefined && !Array.isArray(workflow.cards)) {
|
|
2220
|
+
problems.push('Workflow "cards" must be an array.');
|
|
2221
|
+
}
|
|
2222
|
+
if (problems.length) {
|
|
2223
|
+
throwDiagnosticProblems('Workflow layout validation failed', problems, {
|
|
2224
|
+
subject: { diagramType: 'workflow' },
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
enforceLegacyColumnCapacity();
|
|
2229
|
+
|
|
2230
|
+
const laneIds = new Set(workflow.lanes.map((lane) => lane.id));
|
|
2231
|
+
if (laneIds.size !== workflow.lanes.length) {
|
|
2232
|
+
problems.push('Lane ids must be unique.');
|
|
2233
|
+
}
|
|
2234
|
+
if (nodes.size !== workflow.nodes.length) {
|
|
2235
|
+
problems.push('Node ids must be unique.');
|
|
2236
|
+
}
|
|
2237
|
+
const phaseIds = new Set(asArray(workflow.phases).map((phase) => phase.id));
|
|
2238
|
+
if (phaseIds.size !== asArray(workflow.phases).length) {
|
|
2239
|
+
problems.push('Phase ids must be unique.');
|
|
2240
|
+
}
|
|
2241
|
+
const groupIds = new Set(asArray(workflow.groups).map((group) => group.id));
|
|
2242
|
+
if (groupIds.size !== asArray(workflow.groups).length) {
|
|
2243
|
+
problems.push('Group ids must be unique.');
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
for (const node of nodes.values()) {
|
|
2247
|
+
if (!laneIds.has(node.lane)) {
|
|
2248
|
+
problems.push(`Node "${node.id}" uses unknown lane "${node.lane}".`);
|
|
2249
|
+
continue;
|
|
2250
|
+
}
|
|
2251
|
+
if (!Number.isInteger(node.col) || node.col < 0 || node.col >= layout.colXs.length) {
|
|
2252
|
+
problems.push(`Node "${node.id}" uses column ${node.col}, but valid columns are integers 0..${layout.colXs.length - 1}.`);
|
|
2253
|
+
continue;
|
|
2254
|
+
}
|
|
2255
|
+
if (!isFinitePoint(node.x, node.y, node.cx, node.cy)) {
|
|
2256
|
+
problems.push(`Node "${node.id}" produced non-finite coordinates — check col, width, height, and yOffset are numbers.`);
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2259
|
+
const estLabelW = textUnits(node.label) * 6.8;
|
|
2260
|
+
if (estLabelW > node.width + 6) {
|
|
2261
|
+
problems.push(`Label "${node.label}" (~${Math.round(estLabelW)}px) is wider than node "${node.id}" (${node.width}px) — shorten the label or increase node.width.`);
|
|
2262
|
+
}
|
|
2263
|
+
const brandRailProblem = brandTopRailProblem(node, node.width, nodeTextFit.labelMinimum);
|
|
2264
|
+
if (brandRailProblem) problems.push(brandRailProblem);
|
|
2265
|
+
const availableTextW = availableNodeTextWidth(node.width);
|
|
2266
|
+
for (const [field, value, minimum] of [
|
|
2267
|
+
['Sublabel', node.sublabel, nodeTextFit.sublabelMinimum],
|
|
2268
|
+
['Tag', node.tag, nodeTextFit.tagMinimum],
|
|
2269
|
+
]) {
|
|
2270
|
+
if (!value) continue;
|
|
2271
|
+
const minimumW = minimumNodeTextWidth(value, minimum);
|
|
2272
|
+
if (minimumW > availableTextW) {
|
|
2273
|
+
problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but node "${node.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or increase node.width.`);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
const top = laneTop(node.lane);
|
|
2278
|
+
const contentTop = top + layout.laneTitleH + laneGroupHeaderH(node.lane);
|
|
2279
|
+
const laneRight = layout.laneX + layout.laneW;
|
|
2280
|
+
if (node.x < layout.laneX || node.x + node.width > laneRight) {
|
|
2281
|
+
problems.push(`Node "${node.id}" exceeds the horizontal bounds of lane "${node.lane}".`);
|
|
2282
|
+
}
|
|
2283
|
+
if (node.y < contentTop || node.y + node.height > top + laneHeight(node.lane)) {
|
|
2284
|
+
problems.push(`Node "${node.id}" collides with the title or boundary of lane "${node.lane}".`);
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
const phaseRanges = [];
|
|
2289
|
+
for (const phase of asArray(workflow.phases)) {
|
|
2290
|
+
if (!Number.isInteger(phase.fromCol) || !Number.isInteger(phase.toCol)) {
|
|
2291
|
+
problems.push(`Phase "${phase.id}" must use integer fromCol/toCol values.`);
|
|
2292
|
+
continue;
|
|
2293
|
+
}
|
|
2294
|
+
if (phase.fromCol < 0 || phase.toCol >= layout.colXs.length || phase.fromCol > phase.toCol) {
|
|
2295
|
+
problems.push(`Phase "${phase.id}" uses invalid columns ${phase.fromCol}..${phase.toCol}; use an ordered range within 0..${layout.colXs.length - 1}.`);
|
|
2296
|
+
} else {
|
|
2297
|
+
phaseRanges.push(phase);
|
|
2298
|
+
}
|
|
2299
|
+
const estLabelW = textUnits(phase.label) * 5.6;
|
|
2300
|
+
const width = phaseSpan(phase).width;
|
|
2301
|
+
if (estLabelW > width + 8) {
|
|
2302
|
+
problems.push(`Phase label "${phase.label}" (~${Math.round(estLabelW)}px) is wider than its ${Math.round(width)}px span — shorten the label or widen the phase range.`);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
phaseRanges.sort((a, b) => a.fromCol - b.fromCol || a.toCol - b.toCol);
|
|
2306
|
+
for (let i = 0; i < phaseRanges.length; i += 1) {
|
|
2307
|
+
for (let j = i + 1; j < phaseRanges.length; j += 1) {
|
|
2308
|
+
const earlier = phaseRanges[i];
|
|
2309
|
+
const later = phaseRanges[j];
|
|
2310
|
+
if (later.fromCol > earlier.toCol) break;
|
|
2311
|
+
problems.push(`Phase "${later.id}" (${later.fromCol}..${later.toCol}) overlaps phase "${earlier.id}" (${earlier.fromCol}..${earlier.toCol}) — start at col ${earlier.toCol + 1} or later, or end the earlier phase at col ${later.fromCol - 1}.`);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
for (const group of asArray(workflow.groups)) {
|
|
2316
|
+
if (!laneIds.has(group.lane)) {
|
|
2317
|
+
problems.push(`Group "${group.id}" uses unknown lane "${group.lane}".`);
|
|
2318
|
+
continue;
|
|
2319
|
+
}
|
|
2320
|
+
if (!Number.isInteger(group.fromCol) || !Number.isInteger(group.toCol)) {
|
|
2321
|
+
problems.push(`Group "${group.id}" must use integer fromCol/toCol values.`);
|
|
2322
|
+
continue;
|
|
2323
|
+
}
|
|
2324
|
+
if (group.fromCol < 0 || group.toCol >= layout.colXs.length || group.fromCol > group.toCol) {
|
|
2325
|
+
problems.push(`Group "${group.id}" uses invalid columns ${group.fromCol}..${group.toCol}; use an ordered range within 0..${layout.colXs.length - 1}.`);
|
|
2326
|
+
}
|
|
2327
|
+
const contained = [...nodes.values()].some((node) => node.lane === group.lane && node.col >= group.fromCol && node.col <= group.toCol);
|
|
2328
|
+
if (!contained) {
|
|
2329
|
+
problems.push(`Group "${group.id}" does not contain any nodes — align its lane/columns with the parallel or branch work it frames.`);
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
const byLane = new Map();
|
|
2334
|
+
for (const node of nodes.values()) {
|
|
2335
|
+
byLane.set(node.lane, [...(byLane.get(node.lane) || []), node]);
|
|
2336
|
+
}
|
|
2337
|
+
for (const [lane, laneNodes] of byLane) {
|
|
2338
|
+
for (let i = 0; i < laneNodes.length; i += 1) {
|
|
2339
|
+
for (let j = i + 1; j < laneNodes.length; j += 1) {
|
|
2340
|
+
if (rectsOverlap(laneNodes[i], laneNodes[j], 8)) {
|
|
2341
|
+
problems.push(`Nodes "${laneNodes[i].id}" and "${laneNodes[j].id}" are less than 8px apart in lane "${lane}" — move one to another col, adjust yOffset, or reduce width/height.`);
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
for (const edge of workflow.edges) {
|
|
2348
|
+
if (!nodes.has(edge.from)) problems.push(`Edge "${edge.label || edge.from}" references unknown source "${edge.from}".`);
|
|
2349
|
+
if (!nodes.has(edge.to)) problems.push(`Edge "${edge.label || edge.to}" references unknown target "${edge.to}".`);
|
|
2350
|
+
if (nodes.has(edge.from) && nodes.has(edge.to)) {
|
|
2351
|
+
const routed = pathFor(edge);
|
|
2352
|
+
if (routed.points.length === 2) {
|
|
2353
|
+
const [start, end] = routed.points;
|
|
2354
|
+
const segmentLength = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
|
2355
|
+
if (segmentLength < 28) {
|
|
2356
|
+
problems.push(`Edge "${edge.from}" -> "${edge.to}" is too short (${Math.round(segmentLength)}px; minimum 28px) — move the nodes farther apart or use a verified orthogonal route with readable clearance.`);
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
problems.push(...cleanEndpointSideProblems({
|
|
2363
|
+
relations: workflow.edges,
|
|
2364
|
+
endpointIds: new Set(nodes.keys()),
|
|
2365
|
+
pathFor,
|
|
2366
|
+
diagramType: 'workflow',
|
|
2367
|
+
relationCollection: 'edges',
|
|
2368
|
+
fromSideFor: (edge) => edgeSides(edge).fromSide,
|
|
2369
|
+
toSideFor: (edge) => edgeSides(edge).toSide,
|
|
2370
|
+
routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross node borders perpendicularly',
|
|
2371
|
+
}));
|
|
2372
|
+
problems.push(...cleanFlowProblems({
|
|
2373
|
+
relations: workflow.edges,
|
|
2374
|
+
obstacles: nodes.values(),
|
|
2375
|
+
pathFor,
|
|
2376
|
+
diagramType: 'workflow',
|
|
2377
|
+
relationCollection: 'edges',
|
|
2378
|
+
obstacleKind: 'node',
|
|
2379
|
+
routeHint: 'adjust fromSide/toSide, set route/via or channel coordinates, or move the node to a clearer lane/column'
|
|
2380
|
+
}));
|
|
2381
|
+
problems.push(...cleanCrossingProblems({
|
|
2382
|
+
relations: workflow.edges,
|
|
2383
|
+
endpointIds: new Set(nodes.keys()),
|
|
2384
|
+
pathFor,
|
|
2385
|
+
diagramType: 'workflow',
|
|
2386
|
+
relationCollection: 'edges',
|
|
2387
|
+
profile: workflow.meta?.quality_profile,
|
|
2388
|
+
profileIsAuthoritative: true,
|
|
2389
|
+
mergeForwardCollinearWaypoints: workflow.schema_version === 2,
|
|
2390
|
+
routeHint: 'adjust route/via, bias, or channel coordinates so the edges use separate lane corridors'
|
|
2391
|
+
}));
|
|
2392
|
+
problems.push(...cleanAmbiguousCorridorProblems({
|
|
2393
|
+
relations: workflow.edges,
|
|
2394
|
+
endpointIds: new Set(nodes.keys()),
|
|
2395
|
+
pathFor,
|
|
2396
|
+
diagramType: 'workflow',
|
|
2397
|
+
relationCollection: 'edges',
|
|
2398
|
+
profile: workflow.meta?.quality_profile,
|
|
2399
|
+
profileIsAuthoritative: true,
|
|
2400
|
+
routeHint: 'adjust route/via, bias, or channel coordinates so unrelated edges do not visually merge'
|
|
2401
|
+
}));
|
|
2402
|
+
problems.push(...cleanBorderRunProblems({
|
|
2403
|
+
relations: workflow.edges,
|
|
2404
|
+
endpointIds: new Set(nodes.keys()),
|
|
2405
|
+
frames: workflowCompositionFrames(),
|
|
2406
|
+
pathFor,
|
|
2407
|
+
diagramType: 'workflow',
|
|
2408
|
+
relationCollection: 'edges',
|
|
2409
|
+
profile: workflow.meta?.quality_profile,
|
|
2410
|
+
profileIsAuthoritative: true,
|
|
2411
|
+
routeHint: 'adjust route/via, bias, or channel coordinates so the edge crosses the lane or group perpendicularly instead of following its border'
|
|
2412
|
+
}));
|
|
2413
|
+
problems.push(...cleanRouteRhythmProblems({
|
|
2414
|
+
relations: workflow.edges,
|
|
2415
|
+
endpointIds: new Set(nodes.keys()),
|
|
2416
|
+
pathFor,
|
|
2417
|
+
diagramType: 'workflow',
|
|
2418
|
+
relationCollection: 'edges',
|
|
2419
|
+
profile: workflow.meta?.quality_profile,
|
|
2420
|
+
profileIsAuthoritative: true,
|
|
2421
|
+
routeHint: 'adjust route/via, bias, or channel coordinates so each turn has a readable run-up'
|
|
2422
|
+
}));
|
|
2423
|
+
|
|
2424
|
+
if (Array.isArray(workflow.mainPath)) {
|
|
2425
|
+
for (const id of workflow.mainPath) {
|
|
2426
|
+
if (!nodes.has(id)) {
|
|
2427
|
+
problems.push(`mainPath references unknown node "${id}".`);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
for (let i = 0; i < workflow.mainPath.length - 1; i += 1) {
|
|
2431
|
+
const fromId = workflow.mainPath[i];
|
|
2432
|
+
const toId = workflow.mainPath[i + 1];
|
|
2433
|
+
const from = nodes.get(fromId);
|
|
2434
|
+
const to = nodes.get(toId);
|
|
2435
|
+
if (!from || !to) continue;
|
|
2436
|
+
const linked = workflow.edges.some((edge) => edge.from === fromId && edge.to === toId);
|
|
2437
|
+
if (!linked) {
|
|
2438
|
+
problems.push(`mainPath step "${fromId}" -> "${toId}" has no matching edge — add the edge or remove the pair from mainPath.`);
|
|
2439
|
+
}
|
|
2440
|
+
if (to.col < from.col) {
|
|
2441
|
+
problems.push(`mainPath step "${fromId}" -> "${toId}" moves backward from col ${from.col} to ${to.col} — use a return edge outside mainPath for loops.`);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
const labelRects = [];
|
|
2447
|
+
for (const [edgeIndex, edge] of workflow.edges.entries()) {
|
|
2448
|
+
const labelRect = labelRectFor(edge, edgeIndex);
|
|
2449
|
+
if (labelRect) labelRects.push(labelRect);
|
|
2450
|
+
}
|
|
2451
|
+
for (const rect of labelRects) {
|
|
2452
|
+
for (const node of nodes.values()) {
|
|
2453
|
+
if (rectsOverlap(rect, node, -2)) {
|
|
2454
|
+
problems.push(`Label "${rect.label}" overlaps node "${node.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, node, 'node')}`);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
for (let i = 0; i < labelRects.length; i += 1) {
|
|
2459
|
+
for (let j = i + 1; j < labelRects.length; j += 1) {
|
|
2460
|
+
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
|
2461
|
+
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy/labelSegment or route one relationship through a separate corridor.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
problems.push(...cleanLabelRouteClearanceProblems({
|
|
2466
|
+
relations: workflow.edges,
|
|
2467
|
+
labels: labelRects,
|
|
2468
|
+
endpointIds: new Set(nodes.keys()),
|
|
2469
|
+
pathFor,
|
|
2470
|
+
diagramType: 'workflow',
|
|
2471
|
+
relationCollection: 'edges',
|
|
2472
|
+
profile: workflow.meta?.quality_profile,
|
|
2473
|
+
profileIsAuthoritative: true,
|
|
2474
|
+
}));
|
|
2475
|
+
|
|
2476
|
+
if (workflow.schema_version === 1) {
|
|
2477
|
+
if (viewBox[0] < layout.laneX + layout.laneW + 16) {
|
|
2478
|
+
problems.push(`viewBox width ${viewBox[0]} clips the ${layout.laneW}px lanes — set meta.viewBox[0] to at least ${layout.laneX + layout.laneW + 16}.`);
|
|
2479
|
+
}
|
|
2480
|
+
if (legendY() + 18 > viewBox[1]) {
|
|
2481
|
+
problems.push(`Legend exceeds viewBox height ${viewBox[1]} — set meta.viewBox[1] to at least ${legendY() + 18}.`);
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
if (problems.length) {
|
|
2486
|
+
throwDiagnosticProblems('Workflow layout validation failed', problems, {
|
|
2487
|
+
subject: { diagramType: 'workflow' },
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
function validateReadableInputsBeforeRouting() {
|
|
2493
|
+
if (workflow.schema_version !== 2) return;
|
|
2494
|
+
const fail = (diagnostic) => throwDiagnosticError(diagnostic.message, [diagnostic]);
|
|
2495
|
+
const unusedId = (base, used) => {
|
|
2496
|
+
for (let suffix = 2; ; suffix += 1) {
|
|
2497
|
+
const candidate = `${base}-${suffix}`;
|
|
2498
|
+
if (!used.has(candidate)) return candidate;
|
|
2499
|
+
}
|
|
2500
|
+
};
|
|
2501
|
+
const authoredLanes = [...workflow.lanes].sort((left, right) => (
|
|
2502
|
+
sourceIndexes.lanes.get(left) - sourceIndexes.lanes.get(right)
|
|
2503
|
+
));
|
|
2504
|
+
const authoredNodes = [...workflow.nodes].sort((left, right) => (
|
|
2505
|
+
sourceIndexes.nodes.get(left) - sourceIndexes.nodes.get(right)
|
|
2506
|
+
));
|
|
2507
|
+
const authoredEdges = [...workflow.edges].sort((left, right) => (
|
|
2508
|
+
sourceIndexes.edges.get(left) - sourceIndexes.edges.get(right)
|
|
2509
|
+
));
|
|
2510
|
+
|
|
2511
|
+
const firstLaneIndex = new Map();
|
|
2512
|
+
for (const lane of authoredLanes) {
|
|
2513
|
+
const laneIndex = sourceIndexes.lanes.get(lane);
|
|
2514
|
+
if (firstLaneIndex.has(lane.id)) {
|
|
2515
|
+
const message = `Workflow lane id "${lane.id}" is duplicated.`;
|
|
2516
|
+
const replacement = unusedId(lane.id, new Set(workflow.lanes.map(({ id }) => id)));
|
|
2517
|
+
const canonicalLaneIndex = workflow.lanes.indexOf(lane);
|
|
2518
|
+
const supportedFixes = acceptsFix((document) => {
|
|
2519
|
+
document.lanes[canonicalLaneIndex].id = replacement;
|
|
2520
|
+
}) ? [`rename /lanes/${laneIndex}/id to verified unique id "${replacement}"`] : [];
|
|
2521
|
+
fail({
|
|
2522
|
+
code: 'workflow/duplicate-lane-id',
|
|
2523
|
+
severity: 'error',
|
|
2524
|
+
message,
|
|
2525
|
+
subject: { diagramType: 'workflow', lane: lane.id, path: `/lanes/${laneIndex}/id` },
|
|
2526
|
+
evidence: {
|
|
2527
|
+
duplicateLaneId: lane.id,
|
|
2528
|
+
firstPath: `/lanes/${firstLaneIndex.get(lane.id)}/id`,
|
|
2529
|
+
duplicatePath: `/lanes/${laneIndex}/id`,
|
|
2530
|
+
},
|
|
2531
|
+
supportedFixes,
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
firstLaneIndex.set(lane.id, laneIndex);
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
const firstNodeIndex = new Map();
|
|
2538
|
+
for (const node of authoredNodes) {
|
|
2539
|
+
const nodeIndex = sourceIndexes.nodes.get(node);
|
|
2540
|
+
if (firstNodeIndex.has(node.id)) {
|
|
2541
|
+
const message = `Workflow node id "${node.id}" is duplicated.`;
|
|
2542
|
+
const replacement = unusedId(node.id, new Set(workflow.nodes.map(({ id }) => id)));
|
|
2543
|
+
const canonicalNodeIndex = workflow.nodes.indexOf(node);
|
|
2544
|
+
const supportedFixes = acceptsFix((document) => {
|
|
2545
|
+
document.nodes[canonicalNodeIndex].id = replacement;
|
|
2546
|
+
}) ? [`rename /nodes/${nodeIndex}/id to verified unique id "${replacement}"`] : [];
|
|
2547
|
+
fail({
|
|
2548
|
+
code: 'workflow/duplicate-node-id',
|
|
2549
|
+
severity: 'error',
|
|
2550
|
+
message,
|
|
2551
|
+
subject: { diagramType: 'workflow', node: node.id, path: `/nodes/${nodeIndex}/id` },
|
|
2552
|
+
evidence: {
|
|
2553
|
+
duplicateNodeId: node.id,
|
|
2554
|
+
firstPath: `/nodes/${firstNodeIndex.get(node.id)}/id`,
|
|
2555
|
+
duplicatePath: `/nodes/${nodeIndex}/id`,
|
|
2556
|
+
},
|
|
2557
|
+
supportedFixes,
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
firstNodeIndex.set(node.id, nodeIndex);
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
const availableNodeIds = [...nodes.keys()].sort(stableCompare);
|
|
2564
|
+
for (const edge of authoredEdges) {
|
|
2565
|
+
const edgeIndex = sourceIndexes.edges.get(edge);
|
|
2566
|
+
for (const [field, endpoint] of [['from', 'source'], ['to', 'target']]) {
|
|
2567
|
+
if (nodes.has(edge[field])) continue;
|
|
2568
|
+
const message = `Workflow edge "${workflowEdgeName(edge)}" references unknown ${endpoint} "${edge[field]}".`;
|
|
2569
|
+
const canonicalEdgeIndex = workflow.edges.indexOf(edge);
|
|
2570
|
+
const supportedFixes = availableNodeIds.flatMap((nodeId) => (
|
|
2571
|
+
acceptsFix((document) => {
|
|
2572
|
+
document.edges[canonicalEdgeIndex][field] = nodeId;
|
|
2573
|
+
})
|
|
2574
|
+
? [`set /edges/${edgeIndex}/${field} to verified node id "${nodeId}"`]
|
|
2575
|
+
: []
|
|
2576
|
+
));
|
|
2577
|
+
fail({
|
|
2578
|
+
code: 'workflow/unknown-edge-endpoint',
|
|
2579
|
+
severity: 'error',
|
|
2580
|
+
message,
|
|
2581
|
+
subject: {
|
|
2582
|
+
diagramType: 'workflow',
|
|
2583
|
+
edge: edge.id ?? null,
|
|
2584
|
+
path: `/edges/${edgeIndex}/${field}`,
|
|
2585
|
+
from: edge.from,
|
|
2586
|
+
to: edge.to,
|
|
2587
|
+
},
|
|
2588
|
+
evidence: {
|
|
2589
|
+
endpoint,
|
|
2590
|
+
unknownNodeId: edge[field],
|
|
2591
|
+
availableNodeIds,
|
|
2592
|
+
},
|
|
2593
|
+
supportedFixes,
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
const laneIds = new Set(workflow.lanes.map((lane) => lane.id));
|
|
2598
|
+
const availableLaneIds = [...laneIds].sort(stableCompare);
|
|
2599
|
+
const nodeSourceIndexes = new Map(authoredNodes.map((node) => [
|
|
2600
|
+
node.id,
|
|
2601
|
+
sourceIndexes.nodes.get(node),
|
|
2602
|
+
]));
|
|
2603
|
+
|
|
2604
|
+
const byLane = new Map();
|
|
2605
|
+
for (const authoredNode of authoredNodes) {
|
|
2606
|
+
const nodeIndex = sourceIndexes.nodes.get(authoredNode);
|
|
2607
|
+
const node = nodes.get(authoredNode.id);
|
|
2608
|
+
if (!laneIds.has(node.lane)) {
|
|
2609
|
+
const message = `Workflow node "${node.id}" uses unknown lane "${node.lane}".`;
|
|
2610
|
+
const canonicalNodeIndex = workflow.nodes.findIndex((candidate) => candidate.id === node.id);
|
|
2611
|
+
const supportedFixes = availableLaneIds.flatMap((laneId) => (
|
|
2612
|
+
acceptsFix((document) => {
|
|
2613
|
+
document.nodes[canonicalNodeIndex].lane = laneId;
|
|
2614
|
+
})
|
|
2615
|
+
? [`set /nodes/${nodeIndex}/lane to verified lane id "${laneId}"`]
|
|
2616
|
+
: []
|
|
2617
|
+
));
|
|
2618
|
+
fail({
|
|
2619
|
+
code: 'workflow/unknown-node-lane',
|
|
2620
|
+
severity: 'error',
|
|
2621
|
+
message,
|
|
2622
|
+
subject: { diagramType: 'workflow', node: node.id, path: `/nodes/${nodeIndex}/lane` },
|
|
2623
|
+
evidence: { unknownLaneId: node.lane, availableLaneIds },
|
|
2624
|
+
supportedFixes,
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
if (!Number.isInteger(node.col) || node.col < 0 || node.col >= layout.colXs.length) {
|
|
2628
|
+
const message = `Workflow node "${node.id}" uses column ${node.col}, but valid columns are integers 0..${layout.colXs.length - 1}.`;
|
|
2629
|
+
const canonicalNodeIndex = workflow.nodes.findIndex((candidate) => candidate.id === node.id);
|
|
2630
|
+
const supportedFixes = layout.colXs.flatMap((_x, col) => (
|
|
2631
|
+
acceptsFix((document) => {
|
|
2632
|
+
document.nodes[canonicalNodeIndex].col = col;
|
|
2633
|
+
})
|
|
2634
|
+
? [`set /nodes/${nodeIndex}/col to verified column ${col}`]
|
|
2635
|
+
: []
|
|
2636
|
+
));
|
|
2637
|
+
fail({
|
|
2638
|
+
code: 'workflow/invalid-node-column',
|
|
2639
|
+
severity: 'error',
|
|
2640
|
+
message,
|
|
2641
|
+
subject: { diagramType: 'workflow', node: node.id, path: `/nodes/${nodeIndex}/col` },
|
|
2642
|
+
evidence: { actualColumn: node.col, minimumColumn: 0, maximumColumn: layout.colXs.length - 1 },
|
|
2643
|
+
supportedFixes,
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
if (!isFinitePoint(node.x, node.y, node.cx, node.cy)) {
|
|
2647
|
+
const message = `Workflow node "${node.id}" produced non-finite coordinates.`;
|
|
2648
|
+
fail({
|
|
2649
|
+
code: 'workflow/non-finite-node-geometry',
|
|
2650
|
+
severity: 'error',
|
|
2651
|
+
message,
|
|
2652
|
+
subject: { diagramType: 'workflow', node: node.id, path: `/nodes/${nodeIndex}` },
|
|
2653
|
+
evidence: {
|
|
2654
|
+
measuredRect: { x: node.x, y: node.y, width: node.width, height: node.height },
|
|
2655
|
+
authored: {
|
|
2656
|
+
col: authoredNode.col,
|
|
2657
|
+
width: authoredNode.width ?? null,
|
|
2658
|
+
height: authoredNode.height ?? null,
|
|
2659
|
+
yOffset: authoredNode.yOffset ?? null,
|
|
2660
|
+
},
|
|
2661
|
+
},
|
|
2662
|
+
supportedFixes: [],
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
byLane.set(node.lane, [...(byLane.get(node.lane) || []), node]);
|
|
2666
|
+
}
|
|
2667
|
+
for (const [lane, laneNodes] of byLane) {
|
|
2668
|
+
for (let left = 0; left < laneNodes.length; left += 1) {
|
|
2669
|
+
for (let right = left + 1; right < laneNodes.length; right += 1) {
|
|
2670
|
+
if (rectsOverlap(laneNodes[left], laneNodes[right], 8)) {
|
|
2671
|
+
const leftNode = laneNodes[left];
|
|
2672
|
+
const rightNode = laneNodes[right];
|
|
2673
|
+
const rightIndex = nodeSourceIndexes.get(rightNode.id);
|
|
2674
|
+
const canonicalNodeIndex = workflow.nodes.findIndex((candidate) => (
|
|
2675
|
+
candidate.id === rightNode.id
|
|
2676
|
+
));
|
|
2677
|
+
const supportedFixes = layout.colXs.flatMap((_x, col) => {
|
|
2678
|
+
if (col === rightNode.col) return [];
|
|
2679
|
+
return acceptsFix((document) => {
|
|
2680
|
+
document.nodes[canonicalNodeIndex].col = col;
|
|
2681
|
+
})
|
|
2682
|
+
? [`set /nodes/${rightIndex}/col to verified free column ${col}`]
|
|
2683
|
+
: [];
|
|
2684
|
+
});
|
|
2685
|
+
const message = `Workflow nodes "${leftNode.id}" and "${rightNode.id}" are less than 8px apart in lane "${lane}".`;
|
|
2686
|
+
fail({
|
|
2687
|
+
code: 'workflow/node-overlap',
|
|
2688
|
+
severity: 'error',
|
|
2689
|
+
message,
|
|
2690
|
+
subject: { diagramType: 'workflow', node: rightNode.id, path: `/nodes/${rightIndex}` },
|
|
2691
|
+
evidence: {
|
|
2692
|
+
lane,
|
|
2693
|
+
minimumClearancePx: 8,
|
|
2694
|
+
nodes: [
|
|
2695
|
+
{ id: leftNode.id, rect: { x: leftNode.x, y: leftNode.y, width: leftNode.width, height: leftNode.height } },
|
|
2696
|
+
{ id: rightNode.id, rect: { x: rightNode.x, y: rightNode.y, width: rightNode.width, height: rightNode.height } },
|
|
2697
|
+
],
|
|
2698
|
+
},
|
|
2699
|
+
supportedFixes,
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
function gapYBetween(fromLane, toLane, bias = 0.5) {
|
|
2708
|
+
const a = laneTop(fromLane) + laneHeight(fromLane);
|
|
2709
|
+
const b = laneTop(toLane);
|
|
2710
|
+
return a + (b - a) * bias;
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
function spanForCols(fromCol, toCol, pad = 46, minimumWidth = 0) {
|
|
2714
|
+
const start = layout.colXs[fromCol] - pad;
|
|
2715
|
+
const end = layout.colXs[toCol] + pad;
|
|
2716
|
+
const width = Math.max(end - start, minimumWidth);
|
|
2717
|
+
if (fromCol === toCol && width > end - start) {
|
|
2718
|
+
return { x: start, width, cx: start + width / 2 };
|
|
2719
|
+
}
|
|
2720
|
+
const cx = (start + end) / 2;
|
|
2721
|
+
return { x: cx - width / 2, width, cx };
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
function phaseSpan(phase) {
|
|
2725
|
+
return spanForCols(
|
|
2726
|
+
phase.fromCol,
|
|
2727
|
+
phase.toCol,
|
|
2728
|
+
46,
|
|
2729
|
+
workflow.schema_version === 2 ? textUnits(phase.label) * 5.6 + 8 : 0,
|
|
2730
|
+
);
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
function groupSpan(group) {
|
|
2734
|
+
if (workflow.schema_version === 2) {
|
|
2735
|
+
return readableGroupBounds(workflow, group, layout.colXs);
|
|
2736
|
+
}
|
|
2737
|
+
return spanForCols(
|
|
2738
|
+
group.fromCol,
|
|
2739
|
+
group.toCol,
|
|
2740
|
+
50,
|
|
2741
|
+
0,
|
|
2742
|
+
);
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
function sameLaneAutoVia(start, end) {
|
|
2746
|
+
if (start[0] === end[0] || start[1] === end[1]) return [];
|
|
2747
|
+
const midX = (start[0] + end[0]) / 2;
|
|
2748
|
+
return [[midX, start[1]], [midX, end[1]]];
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
function routeClearsUnrelatedNodes(edge, points, clearance = 2) {
|
|
2752
|
+
const endpointIds = new Set([edge.from, edge.to]);
|
|
2753
|
+
for (const node of nodes.values()) {
|
|
2754
|
+
if (endpointIds.has(node.id)) continue;
|
|
2755
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
2756
|
+
if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, node, clearance)) {
|
|
2757
|
+
return false;
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
return true;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
function firstRouteNodeCollision(edge, points) {
|
|
2765
|
+
const lastSegment = points.length - 2;
|
|
2766
|
+
for (const node of nodes.values()) {
|
|
2767
|
+
const endpointRole = node.id === edge.from
|
|
2768
|
+
? 'source-endpoint'
|
|
2769
|
+
: node.id === edge.to ? 'target-endpoint' : 'unrelated';
|
|
2770
|
+
for (let segmentIndex = 0; segmentIndex <= lastSegment; segmentIndex += 1) {
|
|
2771
|
+
if (endpointRole === 'source-endpoint' && segmentIndex === 0) continue;
|
|
2772
|
+
if (endpointRole === 'target-endpoint' && segmentIndex === lastSegment) continue;
|
|
2773
|
+
const clearancePx = endpointRole === 'unrelated' ? 2 : 0;
|
|
2774
|
+
const from = points[segmentIndex];
|
|
2775
|
+
const to = points[segmentIndex + 1];
|
|
2776
|
+
if (segmentIntersectsRect({ start: from, end: to }, node, clearancePx)) {
|
|
2777
|
+
return {
|
|
2778
|
+
obstacleNode: node.id,
|
|
2779
|
+
obstacleRole: endpointRole,
|
|
2780
|
+
segmentIndex,
|
|
2781
|
+
from: [...from],
|
|
2782
|
+
to: [...to],
|
|
2783
|
+
clearancePx,
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
return null;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
function oneBendCrossLaneVia(edge, start, end, fromSide, toSide) {
|
|
2792
|
+
const fromVertical = fromSide === 'top' || fromSide === 'bottom';
|
|
2793
|
+
const toVertical = toSide === 'top' || toSide === 'bottom';
|
|
2794
|
+
if (fromVertical === toVertical) return null;
|
|
2795
|
+
|
|
2796
|
+
const corner = fromVertical ? [start[0], end[1]] : [end[0], start[1]];
|
|
2797
|
+
const points = normalizeRoutePoints([start, corner, end]);
|
|
2798
|
+
if (points.length !== 3 || !routeHonorsEndpointSides(points, fromSide, toSide)) return null;
|
|
2799
|
+
|
|
2800
|
+
const segmentsAreReadable = points.slice(0, -1).every((point, index) => (
|
|
2801
|
+
Math.hypot(
|
|
2802
|
+
points[index + 1][0] - point[0],
|
|
2803
|
+
points[index + 1][1] - point[1],
|
|
2804
|
+
) >= 8
|
|
2805
|
+
));
|
|
2806
|
+
if (!segmentsAreReadable || !routeClearsUnrelatedNodes(edge, points)) return null;
|
|
2807
|
+
return points.slice(1, -1);
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
const pathCache = new Map();
|
|
2811
|
+
const readableSideCache = new Map();
|
|
2812
|
+
|
|
2813
|
+
function legacyAutomaticOneBendSides(edge, from, to) {
|
|
2814
|
+
const automaticRoute = !edge.via && (!edge.route || edge.route === 'auto');
|
|
2815
|
+
const automaticFrom = !edge.fromSide || edge.fromSide === 'auto';
|
|
2816
|
+
const automaticTo = !edge.toSide || edge.toSide === 'auto';
|
|
2817
|
+
if (!automaticRoute || !automaticFrom || !automaticTo || from.lane === to.lane) return null;
|
|
2818
|
+
if (from.cx === to.cx || from.cy === to.cy) return null;
|
|
2819
|
+
const verticalFrom = to.cy < from.cy ? 'top' : 'bottom';
|
|
2820
|
+
const horizontalTo = to.cx < from.cx ? 'right' : 'left';
|
|
2821
|
+
const horizontalFrom = to.cx < from.cx ? 'left' : 'right';
|
|
2822
|
+
const verticalTo = to.cy < from.cy ? 'bottom' : 'top';
|
|
2823
|
+
const candidates = [
|
|
2824
|
+
{ fromSide: verticalFrom, toSide: horizontalTo },
|
|
2825
|
+
{ fromSide: horizontalFrom, toSide: verticalTo },
|
|
2826
|
+
];
|
|
2827
|
+
|
|
2828
|
+
return candidates.find(({ fromSide, toSide }) => {
|
|
2829
|
+
const start = anchor(from, fromSide);
|
|
2830
|
+
const end = anchor(to, toSide);
|
|
2831
|
+
return oneBendCrossLaneVia(edge, start, end, fromSide, toSide);
|
|
2832
|
+
}) || null;
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
function readableAutomaticSides(edge, from, to) {
|
|
2836
|
+
const automaticRoute = !edge.via
|
|
2837
|
+
&& edge.channelX === undefined
|
|
2838
|
+
&& edge.channelY === undefined
|
|
2839
|
+
&& (!edge.route || edge.route === 'auto');
|
|
2840
|
+
const authoredFrom = edge.fromSide && edge.fromSide !== 'auto' ? edge.fromSide : null;
|
|
2841
|
+
const authoredTo = edge.toSide && edge.toSide !== 'auto' ? edge.toSide : null;
|
|
2842
|
+
if (!automaticRoute || (authoredFrom && authoredTo)) return null;
|
|
2843
|
+
if (readableSideCache.has(edge)) return readableSideCache.get(edge);
|
|
2844
|
+
|
|
2845
|
+
const preferred = [];
|
|
2846
|
+
const legacyPreferred = legacyAutomaticOneBendSides(edge, from, to);
|
|
2847
|
+
if (legacyPreferred) preferred.push(legacyPreferred);
|
|
2848
|
+
preferred.push({
|
|
2849
|
+
fromSide: authoredFrom || defaultFromSide(from, to),
|
|
2850
|
+
toSide: authoredTo || defaultToSide(from, to),
|
|
2851
|
+
});
|
|
2852
|
+
const sideOrder = ['right', 'bottom', 'left', 'top'];
|
|
2853
|
+
for (const fromSide of authoredFrom ? [authoredFrom] : sideOrder) {
|
|
2854
|
+
for (const toSide of authoredTo ? [authoredTo] : sideOrder) {
|
|
2855
|
+
preferred.push({ fromSide, toSide });
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
const seen = new Set();
|
|
2860
|
+
const sidePairs = [];
|
|
2861
|
+
for (const candidate of preferred) {
|
|
2862
|
+
if (authoredFrom && candidate.fromSide !== authoredFrom) continue;
|
|
2863
|
+
if (authoredTo && candidate.toSide !== authoredTo) continue;
|
|
2864
|
+
const key = `${candidate.fromSide}:${candidate.toSide}`;
|
|
2865
|
+
if (seen.has(key)) continue;
|
|
2866
|
+
seen.add(key);
|
|
2867
|
+
sidePairs.push(candidate);
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
const naturalFromSide = authoredFrom || defaultFromSide(from, to);
|
|
2871
|
+
const naturalToSide = authoredTo || defaultToSide(from, to);
|
|
2872
|
+
const planFor = (candidate, pairOrdinal) => {
|
|
2873
|
+
const start = anchor(from, candidate.fromSide);
|
|
2874
|
+
const end = anchor(to, candidate.toSide);
|
|
2875
|
+
return {
|
|
2876
|
+
start,
|
|
2877
|
+
end,
|
|
2878
|
+
planned: readableAutomaticCandidateSet(
|
|
2879
|
+
edge,
|
|
2880
|
+
from,
|
|
2881
|
+
to,
|
|
2882
|
+
start,
|
|
2883
|
+
end,
|
|
2884
|
+
candidate.fromSide,
|
|
2885
|
+
candidate.toSide,
|
|
2886
|
+
{
|
|
2887
|
+
ordinalOffset: pairOrdinal * 9,
|
|
2888
|
+
naturalFromSide,
|
|
2889
|
+
naturalToSide,
|
|
2890
|
+
},
|
|
2891
|
+
),
|
|
2892
|
+
};
|
|
2893
|
+
};
|
|
2894
|
+
|
|
2895
|
+
const primary = sidePairs[0];
|
|
2896
|
+
if (primary) {
|
|
2897
|
+
const { planned } = planFor(primary, 0);
|
|
2898
|
+
if (planned.candidates.length) {
|
|
2899
|
+
readableSideCache.set(edge, primary);
|
|
2900
|
+
return primary;
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
const candidates = [];
|
|
2905
|
+
for (const [pairOrdinal, candidate] of sidePairs.entries()) {
|
|
2906
|
+
const { planned } = planFor(candidate, pairOrdinal);
|
|
2907
|
+
candidates.push(...planned.candidates.map((route) => ({ ...route, ...candidate })));
|
|
2908
|
+
}
|
|
2909
|
+
candidates.sort((left, right) => compareCost(left.cost, right.cost));
|
|
2910
|
+
if (candidates.length) {
|
|
2911
|
+
const selected = {
|
|
2912
|
+
fromSide: candidates[0].fromSide,
|
|
2913
|
+
toSide: candidates[0].toSide,
|
|
2914
|
+
};
|
|
2915
|
+
readableSideCache.set(edge, selected);
|
|
2916
|
+
return selected;
|
|
2917
|
+
}
|
|
2918
|
+
readableSideCache.set(edge, null);
|
|
2919
|
+
return null;
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
function automaticOneBendSides(edge, from, to) {
|
|
2923
|
+
return workflow.schema_version === 2
|
|
2924
|
+
? readableAutomaticSides(edge, from, to)
|
|
2925
|
+
: legacyAutomaticOneBendSides(edge, from, to);
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
const OUTWARD_SIDE_VECTOR = Object.freeze({
|
|
2929
|
+
left: [-1, 0],
|
|
2930
|
+
right: [1, 0],
|
|
2931
|
+
top: [0, -1],
|
|
2932
|
+
bottom: [0, 1],
|
|
2933
|
+
});
|
|
2934
|
+
|
|
2935
|
+
function outwardStub(point, side, distance = 16) {
|
|
2936
|
+
const [dx, dy] = OUTWARD_SIDE_VECTOR[side] || [0, 0];
|
|
2937
|
+
return [point[0] + dx * distance, point[1] + dy * distance];
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
function orthogonalRoute(points) {
|
|
2941
|
+
return points.every((point, index) => {
|
|
2942
|
+
if (!Array.isArray(point) || point.length !== 2 || !isFinitePoint(...point)) return false;
|
|
2943
|
+
if (index === 0) return true;
|
|
2944
|
+
const previous = points[index - 1];
|
|
2945
|
+
const dx = Math.abs(point[0] - previous[0]);
|
|
2946
|
+
const dy = Math.abs(point[1] - previous[1]);
|
|
2947
|
+
return (dx <= 0.0001) !== (dy <= 0.0001);
|
|
2948
|
+
});
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
function routeClearsEndpointNodes(points, from, to) {
|
|
2952
|
+
const lastSegment = points.length - 2;
|
|
2953
|
+
for (let index = 0; index <= lastSegment; index += 1) {
|
|
2954
|
+
const segment = { start: points[index], end: points[index + 1] };
|
|
2955
|
+
if (index > 0 && segmentIntersectsRect(segment, from)) return false;
|
|
2956
|
+
if (index < lastSegment && segmentIntersectsRect(segment, to)) return false;
|
|
2957
|
+
}
|
|
2958
|
+
return true;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
function routeMeetsHardRhythm(points) {
|
|
2962
|
+
if (points.length === 2) {
|
|
2963
|
+
return Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]) + 0.0001 >= 28;
|
|
2964
|
+
}
|
|
2965
|
+
return points.slice(0, -1).every((point, index) => {
|
|
2966
|
+
const length = Math.abs(points[index + 1][0] - point[0]) + Math.abs(points[index + 1][1] - point[1]);
|
|
2967
|
+
const endpoint = index === 0 || index === points.length - 2;
|
|
2968
|
+
return length + 0.0001 >= (endpoint ? 8 : 16);
|
|
2969
|
+
});
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
function routeLabelClearsNodes(edge, points) {
|
|
2973
|
+
if (!edge.label || edge.labelAt) return true;
|
|
2974
|
+
const [lx, ly] = workflowEdgeLabelPoint(edge, points);
|
|
2975
|
+
const rect = {
|
|
2976
|
+
x: lx - workflowLabelWidth(edge.label) / 2,
|
|
2977
|
+
y: ly - 10,
|
|
2978
|
+
width: workflowLabelWidth(edge.label),
|
|
2979
|
+
height: 14,
|
|
2980
|
+
};
|
|
2981
|
+
return [...nodes.values()].every((node) => !rectsOverlap(rect, node, -2));
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
function candidateLabelRect(edge, points) {
|
|
2985
|
+
if (!edge.label) return null;
|
|
2986
|
+
const [lx, ly] = workflowEdgeLabelPoint(edge, points);
|
|
2987
|
+
const width = workflowLabelWidth(edge.label);
|
|
2988
|
+
return { x: lx - width / 2, y: ly - 10, width, height: 14 };
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
function labelRouteClearanceDeficit(edge, points, threshold = 8) {
|
|
2992
|
+
const candidateLabel = candidateLabelRect(edge, points);
|
|
2993
|
+
let deficit = 0;
|
|
2994
|
+
for (const [otherEdge, routed] of pathCache) {
|
|
2995
|
+
const otherIndex = workflow.edges.indexOf(otherEdge);
|
|
2996
|
+
const otherLabel = labelRectFor(otherEdge, otherIndex);
|
|
2997
|
+
if (candidateLabel) {
|
|
2998
|
+
for (let index = 0; index < routed.points.length - 1; index += 1) {
|
|
2999
|
+
const clearance = segmentRectClearance({
|
|
3000
|
+
start: routed.points[index],
|
|
3001
|
+
end: routed.points[index + 1],
|
|
3002
|
+
}, candidateLabel);
|
|
3003
|
+
if (clearance != null) deficit += Math.max(0, threshold - clearance);
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
if (otherLabel) {
|
|
3007
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
3008
|
+
const clearance = segmentRectClearance({
|
|
3009
|
+
start: points[index],
|
|
3010
|
+
end: points[index + 1],
|
|
3011
|
+
}, otherLabel);
|
|
3012
|
+
if (clearance != null) deficit += Math.max(0, threshold - clearance);
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
return deficit;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
function routeClearsPlacedLabels(edge, points) {
|
|
3020
|
+
const candidateLabel = candidateLabelRect(edge, points);
|
|
3021
|
+
for (const [otherEdge, routed] of pathCache) {
|
|
3022
|
+
const otherIndex = workflow.edges.indexOf(otherEdge);
|
|
3023
|
+
const otherLabel = labelRectFor(otherEdge, otherIndex);
|
|
3024
|
+
if (candidateLabel && otherLabel && rectsOverlap(candidateLabel, otherLabel, -2)) return false;
|
|
3025
|
+
if (candidateLabel) {
|
|
3026
|
+
for (let index = 0; index < routed.points.length - 1; index += 1) {
|
|
3027
|
+
const clearance = segmentRectClearance({
|
|
3028
|
+
start: routed.points[index],
|
|
3029
|
+
end: routed.points[index + 1],
|
|
3030
|
+
}, candidateLabel);
|
|
3031
|
+
if (clearance != null && clearance + 0.0001 < 4) return false;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
if (otherLabel) {
|
|
3035
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
3036
|
+
const clearance = segmentRectClearance({
|
|
3037
|
+
start: points[index],
|
|
3038
|
+
end: points[index + 1],
|
|
3039
|
+
}, otherLabel);
|
|
3040
|
+
if (clearance != null && clearance + 0.0001 < 4) return false;
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
return true;
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
function routeClearsLegend(edge, points) {
|
|
3048
|
+
if (!workflowLegendEntries.length) return true;
|
|
3049
|
+
const legendRects = workflowLegendRects();
|
|
3050
|
+
for (const rect of legendRects) {
|
|
3051
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
3052
|
+
if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, rect)) return false;
|
|
3053
|
+
}
|
|
3054
|
+
const label = candidateLabelRect(edge, points);
|
|
3055
|
+
if (label && rectsOverlap(label, rect)) return false;
|
|
3056
|
+
}
|
|
3057
|
+
return true;
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
function routeClearsSceneLabelObstacles(edge, points) {
|
|
3061
|
+
const label = candidateLabelRect(edge, points);
|
|
3062
|
+
for (const obstacle of workflowSceneLabelObstacles()) {
|
|
3063
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
3064
|
+
if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, obstacle)) {
|
|
3065
|
+
return false;
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
if (label && rectsOverlap(label, obstacle)) return false;
|
|
3069
|
+
}
|
|
3070
|
+
return true;
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
function routeClearsFrameBorders(points) {
|
|
3074
|
+
return collectBorderRuns({
|
|
3075
|
+
routedRelations: [{ points }],
|
|
3076
|
+
frames: workflowCompositionFrames(),
|
|
3077
|
+
}).length === 0;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
function routeExtentCoordinates(edge, points) {
|
|
3081
|
+
const coordinates = [...points];
|
|
3082
|
+
if (!edge.labelAt) {
|
|
3083
|
+
const label = candidateLabelRect(edge, points);
|
|
3084
|
+
if (label) {
|
|
3085
|
+
coordinates.push([label.x, label.y], [label.x + label.width, label.y + label.height]);
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
return coordinates;
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
function routeFitsCanvasOrigin(edge, points) {
|
|
3092
|
+
return routeExtentCoordinates(edge, points).every(([x, y]) => x >= 0 && y >= 0);
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
function readableCandidateIsFeasible(edge, points, from, to, fromSide, toSide) {
|
|
3096
|
+
return points.length >= 2
|
|
3097
|
+
&& orthogonalRoute(points)
|
|
3098
|
+
&& routeHonorsEndpointSides(points, fromSide, toSide)
|
|
3099
|
+
&& routeMeetsHardRhythm(points)
|
|
3100
|
+
&& routeClearsEndpointNodes(points, from, to)
|
|
3101
|
+
&& routeClearsUnrelatedNodes(edge, points)
|
|
3102
|
+
&& routeLabelClearsNodes(edge, points)
|
|
3103
|
+
&& routeClearsPlacedLabels(edge, points)
|
|
3104
|
+
&& routeClearsLegend(edge, points)
|
|
3105
|
+
&& routeClearsSceneLabelObstacles(edge, points)
|
|
3106
|
+
&& routeClearsFrameBorders(points)
|
|
3107
|
+
&& routeFitsCanvasOrigin(edge, points);
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
function corridorViaY(start, end, fromSide, toSide, y) {
|
|
3111
|
+
const startStub = outwardStub(start, fromSide);
|
|
3112
|
+
const endStub = outwardStub(end, toSide);
|
|
3113
|
+
return [startStub, [startStub[0], y], [endStub[0], y], endStub];
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
function corridorViaX(start, end, fromSide, toSide, x) {
|
|
3117
|
+
const startStub = outwardStub(start, fromSide);
|
|
3118
|
+
const endStub = outwardStub(end, toSide);
|
|
3119
|
+
return [startStub, [x, startStub[1]], [x, endStub[1]], endStub];
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
function axisOverlapLength(a, b, c, d) {
|
|
3123
|
+
const horizontal = Math.abs(a[1] - b[1]) <= 0.0001
|
|
3124
|
+
&& Math.abs(c[1] - d[1]) <= 0.0001
|
|
3125
|
+
&& Math.abs(a[1] - c[1]) <= 0.0001;
|
|
3126
|
+
const vertical = Math.abs(a[0] - b[0]) <= 0.0001
|
|
3127
|
+
&& Math.abs(c[0] - d[0]) <= 0.0001
|
|
3128
|
+
&& Math.abs(a[0] - c[0]) <= 0.0001;
|
|
3129
|
+
if (!horizontal && !vertical) return 0;
|
|
3130
|
+
const axis = horizontal ? 0 : 1;
|
|
3131
|
+
return Math.max(0, Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis]))
|
|
3132
|
+
- Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis])));
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
function properAxisCrossing(a, b, c, d) {
|
|
3136
|
+
const firstHorizontal = Math.abs(a[1] - b[1]) <= 0.0001;
|
|
3137
|
+
const secondHorizontal = Math.abs(c[1] - d[1]) <= 0.0001;
|
|
3138
|
+
if (firstHorizontal === secondHorizontal) return false;
|
|
3139
|
+
const horizontal = firstHorizontal ? [a, b] : [c, d];
|
|
3140
|
+
const vertical = firstHorizontal ? [c, d] : [a, b];
|
|
3141
|
+
const x = vertical[0][0];
|
|
3142
|
+
const y = horizontal[0][1];
|
|
3143
|
+
return x > Math.min(horizontal[0][0], horizontal[1][0]) + 0.0001
|
|
3144
|
+
&& x < Math.max(horizontal[0][0], horizontal[1][0]) - 0.0001
|
|
3145
|
+
&& y > Math.min(vertical[0][1], vertical[1][1]) + 0.0001
|
|
3146
|
+
&& y < Math.max(vertical[0][1], vertical[1][1]) - 0.0001;
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
function routeInteractionMetrics(edge, points) {
|
|
3150
|
+
let properCrossingCount = 0;
|
|
3151
|
+
let sharedCorridorPx = 0;
|
|
3152
|
+
for (const [otherEdge, routed] of pathCache) {
|
|
3153
|
+
if ([edge.from, edge.to].some((id) => id === otherEdge.from || id === otherEdge.to)) continue;
|
|
3154
|
+
for (let left = 0; left < points.length - 1; left += 1) {
|
|
3155
|
+
for (let right = 0; right < routed.points.length - 1; right += 1) {
|
|
3156
|
+
if (properAxisCrossing(points[left], points[left + 1], routed.points[right], routed.points[right + 1])) {
|
|
3157
|
+
properCrossingCount += 1;
|
|
3158
|
+
}
|
|
3159
|
+
sharedCorridorPx += axisOverlapLength(
|
|
3160
|
+
points[left], points[left + 1], routed.points[right], routed.points[right + 1],
|
|
3161
|
+
);
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
return { properCrossingCount, sharedCorridorPx };
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
function automaticForwardReversePx(edge, points) {
|
|
3169
|
+
const from = nodes.get(edge.from);
|
|
3170
|
+
const to = nodes.get(edge.to);
|
|
3171
|
+
if (!from || !to || ['return', 'error'].includes(edge.role) || to.col <= from.col) return 0;
|
|
3172
|
+
return points.slice(0, -1).reduce((total, point, index) => (
|
|
3173
|
+
total + Math.max(0, point[0] - points[index + 1][0])
|
|
3174
|
+
), 0);
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
function readableCandidateCost(
|
|
3178
|
+
edge,
|
|
3179
|
+
points,
|
|
3180
|
+
ordinal,
|
|
3181
|
+
naturalFromSide,
|
|
3182
|
+
naturalToSide,
|
|
3183
|
+
) {
|
|
3184
|
+
const interaction = routeInteractionMetrics(edge, points);
|
|
3185
|
+
const segmentLengths = points.slice(0, -1).map((point, index) => (
|
|
3186
|
+
Math.abs(points[index + 1][0] - point[0]) + Math.abs(points[index + 1][1] - point[1])
|
|
3187
|
+
));
|
|
3188
|
+
const routeLength = segmentLengths.reduce((total, length) => total + length, 0);
|
|
3189
|
+
const directLength = Math.abs(points.at(-1)[0] - points[0][0]) + Math.abs(points.at(-1)[1] - points[0][1]);
|
|
3190
|
+
const interiorPreferred28Deficit = segmentLengths.slice(1, -1)
|
|
3191
|
+
.reduce((total, length) => total + Math.max(0, 28 - length), 0);
|
|
3192
|
+
const xs = points.map(([x]) => x);
|
|
3193
|
+
const ys = points.map(([, y]) => y);
|
|
3194
|
+
const canvasGrowthPx = Math.max(0, -Math.min(...xs))
|
|
3195
|
+
+ Math.max(0, Math.max(...xs) - minimumCanvasWidth)
|
|
3196
|
+
+ Math.max(0, -Math.min(...ys))
|
|
3197
|
+
+ Math.max(0, Math.max(...ys) - autoHeight);
|
|
3198
|
+
const from = nodes.get(edge.from);
|
|
3199
|
+
const to = nodes.get(edge.to);
|
|
3200
|
+
const naturalStart = anchor(from, naturalFromSide);
|
|
3201
|
+
const naturalEnd = anchor(to, naturalToSide);
|
|
3202
|
+
const portDisplacementPx = Math.abs(points[0][0] - naturalStart[0])
|
|
3203
|
+
+ Math.abs(points[0][1] - naturalStart[1])
|
|
3204
|
+
+ Math.abs(points.at(-1)[0] - naturalEnd[0])
|
|
3205
|
+
+ Math.abs(points.at(-1)[1] - naturalEnd[1]);
|
|
3206
|
+
const legacyCoordinateDisplacement = Math.abs(from.cx - LEGACY_COLUMN_CENTERS[from.col])
|
|
3207
|
+
+ Math.abs(to.cx - LEGACY_COLUMN_CENTERS[to.col]);
|
|
3208
|
+
return {
|
|
3209
|
+
automaticForwardReversePx: automaticForwardReversePx(edge, points),
|
|
3210
|
+
properCrossingCount: interaction.properCrossingCount,
|
|
3211
|
+
sharedCorridorPx: interaction.sharedCorridorPx,
|
|
3212
|
+
labelRouteClearanceDeficit: labelRouteClearanceDeficit(edge, points),
|
|
3213
|
+
interiorPreferred28Deficit,
|
|
3214
|
+
bendCount: Math.max(0, points.length - 2),
|
|
3215
|
+
stretchMilli: Math.round((directLength > 0 ? routeLength / directLength : 1) * 1000),
|
|
3216
|
+
canvasGrowthPx,
|
|
3217
|
+
portDisplacementMilli: Math.round(portDisplacementPx * 1000),
|
|
3218
|
+
legacyCoordinateDisplacement,
|
|
3219
|
+
stableCandidateOrdinal: ordinal,
|
|
3220
|
+
};
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
function compareCost(left, right) {
|
|
3224
|
+
for (const dimension of READABLE_CANDIDATE_COST_PRIORITY) {
|
|
3225
|
+
if ((left[dimension] || 0) !== (right[dimension] || 0)) {
|
|
3226
|
+
return (left[dimension] || 0) - (right[dimension] || 0);
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
return 0;
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
function readableAutomaticCandidateSet(
|
|
3233
|
+
edge,
|
|
3234
|
+
from,
|
|
3235
|
+
to,
|
|
3236
|
+
start,
|
|
3237
|
+
end,
|
|
3238
|
+
fromSide,
|
|
3239
|
+
toSide,
|
|
3240
|
+
{
|
|
3241
|
+
ordinalOffset = 0,
|
|
3242
|
+
naturalFromSide = fromSide,
|
|
3243
|
+
naturalToSide = toSide,
|
|
3244
|
+
} = {},
|
|
3245
|
+
) {
|
|
3246
|
+
const midX = (start[0] + end[0]) / 2;
|
|
3247
|
+
const laneGapY = from.lane === to.lane
|
|
3248
|
+
? laneTop(from.lane) - 16
|
|
3249
|
+
: gapYBetween(from.lane, to.lane, edge.bias ?? 0.5);
|
|
3250
|
+
const topY = Math.max(8, Math.min(laneTop(from.lane), laneTop(to.lane)) - 16);
|
|
3251
|
+
const bottomY = Math.max(
|
|
3252
|
+
laneTop(from.lane) + laneHeight(from.lane),
|
|
3253
|
+
laneTop(to.lane) + laneHeight(to.lane),
|
|
3254
|
+
) + 16;
|
|
3255
|
+
const outsideLeft = layout.laneX - 20;
|
|
3256
|
+
const outsideRight = layout.laneX + layout.laneW + 12;
|
|
3257
|
+
const rawCandidates = [
|
|
3258
|
+
{ family: 'facing-straight', via: [] },
|
|
3259
|
+
{ family: 'horizontal-then-vertical', via: [[end[0], start[1]]] },
|
|
3260
|
+
{ family: 'vertical-then-horizontal', via: [[start[0], end[1]]] },
|
|
3261
|
+
{ family: 'lane-gap-corridor', via: corridorViaY(start, end, fromSide, toSide, laneGapY) },
|
|
3262
|
+
{ family: 'column-gap-corridor', via: corridorViaX(start, end, fromSide, toSide, midX) },
|
|
3263
|
+
{ family: 'outside-left', via: corridorViaX(start, end, fromSide, toSide, outsideLeft) },
|
|
3264
|
+
{ family: 'outside-right', via: corridorViaX(start, end, fromSide, toSide, outsideRight) },
|
|
3265
|
+
{ family: 'top-corridor', via: corridorViaY(start, end, fromSide, toSide, topY) },
|
|
3266
|
+
{ family: 'bottom-corridor', via: corridorViaY(start, end, fromSide, toSide, bottomY) },
|
|
3267
|
+
];
|
|
3268
|
+
const candidates = rawCandidates.map((candidate, ordinal) => ({
|
|
3269
|
+
...candidate,
|
|
3270
|
+
ordinal: ordinalOffset + ordinal,
|
|
3271
|
+
points: normalizeRoutePoints([start, ...candidate.via, end]),
|
|
3272
|
+
})).filter(({ points }) => (
|
|
3273
|
+
readableCandidateIsFeasible(edge, points, from, to, fromSide, toSide)
|
|
3274
|
+
)).map((candidate) => ({
|
|
3275
|
+
...candidate,
|
|
3276
|
+
cost: readableCandidateCost(
|
|
3277
|
+
edge,
|
|
3278
|
+
candidate.points,
|
|
3279
|
+
candidate.ordinal,
|
|
3280
|
+
naturalFromSide,
|
|
3281
|
+
naturalToSide,
|
|
3282
|
+
),
|
|
3283
|
+
})).sort((left, right) => compareCost(left.cost, right.cost));
|
|
3284
|
+
return { rawCandidates, candidates, outsideRight };
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
function readableAutomaticVia(edge, from, to, start, end, fromSide, toSide) {
|
|
3288
|
+
const { rawCandidates, candidates, outsideRight } = readableAutomaticCandidateSet(
|
|
3289
|
+
edge,
|
|
3290
|
+
from,
|
|
3291
|
+
to,
|
|
3292
|
+
start,
|
|
3293
|
+
end,
|
|
3294
|
+
fromSide,
|
|
3295
|
+
toSide,
|
|
3296
|
+
);
|
|
3297
|
+
|
|
3298
|
+
if (candidates.length) return candidates[0].points.slice(1, -1);
|
|
3299
|
+
const outsideRightCandidate = rawCandidates.find(({ family }) => family === 'outside-right');
|
|
3300
|
+
if (outsideRightCandidate) {
|
|
3301
|
+
const currentPoints = normalizeRoutePoints([start, ...outsideRightCandidate.via, end]);
|
|
3302
|
+
const labelRect = candidateLabelRect(edge, currentPoints);
|
|
3303
|
+
let outsideRightMinX = outsideRight;
|
|
3304
|
+
for (const node of nodes.values()) {
|
|
3305
|
+
if (!labelRect || !rectsOverlap(labelRect, node, -2)) continue;
|
|
3306
|
+
const rightwardLabelDeficit = node.x + node.width - 2 - labelRect.x;
|
|
3307
|
+
if (rightwardLabelDeficit > 0) {
|
|
3308
|
+
outsideRightMinX = Math.max(
|
|
3309
|
+
outsideRightMinX,
|
|
3310
|
+
outsideRight + rightwardLabelDeficit * 2,
|
|
3311
|
+
);
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
for (const [otherEdge, routed] of pathCache) {
|
|
3315
|
+
const otherIndex = workflow.edges.indexOf(otherEdge);
|
|
3316
|
+
const otherLabel = labelRectFor(otherEdge, otherIndex);
|
|
3317
|
+
if (labelRect && otherLabel && rectsOverlap(labelRect, otherLabel, -2)) {
|
|
3318
|
+
const rightwardLabelDeficit = otherLabel.x + otherLabel.width - 2 - labelRect.x;
|
|
3319
|
+
if (rightwardLabelDeficit > 0) {
|
|
3320
|
+
outsideRightMinX = Math.max(
|
|
3321
|
+
outsideRightMinX,
|
|
3322
|
+
outsideRight + rightwardLabelDeficit * 2,
|
|
3323
|
+
);
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
if (!labelRect) continue;
|
|
3327
|
+
for (let index = 0; index < routed.points.length - 1; index += 1) {
|
|
3328
|
+
const segment = {
|
|
3329
|
+
start: routed.points[index],
|
|
3330
|
+
end: routed.points[index + 1],
|
|
3331
|
+
};
|
|
3332
|
+
const clearance = segmentRectClearance(segment, labelRect);
|
|
3333
|
+
if (clearance == null || clearance + 0.0001 >= 4) continue;
|
|
3334
|
+
const rightwardLabelDeficit = Math.max(segment.start[0], segment.end[0])
|
|
3335
|
+
+ 4 - labelRect.x;
|
|
3336
|
+
if (rightwardLabelDeficit > 0) {
|
|
3337
|
+
outsideRightMinX = Math.max(
|
|
3338
|
+
outsideRightMinX,
|
|
3339
|
+
outsideRight + rightwardLabelDeficit * 2,
|
|
3340
|
+
);
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
outsideRightMinX = Math.ceil(outsideRightMinX * 1000) / 1000;
|
|
3345
|
+
let rightmostPlacedX = outsideRight;
|
|
3346
|
+
for (const node of nodes.values()) {
|
|
3347
|
+
rightmostPlacedX = Math.max(rightmostPlacedX, node.x + node.width);
|
|
3348
|
+
}
|
|
3349
|
+
for (const [otherEdge, routed] of pathCache) {
|
|
3350
|
+
for (const [x] of routed.points) rightmostPlacedX = Math.max(rightmostPlacedX, x);
|
|
3351
|
+
const otherLabel = labelRectFor(otherEdge, workflow.edges.indexOf(otherEdge));
|
|
3352
|
+
if (otherLabel) {
|
|
3353
|
+
rightmostPlacedX = Math.max(rightmostPlacedX, otherLabel.x + otherLabel.width);
|
|
3354
|
+
}
|
|
3355
|
+
}
|
|
3356
|
+
let probeGrowth = Math.max(
|
|
3357
|
+
32,
|
|
3358
|
+
labelRect?.width ?? 0,
|
|
3359
|
+
rightmostPlacedX + 16 - outsideRightMinX,
|
|
3360
|
+
);
|
|
3361
|
+
let lastInfeasibleX = outsideRight;
|
|
3362
|
+
for (let probe = 0; probe < 7; probe += 1) {
|
|
3363
|
+
if (outsideRightMinX > outsideRight + 0.0001) {
|
|
3364
|
+
const expandedPoints = normalizeRoutePoints([
|
|
3365
|
+
start,
|
|
3366
|
+
...corridorViaX(start, end, fromSide, toSide, outsideRightMinX),
|
|
3367
|
+
end,
|
|
3368
|
+
]);
|
|
3369
|
+
if (readableCandidateIsFeasible(edge, expandedPoints, from, to, fromSide, toSide)) {
|
|
3370
|
+
let feasibleX = outsideRightMinX;
|
|
3371
|
+
let feasiblePoints = expandedPoints;
|
|
3372
|
+
let infeasibleX = lastInfeasibleX;
|
|
3373
|
+
for (let refinement = 0;
|
|
3374
|
+
refinement < 53 && feasibleX - infeasibleX > 0.001;
|
|
3375
|
+
refinement += 1) {
|
|
3376
|
+
const midpointX = Math.ceil(((infeasibleX + feasibleX) / 2) * 1000) / 1000;
|
|
3377
|
+
if (midpointX >= feasibleX - 0.0001) break;
|
|
3378
|
+
const midpointPoints = normalizeRoutePoints([
|
|
3379
|
+
start,
|
|
3380
|
+
...corridorViaX(start, end, fromSide, toSide, midpointX),
|
|
3381
|
+
end,
|
|
3382
|
+
]);
|
|
3383
|
+
if (readableCandidateIsFeasible(
|
|
3384
|
+
edge,
|
|
3385
|
+
midpointPoints,
|
|
3386
|
+
from,
|
|
3387
|
+
to,
|
|
3388
|
+
fromSide,
|
|
3389
|
+
toSide,
|
|
3390
|
+
)) {
|
|
3391
|
+
feasibleX = midpointX;
|
|
3392
|
+
feasiblePoints = midpointPoints;
|
|
3393
|
+
} else {
|
|
3394
|
+
infeasibleX = midpointX;
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
return feasiblePoints.slice(1, -1);
|
|
3398
|
+
}
|
|
3399
|
+
lastInfeasibleX = outsideRightMinX;
|
|
3400
|
+
}
|
|
3401
|
+
outsideRightMinX = Math.ceil((outsideRightMinX + probeGrowth) * 1000) / 1000;
|
|
3402
|
+
probeGrowth *= 2;
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
const hasRelevantAbsolutePin = Array.isArray(edge.labelAt)
|
|
3406
|
+
|| [...pathCache.keys()].some((otherEdge) => (
|
|
3407
|
+
Array.isArray(otherEdge.labelAt) || hasAbsoluteRoutePins(otherEdge)
|
|
3408
|
+
));
|
|
3409
|
+
if (hasRelevantAbsolutePin) classifyFailedAutomaticCandidatePins(edge, rawCandidates);
|
|
3410
|
+
const horizontallyFacing = (
|
|
3411
|
+
fromSide === 'right' && toSide === 'left' && end[0] > start[0]
|
|
3412
|
+
) || (
|
|
3413
|
+
fromSide === 'left' && toSide === 'right' && start[0] > end[0]
|
|
3414
|
+
);
|
|
3415
|
+
if (horizontallyFacing && from.col !== to.col) {
|
|
3416
|
+
const fromCol = Math.min(from.col, to.col);
|
|
3417
|
+
const toCol = Math.max(from.col, to.col);
|
|
3418
|
+
const requiredRankGap = from.width / 2 + 32 + to.width / 2;
|
|
3419
|
+
const actualRankGap = layout.colXs[toCol] - layout.colXs[fromCol];
|
|
3420
|
+
if (actualRankGap + 0.0001 < requiredRankGap) {
|
|
3421
|
+
throw new WorkflowLayoutFeedback({
|
|
3422
|
+
kind: 'rank-gap-minimum',
|
|
3423
|
+
fromCol,
|
|
3424
|
+
toCol,
|
|
3425
|
+
minimum: Math.ceil(requiredRankGap * 1000) / 1000,
|
|
3426
|
+
edge: edge.id ?? null,
|
|
3427
|
+
from: edge.from,
|
|
3428
|
+
to: edge.to,
|
|
3429
|
+
attemptedCandidateFamilies: rawCandidates.map(({ family }) => family),
|
|
3430
|
+
candidateCount: rawCandidates.length,
|
|
3431
|
+
});
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
if (from.lane !== to.lane && layout.laneGap < 32) {
|
|
3435
|
+
throw new WorkflowLayoutFeedback({
|
|
3436
|
+
kind: 'lane-gap-minimum',
|
|
3437
|
+
minimum: 32,
|
|
3438
|
+
edge: edge.id ?? null,
|
|
3439
|
+
from: edge.from,
|
|
3440
|
+
to: edge.to,
|
|
3441
|
+
attemptedCandidateFamilies: rawCandidates.map(({ family }) => family),
|
|
3442
|
+
candidateCount: rawCandidates.length,
|
|
3443
|
+
});
|
|
3444
|
+
}
|
|
3445
|
+
const message = `Workflow edge "${workflowEdgeName(edge)}" has no feasible readable-v2 automatic route.`;
|
|
3446
|
+
throwDiagnosticError(message, [{
|
|
3447
|
+
code: 'workflow/solver-budget-exhausted',
|
|
3448
|
+
severity: 'error',
|
|
3449
|
+
message,
|
|
3450
|
+
subject: {
|
|
3451
|
+
diagramType: 'workflow',
|
|
3452
|
+
edge: edge.id ?? null,
|
|
3453
|
+
from: edge.from,
|
|
3454
|
+
to: edge.to,
|
|
3455
|
+
},
|
|
3456
|
+
evidence: {
|
|
3457
|
+
attemptedCandidateFamilies: rawCandidates.map(({ family }) => family),
|
|
3458
|
+
candidateCount: rawCandidates.length,
|
|
3459
|
+
},
|
|
3460
|
+
supportedFixes: [],
|
|
3461
|
+
}]);
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
function readablePresetVia(edge, from, to, start, end, fromSide, toSide) {
|
|
3465
|
+
const preset = edge.route;
|
|
3466
|
+
let via;
|
|
3467
|
+
switch (preset) {
|
|
3468
|
+
case 'straight':
|
|
3469
|
+
via = [];
|
|
3470
|
+
break;
|
|
3471
|
+
case 'drop': {
|
|
3472
|
+
const y = gapYBetween(from.lane, to.lane, edge.bias ?? 0.5);
|
|
3473
|
+
via = [[start[0], y], [end[0], y]];
|
|
3474
|
+
break;
|
|
3475
|
+
}
|
|
3476
|
+
case 'outside-right': {
|
|
3477
|
+
const x = layout.laneX + layout.laneW + 12;
|
|
3478
|
+
via = [[x, start[1]], [x, end[1]]];
|
|
3479
|
+
break;
|
|
3480
|
+
}
|
|
3481
|
+
case 'return-left': {
|
|
3482
|
+
const x = Math.min(from.x, to.x) - 28;
|
|
3483
|
+
via = [[x, start[1]], [x, end[1]]];
|
|
3484
|
+
break;
|
|
3485
|
+
}
|
|
3486
|
+
case 'bottom-channel': {
|
|
3487
|
+
const y = Math.max(from.y + from.height, to.y + to.height) + 32;
|
|
3488
|
+
via = [[start[0], y], [end[0], y]];
|
|
3489
|
+
break;
|
|
3490
|
+
}
|
|
3491
|
+
case 'up-channel': {
|
|
3492
|
+
const y = Math.min(from.y, to.y) - 28;
|
|
3493
|
+
via = [[start[0], y], [end[0], y]];
|
|
3494
|
+
break;
|
|
3495
|
+
}
|
|
3496
|
+
default:
|
|
3497
|
+
return readableAutomaticVia(edge, from, to, start, end, fromSide, toSide);
|
|
3498
|
+
}
|
|
3499
|
+
const points = normalizeRoutePoints([start, ...via, end]);
|
|
3500
|
+
if (readableCandidateIsFeasible(edge, points, from, to, fromSide, toSide)
|
|
3501
|
+
&& routeMatchesPresetFamily(preset, points, from, to)) {
|
|
3502
|
+
return points.slice(1, -1);
|
|
3503
|
+
}
|
|
3504
|
+
const message = `Workflow edge "${workflowEdgeName(edge)}" cannot satisfy route preset "${preset}" under readable-v2 constraints (minimum 8px endpoint stubs, 16px interior turns, and 28px direct clearance).`;
|
|
3505
|
+
const edgeIndex = workflow.edges.indexOf(edge);
|
|
3506
|
+
const edgeName = workflowEdgeName(edge);
|
|
3507
|
+
const supportedFixes = [];
|
|
3508
|
+
for (const candidatePreset of ['straight', 'drop', 'outside-right', 'return-left', 'bottom-channel', 'up-channel']) {
|
|
3509
|
+
if (candidatePreset === preset) continue;
|
|
3510
|
+
if (acceptsFix((document) => {
|
|
3511
|
+
document.edges[edgeIndex].route = candidatePreset;
|
|
3512
|
+
})) {
|
|
3513
|
+
supportedFixes.push(`set edge "${edgeName}" route to verified preset "${candidatePreset}"`);
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
if (acceptsFix((document) => {
|
|
3517
|
+
delete document.edges[edgeIndex].route;
|
|
3518
|
+
})) {
|
|
3519
|
+
supportedFixes.push(`remove route from edge "${edgeName}" so readable-v2 can use its verified automatic candidate`);
|
|
3520
|
+
}
|
|
3521
|
+
throwDiagnosticError(message, [{
|
|
3522
|
+
code: 'workflow/route-preset-conflict',
|
|
3523
|
+
severity: 'error',
|
|
3524
|
+
message,
|
|
3525
|
+
subject: {
|
|
3526
|
+
diagramType: 'workflow',
|
|
3527
|
+
edge: edge.id ?? null,
|
|
3528
|
+
from: edge.from,
|
|
3529
|
+
to: edge.to,
|
|
3530
|
+
route: preset,
|
|
3531
|
+
},
|
|
3532
|
+
evidence: {
|
|
3533
|
+
attemptedCandidateFamily: preset,
|
|
3534
|
+
points,
|
|
3535
|
+
fromSide,
|
|
3536
|
+
toSide,
|
|
3537
|
+
requiredEndpointStubPx: 8,
|
|
3538
|
+
requiredInteriorSegmentPx: 16,
|
|
3539
|
+
requiredDirectClearancePx: 28,
|
|
3540
|
+
},
|
|
3541
|
+
supportedFixes,
|
|
3542
|
+
}]);
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
function routeVia(
|
|
3546
|
+
edge,
|
|
3547
|
+
from,
|
|
3548
|
+
to,
|
|
3549
|
+
start,
|
|
3550
|
+
end,
|
|
3551
|
+
fromSide,
|
|
3552
|
+
toSide,
|
|
3553
|
+
{ validateReadablePreset = true } = {},
|
|
3554
|
+
) {
|
|
3555
|
+
if (edge.via) return edge.via;
|
|
3556
|
+
const hasCoordinatePins = edge.channelX !== undefined || edge.channelY !== undefined;
|
|
3557
|
+
if (workflow.schema_version === 2
|
|
3558
|
+
&& edge.route
|
|
3559
|
+
&& edge.route !== 'auto'
|
|
3560
|
+
&& !hasCoordinatePins
|
|
3561
|
+
&& validateReadablePreset) {
|
|
3562
|
+
return readablePresetVia(edge, from, to, start, end, fromSide, toSide);
|
|
3563
|
+
}
|
|
3564
|
+
switch (edge.route || 'auto') {
|
|
3565
|
+
case 'straight':
|
|
3566
|
+
return [];
|
|
3567
|
+
case 'drop': {
|
|
3568
|
+
const y = edge.channelY ?? gapYBetween(from.lane, to.lane, edge.bias ?? 0.5);
|
|
3569
|
+
return [[start[0], y], [end[0], y]];
|
|
3570
|
+
}
|
|
3571
|
+
case 'outside-right': {
|
|
3572
|
+
const x = edge.channelX ?? layout.laneX + layout.laneW + 12;
|
|
3573
|
+
return [[x, start[1]], [x, end[1]]];
|
|
3574
|
+
}
|
|
3575
|
+
case 'return-left': {
|
|
3576
|
+
const x = edge.channelX ?? Math.min(from.x, to.x) - 28;
|
|
3577
|
+
return [[x, start[1]], [x, end[1]]];
|
|
3578
|
+
}
|
|
3579
|
+
case 'bottom-channel': {
|
|
3580
|
+
const y = edge.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 32;
|
|
3581
|
+
return [[start[0], y], [end[0], y]];
|
|
3582
|
+
}
|
|
3583
|
+
case 'up-channel': {
|
|
3584
|
+
const y = edge.channelY ?? Math.min(from.y, to.y) - 28;
|
|
3585
|
+
return [[start[0], y], [end[0], y]];
|
|
3586
|
+
}
|
|
3587
|
+
case 'auto':
|
|
3588
|
+
default: {
|
|
3589
|
+
if (workflow.schema_version === 2) {
|
|
3590
|
+
if (edge.channelX !== undefined && edge.channelY !== undefined) {
|
|
3591
|
+
return [[edge.channelX, start[1]], [edge.channelX, edge.channelY], [end[0], edge.channelY]];
|
|
3592
|
+
}
|
|
3593
|
+
if (edge.channelX !== undefined) return [[edge.channelX, start[1]], [edge.channelX, end[1]]];
|
|
3594
|
+
if (edge.channelY !== undefined) return [[start[0], edge.channelY], [end[0], edge.channelY]];
|
|
3595
|
+
return readableAutomaticVia(edge, from, to, start, end, fromSide, toSide);
|
|
3596
|
+
}
|
|
3597
|
+
if (from.lane === to.lane) return sameLaneAutoVia(start, end);
|
|
3598
|
+
const oneBendVia = oneBendCrossLaneVia(edge, start, end, fromSide, toSide);
|
|
3599
|
+
if (oneBendVia) return oneBendVia;
|
|
3600
|
+
const y = gapYBetween(from.lane, to.lane, edge.bias ?? 0.5);
|
|
3601
|
+
return [[start[0], y], [end[0], y]];
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
function workflowEdgeLabelPoint(edge, points) {
|
|
3607
|
+
if (workflow.schema_version === 1) {
|
|
3608
|
+
if (edge.labelAt || Number.isInteger(edge.labelSegment) || points.length !== 3) {
|
|
3609
|
+
return labelPoint(edge, points);
|
|
3610
|
+
}
|
|
3611
|
+
const segmentLengths = [0, 1].map((index) => Math.hypot(
|
|
3612
|
+
points[index + 1][0] - points[index][0],
|
|
3613
|
+
points[index + 1][1] - points[index][1],
|
|
3614
|
+
));
|
|
3615
|
+
const labelSegment = segmentLengths[0] >= segmentLengths[1] ? 0 : 1;
|
|
3616
|
+
const point = labelPoint({ ...edge, labelSegment }, points);
|
|
3617
|
+
if (points[labelSegment][0] === points[labelSegment + 1][0]) point[1] += 10;
|
|
3618
|
+
return point;
|
|
3619
|
+
}
|
|
3620
|
+
if (edge.labelAt || Number.isInteger(edge.labelSegment) || points.length <= 2) {
|
|
3621
|
+
return labelPoint(edge, points);
|
|
3622
|
+
}
|
|
3623
|
+
const segments = points.slice(0, -1).map((point, index) => ({
|
|
3624
|
+
index,
|
|
3625
|
+
horizontal: Math.abs(points[index + 1][1] - point[1]) <= 0.0001,
|
|
3626
|
+
length: Math.hypot(
|
|
3627
|
+
points[index + 1][0] - point[0],
|
|
3628
|
+
points[index + 1][1] - point[1],
|
|
3629
|
+
),
|
|
3630
|
+
})).sort((left, right) => (
|
|
3631
|
+
Number(right.horizontal) - Number(left.horizontal)
|
|
3632
|
+
|| right.length - left.length
|
|
3633
|
+
|| left.index - right.index
|
|
3634
|
+
));
|
|
3635
|
+
const labelSegment = segments[0]?.index ?? 0;
|
|
3636
|
+
const point = labelPoint({ ...edge, labelSegment }, points);
|
|
3637
|
+
if (points[labelSegment][0] === points[labelSegment + 1][0]) point[1] += 10;
|
|
3638
|
+
return point;
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
function edgeSides(edge) {
|
|
3642
|
+
const from = nodes.get(edge.from);
|
|
3643
|
+
const to = nodes.get(edge.to);
|
|
3644
|
+
const resolved = workflow.schema_version === 2 ? readableSideCache.get(edge) : null;
|
|
3645
|
+
if (resolved) return resolved;
|
|
3646
|
+
const oneBendSides = automaticOneBendSides(edge, from, to);
|
|
3647
|
+
if (oneBendSides) return oneBendSides;
|
|
3648
|
+
if (workflow.schema_version === 2
|
|
3649
|
+
&& layout.channelLabelEdgeKeys?.has(stableValueKey(edge))
|
|
3650
|
+
&& !edge.fromSide
|
|
3651
|
+
&& !edge.toSide) {
|
|
3652
|
+
return { fromSide: 'top', toSide: 'top' };
|
|
3653
|
+
}
|
|
3654
|
+
return {
|
|
3655
|
+
fromSide: chosenSide(edge.fromSide, defaultFromSide(from, to)),
|
|
3656
|
+
toSide: chosenSide(edge.toSide, defaultToSide(from, to)),
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
const automaticPorts = automaticPortSpread(workflow.edges, nodes, {
|
|
3661
|
+
sideFor: (edge, endpoint) => edgeSides(edge)[endpoint === 'source' ? 'fromSide' : 'toSide'],
|
|
3662
|
+
});
|
|
3663
|
+
|
|
3664
|
+
function readableAutomaticRoute(edge, from, to, primarySides, primaryPorts) {
|
|
3665
|
+
const authoredFrom = edge.fromSide && edge.fromSide !== 'auto' ? edge.fromSide : null;
|
|
3666
|
+
const authoredTo = edge.toSide && edge.toSide !== 'auto' ? edge.toSide : null;
|
|
3667
|
+
const sideOrder = ['right', 'bottom', 'left', 'top'];
|
|
3668
|
+
const sidePairs = [primarySides];
|
|
3669
|
+
for (const fromSide of authoredFrom ? [authoredFrom] : sideOrder) {
|
|
3670
|
+
for (const toSide of authoredTo ? [authoredTo] : sideOrder) {
|
|
3671
|
+
sidePairs.push({ fromSide, toSide });
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
const naturalFromSide = authoredFrom || defaultFromSide(from, to);
|
|
3676
|
+
const naturalToSide = authoredTo || defaultToSide(from, to);
|
|
3677
|
+
const seen = new Set();
|
|
3678
|
+
const plans = [];
|
|
3679
|
+
const feedback = [];
|
|
3680
|
+
let firstFailure = null;
|
|
3681
|
+
for (const candidateSides of sidePairs) {
|
|
3682
|
+
if (authoredFrom && candidateSides.fromSide !== authoredFrom) continue;
|
|
3683
|
+
if (authoredTo && candidateSides.toSide !== authoredTo) continue;
|
|
3684
|
+
const key = `${candidateSides.fromSide}:${candidateSides.toSide}`;
|
|
3685
|
+
if (seen.has(key)) continue;
|
|
3686
|
+
const pairOrdinal = seen.size;
|
|
3687
|
+
seen.add(key);
|
|
3688
|
+
const primary = pairOrdinal === 0;
|
|
3689
|
+
const start = primaryPorts?.from && primary
|
|
3690
|
+
? primaryPorts.from
|
|
3691
|
+
: anchor(from, candidateSides.fromSide);
|
|
3692
|
+
const end = primaryPorts?.to && primary
|
|
3693
|
+
? primaryPorts.to
|
|
3694
|
+
: anchor(to, candidateSides.toSide);
|
|
3695
|
+
const planned = readableAutomaticCandidateSet(
|
|
3696
|
+
edge,
|
|
3697
|
+
from,
|
|
3698
|
+
to,
|
|
3699
|
+
start,
|
|
3700
|
+
end,
|
|
3701
|
+
candidateSides.fromSide,
|
|
3702
|
+
candidateSides.toSide,
|
|
3703
|
+
{
|
|
3704
|
+
ordinalOffset: pairOrdinal * 9,
|
|
3705
|
+
naturalFromSide,
|
|
3706
|
+
naturalToSide,
|
|
3707
|
+
},
|
|
3708
|
+
);
|
|
3709
|
+
plans.push(...planned.candidates.map((candidate) => ({
|
|
3710
|
+
...candidate,
|
|
3711
|
+
...candidateSides,
|
|
3712
|
+
})));
|
|
3713
|
+
if (planned.candidates.length) continue;
|
|
3714
|
+
try {
|
|
3715
|
+
const expandedVia = withDiagnosticRecordingSuppressed(() => readableAutomaticVia(
|
|
3716
|
+
edge,
|
|
3717
|
+
from,
|
|
3718
|
+
to,
|
|
3719
|
+
start,
|
|
3720
|
+
end,
|
|
3721
|
+
candidateSides.fromSide,
|
|
3722
|
+
candidateSides.toSide,
|
|
3723
|
+
));
|
|
3724
|
+
const expandedPoints = normalizeRoutePoints([start, ...expandedVia, end]);
|
|
3725
|
+
const outsideRightOrdinal = planned.rawCandidates.findIndex(({ family }) => (
|
|
3726
|
+
family === 'outside-right'
|
|
3727
|
+
));
|
|
3728
|
+
const ordinal = pairOrdinal * 9 + Math.max(0, outsideRightOrdinal);
|
|
3729
|
+
plans.push({
|
|
3730
|
+
family: 'outside-right',
|
|
3731
|
+
ordinal,
|
|
3732
|
+
points: expandedPoints,
|
|
3733
|
+
cost: readableCandidateCost(
|
|
3734
|
+
edge,
|
|
3735
|
+
expandedPoints,
|
|
3736
|
+
ordinal,
|
|
3737
|
+
naturalFromSide,
|
|
3738
|
+
naturalToSide,
|
|
3739
|
+
),
|
|
3740
|
+
...candidateSides,
|
|
3741
|
+
});
|
|
3742
|
+
} catch (error) {
|
|
3743
|
+
if (error instanceof WorkflowLayoutFeedback) {
|
|
3744
|
+
feedback.push({ error, pairOrdinal });
|
|
3745
|
+
} else if (!firstFailure) {
|
|
3746
|
+
firstFailure = error;
|
|
3747
|
+
}
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3751
|
+
plans.sort((left, right) => compareCost(left.cost, right.cost));
|
|
3752
|
+
if (plans.length) {
|
|
3753
|
+
const selected = plans[0];
|
|
3754
|
+
return {
|
|
3755
|
+
points: selected.points,
|
|
3756
|
+
fromSide: selected.fromSide,
|
|
3757
|
+
toSide: selected.toSide,
|
|
3758
|
+
};
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
const feedbackPriority = {
|
|
3762
|
+
'rank-gap-minimum': 0,
|
|
3763
|
+
'lane-gap-minimum': 1,
|
|
3764
|
+
};
|
|
3765
|
+
feedback.sort((left, right) => (
|
|
3766
|
+
(feedbackPriority[left.error.request?.kind] ?? 99)
|
|
3767
|
+
- (feedbackPriority[right.error.request?.kind] ?? 99)
|
|
3768
|
+
|| left.pairOrdinal - right.pairOrdinal
|
|
3769
|
+
));
|
|
3770
|
+
if (feedback.length) throw feedback[0].error;
|
|
3771
|
+
const authoredSideFields = [
|
|
3772
|
+
...(authoredFrom ? ['fromSide'] : []),
|
|
3773
|
+
...(authoredTo ? ['toSide'] : []),
|
|
3774
|
+
];
|
|
3775
|
+
if (authoredSideFields.length) {
|
|
3776
|
+
const alternatives = verifiedPinRemovalAlternatives(
|
|
3777
|
+
edge,
|
|
3778
|
+
authoredSideFields,
|
|
3779
|
+
'so readable-v2 can replan the remaining endpoint-side pins',
|
|
3780
|
+
);
|
|
3781
|
+
const sourceAnchor = primaryPorts?.from || anchor(from, primarySides.fromSide);
|
|
3782
|
+
const targetAnchor = primaryPorts?.to || anchor(to, primarySides.toSide);
|
|
3783
|
+
const attemptedEvidence = firstFailure?.archifyDiagnostics?.[0]?.evidence || {};
|
|
3784
|
+
throwExplicitPinConflict(edge, 'readable route feasibility with authored endpoint sides', {
|
|
3785
|
+
conflictingPins: conflictPinsFromRemovalSets(
|
|
3786
|
+
edge,
|
|
3787
|
+
alternatives.removalSets,
|
|
3788
|
+
authoredSideFields,
|
|
3789
|
+
),
|
|
3790
|
+
actualCoordinates: {
|
|
3791
|
+
sourceAnchor: [...sourceAnchor],
|
|
3792
|
+
targetAnchor: [...targetAnchor],
|
|
3793
|
+
},
|
|
3794
|
+
fromSide: primarySides.fromSide,
|
|
3795
|
+
toSide: primarySides.toSide,
|
|
3796
|
+
...(attemptedEvidence.attemptedCandidateFamilies
|
|
3797
|
+
? { attemptedCandidateFamilies: attemptedEvidence.attemptedCandidateFamilies }
|
|
3798
|
+
: {}),
|
|
3799
|
+
...(attemptedEvidence.candidateCount !== undefined
|
|
3800
|
+
? { candidateCount: attemptedEvidence.candidateCount }
|
|
3801
|
+
: {}),
|
|
3802
|
+
}, alternatives.supportedFixes);
|
|
3803
|
+
}
|
|
3804
|
+
if (firstFailure) throw firstFailure;
|
|
3805
|
+
throw new Error('readable-v2 automatic route enumeration produced no result');
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
function isReadableControlledRoute(edge) {
|
|
3809
|
+
return workflow.schema_version === 2 && (
|
|
3810
|
+
Array.isArray(edge.via)
|
|
3811
|
+
|| edge.channelX !== undefined
|
|
3812
|
+
|| edge.channelY !== undefined
|
|
3813
|
+
|| (edge.route && edge.route !== 'auto')
|
|
3814
|
+
);
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
function readableControlledRoute(edge, from, to) {
|
|
3818
|
+
const authoredFrom = edge.fromSide && edge.fromSide !== 'auto' ? edge.fromSide : null;
|
|
3819
|
+
const authoredTo = edge.toSide && edge.toSide !== 'auto' ? edge.toSide : null;
|
|
3820
|
+
const naturalFromSide = authoredFrom || defaultFromSide(from, to);
|
|
3821
|
+
const naturalToSide = authoredTo || defaultToSide(from, to);
|
|
3822
|
+
const sideOrder = ['right', 'bottom', 'left', 'top'];
|
|
3823
|
+
const preferredPairs = [{
|
|
3824
|
+
fromSide: naturalFromSide,
|
|
3825
|
+
toSide: naturalToSide,
|
|
3826
|
+
}];
|
|
3827
|
+
for (const fromSide of authoredFrom ? [authoredFrom] : sideOrder) {
|
|
3828
|
+
for (const toSide of authoredTo ? [authoredTo] : sideOrder) {
|
|
3829
|
+
preferredPairs.push({ fromSide, toSide });
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
const seen = new Set();
|
|
3834
|
+
const sidePairs = preferredPairs.filter(({ fromSide, toSide }) => {
|
|
3835
|
+
if (authoredFrom && fromSide !== authoredFrom) return false;
|
|
3836
|
+
if (authoredTo && toSide !== authoredTo) return false;
|
|
3837
|
+
const key = `${fromSide}:${toSide}`;
|
|
3838
|
+
if (seen.has(key)) return false;
|
|
3839
|
+
seen.add(key);
|
|
3840
|
+
return true;
|
|
3841
|
+
});
|
|
3842
|
+
const hasAbsoluteRoutePins = Array.isArray(edge.via)
|
|
3843
|
+
|| edge.channelX !== undefined
|
|
3844
|
+
|| edge.channelY !== undefined;
|
|
3845
|
+
const candidates = [];
|
|
3846
|
+
const diagnosticCandidates = [];
|
|
3847
|
+
const materializedCandidates = [];
|
|
3848
|
+
for (const [ordinal, { fromSide, toSide }] of sidePairs.entries()) {
|
|
3849
|
+
const start = anchor(from, fromSide);
|
|
3850
|
+
const end = anchor(to, toSide);
|
|
3851
|
+
const via = routeVia(
|
|
3852
|
+
edge,
|
|
3853
|
+
from,
|
|
3854
|
+
to,
|
|
3855
|
+
start,
|
|
3856
|
+
end,
|
|
3857
|
+
fromSide,
|
|
3858
|
+
toSide,
|
|
3859
|
+
{ validateReadablePreset: false },
|
|
3860
|
+
);
|
|
3861
|
+
const authoredPoints = [start, ...via, end];
|
|
3862
|
+
const points = hasAbsoluteRoutePins
|
|
3863
|
+
? authoredPoints
|
|
3864
|
+
: normalizeRoutePoints(authoredPoints);
|
|
3865
|
+
const materialized = { points, fromSide, toSide, ordinal };
|
|
3866
|
+
materializedCandidates.push(materialized);
|
|
3867
|
+
if (points.length >= 2
|
|
3868
|
+
&& points.every((point) => (
|
|
3869
|
+
Array.isArray(point) && point.length === 2 && isFinitePoint(...point)
|
|
3870
|
+
))
|
|
3871
|
+
&& routeHonorsEndpointSides(points, fromSide, toSide)) {
|
|
3872
|
+
diagnosticCandidates.push(materialized);
|
|
3873
|
+
}
|
|
3874
|
+
if (!readableCandidateIsFeasible(edge, points, from, to, fromSide, toSide)) continue;
|
|
3875
|
+
if (edge.route && edge.route !== 'auto' && !routeMatchesPresetFamily(
|
|
3876
|
+
edge.route,
|
|
3877
|
+
points,
|
|
3878
|
+
from,
|
|
3879
|
+
to,
|
|
3880
|
+
)) continue;
|
|
3881
|
+
if (presentChannelPins(edge).some((field) => (
|
|
3882
|
+
!routeContainsChannelPin(points, field, edge[field])
|
|
3883
|
+
))) continue;
|
|
3884
|
+
candidates.push({
|
|
3885
|
+
points,
|
|
3886
|
+
fromSide,
|
|
3887
|
+
toSide,
|
|
3888
|
+
cost: readableCandidateCost(
|
|
3889
|
+
edge,
|
|
3890
|
+
points,
|
|
3891
|
+
ordinal,
|
|
3892
|
+
naturalFromSide,
|
|
3893
|
+
naturalToSide,
|
|
3894
|
+
),
|
|
3895
|
+
});
|
|
3896
|
+
}
|
|
3897
|
+
candidates.sort((left, right) => compareCost(left.cost, right.cost));
|
|
3898
|
+
if (candidates.length) return candidates[0];
|
|
3899
|
+
|
|
3900
|
+
// Absolute geometry is authoritative even when it is invalid. Preserve the
|
|
3901
|
+
// best endpoint-side inference so validation can diagnose the authored
|
|
3902
|
+
// segment or preset that actually failed instead of silently falling back to
|
|
3903
|
+
// default sides and changing the route's meaning.
|
|
3904
|
+
if (hasAbsoluteRoutePins) {
|
|
3905
|
+
return diagnosticCandidates[0] || materializedCandidates[0] || null;
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
// Preset-only routes retain their dedicated typed conflict (and verified
|
|
3909
|
+
// alternative search) when exhaustive side inference found no valid plan.
|
|
3910
|
+
const fallback = materializedCandidates[0];
|
|
3911
|
+
if (!fallback) return null;
|
|
3912
|
+
const fallbackVia = readablePresetVia(
|
|
3913
|
+
edge,
|
|
3914
|
+
from,
|
|
3915
|
+
to,
|
|
3916
|
+
fallback.points[0],
|
|
3917
|
+
fallback.points.at(-1),
|
|
3918
|
+
fallback.fromSide,
|
|
3919
|
+
fallback.toSide,
|
|
3920
|
+
);
|
|
3921
|
+
return {
|
|
3922
|
+
...fallback,
|
|
3923
|
+
points: normalizeRoutePoints([
|
|
3924
|
+
fallback.points[0],
|
|
3925
|
+
...fallbackVia,
|
|
3926
|
+
fallback.points.at(-1),
|
|
3927
|
+
]),
|
|
3928
|
+
};
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
function pathFor(edge) {
|
|
3932
|
+
if (pathCache.has(edge)) return pathCache.get(edge);
|
|
3933
|
+
const from = nodes.get(edge.from);
|
|
3934
|
+
const to = nodes.get(edge.to);
|
|
3935
|
+
if (isReadableControlledRoute(edge)) {
|
|
3936
|
+
const planned = readableControlledRoute(edge, from, to);
|
|
3937
|
+
if (planned) {
|
|
3938
|
+
readableSideCache.set(edge, {
|
|
3939
|
+
fromSide: planned.fromSide,
|
|
3940
|
+
toSide: planned.toSide,
|
|
3941
|
+
});
|
|
3942
|
+
const routed = { d: polylinePath(planned.points), points: planned.points };
|
|
3943
|
+
pathCache.set(edge, routed);
|
|
3944
|
+
return routed;
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
const ports = automaticPorts.get(edge);
|
|
3948
|
+
const { fromSide, toSide } = edgeSides(edge);
|
|
3949
|
+
const readableAutomatic = workflow.schema_version === 2
|
|
3950
|
+
&& !Array.isArray(edge.via)
|
|
3951
|
+
&& edge.channelX === undefined
|
|
3952
|
+
&& edge.channelY === undefined
|
|
3953
|
+
&& (!edge.route || edge.route === 'auto');
|
|
3954
|
+
if (readableAutomatic) {
|
|
3955
|
+
const planned = readableAutomaticRoute(
|
|
3956
|
+
edge,
|
|
3957
|
+
from,
|
|
3958
|
+
to,
|
|
3959
|
+
{ fromSide, toSide },
|
|
3960
|
+
ports,
|
|
3961
|
+
);
|
|
3962
|
+
readableSideCache.set(edge, {
|
|
3963
|
+
fromSide: planned.fromSide,
|
|
3964
|
+
toSide: planned.toSide,
|
|
3965
|
+
});
|
|
3966
|
+
const routed = { d: polylinePath(planned.points), points: planned.points };
|
|
3967
|
+
pathCache.set(edge, routed);
|
|
3968
|
+
return routed;
|
|
3969
|
+
}
|
|
3970
|
+
const start = ports?.from || anchor(from, fromSide);
|
|
3971
|
+
const end = ports?.to || anchor(to, toSide);
|
|
3972
|
+
const authoredPoints = [start, ...routeVia(edge, from, to, start, end, fromSide, toSide), end];
|
|
3973
|
+
const hasAbsoluteRoutePins = Array.isArray(edge.via)
|
|
3974
|
+
|| edge.channelX !== undefined
|
|
3975
|
+
|| edge.channelY !== undefined;
|
|
3976
|
+
const points = workflow.schema_version === 2 && !hasAbsoluteRoutePins
|
|
3977
|
+
? normalizeRoutePoints(authoredPoints)
|
|
3978
|
+
: authoredPoints;
|
|
3979
|
+
const routed = { d: polylinePath(points), points };
|
|
3980
|
+
pathCache.set(edge, routed);
|
|
3981
|
+
return routed;
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
function labelRectFor(edge, relationIndex) {
|
|
3985
|
+
if (!edge.label || !nodes.has(edge.from) || !nodes.has(edge.to)) return null;
|
|
3986
|
+
const [lx, ly] = workflowEdgeLabelPoint(edge, pathFor(edge).points);
|
|
3987
|
+
const width = workflowLabelWidth(edge.label);
|
|
3988
|
+
return {
|
|
3989
|
+
relation: edge,
|
|
3990
|
+
relationIndex,
|
|
3991
|
+
label: edge.label,
|
|
3992
|
+
x: lx - width / 2,
|
|
3993
|
+
y: ly - 10,
|
|
3994
|
+
width,
|
|
3995
|
+
height: 14,
|
|
3996
|
+
lx,
|
|
3997
|
+
ly,
|
|
3998
|
+
};
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
function measuredContentBounds() {
|
|
4002
|
+
let left = layout.laneX;
|
|
4003
|
+
let top = 27;
|
|
4004
|
+
let right = layout.laneX + layout.laneW;
|
|
4005
|
+
let bottom = legendY() + 18;
|
|
4006
|
+
const owners = {
|
|
4007
|
+
left: 'workflow lanes',
|
|
4008
|
+
top: asArray(workflow.phases).length ? 'phase header band' : 'workflow top padding',
|
|
4009
|
+
right: 'workflow lanes',
|
|
4010
|
+
bottom: workflowLegendEntries.length ? 'legend' : 'workflow lanes and bottom padding',
|
|
4011
|
+
};
|
|
4012
|
+
const includePoint = ([x, y], contributor) => {
|
|
4013
|
+
if (x < left) {
|
|
4014
|
+
left = x;
|
|
4015
|
+
owners.left = contributor;
|
|
4016
|
+
}
|
|
4017
|
+
if (y < top) {
|
|
4018
|
+
top = y;
|
|
4019
|
+
owners.top = contributor;
|
|
4020
|
+
}
|
|
4021
|
+
if (x > right) {
|
|
4022
|
+
right = x;
|
|
4023
|
+
owners.right = contributor;
|
|
4024
|
+
}
|
|
4025
|
+
if (y > bottom) {
|
|
4026
|
+
bottom = y;
|
|
4027
|
+
owners.bottom = contributor;
|
|
4028
|
+
}
|
|
4029
|
+
};
|
|
4030
|
+
const includeRect = (rect, contributor) => {
|
|
4031
|
+
includePoint([rect.x, rect.y], contributor);
|
|
4032
|
+
includePoint([rect.x + rect.width, rect.y + rect.height], contributor);
|
|
4033
|
+
};
|
|
4034
|
+
|
|
4035
|
+
for (const node of nodes.values()) includeRect(node, `node ${node.id}`);
|
|
4036
|
+
for (const [index, edge] of workflow.edges.entries()) {
|
|
4037
|
+
if (!nodes.has(edge.from) || !nodes.has(edge.to)) continue;
|
|
4038
|
+
for (const point of pathFor(edge).points) includePoint(point, `edge ${edge.id || index}`);
|
|
4039
|
+
const label = labelRectFor(edge, index);
|
|
4040
|
+
if (label) includeRect(label, `edge ${edge.id || index} label mask`);
|
|
4041
|
+
}
|
|
4042
|
+
for (const phase of asArray(workflow.phases)) {
|
|
4043
|
+
if (!Number.isInteger(phase.fromCol) || !Number.isInteger(phase.toCol)
|
|
4044
|
+
|| phase.fromCol < 0 || phase.toCol >= layout.colXs.length || phase.fromCol > phase.toCol) continue;
|
|
4045
|
+
const span = phaseSpan(phase);
|
|
4046
|
+
includeRect({ x: span.x, y: 27, width: span.width, height: 16 }, `phase ${phase.id}`);
|
|
4047
|
+
}
|
|
4048
|
+
for (const group of asArray(workflow.groups)) {
|
|
4049
|
+
if (!laneIndex.has(group.lane) || !Number.isInteger(group.fromCol) || !Number.isInteger(group.toCol)
|
|
4050
|
+
|| group.fromCol < 0 || group.toCol >= layout.colXs.length || group.fromCol > group.toCol) continue;
|
|
4051
|
+
const span = groupSpan(group);
|
|
4052
|
+
includeRect({
|
|
4053
|
+
x: span.x,
|
|
4054
|
+
y: laneTop(group.lane) + layout.laneTitleH + GROUP_FRAME_TOP_INSET,
|
|
4055
|
+
width: span.width,
|
|
4056
|
+
height: workflow.schema_version === 2
|
|
4057
|
+
? laneHeight(group.lane) - layout.laneTitleH
|
|
4058
|
+
- GROUP_FRAME_TOP_INSET - GROUP_FRAME_BOTTOM_INSET
|
|
4059
|
+
: layout.laneH - layout.laneTitleH - 16,
|
|
4060
|
+
}, `group ${group.id}`);
|
|
4061
|
+
if (workflow.schema_version === 2) {
|
|
4062
|
+
const frameY = laneTop(group.lane) + layout.laneTitleH + GROUP_FRAME_TOP_INSET;
|
|
4063
|
+
const labelBaseline = frameY + GROUP_LABEL_BASELINE_OFFSET;
|
|
4064
|
+
includeRect({
|
|
4065
|
+
x: span.x + 10,
|
|
4066
|
+
y: labelBaseline - GROUP_LABEL_MASK_ASCENT,
|
|
4067
|
+
width: textUnits(group.label) * 5.6,
|
|
4068
|
+
height: GROUP_LABEL_MASK_H,
|
|
4069
|
+
}, `group ${group.id} label`);
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
if (workflowLegendEntries.length) {
|
|
4073
|
+
for (const rect of workflowLegendRects()) includeRect(rect, `legend ${rect.kind}`);
|
|
4074
|
+
}
|
|
4075
|
+
return {
|
|
4076
|
+
left,
|
|
4077
|
+
top,
|
|
4078
|
+
right,
|
|
4079
|
+
bottom,
|
|
4080
|
+
contributors: [...new Set([
|
|
4081
|
+
...Object.values(owners),
|
|
4082
|
+
...asArray(layout.widthContributors),
|
|
4083
|
+
...asArray(layout.heightContributors),
|
|
4084
|
+
])],
|
|
4085
|
+
};
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
function finalizeReadableViewBox() {
|
|
4089
|
+
if (workflow.schema_version !== 2) {
|
|
4090
|
+
requiredViewBox = [...viewBox];
|
|
4091
|
+
return;
|
|
4092
|
+
}
|
|
4093
|
+
const bounds = measuredContentBounds();
|
|
4094
|
+
requiredViewBox = [
|
|
4095
|
+
Math.max(minimumCanvasWidth, Math.ceil(bounds.right + 16)),
|
|
4096
|
+
Math.max(autoHeight, Math.ceil(bounds.bottom + 18)),
|
|
4097
|
+
];
|
|
4098
|
+
const outsideOrigin = bounds.left < 0 || bounds.top < 0;
|
|
4099
|
+
if (outsideOrigin) {
|
|
4100
|
+
const hasAbsolutePins = workflow.edges.some((edge) => (
|
|
4101
|
+
Array.isArray(edge.via)
|
|
4102
|
+
|| Array.isArray(edge.labelAt)
|
|
4103
|
+
|| edge.channelX !== undefined
|
|
4104
|
+
|| edge.channelY !== undefined
|
|
4105
|
+
));
|
|
4106
|
+
const message = `Workflow geometry extends above or left of the viewBox origin (${Math.round(bounds.left)}, ${Math.round(bounds.top)}).`;
|
|
4107
|
+
throwDiagnosticError(message, [{
|
|
4108
|
+
code: hasAbsolutePins ? 'workflow/explicit-pin-conflict' : 'workflow/solver-budget-exhausted',
|
|
4109
|
+
severity: 'error',
|
|
4110
|
+
message,
|
|
4111
|
+
subject: { diagramType: 'workflow', path: '/meta/viewBox' },
|
|
4112
|
+
evidence: {
|
|
4113
|
+
actualViewBox: [...viewBox],
|
|
4114
|
+
requiredViewBox: [...requiredViewBox],
|
|
4115
|
+
contentBounds: [bounds.left, bounds.top, bounds.right, bounds.bottom],
|
|
4116
|
+
contributors: bounds.contributors,
|
|
4117
|
+
},
|
|
4118
|
+
supportedFixes: [],
|
|
4119
|
+
}]);
|
|
4120
|
+
}
|
|
4121
|
+
if (!workflow.meta?.viewBox) {
|
|
4122
|
+
viewBox = [...requiredViewBox];
|
|
4123
|
+
return;
|
|
4124
|
+
}
|
|
4125
|
+
const tooNarrow = viewBox[0] < requiredViewBox[0];
|
|
4126
|
+
const tooShort = viewBox[1] < requiredViewBox[1];
|
|
4127
|
+
if (!tooNarrow && !tooShort) return;
|
|
4128
|
+
const message = `Workflow viewBox ${viewBox[0]}×${viewBox[1]} cannot contain the readable-v2 layout; minimum ${requiredViewBox[0]}×${requiredViewBox[1]}.`;
|
|
4129
|
+
const supportedFixes = [];
|
|
4130
|
+
if (acceptsFix((document) => {
|
|
4131
|
+
document.meta.viewBox = [...requiredViewBox];
|
|
4132
|
+
})) {
|
|
4133
|
+
supportedFixes.push(`set meta.viewBox to at least [${requiredViewBox[0]}, ${requiredViewBox[1]}]`);
|
|
4134
|
+
}
|
|
4135
|
+
if (acceptsFix((document) => {
|
|
4136
|
+
delete document.meta.viewBox;
|
|
4137
|
+
})) {
|
|
4138
|
+
supportedFixes.push('omit meta.viewBox so the compiler can use its measured intrinsic canvas');
|
|
4139
|
+
}
|
|
4140
|
+
throwDiagnosticError(message, [{
|
|
4141
|
+
code: 'workflow/viewbox-capacity',
|
|
4142
|
+
severity: 'error',
|
|
4143
|
+
message,
|
|
4144
|
+
subject: { diagramType: 'workflow', path: '/meta/viewBox' },
|
|
4145
|
+
evidence: {
|
|
4146
|
+
actualViewBox: [...viewBox],
|
|
4147
|
+
requiredViewBox: [...requiredViewBox],
|
|
4148
|
+
contentBounds: [bounds.left, bounds.top, bounds.right, bounds.bottom],
|
|
4149
|
+
contributors: bounds.contributors,
|
|
4150
|
+
},
|
|
4151
|
+
supportedFixes,
|
|
4152
|
+
}]);
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
function renderLane(lane, index) {
|
|
4156
|
+
const y = laneTop(lane.id);
|
|
4157
|
+
const height = laneHeight(index);
|
|
4158
|
+
const exception = lane.variant === 'exception'
|
|
4159
|
+
? `\n <rect data-graph-role="structural-frame" data-composition-frame-kind="exception-lane" data-composition-frame-id="lane-${index}-exception" x="${layout.laneX + 6}" y="${y + 6}" width="${layout.laneW - 12}" height="${height - 12}" rx="8" class="c-security-group" stroke-width="1"/>`
|
|
4160
|
+
: '';
|
|
4161
|
+
const labelClass = lane.variant === 'exception' ? 't-security' : 't-dim';
|
|
4162
|
+
const prefix = lane.variant === 'exception' ? 'EX' : String(index + 1).padStart(2, '0');
|
|
4163
|
+
return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-${index}" x="${layout.laneX}" y="${y}" width="${layout.laneW}" height="${height}" rx="10" class="c-lane" stroke-width="1"/>${exception}
|
|
4164
|
+
<text x="${layout.laneX + 14}" y="${y + 22}" class="${labelClass}" font-size="10" font-weight="600">${prefix} / ${esc(lane.label)}</text>`;
|
|
4165
|
+
}
|
|
4166
|
+
|
|
4167
|
+
function renderPhase(phase) {
|
|
4168
|
+
const span = phaseSpan(phase);
|
|
4169
|
+
const accent = variantAccent(phase.variant);
|
|
4170
|
+
const [lineClass] = arrowClassMap[phase.variant || 'default'] || arrowClassMap.default;
|
|
4171
|
+
return ` <line x1="${span.x}" y1="35" x2="${span.x + span.width}" y2="35" class="${lineClass}" stroke-width="1.1"/>
|
|
4172
|
+
<rect x="${span.x}" y="27" width="${span.width}" height="16" rx="4" class="c-mask"/>
|
|
4173
|
+
<text x="${span.cx}" y="39" class="${accent}" font-size="8" font-weight="600" text-anchor="middle">${esc(phase.label)}</text>`;
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
function renderGroup(group, index) {
|
|
4177
|
+
const span = groupSpan(group);
|
|
4178
|
+
const y = laneTop(group.lane) + layout.laneTitleH + GROUP_FRAME_TOP_INSET;
|
|
4179
|
+
const height = workflow.schema_version === 2
|
|
4180
|
+
? laneHeight(group.lane) - layout.laneTitleH
|
|
4181
|
+
- GROUP_FRAME_TOP_INSET - GROUP_FRAME_BOTTOM_INSET
|
|
4182
|
+
: layout.laneH - layout.laneTitleH - 16;
|
|
4183
|
+
const cls = group.variant === 'security' ? 'c-security-group' : 'c-lane';
|
|
4184
|
+
const textClass = variantAccent(group.variant);
|
|
4185
|
+
const labelY = workflow.schema_version === 2 ? y + GROUP_LABEL_BASELINE_OFFSET : y + 14;
|
|
4186
|
+
return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="group" data-composition-frame-id="group-${index}" x="${span.x}" y="${y}" width="${span.width}" height="${height}" rx="9" class="${cls}" stroke-width="1"/>
|
|
4187
|
+
<text x="${span.x + 10}" y="${labelY}" class="${textClass}" font-size="7" font-weight="600">${esc(group.label)}</text>`;
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4190
|
+
function renderNode(node) {
|
|
4191
|
+
const fill = componentFill[node.type] || 'c-external';
|
|
4192
|
+
const accent = componentText[node.type] || 't-muted';
|
|
4193
|
+
const hasSub = node.sublabel != null && node.sublabel !== '';
|
|
4194
|
+
const labelFontSize = fittedNodeFontSize(node.label, brandLabelFitWidth(node, node.width), nodeTextFit.labelPreferred, nodeTextFit.labelMinimum);
|
|
4195
|
+
const sublabelFontSize = hasSub
|
|
4196
|
+
? fittedNodeFontSize(node.sublabel, node.width, nodeTextFit.sublabelPreferred, nodeTextFit.sublabelMinimum)
|
|
4197
|
+
: nodeTextFit.sublabelPreferred;
|
|
4198
|
+
const sub = hasSub
|
|
4199
|
+
? `\n <text data-detail="context" x="${node.cx}" y="${node.y + 38}" class="t-muted" font-size="${sublabelFontSize}" text-anchor="middle">${esc(node.sublabel)}</text>`
|
|
4200
|
+
: '';
|
|
4201
|
+
const tag = node.tag
|
|
4202
|
+
? `\n <text data-detail="fine" x="${node.cx}" y="${node.y + node.height - 12}" class="${accent}" font-size="${fittedNodeFontSize(node.tag, node.width, nodeTextFit.tagPreferred, nodeTextFit.tagMinimum)}" text-anchor="middle">${esc(node.tag)}</text>`
|
|
4203
|
+
: '';
|
|
4204
|
+
const brand = renderBrandMark(node, { x: node.x + node.width - 22, y: node.y + 6 });
|
|
4205
|
+
const passport = { kind: node.type, sublabel: node.sublabel, tag: node.tag, context: nodeContext(node), ...brandMetadataFor(node) };
|
|
4206
|
+
return ` <g ${focusNodeAttrs(node.id, node.label, passport, workflow.meta.locale)}>
|
|
4207
|
+
${focusNodeTitle(node.label, passport)}
|
|
4208
|
+
<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="c-mask"/>
|
|
4209
|
+
<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="${fill}"${animateAttr(workflow.meta, 'node', nodeStep(node))} stroke-width="1.5"/>
|
|
4210
|
+
${renderSemanticSigil(node.type, { x: node.x + 6, y: node.y + 6 })}${brand ? `\n ${brand}` : ''}
|
|
4211
|
+
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${node.cx}" y="${node.y + 21}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(node.label)}</text>${sub}${tag}
|
|
4212
|
+
</g>`;
|
|
4213
|
+
}
|
|
4214
|
+
|
|
4215
|
+
function renderEdgePath(edge, index) {
|
|
4216
|
+
const [cls, marker] = arrowClassMap[edge.variant || 'default'] || arrowClassMap.default;
|
|
4217
|
+
const routed = pathFor(edge);
|
|
4218
|
+
const strokeWidth = edge.width || (edge.variant === 'emphasis' ? 1.8 : 1.4);
|
|
4219
|
+
return ` <path ${focusEdgeAttrs(edge.from, edge.to, edge.label, index, edge.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(workflow.meta, 'edge', edgeSteps.get(edge))} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
function renderEdgeLabel(edge, index) {
|
|
4223
|
+
if (!edge.label) return '';
|
|
4224
|
+
const routed = pathFor(edge);
|
|
4225
|
+
const [lx, ly] = workflowEdgeLabelPoint(edge, routed.points);
|
|
4226
|
+
const labelW = workflowLabelWidth(edge.label);
|
|
4227
|
+
return ` <g data-detail="context" ${focusEdgeAttrs(edge.from, edge.to, edge.label, index, edge.id)}>
|
|
4228
|
+
<rect x="${lx - labelW / 2}" y="${ly - 10}" width="${labelW}" height="14" rx="3" class="c-mask"/>
|
|
4229
|
+
<text x="${lx}" y="${ly}" class="${variantAccent(edge.variant, { dashed: 't-database' })}" font-size="8" text-anchor="middle">${esc(edge.label)}</text>
|
|
4230
|
+
</g>`;
|
|
4231
|
+
}
|
|
4232
|
+
|
|
4233
|
+
function renderLegend() {
|
|
4234
|
+
const obstacles = workflow.schema_version === 2
|
|
4235
|
+
? relationshipLegendObstacles(workflow.edges, {
|
|
4236
|
+
pointsFor: (edge) => pathFor(edge).points,
|
|
4237
|
+
labelRectFor,
|
|
4238
|
+
})
|
|
4239
|
+
: [];
|
|
4240
|
+
return renderResolvedLegend({
|
|
4241
|
+
entries: workflowLegendEntries,
|
|
4242
|
+
locale: workflow.meta.locale,
|
|
4243
|
+
layout: workflowLegendLayout(obstacles),
|
|
4244
|
+
renderSwatch: (entry) => `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="${componentFill[entry.kind] || 'c-external'}" stroke-width="1"/>`,
|
|
4245
|
+
});
|
|
4246
|
+
}
|
|
4247
|
+
|
|
4248
|
+
function renderSvg() {
|
|
4249
|
+
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(workflow.meta, 'workflow diagram')}>
|
|
4250
|
+
${svgAccessibleText(workflow.meta, 'workflow')}
|
|
4251
|
+
${renderDefinitions()}
|
|
4252
|
+
|
|
4253
|
+
<!-- Background Grid -->
|
|
4254
|
+
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
4255
|
+
|
|
4256
|
+
<!-- Swimlanes -->
|
|
4257
|
+
${workflow.lanes.map(renderLane).join('\n\n')}
|
|
4258
|
+
|
|
4259
|
+
<!-- Phase headers -->
|
|
4260
|
+
${asArray(workflow.phases).map(renderPhase).join('\n')}
|
|
4261
|
+
|
|
4262
|
+
<!-- Workflow groups -->
|
|
4263
|
+
${asArray(workflow.groups).map(renderGroup).join('\n')}
|
|
4264
|
+
|
|
4265
|
+
<!-- Edge paths -->
|
|
4266
|
+
${workflow.edges.map(renderEdgePath).join('\n')}
|
|
4267
|
+
|
|
4268
|
+
<!-- Nodes -->
|
|
4269
|
+
${[...nodes.values()].map(renderNode).join('\n\n')}
|
|
4270
|
+
|
|
4271
|
+
<!-- Edge labels -->
|
|
4272
|
+
${workflow.edges.map(renderEdgeLabel).join('\n')}
|
|
4273
|
+
|
|
4274
|
+
<!-- Legend -->
|
|
4275
|
+
${renderLegend()}
|
|
4276
|
+
</svg>`;
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
|
|
4280
|
+
try {
|
|
4281
|
+
validateReadableInputsBeforeRouting();
|
|
4282
|
+
validateReadablePinnedGeometry();
|
|
4283
|
+
validateWorkflow();
|
|
4284
|
+
finalizeReadableViewBox();
|
|
4285
|
+
const svg = renderSvg();
|
|
4286
|
+
const receipt = {
|
|
4287
|
+
contract: layout.contract,
|
|
4288
|
+
viewBox: [...viewBox],
|
|
4289
|
+
requiredViewBox: [...requiredViewBox],
|
|
4290
|
+
columns: [...layout.colXs],
|
|
4291
|
+
nodes: [...nodes.values()].map((node) => ({
|
|
4292
|
+
id: node.id,
|
|
4293
|
+
lane: node.lane,
|
|
4294
|
+
col: node.col,
|
|
4295
|
+
x: node.x,
|
|
4296
|
+
y: node.y,
|
|
4297
|
+
width: node.width,
|
|
4298
|
+
height: node.height,
|
|
4299
|
+
})),
|
|
4300
|
+
edges: workflow.edges.map((edge) => ({
|
|
4301
|
+
id: edge.id ?? null,
|
|
4302
|
+
from: edge.from,
|
|
4303
|
+
to: edge.to,
|
|
4304
|
+
points: pathFor(edge).points.map((point) => [...point]),
|
|
4305
|
+
})),
|
|
4306
|
+
labels: workflow.edges.flatMap((edge) => {
|
|
4307
|
+
if (!edge.label || !nodes.has(edge.from) || !nodes.has(edge.to)) return [];
|
|
4308
|
+
const [x, y] = workflowEdgeLabelPoint(edge, pathFor(edge).points);
|
|
4309
|
+
return [{ edge: edge.id ?? null, label: edge.label, x, y, width: workflowLabelWidth(edge.label), height: 14 }];
|
|
4310
|
+
}),
|
|
4311
|
+
diagnostics: [],
|
|
4312
|
+
};
|
|
4313
|
+
return { ok: true, svg, receipt };
|
|
4314
|
+
} catch (error) {
|
|
4315
|
+
if (!Array.isArray(error?.archifyDiagnostics)) throw error;
|
|
4316
|
+
const diagnostics = error.archifyDiagnostics.map((diagnostic) => ({ ...diagnostic }));
|
|
4317
|
+
return compilerFailure(layout.contract, diagnostics, error.message);
|
|
4318
|
+
}
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4321
|
+
function feedbackFailure(request) {
|
|
4322
|
+
const message = `Workflow edge "${request.edge || `${request.from}->${request.to}`}" exhausted bounded readable-v2 layout feedback without a feasible automatic route.`;
|
|
4323
|
+
const diagnostics = [{
|
|
4324
|
+
code: 'workflow/solver-budget-exhausted',
|
|
4325
|
+
severity: 'error',
|
|
4326
|
+
message,
|
|
4327
|
+
subject: {
|
|
4328
|
+
diagramType: 'workflow',
|
|
4329
|
+
edge: request.edge,
|
|
4330
|
+
from: request.from,
|
|
4331
|
+
to: request.to,
|
|
4332
|
+
},
|
|
4333
|
+
evidence: {
|
|
4334
|
+
attemptedCandidateFamilies: request.attemptedCandidateFamilies,
|
|
4335
|
+
candidateCount: request.candidateCount,
|
|
4336
|
+
},
|
|
4337
|
+
supportedFixes: [],
|
|
4338
|
+
}];
|
|
4339
|
+
return compilerFailure('readable-v2', diagnostics, message);
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
function compileWorkflowWithFeedback({ workflow, qualityProfile, discoverFixes = true } = {}) {
|
|
4343
|
+
let layoutFeedback = {};
|
|
4344
|
+
for (let attempt = 0; attempt <= MAX_READABLE_LAYOUT_FEEDBACK_ROUNDS; attempt += 1) {
|
|
4345
|
+
try {
|
|
4346
|
+
return compileWorkflowInternal({
|
|
4347
|
+
workflow,
|
|
4348
|
+
qualityProfile,
|
|
4349
|
+
discoverFixes,
|
|
4350
|
+
layoutFeedback,
|
|
4351
|
+
});
|
|
4352
|
+
} catch (error) {
|
|
4353
|
+
if (!(error instanceof WorkflowLayoutFeedback)) throw error;
|
|
4354
|
+
const request = error.request;
|
|
4355
|
+
let nextFeedback = null;
|
|
4356
|
+
if (request.kind === 'rank-gap-minimum'
|
|
4357
|
+
&& Number.isInteger(request.fromCol)
|
|
4358
|
+
&& Number.isInteger(request.toCol)
|
|
4359
|
+
&& Number.isFinite(request.minimum)) {
|
|
4360
|
+
const key = `${request.fromCol}:${request.toCol}`;
|
|
4361
|
+
const current = layoutFeedback.rankGapMinimums?.[key] ?? -Infinity;
|
|
4362
|
+
if (request.minimum > current + 0.0001) {
|
|
4363
|
+
nextFeedback = {
|
|
4364
|
+
...layoutFeedback,
|
|
4365
|
+
rankGapMinimums: {
|
|
4366
|
+
...(layoutFeedback.rankGapMinimums || {}),
|
|
4367
|
+
[key]: request.minimum,
|
|
4368
|
+
},
|
|
4369
|
+
rankGapContributors: {
|
|
4370
|
+
...(layoutFeedback.rankGapContributors || {}),
|
|
4371
|
+
[key]: [
|
|
4372
|
+
`rank ${request.fromCol}→${request.toCol} route clearance`,
|
|
4373
|
+
`edge ${request.edge || `${request.from}->${request.to}`} route`,
|
|
4374
|
+
],
|
|
4375
|
+
},
|
|
4376
|
+
};
|
|
4377
|
+
}
|
|
4378
|
+
} else if (request.kind === 'lane-gap-minimum'
|
|
4379
|
+
&& Number.isFinite(request.minimum)
|
|
4380
|
+
&& request.minimum > (layoutFeedback.laneGapMin ?? -Infinity) + 0.0001) {
|
|
4381
|
+
nextFeedback = {
|
|
4382
|
+
...layoutFeedback,
|
|
4383
|
+
laneGapMin: request.minimum,
|
|
4384
|
+
laneGapContributors: [
|
|
4385
|
+
`edge ${request.edge || `${request.from}->${request.to}`} lane-gap route clearance`,
|
|
4386
|
+
],
|
|
4387
|
+
};
|
|
4388
|
+
}
|
|
4389
|
+
if (!nextFeedback || attempt === MAX_READABLE_LAYOUT_FEEDBACK_ROUNDS) {
|
|
4390
|
+
return feedbackFailure(request);
|
|
4391
|
+
}
|
|
4392
|
+
layoutFeedback = nextFeedback;
|
|
4393
|
+
}
|
|
4394
|
+
}
|
|
4395
|
+
throw new Error('unreachable readable-v2 layout feedback state');
|
|
4396
|
+
}
|
|
4397
|
+
|
|
4398
|
+
export function compileWorkflow({ workflow, qualityProfile } = {}) {
|
|
4399
|
+
return compileWorkflowWithFeedback({ workflow, qualityProfile });
|
|
4400
|
+
}
|