@diagrammo/dgmo 0.7.0 → 0.7.2

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.
@@ -27,7 +27,7 @@ import {
27
27
  import type { PaletteColors } from '../palettes';
28
28
  import type { D3ExportDimensions } from '../d3';
29
29
  import type { ResolvedSchedule, ResolvedTask, ResolvedGroup, Weekday } from './types';
30
- import type { TagGroup } from '../utils/tag-groups';
30
+ import type { TagGroup, TagEntry } from '../utils/tag-groups';
31
31
 
32
32
  // ── Constants ───────────────────────────────────────────────
33
33
 
@@ -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,8 +181,9 @@ export function renderGantt(
67
181
  ): void {
68
182
  // Clear previous content
69
183
  container.innerHTML = '';
184
+ bandClipCounter = 0;
70
185
 
71
- if (resolved.error || resolved.tasks.length === 0) return;
186
+ if (resolved.tasks.length === 0) return;
72
187
 
73
188
  // ── Destructure options ─────────────────────────────────
74
189
 
@@ -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,14 +236,17 @@ 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
241
+ const CONTENT_TOP_PAD = 16; // breathing room between scale labels and first row
121
242
 
122
- const marginTop = titleHeight + tagLegendReserve + topDateLabelReserve;
243
+ const marginTop = titleHeight + tagLegendReserve + topDateLabelReserve + markerLabelReserve;
123
244
 
124
245
  // Content area
125
246
  const contentH = isTagMode
126
247
  ? totalRows * (BAR_H + ROW_GAP)
127
248
  : totalRows * (BAR_H + ROW_GAP) + GROUP_GAP * resolved.groups.length;
128
- const innerHeight = contentH;
249
+ const innerHeight = CONTENT_TOP_PAD + contentH;
129
250
  const outerHeight = marginTop + innerHeight + BOTTOM_MARGIN;
130
251
 
131
252
  const containerWidth = exportDims?.width ?? (container.clientWidth || 800);
@@ -170,7 +291,7 @@ export function renderGantt(
170
291
  const legendY = titleHeight;
171
292
  renderTagLegend(
172
293
  svg, g, resolved.tagGroups, currentActiveGroup, leftMargin, innerWidth,
173
- legendY, palette, isDark, hasCriticalPath, criticalPathActive,
294
+ legendY, palette, isDark, hasCriticalPath, criticalPathActive, resolved.options.optionLineNumbers,
174
295
  (groupName) => {
175
296
  // Toggle active group
176
297
  currentActiveGroup = currentActiveGroup?.toLowerCase() === groupName.toLowerCase()
@@ -186,6 +307,7 @@ export function renderGantt(
186
307
  currentSwimlaneGroup,
187
308
  onSwimlaneChange,
188
309
  viewMode,
310
+ resolved.tasks,
189
311
  );
190
312
  }
191
313
  }
@@ -228,7 +350,48 @@ export function renderGantt(
228
350
 
229
351
  renderWeekendBands(g, resolved, xScale, innerHeight, palette, isDark);
230
352
  renderHolidayBands(g, svg, resolved, xScale, innerHeight, palette, isDark, marginTop - 4, leftMargin, onClickItem);
231
- 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
+ }
232
395
 
233
396
  // ── Render rows ─────────────────────────────────────────
234
397
 
@@ -256,7 +419,7 @@ export function renderGantt(
256
419
  }
257
420
  }
258
421
  }
259
- let yOffset = 0;
422
+ let yOffset = CONTENT_TOP_PAD;
260
423
 
261
424
  for (const row of rows) {
262
425
  if (row.type === 'lane-header') {
@@ -277,6 +440,7 @@ export function renderGantt(
277
440
 
278
441
  lanePositions.set(row.laneName, { x1: lx1, x2: lx1 + laneBarWidth, y: yOffset + BAR_H / 2 });
279
442
 
443
+ renderLabelBand(svg, marginTop + yOffset + BAR_H / 2, leftMargin, laneColor, palette, 'lane', { key: 'data-lane', value: row.laneName });
280
444
  const labelG = svg
281
445
  .append('g')
282
446
  .attr('class', 'gantt-lane-header')
@@ -344,7 +508,6 @@ export function renderGantt(
344
508
  .attr('y', yOffset)
345
509
  .attr('width', laneBarWidth * Math.min(row.aggregateProgress / 100, 1))
346
510
  .attr('height', BAR_H)
347
- .attr('rx', 4)
348
511
  .attr('fill', laneColor)
349
512
  .attr('opacity', 0.5)
350
513
  .attr('pointer-events', 'none');
@@ -358,8 +521,10 @@ export function renderGantt(
358
521
  const indent = ' '.repeat(group.depth);
359
522
  const toggleIcon = isCollapsed ? '►' : '▼';
360
523
 
361
- // Group label with toggle
362
- const groupColor = group.color || palette.textMuted;
524
+ // Group label with toggle — resolve tag color from group metadata
525
+ const tagColor = resolveTagColor(group.metadata, resolved.tagGroups, currentActiveGroup, true);
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 });
363
528
  const labelG = svg
364
529
  .append('g')
365
530
  .attr('class', 'gantt-group-label')
@@ -378,7 +543,8 @@ export function renderGantt(
378
543
  hideGanttDateIndicators(g);
379
544
  });
380
545
 
381
- 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;
382
548
  labelG
383
549
  .append('text')
384
550
  .attr('x', labelX)
@@ -430,11 +596,27 @@ export function renderGantt(
430
596
  .attr('y', yOffset)
431
597
  .attr('width', barWidth * Math.min(group.progress / 100, 1))
432
598
  .attr('height', BAR_H)
433
- .attr('rx', 4)
434
599
  .attr('fill', groupColor)
435
600
  .attr('opacity', 0.5);
436
601
  }
437
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
+
438
620
  // Track collapsed group position for dependency arrow redirection
439
621
  groupPositions.set(group.name, { x1: gx1, x2: gx1 + barWidth, y: yOffset + BAR_H / 2 });
440
622
  } else {
@@ -472,10 +654,26 @@ export function renderGantt(
472
654
  .attr('y', yOffset)
473
655
  .attr('width', groupBarWidth * Math.min(group.progress / 100, 1))
474
656
  .attr('height', BAR_H)
475
- .attr('rx', 4)
476
657
  .attr('fill', groupColor)
477
658
  .attr('opacity', 0.5);
478
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
+ }
479
677
  }
480
678
  }
481
679
 
@@ -484,8 +682,13 @@ export function renderGantt(
484
682
  const rt = row.task;
485
683
  const task = rt.task;
486
684
 
685
+ // Resolve bar color early so icon tspan can use it
686
+ const barColor = resolveTaskColor(rt, currentActiveGroup, resolved, seriesColors, palette);
687
+
487
688
  // Task label on the left (left-aligned with indent; flat in tag mode)
488
- 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;
489
692
  const topGroup = rt.groupPath.length > 0 ? rt.groupPath[0] : null;
490
693
  const taskLabel = svg
491
694
  .append('text')
@@ -500,17 +703,22 @@ export function renderGantt(
500
703
  .attr('data-task-id', task.id)
501
704
  .attr('data-group', topGroup)
502
705
  .style('cursor', onClickItem ? 'pointer' : 'default')
503
- .text(task.label)
504
706
  .on('click', () => {
505
707
  if (onClickItem) onClickItem(task.lineNumber);
506
708
  })
507
709
  .on('mouseenter', () => {
508
- highlightTask(g, svg, task.id);
710
+ if (rt.isMilestone) {
711
+ highlightMilestone(g, svg, task.id);
712
+ } else {
713
+ highlightTask(g, svg, task.id);
714
+ }
509
715
  })
510
716
  .on('mouseleave', () => {
511
717
  resetHighlight(g, svg);
512
718
  });
513
719
 
720
+ appendTaskIcon(taskLabel, task.label, rt.isMilestone, barColor, palette.text);
721
+
514
722
  // Tag attributes on label for legend hover matching
515
723
  for (const [key, value] of Object.entries(rt.effectiveMetadata)) {
516
724
  taskLabel.attr(`data-tag-${key}`, value.toLowerCase());
@@ -519,9 +727,6 @@ export function renderGantt(
519
727
  taskLabel.attr('data-critical-path', 'true');
520
728
  }
521
729
 
522
- // Determine color
523
- let barColor = resolveTaskColor(rt, currentActiveGroup, resolved, seriesColors, palette);
524
-
525
730
  if (rt.isMilestone) {
526
731
  // Render diamond
527
732
  const mx = xScale(dateToFractionalYear(rt.startDate));
@@ -534,13 +739,14 @@ export function renderGantt(
534
739
  .attr('stroke-width', 1.5)
535
740
  .attr('data-line-number', String(task.lineNumber))
536
741
  .attr('data-task-name', task.label)
742
+ .attr('data-task-id', task.id)
537
743
  .attr('data-group', topGroup)
538
744
  .style('cursor', onClickItem ? 'pointer' : 'default')
539
745
  .on('click', () => {
540
746
  if (onClickItem) onClickItem(task.lineNumber);
541
747
  })
542
748
  .on('mouseenter', () => {
543
- highlightTaskLabel(svg, task.lineNumber);
749
+ highlightMilestone(g, svg, task.id);
544
750
  showGanttDateIndicators(g, xScale, rt.startDate, null, innerHeight, barColor);
545
751
  // Show label next to diamond
546
752
  g.append('text')
@@ -555,7 +761,7 @@ export function renderGantt(
555
761
  .text(task.label);
556
762
  })
557
763
  .on('mouseleave', () => {
558
- resetTaskLabels(svg);
764
+ resetHighlight(g, svg);
559
765
  hideGanttDateIndicators(g);
560
766
  g.selectAll('.gantt-milestone-hover-label').remove();
561
767
  });
@@ -672,7 +878,6 @@ export function renderGantt(
672
878
  .attr('y', yOffset)
673
879
  .attr('width', progressWidth)
674
880
  .attr('height', BAR_H)
675
- .attr('rx', 4)
676
881
  .attr('fill', progressFill)
677
882
  .attr('opacity', 0.5);
678
883
  }
@@ -683,18 +888,19 @@ export function renderGantt(
683
888
  }
684
889
 
685
890
 
686
- // Label inside bar (if fits)
687
- const textWidth = task.label.length * 6.5;
688
- 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) {
689
894
  taskG
690
895
  .append('text')
691
- .attr('x', x1 + 6)
896
+ .attr('x', labelPlacement.x)
692
897
  .attr('y', yOffset + BAR_H / 2)
693
898
  .attr('dy', '0.35em')
694
899
  .attr('font-size', '10px')
695
- .attr('fill', palette.text)
900
+ .attr('text-anchor', labelPlacement.anchor)
901
+ .attr('fill', labelPlacement.fill)
696
902
  .attr('pointer-events', 'none')
697
- .text(task.label);
903
+ .text(labelPlacement.text);
698
904
  }
699
905
 
700
906
  // Track bar position for arrows
@@ -705,38 +911,42 @@ export function renderGantt(
705
911
  }
706
912
  }
707
913
 
708
- // ── Today marker ────────────────────────────────────────
914
+ // ── Today hover overlay (rendered after rows so it receives pointer events) ──
709
915
 
710
- if (resolved.options.todayMarker !== 'off') {
711
- let todayDate: Date;
712
- if (resolved.options.todayMarker === 'on') {
713
- todayDate = new Date();
714
- } else {
715
- todayDate = new Date(resolved.options.todayMarker + 'T00:00:00');
716
- }
717
- const todayX = xScale(dateToFractionalYear(todayDate));
718
- if (todayX >= 0 && todayX <= innerWidth) {
719
- g.append('line')
720
- .attr('class', 'gantt-today')
721
- .attr('x1', todayX)
722
- .attr('y1', 0)
723
- .attr('x2', todayX)
724
- .attr('y2', innerHeight + 10)
725
- .attr('stroke', palette.accent || '#e74c3c')
726
- .attr('stroke-width', 2)
727
- .attr('stroke-dasharray', '6 4')
728
- .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');
729
920
 
730
- g.append('text')
731
- .attr('class', 'gantt-today')
732
- .attr('x', todayX + 4)
733
- .attr('y', innerHeight + 24)
734
- .attr('text-anchor', 'start')
735
- .attr('font-size', '10px')
736
- .attr('fill', palette.accent || '#e74c3c')
737
- .attr('opacity', 0.7)
738
- .text('Today');
739
- }
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
+ });
740
950
  }
741
951
 
742
952
  // ── Dependency arrows ───────────────────────────────────
@@ -1021,6 +1231,7 @@ function renderDependencyArrows(
1021
1231
  .attr('class', 'gantt-dep-arrow')
1022
1232
  .attr('data-dep-from', rt.task.id)
1023
1233
  .attr('data-dep-to', targetTask.task.id)
1234
+ .attr('data-line-number', String(dep.lineNumber))
1024
1235
  .attr('data-critical-path', isCpArrow ? 'true' : null)
1025
1236
  .attr('d', path)
1026
1237
  .attr('fill', 'none')
@@ -1035,6 +1246,7 @@ function renderDependencyArrows(
1035
1246
  .attr('class', 'gantt-dep-arrowhead')
1036
1247
  .attr('data-dep-from', rt.task.id)
1037
1248
  .attr('data-dep-to', targetTask.task.id)
1249
+ .attr('data-line-number', String(dep.lineNumber))
1038
1250
  .attr('data-critical-path', isCpArrow ? 'true' : null)
1039
1251
  .attr('points', arrowheadPoints(tx, ty, headSize, angle))
1040
1252
  .attr('fill', arrowColor)
@@ -1066,7 +1278,9 @@ function applyCriticalPathHighlight(
1066
1278
  el.attr('opacity', el.attr('data-critical-path') === 'true' ? 1 : FADE_OPACITY);
1067
1279
  });
1068
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);
1069
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);
1070
1284
  chartG.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr('opacity', FADE_OPACITY);
1071
1285
  // Show critical path arrows at full opacity, fade others
1072
1286
  chartG.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').each(function () {
@@ -1083,7 +1297,9 @@ function resetHighlightAll(
1083
1297
  chartG.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', 1);
1084
1298
  svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').attr('opacity', 1);
1085
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);
1086
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);
1087
1303
  chartG.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr('opacity', 1);
1088
1304
  chartG.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', 0.5);
1089
1305
  }
