@diagrammo/dgmo 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.claude/commands/dgmo.md +231 -13
  2. package/AGENTS.md +148 -0
  3. package/dist/cli.cjs +341 -165
  4. package/dist/index.cjs +4900 -1685
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +259 -18
  7. package/dist/index.d.ts +259 -18
  8. package/dist/index.js +4642 -1436
  9. package/dist/index.js.map +1 -1
  10. package/package.json +5 -3
  11. package/src/c4/layout.ts +0 -5
  12. package/src/c4/parser.ts +0 -16
  13. package/src/c4/renderer.ts +7 -11
  14. package/src/class/layout.ts +0 -1
  15. package/src/class/parser.ts +28 -0
  16. package/src/class/renderer.ts +189 -34
  17. package/src/cli.ts +566 -25
  18. package/src/colors.ts +3 -3
  19. package/src/completion.ts +58 -0
  20. package/src/d3.ts +179 -122
  21. package/src/dgmo-router.ts +3 -58
  22. package/src/echarts.ts +96 -55
  23. package/src/er/parser.ts +30 -1
  24. package/src/er/renderer.ts +12 -7
  25. package/src/gantt/calculator.ts +677 -0
  26. package/src/gantt/parser.ts +761 -0
  27. package/src/gantt/renderer.ts +2125 -0
  28. package/src/gantt/resolver.ts +144 -0
  29. package/src/gantt/types.ts +168 -0
  30. package/src/graph/flowchart-parser.ts +27 -4
  31. package/src/graph/flowchart-renderer.ts +1 -2
  32. package/src/graph/state-parser.ts +0 -1
  33. package/src/graph/state-renderer.ts +1 -3
  34. package/src/index.ts +37 -0
  35. package/src/infra/compute.ts +0 -7
  36. package/src/infra/layout.ts +0 -2
  37. package/src/infra/parser.ts +46 -4
  38. package/src/infra/renderer.ts +49 -27
  39. package/src/initiative-status/filter.ts +63 -0
  40. package/src/initiative-status/layout.ts +319 -67
  41. package/src/initiative-status/parser.ts +200 -25
  42. package/src/initiative-status/renderer.ts +298 -35
  43. package/src/initiative-status/types.ts +6 -0
  44. package/src/kanban/parser.ts +0 -2
  45. package/src/org/layout.ts +22 -59
  46. package/src/org/renderer.ts +11 -36
  47. package/src/palettes/dracula.ts +60 -0
  48. package/src/palettes/index.ts +8 -6
  49. package/src/palettes/monokai.ts +60 -0
  50. package/src/palettes/registry.ts +4 -2
  51. package/src/sequence/parser.ts +14 -11
  52. package/src/sequence/renderer.ts +5 -6
  53. package/src/sequence/tag-resolution.ts +0 -1
  54. package/src/sharing.ts +8 -0
  55. package/src/sitemap/layout.ts +1 -14
  56. package/src/sitemap/parser.ts +1 -2
  57. package/src/sitemap/renderer.ts +4 -7
  58. package/src/utils/arrows.ts +7 -7
  59. package/src/utils/duration.ts +212 -0
  60. package/src/utils/export-container.ts +40 -0
  61. package/src/utils/legend-constants.ts +1 -0
