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.
@@ -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
+ }