@@ -1133,11 +1349,13 @@ function renderTagLegend(
1133
1349
  isDark: boolean,
1134
1350
  hasCriticalPath: boolean,
1135
1351
  criticalPathActive: boolean,
1352
+ optionLineNumbers: Record<string, number>,
1136
1353
  onToggle?: (groupName: string) => void,
1137
1354
  onToggleCriticalPath?: () => void,
1138
1355
  currentSwimlaneGroup?: string | null,
1139
1356
  onSwimlaneChange?: (group: string | null) => void,
1140
1357
  legendViewMode?: boolean,
1358
+ resolvedTasks?: ResolvedTask[],
1141
1359
  ): void {
1142
1360
  const groupBg = isDark
1143
1361
  ? mix(palette.surface, palette.bg, 50)
@@ -1155,6 +1373,32 @@ function renderTagLegend(
1155
1373
  visibleGroups = tagGroups;
1156
1374
  }
1157
1375
 
1376
+ // Build set of used tag values per group from resolved tasks
1377
+ const usedValues = new Map<string, Set<string>>();
1378
+ if (resolvedTasks) {
1379
+ for (const group of visibleGroups) {
1380
+ const key = group.name.toLowerCase();
1381
+ const used = new Set<string>();
1382
+ for (const rt of resolvedTasks) {
1383
+ const val = rt.effectiveMetadata[key];
1384
+ if (val) used.add(val.toLowerCase());
1385
+ }
1386
+ usedValues.set(key, used);
1387
+ }
1388
+ }
1389
+
1390
+ // Filter entries to only those used in the current view
1391
+ const filteredEntries = new Map<string, TagEntry[]>();
1392
+ for (const group of visibleGroups) {
1393
+ const key = group.name.toLowerCase();
1394
+ const used = usedValues.get(key);
1395
+ if (used && used.size > 0) {
1396
+ filteredEntries.set(key, group.entries.filter(e => used.has(e.value.toLowerCase())));
1397
+ } else {
1398
+ filteredEntries.set(key, group.entries);
1399
+ }
1400
+ }
1401
+
1158
1402
  // Compute per-group widths
1159
1403
  const groupWidths: number[] = [];
1160
1404
  let totalW = 0;
@@ -1166,8 +1410,9 @@ function renderTagLegend(
1166
1410
  const pillW = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD + iconReserve;
1167
1411
  let groupW = pillW;
1168
1412
  if (isActive) {
1413
+ const entries = filteredEntries.get(group.name.toLowerCase()) ?? group.entries;
1169
1414
  let entriesW = 0;
1170
- for (const entry of group.entries) {
1415
+ for (const entry of entries) {
1171
1416
  entriesW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1172
1417
  }
1173
1418
  groupW = LEGEND_CAPSULE_PAD * 2 + pillW + 4 + entriesW;
@@ -1188,8 +1433,9 @@ function renderTagLegend(
1188
1433
  totalW += cpPillW;
1189
1434
  }
1190
1435
 
1191
- // Center over chart area (not full container)
1192
- 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;
1193
1439
 
1194
1440
  const legendRow = svg.append('g')
1195
1441
  .attr('class', 'gantt-tag-legend-container')
@@ -1211,6 +1457,7 @@ function renderTagLegend(
1211
1457
  .attr('transform', `translate(${cursorX}, 0)`)
1212
1458
  .attr('class', 'gantt-tag-legend-group')
1213
1459
  .attr('data-tag-group', group.name)
1460
+ .attr('data-line-number', String(group.lineNumber))
1214
1461
  .style('cursor', 'pointer')
1215
1462
  .on('click', () => { if (onToggle) onToggle(group.name); });
1216
1463
 
@@ -1278,16 +1525,18 @@ function renderTagLegend(
1278
1525
  });
1279
1526
  }
1280
1527
 
1281
- // Entries (when active — expanded color group)
1528
+ // Entries (when active — expanded color group, only used values)
1282
1529
  if (isActive) {
1283
1530
  const tagKey = group.name.toLowerCase();
1531
+ const entries = filteredEntries.get(tagKey) ?? group.entries;
1284
1532
  let ex = pillXOff + pillW + LEGEND_CAPSULE_PAD + 4;
1285
- for (const entry of group.entries) {
1533
+ for (const entry of entries) {
1286
1534
  const entryValue = entry.value.toLowerCase();
1287
1535
 
1288
1536
  // Wrap dot + label in a <g> for hover targeting
1289
1537
  const entryG = gEl.append('g')
1290
1538
  .attr('class', 'gantt-legend-entry')
1539
+ .attr('data-line-number', String(entry.lineNumber))
1291
1540
  .style('cursor', 'pointer');
1292
1541
 
1293
1542
  // Dot
@@ -1349,11 +1598,13 @@ function renderTagLegend(
1349
1598
 
1350
1599
  // Critical Path pill
1351
1600
  if (hasCriticalPath) {
1601
+ const cpLineNum = optionLineNumbers['critical-path'];
1352
1602
  const cpG = legendRow.append('g')
1353
1603
  .attr('transform', `translate(${cursorX}, 0)`)
1354
1604
  .attr('class', 'gantt-legend-critical-path')
1355
1605
  .style('cursor', 'pointer')
1356
1606
  .on('click', () => { if (onToggleCriticalPath) onToggleCriticalPath(); });
1607
+ if (cpLineNum) cpG.attr('data-line-number', String(cpLineNum));
1357
1608
 
1358
1609
  cpG.append('rect')
1359
1610
  .attr('width', cpPillW)
@@ -1403,6 +1654,7 @@ const ERA_COLORS = ['#5e81ac', '#a3be8c', '#ebcb8b', '#d08770', '#b48ead'];
1403
1654
 
1404
1655
  function renderErasAndMarkers(
1405
1656
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1657
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1406
1658
  resolved: ResolvedSchedule,
1407
1659
  xScale: d3Scale.ScaleLinear<number, number>,
1408
1660
  innerHeight: number,
@@ -1422,7 +1674,8 @@ function renderErasAndMarkers(
1422
1674
  const eraEndDate = parseDateStringToDate(era.endDate);
1423
1675
 
1424
1676
  const eraG = g.append('g')
1425
- .attr('class', 'gantt-era-group');
1677
+ .attr('class', 'gantt-era-group')
1678
+ .attr('data-line-number', String(era.lineNumber));
1426
1679
 
1427
1680
  const eraRect = eraG.append('rect')
1428
1681
  .attr('class', 'gantt-era')
@@ -1433,71 +1686,119 @@ function renderErasAndMarkers(
1433
1686
  .attr('fill', color)
1434
1687
  .attr('opacity', baseEraOpacity);
1435
1688
 
1436
- // Era label (inside chart at top)
1689
+ // Era label (above date scale, same zone as markers)
1437
1690
  eraG.append('text')
1438
1691
  .attr('class', 'gantt-era-label')
1439
1692
  .attr('x', (sx + ex) / 2)
1440
- .attr('y', 12)
1693
+ .attr('y', -24)
1441
1694
  .attr('text-anchor', 'middle')
1442
1695
  .attr('font-size', '10px')
1443
1696
  .attr('fill', color)
1444
1697
  .attr('opacity', 0.7)
1445
- .attr('pointer-events', 'none')
1698
+ .style('cursor', 'pointer')
1446
1699
  .text(era.label);
1447
1700
 
1448
1701
  eraG
1449
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
1450
1714
  eraRect.attr('opacity', hoverEraOpacity);
1451
1715
  showGanttDateIndicators(g, xScale, eraStartDate, eraEndDate, innerHeight, color);
1452
1716
  })
1453
1717
  .on('mouseleave', () => {
1718
+ resetHighlight(g, svg);
1454
1719
  eraRect.attr('opacity', baseEraOpacity);
1455
1720
  hideGanttDateIndicators(g);
1456
1721
  });
1457
1722
  }
1458
1723
 
1459
- // Markers: vertical dashed lines
1724
+ // Markers: label → diamond → dashed line (same layout as timeline)
1460
1725
  for (const marker of resolved.markers) {
1461
1726
  const color = marker.color || palette.accent || '#d08770';
1462
1727
  const mx = xScale(parseDateToFractionalYear(marker.date));
1463
1728
  const markerDate = parseDateStringToDate(marker.date);
1729
+ const diamondSize = 5;
1730
+ const labelY = -24;
1731
+ const diamondY = labelY + 14;
1464
1732
 
1465
1733
  const markerG = g.append('g')
1466
- .attr('class', 'gantt-marker-group');
1734
+ .attr('class', 'gantt-marker-group')
1735
+ .attr('data-line-number', String(marker.lineNumber))
1736
+ .style('cursor', 'pointer');
1737
+
1738
+ // Invisible hit rect for easier clicking/hovering
1739
+ markerG.append('rect')
1740
+ .attr('x', mx - 40)
1741
+ .attr('y', labelY - 12)
1742
+ .attr('width', 80)
1743
+ .attr('height', innerHeight - labelY + 12)
1744
+ .attr('fill', 'transparent')
1745
+ .attr('pointer-events', 'all');
1746
+
1747
+ // Label above diamond
1748
+ markerG.append('text')
1749
+ .attr('class', 'gantt-marker-label')
1750
+ .attr('x', mx)
1751
+ .attr('y', labelY)
1752
+ .attr('text-anchor', 'middle')
1753
+ .attr('font-size', '11px')
1754
+ .attr('font-weight', '600')
1755
+ .attr('fill', color)
1756
+ .text(marker.label);
1757
+
1758
+ // Diamond below label
1759
+ markerG.append('path')
1760
+ .attr('d', `M${mx},${diamondY - diamondSize} l${diamondSize},${diamondSize} l-${diamondSize},${diamondSize} l-${diamondSize},-${diamondSize} Z`)
1761
+ .attr('fill', color)
1762
+ .attr('opacity', 0.9);
1467
1763
 
1764
+ // Dashed line from diamond down
1468
1765
  markerG.append('line')
1469
1766
  .attr('class', 'gantt-marker')
1470
1767
  .attr('x1', mx)
1471
- .attr('y1', 0)
1768
+ .attr('y1', diamondY + diamondSize)
1472
1769
  .attr('x2', mx)
1473
1770
  .attr('y2', innerHeight)
1474
1771
  .attr('stroke', color)
1475
1772
  .attr('stroke-width', 1.5)
1476
- .attr('stroke-dasharray', '6 3')
1477
- .attr('opacity', 0.5);
1478
-
1479
- // Diamond indicator (at top of chart area)
1480
- markerG.append('polygon')
1481
- .attr('points', diamondPoints(mx, 6, 8))
1482
- .attr('fill', color)
1773
+ .attr('stroke-dasharray', '6 4')
1483
1774
  .attr('opacity', 0.5);
1484
1775
 
1485
- // Label (inside chart at top)
1486
- markerG.append('text')
1487
- .attr('class', 'gantt-marker-label')
1488
- .attr('x', mx + 8)
1489
- .attr('y', 10)
1490
- .attr('font-size', '9px')
1491
- .attr('fill', color)
1492
- .attr('opacity', 0.7)
1493
- .attr('pointer-events', 'none')
1494
- .text(marker.label);
1495
-
1776
+ // Hide marker line/diamond on hover but keep label visible
1777
+ const markerLine = markerG.select('.gantt-marker');
1778
+ const markerDiamond = markerG.select('path');
1496
1779
  markerG
1497
1780
  .on('mouseenter', () => {
1498
- showGanttDateIndicators(g, xScale, markerDate, null, innerHeight, color);
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);
1795
+ markerDiamond.attr('opacity', 0);
1796
+ showGanttDateIndicators(g, xScale, markerDate, null, innerHeight, color, { skipStartLine: true });
1499
1797
  })
1500
1798
  .on('mouseleave', () => {
1799
+ resetHighlight(g, svg);
1800
+ markerLine.attr('opacity', 0.5);
1801
+ markerDiamond.attr('opacity', 0.9);
1501
1802
  hideGanttDateIndicators(g);
1502
1803
  });
1503
1804
  }
@@ -1520,11 +1821,7 @@ function parseDateStringToDate(s: string): Date {
1520
1821
  * Used for eras and markers which may have partial dates.
1521
1822
  */
1522
1823
  function parseDateToFractionalYear(s: string): number {
1523
- const parts = s.split('-').map(p => parseInt(p, 10));
1524
- const year = parts[0];
1525
- const month = parts.length >= 2 ? parts[1] : 1;
1526
- const day = parts.length >= 3 ? parts[2] : 1;
1527
- return year + (month - 1) / 12 + (day - 1) / 365;
1824
+ return dateToFractionalYear(parseDateStringToDate(s));
1528
1825
  }
1529
1826
 
1530
1827
  // ── Dependency Hover Helpers ─────────────────────────────────
@@ -1580,6 +1877,8 @@ function highlightDeps(
1580
1877
  const isRelated = (from && related.has(from)) || (to && related.has(to));
1581
1878
  el.attr('opacity', isRelated ? 0.5 : FADE_OPACITY);
1582
1879
  });
1880
+ // Fade markers
1881
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
1583
1882
  }
1584
1883
 
1585
1884
  function highlightGroup(
@@ -1612,9 +1911,17 @@ function highlightGroup(
1612
1911
  const el = d3Selection.select(this);
1613
1912
  el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
1614
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
+ });
1615
1919
  // Fade lane elements
1616
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);
1617
1922
  g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr('opacity', FADE_OPACITY);
1923
+ // Fade markers
1924
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
1618
1925
  }
1619
1926
 
1620
1927
  function highlightLane(
@@ -1651,9 +1958,17 @@ function highlightLane(
1651
1958
  const el = d3Selection.select(this);
1652
1959
  el.attr('opacity', el.attr('data-lane') === laneName ? 1 : FADE_OPACITY);
1653
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
+ });
1654
1966
  // Fade group elements (not relevant in lane mode)
1655
1967
  g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1656
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);
1970
+ // Fade markers
1971
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
1657
1972
  }
1658
1973
 
1659
1974
  function highlightTask(
@@ -1676,9 +1991,42 @@ function highlightTask(
1676
1991
  // Fade group/lane elements
1677
1992
  g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1678
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);
1679
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);
1680
1997
  g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', FADE_OPACITY);
1681
1998
  g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', FADE_OPACITY);
1999
+ // Fade markers
2000
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
2001
+ }
2002
+
2003
+ function highlightMilestone(
2004
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2005
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2006
+ taskId: string,
2007
+ ): void {
2008
+ // Fade tasks
2009
+ g.selectAll<SVGGElement, unknown>('.gantt-task').attr('opacity', FADE_OPACITY);
2010
+ // Fade milestones not matching
2011
+ g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
2012
+ const el = d3Selection.select(this);
2013
+ el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
2014
+ });
2015
+ // Fade task labels not matching
2016
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
2017
+ const el = d3Selection.select(this);
2018
+ el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
2019
+ });
2020
+ // Fade group/lane elements
2021
+ g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
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);
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);
2026
+ g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', FADE_OPACITY);
2027
+ g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', FADE_OPACITY);
2028
+ // Fade markers
2029
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
1682
2030
  }
