@diagrammo/dgmo 0.47.0 → 0.48.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/auto.js +1 -1
- package/dist/auto.mjs +1 -1
- package/dist/element.js +1 -1
- package/dist/element.mjs +1 -1
- package/package.json +19 -70
- package/dist/advanced.cjs +0 -71648
- package/dist/advanced.d.cts +0 -5719
- package/dist/auto.cjs +0 -70648
- package/dist/auto.d.cts +0 -54
- package/dist/block.cjs +0 -70208
- package/dist/block.d.cts +0 -115
- package/dist/editor.cjs +0 -526
- package/dist/editor.d.cts +0 -26
- package/dist/element.cjs +0 -70530
- package/dist/element.d.cts +0 -62
- package/dist/highlight.cjs +0 -852
- package/dist/highlight.d.cts +0 -49
- package/dist/index.cjs +0 -70491
- package/dist/index.d.cts +0 -160
- package/dist/pert.cjs +0 -325
- package/dist/pert.d.cts +0 -559
- package/dist/themes-1-CuKpeH.d.cts +0 -908
- package/src/advanced.ts +0 -873
- package/src/arc/parser.ts +0 -15
- package/src/arc/renderer.ts +0 -633
- package/src/auto/index.ts +0 -621
- package/src/auto/shared.ts +0 -370
- package/src/auto/styles.ts +0 -73
- package/src/block/layout.ts +0 -198
- package/src/block/parser.ts +0 -427
- package/src/block/renderer.ts +0 -484
- package/src/block/types.ts +0 -67
- package/src/boxes-and-lines/collapse.ts +0 -96
- package/src/boxes-and-lines/focus.ts +0 -257
- package/src/boxes-and-lines/label-placement.ts +0 -339
- package/src/boxes-and-lines/layout-grouped.ts +0 -912
- package/src/boxes-and-lines/layout-layered.ts +0 -722
- package/src/boxes-and-lines/layout-search.ts +0 -1678
- package/src/boxes-and-lines/layout.ts +0 -398
- package/src/boxes-and-lines/parser.ts +0 -1181
- package/src/boxes-and-lines/renderer.ts +0 -1465
- package/src/boxes-and-lines/types.ts +0 -66
- package/src/c4/layout.ts +0 -2441
- package/src/c4/parser.ts +0 -1158
- package/src/c4/renderer.ts +0 -2008
- package/src/c4/types.ts +0 -89
- package/src/chart-type-registry.ts +0 -529
- package/src/chart-types.ts +0 -225
- package/src/chart.ts +0 -685
- package/src/charts-d3/bar.ts +0 -338
- package/src/charts-d3/chord.ts +0 -140
- package/src/charts-d3/function.ts +0 -147
- package/src/charts-d3/funnel.ts +0 -104
- package/src/charts-d3/heatmap.ts +0 -145
- package/src/charts-d3/index.ts +0 -337
- package/src/charts-d3/interactions.ts +0 -477
- package/src/charts-d3/line.ts +0 -344
- package/src/charts-d3/mount.ts +0 -151
- package/src/charts-d3/pie.ts +0 -117
- package/src/charts-d3/polar.ts +0 -89
- package/src/charts-d3/radar.ts +0 -0
- package/src/charts-d3/sankey.ts +0 -0
- package/src/charts-d3/scatter.ts +0 -185
- package/src/charts-d3/shared.ts +0 -208
- package/src/class/layout.ts +0 -326
- package/src/class/parser.ts +0 -635
- package/src/class/renderer.ts +0 -793
- package/src/class/types.ts +0 -58
- package/src/cli-banner.ts +0 -106
- package/src/cli.ts +0 -1080
- package/src/colors-diagnostics.ts +0 -35
- package/src/colors.ts +0 -379
- package/src/completion-types.ts +0 -27
- package/src/completion.ts +0 -2668
- package/src/cycle/layout.ts +0 -1026
- package/src/cycle/parser.ts +0 -387
- package/src/cycle/renderer.ts +0 -653
- package/src/cycle/types.ts +0 -101
- package/src/d3.ts +0 -1434
- package/src/data-chart-parser.ts +0 -1007
- package/src/dgmo-router.ts +0 -296
- package/src/diagnostics-registry.ts +0 -108
- package/src/diagnostics.ts +0 -0
- package/src/dimensions.ts +0 -20
- package/src/directives-registry.ts +0 -396
- package/src/editor/dgmo.grammar +0 -76
- package/src/editor/dgmo.grammar.d.ts +0 -2
- package/src/editor/dgmo.grammar.js +0 -18
- package/src/editor/dgmo.grammar.terms.d.ts +0 -5
- package/src/editor/dgmo.grammar.terms.js +0 -35
- package/src/editor/highlight-api.ts +0 -596
- package/src/editor/highlight.ts +0 -36
- package/src/editor/index.ts +0 -27
- package/src/editor/keywords.ts +0 -128
- package/src/editor/tokens.ts +0 -30
- package/src/element/index.ts +0 -432
- package/src/embed/css.ts +0 -312
- package/src/embed/escape.ts +0 -15
- package/src/embed/index.ts +0 -280
- package/src/er/classify.ts +0 -228
- package/src/er/layout.ts +0 -530
- package/src/er/parser.ts +0 -750
- package/src/er/renderer.ts +0 -725
- package/src/er/types.ts +0 -52
- package/src/error-card.ts +0 -269
- package/src/event-line/diagnostics.ts +0 -117
- package/src/event-line/parser.ts +0 -643
- package/src/event-line/renderer.ts +0 -1742
- package/src/event-line/types.ts +0 -74
- package/src/fonts.ts +0 -3
- package/src/gantt/calculator.ts +0 -979
- package/src/gantt/parser.ts +0 -1641
- package/src/gantt/renderer.ts +0 -3787
- package/src/gantt/resolver.ts +0 -172
- package/src/gantt/types.ts +0 -206
- package/src/graph/diagnostics.ts +0 -64
- package/src/graph/edge-spline.ts +0 -29
- package/src/graph/flowchart-parser.ts +0 -759
- package/src/graph/flowchart-renderer.ts +0 -817
- package/src/graph/layout.ts +0 -472
- package/src/graph/notes.ts +0 -21
- package/src/graph/state-collapse.ts +0 -78
- package/src/graph/state-parser.ts +0 -574
- package/src/graph/state-renderer.ts +0 -653
- package/src/graph/types.ts +0 -65
- package/src/index.ts +0 -261
- package/src/infra/compute.ts +0 -1385
- package/src/infra/layout.ts +0 -888
- package/src/infra/parser.ts +0 -1234
- package/src/infra/renderer.ts +0 -2534
- package/src/infra/roles.ts +0 -75
- package/src/infra/types.ts +0 -216
- package/src/infra/validation.ts +0 -201
- package/src/journey-map/layout.ts +0 -490
- package/src/journey-map/parser.ts +0 -557
- package/src/journey-map/renderer.ts +0 -1580
- package/src/journey-map/types.ts +0 -47
- package/src/kanban/mutations.ts +0 -175
- package/src/kanban/parser.ts +0 -509
- package/src/kanban/renderer.ts +0 -1449
- package/src/kanban/types.ts +0 -39
- package/src/label-layout.ts +0 -368
- package/src/map/colorize.ts +0 -54
- package/src/map/completion.ts +0 -316
- package/src/map/context-labels.ts +0 -609
- package/src/map/data/PROVENANCE.json +0 -1
- package/src/map/data/README.md +0 -40
- package/src/map/data/airport-collisions.json +0 -1
- package/src/map/data/airports.json +0 -1
- package/src/map/data/gazetteer.json +0 -1
- package/src/map/data/lakes.json +0 -1
- package/src/map/data/mountain-ranges.json +0 -1
- package/src/map/data/na-lakes.json +0 -1
- package/src/map/data/na-land.json +0 -1
- package/src/map/data/region-names.json +0 -1
- package/src/map/data/rivers.json +0 -1
- package/src/map/data/types.ts +0 -121
- package/src/map/data/us-states.json +0 -1
- package/src/map/data/water-bodies.json +0 -1
- package/src/map/data/world-coarse.json +0 -1
- package/src/map/data/world-detail.json +0 -1
- package/src/map/diagnostics.ts +0 -134
- package/src/map/dimensions.ts +0 -150
- package/src/map/geo-query.ts +0 -295
- package/src/map/geo.ts +0 -438
- package/src/map/invert.ts +0 -111
- package/src/map/layout.ts +0 -5069
- package/src/map/legend-band.ts +0 -127
- package/src/map/load-data.ts +0 -186
- package/src/map/parser.ts +0 -783
- package/src/map/renderer.ts +0 -1445
- package/src/map/resolved-types.ts +0 -160
- package/src/map/resolver.ts +0 -1146
- package/src/map/types.ts +0 -194
- package/src/mindmap/collapse.ts +0 -67
- package/src/mindmap/layout.ts +0 -649
- package/src/mindmap/parser.ts +0 -430
- package/src/mindmap/renderer.ts +0 -637
- package/src/mindmap/text-wrap.ts +0 -218
- package/src/mindmap/types.ts +0 -56
- package/src/org/collapse.ts +0 -151
- package/src/org/layout.ts +0 -1262
- package/src/org/parser.ts +0 -519
- package/src/org/renderer.ts +0 -866
- package/src/org/resolver.ts +0 -636
- package/src/palettes/atlas.ts +0 -77
- package/src/palettes/blueprint.ts +0 -73
- package/src/palettes/catppuccin.ts +0 -84
- package/src/palettes/color-utils.ts +0 -443
- package/src/palettes/index.ts +0 -67
- package/src/palettes/nord.ts +0 -67
- package/src/palettes/registry.ts +0 -114
- package/src/palettes/slate.ts +0 -73
- package/src/palettes/tidewater.ts +0 -73
- package/src/palettes/tokyo-night.ts +0 -86
- package/src/palettes/types.ts +0 -88
- package/src/pert/analyzer.ts +0 -1183
- package/src/pert/diagnostics.ts +0 -110
- package/src/pert/index.ts +0 -43
- package/src/pert/internal.ts +0 -252
- package/src/pert/layout.ts +0 -971
- package/src/pert/monte-carlo.ts +0 -446
- package/src/pert/parser.ts +0 -1606
- package/src/pert/renderer.ts +0 -4009
- package/src/pert/share-normalize.ts +0 -62
- package/src/pert/types.ts +0 -604
- package/src/pyramid/parser.ts +0 -185
- package/src/pyramid/renderer.ts +0 -768
- package/src/pyramid/types.ts +0 -28
- package/src/quadrant/parser.ts +0 -18
- package/src/quadrant/renderer.ts +0 -638
- package/src/raci/diagnostics.ts +0 -213
- package/src/raci/index.ts +0 -23
- package/src/raci/mutations.ts +0 -321
- package/src/raci/parser.ts +0 -699
- package/src/raci/renderer.ts +0 -1627
- package/src/raci/types.ts +0 -82
- package/src/raci/variants.ts +0 -320
- package/src/render.ts +0 -268
- package/src/ring/parser.ts +0 -212
- package/src/ring/renderer.ts +0 -490
- package/src/ring/types.ts +0 -27
- package/src/sequence/collapse.ts +0 -174
- package/src/sequence/parser.ts +0 -1596
- package/src/sequence/participant-inference.ts +0 -152
- package/src/sequence/renderer.ts +0 -3168
- package/src/sequence/tag-resolution.ts +0 -164
- package/src/sharing.ts +0 -211
- package/src/sitemap/collapse.ts +0 -100
- package/src/sitemap/layout.ts +0 -925
- package/src/sitemap/parser.ts +0 -755
- package/src/sitemap/renderer.ts +0 -828
- package/src/sitemap/types.ts +0 -42
- package/src/slope/parser.ts +0 -18
- package/src/slope/renderer.ts +0 -378
- package/src/swimlane/diagnostics.ts +0 -94
- package/src/swimlane/layout.ts +0 -597
- package/src/swimlane/parser.ts +0 -613
- package/src/swimlane/renderer.ts +0 -681
- package/src/swimlane/types.ts +0 -151
- package/src/tech-radar/interactive.ts +0 -1129
- package/src/tech-radar/layout.ts +0 -197
- package/src/tech-radar/parser.ts +0 -436
- package/src/tech-radar/renderer.ts +0 -1239
- package/src/tech-radar/shared.ts +0 -151
- package/src/tech-radar/types.ts +0 -86
- package/src/themes.ts +0 -22
- package/src/timeline/parser.ts +0 -472
- package/src/timeline/renderer.ts +0 -3024
- package/src/timeline/types.ts +0 -33
- package/src/timeline/viz-parser.ts +0 -21
- package/src/treemap/diagnostics.ts +0 -62
- package/src/treemap/layout-radial.ts +0 -233
- package/src/treemap/layout.ts +0 -189
- package/src/treemap/parser.ts +0 -463
- package/src/treemap/renderer-radial.ts +0 -438
- package/src/treemap/renderer.ts +0 -572
- package/src/treemap/treemap-shared.ts +0 -309
- package/src/treemap/types.ts +0 -60
- package/src/utils/arrow-markers.ts +0 -51
- package/src/utils/arrows-diagnostics.ts +0 -62
- package/src/utils/arrows.ts +0 -227
- package/src/utils/brand.ts +0 -61
- package/src/utils/card.ts +0 -183
- package/src/utils/collapse-engine/tree-edges.ts +0 -115
- package/src/utils/collapse-engine/tree.ts +0 -117
- package/src/utils/d3-helpers.ts +0 -219
- package/src/utils/d3-types.ts +0 -9
- package/src/utils/description-helpers.ts +0 -38
- package/src/utils/duration.ts +0 -312
- package/src/utils/export-container.ts +0 -41
- package/src/utils/expr-eval.ts +0 -27
- package/src/utils/fit-canvas.ts +0 -64
- package/src/utils/hover-styles.ts +0 -673
- package/src/utils/inline-markdown.ts +0 -96
- package/src/utils/legend-constants.ts +0 -48
- package/src/utils/legend-d3.ts +0 -618
- package/src/utils/legend-integration.ts +0 -107
- package/src/utils/legend-layout.ts +0 -776
- package/src/utils/legend-svg.ts +0 -214
- package/src/utils/legend-types.ts +0 -272
- package/src/utils/name-normalize.ts +0 -163
- package/src/utils/note-box/constants.ts +0 -25
- package/src/utils/note-box/index.ts +0 -11
- package/src/utils/note-box/metrics.ts +0 -90
- package/src/utils/note-box/svg.ts +0 -331
- package/src/utils/notes/bounds.ts +0 -30
- package/src/utils/notes/build.ts +0 -131
- package/src/utils/notes/index.ts +0 -18
- package/src/utils/notes/model.ts +0 -19
- package/src/utils/notes/parse.ts +0 -131
- package/src/utils/notes/place.ts +0 -177
- package/src/utils/notes/resolve.ts +0 -88
- package/src/utils/number-format.ts +0 -36
- package/src/utils/parsing.ts +0 -953
- package/src/utils/reserved-key-registry.ts +0 -239
- package/src/utils/safe-href.ts +0 -38
- package/src/utils/scaling.ts +0 -97
- package/src/utils/svg-embed.ts +0 -251
- package/src/utils/tag-groups.ts +0 -607
- package/src/utils/text-measure.ts +0 -122
- package/src/utils/time-ticks.ts +0 -253
- package/src/utils/title-constants.ts +0 -24
- package/src/utils/visual-conventions.ts +0 -61
- package/src/utils/wrapped-desc.ts +0 -87
- package/src/venn/parser.ts +0 -18
- package/src/venn/renderer.ts +0 -1047
- package/src/version-control/diagnostics.ts +0 -67
- package/src/version-control/parser.ts +0 -544
- package/src/version-control/renderer.ts +0 -724
- package/src/version-control/types.ts +0 -100
- package/src/visualizations/parse.ts +0 -1560
- package/src/visualizations/types.ts +0 -262
- package/src/wireframe/layout.ts +0 -479
- package/src/wireframe/parser.ts +0 -1114
- package/src/wireframe/renderer.ts +0 -1318
- package/src/wireframe/types.ts +0 -110
- package/src/wordcloud/parser.ts +0 -18
- package/src/wordcloud/renderer.ts +0 -357
package/src/gantt/renderer.ts
DELETED
|
@@ -1,3787 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// Gantt Chart Renderer
|
|
3
|
-
// ============================================================
|
|
4
|
-
|
|
5
|
-
import { tagAttrKey } from '../utils/tag-groups';
|
|
6
|
-
import * as d3Scale from 'd3-scale';
|
|
7
|
-
import * as d3Selection from 'd3-selection';
|
|
8
|
-
import { FONT_FAMILY } from '../fonts';
|
|
9
|
-
import { getSeriesColors } from '../palettes';
|
|
10
|
-
import { contrastText, mix, shapeFill } from '../palettes/color-utils';
|
|
11
|
-
import { normalizeName } from '../utils/name-normalize';
|
|
12
|
-
import { resolveTagColor, resolveActiveTagGroup } from '../utils/tag-groups';
|
|
13
|
-
import { ScaleContext } from '../utils/scaling';
|
|
14
|
-
import { computeTimeTicks, MONTH_ABBR } from '../utils/time-ticks';
|
|
15
|
-
import {
|
|
16
|
-
measureText,
|
|
17
|
-
truncateText,
|
|
18
|
-
CHAR_WIDTH_RATIO,
|
|
19
|
-
} from '../utils/text-measure';
|
|
20
|
-
import {
|
|
21
|
-
LEGEND_HEIGHT,
|
|
22
|
-
LEGEND_PILL_PAD,
|
|
23
|
-
LEGEND_PILL_FONT_SIZE,
|
|
24
|
-
LEGEND_CAPSULE_PAD,
|
|
25
|
-
LEGEND_DOT_R,
|
|
26
|
-
LEGEND_ENTRY_FONT_SIZE,
|
|
27
|
-
LEGEND_ENTRY_DOT_GAP,
|
|
28
|
-
LEGEND_ENTRY_TRAIL,
|
|
29
|
-
LEGEND_GROUP_GAP,
|
|
30
|
-
LEGEND_ICON_W,
|
|
31
|
-
LEGEND_GEAR_PILL_W,
|
|
32
|
-
measureLegendText,
|
|
33
|
-
truncateLegendText,
|
|
34
|
-
} from '../utils/legend-constants';
|
|
35
|
-
import { renderIntegratedLegend } from '../utils/legend-integration';
|
|
36
|
-
import {
|
|
37
|
-
controlsGroupCapsuleWidth,
|
|
38
|
-
getMaxLegendReservedHeight,
|
|
39
|
-
} from '../utils/legend-layout';
|
|
40
|
-
import type { LegendCallbacks } from '../utils/legend-types';
|
|
41
|
-
import {
|
|
42
|
-
TITLE_FONT_SIZE,
|
|
43
|
-
TITLE_FONT_WEIGHT,
|
|
44
|
-
TITLE_Y,
|
|
45
|
-
} from '../utils/title-constants';
|
|
46
|
-
import type { PaletteColors } from '../palettes';
|
|
47
|
-
import type { D3ExportDimensions } from '../utils/d3-types';
|
|
48
|
-
import type {
|
|
49
|
-
ResolvedSchedule,
|
|
50
|
-
ResolvedTask,
|
|
51
|
-
ResolvedGroup,
|
|
52
|
-
Weekday,
|
|
53
|
-
} from './types';
|
|
54
|
-
import type { TagGroup, TagEntry } from '../utils/tag-groups';
|
|
55
|
-
|
|
56
|
-
// ── Constants ───────────────────────────────────────────────
|
|
57
|
-
|
|
58
|
-
const BAR_H = 22;
|
|
59
|
-
const ROW_GAP = 6;
|
|
60
|
-
const GROUP_GAP = 14;
|
|
61
|
-
const MILESTONE_SIZE = 10;
|
|
62
|
-
const MIN_LEFT_MARGIN = 120;
|
|
63
|
-
const BOTTOM_MARGIN = 40;
|
|
64
|
-
const RIGHT_MARGIN = 20;
|
|
65
|
-
const LABEL_PAD = 8; // inner padding to decide if label fits inside bar
|
|
66
|
-
const LABEL_GAP = 5; // gap between bar edge and external label
|
|
67
|
-
|
|
68
|
-
// ── Bar label placement ─────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
type BarLabelPlacement = {
|
|
71
|
-
x: number;
|
|
72
|
-
anchor: 'start' | 'end';
|
|
73
|
-
fill: string;
|
|
74
|
-
text: string;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
function computeBarLabel(
|
|
78
|
-
label: string,
|
|
79
|
-
x1: number,
|
|
80
|
-
barWidth: number,
|
|
81
|
-
innerWidth: number,
|
|
82
|
-
textColor: string,
|
|
83
|
-
onFillColor?: string,
|
|
84
|
-
fontSize = 10,
|
|
85
|
-
labelPad = LABEL_PAD,
|
|
86
|
-
labelGap = LABEL_GAP
|
|
87
|
-
): BarLabelPlacement | null {
|
|
88
|
-
const textWidth = measureText(label, fontSize);
|
|
89
|
-
const x2 = x1 + barWidth;
|
|
90
|
-
|
|
91
|
-
if (textWidth < barWidth - labelPad) {
|
|
92
|
-
return {
|
|
93
|
-
x: x1 + 6,
|
|
94
|
-
anchor: 'start',
|
|
95
|
-
fill: onFillColor ?? textColor,
|
|
96
|
-
text: label,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (x2 + labelGap + textWidth <= innerWidth) {
|
|
101
|
-
return { x: x2 + labelGap, anchor: 'start', fill: textColor, text: label };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (x1 - labelGap - textWidth >= 0) {
|
|
105
|
-
return { x: x1 - labelGap, anchor: 'end', fill: textColor, text: label };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const availWidth = x1 - labelGap;
|
|
109
|
-
// Roughly three average glyphs' worth of room before truncation is worthwhile.
|
|
110
|
-
if (availWidth > fontSize * CHAR_WIDTH_RATIO * 3) {
|
|
111
|
-
const truncated = truncateText(label, fontSize, availWidth);
|
|
112
|
-
if (!truncated) return null;
|
|
113
|
-
return {
|
|
114
|
-
x: x1 - labelGap,
|
|
115
|
-
anchor: 'end',
|
|
116
|
-
fill: textColor,
|
|
117
|
-
text: truncated,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ── Left-panel visual helpers ───────────────────────────────
|
|
125
|
-
|
|
126
|
-
const BAND_ACCENT_W = 4;
|
|
127
|
-
const BAND_RADIUS = 4;
|
|
128
|
-
let bandClipCounter = 0;
|
|
129
|
-
|
|
130
|
-
function renderLabelBand(
|
|
131
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
132
|
-
y: number,
|
|
133
|
-
leftMargin: number,
|
|
134
|
-
color: string,
|
|
135
|
-
palette: PaletteColors,
|
|
136
|
-
isDark: boolean,
|
|
137
|
-
cssPrefix: 'group' | 'lane',
|
|
138
|
-
dataAttr?: { key: string; value: string },
|
|
139
|
-
solid?: boolean,
|
|
140
|
-
barH = BAR_H,
|
|
141
|
-
bandRadius = BAND_RADIUS,
|
|
142
|
-
bandAccentW = BAND_ACCENT_W
|
|
143
|
-
): void {
|
|
144
|
-
const bandX = 5;
|
|
145
|
-
const bandW = leftMargin - 7;
|
|
146
|
-
const bandY = y - barH / 2;
|
|
147
|
-
const clipId = `gantt-band-clip-${bandClipCounter++}`;
|
|
148
|
-
|
|
149
|
-
svg
|
|
150
|
-
.append('clipPath')
|
|
151
|
-
.attr('id', clipId)
|
|
152
|
-
.append('rect')
|
|
153
|
-
.attr('x', bandX)
|
|
154
|
-
.attr('y', bandY)
|
|
155
|
-
.attr('width', bandW)
|
|
156
|
-
.attr('height', barH)
|
|
157
|
-
.attr('rx', bandRadius);
|
|
158
|
-
|
|
159
|
-
const tint = svg
|
|
160
|
-
.append('rect')
|
|
161
|
-
.attr('class', `gantt-${cssPrefix}-band-bg`)
|
|
162
|
-
.attr('x', bandX)
|
|
163
|
-
.attr('y', bandY)
|
|
164
|
-
.attr('width', bandW)
|
|
165
|
-
.attr('height', barH)
|
|
166
|
-
.attr('rx', bandRadius)
|
|
167
|
-
.attr(
|
|
168
|
-
'fill',
|
|
169
|
-
shapeFill(palette, color, isDark, {
|
|
170
|
-
...(solid !== undefined && { solid }),
|
|
171
|
-
})
|
|
172
|
-
)
|
|
173
|
-
.style('pointer-events', 'none');
|
|
174
|
-
|
|
175
|
-
const accent = svg
|
|
176
|
-
.append('rect')
|
|
177
|
-
.attr('class', `gantt-${cssPrefix}-band-accent`)
|
|
178
|
-
.attr('x', bandX)
|
|
179
|
-
.attr('y', bandY)
|
|
180
|
-
.attr('width', bandAccentW)
|
|
181
|
-
.attr('height', barH)
|
|
182
|
-
.attr('fill', color)
|
|
183
|
-
.attr('clip-path', `url(#${clipId})`)
|
|
184
|
-
.style('pointer-events', 'none');
|
|
185
|
-
|
|
186
|
-
if (dataAttr) {
|
|
187
|
-
tint.attr(dataAttr.key, dataAttr.value);
|
|
188
|
-
accent.attr(dataAttr.key, dataAttr.value);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function appendTaskIcon(
|
|
193
|
-
textEl: d3Selection.Selection<SVGTextElement, unknown, null, undefined>,
|
|
194
|
-
label: string,
|
|
195
|
-
isMilestone: boolean,
|
|
196
|
-
iconColor: string,
|
|
197
|
-
textColor: string
|
|
198
|
-
): void {
|
|
199
|
-
const icon = isMilestone ? '◆' : '●';
|
|
200
|
-
textEl.append('tspan').attr('fill', iconColor).text(icon);
|
|
201
|
-
textEl
|
|
202
|
-
.append('tspan')
|
|
203
|
-
.attr('fill', textColor)
|
|
204
|
-
.text(' ' + label);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ── Interactive Options ─────────────────────────────────────
|
|
208
|
-
|
|
209
|
-
export interface GanttInteractiveOptions {
|
|
210
|
-
onClickItem?: (lineNumber: number) => void;
|
|
211
|
-
collapsedGroups?: Set<string>;
|
|
212
|
-
onToggleGroup?: (groupName: string) => void;
|
|
213
|
-
currentSwimlaneGroup?: string | null;
|
|
214
|
-
onSwimlaneChange?: (group: string | null) => void;
|
|
215
|
-
currentActiveGroup?: string | null;
|
|
216
|
-
onActiveGroupChange?: (group: string | null) => void;
|
|
217
|
-
collapsedLanes?: Set<string>;
|
|
218
|
-
onToggleLane?: (laneName: string) => void;
|
|
219
|
-
viewMode?: boolean;
|
|
220
|
-
exportMode?: boolean;
|
|
221
|
-
/**
|
|
222
|
-
* Where the Critical Path / Dependencies controls are hosted. Default
|
|
223
|
-
* `'inline'` draws the gear pill inside the SVG legend. `'app'` suppresses
|
|
224
|
-
* the inline gear so the desktop app can surface the same toggles in its
|
|
225
|
-
* unified ControlsStrip ("settings pull-down tab"); the toggle state is then
|
|
226
|
-
* driven by `criticalPathActive`/`dependenciesActive` below (re-render on
|
|
227
|
-
* change), matching every other ControlsStrip-hosted chart type.
|
|
228
|
-
*/
|
|
229
|
-
controlsHost?: 'app' | 'inline';
|
|
230
|
-
/** Initial Critical Path highlight state (used when `controlsHost` is `'app'`). */
|
|
231
|
-
criticalPathActive?: boolean;
|
|
232
|
-
/** Initial Dependencies-arrow visibility (used when `controlsHost` is `'app'`). */
|
|
233
|
-
dependenciesActive?: boolean;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// ── Main Renderer ───────────────────────────────────────────
|
|
237
|
-
|
|
238
|
-
export function renderGantt(
|
|
239
|
-
container: HTMLDivElement,
|
|
240
|
-
resolved: ResolvedSchedule,
|
|
241
|
-
palette: PaletteColors,
|
|
242
|
-
isDark: boolean,
|
|
243
|
-
options?: GanttInteractiveOptions,
|
|
244
|
-
exportDims?: D3ExportDimensions
|
|
245
|
-
): void {
|
|
246
|
-
// Clear previous content
|
|
247
|
-
container.innerHTML = '';
|
|
248
|
-
bandClipCounter = 0;
|
|
249
|
-
|
|
250
|
-
if (resolved.tasks.length === 0) return;
|
|
251
|
-
// Gantt INTENTIONALLY ignores `solid-fill` — the partial-tint vs full-saturation
|
|
252
|
-
// split inside each bar IS the progress visualization (e.g. "Blockade 72%" =
|
|
253
|
-
// 72% saturated + 28% lighter). Solid mode would destroy that signal. Hardcode
|
|
254
|
-
// false so all `shapeFill()` calls in this renderer keep using the 25% tint.
|
|
255
|
-
const solid = false;
|
|
256
|
-
|
|
257
|
-
// ── Destructure options ─────────────────────────────────
|
|
258
|
-
|
|
259
|
-
const onClickItem = options?.onClickItem;
|
|
260
|
-
// Fall back to the groups declared collapsed in source (`[Group] collapsed:
|
|
261
|
-
// true`) when no interactive set is supplied — so a bare render honors the
|
|
262
|
-
// source marker. Interactive callers (the app) pass their own set, seeded
|
|
263
|
-
// from source and expandable.
|
|
264
|
-
const collapsedGroups =
|
|
265
|
-
options?.collapsedGroups ??
|
|
266
|
-
new Set(resolved.groups.filter((g) => g.collapsed).map((g) => g.name));
|
|
267
|
-
const onToggleGroup = options?.onToggleGroup;
|
|
268
|
-
const viewMode = options?.viewMode ?? false;
|
|
269
|
-
const currentSwimlaneGroup = options?.currentSwimlaneGroup ?? null;
|
|
270
|
-
const onSwimlaneChange = options?.onSwimlaneChange;
|
|
271
|
-
const onActiveGroupChange = options?.onActiveGroupChange;
|
|
272
|
-
const collapsedLanes = options?.collapsedLanes;
|
|
273
|
-
const onToggleLane = options?.onToggleLane;
|
|
274
|
-
const controlsHost = options?.controlsHost ?? 'inline';
|
|
275
|
-
|
|
276
|
-
// ── Compute layout dimensions ───────────────────────────
|
|
277
|
-
|
|
278
|
-
const seriesColors = getSeriesColors(palette);
|
|
279
|
-
let currentActiveGroup: string | null = resolveActiveTagGroup(
|
|
280
|
-
resolved.tagGroups,
|
|
281
|
-
resolved.options.activeTag ?? undefined,
|
|
282
|
-
options?.currentActiveGroup
|
|
283
|
-
);
|
|
284
|
-
// Seeded from options so app-hosted controls (controlsHost: 'app') render the
|
|
285
|
-
// correct state on the very first draw; in inline mode these stay false until
|
|
286
|
-
// the user clicks the gear (existing behavior).
|
|
287
|
-
let criticalPathActive = options?.criticalPathActive ?? false;
|
|
288
|
-
let dependenciesActive = options?.dependenciesActive ?? false;
|
|
289
|
-
let controlsExpanded = false;
|
|
290
|
-
|
|
291
|
-
// ── Build row list (structural vs tag mode) ─────────────
|
|
292
|
-
|
|
293
|
-
const tagRows = currentSwimlaneGroup
|
|
294
|
-
? buildTagLaneRowList(resolved, currentSwimlaneGroup, collapsedLanes)
|
|
295
|
-
: null;
|
|
296
|
-
const rows = tagRows ?? buildRowList(resolved, collapsedGroups);
|
|
297
|
-
const isTagMode = tagRows !== null;
|
|
298
|
-
|
|
299
|
-
// Compute left margin based on longest visible label (include ● /◆ prefix
|
|
300
|
-
// for tasks). Labels render at font 11; group labels carry a pixel indent.
|
|
301
|
-
const LABEL_FONT = 11;
|
|
302
|
-
const labelWidths = isTagMode
|
|
303
|
-
? [
|
|
304
|
-
...rows
|
|
305
|
-
.filter((r): r is LaneHeaderRow => r.type === 'lane-header')
|
|
306
|
-
.map((r) => measureText(r.laneName, LABEL_FONT)),
|
|
307
|
-
...rows
|
|
308
|
-
.filter((r): r is TaskRow => r.type === 'task')
|
|
309
|
-
.map((r) => measureText('● ' + r.task.task.label, LABEL_FONT)),
|
|
310
|
-
]
|
|
311
|
-
: [
|
|
312
|
-
...resolved.tasks.map((t) =>
|
|
313
|
-
measureText('● ' + t.task.label, LABEL_FONT)
|
|
314
|
-
),
|
|
315
|
-
...resolved.groups.map((g) => {
|
|
316
|
-
const indentPx =
|
|
317
|
-
g.depth <= 2 ? g.depth * 14 : 2 * 14 + (g.depth - 2) * 8;
|
|
318
|
-
return indentPx + measureText(g.name, LABEL_FONT);
|
|
319
|
-
}),
|
|
320
|
-
];
|
|
321
|
-
// Floor on a 10-char label so very short schedules still get a usable gutter.
|
|
322
|
-
const maxLabelWidth = Math.max(
|
|
323
|
-
...labelWidths,
|
|
324
|
-
measureText('0000000000', LABEL_FONT)
|
|
325
|
-
);
|
|
326
|
-
const leftMargin = Math.max(MIN_LEFT_MARGIN, maxLabelWidth + 30);
|
|
327
|
-
|
|
328
|
-
const totalRows = rows.length;
|
|
329
|
-
|
|
330
|
-
// Pre-compute critical path / dependency flags (needed for legend height reservation)
|
|
331
|
-
const hasCriticalPath =
|
|
332
|
-
resolved.options.criticalPath &&
|
|
333
|
-
resolved.tasks.some((t) => t.isCriticalPath);
|
|
334
|
-
const hasDependencies =
|
|
335
|
-
resolved.options.dependencies &&
|
|
336
|
-
resolved.tasks.some((t) => t.task.dependencies.length > 0);
|
|
337
|
-
|
|
338
|
-
// Resolve today-marker date early so we can decide whether to reserve a row.
|
|
339
|
-
// Each label-bearing element gets its own horizontal row, reserved only when
|
|
340
|
-
// present in the DGMO. Stack (top → bottom): title, legend, eras, markers,
|
|
341
|
-
// today, dates. Within-row collisions (long labels on adjacent items) are a
|
|
342
|
-
// separate concern.
|
|
343
|
-
let resolvedTodayDate: Date | null = null;
|
|
344
|
-
if (resolved.options.todayMarker !== 'off') {
|
|
345
|
-
const d =
|
|
346
|
-
resolved.options.todayMarker === 'on'
|
|
347
|
-
? new Date()
|
|
348
|
-
: new Date(resolved.options.todayMarker + 'T00:00:00');
|
|
349
|
-
if (d >= resolved.startDate && d <= resolved.endDate) {
|
|
350
|
-
resolvedTodayDate = d;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Vertical layout — matches timeline pattern (d3.ts:3649-3655)
|
|
355
|
-
const title = resolved.options.title;
|
|
356
|
-
const showTitle = !!title && !resolved.options.noTitle;
|
|
357
|
-
const titleHeight = showTitle ? 50 : 20;
|
|
358
|
-
const containerWidth = exportDims?.width ?? (container.clientWidth || 800);
|
|
359
|
-
const hasTagLegend =
|
|
360
|
-
resolved.tagGroups.length > 0 || hasCriticalPath || hasDependencies;
|
|
361
|
-
const tagLegendReserve = hasTagLegend
|
|
362
|
-
? getMaxLegendReservedHeight(
|
|
363
|
-
{
|
|
364
|
-
groups: resolved.tagGroups.map((tg) => ({
|
|
365
|
-
name: tg.name,
|
|
366
|
-
entries: tg.entries,
|
|
367
|
-
})),
|
|
368
|
-
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
369
|
-
mode: 'preview',
|
|
370
|
-
capsulePillAddonWidth: LEGEND_ICON_W,
|
|
371
|
-
},
|
|
372
|
-
containerWidth
|
|
373
|
-
) + 8
|
|
374
|
-
: 0;
|
|
375
|
-
const topDateLabelReserve = 22; // tick (6) + gap (4) + label height (~12)
|
|
376
|
-
const HEADER_ROW_H = 18; // height per reserved label row
|
|
377
|
-
const eraReserve = resolved.eras.length > 0 ? HEADER_ROW_H : 0;
|
|
378
|
-
const markerReserve = resolved.markers.length > 0 ? HEADER_ROW_H : 0;
|
|
379
|
-
const todayReserve = resolvedTodayDate ? HEADER_ROW_H : 0;
|
|
380
|
-
const sprintLabelReserve = resolved.sprints.length > 0 ? 16 : 0; // sprint hover label above date labels
|
|
381
|
-
const CONTENT_TOP_PAD = 16; // breathing room between scale labels and first row
|
|
382
|
-
|
|
383
|
-
const idealWidth = leftMargin + totalRows * 20 + RIGHT_MARGIN + 200;
|
|
384
|
-
const ctx = exportDims
|
|
385
|
-
? ScaleContext.identity()
|
|
386
|
-
: ScaleContext.from(containerWidth, idealWidth);
|
|
387
|
-
|
|
388
|
-
const sBarH = ctx.structural(BAR_H);
|
|
389
|
-
const sRowGap = ctx.structural(ROW_GAP);
|
|
390
|
-
const sGroupGap = ctx.structural(GROUP_GAP);
|
|
391
|
-
const sMilestoneSize = ctx.structural(MILESTONE_SIZE);
|
|
392
|
-
const sBottomMargin = ctx.aesthetic(BOTTOM_MARGIN);
|
|
393
|
-
const sRightMargin = ctx.aesthetic(RIGHT_MARGIN);
|
|
394
|
-
const sLabelPad = ctx.structural(LABEL_PAD);
|
|
395
|
-
const sLabelGap = ctx.structural(LABEL_GAP);
|
|
396
|
-
const sBandAccentW = ctx.structural(BAND_ACCENT_W);
|
|
397
|
-
const sBandRadius = ctx.structural(BAND_RADIUS);
|
|
398
|
-
const sTitleFontSize = ctx.text(TITLE_FONT_SIZE);
|
|
399
|
-
const sTitleY = ctx.aesthetic(TITLE_Y);
|
|
400
|
-
const sHeaderRowH = ctx.structural(HEADER_ROW_H);
|
|
401
|
-
const sContentTopPad = ctx.aesthetic(CONTENT_TOP_PAD);
|
|
402
|
-
const sTopDateLabelReserve = ctx.structural(topDateLabelReserve);
|
|
403
|
-
const sSprintLabelReserve = ctx.structural(sprintLabelReserve);
|
|
404
|
-
const sTitleHeight = ctx.aesthetic(titleHeight);
|
|
405
|
-
const sTagLegendReserve = ctx.structural(tagLegendReserve);
|
|
406
|
-
const sEraReserve = ctx.structural(eraReserve);
|
|
407
|
-
const sMarkerReserve = ctx.structural(markerReserve);
|
|
408
|
-
const sTodayReserve = ctx.structural(todayReserve);
|
|
409
|
-
const sFont10 = ctx.text(10);
|
|
410
|
-
const sFont11 = ctx.text(11);
|
|
411
|
-
|
|
412
|
-
const sMarginTop =
|
|
413
|
-
sTitleHeight +
|
|
414
|
-
sTagLegendReserve +
|
|
415
|
-
sEraReserve +
|
|
416
|
-
sMarkerReserve +
|
|
417
|
-
sTodayReserve +
|
|
418
|
-
sTopDateLabelReserve +
|
|
419
|
-
sSprintLabelReserve;
|
|
420
|
-
|
|
421
|
-
const sTodayLabelY = sTodayReserve
|
|
422
|
-
? -(sTopDateLabelReserve + sHeaderRowH / 2)
|
|
423
|
-
: 0;
|
|
424
|
-
const sMarkerLabelY = sMarkerReserve
|
|
425
|
-
? -(sTopDateLabelReserve + sTodayReserve + sHeaderRowH / 2)
|
|
426
|
-
: 0;
|
|
427
|
-
const sEraLabelY = sEraReserve
|
|
428
|
-
? -(sTopDateLabelReserve + sTodayReserve + sMarkerReserve + sHeaderRowH / 2)
|
|
429
|
-
: 0;
|
|
430
|
-
|
|
431
|
-
const sContentH = isTagMode
|
|
432
|
-
? totalRows * (sBarH + sRowGap)
|
|
433
|
-
: totalRows * (sBarH + sRowGap) + sGroupGap * resolved.groups.length;
|
|
434
|
-
const sInnerHeight = sContentTopPad + sContentH;
|
|
435
|
-
const sOuterHeight = sMarginTop + sInnerHeight + sBottomMargin;
|
|
436
|
-
|
|
437
|
-
// Extra right margin when sprints present so hover date labels aren't clipped
|
|
438
|
-
const sprintRightPad = resolved.sprints.length > 0 ? 50 : 0;
|
|
439
|
-
const innerWidth =
|
|
440
|
-
containerWidth - leftMargin - sRightMargin - sprintRightPad;
|
|
441
|
-
|
|
442
|
-
// ── Create SVG ──────────────────────────────────────────
|
|
443
|
-
|
|
444
|
-
const svg = d3Selection
|
|
445
|
-
.select(container)
|
|
446
|
-
.append('svg')
|
|
447
|
-
.attr('viewBox', `0 0 ${containerWidth} ${sOuterHeight}`)
|
|
448
|
-
.attr('width', exportDims ? containerWidth : '100%')
|
|
449
|
-
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
450
|
-
.attr('font-family', FONT_FAMILY)
|
|
451
|
-
.style('overflow', 'visible');
|
|
452
|
-
|
|
453
|
-
const g = svg
|
|
454
|
-
.append('g')
|
|
455
|
-
.attr('transform', `translate(${leftMargin}, ${sMarginTop})`);
|
|
456
|
-
|
|
457
|
-
if (showTitle) {
|
|
458
|
-
svg
|
|
459
|
-
.append('text')
|
|
460
|
-
.attr('class', 'chart-title')
|
|
461
|
-
.attr('x', containerWidth / 2)
|
|
462
|
-
.attr('y', sTitleY)
|
|
463
|
-
.attr('text-anchor', 'middle')
|
|
464
|
-
.attr('font-size', sTitleFontSize)
|
|
465
|
-
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
466
|
-
.attr('fill', palette.text)
|
|
467
|
-
.text(title);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// ── Tag legend (interactive) ────────────────────────────
|
|
471
|
-
|
|
472
|
-
function drawLegend() {
|
|
473
|
-
svg.selectAll('.gantt-tag-legend-container').remove();
|
|
474
|
-
if (resolved.tagGroups.length > 0 || hasCriticalPath || hasDependencies) {
|
|
475
|
-
const legendY = sTitleHeight;
|
|
476
|
-
renderTagLegend(
|
|
477
|
-
svg,
|
|
478
|
-
g,
|
|
479
|
-
resolved.tagGroups,
|
|
480
|
-
currentActiveGroup,
|
|
481
|
-
leftMargin,
|
|
482
|
-
innerWidth,
|
|
483
|
-
legendY,
|
|
484
|
-
palette,
|
|
485
|
-
isDark,
|
|
486
|
-
hasCriticalPath,
|
|
487
|
-
criticalPathActive,
|
|
488
|
-
resolved.options.optionLineNumbers,
|
|
489
|
-
(groupName) => {
|
|
490
|
-
// Toggle active group
|
|
491
|
-
currentActiveGroup =
|
|
492
|
-
currentActiveGroup?.toLowerCase() === groupName.toLowerCase()
|
|
493
|
-
? null
|
|
494
|
-
: groupName;
|
|
495
|
-
if (onActiveGroupChange) onActiveGroupChange(currentActiveGroup);
|
|
496
|
-
drawLegend();
|
|
497
|
-
recolorBars();
|
|
498
|
-
},
|
|
499
|
-
() => {
|
|
500
|
-
controlsExpanded = !controlsExpanded;
|
|
501
|
-
drawLegend();
|
|
502
|
-
},
|
|
503
|
-
currentSwimlaneGroup,
|
|
504
|
-
onSwimlaneChange,
|
|
505
|
-
viewMode,
|
|
506
|
-
resolved.tasks,
|
|
507
|
-
controlsExpanded,
|
|
508
|
-
hasDependencies,
|
|
509
|
-
dependenciesActive,
|
|
510
|
-
(toggleId, active) => {
|
|
511
|
-
if (toggleId === 'critical-path') {
|
|
512
|
-
criticalPathActive = active;
|
|
513
|
-
if (active) {
|
|
514
|
-
applyCriticalPathHighlight(svg, g);
|
|
515
|
-
} else {
|
|
516
|
-
svg.attr('data-critical-path-active', null);
|
|
517
|
-
resetHighlightAll(svg, g);
|
|
518
|
-
}
|
|
519
|
-
} else if (toggleId === 'dependencies') {
|
|
520
|
-
dependenciesActive = active;
|
|
521
|
-
// Show/hide dependency arrows
|
|
522
|
-
g.selectAll<SVGElement, unknown>(
|
|
523
|
-
'.gantt-dep-arrow, .gantt-dep-arrowhead, .gantt-dep-label'
|
|
524
|
-
).attr('display', active ? null : 'none');
|
|
525
|
-
}
|
|
526
|
-
drawLegend();
|
|
527
|
-
},
|
|
528
|
-
options?.exportMode ?? false,
|
|
529
|
-
controlsHost
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
function restoreHighlight() {
|
|
535
|
-
if (criticalPathActive) {
|
|
536
|
-
applyCriticalPathHighlight(svg, g);
|
|
537
|
-
} else {
|
|
538
|
-
svg.attr('data-critical-path-active', null);
|
|
539
|
-
resetHighlight(g, svg);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function recolorBars() {
|
|
544
|
-
g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
|
|
545
|
-
const el = d3Selection.select(this);
|
|
546
|
-
const taskId = el.attr('data-task-id');
|
|
547
|
-
const rt = resolved.tasks.find((t) => t.task.id === taskId);
|
|
548
|
-
if (!rt) return;
|
|
549
|
-
const color = resolveTaskColor(
|
|
550
|
-
rt,
|
|
551
|
-
currentActiveGroup,
|
|
552
|
-
resolved,
|
|
553
|
-
seriesColors,
|
|
554
|
-
palette
|
|
555
|
-
);
|
|
556
|
-
const fillColor = shapeFill(palette, color, isDark, { solid });
|
|
557
|
-
el.select('rect').attr('fill', fillColor).attr('stroke', color);
|
|
558
|
-
});
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
drawLegend();
|
|
562
|
-
|
|
563
|
-
// ── Time scale ──────────────────────────────────────────
|
|
564
|
-
|
|
565
|
-
const startTime = dateToFractionalYear(resolved.startDate);
|
|
566
|
-
const endTime = dateToFractionalYear(resolved.endDate);
|
|
567
|
-
|
|
568
|
-
// Add small padding to domain
|
|
569
|
-
const domainPad = Math.max((endTime - startTime) * 0.02, 0.01);
|
|
570
|
-
const domainMin = startTime - domainPad;
|
|
571
|
-
const domainMax = endTime + domainPad;
|
|
572
|
-
|
|
573
|
-
const xScale = d3Scale
|
|
574
|
-
.scaleLinear()
|
|
575
|
-
.domain([domainMin, domainMax])
|
|
576
|
-
.range([0, innerWidth]);
|
|
577
|
-
|
|
578
|
-
// Render time scale ticks (bottom only)
|
|
579
|
-
renderTimeScaleHorizontal(g, xScale, innerWidth, sInnerHeight, palette.text);
|
|
580
|
-
|
|
581
|
-
renderWeekendBands(g, resolved, xScale, sInnerHeight, palette, isDark);
|
|
582
|
-
renderHolidayBands(
|
|
583
|
-
g,
|
|
584
|
-
svg,
|
|
585
|
-
resolved,
|
|
586
|
-
xScale,
|
|
587
|
-
sInnerHeight,
|
|
588
|
-
palette,
|
|
589
|
-
isDark,
|
|
590
|
-
sMarginTop - 4,
|
|
591
|
-
leftMargin,
|
|
592
|
-
onClickItem
|
|
593
|
-
);
|
|
594
|
-
renderErasAndMarkers(
|
|
595
|
-
g,
|
|
596
|
-
svg,
|
|
597
|
-
resolved,
|
|
598
|
-
xScale,
|
|
599
|
-
sInnerHeight,
|
|
600
|
-
palette,
|
|
601
|
-
sEraLabelY,
|
|
602
|
-
sMarkerLabelY
|
|
603
|
-
);
|
|
604
|
-
renderSprintBands(g, svg, resolved, xScale, sInnerHeight, palette);
|
|
605
|
-
|
|
606
|
-
// ── Today marker (line rendered before rows so it paints behind task bars) ──
|
|
607
|
-
|
|
608
|
-
const todayDate: Date | null = resolvedTodayDate;
|
|
609
|
-
let todayX = -1;
|
|
610
|
-
const todayColor = palette.accent || '#e74c3c';
|
|
611
|
-
const todayMarkerLineNum = resolved.options.optionLineNumbers['today-marker'];
|
|
612
|
-
if (todayDate) {
|
|
613
|
-
todayX = xScale(dateToFractionalYear(todayDate));
|
|
614
|
-
if (todayX >= 0 && todayX <= innerWidth) {
|
|
615
|
-
const todayLine = g
|
|
616
|
-
.append('line')
|
|
617
|
-
.attr('class', 'gantt-today')
|
|
618
|
-
.attr('x1', todayX)
|
|
619
|
-
.attr('y1', 0)
|
|
620
|
-
.attr('x2', todayX)
|
|
621
|
-
.attr('y2', sInnerHeight + 10)
|
|
622
|
-
.attr('stroke', todayColor)
|
|
623
|
-
.attr('stroke-width', 2)
|
|
624
|
-
.attr('stroke-dasharray', '6 4')
|
|
625
|
-
.attr('opacity', 0.7)
|
|
626
|
-
.attr('pointer-events', 'none');
|
|
627
|
-
if (todayMarkerLineNum)
|
|
628
|
-
todayLine.attr('data-line-number', String(todayMarkerLineNum));
|
|
629
|
-
|
|
630
|
-
const todayLabel = g
|
|
631
|
-
.append('text')
|
|
632
|
-
.attr('class', 'gantt-today')
|
|
633
|
-
.attr('x', todayX)
|
|
634
|
-
.attr('y', sTodayLabelY)
|
|
635
|
-
.attr('text-anchor', 'middle')
|
|
636
|
-
.attr('font-size', `${sFont10}px`)
|
|
637
|
-
.attr('fill', todayColor)
|
|
638
|
-
.attr('opacity', 0.7)
|
|
639
|
-
.attr('pointer-events', 'none')
|
|
640
|
-
.text('Today');
|
|
641
|
-
if (todayMarkerLineNum)
|
|
642
|
-
todayLabel.attr('data-line-number', String(todayMarkerLineNum));
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// ── Render rows ─────────────────────────────────────────
|
|
647
|
-
|
|
648
|
-
// Track task positions for dependency arrows
|
|
649
|
-
const taskPositions = new Map<
|
|
650
|
-
string,
|
|
651
|
-
{ x1: number; x2: number; y: number }
|
|
652
|
-
>();
|
|
653
|
-
// Track collapsed group bar positions so hidden-task arrows redirect there
|
|
654
|
-
const groupPositions = new Map<
|
|
655
|
-
string,
|
|
656
|
-
{ x1: number; x2: number; y: number }
|
|
657
|
-
>();
|
|
658
|
-
// Track lane header positions for collapsed lane arrow redirection (tag mode)
|
|
659
|
-
const lanePositions = new Map<
|
|
660
|
-
string,
|
|
661
|
-
{ x1: number; x2: number; y: number }
|
|
662
|
-
>();
|
|
663
|
-
// Map task ID → lane name for collapsed lane lookup (tag mode)
|
|
664
|
-
const taskLaneMap = new Map<string, string>();
|
|
665
|
-
if (isTagMode && currentSwimlaneGroup) {
|
|
666
|
-
const tagGroup = resolved.tagGroups.find(
|
|
667
|
-
(tg) => tg.name.toLowerCase() === currentSwimlaneGroup.toLowerCase()
|
|
668
|
-
);
|
|
669
|
-
if (tagGroup) {
|
|
670
|
-
const tagKey = tagAttrKey(tagGroup.name);
|
|
671
|
-
for (const rt of resolved.tasks) {
|
|
672
|
-
let value = rt.effectiveMetadata[tagKey];
|
|
673
|
-
if (!value && tagGroup.defaultValue) value = tagGroup.defaultValue;
|
|
674
|
-
if (value) {
|
|
675
|
-
const entry = tagGroup.entries.find(
|
|
676
|
-
(e) => e.value.toLowerCase() === value!.toLowerCase()
|
|
677
|
-
);
|
|
678
|
-
if (entry) taskLaneMap.set(rt.task.id, entry.value);
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
let yOffset = sContentTopPad;
|
|
684
|
-
|
|
685
|
-
for (const row of rows) {
|
|
686
|
-
if (row.type === 'lane-header') {
|
|
687
|
-
const laneColor =
|
|
688
|
-
row.laneColor === '#999999' ? palette.textMuted : row.laneColor;
|
|
689
|
-
const toggleIcon = row.isCollapsed ? '▶' : '▼';
|
|
690
|
-
const labelX = 10;
|
|
691
|
-
|
|
692
|
-
let lx1 = 0;
|
|
693
|
-
let lx2 = innerWidth; // eslint-disable-line no-useless-assignment
|
|
694
|
-
let laneBarWidth = innerWidth;
|
|
695
|
-
if (row.laneStartDate && row.laneEndDate) {
|
|
696
|
-
lx1 = xScale(dateToFractionalYear(row.laneStartDate));
|
|
697
|
-
lx2 = xScale(dateToFractionalYear(row.laneEndDate));
|
|
698
|
-
laneBarWidth = Math.max(lx2 - lx1, 2);
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
lanePositions.set(row.laneName, {
|
|
702
|
-
x1: lx1,
|
|
703
|
-
x2: lx1 + laneBarWidth,
|
|
704
|
-
y: yOffset + sBarH / 2,
|
|
705
|
-
});
|
|
706
|
-
|
|
707
|
-
renderLabelBand(
|
|
708
|
-
svg,
|
|
709
|
-
sMarginTop + yOffset + sBarH / 2,
|
|
710
|
-
leftMargin,
|
|
711
|
-
laneColor,
|
|
712
|
-
palette,
|
|
713
|
-
isDark,
|
|
714
|
-
'lane',
|
|
715
|
-
{ key: 'data-lane', value: row.laneName },
|
|
716
|
-
solid,
|
|
717
|
-
sBarH,
|
|
718
|
-
sBandRadius,
|
|
719
|
-
sBandAccentW
|
|
720
|
-
);
|
|
721
|
-
const labelG = svg
|
|
722
|
-
.append('g')
|
|
723
|
-
.attr('class', 'gantt-lane-header')
|
|
724
|
-
.attr(`data-tag-${row.tagKey}`, row.laneName.toLowerCase())
|
|
725
|
-
.attr('data-lane', row.laneName)
|
|
726
|
-
.style('cursor', onToggleLane ? 'pointer' : 'default')
|
|
727
|
-
.on('click', () => {
|
|
728
|
-
if (onToggleLane) onToggleLane(row.laneName);
|
|
729
|
-
})
|
|
730
|
-
.on('mouseenter', () => {
|
|
731
|
-
highlightLane(g, svg, row.tagKey, row.laneName);
|
|
732
|
-
if (row.laneStartDate && row.laneEndDate) {
|
|
733
|
-
showGanttDateIndicators(
|
|
734
|
-
g,
|
|
735
|
-
xScale,
|
|
736
|
-
row.laneStartDate,
|
|
737
|
-
row.laneEndDate,
|
|
738
|
-
sInnerHeight,
|
|
739
|
-
laneColor
|
|
740
|
-
);
|
|
741
|
-
}
|
|
742
|
-
})
|
|
743
|
-
.on('mouseleave', () => {
|
|
744
|
-
restoreHighlight();
|
|
745
|
-
hideGanttDateIndicators(g);
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
labelG
|
|
749
|
-
.append('text')
|
|
750
|
-
.attr('x', labelX)
|
|
751
|
-
.attr('y', sMarginTop + yOffset + sBarH / 2)
|
|
752
|
-
.attr('dy', '0.35em')
|
|
753
|
-
.attr('text-anchor', 'start')
|
|
754
|
-
.attr('font-size', `${sFont11}px`)
|
|
755
|
-
.attr('font-weight', 'bold')
|
|
756
|
-
.attr('fill', laneColor)
|
|
757
|
-
.text(
|
|
758
|
-
toggleIcon +
|
|
759
|
-
' ' +
|
|
760
|
-
row.laneName +
|
|
761
|
-
(row.aggregateProgress !== null
|
|
762
|
-
? ` ${Math.round(row.aggregateProgress)}%`
|
|
763
|
-
: '')
|
|
764
|
-
);
|
|
765
|
-
|
|
766
|
-
if (laneBarWidth > 0) {
|
|
767
|
-
const barFill = shapeFill(palette, laneColor, isDark, { solid });
|
|
768
|
-
const laneBandG = g
|
|
769
|
-
.append('g')
|
|
770
|
-
.attr('class', 'gantt-lane-band-group')
|
|
771
|
-
.attr('data-lane', row.laneName)
|
|
772
|
-
.on('mouseenter', () => {
|
|
773
|
-
highlightLane(g, svg, row.tagKey, row.laneName);
|
|
774
|
-
if (row.laneStartDate && row.laneEndDate) {
|
|
775
|
-
showGanttDateIndicators(
|
|
776
|
-
g,
|
|
777
|
-
xScale,
|
|
778
|
-
row.laneStartDate,
|
|
779
|
-
row.laneEndDate,
|
|
780
|
-
sInnerHeight,
|
|
781
|
-
laneColor
|
|
782
|
-
);
|
|
783
|
-
}
|
|
784
|
-
})
|
|
785
|
-
.on('mouseleave', () => {
|
|
786
|
-
resetHighlight(g, svg);
|
|
787
|
-
hideGanttDateIndicators(g);
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
laneBandG
|
|
791
|
-
.append('rect')
|
|
792
|
-
.attr('class', 'gantt-lane-band')
|
|
793
|
-
.attr('x', lx1)
|
|
794
|
-
.attr('y', yOffset)
|
|
795
|
-
.attr('width', laneBarWidth)
|
|
796
|
-
.attr('height', sBarH)
|
|
797
|
-
.attr('rx', 4)
|
|
798
|
-
.attr('fill', barFill)
|
|
799
|
-
.attr('stroke', laneColor)
|
|
800
|
-
.attr('stroke-width', 2);
|
|
801
|
-
|
|
802
|
-
if (row.aggregateProgress !== null && row.aggregateProgress > 0) {
|
|
803
|
-
laneBandG
|
|
804
|
-
.append('rect')
|
|
805
|
-
.attr('class', 'gantt-lane-progress')
|
|
806
|
-
.attr('x', lx1)
|
|
807
|
-
.attr('y', yOffset)
|
|
808
|
-
.attr(
|
|
809
|
-
'width',
|
|
810
|
-
laneBarWidth * Math.min(row.aggregateProgress / 100, 1)
|
|
811
|
-
)
|
|
812
|
-
.attr('height', sBarH)
|
|
813
|
-
.attr('fill', laneColor)
|
|
814
|
-
.attr('opacity', 0.5)
|
|
815
|
-
.attr('pointer-events', 'none');
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
yOffset += sBarH + sRowGap;
|
|
820
|
-
} else if (row.type === 'group') {
|
|
821
|
-
const group = row.group;
|
|
822
|
-
const isCollapsed = collapsedGroups?.has(group.name) ?? false;
|
|
823
|
-
const toggleIcon = isCollapsed ? '▶' : '▼';
|
|
824
|
-
|
|
825
|
-
// Group label with toggle — resolve tag color from group metadata
|
|
826
|
-
const tagColor = resolveTagColor(
|
|
827
|
-
group.metadata,
|
|
828
|
-
resolved.tagGroups,
|
|
829
|
-
currentActiveGroup,
|
|
830
|
-
true
|
|
831
|
-
);
|
|
832
|
-
const groupColor =
|
|
833
|
-
tagColor && tagColor !== '#999999'
|
|
834
|
-
? tagColor
|
|
835
|
-
: group.color || palette.textMuted;
|
|
836
|
-
renderLabelBand(
|
|
837
|
-
svg,
|
|
838
|
-
sMarginTop + yOffset + sBarH / 2,
|
|
839
|
-
leftMargin,
|
|
840
|
-
groupColor,
|
|
841
|
-
palette,
|
|
842
|
-
isDark,
|
|
843
|
-
'group',
|
|
844
|
-
{ key: 'data-group', value: group.name },
|
|
845
|
-
solid,
|
|
846
|
-
sBarH,
|
|
847
|
-
sBandRadius,
|
|
848
|
-
sBandAccentW
|
|
849
|
-
);
|
|
850
|
-
const labelG = svg
|
|
851
|
-
.append('g')
|
|
852
|
-
.attr('class', 'gantt-group-label')
|
|
853
|
-
.attr('data-group', group.name)
|
|
854
|
-
.attr('data-line-number', String(group.lineNumber))
|
|
855
|
-
.style('cursor', onToggleGroup ? 'pointer' : 'default')
|
|
856
|
-
.on('click', () => {
|
|
857
|
-
if (onToggleGroup) onToggleGroup(group.name);
|
|
858
|
-
})
|
|
859
|
-
.on('mouseenter', () => {
|
|
860
|
-
highlightGroup(g, svg, group.name);
|
|
861
|
-
showGanttDateIndicators(
|
|
862
|
-
g,
|
|
863
|
-
xScale,
|
|
864
|
-
group.startDate,
|
|
865
|
-
group.endDate,
|
|
866
|
-
sInnerHeight,
|
|
867
|
-
groupColor
|
|
868
|
-
);
|
|
869
|
-
})
|
|
870
|
-
.on('mouseleave', () => {
|
|
871
|
-
resetHighlight(g, svg);
|
|
872
|
-
hideGanttDateIndicators(g);
|
|
873
|
-
});
|
|
874
|
-
|
|
875
|
-
const groupIndent =
|
|
876
|
-
group.depth <= 2 ? group.depth * 14 : 2 * 14 + (group.depth - 2) * 8;
|
|
877
|
-
const labelX = 10 + groupIndent;
|
|
878
|
-
labelG
|
|
879
|
-
.append('text')
|
|
880
|
-
.attr('x', labelX)
|
|
881
|
-
.attr('y', sMarginTop + yOffset + sBarH / 2)
|
|
882
|
-
.attr('dy', '0.35em')
|
|
883
|
-
.attr('text-anchor', 'start')
|
|
884
|
-
.attr('font-size', `${sFont11}px`)
|
|
885
|
-
.attr('font-weight', 'bold')
|
|
886
|
-
.attr('fill', palette.text)
|
|
887
|
-
.text(
|
|
888
|
-
toggleIcon +
|
|
889
|
-
' ' +
|
|
890
|
-
group.name +
|
|
891
|
-
(group.progress !== null ? ` ${Math.round(group.progress)}%` : '')
|
|
892
|
-
);
|
|
893
|
-
|
|
894
|
-
// Group bar
|
|
895
|
-
const gStart = dateToFractionalYear(group.startDate);
|
|
896
|
-
const gEnd = dateToFractionalYear(group.endDate);
|
|
897
|
-
const gx1 = xScale(gStart);
|
|
898
|
-
const gx2 = xScale(gEnd);
|
|
899
|
-
|
|
900
|
-
if (gx2 > gx1) {
|
|
901
|
-
if (isCollapsed) {
|
|
902
|
-
// Summary bar (full height, shows aggregate progress)
|
|
903
|
-
const barWidth = Math.max(gx2 - gx1, 2);
|
|
904
|
-
const summaryG = g
|
|
905
|
-
.append('g')
|
|
906
|
-
.attr('class', 'gantt-group-summary')
|
|
907
|
-
.attr('data-group', group.name)
|
|
908
|
-
.attr('data-line-number', String(group.lineNumber))
|
|
909
|
-
.on('mouseenter', () => {
|
|
910
|
-
highlightGroup(g, svg, group.name);
|
|
911
|
-
showGanttDateIndicators(
|
|
912
|
-
g,
|
|
913
|
-
xScale,
|
|
914
|
-
group.startDate,
|
|
915
|
-
group.endDate,
|
|
916
|
-
sInnerHeight,
|
|
917
|
-
groupColor
|
|
918
|
-
);
|
|
919
|
-
})
|
|
920
|
-
.on('mouseleave', () => {
|
|
921
|
-
resetHighlight(g, svg);
|
|
922
|
-
hideGanttDateIndicators(g);
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
summaryG
|
|
926
|
-
.append('rect')
|
|
927
|
-
.attr('x', gx1)
|
|
928
|
-
.attr('y', yOffset)
|
|
929
|
-
.attr('width', barWidth)
|
|
930
|
-
.attr('height', sBarH)
|
|
931
|
-
.attr('rx', 4)
|
|
932
|
-
.attr('fill', shapeFill(palette, groupColor, isDark, { solid }))
|
|
933
|
-
.attr('stroke', groupColor)
|
|
934
|
-
.attr('stroke-width', 2);
|
|
935
|
-
|
|
936
|
-
if (group.progress !== null && group.progress > 0) {
|
|
937
|
-
summaryG
|
|
938
|
-
.append('rect')
|
|
939
|
-
.attr('x', gx1)
|
|
940
|
-
.attr('y', yOffset)
|
|
941
|
-
.attr('width', barWidth * Math.min(group.progress / 100, 1))
|
|
942
|
-
.attr('height', sBarH)
|
|
943
|
-
.attr('fill', groupColor)
|
|
944
|
-
.attr('opacity', 0.5);
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
const summaryLabel =
|
|
948
|
-
group.name +
|
|
949
|
-
(group.progress !== null ? ` ${Math.round(group.progress)}%` : '');
|
|
950
|
-
const summaryPlacement = computeBarLabel(
|
|
951
|
-
summaryLabel,
|
|
952
|
-
gx1,
|
|
953
|
-
barWidth,
|
|
954
|
-
innerWidth,
|
|
955
|
-
palette.text,
|
|
956
|
-
contrastText(
|
|
957
|
-
shapeFill(palette, groupColor, isDark, { solid }),
|
|
958
|
-
palette.textOnFillLight,
|
|
959
|
-
palette.textOnFillDark
|
|
960
|
-
),
|
|
961
|
-
sFont10,
|
|
962
|
-
sLabelPad,
|
|
963
|
-
sLabelGap
|
|
964
|
-
);
|
|
965
|
-
if (summaryPlacement) {
|
|
966
|
-
summaryG
|
|
967
|
-
.append('text')
|
|
968
|
-
.attr('x', summaryPlacement.x)
|
|
969
|
-
.attr('y', yOffset + sBarH / 2)
|
|
970
|
-
.attr('dy', '0.35em')
|
|
971
|
-
.attr('font-size', `${sFont10}px`)
|
|
972
|
-
.attr('font-weight', 'bold')
|
|
973
|
-
.attr('text-anchor', summaryPlacement.anchor)
|
|
974
|
-
.attr('fill', summaryPlacement.fill)
|
|
975
|
-
.attr('pointer-events', 'none')
|
|
976
|
-
.text(summaryPlacement.text);
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
groupPositions.set(group.name, {
|
|
980
|
-
x1: gx1,
|
|
981
|
-
x2: gx1 + barWidth,
|
|
982
|
-
y: yOffset + sBarH / 2,
|
|
983
|
-
});
|
|
984
|
-
} else {
|
|
985
|
-
const groupBarWidth = Math.max(gx2 - gx1, 2);
|
|
986
|
-
const bandFill = shapeFill(palette, groupColor, isDark, { solid });
|
|
987
|
-
const groupBarG = g
|
|
988
|
-
.append('g')
|
|
989
|
-
.attr('class', 'gantt-group-bar')
|
|
990
|
-
.attr('data-group', group.name)
|
|
991
|
-
.attr('data-line-number', String(group.lineNumber))
|
|
992
|
-
.on('mouseenter', () => {
|
|
993
|
-
highlightGroup(g, svg, group.name);
|
|
994
|
-
showGanttDateIndicators(
|
|
995
|
-
g,
|
|
996
|
-
xScale,
|
|
997
|
-
group.startDate,
|
|
998
|
-
group.endDate,
|
|
999
|
-
sInnerHeight,
|
|
1000
|
-
groupColor
|
|
1001
|
-
);
|
|
1002
|
-
})
|
|
1003
|
-
.on('mouseleave', () => {
|
|
1004
|
-
resetHighlight(g, svg);
|
|
1005
|
-
hideGanttDateIndicators(g);
|
|
1006
|
-
});
|
|
1007
|
-
|
|
1008
|
-
groupBarG
|
|
1009
|
-
.append('rect')
|
|
1010
|
-
.attr('x', gx1)
|
|
1011
|
-
.attr('y', yOffset)
|
|
1012
|
-
.attr('width', groupBarWidth)
|
|
1013
|
-
.attr('height', sBarH)
|
|
1014
|
-
.attr('rx', 4)
|
|
1015
|
-
.attr('fill', bandFill)
|
|
1016
|
-
.attr('stroke', groupColor)
|
|
1017
|
-
.attr('stroke-width', 2);
|
|
1018
|
-
|
|
1019
|
-
if (group.progress !== null && group.progress > 0) {
|
|
1020
|
-
groupBarG
|
|
1021
|
-
.append('rect')
|
|
1022
|
-
.attr('class', 'gantt-group-progress')
|
|
1023
|
-
.attr('x', gx1)
|
|
1024
|
-
.attr('y', yOffset)
|
|
1025
|
-
.attr('width', groupBarWidth * Math.min(group.progress / 100, 1))
|
|
1026
|
-
.attr('height', sBarH)
|
|
1027
|
-
.attr('fill', groupColor)
|
|
1028
|
-
.attr('opacity', 0.5);
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
const expandedLabel =
|
|
1032
|
-
group.name +
|
|
1033
|
-
(group.progress !== null ? ` ${Math.round(group.progress)}%` : '');
|
|
1034
|
-
const expandedPlacement = computeBarLabel(
|
|
1035
|
-
expandedLabel,
|
|
1036
|
-
gx1,
|
|
1037
|
-
groupBarWidth,
|
|
1038
|
-
innerWidth,
|
|
1039
|
-
palette.text,
|
|
1040
|
-
contrastText(
|
|
1041
|
-
shapeFill(palette, groupColor, isDark, { solid }),
|
|
1042
|
-
palette.textOnFillLight,
|
|
1043
|
-
palette.textOnFillDark
|
|
1044
|
-
),
|
|
1045
|
-
sFont10,
|
|
1046
|
-
sLabelPad,
|
|
1047
|
-
sLabelGap
|
|
1048
|
-
);
|
|
1049
|
-
if (expandedPlacement) {
|
|
1050
|
-
groupBarG
|
|
1051
|
-
.append('text')
|
|
1052
|
-
.attr('x', expandedPlacement.x)
|
|
1053
|
-
.attr('y', yOffset + sBarH / 2)
|
|
1054
|
-
.attr('dy', '0.35em')
|
|
1055
|
-
.attr('font-size', `${sFont10}px`)
|
|
1056
|
-
.attr('font-weight', 'bold')
|
|
1057
|
-
.attr('text-anchor', expandedPlacement.anchor)
|
|
1058
|
-
.attr('fill', expandedPlacement.fill)
|
|
1059
|
-
.attr('pointer-events', 'none')
|
|
1060
|
-
.text(expandedPlacement.text);
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
yOffset += sBarH + sRowGap;
|
|
1066
|
-
} else if (row.type === 'task') {
|
|
1067
|
-
const rt = row.task;
|
|
1068
|
-
const task = rt.task;
|
|
1069
|
-
|
|
1070
|
-
const barColor = resolveTaskColor(
|
|
1071
|
-
rt,
|
|
1072
|
-
currentActiveGroup,
|
|
1073
|
-
resolved,
|
|
1074
|
-
seriesColors,
|
|
1075
|
-
palette
|
|
1076
|
-
);
|
|
1077
|
-
|
|
1078
|
-
const depth = rt.groupPath.length;
|
|
1079
|
-
const indent = depth <= 2 ? depth * 14 : 2 * 14 + (depth - 2) * 8;
|
|
1080
|
-
const taskLabelX = isTagMode ? 20 : 6 + indent;
|
|
1081
|
-
const topGroup = rt.groupPath.length > 0 ? rt.groupPath[0]! : null;
|
|
1082
|
-
const taskLabel = svg
|
|
1083
|
-
.append('text')
|
|
1084
|
-
.attr('class', 'gantt-task-label')
|
|
1085
|
-
.attr('x', taskLabelX)
|
|
1086
|
-
.attr('y', sMarginTop + yOffset + sBarH / 2)
|
|
1087
|
-
.attr('dy', '0.35em')
|
|
1088
|
-
.attr('text-anchor', 'start')
|
|
1089
|
-
.attr('font-size', `${sFont11}px`)
|
|
1090
|
-
.attr('fill', palette.text)
|
|
1091
|
-
.attr('data-line-number', String(task.lineNumber))
|
|
1092
|
-
.attr('data-task-id', task.id)
|
|
1093
|
-
.attr('data-group', topGroup)
|
|
1094
|
-
.style('cursor', onClickItem ? 'pointer' : 'default')
|
|
1095
|
-
.on('click', () => {
|
|
1096
|
-
if (onClickItem) onClickItem(task.lineNumber);
|
|
1097
|
-
})
|
|
1098
|
-
.on('mouseenter', () => {
|
|
1099
|
-
if (rt.isMilestone) {
|
|
1100
|
-
highlightMilestone(g, svg, task.id);
|
|
1101
|
-
} else {
|
|
1102
|
-
highlightTask(g, svg, task.id);
|
|
1103
|
-
}
|
|
1104
|
-
})
|
|
1105
|
-
.on('mouseleave', () => {
|
|
1106
|
-
resetHighlight(g, svg);
|
|
1107
|
-
});
|
|
1108
|
-
|
|
1109
|
-
appendTaskIcon(
|
|
1110
|
-
taskLabel,
|
|
1111
|
-
task.label,
|
|
1112
|
-
rt.isMilestone,
|
|
1113
|
-
barColor,
|
|
1114
|
-
palette.text
|
|
1115
|
-
);
|
|
1116
|
-
|
|
1117
|
-
// Tag attributes on label for legend hover matching
|
|
1118
|
-
for (const [key, value] of Object.entries(rt.effectiveMetadata)) {
|
|
1119
|
-
taskLabel.attr(`data-tag-${key}`, value.toLowerCase());
|
|
1120
|
-
}
|
|
1121
|
-
if (rt.isCriticalPath) {
|
|
1122
|
-
taskLabel.attr('data-critical-path', 'true');
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
if (rt.isMilestone) {
|
|
1126
|
-
const mx = xScale(dateToFractionalYear(rt.startDate));
|
|
1127
|
-
const my = yOffset + sBarH / 2;
|
|
1128
|
-
g.append('polygon')
|
|
1129
|
-
.attr('class', 'gantt-milestone')
|
|
1130
|
-
.attr('points', diamondPoints(mx, my, sMilestoneSize))
|
|
1131
|
-
.attr('fill', barColor)
|
|
1132
|
-
.attr('stroke', barColor)
|
|
1133
|
-
.attr('stroke-width', 1.5)
|
|
1134
|
-
.attr('data-line-number', String(task.lineNumber))
|
|
1135
|
-
.attr('data-task-name', task.label)
|
|
1136
|
-
.attr('data-task-id', task.id)
|
|
1137
|
-
.attr('data-group', topGroup)
|
|
1138
|
-
.style('cursor', onClickItem ? 'pointer' : 'default')
|
|
1139
|
-
.on('click', () => {
|
|
1140
|
-
if (onClickItem) onClickItem(task.lineNumber);
|
|
1141
|
-
})
|
|
1142
|
-
.on('mouseenter', () => {
|
|
1143
|
-
highlightMilestone(g, svg, task.id);
|
|
1144
|
-
showGanttDateIndicators(
|
|
1145
|
-
g,
|
|
1146
|
-
xScale,
|
|
1147
|
-
rt.startDate,
|
|
1148
|
-
null,
|
|
1149
|
-
sInnerHeight,
|
|
1150
|
-
barColor
|
|
1151
|
-
);
|
|
1152
|
-
g.append('text')
|
|
1153
|
-
.attr('class', 'gantt-milestone-hover-label')
|
|
1154
|
-
.attr('x', mx - sMilestoneSize - 4)
|
|
1155
|
-
.attr('y', my)
|
|
1156
|
-
.attr('dy', '0.35em')
|
|
1157
|
-
.attr('text-anchor', 'end')
|
|
1158
|
-
.attr('font-size', `${sFont10}px`)
|
|
1159
|
-
.attr('fill', barColor)
|
|
1160
|
-
.attr('font-weight', '600')
|
|
1161
|
-
.text(task.label);
|
|
1162
|
-
})
|
|
1163
|
-
.on('mouseleave', () => {
|
|
1164
|
-
restoreHighlight();
|
|
1165
|
-
hideGanttDateIndicators(g);
|
|
1166
|
-
g.selectAll('.gantt-milestone-hover-label').remove();
|
|
1167
|
-
});
|
|
1168
|
-
|
|
1169
|
-
taskPositions.set(task.id, { x1: mx, x2: mx, y: my });
|
|
1170
|
-
} else {
|
|
1171
|
-
// Render bar
|
|
1172
|
-
const tStart = dateToFractionalYear(rt.startDate);
|
|
1173
|
-
const tEnd = dateToFractionalYear(rt.endDate);
|
|
1174
|
-
const x1 = xScale(tStart);
|
|
1175
|
-
const x2 = xScale(tEnd);
|
|
1176
|
-
const barWidth = Math.max(x2 - x1, 2);
|
|
1177
|
-
|
|
1178
|
-
const fillColor = shapeFill(palette, barColor, isDark, { solid });
|
|
1179
|
-
|
|
1180
|
-
const taskG = g
|
|
1181
|
-
.append('g')
|
|
1182
|
-
.attr('class', 'gantt-task')
|
|
1183
|
-
.attr('data-line-number', String(task.lineNumber))
|
|
1184
|
-
.attr('data-task-name', task.label)
|
|
1185
|
-
.attr('data-task-id', task.id)
|
|
1186
|
-
.attr('data-group', topGroup)
|
|
1187
|
-
.style('cursor', onClickItem ? 'pointer' : 'default')
|
|
1188
|
-
.on('click', () => {
|
|
1189
|
-
if (onClickItem) onClickItem(task.lineNumber);
|
|
1190
|
-
})
|
|
1191
|
-
.on('mouseenter', () => {
|
|
1192
|
-
if (resolved.options.dependencies) {
|
|
1193
|
-
highlightDeps(g, svg, task.id, resolved);
|
|
1194
|
-
}
|
|
1195
|
-
highlightTaskLabel(svg, task.lineNumber);
|
|
1196
|
-
showGanttDateIndicators(
|
|
1197
|
-
g,
|
|
1198
|
-
xScale,
|
|
1199
|
-
rt.startDate,
|
|
1200
|
-
rt.endDate,
|
|
1201
|
-
sInnerHeight,
|
|
1202
|
-
barColor
|
|
1203
|
-
);
|
|
1204
|
-
})
|
|
1205
|
-
.on('mouseleave', () => {
|
|
1206
|
-
if (resolved.options.dependencies) {
|
|
1207
|
-
restoreHighlight();
|
|
1208
|
-
}
|
|
1209
|
-
resetTaskLabels(svg);
|
|
1210
|
-
hideGanttDateIndicators(g);
|
|
1211
|
-
});
|
|
1212
|
-
|
|
1213
|
-
// Set tag attributes
|
|
1214
|
-
for (const [key, value] of Object.entries(rt.effectiveMetadata)) {
|
|
1215
|
-
taskG.attr(`data-tag-${key}`, value.toLowerCase());
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
// Uncertainty gradient — fade out the trailing edge unless progress > 80%
|
|
1219
|
-
const showUncertainFade =
|
|
1220
|
-
rt.isUncertain && (task.progress === null || task.progress <= 80);
|
|
1221
|
-
let barFill: string = fillColor;
|
|
1222
|
-
let barStroke: string = barColor;
|
|
1223
|
-
if (showUncertainFade) {
|
|
1224
|
-
const defs = svg.select('defs').empty()
|
|
1225
|
-
? svg.append('defs')
|
|
1226
|
-
: svg.select<SVGDefsElement>('defs');
|
|
1227
|
-
|
|
1228
|
-
const fillGradId = `gantt-uncertain-fill-${task.id}`;
|
|
1229
|
-
const fillGrad = defs
|
|
1230
|
-
.append('linearGradient')
|
|
1231
|
-
.attr('id', fillGradId)
|
|
1232
|
-
.attr('x1', '0')
|
|
1233
|
-
.attr('x2', '1')
|
|
1234
|
-
.attr('y1', '0')
|
|
1235
|
-
.attr('y2', '0');
|
|
1236
|
-
fillGrad
|
|
1237
|
-
.append('stop')
|
|
1238
|
-
.attr('offset', '0%')
|
|
1239
|
-
.attr('stop-color', fillColor)
|
|
1240
|
-
.attr('stop-opacity', 1);
|
|
1241
|
-
fillGrad
|
|
1242
|
-
.append('stop')
|
|
1243
|
-
.attr('offset', '50%')
|
|
1244
|
-
.attr('stop-color', fillColor)
|
|
1245
|
-
.attr('stop-opacity', 1);
|
|
1246
|
-
fillGrad
|
|
1247
|
-
.append('stop')
|
|
1248
|
-
.attr('offset', '100%')
|
|
1249
|
-
.attr('stop-color', fillColor)
|
|
1250
|
-
.attr('stop-opacity', 0);
|
|
1251
|
-
|
|
1252
|
-
const strokeGradId = `gantt-uncertain-stroke-${task.id}`;
|
|
1253
|
-
const strokeGrad = defs
|
|
1254
|
-
.append('linearGradient')
|
|
1255
|
-
.attr('id', strokeGradId)
|
|
1256
|
-
.attr('x1', '0')
|
|
1257
|
-
.attr('x2', '1')
|
|
1258
|
-
.attr('y1', '0')
|
|
1259
|
-
.attr('y2', '0');
|
|
1260
|
-
strokeGrad
|
|
1261
|
-
.append('stop')
|
|
1262
|
-
.attr('offset', '0%')
|
|
1263
|
-
.attr('stop-color', barColor)
|
|
1264
|
-
.attr('stop-opacity', 1);
|
|
1265
|
-
strokeGrad
|
|
1266
|
-
.append('stop')
|
|
1267
|
-
.attr('offset', '50%')
|
|
1268
|
-
.attr('stop-color', barColor)
|
|
1269
|
-
.attr('stop-opacity', 1);
|
|
1270
|
-
strokeGrad
|
|
1271
|
-
.append('stop')
|
|
1272
|
-
.attr('offset', '100%')
|
|
1273
|
-
.attr('stop-color', barColor)
|
|
1274
|
-
.attr('stop-opacity', 0);
|
|
1275
|
-
|
|
1276
|
-
barFill = `url(#${fillGradId})`;
|
|
1277
|
-
barStroke = `url(#${strokeGradId})`;
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
taskG
|
|
1281
|
-
.append('rect')
|
|
1282
|
-
.attr('x', x1)
|
|
1283
|
-
.attr('y', yOffset)
|
|
1284
|
-
.attr('width', barWidth)
|
|
1285
|
-
.attr('height', sBarH)
|
|
1286
|
-
.attr('rx', 4)
|
|
1287
|
-
.attr('fill', barFill)
|
|
1288
|
-
.attr('stroke', barStroke)
|
|
1289
|
-
.attr('stroke-width', 2);
|
|
1290
|
-
|
|
1291
|
-
if (task.progress !== null && task.progress > 0) {
|
|
1292
|
-
const progressWidth = barWidth * Math.min(task.progress / 100, 1);
|
|
1293
|
-
let progressFill: string = barColor;
|
|
1294
|
-
if (showUncertainFade) {
|
|
1295
|
-
const ratio = barWidth / progressWidth;
|
|
1296
|
-
const fadeStart = Math.min(50 * ratio, 100);
|
|
1297
|
-
const defs = svg.select<SVGDefsElement>('defs');
|
|
1298
|
-
const progGradId = `gantt-uncertain-progress-${task.id}`;
|
|
1299
|
-
const progGrad = defs
|
|
1300
|
-
.append('linearGradient')
|
|
1301
|
-
.attr('id', progGradId)
|
|
1302
|
-
.attr('x1', '0')
|
|
1303
|
-
.attr('x2', '1')
|
|
1304
|
-
.attr('y1', '0')
|
|
1305
|
-
.attr('y2', '0');
|
|
1306
|
-
progGrad
|
|
1307
|
-
.append('stop')
|
|
1308
|
-
.attr('offset', '0%')
|
|
1309
|
-
.attr('stop-color', barColor)
|
|
1310
|
-
.attr('stop-opacity', 1);
|
|
1311
|
-
progGrad
|
|
1312
|
-
.append('stop')
|
|
1313
|
-
.attr('offset', `${fadeStart}%`)
|
|
1314
|
-
.attr('stop-color', barColor)
|
|
1315
|
-
.attr('stop-opacity', 1);
|
|
1316
|
-
progGrad
|
|
1317
|
-
.append('stop')
|
|
1318
|
-
.attr('offset', '100%')
|
|
1319
|
-
.attr('stop-color', barColor)
|
|
1320
|
-
.attr('stop-opacity', 0);
|
|
1321
|
-
progressFill = `url(#${progGradId})`;
|
|
1322
|
-
}
|
|
1323
|
-
taskG
|
|
1324
|
-
.append('rect')
|
|
1325
|
-
.attr('class', 'gantt-progress')
|
|
1326
|
-
.attr('x', x1)
|
|
1327
|
-
.attr('y', yOffset)
|
|
1328
|
-
.attr('width', progressWidth)
|
|
1329
|
-
.attr('height', sBarH)
|
|
1330
|
-
.attr('fill', progressFill)
|
|
1331
|
-
.attr('opacity', 0.5);
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
if (rt.isCriticalPath) {
|
|
1335
|
-
taskG.attr('data-critical-path', 'true');
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
const labelPlacement = computeBarLabel(
|
|
1339
|
-
task.label,
|
|
1340
|
-
x1,
|
|
1341
|
-
barWidth,
|
|
1342
|
-
innerWidth,
|
|
1343
|
-
palette.text,
|
|
1344
|
-
contrastText(
|
|
1345
|
-
shapeFill(palette, barColor, isDark, { solid }),
|
|
1346
|
-
palette.textOnFillLight,
|
|
1347
|
-
palette.textOnFillDark
|
|
1348
|
-
),
|
|
1349
|
-
sFont10,
|
|
1350
|
-
sLabelPad,
|
|
1351
|
-
sLabelGap
|
|
1352
|
-
);
|
|
1353
|
-
if (labelPlacement) {
|
|
1354
|
-
taskG
|
|
1355
|
-
.append('text')
|
|
1356
|
-
.attr('x', labelPlacement.x)
|
|
1357
|
-
.attr('y', yOffset + sBarH / 2)
|
|
1358
|
-
.attr('dy', '0.35em')
|
|
1359
|
-
.attr('font-size', `${sFont10}px`)
|
|
1360
|
-
.attr('text-anchor', labelPlacement.anchor)
|
|
1361
|
-
.attr('fill', labelPlacement.fill)
|
|
1362
|
-
.attr('pointer-events', 'none')
|
|
1363
|
-
.text(labelPlacement.text);
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
taskPositions.set(task.id, {
|
|
1367
|
-
x1,
|
|
1368
|
-
x2: x1 + barWidth,
|
|
1369
|
-
y: yOffset + sBarH / 2,
|
|
1370
|
-
});
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
yOffset += sBarH + sRowGap;
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
if (todayDate && todayX >= 0 && todayX <= innerWidth) {
|
|
1378
|
-
const todayHoverG = g
|
|
1379
|
-
.append('g')
|
|
1380
|
-
.attr('class', 'gantt-today-hover')
|
|
1381
|
-
.style('cursor', 'pointer');
|
|
1382
|
-
|
|
1383
|
-
todayHoverG
|
|
1384
|
-
.append('rect')
|
|
1385
|
-
.attr('x', todayX - 10)
|
|
1386
|
-
.attr('y', -6)
|
|
1387
|
-
.attr('width', 20)
|
|
1388
|
-
.attr('height', sInnerHeight + 16)
|
|
1389
|
-
.attr('fill', 'transparent')
|
|
1390
|
-
.attr('pointer-events', 'all');
|
|
1391
|
-
|
|
1392
|
-
const todayDateObj = todayDate;
|
|
1393
|
-
todayHoverG
|
|
1394
|
-
.on('mouseenter', () => {
|
|
1395
|
-
// Fade everything
|
|
1396
|
-
g.selectAll<SVGGElement, unknown>('.gantt-task').attr(
|
|
1397
|
-
'opacity',
|
|
1398
|
-
FADE_OPACITY
|
|
1399
|
-
);
|
|
1400
|
-
g.selectAll<SVGElement, unknown>('.gantt-milestone').attr(
|
|
1401
|
-
'opacity',
|
|
1402
|
-
FADE_OPACITY
|
|
1403
|
-
);
|
|
1404
|
-
g.selectAll<SVGElement, unknown>(
|
|
1405
|
-
'.gantt-group-bar, .gantt-group-summary'
|
|
1406
|
-
).attr('opacity', FADE_OPACITY);
|
|
1407
|
-
svg
|
|
1408
|
-
.selectAll<SVGGElement, unknown>('.gantt-group-label')
|
|
1409
|
-
.attr('opacity', FADE_OPACITY);
|
|
1410
|
-
svg
|
|
1411
|
-
.selectAll<SVGTextElement, unknown>('.gantt-task-label')
|
|
1412
|
-
.attr('opacity', FADE_OPACITY);
|
|
1413
|
-
svg
|
|
1414
|
-
.selectAll<SVGGElement, unknown>('.gantt-lane-header')
|
|
1415
|
-
.attr('opacity', FADE_OPACITY);
|
|
1416
|
-
g.selectAll<SVGElement, unknown>(
|
|
1417
|
-
'.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
|
|
1418
|
-
).attr('opacity', FADE_OPACITY);
|
|
1419
|
-
g.selectAll<SVGElement, unknown>(
|
|
1420
|
-
'.gantt-dep-arrow, .gantt-dep-arrowhead'
|
|
1421
|
-
).attr('opacity', FADE_OPACITY);
|
|
1422
|
-
g.selectAll<SVGElement, unknown>('.gantt-era-group').attr(
|
|
1423
|
-
'opacity',
|
|
1424
|
-
FADE_OPACITY
|
|
1425
|
-
);
|
|
1426
|
-
g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
|
|
1427
|
-
'opacity',
|
|
1428
|
-
FADE_OPACITY
|
|
1429
|
-
);
|
|
1430
|
-
showGanttDateIndicators(
|
|
1431
|
-
g,
|
|
1432
|
-
xScale,
|
|
1433
|
-
todayDateObj,
|
|
1434
|
-
null,
|
|
1435
|
-
sInnerHeight,
|
|
1436
|
-
todayColor
|
|
1437
|
-
);
|
|
1438
|
-
})
|
|
1439
|
-
.on('mouseleave', () => {
|
|
1440
|
-
resetHighlight(g, svg);
|
|
1441
|
-
hideGanttDateIndicators(g);
|
|
1442
|
-
});
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
// ── Dependency arrows ───────────────────────────────────
|
|
1446
|
-
|
|
1447
|
-
if (resolved.options.dependencies) {
|
|
1448
|
-
renderDependencyArrows(
|
|
1449
|
-
g,
|
|
1450
|
-
resolved,
|
|
1451
|
-
taskPositions,
|
|
1452
|
-
groupPositions,
|
|
1453
|
-
collapsedGroups,
|
|
1454
|
-
palette,
|
|
1455
|
-
isDark,
|
|
1456
|
-
isTagMode,
|
|
1457
|
-
lanePositions,
|
|
1458
|
-
collapsedLanes,
|
|
1459
|
-
taskLaneMap
|
|
1460
|
-
);
|
|
1461
|
-
if (!dependenciesActive) {
|
|
1462
|
-
g.selectAll<SVGElement, unknown>(
|
|
1463
|
-
'.gantt-dep-arrow, .gantt-dep-arrowhead, .gantt-dep-label'
|
|
1464
|
-
).attr('display', 'none');
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
// ── Weekend Band Rendering ──────────────────────────────────
|
|
1470
|
-
|
|
1471
|
-
const JS_DAY_TO_WEEKDAY: Weekday[] = [
|
|
1472
|
-
'sun',
|
|
1473
|
-
'mon',
|
|
1474
|
-
'tue',
|
|
1475
|
-
'wed',
|
|
1476
|
-
'thu',
|
|
1477
|
-
'fri',
|
|
1478
|
-
'sat',
|
|
1479
|
-
];
|
|
1480
|
-
|
|
1481
|
-
function renderWeekendBands(
|
|
1482
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1483
|
-
resolved: ResolvedSchedule,
|
|
1484
|
-
xScale: d3Scale.ScaleLinear<number, number>,
|
|
1485
|
-
innerHeight: number,
|
|
1486
|
-
palette: PaletteColors,
|
|
1487
|
-
isDark: boolean
|
|
1488
|
-
): void {
|
|
1489
|
-
const workweek = new Set(resolved.holidays.workweek);
|
|
1490
|
-
const start = new Date(resolved.startDate);
|
|
1491
|
-
start.setDate(start.getDate() - 1); // start one day before
|
|
1492
|
-
const end = new Date(resolved.endDate);
|
|
1493
|
-
end.setDate(end.getDate() + 1); // end one day after
|
|
1494
|
-
|
|
1495
|
-
const current = new Date(start);
|
|
1496
|
-
let bandStart: Date | null = null;
|
|
1497
|
-
|
|
1498
|
-
while (current <= end) {
|
|
1499
|
-
// getDay() returns 0-6; JS_DAY_TO_WEEKDAY has 7 entries.
|
|
1500
|
-
const dayName = JS_DAY_TO_WEEKDAY[current.getDay()]!;
|
|
1501
|
-
const isWeekend = !workweek.has(dayName);
|
|
1502
|
-
|
|
1503
|
-
if (isWeekend && !bandStart) {
|
|
1504
|
-
bandStart = new Date(current);
|
|
1505
|
-
} else if (!isWeekend && bandStart) {
|
|
1506
|
-
// Draw band from bandStart to current
|
|
1507
|
-
drawBand(
|
|
1508
|
-
g,
|
|
1509
|
-
xScale,
|
|
1510
|
-
bandStart,
|
|
1511
|
-
current,
|
|
1512
|
-
innerHeight,
|
|
1513
|
-
palette,
|
|
1514
|
-
isDark,
|
|
1515
|
-
'gantt-weekend-band',
|
|
1516
|
-
0.04
|
|
1517
|
-
);
|
|
1518
|
-
bandStart = null;
|
|
1519
|
-
}
|
|
1520
|
-
current.setDate(current.getDate() + 1);
|
|
1521
|
-
}
|
|
1522
|
-
// Close any trailing band
|
|
1523
|
-
if (bandStart) {
|
|
1524
|
-
drawBand(
|
|
1525
|
-
g,
|
|
1526
|
-
xScale,
|
|
1527
|
-
bandStart,
|
|
1528
|
-
current,
|
|
1529
|
-
innerHeight,
|
|
1530
|
-
palette,
|
|
1531
|
-
isDark,
|
|
1532
|
-
'gantt-weekend-band',
|
|
1533
|
-
0.04
|
|
1534
|
-
);
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
// ── Holiday Band Rendering ──────────────────────────────────
|
|
1539
|
-
|
|
1540
|
-
function renderHolidayBands(
|
|
1541
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1542
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
1543
|
-
resolved: ResolvedSchedule,
|
|
1544
|
-
xScale: d3Scale.ScaleLinear<number, number>,
|
|
1545
|
-
innerHeight: number,
|
|
1546
|
-
palette: PaletteColors,
|
|
1547
|
-
isDark: boolean,
|
|
1548
|
-
headerY: number,
|
|
1549
|
-
chartLeftMargin: number,
|
|
1550
|
-
onClickItem?: (lineNumber: number) => void
|
|
1551
|
-
): void {
|
|
1552
|
-
for (const h of resolved.holidays.dates) {
|
|
1553
|
-
const start = new Date(h.date + 'T00:00:00');
|
|
1554
|
-
const end = new Date(start);
|
|
1555
|
-
end.setDate(end.getDate() + 1);
|
|
1556
|
-
drawHolidayBand(
|
|
1557
|
-
g,
|
|
1558
|
-
svg,
|
|
1559
|
-
xScale,
|
|
1560
|
-
start,
|
|
1561
|
-
end,
|
|
1562
|
-
innerHeight,
|
|
1563
|
-
palette,
|
|
1564
|
-
isDark,
|
|
1565
|
-
h.label,
|
|
1566
|
-
h.lineNumber,
|
|
1567
|
-
headerY,
|
|
1568
|
-
chartLeftMargin,
|
|
1569
|
-
onClickItem
|
|
1570
|
-
);
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
for (const r of resolved.holidays.ranges) {
|
|
1574
|
-
const start = new Date(r.startDate + 'T00:00:00');
|
|
1575
|
-
const end = new Date(r.endDate + 'T00:00:00');
|
|
1576
|
-
end.setDate(end.getDate() + 1);
|
|
1577
|
-
drawHolidayBand(
|
|
1578
|
-
g,
|
|
1579
|
-
svg,
|
|
1580
|
-
xScale,
|
|
1581
|
-
start,
|
|
1582
|
-
end,
|
|
1583
|
-
innerHeight,
|
|
1584
|
-
palette,
|
|
1585
|
-
isDark,
|
|
1586
|
-
r.label,
|
|
1587
|
-
r.lineNumber,
|
|
1588
|
-
headerY,
|
|
1589
|
-
chartLeftMargin,
|
|
1590
|
-
onClickItem
|
|
1591
|
-
);
|
|
1592
|
-
}
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
function drawBand(
|
|
1596
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1597
|
-
xScale: d3Scale.ScaleLinear<number, number>,
|
|
1598
|
-
start: Date,
|
|
1599
|
-
end: Date,
|
|
1600
|
-
height: number,
|
|
1601
|
-
palette: PaletteColors,
|
|
1602
|
-
_isDark: boolean,
|
|
1603
|
-
className: string,
|
|
1604
|
-
opacity: number
|
|
1605
|
-
): void {
|
|
1606
|
-
const x1 = xScale(dateToFractionalYear(start));
|
|
1607
|
-
const x2 = xScale(dateToFractionalYear(end));
|
|
1608
|
-
if (x2 <= x1) return;
|
|
1609
|
-
|
|
1610
|
-
g.append('rect')
|
|
1611
|
-
.attr('class', className)
|
|
1612
|
-
.attr('x', x1)
|
|
1613
|
-
.attr('y', 0)
|
|
1614
|
-
.attr('width', x2 - x1)
|
|
1615
|
-
.attr('height', height)
|
|
1616
|
-
.attr('fill', palette.text)
|
|
1617
|
-
.attr('opacity', opacity)
|
|
1618
|
-
.attr('pointer-events', 'none');
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
function drawHolidayBand(
|
|
1622
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1623
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
1624
|
-
xScale: d3Scale.ScaleLinear<number, number>,
|
|
1625
|
-
start: Date,
|
|
1626
|
-
end: Date,
|
|
1627
|
-
height: number,
|
|
1628
|
-
palette: PaletteColors,
|
|
1629
|
-
_isDark: boolean,
|
|
1630
|
-
label: string,
|
|
1631
|
-
lineNumber: number,
|
|
1632
|
-
headerY: number,
|
|
1633
|
-
chartLeftMargin: number,
|
|
1634
|
-
onClickItem?: (lineNumber: number) => void
|
|
1635
|
-
): void {
|
|
1636
|
-
const x1 = xScale(dateToFractionalYear(start));
|
|
1637
|
-
const x2 = xScale(dateToFractionalYear(end));
|
|
1638
|
-
if (x2 <= x1) return;
|
|
1639
|
-
|
|
1640
|
-
const bandW = Math.max(x2 - x1, 4);
|
|
1641
|
-
const baseOpacity = 0.08;
|
|
1642
|
-
const hoverOpacity = 0.18;
|
|
1643
|
-
|
|
1644
|
-
const bandG = g
|
|
1645
|
-
.append('g')
|
|
1646
|
-
.attr('class', 'gantt-holiday-band')
|
|
1647
|
-
.attr('data-line-number', String(lineNumber))
|
|
1648
|
-
.style('cursor', onClickItem ? 'pointer' : 'default');
|
|
1649
|
-
|
|
1650
|
-
// Band rect
|
|
1651
|
-
const bandRect = bandG
|
|
1652
|
-
.append('rect')
|
|
1653
|
-
.attr('x', x1)
|
|
1654
|
-
.attr('y', 0)
|
|
1655
|
-
.attr('width', bandW)
|
|
1656
|
-
.attr('height', height)
|
|
1657
|
-
.attr('fill', palette.text)
|
|
1658
|
-
.attr('opacity', baseOpacity);
|
|
1659
|
-
|
|
1660
|
-
// Hover label in SVG-space (date header row) — hidden by default
|
|
1661
|
-
// Background rect to mask date labels underneath
|
|
1662
|
-
const labelX = chartLeftMargin + x1 + bandW / 2;
|
|
1663
|
-
const textLen = measureText(label, 10) + 8;
|
|
1664
|
-
const labelBg = svg
|
|
1665
|
-
.append('rect')
|
|
1666
|
-
.attr('class', 'gantt-holiday-hover-bg')
|
|
1667
|
-
.attr('data-line-number', String(lineNumber))
|
|
1668
|
-
.attr('x', labelX - textLen / 2)
|
|
1669
|
-
.attr('y', headerY - 11)
|
|
1670
|
-
.attr('width', textLen)
|
|
1671
|
-
.attr('height', 14)
|
|
1672
|
-
.attr('rx', 3)
|
|
1673
|
-
.attr('fill', palette.bg)
|
|
1674
|
-
.attr('opacity', 0)
|
|
1675
|
-
.attr('pointer-events', 'none');
|
|
1676
|
-
|
|
1677
|
-
const labelText = svg
|
|
1678
|
-
.append('text')
|
|
1679
|
-
.attr('class', 'gantt-holiday-hover-label')
|
|
1680
|
-
.attr('data-line-number', String(lineNumber))
|
|
1681
|
-
.attr('x', labelX)
|
|
1682
|
-
.attr('y', headerY)
|
|
1683
|
-
.attr('text-anchor', 'middle')
|
|
1684
|
-
.attr('font-size', '10px')
|
|
1685
|
-
.attr('font-weight', '500')
|
|
1686
|
-
.attr('fill', palette.text)
|
|
1687
|
-
.attr('opacity', 0)
|
|
1688
|
-
.attr('pointer-events', 'none')
|
|
1689
|
-
.text(label);
|
|
1690
|
-
|
|
1691
|
-
// Hover: highlight band + show label in header + date indicators
|
|
1692
|
-
bandG
|
|
1693
|
-
.on('mouseenter', () => {
|
|
1694
|
-
bandRect.attr('opacity', hoverOpacity);
|
|
1695
|
-
labelBg.attr('opacity', 1);
|
|
1696
|
-
labelText.attr('opacity', 1);
|
|
1697
|
-
showGanttDateIndicators(g, xScale, start, end, height, palette.textMuted);
|
|
1698
|
-
})
|
|
1699
|
-
.on('mouseleave', () => {
|
|
1700
|
-
bandRect.attr('opacity', baseOpacity);
|
|
1701
|
-
labelBg.attr('opacity', 0);
|
|
1702
|
-
labelText.attr('opacity', 0);
|
|
1703
|
-
hideGanttDateIndicators(g);
|
|
1704
|
-
})
|
|
1705
|
-
.on('click', () => {
|
|
1706
|
-
if (onClickItem) onClickItem(lineNumber);
|
|
1707
|
-
});
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
// ── Dependency Arrow Rendering ──────────────────────────────
|
|
1711
|
-
|
|
1712
|
-
function findCollapsedGroupPos(
|
|
1713
|
-
rt: ResolvedTask,
|
|
1714
|
-
collapsedGroups: Set<string> | undefined,
|
|
1715
|
-
groupPositions: Map<string, { x1: number; x2: number; y: number }>
|
|
1716
|
-
): { x1: number; x2: number; y: number } | undefined {
|
|
1717
|
-
if (!collapsedGroups) return undefined;
|
|
1718
|
-
// Walk the task's group path and find the first collapsed group with a position
|
|
1719
|
-
for (const groupName of rt.groupPath) {
|
|
1720
|
-
if (collapsedGroups.has(groupName)) {
|
|
1721
|
-
return groupPositions.get(groupName);
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
return undefined;
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
function findCollapsedLanePos(
|
|
1728
|
-
rt: ResolvedTask,
|
|
1729
|
-
collapsedLanes: Set<string> | undefined,
|
|
1730
|
-
taskLaneMap: Map<string, string>,
|
|
1731
|
-
lanePositions: Map<string, { x1: number; x2: number; y: number }>
|
|
1732
|
-
): { x1: number; x2: number; y: number } | undefined {
|
|
1733
|
-
if (!collapsedLanes) return undefined;
|
|
1734
|
-
const laneName = taskLaneMap.get(rt.task.id);
|
|
1735
|
-
if (laneName && collapsedLanes.has(laneName)) {
|
|
1736
|
-
return lanePositions.get(laneName);
|
|
1737
|
-
}
|
|
1738
|
-
return undefined;
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
function renderDependencyArrows(
|
|
1742
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1743
|
-
resolved: ResolvedSchedule,
|
|
1744
|
-
taskPositions: Map<string, { x1: number; x2: number; y: number }>,
|
|
1745
|
-
groupPositions: Map<string, { x1: number; x2: number; y: number }>,
|
|
1746
|
-
collapsedGroups: Set<string> | undefined,
|
|
1747
|
-
palette: PaletteColors,
|
|
1748
|
-
_isDark: boolean,
|
|
1749
|
-
isTagMode: boolean,
|
|
1750
|
-
lanePositions: Map<string, { x1: number; x2: number; y: number }>,
|
|
1751
|
-
collapsedLanes: Set<string> | undefined,
|
|
1752
|
-
taskLaneMap: Map<string, string>
|
|
1753
|
-
): void {
|
|
1754
|
-
// Deduplicate arrows that collapse to the same source→target position
|
|
1755
|
-
const drawnArrows = new Set<string>();
|
|
1756
|
-
|
|
1757
|
-
// Build arrow list from task dependencies
|
|
1758
|
-
for (const rt of resolved.tasks) {
|
|
1759
|
-
const sourcePos =
|
|
1760
|
-
taskPositions.get(rt.task.id) ??
|
|
1761
|
-
(isTagMode
|
|
1762
|
-
? findCollapsedLanePos(rt, collapsedLanes, taskLaneMap, lanePositions)
|
|
1763
|
-
: findCollapsedGroupPos(rt, collapsedGroups, groupPositions));
|
|
1764
|
-
if (!sourcePos) continue;
|
|
1765
|
-
|
|
1766
|
-
for (const dep of rt.task.dependencies) {
|
|
1767
|
-
// Find target task — forgiving normalization per universal name handling
|
|
1768
|
-
const depKey = normalizeName(dep.targetName);
|
|
1769
|
-
const targetTask = resolved.tasks.find(
|
|
1770
|
-
(t) =>
|
|
1771
|
-
normalizeName(t.task.label) === depKey ||
|
|
1772
|
-
normalizeName(`${t.groupPath.join('.')}.${t.task.label}`).endsWith(
|
|
1773
|
-
depKey
|
|
1774
|
-
)
|
|
1775
|
-
);
|
|
1776
|
-
if (!targetTask) continue;
|
|
1777
|
-
|
|
1778
|
-
const targetPos =
|
|
1779
|
-
taskPositions.get(targetTask.task.id) ??
|
|
1780
|
-
(isTagMode
|
|
1781
|
-
? findCollapsedLanePos(
|
|
1782
|
-
targetTask,
|
|
1783
|
-
collapsedLanes,
|
|
1784
|
-
taskLaneMap,
|
|
1785
|
-
lanePositions
|
|
1786
|
-
)
|
|
1787
|
-
: findCollapsedGroupPos(targetTask, collapsedGroups, groupPositions));
|
|
1788
|
-
if (!targetPos) continue;
|
|
1789
|
-
|
|
1790
|
-
// Skip self-arrows (both source and target collapsed to the same group)
|
|
1791
|
-
if (sourcePos === targetPos) continue;
|
|
1792
|
-
|
|
1793
|
-
// Deduplicate: multiple hidden tasks in the same collapsed group → same arrow
|
|
1794
|
-
const arrowKey = `${sourcePos.x1},${sourcePos.y}->${targetPos.x1},${targetPos.y}`;
|
|
1795
|
-
if (drawnArrows.has(arrowKey)) continue;
|
|
1796
|
-
drawnArrows.add(arrowKey);
|
|
1797
|
-
|
|
1798
|
-
// Arrow from source end to target start
|
|
1799
|
-
const sx = sourcePos.x2;
|
|
1800
|
-
const sy = sourcePos.y;
|
|
1801
|
-
const tx = targetPos.x1;
|
|
1802
|
-
const ty = targetPos.y;
|
|
1803
|
-
|
|
1804
|
-
// Bezier curve with dy-scaled control points for cross-lane arrows
|
|
1805
|
-
const dx = Math.abs(tx - sx);
|
|
1806
|
-
const dy = Math.abs(ty - sy);
|
|
1807
|
-
const cpOffset = Math.max(dx * 0.3, 15, dy * 0.4);
|
|
1808
|
-
|
|
1809
|
-
const path = `M ${sx} ${sy} C ${sx + cpOffset} ${sy}, ${tx - cpOffset} ${ty}, ${tx} ${ty}`;
|
|
1810
|
-
|
|
1811
|
-
const arrowColor = mix(palette.text, palette.bg, 50);
|
|
1812
|
-
const isCpArrow = rt.isCriticalPath && targetTask.isCriticalPath;
|
|
1813
|
-
|
|
1814
|
-
g.append('path')
|
|
1815
|
-
.attr('class', 'gantt-dep-arrow')
|
|
1816
|
-
.attr('data-dep-from', rt.task.id)
|
|
1817
|
-
.attr('data-dep-to', targetTask.task.id)
|
|
1818
|
-
.attr('data-line-number', String(dep.lineNumber))
|
|
1819
|
-
.attr('data-critical-path', isCpArrow ? 'true' : null)
|
|
1820
|
-
.attr('d', path)
|
|
1821
|
-
.attr('fill', 'none')
|
|
1822
|
-
.attr('stroke', arrowColor)
|
|
1823
|
-
.attr('stroke-width', 1.5)
|
|
1824
|
-
.attr('opacity', 0.5);
|
|
1825
|
-
|
|
1826
|
-
// Arrowhead — always horizontal arrival (bezier cp2 has same Y as endpoint)
|
|
1827
|
-
const headSize = 5;
|
|
1828
|
-
const angle = 0;
|
|
1829
|
-
g.append('polygon')
|
|
1830
|
-
.attr('class', 'gantt-dep-arrowhead')
|
|
1831
|
-
.attr('data-dep-from', rt.task.id)
|
|
1832
|
-
.attr('data-dep-to', targetTask.task.id)
|
|
1833
|
-
.attr('data-line-number', String(dep.lineNumber))
|
|
1834
|
-
.attr('data-critical-path', isCpArrow ? 'true' : null)
|
|
1835
|
-
.attr('points', arrowheadPoints(tx, ty, headSize, angle))
|
|
1836
|
-
.attr('fill', arrowColor)
|
|
1837
|
-
.attr('opacity', 0.5);
|
|
1838
|
-
|
|
1839
|
-
// Label at midpoint of the dependency path
|
|
1840
|
-
if (dep.label) {
|
|
1841
|
-
const midX = (sx + tx) / 2;
|
|
1842
|
-
const midY = (sy + ty) / 2;
|
|
1843
|
-
// Background rect for readability
|
|
1844
|
-
const labelEl = g
|
|
1845
|
-
.append('text')
|
|
1846
|
-
.attr('class', 'gantt-dep-label')
|
|
1847
|
-
.attr('data-dep-from', rt.task.id)
|
|
1848
|
-
.attr('data-dep-to', targetTask.task.id)
|
|
1849
|
-
.attr('x', midX)
|
|
1850
|
-
.attr('y', midY - 4)
|
|
1851
|
-
.attr('text-anchor', 'middle')
|
|
1852
|
-
.attr('font-size', 10)
|
|
1853
|
-
.attr('font-family', FONT_FAMILY)
|
|
1854
|
-
.attr('fill', palette.text)
|
|
1855
|
-
.attr('opacity', 0.7)
|
|
1856
|
-
.text(dep.label);
|
|
1857
|
-
// Insert a background rect behind the text for contrast
|
|
1858
|
-
const bbox = (labelEl.node() as SVGTextElement)?.getBBox?.();
|
|
1859
|
-
if (bbox && bbox.width > 0) {
|
|
1860
|
-
g.insert('rect', '.gantt-dep-label:last-of-type')
|
|
1861
|
-
.attr('class', 'gantt-dep-label-bg')
|
|
1862
|
-
.attr('x', bbox.x - 2)
|
|
1863
|
-
.attr('y', bbox.y - 1)
|
|
1864
|
-
.attr('width', bbox.width + 4)
|
|
1865
|
-
.attr('height', bbox.height + 2)
|
|
1866
|
-
.attr('fill', palette.bg)
|
|
1867
|
-
.attr('opacity', 0.8)
|
|
1868
|
-
.attr('rx', 2);
|
|
1869
|
-
}
|
|
1870
|
-
}
|
|
1871
|
-
}
|
|
1872
|
-
}
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
|
-
function arrowheadPoints(
|
|
1876
|
-
x: number,
|
|
1877
|
-
y: number,
|
|
1878
|
-
size: number,
|
|
1879
|
-
angle: number
|
|
1880
|
-
): string {
|
|
1881
|
-
const a1 = angle + Math.PI * 0.8;
|
|
1882
|
-
const a2 = angle - Math.PI * 0.8;
|
|
1883
|
-
return `${x},${y} ${x + size * Math.cos(a1)},${y + size * Math.sin(a1)} ${x + size * Math.cos(a2)},${y + size * Math.sin(a2)}`;
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
// ── Tag Legend Rendering ─────────────────────────────────────
|
|
1887
|
-
|
|
1888
|
-
function applyCriticalPathHighlight(
|
|
1889
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
1890
|
-
chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>
|
|
1891
|
-
) {
|
|
1892
|
-
svg.attr('data-critical-path-active', 'true');
|
|
1893
|
-
chartG.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
|
|
1894
|
-
const el = d3Selection.select(this);
|
|
1895
|
-
el.attr(
|
|
1896
|
-
'opacity',
|
|
1897
|
-
el.attr('data-critical-path') === 'true' ? 1 : FADE_OPACITY
|
|
1898
|
-
);
|
|
1899
|
-
});
|
|
1900
|
-
chartG
|
|
1901
|
-
.selectAll<SVGElement, unknown>('.gantt-milestone')
|
|
1902
|
-
.attr('opacity', FADE_OPACITY);
|
|
1903
|
-
chartG
|
|
1904
|
-
.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary')
|
|
1905
|
-
.attr('opacity', FADE_OPACITY);
|
|
1906
|
-
svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
|
|
1907
|
-
const el = d3Selection.select(this);
|
|
1908
|
-
el.attr(
|
|
1909
|
-
'opacity',
|
|
1910
|
-
el.attr('data-critical-path') === 'true' ? 1 : FADE_OPACITY
|
|
1911
|
-
);
|
|
1912
|
-
});
|
|
1913
|
-
svg
|
|
1914
|
-
.selectAll<SVGGElement, unknown>('.gantt-group-label')
|
|
1915
|
-
.attr('opacity', FADE_OPACITY);
|
|
1916
|
-
svg
|
|
1917
|
-
.selectAll<
|
|
1918
|
-
SVGElement,
|
|
1919
|
-
unknown
|
|
1920
|
-
>('.gantt-group-band-bg, .gantt-group-band-accent')
|
|
1921
|
-
.attr('opacity', FADE_OPACITY);
|
|
1922
|
-
svg
|
|
1923
|
-
.selectAll<SVGGElement, unknown>('.gantt-lane-header')
|
|
1924
|
-
.attr('opacity', FADE_OPACITY);
|
|
1925
|
-
svg
|
|
1926
|
-
.selectAll<
|
|
1927
|
-
SVGElement,
|
|
1928
|
-
unknown
|
|
1929
|
-
>('.gantt-lane-band-bg, .gantt-lane-band-accent')
|
|
1930
|
-
.attr('opacity', FADE_OPACITY);
|
|
1931
|
-
chartG
|
|
1932
|
-
.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent')
|
|
1933
|
-
.attr('opacity', FADE_OPACITY);
|
|
1934
|
-
// Show critical path arrows at full opacity, fade others
|
|
1935
|
-
chartG
|
|
1936
|
-
.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead')
|
|
1937
|
-
.each(function () {
|
|
1938
|
-
const el = d3Selection.select(this);
|
|
1939
|
-
el.attr(
|
|
1940
|
-
'opacity',
|
|
1941
|
-
el.attr('data-critical-path') === 'true' ? 0.7 : FADE_OPACITY
|
|
1942
|
-
);
|
|
1943
|
-
});
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
|
-
function resetHighlightAll(
|
|
1947
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
1948
|
-
chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>
|
|
1949
|
-
) {
|
|
1950
|
-
chartG
|
|
1951
|
-
.selectAll<SVGGElement, unknown>('.gantt-task, .gantt-milestone')
|
|
1952
|
-
.attr('opacity', 1);
|
|
1953
|
-
chartG
|
|
1954
|
-
.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary')
|
|
1955
|
-
.attr('opacity', 1);
|
|
1956
|
-
svg
|
|
1957
|
-
.selectAll<SVGTextElement, unknown>('.gantt-task-label')
|
|
1958
|
-
.attr('opacity', 1);
|
|
1959
|
-
svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', 1);
|
|
1960
|
-
svg
|
|
1961
|
-
.selectAll<
|
|
1962
|
-
SVGElement,
|
|
1963
|
-
unknown
|
|
1964
|
-
>('.gantt-group-band-bg, .gantt-group-band-accent')
|
|
1965
|
-
.attr('opacity', 1);
|
|
1966
|
-
svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', 1);
|
|
1967
|
-
svg
|
|
1968
|
-
.selectAll<
|
|
1969
|
-
SVGElement,
|
|
1970
|
-
unknown
|
|
1971
|
-
>('.gantt-lane-band-bg, .gantt-lane-band-accent')
|
|
1972
|
-
.attr('opacity', 1);
|
|
1973
|
-
chartG
|
|
1974
|
-
.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent')
|
|
1975
|
-
.attr('opacity', 1);
|
|
1976
|
-
chartG
|
|
1977
|
-
.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead')
|
|
1978
|
-
.attr('opacity', 0.5);
|
|
1979
|
-
}
|
|
1980
|
-
|
|
1981
|
-
// ── Swimlane Icon Helper ─────────────────────────────────────
|
|
1982
|
-
|
|
1983
|
-
function drawSwimlaneIcon(
|
|
1984
|
-
parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1985
|
-
x: number,
|
|
1986
|
-
y: number,
|
|
1987
|
-
isActive: boolean,
|
|
1988
|
-
palette: PaletteColors
|
|
1989
|
-
): d3Selection.Selection<SVGGElement, unknown, null, undefined> {
|
|
1990
|
-
const iconG = parent
|
|
1991
|
-
.append('g')
|
|
1992
|
-
.attr('class', 'gantt-swimlane-icon')
|
|
1993
|
-
.attr('transform', `translate(${x}, ${y})`);
|
|
1994
|
-
|
|
1995
|
-
// Transparent hit area so the whole icon (not just the 2px bars) is clickable
|
|
1996
|
-
iconG
|
|
1997
|
-
.append('rect')
|
|
1998
|
-
.attr('x', -5)
|
|
1999
|
-
.attr('y', -5)
|
|
2000
|
-
.attr('width', 22)
|
|
2001
|
-
.attr('height', 18)
|
|
2002
|
-
.attr('fill', 'transparent');
|
|
2003
|
-
|
|
2004
|
-
const color = isActive ? palette.primary : palette.textMuted;
|
|
2005
|
-
const opacity = isActive ? 1 : 0.35;
|
|
2006
|
-
const barWidths = [8, 12, 6];
|
|
2007
|
-
const barH = 2;
|
|
2008
|
-
const gap = 3;
|
|
2009
|
-
|
|
2010
|
-
for (let i = 0; i < barWidths.length; i++) {
|
|
2011
|
-
iconG
|
|
2012
|
-
.append('rect')
|
|
2013
|
-
.attr('x', 0)
|
|
2014
|
-
.attr('y', i * gap)
|
|
2015
|
-
.attr('width', barWidths[i]!) // In-bounds by loop guard.
|
|
2016
|
-
.attr('height', barH)
|
|
2017
|
-
.attr('rx', 1)
|
|
2018
|
-
.attr('fill', color)
|
|
2019
|
-
.attr('opacity', opacity);
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
return iconG;
|
|
2023
|
-
}
|
|
2024
|
-
|
|
2025
|
-
function buildControlsToggles(
|
|
2026
|
-
hasCriticalPath: boolean,
|
|
2027
|
-
criticalPathActive: boolean,
|
|
2028
|
-
hasDependencies: boolean,
|
|
2029
|
-
dependenciesActive: boolean
|
|
2030
|
-
): import('../utils/legend-types').ControlsGroupToggle[] {
|
|
2031
|
-
const toggles: import('../utils/legend-types').ControlsGroupToggle[] = [];
|
|
2032
|
-
if (hasCriticalPath) {
|
|
2033
|
-
toggles.push({
|
|
2034
|
-
id: 'critical-path',
|
|
2035
|
-
type: 'toggle',
|
|
2036
|
-
label: 'Critical Path',
|
|
2037
|
-
active: criticalPathActive,
|
|
2038
|
-
onToggle: () => {},
|
|
2039
|
-
});
|
|
2040
|
-
}
|
|
2041
|
-
if (hasDependencies) {
|
|
2042
|
-
toggles.push({
|
|
2043
|
-
id: 'dependencies',
|
|
2044
|
-
type: 'toggle',
|
|
2045
|
-
label: 'Dependencies',
|
|
2046
|
-
active: dependenciesActive,
|
|
2047
|
-
onToggle: () => {},
|
|
2048
|
-
});
|
|
2049
|
-
}
|
|
2050
|
-
return toggles;
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
function renderTagLegend(
|
|
2054
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
2055
|
-
chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
2056
|
-
tagGroups: TagGroup[],
|
|
2057
|
-
activeGroupName: string | null,
|
|
2058
|
-
chartLeftMargin: number,
|
|
2059
|
-
chartInnerWidth: number,
|
|
2060
|
-
legendY: number,
|
|
2061
|
-
palette: PaletteColors,
|
|
2062
|
-
isDark: boolean,
|
|
2063
|
-
hasCriticalPath: boolean,
|
|
2064
|
-
criticalPathActive: boolean,
|
|
2065
|
-
_optionLineNumbers: Record<string, number>,
|
|
2066
|
-
onToggle?: (groupName: string) => void,
|
|
2067
|
-
onToggleControlsExpand?: () => void,
|
|
2068
|
-
currentSwimlaneGroup?: string | null,
|
|
2069
|
-
onSwimlaneChange?: (group: string | null) => void,
|
|
2070
|
-
legendViewMode?: boolean,
|
|
2071
|
-
resolvedTasks?: ResolvedTask[],
|
|
2072
|
-
controlsExpanded = false,
|
|
2073
|
-
hasDependencies = false,
|
|
2074
|
-
dependenciesActive = false,
|
|
2075
|
-
onControlsToggle?: (toggleId: string, active: boolean) => void,
|
|
2076
|
-
exportMode = false,
|
|
2077
|
-
controlsHost: 'app' | 'inline' = 'inline'
|
|
2078
|
-
): void {
|
|
2079
|
-
// Build visible groups: active group expanded + swimlane group as compact pill
|
|
2080
|
-
let visibleGroups: TagGroup[];
|
|
2081
|
-
if (activeGroupName) {
|
|
2082
|
-
const activeGroup = tagGroups.filter(
|
|
2083
|
-
(g) => g.name.toLowerCase() === activeGroupName.toLowerCase()
|
|
2084
|
-
);
|
|
2085
|
-
const swimlaneGroup =
|
|
2086
|
-
currentSwimlaneGroup &&
|
|
2087
|
-
currentSwimlaneGroup.toLowerCase() !== activeGroupName.toLowerCase()
|
|
2088
|
-
? tagGroups.filter(
|
|
2089
|
-
(g) => g.name.toLowerCase() === currentSwimlaneGroup.toLowerCase()
|
|
2090
|
-
)
|
|
2091
|
-
: [];
|
|
2092
|
-
visibleGroups = [...swimlaneGroup, ...activeGroup];
|
|
2093
|
-
} else {
|
|
2094
|
-
visibleGroups = tagGroups;
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
// Build set of used tag values per group from resolved tasks
|
|
2098
|
-
const usedValues = new Map<string, Set<string>>();
|
|
2099
|
-
if (resolvedTasks) {
|
|
2100
|
-
for (const group of visibleGroups) {
|
|
2101
|
-
const key = tagAttrKey(group.name);
|
|
2102
|
-
const used = new Set<string>();
|
|
2103
|
-
for (const rt of resolvedTasks) {
|
|
2104
|
-
const val = rt.effectiveMetadata[key];
|
|
2105
|
-
if (val) used.add(val.toLowerCase());
|
|
2106
|
-
}
|
|
2107
|
-
usedValues.set(key, used);
|
|
2108
|
-
}
|
|
2109
|
-
}
|
|
2110
|
-
|
|
2111
|
-
// Filter entries to only those used in the current view
|
|
2112
|
-
const filteredEntries = new Map<string, readonly TagEntry[]>();
|
|
2113
|
-
for (const group of visibleGroups) {
|
|
2114
|
-
const key = tagAttrKey(group.name);
|
|
2115
|
-
const used = usedValues.get(key);
|
|
2116
|
-
if (used && used.size > 0) {
|
|
2117
|
-
filteredEntries.set(
|
|
2118
|
-
key,
|
|
2119
|
-
group.entries.filter((e) => used.has(e.value.toLowerCase()))
|
|
2120
|
-
);
|
|
2121
|
-
} else {
|
|
2122
|
-
filteredEntries.set(key, group.entries);
|
|
2123
|
-
}
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
// Compute per-group widths
|
|
2127
|
-
const groupWidths: number[] = [];
|
|
2128
|
-
let totalW = 0;
|
|
2129
|
-
for (const group of visibleGroups) {
|
|
2130
|
-
const isActive =
|
|
2131
|
-
activeGroupName?.toLowerCase() === group.name.toLowerCase();
|
|
2132
|
-
const isSwimlane =
|
|
2133
|
-
currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase();
|
|
2134
|
-
const showIcon = !legendViewMode && tagGroups.length > 0;
|
|
2135
|
-
const iconReserve = showIcon && isActive ? LEGEND_ICON_W : 0;
|
|
2136
|
-
const pillW =
|
|
2137
|
-
measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) +
|
|
2138
|
-
LEGEND_PILL_PAD +
|
|
2139
|
-
iconReserve;
|
|
2140
|
-
let groupW = pillW;
|
|
2141
|
-
if (isActive) {
|
|
2142
|
-
const entries =
|
|
2143
|
-
filteredEntries.get(tagAttrKey(group.name)) ?? group.entries;
|
|
2144
|
-
let entriesW = 0;
|
|
2145
|
-
for (const entry of entries) {
|
|
2146
|
-
entriesW +=
|
|
2147
|
-
LEGEND_DOT_R * 2 +
|
|
2148
|
-
LEGEND_ENTRY_DOT_GAP +
|
|
2149
|
-
measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
|
|
2150
|
-
LEGEND_ENTRY_TRAIL;
|
|
2151
|
-
}
|
|
2152
|
-
groupW = LEGEND_CAPSULE_PAD * 2 + pillW + 4 + entriesW;
|
|
2153
|
-
} else if (isSwimlane && !isActive) {
|
|
2154
|
-
// Compact swimlane pill: name + highlighted icon, no entries
|
|
2155
|
-
groupW = pillW;
|
|
2156
|
-
}
|
|
2157
|
-
groupWidths.push(groupW);
|
|
2158
|
-
totalW += groupW;
|
|
2159
|
-
}
|
|
2160
|
-
totalW += Math.max(0, (visibleGroups.length - 1) * LEGEND_GROUP_GAP);
|
|
2161
|
-
|
|
2162
|
-
// Controls group width — replaces standalone critical path pill
|
|
2163
|
-
const hasControls = hasCriticalPath || hasDependencies;
|
|
2164
|
-
const controlsToggleLabels: Array<{ label: string }> = [];
|
|
2165
|
-
if (hasCriticalPath) controlsToggleLabels.push({ label: 'Critical Path' });
|
|
2166
|
-
if (hasDependencies) controlsToggleLabels.push({ label: 'Dependencies' });
|
|
2167
|
-
if (hasControls) {
|
|
2168
|
-
if (visibleGroups.length > 0) totalW += LEGEND_GROUP_GAP;
|
|
2169
|
-
totalW += controlsExpanded
|
|
2170
|
-
? controlsGroupCapsuleWidth(controlsToggleLabels)
|
|
2171
|
-
: LEGEND_GEAR_PILL_W;
|
|
2172
|
-
}
|
|
2173
|
-
|
|
2174
|
-
// Center over full container (matching title centering)
|
|
2175
|
-
const containerWidth = chartLeftMargin + chartInnerWidth + RIGHT_MARGIN;
|
|
2176
|
-
const legendX = (containerWidth - totalW) / 2;
|
|
2177
|
-
|
|
2178
|
-
const legendRow = svg
|
|
2179
|
-
.append('g')
|
|
2180
|
-
.attr('class', 'gantt-tag-legend-container')
|
|
2181
|
-
.attr('transform', `translate(${legendX}, ${legendY})`);
|
|
2182
|
-
|
|
2183
|
-
// Render tag groups via centralized legend system
|
|
2184
|
-
if (visibleGroups.length > 0) {
|
|
2185
|
-
const showIcon = !legendViewMode && tagGroups.length > 0;
|
|
2186
|
-
const iconReserve = showIcon ? LEGEND_ICON_W : 0;
|
|
2187
|
-
|
|
2188
|
-
// Build groups with filtered entries
|
|
2189
|
-
const legendGroups = visibleGroups.map((g) => {
|
|
2190
|
-
const key = g.name.toLowerCase();
|
|
2191
|
-
const entries = filteredEntries.get(key) ?? g.entries;
|
|
2192
|
-
return {
|
|
2193
|
-
name: g.name,
|
|
2194
|
-
entries: entries.map((e) => ({ value: e.value, color: e.color })),
|
|
2195
|
-
};
|
|
2196
|
-
});
|
|
2197
|
-
|
|
2198
|
-
const controlsToggles = buildControlsToggles(
|
|
2199
|
-
hasCriticalPath,
|
|
2200
|
-
criticalPathActive,
|
|
2201
|
-
hasDependencies,
|
|
2202
|
-
dependenciesActive
|
|
2203
|
-
);
|
|
2204
|
-
|
|
2205
|
-
let tagGroupsW =
|
|
2206
|
-
// In-bounds: groupWidths has one entry per visibleGroups item.
|
|
2207
|
-
visibleGroups.reduce((s, _, i) => s + groupWidths[i]!, 0) +
|
|
2208
|
-
Math.max(0, (visibleGroups.length - 1) * LEGEND_GROUP_GAP);
|
|
2209
|
-
// Add controls group space to the renderLegendD3 container width
|
|
2210
|
-
if (hasControls) {
|
|
2211
|
-
if (visibleGroups.length > 0) tagGroupsW += LEGEND_GROUP_GAP;
|
|
2212
|
-
tagGroupsW += controlsExpanded
|
|
2213
|
-
? controlsGroupCapsuleWidth(controlsToggleLabels)
|
|
2214
|
-
: LEGEND_GEAR_PILL_W;
|
|
2215
|
-
}
|
|
2216
|
-
const tagGroupG = legendRow.append('g');
|
|
2217
|
-
|
|
2218
|
-
const legendCallbacks: LegendCallbacks = {
|
|
2219
|
-
...(onToggle !== undefined && { onGroupToggle: onToggle }),
|
|
2220
|
-
...(onToggleControlsExpand !== undefined && {
|
|
2221
|
-
onControlsExpand: onToggleControlsExpand,
|
|
2222
|
-
}),
|
|
2223
|
-
...(onControlsToggle !== undefined && { onControlsToggle }),
|
|
2224
|
-
onEntryHover: (groupName, entryValue) => {
|
|
2225
|
-
const tagKey = tagAttrKey(groupName);
|
|
2226
|
-
if (entryValue) {
|
|
2227
|
-
const ev = entryValue.toLowerCase();
|
|
2228
|
-
chartG
|
|
2229
|
-
.selectAll<SVGGElement, unknown>('.gantt-task')
|
|
2230
|
-
.each(function () {
|
|
2231
|
-
const el = d3Selection.select(this);
|
|
2232
|
-
el.attr(
|
|
2233
|
-
'opacity',
|
|
2234
|
-
el.attr(`data-tag-${tagKey}`) === ev ? 1 : FADE_OPACITY
|
|
2235
|
-
);
|
|
2236
|
-
});
|
|
2237
|
-
chartG
|
|
2238
|
-
.selectAll<SVGElement, unknown>('.gantt-milestone')
|
|
2239
|
-
.attr('opacity', FADE_OPACITY);
|
|
2240
|
-
chartG
|
|
2241
|
-
.selectAll<
|
|
2242
|
-
SVGElement,
|
|
2243
|
-
unknown
|
|
2244
|
-
>('.gantt-group-bar, .gantt-group-summary')
|
|
2245
|
-
.attr('opacity', FADE_OPACITY);
|
|
2246
|
-
svg
|
|
2247
|
-
.selectAll<SVGTextElement, unknown>('.gantt-task-label')
|
|
2248
|
-
.each(function () {
|
|
2249
|
-
const el = d3Selection.select(this);
|
|
2250
|
-
el.attr(
|
|
2251
|
-
'opacity',
|
|
2252
|
-
el.attr(`data-tag-${tagKey}`) === ev ? 1 : FADE_OPACITY
|
|
2253
|
-
);
|
|
2254
|
-
});
|
|
2255
|
-
svg
|
|
2256
|
-
.selectAll<SVGGElement, unknown>('.gantt-group-label')
|
|
2257
|
-
.attr('opacity', FADE_OPACITY);
|
|
2258
|
-
svg
|
|
2259
|
-
.selectAll<SVGGElement, unknown>('.gantt-lane-header')
|
|
2260
|
-
.each(function () {
|
|
2261
|
-
const el = d3Selection.select(this);
|
|
2262
|
-
el.attr(
|
|
2263
|
-
'opacity',
|
|
2264
|
-
el.attr(`data-tag-${tagKey}`) === ev ? 1 : FADE_OPACITY
|
|
2265
|
-
);
|
|
2266
|
-
});
|
|
2267
|
-
chartG
|
|
2268
|
-
.selectAll<
|
|
2269
|
-
SVGElement,
|
|
2270
|
-
unknown
|
|
2271
|
-
>('.gantt-lane-band, .gantt-lane-accent')
|
|
2272
|
-
.attr('opacity', FADE_OPACITY);
|
|
2273
|
-
} else {
|
|
2274
|
-
if (criticalPathActive) {
|
|
2275
|
-
applyCriticalPathHighlight(svg, chartG);
|
|
2276
|
-
} else {
|
|
2277
|
-
resetHighlightAll(svg, chartG);
|
|
2278
|
-
}
|
|
2279
|
-
}
|
|
2280
|
-
},
|
|
2281
|
-
onGroupRendered: (groupName, groupEl, _isActive) => {
|
|
2282
|
-
// Add swimlane icon and data attributes
|
|
2283
|
-
const group = visibleGroups.find((g) => g.name === groupName);
|
|
2284
|
-
if (group) {
|
|
2285
|
-
groupEl
|
|
2286
|
-
.attr('data-tag-group', group.name)
|
|
2287
|
-
.attr('data-line-number', String(group.lineNumber));
|
|
2288
|
-
}
|
|
2289
|
-
if (showIcon && _isActive) {
|
|
2290
|
-
const isSwimlane =
|
|
2291
|
-
currentSwimlaneGroup?.toLowerCase() === groupName.toLowerCase();
|
|
2292
|
-
const textW =
|
|
2293
|
-
measureLegendText(groupName, LEGEND_PILL_FONT_SIZE) +
|
|
2294
|
-
LEGEND_PILL_PAD;
|
|
2295
|
-
const pillXOff = LEGEND_CAPSULE_PAD;
|
|
2296
|
-
const iconX = pillXOff + textW + 3;
|
|
2297
|
-
const iconY = (LEGEND_HEIGHT - 10) / 2;
|
|
2298
|
-
const iconEl = drawSwimlaneIcon(
|
|
2299
|
-
groupEl,
|
|
2300
|
-
iconX,
|
|
2301
|
-
iconY,
|
|
2302
|
-
isSwimlane,
|
|
2303
|
-
palette
|
|
2304
|
-
);
|
|
2305
|
-
iconEl.append('title').text(`Group by ${groupName}`);
|
|
2306
|
-
iconEl.style('cursor', 'pointer').on('click', (event: Event) => {
|
|
2307
|
-
event.stopPropagation();
|
|
2308
|
-
if (onSwimlaneChange) {
|
|
2309
|
-
onSwimlaneChange(
|
|
2310
|
-
currentSwimlaneGroup?.toLowerCase() === groupName.toLowerCase()
|
|
2311
|
-
? null
|
|
2312
|
-
: groupName
|
|
2313
|
-
);
|
|
2314
|
-
}
|
|
2315
|
-
});
|
|
2316
|
-
}
|
|
2317
|
-
},
|
|
2318
|
-
};
|
|
2319
|
-
|
|
2320
|
-
renderIntegratedLegend(tagGroupG, {
|
|
2321
|
-
groups: legendGroups,
|
|
2322
|
-
palette,
|
|
2323
|
-
isDark,
|
|
2324
|
-
width: tagGroupsW,
|
|
2325
|
-
mode: exportMode ? 'export' : 'preview',
|
|
2326
|
-
capsulePillAddonWidth: iconReserve,
|
|
2327
|
-
...(controlsToggles.length > 0 && {
|
|
2328
|
-
controlsGroup: { toggles: controlsToggles },
|
|
2329
|
-
}),
|
|
2330
|
-
// 'app' suppresses the inline gear so the desktop ControlsStrip hosts the
|
|
2331
|
-
// Critical Path / Dependencies toggles instead (export keeps inline).
|
|
2332
|
-
...(controlsHost === 'app' && !exportMode && { controlsHost: 'app' }),
|
|
2333
|
-
activeGroup: activeGroupName,
|
|
2334
|
-
controlsExpanded,
|
|
2335
|
-
callbacks: legendCallbacks,
|
|
2336
|
-
});
|
|
2337
|
-
} else if (hasControls) {
|
|
2338
|
-
// No tag groups, but controls group needs rendering
|
|
2339
|
-
const controlsToggles = buildControlsToggles(
|
|
2340
|
-
hasCriticalPath,
|
|
2341
|
-
criticalPathActive,
|
|
2342
|
-
hasDependencies,
|
|
2343
|
-
dependenciesActive
|
|
2344
|
-
);
|
|
2345
|
-
|
|
2346
|
-
const tagGroupG = legendRow.append('g');
|
|
2347
|
-
renderIntegratedLegend(tagGroupG, {
|
|
2348
|
-
groups: [],
|
|
2349
|
-
palette,
|
|
2350
|
-
isDark,
|
|
2351
|
-
width: totalW,
|
|
2352
|
-
mode: exportMode ? 'export' : 'preview',
|
|
2353
|
-
controlsGroup: { toggles: controlsToggles },
|
|
2354
|
-
...(controlsHost === 'app' && !exportMode && { controlsHost: 'app' }),
|
|
2355
|
-
activeGroup: null,
|
|
2356
|
-
controlsExpanded,
|
|
2357
|
-
callbacks: {
|
|
2358
|
-
...(onToggleControlsExpand !== undefined && {
|
|
2359
|
-
onControlsExpand: onToggleControlsExpand,
|
|
2360
|
-
}),
|
|
2361
|
-
...(onControlsToggle !== undefined && { onControlsToggle }),
|
|
2362
|
-
},
|
|
2363
|
-
});
|
|
2364
|
-
}
|
|
2365
|
-
|
|
2366
|
-
// Apply persistent critical path highlighting when active
|
|
2367
|
-
if (criticalPathActive) {
|
|
2368
|
-
applyCriticalPathHighlight(svg, chartG);
|
|
2369
|
-
}
|
|
2370
|
-
}
|
|
2371
|
-
|
|
2372
|
-
// ── Era & Marker Rendering ──────────────────────────────────
|
|
2373
|
-
|
|
2374
|
-
const ERA_COLORS = ['#3b6ea5', '#5b9357', '#c9a227', '#cc7a33', '#7d5ba6'];
|
|
2375
|
-
|
|
2376
|
-
function renderErasAndMarkers(
|
|
2377
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
2378
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
2379
|
-
resolved: ResolvedSchedule,
|
|
2380
|
-
xScale: d3Scale.ScaleLinear<number, number>,
|
|
2381
|
-
innerHeight: number,
|
|
2382
|
-
palette: PaletteColors,
|
|
2383
|
-
eraLabelY: number,
|
|
2384
|
-
markerLabelY: number
|
|
2385
|
-
): void {
|
|
2386
|
-
// Eras: semi-transparent background bands
|
|
2387
|
-
for (let i = 0; i < resolved.eras.length; i++) {
|
|
2388
|
-
const era = resolved.eras[i];
|
|
2389
|
-
if (!era) continue; // In-bounds by loop guard; appeases TS.
|
|
2390
|
-
// In-bounds: ERA_COLORS has 5 entries and i % 5 is always 0-4.
|
|
2391
|
-
const color = era.color || ERA_COLORS[i % ERA_COLORS.length]!;
|
|
2392
|
-
const sx = xScale(parseDateToFractionalYear(era.startDate));
|
|
2393
|
-
const ex = xScale(parseDateToFractionalYear(era.endDate));
|
|
2394
|
-
if (ex <= sx) continue;
|
|
2395
|
-
|
|
2396
|
-
const baseEraOpacity = 0.08;
|
|
2397
|
-
const hoverEraOpacity = 0.16;
|
|
2398
|
-
const eraStartDate = parseDateStringToDate(era.startDate);
|
|
2399
|
-
const eraEndDate = parseDateStringToDate(era.endDate);
|
|
2400
|
-
|
|
2401
|
-
const eraG = g
|
|
2402
|
-
.append('g')
|
|
2403
|
-
.attr('class', 'gantt-era-group')
|
|
2404
|
-
.attr('data-line-number', String(era.lineNumber));
|
|
2405
|
-
|
|
2406
|
-
const eraRect = eraG
|
|
2407
|
-
.append('rect')
|
|
2408
|
-
.attr('class', 'gantt-era')
|
|
2409
|
-
.attr('x', sx)
|
|
2410
|
-
.attr('y', 0)
|
|
2411
|
-
.attr('width', ex - sx)
|
|
2412
|
-
.attr('height', innerHeight)
|
|
2413
|
-
.attr('fill', color)
|
|
2414
|
-
.attr('opacity', baseEraOpacity);
|
|
2415
|
-
|
|
2416
|
-
// Era label sits in the era row (one row above markers when both present).
|
|
2417
|
-
// Truncate to fit the era's own span (with breathing room) so labels stay
|
|
2418
|
-
// confined to their tinted band. Full text is restored on hover.
|
|
2419
|
-
const eraLabelMaxW = Math.max(0, ex - sx - 8);
|
|
2420
|
-
const eraDisplayLabel = truncateLegendText(era.label, 10, eraLabelMaxW);
|
|
2421
|
-
const eraTruncated = eraDisplayLabel !== era.label;
|
|
2422
|
-
const eraLabel = eraG
|
|
2423
|
-
.append('text')
|
|
2424
|
-
.attr('class', 'gantt-era-label')
|
|
2425
|
-
.attr('x', (sx + ex) / 2)
|
|
2426
|
-
.attr('y', eraLabelY)
|
|
2427
|
-
.attr('text-anchor', 'middle')
|
|
2428
|
-
.attr('font-size', '10px')
|
|
2429
|
-
.attr('fill', color)
|
|
2430
|
-
.attr('opacity', 0.7)
|
|
2431
|
-
.style('cursor', 'pointer')
|
|
2432
|
-
.text(eraDisplayLabel);
|
|
2433
|
-
|
|
2434
|
-
eraG
|
|
2435
|
-
.on('mouseenter', () => {
|
|
2436
|
-
// Fade everything
|
|
2437
|
-
g.selectAll<SVGGElement, unknown>('.gantt-task').attr(
|
|
2438
|
-
'opacity',
|
|
2439
|
-
FADE_OPACITY
|
|
2440
|
-
);
|
|
2441
|
-
g.selectAll<SVGElement, unknown>('.gantt-milestone').attr(
|
|
2442
|
-
'opacity',
|
|
2443
|
-
FADE_OPACITY
|
|
2444
|
-
);
|
|
2445
|
-
g.selectAll<SVGElement, unknown>(
|
|
2446
|
-
'.gantt-group-bar, .gantt-group-summary'
|
|
2447
|
-
).attr('opacity', FADE_OPACITY);
|
|
2448
|
-
svg
|
|
2449
|
-
.selectAll<SVGGElement, unknown>('.gantt-group-label')
|
|
2450
|
-
.attr('opacity', FADE_OPACITY);
|
|
2451
|
-
svg
|
|
2452
|
-
.selectAll<SVGTextElement, unknown>('.gantt-task-label')
|
|
2453
|
-
.attr('opacity', FADE_OPACITY);
|
|
2454
|
-
svg
|
|
2455
|
-
.selectAll<SVGGElement, unknown>('.gantt-lane-header')
|
|
2456
|
-
.attr('opacity', FADE_OPACITY);
|
|
2457
|
-
g.selectAll<SVGElement, unknown>(
|
|
2458
|
-
'.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
|
|
2459
|
-
).attr('opacity', FADE_OPACITY);
|
|
2460
|
-
g.selectAll<SVGElement, unknown>(
|
|
2461
|
-
'.gantt-dep-arrow, .gantt-dep-arrowhead'
|
|
2462
|
-
).attr('opacity', FADE_OPACITY);
|
|
2463
|
-
g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
|
|
2464
|
-
'opacity',
|
|
2465
|
-
FADE_OPACITY
|
|
2466
|
-
);
|
|
2467
|
-
// Highlight this era
|
|
2468
|
-
eraRect.attr('opacity', hoverEraOpacity);
|
|
2469
|
-
if (eraTruncated) eraLabel.text(era.label);
|
|
2470
|
-
showGanttDateIndicators(
|
|
2471
|
-
g,
|
|
2472
|
-
xScale,
|
|
2473
|
-
eraStartDate,
|
|
2474
|
-
eraEndDate,
|
|
2475
|
-
innerHeight,
|
|
2476
|
-
color
|
|
2477
|
-
);
|
|
2478
|
-
})
|
|
2479
|
-
.on('mouseleave', () => {
|
|
2480
|
-
resetHighlight(g, svg);
|
|
2481
|
-
eraRect.attr('opacity', baseEraOpacity);
|
|
2482
|
-
if (eraTruncated) eraLabel.text(eraDisplayLabel);
|
|
2483
|
-
hideGanttDateIndicators(g);
|
|
2484
|
-
});
|
|
2485
|
-
}
|
|
2486
|
-
|
|
2487
|
-
// Markers: label → diamond → dashed line (same layout as timeline).
|
|
2488
|
-
// Pre-compute x positions so each marker can size its label based on the
|
|
2489
|
-
// distance to its nearest neighbor — symmetric truncation lets crowded
|
|
2490
|
-
// labels meet in the middle of the gap rather than overlapping.
|
|
2491
|
-
const markerXs = resolved.markers.map((m) =>
|
|
2492
|
-
xScale(parseDateToFractionalYear(m.date))
|
|
2493
|
-
);
|
|
2494
|
-
// d3 scale.range() returns a 2-element array.
|
|
2495
|
-
const innerWidth = xScale.range()[1]!;
|
|
2496
|
-
for (let i = 0; i < resolved.markers.length; i++) {
|
|
2497
|
-
const marker = resolved.markers[i];
|
|
2498
|
-
if (!marker) continue; // In-bounds by loop guard; appeases TS.
|
|
2499
|
-
const color = marker.color || palette.accent || '#3a9188';
|
|
2500
|
-
// In-bounds: markerXs.length === resolved.markers.length.
|
|
2501
|
-
const mx = markerXs[i]!;
|
|
2502
|
-
const markerDate = parseDateStringToDate(marker.date);
|
|
2503
|
-
const diamondSize = 5;
|
|
2504
|
-
const labelY = markerLabelY;
|
|
2505
|
-
const diamondY = -2; // below date indicator labels
|
|
2506
|
-
|
|
2507
|
-
// Available label width: distance to nearest neighbor (or chart edge),
|
|
2508
|
-
// minus a small padding. Both labels truncate symmetrically and meet
|
|
2509
|
-
// in the middle of the gap.
|
|
2510
|
-
let nearestDist = Math.min(mx, innerWidth - mx);
|
|
2511
|
-
for (let j = 0; j < markerXs.length; j++) {
|
|
2512
|
-
if (j === i) continue;
|
|
2513
|
-
// In-bounds by loop guard.
|
|
2514
|
-
const d = Math.abs(markerXs[j]! - mx);
|
|
2515
|
-
if (d < nearestDist) nearestDist = d;
|
|
2516
|
-
}
|
|
2517
|
-
const markerLabelMaxW = Math.max(0, nearestDist - 8);
|
|
2518
|
-
const markerDisplayLabel = truncateLegendText(
|
|
2519
|
-
marker.label,
|
|
2520
|
-
11,
|
|
2521
|
-
markerLabelMaxW
|
|
2522
|
-
);
|
|
2523
|
-
const markerTruncated = markerDisplayLabel !== marker.label;
|
|
2524
|
-
|
|
2525
|
-
const markerG = g
|
|
2526
|
-
.append('g')
|
|
2527
|
-
.attr('class', 'gantt-marker-group')
|
|
2528
|
-
.attr('data-line-number', String(marker.lineNumber))
|
|
2529
|
-
.style('cursor', 'pointer');
|
|
2530
|
-
|
|
2531
|
-
// Invisible hit rect for easier clicking/hovering
|
|
2532
|
-
markerG
|
|
2533
|
-
.append('rect')
|
|
2534
|
-
.attr('x', mx - 40)
|
|
2535
|
-
.attr('y', labelY - 12)
|
|
2536
|
-
.attr('width', 80)
|
|
2537
|
-
.attr('height', innerHeight - labelY + 12)
|
|
2538
|
-
.attr('fill', 'transparent')
|
|
2539
|
-
.attr('pointer-events', 'all');
|
|
2540
|
-
|
|
2541
|
-
// Label above diamond
|
|
2542
|
-
const markerLabelEl = markerG
|
|
2543
|
-
.append('text')
|
|
2544
|
-
.attr('class', 'gantt-marker-label')
|
|
2545
|
-
.attr('x', mx)
|
|
2546
|
-
.attr('y', labelY)
|
|
2547
|
-
.attr('text-anchor', 'middle')
|
|
2548
|
-
.attr('font-size', '11px')
|
|
2549
|
-
.attr('font-weight', '600')
|
|
2550
|
-
.attr('fill', color)
|
|
2551
|
-
.text(markerDisplayLabel);
|
|
2552
|
-
|
|
2553
|
-
// Diamond below label
|
|
2554
|
-
markerG
|
|
2555
|
-
.append('path')
|
|
2556
|
-
.attr(
|
|
2557
|
-
'd',
|
|
2558
|
-
`M${mx},${diamondY - diamondSize} l${diamondSize},${diamondSize} l-${diamondSize},${diamondSize} l-${diamondSize},-${diamondSize} Z`
|
|
2559
|
-
)
|
|
2560
|
-
.attr('fill', color)
|
|
2561
|
-
.attr('opacity', 0.9);
|
|
2562
|
-
|
|
2563
|
-
// Dashed line from diamond down
|
|
2564
|
-
markerG
|
|
2565
|
-
.append('line')
|
|
2566
|
-
.attr('class', 'gantt-marker')
|
|
2567
|
-
.attr('x1', mx)
|
|
2568
|
-
.attr('y1', diamondY + diamondSize)
|
|
2569
|
-
.attr('x2', mx)
|
|
2570
|
-
.attr('y2', innerHeight)
|
|
2571
|
-
.attr('stroke', color)
|
|
2572
|
-
.attr('stroke-width', 1.5)
|
|
2573
|
-
.attr('stroke-dasharray', '6 4')
|
|
2574
|
-
.attr('opacity', 0.5);
|
|
2575
|
-
|
|
2576
|
-
// Hide marker line/diamond on hover but keep label visible
|
|
2577
|
-
const markerLine = markerG.select('.gantt-marker');
|
|
2578
|
-
const markerDiamond = markerG.select('path');
|
|
2579
|
-
markerG
|
|
2580
|
-
.on('mouseenter', () => {
|
|
2581
|
-
// Fade everything
|
|
2582
|
-
g.selectAll<SVGGElement, unknown>('.gantt-task').attr(
|
|
2583
|
-
'opacity',
|
|
2584
|
-
FADE_OPACITY
|
|
2585
|
-
);
|
|
2586
|
-
g.selectAll<SVGElement, unknown>('.gantt-milestone').attr(
|
|
2587
|
-
'opacity',
|
|
2588
|
-
FADE_OPACITY
|
|
2589
|
-
);
|
|
2590
|
-
g.selectAll<SVGElement, unknown>(
|
|
2591
|
-
'.gantt-group-bar, .gantt-group-summary'
|
|
2592
|
-
).attr('opacity', FADE_OPACITY);
|
|
2593
|
-
svg
|
|
2594
|
-
.selectAll<SVGGElement, unknown>('.gantt-group-label')
|
|
2595
|
-
.attr('opacity', FADE_OPACITY);
|
|
2596
|
-
svg
|
|
2597
|
-
.selectAll<SVGTextElement, unknown>('.gantt-task-label')
|
|
2598
|
-
.attr('opacity', FADE_OPACITY);
|
|
2599
|
-
svg
|
|
2600
|
-
.selectAll<SVGGElement, unknown>('.gantt-lane-header')
|
|
2601
|
-
.attr('opacity', FADE_OPACITY);
|
|
2602
|
-
g.selectAll<SVGElement, unknown>(
|
|
2603
|
-
'.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
|
|
2604
|
-
).attr('opacity', FADE_OPACITY);
|
|
2605
|
-
g.selectAll<SVGElement, unknown>(
|
|
2606
|
-
'.gantt-dep-arrow, .gantt-dep-arrowhead'
|
|
2607
|
-
).attr('opacity', FADE_OPACITY);
|
|
2608
|
-
g.selectAll<SVGElement, unknown>('.gantt-era-group').attr(
|
|
2609
|
-
'opacity',
|
|
2610
|
-
FADE_OPACITY
|
|
2611
|
-
);
|
|
2612
|
-
// Fade other markers but keep this one highlighted
|
|
2613
|
-
g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
|
|
2614
|
-
'opacity',
|
|
2615
|
-
FADE_OPACITY
|
|
2616
|
-
);
|
|
2617
|
-
markerG.attr('opacity', 1);
|
|
2618
|
-
markerLine.attr('opacity', 0.8);
|
|
2619
|
-
markerDiamond.attr('opacity', 0);
|
|
2620
|
-
if (markerTruncated) markerLabelEl.text(marker.label);
|
|
2621
|
-
showGanttDateIndicators(
|
|
2622
|
-
g,
|
|
2623
|
-
xScale,
|
|
2624
|
-
markerDate,
|
|
2625
|
-
null,
|
|
2626
|
-
innerHeight,
|
|
2627
|
-
color,
|
|
2628
|
-
{ skipStartLine: true }
|
|
2629
|
-
);
|
|
2630
|
-
})
|
|
2631
|
-
.on('mouseleave', () => {
|
|
2632
|
-
resetHighlight(g, svg);
|
|
2633
|
-
markerLine.attr('opacity', 0.5);
|
|
2634
|
-
markerDiamond.attr('opacity', 0.9);
|
|
2635
|
-
if (markerTruncated) markerLabelEl.text(markerDisplayLabel);
|
|
2636
|
-
hideGanttDateIndicators(g);
|
|
2637
|
-
});
|
|
2638
|
-
}
|
|
2639
|
-
}
|
|
2640
|
-
|
|
2641
|
-
// ── Sprint band rendering ──────────────────────────────────
|
|
2642
|
-
|
|
2643
|
-
const SPRINT_BAND_OPACITY = 0.05;
|
|
2644
|
-
const SPRINT_HOVER_OPACITY = 0.12;
|
|
2645
|
-
const SPRINT_BOUNDARY_OPACITY = 0.3;
|
|
2646
|
-
|
|
2647
|
-
function renderSprintBands(
|
|
2648
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
2649
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
2650
|
-
resolved: ResolvedSchedule,
|
|
2651
|
-
xScale: d3Scale.ScaleLinear<number, number>,
|
|
2652
|
-
innerHeight: number,
|
|
2653
|
-
palette: PaletteColors
|
|
2654
|
-
): void {
|
|
2655
|
-
if (resolved.sprints.length === 0) return;
|
|
2656
|
-
|
|
2657
|
-
// When both eras and sprints defined, eras win — don't render sprint bands
|
|
2658
|
-
if (resolved.eras.length > 0) return;
|
|
2659
|
-
|
|
2660
|
-
const bandColor = palette.textMuted || palette.text || '#888';
|
|
2661
|
-
|
|
2662
|
-
// Chart content area starts at x=0 in the g coordinate space
|
|
2663
|
-
const chartMinX = 0;
|
|
2664
|
-
|
|
2665
|
-
for (let i = 0; i < resolved.sprints.length; i++) {
|
|
2666
|
-
const sprint = resolved.sprints[i];
|
|
2667
|
-
if (!sprint) continue; // In-bounds by loop guard; appeases TS.
|
|
2668
|
-
const rawSx = xScale(dateToFractionalYear(sprint.startDate));
|
|
2669
|
-
const rawEx = xScale(dateToFractionalYear(sprint.endDate));
|
|
2670
|
-
if (rawEx <= rawSx) continue;
|
|
2671
|
-
|
|
2672
|
-
// Clip to chart content area — prevent bands from extending into swimlane margin
|
|
2673
|
-
const sx = Math.max(rawSx, chartMinX);
|
|
2674
|
-
const ex = rawEx;
|
|
2675
|
-
const bandWidth = ex - sx;
|
|
2676
|
-
if (bandWidth <= 0) continue;
|
|
2677
|
-
|
|
2678
|
-
const sprintG = g
|
|
2679
|
-
.append('g')
|
|
2680
|
-
.attr('class', 'gantt-sprint-group')
|
|
2681
|
-
.style('cursor', 'pointer');
|
|
2682
|
-
|
|
2683
|
-
// Alternating shaded bands (consistent by sprint number)
|
|
2684
|
-
const sprintRect = sprintG
|
|
2685
|
-
.append('rect')
|
|
2686
|
-
.attr('class', 'gantt-sprint-band')
|
|
2687
|
-
.attr('x', sx)
|
|
2688
|
-
.attr('y', 0)
|
|
2689
|
-
.attr('width', bandWidth)
|
|
2690
|
-
.attr('height', innerHeight)
|
|
2691
|
-
.attr('fill', bandColor)
|
|
2692
|
-
.attr('opacity', sprint.number % 2 === 0 ? SPRINT_BAND_OPACITY : 0);
|
|
2693
|
-
|
|
2694
|
-
// Invisible hit rect for hover on unshaded bands
|
|
2695
|
-
if (sprint.number % 2 !== 0) {
|
|
2696
|
-
sprintG
|
|
2697
|
-
.append('rect')
|
|
2698
|
-
.attr('x', sx)
|
|
2699
|
-
.attr('y', 0)
|
|
2700
|
-
.attr('width', bandWidth)
|
|
2701
|
-
.attr('height', innerHeight)
|
|
2702
|
-
.attr('fill', 'transparent');
|
|
2703
|
-
}
|
|
2704
|
-
|
|
2705
|
-
// Persistent sprint number label — always visible, turns into "Sprint X" on hover
|
|
2706
|
-
const sprintLabel = sprintG
|
|
2707
|
-
.append('text')
|
|
2708
|
-
.attr('class', 'gantt-sprint-label')
|
|
2709
|
-
.attr('x', (sx + ex) / 2)
|
|
2710
|
-
.attr('y', -22)
|
|
2711
|
-
.attr('text-anchor', 'middle')
|
|
2712
|
-
.attr('font-size', '10px')
|
|
2713
|
-
.attr('font-weight', '600')
|
|
2714
|
-
.attr('fill', bandColor)
|
|
2715
|
-
.attr('opacity', 0.4)
|
|
2716
|
-
.text(String(sprint.number));
|
|
2717
|
-
|
|
2718
|
-
// Dashed boundary line at sprint start (skip for first visible band)
|
|
2719
|
-
if (i > 0 && rawSx >= chartMinX) {
|
|
2720
|
-
sprintG
|
|
2721
|
-
.append('line')
|
|
2722
|
-
.attr('class', 'gantt-sprint-boundary')
|
|
2723
|
-
.attr('x1', sx)
|
|
2724
|
-
.attr('y1', -6)
|
|
2725
|
-
.attr('x2', sx)
|
|
2726
|
-
.attr('y2', innerHeight)
|
|
2727
|
-
.attr('stroke', bandColor)
|
|
2728
|
-
.attr('stroke-width', 1)
|
|
2729
|
-
.attr('stroke-dasharray', '3 3')
|
|
2730
|
-
.attr('opacity', SPRINT_BOUNDARY_OPACITY);
|
|
2731
|
-
}
|
|
2732
|
-
|
|
2733
|
-
// Determine which tasks and groups overlap this sprint (start-inclusive, end-exclusive)
|
|
2734
|
-
const sprintStartMs = sprint.startDate.getTime();
|
|
2735
|
-
const sprintEndMs = sprint.endDate.getTime();
|
|
2736
|
-
const overlappingTaskIds = new Set<string>();
|
|
2737
|
-
for (const rt of resolved.tasks) {
|
|
2738
|
-
const taskStart = rt.startDate.getTime();
|
|
2739
|
-
const taskEnd = rt.endDate.getTime();
|
|
2740
|
-
// Task overlaps sprint if it starts before sprint ends AND ends after sprint starts
|
|
2741
|
-
if (taskStart < sprintEndMs && taskEnd > sprintStartMs) {
|
|
2742
|
-
overlappingTaskIds.add(rt.task.id);
|
|
2743
|
-
}
|
|
2744
|
-
// Milestones (zero duration): include if they fall within [sprintStart, sprintEnd)
|
|
2745
|
-
if (
|
|
2746
|
-
taskStart === taskEnd &&
|
|
2747
|
-
taskStart >= sprintStartMs &&
|
|
2748
|
-
taskStart < sprintEndMs
|
|
2749
|
-
) {
|
|
2750
|
-
overlappingTaskIds.add(rt.task.id);
|
|
2751
|
-
}
|
|
2752
|
-
}
|
|
2753
|
-
const overlappingGroupNames = new Set<string>();
|
|
2754
|
-
for (const rg of resolved.groups) {
|
|
2755
|
-
const gStart = rg.startDate.getTime();
|
|
2756
|
-
const gEnd = rg.endDate.getTime();
|
|
2757
|
-
if (gStart < sprintEndMs && gEnd > sprintStartMs) {
|
|
2758
|
-
overlappingGroupNames.add(rg.name);
|
|
2759
|
-
}
|
|
2760
|
-
}
|
|
2761
|
-
|
|
2762
|
-
// Hover: highlight band, keep overlapping tasks visible, show dates + sprint label
|
|
2763
|
-
const baseOpacity = sprint.number % 2 === 0 ? SPRINT_BAND_OPACITY : 0;
|
|
2764
|
-
sprintG
|
|
2765
|
-
.on('mouseenter', () => {
|
|
2766
|
-
// Dim tasks NOT in this sprint, keep overlapping ones visible
|
|
2767
|
-
g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
|
|
2768
|
-
const el = d3Selection.select(this);
|
|
2769
|
-
const id = el.attr('data-task-id');
|
|
2770
|
-
el.attr(
|
|
2771
|
-
'opacity',
|
|
2772
|
-
id && overlappingTaskIds.has(id) ? 1 : FADE_OPACITY
|
|
2773
|
-
);
|
|
2774
|
-
});
|
|
2775
|
-
g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
|
|
2776
|
-
const el = d3Selection.select(this);
|
|
2777
|
-
const id = el.attr('data-task-id');
|
|
2778
|
-
el.attr(
|
|
2779
|
-
'opacity',
|
|
2780
|
-
id && overlappingTaskIds.has(id) ? 1 : FADE_OPACITY
|
|
2781
|
-
);
|
|
2782
|
-
});
|
|
2783
|
-
svg
|
|
2784
|
-
.selectAll<SVGTextElement, unknown>('.gantt-task-label')
|
|
2785
|
-
.each(function () {
|
|
2786
|
-
const el = d3Selection.select(this);
|
|
2787
|
-
const id = el.attr('data-task-id');
|
|
2788
|
-
el.attr(
|
|
2789
|
-
'opacity',
|
|
2790
|
-
id && overlappingTaskIds.has(id) ? 1 : FADE_OPACITY
|
|
2791
|
-
);
|
|
2792
|
-
});
|
|
2793
|
-
g.selectAll<SVGElement, unknown>(
|
|
2794
|
-
'.gantt-group-bar, .gantt-group-summary'
|
|
2795
|
-
).each(function () {
|
|
2796
|
-
const el = d3Selection.select(this);
|
|
2797
|
-
const name = el.attr('data-group');
|
|
2798
|
-
el.attr(
|
|
2799
|
-
'opacity',
|
|
2800
|
-
name && overlappingGroupNames.has(name) ? 1 : FADE_OPACITY
|
|
2801
|
-
);
|
|
2802
|
-
});
|
|
2803
|
-
svg
|
|
2804
|
-
.selectAll<SVGGElement, unknown>('.gantt-group-label')
|
|
2805
|
-
.each(function () {
|
|
2806
|
-
const el = d3Selection.select(this);
|
|
2807
|
-
const name = el.attr('data-group');
|
|
2808
|
-
el.attr(
|
|
2809
|
-
'opacity',
|
|
2810
|
-
name && overlappingGroupNames.has(name) ? 1 : FADE_OPACITY
|
|
2811
|
-
);
|
|
2812
|
-
});
|
|
2813
|
-
svg
|
|
2814
|
-
.selectAll<SVGGElement, unknown>('.gantt-lane-header')
|
|
2815
|
-
.attr('opacity', FADE_OPACITY);
|
|
2816
|
-
g.selectAll<SVGElement, unknown>(
|
|
2817
|
-
'.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
|
|
2818
|
-
).attr('opacity', FADE_OPACITY);
|
|
2819
|
-
g.selectAll<SVGElement, unknown>(
|
|
2820
|
-
'.gantt-dep-arrow, .gantt-dep-arrowhead'
|
|
2821
|
-
).attr('opacity', FADE_OPACITY);
|
|
2822
|
-
g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
|
|
2823
|
-
'opacity',
|
|
2824
|
-
FADE_OPACITY
|
|
2825
|
-
);
|
|
2826
|
-
sprintRect.attr('opacity', SPRINT_HOVER_OPACITY);
|
|
2827
|
-
// Only show start date indicator if it's within the visible chart area
|
|
2828
|
-
const startVisible = rawSx >= chartMinX;
|
|
2829
|
-
if (startVisible) {
|
|
2830
|
-
showGanttDateIndicators(
|
|
2831
|
-
g,
|
|
2832
|
-
xScale,
|
|
2833
|
-
sprint.startDate,
|
|
2834
|
-
sprint.endDate,
|
|
2835
|
-
innerHeight,
|
|
2836
|
-
bandColor
|
|
2837
|
-
);
|
|
2838
|
-
} else {
|
|
2839
|
-
// Only show end date indicator (start is clipped/off-screen)
|
|
2840
|
-
showGanttDateIndicators(
|
|
2841
|
-
g,
|
|
2842
|
-
xScale,
|
|
2843
|
-
sprint.endDate,
|
|
2844
|
-
null,
|
|
2845
|
-
innerHeight,
|
|
2846
|
-
bandColor
|
|
2847
|
-
);
|
|
2848
|
-
}
|
|
2849
|
-
// Swap persistent label to full "Sprint X" on hover
|
|
2850
|
-
const accentColor = palette.accent || palette.text || bandColor;
|
|
2851
|
-
sprintLabel
|
|
2852
|
-
.text(`Sprint ${sprint.number}`)
|
|
2853
|
-
.attr('font-size', '13px')
|
|
2854
|
-
.attr('font-weight', '700')
|
|
2855
|
-
.attr('fill', accentColor)
|
|
2856
|
-
.attr('opacity', 1);
|
|
2857
|
-
})
|
|
2858
|
-
.on('mouseleave', () => {
|
|
2859
|
-
resetHighlight(g, svg);
|
|
2860
|
-
sprintRect.attr('opacity', baseOpacity);
|
|
2861
|
-
sprintLabel
|
|
2862
|
-
.text(String(sprint.number))
|
|
2863
|
-
.attr('font-size', '10px')
|
|
2864
|
-
.attr('font-weight', '600')
|
|
2865
|
-
.attr('fill', bandColor)
|
|
2866
|
-
.attr('opacity', 0.4);
|
|
2867
|
-
hideGanttDateIndicators(g);
|
|
2868
|
-
});
|
|
2869
|
-
}
|
|
2870
|
-
|
|
2871
|
-
// Boundary line at end of last sprint
|
|
2872
|
-
// In-bounds: early return above when sprints.length === 0.
|
|
2873
|
-
const lastSprint = resolved.sprints[resolved.sprints.length - 1]!;
|
|
2874
|
-
const lastEx = xScale(dateToFractionalYear(lastSprint.endDate));
|
|
2875
|
-
g.append('line')
|
|
2876
|
-
.attr('class', 'gantt-sprint-boundary')
|
|
2877
|
-
.attr('x1', lastEx)
|
|
2878
|
-
.attr('y1', -6)
|
|
2879
|
-
.attr('x2', lastEx)
|
|
2880
|
-
.attr('y2', innerHeight)
|
|
2881
|
-
.attr('stroke', bandColor)
|
|
2882
|
-
.attr('stroke-width', 1)
|
|
2883
|
-
.attr('stroke-dasharray', '3 3')
|
|
2884
|
-
.attr('opacity', SPRINT_BOUNDARY_OPACITY);
|
|
2885
|
-
}
|
|
2886
|
-
|
|
2887
|
-
/**
|
|
2888
|
-
* Parse a date string (YYYY, YYYY-MM, YYYY-MM-DD) to a Date object.
|
|
2889
|
-
* Used for eras and markers which store dates as strings.
|
|
2890
|
-
*/
|
|
2891
|
-
function parseDateStringToDate(s: string): Date {
|
|
2892
|
-
const parts = s.split('-').map((p) => parseInt(p, 10));
|
|
2893
|
-
// In-bounds: split() returns at least one element.
|
|
2894
|
-
const year = parts[0]!;
|
|
2895
|
-
const month = parts.length >= 2 ? parts[1]! - 1 : 0;
|
|
2896
|
-
const day = parts.length >= 3 ? parts[2]! : 1;
|
|
2897
|
-
return new Date(year, month, day);
|
|
2898
|
-
}
|
|
2899
|
-
|
|
2900
|
-
/**
|
|
2901
|
-
* Parse a date string (YYYY, YYYY-MM, YYYY-MM-DD) to fractional year.
|
|
2902
|
-
* Used for eras and markers which may have partial dates.
|
|
2903
|
-
*/
|
|
2904
|
-
function parseDateToFractionalYear(s: string): number {
|
|
2905
|
-
return dateToFractionalYear(parseDateStringToDate(s));
|
|
2906
|
-
}
|
|
2907
|
-
|
|
2908
|
-
// ── Dependency Hover Helpers ─────────────────────────────────
|
|
2909
|
-
|
|
2910
|
-
const FADE_OPACITY = 0.1;
|
|
2911
|
-
|
|
2912
|
-
function highlightDeps(
|
|
2913
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
2914
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
2915
|
-
taskId: string,
|
|
2916
|
-
resolved: ResolvedSchedule
|
|
2917
|
-
): void {
|
|
2918
|
-
// Find immediate predecessors and successors
|
|
2919
|
-
const related = new Set<string>([taskId]);
|
|
2920
|
-
const task = resolved.tasks.find((t) => t.task.id === taskId);
|
|
2921
|
-
if (!task) return;
|
|
2922
|
-
|
|
2923
|
-
// Predecessors: tasks whose deps point to this task
|
|
2924
|
-
for (const rt of resolved.tasks) {
|
|
2925
|
-
for (const dep of rt.task.dependencies) {
|
|
2926
|
-
// Check if this dep points to our task (forgiving normalization)
|
|
2927
|
-
const depKey = normalizeName(dep.targetName);
|
|
2928
|
-
if (
|
|
2929
|
-
depKey === normalizeName(task.task.label) ||
|
|
2930
|
-
normalizeName(
|
|
2931
|
-
`${task.groupPath.join('.')}.${task.task.label}`
|
|
2932
|
-
).endsWith(depKey)
|
|
2933
|
-
) {
|
|
2934
|
-
related.add(rt.task.id);
|
|
2935
|
-
}
|
|
2936
|
-
}
|
|
2937
|
-
}
|
|
2938
|
-
// Successors: tasks this task has deps pointing to
|
|
2939
|
-
for (const dep of task.task.dependencies) {
|
|
2940
|
-
const target = resolved.tasks.find(
|
|
2941
|
-
(t) =>
|
|
2942
|
-
t.task.label === dep.targetName ||
|
|
2943
|
-
`${t.groupPath.join('.')}.${t.task.label}`.endsWith(dep.targetName)
|
|
2944
|
-
);
|
|
2945
|
-
if (target) related.add(target.task.id);
|
|
2946
|
-
}
|
|
2947
|
-
|
|
2948
|
-
// Fade all tasks not in related set
|
|
2949
|
-
g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
|
|
2950
|
-
const el = d3Selection.select(this);
|
|
2951
|
-
const id = el.attr('data-task-id');
|
|
2952
|
-
el.attr('opacity', id && related.has(id) ? 1 : FADE_OPACITY);
|
|
2953
|
-
});
|
|
2954
|
-
g.selectAll<SVGGElement, unknown>('.gantt-milestone').attr(
|
|
2955
|
-
'opacity',
|
|
2956
|
-
FADE_OPACITY
|
|
2957
|
-
);
|
|
2958
|
-
g.selectAll<SVGElement, unknown>(
|
|
2959
|
-
'.gantt-group-bar, .gantt-group-summary'
|
|
2960
|
-
).attr('opacity', FADE_OPACITY);
|
|
2961
|
-
svg
|
|
2962
|
-
.selectAll<SVGGElement, unknown>('.gantt-group-label')
|
|
2963
|
-
.attr('opacity', FADE_OPACITY);
|
|
2964
|
-
svg
|
|
2965
|
-
.selectAll<SVGGElement, unknown>('.gantt-lane-header')
|
|
2966
|
-
.attr('opacity', FADE_OPACITY);
|
|
2967
|
-
g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr(
|
|
2968
|
-
'opacity',
|
|
2969
|
-
FADE_OPACITY
|
|
2970
|
-
);
|
|
2971
|
-
|
|
2972
|
-
// Fade dependency arrows not connected to related tasks
|
|
2973
|
-
g.selectAll<SVGElement, unknown>(
|
|
2974
|
-
'.gantt-dep-arrow, .gantt-dep-arrowhead'
|
|
2975
|
-
).each(function () {
|
|
2976
|
-
const el = d3Selection.select(this);
|
|
2977
|
-
const from = el.attr('data-dep-from');
|
|
2978
|
-
const to = el.attr('data-dep-to');
|
|
2979
|
-
const isRelated = (from && related.has(from)) || (to && related.has(to));
|
|
2980
|
-
el.attr('opacity', isRelated ? 0.5 : FADE_OPACITY);
|
|
2981
|
-
});
|
|
2982
|
-
// Fade markers
|
|
2983
|
-
g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
|
|
2984
|
-
'opacity',
|
|
2985
|
-
FADE_OPACITY
|
|
2986
|
-
);
|
|
2987
|
-
}
|
|
2988
|
-
|
|
2989
|
-
function highlightGroup(
|
|
2990
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
2991
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
2992
|
-
groupName: string
|
|
2993
|
-
): void {
|
|
2994
|
-
// Fade tasks not in this group
|
|
2995
|
-
g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
|
|
2996
|
-
const el = d3Selection.select(this);
|
|
2997
|
-
el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
|
|
2998
|
-
});
|
|
2999
|
-
// Fade milestones not in this group
|
|
3000
|
-
g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
|
|
3001
|
-
const el = d3Selection.select(this);
|
|
3002
|
-
el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
|
|
3003
|
-
});
|
|
3004
|
-
// Fade other group bars
|
|
3005
|
-
g.selectAll<SVGElement, unknown>(
|
|
3006
|
-
'.gantt-group-bar, .gantt-group-summary'
|
|
3007
|
-
).each(function () {
|
|
3008
|
-
const el = d3Selection.select(this);
|
|
3009
|
-
el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
|
|
3010
|
-
});
|
|
3011
|
-
// Fade other group labels
|
|
3012
|
-
svg.selectAll<SVGGElement, unknown>('.gantt-group-label').each(function () {
|
|
3013
|
-
const el = d3Selection.select(this);
|
|
3014
|
-
el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
|
|
3015
|
-
});
|
|
3016
|
-
// Fade task labels not in this group
|
|
3017
|
-
svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
|
|
3018
|
-
const el = d3Selection.select(this);
|
|
3019
|
-
el.attr('opacity', el.attr('data-group') === groupName ? 1 : FADE_OPACITY);
|
|
3020
|
-
});
|
|
3021
|
-
// Fade group bands not matching
|
|
3022
|
-
svg
|
|
3023
|
-
.selectAll<
|
|
3024
|
-
SVGElement,
|
|
3025
|
-
unknown
|
|
3026
|
-
>('.gantt-group-band-bg, .gantt-group-band-accent')
|
|
3027
|
-
.each(function () {
|
|
3028
|
-
const el = d3Selection.select(this);
|
|
3029
|
-
el.attr(
|
|
3030
|
-
'opacity',
|
|
3031
|
-
el.attr('data-group') === groupName ? 1 : FADE_OPACITY
|
|
3032
|
-
);
|
|
3033
|
-
});
|
|
3034
|
-
// Fade lane elements
|
|
3035
|
-
svg
|
|
3036
|
-
.selectAll<SVGGElement, unknown>('.gantt-lane-header')
|
|
3037
|
-
.attr('opacity', FADE_OPACITY);
|
|
3038
|
-
svg
|
|
3039
|
-
.selectAll<
|
|
3040
|
-
SVGElement,
|
|
3041
|
-
unknown
|
|
3042
|
-
>('.gantt-lane-band-bg, .gantt-lane-band-accent')
|
|
3043
|
-
.attr('opacity', FADE_OPACITY);
|
|
3044
|
-
g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr(
|
|
3045
|
-
'opacity',
|
|
3046
|
-
FADE_OPACITY
|
|
3047
|
-
);
|
|
3048
|
-
// Fade markers
|
|
3049
|
-
g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
|
|
3050
|
-
'opacity',
|
|
3051
|
-
FADE_OPACITY
|
|
3052
|
-
);
|
|
3053
|
-
}
|
|
3054
|
-
|
|
3055
|
-
function highlightLane(
|
|
3056
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3057
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
3058
|
-
tagKey: string,
|
|
3059
|
-
laneName: string
|
|
3060
|
-
): void {
|
|
3061
|
-
const tagAttr = `data-tag-${tagKey}`;
|
|
3062
|
-
const laneValue = laneName.toLowerCase();
|
|
3063
|
-
|
|
3064
|
-
// Fade tasks not in this lane
|
|
3065
|
-
g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
|
|
3066
|
-
const el = d3Selection.select(this);
|
|
3067
|
-
el.attr('opacity', el.attr(tagAttr) === laneValue ? 1 : FADE_OPACITY);
|
|
3068
|
-
});
|
|
3069
|
-
// Fade milestones not in this lane
|
|
3070
|
-
g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
|
|
3071
|
-
const el = d3Selection.select(this);
|
|
3072
|
-
el.attr('opacity', el.attr(tagAttr) === laneValue ? 1 : FADE_OPACITY);
|
|
3073
|
-
});
|
|
3074
|
-
// Fade task labels not in this lane
|
|
3075
|
-
svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
|
|
3076
|
-
const el = d3Selection.select(this);
|
|
3077
|
-
el.attr('opacity', el.attr(tagAttr) === laneValue ? 1 : FADE_OPACITY);
|
|
3078
|
-
});
|
|
3079
|
-
// Fade other lane headers
|
|
3080
|
-
svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').each(function () {
|
|
3081
|
-
const el = d3Selection.select(this);
|
|
3082
|
-
el.attr('opacity', el.attr('data-lane') === laneName ? 1 : FADE_OPACITY);
|
|
3083
|
-
});
|
|
3084
|
-
// Fade other lane band groups
|
|
3085
|
-
g.selectAll<SVGElement, unknown>('.gantt-lane-band-group').each(function () {
|
|
3086
|
-
const el = d3Selection.select(this);
|
|
3087
|
-
el.attr('opacity', el.attr('data-lane') === laneName ? 1 : FADE_OPACITY);
|
|
3088
|
-
});
|
|
3089
|
-
// Fade lane bands not matching
|
|
3090
|
-
svg
|
|
3091
|
-
.selectAll<
|
|
3092
|
-
SVGElement,
|
|
3093
|
-
unknown
|
|
3094
|
-
>('.gantt-lane-band-bg, .gantt-lane-band-accent')
|
|
3095
|
-
.each(function () {
|
|
3096
|
-
const el = d3Selection.select(this);
|
|
3097
|
-
el.attr('opacity', el.attr('data-lane') === laneName ? 1 : FADE_OPACITY);
|
|
3098
|
-
});
|
|
3099
|
-
// Fade group elements (not relevant in lane mode)
|
|
3100
|
-
g.selectAll<SVGElement, unknown>(
|
|
3101
|
-
'.gantt-group-bar, .gantt-group-summary'
|
|
3102
|
-
).attr('opacity', FADE_OPACITY);
|
|
3103
|
-
svg
|
|
3104
|
-
.selectAll<SVGGElement, unknown>('.gantt-group-label')
|
|
3105
|
-
.attr('opacity', FADE_OPACITY);
|
|
3106
|
-
svg
|
|
3107
|
-
.selectAll<
|
|
3108
|
-
SVGElement,
|
|
3109
|
-
unknown
|
|
3110
|
-
>('.gantt-group-band-bg, .gantt-group-band-accent')
|
|
3111
|
-
.attr('opacity', FADE_OPACITY);
|
|
3112
|
-
// Fade markers
|
|
3113
|
-
g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
|
|
3114
|
-
'opacity',
|
|
3115
|
-
FADE_OPACITY
|
|
3116
|
-
);
|
|
3117
|
-
}
|
|
3118
|
-
|
|
3119
|
-
function highlightTask(
|
|
3120
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3121
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
3122
|
-
taskId: string
|
|
3123
|
-
): void {
|
|
3124
|
-
// Fade tasks not matching
|
|
3125
|
-
g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
|
|
3126
|
-
const el = d3Selection.select(this);
|
|
3127
|
-
el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
|
|
3128
|
-
});
|
|
3129
|
-
// Fade milestones not matching
|
|
3130
|
-
g.selectAll<SVGElement, unknown>('.gantt-milestone').attr(
|
|
3131
|
-
'opacity',
|
|
3132
|
-
FADE_OPACITY
|
|
3133
|
-
);
|
|
3134
|
-
// Fade task labels not matching
|
|
3135
|
-
svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
|
|
3136
|
-
const el = d3Selection.select(this);
|
|
3137
|
-
el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
|
|
3138
|
-
});
|
|
3139
|
-
// Fade group/lane elements
|
|
3140
|
-
g.selectAll<SVGElement, unknown>(
|
|
3141
|
-
'.gantt-group-bar, .gantt-group-summary'
|
|
3142
|
-
).attr('opacity', FADE_OPACITY);
|
|
3143
|
-
svg
|
|
3144
|
-
.selectAll<SVGGElement, unknown>('.gantt-group-label')
|
|
3145
|
-
.attr('opacity', FADE_OPACITY);
|
|
3146
|
-
svg
|
|
3147
|
-
.selectAll<
|
|
3148
|
-
SVGElement,
|
|
3149
|
-
unknown
|
|
3150
|
-
>('.gantt-group-band-bg, .gantt-group-band-accent')
|
|
3151
|
-
.attr('opacity', FADE_OPACITY);
|
|
3152
|
-
svg
|
|
3153
|
-
.selectAll<SVGGElement, unknown>('.gantt-lane-header')
|
|
3154
|
-
.attr('opacity', FADE_OPACITY);
|
|
3155
|
-
svg
|
|
3156
|
-
.selectAll<
|
|
3157
|
-
SVGElement,
|
|
3158
|
-
unknown
|
|
3159
|
-
>('.gantt-lane-band-bg, .gantt-lane-band-accent')
|
|
3160
|
-
.attr('opacity', FADE_OPACITY);
|
|
3161
|
-
g.selectAll<SVGElement, unknown>(
|
|
3162
|
-
'.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
|
|
3163
|
-
).attr('opacity', FADE_OPACITY);
|
|
3164
|
-
g.selectAll<SVGElement, unknown>(
|
|
3165
|
-
'.gantt-dep-arrow, .gantt-dep-arrowhead'
|
|
3166
|
-
).attr('opacity', FADE_OPACITY);
|
|
3167
|
-
// Fade markers
|
|
3168
|
-
g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
|
|
3169
|
-
'opacity',
|
|
3170
|
-
FADE_OPACITY
|
|
3171
|
-
);
|
|
3172
|
-
}
|
|
3173
|
-
|
|
3174
|
-
function highlightMilestone(
|
|
3175
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3176
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
3177
|
-
taskId: string
|
|
3178
|
-
): void {
|
|
3179
|
-
// Fade tasks
|
|
3180
|
-
g.selectAll<SVGGElement, unknown>('.gantt-task').attr(
|
|
3181
|
-
'opacity',
|
|
3182
|
-
FADE_OPACITY
|
|
3183
|
-
);
|
|
3184
|
-
// Fade milestones not matching
|
|
3185
|
-
g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
|
|
3186
|
-
const el = d3Selection.select(this);
|
|
3187
|
-
el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
|
|
3188
|
-
});
|
|
3189
|
-
// Fade task labels not matching
|
|
3190
|
-
svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
|
|
3191
|
-
const el = d3Selection.select(this);
|
|
3192
|
-
el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
|
|
3193
|
-
});
|
|
3194
|
-
// Fade group/lane elements
|
|
3195
|
-
g.selectAll<SVGElement, unknown>(
|
|
3196
|
-
'.gantt-group-bar, .gantt-group-summary'
|
|
3197
|
-
).attr('opacity', FADE_OPACITY);
|
|
3198
|
-
svg
|
|
3199
|
-
.selectAll<SVGGElement, unknown>('.gantt-group-label')
|
|
3200
|
-
.attr('opacity', FADE_OPACITY);
|
|
3201
|
-
svg
|
|
3202
|
-
.selectAll<
|
|
3203
|
-
SVGElement,
|
|
3204
|
-
unknown
|
|
3205
|
-
>('.gantt-group-band-bg, .gantt-group-band-accent')
|
|
3206
|
-
.attr('opacity', FADE_OPACITY);
|
|
3207
|
-
svg
|
|
3208
|
-
.selectAll<SVGGElement, unknown>('.gantt-lane-header')
|
|
3209
|
-
.attr('opacity', FADE_OPACITY);
|
|
3210
|
-
svg
|
|
3211
|
-
.selectAll<
|
|
3212
|
-
SVGElement,
|
|
3213
|
-
unknown
|
|
3214
|
-
>('.gantt-lane-band-bg, .gantt-lane-band-accent')
|
|
3215
|
-
.attr('opacity', FADE_OPACITY);
|
|
3216
|
-
g.selectAll<SVGElement, unknown>(
|
|
3217
|
-
'.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
|
|
3218
|
-
).attr('opacity', FADE_OPACITY);
|
|
3219
|
-
g.selectAll<SVGElement, unknown>(
|
|
3220
|
-
'.gantt-dep-arrow, .gantt-dep-arrowhead'
|
|
3221
|
-
).attr('opacity', FADE_OPACITY);
|
|
3222
|
-
// Fade markers
|
|
3223
|
-
g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
|
|
3224
|
-
'opacity',
|
|
3225
|
-
FADE_OPACITY
|
|
3226
|
-
);
|
|
3227
|
-
}
|
|
3228
|
-
|
|
3229
|
-
function highlightTaskLabel(
|
|
3230
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
3231
|
-
lineNumber: number
|
|
3232
|
-
): void {
|
|
3233
|
-
const ln = String(lineNumber);
|
|
3234
|
-
svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
|
|
3235
|
-
const el = d3Selection.select(this);
|
|
3236
|
-
el.attr('opacity', el.attr('data-line-number') === ln ? 1 : FADE_OPACITY);
|
|
3237
|
-
});
|
|
3238
|
-
}
|
|
3239
|
-
|
|
3240
|
-
function resetTaskLabels(
|
|
3241
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>
|
|
3242
|
-
): void {
|
|
3243
|
-
svg
|
|
3244
|
-
.selectAll<SVGTextElement, unknown>('.gantt-task-label')
|
|
3245
|
-
.attr('opacity', 1);
|
|
3246
|
-
}
|
|
3247
|
-
|
|
3248
|
-
function resetHighlight(
|
|
3249
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3250
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>
|
|
3251
|
-
): void {
|
|
3252
|
-
// If critical path is actively toggled ON, restore its highlight instead of full reset
|
|
3253
|
-
if (svg.attr('data-critical-path-active') === 'true') {
|
|
3254
|
-
applyCriticalPathHighlight(svg, g);
|
|
3255
|
-
return;
|
|
3256
|
-
}
|
|
3257
|
-
g.selectAll<SVGGElement, unknown>('.gantt-task, .gantt-milestone').attr(
|
|
3258
|
-
'opacity',
|
|
3259
|
-
1
|
|
3260
|
-
);
|
|
3261
|
-
g.selectAll<SVGElement, unknown>(
|
|
3262
|
-
'.gantt-group-bar, .gantt-group-summary'
|
|
3263
|
-
).attr('opacity', 1);
|
|
3264
|
-
svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', 1);
|
|
3265
|
-
svg
|
|
3266
|
-
.selectAll<
|
|
3267
|
-
SVGElement,
|
|
3268
|
-
unknown
|
|
3269
|
-
>('.gantt-group-band-bg, .gantt-group-band-accent')
|
|
3270
|
-
.attr('opacity', 1);
|
|
3271
|
-
svg
|
|
3272
|
-
.selectAll<SVGTextElement, unknown>('.gantt-task-label')
|
|
3273
|
-
.attr('opacity', 1);
|
|
3274
|
-
svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', 1);
|
|
3275
|
-
svg
|
|
3276
|
-
.selectAll<
|
|
3277
|
-
SVGElement,
|
|
3278
|
-
unknown
|
|
3279
|
-
>('.gantt-lane-band-bg, .gantt-lane-band-accent')
|
|
3280
|
-
.attr('opacity', 1);
|
|
3281
|
-
g.selectAll<SVGElement, unknown>(
|
|
3282
|
-
'.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
|
|
3283
|
-
).attr('opacity', 1);
|
|
3284
|
-
g.selectAll<SVGElement, unknown>(
|
|
3285
|
-
'.gantt-dep-arrow, .gantt-dep-arrowhead'
|
|
3286
|
-
).attr('opacity', 0.5);
|
|
3287
|
-
g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', 1);
|
|
3288
|
-
g.selectAll<SVGElement, unknown>('.gantt-era-group').attr('opacity', 1);
|
|
3289
|
-
}
|
|
3290
|
-
|
|
3291
|
-
// ── Row Building ────────────────────────────────────────────
|
|
3292
|
-
|
|
3293
|
-
type GroupRow = { type: 'group'; group: ResolvedGroup };
|
|
3294
|
-
type TaskRow = { type: 'task'; task: ResolvedTask };
|
|
3295
|
-
type LaneHeaderRow = {
|
|
3296
|
-
type: 'lane-header';
|
|
3297
|
-
laneName: string;
|
|
3298
|
-
laneColor: string;
|
|
3299
|
-
aggregateProgress: number | null;
|
|
3300
|
-
tagKey: string;
|
|
3301
|
-
isCollapsed: boolean;
|
|
3302
|
-
laneStartDate: Date | null;
|
|
3303
|
-
laneEndDate: Date | null;
|
|
3304
|
-
};
|
|
3305
|
-
type Row = GroupRow | TaskRow | LaneHeaderRow;
|
|
3306
|
-
|
|
3307
|
-
// Public type aliases (prefixed to avoid collisions in consumer code)
|
|
3308
|
-
export type {
|
|
3309
|
-
GroupRow as GanttGroupRow,
|
|
3310
|
-
TaskRow as GanttTaskRow,
|
|
3311
|
-
LaneHeaderRow as GanttLaneHeaderRow,
|
|
3312
|
-
Row as GanttRow,
|
|
3313
|
-
};
|
|
3314
|
-
|
|
3315
|
-
function buildRowList(
|
|
3316
|
-
resolved: ResolvedSchedule,
|
|
3317
|
-
collapsedGroups?: Set<string>
|
|
3318
|
-
): Row[] {
|
|
3319
|
-
const rows: Row[] = [];
|
|
3320
|
-
const groupMap = new Map<string, ResolvedGroup>();
|
|
3321
|
-
for (const g of resolved.groups) {
|
|
3322
|
-
groupMap.set(g.name, g);
|
|
3323
|
-
}
|
|
3324
|
-
|
|
3325
|
-
// Sort tasks by group order so tasks from the same group are contiguous.
|
|
3326
|
-
// resolved.groups is in parse-tree order; use that as the sort key.
|
|
3327
|
-
// Tasks with no group come first, then tasks grouped by their groupPath.
|
|
3328
|
-
const groupOrder = new Map<string, number>();
|
|
3329
|
-
resolved.groups.forEach((g, i) => groupOrder.set(g.name, i));
|
|
3330
|
-
|
|
3331
|
-
const sortedTasks = [...resolved.tasks].sort((a, b) => {
|
|
3332
|
-
const maxLen = Math.max(a.groupPath.length, b.groupPath.length);
|
|
3333
|
-
for (let i = 0; i < maxLen; i++) {
|
|
3334
|
-
const ga = a.groupPath[i];
|
|
3335
|
-
const gb = b.groupPath[i];
|
|
3336
|
-
if (ga === gb) continue;
|
|
3337
|
-
// Task with shorter path (no group at this level) comes first
|
|
3338
|
-
if (ga === undefined) return -1;
|
|
3339
|
-
if (gb === undefined) return 1;
|
|
3340
|
-
const oa = groupOrder.get(ga) ?? 0;
|
|
3341
|
-
const ob = groupOrder.get(gb) ?? 0;
|
|
3342
|
-
if (oa !== ob) return oa - ob;
|
|
3343
|
-
}
|
|
3344
|
-
return 0; // same group — preserve original (topo-sort) order
|
|
3345
|
-
});
|
|
3346
|
-
|
|
3347
|
-
// Build a flat display list from the resolved groups and tasks
|
|
3348
|
-
// Groups appear before their children. Collapsed groups hide children.
|
|
3349
|
-
const seenGroups = new Set<string>();
|
|
3350
|
-
for (const rt of sortedTasks) {
|
|
3351
|
-
// Check if any group in this task's path is collapsed
|
|
3352
|
-
const isHidden = rt.groupPath.some((g) => collapsedGroups?.has(g));
|
|
3353
|
-
if (isHidden) {
|
|
3354
|
-
// Still insert collapsed group headers if not seen
|
|
3355
|
-
for (const groupName of rt.groupPath) {
|
|
3356
|
-
if (!seenGroups.has(groupName)) {
|
|
3357
|
-
seenGroups.add(groupName);
|
|
3358
|
-
const group = groupMap.get(groupName);
|
|
3359
|
-
if (group) {
|
|
3360
|
-
rows.push({ type: 'group', group });
|
|
3361
|
-
}
|
|
3362
|
-
}
|
|
3363
|
-
if (collapsedGroups?.has(groupName)) break; // stop at collapsed group
|
|
3364
|
-
}
|
|
3365
|
-
continue; // skip task row
|
|
3366
|
-
}
|
|
3367
|
-
|
|
3368
|
-
// Insert group rows for any groups in the path not yet seen
|
|
3369
|
-
for (let i = 0; i < rt.groupPath.length; i++) {
|
|
3370
|
-
const groupName = rt.groupPath[i];
|
|
3371
|
-
if (groupName === undefined) continue; // In-bounds by loop guard; appeases TS.
|
|
3372
|
-
if (!seenGroups.has(groupName)) {
|
|
3373
|
-
seenGroups.add(groupName);
|
|
3374
|
-
const group = groupMap.get(groupName);
|
|
3375
|
-
if (group) {
|
|
3376
|
-
rows.push({ type: 'group', group });
|
|
3377
|
-
}
|
|
3378
|
-
}
|
|
3379
|
-
}
|
|
3380
|
-
rows.push({ type: 'task', task: rt });
|
|
3381
|
-
}
|
|
3382
|
-
|
|
3383
|
-
return rows;
|
|
3384
|
-
}
|
|
3385
|
-
|
|
3386
|
-
// ── Tag Lane Row Building ──────────────────────────────────
|
|
3387
|
-
|
|
3388
|
-
export function buildTagLaneRowList(
|
|
3389
|
-
resolved: ResolvedSchedule,
|
|
3390
|
-
swimlaneGroup: string,
|
|
3391
|
-
collapsedLanes?: Set<string>
|
|
3392
|
-
): Row[] | null {
|
|
3393
|
-
const tagGroup = resolved.tagGroups.find(
|
|
3394
|
-
(g) => g.name.toLowerCase() === swimlaneGroup.toLowerCase()
|
|
3395
|
-
);
|
|
3396
|
-
if (!tagGroup) return null;
|
|
3397
|
-
|
|
3398
|
-
const tagKey = tagAttrKey(tagGroup.name);
|
|
3399
|
-
const rows: Row[] = [];
|
|
3400
|
-
|
|
3401
|
-
// Bucket tasks by tag value
|
|
3402
|
-
const buckets = new Map<string, ResolvedTask[]>();
|
|
3403
|
-
const unbucketed: ResolvedTask[] = [];
|
|
3404
|
-
|
|
3405
|
-
for (const rt of resolved.tasks) {
|
|
3406
|
-
let value = rt.effectiveMetadata[tagKey];
|
|
3407
|
-
if (!value && tagGroup.defaultValue) {
|
|
3408
|
-
value = tagGroup.defaultValue;
|
|
3409
|
-
}
|
|
3410
|
-
if (value) {
|
|
3411
|
-
const key = value.toLowerCase();
|
|
3412
|
-
if (!buckets.has(key)) buckets.set(key, []);
|
|
3413
|
-
buckets.get(key)!.push(rt);
|
|
3414
|
-
} else {
|
|
3415
|
-
unbucketed.push(rt);
|
|
3416
|
-
}
|
|
3417
|
-
}
|
|
3418
|
-
|
|
3419
|
-
// Emit lanes in tag entry declaration order (skip empty lanes)
|
|
3420
|
-
for (const entry of tagGroup.entries) {
|
|
3421
|
-
const entryKey = entry.value.toLowerCase();
|
|
3422
|
-
const tasks = buckets.get(entryKey) ?? [];
|
|
3423
|
-
if (tasks.length === 0) continue;
|
|
3424
|
-
// Sort tasks within lane by start date
|
|
3425
|
-
tasks.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
|
3426
|
-
|
|
3427
|
-
// Compute duration-weighted aggregate progress (tasks without progress count as 0%)
|
|
3428
|
-
const aggregateProgress = durationWeightedProgress(tasks);
|
|
3429
|
-
|
|
3430
|
-
// Compute lane date range from tasks
|
|
3431
|
-
const laneStartDate =
|
|
3432
|
-
tasks.length > 0
|
|
3433
|
-
? new Date(Math.min(...tasks.map((t) => t.startDate.getTime())))
|
|
3434
|
-
: null;
|
|
3435
|
-
const laneEndDate =
|
|
3436
|
-
tasks.length > 0
|
|
3437
|
-
? new Date(Math.max(...tasks.map((t) => t.endDate.getTime())))
|
|
3438
|
-
: null;
|
|
3439
|
-
|
|
3440
|
-
const isCollapsed = collapsedLanes?.has(entry.value) ?? false;
|
|
3441
|
-
rows.push({
|
|
3442
|
-
type: 'lane-header',
|
|
3443
|
-
laneName: entry.value,
|
|
3444
|
-
laneColor: entry.color,
|
|
3445
|
-
aggregateProgress,
|
|
3446
|
-
tagKey,
|
|
3447
|
-
isCollapsed,
|
|
3448
|
-
laneStartDate,
|
|
3449
|
-
laneEndDate,
|
|
3450
|
-
});
|
|
3451
|
-
if (!isCollapsed) {
|
|
3452
|
-
for (const rt of tasks) {
|
|
3453
|
-
rows.push({ type: 'task', task: rt });
|
|
3454
|
-
}
|
|
3455
|
-
}
|
|
3456
|
-
}
|
|
3457
|
-
|
|
3458
|
-
// Append unbucketed tasks as "No {GroupName}" lane
|
|
3459
|
-
if (unbucketed.length > 0) {
|
|
3460
|
-
unbucketed.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
|
3461
|
-
const aggregateProgress = durationWeightedProgress(unbucketed);
|
|
3462
|
-
|
|
3463
|
-
const noLaneStartDate =
|
|
3464
|
-
unbucketed.length > 0
|
|
3465
|
-
? new Date(Math.min(...unbucketed.map((t) => t.startDate.getTime())))
|
|
3466
|
-
: null;
|
|
3467
|
-
const noLaneEndDate =
|
|
3468
|
-
unbucketed.length > 0
|
|
3469
|
-
? new Date(Math.max(...unbucketed.map((t) => t.endDate.getTime())))
|
|
3470
|
-
: null;
|
|
3471
|
-
|
|
3472
|
-
const noLaneName = `No ${tagGroup.name}`;
|
|
3473
|
-
const isCollapsed = collapsedLanes?.has(noLaneName) ?? false;
|
|
3474
|
-
rows.push({
|
|
3475
|
-
type: 'lane-header',
|
|
3476
|
-
laneName: noLaneName,
|
|
3477
|
-
laneColor: '#999999',
|
|
3478
|
-
aggregateProgress,
|
|
3479
|
-
tagKey,
|
|
3480
|
-
isCollapsed,
|
|
3481
|
-
laneStartDate: noLaneStartDate,
|
|
3482
|
-
laneEndDate: noLaneEndDate,
|
|
3483
|
-
});
|
|
3484
|
-
if (!isCollapsed) {
|
|
3485
|
-
for (const rt of unbucketed) {
|
|
3486
|
-
rows.push({ type: 'task', task: rt });
|
|
3487
|
-
}
|
|
3488
|
-
}
|
|
3489
|
-
}
|
|
3490
|
-
|
|
3491
|
-
return rows;
|
|
3492
|
-
}
|
|
3493
|
-
|
|
3494
|
-
// ── Helpers ─────────────────────────────────────────────────
|
|
3495
|
-
|
|
3496
|
-
/** Duration-weighted progress: tasks without explicit progress count as 0%. Returns null if no task has progress. */
|
|
3497
|
-
function durationWeightedProgress(tasks: ResolvedTask[]): number | null {
|
|
3498
|
-
let totalDuration = 0;
|
|
3499
|
-
let totalProgress = 0;
|
|
3500
|
-
let hasProgress = false;
|
|
3501
|
-
for (const rt of tasks) {
|
|
3502
|
-
const dur = rt.endDate.getTime() - rt.startDate.getTime();
|
|
3503
|
-
totalDuration += dur;
|
|
3504
|
-
if (rt.task.progress !== null) {
|
|
3505
|
-
totalProgress += rt.task.progress * dur;
|
|
3506
|
-
hasProgress = true;
|
|
3507
|
-
}
|
|
3508
|
-
}
|
|
3509
|
-
return hasProgress && totalDuration > 0
|
|
3510
|
-
? totalProgress / totalDuration
|
|
3511
|
-
: null;
|
|
3512
|
-
}
|
|
3513
|
-
|
|
3514
|
-
function dateToFractionalYear(d: Date): number {
|
|
3515
|
-
const y = d.getFullYear();
|
|
3516
|
-
const startOfYear = new Date(y, 0, 1);
|
|
3517
|
-
const endOfYear = new Date(y + 1, 0, 1);
|
|
3518
|
-
const fraction =
|
|
3519
|
-
(d.getTime() - startOfYear.getTime()) /
|
|
3520
|
-
(endOfYear.getTime() - startOfYear.getTime());
|
|
3521
|
-
return y + fraction;
|
|
3522
|
-
}
|
|
3523
|
-
|
|
3524
|
-
function diamondPoints(cx: number, cy: number, size: number): string {
|
|
3525
|
-
const half = size / 2;
|
|
3526
|
-
return `${cx},${cy - half} ${cx + half},${cy} ${cx},${cy + half} ${cx - half},${cy}`;
|
|
3527
|
-
}
|
|
3528
|
-
|
|
3529
|
-
// ── Hover Date Indicators ───────────────────────────────────
|
|
3530
|
-
|
|
3531
|
-
function formatGanttDate(d: Date): string {
|
|
3532
|
-
const base = `${MONTH_ABBR[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
|
|
3533
|
-
if (d.getHours() === 0 && d.getMinutes() === 0) return base;
|
|
3534
|
-
const hh = String(d.getHours()).padStart(2, '0');
|
|
3535
|
-
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
3536
|
-
return `${base} ${hh}:${mm}`;
|
|
3537
|
-
}
|
|
3538
|
-
|
|
3539
|
-
function showGanttDateIndicators(
|
|
3540
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3541
|
-
xScale: d3Scale.ScaleLinear<number, number>,
|
|
3542
|
-
startDate: Date,
|
|
3543
|
-
endDate: Date | null,
|
|
3544
|
-
innerHeight: number,
|
|
3545
|
-
color: string,
|
|
3546
|
-
options?: { skipStartLine?: boolean }
|
|
3547
|
-
): void {
|
|
3548
|
-
// Fade existing scale ticks and today marker
|
|
3549
|
-
g.selectAll('.gantt-scale-tick').attr('opacity', 0.05);
|
|
3550
|
-
g.selectAll('.gantt-today').attr('opacity', 0.05);
|
|
3551
|
-
|
|
3552
|
-
// Wrap all hover indicators in a group that ignores pointer events,
|
|
3553
|
-
// so they don't steal mouseleave from the element being hovered.
|
|
3554
|
-
const hg = g
|
|
3555
|
-
.append('g')
|
|
3556
|
-
.attr('class', 'gantt-hover-date')
|
|
3557
|
-
.attr('pointer-events', 'none');
|
|
3558
|
-
|
|
3559
|
-
const tickLen = 6;
|
|
3560
|
-
const startPos = xScale(dateToFractionalYear(startDate));
|
|
3561
|
-
const startLabel = formatGanttDate(startDate);
|
|
3562
|
-
|
|
3563
|
-
// Start date — dashed vertical line (skip when caller already shows its own line)
|
|
3564
|
-
if (!options?.skipStartLine) {
|
|
3565
|
-
hg.append('line')
|
|
3566
|
-
.attr('class', 'gantt-hover-date')
|
|
3567
|
-
.attr('x1', startPos)
|
|
3568
|
-
.attr('y1', -tickLen)
|
|
3569
|
-
.attr('x2', startPos)
|
|
3570
|
-
.attr('y2', innerHeight)
|
|
3571
|
-
.attr('stroke', color)
|
|
3572
|
-
.attr('stroke-width', 1.5)
|
|
3573
|
-
.attr('stroke-dasharray', '4 4')
|
|
3574
|
-
.attr('opacity', 0.6);
|
|
3575
|
-
}
|
|
3576
|
-
|
|
3577
|
-
// Start date — top label
|
|
3578
|
-
hg.append('text')
|
|
3579
|
-
.attr('class', 'gantt-hover-date')
|
|
3580
|
-
.attr('x', startPos)
|
|
3581
|
-
.attr('y', -tickLen - 4)
|
|
3582
|
-
.attr('text-anchor', 'middle')
|
|
3583
|
-
.attr('fill', color)
|
|
3584
|
-
.attr('font-size', '10px')
|
|
3585
|
-
.attr('font-weight', '600')
|
|
3586
|
-
.text(startLabel);
|
|
3587
|
-
|
|
3588
|
-
// Start date — bottom label
|
|
3589
|
-
hg.append('text')
|
|
3590
|
-
.attr('class', 'gantt-hover-date')
|
|
3591
|
-
.attr('x', startPos)
|
|
3592
|
-
.attr('y', innerHeight + tickLen + 12)
|
|
3593
|
-
.attr('text-anchor', 'middle')
|
|
3594
|
-
.attr('fill', color)
|
|
3595
|
-
.attr('font-size', '10px')
|
|
3596
|
-
.attr('font-weight', '600')
|
|
3597
|
-
.text(startLabel);
|
|
3598
|
-
|
|
3599
|
-
if (endDate && endDate.getTime() !== startDate.getTime()) {
|
|
3600
|
-
const endPos = xScale(dateToFractionalYear(endDate));
|
|
3601
|
-
const endLabel = formatGanttDate(endDate);
|
|
3602
|
-
|
|
3603
|
-
// When dates are close, push labels apart so they don't overlap.
|
|
3604
|
-
// ~90px is roughly the width of a date label like "Aug 12, 2026" at 10px.
|
|
3605
|
-
const minLabelGap = 90;
|
|
3606
|
-
const gap = endPos - startPos;
|
|
3607
|
-
let startLabelX = startPos;
|
|
3608
|
-
let endLabelX = endPos;
|
|
3609
|
-
let startAnchor = 'middle';
|
|
3610
|
-
let endAnchor = 'middle';
|
|
3611
|
-
if (gap < minLabelGap) {
|
|
3612
|
-
const mid = (startPos + endPos) / 2;
|
|
3613
|
-
startLabelX = mid - minLabelGap / 2;
|
|
3614
|
-
endLabelX = mid + minLabelGap / 2;
|
|
3615
|
-
startAnchor = 'middle';
|
|
3616
|
-
endAnchor = 'middle';
|
|
3617
|
-
}
|
|
3618
|
-
|
|
3619
|
-
// End date — dashed vertical line
|
|
3620
|
-
hg.append('line')
|
|
3621
|
-
.attr('class', 'gantt-hover-date')
|
|
3622
|
-
.attr('x1', endPos)
|
|
3623
|
-
.attr('y1', -tickLen)
|
|
3624
|
-
.attr('x2', endPos)
|
|
3625
|
-
.attr('y2', innerHeight)
|
|
3626
|
-
.attr('stroke', color)
|
|
3627
|
-
.attr('stroke-width', 1.5)
|
|
3628
|
-
.attr('stroke-dasharray', '4 4')
|
|
3629
|
-
.attr('opacity', 0.6);
|
|
3630
|
-
|
|
3631
|
-
// Reposition start labels to avoid overlap
|
|
3632
|
-
hg.selectAll<SVGTextElement, unknown>('text.gantt-hover-date').each(
|
|
3633
|
-
function () {
|
|
3634
|
-
const el = d3Selection.select(this);
|
|
3635
|
-
if (el.text() === startLabel) {
|
|
3636
|
-
el.attr('x', startLabelX).attr('text-anchor', startAnchor);
|
|
3637
|
-
}
|
|
3638
|
-
}
|
|
3639
|
-
);
|
|
3640
|
-
|
|
3641
|
-
// End date — top label
|
|
3642
|
-
hg.append('text')
|
|
3643
|
-
.attr('class', 'gantt-hover-date')
|
|
3644
|
-
.attr('x', endLabelX)
|
|
3645
|
-
.attr('y', -tickLen - 4)
|
|
3646
|
-
.attr('text-anchor', endAnchor)
|
|
3647
|
-
.attr('fill', color)
|
|
3648
|
-
.attr('font-size', '10px')
|
|
3649
|
-
.attr('font-weight', '600')
|
|
3650
|
-
.text(endLabel);
|
|
3651
|
-
|
|
3652
|
-
// End date — bottom label
|
|
3653
|
-
hg.append('text')
|
|
3654
|
-
.attr('class', 'gantt-hover-date')
|
|
3655
|
-
.attr('x', endLabelX)
|
|
3656
|
-
.attr('y', innerHeight + tickLen + 12)
|
|
3657
|
-
.attr('text-anchor', endAnchor)
|
|
3658
|
-
.attr('fill', color)
|
|
3659
|
-
.attr('font-size', '10px')
|
|
3660
|
-
.attr('font-weight', '600')
|
|
3661
|
-
.text(endLabel);
|
|
3662
|
-
}
|
|
3663
|
-
}
|
|
3664
|
-
|
|
3665
|
-
function hideGanttDateIndicators(
|
|
3666
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>
|
|
3667
|
-
): void {
|
|
3668
|
-
g.selectAll('.gantt-hover-date').remove();
|
|
3669
|
-
// Restore scale tick opacity
|
|
3670
|
-
g.selectAll('.gantt-scale-tick').each(function () {
|
|
3671
|
-
const el = d3Selection.select(this);
|
|
3672
|
-
const isDashed = el.attr('stroke-dasharray');
|
|
3673
|
-
el.attr('opacity', isDashed ? 0.15 : 0.4);
|
|
3674
|
-
});
|
|
3675
|
-
// Restore today marker opacity
|
|
3676
|
-
g.selectAll('.gantt-today').attr('opacity', 0.7);
|
|
3677
|
-
}
|
|
3678
|
-
|
|
3679
|
-
function resolveTaskColor(
|
|
3680
|
-
rt: ResolvedTask,
|
|
3681
|
-
activeTagGroup: string | null,
|
|
3682
|
-
resolved: ResolvedSchedule,
|
|
3683
|
-
seriesColors: string[],
|
|
3684
|
-
palette: PaletteColors
|
|
3685
|
-
): string {
|
|
3686
|
-
// Try tag-based coloring first
|
|
3687
|
-
const tagColor = resolveTagColor(
|
|
3688
|
-
rt.effectiveMetadata,
|
|
3689
|
-
resolved.tagGroups,
|
|
3690
|
-
activeTagGroup
|
|
3691
|
-
);
|
|
3692
|
-
if (tagColor && tagColor !== '#999999') return tagColor;
|
|
3693
|
-
|
|
3694
|
-
// Fall back to group-based coloring
|
|
3695
|
-
if (rt.groupPath.length > 0) {
|
|
3696
|
-
// In-bounds by length check above.
|
|
3697
|
-
const topGroup = rt.groupPath[0]!;
|
|
3698
|
-
const groupIdx = resolved.groups.findIndex((g) => g.name === topGroup);
|
|
3699
|
-
if (groupIdx >= 0) {
|
|
3700
|
-
// In-bounds: findIndex >= 0 means index is valid.
|
|
3701
|
-
const group = resolved.groups[groupIdx]!;
|
|
3702
|
-
if (group.color) return group.color;
|
|
3703
|
-
// In-bounds: i % n is always 0..n-1 for n > 0; seriesColors is non-empty here.
|
|
3704
|
-
return seriesColors[groupIdx % seriesColors.length]!;
|
|
3705
|
-
}
|
|
3706
|
-
}
|
|
3707
|
-
|
|
3708
|
-
// Default
|
|
3709
|
-
return palette.accent || seriesColors[0] || '#4a90d9';
|
|
3710
|
-
}
|
|
3711
|
-
|
|
3712
|
-
function renderTimeScaleHorizontal(
|
|
3713
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3714
|
-
scale: d3Scale.ScaleLinear<number, number>,
|
|
3715
|
-
_innerWidth: number,
|
|
3716
|
-
innerHeight: number,
|
|
3717
|
-
textColor: string
|
|
3718
|
-
): void {
|
|
3719
|
-
const domain = scale.domain();
|
|
3720
|
-
// In-bounds: d3 ScaleLinear.domain() returns at least 2 elements.
|
|
3721
|
-
const domainMin = domain[0]!;
|
|
3722
|
-
const domainMax = domain[1]!;
|
|
3723
|
-
const ticks = computeTimeTicks(domainMin, domainMax, scale);
|
|
3724
|
-
if (ticks.length < 2) return;
|
|
3725
|
-
|
|
3726
|
-
const tickLen = 6;
|
|
3727
|
-
const opacity = 0.4;
|
|
3728
|
-
const guideOpacity = 0.15;
|
|
3729
|
-
|
|
3730
|
-
for (const tick of ticks) {
|
|
3731
|
-
// Guide line
|
|
3732
|
-
g.append('line')
|
|
3733
|
-
.attr('class', 'gantt-scale-tick')
|
|
3734
|
-
.attr('x1', tick.pos)
|
|
3735
|
-
.attr('y1', 0)
|
|
3736
|
-
.attr('x2', tick.pos)
|
|
3737
|
-
.attr('y2', innerHeight)
|
|
3738
|
-
.attr('stroke', textColor)
|
|
3739
|
-
.attr('stroke-width', 1)
|
|
3740
|
-
.attr('stroke-dasharray', '4 4')
|
|
3741
|
-
.attr('opacity', guideOpacity);
|
|
3742
|
-
|
|
3743
|
-
// Top tick
|
|
3744
|
-
g.append('line')
|
|
3745
|
-
.attr('class', 'gantt-scale-tick')
|
|
3746
|
-
.attr('x1', tick.pos)
|
|
3747
|
-
.attr('y1', 0)
|
|
3748
|
-
.attr('x2', tick.pos)
|
|
3749
|
-
.attr('y2', -tickLen)
|
|
3750
|
-
.attr('stroke', textColor)
|
|
3751
|
-
.attr('stroke-width', 1)
|
|
3752
|
-
.attr('opacity', opacity);
|
|
3753
|
-
|
|
3754
|
-
// Top label
|
|
3755
|
-
g.append('text')
|
|
3756
|
-
.attr('class', 'gantt-scale-tick')
|
|
3757
|
-
.attr('x', tick.pos)
|
|
3758
|
-
.attr('y', -tickLen - 4)
|
|
3759
|
-
.attr('text-anchor', 'middle')
|
|
3760
|
-
.attr('dominant-baseline', 'auto')
|
|
3761
|
-
.attr('font-size', '10px')
|
|
3762
|
-
.attr('fill', textColor)
|
|
3763
|
-
.attr('opacity', opacity)
|
|
3764
|
-
.text(tick.label);
|
|
3765
|
-
|
|
3766
|
-
// Bottom tick
|
|
3767
|
-
g.append('line')
|
|
3768
|
-
.attr('class', 'gantt-scale-tick')
|
|
3769
|
-
.attr('x1', tick.pos)
|
|
3770
|
-
.attr('y1', innerHeight)
|
|
3771
|
-
.attr('x2', tick.pos)
|
|
3772
|
-
.attr('y2', innerHeight + tickLen)
|
|
3773
|
-
.attr('stroke', textColor)
|
|
3774
|
-
.attr('stroke-width', 1)
|
|
3775
|
-
.attr('opacity', opacity);
|
|
3776
|
-
|
|
3777
|
-
g.append('text')
|
|
3778
|
-
.attr('class', 'gantt-scale-tick')
|
|
3779
|
-
.attr('x', tick.pos)
|
|
3780
|
-
.attr('y', innerHeight + tickLen + 12)
|
|
3781
|
-
.attr('text-anchor', 'middle')
|
|
3782
|
-
.attr('font-size', '10px')
|
|
3783
|
-
.attr('fill', textColor)
|
|
3784
|
-
.attr('opacity', opacity)
|
|
3785
|
-
.text(tick.label);
|
|
3786
|
-
}
|
|
3787
|
-
}
|