@workiom/frappe-gantt 1.0.19 → 1.0.21
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/README.md +52 -19
- package/dist/frappe-gantt.css +1 -1
- package/dist/frappe-gantt.es.js +761 -630
- package/dist/frappe-gantt.umd.js +46 -21
- package/package.json +1 -1
- package/src/arrow.js +171 -84
- package/src/bar.js +0 -70
- package/src/defaults.js +2 -2
- package/src/dependency_shifting.js +201 -0
- package/src/index.js +115 -151
- package/src/styles/dark.css +5 -0
- package/src/styles/gantt.css +13 -0
- package/src/styles/light.css +1 -0
package/src/arrow.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { createSVG } from './svg_utils';
|
|
2
2
|
|
|
3
3
|
export default class Arrow {
|
|
4
|
-
constructor(gantt, from_task, to_task) {
|
|
4
|
+
constructor(gantt, from_task, to_task, dependency_type) {
|
|
5
5
|
this.gantt = gantt;
|
|
6
6
|
this.from_task = from_task;
|
|
7
7
|
this.to_task = to_task;
|
|
8
|
+
this.dependency_type = dependency_type;
|
|
8
9
|
this.is_critical = this.check_critical_path();
|
|
9
10
|
this.is_invalid = this.check_invalid_dependency();
|
|
11
|
+
this.is_hovered = false;
|
|
10
12
|
|
|
11
13
|
this.calculate_path();
|
|
12
14
|
this.draw();
|
|
@@ -21,18 +23,11 @@ export default class Arrow {
|
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
check_invalid_dependency() {
|
|
24
|
-
const
|
|
25
|
-
this.gantt.options.dependencies_type;
|
|
26
|
-
|
|
27
|
-
// Fixed dependencies use old logic
|
|
28
|
-
if (dependencies_type === 'fixed') {
|
|
29
|
-
return this.to_task.$bar.getX() < this.from_task.$bar.getX();
|
|
30
|
-
}
|
|
31
|
-
|
|
26
|
+
const dependency_type = this.dependency_type;
|
|
32
27
|
const parent_task = this.from_task.task;
|
|
33
28
|
const child_task = this.to_task.task;
|
|
34
29
|
|
|
35
|
-
switch(
|
|
30
|
+
switch(dependency_type) {
|
|
36
31
|
case 'finish-to-start':
|
|
37
32
|
// Child task cannot start before parent finishes
|
|
38
33
|
return child_task._start < parent_task._end;
|
|
@@ -54,81 +49,139 @@ export default class Arrow {
|
|
|
54
49
|
}
|
|
55
50
|
|
|
56
51
|
calculate_path() {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
52
|
+
const opt = this.gantt.options;
|
|
53
|
+
const cfg = this.gantt.config;
|
|
54
|
+
const curve = opt.arrow_curve;
|
|
55
|
+
const padding = opt.padding;
|
|
56
|
+
|
|
57
|
+
// Anchor x positions
|
|
58
|
+
const right_A = this.from_task.$bar.getX() + this.from_task.$bar.getWidth();
|
|
59
|
+
const left_A = this.from_task.$bar.getX();
|
|
60
|
+
const right_B = this.to_task.$bar.getX() + this.to_task.$bar.getWidth();
|
|
61
|
+
const left_B = this.to_task.$bar.getX();
|
|
62
|
+
|
|
63
|
+
// Anchor y positions — vertical center of each bar row
|
|
64
|
+
const row_center = (task) =>
|
|
65
|
+
cfg.header_height +
|
|
66
|
+
opt.bar_height / 2 +
|
|
67
|
+
(opt.padding + opt.bar_height) * task.task._index +
|
|
68
|
+
opt.padding / 2;
|
|
69
|
+
|
|
70
|
+
const y_A = row_center(this.from_task);
|
|
71
|
+
const y_B = row_center(this.to_task);
|
|
72
|
+
const y_mid = (y_A + y_B) / 2;
|
|
73
|
+
|
|
74
|
+
switch (this.dependency_type) {
|
|
75
|
+
case 'finish-to-start':
|
|
76
|
+
this.path = this._path_finish_to_start(
|
|
77
|
+
right_A, left_A, right_B, left_B, y_A, y_B, y_mid, padding, curve
|
|
78
|
+
);
|
|
79
|
+
break;
|
|
80
|
+
case 'start-to-start':
|
|
81
|
+
this.path = this._path_start_to_start(
|
|
82
|
+
left_A, left_B, y_A, y_B, padding, curve
|
|
83
|
+
);
|
|
84
|
+
break;
|
|
85
|
+
case 'finish-to-finish':
|
|
86
|
+
this.path = this._path_finish_to_finish(
|
|
87
|
+
right_A, right_B, y_A, y_B, padding, curve
|
|
88
|
+
);
|
|
89
|
+
break;
|
|
90
|
+
case 'start-to-finish':
|
|
91
|
+
this.path = this._path_start_to_finish(
|
|
92
|
+
left_A, right_B, y_A, y_B, y_mid, padding, curve
|
|
93
|
+
);
|
|
94
|
+
break;
|
|
95
|
+
default:
|
|
96
|
+
this.path = this._path_finish_to_start(
|
|
97
|
+
right_A, left_A, right_B, left_B, y_A, y_B, y_mid, padding, curve
|
|
98
|
+
);
|
|
66
99
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
this.gantt.options.padding / 2;
|
|
83
|
-
|
|
84
|
-
const from_is_below_to =
|
|
85
|
-
this.from_task.task._index > this.to_task.task._index;
|
|
86
|
-
|
|
87
|
-
let curve = this.gantt.options.arrow_curve;
|
|
88
|
-
const clockwise = from_is_below_to ? 1 : 0;
|
|
89
|
-
let curve_y = from_is_below_to ? -curve : curve;
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
this.to_task.$bar.getX() <=
|
|
93
|
-
this.from_task.$bar.getX() + this.gantt.options.padding
|
|
94
|
-
) {
|
|
95
|
-
let down_1 = this.gantt.options.padding / 2 - curve;
|
|
96
|
-
if (down_1 < 0) {
|
|
97
|
-
down_1 = 0;
|
|
98
|
-
curve = this.gantt.options.padding / 2;
|
|
99
|
-
curve_y = from_is_below_to ? -curve : curve;
|
|
100
|
-
}
|
|
101
|
-
const down_2 =
|
|
102
|
-
this.to_task.$bar.getY() +
|
|
103
|
-
this.to_task.$bar.getHeight() / 2 -
|
|
104
|
-
curve_y;
|
|
105
|
-
const left = this.to_task.$bar.getX() - this.gantt.options.padding;
|
|
106
|
-
this.path = `
|
|
107
|
-
M ${start_x} ${start_y}
|
|
108
|
-
v ${down_1}
|
|
109
|
-
a ${curve} ${curve} 0 0 1 ${-curve} ${curve}
|
|
110
|
-
H ${left}
|
|
111
|
-
a ${curve} ${curve} 0 0 ${clockwise} ${-curve} ${curve_y}
|
|
112
|
-
V ${down_2}
|
|
113
|
-
a ${curve} ${curve} 0 0 ${clockwise} ${curve} ${curve_y}
|
|
114
|
-
L ${end_x} ${end_y}
|
|
115
|
-
m -5 -5
|
|
116
|
-
l 5 5
|
|
117
|
-
l -5 5`;
|
|
118
|
-
} else {
|
|
119
|
-
if (end_x < start_x + curve) curve = end_x - start_x;
|
|
120
|
-
|
|
121
|
-
let offset = from_is_below_to ? end_y + curve : end_y - curve;
|
|
122
|
-
|
|
123
|
-
this.path = `
|
|
124
|
-
M ${start_x} ${start_y}
|
|
125
|
-
V ${offset}
|
|
126
|
-
a ${curve} ${curve} 0 0 ${clockwise} ${curve} ${curve}
|
|
127
|
-
L ${end_x} ${end_y}
|
|
128
|
-
m -5 -5
|
|
129
|
-
l 5 5
|
|
130
|
-
l -5 5`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_path_finish_to_start(right_A, left_A, right_B, left_B, y_A, y_B, y_mid, padding, curve) {
|
|
103
|
+
const x_right = right_A + padding;
|
|
104
|
+
|
|
105
|
+
if (x_right < left_B) {
|
|
106
|
+
// Case 1: space between tasks — 3 segments: right, down, right
|
|
107
|
+
return `
|
|
108
|
+
M ${right_A} ${y_A}
|
|
109
|
+
H ${x_right - curve}
|
|
110
|
+
a ${curve} ${curve} 0 0 1 ${curve} ${curve}
|
|
111
|
+
V ${y_B - curve}
|
|
112
|
+
a ${curve} ${curve} 0 0 0 ${curve} ${curve}
|
|
113
|
+
H ${left_B}
|
|
114
|
+
m -5 -5 l 5 5 l -5 5`;
|
|
131
115
|
}
|
|
116
|
+
|
|
117
|
+
// Case 2: overlap — 5 segments: right, down, left, down, right
|
|
118
|
+
const x_left = left_B - padding;
|
|
119
|
+
return `
|
|
120
|
+
M ${right_A} ${y_A}
|
|
121
|
+
H ${x_right - curve}
|
|
122
|
+
a ${curve} ${curve} 0 0 1 ${curve} ${curve}
|
|
123
|
+
V ${y_mid - curve}
|
|
124
|
+
a ${curve} ${curve} 0 0 1 ${-curve} ${curve}
|
|
125
|
+
H ${x_left + curve}
|
|
126
|
+
a ${curve} ${curve} 0 0 0 ${-curve} ${curve}
|
|
127
|
+
V ${y_B - curve}
|
|
128
|
+
a ${curve} ${curve} 0 0 0 ${curve} ${curve}
|
|
129
|
+
H ${left_B}
|
|
130
|
+
m -5 -5 l 5 5 l -5 5`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_path_start_to_start(left_A, left_B, y_A, y_B, padding, curve) {
|
|
134
|
+
const x_left = Math.min(left_A, left_B) - padding;
|
|
135
|
+
|
|
136
|
+
return `
|
|
137
|
+
M ${left_A} ${y_A}
|
|
138
|
+
H ${x_left + curve}
|
|
139
|
+
a ${curve} ${curve} 0 0 0 ${-curve} ${curve}
|
|
140
|
+
V ${y_B - curve}
|
|
141
|
+
a ${curve} ${curve} 0 0 0 ${curve} ${curve}
|
|
142
|
+
H ${left_B}
|
|
143
|
+
m -5 -5 l 5 5 l -5 5`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_path_finish_to_finish(right_A, right_B, y_A, y_B, padding, curve) {
|
|
147
|
+
const x_right = Math.max(right_A, right_B) + padding;
|
|
148
|
+
|
|
149
|
+
return `
|
|
150
|
+
M ${right_A} ${y_A}
|
|
151
|
+
H ${x_right - curve}
|
|
152
|
+
a ${curve} ${curve} 0 0 1 ${curve} ${curve}
|
|
153
|
+
V ${y_B - curve}
|
|
154
|
+
a ${curve} ${curve} 0 0 1 ${-curve} ${curve}
|
|
155
|
+
H ${right_B}
|
|
156
|
+
m 5 -5 l -5 5 l 5 5`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_path_start_to_finish(left_A, right_B, y_A, y_B, y_mid, padding, curve) {
|
|
160
|
+
const x_left = left_A - padding;
|
|
161
|
+
const x_right = right_B + padding;
|
|
162
|
+
|
|
163
|
+
return `
|
|
164
|
+
M ${left_A} ${y_A}
|
|
165
|
+
H ${x_left + curve}
|
|
166
|
+
a ${curve} ${curve} 0 0 0 ${-curve} ${curve}
|
|
167
|
+
V ${y_mid - curve}
|
|
168
|
+
a ${curve} ${curve} 0 0 0 ${curve} ${curve}
|
|
169
|
+
H ${x_right - curve}
|
|
170
|
+
a ${curve} ${curve} 0 0 1 ${curve} ${curve}
|
|
171
|
+
V ${y_B - curve}
|
|
172
|
+
a ${curve} ${curve} 0 0 1 ${-curve} ${curve}
|
|
173
|
+
H ${right_B}
|
|
174
|
+
m 5 -5 l -5 5 l 5 5`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_get_connected_bars() {
|
|
178
|
+
const from_id = this.from_task.task.id;
|
|
179
|
+
const to_id = this.to_task.task.id;
|
|
180
|
+
return Array.from(
|
|
181
|
+
this.gantt.$svg.querySelectorAll(
|
|
182
|
+
`[data-id="${CSS.escape(from_id)}"], [data-id="${CSS.escape(to_id)}"]`
|
|
183
|
+
)
|
|
184
|
+
);
|
|
132
185
|
}
|
|
133
186
|
|
|
134
187
|
draw() {
|
|
@@ -145,11 +198,44 @@ export default class Arrow {
|
|
|
145
198
|
'data-to': this.to_task.task.id,
|
|
146
199
|
class: arrowClass,
|
|
147
200
|
});
|
|
201
|
+
|
|
202
|
+
// Wide transparent path for easier mouse targeting
|
|
203
|
+
this.hit_element = createSVG('path', {
|
|
204
|
+
d: this.path,
|
|
205
|
+
stroke: 'transparent',
|
|
206
|
+
'stroke-width': 15,
|
|
207
|
+
fill: 'none',
|
|
208
|
+
style: 'pointer-events: stroke; cursor: pointer;',
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
this.hit_element.addEventListener('mouseenter', () => {
|
|
212
|
+
this.is_hovered = true;
|
|
213
|
+
this.element.classList.add('arrow-hover');
|
|
214
|
+
const bar_class = this.is_invalid
|
|
215
|
+
? 'bar-arrow-invalid'
|
|
216
|
+
: this.is_critical
|
|
217
|
+
? 'bar-arrow-critical'
|
|
218
|
+
: 'bar-arrow-hover';
|
|
219
|
+
this._get_connected_bars().forEach(el => {
|
|
220
|
+
const bar = el.querySelector('.bar');
|
|
221
|
+
if (bar) bar.classList.add(bar_class);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
this.hit_element.addEventListener('mouseleave', () => {
|
|
226
|
+
this.is_hovered = false;
|
|
227
|
+
this.element.classList.remove('arrow-hover');
|
|
228
|
+
this._get_connected_bars().forEach(el => {
|
|
229
|
+
const bar = el.querySelector('.bar');
|
|
230
|
+
if (bar) bar.classList.remove('bar-arrow-hover', 'bar-arrow-critical', 'bar-arrow-invalid');
|
|
231
|
+
});
|
|
232
|
+
});
|
|
148
233
|
}
|
|
149
234
|
|
|
150
235
|
update() {
|
|
151
236
|
this.calculate_path();
|
|
152
237
|
this.element.setAttribute('d', this.path);
|
|
238
|
+
this.hit_element.setAttribute('d', this.path);
|
|
153
239
|
|
|
154
240
|
// Update invalid state
|
|
155
241
|
this.is_invalid = this.check_invalid_dependency();
|
|
@@ -161,6 +247,7 @@ export default class Arrow {
|
|
|
161
247
|
} else if (this.is_critical) {
|
|
162
248
|
arrowClass = 'arrow-critical';
|
|
163
249
|
}
|
|
164
|
-
this.
|
|
250
|
+
if (this.is_hovered) arrowClass += ' arrow-hover';
|
|
251
|
+
this.element.setAttribute('class', arrowClass.trim());
|
|
165
252
|
}
|
|
166
253
|
}
|
package/src/bar.js
CHANGED
|
@@ -655,76 +655,6 @@ export default class Bar {
|
|
|
655
655
|
this.update_arrow_position();
|
|
656
656
|
}
|
|
657
657
|
|
|
658
|
-
validate_dependency_constraints(new_x, new_width = null) {
|
|
659
|
-
const dependencies_type = this.task.dependencies_type || this.gantt.options.dependencies_type;
|
|
660
|
-
|
|
661
|
-
// For fixed dependencies, use old validation logic
|
|
662
|
-
if (dependencies_type === 'fixed') {
|
|
663
|
-
const xs = this.task.dependencies.map((dep) => {
|
|
664
|
-
return this.gantt.get_bar(dep).$bar.getX();
|
|
665
|
-
});
|
|
666
|
-
return xs.reduce((prev, curr) => {
|
|
667
|
-
return prev && new_x >= curr;
|
|
668
|
-
}, true);
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// Calculate what the new dates would be
|
|
672
|
-
const new_start_x = new_x / this.gantt.config.column_width;
|
|
673
|
-
const new_start = date_utils.add(
|
|
674
|
-
this.gantt.gantt_start,
|
|
675
|
-
new_start_x * this.gantt.config.step,
|
|
676
|
-
this.gantt.config.unit
|
|
677
|
-
);
|
|
678
|
-
|
|
679
|
-
const bar_width = new_width || this.$bar.getWidth();
|
|
680
|
-
const width_in_units = bar_width / this.gantt.config.column_width;
|
|
681
|
-
const new_end = date_utils.add(
|
|
682
|
-
new_start,
|
|
683
|
-
width_in_units * this.gantt.config.step,
|
|
684
|
-
this.gantt.config.unit
|
|
685
|
-
);
|
|
686
|
-
|
|
687
|
-
// Check constraints for each parent dependency
|
|
688
|
-
for (const dep_id of this.task.dependencies) {
|
|
689
|
-
const parent_bar = this.gantt.get_bar(dep_id);
|
|
690
|
-
if (!parent_bar) continue;
|
|
691
|
-
|
|
692
|
-
const parent_task = parent_bar.task;
|
|
693
|
-
|
|
694
|
-
switch(dependencies_type) {
|
|
695
|
-
case 'finish-to-start':
|
|
696
|
-
// This task cannot start before parent finishes
|
|
697
|
-
if (new_start < parent_task._end) {
|
|
698
|
-
return false;
|
|
699
|
-
}
|
|
700
|
-
break;
|
|
701
|
-
|
|
702
|
-
case 'start-to-start':
|
|
703
|
-
// This task cannot start before parent starts
|
|
704
|
-
if (new_start < parent_task._start) {
|
|
705
|
-
return false;
|
|
706
|
-
}
|
|
707
|
-
break;
|
|
708
|
-
|
|
709
|
-
case 'finish-to-finish':
|
|
710
|
-
// This task cannot finish before parent finishes
|
|
711
|
-
if (new_end < parent_task._end) {
|
|
712
|
-
return false;
|
|
713
|
-
}
|
|
714
|
-
break;
|
|
715
|
-
|
|
716
|
-
case 'start-to-finish':
|
|
717
|
-
// This task cannot finish before parent starts
|
|
718
|
-
if (new_end < parent_task._start) {
|
|
719
|
-
return false;
|
|
720
|
-
}
|
|
721
|
-
break;
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
return true;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
658
|
update_label_position_on_horizontal_scroll({ x, sx }) {
|
|
729
659
|
const container = this.gantt.$container;
|
|
730
660
|
const label = this.group.querySelector('.bar-label');
|
package/src/defaults.js
CHANGED
|
@@ -116,7 +116,8 @@ const DEFAULT_OPTIONS = {
|
|
|
116
116
|
column_width: null,
|
|
117
117
|
critical_path: false,
|
|
118
118
|
date_format: 'YYYY-MM-DD HH:mm',
|
|
119
|
-
dependencies_type: '
|
|
119
|
+
dependencies_type: 'finish-to-start',
|
|
120
|
+
dependency_shifting: 'none', // 'none' | 'maintain_buffer_all' | 'maintain_buffer_downstream' | 'consume_buffer'
|
|
120
121
|
upper_header_height: 45,
|
|
121
122
|
lower_header_height: 30,
|
|
122
123
|
snap_at: null,
|
|
@@ -126,7 +127,6 @@ const DEFAULT_OPTIONS = {
|
|
|
126
127
|
isRTL: false,
|
|
127
128
|
language: 'en',
|
|
128
129
|
lines: 'both',
|
|
129
|
-
move_dependencies: true,
|
|
130
130
|
padding: 18,
|
|
131
131
|
popup: (ctx) => {
|
|
132
132
|
ctx.set_title(ctx.task.name);
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computes how much each dependent task should shift after a task is dragged.
|
|
3
|
+
*
|
|
4
|
+
* @param {object[]} tasks - Full gantt tasks array (each with _start, _end as Date, id, dependencies)
|
|
5
|
+
* @param {string} movedTaskId - ID of the task that was dragged
|
|
6
|
+
* @param {number} deltaMs - Net movement in milliseconds (negative = moved earlier)
|
|
7
|
+
* @param {string} mode - Value of options.dependency_shifting
|
|
8
|
+
* @returns {Map<string, number>} taskId → deltaMs to apply (movedTaskId is excluded)
|
|
9
|
+
*/
|
|
10
|
+
export function compute_dependency_shifts(tasks, movedTaskId, deltaMs, mode, direction = 'downstream') {
|
|
11
|
+
if (mode === 'none' || deltaMs === 0) return new Map();
|
|
12
|
+
|
|
13
|
+
if (!['upstream', 'downstream', 'both'].includes(direction)) {
|
|
14
|
+
console.warn(`[frappe-gantt] compute_dependency_shifts: unknown direction "${direction}", falling back to "downstream"`);
|
|
15
|
+
direction = 'downstream';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Build graph
|
|
19
|
+
const taskById = new Map();
|
|
20
|
+
const predecessors = new Map(); // taskId -> [{ id, type }]
|
|
21
|
+
const successors = new Map(); // taskId -> [{ id, type }]
|
|
22
|
+
|
|
23
|
+
for (const task of tasks) {
|
|
24
|
+
if (task._has_no_dates) continue;
|
|
25
|
+
taskById.set(task.id, task);
|
|
26
|
+
predecessors.set(task.id, []);
|
|
27
|
+
successors.set(task.id, []);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const task of tasks) {
|
|
31
|
+
if (task._has_no_dates) continue;
|
|
32
|
+
for (const dep of task.dependencies || []) {
|
|
33
|
+
if (!taskById.has(dep.id)) continue;
|
|
34
|
+
const type = dep.type || 'finish-to-start';
|
|
35
|
+
predecessors.get(task.id).push({ id: dep.id, type });
|
|
36
|
+
successors.get(dep.id).push({ id: task.id, type });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (mode === 'maintain_buffer_all') {
|
|
41
|
+
const bidirectional = direction === 'both';
|
|
42
|
+
const upstream_only = direction === 'upstream';
|
|
43
|
+
return _bfs_shift(movedTaskId, deltaMs, predecessors, successors, bidirectional, upstream_only);
|
|
44
|
+
}
|
|
45
|
+
if (mode === 'maintain_buffer_downstream') {
|
|
46
|
+
const bidirectional = direction === 'both';
|
|
47
|
+
const upstream_only = direction === 'upstream';
|
|
48
|
+
return _bfs_shift(movedTaskId, deltaMs, predecessors, successors, bidirectional, upstream_only);
|
|
49
|
+
}
|
|
50
|
+
if (mode === 'consume_buffer') {
|
|
51
|
+
return _consume_buffer_shift(movedTaskId, deltaMs, taskById, predecessors, successors, direction);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return new Map();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* BFS traversal: applies the same deltaMs to every reachable task.
|
|
59
|
+
* bidirectional=true visits both upstream and downstream.
|
|
60
|
+
* upstream_only=true (and bidirectional=false) visits predecessors only.
|
|
61
|
+
* Default (both false) visits successors only.
|
|
62
|
+
*/
|
|
63
|
+
function _bfs_shift(movedTaskId, deltaMs, predecessors, successors, bidirectional, upstream_only) {
|
|
64
|
+
const result = new Map();
|
|
65
|
+
const visited = new Set([movedTaskId]);
|
|
66
|
+
const queue = [];
|
|
67
|
+
|
|
68
|
+
const enqueue = (neighbors) => {
|
|
69
|
+
for (const { id } of neighbors) {
|
|
70
|
+
if (!visited.has(id)) {
|
|
71
|
+
visited.add(id);
|
|
72
|
+
queue.push(id);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (upstream_only && !bidirectional) {
|
|
78
|
+
enqueue(predecessors.get(movedTaskId) || []);
|
|
79
|
+
} else {
|
|
80
|
+
enqueue(successors.get(movedTaskId) || []);
|
|
81
|
+
if (bidirectional) enqueue(predecessors.get(movedTaskId) || []);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
while (queue.length > 0) {
|
|
85
|
+
const id = queue.shift();
|
|
86
|
+
result.set(id, deltaMs);
|
|
87
|
+
if (upstream_only && !bidirectional) {
|
|
88
|
+
enqueue(predecessors.get(id) || []);
|
|
89
|
+
} else {
|
|
90
|
+
enqueue(successors.get(id) || []);
|
|
91
|
+
if (bidirectional) enqueue(predecessors.get(id) || []);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Dependency-type-aware shifting.
|
|
100
|
+
* Forward pass (topological order): shift downstream tasks by the minimum needed to resolve conflicts.
|
|
101
|
+
* Backward pass (reverse topological order): pull upstream tasks earlier if needed.
|
|
102
|
+
* Diamond resolution: when multiple predecessors propose a shift, take the maximum.
|
|
103
|
+
*/
|
|
104
|
+
function _consume_buffer_shift(movedTaskId, deltaMs, taskById, predecessors, successors, direction) {
|
|
105
|
+
// Kahn's algorithm for topological order
|
|
106
|
+
const in_degree = new Map();
|
|
107
|
+
for (const [id] of taskById) {
|
|
108
|
+
in_degree.set(id, (predecessors.get(id) || []).length);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const topo_order = [];
|
|
112
|
+
const queue = [];
|
|
113
|
+
for (const [id, deg] of in_degree) {
|
|
114
|
+
if (deg === 0) queue.push(id);
|
|
115
|
+
}
|
|
116
|
+
while (queue.length > 0) {
|
|
117
|
+
const id = queue.shift();
|
|
118
|
+
topo_order.push(id);
|
|
119
|
+
for (const { id: succId } of successors.get(id) || []) {
|
|
120
|
+
const new_deg = in_degree.get(succId) - 1;
|
|
121
|
+
in_degree.set(succId, new_deg);
|
|
122
|
+
if (new_deg === 0) queue.push(succId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The moved task's dates are already updated by date_changed() before this
|
|
127
|
+
// function is called, so its effective shift is 0 — no double-counting.
|
|
128
|
+
const shifts = new Map([[movedTaskId, 0]]);
|
|
129
|
+
|
|
130
|
+
// Helpers: effective timestamps accounting for accumulated shifts
|
|
131
|
+
const eff_start = (id) => taskById.get(id)._start.getTime() + (shifts.get(id) || 0);
|
|
132
|
+
const eff_end = (id) => taskById.get(id)._end.getTime() + (shifts.get(id) || 0);
|
|
133
|
+
|
|
134
|
+
// Forward pass: push downstream tasks later when a conflict exists
|
|
135
|
+
if (direction === 'downstream' || direction === 'both') {
|
|
136
|
+
for (const id of topo_order) {
|
|
137
|
+
if (id === movedTaskId) continue;
|
|
138
|
+
let max_shift = 0;
|
|
139
|
+
for (const { id: predId, type } of predecessors.get(id) || []) {
|
|
140
|
+
const needed = _conflict_shift(predId, id, type, eff_start, eff_end);
|
|
141
|
+
if (needed > max_shift) max_shift = needed;
|
|
142
|
+
}
|
|
143
|
+
if (max_shift > 0) {
|
|
144
|
+
shifts.set(id, (shifts.get(id) || 0) + max_shift);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Backward pass: pull upstream tasks earlier when a conflict exists
|
|
150
|
+
if (direction === 'upstream' || direction === 'both') {
|
|
151
|
+
for (let i = topo_order.length - 1; i >= 0; i--) {
|
|
152
|
+
const id = topo_order[i];
|
|
153
|
+
if (id === movedTaskId) continue;
|
|
154
|
+
let max_pull = 0; // most-negative value wins (furthest earlier)
|
|
155
|
+
for (const { id: succId, type } of successors.get(id) || []) {
|
|
156
|
+
const needed = _conflict_shift(id, succId, type, eff_start, eff_end);
|
|
157
|
+
// If a forward conflict exists for this pair, the predecessor must shift earlier
|
|
158
|
+
if (needed > 0) {
|
|
159
|
+
const pull = -needed;
|
|
160
|
+
if (pull < max_pull) max_pull = pull;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (max_pull < 0) {
|
|
164
|
+
shifts.set(id, (shifts.get(id) || 0) + max_pull);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Return map without the moved task (caller already applied its shift)
|
|
170
|
+
const result = new Map();
|
|
171
|
+
for (const [id, shift] of shifts) {
|
|
172
|
+
if (id !== movedTaskId && shift !== 0) {
|
|
173
|
+
result.set(id, shift);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Returns the positive millisecond shift needed to resolve a conflict between pred and succ,
|
|
181
|
+
* based on the dependency type. Returns 0 if no conflict.
|
|
182
|
+
*/
|
|
183
|
+
function _conflict_shift(predId, succId, type, eff_start, eff_end) {
|
|
184
|
+
const pred_start = eff_start(predId);
|
|
185
|
+
const pred_end = eff_end(predId);
|
|
186
|
+
const succ_start = eff_start(succId);
|
|
187
|
+
const succ_end = eff_end(succId);
|
|
188
|
+
|
|
189
|
+
switch (type) {
|
|
190
|
+
case 'finish-to-start':
|
|
191
|
+
return pred_end > succ_start ? pred_end - succ_start : 0;
|
|
192
|
+
case 'start-to-start':
|
|
193
|
+
return pred_start > succ_start ? pred_start - succ_start : 0;
|
|
194
|
+
case 'finish-to-finish':
|
|
195
|
+
return pred_end > succ_end ? pred_end - succ_end : 0;
|
|
196
|
+
case 'start-to-finish':
|
|
197
|
+
return pred_start > succ_end ? pred_start - succ_end : 0;
|
|
198
|
+
default:
|
|
199
|
+
return pred_end > succ_start ? pred_end - succ_start : 0;
|
|
200
|
+
}
|
|
201
|
+
}
|