@@ -0,0 +1,2125 @@
1
+ // ============================================================
2
+ // Gantt Chart Renderer
3
+ // ============================================================
4
+
5
+ import * as d3Scale from 'd3-scale';
6
+ import * as d3Selection from 'd3-selection';
7
+ import { FONT_FAMILY } from '../fonts';
8
+ import { getSeriesColors } from '../palettes';
9
+ import { mix } from '../palettes/color-utils';
10
+ import { resolveTagColor } from '../utils/tag-groups';
11
+ import { computeTimeTicks } from '../d3';
12
+ import { buildHolidaySet, formatDateKey } from '../utils/duration';
13
+ import {
14
+ LEGEND_HEIGHT,
15
+ LEGEND_PILL_PAD,
16
+ LEGEND_PILL_FONT_SIZE,
17
+ LEGEND_PILL_FONT_W,
18
+ LEGEND_CAPSULE_PAD,
19
+ LEGEND_DOT_R,
20
+ LEGEND_ENTRY_FONT_SIZE,
21
+ LEGEND_ENTRY_FONT_W,
22
+ LEGEND_ENTRY_DOT_GAP,
23
+ LEGEND_ENTRY_TRAIL,
24
+ LEGEND_GROUP_GAP,
25
+ LEGEND_ICON_W,
26
+ } from '../utils/legend-constants';
27
+ import type { PaletteColors } from '../palettes';
28
+ import type { D3ExportDimensions } from '../d3';
29
+ import type { ResolvedSchedule, ResolvedTask, ResolvedGroup, Weekday } from './types';
30
+ import type { TagGroup } from '../utils/tag-groups';
31
+
32
+ // ── Constants ───────────────────────────────────────────────
33
+
34
+ const BAR_H = 22;
35
+ const ROW_GAP = 6;
36
+ const GROUP_GAP = 14;
37
+ const GROUP_LABEL_GAP = 8;
38
+ const MILESTONE_SIZE = 10;
39
+ const MIN_LEFT_MARGIN = 120;
40
+ const BOTTOM_MARGIN = 40;
41
+ const RIGHT_MARGIN = 20;
42
+
43
+ // ── Interactive Options ─────────────────────────────────────
44
+
45
+ export interface GanttInteractiveOptions {
46
+ onClickItem?: (lineNumber: number) => void;
47
+ collapsedGroups?: Set<string>;
48
+ onToggleGroup?: (groupName: string) => void;
49
+ currentSwimlaneGroup?: string | null;
50
+ onSwimlaneChange?: (group: string | null) => void;
51
+ currentActiveGroup?: string | null;
52
+ onActiveGroupChange?: (group: string | null) => void;
53
+ collapsedLanes?: Set<string>;
54
+ onToggleLane?: (laneName: string) => void;
55
+ viewMode?: boolean;
56
+ }
57
+
58
+ // ── Main Renderer ───────────────────────────────────────────
59
+
60
+ export function renderGantt(
61
+ container: HTMLDivElement,
62
+ resolved: ResolvedSchedule,
63
+ palette: PaletteColors,
64
+ isDark: boolean,
65
+ options?: GanttInteractiveOptions,
66
+ exportDims?: D3ExportDimensions,
67
+ ): void {
68
+ // Clear previous content
69
+ container.innerHTML = '';
70
+
71
+ if (resolved.error || resolved.tasks.length === 0) return;
72
+
73
+ // ── Destructure options ─────────────────────────────────
74
+
75
+ const onClickItem = options?.onClickItem;
76
+ const collapsedGroups = options?.collapsedGroups;
77
+ const onToggleGroup = options?.onToggleGroup;
78
+ const viewMode = options?.viewMode ?? false;
79
+ const currentSwimlaneGroup = options?.currentSwimlaneGroup ?? null;
80
+ const onSwimlaneChange = options?.onSwimlaneChange;
81
+ const onActiveGroupChange = options?.onActiveGroupChange;
82
+ const collapsedLanes = options?.collapsedLanes;
83
+ const onToggleLane = options?.onToggleLane;
84
+
85
+ // ── Compute layout dimensions ───────────────────────────
86
+
87
+ const seriesColors = getSeriesColors(palette);
88
+ let currentActiveGroup: string | null = options?.currentActiveGroup !== undefined
89
+ ? options.currentActiveGroup
90
+ : (resolved.tagGroups.length > 0 ? resolved.tagGroups[0].name : null);
91
+ let criticalPathActive = false;
92
+
93
+ // ── Build row list (structural vs tag mode) ─────────────
94
+
95
+ const tagRows = currentSwimlaneGroup
96
+ ? buildTagLaneRowList(resolved, currentSwimlaneGroup, collapsedLanes)
97
+ : null;
98
+ const rows = tagRows ?? buildRowList(resolved, collapsedGroups);
99
+ const isTagMode = tagRows !== null;
100
+
101
+ // Compute left margin based on longest visible label
102
+ const allLabels = isTagMode
103
+ ? [
104
+ ...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),
106
+ ]
107
+ : [
108
+ ...resolved.tasks.map(t => t.task.label),
109
+ ...resolved.groups.map(g => ' '.repeat(g.depth) + g.name),
110
+ ];
111
+ const maxLabelLen = Math.max(...allLabels.map(l => l.length), 10);
112
+ const leftMargin = Math.max(MIN_LEFT_MARGIN, maxLabelLen * 7 + 30);
113
+
114
+ const totalRows = rows.length;
115
+
116
+ // Vertical layout — matches timeline pattern (d3.ts:3649-3655)
117
+ const title = resolved.options.title;
118
+ const titleHeight = title ? 50 : 20;
119
+ const tagLegendReserve = resolved.tagGroups.length > 0 ? LEGEND_HEIGHT + 8 : 0;
120
+ const topDateLabelReserve = 22; // tick (6) + gap (4) + label height (~12)
121
+
122
+ const marginTop = titleHeight + tagLegendReserve + topDateLabelReserve;
123
+
124
+ // Content area
125
+ const contentH = isTagMode
126
+ ? totalRows * (BAR_H + ROW_GAP)
127
+ : totalRows * (BAR_H + ROW_GAP) + GROUP_GAP * resolved.groups.length;
128
+ const innerHeight = contentH;
129
+ const outerHeight = marginTop + innerHeight + BOTTOM_MARGIN;
130
+
131
+ const containerWidth = exportDims?.width ?? (container.clientWidth || 800);
132
+ const innerWidth = containerWidth - leftMargin - RIGHT_MARGIN;
133
+
134
+ // ── Create SVG ──────────────────────────────────────────
135
+
136
+ const svg = d3Selection
137
+ .select(container)
138
+ .append('svg')
139
+ .attr('viewBox', `0 0 ${containerWidth} ${outerHeight}`)
140
+ .attr('width', containerWidth)
141
+ .attr('height', outerHeight)
142
+ .attr('font-family', FONT_FAMILY)
143
+ .style('overflow', 'visible');
144
+
145
+ const g = svg
146
+ .append('g')
147
+ .attr('transform', `translate(${leftMargin}, ${marginTop})`);
148
+
149
+ // ── Title (y=30, consistent with timeline/initiative-status) ──
150
+
151
+ if (title) {
152
+ svg
153
+ .append('text')
154
+ .attr('x', containerWidth / 2)
155
+ .attr('y', 30)
156
+ .attr('text-anchor', 'middle')
157
+ .attr('font-size', '20px')
158
+ .attr('font-weight', '700')
159
+ .attr('fill', palette.text)
160
+ .text(title);
161
+ }
162
+
163
+ // ── Tag legend (interactive) ────────────────────────────
164
+
165
+ const hasCriticalPath = resolved.options.criticalPath && resolved.tasks.some(t => t.isCriticalPath);
166
+
167
+ function drawLegend() {
168
+ svg.selectAll('.gantt-tag-legend-container').remove();
169
+ if (resolved.tagGroups.length > 0 || hasCriticalPath) {
170
+ const legendY = titleHeight;
171
+ renderTagLegend(
172
+ svg, g, resolved.tagGroups, currentActiveGroup, leftMargin, innerWidth,
173
+ legendY, palette, isDark, hasCriticalPath, criticalPathActive,
174
+ (groupName) => {
175
+ // Toggle active group
176
+ currentActiveGroup = currentActiveGroup?.toLowerCase() === groupName.toLowerCase()
177
+ ? null : groupName;
178
+ if (onActiveGroupChange) onActiveGroupChange(currentActiveGroup);
179
+ drawLegend();
180
+ recolorBars();
181
+ },
182
+ () => {
183
+ criticalPathActive = !criticalPathActive;
184
+ drawLegend();
185
+ },
186
+ currentSwimlaneGroup,
187
+ onSwimlaneChange,
188
+ viewMode,
189
+ );
190
+ }
191
+ }
192
+
193
+ function recolorBars() {
194
+ g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
195
+ const el = d3Selection.select(this);
196
+ const taskId = el.attr('data-task-id');
197
+ const rt = resolved.tasks.find(t => t.task.id === taskId);
198
+ if (!rt) return;
199
+ const color = resolveTaskColor(rt, currentActiveGroup, resolved, seriesColors, palette);
200
+ const fillColor = mix(color, palette.bg, 30);
201
+ el.select('rect').attr('fill', fillColor).attr('stroke', color);
202
+ });
203
+ }
204
+
205
+ drawLegend();
206
+
207
+ // ── Time scale ──────────────────────────────────────────
208
+
209
+ const startTime = dateToFractionalYear(resolved.startDate);
210
+ const endTime = dateToFractionalYear(resolved.endDate);
211
+
212
+ // Add small padding to domain
213
+ const domainPad = Math.max((endTime - startTime) * 0.02, 0.01);
214
+ const domainMin = startTime - domainPad;
215
+ const domainMax = endTime + domainPad;
216
+
217
+ const xScale = d3Scale
218
+ .scaleLinear()
219
+ .domain([domainMin, domainMax])
220
+ .range([0, innerWidth]);
221
+
222
+ // Render time scale ticks (bottom only)
223
+ renderTimeScaleHorizontal(g, xScale, innerWidth, innerHeight, palette.text);
224
+
225
+ // Date labels are rendered at the bottom only (via renderTimeScaleHorizontal)
226
+
227
+ // ── Weekend + holiday bands ─────────────────────────────
228
+
229
+ renderWeekendBands(g, resolved, xScale, innerHeight, palette, isDark);
230
+ renderHolidayBands(g, svg, resolved, xScale, innerHeight, palette, isDark, marginTop - 4, leftMargin, onClickItem);
231
+ renderErasAndMarkers(g, resolved, xScale, innerHeight, palette);
232
+
233
+ // ── Render rows ─────────────────────────────────────────
234
+
235
+ // Track task positions for dependency arrows
236
+ const taskPositions = new Map<string, { x1: number; x2: number; y: number }>();
237
+ // Track collapsed group bar positions so hidden-task arrows redirect there
238
+ const groupPositions = new Map<string, { x1: number; x2: number; y: number }>();
239
+ // Track lane header positions for collapsed lane arrow redirection (tag mode)
240
+ const lanePositions = new Map<string, { x1: number; x2: number; y: number }>();
241
+ // Map task ID → lane name for collapsed lane lookup (tag mode)
242
+ const taskLaneMap = new Map<string, string>();
243
+ if (isTagMode && currentSwimlaneGroup) {
244
+ const tagGroup = resolved.tagGroups.find(
245
+ tg => tg.name.toLowerCase() === currentSwimlaneGroup.toLowerCase()
246
+ );
247
+ if (tagGroup) {
248
+ const tagKey = tagGroup.name.toLowerCase();
249
+ for (const rt of resolved.tasks) {
250
+ let value = rt.effectiveMetadata[tagKey];
251
+ if (!value && tagGroup.defaultValue) value = tagGroup.defaultValue;
252
+ if (value) {
253
+ const entry = tagGroup.entries.find(e => e.value.toLowerCase() === value!.toLowerCase());
254
+ if (entry) taskLaneMap.set(rt.task.id, entry.value);
255
+ }
256
+ }
257
+ }
258
+ }
259
+ let yOffset = 0;
260
+
261
+ for (const row of rows) {
262
+ if (row.type === 'lane-header') {
263
+ // ── Lane header (tag swimlane mode) ──
264
+ const laneColor = row.laneColor === '#999999' ? palette.textMuted : row.laneColor;
265
+ const toggleIcon = row.isCollapsed ? '►' : '▼';
266
+ const labelX = 10;
267
+
268
+ // Compute lane bar x range from task dates
269
+ let lx1 = 0;
270
+ let lx2 = innerWidth;
271
+ let laneBarWidth = innerWidth;
272
+ if (row.laneStartDate && row.laneEndDate) {
273
+ lx1 = xScale(dateToFractionalYear(row.laneStartDate));
274
+ lx2 = xScale(dateToFractionalYear(row.laneEndDate));
275
+ laneBarWidth = Math.max(lx2 - lx1, 2);
276
+ }
277
+
278
+ lanePositions.set(row.laneName, { x1: lx1, x2: lx1 + laneBarWidth, y: yOffset + BAR_H / 2 });
279
+
280
+ const labelG = svg
281
+ .append('g')
282
+ .attr('class', 'gantt-lane-header')
283
+ .attr(`data-tag-${row.tagKey}`, row.laneName.toLowerCase())
284
+ .attr('data-lane', row.laneName)
285
+ .style('cursor', onToggleLane ? 'pointer' : 'default')
286
+ .on('click', () => {
287
+ if (onToggleLane) onToggleLane(row.laneName);
288
+ })
289
+ .on('mouseenter', () => {
290
+ highlightLane(g, svg, row.tagKey, row.laneName);
291
+ if (row.laneStartDate && row.laneEndDate) {
292
+ showGanttDateIndicators(g, xScale, row.laneStartDate, row.laneEndDate, innerHeight, laneColor);
293
+ }
294
+ })
295
+ .on('mouseleave', () => {
296
+ resetHighlight(g, svg);
297
+ hideGanttDateIndicators(g);
298
+ });
299
+
300
+ // Label with toggle icon
301
+ labelG
302
+ .append('text')
303
+ .attr('x', labelX)
304
+ .attr('y', marginTop + yOffset + BAR_H / 2)
305
+ .attr('dy', '0.35em')
306
+ .attr('text-anchor', 'start')
307
+ .attr('font-size', '11px')
308
+ .attr('font-weight', 'bold')
309
+ .attr('fill', laneColor)
310
+ .text(toggleIcon + ' ' + row.laneName + (row.aggregateProgress !== null ? ` ${Math.round(row.aggregateProgress)}%` : ''));
311
+
312
+ if (laneBarWidth > 0) {
313
+ const barFill = mix(laneColor, palette.bg, 30);
314
+ const laneBandG = g.append('g')
315
+ .attr('class', 'gantt-lane-band-group')
316
+ .attr('data-lane', row.laneName)
317
+ .on('mouseenter', () => {
318
+ highlightLane(g, svg, row.tagKey, row.laneName);
319
+ if (row.laneStartDate && row.laneEndDate) {
320
+ showGanttDateIndicators(g, xScale, row.laneStartDate, row.laneEndDate, innerHeight, laneColor);
321
+ }
322
+ })
323
+ .on('mouseleave', () => {
324
+ resetHighlight(g, svg);
325
+ hideGanttDateIndicators(g);
326
+ });
327
+
328
+ laneBandG.append('rect')
329
+ .attr('class', 'gantt-lane-band')
330
+ .attr('x', lx1)
331
+ .attr('y', yOffset)
332
+ .attr('width', laneBarWidth)
333
+ .attr('height', BAR_H)
334
+ .attr('rx', 4)
335
+ .attr('fill', barFill)
336
+ .attr('stroke', laneColor)
337
+ .attr('stroke-width', 2);
338
+
339
+ // Aggregate progress fill
340
+ if (row.aggregateProgress !== null && row.aggregateProgress > 0) {
341
+ laneBandG.append('rect')
342
+ .attr('class', 'gantt-lane-progress')
343
+ .attr('x', lx1)
344
+ .attr('y', yOffset)
345
+ .attr('width', laneBarWidth * Math.min(row.aggregateProgress / 100, 1))
346
+ .attr('height', BAR_H)
347
+ .attr('rx', 4)
348
+ .attr('fill', laneColor)
349
+ .attr('opacity', 0.5)
350
+ .attr('pointer-events', 'none');
351
+ }
352
+ }
353
+
354
+ yOffset += BAR_H + ROW_GAP;
355
+ } else if (row.type === 'group') {
356
+ const group = row.group;
357
+ const isCollapsed = collapsedGroups?.has(group.name) ?? false;
358
+ const indent = ' '.repeat(group.depth);
359
+ const toggleIcon = isCollapsed ? '►' : '▼';
360
+
361
+ // Group label with toggle
362
+ const groupColor = group.color || palette.textMuted;
363
+ const labelG = svg
364
+ .append('g')
365
+ .attr('class', 'gantt-group-label')
366
+ .attr('data-group', group.name)
367
+ .attr('data-line-number', String(group.lineNumber))
368
+ .style('cursor', onToggleGroup ? 'pointer' : 'default')
369
+ .on('click', () => {
370
+ if (onToggleGroup) onToggleGroup(group.name);
371
+ })
372
+ .on('mouseenter', () => {
373
+ highlightGroup(g, svg, group.name);
374
+ showGanttDateIndicators(g, xScale, group.startDate, group.endDate, innerHeight, groupColor);
375
+ })
376
+ .on('mouseleave', () => {
377
+ resetHighlight(g, svg);
378
+ hideGanttDateIndicators(g);
379
+ });
380
+
381
+ const labelX = 10 + group.depth * 14;
382
+ labelG
383
+ .append('text')
384
+ .attr('x', labelX)
385
+ .attr('y', marginTop + yOffset + BAR_H / 2)
386
+ .attr('dy', '0.35em')
387
+ .attr('text-anchor', 'start')
388
+ .attr('font-size', '11px')
389
+ .attr('font-weight', 'bold')
390
+ .attr('fill', palette.text)
391
+ .text(toggleIcon + ' ' + group.name + (group.progress !== null ? ` ${Math.round(group.progress)}%` : ''));
392
+
393
+ // Group bar
394
+ const gStart = dateToFractionalYear(group.startDate);
395
+ const gEnd = dateToFractionalYear(group.endDate);
396
+ const gx1 = xScale(gStart);
397
+ const gx2 = xScale(gEnd);
398
+
399
+ if (gx2 > gx1) {
400
+ if (isCollapsed) {
401
+ // Summary bar (full height, shows aggregate progress)
402
+ const barWidth = Math.max(gx2 - gx1, 2);
403
+ const summaryG = g.append('g')
404
+ .attr('class', 'gantt-group-summary')
405
+ .attr('data-group', group.name)
406
+ .attr('data-line-number', String(group.lineNumber))
407
+ .on('mouseenter', () => {
408
+ highlightGroup(g, svg, group.name);
409
+ showGanttDateIndicators(g, xScale, group.startDate, group.endDate, innerHeight, groupColor);
410
+ })
411
+ .on('mouseleave', () => {
412
+ resetHighlight(g, svg);
413
+ hideGanttDateIndicators(g);
414
+ });
415
+
416
+ summaryG.append('rect')
417
+ .attr('x', gx1)
418
+ .attr('y', yOffset)
419
+ .attr('width', barWidth)
420
+ .attr('height', BAR_H)
421
+ .attr('rx', 4)
422
+ .attr('fill', mix(groupColor, palette.bg, 30))
423
+ .attr('stroke', groupColor)
424
+ .attr('stroke-width', 2);
425
+
426
+ // Aggregate progress fill
427
+ if (group.progress !== null && group.progress > 0) {
428
+ summaryG.append('rect')
429
+ .attr('x', gx1)
430
+ .attr('y', yOffset)
431
+ .attr('width', barWidth * Math.min(group.progress / 100, 1))
432
+ .attr('height', BAR_H)
433
+ .attr('rx', 4)
434
+ .attr('fill', groupColor)
435
+ .attr('opacity', 0.5);
436
+ }
437
+
438
+ // Track collapsed group position for dependency arrow redirection
439
+ groupPositions.set(group.name, { x1: gx1, x2: gx1 + barWidth, y: yOffset + BAR_H / 2 });
440
+ } else {
441
+ // Expanded: bar spanning group date range (matches task bar style)
442
+ const groupBarWidth = Math.max(gx2 - gx1, 2);
443
+ const bandFill = mix(groupColor, palette.bg, 30);
444
+ const groupBarG = g.append('g')
445
+ .attr('class', 'gantt-group-bar')
446
+ .attr('data-group', group.name)
447
+ .attr('data-line-number', String(group.lineNumber))
448
+ .on('mouseenter', () => {
449
+ highlightGroup(g, svg, group.name);
450
+ showGanttDateIndicators(g, xScale, group.startDate, group.endDate, innerHeight, groupColor);
451
+ })
452
+ .on('mouseleave', () => {
453
+ resetHighlight(g, svg);
454
+ hideGanttDateIndicators(g);
455
+ });
456
+
457
+ groupBarG.append('rect')
458
+ .attr('x', gx1)
459
+ .attr('y', yOffset)
460
+ .attr('width', groupBarWidth)
461
+ .attr('height', BAR_H)
462
+ .attr('rx', 4)
463
+ .attr('fill', bandFill)
464
+ .attr('stroke', groupColor)
465
+ .attr('stroke-width', 2);
466
+
467
+ // Aggregate progress fill
468
+ if (group.progress !== null && group.progress > 0) {
469
+ groupBarG.append('rect')
470
+ .attr('class', 'gantt-group-progress')
471
+ .attr('x', gx1)
472
+ .attr('y', yOffset)
473
+ .attr('width', groupBarWidth * Math.min(group.progress / 100, 1))
474
+ .attr('height', BAR_H)
475
+ .attr('rx', 4)
476
+ .attr('fill', groupColor)
477
+ .attr('opacity', 0.5);
478
+ }
479
+ }
480
+ }
481
+
482
+ yOffset += BAR_H + ROW_GAP;
483
+ } else if (row.type === 'task') {
484
+ const rt = row.task;
485
+ const task = rt.task;
486
+
487
+ // Task label on the left (left-aligned with indent; flat in tag mode)
488
+ const taskLabelX = isTagMode ? 20 : 6 + rt.groupPath.length * 14;
489
+ const topGroup = rt.groupPath.length > 0 ? rt.groupPath[0] : null;
490
+ const taskLabel = svg
491
+ .append('text')
492
+ .attr('class', 'gantt-task-label')
493
+ .attr('x', taskLabelX)
494
+ .attr('y', marginTop + yOffset + BAR_H / 2)
495
+ .attr('dy', '0.35em')
496
+ .attr('text-anchor', 'start')
497
+ .attr('font-size', '11px')
498
+ .attr('fill', palette.text)
499
+ .attr('data-line-number', String(task.lineNumber))
500
+ .attr('data-task-id', task.id)
501
+ .attr('data-group', topGroup)
502
+ .style('cursor', onClickItem ? 'pointer' : 'default')
503
+ .text(task.label)
504
+ .on('click', () => {
505
+ if (onClickItem) onClickItem(task.lineNumber);
506
+ })
507
+ .on('mouseenter', () => {
508
+ highlightTask(g, svg, task.id);
509
+ })
510
+ .on('mouseleave', () => {
511
+ resetHighlight(g, svg);
512
+ });
513
+
514
+ // Tag attributes on label for legend hover matching
515
+ for (const [key, value] of Object.entries(rt.effectiveMetadata)) {
516
+ taskLabel.attr(`data-tag-${key}`, value.toLowerCase());
517
+ }
518
+ if (rt.isCriticalPath) {
519
+ taskLabel.attr('data-critical-path', 'true');
520
+ }
521
+
522
+ // Determine color
523
+ let barColor = resolveTaskColor(rt, currentActiveGroup, resolved, seriesColors, palette);
524
+
525
+ if (rt.isMilestone) {
526
+ // Render diamond
527
+ const mx = xScale(dateToFractionalYear(rt.startDate));
528
+ const my = yOffset + BAR_H / 2;
529
+ g.append('polygon')
530
+ .attr('class', 'gantt-milestone')
531
+ .attr('points', diamondPoints(mx, my, MILESTONE_SIZE))
532
+ .attr('fill', barColor)
533
+ .attr('stroke', barColor)
534
+ .attr('stroke-width', 1.5)
535
+ .attr('data-line-number', String(task.lineNumber))
536
+ .attr('data-task-name', task.label)
537
+ .attr('data-group', topGroup)
538
+ .style('cursor', onClickItem ? 'pointer' : 'default')
539
+ .on('click', () => {
540
+ if (onClickItem) onClickItem(task.lineNumber);
541
+ })
542
+ .on('mouseenter', () => {
543
+ highlightTaskLabel(svg, task.lineNumber);
544
+ showGanttDateIndicators(g, xScale, rt.startDate, null, innerHeight, barColor);
545
+ // Show label next to diamond
546
+ g.append('text')
547
+ .attr('class', 'gantt-milestone-hover-label')
548
+ .attr('x', mx - MILESTONE_SIZE - 4)
549
+ .attr('y', my)
550
+ .attr('dy', '0.35em')
551
+ .attr('text-anchor', 'end')
552
+ .attr('font-size', '10px')
553
+ .attr('fill', barColor)
554
+ .attr('font-weight', '600')
555
+ .text(task.label);
556
+ })
557
+ .on('mouseleave', () => {
558
+ resetTaskLabels(svg);
559
+ hideGanttDateIndicators(g);
560
+ g.selectAll('.gantt-milestone-hover-label').remove();
561
+ });
562
+
563
+ // Track milestone position for arrows
564
+ taskPositions.set(task.id, { x1: mx, x2: mx, y: my });
565
+ } else {
566
+ // Render bar
567
+ const tStart = dateToFractionalYear(rt.startDate);
568
+ const tEnd = dateToFractionalYear(rt.endDate);
569
+ const x1 = xScale(tStart);
570
+ const x2 = xScale(tEnd);
571
+ const barWidth = Math.max(x2 - x1, 2);
572
+
573
+ const fillColor = mix(barColor, palette.bg, 30);
574
+
575
+ const taskG = g.append('g')
576
+ .attr('class', 'gantt-task')
577
+ .attr('data-line-number', String(task.lineNumber))
578
+ .attr('data-task-name', task.label)
579
+ .attr('data-task-id', task.id)
580
+ .attr('data-group', topGroup)
581
+ .style('cursor', onClickItem ? 'pointer' : 'default')
582
+ .on('click', () => {
583
+ if (onClickItem) onClickItem(task.lineNumber);
584
+ })
585
+ .on('mouseenter', () => {
586
+ if (resolved.options.dependencies) {
587
+ highlightDeps(g, svg, task.id, resolved);
588
+ }
589
+ highlightTaskLabel(svg, task.lineNumber);
590
+ showGanttDateIndicators(g, xScale, rt.startDate, rt.endDate, innerHeight, barColor);
591
+ })
592
+ .on('mouseleave', () => {
593
+ if (resolved.options.dependencies) {
594
+ if (criticalPathActive) {
595
+ applyCriticalPathHighlight(svg, g);
596
+ } else {
597
+ resetHighlight(g, svg);
598
+ }
599
+ }
600
+ resetTaskLabels(svg);
601
+ hideGanttDateIndicators(g);
602
+ });
603
+
604
+ // Set tag attributes
605
+ for (const [key, value] of Object.entries(rt.effectiveMetadata)) {
606
+ taskG.attr(`data-tag-${key}`, value.toLowerCase());
607
+ }
608
+
609
+ // Uncertainty gradient — fade out the trailing edge unless progress > 80%
610
+ const showUncertainFade = rt.isUncertain && (task.progress === null || task.progress <= 80);
611
+ let barFill: string = fillColor;
612
+ let barStroke: string = barColor;
613
+ if (showUncertainFade) {
614
+ const defs = svg.select('defs').empty()
615
+ ? svg.append('defs')
616
+ : svg.select<SVGDefsElement>('defs');
617
+
618
+ const fillGradId = `gantt-uncertain-fill-${task.id}`;
619
+ const fillGrad = defs.append('linearGradient')
620
+ .attr('id', fillGradId)
621
+ .attr('x1', '0').attr('x2', '1').attr('y1', '0').attr('y2', '0');
622
+ fillGrad.append('stop').attr('offset', '0%').attr('stop-color', fillColor).attr('stop-opacity', 1);
623
+ fillGrad.append('stop').attr('offset', '50%').attr('stop-color', fillColor).attr('stop-opacity', 1);
624
+ fillGrad.append('stop').attr('offset', '100%').attr('stop-color', fillColor).attr('stop-opacity', 0);
625
+
626
+ const strokeGradId = `gantt-uncertain-stroke-${task.id}`;
627
+ const strokeGrad = defs.append('linearGradient')
628
+ .attr('id', strokeGradId)
629
+ .attr('x1', '0').attr('x2', '1').attr('y1', '0').attr('y2', '0');
630
+ strokeGrad.append('stop').attr('offset', '0%').attr('stop-color', barColor).attr('stop-opacity', 1);
631
+ strokeGrad.append('stop').attr('offset', '50%').attr('stop-color', barColor).attr('stop-opacity', 1);
632
+ strokeGrad.append('stop').attr('offset', '100%').attr('stop-color', barColor).attr('stop-opacity', 0);
633
+
634
+ barFill = `url(#${fillGradId})`;
635
+ barStroke = `url(#${strokeGradId})`;
636
+ }
637
+
638
+ // Main bar
639
+ taskG
640
+ .append('rect')
641
+ .attr('x', x1)
642
+ .attr('y', yOffset)
643
+ .attr('width', barWidth)
644
+ .attr('height', BAR_H)
645
+ .attr('rx', 4)
646
+ .attr('fill', barFill)
647
+ .attr('stroke', barStroke)
648
+ .attr('stroke-width', 2);
649
+
650
+ // Progress fill
651
+ if (task.progress !== null && task.progress > 0) {
652
+ const progressWidth = barWidth * Math.min(task.progress / 100, 1);
653
+ let progressFill: string = barColor;
654
+ if (showUncertainFade) {
655
+ // Scale gradient stops relative to progress width within the full bar
656
+ const ratio = barWidth / progressWidth;
657
+ const fadeStart = Math.min(50 * ratio, 100);
658
+ const defs = svg.select<SVGDefsElement>('defs');
659
+ const progGradId = `gantt-uncertain-progress-${task.id}`;
660
+ const progGrad = defs.append('linearGradient')
661
+ .attr('id', progGradId)
662
+ .attr('x1', '0').attr('x2', '1').attr('y1', '0').attr('y2', '0');
663
+ progGrad.append('stop').attr('offset', '0%').attr('stop-color', barColor).attr('stop-opacity', 1);
664
+ progGrad.append('stop').attr('offset', `${fadeStart}%`).attr('stop-color', barColor).attr('stop-opacity', 1);
665
+ progGrad.append('stop').attr('offset', '100%').attr('stop-color', barColor).attr('stop-opacity', 0);
666
+ progressFill = `url(#${progGradId})`;
667
+ }
668
+ taskG
669
+ .append('rect')
670
+ .attr('class', 'gantt-progress')
671
+ .attr('x', x1)
672
+ .attr('y', yOffset)
673
+ .attr('width', progressWidth)
674
+ .attr('height', BAR_H)
675
+ .attr('rx', 4)
676
+ .attr('fill', progressFill)
677
+ .attr('opacity', 0.5);
678
+ }
679
+
680
+ // Critical path data attribute (for legend hover highlighting)
681
+ if (rt.isCriticalPath) {
682
+ taskG.attr('data-critical-path', 'true');
683
+ }
684
+
685
+
686
+ // Label inside bar (if fits)
687
+ const textWidth = task.label.length * 6.5;
688
+ if (textWidth < barWidth - 8) {
689
+ taskG
690
+ .append('text')
691
+ .attr('x', x1 + 6)
692
+ .attr('y', yOffset + BAR_H / 2)
693
+ .attr('dy', '0.35em')
694
+ .attr('font-size', '10px')
695
+ .attr('fill', palette.text)
696
+ .attr('pointer-events', 'none')
697
+ .text(task.label);
698
+ }
699
+
700
+ // Track bar position for arrows
701
+ taskPositions.set(task.id, { x1, x2: x1 + barWidth, y: yOffset + BAR_H / 2 });
702
+ }
703
+
704
+ yOffset += BAR_H + ROW_GAP;
705
+ }
706
+ }
707
+
708
+ // ── Today marker ────────────────────────────────────────
709
+
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);
729
+
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
+ }
740
+ }
741
+
742
+ // ── Dependency arrows ───────────────────────────────────
743
+
744
+ if (resolved.options.dependencies) {
745
+ renderDependencyArrows(g, resolved, taskPositions, groupPositions, collapsedGroups, palette, isDark, isTagMode, lanePositions, collapsedLanes, taskLaneMap);
746
+ }
747
+ }
748
+
749
+ // ── Weekend Band Rendering ──────────────────────────────────
750
+
751
+ const JS_DAY_TO_WEEKDAY: Weekday[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
752
+
753
+ function renderWeekendBands(
754
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
755
+ resolved: ResolvedSchedule,
756
+ xScale: d3Scale.ScaleLinear<number, number>,
757
+ innerHeight: number,
758
+ palette: PaletteColors,
759
+ isDark: boolean,
760
+ ): void {
761
+ const workweek = new Set(resolved.holidays.workweek);
762
+ const start = new Date(resolved.startDate);
763
+ start.setDate(start.getDate() - 1); // start one day before
764
+ const end = new Date(resolved.endDate);
765
+ end.setDate(end.getDate() + 1); // end one day after
766
+
767
+ const current = new Date(start);
768
+ let bandStart: Date | null = null;
769
+
770
+ while (current <= end) {
771
+ const dayName = JS_DAY_TO_WEEKDAY[current.getDay()];
772
+ const isWeekend = !workweek.has(dayName);
773
+
774
+ if (isWeekend && !bandStart) {
775
+ bandStart = new Date(current);
776
+ } else if (!isWeekend && bandStart) {
777
+ // Draw band from bandStart to current
778
+ drawBand(g, xScale, bandStart, current, innerHeight, palette, isDark, 'gantt-weekend-band', 0.04);
779
+ bandStart = null;
780
+ }
781
+ current.setDate(current.getDate() + 1);
782
+ }
783
+ // Close any trailing band
784
+ if (bandStart) {
785
+ drawBand(g, xScale, bandStart, current, innerHeight, palette, isDark, 'gantt-weekend-band', 0.04);
786
+ }
787
+ }
788
+
789
+ // ── Holiday Band Rendering ──────────────────────────────────
790
+
791
+ function renderHolidayBands(
792
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
793
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
794
+ resolved: ResolvedSchedule,
795
+ xScale: d3Scale.ScaleLinear<number, number>,
796
+ innerHeight: number,
797
+ palette: PaletteColors,
798
+ isDark: boolean,
799
+ headerY: number,
800
+ chartLeftMargin: number,
801
+ onClickItem?: (lineNumber: number) => void,
802
+ ): void {
803
+ for (const h of resolved.holidays.dates) {
804
+ const start = new Date(h.date + 'T00:00:00');
805
+ const end = new Date(start);
806
+ end.setDate(end.getDate() + 1);
807
+ drawHolidayBand(g, svg, xScale, start, end, innerHeight, palette, isDark, h.label, h.lineNumber, headerY, chartLeftMargin, onClickItem);
808
+ }
809
+
810
+ for (const r of resolved.holidays.ranges) {
811
+ const start = new Date(r.startDate + 'T00:00:00');
812
+ const end = new Date(r.endDate + 'T00:00:00');
813
+ end.setDate(end.getDate() + 1);
814
+ drawHolidayBand(g, svg, xScale, start, end, innerHeight, palette, isDark, r.label, r.lineNumber, headerY, chartLeftMargin, onClickItem);
815
+ }
816
+ }
817
+
818
+ function drawBand(
819
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
820
+ xScale: d3Scale.ScaleLinear<number, number>,
821
+ start: Date,
822
+ end: Date,
823
+ height: number,
824
+ palette: PaletteColors,
825
+ _isDark: boolean,
826
+ className: string,
827
+ opacity: number,
828
+ ): void {
829
+ const x1 = xScale(dateToFractionalYear(start));
830
+ const x2 = xScale(dateToFractionalYear(end));
831
+ if (x2 <= x1) return;
832
+
833
+ g.append('rect')
834
+ .attr('class', className)
835
+ .attr('x', x1)
836
+ .attr('y', 0)
837
+ .attr('width', x2 - x1)
838
+ .attr('height', height)
839
+ .attr('fill', palette.text)
840
+ .attr('opacity', opacity)
841
+ .attr('pointer-events', 'none');
842
+ }
843
+
844
+ function drawHolidayBand(
845
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
846
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
847
+ xScale: d3Scale.ScaleLinear<number, number>,
848
+ start: Date,
849
+ end: Date,
850
+ height: number,
851
+ palette: PaletteColors,
852
+ _isDark: boolean,
853
+ label: string,
854
+ lineNumber: number,
855
+ headerY: number,
856
+ chartLeftMargin: number,
857
+ onClickItem?: (lineNumber: number) => void,
858
+ ): void {
859
+ const x1 = xScale(dateToFractionalYear(start));
860
+ const x2 = xScale(dateToFractionalYear(end));
861
+ if (x2 <= x1) return;
862
+
863
+ const bandW = Math.max(x2 - x1, 4);
864
+ const baseOpacity = 0.08;
865
+ const hoverOpacity = 0.18;
866
+
867
+ const bandG = g.append('g')
868
+ .attr('class', 'gantt-holiday-band')
869
+ .attr('data-line-number', String(lineNumber))
870
+ .style('cursor', onClickItem ? 'pointer' : 'default');
871
+
872
+ // Band rect
873
+ const bandRect = bandG.append('rect')
874
+ .attr('x', x1)
875
+ .attr('y', 0)
876
+ .attr('width', bandW)
877
+ .attr('height', height)
878
+ .attr('fill', palette.text)
879
+ .attr('opacity', baseOpacity);
880
+
881
+ // Hover label in SVG-space (date header row) — hidden by default
882
+ // Background rect to mask date labels underneath
883
+ const labelX = chartLeftMargin + x1 + bandW / 2;
884
+ const textLen = label.length * 6 + 8;
885
+ const labelBg = svg.append('rect')
886
+ .attr('class', 'gantt-holiday-hover-bg')
887
+ .attr('data-line-number', String(lineNumber))
888
+ .attr('x', labelX - textLen / 2)
889
+ .attr('y', headerY - 11)
890
+ .attr('width', textLen)
891
+ .attr('height', 14)
892
+ .attr('rx', 3)
893
+ .attr('fill', palette.bg)
894
+ .attr('opacity', 0)
895
+ .attr('pointer-events', 'none');
896
+
897
+ const labelText = svg.append('text')
898
+ .attr('class', 'gantt-holiday-hover-label')
899
+ .attr('data-line-number', String(lineNumber))
900
+ .attr('x', labelX)
901
+ .attr('y', headerY)
902
+ .attr('text-anchor', 'middle')
903
+ .attr('font-size', '10px')
904
+ .attr('font-weight', '500')
905
+ .attr('fill', palette.text)
906
+ .attr('opacity', 0)
907
+ .attr('pointer-events', 'none')
908
+ .text(label);
909
+
910
+ // Hover: highlight band + show label in header + date indicators
911
+ bandG
912
+ .on('mouseenter', () => {
913
+ bandRect.attr('opacity', hoverOpacity);
914
+ labelBg.attr('opacity', 1);
915
+ labelText.attr('opacity', 1);
916
+ showGanttDateIndicators(g, xScale, start, end, height, palette.textMuted);
917
+ })
918
+ .on('mouseleave', () => {
919
+ bandRect.attr('opacity', baseOpacity);
920
+ labelBg.attr('opacity', 0);
921
+ labelText.attr('opacity', 0);
922
+ hideGanttDateIndicators(g);
923
+ })
924
+ .on('click', () => {
925
+ if (onClickItem) onClickItem(lineNumber);
926
+ });
927
+ }
928
+
929
+ // ── Dependency Arrow Rendering ──────────────────────────────
930
+
931
+ function findCollapsedGroupPos(
932
+ rt: ResolvedTask,
933
+ collapsedGroups: Set<string> | undefined,
934
+ groupPositions: Map<string, { x1: number; x2: number; y: number }>,
935
+ ): { x1: number; x2: number; y: number } | undefined {
936
+ if (!collapsedGroups) return undefined;
937
+ // Walk the task's group path and find the first collapsed group with a position
938
+ for (const groupName of rt.groupPath) {
939
+ if (collapsedGroups.has(groupName)) {
940
+ return groupPositions.get(groupName);
941
+ }
942
+ }
943
+ return undefined;
944
+ }
945
+
946
+ function findCollapsedLanePos(
947
+ rt: ResolvedTask,
948
+ collapsedLanes: Set<string> | undefined,
949
+ taskLaneMap: Map<string, string>,
950
+ lanePositions: Map<string, { x1: number; x2: number; y: number }>,
951
+ ): { x1: number; x2: number; y: number } | undefined {
952
+ if (!collapsedLanes) return undefined;
953
+ const laneName = taskLaneMap.get(rt.task.id);
954
+ if (laneName && collapsedLanes.has(laneName)) {
955
+ return lanePositions.get(laneName);
956
+ }
957
+ return undefined;
958
+ }
959
+
960
+ function renderDependencyArrows(
961
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
962
+ resolved: ResolvedSchedule,
963
+ taskPositions: Map<string, { x1: number; x2: number; y: number }>,
964
+ groupPositions: Map<string, { x1: number; x2: number; y: number }>,
965
+ collapsedGroups: Set<string> | undefined,
966
+ palette: PaletteColors,
967
+ _isDark: boolean,
968
+ isTagMode: boolean,
969
+ lanePositions: Map<string, { x1: number; x2: number; y: number }>,
970
+ collapsedLanes: Set<string> | undefined,
971
+ taskLaneMap: Map<string, string>,
972
+ ): void {
973
+ // Deduplicate arrows that collapse to the same source→target position
974
+ const drawnArrows = new Set<string>();
975
+
976
+ // Build arrow list from task dependencies
977
+ for (const rt of resolved.tasks) {
978
+ const sourcePos = taskPositions.get(rt.task.id)
979
+ ?? (isTagMode
980
+ ? findCollapsedLanePos(rt, collapsedLanes, taskLaneMap, lanePositions)
981
+ : findCollapsedGroupPos(rt, collapsedGroups, groupPositions));
982
+ if (!sourcePos) continue;
983
+
984
+ for (const dep of rt.task.dependencies) {
985
+ // Find target task
986
+ const targetTask = resolved.tasks.find(t => t.task.label === dep.targetName ||
987
+ `${t.groupPath.join('.')}.${t.task.label}`.endsWith(dep.targetName));
988
+ if (!targetTask) continue;
989
+
990
+ const targetPos = taskPositions.get(targetTask.task.id)
991
+ ?? (isTagMode
992
+ ? findCollapsedLanePos(targetTask, collapsedLanes, taskLaneMap, lanePositions)
993
+ : findCollapsedGroupPos(targetTask, collapsedGroups, groupPositions));
994
+ if (!targetPos) continue;
995
+
996
+ // Skip self-arrows (both source and target collapsed to the same group)
997
+ if (sourcePos === targetPos) continue;
998
+
999
+ // Deduplicate: multiple hidden tasks in the same collapsed group → same arrow
1000
+ const arrowKey = `${sourcePos.x1},${sourcePos.y}->${targetPos.x1},${targetPos.y}`;
1001
+ if (drawnArrows.has(arrowKey)) continue;
1002
+ drawnArrows.add(arrowKey);
1003
+
1004
+ // Arrow from source end to target start
1005
+ const sx = sourcePos.x2;
1006
+ const sy = sourcePos.y;
1007
+ const tx = targetPos.x1;
1008
+ const ty = targetPos.y;
1009
+
1010
+ // Bezier curve with dy-scaled control points for cross-lane arrows
1011
+ const dx = Math.abs(tx - sx);
1012
+ const dy = Math.abs(ty - sy);
1013
+ const cpOffset = Math.max(dx * 0.3, 15, dy * 0.4);
1014
+
1015
+ const path = `M ${sx} ${sy} C ${sx + cpOffset} ${sy}, ${tx - cpOffset} ${ty}, ${tx} ${ty}`;
1016
+
1017
+ const arrowColor = mix(palette.text, palette.bg, 50);
1018
+ const isCpArrow = rt.isCriticalPath && targetTask.isCriticalPath;
1019
+
1020
+ g.append('path')
1021
+ .attr('class', 'gantt-dep-arrow')
1022
+ .attr('data-dep-from', rt.task.id)
1023
+ .attr('data-dep-to', targetTask.task.id)
1024
+ .attr('data-critical-path', isCpArrow ? 'true' : null)
1025
+ .attr('d', path)
1026
+ .attr('fill', 'none')
1027
+ .attr('stroke', arrowColor)
1028
+ .attr('stroke-width', 1.5)
1029
+ .attr('opacity', 0.5);
1030
+
1031
+ // Arrowhead — always horizontal arrival (bezier cp2 has same Y as endpoint)
1032
+ const headSize = 5;
1033
+ const angle = 0;
1034
+ g.append('polygon')
1035
+ .attr('class', 'gantt-dep-arrowhead')
1036
+ .attr('data-dep-from', rt.task.id)
1037
+ .attr('data-dep-to', targetTask.task.id)
1038
+ .attr('data-critical-path', isCpArrow ? 'true' : null)
1039
+ .attr('points', arrowheadPoints(tx, ty, headSize, angle))
1040
+ .attr('fill', arrowColor)
1041
+ .attr('opacity', 0.5);
1042
+ }
1043
+ }
1044
+ }
1045
+
1046
+ function arrowheadPoints(x: number, y: number, size: number, angle: number): string {
1047
+ const a1 = angle + Math.PI * 0.8;
1048
+ const a2 = angle - Math.PI * 0.8;
1049
+ return `${x},${y} ${x + size * Math.cos(a1)},${y + size * Math.sin(a1)} ${x + size * Math.cos(a2)},${y + size * Math.sin(a2)}`;
1050
+ }
1051
+
1052
+ // ── Tag Legend Rendering ─────────────────────────────────────
1053
+
1054
+ function applyCriticalPathHighlight(
1055
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1056
+ chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1057
+ ) {
1058
+ chartG.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
1059
+ const el = d3Selection.select(this);
1060
+ el.attr('opacity', el.attr('data-critical-path') === 'true' ? 1 : FADE_OPACITY);
1061
+ });
1062
+ chartG.selectAll<SVGElement, unknown>('.gantt-milestone').attr('opacity', FADE_OPACITY);
1063
+ chartG.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1064
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
1065
+ const el = d3Selection.select(this);
1066
+ el.attr('opacity', el.attr('data-critical-path') === 'true' ? 1 : FADE_OPACITY);
1067
+ });
1068
+ svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
1069
+ svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
1070
+ chartG.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr('opacity', FADE_OPACITY);
1071
+ // Show critical path arrows at full opacity, fade others
1072
+ chartG.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').each(function () {
1073
+ const el = d3Selection.select(this);
1074
+ el.attr('opacity', el.attr('data-critical-path') === 'true' ? 0.7 : FADE_OPACITY);
1075
+ });
1076
+ }
1077
+
1078
+ function resetHighlightAll(
1079
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1080
+ chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1081
+ ) {
1082
+ chartG.selectAll<SVGGElement, unknown>('.gantt-task, .gantt-milestone').attr('opacity', 1);
1083
+ chartG.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', 1);
1084
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').attr('opacity', 1);
1085
+ svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', 1);
1086
+ svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', 1);
1087
+ chartG.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr('opacity', 1);
1088
+ chartG.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', 0.5);
1089
+ }
1090
+
1091
+ // ── Swimlane Icon Helper ─────────────────────────────────────
1092
+
1093
+ function drawSwimlaneIcon(
1094
+ parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1095
+ x: number,
1096
+ y: number,
1097
+ isActive: boolean,
1098
+ palette: PaletteColors,
1099
+ ): d3Selection.Selection<SVGGElement, unknown, null, undefined> {
1100
+ const iconG = parent.append('g')
1101
+ .attr('class', 'gantt-swimlane-icon')
1102
+ .attr('transform', `translate(${x}, ${y})`);
1103
+
1104
+ const color = isActive ? palette.primary : palette.textMuted;
1105
+ const opacity = isActive ? 1 : 0.35;
1106
+ const barWidths = [8, 12, 6];
1107
+ const barH = 2;
1108
+ const gap = 3;
1109
+
1110
+ for (let i = 0; i < barWidths.length; i++) {
1111
+ iconG.append('rect')
1112
+ .attr('x', 0)
1113
+ .attr('y', i * gap)
1114
+ .attr('width', barWidths[i])
1115
+ .attr('height', barH)
1116
+ .attr('rx', 1)
1117
+ .attr('fill', color)
1118
+ .attr('opacity', opacity);
1119
+ }
1120
+
1121
+ return iconG;
1122
+ }
1123
+
1124
+ function renderTagLegend(
1125
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1126
+ chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1127
+ tagGroups: TagGroup[],
1128
+ activeGroupName: string | null,
1129
+ chartLeftMargin: number,
1130
+ chartInnerWidth: number,
1131
+ legendY: number,
1132
+ palette: PaletteColors,
1133
+ isDark: boolean,
1134
+ hasCriticalPath: boolean,
1135
+ criticalPathActive: boolean,
1136
+ onToggle?: (groupName: string) => void,
1137
+ onToggleCriticalPath?: () => void,
1138
+ currentSwimlaneGroup?: string | null,
1139
+ onSwimlaneChange?: (group: string | null) => void,
1140
+ legendViewMode?: boolean,
1141
+ ): void {
1142
+ const groupBg = isDark
1143
+ ? mix(palette.surface, palette.bg, 50)
1144
+ : mix(palette.surface, palette.bg, 30);
1145
+
1146
+ // Build visible groups: active group expanded + swimlane group as compact pill
1147
+ let visibleGroups: TagGroup[];
1148
+ if (activeGroupName) {
1149
+ const activeGroup = tagGroups.filter(g => g.name.toLowerCase() === activeGroupName.toLowerCase());
1150
+ const swimlaneGroup = currentSwimlaneGroup && currentSwimlaneGroup.toLowerCase() !== activeGroupName.toLowerCase()
1151
+ ? tagGroups.filter(g => g.name.toLowerCase() === currentSwimlaneGroup.toLowerCase())
1152
+ : [];
1153
+ visibleGroups = [...swimlaneGroup, ...activeGroup];
1154
+ } else {
1155
+ visibleGroups = tagGroups;
1156
+ }
1157
+
1158
+ // Compute per-group widths
1159
+ const groupWidths: number[] = [];
1160
+ let totalW = 0;
1161
+ for (const group of visibleGroups) {
1162
+ const isActive = activeGroupName?.toLowerCase() === group.name.toLowerCase();
1163
+ const isSwimlane = currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase();
1164
+ const showIcon = !legendViewMode && tagGroups.length > 0;
1165
+ const iconReserve = showIcon ? LEGEND_ICON_W : 0;
1166
+ const pillW = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD + iconReserve;
1167
+ let groupW = pillW;
1168
+ if (isActive) {
1169
+ let entriesW = 0;
1170
+ for (const entry of group.entries) {
1171
+ entriesW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1172
+ }
1173
+ groupW = LEGEND_CAPSULE_PAD * 2 + pillW + 4 + entriesW;
1174
+ } else if (isSwimlane && !isActive) {
1175
+ // Compact swimlane pill: name + highlighted icon, no entries
1176
+ groupW = pillW;
1177
+ }
1178
+ groupWidths.push(groupW);
1179
+ totalW += groupW;
1180
+ }
1181
+ totalW += Math.max(0, (visibleGroups.length - 1) * LEGEND_GROUP_GAP);
1182
+
1183
+ // Critical Path pill width
1184
+ const cpLabel = 'Critical Path';
1185
+ const cpPillW = cpLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1186
+ if (hasCriticalPath) {
1187
+ if (visibleGroups.length > 0) totalW += LEGEND_GROUP_GAP;
1188
+ totalW += cpPillW;
1189
+ }
1190
+
1191
+ // Center over chart area (not full container)
1192
+ const legendX = chartLeftMargin + (chartInnerWidth - totalW) / 2;
1193
+
1194
+ const legendRow = svg.append('g')
1195
+ .attr('class', 'gantt-tag-legend-container')
1196
+ .attr('transform', `translate(${legendX}, ${legendY})`);
1197
+
1198
+ let cursorX = 0;
1199
+
1200
+ for (let i = 0; i < visibleGroups.length; i++) {
1201
+ const group = visibleGroups[i];
1202
+ const isActive = activeGroupName?.toLowerCase() === group.name.toLowerCase();
1203
+ const isSwimlane = currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase();
1204
+ const showIcon = !legendViewMode && tagGroups.length > 0;
1205
+ const iconReserve = showIcon ? LEGEND_ICON_W : 0;
1206
+ const pillW = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD + iconReserve;
1207
+ const pillH = isActive ? LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2 : LEGEND_HEIGHT;
1208
+ const groupW = groupWidths[i];
1209
+
1210
+ const gEl = legendRow.append('g')
1211
+ .attr('transform', `translate(${cursorX}, 0)`)
1212
+ .attr('class', 'gantt-tag-legend-group')
1213
+ .attr('data-tag-group', group.name)
1214
+ .style('cursor', 'pointer')
1215
+ .on('click', () => { if (onToggle) onToggle(group.name); });
1216
+
1217
+ if (isActive) {
1218
+ // Outer capsule background
1219
+ gEl.append('rect')
1220
+ .attr('width', groupW)
1221
+ .attr('height', LEGEND_HEIGHT)
1222
+ .attr('rx', LEGEND_HEIGHT / 2)
1223
+ .attr('fill', groupBg);
1224
+ }
1225
+
1226
+ const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
1227
+ const pillYOff = isActive ? LEGEND_CAPSULE_PAD : 0;
1228
+
1229
+ // Pill background
1230
+ gEl.append('rect')
1231
+ .attr('x', pillXOff)
1232
+ .attr('y', pillYOff)
1233
+ .attr('width', pillW)
1234
+ .attr('height', pillH)
1235
+ .attr('rx', pillH / 2)
1236
+ .attr('fill', isActive ? palette.bg : groupBg);
1237
+
1238
+ // Active pill border
1239
+ if (isActive) {
1240
+ gEl.append('rect')
1241
+ .attr('x', pillXOff)
1242
+ .attr('y', pillYOff)
1243
+ .attr('width', pillW)
1244
+ .attr('height', pillH)
1245
+ .attr('rx', pillH / 2)
1246
+ .attr('fill', 'none')
1247
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
1248
+ .attr('stroke-width', 0.75);
1249
+ }
1250
+
1251
+ // Pill text (offset to leave room for icon on right)
1252
+ const textW = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1253
+ gEl.append('text')
1254
+ .attr('x', pillXOff + textW / 2)
1255
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
1256
+ .attr('text-anchor', 'middle')
1257
+ .attr('font-size', `${LEGEND_PILL_FONT_SIZE}px`)
1258
+ .attr('font-weight', '500')
1259
+ .attr('fill', isActive || isSwimlane ? palette.text : palette.textMuted)
1260
+ .text(group.name);
1261
+
1262
+ // ≡ swimlane icon (after pill name)
1263
+ if (showIcon) {
1264
+ const iconX = pillXOff + textW + 3;
1265
+ const iconY = (LEGEND_HEIGHT - 10) / 2;
1266
+ const iconEl = drawSwimlaneIcon(gEl, iconX, iconY, isSwimlane, palette);
1267
+ iconEl.append('title').text(`Group by ${group.name}`);
1268
+ iconEl
1269
+ .style('cursor', 'pointer')
1270
+ .on('click', (event: Event) => {
1271
+ event.stopPropagation();
1272
+ if (onSwimlaneChange) {
1273
+ onSwimlaneChange(
1274
+ currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase()
1275
+ ? null : group.name
1276
+ );
1277
+ }
1278
+ });
1279
+ }
1280
+
1281
+ // Entries (when active — expanded color group)
1282
+ if (isActive) {
1283
+ const tagKey = group.name.toLowerCase();
1284
+ let ex = pillXOff + pillW + LEGEND_CAPSULE_PAD + 4;
1285
+ for (const entry of group.entries) {
1286
+ const entryValue = entry.value.toLowerCase();
1287
+
1288
+ // Wrap dot + label in a <g> for hover targeting
1289
+ const entryG = gEl.append('g')
1290
+ .attr('class', 'gantt-legend-entry')
1291
+ .style('cursor', 'pointer');
1292
+
1293
+ // Dot
1294
+ entryG.append('circle')
1295
+ .attr('cx', ex + LEGEND_DOT_R)
1296
+ .attr('cy', LEGEND_HEIGHT / 2)
1297
+ .attr('r', LEGEND_DOT_R)
1298
+ .attr('fill', entry.color);
1299
+
1300
+ // Label
1301
+ entryG.append('text')
1302
+ .attr('x', ex + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
1303
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 2)
1304
+ .attr('text-anchor', 'start')
1305
+ .attr('font-size', `${LEGEND_ENTRY_FONT_SIZE}px`)
1306
+ .attr('fill', palette.textMuted)
1307
+ .text(entry.value);
1308
+
1309
+ // Hover: highlight matching tasks + labels + lane headers, fade others
1310
+ entryG
1311
+ .on('mouseenter', () => {
1312
+ chartG.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
1313
+ const el = d3Selection.select(this);
1314
+ const matches = el.attr(`data-tag-${tagKey}`) === entryValue;
1315
+ el.attr('opacity', matches ? 1 : FADE_OPACITY);
1316
+ });
1317
+ chartG.selectAll<SVGElement, unknown>('.gantt-milestone').attr('opacity', FADE_OPACITY);
1318
+ chartG.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1319
+ // Fade left-side task labels
1320
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
1321
+ const el = d3Selection.select(this);
1322
+ const matches = el.attr(`data-tag-${tagKey}`) === entryValue;
1323
+ el.attr('opacity', matches ? 1 : FADE_OPACITY);
1324
+ });
1325
+ // Fade group labels
1326
+ svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
1327
+ // Fade non-matching lane headers + bands + accents
1328
+ svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').each(function () {
1329
+ const el = d3Selection.select(this);
1330
+ const matches = el.attr(`data-tag-${tagKey}`) === entryValue;
1331
+ el.attr('opacity', matches ? 1 : FADE_OPACITY);
1332
+ });
1333
+ chartG.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr('opacity', FADE_OPACITY);
1334
+ })
1335
+ .on('mouseleave', () => {
1336
+ if (criticalPathActive) {
1337
+ applyCriticalPathHighlight(svg, chartG);
1338
+ } else {
1339
+ resetHighlightAll(svg, chartG);
1340
+ }
1341
+ });
1342
+
1343
+ ex += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1344
+ }
1345
+ }
1346
+
1347
+ cursorX += groupW + LEGEND_GROUP_GAP;
1348
+ }
1349
+
1350
+ // Critical Path pill
1351
+ if (hasCriticalPath) {
1352
+ const cpG = legendRow.append('g')
1353
+ .attr('transform', `translate(${cursorX}, 0)`)
1354
+ .attr('class', 'gantt-legend-critical-path')
1355
+ .style('cursor', 'pointer')
1356
+ .on('click', () => { if (onToggleCriticalPath) onToggleCriticalPath(); });
1357
+
1358
+ cpG.append('rect')
1359
+ .attr('width', cpPillW)
1360
+ .attr('height', LEGEND_HEIGHT)
1361
+ .attr('rx', LEGEND_HEIGHT / 2)
1362
+ .attr('fill', criticalPathActive ? palette.bg : groupBg);
1363
+
1364
+ if (criticalPathActive) {
1365
+ cpG.append('rect')
1366
+ .attr('width', cpPillW)
1367
+ .attr('height', LEGEND_HEIGHT)
1368
+ .attr('rx', LEGEND_HEIGHT / 2)
1369
+ .attr('fill', 'none')
1370
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
1371
+ .attr('stroke-width', 0.75);
1372
+ }
1373
+
1374
+ cpG.append('text')
1375
+ .attr('x', cpPillW / 2)
1376
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
1377
+ .attr('text-anchor', 'middle')
1378
+ .attr('font-size', `${LEGEND_PILL_FONT_SIZE}px`)
1379
+ .attr('font-weight', '500')
1380
+ .attr('fill', criticalPathActive ? palette.text : palette.textMuted)
1381
+ .text(cpLabel);
1382
+
1383
+ // Apply persistent highlighting when active
1384
+ if (criticalPathActive) {
1385
+ applyCriticalPathHighlight(svg, chartG);
1386
+ }
1387
+
1388
+ cpG
1389
+ .on('mouseenter', () => {
1390
+ applyCriticalPathHighlight(svg, chartG);
1391
+ })
1392
+ .on('mouseleave', () => {
1393
+ if (!criticalPathActive) {
1394
+ resetHighlightAll(svg, chartG);
1395
+ }
1396
+ });
1397
+ }
1398
+ }
1399
+
1400
+ // ── Era & Marker Rendering ──────────────────────────────────
1401
+
1402
+ const ERA_COLORS = ['#5e81ac', '#a3be8c', '#ebcb8b', '#d08770', '#b48ead'];
1403
+
1404
+ function renderErasAndMarkers(
1405
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1406
+ resolved: ResolvedSchedule,
1407
+ xScale: d3Scale.ScaleLinear<number, number>,
1408
+ innerHeight: number,
1409
+ palette: PaletteColors,
1410
+ ): void {
1411
+ // Eras: semi-transparent background bands
1412
+ for (let i = 0; i < resolved.eras.length; i++) {
1413
+ const era = resolved.eras[i];
1414
+ const color = era.color || ERA_COLORS[i % ERA_COLORS.length];
1415
+ const sx = xScale(parseDateToFractionalYear(era.startDate));
1416
+ const ex = xScale(parseDateToFractionalYear(era.endDate));
1417
+ if (ex <= sx) continue;
1418
+
1419
+ const baseEraOpacity = 0.08;
1420
+ const hoverEraOpacity = 0.16;
1421
+ const eraStartDate = parseDateStringToDate(era.startDate);
1422
+ const eraEndDate = parseDateStringToDate(era.endDate);
1423
+
1424
+ const eraG = g.append('g')
1425
+ .attr('class', 'gantt-era-group');
1426
+
1427
+ const eraRect = eraG.append('rect')
1428
+ .attr('class', 'gantt-era')
1429
+ .attr('x', sx)
1430
+ .attr('y', 0)
1431
+ .attr('width', ex - sx)
1432
+ .attr('height', innerHeight)
1433
+ .attr('fill', color)
1434
+ .attr('opacity', baseEraOpacity);
1435
+
1436
+ // Era label (inside chart at top)
1437
+ eraG.append('text')
1438
+ .attr('class', 'gantt-era-label')
1439
+ .attr('x', (sx + ex) / 2)
1440
+ .attr('y', 12)
1441
+ .attr('text-anchor', 'middle')
1442
+ .attr('font-size', '10px')
1443
+ .attr('fill', color)
1444
+ .attr('opacity', 0.7)
1445
+ .attr('pointer-events', 'none')
1446
+ .text(era.label);
1447
+
1448
+ eraG
1449
+ .on('mouseenter', () => {
1450
+ eraRect.attr('opacity', hoverEraOpacity);
1451
+ showGanttDateIndicators(g, xScale, eraStartDate, eraEndDate, innerHeight, color);
1452
+ })
1453
+ .on('mouseleave', () => {
1454
+ eraRect.attr('opacity', baseEraOpacity);
1455
+ hideGanttDateIndicators(g);
1456
+ });
1457
+ }
1458
+
1459
+ // Markers: vertical dashed lines
1460
+ for (const marker of resolved.markers) {
1461
+ const color = marker.color || palette.accent || '#d08770';
1462
+ const mx = xScale(parseDateToFractionalYear(marker.date));
1463
+ const markerDate = parseDateStringToDate(marker.date);
1464
+
1465
+ const markerG = g.append('g')
1466
+ .attr('class', 'gantt-marker-group');
1467
+
1468
+ markerG.append('line')
1469
+ .attr('class', 'gantt-marker')
1470
+ .attr('x1', mx)
1471
+ .attr('y1', 0)
1472
+ .attr('x2', mx)
1473
+ .attr('y2', innerHeight)
1474
+ .attr('stroke', color)
1475
+ .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)
1483
+ .attr('opacity', 0.5);
1484
+
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
+
1496
+ markerG
1497
+ .on('mouseenter', () => {
1498
+ showGanttDateIndicators(g, xScale, markerDate, null, innerHeight, color);
1499
+ })
1500
+ .on('mouseleave', () => {
1501
+ hideGanttDateIndicators(g);
1502
+ });
1503
+ }
1504
+ }
1505
+
1506
+ /**
1507
+ * Parse a date string (YYYY, YYYY-MM, YYYY-MM-DD) to a Date object.
1508
+ * Used for eras and markers which store dates as strings.
1509
+ */
1510
+ function parseDateStringToDate(s: string): Date {
1511
+ const parts = s.split('-').map(p => parseInt(p, 10));
1512
+ const year = parts[0];
1513
+ const month = parts.length >= 2 ? parts[1] - 1 : 0;
1514
+ const day = parts.length >= 3 ? parts[2] : 1;
1515
+ return new Date(year, month, day);
1516
+ }
1517
+
1518
+ /**
1519
+ * Parse a date string (YYYY, YYYY-MM, YYYY-MM-DD) to fractional year.
1520
+ * Used for eras and markers which may have partial dates.
1521
+ */
1522
+ 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;
1528
+ }
1529
+
1530
+ // ── Dependency Hover Helpers ─────────────────────────────────
1531
+
1532
+ const FADE_OPACITY = 0.1;
1533
+
1534
+ function highlightDeps(
1535
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1536
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1537
+ taskId: string,
1538
+ resolved: ResolvedSchedule,
1539
+ ): void {
1540
+ // Find immediate predecessors and successors
1541
+ const related = new Set<string>([taskId]);
1542
+ const task = resolved.tasks.find(t => t.task.id === taskId);
1543
+ if (!task) return;
1544
+
1545
+ // Predecessors: tasks whose deps point to this task
1546
+ for (const rt of resolved.tasks) {
1547
+ for (const dep of rt.task.dependencies) {
1548
+ // Check if this dep points to our task
1549
+ if (dep.targetName === task.task.label ||
1550
+ `${task.groupPath.join('.')}.${task.task.label}`.endsWith(dep.targetName)) {
1551
+ related.add(rt.task.id);
1552
+ }
1553
+ }
1554
+ }
1555
+ // Successors: tasks this task has deps pointing to
1556
+ for (const dep of task.task.dependencies) {
1557
+ const target = resolved.tasks.find(t =>
1558
+ t.task.label === dep.targetName ||
1559
+ `${t.groupPath.join('.')}.${t.task.label}`.endsWith(dep.targetName));
1560
+ if (target) related.add(target.task.id);
1561
+ }
1562
+
1563
+ // Fade all tasks not in related set
1564
+ g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
1565
+ const el = d3Selection.select(this);
1566
+ const id = el.attr('data-task-id');
1567
+ el.attr('opacity', id && related.has(id) ? 1 : FADE_OPACITY);
1568
+ });
1569
+ g.selectAll<SVGGElement, unknown>('.gantt-milestone').attr('opacity', FADE_OPACITY);
1570
+ g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1571
+ svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
1572
+ svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
1573
+ g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr('opacity', FADE_OPACITY);
1574
+
1575
+ // Fade dependency arrows not connected to related tasks
1576
+ g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').each(function () {
1577
+ const el = d3Selection.select(this);
1578
+ const from = el.attr('data-dep-from');
1579
+ const to = el.attr('data-dep-to');
1580
+ const isRelated = (from && related.has(from)) || (to && related.has(to));
1581
+ el.attr('opacity', isRelated ? 0.5 : FADE_OPACITY);
1582
+ });
1583
+ }
1584
+
1585
+ function highlightGroup(
1586
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1587
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1588
+ groupName: string,
1589
+ ): void {
1590
+ // Fade tasks not in this group
1591
+ g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
1592
+ const el = d3Selection.select(this);
1593
+ el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
1594
+ });
1595
+ // Fade milestones not in this group
1596
+ g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
1597
+ const el = d3Selection.select(this);
1598
+ el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
1599
+ });
1600
+ // Fade other group bars
1601
+ g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').each(function () {
1602
+ const el = d3Selection.select(this);
1603
+ el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
1604
+ });
1605
+ // Fade other group labels
1606
+ svg.selectAll<SVGGElement, unknown>('.gantt-group-label').each(function () {
1607
+ const el = d3Selection.select(this);
1608
+ el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
1609
+ });
1610
+ // Fade task labels not in this group
1611
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
1612
+ const el = d3Selection.select(this);
1613
+ el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
1614
+ });
1615
+ // Fade lane elements
1616
+ svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
1617
+ g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr('opacity', FADE_OPACITY);
1618
+ }
1619
+
1620
+ function highlightLane(
1621
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1622
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1623
+ tagKey: string,
1624
+ laneName: string,
1625
+ ): void {
1626
+ const tagAttr = `data-tag-${tagKey}`;
1627
+ const laneValue = laneName.toLowerCase();
1628
+
1629
+ // Fade tasks not in this lane
1630
+ g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
1631
+ const el = d3Selection.select(this);
1632
+ el.attr('opacity', el.attr(tagAttr) === laneValue ? 1 : FADE_OPACITY);
1633
+ });
1634
+ // Fade milestones not in this lane
1635
+ g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
1636
+ const el = d3Selection.select(this);
1637
+ el.attr('opacity', el.attr(tagAttr) === laneValue ? 1 : FADE_OPACITY);
1638
+ });
1639
+ // Fade task labels not in this lane
1640
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
1641
+ const el = d3Selection.select(this);
1642
+ el.attr('opacity', el.attr(tagAttr) === laneValue ? 1 : FADE_OPACITY);
1643
+ });
1644
+ // Fade other lane headers
1645
+ svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').each(function () {
1646
+ const el = d3Selection.select(this);
1647
+ el.attr('opacity', el.attr('data-lane') === laneName ? 1 : FADE_OPACITY);
1648
+ });
1649
+ // Fade other lane band groups
1650
+ g.selectAll<SVGElement, unknown>('.gantt-lane-band-group').each(function () {
1651
+ const el = d3Selection.select(this);
1652
+ el.attr('opacity', el.attr('data-lane') === laneName ? 1 : FADE_OPACITY);
1653
+ });
1654
+ // Fade group elements (not relevant in lane mode)
1655
+ g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1656
+ svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
1657
+ }
1658
+
1659
+ function highlightTask(
1660
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1661
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1662
+ taskId: string,
1663
+ ): void {
1664
+ // Fade tasks not matching
1665
+ g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
1666
+ const el = d3Selection.select(this);
1667
+ el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
1668
+ });
1669
+ // Fade milestones not matching
1670
+ g.selectAll<SVGElement, unknown>('.gantt-milestone').attr('opacity', FADE_OPACITY);
1671
+ // Fade task labels not matching
1672
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
1673
+ const el = d3Selection.select(this);
1674
+ el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
1675
+ });
1676
+ // Fade group/lane elements
1677
+ g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1678
+ svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
1679
+ svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
1680
+ g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', FADE_OPACITY);
1681
+ g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', FADE_OPACITY);
1682
+ }
1683
+
1684
+ function highlightTaskLabel(
1685
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1686
+ lineNumber: number,
1687
+ ): void {
1688
+ const ln = String(lineNumber);
1689
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
1690
+ const el = d3Selection.select(this);
1691
+ el.attr('opacity', el.attr('data-line-number') === ln ? 1 : FADE_OPACITY);
1692
+ });
1693
+ }
1694
+
1695
+ function resetTaskLabels(
1696
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1697
+ ): void {
1698
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').attr('opacity', 1);
1699
+ }
1700
+
1701
+ function resetHighlight(
1702
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1703
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1704
+ ): void {
1705
+ g.selectAll<SVGGElement, unknown>('.gantt-task, .gantt-milestone').attr('opacity', 1);
1706
+ g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', 1);
1707
+ svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', 1);
1708
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').attr('opacity', 1);
1709
+ svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', 1);
1710
+ g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', 1);
1711
+ g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', 0.5);
1712
+ }
1713
+
1714
+ // ── Row Building ────────────────────────────────────────────
1715
+
1716
+ type GroupRow = { type: 'group'; group: ResolvedGroup };
1717
+ type TaskRow = { type: 'task'; task: ResolvedTask };
1718
+ type LaneHeaderRow = { type: 'lane-header'; laneName: string; laneColor: string; aggregateProgress: number | null; tagKey: string; isCollapsed: boolean; laneStartDate: Date | null; laneEndDate: Date | null };
1719
+ type Row = GroupRow | TaskRow | LaneHeaderRow;
1720
+
1721
+ // Public type aliases (prefixed to avoid collisions in consumer code)
1722
+ export type { GroupRow as GanttGroupRow, TaskRow as GanttTaskRow, LaneHeaderRow as GanttLaneHeaderRow, Row as GanttRow };
1723
+
1724
+ function buildRowList(resolved: ResolvedSchedule, collapsedGroups?: Set<string>): Row[] {
1725
+ const rows: Row[] = [];
1726
+ const groupMap = new Map<string, ResolvedGroup>();
1727
+ for (const g of resolved.groups) {
1728
+ groupMap.set(g.name, g);
1729
+ }
1730
+
1731
+ // Sort tasks by group order so tasks from the same group are contiguous.
1732
+ // resolved.groups is in parse-tree order; use that as the sort key.
1733
+ // Tasks with no group come first, then tasks grouped by their groupPath.
1734
+ const groupOrder = new Map<string, number>();
1735
+ resolved.groups.forEach((g, i) => groupOrder.set(g.name, i));
1736
+
1737
+ const sortedTasks = [...resolved.tasks].sort((a, b) => {
1738
+ const maxLen = Math.max(a.groupPath.length, b.groupPath.length);
1739
+ for (let i = 0; i < maxLen; i++) {
1740
+ const ga = a.groupPath[i];
1741
+ const gb = b.groupPath[i];
1742
+ if (ga === gb) continue;
1743
+ // Task with shorter path (no group at this level) comes first
1744
+ if (ga === undefined) return -1;
1745
+ if (gb === undefined) return 1;
1746
+ const oa = groupOrder.get(ga) ?? 0;
1747
+ const ob = groupOrder.get(gb) ?? 0;
1748
+ if (oa !== ob) return oa - ob;
1749
+ }
1750
+ return 0; // same group — preserve original (topo-sort) order
1751
+ });
1752
+
1753
+ // Build a flat display list from the resolved groups and tasks
1754
+ // Groups appear before their children. Collapsed groups hide children.
1755
+ const seenGroups = new Set<string>();
1756
+ for (const rt of sortedTasks) {
1757
+ // Check if any group in this task's path is collapsed
1758
+ const isHidden = rt.groupPath.some(g => collapsedGroups?.has(g));
1759
+ if (isHidden) {
1760
+ // Still insert collapsed group headers if not seen
1761
+ for (const groupName of rt.groupPath) {
1762
+ if (!seenGroups.has(groupName)) {
1763
+ seenGroups.add(groupName);
1764
+ const group = groupMap.get(groupName);
1765
+ if (group) {
1766
+ rows.push({ type: 'group', group });
1767
+ }
1768
+ }
1769
+ if (collapsedGroups?.has(groupName)) break; // stop at collapsed group
1770
+ }
1771
+ continue; // skip task row
1772
+ }
1773
+
1774
+ // Insert group rows for any groups in the path not yet seen
1775
+ for (let i = 0; i < rt.groupPath.length; i++) {
1776
+ const groupName = rt.groupPath[i];
1777
+ if (!seenGroups.has(groupName)) {
1778
+ seenGroups.add(groupName);
1779
+ const group = groupMap.get(groupName);
1780
+ if (group) {
1781
+ rows.push({ type: 'group', group });
1782
+ }
1783
+ }
1784
+ }
1785
+ rows.push({ type: 'task', task: rt });
1786
+ }
1787
+
1788
+ return rows;
1789
+ }
1790
+
1791
+ // ── Tag Lane Row Building ──────────────────────────────────
1792
+
1793
+ export function buildTagLaneRowList(
1794
+ resolved: ResolvedSchedule,
1795
+ swimlaneGroup: string,
1796
+ collapsedLanes?: Set<string>,
1797
+ ): Row[] | null {
1798
+ const tagGroup = resolved.tagGroups.find(
1799
+ g => g.name.toLowerCase() === swimlaneGroup.toLowerCase()
1800
+ );
1801
+ if (!tagGroup) return null;
1802
+
1803
+ const tagKey = tagGroup.name.toLowerCase();
1804
+ const rows: Row[] = [];
1805
+
1806
+ // Bucket tasks by tag value
1807
+ const buckets = new Map<string, ResolvedTask[]>();
1808
+ const unbucketed: ResolvedTask[] = [];
1809
+
1810
+ for (const rt of resolved.tasks) {
1811
+ let value = rt.effectiveMetadata[tagKey];
1812
+ if (!value && tagGroup.defaultValue) {
1813
+ value = tagGroup.defaultValue;
1814
+ }
1815
+ if (value) {
1816
+ const key = value.toLowerCase();
1817
+ if (!buckets.has(key)) buckets.set(key, []);
1818
+ buckets.get(key)!.push(rt);
1819
+ } else {
1820
+ unbucketed.push(rt);
1821
+ }
1822
+ }
1823
+
1824
+ // Emit lanes in tag entry declaration order
1825
+ for (const entry of tagGroup.entries) {
1826
+ const entryKey = entry.value.toLowerCase();
1827
+ const tasks = buckets.get(entryKey) ?? [];
1828
+ // Sort tasks within lane by start date
1829
+ tasks.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
1830
+
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;
1838
+
1839
+ // Compute lane date range from tasks
1840
+ const laneStartDate = tasks.length > 0 ? new Date(Math.min(...tasks.map(t => t.startDate.getTime()))) : null;
1841
+ const laneEndDate = tasks.length > 0 ? new Date(Math.max(...tasks.map(t => t.endDate.getTime()))) : null;
1842
+
1843
+ const isCollapsed = collapsedLanes?.has(entry.value) ?? false;
1844
+ rows.push({
1845
+ type: 'lane-header',
1846
+ laneName: entry.value,
1847
+ laneColor: entry.color,
1848
+ aggregateProgress,
1849
+ tagKey,
1850
+ isCollapsed,
1851
+ laneStartDate,
1852
+ laneEndDate,
1853
+ });
1854
+ if (!isCollapsed) {
1855
+ for (const rt of tasks) {
1856
+ rows.push({ type: 'task', task: rt });
1857
+ }
1858
+ }
1859
+ }
1860
+
1861
+ // Append unbucketed tasks as "No {GroupName}" lane
1862
+ if (unbucketed.length > 0) {
1863
+ 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;
1870
+
1871
+ const noLaneStartDate = unbucketed.length > 0 ? new Date(Math.min(...unbucketed.map(t => t.startDate.getTime()))) : null;
1872
+ const noLaneEndDate = unbucketed.length > 0 ? new Date(Math.max(...unbucketed.map(t => t.endDate.getTime()))) : null;
1873
+
1874
+ const noLaneName = `No ${tagGroup.name}`;
1875
+ const isCollapsed = collapsedLanes?.has(noLaneName) ?? false;
1876
+ rows.push({
1877
+ type: 'lane-header',
1878
+ laneName: noLaneName,
1879
+ laneColor: '#999999',
1880
+ aggregateProgress,
1881
+ tagKey,
1882
+ isCollapsed,
1883
+ laneStartDate: noLaneStartDate,
1884
+ laneEndDate: noLaneEndDate,
1885
+ });
1886
+ if (!isCollapsed) {
1887
+ for (const rt of unbucketed) {
1888
+ rows.push({ type: 'task', task: rt });
1889
+ }
1890
+ }
1891
+ }
1892
+
1893
+ return rows;
1894
+ }
1895
+
1896
+ // ── Helpers ─────────────────────────────────────────────────
1897
+
1898
+ function dateToFractionalYear(d: Date): number {
1899
+ const y = d.getFullYear();
1900
+ const startOfYear = new Date(y, 0, 1);
1901
+ const endOfYear = new Date(y + 1, 0, 1);
1902
+ const fraction = (d.getTime() - startOfYear.getTime()) / (endOfYear.getTime() - startOfYear.getTime());
1903
+ return y + fraction;
1904
+ }
1905
+
1906
+ function diamondPoints(cx: number, cy: number, size: number): string {
1907
+ const half = size / 2;
1908
+ return `${cx},${cy - half} ${cx + half},${cy} ${cx},${cy + half} ${cx - half},${cy}`;
1909
+ }
1910
+
1911
+ // ── Hover Date Indicators ───────────────────────────────────
1912
+
1913
+ const MONTH_ABBR = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1914
+
1915
+ function formatGanttDate(d: Date): string {
1916
+ return `${MONTH_ABBR[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
1917
+ }
1918
+
1919
+ function showGanttDateIndicators(
1920
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1921
+ xScale: d3Scale.ScaleLinear<number, number>,
1922
+ startDate: Date,
1923
+ endDate: Date | null,
1924
+ innerHeight: number,
1925
+ color: string,
1926
+ ): void {
1927
+ // Fade existing scale ticks and today marker
1928
+ g.selectAll('.gantt-scale-tick').attr('opacity', 0.05);
1929
+ g.selectAll('.gantt-today').attr('opacity', 0.05);
1930
+
1931
+ const tickLen = 6;
1932
+ const startPos = xScale(dateToFractionalYear(startDate));
1933
+ const startLabel = formatGanttDate(startDate);
1934
+
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);
1946
+
1947
+ // Start date — top label
1948
+ g.append('text')
1949
+ .attr('class', 'gantt-hover-date')
1950
+ .attr('x', startPos)
1951
+ .attr('y', -tickLen - 4)
1952
+ .attr('text-anchor', 'middle')
1953
+ .attr('fill', color)
1954
+ .attr('font-size', '10px')
1955
+ .attr('font-weight', '600')
1956
+ .text(startLabel);
1957
+
1958
+ // Start date — bottom label
1959
+ g.append('text')
1960
+ .attr('class', 'gantt-hover-date')
1961
+ .attr('x', startPos)
1962
+ .attr('y', innerHeight + tickLen + 12)
1963
+ .attr('text-anchor', 'middle')
1964
+ .attr('fill', color)
1965
+ .attr('font-size', '10px')
1966
+ .attr('font-weight', '600')
1967
+ .text(startLabel);
1968
+
1969
+ if (endDate && endDate.getTime() !== startDate.getTime()) {
1970
+ const endPos = xScale(dateToFractionalYear(endDate));
1971
+ const endLabel = formatGanttDate(endDate);
1972
+
1973
+ // End date — dashed vertical line
1974
+ g.append('line')
1975
+ .attr('class', 'gantt-hover-date')
1976
+ .attr('x1', endPos)
1977
+ .attr('y1', -tickLen)
1978
+ .attr('x2', endPos)
1979
+ .attr('y2', innerHeight)
1980
+ .attr('stroke', color)
1981
+ .attr('stroke-width', 1.5)
1982
+ .attr('stroke-dasharray', '4 4')
1983
+ .attr('opacity', 0.6);
1984
+
1985
+ // End date — top label
1986
+ g.append('text')
1987
+ .attr('class', 'gantt-hover-date')
1988
+ .attr('x', endPos)
1989
+ .attr('y', -tickLen - 4)
1990
+ .attr('text-anchor', 'middle')
1991
+ .attr('fill', color)
1992
+ .attr('font-size', '10px')
1993
+ .attr('font-weight', '600')
1994
+ .text(endLabel);
1995
+
1996
+ // End date — bottom label
1997
+ g.append('text')
1998
+ .attr('class', 'gantt-hover-date')
1999
+ .attr('x', endPos)
2000
+ .attr('y', innerHeight + tickLen + 12)
2001
+ .attr('text-anchor', 'middle')
2002
+ .attr('fill', color)
2003
+ .attr('font-size', '10px')
2004
+ .attr('font-weight', '600')
2005
+ .text(endLabel);
2006
+ }
2007
+ }
2008
+
2009
+ function hideGanttDateIndicators(
2010
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2011
+ ): void {
2012
+ g.selectAll('.gantt-hover-date').remove();
2013
+ // Restore scale tick opacity
2014
+ g.selectAll('.gantt-scale-tick').each(function () {
2015
+ const el = d3Selection.select(this);
2016
+ const isDashed = el.attr('stroke-dasharray');
2017
+ el.attr('opacity', isDashed ? 0.15 : 0.4);
2018
+ });
2019
+ // Restore today marker opacity
2020
+ g.selectAll('.gantt-today').attr('opacity', 0.7);
2021
+ }
2022
+
2023
+ function resolveTaskColor(
2024
+ rt: ResolvedTask,
2025
+ activeTagGroup: string | null,
2026
+ resolved: ResolvedSchedule,
2027
+ seriesColors: string[],
2028
+ palette: PaletteColors,
2029
+ ): string {
2030
+ // Try tag-based coloring first
2031
+ const tagColor = resolveTagColor(
2032
+ rt.effectiveMetadata,
2033
+ resolved.tagGroups,
2034
+ activeTagGroup,
2035
+ );
2036
+ if (tagColor && tagColor !== '#999999') return tagColor;
2037
+
2038
+ // Fall back to group-based coloring
2039
+ if (rt.groupPath.length > 0) {
2040
+ const topGroup = rt.groupPath[0];
2041
+ const groupIdx = resolved.groups.findIndex(g => g.name === topGroup);
2042
+ if (groupIdx >= 0) {
2043
+ const group = resolved.groups[groupIdx];
2044
+ if (group.color) return group.color;
2045
+ return seriesColors[groupIdx % seriesColors.length];
2046
+ }
2047
+ }
2048
+
2049
+ // Default
2050
+ return palette.accent || seriesColors[0] || '#4a90d9';
2051
+ }
2052
+
2053
+ function renderTimeScaleHorizontal(
2054
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2055
+ scale: d3Scale.ScaleLinear<number, number>,
2056
+ innerWidth: number,
2057
+ innerHeight: number,
2058
+ textColor: string,
2059
+ ): void {
2060
+ const [domainMin, domainMax] = scale.domain();
2061
+ const ticks = computeTimeTicks(domainMin, domainMax, scale);
2062
+ if (ticks.length < 2) return;
2063
+
2064
+ const tickLen = 6;
2065
+ const opacity = 0.4;
2066
+ const guideOpacity = 0.15;
2067
+
2068
+ for (const tick of ticks) {
2069
+ // Guide line
2070
+ g.append('line')
2071
+ .attr('class', 'gantt-scale-tick')
2072
+ .attr('x1', tick.pos)
2073
+ .attr('y1', 0)
2074
+ .attr('x2', tick.pos)
2075
+ .attr('y2', innerHeight)
2076
+ .attr('stroke', textColor)
2077
+ .attr('stroke-width', 1)
2078
+ .attr('stroke-dasharray', '4 4')
2079
+ .attr('opacity', guideOpacity);
2080
+
2081
+ // Top tick
2082
+ g.append('line')
2083
+ .attr('class', 'gantt-scale-tick')
2084
+ .attr('x1', tick.pos)
2085
+ .attr('y1', 0)
2086
+ .attr('x2', tick.pos)
2087
+ .attr('y2', -tickLen)
2088
+ .attr('stroke', textColor)
2089
+ .attr('stroke-width', 1)
2090
+ .attr('opacity', opacity);
2091
+
2092
+ // Top label
2093
+ g.append('text')
2094
+ .attr('class', 'gantt-scale-tick')
2095
+ .attr('x', tick.pos)
2096
+ .attr('y', -tickLen - 4)
2097
+ .attr('text-anchor', 'middle')
2098
+ .attr('dominant-baseline', 'auto')
2099
+ .attr('font-size', '10px')
2100
+ .attr('fill', textColor)
2101
+ .attr('opacity', opacity)
2102
+ .text(tick.label);
2103
+
2104
+ // Bottom tick
2105
+ g.append('line')
2106
+ .attr('class', 'gantt-scale-tick')
2107
+ .attr('x1', tick.pos)
2108
+ .attr('y1', innerHeight)
2109
+ .attr('x2', tick.pos)
2110
+ .attr('y2', innerHeight + tickLen)
2111
+ .attr('stroke', textColor)
2112
+ .attr('stroke-width', 1)
2113
+ .attr('opacity', opacity);
2114
+
2115
+ g.append('text')
2116
+ .attr('class', 'gantt-scale-tick')
2117
+ .attr('x', tick.pos)
2118
+ .attr('y', innerHeight + tickLen + 12)
2119
+ .attr('text-anchor', 'middle')
2120
+ .attr('font-size', '10px')
2121
+ .attr('fill', textColor)
2122
+ .attr('opacity', opacity)
2123
+ .text(tick.label);
2124
+ }
2125
+ }