ct-gantt-core 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +570 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +235 -0
- package/dist/index.d.ts +235 -0
- package/dist/index.js +526 -0
- package/dist/index.js.map +1 -0
- package/package.json +28 -0
- package/src/engines/computeImpact.ts +98 -0
- package/src/engines/computeLayout.ts +73 -0
- package/src/engines/scheduling.ts +155 -0
- package/src/index.ts +9 -0
- package/src/services/checkCyclicDependency.ts +54 -0
- package/src/services/computeTimeScale.ts +71 -0
- package/src/services/flattenTasks.ts +38 -0
- package/src/services/normalizeLinks.ts +69 -0
- package/src/types/index.ts +241 -0
- package/src/utils/date.ts +54 -0
- package/tests/core.test.ts +137 -0
- package/tsconfig.json +11 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { AffectedTasks, GanttConfig, GanttLink, GanttTask, PatchTask, Result } from "../types"
|
|
2
|
+
import { flattenTasks } from "../services/flattenTasks"
|
|
3
|
+
import { scheduleByDependencies, shiftTask } from "./scheduling"
|
|
4
|
+
import { addDays, inclusiveDays, toDate } from "../utils/date"
|
|
5
|
+
|
|
6
|
+
export function computeImpact(
|
|
7
|
+
taskId: string,
|
|
8
|
+
patch: PatchTask,
|
|
9
|
+
tasks: GanttTask[],
|
|
10
|
+
links: GanttLink[],
|
|
11
|
+
config: Partial<GanttConfig>
|
|
12
|
+
): Result<AffectedTasks> {
|
|
13
|
+
const target = tasks.find((task) => task.id === taskId)
|
|
14
|
+
if (!target) {
|
|
15
|
+
return {
|
|
16
|
+
ok: false,
|
|
17
|
+
error: {
|
|
18
|
+
code: "MISSING_TASK",
|
|
19
|
+
message: `Task ${taskId} does not exist.`
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const patchedTasks = tasks.map((task) => {
|
|
25
|
+
if (task.id !== taskId) {
|
|
26
|
+
return task
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let next = { ...task, actual: { ...task.actual } }
|
|
30
|
+
if (patch.actualStart) {
|
|
31
|
+
next = shiftTask(next, patch.actualStart)
|
|
32
|
+
}
|
|
33
|
+
if (patch.actualEnd) {
|
|
34
|
+
next.actual.end = patch.actualEnd
|
|
35
|
+
}
|
|
36
|
+
if (typeof patch.progress === "number") {
|
|
37
|
+
next.actual.progress = patch.progress
|
|
38
|
+
}
|
|
39
|
+
if (patch.duration) {
|
|
40
|
+
next.duration = patch.duration
|
|
41
|
+
next.actual.end = addDays(toDate(next.actual.start), Math.max(0, patch.duration - 1))
|
|
42
|
+
}
|
|
43
|
+
if (patch.name) {
|
|
44
|
+
next.name = patch.name
|
|
45
|
+
}
|
|
46
|
+
if (patch.type) {
|
|
47
|
+
next.type = patch.type
|
|
48
|
+
}
|
|
49
|
+
if ("parentId" in patch) {
|
|
50
|
+
next.parentId = patch.parentId
|
|
51
|
+
}
|
|
52
|
+
if (patch.color) {
|
|
53
|
+
next.color = patch.color
|
|
54
|
+
}
|
|
55
|
+
if ("planColor" in patch) {
|
|
56
|
+
next.planColor = patch.planColor
|
|
57
|
+
}
|
|
58
|
+
if (patch.schedulingMode) {
|
|
59
|
+
next.schedulingMode = patch.schedulingMode
|
|
60
|
+
}
|
|
61
|
+
return next
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const scheduled = config.autoSchedule === false
|
|
65
|
+
? { ok: true as const, data: patchedTasks }
|
|
66
|
+
: scheduleByDependencies(patchedTasks, links)
|
|
67
|
+
|
|
68
|
+
if (!scheduled.ok) {
|
|
69
|
+
return scheduled
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const rows = new Map(flattenTasks(scheduled.data).map((flat) => [flat.task.id, flat.rowIndex]))
|
|
73
|
+
const originalById = new Map(tasks.map((task) => [task.id, task]))
|
|
74
|
+
const changed = scheduled.data
|
|
75
|
+
.filter((task) => {
|
|
76
|
+
const original = originalById.get(task.id)
|
|
77
|
+
return original && (
|
|
78
|
+
String(original.actual.start) !== String(task.actual.start) ||
|
|
79
|
+
String(original.actual.end) !== String(task.actual.end)
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
.map((task) => ({
|
|
83
|
+
taskId: task.id,
|
|
84
|
+
actualStart: task.actual.start,
|
|
85
|
+
actualEnd: task.actual.end,
|
|
86
|
+
rowIndex: rows.get(task.id) ?? 0
|
|
87
|
+
}))
|
|
88
|
+
|
|
89
|
+
const conflicts = scheduled.data
|
|
90
|
+
.filter((task) => task.constraint?.date && inclusiveDays(task.actual.start, task.constraint.date) < 0)
|
|
91
|
+
.map((task) => ({
|
|
92
|
+
taskId: task.id,
|
|
93
|
+
constraintType: task.constraint!.type,
|
|
94
|
+
message: "Task violates its configured constraint date."
|
|
95
|
+
}))
|
|
96
|
+
|
|
97
|
+
return { ok: true, data: { changed, conflicts } }
|
|
98
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { GanttConfig, GanttLink, GanttTask, Result, TaskLayout, Viewport } from "../types"
|
|
2
|
+
import { defaultConfig } from "../types"
|
|
3
|
+
import { flattenTasks } from "../services/flattenTasks"
|
|
4
|
+
import { normalizeLinks } from "../services/normalizeLinks"
|
|
5
|
+
import { scheduleByDependencies } from "./scheduling"
|
|
6
|
+
import { diffDays, inclusiveDays, isValidDate, toDate } from "../utils/date"
|
|
7
|
+
|
|
8
|
+
export function computeLayout(
|
|
9
|
+
tasks: GanttTask[],
|
|
10
|
+
links: GanttLink[],
|
|
11
|
+
config: Partial<GanttConfig>,
|
|
12
|
+
collapsedIds: Set<string> = new Set(),
|
|
13
|
+
viewport?: Viewport
|
|
14
|
+
): Result<TaskLayout[]> {
|
|
15
|
+
const mergedConfig = { ...defaultConfig, ...config }
|
|
16
|
+
|
|
17
|
+
for (const task of tasks) {
|
|
18
|
+
if (!isValidDate(task.actual.start) || !isValidDate(task.actual.end)) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
error: {
|
|
22
|
+
code: "INVALID_DATE",
|
|
23
|
+
message: `Task ${task.id} has invalid actual dates.`
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const normalizedLinks = normalizeLinks(tasks, links)
|
|
30
|
+
const scheduled = mergedConfig.autoSchedule === false
|
|
31
|
+
? { ok: true as const, data: tasks }
|
|
32
|
+
: scheduleByDependencies(tasks, normalizedLinks)
|
|
33
|
+
|
|
34
|
+
if (!scheduled.ok) {
|
|
35
|
+
return scheduled
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const flatTasks = flattenTasks(scheduled.data, collapsedIds)
|
|
39
|
+
const firstDate = mergedConfig.visibleRange
|
|
40
|
+
? toDate(mergedConfig.visibleRange.start)
|
|
41
|
+
: minDate(scheduled.data.map((task) => toDate(task.actual.start)))
|
|
42
|
+
|
|
43
|
+
const visibleStart = viewport
|
|
44
|
+
? Math.max(0, Math.floor(viewport.scrollTop / mergedConfig.rowHeight) - 8)
|
|
45
|
+
: 0
|
|
46
|
+
const visibleEnd = viewport
|
|
47
|
+
? Math.ceil((viewport.scrollTop + viewport.clientHeight) / mergedConfig.rowHeight) + 8
|
|
48
|
+
: Number.POSITIVE_INFINITY
|
|
49
|
+
|
|
50
|
+
const layouts = flatTasks
|
|
51
|
+
.filter((flat) => flat.rowIndex >= visibleStart && flat.rowIndex <= visibleEnd)
|
|
52
|
+
.map<TaskLayout>((flat) => {
|
|
53
|
+
const start = toDate(flat.task.actual.start)
|
|
54
|
+
const end = toDate(flat.task.actual.end)
|
|
55
|
+
const duration = flat.task.type === "milestone" ? 0 : inclusiveDays(start, end)
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
taskId: flat.task.id,
|
|
59
|
+
rowIndex: flat.rowIndex,
|
|
60
|
+
left: diffDays(firstDate, start) * mergedConfig.columnWidth,
|
|
61
|
+
width: Math.max(flat.task.type === "milestone" ? mergedConfig.columnWidth / 2 : mergedConfig.columnWidth, duration * mergedConfig.columnWidth),
|
|
62
|
+
top: mergedConfig.headerHeight + flat.rowIndex * mergedConfig.rowHeight,
|
|
63
|
+
depth: flat.depth,
|
|
64
|
+
isCritical: false
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return { ok: true, data: layouts }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function minDate(dates: Date[]): Date {
|
|
72
|
+
return new Date(Math.min(...dates.map((date) => date.getTime())))
|
|
73
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { GanttLink, GanttTask, LinkType, Result } from "../types"
|
|
2
|
+
import { addDays, diffDays, inclusiveDays, toDate } from "../utils/date"
|
|
3
|
+
import { checkCyclicDependency } from "../services/checkCyclicDependency"
|
|
4
|
+
|
|
5
|
+
export function scheduleByDependencies(tasks: GanttTask[], links: GanttLink[]): Result<GanttTask[]> {
|
|
6
|
+
const cycle = checkCyclicDependency(links)
|
|
7
|
+
if (cycle.hasCycle) {
|
|
8
|
+
return {
|
|
9
|
+
ok: false,
|
|
10
|
+
error: {
|
|
11
|
+
code: "CYCLE_DEPENDENCY",
|
|
12
|
+
message: "Dependency graph contains a cycle.",
|
|
13
|
+
details: cycle.cyclePath
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const taskMap = new Map(tasks.map((task) => [task.id, cloneTask(task)]))
|
|
19
|
+
const incoming = new Map<string, GanttLink[]>()
|
|
20
|
+
const outgoing = new Map<string, GanttLink[]>()
|
|
21
|
+
|
|
22
|
+
for (const link of links) {
|
|
23
|
+
const source = taskMap.get(link.sourceId)
|
|
24
|
+
const target = taskMap.get(link.targetId)
|
|
25
|
+
if (!source || !target) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
error: {
|
|
29
|
+
code: "ORPHAN_DEPENDENCY",
|
|
30
|
+
message: `Dependency ${link.id} references a missing task.`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
incoming.set(link.targetId, [...(incoming.get(link.targetId) ?? []), link])
|
|
35
|
+
outgoing.set(link.sourceId, [...(outgoing.get(link.sourceId) ?? []), link])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ordered = topologicalOrder(tasks.map((task) => task.id), links)
|
|
39
|
+
|
|
40
|
+
for (const taskId of ordered) {
|
|
41
|
+
const task = taskMap.get(taskId)
|
|
42
|
+
if (!task || task.schedulingMode === "manual") {
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const linksToTask = incoming.get(taskId) ?? []
|
|
47
|
+
let start = toDate(task.actual.start)
|
|
48
|
+
let end = toDate(task.actual.end)
|
|
49
|
+
const duration = Math.max(0, task.duration ?? inclusiveDays(start, end))
|
|
50
|
+
|
|
51
|
+
for (const link of linksToTask) {
|
|
52
|
+
const source = taskMap.get(link.sourceId)
|
|
53
|
+
if (!source) {
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const candidate = applyDependency(source, start, end, duration, link.type, link.lag ?? 0)
|
|
58
|
+
if (candidate.start.getTime() > start.getTime()) {
|
|
59
|
+
start = candidate.start
|
|
60
|
+
}
|
|
61
|
+
if (candidate.end.getTime() > end.getTime()) {
|
|
62
|
+
end = candidate.end
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const normalizedEnd = task.type === "milestone" ? start : addDays(start, Math.max(0, duration - 1))
|
|
67
|
+
task.actual = {
|
|
68
|
+
...task.actual,
|
|
69
|
+
start,
|
|
70
|
+
end: normalizedEnd
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { ok: true, data: tasks.map((task) => taskMap.get(task.id) ?? task) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function applyDependency(
|
|
78
|
+
source: GanttTask,
|
|
79
|
+
currentStart: Date,
|
|
80
|
+
currentEnd: Date,
|
|
81
|
+
duration: number,
|
|
82
|
+
type: LinkType,
|
|
83
|
+
lag: number
|
|
84
|
+
): { start: Date; end: Date } {
|
|
85
|
+
const sourceStart = toDate(source.actual.start)
|
|
86
|
+
const sourceEnd = toDate(source.actual.end)
|
|
87
|
+
const span = Math.max(0, duration - 1)
|
|
88
|
+
|
|
89
|
+
if (type === "FS") {
|
|
90
|
+
const start = addDays(sourceEnd, lag + 1)
|
|
91
|
+
return { start, end: addDays(start, span) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (type === "SS") {
|
|
95
|
+
const start = addDays(sourceStart, lag)
|
|
96
|
+
return { start, end: addDays(start, span) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (type === "FF") {
|
|
100
|
+
const end = addDays(sourceEnd, lag)
|
|
101
|
+
return { start: addDays(end, -span), end }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const end = addDays(sourceStart, lag)
|
|
105
|
+
return { start: addDays(end, -span), end: end.getTime() > currentEnd.getTime() ? end : currentEnd }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function topologicalOrder(ids: string[], links: GanttLink[]): string[] {
|
|
109
|
+
const indegree = new Map(ids.map((id) => [id, 0]))
|
|
110
|
+
const outgoing = new Map<string, string[]>()
|
|
111
|
+
|
|
112
|
+
for (const link of links) {
|
|
113
|
+
indegree.set(link.targetId, (indegree.get(link.targetId) ?? 0) + 1)
|
|
114
|
+
outgoing.set(link.sourceId, [...(outgoing.get(link.sourceId) ?? []), link.targetId])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const queue = ids.filter((id) => (indegree.get(id) ?? 0) === 0)
|
|
118
|
+
const ordered: string[] = []
|
|
119
|
+
|
|
120
|
+
while (queue.length) {
|
|
121
|
+
const current = queue.shift()!
|
|
122
|
+
ordered.push(current)
|
|
123
|
+
for (const target of outgoing.get(current) ?? []) {
|
|
124
|
+
indegree.set(target, (indegree.get(target) ?? 1) - 1)
|
|
125
|
+
if (indegree.get(target) === 0) {
|
|
126
|
+
queue.push(target)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return ordered.length === ids.length ? ordered : ids
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function cloneTask(task: GanttTask): GanttTask {
|
|
135
|
+
return {
|
|
136
|
+
...task,
|
|
137
|
+
plan: { ...task.plan },
|
|
138
|
+
actual: { ...task.actual },
|
|
139
|
+
dependencies: task.dependencies?.map((dependency) => ({ ...dependency }))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function shiftTask(task: GanttTask, start: string | Date): GanttTask {
|
|
144
|
+
const currentStart = toDate(task.actual.start)
|
|
145
|
+
const nextStart = toDate(start)
|
|
146
|
+
const delta = diffDays(currentStart, nextStart)
|
|
147
|
+
return {
|
|
148
|
+
...task,
|
|
149
|
+
actual: {
|
|
150
|
+
...task.actual,
|
|
151
|
+
start: nextStart,
|
|
152
|
+
end: addDays(toDate(task.actual.end), delta)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./types"
|
|
2
|
+
export * from "./services/checkCyclicDependency"
|
|
3
|
+
export * from "./services/normalizeLinks"
|
|
4
|
+
export * from "./services/flattenTasks"
|
|
5
|
+
export * from "./services/computeTimeScale"
|
|
6
|
+
export * from "./engines/computeLayout"
|
|
7
|
+
export * from "./engines/computeImpact"
|
|
8
|
+
export * from "./engines/scheduling"
|
|
9
|
+
export * from "./utils/date"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { GanttLink } from "../types"
|
|
2
|
+
|
|
3
|
+
export function checkCyclicDependency(
|
|
4
|
+
links: GanttLink[]
|
|
5
|
+
): { hasCycle: boolean; cyclePath?: string[] } {
|
|
6
|
+
const graph = new Map<string, string[]>()
|
|
7
|
+
|
|
8
|
+
for (const link of links) {
|
|
9
|
+
const targets = graph.get(link.sourceId) ?? []
|
|
10
|
+
targets.push(link.targetId)
|
|
11
|
+
graph.set(link.sourceId, targets)
|
|
12
|
+
if (!graph.has(link.targetId)) {
|
|
13
|
+
graph.set(link.targetId, [])
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const visiting = new Set<string>()
|
|
18
|
+
const visited = new Set<string>()
|
|
19
|
+
const path: string[] = []
|
|
20
|
+
|
|
21
|
+
const dfs = (node: string): string[] | undefined => {
|
|
22
|
+
if (visiting.has(node)) {
|
|
23
|
+
const start = path.indexOf(node)
|
|
24
|
+
return [...path.slice(start), node]
|
|
25
|
+
}
|
|
26
|
+
if (visited.has(node)) {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
visiting.add(node)
|
|
31
|
+
path.push(node)
|
|
32
|
+
|
|
33
|
+
for (const next of graph.get(node) ?? []) {
|
|
34
|
+
const cycle = dfs(next)
|
|
35
|
+
if (cycle) {
|
|
36
|
+
return cycle
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
path.pop()
|
|
41
|
+
visiting.delete(node)
|
|
42
|
+
visited.add(node)
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const node of graph.keys()) {
|
|
47
|
+
const cyclePath = dfs(node)
|
|
48
|
+
if (cyclePath) {
|
|
49
|
+
return { hasCycle: true, cyclePath }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { hasCycle: false }
|
|
54
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { TimeScale, ViewMode } from "../types"
|
|
2
|
+
import { addDays, diffDays, startOfDay } from "../utils/date"
|
|
3
|
+
|
|
4
|
+
export function computeTimeScale(
|
|
5
|
+
start: Date,
|
|
6
|
+
end: Date,
|
|
7
|
+
viewMode: ViewMode,
|
|
8
|
+
columnWidth: number,
|
|
9
|
+
firstDayOfWeek: 0 | 1
|
|
10
|
+
): TimeScale[] {
|
|
11
|
+
const rangeStart = alignStart(start, viewMode, firstDayOfWeek)
|
|
12
|
+
const endDate = startOfDay(end)
|
|
13
|
+
const rangeEnd = viewMode === "day"
|
|
14
|
+
? addDays(endDate, (firstDayOfWeek + 6 - endDate.getDay() + 7) % 7)
|
|
15
|
+
: endDate
|
|
16
|
+
const scale: TimeScale[] = []
|
|
17
|
+
let cursor = rangeStart
|
|
18
|
+
let left = 0
|
|
19
|
+
|
|
20
|
+
while (cursor.getTime() <= rangeEnd.getTime()) {
|
|
21
|
+
const next = nextUnit(cursor, viewMode)
|
|
22
|
+
const unitEnd = addDays(next, -1)
|
|
23
|
+
const width = Math.max(1, diffDays(cursor, next)) * columnWidth
|
|
24
|
+
|
|
25
|
+
scale.push({
|
|
26
|
+
start: cursor,
|
|
27
|
+
end: unitEnd,
|
|
28
|
+
left,
|
|
29
|
+
width
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
left += width
|
|
33
|
+
cursor = next
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return scale
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function alignStart(date: Date, viewMode: ViewMode, firstDayOfWeek: 0 | 1): Date {
|
|
40
|
+
const start = startOfDay(date)
|
|
41
|
+
if (viewMode === "day" || viewMode === "week") {
|
|
42
|
+
const delta = (start.getDay() - firstDayOfWeek + 7) % 7
|
|
43
|
+
return addDays(start, -delta)
|
|
44
|
+
}
|
|
45
|
+
if (viewMode === "month") {
|
|
46
|
+
return new Date(start.getFullYear(), start.getMonth(), 1)
|
|
47
|
+
}
|
|
48
|
+
if (viewMode === "quarter") {
|
|
49
|
+
return new Date(start.getFullYear(), Math.floor(start.getMonth() / 3) * 3, 1)
|
|
50
|
+
}
|
|
51
|
+
if (viewMode === "year") {
|
|
52
|
+
return new Date(start.getFullYear(), 0, 1)
|
|
53
|
+
}
|
|
54
|
+
return start
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function nextUnit(date: Date, viewMode: ViewMode): Date {
|
|
58
|
+
if (viewMode === "week") {
|
|
59
|
+
return addDays(date, 7)
|
|
60
|
+
}
|
|
61
|
+
if (viewMode === "month") {
|
|
62
|
+
return new Date(date.getFullYear(), date.getMonth() + 1, 1)
|
|
63
|
+
}
|
|
64
|
+
if (viewMode === "quarter") {
|
|
65
|
+
return new Date(date.getFullYear(), date.getMonth() + 3, 1)
|
|
66
|
+
}
|
|
67
|
+
if (viewMode === "year") {
|
|
68
|
+
return new Date(date.getFullYear() + 1, 0, 1)
|
|
69
|
+
}
|
|
70
|
+
return addDays(date, 1)
|
|
71
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { FlatTask, GanttTask } from "../types"
|
|
2
|
+
|
|
3
|
+
export function flattenTasks(tasks: GanttTask[], collapsedIds: Set<string> = new Set()): FlatTask[] {
|
|
4
|
+
const children = new Map<string | null, GanttTask[]>()
|
|
5
|
+
|
|
6
|
+
for (const task of tasks) {
|
|
7
|
+
const parentId = task.parentId ?? null
|
|
8
|
+
const bucket = children.get(parentId) ?? []
|
|
9
|
+
bucket.push(task)
|
|
10
|
+
children.set(parentId, bucket)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const flattened: FlatTask[] = []
|
|
14
|
+
const visit = (task: GanttTask, depth: number) => {
|
|
15
|
+
const childTasks = children.get(task.id) ?? []
|
|
16
|
+
const collapsed = collapsedIds.has(task.id)
|
|
17
|
+
|
|
18
|
+
flattened.push({
|
|
19
|
+
task,
|
|
20
|
+
depth,
|
|
21
|
+
rowIndex: flattened.length,
|
|
22
|
+
hasChildren: childTasks.length > 0,
|
|
23
|
+
collapsed
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
if (!collapsed) {
|
|
27
|
+
for (const child of childTasks) {
|
|
28
|
+
visit(child, depth + 1)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const root of children.get(null) ?? []) {
|
|
34
|
+
visit(root, 0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return flattened
|
|
38
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { GanttLink, GanttTask } from "../types"
|
|
2
|
+
|
|
3
|
+
export function normalizeLinks(tasks: GanttTask[], standaloneLinks: GanttLink[] = []): GanttLink[] {
|
|
4
|
+
const taskIds = new Set(tasks.map((task) => task.id))
|
|
5
|
+
const byTaskPair = new Map<string, GanttLink>()
|
|
6
|
+
const outgoing = new Map<string, Set<string>>()
|
|
7
|
+
|
|
8
|
+
const createsCycle = (sourceId: string, targetId: string): boolean => {
|
|
9
|
+
const pending = [targetId]
|
|
10
|
+
const visited = new Set<string>()
|
|
11
|
+
while (pending.length > 0) {
|
|
12
|
+
const current = pending.pop()!
|
|
13
|
+
if (current === sourceId) {
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
if (visited.has(current)) {
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
visited.add(current)
|
|
20
|
+
pending.push(...(outgoing.get(current) ?? []))
|
|
21
|
+
}
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const append = (link: GanttLink) => {
|
|
26
|
+
if (
|
|
27
|
+
!taskIds.has(link.sourceId)
|
|
28
|
+
|| !taskIds.has(link.targetId)
|
|
29
|
+
|| link.sourceId === link.targetId
|
|
30
|
+
) {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const normalized: GanttLink = {
|
|
35
|
+
...link,
|
|
36
|
+
id: link.id || `link-${link.sourceId}-${link.targetId}`,
|
|
37
|
+
lag: link.lag ?? 0,
|
|
38
|
+
lagUnit: link.lagUnit ?? "calendar"
|
|
39
|
+
}
|
|
40
|
+
const pairKey = `${normalized.sourceId}|${normalized.targetId}`
|
|
41
|
+
if (byTaskPair.has(pairKey) || createsCycle(normalized.sourceId, normalized.targetId)) {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
byTaskPair.set(pairKey, normalized)
|
|
46
|
+
const targets = outgoing.get(normalized.sourceId) ?? new Set<string>()
|
|
47
|
+
targets.add(normalized.targetId)
|
|
48
|
+
outgoing.set(normalized.sourceId, targets)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const task of tasks) {
|
|
52
|
+
for (const dependency of task.dependencies ?? []) {
|
|
53
|
+
append({
|
|
54
|
+
id: dependency.id || `link-${dependency.predecessorId}-${task.id}`,
|
|
55
|
+
sourceId: dependency.predecessorId,
|
|
56
|
+
targetId: task.id,
|
|
57
|
+
type: dependency.type,
|
|
58
|
+
lag: dependency.lag,
|
|
59
|
+
lagUnit: dependency.lagUnit
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const link of standaloneLinks) {
|
|
65
|
+
append(link)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return [...byTaskPair.values()]
|
|
69
|
+
}
|