@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.js CHANGED
@@ -140,7 +140,6 @@ function computeCriticalPath(tasks, dependencies) {
140
140
  }
141
141
  for (const e of edges) {
142
142
  outgoing.get(e.fromId)?.push(e);
143
- incoming.get(e.fromId) === void 0 ? void 0 : void 0;
144
143
  incoming.get(e.toId)?.push(e);
145
144
  }
146
145
  const inDegree = /* @__PURE__ */ new Map();
@@ -225,11 +224,9 @@ function computeCriticalPath(tasks, dependencies) {
225
224
  criticalLinks.add(linkKey(e.fromId, e.toId));
226
225
  }
227
226
  }
228
- const taskMap = new Map(tasks.map((t) => [t.id, t]));
229
227
  for (const task of tasks) {
230
228
  if (!task.isGroup) continue;
231
229
  let hasCriticalDescendant = false;
232
- let cur = task;
233
230
  const descendantStack = [task.id];
234
231
  const visited = /* @__PURE__ */ new Set();
235
232
  while (descendantStack.length > 0) {
@@ -244,8 +241,6 @@ function computeCriticalPath(tasks, dependencies) {
244
241
  }
245
242
  }
246
243
  if (hasCriticalDescendant) criticalTasks.add(task.id);
247
- void cur;
248
- void taskMap;
249
244
  }
250
245
  return { criticalTasks, criticalLinks };
251
246
  }
@@ -619,6 +614,29 @@ function computeProgressRollup(node) {
619
614
  }
620
615
  node.computedProgress = totalWeight > 0 ? weightedSum / totalWeight : 0;
621
616
  }
617
+ function sortValue(node, col) {
618
+ if (!col) return null;
619
+ if (col.accessor) return col.accessor(node.task) ?? null;
620
+ return col.id === "name" ? node.task.name : null;
621
+ }
622
+ function compareValues(a, b) {
623
+ if (a === null && b === null) return 0;
624
+ if (a === null) return 1;
625
+ if (b === null) return -1;
626
+ if (typeof a === "number" && typeof b === "number") return a - b;
627
+ return String(a).localeCompare(String(b), void 0, { numeric: true, sensitivity: "base" });
628
+ }
629
+ function sortTree(roots, columns, sort) {
630
+ if (!sort) return;
631
+ const col = columns.find((c) => c.id === sort.columnId);
632
+ const dir = sort.direction === "desc" ? -1 : 1;
633
+ const compare = (a, b) => dir * compareValues(sortValue(a, col), sortValue(b, col));
634
+ const sortLevel = (nodes) => {
635
+ nodes.sort(compare);
636
+ for (const node of nodes) sortLevel(node.children);
637
+ };
638
+ sortLevel(roots);
639
+ }
622
640
  function flatten(roots) {
623
641
  const out = [];
624
642
  const stack = [];
@@ -640,6 +658,9 @@ function flatten(roots) {
640
658
  }
641
659
  return out;
642
660
  }
