@ganttloom/gantt-core 0.2.0 → 0.4.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.
package/dist/index.cjs CHANGED
@@ -189,7 +189,6 @@ function computeCriticalPath(tasks, dependencies) {
189
189
  }
190
190
  for (const e of edges) {
191
191
  outgoing.get(e.fromId)?.push(e);
192
- incoming.get(e.fromId) === void 0 ? void 0 : void 0;
193
192
  incoming.get(e.toId)?.push(e);
194
193
  }
195
194
  const inDegree = /* @__PURE__ */ new Map();
@@ -274,11 +273,9 @@ function computeCriticalPath(tasks, dependencies) {
274
273
  criticalLinks.add(linkKey(e.fromId, e.toId));
275
274
  }
276
275
  }
277
- const taskMap = new Map(tasks.map((t) => [t.id, t]));
278
276
  for (const task of tasks) {
279
277
  if (!task.isGroup) continue;
280
278
  let hasCriticalDescendant = false;
281
- let cur = task;
282
279
  const descendantStack = [task.id];
283
280
  const visited = /* @__PURE__ */ new Set();
284
281
  while (descendantStack.length > 0) {
@@ -293,8 +290,6 @@ function computeCriticalPath(tasks, dependencies) {
293
290
  }
294
291
  }
295
292
  if (hasCriticalDescendant) criticalTasks.add(task.id);
296
- void cur;
297
- void taskMap;
298
293
  }
299
294
  return { criticalTasks, criticalLinks };
300
295
  }
@@ -668,6 +663,29 @@ function computeProgressRollup(node) {
668
663
  }
669
664
  node.computedProgress = totalWeight > 0 ? weightedSum / totalWeight : 0;
670
665
  }
666
+ function sortValue(node, col) {
667
+ if (!col) return null;
668
+ if (col.accessor) return col.accessor(node.task) ?? null;
669
+ return col.id === "name" ? node.task.name : null;
670
+ }
671
+ function compareValues(a, b) {
672
+ if (a === null && b === null) return 0;
673
+ if (a === null) return 1;
674
+ if (b === null) return -1;
675
+ if (typeof a === "number" && typeof b === "number") return a - b;
676
+ return String(a).localeCompare(String(b), void 0, { numeric: true, sensitivity: "base" });
677
+ }
678
+ function sortTree(roots, columns, sort) {
679
+ if (!sort) return;
680
+ const col = columns.find((c) => c.id === sort.columnId);
681
+ const dir = sort.direction === "desc" ? -1 : 1;
682
+ const compare = (a, b) => dir * compareValues(sortValue(a, col), sortValue(b, col));
683
+ const sortLevel = (nodes) => {
684
+ nodes.sort(compare);
685
+ for (const node of nodes) sortLevel(node.children);
686
+ };
687
+ sortLevel(roots);
688
+ }
671
689
  function flatten(roots) {
672
690
  const out = [];
673
691
  const stack = [];
@@ -689,6 +707,9 @@ function flatten(roots) {
689
707
  }
690
708
  return out;
691
709
  }
