astro-archify 0.3.4

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.
Files changed (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/astro-archify-integration.d.ts +100 -0
  4. package/astro-archify-integration.js +0 -0
  5. package/package.json +64 -0
  6. package/vendor/archify/LICENSE +22 -0
  7. package/vendor/archify/NOTICE.md +48 -0
  8. package/vendor/archify/assets/template.html +14787 -0
  9. package/vendor/archify/renderers/architecture/grid.mjs +62 -0
  10. package/vendor/archify/renderers/architecture/render-architecture.mjs +1089 -0
  11. package/vendor/archify/renderers/dataflow/render-dataflow.mjs +482 -0
  12. package/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +570 -0
  13. package/vendor/archify/renderers/sequence/render-sequence.mjs +468 -0
  14. package/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  15. package/vendor/archify/renderers/shared/cli.mjs +220 -0
  16. package/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  17. package/vendor/archify/renderers/shared/diagnostics.mjs +116 -0
  18. package/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  19. package/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  20. package/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  21. package/vendor/archify/renderers/shared/geometry.mjs +1334 -0
  22. package/vendor/archify/renderers/shared/i18n.mjs +594 -0
  23. package/vendor/archify/renderers/shared/layout-report.mjs +40 -0
  24. package/vendor/archify/renderers/shared/legend.mjs +217 -0
  25. package/vendor/archify/renderers/shared/output-path.mjs +321 -0
  26. package/vendor/archify/renderers/shared/repository-evidence.mjs +235 -0
  27. package/vendor/archify/renderers/shared/text-fit.mjs +49 -0
  28. package/vendor/archify/renderers/shared/utils.mjs +232 -0
  29. package/vendor/archify/renderers/shared/validator.mjs +86 -0
  30. package/vendor/archify/renderers/workflow/render-workflow.mjs +749 -0
@@ -0,0 +1,749 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
4
+ import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
5
+ import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
6
+ import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
7
+ import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
8
+ import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
9
+ import { translateMessage as i18nText } from '../shared/i18n.mjs';
10
+ import {
11
+ asArray,
12
+ isFinitePoint,
13
+ rectsOverlap,
14
+ segmentIntersectsRect,
15
+ cleanEndpointSideProblems,
16
+ cleanFlowProblems,
17
+ cleanCrossingProblems,
18
+ cleanAmbiguousCorridorProblems,
19
+ cleanBorderRunProblems,
20
+ cleanRouteRhythmProblems,
21
+ cleanLabelRouteClearanceProblems,
22
+ suggestLabelObstacleFix,
23
+ suggestLabelPairFix,
24
+ anchor,
25
+ automaticPortSpread,
26
+ defaultFromSide,
27
+ defaultToSide,
28
+ chosenSide,
29
+ normalizeRoutePoints,
30
+ routeHonorsEndpointSides,
31
+ polylinePath,
32
+ routePointsValue,
33
+ labelPoint,
34
+ componentFill,
35
+ componentText,
36
+ arrowClassMap,
37
+ variantAccent
38
+ } from '../shared/geometry.mjs';
39
+
40
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
41
+ const { diagram: workflow, template, outPath } = await loadDiagramWithBrandMarks({
42
+ rendererDir: __dirname,
43
+ diagramType: 'workflow',
44
+ defaultExample: 'agent-tool-call.workflow.json'
45
+ });
46
+
47
+ const layout = {
48
+ laneX: 40,
49
+ laneY: 52,
50
+ laneW: 640,
51
+ laneH: 104,
52
+ laneGap: 20,
53
+ laneTitleH: 30,
54
+ colXs: [88, 220, 300, 430, 500, 625],
55
+ nodeW: 92,
56
+ nodeH: 52
57
+ };
58
+
59
+ // Content is 680px wide (laneX + laneW); auto height fits the lanes plus legend.
60
+ const autoHeight = layout.laneY
61
+ + (workflow.lanes?.length || 1) * layout.laneH
62
+ + ((workflow.lanes?.length || 1) - 1) * layout.laneGap
63
+ + 124;
64
+ const viewBox = workflow.meta?.viewBox || [720, autoHeight];
65
+
66
+ const laneIndex = new Map(asArray(workflow.lanes).map((lane, index) => [lane.id, index]));
67
+ const laneLabels = new Map(asArray(workflow.lanes).map((lane) => [lane.id, lane.label]));
68
+
69
+ function nodeContext(node) {
70
+ const group = asArray(workflow.groups).find((candidate) => (
71
+ candidate.lane === node.lane && node.col >= candidate.fromCol && node.col <= candidate.toCol
72
+ ));
73
+ const phase = asArray(workflow.phases).find((candidate) => (
74
+ node.col >= candidate.fromCol && node.col <= candidate.toCol
75
+ ));
76
+ return [laneLabels.get(node.lane), group?.label, phase?.label].filter(Boolean).join(' › ')
77
+ || i18nText(workflow.meta.locale, 'node.context.workflow');
78
+ }
79
+
80
+ function laneTop(id) {
81
+ return layout.laneY + laneIndex.get(id) * (layout.laneH + layout.laneGap);
82
+ }
83
+
84
+ function lastLaneBottom() {
85
+ return layout.laneY + workflow.lanes.length * layout.laneH + (workflow.lanes.length - 1) * layout.laneGap;
86
+ }
87
+
88
+ function legendY() {
89
+ return lastLaneBottom() + 44;
90
+ }
91
+
92
+ function measureNode(node) {
93
+ const width = node.width || layout.nodeW;
94
+ const height = node.height || (node.tag ? 68 : layout.nodeH);
95
+ const cx = layout.colXs[node.col];
96
+ const contentH = layout.laneH - layout.laneTitleH;
97
+ const y = laneTop(node.lane) + layout.laneTitleH + (contentH - height) / 2 + (node.yOffset || 0);
98
+ return {
99
+ ...node,
100
+ width,
101
+ height,
102
+ x: cx - width / 2,
103
+ y,
104
+ cx,
105
+ cy: y + height / 2
106
+ };
107
+ }
108
+
109
+ // Font sizes for this renderer's node text; the fitting geometry is shared.
110
+ const nodeTextFit = {
111
+ labelPreferred: 11,
112
+ labelMinimum: 9,
113
+ sublabelPreferred: 8,
114
+ sublabelMinimum: 6,
115
+ tagPreferred: 7,
116
+ tagMinimum: 6,
117
+ };
118
+
119
+ const nodes = new Map(asArray(workflow.nodes).map((node) => [node.id, measureNode(node)]));
120
+
121
+ function workflowCompositionFrames() {
122
+ const frames = [];
123
+ for (const [index, lane] of asArray(workflow.lanes).entries()) {
124
+ const y = layout.laneY + index * (layout.laneH + layout.laneGap);
125
+ frames.push({ id: `lane-${index}`, label: lane.label, kind: 'lane', x: layout.laneX, y, width: layout.laneW, height: layout.laneH, radius: 10 });
126
+ if (lane.variant === 'exception') {
127
+ 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: layout.laneH - 12, radius: 8 });
128
+ }
129
+ }
130
+ for (const [index, group] of asArray(workflow.groups).entries()) {
131
+ const span = spanForCols(group.fromCol, group.toCol, 50);
132
+ frames.push({
133
+ id: `group-${index}`,
134
+ label: group.label,
135
+ kind: 'group',
136
+ x: span.x,
137
+ y: laneTop(group.lane) + layout.laneTitleH + 8,
138
+ width: span.width,
139
+ height: layout.laneH - layout.laneTitleH - 16,
140
+ radius: 9,
141
+ });
142
+ }
143
+ return frames;
144
+ }
145
+
146
+ const mainPathSteps = new Map(asArray(workflow.mainPath).map((id, index) => [id, index]));
147
+ const edgeSteps = new Map(asArray(workflow.edges).map((edge, index) => {
148
+ const fromStep = mainPathSteps.get(edge.from);
149
+ const toStep = mainPathSteps.get(edge.to);
150
+ const mainStep = Number.isInteger(fromStep) && toStep === fromStep + 1 ? fromStep : null;
151
+ return [edge, mainStep ?? asArray(workflow.mainPath).length + index];
152
+ }));
153
+
154
+ function nodeStep(node) {
155
+ return mainPathSteps.get(node.id) ?? asArray(workflow.mainPath).length + asArray(workflow.nodes).findIndex((item) => item.id === node.id);
156
+ }
157
+
158
+ function validateWorkflow() {
159
+ const problems = [];
160
+ if (workflow.schema_version !== 1) {
161
+ problems.push('Workflow files must set "schema_version": 1.');
162
+ }
163
+ if (workflow.diagram_type !== 'workflow') {
164
+ problems.push(`Unsupported diagram_type "${workflow.diagram_type}". Expected "workflow".`);
165
+ }
166
+ if (!workflow.meta || !workflow.meta.title) {
167
+ problems.push('Workflow files must include meta.title.');
168
+ }
169
+ if (!Array.isArray(workflow.lanes) || !workflow.lanes.length) {
170
+ problems.push('Workflow files must include at least one lane.');
171
+ }
172
+ if (!Array.isArray(workflow.nodes)) {
173
+ problems.push('Workflow files must include a nodes array.');
174
+ }
175
+ if (!Array.isArray(workflow.edges)) {
176
+ problems.push('Workflow files must include an edges array.');
177
+ }
178
+ if (workflow.phases !== undefined && !Array.isArray(workflow.phases)) {
179
+ problems.push('Workflow "phases" must be an array.');
180
+ }
181
+ if (workflow.groups !== undefined && !Array.isArray(workflow.groups)) {
182
+ problems.push('Workflow "groups" must be an array.');
183
+ }
184
+ if (workflow.mainPath !== undefined && !Array.isArray(workflow.mainPath)) {
185
+ problems.push('Workflow "mainPath" must be an array of node ids.');
186
+ }
187
+ if (workflow.cards !== undefined && !Array.isArray(workflow.cards)) {
188
+ problems.push('Workflow "cards" must be an array.');
189
+ }
190
+ if (problems.length) {
191
+ throwDiagnosticProblems('Workflow layout validation failed', problems, {
192
+ subject: { diagramType: 'workflow' },
193
+ });
194
+ }
195
+
196
+ const laneIds = new Set(workflow.lanes.map((lane) => lane.id));
197
+ if (laneIds.size !== workflow.lanes.length) {
198
+ problems.push('Lane ids must be unique.');
199
+ }
200
+ if (nodes.size !== workflow.nodes.length) {
201
+ problems.push('Node ids must be unique.');
202
+ }
203
+ const phaseIds = new Set(asArray(workflow.phases).map((phase) => phase.id));
204
+ if (phaseIds.size !== asArray(workflow.phases).length) {
205
+ problems.push('Phase ids must be unique.');
206
+ }
207
+ const groupIds = new Set(asArray(workflow.groups).map((group) => group.id));
208
+ if (groupIds.size !== asArray(workflow.groups).length) {
209
+ problems.push('Group ids must be unique.');
210
+ }
211
+
212
+ for (const node of nodes.values()) {
213
+ if (!laneIds.has(node.lane)) {
214
+ problems.push(`Node "${node.id}" uses unknown lane "${node.lane}".`);
215
+ continue;
216
+ }
217
+ if (!Number.isInteger(node.col) || node.col < 0 || node.col >= layout.colXs.length) {
218
+ problems.push(`Node "${node.id}" uses column ${node.col}, but valid columns are integers 0..${layout.colXs.length - 1}.`);
219
+ continue;
220
+ }
221
+ if (!isFinitePoint(node.x, node.y, node.cx, node.cy)) {
222
+ problems.push(`Node "${node.id}" produced non-finite coordinates — check col, width, height, and yOffset are numbers.`);
223
+ continue;
224
+ }
225
+ const estLabelW = textUnits(node.label) * 6.8;
226
+ if (estLabelW > node.width + 6) {
227
+ 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.`);
228
+ }
229
+ const brandRailProblem = brandTopRailProblem(node, node.width, nodeTextFit.labelMinimum);
230
+ if (brandRailProblem) problems.push(brandRailProblem);
231
+ const availableTextW = availableNodeTextWidth(node.width);
232
+ for (const [field, value, minimum] of [
233
+ ['Sublabel', node.sublabel, nodeTextFit.sublabelMinimum],
234
+ ['Tag', node.tag, nodeTextFit.tagMinimum],
235
+ ]) {
236
+ if (!value) continue;
237
+ const minimumW = minimumNodeTextWidth(value, minimum);
238
+ if (minimumW > availableTextW) {
239
+ 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.`);
240
+ }
241
+ }
242
+
243
+ const top = laneTop(node.lane);
244
+ const contentTop = top + layout.laneTitleH;
245
+ const laneRight = layout.laneX + layout.laneW;
246
+ if (node.x < layout.laneX || node.x + node.width > laneRight) {
247
+ problems.push(`Node "${node.id}" exceeds the horizontal bounds of lane "${node.lane}".`);
248
+ }
249
+ if (node.y < contentTop || node.y + node.height > top + layout.laneH) {
250
+ problems.push(`Node "${node.id}" collides with the title or boundary of lane "${node.lane}".`);
251
+ }
252
+ }
253
+
254
+ const phaseRanges = [];
255
+ for (const phase of asArray(workflow.phases)) {
256
+ if (!Number.isInteger(phase.fromCol) || !Number.isInteger(phase.toCol)) {
257
+ problems.push(`Phase "${phase.id}" must use integer fromCol/toCol values.`);
258
+ continue;
259
+ }
260
+ if (phase.fromCol < 0 || phase.toCol >= layout.colXs.length || phase.fromCol > phase.toCol) {
261
+ problems.push(`Phase "${phase.id}" uses invalid columns ${phase.fromCol}..${phase.toCol}; use an ordered range within 0..${layout.colXs.length - 1}.`);
262
+ } else {
263
+ phaseRanges.push(phase);
264
+ }
265
+ const estLabelW = textUnits(phase.label) * 5.6;
266
+ const width = spanForCols(phase.fromCol, phase.toCol).width;
267
+ if (estLabelW > width + 8) {
268
+ 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.`);
269
+ }
270
+ }
271
+ phaseRanges.sort((a, b) => a.fromCol - b.fromCol || a.toCol - b.toCol);
272
+ for (let i = 0; i < phaseRanges.length; i += 1) {
273
+ for (let j = i + 1; j < phaseRanges.length; j += 1) {
274
+ const earlier = phaseRanges[i];
275
+ const later = phaseRanges[j];
276
+ if (later.fromCol > earlier.toCol) break;
277
+ 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}.`);
278
+ }
279
+ }
280
+
281
+ for (const group of asArray(workflow.groups)) {
282
+ if (!laneIds.has(group.lane)) {
283
+ problems.push(`Group "${group.id}" uses unknown lane "${group.lane}".`);
284
+ continue;
285
+ }
286
+ if (!Number.isInteger(group.fromCol) || !Number.isInteger(group.toCol)) {
287
+ problems.push(`Group "${group.id}" must use integer fromCol/toCol values.`);
288
+ continue;
289
+ }
290
+ if (group.fromCol < 0 || group.toCol >= layout.colXs.length || group.fromCol > group.toCol) {
291
+ problems.push(`Group "${group.id}" uses invalid columns ${group.fromCol}..${group.toCol}; use an ordered range within 0..${layout.colXs.length - 1}.`);
292
+ }
293
+ const contained = [...nodes.values()].some((node) => node.lane === group.lane && node.col >= group.fromCol && node.col <= group.toCol);
294
+ if (!contained) {
295
+ problems.push(`Group "${group.id}" does not contain any nodes — align its lane/columns with the parallel or branch work it frames.`);
296
+ }
297
+ }
298
+
299
+ const byLane = new Map();
300
+ for (const node of nodes.values()) {
301
+ byLane.set(node.lane, [...(byLane.get(node.lane) || []), node]);
302
+ }
303
+ for (const [lane, laneNodes] of byLane) {
304
+ for (let i = 0; i < laneNodes.length; i += 1) {
305
+ for (let j = i + 1; j < laneNodes.length; j += 1) {
306
+ if (rectsOverlap(laneNodes[i], laneNodes[j], 8)) {
307
+ 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.`);
308
+ }
309
+ }
310
+ }
311
+ }
312
+
313
+ for (const edge of workflow.edges) {
314
+ if (!nodes.has(edge.from)) problems.push(`Edge "${edge.label || edge.from}" references unknown source "${edge.from}".`);
315
+ if (!nodes.has(edge.to)) problems.push(`Edge "${edge.label || edge.to}" references unknown target "${edge.to}".`);
316
+ if (nodes.has(edge.from) && nodes.has(edge.to)) {
317
+ const routed = pathFor(edge);
318
+ if (routed.points.length === 2) {
319
+ const [start, end] = routed.points;
320
+ const segmentLength = Math.hypot(end[0] - start[0], end[1] - start[1]);
321
+ if (segmentLength < 28) {
322
+ problems.push(`Edge "${edge.from}" -> "${edge.to}" is too short (${Math.round(segmentLength)}px; minimum 28px) — drop its label or route it through a channel.`);
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ problems.push(...cleanEndpointSideProblems({
329
+ relations: workflow.edges,
330
+ endpointIds: new Set(nodes.keys()),
331
+ pathFor,
332
+ diagramType: 'workflow',
333
+ relationCollection: 'edges',
334
+ fromSideFor: (edge) => edgeSides(edge).fromSide,
335
+ toSideFor: (edge) => edgeSides(edge).toSide,
336
+ routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross node borders perpendicularly',
337
+ }));
338
+ problems.push(...cleanFlowProblems({
339
+ relations: workflow.edges,
340
+ endpointIds: new Set(nodes.keys()),
341
+ obstacles: nodes.values(),
342
+ pathFor,
343
+ diagramType: 'workflow',
344
+ relationCollection: 'edges',
345
+ obstacleKind: 'node',
346
+ profile: workflow.meta?.quality_profile,
347
+ routeHint: 'adjust fromSide/toSide, set route/via or channel coordinates, or move the node to a clearer lane/column'
348
+ }));
349
+ problems.push(...cleanCrossingProblems({
350
+ relations: workflow.edges,
351
+ endpointIds: new Set(nodes.keys()),
352
+ pathFor,
353
+ diagramType: 'workflow',
354
+ relationCollection: 'edges',
355
+ profile: workflow.meta?.quality_profile,
356
+ routeHint: 'adjust route/via, bias, or channel coordinates so the edges use separate lane corridors'
357
+ }));
358
+ problems.push(...cleanAmbiguousCorridorProblems({
359
+ relations: workflow.edges,
360
+ endpointIds: new Set(nodes.keys()),
361
+ pathFor,
362
+ diagramType: 'workflow',
363
+ relationCollection: 'edges',
364
+ profile: workflow.meta?.quality_profile,
365
+ routeHint: 'adjust route/via, bias, or channel coordinates so unrelated edges do not visually merge'
366
+ }));
367
+ problems.push(...cleanBorderRunProblems({
368
+ relations: workflow.edges,
369
+ endpointIds: new Set(nodes.keys()),
370
+ frames: workflowCompositionFrames(),
371
+ pathFor,
372
+ diagramType: 'workflow',
373
+ relationCollection: 'edges',
374
+ profile: workflow.meta?.quality_profile,
375
+ routeHint: 'adjust route/via, bias, or channel coordinates so the edge crosses the lane or group perpendicularly instead of following its border'
376
+ }));
377
+ problems.push(...cleanRouteRhythmProblems({
378
+ relations: workflow.edges,
379
+ endpointIds: new Set(nodes.keys()),
380
+ pathFor,
381
+ diagramType: 'workflow',
382
+ relationCollection: 'edges',
383
+ profile: workflow.meta?.quality_profile,
384
+ routeHint: 'adjust route/via, bias, or channel coordinates so each turn has a readable run-up'
385
+ }));
386
+
387
+ if (Array.isArray(workflow.mainPath)) {
388
+ for (const id of workflow.mainPath) {
389
+ if (!nodes.has(id)) {
390
+ problems.push(`mainPath references unknown node "${id}".`);
391
+ }
392
+ }
393
+ for (let i = 0; i < workflow.mainPath.length - 1; i += 1) {
394
+ const fromId = workflow.mainPath[i];
395
+ const toId = workflow.mainPath[i + 1];
396
+ const from = nodes.get(fromId);
397
+ const to = nodes.get(toId);
398
+ if (!from || !to) continue;
399
+ const linked = workflow.edges.some((edge) => edge.from === fromId && edge.to === toId);
400
+ if (!linked) {
401
+ problems.push(`mainPath step "${fromId}" -> "${toId}" has no matching edge — add the edge or remove the pair from mainPath.`);
402
+ }
403
+ if (to.col < from.col) {
404
+ problems.push(`mainPath step "${fromId}" -> "${toId}" moves backward from col ${from.col} to ${to.col} — use a return edge outside mainPath for loops.`);
405
+ }
406
+ }
407
+ }
408
+
409
+ const labelRects = [];
410
+ for (const [edgeIndex, edge] of workflow.edges.entries()) {
411
+ if (!edge.label || !nodes.has(edge.from) || !nodes.has(edge.to)) continue;
412
+ const [lx, ly] = workflowEdgeLabelPoint(edge, pathFor(edge).points);
413
+ const width = Math.max(30, textUnits(edge.label) * 4.8 + 10);
414
+ labelRects.push({ relation: edge, relationIndex: edgeIndex, label: edge.label, x: lx - width / 2, y: ly - 10, width, height: 14, lx, ly });
415
+ }
416
+ for (const rect of labelRects) {
417
+ for (const node of nodes.values()) {
418
+ if (rectsOverlap(rect, node, -2)) {
419
+ 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')}`);
420
+ }
421
+ }
422
+ }
423
+ for (let i = 0; i < labelRects.length; i += 1) {
424
+ for (let j = i + 1; j < labelRects.length; j += 1) {
425
+ if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
426
+ problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy or remove one label.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
427
+ }
428
+ }
429
+ }
430
+ problems.push(...cleanLabelRouteClearanceProblems({
431
+ relations: workflow.edges,
432
+ labels: labelRects,
433
+ endpointIds: new Set(nodes.keys()),
434
+ pathFor,
435
+ diagramType: 'workflow',
436
+ relationCollection: 'edges',
437
+ profile: workflow.meta?.quality_profile,
438
+ }));
439
+
440
+ if (viewBox[0] < layout.laneX + layout.laneW + 16) {
441
+ problems.push(`viewBox width ${viewBox[0]} clips the ${layout.laneW}px lanes — set meta.viewBox[0] to at least ${layout.laneX + layout.laneW + 16}.`);
442
+ }
443
+ if (legendY() + 18 > viewBox[1]) {
444
+ problems.push(`Legend exceeds viewBox height ${viewBox[1]} — set meta.viewBox[1] to at least ${legendY() + 18}.`);
445
+ }
446
+
447
+ if (problems.length) {
448
+ throwDiagnosticProblems('Workflow layout validation failed', problems, {
449
+ subject: { diagramType: 'workflow' },
450
+ });
451
+ }
452
+ }
453
+
454
+ function gapYBetween(fromLane, toLane, bias = 0.5) {
455
+ const a = laneTop(fromLane) + layout.laneH;
456
+ const b = laneTop(toLane);
457
+ return a + (b - a) * bias;
458
+ }
459
+
460
+ function spanForCols(fromCol, toCol, pad = 46) {
461
+ const start = layout.colXs[fromCol] - pad;
462
+ const end = layout.colXs[toCol] + pad;
463
+ return { x: start, width: end - start, cx: (start + end) / 2 };
464
+ }
465
+
466
+ function sameLaneAutoVia(start, end) {
467
+ if (start[0] === end[0] || start[1] === end[1]) return [];
468
+ const midX = (start[0] + end[0]) / 2;
469
+ return [[midX, start[1]], [midX, end[1]]];
470
+ }
471
+
472
+ function routeClearsUnrelatedNodes(edge, points, clearance = 2) {
473
+ const endpointIds = new Set([edge.from, edge.to]);
474
+ for (const node of nodes.values()) {
475
+ if (endpointIds.has(node.id)) continue;
476
+ for (let index = 0; index < points.length - 1; index += 1) {
477
+ if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, node, clearance)) {
478
+ return false;
479
+ }
480
+ }
481
+ }
482
+ return true;
483
+ }
484
+
485
+ function oneBendCrossLaneVia(edge, start, end, fromSide, toSide) {
486
+ const fromVertical = fromSide === 'top' || fromSide === 'bottom';
487
+ const toVertical = toSide === 'top' || toSide === 'bottom';
488
+ if (fromVertical === toVertical) return null;
489
+
490
+ const corner = fromVertical ? [start[0], end[1]] : [end[0], start[1]];
491
+ const points = normalizeRoutePoints([start, corner, end]);
492
+ if (points.length !== 3 || !routeHonorsEndpointSides(points, fromSide, toSide)) return null;
493
+
494
+ const segmentsAreReadable = points.slice(0, -1).every((point, index) => (
495
+ Math.hypot(
496
+ points[index + 1][0] - point[0],
497
+ points[index + 1][1] - point[1],
498
+ ) >= 8
499
+ ));
500
+ if (!segmentsAreReadable || !routeClearsUnrelatedNodes(edge, points)) return null;
501
+ return points.slice(1, -1);
502
+ }
503
+
504
+ function automaticOneBendSides(edge, from, to) {
505
+ const automaticRoute = !edge.via && (!edge.route || edge.route === 'auto');
506
+ const automaticFrom = !edge.fromSide || edge.fromSide === 'auto';
507
+ const automaticTo = !edge.toSide || edge.toSide === 'auto';
508
+ if (!automaticRoute || !automaticFrom || !automaticTo || from.lane === to.lane) return null;
509
+ if (from.cx === to.cx || from.cy === to.cy) return null;
510
+ const verticalFrom = to.cy < from.cy ? 'top' : 'bottom';
511
+ const horizontalTo = to.cx < from.cx ? 'right' : 'left';
512
+ const horizontalFrom = to.cx < from.cx ? 'left' : 'right';
513
+ const verticalTo = to.cy < from.cy ? 'bottom' : 'top';
514
+ const candidates = [
515
+ { fromSide: verticalFrom, toSide: horizontalTo },
516
+ { fromSide: horizontalFrom, toSide: verticalTo },
517
+ ];
518
+
519
+ return candidates.find(({ fromSide, toSide }) => {
520
+ const start = anchor(from, fromSide);
521
+ const end = anchor(to, toSide);
522
+ return oneBendCrossLaneVia(edge, start, end, fromSide, toSide);
523
+ }) || null;
524
+ }
525
+
526
+ function routeVia(edge, from, to, start, end, fromSide, toSide) {
527
+ if (edge.via) return edge.via;
528
+ switch (edge.route || 'auto') {
529
+ case 'straight':
530
+ return [];
531
+ case 'drop': {
532
+ const y = gapYBetween(from.lane, to.lane, edge.bias ?? 0.5);
533
+ return [[start[0], y], [end[0], y]];
534
+ }
535
+ case 'outside-right': {
536
+ const x = edge.channelX ?? layout.laneX + layout.laneW + 12;
537
+ return [[x, start[1]], [x, end[1]]];
538
+ }
539
+ case 'return-left': {
540
+ const x = edge.channelX ?? Math.min(from.x, to.x) - 28;
541
+ return [[x, start[1]], [x, end[1]]];
542
+ }
543
+ case 'bottom-channel': {
544
+ const y = edge.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 32;
545
+ return [[start[0], y], [end[0], y]];
546
+ }
547
+ case 'up-channel': {
548
+ const y = edge.channelY ?? Math.min(from.y, to.y) - 28;
549
+ return [[start[0], y], [end[0], y]];
550
+ }
551
+ case 'auto':
552
+ default: {
553
+ if (from.lane === to.lane) return sameLaneAutoVia(start, end);
554
+ const oneBendVia = oneBendCrossLaneVia(edge, start, end, fromSide, toSide);
555
+ if (oneBendVia) return oneBendVia;
556
+ const y = gapYBetween(from.lane, to.lane, edge.bias ?? 0.5);
557
+ return [[start[0], y], [end[0], y]];
558
+ }
559
+ }
560
+ }
561
+
562
+ const pathCache = new Map();
563
+
564
+ function workflowEdgeLabelPoint(edge, points) {
565
+ if (edge.labelAt || Number.isInteger(edge.labelSegment) || points.length !== 3) {
566
+ return labelPoint(edge, points);
567
+ }
568
+ const segmentLengths = [0, 1].map((index) => Math.hypot(
569
+ points[index + 1][0] - points[index][0],
570
+ points[index + 1][1] - points[index][1],
571
+ ));
572
+ const labelSegment = segmentLengths[0] >= segmentLengths[1] ? 0 : 1;
573
+ const point = labelPoint({ ...edge, labelSegment }, points);
574
+ if (points[labelSegment][0] === points[labelSegment + 1][0]) point[1] += 10;
575
+ return point;
576
+ }
577
+
578
+ function edgeSides(edge) {
579
+ const from = nodes.get(edge.from);
580
+ const to = nodes.get(edge.to);
581
+ const oneBendSides = automaticOneBendSides(edge, from, to);
582
+ if (oneBendSides) return oneBendSides;
583
+ return {
584
+ fromSide: chosenSide(edge.fromSide, defaultFromSide(from, to)),
585
+ toSide: chosenSide(edge.toSide, defaultToSide(from, to)),
586
+ };
587
+ }
588
+
589
+ const automaticPorts = automaticPortSpread(workflow.edges, nodes, {
590
+ sideFor: (edge, endpoint) => edgeSides(edge)[endpoint === 'source' ? 'fromSide' : 'toSide'],
591
+ });
592
+
593
+ function pathFor(edge) {
594
+ if (pathCache.has(edge)) return pathCache.get(edge);
595
+ const from = nodes.get(edge.from);
596
+ const to = nodes.get(edge.to);
597
+ const ports = automaticPorts.get(edge);
598
+ const { fromSide, toSide } = edgeSides(edge);
599
+ const start = ports?.from || anchor(from, fromSide);
600
+ const end = ports?.to || anchor(to, toSide);
601
+ const points = [start, ...routeVia(edge, from, to, start, end, fromSide, toSide), end];
602
+ const routed = { d: polylinePath(points), points };
603
+ pathCache.set(edge, routed);
604
+ return routed;
605
+ }
606
+
607
+ function renderLane(lane, index) {
608
+ const y = layout.laneY + index * (layout.laneH + layout.laneGap);
609
+ const exception = lane.variant === 'exception'
610
+ ? `\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="${layout.laneH - 12}" rx="8" class="c-security-group" stroke-width="1"/>`
611
+ : '';
612
+ const labelClass = lane.variant === 'exception' ? 't-security' : 't-dim';
613
+ const prefix = lane.variant === 'exception' ? 'EX' : String(index + 1).padStart(2, '0');
614
+ 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="${layout.laneH}" rx="10" class="c-lane" stroke-width="1"/>${exception}
615
+ <text x="${layout.laneX + 14}" y="${y + 22}" class="${labelClass}" font-size="10" font-weight="600">${prefix} / ${esc(lane.label)}</text>`;
616
+ }
617
+
618
+ function renderPhase(phase) {
619
+ const span = spanForCols(phase.fromCol, phase.toCol, 46);
620
+ const accent = variantAccent(phase.variant);
621
+ const [lineClass] = arrowClassMap[phase.variant || 'default'] || arrowClassMap.default;
622
+ return ` <line x1="${span.x}" y1="35" x2="${span.x + span.width}" y2="35" class="${lineClass}" stroke-width="1.1"/>
623
+ <rect x="${span.x}" y="27" width="${span.width}" height="16" rx="4" class="c-mask"/>
624
+ <text x="${span.cx}" y="39" class="${accent}" font-size="8" font-weight="600" text-anchor="middle">${esc(phase.label)}</text>`;
625
+ }
626
+
627
+ function renderGroup(group, index) {
628
+ const span = spanForCols(group.fromCol, group.toCol, 50);
629
+ const y = laneTop(group.lane) + layout.laneTitleH + 8;
630
+ const height = layout.laneH - layout.laneTitleH - 16;
631
+ const cls = group.variant === 'security' ? 'c-security-group' : 'c-lane';
632
+ const textClass = variantAccent(group.variant);
633
+ 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"/>
634
+ <text x="${span.x + 10}" y="${y + 14}" class="${textClass}" font-size="7" font-weight="600">${esc(group.label)}</text>`;
635
+ }
636
+
637
+ function renderNode(node) {
638
+ const fill = componentFill[node.type] || 'c-external';
639
+ const accent = componentText[node.type] || 't-muted';
640
+ const hasSub = node.sublabel != null && node.sublabel !== '';
641
+ const labelFontSize = fittedNodeFontSize(node.label, brandLabelFitWidth(node, node.width), nodeTextFit.labelPreferred, nodeTextFit.labelMinimum);
642
+ const sublabelFontSize = hasSub
643
+ ? fittedNodeFontSize(node.sublabel, node.width, nodeTextFit.sublabelPreferred, nodeTextFit.sublabelMinimum)
644
+ : nodeTextFit.sublabelPreferred;
645
+ const sub = hasSub
646
+ ? `\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>`
647
+ : '';
648
+ const tag = node.tag
649
+ ? `\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>`
650
+ : '';
651
+ const brand = renderBrandMark(node, { x: node.x + node.width - 22, y: node.y + 6 });
652
+ const passport = { kind: node.type, sublabel: node.sublabel, tag: node.tag, context: nodeContext(node), ...brandMetadataFor(node) };
653
+ return ` <g ${focusNodeAttrs(node.id, node.label, passport, workflow.meta.locale)}>
654
+ ${focusNodeTitle(node.label, passport)}
655
+ <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="c-mask"/>
656
+ <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"/>
657
+ ${renderSemanticSigil(node.type, { x: node.x + 6, y: node.y + 6 })}${brand ? `\n ${brand}` : ''}
658
+ <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}
659
+ </g>`;
660
+ }
661
+
662
+ function renderEdgePath(edge, index) {
663
+ const [cls, marker] = arrowClassMap[edge.variant || 'default'] || arrowClassMap.default;
664
+ const routed = pathFor(edge);
665
+ const strokeWidth = edge.width || (edge.variant === 'emphasis' ? 1.8 : 1.4);
666
+ 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})"/>`;
667
+ }
668
+
669
+ function renderEdgeLabel(edge, index) {
670
+ if (!edge.label) return '';
671
+ const routed = pathFor(edge);
672
+ const [lx, ly] = workflowEdgeLabelPoint(edge, routed.points);
673
+ const labelW = Math.max(30, textUnits(edge.label) * 4.8 + 10);
674
+ return ` <g data-detail="context" ${focusEdgeAttrs(edge.from, edge.to, edge.label, index, edge.id)}>
675
+ <rect x="${lx - labelW / 2}" y="${ly - 10}" width="${labelW}" height="14" rx="3" class="c-mask"/>
676
+ <text x="${lx}" y="${ly}" class="${variantAccent(edge.variant, { dashed: 't-database' })}" font-size="8" text-anchor="middle">${esc(edge.label)}</text>
677
+ </g>`;
678
+ }
679
+
680
+ const LEGEND_CATALOG = [
681
+ 'frontend',
682
+ 'backend',
683
+ 'security',
684
+ 'messagebus',
685
+ 'database',
686
+ 'cloud',
687
+ 'external',
688
+ ].map((kind) => ({ kind, label: i18nText(workflow.meta.locale, `legend.workflow.${kind}`) }));
689
+
690
+ function renderLegend() {
691
+ const presentKinds = new Set([...nodes.values()].map((node) => node.type));
692
+ const entries = resolveLegend(workflow.meta?.legend, LEGEND_CATALOG, presentKinds);
693
+ return renderResolvedLegend({
694
+ entries,
695
+ locale: workflow.meta.locale,
696
+ layout: {
697
+ x: 20,
698
+ baselineY: legendY(),
699
+ width: viewBox[0] - 40,
700
+ fontSize: 7,
701
+ itemGap: 7,
702
+ minTitleY: lastLaneBottom() + 8,
703
+ unfit: workflow.meta?.legend === undefined ? 'hide' : 'error',
704
+ diagramType: 'workflow',
705
+ },
706
+ 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"/>`,
707
+ });
708
+ }
709
+
710
+ function renderSvg() {
711
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(workflow.meta, 'workflow diagram')}>
712
+ ${svgAccessibleText(workflow.meta, 'workflow')}
713
+ ${renderDefinitions()}
714
+
715
+ <!-- Background Grid -->
716
+ <rect width="100%" height="100%" fill="url(#grid)" />
717
+
718
+ <!-- Swimlanes -->
719
+ ${workflow.lanes.map(renderLane).join('\n\n')}
720
+
721
+ <!-- Phase headers -->
722
+ ${asArray(workflow.phases).map(renderPhase).join('\n')}
723
+
724
+ <!-- Workflow groups -->
725
+ ${asArray(workflow.groups).map(renderGroup).join('\n')}
726
+
727
+ <!-- Edge paths -->
728
+ ${workflow.edges.map(renderEdgePath).join('\n')}
729
+
730
+ <!-- Nodes -->
731
+ ${[...nodes.values()].map(renderNode).join('\n\n')}
732
+
733
+ <!-- Edge labels -->
734
+ ${workflow.edges.map(renderEdgeLabel).join('\n')}
735
+
736
+ <!-- Legend -->
737
+ ${renderLegend()}
738
+ </svg>`;
739
+ }
740
+
741
+ validateWorkflow();
742
+ writeDiagram({
743
+ outPath,
744
+ template,
745
+ diagramType: 'workflow',
746
+ meta: workflow.meta,
747
+ svg: renderSvg(),
748
+ cards: workflow.cards,
749
+ });