@ganttloom/gantt-core 0.2.1 → 0.4.3

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
@@ -74,7 +74,8 @@ var DEFAULT_THEME = {
74
74
  backgroundColor: "#ffffff",
75
75
  fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
76
76
  fontSize: 12,
77
- borderRadius: 4
77
+ borderRadius: 4,
78
+ borderColor: "#e2e8f0"
78
79
  };
79
80
  var DARK_THEME = {
80
81
  ...DEFAULT_THEME,
@@ -91,7 +92,8 @@ var DARK_THEME = {
91
92
  markerColor: "#a78bfa",
92
93
  selectionColor: "#38bdf8",
93
94
  textColor: "#e5e7eb",
94
- backgroundColor: "#181c24"
95
+ backgroundColor: "#181c24",
96
+ borderColor: "#334155"
95
97
  };
96
98
  var DENSITY_PRESETS = {
97
99
  compact: { rowHeight: 26, barHeight: 16, headerHeight: 38, fontSize: 11 },
@@ -130,7 +132,8 @@ var CSS_VAR_NAMES = {
130
132
  backgroundColor: "--gantt-background-color",
131
133
  fontFamily: "--gantt-font-family",
132
134
  fontSize: "--gantt-font-size",
133
- borderRadius: "--gantt-border-radius"
135
+ borderRadius: "--gantt-border-radius",
136
+ borderColor: "--gantt-border-color"
134
137
  };
135
138
  var PX_FIELDS = /* @__PURE__ */ new Set([
136
139
  "rowHeight",
@@ -556,20 +559,38 @@ function labelFor(date, viewMode) {
556
559
  const h = date.getHours();
557
560
  const period = h >= 12 ? "PM" : "AM";
558
561
  const h12 = h % 12 === 0 ? 12 : h % 12;
559
- return `${h12} ${period}`;
562
+ return {
563
+ label: `${h12} ${period}`,
564
+ parentLabel: `${WEEKDAY_LABELS[date.getDay()]} ${date.getDate()} ${MONTH_LABELS[date.getMonth()]} ${date.getFullYear()}`
565
+ };
560
566
  }
561
567
  case "day":
562
- return `${WEEKDAY_LABELS[date.getDay()]} ${date.getDate()}`;
568
+ return {
569
+ label: `${WEEKDAY_LABELS[date.getDay()]} ${date.getDate()}`,
570
+ parentLabel: `${MONTH_LABELS[date.getMonth()]} ${date.getFullYear()}`
571
+ };
563
572
  case "week":
564
- return `${MONTH_LABELS[date.getMonth()]} ${date.getDate()}`;
573
+ return {
574
+ label: `${MONTH_LABELS[date.getMonth()]} ${date.getDate()}`,
575
+ parentLabel: `${MONTH_LABELS[date.getMonth()]} ${date.getFullYear()}`
576
+ };
565
577
  case "month":
566
- return `${MONTH_LABELS[date.getMonth()]} ${date.getFullYear()}`;
578
+ return {
579
+ label: MONTH_LABELS[date.getMonth()] ?? "",
580
+ parentLabel: `${date.getFullYear()}`
581
+ };
567
582
  case "quarter": {
568
583
  const q = Math.floor(date.getMonth() / 3) + 1;
569
- return `Q${q} ${date.getFullYear()}`;
584
+ return {
585
+ label: `Q${q}`,
586
+ parentLabel: `${date.getFullYear()}`
587
+ };
570
588
  }
571
589
  case "year":
572
- return `${date.getFullYear()}`;
590
+ return {
591
+ label: `${date.getFullYear()}`,
592
+ parentLabel: ""
593
+ };
573
594
  }
574
595
  }
575
596
  function generateTicks(rangeStart, rangeEnd, viewMode, columnWidth, today = /* @__PURE__ */ new Date(), calendar) {
@@ -583,10 +604,12 @@ function generateTicks(rangeStart, rangeEnd, viewMode, columnWidth, today = /* @
583
604
  while (cursor.getTime() < rangeEnd.getTime() && guard < MAX_TICKS) {
584
605
  guard++;
585
606
  const x = (cursor.getTime() - rangeStart.getTime()) * scale;
607
+ const labels = labelFor(cursor, viewMode);
586
608
  ticks.push({
587
609
  date: new Date(cursor.getTime()),
588
610
  x,
589
- label: labelFor(cursor, viewMode),
611
+ label: labels.label,
612
+ parentLabel: labels.parentLabel,
590
613
  isWeekend: dayLevel ? isWeekend(cursor) : false,
591
614
  isNonWorking: viewMode === "hour" && calendar?.workingHours ? !isWorkingTime(cursor, calendar) : dayLevel ? isNonWorkingDay(cursor, calendar) : false,
592
615
  isToday: isSameDay(cursor, today) && dayLevel,
@@ -663,6 +686,29 @@ function computeProgressRollup(node) {
663
686
  }
664
687
  node.computedProgress = totalWeight > 0 ? weightedSum / totalWeight : 0;
665
688
  }
689
+ function sortValue(node, col) {
690
+ if (!col) return null;
691
+ if (col.accessor) return col.accessor(node.task) ?? null;
692
+ return col.id === "name" ? node.task.name : null;
693
+ }
694
+ function compareValues(a, b) {
695
+ if (a === null && b === null) return 0;
696
+ if (a === null) return 1;
697
+ if (b === null) return -1;
698
+ if (typeof a === "number" && typeof b === "number") return a - b;
699
+ return String(a).localeCompare(String(b), void 0, { numeric: true, sensitivity: "base" });
700
+ }
701
+ function sortTree(roots, columns, sort) {
702
+ if (!sort) return;
703
+ const col = columns.find((c) => c.id === sort.columnId);
704
+ const dir = sort.direction === "desc" ? -1 : 1;
705
+ const compare = (a, b) => dir * compareValues(sortValue(a, col), sortValue(b, col));
706
+ const sortLevel = (nodes) => {
707
+ nodes.sort(compare);
708
+ for (const node of nodes) sortLevel(node.children);
709
+ };
710
+ sortLevel(roots);
711
+ }
666
712
  function flatten(roots) {
667
713
  const out = [];
668
714
  const stack = [];
@@ -749,6 +795,14 @@ function linkPath(from, to) {
749
795
  const c2y = to.y;
750
796
  return `M ${from.x} ${from.y} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${to.x} ${to.y}`;
751
797
  }
798
+ var MIN_VISIBLE_UNITS = {
799
+ hour: 8,
800
+ day: 7,
801
+ week: 6,
802
+ month: 6,
803
+ quarter: 4,
804
+ year: 5
805
+ };
752
806
  function computeLayout(input) {
753
807
  const {
754
808
  tasks,
@@ -764,7 +818,8 @@ function computeLayout(input) {
764
818
  markers = [],
765
819
  autoRollupProgress = false,
766
820
  selectedTaskIds,
767
- pagination
821
+ pagination,
822
+ sort = null
768
823
  } = input;
769
824
  validateInputs(tasks, dependencies);
770
825
  const { roots, nodeById } = buildTree(tasks);
@@ -772,6 +827,7 @@ function computeLayout(input) {
772
827
  if (autoRollupProgress) {
773
828
  for (const root of roots) computeProgressRollup(root);
774
829
  }
830
+ sortTree(roots, columns, sort);
775
831
  let visibleRoots = roots;
776
832
  if (pagination && pagination.pageSize > 0) {
777
833
  const start = Math.max(0, (pagination.page - 1) * pagination.pageSize);
@@ -791,8 +847,18 @@ function computeLayout(input) {
791
847
  const now = Date.now();
792
848
  const rawRangeStart = rangeMin === null ? new Date(now) : new Date(rangeMin);
793
849
  const rawRangeEnd = rangeMax === null ? new Date(now + 1) : new Date(rangeMax);
794
- const paddedStart = addUnit(rawRangeStart, viewMode, -1);
795
- const paddedEnd = addUnit(rawRangeEnd, viewMode, 1);
850
+ let paddedStart = addUnit(rawRangeStart, viewMode, -1);
851
+ let paddedEnd = addUnit(rawRangeEnd, viewMode, 1);
852
+ const minUnits = MIN_VISIBLE_UNITS[viewMode];
853
+ let targetMinUnits = minUnits;
854
+ if (input.timelineViewportWidth && input.timelineViewportWidth > 0) {
855
+ targetMinUnits = Math.max(targetMinUnits, Math.ceil(input.timelineViewportWidth / columnWidth));
856
+ }
857
+ const currentUnits = (paddedEnd.getTime() - paddedStart.getTime()) / approxUnitMs(viewMode);
858
+ if (currentUnits < targetMinUnits) {
859
+ const deficit = targetMinUnits - currentUnits;
860
+ paddedEnd = addUnit(paddedEnd, viewMode, Math.ceil(deficit));
861
+ }
796
862
  const rows = [];
797
863
  const bars = [];
798
864
  const barByTaskId = /* @__PURE__ */ new Map();
@@ -909,6 +975,7 @@ function computeLayout(input) {
909
975
  (t) => ({
910
976
  x: t.x,
911
977
  label: t.label,
978
+ parentLabel: t.parentLabel,
912
979
  isWeekend: t.isWeekend,
913
980
  isNonWorking: t.isNonWorking,
914
981
  isToday: t.isToday,
@@ -943,7 +1010,8 @@ function computeLayout(input) {
943
1010
  columns,
944
1011
  theme,
945
1012
  markers: renderMarkers,
946
- rangeStart: paddedStart
1013
+ rangeStart: paddedStart,
1014
+ sort
947
1015
  };
948
1016
  }
949
1017
 
@@ -1140,6 +1208,7 @@ var GanttRenderer = class {
1140
1208
  this.lastModel = null;
1141
1209
  this.lastTasks = [];
1142
1210
  this.lastColumns = [];
1211
+ this.lastExplicitThemeKeys = /* @__PURE__ */ new Set();
1143
1212
  this.onGridDoubleClick = (evt) => {
1144
1213
  if (!this.options.onRenameCommit) return;
1145
1214
  const target = evt.target;
@@ -1177,8 +1246,16 @@ var GanttRenderer = class {
1177
1246
  this.root = el("div", "gantt-root");
1178
1247
  this.gridPanel = el("div", "gantt-grid-panel");
1179
1248
  this.timelineScroll = el("div", "gantt-timeline-scroll");
1249
+ this.headerContainer = el("div", "gantt-timeline-header");
1250
+ this.headerSvg = svgEl("svg");
1251
+ this.headerSvg.classList.add("gantt-header-svg");
1252
+ this.headerContainer.appendChild(this.headerSvg);
1180
1253
  this.svg = svgEl("svg");
1181
1254
  this.svg.classList.add("gantt-svg");
1255
+ this.timelineScroll.appendChild(this.headerContainer);
1256
+ if (this.options.readonly) {
1257
+ this.root.classList.add("gantt-readonly");
1258
+ }
1182
1259
  this.timelineScroll.appendChild(this.svg);
1183
1260
  this.root.appendChild(this.gridPanel);
1184
1261
  this.root.appendChild(this.timelineScroll);
@@ -1194,9 +1271,14 @@ var GanttRenderer = class {
1194
1271
  this.lastTasks = tasks;
1195
1272
  this.lastColumns = columns;
1196
1273
  const cssVars = explicitThemeToCssVars(explicitTheme);
1197
- for (const key of Object.keys(cssVars)) {
1274
+ const newKeys = new Set(Object.keys(cssVars));
1275
+ for (const key of this.lastExplicitThemeKeys) {
1276
+ if (!newKeys.has(key)) this.root.style.removeProperty(key);
1277
+ }
1278
+ for (const key of newKeys) {
1198
1279
  this.root.style.setProperty(key, cssVars[key]);
1199
1280
  }
1281
+ this.lastExplicitThemeKeys = newKeys;
1200
1282
  this.renderGridPanel(model, tasks, columns);
1201
1283
  this.renderTimeline(model, tasks);
1202
1284
  }
@@ -1224,7 +1306,7 @@ var GanttRenderer = class {
1224
1306
  const header = el("div", "gantt-grid-header-row");
1225
1307
  header.style.height = `${model.headerHeight}px`;
1226
1308
  for (const col of columns) {
1227
- header.appendChild(this.renderHeaderCell(col, columns));
1309
+ header.appendChild(this.renderHeaderCell(col, columns, model.sort));
1228
1310
  }
1229
1311
  this.gridPanel.appendChild(header);
1230
1312
  const body = el("div", "gantt-grid-body");
@@ -1237,11 +1319,26 @@ var GanttRenderer = class {
1237
1319
  });
1238
1320
  this.gridPanel.appendChild(body);
1239
1321
  }
1240
- renderHeaderCell(col, columns) {
1322
+ renderHeaderCell(col, columns, sort) {
1241
1323
  const cell = el("div", "gantt-grid-header-cell");
1242
1324
  cell.style.position = "relative";
1243
1325
  if (col.width) cell.style.width = `${col.width}px`;
1244
- cell.textContent = col.title;
1326
+ const titleSpan = document.createElement("span");
1327
+ titleSpan.className = "gantt-cell-text";
1328
+ titleSpan.textContent = col.title;
1329
+ cell.appendChild(titleSpan);
1330
+ if (col.sortable) {
1331
+ cell.classList.add("gantt-grid-header-cell-sortable");
1332
+ const active = sort?.columnId === col.id;
1333
+ const indicator = el("span", "gantt-sort-indicator");
1334
+ indicator.textContent = active ? sort.direction === "asc" ? " \u25B2" : " \u25BC" : "";
1335
+ cell.appendChild(indicator);
1336
+ cell.addEventListener("click", (evt) => {
1337
+ const target = evt.target;
1338
+ if (target.closest(".gantt-col-resize-handle")) return;
1339
+ this.options.onSortClick?.(col.id);
1340
+ });
1341
+ }
1245
1342
  if (this.options.onColumnReorder) {
1246
1343
  cell.draggable = true;
1247
1344
  cell.style.cursor = "grab";
@@ -1271,7 +1368,7 @@ var GanttRenderer = class {
1271
1368
  evt.preventDefault();
1272
1369
  evt.stopPropagation();
1273
1370
  const startX = evt.clientX;
1274
- const startWidth = col.width ?? cell.getBoundingClientRect().width;
1371
+ const startWidth = cell.getBoundingClientRect().width;
1275
1372
  const onMove = (moveEvt) => {
1276
1373
  const newWidth = Math.max(30, startWidth + (moveEvt.clientX - startX));
1277
1374
  cell.style.width = `${newWidth}px`;
@@ -1324,9 +1421,11 @@ var GanttRenderer = class {
1324
1421
  const cell = el("div", "gantt-grid-cell");
1325
1422
  if (col.width) cell.style.width = `${col.width}px`;
1326
1423
  if (col.align) cell.style.textAlign = col.align;
1327
- const rawValue = col.accessor ? col.accessor(task) : colIndex === 0 ? task.name : "";
1424
+ const isTreeColumn = col.id === "name" || !columns.some((c) => c.id === "name") && colIndex === 0;
1425
+ console.log(`Column ${col.id}: isTreeColumn=${isTreeColumn}, colIndex=${colIndex}, hasNameCol=${columns.some((c) => c.id === "name")}`);
1426
+ const rawValue = col.accessor ? col.accessor(task) : isTreeColumn ? task.name : task[col.id] ?? "";
1328
1427
  const textValue = rawValue === null || rawValue === void 0 ? "" : String(rawValue);
1329
- if (colIndex === 0) {
1428
+ if (isTreeColumn) {
1330
1429
  cell.dataset.ganttNameCell = task.id;
1331
1430
  cell.dataset.ganttNameValue = textValue;
1332
1431
  const indent = el("span", "gantt-indent");
@@ -1335,17 +1434,27 @@ var GanttRenderer = class {
1335
1434
  if (row.hasChildren) {
1336
1435
  const toggle = el("button", "gantt-toggle");
1337
1436
  toggle.type = "button";
1437
+ toggle.className = `gantt-toggle ${row.collapsed ? "collapsed" : "expanded"}`;
1338
1438
  toggle.dataset.ganttToggle = task.id;
1339
1439
  toggle.setAttribute("aria-label", row.collapsed ? "Expand" : "Collapse");
1340
1440
  toggle.textContent = row.collapsed ? "\u25B8" : "\u25BE";
1341
1441
  cell.appendChild(toggle);
1342
1442
  }
1443
+ const addChildBtn = el("button", "gantt-add-child");
1444
+ addChildBtn.type = "button";
1445
+ addChildBtn.dataset.ganttAddChild = task.id;
1446
+ addChildBtn.setAttribute("aria-label", "Add child task");
1447
+ addChildBtn.textContent = "+";
1448
+ cell.appendChild(addChildBtn);
1343
1449
  }
1344
1450
  const rendered = col.render ? col.render(task) : void 0;
1345
1451
  if (rendered instanceof HTMLElement) {
1346
1452
  cell.appendChild(rendered);
1347
1453
  } else if (typeof rendered === "string") {
1348
- cell.appendChild(document.createTextNode(rendered));
1454
+ const span = document.createElement("span");
1455
+ span.className = "gantt-cell-text";
1456
+ span.textContent = rendered;
1457
+ cell.appendChild(span);
1349
1458
  } else {
1350
1459
  const href = col.getHref ? col.getHref(task) : null;
1351
1460
  if (href && isSafeHref(href)) {
@@ -1354,10 +1463,13 @@ var GanttRenderer = class {
1354
1463
  anchor.target = col.linkTarget ?? "_blank";
1355
1464
  anchor.rel = "noopener noreferrer";
1356
1465
  anchor.textContent = textValue;
1466
+ anchor.className = "gantt-cell-text";
1357
1467
  cell.appendChild(anchor);
1358
1468
  } else {
1359
- const textNode = document.createTextNode(textValue);
1360
- cell.appendChild(textNode);
1469
+ const span = document.createElement("span");
1470
+ span.className = "gantt-cell-text";
1471
+ span.textContent = textValue;
1472
+ cell.appendChild(span);
1361
1473
  }
1362
1474
  }
1363
1475
  rowEl.appendChild(cell);
@@ -1368,8 +1480,8 @@ var GanttRenderer = class {
1368
1480
  renderTimeline(model, tasks) {
1369
1481
  this.svg.replaceChildren();
1370
1482
  this.svg.setAttribute("width", String(model.width));
1371
- this.svg.setAttribute("height", String(model.headerHeight + model.height));
1372
- this.svg.setAttribute("viewBox", `0 0 ${model.width} ${model.headerHeight + model.height}`);
1483
+ this.svg.setAttribute("height", String(model.height));
1484
+ this.svg.setAttribute("viewBox", `0 0 ${model.width} ${model.height}`);
1373
1485
  const defs = svgEl("defs");
1374
1486
  const marker = svgEl("marker");
1375
1487
  marker.setAttribute("id", this.markerDefsId);
@@ -1386,11 +1498,10 @@ var GanttRenderer = class {
1386
1498
  this.svg.appendChild(defs);
1387
1499
  const { startY, endY } = this.getViewportRowRange(model);
1388
1500
  const bodyGroup = svgEl("g");
1389
- bodyGroup.setAttribute("transform", `translate(0, ${model.headerHeight})`);
1390
1501
  this.renderGridLines(bodyGroup, model);
1391
1502
  this.renderRowBackgrounds(bodyGroup, model, startY, endY);
1392
- this.renderBars(bodyGroup, model, tasks, startY, endY);
1393
1503
  this.renderLinks(bodyGroup, model, startY, endY);
1504
+ this.renderBars(bodyGroup, model, tasks, startY, endY);
1394
1505
  this.renderMarkers(bodyGroup, model);
1395
1506
  this.svg.appendChild(bodyGroup);
1396
1507
  this.renderHeader(model);
@@ -1457,22 +1568,31 @@ var GanttRenderer = class {
1457
1568
  clipRect.setAttribute("height", String(model.height));
1458
1569
  clipPath.appendChild(clipRect);
1459
1570
  defs.appendChild(clipPath);
1460
- const label = svgEl("text");
1461
- label.setAttribute("x", String(marker.x + 4));
1462
- label.setAttribute("y", "12");
1463
- label.setAttribute("clip-path", `url(#${clipId})`);
1464
- label.setAttribute("fill", marker.color);
1465
- label.setAttribute("font-size", String(model.theme.fontSize));
1466
- label.setAttribute("font-family", model.theme.fontFamily);
1467
- label.textContent = marker.label;
1468
- group.appendChild(label);
1571
+ const fo = svgEl("foreignObject");
1572
+ fo.setAttribute("x", String(marker.x + 4));
1573
+ fo.setAttribute("y", "0");
1574
+ fo.setAttribute("width", String(cellWidth > 8 ? cellWidth - 8 : cellWidth));
1575
+ fo.setAttribute("height", String(model.height));
1576
+ const div = document.createElement("div");
1577
+ div.style.color = marker.color;
1578
+ div.style.fontSize = `${model.theme.fontSize}px`;
1579
+ div.style.fontFamily = model.theme.fontFamily;
1580
+ div.style.whiteSpace = "normal";
1581
+ div.style.overflowWrap = "break-word";
1582
+ div.style.paddingTop = "4px";
1583
+ div.textContent = marker.label;
1584
+ fo.appendChild(div);
1585
+ group.appendChild(fo);
1469
1586
  }
1470
1587
  });
1471
1588
  }
1472
1589
  renderHeader(model) {
1473
- let headerGroup = this.svg.querySelector(".gantt-header-group");
1474
- if (headerGroup) headerGroup.remove();
1475
- headerGroup = svgEl("g");
1590
+ this.headerSvg.replaceChildren();
1591
+ this.headerSvg.setAttribute("width", String(model.width));
1592
+ this.headerSvg.setAttribute("height", String(model.headerHeight));
1593
+ this.headerSvg.setAttribute("viewBox", `0 0 ${model.width} ${model.headerHeight}`);
1594
+ this.headerContainer.style.height = `${model.headerHeight}px`;
1595
+ const headerGroup = svgEl("g");
1476
1596
  headerGroup.setAttribute("class", "gantt-header-group");
1477
1597
  const bg = svgEl("rect");
1478
1598
  bg.setAttribute("x", "0");
@@ -1483,6 +1603,60 @@ var GanttRenderer = class {
1483
1603
  headerGroup.appendChild(bg);
1484
1604
  const defs = svgEl("defs");
1485
1605
  const ticks = model.ticks;
1606
+ const parentGroups = [];
1607
+ let currentGroup = null;
1608
+ for (const tick of ticks) {
1609
+ if (!currentGroup || currentGroup.label !== tick.parentLabel) {
1610
+ currentGroup = { x: tick.x, label: tick.parentLabel };
1611
+ parentGroups.push(currentGroup);
1612
+ }
1613
+ }
1614
+ const hasParentTier = parentGroups.some((g) => g.label !== "");
1615
+ const bottomTierY = hasParentTier ? model.headerHeight / 2 : 0;
1616
+ const tierHeight = hasParentTier ? model.headerHeight / 2 : model.headerHeight;
1617
+ if (hasParentTier) {
1618
+ for (let i = 0; i < parentGroups.length; i++) {
1619
+ const group = parentGroups[i];
1620
+ const nextX = parentGroups[i + 1]?.x ?? model.width;
1621
+ const cellWidth = nextX - group.x;
1622
+ const fo = svgEl("foreignObject");
1623
+ fo.setAttribute("x", String(group.x + 4));
1624
+ fo.setAttribute("y", "0");
1625
+ fo.setAttribute("width", String(Math.max(0, cellWidth - 4)));
1626
+ fo.setAttribute("height", String(tierHeight));
1627
+ const div = document.createElement("div");
1628
+ div.style.color = themedFill("textColor", model.theme.textColor);
1629
+ div.style.fontSize = `${model.theme.fontSize * 0.95}px`;
1630
+ div.style.fontFamily = model.theme.fontFamily;
1631
+ div.style.fontWeight = "600";
1632
+ div.style.whiteSpace = "nowrap";
1633
+ div.style.overflow = "hidden";
1634
+ div.style.textOverflow = "ellipsis";
1635
+ div.style.display = "flex";
1636
+ div.style.alignItems = "center";
1637
+ div.style.justifyContent = "center";
1638
+ div.style.height = "100%";
1639
+ div.textContent = group.label;
1640
+ fo.appendChild(div);
1641
+ headerGroup.appendChild(fo);
1642
+ const border = svgEl("line");
1643
+ border.setAttribute("x1", String(group.x));
1644
+ border.setAttribute("x2", String(nextX));
1645
+ border.setAttribute("y1", String(tierHeight));
1646
+ border.setAttribute("y2", String(tierHeight));
1647
+ border.style.stroke = themedFill("borderColor", model.theme.borderColor ?? "#e2e8f0");
1648
+ headerGroup.appendChild(border);
1649
+ if (i > 0) {
1650
+ const sep = svgEl("line");
1651
+ sep.setAttribute("x1", String(group.x));
1652
+ sep.setAttribute("x2", String(group.x));
1653
+ sep.setAttribute("y1", "0");
1654
+ sep.setAttribute("y2", String(tierHeight));
1655
+ sep.style.stroke = themedFill("borderColor", model.theme.borderColor ?? "#e2e8f0");
1656
+ headerGroup.appendChild(sep);
1657
+ }
1658
+ }
1659
+ }
1486
1660
  for (let i = 0; i < ticks.length; i++) {
1487
1661
  const tick = ticks[i];
1488
1662
  const cellWidth = (ticks[i + 1]?.x ?? model.width) - tick.x;
@@ -1491,23 +1665,33 @@ var GanttRenderer = class {
1491
1665
  clipPath.setAttribute("id", clipId);
1492
1666
  const clipRect = svgEl("rect");
1493
1667
  clipRect.setAttribute("x", String(tick.x));
1494
- clipRect.setAttribute("y", "0");
1668
+ clipRect.setAttribute("y", String(bottomTierY));
1495
1669
  clipRect.setAttribute("width", String(Math.max(0, cellWidth)));
1496
- clipRect.setAttribute("height", String(model.headerHeight));
1670
+ clipRect.setAttribute("height", String(tierHeight));
1497
1671
  clipPath.appendChild(clipRect);
1498
1672
  defs.appendChild(clipPath);
1499
- const text = svgEl("text");
1500
- text.setAttribute("x", String(tick.x + 4));
1501
- text.setAttribute("y", String(model.headerHeight - 8));
1502
- text.setAttribute("clip-path", `url(#${clipId})`);
1503
- text.style.fill = themedFill("textColor", model.theme.textColor);
1504
- text.setAttribute("font-size", String(model.theme.fontSize));
1505
- text.setAttribute("font-family", model.theme.fontFamily);
1506
- text.textContent = tick.label;
1507
- headerGroup.appendChild(text);
1673
+ const fo = svgEl("foreignObject");
1674
+ fo.setAttribute("x", String(tick.x + 4));
1675
+ fo.setAttribute("y", String(bottomTierY));
1676
+ fo.setAttribute("width", String(cellWidth > 8 ? cellWidth - 8 : cellWidth));
1677
+ fo.setAttribute("height", String(tierHeight));
1678
+ const div = document.createElement("div");
1679
+ div.style.color = themedFill("textColor", model.theme.textColor);
1680
+ div.style.fontSize = `${model.theme.fontSize}px`;
1681
+ div.style.fontFamily = model.theme.fontFamily;
1682
+ div.style.whiteSpace = "normal";
1683
+ div.style.overflowWrap = "break-word";
1684
+ div.style.display = "flex";
1685
+ div.style.alignItems = hasParentTier ? "center" : "flex-end";
1686
+ div.style.justifyContent = "center";
1687
+ div.style.paddingBottom = hasParentTier ? "0" : "4px";
1688
+ div.style.height = "100%";
1689
+ div.textContent = tick.label;
1690
+ fo.appendChild(div);
1691
+ headerGroup.appendChild(fo);
1508
1692
  }
1509
1693
  headerGroup.insertBefore(defs, headerGroup.firstChild);
1510
- this.svg.appendChild(headerGroup);
1694
+ this.headerSvg.appendChild(headerGroup);
1511
1695
  }
1512
1696
  /**
1513
1697
  * Invisible full-width hit-test rects, one per row, behind the bars. Lets
@@ -1542,18 +1726,13 @@ var GanttRenderer = class {
1542
1726
  g.setAttribute("class", "gantt-bar");
1543
1727
  g.dataset.ganttBar = bar.taskId;
1544
1728
  g.setAttribute("transform", `translate(${bar.x}, ${bar.y})`);
1545
- if (task?.notes) {
1546
- const title = svgEl("title");
1547
- title.textContent = task.notes;
1548
- g.appendChild(title);
1549
- }
1550
1729
  if (this.options.keyboardAccessible) {
1551
1730
  g.setAttribute("tabindex", "0");
1552
1731
  g.setAttribute("role", "button");
1553
1732
  if (task) {
1554
1733
  const progress = Math.round(task.progress ?? 0);
1555
- const label2 = `${task.name}, ${task.start.toDateString()} to ${task.end.toDateString()}, ${progress}% complete`;
1556
- g.setAttribute("aria-label", label2);
1734
+ const label = `${task.name}, ${task.start.toDateString()} to ${task.end.toDateString()}, ${progress}% complete`;
1735
+ g.setAttribute("aria-label", label);
1557
1736
  }
1558
1737
  }
1559
1738
  if (bar.baseline) {
@@ -1601,17 +1780,15 @@ var GanttRenderer = class {
1601
1780
  `rotate(45, 0, ${bar.height / 2})`
1602
1781
  );
1603
1782
  g.appendChild(diamond);
1604
- const label2 = svgEl("text");
1605
- label2.setAttribute("x", String(size / 2 + 8));
1606
- label2.setAttribute("y", String(bar.height / 2 + 4));
1607
- label2.style.fill = themedFill("textColor", model.theme.textColor);
1608
- label2.setAttribute("font-size", String(model.theme.fontSize));
1609
- label2.setAttribute("font-family", model.theme.fontFamily);
1610
- label2.textContent = bar.label;
1611
- g.appendChild(label2);
1612
- return g;
1613
- }
1614
- if (bar.segments && bar.segments.length > 0) {
1783
+ const label = svgEl("text");
1784
+ label.setAttribute("x", String(size / 2 + 8));
1785
+ label.setAttribute("y", String(bar.height / 2 + 4));
1786
+ label.style.fill = themedFill("textColor", model.theme.textColor);
1787
+ label.setAttribute("font-size", String(model.theme.fontSize));
1788
+ label.setAttribute("font-family", model.theme.fontFamily);
1789
+ label.textContent = bar.label;
1790
+ g.appendChild(label);
1791
+ } else if (bar.segments && bar.segments.length > 0) {
1615
1792
  bar.segments.forEach((seg, i) => {
1616
1793
  const segRect = svgEl("rect");
1617
1794
  segRect.setAttribute("x", String(seg.x));
@@ -1684,29 +1861,31 @@ var GanttRenderer = class {
1684
1861
  selectionOutline.setAttribute("stroke-width", "2");
1685
1862
  g.appendChild(selectionOutline);
1686
1863
  }
1687
- const handleWidth = 6;
1688
- const leftHandle = svgEl("rect");
1689
- leftHandle.setAttribute("x", "0");
1690
- leftHandle.setAttribute("y", "0");
1691
- leftHandle.setAttribute("width", String(handleWidth));
1692
- leftHandle.setAttribute("height", String(bar.height));
1693
- leftHandle.setAttribute("fill", "transparent");
1694
- leftHandle.dataset.ganttHandle = "left";
1695
- leftHandle.dataset.ganttHandleFor = bar.taskId;
1696
- leftHandle.style.cursor = "ew-resize";
1697
- g.appendChild(leftHandle);
1698
- const rightHandle = svgEl("rect");
1699
- rightHandle.setAttribute("x", String(Math.max(0, bar.width - handleWidth)));
1700
- rightHandle.setAttribute("y", "0");
1701
- rightHandle.setAttribute("width", String(handleWidth));
1702
- rightHandle.setAttribute("height", String(bar.height));
1703
- rightHandle.setAttribute("fill", "transparent");
1704
- rightHandle.dataset.ganttHandle = "right";
1705
- rightHandle.dataset.ganttHandleFor = bar.taskId;
1706
- rightHandle.style.cursor = "ew-resize";
1707
- g.appendChild(rightHandle);
1864
+ if (!bar.isMilestone) {
1865
+ const handleWidth = 6;
1866
+ const leftHandle = svgEl("rect");
1867
+ leftHandle.setAttribute("x", "0");
1868
+ leftHandle.setAttribute("y", "0");
1869
+ leftHandle.setAttribute("width", String(handleWidth));
1870
+ leftHandle.setAttribute("height", String(bar.height));
1871
+ leftHandle.setAttribute("fill", "transparent");
1872
+ leftHandle.dataset.ganttHandle = "left";
1873
+ leftHandle.dataset.ganttHandleFor = bar.taskId;
1874
+ leftHandle.style.cursor = "ew-resize";
1875
+ g.appendChild(leftHandle);
1876
+ const rightHandle = svgEl("rect");
1877
+ rightHandle.setAttribute("x", String(Math.max(0, bar.width - handleWidth)));
1878
+ rightHandle.setAttribute("y", "0");
1879
+ rightHandle.setAttribute("width", String(handleWidth));
1880
+ rightHandle.setAttribute("height", String(bar.height));
1881
+ rightHandle.setAttribute("fill", "transparent");
1882
+ rightHandle.dataset.ganttHandle = "right";
1883
+ rightHandle.dataset.ganttHandleFor = bar.taskId;
1884
+ rightHandle.style.cursor = "ew-resize";
1885
+ g.appendChild(rightHandle);
1886
+ }
1708
1887
  const connector = svgEl("circle");
1709
- connector.setAttribute("cx", String(bar.width));
1888
+ connector.setAttribute("cx", String(bar.width + (bar.isMilestone ? 10 : 0)));
1710
1889
  connector.setAttribute("cy", String(bar.height / 2));
1711
1890
  connector.setAttribute("r", "4");
1712
1891
  connector.style.fill = themedFill("linkColor", model.theme.linkColor);
@@ -1714,14 +1893,16 @@ var GanttRenderer = class {
1714
1893
  connector.dataset.ganttConnectorSide = "right";
1715
1894
  connector.style.cursor = "crosshair";
1716
1895
  g.appendChild(connector);
1717
- const label = svgEl("text");
1718
- label.setAttribute("x", String(bar.width + 6));
1719
- label.setAttribute("y", String(bar.height / 2 + 4));
1720
- label.style.fill = themedFill("textColor", model.theme.textColor);
1721
- label.setAttribute("font-size", String(model.theme.fontSize));
1722
- label.setAttribute("font-family", model.theme.fontFamily);
1723
- label.textContent = bar.label;
1724
- g.appendChild(label);
1896
+ if (!bar.isMilestone) {
1897
+ const label = svgEl("text");
1898
+ label.setAttribute("x", String(bar.width + 6));
1899
+ label.setAttribute("y", String(bar.height / 2 + 4));
1900
+ label.style.fill = themedFill("textColor", model.theme.textColor);
1901
+ label.setAttribute("font-size", String(model.theme.fontSize));
1902
+ label.setAttribute("font-family", model.theme.fontFamily);
1903
+ label.textContent = bar.label;
1904
+ g.appendChild(label);
1905
+ }
1725
1906
  if (bar.deadlineX !== void 0) {
1726
1907
  const markerX = bar.deadlineX - bar.x;
1727
1908
  const flag = svgEl("g");
@@ -1779,10 +1960,20 @@ var GanttRenderer = class {
1779
1960
  for (const link of model.links) {
1780
1961
  const fromBar = barByTaskId.get(link.fromId);
1781
1962
  const toBar = barByTaskId.get(link.toId);
1782
- const relevantY = toBar?.y ?? fromBar?.y ?? 0;
1963
+ if (!fromBar || !toBar) continue;
1964
+ const relevantY = toBar.y ?? fromBar.y ?? 0;
1783
1965
  if (relevantY < startY || relevantY > endY) continue;
1966
+ const type = link.type ?? "FS";
1967
+ const x1 = type.startsWith("F") ? fromBar.x + fromBar.width : fromBar.x;
1968
+ const y1 = fromBar.y + fromBar.height / 2;
1969
+ const x2 = type.endsWith("F") ? toBar.x + toBar.width : toBar.x;
1970
+ const y2 = toBar.y + toBar.height / 2;
1971
+ const offset = 10;
1972
+ const routingX1 = type.startsWith("F") ? x1 + offset : x1 - offset;
1973
+ const routingX2 = type.endsWith("F") ? x2 + offset : x2 - offset;
1974
+ const d = `M ${x1} ${y1} L ${routingX1} ${y1} C ${routingX1 + (routingX2 - routingX1) / 2} ${y1}, ${routingX1 + (routingX2 - routingX1) / 2} ${y2}, ${routingX2} ${y2} L ${x2} ${y2}`;
1784
1975
  const path = svgEl("path");
1785
- path.setAttribute("d", link.path);
1976
+ path.setAttribute("d", d);
1786
1977
  path.setAttribute("fill", "none");
1787
1978
  path.style.stroke = link.isCritical ? themedFill("criticalColor", model.theme.criticalColor) : themedFill("linkColor", model.theme.linkColor);
1788
1979
  path.setAttribute("stroke-width", link.isCritical ? "2" : "1.5");
@@ -1791,8 +1982,30 @@ var GanttRenderer = class {
1791
1982
  group.appendChild(path);
1792
1983
  }
1793
1984
  }
1985
+ /**
1986
+ * The header and body are separate DOM elements at runtime (so plain CSS `position: sticky`
1987
+ * can pin the header - see the constructor's doc comment), but exports need one self-contained
1988
+ * SVG with both, laid out exactly as the original single-SVG version was.
1989
+ */
1794
1990
  toSVGString() {
1795
- return new XMLSerializer().serializeToString(this.svg);
1991
+ const headerHeight = this.lastModel?.headerHeight ?? 0;
1992
+ const width = this.lastModel?.width ?? 0;
1993
+ const bodyHeight = this.lastModel?.height ?? 0;
1994
+ const combined = svgEl("svg");
1995
+ combined.setAttribute("xmlns", SVG_NS);
1996
+ combined.setAttribute("width", String(width));
1997
+ combined.setAttribute("height", String(headerHeight + bodyHeight));
1998
+ combined.setAttribute("viewBox", `0 0 ${width} ${headerHeight + bodyHeight}`);
1999
+ for (const child of Array.from(this.headerSvg.children)) {
2000
+ combined.appendChild(child.cloneNode(true));
2001
+ }
2002
+ const bodyGroup = svgEl("g");
2003
+ bodyGroup.setAttribute("transform", `translate(0, ${headerHeight})`);
2004
+ for (const child of Array.from(this.svg.children)) {
2005
+ bodyGroup.appendChild(child.cloneNode(true));
2006
+ }
2007
+ combined.appendChild(bodyGroup);
2008
+ return new XMLSerializer().serializeToString(combined);
1796
2009
  }
1797
2010
  destroy() {
1798
2011
  if (this.scrollListener) {
@@ -2158,6 +2371,12 @@ var InteractionController = class {
2158
2371
  const toggleId = target.closest("[data-gantt-toggle]")?.dataset.ganttToggle;
2159
2372
  if (toggleId) {
2160
2373
  this.callbacks.onToggleCollapse(toggleId);
2374
+ return;
2375
+ }
2376
+ const addChildId = target.closest("[data-gantt-add-child]")?.dataset.ganttAddChild;
2377
+ if (addChildId && this.callbacks.onAddChild) {
2378
+ this.callbacks.onAddChild(addChildId);
2379
+ return;
2161
2380
  }
2162
2381
  };
2163
2382
  this.onContextMenu = (evt) => {
@@ -2171,13 +2390,17 @@ var InteractionController = class {
2171
2390
  }
2172
2391
  };
2173
2392
  this.onSvgDoubleClick = (evt) => {
2174
- if (!this.callbacks.onLinkDblClick) return;
2175
2393
  const target = evt.target;
2176
2394
  const linkPath2 = target.closest("[data-gantt-link]");
2177
2395
  const key = linkPath2?.dataset.ganttLink;
2178
- if (!key) return;
2179
- const [fromId, toId] = key.split("->");
2180
- if (fromId && toId) this.callbacks.onLinkDblClick(fromId, toId);
2396
+ if (key && this.callbacks.onLinkDblClick) {
2397
+ const [fromId, toId] = key.split("->");
2398
+ if (fromId && toId) this.callbacks.onLinkDblClick(fromId, toId);
2399
+ return;
2400
+ }
2401
+ const barGroup = target.closest("[data-gantt-bar]");
2402
+ const taskId = barGroup?.dataset.ganttBar;
2403
+ if (taskId && this.callbacks.onBarDblClick) this.callbacks.onBarDblClick(taskId);
2181
2404
  };
2182
2405
  this.onPointerDown = (evt) => {
2183
2406
  const target = evt.target;
@@ -2440,6 +2663,119 @@ var InteractionController = class {
2440
2663
  }
2441
2664
  };
2442
2665
 
2666
+ // src/tooltip.ts
2667
+ function formatDate(d) {
2668
+ return d.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" });
2669
+ }
2670
+ function defaultTooltipContent(task) {
2671
+ const el2 = document.createElement("div");
2672
+ const title = document.createElement("div");
2673
+ title.className = "gantt-tooltip-title";
2674
+ title.textContent = task.name;
2675
+ el2.appendChild(title);
2676
+ const dates = document.createElement("div");
2677
+ dates.className = "gantt-tooltip-row";
2678
+ dates.textContent = task.isMilestone ? formatDate(task.start) : `${formatDate(task.start)} \u2192 ${formatDate(task.end)}`;
2679
+ el2.appendChild(dates);
2680
+ if (task.progress !== void 0) {
2681
+ const progress = document.createElement("div");
2682
+ progress.className = "gantt-tooltip-row";
2683
+ progress.textContent = `${Math.round(task.progress)}% complete`;
2684
+ el2.appendChild(progress);
2685
+ }
2686
+ if (task.assignees && task.assignees.length > 0) {
2687
+ const assignees = document.createElement("div");
2688
+ assignees.className = "gantt-tooltip-row";
2689
+ assignees.textContent = task.assignees.map((a) => a.name).join(", ");
2690
+ el2.appendChild(assignees);
2691
+ }
2692
+ if (task.notes) {
2693
+ const notes = document.createElement("div");
2694
+ notes.className = "gantt-tooltip-notes";
2695
+ notes.textContent = task.notes;
2696
+ el2.appendChild(notes);
2697
+ }
2698
+ return el2;
2699
+ }
2700
+ var Tooltip = class {
2701
+ constructor(getOptions, getTask) {
2702
+ this.svg = null;
2703
+ this.hoveredTaskId = null;
2704
+ this.onPointerOver = (evt) => {
2705
+ if (!this.getOptions().enabled) return;
2706
+ const target = evt.target;
2707
+ const barGroup = target.closest("[data-gantt-bar]");
2708
+ const taskId = barGroup?.dataset.ganttBar;
2709
+ if (!taskId || taskId === this.hoveredTaskId) return;
2710
+ const task = this.getTask(taskId);
2711
+ if (!task) return;
2712
+ const { render } = this.getOptions();
2713
+ const content = render ? render(task) : defaultTooltipContent(task);
2714
+ if (content === null || content === void 0) {
2715
+ this.hide();
2716
+ return;
2717
+ }
2718
+ this.el.replaceChildren();
2719
+ if (content instanceof HTMLElement) {
2720
+ this.el.appendChild(content);
2721
+ } else {
2722
+ this.el.textContent = content;
2723
+ }
2724
+ this.hoveredTaskId = taskId;
2725
+ this.el.style.display = "block";
2726
+ this.position(evt.clientX, evt.clientY);
2727
+ };
2728
+ this.onPointerMove = (evt) => {
2729
+ if (this.hoveredTaskId === null) return;
2730
+ this.position(evt.clientX, evt.clientY);
2731
+ };
2732
+ this.onPointerOut = (evt) => {
2733
+ const related = evt.relatedTarget;
2734
+ const target = evt.target;
2735
+ const leftBarGroup = target.closest("[data-gantt-bar]");
2736
+ if (related && leftBarGroup?.contains(related)) return;
2737
+ this.hide();
2738
+ };
2739
+ this.getOptions = getOptions;
2740
+ this.getTask = getTask;
2741
+ this.el = document.createElement("div");
2742
+ this.el.className = "gantt-tooltip";
2743
+ this.el.setAttribute("role", "tooltip");
2744
+ this.el.style.display = "none";
2745
+ document.body.appendChild(this.el);
2746
+ }
2747
+ attach(svg) {
2748
+ this.svg = svg;
2749
+ svg.addEventListener("pointerover", this.onPointerOver);
2750
+ svg.addEventListener("pointermove", this.onPointerMove);
2751
+ svg.addEventListener("pointerout", this.onPointerOut);
2752
+ }
2753
+ position(clientX, clientY) {
2754
+ const OFFSET = 12;
2755
+ const rect = this.el.getBoundingClientRect();
2756
+ const viewportW = window.innerWidth;
2757
+ const viewportH = window.innerHeight;
2758
+ let left = clientX + OFFSET;
2759
+ let top = clientY + OFFSET;
2760
+ if (left + rect.width > viewportW) left = clientX - OFFSET - rect.width;
2761
+ if (top + rect.height > viewportH) top = clientY - OFFSET - rect.height;
2762
+ this.el.style.left = `${Math.max(0, left)}px`;
2763
+ this.el.style.top = `${Math.max(0, top)}px`;
2764
+ }
2765
+ hide() {
2766
+ this.hoveredTaskId = null;
2767
+ this.el.style.display = "none";
2768
+ }
2769
+ destroy() {
2770
+ if (this.svg) {
2771
+ this.svg.removeEventListener("pointerover", this.onPointerOver);
2772
+ this.svg.removeEventListener("pointermove", this.onPointerMove);
2773
+ this.svg.removeEventListener("pointerout", this.onPointerOut);
2774
+ }
2775
+ this.el.remove();
2776
+ }
2777
+ };
2778
+
2443
2779
  // src/index.ts
2444
2780
  var HAS_DOM = typeof document !== "undefined" && typeof window !== "undefined";
2445
2781
  var DEFAULT_COLUMNS = [{ id: "name", title: "Name" }];
@@ -2459,6 +2795,9 @@ var _GanttChart = class _GanttChart {
2459
2795
  this.history = null;
2460
2796
  this.renderer = null;
2461
2797
  this.interactions = null;
2798
+ this.tooltip = null;
2799
+ this.resizeObserver = null;
2800
+ this.sort = null;
2462
2801
  this.renderModel = null;
2463
2802
  this.rafHandle = null;
2464
2803
  this.currentPage = 1;
@@ -2481,6 +2820,8 @@ var _GanttChart = class _GanttChart {
2481
2820
  showCriticalPath: options.showCriticalPath ?? false,
2482
2821
  showBaseline: options.showBaseline ?? false,
2483
2822
  showDeadlines: options.showDeadlines ?? true,
2823
+ showTooltip: options.showTooltip ?? true,
2824
+ autoFitToViewport: options.autoFitToViewport ?? false,
2484
2825
  showAssigneeAvatars: options.showAssigneeAvatars ?? false,
2485
2826
  enableHistory: options.enableHistory ?? false,
2486
2827
  keyboardAccessible: options.keyboardAccessible ?? false,
@@ -2495,19 +2836,23 @@ var _GanttChart = class _GanttChart {
2495
2836
  markers: options.markers,
2496
2837
  pagination: options.pagination,
2497
2838
  snapToUnit: options.snapToUnit,
2839
+ headerPosition: options.headerPosition ?? "top",
2498
2840
  onDateChange: options.onDateChange,
2499
2841
  onProgressChange: options.onProgressChange,
2500
2842
  onDependencyCreate: options.onDependencyCreate,
2501
2843
  onDependencyRemove: options.onDependencyRemove,
2502
2844
  onDependencyDblClick: options.onDependencyDblClick,
2503
2845
  onTaskClick: options.onTaskClick,
2846
+ onTaskDblClick: options.onTaskDblClick,
2504
2847
  onGroupToggle: options.onGroupToggle,
2505
2848
  onContextMenu: options.onContextMenu,
2506
2849
  onTaskCreate: options.onTaskCreate,
2507
2850
  onColumnResize: options.onColumnResize,
2508
2851
  onColumnReorder: options.onColumnReorder,
2509
2852
  onTaskReorder: options.onTaskReorder,
2510
- onSelectionChange: options.onSelectionChange
2853
+ onSelectionChange: options.onSelectionChange,
2854
+ onSortChange: options.onSortChange,
2855
+ renderTooltip: options.renderTooltip
2511
2856
  };
2512
2857
  if (this.options.enableHistory) {
2513
2858
  this.history = new HistoryManager((state) => this.emitter.emit("history-change", state));
@@ -2524,14 +2869,23 @@ var _GanttChart = class _GanttChart {
2524
2869
  this.darkMediaQuery.addEventListener?.("change", this.handleSchemeChange);
2525
2870
  }
2526
2871
  this.renderer = new GanttRenderer(this.container, {
2872
+ readonly: this.options.readonly,
2527
2873
  keyboardAccessible: this.options.keyboardAccessible,
2528
2874
  showAssigneeAvatars: this.options.showAssigneeAvatars,
2529
2875
  virtualScroll: this.options.virtualScroll,
2530
2876
  onRenameCommit: (taskId, name) => this.updateTask(taskId, { name }),
2531
2877
  onColumnResize: this.options.onColumnResize ? (columnId, width) => this.handleColumnResize(columnId, width) : void 0,
2532
2878
  onColumnReorder: this.options.onColumnReorder ? (order) => this.handleColumnReorder(order) : void 0,
2533
- onRowReorder: this.options.onTaskReorder ? (draggedId, targetId, position) => this.handleRowReorder(draggedId, targetId, position) : void 0
2879
+ onRowReorder: this.options.onTaskReorder ? (draggedId, targetId, position) => this.handleRowReorder(draggedId, targetId, position) : void 0,
2880
+ onSortClick: (columnId) => this.handleSortClick(columnId)
2534
2881
  });
2882
+ if (this.options.headerPosition && this.renderer?.root) {
2883
+ this.renderer.root.dataset.ganttHeaderPosition = this.options.headerPosition;
2884
+ }
2885
+ if (typeof ResizeObserver !== "undefined") {
2886
+ this.resizeObserver = new ResizeObserver(() => this.scheduleRender());
2887
+ this.resizeObserver.observe(this.renderer.timelineScroll);
2888
+ }
2535
2889
  this.interactions = new InteractionController(
2536
2890
  this.renderer.svg,
2537
2891
  this.renderer.gridPanel,
@@ -2551,7 +2905,17 @@ var _GanttChart = class _GanttChart {
2551
2905
  onCreateTaskDrag: this.options.onTaskCreate ? (rowTaskId, startXPx, endXPx) => this.handleCreateTaskDrag(rowTaskId, startXPx, endXPx) : void 0,
2552
2906
  onLinkDblClick: this.options.onDependencyDblClick ? (fromId, toId) => {
2553
2907
  const dep = this.dependencies.find((d) => d.fromId === fromId && d.toId === toId);
2554
- if (dep) this.options.onDependencyDblClick?.(dep);
2908
+ if (dep) {
2909
+ this.emitter.emit("dependency-dblclick", dep);
2910
+ this.options.onDependencyDblClick?.(dep);
2911
+ }
2912
+ } : void 0,
2913
+ onBarDblClick: this.options.onTaskDblClick ? (taskId) => {
2914
+ const task = this.tasks.find((t) => t.id === taskId);
2915
+ if (task) {
2916
+ this.emitter.emit("task-dblclick", { task });
2917
+ this.options.onTaskDblClick?.(task);
2918
+ }
2555
2919
  } : void 0,
2556
2920
  onContextMenu: this.options.onContextMenu ? (taskId, evt) => {
2557
2921
  const task = this.tasks.find((t) => t.id === taskId);
@@ -2560,7 +2924,8 @@ var _GanttChart = class _GanttChart {
2560
2924
  } : void 0,
2561
2925
  onSelectAll: this.options.selectable ? () => this.selectAllVisible() : void 0,
2562
2926
  onJumpToStart: () => this.scrollToRangeStart(),
2563
- onJumpToEnd: () => this.scrollToRangeEnd()
2927
+ onJumpToEnd: () => this.scrollToRangeEnd(),
2928
+ onAddChild: (parentId) => this.handleAddChild(parentId)
2564
2929
  },
2565
2930
  {
2566
2931
  readonly: this.options.readonly,
@@ -2570,6 +2935,17 @@ var _GanttChart = class _GanttChart {
2570
2935
  pxPerMs: () => pxPerMs(this.options.viewMode, this.columnWidth)
2571
2936
  }
2572
2937
  );
2938
+ this.tooltip = new Tooltip(
2939
+ () => ({ enabled: this.options.showTooltip, render: this.options.renderTooltip }),
2940
+ (taskId) => this.tasks.find((t) => t.id === taskId)
2941
+ );
2942
+ this.tooltip.attach(this.renderer.svg);
2943
+ if (typeof ResizeObserver !== "undefined") {
2944
+ this.resizeObserver = new ResizeObserver(() => {
2945
+ if (this.options.autoFitToViewport) this.scheduleRender();
2946
+ });
2947
+ this.resizeObserver.observe(this.container);
2948
+ }
2573
2949
  }
2574
2950
  handleMove(taskId, dxMs) {
2575
2951
  if (dxMs === 0) return;
@@ -2839,6 +3215,35 @@ var _GanttChart = class _GanttChart {
2839
3215
  };
2840
3216
  this.runCommand(cmd);
2841
3217
  }
3218
+ handleAddChild(parentId) {
3219
+ const parent = this.tasks.find((t) => t.id === parentId);
3220
+ if (!parent) return;
3221
+ const newTask = {
3222
+ id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
3223
+ name: "New task",
3224
+ start: new Date(parent.start.getTime()),
3225
+ end: new Date(parent.start.getTime() + MS_PER_DAY),
3226
+ parentId: parent.id
3227
+ };
3228
+ const cmd = {
3229
+ label: "task-create",
3230
+ do: () => {
3231
+ const index = this.tasks.findIndex((t) => t.id === parentId);
3232
+ if (index >= 0) this.tasks.splice(index + 1, 0, newTask);
3233
+ else this.tasks.push(newTask);
3234
+ this.emitter.emit("task-create", { task: newTask });
3235
+ this.options.onTaskCreate?.(newTask);
3236
+ this.emitter.emit("tasks-change", { tasks: this.getTasks() });
3237
+ this.scheduleRender();
3238
+ },
3239
+ undo: () => {
3240
+ this.tasks = this.tasks.filter((t) => t.id !== newTask.id);
3241
+ this.emitter.emit("tasks-change", { tasks: this.getTasks() });
3242
+ this.scheduleRender();
3243
+ }
3244
+ };
3245
+ this.runCommand(cmd);
3246
+ }
2842
3247
  handleBarClick(taskId, modifiers) {
2843
3248
  const task = this.tasks.find((t) => t.id === taskId);
2844
3249
  if (!task) return;
@@ -2974,6 +3379,24 @@ var _GanttChart = class _GanttChart {
2974
3379
  this.options.onColumnReorder?.(order);
2975
3380
  this.scheduleRender();
2976
3381
  }
3382
+ /** Cycles a sortable column's header through asc -> desc -> none; clicking a different column starts it fresh at asc. */
3383
+ handleSortClick(columnId) {
3384
+ if (this.sort?.columnId === columnId) {
3385
+ this.setSort(columnId, this.sort.direction === "asc" ? "desc" : null);
3386
+ } else {
3387
+ this.setSort(columnId, "asc");
3388
+ }
3389
+ }
3390
+ /** Sorts siblings at every tree level by a column's value (see GanttColumn.sortable); `direction: null`/omitted `columnId` clears it. */
3391
+ setSort(columnId, direction = "asc") {
3392
+ this.sort = columnId && direction ? { columnId, direction } : null;
3393
+ this.emitter.emit("sort-change", { sort: this.sort });
3394
+ this.options.onSortChange?.(this.sort);
3395
+ this.scheduleRender();
3396
+ }
3397
+ getSort() {
3398
+ return this.sort;
3399
+ }
2977
3400
  handleRowReorder(draggedId, targetId, position) {
2978
3401
  const draggedIndex = this.tasks.findIndex((t) => t.id === draggedId);
2979
3402
  const targetIndex = this.tasks.findIndex((t) => t.id === targetId);
@@ -3040,13 +3463,24 @@ var _GanttChart = class _GanttChart {
3040
3463
  this.scheduleRender();
3041
3464
  }
3042
3465
  setOptions(partial) {
3043
- if (partial.colorScheme !== void 0) this.colorScheme = partial.colorScheme;
3044
- if (partial.theme !== void 0) this.explicitTheme = partial.theme;
3045
- if (partial.theme !== void 0 || partial.colorScheme !== void 0) {
3466
+ const hasTheme = "theme" in partial;
3467
+ const hasColorScheme = "colorScheme" in partial;
3468
+ if (hasColorScheme) this.colorScheme = partial.colorScheme ?? "auto";
3469
+ if (hasTheme) this.explicitTheme = partial.theme;
3470
+ if (hasTheme || hasColorScheme) {
3046
3471
  this.theme = mergeTheme(this.explicitTheme, this.colorScheme);
3047
3472
  }
3048
- if (partial.columns) this.columns = partial.columns;
3473
+ if ("columns" in partial) this.columns = partial.columns ?? this.columns;
3049
3474
  if (partial.columnWidth !== void 0) this.columnWidth = partial.columnWidth;
3475
+ if (partial.gridPanelWidth !== void 0) {
3476
+ this.container.style.setProperty("--gantt-grid-panel-width", `${partial.gridPanelWidth}px`);
3477
+ }
3478
+ if ("headerPosition" in partial) {
3479
+ this.options.headerPosition = partial.headerPosition ?? "top";
3480
+ if (this.renderer?.root) {
3481
+ this.renderer.root.dataset.ganttHeaderPosition = this.options.headerPosition;
3482
+ }
3483
+ }
3050
3484
  Object.assign(this.options, partial);
3051
3485
  this.scheduleRender();
3052
3486
  }
@@ -3134,9 +3568,11 @@ var _GanttChart = class _GanttChart {
3134
3568
  }
3135
3569
  computeModel() {
3136
3570
  const pagination = this.options.pagination ? { pageSize: this.options.pagination.pageSize, page: this.currentPage } : void 0;
3571
+ const timelineViewportWidth = HAS_DOM && this.renderer ? this.renderer.timelineScroll.clientWidth : void 0;
3137
3572
  return computeLayout({
3138
3573
  tasks: this.tasks,
3139
3574
  dependencies: this.dependencies,
3575
+ timelineViewportWidth,
3140
3576
  viewMode: this.options.viewMode,
3141
3577
  columnWidth: this.columnWidth,
3142
3578
  theme: this.theme,
@@ -3149,7 +3585,8 @@ var _GanttChart = class _GanttChart {
3149
3585
  markers: this.options.markers,
3150
3586
  autoRollupProgress: this.options.autoRollupProgress,
3151
3587
  selectedTaskIds: this.options.selectable ? this.selectedTaskIds : void 0,
3152
- pagination
3588
+ pagination,
3589
+ sort: this.sort
3153
3590
  });
3154
3591
  }
3155
3592
  scheduleRender(immediate = false) {
@@ -3164,6 +3601,7 @@ var _GanttChart = class _GanttChart {
3164
3601
  });
3165
3602
  }
3166
3603
  doRender() {
3604
+ if (this.options.autoFitToViewport) this.applyAutoFit();
3167
3605
  this.renderModel = this.computeModel();
3168
3606
  this.renderer?.render(this.renderModel, this.tasks, this.columns, this.explicitTheme);
3169
3607
  if (this.renderer && this.colorScheme !== "auto") {
@@ -3172,6 +3610,29 @@ var _GanttChart = class _GanttChart {
3172
3610
  this.renderer?.root.removeAttribute("data-gantt-theme");
3173
3611
  }
3174
3612
  }
3613
+ /**
3614
+ * Stretch-only fit: bump columnWidth up so the timeline fills the container's available
3615
+ * width, but never shrink it below what the current zoom level already implies - a project
3616
+ * wider than the container should still scroll normally, not get crammed to fit.
3617
+ */
3618
+ applyAutoFit() {
3619
+ const availableWidth = this.container.clientWidth - this.options.gridPanelWidth;
3620
+ if (!Number.isFinite(availableWidth) || availableWidth <= 0) return;
3621
+ let min = null;
3622
+ let max = null;
3623
+ for (const t of this.tasks) {
3624
+ if (!isValidDate(t.start) || !isValidDate(t.end)) continue;
3625
+ min = min === null ? t.start.getTime() : Math.min(min, t.start.getTime());
3626
+ max = max === null ? t.end.getTime() : Math.max(max, t.end.getTime());
3627
+ }
3628
+ if (min === null || max === null || max <= min) return;
3629
+ const rangeMs = max - min;
3630
+ const unitMs = approxUnitMs(this.options.viewMode);
3631
+ const neededColumnWidth = availableWidth * unitMs / rangeMs;
3632
+ if (neededColumnWidth > this.columnWidth) {
3633
+ this.columnWidth = Math.min(neededColumnWidth, _GanttChart.MAX_COLUMN_WIDTH);
3634
+ }
3635
+ }
3175
3636
  undo() {
3176
3637
  this.history?.undo();
3177
3638
  }
@@ -3194,7 +3655,35 @@ var _GanttChart = class _GanttChart {
3194
3655
  return this.renderer?.toSVGString() ?? "";
3195
3656
  }
3196
3657
  async rasterizeSVG(svgString, width, height) {
3197
- const svgBlob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
3658
+ let cleanSvgString = svgString;
3659
+ if (typeof DOMParser !== "undefined") {
3660
+ const parser = new DOMParser();
3661
+ const doc = parser.parseFromString(svgString, "image/svg+xml");
3662
+ const foreignObjects = Array.from(doc.querySelectorAll("foreignObject"));
3663
+ for (const fo of foreignObjects) {
3664
+ const x = fo.getAttribute("x") || "0";
3665
+ const y = fo.getAttribute("y") || "0";
3666
+ const w = parseFloat(fo.getAttribute("width") || "0");
3667
+ const h = parseFloat(fo.getAttribute("height") || "0");
3668
+ const div = fo.querySelector("div");
3669
+ if (div) {
3670
+ const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
3671
+ text.setAttribute("x", String(parseFloat(x) + w / 2));
3672
+ text.setAttribute("y", String(parseFloat(y) + h / 2 + 4));
3673
+ text.setAttribute("text-anchor", "middle");
3674
+ text.setAttribute("fill", div.style.color || "#000");
3675
+ text.setAttribute("font-size", div.style.fontSize || "12px");
3676
+ text.setAttribute("font-family", div.style.fontFamily || "sans-serif");
3677
+ text.setAttribute("font-weight", div.style.fontWeight || "normal");
3678
+ text.textContent = div.textContent;
3679
+ fo.parentNode?.replaceChild(text, fo);
3680
+ } else {
3681
+ fo.parentNode?.removeChild(fo);
3682
+ }
3683
+ }
3684
+ cleanSvgString = new XMLSerializer().serializeToString(doc);
3685
+ }
3686
+ const svgBlob = new Blob([cleanSvgString], { type: "image/svg+xml;charset=utf-8" });
3198
3687
  const url = URL.createObjectURL(svgBlob);
3199
3688
  try {
3200
3689
  return await new Promise((resolve, reject) => {
@@ -3266,6 +3755,8 @@ var _GanttChart = class _GanttChart {
3266
3755
  this.darkMediaQuery?.removeEventListener?.("change", this.handleSchemeChange);
3267
3756
  this.emitter.removeAllListeners();
3268
3757
  this.interactions?.destroy();
3758
+ this.tooltip?.destroy();
3759
+ this.resizeObserver?.disconnect();
3269
3760
  this.renderer?.destroy();
3270
3761
  }
3271
3762
  };