@diagrammo/dgmo 0.47.0 → 0.48.1

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 (345) hide show
  1. package/dist/advanced.js +11 -11
  2. package/dist/auto.js +16 -16
  3. package/dist/auto.mjs +41 -12
  4. package/dist/block.js +4 -4
  5. package/dist/{chunk-FNB3PNGF.js → chunk-7DTWEFUE.js} +3 -3
  6. package/dist/{chunk-2DXMZRWJ.js → chunk-B65G5DNL.js} +8 -1
  7. package/dist/{chunk-RZCF2VVM.js → chunk-B7JUCQUA.js} +3 -1
  8. package/dist/{chunk-DU7W62N5.js → chunk-FANX6UJR.js} +3 -3
  9. package/dist/{chunk-6FZYCKXB.js → chunk-HSH73DP6.js} +2 -2
  10. package/dist/{chunk-P4RH4OSP.js → chunk-ILKORZ3H.js} +2 -2
  11. package/dist/{chunk-Y5QGVTVC.js → chunk-JTSMEYIZ.js} +7 -3
  12. package/dist/{chunk-RCERWOJR.js → chunk-ODZVHU3R.js} +1 -2
  13. package/dist/{chunk-DZAQBW35.js → chunk-SGZYXELT.js} +3 -7
  14. package/dist/{chunk-CUVHQHZ7.js → chunk-THXCFRTU.js} +3 -3
  15. package/dist/{chunk-IH6ZMVQ5.js → chunk-UC7TFHUM.js} +29 -11
  16. package/dist/{chunk-EMS2SK26.js → chunk-ZBWMSD5N.js} +3 -1
  17. package/dist/cli.cjs +15 -15
  18. package/dist/{dgmo-router-DZCIDUO5.js → dgmo-router-X35VLZLM.js} +3 -3
  19. package/dist/element.js +16 -16
  20. package/dist/element.mjs +41 -12
  21. package/dist/{flowchart-renderer-ZAEOL4DX.js → flowchart-renderer-SNDSII4D.js} +2 -2
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.js +4 -4
  24. package/dist/{parser-FUSKFGZN.js → parser-2EWECDSA.js} +2 -2
  25. package/dist/{renderer-UWA5XS74.js → renderer-4JHRUBE6.js} +3 -3
  26. package/dist/{renderer-WZRREQ2P.js → renderer-5MYBMBYO.js} +2 -2
  27. package/dist/{renderer-TOCOVLHS.js → renderer-B2OMQIQG.js} +3 -3
  28. package/dist/{renderer-2XQQQ6QR.js → renderer-N43WR5GV.js} +3 -3
  29. package/dist/{renderer-T7YKC5VP.js → renderer-ZRNG54CW.js} +3 -3
  30. package/dist/{state-renderer-RFYHSP4K.js → state-renderer-B4DQBM6M.js} +2 -2
  31. package/package.json +23 -71
  32. package/dist/advanced.cjs +0 -71648
  33. package/dist/advanced.d.cts +0 -5719
  34. package/dist/auto.cjs +0 -70648
  35. package/dist/auto.d.cts +0 -54
  36. package/dist/block.cjs +0 -70208
  37. package/dist/block.d.cts +0 -115
  38. package/dist/editor.cjs +0 -526
  39. package/dist/editor.d.cts +0 -26
  40. package/dist/element.cjs +0 -70530
  41. package/dist/element.d.cts +0 -62
  42. package/dist/highlight.cjs +0 -852
  43. package/dist/highlight.d.cts +0 -49
  44. package/dist/index.cjs +0 -70491
  45. package/dist/index.d.cts +0 -160
  46. package/dist/pert.cjs +0 -325
  47. package/dist/pert.d.cts +0 -559
  48. package/dist/themes-1-CuKpeH.d.cts +0 -908
  49. package/src/advanced.ts +0 -873
  50. package/src/arc/parser.ts +0 -15
  51. package/src/arc/renderer.ts +0 -633
  52. package/src/auto/index.ts +0 -621
  53. package/src/auto/shared.ts +0 -370
  54. package/src/auto/styles.ts +0 -73
  55. package/src/block/layout.ts +0 -198
  56. package/src/block/parser.ts +0 -427
  57. package/src/block/renderer.ts +0 -484
  58. package/src/block/types.ts +0 -67
  59. package/src/boxes-and-lines/collapse.ts +0 -96
  60. package/src/boxes-and-lines/focus.ts +0 -257
  61. package/src/boxes-and-lines/label-placement.ts +0 -339
  62. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  63. package/src/boxes-and-lines/layout-layered.ts +0 -722
  64. package/src/boxes-and-lines/layout-search.ts +0 -1678
  65. package/src/boxes-and-lines/layout.ts +0 -398
  66. package/src/boxes-and-lines/parser.ts +0 -1181
  67. package/src/boxes-and-lines/renderer.ts +0 -1465
  68. package/src/boxes-and-lines/types.ts +0 -66
  69. package/src/c4/layout.ts +0 -2441
  70. package/src/c4/parser.ts +0 -1158
  71. package/src/c4/renderer.ts +0 -2008
  72. package/src/c4/types.ts +0 -89
  73. package/src/chart-type-registry.ts +0 -529
  74. package/src/chart-types.ts +0 -225
  75. package/src/chart.ts +0 -685
  76. package/src/charts-d3/bar.ts +0 -338
  77. package/src/charts-d3/chord.ts +0 -140
  78. package/src/charts-d3/function.ts +0 -147
  79. package/src/charts-d3/funnel.ts +0 -104
  80. package/src/charts-d3/heatmap.ts +0 -145
  81. package/src/charts-d3/index.ts +0 -337
  82. package/src/charts-d3/interactions.ts +0 -477
  83. package/src/charts-d3/line.ts +0 -344
  84. package/src/charts-d3/mount.ts +0 -151
  85. package/src/charts-d3/pie.ts +0 -117
  86. package/src/charts-d3/polar.ts +0 -89
  87. package/src/charts-d3/radar.ts +0 -0
  88. package/src/charts-d3/sankey.ts +0 -0
  89. package/src/charts-d3/scatter.ts +0 -185
  90. package/src/charts-d3/shared.ts +0 -208
  91. package/src/class/layout.ts +0 -326
  92. package/src/class/parser.ts +0 -635
  93. package/src/class/renderer.ts +0 -793
  94. package/src/class/types.ts +0 -58
  95. package/src/cli-banner.ts +0 -106
  96. package/src/cli.ts +0 -1080
  97. package/src/colors-diagnostics.ts +0 -35
  98. package/src/colors.ts +0 -379
  99. package/src/completion-types.ts +0 -27
  100. package/src/completion.ts +0 -2668
  101. package/src/cycle/layout.ts +0 -1026
  102. package/src/cycle/parser.ts +0 -387
  103. package/src/cycle/renderer.ts +0 -653
  104. package/src/cycle/types.ts +0 -101
  105. package/src/d3.ts +0 -1434
  106. package/src/data-chart-parser.ts +0 -1007
  107. package/src/dgmo-router.ts +0 -296
  108. package/src/diagnostics-registry.ts +0 -108
  109. package/src/diagnostics.ts +0 -0
  110. package/src/dimensions.ts +0 -20
  111. package/src/directives-registry.ts +0 -396
  112. package/src/editor/dgmo.grammar +0 -76
  113. package/src/editor/dgmo.grammar.d.ts +0 -2
  114. package/src/editor/dgmo.grammar.js +0 -18
  115. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  116. package/src/editor/dgmo.grammar.terms.js +0 -35
  117. package/src/editor/highlight-api.ts +0 -596
  118. package/src/editor/highlight.ts +0 -36
  119. package/src/editor/index.ts +0 -27
  120. package/src/editor/keywords.ts +0 -128
  121. package/src/editor/tokens.ts +0 -30
  122. package/src/element/index.ts +0 -432
  123. package/src/embed/css.ts +0 -312
  124. package/src/embed/escape.ts +0 -15
  125. package/src/embed/index.ts +0 -280
  126. package/src/er/classify.ts +0 -228
  127. package/src/er/layout.ts +0 -530
  128. package/src/er/parser.ts +0 -750
  129. package/src/er/renderer.ts +0 -725
  130. package/src/er/types.ts +0 -52
  131. package/src/error-card.ts +0 -269
  132. package/src/event-line/diagnostics.ts +0 -117
  133. package/src/event-line/parser.ts +0 -643
  134. package/src/event-line/renderer.ts +0 -1742
  135. package/src/event-line/types.ts +0 -74
  136. package/src/fonts.ts +0 -3
  137. package/src/gantt/calculator.ts +0 -979
  138. package/src/gantt/parser.ts +0 -1641
  139. package/src/gantt/renderer.ts +0 -3787
  140. package/src/gantt/resolver.ts +0 -172
  141. package/src/gantt/types.ts +0 -206
  142. package/src/graph/diagnostics.ts +0 -64
  143. package/src/graph/edge-spline.ts +0 -29
  144. package/src/graph/flowchart-parser.ts +0 -759
  145. package/src/graph/flowchart-renderer.ts +0 -817
  146. package/src/graph/layout.ts +0 -472
  147. package/src/graph/notes.ts +0 -21
  148. package/src/graph/state-collapse.ts +0 -78
  149. package/src/graph/state-parser.ts +0 -574
  150. package/src/graph/state-renderer.ts +0 -653
  151. package/src/graph/types.ts +0 -65
  152. package/src/index.ts +0 -261
  153. package/src/infra/compute.ts +0 -1385
  154. package/src/infra/layout.ts +0 -888
  155. package/src/infra/parser.ts +0 -1234
  156. package/src/infra/renderer.ts +0 -2534
  157. package/src/infra/roles.ts +0 -75
  158. package/src/infra/types.ts +0 -216
  159. package/src/infra/validation.ts +0 -201
  160. package/src/journey-map/layout.ts +0 -490
  161. package/src/journey-map/parser.ts +0 -557
  162. package/src/journey-map/renderer.ts +0 -1580
  163. package/src/journey-map/types.ts +0 -47
  164. package/src/kanban/mutations.ts +0 -175
  165. package/src/kanban/parser.ts +0 -509
  166. package/src/kanban/renderer.ts +0 -1449
  167. package/src/kanban/types.ts +0 -39
  168. package/src/label-layout.ts +0 -368
  169. package/src/map/colorize.ts +0 -54
  170. package/src/map/completion.ts +0 -316
  171. package/src/map/context-labels.ts +0 -609
  172. package/src/map/data/PROVENANCE.json +0 -1
  173. package/src/map/data/README.md +0 -40
  174. package/src/map/data/airport-collisions.json +0 -1
  175. package/src/map/data/airports.json +0 -1
  176. package/src/map/data/gazetteer.json +0 -1
  177. package/src/map/data/lakes.json +0 -1
  178. package/src/map/data/mountain-ranges.json +0 -1
  179. package/src/map/data/na-lakes.json +0 -1
  180. package/src/map/data/na-land.json +0 -1
  181. package/src/map/data/region-names.json +0 -1
  182. package/src/map/data/rivers.json +0 -1
  183. package/src/map/data/types.ts +0 -121
  184. package/src/map/data/us-states.json +0 -1
  185. package/src/map/data/water-bodies.json +0 -1
  186. package/src/map/data/world-coarse.json +0 -1
  187. package/src/map/data/world-detail.json +0 -1
  188. package/src/map/diagnostics.ts +0 -134
  189. package/src/map/dimensions.ts +0 -150
  190. package/src/map/geo-query.ts +0 -295
  191. package/src/map/geo.ts +0 -438
  192. package/src/map/invert.ts +0 -111
  193. package/src/map/layout.ts +0 -5069
  194. package/src/map/legend-band.ts +0 -127
  195. package/src/map/load-data.ts +0 -186
  196. package/src/map/parser.ts +0 -783
  197. package/src/map/renderer.ts +0 -1445
  198. package/src/map/resolved-types.ts +0 -160
  199. package/src/map/resolver.ts +0 -1146
  200. package/src/map/types.ts +0 -194
  201. package/src/mindmap/collapse.ts +0 -67
  202. package/src/mindmap/layout.ts +0 -649
  203. package/src/mindmap/parser.ts +0 -430
  204. package/src/mindmap/renderer.ts +0 -637
  205. package/src/mindmap/text-wrap.ts +0 -218
  206. package/src/mindmap/types.ts +0 -56
  207. package/src/org/collapse.ts +0 -151
  208. package/src/org/layout.ts +0 -1262
  209. package/src/org/parser.ts +0 -519
  210. package/src/org/renderer.ts +0 -866
  211. package/src/org/resolver.ts +0 -636
  212. package/src/palettes/atlas.ts +0 -77
  213. package/src/palettes/blueprint.ts +0 -73
  214. package/src/palettes/catppuccin.ts +0 -84
  215. package/src/palettes/color-utils.ts +0 -443
  216. package/src/palettes/index.ts +0 -67
  217. package/src/palettes/nord.ts +0 -67
  218. package/src/palettes/registry.ts +0 -114
  219. package/src/palettes/slate.ts +0 -73
  220. package/src/palettes/tidewater.ts +0 -73
  221. package/src/palettes/tokyo-night.ts +0 -86
  222. package/src/palettes/types.ts +0 -88
  223. package/src/pert/analyzer.ts +0 -1183
  224. package/src/pert/diagnostics.ts +0 -110
  225. package/src/pert/index.ts +0 -43
  226. package/src/pert/internal.ts +0 -252
  227. package/src/pert/layout.ts +0 -971
  228. package/src/pert/monte-carlo.ts +0 -446
  229. package/src/pert/parser.ts +0 -1606
  230. package/src/pert/renderer.ts +0 -4009
  231. package/src/pert/share-normalize.ts +0 -62
  232. package/src/pert/types.ts +0 -604
  233. package/src/pyramid/parser.ts +0 -185
  234. package/src/pyramid/renderer.ts +0 -768
  235. package/src/pyramid/types.ts +0 -28
  236. package/src/quadrant/parser.ts +0 -18
  237. package/src/quadrant/renderer.ts +0 -638
  238. package/src/raci/diagnostics.ts +0 -213
  239. package/src/raci/index.ts +0 -23
  240. package/src/raci/mutations.ts +0 -321
  241. package/src/raci/parser.ts +0 -699
  242. package/src/raci/renderer.ts +0 -1627
  243. package/src/raci/types.ts +0 -82
  244. package/src/raci/variants.ts +0 -320
  245. package/src/render.ts +0 -268
  246. package/src/ring/parser.ts +0 -212
  247. package/src/ring/renderer.ts +0 -490
  248. package/src/ring/types.ts +0 -27
  249. package/src/sequence/collapse.ts +0 -174
  250. package/src/sequence/parser.ts +0 -1596
  251. package/src/sequence/participant-inference.ts +0 -152
  252. package/src/sequence/renderer.ts +0 -3168
  253. package/src/sequence/tag-resolution.ts +0 -164
  254. package/src/sharing.ts +0 -211
  255. package/src/sitemap/collapse.ts +0 -100
  256. package/src/sitemap/layout.ts +0 -925
  257. package/src/sitemap/parser.ts +0 -755
  258. package/src/sitemap/renderer.ts +0 -828
  259. package/src/sitemap/types.ts +0 -42
  260. package/src/slope/parser.ts +0 -18
  261. package/src/slope/renderer.ts +0 -378
  262. package/src/swimlane/diagnostics.ts +0 -94
  263. package/src/swimlane/layout.ts +0 -597
  264. package/src/swimlane/parser.ts +0 -613
  265. package/src/swimlane/renderer.ts +0 -681
  266. package/src/swimlane/types.ts +0 -151
  267. package/src/tech-radar/interactive.ts +0 -1129
  268. package/src/tech-radar/layout.ts +0 -197
  269. package/src/tech-radar/parser.ts +0 -436
  270. package/src/tech-radar/renderer.ts +0 -1239
  271. package/src/tech-radar/shared.ts +0 -151
  272. package/src/tech-radar/types.ts +0 -86
  273. package/src/themes.ts +0 -22
  274. package/src/timeline/parser.ts +0 -472
  275. package/src/timeline/renderer.ts +0 -3024
  276. package/src/timeline/types.ts +0 -33
  277. package/src/timeline/viz-parser.ts +0 -21
  278. package/src/treemap/diagnostics.ts +0 -62
  279. package/src/treemap/layout-radial.ts +0 -233
  280. package/src/treemap/layout.ts +0 -189
  281. package/src/treemap/parser.ts +0 -463
  282. package/src/treemap/renderer-radial.ts +0 -438
  283. package/src/treemap/renderer.ts +0 -572
  284. package/src/treemap/treemap-shared.ts +0 -309
  285. package/src/treemap/types.ts +0 -60
  286. package/src/utils/arrow-markers.ts +0 -51
  287. package/src/utils/arrows-diagnostics.ts +0 -62
  288. package/src/utils/arrows.ts +0 -227
  289. package/src/utils/brand.ts +0 -61
  290. package/src/utils/card.ts +0 -183
  291. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  292. package/src/utils/collapse-engine/tree.ts +0 -117
  293. package/src/utils/d3-helpers.ts +0 -219
  294. package/src/utils/d3-types.ts +0 -9
  295. package/src/utils/description-helpers.ts +0 -38
  296. package/src/utils/duration.ts +0 -312
  297. package/src/utils/export-container.ts +0 -41
  298. package/src/utils/expr-eval.ts +0 -27
  299. package/src/utils/fit-canvas.ts +0 -64
  300. package/src/utils/hover-styles.ts +0 -673
  301. package/src/utils/inline-markdown.ts +0 -96
  302. package/src/utils/legend-constants.ts +0 -48
  303. package/src/utils/legend-d3.ts +0 -618
  304. package/src/utils/legend-integration.ts +0 -107
  305. package/src/utils/legend-layout.ts +0 -776
  306. package/src/utils/legend-svg.ts +0 -214
  307. package/src/utils/legend-types.ts +0 -272
  308. package/src/utils/name-normalize.ts +0 -163
  309. package/src/utils/note-box/constants.ts +0 -25
  310. package/src/utils/note-box/index.ts +0 -11
  311. package/src/utils/note-box/metrics.ts +0 -90
  312. package/src/utils/note-box/svg.ts +0 -331
  313. package/src/utils/notes/bounds.ts +0 -30
  314. package/src/utils/notes/build.ts +0 -131
  315. package/src/utils/notes/index.ts +0 -18
  316. package/src/utils/notes/model.ts +0 -19
  317. package/src/utils/notes/parse.ts +0 -131
  318. package/src/utils/notes/place.ts +0 -177
  319. package/src/utils/notes/resolve.ts +0 -88
  320. package/src/utils/number-format.ts +0 -36
  321. package/src/utils/parsing.ts +0 -953
  322. package/src/utils/reserved-key-registry.ts +0 -239
  323. package/src/utils/safe-href.ts +0 -38
  324. package/src/utils/scaling.ts +0 -97
  325. package/src/utils/svg-embed.ts +0 -251
  326. package/src/utils/tag-groups.ts +0 -607
  327. package/src/utils/text-measure.ts +0 -122
  328. package/src/utils/time-ticks.ts +0 -253
  329. package/src/utils/title-constants.ts +0 -24
  330. package/src/utils/visual-conventions.ts +0 -61
  331. package/src/utils/wrapped-desc.ts +0 -87
  332. package/src/venn/parser.ts +0 -18
  333. package/src/venn/renderer.ts +0 -1047
  334. package/src/version-control/diagnostics.ts +0 -67
  335. package/src/version-control/parser.ts +0 -544
  336. package/src/version-control/renderer.ts +0 -724
  337. package/src/version-control/types.ts +0 -100
  338. package/src/visualizations/parse.ts +0 -1560
  339. package/src/visualizations/types.ts +0 -262
  340. package/src/wireframe/layout.ts +0 -479
  341. package/src/wireframe/parser.ts +0 -1114
  342. package/src/wireframe/renderer.ts +0 -1318
  343. package/src/wireframe/types.ts +0 -110
  344. package/src/wordcloud/parser.ts +0 -18
  345. 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
- }