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