@diagrammo/dgmo 0.46.0 → 0.48.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 (534) hide show
  1. package/dist/advanced.d.ts +3 -855
  2. package/dist/advanced.js +541 -70107
  3. package/dist/analyzer-UYKBXAKR.js +15 -0
  4. package/dist/auto.js +1 -1
  5. package/dist/auto.mjs +1 -1
  6. package/dist/block.js +83 -69417
  7. package/dist/calculator-LDYQCDDY.js +11 -0
  8. package/dist/charts-d3-QOZHKTCT.js +25 -0
  9. package/dist/chunk-2DXMZRWJ.js +800 -0
  10. package/dist/chunk-2LACINWN.js +786 -0
  11. package/dist/chunk-33LCUH4H.js +893 -0
  12. package/dist/chunk-3CIOJ5YJ.js +35 -0
  13. package/dist/chunk-3HDBAXF5.js +86 -0
  14. package/dist/chunk-3JLKYGBQ.js +290 -0
  15. package/dist/chunk-3MQQNXPD.js +415 -0
  16. package/dist/chunk-3R5POCTZ.js +967 -0
  17. package/dist/chunk-3SMGIJDD.js +112 -0
  18. package/dist/chunk-44CM72SQ.js +409 -0
  19. package/dist/chunk-4GRI7MEB.js +1604 -0
  20. package/dist/chunk-4OHACBGX.js +606 -0
  21. package/dist/chunk-5DH3KYFH.js +21 -0
  22. package/dist/chunk-5MLYVTYM.js +76 -0
  23. package/dist/chunk-5QXL4NYS.js +553 -0
  24. package/dist/chunk-5WRI5ZAA.js +31 -0
  25. package/dist/chunk-6EK55ART.js +117 -0
  26. package/dist/chunk-6FZYCKXB.js +2065 -0
  27. package/dist/chunk-6HO5ZTXK.js +277 -0
  28. package/dist/chunk-6KFWC4CO.js +7 -0
  29. package/dist/chunk-6MTWIQVO.js +121 -0
  30. package/dist/chunk-6NYWYNBI.js +329 -0
  31. package/dist/chunk-6SM5NJQH.js +620 -0
  32. package/dist/chunk-725LDASD.js +422 -0
  33. package/dist/chunk-76OWZ3BA.js +130 -0
  34. package/{src/block/parser.ts → dist/chunk-7JEGTRLD.js} +100 -189
  35. package/dist/chunk-A44USUV7.js +923 -0
  36. package/dist/chunk-A4GOY3PE.js +25 -0
  37. package/dist/chunk-A4VFQH5G.js +251 -0
  38. package/dist/chunk-AELOBFW5.js +375 -0
  39. package/dist/chunk-AN2MOO5T.js +522 -0
  40. package/dist/chunk-BMR4CIEO.js +41 -0
  41. package/dist/chunk-BS7SV7C3.js +51 -0
  42. package/dist/chunk-BUPDIHML.js +613 -0
  43. package/dist/chunk-BUVJFRPY.js +887 -0
  44. package/dist/chunk-C3Z4DX3V.js +112 -0
  45. package/dist/chunk-CI2G3OEB.js +1818 -0
  46. package/dist/chunk-CKNVO24D.js +54 -0
  47. package/dist/chunk-CUVHQHZ7.js +391 -0
  48. package/dist/chunk-CXK4DZH3.js +114 -0
  49. package/dist/{editor.cjs → chunk-DKEPS5LH.js} +14 -225
  50. package/dist/chunk-DU7W62N5.js +487 -0
  51. package/dist/chunk-DZAQBW35.js +751 -0
  52. package/dist/chunk-EB6FPQUP.js +316 -0
  53. package/dist/chunk-EMS2SK26.js +376 -0
  54. package/dist/chunk-ERLBPDGU.js +510 -0
  55. package/dist/chunk-EVCVDTXE.js +732 -0
  56. package/dist/chunk-FCIIHT24.js +384 -0
  57. package/dist/chunk-FFUN5D66.js +97 -0
  58. package/dist/chunk-FITOLXSX.js +714 -0
  59. package/dist/chunk-FMDSF3OV.js +68 -0
  60. package/dist/chunk-FNB3PNGF.js +410 -0
  61. package/dist/chunk-G2H74XJZ.js +2104 -0
  62. package/dist/chunk-GMR5MFHJ.js +339 -0
  63. package/dist/chunk-H6PI3KJT.js +305 -0
  64. package/dist/chunk-I46X5VUA.js +59 -0
  65. package/dist/chunk-I5IGB56C.js +255 -0
  66. package/dist/chunk-IH6ZMVQ5.js +3950 -0
  67. package/dist/chunk-IJZFN3HM.js +463 -0
  68. package/dist/chunk-IPWPWM6R.js +3079 -0
  69. package/dist/chunk-IYULMRBE.js +23 -0
  70. package/dist/chunk-IZMRON6C.js +167 -0
  71. package/dist/chunk-J5FIXQRT.js +813 -0
  72. package/dist/chunk-JED6ZDEE.js +73 -0
  73. package/dist/chunk-JEUJOX5X.js +136 -0
  74. package/dist/chunk-JM4LEPY3.js +436 -0
  75. package/dist/chunk-L3UHP6TD.js +101 -0
  76. package/dist/chunk-L66GEFB6.js +524 -0
  77. package/dist/chunk-LIOHW36Z.js +469 -0
  78. package/dist/chunk-LP7EC4MI.js +16 -0
  79. package/dist/chunk-MITI6NOL.js +548 -0
  80. package/dist/chunk-MO5YHHMQ.js +168 -0
  81. package/dist/chunk-MZSBFAPJ.js +885 -0
  82. package/dist/chunk-NNAAJVBU.js +9 -0
  83. package/dist/chunk-NOTA6TEI.js +47 -0
  84. package/dist/chunk-NURGB5Z4.js +335 -0
  85. package/dist/chunk-NX4EQS3U.js +1062 -0
  86. package/dist/chunk-NXMESY4J.js +1 -0
  87. package/dist/chunk-OJR3MQYV.js +1458 -0
  88. package/dist/chunk-OURHMODR.js +454 -0
  89. package/dist/chunk-P235SVAH.js +1057 -0
  90. package/dist/chunk-P4RH4OSP.js +1038 -0
  91. package/dist/chunk-P6T66CCZ.js +226 -0
  92. package/dist/chunk-PBAHEPLQ.js +223 -0
  93. package/dist/chunk-PBHE5UG6.js +1530 -0
  94. package/dist/chunk-PDKUANDZ.js +35 -0
  95. package/dist/chunk-PWIHZ6NJ.js +296 -0
  96. package/{src/c4/layout.ts → dist/chunk-QEBUY5NY.js} +384 -1140
  97. package/dist/chunk-QLCCJEJI.js +253 -0
  98. package/dist/chunk-QOOUC2J5.js +52 -0
  99. package/dist/chunk-QYTPPDAO.js +402 -0
  100. package/dist/chunk-R3XI2VC7.js +801 -0
  101. package/dist/chunk-RCERWOJR.js +154 -0
  102. package/dist/chunk-RJE5JTVK.js +362 -0
  103. package/dist/chunk-RTLBSQ3G.js +1155 -0
  104. package/dist/chunk-RUNNI6VE.js +448 -0
  105. package/dist/chunk-RVMRZAPB.js +354 -0
  106. package/dist/chunk-RYEGFMUO.js +955 -0
  107. package/dist/chunk-RZCF2VVM.js +528 -0
  108. package/dist/chunk-SML67VPV.js +1025 -0
  109. package/dist/chunk-SQ7IY53Q.js +44 -0
  110. package/dist/chunk-SQAG3UXH.js +161 -0
  111. package/dist/chunk-SRTU6HMQ.js +1851 -0
  112. package/dist/chunk-T2OE3YQB.js +550 -0
  113. package/dist/chunk-T3UBLACL.js +299 -0
  114. package/dist/chunk-TLVIHFRO.js +296 -0
  115. package/dist/chunk-TYS6CSS5.js +52 -0
  116. package/dist/chunk-U6EQI3U6.js +130 -0
  117. package/dist/chunk-UJ4FJLBF.js +86 -0
  118. package/dist/chunk-ULOEATBZ.js +347 -0
  119. package/dist/chunk-UNCCZ3NU.js +127 -0
  120. package/dist/chunk-USZW2HHU.js +197 -0
  121. package/dist/chunk-UWN33VNX.js +1610 -0
  122. package/dist/chunk-UZ7EZW3B.js +40 -0
  123. package/dist/chunk-VOKGH627.js +37 -0
  124. package/dist/chunk-WBRZJOEC.js +204 -0
  125. package/dist/chunk-WCSOJFU3.js +380 -0
  126. package/dist/chunk-WCT3NYCV.js +160 -0
  127. package/dist/chunk-WVAJFAIA.js +529 -0
  128. package/dist/chunk-X5LCYCL5.js +394 -0
  129. package/dist/chunk-XAKEUMEJ.js +1256 -0
  130. package/dist/chunk-Y2ZE3MSG.js +590 -0
  131. package/dist/chunk-Y3J7VR52.js +646 -0
  132. package/dist/chunk-Y5QGVTVC.js +33 -0
  133. package/dist/chunk-YAA6IID3.js +548 -0
  134. package/dist/chunk-YGJTDXVR.js +692 -0
  135. package/{src/utils/time-ticks.ts → dist/chunk-YRYKTYMQ.js} +45 -112
  136. package/dist/chunk-YYVTBZZC.js +61 -0
  137. package/dist/chunk-Z5CGSFJB.js +647 -0
  138. package/{src/gantt/calculator.ts → dist/chunk-Z72XRXD4.js} +236 -428
  139. package/dist/chunk-ZGR7ULFJ.js +978 -0
  140. package/dist/chunk-ZJEL4H2R.js +851 -0
  141. package/dist/chunk-ZO27FGJS.js +554 -0
  142. package/dist/collapse-CLPJD2RL.js +11 -0
  143. package/dist/collapse-SLINJQW6.js +9 -0
  144. package/dist/collapse-ZWUFFCK2.js +9 -0
  145. package/dist/compute-G55RG53N.js +8 -0
  146. package/dist/dgmo-router-DZCIDUO5.js +73 -0
  147. package/dist/dimensions-J3FHIMJ6.js +22 -0
  148. package/dist/element.js +1 -1
  149. package/dist/element.mjs +1 -1
  150. package/dist/flowchart-parser-ZNSKIBVR.js +21 -0
  151. package/dist/flowchart-renderer-ZAEOL4DX.js +35 -0
  152. package/dist/index.d.ts +3 -469
  153. package/dist/index.js +137 -69685
  154. package/dist/layout-46N4JJYK.js +17 -0
  155. package/dist/layout-5RA6QWR6.js +26 -0
  156. package/dist/layout-5T6RH5M6.js +14 -0
  157. package/dist/layout-7QXCNOVS.js +13 -0
  158. package/dist/layout-EE6GE46Y.js +17 -0
  159. package/dist/layout-G23F7LDH.js +9 -0
  160. package/dist/layout-HRBZPFCN.js +23 -0
  161. package/dist/layout-HWDSRX5E.js +20 -0
  162. package/dist/layout-HYZPWAK5.js +18 -0
  163. package/dist/layout-N76NR4ZO.js +19 -0
  164. package/dist/layout-OB3RI63S.js +14 -0
  165. package/dist/layout-S2QBYSK2.js +15 -0
  166. package/dist/layout-UM5F4DJB.js +15 -0
  167. package/dist/layout-search-OYV77HHS.js +1655 -0
  168. package/dist/load-data-EHDDZ5ZS.js +8 -0
  169. package/dist/palettes-PRJ2RI6D.js +56 -0
  170. package/dist/parser-2MFP2H2E.js +11 -0
  171. package/dist/parser-3STE62GB.js +21 -0
  172. package/dist/parser-4FL2CMDG.js +18 -0
  173. package/dist/parser-4OGOBJJ4.js +13 -0
  174. package/dist/parser-6BTRLKRZ.js +15 -0
  175. package/dist/parser-AQETEVRW.js +21 -0
  176. package/dist/parser-CFZD5IBL.js +12 -0
  177. package/dist/parser-FUSKFGZN.js +16 -0
  178. package/dist/parser-I6KXEYFK.js +13 -0
  179. package/dist/parser-IXSZZQHY.js +18 -0
  180. package/dist/parser-JAL2HD4I.js +13 -0
  181. package/dist/parser-KNSQSKQO.js +20 -0
  182. package/dist/parser-N5O4IHUN.js +13 -0
  183. package/dist/parser-NGU3ETO6.js +16 -0
  184. package/dist/parser-NRAI6O25.js +19 -0
  185. package/dist/parser-O5J4VBII.js +11 -0
  186. package/dist/parser-OV7NOBW6.js +21 -0
  187. package/dist/parser-PQ3POX45.js +23 -0
  188. package/dist/parser-TFZFBVWN.js +11 -0
  189. package/dist/parser-TRTHDFPP.js +18 -0
  190. package/dist/parser-VQP2UJ6W.js +11 -0
  191. package/dist/parser-XDVELHRK.js +13 -0
  192. package/dist/parser-XKRP7QKP.js +11 -0
  193. package/dist/parser-Y6WTBU76.js +14 -0
  194. package/dist/parser-ZSDPZYQP.js +12 -0
  195. package/dist/renderer-2XQQQ6QR.js +28 -0
  196. package/dist/renderer-5NDVNDAZ.js +26 -0
  197. package/dist/renderer-6EEZHMH4.js +26 -0
  198. package/dist/renderer-6W5462RB.js +43 -0
  199. package/dist/renderer-7MVLWWQD.js +22 -0
  200. package/dist/renderer-AWWAF6XD.js +33 -0
  201. package/dist/renderer-BYCNMBF7.js +34 -0
  202. package/dist/renderer-CLJHQPBJ.js +29 -0
  203. package/dist/renderer-GHPZMFU2.js +15 -0
  204. package/dist/renderer-JGBKUCZD.js +16 -0
  205. package/dist/renderer-KQB7UGDS.js +19 -0
  206. package/dist/renderer-PGX2TKVS.js +24 -0
  207. package/dist/renderer-QIWVHWZO.js +15 -0
  208. package/dist/renderer-RPOA5YPZ.js +35 -0
  209. package/dist/renderer-SMIBCRVB.js +24 -0
  210. package/dist/renderer-T7YKC5VP.js +28 -0
  211. package/dist/renderer-TOCOVLHS.js +37 -0
  212. package/dist/renderer-UWA5XS74.js +39 -0
  213. package/dist/renderer-VACVQYML.js +25 -0
  214. package/dist/renderer-W4M6QLIJ.js +37 -0
  215. package/dist/renderer-WZRREQ2P.js +23 -0
  216. package/dist/renderer-YY2OSF6E.js +27 -0
  217. package/dist/renderer-Z7I3W2PP.js +24 -0
  218. package/dist/renderer-ZGNTUWC4.js +22 -0
  219. package/dist/renderer-ZTR3DER3.js +19 -0
  220. package/dist/renderer-radial-C32GPJQ7.js +21 -0
  221. package/dist/resolver-NOAA7ID7.js +13 -0
  222. package/dist/state-collapse-HCGD6IVJ.js +8 -0
  223. package/dist/state-parser-Z2V23ATR.js +18 -0
  224. package/dist/state-renderer-RFYHSP4K.js +34 -0
  225. package/dist/themes-1-CuKpeH.d.ts +908 -0
  226. package/package.json +19 -70
  227. package/dist/advanced.cjs +0 -71648
  228. package/dist/advanced.d.cts +0 -6571
  229. package/dist/auto.cjs +0 -70648
  230. package/dist/auto.d.cts +0 -54
  231. package/dist/block.cjs +0 -70208
  232. package/dist/block.d.cts +0 -115
  233. package/dist/editor.d.cts +0 -26
  234. package/dist/element.cjs +0 -70530
  235. package/dist/element.d.cts +0 -62
  236. package/dist/highlight.cjs +0 -852
  237. package/dist/highlight.d.cts +0 -49
  238. package/dist/index.cjs +0 -70491
  239. package/dist/index.d.cts +0 -626
  240. package/dist/pert.cjs +0 -325
  241. package/dist/pert.d.cts +0 -559
  242. package/src/advanced.ts +0 -873
  243. package/src/arc/parser.ts +0 -15
  244. package/src/arc/renderer.ts +0 -633
  245. package/src/auto/index.ts +0 -621
  246. package/src/auto/shared.ts +0 -370
  247. package/src/auto/styles.ts +0 -73
  248. package/src/block/layout.ts +0 -198
  249. package/src/block/renderer.ts +0 -484
  250. package/src/block/types.ts +0 -67
  251. package/src/boxes-and-lines/collapse.ts +0 -96
  252. package/src/boxes-and-lines/focus.ts +0 -257
  253. package/src/boxes-and-lines/label-placement.ts +0 -339
  254. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  255. package/src/boxes-and-lines/layout-layered.ts +0 -722
  256. package/src/boxes-and-lines/layout-search.ts +0 -1678
  257. package/src/boxes-and-lines/layout.ts +0 -398
  258. package/src/boxes-and-lines/parser.ts +0 -1181
  259. package/src/boxes-and-lines/renderer.ts +0 -1465
  260. package/src/boxes-and-lines/types.ts +0 -66
  261. package/src/c4/parser.ts +0 -1158
  262. package/src/c4/renderer.ts +0 -2008
  263. package/src/c4/types.ts +0 -89
  264. package/src/chart-type-registry.ts +0 -529
  265. package/src/chart-types.ts +0 -225
  266. package/src/chart.ts +0 -685
  267. package/src/charts-d3/bar.ts +0 -338
  268. package/src/charts-d3/chord.ts +0 -140
  269. package/src/charts-d3/function.ts +0 -147
  270. package/src/charts-d3/funnel.ts +0 -104
  271. package/src/charts-d3/heatmap.ts +0 -145
  272. package/src/charts-d3/index.ts +0 -337
  273. package/src/charts-d3/interactions.ts +0 -477
  274. package/src/charts-d3/line.ts +0 -344
  275. package/src/charts-d3/mount.ts +0 -151
  276. package/src/charts-d3/pie.ts +0 -117
  277. package/src/charts-d3/polar.ts +0 -89
  278. package/src/charts-d3/radar.ts +0 -0
  279. package/src/charts-d3/sankey.ts +0 -0
  280. package/src/charts-d3/scatter.ts +0 -185
  281. package/src/charts-d3/shared.ts +0 -208
  282. package/src/class/layout.ts +0 -326
  283. package/src/class/parser.ts +0 -635
  284. package/src/class/renderer.ts +0 -793
  285. package/src/class/types.ts +0 -58
  286. package/src/cli-banner.ts +0 -106
  287. package/src/cli.ts +0 -1080
  288. package/src/colors-diagnostics.ts +0 -35
  289. package/src/colors.ts +0 -379
  290. package/src/completion-types.ts +0 -27
  291. package/src/completion.ts +0 -2668
  292. package/src/cycle/layout.ts +0 -1026
  293. package/src/cycle/parser.ts +0 -387
  294. package/src/cycle/renderer.ts +0 -653
  295. package/src/cycle/types.ts +0 -101
  296. package/src/d3.ts +0 -1434
  297. package/src/data-chart-parser.ts +0 -1007
  298. package/src/dgmo-router.ts +0 -296
  299. package/src/diagnostics-registry.ts +0 -108
  300. package/src/diagnostics.ts +0 -0
  301. package/src/dimensions.ts +0 -20
  302. package/src/directives-registry.ts +0 -396
  303. package/src/editor/dgmo.grammar +0 -76
  304. package/src/editor/dgmo.grammar.d.ts +0 -2
  305. package/src/editor/dgmo.grammar.js +0 -18
  306. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  307. package/src/editor/dgmo.grammar.terms.js +0 -35
  308. package/src/editor/highlight-api.ts +0 -596
  309. package/src/editor/highlight.ts +0 -36
  310. package/src/editor/index.ts +0 -27
  311. package/src/editor/keywords.ts +0 -128
  312. package/src/editor/tokens.ts +0 -30
  313. package/src/element/index.ts +0 -432
  314. package/src/embed/css.ts +0 -312
  315. package/src/embed/escape.ts +0 -15
  316. package/src/embed/index.ts +0 -280
  317. package/src/er/classify.ts +0 -228
  318. package/src/er/layout.ts +0 -530
  319. package/src/er/parser.ts +0 -750
  320. package/src/er/renderer.ts +0 -725
  321. package/src/er/types.ts +0 -52
  322. package/src/error-card.ts +0 -269
  323. package/src/event-line/diagnostics.ts +0 -117
  324. package/src/event-line/parser.ts +0 -643
  325. package/src/event-line/renderer.ts +0 -1742
  326. package/src/event-line/types.ts +0 -74
  327. package/src/fonts.ts +0 -3
  328. package/src/gantt/parser.ts +0 -1641
  329. package/src/gantt/renderer.ts +0 -3787
  330. package/src/gantt/resolver.ts +0 -172
  331. package/src/gantt/types.ts +0 -206
  332. package/src/graph/diagnostics.ts +0 -64
  333. package/src/graph/edge-spline.ts +0 -29
  334. package/src/graph/flowchart-parser.ts +0 -759
  335. package/src/graph/flowchart-renderer.ts +0 -817
  336. package/src/graph/layout.ts +0 -472
  337. package/src/graph/notes.ts +0 -21
  338. package/src/graph/state-collapse.ts +0 -78
  339. package/src/graph/state-parser.ts +0 -574
  340. package/src/graph/state-renderer.ts +0 -653
  341. package/src/graph/types.ts +0 -65
  342. package/src/index.ts +0 -261
  343. package/src/infra/compute.ts +0 -1385
  344. package/src/infra/layout.ts +0 -888
  345. package/src/infra/parser.ts +0 -1234
  346. package/src/infra/renderer.ts +0 -2534
  347. package/src/infra/roles.ts +0 -75
  348. package/src/infra/types.ts +0 -216
  349. package/src/infra/validation.ts +0 -201
  350. package/src/journey-map/layout.ts +0 -490
  351. package/src/journey-map/parser.ts +0 -557
  352. package/src/journey-map/renderer.ts +0 -1580
  353. package/src/journey-map/types.ts +0 -47
  354. package/src/kanban/mutations.ts +0 -175
  355. package/src/kanban/parser.ts +0 -509
  356. package/src/kanban/renderer.ts +0 -1449
  357. package/src/kanban/types.ts +0 -39
  358. package/src/label-layout.ts +0 -368
  359. package/src/map/colorize.ts +0 -54
  360. package/src/map/completion.ts +0 -316
  361. package/src/map/context-labels.ts +0 -609
  362. package/src/map/data/PROVENANCE.json +0 -1
  363. package/src/map/data/README.md +0 -40
  364. package/src/map/data/airport-collisions.json +0 -1
  365. package/src/map/data/airports.json +0 -1
  366. package/src/map/data/gazetteer.json +0 -1
  367. package/src/map/data/lakes.json +0 -1
  368. package/src/map/data/mountain-ranges.json +0 -1
  369. package/src/map/data/na-lakes.json +0 -1
  370. package/src/map/data/na-land.json +0 -1
  371. package/src/map/data/region-names.json +0 -1
  372. package/src/map/data/rivers.json +0 -1
  373. package/src/map/data/types.ts +0 -121
  374. package/src/map/data/us-states.json +0 -1
  375. package/src/map/data/water-bodies.json +0 -1
  376. package/src/map/data/world-coarse.json +0 -1
  377. package/src/map/data/world-detail.json +0 -1
  378. package/src/map/diagnostics.ts +0 -134
  379. package/src/map/dimensions.ts +0 -150
  380. package/src/map/geo-query.ts +0 -295
  381. package/src/map/geo.ts +0 -438
  382. package/src/map/invert.ts +0 -111
  383. package/src/map/layout.ts +0 -5069
  384. package/src/map/legend-band.ts +0 -127
  385. package/src/map/load-data.ts +0 -186
  386. package/src/map/parser.ts +0 -783
  387. package/src/map/renderer.ts +0 -1445
  388. package/src/map/resolved-types.ts +0 -160
  389. package/src/map/resolver.ts +0 -1146
  390. package/src/map/types.ts +0 -194
  391. package/src/mindmap/collapse.ts +0 -67
  392. package/src/mindmap/layout.ts +0 -649
  393. package/src/mindmap/parser.ts +0 -430
  394. package/src/mindmap/renderer.ts +0 -637
  395. package/src/mindmap/text-wrap.ts +0 -218
  396. package/src/mindmap/types.ts +0 -56
  397. package/src/org/collapse.ts +0 -151
  398. package/src/org/layout.ts +0 -1262
  399. package/src/org/parser.ts +0 -519
  400. package/src/org/renderer.ts +0 -866
  401. package/src/org/resolver.ts +0 -636
  402. package/src/palettes/atlas.ts +0 -77
  403. package/src/palettes/blueprint.ts +0 -73
  404. package/src/palettes/catppuccin.ts +0 -84
  405. package/src/palettes/color-utils.ts +0 -443
  406. package/src/palettes/index.ts +0 -67
  407. package/src/palettes/nord.ts +0 -67
  408. package/src/palettes/registry.ts +0 -114
  409. package/src/palettes/slate.ts +0 -73
  410. package/src/palettes/tidewater.ts +0 -73
  411. package/src/palettes/tokyo-night.ts +0 -86
  412. package/src/palettes/types.ts +0 -88
  413. package/src/pert/analyzer.ts +0 -1183
  414. package/src/pert/diagnostics.ts +0 -110
  415. package/src/pert/index.ts +0 -43
  416. package/src/pert/internal.ts +0 -252
  417. package/src/pert/layout.ts +0 -971
  418. package/src/pert/monte-carlo.ts +0 -446
  419. package/src/pert/parser.ts +0 -1606
  420. package/src/pert/renderer.ts +0 -4009
  421. package/src/pert/share-normalize.ts +0 -62
  422. package/src/pert/types.ts +0 -604
  423. package/src/pyramid/parser.ts +0 -185
  424. package/src/pyramid/renderer.ts +0 -768
  425. package/src/pyramid/types.ts +0 -28
  426. package/src/quadrant/parser.ts +0 -18
  427. package/src/quadrant/renderer.ts +0 -638
  428. package/src/raci/diagnostics.ts +0 -213
  429. package/src/raci/index.ts +0 -23
  430. package/src/raci/mutations.ts +0 -321
  431. package/src/raci/parser.ts +0 -699
  432. package/src/raci/renderer.ts +0 -1627
  433. package/src/raci/types.ts +0 -82
  434. package/src/raci/variants.ts +0 -320
  435. package/src/render.ts +0 -268
  436. package/src/ring/parser.ts +0 -212
  437. package/src/ring/renderer.ts +0 -490
  438. package/src/ring/types.ts +0 -27
  439. package/src/sequence/collapse.ts +0 -174
  440. package/src/sequence/parser.ts +0 -1596
  441. package/src/sequence/participant-inference.ts +0 -152
  442. package/src/sequence/renderer.ts +0 -3168
  443. package/src/sequence/tag-resolution.ts +0 -164
  444. package/src/sharing.ts +0 -211
  445. package/src/sitemap/collapse.ts +0 -100
  446. package/src/sitemap/layout.ts +0 -925
  447. package/src/sitemap/parser.ts +0 -755
  448. package/src/sitemap/renderer.ts +0 -828
  449. package/src/sitemap/types.ts +0 -42
  450. package/src/slope/parser.ts +0 -18
  451. package/src/slope/renderer.ts +0 -378
  452. package/src/swimlane/diagnostics.ts +0 -94
  453. package/src/swimlane/layout.ts +0 -597
  454. package/src/swimlane/parser.ts +0 -613
  455. package/src/swimlane/renderer.ts +0 -681
  456. package/src/swimlane/types.ts +0 -151
  457. package/src/tech-radar/interactive.ts +0 -1129
  458. package/src/tech-radar/layout.ts +0 -197
  459. package/src/tech-radar/parser.ts +0 -436
  460. package/src/tech-radar/renderer.ts +0 -1239
  461. package/src/tech-radar/shared.ts +0 -151
  462. package/src/tech-radar/types.ts +0 -86
  463. package/src/themes.ts +0 -22
  464. package/src/timeline/parser.ts +0 -472
  465. package/src/timeline/renderer.ts +0 -3024
  466. package/src/timeline/types.ts +0 -33
  467. package/src/timeline/viz-parser.ts +0 -21
  468. package/src/treemap/diagnostics.ts +0 -62
  469. package/src/treemap/layout-radial.ts +0 -233
  470. package/src/treemap/layout.ts +0 -189
  471. package/src/treemap/parser.ts +0 -463
  472. package/src/treemap/renderer-radial.ts +0 -438
  473. package/src/treemap/renderer.ts +0 -572
  474. package/src/treemap/treemap-shared.ts +0 -309
  475. package/src/treemap/types.ts +0 -60
  476. package/src/utils/arrow-markers.ts +0 -51
  477. package/src/utils/arrows-diagnostics.ts +0 -62
  478. package/src/utils/arrows.ts +0 -227
  479. package/src/utils/brand.ts +0 -61
  480. package/src/utils/card.ts +0 -183
  481. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  482. package/src/utils/collapse-engine/tree.ts +0 -117
  483. package/src/utils/d3-helpers.ts +0 -219
  484. package/src/utils/d3-types.ts +0 -9
  485. package/src/utils/description-helpers.ts +0 -38
  486. package/src/utils/duration.ts +0 -312
  487. package/src/utils/export-container.ts +0 -41
  488. package/src/utils/expr-eval.ts +0 -27
  489. package/src/utils/fit-canvas.ts +0 -64
  490. package/src/utils/hover-styles.ts +0 -673
  491. package/src/utils/inline-markdown.ts +0 -96
  492. package/src/utils/legend-constants.ts +0 -48
  493. package/src/utils/legend-d3.ts +0 -618
  494. package/src/utils/legend-integration.ts +0 -107
  495. package/src/utils/legend-layout.ts +0 -776
  496. package/src/utils/legend-svg.ts +0 -214
  497. package/src/utils/legend-types.ts +0 -272
  498. package/src/utils/name-normalize.ts +0 -163
  499. package/src/utils/note-box/constants.ts +0 -25
  500. package/src/utils/note-box/index.ts +0 -11
  501. package/src/utils/note-box/metrics.ts +0 -90
  502. package/src/utils/note-box/svg.ts +0 -331
  503. package/src/utils/notes/bounds.ts +0 -30
  504. package/src/utils/notes/build.ts +0 -131
  505. package/src/utils/notes/index.ts +0 -18
  506. package/src/utils/notes/model.ts +0 -19
  507. package/src/utils/notes/parse.ts +0 -131
  508. package/src/utils/notes/place.ts +0 -177
  509. package/src/utils/notes/resolve.ts +0 -88
  510. package/src/utils/number-format.ts +0 -36
  511. package/src/utils/parsing.ts +0 -953
  512. package/src/utils/reserved-key-registry.ts +0 -239
  513. package/src/utils/safe-href.ts +0 -38
  514. package/src/utils/scaling.ts +0 -97
  515. package/src/utils/svg-embed.ts +0 -251
  516. package/src/utils/tag-groups.ts +0 -607
  517. package/src/utils/text-measure.ts +0 -122
  518. package/src/utils/title-constants.ts +0 -24
  519. package/src/utils/visual-conventions.ts +0 -61
  520. package/src/utils/wrapped-desc.ts +0 -87
  521. package/src/venn/parser.ts +0 -18
  522. package/src/venn/renderer.ts +0 -1047
  523. package/src/version-control/diagnostics.ts +0 -67
  524. package/src/version-control/parser.ts +0 -544
  525. package/src/version-control/renderer.ts +0 -724
  526. package/src/version-control/types.ts +0 -100
  527. package/src/visualizations/parse.ts +0 -1560
  528. package/src/visualizations/types.ts +0 -262
  529. package/src/wireframe/layout.ts +0 -479
  530. package/src/wireframe/parser.ts +0 -1114
  531. package/src/wireframe/renderer.ts +0 -1318
  532. package/src/wireframe/types.ts +0 -110
  533. package/src/wordcloud/parser.ts +0 -18
  534. package/src/wordcloud/renderer.ts +0 -357
