@diagrammo/dgmo 0.7.1 → 0.7.3

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.
@@ -39,6 +39,120 @@ const MILESTONE_SIZE = 10;
39
39
  const MIN_LEFT_MARGIN = 120;
40
40
  const BOTTOM_MARGIN = 40;
41
41
  const RIGHT_MARGIN = 20;
42
+ const CHAR_W = 6.5; // estimated px per character for bar labels
43
+ const LABEL_PAD = 8; // inner padding to decide if label fits inside bar
44
+ const LABEL_GAP = 5; // gap between bar edge and external label
45
+
46
+ // ── Bar label placement ─────────────────────────────────────
47
+
48
+ type BarLabelPlacement = {
49
+ x: number;
50
+ anchor: 'start' | 'end';
51
+ fill: string;
52
+ text: string;
53
+ };
54
+
55
+ function computeBarLabel(
56
+ label: string,
57
+ x1: number,
58
+ barWidth: number,
59
+ innerWidth: number,
60
+ textColor: string,
61
+ ): BarLabelPlacement | null {
62
+ const textWidth = label.length * CHAR_W;
63
+ const x2 = x1 + barWidth;
64
+
65
+ // 1. Inside
66
+ if (textWidth < barWidth - LABEL_PAD) {
67
+ return { x: x1 + 6, anchor: 'start', fill: textColor, text: label };
68
+ }
69
+
70
+ // 2. After (right of bar)
71
+ if (x2 + LABEL_GAP + textWidth <= innerWidth) {
72
+ return { x: x2 + LABEL_GAP, anchor: 'start', fill: textColor, text: label };
73
+ }
74
+
75
+ // 3. Before (left of bar)
76
+ if (x1 - LABEL_GAP - textWidth >= 0) {
77
+ return { x: x1 - LABEL_GAP, anchor: 'end', fill: textColor, text: label };
78
+ }
79
+
80
+ // 4. Truncate to fit before the bar
81
+ const availWidth = x1 - LABEL_GAP;
82
+ if (availWidth > CHAR_W * 3) {
83
+ const maxChars = Math.floor(availWidth / CHAR_W) - 1;
84
+ return { x: x1 - LABEL_GAP, anchor: 'end', fill: textColor, text: label.slice(0, maxChars) + '\u2026' };
85
+ }
86
+
87
+ return null;
88
+ }
89
+
90
+ // ── Left-panel visual helpers ───────────────────────────────
91
+
92
+ const BAND_ACCENT_W = 4;
93
+ const BAND_RADIUS = 4;
94
+ let bandClipCounter = 0;
95
+
96
+ function renderLabelBand(
97
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
98
+ y: number,
99
+ leftMargin: number,
100
+ color: string,
101
+ palette: PaletteColors,
102
+ cssPrefix: 'group' | 'lane',
103
+ dataAttr?: { key: string; value: string },
104
+ ): void {
105
+ const bandX = 5;
106
+ const bandW = leftMargin - 7;
107
+ const bandY = y - BAR_H / 2;
108
+ const clipId = `gantt-band-clip-${bandClipCounter++}`;
109
+
110
+ // ClipPath matching the tint band shape
111
+ svg.append('clipPath').attr('id', clipId)
112
+ .append('rect')
113
+ .attr('x', bandX).attr('y', bandY)
114
+ .attr('width', bandW).attr('height', BAR_H)
115
+ .attr('rx', BAND_RADIUS);
116
+
117
+ // Tint band
118
+ const tint = svg.append('rect')
119
+ .attr('class', `gantt-${cssPrefix}-band-bg`)
120
+ .attr('x', bandX)
121
+ .attr('y', bandY)
122
+ .attr('width', bandW)
123
+ .attr('height', BAR_H)
124
+ .attr('rx', BAND_RADIUS)
125
+ .attr('fill', mix(color, palette.bg, 20))
126
+ .style('pointer-events', 'none');
127
+
128
+ // Accent strip inside the tint, clipped to the band's rounded shape
129
+ const accent = svg.append('rect')
130
+ .attr('class', `gantt-${cssPrefix}-band-accent`)
131
+ .attr('x', bandX)
132
+ .attr('y', bandY)
133
+ .attr('width', BAND_ACCENT_W)
134
+ .attr('height', BAR_H)
135
+ .attr('fill', color)
136
+ .attr('clip-path', `url(#${clipId})`)
137
+ .style('pointer-events', 'none');
138
+
139
+ if (dataAttr) {
140
+ tint.attr(dataAttr.key, dataAttr.value);
141
+ accent.attr(dataAttr.key, dataAttr.value);
142
+ }
143
+ }
144
+
145
+ function appendTaskIcon(
146
+ textEl: d3Selection.Selection<SVGTextElement, unknown, null, undefined>,
147
+ label: string,
148
+ isMilestone: boolean,
149
+ iconColor: string,
150
+ textColor: string,
151
+ ): void {
152
+ const icon = isMilestone ? '◆' : '●';
153
+ textEl.append('tspan').attr('fill', iconColor).text(icon);
154
+ textEl.append('tspan').attr('fill', textColor).text(' ' + label);
155
+ }
42
156
 
43
157
  // ── Interactive Options ─────────────────────────────────────
44
158
 
