@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.
- package/dist/cli.cjs +180 -180
- package/dist/index.cjs +415 -144
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +415 -144
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/c4/parser.ts +3 -2
- package/src/d3.ts +7 -9
- package/src/er/parser.ts +5 -3
- package/src/gantt/calculator.ts +51 -11
- package/src/gantt/parser.ts +31 -20
- package/src/gantt/renderer.ts +524 -132
- package/src/gantt/types.ts +4 -0
- package/src/org/parser.ts +7 -5
- package/src/sequence/parser.ts +10 -9
- package/src/sitemap/parser.ts +5 -3
- package/src/utils/parsing.ts +23 -12
package/src/gantt/renderer.ts
CHANGED
|
@@ -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.
|
|
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 =>
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
687
|
-
const
|
|
688
|
-
if (
|
|
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',
|
|
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('
|
|
900
|
+
.attr('text-anchor', labelPlacement.anchor)
|
|
901
|
+
.attr('fill', labelPlacement.fill)
|
|
696
902
|
.attr('pointer-events', 'none')
|
|
697
|
-
.text(
|
|
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
|
|
914
|
+
// ── Today hover overlay (rendered after rows so it receives pointer events) ──
|
|
709
915
|
|
|
710
|
-
if (
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
|
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
|
|
1192
|
-
const
|
|
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
|
|
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 (
|
|
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',
|
|
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
|
-
.
|
|
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:
|
|
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',
|
|
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
|
|
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
|
-
//
|
|
1486
|
-
markerG.
|
|
1487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1937
|
-
.
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2378
|
+
hg.append('text')
|
|
1987
2379
|
.attr('class', 'gantt-hover-date')
|
|
1988
|
-
.attr('x',
|
|
2380
|
+
.attr('x', endLabelX)
|
|
1989
2381
|
.attr('y', -tickLen - 4)
|
|
1990
|
-
.attr('text-anchor',
|
|
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
|
-
|
|
2389
|
+
hg.append('text')
|
|
1998
2390
|
.attr('class', 'gantt-hover-date')
|
|
1999
|
-
.attr('x',
|
|
2391
|
+
.attr('x', endLabelX)
|
|
2000
2392
|
.attr('y', innerHeight + tickLen + 12)
|
|
2001
|
-
.attr('text-anchor',
|
|
2393
|
+
.attr('text-anchor', endAnchor)
|
|
2002
2394
|
.attr('fill', color)
|
|
2003
2395
|
.attr('font-size', '10px')
|
|
2004
2396
|
.attr('font-weight', '600')
|