661
+ function estimateTextWidth(text, fontSize) {
662
+ return text.length * fontSize * 0.62;
663
+ }
643
664
  function anchorPoint(bar, side) {
644
665
  const y = bar.y + bar.height / 2;
645
666
  return side === "start" ? { x: bar.x, y } : { x: bar.x + bar.width, y };
@@ -702,6 +723,14 @@ function linkPath(from, to) {
702
723
  const c2y = to.y;
703
724
  return `M ${from.x} ${from.y} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${to.x} ${to.y}`;
704
725
  }
726
+ var MIN_VISIBLE_UNITS = {
727
+ hour: 8,
728
+ day: 7,
729
+ week: 6,
730
+ month: 6,
731
+ quarter: 4,
732
+ year: 5
733
+ };
705
734
  function computeLayout(input) {
706
735
  const {
707
736
  tasks,
@@ -717,7 +746,8 @@ function computeLayout(input) {
717
746
  markers = [],
718
747
  autoRollupProgress = false,
719
748
  selectedTaskIds,
720
- pagination
749
+ pagination,
750
+ sort = null
721
751
  } = input;
722
752
  validateInputs(tasks, dependencies);
723
753
  const { roots, nodeById } = buildTree(tasks);
@@ -725,6 +755,7 @@ function computeLayout(input) {
725
755
  if (autoRollupProgress) {
726
756
  for (const root of roots) computeProgressRollup(root);
727
757
  }
758
+ sortTree(roots, columns, sort);
728
759
  let visibleRoots = roots;
729
760
  if (pagination && pagination.pageSize > 0) {
730
761
  const start = Math.max(0, (pagination.page - 1) * pagination.pageSize);
@@ -744,8 +775,15 @@ function computeLayout(input) {
744
775
  const now = Date.now();
745
776
  const rawRangeStart = rangeMin === null ? new Date(now) : new Date(rangeMin);
746
777
  const rawRangeEnd = rangeMax === null ? new Date(now + 1) : new Date(rangeMax);
747
- const paddedStart = addUnit(rawRangeStart, viewMode, -1);
748
- const paddedEnd = addUnit(rawRangeEnd, viewMode, 1);
778
+ let paddedStart = addUnit(rawRangeStart, viewMode, -1);
779
+ let paddedEnd = addUnit(rawRangeEnd, viewMode, 1);
780
+ const minUnits = MIN_VISIBLE_UNITS[viewMode];
781
+ const currentUnits = (paddedEnd.getTime() - paddedStart.getTime()) / approxUnitMs(viewMode);
782
+ if (currentUnits < minUnits) {
783
+ const deficit = minUnits - currentUnits;
784
+ paddedStart = addUnit(paddedStart, viewMode, -Math.ceil(deficit / 2));
785
+ paddedEnd = addUnit(paddedEnd, viewMode, Math.floor(deficit / 2));
786
+ }
749
787
  const rows = [];
750
788
  const bars = [];
751
789
  const barByTaskId = /* @__PURE__ */ new Map();
@@ -765,8 +803,9 @@ function computeLayout(input) {
765
803
  const end = row.node.computedEnd;
766
804
  if (!start || !end) return;
767
805
  const x = dateToX(start, paddedStart, viewMode, columnWidth);
768
- const width2 = Math.max(0, dateToX(end, paddedStart, viewMode, columnWidth) - x);
769
- if (!Number.isFinite(x) || !Number.isFinite(width2)) return;
806
+ const rawWidth = Math.max(0, dateToX(end, paddedStart, viewMode, columnWidth) - x);
807
+ if (!Number.isFinite(x) || !Number.isFinite(rawWidth)) return;
808
+ const width2 = rawWidth > 0 ? Math.max(rawWidth, 3) : rawWidth;
770
809
  const progress = task.progress !== void 0 ? Math.min(100, Math.max(0, task.progress)) : autoRollupProgress ? row.node.computedProgress : 0;
771
810
  const barHeight = theme.barHeight;
772
811
  const barY = y + (theme.rowHeight - barHeight) / 2;
@@ -874,7 +913,13 @@ function computeLayout(input) {
874
913
  })).filter((m) => Number.isFinite(m.x));
875
914
  let width = 0;
876
915
  for (const tick of ticks) width = Math.max(width, tick.x);
877
- for (const bar of bars) width = Math.max(width, bar.x + bar.width);
916
+ for (const bar of bars) {
917
+ width = Math.max(width, bar.x + bar.width);
918
+ if (bar.label) {
919
+ const labelOffset = bar.isMilestone ? theme.barHeight * 0.4 + 8 : bar.width + 6;
920
+ width = Math.max(width, bar.x + labelOffset + estimateTextWidth(bar.label, theme.fontSize));
921
+ }
922
+ }
878
923
  width += columnWidth;
879
924
  const height = rows.length * theme.rowHeight;
880
925
  return {
@@ -889,7 +934,8 @@ function computeLayout(input) {
889
934
  columns,
890
935
  theme,
891
936
  markers: renderMarkers,
892
- rangeStart: paddedStart
937
+ rangeStart: paddedStart,
938
+ sort
893
939
  };
894
940
  }
895
941
 
@@ -1086,6 +1132,7 @@ var GanttRenderer = class {
1086
1132
  this.lastModel = null;
1087
1133
  this.lastTasks = [];
1088
1134
  this.lastColumns = [];
1135
+ this.lastExplicitThemeKeys = /* @__PURE__ */ new Set();
1089
1136
  this.onGridDoubleClick = (evt) => {
1090
1137
  if (!this.options.onRenameCommit) return;
1091
1138
  const target = evt.target;
@@ -1123,8 +1170,13 @@ var GanttRenderer = class {
1123
1170
  this.root = el("div", "gantt-root");
1124
1171
  this.gridPanel = el("div", "gantt-grid-panel");
1125
1172
  this.timelineScroll = el("div", "gantt-timeline-scroll");
1173
+ this.headerContainer = el("div", "gantt-timeline-header");
1174
+ this.headerSvg = svgEl("svg");
1175
+ this.headerSvg.classList.add("gantt-header-svg");
1176
+ this.headerContainer.appendChild(this.headerSvg);
1126
1177
  this.svg = svgEl("svg");
1127
1178
  this.svg.classList.add("gantt-svg");
1179
+ this.timelineScroll.appendChild(this.headerContainer);
1128
1180
  this.timelineScroll.appendChild(this.svg);
1129
1181
  this.root.appendChild(this.gridPanel);
1130
1182
  this.root.appendChild(this.timelineScroll);
@@ -1140,9 +1192,14 @@ var GanttRenderer = class {
1140
1192
  this.lastTasks = tasks;
1141
1193
  this.lastColumns = columns;
1142
1194
  const cssVars = explicitThemeToCssVars(explicitTheme);
1143
- for (const key of Object.keys(cssVars)) {
1195
+ const newKeys = new Set(Object.keys(cssVars));
1196
+ for (const key of this.lastExplicitThemeKeys) {
1197
+ if (!newKeys.has(key)) this.root.style.removeProperty(key);
1198
+ }
1199
+ for (const key of newKeys) {
1144
1200
  this.root.style.setProperty(key, cssVars[key]);
1145
1201
  }
1202
+ this.lastExplicitThemeKeys = newKeys;
1146
1203
  this.renderGridPanel(model, tasks, columns);
1147
1204
  this.renderTimeline(model, tasks);
1148
1205
  }
@@ -1170,7 +1227,7 @@ var GanttRenderer = class {
1170
1227
  const header = el("div", "gantt-grid-header-row");
1171
1228
  header.style.height = `${model.headerHeight}px`;
1172
1229
  for (const col of columns) {
1173
- header.appendChild(this.renderHeaderCell(col, columns));
1230
+ header.appendChild(this.renderHeaderCell(col, columns, model.sort));
1174
1231
  }
1175
1232
  this.gridPanel.appendChild(header);
1176
1233
  const body = el("div", "gantt-grid-body");
@@ -1183,11 +1240,23 @@ var GanttRenderer = class {
1183
1240
  });
1184
1241
  this.gridPanel.appendChild(body);
1185
1242
  }
1186
- renderHeaderCell(col, columns) {
1243
+ renderHeaderCell(col, columns, sort) {
1187
1244
  const cell = el("div", "gantt-grid-header-cell");
1188
1245
  cell.style.position = "relative";
1189
1246
  if (col.width) cell.style.width = `${col.width}px`;
1190
1247
  cell.textContent = col.title;
1248
+ if (col.sortable) {
1249
+ cell.classList.add("gantt-grid-header-cell-sortable");
1250
+ const active = sort?.columnId === col.id;
1251
+ const indicator = el("span", "gantt-sort-indicator");
1252
+ indicator.textContent = active ? sort.direction === "asc" ? " \u25B2" : " \u25BC" : "";
1253
+ cell.appendChild(indicator);
1254
+ cell.addEventListener("click", (evt) => {
1255
+ const target = evt.target;
1256
+ if (target.closest(".gantt-col-resize-handle")) return;
1257
+ this.options.onSortClick?.(col.id);
1258
+ });
1259
+ }
1191
1260
  if (this.options.onColumnReorder) {
1192
1261
  cell.draggable = true;
1193
1262
  cell.style.cursor = "grab";
@@ -1314,8 +1383,8 @@ var GanttRenderer = class {
1314
1383
  renderTimeline(model, tasks) {
1315
1384
  this.svg.replaceChildren();
1316
1385
  this.svg.setAttribute("width", String(model.width));
1317
- this.svg.setAttribute("height", String(model.headerHeight + model.height));
1318
- this.svg.setAttribute("viewBox", `0 0 ${model.width} ${model.headerHeight + model.height}`);
1386
+ this.svg.setAttribute("height", String(model.height));
1387
+ this.svg.setAttribute("viewBox", `0 0 ${model.width} ${model.height}`);
1319
1388
  const defs = svgEl("defs");
1320
1389
  const marker = svgEl("marker");
1321
1390
  marker.setAttribute("id", this.markerDefsId);
@@ -1332,7 +1401,6 @@ var GanttRenderer = class {
1332
1401
  this.svg.appendChild(defs);
1333
1402
  const { startY, endY } = this.getViewportRowRange(model);
1334
1403
  const bodyGroup = svgEl("g");
1335
- bodyGroup.setAttribute("transform", `translate(0, ${model.headerHeight})`);
1336
1404
  this.renderGridLines(bodyGroup, model);
1337
1405
  this.renderRowBackgrounds(bodyGroup, model, startY, endY);
1338
1406
  this.renderBars(bodyGroup, model, tasks, startY, endY);
@@ -1373,7 +1441,9 @@ var GanttRenderer = class {
1373
1441
  }
1374
1442
  }
1375
1443
  renderMarkers(group, model) {
1376
- for (const marker of model.markers) {
1444
+ const sorted = [...model.markers].sort((a, b) => a.x - b.x);
1445
+ let defs = null;
1446
+ sorted.forEach((marker, i) => {
1377
1447
  const line = svgEl("line");
1378
1448
  line.setAttribute("x1", String(marker.x));
1379
1449
  line.setAttribute("x2", String(marker.x));
@@ -1385,21 +1455,41 @@ var GanttRenderer = class {
1385
1455
  line.dataset.ganttMarker = marker.label;
1386
1456
  group.appendChild(line);
1387
1457
  if (marker.label) {
1458
+ const nextX = sorted[i + 1]?.x ?? model.width;
1459
+ const cellWidth = Math.max(0, nextX - marker.x);
1460
+ if (!defs) {
1461
+ defs = svgEl("defs");
1462
+ group.insertBefore(defs, group.firstChild);
1463
+ }
1464
+ const clipId = `${this.markerDefsId}-marker-${i}`;
1465
+ const clipPath = svgEl("clipPath");
1466
+ clipPath.setAttribute("id", clipId);
1467
+ const clipRect = svgEl("rect");
1468
+ clipRect.setAttribute("x", String(marker.x));
1469
+ clipRect.setAttribute("y", "0");
1470
+ clipRect.setAttribute("width", String(cellWidth));
1471
+ clipRect.setAttribute("height", String(model.height));
1472
+ clipPath.appendChild(clipRect);
1473
+ defs.appendChild(clipPath);
1388
1474
  const label = svgEl("text");
1389
1475
  label.setAttribute("x", String(marker.x + 4));
1390
1476
  label.setAttribute("y", "12");
1477
+ label.setAttribute("clip-path", `url(#${clipId})`);
1391
1478
  label.setAttribute("fill", marker.color);
1392
1479
  label.setAttribute("font-size", String(model.theme.fontSize));
1393
1480
  label.setAttribute("font-family", model.theme.fontFamily);
1394
1481
  label.textContent = marker.label;
1395
1482
  group.appendChild(label);
1396
1483
  }
1397
- }
1484
+ });
1398
1485
  }
1399
1486
  renderHeader(model) {
1400
- let headerGroup = this.svg.querySelector(".gantt-header-group");
1401
- if (headerGroup) headerGroup.remove();
1402
- headerGroup = svgEl("g");
1487
+ this.headerSvg.replaceChildren();
1488
+ this.headerSvg.setAttribute("width", String(model.width));
1489
+ this.headerSvg.setAttribute("height", String(model.headerHeight));
1490
+ this.headerSvg.setAttribute("viewBox", `0 0 ${model.width} ${model.headerHeight}`);
1491
+ this.headerContainer.style.height = `${model.headerHeight}px`;
1492
+ const headerGroup = svgEl("g");
1403
1493
  headerGroup.setAttribute("class", "gantt-header-group");
1404
1494
  const bg = svgEl("rect");
1405
1495
  bg.setAttribute("x", "0");
@@ -1408,17 +1498,33 @@ var GanttRenderer = class {
1408
1498
  bg.setAttribute("height", String(model.headerHeight));
1409
1499
  bg.style.fill = themedFill("backgroundColor", model.theme.backgroundColor);
1410
1500
  headerGroup.appendChild(bg);
1411
- for (const tick of model.ticks) {
1501
+ const defs = svgEl("defs");
1502
+ const ticks = model.ticks;
1503
+ for (let i = 0; i < ticks.length; i++) {
1504
+ const tick = ticks[i];
1505
+ const cellWidth = (ticks[i + 1]?.x ?? model.width) - tick.x;
1506
+ const clipId = `${this.markerDefsId}-tick-${i}`;
1507
+ const clipPath = svgEl("clipPath");
1508
+ clipPath.setAttribute("id", clipId);
1509
+ const clipRect = svgEl("rect");
1510
+ clipRect.setAttribute("x", String(tick.x));
1511
+ clipRect.setAttribute("y", "0");
1512
+ clipRect.setAttribute("width", String(Math.max(0, cellWidth)));
1513
+ clipRect.setAttribute("height", String(model.headerHeight));
1514
+ clipPath.appendChild(clipRect);
1515
+ defs.appendChild(clipPath);
1412
1516
  const text = svgEl("text");
1413
1517
  text.setAttribute("x", String(tick.x + 4));
1414
1518
  text.setAttribute("y", String(model.headerHeight - 8));
1519
+ text.setAttribute("clip-path", `url(#${clipId})`);
1415
1520
  text.style.fill = themedFill("textColor", model.theme.textColor);
1416
1521
  text.setAttribute("font-size", String(model.theme.fontSize));
1417
1522
  text.setAttribute("font-family", model.theme.fontFamily);
1418
1523
  text.textContent = tick.label;
1419
1524
  headerGroup.appendChild(text);
1420
1525
  }
1421
- this.svg.appendChild(headerGroup);
1526
+ headerGroup.insertBefore(defs, headerGroup.firstChild);
1527
+ this.headerSvg.appendChild(headerGroup);
1422
1528
  }
1423
1529
  /**
1424
1530
  * Invisible full-width hit-test rects, one per row, behind the bars. Lets
@@ -1453,11 +1559,6 @@ var GanttRenderer = class {
1453
1559
  g.setAttribute("class", "gantt-bar");
1454
1560
  g.dataset.ganttBar = bar.taskId;
1455
1561
  g.setAttribute("transform", `translate(${bar.x}, ${bar.y})`);
1456
- if (task?.notes) {
1457
- const title = svgEl("title");
1458
- title.textContent = task.notes;
1459
- g.appendChild(title);
1460
- }
1461
1562
  if (this.options.keyboardAccessible) {
1462
1563
  g.setAttribute("tabindex", "0");
1463
1564
  g.setAttribute("role", "button");
@@ -1702,8 +1803,30 @@ var GanttRenderer = class {
1702
1803
  group.appendChild(path);
1703
1804
  }
1704
1805
  }
1806
+ /**
1807
+ * The header and body are separate DOM elements at runtime (so plain CSS `position: sticky`
1808
+ * can pin the header - see the constructor's doc comment), but exports need one self-contained
1809
+ * SVG with both, laid out exactly as the original single-SVG version was.
1810
+ */
1705
1811
  toSVGString() {
1706
- return new XMLSerializer().serializeToString(this.svg);
1812
+ const headerHeight = this.lastModel?.headerHeight ?? 0;
1813
+ const width = this.lastModel?.width ?? 0;
1814
+ const bodyHeight = this.lastModel?.height ?? 0;
1815
+ const combined = svgEl("svg");
1816
+ combined.setAttribute("xmlns", SVG_NS);
1817
+ combined.setAttribute("width", String(width));
1818
+ combined.setAttribute("height", String(headerHeight + bodyHeight));
1819
+ combined.setAttribute("viewBox", `0 0 ${width} ${headerHeight + bodyHeight}`);
1820
+ for (const child of Array.from(this.headerSvg.children)) {
1821
+ combined.appendChild(child.cloneNode(true));
1822
+ }
1823
+ const bodyGroup = svgEl("g");
1824
+ bodyGroup.setAttribute("transform", `translate(0, ${headerHeight})`);
1825
+ for (const child of Array.from(this.svg.children)) {
1826
+ bodyGroup.appendChild(child.cloneNode(true));
1827
+ }
1828
+ combined.appendChild(bodyGroup);
1829
+ return new XMLSerializer().serializeToString(combined);
1707
1830
  }
1708
1831
  destroy() {
1709
1832
  if (this.scrollListener) {
@@ -2082,13 +2205,17 @@ var InteractionController = class {
2082
2205
  }
2083
2206
  };
2084
2207
  this.onSvgDoubleClick = (evt) => {
2085
- if (!this.callbacks.onLinkDblClick) return;
2086
2208
  const target = evt.target;
2087
2209
  const linkPath2 = target.closest("[data-gantt-link]");
2088
2210
  const key = linkPath2?.dataset.ganttLink;
2089
- if (!key) return;
2090
- const [fromId, toId] = key.split("->");
2091
- if (fromId && toId) this.callbacks.onLinkDblClick(fromId, toId);
2211
+ if (key && this.callbacks.onLinkDblClick) {
2212
+ const [fromId, toId] = key.split("->");
2213
+ if (fromId && toId) this.callbacks.onLinkDblClick(fromId, toId);
2214
+ return;
2215
+ }
2216
+ const barGroup = target.closest("[data-gantt-bar]");
2217
+ const taskId = barGroup?.dataset.ganttBar;
2218
+ if (taskId && this.callbacks.onBarDblClick) this.callbacks.onBarDblClick(taskId);
2092
2219
  };
2093
2220
  this.onPointerDown = (evt) => {
2094
2221
  const target = evt.target;
@@ -2351,6 +2478,119 @@ var InteractionController = class {
2351
2478
  }
2352
2479
  };
2353
2480
 
2481
+ // src/tooltip.ts
2482
+ function formatDate(d) {
2483
+ return d.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" });
2484
+ }
2485
+ function defaultTooltipContent(task) {
2486
+ const el2 = document.createElement("div");
2487
+ const title = document.createElement("div");
2488
+ title.className = "gantt-tooltip-title";
2489
+ title.textContent = task.name;
2490
+ el2.appendChild(title);
2491
+ const dates = document.createElement("div");
2492
+ dates.className = "gantt-tooltip-row";
2493
+ dates.textContent = task.isMilestone ? formatDate(task.start) : `${formatDate(task.start)} \u2192 ${formatDate(task.end)}`;
2494
+ el2.appendChild(dates);
2495
+ if (task.progress !== void 0) {
2496
+ const progress = document.createElement("div");
2497
+ progress.className = "gantt-tooltip-row";
2498
+ progress.textContent = `${Math.round(task.progress)}% complete`;
2499
+ el2.appendChild(progress);
2500
+ }
2501
+ if (task.assignees && task.assignees.length > 0) {
2502
+ const assignees = document.createElement("div");
2503
+ assignees.className = "gantt-tooltip-row";
2504
+ assignees.textContent = task.assignees.map((a) => a.name).join(", ");
2505
+ el2.appendChild(assignees);
2506
+ }
2507
+ if (task.notes) {
2508
+ const notes = document.createElement("div");
2509
+ notes.className = "gantt-tooltip-notes";
2510
+ notes.textContent = task.notes;
2511
+ el2.appendChild(notes);
2512
+ }
2513
+ return el2;
2514
+ }
2515
+ var Tooltip = class {
2516
+ constructor(getOptions, getTask) {
2517
+ this.svg = null;
2518
+ this.hoveredTaskId = null;
2519
+ this.onPointerOver = (evt) => {
2520
+ if (!this.getOptions().enabled) return;
2521
+ const target = evt.target;
2522
+ const barGroup = target.closest("[data-gantt-bar]");
2523
+ const taskId = barGroup?.dataset.ganttBar;
2524
+ if (!taskId || taskId === this.hoveredTaskId) return;
2525
+ const task = this.getTask(taskId);
2526
+ if (!task) return;
2527
+ const { render } = this.getOptions();
2528
+ const content = render ? render(task) : defaultTooltipContent(task);
2529
+ if (content === null || content === void 0) {
2530
+ this.hide();
2531
+ return;
2532
+ }
2533
+ this.el.replaceChildren();
2534
+ if (content instanceof HTMLElement) {
2535
+ this.el.appendChild(content);
2536
+ } else {
2537
+ this.el.textContent = content;
2538
+ }
2539
+ this.hoveredTaskId = taskId;
2540
+ this.el.style.display = "block";
2541
+ this.position(evt.clientX, evt.clientY);
2542
+ };
2543
+ this.onPointerMove = (evt) => {
2544
+ if (this.hoveredTaskId === null) return;
2545
+ this.position(evt.clientX, evt.clientY);
2546
+ };
2547
+ this.onPointerOut = (evt) => {
2548
+ const related = evt.relatedTarget;
2549
+ const target = evt.target;
2550
+ const leftBarGroup = target.closest("[data-gantt-bar]");
2551
+ if (related && leftBarGroup?.contains(related)) return;
2552
+ this.hide();
2553
+ };
2554
+ this.getOptions = getOptions;
2555
+ this.getTask = getTask;
2556
+ this.el = document.createElement("div");
2557
+ this.el.className = "gantt-tooltip";
2558
+ this.el.setAttribute("role", "tooltip");
2559
+ this.el.style.display = "none";
2560
+ document.body.appendChild(this.el);
2561
+ }
2562
+ attach(svg) {
2563
+ this.svg = svg;
2564
+ svg.addEventListener("pointerover", this.onPointerOver);
2565
+ svg.addEventListener("pointermove", this.onPointerMove);
2566
+ svg.addEventListener("pointerout", this.onPointerOut);
2567
+ }
2568
+ position(clientX, clientY) {
2569
+ const OFFSET = 12;
2570
+ const rect = this.el.getBoundingClientRect();
2571
+ const viewportW = window.innerWidth;
2572
+ const viewportH = window.innerHeight;
2573
+ let left = clientX + OFFSET;
2574
+ let top = clientY + OFFSET;
2575
+ if (left + rect.width > viewportW) left = clientX - OFFSET - rect.width;
2576
+ if (top + rect.height > viewportH) top = clientY - OFFSET - rect.height;
2577
+ this.el.style.left = `${Math.max(0, left)}px`;
2578
+ this.el.style.top = `${Math.max(0, top)}px`;
2579
+ }
2580
+ hide() {
2581
+ this.hoveredTaskId = null;
2582
+ this.el.style.display = "none";
2583
+ }
2584
+ destroy() {
2585
+ if (this.svg) {
2586
+ this.svg.removeEventListener("pointerover", this.onPointerOver);
2587
+ this.svg.removeEventListener("pointermove", this.onPointerMove);
2588
+ this.svg.removeEventListener("pointerout", this.onPointerOut);
2589
+ }
2590
+ this.el.remove();
2591
+ }
2592
+ };
2593
+
2354
2594
  // src/index.ts
2355
2595
  var HAS_DOM = typeof document !== "undefined" && typeof window !== "undefined";
2356
2596
  var DEFAULT_COLUMNS = [{ id: "name", title: "Name" }];
@@ -2370,6 +2610,9 @@ var _GanttChart = class _GanttChart {
2370
2610
  this.history = null;
2371
2611
  this.renderer = null;
2372
2612
  this.interactions = null;
2613
+ this.tooltip = null;
2614
+ this.resizeObserver = null;
2615
+ this.sort = null;
2373
2616
  this.renderModel = null;
2374
2617
  this.rafHandle = null;
2375
2618
  this.currentPage = 1;
@@ -2392,6 +2635,8 @@ var _GanttChart = class _GanttChart {
2392
2635
  showCriticalPath: options.showCriticalPath ?? false,
2393
2636
  showBaseline: options.showBaseline ?? false,
2394
2637
  showDeadlines: options.showDeadlines ?? true,
2638
+ showTooltip: options.showTooltip ?? true,
2639
+ autoFitToViewport: options.autoFitToViewport ?? false,
2395
2640
  showAssigneeAvatars: options.showAssigneeAvatars ?? false,
2396
2641
  enableHistory: options.enableHistory ?? false,
2397
2642
  keyboardAccessible: options.keyboardAccessible ?? false,
@@ -2412,13 +2657,16 @@ var _GanttChart = class _GanttChart {
2412
2657
  onDependencyRemove: options.onDependencyRemove,
2413
2658
  onDependencyDblClick: options.onDependencyDblClick,
2414
2659
  onTaskClick: options.onTaskClick,
2660
+ onTaskDblClick: options.onTaskDblClick,
2415
2661
  onGroupToggle: options.onGroupToggle,
2416
2662
  onContextMenu: options.onContextMenu,
2417
2663
  onTaskCreate: options.onTaskCreate,
2418
2664
  onColumnResize: options.onColumnResize,
2419
2665
  onColumnReorder: options.onColumnReorder,
2420
2666
  onTaskReorder: options.onTaskReorder,
2421
- onSelectionChange: options.onSelectionChange
2667
+ onSelectionChange: options.onSelectionChange,
2668
+ onSortChange: options.onSortChange,
2669
+ renderTooltip: options.renderTooltip
2422
2670
  };
2423
2671
  if (this.options.enableHistory) {
2424
2672
  this.history = new HistoryManager((state) => this.emitter.emit("history-change", state));
@@ -2441,7 +2689,8 @@ var _GanttChart = class _GanttChart {
2441
2689
  onRenameCommit: (taskId, name) => this.updateTask(taskId, { name }),
2442
2690
  onColumnResize: this.options.onColumnResize ? (columnId, width) => this.handleColumnResize(columnId, width) : void 0,
2443
2691
  onColumnReorder: this.options.onColumnReorder ? (order) => this.handleColumnReorder(order) : void 0,
2444
- onRowReorder: this.options.onTaskReorder ? (draggedId, targetId, position) => this.handleRowReorder(draggedId, targetId, position) : void 0
2692
+ onRowReorder: this.options.onTaskReorder ? (draggedId, targetId, position) => this.handleRowReorder(draggedId, targetId, position) : void 0,
2693
+ onSortClick: (columnId) => this.handleSortClick(columnId)
2445
2694
  });
2446
2695
  this.interactions = new InteractionController(
2447
2696
  this.renderer.svg,
@@ -2462,7 +2711,17 @@ var _GanttChart = class _GanttChart {
2462
2711
  onCreateTaskDrag: this.options.onTaskCreate ? (rowTaskId, startXPx, endXPx) => this.handleCreateTaskDrag(rowTaskId, startXPx, endXPx) : void 0,
2463
2712
  onLinkDblClick: this.options.onDependencyDblClick ? (fromId, toId) => {
2464
2713
  const dep = this.dependencies.find((d) => d.fromId === fromId && d.toId === toId);
2465
- if (dep) this.options.onDependencyDblClick?.(dep);
2714
+ if (dep) {
2715
+ this.emitter.emit("dependency-dblclick", dep);
2716
+ this.options.onDependencyDblClick?.(dep);
2717
+ }
2718
+ } : void 0,
2719
+ onBarDblClick: this.options.onTaskDblClick ? (taskId) => {
2720
+ const task = this.tasks.find((t) => t.id === taskId);
2721
+ if (task) {
2722
+ this.emitter.emit("task-dblclick", { task });
2723
+ this.options.onTaskDblClick?.(task);
2724
+ }
2466
2725
  } : void 0,
2467
2726
  onContextMenu: this.options.onContextMenu ? (taskId, evt) => {
2468
2727
  const task = this.tasks.find((t) => t.id === taskId);
@@ -2481,6 +2740,17 @@ var _GanttChart = class _GanttChart {
2481
2740
  pxPerMs: () => pxPerMs(this.options.viewMode, this.columnWidth)
2482
2741
  }
2483
2742
  );
2743
+ this.tooltip = new Tooltip(
2744
+ () => ({ enabled: this.options.showTooltip, render: this.options.renderTooltip }),
2745
+ (taskId) => this.tasks.find((t) => t.id === taskId)
2746
+ );
2747
+ this.tooltip.attach(this.renderer.svg);
2748
+ if (typeof ResizeObserver !== "undefined") {
2749
+ this.resizeObserver = new ResizeObserver(() => {
2750
+ if (this.options.autoFitToViewport) this.scheduleRender();
2751
+ });
2752
+ this.resizeObserver.observe(this.container);
2753
+ }
2484
2754
  }
2485
2755
  handleMove(taskId, dxMs) {
2486
2756
  if (dxMs === 0) return;
@@ -2501,7 +2771,7 @@ var _GanttChart = class _GanttChart {
2501
2771
  const newStart = edge === "left" ? new Date(task.start.getTime() + dxMs) : task.start;
2502
2772
  const newEnd = edge === "right" ? new Date(task.end.getTime() + dxMs) : task.end;
2503
2773
  if (newEnd.getTime() <= newStart.getTime()) return;
2504
- let newSegments = task.segments?.map((s) => ({ ...s }));
2774
+ const newSegments = task.segments?.map((s) => ({ ...s }));
2505
2775
  if (newSegments && newSegments.length > 0) {
2506
2776
  if (edge === "left") newSegments[0] = { ...newSegments[0], start: newStart };
2507
2777
  else newSegments[newSegments.length - 1] = { ...newSegments[newSegments.length - 1], end: newEnd };
@@ -2885,6 +3155,24 @@ var _GanttChart = class _GanttChart {
2885
3155
  this.options.onColumnReorder?.(order);
2886
3156
  this.scheduleRender();
2887
3157
  }
3158
+ /** Cycles a sortable column's header through asc -> desc -> none; clicking a different column starts it fresh at asc. */
3159
+ handleSortClick(columnId) {
3160
+ if (this.sort?.columnId === columnId) {
3161
+ this.setSort(columnId, this.sort.direction === "asc" ? "desc" : null);
3162
+ } else {
3163
+ this.setSort(columnId, "asc");
3164
+ }
3165
+ }
3166
+ /** Sorts siblings at every tree level by a column's value (see GanttColumn.sortable); `direction: null`/omitted `columnId` clears it. */
3167
+ setSort(columnId, direction = "asc") {
3168
+ this.sort = columnId && direction ? { columnId, direction } : null;
3169
+ this.emitter.emit("sort-change", { sort: this.sort });
3170
+ this.options.onSortChange?.(this.sort);
3171
+ this.scheduleRender();
3172
+ }
3173
+ getSort() {
3174
+ return this.sort;
3175
+ }
2888
3176
  handleRowReorder(draggedId, targetId, position) {
2889
3177
  const draggedIndex = this.tasks.findIndex((t) => t.id === draggedId);
2890
3178
  const targetIndex = this.tasks.findIndex((t) => t.id === targetId);
@@ -2951,13 +3239,18 @@ var _GanttChart = class _GanttChart {
2951
3239
  this.scheduleRender();
2952
3240
  }
2953
3241
  setOptions(partial) {
2954
- if (partial.colorScheme !== void 0) this.colorScheme = partial.colorScheme;
2955
- if (partial.theme !== void 0) this.explicitTheme = partial.theme;
2956
- if (partial.theme !== void 0 || partial.colorScheme !== void 0) {
3242
+ const hasTheme = "theme" in partial;
3243
+ const hasColorScheme = "colorScheme" in partial;
3244
+ if (hasColorScheme) this.colorScheme = partial.colorScheme ?? "auto";
3245
+ if (hasTheme) this.explicitTheme = partial.theme;
3246
+ if (hasTheme || hasColorScheme) {
2957
3247
  this.theme = mergeTheme(this.explicitTheme, this.colorScheme);
2958
3248
  }
2959
- if (partial.columns) this.columns = partial.columns;
3249
+ if ("columns" in partial) this.columns = partial.columns ?? this.columns;
2960
3250
  if (partial.columnWidth !== void 0) this.columnWidth = partial.columnWidth;
3251
+ if (partial.gridPanelWidth !== void 0) {
3252
+ this.container.style.setProperty("--gantt-grid-panel-width", `${partial.gridPanelWidth}px`);
3253
+ }
2961
3254
  Object.assign(this.options, partial);
2962
3255
  this.scheduleRender();
2963
3256
  }
@@ -3060,7 +3353,8 @@ var _GanttChart = class _GanttChart {
3060
3353
  markers: this.options.markers,
3061
3354
  autoRollupProgress: this.options.autoRollupProgress,
3062
3355
  selectedTaskIds: this.options.selectable ? this.selectedTaskIds : void 0,
3063
- pagination
3356
+ pagination,
3357
+ sort: this.sort
3064
3358
  });
3065
3359
  }
3066
3360
  scheduleRender(immediate = false) {
@@ -3075,6 +3369,7 @@ var _GanttChart = class _GanttChart {
3075
3369
  });
3076
3370
  }
3077
3371
  doRender() {
3372
+ if (this.options.autoFitToViewport) this.applyAutoFit();
3078
3373
  this.renderModel = this.computeModel();
3079
3374
  this.renderer?.render(this.renderModel, this.tasks, this.columns, this.explicitTheme);
3080
3375
  if (this.renderer && this.colorScheme !== "auto") {
@@ -3083,6 +3378,29 @@ var _GanttChart = class _GanttChart {
3083
3378
  this.renderer?.root.removeAttribute("data-gantt-theme");
3084
3379
  }
3085
3380
  }
3381
+ /**
3382
+ * Stretch-only fit: bump columnWidth up so the timeline fills the container's available
3383
+ * width, but never shrink it below what the current zoom level already implies - a project
3384
+ * wider than the container should still scroll normally, not get crammed to fit.
3385
+ */
3386
+ applyAutoFit() {
3387
+ const availableWidth = this.container.clientWidth - this.options.gridPanelWidth;
3388
+ if (!Number.isFinite(availableWidth) || availableWidth <= 0) return;
3389
+ let min = null;
3390
+ let max = null;
3391
+ for (const t of this.tasks) {
3392
+ if (!isValidDate(t.start) || !isValidDate(t.end)) continue;
3393
+ min = min === null ? t.start.getTime() : Math.min(min, t.start.getTime());
3394
+ max = max === null ? t.end.getTime() : Math.max(max, t.end.getTime());
3395
+ }
3396
+ if (min === null || max === null || max <= min) return;
3397
+ const rangeMs = max - min;
3398
+ const unitMs = approxUnitMs(this.options.viewMode);
3399
+ const neededColumnWidth = availableWidth * unitMs / rangeMs;
3400
+ if (neededColumnWidth > this.columnWidth) {
3401
+ this.columnWidth = Math.min(neededColumnWidth, _GanttChart.MAX_COLUMN_WIDTH);
3402
+ }
3403
+ }
3086
3404
  undo() {
3087
3405
  this.history?.undo();
3088
3406
  }
@@ -3177,6 +3495,8 @@ var _GanttChart = class _GanttChart {
3177
3495
  this.darkMediaQuery?.removeEventListener?.("change", this.handleSchemeChange);
3178
3496
  this.emitter.removeAllListeners();
3179
3497
  this.interactions?.destroy();
3498
+ this.tooltip?.destroy();
3499
+ this.resizeObserver?.disconnect();
3180
3500
  this.renderer?.destroy();
3181
3501
  }
3182
3502
  };