@@ -67,6 +181,7 @@ export function renderGantt(
67
181
  ): void {
68
182
  // Clear previous content
69
183
  container.innerHTML = '';
184
+ bandClipCounter = 0;
70
185
 
71
186
  if (resolved.tasks.length === 0) return;
72
187
 
@@ -98,15 +213,18 @@ export function renderGantt(
98
213
  const rows = tagRows ?? buildRowList(resolved, collapsedGroups);
99
214
  const isTagMode = tagRows !== null;
100
215
 
101
- // Compute left margin based on longest visible label
216
+ // Compute left margin based on longest visible label (include ● /◆ prefix for tasks)
102
217
  const allLabels = isTagMode
103
218
  ? [
104
219
  ...rows.filter((r): r is LaneHeaderRow => r.type === 'lane-header').map(r => r.laneName),
105
- ...rows.filter((r): r is TaskRow => r.type === 'task').map(r => r.task.task.label),
220
+ ...rows.filter((r): r is TaskRow => r.type === 'task').map(r => '● ' + r.task.task.label),
106
221
  ]
107
222
  : [
108
- ...resolved.tasks.map(t => t.task.label),
109
- ...resolved.groups.map(g => ' '.repeat(g.depth) + g.name),
223
+ ...resolved.tasks.map(t => '● ' + t.task.label),
224
+ ...resolved.groups.map(g => {
225
+ const px = g.depth <= 2 ? g.depth * 14 : 2 * 14 + (g.depth - 2) * 8;
226
+ return ' '.repeat(Math.ceil(px / 7)) + g.name;
227
+ }),
110
228
  ];
111
229
  const maxLabelLen = Math.max(...allLabels.map(l => l.length), 10);
112
230
  const leftMargin = Math.max(MIN_LEFT_MARGIN, maxLabelLen * 7 + 30);
@@ -118,9 +236,11 @@ export function renderGantt(
118
236
  const titleHeight = title ? 50 : 20;
119
237
  const tagLegendReserve = resolved.tagGroups.length > 0 ? LEGEND_HEIGHT + 8 : 0;
120
238
  const topDateLabelReserve = 22; // tick (6) + gap (4) + label height (~12)
239
+ const hasOverheadLabels = resolved.markers.length > 0 || resolved.eras.length > 0;
240
+ const markerLabelReserve = hasOverheadLabels ? 18 : 0; // markers/eras extend above date labels
121
241
  const CONTENT_TOP_PAD = 16; // breathing room between scale labels and first row
122
242
 
123
- const marginTop = titleHeight + tagLegendReserve + topDateLabelReserve;
243
+ const marginTop = titleHeight + tagLegendReserve + topDateLabelReserve + markerLabelReserve;
124
244
 
125
245
  // Content area
126
246
  const contentH = isTagMode
@@ -171,7 +291,7 @@ export function renderGantt(
171
291
  const legendY = titleHeight;
172
292
  renderTagLegend(
173
293
  svg, g, resolved.tagGroups, currentActiveGroup, leftMargin, innerWidth,
174
- legendY, palette, isDark, hasCriticalPath, criticalPathActive,
294
+ legendY, palette, isDark, hasCriticalPath, criticalPathActive, resolved.options.optionLineNumbers,
175
295
  (groupName) => {
176
296
  // Toggle active group
177
297
  currentActiveGroup = currentActiveGroup?.toLowerCase() === groupName.toLowerCase()
@@ -230,7 +350,48 @@ export function renderGantt(
230
350
 
231
351
  renderWeekendBands(g, resolved, xScale, innerHeight, palette, isDark);
232
352
  renderHolidayBands(g, svg, resolved, xScale, innerHeight, palette, isDark, marginTop - 4, leftMargin, onClickItem);
233
- renderErasAndMarkers(g, resolved, xScale, innerHeight, palette);
353
+ renderErasAndMarkers(g, svg, resolved, xScale, innerHeight, palette);
354
+
355
+ // ── Today marker (line rendered before rows so it paints behind task bars) ──
356
+
357
+ let todayDate: Date | null = null;
358
+ let todayX = -1;
359
+ const todayColor = palette.accent || '#e74c3c';
360
+ const todayMarkerLineNum = resolved.options.optionLineNumbers['today-marker'];
361
+ if (resolved.options.todayMarker !== 'off') {
362
+ if (resolved.options.todayMarker === 'on') {
363
+ todayDate = new Date();
364
+ } else {
365
+ todayDate = new Date(resolved.options.todayMarker + 'T00:00:00');
366
+ }
367
+ todayX = xScale(dateToFractionalYear(todayDate));
368
+ if (todayX >= 0 && todayX <= innerWidth) {
369
+ const todayLine = g.append('line')
370
+ .attr('class', 'gantt-today')
371
+ .attr('x1', todayX)
372
+ .attr('y1', 0)
373
+ .attr('x2', todayX)
374
+ .attr('y2', innerHeight + 10)
375
+ .attr('stroke', todayColor)
376
+ .attr('stroke-width', 2)
377
+ .attr('stroke-dasharray', '6 4')
378
+ .attr('opacity', 0.7)
379
+ .attr('pointer-events', 'none');
380
+ if (todayMarkerLineNum) todayLine.attr('data-line-number', String(todayMarkerLineNum));
381
+
382
+ const todayLabel = g.append('text')
383
+ .attr('class', 'gantt-today')
384
+ .attr('x', todayX)
385
+ .attr('y', innerHeight + 24)
386
+ .attr('text-anchor', 'middle')
387
+ .attr('font-size', '10px')
388
+ .attr('fill', todayColor)
389
+ .attr('opacity', 0.7)
390
+ .attr('pointer-events', 'none')
391
+ .text('Today');
392
+ if (todayMarkerLineNum) todayLabel.attr('data-line-number', String(todayMarkerLineNum));
393
+ }
394
+ }
234
395
 
235
396
  // ── Render rows ─────────────────────────────────────────
236
397
 
@@ -279,6 +440,7 @@ export function renderGantt(
279
440
 
280
441
  lanePositions.set(row.laneName, { x1: lx1, x2: lx1 + laneBarWidth, y: yOffset + BAR_H / 2 });
281
442
 
443
+ renderLabelBand(svg, marginTop + yOffset + BAR_H / 2, leftMargin, laneColor, palette, 'lane', { key: 'data-lane', value: row.laneName });
282
444
  const labelG = svg
283
445
  .append('g')
284
446
  .attr('class', 'gantt-lane-header')
@@ -346,7 +508,6 @@ export function renderGantt(
346
508
  .attr('y', yOffset)
347
509
  .attr('width', laneBarWidth * Math.min(row.aggregateProgress / 100, 1))
348
510
  .attr('height', BAR_H)
349
- .attr('rx', 4)
350
511
  .attr('fill', laneColor)
351
512
  .attr('opacity', 0.5)
352
513
  .attr('pointer-events', 'none');
@@ -363,6 +524,7 @@ export function renderGantt(
363
524
  // Group label with toggle — resolve tag color from group metadata
364
525
  const tagColor = resolveTagColor(group.metadata, resolved.tagGroups, currentActiveGroup, true);
365
526
  const groupColor = (tagColor && tagColor !== '#999999') ? tagColor : (group.color || palette.textMuted);
527
+ renderLabelBand(svg, marginTop + yOffset + BAR_H / 2, leftMargin, groupColor, palette, 'group', { key: 'data-group', value: group.name });
366
528
  const labelG = svg
367
529
  .append('g')
368
530
  .attr('class', 'gantt-group-label')
@@ -381,7 +543,8 @@ export function renderGantt(
381
543
  hideGanttDateIndicators(g);
382
544
  });
383
545
 
384
- const labelX = 10 + group.depth * 14;
546
+ const groupIndent = group.depth <= 2 ? group.depth * 14 : 2 * 14 + (group.depth - 2) * 8;
547
+ const labelX = 10 + groupIndent;
385
548
  labelG
386
549
  .append('text')
387
550
  .attr('x', labelX)
@@ -433,11 +596,27 @@ export function renderGantt(
433
596
  .attr('y', yOffset)
434
597
  .attr('width', barWidth * Math.min(group.progress / 100, 1))
435
598
  .attr('height', BAR_H)
436
- .attr('rx', 4)
437
599
  .attr('fill', groupColor)
438
600
  .attr('opacity', 0.5);
439
601
  }
440
602
 
603
+ // Bar label (inside → after → before → truncate)
604
+ const summaryLabel = group.name + (group.progress !== null ? ` ${Math.round(group.progress)}%` : '');
605
+ const summaryPlacement = computeBarLabel(summaryLabel, gx1, barWidth, innerWidth, palette.text);
606
+ if (summaryPlacement) {
607
+ summaryG
608
+ .append('text')
609
+ .attr('x', summaryPlacement.x)
610
+ .attr('y', yOffset + BAR_H / 2)
611
+ .attr('dy', '0.35em')
612
+ .attr('font-size', '10px')
613
+ .attr('font-weight', 'bold')
614
+ .attr('text-anchor', summaryPlacement.anchor)
615
+ .attr('fill', summaryPlacement.fill)
616
+ .attr('pointer-events', 'none')
617
+ .text(summaryPlacement.text);
618
+ }
619
+
441
620
  // Track collapsed group position for dependency arrow redirection
442
621
  groupPositions.set(group.name, { x1: gx1, x2: gx1 + barWidth, y: yOffset + BAR_H / 2 });
443
622
  } else {
@@ -475,10 +654,26 @@ export function renderGantt(
475
654
  .attr('y', yOffset)
476
655
  .attr('width', groupBarWidth * Math.min(group.progress / 100, 1))
477
656
  .attr('height', BAR_H)
478
- .attr('rx', 4)
479
657
  .attr('fill', groupColor)
480
658
  .attr('opacity', 0.5);
481
659
  }
660
+
661
+ // Bar label (inside → after → before → truncate)
662
+ const expandedLabel = group.name + (group.progress !== null ? ` ${Math.round(group.progress)}%` : '');
663
+ const expandedPlacement = computeBarLabel(expandedLabel, gx1, groupBarWidth, innerWidth, palette.text);
664
+ if (expandedPlacement) {
665
+ groupBarG
666
+ .append('text')
667
+ .attr('x', expandedPlacement.x)
668
+ .attr('y', yOffset + BAR_H / 2)
669
+ .attr('dy', '0.35em')
670
+ .attr('font-size', '10px')
671
+ .attr('font-weight', 'bold')
672
+ .attr('text-anchor', expandedPlacement.anchor)
673
+ .attr('fill', expandedPlacement.fill)
674
+ .attr('pointer-events', 'none')
675
+ .text(expandedPlacement.text);
676
+ }
482
677
  }
483
678
  }
484
679
 
@@ -487,8 +682,13 @@ export function renderGantt(
487
682
  const rt = row.task;
488
683
  const task = rt.task;
489
684
 
685
+ // Resolve bar color early so icon tspan can use it
686
+ const barColor = resolveTaskColor(rt, currentActiveGroup, resolved, seriesColors, palette);
687
+
490
688
  // Task label on the left (left-aligned with indent; flat in tag mode)
491
- const taskLabelX = isTagMode ? 20 : 6 + rt.groupPath.length * 14;
689
+ const depth = rt.groupPath.length;
690
+ const indent = depth <= 2 ? depth * 14 : 2 * 14 + (depth - 2) * 8;
691
+ const taskLabelX = isTagMode ? 20 : 6 + indent;
492
692
  const topGroup = rt.groupPath.length > 0 ? rt.groupPath[0] : null;
493
693
  const taskLabel = svg
494
694
  .append('text')
@@ -503,7 +703,6 @@ export function renderGantt(
503
703
  .attr('data-task-id', task.id)
504
704
  .attr('data-group', topGroup)
505
705
  .style('cursor', onClickItem ? 'pointer' : 'default')
506
- .text(task.label)
507
706
  .on('click', () => {
508
707
  if (onClickItem) onClickItem(task.lineNumber);
509
708
  })
@@ -518,6 +717,8 @@ export function renderGantt(
518
717
  resetHighlight(g, svg);
519
718
  });
520
719
 
720
+ appendTaskIcon(taskLabel, task.label, rt.isMilestone, barColor, palette.text);
721
+
521
722
  // Tag attributes on label for legend hover matching
522
723
  for (const [key, value] of Object.entries(rt.effectiveMetadata)) {
523
724
  taskLabel.attr(`data-tag-${key}`, value.toLowerCase());
@@ -526,9 +727,6 @@ export function renderGantt(
526
727
  taskLabel.attr('data-critical-path', 'true');
527
728
  }
528
729
 
529
- // Determine color
530
- let barColor = resolveTaskColor(rt, currentActiveGroup, resolved, seriesColors, palette);
531
-
532
730
  if (rt.isMilestone) {
533
731
  // Render diamond
534
732
  const mx = xScale(dateToFractionalYear(rt.startDate));
@@ -680,7 +878,6 @@ export function renderGantt(
680
878
  .attr('y', yOffset)
681
879
  .attr('width', progressWidth)
682
880
  .attr('height', BAR_H)
683
- .attr('rx', 4)
684
881
  .attr('fill', progressFill)
685
882
  .attr('opacity', 0.5);
686
883
  }
@@ -691,18 +888,19 @@ export function renderGantt(
691
888
  }
692
889
 
693
890
 
694
- // Label inside bar (if fits)
695
- const textWidth = task.label.length * 6.5;
696
- if (textWidth < barWidth - 8) {
891
+ // Bar label (inside after → before → truncate)
892
+ const labelPlacement = computeBarLabel(task.label, x1, barWidth, innerWidth, palette.text);
893
+ if (labelPlacement) {
697
894
  taskG
698
895
  .append('text')
699
- .attr('x', x1 + 6)
896
+ .attr('x', labelPlacement.x)
700
897
  .attr('y', yOffset + BAR_H / 2)
701
898
  .attr('dy', '0.35em')
702
899
  .attr('font-size', '10px')
703
- .attr('fill', palette.text)
900
+ .attr('text-anchor', labelPlacement.anchor)
901
+ .attr('fill', labelPlacement.fill)
704
902
  .attr('pointer-events', 'none')
705
- .text(task.label);
903
+ .text(labelPlacement.text);
706
904
  }
707
905
 
708
906
  // Track bar position for arrows
@@ -713,38 +911,42 @@ export function renderGantt(
713
911
  }
714
912
  }
715
913
 
716
- // ── Today marker ────────────────────────────────────────
914
+ // ── Today hover overlay (rendered after rows so it receives pointer events) ──
717
915
 
718
- if (resolved.options.todayMarker !== 'off') {
719
- let todayDate: Date;
720
- if (resolved.options.todayMarker === 'on') {
721
- todayDate = new Date();
722
- } else {
723
- todayDate = new Date(resolved.options.todayMarker + 'T00:00:00');
724
- }
725
- const todayX = xScale(dateToFractionalYear(todayDate));
726
- if (todayX >= 0 && todayX <= innerWidth) {
727
- g.append('line')
728
- .attr('class', 'gantt-today')
729
- .attr('x1', todayX)
730
- .attr('y1', 0)
731
- .attr('x2', todayX)
732
- .attr('y2', innerHeight + 10)
733
- .attr('stroke', palette.accent || '#e74c3c')
734
- .attr('stroke-width', 2)
735
- .attr('stroke-dasharray', '6 4')
736
- .attr('opacity', 0.7);
916
+ if (todayDate && todayX >= 0 && todayX <= innerWidth) {
917
+ const todayHoverG = g.append('g')
918
+ .attr('class', 'gantt-today-hover')
919
+ .style('cursor', 'pointer');
737
920
 
738
- g.append('text')
739
- .attr('class', 'gantt-today')
740
- .attr('x', todayX + 4)
741
- .attr('y', innerHeight + 24)
742
- .attr('text-anchor', 'start')
743
- .attr('font-size', '10px')
744
- .attr('fill', palette.accent || '#e74c3c')
745
- .attr('opacity', 0.7)
746
- .text('Today');
747
- }
921
+ // Invisible wide hit rect for easy hovering
922
+ todayHoverG.append('rect')
923
+ .attr('x', todayX - 10)
924
+ .attr('y', -6)
925
+ .attr('width', 20)
926
+ .attr('height', innerHeight + 16)
927
+ .attr('fill', 'transparent')
928
+ .attr('pointer-events', 'all');
929
+
930
+ const todayDateObj = todayDate;
931
+ todayHoverG
932
+ .on('mouseenter', () => {
933
+ // Fade everything
934
+ g.selectAll<SVGGElement, unknown>('.gantt-task').attr('opacity', FADE_OPACITY);
935
+ g.selectAll<SVGElement, unknown>('.gantt-milestone').attr('opacity', FADE_OPACITY);
936
+ g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
937
+ svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
938
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').attr('opacity', FADE_OPACITY);
939
+ svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
940
+ g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', FADE_OPACITY);
941
+ g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', FADE_OPACITY);
942
+ g.selectAll<SVGElement, unknown>('.gantt-era-group').attr('opacity', FADE_OPACITY);
943
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
944
+ showGanttDateIndicators(g, xScale, todayDateObj, null, innerHeight, todayColor);
945
+ })
946
+ .on('mouseleave', () => {
947
+ resetHighlight(g, svg);
948
+ hideGanttDateIndicators(g);
949
+ });
748
950
  }
749
951
 
750
952
  // ── Dependency arrows ───────────────────────────────────
@@ -1029,6 +1231,7 @@ function renderDependencyArrows(
1029
1231
  .attr('class', 'gantt-dep-arrow')
1030
1232
  .attr('data-dep-from', rt.task.id)
1031
1233
  .attr('data-dep-to', targetTask.task.id)
1234
+ .attr('data-line-number', String(dep.lineNumber))
1032
1235
  .attr('data-critical-path', isCpArrow ? 'true' : null)
1033
1236
  .attr('d', path)
1034
1237
  .attr('fill', 'none')
@@ -1043,6 +1246,7 @@ function renderDependencyArrows(
1043
1246
  .attr('class', 'gantt-dep-arrowhead')
1044
1247
  .attr('data-dep-from', rt.task.id)
1045
1248
  .attr('data-dep-to', targetTask.task.id)
1249
+ .attr('data-line-number', String(dep.lineNumber))
1046
1250
  .attr('data-critical-path', isCpArrow ? 'true' : null)
1047
1251
  .attr('points', arrowheadPoints(tx, ty, headSize, angle))
1048
1252
  .attr('fill', arrowColor)
@@ -1074,7 +1278,9 @@ function applyCriticalPathHighlight(
1074
1278
  el.attr('opacity', el.attr('data-critical-path') === 'true' ? 1 : FADE_OPACITY);
1075
1279
  });
1076
1280
  svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
1281
+ svg.selectAll<SVGElement, unknown>('.gantt-group-band-bg, .gantt-group-band-accent').attr('opacity', FADE_OPACITY);
1077
1282
  svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
1283
+ svg.selectAll<SVGElement, unknown>('.gantt-lane-band-bg, .gantt-lane-band-accent').attr('opacity', FADE_OPACITY);
1078
1284
  chartG.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr('opacity', FADE_OPACITY);
1079
1285
  // Show critical path arrows at full opacity, fade others
1080
1286
  chartG.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').each(function () {
@@ -1091,7 +1297,9 @@ function resetHighlightAll(
1091
1297
  chartG.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', 1);
1092
1298
  svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').attr('opacity', 1);
1093
1299
  svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', 1);
1300
+ svg.selectAll<SVGElement, unknown>('.gantt-group-band-bg, .gantt-group-band-accent').attr('opacity', 1);
1094
1301
  svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', 1);
1302
+ svg.selectAll<SVGElement, unknown>('.gantt-lane-band-bg, .gantt-lane-band-accent').attr('opacity', 1);
1095
1303
  chartG.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr('opacity', 1);
1096
1304
  chartG.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', 0.5);
1097
1305
  }
@@ -1141,6 +1349,7 @@ function renderTagLegend(
1141
1349
  isDark: boolean,
1142
1350
  hasCriticalPath: boolean,
1143
1351
  criticalPathActive: boolean,
1352
+ optionLineNumbers: Record<string, number>,
1144
1353
  onToggle?: (groupName: string) => void,
1145
1354
  onToggleCriticalPath?: () => void,
1146
1355
  currentSwimlaneGroup?: string | null,
@@ -1224,8 +1433,9 @@ function renderTagLegend(
1224
1433
  totalW += cpPillW;
1225
1434
  }
1226
1435
 
1227
- // Center over chart area (not full container)
1228
- const legendX = chartLeftMargin + (chartInnerWidth - totalW) / 2;
1436
+ // Center over full container (matching title centering)
1437
+ const containerWidth = chartLeftMargin + chartInnerWidth + RIGHT_MARGIN;
1438
+ const legendX = (containerWidth - totalW) / 2;
1229
1439
 
1230
1440
  const legendRow = svg.append('g')
1231
1441
  .attr('class', 'gantt-tag-legend-container')
@@ -1247,6 +1457,7 @@ function renderTagLegend(
1247
1457
  .attr('transform', `translate(${cursorX}, 0)`)
1248
1458
  .attr('class', 'gantt-tag-legend-group')
1249
1459
  .attr('data-tag-group', group.name)
1460
+ .attr('data-line-number', String(group.lineNumber))
1250
1461
  .style('cursor', 'pointer')
1251
1462
  .on('click', () => { if (onToggle) onToggle(group.name); });
1252
1463
 
@@ -1325,6 +1536,7 @@ function renderTagLegend(
1325
1536
  // Wrap dot + label in a <g> for hover targeting
1326
1537
  const entryG = gEl.append('g')
1327
1538
  .attr('class', 'gantt-legend-entry')
1539
+ .attr('data-line-number', String(entry.lineNumber))
1328
1540
  .style('cursor', 'pointer');
1329
1541
 
1330
1542
  // Dot
@@ -1386,11 +1598,13 @@ function renderTagLegend(
1386
1598
 
1387
1599
  // Critical Path pill
1388
1600
  if (hasCriticalPath) {
1601
+ const cpLineNum = optionLineNumbers['critical-path'];
1389
1602
  const cpG = legendRow.append('g')
1390
1603
  .attr('transform', `translate(${cursorX}, 0)`)
1391
1604
  .attr('class', 'gantt-legend-critical-path')
1392
1605
  .style('cursor', 'pointer')
1393
1606
  .on('click', () => { if (onToggleCriticalPath) onToggleCriticalPath(); });
1607
+ if (cpLineNum) cpG.attr('data-line-number', String(cpLineNum));
1394
1608
 
1395
1609
  cpG.append('rect')
1396
1610
  .attr('width', cpPillW)
@@ -1440,6 +1654,7 @@ const ERA_COLORS = ['#5e81ac', '#a3be8c', '#ebcb8b', '#d08770', '#b48ead'];
1440
1654
 
1441
1655
  function renderErasAndMarkers(
1442
1656
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1657
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1443
1658
  resolved: ResolvedSchedule,
1444
1659
  xScale: d3Scale.ScaleLinear<number, number>,
1445
1660
  innerHeight: number,
@@ -1459,7 +1674,8 @@ function renderErasAndMarkers(
1459
1674
  const eraEndDate = parseDateStringToDate(era.endDate);
1460
1675
 
1461
1676
  const eraG = g.append('g')
1462
- .attr('class', 'gantt-era-group');
1677
+ .attr('class', 'gantt-era-group')
1678
+ .attr('data-line-number', String(era.lineNumber));
1463
1679
 
1464
1680
  const eraRect = eraG.append('rect')
1465
1681
  .attr('class', 'gantt-era')
@@ -1470,24 +1686,36 @@ function renderErasAndMarkers(
1470
1686
  .attr('fill', color)
1471
1687
  .attr('opacity', baseEraOpacity);
1472
1688
 
1473
- // Era label (inside chart at top)
1689
+ // Era label (above date scale, same zone as markers)
1474
1690
  eraG.append('text')
1475
1691
  .attr('class', 'gantt-era-label')
1476
1692
  .attr('x', (sx + ex) / 2)
1477
- .attr('y', 12)
1693
+ .attr('y', -24)
1478
1694
  .attr('text-anchor', 'middle')
1479
1695
  .attr('font-size', '10px')
1480
1696
  .attr('fill', color)
1481
1697
  .attr('opacity', 0.7)
1482
- .attr('pointer-events', 'none')
1698
+ .style('cursor', 'pointer')
1483
1699
  .text(era.label);
1484
1700
 
1485
1701
  eraG
1486
1702
  .on('mouseenter', () => {
1703
+ // Fade everything
1704
+ g.selectAll<SVGGElement, unknown>('.gantt-task').attr('opacity', FADE_OPACITY);
1705
+ g.selectAll<SVGElement, unknown>('.gantt-milestone').attr('opacity', FADE_OPACITY);
1706
+ g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1707
+ svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
1708
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').attr('opacity', FADE_OPACITY);
1709
+ svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
1710
+ g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', FADE_OPACITY);
1711
+ g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', FADE_OPACITY);
1712
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
1713
+ // Highlight this era
1487
1714
  eraRect.attr('opacity', hoverEraOpacity);
1488
1715
  showGanttDateIndicators(g, xScale, eraStartDate, eraEndDate, innerHeight, color);
1489
1716
  })
1490
1717
  .on('mouseleave', () => {
1718
+ resetHighlight(g, svg);
1491
1719
  eraRect.attr('opacity', baseEraOpacity);
1492
1720
  hideGanttDateIndicators(g);
1493
1721
  });
@@ -1504,6 +1732,7 @@ function renderErasAndMarkers(
1504
1732
 
1505
1733
  const markerG = g.append('g')
1506
1734
  .attr('class', 'gantt-marker-group')
1735
+ .attr('data-line-number', String(marker.lineNumber))
1507
1736
  .style('cursor', 'pointer');
1508
1737
 
1509
1738
  // Invisible hit rect for easier clicking/hovering
@@ -1544,20 +1773,31 @@ function renderErasAndMarkers(
1544
1773
  .attr('stroke-dasharray', '6 4')
1545
1774
  .attr('opacity', 0.5);
1546
1775
 
1547
- // Hide marker visuals on hover showGanttDateIndicators replaces them
1776
+ // Hide marker line/diamond on hover but keep label visible
1548
1777
  const markerLine = markerG.select('.gantt-marker');
1549
- const markerLabel = markerG.select('.gantt-marker-label');
1550
1778
  const markerDiamond = markerG.select('path');
1551
1779
  markerG
1552
1780
  .on('mouseenter', () => {
1553
- markerLine.attr('opacity', 0);
1554
- markerLabel.attr('opacity', 0);
1781
+ // Fade everything
1782
+ g.selectAll<SVGGElement, unknown>('.gantt-task').attr('opacity', FADE_OPACITY);
1783
+ g.selectAll<SVGElement, unknown>('.gantt-milestone').attr('opacity', FADE_OPACITY);
1784
+ g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1785
+ svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
1786
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').attr('opacity', FADE_OPACITY);
1787
+ svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
1788
+ g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', FADE_OPACITY);
1789
+ g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', FADE_OPACITY);
1790
+ g.selectAll<SVGElement, unknown>('.gantt-era-group').attr('opacity', FADE_OPACITY);
1791
+ // Fade other markers but keep this one highlighted
1792
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
1793
+ markerG.attr('opacity', 1);
1794
+ markerLine.attr('opacity', 0.8);
1555
1795
  markerDiamond.attr('opacity', 0);
1556
- showGanttDateIndicators(g, xScale, markerDate, null, innerHeight, color);
1796
+ showGanttDateIndicators(g, xScale, markerDate, null, innerHeight, color, { skipStartLine: true });
1557
1797
  })
1558
1798
  .on('mouseleave', () => {
1799
+ resetHighlight(g, svg);
1559
1800
  markerLine.attr('opacity', 0.5);
1560
- markerLabel.attr('opacity', 1);
1561
1801
  markerDiamond.attr('opacity', 0.9);
1562
1802
  hideGanttDateIndicators(g);
1563
1803
  });
@@ -1581,11 +1821,7 @@ function parseDateStringToDate(s: string): Date {
1581
1821
  * Used for eras and markers which may have partial dates.
1582
1822
  */
1583
1823
  function parseDateToFractionalYear(s: string): number {
1584
- const parts = s.split('-').map(p => parseInt(p, 10));
1585
- const year = parts[0];
1586
- const month = parts.length >= 2 ? parts[1] : 1;
1587
- const day = parts.length >= 3 ? parts[2] : 1;
1588
- return year + (month - 1) / 12 + (day - 1) / 365;
1824
+ return dateToFractionalYear(parseDateStringToDate(s));
1589
1825
  }
1590
1826
 
1591
1827
  // ── Dependency Hover Helpers ─────────────────────────────────
@@ -1641,6 +1877,8 @@ function highlightDeps(
1641
1877
  const isRelated = (from && related.has(from)) || (to && related.has(to));
1642
1878
  el.attr('opacity', isRelated ? 0.5 : FADE_OPACITY);
1643
1879
  });
1880
+ // Fade markers
1881
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
1644
1882
  }
1645
1883
 
1646
1884
  function highlightGroup(
@@ -1673,8 +1911,14 @@ function highlightGroup(
1673
1911
  const el = d3Selection.select(this);
1674
1912
  el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
1675
1913
  });
1914
+ // Fade group bands not matching
1915
+ svg.selectAll<SVGElement, unknown>('.gantt-group-band-bg, .gantt-group-band-accent').each(function () {
1916
+ const el = d3Selection.select(this);
1917
+ el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
1918
+ });
1676
1919
  // Fade lane elements
1677
1920
  svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
1921
+ svg.selectAll<SVGElement, unknown>('.gantt-lane-band-bg, .gantt-lane-band-accent').attr('opacity', FADE_OPACITY);
1678
1922
  g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr('opacity', FADE_OPACITY);
1679
1923
  // Fade markers
1680
1924
  g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
@@ -1714,9 +1958,15 @@ function highlightLane(
1714
1958
  const el = d3Selection.select(this);
1715
1959
  el.attr('opacity', el.attr('data-lane') === laneName ? 1 : FADE_OPACITY);
1716
1960
  });
1961
+ // Fade lane bands not matching
1962
+ svg.selectAll<SVGElement, unknown>('.gantt-lane-band-bg, .gantt-lane-band-accent').each(function () {
1963
+ const el = d3Selection.select(this);
1964
+ el.attr('opacity', el.attr('data-lane') === laneName ? 1 : FADE_OPACITY);
1965
+ });
1717
1966
  // Fade group elements (not relevant in lane mode)
1718
1967
  g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1719
1968
  svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
1969
+ svg.selectAll<SVGElement, unknown>('.gantt-group-band-bg, .gantt-group-band-accent').attr('opacity', FADE_OPACITY);
1720
1970
  // Fade markers
1721
1971
  g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
1722
1972
  }
@@ -1741,7 +1991,9 @@ function highlightTask(
1741
1991
  // Fade group/lane elements
1742
1992
  g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1743
1993
  svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
1994
+ svg.selectAll<SVGElement, unknown>('.gantt-group-band-bg, .gantt-group-band-accent').attr('opacity', FADE_OPACITY);
1744
1995
  svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
1996
+ svg.selectAll<SVGElement, unknown>('.gantt-lane-band-bg, .gantt-lane-band-accent').attr('opacity', FADE_OPACITY);
1745
1997
  g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', FADE_OPACITY);
1746
1998
  g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', FADE_OPACITY);
1747
1999
  // Fade markers
@@ -1768,7 +2020,9 @@ function highlightMilestone(
1768
2020
  // Fade group/lane elements
1769
2021
  g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1770
2022
  svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
2023
+ svg.selectAll<SVGElement, unknown>('.gantt-group-band-bg, .gantt-group-band-accent').attr('opacity', FADE_OPACITY);
1771
2024
  svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
2025
+ svg.selectAll<SVGElement, unknown>('.gantt-lane-band-bg, .gantt-lane-band-accent').attr('opacity', FADE_OPACITY);
1772
2026
  g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', FADE_OPACITY);
1773
2027
  g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', FADE_OPACITY);
1774
2028
  // Fade markers
@@ -1799,11 +2053,14 @@ function resetHighlight(
1799
2053
  g.selectAll<SVGGElement, unknown>('.gantt-task, .gantt-milestone').attr('opacity', 1);
1800
2054
  g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', 1);
1801
2055
  svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', 1);
2056
+ svg.selectAll<SVGElement, unknown>('.gantt-group-band-bg, .gantt-group-band-accent').attr('opacity', 1);
1802
2057
  svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').attr('opacity', 1);
1803
2058
  svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', 1);
2059
+ svg.selectAll<SVGElement, unknown>('.gantt-lane-band-bg, .gantt-lane-band-accent').attr('opacity', 1);
1804
2060
  g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', 1);
1805
2061
  g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', 0.5);
1806
2062
  g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', 1);
2063
+ g.selectAll<SVGElement, unknown>('.gantt-era-group').attr('opacity', 1);
1807
2064
  }
1808
2065
 
1809
2066
  // ── Row Building ────────────────────────────────────────────
@@ -2025,29 +2282,38 @@ function showGanttDateIndicators(
2025
2282
  endDate: Date | null,
2026
2283
  innerHeight: number,
2027
2284
  color: string,
2285
+ options?: { skipStartLine?: boolean },
2028
2286
  ): void {
2029
2287
  // Fade existing scale ticks and today marker
2030
2288
  g.selectAll('.gantt-scale-tick').attr('opacity', 0.05);
2031
2289
  g.selectAll('.gantt-today').attr('opacity', 0.05);
2032
2290
 
2291
+ // Wrap all hover indicators in a group that ignores pointer events,
2292
+ // so they don't steal mouseleave from the element being hovered.
2293
+ const hg = g.append('g')
2294
+ .attr('class', 'gantt-hover-date')
2295
+ .attr('pointer-events', 'none');
2296
+
2033
2297
  const tickLen = 6;
2034
2298
  const startPos = xScale(dateToFractionalYear(startDate));
2035
2299
  const startLabel = formatGanttDate(startDate);
2036
2300
 
2037
- // Start date — dashed vertical line
2038
- g.append('line')
2039
- .attr('class', 'gantt-hover-date')
2040
- .attr('x1', startPos)
2041
- .attr('y1', -tickLen)
2042
- .attr('x2', startPos)
2043
- .attr('y2', innerHeight)
2044
- .attr('stroke', color)
2045
- .attr('stroke-width', 1.5)
2046
- .attr('stroke-dasharray', '4 4')
2047
- .attr('opacity', 0.6);
2301
+ // Start date — dashed vertical line (skip when caller already shows its own line)
2302
+ if (!options?.skipStartLine) {
2303
+ hg.append('line')
2304
+ .attr('class', 'gantt-hover-date')
2305
+ .attr('x1', startPos)
2306
+ .attr('y1', -tickLen)
2307
+ .attr('x2', startPos)
2308
+ .attr('y2', innerHeight)
2309
+ .attr('stroke', color)
2310
+ .attr('stroke-width', 1.5)
2311
+ .attr('stroke-dasharray', '4 4')
2312
+ .attr('opacity', 0.6);
2313
+ }
2048
2314
 
2049
2315
  // Start date — top label
2050
- g.append('text')
2316
+ hg.append('text')
2051
2317
  .attr('class', 'gantt-hover-date')
2052
2318
  .attr('x', startPos)
2053
2319
  .attr('y', -tickLen - 4)
@@ -2058,7 +2324,7 @@ function showGanttDateIndicators(
2058
2324
  .text(startLabel);
2059
2325
 
2060
2326
  // Start date — bottom label
2061
- g.append('text')
2327
+ hg.append('text')
2062
2328
  .attr('class', 'gantt-hover-date')
2063
2329
  .attr('x', startPos)
2064
2330
  .attr('y', innerHeight + tickLen + 12)
@@ -2089,7 +2355,7 @@ function showGanttDateIndicators(
2089
2355
  }
2090
2356
 
2091
2357
  // End date — dashed vertical line
2092
- g.append('line')
2358
+ hg.append('line')
2093
2359
  .attr('class', 'gantt-hover-date')
2094
2360
  .attr('x1', endPos)
2095
2361
  .attr('y1', -tickLen)
@@ -2101,7 +2367,7 @@ function showGanttDateIndicators(
2101
2367
  .attr('opacity', 0.6);
2102
2368
 
2103
2369
  // Reposition start labels to avoid overlap
2104
- g.selectAll<SVGTextElement, unknown>('text.gantt-hover-date').each(function () {
2370
+ hg.selectAll<SVGTextElement, unknown>('text.gantt-hover-date').each(function () {
2105
2371
  const el = d3Selection.select(this);
2106
2372
  if (el.text() === startLabel) {
2107
2373
  el.attr('x', startLabelX).attr('text-anchor', startAnchor);
@@ -2109,7 +2375,7 @@ function showGanttDateIndicators(
2109
2375
  });
2110
2376
 
2111
2377
  // End date — top label
2112
- g.append('text')
2378
+ hg.append('text')
2113
2379
  .attr('class', 'gantt-hover-date')
2114
2380
  .attr('x', endLabelX)
2115
2381
  .attr('y', -tickLen - 4)
@@ -2120,7 +2386,7 @@ function showGanttDateIndicators(
2120
2386
  .text(endLabel);
2121
2387
 
2122
2388
  // End date — bottom label
2123
- g.append('text')
2389
+ hg.append('text')
2124
2390
  .attr('class', 'gantt-hover-date')
2125
2391
  .attr('x', endLabelX)
2126
2392
  .attr('y', innerHeight + tickLen + 12)