etassia 0.1.0

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 (727) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +140 -0
  3. data/LICENSE +25 -0
  4. data/README.adoc +436 -0
  5. data/exe/etassia +7 -0
  6. data/lib/etassia/cli.rb +187 -0
  7. data/lib/etassia/commands/batch.rb +210 -0
  8. data/lib/etassia/commands/notation_loader.rb +22 -0
  9. data/lib/etassia/commands/render.rb +98 -0
  10. data/lib/etassia/commands/types.rb +33 -0
  11. data/lib/etassia/commands/version.rb +24 -0
  12. data/lib/etassia/corpus_type_names.rb +52 -0
  13. data/lib/etassia/diagram/architecture.rb +108 -0
  14. data/lib/etassia/diagram/base.rb +61 -0
  15. data/lib/etassia/diagram/block.rb +102 -0
  16. data/lib/etassia/diagram/c4.rb +337 -0
  17. data/lib/etassia/diagram/class_diagram.rb +466 -0
  18. data/lib/etassia/diagram/class_namespace.rb +17 -0
  19. data/lib/etassia/diagram/class_note.rb +17 -0
  20. data/lib/etassia/diagram/containment.rb +107 -0
  21. data/lib/etassia/diagram/er_diagram.rb +317 -0
  22. data/lib/etassia/diagram/error.rb +38 -0
  23. data/lib/etassia/diagram/flowchart.rb +419 -0
  24. data/lib/etassia/diagram/flowchart_label_text.rb +63 -0
  25. data/lib/etassia/diagram/gantt.rb +94 -0
  26. data/lib/etassia/diagram/generic_text.rb +30 -0
  27. data/lib/etassia/diagram/git_graph.rb +57 -0
  28. data/lib/etassia/diagram/info.rb +38 -0
  29. data/lib/etassia/diagram/kanban.rb +198 -0
  30. data/lib/etassia/diagram/mindmap.rb +71 -0
  31. data/lib/etassia/diagram/packet.rb +93 -0
  32. data/lib/etassia/diagram/pie.rb +115 -0
  33. data/lib/etassia/diagram/quadrant.rb +135 -0
  34. data/lib/etassia/diagram/radar.rb +65 -0
  35. data/lib/etassia/diagram/requirement.rb +168 -0
  36. data/lib/etassia/diagram/sankey.rb +217 -0
  37. data/lib/etassia/diagram/sequence.rb +299 -0
  38. data/lib/etassia/diagram/sequence_box.rb +18 -0
  39. data/lib/etassia/diagram/sequence_frame.rb +37 -0
  40. data/lib/etassia/diagram/sequence_frame_section.rb +23 -0
  41. data/lib/etassia/diagram/sequence_text.rb +69 -0
  42. data/lib/etassia/diagram/sequence_wrap.rb +23 -0
  43. data/lib/etassia/diagram/state_diagram.rb +282 -0
  44. data/lib/etassia/diagram/timeline.rb +183 -0
  45. data/lib/etassia/diagram/treemap.rb +95 -0
  46. data/lib/etassia/diagram/user_journey.rb +153 -0
  47. data/lib/etassia/diagram/xy_chart.rb +90 -0
  48. data/lib/etassia/diagram.rb +8 -0
  49. data/lib/etassia/diagram_registry.rb +34 -0
  50. data/lib/etassia/engine.rb +334 -0
  51. data/lib/etassia/error/diagram_type_error.rb +10 -0
  52. data/lib/etassia/error/layout_error.rb +12 -0
  53. data/lib/etassia/error/notation_registration_error.rb +10 -0
  54. data/lib/etassia/error/parse_error.rb +11 -0
  55. data/lib/etassia/error/pipeline_error.rb +10 -0
  56. data/lib/etassia/error/render_error.rb +12 -0
  57. data/lib/etassia/error/score_error.rb +12 -0
  58. data/lib/etassia/error.rb +10 -0
  59. data/lib/etassia/error_report.rb +69 -0
  60. data/lib/etassia/ir/data.rb +39 -0
  61. data/lib/etassia/ir/data_value.rb +35 -0
  62. data/lib/etassia/ir/dimension.rb +10 -0
  63. data/lib/etassia/ir/edge.rb +34 -0
  64. data/lib/etassia/ir/graph.rb +28 -0
  65. data/lib/etassia/ir/integrity.rb +80 -0
  66. data/lib/etassia/ir/item.rb +46 -0
  67. data/lib/etassia/ir/model.rb +56 -0
  68. data/lib/etassia/ir/node.rb +10 -0
  69. data/lib/etassia/ir/placement.rb +47 -0
  70. data/lib/etassia/ir/prepositioned.rb +25 -0
  71. data/lib/etassia/ir/prepositioned_item.rb +26 -0
  72. data/lib/etassia/ir/property_set.rb +43 -0
  73. data/lib/etassia/ir/scalar.rb +25 -0
  74. data/lib/etassia/ir/series.rb +10 -0
  75. data/lib/etassia/ir.rb +17 -0
  76. data/lib/etassia/js_math.rb +511 -0
  77. data/lib/etassia/js_number.rb +64 -0
  78. data/lib/etassia/layout/architecture.rb +1472 -0
  79. data/lib/etassia/layout/base.rb +276 -0
  80. data/lib/etassia/layout/block.rb +738 -0
  81. data/lib/etassia/layout/c4.rb +876 -0
  82. data/lib/etassia/layout/c4_boundary_metrics.rb +35 -0
  83. data/lib/etassia/layout/c4_bounds.rb +106 -0
  84. data/lib/etassia/layout/c4_intersection.rb +77 -0
  85. data/lib/etassia/layout/c4_placement.rb +135 -0
  86. data/lib/etassia/layout/c4_shape_metrics.rb +97 -0
  87. data/lib/etassia/layout/c4_shape_outline.rb +67 -0
  88. data/lib/etassia/layout/c4_stereotype.rb +30 -0
  89. data/lib/etassia/layout/c4_text.rb +54 -0
  90. data/lib/etassia/layout/class_cell_placement.rb +148 -0
  91. data/lib/etassia/layout/class_diagram.rb +1116 -0
  92. data/lib/etassia/layout/class_diagram_namespaces.rb +26 -0
  93. data/lib/etassia/layout/class_diagram_notes.rb +26 -0
  94. data/lib/etassia/layout/class_name_advances.rb +31 -0
  95. data/lib/etassia/layout/class_namespace_box.rb +21 -0
  96. data/lib/etassia/layout/class_namespace_nodes.rb +197 -0
  97. data/lib/etassia/layout/class_namespace_placement.rb +71 -0
  98. data/lib/etassia/layout/class_note.rb +44 -0
  99. data/lib/etassia/layout/class_note_nodes.rb +84 -0
  100. data/lib/etassia/layout/elk_notices.rb +48 -0
  101. data/lib/etassia/layout/elk_placement.rb +197 -0
  102. data/lib/etassia/layout/er_diagram.rb +1002 -0
  103. data/lib/etassia/layout/er_diagram_adjacency.rb +20 -0
  104. data/lib/etassia/layout/er_diagram_alignment.rb +72 -0
  105. data/lib/etassia/layout/er_diagram_compaction.rb +85 -0
  106. data/lib/etassia/layout/er_diagram_conflicts.rb +70 -0
  107. data/lib/etassia/layout/er_diagram_coordinates.rb +104 -0
  108. data/lib/etassia/layout/er_diagram_entity_size.rb +82 -0
  109. data/lib/etassia/layout/er_diagram_layering.rb +109 -0
  110. data/lib/etassia/layout/er_diagram_ordering.rb +143 -0
  111. data/lib/etassia/layout/er_diagram_placement.rb +163 -0
  112. data/lib/etassia/layout/er_diagram_placement_node.rb +22 -0
  113. data/lib/etassia/layout/error.rb +133 -0
  114. data/lib/etassia/layout/flowchart/brandes_koepf.rb +208 -0
  115. data/lib/etassia/layout/flowchart/cluster_reach.rb +70 -0
  116. data/lib/etassia/layout/flowchart/component_packing.rb +129 -0
  117. data/lib/etassia/layout/flowchart/directed_placement.rb +428 -0
  118. data/lib/etassia/layout/flowchart/edge_chains.rb +111 -0
  119. data/lib/etassia/layout/flowchart/edge_order.rb +34 -0
  120. data/lib/etassia/layout/flowchart/edge_router.rb +533 -0
  121. data/lib/etassia/layout/flowchart/label_backdrop_geometry.rb +62 -0
  122. data/lib/etassia/layout/flowchart/layer_spacing.rb +85 -0
  123. data/lib/etassia/layout/flowchart/level_edges.rb +74 -0
  124. data/lib/etassia/layout/flowchart/level_frame.rb +47 -0
  125. data/lib/etassia/layout/flowchart/minimum_rank_spacing.rb +74 -0
  126. data/lib/etassia/layout/flowchart/row_centering.rb +229 -0
  127. data/lib/etassia/layout/flowchart.rb +1290 -0
  128. data/lib/etassia/layout/gantt.rb +814 -0
  129. data/lib/etassia/layout/gantt_duration.rb +49 -0
  130. data/lib/etassia/layout/gantt_exclusions.rb +74 -0
  131. data/lib/etassia/layout/gantt_ticks.rb +165 -0
  132. data/lib/etassia/layout/git_graph.rb +1075 -0
  133. data/lib/etassia/layout/grid.rb +165 -0
  134. data/lib/etassia/layout/info.rb +113 -0
  135. data/lib/etassia/layout/kanban.rb +452 -0
  136. data/lib/etassia/layout/kanban_card_text.rb +95 -0
  137. data/lib/etassia/layout/mindmap.rb +425 -0
  138. data/lib/etassia/layout/mindmap_cose.rb +81 -0
  139. data/lib/etassia/layout/mindmap_cose_body.rb +93 -0
  140. data/lib/etassia/layout/mindmap_cose_geometry.rb +190 -0
  141. data/lib/etassia/layout/mindmap_cose_grid.rb +96 -0
  142. data/lib/etassia/layout/mindmap_cose_link.rb +42 -0
  143. data/lib/etassia/layout/mindmap_cose_relaxation.rb +166 -0
  144. data/lib/etassia/layout/mindmap_cose_seed.rb +93 -0
  145. data/lib/etassia/layout/mindmap_cose_wedge.rb +37 -0
  146. data/lib/etassia/layout/mindmap_node_size.rb +130 -0
  147. data/lib/etassia/layout/packet.rb +285 -0
  148. data/lib/etassia/layout/pie.rb +305 -0
  149. data/lib/etassia/layout/pie_legend.rb +64 -0
  150. data/lib/etassia/layout/pie_legend_entry.rb +21 -0
  151. data/lib/etassia/layout/quadrant.rb +402 -0
  152. data/lib/etassia/layout/radar.rb +442 -0
  153. data/lib/etassia/layout/requirement.rb +765 -0
  154. data/lib/etassia/layout/sankey.rb +501 -0
  155. data/lib/etassia/layout/scene.rb +18 -0
  156. data/lib/etassia/layout/sequence/actor_gaps.rb +151 -0
  157. data/lib/etassia/layout/sequence/box_shape.rb +23 -0
  158. data/lib/etassia/layout/sequence/creations.rb +73 -0
  159. data/lib/etassia/layout/sequence/frame_divider.rb +22 -0
  160. data/lib/etassia/layout/sequence/frame_placement.rb +424 -0
  161. data/lib/etassia/layout/sequence/frame_reader.rb +75 -0
  162. data/lib/etassia/layout/sequence/frame_shape.rb +45 -0
  163. data/lib/etassia/layout/sequence/geometry.rb +24 -0
  164. data/lib/etassia/layout/sequence/message_rows.rb +165 -0
  165. data/lib/etassia/layout/sequence/note.rb +16 -0
  166. data/lib/etassia/layout/sequence/note_box.rb +114 -0
  167. data/lib/etassia/layout/sequence/note_placement.rb +223 -0
  168. data/lib/etassia/layout/sequence/text_width.rb +69 -0
  169. data/lib/etassia/layout/sequence/text_wrap.rb +93 -0
  170. data/lib/etassia/layout/sequence.rb +936 -0
  171. data/lib/etassia/layout/state_diagram.rb +1277 -0
  172. data/lib/etassia/layout/timeline.rb +165 -0
  173. data/lib/etassia/layout/timeline_card.rb +142 -0
  174. data/lib/etassia/layout/timeline_flow.rb +164 -0
  175. data/lib/etassia/layout/timeline_line.rb +33 -0
  176. data/lib/etassia/layout/timeline_rounding.rb +19 -0
  177. data/lib/etassia/layout/timeline_text.rb +105 -0
  178. data/lib/etassia/layout/treemap.rb +303 -0
  179. data/lib/etassia/layout/treemap_placement.rb +52 -0
  180. data/lib/etassia/layout/treemap_squarify.rb +108 -0
  181. data/lib/etassia/layout/user_journey.rb +300 -0
  182. data/lib/etassia/layout/user_journey_face.rb +33 -0
  183. data/lib/etassia/layout/user_journey_face_geometry.rb +80 -0
  184. data/lib/etassia/layout/user_journey_geometry.rb +336 -0
  185. data/lib/etassia/layout/user_journey_legend.rb +84 -0
  186. data/lib/etassia/layout/xy_chart.rb +620 -0
  187. data/lib/etassia/layout.rb +19 -0
  188. data/lib/etassia/lint_debt_scoreboard.rb +219 -0
  189. data/lib/etassia/markdown_text/emphasis_simulator.rb +373 -0
  190. data/lib/etassia/markdown_text.rb +393 -0
  191. data/lib/etassia/notation/builtin.rb +39 -0
  192. data/lib/etassia/notation/entry.rb +15 -0
  193. data/lib/etassia/notation/mermaid/class_diagram_config.rb +42 -0
  194. data/lib/etassia/notation/mermaid/git_graph_config.rb +46 -0
  195. data/lib/etassia/notation/mermaid/ir_adapter.rb +27 -0
  196. data/lib/etassia/notation/mermaid/ir_adapters/architecture.rb +196 -0
  197. data/lib/etassia/notation/mermaid/ir_adapters/block.rb +138 -0
  198. data/lib/etassia/notation/mermaid/ir_adapters/c4.rb +258 -0
  199. data/lib/etassia/notation/mermaid/ir_adapters/class_diagram.rb +257 -0
  200. data/lib/etassia/notation/mermaid/ir_adapters/er_diagram.rb +220 -0
  201. data/lib/etassia/notation/mermaid/ir_adapters/error.rb +32 -0
  202. data/lib/etassia/notation/mermaid/ir_adapters/flowchart.rb +208 -0
  203. data/lib/etassia/notation/mermaid/ir_adapters/gantt.rb +236 -0
  204. data/lib/etassia/notation/mermaid/ir_adapters/git_graph.rb +186 -0
  205. data/lib/etassia/notation/mermaid/ir_adapters/info.rb +34 -0
  206. data/lib/etassia/notation/mermaid/ir_adapters/kanban.rb +110 -0
  207. data/lib/etassia/notation/mermaid/ir_adapters/mindmap.rb +76 -0
  208. data/lib/etassia/notation/mermaid/ir_adapters/packet.rb +51 -0
  209. data/lib/etassia/notation/mermaid/ir_adapters/pie.rb +71 -0
  210. data/lib/etassia/notation/mermaid/ir_adapters/quadrant.rb +116 -0
  211. data/lib/etassia/notation/mermaid/ir_adapters/radar.rb +142 -0
  212. data/lib/etassia/notation/mermaid/ir_adapters/requirement.rb +237 -0
  213. data/lib/etassia/notation/mermaid/ir_adapters/sankey.rb +74 -0
  214. data/lib/etassia/notation/mermaid/ir_adapters/sequence.rb +304 -0
  215. data/lib/etassia/notation/mermaid/ir_adapters/state_diagram.rb +225 -0
  216. data/lib/etassia/notation/mermaid/ir_adapters/timeline.rb +93 -0
  217. data/lib/etassia/notation/mermaid/ir_adapters/treemap.rb +143 -0
  218. data/lib/etassia/notation/mermaid/ir_adapters/user_journey.rb +127 -0
  219. data/lib/etassia/notation/mermaid/ir_adapters/xychart.rb +131 -0
  220. data/lib/etassia/notation/mermaid.rb +399 -0
  221. data/lib/etassia/notation/parsed.rb +31 -0
  222. data/lib/etassia/notation/plantuml/activity/arrow_head.rb +45 -0
  223. data/lib/etassia/notation/plantuml/activity/arrow_style.rb +53 -0
  224. data/lib/etassia/notation/plantuml/activity/bar_columns.rb +75 -0
  225. data/lib/etassia/notation/plantuml/activity/box.rb +60 -0
  226. data/lib/etassia/notation/plantuml/activity/builder.rb +77 -0
  227. data/lib/etassia/notation/plantuml/activity/canvas.rb +117 -0
  228. data/lib/etassia/notation/plantuml/activity/column_centres.rb +151 -0
  229. data/lib/etassia/notation/plantuml/activity/columns.rb +224 -0
  230. data/lib/etassia/notation/plantuml/activity/diagram.rb +35 -0
  231. data/lib/etassia/notation/plantuml/activity/edge.rb +35 -0
  232. data/lib/etassia/notation/plantuml/activity/edge_marks.rb +68 -0
  233. data/lib/etassia/notation/plantuml/activity/exits.rb +61 -0
  234. data/lib/etassia/notation/plantuml/activity/frame_marks.rb +116 -0
  235. data/lib/etassia/notation/plantuml/activity/lane_steps.rb +82 -0
  236. data/lib/etassia/notation/plantuml/activity/lane_titles.rb +46 -0
  237. data/lib/etassia/notation/plantuml/activity/layout.rb +50 -0
  238. data/lib/etassia/notation/plantuml/activity/legacy_parser.rb +181 -0
  239. data/lib/etassia/notation/plantuml/activity/legacy_row_gaps.rb +65 -0
  240. data/lib/etassia/notation/plantuml/activity/mark.rb +23 -0
  241. data/lib/etassia/notation/plantuml/activity/marks.rb +62 -0
  242. data/lib/etassia/notation/plantuml/activity/metrics.rb +169 -0
  243. data/lib/etassia/notation/plantuml/activity/new_parser/blocks.rb +166 -0
  244. data/lib/etassia/notation/plantuml/activity/new_parser.rb +177 -0
  245. data/lib/etassia/notation/plantuml/activity/node.rb +50 -0
  246. data/lib/etassia/notation/plantuml/activity/node_marks.rb +174 -0
  247. data/lib/etassia/notation/plantuml/activity/note.rb +27 -0
  248. data/lib/etassia/notation/plantuml/activity/parser.rb +51 -0
  249. data/lib/etassia/notation/plantuml/activity/placement.rb +327 -0
  250. data/lib/etassia/notation/plantuml/activity/ranking.rb +142 -0
  251. data/lib/etassia/notation/plantuml/activity/reach.rb +58 -0
  252. data/lib/etassia/notation/plantuml/activity/renderer.rb +220 -0
  253. data/lib/etassia/notation/plantuml/activity/route.rb +22 -0
  254. data/lib/etassia/notation/plantuml/activity/router.rb +308 -0
  255. data/lib/etassia/notation/plantuml/activity/row_gaps.rb +105 -0
  256. data/lib/etassia/notation/plantuml/activity/row_spread.rb +59 -0
  257. data/lib/etassia/notation/plantuml/activity/scene.rb +20 -0
  258. data/lib/etassia/notation/plantuml/activity/text.rb +36 -0
  259. data/lib/etassia/notation/plantuml/activity.rb +50 -0
  260. data/lib/etassia/notation/plantuml/arrow.rb +135 -0
  261. data/lib/etassia/notation/plantuml/caption.rb +50 -0
  262. data/lib/etassia/notation/plantuml/caption_layout.rb +97 -0
  263. data/lib/etassia/notation/plantuml/caption_rows.rb +67 -0
  264. data/lib/etassia/notation/plantuml/class_header.rb +178 -0
  265. data/lib/etassia/notation/plantuml/class_name.rb +51 -0
  266. data/lib/etassia/notation/plantuml/component.rb +37 -0
  267. data/lib/etassia/notation/plantuml/creole.rb +278 -0
  268. data/lib/etassia/notation/plantuml/creole_icons.rb +33 -0
  269. data/lib/etassia/notation/plantuml/diagram.rb +60 -0
  270. data/lib/etassia/notation/plantuml/diagram_builder.rb +442 -0
  271. data/lib/etassia/notation/plantuml/diagram_name.rb +51 -0
  272. data/lib/etassia/notation/plantuml/directives.rb +33 -0
  273. data/lib/etassia/notation/plantuml/drawing/box.rb +28 -0
  274. data/lib/etassia/notation/plantuml/drawing/face.rb +39 -0
  275. data/lib/etassia/notation/plantuml/drawing/pen.rb +105 -0
  276. data/lib/etassia/notation/plantuml/drawing/renderer.rb +36 -0
  277. data/lib/etassia/notation/plantuml/drawing/scene.rb +28 -0
  278. data/lib/etassia/notation/plantuml/drawing.rb +19 -0
  279. data/lib/etassia/notation/plantuml/ebnf/blocks.rb +272 -0
  280. data/lib/etassia/notation/plantuml/ebnf/layout.rb +117 -0
  281. data/lib/etassia/notation/plantuml/ebnf/model.rb +22 -0
  282. data/lib/etassia/notation/plantuml/ebnf/reader.rb +131 -0
  283. data/lib/etassia/notation/plantuml/ebnf.rb +34 -0
  284. data/lib/etassia/notation/plantuml/end_texts.rb +78 -0
  285. data/lib/etassia/notation/plantuml/entity.rb +54 -0
  286. data/lib/etassia/notation/plantuml/further.rb +38 -0
  287. data/lib/etassia/notation/plantuml/gantt/layout.rb +269 -0
  288. data/lib/etassia/notation/plantuml/gantt/reader.rb +210 -0
  289. data/lib/etassia/notation/plantuml/gantt/renderer.rb +81 -0
  290. data/lib/etassia/notation/plantuml/gantt.rb +34 -0
  291. data/lib/etassia/notation/plantuml/include/blocks.rb +66 -0
  292. data/lib/etassia/notation/plantuml/include/finder.rb +61 -0
  293. data/lib/etassia/notation/plantuml/include.rb +78 -0
  294. data/lib/etassia/notation/plantuml/ir_adapter/annotations.rb +40 -0
  295. data/lib/etassia/notation/plantuml/ir_adapter/links.rb +91 -0
  296. data/lib/etassia/notation/plantuml/ir_adapter/sink.rb +49 -0
  297. data/lib/etassia/notation/plantuml/ir_adapter/structure.rb +86 -0
  298. data/lib/etassia/notation/plantuml/ir_adapter.rb +32 -0
  299. data/lib/etassia/notation/plantuml/ir_reader/annotations.rb +51 -0
  300. data/lib/etassia/notation/plantuml/ir_reader/index.rb +49 -0
  301. data/lib/etassia/notation/plantuml/ir_reader/links.rb +133 -0
  302. data/lib/etassia/notation/plantuml/ir_reader/structure.rb +83 -0
  303. data/lib/etassia/notation/plantuml/ir_reader.rb +42 -0
  304. data/lib/etassia/notation/plantuml/junction.rb +20 -0
  305. data/lib/etassia/notation/plantuml/klass.rb +41 -0
  306. data/lib/etassia/notation/plantuml/label_clearance.rb +67 -0
  307. data/lib/etassia/notation/plantuml/layout.rb +2806 -0
  308. data/lib/etassia/notation/plantuml/member.rb +37 -0
  309. data/lib/etassia/notation/plantuml/member_icon.rb +87 -0
  310. data/lib/etassia/notation/plantuml/member_icon_elements.rb +91 -0
  311. data/lib/etassia/notation/plantuml/member_pitch.rb +76 -0
  312. data/lib/etassia/notation/plantuml/member_rule.rb +48 -0
  313. data/lib/etassia/notation/plantuml/network/grid.rb +170 -0
  314. data/lib/etassia/notation/plantuml/network/layout.rb +158 -0
  315. data/lib/etassia/notation/plantuml/network/model.rb +22 -0
  316. data/lib/etassia/notation/plantuml/network/placement.rb +64 -0
  317. data/lib/etassia/notation/plantuml/network/reader.rb +126 -0
  318. data/lib/etassia/notation/plantuml/network/shapes.rb +109 -0
  319. data/lib/etassia/notation/plantuml/network.rb +37 -0
  320. data/lib/etassia/notation/plantuml/node_graph/archimate.rb +49 -0
  321. data/lib/etassia/notation/plantuml/node_graph/box_reader.rb +86 -0
  322. data/lib/etassia/notation/plantuml/node_graph/cloud.rb +73 -0
  323. data/lib/etassia/notation/plantuml/node_graph/crows_foot.rb +85 -0
  324. data/lib/etassia/notation/plantuml/node_graph/curve.rb +66 -0
  325. data/lib/etassia/notation/plantuml/node_graph/data_reader.rb +77 -0
  326. data/lib/etassia/notation/plantuml/node_graph/data_shape.rb +39 -0
  327. data/lib/etassia/notation/plantuml/node_graph/detour.rb +98 -0
  328. data/lib/etassia/notation/plantuml/node_graph/edge.rb +26 -0
  329. data/lib/etassia/notation/plantuml/node_graph/entity_metrics.rb +49 -0
  330. data/lib/etassia/notation/plantuml/node_graph/entity_reader.rb +65 -0
  331. data/lib/etassia/notation/plantuml/node_graph/entity_shape.rb +111 -0
  332. data/lib/etassia/notation/plantuml/node_graph/figures.rb +92 -0
  333. data/lib/etassia/notation/plantuml/node_graph/frame_ports.rb +73 -0
  334. data/lib/etassia/notation/plantuml/node_graph/graph.rb +40 -0
  335. data/lib/etassia/notation/plantuml/node_graph/heads.rb +89 -0
  336. data/lib/etassia/notation/plantuml/node_graph/label_fan.rb +76 -0
  337. data/lib/etassia/notation/plantuml/node_graph/label_placer.rb +310 -0
  338. data/lib/etassia/notation/plantuml/node_graph/label_waypoint.rb +47 -0
  339. data/lib/etassia/notation/plantuml/node_graph/layout.rb +293 -0
  340. data/lib/etassia/notation/plantuml/node_graph/link.rb +78 -0
  341. data/lib/etassia/notation/plantuml/node_graph/macros.rb +39 -0
  342. data/lib/etassia/notation/plantuml/node_graph/metrics.rb +113 -0
  343. data/lib/etassia/notation/plantuml/node_graph/node.rb +44 -0
  344. data/lib/etassia/notation/plantuml/node_graph/note_tail.rb +58 -0
  345. data/lib/etassia/notation/plantuml/node_graph/packer.rb +126 -0
  346. data/lib/etassia/notation/plantuml/node_graph/parser.rb +309 -0
  347. data/lib/etassia/notation/plantuml/node_graph/paths.rb +48 -0
  348. data/lib/etassia/notation/plantuml/node_graph/ranking.rb +231 -0
  349. data/lib/etassia/notation/plantuml/node_graph/reader.rb +53 -0
  350. data/lib/etassia/notation/plantuml/node_graph/references.rb +38 -0
  351. data/lib/etassia/notation/plantuml/node_graph/renderer.rb +160 -0
  352. data/lib/etassia/notation/plantuml/node_graph/scene.rb +22 -0
  353. data/lib/etassia/notation/plantuml/node_graph/shapes.rb +302 -0
  354. data/lib/etassia/notation/plantuml/node_graph/skipped_ranks.rb +61 -0
  355. data/lib/etassia/notation/plantuml/node_graph/tab.rb +68 -0
  356. data/lib/etassia/notation/plantuml/node_graph/usecase_ellipse.rb +100 -0
  357. data/lib/etassia/notation/plantuml/node_graph.rb +38 -0
  358. data/lib/etassia/notation/plantuml/note.rb +27 -0
  359. data/lib/etassia/notation/plantuml/note_layout.rb +257 -0
  360. data/lib/etassia/notation/plantuml/note_outline.rb +68 -0
  361. data/lib/etassia/notation/plantuml/objects/map_rows.rb +46 -0
  362. data/lib/etassia/notation/plantuml/objects/reader.rb +208 -0
  363. data/lib/etassia/notation/plantuml/objects.rb +39 -0
  364. data/lib/etassia/notation/plantuml/package.rb +45 -0
  365. data/lib/etassia/notation/plantuml/package_color.rb +32 -0
  366. data/lib/etassia/notation/plantuml/package_frames.rb +178 -0
  367. data/lib/etassia/notation/plantuml/parser.rb +622 -0
  368. data/lib/etassia/notation/plantuml/portion_visibility.rb +91 -0
  369. data/lib/etassia/notation/plantuml/qualified_association_layout.rb +332 -0
  370. data/lib/etassia/notation/plantuml/qualifier_box.rb +78 -0
  371. data/lib/etassia/notation/plantuml/rank_chain.rb +527 -0
  372. data/lib/etassia/notation/plantuml/rank_hierarchy.rb +347 -0
  373. data/lib/etassia/notation/plantuml/relation.rb +103 -0
  374. data/lib/etassia/notation/plantuml/relation_label_separator.rb +199 -0
  375. data/lib/etassia/notation/plantuml/relation_path_points.rb +84 -0
  376. data/lib/etassia/notation/plantuml/renderer.rb +490 -0
  377. data/lib/etassia/notation/plantuml/scene.rb +134 -0
  378. data/lib/etassia/notation/plantuml/segment_box.rb +85 -0
  379. data/lib/etassia/notation/plantuml/sequence/activation.rb +28 -0
  380. data/lib/etassia/notation/plantuml/sequence/appearance.rb +71 -0
  381. data/lib/etassia/notation/plantuml/sequence/arrow_end.rb +23 -0
  382. data/lib/etassia/notation/plantuml/sequence/arrow_marks.rb +111 -0
  383. data/lib/etassia/notation/plantuml/sequence/arrow_style.rb +42 -0
  384. data/lib/etassia/notation/plantuml/sequence/arrow_syntax.rb +122 -0
  385. data/lib/etassia/notation/plantuml/sequence/bar_tracker.rb +59 -0
  386. data/lib/etassia/notation/plantuml/sequence/box.rb +23 -0
  387. data/lib/etassia/notation/plantuml/sequence/chrome.rb +60 -0
  388. data/lib/etassia/notation/plantuml/sequence/chrome_rows.rb +184 -0
  389. data/lib/etassia/notation/plantuml/sequence/create.rb +34 -0
  390. data/lib/etassia/notation/plantuml/sequence/delay.rb +19 -0
  391. data/lib/etassia/notation/plantuml/sequence/destroy.rb +20 -0
  392. data/lib/etassia/notation/plantuml/sequence/diagram.rb +86 -0
  393. data/lib/etassia/notation/plantuml/sequence/divider.rb +19 -0
  394. data/lib/etassia/notation/plantuml/sequence/edge.rb +53 -0
  395. data/lib/etassia/notation/plantuml/sequence/embedded.rb +77 -0
  396. data/lib/etassia/notation/plantuml/sequence/fill.rb +56 -0
  397. data/lib/etassia/notation/plantuml/sequence/fragment.rb +28 -0
  398. data/lib/etassia/notation/plantuml/sequence/fragment_shape.rb +138 -0
  399. data/lib/etassia/notation/plantuml/sequence/head_style.rb +43 -0
  400. data/lib/etassia/notation/plantuml/sequence/ir_adapter/blocks.rb +73 -0
  401. data/lib/etassia/notation/plantuml/sequence/ir_adapter/fills.rb +24 -0
  402. data/lib/etassia/notation/plantuml/sequence/ir_adapter/items.rb +48 -0
  403. data/lib/etassia/notation/plantuml/sequence/ir_adapter/messages.rb +62 -0
  404. data/lib/etassia/notation/plantuml/sequence/ir_adapter/participants.rb +42 -0
  405. data/lib/etassia/notation/plantuml/sequence/ir_adapter/settings.rb +54 -0
  406. data/lib/etassia/notation/plantuml/sequence/ir_adapter.rb +38 -0
  407. data/lib/etassia/notation/plantuml/sequence/ir_reader/blocks.rb +77 -0
  408. data/lib/etassia/notation/plantuml/sequence/ir_reader/fills.rb +27 -0
  409. data/lib/etassia/notation/plantuml/sequence/ir_reader/index.rb +36 -0
  410. data/lib/etassia/notation/plantuml/sequence/ir_reader/items.rb +35 -0
  411. data/lib/etassia/notation/plantuml/sequence/ir_reader/messages.rb +70 -0
  412. data/lib/etassia/notation/plantuml/sequence/ir_reader/participants.rb +42 -0
  413. data/lib/etassia/notation/plantuml/sequence/ir_reader/settings.rb +74 -0
  414. data/lib/etassia/notation/plantuml/sequence/ir_reader.rb +38 -0
  415. data/lib/etassia/notation/plantuml/sequence/keyword_defaults.rb +28 -0
  416. data/lib/etassia/notation/plantuml/sequence/layout.rb +1567 -0
  417. data/lib/etassia/notation/plantuml/sequence/message.rb +97 -0
  418. data/lib/etassia/notation/plantuml/sequence/message_wrap.rb +77 -0
  419. data/lib/etassia/notation/plantuml/sequence/note.rb +61 -0
  420. data/lib/etassia/notation/plantuml/sequence/note_fill.rb +35 -0
  421. data/lib/etassia/notation/plantuml/sequence/note_geometry.rb +126 -0
  422. data/lib/etassia/notation/plantuml/sequence/note_shape.rb +51 -0
  423. data/lib/etassia/notation/plantuml/sequence/numbered_label.rb +57 -0
  424. data/lib/etassia/notation/plantuml/sequence/numbering.rb +49 -0
  425. data/lib/etassia/notation/plantuml/sequence/outline.rb +204 -0
  426. data/lib/etassia/notation/plantuml/sequence/page_break.rb +17 -0
  427. data/lib/etassia/notation/plantuml/sequence/paint.rb +79 -0
  428. data/lib/etassia/notation/plantuml/sequence/parallel_message.rb +19 -0
  429. data/lib/etassia/notation/plantuml/sequence/parser.rb +844 -0
  430. data/lib/etassia/notation/plantuml/sequence/participant.rb +27 -0
  431. data/lib/etassia/notation/plantuml/sequence/picture.rb +29 -0
  432. data/lib/etassia/notation/plantuml/sequence/ref.rb +24 -0
  433. data/lib/etassia/notation/plantuml/sequence/ref_shape.rb +74 -0
  434. data/lib/etassia/notation/plantuml/sequence/refusals.rb +47 -0
  435. data/lib/etassia/notation/plantuml/sequence/renderer.rb +501 -0
  436. data/lib/etassia/notation/plantuml/sequence/scene.rb +200 -0
  437. data/lib/etassia/notation/plantuml/sequence/skin.rb +76 -0
  438. data/lib/etassia/notation/plantuml/sequence/spacing.rb +28 -0
  439. data/lib/etassia/notation/plantuml/sequence/style.rb +151 -0
  440. data/lib/etassia/notation/plantuml/sequence/tab_size.rb +42 -0
  441. data/lib/etassia/notation/plantuml/sequence/walker.rb +1190 -0
  442. data/lib/etassia/notation/plantuml/sequence.rb +47 -0
  443. data/lib/etassia/notation/plantuml/source_lines.rb +33 -0
  444. data/lib/etassia/notation/plantuml/state/axis_solver.rb +272 -0
  445. data/lib/etassia/notation/plantuml/state/cluster_padding.rb +75 -0
  446. data/lib/etassia/notation/plantuml/state/curve.rb +73 -0
  447. data/lib/etassia/notation/plantuml/state/dot_axis.rb +248 -0
  448. data/lib/etassia/notation/plantuml/state/extent.rb +42 -0
  449. data/lib/etassia/notation/plantuml/state/hierarchy.rb +355 -0
  450. data/lib/etassia/notation/plantuml/state/label_clearance.rb +148 -0
  451. data/lib/etassia/notation/plantuml/state/label_reach.rb +139 -0
  452. data/lib/etassia/notation/plantuml/state/layout.rb +173 -0
  453. data/lib/etassia/notation/plantuml/state/level_edges.rb +113 -0
  454. data/lib/etassia/notation/plantuml/state/look.rb +36 -0
  455. data/lib/etassia/notation/plantuml/state/note_shape.rb +73 -0
  456. data/lib/etassia/notation/plantuml/state/plane.rb +85 -0
  457. data/lib/etassia/notation/plantuml/state/ranking.rb +181 -0
  458. data/lib/etassia/notation/plantuml/state/reader.rb +310 -0
  459. data/lib/etassia/notation/plantuml/state/renderer.rb +253 -0
  460. data/lib/etassia/notation/plantuml/state/route_detour.rb +115 -0
  461. data/lib/etassia/notation/plantuml/state/route_geometry.rb +186 -0
  462. data/lib/etassia/notation/plantuml/state/routes.rb +196 -0
  463. data/lib/etassia/notation/plantuml/state/row.rb +88 -0
  464. data/lib/etassia/notation/plantuml/state/rows.rb +50 -0
  465. data/lib/etassia/notation/plantuml/state/stretch.rb +71 -0
  466. data/lib/etassia/notation/plantuml/state.rb +62 -0
  467. data/lib/etassia/notation/plantuml/stereotype_head.rb +58 -0
  468. data/lib/etassia/notation/plantuml/style/bound.rb +34 -0
  469. data/lib/etassia/notation/plantuml/style/colour.rb +41 -0
  470. data/lib/etassia/notation/plantuml/style/colour_names.rb +89 -0
  471. data/lib/etassia/notation/plantuml/style/defaults.rb +33 -0
  472. data/lib/etassia/notation/plantuml/style/inline.rb +56 -0
  473. data/lib/etassia/notation/plantuml/style/reader.rb +242 -0
  474. data/lib/etassia/notation/plantuml/style/sheet.rb +103 -0
  475. data/lib/etassia/notation/plantuml/style.rb +32 -0
  476. data/lib/etassia/notation/plantuml/style_reader.rb +160 -0
  477. data/lib/etassia/notation/plantuml/style_sheet.rb +27 -0
  478. data/lib/etassia/notation/plantuml/styled_relation_curve.rb +71 -0
  479. data/lib/etassia/notation/plantuml/timing/arrows.rb +81 -0
  480. data/lib/etassia/notation/plantuml/timing/axis.rb +100 -0
  481. data/lib/etassia/notation/plantuml/timing/layout.rb +172 -0
  482. data/lib/etassia/notation/plantuml/timing/model.rb +34 -0
  483. data/lib/etassia/notation/plantuml/timing/reader.rb +173 -0
  484. data/lib/etassia/notation/plantuml/timing/signals.rb +328 -0
  485. data/lib/etassia/notation/plantuml/timing.rb +38 -0
  486. data/lib/etassia/notation/plantuml/tree/layout.rb +276 -0
  487. data/lib/etassia/notation/plantuml/tree/mindmap_layout.rb +157 -0
  488. data/lib/etassia/notation/plantuml/tree/reader.rb +186 -0
  489. data/lib/etassia/notation/plantuml/tree/renderer.rb +67 -0
  490. data/lib/etassia/notation/plantuml/tree/role.rb +57 -0
  491. data/lib/etassia/notation/plantuml/tree/rules.rb +156 -0
  492. data/lib/etassia/notation/plantuml/tree/wbs_layout.rb +164 -0
  493. data/lib/etassia/notation/plantuml/tree.rb +41 -0
  494. data/lib/etassia/notation/plantuml/unsupported_construct_error.rb +39 -0
  495. data/lib/etassia/notation/plantuml/unsupported_constructs.rb +103 -0
  496. data/lib/etassia/notation/plantuml/usecase.rb +36 -0
  497. data/lib/etassia/notation/plantuml.rb +132 -0
  498. data/lib/etassia/notation/plantuml_data/boxes/frames.rb +101 -0
  499. data/lib/etassia/notation/plantuml_data/boxes/graph_layout.rb +348 -0
  500. data/lib/etassia/notation/plantuml_data/boxes/heads.rb +65 -0
  501. data/lib/etassia/notation/plantuml_data/boxes/highlights.rb +70 -0
  502. data/lib/etassia/notation/plantuml_data/boxes/inline_rows.rb +99 -0
  503. data/lib/etassia/notation/plantuml_data/boxes/json_reader.rb +24 -0
  504. data/lib/etassia/notation/plantuml_data/boxes/link_text.rb +94 -0
  505. data/lib/etassia/notation/plantuml_data/boxes/marks.rb +76 -0
  506. data/lib/etassia/notation/plantuml_data/boxes/ornaments.rb +130 -0
  507. data/lib/etassia/notation/plantuml_data/boxes/renderer.rb +95 -0
  508. data/lib/etassia/notation/plantuml_data/boxes/row_alignment.rb +58 -0
  509. data/lib/etassia/notation/plantuml_data/boxes/row_bend.rb +43 -0
  510. data/lib/etassia/notation/plantuml_data/boxes/scene.rb +27 -0
  511. data/lib/etassia/notation/plantuml_data/boxes/table.rb +56 -0
  512. data/lib/etassia/notation/plantuml_data/boxes/table_box.rb +355 -0
  513. data/lib/etassia/notation/plantuml_data/boxes/text.rb +101 -0
  514. data/lib/etassia/notation/plantuml_data/boxes/tree_builder.rb +82 -0
  515. data/lib/etassia/notation/plantuml_data/boxes/tree_layout.rb +82 -0
  516. data/lib/etassia/notation/plantuml_data/boxes/yaml_reader.rb +105 -0
  517. data/lib/etassia/notation/plantuml_data/openiconic.LICENSE +6 -0
  518. data/lib/etassia/notation/plantuml_data/openiconic.json +1 -0
  519. data/lib/etassia/notation/plantuml_data.rb +84 -0
  520. data/lib/etassia/notation/plantuml_salt/block.rb +80 -0
  521. data/lib/etassia/notation/plantuml_salt/box.rb +30 -0
  522. data/lib/etassia/notation/plantuml_salt/font_metrics.rb +28 -0
  523. data/lib/etassia/notation/plantuml_salt/grid_marks.rb +66 -0
  524. data/lib/etassia/notation/plantuml_salt/group_marks.rb +77 -0
  525. data/lib/etassia/notation/plantuml_salt/layout.rb +206 -0
  526. data/lib/etassia/notation/plantuml_salt/mark.rb +36 -0
  527. data/lib/etassia/notation/plantuml_salt/markup.rb +57 -0
  528. data/lib/etassia/notation/plantuml_salt/parser.rb +239 -0
  529. data/lib/etassia/notation/plantuml_salt/renderer.rb +138 -0
  530. data/lib/etassia/notation/plantuml_salt/row.rb +19 -0
  531. data/lib/etassia/notation/plantuml_salt/run.rb +34 -0
  532. data/lib/etassia/notation/plantuml_salt/scene.rb +23 -0
  533. data/lib/etassia/notation/plantuml_salt/scroll_marks.rb +94 -0
  534. data/lib/etassia/notation/plantuml_salt/tab_marks.rb +74 -0
  535. data/lib/etassia/notation/plantuml_salt/tree_marks.rb +127 -0
  536. data/lib/etassia/notation/plantuml_salt/widget.rb +40 -0
  537. data/lib/etassia/notation/plantuml_salt/widget_marks.rb +319 -0
  538. data/lib/etassia/notation/plantuml_salt.rb +66 -0
  539. data/lib/etassia/notation/plugin_failure.rb +42 -0
  540. data/lib/etassia/notation.rb +289 -0
  541. data/lib/etassia/parser/architecture.rb +25 -0
  542. data/lib/etassia/parser/atoms/delimited_run.rb +82 -0
  543. data/lib/etassia/parser/atoms/greedy_run.rb +116 -0
  544. data/lib/etassia/parser/atoms/joined.rb +47 -0
  545. data/lib/etassia/parser/atoms/trimmed_run.rb +47 -0
  546. data/lib/etassia/parser/base.rb +258 -0
  547. data/lib/etassia/parser/block.rb +32 -0
  548. data/lib/etassia/parser/builders/architecture.rb +135 -0
  549. data/lib/etassia/parser/builders/block.rb +288 -0
  550. data/lib/etassia/parser/builders/c4.rb +275 -0
  551. data/lib/etassia/parser/builders/capture_string.rb +17 -0
  552. data/lib/etassia/parser/builders/class_diagram.rb +539 -0
  553. data/lib/etassia/parser/builders/er_diagram.rb +308 -0
  554. data/lib/etassia/parser/builders/error.rb +51 -0
  555. data/lib/etassia/parser/builders/flowchart.rb +1150 -0
  556. data/lib/etassia/parser/builders/gantt.rb +267 -0
  557. data/lib/etassia/parser/builders/git_graph.rb +301 -0
  558. data/lib/etassia/parser/builders/info.rb +57 -0
  559. data/lib/etassia/parser/builders/kanban.rb +411 -0
  560. data/lib/etassia/parser/builders/mindmap.rb +271 -0
  561. data/lib/etassia/parser/builders/packet.rb +85 -0
  562. data/lib/etassia/parser/builders/pie.rb +105 -0
  563. data/lib/etassia/parser/builders/quadrant.rb +165 -0
  564. data/lib/etassia/parser/builders/radar.rb +157 -0
  565. data/lib/etassia/parser/builders/requirement.rb +230 -0
  566. data/lib/etassia/parser/builders/sankey.rb +142 -0
  567. data/lib/etassia/parser/builders/sequence.rb +431 -0
  568. data/lib/etassia/parser/builders/sequence_blocks.rb +117 -0
  569. data/lib/etassia/parser/builders/sequence_box_title.rb +57 -0
  570. data/lib/etassia/parser/builders/state_diagram.rb +364 -0
  571. data/lib/etassia/parser/builders/timeline.rb +133 -0
  572. data/lib/etassia/parser/builders/treemap.rb +138 -0
  573. data/lib/etassia/parser/builders/xy_chart.rb +148 -0
  574. data/lib/etassia/parser/c4.rb +37 -0
  575. data/lib/etassia/parser/class_diagram.rb +58 -0
  576. data/lib/etassia/parser/class_namespaces.rb +62 -0
  577. data/lib/etassia/parser/class_notes.rb +54 -0
  578. data/lib/etassia/parser/er_diagram.rb +28 -0
  579. data/lib/etassia/parser/error.rb +30 -0
  580. data/lib/etassia/parser/flowchart.rb +87 -0
  581. data/lib/etassia/parser/gantt.rb +43 -0
  582. data/lib/etassia/parser/git_graph.rb +106 -0
  583. data/lib/etassia/parser/grammars/architecture.rb +165 -0
  584. data/lib/etassia/parser/grammars/block.rb +190 -0
  585. data/lib/etassia/parser/grammars/c4.rb +229 -0
  586. data/lib/etassia/parser/grammars/class_diagram.rb +606 -0
  587. data/lib/etassia/parser/grammars/common.rb +107 -0
  588. data/lib/etassia/parser/grammars/er_diagram.rb +232 -0
  589. data/lib/etassia/parser/grammars/error.rb +32 -0
  590. data/lib/etassia/parser/grammars/flowchart.rb +1722 -0
  591. data/lib/etassia/parser/grammars/gantt.rb +198 -0
  592. data/lib/etassia/parser/grammars/git_graph.rb +234 -0
  593. data/lib/etassia/parser/grammars/info.rb +53 -0
  594. data/lib/etassia/parser/grammars/kanban.rb +417 -0
  595. data/lib/etassia/parser/grammars/mermaid_unicode_text.rb +108 -0
  596. data/lib/etassia/parser/grammars/mindmap.rb +220 -0
  597. data/lib/etassia/parser/grammars/packet.rb +71 -0
  598. data/lib/etassia/parser/grammars/pie.rb +128 -0
  599. data/lib/etassia/parser/grammars/quadrant.rb +202 -0
  600. data/lib/etassia/parser/grammars/radar.rb +158 -0
  601. data/lib/etassia/parser/grammars/requirement.rb +258 -0
  602. data/lib/etassia/parser/grammars/sankey.rb +104 -0
  603. data/lib/etassia/parser/grammars/sequence.rb +940 -0
  604. data/lib/etassia/parser/grammars/state_diagram.rb +382 -0
  605. data/lib/etassia/parser/grammars/timeline.rb +153 -0
  606. data/lib/etassia/parser/grammars/treemap.rb +122 -0
  607. data/lib/etassia/parser/grammars/xy_chart.rb +119 -0
  608. data/lib/etassia/parser/info.rb +32 -0
  609. data/lib/etassia/parser/kanban.rb +131 -0
  610. data/lib/etassia/parser/mermaid_shapes.rb +106 -0
  611. data/lib/etassia/parser/metadata_yaml.rb +452 -0
  612. data/lib/etassia/parser/mindmap.rb +92 -0
  613. data/lib/etassia/parser/native/builders/architecture.rb +103 -0
  614. data/lib/etassia/parser/native/builders/block.rb +68 -0
  615. data/lib/etassia/parser/native/builders/c4.rb +16 -0
  616. data/lib/etassia/parser/native/builders/class_diagram.rb +63 -0
  617. data/lib/etassia/parser/native/builders/er_diagram.rb +47 -0
  618. data/lib/etassia/parser/native/builders/flowchart.rb +58 -0
  619. data/lib/etassia/parser/native/builders/gantt.rb +35 -0
  620. data/lib/etassia/parser/native/builders/gitgraph.rb +35 -0
  621. data/lib/etassia/parser/native/builders/info.rb +30 -0
  622. data/lib/etassia/parser/native/builders/kanban.rb +37 -0
  623. data/lib/etassia/parser/native/builders/mindmap.rb +26 -0
  624. data/lib/etassia/parser/native/builders/packet.rb +63 -0
  625. data/lib/etassia/parser/native/builders/pie.rb +82 -0
  626. data/lib/etassia/parser/native/builders/quadrant.rb +27 -0
  627. data/lib/etassia/parser/native/builders/radar.rb +84 -0
  628. data/lib/etassia/parser/native/builders/requirement.rb +25 -0
  629. data/lib/etassia/parser/native/builders/sankey.rb +18 -0
  630. data/lib/etassia/parser/native/builders/sequence.rb +48 -0
  631. data/lib/etassia/parser/native/builders/state_diagram.rb +61 -0
  632. data/lib/etassia/parser/native/builders/timeline.rb +46 -0
  633. data/lib/etassia/parser/native/builders/treemap.rb +60 -0
  634. data/lib/etassia/parser/native/builders/user_journey.rb +83 -0
  635. data/lib/etassia/parser/native/builders/xy_chart.rb +94 -0
  636. data/lib/etassia/parser/native/declined.rb +12 -0
  637. data/lib/etassia/parser/native/mermaid.json +1 -0
  638. data/lib/etassia/parser/native/text.rb +31 -0
  639. data/lib/etassia/parser/native.rb +99 -0
  640. data/lib/etassia/parser/packet.rb +56 -0
  641. data/lib/etassia/parser/pie.rb +32 -0
  642. data/lib/etassia/parser/quadrant.rb +77 -0
  643. data/lib/etassia/parser/radar.rb +99 -0
  644. data/lib/etassia/parser/requirement.rb +32 -0
  645. data/lib/etassia/parser/sankey.rb +47 -0
  646. data/lib/etassia/parser/sequence.rb +34 -0
  647. data/lib/etassia/parser/state_diagram.rb +29 -0
  648. data/lib/etassia/parser/timeline.rb +40 -0
  649. data/lib/etassia/parser/treemap.rb +25 -0
  650. data/lib/etassia/parser/user_journey.rb +253 -0
  651. data/lib/etassia/parser/xy_chart.rb +98 -0
  652. data/lib/etassia/parser.rb +18 -0
  653. data/lib/etassia/renderer/architecture.rb +232 -0
  654. data/lib/etassia/renderer/architecture_edge_router.rb +379 -0
  655. data/lib/etassia/renderer/base.rb +295 -0
  656. data/lib/etassia/renderer/block.rb +157 -0
  657. data/lib/etassia/renderer/c4.rb +294 -0
  658. data/lib/etassia/renderer/class_diagram.rb +671 -0
  659. data/lib/etassia/renderer/er_diagram.rb +842 -0
  660. data/lib/etassia/renderer/error.rb +131 -0
  661. data/lib/etassia/renderer/flowchart.rb +315 -0
  662. data/lib/etassia/renderer/gantt.rb +177 -0
  663. data/lib/etassia/renderer/git_graph.rb +224 -0
  664. data/lib/etassia/renderer/info.rb +59 -0
  665. data/lib/etassia/renderer/kanban.rb +157 -0
  666. data/lib/etassia/renderer/line_break_text.rb +56 -0
  667. data/lib/etassia/renderer/markdown_text.rb +106 -0
  668. data/lib/etassia/renderer/mindmap.rb +290 -0
  669. data/lib/etassia/renderer/packet.rb +87 -0
  670. data/lib/etassia/renderer/pie.rb +113 -0
  671. data/lib/etassia/renderer/quadrant.rb +189 -0
  672. data/lib/etassia/renderer/radar.rb +191 -0
  673. data/lib/etassia/renderer/requirement.rb +209 -0
  674. data/lib/etassia/renderer/sankey.rb +114 -0
  675. data/lib/etassia/renderer/sequence/frame_drawing.rb +169 -0
  676. data/lib/etassia/renderer/sequence/note_drawing.rb +69 -0
  677. data/lib/etassia/renderer/sequence.rb +490 -0
  678. data/lib/etassia/renderer/state_diagram.rb +202 -0
  679. data/lib/etassia/renderer/timeline.rb +134 -0
  680. data/lib/etassia/renderer/timeline_palette.rb +18 -0
  681. data/lib/etassia/renderer/treemap/section_total.rb +33 -0
  682. data/lib/etassia/renderer/treemap.rb +128 -0
  683. data/lib/etassia/renderer/user_journey.rb +209 -0
  684. data/lib/etassia/renderer/xy_chart.rb +180 -0
  685. data/lib/etassia/renderer.rb +22 -0
  686. data/lib/etassia/runtime.rb +125 -0
  687. data/lib/etassia/source/frontmatter.rb +501 -0
  688. data/lib/etassia/source.rb +224 -0
  689. data/lib/etassia/svg/arrowhead.rb +205 -0
  690. data/lib/etassia/svg/circle.rb +19 -0
  691. data/lib/etassia/svg/document.rb +106 -0
  692. data/lib/etassia/svg/element.rb +209 -0
  693. data/lib/etassia/svg/ellipse.rb +23 -0
  694. data/lib/etassia/svg/escaping.rb +149 -0
  695. data/lib/etassia/svg/group.rb +64 -0
  696. data/lib/etassia/svg/line.rb +21 -0
  697. data/lib/etassia/svg/numbers.rb +53 -0
  698. data/lib/etassia/svg/path.rb +67 -0
  699. data/lib/etassia/svg/path_geometry.rb +378 -0
  700. data/lib/etassia/svg/polygon.rb +25 -0
  701. data/lib/etassia/svg/polyline.rb +28 -0
  702. data/lib/etassia/svg/rect.rb +34 -0
  703. data/lib/etassia/svg/style.rb +50 -0
  704. data/lib/etassia/svg/text.rb +181 -0
  705. data/lib/etassia/svg/tspan.rb +48 -0
  706. data/lib/etassia/svg.rb +38 -0
  707. data/lib/etassia/text_measurement/arial_advances.rb +122 -0
  708. data/lib/etassia/text_measurement/dejavu_advances.rb +111 -0
  709. data/lib/etassia/text_measurement/times_advances.rb +64 -0
  710. data/lib/etassia/text_measurement/trebuchet_advances.rb +53 -0
  711. data/lib/etassia/text_measurement/trebuchet_kerning.rb +29 -0
  712. data/lib/etassia/text_measurement.rb +198 -0
  713. data/lib/etassia/theme/builtin/dark.yml +229 -0
  714. data/lib/etassia/theme/builtin/default.yml +239 -0
  715. data/lib/etassia/theme/builtin/high_contrast.yml +231 -0
  716. data/lib/etassia/theme/builtin/light.yml +229 -0
  717. data/lib/etassia/theme/color_palette.rb +97 -0
  718. data/lib/etassia/theme/completion.rb +50 -0
  719. data/lib/etassia/theme/effect_styles.rb +34 -0
  720. data/lib/etassia/theme/registry.rb +45 -0
  721. data/lib/etassia/theme/shape_styles.rb +34 -0
  722. data/lib/etassia/theme/spacing_config.rb +30 -0
  723. data/lib/etassia/theme/typography.rb +40 -0
  724. data/lib/etassia/theme.rb +73 -0
  725. data/lib/etassia/version.rb +5 -0
  726. data/lib/etassia.rb +40 -0
  727. metadata +865 -0
