ct-gantt-core 1.0.3 → 1.0.5

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 ADDED
@@ -0,0 +1,80 @@
1
+ # ct-gantt-core
2
+
3
+ 框架无关的甘特图数据、布局、依赖排程和命令式引擎。该包不包含界面,可用于 Vue、React、原生 JavaScript、服务端计算或自定义渲染器。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pnpm add ct-gantt-core
9
+ ```
10
+
11
+ 也可以使用 `npm install ct-gantt-core` 或 `yarn add ct-gantt-core`。
12
+
13
+ ## 最小示例
14
+
15
+ ```ts
16
+ import { GanttEngine, type GanttTask } from "ct-gantt-core"
17
+
18
+ const tasks: GanttTask[] = [
19
+ {
20
+ id: "task-1",
21
+ name: "需求分析",
22
+ type: "task",
23
+ plan: { start: "2026-07-01", end: "2026-07-05" },
24
+ actual: { start: "2026-07-01", end: "2026-07-06", progress: 60 }
25
+ }
26
+ ]
27
+
28
+ const engine = new GanttEngine({
29
+ tasks,
30
+ config: { viewMode: "day", columnWidth: 30 }
31
+ })
32
+
33
+ const layout = engine.getLayout()
34
+ if (layout.ok) {
35
+ console.log(layout.data)
36
+ }
37
+
38
+ engine.setTask("task-1", { progress: 80 })
39
+ engine.destroy()
40
+ ```
41
+
42
+ ## 依赖排程
43
+
44
+ ```ts
45
+ import { scheduleByDependencies, type GanttLink } from "ct-gantt-core"
46
+
47
+ const links: GanttLink[] = [
48
+ { id: "design-to-dev", sourceId: "design", targetId: "dev", type: "FS" }
49
+ ]
50
+
51
+ const result = scheduleByDependencies(tasks, links)
52
+ if (result.ok) {
53
+ console.log(result.data)
54
+ }
55
+ ```
56
+
57
+ 支持 `FS`、`SS`、`FF`、`SF` 四种依赖类型,以及自然日或工作日间隔。
58
+
59
+ ## 主要导出
60
+
61
+ | API | 用途 |
62
+ | --- | --- |
63
+ | `GanttEngine` | 管理任务、依赖、折叠、预览、布局和视口命令 |
64
+ | `computeLayout` | 计算任务条位置和尺寸 |
65
+ | `computeTimeScale` | 计算时间刻度 |
66
+ | `scheduleByDependencies` | 按依赖关系调整任务日期 |
67
+ | `computeImpact` | 分析任务变更的影响和约束冲突 |
68
+ | `checkCyclicDependency` | 检查循环依赖 |
69
+ | `normalizeLinks` | 统一任务内依赖与独立依赖数据 |
70
+ | `flattenTasks` | 将阶段树转换为可渲染行 |
71
+ | `toDate`、`addDays`、`diffDays` | 日期工具 |
72
+
73
+ ## 数据说明
74
+
75
+ - `plan` 表示计划日期。
76
+ - `actual` 表示实际日期和完成进度。
77
+ - `summary` 表示阶段,`task` 表示普通任务,`milestone` 表示任务型里程碑。
78
+ - Core 不负责绘制界面;需要现成 Vue 界面时请安装 `ct-gantt-vue`。
79
+
80
+ 完整类型、配置和示例请查看[项目文档](https://github.com/moonlight-219/ganttu#readme)。
package/package.json CHANGED
@@ -1,10 +1,20 @@
1
1
  {
2
2
  "name": "ct-gantt-core",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
+ "homepage": "https://github.com/moonlight-219/ganttu#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/moonlight-219/ganttu.git",
12
+ "directory": "packages/core"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
8
18
  "exports": {
9
19
  ".": {
10
20
  "types": "./dist/index.d.ts",
@@ -1,432 +0,0 @@
1
- import type {
2
- GanttConfig,
3
- GanttTask,
4
- GanttLink,
5
- GanttMarker,
6
- PatchTask,
7
- TaskLayout,
8
- TimeScale,
9
- FlatTask,
10
- Result
11
- } from "../types"
12
- import { defaultConfig } from "../types"
13
- import { computeLayout } from "../engines/computeLayout"
14
- import { computeTimeScale } from "../services/computeTimeScale"
15
- import { flattenTasks } from "../services/flattenTasks"
16
- import { toDate, diffDays, addDays } from "../utils/date"
17
-
18
- /**
19
- * 把扁平的 PatchTask 合并回嵌套的 GanttTask。
20
- * PatchTask 用 planStart/planEnd/actualStart/actualEnd 表示日期,
21
- * GanttTask 用 plan.start/end、actual.start/end 嵌套表示——这是组件层
22
- * 和数据层之间的桥,本该在引擎层而不是散落在各 demo 里。
23
- */
24
- export function mergeTaskPatch(task: GanttTask, patch: PatchTask): GanttTask {
25
- return {
26
- ...task,
27
- ...patch,
28
- plan: {
29
- ...task.plan,
30
- start: patch.planStart ?? task.plan.start,
31
- end: patch.planEnd ?? task.plan.end,
32
- progress: patch.progress ?? task.plan.progress
33
- },
34
- actual: {
35
- ...task.actual,
36
- start: patch.actualStart ?? task.actual.start,
37
- end: patch.actualEnd ?? task.actual.end,
38
- progress: patch.progress ?? task.actual.progress
39
- },
40
- custom: patch.custom ? { ...task.custom, ...patch.custom } : task.custom
41
- }
42
- }
43
-
44
- export interface GanttEngineOptions {
45
- tasks?: GanttTask[]
46
- links?: GanttLink[]
47
- config?: Partial<GanttConfig>
48
- /** 滚动容器元素;命令式视口 API(scrollToDate / zoomToFit)需要它。纯计算场景可不传。 */
49
- container?: HTMLElement | null
50
- /** 初始折叠的任务 id */
51
- collapsedIds?: Iterable<string>
52
- /** 里程碑标记 */
53
- markers?: GanttMarker[]
54
- }
55
-
56
- export type GanttEngineEvent =
57
- | "ready"
58
- | "taskschange"
59
- | "taskchange"
60
- | "taskcreate"
61
- | "taskdelete"
62
- | "linkschange"
63
- | "configchange"
64
- | "collapsechange"
65
- | "previewchange"
66
- | "markerschange"
67
- | "destroy"
68
-
69
- type Listener = (...args: unknown[]) => void
70
-
71
- /** 命令式甘特图引擎。框架无关,可被 Vue/React/原生 各自的薄壳组件驱动。 */
72
- export class GanttEngine {
73
- private tasks: GanttTask[]
74
- private links: GanttLink[]
75
- private config: GanttConfig
76
- private readonly container: HTMLElement | null
77
- private collapsedIds: Set<string>
78
- private dragPreview: { taskId: string; patch: PatchTask; affected?: Record<string, PatchTask> } | null = null
79
- private markers: GanttMarker[]
80
- private readonly listeners: Map<GanttEngineEvent, Set<Listener>>
81
- private destroyed = false
82
-
83
- constructor(options: GanttEngineOptions = {}) {
84
- this.tasks = options.tasks ? options.tasks.map((task) => ({ ...task })) : []
85
- this.links = options.links ? options.links.map((link) => ({ ...link })) : []
86
- this.config = { ...defaultConfig, ...options.config }
87
- this.container = options.container ?? null
88
- this.collapsedIds = new Set(options.collapsedIds ?? [])
89
- this.markers = options.markers ? options.markers.map((marker) => ({ ...marker })) : []
90
- this.listeners = new Map()
91
- // 异步发 ready,确保订阅者能在构造后、首次 emit 前注册
92
- queueMicrotask(() => this.emit("ready"))
93
- }
94
-
95
- // ── 状态查询 ──
96
- getTasks(): GanttTask[] {
97
- return this.tasks.map((task) => ({ ...task }))
98
- }
99
- getLinks(): GanttLink[] {
100
- return this.links.map((link) => ({ ...link }))
101
- }
102
- getMarkers(): GanttMarker[] {
103
- return this.markers.map((marker) => ({ ...marker }))
104
- }
105
- getConfig(): GanttConfig {
106
- return { ...this.config }
107
- }
108
- getTask(id: string): GanttTask | undefined {
109
- const task = this.tasks.find((item) => item.id === id)
110
- return task ? { ...task } : undefined
111
- }
112
- getCollapsedIds(): string[] {
113
- return Array.from(this.collapsedIds)
114
- }
115
- isDestroyed(): boolean {
116
- return this.destroyed
117
- }
118
-
119
- // ── 命令式变更(均触发对应事件) ──
120
- setTasks(tasks: GanttTask[]): void {
121
- this.assertNotDestroyed()
122
- this.tasks = tasks.map((task) => ({ ...task }))
123
- this.emit("taskschange", this.getTasks())
124
- }
125
- setLinks(links: GanttLink[]): void {
126
- this.assertNotDestroyed()
127
- this.links = links.map((link) => ({ ...link }))
128
- this.emit("linkschange", this.getLinks())
129
- }
130
- setMarkers(markers: GanttMarker[]): void {
131
- this.assertNotDestroyed()
132
- this.markers = markers.map((marker) => ({ ...marker }))
133
- this.emit("markerschange", this.getMarkers())
134
- }
135
- /** 合并配置 patch,等价于 setConfig(mergeConfig(patch)) */
136
- mergeConfig(patch: Partial<GanttConfig>): void {
137
- this.assertNotDestroyed()
138
- this.config = { ...this.config, ...patch }
139
- this.emit("configchange", this.getConfig())
140
- }
141
- setConfig(config: Partial<GanttConfig>): void {
142
- this.mergeConfig(config)
143
- }
144
- /** 用 mergeTaskPatch 把扁平 patch 合并回嵌套结构后更新单个任务 */
145
- setTask(id: string, patch: PatchTask): void {
146
- this.assertNotDestroyed()
147
- const target = this.tasks.find((task) => task.id === id)
148
- if (!target) {
149
- return
150
- }
151
- const updated = mergeTaskPatch(target, patch)
152
- this.tasks = this.tasks.map((task) => (task.id === id ? updated : task))
153
- this.emit("taskchange", id, patch, { ...updated })
154
- }
155
- addTask(task: GanttTask): void {
156
- this.assertNotDestroyed()
157
- this.tasks = [...this.tasks, { ...task }]
158
- this.emit("taskcreate", { ...task })
159
- }
160
- removeTask(id: string): void {
161
- this.assertNotDestroyed()
162
- const removed = this.tasks.find((task) => task.id === id)
163
- if (!removed) return
164
- this.tasks = this.tasks.filter((task) => task.id !== id)
165
- this.collapsedIds.delete(id)
166
- this.emit("taskdelete", id)
167
- }
168
- collapse(id: string, collapsed = true): void {
169
- this.assertNotDestroyed()
170
- if (collapsed) this.collapsedIds.add(id)
171
- else this.collapsedIds.delete(id)
172
- this.emit("collapsechange", this.getCollapsedIds())
173
- }
174
- toggleCollapse(id: string): void {
175
- this.collapse(id, !this.collapsedIds.has(id))
176
- }
177
- /** 批量折叠/展开多个任务(如"折叠全部分组") */
178
- setCollapsed(ids: string[], collapsed = true): void {
179
- this.assertNotDestroyed()
180
- const next = new Set(this.collapsedIds)
181
- for (const id of ids) {
182
- if (collapsed) {
183
- next.add(id)
184
- } else {
185
- next.delete(id)
186
- }
187
- }
188
- this.collapsedIds = next
189
- this.emit("collapsechange", this.getCollapsedIds())
190
- }
191
-
192
- // ── 拖拽预览(交互态源;组件写入迁移到 engine,dateRange 可据此扩展) ──
193
- setPreview(preview: { taskId: string; patch: PatchTask; affected?: Record<string, PatchTask> }): void {
194
- this.assertNotDestroyed()
195
- this.dragPreview = {
196
- taskId: preview.taskId,
197
- patch: { ...preview.patch },
198
- affected: preview.affected ? { ...preview.affected } : undefined
199
- }
200
- this.emit("previewchange", this.getPreview())
201
- }
202
- clearPreview(): void {
203
- if (!this.dragPreview) {
204
- return
205
- }
206
- this.dragPreview = null
207
- this.emit("previewchange", null)
208
- }
209
- getPreview(): { taskId: string; patch: PatchTask; affected?: Record<string, PatchTask> } | null {
210
- if (!this.dragPreview) {
211
- return null
212
- }
213
- return {
214
- taskId: this.dragPreview.taskId,
215
- patch: { ...this.dragPreview.patch },
216
- affected: this.dragPreview.affected ? { ...this.dragPreview.affected } : undefined
217
- }
218
- }
219
-
220
- // ── 计算(delegate 到 core 纯函数) ──
221
- /** 数据真实日期范围(未含 viewport pad) */
222
- getDateRange(): { start: Date; end: Date } {
223
- const dates: Date[] = []
224
- for (const task of this.tasks) {
225
- dates.push(
226
- toDate(task.plan.start),
227
- toDate(task.plan.end),
228
- toDate(task.actual.start),
229
- toDate(task.actual.end)
230
- )
231
- }
232
- for (const marker of this.markers) {
233
- dates.push(toDate(marker.date))
234
- }
235
- // 合并拖拽预览:用 mergeTaskPatch 算 patch 后日期扩范围(只扩不缩)
236
- const preview = this.dragPreview
237
- if (preview) {
238
- const ids = [preview.taskId, ...Object.keys(preview.affected ?? {})]
239
- for (const id of ids) {
240
- const task = this.tasks.find((item) => item.id === id)
241
- if (!task) {
242
- continue
243
- }
244
- const patch = preview.taskId === id ? preview.patch : preview.affected?.[id]
245
- if (!patch) {
246
- continue
247
- }
248
- const merged = mergeTaskPatch(task, patch)
249
- dates.push(
250
- toDate(merged.plan.start),
251
- toDate(merged.plan.end),
252
- toDate(merged.actual.start),
253
- toDate(merged.actual.end)
254
- )
255
- }
256
- }
257
- if (!dates.length) {
258
- const today = toDate(new Date())
259
- return { start: today, end: addDays(today, 30) }
260
- }
261
- return {
262
- start: new Date(Math.min(...dates.map((d) => d.getTime()))),
263
- end: new Date(Math.max(...dates.map((d) => d.getTime())))
264
- }
265
- }
266
- getTimeScale(): TimeScale[] {
267
- const range = this.getDateRange()
268
- return computeTimeScale(
269
- range.start,
270
- range.end,
271
- this.config.viewMode,
272
- this.config.columnWidth,
273
- this.config.firstDayOfWeek
274
- )
275
- }
276
- getLayout(): Result<TaskLayout[]> {
277
- return computeLayout(
278
- this.tasks,
279
- this.links,
280
- this.config,
281
- this.collapsedIds
282
- )
283
- }
284
- getFlatTasks(): FlatTask[] {
285
- return flattenTasks(this.tasks, this.collapsedIds)
286
- }
287
- getTotalWidth(): number {
288
- return this.getTimeScale().reduce((sum, tick) => sum + tick.width, 0)
289
- }
290
- getTotalHeight(): number {
291
- const rows = this.getFlatTasks().length
292
- return this.config.headerHeight + rows * this.config.rowHeight
293
- }
294
-
295
- // ── 视口命令(需要 container) ──
296
- getScrollLeft(): number {
297
- return this.container?.scrollLeft ?? 0
298
- }
299
- setScrollLeft(px: number): void {
300
- if (!this.container) return
301
- const max = Math.max(0, this.container.scrollWidth - this.container.clientWidth)
302
- this.container.scrollLeft = Math.max(0, Math.min(max, px))
303
- }
304
- /** 把当前 scrollLeft 限制到 [0, maxScrollLeft]。
305
- * 拖拽预览收缩(松手/取消)后 timeline 总宽度变小,scrollLeft 可能超出新边界,
306
- * 导致 canvas translate 超出内容区、右侧网格未覆盖。此时调用本方法防超界。 */
307
- clampScrollLeft(): void {
308
- if (!this.container) return
309
- const max = Math.max(0, this.container.scrollWidth - this.container.clientWidth)
310
- if (this.container.scrollLeft > max) {
311
- this.container.scrollLeft = max
312
- }
313
- }
314
- getScrollTop(): number {
315
- return this.container?.scrollTop ?? 0
316
- }
317
- setScrollTop(px: number): void {
318
- if (!this.container) return
319
- const max = Math.max(0, this.container.scrollHeight - this.container.clientHeight)
320
- this.container.scrollTop = Math.max(0, Math.min(max, px))
321
- }
322
- /** 滚动到包含目标日期的 tick 列(跨 viewMode 精确) */
323
- scrollToDate(date: string | Date): void {
324
- if (!this.container) return
325
- const target = toDate(date)
326
- const scale = this.getTimeScale()
327
- const tick = scale.find((t) => target >= t.start && target <= t.end)
328
- const left = tick ? tick.left : diffDays(this.getDateRange().start, target) * this.config.columnWidth
329
- this.setScrollLeft(left)
330
- }
331
- scrollToTask(id: string): void {
332
- const layout = this.getLayout()
333
- if (!layout.ok) return
334
- const item = layout.data.find((row) => row.taskId === id)
335
- if (item) this.setScrollLeft(item.left)
336
- }
337
- scrollToStart(): void {
338
- this.setScrollLeft(0)
339
- }
340
- scrollToEnd(): void {
341
- if (!this.container) return
342
- this.setScrollLeft(this.getTotalWidth())
343
- }
344
- /** 让整个 timeline 总宽度≈视口宽度(调 columnWidth) */
345
- zoomToFit(padding = 1): void {
346
- if (!this.container) return
347
- const spanDays = Math.max(1, diffDays(this.getDateRange().start, this.getDateRange().end) + 1)
348
- const target = (this.container.clientWidth * padding) / spanDays
349
- this.mergeConfig({ columnWidth: this.clampColumnWidth(target) })
350
- }
351
- /** 让指定日期范围占满视口宽度(调 columnWidth) */
352
- zoomToRange(start: string | Date, end: string | Date): void {
353
- if (!this.container) return
354
- const span = Math.max(1, diffDays(start, end) + 1)
355
- const target = this.container.clientWidth / span
356
- this.mergeConfig({ columnWidth: this.clampColumnWidth(target) })
357
- }
358
- private clampColumnWidth(value: number): number {
359
- return Math.max(4, Math.min(400, Math.round(value)))
360
- }
361
-
362
- // ── 事件订阅 ──
363
- on(event: GanttEngineEvent, fn: Listener): () => void {
364
- this.assertNotDestroyed()
365
- let set = this.listeners.get(event)
366
- if (!set) {
367
- set = new Set()
368
- this.listeners.set(event, set)
369
- }
370
- set.add(fn)
371
- return () => {
372
- const current = this.listeners.get(event)
373
- if (current) {
374
- current.delete(fn)
375
- if (!current.size) this.listeners.delete(event)
376
- }
377
- }
378
- }
379
- once(event: GanttEngineEvent, fn: Listener): () => void {
380
- const off = this.on(event, (...args: unknown[]) => {
381
- off()
382
- fn(...args)
383
- })
384
- return off
385
- }
386
- off(event: GanttEngineEvent, fn?: Listener): void {
387
- const set = this.listeners.get(event)
388
- if (!set) return
389
- if (fn) {
390
- set.delete(fn)
391
- if (!set.size) this.listeners.delete(event)
392
- } else {
393
- this.listeners.delete(event)
394
- }
395
- }
396
- emit(event: GanttEngineEvent, ...args: unknown[]): void {
397
- const set = this.listeners.get(event)
398
- if (!set) return
399
- // 复制遍历,避免回调中增删导致迭代错乱
400
- for (const fn of Array.from(set)) {
401
- try {
402
- fn(...args)
403
- } catch (error) {
404
- // 单个监听器抛错不应打断其他监听器
405
- if (typeof console !== "undefined") {
406
- console.error(`[GanttEngine] listener for "${event}" threw`, error)
407
- }
408
- }
409
- }
410
- }
411
-
412
- // ── 生命周期 ──
413
- destroy(): void {
414
- if (this.destroyed) return
415
- this.destroyed = true
416
- this.emit("destroy")
417
- this.listeners.clear()
418
- this.tasks = []
419
- this.links = []
420
- }
421
-
422
- private assertNotDestroyed(): void {
423
- if (this.destroyed) {
424
- throw new Error("GanttEngine: operation on destroyed instance")
425
- }
426
- }
427
- }
428
-
429
- /** 工厂函数,对应成熟做法的 new GanttEngine(container, config) 入口 */
430
- export function createGanttEngine(options: GanttEngineOptions = {}): GanttEngine {
431
- return new GanttEngine(options)
432
- }
@@ -1,98 +0,0 @@
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
- }
@@ -1,73 +0,0 @@
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
- }