@diagrammo/dgmo 0.6.3 → 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.
- package/dist/cli.cjs +180 -178
- package/dist/index.cjs +5296 -2209
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +236 -16
- package/dist/index.d.ts +236 -16
- package/dist/index.js +12423 -9343
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/c4/renderer.ts +6 -6
- package/src/class/renderer.ts +183 -7
- package/src/cli.ts +3 -11
- package/src/colors.ts +3 -3
- package/src/d3.ts +128 -23
- package/src/dgmo-router.ts +3 -1
- package/src/er/renderer.ts +11 -5
- package/src/gantt/calculator.ts +677 -0
- package/src/gantt/parser.ts +761 -0
- package/src/gantt/renderer.ts +2125 -0
- package/src/gantt/resolver.ts +144 -0
- package/src/gantt/types.ts +168 -0
- package/src/index.ts +27 -0
- package/src/infra/renderer.ts +48 -12
- package/src/initiative-status/filter.ts +63 -0
- package/src/initiative-status/layout.ts +319 -67
- package/src/initiative-status/parser.ts +200 -25
- package/src/initiative-status/renderer.ts +293 -10
- package/src/initiative-status/types.ts +6 -0
- package/src/org/layout.ts +22 -55
- package/src/org/renderer.ts +4 -8
- package/src/palettes/dracula.ts +60 -0
- package/src/palettes/index.ts +8 -6
- package/src/palettes/monokai.ts +60 -0
- package/src/palettes/registry.ts +4 -2
- package/src/sequence/renderer.ts +5 -4
- package/src/sharing.ts +8 -0
- package/src/sitemap/renderer.ts +4 -4
- package/src/utils/duration.ts +212 -0
- 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
|
+
}
|