710
+ function estimateTextWidth(text, fontSize) {
711
+ return text.length * fontSize * 0.62;
712
+ }
692
713
  function anchorPoint(bar, side) {
693
714
  const y = bar.y + bar.height / 2;
694
715
  return side === "start" ? { x: bar.x, y } : { x: bar.x + bar.width, y };
@@ -751,6 +772,14 @@ function linkPath(from, to) {
751
772
  const c2y = to.y;
752
773
  return `M ${from.x} ${from.y} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${to.x} ${to.y}`;
753
774
  }
775
+ var MIN_VISIBLE_UNITS = {
776
+ hour: 8,
777
+ day: 7,
778
+ week: 6,
779
+ month: 6,
780
+ quarter: 4,
781
+ year: 5
782
+ };
754
783
  function computeLayout(input) {
755
784
  const {
756
785
  tasks,
@@ -766,7 +795,8 @@ function computeLayout(input) {
766
795
  markers = [],
767
796
  autoRollupProgress = false,
768
797
  selectedTaskIds,
769
- pagination
798
+ pagination,
799
+ sort = null
770
800
  } = input;
771
801
  validateInputs(tasks, dependencies);
772
802
  const { roots, nodeById } = buildTree(tasks);
@@ -774,6 +804,7 @@ function computeLayout(input) {
774
804
  if (autoRollupProgress) {
775
805
  for (const root of roots) computeProgressRollup(root);
776
806
  }
807
+ sortTree(roots, columns, sort);
777
808
  let visibleRoots = roots;
778
809
  if (pagination && pagination.pageSize > 0) {
779
810
  const start = Math.max(0, (pagination.page - 1) * pagination.pageSize);
@@ -793,8 +824,15 @@ function computeLayout(input) {
793
824
  const now = Date.now();
794
825
  const rawRangeStart = rangeMin === null ? new Date(now) : new Date(rangeMin);
795
826
  const rawRangeEnd = rangeMax === null ? new Date(now + 1) : new Date(rangeMax);
796
- const paddedStart = addUnit(rawRangeStart, viewMode, -1);
797
- const paddedEnd = addUnit(rawRangeEnd, viewMode, 1);
827
+ let paddedStart = addUnit(rawRangeStart, viewMode, -1);
828
+ let paddedEnd = addUnit(rawRangeEnd, viewMode, 1);
829
+ const minUnits = MIN_VISIBLE_UNITS[viewMode];
830
+ const currentUnits = (paddedEnd.getTime() - paddedStart.getTime()) / approxUnitMs(viewMode);
831
+ if (currentUnits < minUnits) {
832
+ const deficit = minUnits - currentUnits;
833
+ paddedStart = addUnit(paddedStart, viewMode, -Math.ceil(deficit / 2));
834
+ paddedEnd = addUnit(paddedEnd, viewMode, Math.floor(deficit / 2));
835
+ }
798
836
  const rows = [];
799
837
  const bars = [];
800
838
  const barByTaskId = /* @__PURE__ */ new Map();
@@ -814,8 +852,9 @@ function computeLayout(input) {
814
852
  const end = row.node.computedEnd;
815
853
  if (!start || !end) return;
816
854
  const x = dateToX(start, paddedStart, viewMode, columnWidth);
817
- const width2 = Math.max(0, dateToX(end, paddedStart, viewMode, columnWidth) - x);
818
- if (!Number.isFinite(x) || !Number.isFinite(width2)) return;
855
+ const rawWidth = Math.max(0, dateToX(end, paddedStart, viewMode, columnWidth) - x);
856
+ if (!Number.isFinite(x) || !Number.isFinite(rawWidth)) return;
857
+ const width2 = rawWidth > 0 ? Math.max(rawWidth, 3) : rawWidth;
819
858
  const progress = task.progress !== void 0 ? Math.min(100, Math.max(0, task.progress)) : autoRollupProgress ? row.node.computedProgress : 0;
820
859
  const barHeight = theme.barHeight;
821
860
  const barY = y + (theme.rowHeight - barHeight) / 2;
@@ -923,7 +962,13 @@ function computeLayout(input) {
923
962
  })).filter((m) => Number.isFinite(m.x));
924
963
  let width = 0;
925
964
  for (const tick of ticks) width = Math.max(width, tick.x);
926
- for (const bar of bars) width = Math.max(width, bar.x + bar.width);
965
+ for (const bar of bars) {
966
+ width = Math.max(width, bar.x + bar.width);
967
+ if (bar.label) {
968
+ const labelOffset = bar.isMilestone ? theme.barHeight * 0.4 + 8 : bar.width + 6;
969
+ width = Math.max(width, bar.x + labelOffset + estimateTextWidth(bar.label, theme.fontSize));
970
+ }
971
+ }
927
972
  width += columnWidth;
928
973
  const height = rows.length * theme.rowHeight;
929
974
  return {
@@ -938,7 +983,8 @@ function computeLayout(input) {
938
983
  columns,
939
984
  theme,
940
985
  markers: renderMarkers,
941
- rangeStart: paddedStart
986
+ rangeStart: paddedStart,
987
+ sort
942
988
  };
943
989
  }
944
990
 
@@ -1135,6 +1181,7 @@ var GanttRenderer = class {
1135
1181
  this.lastModel = null;
1136
1182
  this.lastTasks = [];
1137
1183
  this.lastColumns = [];
1184
+ this.lastExplicitThemeKeys = /* @__PURE__ */ new Set();
1138
1185
  this.onGridDoubleClick = (evt) => {
1139
1186
  if (!this.options.onRenameCommit) return;
1140
1187
  const target = evt.target;
@@ -1172,8 +1219,13 @@ var GanttRenderer = class {
1172
1219
  this.root = el("div", "gantt-root");
1173
1220
  this.gridPanel = el("div", "gantt-grid-panel");
1174
1221
  this.timelineScroll = el("div", "gantt-timeline-scroll");
1222
+ this.headerContainer = el("div", "gantt-timeline-header");
1223
+ this.headerSvg = svgEl("svg");
1224
+ this.headerSvg.classList.add("gantt-header-svg");
1225
+ this.headerContainer.appendChild(this.headerSvg);
1175
1226
  this.svg = svgEl("svg");
1176
1227
  this.svg.classList.add("gantt-svg");
1228
+ this.timelineScroll.appendChild(this.headerContainer);
1177
1229
  this.timelineScroll.appendChild(this.svg);
1178
1230
  this.root.appendChild(this.gridPanel);
1179
1231
  this.root.appendChild(this.timelineScroll);
@@ -1189,9 +1241,14 @@ var GanttRenderer = class {
1189
1241
  this.lastTasks = tasks;
1190
1242
  this.lastColumns = columns;
1191
1243
  const cssVars = explicitThemeToCssVars(explicitTheme);
1192
- for (const key of Object.keys(cssVars)) {
1244
+ const newKeys = new Set(Object.keys(cssVars));
1245
+ for (const key of this.lastExplicitThemeKeys) {
1246
+ if (!newKeys.has(key)) this.root.style.removeProperty(key);
1247
+ }
1248
+ for (const key of newKeys) {
1193
1249
  this.root.style.setProperty(key, cssVars[key]);
1194
1250
  }
1251
+ this.lastExplicitThemeKeys = newKeys;
1195
1252
  this.renderGridPanel(model, tasks, columns);
1196
1253
  this.renderTimeline(model, tasks);
1197
1254
  }
@@ -1219,7 +1276,7 @@ var GanttRenderer = class {
1219
1276
  const header = el("div", "gantt-grid-header-row");
1220
1277
  header.style.height = `${model.headerHeight}px`;
1221
1278
  for (const col of columns) {
1222
- header.appendChild(this.renderHeaderCell(col, columns));
1279
+ header.appendChild(this.renderHeaderCell(col, columns, model.sort));
1223
1280
  }
1224
1281
  this.gridPanel.appendChild(header);
1225
1282
  const body = el("div", "gantt-grid-body");
@@ -1232,11 +1289,23 @@ var GanttRenderer = class {
1232
1289
  });
1233
1290
  this.gridPanel.appendChild(body);
1234
1291
  }
1235
- renderHeaderCell(col, columns) {
1292
+ renderHeaderCell(col, columns, sort) {
1236
1293
  const cell = el("div", "gantt-grid-header-cell");
1237
1294
  cell.style.position = "relative";
1238
1295
  if (col.width) cell.style.width = `${col.width}px`;
1239
1296
  cell.textContent = col.title;
1297
+ if (col.sortable) {
1298
+ cell.classList.add("gantt-grid-header-cell-sortable");
1299
+ const active = sort?.columnId === col.id;
1300
+ const indicator = el("span", "gantt-sort-indicator");
1301
+ indicator.textContent = active ? sort.direction === "asc" ? " \u25B2" : " \u25BC" : "";
1302
+ cell.appendChild(indicator);
1303
+ cell.addEventListener("click", (evt) => {
1304
+ const target = evt.target;
1305
+ if (target.closest(".gantt-col-resize-handle")) return;
1306
+ this.options.onSortClick?.(col.id);
1307
+ });
1308
+ }
1240
1309
  if (this.options.onColumnReorder) {
1241
1310
  cell.draggable = true;
1242
1311
  cell.style.cursor = "grab";
@@ -1363,8 +1432,8 @@ var GanttRenderer = class {
1363
1432
  renderTimeline(model, tasks) {
1364
1433
  this.svg.replaceChildren();
1365
1434
  this.svg.setAttribute("width", String(model.width));
1366
- this.svg.setAttribute("height", String(model.headerHeight + model.height));
1367
- this.svg.setAttribute("viewBox", `0 0 ${model.width} ${model.headerHeight + model.height}`);
1435
+ this.svg.setAttribute("height", String(model.height));
1436
+ this.svg.setAttribute("viewBox", `0 0 ${model.width} ${model.height}`);
1368
1437
  const defs = svgEl("defs");
1369
1438
  const marker = svgEl("marker");
1370
1439
  marker.setAttribute("id", this.markerDefsId);
@@ -1381,7 +1450,6 @@ var GanttRenderer = class {
1381
1450
  this.svg.appendChild(defs);
1382
1451
  const { startY, endY } = this.getViewportRowRange(model);
1383
1452
  const bodyGroup = svgEl("g");
1384
- bodyGroup.setAttribute("transform", `translate(0, ${model.headerHeight})`);
1385
1453
  this.renderGridLines(bodyGroup, model);
1386
1454
  this.renderRowBackgrounds(bodyGroup, model, startY, endY);
1387
1455
  this.renderBars(bodyGroup, model, tasks, startY, endY);
@@ -1422,7 +1490,9 @@ var GanttRenderer = class {
1422
1490
  }
1423
1491
  }
1424
1492
  renderMarkers(group, model) {
1425
- for (const marker of model.markers) {
1493
+ const sorted = [...model.markers].sort((a, b) => a.x - b.x);
1494
+ let defs = null;
1495
+ sorted.forEach((marker, i) => {
1426
1496
  const line = svgEl("line");
1427
1497
  line.setAttribute("x1", String(marker.x));
1428
1498
  line.setAttribute("x2", String(marker.x));
@@ -1434,21 +1504,41 @@ var GanttRenderer = class {
1434
1504
  line.dataset.ganttMarker = marker.label;
1435
1505
  group.appendChild(line);
1436
1506
  if (marker.label) {
1507
+ const nextX = sorted[i + 1]?.x ?? model.width;
1508
+ const cellWidth = Math.max(0, nextX - marker.x);
1509
+ if (!defs) {
1510
+ defs = svgEl("defs");
1511
+ group.insertBefore(defs, group.firstChild);
1512
+ }
1513
+ const clipId = `${this.markerDefsId}-marker-${i}`;
1514
+ const clipPath = svgEl("clipPath");
1515
+ clipPath.setAttribute("id", clipId);
1516
+ const clipRect = svgEl("rect");
1517
+ clipRect.setAttribute("x", String(marker.x));
1518
+ clipRect.setAttribute("y", "0");
1519
+ clipRect.setAttribute("width", String(cellWidth));
1520
+ clipRect.setAttribute("height", String(model.height));
1521
+ clipPath.appendChild(clipRect);
1522
+ defs.appendChild(clipPath);
1437
1523
  const label = svgEl("text");
1438
1524
  label.setAttribute("x", String(marker.x + 4));
1439
1525
  label.setAttribute("y", "12");
1526
+ label.setAttribute("clip-path", `url(#${clipId})`);
1440
1527
  label.setAttribute("fill", marker.color);
1441
1528
  label.setAttribute("font-size", String(model.theme.fontSize));
1442
1529
  label.setAttribute("font-family", model.theme.fontFamily);
1443
1530
  label.textContent = marker.label;
1444
1531
  group.appendChild(label);
1445
1532
  }
1446
- }
1533
+ });
1447
1534
  }
1448
1535
  renderHeader(model) {
1449
- let headerGroup = this.svg.querySelector(".gantt-header-group");
1450
- if (headerGroup) headerGroup.remove();
1451
- headerGroup = svgEl("g");
1536
+ this.headerSvg.replaceChildren();
1537
+ this.headerSvg.setAttribute("width", String(model.width));
1538
+ this.headerSvg.setAttribute("height", String(model.headerHeight));
1539
+ this.headerSvg.setAttribute("viewBox", `0 0 ${model.width} ${model.headerHeight}`);
1540
+ this.headerContainer.style.height = `${model.headerHeight}px`;
1541
+ const headerGroup = svgEl("g");
1452
1542
  headerGroup.setAttribute("class", "gantt-header-group");
1453
1543
  const bg = svgEl("rect");
1454
1544
  bg.setAttribute("x", "0");
@@ -1457,17 +1547,33 @@ var GanttRenderer = class {
1457
1547
  bg.setAttribute("height", String(model.headerHeight));
1458
1548
  bg.style.fill = themedFill("backgroundColor", model.theme.backgroundColor);
1459
1549
  headerGroup.appendChild(bg);
1460
- for (const tick of model.ticks) {
1550
+ const defs = svgEl("defs");
1551
+ const ticks = model.ticks;
1552
+ for (let i = 0; i < ticks.length; i++) {
1553
+ const tick = ticks[i];
1554
+ const cellWidth = (ticks[i + 1]?.x ?? model.width) - tick.x;
1555
+ const clipId = `${this.markerDefsId}-tick-${i}`;
1556
+ const clipPath = svgEl("clipPath");
1557
+ clipPath.setAttribute("id", clipId);
1558
+ const clipRect = svgEl("rect");
1559
+ clipRect.setAttribute("x", String(tick.x));
1560
+ clipRect.setAttribute("y", "0");
1561
+ clipRect.setAttribute("width", String(Math.max(0, cellWidth)));
1562
+ clipRect.setAttribute("height", String(model.headerHeight));
1563
+ clipPath.appendChild(clipRect);
1564
+ defs.appendChild(clipPath);
1461
1565
  const text = svgEl("text");
1462
1566
  text.setAttribute("x", String(tick.x + 4));
1463
1567
  text.setAttribute("y", String(model.headerHeight - 8));
1568
+ text.setAttribute("clip-path", `url(#${clipId})`);
1464
1569
  text.style.fill = themedFill("textColor", model.theme.textColor);
1465
1570
  text.setAttribute("font-size", String(model.theme.fontSize));
1466
1571
  text.setAttribute("font-family", model.theme.fontFamily);
1467
1572
  text.textContent = tick.label;
1468
1573
  headerGroup.appendChild(text);
1469
1574
  }
1470
- this.svg.appendChild(headerGroup);
1575
+ headerGroup.insertBefore(defs, headerGroup.firstChild);
1576
+ this.headerSvg.appendChild(headerGroup);
1471
1577
  }
1472
1578
  /**
1473
1579
  * Invisible full-width hit-test rects, one per row, behind the bars. Lets
@@ -1502,11 +1608,6 @@ var GanttRenderer = class {
1502
1608
  g.setAttribute("class", "gantt-bar");
1503
1609
  g.dataset.ganttBar = bar.taskId;
1504
1610
  g.setAttribute("transform", `translate(${bar.x}, ${bar.y})`);
1505
- if (task?.notes) {
1506
- const title = svgEl("title");
1507
- title.textContent = task.notes;
1508
- g.appendChild(title);
1509
- }
1510
1611
  if (this.options.keyboardAccessible) {
1511
1612
  g.setAttribute("tabindex", "0");
1512
1613
  g.setAttribute("role", "button");
@@ -1751,8 +1852,30 @@ var GanttRenderer = class {
1751
1852
  group.appendChild(path);
1752
1853
  }
1753
1854
  }
1855
+ /**
1856
+ * The header and body are separate DOM elements at runtime (so plain CSS `position: sticky`
1857
+ * can pin the header - see the constructor's doc comment), but exports need one self-contained
1858
+ * SVG with both, laid out exactly as the original single-SVG version was.
1859
+ */
1754
1860
  toSVGString() {
1755
- return new XMLSerializer().serializeToString(this.svg);
1861
+ const headerHeight = this.lastModel?.headerHeight ?? 0;
1862
+ const width = this.lastModel?.width ?? 0;
1863
+ const bodyHeight = this.lastModel?.height ?? 0;
1864
+ const combined = svgEl("svg");
1865
+ combined.setAttribute("xmlns", SVG_NS);
1866
+ combined.setAttribute("width", String(width));
1867
+ combined.setAttribute("height", String(headerHeight + bodyHeight));
1868
+ combined.setAttribute("viewBox", `0 0 ${width} ${headerHeight + bodyHeight}`);
1869
+ for (const child of Array.from(this.headerSvg.children)) {
1870
+ combined.appendChild(child.cloneNode(true));
1871
+ }
1872
+ const bodyGroup = svgEl("g");
1873
+ bodyGroup.setAttribute("transform", `translate(0, ${headerHeight})`);
1874
+ for (const child of Array.from(this.svg.children)) {
1875
+ bodyGroup.appendChild(child.cloneNode(true));
1876
+ }
1877
+ combined.appendChild(bodyGroup);
1878
+ return new XMLSerializer().serializeToString(combined);
1756
1879
  }
1757
1880
  destroy() {
1758
1881
  if (this.scrollListener) {
@@ -2131,13 +2254,17 @@ var InteractionController = class {
2131
2254
  }
2132
2255
  };
2133
2256
  this.onSvgDoubleClick = (evt) => {
2134
- if (!this.callbacks.onLinkDblClick) return;
2135
2257
  const target = evt.target;
2136
2258
  const linkPath2 = target.closest("[data-gantt-link]");
2137
2259
  const key = linkPath2?.dataset.ganttLink;
2138
- if (!key) return;
2139
- const [fromId, toId] = key.split("->");
2140
- if (fromId && toId) this.callbacks.onLinkDblClick(fromId, toId);
2260
+ if (key && this.callbacks.onLinkDblClick) {
2261
+ const [fromId, toId] = key.split("->");
2262
+ if (fromId && toId) this.callbacks.onLinkDblClick(fromId, toId);
2263
+ return;
2264
+ }
2265
+ const barGroup = target.closest("[data-gantt-bar]");
2266
+ const taskId = barGroup?.dataset.ganttBar;
2267
+ if (taskId && this.callbacks.onBarDblClick) this.callbacks.onBarDblClick(taskId);
2141
2268
  };
2142
2269
  this.onPointerDown = (evt) => {
2143
2270
  const target = evt.target;
@@ -2400,6 +2527,119 @@ var InteractionController = class {
2400
2527
  }
2401
2528
  };
2402
2529
 
2530
+ // src/tooltip.ts
2531
+ function formatDate(d) {
2532
+ return d.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" });
2533
+ }
2534
+ function defaultTooltipContent(task) {
2535
+ const el2 = document.createElement("div");
2536
+ const title = document.createElement("div");
2537
+ title.className = "gantt-tooltip-title";
2538
+ title.textContent = task.name;
2539
+ el2.appendChild(title);
2540
+ const dates = document.createElement("div");
2541
+ dates.className = "gantt-tooltip-row";
2542
+ dates.textContent = task.isMilestone ? formatDate(task.start) : `${formatDate(task.start)} \u2192 ${formatDate(task.end)}`;
2543
+ el2.appendChild(dates);
2544
+ if (task.progress !== void 0) {
2545
+ const progress = document.createElement("div");
2546
+ progress.className = "gantt-tooltip-row";
2547
+ progress.textContent = `${Math.round(task.progress)}% complete`;
2548
+ el2.appendChild(progress);
2549
+ }
2550
+ if (task.assignees && task.assignees.length > 0) {
2551
+ const assignees = document.createElement("div");
2552
+ assignees.className = "gantt-tooltip-row";
2553
+ assignees.textContent = task.assignees.map((a) => a.name).join(", ");
2554
+ el2.appendChild(assignees);
2555
+ }
2556
+ if (task.notes) {
2557
+ const notes = document.createElement("div");
2558
+ notes.className = "gantt-tooltip-notes";
2559
+ notes.textContent = task.notes;
2560
+ el2.appendChild(notes);
2561
+ }
2562
+ return el2;
2563
+ }
2564
+ var Tooltip = class {
2565
+ constructor(getOptions, getTask) {
2566
+ this.svg = null;
2567
+ this.hoveredTaskId = null;
2568
+ this.onPointerOver = (evt) => {
2569
+ if (!this.getOptions().enabled) return;
2570
+ const target = evt.target;
2571
+ const barGroup = target.closest("[data-gantt-bar]");
2572
+ const taskId = barGroup?.dataset.ganttBar;
2573
+ if (!taskId || taskId === this.hoveredTaskId) return;
2574
+ const task = this.getTask(taskId);
2575
+ if (!task) return;
2576
+ const { render } = this.getOptions();
2577
+ const content = render ? render(task) : defaultTooltipContent(task);
2578
+ if (content === null || content === void 0) {
2579
+ this.hide();
2580
+ return;
2581
+ }
2582
+ this.el.replaceChildren();
2583
+ if (content instanceof HTMLElement) {
2584
+ this.el.appendChild(content);
2585
+ } else {
2586
+ this.el.textContent = content;
2587
+ }
2588
+ this.hoveredTaskId = taskId;
2589
+ this.el.style.display = "block";
2590
+ this.position(evt.clientX, evt.clientY);
2591
+ };
2592
+ this.onPointerMove = (evt) => {
2593
+ if (this.hoveredTaskId === null) return;
2594
+ this.position(evt.clientX, evt.clientY);
2595
+ };
2596
+ this.onPointerOut = (evt) => {
2597
+ const related = evt.relatedTarget;
2598
+ const target = evt.target;
2599
+ const leftBarGroup = target.closest("[data-gantt-bar]");
2600
+ if (related && leftBarGroup?.contains(related)) return;
2601
+ this.hide();
2602
+ };
2603
+ this.getOptions = getOptions;
2604
+ this.getTask = getTask;
2605
+ this.el = document.createElement("div");
2606
+ this.el.className = "gantt-tooltip";
2607
+ this.el.setAttribute("role", "tooltip");
2608
+ this.el.style.display = "none";
2609
+ document.body.appendChild(this.el);
2610
+ }
2611
+ attach(svg) {
2612
+ this.svg = svg;
2613
+ svg.addEventListener("pointerover", this.onPointerOver);
2614
+ svg.addEventListener("pointermove", this.onPointerMove);
2615
+ svg.addEventListener("pointerout", this.onPointerOut);
2616
+ }
2617
+ position(clientX, clientY) {
2618
+ const OFFSET = 12;
2619
+ const rect = this.el.getBoundingClientRect();
2620
+ const viewportW = window.innerWidth;
2621
+ const viewportH = window.innerHeight;
2622
+ let left = clientX + OFFSET;
2623
+ let top = clientY + OFFSET;
2624
+ if (left + rect.width > viewportW) left = clientX - OFFSET - rect.width;
2625
+ if (top + rect.height > viewportH) top = clientY - OFFSET - rect.height;
2626
+ this.el.style.left = `${Math.max(0, left)}px`;
2627
+ this.el.style.top = `${Math.max(0, top)}px`;
2628
+ }
2629
+ hide() {
2630
+ this.hoveredTaskId = null;
2631
+ this.el.style.display = "none";
2632
+ }
2633
+ destroy() {
2634
+ if (this.svg) {
2635
+ this.svg.removeEventListener("pointerover", this.onPointerOver);
2636
+ this.svg.removeEventListener("pointermove", this.onPointerMove);
2637
+ this.svg.removeEventListener("pointerout", this.onPointerOut);
2638
+ }
2639
+ this.el.remove();
2640
+ }
2641
+ };
2642
+
2403
2643
  // src/index.ts
2404
2644
  var HAS_DOM = typeof document !== "undefined" && typeof window !== "undefined";
2405
2645
  var DEFAULT_COLUMNS = [{ id: "name", title: "Name" }];
@@ -2419,6 +2659,9 @@ var _GanttChart = class _GanttChart {
2419
2659
  this.history = null;
2420
2660
  this.renderer = null;
2421
2661
  this.interactions = null;
2662
+ this.tooltip = null;
2663
+ this.resizeObserver = null;
2664
+ this.sort = null;
2422
2665
  this.renderModel = null;
2423
2666
  this.rafHandle = null;
2424
2667
  this.currentPage = 1;
@@ -2441,6 +2684,8 @@ var _GanttChart = class _GanttChart {
2441
2684
  showCriticalPath: options.showCriticalPath ?? false,
2442
2685
  showBaseline: options.showBaseline ?? false,
2443
2686
  showDeadlines: options.showDeadlines ?? true,
2687
+ showTooltip: options.showTooltip ?? true,
2688
+ autoFitToViewport: options.autoFitToViewport ?? false,
2444
2689
  showAssigneeAvatars: options.showAssigneeAvatars ?? false,
2445
2690
  enableHistory: options.enableHistory ?? false,
2446
2691
  keyboardAccessible: options.keyboardAccessible ?? false,
@@ -2461,13 +2706,16 @@ var _GanttChart = class _GanttChart {
2461
2706
  onDependencyRemove: options.onDependencyRemove,
2462
2707
  onDependencyDblClick: options.onDependencyDblClick,
2463
2708
  onTaskClick: options.onTaskClick,
2709
+ onTaskDblClick: options.onTaskDblClick,
2464
2710
  onGroupToggle: options.onGroupToggle,
2465
2711
  onContextMenu: options.onContextMenu,
2466
2712
  onTaskCreate: options.onTaskCreate,
2467
2713
  onColumnResize: options.onColumnResize,
2468
2714
  onColumnReorder: options.onColumnReorder,
2469
2715
  onTaskReorder: options.onTaskReorder,
2470
- onSelectionChange: options.onSelectionChange
2716
+ onSelectionChange: options.onSelectionChange,
2717
+ onSortChange: options.onSortChange,
2718
+ renderTooltip: options.renderTooltip
2471
2719
  };
2472
2720
  if (this.options.enableHistory) {
2473
2721
  this.history = new HistoryManager((state) => this.emitter.emit("history-change", state));
@@ -2490,7 +2738,8 @@ var _GanttChart = class _GanttChart {
2490
2738
  onRenameCommit: (taskId, name) => this.updateTask(taskId, { name }),
2491
2739
  onColumnResize: this.options.onColumnResize ? (columnId, width) => this.handleColumnResize(columnId, width) : void 0,
2492
2740
  onColumnReorder: this.options.onColumnReorder ? (order) => this.handleColumnReorder(order) : void 0,
2493
- onRowReorder: this.options.onTaskReorder ? (draggedId, targetId, position) => this.handleRowReorder(draggedId, targetId, position) : void 0
2741
+ onRowReorder: this.options.onTaskReorder ? (draggedId, targetId, position) => this.handleRowReorder(draggedId, targetId, position) : void 0,
2742
+ onSortClick: (columnId) => this.handleSortClick(columnId)
2494
2743
  });
2495
2744
  this.interactions = new InteractionController(
2496
2745
  this.renderer.svg,
@@ -2511,7 +2760,17 @@ var _GanttChart = class _GanttChart {
2511
2760
  onCreateTaskDrag: this.options.onTaskCreate ? (rowTaskId, startXPx, endXPx) => this.handleCreateTaskDrag(rowTaskId, startXPx, endXPx) : void 0,
2512
2761
  onLinkDblClick: this.options.onDependencyDblClick ? (fromId, toId) => {
2513
2762
  const dep = this.dependencies.find((d) => d.fromId === fromId && d.toId === toId);
2514
- if (dep) this.options.onDependencyDblClick?.(dep);
2763
+ if (dep) {
2764
+ this.emitter.emit("dependency-dblclick", dep);
2765
+ this.options.onDependencyDblClick?.(dep);
2766
+ }
2767
+ } : void 0,
2768
+ onBarDblClick: this.options.onTaskDblClick ? (taskId) => {
2769
+ const task = this.tasks.find((t) => t.id === taskId);
2770
+ if (task) {
2771
+ this.emitter.emit("task-dblclick", { task });
2772
+ this.options.onTaskDblClick?.(task);
2773
+ }
2515
2774
  } : void 0,
2516
2775
  onContextMenu: this.options.onContextMenu ? (taskId, evt) => {
2517
2776
  const task = this.tasks.find((t) => t.id === taskId);
@@ -2530,6 +2789,17 @@ var _GanttChart = class _GanttChart {
2530
2789
  pxPerMs: () => pxPerMs(this.options.viewMode, this.columnWidth)
2531
2790
  }
2532
2791
  );
2792
+ this.tooltip = new Tooltip(
2793
+ () => ({ enabled: this.options.showTooltip, render: this.options.renderTooltip }),
2794
+ (taskId) => this.tasks.find((t) => t.id === taskId)
2795
+ );
2796
+ this.tooltip.attach(this.renderer.svg);
2797
+ if (typeof ResizeObserver !== "undefined") {
2798
+ this.resizeObserver = new ResizeObserver(() => {
2799
+ if (this.options.autoFitToViewport) this.scheduleRender();
2800
+ });
2801
+ this.resizeObserver.observe(this.container);
2802
+ }
2533
2803
  }
2534
2804
  handleMove(taskId, dxMs) {
2535
2805
  if (dxMs === 0) return;
@@ -2550,7 +2820,7 @@ var _GanttChart = class _GanttChart {
2550
2820
  const newStart = edge === "left" ? new Date(task.start.getTime() + dxMs) : task.start;
2551
2821
  const newEnd = edge === "right" ? new Date(task.end.getTime() + dxMs) : task.end;
2552
2822
  if (newEnd.getTime() <= newStart.getTime()) return;
2553
- let newSegments = task.segments?.map((s) => ({ ...s }));
2823
+ const newSegments = task.segments?.map((s) => ({ ...s }));
2554
2824
  if (newSegments && newSegments.length > 0) {
2555
2825
  if (edge === "left") newSegments[0] = { ...newSegments[0], start: newStart };
2556
2826
  else newSegments[newSegments.length - 1] = { ...newSegments[newSegments.length - 1], end: newEnd };
@@ -2934,6 +3204,24 @@ var _GanttChart = class _GanttChart {
2934
3204
  this.options.onColumnReorder?.(order);
2935
3205
  this.scheduleRender();
2936
3206
  }
3207
+ /** Cycles a sortable column's header through asc -> desc -> none; clicking a different column starts it fresh at asc. */
3208
+ handleSortClick(columnId) {
3209
+ if (this.sort?.columnId === columnId) {
3210
+ this.setSort(columnId, this.sort.direction === "asc" ? "desc" : null);
3211
+ } else {
3212
+ this.setSort(columnId, "asc");
3213
+ }
3214
+ }
3215
+ /** Sorts siblings at every tree level by a column's value (see GanttColumn.sortable); `direction: null`/omitted `columnId` clears it. */
3216
+ setSort(columnId, direction = "asc") {
3217
+ this.sort = columnId && direction ? { columnId, direction } : null;
3218
+ this.emitter.emit("sort-change", { sort: this.sort });
3219
+ this.options.onSortChange?.(this.sort);
3220
+ this.scheduleRender();
3221
+ }
3222
+ getSort() {
3223
+ return this.sort;
3224
+ }
2937
3225
  handleRowReorder(draggedId, targetId, position) {
2938
3226
  const draggedIndex = this.tasks.findIndex((t) => t.id === draggedId);
2939
3227
  const targetIndex = this.tasks.findIndex((t) => t.id === targetId);
@@ -3000,13 +3288,18 @@ var _GanttChart = class _GanttChart {
3000
3288
  this.scheduleRender();
3001
3289
  }
3002
3290
  setOptions(partial) {
3003
- if (partial.colorScheme !== void 0) this.colorScheme = partial.colorScheme;
3004
- if (partial.theme !== void 0) this.explicitTheme = partial.theme;
3005
- if (partial.theme !== void 0 || partial.colorScheme !== void 0) {
3291
+ const hasTheme = "theme" in partial;
3292
+ const hasColorScheme = "colorScheme" in partial;
3293
+ if (hasColorScheme) this.colorScheme = partial.colorScheme ?? "auto";
3294
+ if (hasTheme) this.explicitTheme = partial.theme;
3295
+ if (hasTheme || hasColorScheme) {
3006
3296
  this.theme = mergeTheme(this.explicitTheme, this.colorScheme);
3007
3297
  }
3008
- if (partial.columns) this.columns = partial.columns;
3298
+ if ("columns" in partial) this.columns = partial.columns ?? this.columns;
3009
3299
  if (partial.columnWidth !== void 0) this.columnWidth = partial.columnWidth;
3300
+ if (partial.gridPanelWidth !== void 0) {
3301
+ this.container.style.setProperty("--gantt-grid-panel-width", `${partial.gridPanelWidth}px`);
3302
+ }
3010
3303
  Object.assign(this.options, partial);
3011
3304
  this.scheduleRender();
3012
3305
  }
@@ -3109,7 +3402,8 @@ var _GanttChart = class _GanttChart {
3109
3402
  markers: this.options.markers,
3110
3403
  autoRollupProgress: this.options.autoRollupProgress,
3111
3404
  selectedTaskIds: this.options.selectable ? this.selectedTaskIds : void 0,
3112
- pagination
3405
+ pagination,
3406
+ sort: this.sort
3113
3407
  });
3114
3408
  }
3115
3409
  scheduleRender(immediate = false) {
@@ -3124,6 +3418,7 @@ var _GanttChart = class _GanttChart {
3124
3418
  });
3125
3419
  }
3126
3420
  doRender() {
3421
+ if (this.options.autoFitToViewport) this.applyAutoFit();
3127
3422
  this.renderModel = this.computeModel();
3128
3423
  this.renderer?.render(this.renderModel, this.tasks, this.columns, this.explicitTheme);
3129
3424
  if (this.renderer && this.colorScheme !== "auto") {
@@ -3132,6 +3427,29 @@ var _GanttChart = class _GanttChart {
3132
3427
  this.renderer?.root.removeAttribute("data-gantt-theme");
3133
3428
  }
3134
3429
  }
3430
+ /**
3431
+ * Stretch-only fit: bump columnWidth up so the timeline fills the container's available
3432
+ * width, but never shrink it below what the current zoom level already implies - a project
3433
+ * wider than the container should still scroll normally, not get crammed to fit.
3434
+ */
3435
+ applyAutoFit() {
3436
+ const availableWidth = this.container.clientWidth - this.options.gridPanelWidth;
3437
+ if (!Number.isFinite(availableWidth) || availableWidth <= 0) return;
3438
+ let min = null;
3439
+ let max = null;
3440
+ for (const t of this.tasks) {
3441
+ if (!isValidDate(t.start) || !isValidDate(t.end)) continue;
3442
+ min = min === null ? t.start.getTime() : Math.min(min, t.start.getTime());
3443
+ max = max === null ? t.end.getTime() : Math.max(max, t.end.getTime());
3444
+ }
3445
+ if (min === null || max === null || max <= min) return;
3446
+ const rangeMs = max - min;
3447
+ const unitMs = approxUnitMs(this.options.viewMode);
3448
+ const neededColumnWidth = availableWidth * unitMs / rangeMs;
3449
+ if (neededColumnWidth > this.columnWidth) {
3450
+ this.columnWidth = Math.min(neededColumnWidth, _GanttChart.MAX_COLUMN_WIDTH);
3451
+ }
3452
+ }
3135
3453
  undo() {
3136
3454
  this.history?.undo();
3137
3455
  }
@@ -3226,6 +3544,8 @@ var _GanttChart = class _GanttChart {
3226
3544
  this.darkMediaQuery?.removeEventListener?.("change", this.handleSchemeChange);
3227
3545
  this.emitter.removeAllListeners();
3228
3546
  this.interactions?.destroy();
3547
+ this.tooltip?.destroy();
3548
+ this.resizeObserver?.disconnect();
3229
3549
  this.renderer?.destroy();
3230
3550
  }
3231
3551
  };