@diagrammo/dgmo 0.6.3 → 0.7.1

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