gantt-canvas-chart 1.2.0 → 1.3.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.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * gantt-canvas-chart v1.2.0
2
+ * gantt-canvas-chart v1.3.1
3
3
  * (c) 2025-present chandq
4
4
  * Released under the MIT License.
5
5
  */
@@ -91,6 +91,8 @@ class GanttChart {
91
91
  mainCanvas;
92
92
  scrollDummy;
93
93
  tooltip;
94
+ scrolling;
95
+ showTooltip;
94
96
  headerCtx;
95
97
  mainCtx;
96
98
  timelineStart;
@@ -110,9 +112,14 @@ class GanttChart {
110
112
  resizeObserver;
111
113
  taskPositions;
112
114
  taskMap;
113
- boundHandleMouseMove;
114
- boundHandleMouseLeave;
115
- boundHandleScroll;
115
+ isLoadingData = false;
116
+ hasMoreDataLeft = true;
117
+ hasMoreDataRight = true;
118
+ hasMoreDataBottom = true;
119
+ lastScrollLeft = 0;
120
+ lastScrollTop = 0;
121
+ onDataLoad = null;
122
+ scrollLoadTimer = null;
116
123
  constructor(rootContainer, data, config = {}) {
117
124
  const container = document.createElement("div");
118
125
  const scrollEl = document.createElement("div");
@@ -131,6 +138,8 @@ class GanttChart {
131
138
  this.rootContainer = rootContainer;
132
139
  this.container = container;
133
140
  this.data = data;
141
+ this.scrolling = false;
142
+ this.showTooltip = false;
134
143
  this.config = {
135
144
  viewMode: "Month",
136
145
  rowHeight: 48,
@@ -148,11 +157,14 @@ class GanttChart {
148
157
  todayColor: "#ff4d4f",
149
158
  offsetTop: 0,
150
159
  offsetLeft: 0,
160
+ enabledLoadMore: [],
151
161
  viewFactors: { Day: 80, Week: 20, Month: 15, Year: 6 },
152
162
  planBorderColor: "#C1EFCF",
153
163
  actualBgColor: "#5AC989",
164
+ headerBgColor: "#f9f9f9",
154
165
  ...config
155
166
  };
167
+ this.updateLoadMoreConf();
156
168
  this.headerCanvas = headerCanvas;
157
169
  this.mainCanvas = mainCanvas;
158
170
  this.scrollDummy = scrollEl;
@@ -179,10 +191,12 @@ class GanttChart {
179
191
  this.totalHeight = 0;
180
192
  this.taskPositions = /* @__PURE__ */ new Map();
181
193
  this.taskMap = /* @__PURE__ */ new Map();
182
- this.boundHandleMouseMove = this.handleMouseMove.bind(this);
183
- this.boundHandleMouseLeave = this.handleMouseLeave.bind(this);
184
- this.boundHandleScroll = this.handleScroll.bind(this);
185
- this.scrollToStartDate = this.scrollToStartDate.bind(this);
194
+ this.handleMouseMove = this.handleMouseMove.bind(this);
195
+ this.handleMouseLeave = this.handleMouseLeave.bind(this);
196
+ this.handleScroll = this.handleScroll.bind(this);
197
+ this.horizontalScrollTo = this.horizontalScrollTo.bind(this);
198
+ this.verticalScrollTo = this.verticalScrollTo.bind(this);
199
+ this.handleResize = this.handleResize.bind(this);
186
200
  this.init();
187
201
  }
188
202
  init() {
@@ -192,6 +206,11 @@ class GanttChart {
192
206
  this.setupEvents();
193
207
  this.handleResize();
194
208
  }
209
+ updateLoadMoreConf() {
210
+ this.hasMoreDataLeft = this.config.enabledLoadMore.includes("left");
211
+ this.hasMoreDataRight = this.config.enabledLoadMore.includes("right");
212
+ this.hasMoreDataBottom = this.config.enabledLoadMore.includes("bottom");
213
+ }
195
214
  buildTaskMap() {
196
215
  this.taskMap.clear();
197
216
  this.data.forEach((row, rowIndex) => {
@@ -199,8 +218,7 @@ class GanttChart {
199
218
  });
200
219
  }
201
220
  setupEvents() {
202
- this.container.addEventListener("scroll", this.boundHandleScroll);
203
- this.handleResize = this.handleResize.bind(this);
221
+ this.container.addEventListener("scroll", this.handleScroll);
204
222
  if (window.ResizeObserver) {
205
223
  this.resizeObserver = new ResizeObserver(this.handleResize);
206
224
  setTimeout(() => {
@@ -208,8 +226,8 @@ class GanttChart {
208
226
  }, 100);
209
227
  }
210
228
  if (this.config.showTooltip) {
211
- this.mainCanvas.addEventListener("mousemove", this.boundHandleMouseMove);
212
- this.mainCanvas.addEventListener("mouseleave", this.boundHandleMouseLeave);
229
+ this.mainCanvas.addEventListener("mousemove", this.handleMouseMove);
230
+ this.mainCanvas.addEventListener("mouseleave", this.handleMouseLeave);
213
231
  }
214
232
  }
215
233
  updateConfig(newConfig) {
@@ -220,6 +238,7 @@ class GanttChart {
220
238
  this.updatePixelsPerDay();
221
239
  this.calculateFullTimeline();
222
240
  }
241
+ this.updateLoadMoreConf();
223
242
  this.updateDimensions();
224
243
  this.render();
225
244
  }
@@ -238,13 +257,16 @@ class GanttChart {
238
257
  if (this.resizeObserver) {
239
258
  this.resizeObserver.disconnect();
240
259
  }
241
- this.container.removeEventListener("scroll", this.boundHandleScroll);
242
- this.mainCanvas.removeEventListener("mousemove", this.boundHandleMouseMove);
243
- this.mainCanvas.removeEventListener("mouseleave", this.boundHandleMouseLeave);
260
+ this.container.removeEventListener("scroll", this.handleScroll);
261
+ this.mainCanvas.removeEventListener("mousemove", this.handleMouseMove);
262
+ this.mainCanvas.removeEventListener("mouseleave", this.handleMouseLeave);
263
+ this.data = [];
264
+ this.taskMap.clear();
265
+ this.taskPositions.clear();
244
266
  this.container.remove();
245
267
  }
246
268
  calculateFullTimeline() {
247
- const currentYear = (/* @__PURE__ */ new Date()).getFullYear();
269
+ const currentYear = this.today.getFullYear();
248
270
  let minDate = new Date(9999, 0, 1);
249
271
  let maxDate = new Date(1e3, 0, 1);
250
272
  if (this.data.length === 0) {
@@ -308,7 +330,14 @@ class GanttChart {
308
330
  this.updateDimensions();
309
331
  this.render();
310
332
  }
333
+ // Add this method to register the data loading callback
334
+ setOnDataLoadCallback(callback) {
335
+ this.onDataLoad = callback;
336
+ }
311
337
  handleScroll(e) {
338
+ if (this.showTooltip) {
339
+ this.handleMouseLeave();
340
+ }
312
341
  const target = e.target;
313
342
  this.scrollLeft = target.scrollLeft;
314
343
  this.scrollTop = target.scrollTop;
@@ -316,7 +345,131 @@ class GanttChart {
316
345
  detail: { scrollTop: this.scrollTop, scrollLeft: this.scrollLeft }
317
346
  });
318
347
  this.container.dispatchEvent(event);
319
- requestAnimationFrame(() => this.render());
348
+ if (this.config.enabledLoadMore.length > 0) {
349
+ if (this.scrollLoadTimer !== null) {
350
+ clearTimeout(this.scrollLoadTimer);
351
+ this.scrollLoadTimer = null;
352
+ }
353
+ this.scrollLoadTimer = window.setTimeout(() => {
354
+ this.checkScrollLoad();
355
+ this.scrollLoadTimer = null;
356
+ }, 100);
357
+ }
358
+ requestAnimationFrame(() => {
359
+ this.scrolling = true;
360
+ this.render(true);
361
+ this.scrolling = false;
362
+ });
363
+ }
364
+ // checkScrollLoad method
365
+ async checkScrollLoad() {
366
+ if (this.isLoadingData || !this.onDataLoad) {
367
+ return;
368
+ }
369
+ const scrollLeft = this.scrollLeft;
370
+ const scrollTop = this.scrollTop;
371
+ const viewportWidth = this.viewportWidth;
372
+ const viewportHeight = this.viewportHeight;
373
+ const totalWidth = this.totalWidth;
374
+ const totalHeight = this.totalHeight;
375
+ const atLeftEdge = scrollLeft <= 5;
376
+ const atRightEdge = scrollLeft + viewportWidth >= totalWidth - 5;
377
+ const atBottomEdge = scrollTop + viewportHeight >= totalHeight - 5;
378
+ try {
379
+ if (this.hasMoreDataLeft && atLeftEdge && scrollLeft < this.lastScrollLeft) {
380
+ await this.loadMoreData("left");
381
+ } else if (this.hasMoreDataRight && atRightEdge && scrollLeft > this.lastScrollLeft) {
382
+ await this.loadMoreData("right");
383
+ } else if (this.hasMoreDataBottom && atBottomEdge && scrollTop > this.lastScrollTop) {
384
+ await this.loadMoreData("bottom");
385
+ }
386
+ } finally {
387
+ this.lastScrollLeft = scrollLeft;
388
+ this.lastScrollTop = scrollTop;
389
+ }
390
+ }
391
+ // Add this method to reset scroll loading state
392
+ resetScrollLoadingState() {
393
+ this.hasMoreDataLeft = true;
394
+ this.hasMoreDataRight = true;
395
+ this.hasMoreDataBottom = true;
396
+ this.lastScrollLeft = 0;
397
+ this.lastScrollTop = 0;
398
+ if (this.scrollLoadTimer !== null) {
399
+ clearTimeout(this.scrollLoadTimer);
400
+ this.scrollLoadTimer = null;
401
+ }
402
+ }
403
+ // Add this new method to load additional data
404
+ async loadMoreData(direction) {
405
+ if (this.isLoadingData || !this.onDataLoad) {
406
+ return;
407
+ }
408
+ this.isLoadingData = true;
409
+ try {
410
+ let newData = null;
411
+ switch (direction) {
412
+ case "left":
413
+ newData = await this.onDataLoad("left", { date: this.minDate });
414
+ if (newData && newData.length > 0) {
415
+ this.prependData(newData);
416
+ } else {
417
+ this.hasMoreDataLeft = false;
418
+ }
419
+ break;
420
+ case "right":
421
+ newData = await this.onDataLoad("right", { date: this.maxDate });
422
+ if (newData && newData.length > 0) {
423
+ this.appendData(newData);
424
+ } else {
425
+ this.hasMoreDataRight = false;
426
+ }
427
+ break;
428
+ case "bottom":
429
+ const currentRowCount = this.data.length;
430
+ newData = await this.onDataLoad("bottom", { offset: currentRowCount });
431
+ if (newData && newData.length > 0) {
432
+ this.appendRows(newData);
433
+ } else {
434
+ this.hasMoreDataBottom = false;
435
+ }
436
+ break;
437
+ }
438
+ if (newData && newData.length > 0) {
439
+ this.buildTaskMap();
440
+ this.calculateFullTimeline();
441
+ this.updateDimensions();
442
+ this.render();
443
+ }
444
+ } catch (error) {
445
+ console.error("Error loading additional data:", error);
446
+ } finally {
447
+ this.isLoadingData = false;
448
+ }
449
+ }
450
+ // Add this method to append data to the right
451
+ appendData(newData) {
452
+ newData.forEach((newRow, index) => {
453
+ if (index < this.data.length) {
454
+ this.data[index].tasks = [...this.data[index].tasks, ...newRow.tasks];
455
+ } else {
456
+ this.data.push(newRow);
457
+ }
458
+ });
459
+ }
460
+ // Add this method to prepend data to the left
461
+ prependData(newData) {
462
+ newData.forEach((newRow, index) => {
463
+ if (index < this.data.length) {
464
+ this.data[index].tasks = [...newRow.tasks, ...this.data[index].tasks];
465
+ } else {
466
+ this.data.push(newRow);
467
+ }
468
+ });
469
+ }
470
+ // Add this method to append rows to the bottom
471
+ appendRows(newData) {
472
+ this.data.push(...newData);
320
473
  }
321
474
  setScrollTop(scrollTop) {
322
475
  if (this.scrollTop !== scrollTop) this.container.scrollTop = scrollTop;
@@ -330,10 +483,14 @@ class GanttChart {
330
483
  }
331
484
  updateDimensions() {
332
485
  const totalDays = DateUtils.diffDays(this.timelineStart, this.timelineEnd) + 1;
333
- this.totalWidth = totalDays * this.pixelsPerDay;
334
- this.totalHeight = this.data.length * this.config.rowHeight + this.config.headerHeight;
335
- this.scrollDummy.style.width = `${this.totalWidth}px`;
336
- this.scrollDummy.style.height = `${this.totalHeight}px`;
486
+ const newTotalWidth = totalDays * this.pixelsPerDay;
487
+ const newTotalHeight = this.data.length * this.config.rowHeight + this.config.headerHeight;
488
+ if (this.totalWidth !== newTotalWidth || this.totalHeight !== newTotalHeight) {
489
+ this.totalWidth = newTotalWidth;
490
+ this.totalHeight = newTotalHeight;
491
+ this.scrollDummy.style.width = `${this.totalWidth}px`;
492
+ this.scrollDummy.style.height = `${this.totalHeight}px`;
493
+ }
337
494
  }
338
495
  setupCanvas(canvas, width, height) {
339
496
  canvas.width = width * this.devicePixelRatio;
@@ -351,19 +508,41 @@ class GanttChart {
351
508
  const y = i * this.config.rowHeight;
352
509
  row.tasks.forEach((task) => {
353
510
  const x_plan_start = this.dateToX(new Date(task.planStart));
354
- const x_plan_end = this.dateToX(DateUtils.addDays(new Date(task.planEnd), 1));
511
+ const x_plan_end = this.dateToX(DateUtils.addDays(task.planEnd, 1));
355
512
  let x_actual_start = null, x_actual_end = null;
513
+ let offset_x_plan_start = NaN, offset_x_plan_end = NaN;
514
+ let offset_x_actual_start = null, offset_x_actual_end = null;
515
+ let x_plan_width = 0;
516
+ let x_actual_width = 0;
517
+ const [offsetX_actual, percent_actual] = this.config.viewMode === "Day" && task.actualOffsetPercent ? task.actualOffsetPercent : [0, 1];
518
+ const [offsetX, percent_plan] = this.config.viewMode === "Day" && task.planOffsetPercent ? task.planOffsetPercent : [0, 1];
519
+ if (x_plan_start && x_plan_end) {
520
+ x_plan_width = x_plan_end - x_plan_start;
521
+ offset_x_plan_start = x_plan_start + x_plan_width * offsetX;
522
+ x_plan_end && (offset_x_plan_end = offset_x_plan_start + x_plan_width * percent_plan);
523
+ }
356
524
  if (task.actualStart) {
357
525
  x_actual_start = this.dateToX(new Date(task.actualStart));
358
526
  }
359
527
  if (task.actualEnd) {
360
- x_actual_end = this.dateToX(DateUtils.addDays(new Date(task.actualEnd), 1));
528
+ x_actual_end = this.dateToX(DateUtils.addDays(task.actualEnd, 1));
529
+ }
530
+ if (x_actual_start) {
531
+ x_actual_width = (x_actual_end ? x_actual_end : this.dateToX(this.today)) - x_actual_start;
532
+ offset_x_actual_start = Math.round(x_actual_start + x_actual_width * offsetX_actual);
533
+ x_actual_end && (offset_x_actual_end = offset_x_actual_start + x_actual_width * percent_actual);
361
534
  }
362
535
  this.taskPositions.set(task.id, {
363
536
  x_plan_start,
364
537
  x_plan_end,
365
538
  x_actual_start,
366
539
  x_actual_end,
540
+ offset_x_plan_start,
541
+ offset_x_plan_end,
542
+ offset_x_actual_start,
543
+ offset_x_actual_end,
544
+ x_plan_width,
545
+ x_actual_width,
367
546
  y: y + this.config.rowHeight * 0.5,
368
547
  row: i
369
548
  });
@@ -383,9 +562,11 @@ class GanttChart {
383
562
  return new Date(date.getFullYear(), date.getMonth(), date.getDate());
384
563
  }
385
564
  }
386
- render() {
565
+ render(scrolling = false) {
387
566
  this.updateVirtualRanges();
388
- this.calculateAllTaskPositions();
567
+ if (!scrolling) {
568
+ this.calculateAllTaskPositions();
569
+ }
389
570
  this.renderHeader();
390
571
  this.renderMain();
391
572
  }
@@ -395,82 +576,132 @@ class GanttChart {
395
576
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
396
577
  ctx.save();
397
578
  ctx.translate(-this.scrollLeft, 0);
398
- ctx.fillStyle = "#f9f9f9";
579
+ ctx.fillStyle = this.config.headerBgColor;
399
580
  ctx.fillRect(this.scrollLeft, 0, this.viewportWidth, h);
400
581
  ctx.textBaseline = "middle";
401
582
  ctx.textRendering = "optimizeLegibility";
402
583
  let currentDate = new Date(this.visibleDateRange.start);
403
584
  currentDate = this.getIterationStartDate(currentDate);
404
- let lastUpperText = "";
405
- while (this.dateToX(currentDate) < this.scrollLeft - this.pixelsPerDay * 7) {
585
+ const visibleBlocks = [];
586
+ let calcDate = new Date(currentDate);
587
+ while (calcDate <= this.visibleDateRange.end) {
588
+ let nextDate;
589
+ let upperText = "";
590
+ switch (this.config.viewMode) {
591
+ case "Day":
592
+ upperText = DateUtils.format(calcDate, "yyyy年MM月");
593
+ nextDate = DateUtils.addDays(calcDate, 1);
594
+ break;
595
+ case "Week":
596
+ const weekStart = DateUtils.getStartOfWeek(calcDate);
597
+ upperText = DateUtils.format(weekStart, "yyyy年MM月");
598
+ nextDate = DateUtils.addDays(weekStart, 7);
599
+ break;
600
+ case "Month":
601
+ upperText = `${calcDate.getFullYear()}年`;
602
+ nextDate = DateUtils.addMonths(calcDate, 1);
603
+ break;
604
+ case "Year":
605
+ if (calcDate.getMonth() === 0 && calcDate.getDate() === 1) {
606
+ upperText = `${calcDate.getFullYear()}年`;
607
+ nextDate = DateUtils.addMonths(calcDate, 6);
608
+ } else if (calcDate.getMonth() === 6 && calcDate.getDate() === 1) {
609
+ upperText = `${calcDate.getFullYear()}年`;
610
+ nextDate = DateUtils.addMonths(calcDate, 6);
611
+ } else {
612
+ calcDate = DateUtils.addDays(calcDate, 1);
613
+ continue;
614
+ }
615
+ break;
616
+ default:
617
+ nextDate = DateUtils.addDays(calcDate, 1);
618
+ break;
619
+ }
620
+ const startX = this.dateToX(calcDate);
621
+ const endX = this.dateToX(nextDate);
622
+ visibleBlocks.push({
623
+ startX,
624
+ endX,
625
+ text: upperText,
626
+ yPos: h * 0.35
627
+ });
628
+ calcDate = nextDate;
629
+ }
630
+ let currentDateForLower = new Date(currentDate);
631
+ while (this.dateToX(currentDateForLower) < this.scrollLeft - this.pixelsPerDay * 7) {
406
632
  let nextDate;
407
633
  switch (this.config.viewMode) {
408
634
  case "Day":
409
- nextDate = DateUtils.addDays(currentDate, 1);
635
+ nextDate = DateUtils.addDays(currentDateForLower, 1);
410
636
  break;
411
637
  case "Week":
412
- nextDate = DateUtils.addDays(currentDate, 7);
638
+ nextDate = DateUtils.addDays(currentDateForLower, 7);
413
639
  break;
414
640
  case "Month":
415
- nextDate = DateUtils.addMonths(currentDate, 1);
641
+ nextDate = DateUtils.addMonths(currentDateForLower, 1);
416
642
  break;
417
643
  case "Year":
418
- nextDate = DateUtils.addMonths(currentDate, 6);
644
+ nextDate = DateUtils.addMonths(currentDateForLower, 6);
419
645
  break;
420
646
  default:
421
- nextDate = DateUtils.addDays(currentDate, 1);
647
+ nextDate = DateUtils.addDays(currentDateForLower, 1);
422
648
  break;
423
649
  }
424
- if (nextDate.getTime() === currentDate.getTime()) {
425
- currentDate = DateUtils.addDays(currentDate, 1);
650
+ if (nextDate.getTime() === currentDateForLower.getTime()) {
651
+ currentDateForLower = DateUtils.addDays(currentDateForLower, 1);
426
652
  } else {
427
- currentDate = nextDate;
653
+ currentDateForLower = nextDate;
428
654
  }
429
655
  }
430
- while (currentDate <= this.visibleDateRange.end) {
431
- const x = this.dateToX(currentDate);
432
- let upperText = "", lowerText = "", nextDate;
656
+ const groupedBlocks = this.groupConsecutiveBlocks(visibleBlocks);
657
+ ctx.fillStyle = "#333";
658
+ ctx.font = 'bold 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
659
+ ctx.textAlign = "left";
660
+ groupedBlocks.forEach((group) => {
661
+ const visibleStart = Math.max(group.startX, this.scrollLeft);
662
+ const visibleEnd = Math.min(group.endX, this.scrollLeft + this.viewportWidth);
663
+ if (visibleEnd > visibleStart) {
664
+ ctx.fillStyle = this.config.headerBgColor;
665
+ ctx.fillRect(visibleStart, 0, visibleEnd - visibleStart, h * 0.5);
666
+ ctx.fillStyle = "#333";
667
+ ctx.fillText(group.text, visibleStart + 5, group.yPos);
668
+ }
669
+ });
670
+ while (currentDateForLower <= this.visibleDateRange.end) {
671
+ const x = this.dateToX(currentDateForLower);
672
+ let lowerText = "";
673
+ let nextDate;
433
674
  switch (this.config.viewMode) {
434
675
  case "Day":
435
- upperText = DateUtils.format(currentDate, "yyyy年MM月");
436
- lowerText = `${DateUtils.format(currentDate, "d")} ${DateUtils.format(currentDate, "W")}`;
437
- nextDate = DateUtils.addDays(currentDate, 1);
676
+ lowerText = `${DateUtils.format(currentDateForLower, "d")} ${DateUtils.format(currentDateForLower, "W")}`;
677
+ nextDate = DateUtils.addDays(currentDateForLower, 1);
438
678
  break;
439
679
  case "Week":
440
- const weekStart = DateUtils.getStartOfWeek(currentDate);
441
- upperText = DateUtils.format(weekStart, "yyyy年MM月");
680
+ const weekStart = DateUtils.getStartOfWeek(currentDateForLower);
442
681
  lowerText = `第${DateUtils.getWeekNumber(weekStart)}周`;
443
682
  nextDate = DateUtils.addDays(weekStart, 7);
444
683
  break;
445
684
  case "Month":
446
- upperText = `${currentDate.getFullYear()}年`;
447
- lowerText = `${currentDate.getMonth() + 1}月`;
448
- nextDate = DateUtils.addMonths(currentDate, 1);
685
+ lowerText = `${currentDateForLower.getMonth() + 1}月`;
686
+ nextDate = DateUtils.addMonths(currentDateForLower, 1);
449
687
  break;
450
688
  case "Year":
451
- if (currentDate.getMonth() === 0 && currentDate.getDate() === 1) {
452
- upperText = `${currentDate.getFullYear()}年`;
689
+ if (currentDateForLower.getMonth() === 0 && currentDateForLower.getDate() === 1) {
453
690
  lowerText = `上半年`;
454
- nextDate = DateUtils.addMonths(currentDate, 6);
455
- } else if (currentDate.getMonth() === 6 && currentDate.getDate() === 1) {
456
- upperText = `${currentDate.getFullYear()}年`;
691
+ nextDate = DateUtils.addMonths(currentDateForLower, 6);
692
+ } else if (currentDateForLower.getMonth() === 6 && currentDateForLower.getDate() === 1) {
457
693
  lowerText = `下半年`;
458
- nextDate = DateUtils.addMonths(currentDate, 6);
694
+ nextDate = DateUtils.addMonths(currentDateForLower, 6);
459
695
  } else {
460
- currentDate = DateUtils.addDays(currentDate, 1);
696
+ currentDateForLower = DateUtils.addDays(currentDateForLower, 1);
461
697
  continue;
462
698
  }
463
699
  break;
700
+ default:
701
+ nextDate = DateUtils.addDays(currentDateForLower, 1);
702
+ break;
464
703
  }
465
704
  const unitWidth = this.dateToX(nextDate) - x;
466
- if (upperText !== lastUpperText) {
467
- ctx.fillStyle = "#333";
468
- ctx.font = 'bold 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
469
- ctx.textRendering = "optimizeLegibility";
470
- ctx.textAlign = "left";
471
- ctx.fillText(upperText, x + 5, h * 0.35);
472
- lastUpperText = upperText;
473
- }
474
705
  ctx.fillStyle = "#000412";
475
706
  ctx.font = '14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
476
707
  ctx.textAlign = "center";
@@ -480,10 +711,10 @@ class GanttChart {
480
711
  ctx.lineTo(x, h);
481
712
  ctx.strokeStyle = "#e0e0e0";
482
713
  ctx.stroke();
483
- if (nextDate.getTime() === currentDate.getTime()) {
484
- currentDate = DateUtils.addDays(currentDate, 1);
714
+ if (nextDate.getTime() === currentDateForLower.getTime()) {
715
+ currentDateForLower = DateUtils.addDays(currentDateForLower, 1);
485
716
  } else {
486
- currentDate = nextDate;
717
+ currentDateForLower = nextDate;
487
718
  }
488
719
  }
489
720
  ctx.beginPath();
@@ -493,6 +724,22 @@ class GanttChart {
493
724
  ctx.stroke();
494
725
  ctx.restore();
495
726
  }
727
+ // Helper method to group consecutive blocks with same text
728
+ groupConsecutiveBlocks(blocks) {
729
+ if (blocks.length === 0) return [];
730
+ const grouped = [];
731
+ let currentGroup = { ...blocks[0] };
732
+ for (let i = 1; i < blocks.length; i++) {
733
+ if (blocks[i].text === currentGroup.text && Math.abs(blocks[i].startX - currentGroup.endX) < 1) {
734
+ currentGroup.endX = blocks[i].endX;
735
+ } else {
736
+ grouped.push(currentGroup);
737
+ currentGroup = { ...blocks[i] };
738
+ }
739
+ }
740
+ grouped.push(currentGroup);
741
+ return grouped;
742
+ }
496
743
  renderMain() {
497
744
  const ctx = this.mainCtx;
498
745
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
@@ -540,9 +787,9 @@ class GanttChart {
540
787
  if (!fromPos) return;
541
788
  const fromRowIndex = this.taskMap.get(depId).row;
542
789
  const isAdjacent = Math.abs(toRowIndex - fromRowIndex) === 1;
543
- const fromX = Math.max(fromPos.x_plan_end, fromPos.x_actual_end || fromPos.x_plan_end);
790
+ const fromX = Math.max(fromPos.offset_x_plan_end, fromPos.offset_x_actual_end || fromPos.offset_x_plan_end);
544
791
  const fromY = fromPos.y;
545
- const toX = Math.min(toPos.x_plan_start, toPos.x_actual_start || toPos.x_plan_start);
792
+ const toX = Math.min(toPos.offset_x_plan_start, toPos.offset_x_actual_start || toPos.offset_x_plan_start);
546
793
  const toY = toPos.y;
547
794
  ctx.beginPath();
548
795
  if (isAdjacent) {
@@ -697,42 +944,35 @@ class GanttChart {
697
944
  }
698
945
  drawTask(ctx, task, y, pos) {
699
946
  const offset = 4;
700
- const width = pos.x_plan_end - pos.x_plan_start;
701
947
  const taskY = y + this.config.rowHeight * 0.15 + offset;
702
948
  const taskHeight = this.config.rowHeight * 0.7 - offset;
703
949
  const textY = y + this.config.rowHeight / 2 + offset;
704
950
  const [offsetX_actual, percent_actual] = this.config.viewMode === "Day" && task.actualOffsetPercent ? task.actualOffsetPercent : [0, 1];
705
- const [offsetX, percent_plan] = this.config.viewMode === "Day" && task.planOffsetPercent ? task.planOffsetPercent : [0, 1];
706
951
  if (this.config.showActual && pos.x_actual_start) {
707
952
  ctx.fillStyle = task.actualBgColor ? task.actualBgColor : this.config.actualBgColor;
708
- const aWidth = (pos.x_actual_end ? pos.x_actual_end : this.dateToX(this.today)) - pos.x_actual_start;
709
- pos.x_actual_start += aWidth * offsetX_actual;
710
- pos.x_actual_end && (pos.x_actual_end = pos.x_actual_start + aWidth * percent_actual);
711
- ctx.fillRect(Math.round(pos.x_actual_start), Math.round(taskY + 2), Math.round(aWidth * percent_actual), Math.round(taskHeight - 2));
953
+ ctx.fillRect(pos.offset_x_actual_start, Math.round(taskY + 2), Math.round(pos.x_actual_width * percent_actual), Math.round(taskHeight - 2));
712
954
  }
713
955
  if (this.config.showPlan && pos.x_plan_start && pos.x_plan_end) {
714
956
  ctx.strokeStyle = task.planBorderColor ? task.planBorderColor : this.config.planBorderColor;
715
- pos.x_plan_start += width * offsetX;
716
- pos.x_plan_end && (pos.x_plan_end = pos.x_plan_start + width * percent_plan);
717
957
  ctx.lineWidth = 4;
718
958
  ctx.beginPath();
719
- ctx.moveTo(pos.x_plan_start, taskY);
720
- ctx.lineTo(pos.x_plan_start + width * percent_plan, taskY);
959
+ ctx.moveTo(pos.offset_x_plan_start + offset / 2, taskY);
960
+ ctx.lineTo(pos.offset_x_plan_end - offset / 2, taskY);
721
961
  ctx.stroke();
722
962
  }
723
963
  ctx.fillStyle = "#000";
724
964
  if (this.config.showLeftRemark && task.leftRemark) {
725
965
  ctx.textAlign = "right";
726
- ctx.fillText(task.leftRemark, Math.round(Math.min(...[pos.x_plan_start, pos.x_actual_start].filter((val) => val !== null && val !== void 0)) - 8), Math.round(textY));
966
+ ctx.fillText(task.leftRemark, Math.round(Math.min(...[pos.offset_x_plan_start, pos.offset_x_actual_start].filter((val) => val !== null && val !== void 0)) - 8), Math.round(textY));
727
967
  }
728
968
  if (this.config.showRightRemark && task.rightRemark) {
729
969
  ctx.textAlign = "left";
730
- ctx.fillText(task.rightRemark, Math.round(Math.max(...[pos.x_plan_end, pos.x_actual_end].filter((val) => val !== null && val !== void 0)) + 8), Math.round(textY));
970
+ ctx.fillText(task.rightRemark, Math.round(Math.max(...[pos.offset_x_plan_end, pos.offset_x_actual_end].filter((val) => val !== null && val !== void 0)) + 8), Math.round(textY));
731
971
  }
732
972
  if (this.config.showCenterRemark && task.centerRemark) {
733
- const centerX = pos.x_actual_start + (pos.x_actual_end - pos.x_actual_start) / 2;
973
+ const centerX = pos.offset_x_actual_start + (pos.offset_x_actual_end - pos.offset_x_actual_start) / 2;
734
974
  ctx.textAlign = "center";
735
- ctx.fillText(task.centerRemark, Math.round(centerX), Math.round(textY), Math.round(pos.x_actual_end - pos.x_actual_start));
975
+ ctx.fillText(task.centerRemark, Math.round(centerX), Math.round(textY), Math.round(pos.offset_x_actual_end - pos.offset_x_actual_start));
736
976
  }
737
977
  }
738
978
  getTaskStyles(task) {
@@ -753,6 +993,9 @@ class GanttChart {
753
993
  return styles;
754
994
  }
755
995
  handleMouseMove(e) {
996
+ if (this.scrolling) {
997
+ return;
998
+ }
756
999
  const rect = this.mainCanvas.getBoundingClientRect();
757
1000
  const mouseX = e.clientX - rect.left;
758
1001
  const mouseY = e.clientY - rect.top;
@@ -765,15 +1008,16 @@ class GanttChart {
765
1008
  if (this.config.tooltipFormat) {
766
1009
  const htmlStr = this.config.tooltipFormat(row, date, this.config);
767
1010
  if (!htmlStr) {
768
- return this.handleMouseLeave();
1011
+ this.handleMouseLeave();
1012
+ return;
769
1013
  }
770
1014
  this.tooltip.innerHTML = htmlStr;
771
1015
  } else {
772
1016
  const overlappingTasks = row.tasks.filter((task) => {
773
- const pStart = new Date(task.planStart), pEnd = DateUtils.addDays(new Date(task.planEnd), 1);
1017
+ const pStart = new Date(task.planStart).setHours(0, 0, 0, 0), pEnd = DateUtils.addDays(task.planEnd, 1);
774
1018
  if (date >= pStart && date < pEnd) return true;
775
1019
  if (task.actualStart) {
776
- const aStart = new Date(task.actualStart), aEnd = DateUtils.addDays(new Date(task.actualEnd), 1);
1020
+ const aStart = new Date(task.actualStart).setHours(0, 0, 0, 0), aEnd = DateUtils.addDays(task.actualEnd, 1);
777
1021
  if (date >= aStart && date < aEnd) return true;
778
1022
  }
779
1023
  return false;
@@ -787,6 +1031,7 @@ class GanttChart {
787
1031
  this.tooltip.innerHTML = html;
788
1032
  }
789
1033
  this.tooltip.style.display = "block";
1034
+ this.showTooltip = true;
790
1035
  if (this.config.tooltipColor === "white") {
791
1036
  this.tooltip.style.background = "#fff";
792
1037
  this.tooltip.style.color = "#000";
@@ -816,6 +1061,7 @@ class GanttChart {
816
1061
  }
817
1062
  handleMouseLeave() {
818
1063
  this.tooltip.style.display = "none";
1064
+ this.showTooltip = false;
819
1065
  }
820
1066
  /**
821
1067
  * 计算任务宽度占的百分比(方便绘制精确到具体时间的每日任务)
@@ -827,17 +1073,29 @@ class GanttChart {
827
1073
  return diffMilliseconds * pixelsPerDay / DateUtils.ONE_DAY_MS;
828
1074
  }
829
1075
  /**
830
- * scroll to specified date position, default to minDate
1076
+ * horizontal scroll to specified date position, default to minDate
831
1077
  *
832
1078
  * @param date
833
1079
  */
834
- scrollToStartDate(date) {
1080
+ horizontalScrollTo(date) {
835
1081
  const startDate = date ? date : this.minDate;
836
1082
  if (startDate) {
837
1083
  const xPosition = this.dateToX(startDate);
838
1084
  this.container.scrollTo({ left: xPosition - 80 });
839
1085
  }
840
1086
  }
1087
+ /**
1088
+ * vertical scroll to specified position
1089
+ *
1090
+ * @param params
1091
+ */
1092
+ verticalScrollTo(params) {
1093
+ if (params && (params.rowId || params.rowIndex)) {
1094
+ const rowIndex = params.rowIndex ? params.rowIndex : this.data.findIndex((row) => row.id === params.rowId);
1095
+ const yPosition = this.config.rowHeight * rowIndex;
1096
+ this.container.scrollTo({ top: yPosition - 80 });
1097
+ }
1098
+ }
841
1099
  }
842
1100
  exports.DateUtils = DateUtils;
843
1101
  exports.GanttChart = GanttChart;