@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,3787 +0,0 @@
1
- // ============================================================
2
- // Gantt Chart Renderer
3
- // ============================================================
4
-
5
- import { tagAttrKey } from '../utils/tag-groups';
6
- import * as d3Scale from 'd3-scale';
7
- import * as d3Selection from 'd3-selection';
8
- import { FONT_FAMILY } from '../fonts';
9
- import { getSeriesColors } from '../palettes';
10
- import { contrastText, mix, shapeFill } from '../palettes/color-utils';
11
- import { normalizeName } from '../utils/name-normalize';
12
- import { resolveTagColor, resolveActiveTagGroup } from '../utils/tag-groups';
13
- import { ScaleContext } from '../utils/scaling';
14
- import { computeTimeTicks, MONTH_ABBR } from '../utils/time-ticks';
15
- import {
16
- measureText,
17
- truncateText,
18
- CHAR_WIDTH_RATIO,
19
- } from '../utils/text-measure';
20
- import {
21
- LEGEND_HEIGHT,
22
- LEGEND_PILL_PAD,
23
- LEGEND_PILL_FONT_SIZE,
24
- LEGEND_CAPSULE_PAD,
25
- LEGEND_DOT_R,
26
- LEGEND_ENTRY_FONT_SIZE,
27
- LEGEND_ENTRY_DOT_GAP,
28
- LEGEND_ENTRY_TRAIL,
29
- LEGEND_GROUP_GAP,
30
- LEGEND_ICON_W,
31
- LEGEND_GEAR_PILL_W,
32
- measureLegendText,
33
- truncateLegendText,
34
- } from '../utils/legend-constants';
35
- import { renderIntegratedLegend } from '../utils/legend-integration';
36
- import {
37
- controlsGroupCapsuleWidth,
38
- getMaxLegendReservedHeight,
39
- } from '../utils/legend-layout';
40
- import type { LegendCallbacks } from '../utils/legend-types';
41
- import {
42
- TITLE_FONT_SIZE,
43
- TITLE_FONT_WEIGHT,
44
- TITLE_Y,
45
- } from '../utils/title-constants';
46
- import type { PaletteColors } from '../palettes';
47
- import type { D3ExportDimensions } from '../utils/d3-types';
48
- import type {
49
- ResolvedSchedule,
50
- ResolvedTask,
51
- ResolvedGroup,
52
- Weekday,
53
- } from './types';
54
- import type { TagGroup, TagEntry } from '../utils/tag-groups';
55
-
56
- // ── Constants ───────────────────────────────────────────────
57
-
58
- const BAR_H = 22;
59
- const ROW_GAP = 6;
60
- const GROUP_GAP = 14;
61
- const MILESTONE_SIZE = 10;
62
- const MIN_LEFT_MARGIN = 120;
63
- const BOTTOM_MARGIN = 40;
64
- const RIGHT_MARGIN = 20;
65
- const LABEL_PAD = 8; // inner padding to decide if label fits inside bar
66
- const LABEL_GAP = 5; // gap between bar edge and external label
67
-
68
- // ── Bar label placement ─────────────────────────────────────
69
-
70
- type BarLabelPlacement = {
71
- x: number;
72
- anchor: 'start' | 'end';
73
- fill: string;
74
- text: string;
75
- };
76
-
77
- function computeBarLabel(
78
- label: string,
79
- x1: number,
80
- barWidth: number,
81
- innerWidth: number,
82
- textColor: string,
83
- onFillColor?: string,
84
- fontSize = 10,
85
- labelPad = LABEL_PAD,
86
- labelGap = LABEL_GAP
87
- ): BarLabelPlacement | null {
88
- const textWidth = measureText(label, fontSize);
89
- const x2 = x1 + barWidth;
90
-
91
- if (textWidth < barWidth - labelPad) {
92
- return {
93
- x: x1 + 6,
94
- anchor: 'start',
95
- fill: onFillColor ?? textColor,
96
- text: label,
97
- };
98
- }
99
-
100
- if (x2 + labelGap + textWidth <= innerWidth) {
101
- return { x: x2 + labelGap, anchor: 'start', fill: textColor, text: label };
102
- }
103
-
104
- if (x1 - labelGap - textWidth >= 0) {
105
- return { x: x1 - labelGap, anchor: 'end', fill: textColor, text: label };
106
- }
107
-
108
- const availWidth = x1 - labelGap;
109
- // Roughly three average glyphs' worth of room before truncation is worthwhile.
110
- if (availWidth > fontSize * CHAR_WIDTH_RATIO * 3) {
111
- const truncated = truncateText(label, fontSize, availWidth);
112
- if (!truncated) return null;
113
- return {
114
- x: x1 - labelGap,
115
- anchor: 'end',
116
- fill: textColor,
117
- text: truncated,
118
- };
119
- }
120
-
121
- return null;
122
- }
123
-
124
- // ── Left-panel visual helpers ───────────────────────────────
125
-
126
- const BAND_ACCENT_W = 4;
127
- const BAND_RADIUS = 4;
128
- let bandClipCounter = 0;
129
-
130
- function renderLabelBand(
131
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
132
- y: number,
133
- leftMargin: number,
134
- color: string,
135
- palette: PaletteColors,
136
- isDark: boolean,
137
- cssPrefix: 'group' | 'lane',
138
- dataAttr?: { key: string; value: string },
139
- solid?: boolean,
140
- barH = BAR_H,
141
- bandRadius = BAND_RADIUS,
142
- bandAccentW = BAND_ACCENT_W
143
- ): void {
144
- const bandX = 5;
145
- const bandW = leftMargin - 7;
146
- const bandY = y - barH / 2;
147
- const clipId = `gantt-band-clip-${bandClipCounter++}`;
148
-
149
- svg
150
- .append('clipPath')
151
- .attr('id', clipId)
152
- .append('rect')
153
- .attr('x', bandX)
154
- .attr('y', bandY)
155
- .attr('width', bandW)
156
- .attr('height', barH)
157
- .attr('rx', bandRadius);
158
-
159
- const tint = svg
160
- .append('rect')
161
- .attr('class', `gantt-${cssPrefix}-band-bg`)
162
- .attr('x', bandX)
163
- .attr('y', bandY)
164
- .attr('width', bandW)
165
- .attr('height', barH)
166
- .attr('rx', bandRadius)
167
- .attr(
168
- 'fill',
169
- shapeFill(palette, color, isDark, {
170
- ...(solid !== undefined && { solid }),
171
- })
172
- )
173
- .style('pointer-events', 'none');
174
-
175
- const accent = svg
176
- .append('rect')
177
- .attr('class', `gantt-${cssPrefix}-band-accent`)
178
- .attr('x', bandX)
179
- .attr('y', bandY)
180
- .attr('width', bandAccentW)
181
- .attr('height', barH)
182
- .attr('fill', color)
183
- .attr('clip-path', `url(#${clipId})`)
184
- .style('pointer-events', 'none');
185
-
186
- if (dataAttr) {
187
- tint.attr(dataAttr.key, dataAttr.value);
188
- accent.attr(dataAttr.key, dataAttr.value);
189
- }
190
- }
191
-
192
- function appendTaskIcon(
193
- textEl: d3Selection.Selection<SVGTextElement, unknown, null, undefined>,
194
- label: string,
195
- isMilestone: boolean,
196
- iconColor: string,
197
- textColor: string
198
- ): void {
199
- const icon = isMilestone ? '◆' : '●';
200
- textEl.append('tspan').attr('fill', iconColor).text(icon);
201
- textEl
202
- .append('tspan')
203
- .attr('fill', textColor)
204
- .text(' ' + label);
205
- }
206
-
207
- // ── Interactive Options ─────────────────────────────────────
208
-
209
- export interface GanttInteractiveOptions {
210
- onClickItem?: (lineNumber: number) => void;
211
- collapsedGroups?: Set<string>;
212
- onToggleGroup?: (groupName: string) => void;
213
- currentSwimlaneGroup?: string | null;
214
- onSwimlaneChange?: (group: string | null) => void;
215
- currentActiveGroup?: string | null;
216
- onActiveGroupChange?: (group: string | null) => void;
217
- collapsedLanes?: Set<string>;
218
- onToggleLane?: (laneName: string) => void;
219
- viewMode?: boolean;
220
- exportMode?: boolean;
221
- /**
222
- * Where the Critical Path / Dependencies controls are hosted. Default
223
- * `'inline'` draws the gear pill inside the SVG legend. `'app'` suppresses
224
- * the inline gear so the desktop app can surface the same toggles in its
225
- * unified ControlsStrip ("settings pull-down tab"); the toggle state is then
226
- * driven by `criticalPathActive`/`dependenciesActive` below (re-render on
227
- * change), matching every other ControlsStrip-hosted chart type.
228
- */
229
- controlsHost?: 'app' | 'inline';
230
- /** Initial Critical Path highlight state (used when `controlsHost` is `'app'`). */
231
- criticalPathActive?: boolean;
232
- /** Initial Dependencies-arrow visibility (used when `controlsHost` is `'app'`). */
233
- dependenciesActive?: boolean;
234
- }
235
-
236
- // ── Main Renderer ───────────────────────────────────────────
237
-
238
- export function renderGantt(
239
- container: HTMLDivElement,
240
- resolved: ResolvedSchedule,
241
- palette: PaletteColors,
242
- isDark: boolean,
243
- options?: GanttInteractiveOptions,
244
- exportDims?: D3ExportDimensions
245
- ): void {
246
- // Clear previous content
247
- container.innerHTML = '';
248
- bandClipCounter = 0;
249
-
250
- if (resolved.tasks.length === 0) return;
251
- // Gantt INTENTIONALLY ignores `solid-fill` — the partial-tint vs full-saturation
252
- // split inside each bar IS the progress visualization (e.g. "Blockade 72%" =
253
- // 72% saturated + 28% lighter). Solid mode would destroy that signal. Hardcode
254
- // false so all `shapeFill()` calls in this renderer keep using the 25% tint.
255
- const solid = false;
256
-
257
- // ── Destructure options ─────────────────────────────────
258
-
259
- const onClickItem = options?.onClickItem;
260
- // Fall back to the groups declared collapsed in source (`[Group] collapsed:
261
- // true`) when no interactive set is supplied — so a bare render honors the
262
- // source marker. Interactive callers (the app) pass their own set, seeded
263
- // from source and expandable.
264
- const collapsedGroups =
265
- options?.collapsedGroups ??
266
- new Set(resolved.groups.filter((g) => g.collapsed).map((g) => g.name));
267
- const onToggleGroup = options?.onToggleGroup;
268
- const viewMode = options?.viewMode ?? false;
269
- const currentSwimlaneGroup = options?.currentSwimlaneGroup ?? null;
270
- const onSwimlaneChange = options?.onSwimlaneChange;
271
- const onActiveGroupChange = options?.onActiveGroupChange;
272
- const collapsedLanes = options?.collapsedLanes;
273
- const onToggleLane = options?.onToggleLane;
274
- const controlsHost = options?.controlsHost ?? 'inline';
275
-
276
- // ── Compute layout dimensions ───────────────────────────
277
-
278
- const seriesColors = getSeriesColors(palette);
279
- let currentActiveGroup: string | null = resolveActiveTagGroup(
280
- resolved.tagGroups,
281
- resolved.options.activeTag ?? undefined,
282
- options?.currentActiveGroup
283
- );
284
- // Seeded from options so app-hosted controls (controlsHost: 'app') render the
285
- // correct state on the very first draw; in inline mode these stay false until
286
- // the user clicks the gear (existing behavior).
287
- let criticalPathActive = options?.criticalPathActive ?? false;
288
- let dependenciesActive = options?.dependenciesActive ?? false;
289
- let controlsExpanded = false;
290
-
291
- // ── Build row list (structural vs tag mode) ─────────────
292
-
293
- const tagRows = currentSwimlaneGroup
294
- ? buildTagLaneRowList(resolved, currentSwimlaneGroup, collapsedLanes)
295
- : null;
296
- const rows = tagRows ?? buildRowList(resolved, collapsedGroups);
297
- const isTagMode = tagRows !== null;
298
-
299
- // Compute left margin based on longest visible label (include ● /◆ prefix
300
- // for tasks). Labels render at font 11; group labels carry a pixel indent.
301
- const LABEL_FONT = 11;
302
- const labelWidths = isTagMode
303
- ? [
304
- ...rows
305
- .filter((r): r is LaneHeaderRow => r.type === 'lane-header')
306
- .map((r) => measureText(r.laneName, LABEL_FONT)),
307
- ...rows
308
- .filter((r): r is TaskRow => r.type === 'task')
309
- .map((r) => measureText('● ' + r.task.task.label, LABEL_FONT)),
310
- ]
311
- : [
312
- ...resolved.tasks.map((t) =>
313
- measureText('● ' + t.task.label, LABEL_FONT)
314
- ),
315
- ...resolved.groups.map((g) => {
316
- const indentPx =
317
- g.depth <= 2 ? g.depth * 14 : 2 * 14 + (g.depth - 2) * 8;
318
- return indentPx + measureText(g.name, LABEL_FONT);
319
- }),
320
- ];
321
- // Floor on a 10-char label so very short schedules still get a usable gutter.
322
- const maxLabelWidth = Math.max(
323
- ...labelWidths,
324
- measureText('0000000000', LABEL_FONT)
325
- );
326
- const leftMargin = Math.max(MIN_LEFT_MARGIN, maxLabelWidth + 30);
327
-
328
- const totalRows = rows.length;
329
-
330
- // Pre-compute critical path / dependency flags (needed for legend height reservation)
331
- const hasCriticalPath =
332
- resolved.options.criticalPath &&
333
- resolved.tasks.some((t) => t.isCriticalPath);
334
- const hasDependencies =
335
- resolved.options.dependencies &&
336
- resolved.tasks.some((t) => t.task.dependencies.length > 0);
337
-
338
- // Resolve today-marker date early so we can decide whether to reserve a row.
339
- // Each label-bearing element gets its own horizontal row, reserved only when
340
- // present in the DGMO. Stack (top → bottom): title, legend, eras, markers,
341
- // today, dates. Within-row collisions (long labels on adjacent items) are a
342
- // separate concern.
343
- let resolvedTodayDate: Date | null = null;
344
- if (resolved.options.todayMarker !== 'off') {
345
- const d =
346
- resolved.options.todayMarker === 'on'
347
- ? new Date()
348
- : new Date(resolved.options.todayMarker + 'T00:00:00');
349
- if (d >= resolved.startDate && d <= resolved.endDate) {
350
- resolvedTodayDate = d;
351
- }
352
- }
353
-
354
- // Vertical layout — matches timeline pattern (d3.ts:3649-3655)
355
- const title = resolved.options.title;
356
- const showTitle = !!title && !resolved.options.noTitle;
357
- const titleHeight = showTitle ? 50 : 20;
358
- const containerWidth = exportDims?.width ?? (container.clientWidth || 800);
359
- const hasTagLegend =
360
- resolved.tagGroups.length > 0 || hasCriticalPath || hasDependencies;
361
- const tagLegendReserve = hasTagLegend
362
- ? getMaxLegendReservedHeight(
363
- {
364
- groups: resolved.tagGroups.map((tg) => ({
365
- name: tg.name,
366
- entries: tg.entries,
367
- })),
368
- position: { placement: 'top-center', titleRelation: 'below-title' },
369
- mode: 'preview',
370
- capsulePillAddonWidth: LEGEND_ICON_W,
371
- },
372
- containerWidth
373
- ) + 8
374
- : 0;
375
- const topDateLabelReserve = 22; // tick (6) + gap (4) + label height (~12)
376
- const HEADER_ROW_H = 18; // height per reserved label row
377
- const eraReserve = resolved.eras.length > 0 ? HEADER_ROW_H : 0;
378
- const markerReserve = resolved.markers.length > 0 ? HEADER_ROW_H : 0;
379
- const todayReserve = resolvedTodayDate ? HEADER_ROW_H : 0;
380
- const sprintLabelReserve = resolved.sprints.length > 0 ? 16 : 0; // sprint hover label above date labels
381
- const CONTENT_TOP_PAD = 16; // breathing room between scale labels and first row
382
-
383
- const idealWidth = leftMargin + totalRows * 20 + RIGHT_MARGIN + 200;
384
- const ctx = exportDims
385
- ? ScaleContext.identity()
386
- : ScaleContext.from(containerWidth, idealWidth);
387
-
388
- const sBarH = ctx.structural(BAR_H);
389
- const sRowGap = ctx.structural(ROW_GAP);
390
- const sGroupGap = ctx.structural(GROUP_GAP);
391
- const sMilestoneSize = ctx.structural(MILESTONE_SIZE);
392
- const sBottomMargin = ctx.aesthetic(BOTTOM_MARGIN);
393
- const sRightMargin = ctx.aesthetic(RIGHT_MARGIN);
394
- const sLabelPad = ctx.structural(LABEL_PAD);
395
- const sLabelGap = ctx.structural(LABEL_GAP);
396
- const sBandAccentW = ctx.structural(BAND_ACCENT_W);
397
- const sBandRadius = ctx.structural(BAND_RADIUS);
398
- const sTitleFontSize = ctx.text(TITLE_FONT_SIZE);
399
- const sTitleY = ctx.aesthetic(TITLE_Y);
400
- const sHeaderRowH = ctx.structural(HEADER_ROW_H);
401
- const sContentTopPad = ctx.aesthetic(CONTENT_TOP_PAD);
402
- const sTopDateLabelReserve = ctx.structural(topDateLabelReserve);
403
- const sSprintLabelReserve = ctx.structural(sprintLabelReserve);
404
- const sTitleHeight = ctx.aesthetic(titleHeight);
405
- const sTagLegendReserve = ctx.structural(tagLegendReserve);
406
- const sEraReserve = ctx.structural(eraReserve);
407
- const sMarkerReserve = ctx.structural(markerReserve);
408
- const sTodayReserve = ctx.structural(todayReserve);
409
- const sFont10 = ctx.text(10);
410
- const sFont11 = ctx.text(11);
411
-
412
- const sMarginTop =
413
- sTitleHeight +
414
- sTagLegendReserve +
415
- sEraReserve +
416
- sMarkerReserve +
417
- sTodayReserve +
418
- sTopDateLabelReserve +
419
- sSprintLabelReserve;
420
-
421
- const sTodayLabelY = sTodayReserve
422
- ? -(sTopDateLabelReserve + sHeaderRowH / 2)
423
- : 0;
424
- const sMarkerLabelY = sMarkerReserve
425
- ? -(sTopDateLabelReserve + sTodayReserve + sHeaderRowH / 2)
426
- : 0;
427
- const sEraLabelY = sEraReserve
428
- ? -(sTopDateLabelReserve + sTodayReserve + sMarkerReserve + sHeaderRowH / 2)
429
- : 0;
430
-
431
- const sContentH = isTagMode
432
- ? totalRows * (sBarH + sRowGap)
433
- : totalRows * (sBarH + sRowGap) + sGroupGap * resolved.groups.length;
434
- const sInnerHeight = sContentTopPad + sContentH;
435
- const sOuterHeight = sMarginTop + sInnerHeight + sBottomMargin;
436
-
437
- // Extra right margin when sprints present so hover date labels aren't clipped
438
- const sprintRightPad = resolved.sprints.length > 0 ? 50 : 0;
439
- const innerWidth =
440
- containerWidth - leftMargin - sRightMargin - sprintRightPad;
441
-
442
- // ── Create SVG ──────────────────────────────────────────
443
-
444
- const svg = d3Selection
445
- .select(container)
446
- .append('svg')
447
- .attr('viewBox', `0 0 ${containerWidth} ${sOuterHeight}`)
448
- .attr('width', exportDims ? containerWidth : '100%')
449
- .attr('preserveAspectRatio', 'xMidYMin meet')
450
- .attr('font-family', FONT_FAMILY)
451
- .style('overflow', 'visible');
452
-
453
- const g = svg
454
- .append('g')
455
- .attr('transform', `translate(${leftMargin}, ${sMarginTop})`);
456
-
457
- if (showTitle) {
458
- svg
459
- .append('text')
460
- .attr('class', 'chart-title')
461
- .attr('x', containerWidth / 2)
462
- .attr('y', sTitleY)
463
- .attr('text-anchor', 'middle')
464
- .attr('font-size', sTitleFontSize)
465
- .attr('font-weight', TITLE_FONT_WEIGHT)
466
- .attr('fill', palette.text)
467
- .text(title);
468
- }
469
-
470
- // ── Tag legend (interactive) ────────────────────────────
471
-
472
- function drawLegend() {
473
- svg.selectAll('.gantt-tag-legend-container').remove();
474
- if (resolved.tagGroups.length > 0 || hasCriticalPath || hasDependencies) {
475
- const legendY = sTitleHeight;
476
- renderTagLegend(
477
- svg,
478
- g,
479
- resolved.tagGroups,
480
- currentActiveGroup,
481
- leftMargin,
482
- innerWidth,
483
- legendY,
484
- palette,
485
- isDark,
486
- hasCriticalPath,
487
- criticalPathActive,
488
- resolved.options.optionLineNumbers,
489
- (groupName) => {
490
- // Toggle active group
491
- currentActiveGroup =
492
- currentActiveGroup?.toLowerCase() === groupName.toLowerCase()
493
- ? null
494
- : groupName;
495
- if (onActiveGroupChange) onActiveGroupChange(currentActiveGroup);
496
- drawLegend();
497
- recolorBars();
498
- },
499
- () => {
500
- controlsExpanded = !controlsExpanded;
501
- drawLegend();
502
- },
503
- currentSwimlaneGroup,
504
- onSwimlaneChange,
505
- viewMode,
506
- resolved.tasks,
507
- controlsExpanded,
508
- hasDependencies,
509
- dependenciesActive,
510
- (toggleId, active) => {
511
- if (toggleId === 'critical-path') {
512
- criticalPathActive = active;
513
- if (active) {
514
- applyCriticalPathHighlight(svg, g);
515
- } else {
516
- svg.attr('data-critical-path-active', null);
517
- resetHighlightAll(svg, g);
518
- }
519
- } else if (toggleId === 'dependencies') {
520
- dependenciesActive = active;
521
- // Show/hide dependency arrows
522
- g.selectAll<SVGElement, unknown>(
523
- '.gantt-dep-arrow, .gantt-dep-arrowhead, .gantt-dep-label'
524
- ).attr('display', active ? null : 'none');
525
- }
526
- drawLegend();
527
- },
528
- options?.exportMode ?? false,
529
- controlsHost
530
- );
531
- }
532
- }
533
-
534
- function restoreHighlight() {
535
- if (criticalPathActive) {
536
- applyCriticalPathHighlight(svg, g);
537
- } else {
538
- svg.attr('data-critical-path-active', null);
539
- resetHighlight(g, svg);
540
- }
541
- }
542
-
543
- function recolorBars() {
544
- g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
545
- const el = d3Selection.select(this);
546
- const taskId = el.attr('data-task-id');
547
- const rt = resolved.tasks.find((t) => t.task.id === taskId);
548
- if (!rt) return;
549
- const color = resolveTaskColor(
550
- rt,
551
- currentActiveGroup,
552
- resolved,
553
- seriesColors,
554
- palette
555
- );
556
- const fillColor = shapeFill(palette, color, isDark, { solid });
557
- el.select('rect').attr('fill', fillColor).attr('stroke', color);
558
- });
559
- }
560
-
561
- drawLegend();
562
-
563
- // ── Time scale ──────────────────────────────────────────
564
-
565
- const startTime = dateToFractionalYear(resolved.startDate);
566
- const endTime = dateToFractionalYear(resolved.endDate);
567
-
568
- // Add small padding to domain
569
- const domainPad = Math.max((endTime - startTime) * 0.02, 0.01);
570
- const domainMin = startTime - domainPad;
571
- const domainMax = endTime + domainPad;
572
-
573
- const xScale = d3Scale
574
- .scaleLinear()
575
- .domain([domainMin, domainMax])
576
- .range([0, innerWidth]);
577
-
578
- // Render time scale ticks (bottom only)
579
- renderTimeScaleHorizontal(g, xScale, innerWidth, sInnerHeight, palette.text);
580
-
581
- renderWeekendBands(g, resolved, xScale, sInnerHeight, palette, isDark);
582
- renderHolidayBands(
583
- g,
584
- svg,
585
- resolved,
586
- xScale,
587
- sInnerHeight,
588
- palette,
589
- isDark,
590
- sMarginTop - 4,
591
- leftMargin,
592
- onClickItem
593
- );
594
- renderErasAndMarkers(
595
- g,
596
- svg,
597
- resolved,
598
- xScale,
599
- sInnerHeight,
600
- palette,
601
- sEraLabelY,
602
- sMarkerLabelY
603
- );
604
- renderSprintBands(g, svg, resolved, xScale, sInnerHeight, palette);
605
-
606
- // ── Today marker (line rendered before rows so it paints behind task bars) ──
607
-
608
- const todayDate: Date | null = resolvedTodayDate;
609
- let todayX = -1;
610
- const todayColor = palette.accent || '#e74c3c';
611
- const todayMarkerLineNum = resolved.options.optionLineNumbers['today-marker'];
612
- if (todayDate) {
613
- todayX = xScale(dateToFractionalYear(todayDate));
614
- if (todayX >= 0 && todayX <= innerWidth) {
615
- const todayLine = g
616
- .append('line')
617
- .attr('class', 'gantt-today')
618
- .attr('x1', todayX)
619
- .attr('y1', 0)
620
- .attr('x2', todayX)
621
- .attr('y2', sInnerHeight + 10)
622
- .attr('stroke', todayColor)
623
- .attr('stroke-width', 2)
624
- .attr('stroke-dasharray', '6 4')
625
- .attr('opacity', 0.7)
626
- .attr('pointer-events', 'none');
627
- if (todayMarkerLineNum)
628
- todayLine.attr('data-line-number', String(todayMarkerLineNum));
629
-
630
- const todayLabel = g
631
- .append('text')
632
- .attr('class', 'gantt-today')
633
- .attr('x', todayX)
634
- .attr('y', sTodayLabelY)
635
- .attr('text-anchor', 'middle')
636
- .attr('font-size', `${sFont10}px`)
637
- .attr('fill', todayColor)
638
- .attr('opacity', 0.7)
639
- .attr('pointer-events', 'none')
640
- .text('Today');
641
- if (todayMarkerLineNum)
642
- todayLabel.attr('data-line-number', String(todayMarkerLineNum));
643
- }
644
- }
645
-
646
- // ── Render rows ─────────────────────────────────────────
647
-
648
- // Track task positions for dependency arrows
649
- const taskPositions = new Map<
650
- string,
651
- { x1: number; x2: number; y: number }
652
- >();
653
- // Track collapsed group bar positions so hidden-task arrows redirect there
654
- const groupPositions = new Map<
655
- string,
656
- { x1: number; x2: number; y: number }
657
- >();
658
- // Track lane header positions for collapsed lane arrow redirection (tag mode)
659
- const lanePositions = new Map<
660
- string,
661
- { x1: number; x2: number; y: number }
662
- >();
663
- // Map task ID → lane name for collapsed lane lookup (tag mode)
664
- const taskLaneMap = new Map<string, string>();
665
- if (isTagMode && currentSwimlaneGroup) {
666
- const tagGroup = resolved.tagGroups.find(
667
- (tg) => tg.name.toLowerCase() === currentSwimlaneGroup.toLowerCase()
668
- );
669
- if (tagGroup) {
670
- const tagKey = tagAttrKey(tagGroup.name);
671
- for (const rt of resolved.tasks) {
672
- let value = rt.effectiveMetadata[tagKey];
673
- if (!value && tagGroup.defaultValue) value = tagGroup.defaultValue;
674
- if (value) {
675
- const entry = tagGroup.entries.find(
676
- (e) => e.value.toLowerCase() === value!.toLowerCase()
677
- );
678
- if (entry) taskLaneMap.set(rt.task.id, entry.value);
679
- }
680
- }
681
- }
682
- }
683
- let yOffset = sContentTopPad;
684
-
685
- for (const row of rows) {
686
- if (row.type === 'lane-header') {
687
- const laneColor =
688
- row.laneColor === '#999999' ? palette.textMuted : row.laneColor;
689
- const toggleIcon = row.isCollapsed ? '▶' : '▼';
690
- const labelX = 10;
691
-
692
- let lx1 = 0;
693
- let lx2 = innerWidth; // eslint-disable-line no-useless-assignment
694
- let laneBarWidth = innerWidth;
695
- if (row.laneStartDate && row.laneEndDate) {
696
- lx1 = xScale(dateToFractionalYear(row.laneStartDate));
697
- lx2 = xScale(dateToFractionalYear(row.laneEndDate));
698
- laneBarWidth = Math.max(lx2 - lx1, 2);
699
- }
700
-
701
- lanePositions.set(row.laneName, {
702
- x1: lx1,
703
- x2: lx1 + laneBarWidth,
704
- y: yOffset + sBarH / 2,
705
- });
706
-
707
- renderLabelBand(
708
- svg,
709
- sMarginTop + yOffset + sBarH / 2,
710
- leftMargin,
711
- laneColor,
712
- palette,
713
- isDark,
714
- 'lane',
715
- { key: 'data-lane', value: row.laneName },
716
- solid,
717
- sBarH,
718
- sBandRadius,
719
- sBandAccentW
720
- );
721
- const labelG = svg
722
- .append('g')
723
- .attr('class', 'gantt-lane-header')
724
- .attr(`data-tag-${row.tagKey}`, row.laneName.toLowerCase())
725
- .attr('data-lane', row.laneName)
726
- .style('cursor', onToggleLane ? 'pointer' : 'default')
727
- .on('click', () => {
728
- if (onToggleLane) onToggleLane(row.laneName);
729
- })
730
- .on('mouseenter', () => {
731
- highlightLane(g, svg, row.tagKey, row.laneName);
732
- if (row.laneStartDate && row.laneEndDate) {
733
- showGanttDateIndicators(
734
- g,
735
- xScale,
736
- row.laneStartDate,
737
- row.laneEndDate,
738
- sInnerHeight,
739
- laneColor
740
- );
741
- }
742
- })
743
- .on('mouseleave', () => {
744
- restoreHighlight();
745
- hideGanttDateIndicators(g);
746
- });
747
-
748
- labelG
749
- .append('text')
750
- .attr('x', labelX)
751
- .attr('y', sMarginTop + yOffset + sBarH / 2)
752
- .attr('dy', '0.35em')
753
- .attr('text-anchor', 'start')
754
- .attr('font-size', `${sFont11}px`)
755
- .attr('font-weight', 'bold')
756
- .attr('fill', laneColor)
757
- .text(
758
- toggleIcon +
759
- ' ' +
760
- row.laneName +
761
- (row.aggregateProgress !== null
762
- ? ` ${Math.round(row.aggregateProgress)}%`
763
- : '')
764
- );
765
-
766
- if (laneBarWidth > 0) {
767
- const barFill = shapeFill(palette, laneColor, isDark, { solid });
768
- const laneBandG = g
769
- .append('g')
770
- .attr('class', 'gantt-lane-band-group')
771
- .attr('data-lane', row.laneName)
772
- .on('mouseenter', () => {
773
- highlightLane(g, svg, row.tagKey, row.laneName);
774
- if (row.laneStartDate && row.laneEndDate) {
775
- showGanttDateIndicators(
776
- g,
777
- xScale,
778
- row.laneStartDate,
779
- row.laneEndDate,
780
- sInnerHeight,
781
- laneColor
782
- );
783
- }
784
- })
785
- .on('mouseleave', () => {
786
- resetHighlight(g, svg);
787
- hideGanttDateIndicators(g);
788
- });
789
-
790
- laneBandG
791
- .append('rect')
792
- .attr('class', 'gantt-lane-band')
793
- .attr('x', lx1)
794
- .attr('y', yOffset)
795
- .attr('width', laneBarWidth)
796
- .attr('height', sBarH)
797
- .attr('rx', 4)
798
- .attr('fill', barFill)
799
- .attr('stroke', laneColor)
800
- .attr('stroke-width', 2);
801
-
802
- if (row.aggregateProgress !== null && row.aggregateProgress > 0) {
803
- laneBandG
804
- .append('rect')
805
- .attr('class', 'gantt-lane-progress')
806
- .attr('x', lx1)
807
- .attr('y', yOffset)
808
- .attr(
809
- 'width',
810
- laneBarWidth * Math.min(row.aggregateProgress / 100, 1)
811
- )
812
- .attr('height', sBarH)
813
- .attr('fill', laneColor)
814
- .attr('opacity', 0.5)
815
- .attr('pointer-events', 'none');
816
- }
817
- }
818
-
819
- yOffset += sBarH + sRowGap;
820
- } else if (row.type === 'group') {
821
- const group = row.group;
822
- const isCollapsed = collapsedGroups?.has(group.name) ?? false;
823
- const toggleIcon = isCollapsed ? '▶' : '▼';
824
-
825
- // Group label with toggle — resolve tag color from group metadata
826
- const tagColor = resolveTagColor(
827
- group.metadata,
828
- resolved.tagGroups,
829
- currentActiveGroup,
830
- true
831
- );
832
- const groupColor =
833
- tagColor && tagColor !== '#999999'
834
- ? tagColor
835
- : group.color || palette.textMuted;
836
- renderLabelBand(
837
- svg,
838
- sMarginTop + yOffset + sBarH / 2,
839
- leftMargin,
840
- groupColor,
841
- palette,
842
- isDark,
843
- 'group',
844
- { key: 'data-group', value: group.name },
845
- solid,
846
- sBarH,
847
- sBandRadius,
848
- sBandAccentW
849
- );
850
- const labelG = svg
851
- .append('g')
852
- .attr('class', 'gantt-group-label')
853
- .attr('data-group', group.name)
854
- .attr('data-line-number', String(group.lineNumber))
855
- .style('cursor', onToggleGroup ? 'pointer' : 'default')
856
- .on('click', () => {
857
- if (onToggleGroup) onToggleGroup(group.name);
858
- })
859
- .on('mouseenter', () => {
860
- highlightGroup(g, svg, group.name);
861
- showGanttDateIndicators(
862
- g,
863
- xScale,
864
- group.startDate,
865
- group.endDate,
866
- sInnerHeight,
867
- groupColor
868
- );
869
- })
870
- .on('mouseleave', () => {
871
- resetHighlight(g, svg);
872
- hideGanttDateIndicators(g);
873
- });
874
-
875
- const groupIndent =
876
- group.depth <= 2 ? group.depth * 14 : 2 * 14 + (group.depth - 2) * 8;
877
- const labelX = 10 + groupIndent;
878
- labelG
879
- .append('text')
880
- .attr('x', labelX)
881
- .attr('y', sMarginTop + yOffset + sBarH / 2)
882
- .attr('dy', '0.35em')
883
- .attr('text-anchor', 'start')
884
- .attr('font-size', `${sFont11}px`)
885
- .attr('font-weight', 'bold')
886
- .attr('fill', palette.text)
887
- .text(
888
- toggleIcon +
889
- ' ' +
890
- group.name +
891
- (group.progress !== null ? ` ${Math.round(group.progress)}%` : '')
892
- );
893
-
894
- // Group bar
895
- const gStart = dateToFractionalYear(group.startDate);
896
- const gEnd = dateToFractionalYear(group.endDate);
897
- const gx1 = xScale(gStart);
898
- const gx2 = xScale(gEnd);
899
-
900
- if (gx2 > gx1) {
901
- if (isCollapsed) {
902
- // Summary bar (full height, shows aggregate progress)
903
- const barWidth = Math.max(gx2 - gx1, 2);
904
- const summaryG = g
905
- .append('g')
906
- .attr('class', 'gantt-group-summary')
907
- .attr('data-group', group.name)
908
- .attr('data-line-number', String(group.lineNumber))
909
- .on('mouseenter', () => {
910
- highlightGroup(g, svg, group.name);
911
- showGanttDateIndicators(
912
- g,
913
- xScale,
914
- group.startDate,
915
- group.endDate,
916
- sInnerHeight,
917
- groupColor
918
- );
919
- })
920
- .on('mouseleave', () => {
921
- resetHighlight(g, svg);
922
- hideGanttDateIndicators(g);
923
- });
924
-
925
- summaryG
926
- .append('rect')
927
- .attr('x', gx1)
928
- .attr('y', yOffset)
929
- .attr('width', barWidth)
930
- .attr('height', sBarH)
931
- .attr('rx', 4)
932
- .attr('fill', shapeFill(palette, groupColor, isDark, { solid }))
933
- .attr('stroke', groupColor)
934
- .attr('stroke-width', 2);
935
-
936
- if (group.progress !== null && group.progress > 0) {
937
- summaryG
938
- .append('rect')
939
- .attr('x', gx1)
940
- .attr('y', yOffset)
941
- .attr('width', barWidth * Math.min(group.progress / 100, 1))
942
- .attr('height', sBarH)
943
- .attr('fill', groupColor)
944
- .attr('opacity', 0.5);
945
- }
946
-
947
- const summaryLabel =
948
- group.name +
949
- (group.progress !== null ? ` ${Math.round(group.progress)}%` : '');
950
- const summaryPlacement = computeBarLabel(
951
- summaryLabel,
952
- gx1,
953
- barWidth,
954
- innerWidth,
955
- palette.text,
956
- contrastText(
957
- shapeFill(palette, groupColor, isDark, { solid }),
958
- palette.textOnFillLight,
959
- palette.textOnFillDark
960
- ),
961
- sFont10,
962
- sLabelPad,
963
- sLabelGap
964
- );
965
- if (summaryPlacement) {
966
- summaryG
967
- .append('text')
968
- .attr('x', summaryPlacement.x)
969
- .attr('y', yOffset + sBarH / 2)
970
- .attr('dy', '0.35em')
971
- .attr('font-size', `${sFont10}px`)
972
- .attr('font-weight', 'bold')
973
- .attr('text-anchor', summaryPlacement.anchor)
974
- .attr('fill', summaryPlacement.fill)
975
- .attr('pointer-events', 'none')
976
- .text(summaryPlacement.text);
977
- }
978
-
979
- groupPositions.set(group.name, {
980
- x1: gx1,
981
- x2: gx1 + barWidth,
982
- y: yOffset + sBarH / 2,
983
- });
984
- } else {
985
- const groupBarWidth = Math.max(gx2 - gx1, 2);
986
- const bandFill = shapeFill(palette, groupColor, isDark, { solid });
987
- const groupBarG = g
988
- .append('g')
989
- .attr('class', 'gantt-group-bar')
990
- .attr('data-group', group.name)
991
- .attr('data-line-number', String(group.lineNumber))
992
- .on('mouseenter', () => {
993
- highlightGroup(g, svg, group.name);
994
- showGanttDateIndicators(
995
- g,
996
- xScale,
997
- group.startDate,
998
- group.endDate,
999
- sInnerHeight,
1000
- groupColor
1001
- );
1002
- })
1003
- .on('mouseleave', () => {
1004
- resetHighlight(g, svg);
1005
- hideGanttDateIndicators(g);
1006
- });
1007
-
1008
- groupBarG
1009
- .append('rect')
1010
- .attr('x', gx1)
1011
- .attr('y', yOffset)
1012
- .attr('width', groupBarWidth)
1013
- .attr('height', sBarH)
1014
- .attr('rx', 4)
1015
- .attr('fill', bandFill)
1016
- .attr('stroke', groupColor)
1017
- .attr('stroke-width', 2);
1018
-
1019
- if (group.progress !== null && group.progress > 0) {
1020
- groupBarG
1021
- .append('rect')
1022
- .attr('class', 'gantt-group-progress')
1023
- .attr('x', gx1)
1024
- .attr('y', yOffset)
1025
- .attr('width', groupBarWidth * Math.min(group.progress / 100, 1))
1026
- .attr('height', sBarH)
1027
- .attr('fill', groupColor)
1028
- .attr('opacity', 0.5);
1029
- }
1030
-
1031
- const expandedLabel =
1032
- group.name +
1033
- (group.progress !== null ? ` ${Math.round(group.progress)}%` : '');
1034
- const expandedPlacement = computeBarLabel(
1035
- expandedLabel,
1036
- gx1,
1037
- groupBarWidth,
1038
- innerWidth,
1039
- palette.text,
1040
- contrastText(
1041
- shapeFill(palette, groupColor, isDark, { solid }),
1042
- palette.textOnFillLight,
1043
- palette.textOnFillDark
1044
- ),
1045
- sFont10,
1046
- sLabelPad,
1047
- sLabelGap
1048
- );
1049
- if (expandedPlacement) {
1050
- groupBarG
1051
- .append('text')
1052
- .attr('x', expandedPlacement.x)
1053
- .attr('y', yOffset + sBarH / 2)
1054
- .attr('dy', '0.35em')
1055
- .attr('font-size', `${sFont10}px`)
1056
- .attr('font-weight', 'bold')
1057
- .attr('text-anchor', expandedPlacement.anchor)
1058
- .attr('fill', expandedPlacement.fill)
1059
- .attr('pointer-events', 'none')
1060
- .text(expandedPlacement.text);
1061
- }
1062
- }
1063
- }
1064
-
1065
- yOffset += sBarH + sRowGap;
1066
- } else if (row.type === 'task') {
1067
- const rt = row.task;
1068
- const task = rt.task;
1069
-
1070
- const barColor = resolveTaskColor(
1071
- rt,
1072
- currentActiveGroup,
1073
- resolved,
1074
- seriesColors,
1075
- palette
1076
- );
1077
-
1078
- const depth = rt.groupPath.length;
1079
- const indent = depth <= 2 ? depth * 14 : 2 * 14 + (depth - 2) * 8;
1080
- const taskLabelX = isTagMode ? 20 : 6 + indent;
1081
- const topGroup = rt.groupPath.length > 0 ? rt.groupPath[0]! : null;
1082
- const taskLabel = svg
1083
- .append('text')
1084
- .attr('class', 'gantt-task-label')
1085
- .attr('x', taskLabelX)
1086
- .attr('y', sMarginTop + yOffset + sBarH / 2)
1087
- .attr('dy', '0.35em')
1088
- .attr('text-anchor', 'start')
1089
- .attr('font-size', `${sFont11}px`)
1090
- .attr('fill', palette.text)
1091
- .attr('data-line-number', String(task.lineNumber))
1092
- .attr('data-task-id', task.id)
1093
- .attr('data-group', topGroup)
1094
- .style('cursor', onClickItem ? 'pointer' : 'default')
1095
- .on('click', () => {
1096
- if (onClickItem) onClickItem(task.lineNumber);
1097
- })
1098
- .on('mouseenter', () => {
1099
- if (rt.isMilestone) {
1100
- highlightMilestone(g, svg, task.id);
1101
- } else {
1102
- highlightTask(g, svg, task.id);
1103
- }
1104
- })
1105
- .on('mouseleave', () => {
1106
- resetHighlight(g, svg);
1107
- });
1108
-
1109
- appendTaskIcon(
1110
- taskLabel,
1111
- task.label,
1112
- rt.isMilestone,
1113
- barColor,
1114
- palette.text
1115
- );
1116
-
1117
- // Tag attributes on label for legend hover matching
1118
- for (const [key, value] of Object.entries(rt.effectiveMetadata)) {
1119
- taskLabel.attr(`data-tag-${key}`, value.toLowerCase());
1120
- }
1121
- if (rt.isCriticalPath) {
1122
- taskLabel.attr('data-critical-path', 'true');
1123
- }
1124
-
1125
- if (rt.isMilestone) {
1126
- const mx = xScale(dateToFractionalYear(rt.startDate));
1127
- const my = yOffset + sBarH / 2;
1128
- g.append('polygon')
1129
- .attr('class', 'gantt-milestone')
1130
- .attr('points', diamondPoints(mx, my, sMilestoneSize))
1131
- .attr('fill', barColor)
1132
- .attr('stroke', barColor)
1133
- .attr('stroke-width', 1.5)
1134
- .attr('data-line-number', String(task.lineNumber))
1135
- .attr('data-task-name', task.label)
1136
- .attr('data-task-id', task.id)
1137
- .attr('data-group', topGroup)
1138
- .style('cursor', onClickItem ? 'pointer' : 'default')
1139
- .on('click', () => {
1140
- if (onClickItem) onClickItem(task.lineNumber);
1141
- })
1142
- .on('mouseenter', () => {
1143
- highlightMilestone(g, svg, task.id);
1144
- showGanttDateIndicators(
1145
- g,
1146
- xScale,
1147
- rt.startDate,
1148
- null,
1149
- sInnerHeight,
1150
- barColor
1151
- );
1152
- g.append('text')
1153
- .attr('class', 'gantt-milestone-hover-label')
1154
- .attr('x', mx - sMilestoneSize - 4)
1155
- .attr('y', my)
1156
- .attr('dy', '0.35em')
1157
- .attr('text-anchor', 'end')
1158
- .attr('font-size', `${sFont10}px`)
1159
- .attr('fill', barColor)
1160
- .attr('font-weight', '600')
1161
- .text(task.label);
1162
- })
1163
- .on('mouseleave', () => {
1164
- restoreHighlight();
1165
- hideGanttDateIndicators(g);
1166
- g.selectAll('.gantt-milestone-hover-label').remove();
1167
- });
1168
-
1169
- taskPositions.set(task.id, { x1: mx, x2: mx, y: my });
1170
- } else {
1171
- // Render bar
1172
- const tStart = dateToFractionalYear(rt.startDate);
1173
- const tEnd = dateToFractionalYear(rt.endDate);
1174
- const x1 = xScale(tStart);
1175
- const x2 = xScale(tEnd);
1176
- const barWidth = Math.max(x2 - x1, 2);
1177
-
1178
- const fillColor = shapeFill(palette, barColor, isDark, { solid });
1179
-
1180
- const taskG = g
1181
- .append('g')
1182
- .attr('class', 'gantt-task')
1183
- .attr('data-line-number', String(task.lineNumber))
1184
- .attr('data-task-name', task.label)
1185
- .attr('data-task-id', task.id)
1186
- .attr('data-group', topGroup)
1187
- .style('cursor', onClickItem ? 'pointer' : 'default')
1188
- .on('click', () => {
1189
- if (onClickItem) onClickItem(task.lineNumber);
1190
- })
1191
- .on('mouseenter', () => {
1192
- if (resolved.options.dependencies) {
1193
- highlightDeps(g, svg, task.id, resolved);
1194
- }
1195
- highlightTaskLabel(svg, task.lineNumber);
1196
- showGanttDateIndicators(
1197
- g,
1198
- xScale,
1199
- rt.startDate,
1200
- rt.endDate,
1201
- sInnerHeight,
1202
- barColor
1203
- );
1204
- })
1205
- .on('mouseleave', () => {
1206
- if (resolved.options.dependencies) {
1207
- restoreHighlight();
1208
- }
1209
- resetTaskLabels(svg);
1210
- hideGanttDateIndicators(g);
1211
- });
1212
-
1213
- // Set tag attributes
1214
- for (const [key, value] of Object.entries(rt.effectiveMetadata)) {
1215
- taskG.attr(`data-tag-${key}`, value.toLowerCase());
1216
- }
1217
-
1218
- // Uncertainty gradient — fade out the trailing edge unless progress > 80%
1219
- const showUncertainFade =
1220
- rt.isUncertain && (task.progress === null || task.progress <= 80);
1221
- let barFill: string = fillColor;
1222
- let barStroke: string = barColor;
1223
- if (showUncertainFade) {
1224
- const defs = svg.select('defs').empty()
1225
- ? svg.append('defs')
1226
- : svg.select<SVGDefsElement>('defs');
1227
-
1228
- const fillGradId = `gantt-uncertain-fill-${task.id}`;
1229
- const fillGrad = defs
1230
- .append('linearGradient')
1231
- .attr('id', fillGradId)
1232
- .attr('x1', '0')
1233
- .attr('x2', '1')
1234
- .attr('y1', '0')
1235
- .attr('y2', '0');
1236
- fillGrad
1237
- .append('stop')
1238
- .attr('offset', '0%')
1239
- .attr('stop-color', fillColor)
1240
- .attr('stop-opacity', 1);
1241
- fillGrad
1242
- .append('stop')
1243
- .attr('offset', '50%')
1244
- .attr('stop-color', fillColor)
1245
- .attr('stop-opacity', 1);
1246
- fillGrad
1247
- .append('stop')
1248
- .attr('offset', '100%')
1249
- .attr('stop-color', fillColor)
1250
- .attr('stop-opacity', 0);
1251
-
1252
- const strokeGradId = `gantt-uncertain-stroke-${task.id}`;
1253
- const strokeGrad = defs
1254
- .append('linearGradient')
1255
- .attr('id', strokeGradId)
1256
- .attr('x1', '0')
1257
- .attr('x2', '1')
1258
- .attr('y1', '0')
1259
- .attr('y2', '0');
1260
- strokeGrad
1261
- .append('stop')
1262
- .attr('offset', '0%')
1263
- .attr('stop-color', barColor)
1264
- .attr('stop-opacity', 1);
1265
- strokeGrad
1266
- .append('stop')
1267
- .attr('offset', '50%')
1268
- .attr('stop-color', barColor)
1269
- .attr('stop-opacity', 1);
1270
- strokeGrad
1271
- .append('stop')
1272
- .attr('offset', '100%')
1273
- .attr('stop-color', barColor)
1274
- .attr('stop-opacity', 0);
1275
-
1276
- barFill = `url(#${fillGradId})`;
1277
- barStroke = `url(#${strokeGradId})`;
1278
- }
1279
-
1280
- taskG
1281
- .append('rect')
1282
- .attr('x', x1)
1283
- .attr('y', yOffset)
1284
- .attr('width', barWidth)
1285
- .attr('height', sBarH)
1286
- .attr('rx', 4)
1287
- .attr('fill', barFill)
1288
- .attr('stroke', barStroke)
1289
- .attr('stroke-width', 2);
1290
-
1291
- if (task.progress !== null && task.progress > 0) {
1292
- const progressWidth = barWidth * Math.min(task.progress / 100, 1);
1293
- let progressFill: string = barColor;
1294
- if (showUncertainFade) {
1295
- const ratio = barWidth / progressWidth;
1296
- const fadeStart = Math.min(50 * ratio, 100);
1297
- const defs = svg.select<SVGDefsElement>('defs');
1298
- const progGradId = `gantt-uncertain-progress-${task.id}`;
1299
- const progGrad = defs
1300
- .append('linearGradient')
1301
- .attr('id', progGradId)
1302
- .attr('x1', '0')
1303
- .attr('x2', '1')
1304
- .attr('y1', '0')
1305
- .attr('y2', '0');
1306
- progGrad
1307
- .append('stop')
1308
- .attr('offset', '0%')
1309
- .attr('stop-color', barColor)
1310
- .attr('stop-opacity', 1);
1311
- progGrad
1312
- .append('stop')
1313
- .attr('offset', `${fadeStart}%`)
1314
- .attr('stop-color', barColor)
1315
- .attr('stop-opacity', 1);
1316
- progGrad
1317
- .append('stop')
1318
- .attr('offset', '100%')
1319
- .attr('stop-color', barColor)
1320
- .attr('stop-opacity', 0);
1321
- progressFill = `url(#${progGradId})`;
1322
- }
1323
- taskG
1324
- .append('rect')
1325
- .attr('class', 'gantt-progress')
1326
- .attr('x', x1)
1327
- .attr('y', yOffset)
1328
- .attr('width', progressWidth)
1329
- .attr('height', sBarH)
1330
- .attr('fill', progressFill)
1331
- .attr('opacity', 0.5);
1332
- }
1333
-
1334
- if (rt.isCriticalPath) {
1335
- taskG.attr('data-critical-path', 'true');
1336
- }
1337
-
1338
- const labelPlacement = computeBarLabel(
1339
- task.label,
1340
- x1,
1341
- barWidth,
1342
- innerWidth,
1343
- palette.text,
1344
- contrastText(
1345
- shapeFill(palette, barColor, isDark, { solid }),
1346
- palette.textOnFillLight,
1347
- palette.textOnFillDark
1348
- ),
1349
- sFont10,
1350
- sLabelPad,
1351
- sLabelGap
1352
- );
1353
- if (labelPlacement) {
1354
- taskG
1355
- .append('text')
1356
- .attr('x', labelPlacement.x)
1357
- .attr('y', yOffset + sBarH / 2)
1358
- .attr('dy', '0.35em')
1359
- .attr('font-size', `${sFont10}px`)
1360
- .attr('text-anchor', labelPlacement.anchor)
1361
- .attr('fill', labelPlacement.fill)
1362
- .attr('pointer-events', 'none')
1363
- .text(labelPlacement.text);
1364
- }
1365
-
1366
- taskPositions.set(task.id, {
1367
- x1,
1368
- x2: x1 + barWidth,
1369
- y: yOffset + sBarH / 2,
1370
- });
1371
- }
1372
-
1373
- yOffset += sBarH + sRowGap;
1374
- }
1375
- }
1376
-
1377
- if (todayDate && todayX >= 0 && todayX <= innerWidth) {
1378
- const todayHoverG = g
1379
- .append('g')
1380
- .attr('class', 'gantt-today-hover')
1381
- .style('cursor', 'pointer');
1382
-
1383
- todayHoverG
1384
- .append('rect')
1385
- .attr('x', todayX - 10)
1386
- .attr('y', -6)
1387
- .attr('width', 20)
1388
- .attr('height', sInnerHeight + 16)
1389
- .attr('fill', 'transparent')
1390
- .attr('pointer-events', 'all');
1391
-
1392
- const todayDateObj = todayDate;
1393
- todayHoverG
1394
- .on('mouseenter', () => {
1395
- // Fade everything
1396
- g.selectAll<SVGGElement, unknown>('.gantt-task').attr(
1397
- 'opacity',
1398
- FADE_OPACITY
1399
- );
1400
- g.selectAll<SVGElement, unknown>('.gantt-milestone').attr(
1401
- 'opacity',
1402
- FADE_OPACITY
1403
- );
1404
- g.selectAll<SVGElement, unknown>(
1405
- '.gantt-group-bar, .gantt-group-summary'
1406
- ).attr('opacity', FADE_OPACITY);
1407
- svg
1408
- .selectAll<SVGGElement, unknown>('.gantt-group-label')
1409
- .attr('opacity', FADE_OPACITY);
1410
- svg
1411
- .selectAll<SVGTextElement, unknown>('.gantt-task-label')
1412
- .attr('opacity', FADE_OPACITY);
1413
- svg
1414
- .selectAll<SVGGElement, unknown>('.gantt-lane-header')
1415
- .attr('opacity', FADE_OPACITY);
1416
- g.selectAll<SVGElement, unknown>(
1417
- '.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
1418
- ).attr('opacity', FADE_OPACITY);
1419
- g.selectAll<SVGElement, unknown>(
1420
- '.gantt-dep-arrow, .gantt-dep-arrowhead'
1421
- ).attr('opacity', FADE_OPACITY);
1422
- g.selectAll<SVGElement, unknown>('.gantt-era-group').attr(
1423
- 'opacity',
1424
- FADE_OPACITY
1425
- );
1426
- g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
1427
- 'opacity',
1428
- FADE_OPACITY
1429
- );
1430
- showGanttDateIndicators(
1431
- g,
1432
- xScale,
1433
- todayDateObj,
1434
- null,
1435
- sInnerHeight,
1436
- todayColor
1437
- );
1438
- })
1439
- .on('mouseleave', () => {
1440
- resetHighlight(g, svg);
1441
- hideGanttDateIndicators(g);
1442
- });
1443
- }
1444
-
1445
- // ── Dependency arrows ───────────────────────────────────
1446
-
1447
- if (resolved.options.dependencies) {
1448
- renderDependencyArrows(
1449
- g,
1450
- resolved,
1451
- taskPositions,
1452
- groupPositions,
1453
- collapsedGroups,
1454
- palette,
1455
- isDark,
1456
- isTagMode,
1457
- lanePositions,
1458
- collapsedLanes,
1459
- taskLaneMap
1460
- );
1461
- if (!dependenciesActive) {
1462
- g.selectAll<SVGElement, unknown>(
1463
- '.gantt-dep-arrow, .gantt-dep-arrowhead, .gantt-dep-label'
1464
- ).attr('display', 'none');
1465
- }
1466
- }
1467
- }
1468
-
1469
- // ── Weekend Band Rendering ──────────────────────────────────
1470
-
1471
- const JS_DAY_TO_WEEKDAY: Weekday[] = [
1472
- 'sun',
1473
- 'mon',
1474
- 'tue',
1475
- 'wed',
1476
- 'thu',
1477
- 'fri',
1478
- 'sat',
1479
- ];
1480
-
1481
- function renderWeekendBands(
1482
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1483
- resolved: ResolvedSchedule,
1484
- xScale: d3Scale.ScaleLinear<number, number>,
1485
- innerHeight: number,
1486
- palette: PaletteColors,
1487
- isDark: boolean
1488
- ): void {
1489
- const workweek = new Set(resolved.holidays.workweek);
1490
- const start = new Date(resolved.startDate);
1491
- start.setDate(start.getDate() - 1); // start one day before
1492
- const end = new Date(resolved.endDate);
1493
- end.setDate(end.getDate() + 1); // end one day after
1494
-
1495
- const current = new Date(start);
1496
- let bandStart: Date | null = null;
1497
-
1498
- while (current <= end) {
1499
- // getDay() returns 0-6; JS_DAY_TO_WEEKDAY has 7 entries.
1500
- const dayName = JS_DAY_TO_WEEKDAY[current.getDay()]!;
1501
- const isWeekend = !workweek.has(dayName);
1502
-
1503
- if (isWeekend && !bandStart) {
1504
- bandStart = new Date(current);
1505
- } else if (!isWeekend && bandStart) {
1506
- // Draw band from bandStart to current
1507
- drawBand(
1508
- g,
1509
- xScale,
1510
- bandStart,
1511
- current,
1512
- innerHeight,
1513
- palette,
1514
- isDark,
1515
- 'gantt-weekend-band',
1516
- 0.04
1517
- );
1518
- bandStart = null;
1519
- }
1520
- current.setDate(current.getDate() + 1);
1521
- }
1522
- // Close any trailing band
1523
- if (bandStart) {
1524
- drawBand(
1525
- g,
1526
- xScale,
1527
- bandStart,
1528
- current,
1529
- innerHeight,
1530
- palette,
1531
- isDark,
1532
- 'gantt-weekend-band',
1533
- 0.04
1534
- );
1535
- }
1536
- }
1537
-
1538
- // ── Holiday Band Rendering ──────────────────────────────────
1539
-
1540
- function renderHolidayBands(
1541
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1542
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1543
- resolved: ResolvedSchedule,
1544
- xScale: d3Scale.ScaleLinear<number, number>,
1545
- innerHeight: number,
1546
- palette: PaletteColors,
1547
- isDark: boolean,
1548
- headerY: number,
1549
- chartLeftMargin: number,
1550
- onClickItem?: (lineNumber: number) => void
1551
- ): void {
1552
- for (const h of resolved.holidays.dates) {
1553
- const start = new Date(h.date + 'T00:00:00');
1554
- const end = new Date(start);
1555
- end.setDate(end.getDate() + 1);
1556
- drawHolidayBand(
1557
- g,
1558
- svg,
1559
- xScale,
1560
- start,
1561
- end,
1562
- innerHeight,
1563
- palette,
1564
- isDark,
1565
- h.label,
1566
- h.lineNumber,
1567
- headerY,
1568
- chartLeftMargin,
1569
- onClickItem
1570
- );
1571
- }
1572
-
1573
- for (const r of resolved.holidays.ranges) {
1574
- const start = new Date(r.startDate + 'T00:00:00');
1575
- const end = new Date(r.endDate + 'T00:00:00');
1576
- end.setDate(end.getDate() + 1);
1577
- drawHolidayBand(
1578
- g,
1579
- svg,
1580
- xScale,
1581
- start,
1582
- end,
1583
- innerHeight,
1584
- palette,
1585
- isDark,
1586
- r.label,
1587
- r.lineNumber,
1588
- headerY,
1589
- chartLeftMargin,
1590
- onClickItem
1591
- );
1592
- }
1593
- }
1594
-
1595
- function drawBand(
1596
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1597
- xScale: d3Scale.ScaleLinear<number, number>,
1598
- start: Date,
1599
- end: Date,
1600
- height: number,
1601
- palette: PaletteColors,
1602
- _isDark: boolean,
1603
- className: string,
1604
- opacity: number
1605
- ): void {
1606
- const x1 = xScale(dateToFractionalYear(start));
1607
- const x2 = xScale(dateToFractionalYear(end));
1608
- if (x2 <= x1) return;
1609
-
1610
- g.append('rect')
1611
- .attr('class', className)
1612
- .attr('x', x1)
1613
- .attr('y', 0)
1614
- .attr('width', x2 - x1)
1615
- .attr('height', height)
1616
- .attr('fill', palette.text)
1617
- .attr('opacity', opacity)
1618
- .attr('pointer-events', 'none');
1619
- }
1620
-
1621
- function drawHolidayBand(
1622
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1623
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1624
- xScale: d3Scale.ScaleLinear<number, number>,
1625
- start: Date,
1626
- end: Date,
1627
- height: number,
1628
- palette: PaletteColors,
1629
- _isDark: boolean,
1630
- label: string,
1631
- lineNumber: number,
1632
- headerY: number,
1633
- chartLeftMargin: number,
1634
- onClickItem?: (lineNumber: number) => void
1635
- ): void {
1636
- const x1 = xScale(dateToFractionalYear(start));
1637
- const x2 = xScale(dateToFractionalYear(end));
1638
- if (x2 <= x1) return;
1639
-
1640
- const bandW = Math.max(x2 - x1, 4);
1641
- const baseOpacity = 0.08;
1642
- const hoverOpacity = 0.18;
1643
-
1644
- const bandG = g
1645
- .append('g')
1646
- .attr('class', 'gantt-holiday-band')
1647
- .attr('data-line-number', String(lineNumber))
1648
- .style('cursor', onClickItem ? 'pointer' : 'default');
1649
-
1650
- // Band rect
1651
- const bandRect = bandG
1652
- .append('rect')
1653
- .attr('x', x1)
1654
- .attr('y', 0)
1655
- .attr('width', bandW)
1656
- .attr('height', height)
1657
- .attr('fill', palette.text)
1658
- .attr('opacity', baseOpacity);
1659
-
1660
- // Hover label in SVG-space (date header row) — hidden by default
1661
- // Background rect to mask date labels underneath
1662
- const labelX = chartLeftMargin + x1 + bandW / 2;
1663
- const textLen = measureText(label, 10) + 8;
1664
- const labelBg = svg
1665
- .append('rect')
1666
- .attr('class', 'gantt-holiday-hover-bg')
1667
- .attr('data-line-number', String(lineNumber))
1668
- .attr('x', labelX - textLen / 2)
1669
- .attr('y', headerY - 11)
1670
- .attr('width', textLen)
1671
- .attr('height', 14)
1672
- .attr('rx', 3)
1673
- .attr('fill', palette.bg)
1674
- .attr('opacity', 0)
1675
- .attr('pointer-events', 'none');
1676
-
1677
- const labelText = svg
1678
- .append('text')
1679
- .attr('class', 'gantt-holiday-hover-label')
1680
- .attr('data-line-number', String(lineNumber))
1681
- .attr('x', labelX)
1682
- .attr('y', headerY)
1683
- .attr('text-anchor', 'middle')
1684
- .attr('font-size', '10px')
1685
- .attr('font-weight', '500')
1686
- .attr('fill', palette.text)
1687
- .attr('opacity', 0)
1688
- .attr('pointer-events', 'none')
1689
- .text(label);
1690
-
1691
- // Hover: highlight band + show label in header + date indicators
1692
- bandG
1693
- .on('mouseenter', () => {
1694
- bandRect.attr('opacity', hoverOpacity);
1695
- labelBg.attr('opacity', 1);
1696
- labelText.attr('opacity', 1);
1697
- showGanttDateIndicators(g, xScale, start, end, height, palette.textMuted);
1698
- })
1699
- .on('mouseleave', () => {
1700
- bandRect.attr('opacity', baseOpacity);
1701
- labelBg.attr('opacity', 0);
1702
- labelText.attr('opacity', 0);
1703
- hideGanttDateIndicators(g);
1704
- })
1705
- .on('click', () => {
1706
- if (onClickItem) onClickItem(lineNumber);
1707
- });
1708
- }
1709
-
1710
- // ── Dependency Arrow Rendering ──────────────────────────────
1711
-
1712
- function findCollapsedGroupPos(
1713
- rt: ResolvedTask,
1714
- collapsedGroups: Set<string> | undefined,
1715
- groupPositions: Map<string, { x1: number; x2: number; y: number }>
1716
- ): { x1: number; x2: number; y: number } | undefined {
1717
- if (!collapsedGroups) return undefined;
1718
- // Walk the task's group path and find the first collapsed group with a position
1719
- for (const groupName of rt.groupPath) {
1720
- if (collapsedGroups.has(groupName)) {
1721
- return groupPositions.get(groupName);
1722
- }
1723
- }
1724
- return undefined;
1725
- }
1726
-
1727
- function findCollapsedLanePos(
1728
- rt: ResolvedTask,
1729
- collapsedLanes: Set<string> | undefined,
1730
- taskLaneMap: Map<string, string>,
1731
- lanePositions: Map<string, { x1: number; x2: number; y: number }>
1732
- ): { x1: number; x2: number; y: number } | undefined {
1733
- if (!collapsedLanes) return undefined;
1734
- const laneName = taskLaneMap.get(rt.task.id);
1735
- if (laneName && collapsedLanes.has(laneName)) {
1736
- return lanePositions.get(laneName);
1737
- }
1738
- return undefined;
1739
- }
1740
-
1741
- function renderDependencyArrows(
1742
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1743
- resolved: ResolvedSchedule,
1744
- taskPositions: Map<string, { x1: number; x2: number; y: number }>,
1745
- groupPositions: Map<string, { x1: number; x2: number; y: number }>,
1746
- collapsedGroups: Set<string> | undefined,
1747
- palette: PaletteColors,
1748
- _isDark: boolean,
1749
- isTagMode: boolean,
1750
- lanePositions: Map<string, { x1: number; x2: number; y: number }>,
1751
- collapsedLanes: Set<string> | undefined,
1752
- taskLaneMap: Map<string, string>
1753
- ): void {
1754
- // Deduplicate arrows that collapse to the same source→target position
1755
- const drawnArrows = new Set<string>();
1756
-
1757
- // Build arrow list from task dependencies
1758
- for (const rt of resolved.tasks) {
1759
- const sourcePos =
1760
- taskPositions.get(rt.task.id) ??
1761
- (isTagMode
1762
- ? findCollapsedLanePos(rt, collapsedLanes, taskLaneMap, lanePositions)
1763
- : findCollapsedGroupPos(rt, collapsedGroups, groupPositions));
1764
- if (!sourcePos) continue;
1765
-
1766
- for (const dep of rt.task.dependencies) {
1767
- // Find target task — forgiving normalization per universal name handling
1768
- const depKey = normalizeName(dep.targetName);
1769
- const targetTask = resolved.tasks.find(
1770
- (t) =>
1771
- normalizeName(t.task.label) === depKey ||
1772
- normalizeName(`${t.groupPath.join('.')}.${t.task.label}`).endsWith(
1773
- depKey
1774
- )
1775
- );
1776
- if (!targetTask) continue;
1777
-
1778
- const targetPos =
1779
- taskPositions.get(targetTask.task.id) ??
1780
- (isTagMode
1781
- ? findCollapsedLanePos(
1782
- targetTask,
1783
- collapsedLanes,
1784
- taskLaneMap,
1785
- lanePositions
1786
- )
1787
- : findCollapsedGroupPos(targetTask, collapsedGroups, groupPositions));
1788
- if (!targetPos) continue;
1789
-
1790
- // Skip self-arrows (both source and target collapsed to the same group)
1791
- if (sourcePos === targetPos) continue;
1792
-
1793
- // Deduplicate: multiple hidden tasks in the same collapsed group → same arrow
1794
- const arrowKey = `${sourcePos.x1},${sourcePos.y}->${targetPos.x1},${targetPos.y}`;
1795
- if (drawnArrows.has(arrowKey)) continue;
1796
- drawnArrows.add(arrowKey);
1797
-
1798
- // Arrow from source end to target start
1799
- const sx = sourcePos.x2;
1800
- const sy = sourcePos.y;
1801
- const tx = targetPos.x1;
1802
- const ty = targetPos.y;
1803
-
1804
- // Bezier curve with dy-scaled control points for cross-lane arrows
1805
- const dx = Math.abs(tx - sx);
1806
- const dy = Math.abs(ty - sy);
1807
- const cpOffset = Math.max(dx * 0.3, 15, dy * 0.4);
1808
-
1809
- const path = `M ${sx} ${sy} C ${sx + cpOffset} ${sy}, ${tx - cpOffset} ${ty}, ${tx} ${ty}`;
1810
-
1811
- const arrowColor = mix(palette.text, palette.bg, 50);
1812
- const isCpArrow = rt.isCriticalPath && targetTask.isCriticalPath;
1813
-
1814
- g.append('path')
1815
- .attr('class', 'gantt-dep-arrow')
1816
- .attr('data-dep-from', rt.task.id)
1817
- .attr('data-dep-to', targetTask.task.id)
1818
- .attr('data-line-number', String(dep.lineNumber))
1819
- .attr('data-critical-path', isCpArrow ? 'true' : null)
1820
- .attr('d', path)
1821
- .attr('fill', 'none')
1822
- .attr('stroke', arrowColor)
1823
- .attr('stroke-width', 1.5)
1824
- .attr('opacity', 0.5);
1825
-
1826
- // Arrowhead — always horizontal arrival (bezier cp2 has same Y as endpoint)
1827
- const headSize = 5;
1828
- const angle = 0;
1829
- g.append('polygon')
1830
- .attr('class', 'gantt-dep-arrowhead')
1831
- .attr('data-dep-from', rt.task.id)
1832
- .attr('data-dep-to', targetTask.task.id)
1833
- .attr('data-line-number', String(dep.lineNumber))
1834
- .attr('data-critical-path', isCpArrow ? 'true' : null)
1835
- .attr('points', arrowheadPoints(tx, ty, headSize, angle))
1836
- .attr('fill', arrowColor)
1837
- .attr('opacity', 0.5);
1838
-
1839
- // Label at midpoint of the dependency path
1840
- if (dep.label) {
1841
- const midX = (sx + tx) / 2;
1842
- const midY = (sy + ty) / 2;
1843
- // Background rect for readability
1844
- const labelEl = g
1845
- .append('text')
1846
- .attr('class', 'gantt-dep-label')
1847
- .attr('data-dep-from', rt.task.id)
1848
- .attr('data-dep-to', targetTask.task.id)
1849
- .attr('x', midX)
1850
- .attr('y', midY - 4)
1851
- .attr('text-anchor', 'middle')
1852
- .attr('font-size', 10)
1853
- .attr('font-family', FONT_FAMILY)
1854
- .attr('fill', palette.text)
1855
- .attr('opacity', 0.7)
1856
- .text(dep.label);
1857
- // Insert a background rect behind the text for contrast
1858
- const bbox = (labelEl.node() as SVGTextElement)?.getBBox?.();
1859
- if (bbox && bbox.width > 0) {
1860
- g.insert('rect', '.gantt-dep-label:last-of-type')
1861
- .attr('class', 'gantt-dep-label-bg')
1862
- .attr('x', bbox.x - 2)
1863
- .attr('y', bbox.y - 1)
1864
- .attr('width', bbox.width + 4)
1865
- .attr('height', bbox.height + 2)
1866
- .attr('fill', palette.bg)
1867
- .attr('opacity', 0.8)
1868
- .attr('rx', 2);
1869
- }
1870
- }
1871
- }
1872
- }
1873
- }
1874
-
1875
- function arrowheadPoints(
1876
- x: number,
1877
- y: number,
1878
- size: number,
1879
- angle: number
1880
- ): string {
1881
- const a1 = angle + Math.PI * 0.8;
1882
- const a2 = angle - Math.PI * 0.8;
1883
- return `${x},${y} ${x + size * Math.cos(a1)},${y + size * Math.sin(a1)} ${x + size * Math.cos(a2)},${y + size * Math.sin(a2)}`;
1884
- }
1885
-
1886
- // ── Tag Legend Rendering ─────────────────────────────────────
1887
-
1888
- function applyCriticalPathHighlight(
1889
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1890
- chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>
1891
- ) {
1892
- svg.attr('data-critical-path-active', 'true');
1893
- chartG.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
1894
- const el = d3Selection.select(this);
1895
- el.attr(
1896
- 'opacity',
1897
- el.attr('data-critical-path') === 'true' ? 1 : FADE_OPACITY
1898
- );
1899
- });
1900
- chartG
1901
- .selectAll<SVGElement, unknown>('.gantt-milestone')
1902
- .attr('opacity', FADE_OPACITY);
1903
- chartG
1904
- .selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary')
1905
- .attr('opacity', FADE_OPACITY);
1906
- svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
1907
- const el = d3Selection.select(this);
1908
- el.attr(
1909
- 'opacity',
1910
- el.attr('data-critical-path') === 'true' ? 1 : FADE_OPACITY
1911
- );
1912
- });
1913
- svg
1914
- .selectAll<SVGGElement, unknown>('.gantt-group-label')
1915
- .attr('opacity', FADE_OPACITY);
1916
- svg
1917
- .selectAll<
1918
- SVGElement,
1919
- unknown
1920
- >('.gantt-group-band-bg, .gantt-group-band-accent')
1921
- .attr('opacity', FADE_OPACITY);
1922
- svg
1923
- .selectAll<SVGGElement, unknown>('.gantt-lane-header')
1924
- .attr('opacity', FADE_OPACITY);
1925
- svg
1926
- .selectAll<
1927
- SVGElement,
1928
- unknown
1929
- >('.gantt-lane-band-bg, .gantt-lane-band-accent')
1930
- .attr('opacity', FADE_OPACITY);
1931
- chartG
1932
- .selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent')
1933
- .attr('opacity', FADE_OPACITY);
1934
- // Show critical path arrows at full opacity, fade others
1935
- chartG
1936
- .selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead')
1937
- .each(function () {
1938
- const el = d3Selection.select(this);
1939
- el.attr(
1940
- 'opacity',
1941
- el.attr('data-critical-path') === 'true' ? 0.7 : FADE_OPACITY
1942
- );
1943
- });
1944
- }
1945
-
1946
- function resetHighlightAll(
1947
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1948
- chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>
1949
- ) {
1950
- chartG
1951
- .selectAll<SVGGElement, unknown>('.gantt-task, .gantt-milestone')
1952
- .attr('opacity', 1);
1953
- chartG
1954
- .selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary')
1955
- .attr('opacity', 1);
1956
- svg
1957
- .selectAll<SVGTextElement, unknown>('.gantt-task-label')
1958
- .attr('opacity', 1);
1959
- svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', 1);
1960
- svg
1961
- .selectAll<
1962
- SVGElement,
1963
- unknown
1964
- >('.gantt-group-band-bg, .gantt-group-band-accent')
1965
- .attr('opacity', 1);
1966
- svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', 1);
1967
- svg
1968
- .selectAll<
1969
- SVGElement,
1970
- unknown
1971
- >('.gantt-lane-band-bg, .gantt-lane-band-accent')
1972
- .attr('opacity', 1);
1973
- chartG
1974
- .selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent')
1975
- .attr('opacity', 1);
1976
- chartG
1977
- .selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead')
1978
- .attr('opacity', 0.5);
1979
- }
1980
-
1981
- // ── Swimlane Icon Helper ─────────────────────────────────────
1982
-
1983
- function drawSwimlaneIcon(
1984
- parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1985
- x: number,
1986
- y: number,
1987
- isActive: boolean,
1988
- palette: PaletteColors
1989
- ): d3Selection.Selection<SVGGElement, unknown, null, undefined> {
1990
- const iconG = parent
1991
- .append('g')
1992
- .attr('class', 'gantt-swimlane-icon')
1993
- .attr('transform', `translate(${x}, ${y})`);
1994
-
1995
- // Transparent hit area so the whole icon (not just the 2px bars) is clickable
1996
- iconG
1997
- .append('rect')
1998
- .attr('x', -5)
1999
- .attr('y', -5)
2000
- .attr('width', 22)
2001
- .attr('height', 18)
2002
- .attr('fill', 'transparent');
2003
-
2004
- const color = isActive ? palette.primary : palette.textMuted;
2005
- const opacity = isActive ? 1 : 0.35;
2006
- const barWidths = [8, 12, 6];
2007
- const barH = 2;
2008
- const gap = 3;
2009
-
2010
- for (let i = 0; i < barWidths.length; i++) {
2011
- iconG
2012
- .append('rect')
2013
- .attr('x', 0)
2014
- .attr('y', i * gap)
2015
- .attr('width', barWidths[i]!) // In-bounds by loop guard.
2016
- .attr('height', barH)
2017
- .attr('rx', 1)
2018
- .attr('fill', color)
2019
- .attr('opacity', opacity);
2020
- }
2021
-
2022
- return iconG;
2023
- }
2024
-
2025
- function buildControlsToggles(
2026
- hasCriticalPath: boolean,
2027
- criticalPathActive: boolean,
2028
- hasDependencies: boolean,
2029
- dependenciesActive: boolean
2030
- ): import('../utils/legend-types').ControlsGroupToggle[] {
2031
- const toggles: import('../utils/legend-types').ControlsGroupToggle[] = [];
2032
- if (hasCriticalPath) {
2033
- toggles.push({
2034
- id: 'critical-path',
2035
- type: 'toggle',
2036
- label: 'Critical Path',
2037
- active: criticalPathActive,
2038
- onToggle: () => {},
2039
- });
2040
- }
2041
- if (hasDependencies) {
2042
- toggles.push({
2043
- id: 'dependencies',
2044
- type: 'toggle',
2045
- label: 'Dependencies',
2046
- active: dependenciesActive,
2047
- onToggle: () => {},
2048
- });
2049
- }
2050
- return toggles;
2051
- }
2052
-
2053
- function renderTagLegend(
2054
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2055
- chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2056
- tagGroups: TagGroup[],
2057
- activeGroupName: string | null,
2058
- chartLeftMargin: number,
2059
- chartInnerWidth: number,
2060
- legendY: number,
2061
- palette: PaletteColors,
2062
- isDark: boolean,
2063
- hasCriticalPath: boolean,
2064
- criticalPathActive: boolean,
2065
- _optionLineNumbers: Record<string, number>,
2066
- onToggle?: (groupName: string) => void,
2067
- onToggleControlsExpand?: () => void,
2068
- currentSwimlaneGroup?: string | null,
2069
- onSwimlaneChange?: (group: string | null) => void,
2070
- legendViewMode?: boolean,
2071
- resolvedTasks?: ResolvedTask[],
2072
- controlsExpanded = false,
2073
- hasDependencies = false,
2074
- dependenciesActive = false,
2075
- onControlsToggle?: (toggleId: string, active: boolean) => void,
2076
- exportMode = false,
2077
- controlsHost: 'app' | 'inline' = 'inline'
2078
- ): void {
2079
- // Build visible groups: active group expanded + swimlane group as compact pill
2080
- let visibleGroups: TagGroup[];
2081
- if (activeGroupName) {
2082
- const activeGroup = tagGroups.filter(
2083
- (g) => g.name.toLowerCase() === activeGroupName.toLowerCase()
2084
- );
2085
- const swimlaneGroup =
2086
- currentSwimlaneGroup &&
2087
- currentSwimlaneGroup.toLowerCase() !== activeGroupName.toLowerCase()
2088
- ? tagGroups.filter(
2089
- (g) => g.name.toLowerCase() === currentSwimlaneGroup.toLowerCase()
2090
- )
2091
- : [];
2092
- visibleGroups = [...swimlaneGroup, ...activeGroup];
2093
- } else {
2094
- visibleGroups = tagGroups;
2095
- }
2096
-
2097
- // Build set of used tag values per group from resolved tasks
2098
- const usedValues = new Map<string, Set<string>>();
2099
- if (resolvedTasks) {
2100
- for (const group of visibleGroups) {
2101
- const key = tagAttrKey(group.name);
2102
- const used = new Set<string>();
2103
- for (const rt of resolvedTasks) {
2104
- const val = rt.effectiveMetadata[key];
2105
- if (val) used.add(val.toLowerCase());
2106
- }
2107
- usedValues.set(key, used);
2108
- }
2109
- }
2110
-
2111
- // Filter entries to only those used in the current view
2112
- const filteredEntries = new Map<string, readonly TagEntry[]>();
2113
- for (const group of visibleGroups) {
2114
- const key = tagAttrKey(group.name);
2115
- const used = usedValues.get(key);
2116
- if (used && used.size > 0) {
2117
- filteredEntries.set(
2118
- key,
2119
- group.entries.filter((e) => used.has(e.value.toLowerCase()))
2120
- );
2121
- } else {
2122
- filteredEntries.set(key, group.entries);
2123
- }
2124
- }
2125
-
2126
- // Compute per-group widths
2127
- const groupWidths: number[] = [];
2128
- let totalW = 0;
2129
- for (const group of visibleGroups) {
2130
- const isActive =
2131
- activeGroupName?.toLowerCase() === group.name.toLowerCase();
2132
- const isSwimlane =
2133
- currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase();
2134
- const showIcon = !legendViewMode && tagGroups.length > 0;
2135
- const iconReserve = showIcon && isActive ? LEGEND_ICON_W : 0;
2136
- const pillW =
2137
- measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) +
2138
- LEGEND_PILL_PAD +
2139
- iconReserve;
2140
- let groupW = pillW;
2141
- if (isActive) {
2142
- const entries =
2143
- filteredEntries.get(tagAttrKey(group.name)) ?? group.entries;
2144
- let entriesW = 0;
2145
- for (const entry of entries) {
2146
- entriesW +=
2147
- LEGEND_DOT_R * 2 +
2148
- LEGEND_ENTRY_DOT_GAP +
2149
- measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
2150
- LEGEND_ENTRY_TRAIL;
2151
- }
2152
- groupW = LEGEND_CAPSULE_PAD * 2 + pillW + 4 + entriesW;
2153
- } else if (isSwimlane && !isActive) {
2154
- // Compact swimlane pill: name + highlighted icon, no entries
2155
- groupW = pillW;
2156
- }
2157
- groupWidths.push(groupW);
2158
- totalW += groupW;
2159
- }
2160
- totalW += Math.max(0, (visibleGroups.length - 1) * LEGEND_GROUP_GAP);
2161
-
2162
- // Controls group width — replaces standalone critical path pill
2163
- const hasControls = hasCriticalPath || hasDependencies;
2164
- const controlsToggleLabels: Array<{ label: string }> = [];
2165
- if (hasCriticalPath) controlsToggleLabels.push({ label: 'Critical Path' });
2166
- if (hasDependencies) controlsToggleLabels.push({ label: 'Dependencies' });
2167
- if (hasControls) {
2168
- if (visibleGroups.length > 0) totalW += LEGEND_GROUP_GAP;
2169
- totalW += controlsExpanded
2170
- ? controlsGroupCapsuleWidth(controlsToggleLabels)
2171
- : LEGEND_GEAR_PILL_W;
2172
- }
2173
-
2174
- // Center over full container (matching title centering)
2175
- const containerWidth = chartLeftMargin + chartInnerWidth + RIGHT_MARGIN;
2176
- const legendX = (containerWidth - totalW) / 2;
2177
-
2178
- const legendRow = svg
2179
- .append('g')
2180
- .attr('class', 'gantt-tag-legend-container')
2181
- .attr('transform', `translate(${legendX}, ${legendY})`);
2182
-
2183
- // Render tag groups via centralized legend system
2184
- if (visibleGroups.length > 0) {
2185
- const showIcon = !legendViewMode && tagGroups.length > 0;
2186
- const iconReserve = showIcon ? LEGEND_ICON_W : 0;
2187
-
2188
- // Build groups with filtered entries
2189
- const legendGroups = visibleGroups.map((g) => {
2190
- const key = g.name.toLowerCase();
2191
- const entries = filteredEntries.get(key) ?? g.entries;
2192
- return {
2193
- name: g.name,
2194
- entries: entries.map((e) => ({ value: e.value, color: e.color })),
2195
- };
2196
- });
2197
-
2198
- const controlsToggles = buildControlsToggles(
2199
- hasCriticalPath,
2200
- criticalPathActive,
2201
- hasDependencies,
2202
- dependenciesActive
2203
- );
2204
-
2205
- let tagGroupsW =
2206
- // In-bounds: groupWidths has one entry per visibleGroups item.
2207
- visibleGroups.reduce((s, _, i) => s + groupWidths[i]!, 0) +
2208
- Math.max(0, (visibleGroups.length - 1) * LEGEND_GROUP_GAP);
2209
- // Add controls group space to the renderLegendD3 container width
2210
- if (hasControls) {
2211
- if (visibleGroups.length > 0) tagGroupsW += LEGEND_GROUP_GAP;
2212
- tagGroupsW += controlsExpanded
2213
- ? controlsGroupCapsuleWidth(controlsToggleLabels)
2214
- : LEGEND_GEAR_PILL_W;
2215
- }
2216
- const tagGroupG = legendRow.append('g');
2217
-
2218
- const legendCallbacks: LegendCallbacks = {
2219
- ...(onToggle !== undefined && { onGroupToggle: onToggle }),
2220
- ...(onToggleControlsExpand !== undefined && {
2221
- onControlsExpand: onToggleControlsExpand,
2222
- }),
2223
- ...(onControlsToggle !== undefined && { onControlsToggle }),
2224
- onEntryHover: (groupName, entryValue) => {
2225
- const tagKey = tagAttrKey(groupName);
2226
- if (entryValue) {
2227
- const ev = entryValue.toLowerCase();
2228
- chartG
2229
- .selectAll<SVGGElement, unknown>('.gantt-task')
2230
- .each(function () {
2231
- const el = d3Selection.select(this);
2232
- el.attr(
2233
- 'opacity',
2234
- el.attr(`data-tag-${tagKey}`) === ev ? 1 : FADE_OPACITY
2235
- );
2236
- });
2237
- chartG
2238
- .selectAll<SVGElement, unknown>('.gantt-milestone')
2239
- .attr('opacity', FADE_OPACITY);
2240
- chartG
2241
- .selectAll<
2242
- SVGElement,
2243
- unknown
2244
- >('.gantt-group-bar, .gantt-group-summary')
2245
- .attr('opacity', FADE_OPACITY);
2246
- svg
2247
- .selectAll<SVGTextElement, unknown>('.gantt-task-label')
2248
- .each(function () {
2249
- const el = d3Selection.select(this);
2250
- el.attr(
2251
- 'opacity',
2252
- el.attr(`data-tag-${tagKey}`) === ev ? 1 : FADE_OPACITY
2253
- );
2254
- });
2255
- svg
2256
- .selectAll<SVGGElement, unknown>('.gantt-group-label')
2257
- .attr('opacity', FADE_OPACITY);
2258
- svg
2259
- .selectAll<SVGGElement, unknown>('.gantt-lane-header')
2260
- .each(function () {
2261
- const el = d3Selection.select(this);
2262
- el.attr(
2263
- 'opacity',
2264
- el.attr(`data-tag-${tagKey}`) === ev ? 1 : FADE_OPACITY
2265
- );
2266
- });
2267
- chartG
2268
- .selectAll<
2269
- SVGElement,
2270
- unknown
2271
- >('.gantt-lane-band, .gantt-lane-accent')
2272
- .attr('opacity', FADE_OPACITY);
2273
- } else {
2274
- if (criticalPathActive) {
2275
- applyCriticalPathHighlight(svg, chartG);
2276
- } else {
2277
- resetHighlightAll(svg, chartG);
2278
- }
2279
- }
2280
- },
2281
- onGroupRendered: (groupName, groupEl, _isActive) => {
2282
- // Add swimlane icon and data attributes
2283
- const group = visibleGroups.find((g) => g.name === groupName);
2284
- if (group) {
2285
- groupEl
2286
- .attr('data-tag-group', group.name)
2287
- .attr('data-line-number', String(group.lineNumber));
2288
- }
2289
- if (showIcon && _isActive) {
2290
- const isSwimlane =
2291
- currentSwimlaneGroup?.toLowerCase() === groupName.toLowerCase();
2292
- const textW =
2293
- measureLegendText(groupName, LEGEND_PILL_FONT_SIZE) +
2294
- LEGEND_PILL_PAD;
2295
- const pillXOff = LEGEND_CAPSULE_PAD;
2296
- const iconX = pillXOff + textW + 3;
2297
- const iconY = (LEGEND_HEIGHT - 10) / 2;
2298
- const iconEl = drawSwimlaneIcon(
2299
- groupEl,
2300
- iconX,
2301
- iconY,
2302
- isSwimlane,
2303
- palette
2304
- );
2305
- iconEl.append('title').text(`Group by ${groupName}`);
2306
- iconEl.style('cursor', 'pointer').on('click', (event: Event) => {
2307
- event.stopPropagation();
2308
- if (onSwimlaneChange) {
2309
- onSwimlaneChange(
2310
- currentSwimlaneGroup?.toLowerCase() === groupName.toLowerCase()
2311
- ? null
2312
- : groupName
2313
- );
2314
- }
2315
- });
2316
- }
2317
- },
2318
- };
2319
-
2320
- renderIntegratedLegend(tagGroupG, {
2321
- groups: legendGroups,
2322
- palette,
2323
- isDark,
2324
- width: tagGroupsW,
2325
- mode: exportMode ? 'export' : 'preview',
2326
- capsulePillAddonWidth: iconReserve,
2327
- ...(controlsToggles.length > 0 && {
2328
- controlsGroup: { toggles: controlsToggles },
2329
- }),
2330
- // 'app' suppresses the inline gear so the desktop ControlsStrip hosts the
2331
- // Critical Path / Dependencies toggles instead (export keeps inline).
2332
- ...(controlsHost === 'app' && !exportMode && { controlsHost: 'app' }),
2333
- activeGroup: activeGroupName,
2334
- controlsExpanded,
2335
- callbacks: legendCallbacks,
2336
- });
2337
- } else if (hasControls) {
2338
- // No tag groups, but controls group needs rendering
2339
- const controlsToggles = buildControlsToggles(
2340
- hasCriticalPath,
2341
- criticalPathActive,
2342
- hasDependencies,
2343
- dependenciesActive
2344
- );
2345
-
2346
- const tagGroupG = legendRow.append('g');
2347
- renderIntegratedLegend(tagGroupG, {
2348
- groups: [],
2349
- palette,
2350
- isDark,
2351
- width: totalW,
2352
- mode: exportMode ? 'export' : 'preview',
2353
- controlsGroup: { toggles: controlsToggles },
2354
- ...(controlsHost === 'app' && !exportMode && { controlsHost: 'app' }),
2355
- activeGroup: null,
2356
- controlsExpanded,
2357
- callbacks: {
2358
- ...(onToggleControlsExpand !== undefined && {
2359
- onControlsExpand: onToggleControlsExpand,
2360
- }),
2361
- ...(onControlsToggle !== undefined && { onControlsToggle }),
2362
- },
2363
- });
2364
- }
2365
-
2366
- // Apply persistent critical path highlighting when active
2367
- if (criticalPathActive) {
2368
- applyCriticalPathHighlight(svg, chartG);
2369
- }
2370
- }
2371
-
2372
- // ── Era & Marker Rendering ──────────────────────────────────
2373
-
2374
- const ERA_COLORS = ['#3b6ea5', '#5b9357', '#c9a227', '#cc7a33', '#7d5ba6'];
2375
-
2376
- function renderErasAndMarkers(
2377
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2378
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2379
- resolved: ResolvedSchedule,
2380
- xScale: d3Scale.ScaleLinear<number, number>,
2381
- innerHeight: number,
2382
- palette: PaletteColors,
2383
- eraLabelY: number,
2384
- markerLabelY: number
2385
- ): void {
2386
- // Eras: semi-transparent background bands
2387
- for (let i = 0; i < resolved.eras.length; i++) {
2388
- const era = resolved.eras[i];
2389
- if (!era) continue; // In-bounds by loop guard; appeases TS.
2390
- // In-bounds: ERA_COLORS has 5 entries and i % 5 is always 0-4.
2391
- const color = era.color || ERA_COLORS[i % ERA_COLORS.length]!;
2392
- const sx = xScale(parseDateToFractionalYear(era.startDate));
2393
- const ex = xScale(parseDateToFractionalYear(era.endDate));
2394
- if (ex <= sx) continue;
2395
-
2396
- const baseEraOpacity = 0.08;
2397
- const hoverEraOpacity = 0.16;
2398
- const eraStartDate = parseDateStringToDate(era.startDate);
2399
- const eraEndDate = parseDateStringToDate(era.endDate);
2400
-
2401
- const eraG = g
2402
- .append('g')
2403
- .attr('class', 'gantt-era-group')
2404
- .attr('data-line-number', String(era.lineNumber));
2405
-
2406
- const eraRect = eraG
2407
- .append('rect')
2408
- .attr('class', 'gantt-era')
2409
- .attr('x', sx)
2410
- .attr('y', 0)
2411
- .attr('width', ex - sx)
2412
- .attr('height', innerHeight)
2413
- .attr('fill', color)
2414
- .attr('opacity', baseEraOpacity);
2415
-
2416
- // Era label sits in the era row (one row above markers when both present).
2417
- // Truncate to fit the era's own span (with breathing room) so labels stay
2418
- // confined to their tinted band. Full text is restored on hover.
2419
- const eraLabelMaxW = Math.max(0, ex - sx - 8);
2420
- const eraDisplayLabel = truncateLegendText(era.label, 10, eraLabelMaxW);
2421
- const eraTruncated = eraDisplayLabel !== era.label;
2422
- const eraLabel = eraG
2423
- .append('text')
2424
- .attr('class', 'gantt-era-label')
2425
- .attr('x', (sx + ex) / 2)
2426
- .attr('y', eraLabelY)
2427
- .attr('text-anchor', 'middle')
2428
- .attr('font-size', '10px')
2429
- .attr('fill', color)
2430
- .attr('opacity', 0.7)
2431
- .style('cursor', 'pointer')
2432
- .text(eraDisplayLabel);
2433
-
2434
- eraG
2435
- .on('mouseenter', () => {
2436
- // Fade everything
2437
- g.selectAll<SVGGElement, unknown>('.gantt-task').attr(
2438
- 'opacity',
2439
- FADE_OPACITY
2440
- );
2441
- g.selectAll<SVGElement, unknown>('.gantt-milestone').attr(
2442
- 'opacity',
2443
- FADE_OPACITY
2444
- );
2445
- g.selectAll<SVGElement, unknown>(
2446
- '.gantt-group-bar, .gantt-group-summary'
2447
- ).attr('opacity', FADE_OPACITY);
2448
- svg
2449
- .selectAll<SVGGElement, unknown>('.gantt-group-label')
2450
- .attr('opacity', FADE_OPACITY);
2451
- svg
2452
- .selectAll<SVGTextElement, unknown>('.gantt-task-label')
2453
- .attr('opacity', FADE_OPACITY);
2454
- svg
2455
- .selectAll<SVGGElement, unknown>('.gantt-lane-header')
2456
- .attr('opacity', FADE_OPACITY);
2457
- g.selectAll<SVGElement, unknown>(
2458
- '.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
2459
- ).attr('opacity', FADE_OPACITY);
2460
- g.selectAll<SVGElement, unknown>(
2461
- '.gantt-dep-arrow, .gantt-dep-arrowhead'
2462
- ).attr('opacity', FADE_OPACITY);
2463
- g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
2464
- 'opacity',
2465
- FADE_OPACITY
2466
- );
2467
- // Highlight this era
2468
- eraRect.attr('opacity', hoverEraOpacity);
2469
- if (eraTruncated) eraLabel.text(era.label);
2470
- showGanttDateIndicators(
2471
- g,
2472
- xScale,
2473
- eraStartDate,
2474
- eraEndDate,
2475
- innerHeight,
2476
- color
2477
- );
2478
- })
2479
- .on('mouseleave', () => {
2480
- resetHighlight(g, svg);
2481
- eraRect.attr('opacity', baseEraOpacity);
2482
- if (eraTruncated) eraLabel.text(eraDisplayLabel);
2483
- hideGanttDateIndicators(g);
2484
- });
2485
- }
2486
-
2487
- // Markers: label → diamond → dashed line (same layout as timeline).
2488
- // Pre-compute x positions so each marker can size its label based on the
2489
- // distance to its nearest neighbor — symmetric truncation lets crowded
2490
- // labels meet in the middle of the gap rather than overlapping.
2491
- const markerXs = resolved.markers.map((m) =>
2492
- xScale(parseDateToFractionalYear(m.date))
2493
- );
2494
- // d3 scale.range() returns a 2-element array.
2495
- const innerWidth = xScale.range()[1]!;
2496
- for (let i = 0; i < resolved.markers.length; i++) {
2497
- const marker = resolved.markers[i];
2498
- if (!marker) continue; // In-bounds by loop guard; appeases TS.
2499
- const color = marker.color || palette.accent || '#3a9188';
2500
- // In-bounds: markerXs.length === resolved.markers.length.
2501
- const mx = markerXs[i]!;
2502
- const markerDate = parseDateStringToDate(marker.date);
2503
- const diamondSize = 5;
2504
- const labelY = markerLabelY;
2505
- const diamondY = -2; // below date indicator labels
2506
-
2507
- // Available label width: distance to nearest neighbor (or chart edge),
2508
- // minus a small padding. Both labels truncate symmetrically and meet
2509
- // in the middle of the gap.
2510
- let nearestDist = Math.min(mx, innerWidth - mx);
2511
- for (let j = 0; j < markerXs.length; j++) {
2512
- if (j === i) continue;
2513
- // In-bounds by loop guard.
2514
- const d = Math.abs(markerXs[j]! - mx);
2515
- if (d < nearestDist) nearestDist = d;
2516
- }
2517
- const markerLabelMaxW = Math.max(0, nearestDist - 8);
2518
- const markerDisplayLabel = truncateLegendText(
2519
- marker.label,
2520
- 11,
2521
- markerLabelMaxW
2522
- );
2523
- const markerTruncated = markerDisplayLabel !== marker.label;
2524
-
2525
- const markerG = g
2526
- .append('g')
2527
- .attr('class', 'gantt-marker-group')
2528
- .attr('data-line-number', String(marker.lineNumber))
2529
- .style('cursor', 'pointer');
2530
-
2531
- // Invisible hit rect for easier clicking/hovering
2532
- markerG
2533
- .append('rect')
2534
- .attr('x', mx - 40)
2535
- .attr('y', labelY - 12)
2536
- .attr('width', 80)
2537
- .attr('height', innerHeight - labelY + 12)
2538
- .attr('fill', 'transparent')
2539
- .attr('pointer-events', 'all');
2540
-
2541
- // Label above diamond
2542
- const markerLabelEl = markerG
2543
- .append('text')
2544
- .attr('class', 'gantt-marker-label')
2545
- .attr('x', mx)
2546
- .attr('y', labelY)
2547
- .attr('text-anchor', 'middle')
2548
- .attr('font-size', '11px')
2549
- .attr('font-weight', '600')
2550
- .attr('fill', color)
2551
- .text(markerDisplayLabel);
2552
-
2553
- // Diamond below label
2554
- markerG
2555
- .append('path')
2556
- .attr(
2557
- 'd',
2558
- `M${mx},${diamondY - diamondSize} l${diamondSize},${diamondSize} l-${diamondSize},${diamondSize} l-${diamondSize},-${diamondSize} Z`
2559
- )
2560
- .attr('fill', color)
2561
- .attr('opacity', 0.9);
2562
-
2563
- // Dashed line from diamond down
2564
- markerG
2565
- .append('line')
2566
- .attr('class', 'gantt-marker')
2567
- .attr('x1', mx)
2568
- .attr('y1', diamondY + diamondSize)
2569
- .attr('x2', mx)
2570
- .attr('y2', innerHeight)
2571
- .attr('stroke', color)
2572
- .attr('stroke-width', 1.5)
2573
- .attr('stroke-dasharray', '6 4')
2574
- .attr('opacity', 0.5);
2575
-
2576
- // Hide marker line/diamond on hover but keep label visible
2577
- const markerLine = markerG.select('.gantt-marker');
2578
- const markerDiamond = markerG.select('path');
2579
- markerG
2580
- .on('mouseenter', () => {
2581
- // Fade everything
2582
- g.selectAll<SVGGElement, unknown>('.gantt-task').attr(
2583
- 'opacity',
2584
- FADE_OPACITY
2585
- );
2586
- g.selectAll<SVGElement, unknown>('.gantt-milestone').attr(
2587
- 'opacity',
2588
- FADE_OPACITY
2589
- );
2590
- g.selectAll<SVGElement, unknown>(
2591
- '.gantt-group-bar, .gantt-group-summary'
2592
- ).attr('opacity', FADE_OPACITY);
2593
- svg
2594
- .selectAll<SVGGElement, unknown>('.gantt-group-label')
2595
- .attr('opacity', FADE_OPACITY);
2596
- svg
2597
- .selectAll<SVGTextElement, unknown>('.gantt-task-label')
2598
- .attr('opacity', FADE_OPACITY);
2599
- svg
2600
- .selectAll<SVGGElement, unknown>('.gantt-lane-header')
2601
- .attr('opacity', FADE_OPACITY);
2602
- g.selectAll<SVGElement, unknown>(
2603
- '.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
2604
- ).attr('opacity', FADE_OPACITY);
2605
- g.selectAll<SVGElement, unknown>(
2606
- '.gantt-dep-arrow, .gantt-dep-arrowhead'
2607
- ).attr('opacity', FADE_OPACITY);
2608
- g.selectAll<SVGElement, unknown>('.gantt-era-group').attr(
2609
- 'opacity',
2610
- FADE_OPACITY
2611
- );
2612
- // Fade other markers but keep this one highlighted
2613
- g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
2614
- 'opacity',
2615
- FADE_OPACITY
2616
- );
2617
- markerG.attr('opacity', 1);
2618
- markerLine.attr('opacity', 0.8);
2619
- markerDiamond.attr('opacity', 0);
2620
- if (markerTruncated) markerLabelEl.text(marker.label);
2621
- showGanttDateIndicators(
2622
- g,
2623
- xScale,
2624
- markerDate,
2625
- null,
2626
- innerHeight,
2627
- color,
2628
- { skipStartLine: true }
2629
- );
2630
- })
2631
- .on('mouseleave', () => {
2632
- resetHighlight(g, svg);
2633
- markerLine.attr('opacity', 0.5);
2634
- markerDiamond.attr('opacity', 0.9);
2635
- if (markerTruncated) markerLabelEl.text(markerDisplayLabel);
2636
- hideGanttDateIndicators(g);
2637
- });
2638
- }
2639
- }
2640
-
2641
- // ── Sprint band rendering ──────────────────────────────────
2642
-
2643
- const SPRINT_BAND_OPACITY = 0.05;
2644
- const SPRINT_HOVER_OPACITY = 0.12;
2645
- const SPRINT_BOUNDARY_OPACITY = 0.3;
2646
-
2647
- function renderSprintBands(
2648
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2649
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2650
- resolved: ResolvedSchedule,
2651
- xScale: d3Scale.ScaleLinear<number, number>,
2652
- innerHeight: number,
2653
- palette: PaletteColors
2654
- ): void {
2655
- if (resolved.sprints.length === 0) return;
2656
-
2657
- // When both eras and sprints defined, eras win — don't render sprint bands
2658
- if (resolved.eras.length > 0) return;
2659
-
2660
- const bandColor = palette.textMuted || palette.text || '#888';
2661
-
2662
- // Chart content area starts at x=0 in the g coordinate space
2663
- const chartMinX = 0;
2664
-
2665
- for (let i = 0; i < resolved.sprints.length; i++) {
2666
- const sprint = resolved.sprints[i];
2667
- if (!sprint) continue; // In-bounds by loop guard; appeases TS.
2668
- const rawSx = xScale(dateToFractionalYear(sprint.startDate));
2669
- const rawEx = xScale(dateToFractionalYear(sprint.endDate));
2670
- if (rawEx <= rawSx) continue;
2671
-
2672
- // Clip to chart content area — prevent bands from extending into swimlane margin
2673
- const sx = Math.max(rawSx, chartMinX);
2674
- const ex = rawEx;
2675
- const bandWidth = ex - sx;
2676
- if (bandWidth <= 0) continue;
2677
-
2678
- const sprintG = g
2679
- .append('g')
2680
- .attr('class', 'gantt-sprint-group')
2681
- .style('cursor', 'pointer');
2682
-
2683
- // Alternating shaded bands (consistent by sprint number)
2684
- const sprintRect = sprintG
2685
- .append('rect')
2686
- .attr('class', 'gantt-sprint-band')
2687
- .attr('x', sx)
2688
- .attr('y', 0)
2689
- .attr('width', bandWidth)
2690
- .attr('height', innerHeight)
2691
- .attr('fill', bandColor)
2692
- .attr('opacity', sprint.number % 2 === 0 ? SPRINT_BAND_OPACITY : 0);
2693
-
2694
- // Invisible hit rect for hover on unshaded bands
2695
- if (sprint.number % 2 !== 0) {
2696
- sprintG
2697
- .append('rect')
2698
- .attr('x', sx)
2699
- .attr('y', 0)
2700
- .attr('width', bandWidth)
2701
- .attr('height', innerHeight)
2702
- .attr('fill', 'transparent');
2703
- }
2704
-
2705
- // Persistent sprint number label — always visible, turns into "Sprint X" on hover
2706
- const sprintLabel = sprintG
2707
- .append('text')
2708
- .attr('class', 'gantt-sprint-label')
2709
- .attr('x', (sx + ex) / 2)
2710
- .attr('y', -22)
2711
- .attr('text-anchor', 'middle')
2712
- .attr('font-size', '10px')
2713
- .attr('font-weight', '600')
2714
- .attr('fill', bandColor)
2715
- .attr('opacity', 0.4)
2716
- .text(String(sprint.number));
2717
-
2718
- // Dashed boundary line at sprint start (skip for first visible band)
2719
- if (i > 0 && rawSx >= chartMinX) {
2720
- sprintG
2721
- .append('line')
2722
- .attr('class', 'gantt-sprint-boundary')
2723
- .attr('x1', sx)
2724
- .attr('y1', -6)
2725
- .attr('x2', sx)
2726
- .attr('y2', innerHeight)
2727
- .attr('stroke', bandColor)
2728
- .attr('stroke-width', 1)
2729
- .attr('stroke-dasharray', '3 3')
2730
- .attr('opacity', SPRINT_BOUNDARY_OPACITY);
2731
- }
2732
-
2733
- // Determine which tasks and groups overlap this sprint (start-inclusive, end-exclusive)
2734
- const sprintStartMs = sprint.startDate.getTime();
2735
- const sprintEndMs = sprint.endDate.getTime();
2736
- const overlappingTaskIds = new Set<string>();
2737
- for (const rt of resolved.tasks) {
2738
- const taskStart = rt.startDate.getTime();
2739
- const taskEnd = rt.endDate.getTime();
2740
- // Task overlaps sprint if it starts before sprint ends AND ends after sprint starts
2741
- if (taskStart < sprintEndMs && taskEnd > sprintStartMs) {
2742
- overlappingTaskIds.add(rt.task.id);
2743
- }
2744
- // Milestones (zero duration): include if they fall within [sprintStart, sprintEnd)
2745
- if (
2746
- taskStart === taskEnd &&
2747
- taskStart >= sprintStartMs &&
2748
- taskStart < sprintEndMs
2749
- ) {
2750
- overlappingTaskIds.add(rt.task.id);
2751
- }
2752
- }
2753
- const overlappingGroupNames = new Set<string>();
2754
- for (const rg of resolved.groups) {
2755
- const gStart = rg.startDate.getTime();
2756
- const gEnd = rg.endDate.getTime();
2757
- if (gStart < sprintEndMs && gEnd > sprintStartMs) {
2758
- overlappingGroupNames.add(rg.name);
2759
- }
2760
- }
2761
-
2762
- // Hover: highlight band, keep overlapping tasks visible, show dates + sprint label
2763
- const baseOpacity = sprint.number % 2 === 0 ? SPRINT_BAND_OPACITY : 0;
2764
- sprintG
2765
- .on('mouseenter', () => {
2766
- // Dim tasks NOT in this sprint, keep overlapping ones visible
2767
- g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
2768
- const el = d3Selection.select(this);
2769
- const id = el.attr('data-task-id');
2770
- el.attr(
2771
- 'opacity',
2772
- id && overlappingTaskIds.has(id) ? 1 : FADE_OPACITY
2773
- );
2774
- });
2775
- g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
2776
- const el = d3Selection.select(this);
2777
- const id = el.attr('data-task-id');
2778
- el.attr(
2779
- 'opacity',
2780
- id && overlappingTaskIds.has(id) ? 1 : FADE_OPACITY
2781
- );
2782
- });
2783
- svg
2784
- .selectAll<SVGTextElement, unknown>('.gantt-task-label')
2785
- .each(function () {
2786
- const el = d3Selection.select(this);
2787
- const id = el.attr('data-task-id');
2788
- el.attr(
2789
- 'opacity',
2790
- id && overlappingTaskIds.has(id) ? 1 : FADE_OPACITY
2791
- );
2792
- });
2793
- g.selectAll<SVGElement, unknown>(
2794
- '.gantt-group-bar, .gantt-group-summary'
2795
- ).each(function () {
2796
- const el = d3Selection.select(this);
2797
- const name = el.attr('data-group');
2798
- el.attr(
2799
- 'opacity',
2800
- name && overlappingGroupNames.has(name) ? 1 : FADE_OPACITY
2801
- );
2802
- });
2803
- svg
2804
- .selectAll<SVGGElement, unknown>('.gantt-group-label')
2805
- .each(function () {
2806
- const el = d3Selection.select(this);
2807
- const name = el.attr('data-group');
2808
- el.attr(
2809
- 'opacity',
2810
- name && overlappingGroupNames.has(name) ? 1 : FADE_OPACITY
2811
- );
2812
- });
2813
- svg
2814
- .selectAll<SVGGElement, unknown>('.gantt-lane-header')
2815
- .attr('opacity', FADE_OPACITY);
2816
- g.selectAll<SVGElement, unknown>(
2817
- '.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
2818
- ).attr('opacity', FADE_OPACITY);
2819
- g.selectAll<SVGElement, unknown>(
2820
- '.gantt-dep-arrow, .gantt-dep-arrowhead'
2821
- ).attr('opacity', FADE_OPACITY);
2822
- g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
2823
- 'opacity',
2824
- FADE_OPACITY
2825
- );
2826
- sprintRect.attr('opacity', SPRINT_HOVER_OPACITY);
2827
- // Only show start date indicator if it's within the visible chart area
2828
- const startVisible = rawSx >= chartMinX;
2829
- if (startVisible) {
2830
- showGanttDateIndicators(
2831
- g,
2832
- xScale,
2833
- sprint.startDate,
2834
- sprint.endDate,
2835
- innerHeight,
2836
- bandColor
2837
- );
2838
- } else {
2839
- // Only show end date indicator (start is clipped/off-screen)
2840
- showGanttDateIndicators(
2841
- g,
2842
- xScale,
2843
- sprint.endDate,
2844
- null,
2845
- innerHeight,
2846
- bandColor
2847
- );
2848
- }
2849
- // Swap persistent label to full "Sprint X" on hover
2850
- const accentColor = palette.accent || palette.text || bandColor;
2851
- sprintLabel
2852
- .text(`Sprint ${sprint.number}`)
2853
- .attr('font-size', '13px')
2854
- .attr('font-weight', '700')
2855
- .attr('fill', accentColor)
2856
- .attr('opacity', 1);
2857
- })
2858
- .on('mouseleave', () => {
2859
- resetHighlight(g, svg);
2860
- sprintRect.attr('opacity', baseOpacity);
2861
- sprintLabel
2862
- .text(String(sprint.number))
2863
- .attr('font-size', '10px')
2864
- .attr('font-weight', '600')
2865
- .attr('fill', bandColor)
2866
- .attr('opacity', 0.4);
2867
- hideGanttDateIndicators(g);
2868
- });
2869
- }
2870
-
2871
- // Boundary line at end of last sprint
2872
- // In-bounds: early return above when sprints.length === 0.
2873
- const lastSprint = resolved.sprints[resolved.sprints.length - 1]!;
2874
- const lastEx = xScale(dateToFractionalYear(lastSprint.endDate));
2875
- g.append('line')
2876
- .attr('class', 'gantt-sprint-boundary')
2877
- .attr('x1', lastEx)
2878
- .attr('y1', -6)
2879
- .attr('x2', lastEx)
2880
- .attr('y2', innerHeight)
2881
- .attr('stroke', bandColor)
2882
- .attr('stroke-width', 1)
2883
- .attr('stroke-dasharray', '3 3')
2884
- .attr('opacity', SPRINT_BOUNDARY_OPACITY);
2885
- }
2886
-
2887
- /**
2888
- * Parse a date string (YYYY, YYYY-MM, YYYY-MM-DD) to a Date object.
2889
- * Used for eras and markers which store dates as strings.
2890
- */
2891
- function parseDateStringToDate(s: string): Date {
2892
- const parts = s.split('-').map((p) => parseInt(p, 10));
2893
- // In-bounds: split() returns at least one element.
2894
- const year = parts[0]!;
2895
- const month = parts.length >= 2 ? parts[1]! - 1 : 0;
2896
- const day = parts.length >= 3 ? parts[2]! : 1;
2897
- return new Date(year, month, day);
2898
- }
2899
-
2900
- /**
2901
- * Parse a date string (YYYY, YYYY-MM, YYYY-MM-DD) to fractional year.
2902
- * Used for eras and markers which may have partial dates.
2903
- */
2904
- function parseDateToFractionalYear(s: string): number {
2905
- return dateToFractionalYear(parseDateStringToDate(s));
2906
- }
2907
-
2908
- // ── Dependency Hover Helpers ─────────────────────────────────
2909
-
2910
- const FADE_OPACITY = 0.1;
2911
-
2912
- function highlightDeps(
2913
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2914
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2915
- taskId: string,
2916
- resolved: ResolvedSchedule
2917
- ): void {
2918
- // Find immediate predecessors and successors
2919
- const related = new Set<string>([taskId]);
2920
- const task = resolved.tasks.find((t) => t.task.id === taskId);
2921
- if (!task) return;
2922
-
2923
- // Predecessors: tasks whose deps point to this task
2924
- for (const rt of resolved.tasks) {
2925
- for (const dep of rt.task.dependencies) {
2926
- // Check if this dep points to our task (forgiving normalization)
2927
- const depKey = normalizeName(dep.targetName);
2928
- if (
2929
- depKey === normalizeName(task.task.label) ||
2930
- normalizeName(
2931
- `${task.groupPath.join('.')}.${task.task.label}`
2932
- ).endsWith(depKey)
2933
- ) {
2934
- related.add(rt.task.id);
2935
- }
2936
- }
2937
- }
2938
- // Successors: tasks this task has deps pointing to
2939
- for (const dep of task.task.dependencies) {
2940
- const target = resolved.tasks.find(
2941
- (t) =>
2942
- t.task.label === dep.targetName ||
2943
- `${t.groupPath.join('.')}.${t.task.label}`.endsWith(dep.targetName)
2944
- );
2945
- if (target) related.add(target.task.id);
2946
- }
2947
-
2948
- // Fade all tasks not in related set
2949
- g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
2950
- const el = d3Selection.select(this);
2951
- const id = el.attr('data-task-id');
2952
- el.attr('opacity', id && related.has(id) ? 1 : FADE_OPACITY);
2953
- });
2954
- g.selectAll<SVGGElement, unknown>('.gantt-milestone').attr(
2955
- 'opacity',
2956
- FADE_OPACITY
2957
- );
2958
- g.selectAll<SVGElement, unknown>(
2959
- '.gantt-group-bar, .gantt-group-summary'
2960
- ).attr('opacity', FADE_OPACITY);
2961
- svg
2962
- .selectAll<SVGGElement, unknown>('.gantt-group-label')
2963
- .attr('opacity', FADE_OPACITY);
2964
- svg
2965
- .selectAll<SVGGElement, unknown>('.gantt-lane-header')
2966
- .attr('opacity', FADE_OPACITY);
2967
- g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr(
2968
- 'opacity',
2969
- FADE_OPACITY
2970
- );
2971
-
2972
- // Fade dependency arrows not connected to related tasks
2973
- g.selectAll<SVGElement, unknown>(
2974
- '.gantt-dep-arrow, .gantt-dep-arrowhead'
2975
- ).each(function () {
2976
- const el = d3Selection.select(this);
2977
- const from = el.attr('data-dep-from');
2978
- const to = el.attr('data-dep-to');
2979
- const isRelated = (from && related.has(from)) || (to && related.has(to));
2980
- el.attr('opacity', isRelated ? 0.5 : FADE_OPACITY);
2981
- });
2982
- // Fade markers
2983
- g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
2984
- 'opacity',
2985
- FADE_OPACITY
2986
- );
2987
- }
2988
-
2989
- function highlightGroup(
2990
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2991
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2992
- groupName: string
2993
- ): void {
2994
- // Fade tasks not in this group
2995
- g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
2996
- const el = d3Selection.select(this);
2997
- el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
2998
- });
2999
- // Fade milestones not in this group
3000
- g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
3001
- const el = d3Selection.select(this);
3002
- el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
3003
- });
3004
- // Fade other group bars
3005
- g.selectAll<SVGElement, unknown>(
3006
- '.gantt-group-bar, .gantt-group-summary'
3007
- ).each(function () {
3008
- const el = d3Selection.select(this);
3009
- el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
3010
- });
3011
- // Fade other group labels
3012
- svg.selectAll<SVGGElement, unknown>('.gantt-group-label').each(function () {
3013
- const el = d3Selection.select(this);
3014
- el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
3015
- });
3016
- // Fade task labels not in this group
3017
- svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
3018
- const el = d3Selection.select(this);
3019
- el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
3020
- });
3021
- // Fade group bands not matching
3022
- svg
3023
- .selectAll<
3024
- SVGElement,
3025
- unknown
3026
- >('.gantt-group-band-bg, .gantt-group-band-accent')
3027
- .each(function () {
3028
- const el = d3Selection.select(this);
3029
- el.attr(
3030
- 'opacity',
3031
- el.attr('data-group') === groupName ? 1 : FADE_OPACITY
3032
- );
3033
- });
3034
- // Fade lane elements
3035
- svg
3036
- .selectAll<SVGGElement, unknown>('.gantt-lane-header')
3037
- .attr('opacity', FADE_OPACITY);
3038
- svg
3039
- .selectAll<
3040
- SVGElement,
3041
- unknown
3042
- >('.gantt-lane-band-bg, .gantt-lane-band-accent')
3043
- .attr('opacity', FADE_OPACITY);
3044
- g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr(
3045
- 'opacity',
3046
- FADE_OPACITY
3047
- );
3048
- // Fade markers
3049
- g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
3050
- 'opacity',
3051
- FADE_OPACITY
3052
- );
3053
- }
3054
-
3055
- function highlightLane(
3056
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
3057
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
3058
- tagKey: string,
3059
- laneName: string
3060
- ): void {
3061
- const tagAttr = `data-tag-${tagKey}`;
3062
- const laneValue = laneName.toLowerCase();
3063
-
3064
- // Fade tasks not in this lane
3065
- g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
3066
- const el = d3Selection.select(this);
3067
- el.attr('opacity', el.attr(tagAttr) === laneValue ? 1 : FADE_OPACITY);
3068
- });
3069
- // Fade milestones not in this lane
3070
- g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
3071
- const el = d3Selection.select(this);
3072
- el.attr('opacity', el.attr(tagAttr) === laneValue ? 1 : FADE_OPACITY);
3073
- });
3074
- // Fade task labels not in this lane
3075
- svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
3076
- const el = d3Selection.select(this);
3077
- el.attr('opacity', el.attr(tagAttr) === laneValue ? 1 : FADE_OPACITY);
3078
- });
3079
- // Fade other lane headers
3080
- svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').each(function () {
3081
- const el = d3Selection.select(this);
3082
- el.attr('opacity', el.attr('data-lane') === laneName ? 1 : FADE_OPACITY);
3083
- });
3084
- // Fade other lane band groups
3085
- g.selectAll<SVGElement, unknown>('.gantt-lane-band-group').each(function () {
3086
- const el = d3Selection.select(this);
3087
- el.attr('opacity', el.attr('data-lane') === laneName ? 1 : FADE_OPACITY);
3088
- });
3089
- // Fade lane bands not matching
3090
- svg
3091
- .selectAll<
3092
- SVGElement,
3093
- unknown
3094
- >('.gantt-lane-band-bg, .gantt-lane-band-accent')
3095
- .each(function () {
3096
- const el = d3Selection.select(this);
3097
- el.attr('opacity', el.attr('data-lane') === laneName ? 1 : FADE_OPACITY);
3098
- });
3099
- // Fade group elements (not relevant in lane mode)
3100
- g.selectAll<SVGElement, unknown>(
3101
- '.gantt-group-bar, .gantt-group-summary'
3102
- ).attr('opacity', FADE_OPACITY);
3103
- svg
3104
- .selectAll<SVGGElement, unknown>('.gantt-group-label')
3105
- .attr('opacity', FADE_OPACITY);
3106
- svg
3107
- .selectAll<
3108
- SVGElement,
3109
- unknown
3110
- >('.gantt-group-band-bg, .gantt-group-band-accent')
3111
- .attr('opacity', FADE_OPACITY);
3112
- // Fade markers
3113
- g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
3114
- 'opacity',
3115
- FADE_OPACITY
3116
- );
3117
- }
3118
-
3119
- function highlightTask(
3120
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
3121
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
3122
- taskId: string
3123
- ): void {
3124
- // Fade tasks not matching
3125
- g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
3126
- const el = d3Selection.select(this);
3127
- el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
3128
- });
3129
- // Fade milestones not matching
3130
- g.selectAll<SVGElement, unknown>('.gantt-milestone').attr(
3131
- 'opacity',
3132
- FADE_OPACITY
3133
- );
3134
- // Fade task labels not matching
3135
- svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
3136
- const el = d3Selection.select(this);
3137
- el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
3138
- });
3139
- // Fade group/lane elements
3140
- g.selectAll<SVGElement, unknown>(
3141
- '.gantt-group-bar, .gantt-group-summary'
3142
- ).attr('opacity', FADE_OPACITY);
3143
- svg
3144
- .selectAll<SVGGElement, unknown>('.gantt-group-label')
3145
- .attr('opacity', FADE_OPACITY);
3146
- svg
3147
- .selectAll<
3148
- SVGElement,
3149
- unknown
3150
- >('.gantt-group-band-bg, .gantt-group-band-accent')
3151
- .attr('opacity', FADE_OPACITY);
3152
- svg
3153
- .selectAll<SVGGElement, unknown>('.gantt-lane-header')
3154
- .attr('opacity', FADE_OPACITY);
3155
- svg
3156
- .selectAll<
3157
- SVGElement,
3158
- unknown
3159
- >('.gantt-lane-band-bg, .gantt-lane-band-accent')
3160
- .attr('opacity', FADE_OPACITY);
3161
- g.selectAll<SVGElement, unknown>(
3162
- '.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
3163
- ).attr('opacity', FADE_OPACITY);
3164
- g.selectAll<SVGElement, unknown>(
3165
- '.gantt-dep-arrow, .gantt-dep-arrowhead'
3166
- ).attr('opacity', FADE_OPACITY);
3167
- // Fade markers
3168
- g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
3169
- 'opacity',
3170
- FADE_OPACITY
3171
- );
3172
- }
3173
-
3174
- function highlightMilestone(
3175
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
3176
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
3177
- taskId: string
3178
- ): void {
3179
- // Fade tasks
3180
- g.selectAll<SVGGElement, unknown>('.gantt-task').attr(
3181
- 'opacity',
3182
- FADE_OPACITY
3183
- );
3184
- // Fade milestones not matching
3185
- g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
3186
- const el = d3Selection.select(this);
3187
- el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
3188
- });
3189
- // Fade task labels not matching
3190
- svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
3191
- const el = d3Selection.select(this);
3192
- el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
3193
- });
3194
- // Fade group/lane elements
3195
- g.selectAll<SVGElement, unknown>(
3196
- '.gantt-group-bar, .gantt-group-summary'
3197
- ).attr('opacity', FADE_OPACITY);
3198
- svg
3199
- .selectAll<SVGGElement, unknown>('.gantt-group-label')
3200
- .attr('opacity', FADE_OPACITY);
3201
- svg
3202
- .selectAll<
3203
- SVGElement,
3204
- unknown
3205
- >('.gantt-group-band-bg, .gantt-group-band-accent')
3206
- .attr('opacity', FADE_OPACITY);
3207
- svg
3208
- .selectAll<SVGGElement, unknown>('.gantt-lane-header')
3209
- .attr('opacity', FADE_OPACITY);
3210
- svg
3211
- .selectAll<
3212
- SVGElement,
3213
- unknown
3214
- >('.gantt-lane-band-bg, .gantt-lane-band-accent')
3215
- .attr('opacity', FADE_OPACITY);
3216
- g.selectAll<SVGElement, unknown>(
3217
- '.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
3218
- ).attr('opacity', FADE_OPACITY);
3219
- g.selectAll<SVGElement, unknown>(
3220
- '.gantt-dep-arrow, .gantt-dep-arrowhead'
3221
- ).attr('opacity', FADE_OPACITY);
3222
- // Fade markers
3223
- g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
3224
- 'opacity',
3225
- FADE_OPACITY
3226
- );
3227
- }
3228
-
3229
- function highlightTaskLabel(
3230
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
3231
- lineNumber: number
3232
- ): void {
3233
- const ln = String(lineNumber);
3234
- svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
3235
- const el = d3Selection.select(this);
3236
- el.attr('opacity', el.attr('data-line-number') === ln ? 1 : FADE_OPACITY);
3237
- });
3238
- }
3239
-
3240
- function resetTaskLabels(
3241
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>
3242
- ): void {
3243
- svg
3244
- .selectAll<SVGTextElement, unknown>('.gantt-task-label')
3245
- .attr('opacity', 1);
3246
- }
3247
-
3248
- function resetHighlight(
3249
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
3250
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>
3251
- ): void {
3252
- // If critical path is actively toggled ON, restore its highlight instead of full reset
3253
- if (svg.attr('data-critical-path-active') === 'true') {
3254
- applyCriticalPathHighlight(svg, g);
3255
- return;
3256
- }
3257
- g.selectAll<SVGGElement, unknown>('.gantt-task, .gantt-milestone').attr(
3258
- 'opacity',
3259
- 1
3260
- );
3261
- g.selectAll<SVGElement, unknown>(
3262
- '.gantt-group-bar, .gantt-group-summary'
3263
- ).attr('opacity', 1);
3264
- svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', 1);
3265
- svg
3266
- .selectAll<
3267
- SVGElement,
3268
- unknown
3269
- >('.gantt-group-band-bg, .gantt-group-band-accent')
3270
- .attr('opacity', 1);
3271
- svg
3272
- .selectAll<SVGTextElement, unknown>('.gantt-task-label')
3273
- .attr('opacity', 1);
3274
- svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', 1);
3275
- svg
3276
- .selectAll<
3277
- SVGElement,
3278
- unknown
3279
- >('.gantt-lane-band-bg, .gantt-lane-band-accent')
3280
- .attr('opacity', 1);
3281
- g.selectAll<SVGElement, unknown>(
3282
- '.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
3283
- ).attr('opacity', 1);
3284
- g.selectAll<SVGElement, unknown>(
3285
- '.gantt-dep-arrow, .gantt-dep-arrowhead'
3286
- ).attr('opacity', 0.5);
3287
- g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', 1);
3288
- g.selectAll<SVGElement, unknown>('.gantt-era-group').attr('opacity', 1);
3289
- }
3290
-
3291
- // ── Row Building ────────────────────────────────────────────
3292
-
3293
- type GroupRow = { type: 'group'; group: ResolvedGroup };
3294
- type TaskRow = { type: 'task'; task: ResolvedTask };
3295
- type LaneHeaderRow = {
3296
- type: 'lane-header';
3297
- laneName: string;
3298
- laneColor: string;
3299
- aggregateProgress: number | null;
3300
- tagKey: string;
3301
- isCollapsed: boolean;
3302
- laneStartDate: Date | null;
3303
- laneEndDate: Date | null;
3304
- };
3305
- type Row = GroupRow | TaskRow | LaneHeaderRow;
3306
-
3307
- // Public type aliases (prefixed to avoid collisions in consumer code)
3308
- export type {
3309
- GroupRow as GanttGroupRow,
3310
- TaskRow as GanttTaskRow,
3311
- LaneHeaderRow as GanttLaneHeaderRow,
3312
- Row as GanttRow,
3313
- };
3314
-
3315
- function buildRowList(
3316
- resolved: ResolvedSchedule,
3317
- collapsedGroups?: Set<string>
3318
- ): Row[] {
3319
- const rows: Row[] = [];
3320
- const groupMap = new Map<string, ResolvedGroup>();
3321
- for (const g of resolved.groups) {
3322
- groupMap.set(g.name, g);
3323
- }
3324
-
3325
- // Sort tasks by group order so tasks from the same group are contiguous.
3326
- // resolved.groups is in parse-tree order; use that as the sort key.
3327
- // Tasks with no group come first, then tasks grouped by their groupPath.
3328
- const groupOrder = new Map<string, number>();
3329
- resolved.groups.forEach((g, i) => groupOrder.set(g.name, i));
3330
-
3331
- const sortedTasks = [...resolved.tasks].sort((a, b) => {
3332
- const maxLen = Math.max(a.groupPath.length, b.groupPath.length);
3333
- for (let i = 0; i < maxLen; i++) {
3334
- const ga = a.groupPath[i];
3335
- const gb = b.groupPath[i];
3336
- if (ga === gb) continue;
3337
- // Task with shorter path (no group at this level) comes first
3338
- if (ga === undefined) return -1;
3339
- if (gb === undefined) return 1;
3340
- const oa = groupOrder.get(ga) ?? 0;
3341
- const ob = groupOrder.get(gb) ?? 0;
3342
- if (oa !== ob) return oa - ob;
3343
- }
3344
- return 0; // same group — preserve original (topo-sort) order
3345
- });
3346
-
3347
- // Build a flat display list from the resolved groups and tasks
3348
- // Groups appear before their children. Collapsed groups hide children.
3349
- const seenGroups = new Set<string>();
3350
- for (const rt of sortedTasks) {
3351
- // Check if any group in this task's path is collapsed
3352
- const isHidden = rt.groupPath.some((g) => collapsedGroups?.has(g));
3353
- if (isHidden) {
3354
- // Still insert collapsed group headers if not seen
3355
- for (const groupName of rt.groupPath) {
3356
- if (!seenGroups.has(groupName)) {
3357
- seenGroups.add(groupName);
3358
- const group = groupMap.get(groupName);
3359
- if (group) {
3360
- rows.push({ type: 'group', group });
3361
- }
3362
- }
3363
- if (collapsedGroups?.has(groupName)) break; // stop at collapsed group
3364
- }
3365
- continue; // skip task row
3366
- }
3367
-
3368
- // Insert group rows for any groups in the path not yet seen
3369
- for (let i = 0; i < rt.groupPath.length; i++) {
3370
- const groupName = rt.groupPath[i];
3371
- if (groupName === undefined) continue; // In-bounds by loop guard; appeases TS.
3372
- if (!seenGroups.has(groupName)) {
3373
- seenGroups.add(groupName);
3374
- const group = groupMap.get(groupName);
3375
- if (group) {
3376
- rows.push({ type: 'group', group });
3377
- }
3378
- }
3379
- }
3380
- rows.push({ type: 'task', task: rt });
3381
- }
3382
-
3383
- return rows;
3384
- }
3385
-
3386
- // ── Tag Lane Row Building ──────────────────────────────────
3387
-
3388
- export function buildTagLaneRowList(
3389
- resolved: ResolvedSchedule,
3390
- swimlaneGroup: string,
3391
- collapsedLanes?: Set<string>
3392
- ): Row[] | null {
3393
- const tagGroup = resolved.tagGroups.find(
3394
- (g) => g.name.toLowerCase() === swimlaneGroup.toLowerCase()
3395
- );
3396
- if (!tagGroup) return null;
3397
-
3398
- const tagKey = tagAttrKey(tagGroup.name);
3399
- const rows: Row[] = [];
3400
-
3401
- // Bucket tasks by tag value
3402
- const buckets = new Map<string, ResolvedTask[]>();
3403
- const unbucketed: ResolvedTask[] = [];
3404
-
3405
- for (const rt of resolved.tasks) {
3406
- let value = rt.effectiveMetadata[tagKey];
3407
- if (!value && tagGroup.defaultValue) {
3408
- value = tagGroup.defaultValue;
3409
- }
3410
- if (value) {
3411
- const key = value.toLowerCase();
3412
- if (!buckets.has(key)) buckets.set(key, []);
3413
- buckets.get(key)!.push(rt);
3414
- } else {
3415
- unbucketed.push(rt);
3416
- }
3417
- }
3418
-
3419
- // Emit lanes in tag entry declaration order (skip empty lanes)
3420
- for (const entry of tagGroup.entries) {
3421
- const entryKey = entry.value.toLowerCase();
3422
- const tasks = buckets.get(entryKey) ?? [];
3423
- if (tasks.length === 0) continue;
3424
- // Sort tasks within lane by start date
3425
- tasks.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
3426
-
3427
- // Compute duration-weighted aggregate progress (tasks without progress count as 0%)
3428
- const aggregateProgress = durationWeightedProgress(tasks);
3429
-
3430
- // Compute lane date range from tasks
3431
- const laneStartDate =
3432
- tasks.length > 0
3433
- ? new Date(Math.min(...tasks.map((t) => t.startDate.getTime())))
3434
- : null;
3435
- const laneEndDate =
3436
- tasks.length > 0
3437
- ? new Date(Math.max(...tasks.map((t) => t.endDate.getTime())))
3438
- : null;
3439
-
3440
- const isCollapsed = collapsedLanes?.has(entry.value) ?? false;
3441
- rows.push({
3442
- type: 'lane-header',
3443
- laneName: entry.value,
3444
- laneColor: entry.color,
3445
- aggregateProgress,
3446
- tagKey,
3447
- isCollapsed,
3448
- laneStartDate,
3449
- laneEndDate,
3450
- });
3451
- if (!isCollapsed) {
3452
- for (const rt of tasks) {
3453
- rows.push({ type: 'task', task: rt });
3454
- }
3455
- }
3456
- }
3457
-
3458
- // Append unbucketed tasks as "No {GroupName}" lane
3459
- if (unbucketed.length > 0) {
3460
- unbucketed.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
3461
- const aggregateProgress = durationWeightedProgress(unbucketed);
3462
-
3463
- const noLaneStartDate =
3464
- unbucketed.length > 0
3465
- ? new Date(Math.min(...unbucketed.map((t) => t.startDate.getTime())))
3466
- : null;
3467
- const noLaneEndDate =
3468
- unbucketed.length > 0
3469
- ? new Date(Math.max(...unbucketed.map((t) => t.endDate.getTime())))
3470
- : null;
3471
-
3472
- const noLaneName = `No ${tagGroup.name}`;
3473
- const isCollapsed = collapsedLanes?.has(noLaneName) ?? false;
3474
- rows.push({
3475
- type: 'lane-header',
3476
- laneName: noLaneName,
3477
- laneColor: '#999999',
3478
- aggregateProgress,
3479
- tagKey,
3480
- isCollapsed,
3481
- laneStartDate: noLaneStartDate,
3482
- laneEndDate: noLaneEndDate,
3483
- });
3484
- if (!isCollapsed) {
3485
- for (const rt of unbucketed) {
3486
- rows.push({ type: 'task', task: rt });
3487
- }
3488
- }
3489
- }
3490
-
3491
- return rows;
3492
- }
3493
-
3494
- // ── Helpers ─────────────────────────────────────────────────
3495
-
3496
- /** Duration-weighted progress: tasks without explicit progress count as 0%. Returns null if no task has progress. */
3497
- function durationWeightedProgress(tasks: ResolvedTask[]): number | null {
3498
- let totalDuration = 0;
3499
- let totalProgress = 0;
3500
- let hasProgress = false;
3501
- for (const rt of tasks) {
3502
- const dur = rt.endDate.getTime() - rt.startDate.getTime();
3503
- totalDuration += dur;
3504
- if (rt.task.progress !== null) {
3505
- totalProgress += rt.task.progress * dur;
3506
- hasProgress = true;
3507
- }
3508
- }
3509
- return hasProgress && totalDuration > 0
3510
- ? totalProgress / totalDuration
3511
- : null;
3512
- }
3513
-
3514
- function dateToFractionalYear(d: Date): number {
3515
- const y = d.getFullYear();
3516
- const startOfYear = new Date(y, 0, 1);
3517
- const endOfYear = new Date(y + 1, 0, 1);
3518
- const fraction =
3519
- (d.getTime() - startOfYear.getTime()) /
3520
- (endOfYear.getTime() - startOfYear.getTime());
3521
- return y + fraction;
3522
- }
3523
-
3524
- function diamondPoints(cx: number, cy: number, size: number): string {
3525
- const half = size / 2;
3526
- return `${cx},${cy - half} ${cx + half},${cy} ${cx},${cy + half} ${cx - half},${cy}`;
3527
- }
3528
-
3529
- // ── Hover Date Indicators ───────────────────────────────────
3530
-
3531
- function formatGanttDate(d: Date): string {
3532
- const base = `${MONTH_ABBR[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
3533
- if (d.getHours() === 0 && d.getMinutes() === 0) return base;
3534
- const hh = String(d.getHours()).padStart(2, '0');
3535
- const mm = String(d.getMinutes()).padStart(2, '0');
3536
- return `${base} ${hh}:${mm}`;
3537
- }
3538
-
3539
- function showGanttDateIndicators(
3540
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
3541
- xScale: d3Scale.ScaleLinear<number, number>,
3542
- startDate: Date,
3543
- endDate: Date | null,
3544
- innerHeight: number,
3545
- color: string,
3546
- options?: { skipStartLine?: boolean }
3547
- ): void {
3548
- // Fade existing scale ticks and today marker
3549
- g.selectAll('.gantt-scale-tick').attr('opacity', 0.05);
3550
- g.selectAll('.gantt-today').attr('opacity', 0.05);
3551
-
3552
- // Wrap all hover indicators in a group that ignores pointer events,
3553
- // so they don't steal mouseleave from the element being hovered.
3554
- const hg = g
3555
- .append('g')
3556
- .attr('class', 'gantt-hover-date')
3557
- .attr('pointer-events', 'none');
3558
-
3559
- const tickLen = 6;
3560
- const startPos = xScale(dateToFractionalYear(startDate));
3561
- const startLabel = formatGanttDate(startDate);
3562
-
3563
- // Start date — dashed vertical line (skip when caller already shows its own line)
3564
- if (!options?.skipStartLine) {
3565
- hg.append('line')
3566
- .attr('class', 'gantt-hover-date')
3567
- .attr('x1', startPos)
3568
- .attr('y1', -tickLen)
3569
- .attr('x2', startPos)
3570
- .attr('y2', innerHeight)
3571
- .attr('stroke', color)
3572
- .attr('stroke-width', 1.5)
3573
- .attr('stroke-dasharray', '4 4')
3574
- .attr('opacity', 0.6);
3575
- }
3576
-
3577
- // Start date — top label
3578
- hg.append('text')
3579
- .attr('class', 'gantt-hover-date')
3580
- .attr('x', startPos)
3581
- .attr('y', -tickLen - 4)
3582
- .attr('text-anchor', 'middle')
3583
- .attr('fill', color)
3584
- .attr('font-size', '10px')
3585
- .attr('font-weight', '600')
3586
- .text(startLabel);
3587
-
3588
- // Start date — bottom label
3589
- hg.append('text')
3590
- .attr('class', 'gantt-hover-date')
3591
- .attr('x', startPos)
3592
- .attr('y', innerHeight + tickLen + 12)
3593
- .attr('text-anchor', 'middle')
3594
- .attr('fill', color)
3595
- .attr('font-size', '10px')
3596
- .attr('font-weight', '600')
3597
- .text(startLabel);
3598
-
3599
- if (endDate && endDate.getTime() !== startDate.getTime()) {
3600
- const endPos = xScale(dateToFractionalYear(endDate));
3601
- const endLabel = formatGanttDate(endDate);
3602
-
3603
- // When dates are close, push labels apart so they don't overlap.
3604
- // ~90px is roughly the width of a date label like "Aug 12, 2026" at 10px.
3605
- const minLabelGap = 90;
3606
- const gap = endPos - startPos;
3607
- let startLabelX = startPos;
3608
- let endLabelX = endPos;
3609
- let startAnchor = 'middle';
3610
- let endAnchor = 'middle';
3611
- if (gap < minLabelGap) {
3612
- const mid = (startPos + endPos) / 2;
3613
- startLabelX = mid - minLabelGap / 2;
3614
- endLabelX = mid + minLabelGap / 2;
3615
- startAnchor = 'middle';
3616
- endAnchor = 'middle';
3617
- }
3618
-
3619
- // End date — dashed vertical line
3620
- hg.append('line')
3621
- .attr('class', 'gantt-hover-date')
3622
- .attr('x1', endPos)
3623
- .attr('y1', -tickLen)
3624
- .attr('x2', endPos)
3625
- .attr('y2', innerHeight)
3626
- .attr('stroke', color)
3627
- .attr('stroke-width', 1.5)
3628
- .attr('stroke-dasharray', '4 4')
3629
- .attr('opacity', 0.6);
3630
-
3631
- // Reposition start labels to avoid overlap
3632
- hg.selectAll<SVGTextElement, unknown>('text.gantt-hover-date').each(
3633
- function () {
3634
- const el = d3Selection.select(this);
3635
- if (el.text() === startLabel) {
3636
- el.attr('x', startLabelX).attr('text-anchor', startAnchor);
3637
- }
3638
- }
3639
- );
3640
-
3641
- // End date — top label
3642
- hg.append('text')
3643
- .attr('class', 'gantt-hover-date')
3644
- .attr('x', endLabelX)
3645
- .attr('y', -tickLen - 4)
3646
- .attr('text-anchor', endAnchor)
3647
- .attr('fill', color)
3648
- .attr('font-size', '10px')
3649
- .attr('font-weight', '600')
3650
- .text(endLabel);
3651
-
3652
- // End date — bottom label
3653
- hg.append('text')
3654
- .attr('class', 'gantt-hover-date')
3655
- .attr('x', endLabelX)
3656
- .attr('y', innerHeight + tickLen + 12)
3657
- .attr('text-anchor', endAnchor)
3658
- .attr('fill', color)
3659
- .attr('font-size', '10px')
3660
- .attr('font-weight', '600')
3661
- .text(endLabel);
3662
- }
3663
- }
3664
-
3665
- function hideGanttDateIndicators(
3666
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>
3667
- ): void {
3668
- g.selectAll('.gantt-hover-date').remove();
3669
- // Restore scale tick opacity
3670
- g.selectAll('.gantt-scale-tick').each(function () {
3671
- const el = d3Selection.select(this);
3672
- const isDashed = el.attr('stroke-dasharray');
3673
- el.attr('opacity', isDashed ? 0.15 : 0.4);
3674
- });
3675
- // Restore today marker opacity
3676
- g.selectAll('.gantt-today').attr('opacity', 0.7);
3677
- }
3678
-
3679
- function resolveTaskColor(
3680
- rt: ResolvedTask,
3681
- activeTagGroup: string | null,
3682
- resolved: ResolvedSchedule,
3683
- seriesColors: string[],
3684
- palette: PaletteColors
3685
- ): string {
3686
- // Try tag-based coloring first
3687
- const tagColor = resolveTagColor(
3688
- rt.effectiveMetadata,
3689
- resolved.tagGroups,
3690
- activeTagGroup
3691
- );
3692
- if (tagColor && tagColor !== '#999999') return tagColor;
3693
-
3694
- // Fall back to group-based coloring
3695
- if (rt.groupPath.length > 0) {
3696
- // In-bounds by length check above.
3697
- const topGroup = rt.groupPath[0]!;
3698
- const groupIdx = resolved.groups.findIndex((g) => g.name === topGroup);
3699
- if (groupIdx >= 0) {
3700
- // In-bounds: findIndex >= 0 means index is valid.
3701
- const group = resolved.groups[groupIdx]!;
3702
- if (group.color) return group.color;
3703
- // In-bounds: i % n is always 0..n-1 for n > 0; seriesColors is non-empty here.
3704
- return seriesColors[groupIdx % seriesColors.length]!;
3705
- }
3706
- }
3707
-
3708
- // Default
3709
- return palette.accent || seriesColors[0] || '#4a90d9';
3710
- }
3711
-
3712
- function renderTimeScaleHorizontal(
3713
- g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
3714
- scale: d3Scale.ScaleLinear<number, number>,
3715
- _innerWidth: number,
3716
- innerHeight: number,
3717
- textColor: string
3718
- ): void {
3719
- const domain = scale.domain();
3720
- // In-bounds: d3 ScaleLinear.domain() returns at least 2 elements.
3721
- const domainMin = domain[0]!;
3722
- const domainMax = domain[1]!;
3723
- const ticks = computeTimeTicks(domainMin, domainMax, scale);
3724
- if (ticks.length < 2) return;
3725
-
3726
- const tickLen = 6;
3727
- const opacity = 0.4;
3728
- const guideOpacity = 0.15;
3729
-
3730
- for (const tick of ticks) {
3731
- // Guide line
3732
- g.append('line')
3733
- .attr('class', 'gantt-scale-tick')
3734
- .attr('x1', tick.pos)
3735
- .attr('y1', 0)
3736
- .attr('x2', tick.pos)
3737
- .attr('y2', innerHeight)
3738
- .attr('stroke', textColor)
3739
- .attr('stroke-width', 1)
3740
- .attr('stroke-dasharray', '4 4')
3741
- .attr('opacity', guideOpacity);
3742
-
3743
- // Top tick
3744
- g.append('line')
3745
- .attr('class', 'gantt-scale-tick')
3746
- .attr('x1', tick.pos)
3747
- .attr('y1', 0)
3748
- .attr('x2', tick.pos)
3749
- .attr('y2', -tickLen)
3750
- .attr('stroke', textColor)
3751
- .attr('stroke-width', 1)
3752
- .attr('opacity', opacity);
3753
-
3754
- // Top label
3755
- g.append('text')
3756
- .attr('class', 'gantt-scale-tick')
3757
- .attr('x', tick.pos)
3758
- .attr('y', -tickLen - 4)
3759
- .attr('text-anchor', 'middle')
3760
- .attr('dominant-baseline', 'auto')
3761
- .attr('font-size', '10px')
3762
- .attr('fill', textColor)
3763
- .attr('opacity', opacity)
3764
- .text(tick.label);
3765
-
3766
- // Bottom tick
3767
- g.append('line')
3768
- .attr('class', 'gantt-scale-tick')
3769
- .attr('x1', tick.pos)
3770
- .attr('y1', innerHeight)
3771
- .attr('x2', tick.pos)
3772
- .attr('y2', innerHeight + tickLen)
3773
- .attr('stroke', textColor)
3774
- .attr('stroke-width', 1)
3775
- .attr('opacity', opacity);
3776
-
3777
- g.append('text')
3778
- .attr('class', 'gantt-scale-tick')
3779
- .attr('x', tick.pos)
3780
- .attr('y', innerHeight + tickLen + 12)
3781
- .attr('text-anchor', 'middle')
3782
- .attr('font-size', '10px')
3783
- .attr('fill', textColor)
3784
- .attr('opacity', opacity)
3785
- .text(tick.label);
3786
- }
3787
- }