Skill Tracer
Skills › Archify › archify › dataflow-compiler.mjs

dataflow-compiler.mjs

1605 lines · archify/renderers/dataflow/dataflow-compiler.mjs at a406594

1import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
2import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
3import { throwDiagnosticProblems, withIsolatedDiagnosticRecording, rendererFailure } from '../shared/diagnostics.mjs';
4import { resolveLegend, renderLegend as renderResolvedLegend, legendFootprint } from '../shared/legend.mjs';
5import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth, nodeLabelLayout } from '../shared/text-fit.mjs';
6import { brandLabelFitWidth, brandMarkFor, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
7import { translateMessage as i18nText } from '../shared/i18n.mjs';
8import { DECLARED_WIDE_READER_RATIO, minimumReadableSourceTextPx, predictedFixedWidthOverflow } from '../shared/desktop-readability.mjs';
9import { placeAutomaticLabels } from '../shared/automatic-labels.mjs';
10import { shortestOrthogonalGridRoute } from '../shared/route-quality.mjs';
11import { repairLabelRoutes } from './joint-label-routing.mjs';
12import { FLOW_LABEL_HEIGHT, flowLabelSize, wrapBlockedFlowLabels } from './label-capacity.mjs';
13import {
14 asArray,
15 isFinitePoint,
16 rectsOverlap,
17 cleanEndpointSideProblems,
18 cleanFlowProblems,
19 cleanCrossingProblems,
20 collectAmbiguousCorridors,
21 collectBorderRuns,
22 cleanAmbiguousCorridorProblems,
23 cleanBorderRunProblems,
24 frameBorderSegments,
25 cleanRouteRhythmProblems,
26 collectRouteRhythmIssues,
27 cleanLabelRouteClearanceProblems,
28 cleanLabelCanvasContainmentProblems,
29 suggestLabelObstacleFix,
30 suggestLabelPairFix,
31 anchor,
32 automaticPortSpread,
33 alignAutomaticFacingPorts,
34 automaticPortRhythmBridge,
35 segmentIntersectsRect,
36 segmentRectClearanceWithin,
37 routeHonorsEndpointSides,
38 normalizeRoutePoints,
39 legacyDefaultFromSide as defaultFromSide,
40 legacyDefaultToSide as defaultToSide,
41 chosenSide,
42 polylinePath,
43 routePointsValue,
44 authoredStraightRouteAttrs,
45 labelPoint as legacyLabelPoint,
46 componentFill,
47 componentText,
48 arrowClassMap,
49 edgeLabelAccent,
50 properSegmentIntersection
51} from '../shared/geometry.mjs';
53const nodeTextFit = {
54 sublabelPreferred: 7,
55 sublabelMinimum: 6,
56 tagPreferred: 7,
57 tagMinimum: 6,
58};
60// All geometry is local to an attempt. File publication and source loading
61// stay in the CLI renderer and happen once, after a complete valid SVG exists.
62export function compileDataflow({ dataflow, sourceEvidence, qualityProfile: requestedQualityProfile }) {
63 function build(rebalancePorts) {
64 const layout = {
65 stageY: 46,
66 stageH: 36,
67 stageBottomPad: 74,
68 leftX: 100,
69 colGap: 215,
70 stageW: 168,
71 nodeW: 112,
72 nodeH: 58,
73 rowYs: [128, 242, 356, 470, 584],
74 labelH: FLOW_LABEL_HEIGHT
75 };
76 // Mirrors the authored minimum in schemas/dataflow.schema.json: a repair that
77 // would need a node shorter than the schema allows cannot be offered.
78 const minimumNodeHeight = 36;
80 // Explicit geometry preserves established node sizing, typography, height,
81 // routing and label placement. An omitted canvas width still fits its content
82 // in either profile, including authored node widths.
83 const qualityProfile = requestedQualityProfile;
84 const automaticShowcase = qualityProfile === 'showcase'
85 && dataflow.meta?.viewBox === undefined
86 && !asArray(dataflow.nodes).some(node => node.width !== undefined);
87 const stageRight = stageX(asArray(dataflow.stages).length - 1) + layout.stageW / 2;
88 const nodeRights = asArray(dataflow.nodes).map(node =>
89 stageX(node.stage) + (node.width || layout.nodeW) / 2).filter(Number.isFinite);
90 const pinnedLabelRights = asArray(dataflow.flows).filter(flow => flow.label && Array.isArray(flow.labelAt) && isFinitePoint(...flow.labelAt))
91 .map(flow => flow.labelAt[0] + flowLabelSize(flow).width / 2);
92 // Automatic width fits stages/nodes with 24px right pad. The old 940px floor
93 // left a blank third of the canvas on 2–3 stage drafts; five default stages
94 // still measure ~1068 and stay unchanged. Height starts at 720 and is refit
95 // by heightForLabels for automatic showcase.
96 const contentWidth = Math.ceil(Math.max(stageRight, ...nodeRights, ...pinnedLabelRights, 0) + 24);
97 const viewBox = dataflow.meta?.viewBox || [
98 Math.max(480, contentWidth),
99 720,
100 ];
101 const contextFontMinimum = automaticShowcase
102 ? Math.max(6, Math.ceil(minimumReadableSourceTextPx(viewBox[0]) * 10) / 10)
103 : 6;
105 function stageX(index) {
106 return layout.leftX + index * layout.colGap;
107 }
109 function stageFrame(stage, index) {
110 return {
111 id: index,
112 label: stage.label,
113 kind: 'stage',
114 x: stageX(index) - layout.stageW / 2,
115 y: layout.stageY,
116 width: layout.stageW,
117 height: viewBox[1] - layout.stageY - layout.stageBottomPad,
118 radius: 10,
119 };
120 }
122 function measureNode(node) {
123 const cx = stageX(node.stage);
124 // Expand only unpinned showcase nodes, within their existing stage budget.
125 // Very long text still gets an actionable error instead of an unbounded
126 // canvas/typography feedback loop.
127 const contextWidth = Math.max(0, ...[node.sublabel, node.tag].map(text =>
128 text ? minimumNodeTextWidth(text, Math.max(7, contextFontMinimum)) + 8 : 0));
129 const width = node.width || (automaticShowcase
130 ? Math.max(layout.nodeW, Math.min(layout.stageW,
131 (cx - 24) * 2, (viewBox[0] - 24 - cx) * 2, Math.ceil(contextWidth)))
132 : layout.nodeW);
133 const height = node.height || layout.nodeH;
134 const y = layout.rowYs[node.row] + (node.yOffset || 0);
135 return {
136 ...node,
137 width,
138 height,
139 cx,
140 cy: y + height / 2,
141 x: cx - width / 2,
142 y
143 };
144 }
146 const nodes = new Map(asArray(dataflow.nodes).map((node) => [node.id, measureNode(node)]));
147 // Move one outgoing arrow off a crowded mixed side when its
148 // perpendicular exit has capacity and reaches the target with a clear corner.
149 const hasPinnedFlowGeometry = flow => flow.via || (flow.route && flow.route !== 'auto')
150 || ['fromSide', 'toSide', 'channelX', 'channelY', 'labelAt', 'labelDx', 'labelDy', 'labelSegment']
151 .some(key => flow[key] !== undefined);
152 // Arrowheads are seven stroke-width units tall. Reserve two units of
153 // clear ink within the automatic spread's existing 18px spacing limit.
154 const portSpan = items => 32 + Math.max(0, items.length - 1) * Math.min(18,
155 Math.ceil(Math.max(0, ...items.map(item => item.width * (item.incoming ? 7 : 1)))) + 2);
157 const automaticSourceSides = new Map();
158 if (automaticShowcase && rebalancePorts) {
159 const flows = asArray(dataflow.flows);
160 for (const node of nodes.values()) {
161 const authored = dataflow.nodes.find(item => item.id === node.id);
162 if (authored.height !== undefined || authored.yOffset !== undefined) continue;
163 const incident = flows.filter(flow => flow.from === node.id || flow.to === node.id);
164 if (incident.some(hasPinnedFlowGeometry)) continue;
165 const sides = { left: [], right: [], top: [], bottom: [] };
166 for (const flow of incident) {
167 const from = nodes.get(flow.from), to = nodes.get(flow.to);
168 if (!from || !to) continue;
169 const incoming = flow.to === node.id;
170 const side = incoming ? defaultToSide(from, to)
171 : automaticSourceSides.get(flow) || defaultFromSide(from, to);
172 sides[side].push({ flow, incoming, width: flowStrokeWidth(flow) });
173 }
174 const choices = [];
175 for (const side of ['left', 'right']) {
176 const items = sides[side];
177 if (!items.some(item => item.incoming) || portSpan(items) <= node.height) continue;
178 for (const item of items.filter(item => !item.incoming)) {
179 const target = nodes.get(item.flow.to), dy = target.cy - node.cy;
180 if (Math.abs(dy) < (node.height + target.height) / 2 + 16) continue;
181 const alternate = dy > 0 ? 'bottom' : 'top';
182 if (portSpan([...sides[alternate], item]) > node.width) continue;
183 const toSide = defaultToSide(node, target);
184 if (toSide !== 'left' && toSide !== 'right') continue;
185 const start = anchor(node, alternate), end = anchor(target, toSide);
186 const points = normalizeRoutePoints([start, [start[0], end[1]], end]);
187 if (!routeHonorsEndpointSides(points, alternate, toSide)) continue;
188 if ([...nodes.values()].some(other => points.slice(1).some((point, index) =>
189 segmentIntersectsRect({ start: points[index], end: point }, other, 2)
190 && !((other.id === node.id && index === 0)
191 || (other.id === target.id && index === points.length - 2))))) continue;
192 choices.push({ flow: item.flow, alternate,
193 distance: Math.abs(start[0] - end[0]) + Math.abs(start[1] - end[1]) });
194 }
195 }
196 choices.sort((a, b) => a.distance - b.distance);
197 if (choices.length) automaticSourceSides.set(choices[0].flow, choices[0].alternate);
198 }
199 }
201 if (rebalancePorts && !automaticSourceSides.size) return null;
203 // Seven incoming arrows cannot fit on a 58px box after the corner gutters.
204 // Use spare row space around its centre, so clear same-row outputs stay
205 // straight. Explicit geometry and incident route/label pins stay authoritative.
206 if (automaticShowcase) {
207 const flows = asArray(dataflow.flows);
208 const originalBoxes = [...nodes.values()].map(node => ({ ...node }));
209 const rowBudget = Math.min(...layout.rowYs.slice(1).map((y, index) => y - layout.rowYs[index])) - 10;
210 for (const authored of asArray(dataflow.nodes)) {
211 if (authored.height !== undefined || authored.yOffset !== undefined) continue;
212 const node = nodes.get(authored.id);
213 const incident = flows.filter(flow => flow.from === node.id || flow.to === node.id);
214 if (incident.some(hasPinnedFlowGeometry)) continue;
215 const sides = { left: [], right: [] };
216 for (const flow of incident) {
217 if (!nodes.has(flow.from) || !nodes.has(flow.to)) continue;
218 const { fromSide, toSide } = flowSides(flow);
219 if (flow.from === node.id) sides[fromSide]?.push({ incoming: false, width: flowStrokeWidth(flow) });
220 if (flow.to === node.id) sides[toSide]?.push({ incoming: true, width: flowStrokeWidth(flow) });
221 }
222 const desired = Math.max(node.height, ...Object.values(sides)
223 .filter(items => items.some(item => item.incoming)).map(portSpan));
224 const neighbours = originalBoxes.filter(other => other.id !== node.id
225 && other.x < node.x + node.width + 10 && other.x + other.width + 10 > node.x);
226 const height = Math.min(desired, rowBudget,
227 2 * (node.cy - (layout.stageY + layout.stageH + 22)),
228 ...neighbours.map(other => {
229 const distance = Math.abs(other.cy - node.cy);
230 // Split spare space with another growing box, and also respect a
231 // taller authored neighbour. Use original bounds for stable ordering.
232 return Math.min(distance - 10, 2 * distance - other.height - 20);
233 }));
234 if (height > node.height) {
235 node.autoHeightInset = (height - node.height) / 2;
236 node.y -= node.autoHeightInset;
237 node.height = height;
238 }
239 }
240 }
241 const nodeSteps = new Map();
242 for (const [index, flow] of asArray(dataflow.flows).entries()) {
243 if (!nodeSteps.has(flow.from)) nodeSteps.set(flow.from, index);
244 if (!nodeSteps.has(flow.to)) nodeSteps.set(flow.to, index + 1);
245 }
246 for (const [index, node] of asArray(dataflow.nodes).entries()) {
247 if (!nodeSteps.has(node.id)) nodeSteps.set(node.id, index);
248 }
250 function validateDataflow() {
251 const problems = [];
252 const diagnostics = [];
253 if (nodes.size !== asArray(dataflow.nodes).length) problems.push('Node ids must be unique.');
255 const stageCount = asArray(dataflow.stages).length;
256 for (const node of nodes.values()) {
257 if (typeof node.stage !== 'number' || node.stage < 0 || node.stage >= stageCount) {
258 problems.push(`Node "${node.id}" uses invalid stage ${node.stage} — valid stages are 0..${stageCount - 1}.`);
259 }
260 if (typeof node.row !== 'number' || node.row < 0 || node.row >= layout.rowYs.length) {
261 problems.push(`Node "${node.id}" uses invalid row ${node.row} — valid rows are 0..${layout.rowYs.length - 1}.`);
262 }
263 if (!isFinitePoint(node.x, node.y, node.cx, node.cy)) {
264 problems.push(`Node "${node.id}" produced non-finite coordinates — check stage, row, width, height, and yOffset are numbers.`);
265 continue;
266 }
267 if (node.x < 24 || node.x + node.width > viewBox[0] - 24) {
268 problems.push(`Node "${node.id}" exceeds the horizontal bounds of the viewBox — reduce node.width or increase meta.viewBox[0].`);
269 }
270 const diagramAreaTop = layout.stageY + layout.stageH + 22;
271 const diagramAreaBottom = viewBox[1] - layout.stageBottomPad;
272 // y is the node's top edge, so the top bound moves only with yOffset —
273 // height cannot repair it. Both bounds are reported independently, because
274 // a node tall enough to cross the whole area violates both at once.
275 if (node.y < diagramAreaTop) {
276 const message = `Node "${node.id}" starts above the readable diagram area — keep y at or above ${diagramAreaTop}.`;
277 diagnostics.push({
278 code: 'dataflow/node-above-area',
279 severity: 'error',
280 message,
281 subject: { diagramType: 'dataflow', node: node.id },
282 evidence: { y: node.y, areaTop: diagramAreaTop, yOffset: node.yOffset || 0 },
283 supportedFixes: [
284 `raise or remove the negative yOffset on node "${node.id}"`,
285 ],
286 });
287 problems.push(message);
288 }
289 if (node.y + node.height > diagramAreaBottom) {
290 const nodeBottom = node.y + node.height;
291 const requiredViewBoxHeight = nodeBottom + layout.stageBottomPad;
292 // Only offer a repair that can clear this bound on its own. Lowering
293 // yOffset moves the whole node, which reaches the bound only while the
294 // node is short enough to fit between them at all; reducing height moves
295 // only the bottom edge, and only reaches the bound while a node short
296 // enough to do it is still legal to author.
297 const supportedFixes = [];
298 const roomForNode = diagramAreaBottom - diagramAreaTop;
299 // The height that clears the bound at the node's current y. The room
300 // between the bounds is the cap only while the node can still move; once
301 // it sits below the area, the cap is what remains below it.
302 const heightCapAtY = Math.min(roomForNode, diagramAreaBottom - node.y);
303 if (node.height <= roomForNode) {
304 supportedFixes.push(`lower yOffset on node "${node.id}"`);
305 }
306 if (heightCapAtY >= minimumNodeHeight) {
307 supportedFixes.push(`reduce node "${node.id}" height to at most ${heightCapAtY}`);
308 }
309 // A dataflow canvas with an authored meta.viewBox gets no readable-scroll
310 // exception, so the raised canvas is offered as a standalone repair only
311 // while the delivered page still fits the target viewport — the same
312 // page-fit math the checker applies for composition/viewport-height. The
313 // raise is bounded above too: growing the height lowers the canvas ratio,
314 // and under the wide threshold the Reader can no longer narrow the page,
315 // so advice that stopped at the minimum let an author overshoot into a
316 // certain visual-check failure.
317 const raisedOverflow = predictedFixedWidthOverflow({
318 viewBoxWidth: viewBox[0],
319 viewBoxHeight: requiredViewBoxHeight,
320 readerFit: null,
321 diagramType: null,
322 });
323 const raisedCanvasFits = raisedOverflow === null;
324 let maximumViewBoxHeight = Math.floor(viewBox[0] / DECLARED_WIDE_READER_RATIO);
325 // The ratio boundary can land between integers; step down until the same
326 // predicate the checker uses accepts the height.
327 while (maximumViewBoxHeight > 1 && predictedFixedWidthOverflow({
328 viewBoxWidth: viewBox[0],
329 viewBoxHeight: maximumViewBoxHeight,
330 readerFit: null,
331 diagramType: null,
332 }) !== null) {
333 maximumViewBoxHeight -= 1;
334 }
335 const raiseTarget = maximumViewBoxHeight > requiredViewBoxHeight
336 ? `to at least ${requiredViewBoxHeight} and at most ${maximumViewBoxHeight}`
337 : `to ${requiredViewBoxHeight}`;
338 if (raisedCanvasFits) {
339 supportedFixes.push(`raise meta.viewBox[1] ${raiseTarget}, then rerun deliver and visual-check`);
340 }
341 // The bound belongs to y + height, so name the y that satisfies it. The
342 // unadjusted "keep y between 104 and areaBottom" let an author follow the
343 // message exactly and still fail.
344 const extent = node.height <= roomForNode
345 ? `keep y within [${diagramAreaTop}, ${diagramAreaBottom - node.height}] so that y + height does not pass ${diagramAreaBottom}`
346 : heightCapAtY >= minimumNodeHeight
347 ? `it is taller than the area from y = ${diagramAreaTop} to y = ${diagramAreaBottom}, so no yOffset can fit it — reduce node height to at most ${heightCapAtY} and keep y within [${diagramAreaTop}, ${diagramAreaBottom - heightCapAtY}] so that y + height does not pass ${diagramAreaBottom}`
348 : `it is taller than the area from y = ${diagramAreaTop} to y = ${diagramAreaBottom} and sits too low for any legal height, so no single change clears this bound — reduce node height to at most ${roomForNode} and move it up so that y + height does not pass ${diagramAreaBottom}`;
349 const raiseClause = raisedCanvasFits
350 ? ` Raising meta.viewBox[1] ${raiseTarget} also fits it and still fits the target viewport.`
351 : ` Raising meta.viewBox[1] to ${requiredViewBoxHeight} would fit the node, but the page then reaches at least ~${raisedOverflow.pageHeightPx}px in the 1440x900 viewport, so the raise cannot stand on its own — compact the node instead.`;
352 const message = `Node "${node.id}" exceeds the readable diagram area — ${extent}.${raiseClause}`;
353 diagnostics.push({
354 code: 'dataflow/node-below-area',
355 severity: 'error',
356 message,
357 subject: { diagramType: 'dataflow', node: node.id },
358 evidence: {
359 y: node.y,
360 height: node.height,
361 areaBottom: diagramAreaBottom,
362 requiredViewBoxHeight,
363 requiredViewBoxFitsViewport: raisedCanvasFits,
364 maximumViewBoxHeight,
365 ...(raisedCanvasFits ? {} : { requiredViewBoxPageHeightPx: raisedOverflow.pageHeightPx }),
366 viewBoxHeight: viewBox[1],
367 },
368 supportedFixes,
369 });
370 problems.push(message);
371 }
372 const estLabelW = textUnits(node.label) * 6.2;
373 if (estLabelW > node.width + 6) {
374 problems.push(`Label "${node.label}" (~${Math.round(estLabelW)}px) is wider than component "${node.id}" (${node.width}px) — shorten the label or increase node.width.`);
375 }
376 const brandRailProblem = brandTopRailProblem(node, node.width, 8);
377 if (brandRailProblem) problems.push(brandRailProblem);
378 // sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
379 // handles the ordinary case, this rejects what it cannot rescue.
380 const availableTextW = availableNodeTextWidth(node.width);
381 for (const [field, value, minimum] of [
382 ['Sublabel', node.sublabel, contextFontMinimum],
383 ['Tag', node.tag, contextFontMinimum],
384 ]) {
385 if (!value) continue;
386 const minimumW = minimumNodeTextWidth(value, minimum);
387 if (minimumW > availableTextW) {
388 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.`);
389 }
390 }
391 }
393 const nodeList = asArray(dataflow.nodes);
394 for (let i = 0; i < nodeList.length; i += 1) {
395 for (let j = i + 1; j < nodeList.length; j += 1) {
396 const a = nodes.get(nodeList[i].id);
397 const b = nodes.get(nodeList[j].id);
398 if (rectsOverlap(a, b, 10)) {
399 problems.push(`Nodes "${a.id}" and "${b.id}" are less than 10px apart — move one to another stage/row or adjust yOffset.`);
400 }
401 }
402 }
404 const flowList = asArray(dataflow.flows);
405 const stageById = new Map(asArray(dataflow.nodes).map((node) => [node.id, node.stage]));
406 for (const flow of flowList) {
407 const flowIndex = flowList.indexOf(flow);
408 for (const [field, endpoint] of [['from', 'source'], ['to', 'target']]) {
409 if (nodes.has(flow[field])) continue;
410 const problem = `Flow "${flow.label || flow[field]}" references unknown ${endpoint} "${flow[field]}".`;
411 const anchorStage = stageById.get(flow[field === 'from' ? 'to' : 'from']);
412 const anchorId = flow[field === 'from' ? 'to' : 'from'];
413 const stageDelta = (id) => (anchorStage === undefined || stageById.get(id) === undefined
414 ? 0
415 : field === 'to'
416 ? stageById.get(id) - anchorStage
417 : anchorStage - stageById.get(id));
418 const candidates = [...stageById.keys()]
419 .filter((id) => id !== anchorId)
420 .sort((a, b) => {
421 const da = stageDelta(a);
422 const db = stageDelta(b);
423 const forwardDiff = (db > 0) - (da > 0);
424 return forwardDiff !== 0 ? forwardDiff : da - db;
425 });
426 diagnostics.push({
427 code: 'dataflow/unknown-endpoint', severity: 'error', message: problem,
428 subject: {
429 diagramType: 'dataflow',
430 flow: flow.id ?? null,
431 path: `/flows/${flowIndex}/${field}`,
432 from: flow.from,
433 to: flow.to,
434 },
435 evidence: { endpoint, unknownNodeId: flow[field], availableNodeIds: candidates },
436 supportedFixes: candidates.slice(0, 3).map((id) => `set /flows/${flowIndex}/${field} to verified node id "${id}"`),
437 });
438 problems.push(problem);
439 }
440 if (!flow.label) problems.push(`Flow "${flow.from}" -> "${flow.to}" must include a short data label.`);
441 if (nodes.has(flow.from) && nodes.has(flow.to)) {
442 const routed = pathFor(flow);
443 const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
444 const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
445 if (distance < 34) problems.push(`Flow "${flow.label}" is too short (${Math.round(distance)}px; minimum 34px) — route it through a channel or spread its nodes.`);
446 if (Array.isArray(flow.via)) {
447 for (let segmentIndex = 0; segmentIndex < routed.points.length - 1; segmentIndex += 1) {
448 const segmentStart = routed.points[segmentIndex];
449 const segmentEnd = routed.points[segmentIndex + 1];
450 const isDiagonal = Math.abs(segmentStart[0] - segmentEnd[0]) > 0.01
451 && Math.abs(segmentStart[1] - segmentEnd[1]) > 0.01;
452 if (!isDiagonal) continue;
453 const viaIndex = Math.min(segmentIndex, flow.via.length - 1);
454 problems.push(`Flow "${flow.label}" has a diagonal segment from (${segmentStart.join(', ')}) to (${segmentEnd.join(', ')}) — align via[${viaIndex}] with its adjacent point by sharing the same x or y coordinate.`);
455 }
456 }
457 }
458 }
460 problems.push(...cleanEndpointSideProblems({
461 relations: dataflow.flows,
462 endpointIds: new Set(nodes.keys()),
463 pathFor,
464 diagramType: 'dataflow',
465 relationCollection: 'flows',
466 fromSideFor: (flow) => flowSides(flow).fromSide,
467 toSideFor: (flow) => flowSides(flow).toSide,
468 routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross node borders perpendicularly',
469 }));
470 problems.push(...cleanFlowProblems({
471 relations: dataflow.flows,
472 obstacles: nodes.values(),
473 pathFor,
474 diagramType: 'dataflow',
475 relationCollection: 'flows',
476 obstacleKind: 'node',
477 routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the node to another stage/row'
478 }));
479 problems.push(...cleanCrossingProblems({
480 relations: dataflow.flows,
481 endpointIds: new Set(nodes.keys()),
482 pathFor,
483 diagramType: 'dataflow',
484 relationCollection: 'flows',
485 profile: dataflow.meta?.quality_profile,
486 crossingResolved: (left, right) => resolvedCrossoverFlows.has(left) && resolvedCrossoverFlows.has(right),
487 routeHint: 'adjust route/via or channelX/channelY so the flows use separate stage corridors'
488 }));
489 problems.push(...cleanAmbiguousCorridorProblems({
490 relations: dataflow.flows,
491 endpointIds: new Set(nodes.keys()),
492 pathFor,
493 diagramType: 'dataflow',
494 relationCollection: 'flows',
495 profile: dataflow.meta?.quality_profile,
496 routeHint: 'adjust route/via or channelX/channelY so unrelated flows do not visually merge'
497 }));
498 problems.push(...cleanBorderRunProblems({
499 relations: dataflow.flows,
500 endpointIds: new Set(nodes.keys()),
501 frames: compositionFrames,
502 pathFor,
503 diagramType: 'dataflow',
504 relationCollection: 'flows',
505 profile: dataflow.meta?.quality_profile,
506 routeHint: 'adjust route/via or channelX/channelY so the flow crosses the stage perpendicularly instead of following its border'
507 }));
508 problems.push(...cleanRouteRhythmProblems({
509 relations: dataflow.flows,
510 endpointIds: new Set(nodes.keys()),
511 pathFor,
512 diagramType: 'dataflow',
513 relationCollection: 'flows',
514 profile: dataflow.meta?.quality_profile,
515 routeHint: 'adjust route/via or channelX/channelY so each turn uses a clear inter-stage corridor'
516 }));
518 const labelRects = resolvedLabelRects;
519 for (const rect of labelRects) {
520 for (const node of nodes.values()) {
521 if (rectsOverlap(rect, node, -2)) {
522 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', viewBox, nodes.values())}`);
523 }
524 }
525 }
526 for (let i = 0; i < labelRects.length; i += 1) {
527 for (let j = i + 1; j < labelRects.length; j += 1) {
528 if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
529 problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
530 }
531 }
532 }
533 problems.push(...cleanLabelRouteClearanceProblems({
534 relations: dataflow.flows,
535 labels: labelRects,
536 endpointIds: new Set(nodes.keys()),
537 pathFor,
538 diagramType: 'dataflow',
539 relationCollection: 'flows',
540 profile: dataflow.meta?.quality_profile,
541 routeHint: 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other flow route/via/channelX/channelY'
542 }));
543 problems.push(...cleanLabelCanvasContainmentProblems({
544 labels: labelRects,
545 viewBox,
546 diagramType: 'dataflow',
547 relationCollection: 'flows',
548 profile: dataflow.meta?.quality_profile,
549 }));
551 const lastStageX = stageX(asArray(dataflow.stages).length - 1);
552 if (lastStageX + layout.stageW / 2 > viewBox[0] - 24) {
553 problems.push(`Stages exceed viewBox width — set meta.viewBox[0] to at least ${Math.ceil(lastStageX + layout.stageW / 2 + 24)}.`);
554 }
556 if (problems.length) {
557 throwDiagnosticProblems('Data-flow layout validation failed', problems, {
558 subject: { diagramType: 'dataflow' },
559 diagnostics,
560 });
561 }
562 }
564 function supportsAutomaticCrossover(flow) {
565 return automaticShowcase && !flow.via && (!flow.route || flow.route === 'auto')
566 && flow.channelX === undefined && flow.channelY === undefined;
567 }
569 function routeVia(flow, from, to, start, end) {
570 if (flow.via) return flow.via;
571 switch (flow.route || 'auto') {
572 case 'straight':
573 return [];
574 case 'vertical-channel': {
575 const x = flow.channelX ?? start[0] + (end[0] > start[0] ? 44 : -44);
576 return [[x, start[1]], [x, end[1]]];
577 }
578 case 'bottom-channel': {
579 const y = flow.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 26;
580 return [[start[0], y], [end[0], y]];
581 }
582 case 'top-channel': {
583 const y = flow.channelY ?? Math.min(from.y, to.y) - 24;
584 return [[start[0], y], [end[0], y]];
585 }
586 case 'auto':
587 default: {
588 const { fromSide, toSide } = flowSides(flow);
589 const fromVertical = fromSide === 'top' || fromSide === 'bottom';
590 const toVertical = toSide === 'top' || toSide === 'bottom';
591 const acrossDelta = fromVertical && toVertical
592 ? Math.abs(start[0] - end[0]) : Math.abs(start[1] - end[1]);
593 if (automaticShowcase && acrossDelta > 0.0001 && acrossDelta < 16) {
594 // Spread ports can differ by only 7px/14px. Reuse the bounded bridge
595 // rather than emit a midpoint turn below our own rhythm floor.
596 const bridge = automaticPortRhythmBridge(start, end, fromSide, toSide, {
597 accept: points => ![...nodes.values()].some(node => (
598 node.id !== from.id && node.id !== to.id && points.slice(1).some((point, index) =>
599 segmentIntersectsRect({ start: points[index], end: point }, node, 2))
600 )),
601 });
602 if (bridge) return bridge.slice(1, -1);
603 }
604 if (fromVertical !== toVertical) {
605 // Perpendicular sides meet at one corner when both endpoints face it;
606 // otherwise each leaves through a short stub before the corner.
607 const outward = { left: [-24, 0], right: [24, 0], top: [0, -24], bottom: [0, 24] };
608 const stub = (point, side) => [point[0] + outward[side][0], point[1] + outward[side][1]];
609 const startStub = stub(start, fromSide);
610 const endStub = stub(end, toSide);
611 const route = [
612 [fromVertical ? [start[0], end[1]] : [end[0], start[1]]],
613 [startStub, [startStub[0], endStub[1]], endStub],
614 [startStub, [endStub[0], startStub[1]], endStub],
615 ].find(candidate => {
616 const points = normalizeRoutePoints([start, ...candidate, end]);
617 return routeHonorsEndpointSides(points, fromSide, toSide)
618 && ![...nodes.values()].some(node => points.slice(1).some((point, index) => (
619 segmentIntersectsRect({ start: points[index], end: point }, node, 2)
620 && !((node.id === from.id && index === 0) || (node.id === to.id && index === points.length - 2)))));
621 });
622 if (route) return route;
623 }
624 if (automaticShowcase && fromVertical && toVertical) {
625 if (Math.abs(start[0] - end[0]) < 0.0001) return [];
626 const midY = (start[1] + end[1]) / 2;
627 return [[start[0], midY], [end[0], midY]];
628 }
629 if (Math.abs(start[1] - end[1]) < 4) return [];
630 const midX = start[0] + (end[0] - start[0]) / 2;
631 // Across several stages the midpoint can fall inside a middle stage's
632 // node; then turn in the clear inter-stage gap nearest to it.
633 const gapXs = asArray(dataflow.stages).slice(1).map((_, index) => (stageX(index) + stageX(index + 1)) / 2)
634 .filter((x) => x > Math.min(start[0], end[0]) && x < Math.max(start[0], end[0]))
635 .sort((left, right) => Math.abs(left - midX) - Math.abs(right - midX));
636 const turnX = [midX, ...gapXs].find((x) => {
637 const points = [start, [x, start[1]], [x, end[1]], end];
638 return ![...nodes.values()].some((node) => node.id !== from.id && node.id !== to.id
639 && points.slice(1).some((point, index) => segmentIntersectsRect({ start: points[index], end: point }, node, 2)));
640 }) ?? midX;
641 return [[turnX, start[1]], [turnX, end[1]]];
642 }
643 }
644 }
646 const pathCache = new Map();
648 function flowSides(flow) {
649 const from = nodes.get(flow.from);
650 const to = nodes.get(flow.to);
651 return {
652 fromSide: chosenSide(flow.fromSide, automaticSourceSides.get(flow) || defaultFromSide(from, to)),
653 toSide: chosenSide(flow.toSide, defaultToSide(from, to)),
654 };
655 }
657 // The shared default of 14px packs two 10px arrowheads on top of each other.
658 // Raise the ceiling so a side that has room spreads them; a short side still
659 // clamps to usable/(n-1) and keeps the previous layout.
660 const automaticPorts = automaticPortSpread(dataflow.flows, nodes, {
661 sideFor: (flow, endpoint) => flowSides(flow)[endpoint === 'source' ? 'fromSide' : 'toSide'],
662 maxSpacing: 18,
663 });
665 function pathFor(flow) {
666 if (pathCache.has(flow)) return pathCache.get(flow);
667 const from = nodes.get(flow.from);
668 const to = nodes.get(flow.to);
669 const ports = automaticPorts.get(flow);
670 const { fromSide, toSide } = flowSides(flow);
671 let start = ports?.from || anchor(from, fromSide);
672 let end = ports?.to || anchor(to, toSide);
673 if (automaticShowcase && flow.labelDx === undefined && flow.labelDy === undefined && flow.labelSegment === undefined) {
674 const aligned = alignAutomaticFacingPorts(flow, from, to, start, end, fromSide, toSide, ports, {
675 accept: points => {
676 if ([...nodes.values()].some(node => node.id !== from.id && node.id !== to.id
677 && segmentIntersectsRect({ start: points[0], end: points[1] }, node, 2))) return false;
678 // A pinned route still occupies its endpoint. Check both ends: on a
679 // short crowded side, keeping the bridge also preserves space for
680 // labels between neighbouring branches.
681 for (const [rect, side, point] of [
682 [from, fromSide, points[0]], [to, toSide, points[1]],
683 ]) {
684 for (const other of asArray(dataflow.flows)) {
685 if (other === flow || !nodes.has(other.from) || !nodes.has(other.to)) continue;
686 for (const field of ['from', 'to']) {
687 if (other[field] !== rect.id || flowSides(other)[field === 'from' ? 'fromSide' : 'toSide'] !== side) continue;
688 const occupied = automaticPorts.get(other)?.[field] || anchor(rect, side);
689 if (Math.hypot(point[0] - occupied[0], point[1] - occupied[1]) < 18 - 0.0001) return false;
690 }
691 }
692 }
693 return true;
694 },
695 });
696 start = aligned.start;
697 end = aligned.end;
698 }
699 // Drop consecutive duplicate points so a purely vertical (or horizontal)
700 // auto-route never emits a zero-length final segment — SVG derives
701 // marker-end orientation from the last segment, and a degenerate segment
702 // leaves the arrowhead angle undefined (see #169).
703 const rawPoints = [start, ...routeVia(flow, from, to, start, end), end];
704 const points = [];
705 for (const p of rawPoints) {
706 const prev = points.at(-1);
707 if (!prev || Math.abs(p[0] - prev[0]) > 0.0001 || Math.abs(p[1] - prev[1]) > 0.0001) {
708 points.push(p);
709 }
710 }
711 // Guard against an all-degenerate route (e.g. start === end): keep both
712 // endpoints so the path is still well-formed even if the marker is hidden.
713 if (points.length < 2) points.push(end);
714 const routed = { d: polylinePath(points), points };
715 pathCache.set(flow, routed);
716 return routed;
717 }
719 const resolvedCrossoverFlows = new Set();
720 const routable = asArray(dataflow.flows).filter(flow => nodes.has(flow.from) && nodes.has(flow.to));
721 const segmentsOf = points => points.slice(1).map((end, index) => ({ start: points[index], end }));
722 const related = (left, right) => [left.from, left.to].some(id => id === right.from || id === right.to);
723 const crosses = (left, right) => segmentsOf(pathFor(left).points).some(a => segmentsOf(pathFor(right).points).some(b => (
724 properSegmentIntersection(a.start, a.end, b.start, b.end))));
725 const unrelated = flow => routable.filter(other => other !== flow && !related(flow, other));
726 const length = points => segmentsOf(points).reduce((sum, { start, end }) => sum + Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]), 0);
727 const DETOUR_NODE_GAP = 12;
728 const ENDPOINT_STUB = 24;
729 function intrudesNode(flow, points) {
730 return [...nodes.values()].some(node => segmentsOf(points).some((segment, index) => (
731 segmentIntersectsRect(segment, node, 2)
732 && !((node.id === flow.from && index === 0) || (node.id === flow.to && index === points.length - 2))
733 )));
734 }
735 function sharesCorridor(flow, points, deferred = new Set()) {
736 return unrelated(flow).some(other => {
737 const occupied = deferred.has(other)
738 ? endpointStubs(other).map(({ start, end }) => [start, end])
739 : [pathFor(other).points];
740 return occupied.some(otherPoints => collectAmbiguousCorridors({
741 routedRelations: [{ relation: flow, points }, { relation: other, points: otherPoints }],
742 }).length > 0);
743 });
744 }
745 function endpointStubs(flow) {
746 const points = pathFor(flow).points;
747 const { fromSide, toSide } = flowSides(flow);
748 const directions = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] };
749 return [[points[0], fromSide], [points.at(-1), toSide]].map(([start, side]) => ({
750 start, end: start.map((value, axis) => value + directions[side][axis] * ENDPOINT_STUB),
751 }));
752 }
753 function crossingFlows(flow, points) {
754 return unrelated(flow).filter(other => segmentsOf(points).some(a => segmentsOf(pathFor(other).points).some(b => (
755 properSegmentIntersection(a.start, a.end, b.start, b.end)))));
756 }
757 function readingCost(flow, points) {
758 return length(points) + Math.max(0, points.length - 2) * 48 + crossingFlows(flow, points).length * 160;
759 }
761 // Preset paths can cross unrelated flows. Prefer a short clear detour, but
762 // give resolved crossings the same finite reading cost as Architecture rather
763 // than forcing a perimeter excursion. Crossings with authored routes still
764 // need a clear detour; only actual automatic/automatic crosses get halos.
765 if (automaticShowcase) {
766 // The stage bottoms are fitted below the final routes and labels later.
767 // Reserve the fixed tops/sides now, extending sides past the search area.
768 const routingBottom = Math.max(viewBox[1], ...[...nodes.values()].map(node => node.y + node.height),
769 ...routable.flatMap(flow => pathFor(flow).points.map(point => point[1]))) + DETOUR_NODE_GAP + ENDPOINT_STUB;
770 const routingFrames = asArray(dataflow.stages).map((stage, index) => ({
771 ...stageFrame(stage, index), height: routingBottom - layout.stageY,
772 }));
773 const stageBorders = routingFrames.flatMap(frame => frameBorderSegments(frame).filter(border => border.side !== 'bottom'));
774 // Check nearby simple corridors before accepting a grid detour. Coordinate
775 // thinning can discard an open two-bend corridor beside an occupied one.
776 // Bound these candidates; the grid still handles routes around obstacles.
777 function compactCorridors(flow) {
778 const { fromSide, toSide } = flowSides(flow);
779 const axis = fromSide === 'right' && toSide === 'left' ? 0
780 : fromSide === 'left' && toSide === 'right' ? 0
781 : fromSide === 'bottom' && toSide === 'top' ? 1
782 : fromSide === 'top' && toSide === 'bottom' ? 1 : -1;
783 if (axis < 0) return [];
784 const [start, end] = [pathFor(flow).points[0], pathFor(flow).points.at(-1)];
785 const low = Math.min(start[axis], end[axis]) + ENDPOINT_STUB;
786 const high = Math.max(start[axis], end[axis]) - ENDPOINT_STUB;
787 if (low > high) return [];
788 const middle = (low + high) / 2;
789 const coordinates = new Set([low, middle, high]);
790 const others = routable.filter(other => other !== flow);
791 for (const other of others) {
792 for (const segment of segmentsOf(pathFor(other).points)) {
793 if (segment.start[axis] === segment.end[axis]) {
794 coordinates.add(segment.start[axis] - 8);
795 coordinates.add(segment.start[axis] + 8);
796 }
797 }
798 }
799 return [...coordinates].filter(value => value >= low && value <= high)
800 .sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle)).slice(0, 16)
801 .map(value => {
802 const a = [...start], b = [...end];
803 a[axis] = b[axis] = value;
804 return normalizeRoutePoints([start, a, b, end]);
805 }).filter(points => routeHonorsEndpointSides(points, fromSide, toSide)
806 && !intrudesNode(flow, points)
807 && !collectRouteRhythmIssues({ routedRelations: [{ points }] }).length
808 && !collectBorderRuns({ routedRelations: [{ points }], frames: routingFrames })
809 .some(hit => hit.side !== 'bottom')
810 && crossingFlows(flow, points).every(supportsAutomaticCrossover)
811 && !others.some(other => collectAmbiguousCorridors({
812 routedRelations: [{ relation: flow, points }, { relation: other, points: pathFor(other).points }],
813 includeSharedEndpoints: () => true,
814 }).length));
815 }
816 function gridDetour(flow, allowAvoidedCrossings = false, deferred = new Set()) {
817 const { points } = pathFor(flow);
818 const { fromSide, toSide } = flowSides(flow);
819 const others = unrelated(flow);
820 const options = {
821 start: points[0],
822 end: points.at(-1),
823 points: [points[0], points.at(-1)],
824 // Keep a detour a readable gap away from the nodes it passes; only its
825 // own endpoints are held to the grid's 2-unit clearance.
826 obstacles: [...nodes.values()].map(node => (node.id === flow.from || node.id === flow.to ? node : {
827 x: node.x - DETOUR_NODE_GAP, y: node.y - DETOUR_NODE_GAP,
828 width: node.width + DETOUR_NODE_GAP * 2, height: node.height + DETOUR_NODE_GAP * 2,
829 })),
830 fromSide,
831 toSide,
832 clearance: 2,
833 maximumObstacleCount: 80,
834 endpointStubPx: ENDPOINT_STUB,
835 maximumGridNodes: 4096,
836 avoidedSegments: others.flatMap(other => deferred.has(other) ? endpointStubs(other) : segmentsOf(pathFor(other).points)),
837 allowAvoidedCrossings,
838 minimumAvoidedOverlapPx: 8,
839 routeSeparationPx: 8,
840 minimumSegmentPx: 16,
841 bendPenaltyPx: 48,
842 };
843 let detour = shortestOrthogonalGridRoute(options);
844 // Frame constraints add coordinates to the grid. Keep an already-clear
845 // route stable; only retry if this route actually borrows a frame side.
846 if (detour && collectBorderRuns({ routedRelations: [{ points: detour.points }], frames: routingFrames })
847 .some(hit => hit.side !== 'bottom')) {
848 detour = shortestOrthogonalGridRoute({ ...options, borderSegments: stageBorders });
849 }
850 // The grid preserves both endpoint coordinates, even when their axes
851 // are closer than its coalescing distance. Check the resulting route
852 // before a cheaper micro jog can replace a readable port bridge.
853 const normalized = detour && normalizeRoutePoints(detour.points);
854 return normalized && !collectRouteRhythmIssues({ routedRelations: [{ points: normalized }] }).length
855 ? normalized : null;
856 }
857 function detourFor(flow) {
858 const { points } = pathFor(flow);
859 const normalized = gridDetour(flow);
860 if (!normalized) return null;
861 // Reuse Architecture's finite reading cost: 48px per bend, 160px per
862 // resolved crossing. A small clear detour still wins over a crossover.
863 const currentCost = readingCost(flow, points);
864 const detourCost = length(normalized) + Math.max(0, normalized.length - 2) * 48;
865 // A halo resolves an edge/edge crossing, never an edge through a node.
866 // Keep the clear detour even when the obstructed preset costs less.
867 const canResolve = supportsAutomaticCrossover(flow) && crossingFlows(flow, points).every(supportsAutomaticCrossover)
868 && !sharesCorridor(flow, points) && !intrudesNode(flow, points);
869 if (canResolve && detourCost >= currentCost) return null;
870 return { flow, routed: { d: polylinePath(normalized), points: normalized }, cost: detourCost - currentCost };
871 }
872 // A skipped stage/row or shared corridor can obstruct a preset without a
873 // proper crossing. Repair those routes first; otherwise the crossing pass either
874 // misses them or treats every neighbouring edge as an impenetrable wall.
875 // Both bounded searches keep node clearance and separate corridors. Only
876 // automatic/automatic crosses can use the shorter, halo-resolved option.
877 // Repairing a later preset can free a corridor for an earlier one. An
878 // already-obstructed automatic preset is not a settled corridor: two such
879 // presets can cover each other's endpoint stubs. Defer those presets while
880 // repairing one route, but reserve their endpoint stubs so they can still
881 // leave and enter their nodes. Plan the others around that repaired route.
882 // Supplied routes and routes already clear of nodes remain obstacles.
883 // Node intrusions only decrease; stop as soon as no route improves. Final
884 // validation still checks every route, including any unrepaired preset.
885 for (let pass = 0; pass < routable.length; pass += 1) {
886 let repaired = false;
887 for (const flow of routable) {
888 if (!supportsAutomaticCrossover(flow)) continue;
889 const current = pathFor(flow).points;
890 if (!intrudesNode(flow, current) && !sharesCorridor(flow, current)) continue;
891 const { fromSide, toSide } = flowSides(flow);
892 const candidatesFor = deferred => [gridDetour(flow, false, deferred), gridDetour(flow, true, deferred)].filter(points => points
893 && routeHonorsEndpointSides(points, fromSide, toSide) && !intrudesNode(flow, points)
894 && !sharesCorridor(flow, points, deferred) && crossingFlows(flow, points).every(supportsAutomaticCrossover));
895 let candidates = candidatesFor(new Set());
896 if (!candidates.length) {
897 const deferred = new Set(unrelated(flow).filter(other => supportsAutomaticCrossover(other)
898 && intrudesNode(other, pathFor(other).points)));
899 if (deferred.size) candidates = candidatesFor(deferred);
900 }
901 candidates.push(...compactCorridors(flow));
902 candidates.sort((left, right) => readingCost(flow, left) - readingCost(flow, right));
903 if (candidates.length && JSON.stringify(candidates[0]) !== JSON.stringify(current)) {
904 pathCache.set(flow, { d: polylinePath(candidates[0]), points: candidates[0] });
905 repaired = true;
906 }
907 }
908 if (!repaired) break;
909 }
910 // Resolve the most useful clear detour first. Remaining automatic crosses
911 // get explicit halos; supplied geometry retains its crossing diagnostic.
912 for (let pass = 0; pass < routable.length; pass += 1) {
913 const crossing = routable.filter(flow => supportsAutomaticCrossover(flow) && unrelated(flow).some(other => crosses(flow, other)));
914 const options = crossing.map(detourFor).filter(Boolean).sort((left, right) => left.cost - right.cost);
915 const before = routable.filter(flow => unrelated(flow).some(other => crosses(flow, other))).length;
916 const chosen = options.find(({ flow, routed }) => {
917 const previous = pathCache.get(flow);
918 pathCache.set(flow, routed);
919 const after = routable.filter(other => unrelated(other).some(next => crosses(other, next))).length;
920 if (after < before) return true;
921 pathCache.set(flow, previous);
922 return false;
923 });
924 if (!chosen) break;
925 }
926 }
928 const intersectionCount = (points, otherPoints) => segmentsOf(points).reduce((count, a) => count
929 + segmentsOf(otherPoints).filter(b => properSegmentIntersection(a.start, a.end, b.start, b.end)).length, 0);
930 const foldsBack = points => {
931 const segments = segmentsOf(points);
932 return segments.some((segment, index) => segments.slice(index + 2).some(other => segmentIntersectsRect(segment, {
933 x: Math.min(other.start[0], other.end[0]), y: Math.min(other.start[1], other.end[1]),
934 width: Math.abs(other.end[0] - other.start[0]), height: Math.abs(other.end[1] - other.start[1]),
935 })));
936 };
938 // Route detours can reverse the direction used to sort a shared node's ports.
939 // Reorder the existing slots by the first turn of the settled route, only when
940 // the whole automatic group becomes shorter without adding any intersection.
941 if (automaticShowcase) {
942 const portFrames = asArray(dataflow.stages).map(stageFrame);
943 const groups = new Map();
944 for (const flow of routable) for (const field of ['from', 'to']) {
945 const side = flowSides(flow)[field === 'from' ? 'fromSide' : 'toSide'];
946 const key = `${flow[field]}\0${side}`;
947 if (!groups.has(key)) groups.set(key, []);
948 groups.get(key).push({ flow, field, side });
949 }
950 const pinned = flow => !supportsAutomaticCrossover(flow)
951 || ['fromSide', 'toSide', 'labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => flow[key] !== undefined);
952 let probes = 0;
953 for (const group of groups.values()) {
954 if (probes >= 16) break;
955 if (group.length < 2 || group.length > 8
956 || group.some(({ flow, field }) => pinned(flow) || !automaticPorts.get(flow)?.[field])) continue;
957 const axis = ['left', 'right'].includes(group[0].side) ? 1 : 0;
958 const currentRoutes = group.map(item => ({ ...item, current: normalizeRoutePoints(pathFor(item.flow).points) }));
959 if (currentRoutes.some(({ current }) => current.length < 2
960 || current.some(point => !point.every(Number.isFinite)))) continue;
961 const items = currentRoutes.map(({ current, ...item }) => {
962 const outward = item.field === 'from' ? current : [...current].reverse();
963 return { ...item, current, outward, coordinate: outward[0][axis],
964 turn: outward.find(point => Math.abs(point[axis] - outward[0][axis]) > 0.0001)?.[axis] ?? outward[0][axis] };
965 });
966 const slots = items.map(item => item.coordinate).sort((a, b) => a - b);
967 const ordered = [...items].sort((a, b) => a.turn - b.turn || a.coordinate - b.coordinate);
968 if (ordered.every((item, index) => item.coordinate === slots[index])) continue;
969 probes++;
970 const proposal = new Map();
971 let straightened = false;
972 for (const [index, item] of ordered.entries()) {
973 const outward = item.outward.map(point => [...point]);
974 outward[0][axis] = slots[index];
975 if (outward.length > 2) outward[1][axis] = slots[index];
976 let points = item.field === 'from' ? outward : outward.reverse();
977 const { fromSide, toSide } = flowSides(item.flow);
978 const direct = [points[0], points.at(-1)];
979 if (routeHonorsEndpointSides(direct, fromSide, toSide)
980 && (direct[0][0] === direct[1][0] || direct[0][1] === direct[1][1])) points = direct;
981 points = normalizeRoutePoints(points);
982 if (points.length === 2 && item.current.length > 2) straightened = true;
983 proposal.set(item.flow, points);
984 }
985 if (!straightened || items.some(({ flow, current }) => {
986 const points = proposal.get(flow), { fromSide, toSide } = flowSides(flow);
987 return points.length > current.length || length(points) > length(current) + 0.0001 || foldsBack(points)
988 || !routeHonorsEndpointSides(points, fromSide, toSide) || intrudesNode(flow, points)
989 || collectRouteRhythmIssues({ routedRelations: [{ points }] }).length
990 || collectBorderRuns({ routedRelations: [{ points }], frames: portFrames }).length
991 || points.some(([x, y]) => x < 0 || y < 0 || x > viewBox[0] || y > viewBox[1] - layout.stageBottomPad)
992 || routable.some(other => other !== flow && (
993 intersectionCount(points, proposal.get(other) || pathFor(other).points)
994 > intersectionCount(current, pathFor(other).points)
995 || collectAmbiguousCorridors({
996 routedRelations: [{ relation: flow, points }, { relation: other, points: proposal.get(other) || pathFor(other).points }],
997 includeSharedEndpoints: () => true,
998 }).length));
999 })) continue;
1000 for (const { flow, field } of items) {
1001 const points = proposal.get(flow);
1002 pathCache.set(flow, { points, d: polylinePath(points) });
1003 automaticPorts.get(flow)[field] = field === 'from' ? points[0] : points.at(-1);
1008 // The legacy two-point anchor is relative to the source's y coordinate.
1009 // On an automatic vertical flow that places the label inside the source.
1010 // Start in the inter-node corridor; explicit offsets retain their old anchor.
1011 function labelPoint(flow, points) {
1012 if (automaticShowcase && !flow.classification && points.length === 2 && points[0][0] === points[1][0]
1013 && !['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => flow[key] !== undefined)) {
1014 const anchor = [points[0][0], (points[0][1] + points[1][1]) / 2 - 10];
1015 const { width, height } = flowLabelSize(flow);
1016 const rect = { x: anchor[0] - width / 2, y: anchor[1] - 11, width, height };
1017 // Sibling flows share a fixed source-side corridor. Keep the old search
1018 // anchor if a sibling occupies the midpoint; interior routes from other
1019 // nodes can still move during the later label/corridor repair.
1020 if (routable.every(other => other === flow || other.from !== flow.from || segmentsOf(pathFor(other).points)
1021 .every(segment => segmentRectClearanceWithin(segment, rect, 4) + 0.0001 >= 4))) return anchor;
1023 return legacyLabelPoint(flow, points);
1026 function extraLabelLineMasksRoute(rect, points) {
1027 if (rect.height <= layout.labelH) return false;
1028 const segments = segmentsOf(points);
1029 return segments.some(segment => segmentIntersectsRect(segment, rect))
1030 && !segments.some(segment => segmentIntersectsRect(segment, { ...rect, height: layout.labelH }));
1033 const resolvedLabelPoints = new Map();
1034 const twoLineOcclusions = new Set();
1035 const initialLabelRects = asArray(dataflow.flows).flatMap((flow, relationIndex) => {
1036 if (!flow.label || !nodes.has(flow.from) || !nodes.has(flow.to)) return [];
1037 const [lx, ly] = labelPoint(flow, pathFor(flow).points);
1038 const { width, height } = flowLabelSize(flow);
1039 // The shared placer uses a 10px baseline inset; Dataflow masks use 11px.
1040 // Adapt the baseline so it tests exactly the rectangle we later render.
1041 const rect = { relation: flow, relationIndex, label: flow.label,
1042 x: lx - width / 2, y: ly - 11, width, height, lx, ly: ly - 1 };
1043 // Only repair the additional footprint of an unpinned second line. A
1044 // single-line plate may intentionally interrupt its own route, especially
1045 // on a vertical segment; that established placement remains authoritative.
1046 if (flow.classification && !['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => flow[key] !== undefined)) {
1047 if (extraLabelLineMasksRoute(rect, pathFor(flow).points)) {
1048 twoLineOcclusions.add(flow);
1051 return [rect];
1052 });
1053 // Routing depends on node geometry and canvas width, so its actual footprint
1054 // can determine height without another copy of routeVia's preset rules.
1055 function heightForLabels(labels) {
1056 const geometryBottom = Math.max(0,
1057 ...[...nodes.values()].map(node => node.y + node.height),
1058 ...routable.flatMap(flow => pathFor(flow).points.map(point => point[1])));
1059 const contentBottom = Math.max(geometryBottom, ...labels.map(rect => rect.y + rect.height));
1060 return Math.max(360, Math.ceil(contentBottom + 24 + layout.stageBottomPad));
1062 function fitAutomaticWidthToLabels(labels) {
1063 if (dataflow.meta?.viewBox) return;
1064 const labelRight = Math.max(0, ...labels.map(rect => rect.x + rect.width));
1065 viewBox[0] = Math.max(viewBox[0], Math.ceil(labelRight + 24));
1067 // Give the shared placer room for complete initial plates before its search;
1068 // width growth here does not recompute the measured nodes or cached routes.
1069 fitAutomaticWidthToLabels(initialLabelRects);
1070 if (automaticShowcase) viewBox[1] = heightForLabels(initialLabelRects)
1071 + Math.max(0, ...initialLabelRects.map(rect => rect.height - layout.labelH));
1072 const compositionFrames = asArray(dataflow.stages).map(stageFrame);
1074 const resolvedLabelRects = (automaticShowcase ? placeAutomaticLabels({
1075 labels: initialLabelRects,
1076 routes: asArray(dataflow.flows).flatMap((flow, relationIndex) => (
1077 nodes.has(flow.from) && nodes.has(flow.to)
1078 ? [{ relationIndex, points: pathFor(flow).points }] : []
1079 )),
1080 components: [...nodes.values()],
1081 titles: compositionFrames.map(frame => ({ ...frame, height: layout.stageH })),
1082 viewBox,
1083 placementBottom: viewBox[1] - layout.stageBottomPad,
1084 maxOwnRouteDistance: 36,
1085 }) : initialLabelRects).map(rect => {
1086 // Dataflow has compact 16px plates. Permit one clearance gutter beyond
1087 // two plate heights (36px), which covers a label immediately above a node,
1088 // while rejecting a collision-free island far from the labelled route.
1089 const ownPoints = pathFor(rect.relation).points;
1090 const nearby = ownPoints.slice(1).some((point, index) =>
1091 segmentRectClearanceWithin({ start: ownPoints[index], end: point }, rect, 36) <= 36);
1092 const original = initialLabelRects.find(label => label.relation === rect.relation);
1093 const kept = nearby ? rect : original;
1094 const resolved = { ...kept, ly: kept.ly + 1 };
1095 if (automaticShowcase) resolvedLabelPoints.set(rect.relation, [resolved.lx, resolved.ly]);
1096 return resolved;
1097 });
1099 // The ordinary shared placer allows a plate to interrupt its own connector.
1100 // For the second-line defect above, keep the complete plate beside the route
1101 // instead. This local search freezes all other labels and authored geometry,
1102 // and uses the same node/title/label/other-route checks as ordinary placement.
1103 for (const [index, rect] of resolvedLabelRects.entries()) {
1104 if (!twoLineOcclusions.has(rect.relation)) continue;
1105 const ownPoints = pathFor(rect.relation).points;
1106 if (!ownPoints.slice(1).some((end, segmentIndex) => segmentIntersectsRect({ start: ownPoints[segmentIndex], end }, rect))) continue;
1107 const original = initialLabelRects.find(label => label.relation === rect.relation);
1108 const extraHeight = original.height - layout.labelH;
1109 const preferred = { ...original, y: original.y - extraHeight, ly: original.ly - extraHeight };
1110 const ownRouteBands = ownPoints.slice(1).map((end, segmentIndex) => {
1111 const start = ownPoints[segmentIndex];
1112 return {
1113 x: Math.min(start[0], end[0]) - 2, y: Math.min(start[1], end[1]) - 2,
1114 width: Math.abs(end[0] - start[0]) + 4, height: Math.abs(end[1] - start[1]) + 4,
1115 };
1116 });
1117 const labels = resolvedLabelRects.map((other, otherIndex) => otherIndex === index ? preferred : {
1118 ...other, ly: other.ly - 1,
1119 relation: { ...other.relation, labelAt: [other.lx, other.ly] },
1120 });
1121 const routes = asArray(dataflow.flows).flatMap((flow, relationIndex) => (
1122 nodes.has(flow.from) && nodes.has(flow.to) ? [{ relationIndex, points: pathFor(flow).points }] : []
1123 ));
1124 const titles = compositionFrames.map(frame => ({ ...frame, height: layout.stageH }));
1125 const replacement = placeAutomaticLabels({
1126 labels, routes,
1127 components: [...nodes.values(), ...ownRouteBands],
1128 titles,
1129 viewBox,
1130 placementBottom: viewBox[1] - layout.stageBottomPad,
1131 keepFallbackNearRoute: true,
1132 maxOwnRouteDistance: 36,
1133 })[index];
1134 // An exhausted shared search returns its supplied preferred rect. That is
1135 // not proof of a safe replacement: preserve the previously accepted label
1136 // if the complete footprint cannot fit near its connector without collisions.
1137 const clear = replacement.x >= 0 && replacement.y >= 0
1138 && replacement.x + replacement.width <= viewBox[0]
1139 && replacement.y + replacement.height <= viewBox[1] - layout.stageBottomPad
1140 && ![...nodes.values(), ...titles].some(obstacle => rectsOverlap(replacement, obstacle, 2))
1141 && !resolvedLabelRects.some((other, otherIndex) => otherIndex !== index && rectsOverlap(replacement, other, 2))
1142 && routes.every(route => route.points.slice(1).every((end, segmentIndex) => (
1143 segmentRectClearanceWithin({ start: route.points[segmentIndex], end }, replacement, 4) >= 4
1144 )))
1145 && ownPoints.slice(1).some((end, segmentIndex) => (
1146 segmentRectClearanceWithin({ start: ownPoints[segmentIndex], end }, replacement, 36) <= 36
1147 ));
1148 if (!clear) continue;
1149 const resolved = { ...replacement, ly: replacement.ly + 1 };
1150 resolvedLabelRects[index] = resolved;
1151 resolvedLabelPoints.set(rect.relation, [resolved.lx, resolved.ly]);
1154 // Labels may occupy the only useful part of an inter-stage corridor after
1155 // ordinary routing has finished. Try shifting one interior segment past the
1156 // actual plate, then re-place only the labels that this move obstructs. Valid
1157 // labels keep their positions. Endpoint geometry and authored controls remain
1158 // authoritative, and every accepted route clears all final foreign labels.
1159 if (automaticShowcase) {
1160 const titles = compositionFrames.map(frame => ({ ...frame, height: layout.stageH }));
1161 const labelGap = 4;
1162 // Bound the complete candidate checks, including rejected geometry, across
1163 // the diagram. Each accepted move clears one route's label collisions.
1164 let attempts = 0;
1165 const nearOwnRoute = (rect, points) => segmentsOf(points)
1166 .some(segment => segmentRectClearanceWithin(segment, rect, 36) <= 36);
1167 const masksLabel = (points, rect) => segmentsOf(points)
1168 .some(segment => segmentRectClearanceWithin(segment, rect, labelGap) + 0.0001 < labelGap);
1169 const overlapsNode = rect => [...nodes.values()].some(node => rectsOverlap(rect, node, -2));
1170 // A label left inside a node does not mask the route occupying its usable
1171 // slot. Probe a nearby node-clear slot with the other labels fixed. These
1172 // are search targets only: accept a corridor move after placing every label
1173 // against the real routes, including resolving the original node overlap.
1174 let slotProbes = 0;
1175 const blockedLabelSlots = resolvedLabelRects.flatMap((rect, index) => {
1176 if (slotProbes >= 16 || ['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => rect.relation[key] !== undefined)
1177 || !overlapsNode(rect)) return [];
1178 slotProbes++;
1179 const labels = resolvedLabelRects.map((other, otherIndex) => ({ ...other, ly: other.ly - 1,
1180 relation: otherIndex === index ? other.relation : { ...other.relation, labelAt: [other.lx, other.ly] } }));
1181 const probe = placeAutomaticLabels({ labels,
1182 routes: [{ relationIndex: rect.relationIndex, points: pathFor(rect.relation).points }],
1183 components: [...nodes.values()], titles, viewBox,
1184 placementBottom: viewBox[1] - layout.stageBottomPad, maxOwnRouteDistance: 36 })[index];
1185 return probe !== labels[index] ? [probe] : [];
1186 });
1187 for (const flow of routable) {
1188 if (attempts >= 16) break;
1189 if (!supportsAutomaticCrossover(flow)
1190 || ['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => flow[key] !== undefined)) continue;
1191 const current = normalizeRoutePoints(pathFor(flow).points);
1192 const pendingSlots = blockedLabelSlots.filter(slot => overlapsNode(
1193 resolvedLabelRects.find(rect => rect.relation === slot.relation)));
1194 const blocked = [...resolvedLabelRects, ...pendingSlots]
1195 .filter(rect => rect.relation !== flow && masksLabel(current, rect));
1196 if (!blocked.length) continue;
1197 const candidates = new Map();
1198 for (const rect of blocked) {
1199 for (let index = 1; index < current.length - 2; index++) {
1200 const [start, end] = [current[index], current[index + 1]];
1201 if (segmentRectClearanceWithin({ start, end }, rect, labelGap) + 0.0001 >= labelGap) continue;
1202 const axis = start[0] === end[0] ? 0 : start[1] === end[1] ? 1 : -1;
1203 if (axis === -1) continue;
1204 const [low, size] = axis === 0 ? [rect.x, rect.width] : [rect.y, rect.height];
1205 for (const coordinate of [low - labelGap, low + size + labelGap]) {
1206 const points = current.map(point => [...point]);
1207 points[index][axis] = points[index + 1][axis] = coordinate;
1208 // Do not reverse either neighbouring segment into a doubled-back
1209 // path. A zero-length neighbour can be removed by normalization.
1210 if ([index - 1, index + 1].some(k => [0, 1].reduce((dot, axis) => dot
1211 + (current[k + 1][axis] - current[k][axis]) * (points[k + 1][axis] - points[k][axis]), 0) < 0)) continue;
1212 const normalized = normalizeRoutePoints(points);
1213 candidates.set(JSON.stringify(normalized), normalized);
1217 const { fromSide, toSide } = flowSides(flow);
1218 const oldCrossings = new Map(unrelated(flow).map(other => [other, intersectionCount(current, pathFor(other).points)]));
1219 const oldCost = readingCost(flow, current);
1220 const ranked = [...candidates.values()].map(points => ({ points, cost: readingCost(flow, points) }))
1221 .sort((a, b) => a.cost - b.cost);
1222 for (const { points, cost } of ranked) {
1223 if (attempts >= 16) break;
1224 attempts++;
1225 // At most two bend-costs of extra distance; never add bends, crossings,
1226 // self-overlaps, shared corridors or a new route through a node.
1227 if (points.length > current.length || cost > oldCost + 96 || foldsBack(points)
1228 || !routeHonorsEndpointSides(points, fromSide, toSide)
1229 || points.some(([x, y]) => x < 0 || y < 0 || x > viewBox[0] || y > viewBox[1] - layout.stageBottomPad)
1230 || intrudesNode(flow, points) || sharesCorridor(flow, points)
1231 || collectRouteRhythmIssues({ routedRelations: [{ points }] }).length
1232 || collectBorderRuns({ routedRelations: [{ points }], frames: compositionFrames }).length
1233 || [...oldCrossings].some(([other, count]) => {
1234 const nextCount = intersectionCount(points, pathFor(other).points);
1235 return nextCount > count || nextCount > 0 && !supportsAutomaticCrossover(other);
1236 })) continue;
1237 const labels = resolvedLabelRects.map(rect => ({ ...rect, ly: rect.ly - 1 }));
1238 const routes = asArray(dataflow.flows).flatMap((other, relationIndex) => (
1239 nodes.has(other.from) && nodes.has(other.to)
1240 ? [{ relationIndex, points: other === flow ? points : pathFor(other).points }] : []
1241 ));
1242 const placed = placeAutomaticLabels({ labels, routes, components: [...nodes.values()], titles,
1243 viewBox, placementBottom: viewBox[1] - layout.stageBottomPad, maxOwnRouteDistance: 36 });
1244 if (placed.some(rect => rect.relation !== flow && masksLabel(points, rect))) continue;
1245 if (blocked.some(slot => pendingSlots.includes(slot) && overlapsNode(
1246 placed.find(rect => rect.relation === slot.relation)))) continue;
1247 if (placed.some((rect, index) => {
1248 const oldPoints = pathFor(rect.relation).points;
1249 const ownPoints = rect.relation === flow ? points : oldPoints;
1250 return ((rect.relation === flow || rect !== labels[index]) && !nearOwnRoute(rect, ownPoints))
1251 || (extraLabelLineMasksRoute(rect, ownPoints) && !extraLabelLineMasksRoute(labels[index], oldPoints));
1252 })) continue;
1253 pathCache.set(flow, { points, d: polylinePath(points) });
1254 for (const [index, rect] of placed.entries()) {
1255 const resolved = { ...rect, ly: rect.ly + 1 };
1256 resolvedLabelRects[index] = resolved;
1257 resolvedLabelPoints.set(rect.relation, [resolved.lx, resolved.ly]);
1259 break;
1262 // Once labels and routes are settled, align only the free endpoint of a
1263 // short port bridge. Keep other routes and labels fixed so a local straight
1264 // line cannot trigger a longer detour or hide a neighbouring label.
1265 let alignmentProbes = 0;
1266 for (const flow of routable) {
1267 if (alignmentProbes >= 16) break;
1268 if (!supportsAutomaticCrossover(flow)
1269 || ['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => flow[key] !== undefined)) continue;
1270 const current = normalizeRoutePoints(pathFor(flow).points);
1271 if (current.length <= 2) continue;
1272 const index = resolvedLabelRects.findIndex(rect => rect.relation === flow);
1273 if (index < 0) continue;
1274 const from = nodes.get(flow.from), to = nodes.get(flow.to);
1275 const { fromSide, toSide } = flowSides(flow);
1276 let placedLabel;
1277 const aligned = alignAutomaticFacingPorts(flow, from, to, current[0], current.at(-1), fromSide, toSide, automaticPorts.get(flow), {
1278 accept: points => {
1279 if (alignmentProbes >= 16 || intrudesNode(flow, points)
1280 || unrelated(flow).some(other => intersectionCount(points, pathFor(other).points)
1281 > intersectionCount(current, pathFor(other).points))
1282 || resolvedLabelRects.some(rect => rect.relation !== flow && masksLabel(points, rect))) return false;
1283 for (const [node, side, point, original] of [
1284 [from, fromSide, points[0], current[0]], [to, toSide, points[1], current.at(-1)],
1285 ]) {
1286 if (Math.hypot(point[0] - original[0], point[1] - original[1]) < 0.0001) continue;
1287 for (const other of routable) for (const field of ['from', 'to']) {
1288 if (other === flow || other[field] !== node.id || flowSides(other)[field === 'from' ? 'fromSide' : 'toSide'] !== side) continue;
1289 const occupied = field === 'from' ? pathFor(other).points[0] : pathFor(other).points.at(-1);
1290 if (Math.hypot(point[0] - occupied[0], point[1] - occupied[1]) < 18 - 0.0001) return false;
1293 if (routable.some(other => other !== flow && collectAmbiguousCorridors({
1294 routedRelations: [{ relation: flow, points }, { relation: other, points: pathFor(other).points }],
1295 includeSharedEndpoints: () => true,
1296 }).length)) return false;
1297 alignmentProbes++;
1298 const [lx, ly] = labelPoint(flow, points), rect = resolvedLabelRects[index];
1299 const labels = resolvedLabelRects.map((other, i) => i === index
1300 ? { ...other, x: lx - other.width / 2, y: ly - 11, lx, ly: ly - 1 }
1301 : { ...other, ly: other.ly - 1, relation: { ...other.relation, labelAt: [other.lx, other.ly] } });
1302 const routes = routable.map(other => ({ relationIndex: asArray(dataflow.flows).indexOf(other), points: other === flow ? points : pathFor(other).points }));
1303 const candidate = placeAutomaticLabels({ labels, routes, components: [...nodes.values()], titles,
1304 viewBox, placementBottom: viewBox[1] - layout.stageBottomPad, maxOwnRouteDistance: 36 })[index];
1305 if (candidate.x < 0 || candidate.y < 0 || candidate.x + candidate.width > viewBox[0]
1306 || candidate.y + candidate.height > viewBox[1] - layout.stageBottomPad
1307 || [...nodes.values(), ...titles].some(obstacle => rectsOverlap(candidate, obstacle, 2))
1308 || resolvedLabelRects.some((other, i) => i !== index && rectsOverlap(candidate, other, 2))
1309 || routes.some(route => route.relationIndex !== rect.relationIndex && masksLabel(route.points, candidate))
1310 || !nearOwnRoute(candidate, points)
1311 || (extraLabelLineMasksRoute(candidate, points) && !extraLabelLineMasksRoute(rect, current))) return false;
1312 placedLabel = { ...candidate, ly: candidate.ly + 1 };
1313 return true;
1314 },
1315 });
1316 if (!placedLabel) continue;
1317 const points = [aligned.start, aligned.end];
1318 pathCache.set(flow, { points, d: polylinePath(points) });
1319 resolvedLabelRects[index] = placedLabel;
1320 resolvedLabelPoints.set(flow, [placedLabel.lx, placedLabel.ly]);
1322 for (const [index, left] of routable.entries()) {
1323 for (const right of routable.slice(index + 1)) {
1324 if (!related(left, right) && supportsAutomaticCrossover(left) && supportsAutomaticCrossover(right) && crosses(left, right)) {
1325 resolvedCrossoverFlows.add(left);
1326 resolvedCrossoverFlows.add(right);
1332 // Only after routes settle, try two complete rows for an unresolved wide
1333 // label. Keep the plate immediately beside its own arrow, with every other
1334 // route and label fixed; an empty island above a node is not a useful slot.
1335 if (automaticShowcase) {
1336 const wrapped = wrapBlockedFlowLabels({ labels: resolvedLabelRects,
1337 routes: routable.map(flow => ({ relationIndex: asArray(dataflow.flows).indexOf(flow), points: pathFor(flow).points })),
1338 components: [...nodes.values()], titles: compositionFrames.map(frame => ({ ...frame, height: layout.stageH })),
1339 viewBox, placementBottom: viewBox[1] - layout.stageBottomPad });
1340 for (const [index, rect] of wrapped.entries()) {
1341 if (rect === resolvedLabelRects[index]) continue;
1342 resolvedLabelRects[index] = rect;
1343 resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
1347 // A shared placement search may retain an unpinned original plate when none
1348 // of its candidates fit the compact width. Include every final rendered plate
1349 // without moving nodes, routes or labels; an authored canvas remains fixed.
1350 fitAutomaticWidthToLabels(resolvedLabelRects);
1351 // A bounded label move may also extend below the initial route footprint.
1352 if (automaticShowcase) {
1353 viewBox[1] = heightForLabels(resolvedLabelRects);
1354 for (const frame of compositionFrames) frame.height = viewBox[1] - layout.stageY - layout.stageBottomPad;
1357 // Resolve a remaining label and its occupied corridors as one proposal.
1358 // Earlier valid placements and authored geometry remain authoritative.
1359 if (automaticShowcase) {
1360 const proposal = repairLabelRoutes({ labels: resolvedLabelRects,
1361 routes: routable.map(flow => ({ relation: flow, relationIndex: dataflow.flows.indexOf(flow), points: pathFor(flow).points })),
1362 nodes: [...nodes.values()], titles: compositionFrames.map(frame => ({ ...frame, height: layout.stageH })),
1363 frames: compositionFrames, viewBox, placementBottom: viewBox[1] - layout.stageBottomPad,
1364 sideFor: flowSides });
1365 if (proposal.labels !== resolvedLabelRects) {
1366 for (const route of proposal.routes) pathCache.set(route.relation, { points: route.points, d: polylinePath(route.points) });
1367 for (const [index, rect] of proposal.labels.entries()) {
1368 resolvedLabelRects[index] = rect;
1369 resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
1371 viewBox[1] = heightForLabels(resolvedLabelRects);
1372 for (const frame of compositionFrames) frame.height = viewBox[1] - layout.stageY - layout.stageBottomPad;
1373 resolvedCrossoverFlows.clear();
1374 for (const [index, left] of routable.entries()) for (const right of routable.slice(index + 1)) {
1375 if (!related(left, right) && supportsAutomaticCrossover(left) && supportsAutomaticCrossover(right) && crosses(left, right)) {
1376 resolvedCrossoverFlows.add(left); resolvedCrossoverFlows.add(right);
1382 // Header measurement follows 276970789's #257, including the ordinal.
1383 // Long titles wrap at the same legible floor instead of becoming invalid input.
1384 function stageHeaderText(stage, index) {
1385 return `${String(index + 1).padStart(2, '0')} / ${stage.label}`;
1388 function renderStageHeader(stage, index, cx) {
1389 const text = stageHeaderText(stage, index);
1390 const font = fittedNodeFontSize(text, layout.stageW, 9, 7);
1391 const available = availableNodeTextWidth(layout.stageW);
1392 const open = `<text x="${cx}" y="${layout.stageY + 22}" class="t-dim" font-size="${font}" font-weight="600" text-anchor="middle">`;
1393 if (minimumNodeTextWidth(text, font) <= available) return `${open}${esc(text)}</text>`;
1394 const lines = [];
1395 let line = '';
1396 // Prefer word boundaries; split oversized words and CJK by grapheme, without
1397 // dropping whitespace or splitting a combining character/emoji sequence.
1398 const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
1399 for (const word of text.match(/\s+|\S+/gu) || []) {
1400 if (line && minimumNodeTextWidth(line + word, font) > available) {
1401 lines.push(line); line = '';
1403 for (const { segment } of segmenter.segment(word)) {
1404 if (line && minimumNodeTextWidth(line + segment, font) > available) {
1405 lines.push(line); line = '';
1407 line += segment;
1410 if (line) lines.push(line);
1411 // Explicit node geometry is authoritative. Do not turn an old horizontal
1412 // overflow into a new collision with nodes when the header area is packed.
1413 const firstNodeY = Math.min(...[...nodes.values()].filter(node => node.stage === index).map(node => node.y),
1414 viewBox[1] - layout.stageBottomPad);
1415 const lastLineBottom = layout.stageY + 22 + (lines.length - 1) * (font + 4) + font * 0.3;
1416 if (lastLineBottom > firstNodeY - 4) return `${open}${esc(text)}</text>`;
1417 const content = lines.length === 1 ? esc(text) : lines.map((line, i) =>
1418 `<tspan x="${cx}" dy="${i ? font + 4 : 0}">${esc(line)}</tspan>`).join('');
1419 return `${open}${content}</text>`;
1422 function renderStage(stage, index) {
1423 const frame = compositionFrames[index];
1424 const cx = stageX(index);
1425 return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="${index}" x="${frame.x}" y="${frame.y}" width="${frame.width}" height="${frame.height}" rx="${frame.radius}" class="c-lane" stroke-width="1"/>
1426 ${renderStageHeader(stage, index, cx)}`;
1429 function renderNode(node) {
1430 const fill = componentFill[node.type] || 'c-external';
1431 const accent = componentText[node.type] || 't-muted';
1432 const hasSub = node.sublabel != null && node.sublabel !== '';
1433 const labelFontSize = fittedNodeFontSize(node.label, brandLabelFitWidth(node, node.width), 10, 8);
1434 const sublabelFontSize = fittedNodeFontSize(node.sublabel, node.width, Math.max(nodeTextFit.sublabelPreferred, contextFontMinimum), contextFontMinimum);
1435 const tagFontSize = fittedNodeFontSize(node.tag, node.width, Math.max(nodeTextFit.tagPreferred, contextFontMinimum), contextFontMinimum);
1436 const inset = node.autoHeightInset || 0;
1437 const textRows = [{ text: node.label, font: labelFontSize, y: 21 + inset }];
1438 if (hasSub) textRows.push({ text: node.sublabel, font: sublabelFontSize, y: 37 + inset });
1439 if (node.tag) textRows.push({ text: node.tag, font: tagFontSize, y: node.height - 11 - inset });
1440 const labelLayout = nodeLabelLayout({ width: node.width, height: node.height, rows: textRows,
1441 brand: Boolean(brandMarkFor(node)), source: Boolean(sourceEvidence?.nodes?.[node.id]?.length) });
1442 const sub = hasSub
1443 ? `\n <text data-detail="context" x="${node.cx}" y="${node.y + labelLayout.ys[1]}" class="t-muted" font-size="${sublabelFontSize}" text-anchor="middle">${esc(node.sublabel)}</text>`
1444 : '';
1445 const tag = node.tag
1446 ? `\n <text data-detail="fine" x="${node.cx}" y="${node.y + labelLayout.ys[hasSub ? 2 : 1]}" class="${accent}" font-size="${tagFontSize}" text-anchor="middle">${esc(node.tag)}</text>`
1447 : '';
1448 const stage = asArray(dataflow.stages)[node.stage];
1449 const context = stage
1450 ? `${String(node.stage + 1).padStart(2, '0')} / ${stage.label}`
1451 : i18nText(dataflow.meta.locale, 'node.context.dataflow');
1452 const brand = renderBrandMark(node, { x: node.x + node.width - 22, y: node.y + 6 });
1453 const passport = { kind: node.type, sublabel: node.sublabel, tag: node.tag, context, ...brandMetadataFor(node) };
1454 return ` <g ${focusNodeAttrs(node.id, node.label, passport, dataflow.meta.locale)}>
1455 ${focusNodeTitle(node.label, passport)}
1456 <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="c-mask"/>
1457 <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="${fill}"${animateAttr(dataflow.meta, 'node', nodeSteps.get(node.id))} stroke-width="1.5"/>
1458 ${renderSemanticSigil(node.type, { icon: node.icon, x: node.x + 6, y: node.y + labelLayout.sigilY, size: labelLayout.sigilSize })}${brand ? `\n ${brand}` : ''}
1459 <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${node.x + labelLayout.x}" y="${node.y + labelLayout.ys[0]}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(node.label)}</text>${sub}${tag}
1460 </g>`;
1463 function flowStrokeWidth(flow) {
1464 return flow.width || (flow.variant === 'emphasis' ? 1.8 : 1.4);
1467 function renderFlowPath(flow, index) {
1468 const [cls, marker] = arrowClassMap[flow.variant || 'default'] || arrowClassMap.default;
1469 const routed = pathFor(flow);
1470 const strokeWidth = flowStrokeWidth(flow);
1471 const automatic = resolvedCrossoverFlows.has(flow);
1472 const edge = ` <path ${focusEdgeAttrs(flow.from, flow.to, flow.label, index, flow.id)} data-composition-points="${routePointsValue(routed.points)}"${automatic ? ' data-composition-crossover="halo"' : ''}${authoredStraightRouteAttrs(flow, routed.points)} d="${routed.d}" class="${cls}"${animateAttr(dataflow.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
1473 if (!automatic) return edge;
1474 return ` <g data-graph-role="automatic-crossover" style="--step:${index}">
1475 <path data-graph-role="automatic-crossover-underlay" d="${routed.d}" fill="none" stroke="var(--mask)" stroke-width="${strokeWidth + 4}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"/>
1476${edge}
1477 </g>`;
1480 function renderFlowLabel(flow, index) {
1481 const routed = pathFor(flow);
1482 const [lx, ly] = resolvedLabelPoints.get(flow) || labelPoint(flow, routed.points);
1483 const rect = resolvedLabelRects.find(rect => rect.relation === flow);
1484 const { width: labelW, height: labelH } = rect || flowLabelSize(flow);
1485 const lines = rect?.lines;
1486 const text = lines ? lines.map((line, i) => `<tspan x="${lx}" dy="${i ? 11 : 0}">${esc(line)}</tspan>`).join('') : esc(flow.label);
1487 const classification = flow.classification
1488 ? `\n <text data-detail="fine" x="${lx}" y="${ly + 11 * (lines?.length || 1)}" class="t-dim" font-size="7" text-anchor="middle">${esc(flow.classification)}</text>`
1489 : '';
1490 return ` <g data-detail="context" ${focusEdgeAttrs(flow.from, flow.to, flow.label, index, flow.id)}>
1491 <rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
1492 <text x="${lx}" y="${ly}" class="${edgeLabelAccent(flow.variant)}" font-size="8" text-anchor="middle">${text}</text>${classification}
1493 </g>`;
1496 const LEGEND_CATALOG = [
1497 { kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8, swatchWidth: 34, swatchGap: 9, interactive: false },
1498 { kind: 'security', className: 'a-security', marker: 'arrowhead-security', swatchWidth: 34, swatchGap: 9, interactive: false },
1499 { kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed', swatchWidth: 34, swatchGap: 9, interactive: false },
1500 { kind: 'database' },
1501 { kind: 'default', className: 'a-default', marker: 'arrowhead', swatchWidth: 34, swatchGap: 9, interactive: false },
1502 ].map((entry) => ({
1503 ...entry,
1504 label: i18nText(dataflow.meta.locale, `legend.dataflow.${entry.kind}`),
1505 }));
1507 function renderLegend() {
1508 const presentKinds = new Set(asArray(dataflow.flows).map((flow) => flow.variant || 'default'));
1509 if ([...nodes.values()].some((node) => node.type === 'database')) presentKinds.add('database');
1510 const entries = resolveLegend(dataflow.meta?.legend, LEGEND_CATALOG, presentKinds);
1511 return renderResolvedLegend({
1512 entries,
1513 locale: dataflow.meta.locale,
1514 layout: {
1515 x: 40,
1516 baselineY: viewBox[1] - 36,
1517 width: viewBox[0] - 80,
1518 minTitleY: viewBox[1] - 66,
1519 unfit: dataflow.meta?.legend === undefined ? 'hide' : 'error',
1520 diagramType: 'dataflow',
1521 },
1522 renderSwatch: (entry) => entry.kind === 'database'
1523 ? `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="c-database" stroke-width="1"/>`
1524 : `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}" marker-end="url(#${entry.marker})"/>`,
1525 });
1528 function ensureAutoLegendFits() {
1529 // Narrow auto canvases can wrap the legend onto extra rows. Stage frames
1530 // fill the canvas height, so growing height alone does not free a legend
1531 // band — widen just enough for a one-row pack instead.
1532 if (dataflow.meta?.viewBox) return;
1533 const presentKinds = new Set(asArray(dataflow.flows).map((flow) => flow.variant || 'default'));
1534 if ([...nodes.values()].some((node) => node.type === 'database')) presentKinds.add('database');
1535 const entries = resolveLegend(dataflow.meta?.legend, LEGEND_CATALOG, presentKinds);
1536 if (!entries.length) return;
1537 if (legendFootprint(entries, { width: Math.max(1, viewBox[0] - 80) }).extraHeight <= 0) return;
1538 let width = viewBox[0];
1539 while (width < 1600
1540 && legendFootprint(entries, { width: Math.max(1, width - 80) }).extraHeight > 0) {
1541 width += 40;
1543 viewBox[0] = width;
1546 function renderSvg() {
1547 ensureAutoLegendFits();
1548 // Same default-canvas contract as lifecycle: 940x720 is below the 1.55 wide
1549 // ratio, so without intrinsic-height the desktop Reader can neither narrow
1550 // nor scroll it and every default dataflow fails the browser gate.
1551 const readerFit = dataflow.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height"';
1552 return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}"${readerFit} ${svgRootAttrs(dataflow.meta)} data-motion-choreography="stream">
1553${svgAccessibleText(dataflow.meta, 'dataflow')}
1554${renderDefinitions()}
1556 <!-- Background Grid -->
1557 <rect width="100%" height="100%" fill="url(#grid)" />
1559 <!-- Data Stages -->
1560${dataflow.stages.map(renderStage).join('\n\n')}
1562 <!-- Flow paths -->
1563${asArray(dataflow.flows).map(renderFlowPath).join('\n')}
1565 <!-- Nodes -->
1566${[...nodes.values()].map(renderNode).join('\n\n')}
1568 <!-- Flow labels -->
1569${asArray(dataflow.flows).map(renderFlowLabel).join('\n')}
1571 <!-- Legend -->
1572${renderLegend()}
1573 </svg>`;
1575 validateDataflow();
1576 return renderSvg();
1579 function attempt(rebalancePorts) {
1580 return withIsolatedDiagnosticRecording(() => {
1581 try { return build(rebalancePorts); }
1582 catch (error) {
1583 if (error?.archifyDiagnostics) error.archifyDiagnostics = rendererFailure(error).diagnostics;
1584 throw error;
1586 });
1589 try {
1590 return attempt(false);
1591 } catch (initial) {
1592 const layoutFailure = error => error?.archifyDiagnostics?.length
1593 && error.message.startsWith('Data-flow layout validation failed:');
1594 if (!layoutFailure(initial)) throw initial;
1595 try {
1596 const recovered = attempt(true);
1597 if (recovered !== null) return recovered;
1598 } catch (error) {
1599 // A failed geometry proposal leaves the original actionable diagnosis.
1600 // Unexpected implementation errors still escape with their own stack.
1601 if (!layoutFailure(error)) throw error;
1603 throw initial;