qgraphflow 0.0.6 → 0.0.7

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 (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.qoder-plugin/plugin.json +1 -1
  6. package/README.md +119 -70
  7. package/docs/clients.de.md +15 -24
  8. package/docs/clients.es.md +15 -24
  9. package/docs/clients.ja.md +15 -24
  10. package/docs/clients.md +15 -24
  11. package/docs/clients.pt.md +15 -24
  12. package/docs/clients.ru.md +15 -24
  13. package/docs/clients.zh-CN.md +15 -24
  14. package/docs/readme/README.de.md +120 -71
  15. package/docs/readme/README.es.md +120 -71
  16. package/docs/readme/README.ja.md +120 -71
  17. package/docs/readme/README.pt.md +120 -71
  18. package/docs/readme/README.ru.md +120 -71
  19. package/docs/readme/README.zh-CN.md +106 -59
  20. package/examples/jeepay/README.md +23 -0
  21. package/examples/jeepay/capabilities.graph.json +270 -0
  22. package/examples/jeepay/class.graph.json +237 -0
  23. package/examples/jeepay/collection.graph.json +3057 -0
  24. package/examples/jeepay/dataflow.graph.json +212 -0
  25. package/examples/jeepay/deployment.graph.json +222 -0
  26. package/examples/jeepay/engineering.graph.json +277 -0
  27. package/examples/jeepay/er.graph.json +482 -0
  28. package/examples/jeepay/flowchart.graph.json +312 -0
  29. package/examples/jeepay/relations.graph.json +289 -0
  30. package/examples/jeepay/sequence.graph.json +355 -0
  31. package/examples/jeepay/source.json +95 -0
  32. package/examples/jeepay/state.graph.json +175 -0
  33. package/examples/jeepay/usecase.graph.json +222 -0
  34. package/package.json +14 -3
  35. package/skills/q-flow/SKILL.md +28 -20
  36. package/skills/q-flow/agents/openai.yaml +1 -1
  37. package/skills/q-flow/assets/viewer/package.json +1 -1
  38. package/skills/q-flow/assets/viewer/src/architecture-overview-theme.js +22 -0
  39. package/skills/q-flow/assets/viewer/src/architecture-overview.js +340 -0
  40. package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +8 -5
  41. package/skills/q-flow/assets/viewer/src/diagrams/card.js +35 -17
  42. package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +7 -5
  43. package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +5 -2
  44. package/skills/q-flow/assets/viewer/src/diagrams/registry.js +10 -0
  45. package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +13 -7
  46. package/skills/q-flow/assets/viewer/src/edge-routing.js +43 -22
  47. package/skills/q-flow/assets/viewer/src/export-svg.js +27 -5
  48. package/skills/q-flow/assets/viewer/src/graph-validation.js +72 -15
  49. package/skills/q-flow/assets/viewer/src/i18n-messages.json +184 -8
  50. package/skills/q-flow/assets/viewer/src/layout-compaction.js +123 -0
  51. package/skills/q-flow/assets/viewer/src/layout-measure.js +14 -8
  52. package/skills/q-flow/assets/viewer/src/layout-policy.js +6 -0
  53. package/skills/q-flow/assets/viewer/src/layout-quality.js +61 -15
  54. package/skills/q-flow/assets/viewer/src/layout-refinement.js +271 -0
  55. package/skills/q-flow/assets/viewer/src/layout-semantics.js +8 -0
  56. package/skills/q-flow/assets/viewer/src/layout-spacing.js +23 -4
  57. package/skills/q-flow/assets/viewer/src/layout-templates.js +298 -0
  58. package/skills/q-flow/assets/viewer/src/node-svg.js +1 -1
  59. package/skills/q-flow/assets/viewer/src/orthogonal-routing.js +475 -0
  60. package/skills/q-flow/assets/viewer/src/presentation-graph.js +31 -0
  61. package/skills/q-flow/assets/viewer/src/route-clearance.js +144 -0
  62. package/skills/q-flow/assets/viewer/src/sequence-executions.js +22 -0
  63. package/skills/q-flow/assets/viewer/src/sequence-fragments.js +20 -2
  64. package/skills/q-flow/assets/viewer/src/session-graph.js +46 -3
  65. package/skills/q-flow/assets/viewer/src/text-layout.js +33 -6
  66. package/skills/q-flow/assets/viewer/src/view-identity.js +26 -0
  67. package/skills/q-flow/assets/viewer/src/visual-style.js +13 -5
  68. package/skills/q-flow/assets/viewer-dist/index.html +30 -28
  69. package/skills/q-flow/references/evidence-sources.md +7 -5
  70. package/skills/q-flow/references/graph-common.md +34 -34
  71. package/skills/q-flow/references/graph-schema.md +28 -7
  72. package/skills/q-flow/references/guided-intake.md +51 -71
  73. package/skills/q-flow/references/layout-routing.md +47 -0
  74. package/skills/q-flow/references/types/architecture.md +42 -22
  75. package/skills/q-flow/references/types/class.md +9 -2
  76. package/skills/q-flow/references/types/dataflow.md +11 -4
  77. package/skills/q-flow/references/types/deployment.md +11 -3
  78. package/skills/q-flow/references/types/er.md +8 -1
  79. package/skills/q-flow/references/types/flowchart.md +12 -5
  80. package/skills/q-flow/references/types/sequence.md +20 -16
  81. package/skills/q-flow/references/types/state.md +10 -3
  82. package/skills/q-flow/references/types/usecase.md +6 -0
  83. package/skills/q-flow/references/viewer-development.md +37 -24
  84. package/skills/q-flow/references/visual-contract.md +12 -6
  85. package/skills/q-flow/scripts/compile-layout.mjs +85 -102
  86. package/skills/q-flow/scripts/compile-sequence.mjs +4 -21
  87. package/skills/q-flow/scripts/generate-viewer.mjs +18 -9
  88. package/skills/q-flow/scripts/validate-graph.mjs +38 -21
  89. package/examples/order-flow.graph.json +0 -94
@@ -1,5 +1,7 @@
1
1
  {
2
2
  "zh-CN": {
3
+ "Layout suggestions": "布局优化建议",
4
+ "Arrange this view to improve connector clearance and spacing. Existing positions are preserved until then.": "整理此图可改善连线间距与布局;整理前保留现有位置。",
3
5
  "Architecture": "架构图",
4
6
  "Flowchart": "流程图",
5
7
  "Sequence": "时序图",
@@ -161,7 +163,27 @@
161
163
  "In-progress state": "进行中状态",
162
164
  "In-progress state: warm to cool as it advances": "进行中状态:由暖到冷表示推进顺序",
163
165
  "Transition": "转移",
164
- "Self-transition: handled without leaving the state": "自转移:留在原状态内处理的事件"
166
+ "Self-transition: handled without leaving the state": "自转移:留在原状态内处理的事件",
167
+ "Key points": "要点",
168
+ "Show key points": "显示要点",
169
+ "Hide key points": "隐藏要点",
170
+ "Component relations": "组件关系",
171
+ "Platform capabilities": "平台能力总览",
172
+ "Engineering layers": "工程分层总览",
173
+ "Overview text": "总览正文",
174
+ "Badges": "徽章",
175
+ "Badge type": "徽章类型",
176
+ "Add badge": "添加徽章",
177
+ "Remove badge": "移除徽章",
178
+ "Status": "状态",
179
+ "Version": "版本",
180
+ "Requirement": "接入要求",
181
+ "Overview aligned": "总览已对齐",
182
+ "Section": "分区",
183
+ "Cancelled": "已取消",
184
+ "Component relationship architecture": "组件关系架构图",
185
+ "Platform capability architecture": "平台能力架构图",
186
+ "Engineering layer architecture": "工程分层架构图"
165
187
  },
166
188
  "ja": {
167
189
  "Architecture": "アーキテクチャ",
@@ -325,7 +347,29 @@
325
347
  "In-progress state": "進行中の状態",
326
348
  "In-progress state: warm to cool as it advances": "進行中の状態:進むにつれて暖色から寒色へ",
327
349
  "Transition": "遷移",
328
- "Self-transition: handled without leaving the state": "自己遷移:状態を離れずに処理するイベント"
350
+ "Self-transition: handled without leaving the state": "自己遷移:状態を離れずに処理するイベント",
351
+ "Key points": "要点",
352
+ "Show key points": "要点を表示",
353
+ "Hide key points": "要点を隠す",
354
+ "Component relations": "コンポーネント関係",
355
+ "Platform capabilities": "プラットフォーム機能",
356
+ "Engineering layers": "プロジェクト階層",
357
+ "Overview text": "概要本文",
358
+ "Badges": "バッジ",
359
+ "Badge type": "バッジの種類",
360
+ "Add badge": "バッジを追加",
361
+ "Remove badge": "バッジを削除",
362
+ "Status": "状態",
363
+ "Version": "バージョン",
364
+ "Requirement": "要件",
365
+ "Overview aligned": "概要を整列しました",
366
+ "Section": "セクション",
367
+ "Cancelled": "キャンセルしました",
368
+ "Component relationship architecture": "コンポーネント関係アーキテクチャ図",
369
+ "Platform capability architecture": "プラットフォーム機能アーキテクチャ図",
370
+ "Engineering layer architecture": "プロジェクト階層アーキテクチャ図",
371
+ "Layout suggestions": "レイアウトの提案",
372
+ "Arrange this view to improve connector clearance and spacing. Existing positions are preserved until then.": "このビューを整列すると、接続線の余白と間隔を改善できます。それまでは現在の位置が保持されます。"
329
373
  },
330
374
  "ko": {
331
375
  "Architecture": "아키텍처",
@@ -489,7 +533,29 @@
489
533
  "In-progress state": "진행 중인 상태",
490
534
  "In-progress state: warm to cool as it advances": "진행 중인 상태: 진행될수록 따뜻한 색에서 차가운 색으로",
491
535
  "Transition": "전이",
492
- "Self-transition: handled without leaving the state": "자기 전이: 상태를 벗어나지 않고 처리하는 이벤트"
536
+ "Self-transition: handled without leaving the state": "자기 전이: 상태를 벗어나지 않고 처리하는 이벤트",
537
+ "Key points": "요점",
538
+ "Show key points": "요점 보기",
539
+ "Hide key points": "요점 숨기기",
540
+ "Component relations": "구성 요소 관계",
541
+ "Platform capabilities": "플랫폼 기능",
542
+ "Engineering layers": "프로젝트 계층",
543
+ "Overview text": "개요 본문",
544
+ "Badges": "배지",
545
+ "Badge type": "배지 유형",
546
+ "Add badge": "배지 추가",
547
+ "Remove badge": "배지 제거",
548
+ "Status": "상태",
549
+ "Version": "버전",
550
+ "Requirement": "요구 사항",
551
+ "Overview aligned": "개요 정렬 완료",
552
+ "Section": "섹션",
553
+ "Cancelled": "취소됨",
554
+ "Component relationship architecture": "구성 요소 관계 아키텍처",
555
+ "Platform capability architecture": "플랫폼 기능 아키텍처",
556
+ "Engineering layer architecture": "엔지니어링 계층 아키텍처",
557
+ "Layout suggestions": "레이아웃 제안",
558
+ "Arrange this view to improve connector clearance and spacing. Existing positions are preserved until then.": "이 보기를 정리하여 연결선의 여백과 간격을 개선하세요. 정리하기 전까지 기존 위치가 유지됩니다."
493
559
  },
494
560
  "de": {
495
561
  "Architecture": "Architektur",
@@ -653,7 +719,29 @@
653
719
  "In-progress state": "Zustand in Bearbeitung",
654
720
  "In-progress state: warm to cool as it advances": "Zustand in Bearbeitung: von warm nach kalt, je weiter er fortschreitet",
655
721
  "Transition": "Übergang",
656
- "Self-transition: handled without leaving the state": "Selbstübergang: Ereignis, das im Zustand bleibt"
722
+ "Self-transition: handled without leaving the state": "Selbstübergang: Ereignis, das im Zustand bleibt",
723
+ "Key points": "Kernaussagen",
724
+ "Show key points": "Kernaussagen anzeigen",
725
+ "Hide key points": "Kernaussagen ausblenden",
726
+ "Component relations": "Komponentenbeziehungen",
727
+ "Platform capabilities": "Plattformfunktionen",
728
+ "Engineering layers": "Projektebenen",
729
+ "Overview text": "Übersichtstext",
730
+ "Badges": "Abzeichen",
731
+ "Badge type": "Abzeichentyp",
732
+ "Add badge": "Abzeichen hinzufügen",
733
+ "Remove badge": "Abzeichen entfernen",
734
+ "Status": "Status",
735
+ "Version": "Version",
736
+ "Requirement": "Anforderung",
737
+ "Overview aligned": "Übersicht ausgerichtet",
738
+ "Section": "Abschnitt",
739
+ "Cancelled": "Abgebrochen",
740
+ "Component relationship architecture": "Komponentenarchitektur",
741
+ "Platform capability architecture": "Plattformfähigkeitsarchitektur",
742
+ "Engineering layer architecture": "Schichtenarchitektur",
743
+ "Layout suggestions": "Layoutvorschläge",
744
+ "Arrange this view to improve connector clearance and spacing. Existing positions are preserved until then.": "Ordnen Sie diese Ansicht an, um Abstände der Verbindungslinien zu verbessern. Bis dahin bleiben die bisherigen Positionen erhalten."
657
745
  },
658
746
  "fr": {
659
747
  "Architecture": "Architecture",
@@ -817,7 +905,29 @@
817
905
  "In-progress state": "État en cours",
818
906
  "In-progress state: warm to cool as it advances": "État en cours : du chaud au froid à mesure qu’il avance",
819
907
  "Transition": "Transition",
820
- "Self-transition: handled without leaving the state": "Auto-transition : événement traité sans quitter l’état"
908
+ "Self-transition: handled without leaving the state": "Auto-transition : événement traité sans quitter l’état",
909
+ "Key points": "Points clés",
910
+ "Show key points": "Afficher les points clés",
911
+ "Hide key points": "Masquer les points clés",
912
+ "Component relations": "Relations entre composants",
913
+ "Platform capabilities": "Capacités de la plateforme",
914
+ "Engineering layers": "Couches du projet",
915
+ "Overview text": "Texte de présentation",
916
+ "Badges": "Badges",
917
+ "Badge type": "Type de badge",
918
+ "Add badge": "Ajouter un badge",
919
+ "Remove badge": "Supprimer le badge",
920
+ "Status": "Statut",
921
+ "Version": "Version",
922
+ "Requirement": "Exigence",
923
+ "Overview aligned": "Présentation alignée",
924
+ "Section": "Section",
925
+ "Cancelled": "Annulé",
926
+ "Component relationship architecture": "Architecture des composants",
927
+ "Platform capability architecture": "Architecture des capacités",
928
+ "Engineering layer architecture": "Architecture en couches",
929
+ "Layout suggestions": "Suggestions de disposition",
930
+ "Arrange this view to improve connector clearance and spacing. Existing positions are preserved until then.": "Réorganisez cette vue pour améliorer les dégagements et les espacements des connecteurs. Les positions actuelles sont conservées jusque-là."
821
931
  },
822
932
  "es": {
823
933
  "Architecture": "Arquitectura",
@@ -981,7 +1091,29 @@
981
1091
  "In-progress state": "Estado en curso",
982
1092
  "In-progress state: warm to cool as it advances": "Estado en curso: de cálido a frío a medida que avanza",
983
1093
  "Transition": "Transición",
984
- "Self-transition: handled without leaving the state": "Autotransición: evento atendido sin salir del estado"
1094
+ "Self-transition: handled without leaving the state": "Autotransición: evento atendido sin salir del estado",
1095
+ "Key points": "Puntos clave",
1096
+ "Show key points": "Mostrar puntos clave",
1097
+ "Hide key points": "Ocultar puntos clave",
1098
+ "Component relations": "Relaciones entre componentes",
1099
+ "Platform capabilities": "Capacidades de la plataforma",
1100
+ "Engineering layers": "Capas del proyecto",
1101
+ "Overview text": "Texto del resumen",
1102
+ "Badges": "Insignias",
1103
+ "Badge type": "Tipo de insignia",
1104
+ "Add badge": "Añadir insignia",
1105
+ "Remove badge": "Eliminar insignia",
1106
+ "Status": "Estado",
1107
+ "Version": "Versión",
1108
+ "Requirement": "Requisito",
1109
+ "Overview aligned": "Resumen alineado",
1110
+ "Section": "Sección",
1111
+ "Cancelled": "Cancelado",
1112
+ "Component relationship architecture": "Arquitectura de componentes",
1113
+ "Platform capability architecture": "Arquitectura de capacidades",
1114
+ "Engineering layer architecture": "Arquitectura por capas",
1115
+ "Layout suggestions": "Sugerencias de diseño",
1116
+ "Arrange this view to improve connector clearance and spacing. Existing positions are preserved until then.": "Organiza esta vista para mejorar la separación y el espacio de los conectores. Las posiciones actuales se conservan hasta entonces."
985
1117
  },
986
1118
  "ru": {
987
1119
  "Architecture": "Архитектура",
@@ -1145,7 +1277,29 @@
1145
1277
  "In-progress state": "Состояние в процессе",
1146
1278
  "In-progress state: warm to cool as it advances": "Состояние в процессе: от тёплого цвета к холодному по мере продвижения",
1147
1279
  "Transition": "Переход",
1148
- "Self-transition: handled without leaving the state": "Петля: событие обрабатывается без выхода из состояния"
1280
+ "Self-transition: handled without leaving the state": "Петля: событие обрабатывается без выхода из состояния",
1281
+ "Key points": "Ключевые выводы",
1282
+ "Show key points": "Показать ключевые выводы",
1283
+ "Hide key points": "Скрыть ключевые выводы",
1284
+ "Component relations": "Связи компонентов",
1285
+ "Platform capabilities": "Возможности платформы",
1286
+ "Engineering layers": "Слои проекта",
1287
+ "Overview text": "Текст обзора",
1288
+ "Badges": "Метки",
1289
+ "Badge type": "Тип метки",
1290
+ "Add badge": "Добавить метку",
1291
+ "Remove badge": "Удалить метку",
1292
+ "Status": "Статус",
1293
+ "Version": "Версия",
1294
+ "Requirement": "Требование",
1295
+ "Overview aligned": "Обзор выровнен",
1296
+ "Section": "Раздел",
1297
+ "Cancelled": "Отменено",
1298
+ "Component relationship architecture": "Архитектура компонентов",
1299
+ "Platform capability architecture": "Архитектура возможностей платформы",
1300
+ "Engineering layer architecture": "Слоистая архитектура",
1301
+ "Layout suggestions": "Рекомендации по размещению",
1302
+ "Arrange this view to improve connector clearance and spacing. Existing positions are preserved until then.": "Упорядочьте эту схему, чтобы улучшить зазоры и расстояния между связями. До этого текущие позиции сохраняются."
1149
1303
  },
1150
1304
  "pt": {
1151
1305
  "Architecture": "Arquitetura",
@@ -1309,6 +1463,28 @@
1309
1463
  "In-progress state": "Estado em andamento",
1310
1464
  "In-progress state: warm to cool as it advances": "Estado em andamento: do quente ao frio conforme avança",
1311
1465
  "Transition": "Transição",
1312
- "Self-transition: handled without leaving the state": "Autotransição: evento tratado sem sair do estado"
1466
+ "Self-transition: handled without leaving the state": "Autotransição: evento tratado sem sair do estado",
1467
+ "Key points": "Pontos-chave",
1468
+ "Show key points": "Mostrar pontos-chave",
1469
+ "Hide key points": "Ocultar pontos-chave",
1470
+ "Component relations": "Relações entre componentes",
1471
+ "Platform capabilities": "Capacidades da plataforma",
1472
+ "Engineering layers": "Camadas do projeto",
1473
+ "Overview text": "Texto da visão geral",
1474
+ "Badges": "Emblemas",
1475
+ "Badge type": "Tipo de emblema",
1476
+ "Add badge": "Adicionar emblema",
1477
+ "Remove badge": "Remover emblema",
1478
+ "Status": "Estado",
1479
+ "Version": "Versão",
1480
+ "Requirement": "Requisito",
1481
+ "Overview aligned": "Visão geral alinhada",
1482
+ "Section": "Seção",
1483
+ "Cancelled": "Cancelado",
1484
+ "Component relationship architecture": "Arquitetura de componentes",
1485
+ "Platform capability architecture": "Arquitetura de capacidades",
1486
+ "Engineering layer architecture": "Arquitetura em camadas",
1487
+ "Layout suggestions": "Sugestões de disposição",
1488
+ "Arrange this view to improve connector clearance and spacing. Existing positions are preserved until then.": "Organize esta vista para melhorar as folgas e os espaçamentos das ligações. As posições atuais são preservadas até lá."
1313
1489
  }
1314
1490
  }
@@ -0,0 +1,123 @@
1
+ import { groupHeadingBoxes, occupiedBox, visibleEdgeLabel } from './edge-routing.js';
2
+ import { diagramTypeOf, getDiagram } from './diagrams/registry.js';
3
+ import { layoutLimits, layoutTargets } from './layout-spacing.js';
4
+ import { groupHeadingLayout, estimateLabelSize } from './text-layout.js';
5
+
6
+ // Tighten actual ownership subtrees bottom-up; never infer ownership from containment.
7
+ export function tightenGroups(graph) {
8
+ const groups=graph.groups??[], limits=layoutLimits(getDiagram(diagramTypeOf(graph))), done=new Set();
9
+ const fit=group=>{
10
+ if(done.has(group.id))return; done.add(group.id);
11
+ const children=groups.filter(g=>g.parentId===group.id); children.forEach(fit);
12
+ const members=[...children,...graph.nodes.filter(n=>n.groupId===group.id)]; if(!members.length)return;
13
+ const heading=groupHeadingLayout(group), top=heading.height+limits.groupHeadingGap;
14
+ const x=Math.max(0,Math.min(...members.map(n=>n.position.x))-limits.groupInset), y=Math.max(0,Math.min(...members.map(n=>n.position.y))-top);
15
+ const right=Math.max(...members.map(n=>n.position.x+n.size.width))+limits.groupInset, bottom=Math.max(...members.map(n=>n.position.y+n.size.height))+limits.groupInset;
16
+ group.position={x,y}; group.size={width:Math.max(right-x,heading.width+2*limits.groupInset),height:bottom-y};
17
+ };
18
+ groups.forEach(fit);return graph;
19
+ }
20
+
21
+ // Sweep genuinely empty horizontal/vertical slabs. A subtree moves intact when the cut is outside it;
22
+ // a boundary spanning the cut shrinks with its members. Routing validates the reclaimed channel.
23
+ export function* compactCandidates(input) {
24
+ const type=diagramTypeOf(input); if(type==='sequence')return;
25
+ const diagram=getDiagram(type), limits=layoutLimits(diagram), target=layoutTargets(diagram);
26
+ yield* compactChannels(input,type,limits,target);
27
+ if (['architecture','deployment','er','dataflow'].includes(type)) {
28
+ const groups=input.groups??[];
29
+ const descendants=id=>{const ids=new Set([id]);for(let size=0;size!==ids.size;){size=ids.size;for(const group of groups)if(ids.has(group.parentId))ids.add(group.id);}return ids;};
30
+ // Ownership frames can occupy unused space beside a connected frame while their subtree remains rigid.
31
+ // Authored ranks/order and type-specific partitions are retained; the caller rechecks semantic geometry.
32
+ if (type === 'architecture' && !input.layout?.primaryPath?.length) {
33
+ const roots = groups.filter(g=>!g.parentId).map(item=>{const ids=descendants(item.id);return {item,groups:groups.filter(g=>ids.has(g.id)),nodes:input.nodes.filter(n=>ids.has(n.groupId))};});
34
+ const owner = new Map(roots.flatMap(unit=>unit.nodes.map(node=>[node.id,unit]))), proposals=[];
35
+ const estimate = graph => {
36
+ const all=[...graph.nodes,...(graph.groups??[])], byId=new Map(graph.nodes.map(n=>[n.id,n]));
37
+ const area=(Math.max(...all.map(n=>n.position.x+n.size.width))-Math.min(...all.map(n=>n.position.x)))*(Math.max(...all.map(n=>n.position.y+n.size.height))-Math.min(...all.map(n=>n.position.y)));
38
+ const nodeArea=graph.nodes.reduce((sum,n)=>sum+n.size.width*n.size.height,0),unit=Math.sqrt(nodeArea/graph.nodes.length);
39
+ const length=graph.edges.reduce((sum,e)=>{const a=byId.get(e.source),b=byId.get(e.target);return sum+Math.abs(a.position.x+a.size.width/2-b.position.x-b.size.width/2)+Math.abs(a.position.y+a.size.height/2-b.position.y-b.size.height/2);},0);
40
+ return area/nodeArea+length/(Math.max(1,graph.edges.length)*unit);
41
+ };
42
+ for (const unit of roots) {
43
+ if(unit.nodes.some(n=>n.layout?.order!==undefined||n.layout?.rank!==undefined))continue;
44
+ const peers=new Set(input.edges.flatMap(e=>owner.get(e.source)===unit?[owner.get(e.target)]:owner.get(e.target)===unit?[owner.get(e.source)]:[]).filter(p=>p&&p!==unit));
45
+ for(const peer of peers) for(const axis of ['x','y']) for(const after of [false,true]) {
46
+ const across=axis==='x'?'y':'x',extent=axis==='x'?'width':'height', gap=target.nodeGap;
47
+ const position={...peer.item.position,[axis]:after?peer.item.position[axis]+peer.item.size[extent]+gap:peer.item.position[axis]-unit.item.size[extent]-gap};
48
+ if(position.x<0||position.y<0)continue;
49
+ const rect={...position,...unit.item.size};
50
+ if(roots.some(other=>other!==unit&&rect.x<other.item.position.x+other.item.size.width+limits.groupGap&&other.item.position.x<rect.x+rect.width+limits.groupGap&&rect.y<other.item.position.y+other.item.size.height+limits.groupGap&&other.item.position.y<rect.y+rect.height+limits.groupGap))continue;
51
+ const ids=new Set([...unit.groups,...unit.nodes].map(n=>n.id)),dx=position.x-unit.item.position.x,dy=position.y-unit.item.position.y;
52
+ const geometry=items=>items.map(n=>({id:n.id,size:n.size,position:ids.has(n.id)?{x:n.position.x+dx,y:n.position.y+dy}:n.position}));
53
+ const score=estimate({...input,nodes:geometry(input.nodes),groups:geometry(groups)});
54
+ if(score<estimate(input)-.001)proposals.push({ids,dx,dy,score,id:unit.item.id+':'+peer.item.id+':'+axis+':'+after});
55
+ }
56
+ }
57
+ proposals.sort((a,b)=>a.score-b.score||a.id.localeCompare(b.id));
58
+ for(const proposal of proposals.slice(0,12)) {
59
+ const graph=structuredClone(input);
60
+ for(const member of [...graph.nodes,...graph.groups])if(proposal.ids.has(member.id)){member.position.x+=proposal.dx;member.position.y+=proposal.dy;}
61
+ yield graph;
62
+ }
63
+ }
64
+ const rows=new Map();
65
+ for(const item of [...groups,...input.nodes].sort((a,b)=>a.id.localeCompare(b.id))) {
66
+ const group=groups.includes(item), scope=group?item.parentId:item.groupId;
67
+ const key=`${scope??''}:${item.position.y}`;
68
+ const ids=group?descendants(item.id):new Set();
69
+ const nodes=group?input.nodes.filter(n=>ids.has(n.groupId)):[item];
70
+ const unit={item,groups:group?groups.filter(g=>ids.has(g.id)):[],nodes};
71
+ rows.set(key,[...(rows.get(key)??[]),unit]);
72
+ }
73
+ for(const [,items] of [...rows].sort(([a],[b])=>a.localeCompare(b))) {
74
+ if(items.length<2 || items.some(unit=>unit.nodes.some(n=>n.layout?.order!==undefined)))continue;
75
+ const row=items.sort((a,b)=>a.item.position.x-b.item.position.x||a.item.id.localeCompare(b.item.id));
76
+ for(let i=0;i<row.length;i++)for(let j=i+1;j<row.length;j++) {
77
+ const graph=structuredClone(input), byId=new Map([...graph.nodes,...(graph.groups??[])].map(n=>[n.id,n]));
78
+ const order=[...row];[order[i],order[j]]=[order[j],order[i]];
79
+ let x=row[0].item.position.x;
80
+ for(let k=0;k<order.length;k++) {
81
+ const unit=order[k],dx=x-unit.item.position.x;
82
+ for(const member of [...unit.groups,...unit.nodes])byId.get(member.id).position.x+=dx;
83
+ const gap=k<row.length-1?Math.max(target.nodeGap,row[k+1].item.position.x-row[k].item.position.x-row[k].item.size.width):0;
84
+ x+=unit.item.size.width+gap;
85
+ }
86
+ yield graph;
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ function* compactChannels(input,type,limits,target) {
93
+ const tight=tightenGroups(structuredClone(input));
94
+ if(JSON.stringify(tight.groups)!==JSON.stringify(input.groups))yield tight;
95
+ const boxes=[...input.nodes.map(n=>occupiedBox(n,type)),...(input.groups??[]).flatMap(groupHeadingBoxes)];
96
+ const gaps=[];
97
+ for(const [axis,extent] of [['x','width'],['y','height']]) {
98
+ const labelSpan=Math.max(0,...input.edges.map(e=>estimateLabelSize(visibleEdgeLabel(e,type,input.meta.locale))[extent]));
99
+ const layerGap = axis === 'y' && ['class', 'state', 'flowchart'].includes(type) ? target.layerGap : target.nodeGap;
100
+ const keep=Math.max(layerGap,labelSpan+2*limits.labelGap);
101
+ const intervals=boxes.map(r=>[r[axis],r[axis]+r[extent]]).sort((a,b)=>a[0]-b[0]);
102
+ let end=intervals[0]?.[1]??0;
103
+ for(const [start,last] of intervals) {
104
+ if(start-end>keep+1)gaps.push({axis,extent,cut:start,remove:start-end-keep});
105
+ end=Math.max(end,last);
106
+ }
107
+ }
108
+ gaps.sort((a,b)=>b.remove-a.remove||a.axis.localeCompare(b.axis)||a.cut-b.cut);
109
+ for(const gap of gaps) for(const fraction of [1,.5]) {
110
+ const graph=structuredClone(input), delta=gap.remove*fraction;
111
+ const follow = value => value >= gap.cut ? value-delta : value > gap.cut-delta ? gap.cut-delta : value;
112
+ for(const node of graph.nodes)if(node.position[gap.axis]>=gap.cut)node.position[gap.axis]-=delta;
113
+ for(const edge of graph.edges) {
114
+ if(edge.route?.via) edge.route.via=edge.route.via.map(point=>({...point,[gap.axis]:follow(point[gap.axis])}));
115
+ if(edge.route?.labelAt) edge.route.labelAt={...edge.route.labelAt,[gap.axis]:follow(edge.route.labelAt[gap.axis])};
116
+ }
117
+ for(const group of graph.groups??[]) {
118
+ if(group.position[gap.axis]>=gap.cut)group.position[gap.axis]-=delta;
119
+ else if(group.position[gap.axis]+group.size[gap.extent]>=gap.cut)group.size[gap.extent]-=delta;
120
+ }
121
+ yield graph;
122
+ }
123
+ }
@@ -1,8 +1,8 @@
1
+ import { hasOverviewContent, overviewCardLayout } from './architecture-overview.js';
1
2
  import { getDiagram } from './diagrams/registry.js';
2
3
  import { sequenceHeaderHeight } from './diagrams/sequence.js';
3
- import { cardTextLayout, layoutText } from './text-layout.js';
4
- import { TYPOGRAPHY, kindLabels } from './visual-style.js';
5
- import { translate } from './i18n.js';
4
+ import { CARD, cardSourceText, cardTag, cardTextLayout, layoutText } from './text-layout.js';
5
+ import { TYPOGRAPHY } from './visual-style.js';
6
6
  import { STATE_ACTIONS, STATE_ACTION_INSET, stateActivities } from './diagrams/state.js';
7
7
 
8
8
  // System fonts vary: reserve width beyond the estimate, then verify actual glyphs in the browser.
@@ -10,6 +10,7 @@ const singleLineWidth = (text, font) => Math.ceil(layoutText(String(text ?? '').
10
10
  const rounded = size => Object.fromEntries(Object.entries(size).map(([key, value]) => [key, Math.ceil(value)]));
11
11
 
12
12
  export function minimumNodeSize(node, type, locale = 'en') {
13
+ if (type === 'architecture' && hasOverviewContent(node)) return { width: 300, height: overviewCardLayout(node, node.size?.width ?? 300).minHeight };
13
14
  const diagram = getDiagram(type), title = singleLineWidth(node.label, TYPOGRAPHY.title), body = singleLineWidth(node.subtitle, TYPOGRAPHY.body);
14
15
  if (type === 'state' && ['initial', 'final'].includes(node.kind)) {
15
16
  if (!node.subtitle) return { width: 28, height: 28 };
@@ -22,7 +23,8 @@ export function minimumNodeSize(node, type, locale = 'en') {
22
23
  const heading = layoutText(node.label, width - 24, TYPOGRAPHY.title, 29), subtitle = layoutText(node.subtitle, width - 24, TYPOGRAPHY.body, 23.2);
23
24
  return rounded({ width, height: Math.max(72, heading.height + (subtitle.height ? 5 + subtitle.height : 0) + 16) + stateActivities({ ...node, size: { width, height: 0 } }).height });
24
25
  }
25
- if (diagram.sequence || node.kind === 'actor') return { width: Math.max(160, title + 28, body + 28), height: diagram.sequence ? sequenceHeaderHeight(node) + 48 : 104 + (node.subtitle ? 24 : 0) };
26
+ if (diagram.sequence) { const width = Math.max(160, Math.min(280, Math.max(title, body) + 28)); return { width, height: sequenceHeaderHeight({ ...node, size: { width } }) + 48 }; }
27
+ if (node.kind === 'actor') return { width: Math.max(160, title + 28, body + 28), height: diagram.sequence ? sequenceHeaderHeight(node) + 48 : 104 + (node.subtitle ? 24 : 0) };
26
28
  if (diagram.cardinalities) {
27
29
  const fields = node.fields.map(field => {
28
30
  const name = singleLineWidth(field.name, TYPOGRAPHY.body), type = singleLineWidth(field.type, TYPOGRAPHY.body);
@@ -35,13 +37,17 @@ export function minimumNodeSize(node, type, locale = 'en') {
35
37
  return rounded({ width: Math.max(240, title + 26, body + 26, ...[...attributes, ...methods].map(value => singleLineWidth(value, TYPOGRAPHY.body) + 26)),
36
38
  height: TYPOGRAPHY.classHeader + (node.subtitle ? 24 : 0) + (attributes.length ? attributes.length * TYPOGRAPHY.classRow + 13 : 30) + (methods.length ? methods.length * TYPOGRAPHY.classRow + 13 : 30) });
37
39
  }
38
- const size = { width: Math.max(240, Math.min(480, Math.max(title, body) + 60)), height: 100 };
39
40
  if (diagram.cardLayout) {
40
- const inset = diagram.contentInset?.({ ...node, size }) ?? 0;
41
- size.width = Math.max(size.width, singleLineWidth(translate(locale, kindLabels[node.kind] ?? node.kind), TYPOGRAPHY.small) + 70 + 2 * inset);
42
- size.height = Math.max(100, cardTextLayout({ ...node, size: { ...size, width: size.width - 2 * (diagram.contentInset?.({ ...node, size }) ?? 0) } }).minHeight);
41
+ // One-line title beside the plate and kind tag where the width cap allows; longer text wraps and the card grows taller.
42
+ const corner = diagram.cardCorner?.(node) ?? 0;
43
+ const needed = Math.max(title + cardTag(node, locale).width + CARD.tagGap + corner, body, singleLineWidth(cardSourceText(node), TYPOGRAPHY.small)) + CARD.textX + CARD.pad;
44
+ // Outlines with slanted sides (hexagon, parallelogram, octagon) inset the text on both sides.
45
+ const inset = diagram.contentInset?.({ ...node, size: { width: Math.min(CARD.maxWidth, needed + 56), height: 0 } }) ?? 0;
46
+ const size = { width: Math.max(240, Math.min(CARD.maxWidth, needed + 2 * inset)), height: 0 };
47
+ size.height = cardTextLayout({ ...node, size: { ...size, width: size.width - 2 * (diagram.contentInset?.({ ...node, size }) ?? 0) } }, locale, false, corner).minHeight;
43
48
  return rounded(size);
44
49
  }
50
+ const size = { width: Math.max(240, Math.min(480, Math.max(title, body) + 60)), height: type === 'flowchart' ? 72 : 100 };
45
51
  const textArea = diagram.textArea ?? (node => ({ width: node.size.width - 36, height: node.size.height - 20 }));
46
52
  // All supported safety regions grow monotonically with height. Bounded expansion also handles diamond/ellipse insets.
47
53
  for (let i = 0; i < 16; i++) {
@@ -0,0 +1,6 @@
1
+ // Persist only the layout version; callers of preserve never upgrade it implicitly.
2
+ export const LAYOUT_VERSION = 'templates-v4-clearance-compact-elkjs-0.11.0';
3
+ export const currentLayout = graph => /^templates-v([4-9]|\d{2,})-/.test(graph.layout?.version ?? '');
4
+ export const alignedLayout = graph => /^templates-v([3-9]|\d{2,})-/.test(graph.layout?.version ?? '');
5
+ export const tieredLayout = graph => /^templates-v([2-9]|\d{2,})-/.test(graph.layout?.version ?? '');
6
+ export const layoutWeights = Object.freeze({ bends: .5, crossings: 2 });
@@ -1,7 +1,13 @@
1
+ import { currentLayout, alignedLayout, tieredLayout } from './layout-policy.js';
2
+ import { clearanceLimits, nodeClearance, boundaryClearance } from './route-clearance.js';
3
+ import { deploymentTierOf } from './layout-semantics.js';
4
+ import { presentationGraph } from './presentation-graph.js';
5
+ import { isArchitectureOverview } from './view-identity.js';
6
+ import { overviewGeometryErrors, overviewSections } from './architecture-overview.js';
1
7
  import { auditGraphLayout, boxDistance, occupiedBox, graphBounds, segmentCrossesBox } from './edge-routing.js';
2
- import { diagramTypeOf, getDiagram } from './diagrams/registry.js';
3
- import { cardTextLayout, layoutText, groupHeadingLayout } from './text-layout.js';
4
- import { LAYOUT_LIMITS } from './layout-spacing.js';
8
+ import { cardText, compactCards, diagramTypeOf, getDiagram } from './diagrams/registry.js';
9
+ import { layoutText, groupHeadingLayout } from './text-layout.js';
10
+ import { layeredDown, layoutLimits } from './layout-spacing.js';
5
11
  import { minimumNodeSize } from './layout-measure.js';
6
12
  import { validateGraph } from './graph-validation.js';
7
13
 
@@ -14,6 +20,8 @@ export function qualityFailure(graph, phase, message, diagnostics = []) {
14
20
  if (!diagnostics.length) diagnostics = (String(message).includes('\n- ') ? String(message).split('\n- ').slice(1) : [String(message)]).map(detail => {
15
21
  const elements = ['nodes', 'edges', 'groups'].flatMap(key => (Array.isArray(graph?.[key]) ? graph[key] : []).filter((item, index) => item && typeof item === 'object' && (
16
22
  detail.includes(`${key}[${index}]`) || new RegExp(`(^|[^\\w.-])${String(item.id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\w.-])`).test(detail))));
23
+ for (const edge of elements.filter(item => item.source && item.target)) elements.push(...(graph.nodes ?? []).filter(node => node.id === edge.source || node.id === edge.target));
24
+ elements.push(...overviewSections(graph ?? {}).filter(item => String(detail).includes(item.id)));
17
25
  return { ruleId: `${phase}.invalid`, severity: 'error', diagramType: diagramTypeOf(graph),
18
26
  elementIds: elements.map(item => item.id), measured: detail, required: `Valid ${phase} content and complete notation`,
19
27
  bounds: elements.filter(item => item.position && item.size).map(item => ({ ...item.position, ...item.size })),
@@ -26,18 +34,19 @@ export function qualityFailure(graph, phase, message, diagnostics = []) {
26
34
  } });
27
35
  }
28
36
 
29
- export function requireDiagramQuality(graph) {
37
+ export function requireDiagramQuality(graph, options = {}) {
30
38
  const semantic = validateGraph(graph, { inputOnly: true });
31
39
  const geometry = semantic.length ? [] : validateGraph(graph, { audit: false });
32
- const audit = semantic.length || geometry.length ? null : auditLayoutQuality(graph);
40
+ const audit = semantic.length || geometry.length ? null : auditLayoutQuality(graph, options);
33
41
  const errors = [...semantic, ...geometry, ...(audit?.errors ?? [])];
34
42
  const phases = { semantic: { status: semantic.length ? 'failed' : 'passed' }, geometry: { status: semantic.length ? 'not-checked' : errors.length ? 'failed' : 'passed' }, rendering: { status: 'not-checked' } };
35
43
  if (errors.length) throw qualityFailure(graph, semantic.length ? 'semantic' : 'geometry', `Diagram quality failed:\n- ${errors.join('\n- ')}`, audit?.diagnostics ?? []);
36
44
  return { ...phases, diagnostics: audit.diagnostics };
37
45
  }
38
46
 
39
- export function auditLayoutQuality(graph) {
40
- const legacy = auditGraphLayout(graph), type = diagramTypeOf(graph), diagram = getDiagram(type), limits = LAYOUT_LIMITS;
47
+ export function auditLayoutQuality(graph, { strictEdgeIds = null } = {}) {
48
+ graph = presentationGraph(graph);
49
+ const legacy = auditGraphLayout(graph), type = diagramTypeOf(graph), diagram = getDiagram(type), limits = isArchitectureOverview(graph) ? { ...layoutLimits(diagram), nodeGap: 14, labelGap: 6, parallelGap: 8, groupInset: 22, groupHeadingGap: 14 } : layoutLimits(diagram);
41
50
  const diagnostics = [...legacy.diagnostics, ...legacy.crossings];
42
51
  const issue = (ruleId, ids, measured, required, boxes, remediation) => diagnostics.push({ ruleId, severity: 'error', diagramType: type, elementIds: ids, measured, required, bounds: boxes, remediation });
43
52
  const nodeBoxes = new Map(graph.nodes.map(node => [node.id, occupiedBox(node, type)]));
@@ -60,7 +69,7 @@ export function auditLayoutQuality(graph) {
60
69
  const branches = graph.edges.filter(edge => edge.source === node.id && edge.target !== node.id);
61
70
  return { node, branches, sides: new Set(branches.map(edge => legacy.routes.get(edge.id).sourceSide)) };
62
71
  }).filter(item => item.branches.length >= 2) : [];
63
- const vertical = !['er', 'deployment', 'dataflow', 'usecase'].includes(type);
72
+ const vertical = layeredDown(type, graph.layout);
64
73
  const coordinate = vertical ? 'y' : 'x', dimension = vertical ? 'height' : 'width';
65
74
  // A layout folded into top-down columns continues at the top of the next column: a successor that sits entirely to the
66
75
  // right of its predecessor keeps the notation's reading direction; a path or hierarchy step must also start higher.
@@ -70,29 +79,34 @@ export function auditLayoutQuality(graph) {
70
79
  if (distance + .001 < limits.nodeGap) issue('semantic.rank', [first.id, second.id], distance, limits.nodeGap, [nodeBoxes.get(first.id), nodeBoxes.get(second.id)], 'Keep declared ranks in separate layers along the reading direction.');
71
80
  }
72
81
  if (vertical && graph.nodes.length > 5 && Math.max(...graph.nodes.map(node => node.position.y)) < Math.min(...graph.nodes.map(node => node.position.y + node.size.height))) issue('semantic.single-row', graph.nodes.map(node => node.id), 1, 'multiple rows', [...nodeBoxes.values()], 'Arrange responsibility layers or wrap peer nodes; tiny y offsets do not create another row.');
82
+ if (type === 'deployment' && (tieredLayout(graph) || graph.layout?.strategy?.startsWith('template-runtime-tiers') || graph.nodes.some(node => node.layout?.tier))) for (const parent of graph.nodes) for (const child of graph.nodes) {
83
+ if (deploymentTierOf(parent) >= deploymentTierOf(child)) continue;
84
+ const distance = child.position.y - parent.position.y - parent.size.height;
85
+ if (distance + .001 < limits.nodeGap) issue('semantic.deployment-tier', [parent.id, child.id], distance, limits.nodeGap, [nodeBoxes.get(parent.id), nodeBoxes.get(child.id)], 'Keep external, application, infrastructure and data tiers from top to bottom.');
86
+ }
73
87
  if (type === 'class') for (const edge of graph.edges.filter(edge => ['inheritance', 'implementation'].includes(edge.kind))) {
74
88
  const child = nodeById.get(edge.source), parent = nodeById.get(edge.target), distance = child.position.y - parent.position.y - parent.size.height;
75
- if (distance + .001 < limits.nodeGap && !continues(parent, child, true)) issue('semantic.class-hierarchy', [edge.id, parent.id, child.id], distance, limits.nodeGap, [nodeBoxes.get(parent.id), nodeBoxes.get(child.id)], 'Place the parent class or interface above its child or implementation, or continue the hierarchy at the top of the next column.');
89
+ if (distance + .001 < limits.nodeGap) issue('semantic.class-hierarchy', [edge.id, parent.id, child.id], distance, limits.nodeGap, [nodeBoxes.get(parent.id), nodeBoxes.get(child.id)], 'Place the parent class or interface above its child or implementation.');
76
90
  }
77
91
  if (type === 'state') for (const symbol of graph.nodes.filter(node => ['initial', 'final'].includes(node.kind))) {
78
92
  for (const state of graph.nodes.filter(node => !['initial', 'final'].includes(node.kind))) {
79
93
  const distance = symbol.kind === 'initial' ? state.position.y - symbol.position.y - symbol.size.height : symbol.position.y - state.position.y - state.size.height;
80
- const continued = symbol.kind === 'initial' ? continues(symbol, state, false) : continues(state, symbol, false);
81
- if (distance + .001 < limits.nodeGap && !continued) issue('semantic.state-endpoint', [symbol.id, state.id], distance, limits.nodeGap, [nodeBoxes.get(symbol.id), nodeBoxes.get(state.id)], 'Keep initial symbols above the lifecycle and final symbols below it; a column fold continues the lifecycle at the top of the next column.');
94
+ if (distance + .001 < limits.nodeGap) issue('semantic.state-endpoint', [symbol.id, state.id], distance, limits.nodeGap, [nodeBoxes.get(symbol.id), nodeBoxes.get(state.id)], 'Keep initial symbols above the lifecycle and final symbols below it.');
82
95
  }
83
96
  }
84
97
  for (const [source, target] of primary) {
85
98
  const distance = target.position[coordinate] - source.position[coordinate] - source.size[dimension];
86
- if (distance + .001 < limits.nodeGap && !continues(source, target, true)) issue('semantic.primary-path', [source.id, target.id], distance, limits.nodeGap, [nodeBoxes.get(source.id), nodeBoxes.get(target.id)], 'Keep the declared or unambiguous main path directed downwards; a column fold continues it at the top of the next column.');
99
+ if (distance + .001 < limits.nodeGap && (graph.layout?.strategy?.startsWith('template-') || !continues(source, target, true))) issue('semantic.primary-path', [source.id, target.id], distance, limits.nodeGap, [nodeBoxes.get(source.id), nodeBoxes.get(target.id)], 'Keep the declared or unambiguous main path directed downwards; a column fold continues it at the top of the next column.');
87
100
  }
88
101
  for (const { node, branches, sides } of decisions) {
89
102
  if (!['left', 'right'].every(side => sides.has(side))) issue('semantic.branch-sides', [node.id, ...branches.map(edge => edge.id)], [...sides], ['left', 'right'], [nodeBoxes.get(node.id)], 'Give alternatives separate left and right corridors while retaining their real targets and merges.');
90
103
  }
91
104
  }
105
+ const classicCard = !compactCards(graph);
92
106
  for (let i = 0; i < graph.nodes.length; i++) {
93
107
  const node = graph.nodes[i], rect = nodeBoxes.get(node.id), minimum = minimumNodeSize(node, type, graph.meta.locale);
94
108
  if (diagram.cardLayout) {
95
- const inset = diagram.contentInset?.(node) ?? 0, required = Math.max(100, cardTextLayout({ ...node, size: { ...node.size, width: node.size.width - inset * 2 } }).minHeight);
109
+ const required = cardText(node, type, graph.meta.locale, classicCard).minHeight;
96
110
  if (node.size.height < required) issue('text.node-height', [node.id], node.size.height, required, [rect], 'Increase height to keep full title and subtitle at 20/16px.');
97
111
  } else if (diagram.textArea && node.kind !== 'actor' && !['initial', 'final'].includes(node.kind)) {
98
112
  const area = diagram.textArea(node), title = layoutText(node.label, area.width, 20, 29), body = layoutText(node.subtitle, area.width, 16, 23.2), required = title.height + (body.height ? body.height + 5 : 0);
@@ -125,9 +139,16 @@ export function auditLayoutQuality(graph) {
125
139
  }
126
140
  for (let i = 0; i < graph.edges.length; i++) {
127
141
  const edge = graph.edges[i], route = legacy.routes.get(edge.id), parts = segments(route);
142
+ if (!diagram.sequence && alignedLayout(graph) && route.label) {
143
+ const point = route.labelPoint;
144
+ const distance = Math.min(...parts.map(([a, b]) => Math.hypot(point.x - Math.max(Math.min(a.x, b.x), Math.min(Math.max(a.x, b.x), point.x)), point.y - Math.max(Math.min(a.y, b.y), Math.min(Math.max(a.y, b.y), point.y)))));
145
+ if (distance > .001) issue('label.on-route', [edge.id], distance, 0, [route.labelBox], 'Keep the relationship label centred on its own straight segment.');
146
+ }
128
147
  if (!diagram.sequence) for (const [point, neighbor, hint] of [[route.points[0], route.points[1], edge.route?.via?.[0]], [route.points.at(-1), route.points.at(-2), edge.route?.via?.at(-1)]]) {
129
148
  const distance = Math.hypot(point.x - neighbor.x, point.y - neighbor.y), required = diagram.endpointStub ?? limits.endpoint;
130
- const length = hint && distance ? Math.min(distance, ((hint.x - point.x) * (neighbor.x - point.x) + (hint.y - point.y) * (neighbor.y - point.y)) / distance) : distance;
149
+ // A monotone direct segment has no turn at its guide; a bent path must still reserve the authored first-turn clearance.
150
+ const projection = hint && distance ? ((hint.x - point.x) * (neighbor.x - point.x) + (hint.y - point.y) * (neighbor.y - point.y)) / distance : distance;
151
+ const length = route.points.length === 2 && projection >= 0 ? distance : Math.min(distance, projection);
131
152
  if (length + .001 < required) issue('route.endpoint-stub', [edge.id], length, required, [{ ...point, width: 0, height: 0 }], 'Reserve a straight outward segment for the endpoint symbol.');
132
153
  }
133
154
  for (const other of graph.edges.slice(i + 1)) {
@@ -158,7 +179,32 @@ export function auditLayoutQuality(graph) {
158
179
  for (const other of groups.slice(i + 1)) if (group.parentId === other.parentId && boxDistance(rect, bounds(other)) + .001 < limits.groupGap) issue('group.sibling-gap', [group.id, other.id], boxDistance(rect, bounds(other)), limits.groupGap, [rect, bounds(other)], 'Separate sibling boundaries.');
159
180
  }
160
181
  }
182
+ const routeMetrics = [];
183
+ if (!diagram.sequence) {
184
+ const clearance = clearanceLimits(graph), probes = graph.nodes.map(node => ({ node, probe: nodeClearance(node, type) }));
185
+ for (const edge of graph.edges) {
186
+ const route = legacy.routes.get(edge.id), parts = segments(route), strict = currentLayout(graph) || strictEdgeIds?.has(edge.id);
187
+ const report = (ruleId, ids, measured, required, boxes, remediation, hard = true) => diagnostics.push({ ruleId, severity: hard && strict ? 'error' : 'warning', diagramType: type, elementIds: ids, measured, required, bounds: boxes, remediation });
188
+ let minimum = Infinity, hugging = 0;
189
+ for (const {node,probe} of probes) {
190
+ const distances = parts.flatMap(([a,b], index) => (node.id === edge.source && index === 0 || node.id === edge.target && index === parts.length-1) ? [] : [probe(a,b)]);
191
+ const distance = Math.min(...distances); minimum = Math.min(minimum,distance);
192
+ if (distance + .001 < clearance.node) report('route.node-clearance',[edge.id,node.id],distance,clearance.node,[nodeBoxes.get(node.id)],'Arrange this relation with clear space around the visible component.');
193
+ }
194
+ for (const group of graph.groups ?? []) {
195
+ const near = parts.map(([a,b])=>boundaryClearance(a,b,group)).filter(item=>item.distance+.001<clearance.boundary);
196
+ hugging += near.reduce((sum,item)=>sum+item.length,0);
197
+ if (near.length) report('route.boundary-clearance',[edge.id,group.id],Math.min(...near.map(item=>item.distance)),clearance.boundary,[bounds(group)],'Cross the boundary perpendicularly or leave a clear parallel channel.');
198
+ }
199
+ const length = parts.reduce((sum,[a,b])=>sum+Math.abs(a.x-b.x)+Math.abs(a.y-b.y),0), start=route.points[0], end=route.points.at(-1);
200
+ const direct=Math.abs(start.x-end.x)+Math.abs(start.y-end.y), bends=Math.max(0,route.points.length-2), detour=direct ? length/direct : null;
201
+ const shortSegments=parts.slice(1,-1).filter(([a,b])=>Math.abs(a.x-b.x)+Math.abs(a.y-b.y)<clearance.node-.001).length;
202
+ routeMetrics.push({edgeId:edge.id,length,bends,detour,minimumClearance:Number.isFinite(minimum)?minimum:null,boundaryHugging:hugging,shortSegments});
203
+ if (bends>4 || shortSegments || edge.source!==edge.target && detour>1.8) report('route.complexity',[edge.id],{bends,shortSegments,detour},{bends:4,shortSegments:0,detour:1.8},[{x:Math.min(...route.points.map(p=>p.x)),y:Math.min(...route.points.map(p=>p.y)),width:Math.max(...route.points.map(p=>p.x))-Math.min(...route.points.map(p=>p.x)),height:Math.max(...route.points.map(p=>p.y))-Math.min(...route.points.map(p=>p.y))}],'Try a simpler corridor or arrange nearby components; necessary detours remain supported.',false);
204
+ }
205
+ }
161
206
  // Existing shape/fragment/marker checks stay blocking; new rules carry structured locations above.
207
+ for (const detail of overviewGeometryErrors(graph, legacy.routes)) { const elements = overviewSections(graph).filter(item => detail.includes(item.id)); issue('overview.section', elements.map(item => item.id), detail, 'Complete section text and contained items', elements.map(bounds), 'Expand and re-layout the identified section without removing facts.'); }
162
208
  const errors = [...diagnostics.filter(item => item.severity === 'error').map(item => `${item.ruleId}: ${item.elementIds.join(', ')} (${JSON.stringify(item.measured)}; required ${JSON.stringify(item.required)})`)];
163
- return { ...legacy, errors, diagnostics, bounds: graphBounds(graph, legacy.routes) };
209
+ return { ...legacy, errors, diagnostics, routeMetrics, bounds: graphBounds(graph, legacy.routes) };
164
210
  }