@@ -1,3168 +0,0 @@
1
- // ============================================================
2
- // Sequence Diagram SVG Renderer
3
- // ============================================================
4
-
5
- import { tagAttrKey } from '../utils/tag-groups';
6
- import * as d3Selection from 'd3-selection';
7
- import type { PaletteColors } from '../palettes';
8
- import {
9
- contrastText,
10
- mix,
11
- shapeFill,
12
- themeBaseBg,
13
- } from '../palettes/color-utils';
14
- import {
15
- parseInlineMarkdown,
16
- truncateBareUrl,
17
- renderInlineText,
18
- } from '../utils/inline-markdown';
19
- export { parseInlineMarkdown, truncateBareUrl };
20
- import { FONT_FAMILY } from '../fonts';
21
- import type {
22
- ParsedSequenceDgmo,
23
- SequenceElement,
24
- SequenceGroup,
25
- SequenceMessage,
26
- SequenceNote,
27
- SequenceParticipant,
28
- } from './parser';
29
- import { isSequenceBlock, isSequenceSection, isSequenceNote } from './parser';
30
- import { applyCollapseProjection } from './collapse';
31
- import type { CollapsedView } from './collapse';
32
- import {
33
- wrapDescriptionLines,
34
- type WrappedDescLine,
35
- } from '../utils/wrapped-desc';
36
- import { measureText, truncateText } from '../utils/text-measure';
37
- import { resolveSequenceTags } from './tag-resolution';
38
- import type { ResolvedTagMap } from './tag-resolution';
39
- import { resolveActiveTagGroup } from '../utils/tag-groups';
40
- import { getMaxLegendReservedHeight } from '../utils/legend-layout';
41
- import { renderIntegratedLegend } from '../utils/legend-integration';
42
- import type { LegendCallbacks, LegendConfig } from '../utils/legend-types';
43
- import {
44
- TITLE_FONT_SIZE,
45
- TITLE_FONT_WEIGHT,
46
- TITLE_Y,
47
- } from '../utils/title-constants';
48
- import { ScaleContext } from '../utils/scaling';
49
-
50
- // ============================================================
51
- // Layout Constants
52
- // ============================================================
53
-
54
- const PARTICIPANT_GAP = 160;
55
- const PARTICIPANT_BOX_WIDTH = 120;
56
- const PARTICIPANT_BOX_HEIGHT = 50;
57
- const LABEL_FONT_SIZE = 13;
58
- const TOP_MARGIN = 20;
59
- const TITLE_HEIGHT = 30;
60
- const PARTICIPANT_Y_OFFSET = 10;
61
- const MESSAGE_START_OFFSET = 50;
62
- const LIFELINE_TAIL = 30;
63
- const ARROWHEAD_SIZE = 8;
64
-
65
- // Note rendering constants
66
- const NOTE_MAX_W = 200;
67
- const NOTE_FOLD = 10;
68
- const NOTE_PAD_H = 8;
69
- const NOTE_PAD_V = 6;
70
- const NOTE_FONT_SIZE = 10;
71
- const NOTE_LINE_H = 14;
72
- const NOTE_GAP = 15;
73
- const ACTIVATION_WIDTH = 10;
74
- const SELF_CALL_HEIGHT = 25;
75
- const SELF_CALL_WIDTH = 30;
76
- // Actors render their label below the stick figure (at boxH + 14). Their
77
- // lifeline starts this far below the box so the dashes clear the label text.
78
- const ACTOR_LABEL_CLEARANCE = 22;
79
- function wrapTextLines(
80
- text: string,
81
- maxWidth: number,
82
- fontSize: number
83
- ): WrappedDescLine[] {
84
- // Convert leading "- " to the canonical bullet prefix so the shared wrap
85
- // helper can split bullet lines into bullet-first / bullet-cont kinds and
86
- // give us hanging-indent alignment on continuation lines.
87
- const rawLines = text
88
- .split('\n')
89
- .map((l) => (l.startsWith('- ') ? '• ' + l.slice(2) : l));
90
- // Drive the shared bullet-aware wrapper by pixel width: passing the note's
91
- // available text width as the limit and a glyph-accurate measurer as the
92
- // length function turns its char-count comparison into a true pixel wrap.
93
- return wrapDescriptionLines(rawLines, maxWidth, (s) =>
94
- measureText(s, fontSize)
95
- );
96
- }
97
-
98
- /**
99
- * Available pixel width for a participant label inside a box of the given
100
- * width (the 10px accounts for the same left/right inset the layout uses).
101
- */
102
- const labelTextWidth = (boxW: number): number => boxW - 10;
103
-
104
- /**
105
- * Split a participant label into multiple lines if it exceeds the box width.
106
- * Splits on spaces first, then dashes, then camelCase boundaries.
107
- */
108
- function splitParticipantLabel(
109
- label: string,
110
- maxWidth: number,
111
- fontSize: number
112
- ): string[] {
113
- if (measureText(label, fontSize) <= maxWidth) return [label];
114
-
115
- // Split on spaces
116
- if (label.includes(' ')) {
117
- return wrapLabelWords(label.split(' '), maxWidth, fontSize);
118
- }
119
-
120
- // Split on dashes/underscores/colons/slashes
121
- if (/[-_:/]/.test(label)) {
122
- const parts = label.split(/[-_:/]+/);
123
- return wrapLabelWords(parts, maxWidth, fontSize);
124
- }
125
-
126
- // Split on camelCase boundaries: "UserLookupCloudFx" → ["User", "Lookup", "Cloud", "Fx"]
127
- const camelParts = label
128
- .replace(/([a-z])([A-Z])/g, '$1\x00$2')
129
- .replace(/([A-Z]+)([A-Z][a-z])/g, '$1\x00$2')
130
- .split('\x00');
131
- if (camelParts.length > 1) {
132
- return wrapLabelWords(camelParts, maxWidth, fontSize);
133
- }
134
-
135
- return [label];
136
- }
137
-
138
- /** Greedily join word parts into lines that fit within maxWidth pixels. */
139
- function wrapLabelWords(
140
- words: string[],
141
- maxWidth: number,
142
- fontSize: number
143
- ): string[] {
144
- const lines: string[] = [];
145
- let current = '';
146
- for (const word of words) {
147
- const test = current ? current + word : word;
148
- if (measureText(test, fontSize) > maxWidth && current) {
149
- lines.push(current);
150
- current = word;
151
- } else {
152
- current = test;
153
- }
154
- }
155
- if (current) lines.push(current);
156
- return lines;
157
- }
158
-
159
- // Shared fill/stroke helpers — accept optional color override for per-participant coloring.
160
- // When `color` is provided we use the canonical shapeFill() 25% tint.
161
- // When omitted we fall back to a subtle-neutral surface highlight (out of scope for the
162
- // shape-fill standardization spec — see TD-2 / F11). Participants without a color should
163
- // recede; bumping them to 25% intent would defeat that intent.
164
- const fill = (
165
- palette: PaletteColors,
166
- isDark: boolean,
167
- color?: string,
168
- solid?: boolean
169
- ): string =>
170
- color
171
- ? shapeFill(palette, color, isDark, {
172
- ...(solid !== undefined && { solid }),
173
- })
174
- : isDark
175
- ? mix(palette.overlay, palette.surface, 50)
176
- : mix(palette.bg, palette.surface, 50);
177
- const stroke = (palette: PaletteColors, color?: string): string =>
178
- color || palette.border;
179
- const SW = 1.5;
180
- const W = PARTICIPANT_BOX_WIDTH;
181
- const H = PARTICIPANT_BOX_HEIGHT;
182
-
183
- // ============================================================
184
- // Participant Shape Renderers
185
- // ============================================================
186
-
187
- function renderRectParticipant(
188
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
189
- palette: PaletteColors,
190
- isDark: boolean,
191
- color?: string,
192
- solid?: boolean,
193
- w: number = W,
194
- h: number = H
195
- ): void {
196
- g.append('rect')
197
- .attr('x', -w / 2)
198
- .attr('y', 0)
199
- .attr('width', w)
200
- .attr('height', h)
201
- .attr('rx', 2)
202
- .attr('ry', 2)
203
- .attr('fill', fill(palette, isDark, color, solid))
204
- .attr('stroke', stroke(palette, color))
205
- .attr('stroke-width', SW);
206
- }
207
-
208
- function renderActorParticipant(
209
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
210
- palette: PaletteColors,
211
- color?: string,
212
- h: number = H
213
- ): void {
214
- // Stick figure — no background. Every dimension is scaled by sc = h / H so
215
- // the figure keeps its proportions at any box height. The box height itself
216
- // is compressed by ScaleContext when the diagram is wider than its container;
217
- // scaling head/arms/legs by the same factor (instead of hardcoding 8/16/12)
218
- // prevents the head from ballooning over a collapsed body when compressed.
219
- const sc = h / H;
220
- const headR = 8 * sc;
221
- const cx = 0;
222
- const headY = 10 * sc;
223
- const bodyTopY = 19 * sc;
224
- const bodyBottomY = h * 0.65;
225
- const legY = h - 2 * sc;
226
- const armY = 24 * sc;
227
- const armSpan = 16 * sc;
228
- const legSpan = 12 * sc;
229
- const s = stroke(palette, color);
230
- const actorSW = Math.max(1.2, 2.5 * sc);
231
-
232
- g.append('circle')
233
- .attr('cx', cx)
234
- .attr('cy', headY)
235
- .attr('r', headR)
236
- .attr('fill', 'none')
237
- .attr('stroke', s)
238
- .attr('stroke-width', actorSW);
239
-
240
- g.append('line')
241
- .attr('x1', cx)
242
- .attr('y1', bodyTopY)
243
- .attr('x2', cx)
244
- .attr('y2', bodyBottomY)
245
- .attr('stroke', s)
246
- .attr('stroke-width', actorSW);
247
-
248
- g.append('line')
249
- .attr('x1', cx - armSpan)
250
- .attr('y1', armY)
251
- .attr('x2', cx + armSpan)
252
- .attr('y2', armY)
253
- .attr('stroke', s)
254
- .attr('stroke-width', actorSW);
255
-
256
- g.append('line')
257
- .attr('x1', cx)
258
- .attr('y1', bodyBottomY)
259
- .attr('x2', cx - legSpan)
260
- .attr('y2', legY)
261
- .attr('stroke', s)
262
- .attr('stroke-width', actorSW);
263
-
264
- g.append('line')
265
- .attr('x1', cx)
266
- .attr('y1', bodyBottomY)
267
- .attr('x2', cx + legSpan)
268
- .attr('y2', legY)
269
- .attr('stroke', s)
270
- .attr('stroke-width', actorSW);
271
- }
272
-
273
- function renderDatabaseParticipant(
274
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
275
- palette: PaletteColors,
276
- isDark: boolean,
277
- color?: string,
278
- solid?: boolean,
279
- w: number = W,
280
- h: number = H
281
- ): void {
282
- // Cylinder fitting within w x h
283
- const ry = 7;
284
- const topY = ry;
285
- const bodyH = h - ry * 2;
286
- const f = fill(palette, isDark, color, solid);
287
- const s = stroke(palette, color);
288
-
289
- // Bottom ellipse (drawn first — rect will cover its top arc)
290
- g.append('ellipse')
291
- .attr('cx', 0)
292
- .attr('cy', topY + bodyH)
293
- .attr('rx', w / 2)
294
- .attr('ry', ry)
295
- .attr('fill', f)
296
- .attr('stroke', s)
297
- .attr('stroke-width', SW);
298
-
299
- // Filled body (no stroke) to hide the top arc of the bottom ellipse
300
- g.append('rect')
301
- .attr('class', 'participant-body')
302
- .attr('x', -w / 2)
303
- .attr('y', topY)
304
- .attr('width', w)
305
- .attr('height', bodyH)
306
- .attr('fill', f)
307
- .attr('stroke', 'none');
308
-
309
- // Side lines
310
- g.append('line')
311
- .attr('x1', -w / 2)
312
- .attr('y1', topY)
313
- .attr('x2', -w / 2)
314
- .attr('y2', topY + bodyH)
315
- .attr('stroke', s)
316
- .attr('stroke-width', SW);
317
- g.append('line')
318
- .attr('x1', w / 2)
319
- .attr('y1', topY)
320
- .attr('x2', w / 2)
321
- .attr('y2', topY + bodyH)
322
- .attr('stroke', s)
323
- .attr('stroke-width', SW);
324
-
325
- // Top ellipse cap (drawn last, on top)
326
- g.append('ellipse')
327
- .attr('cx', 0)
328
- .attr('cy', topY)
329
- .attr('rx', w / 2)
330
- .attr('ry', ry)
331
- .attr('fill', f)
332
- .attr('stroke', s)
333
- .attr('stroke-width', SW);
334
- }
335
-
336
- function renderQueueParticipant(
337
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
338
- palette: PaletteColors,
339
- isDark: boolean,
340
- color?: string,
341
- solid?: boolean,
342
- w: number = W,
343
- h: number = H
344
- ): void {
345
- // Horizontal cylinder (pipe) — like database rotated 90 degrees
346
- const rx = 10;
347
- const leftX = -w / 2 + rx;
348
- const bodyW = w - rx * 2;
349
- const f = fill(palette, isDark, color, solid);
350
- const s = stroke(palette, color);
351
-
352
- // Right ellipse (back face, drawn first — rect will cover its left arc)
353
- g.append('ellipse')
354
- .attr('cx', leftX + bodyW)
355
- .attr('cy', h / 2)
356
- .attr('rx', rx)
357
- .attr('ry', h / 2)
358
- .attr('fill', f)
359
- .attr('stroke', s)
360
- .attr('stroke-width', SW);
361
-
362
- // Body rect (no stroke) to hide left arc of right ellipse
363
- g.append('rect')
364
- .attr('class', 'participant-body')
365
- .attr('x', leftX)
366
- .attr('y', 0)
367
- .attr('width', bodyW)
368
- .attr('height', h)
369
- .attr('fill', f)
370
- .attr('stroke', 'none');
371
-
372
- // Top and bottom lines
373
- g.append('line')
374
- .attr('x1', leftX)
375
- .attr('y1', 0)
376
- .attr('x2', leftX + bodyW)
377
- .attr('y2', 0)
378
- .attr('stroke', s)
379
- .attr('stroke-width', SW);
380
- g.append('line')
381
- .attr('x1', leftX)
382
- .attr('y1', h)
383
- .attr('x2', leftX + bodyW)
384
- .attr('y2', h)
385
- .attr('stroke', s)
386
- .attr('stroke-width', SW);
387
-
388
- // Left ellipse (front face, drawn last)
389
- g.append('ellipse')
390
- .attr('cx', leftX)
391
- .attr('cy', h / 2)
392
- .attr('rx', rx)
393
- .attr('ry', h / 2)
394
- .attr('fill', f)
395
- .attr('stroke', s)
396
- .attr('stroke-width', SW);
397
- }
398
-
399
- function renderCacheParticipant(
400
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
401
- palette: PaletteColors,
402
- isDark: boolean,
403
- color?: string,
404
- solid?: boolean,
405
- w: number = W,
406
- h: number = H
407
- ): void {
408
- // Dashed cylinder — variation of database to convey ephemeral storage
409
- const ry = 7;
410
- const topY = ry;
411
- const bodyH = h - ry * 2;
412
- const f = fill(palette, isDark, color, solid);
413
- const s = stroke(palette, color);
414
- const dash = '4 3';
415
-
416
- // Bottom ellipse (back face)
417
- g.append('ellipse')
418
- .attr('cx', 0)
419
- .attr('cy', topY + bodyH)
420
- .attr('rx', w / 2)
421
- .attr('ry', ry)
422
- .attr('fill', f)
423
- .attr('stroke', s)
424
- .attr('stroke-width', SW)
425
- .attr('stroke-dasharray', dash);
426
-
427
- g.append('rect')
428
- .attr('class', 'participant-body')
429
- .attr('x', -w / 2)
430
- .attr('y', topY)
431
- .attr('width', w)
432
- .attr('height', bodyH)
433
- .attr('fill', f)
434
- .attr('stroke', 'none');
435
-
436
- g.append('line')
437
- .attr('x1', -w / 2)
438
- .attr('y1', topY)
439
- .attr('x2', -w / 2)
440
- .attr('y2', topY + bodyH)
441
- .attr('stroke', s)
442
- .attr('stroke-width', SW)
443
- .attr('stroke-dasharray', dash);
444
- g.append('line')
445
- .attr('x1', w / 2)
446
- .attr('y1', topY)
447
- .attr('x2', w / 2)
448
- .attr('y2', topY + bodyH)
449
- .attr('stroke', s)
450
- .attr('stroke-width', SW)
451
- .attr('stroke-dasharray', dash);
452
-
453
- // Top ellipse cap
454
- g.append('ellipse')
455
- .attr('cx', 0)
456
- .attr('cy', topY)
457
- .attr('rx', w / 2)
458
- .attr('ry', ry)
459
- .attr('fill', f)
460
- .attr('stroke', s)
461
- .attr('stroke-width', SW)
462
- .attr('stroke-dasharray', dash);
463
- }
464
-
465
- // ============================================================
466
- // Collapsible Section Support
467
- // ============================================================
468
-
469
- export interface SectionMessageGroup {
470
- section: import('./parser').SequenceSection;
471
- messageIndices: number[]; // indices into messages[]
472
- }
473
-
474
- export interface SequenceRenderOptions {
475
- collapsedSections?: Set<number>; // keyed by section lineNumber
476
- collapsedGroups?: Set<number>; // keyed by group lineNumber
477
- exportWidth?: number; // Explicit width for CLI/export rendering (bypasses getBoundingClientRect)
478
- activeTagGroup?: string | null; // Active tag group name for tag-driven recoloring; null = explicitly none
479
- }
480
-
481
- /**
482
- * Group messages by the top-level section that precedes them.
483
- * Messages before the first section are ungrouped (always visible).
484
- * Only top-level sections are collapsible — sections inside blocks are excluded.
485
- */
486
- export function groupMessagesBySection(
487
- elements: readonly SequenceElement[],
488
- messages: readonly SequenceMessage[]
489
- ): SectionMessageGroup[] {
490
- const groups: SectionMessageGroup[] = [];
491
- let currentGroup: SectionMessageGroup | null = null;
492
-
493
- // Look up by lineNumber — collapse projection creates separate spread copies
494
- // for messages[] and the messages embedded in elements[], breaking reference
495
- // equality. lineNumber is preserved across spreads.
496
- const msgLineToIdx = new Map<number, number>();
497
- messages.forEach((m, i) => msgLineToIdx.set(m.lineNumber, i));
498
-
499
- // Recursively collect all message indices from an element subtree
500
- const collectIndices = (els: readonly SequenceElement[]): number[] => {
501
- const indices: number[] = [];
502
- for (const el of els) {
503
- if (isSequenceBlock(el)) {
504
- indices.push(
505
- ...collectIndices(el.children),
506
- ...collectIndices(el.elseChildren)
507
- );
508
- if (el.elseIfBranches) {
509
- for (const branch of el.elseIfBranches) {
510
- indices.push(...collectIndices(branch.children));
511
- }
512
- }
513
- } else if (isSequenceSection(el) || isSequenceNote(el)) {
514
- // Sections and notes inside blocks are not messages — skip
515
- continue;
516
- } else {
517
- const idx = msgLineToIdx.get(el.lineNumber) ?? -1;
518
- if (idx >= 0) indices.push(idx);
519
- }
520
- }
521
- return indices;
522
- };
523
-
524
- for (const el of elements) {
525
- if (isSequenceSection(el)) {
526
- // Start a new group for this top-level section
527
- currentGroup = { section: el, messageIndices: [] };
528
- groups.push(currentGroup);
529
- } else if (currentGroup) {
530
- // Collect messages from this element into the current group
531
- if (isSequenceBlock(el)) {
532
- currentGroup.messageIndices.push(...collectIndices([el]));
533
- } else if (!isSequenceNote(el)) {
534
- const idx = msgLineToIdx.get(el.lineNumber) ?? -1;
535
- if (idx >= 0) currentGroup.messageIndices.push(idx);
536
- }
537
- }
538
- // Messages before the first section are ungrouped — skip
539
- }
540
-
541
- return groups;
542
- }
543
-
544
- // ============================================================
545
- // Render Sequence Builder (stack-based return placement)
546
- // ============================================================
547
-
548
- export interface RenderStep {
549
- type: 'call' | 'return';
550
- from: string;
551
- to: string;
552
- label: string;
553
- messageIndex: number;
554
- async?: boolean;
555
- }
556
-
557
- /**
558
- * Build an ordered render sequence from flat messages.
559
- * Uses a call stack to infer where returns should be placed:
560
- * returns appear after all nested sub-calls complete.
561
- */
562
- export function buildRenderSequence(
563
- messages: readonly SequenceMessage[]
564
- ): RenderStep[] {
565
- const steps: RenderStep[] = [];
566
- const stack: {
567
- from: string;
568
- to: string;
569
- messageIndex: number;
570
- }[] = [];
571
-
572
- for (let mi = 0; mi < messages.length; mi++) {
573
- // In-bounds by loop guard.
574
- const msg = messages[mi]!;
575
- // Pop returns for callees that are no longer the sender
576
- while (stack.length > 0) {
577
- // In-bounds: stack.length > 0 guard above.
578
- const top = stack[stack.length - 1]!;
579
- if (top.to === msg.from) break; // callee is still working
580
- stack.pop();
581
- steps.push({
582
- type: 'return',
583
- from: top.to,
584
- to: top.from,
585
- label: '',
586
- messageIndex: top.messageIndex,
587
- });
588
- }
589
-
590
- // Response heuristic: if B sends back to A and the top of the
591
- // stack is A→B, treat this as a response (pop) rather than a new
592
- // call. Covers the common request-response ping-pong pattern.
593
- if (stack.length > 0 && !msg.async && msg.from !== msg.to) {
594
- const top = stack[stack.length - 1]!;
595
- if (top.from === msg.to && top.to === msg.from) {
596
- stack.pop();
597
- steps.push({
598
- type: 'return',
599
- from: msg.from,
600
- to: msg.to,
601
- label: msg.label,
602
- messageIndex: mi,
603
- });
604
- continue;
605
- }
606
- }
607
-
608
- // Emit call
609
- steps.push({
610
- type: 'call',
611
- from: msg.from,
612
- to: msg.to,
613
- label: msg.label,
614
- messageIndex: mi,
615
- ...(msg.async ? { async: true } : {}),
616
- });
617
-
618
- // Async messages: no return arrow, no activation on target
619
- if (msg.async) {
620
- continue;
621
- }
622
-
623
- if (msg.from === msg.to) {
624
- // Self-call: immediately emit return (completes instantly)
625
- steps.push({
626
- type: 'return',
627
- from: msg.to,
628
- to: msg.from,
629
- label: '',
630
- messageIndex: mi,
631
- });
632
- } else {
633
- // Push onto stack for pending return
634
- stack.push({
635
- from: msg.from,
636
- to: msg.to,
637
- messageIndex: mi,
638
- });
639
- }
640
- }
641
-
642
- // Flush remaining returns
643
- while (stack.length > 0) {
644
- const top = stack.pop()!;
645
- steps.push({
646
- type: 'return',
647
- from: top.to,
648
- to: top.from,
649
- label: '',
650
- messageIndex: top.messageIndex,
651
- });
652
- }
653
-
654
- return steps;
655
- }
656
-
657
- // ============================================================
658
- // Activation Computation
659
- // ============================================================
660
-
661
- export interface Activation {
662
- participantId: string;
663
- startStep: number;
664
- endStep: number;
665
- depth: number;
666
- }
667
-
668
- /**
669
- * Compute activation rectangles from render steps.
670
- * Each call pushes onto the callee's stack; each return pops it.
671
- */
672
- export function computeActivations(steps: RenderStep[]): Activation[] {
673
- const activations: Activation[] = [];
674
- // Per-participant stack of open activations (step index)
675
- const stacks = new Map<string, number[]>();
676
-
677
- const getStack = (id: string): number[] => {
678
- if (!stacks.has(id)) stacks.set(id, []);
679
- return stacks.get(id)!;
680
- };
681
-
682
- for (let i = 0; i < steps.length; i++) {
683
- // In-bounds by loop guard.
684
- const step = steps[i]!;
685
- if (step.type === 'call') {
686
- const s = getStack(step.to);
687
- s.push(i);
688
- } else {
689
- // return: step.from is the callee returning
690
- const s = getStack(step.from);
691
- if (s.length > 0) {
692
- const startIdx = s.pop()!;
693
- activations.push({
694
- participantId: step.from,
695
- startStep: startIdx,
696
- endStep: i,
697
- depth: s.length,
698
- });
699
- }
700
- }
701
- }
702
-
703
- return activations;
704
- }
705
-
706
- // ============================================================
707
- // Position Override Sorting
708
- // ============================================================
709
-
710
- /**
711
- * Reorder participants based on explicit `position` overrides.
712
- * Positive positions are 0-based from the left; negative positions count from the right (-1 = last).
713
- * Unpositioned participants maintain their relative order, filling remaining slots.
714
- */
715
- export function applyPositionOverrides(
716
- participants: readonly SequenceParticipant[]
717
- ): SequenceParticipant[] {
718
- // Copy to a mutable array on the no-op path so callers always get a
719
- // fresh `SequenceParticipant[]` regardless of input mutability.
720
- if (!participants.some((p) => p.position !== undefined))
721
- return [...participants];
722
-
723
- const total = participants.length;
724
- const positioned: { participant: SequenceParticipant; index: number }[] = [];
725
- const unpositioned: SequenceParticipant[] = [];
726
-
727
- for (const p of participants) {
728
- if (p.position !== undefined) {
729
- // Resolve negative: -1 → last, -2 → second-to-last
730
- let idx = p.position < 0 ? total + p.position : p.position;
731
- // Clamp to valid range
732
- idx = Math.max(0, Math.min(total - 1, idx));
733
- positioned.push({ participant: p, index: idx });
734
- } else {
735
- unpositioned.push(p);
736
- }
737
- }
738
-
739
- // Sort positioned by target index for deterministic placement
740
- positioned.sort((a, b) => a.index - b.index);
741
-
742
- // Place positioned participants, resolving conflicts by finding nearest free slot
743
- const result: (SequenceParticipant | null)[] = new Array(total).fill(null);
744
- const usedIndices = new Set<number>();
745
-
746
- for (const { participant, index } of positioned) {
747
- let idx = index;
748
- if (usedIndices.has(idx)) {
749
- // Find nearest free slot
750
- for (let offset = 1; offset < total; offset++) {
751
- if (idx + offset < total && !usedIndices.has(idx + offset)) {
752
- idx = idx + offset;
753
- break;
754
- }
755
- if (idx - offset >= 0 && !usedIndices.has(idx - offset)) {
756
- idx = idx - offset;
757
- break;
758
- }
759
- }
760
- }
761
- result[idx] = participant;
762
- usedIndices.add(idx);
763
- }
764
-
765
- // Fill remaining slots with unpositioned participants in order
766
- let uIdx = 0;
767
- for (let i = 0; i < total; i++) {
768
- if (result[i] === null) {
769
- // In-bounds: positioned.length + unpositioned.length === total, so the
770
- // remaining `null` slots equal unpositioned.length.
771
- result[i] = unpositioned[uIdx++]!;
772
- }
773
- }
774
-
775
- return result as SequenceParticipant[];
776
- }
777
-
778
- // Group Ordering
779
- // ============================================================
780
-
781
- /**
782
- * Reorder participants so that members of the same group are adjacent.
783
- * Groups are positioned at the point where their first member would naturally
784
- * appear based on message order (first-occurrence positioning). This prevents
785
- * groups declared at the top of the file from being placed before participants
786
- * that appear in messages earlier.
787
- *
788
- * Explicit `position` overrides are handled separately by `applyPositionOverrides`.
789
- */
790
- export function applyGroupOrdering(
791
- participants: readonly SequenceParticipant[],
792
- groups: readonly SequenceGroup[],
793
- messages: readonly SequenceMessage[] = []
794
- ): SequenceParticipant[] {
795
- // Copy to a mutable array on the no-op path so callers always get a
796
- // fresh `SequenceParticipant[]` regardless of input mutability.
797
- if (groups.length === 0) return [...participants];
798
-
799
- // Build a map: participantId → group
800
- const idToGroup = new Map<string, SequenceGroup>();
801
- for (const group of groups) {
802
- for (const id of group.participantIds) {
803
- idToGroup.set(id, group);
804
- }
805
- }
806
-
807
- // Build first-appearance index from messages (order in which participants
808
- // are first referenced). Participants not in any message keep their
809
- // declaration order from the participants array.
810
- const appearanceOrder: string[] = [];
811
- const seen = new Set<string>();
812
- for (const msg of messages) {
813
- for (const id of [msg.from, msg.to]) {
814
- if (!seen.has(id)) {
815
- seen.add(id);
816
- appearanceOrder.push(id);
817
- }
818
- }
819
- }
820
- // Append any participants not referenced in messages (declaration-only)
821
- for (const p of participants) {
822
- if (!seen.has(p.id)) {
823
- seen.add(p.id);
824
- appearanceOrder.push(p.id);
825
- }
826
- }
827
-
828
- // Walk appearance order; when we encounter a grouped participant,
829
- // insert the entire group at that position (if not already placed).
830
- const result: SequenceParticipant[] = [];
831
- const placed = new Set<string>();
832
- const placedGroups = new Set<SequenceGroup>();
833
-
834
- for (const id of appearanceOrder) {
835
- if (placed.has(id)) continue;
836
-
837
- const group = idToGroup.get(id);
838
- if (group && !placedGroups.has(group)) {
839
- // Place entire group here
840
- placedGroups.add(group);
841
- for (const gid of group.participantIds) {
842
- const p = participants.find((pp) => pp.id === gid);
843
- if (p && !placed.has(gid)) {
844
- result.push(p);
845
- placed.add(gid);
846
- }
847
- }
848
- } else if (!group) {
849
- // Ungrouped participant
850
- const p = participants.find((pp) => pp.id === id);
851
- if (p) {
852
- result.push(p);
853
- placed.add(id);
854
- }
855
- }
856
- // If group already placed, skip (member already included)
857
- }
858
-
859
- return result;
860
- }
861
-
862
- // Main Renderer
863
- // ============================================================
864
-
865
- /**
866
- * Render a sequence diagram into the given container element.
867
- */
868
- export function renderSequenceDiagram(
869
- container: HTMLDivElement,
870
- parsed: ParsedSequenceDgmo,
871
- palette: PaletteColors,
872
- isDark: boolean,
873
- _onNavigateToLine?: (line: number) => void,
874
- options?: SequenceRenderOptions
875
- ): void {
876
- // Clear previous content
877
- d3Selection.select(container).selectAll('*').remove();
878
-
879
- const { title, options: parsedOptions } = parsed;
880
- const solid = parsedOptions['solid-fill'] === 'on';
881
-
882
- // Effective collapsed groups. `options.collapsedGroups` is the AUTHORITATIVE
883
- // desired set when supplied — the app seeds it from the source `collapsed:
884
- // true` markers and mutates it on toggle (write-through keeps source in sync),
885
- // so it must NOT be XOR'd against the syntax-declared set (doing so cancelled
886
- // a seeded set back to expanded). With no set supplied (bare render / CLI),
887
- // default to the syntax-declared collapsed groups. Mirrors gantt/kanban/state.
888
- const effectiveCollapsedGroups = options?.collapsedGroups
889
- ? new Set(options.collapsedGroups)
890
- : new Set(
891
- parsed.groups.filter((g) => g.collapsed).map((g) => g.lineNumber)
892
- );
893
-
894
- // Apply collapse projection before participant ordering
895
- const collapsed: CollapsedView | null =
896
- effectiveCollapsedGroups.size > 0
897
- ? applyCollapseProjection(parsed, effectiveCollapsedGroups)
898
- : null;
899
-
900
- const messages = collapsed ? collapsed.messages : parsed.messages;
901
- const elements = collapsed ? collapsed.elements : parsed.elements;
902
- const groups = collapsed ? collapsed.groups : parsed.groups;
903
- const collapsedSections = options?.collapsedSections;
904
-
905
- const sourceParticipants = collapsed
906
- ? collapsed.participants
907
- : parsed.participants;
908
- const participants = applyPositionOverrides(
909
- applyGroupOrdering(sourceParticipants, groups, messages)
910
- );
911
- if (participants.length === 0) return;
912
-
913
- // Compute group boundaries for inter-group spacing redistribution.
914
- // A gap is "between" (wider) whenever the two adjacent lifelines are not
915
- // members of the same group — this includes group→group, group→loose, and
916
- // loose→group transitions. Only two lifelines inside the same group, or two
917
- // adjacent loose lifelines, get the tighter "within" gap. Treating a loose
918
- // participant as its own non-group prevents it from being jammed against an
919
- // adjacent group box (whose frame extends GROUP_PADDING_X past its members).
920
- const groupBoundaryIds = new Set<string>();
921
- if (groups.length > 0) {
922
- const pToG = new Map<string, number>();
923
- for (let gi = 0; gi < groups.length; gi++) {
924
- for (const pid of groups[gi]!.participantIds) pToG.set(pid, gi);
925
- }
926
- const LOOSE = -1;
927
- for (let i = 1; i < participants.length; i++) {
928
- const prevGi = pToG.get(participants[i - 1]!.id) ?? LOOSE;
929
- const gi = pToG.get(participants[i]!.id) ?? LOOSE;
930
- // Different group membership → boundary. Two loose lifelines (both LOOSE)
931
- // stay tight since neither carries a group frame.
932
- if (gi !== prevGi && !(gi === LOOSE && prevGi === LOOSE)) {
933
- groupBoundaryIds.add(participants[i]!.id);
934
- }
935
- }
936
- }
937
- const numGroupGaps = groupBoundaryIds.size;
938
-
939
- // Content-adaptive box width: measure widest label, use uniform width for all
940
- const MAX_BOX_WIDTH = 225;
941
- let uniformBoxWidth = PARTICIPANT_BOX_WIDTH;
942
- for (const p of participants) {
943
- const lines = splitParticipantLabel(
944
- p.label,
945
- labelTextWidth(PARTICIPANT_BOX_WIDTH),
946
- LABEL_FONT_SIZE
947
- );
948
- if (lines.length === 0) continue;
949
- const widest = Math.max(
950
- ...lines.map((l) => measureText(l, LABEL_FONT_SIZE))
951
- );
952
- const labelWidth = widest + 10;
953
- uniformBoxWidth = Math.max(uniformBoxWidth, labelWidth);
954
- }
955
- uniformBoxWidth = Math.min(MAX_BOX_WIDTH, uniformBoxWidth);
956
- const effectiveGap = Math.max(PARTICIPANT_GAP, uniformBoxWidth + 30);
957
-
958
- const idealWidth = Math.max(
959
- participants.length * effectiveGap,
960
- uniformBoxWidth + 40
961
- );
962
- const containerWidth =
963
- options?.exportWidth ?? container.getBoundingClientRect().width;
964
- const ctx = options?.exportWidth
965
- ? ScaleContext.identity()
966
- : ScaleContext.from(containerWidth, idealWidth);
967
-
968
- const sGap = ctx.structural(effectiveGap);
969
- const sBoxW = ctx.structural(uniformBoxWidth);
970
- const sBoxH = ctx.structural(PARTICIPANT_BOX_HEIGHT);
971
- const sTopMargin = ctx.aesthetic(TOP_MARGIN);
972
- const sTitleHeight = ctx.aesthetic(TITLE_HEIGHT);
973
- const sParticipantYOffset = ctx.aesthetic(PARTICIPANT_Y_OFFSET);
974
- const sMsgStartOffset = ctx.structural(MESSAGE_START_OFFSET);
975
- const sLifelineTail = ctx.structural(LIFELINE_TAIL);
976
- const sArrowheadSize = ctx.structural(ARROWHEAD_SIZE);
977
- const sNoteMaxW = ctx.structural(NOTE_MAX_W);
978
- const sNoteFold = ctx.structural(NOTE_FOLD);
979
- const sNotePadH = ctx.structural(NOTE_PAD_H);
980
- const sNotePadV = ctx.structural(NOTE_PAD_V);
981
- const sNoteFontSize = ctx.text(NOTE_FONT_SIZE);
982
- const sNoteLineH = ctx.structural(NOTE_LINE_H);
983
- const sNoteGap = ctx.structural(NOTE_GAP);
984
- const sActivationWidth = ctx.structural(ACTIVATION_WIDTH);
985
- const sSelfCallHeight = SELF_CALL_HEIGHT;
986
- const sSelfCallWidth = ctx.structural(SELF_CALL_WIDTH);
987
- // Pixel width available for note text inside the widest allowed note box
988
- // (box width minus the horizontal padding on both sides and the fold cut).
989
- const sNoteTextWidthMax = sNoteMaxW - sNotePadH * 2 - sNoteFold;
990
- const sNoteLaneMax = sGap - sActivationWidth - sNoteGap;
991
- const sLabelFontSize = ctx.text(LABEL_FONT_SIZE);
992
- // Pixel width available for a participant label inside the scaled box.
993
- const sLabelTextWidth = labelTextWidth(sBoxW);
994
-
995
- // Participant index lookup — used to clamp note width within one lane
996
- const participantIndexMap = new Map<string, number>();
997
- participants.forEach((p, i) => participantIndexMap.set(p.id, i));
998
-
999
- // Notes anchored to the outermost participant on the side they'd extend
1000
- // toward have no neighbor lane to fit into, so they would overflow the SVG.
1001
- // Flip them to the inside when there's a participant on the other side.
1002
- const effectiveNotePosition = (note: SequenceNote): 'left' | 'right' => {
1003
- const idx = participantIndexMap.get(note.participantId);
1004
- if (idx === undefined) return note.position;
1005
- if (note.position === 'right' && idx === participants.length - 1 && idx > 0)
1006
- return 'left';
1007
- if (note.position === 'left' && idx === 0 && participants.length > 1)
1008
- return 'right';
1009
- return note.position;
1010
- };
1011
-
1012
- // Extra X shift for notes after self-calls
1013
- const SELF_CALL_NOTE_X_SHIFT =
1014
- sActivationWidth / 2 +
1015
- sSelfCallWidth +
1016
- sNoteGap -
1017
- (sActivationWidth + sNoteGap);
1018
-
1019
- const noteEffectiveMaxW = (
1020
- participantId: string,
1021
- position: 'right' | 'left',
1022
- afterSelfCall = false
1023
- ): number => {
1024
- const idx = participantIndexMap.get(participantId);
1025
- if (idx === undefined) return sNoteMaxW;
1026
- const hasNeighbor =
1027
- position === 'right' ? idx < participants.length - 1 : idx > 0;
1028
- if (!hasNeighbor) return sNoteMaxW;
1029
- const laneMax =
1030
- afterSelfCall && position === 'right'
1031
- ? sNoteLaneMax - SELF_CALL_NOTE_X_SHIFT
1032
- : sNoteLaneMax;
1033
- return Math.min(sNoteMaxW, laneMax);
1034
- };
1035
-
1036
- // Pixel width available for note text inside a note box of the given outer
1037
- // width — outer width minus horizontal padding (both sides) and the fold cut.
1038
- const noteTextWidth = (maxW: number): number =>
1039
- maxW - sNotePadH * 2 - sNoteFold;
1040
-
1041
- const activationsOff = parsedOptions['activations']?.toLowerCase() === 'off';
1042
-
1043
- // Tag resolution — shared utility handles priority chain:
1044
- // programmatic override → diagram-level active-tag → auto-activate first group
1045
- const activeTagGroup =
1046
- resolveActiveTagGroup(
1047
- parsed.tagGroups.filter((tg) => tg.entries.length > 0),
1048
- parsedOptions['active-tag'],
1049
- options?.activeTagGroup
1050
- ) ?? undefined;
1051
- let tagMap: ResolvedTagMap | undefined;
1052
- const tagValueToColor = new Map<string, string>();
1053
- if (activeTagGroup) {
1054
- tagMap = resolveSequenceTags(parsed, activeTagGroup);
1055
- const tg = parsed.tagGroups.find(
1056
- (g) => g.name.toLowerCase() === activeTagGroup.toLowerCase()
1057
- );
1058
- if (tg) {
1059
- for (const entry of tg.entries) {
1060
- tagValueToColor.set(entry.value.toLowerCase(), entry.color);
1061
- }
1062
- }
1063
- }
1064
- const getTagColor = (value: string | undefined): string | undefined =>
1065
- value ? tagValueToColor.get(value.toLowerCase()) : undefined;
1066
- const tagKey = activeTagGroup ? tagAttrKey(activeTagGroup) : undefined;
1067
-
1068
- // Build hidden message set for collapse support
1069
- const hiddenMsgIndices = new Set<number>();
1070
- if (collapsedSections && collapsedSections.size > 0) {
1071
- const sectionGroups = groupMessagesBySection(elements, messages);
1072
- for (const grp of sectionGroups) {
1073
- if (collapsedSections.has(grp.section.lineNumber)) {
1074
- for (const idx of grp.messageIndices) {
1075
- hiddenMsgIndices.add(idx);
1076
- }
1077
- }
1078
- }
1079
- }
1080
-
1081
- // Build render sequence with stack-based return placement
1082
- // Run on ALL messages first (preserves call stack correctness), then filter
1083
- const allRenderSteps = buildRenderSequence(messages);
1084
- // A step survives into the laid-out sequence when it is not hidden by a
1085
- // collapsed section AND it is either a call or a *labeled* return. Unlabeled
1086
- // returns are dropped from the layout — they add vertical noise without
1087
- // conveying information — but they still balance the activation stack, so
1088
- // dropping them before computeActivations would corrupt nesting depth
1089
- // (self-call returns are unlabeled; their pushes would never pop, shifting
1090
- // the parent activation to a bogus depth). Compute depth on the full
1091
- // balanced sequence, then remap step indices to the laid-out array.
1092
- const stepSurvives = (s: RenderStep): boolean =>
1093
- (hiddenMsgIndices.size === 0 || !hiddenMsgIndices.has(s.messageIndex)) &&
1094
- (s.type === 'call' || !!s.label);
1095
- const renderSteps: RenderStep[] = [];
1096
- // allRenderSteps index → laid-out (renderSteps) index. A dropped step maps to
1097
- // the next surviving step, so an activation whose closing return was dropped
1098
- // still extends to the following event rather than collapsing to zero height.
1099
- const allToFiltered: number[] = new Array(allRenderSteps.length);
1100
- for (let j = 0; j < allRenderSteps.length; j++) {
1101
- const s = allRenderSteps[j]!;
1102
- allToFiltered[j] = renderSteps.length;
1103
- if (stepSurvives(s)) renderSteps.push(s);
1104
- }
1105
- const lastStepIdx = Math.max(renderSteps.length - 1, 0);
1106
- const clampStep = (i: number): number =>
1107
- Math.min(Math.max(i, 0), lastStepIdx);
1108
- // Activations computed on the balanced sequence for correct nesting depth,
1109
- // then remapped to laid-out indices for Y positioning.
1110
- const activations = activationsOff
1111
- ? []
1112
- : computeActivations(allRenderSteps).map((a) => ({
1113
- ...a,
1114
- startStep: clampStep(allToFiltered[a.startStep] ?? 0),
1115
- endStep: clampStep(allToFiltered[a.endStep] ?? 0),
1116
- }));
1117
- // Vertical spacing is NOT compressed by ScaleContext — the container scrolls
1118
- // vertically, so keeping full spacing preserves message readability at all scales.
1119
- const stepSpacing = 35;
1120
-
1121
- // --- Block-aware Y spacing ---
1122
- const BLOCK_HEADER_SPACE = 30;
1123
- const BLOCK_AFTER_SPACE = 15;
1124
- const FRAME_PADDING_TOP = 42;
1125
-
1126
- // Build maps from messageIndex to render step indices (needed early for spacing)
1127
- const msgToFirstStep = new Map<number, number>();
1128
- const msgToLastStep = new Map<number, number>();
1129
- renderSteps.forEach((step, si) => {
1130
- if (!msgToFirstStep.has(step.messageIndex)) {
1131
- msgToFirstStep.set(step.messageIndex, si);
1132
- }
1133
- msgToLastStep.set(step.messageIndex, si);
1134
- });
1135
-
1136
- // Map a note to the last render-step index of its preceding message
1137
- // (the return arrow if present, otherwise the call arrow).
1138
- // This ensures notes are positioned below the return arrow so they
1139
- // don't overlap it.
1140
- // If the note's closest preceding message is hidden (collapsed section), return -1
1141
- // so the note is hidden along with its section.
1142
- const findAssociatedLastStep = (note: SequenceNote): number => {
1143
- // First find the closest preceding message (ignoring hidden filter)
1144
- let closestMsgIndex = -1;
1145
- let closestLine = -1;
1146
- for (let mi = 0; mi < messages.length; mi++) {
1147
- // In-bounds by loop guard.
1148
- const m = messages[mi]!;
1149
- if (m.lineNumber < note.lineNumber && m.lineNumber > closestLine) {
1150
- closestLine = m.lineNumber;
1151
- closestMsgIndex = mi;
1152
- }
1153
- }
1154
- // If the closest preceding message is hidden, hide the note too
1155
- if (closestMsgIndex >= 0 && hiddenMsgIndices.has(closestMsgIndex)) {
1156
- return -1;
1157
- }
1158
- if (closestMsgIndex < 0) return -1;
1159
- return msgToLastStep.get(closestMsgIndex) ?? -1;
1160
- };
1161
-
1162
- // Check whether a note's preceding message is a self-call.
1163
- // Self-call loopback arrows extend SELF_CALL_HEIGHT below the step Y,
1164
- // so notes after self-calls need a larger vertical offset.
1165
- const isNoteAfterSelfCall = (note: SequenceNote): boolean => {
1166
- let closestMsgIndex = -1;
1167
- let closestLine = -1;
1168
- for (let mi = 0; mi < messages.length; mi++) {
1169
- // In-bounds by loop guard.
1170
- const m = messages[mi]!;
1171
- if (m.lineNumber < note.lineNumber && m.lineNumber > closestLine) {
1172
- closestLine = m.lineNumber;
1173
- closestMsgIndex = mi;
1174
- }
1175
- }
1176
- if (closestMsgIndex < 0) return false;
1177
- // In-bounds: closestMsgIndex was set from a valid `mi` above.
1178
- const msg = messages[closestMsgIndex]!;
1179
- return msg.from === msg.to;
1180
- };
1181
-
1182
- // Extra gap below self-call loop before note starts
1183
- const SELF_CALL_NOTE_GAP = ctx.structural(8);
1184
- const noteOffsetBelow = (note: SequenceNote): number =>
1185
- isNoteAfterSelfCall(note)
1186
- ? sSelfCallHeight + NOTE_OFFSET_BELOW + SELF_CALL_NOTE_GAP
1187
- : NOTE_OFFSET_BELOW;
1188
-
1189
- // Find the first visible message index in an element subtree.
1190
- // Use lineNumber lookup instead of indexOf — collapse projection creates
1191
- // separate spread copies for messages[] and elements[], breaking reference equality.
1192
- const msgLineToIdx = new Map<number, number>();
1193
- messages.forEach((m, i) => msgLineToIdx.set(m.lineNumber, i));
1194
-
1195
- const findFirstMsgIndex = (els: readonly SequenceElement[]): number => {
1196
- for (const el of els) {
1197
- if (isSequenceBlock(el)) {
1198
- const idx = findFirstMsgIndex(el.children);
1199
- if (idx >= 0) return idx;
1200
- if (el.elseIfBranches) {
1201
- for (const branch of el.elseIfBranches) {
1202
- const branchIdx = findFirstMsgIndex(branch.children);
1203
- if (branchIdx >= 0) return branchIdx;
1204
- }
1205
- }
1206
- const elseIdx = findFirstMsgIndex(el.elseChildren);
1207
- if (elseIdx >= 0) return elseIdx;
1208
- } else if (!isSequenceSection(el) && !isSequenceNote(el)) {
1209
- const idx = msgLineToIdx.get(el.lineNumber) ?? -1;
1210
- if (idx >= 0 && !hiddenMsgIndices.has(idx)) return idx;
1211
- }
1212
- }
1213
- return -1;
1214
- };
1215
-
1216
- // Section layout constants
1217
- const SECTION_TOP_PAD = 35;
1218
- const SECTION_BOTTOM_PAD = 45;
1219
-
1220
- // Block spacing via extraBeforeMsg (sections handled separately below)
1221
- const extraBeforeMsg = new Map<number, number>();
1222
- const addExtra = (msgIdx: number, amount: number) => {
1223
- extraBeforeMsg.set(msgIdx, (extraBeforeMsg.get(msgIdx) || 0) + amount);
1224
- };
1225
-
1226
- const markBlockSpacing = (els: readonly SequenceElement[]): void => {
1227
- for (let i = 0; i < els.length; i++) {
1228
- // In-bounds by loop guard.
1229
- const el = els[i]!;
1230
- if (isSequenceSection(el)) continue; // sections handled separately
1231
- if (!isSequenceBlock(el)) continue;
1232
-
1233
- const firstIdx = findFirstMsgIndex(el.children);
1234
- if (firstIdx >= 0) addExtra(firstIdx, BLOCK_HEADER_SPACE);
1235
-
1236
- if (el.elseIfBranches) {
1237
- for (const branch of el.elseIfBranches) {
1238
- const firstBranchIdx = findFirstMsgIndex(branch.children);
1239
- if (firstBranchIdx >= 0) addExtra(firstBranchIdx, BLOCK_HEADER_SPACE);
1240
- markBlockSpacing(branch.children);
1241
- }
1242
- }
1243
-
1244
- const firstElseIdx = findFirstMsgIndex(el.elseChildren);
1245
- if (firstElseIdx >= 0) addExtra(firstElseIdx, BLOCK_HEADER_SPACE);
1246
-
1247
- markBlockSpacing(el.children);
1248
- markBlockSpacing(el.elseChildren);
1249
-
1250
- if (i + 1 < els.length) {
1251
- // In-bounds: i + 1 < els.length guard.
1252
- const nextIdx = findFirstMsgIndex([els[i + 1]!]);
1253
- if (nextIdx >= 0) addExtra(nextIdx, BLOCK_AFTER_SPACE);
1254
- }
1255
- }
1256
- };
1257
-
1258
- if (elements && elements.length > 0) {
1259
- markBlockSpacing(elements);
1260
- }
1261
-
1262
- // Note spacing — add vertical room after messages that have notes attached
1263
- const NOTE_OFFSET_BELOW = ctx.structural(14);
1264
- // The next message label extends ~17px above its arrow line (8px offset + 9px cap height).
1265
- // When notes share horizontal space with subsequent arrows, generous vertical clearance
1266
- // is needed so note boxes don't visually cover message labels.
1267
- const NOTE_TRAILING_GAP = ctx.aesthetic(35);
1268
- const computeNoteHeight = (
1269
- text: string,
1270
- textWidth: number = sNoteTextWidthMax
1271
- ): number => {
1272
- const lines = wrapTextLines(text, textWidth, sNoteFontSize);
1273
- return lines.length * sNoteLineH + sNotePadV * 2;
1274
- };
1275
- let trailingNoteSpace = 0; // extra space for notes at the end with no following message
1276
- const markNoteSpacing = (els: readonly SequenceElement[]): void => {
1277
- for (let i = 0; i < els.length; i++) {
1278
- // In-bounds by loop guard.
1279
- const el = els[i]!;
1280
- if (isSequenceNote(el)) {
1281
- // Total vertical extent of notes from the message arrow:
1282
- // offset (gap above first note — larger after self-calls)
1283
- // + each note's height + NOTE_OFFSET_BELOW (inter-note gap)
1284
- // + NOTE_TRAILING_GAP (gap below last note — clears next message label)
1285
- const firstOffset = noteOffsetBelow(el as SequenceNote);
1286
- let totalExtent = firstOffset;
1287
- let j = i;
1288
- // In-bounds: j < els.length guard inside the while.
1289
- while (j < els.length && isSequenceNote(els[j]!)) {
1290
- // In-bounds: same guard as while.
1291
- const note = els[j]! as SequenceNote;
1292
- const sc = isNoteAfterSelfCall(note);
1293
- const maxW = noteEffectiveMaxW(note.participantId, note.position, sc);
1294
- const noteH = computeNoteHeight(note.text, noteTextWidth(maxW));
1295
- totalExtent += noteH + NOTE_OFFSET_BELOW;
1296
- j++;
1297
- }
1298
- // Replace the final inter-note gap with a proportional trailing gap
1299
- // so tall/stacked notes get extra clearance while short notes keep baseline
1300
- const trailingGap = Math.max(NOTE_TRAILING_GAP, totalExtent * 0.3);
1301
- totalExtent += trailingGap - NOTE_OFFSET_BELOW;
1302
- // Only reserve space beyond the existing stepSpacing gap
1303
- let extraNeeded = Math.max(0, totalExtent - stepSpacing);
1304
- // Scan forward past sections, blocks, and other non-message elements to find next message
1305
- let nextMsgIdx = -1;
1306
- for (let k = j; k < els.length; k++) {
1307
- // In-bounds by loop guard.
1308
- nextMsgIdx = findFirstMsgIndex([els[k]!]);
1309
- if (nextMsgIdx >= 0) break;
1310
- }
1311
- // If a block follows, its frame extends FRAME_PADDING_TOP above the first
1312
- // message but only BLOCK_HEADER_SPACE is reserved. Add the difference so
1313
- // the note doesn't overlap the frame.
1314
- // In-bounds: j < els.length guard.
1315
- if (j < els.length && isSequenceBlock(els[j]!)) {
1316
- extraNeeded += FRAME_PADDING_TOP - BLOCK_HEADER_SPACE;
1317
- }
1318
- if (nextMsgIdx >= 0) {
1319
- addExtra(nextMsgIdx, extraNeeded);
1320
- } else {
1321
- // Notes at the end — reserve only the excess beyond stepSpacing
1322
- trailingNoteSpace = Math.max(trailingNoteSpace, extraNeeded);
1323
- }
1324
- // Skip over the consecutive notes we just processed
1325
- i = j - 1;
1326
- } else if (isSequenceBlock(el)) {
1327
- markNoteSpacing(el.children);
1328
- if (el.elseIfBranches) {
1329
- for (const branch of el.elseIfBranches) {
1330
- markNoteSpacing(branch.children);
1331
- }
1332
- }
1333
- markNoteSpacing(el.elseChildren);
1334
- }
1335
- }
1336
- };
1337
- if (elements && elements.length > 0) {
1338
- markNoteSpacing(elements);
1339
- }
1340
-
1341
- // --- Section-aware Y layout ---
1342
- // Sections get their own Y positions computed from content above them (not anchored
1343
- // to messages below). This ensures toggling collapse/expand doesn't move the divider.
1344
-
1345
- // Walk top-level elements to build section regions
1346
- interface SectionRegion {
1347
- section: import('./parser').SequenceSection;
1348
- msgIndices: number[]; // message indices belonging to this section
1349
- }
1350
- const preSectionMsgIndices: number[] = [];
1351
- const sectionRegions: SectionRegion[] = [];
1352
- {
1353
- // Build lineNumber → message index lookup. This is used instead of
1354
- // messages.indexOf() because collapse projection creates spread copies
1355
- // of messages, breaking reference equality.
1356
- const msgLineToIndex = new Map<number, number>();
1357
- messages.forEach((m, i) => msgLineToIndex.set(m.lineNumber, i));
1358
-
1359
- const findMsgIndex = (child: SequenceElement): number =>
1360
- msgLineToIndex.get(child.lineNumber) ?? -1;
1361
-
1362
- const collectMsgIndicesFromBlock = (
1363
- block: import('./parser').SequenceBlock
1364
- ): number[] => {
1365
- const indices: number[] = [];
1366
- for (const child of block.children) {
1367
- if (isSequenceBlock(child)) {
1368
- indices.push(...collectMsgIndicesFromBlock(child));
1369
- } else if (!isSequenceSection(child) && !isSequenceNote(child)) {
1370
- const idx = findMsgIndex(child);
1371
- if (idx >= 0) indices.push(idx);
1372
- }
1373
- }
1374
- if (block.elseIfBranches) {
1375
- for (const branch of block.elseIfBranches) {
1376
- for (const child of branch.children) {
1377
- if (isSequenceBlock(child)) {
1378
- indices.push(...collectMsgIndicesFromBlock(child));
1379
- } else if (!isSequenceSection(child) && !isSequenceNote(child)) {
1380
- const idx = findMsgIndex(child);
1381
- if (idx >= 0) indices.push(idx);
1382
- }
1383
- }
1384
- }
1385
- }
1386
- for (const child of block.elseChildren) {
1387
- if (isSequenceBlock(child)) {
1388
- indices.push(...collectMsgIndicesFromBlock(child));
1389
- } else if (!isSequenceSection(child) && !isSequenceNote(child)) {
1390
- const idx = findMsgIndex(child);
1391
- if (idx >= 0) indices.push(idx);
1392
- }
1393
- }
1394
- return indices;
1395
- };
1396
-
1397
- let currentTarget = preSectionMsgIndices;
1398
- for (const el of elements) {
1399
- if (isSequenceSection(el)) {
1400
- const region: SectionRegion = { section: el, msgIndices: [] };
1401
- sectionRegions.push(region);
1402
- currentTarget = region.msgIndices;
1403
- } else if (isSequenceBlock(el)) {
1404
- currentTarget.push(...collectMsgIndicesFromBlock(el));
1405
- } else {
1406
- const idx = findMsgIndex(el);
1407
- if (idx >= 0) currentTarget.push(idx);
1408
- }
1409
- }
1410
- }
1411
-
1412
- // Build mapping from original (all) render step index → filtered step index
1413
- const allMsgToFirstStep = new Map<number, number>();
1414
- allRenderSteps.forEach((step, si) => {
1415
- if (!allMsgToFirstStep.has(step.messageIndex)) {
1416
- allMsgToFirstStep.set(step.messageIndex, si);
1417
- }
1418
- });
1419
-
1420
- const originalToFiltered = new Map<number, number>();
1421
- {
1422
- let fi = 0;
1423
- for (let oi = 0; oi < allRenderSteps.length; oi++) {
1424
- // In-bounds by loop guard.
1425
- const step = allRenderSteps[oi]!;
1426
- if (
1427
- !hiddenMsgIndices.has(step.messageIndex) &&
1428
- (step.type === 'call' || step.label)
1429
- ) {
1430
- originalToFiltered.set(oi, fi);
1431
- fi++;
1432
- }
1433
- }
1434
- }
1435
-
1436
- // For each section, find the filtered step index where its padding should be inserted
1437
- const findFilteredInsertionPoint = (origStep: number): number | null => {
1438
- for (let i = origStep; i < allRenderSteps.length; i++) {
1439
- const fi = originalToFiltered.get(i);
1440
- if (fi !== undefined) return fi;
1441
- }
1442
- return null;
1443
- };
1444
-
1445
- // Map: filtered step index → sections to insert before it (in document order)
1446
- const sectionsBeforeStep = new Map<
1447
- number,
1448
- import('./parser').SequenceSection[]
1449
- >();
1450
- const trailingSections: import('./parser').SequenceSection[] = [];
1451
-
1452
- for (const region of sectionRegions) {
1453
- if (region.msgIndices.length === 0) {
1454
- trailingSections.push(region.section);
1455
- continue;
1456
- }
1457
- // In-bounds: msgIndices.length === 0 guard above.
1458
- const firstMsgIdx = region.msgIndices[0]!;
1459
- const origStep = allMsgToFirstStep.get(firstMsgIdx);
1460
- if (origStep === undefined) {
1461
- trailingSections.push(region.section);
1462
- continue;
1463
- }
1464
- const filteredStep = findFilteredInsertionPoint(origStep);
1465
- if (filteredStep === null) {
1466
- trailingSections.push(region.section);
1467
- continue;
1468
- }
1469
- const existing = sectionsBeforeStep.get(filteredStep) || [];
1470
- existing.push(region.section);
1471
- sectionsBeforeStep.set(filteredStep, existing);
1472
- }
1473
-
1474
- // Section message counts for collapsed labels
1475
- const sectionMsgCounts = new Map<number, number>();
1476
- for (const region of sectionRegions) {
1477
- sectionMsgCounts.set(region.section.lineNumber, region.msgIndices.length);
1478
- }
1479
-
1480
- // Group box layout constants (needed early for Y offset)
1481
- const GROUP_PADDING_X = 15;
1482
- const GROUP_PADDING_TOP = 22;
1483
- const GROUP_PADDING_BOTTOM = 8;
1484
- const GROUP_LABEL_SIZE = 11;
1485
-
1486
- // Compute cumulative Y positions for each step, with section dividers as stable anchors
1487
- const showTitle = !!title && parsedOptions['no-title'] !== 'on';
1488
- const titleOffset = showTitle ? sTitleHeight : 0;
1489
- const LEGEND_FIXED_GAP = 8;
1490
- const resolvedGroups = parsed.tagGroups
1491
- .filter((tg) => tg.entries.length > 0)
1492
- .map((tg) => ({
1493
- name: tg.name,
1494
- entries: tg.entries.map((e) => ({ value: e.value, color: e.color })),
1495
- }));
1496
- const legendConfig: LegendConfig = {
1497
- groups: resolvedGroups,
1498
- position: { placement: 'top-center', titleRelation: 'below-title' },
1499
- mode: 'preview',
1500
- };
1501
- const legendTopSpace =
1502
- parsed.tagGroups.length > 0
1503
- ? getMaxLegendReservedHeight(legendConfig, containerWidth) +
1504
- LEGEND_FIXED_GAP
1505
- : 0;
1506
- // Use parsed.groups (not projected groups) to keep vertical space consistent
1507
- // even when all groups are collapsed into virtual participants
1508
- const groupOffset =
1509
- parsed.groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
1510
- const participantStartY =
1511
- sTopMargin +
1512
- titleOffset +
1513
- legendTopSpace +
1514
- sParticipantYOffset +
1515
- groupOffset;
1516
- const lifelineStartY0 = participantStartY + sBoxH;
1517
- const hasActors = participants.some((p) => p.type === 'actor');
1518
- const messageStartOffset =
1519
- sMsgStartOffset +
1520
- (hasActors ? 20 : 0) +
1521
- (parsed.groups.length > 0 ? GROUP_PADDING_BOTTOM : 0);
1522
- const stepYPositions: number[] = [];
1523
- const sectionYPositions = new Map<number, number>(); // section lineNumber → Y
1524
- let layoutEndY: number; // final Y after all steps and trailing sections
1525
- {
1526
- let curY = lifelineStartY0 + messageStartOffset;
1527
- for (let i = 0; i < renderSteps.length; i++) {
1528
- // Insert section padding before this step if needed
1529
- const sections = sectionsBeforeStep.get(i);
1530
- if (sections) {
1531
- for (const sec of sections) {
1532
- curY += SECTION_TOP_PAD;
1533
- sectionYPositions.set(sec.lineNumber, curY);
1534
- curY += SECTION_BOTTOM_PAD;
1535
- }
1536
- }
1537
-
1538
- // In-bounds by loop guard.
1539
- const step = renderSteps[i]!;
1540
- // Add extra spacing before the first render step of a flagged message (block spacing)
1541
- if (msgToFirstStep.get(step.messageIndex) === i) {
1542
- const extra = extraBeforeMsg.get(step.messageIndex) || 0;
1543
- curY += extra;
1544
- }
1545
- stepYPositions.push(curY);
1546
- const isSelfCall = step.type === 'call' && step.from === step.to;
1547
- curY += isSelfCall ? sSelfCallHeight + 25 : stepSpacing;
1548
- }
1549
- // Handle trailing sections (after all steps)
1550
- for (const sec of trailingSections) {
1551
- curY += SECTION_TOP_PAD;
1552
- sectionYPositions.set(sec.lineNumber, curY);
1553
- curY += SECTION_BOTTOM_PAD;
1554
- }
1555
- // Extend for trailing notes that have no following message
1556
- curY += trailingNoteSpace;
1557
- layoutEndY = curY;
1558
- }
1559
-
1560
- // Helper: compute Y for a step index
1561
- // Callers always pass a valid filtered step index from renderSteps (which is
1562
- // 1:1 with stepYPositions by construction in the layout loop above).
1563
- const stepY = (i: number) => stepYPositions[i]!;
1564
-
1565
- // Compute absolute Y positions for each note element
1566
- const noteYMap = new Map<SequenceNote, number>();
1567
- {
1568
- const computeNotePositions = (els: readonly SequenceElement[]): void => {
1569
- for (let i = 0; i < els.length; i++) {
1570
- // In-bounds by loop guard.
1571
- const el = els[i]!;
1572
- if (isSequenceNote(el)) {
1573
- const si = findAssociatedLastStep(el);
1574
- if (si < 0) continue;
1575
- // Check if there's a preceding note that we should stack below
1576
- // In-bounds: i > 0 guard.
1577
- const prevNote =
1578
- i > 0 && isSequenceNote(els[i - 1]!)
1579
- ? (els[i - 1]! as SequenceNote)
1580
- : null;
1581
- const prevNoteY = prevNote ? noteYMap.get(prevNote) : undefined;
1582
- let noteTopY: number;
1583
- if (prevNoteY !== undefined && prevNote) {
1584
- // Stack below previous note
1585
- const prevMaxW = noteEffectiveMaxW(
1586
- prevNote.participantId,
1587
- effectiveNotePosition(prevNote),
1588
- isNoteAfterSelfCall(prevNote)
1589
- );
1590
- const prevNoteH = computeNoteHeight(
1591
- prevNote.text,
1592
- noteTextWidth(prevMaxW)
1593
- );
1594
- noteTopY = prevNoteY + prevNoteH + NOTE_OFFSET_BELOW;
1595
- } else {
1596
- // First note after a message — use larger offset after self-calls
1597
- noteTopY = stepY(si) + noteOffsetBelow(el);
1598
- }
1599
- noteYMap.set(el, noteTopY);
1600
- } else if (isSequenceBlock(el)) {
1601
- computeNotePositions(el.children);
1602
- if (el.elseIfBranches) {
1603
- for (const branch of el.elseIfBranches) {
1604
- computeNotePositions(branch.children);
1605
- }
1606
- }
1607
- computeNotePositions(el.elseChildren);
1608
- }
1609
- }
1610
- };
1611
- if (elements && elements.length > 0) {
1612
- computeNotePositions(elements);
1613
- }
1614
- }
1615
-
1616
- // Ensure contentBottomY accounts for all note extents
1617
- const lastStep = renderSteps[renderSteps.length - 1];
1618
- const lastIsSelfCall =
1619
- lastStep?.type === 'call' && lastStep.from === lastStep.to;
1620
- const lastStepTrailing = lastIsSelfCall ? sSelfCallHeight + 25 : stepSpacing;
1621
- let contentBottomY =
1622
- renderSteps.length > 0
1623
- ? Math.max(
1624
- // In-bounds: renderSteps.length > 0 guarantees stepYPositions is
1625
- // non-empty (1:1 with renderSteps in the layout loop above).
1626
- stepYPositions[stepYPositions.length - 1]! + lastStepTrailing,
1627
- layoutEndY
1628
- )
1629
- : layoutEndY;
1630
- for (const [note, noteTopY] of noteYMap) {
1631
- const maxW = noteEffectiveMaxW(
1632
- note.participantId,
1633
- effectiveNotePosition(note),
1634
- isNoteAfterSelfCall(note)
1635
- );
1636
- const noteH = computeNoteHeight(note.text, noteTextWidth(maxW));
1637
- contentBottomY = Math.max(
1638
- contentBottomY,
1639
- noteTopY + noteH + NOTE_TRAILING_GAP
1640
- );
1641
- }
1642
- const messageAreaHeight = contentBottomY - lifelineStartY0;
1643
- const lifelineLength = messageAreaHeight + sLifelineTail;
1644
- // Redistribute gap: tighter within groups, wider between groups.
1645
- // Total width stays exactly participants.length * sGap — no viewBox change.
1646
- const totalGaps = participants.length > 1 ? participants.length - 1 : 0;
1647
- const numWithinGaps = totalGaps - numGroupGaps;
1648
- let sWithinGap = sGap;
1649
- let sBetweenGap = sGap;
1650
- if (numGroupGaps > 0 && totalGaps > 0) {
1651
- sWithinGap = sGap * 0.88;
1652
- sBetweenGap =
1653
- (totalGaps * sGap - numWithinGaps * sWithinGap) / numGroupGaps;
1654
- }
1655
-
1656
- // Compute right-edge projection: how far content extends past the rightmost lifeline
1657
- const rightmostId = participants[participants.length - 1]?.id;
1658
- let rightProjection = sBoxW / 2;
1659
- if (rightmostId) {
1660
- // Group padding if rightmost participant is in a group
1661
- if (
1662
- parsed.groups.some((g: SequenceGroup) =>
1663
- g.participantIds.includes(rightmostId)
1664
- )
1665
- )
1666
- rightProjection = Math.max(rightProjection, sBoxW / 2 + GROUP_PADDING_X);
1667
- // Self-calls on rightmost: loop + label projection (+ activation nesting buffer)
1668
- for (const step of renderSteps) {
1669
- if (step.from === step.to && step.from === rightmostId) {
1670
- const selfProj = sActivationWidth + sSelfCallWidth;
1671
- let labelProj = 0;
1672
- // Self-call labels render at the fixed 12px message-label size.
1673
- if (step.label) labelProj = measureText(step.label, 12) + 15;
1674
- rightProjection = Math.max(rightProjection, selfProj + labelProj);
1675
- }
1676
- }
1677
- // Block frames spanning rightmost participant (must match FRAME_PADDING_X = 30 in block rendering below)
1678
- const blockFramePadX = 30;
1679
- const hasBlockWithRightmost = (
1680
- els: readonly SequenceElement[]
1681
- ): boolean => {
1682
- for (const el of els) {
1683
- if (isSequenceBlock(el)) {
1684
- const involvesRightmost = (
1685
- children: readonly SequenceElement[]
1686
- ): boolean =>
1687
- children.some(
1688
- (c) =>
1689
- !isSequenceBlock(c) &&
1690
- !isSequenceNote(c) &&
1691
- !isSequenceSection(c) &&
1692
- 'from' in c &&
1693
- (c.from === rightmostId || c.to === rightmostId)
1694
- );
1695
- if (
1696
- involvesRightmost(el.children) ||
1697
- involvesRightmost(el.elseChildren) ||
1698
- el.elseIfBranches?.some((b) => involvesRightmost(b.children))
1699
- )
1700
- return true;
1701
- if (
1702
- hasBlockWithRightmost(el.children) ||
1703
- hasBlockWithRightmost(el.elseChildren) ||
1704
- el.elseIfBranches?.some((b) => hasBlockWithRightmost(b.children))
1705
- )
1706
- return true;
1707
- }
1708
- }
1709
- return false;
1710
- };
1711
- if (elements && hasBlockWithRightmost(elements))
1712
- rightProjection = Math.max(rightProjection, sBoxW / 2 + blockFramePadX);
1713
- }
1714
- let rightMargin = Math.max(rightProjection + 10, sGap / 2);
1715
-
1716
- let leftMargin = sGap / 2;
1717
- const diagramWidth = participants.length * sGap;
1718
- let totalWidth = Math.max(
1719
- leftMargin + diagramWidth + rightMargin,
1720
- sBoxW + 40
1721
- );
1722
- const contentHeight =
1723
- participantStartY + sBoxH + Math.max(lifelineLength, 40) + 40;
1724
- const totalHeight = contentHeight;
1725
-
1726
- let svgWidth = Math.max(totalWidth, containerWidth);
1727
-
1728
- let offsetX = leftMargin + Math.max(0, (svgWidth - totalWidth) / 2);
1729
-
1730
- // Build participant x-position lookup with redistributed gaps
1731
- const participantX = new Map<string, number>();
1732
- const buildParticipantX = (): void => {
1733
- let px = offsetX;
1734
- participantX.clear();
1735
- participants.forEach((p, i) => {
1736
- participantX.set(p.id, px);
1737
- if (i < participants.length - 1) {
1738
- const nextId = participants[i + 1]!.id;
1739
- px += groupBoundaryIds.has(nextId) ? sBetweenGap : sWithinGap;
1740
- }
1741
- });
1742
- };
1743
- buildParticipantX();
1744
-
1745
- // Post-layout content scan: detect labels/notes that overflow the SVG boundaries.
1746
- // Message labels render at a fixed 12px font (unscaled) so they can extend past
1747
- // the scaled participant grid at small scale factors.
1748
- let contentLeft = 0;
1749
- let contentRight = svgWidth;
1750
- for (const step of renderSteps) {
1751
- if (!step.label) continue;
1752
- const labelW = measureText(step.label, 12);
1753
- if (step.from === step.to) {
1754
- const px = participantX.get(step.from);
1755
- if (px !== undefined) {
1756
- const loopRight =
1757
- px + sActivationWidth / 2 + sSelfCallWidth + 5 + labelW;
1758
- contentRight = Math.max(contentRight, loopRight);
1759
- }
1760
- } else {
1761
- const fromX = participantX.get(step.from);
1762
- const toX = participantX.get(step.to);
1763
- if (fromX !== undefined && toX !== undefined) {
1764
- const midX = (fromX + toX) / 2;
1765
- contentLeft = Math.min(contentLeft, midX - labelW / 2);
1766
- contentRight = Math.max(contentRight, midX + labelW / 2);
1767
- }
1768
- }
1769
- }
1770
- // Scan right-positioned notes for overflow past the right edge
1771
- if (elements) {
1772
- const scanNotes = (els: readonly SequenceElement[]): void => {
1773
- for (const el of els) {
1774
- if (isSequenceNote(el)) {
1775
- const note = el as SequenceNote;
1776
- const pos = effectiveNotePosition(note);
1777
- if (pos === 'right') {
1778
- const px = participantX.get(note.participantId);
1779
- if (px !== undefined) {
1780
- const sc = isNoteAfterSelfCall(note);
1781
- const rOff = sc
1782
- ? sActivationWidth / 2 + sSelfCallWidth + sNoteGap
1783
- : sActivationWidth + sNoteGap;
1784
- const maxW = noteEffectiveMaxW(note.participantId, pos, sc);
1785
- contentRight = Math.max(contentRight, px + rOff + maxW);
1786
- }
1787
- }
1788
- } else if (isSequenceBlock(el)) {
1789
- scanNotes(el.children);
1790
- scanNotes(el.elseChildren);
1791
- if (el.elseIfBranches) {
1792
- for (const b of el.elseIfBranches) scanNotes(b.children);
1793
- }
1794
- }
1795
- }
1796
- };
1797
- scanNotes(elements);
1798
- }
1799
- const neededLeftPad = Math.max(0, -contentLeft);
1800
- const neededRightPad = Math.max(0, contentRight - svgWidth);
1801
- if (neededLeftPad > 0 || neededRightPad > 0) {
1802
- leftMargin += neededLeftPad;
1803
- rightMargin += neededRightPad;
1804
- totalWidth = Math.max(leftMargin + diagramWidth + rightMargin, sBoxW + 40);
1805
- svgWidth = Math.max(totalWidth, containerWidth);
1806
- offsetX = leftMargin + Math.max(0, (svgWidth - totalWidth) / 2);
1807
- buildParticipantX();
1808
- }
1809
-
1810
- const svg = d3Selection
1811
- .select(container)
1812
- .append('svg')
1813
- .attr('width', '100%')
1814
- .attr('height', totalHeight)
1815
- .attr('viewBox', `0 0 ${svgWidth} ${totalHeight}`)
1816
- .attr('preserveAspectRatio', 'xMidYMin meet')
1817
- .attr('class', 'sequence-diagram')
1818
- .style('font-family', FONT_FAMILY);
1819
-
1820
- // Define arrowhead markers
1821
- const defs = svg.append('defs');
1822
-
1823
- // Filled arrowhead for call arrows
1824
- defs
1825
- .append('marker')
1826
- .attr('id', 'seq-arrowhead')
1827
- .attr('viewBox', `0 0 ${sArrowheadSize} ${sArrowheadSize}`)
1828
- .attr('refX', sArrowheadSize)
1829
- .attr('refY', sArrowheadSize / 2)
1830
- .attr('markerWidth', sArrowheadSize)
1831
- .attr('markerHeight', sArrowheadSize)
1832
- .attr('orient', 'auto')
1833
- .append('polygon')
1834
- .attr(
1835
- 'points',
1836
- `0,0 ${sArrowheadSize},${sArrowheadSize / 2} 0,${sArrowheadSize}`
1837
- )
1838
- .attr('fill', palette.text);
1839
-
1840
- // Open arrowhead for return arrows
1841
- defs
1842
- .append('marker')
1843
- .attr('id', 'seq-arrowhead-open')
1844
- .attr('viewBox', `0 0 ${sArrowheadSize} ${sArrowheadSize}`)
1845
- .attr('refX', sArrowheadSize)
1846
- .attr('refY', sArrowheadSize / 2)
1847
- .attr('markerWidth', sArrowheadSize)
1848
- .attr('markerHeight', sArrowheadSize)
1849
- .attr('orient', 'auto')
1850
- .append('polyline')
1851
- .attr(
1852
- 'points',
1853
- `0,0 ${sArrowheadSize},${sArrowheadSize / 2} 0,${sArrowheadSize}`
1854
- )
1855
- .attr('fill', 'none')
1856
- .attr('stroke', palette.textMuted)
1857
- .attr('stroke-width', 1.2);
1858
-
1859
- // Open arrowhead for async (fire-and-forget) arrows — same as return but text color
1860
- defs
1861
- .append('marker')
1862
- .attr('id', 'seq-arrowhead-async')
1863
- .attr('viewBox', `0 0 ${sArrowheadSize} ${sArrowheadSize}`)
1864
- .attr('refX', sArrowheadSize)
1865
- .attr('refY', sArrowheadSize / 2)
1866
- .attr('markerWidth', sArrowheadSize)
1867
- .attr('markerHeight', sArrowheadSize)
1868
- .attr('orient', 'auto')
1869
- .append('polyline')
1870
- .attr(
1871
- 'points',
1872
- `0,0 ${sArrowheadSize},${sArrowheadSize / 2} 0,${sArrowheadSize}`
1873
- )
1874
- .attr('fill', 'none')
1875
- .attr('stroke', palette.text)
1876
- .attr('stroke-width', 1.2);
1877
-
1878
- // Per-color arrowhead markers for tag-driven coloring
1879
- const arrowPoints = `0,0 ${sArrowheadSize},${sArrowheadSize / 2} 0,${sArrowheadSize}`;
1880
- for (const [, color] of tagValueToColor) {
1881
- const hex = color.replace('#', '');
1882
- // Filled arrowhead (call arrows)
1883
- defs
1884
- .append('marker')
1885
- .attr('id', `seq-arrowhead-c${hex}`)
1886
- .attr('viewBox', `0 0 ${sArrowheadSize} ${sArrowheadSize}`)
1887
- .attr('refX', sArrowheadSize)
1888
- .attr('refY', sArrowheadSize / 2)
1889
- .attr('markerWidth', sArrowheadSize)
1890
- .attr('markerHeight', sArrowheadSize)
1891
- .attr('orient', 'auto')
1892
- .append('polygon')
1893
- .attr('points', arrowPoints)
1894
- .attr('fill', color);
1895
- // Open arrowhead (async arrows)
1896
- defs
1897
- .append('marker')
1898
- .attr('id', `seq-arrowhead-async-c${hex}`)
1899
- .attr('viewBox', `0 0 ${sArrowheadSize} ${sArrowheadSize}`)
1900
- .attr('refX', sArrowheadSize)
1901
- .attr('refY', sArrowheadSize / 2)
1902
- .attr('markerWidth', sArrowheadSize)
1903
- .attr('markerHeight', sArrowheadSize)
1904
- .attr('orient', 'auto')
1905
- .append('polyline')
1906
- .attr('points', arrowPoints)
1907
- .attr('fill', 'none')
1908
- .attr('stroke', color)
1909
- .attr('stroke-width', 1.2);
1910
- // Open arrowhead (return arrows)
1911
- defs
1912
- .append('marker')
1913
- .attr('id', `seq-arrowhead-open-c${hex}`)
1914
- .attr('viewBox', `0 0 ${sArrowheadSize} ${sArrowheadSize}`)
1915
- .attr('refX', sArrowheadSize)
1916
- .attr('refY', sArrowheadSize / 2)
1917
- .attr('markerWidth', sArrowheadSize)
1918
- .attr('markerHeight', sArrowheadSize)
1919
- .attr('orient', 'auto')
1920
- .append('polyline')
1921
- .attr('points', arrowPoints)
1922
- .attr('fill', 'none')
1923
- .attr('stroke', color)
1924
- .attr('stroke-width', 1.2);
1925
- }
1926
-
1927
- // Helper: resolve marker ref for tag-colored arrows
1928
- const coloredMarker = (
1929
- type: 'call' | 'async' | 'return',
1930
- tagColor?: string
1931
- ): string => {
1932
- if (tagColor) {
1933
- const hex = tagColor.replace('#', '');
1934
- switch (type) {
1935
- case 'call':
1936
- return `url(#seq-arrowhead-c${hex})`;
1937
- case 'async':
1938
- return `url(#seq-arrowhead-async-c${hex})`;
1939
- case 'return':
1940
- return `url(#seq-arrowhead-open-c${hex})`;
1941
- }
1942
- }
1943
- switch (type) {
1944
- case 'call':
1945
- return 'url(#seq-arrowhead)';
1946
- case 'async':
1947
- return 'url(#seq-arrowhead-async)';
1948
- case 'return':
1949
- return 'url(#seq-arrowhead-open)';
1950
- }
1951
- };
1952
-
1953
- // Render title
1954
- if (showTitle) {
1955
- const titleEl = svg
1956
- .append('text')
1957
- .attr('class', 'chart-title')
1958
- .attr('x', svgWidth / 2)
1959
- .attr('y', ctx.structural(TITLE_Y))
1960
- .attr('text-anchor', 'middle')
1961
- .attr('fill', palette.text)
1962
- .attr('font-size', ctx.text(TITLE_FONT_SIZE))
1963
- .attr('font-weight', TITLE_FONT_WEIGHT)
1964
- .text(title);
1965
-
1966
- if (parsed.titleLineNumber) {
1967
- titleEl.attr('data-line-number', parsed.titleLineNumber);
1968
- }
1969
- }
1970
-
1971
- const hasTagGroups = parsed.tagGroups.length > 0;
1972
-
1973
- // Build set of collapsed group names for drill-bar rendering
1974
- const collapsedGroupNames = new Set<string>();
1975
- const collapsedGroupMeta = new Map<
1976
- string,
1977
- { lineNumber: number; metadata?: Record<string, string> }
1978
- >();
1979
- for (const group of parsed.groups) {
1980
- if (effectiveCollapsedGroups.has(group.lineNumber)) {
1981
- collapsedGroupNames.add(group.name);
1982
- collapsedGroupMeta.set(group.name, {
1983
- lineNumber: group.lineNumber,
1984
- ...(group.metadata !== undefined && { metadata: group.metadata }),
1985
- });
1986
- }
1987
- }
1988
-
1989
- // Render group boxes (behind participant shapes) — skip collapsed groups
1990
- for (const group of groups) {
1991
- if (group.participantIds.length === 0) continue;
1992
-
1993
- // Find X bounds from member participant positions
1994
- const memberXs = group.participantIds
1995
- .map((id) => participantX.get(id))
1996
- .filter((x): x is number => x !== undefined);
1997
- if (memberXs.length === 0) continue;
1998
-
1999
- const minX = Math.min(...memberXs) - sBoxW / 2 - GROUP_PADDING_X;
2000
- const maxX = Math.max(...memberXs) + sBoxW / 2 + GROUP_PADDING_X;
2001
- const boxY = participantStartY - GROUP_PADDING_TOP;
2002
- const boxH = sBoxH + GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM;
2003
-
2004
- // Group box background — use tag color if group has metadata for the active tag group.
2005
- // Intentionally 15-20% (not the canonical 25% shapeFill): group boxes are
2006
- // CONTAINERS that wrap multiple participants, so they should recede behind
2007
- // the tinted participant shapes inside them. Promoting to 25% would make
2008
- // the wrapper compete with its contents.
2009
- const groupTagValue = tagKey && group.metadata?.[tagKey];
2010
- const groupTagColor = getTagColor(groupTagValue || undefined);
2011
- const fillColor = groupTagColor
2012
- ? mix(groupTagColor, themeBaseBg(palette, isDark), isDark ? 15 : 20)
2013
- : isDark
2014
- ? palette.surface
2015
- : palette.bg;
2016
- const strokeColor = groupTagColor || palette.textMuted;
2017
-
2018
- const groupG = svg
2019
- .append('g')
2020
- .attr('class', 'group-box-wrapper')
2021
- .attr('data-group-toggle', '')
2022
- .attr('data-group-line', String(group.lineNumber))
2023
- .attr('tabindex', '0')
2024
- .attr('role', 'button')
2025
- .attr('aria-expanded', 'true')
2026
- .attr('cursor', 'pointer');
2027
- groupG.append('title').text('Click to collapse');
2028
-
2029
- // Visual group frame — pointer-events:none so it never intercepts clicks.
2030
- // The box is rendered behind the participant shapes, so the only reliable
2031
- // toggle target is the header strip above the participants (hit area below).
2032
- groupG
2033
- .append('rect')
2034
- .attr('x', minX)
2035
- .attr('y', boxY)
2036
- .attr('width', maxX - minX)
2037
- .attr('height', boxH)
2038
- .attr('rx', 6)
2039
- .attr('fill', fillColor)
2040
- .attr('stroke', strokeColor)
2041
- .attr('stroke-width', 1)
2042
- .attr('stroke-opacity', 0.5)
2043
- .attr('pointer-events', 'none')
2044
- .attr('class', 'group-box');
2045
-
2046
- // Transparent hit area over the header strip (above the participant boxes,
2047
- // so it is never occluded). Gives the toggle a generous, discoverable
2048
- // click target — mirrors the section-label-hit pattern.
2049
- groupG
2050
- .append('rect')
2051
- .attr('x', minX)
2052
- .attr('y', boxY)
2053
- .attr('width', maxX - minX)
2054
- .attr('height', participantStartY - boxY)
2055
- .attr('fill', 'transparent')
2056
- .attr('class', 'group-label-hit');
2057
-
2058
- // Group label — centered across the header strip.
2059
- groupG
2060
- .append('text')
2061
- .attr('x', (minX + maxX) / 2)
2062
- .attr('y', boxY + GROUP_LABEL_SIZE + 4)
2063
- .attr('text-anchor', 'middle')
2064
- .attr('fill', strokeColor)
2065
- .attr('font-size', GROUP_LABEL_SIZE)
2066
- .attr('font-weight', 'bold')
2067
- .attr('opacity', 0.7)
2068
- .attr('pointer-events', 'none')
2069
- .attr('class', 'group-label')
2070
- .text(group.name);
2071
- }
2072
-
2073
- // Render each participant
2074
- const lifelineStartY = lifelineStartY0;
2075
- participants.forEach((participant) => {
2076
- const cx = participantX.get(participant.id)!;
2077
- const cy = participantStartY;
2078
-
2079
- const pTagValue = tagMap?.participants.get(participant.id);
2080
- const pTagColor = getTagColor(pTagValue);
2081
- const pTagAttr =
2082
- tagKey && pTagValue
2083
- ? { key: tagKey, value: pTagValue.toLowerCase() }
2084
- : undefined;
2085
- // For collapsed group participants, resolve tag color from group metadata
2086
- const isCollapsedGroup = collapsedGroupNames.has(participant.id);
2087
- let effectiveTagColor = pTagColor;
2088
- if (isCollapsedGroup && !effectiveTagColor) {
2089
- const meta = collapsedGroupMeta.get(participant.id);
2090
- if (meta?.metadata && tagKey) {
2091
- effectiveTagColor = getTagColor(meta.metadata[tagKey]);
2092
- }
2093
- }
2094
-
2095
- renderParticipant(
2096
- svg,
2097
- participant,
2098
- cx,
2099
- cy,
2100
- palette,
2101
- isDark,
2102
- effectiveTagColor,
2103
- pTagAttr,
2104
- solid,
2105
- sBoxW,
2106
- sBoxH,
2107
- sLabelTextWidth,
2108
- sLabelFontSize
2109
- );
2110
-
2111
- // Collapsed group: re-render participant box at full group height + drill-bar
2112
- if (isCollapsedGroup) {
2113
- const meta = collapsedGroupMeta.get(participant.id)!;
2114
- const drillColor = effectiveTagColor || palette.textMuted;
2115
- const drillBarH = 6;
2116
- const boxW = sBoxW;
2117
- // Match the group box dimensions
2118
- const fullH = sBoxH + GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM;
2119
- const clipId = `clip-drill-group-${participant.id.replace(/[^a-zA-Z0-9-]/g, '-')}`;
2120
-
2121
- // Add toggle attributes to the participant <g> so any click on it
2122
- // (overlay rect, label, drill-bar) walks up and triggers the toggle
2123
- const participantG = svg.select<SVGGElement>(
2124
- `.participant[data-participant-id="${participant.id}"]`
2125
- );
2126
- participantG
2127
- .attr('data-group-toggle', '')
2128
- .attr('data-group-line', String(meta.lineNumber))
2129
- .attr('tabindex', '0')
2130
- .attr('role', 'button')
2131
- .attr('aria-expanded', 'false')
2132
- .attr('cursor', 'pointer');
2133
- participantG.append('title').text('Click to expand');
2134
-
2135
- // Overlay a taller rect to replace the standard participant box
2136
- const pFill = effectiveTagColor
2137
- ? mix(effectiveTagColor, themeBaseBg(palette, isDark), isDark ? 30 : 40)
2138
- : isDark
2139
- ? mix(palette.overlay, palette.surface, 50)
2140
- : mix(palette.bg, palette.surface, 50);
2141
- const pStroke = effectiveTagColor || palette.border;
2142
-
2143
- // Taller box inside the participant <g> (local coords, y=0 is participant cy)
2144
- participantG
2145
- .append('rect')
2146
- .attr('x', -boxW / 2)
2147
- .attr('y', -GROUP_PADDING_TOP)
2148
- .attr('width', boxW)
2149
- .attr('height', fullH)
2150
- .attr('rx', 6)
2151
- .attr('fill', pFill)
2152
- .attr('stroke', pStroke)
2153
- .attr('stroke-width', 1.5);
2154
-
2155
- // Re-render label centered in the taller box (local coords)
2156
- participantG
2157
- .append('text')
2158
- .attr('x', 0)
2159
- .attr('y', -GROUP_PADDING_TOP + fullH / 2)
2160
- .attr('text-anchor', 'middle')
2161
- .attr('dominant-baseline', 'central')
2162
- .attr('fill', palette.text)
2163
- .attr('font-size', ctx.text(13))
2164
- .attr('font-weight', 500)
2165
- .text(participant.label);
2166
-
2167
- // Drill-bar at bottom (local coords)
2168
- participantG
2169
- .append('clipPath')
2170
- .attr('id', clipId)
2171
- .append('rect')
2172
- .attr('x', -boxW / 2)
2173
- .attr('y', -GROUP_PADDING_TOP)
2174
- .attr('width', boxW)
2175
- .attr('height', fullH)
2176
- .attr('rx', 6);
2177
-
2178
- participantG
2179
- .append('rect')
2180
- .attr('class', 'sequence-drill-bar')
2181
- .attr('x', -boxW / 2)
2182
- .attr('y', -GROUP_PADDING_TOP + fullH - drillBarH)
2183
- .attr('width', boxW)
2184
- .attr('height', drillBarH)
2185
- .attr('fill', drillColor)
2186
- .attr('clip-path', `url(#${clipId})`);
2187
- }
2188
-
2189
- // Render lifeline — collapsed groups start below the taller box; actors
2190
- // carry their label *below* the stick figure (at boxH + 14), so their
2191
- // lifeline must start below that label or the dashes run through the text.
2192
- const llY = isCollapsedGroup
2193
- ? lifelineStartY + GROUP_PADDING_BOTTOM
2194
- : participant.type === 'actor'
2195
- ? lifelineStartY + ACTOR_LABEL_CLEARANCE
2196
- : lifelineStartY;
2197
- const llColor = isCollapsedGroup
2198
- ? effectiveTagColor || palette.textMuted
2199
- : pTagColor || palette.textMuted;
2200
- const lifelineEl = svg
2201
- .append('line')
2202
- .attr('x1', cx)
2203
- .attr('y1', llY)
2204
- .attr('x2', cx)
2205
- .attr('y2', lifelineStartY + lifelineLength)
2206
- .attr('stroke', llColor)
2207
- .attr('stroke-width', 1)
2208
- .attr('stroke-dasharray', '6 4')
2209
- .attr('class', 'lifeline')
2210
- .attr('data-participant-id', participant.id);
2211
- if (tagKey && pTagValue) {
2212
- lifelineEl.attr(`data-tag-${tagKey}`, pTagValue.toLowerCase());
2213
- }
2214
- });
2215
-
2216
- // Render block frames (behind everything else)
2217
- const FRAME_PADDING_X = 30;
2218
- // FRAME_PADDING_TOP declared earlier (near BLOCK_HEADER_SPACE)
2219
- const FRAME_PADDING_BOTTOM = 15;
2220
- const FRAME_LABEL_HEIGHT = 18;
2221
- // Self-loop projects ACTIVATION_WIDTH/2 + SELF_CALL_WIDTH (=35) past the
2222
- // lifeline; FRAME_PADDING_X (=30) leaves no breathing room. When a block
2223
- // contains a self-arrow, extend the frame on the loop's side so the loop
2224
- // sits comfortably inside.
2225
- const SELF_ARROW_PROJECTION = sActivationWidth / 2 + sSelfCallWidth;
2226
- const SELF_ARROW_FRAME_PAD = 10;
2227
- const frameRightmostX = Math.max(...Array.from(participantX.values()));
2228
-
2229
- // Collect message indices from an element subtree
2230
- const collectMsgIndices = (els: readonly SequenceElement[]): number[] => {
2231
- const indices: number[] = [];
2232
- for (const el of els) {
2233
- if (isSequenceBlock(el)) {
2234
- indices.push(
2235
- ...collectMsgIndices(el.children),
2236
- ...collectMsgIndices(el.elseChildren)
2237
- );
2238
- if (el.elseIfBranches) {
2239
- for (const branch of el.elseIfBranches) {
2240
- indices.push(...collectMsgIndices(branch.children));
2241
- }
2242
- }
2243
- } else if (!isSequenceSection(el) && !isSequenceNote(el)) {
2244
- // Narrowed to SequenceMessage by the discriminator (kind === 'message')
2245
- const idx = messages.indexOf(el);
2246
- if (idx >= 0) indices.push(idx);
2247
- }
2248
- }
2249
- return indices;
2250
- };
2251
-
2252
- // Collect deferred draws (rendered after activations so they appear on top)
2253
- const deferredLabels: Array<{
2254
- x: number;
2255
- y: number;
2256
- text: string;
2257
- bold: boolean;
2258
- italic: boolean;
2259
- blockLine?: number;
2260
- }> = [];
2261
- const deferredLines: Array<{
2262
- x1: number;
2263
- y1: number;
2264
- x2: number;
2265
- y2: number;
2266
- blockLine?: number;
2267
- }> = [];
2268
-
2269
- // Recursive block renderer — draws borders/dividers now, defers label text
2270
- const renderBlockFrames = (
2271
- els: readonly SequenceElement[],
2272
- depth: number
2273
- ): void => {
2274
- for (const el of els) {
2275
- if (!isSequenceBlock(el)) continue;
2276
-
2277
- const ifIndices = collectMsgIndices(el.children);
2278
- const elseIfBranchData: {
2279
- label: string;
2280
- indices: number[];
2281
- lineNumber: number;
2282
- }[] = [];
2283
- if (el.elseIfBranches) {
2284
- for (const branch of el.elseIfBranches) {
2285
- elseIfBranchData.push({
2286
- label: branch.label,
2287
- indices: collectMsgIndices(branch.children),
2288
- lineNumber: branch.lineNumber,
2289
- });
2290
- }
2291
- }
2292
- const elseIndices = collectMsgIndices(el.elseChildren);
2293
- const allIndices = [
2294
- ...ifIndices,
2295
- ...elseIfBranchData.flatMap((b) => b.indices),
2296
- ...elseIndices,
2297
- ];
2298
- if (allIndices.length === 0) continue;
2299
-
2300
- // Find render step range
2301
- let minStep = Infinity;
2302
- let maxStep = -Infinity;
2303
- for (const mi of allIndices) {
2304
- const first = msgToFirstStep.get(mi);
2305
- const last = msgToLastStep.get(mi);
2306
- if (first !== undefined) minStep = Math.min(minStep, first);
2307
- if (last !== undefined) maxStep = Math.max(maxStep, last);
2308
- }
2309
- if (minStep === Infinity) continue;
2310
-
2311
- // Find participant X range
2312
- const involved = new Set<string>();
2313
- for (const mi of allIndices) {
2314
- // In-bounds: allIndices is built from valid message indices via
2315
- // collectMsgIndices (only pushes `idx >= 0` from messages.indexOf).
2316
- const m = messages[mi]!;
2317
- involved.add(m.from);
2318
- involved.add(m.to);
2319
- }
2320
- let minPX = Infinity;
2321
- let maxPX = -Infinity;
2322
- for (const pid of involved) {
2323
- const px = participantX.get(pid);
2324
- if (px !== undefined) {
2325
- minPX = Math.min(minPX, px);
2326
- maxPX = Math.max(maxPX, px);
2327
- }
2328
- }
2329
-
2330
- // Self-arrow geometry: extend frame on the loop's side so loops sit
2331
- // comfortably inside, and extend vertically if the block's last step
2332
- // is a self-call (whose loop drops SELF_CALL_HEIGHT below stepY).
2333
- let extraRight = 0;
2334
- let maxStepIsSelfCall = false;
2335
- for (const mi of allIndices) {
2336
- // In-bounds: same guarantee as above (allIndices built from valid indices).
2337
- const m = messages[mi]!;
2338
- if (m.from === m.to) {
2339
- const px = participantX.get(m.from);
2340
- if (px !== undefined) {
2341
- const loopMax = px + SELF_ARROW_PROJECTION;
2342
- const need =
2343
- loopMax - (maxPX + FRAME_PADDING_X) + SELF_ARROW_FRAME_PAD;
2344
- if (need > 0) extraRight = Math.max(extraRight, need);
2345
- }
2346
- if (msgToLastStep.get(mi) === maxStep) {
2347
- maxStepIsSelfCall = true;
2348
- }
2349
- }
2350
- }
2351
-
2352
- const frameX = minPX - FRAME_PADDING_X;
2353
- const frameY = stepY(minStep) - FRAME_PADDING_TOP;
2354
- const frameW = maxPX - minPX + FRAME_PADDING_X * 2 + extraRight;
2355
- const frameH =
2356
- stepY(maxStep) -
2357
- stepY(minStep) +
2358
- FRAME_PADDING_TOP +
2359
- FRAME_PADDING_BOTTOM +
2360
- (maxStepIsSelfCall ? sSelfCallHeight : 0);
2361
-
2362
- // Frame border
2363
- svg
2364
- .append('rect')
2365
- .attr('x', frameX)
2366
- .attr('y', frameY)
2367
- .attr('width', frameW)
2368
- .attr('height', frameH)
2369
- .attr('fill', 'none')
2370
- .attr('stroke', palette.textMuted)
2371
- .attr('stroke-width', 1)
2372
- .attr('stroke-dasharray', '2 3')
2373
- .attr('rx', 3)
2374
- .attr('ry', 3)
2375
- .attr('class', 'block-frame')
2376
- .attr('data-block-line', String(el.lineNumber));
2377
-
2378
- // Defer label text (rendered on top of activations later)
2379
- deferredLabels.push({
2380
- x: frameX + 6,
2381
- y: frameY + FRAME_LABEL_HEIGHT - 4,
2382
- text: `${el.type} ${el.label}`,
2383
- bold: false,
2384
- italic: false,
2385
- blockLine: el.lineNumber,
2386
- });
2387
-
2388
- // Else-if dividers
2389
- for (const branchData of elseIfBranchData) {
2390
- if (branchData.indices.length > 0) {
2391
- let firstBranchStep = Infinity;
2392
- for (const mi of branchData.indices) {
2393
- const first = msgToFirstStep.get(mi);
2394
- if (first !== undefined)
2395
- firstBranchStep = Math.min(firstBranchStep, first);
2396
- }
2397
- if (firstBranchStep < Infinity) {
2398
- const dividerY = stepY(firstBranchStep) - BLOCK_HEADER_SPACE;
2399
- deferredLines.push({
2400
- x1: frameX,
2401
- y1: dividerY,
2402
- x2: frameX + frameW,
2403
- y2: dividerY,
2404
- blockLine: branchData.lineNumber,
2405
- });
2406
- deferredLabels.push({
2407
- x: frameX + 6,
2408
- y: dividerY + 14,
2409
- text: `else if ${branchData.label}`,
2410
- bold: false,
2411
- italic: false,
2412
- blockLine: branchData.lineNumber,
2413
- });
2414
- }
2415
- }
2416
- }
2417
-
2418
- // Else divider
2419
- if (elseIndices.length > 0) {
2420
- let firstElseStep = Infinity;
2421
- for (const mi of elseIndices) {
2422
- const first = msgToFirstStep.get(mi);
2423
- if (first !== undefined)
2424
- firstElseStep = Math.min(firstElseStep, first);
2425
- }
2426
- if (firstElseStep < Infinity) {
2427
- const dividerY = stepY(firstElseStep) - BLOCK_HEADER_SPACE;
2428
- deferredLines.push({
2429
- x1: frameX,
2430
- y1: dividerY,
2431
- x2: frameX + frameW,
2432
- y2: dividerY,
2433
- ...(el.elseLineNumber !== undefined && {
2434
- blockLine: el.elseLineNumber,
2435
- }),
2436
- });
2437
- deferredLabels.push({
2438
- x: frameX + 6,
2439
- y: dividerY + 14,
2440
- text: 'else',
2441
- bold: false,
2442
- italic: false,
2443
- ...(el.elseLineNumber !== undefined && {
2444
- blockLine: el.elseLineNumber,
2445
- }),
2446
- });
2447
- }
2448
- }
2449
-
2450
- // Recurse into nested blocks
2451
- renderBlockFrames(el.children, depth + 1);
2452
- if (el.elseIfBranches) {
2453
- for (const branch of el.elseIfBranches) {
2454
- renderBlockFrames(branch.children, depth + 1);
2455
- }
2456
- }
2457
- renderBlockFrames(el.elseChildren, depth + 1);
2458
- }
2459
- };
2460
-
2461
- if (elements && elements.length > 0) {
2462
- renderBlockFrames(elements, 0);
2463
- }
2464
-
2465
- // Render activation rectangles (behind arrows)
2466
- const ACTIVATION_NEST_OFFSET = ctx.structural(6);
2467
- activations.forEach((act) => {
2468
- const px = participantX.get(act.participantId);
2469
- if (px === undefined) return;
2470
-
2471
- const x = px - sActivationWidth / 2 + act.depth * ACTIVATION_NEST_OFFSET;
2472
- const y1 = stepY(act.startStep);
2473
- const y2 = stepY(act.endStep);
2474
-
2475
- // Collect message line numbers covered by this activation
2476
- const coveredLines: number[] = [];
2477
- for (let si = act.startStep; si <= act.endStep; si++) {
2478
- const step = renderSteps[si];
2479
- if (!step) continue;
2480
- const msg = messages[step.messageIndex];
2481
- if (msg) coveredLines.push(msg.lineNumber);
2482
- }
2483
-
2484
- // Determine activation color from triggering message's tag
2485
- const triggerStep = renderSteps[act.startStep];
2486
- const triggerMsg =
2487
- triggerStep !== undefined
2488
- ? messages[triggerStep.messageIndex]
2489
- : undefined;
2490
- const actTagValue = triggerMsg
2491
- ? tagMap?.messages.get(triggerMsg.lineNumber)
2492
- : undefined;
2493
- const actTagColor = getTagColor(actTagValue);
2494
- const actBaseColor = actTagColor || palette.primary;
2495
-
2496
- // Opaque background to mask the lifeline
2497
- svg
2498
- .append('rect')
2499
- .attr('x', x)
2500
- .attr('y', y1)
2501
- .attr('width', sActivationWidth)
2502
- .attr('height', y2 - y1)
2503
- .attr('fill', themeBaseBg(palette, isDark));
2504
-
2505
- // Canonical 25% tint via shapeFill() (or full intent when solid-fill is on).
2506
- const actFill = shapeFill(palette, actBaseColor, isDark, { solid });
2507
- const actRect = svg
2508
- .append('rect')
2509
- .attr('x', x)
2510
- .attr('y', y1)
2511
- .attr('width', sActivationWidth)
2512
- .attr('height', y2 - y1)
2513
- .attr('fill', actFill)
2514
- .attr('stroke', actBaseColor)
2515
- .attr('stroke-width', 1)
2516
- .attr('stroke-opacity', 0.5)
2517
- .attr('data-participant-id', act.participantId)
2518
- .attr('data-msg-lines', coveredLines.join(','))
2519
- .attr('data-line-number', coveredLines[0] ?? '')
2520
- .attr('class', 'activation');
2521
- if (tagKey && actTagValue) {
2522
- actRect.attr(`data-tag-${tagKey}`, actTagValue.toLowerCase());
2523
- }
2524
- });
2525
-
2526
- // Render deferred else dividers (on top of activations)
2527
- for (const ln of deferredLines) {
2528
- const line = svg
2529
- .append('line')
2530
- .attr('x1', ln.x1)
2531
- .attr('y1', ln.y1)
2532
- .attr('x2', ln.x2)
2533
- .attr('y2', ln.y2)
2534
- .attr('stroke', palette.textMuted)
2535
- .attr('stroke-width', 1)
2536
- .attr('stroke-dasharray', '2 3')
2537
- .attr('class', 'block-divider');
2538
- if (ln.blockLine !== undefined)
2539
- line.attr('data-block-line', String(ln.blockLine));
2540
- }
2541
-
2542
- // Render deferred block labels (on top of activations)
2543
- for (const lbl of deferredLabels) {
2544
- const t = svg
2545
- .append('text')
2546
- .attr('x', lbl.x)
2547
- .attr('y', lbl.y)
2548
- .attr('fill', palette.text)
2549
- .attr('font-size', 11)
2550
- .attr('class', 'block-label')
2551
- .text(lbl.text);
2552
- if (lbl.bold) t.attr('font-weight', 'bold');
2553
- if (lbl.italic) t.attr('font-style', 'italic');
2554
- if (lbl.blockLine !== undefined)
2555
- t.attr('data-block-line', String(lbl.blockLine));
2556
- }
2557
-
2558
- // Helper: find max active activation depth for a participant at a step
2559
- const activeDepthAt = (pid: string, stepIdx: number): number => {
2560
- let maxDepth = -1;
2561
- for (const act of activations) {
2562
- if (
2563
- act.participantId === pid &&
2564
- act.startStep <= stepIdx &&
2565
- stepIdx <= act.endStep &&
2566
- act.depth > maxDepth
2567
- ) {
2568
- maxDepth = act.depth;
2569
- }
2570
- }
2571
- return maxDepth;
2572
- };
2573
-
2574
- // Helper: compute arrow endpoint X, snapping to activation box edge
2575
- const arrowEdgeX = (
2576
- pid: string,
2577
- stepIdx: number,
2578
- side: 'left' | 'right'
2579
- ): number => {
2580
- const px = participantX.get(pid)!;
2581
- const depth = activeDepthAt(pid, stepIdx);
2582
- if (depth < 0) return px;
2583
- const offset = depth * ACTIVATION_NEST_OFFSET;
2584
- return side === 'right'
2585
- ? px + sActivationWidth / 2 + offset
2586
- : px - sActivationWidth / 2 + offset;
2587
- };
2588
-
2589
- // Render section dividers
2590
- const leftmostX = Math.min(...Array.from(participantX.values()));
2591
- const rightmostX = frameRightmostX;
2592
- const sectionLineX1 = leftmostX - sBoxW / 2 - 10;
2593
- const sectionLineX2 = rightmostX + sBoxW / 2 + 10;
2594
-
2595
- for (const region of sectionRegions) {
2596
- const sec = region.section;
2597
- const secY = sectionYPositions.get(sec.lineNumber);
2598
- if (secY === undefined) continue;
2599
-
2600
- const isCollapsed = collapsedSections?.has(sec.lineNumber) ?? false;
2601
- const lineColor = palette.textMuted;
2602
-
2603
- // Wrap section elements in a <g> for toggle.
2604
- // IMPORTANT: only the <g> carries data-line-number / data-section —
2605
- // children must NOT have them, otherwise the click walk-up resolves
2606
- // to a line-number navigation before reaching data-section-toggle.
2607
- const sectionG = svg
2608
- .append('g')
2609
- .attr('data-section-toggle', '')
2610
- .attr('data-line-number', String(sec.lineNumber))
2611
- .attr('data-section', '')
2612
- .attr('tabindex', '0')
2613
- .attr('role', 'button')
2614
- .attr('aria-expanded', String(!isCollapsed));
2615
-
2616
- // Full-width tinted band
2617
- const BAND_HEIGHT = 22;
2618
- const bandX = sectionLineX1 - 10;
2619
- const bandWidth = sectionLineX2 - sectionLineX1 + 20;
2620
- const bandOpacity = isCollapsed
2621
- ? isDark
2622
- ? 0.35
2623
- : 0.25
2624
- : isDark
2625
- ? 0.1
2626
- : 0.08;
2627
- // Visual band — pointer-events:none so it never intercepts clicks
2628
- // intended for elements rendered earlier (participants, lifelines, etc.).
2629
- // Toggle hit area is the label rect below.
2630
- sectionG
2631
- .append('rect')
2632
- .attr('x', bandX)
2633
- .attr('y', secY - BAND_HEIGHT / 2)
2634
- .attr('width', bandWidth)
2635
- .attr('height', BAND_HEIGHT)
2636
- .attr('fill', lineColor)
2637
- .attr('opacity', bandOpacity)
2638
- .attr('rx', 2)
2639
- .attr('pointer-events', 'none')
2640
- .attr('class', 'section-divider');
2641
-
2642
- // Build display label
2643
- const msgCount = sectionMsgCounts.get(sec.lineNumber) ?? 0;
2644
- const labelText = isCollapsed
2645
- ? `${sec.label} (${msgCount} ${msgCount === 1 ? 'message' : 'messages'})`
2646
- : sec.label;
2647
-
2648
- // Centered label text
2649
- const labelX = (sectionLineX1 + sectionLineX2) / 2;
2650
-
2651
- // Transparent hit area scoped to the label so the toggle stays clickable
2652
- // without the band swallowing clicks across the full diagram width.
2653
- // Label renders at the fixed 11px section-label size.
2654
- const labelHitW = Math.max(80, measureText(labelText, 11) + 24);
2655
- sectionG
2656
- .append('rect')
2657
- .attr('x', labelX - labelHitW / 2)
2658
- .attr('y', secY - BAND_HEIGHT / 2)
2659
- .attr('width', labelHitW)
2660
- .attr('height', BAND_HEIGHT)
2661
- .attr('fill', 'transparent')
2662
- .attr('class', 'section-label-hit');
2663
-
2664
- sectionG
2665
- .append('text')
2666
- .attr('x', labelX)
2667
- .attr('y', secY + 4)
2668
- .attr('text-anchor', 'middle')
2669
- .attr('fill', lineColor)
2670
- .attr('font-size', 11)
2671
- .attr('font-weight', 'bold')
2672
- .attr('class', 'section-label')
2673
- .text(labelText);
2674
- }
2675
-
2676
- // Render steps (calls and returns in stack-inferred order)
2677
- // Self-call rendering uses scaled constants
2678
- renderSteps.forEach((step, i) => {
2679
- const fromX = participantX.get(step.from);
2680
- const toX = participantX.get(step.to);
2681
- if (fromX === undefined || toX === undefined) return;
2682
-
2683
- const y = stepY(i);
2684
-
2685
- const HIT_H = 20; // transparent hit area height (10px above + below arrow)
2686
-
2687
- // Resolve tag color for this message
2688
- // In-bounds: step.messageIndex is built from a valid messages[] index by
2689
- // buildRenderSequence/computeActivations (no synthetic step indices exist).
2690
- const msg = messages[step.messageIndex]!;
2691
- const msgTagValue = msg ? tagMap?.messages.get(msg.lineNumber) : undefined;
2692
- const msgTagColor = getTagColor(msgTagValue);
2693
-
2694
- if (step.type === 'call') {
2695
- const arrowColor = msgTagColor || palette.text;
2696
-
2697
- if (step.from === step.to) {
2698
- // Self-call: loopback arrow — always loops to the right.
2699
- // Canvas width extends to accommodate via right-edge projection.
2700
- const x = arrowEdgeX(step.from, i, 'right');
2701
- const loopX = x + sSelfCallWidth;
2702
- const hitX = x;
2703
-
2704
- // Hit area for self-call
2705
- svg
2706
- .append('rect')
2707
- .attr('x', hitX)
2708
- .attr('y', y - 5)
2709
- .attr('width', sSelfCallWidth)
2710
- .attr('height', sSelfCallHeight + 10)
2711
- .attr('fill', 'transparent')
2712
- .attr('class', 'message-hit-area')
2713
- .attr('data-line-number', String(msg.lineNumber))
2714
- .attr('data-msg-index', String(step.messageIndex))
2715
- .attr('data-step-index', String(i));
2716
-
2717
- const selfCallEl = svg
2718
- .append('path')
2719
- .attr('d', `M ${x} ${y} H ${loopX} V ${y + sSelfCallHeight} H ${x}`)
2720
- .attr('fill', 'none')
2721
- .attr('stroke', arrowColor)
2722
- .attr('stroke-width', 1.2)
2723
- .attr('marker-end', coloredMarker('call', msgTagColor))
2724
- .attr('class', 'message-arrow self-call')
2725
- .attr('data-line-number', String(msg.lineNumber))
2726
- .attr('data-msg-index', String(step.messageIndex))
2727
- .attr('data-step-index', String(i))
2728
- .attr('data-from', step.from)
2729
- .attr('data-to', step.to);
2730
- if (tagKey && msgTagValue) {
2731
- selfCallEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2732
- }
2733
-
2734
- if (step.label) {
2735
- const labelEl = svg
2736
- .append('text')
2737
- .attr('x', loopX + 5)
2738
- .attr('y', y + sSelfCallHeight / 2 + 4)
2739
- .attr('text-anchor', 'start')
2740
- .attr('fill', arrowColor)
2741
- .attr('paint-order', 'stroke fill')
2742
- .attr('stroke', palette.bg)
2743
- .attr('stroke-width', 4)
2744
- .attr('stroke-linejoin', 'round')
2745
- .attr('font-size', 12)
2746
- .attr('class', 'message-label')
2747
- .attr('data-line-number', String(msg.lineNumber))
2748
- .attr('data-msg-index', String(step.messageIndex))
2749
- .attr('data-step-index', String(i));
2750
- if (tagKey && msgTagValue) {
2751
- labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2752
- }
2753
- // TD-1: in-arrow labels render as plain text (no markdown interpretation).
2754
- // Fixes the `location[]`-style silent character drop.
2755
- labelEl.text(step.label);
2756
- }
2757
- } else {
2758
- // Normal call arrow — snap to activation box edges
2759
- const goingRight = fromX < toX;
2760
- const x1 = arrowEdgeX(step.from, i, goingRight ? 'right' : 'left');
2761
- const x2 = arrowEdgeX(step.to, i, goingRight ? 'left' : 'right');
2762
-
2763
- // Hit area for call arrow
2764
- svg
2765
- .append('rect')
2766
- .attr('x', Math.min(x1, x2))
2767
- .attr('y', y - HIT_H / 2)
2768
- .attr('width', Math.abs(x2 - x1))
2769
- .attr('height', HIT_H)
2770
- .attr('fill', 'transparent')
2771
- .attr('class', 'message-hit-area')
2772
- .attr('data-line-number', String(msg.lineNumber))
2773
- .attr('data-msg-index', String(step.messageIndex))
2774
- .attr('data-step-index', String(i));
2775
-
2776
- const markerRef = step.async
2777
- ? coloredMarker('async', msgTagColor)
2778
- : coloredMarker('call', msgTagColor);
2779
- const arrowEl = svg
2780
- .append('line')
2781
- .attr('x1', x1)
2782
- .attr('y1', y)
2783
- .attr('x2', x2)
2784
- .attr('y2', y)
2785
- .attr('stroke', arrowColor)
2786
- .attr('stroke-width', 1.2)
2787
- .attr('marker-end', markerRef)
2788
- .attr('class', 'message-arrow')
2789
- .attr('data-line-number', String(msg.lineNumber))
2790
- .attr('data-msg-index', String(step.messageIndex))
2791
- .attr('data-step-index', String(i))
2792
- .attr('data-from', step.from)
2793
- .attr('data-to', step.to);
2794
- if (tagKey && msgTagValue) {
2795
- arrowEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2796
- }
2797
-
2798
- if (step.label) {
2799
- const midX = (x1 + x2) / 2;
2800
- const labelEl = svg
2801
- .append('text')
2802
- .attr('x', midX)
2803
- .attr('y', y - 8)
2804
- .attr('text-anchor', 'middle')
2805
- .attr('fill', arrowColor)
2806
- .attr('paint-order', 'stroke fill')
2807
- .attr('stroke', palette.bg)
2808
- .attr('stroke-width', 4)
2809
- .attr('stroke-linejoin', 'round')
2810
- .attr('font-size', 12)
2811
- .attr('class', 'message-label')
2812
- .attr('data-line-number', String(msg.lineNumber))
2813
- .attr('data-msg-index', String(step.messageIndex))
2814
- .attr('data-step-index', String(i));
2815
- if (tagKey && msgTagValue) {
2816
- labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2817
- }
2818
- // TD-1: in-arrow labels render as plain text (no markdown interpretation).
2819
- // Fixes the `location[]`-style silent character drop.
2820
- labelEl.text(step.label);
2821
- }
2822
- }
2823
- } else {
2824
- if (step.from === step.to) {
2825
- // Self-call return — already handled by the loopback path, skip
2826
- return;
2827
- }
2828
- // Return arrow — snap to activation box edges
2829
- const goingRight = fromX < toX;
2830
- const x1 = arrowEdgeX(step.from, i, goingRight ? 'right' : 'left');
2831
- const x2 = arrowEdgeX(step.to, i, goingRight ? 'left' : 'right');
2832
- const returnColor = msgTagColor || palette.textMuted;
2833
-
2834
- // Hit area for return arrow
2835
- svg
2836
- .append('rect')
2837
- .attr('x', Math.min(x1, x2))
2838
- .attr('y', y - HIT_H / 2)
2839
- .attr('width', Math.abs(x2 - x1))
2840
- .attr('height', HIT_H)
2841
- .attr('fill', 'transparent')
2842
- .attr('class', 'message-hit-area')
2843
- .attr('data-line-number', String(msg.lineNumber))
2844
- .attr('data-msg-index', String(step.messageIndex))
2845
- .attr('data-step-index', String(i));
2846
-
2847
- const returnEl = svg
2848
- .append('line')
2849
- .attr('x1', x1)
2850
- .attr('y1', y)
2851
- .attr('x2', x2)
2852
- .attr('y2', y)
2853
- .attr('stroke', returnColor)
2854
- .attr('stroke-width', 1)
2855
- .attr('stroke-dasharray', '6 4')
2856
- .attr('marker-end', coloredMarker('return', msgTagColor))
2857
- .attr('class', 'return-arrow')
2858
- .attr('data-line-number', String(msg.lineNumber))
2859
- .attr('data-msg-index', String(step.messageIndex))
2860
- .attr('data-step-index', String(i))
2861
- .attr('data-from', step.from)
2862
- .attr('data-to', step.to);
2863
- if (tagKey && msgTagValue) {
2864
- returnEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2865
- }
2866
-
2867
- if (step.label) {
2868
- const midX = (x1 + x2) / 2;
2869
- const labelEl = svg
2870
- .append('text')
2871
- .attr('x', midX)
2872
- .attr('y', y - 6)
2873
- .attr('text-anchor', 'middle')
2874
- .attr('fill', returnColor)
2875
- .attr('paint-order', 'stroke fill')
2876
- .attr('stroke', palette.bg)
2877
- .attr('stroke-width', 4)
2878
- .attr('stroke-linejoin', 'round')
2879
- .attr('font-size', 11)
2880
- .attr('class', 'message-label')
2881
- .attr('data-line-number', String(msg.lineNumber))
2882
- .attr('data-msg-index', String(step.messageIndex))
2883
- .attr('data-step-index', String(i));
2884
- if (tagKey && msgTagValue) {
2885
- labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2886
- }
2887
- // TD-1: in-arrow labels render as plain text (no markdown
2888
- // interpretation). Return-arrow labels are currently always empty
2889
- // (buildRenderSequence sets them to '') but this path is kept in
2890
- // sync with the call/self-call sites above to prevent a future
2891
- // change resurrecting the location[] silent-drop bug.
2892
- labelEl.text(step.label);
2893
- }
2894
- }
2895
- });
2896
-
2897
- // Render notes — folded-corner boxes attached to participant lifelines
2898
- const noteFill = isDark
2899
- ? mix(palette.surface, palette.bg, 50)
2900
- : mix(palette.bg, palette.surface, 15);
2901
-
2902
- const renderNoteElements = (els: readonly SequenceElement[]): void => {
2903
- for (const el of els) {
2904
- if (isSequenceNote(el)) {
2905
- const px = participantX.get(el.participantId);
2906
- if (px === undefined) continue;
2907
- const noteTopY = noteYMap.get(el);
2908
- if (noteTopY === undefined) continue;
2909
-
2910
- const position = effectiveNotePosition(el);
2911
- const isRight = position === 'right';
2912
- const afterSelfCall = isNoteAfterSelfCall(el);
2913
- const maxW = noteEffectiveMaxW(
2914
- el.participantId,
2915
- position,
2916
- afterSelfCall
2917
- );
2918
- const textWidth = noteTextWidth(maxW);
2919
- const wrappedLines = wrapTextLines(el.text, textWidth, sNoteFontSize);
2920
- const noteH = wrappedLines.length * sNoteLineH + sNotePadV * 2;
2921
- const maxLineW = Math.max(
2922
- ...wrappedLines.map((l) => measureText(l.text, sNoteFontSize))
2923
- );
2924
- const noteW = Math.min(
2925
- maxW,
2926
- Math.max(80, maxLineW + sNotePadH * 2 + sNoteFold)
2927
- );
2928
- // Shift notes past self-call loopback when applicable
2929
- const rightOffset =
2930
- afterSelfCall && isRight
2931
- ? sActivationWidth / 2 + sSelfCallWidth + sNoteGap
2932
- : sActivationWidth + sNoteGap;
2933
- const noteX = isRight
2934
- ? px + rightOffset
2935
- : px - sActivationWidth - sNoteGap - noteW;
2936
-
2937
- const noteG = svg
2938
- .append('g')
2939
- .attr('class', 'note')
2940
- .attr('data-note-toggle', '')
2941
- .attr('data-line-number', String(el.lineNumber))
2942
- .attr('data-line-end', String(el.endLineNumber));
2943
-
2944
- // Folded-corner path
2945
- noteG
2946
- .append('path')
2947
- .attr(
2948
- 'd',
2949
- [
2950
- `M ${noteX} ${noteTopY}`,
2951
- `L ${noteX + noteW - sNoteFold} ${noteTopY}`,
2952
- `L ${noteX + noteW} ${noteTopY + sNoteFold}`,
2953
- `L ${noteX + noteW} ${noteTopY + noteH}`,
2954
- `L ${noteX} ${noteTopY + noteH}`,
2955
- 'Z',
2956
- ].join(' ')
2957
- )
2958
- .attr('fill', noteFill)
2959
- .attr('stroke', palette.textMuted)
2960
- .attr('stroke-width', 0.75)
2961
- .attr('class', 'note-box');
2962
-
2963
- // Fold triangle
2964
- noteG
2965
- .append('path')
2966
- .attr(
2967
- 'd',
2968
- [
2969
- `M ${noteX + noteW - sNoteFold} ${noteTopY}`,
2970
- `L ${noteX + noteW - sNoteFold} ${noteTopY + sNoteFold}`,
2971
- `L ${noteX + noteW} ${noteTopY + sNoteFold}`,
2972
- ].join(' ')
2973
- )
2974
- .attr('fill', 'none')
2975
- .attr('stroke', palette.textMuted)
2976
- .attr('stroke-width', 0.75)
2977
- .attr('class', 'note-fold');
2978
-
2979
- // Render text with inline markdown. Bullet first lines get a "\u2022"
2980
- // glyph at the left edge with body text indented; bullet continuation
2981
- // lines render at the same indented body column for hanging alignment.
2982
- const BULLET_BODY_INDENT = 10;
2983
- wrappedLines.forEach((line, li) => {
2984
- const textY = noteTopY + sNotePadV + (li + 1) * sNoteLineH - 3;
2985
- const indent = line.kind === 'plain' ? 0 : BULLET_BODY_INDENT;
2986
- if (line.kind === 'bullet-first') {
2987
- noteG
2988
- .append('text')
2989
- .attr('x', noteX + sNotePadH)
2990
- .attr('y', textY)
2991
- .attr('fill', palette.text)
2992
- .attr('font-size', sNoteFontSize)
2993
- .text('\u2022');
2994
- }
2995
- const textEl = noteG
2996
- .append('text')
2997
- .attr('x', noteX + sNotePadH + indent)
2998
- .attr('y', textY)
2999
- .attr('fill', palette.text)
3000
- .attr('font-size', sNoteFontSize)
3001
- .attr('class', 'note-text');
3002
- renderInlineText(textEl, line.text, palette, sNoteFontSize);
3003
- });
3004
- } else if (isSequenceBlock(el)) {
3005
- renderNoteElements(el.children);
3006
- if (el.elseIfBranches) {
3007
- for (const branch of el.elseIfBranches) {
3008
- renderNoteElements(branch.children);
3009
- }
3010
- }
3011
- renderNoteElements(el.elseChildren);
3012
- }
3013
- }
3014
- };
3015
-
3016
- if (elements && elements.length > 0) {
3017
- renderNoteElements(elements);
3018
- }
3019
-
3020
- // Render legend LAST so it sits on top of all other SVG elements
3021
- // (group boxes, lifelines, participants, etc.) and can receive clicks.
3022
- if (hasTagGroups) {
3023
- const legendY = sTopMargin + titleOffset;
3024
- const legendCallbacks: LegendCallbacks = {};
3025
-
3026
- const legendG = svg
3027
- .append('g')
3028
- .attr('class', 'sequence-legend')
3029
- .attr('transform', `translate(0,${legendY})`);
3030
- renderIntegratedLegend(legendG, {
3031
- ...legendConfig,
3032
- palette,
3033
- isDark,
3034
- width: svgWidth,
3035
- activeGroup: activeTagGroup ?? null,
3036
- controlsExpanded: false,
3037
- callbacks: legendCallbacks,
3038
- });
3039
- }
3040
- }
3041
-
3042
- /**
3043
- * Build a mapping from each note's lineNumber to the lineNumber of its
3044
- * associated message (the last message before the note in document order).
3045
- * Used by the app to highlight the associated message when cursor is on a note.
3046
- */
3047
- export function buildNoteMessageMap(
3048
- elements: readonly SequenceElement[]
3049
- ): Map<number, number> {
3050
- const map = new Map<number, number>();
3051
- let lastMessageLine = -1;
3052
-
3053
- const walk = (els: readonly SequenceElement[]): void => {
3054
- for (const el of els) {
3055
- if (isSequenceNote(el)) {
3056
- if (lastMessageLine >= 0) {
3057
- map.set(el.lineNumber, lastMessageLine);
3058
- }
3059
- } else if (isSequenceBlock(el)) {
3060
- walk(el.children);
3061
- if (el.elseIfBranches) {
3062
- for (const branch of el.elseIfBranches) {
3063
- walk(branch.children);
3064
- }
3065
- }
3066
- walk(el.elseChildren);
3067
- } else if (!isSequenceSection(el)) {
3068
- // Narrowed to SequenceMessage by the discriminator (kind === 'message')
3069
- lastMessageLine = el.lineNumber;
3070
- }
3071
- }
3072
- };
3073
- walk(elements);
3074
- return map;
3075
- }
3076
-
3077
- function renderParticipant(
3078
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
3079
- participant: SequenceParticipant,
3080
- cx: number,
3081
- cy: number,
3082
- palette: PaletteColors,
3083
- isDark: boolean,
3084
- color?: string,
3085
- tagAttr?: { key: string; value: string },
3086
- solid?: boolean,
3087
- boxW: number = W,
3088
- boxH: number = H,
3089
- labelTextW: number = labelTextWidth(W),
3090
- labelFontSize: number = LABEL_FONT_SIZE
3091
- ): void {
3092
- const g = svg
3093
- .append('g')
3094
- .attr('transform', `translate(${cx}, ${cy})`)
3095
- .attr('class', 'participant')
3096
- .attr('data-participant-id', participant.id);
3097
-
3098
- // Set data-tag attribute for legend hover dimming
3099
- if (tagAttr) {
3100
- g.attr(`data-tag-${tagAttr.key}`, tagAttr.value);
3101
- }
3102
-
3103
- // Render shape based on type
3104
- switch (participant.type) {
3105
- case 'actor':
3106
- renderActorParticipant(g, palette, color, boxH);
3107
- break;
3108
- case 'database':
3109
- renderDatabaseParticipant(g, palette, isDark, color, solid, boxW, boxH);
3110
- break;
3111
- case 'queue':
3112
- renderQueueParticipant(g, palette, isDark, color, solid, boxW, boxH);
3113
- break;
3114
- case 'cache':
3115
- renderCacheParticipant(g, palette, isDark, color, solid, boxW, boxH);
3116
- break;
3117
- default:
3118
- renderRectParticipant(g, palette, isDark, color, solid, boxW, boxH);
3119
- break;
3120
- }
3121
-
3122
- // Render label — below the shape for actors, centered inside for others
3123
- const isActor = participant.type === 'actor';
3124
- const labelLines = splitParticipantLabel(
3125
- participant.label,
3126
- labelTextW,
3127
- labelFontSize
3128
- );
3129
- const fontSize = labelFontSize;
3130
- const lineHeight = fontSize + 2;
3131
- const labelFill = isActor
3132
- ? palette.text
3133
- : contrastText(
3134
- fill(palette, isDark, color, solid),
3135
- palette.textOnFillLight,
3136
- palette.textOnFillDark
3137
- );
3138
- const textEl = g
3139
- .append('text')
3140
- .attr('x', 0)
3141
- .attr('text-anchor', 'middle')
3142
- .attr('fill', labelFill)
3143
- .attr('font-size', fontSize)
3144
- .attr('font-weight', 500);
3145
-
3146
- const maxLabelW = labelTextWidth(boxW);
3147
- const truncLine = (text: string): string =>
3148
- truncateText(text, fontSize, maxLabelW);
3149
-
3150
- if (labelLines.length === 1) {
3151
- textEl
3152
- .attr('y', isActor ? boxH + 14 : boxH / 2 + 5)
3153
- .text(truncLine(participant.label));
3154
- } else {
3155
- const totalHeight = labelLines.length * lineHeight;
3156
- const baseY = isActor
3157
- ? boxH + 14 - ((labelLines.length - 1) * lineHeight) / 2
3158
- : boxH / 2 + 5 - (totalHeight - lineHeight) / 2;
3159
-
3160
- labelLines.forEach((line, i) => {
3161
- textEl
3162
- .append('tspan')
3163
- .attr('x', 0)
3164
- .attr('dy', i === 0 ? `${baseY}px` : `${lineHeight}px`)
3165
- .text(truncLine(line));
3166
- });
3167
- }
3168
- }