gantt-canvas-chart 1.0.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.
@@ -0,0 +1,790 @@
1
+ /*!
2
+ * gantt-canvas-chart v1.0.0
3
+ * (c) 2025-present chandq
4
+ * Released under the MIT License.
5
+ */
6
+
7
+ class DateUtils {
8
+ static ONE_DAY_MS = 24 * 60 * 60 * 1e3;
9
+ static format(date, format = "yyyy-MM-dd") {
10
+ const o = {
11
+ "M+": date.getMonth() + 1,
12
+ "d+": date.getDate(),
13
+ "h+": date.getHours(),
14
+ "m+": date.getMinutes(),
15
+ "s+": date.getSeconds(),
16
+ "q+": Math.floor((date.getMonth() + 3) / 3),
17
+ W: ["日", "一", "二", "三", "四", "五", "六"][date.getDay()]
18
+ };
19
+ if (/(y+)/.test(format))
20
+ format = format.replace(RegExp.$1, (date.getFullYear() + "").substr(4 - RegExp.$1.length));
21
+ for (let k in o)
22
+ if (new RegExp("(" + k + ")").test(format))
23
+ format = format.replace(RegExp.$1, RegExp.$1.length == 1 ? o[k] : ("00" + o[k]).substr(("" + o[k]).length));
24
+ return format;
25
+ }
26
+ static addDays(date, days) {
27
+ const r = new Date(date);
28
+ r.setDate(r.getDate() + days);
29
+ return r;
30
+ }
31
+ static addMonths(date, months) {
32
+ const d = new Date(date);
33
+ d.setDate(1);
34
+ d.setMonth(d.getMonth() + months);
35
+ return d;
36
+ }
37
+ static addYears(date, years) {
38
+ const r = new Date(date);
39
+ r.setFullYear(r.getFullYear() + years);
40
+ return r;
41
+ }
42
+ static diffDays(date1, date2) {
43
+ return Math.round((new Date(date2.getFullYear(), date2.getMonth(), date2.getDate()).getTime() - new Date(date1.getFullYear(), date1.getMonth(), date1.getDate()).getTime()) / this.ONE_DAY_MS);
44
+ }
45
+ static diffDaysInclusive(date1, date2) {
46
+ return this.diffDays(date1, date2) + 1;
47
+ }
48
+ static getDaysInMonth(year, month) {
49
+ return new Date(year, month + 1, 0).getDate();
50
+ }
51
+ static getWeekNumber(d) {
52
+ d = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
53
+ d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
54
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
55
+ return Math.ceil(((d - yearStart) / 864e5 + 1) / 7);
56
+ }
57
+ static getStartOfWeek(d) {
58
+ const date = new Date(d);
59
+ const day = date.getDay() || 7;
60
+ if (day !== 1)
61
+ date.setHours(-24 * (day - 1));
62
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
63
+ }
64
+ static getStartOfMonth(d) {
65
+ return new Date(d.getFullYear(), d.getMonth(), 1);
66
+ }
67
+ static getStartOfYear(d) {
68
+ return new Date(d.getFullYear(), 0, 1);
69
+ }
70
+ }
71
+ function firstValidValue(...args) {
72
+ for (let i = 0; i < args.length; i++) {
73
+ if (args[i] !== null && args[i] !== void 0) {
74
+ return args[i];
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+ class GanttChart {
80
+ rootContainer;
81
+ container;
82
+ data;
83
+ config;
84
+ headerCanvas;
85
+ mainCanvas;
86
+ scrollDummy;
87
+ tooltip;
88
+ headerCtx;
89
+ mainCtx;
90
+ timelineStart;
91
+ timelineEnd;
92
+ pixelsPerDay;
93
+ scrollLeft;
94
+ scrollTop;
95
+ visibleDateRange;
96
+ today;
97
+ devicePixelRatio;
98
+ viewportWidth;
99
+ viewportHeight;
100
+ totalWidth;
101
+ totalHeight;
102
+ resizeObserver;
103
+ taskPositions;
104
+ taskMap;
105
+ constructor(rootContainer, data, config = {}) {
106
+ const container = document.createElement("div");
107
+ const scrollEl = document.createElement("div");
108
+ const headerCanvas = document.createElement("canvas");
109
+ const mainCanvas = document.createElement("canvas");
110
+ container.classList.add("__gantt-chart-container");
111
+ scrollEl.classList.add("__gantt-scroll-dummy");
112
+ headerCanvas.classList.add("__gantt-header-canvas");
113
+ mainCanvas.classList.add("__gantt-main-canvas");
114
+ container.appendChild(scrollEl);
115
+ container.appendChild(headerCanvas);
116
+ container.appendChild(mainCanvas);
117
+ rootContainer.classList.add("__gantt-chart-wrapper");
118
+ rootContainer.appendChild(container);
119
+ this.resizeObserver = null;
120
+ this.rootContainer = rootContainer;
121
+ this.container = container;
122
+ this.data = data;
123
+ this.config = {
124
+ viewMode: "Month",
125
+ rowHeight: 48,
126
+ headerHeight: 56,
127
+ showPlan: true,
128
+ showActual: true,
129
+ showRowLines: true,
130
+ showColLines: true,
131
+ showLeftRemark: false,
132
+ showRightRemark: false,
133
+ showCenterRemark: false,
134
+ showTooltip: true,
135
+ tooltipColor: "black",
136
+ offsetTop: 0,
137
+ offsetLeft: 0,
138
+ planBorderColor: "#caeed2",
139
+ actualBgColor: "#78c78f",
140
+ ...config
141
+ };
142
+ this.headerCanvas = headerCanvas;
143
+ this.mainCanvas = mainCanvas;
144
+ this.scrollDummy = scrollEl;
145
+ const tooltip = document.createElement("div");
146
+ tooltip.classList.add("__gantt-tooltip");
147
+ document.body.appendChild(tooltip);
148
+ this.tooltip = tooltip;
149
+ this.mainCanvas.style.top = `${this.config.headerHeight}px`;
150
+ this.headerCtx = this.headerCanvas.getContext("2d");
151
+ this.mainCtx = this.mainCanvas.getContext("2d");
152
+ this.timelineStart = /* @__PURE__ */ new Date();
153
+ this.timelineEnd = /* @__PURE__ */ new Date();
154
+ this.pixelsPerDay = 40;
155
+ this.scrollLeft = 0;
156
+ this.scrollTop = 0;
157
+ this.visibleDateRange = { start: /* @__PURE__ */ new Date(), end: /* @__PURE__ */ new Date() };
158
+ this.today = new Date((/* @__PURE__ */ new Date()).setHours(0, 0, 0, 0));
159
+ this.devicePixelRatio = window.devicePixelRatio || 1;
160
+ this.viewportWidth = 0;
161
+ this.viewportHeight = 0;
162
+ this.totalWidth = 0;
163
+ this.totalHeight = 0;
164
+ this.taskPositions = /* @__PURE__ */ new Map();
165
+ this.taskMap = /* @__PURE__ */ new Map();
166
+ this.init();
167
+ }
168
+ init() {
169
+ this.buildTaskMap();
170
+ this.calculateFullTimeline();
171
+ this.updatePixelsPerDay();
172
+ this.setupEvents();
173
+ this.handleResize();
174
+ }
175
+ buildTaskMap() {
176
+ this.taskMap.clear();
177
+ this.data.forEach((row, rowIndex) => {
178
+ row.tasks.forEach((task) => this.taskMap.set(task.id, { row: rowIndex, task }));
179
+ });
180
+ }
181
+ setupEvents() {
182
+ this.container.addEventListener("scroll", this.handleScroll.bind(this));
183
+ this.handleResize = this.handleResize.bind(this);
184
+ if (window.ResizeObserver) {
185
+ this.resizeObserver = new ResizeObserver(this.handleResize);
186
+ setTimeout(() => {
187
+ this.resizeObserver.observe(this.rootContainer);
188
+ }, 100);
189
+ }
190
+ if (this.config.showTooltip) {
191
+ this.mainCanvas.addEventListener("mousemove", this.handleMouseMove.bind(this));
192
+ this.mainCanvas.addEventListener("mouseleave", this.handleMouseLeave.bind(this));
193
+ }
194
+ }
195
+ updateConfig(newConfig) {
196
+ Object.assign(this.config, newConfig);
197
+ if (newConfig.viewMode) {
198
+ this.container.scrollLeft = 0;
199
+ this.scrollLeft = 0;
200
+ this.updatePixelsPerDay();
201
+ this.calculateFullTimeline();
202
+ }
203
+ this.updateDimensions();
204
+ this.render();
205
+ }
206
+ setData(newData) {
207
+ this.data = newData;
208
+ this.buildTaskMap();
209
+ this.calculateFullTimeline();
210
+ this.updateDimensions();
211
+ this.render();
212
+ }
213
+ destroy() {
214
+ if (this.resizeObserver) {
215
+ this.resizeObserver.disconnect();
216
+ }
217
+ this.container.remove();
218
+ }
219
+ calculateFullTimeline() {
220
+ let minDate = new Date(9999, 0, 1);
221
+ let maxDate = new Date(1e3, 0, 1);
222
+ if (this.data.length === 0) {
223
+ minDate = /* @__PURE__ */ new Date();
224
+ maxDate = DateUtils.addDays(/* @__PURE__ */ new Date(), 30);
225
+ } else {
226
+ this.taskMap.forEach(({ task }) => {
227
+ const pStart = new Date(task.planStart);
228
+ const pEnd = new Date(task.planEnd);
229
+ if (pStart < minDate)
230
+ minDate = pStart;
231
+ if (pEnd > maxDate)
232
+ maxDate = pEnd;
233
+ if (task.actualStart) {
234
+ const aStart = new Date(task.actualStart);
235
+ const aEnd = new Date(task.actualEnd);
236
+ if (aStart < minDate)
237
+ minDate = aStart;
238
+ if (aEnd > maxDate)
239
+ maxDate = aEnd;
240
+ }
241
+ });
242
+ }
243
+ minDate = DateUtils.addDays(minDate, -7);
244
+ maxDate = DateUtils.addDays(maxDate, 14);
245
+ switch (this.config.viewMode) {
246
+ case "Year":
247
+ this.timelineStart = DateUtils.getStartOfYear(minDate);
248
+ this.timelineEnd = DateUtils.addDays(DateUtils.addYears(DateUtils.getStartOfYear(maxDate), 1), -1);
249
+ break;
250
+ case "Month":
251
+ this.timelineStart = DateUtils.getStartOfMonth(minDate);
252
+ this.timelineEnd = DateUtils.addDays(DateUtils.addMonths(DateUtils.getStartOfMonth(maxDate), 1), -1);
253
+ break;
254
+ case "Week":
255
+ this.timelineStart = DateUtils.getStartOfWeek(minDate);
256
+ this.timelineEnd = DateUtils.addDays(DateUtils.getStartOfWeek(maxDate), 7);
257
+ break;
258
+ case "Day":
259
+ default:
260
+ this.timelineStart = DateUtils.addDays(minDate, -3);
261
+ this.timelineEnd = DateUtils.addDays(maxDate, 3);
262
+ break;
263
+ }
264
+ }
265
+ updatePixelsPerDay() {
266
+ const viewFactors = { Day: 80, Week: 20, Month: 15, Year: 6 };
267
+ this.pixelsPerDay = viewFactors[this.config.viewMode];
268
+ }
269
+ dateToX(date) {
270
+ return DateUtils.diffDays(this.timelineStart, date) * this.pixelsPerDay;
271
+ }
272
+ xToDate(x) {
273
+ return DateUtils.addDays(this.timelineStart, x / this.pixelsPerDay);
274
+ }
275
+ handleResize() {
276
+ this.viewportWidth = this.container.clientWidth;
277
+ this.viewportHeight = this.container.clientHeight;
278
+ this.mainCanvas.style.top = `${this.config.headerHeight}px`;
279
+ this.setupCanvas(this.headerCanvas, this.viewportWidth, this.config.headerHeight);
280
+ this.setupCanvas(this.mainCanvas, this.viewportWidth, this.viewportHeight - this.config.headerHeight);
281
+ this.updateDimensions();
282
+ this.render();
283
+ }
284
+ handleScroll(e) {
285
+ const target = e.target;
286
+ this.scrollLeft = target.scrollLeft;
287
+ this.scrollTop = target.scrollTop;
288
+ const event = new CustomEvent("ganttscroll", {
289
+ detail: { scrollTop: this.scrollTop, scrollLeft: this.scrollLeft }
290
+ });
291
+ this.container.dispatchEvent(event);
292
+ requestAnimationFrame(() => this.render());
293
+ }
294
+ setScrollTop(scrollTop) {
295
+ if (this.scrollTop !== scrollTop)
296
+ this.container.scrollTop = scrollTop;
297
+ }
298
+ updateVirtualRanges() {
299
+ const buffer = 200;
300
+ this.visibleDateRange = {
301
+ start: this.xToDate(this.scrollLeft - buffer),
302
+ end: this.xToDate(this.scrollLeft + this.viewportWidth + buffer)
303
+ };
304
+ }
305
+ updateDimensions() {
306
+ const totalDays = DateUtils.diffDays(this.timelineStart, this.timelineEnd) + 1;
307
+ this.totalWidth = totalDays * this.pixelsPerDay;
308
+ this.totalHeight = this.data.length * this.config.rowHeight + this.config.headerHeight;
309
+ this.scrollDummy.style.width = `${this.totalWidth}px`;
310
+ this.scrollDummy.style.height = `${this.totalHeight}px`;
311
+ }
312
+ setupCanvas(canvas, width, height) {
313
+ canvas.width = width * this.devicePixelRatio;
314
+ canvas.height = height * this.devicePixelRatio;
315
+ canvas.style.width = `${width}px`;
316
+ canvas.style.height = `${height}px`;
317
+ const ctx = canvas.getContext("2d");
318
+ ctx.scale(this.devicePixelRatio, this.devicePixelRatio);
319
+ }
320
+ calculateAllTaskPositions() {
321
+ this.taskPositions.clear();
322
+ for (let i = 0; i < this.data.length; i++) {
323
+ const row = this.data[i];
324
+ const y = i * this.config.rowHeight;
325
+ row.tasks.forEach((task) => {
326
+ const x_plan_start = this.dateToX(new Date(task.planStart));
327
+ const x_plan_end = this.dateToX(DateUtils.addDays(new Date(task.planEnd), 1));
328
+ let x_actual_start = null, x_actual_end = null;
329
+ if (task.actualStart) {
330
+ x_actual_start = this.dateToX(new Date(task.actualStart));
331
+ }
332
+ if (task.actualEnd) {
333
+ x_actual_end = this.dateToX(DateUtils.addDays(new Date(task.actualEnd), 1));
334
+ }
335
+ this.taskPositions.set(task.id, {
336
+ x_plan_start,
337
+ x_plan_end,
338
+ x_actual_start,
339
+ x_actual_end,
340
+ y: y + this.config.rowHeight * 0.5,
341
+ row: i
342
+ });
343
+ });
344
+ }
345
+ }
346
+ getIterationStartDate(date) {
347
+ switch (this.config.viewMode) {
348
+ case "Year":
349
+ return DateUtils.getStartOfYear(date);
350
+ case "Month":
351
+ return DateUtils.getStartOfMonth(date);
352
+ case "Week":
353
+ return DateUtils.getStartOfWeek(date);
354
+ case "Day":
355
+ default:
356
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
357
+ }
358
+ }
359
+ render() {
360
+ this.updateVirtualRanges();
361
+ this.calculateAllTaskPositions();
362
+ this.renderHeader();
363
+ this.renderMain();
364
+ }
365
+ renderHeader() {
366
+ const ctx = this.headerCtx;
367
+ const h = this.config.headerHeight;
368
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
369
+ ctx.save();
370
+ ctx.translate(-this.scrollLeft, 0);
371
+ ctx.fillStyle = "#f9f9f9";
372
+ ctx.fillRect(this.scrollLeft, 0, this.viewportWidth, h);
373
+ ctx.textBaseline = "middle";
374
+ let currentDate = new Date(this.visibleDateRange.start);
375
+ currentDate = this.getIterationStartDate(currentDate);
376
+ let lastUpperText = "";
377
+ while (this.dateToX(currentDate) < this.scrollLeft - this.pixelsPerDay * 7) {
378
+ let nextDate;
379
+ switch (this.config.viewMode) {
380
+ case "Day":
381
+ nextDate = DateUtils.addDays(currentDate, 1);
382
+ break;
383
+ case "Week":
384
+ nextDate = DateUtils.addDays(currentDate, 7);
385
+ break;
386
+ case "Month":
387
+ nextDate = DateUtils.addMonths(currentDate, 1);
388
+ break;
389
+ case "Year":
390
+ nextDate = DateUtils.addMonths(currentDate, 6);
391
+ break;
392
+ default:
393
+ nextDate = DateUtils.addDays(currentDate, 1);
394
+ break;
395
+ }
396
+ if (nextDate.getTime() === currentDate.getTime()) {
397
+ currentDate = DateUtils.addDays(currentDate, 1);
398
+ } else {
399
+ currentDate = nextDate;
400
+ }
401
+ }
402
+ while (currentDate <= this.visibleDateRange.end) {
403
+ const x = this.dateToX(currentDate);
404
+ let upperText = "", lowerText = "", nextDate;
405
+ switch (this.config.viewMode) {
406
+ case "Day":
407
+ upperText = DateUtils.format(currentDate, "yyyy年MM月");
408
+ lowerText = `${DateUtils.format(currentDate, "d")} ${DateUtils.format(currentDate, "W")}`;
409
+ nextDate = DateUtils.addDays(currentDate, 1);
410
+ break;
411
+ case "Week":
412
+ const weekStart = DateUtils.getStartOfWeek(currentDate);
413
+ upperText = DateUtils.format(weekStart, "yyyy年MM月");
414
+ lowerText = `第${DateUtils.getWeekNumber(weekStart)}周`;
415
+ nextDate = DateUtils.addDays(weekStart, 7);
416
+ break;
417
+ case "Month":
418
+ upperText = `${currentDate.getFullYear()}年`;
419
+ lowerText = `${currentDate.getMonth() + 1}月`;
420
+ nextDate = DateUtils.addMonths(currentDate, 1);
421
+ break;
422
+ case "Year":
423
+ if (currentDate.getMonth() === 0 && currentDate.getDate() === 1) {
424
+ upperText = `${currentDate.getFullYear()}年`;
425
+ lowerText = `上半年`;
426
+ nextDate = DateUtils.addMonths(currentDate, 6);
427
+ } else if (currentDate.getMonth() === 6 && currentDate.getDate() === 1) {
428
+ upperText = `${currentDate.getFullYear()}年`;
429
+ lowerText = `下半年`;
430
+ nextDate = DateUtils.addMonths(currentDate, 6);
431
+ } else {
432
+ currentDate = DateUtils.addDays(currentDate, 1);
433
+ continue;
434
+ }
435
+ break;
436
+ }
437
+ const unitWidth = this.dateToX(nextDate) - x;
438
+ if (upperText !== lastUpperText) {
439
+ ctx.fillStyle = "#333";
440
+ ctx.font = "bold 13px sans-serif";
441
+ ctx.textAlign = "left";
442
+ ctx.fillText(upperText, x + 5, h * 0.35);
443
+ lastUpperText = upperText;
444
+ }
445
+ ctx.fillStyle = "#666";
446
+ ctx.font = "12px sans-serif";
447
+ ctx.textAlign = "center";
448
+ ctx.fillText(lowerText, x + unitWidth / 2, h * 0.7);
449
+ ctx.beginPath();
450
+ ctx.moveTo(x, h * 0.5);
451
+ ctx.lineTo(x, h);
452
+ ctx.strokeStyle = "#e0e0e0";
453
+ ctx.stroke();
454
+ if (nextDate.getTime() === currentDate.getTime()) {
455
+ currentDate = DateUtils.addDays(currentDate, 1);
456
+ } else {
457
+ currentDate = nextDate;
458
+ }
459
+ }
460
+ ctx.beginPath();
461
+ ctx.moveTo(this.scrollLeft, h - 0.5);
462
+ ctx.lineTo(this.scrollLeft + this.viewportWidth, h - 0.5);
463
+ ctx.strokeStyle = "#e0e0e0";
464
+ ctx.stroke();
465
+ ctx.restore();
466
+ }
467
+ renderMain() {
468
+ const ctx = this.mainCtx;
469
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
470
+ ctx.save();
471
+ ctx.translate(-this.scrollLeft, -this.scrollTop);
472
+ const { start: startDate, end: endDate } = this.visibleDateRange;
473
+ this.drawGrid(ctx, startDate, endDate);
474
+ this.drawToday(ctx);
475
+ this.drawAllDependencies(ctx);
476
+ this.drawAllTasks(ctx);
477
+ ctx.restore();
478
+ }
479
+ // Helper: Draw arrow
480
+ drawArrow(ctx, x, y, direction) {
481
+ const size = 6;
482
+ ctx.beginPath();
483
+ ctx.moveTo(x, y);
484
+ if (direction === "right") {
485
+ ctx.lineTo(x - size, y - size * 0.6);
486
+ ctx.lineTo(x - size, y + size * 0.6);
487
+ } else if (direction === "down") {
488
+ ctx.lineTo(x - size * 0.6, y - size);
489
+ ctx.lineTo(x + size * 0.6, y - size);
490
+ } else if (direction === "up") {
491
+ ctx.lineTo(x - size * 0.6, y + size);
492
+ ctx.lineTo(x + size * 0.6, y + size);
493
+ }
494
+ ctx.fillStyle = ctx.strokeStyle;
495
+ ctx.fill();
496
+ }
497
+ drawAllDependencies(ctx) {
498
+ const lineColor = "#64748b";
499
+ const lineWidth = 1;
500
+ ctx.strokeStyle = lineColor;
501
+ ctx.lineWidth = lineWidth;
502
+ ctx.lineCap = "round";
503
+ ctx.lineJoin = "round";
504
+ this.taskMap.forEach(({ task }) => {
505
+ if (!task.dependencies || task.dependencies.length === 0)
506
+ return;
507
+ const toPos = this.taskPositions.get(task.id);
508
+ if (!toPos)
509
+ return;
510
+ const toRowIndex = this.taskMap.get(task.id).row;
511
+ task.dependencies.forEach((depId) => {
512
+ const fromPos = this.taskPositions.get(depId);
513
+ if (!fromPos)
514
+ return;
515
+ const fromRowIndex = this.taskMap.get(depId).row;
516
+ const isAdjacent = Math.abs(toRowIndex - fromRowIndex) === 1;
517
+ const fromX = Math.max(fromPos.x_plan_end, fromPos.x_actual_end || fromPos.x_plan_end);
518
+ const fromY = fromPos.y;
519
+ const toX = Math.min(toPos.x_plan_start, toPos.x_actual_start || toPos.x_plan_start);
520
+ const toY = toPos.y;
521
+ ctx.beginPath();
522
+ if (isAdjacent) {
523
+ const isDown = toRowIndex > fromRowIndex;
524
+ const rowH = this.config.rowHeight;
525
+ const fromY_Edge = fromY + (isDown ? rowH * 0.3 : -rowH * 0.3);
526
+ const toY_Edge = toY + (isDown ? -rowH * 0.3 : rowH * 0.3);
527
+ const midY = (fromY + toY) / 2;
528
+ ctx.moveTo(fromX, fromY_Edge);
529
+ ctx.lineTo(fromX, midY);
530
+ ctx.lineTo(toX, midY);
531
+ ctx.lineTo(toX, toY_Edge);
532
+ ctx.stroke();
533
+ this.drawArrow(ctx, toX, toY_Edge, isDown ? "down" : "up");
534
+ } else {
535
+ const gap = 15;
536
+ const exitX = fromX + gap;
537
+ ctx.moveTo(fromX, fromY);
538
+ if (toX > exitX + gap) {
539
+ ctx.lineTo(exitX, fromY);
540
+ ctx.lineTo(exitX, toY);
541
+ ctx.lineTo(toX, toY);
542
+ ctx.stroke();
543
+ this.drawArrow(ctx, toX, toY, "right");
544
+ } else {
545
+ const isDown = toRowIndex > fromRowIndex;
546
+ const rowH = this.config.rowHeight;
547
+ const targetGapY = toY - (isDown ? rowH / 2 : -rowH / 2);
548
+ const entryX = toX - gap;
549
+ ctx.lineTo(exitX, fromY);
550
+ ctx.lineTo(exitX, targetGapY);
551
+ ctx.lineTo(entryX, targetGapY);
552
+ ctx.lineTo(entryX, toY);
553
+ ctx.lineTo(toX, toY);
554
+ ctx.stroke();
555
+ this.drawArrow(ctx, toX, toY, "right");
556
+ }
557
+ }
558
+ });
559
+ });
560
+ }
561
+ drawAllTasks(ctx) {
562
+ ctx.textBaseline = "middle";
563
+ ctx.font = "12px Arial";
564
+ for (let i = 0; i < this.data.length; i++) {
565
+ const row = this.data[i];
566
+ const y = i * this.config.rowHeight;
567
+ if (y + this.config.rowHeight < this.scrollTop || y > this.scrollTop + this.viewportHeight)
568
+ continue;
569
+ row.tasks.forEach((task) => {
570
+ const pos = this.taskPositions.get(task.id);
571
+ if (!pos)
572
+ return;
573
+ if (pos.x_plan_end < this.scrollLeft || pos.x_plan_start > this.scrollLeft + this.viewportWidth) {
574
+ if (!pos.x_actual_start || pos.x_actual_end < this.scrollLeft || pos.x_actual_start > this.scrollLeft + this.viewportWidth)
575
+ return;
576
+ }
577
+ this.drawTask(ctx, task, y, pos);
578
+ });
579
+ }
580
+ }
581
+ drawGrid(ctx, startDate, endDate) {
582
+ ctx.strokeStyle = "#f0f0f0";
583
+ ctx.lineWidth = 1;
584
+ ctx.beginPath();
585
+ if (this.config.showRowLines) {
586
+ for (let i = 0; i <= this.data.length; i++) {
587
+ const y = i * this.config.rowHeight;
588
+ if (y < this.scrollTop || y > this.scrollTop + this.viewportHeight)
589
+ continue;
590
+ ctx.moveTo(this.scrollLeft, y);
591
+ ctx.lineTo(this.scrollLeft + this.viewportWidth, y);
592
+ }
593
+ }
594
+ if (this.config.showColLines) {
595
+ let currentDate = startDate;
596
+ switch (this.config.viewMode) {
597
+ case "Day":
598
+ currentDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
599
+ break;
600
+ case "Week":
601
+ currentDate = DateUtils.getStartOfWeek(startDate);
602
+ break;
603
+ case "Month":
604
+ currentDate = DateUtils.getStartOfMonth(startDate);
605
+ break;
606
+ case "Year":
607
+ currentDate = DateUtils.getStartOfYear(startDate);
608
+ break;
609
+ }
610
+ let nextDate;
611
+ while (this.dateToX(currentDate) < this.scrollLeft - this.pixelsPerDay * 7) {
612
+ switch (this.config.viewMode) {
613
+ case "Day":
614
+ nextDate = DateUtils.addDays(currentDate, 1);
615
+ break;
616
+ case "Week":
617
+ nextDate = DateUtils.addDays(currentDate, 7);
618
+ break;
619
+ case "Month":
620
+ nextDate = DateUtils.addMonths(currentDate, 1);
621
+ break;
622
+ case "Year":
623
+ nextDate = currentDate.getMonth() === 0 && currentDate.getDate() === 1 ? DateUtils.addMonths(currentDate, 6) : DateUtils.addMonths(currentDate, 6);
624
+ break;
625
+ default:
626
+ nextDate = DateUtils.addDays(currentDate, 1);
627
+ break;
628
+ }
629
+ currentDate = nextDate;
630
+ }
631
+ while (currentDate <= endDate) {
632
+ const x = this.dateToX(currentDate);
633
+ ctx.moveTo(x, this.scrollTop);
634
+ ctx.lineTo(x, this.scrollTop + this.viewportHeight);
635
+ switch (this.config.viewMode) {
636
+ case "Day":
637
+ nextDate = DateUtils.addDays(currentDate, 1);
638
+ break;
639
+ case "Week":
640
+ nextDate = DateUtils.addDays(currentDate, 7);
641
+ break;
642
+ case "Month":
643
+ nextDate = DateUtils.addMonths(currentDate, 1);
644
+ break;
645
+ case "Year":
646
+ if (currentDate.getMonth() === 0 && currentDate.getDate() === 1)
647
+ nextDate = DateUtils.addMonths(currentDate, 6);
648
+ else if (currentDate.getMonth() === 6 && currentDate.getDate() === 1)
649
+ nextDate = DateUtils.addMonths(currentDate, 6);
650
+ else
651
+ nextDate = DateUtils.addDays(currentDate, 1);
652
+ break;
653
+ default:
654
+ nextDate = DateUtils.addDays(currentDate, 1);
655
+ break;
656
+ }
657
+ if (nextDate.getTime() === currentDate.getTime())
658
+ currentDate = DateUtils.addDays(currentDate, 1);
659
+ else
660
+ currentDate = nextDate;
661
+ }
662
+ }
663
+ ctx.stroke();
664
+ }
665
+ drawToday(ctx) {
666
+ const x = this.dateToX(this.today);
667
+ if (x >= this.scrollLeft && x <= this.scrollLeft + this.viewportWidth) {
668
+ ctx.strokeStyle = "#ff4d4f";
669
+ ctx.lineWidth = 2;
670
+ ctx.beginPath();
671
+ ctx.moveTo(x, this.scrollTop);
672
+ ctx.lineTo(x, this.scrollTop + this.viewportHeight);
673
+ ctx.stroke();
674
+ }
675
+ }
676
+ drawTask(ctx, task, y, pos) {
677
+ const offset = 4;
678
+ const width = pos.x_plan_end - pos.x_plan_start;
679
+ const taskY = y + this.config.rowHeight * 0.15 + offset;
680
+ const taskHeight = this.config.rowHeight * 0.7 - offset;
681
+ const textY = y + this.config.rowHeight / 2 + offset;
682
+ if (this.config.showActual && pos.x_actual_start) {
683
+ const aWidth = (pos.x_actual_end ? pos.x_actual_end : pos.x_plan_end) - pos.x_actual_start;
684
+ ctx.fillStyle = task.actualBgColor ? task.actualBgColor : this.config.actualBgColor;
685
+ ctx.fillRect(pos.x_actual_start, taskY, aWidth, taskHeight);
686
+ }
687
+ if (this.config.showPlan && pos.x_plan_start && pos.x_plan_end) {
688
+ ctx.strokeStyle = task.planBorderColor ? task.planBorderColor : this.config.planBorderColor;
689
+ ctx.lineWidth = 4;
690
+ ctx.beginPath();
691
+ ctx.moveTo(pos.x_plan_start, taskY);
692
+ ctx.lineTo(pos.x_plan_start + width, taskY);
693
+ ctx.stroke();
694
+ }
695
+ ctx.fillStyle = "#333";
696
+ if (this.config.showLeftRemark && task.leftRemark) {
697
+ ctx.textAlign = "right";
698
+ ctx.fillText(task.leftRemark, Math.min(...[pos.x_plan_start, pos.x_actual_start].filter((val) => val !== null && val !== void 0)) - 8, textY);
699
+ }
700
+ if (this.config.showRightRemark && task.rightRemark) {
701
+ ctx.textAlign = "left";
702
+ ctx.fillText(task.rightRemark, Math.max(...[pos.x_plan_end, pos.x_actual_end].filter((val) => val !== null && val !== void 0)) + 8, textY);
703
+ }
704
+ if (this.config.showCenterRemark && task.centerRemark) {
705
+ const centerX = pos.x_actual_start + (pos.x_actual_end - pos.x_actual_start) / 2;
706
+ ctx.textAlign = "center";
707
+ ctx.fillText(task.centerRemark, centerX, textY, pos.x_actual_end - pos.x_actual_start);
708
+ }
709
+ }
710
+ getTaskStyles(task) {
711
+ let styles = { planBorder: "#2563eb", actualBg: "#3b82f6" };
712
+ if (task.styleClass === "demo1-task") {
713
+ styles.planBorder = "#fb923c";
714
+ styles.actualBg = "#fdba74";
715
+ }
716
+ if (task.styleClass === "demo2-plan") {
717
+ styles.planBorder = "#22c55e";
718
+ }
719
+ if (task.styleClass === "demo2-completed") {
720
+ styles.actualBg = "#86efac";
721
+ }
722
+ if (task.styleClass === "demo2-leave") {
723
+ styles.actualBg = "#f43f5e";
724
+ }
725
+ return styles;
726
+ }
727
+ handleMouseMove(e) {
728
+ const rect = this.mainCanvas.getBoundingClientRect();
729
+ const mouseX = e.clientX - rect.left;
730
+ const mouseY = e.clientY - rect.top;
731
+ const chartX = mouseX + this.scrollLeft;
732
+ const chartY = mouseY + this.scrollTop;
733
+ const rowIndex = Math.floor(chartY / this.config.rowHeight);
734
+ const date = this.xToDate(chartX);
735
+ if (rowIndex < 0 || rowIndex >= this.data.length)
736
+ return this.handleMouseLeave();
737
+ const row = this.data[rowIndex];
738
+ const overlappingTasks = row.tasks.filter((task) => {
739
+ const pStart = new Date(task.planStart), pEnd = DateUtils.addDays(new Date(task.planEnd), 1);
740
+ if (date >= pStart && date < pEnd)
741
+ return true;
742
+ if (task.actualStart) {
743
+ const aStart = new Date(task.actualStart), aEnd = DateUtils.addDays(new Date(task.actualEnd), 1);
744
+ if (date >= aStart && date < aEnd)
745
+ return true;
746
+ }
747
+ return false;
748
+ });
749
+ if (overlappingTasks.length === 0)
750
+ return this.handleMouseLeave();
751
+ let html = `<strong>${row.name}</strong> (${DateUtils.format(date, "yyyy-MM-dd")})<hr class="__gantt_tooltip-divider">`;
752
+ overlappingTasks.forEach((task) => html += this.getTaskTooltipHtml(task));
753
+ this.tooltip.innerHTML = html;
754
+ this.tooltip.style.display = "block";
755
+ if (this.config.tooltipColor === "white") {
756
+ this.tooltip.style.background = "#fff";
757
+ this.tooltip.style.color = "#000";
758
+ }
759
+ const tipRect = this.tooltip.getBoundingClientRect();
760
+ let x = e.clientX + 15, y = e.clientY + 15;
761
+ if (x + tipRect.width > window.innerWidth)
762
+ x = e.clientX - 15 - tipRect.width;
763
+ if (y + tipRect.height > window.innerHeight)
764
+ y = e.clientY - 15 - tipRect.height;
765
+ this.tooltip.style.left = `${x + this.config.offsetLeft}px`;
766
+ this.tooltip.style.top = `${y + this.config.offsetTop}px`;
767
+ }
768
+ getTaskTooltipHtml(task) {
769
+ if (task.type === "leave") {
770
+ const days = DateUtils.diffDaysInclusive(new Date(task.actualStart), new Date(task.actualEnd));
771
+ return `<div><span style="color: ${firstValidValue(task.actualBgColor, this.config.actualBgColor, "#f43f5e")};">■</span> <strong>${task.name} (${days}天)</strong><br><span class="__gantt_tooltip-indent">${task.actualStart} 到 ${task.actualEnd}</span></div>`;
772
+ }
773
+ let html = `<div><span style="color: ${firstValidValue(task.actualBgColor, this.config.actualBgColor, this.config.planBorderColor, this.getTaskStyles(task).planBorder)};">■</span> <strong>${task.name}</strong><br>`;
774
+ if (this.config.showPlan) {
775
+ const days = DateUtils.diffDaysInclusive(new Date(task.planStart), new Date(task.planEnd));
776
+ html += `<span class="__gantt_tooltip-indent">计划: ${task.planStart} - ${task.planEnd} (${days}天)</span><br>`;
777
+ }
778
+ if (this.config.showActual && task.actualStart) {
779
+ const days = DateUtils.diffDaysInclusive(new Date(task.actualStart), new Date(task.actualEnd));
780
+ html += `<span class="__gantt_tooltip-indent">实际: ${task.actualStart} - ${task.actualEnd} (${days}天)</span><br>`;
781
+ }
782
+ return html + "</div>";
783
+ }
784
+ handleMouseLeave() {
785
+ this.tooltip.style.display = "none";
786
+ }
787
+ }
788
+ export {
789
+ GanttChart
790
+ };