1683
2031
 
1684
2032
  function highlightTaskLabel(
@@ -1705,10 +2053,14 @@ function resetHighlight(
1705
2053
  g.selectAll<SVGGElement, unknown>('.gantt-task, .gantt-milestone').attr('opacity', 1);
1706
2054
  g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', 1);
1707
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);
1708
2057
  svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').attr('opacity', 1);
1709
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);
1710
2060
  g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', 1);
1711
2061
  g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', 0.5);
2062
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', 1);
2063
+ g.selectAll<SVGElement, unknown>('.gantt-era-group').attr('opacity', 1);
1712
2064
  }
1713
2065
 
1714
2066
  // ── Row Building ────────────────────────────────────────────
@@ -1821,20 +2173,16 @@ export function buildTagLaneRowList(
1821
2173
  }
1822
2174
  }
1823
2175
 
1824
- // Emit lanes in tag entry declaration order
2176
+ // Emit lanes in tag entry declaration order (skip empty lanes)
1825
2177
  for (const entry of tagGroup.entries) {
1826
2178
  const entryKey = entry.value.toLowerCase();
1827
2179
  const tasks = buckets.get(entryKey) ?? [];
2180
+ if (tasks.length === 0) continue;
1828
2181
  // Sort tasks within lane by start date
1829
2182
  tasks.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
1830
2183
 
1831
- // Compute aggregate progress
1832
- const progressValues = tasks
1833
- .map(t => t.task.progress)
1834
- .filter((p): p is number => p !== null);
1835
- const aggregateProgress = progressValues.length > 0
1836
- ? progressValues.reduce((a, b) => a + b, 0) / progressValues.length
1837
- : null;
2184
+ // Compute duration-weighted aggregate progress (tasks without progress count as 0%)
2185
+ const aggregateProgress = durationWeightedProgress(tasks);
1838
2186
 
1839
2187
  // Compute lane date range from tasks
1840
2188
  const laneStartDate = tasks.length > 0 ? new Date(Math.min(...tasks.map(t => t.startDate.getTime()))) : null;
@@ -1861,12 +2209,7 @@ export function buildTagLaneRowList(
1861
2209
  // Append unbucketed tasks as "No {GroupName}" lane
1862
2210
  if (unbucketed.length > 0) {
1863
2211
  unbucketed.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
1864
- const progressValues = unbucketed
1865
- .map(t => t.task.progress)
1866
- .filter((p): p is number => p !== null);
1867
- const aggregateProgress = progressValues.length > 0
1868
- ? progressValues.reduce((a, b) => a + b, 0) / progressValues.length
1869
- : null;
2212
+ const aggregateProgress = durationWeightedProgress(unbucketed);
1870
2213
 
1871
2214
  const noLaneStartDate = unbucketed.length > 0 ? new Date(Math.min(...unbucketed.map(t => t.startDate.getTime()))) : null;
1872
2215
  const noLaneEndDate = unbucketed.length > 0 ? new Date(Math.max(...unbucketed.map(t => t.endDate.getTime()))) : null;
@@ -1895,6 +2238,22 @@ export function buildTagLaneRowList(
1895
2238
 
1896
2239
  // ── Helpers ─────────────────────────────────────────────────
1897
2240
 
2241
+ /** Duration-weighted progress: tasks without explicit progress count as 0%. Returns null if no task has progress. */
2242
+ function durationWeightedProgress(tasks: ResolvedTask[]): number | null {
2243
+ let totalDuration = 0;
2244
+ let totalProgress = 0;
2245
+ let hasProgress = false;
2246
+ for (const rt of tasks) {
2247
+ const dur = rt.endDate.getTime() - rt.startDate.getTime();
2248
+ totalDuration += dur;
2249
+ if (rt.task.progress !== null) {
2250
+ totalProgress += rt.task.progress * dur;
2251
+ hasProgress = true;
2252
+ }
2253
+ }
2254
+ return hasProgress && totalDuration > 0 ? totalProgress / totalDuration : null;
2255
+ }
2256
+
1898
2257
  function dateToFractionalYear(d: Date): number {
1899
2258
  const y = d.getFullYear();
1900
2259
  const startOfYear = new Date(y, 0, 1);
@@ -1923,29 +2282,38 @@ function showGanttDateIndicators(
1923
2282
  endDate: Date | null,
1924
2283
  innerHeight: number,
1925
2284
  color: string,
2285
+ options?: { skipStartLine?: boolean },
1926
2286
  ): void {
1927
2287
  // Fade existing scale ticks and today marker
1928
2288
  g.selectAll('.gantt-scale-tick').attr('opacity', 0.05);
1929
2289
  g.selectAll('.gantt-today').attr('opacity', 0.05);
1930
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
+
1931
2297
  const tickLen = 6;
1932
2298
  const startPos = xScale(dateToFractionalYear(startDate));
1933
2299
  const startLabel = formatGanttDate(startDate);
1934
2300
 
1935
- // Start date — dashed vertical line
1936
- g.append('line')
1937
- .attr('class', 'gantt-hover-date')
1938
- .attr('x1', startPos)
1939
- .attr('y1', -tickLen)
1940
- .attr('x2', startPos)
1941
- .attr('y2', innerHeight)
1942
- .attr('stroke', color)
1943
- .attr('stroke-width', 1.5)
1944
- .attr('stroke-dasharray', '4 4')
1945
- .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
+ }
1946
2314
 
1947
2315
  // Start date — top label
1948
- g.append('text')
2316
+ hg.append('text')
1949
2317
  .attr('class', 'gantt-hover-date')
1950
2318
  .attr('x', startPos)
1951
2319
  .attr('y', -tickLen - 4)
@@ -1956,7 +2324,7 @@ function showGanttDateIndicators(
1956
2324
  .text(startLabel);
1957
2325
 
1958
2326
  // Start date — bottom label
1959
- g.append('text')
2327
+ hg.append('text')
1960
2328
  .attr('class', 'gantt-hover-date')
1961
2329
  .attr('x', startPos)
1962
2330
  .attr('y', innerHeight + tickLen + 12)
@@ -1970,8 +2338,24 @@ function showGanttDateIndicators(
1970
2338
  const endPos = xScale(dateToFractionalYear(endDate));
1971
2339
  const endLabel = formatGanttDate(endDate);
1972
2340
 
2341
+ // When dates are close, push labels apart so they don't overlap.
2342
+ // ~90px is roughly the width of a date label like "Aug 12, 2026" at 10px.
2343
+ const minLabelGap = 90;
2344
+ const gap = endPos - startPos;
2345
+ let startLabelX = startPos;
2346
+ let endLabelX = endPos;
2347
+ let startAnchor = 'middle';
2348
+ let endAnchor = 'middle';
2349
+ if (gap < minLabelGap) {
2350
+ const mid = (startPos + endPos) / 2;
2351
+ startLabelX = mid - minLabelGap / 2;
2352
+ endLabelX = mid + minLabelGap / 2;
2353
+ startAnchor = 'middle';
2354
+ endAnchor = 'middle';
2355
+ }
2356
+
1973
2357
  // End date — dashed vertical line
1974
- g.append('line')
2358
+ hg.append('line')
1975
2359
  .attr('class', 'gantt-hover-date')
1976
2360
  .attr('x1', endPos)
1977
2361
  .attr('y1', -tickLen)
@@ -1982,23 +2366,31 @@ function showGanttDateIndicators(
1982
2366
  .attr('stroke-dasharray', '4 4')
1983
2367
  .attr('opacity', 0.6);
1984
2368
 
2369
+ // Reposition start labels to avoid overlap
2370
+ hg.selectAll<SVGTextElement, unknown>('text.gantt-hover-date').each(function () {
2371
+ const el = d3Selection.select(this);
2372
+ if (el.text() === startLabel) {
2373
+ el.attr('x', startLabelX).attr('text-anchor', startAnchor);
2374
+ }
2375
+ });
2376
+
1985
2377
  // End date — top label
1986
- g.append('text')
2378
+ hg.append('text')
1987
2379
  .attr('class', 'gantt-hover-date')
1988
- .attr('x', endPos)
2380
+ .attr('x', endLabelX)
1989
2381
  .attr('y', -tickLen - 4)
1990
- .attr('text-anchor', 'middle')
2382
+ .attr('text-anchor', endAnchor)
1991
2383
  .attr('fill', color)
1992
2384
  .attr('font-size', '10px')
1993
2385
  .attr('font-weight', '600')
1994
2386
  .text(endLabel);
1995
2387
 
1996
2388
  // End date — bottom label
1997
- g.append('text')
2389
+ hg.append('text')
1998
2390
  .attr('class', 'gantt-hover-date')
1999
- .attr('x', endPos)
2391
+ .attr('x', endLabelX)
2000
2392
  .attr('y', innerHeight + tickLen + 12)
2001
- .attr('text-anchor', 'middle')
2393
+ .attr('text-anchor', endAnchor)
2002
2394
  .attr('fill', color)
2003
2395
  .attr('font-size', '10px')
2004
2396
  .attr('font-weight', '600')