@@ -0,0 +1,2806 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../layout/base"
4
+ require_relative "caption_layout"
5
+ require_relative "class_name"
6
+ require_relative "stereotype_head"
7
+ require_relative "class_header"
8
+ require_relative "end_texts"
9
+ require_relative "member_icon"
10
+ require_relative "member_pitch"
11
+ require_relative "member_rule"
12
+ require_relative "ir_reader"
13
+ require_relative "package_frames"
14
+ require_relative "qualified_association_layout"
15
+ require_relative "qualifier_box"
16
+ require_relative "label_clearance"
17
+ require_relative "styled_relation_curve"
18
+ require_relative "rank_chain"
19
+ require_relative "rank_hierarchy"
20
+ require_relative "note_layout"
21
+ require_relative "relation_label_separator"
22
+ require_relative "scene"
23
+ require_relative "drawing"
24
+ require_relative "../../js_math"
25
+
26
+ module Etassia
27
+ module Notation
28
+ module PlantUML
29
+ # Positions PlantUML class boxes and relations in final canvas space,
30
+ # from the IR::Graph {IRAdapter} emits.
31
+ class Layout < Etassia::Layout::Base
32
+ MARGIN = 7.0
33
+ COLUMN_GAP = 90.0
34
+ ROW_GAP = 90.0
35
+ ANNOTATED_RELATION_SLOT_WIDTH = 160.0
36
+ BOX_PADDING = 12.0
37
+ ROW_HEIGHT = 22.0
38
+ # A compact box, sized from the reference row pitch, draws its member
39
+ # rows from the bottom edge: last baseline 6.95 above it, the head
40
+ # rule 17.5 above the first baseline, 8.0 between a field group and
41
+ # a method group.
42
+ COMPACT_BOTTOM_PAD = 6.95
43
+ COMPACT_HEAD_GAP = 17.5
44
+ COMPACT_GROUP_GAP = 8.0
45
+ # The head rule is 32 below the top and the first row 17.5 below it,
46
+ # the last row 6.95 above the bottom, less the pitch of that row.
47
+ PROPORTIONAL_MEMBER_BASE_HEIGHT = 40.0
48
+ COMPACT_RULE_BASE_HEIGHT = 34.488
49
+ MARKER_LENGTH = 12.0
50
+ THICK_LINE = 4.0
51
+ JUNCTION_DOT_RADIUS = 2.0
52
+ MARKER_HALF_WIDTH = 7.0
53
+ LABEL_OFFSET = 10.0
54
+ LABEL_GAP = 2.0
55
+ BESIDE_LABEL_SLOPE = 0.8
56
+ END_TEXT_ACROSS = 3.0
57
+ END_TEXT_ALONG = 1.0
58
+ TEXT_DESCENT = 0.25
59
+ # The renderer draws end texts and relation labels at this share of
60
+ # the body font size.
61
+ SMALL_TEXT_SCALE = 0.9
62
+ NESTING_RADIUS = 8.0
63
+ MEMBER_NOTE_TEXT_SCALE = 1.15
64
+ MEMBER_NOTE_ROW_HEIGHT = 16.48828125
65
+ MEMBER_NOTE_LINE_HEIGHT = 15.310546875
66
+ MEMBER_NOTE_STACK_GAP = 10.0
67
+ MEMBER_NOTE_BODY_GAP = 35.0
68
+ CLASS_NOTE_GAP = 35.0
69
+ MEMBER_NOTE_CLASS_HEIGHT = 48.0
70
+ MEMBER_NOTE_SIDE_PADDING = 6.0
71
+ STANDARD_MEMBER_SIDE_ALLOWANCE = 20.0
72
+ STANDARD_STEREOTYPE_ROW_HEIGHT = 8.62109375
73
+ STANDARD_TITLE_SIDE_ALLOWANCE = 32.0
74
+ MULTILINE_TITLE_ROW_HEIGHT = 16.48828125
75
+ MULTILINE_TITLE_VERTICAL_ALLOWANCE = 26.0
76
+ MULTILINE_TITLE_BASELINE = 18.53515625
77
+ MULTILINE_TITLE_SIDE_ALLOWANCE = 36.0
78
+ SELF_RELATION_START_RATIO = 0.37
79
+ SELF_RELATION_END_RATIO = 0.635
80
+ SELF_RELATION_CONTROL_OFFSET = 70.0
81
+ SELF_RELATION_LABEL_GAP = 6.0
82
+ # PlantUML removes the otherwise empty member compartment below a
83
+ # one-line title when `hide empty members` is active. Its Smetana
84
+ # boxes also keep four more pixels of side padding than our ordinary
85
+ # measured class box; both values are pinned by the group2712 family.
86
+ EMPTY_MEMBER_COMPARTMENT = 14.0
87
+ EMPTY_COMPARTMENTS_EXTRA = 2.0
88
+ AXIS_TOLERANCE = 0.5
89
+ EMPTY_CLASS_WIDTH_EXTRA = 8.0
90
+ EMPTY_CLASS_MIN_WIDTH = 41.5
91
+ HIDE_CIRCLE_BASE_HEIGHT = 42.48828125
92
+ HIDE_CIRCLE_TITLE_ALLOWANCE = 6.0
93
+ HIDE_CIRCLE_MEMBER_ALLOWANCE = 12.0
94
+ ISOLATED_KIND_COLUMN_GAP = 35.0
95
+ ISOLATED_KIND_ROW_GAP = 60.0
96
+ ISOLATED_KIND_MEMBER_ALLOWANCE = 26.0
97
+ HIDDEN_MEMBER_EMPTY_HEIGHT = 32.0
98
+ RESHOWN_MEMBER_BASE_HEIGHT = 40.0
99
+ QUALIFIED_GENERIC_FONT_SIZE = 12.0
100
+ QUALIFIED_GENERIC_GAP = 7.0
101
+ QUALIFIED_BOX_HEIGHT = 48.0
102
+ QUALIFIED_GENERIC_HEIGHT = 51.0
103
+ QUALIFIED_QUALIFIER_ALLOWANCE = 40.0
104
+ COMPACT_QUALIFIER_ALLOWANCE = 20.0
105
+ QUALIFIED_STEEP_ARROW_OFFSET = 37.0
106
+ QUALIFIED_LONG_CURVE_OFFSET = 210.0
107
+ QUALIFIED_FLAT_CURVE_OFFSET = 30.0
108
+ QUALIFIED_FAN_LANE_ALLOWANCE = 4.0
109
+ QUALIFIED_DOUBLE_END_HALF_WIDTH = 10.0
110
+ QUALIFIED_INHERITANCE_HALF_WIDTH = 6.0
111
+ PACKAGE_CANVAS_MARGIN = 6.0
112
+ PACKAGE_OUTER_GAP = 24.0
113
+ PACKAGE_CANVAS_EDGE = 15.0
114
+ ARROW_LENGTH = 9.0
115
+ ARROW_HALF_WIDTH = 4.0
116
+ ARROW_NOTCH = 5.0
117
+ DIAMOND_LENGTH = 12.0
118
+ DIAMOND_HALF_WIDTH = 4.0
119
+ ARROW_MARKERS = %i[association dependency].freeze
120
+ FILLED_MARKERS = %i[association dependency composition].freeze
121
+ private_constant :PROPORTIONAL_MEMBER_BASE_HEIGHT,
122
+ :COMPACT_BOTTOM_PAD, :COMPACT_HEAD_GAP,
123
+ :COMPACT_GROUP_GAP, :COMPACT_RULE_BASE_HEIGHT
124
+ private_constant :MARGIN, :COLUMN_GAP, :ROW_GAP,
125
+ :ANNOTATED_RELATION_SLOT_WIDTH, :BOX_PADDING,
126
+ :ROW_HEIGHT, :MARKER_LENGTH, :MARKER_HALF_WIDTH,
127
+ :LABEL_OFFSET, :LABEL_GAP, :FILLED_MARKERS,
128
+ :NESTING_RADIUS,
129
+ :END_TEXT_ACROSS, :END_TEXT_ALONG, :TEXT_DESCENT,
130
+ :SMALL_TEXT_SCALE,
131
+ :ARROW_LENGTH, :ARROW_HALF_WIDTH, :ARROW_NOTCH,
132
+ :DIAMOND_LENGTH, :DIAMOND_HALF_WIDTH,
133
+ :ARROW_MARKERS,
134
+ :MEMBER_NOTE_TEXT_SCALE, :MEMBER_NOTE_ROW_HEIGHT,
135
+ :MEMBER_NOTE_LINE_HEIGHT, :MEMBER_NOTE_STACK_GAP,
136
+ :MEMBER_NOTE_BODY_GAP, :MEMBER_NOTE_CLASS_HEIGHT,
137
+ :MEMBER_NOTE_SIDE_PADDING,
138
+ :CLASS_NOTE_GAP,
139
+ :STANDARD_MEMBER_SIDE_ALLOWANCE,
140
+ :STANDARD_STEREOTYPE_ROW_HEIGHT,
141
+ :STANDARD_TITLE_SIDE_ALLOWANCE,
142
+ :MULTILINE_TITLE_ROW_HEIGHT,
143
+ :MULTILINE_TITLE_VERTICAL_ALLOWANCE,
144
+ :MULTILINE_TITLE_BASELINE,
145
+ :MULTILINE_TITLE_SIDE_ALLOWANCE,
146
+ :SELF_RELATION_START_RATIO,
147
+ :SELF_RELATION_END_RATIO,
148
+ :SELF_RELATION_CONTROL_OFFSET,
149
+ :SELF_RELATION_LABEL_GAP,
150
+ :EMPTY_MEMBER_COMPARTMENT,
151
+ :EMPTY_COMPARTMENTS_EXTRA, :AXIS_TOLERANCE,
152
+ :EMPTY_CLASS_WIDTH_EXTRA,
153
+ :EMPTY_CLASS_MIN_WIDTH, :HIDE_CIRCLE_BASE_HEIGHT,
154
+ :HIDE_CIRCLE_TITLE_ALLOWANCE,
155
+ :HIDE_CIRCLE_MEMBER_ALLOWANCE,
156
+ :ISOLATED_KIND_COLUMN_GAP,
157
+ :ISOLATED_KIND_ROW_GAP,
158
+ :ISOLATED_KIND_MEMBER_ALLOWANCE,
159
+ :HIDDEN_MEMBER_EMPTY_HEIGHT,
160
+ :RESHOWN_MEMBER_BASE_HEIGHT,
161
+ :QUALIFIED_GENERIC_FONT_SIZE,
162
+ :QUALIFIED_GENERIC_GAP, :QUALIFIED_BOX_HEIGHT,
163
+ :QUALIFIED_GENERIC_HEIGHT,
164
+ :QUALIFIED_QUALIFIER_ALLOWANCE,
165
+ :COMPACT_QUALIFIER_ALLOWANCE,
166
+ :QUALIFIED_STEEP_ARROW_OFFSET,
167
+ :QUALIFIED_LONG_CURVE_OFFSET,
168
+ :QUALIFIED_FLAT_CURVE_OFFSET,
169
+ :QUALIFIED_FAN_LANE_ALLOWANCE,
170
+ :QUALIFIED_DOUBLE_END_HALF_WIDTH,
171
+ :QUALIFIED_INHERITANCE_HALF_WIDTH,
172
+ :PACKAGE_CANVAS_MARGIN, :PACKAGE_OUTER_GAP,
173
+ :PACKAGE_CANVAS_EDGE
174
+
175
+ # @param graph [IR::Graph] from {IRAdapter}
176
+ def scene(graph)
177
+ diagram = IRReader.call(graph)
178
+ @kind_in_spot = !ClassHeader.circles_hidden?(diagram)
179
+ body = if member_note_family?(diagram)
180
+ member_note_scene(diagram)
181
+ elsif class_note_pair_family?(diagram)
182
+ class_note_pair_scene(diagram)
183
+ else
184
+ standard_scene(diagram)
185
+ end
186
+ dress(diagram, body)
187
+ end
188
+
189
+ private
190
+
191
+ def standard_scene(diagram)
192
+ specifications = box_specifications(diagram)
193
+ hidden_members = hide_members_grid_scene(diagram, specifications)
194
+ return hidden_members if hidden_members
195
+
196
+ isolated_kinds = isolated_class_kind_scene(diagram, specifications)
197
+ return isolated_kinds if isolated_kinds
198
+
199
+ qualified = qualified_scene(diagram, specifications)
200
+ return qualified if qualified
201
+
202
+ standard_box_scene(diagram, specifications)
203
+ end
204
+
205
+ def hide_members_grid_scene(diagram, specifications)
206
+ return unless hide_members_grid_family?(diagram)
207
+
208
+ visible, hidden = specifications.partition do |item|
209
+ item[:member_rows].any?
210
+ end
211
+ ordered = visible + hidden
212
+ rows = ordered.each_slice(2).to_a
213
+ boxes = hide_members_grid_boxes(rows)
214
+ width, height = chain_dimensions(boxes)
215
+ Scene.new(width: width, height: height, boxes: boxes,
216
+ relations: [], frames: [])
217
+ end
218
+
219
+ def hide_members_grid_family?(diagram)
220
+ diagram.classes.size > 1 && diagram.notes.empty? &&
221
+ standalone_diagram?(diagram) &&
222
+ diagram.directives.include?("hide members") &&
223
+ diagram.classes.any? { |klass| klass.body.any? }
224
+ end
225
+
226
+ def hide_members_grid_boxes(rows)
227
+ row_widths = rows.map { |row| hide_members_row_width(row) }
228
+ widest = row_widths.max
229
+ tops = isolated_class_kind_row_tops(rows)
230
+ rows.each_with_index.flat_map do |row, index|
231
+ left = MARGIN + ((widest - row_widths.fetch(index)) / 2)
232
+ hide_members_grid_row(row, left, tops.fetch(index))
233
+ end
234
+ end
235
+
236
+ def hide_members_row_width(row)
237
+ row.sum { |item| item[:width] } +
238
+ (ISOLATED_KIND_COLUMN_GAP * (row.size - 1))
239
+ end
240
+
241
+ def hide_members_grid_row(row, left, top)
242
+ height = row.map { |item| item[:height] }.max
243
+ row.each_with_object([]) do |item, boxes|
244
+ y = top + ((height - item[:height]) / 2)
245
+ boxes << build_box(item, left, y, item[:width])
246
+ left += item[:width] + ISOLATED_KIND_COLUMN_GAP
247
+ end
248
+ end
249
+
250
+ def isolated_class_kind_scene(diagram, specifications)
251
+ return unless isolated_class_kind_family?(diagram) ||
252
+ loose_member_class_family?(diagram)
253
+
254
+ rows = specifications.each_slice(2).to_a
255
+ boxes = isolated_class_kind_boxes(rows)
256
+ width, height = chain_dimensions(boxes)
257
+ Scene.new(width: width, height: height, boxes: boxes,
258
+ relations: [], frames: [])
259
+ end
260
+
261
+ def isolated_class_kind_family?(diagram)
262
+ diagram.classes.size > 1 && diagram.notes.empty? &&
263
+ standalone_diagram?(diagram) &&
264
+ diagram.classes.any? { |klass| klass.kind != :class }
265
+ end
266
+
267
+ # Several unlinked classes with members: the jar packs them like
268
+ # the other loose boxes, a node-separation apart, not a grid column.
269
+ def loose_member_class_family?(diagram)
270
+ diagram.classes.size > 1 && diagram.notes.empty? &&
271
+ standalone_diagram?(diagram) &&
272
+ diagram.classes.any? { |klass| klass.body.any? }
273
+ end
274
+
275
+ def isolated_class_kind_boxes(rows)
276
+ widths = isolated_class_kind_column_widths(rows)
277
+ tops = isolated_class_kind_row_tops(rows)
278
+ rows.each_with_index.flat_map do |row, row_index|
279
+ isolated_class_kind_row(row, widths, tops.fetch(row_index))
280
+ end
281
+ end
282
+
283
+ def isolated_class_kind_column_widths(rows)
284
+ columns = rows.map(&:size).max
285
+ Array.new(columns) do |column|
286
+ rows.filter_map { |row| row[column]&.fetch(:width) }.max
287
+ end
288
+ end
289
+
290
+ def isolated_class_kind_row_tops(rows)
291
+ heights = rows.map { |row| row.map { |item| item[:height] }.max }
292
+ heights.each_with_object([MARGIN]) do |height, tops|
293
+ tops << (tops.last + height + ISOLATED_KIND_ROW_GAP)
294
+ end
295
+ end
296
+
297
+ def isolated_class_kind_row(row, widths, top)
298
+ lefts = isolated_class_kind_column_lefts(widths)
299
+ height = row.map { |item| item[:height] }.max
300
+ row.each_with_index.map do |item, column|
301
+ origin = isolated_class_kind_origin(item, lefts.fetch(column),
302
+ widths.fetch(column),
303
+ [top, height])
304
+ build_box(item, *origin, item[:width])
305
+ end
306
+ end
307
+
308
+ def isolated_class_kind_origin(item, left, cell_width, (top, height))
309
+ [left + ((cell_width - item[:width]) / 2),
310
+ top + ((height - item[:height]) / 2)]
311
+ end
312
+
313
+ def isolated_class_kind_column_lefts(widths)
314
+ widths.each_with_object([MARGIN]) do |width, lefts|
315
+ lefts << (lefts.last + width + ISOLATED_KIND_COLUMN_GAP)
316
+ end
317
+ end
318
+
319
+ def standard_box_scene(diagram, specifications)
320
+ chain = RankChain.for(diagram)
321
+ return chain_scene(diagram, chain, specifications) if chain
322
+ return noted_scene(diagram, specifications) if diagram.notes.any?
323
+
324
+ hierarchy = RankHierarchy.for(diagram)
325
+ if hierarchy
326
+ return hierarchy_scene(diagram, hierarchy, specifications)
327
+ end
328
+
329
+ grid_scene(diagram, specifications)
330
+ end
331
+
332
+ def grid_scene(diagram, specifications)
333
+ box_width = widest_box(specifications)
334
+ boxes = position_boxes(rows_by_package(specifications, diagram),
335
+ box_width, stable_centers?(diagram),
336
+ intrinsic_box_widths?(diagram), diagram)
337
+ boxes = align_package_relation_targets(diagram, boxes,
338
+ specifications)
339
+
340
+ build_scene(diagram, boxes)
341
+ end
342
+
343
+ def box_specifications(diagram)
344
+ classes = diagram.classes.map do |klass|
345
+ box_specification(klass, diagram)
346
+ end
347
+ classes + note_specifications(diagram.notes)
348
+ end
349
+
350
+ def qualified_scene(diagram, specifications)
351
+ qualified = QualifiedAssociationLayout.for(diagram)
352
+ if qualified
353
+ qualified_association_scene(diagram, qualified, specifications)
354
+ end
355
+ end
356
+
357
+ def qualified_association_scene(diagram, qualified, specifications)
358
+ sizes = qualified.constrained_sizes(qualified_sizes(specifications))
359
+ specifications = resized_specifications(specifications, sizes)
360
+ corners = qualified.place(sizes, [MARGIN, MARGIN])
361
+ boxes = chain_boxes(specifications, corners, sizes)
362
+ width, height = chain_dimensions(boxes)
363
+ @qualified_layout_active = true
364
+ @qualified_route_middle = qualified_route_middle(boxes)
365
+ Scene.new(width: width, height: height, boxes: boxes, frames: [],
366
+ relations: scene_relations(diagram, boxes))
367
+ end
368
+
369
+ def resized_specifications(specifications, sizes)
370
+ specifications.map do |item|
371
+ width, height = sizes.fetch(item[:id])
372
+ item.merge(width: width, height: height)
373
+ end
374
+ end
375
+
376
+ def qualified_sizes(specifications)
377
+ specifications.to_h do |item|
378
+ [item[:id], [item[:width], item[:height]]]
379
+ end
380
+ end
381
+
382
+ def qualified_route_middle(boxes)
383
+ boxes.sum { |box| box.y + (box.height / 2) } / boxes.size
384
+ end
385
+
386
+ def chain_scene(diagram, chain, specifications)
387
+ sizes = chain_sizes(specifications, diagram)
388
+ corners, nodes = chain.place(sizes, [MARGIN, MARGIN],
389
+ label_widths(diagram))
390
+ boxes = chain_boxes(specifications, corners, sizes)
391
+ width, height = chain_dimensions(boxes)
392
+ Scene.new(width: width, height: height,
393
+ boxes: boxes, frames: [],
394
+ relations: scene_relations(diagram, boxes, nodes))
395
+ end
396
+
397
+ def hierarchy_scene(diagram, hierarchy, specifications)
398
+ items = specifications.reject { |item| item[:note] }
399
+ sizes = item_sizes(items)
400
+ corners = hierarchy.place(sizes, [MARGIN, MARGIN])
401
+ boxes = chain_boxes(items, corners, sizes)
402
+ width, height = chain_dimensions(boxes)
403
+ Scene.new(width: width, height: height, boxes: boxes, frames: [],
404
+ relations: scene_relations(diagram, boxes))
405
+ end
406
+
407
+ def item_sizes(items)
408
+ items.to_h { |item| [item[:id], [item[:width], item[:height]]] }
409
+ end
410
+
411
+ # Classes placed as without notes, then the notes around them; the
412
+ # whole drawing is moved so its top left corner keeps the margin.
413
+ def noted_scene(diagram, specifications)
414
+ items = specifications.reject { |item| item[:note] }
415
+ sizes, corners = noted_class_corners(diagram, items)
416
+ boxes = margined_boxes(diagram, items, sizes, corners)
417
+ width, height = chain_dimensions(boxes)
418
+ Scene.new(width: width, height: height, boxes: boxes, frames: [],
419
+ relations: scene_relations(diagram, boxes))
420
+ end
421
+
422
+ # The class boxes and their notes, moved so the drawing's top left
423
+ # corner keeps the margin.
424
+ def margined_boxes(diagram, items, sizes, corners)
425
+ boxes = chain_boxes(items, corners, sizes)
426
+ shift = noted_shift(boxes + noted_boxes(diagram, boxes))
427
+ boxes = chain_boxes(items, shifted_corners(corners, shift), sizes)
428
+ boxes + noted_boxes(diagram, boxes)
429
+ end
430
+
431
+ def noted_class_corners(diagram, items)
432
+ hierarchy = RankHierarchy.for(diagram)
433
+ return hierarchy_corners(hierarchy, items) if hierarchy
434
+
435
+ grid_corners(diagram, items)
436
+ end
437
+
438
+ def hierarchy_corners(hierarchy, items)
439
+ sizes = item_sizes(items)
440
+ [sizes, hierarchy.place(sizes, [MARGIN, MARGIN])]
441
+ end
442
+
443
+ def grid_corners(diagram, items)
444
+ rows = rows_by_package(items, diagram)
445
+ placed = position_boxes(rows, widest_box(items),
446
+ stable_centers?(diagram),
447
+ intrinsic_box_widths?(diagram), diagram)
448
+ [placed.to_h { |box| [box.id, [box.width, box.height]] },
449
+ placed.to_h { |box| [box.id, [box.x, box.y]] }]
450
+ end
451
+
452
+ def noted_boxes(diagram, boxes)
453
+ NoteLayout.new(diagram, method(:note_line_width),
454
+ method(:member_text_width)).place(boxes)
455
+ end
456
+
457
+ def member_text_width(text)
458
+ standard_text_width(text, font_size)
459
+ end
460
+
461
+ def note_line_width(text)
462
+ measure_text(text, font_size: font_size * (13.0 / 14.0))[:width]
463
+ end
464
+
465
+ def noted_shift(boxes)
466
+ [MARGIN - boxes.map(&:x).min, MARGIN - boxes.map(&:y).min]
467
+ end
468
+
469
+ def shifted_corners(corners, shift)
470
+ corners.transform_values do |(left, top)|
471
+ [left + shift.first, top + shift.last]
472
+ end
473
+ end
474
+
475
+ def chain_sizes(specifications, diagram)
476
+ widest = widest_box(specifications)
477
+ intrinsic_widths = intrinsic_box_widths?(diagram)
478
+ specifications.to_h do |item|
479
+ width = if compact_empty_item?(item, diagram) || intrinsic_widths
480
+ item[:width]
481
+ else
482
+ widest
483
+ end
484
+ [item[:id], [width, item[:height]]]
485
+ end
486
+ end
487
+
488
+ def chain_dimensions(boxes)
489
+ [far_edge(boxes) { |box| box.x + box.width },
490
+ far_edge(boxes) { |box| box.y + box.height }]
491
+ end
492
+
493
+ def chain_boxes(specifications, corners, sizes)
494
+ specifications.map do |item|
495
+ left, top = corners.fetch(item[:id])
496
+ build_box(item, left, top, sizes.fetch(item[:id]).first)
497
+ end
498
+ end
499
+
500
+ def member_note_family?(diagram)
501
+ return false unless diagram.classes.one? && diagram.notes.any?
502
+ return false unless standalone_diagram?(diagram)
503
+
504
+ klass = diagram.classes.first
505
+ plain_class?(klass) && member_notes?(diagram.notes, klass)
506
+ end
507
+
508
+ def class_note_pair_family?(diagram)
509
+ classes = diagram.classes
510
+ notes = diagram.notes
511
+ class_note_pair_counts?(classes, notes) &&
512
+ standalone_diagram?(diagram) &&
513
+ classes.all? { |klass| class_note_class?(klass) } &&
514
+ paired_class_notes?(classes, notes)
515
+ end
516
+
517
+ def class_note_pair_counts?(classes, notes)
518
+ classes.size > 1 && notes.size == classes.size
519
+ end
520
+
521
+ def paired_class_notes?(classes, notes)
522
+ return false unless class_level_right_notes?(notes)
523
+
524
+ notes.map(&:target).sort == classes.map(&:name).sort
525
+ end
526
+
527
+ def class_note_class?(klass)
528
+ plain_class?(klass) && klass.body.empty?
529
+ end
530
+
531
+ def class_level_right_notes?(notes)
532
+ notes.all? { |note| note.member.nil? && note.side == :right }
533
+ end
534
+
535
+ def standalone_diagram?(diagram)
536
+ diagram.relations.empty? && diagram.junctions.empty? &&
537
+ diagram.packages.empty?
538
+ end
539
+
540
+ def plain_class?(klass)
541
+ klass.kind == :class && klass.stereotypes.empty? &&
542
+ klass.generics.nil?
543
+ end
544
+
545
+ def member_notes?(notes, klass)
546
+ members = klass.body.map(&:name)
547
+ notes.all? do |note|
548
+ note.member && members.include?(note.member) &&
549
+ note.target == klass.name && note.side == :right
550
+ end
551
+ end
552
+
553
+ def member_note_scene(diagram)
554
+ klass = diagram.classes.first
555
+ class_box = member_note_class_box(klass)
556
+ notes = member_note_boxes(diagram, klass, class_box)
557
+ boxes = [class_box, *notes]
558
+ boxes_only_scene(boxes)
559
+ end
560
+
561
+ def class_note_pair_scene(diagram)
562
+ specifications = diagram.classes.to_h do |klass|
563
+ [klass.name, box_specification(klass, diagram)]
564
+ end
565
+ notes = indexed_class_notes(diagram.notes)
566
+ boxes = place_class_note_pairs(diagram.classes, specifications,
567
+ notes)
568
+ boxes_only_scene(boxes)
569
+ end
570
+
571
+ def boxes_only_scene(boxes)
572
+ Scene.new(width: far_edge(boxes) { |box| box.x + box.width },
573
+ height: far_edge(boxes) { |box| box.y + box.height },
574
+ boxes: boxes, relations: [], frames: [])
575
+ end
576
+
577
+ def indexed_class_notes(notes)
578
+ notes.each_with_index.to_h do |note, index|
579
+ [note.target, [note, index]]
580
+ end
581
+ end
582
+
583
+ def place_class_note_pairs(classes, specifications, notes)
584
+ cursor = MARGIN
585
+ classes.flat_map do |klass|
586
+ item = specifications.fetch(klass.name)
587
+ class_box = build_box(item, cursor, MARGIN, item[:width])
588
+ note, index = notes.fetch(klass.name)
589
+ note_box = class_level_note_box(note, index, class_box)
590
+ cursor = note_box.x + note_box.width + CLASS_NOTE_GAP
591
+ [class_box, note_box]
592
+ end
593
+ end
594
+
595
+ def class_level_note_box(note, index, class_box)
596
+ geometry = class_level_note_geometry(note, class_box)
597
+ Scene::Box.new(
598
+ id: "note-#{index}", **geometry,
599
+ texts: member_note_texts(note, geometry[:x], geometry[:y]),
600
+ separators: [class_level_note_pointer(class_box, geometry)],
601
+ kind: "member_note", fill: note.color
602
+ )
603
+ end
604
+
605
+ def class_level_note_geometry(note, class_box)
606
+ height = member_note_height(note)
607
+ {
608
+ x: class_box.x + class_box.width + CLASS_NOTE_GAP,
609
+ y: class_box.y + ((class_box.height - height) / 2),
610
+ width: class_level_note_width(note), height: height
611
+ }
612
+ end
613
+
614
+ def class_level_note_pointer(class_box, geometry)
615
+ segment(class_box.x + class_box.width,
616
+ class_box.y + (class_box.height / 2),
617
+ geometry[:x], geometry[:y] + (geometry[:height] / 2))
618
+ end
619
+
620
+ def class_level_note_width(note)
621
+ size = font_size * (13.0 / 14.0)
622
+ widths = note.lines.map do |line|
623
+ Drawing::Face.widest_line(line, size: size)
624
+ end
625
+ widths.max.to_f + 21.0
626
+ end
627
+
628
+ def far_edge(boxes, &)
629
+ boxes.map(&).max + (MARGIN * 2)
630
+ end
631
+
632
+ def member_note_class_box(klass)
633
+ width = member_note_class_width(klass)
634
+ height = MEMBER_NOTE_CLASS_HEIGHT +
635
+ (klass.body.size * MEMBER_NOTE_ROW_HEIGHT)
636
+ texts = member_note_class_texts(klass, width)
637
+ Scene::Box.new(
638
+ id: klass.name, x: MARGIN, y: MARGIN, width: width, height: height,
639
+ texts: texts, separators: [member_note_separator(width)],
640
+ kind: "class"
641
+ )
642
+ end
643
+
644
+ def member_note_separator(width)
645
+ separator_y = MARGIN + MEMBER_NOTE_CLASS_HEIGHT -
646
+ MEMBER_NOTE_ROW_HEIGHT
647
+ segment(MARGIN, separator_y, MARGIN + width, separator_y)
648
+ end
649
+
650
+ def member_note_class_width(klass)
651
+ # The pinned renderer's sans-serif is wider than the deterministic
652
+ # Arial table on this machine. This factor is measured across every
653
+ # member row in the reference-backed group2814 family.
654
+ name = plantuml_text_width(klass.name) + 32.0
655
+ members = klass.body.map do |member|
656
+ plantuml_text_width(member_text(member)) +
657
+ (MEMBER_NOTE_SIDE_PADDING * 2)
658
+ end
659
+ [name, *members].max
660
+ end
661
+
662
+ def member_note_class_texts(klass, width)
663
+ title = scene_text(klass.name, MARGIN + (width / 2), MARGIN + 21.291,
664
+ "class_name", "middle")
665
+ members = klass.body.each_with_index.map do |member, index|
666
+ member_note_row_text(member, index)
667
+ end
668
+ [title, *members]
669
+ end
670
+
671
+ def member_note_row_text(member, index)
672
+ vertical = MARGIN + 49.535 + (index * MEMBER_NOTE_ROW_HEIGHT)
673
+ role = member.type_position == :prefix ? "member_prefix" : "member"
674
+ scene_text(member_text(member), MARGIN + MEMBER_NOTE_SIDE_PADDING,
675
+ vertical, role, "start")
676
+ end
677
+
678
+ def member_note_boxes(diagram, klass, class_box)
679
+ tops = member_note_tops(diagram.notes, class_box)
680
+ fill = shared_member_note_fill(diagram)
681
+ diagram.notes.each_with_index.map do |note, index|
682
+ box = member_note_box(note, index, klass, class_box, tops[index])
683
+ box.tap { |placed| placed.fill = fill }
684
+ end
685
+ end
686
+
687
+ def member_note_tops(notes, class_box)
688
+ heights = notes.map { |note| member_note_height(note) }
689
+ first = first_note_top(class_box, heights)
690
+ heights.each_with_object([first]) do |height, tops|
691
+ tops << (tops.last + (height + MEMBER_NOTE_STACK_GAP))
692
+ end
693
+ end
694
+
695
+ def first_note_top(class_box, heights)
696
+ total = heights.sum +
697
+ (MEMBER_NOTE_STACK_GAP * (heights.size - 1))
698
+ class_box.y + ((class_box.height - total) / 2) - 5.0
699
+ end
700
+
701
+ def member_note_box(note, index, klass, class_box, top)
702
+ height = member_note_height(note)
703
+ left = class_box.x + class_box.width + MEMBER_NOTE_BODY_GAP
704
+ pointer = member_note_pointer(note, klass, class_box,
705
+ [left, top + (height / 2)])
706
+ Scene::Box.new(id: "note-#{index}", x: left, y: top,
707
+ width: member_note_width(note), height: height,
708
+ texts: member_note_texts(note, left, top),
709
+ separators: [pointer], kind: "member_note")
710
+ end
711
+
712
+ def member_note_width(note)
713
+ note.lines.map { |line| plantuml_note_width(line) }.max.to_f + 21.0
714
+ end
715
+
716
+ def member_note_pointer(note, klass, class_box, anchor)
717
+ target = member_note_target(note, klass, class_box)
718
+ segment(target[0], target[1], anchor[0], anchor[1])
719
+ end
720
+
721
+ def member_note_target(note, klass, class_box)
722
+ index = klass.body.index { |member| member.name == note.member }
723
+ [member_note_target_x(klass.body.fetch(index), class_box),
724
+ class_box.y + 44.244 + (index * MEMBER_NOTE_ROW_HEIGHT)]
725
+ end
726
+
727
+ def member_note_target_x(member, class_box)
728
+ class_box.x + MEMBER_NOTE_SIDE_PADDING +
729
+ plantuml_text_width(member_text(member))
730
+ end
731
+
732
+ def member_note_texts(note, left, top)
733
+ note.lines.each_with_index.map do |line, index|
734
+ vertical = top + 17.568 + (index * MEMBER_NOTE_LINE_HEIGHT)
735
+ scene_text(line, left + 6.0, vertical, "member_note", "start")
736
+ end
737
+ end
738
+
739
+ def member_note_height(note)
740
+ 10.0 + ([note.lines.size, 1].max * MEMBER_NOTE_LINE_HEIGHT)
741
+ end
742
+
743
+ def shared_member_note_fill(diagram)
744
+ # PlantUML 1.2026.6 gives one side's shared tips entity the last
745
+ # note's paint. Preserve that pinned visible behavior for the stack.
746
+ note = diagram.notes.last
747
+ note.color || stereotype_note_fill(note.stereotype,
748
+ diagram.directives)
749
+ end
750
+
751
+ def stereotype_note_fill(stereotype, directives)
752
+ return unless stereotype
753
+
754
+ name = stereotype.delete_prefix("<<").delete_suffix(">>")
755
+ prefix = "skinparam\\s+noteBackgroundColor<<#{Regexp.escape(name)}>>"
756
+ pattern = /\A#{prefix}\s+(\S+)\z/i
757
+ directive = directives.reverse.find { |line| line.match?(pattern) }
758
+ directive&.match(pattern)&.[](1)
759
+ end
760
+
761
+ def plantuml_text_width(text)
762
+ measure_text(text, font_size: font_size)[:width] *
763
+ MEMBER_NOTE_TEXT_SCALE
764
+ end
765
+
766
+ def plantuml_note_width(text)
767
+ measure_text(text, font_size: font_size * (13.0 / 14.0))[:width] *
768
+ MEMBER_NOTE_TEXT_SCALE
769
+ end
770
+
771
+ def dress(diagram, scene)
772
+ measure = lambda do |text, size|
773
+ measure_text(text, font_size: size)[:width]
774
+ end
775
+ ClassHeader.apply(diagram, scene,
776
+ ->(text) { measure.call(text, font_size) })
777
+ captions = CaptionLayout.new(diagram, normal_size: font_size,
778
+ measure: measure)
779
+ captions.dress(scene)
780
+ end
781
+
782
+ def build_scene(diagram, boxes)
783
+ frames = scene_frames(diagram, boxes)
784
+ width, height = scene_dimensions(diagram, boxes, frames)
785
+ Scene.new(width: width, height: height, boxes: boxes,
786
+ relations: scene_relations(diagram, boxes + frames),
787
+ frames: frames)
788
+ end
789
+
790
+ def scene_dimensions(diagram, boxes, frames)
791
+ unless package_endpoint_relation?(diagram)
792
+ return [canvas_width(diagram, boxes),
793
+ scene_height(diagram, boxes)]
794
+ end
795
+
796
+ figures = boxes + frames
797
+ [package_extent(figures, :x, :width),
798
+ package_extent(figures, :y, :height)]
799
+ end
800
+
801
+ def package_extent(figures, origin, size)
802
+ figures.map do |item|
803
+ item.public_send(origin) + item.public_send(size)
804
+ end.max + PACKAGE_CANVAS_EDGE
805
+ end
806
+
807
+ def scene_height(diagram, boxes)
808
+ room = PackageFrames::OPEN + PackageFrames::CLOSE
809
+ canvas_height(box_rows(boxes)) + (diagram.packages.size * room) +
810
+ trailing_margin(diagram)
811
+ end
812
+
813
+ def scene_frames(diagram, boxes)
814
+ PackageFrames.new(diagram, method(:measured_width)).call(boxes)
815
+ end
816
+
817
+ # `nodes` holds each junction node as `[x, y, width, height]` by the
818
+ # index of its relation; without one the legs meet at the middle.
819
+ def scene_relations(diagram, boxes, nodes = {})
820
+ build_relations(diagram, boxes, nodes)
821
+ end
822
+
823
+ def box_specification(klass, diagram)
824
+ member_rows = klass.body.map { |member| member_text(member) }
825
+ names, generic = qualified_box_names(klass, diagram)
826
+ record = box_record(klass.name, header_rows(klass) + names,
827
+ member_rows)
828
+ details = { member_rows: member_rows, names: names, generic: generic }
829
+ complete_box_specification(record, klass, diagram, details)
830
+ end
831
+
832
+ def qualified_box_names(klass, diagram)
833
+ qualified = !QualifiedAssociationLayout.for(diagram).nil?
834
+ names = name_rows(klass, separate_generics: qualified)
835
+ generic = klass.generics if qualified
836
+ [generic ? names[0...-1] : names, generic]
837
+ end
838
+
839
+ def complete_box_specification(record, klass, diagram, details)
840
+ record = proportional_member_box(record, klass, diagram, details)
841
+ record = compartmented_empty_box(record, details[:member_rows],
842
+ diagram)
843
+ record = compact_empty_box(record, details[:member_rows], diagram)
844
+ record = smetana_hide_circle_box(record, klass, diagram, details)
845
+ adjust_plain_empty_width(record, klass, diagram, details[:names])
846
+ record = package_relation_box(record, klass, diagram, details)
847
+ record = multiline_title_box(record, klass, details)
848
+ record = isolated_box_sizes(record, klass, diagram, details)
849
+ with_box_context(record, klass, diagram, details)
850
+ end
851
+
852
+ def with_box_context(record, klass, diagram, details)
853
+ record.merge(name_rows: details[:names].size,
854
+ floating_generic: details[:generic],
855
+ **box_context(klass, diagram))
856
+ end
857
+
858
+ # A box of one name row and some members is as wide as its widest
859
+ # proportional row and as tall as the reference row pitch makes it.
860
+ def proportional_member_box(record, klass, diagram, details)
861
+ rows = details[:member_rows]
862
+ return record if rows.empty? || record.fetch(:title_rows).size > 1
863
+
864
+ record.merge(
865
+ width: [*standard_title_widths(details[:names]),
866
+ *member_row_widths(klass, rows, diagram)].max,
867
+ height: pitched_member_height(klass, diagram, rows),
868
+ compact_members: true,
869
+ )
870
+ end
871
+
872
+ MEMBER_PORTIONS = /\A(?:hide|show)\b.*\b(?:members|attributes|methods|
873
+ fields)\z/xi
874
+ private_constant :MEMBER_PORTIONS
875
+
876
+ # An empty field or method group keeps its 8-high compartment unless
877
+ # a `hide` or `show` line is in play.
878
+ def empty_gaps?(diagram)
879
+ diagram.directives.none? { |line| line.match?(MEMBER_PORTIONS) }
880
+ end
881
+
882
+ def member_row_widths(klass, rows, diagram)
883
+ insets = member_insets_for(klass, diagram)
884
+ klass.body.each_index.filter_map do |index|
885
+ next unless insets[index]
886
+
887
+ member_row_width(rows[index], klass.body[index], insets[index])
888
+ end
889
+ end
890
+
891
+ def member_insets_for(klass, diagram)
892
+ MemberIcon.insets(klass.body.map(&:kind),
893
+ klass.body.map(&:visibility),
894
+ MemberIcon.enabled?(diagram.directives))
895
+ end
896
+
897
+ def member_row_width(row, member, inset)
898
+ text = MemberIcon.visible_text(row, member.visibility,
899
+ inset == MemberIcon::TEXT_INSET)
900
+ inset + standard_text_width(text, font_size) +
901
+ MemberIcon::TEXT_PLAIN
902
+ end
903
+
904
+ def isolated_box_sizes(record, klass, diagram, details)
905
+ record = isolated_standard_box(record, klass, diagram, details)
906
+ record = isolated_class_kind_box(record, diagram, details)
907
+ record = hide_members_grid_box(record, diagram, details)
908
+ qualified_association_box(record, klass, diagram, details)
909
+ end
910
+
911
+ def package_relation_box(record, klass, diagram, details)
912
+ return record unless package_endpoint_relation?(diagram)
913
+ return record unless plain_class?(klass) && klass.body.empty? &&
914
+ details[:names].one?
915
+
916
+ record.merge(width: standard_title_widths(details[:names]).max,
917
+ height: MEMBER_NOTE_CLASS_HEIGHT)
918
+ end
919
+
920
+ def qualified_association_box(record, klass, diagram, details)
921
+ return record unless QualifiedAssociationLayout.for(diagram)
922
+
923
+ record.merge(title_rows: details[:names],
924
+ width: qualified_box_width(klass, diagram, details),
925
+ height: qualified_height_with_members(
926
+ klass, diagram, details
927
+ ),
928
+ compact_members: details[:member_rows].any?)
929
+ end
930
+
931
+ # A qualified box that carries members grows by their rows; its fixed
932
+ # height is only right for a box that is a bare title.
933
+ def qualified_height_with_members(klass, diagram, details)
934
+ height = qualified_box_height(diagram, details[:generic])
935
+ rows = details[:member_rows].size
936
+ return height if rows.zero?
937
+
938
+ base = qualified_compact?(diagram) ? COMPACT_RULE_BASE_HEIGHT : height
939
+ gaps = member_group_boundaries(klass.body.map(&:kind)).size
940
+ base + (rows * MEMBER_NOTE_ROW_HEIGHT) + (gaps * COMPACT_GROUP_GAP)
941
+ end
942
+
943
+ # Indexes of the rows that open a method group after a field group.
944
+ def member_group_boundaries(kinds)
945
+ kinds.each_cons(2).with_index(1).filter_map do |(left, right), index|
946
+ index if left == :field && right == :method
947
+ end
948
+ end
949
+
950
+ def qualified_box_width(klass, diagram, details)
951
+ widths = standard_title_widths(details[:names])
952
+ widths << qualified_generic_width(widths.max, details[:generic])
953
+ widths << qualified_incident_width(klass, diagram)
954
+ widths.concat(member_widths(details[:member_rows]))
955
+ widths.compact.max
956
+ end
957
+
958
+ def member_widths(rows)
959
+ rows.map { |row| measured_width(row) + (BOX_PADDING * 2) }
960
+ end
961
+
962
+ def qualified_generic_width(title_width, generic)
963
+ return unless generic
964
+
965
+ title_width + standard_text_width(generic,
966
+ QUALIFIED_GENERIC_FONT_SIZE) +
967
+ QUALIFIED_GENERIC_GAP
968
+ end
969
+
970
+ def qualified_incident_width(klass, diagram)
971
+ text = incident_qualifiers(klass, diagram).max_by(&:length)
972
+ return unless text
973
+
974
+ standard_text_width(text, font_size) + qualifier_allowance(diagram)
975
+ end
976
+
977
+ def incident_qualifiers(klass, diagram)
978
+ diagram.relations.filter_map do |relation|
979
+ next unless relation.direction.nil?
980
+ next unless [relation.left, relation.right].include?(klass.name)
981
+
982
+ qualified_text_at(relation, klass.name)
983
+ end
984
+ end
985
+
986
+ def qualifier_allowance(diagram)
987
+ if qualified_compact?(diagram)
988
+ COMPACT_QUALIFIER_ALLOWANCE
989
+ else
990
+ QUALIFIED_QUALIFIER_ALLOWANCE
991
+ end
992
+ end
993
+
994
+ def qualified_text_at(relation, name)
995
+ content = if relation.left == name
996
+ relation.left_multiplicity
997
+ else
998
+ relation.right_multiplicity
999
+ end
1000
+ return unless content&.start_with?("[")
1001
+
1002
+ content.delete_prefix("[").delete_suffix("]")
1003
+ .split("\\n").max_by(&:length)
1004
+ end
1005
+
1006
+ def qualified_box_height(diagram, generic)
1007
+ return HIDDEN_MEMBER_EMPTY_HEIGHT if qualified_compact?(diagram)
1008
+
1009
+ generic ? QUALIFIED_GENERIC_HEIGHT : QUALIFIED_BOX_HEIGHT
1010
+ end
1011
+
1012
+ def qualified_compact?(diagram)
1013
+ diagram.directives.include?("hide empty members")
1014
+ end
1015
+
1016
+ def hide_members_grid_box(record, diagram, details)
1017
+ return record unless hide_members_grid_family?(diagram)
1018
+
1019
+ members = details[:member_rows]
1020
+ widths = standard_title_widths(details[:names]) +
1021
+ isolated_class_kind_member_widths(members)
1022
+ record.merge(width: widths.max, compact_members: true,
1023
+ height: hide_members_box_height(members))
1024
+ end
1025
+
1026
+ def hide_members_box_height(members)
1027
+ return HIDDEN_MEMBER_EMPTY_HEIGHT if members.empty?
1028
+
1029
+ RESHOWN_MEMBER_BASE_HEIGHT +
1030
+ (members.size * MEMBER_NOTE_ROW_HEIGHT)
1031
+ end
1032
+
1033
+ def isolated_class_kind_box(record, diagram, details)
1034
+ return record unless isolated_class_kind_family?(diagram)
1035
+
1036
+ widths = standard_title_widths(details[:names]) +
1037
+ isolated_class_kind_member_widths(details[:member_rows])
1038
+ record.merge(
1039
+ title_rows: details[:names], width: widths.max,
1040
+ compact_members: true,
1041
+ height: MEMBER_NOTE_CLASS_HEIGHT +
1042
+ (details[:member_rows].size * MEMBER_NOTE_ROW_HEIGHT)
1043
+ )
1044
+ end
1045
+
1046
+ def isolated_class_kind_member_widths(rows)
1047
+ rows.map do |row|
1048
+ visible = row.sub(/\A[+\-#~]/, "")
1049
+ standard_text_width(visible, font_size) +
1050
+ ISOLATED_KIND_MEMBER_ALLOWANCE
1051
+ end
1052
+ end
1053
+
1054
+ # A class with no members keeps its two 8-high compartments below
1055
+ # the name, which ClassHeader rules.
1056
+ def compartmented_empty_box(record, member_rows, diagram)
1057
+ return record unless member_rows.empty? && empty_gaps?(diagram)
1058
+ return record unless record.fetch(:title_rows).one?
1059
+
1060
+ record.merge(height: record.fetch(:height) + EMPTY_COMPARTMENTS_EXTRA)
1061
+ end
1062
+
1063
+ def compact_empty_box(record, member_rows, diagram)
1064
+ return record unless member_rows.empty?
1065
+ return record unless compact_empty_members?(diagram)
1066
+
1067
+ record.merge(
1068
+ height: record[:height] - EMPTY_MEMBER_COMPARTMENT,
1069
+ width: record[:width] + EMPTY_CLASS_WIDTH_EXTRA,
1070
+ )
1071
+ end
1072
+
1073
+ def compact_empty_members?(diagram)
1074
+ RankChain.engine(diagram) == :smetana &&
1075
+ diagram.directives.include?("hide empty members")
1076
+ end
1077
+
1078
+ def compact_empty_item?(item, diagram)
1079
+ compact_empty_members?(diagram) && item[:member_rows]&.empty?
1080
+ end
1081
+
1082
+ def smetana_hide_circle_box(record, klass, diagram, details)
1083
+ return record unless smetana_hide_circle_class?(klass, diagram)
1084
+
1085
+ record.merge(
1086
+ width: hide_circle_box_width(details),
1087
+ compact_members: true,
1088
+ height: HIDE_CIRCLE_BASE_HEIGHT +
1089
+ (details[:member_rows].size * MEMBER_NOTE_ROW_HEIGHT),
1090
+ )
1091
+ end
1092
+
1093
+ def hide_circle_box_width(details)
1094
+ [
1095
+ *standard_row_widths(details[:names], font_size,
1096
+ HIDE_CIRCLE_TITLE_ALLOWANCE),
1097
+ *standard_row_widths(details[:member_rows], font_size,
1098
+ HIDE_CIRCLE_MEMBER_ALLOWANCE),
1099
+ ].max
1100
+ end
1101
+
1102
+ def smetana_hide_circle_class?(klass, diagram)
1103
+ RankChain.engine(diagram) == :smetana && plain_class?(klass) &&
1104
+ klass.package.nil? &&
1105
+ diagram.directives.any? { |line| line.match?(/\Ahide circle\z/i) }
1106
+ end
1107
+
1108
+ def adjust_plain_empty_width(specification, klass, diagram, names)
1109
+ if plain_empty_class?(klass, diagram, names)
1110
+ specification[:width] = plain_empty_class_width(names.first)
1111
+ elsif heterogeneous_smetana_empty_class?(klass, diagram, names)
1112
+ specification[:width] = standard_title_widths(names).max
1113
+ specification[:height] = MEMBER_NOTE_CLASS_HEIGHT
1114
+ end
1115
+ end
1116
+
1117
+ def plain_empty_class?(klass, diagram, names)
1118
+ return false unless simple_empty_class?(klass, names)
1119
+ return false unless uniform_empty_class_lengths?(diagram)
1120
+
1121
+ diagram.directives.none? do |line|
1122
+ line.match?(/\Ahide[ \t]+(?:circle|empty[ \t]+members)\z/i)
1123
+ end
1124
+ end
1125
+
1126
+ def simple_empty_class?(klass, names)
1127
+ plain_class?(klass) && klass.body.empty? && klass.package.nil? &&
1128
+ names.one?
1129
+ end
1130
+
1131
+ def uniform_empty_class_lengths?(diagram)
1132
+ names = diagram.classes.map do |candidate|
1133
+ rows = name_rows(candidate)
1134
+ return false unless simple_empty_class?(candidate, rows)
1135
+
1136
+ rows.first
1137
+ end
1138
+ names.map(&:length).uniq.one?
1139
+ end
1140
+
1141
+ def heterogeneous_smetana_empty_class?(klass, diagram, names)
1142
+ simple_empty_class?(klass, names) &&
1143
+ heterogeneous_smetana_empty_classes?(diagram)
1144
+ end
1145
+
1146
+ def heterogeneous_smetana_empty_classes?(diagram)
1147
+ return false unless plain_smetana_diagram?(diagram)
1148
+
1149
+ names = simple_empty_class_names(diagram)
1150
+ names && names.map(&:length).uniq.size > 1
1151
+ end
1152
+
1153
+ def plain_smetana_diagram?(diagram)
1154
+ RankChain.engine(diagram) == :smetana &&
1155
+ !compact_empty_members?(diagram) &&
1156
+ diagram.directives.none? { |line| hide_or_show?(line) }
1157
+ end
1158
+
1159
+ def simple_empty_class_names(diagram)
1160
+ diagram.classes.map do |candidate|
1161
+ rows = name_rows(candidate)
1162
+ return unless simple_empty_class?(candidate, rows)
1163
+
1164
+ rows.first
1165
+ end
1166
+ end
1167
+
1168
+ def hide_or_show?(line)
1169
+ line.match?(/\A(?:hide|show)\b/i)
1170
+ end
1171
+
1172
+ def plain_empty_class_width(name)
1173
+ title_width = Drawing::Face.widest_line(name, size: font_size)
1174
+ [((title_width * 2).round / 2.0) + 32.0,
1175
+ EMPTY_CLASS_MIN_WIDTH].max
1176
+ end
1177
+
1178
+ def multiline_title_box(record, klass, details)
1179
+ return record unless multiline_title_box?(record, klass, details)
1180
+
1181
+ rows = record.fetch(:title_rows).size
1182
+ height = MULTILINE_TITLE_VERTICAL_ALLOWANCE +
1183
+ (rows * MULTILINE_TITLE_ROW_HEIGHT)
1184
+ width = multiline_title_width(record.fetch(:title_rows))
1185
+ record.merge(width: width, height: height, compact_title_rows: true)
1186
+ end
1187
+
1188
+ def multiline_title_box?(record, klass, details)
1189
+ details[:member_rows].empty? && record.fetch(:title_rows).size > 1 &&
1190
+ header_rows(klass).empty?
1191
+ end
1192
+
1193
+ def multiline_title_width(rows)
1194
+ widths = rows.map do |row|
1195
+ measure_text(row, font_size: font_size)[:width]
1196
+ end
1197
+ widths.max.to_f + MULTILINE_TITLE_SIDE_ALLOWANCE
1198
+ end
1199
+
1200
+ def isolated_standard_box(record, klass, diagram, details)
1201
+ return record unless isolated_standard_box?(klass, diagram, details)
1202
+
1203
+ record = record.merge(
1204
+ width: isolated_standard_width(klass, details),
1205
+ height: isolated_standard_height(klass, diagram,
1206
+ details[:member_rows]),
1207
+ compact_members: header_rows(klass).empty?,
1208
+ )
1209
+ stereotype_headed(record, klass, details)
1210
+ end
1211
+
1212
+ # A lone class with one stereotype takes the jar's header: circle
1213
+ # at the left, stereotype above the name, rows packed from the foot.
1214
+ def stereotype_headed(record, klass, details)
1215
+ return record unless StereotypeHead.applies?(klass,
1216
+ circle: @kind_in_spot)
1217
+
1218
+ row = "«#{klass.stereotypes.first}»"
1219
+ sizes = stereotype_head_sizes(row, details[:names].first)
1220
+ width = [StereotypeHead.width(*sizes),
1221
+ *standard_member_widths(details[:member_rows])].max
1222
+ record.merge(width: width, compact_members: true,
1223
+ stereotype_head: { row: row, sizes: sizes })
1224
+ end
1225
+
1226
+ def stereotype_head_sizes(row, name)
1227
+ [standard_text_width(row, font_size * 0.85),
1228
+ standard_text_width(name, font_size)]
1229
+ end
1230
+
1231
+ def isolated_standard_box?(klass, diagram, details)
1232
+ lone_standard_class?(klass, diagram) &&
1233
+ (details[:member_rows].any? || klass.stereotypes.any?)
1234
+ end
1235
+
1236
+ def lone_standard_class?(klass, diagram)
1237
+ diagram.classes.one? && diagram.notes.empty? &&
1238
+ standalone_diagram?(diagram) && klass.kind == :class &&
1239
+ klass.generics.nil?
1240
+ end
1241
+
1242
+ def isolated_standard_width(klass, details)
1243
+ [standard_title_widths(details[:names]),
1244
+ standard_header_widths(klass),
1245
+ standard_member_widths(details[:member_rows])].flatten.max
1246
+ end
1247
+
1248
+ def standard_title_widths(rows)
1249
+ standard_row_widths(rows, font_size,
1250
+ STANDARD_TITLE_SIDE_ALLOWANCE)
1251
+ end
1252
+
1253
+ def standard_header_widths(klass)
1254
+ rows = header_rows(klass).map do |row|
1255
+ row.gsub(/<<([^<>]+)>>/, '«\1»')
1256
+ end
1257
+ standard_row_widths(rows, font_size * (12.0 / 14.0),
1258
+ STANDARD_TITLE_SIDE_ALLOWANCE)
1259
+ end
1260
+
1261
+ def standard_member_widths(rows)
1262
+ standard_row_widths(rows, font_size,
1263
+ STANDARD_MEMBER_SIDE_ALLOWANCE)
1264
+ end
1265
+
1266
+ def standard_row_widths(rows, size, allowance)
1267
+ rows.map { |row| standard_text_width(row, size) + allowance }
1268
+ end
1269
+
1270
+ def standard_text_width(text, size)
1271
+ Drawing::Face.widest_line(text, size: size)
1272
+ end
1273
+
1274
+ def isolated_standard_height(klass, diagram, member_rows)
1275
+ headers = header_rows(klass)
1276
+ unless headers.empty?
1277
+ return MEMBER_NOTE_CLASS_HEIGHT +
1278
+ (member_rows.size * MEMBER_NOTE_ROW_HEIGHT) +
1279
+ (headers.size * STANDARD_STEREOTYPE_ROW_HEIGHT)
1280
+ end
1281
+
1282
+ pitched_member_height(klass, diagram, member_rows)
1283
+ end
1284
+
1285
+ def pitched_member_height(klass, diagram, member_rows)
1286
+ PROPORTIONAL_MEMBER_BASE_HEIGHT +
1287
+ MemberPitch.layout(klass.body.map(&:kind), member_rows,
1288
+ gaps: empty_gaps?(diagram)).last
1289
+ end
1290
+
1291
+ def box_context(klass, diagram)
1292
+ modifiers = klass.body.map { |member| rule_modifiers(member) }
1293
+ { member_modifiers: modifiers, package: klass.package,
1294
+ member_kinds: klass.body.map(&:kind),
1295
+ member_visibilities: klass.body.map(&:visibility),
1296
+ icons_enabled: MemberIcon.enabled?(diagram.directives),
1297
+ empty_gaps: empty_gaps?(diagram),
1298
+ chain: diagram.package_chain(klass.package) }
1299
+ end
1300
+
1301
+ # A separator row carries the pseudo-modifier :rule, which the box
1302
+ # draws as a line instead of text.
1303
+ def rule_modifiers(member)
1304
+ member.kind == :separator ? [:rule, member.type] : member.modifiers
1305
+ end
1306
+
1307
+ def note_specifications(notes)
1308
+ notes.each_with_index.map do |note, index|
1309
+ rows = note.lines
1310
+ { id: "note-#{index}", note: note, rows: rows,
1311
+ width: measured_box_width(rows),
1312
+ height: (BOX_PADDING * 2) + ([rows.size, 1].max * ROW_HEIGHT) }
1313
+ end
1314
+ end
1315
+
1316
+ def box_record(name, title_rows, member_rows)
1317
+ rows = title_rows + member_rows
1318
+
1319
+ specification = { id: name }
1320
+ specification[:width] = measured_box_width(rows)
1321
+ specification[:height] = box_height(rows, member_rows)
1322
+ specification[:title_rows] = title_rows
1323
+ specification[:member_rows] = member_rows
1324
+ specification
1325
+ end
1326
+
1327
+ def measured_box_width(rows)
1328
+ measured = rows.map { |text| measured_width(text) }
1329
+ measured.max.to_f + (BOX_PADDING * 2)
1330
+ end
1331
+
1332
+ def box_height(rows, member_rows)
1333
+ height = (BOX_PADDING * 2) + (rows.size * ROW_HEIGHT)
1334
+ member_rows.empty? ? height : height + 6.0
1335
+ end
1336
+
1337
+ def header_rows(klass)
1338
+ kind = ["<<#{klass.kind}>>"] unless spot_names_kind?(klass)
1339
+ tags = klass.stereotypes.map { |tag| "<<#{tag}>>" }
1340
+ [*kind, *(tags.join(" ") unless tags.empty?)]
1341
+ end
1342
+
1343
+ # A class whose one-line name carries a circle needs no `<<kind>>`
1344
+ # row.
1345
+ def spot_names_kind?(klass)
1346
+ klass.kind == :class ||
1347
+ (@kind_in_spot && !ClassHeader.glyph(klass.kind).nil? &&
1348
+ ClassName.lines(klass.name).one?)
1349
+ end
1350
+
1351
+ def name_rows(klass, separate_generics: false)
1352
+ *first, last = ClassName.lines(klass.name)
1353
+ return [*first, last, klass.generics] if separate_generics &&
1354
+ klass.generics
1355
+
1356
+ [*first, (klass.generics ? "#{last}<#{klass.generics}>" : last)]
1357
+ end
1358
+
1359
+ def measured_width(text)
1360
+ measure_text(text, font_size: font_size, monospace: true)[:width]
1361
+ end
1362
+
1363
+ def font_size
1364
+ theme.typography.font_size_normal.to_f
1365
+ end
1366
+
1367
+ def member_text(member)
1368
+ return member.name if member.kind == :separator
1369
+
1370
+ name = member.name.dup
1371
+ name += "(#{member.parameters})" if member.parameters
1372
+ type = member.type
1373
+ content = if type && member.type_position == :prefix
1374
+ "#{type} #{name}"
1375
+ else
1376
+ "#{name}#{"#{member.type_separator}#{type}" if type}"
1377
+ end
1378
+ "#{visibility_mark(member.visibility)}#{content}"
1379
+ end
1380
+
1381
+ def visibility_mark(visibility)
1382
+ MemberIcon.mark(visibility)
1383
+ end
1384
+
1385
+ # Classes outside any package first, then each package's own classes
1386
+ # in the order the packages open (an outer package before the ones
1387
+ # inside it), every group padded to whole rows so a row holds one
1388
+ # group only.
1389
+ def rows_by_package(specifications, diagram)
1390
+ columns = column_count(specifications)
1391
+ package_groups(specifications, diagram).flat_map do |group|
1392
+ group.each_slice(columns).map { |row| pad(row, columns) }
1393
+ end
1394
+ end
1395
+
1396
+ def package_groups(specifications, diagram)
1397
+ by_package = specifications.group_by { |item| item[:package] }
1398
+ ids = package_group_ids(diagram)
1399
+ ids.filter_map { |id| by_package[id] }
1400
+ end
1401
+
1402
+ def package_group_ids(diagram)
1403
+ package_ids = diagram.packages.map(&:id)
1404
+ source_package = diagram.relations.any? do |relation|
1405
+ package_ids.include?(relation.left) &&
1406
+ !package_ids.include?(relation.right)
1407
+ end
1408
+ source_package ? [*package_ids, nil] : [nil, *package_ids]
1409
+ end
1410
+
1411
+ def pad(row, columns)
1412
+ row + Array.new(columns - row.size)
1413
+ end
1414
+
1415
+ def stable_centers?(diagram)
1416
+ horizontal = diagram.directives.include?("left to right direction")
1417
+ diagram.relations.any? do |relation|
1418
+ qualifiers = [relation.left_multiplicity,
1419
+ relation.right_multiplicity]
1420
+ # PlantUML reserves relation-label lanes for qualifiers and for
1421
+ # fully annotated horizontal ends independently of class chrome.
1422
+ qualifiers.any? { |text| text&.start_with?("[") } ||
1423
+ (horizontal && annotated_ends?(relation))
1424
+ end
1425
+ end
1426
+
1427
+ def annotated_ends?(relation)
1428
+ [relation.left_role, relation.right_role].compact.size == 2
1429
+ end
1430
+
1431
+ def intrinsic_box_widths?(diagram)
1432
+ package_endpoint_relation?(diagram) || intrinsic_content?(diagram)
1433
+ end
1434
+
1435
+ def intrinsic_content?(diagram)
1436
+ body = diagram.classes.any? { |klass| klass.body.any? }
1437
+ diagram.notes.any? || body ||
1438
+ heterogeneous_smetana_empty_classes?(diagram) ||
1439
+ diagram.directives.any? { |line| hide_or_show?(line) }
1440
+ end
1441
+
1442
+ def position_boxes(rows, box_width, stable_centers, intrinsic_widths,
1443
+ diagram)
1444
+ tops = row_tops(rows, diagram)
1445
+ placement = box_placement(rows, box_width, stable_centers,
1446
+ intrinsic_widths, diagram)
1447
+ rows.each_with_index.flat_map do |row, row_index|
1448
+ position_row(row, tops[row_index], box_width, placement)
1449
+ end
1450
+ end
1451
+
1452
+ def box_placement(rows, box_width, stable_centers, intrinsic_widths,
1453
+ diagram)
1454
+ { left: left_margin(rows, diagram),
1455
+ footprint: box_footprint(box_width, stable_centers),
1456
+ box_width: box_width,
1457
+ intrinsic_widths: intrinsic_widths,
1458
+ left_align: intrinsic_widths &&
1459
+ package_endpoint_relation?(diagram) }
1460
+ end
1461
+
1462
+ def position_row(row, top, box_width, placement)
1463
+ row.each_with_index.filter_map do |item, column|
1464
+ x = box_left(item, column, placement)
1465
+ width = positioned_box_width(item, box_width, placement)
1466
+ build_box(item, x, top, width) if item
1467
+ end
1468
+ end
1469
+
1470
+ def positioned_box_width(item, box_width, placement)
1471
+ return box_width unless placement[:intrinsic_widths]
1472
+
1473
+ item&.fetch(:width)
1474
+ end
1475
+
1476
+ def box_footprint(box_width, stable_centers)
1477
+ return box_width unless stable_centers
1478
+
1479
+ [ANNOTATED_RELATION_SLOT_WIDTH, box_width].max
1480
+ end
1481
+
1482
+ def box_left(item, column, placement)
1483
+ slot = placement[:left] +
1484
+ (column * (placement[:footprint] + COLUMN_GAP))
1485
+ return slot if placement[:left_align]
1486
+
1487
+ width = if item && placement[:intrinsic_widths]
1488
+ item.fetch(:width)
1489
+ else
1490
+ placement[:box_width]
1491
+ end
1492
+
1493
+ slot + ((placement[:footprint] - width) / 2)
1494
+ end
1495
+
1496
+ def row_tops(rows, diagram)
1497
+ chains = rows.map { |row| row.compact.first[:chain] || [] }
1498
+ first = [top_margin(diagram) +
1499
+ frame_space([], chains.first, diagram)]
1500
+ steps = row_steps(rows, chains, diagram)
1501
+ steps.each_with_object(first) do |step, tops|
1502
+ tops << (tops.last + step)
1503
+ end
1504
+ end
1505
+
1506
+ def row_steps(rows, chains, diagram)
1507
+ row_heights(rows).each_with_index.map do |height, index|
1508
+ below = chains[index + 1] || []
1509
+ space = frame_space(chains[index], below, diagram)
1510
+ height + row_gap(diagram, chains[index], below) + space
1511
+ end
1512
+ end
1513
+
1514
+ def row_heights(rows)
1515
+ rows.map { |row| row.compact.map { |item| item[:height] }.max }
1516
+ end
1517
+
1518
+ # The room for the frames closed on leaving the packages `above` and
1519
+ # opened on entering the packages `below`, each given outermost first.
1520
+ def frame_space(above, below, diagram)
1521
+ shared = above.zip(below).take_while { |a, b| a == b }.size
1522
+ closing = above.size - shared
1523
+ opening = below.size - shared
1524
+ if package_endpoint_relation?(diagram)
1525
+ return nested_frame_space(closing, opening)
1526
+ end
1527
+
1528
+ (closing * PackageFrames::CLOSE) +
1529
+ (opening * PackageFrames::OPEN)
1530
+ end
1531
+
1532
+ def nested_frame_space(closing, opening)
1533
+ PackageFrames.stack_close(closing) +
1534
+ PackageFrames.stack_open(opening)
1535
+ end
1536
+
1537
+ def top_margin(diagram)
1538
+ package_endpoint_relation?(diagram) ? PACKAGE_CANVAS_MARGIN : MARGIN
1539
+ end
1540
+
1541
+ def row_gap(diagram, above, below)
1542
+ crossing = above.empty? != below.empty?
1543
+ if package_endpoint_relation?(diagram) && crossing
1544
+ PACKAGE_OUTER_GAP
1545
+ else
1546
+ ROW_GAP
1547
+ end
1548
+ end
1549
+
1550
+ def column_count(items)
1551
+ items.compact.size > 1 ? 2 : 1
1552
+ end
1553
+
1554
+ # Room on the left for the deepest stack of package frames.
1555
+ def left_margin(rows, diagram)
1556
+ items = rows.flat_map(&:compact)
1557
+ depth = package_depth(items)
1558
+ if package_endpoint_relation?(diagram)
1559
+ return package_left_margin(depth)
1560
+ end
1561
+
1562
+ return MARGIN if depth.zero?
1563
+
1564
+ [MARGIN, (depth * PackageFrames::SIDE) + 8.0].max
1565
+ end
1566
+
1567
+ def package_depth(items)
1568
+ items.filter_map { |item| item[:chain]&.size }.max.to_i
1569
+ end
1570
+
1571
+ def package_left_margin(depth)
1572
+ PACKAGE_CANVAS_MARGIN + PackageFrames.stack_side(depth)
1573
+ end
1574
+
1575
+ def package_endpoint_relation?(diagram)
1576
+ PackageFrames.relation_layout?(diagram)
1577
+ end
1578
+
1579
+ def align_package_relation_targets(diagram, boxes, specifications)
1580
+ by_name = boxes.to_h { |box| [box.id, box] }
1581
+ specs = specifications.to_h { |item| [item[:id], item] }
1582
+ replace_package_targets(diagram, by_name, specs)
1583
+ boxes.map { |box| by_name.fetch(box.id) }
1584
+ end
1585
+
1586
+ def replace_package_targets(diagram, by_name, specs)
1587
+ diagram.relations.each do |relation|
1588
+ replacement = aligned_package_target(diagram, by_name, specs,
1589
+ relation)
1590
+ by_name[replacement.id] = replacement if replacement
1591
+ end
1592
+ end
1593
+
1594
+ def aligned_package_target(diagram, by_name, specs, relation)
1595
+ return unless package_source?(diagram, relation)
1596
+
1597
+ target = by_name[relation.right]
1598
+ descendants = package_descendant_boxes(diagram, by_name,
1599
+ relation.left)
1600
+ return unless target && descendants.any?
1601
+
1602
+ build_aligned_package_target(diagram, specs, relation, target,
1603
+ descendants)
1604
+ end
1605
+
1606
+ def build_aligned_package_target(diagram, specs, relation, target,
1607
+ descendants)
1608
+ left = package_target_left(diagram, descendants, target, relation)
1609
+ build_box(specs.fetch(target.id), left, target.y, target.width)
1610
+ end
1611
+
1612
+ def package_source?(diagram, relation)
1613
+ diagram.packages.any? { |item| item.id == relation.left }
1614
+ end
1615
+
1616
+ def package_target_left(diagram, descendants, target, relation)
1617
+ edge = descendants.map { |box| box.x + box.width }.max
1618
+ depth = package_relation_depth(diagram, relation.left)
1619
+ edge + PackageFrames.stack_side(depth) - (target.width / 2)
1620
+ end
1621
+
1622
+ def package_descendant_boxes(diagram, by_name, package_id)
1623
+ diagram.classes.filter_map do |klass|
1624
+ chain = diagram.package_chain(klass.package)
1625
+ next unless chain.include?(package_id)
1626
+
1627
+ by_name[klass.name]
1628
+ end
1629
+ end
1630
+
1631
+ def package_relation_depth(diagram, package_id)
1632
+ diagram.classes.filter_map do |klass|
1633
+ chain = diagram.package_chain(klass.package)
1634
+ index = chain.index(package_id)
1635
+ chain.size - index if index
1636
+ end.max
1637
+ end
1638
+
1639
+ def build_box(item, left, top, box_width)
1640
+ texts, separators = box_contents(item, left, top, box_width)
1641
+
1642
+ Scene::Box.new(
1643
+ id: item[:id], x: left, y: top, width: box_width,
1644
+ height: item[:height], texts: texts, separators: separators,
1645
+ kind: item[:note] ? "note" : "class", fill: item[:note]&.color,
1646
+ spot: stereotype_spot(item, left, top)
1647
+ )
1648
+ end
1649
+
1650
+ def stereotype_spot(item, left, top)
1651
+ return unless item[:stereotype_head]
1652
+
1653
+ across, down = StereotypeHead.circle_centre
1654
+ Scene::Spot.new(kind: "class", cx: left + across, cy: top + down)
1655
+ end
1656
+
1657
+ def note_texts(item, horizontal, vertical)
1658
+ item[:rows].each_with_index.map do |content, index|
1659
+ baseline = vertical + BOX_PADDING + font_size + (index * ROW_HEIGHT)
1660
+ scene_text(content, horizontal + BOX_PADDING, baseline,
1661
+ "note", "start")
1662
+ end
1663
+ end
1664
+
1665
+ def box_contents(item, horizontal, vertical, width)
1666
+ return [note_texts(item, horizontal, vertical), []] if item[:note]
1667
+
1668
+ item = item.merge(compact_bottom: vertical + item[:height])
1669
+ texts, cursor = title_texts(item, horizontal, vertical, width)
1670
+ members, separators = member_contents(
1671
+ item, horizontal, cursor, width
1672
+ )
1673
+ [texts + members, separators]
1674
+ end
1675
+
1676
+ def title_texts(item, horizontal, vertical, width)
1677
+ if item[:stereotype_head]
1678
+ return stereotype_head_texts(item, horizontal, vertical)
1679
+ end
1680
+
1681
+ cursor = title_text_cursor(item, vertical)
1682
+ texts, cursor = title_text_rows(item, horizontal, width, cursor)
1683
+ texts << floating_generic_text(item, horizontal, vertical, width)
1684
+ [texts.compact, cursor]
1685
+ end
1686
+
1687
+ def stereotype_head_texts(item, horizontal, vertical)
1688
+ head = item[:stereotype_head]
1689
+ centre = horizontal + StereotypeHead.centre(*head[:sizes])
1690
+ first, second = StereotypeHead.baselines
1691
+ [[scene_text(head[:row], centre, vertical + first, "kind", "middle"),
1692
+ scene_text(item[:title_rows].last, centre, vertical + second,
1693
+ "class_name", "middle")], vertical + second]
1694
+ end
1695
+
1696
+ def title_text_rows(item, horizontal, width, cursor)
1697
+ texts = item[:title_rows].each_with_index.map do |content, index|
1698
+ text = scene_text(content, horizontal + (width / 2), cursor,
1699
+ title_role(item, index), "middle")
1700
+ cursor += title_row_height(item)
1701
+ text
1702
+ end
1703
+ [texts, cursor]
1704
+ end
1705
+
1706
+ def title_text_cursor(item, vertical)
1707
+ if item[:compact_title_rows]
1708
+ return vertical + MULTILINE_TITLE_BASELINE
1709
+ end
1710
+
1711
+ baseline = vertical + BOX_PADDING + font_size
1712
+ return baseline unless compact_single_title?(item)
1713
+
1714
+ [baseline, compact_row_geometry(item)[:head] - 10.709].min
1715
+ end
1716
+
1717
+ def compact_single_title?(item)
1718
+ item[:compact_members] && item[:title_rows].size == 1 &&
1719
+ item[:member_rows].any?
1720
+ end
1721
+
1722
+ def title_row_height(item)
1723
+ item[:compact_title_rows] ? MULTILINE_TITLE_ROW_HEIGHT : ROW_HEIGHT
1724
+ end
1725
+
1726
+ def floating_generic_text(item, horizontal, vertical, width)
1727
+ generic = item[:floating_generic]
1728
+ return unless generic
1729
+
1730
+ scaled_width = measured_width(generic) * 0.85
1731
+ scene_text(generic, horizontal + width - scaled_width + 4.0,
1732
+ vertical + (font_size * 0.9), "kind", "start")
1733
+ end
1734
+
1735
+ def title_role(item, index)
1736
+ header = item[:title_rows].size - item[:name_rows]
1737
+ index < header ? "kind" : "class_name"
1738
+ end
1739
+
1740
+ def member_contents(item, horizontal, cursor, width)
1741
+ return [[], []] if item[:member_rows].empty?
1742
+
1743
+ rows = member_row_geometry(item, cursor)
1744
+ [member_texts(item, horizontal, width, rows[:baselines]),
1745
+ member_lines(item, horizontal, width, rows)]
1746
+ end
1747
+
1748
+ def member_lines(item, horizontal, width, rows)
1749
+ base = rows[:baselines]
1750
+ [segment(horizontal, rows[:head], horizontal + width, rows[:head]),
1751
+ *group_rules(item, horizontal, width, base),
1752
+ *underlines(item, horizontal, base),
1753
+ *rules(item, horizontal, width, base)]
1754
+ end
1755
+
1756
+ # Baselines of the member rows and the y of the rule above them. The
1757
+ # default box stacks rows from the title; a compact box, whose height
1758
+ # was sized from the reference row pitch, stacks them up from its
1759
+ # bottom edge so the last row always lies inside it.
1760
+ def member_row_geometry(item, cursor)
1761
+ return compact_row_geometry(item) if item[:compact_members]
1762
+
1763
+ { head: cursor - (ROW_HEIGHT / 2),
1764
+ baselines: item[:member_rows].each_index.map do |index|
1765
+ cursor + 6.0 + (index * ROW_HEIGHT)
1766
+ end }
1767
+ end
1768
+
1769
+ def compact_row_geometry(item)
1770
+ offsets = compact_row_offsets(item)
1771
+ last = item.fetch(:compact_bottom) - COMPACT_BOTTOM_PAD -
1772
+ empty_gap(item, :tail)
1773
+ baselines = offsets.map { |offset| last - offsets.last + offset }
1774
+ { head: baselines.first - COMPACT_HEAD_GAP - offsets.first,
1775
+ baselines: baselines }
1776
+ end
1777
+
1778
+ # `:lead` or `:tail`: the gap an empty field or method group keeps.
1779
+ def empty_gap(item, side)
1780
+ return 0.0 unless item[:empty_gaps]
1781
+
1782
+ MemberPitch.public_send(side, item[:member_kinds])
1783
+ end
1784
+
1785
+ def compact_row_offsets(item)
1786
+ MemberPitch.layout(item[:member_kinds], item[:member_rows],
1787
+ gaps: item[:empty_gaps]).first
1788
+ end
1789
+
1790
+ # A thin rule between the field group and the method group, and one
1791
+ # closing whichever of them is empty.
1792
+ def group_rules(item, horizontal, width, baselines)
1793
+ return [] unless item[:compact_members]
1794
+
1795
+ group_rule_heights(item, baselines).map do |at|
1796
+ segment(horizontal, at, horizontal + width, at)
1797
+ end
1798
+ end
1799
+
1800
+ def group_rule_heights(item, baselines)
1801
+ member_group_boundaries(item[:member_kinds]).map do |index|
1802
+ ((baselines[index - 1] + baselines[index]) / 2.0) - 5.2911
1803
+ end + empty_group_rules(item, baselines)
1804
+ end
1805
+
1806
+ def empty_group_rules(item, baselines)
1807
+ [(baselines.first - COMPACT_HEAD_GAP if
1808
+ empty_gap(item, :lead).positive?),
1809
+ (baselines.last + COMPACT_BOTTOM_PAD if
1810
+ empty_gap(item, :tail).positive?)].compact
1811
+ end
1812
+
1813
+ def member_texts(item, horizontal, width, baselines)
1814
+ insets = member_insets(item)
1815
+ item[:member_rows].each_with_index.filter_map do |content, index|
1816
+ next if content.empty?
1817
+
1818
+ text = member_text_row(visible_member(item, content, index),
1819
+ item[:member_modifiers][index],
1820
+ [horizontal, width, insets[index]],
1821
+ baselines.fetch(index))
1822
+ text.icon = member_icon(item, index, horizontal, baselines)
1823
+ text
1824
+ end
1825
+ end
1826
+
1827
+ def member_icon(item, index, horizontal, baselines)
1828
+ return unless item[:icons_enabled]
1829
+
1830
+ MemberIcon.at(item[:member_visibilities][index],
1831
+ item[:member_kinds][index], horizontal,
1832
+ baselines.fetch(index))
1833
+ end
1834
+
1835
+ def member_insets(item)
1836
+ MemberIcon.insets(item[:member_kinds], item[:member_visibilities],
1837
+ item[:icons_enabled])
1838
+ end
1839
+
1840
+ def visible_member(item, content, index)
1841
+ MemberIcon.visible_text(content, item[:member_visibilities][index],
1842
+ item[:icons_enabled])
1843
+ end
1844
+
1845
+ # A separator's title is centred; any other row starts at its inset.
1846
+ def member_text_row(content, modifiers, box, vertical)
1847
+ left, width, inset = box
1848
+ if modifiers.include?(:rule)
1849
+ return scene_text(content, left + (width / 2.0), vertical,
1850
+ "member", "middle")
1851
+ end
1852
+
1853
+ role = modifiers.include?(:abstract) ? "member_abstract" : "member"
1854
+ scene_text(content, left + inset, vertical, role, "start")
1855
+ end
1856
+
1857
+ # A separator is a rule across the box, broken around its title.
1858
+ def rules(item, horizontal, width, baselines)
1859
+ item[:member_rows].each_with_index.flat_map do |content, index|
1860
+ rule, character = item[:member_modifiers][index]
1861
+ next [] unless rule == :rule
1862
+
1863
+ MemberRule.style(character,
1864
+ rule_segments(content, horizontal,
1865
+ baselines.fetch(index) - 4.0, width))
1866
+ end
1867
+ end
1868
+
1869
+ def rule_segments(content, horizontal, vertical, width)
1870
+ right = horizontal + width
1871
+ if content.empty?
1872
+ return [segment(horizontal, vertical, right,
1873
+ vertical)]
1874
+ end
1875
+
1876
+ gap = standard_text_width(content, font_size) / 2.0
1877
+ middle = horizontal + (width / 2.0)
1878
+ [segment(horizontal, vertical, middle - gap, vertical),
1879
+ segment(middle + gap, vertical, right, vertical)]
1880
+ end
1881
+
1882
+ # A static member is underlined, as PlantUML draws it.
1883
+ def underlines(item, horizontal, baselines)
1884
+ item[:member_rows].each_with_index.filter_map do |content, index|
1885
+ next unless item[:member_modifiers][index].include?(:static)
1886
+
1887
+ underline(item, horizontal, content, [index, baselines])
1888
+ end
1889
+ end
1890
+
1891
+ def underline(item, horizontal, content, (index, baselines))
1892
+ vertical = baselines.fetch(index) + 2.0
1893
+ left = horizontal + member_insets(item).fetch(index)
1894
+ width = standard_text_width(visible_member(item, content, index),
1895
+ font_size)
1896
+ segment(left, vertical, left + width, vertical)
1897
+ end
1898
+
1899
+ def build_relations(diagram, boxes, nodes)
1900
+ by_name = boxes.to_h { |box| [box.id.delete_prefix("package-"), box] }
1901
+ owners = junction_owners(diagram)
1902
+ engine = RankChain.engine(diagram)
1903
+ context = { by_name: by_name, owners: owners, nodes: nodes,
1904
+ engine: engine }
1905
+ scenes = diagram.relations.each_with_index.flat_map do |rel, index|
1906
+ next [] if rel.layout_only?
1907
+
1908
+ relation_scenes(rel, index, context)
1909
+ end
1910
+ separate_relation_labels(scenes, boxes)
1911
+ end
1912
+
1913
+ def separate_relation_labels(scenes, boxes)
1914
+ RelationLabelSeparator.new(
1915
+ boxes: boxes,
1916
+ size: font_size * SMALL_TEXT_SCALE,
1917
+ width_of: method(:end_text_width),
1918
+ drawn_width_of: lambda { |content|
1919
+ Drawing::Face.width(content, size: font_size * SMALL_TEXT_SCALE)
1920
+ },
1921
+ ).call(scenes)
1922
+ end
1923
+
1924
+ def relation_scenes(relation, index, context)
1925
+ by_name = context.fetch(:by_name)
1926
+ ends = relation_ends(relation, by_name)
1927
+ end_texts = EndTexts.new(relation, context.fetch(:engine))
1928
+ junctions = context.fetch(:owners)[index]
1929
+ if junctions.empty?
1930
+ return plain_relation_scene(relation, index, ends, end_texts)
1931
+ end
1932
+
1933
+ junction_relation_scenes(relation, index,
1934
+ [ends, end_texts, junctions], context)
1935
+ end
1936
+
1937
+ def relation_ends(relation, by_name)
1938
+ [relation.left, relation.right].map { |name| by_name.fetch(name) }
1939
+ end
1940
+
1941
+ def plain_relation_scene(relation, index, ends, end_texts)
1942
+ [relation_scene(relation, "relation-#{index}",
1943
+ relation_endpoints(*ends), end_texts)]
1944
+ end
1945
+
1946
+ def junction_relation_scenes(relation, index, details, context)
1947
+ ends, end_texts, junctions = details
1948
+ by_name = context.fetch(:by_name)
1949
+ junction = junction_box(context.fetch(:nodes)[index], ends,
1950
+ context.fetch(:engine))
1951
+ entries = junctions.map { |entry| [*entry, by_name] }
1952
+ junction_scenes(relation, index, [ends, junction, end_texts], entries)
1953
+ end
1954
+
1955
+ # The association classes, with their numbers, grouped by the index
1956
+ # of the first relation joining their pair, written either way round.
1957
+ def junction_owners(diagram)
1958
+ owners = Hash.new { |hash, index| hash[index] = [] }
1959
+ diagram.junctions.each_with_index do |junction, number|
1960
+ index = junction_relation_index(diagram.relations, junction)
1961
+ owners[index] << [junction, number]
1962
+ end
1963
+ owners
1964
+ end
1965
+
1966
+ def junction_relation_index(relations, junction)
1967
+ pair = [junction.from, junction.to].sort
1968
+ relations.index do |relation|
1969
+ [relation.left, relation.right].sort == pair
1970
+ end
1971
+ end
1972
+
1973
+ # The junction node, or a zero-size one at the middle of the
1974
+ # relation when the grid placed the classes.
1975
+ def junction_box(node, ends, engine)
1976
+ node ||= [*midpoint(*relation_endpoints(*ends).values_at(:left,
1977
+ :right)),
1978
+ 0.0, 0.0]
1979
+ x, y, width, height = node
1980
+ # Smetana's legs stop at the junction node's edge; the default
1981
+ # engine runs one line through its middle.
1982
+ return junction_node(x, y, width, height) unless engine == :dot
1983
+
1984
+ Scene::Box.new(id: "junction", x: x + (width / 2),
1985
+ y: y + (height / 2), width: 0.0, height: 0.0,
1986
+ texts: [], separators: [], kind: "junction")
1987
+ end
1988
+
1989
+ def relation_scene(relation, id, endpoints, end_texts, style = {})
1990
+ sides = style.fetch(:sides, %i[left right])
1991
+ boxes, endpoints = qualifier_boxes(relation, end_texts, endpoints,
1992
+ sides)
1993
+ texts = relation_texts(relation, endpoints, end_texts, sides,
1994
+ boxes.size)
1995
+ drawn = styled_markers(relation, style, sides, endpoints)
1996
+ relation_record(relation, id, texts: texts, markers: drawn,
1997
+ qualifiers: boxes,
1998
+ path: relation_path_for(relation,
1999
+ endpoints))
2000
+ end
2001
+
2002
+ def styled_markers(relation, style, sides, endpoints)
2003
+ relation_markers(relation, style.fetch(:markers, relation.markers),
2004
+ sides, endpoints)
2005
+ end
2006
+
2007
+ def relation_record(relation, id, **drawn)
2008
+ Scene::Relation.new(id: id, dashed: relation.dashed?,
2009
+ dotted: relation.dotted?,
2010
+ stroke: svg_colour(relation.color),
2011
+ stroke_width: relation.width, **drawn)
2012
+ end
2013
+
2014
+ # A one-line bracketed qualifier is a box on the class edge, and the
2015
+ # link starts at the box's far side.
2016
+ # @return [Array(Array<Scene::Box>, Hash)] the boxes and the
2017
+ # endpoints moved clear of them
2018
+ def qualifier_boxes(relation, end_texts, endpoints, sides)
2019
+ first, second = end_texts.sides
2020
+ direction = unit_vector(*endpoints.values_at(first, second))
2021
+ entries = qualifier_entries(end_texts, endpoints, sides, relation)
2022
+ placed = entries.to_h do |entry|
2023
+ [entry[:side], qualifier_placement(entry, endpoints, direction,
2024
+ first)]
2025
+ end
2026
+ [placed.values.map(&:box),
2027
+ endpoints.merge(placed) { |_, tip, found| found.clear(tip) }]
2028
+ end
2029
+
2030
+ # Only on a straight vertical, unlabelled link: a slanted or flat one
2031
+ # is drawn between estimated class positions and beside labels that
2032
+ # sit away from the reference's, so a box would land off it.
2033
+ def qualifier_entries(end_texts, endpoints, sides, relation)
2034
+ return [] unless boxable_link?(endpoints, relation)
2035
+
2036
+ end_texts.entries.select do |entry|
2037
+ entry[:kind] == :qualifier && entry[:pieces].one? &&
2038
+ sides.include?(entry[:side])
2039
+ end
2040
+ end
2041
+
2042
+ def boxable_link?(endpoints, relation)
2043
+ left, right = endpoints.values_at(:left, :right)
2044
+ !endpoints[:control] && !relation.label &&
2045
+ (left[0] - right[0]).abs < AXIS_TOLERANCE
2046
+ end
2047
+
2048
+ def qualifier_placement(entry, endpoints, direction, first)
2049
+ content = entry[:pieces].first
2050
+ outward = entry[:side] == first ? direction : direction.map(&:-@)
2051
+ width = measure_text(content, font_size: font_size)[:width]
2052
+ QualifierBox.place(content, endpoints.fetch(entry[:side]), outward,
2053
+ width, font_size)
2054
+ end
2055
+
2056
+ # A bracketed colour is a CSS name (`red`) or bare hex digits.
2057
+ def svg_colour(color)
2058
+ return unless color
2059
+
2060
+ color.match?(/\A(?:\h{3}|\h{6}|\h{8})\z/) ? "##{color}" : color
2061
+ end
2062
+
2063
+ def relation_markers(relation, markers, sides, endpoints)
2064
+ half_width = qualified_marker_half_width(relation, endpoints)
2065
+ drawn_markers = marker_scenes(markers.slice(*sides), endpoints,
2066
+ half_width: half_width)
2067
+ drawn_markers << label_arrow_scene(relation, endpoints)
2068
+ scale_thick_markers(drawn_markers.compact, relation, endpoints)
2069
+ end
2070
+
2071
+ # PlantUML grows an arrowhead with the line it ends: a head on a
2072
+ # line thicker than 4 is as many times larger as 4 goes into it.
2073
+ def scale_thick_markers(markers, relation, endpoints)
2074
+ factor = relation.width.to_f / THICK_LINE
2075
+ return markers unless factor > 1.0
2076
+
2077
+ markers.each do |marker|
2078
+ next unless marker.shape == "polygon"
2079
+
2080
+ tip = marker_tip(marker, endpoints)
2081
+ marker.points = scaled_points(marker.points, tip, factor)
2082
+ end
2083
+ end
2084
+
2085
+ def marker_tip(marker, endpoints)
2086
+ first = point_pairs(marker.points).first
2087
+ endpoints.values_at(:left, :right).compact.min_by do |tip|
2088
+ JsMath.hypot(*tip.zip(first).map { |at, from| at - from })
2089
+ end
2090
+ end
2091
+
2092
+ def scaled_points(points, tip, factor)
2093
+ formatted_points(point_pairs(points).map do |point|
2094
+ point.zip(tip).map { |at, from| from + ((at - from) * factor) }
2095
+ end)
2096
+ end
2097
+
2098
+ def point_pairs(points)
2099
+ points.split.map { |pair| pair.split(",").map(&:to_f) }
2100
+ end
2101
+
2102
+ def relation_path_for(relation, endpoints)
2103
+ ordered = if reversed_qualified?(relation)
2104
+ endpoints.merge(left: endpoints[:right],
2105
+ right: endpoints[:left])
2106
+ else
2107
+ endpoints
2108
+ end
2109
+ qualified_special_path(relation, ordered) ||
2110
+ controlled_relation_path(relation, ordered)
2111
+ end
2112
+
2113
+ def qualified_special_path(relation, ordered)
2114
+ if fan_qualifier_relation?(relation)
2115
+ fan_qualifier_path(relation, ordered)
2116
+ elsif long_qualified_vertical?(relation, ordered)
2117
+ long_qualified_vertical_path(ordered)
2118
+ end
2119
+ end
2120
+
2121
+ def controlled_relation_path(relation, ordered)
2122
+ control = qualified_label_control(relation, ordered) ||
2123
+ qualified_lane_control(relation, ordered)
2124
+ return styled_curve_path(relation, ordered) unless control
2125
+
2126
+ "M #{point(ordered[:left])} Q #{point(control)} " \
2127
+ "#{point(ordered[:right])}"
2128
+ end
2129
+
2130
+ def styled_curve_path(relation, ordered)
2131
+ first, last = ordered.values_at(:left, :right)
2132
+ controls = StyledRelationCurve.controls(relation, first, last)
2133
+ return relation_path(ordered) unless controls
2134
+
2135
+ "M #{point(first)} C #{point(controls.first)} " \
2136
+ "#{point(controls.last)} #{point(last)}"
2137
+ end
2138
+
2139
+ def fan_qualifier_relation?(relation)
2140
+ multiplicities = [relation.left_multiplicity,
2141
+ relation.right_multiplicity].compact
2142
+ relation.direction.nil? && relation.length == 2 &&
2143
+ multiplicities.one? && multiplicities.first.start_with?("[")
2144
+ end
2145
+
2146
+ def fan_qualifier_path(relation, endpoints)
2147
+ first, last = endpoints.values_at(:left, :right)
2148
+ width = standard_text_width(fan_qualifier_text(relation), font_size) +
2149
+ QUALIFIED_FAN_LANE_ALLOWANCE
2150
+ left, right = fan_lane_points(first, last, width)
2151
+ "M #{point(first)} L #{point(left)} L #{point(right)} " \
2152
+ "L #{point(last)}"
2153
+ end
2154
+
2155
+ def fan_qualifier_text(relation)
2156
+ [relation.left_multiplicity, relation.right_multiplicity]
2157
+ .compact.first.delete_prefix("[").delete_suffix("]")
2158
+ end
2159
+
2160
+ def fan_lane_points(first, last, width)
2161
+ centre = midpoint(first, last).first
2162
+ rise = (last[1] - first[1]) / 3
2163
+ [offset_point([centre, first[1]], -(width / 2), rise),
2164
+ offset_point([centre, last[1]], width / 2, -rise)]
2165
+ end
2166
+
2167
+ def long_qualified_vertical?(relation, endpoints)
2168
+ qualifier_relation?(relation) && relation.length > 2 &&
2169
+ steep?(*endpoints.values_at(:left, :right))
2170
+ end
2171
+
2172
+ def long_qualified_vertical_path(endpoints)
2173
+ first, last = endpoints.values_at(:left, :right)
2174
+ vertical = (last[1] - first[1]) / 3
2175
+ outgoing = offset_point(first, -QUALIFIED_LONG_CURVE_OFFSET, vertical)
2176
+ incoming = offset_point(last, QUALIFIED_LONG_CURVE_OFFSET, -vertical)
2177
+ "M #{point(first)} C #{point(outgoing)} #{point(incoming)} " \
2178
+ "#{point(last)}"
2179
+ end
2180
+
2181
+ def offset_point(origin, delta_x, delta_y)
2182
+ [origin[0] + delta_x, origin[1] + delta_y]
2183
+ end
2184
+
2185
+ def qualified_lane_control(relation, endpoints)
2186
+ return unless single_qualifier_with_other_end?(relation)
2187
+
2188
+ left, right = endpoints.values_at(:left, :right)
2189
+ return if steep?(left, right)
2190
+
2191
+ middle = midpoint(left, right)
2192
+ outward = if middle[1] < @qualified_route_middle
2193
+ -QUALIFIED_FLAT_CURVE_OFFSET
2194
+ else
2195
+ QUALIFIED_FLAT_CURVE_OFFSET
2196
+ end
2197
+ [middle[0], middle[1] + outward]
2198
+ end
2199
+
2200
+ def single_qualifier_with_other_end?(relation)
2201
+ multiplicities = [relation.left_multiplicity,
2202
+ relation.right_multiplicity].compact
2203
+ multiplicities.one? { |text| text.start_with?("[") } &&
2204
+ multiplicities.size > 1
2205
+ end
2206
+
2207
+ def reversed_qualified?(relation)
2208
+ qualifiers = [relation.left_multiplicity,
2209
+ relation.right_multiplicity]
2210
+ qualifiers.compact.any? { |text| text.start_with?("[") } &&
2211
+ %i[up left].include?(relation.direction)
2212
+ end
2213
+
2214
+ def qualified_label_control(relation, endpoints)
2215
+ return unless qualifier_relation?(relation) && label_arrow?(relation)
2216
+
2217
+ left, right = endpoints.values_at(:left, :right)
2218
+ return if steep?(left, right)
2219
+
2220
+ middle = midpoint(left, right)
2221
+ outward = middle[1] < @qualified_route_middle ? -30.0 : 30.0
2222
+ [middle[0], middle[1] + outward]
2223
+ end
2224
+
2225
+ def qualifier_relation?(relation)
2226
+ [relation.left_multiplicity, relation.right_multiplicity]
2227
+ .compact.any? { |text| text.start_with?("[") }
2228
+ end
2229
+
2230
+ def qualified_marker_half_width(relation, endpoints)
2231
+ qualifiers = [relation.left_multiplicity,
2232
+ relation.right_multiplicity]
2233
+ if qualifiers.all? { |text| text&.start_with?("[") }
2234
+ QUALIFIED_DOUBLE_END_HALF_WIDTH
2235
+ else
2236
+ inheritance_marker_half_width(relation, endpoints, qualifiers)
2237
+ end
2238
+ end
2239
+
2240
+ def inheritance_marker_half_width(relation, endpoints, qualifiers)
2241
+ return unless @qualified_layout_active && qualifiers.compact.empty?
2242
+ return unless relation.markers.value?(:implementation)
2243
+
2244
+ left, right = endpoints.values_at(:left, :right)
2245
+ QUALIFIED_INHERITANCE_HALF_WIDTH if steep?(left, right)
2246
+ end
2247
+
2248
+ def label_arrow?(relation)
2249
+ relation.label&.match?(/[ \t]+[<>]\z/)
2250
+ end
2251
+
2252
+ def label_arrow_scene(relation, endpoints)
2253
+ return unless qualifier_relation?(relation) && label_arrow?(relation)
2254
+
2255
+ centre, direction = label_arrow_geometry(relation, endpoints)
2256
+ tip = shift(centre, *direction, 5.0)
2257
+ base = shift(centre, *direction, -4.0)
2258
+ points = [tip, marker_side(base, direction, 3.0),
2259
+ marker_side(base, direction, -3.0)]
2260
+ Scene::Marker.new(shape: "polygon", filled: true,
2261
+ points: formatted_points(points))
2262
+ end
2263
+
2264
+ def label_arrow_geometry(relation, endpoints)
2265
+ left, right = endpoints.values_at(:left, :right)
2266
+ direction = unit_vector(left, right)
2267
+ direction = direction.map(&:-@) if relation.label.end_with?("<")
2268
+ centre = qualified_label_control(relation, endpoints) ||
2269
+ midpoint(left, right)
2270
+ if steep?(left, right)
2271
+ centre = shift(centre, 1.0, 0.0,
2272
+ QUALIFIED_STEEP_ARROW_OFFSET)
2273
+ end
2274
+ [centre, direction]
2275
+ end
2276
+
2277
+ # PlantUML turns a relation carrying an association class into two
2278
+ # legs meeting at a junction node, each keeping its own end's text,
2279
+ # plus a dashed link from that node to each association class. An
2280
+ # `up` or `left` word moves each end's marker to the other leg, as
2281
+ # the pinned references draw it.
2282
+ def junction_scenes(relation, index, boxes, owners)
2283
+ (from, to), junction, end_texts = boxes
2284
+ legs = junction_legs(relation, index, [from, junction, to],
2285
+ end_texts)
2286
+ legs.first.markers << junction_dot(junction)
2287
+ [*legs, *owners.map { |entry| owner_link(junction, *entry) }]
2288
+ end
2289
+
2290
+ def junction_legs(relation, index, (from, junction, to), end_texts)
2291
+ swap = %i[up left].include?(relation.direction)
2292
+ [[:left, "from", from, junction],
2293
+ [:right, "to", junction, to]].map do |side, name, tail, head|
2294
+ markers = junction_markers(relation.markers, swap, side)
2295
+ relation_scene(relation, "relation-#{index}-#{name}",
2296
+ relation_endpoints(tail, head), end_texts,
2297
+ sides: [side], markers: markers)
2298
+ end
2299
+ end
2300
+
2301
+ def junction_node(left, top, width, height)
2302
+ Scene::Box.new(id: "junction", x: left, y: top, width: width,
2303
+ height: height, texts: [], separators: [],
2304
+ kind: "junction")
2305
+ end
2306
+
2307
+ # The filled dot where an association class meets its relation.
2308
+ def junction_dot(junction)
2309
+ Scene::Marker.new(shape: "dot", filled: true,
2310
+ cx: junction.x + (junction.width / 2),
2311
+ cy: junction.y + (junction.height / 2),
2312
+ r: JUNCTION_DOT_RADIUS)
2313
+ end
2314
+
2315
+ def junction_markers(markers, swap, side)
2316
+ opposite = side == :left ? :right : :left
2317
+ { side => markers[swap ? opposite : side] }
2318
+ end
2319
+
2320
+ def owner_link(junction, owner, number, by_name)
2321
+ edge = relation_endpoints(junction, by_name.fetch(owner.owner))
2322
+ Scene::Relation.new(id: "junction-#{number}", dashed: true,
2323
+ path: relation_path(edge), texts: [], markers: [])
2324
+ end
2325
+
2326
+ def midpoint(first, second)
2327
+ [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]
2328
+ end
2329
+
2330
+ def relation_endpoints(left_box, right_box)
2331
+ return self_relation_endpoints(left_box) if left_box.equal?(right_box)
2332
+
2333
+ aligned = aligned_package_endpoints(left_box, right_box)
2334
+ return aligned if aligned
2335
+
2336
+ {
2337
+ left: boundary_point(left_box, right_box),
2338
+ right: boundary_point(right_box, left_box),
2339
+ }
2340
+ end
2341
+
2342
+ def aligned_package_endpoints(left_box, right_box)
2343
+ if package_frame?(left_box)
2344
+ vertical_package_endpoints(left_box, right_box, :left)
2345
+ elsif package_frame?(right_box)
2346
+ vertical_package_endpoints(right_box, left_box, :right)
2347
+ end
2348
+ end
2349
+
2350
+ def package_frame?(box)
2351
+ box.id.start_with?("package-")
2352
+ end
2353
+
2354
+ def vertical_package_endpoints(frame, box, frame_side)
2355
+ centre = box_centre(box)
2356
+ return unless centre.first.between?(frame.x, frame.x + frame.width)
2357
+
2358
+ points = vertical_package_points(frame, box, centre.first)
2359
+ return unless points
2360
+
2361
+ frame_point, box_point = points
2362
+ return { left: frame_point, right: box_point } if frame_side == :left
2363
+
2364
+ { left: box_point, right: frame_point }
2365
+ end
2366
+
2367
+ def vertical_package_points(frame, box, horizontal)
2368
+ verticals = vertical_package_edges(frame, box)
2369
+ verticals&.map { |vertical| [horizontal, vertical] }
2370
+ end
2371
+
2372
+ def vertical_package_edges(frame, box)
2373
+ return [bottom_of(frame), box.y] if below?(box, frame)
2374
+
2375
+ [frame.y, bottom_of(box)] if below?(frame, box)
2376
+ end
2377
+
2378
+ def below?(box, other)
2379
+ box.y >= bottom_of(other)
2380
+ end
2381
+
2382
+ def bottom_of(box)
2383
+ box.y + box.height
2384
+ end
2385
+
2386
+ def self_relation_endpoints(box)
2387
+ endpoints = { left: right_edge_point(box,
2388
+ SELF_RELATION_START_RATIO) }
2389
+ endpoints[:right] = right_edge_point(box, SELF_RELATION_END_RATIO)
2390
+ endpoints[:control] = right_edge_point(
2391
+ box, 0.5, offset: SELF_RELATION_CONTROL_OFFSET
2392
+ )
2393
+ endpoints
2394
+ end
2395
+
2396
+ def right_edge_point(box, height_ratio, offset: 0.0)
2397
+ [box.x + box.width + offset, box.y + (box.height * height_ratio)]
2398
+ end
2399
+
2400
+ def boundary_point(box, target)
2401
+ boundary_toward(box, box_centre(target))
2402
+ end
2403
+
2404
+ def boundary_toward(box, destination)
2405
+ centre = box_centre(box)
2406
+ delta = vector_between(centre, destination)
2407
+ shift(centre, *delta, boundary_scale(box, delta))
2408
+ end
2409
+
2410
+ def vector_between(from, to)
2411
+ [to[0] - from[0], to[1] - from[1]]
2412
+ end
2413
+
2414
+ def boundary_scale(box, delta)
2415
+ horizontal = axis_scale(box.width, delta[0])
2416
+ vertical = axis_scale(box.height, delta[1])
2417
+ [horizontal, vertical].compact.min
2418
+ end
2419
+
2420
+ def axis_scale(size, delta)
2421
+ return if delta.zero?
2422
+
2423
+ (size / 2) / delta.abs
2424
+ end
2425
+
2426
+ def box_centre(box)
2427
+ [box.x + (box.width / 2), box.y + (box.height / 2)]
2428
+ end
2429
+
2430
+ def relation_path(endpoints)
2431
+ left = endpoints.fetch(:left)
2432
+ right = endpoints.fetch(:right)
2433
+ return "M #{point(left)} L #{point(right)}" unless endpoints[:control]
2434
+
2435
+ control = endpoints.fetch(:control)
2436
+ "M #{point(left)} Q #{point(control)} #{point(right)}"
2437
+ end
2438
+
2439
+ def marker_scenes(markers, endpoints, half_width: nil)
2440
+ markers.compact.map do |side, kind|
2441
+ tip = endpoints.fetch(side)
2442
+ opposite = side == :left ? :right : :left
2443
+ other = endpoints[:control] || endpoints.fetch(opposite)
2444
+ marker_scene(kind, tip, other, half_width: half_width)
2445
+ end
2446
+ end
2447
+
2448
+ def marker_scene(kind, tip, other, half_width: nil)
2449
+ return nesting_scene(tip, other) if kind == :nesting
2450
+
2451
+ Scene::Marker.new(
2452
+ shape: "polygon", filled: FILLED_MARKERS.include?(kind),
2453
+ points: marker_points(tip, other, marker_shape(kind),
2454
+ half_width: half_width)
2455
+ )
2456
+ end
2457
+
2458
+ def nesting_scene(tip, other)
2459
+ centre = shift(tip, *unit_vector(tip, other), NESTING_RADIUS)
2460
+ Scene::Marker.new(shape: "nesting", filled: false, cx: centre[0],
2461
+ cy: centre[1], r: NESTING_RADIUS,
2462
+ cross: cross_path(centre))
2463
+ end
2464
+
2465
+ def cross_path(centre)
2466
+ x, y = centre
2467
+ reach = NESTING_RADIUS
2468
+ "M #{x - reach} #{y} L #{x + reach} #{y} " \
2469
+ "M #{x} #{y - reach} L #{x} #{y + reach}"
2470
+ end
2471
+
2472
+ def marker_shape(relation_kind)
2473
+ return :diamond if %i[aggregation composition].include?(relation_kind)
2474
+ return :arrow if ARROW_MARKERS.include?(relation_kind)
2475
+
2476
+ :triangle
2477
+ end
2478
+
2479
+ def marker_points(tip, other, shape, half_width: nil)
2480
+ direction = unit_vector(other, tip)
2481
+ return arrow_points(tip, direction, half_width) if shape == :arrow
2482
+ return diamond_points(tip, direction, half_width) if shape == :diamond
2483
+
2484
+ base = shift(tip, *direction, -MARKER_LENGTH)
2485
+ half_width ||= MARKER_HALF_WIDTH
2486
+ formatted_points(
2487
+ [tip, marker_side(base, direction, half_width),
2488
+ marker_side(base, direction, -half_width)],
2489
+ )
2490
+ end
2491
+
2492
+ # PlantUML's aggregation and composition diamond.
2493
+ def diamond_points(tip, direction, half_width = nil)
2494
+ middle = shift(tip, *direction, -(DIAMOND_LENGTH / 2))
2495
+ half_width ||= DIAMOND_HALF_WIDTH
2496
+ formatted_points(
2497
+ [tip, marker_side(middle, direction, half_width),
2498
+ shift(tip, *direction, -DIAMOND_LENGTH),
2499
+ marker_side(middle, direction, -half_width)],
2500
+ )
2501
+ end
2502
+
2503
+ # PlantUML's open arrowhead: two wings joined through a notch.
2504
+ def arrow_points(tip, direction, half_width = nil)
2505
+ base = shift(tip, *direction, -ARROW_LENGTH)
2506
+ half_width ||= ARROW_HALF_WIDTH
2507
+ formatted_points(
2508
+ [tip, marker_side(base, direction, half_width),
2509
+ shift(tip, *direction, -ARROW_NOTCH),
2510
+ marker_side(base, direction, -half_width)],
2511
+ )
2512
+ end
2513
+
2514
+ def marker_side(base, direction, distance)
2515
+ shift(base, -direction[1], direction[0], distance)
2516
+ end
2517
+
2518
+ def formatted_points(points)
2519
+ points.map do |coordinate|
2520
+ point(coordinate, separator: ",")
2521
+ end.join(" ")
2522
+ end
2523
+
2524
+ def unit_vector(from, to)
2525
+ delta_horizontal, delta_vertical = vector_between(from, to)
2526
+ length = JsMath.hypot(delta_horizontal, delta_vertical)
2527
+ return [1.0, 0.0] if length.zero?
2528
+
2529
+ [delta_horizontal / length, delta_vertical / length]
2530
+ end
2531
+
2532
+ def shift(origin, direction_horizontal, direction_vertical, distance)
2533
+ [origin[0] + (direction_horizontal * distance),
2534
+ origin[1] + (direction_vertical * distance)]
2535
+ end
2536
+
2537
+ # The label rides with the left end, so a relation split into legs
2538
+ # still shows it once.
2539
+ def relation_texts(relation, endpoints, end_texts, sides, boxed = 0)
2540
+ first, second = end_texts.sides
2541
+ direction = unit_vector(*endpoints.values_at(first, second))
2542
+ label = relation_text_label(relation, endpoints, sides)
2543
+ texts = positioned_end_texts(unboxed_entries(end_texts, boxed),
2544
+ endpoints, direction, first, sides)
2545
+ [label, *texts].compact
2546
+ end
2547
+
2548
+ def relation_text_label(relation, endpoints, sides)
2549
+ return unless sides.include?(:left)
2550
+
2551
+ content = visible_relation_label(relation.label)
2552
+ clearance = [relation.width.to_f - 1.0, 0.0].max / 2
2553
+ curved_relation_label(content, relation, endpoints, clearance) ||
2554
+ relation_label(content, endpoints, clearance)
2555
+ end
2556
+
2557
+ # A label on a sloping curve starts, or ends, on the side where the
2558
+ # curve runs away below it, so the curve does not cross the text.
2559
+ def curved_relation_label(content, relation, endpoints, clearance)
2560
+ left, right = endpoints.values_at(:left, :right)
2561
+ point = StyledRelationCurve.label_point(relation, left, right)
2562
+ return unless content && point && !endpoints[:control]
2563
+
2564
+ descending = descending?(left, right)
2565
+ scene_text(content, point[0] + (descending ? LABEL_GAP : -LABEL_GAP),
2566
+ point[1] - LABEL_OFFSET - clearance, "relation_label",
2567
+ descending ? "start" : "end")
2568
+ end
2569
+
2570
+ def descending?(left, right)
2571
+ horizontal, vertical = vector_between(left, right)
2572
+ (horizontal * vertical).positive?
2573
+ end
2574
+
2575
+ def visible_relation_label(label)
2576
+ label&.sub(/[ \t]+[<>]\z/, "")
2577
+ end
2578
+
2579
+ def unboxed_entries(end_texts, boxed)
2580
+ return end_texts.entries if boxed.zero?
2581
+
2582
+ end_texts.entries.reject do |entry|
2583
+ entry[:kind] == :qualifier && entry[:pieces].one?
2584
+ end
2585
+ end
2586
+
2587
+ def positioned_end_texts(entries, endpoints, direction, first, sides)
2588
+ selected = entries.select { |entry| sides.include?(entry[:side]) }
2589
+ selected.flat_map do |entry|
2590
+ inward = entry[:side] == first ? 1 : -1
2591
+ endpoint = endpoints.fetch(entry[:side])
2592
+ end_text(entry, endpoint, direction, inward)
2593
+ end
2594
+ end
2595
+
2596
+ # Above the middle of a flat line; beside a steep one, centred on
2597
+ # its middle, as PlantUML draws a label in its own rank.
2598
+ def relation_label(content, endpoints, clearance = 0.0)
2599
+ return unless content
2600
+
2601
+ return self_relation_label(content, endpoints) if endpoints[:control]
2602
+
2603
+ left, right = endpoints.values_at(:left, :right)
2604
+ if beside_label?(left, right)
2605
+ return steep_label_for(content, left, right, clearance)
2606
+ end
2607
+
2608
+ scene_text(content, midpoint(left, right).first,
2609
+ flat_label_baseline(content, left, right, clearance),
2610
+ "relation_label", "middle")
2611
+ end
2612
+
2613
+ def steep_label_for(content, left, right, clearance)
2614
+ horizontal, middle = midpoint(left, right)
2615
+ steep_relation_label(content, horizontal, middle,
2616
+ [left, right], clearance)
2617
+ end
2618
+
2619
+ # Above the line; higher where a slope climbs under the text's
2620
+ # ends, so the line does not run through it.
2621
+ def flat_label_baseline(content, left, right, clearance)
2622
+ centre, middle = midpoint(left, right)
2623
+ size = font_size * SMALL_TEXT_SCALE
2624
+ half = standard_text_width(content.to_s, size) / 2
2625
+ top = LabelClearance.top_between(left, right, centre - half,
2626
+ centre + half)
2627
+ [middle - LABEL_OFFSET, top - (size * TEXT_DESCENT)].min - clearance
2628
+ end
2629
+
2630
+ def self_relation_label(content, endpoints)
2631
+ horizontal = self_relation_label_horizontal(endpoints)
2632
+ baseline = self_relation_label_baseline(endpoints)
2633
+ scene_text(content, horizontal, baseline,
2634
+ "relation_label", "start")
2635
+ end
2636
+
2637
+ def self_relation_label_horizontal(endpoints)
2638
+ left, right, control = endpoints.values_at(:left, :right, :control)
2639
+ curve_right = (left[0] + (2 * control[0]) + right[0]) / 4
2640
+ curve_right + SELF_RELATION_LABEL_GAP
2641
+ end
2642
+
2643
+ def self_relation_label_baseline(endpoints)
2644
+ left, right = endpoints.values_at(:left, :right)
2645
+ vertical = midpoint(left, right).last
2646
+ size = font_size * SMALL_TEXT_SCALE
2647
+ vertical + (size * (1 - TEXT_DESCENT) / 2)
2648
+ end
2649
+
2650
+ def steep?(left, right)
2651
+ horizontal, vertical = vector_between(left, right)
2652
+ vertical.abs > horizontal.abs
2653
+ end
2654
+
2655
+ # A line this near the diagonal would run through a label centred
2656
+ # above its middle, so the label goes beside it.
2657
+ def beside_label?(left, right)
2658
+ horizontal, vertical = vector_between(left, right)
2659
+ vertical.abs > horizontal.abs * BESIDE_LABEL_SLOPE
2660
+ end
2661
+
2662
+ def steep_relation_label(content, horizontal, middle, line, clearance)
2663
+ size = font_size * SMALL_TEXT_SCALE
2664
+ baseline = middle + (size * (1 - TEXT_DESCENT) / 2)
2665
+ reach = LabelClearance.right_between(*line, baseline - size,
2666
+ baseline)
2667
+ scene_text(content, [horizontal, reach].max + clearance + LABEL_GAP,
2668
+ baseline,
2669
+ "relation_label", "start")
2670
+ end
2671
+
2672
+ # A multiplicity sits on the right of the line as it runs from the
2673
+ # link's first class to its second, and a role on its left. Each
2674
+ # text hugs its end, as PlantUML draws it: beside a steep line it
2675
+ # grows away from the line and into the gap along it, beside a flat
2676
+ # one into the gap and away from the line. `inward` is 1 at the
2677
+ # first end and -1 at the second.
2678
+ def end_text(entry, endpoint, direction, inward)
2679
+ point, sideways, upright = end_text_position(entry, endpoint,
2680
+ direction, inward)
2681
+ anchor = sideways[0].positive? ? "start" : "end"
2682
+ if entry[:multiline]
2683
+ return multiline_end_text_run(entry[:pieces],
2684
+ hug(point, upright[1]), anchor)
2685
+ end
2686
+
2687
+ end_text_run(entry[:pieces], hug(point, upright[1]), anchor)
2688
+ end
2689
+
2690
+ def end_text_position(entry, endpoint, direction, inward)
2691
+ normal, along = end_text_vectors(entry, direction, inward)
2692
+ point = shift(shift(endpoint, *along, END_TEXT_ALONG),
2693
+ *normal, END_TEXT_ACROSS)
2694
+ sideways, upright = if direction[1].abs >= direction[0].abs
2695
+ [normal, along]
2696
+ else
2697
+ [along, normal]
2698
+ end
2699
+ [point, sideways, upright]
2700
+ end
2701
+
2702
+ def end_text_vectors(entry, direction, inward)
2703
+ side = entry[:kind] == :role ? -1 : 1
2704
+ normal = [-direction[1] * side, direction[0] * side]
2705
+ inward *= -1 if entry[:kind] == :qualifier
2706
+ along = direction.map { |component| component * inward }
2707
+ [normal, along]
2708
+ end
2709
+
2710
+ # The baseline that puts the text just below `point`, or just above.
2711
+ def hug(point, downward)
2712
+ size = font_size * SMALL_TEXT_SCALE
2713
+ drop = downward.positive? ? size : -(size * TEXT_DESCENT)
2714
+ [point[0], point[1] + drop]
2715
+ end
2716
+
2717
+ # A numbered item's `1.` and its text are drawn side by side, one
2718
+ # space apart, as one run.
2719
+ def end_text_run(pieces, point, anchor)
2720
+ if pieces.one?
2721
+ return [scene_text(pieces.first, *point, "multiplicity", anchor)]
2722
+ end
2723
+
2724
+ numbered_end_text_run(pieces, point, anchor)
2725
+ end
2726
+
2727
+ def multiline_end_text_run(lines, point, anchor)
2728
+ line_height = font_size * SMALL_TEXT_SCALE * 1.215
2729
+ top = point[1] - (line_height * (lines.size - 1))
2730
+ lines.each_with_index.map do |line, index|
2731
+ scene_text(line, point[0], top + (index * line_height),
2732
+ "multiplicity", anchor)
2733
+ end
2734
+ end
2735
+
2736
+ def numbered_end_text_run(pieces, point, anchor)
2737
+ width = end_text_width(pieces.join(" "))
2738
+ start = point[0] - anchor_offset(anchor, width)
2739
+ offset = end_text_width("#{pieces.first} ")
2740
+ baseline = point[1]
2741
+ [scene_text(pieces.first, start, baseline, "multiplicity", "start"),
2742
+ scene_text(pieces.last, start + offset, baseline, "multiplicity",
2743
+ "start")]
2744
+ end
2745
+
2746
+ def anchor_offset(anchor, width)
2747
+ { "end" => width, "middle" => width / 2 }.fetch(anchor, 0)
2748
+ end
2749
+
2750
+ def end_text_width(content)
2751
+ measure_text(content, font_size: font_size * SMALL_TEXT_SCALE)[:width]
2752
+ end
2753
+
2754
+ def label_widths(diagram)
2755
+ diagram.relations.each_with_index.filter_map do |relation, index|
2756
+ label = visible_relation_label(relation.label)
2757
+ [index, end_text_width(label)] if label
2758
+ end.to_h
2759
+ end
2760
+
2761
+ def scene_text(content, horizontal, vertical, role, anchor)
2762
+ Scene::Text.new(content: content, x: horizontal, y: vertical,
2763
+ role: role,
2764
+ anchor: anchor)
2765
+ end
2766
+
2767
+ def segment(from_horizontal, from_vertical,
2768
+ to_horizontal, to_vertical)
2769
+ Scene::Segment.new(
2770
+ x1: from_horizontal, y1: from_vertical,
2771
+ x2: to_horizontal, y2: to_vertical
2772
+ )
2773
+ end
2774
+
2775
+ def point(coordinates, separator: " ")
2776
+ coordinates.map { |number| number.round(3) }.join(separator)
2777
+ end
2778
+
2779
+ # The jar leaves one margin before the drawing and two after it; a
2780
+ # diagram with package frames keeps the frames' own symmetric room.
2781
+ def trailing_margin(diagram)
2782
+ diagram.packages.empty? ? MARGIN : 0.0
2783
+ end
2784
+
2785
+ def canvas_width(diagram, boxes)
2786
+ boxes.map { |box| box.x + box.width }.max + boxes.map(&:x).min +
2787
+ trailing_margin(diagram)
2788
+ end
2789
+
2790
+ def widest_box(specifications)
2791
+ widths = specifications.map { |item| item[:width] }
2792
+ widths.max
2793
+ end
2794
+
2795
+ def box_rows(boxes)
2796
+ boxes.group_by(&:y).values
2797
+ end
2798
+
2799
+ def canvas_height(rows)
2800
+ rows.sum { |row| row.map(&:height).max } +
2801
+ ((rows.size - 1) * ROW_GAP) + (MARGIN * 2)
2802
+ end
2803
+ end
2804
+ end
2805
+ end
2806
+ end